use std::fmt; use std::fs; use std::path::{Path, PathBuf}; use crate::generated_scene::analyzer::{ analyze_scene_source_with_hint, AnalyzeSceneError, SceneKind, SceneSourceAnalysis, }; use crate::generated_scene::lessons::load_generation_lessons; #[derive(Debug, Clone)] pub struct GenerateSceneRequest { pub source_dir: PathBuf, pub scene_id: String, pub scene_name: String, pub scene_kind: Option, pub output_root: PathBuf, pub lessons_path: PathBuf, } #[derive(Debug)] pub struct GenerateSceneError { message: String, } impl GenerateSceneError { fn new(message: impl Into) -> Self { Self { message: message.into(), } } } impl fmt::Display for GenerateSceneError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.message) } } impl std::error::Error for GenerateSceneError {} impl From for GenerateSceneError { fn from(value: AnalyzeSceneError) -> Self { Self::new(value.to_string()) } } pub fn generate_scene_package( request: GenerateSceneRequest, ) -> Result { let analysis = analyze_scene_source_with_hint(&request.source_dir, request.scene_kind.clone())?; let lessons = load_generation_lessons(&request.lessons_path).map_err(GenerateSceneError::new)?; if !lessons.routing.require_exact_suffix || !lessons.bootstrap.require_expected_domain || !lessons.bootstrap.require_target_url || !lessons.artifact.require_report_artifact { return Err(GenerateSceneError::new( "generation lessons do not allow a registration-ready report scene", )); } let tool_name = format!("collect_{}", sanitize_ident(&request.scene_id)); let skill_root = request.output_root.join("skills").join(&request.scene_id); let scripts_dir = skill_root.join("scripts"); let references_dir = skill_root.join("references"); fs::create_dir_all(&scripts_dir).map_err(|err| io_error("create", &scripts_dir, err))?; fs::create_dir_all(&references_dir).map_err(|err| io_error("create", &references_dir, err))?; write_file( &skill_root.join("SKILL.toml"), &skill_toml(&request, &analysis, &tool_name), )?; write_file( &skill_root.join("SKILL.md"), &skill_md(&request, &analysis, &tool_name), )?; write_file( &skill_root.join("scene.toml"), &scene_toml(&request, &analysis, &tool_name), )?; write_file( &references_dir.join("org-dictionary.json"), default_org_dictionary(), )?; write_file( &scripts_dir.join(format!("{tool_name}.js")), &browser_script(&request.scene_id, &analysis), )?; write_file( &scripts_dir.join(format!("{tool_name}.test.js")), &browser_script_test(&tool_name, &analysis), )?; write_file( &references_dir.join("generation-lessons.md"), &format!( "# Generation Lessons\n\nGenerated from `{}` with lessons `{}`.\n\nThis package is limited to report/collection browser_script scenes.\n", request.source_dir.display(), request.lessons_path.display() ), )?; if let Some(entry_script) = analysis.collection_entry_script.as_deref() { let source_script = request.source_dir.join(entry_script); if source_script.exists() { let copied = fs::read_to_string(&source_script) .map_err(|err| io_error("read", &source_script, err))?; write_file(&references_dir.join("source-entry-script.js"), &copied)?; } } Ok(skill_root) } fn write_file(path: &Path, content: &str) -> Result<(), GenerateSceneError> { fs::write(path, content).map_err(|err| io_error("write", path, err)) } fn io_error(action: &str, path: &Path, err: std::io::Error) -> GenerateSceneError { GenerateSceneError::new(format!("failed to {action} {}: {err}", path.display())) } fn skill_toml(request: &GenerateSceneRequest, analysis: &SceneSourceAnalysis, tool_name: &str) -> String { let category = analysis.scene_kind.as_str(); format!( "[skill]\nname = \"{}\"\ndescription = \"Generated {} skill: {}\"\nversion = \"0.1.0\"\n\n[[tools]]\nname = \"{}\"\ndescription = \"Collect {} scene artifact\"\nkind = \"browser_script\"\ncommand = \"scripts/{}.js\"\n", request.scene_id, category, request.scene_name, tool_name, request.scene_name, tool_name ) } fn skill_md(request: &GenerateSceneRequest, analysis: &SceneSourceAnalysis, tool_name: &str) -> String { let category = analysis.scene_kind.as_str(); format!( "# {}\n\nGenerated v1 {} browser_script scene skill.\n\n- Scene id: `{}`\n- Tool: `{}`\n- Runtime contract: manifest-driven deterministic routing, canonical args, generic `report-artifact` output.\n", request.scene_name, category, request.scene_id, tool_name ) } fn scene_toml( request: &GenerateSceneRequest, analysis: &SceneSourceAnalysis, tool_name: &str, ) -> String { match analysis.scene_kind { SceneKind::ReportCollection => scene_toml_report_collection(request, analysis, tool_name), SceneKind::Monitoring => scene_toml_monitoring(request, analysis, tool_name), } } fn scene_toml_report_collection( request: &GenerateSceneRequest, analysis: &SceneSourceAnalysis, tool_name: &str, ) -> String { let expected_domain = analysis.bootstrap.expected_domain.as_deref().unwrap_or_default(); let target_url = analysis.bootstrap.target_url.as_deref().unwrap_or_default(); format!( "[scene]\nid = \"{}\"\nskill = \"{}\"\ntool = \"{}\"\nkind = \"browser_script\"\nversion = \"0.1.0\"\ncategory = \"report_collection\"\n\n[manifest]\nschema_version = \"1\"\n\n[bootstrap]\nexpected_domain = \"{}\"\ntarget_url = \"{}\"\npage_title_keywords = [\"报表\", \"线损\"]\nrequires_target_page = true\n\n[deterministic]\nsuffix = \"。。。\"\ninclude_keywords = [\"{}\", \"报表\", \"统计\"]\nexclude_keywords = [\"知乎\"]\n\n[[params]]\nname = \"org\"\nresolver = \"dictionary_entity\"\nrequired = true\nprompt_missing = \"已命中{},但缺少供电单位。\"\nprompt_ambiguous = \"已命中{},但供电单位存在歧义。\"\n\n[params.resolver_config]\ndictionary_ref = \"references/org-dictionary.json\"\noutput_label_field = \"org_label\"\noutput_code_field = \"org_code\"\n\n[[params]]\nname = \"period\"\nresolver = \"month_week_period\"\nrequired = true\nprompt_missing = \"已命中{},但缺少统计周期。\"\nprompt_ambiguous = \"已命中{},但统计周期存在歧义。\"\n\n[artifact]\ntype = \"report-artifact\"\nsuccess_status = [\"ok\", \"partial\", \"empty\"]\nfailure_status = [\"blocked\", \"error\"]\n\n[postprocess]\nexporter = \"xlsx_report\"\nauto_open = \"excel\"\n", request.scene_id, request.scene_id, tool_name, expected_domain, target_url, request.scene_name, request.scene_name, request.scene_name, request.scene_name, request.scene_name ) } fn scene_toml_monitoring( request: &GenerateSceneRequest, analysis: &SceneSourceAnalysis, tool_name: &str, ) -> String { let expected_domain = analysis.bootstrap.expected_domain.as_deref().unwrap_or_default(); let target_url = analysis.bootstrap.target_url.as_deref().unwrap_or_default(); format!( "[scene]\nid = \"{}\"\nskill = \"{}\"\ntool = \"{}\"\nkind = \"browser_script\"\nversion = \"0.1.0\"\ncategory = \"monitoring\"\n\n[manifest]\nschema_version = \"1\"\n\n[bootstrap]\nexpected_domain = \"{}\"\ntarget_url = \"{}\"\npage_title_keywords = [\"监测\", \"状态\"]\nrequires_target_page = true\n\n[deterministic]\nsuffix = \"。。。\"\ninclude_keywords = [\"{}\", \"监测\", \"状态\"]\nexclude_keywords = [\"知乎\"]\n\n# 监测类场景参数留空,由用户手动编辑\n\n[artifact]\ntype = \"report-artifact\"\nsuccess_status = [\"ok\", \"partial\", \"empty\"]\nfailure_status = [\"blocked\", \"error\"]\n\n[postprocess]\nexporter = \"xlsx_report\"\nauto_open = \"excel\"\n", request.scene_id, request.scene_id, tool_name, expected_domain, target_url, request.scene_name ) } fn default_org_dictionary() -> &'static str { r#"[ { "label": "国网兰州供电公司", "code": "62401" } ] "# } fn browser_script(scene_id: &str, _analysis: &SceneSourceAnalysis) -> String { 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", scene_id ) } fn browser_script_test(tool_name: &str, _analysis: &SceneSourceAnalysis) -> String { 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", tool_name ) } fn sanitize_ident(value: &str) -> String { value .chars() .map(|ch| { if ch.is_ascii_alphanumeric() { ch.to_ascii_lowercase() } else { '_' } }) .collect::() .split('_') .filter(|part| !part.is_empty()) .collect::>() .join("_") }