feat(rust): add --scene-info-json parameter for LLM extraction results

- Add SceneInfoJson struct with serde rename for camelCase compatibility
- Add ApiEndpointJson and BusinessLogicJson support structs
- Add scene_info_json field to GenerateSceneRequest (backward compatible)
- Rename browser_script to browser_script_skeleton
- Add new browser_script that delegates based on scene_info presence
- Add browser_script_with_business_logic for enhanced script generation
- Update CLI to accept --scene-info-json parameter
- Update usage string to document new parameter

Generated with [Qoder][https://qoder.com]
This commit is contained in:
木炎
2026-04-17 10:27:27 +08:00
parent 2ffb42c181
commit 689abf08ec
2 changed files with 230 additions and 12 deletions

View File

@@ -2,7 +2,7 @@ use std::env;
use std::path::PathBuf; use std::path::PathBuf;
use sgclaw::generated_scene::analyzer::SceneKind; use sgclaw::generated_scene::analyzer::SceneKind;
use sgclaw::generated_scene::generator::{generate_scene_package, GenerateSceneRequest}; use sgclaw::generated_scene::generator::{generate_scene_package, GenerateSceneRequest, SceneInfoJson};
fn main() { fn main() {
if let Err(err) = run() { if let Err(err) = run() {
@@ -13,6 +13,10 @@ fn main() {
fn run() -> Result<(), String> { fn run() -> Result<(), String> {
let args = parse_args(env::args().skip(1))?; let args = parse_args(env::args().skip(1))?;
let scene_info: Option<SceneInfoJson> = args.scene_info_json
.map(|json| serde_json::from_str(&json))
.transpose()
.map_err(|e| format!("Invalid scene-info-json: {}", e))?;
let skill_root = generate_scene_package(GenerateSceneRequest { let skill_root = generate_scene_package(GenerateSceneRequest {
source_dir: args.source_dir, source_dir: args.source_dir,
scene_id: args.scene_id, scene_id: args.scene_id,
@@ -21,6 +25,7 @@ fn run() -> Result<(), String> {
target_url: args.target_url, target_url: args.target_url,
output_root: args.output_root, output_root: args.output_root,
lessons_path: args.lessons_path, lessons_path: args.lessons_path,
scene_info_json: scene_info,
}) })
.map_err(|err| err.to_string())?; .map_err(|err| err.to_string())?;
@@ -35,7 +40,8 @@ struct CliArgs {
scene_kind: Option<SceneKind>, scene_kind: Option<SceneKind>,
target_url: Option<String>, target_url: Option<String>,
output_root: PathBuf, output_root: PathBuf,
lessons_path: PathBuf, lessons_path: Option<PathBuf>,
scene_info_json: Option<String>,
} }
fn parse_args(args: impl Iterator<Item = String>) -> Result<CliArgs, String> { fn parse_args(args: impl Iterator<Item = String>) -> Result<CliArgs, String> {
@@ -46,6 +52,7 @@ fn parse_args(args: impl Iterator<Item = String>) -> Result<CliArgs, String> {
let mut target_url = None; let mut target_url = None;
let mut output_root = None; let mut output_root = None;
let mut lessons_path = None; let mut lessons_path = None;
let mut scene_info_json = None;
let mut pending_flag: Option<String> = None; let mut pending_flag: Option<String> = None;
for arg in args { for arg in args {
@@ -63,6 +70,7 @@ fn parse_args(args: impl Iterator<Item = String>) -> Result<CliArgs, String> {
"--target-url" => target_url = Some(arg), "--target-url" => target_url = Some(arg),
"--output-root" => output_root = Some(PathBuf::from(arg)), "--output-root" => output_root = Some(PathBuf::from(arg)),
"--lessons" => lessons_path = Some(PathBuf::from(arg)), "--lessons" => lessons_path = Some(PathBuf::from(arg)),
"--scene-info-json" => scene_info_json = Some(arg),
_ => return Err(format!("unsupported argument {flag}")), _ => return Err(format!("unsupported argument {flag}")),
} }
continue; continue;
@@ -70,7 +78,7 @@ fn parse_args(args: impl Iterator<Item = String>) -> Result<CliArgs, String> {
match arg.as_str() { match arg.as_str() {
"--source-dir" | "--scene-id" | "--scene-name" | "--scene-kind" | "--target-url" "--source-dir" | "--scene-id" | "--scene-name" | "--scene-kind" | "--target-url"
| "--output-root" | "--lessons" => { | "--output-root" | "--lessons" | "--scene-info-json" => {
pending_flag = Some(arg); pending_flag = Some(arg);
} }
"--help" | "-h" => return Err(usage()), "--help" | "-h" => return Err(usage()),
@@ -89,10 +97,11 @@ fn parse_args(args: impl Iterator<Item = String>) -> Result<CliArgs, String> {
scene_kind, scene_kind,
target_url, target_url,
output_root: output_root.ok_or_else(usage)?, output_root: output_root.ok_or_else(usage)?,
lessons_path: lessons_path.ok_or_else(usage)?, lessons_path,
scene_info_json,
}) })
} }
fn usage() -> String { fn usage() -> String {
"usage: sg_scene_generate --source-dir <scenario-dir> --scene-id <scene-id> --scene-name <display-name> [--scene-kind <report_collection|monitoring>] [--target-url <url>] --output-root <skill-staging-root> --lessons <lessons-toml>".to_string() "usage: sg_scene_generate --source-dir <scenario-dir> --scene-id <scene-id> --scene-name <display-name> [--scene-kind <report_collection|monitoring>] [--target-url <url>] --output-root <skill-staging-root> [--lessons <lessons-toml>] [--scene-info-json '<json>']".to_string()
} }

View File

@@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::fmt; use std::fmt;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -5,7 +6,53 @@ use std::path::{Path, PathBuf};
use crate::generated_scene::analyzer::{ use crate::generated_scene::analyzer::{
analyze_scene_source_with_hint, AnalyzeSceneError, SceneKind, SceneSourceAnalysis, analyze_scene_source_with_hint, AnalyzeSceneError, SceneKind, SceneSourceAnalysis,
}; };
use crate::generated_scene::lessons::load_generation_lessons; use crate::generated_scene::lessons::{
load_generation_lessons, GenerationLessons, BUILTIN_REPORT_COLLECTION_LESSONS,
};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ApiEndpointJson {
pub name: String,
pub url: String,
#[serde(default)]
pub method: String,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct BusinessLogicJson {
#[serde(default)]
pub data_fetch: Option<String>,
#[serde(default)]
pub data_transform: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SceneInfoJson {
#[serde(rename = "sceneId")]
pub scene_id: String,
#[serde(rename = "sceneName")]
pub scene_name: String,
#[serde(rename = "sceneKind", default)]
pub scene_kind: String,
#[serde(rename = "sourceSystem", default)]
pub source_system: Option<String>,
#[serde(rename = "expectedDomain", default)]
pub expected_domain: Option<String>,
#[serde(rename = "targetUrl", default)]
pub target_url: Option<String>,
#[serde(rename = "apiEndpoints", default)]
pub api_endpoints: Vec<ApiEndpointJson>,
#[serde(rename = "staticParams", default)]
pub static_params: HashMap<String, String>,
#[serde(rename = "columnDefs", default)]
pub column_defs: Vec<(String, String)>,
#[serde(rename = "entryMethod", default)]
pub entry_method: Option<String>,
#[serde(rename = "businessLogic", default)]
pub business_logic: Option<BusinessLogicJson>,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct GenerateSceneRequest { pub struct GenerateSceneRequest {
@@ -15,7 +62,8 @@ pub struct GenerateSceneRequest {
pub scene_kind: Option<SceneKind>, pub scene_kind: Option<SceneKind>,
pub target_url: Option<String>, pub target_url: Option<String>,
pub output_root: PathBuf, pub output_root: PathBuf,
pub lessons_path: PathBuf, pub lessons_path: Option<PathBuf>,
pub scene_info_json: Option<SceneInfoJson>,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -49,8 +97,16 @@ pub fn generate_scene_package(
request: GenerateSceneRequest, request: GenerateSceneRequest,
) -> Result<PathBuf, GenerateSceneError> { ) -> Result<PathBuf, GenerateSceneError> {
let analysis = analyze_scene_source_with_hint(&request.source_dir, request.scene_kind.clone())?; let analysis = analyze_scene_source_with_hint(&request.source_dir, request.scene_kind.clone())?;
let lessons = let (lessons, lessons_source): (GenerationLessons, String) = match &request.lessons_path {
load_generation_lessons(&request.lessons_path).map_err(GenerateSceneError::new)?; Some(path) => (
load_generation_lessons(path).map_err(GenerateSceneError::new)?,
path.display().to_string(),
),
None => (
GenerationLessons::default_report_collection(),
BUILTIN_REPORT_COLLECTION_LESSONS.to_string(),
),
};
if !lessons.routing.require_exact_suffix if !lessons.routing.require_exact_suffix
|| !lessons.bootstrap.require_expected_domain || !lessons.bootstrap.require_expected_domain
|| !lessons.bootstrap.require_target_url || !lessons.bootstrap.require_target_url
@@ -86,7 +142,7 @@ pub fn generate_scene_package(
)?; )?;
write_file( write_file(
&scripts_dir.join(format!("{tool_name}.js")), &scripts_dir.join(format!("{tool_name}.js")),
&browser_script(&request.scene_id, &analysis), &browser_script(&request.scene_id, &analysis, request.scene_info_json.as_ref()),
)?; )?;
write_file( write_file(
&scripts_dir.join(format!("{tool_name}.test.js")), &scripts_dir.join(format!("{tool_name}.test.js")),
@@ -97,7 +153,7 @@ pub fn generate_scene_package(
&format!( &format!(
"# Generation Lessons\n\nGenerated from `{}` with lessons `{}`.\n\nThis package is limited to report/collection browser_script scenes.\n", "# Generation Lessons\n\nGenerated from `{}` with lessons `{}`.\n\nThis package is limited to report/collection browser_script scenes.\n",
request.source_dir.display(), request.source_dir.display(),
request.lessons_path.display() lessons_source
), ),
)?; )?;
@@ -204,13 +260,166 @@ fn default_org_dictionary() -> &'static str {
"# "#
} }
fn browser_script(scene_id: &str, _analysis: &SceneSourceAnalysis) -> String { fn browser_script_skeleton(scene_id: &str, _analysis: &SceneSourceAnalysis) -> String {
format!( format!(
"function normalizePayload(payload) {{\n if (typeof payload === 'string') {{\n try {{ return JSON.parse(payload); }} catch (_) {{ return {{}}; }}\n }}\n return payload && typeof payload === 'object' ? payload : {{}};\n}}\n\nasync function buildBrowserEntrypointResult(args, deps = {{}}) {{\n const rows = typeof deps.collectRows === 'function'\n ? await deps.collectRows(args)\n : [{{\n org_label: args.org_label || '',\n org_code: args.org_code || '',\n period_mode: args.period_mode || '',\n period_value: args.period_value || '',\n value: ''\n }}];\n return {{\n type: 'report-artifact',\n report_name: '{}',\n status: rows.length > 0 ? 'ok' : 'empty',\n period: {{\n mode: args.period_mode,\n mode_code: args.period_mode_code,\n value: args.period_value,\n payload: normalizePayload(args.period_payload)\n }},\n org: {{ label: args.org_label, code: args.org_code }},\n column_defs: [\n ['org_label', '供电单位'],\n ['org_code', '供电单位编码'],\n ['period_mode', '统计周期类型'],\n ['period_value', '统计周期'],\n ['value', '采集值']\n ],\n columns: ['org_label', 'org_code', 'period_mode', 'period_value', 'value'],\n rows,\n counts: {{ detail_rows: rows.length }},\n partial_reasons: [],\n reasons: []\n }};\n}}\n\nif (typeof module !== 'undefined') {{\n module.exports = {{ buildBrowserEntrypointResult, normalizePayload }};\n}}\n\nif (typeof args !== 'undefined') {{\n return buildBrowserEntrypointResult(args);\n}}\n", "function normalizePayload(payload) {{\n if (typeof payload === 'string') {{\n try {{ return JSON.parse(payload); }} catch (_) {{ return {{}}; }}\n }}\n return payload && typeof payload === 'object' ? payload : {{}};\n}}\n\nasync function buildBrowserEntrypointResult(args, deps = {{}}) {{\n const rows = typeof deps.collectRows === 'function'\n ? await deps.collectRows(args)\n : [{{\n org_label: args.org_label || '',\n org_code: args.org_code || '',\n period_mode: args.period_mode || '',\n period_value: args.period_value || '',\n value: ''\n }}];\n return {{\n type: 'report-artifact',\n report_name: '{}',\n status: rows.length > 0 ? 'ok' : 'empty',\n period: {{\n mode: args.period_mode,\n mode_code: args.period_mode_code,\n value: args.period_value,\n payload: normalizePayload(args.period_payload)\n }},\n org: {{ label: args.org_label, code: args.org_code }},\n column_defs: [\n ['org_label', '供电单位'],\n ['org_code', '供电单位编码'],\n ['period_mode', '统计周期类型'],\n ['period_value', '统计周期'],\n ['value', '采集值']\n ],\n columns: ['org_label', 'org_code', 'period_mode', 'period_value', 'value'],\n rows,\n counts: {{ detail_rows: rows.length }},\n partial_reasons: [],\n reasons: []\n }};\n}}\n\nif (typeof module !== 'undefined') {{\n module.exports = {{ buildBrowserEntrypointResult, normalizePayload }};\n}}\n\nif (typeof args !== 'undefined') {{\n return buildBrowserEntrypointResult(args);\n}}\n",
scene_id scene_id
) )
} }
fn browser_script(scene_id: &str, analysis: &SceneSourceAnalysis, scene_info: Option<&SceneInfoJson>) -> String {
match scene_info {
Some(info) if !info.api_endpoints.is_empty() || !info.column_defs.is_empty() => {
browser_script_with_business_logic(scene_id, analysis, info)
}
_ => browser_script_skeleton(scene_id, analysis),
}
}
fn browser_script_with_business_logic(scene_id: &str, _analysis: &SceneSourceAnalysis, scene_info: &SceneInfoJson) -> String {
let api_endpoints_json = serde_json::to_string_pretty(&scene_info.api_endpoints).unwrap_or_else(|_| "[]".to_string());
let static_params_json = serde_json::to_string_pretty(&scene_info.static_params).unwrap_or_else(|_| "{}".to_string());
let column_defs_json = serde_json::to_string_pretty(&scene_info.column_defs).unwrap_or_else(|_| "[]".to_string());
let column_names: Vec<&str> = scene_info.column_defs.iter().map(|(name, _)| name.as_str()).collect();
let columns_json = serde_json::to_string(&column_names).unwrap_or_else(|_| "[]".to_string());
format!(
r#"const API_ENDPOINTS = {api_endpoints_json};
const STATIC_PARAMS = {static_params_json};
const COLUMN_DEFS = {column_defs_json};
function normalizePayload(payload) {{
if (typeof payload === 'string') {{
try {{ return JSON.parse(payload); }} catch (_) {{ return {{}}; }}
}}
return payload && typeof payload === 'object' ? payload : {{}};
}}
function validateArgs(args) {{
const errors = [];
if (!args.org_code) errors.push('Missing org_code');
if (!args.period_value) errors.push('Missing period_value');
return {{ valid: errors.length === 0, errors }};
}}
function buildRequest(args, endpoint) {{
const url = new URL(endpoint.url, window.location.origin);
const params = {{ ...STATIC_PARAMS, ...args }};
for (const [key, value] of Object.entries(params)) {{
if (value !== undefined && value !== null) {{
url.searchParams.set(key, String(value));
}}
}}
return {{
url: url.toString(),
method: endpoint.method || 'GET',
headers: {{ 'Content-Type': 'application/json' }}
}};
}}
function normalizeRows(rawData) {{
if (!Array.isArray(rawData)) return [];
return rawData.map((row, index) => {{
const result = {{ _index: index }};
for (const [key, label] of COLUMN_DEFS) {{
result[key] = row[key] ?? '';
}}
return result;
}});
}}
function buildArtifact(args, rows) {{
return {{
type: 'report-artifact',
report_name: '{scene_id}',
status: rows.length > 0 ? 'ok' : 'empty',
period: {{
mode: args.period_mode,
mode_code: args.period_mode_code,
value: args.period_value,
payload: normalizePayload(args.period_payload)
}},
org: {{ label: args.org_label, code: args.org_code }},
column_defs: COLUMN_DEFS,
columns: {columns_json},
rows,
counts: {{ detail_rows: rows.length }},
partial_reasons: [],
reasons: []
}};
}}
const defaultDeps = {{
validatePageContext: async () => true,
queryData: async (args) => {{
const endpoint = API_ENDPOINTS[0];
if (!endpoint) throw new Error('No API endpoint configured');
const request = buildRequest(args, endpoint);
const response = await fetch(request.url, {{
method: request.method,
headers: request.headers
}});
if (!response.ok) throw new Error(`HTTP ${{response.status}}: ${{response.statusText}}`);
return response.json();
}}
}};
async function buildBrowserEntrypointResult(args, deps = defaultDeps) {{
const validation = validateArgs(args);
if (!validation.valid) {{
return {{
type: 'report-artifact',
report_name: '{scene_id}',
status: 'error',
error: 'Validation failed: ' + validation.errors.join(', '),
column_defs: COLUMN_DEFS,
columns: {columns_json},
rows: [],
counts: {{ detail_rows: 0 }},
partial_reasons: [],
reasons: validation.errors
}};
}}
try {{
const rawData = await (deps.queryData ? deps.queryData(args) : Promise.resolve([]));
const rows = normalizeRows(rawData);
return buildArtifact(args, rows);
}} catch (error) {{
return {{
type: 'report-artifact',
report_name: '{scene_id}',
status: 'error',
error: error.message,
column_defs: COLUMN_DEFS,
columns: {columns_json},
rows: [],
counts: {{ detail_rows: 0 }},
partial_reasons: [],
reasons: [error.message]
}};
}}
}}
if (typeof module !== 'undefined') {{
module.exports = {{ buildBrowserEntrypointResult, normalizePayload, validateArgs, buildRequest, normalizeRows, buildArtifact, API_ENDPOINTS, STATIC_PARAMS, COLUMN_DEFS }};
}}
if (typeof args !== 'undefined') {{
return buildBrowserEntrypointResult(args);
}}
"#,
scene_id = scene_id,
api_endpoints_json = api_endpoints_json,
static_params_json = static_params_json,
column_defs_json = column_defs_json,
columns_json = columns_json
)
}
fn browser_script_test(tool_name: &str, _analysis: &SceneSourceAnalysis) -> String { fn browser_script_test(tool_name: &str, _analysis: &SceneSourceAnalysis) -> String {
format!( format!(
"const assert = require('assert');\nconst {{ buildBrowserEntrypointResult }} = require('./{}.js');\n\n(async () => {{\n const artifact = await buildBrowserEntrypointResult({{\n org_label: '国网兰州供电公司',\n org_code: '62401',\n period_mode: 'month',\n period_mode_code: '1',\n period_value: '2026-03',\n period_payload: '{{\"fdate\":\"2026-03\"}}'\n }});\n assert.equal(artifact.type, 'report-artifact');\n assert.ok(Array.isArray(artifact.column_defs));\n assert.equal(artifact.rows.length, 1);\n}})().catch((err) => {{\n console.error(err);\n process.exit(1);\n}});\n", "const assert = require('assert');\nconst {{ buildBrowserEntrypointResult }} = require('./{}.js');\n\n(async () => {{\n const artifact = await buildBrowserEntrypointResult({{\n org_label: '国网兰州供电公司',\n org_code: '62401',\n period_mode: 'month',\n period_mode_code: '1',\n period_value: '2026-03',\n period_payload: '{{\"fdate\":\"2026-03\"}}'\n }});\n assert.equal(artifact.type, 'report-artifact');\n assert.ok(Array.isArray(artifact.column_defs));\n assert.equal(artifact.rows.length, 1);\n}})().catch((err) => {{\n console.error(err);\n process.exit(1);\n}});\n",