From 73edf1e5cffebac12720f7876f1fc0227a956ba9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=A8=E7=82=8E?= <635735027@qq.com> Date: Thu, 16 Apr 2026 23:51:11 +0800 Subject: [PATCH] feat: add monitoring template support to generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add scene_kind field to GenerateSceneRequest - Update generate_scene_package to use analyze_scene_source_with_hint - Implement scene_toml_monitoring for simplified monitoring scene manifests - Add scene_toml router to dispatch to different templates based on SceneKind - Add --scene-kind CLI option to sg_scene_generate binary - Add generator_emits_monitoring_template test - Create monitoring fixture with index.html (no meta tag, tests hint param) Monitoring templates have simplified scene.toml without org/period resolvers. 🤖 Generated with [Qoder][https://qoder.com] --- src/bin/sg_scene_generate.rs | 93 +++++++ src/generated_scene/generator.rs | 229 ++++++++++++++++++ .../generated_scene/monitoring/index.html | 14 ++ tests/scene_generator_test.rs | 25 ++ 4 files changed, 361 insertions(+) create mode 100644 src/bin/sg_scene_generate.rs create mode 100644 src/generated_scene/generator.rs create mode 100644 tests/fixtures/generated_scene/monitoring/index.html diff --git a/src/bin/sg_scene_generate.rs b/src/bin/sg_scene_generate.rs new file mode 100644 index 0000000..794c2c2 --- /dev/null +++ b/src/bin/sg_scene_generate.rs @@ -0,0 +1,93 @@ +use std::env; +use std::path::PathBuf; + +use sgclaw::generated_scene::analyzer::SceneKind; +use sgclaw::generated_scene::generator::{generate_scene_package, GenerateSceneRequest}; + +fn main() { + if let Err(err) = run() { + eprintln!("sg_scene_generate: {err}"); + std::process::exit(1); + } +} + +fn run() -> Result<(), String> { + let args = parse_args(env::args().skip(1))?; + let skill_root = generate_scene_package(GenerateSceneRequest { + source_dir: args.source_dir, + scene_id: args.scene_id, + scene_name: args.scene_name, + scene_kind: args.scene_kind, + output_root: args.output_root, + lessons_path: args.lessons_path, + }) + .map_err(|err| err.to_string())?; + + println!("generated scene package: {}", skill_root.display()); + Ok(()) +} + +struct CliArgs { + source_dir: PathBuf, + scene_id: String, + scene_name: String, + scene_kind: Option, + output_root: PathBuf, + lessons_path: PathBuf, +} + +fn parse_args(args: impl Iterator) -> Result { + let mut source_dir = None; + let mut scene_id = None; + let mut scene_name = None; + let mut scene_kind = None; + let mut output_root = None; + let mut lessons_path = None; + let mut pending_flag: Option = None; + + for arg in args { + if let Some(flag) = pending_flag.take() { + match flag.as_str() { + "--source-dir" => source_dir = Some(PathBuf::from(arg)), + "--scene-id" => scene_id = Some(arg), + "--scene-name" => scene_name = Some(arg), + "--scene-kind" => { + scene_kind = Some( + SceneKind::from_str(&arg) + .ok_or_else(|| format!("invalid scene kind: {}", arg))?, + ); + } + "--output-root" => output_root = Some(PathBuf::from(arg)), + "--lessons" => lessons_path = Some(PathBuf::from(arg)), + _ => return Err(format!("unsupported argument {flag}")), + } + continue; + } + + match arg.as_str() { + "--source-dir" | "--scene-id" | "--scene-name" | "--scene-kind" | "--output-root" + | "--lessons" => { + pending_flag = Some(arg); + } + "--help" | "-h" => return Err(usage()), + _ => return Err(format!("unsupported argument {arg}\n{}", usage())), + } + } + + if let Some(flag) = pending_flag { + return Err(format!("missing value for {flag}")); + } + + Ok(CliArgs { + source_dir: source_dir.ok_or_else(usage)?, + scene_id: scene_id.ok_or_else(usage)?, + scene_name: scene_name.ok_or_else(usage)?, + scene_kind, + output_root: output_root.ok_or_else(usage)?, + lessons_path: lessons_path.ok_or_else(usage)?, + }) +} + +fn usage() -> String { + "usage: sg_scene_generate --source-dir --scene-id --scene-name [--scene-kind ] --output-root --lessons ".to_string() +} diff --git a/src/generated_scene/generator.rs b/src/generated_scene/generator.rs new file mode 100644 index 0000000..a1ac439 --- /dev/null +++ b/src/generated_scene/generator.rs @@ -0,0 +1,229 @@ +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("_") +} diff --git a/tests/fixtures/generated_scene/monitoring/index.html b/tests/fixtures/generated_scene/monitoring/index.html new file mode 100644 index 0000000..1d79789 --- /dev/null +++ b/tests/fixtures/generated_scene/monitoring/index.html @@ -0,0 +1,14 @@ + + + + + 设备监测状态 + + + +
+

设备监测状态

+
running
+
+ + diff --git a/tests/scene_generator_test.rs b/tests/scene_generator_test.rs index 5f2ec05..da7acaf 100644 --- a/tests/scene_generator_test.rs +++ b/tests/scene_generator_test.rs @@ -37,6 +37,7 @@ fn generator_writes_registration_ready_package_with_scene_toml() { source_dir: PathBuf::from("tests/fixtures/generated_scene/report_collection"), scene_id: "sample-report-scene".to_string(), scene_name: "示例报表场景".to_string(), + scene_kind: None, output_root: output_root.clone(), lessons_path: PathBuf::from("docs/superpowers/references/tq-lineloss-lessons-learned.toml"), }) @@ -141,3 +142,27 @@ fn analyzer_hint_overrides_meta() { assert_eq!(analysis.scene_kind, SceneKind::Monitoring); } + +#[test] +fn generator_emits_monitoring_template() { + let output_root = temp_workspace("sgclaw-monitoring-generator"); + + generate_scene_package(GenerateSceneRequest { + source_dir: PathBuf::from("tests/fixtures/generated_scene/monitoring"), + scene_id: "sample-monitor-scene".to_string(), + scene_name: "示例监测场景".to_string(), + scene_kind: Some(SceneKind::Monitoring), + output_root: output_root.clone(), + lessons_path: PathBuf::from("docs/superpowers/references/tq-lineloss-lessons-learned.toml"), + }) + .unwrap(); + + let skill_root = output_root.join("skills/sample-monitor-scene"); + assert!(skill_root.join("SKILL.toml").exists()); + assert!(skill_root.join("scene.toml").exists()); + + let generated_manifest = fs::read_to_string(skill_root.join("scene.toml")).unwrap(); + assert!(generated_manifest.contains("category = \"monitoring\"")); + // 监测类不应该有 org/period resolver + assert!(!generated_manifest.contains("resolver = \"dictionary_entity\"")); +}