From d1711a3db3ab97224d9b4439a39c94a22f670229 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=A8=E7=82=8E?= <635735027@qq.com> Date: Fri, 17 Apr 2026 18:28:47 +0800 Subject: [PATCH] feat(generator): unify all scene types through multi-mode path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-mode and page-state-eval scenes now get auto-wrapped into a default mode and compiled through compile_multi_mode_request. This eliminates the old browser_script_with_business_logic code path and ensures all scenes get responsePath extraction, requestTemplate, and contentType support. 🤖 Generated with [Qoder][https://qoder.com] --- src/generated_scene/generator.rs | 1701 +++++++++++++++++++++--------- 1 file changed, 1223 insertions(+), 478 deletions(-) diff --git a/src/generated_scene/generator.rs b/src/generated_scene/generator.rs index e451809..e67abec 100644 --- a/src/generated_scene/generator.rs +++ b/src/generated_scene/generator.rs @@ -1,126 +1,24 @@ -use std::collections::HashMap; +use std::collections::{BTreeMap, HashSet}; use std::fmt; use std::fs; use std::path::{Path, PathBuf}; +use serde_json::{json, Map, Value}; + +use crate::compat::scene_platform::resolvers::is_runtime_supported_resolver; use crate::generated_scene::analyzer::{ - analyze_scene_source_with_hint, AnalyzeSceneError, SceneKind, SceneSourceAnalysis, + analyze_scene_source_with_hint, extract_deterministic_scene_facts, AnalyzeSceneError, + DeterministicSceneFacts, SceneKind, SceneSourceAnalysis, +}; +use crate::generated_scene::ir::{ + ApiEndpointIr, ArtifactContractIr, BootstrapIr, EvidenceIr, LegacySceneInfoJson, ModeConditionIr, + ModeIr, NormalizeRulesIr, ParamIr, ReadinessGateIr, ReadinessIr, SceneIdDiagnosticsIr, SceneIr, + ValidationHintsIr, WorkflowArchetype, WorkflowEvidenceIr, WorkflowStepIr, }; 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, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct ModeConditionJson { - pub field: String, - #[serde(default = "default_equals")] - pub operator: String, - pub value: serde_json::Value, -} - -fn default_equals() -> String { - "equals".to_string() -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct NormalizeRulesJson { - #[serde(rename = "type", default = "default_validate_all")] - pub rules_type: String, - #[serde(rename = "requiredFields", default)] - pub required_fields: Vec, - #[serde(rename = "filterNull", default = "default_true")] - pub filter_null: bool, -} - -fn default_validate_all() -> String { - "validate_all_columns".to_string() -} - -fn default_true() -> bool { - true -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct ApiEndpointEnhancedJson { - pub name: String, - pub url: String, - #[serde(default)] - pub method: String, - #[serde(rename = "contentType", default)] - pub content_type: Option, - #[serde(default)] - pub description: Option, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct ModeConfigJson { - pub name: String, - #[serde(default)] - pub label: Option, - pub condition: ModeConditionJson, - #[serde(rename = "apiEndpoint")] - pub api_endpoint: ApiEndpointEnhancedJson, - #[serde(rename = "columnDefs", default)] - pub column_defs: Vec<(String, String)>, - #[serde(rename = "requestTemplate", default)] - pub request_template: Option, - #[serde(rename = "normalizeRules", default)] - pub normalize_rules: Option, - #[serde(rename = "responsePath", default)] - pub response_path: Option, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct BusinessLogicJson { - #[serde(default)] - pub data_fetch: Option, - #[serde(default)] - pub data_transform: Option, -} - -#[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, - #[serde(rename = "expectedDomain", default)] - pub expected_domain: Option, - #[serde(rename = "targetUrl", default)] - pub target_url: Option, - #[serde(rename = "apiEndpoints", default)] - pub api_endpoints: Vec, - #[serde(rename = "staticParams", default)] - pub static_params: HashMap, - #[serde(rename = "columnDefs", default)] - pub column_defs: Vec<(String, String)>, - #[serde(rename = "entryMethod", default)] - pub entry_method: Option, - #[serde(rename = "businessLogic", default)] - pub business_logic: Option, - // Multi-mode support (new fields) - #[serde(default)] - pub modes: Vec, - #[serde(rename = "defaultMode", default)] - pub default_mode: Option, - #[serde(rename = "modeSwitchField", default)] - pub mode_switch_field: Option, -} - #[derive(Debug, Clone)] pub struct GenerateSceneRequest { pub source_dir: PathBuf, @@ -130,7 +28,8 @@ pub struct GenerateSceneRequest { pub target_url: Option, pub output_root: PathBuf, pub lessons_path: Option, - pub scene_info_json: Option, + pub scene_info_json: Option, + pub scene_ir_json: Option, } #[derive(Debug)] @@ -160,10 +59,18 @@ impl From for GenerateSceneError { } } +#[derive(Debug, Clone)] +struct CompiledScene { + scene_toml: String, + browser_script: String, + browser_test: String, +} + pub fn generate_scene_package( request: GenerateSceneRequest, ) -> Result { let analysis = analyze_scene_source_with_hint(&request.source_dir, request.scene_kind.clone())?; + let facts = extract_deterministic_scene_facts(&request.source_dir)?; let (lessons, lessons_source): (GenerationLessons, String) = match &request.lessons_path { Some(path) => ( load_generation_lessons(path).map_err(GenerateSceneError::new)?, @@ -184,6 +91,13 @@ pub fn generate_scene_package( )); } + let scene_ir = resolve_scene_ir(&request, &analysis, &facts); + if !workflow_complete_for_generation(&scene_ir) { + return Err(GenerateSceneError::new(format!( + "workflow evidence is incomplete for archetype {}", + scene_ir.workflow_archetype().as_str() + ))); + } 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"); @@ -191,38 +105,51 @@ pub fn generate_scene_package( 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))?; + let compiled = compile_scene(&scene_ir, &analysis, &tool_name); + write_file( &skill_root.join("SKILL.toml"), - &skill_toml(&request, &analysis, &tool_name), + &skill_toml(&request, &analysis, &tool_name, &scene_ir), )?; 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(), + &skill_md(&request, &analysis, &tool_name, &scene_ir), )?; + write_file(&skill_root.join("scene.toml"), &compiled.scene_toml)?; write_file( &scripts_dir.join(format!("{tool_name}.js")), - &browser_script(&request.scene_id, &analysis, request.scene_info_json.as_ref()), + &compiled.browser_script, )?; write_file( &scripts_dir.join(format!("{tool_name}.test.js")), - &browser_script_test(&tool_name, &analysis), + &compiled.browser_test, )?; + if scene_ir + .params + .iter() + .any(|param| param.resolver == "dictionary_entity") + { + write_file( + &references_dir.join("org-dictionary.json"), + placeholder_org_dictionary(), + )?; + } 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", + "# Generation Lessons\n\nGenerated from `{}` with lessons `{}`.\n\nThis package was compiled through the scene skill compiler pipeline.\n", request.source_dir.display(), lessons_source ), )?; + write_file( + &references_dir.join("generation-report.json"), + &serde_json::to_string_pretty(&scene_ir).unwrap_or_else(|_| "{}".to_string()), + )?; + write_file( + &references_dir.join("generation-report.md"), + &generation_report_md(&scene_ir), + )?; if let Some(entry_script) = analysis.collection_entry_script.as_deref() { let source_script = request.source_dir.join(entry_script); @@ -236,134 +163,767 @@ pub fn generate_scene_package( 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( +fn resolve_scene_ir( 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), + facts: &DeterministicSceneFacts, +) -> SceneIr { + let mut scene_ir = if let Some(ir) = request.scene_ir_json.clone() { + ir + } else if let Some(legacy) = request.scene_info_json.clone() { + SceneIr::from(legacy) + } else { + fallback_scene_ir(request, analysis, facts) + }; + + scene_ir.scene_id = request.scene_id.clone(); + scene_ir.scene_name = request.scene_name.clone(); + scene_ir.scene_kind = analysis.scene_kind.as_str().to_string(); + + if scene_ir.bootstrap.expected_domain.trim().is_empty() { + scene_ir.bootstrap.expected_domain = analysis + .bootstrap + .expected_domain + .clone() + .unwrap_or_default(); + scene_ir.bootstrap.source = Some("analysis".to_string()); + } + if scene_ir.bootstrap.target_url.trim().is_empty() { + scene_ir.bootstrap.target_url = analysis.bootstrap.target_url.clone().unwrap_or_default(); + if scene_ir.bootstrap.source.is_none() { + scene_ir.bootstrap.source = Some("analysis".to_string()); + } + } + if let Some(target_url) = request.target_url.as_deref().filter(|value| !value.trim().is_empty()) { + scene_ir.bootstrap.target_url = target_url.to_string(); + scene_ir.bootstrap.source = Some("request_override".to_string()); + } + if scene_ir.workflow_archetype.is_none() { + scene_ir.workflow_archetype = Some(facts.workflow_archetype.clone()); + } + if scene_ir.api_endpoints.is_empty() { + scene_ir.api_endpoints = facts + .endpoints + .iter() + .map(|endpoint| ApiEndpointIr { + name: endpoint.name.clone(), + url: endpoint.url.clone(), + method: endpoint.method.clone(), + content_type: endpoint.content_type.clone(), + description: None, + }) + .collect(); + } + if scene_ir.workflow_steps.is_empty() { + scene_ir.workflow_steps = build_workflow_steps_from_facts(&scene_ir, facts); + } + if scene_ir.evidence.is_empty() { + scene_ir.evidence = facts + .evidence + .iter() + .map(|summary| EvidenceIr { + kind: "deterministic".to_string(), + summary: summary.clone(), + source: Some("analyzer".to_string()), + confidence: 0.8, + }) + .collect(); + } + if scene_ir.params.is_empty() { + scene_ir.params = infer_default_params(&scene_ir); + } + let (sanitized_params, runtime_risks) = sanitize_params_for_runtime(&scene_ir.params); + scene_ir.params = sanitized_params; + scene_ir.readiness = compute_readiness(&scene_ir, facts, &runtime_risks); + scene_ir.validation_hints = ValidationHintsIr { + requires_target_page: scene_ir.bootstrap.requires_target_page, + runtime_compatible: runtime_risks.is_empty(), + manual_completion_required: scene_ir.readiness.level != "A", + missing_pieces: scene_ir.readiness.missing_pieces.clone(), + }; + scene_ir +} + +fn fallback_scene_ir( + request: &GenerateSceneRequest, + analysis: &SceneSourceAnalysis, + facts: &DeterministicSceneFacts, +) -> SceneIr { + SceneIr { + scene_id: request.scene_id.clone(), + scene_id_diagnostics: SceneIdDiagnosticsIr::default(), + scene_name: request.scene_name.clone(), + scene_kind: analysis.scene_kind.as_str().to_string(), + workflow_archetype: Some(facts.workflow_archetype.clone()), + bootstrap: BootstrapIr { + expected_domain: analysis + .bootstrap + .expected_domain + .clone() + .unwrap_or_default(), + target_url: analysis.bootstrap.target_url.clone().unwrap_or_default(), + requires_target_page: true, + page_title_keywords: vec![request.scene_name.clone()], + source: Some("deterministic".to_string()), + }, + params: Vec::new(), + modes: infer_modes_from_facts(facts), + default_mode: Some("month".to_string()), + mode_switch_field: Some("period_mode".to_string()), + workflow_steps: build_workflow_steps_from_facts( + &SceneIr { + scene_id: request.scene_id.clone(), + scene_id_diagnostics: SceneIdDiagnosticsIr::default(), + scene_name: request.scene_name.clone(), + scene_kind: analysis.scene_kind.as_str().to_string(), + workflow_archetype: Some(facts.workflow_archetype.clone()), + bootstrap: BootstrapIr::default(), + params: Vec::new(), + modes: Vec::new(), + default_mode: None, + mode_switch_field: None, + workflow_steps: Vec::new(), + workflow_evidence: WorkflowEvidenceIr::default(), + request_template: Value::Null, + response_path: String::new(), + normalize_rules: None, + artifact_contract: ArtifactContractIr::default(), + validation_hints: ValidationHintsIr::default(), + evidence: Vec::new(), + readiness: ReadinessIr::default(), + api_endpoints: Vec::new(), + static_params: Default::default(), + column_defs: Vec::new(), + confidence: 0.0, + uncertainties: Vec::new(), + }, + facts, + ), + workflow_evidence: WorkflowEvidenceIr::default(), + request_template: Value::Null, + response_path: facts.response_paths.first().cloned().unwrap_or_default(), + normalize_rules: Some(NormalizeRulesIr { + rules_type: "validate_required".to_string(), + required_fields: Vec::new(), + filter_null: true, + }), + artifact_contract: ArtifactContractIr::default(), + validation_hints: ValidationHintsIr::default(), + evidence: facts + .evidence + .iter() + .map(|summary| EvidenceIr { + kind: "deterministic".to_string(), + summary: summary.clone(), + source: Some("analyzer".to_string()), + confidence: 0.7, + }) + .collect(), + readiness: ReadinessIr::default(), + api_endpoints: facts + .endpoints + .iter() + .map(|endpoint| ApiEndpointIr { + name: endpoint.name.clone(), + url: endpoint.url.clone(), + method: endpoint.method.clone(), + content_type: endpoint.content_type.clone(), + description: None, + }) + .collect(), + static_params: Default::default(), + column_defs: Vec::new(), + confidence: 0.7, + uncertainties: Vec::new(), } } -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(); - // Use request.target_url if provided, otherwise fall back to analysis - let target_url = request.target_url.as_deref() - .or(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 infer_modes_from_facts(facts: &DeterministicSceneFacts) -> Vec { + if facts.workflow_archetype != WorkflowArchetype::MultiModeRequest || facts.endpoints.is_empty() + { + return Vec::new(); + } + let month_endpoint = facts.endpoints.first().cloned(); + let week_endpoint = facts.endpoints.get(1).cloned().or(month_endpoint.clone()); + let mut modes = Vec::new(); + if let Some(endpoint) = month_endpoint { + modes.push(ModeIr { + name: "month".to_string(), + label: Some("month".to_string()), + condition: Some(ModeConditionIr { + field: "period_mode".to_string(), + operator: "equals".to_string(), + value: Value::String("month".to_string()), + }), + api_endpoint: Some(ApiEndpointIr { + name: endpoint.name, + url: endpoint.url, + method: endpoint.method, + content_type: endpoint.content_type, + description: None, + }), + column_defs: Vec::new(), + request_template: json!({}), + normalize_rules: Some(NormalizeRulesIr { + rules_type: "validate_required".to_string(), + required_fields: Vec::new(), + filter_null: true, + }), + response_path: facts.response_paths.first().cloned().unwrap_or_default(), + }); + } + if let Some(endpoint) = week_endpoint { + modes.push(ModeIr { + name: "week".to_string(), + label: Some("week".to_string()), + condition: Some(ModeConditionIr { + field: "period_mode".to_string(), + operator: "equals".to_string(), + value: Value::String("week".to_string()), + }), + api_endpoint: Some(ApiEndpointIr { + name: endpoint.name, + url: endpoint.url, + method: endpoint.method, + content_type: endpoint.content_type, + description: None, + }), + column_defs: Vec::new(), + request_template: json!({}), + normalize_rules: Some(NormalizeRulesIr { + rules_type: "validate_required".to_string(), + required_fields: Vec::new(), + filter_null: true, + }), + response_path: facts.response_paths.first().cloned().unwrap_or_default(), + }); + } + modes } -fn scene_toml_monitoring( - request: &GenerateSceneRequest, - analysis: &SceneSourceAnalysis, - tool_name: &str, -) -> String { - let expected_domain = analysis.bootstrap.expected_domain.as_deref().unwrap_or_default(); - // Use request.target_url if provided, otherwise fall back to analysis - let target_url = request.target_url.as_deref() - .or(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 - ) +/// Ensure a SceneIr has populated `modes` for compilation through `compile_multi_mode_request`. +/// If modes are already non-empty, returns a clone as-is. +/// If modes are empty but `api_endpoints` is non-empty, constructs a default mode from the first endpoint. +fn ensure_modes_populated(scene_ir: &SceneIr) -> SceneIr { + let mut adapted = scene_ir.clone(); + + if !adapted.modes.is_empty() { + return adapted; + } + + if adapted.api_endpoints.is_empty() { + return adapted; + } + + let first_endpoint = adapted.api_endpoints[0].clone(); + let default_mode = ModeIr { + name: "default".to_string(), + label: Some("default".to_string()), + condition: Some(ModeConditionIr { + field: "period_mode".to_string(), + operator: "equals".to_string(), + value: Value::String("default".to_string()), + }), + api_endpoint: Some(first_endpoint), + column_defs: adapted.column_defs.clone(), + request_template: if adapted.request_template.is_null() { + json!({}) + } else { + adapted.request_template.clone() + }, + normalize_rules: adapted.normalize_rules.clone().or_else(|| { + Some(NormalizeRulesIr { + rules_type: "validate_required".to_string(), + required_fields: Vec::new(), + filter_null: true, + }) + }), + response_path: adapted.response_path.clone(), + }; + + adapted.modes = vec![default_mode]; + adapted.default_mode = Some("default".to_string()); + adapted.mode_switch_field = Some("period_mode".to_string()); + + adapted } -fn default_org_dictionary() -> &'static str { - r#"[ - { - "label": "国网兰州供电公司", - "code": "62401" - } -] -"# -} - -fn browser_script_skeleton(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(scene_id: &str, analysis: &SceneSourceAnalysis, scene_info: Option<&SceneInfoJson>) -> String { - match scene_info { - Some(info) if !info.modes.is_empty() => { - browser_script_with_modes(scene_id, info) +fn build_workflow_steps_from_facts( + scene_ir: &SceneIr, + facts: &DeterministicSceneFacts, +) -> Vec { + let mut steps = Vec::new(); + match scene_ir.workflow_archetype() { + WorkflowArchetype::SingleRequestTable => { + steps.push(WorkflowStepIr { + step_type: "request".to_string(), + entry: facts.entry_methods.first().cloned(), + endpoint: scene_ir.api_endpoints.first().map(|endpoint| endpoint.name.clone()), + description: Some("single request table collection".to_string()), + ..WorkflowStepIr::default() + }); + steps.push(WorkflowStepIr { + step_type: "transform".to_string(), + description: Some("normalize table rows".to_string()), + ..WorkflowStepIr::default() + }); } - Some(info) if !info.api_endpoints.is_empty() || !info.column_defs.is_empty() => { - browser_script_with_business_logic(scene_id, analysis, info) + WorkflowArchetype::MultiModeRequest => { + steps.push(WorkflowStepIr { + step_type: "request".to_string(), + entry: facts.entry_methods.first().cloned(), + description: Some("select mode and query corresponding endpoint".to_string()), + ..WorkflowStepIr::default() + }); + steps.push(WorkflowStepIr { + step_type: "transform".to_string(), + description: Some("normalize mode-specific table rows".to_string()), + ..WorkflowStepIr::default() + }); } - _ => browser_script_skeleton(scene_id, analysis), + WorkflowArchetype::PaginatedEnrichment => { + steps.push(WorkflowStepIr { + step_type: "paginate".to_string(), + entry: facts.entry_methods.first().cloned(), + description: Some("iterate list pages".to_string()), + ..WorkflowStepIr::default() + }); + steps.push(WorkflowStepIr { + step_type: "secondary_request".to_string(), + entry: facts.secondary_request_methods.first().cloned(), + description: Some("enrich each row with secondary request".to_string()), + ..WorkflowStepIr::default() + }); + if let Some(expr) = facts.filter_expressions.first() { + steps.push(WorkflowStepIr { + step_type: "filter".to_string(), + expr: Some(expr.clone()), + description: Some("apply business filter".to_string()), + ..WorkflowStepIr::default() + }); + } + if let Some(entry) = facts.export_methods.first() { + steps.push(WorkflowStepIr { + step_type: "export".to_string(), + entry: Some(entry.clone()), + description: Some("prepare export payload".to_string()), + ..WorkflowStepIr::default() + }); + } + } + WorkflowArchetype::PageStateEval => { + steps.push(WorkflowStepIr { + step_type: "page_state".to_string(), + description: Some("evaluate page state or status".to_string()), + ..WorkflowStepIr::default() + }); + } + } + steps +} + +fn infer_default_params(scene_ir: &SceneIr) -> Vec { + match scene_ir.workflow_archetype() { + WorkflowArchetype::MultiModeRequest => vec![org_dictionary_param(), month_week_period_param()], + WorkflowArchetype::PaginatedEnrichment => Vec::new(), + WorkflowArchetype::SingleRequestTable => Vec::new(), + WorkflowArchetype::PageStateEval => Vec::new(), } } -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()); - +fn org_dictionary_param() -> ParamIr { + let mut resolver_config = Map::new(); + resolver_config.insert( + "dictionary_ref".to_string(), + Value::String("references/org-dictionary.json".to_string()), + ); + resolver_config.insert( + "output_label_field".to_string(), + Value::String("org_label".to_string()), + ); + resolver_config.insert( + "output_code_field".to_string(), + Value::String("org_code".to_string()), + ); + ParamIr { + name: "org".to_string(), + resolver: "dictionary_entity".to_string(), + required: true, + prompt_missing: "缺少供电单位。".to_string(), + prompt_ambiguous: "供电单位存在歧义。".to_string(), + resolver_config, + } +} + +fn month_week_period_param() -> ParamIr { + ParamIr { + name: "period".to_string(), + resolver: "month_week_period".to_string(), + required: true, + prompt_missing: "缺少统计周期。".to_string(), + prompt_ambiguous: "统计周期存在歧义。".to_string(), + resolver_config: Map::new(), + } +} + +fn sanitize_params_for_runtime(params: &[ParamIr]) -> (Vec, Vec) { + let allowed_output_fields = [ + "org_label", + "org_code", + "period_mode", + "period_mode_code", + "period_value", + "period_payload", + ]; + let mut kept = Vec::new(); + let mut risks = Vec::new(); + + for param in params { + let mut param = param.clone(); + if !is_runtime_supported_resolver(¶m.resolver) { + match param.resolver.as_str() { + "org_tree" => { + param.resolver = "dictionary_entity".to_string(); + if !param.resolver_config.contains_key("dictionary_ref") { + risks.push(format!("param:{} downgraded_without_dictionary_ref", param.name)); + continue; + } + } + "hidden_static" | "page_size" => { + if !param.resolver_config.contains_key("value") { + risks.push(format!("param:{} missing_fixed_value", param.name)); + continue; + } + param.resolver = "fixed_enum".to_string(); + } + other => { + risks.push(format!("param:{} unsupported_resolver:{other}", param.name)); + continue; + } + } + } + + if param.resolver == "fixed_enum" || param.resolver == "literal_passthrough" { + let output_field = param + .resolver_config + .get("output_field") + .and_then(Value::as_str) + .unwrap_or(¶m.name); + if !allowed_output_fields.contains(&output_field) { + risks.push(format!("param:{} runtime_drops_output:{output_field}", param.name)); + continue; + } + } + kept.push(param); + } + + (kept, risks) +} + +fn compute_readiness( + scene_ir: &SceneIr, + facts: &DeterministicSceneFacts, + runtime_risks: &[String], +) -> ReadinessIr { + let mut risks = runtime_risks.to_vec(); + let mut missing = Vec::new(); + if scene_ir.bootstrap.expected_domain.trim().is_empty() { + missing.push("expected_domain".to_string()); + } + if scene_ir.bootstrap.target_url.trim().is_empty() { + missing.push("target_url".to_string()); + } + match scene_ir.workflow_archetype() { + WorkflowArchetype::SingleRequestTable => { + if scene_ir.api_endpoints.is_empty() { + missing.push("api_endpoints".to_string()); + } + } + WorkflowArchetype::MultiModeRequest => { + if scene_ir.modes.is_empty() { + missing.push("modes".to_string()); + } + if scene_ir.mode_switch_field.as_deref().unwrap_or_default().is_empty() { + missing.push("mode_switch_field".to_string()); + } + } + WorkflowArchetype::PaginatedEnrichment => { + let step_types = scene_ir + .workflow_steps + .iter() + .map(|step| step.step_type.as_str()) + .collect::>(); + let has_paginate = step_types.contains("paginate") + || !scene_ir.workflow_evidence.pagination_fields.is_empty(); + let has_secondary = step_types.contains("secondary_request") + || !scene_ir.workflow_evidence.secondary_request_entries.is_empty(); + let has_post_process = step_types.contains("filter") + || step_types.contains("transform") + || step_types.contains("export") + || !scene_ir.workflow_evidence.post_process_steps.is_empty(); + if !has_paginate { + missing.push("paginate_step".to_string()); + } + if !has_secondary { + missing.push("secondary_request_step".to_string()); + } + if !has_post_process { + missing.push("post_process_step".to_string()); + } + if scene_ir.api_endpoints.len() < 2 { + risks.push("paginated_enrichment_needs_multiple_endpoints".to_string()); + } + } + WorkflowArchetype::PageStateEval => { + if scene_ir.workflow_steps.is_empty() && facts.endpoints.is_empty() { + missing.push("page_state_or_endpoint_evidence".to_string()); + } + } + } + if scene_ir.workflow_steps.is_empty() { + risks.push("workflow_steps_missing".to_string()); + } + if scene_ir.params.is_empty() && matches!(scene_ir.workflow_archetype(), WorkflowArchetype::MultiModeRequest) { + risks.push("runtime_params_missing".to_string()); + } + + let level = if missing.is_empty() && risks.is_empty() { + "A" + } else if missing.len() <= 1 && risks.len() <= 2 { + "B" + } else { + "C" + }; + let workflow_reason = missing + .iter() + .find(|item| { + matches!( + item.as_str(), + "paginate_step" | "secondary_request_step" | "post_process_step" | "workflow_steps" + ) + }) + .cloned(); + ReadinessIr { + level: level.to_string(), + confidence: scene_ir.confidence.max(0.65), + gates: vec![ + ReadinessGateIr { + name: "scene_id_valid".to_string(), + passed: !scene_ir.scene_id.trim().is_empty(), + reason: if scene_ir.scene_id.trim().is_empty() { + Some("invalid_scene_id".to_string()) + } else { + None + }, + }, + ReadinessGateIr { + name: "bootstrap_resolved".to_string(), + passed: !scene_ir.bootstrap.expected_domain.trim().is_empty() + && !scene_ir.bootstrap.target_url.trim().is_empty(), + reason: if scene_ir.bootstrap.expected_domain.trim().is_empty() + || scene_ir.bootstrap.target_url.trim().is_empty() + { + Some("bootstrap_target".to_string()) + } else { + None + }, + }, + ReadinessGateIr { + name: "workflow_complete_for_archetype".to_string(), + passed: workflow_reason.is_none(), + reason: workflow_reason, + }, + ReadinessGateIr { + name: "runtime_contract_compatible".to_string(), + passed: runtime_risks.is_empty(), + reason: runtime_risks.first().cloned(), + }, + ], + risks: risks.clone(), + missing_pieces: missing.clone(), + notes: vec![format!( + "workflow_archetype={}", + scene_ir.workflow_archetype().as_str() + )], + } +} + +fn workflow_complete_for_generation(scene_ir: &SceneIr) -> bool { + match scene_ir.workflow_archetype() { + WorkflowArchetype::PaginatedEnrichment => { + let step_types = scene_ir + .workflow_steps + .iter() + .map(|step| step.step_type.as_str()) + .collect::>(); + let has_paginate = step_types.contains("paginate") + || !scene_ir.workflow_evidence.pagination_fields.is_empty(); + let has_secondary = step_types.contains("secondary_request") + || !scene_ir.workflow_evidence.secondary_request_entries.is_empty(); + let has_post_process = step_types.contains("filter") + || step_types.contains("transform") + || step_types.contains("export") + || !scene_ir.workflow_evidence.post_process_steps.is_empty(); + has_paginate && has_secondary && has_post_process && scene_ir.api_endpoints.len() >= 2 + } + WorkflowArchetype::MultiModeRequest => !scene_ir.modes.is_empty(), + WorkflowArchetype::SingleRequestTable => !scene_ir.api_endpoints.is_empty(), + WorkflowArchetype::PageStateEval => !scene_ir.workflow_steps.is_empty() || !scene_ir.api_endpoints.is_empty(), + } +} + +fn compile_scene(scene_ir: &SceneIr, analysis: &SceneSourceAnalysis, tool_name: &str) -> CompiledScene { + let scene_toml = render_scene_toml(scene_ir, analysis, tool_name); + let browser_script = match scene_ir.workflow_archetype() { + WorkflowArchetype::MultiModeRequest => compile_multi_mode_request(scene_ir), + WorkflowArchetype::PaginatedEnrichment => compile_paginated_enrichment(scene_ir), + _ => { + // SingleRequestTable, PageStateEval — fallback to multi-mode with auto-wrapped default mode + let adapted = ensure_modes_populated(scene_ir); + compile_multi_mode_request(&adapted) + } + }; + let browser_test = browser_script_test(tool_name, scene_ir); + CompiledScene { + scene_toml, + browser_script, + browser_test, + } +} + +fn skill_toml( + request: &GenerateSceneRequest, + analysis: &SceneSourceAnalysis, + tool_name: &str, + scene_ir: &SceneIr, +) -> String { + let category = analysis.scene_kind.as_str(); format!( - r#"const API_ENDPOINTS = {api_endpoints_json}; + "[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, + scene_ir.workflow_archetype().as_str(), + tool_name, + request.scene_name, + tool_name + ) +} -const STATIC_PARAMS = {static_params_json}; +fn skill_md( + request: &GenerateSceneRequest, + analysis: &SceneSourceAnalysis, + tool_name: &str, + scene_ir: &SceneIr, +) -> String { + let category = analysis.scene_kind.as_str(); + format!( + "# {}\n\nGenerated {} browser_script scene skill.\n\n- Scene id: `{}`\n- Tool: `{}`\n- Workflow archetype: `{}`\n- Readiness: `{}`\n", + request.scene_name, + category, + request.scene_id, + tool_name, + scene_ir.workflow_archetype().as_str(), + scene_ir.readiness.level + ) +} -const COLUMN_DEFS = {column_defs_json}; +fn render_scene_toml(scene_ir: &SceneIr, analysis: &SceneSourceAnalysis, tool_name: &str) -> String { + let category = analysis.scene_kind.as_str(); + let include_keywords = if scene_ir.bootstrap.page_title_keywords.is_empty() { + vec![scene_ir.scene_name.clone()] + } else { + scene_ir.bootstrap.page_title_keywords.clone() + }; + let mut toml = format!( + "[scene]\nid = \"{}\"\nskill = \"{}\"\ntool = \"{}\"\nkind = \"browser_script\"\nversion = \"0.1.0\"\ncategory = \"{}\"\n\n[manifest]\nschema_version = \"1\"\n\n[bootstrap]\nexpected_domain = \"{}\"\ntarget_url = \"{}\"\npage_title_keywords = {}\nrequires_target_page = {}\n\n[deterministic]\nsuffix = \"{}\"\ninclude_keywords = {}\nexclude_keywords = []\n\n", + escape_toml(&scene_ir.scene_id), + escape_toml(&scene_ir.scene_id), + escape_toml(tool_name), + escape_toml(category), + escape_toml(&scene_ir.bootstrap.expected_domain), + escape_toml(&scene_ir.bootstrap.target_url), + render_toml_string_array(&include_keywords), + scene_ir.bootstrap.requires_target_page, + escape_toml(&scene_ir.scene_name), + render_toml_string_array(&include_keywords), + ); -const COLUMNS = {columns_json}; + for param in &scene_ir.params { + toml.push_str(&render_param_toml(param)); + toml.push('\n'); + } -const REPORT_NAME = '{scene_id}'; + toml.push_str(&format!( + "[artifact]\ntype = \"{}\"\nsuccess_status = {}\nfailure_status = {}\n\n", + escape_toml(&scene_ir.artifact_contract.artifact_type), + render_toml_string_array(&scene_ir.artifact_contract.success_status), + render_toml_string_array(&scene_ir.artifact_contract.failure_status) + )); + + if category == "report_collection" { + toml.push_str("[postprocess]\nexporter = \"xlsx_report\"\nauto_open = \"excel\"\n"); + } + toml +} + +fn render_param_toml(param: &ParamIr) -> String { + let mut content = format!( + "[[params]]\nname = \"{}\"\nresolver = \"{}\"\nrequired = {}\nprompt_missing = \"{}\"\nprompt_ambiguous = \"{}\"\n", + escape_toml(¶m.name), + escape_toml(¶m.resolver), + param.required, + escape_toml(¶m.prompt_missing), + escape_toml(¶m.prompt_ambiguous) + ); + if !param.resolver_config.is_empty() { + content.push_str("\n[params.resolver_config]\n"); + let sorted = param + .resolver_config + .iter() + .collect::>(); + for (key, value) in sorted { + content.push_str(&format!("{key} = {}\n", render_toml_value(value))); + } + } + content +} + +/// Legacy path, superseded by multi-mode unified path. +fn compile_single_request_table(scene_ir: &SceneIr) -> String { + compile_simple_request_script(scene_ir, "queryData") +} + +fn compile_page_state_eval(scene_ir: &SceneIr) -> String { + let mut scene_ir = scene_ir.clone(); + if scene_ir.api_endpoints.is_empty() { + scene_ir.api_endpoints.push(ApiEndpointIr { + name: "page_state".to_string(), + url: scene_ir.bootstrap.target_url.clone(), + method: "GET".to_string(), + content_type: Some("application/json".to_string()), + description: Some("page state evaluation".to_string()), + }); + } + compile_simple_request_script(&scene_ir, "queryState") +} + +/// Legacy path, superseded by multi-mode unified path. +fn compile_simple_request_script(scene_ir: &SceneIr, query_function_name: &str) -> String { + let endpoints = serde_json::to_string_pretty(&scene_ir.api_endpoints).unwrap_or_else(|_| "[]".to_string()); + let static_params = serde_json::to_string_pretty(&scene_ir.static_params).unwrap_or_else(|_| "{}".to_string()); + let column_defs = serde_json::to_string_pretty(&scene_ir.column_defs).unwrap_or_else(|_| "[]".to_string()); + let request_template = serde_json::to_string_pretty(&scene_ir.request_template).unwrap_or_else(|_| "{}".to_string()); + let response_path = serde_json::to_string(&scene_ir.response_path).unwrap_or_else(|_| "\"\"".to_string()); + let normalize_rules = serde_json::to_string_pretty(&scene_ir.normalize_rules).unwrap_or_else(|_| "null".to_string()); + format!( + r#"const REPORT_NAME = {report_name}; +const API_ENDPOINTS = {endpoints}; +const STATIC_PARAMS = {static_params}; +const COLUMN_DEFS = {column_defs}; +const REQUEST_TEMPLATE = {request_template}; +const RESPONSE_PATH = {response_path}; +const NORMALIZE_RULES = {normalize_rules}; function normalizePayload(payload) {{ if (typeof payload === 'string') {{ @@ -372,30 +932,70 @@ function normalizePayload(payload) {{ 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 safeGet(obj, path) {{ + if (!path) return obj; + return path.split('.').reduce((acc, key) => (acc && acc[key] !== undefined ? acc[key] : undefined), obj); +}} + +function isPlainObject(value) {{ + return value && typeof value === 'object' && !Array.isArray(value); +}} + +function resolveTemplateValue(value, args) {{ + if (typeof value !== 'string') return value; + const match = value.match(/^\$\{{args\.(\w+)}}$/); + if (match) return args[match[1]]; + return value; +}} + +function mergeTemplate(template, args) {{ + if (!isPlainObject(template)) return {{}}; + const out = {{}}; + for (const [key, value] of Object.entries(template)) {{ + out[key] = resolveTemplateValue(value, args); + }} + return out; +}} + +function toFormBody(body) {{ + return Object.entries(body) + .filter(([, value]) => value !== undefined && value !== null && value !== '') + .map(([key, value]) => encodeURIComponent(key) + '=' + encodeURIComponent(String(value))) + .join('&'); }} function buildRequest(args, endpoint) {{ - // Use endpoint.url directly - it's already a complete URL - const url = endpoint.url; - const method = endpoint.method || 'POST'; - const headers = {{ 'Content-Type': 'application/json' }}; - const body = JSON.stringify({{ ...STATIC_PARAMS, ...args }}); - return {{ url, method, headers, body }}; + const method = (endpoint.method || 'POST').toUpperCase(); + const contentType = endpoint.contentType || 'application/json'; + const requestBody = {{ ...STATIC_PARAMS, ...mergeTemplate(REQUEST_TEMPLATE, args), ...args }}; + const headers = {{ 'Content-Type': contentType }}; + const body = contentType === 'application/x-www-form-urlencoded' + ? toFormBody(requestBody) + : JSON.stringify(requestBody); + return {{ url: endpoint.url, method, headers, body }}; }} -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] ?? ''; +function inferColumnDefs(rows) {{ + if (COLUMN_DEFS.length > 0) return COLUMN_DEFS; + const first = rows[0] || {{}}; + return Object.keys(first).map((key) => [key, key]); +}} + +function normalizeRows(rawRows) {{ + const rows = Array.isArray(rawRows) ? rawRows : []; + const defs = inferColumnDefs(rows); + const required = NORMALIZE_RULES?.requiredFields || []; + const filterNull = NORMALIZE_RULES?.filterNull !== false; + return rows.map((row, index) => {{ + const normalized = {{ _index: index }}; + for (const [key] of defs) {{ + normalized[key] = row?.[key] ?? ''; }} - return result; + return normalized; + }}).filter((row) => {{ + if (!filterNull) return true; + if (required.length > 0) return required.every((field) => row[field] !== ''); + return Object.values(row).some((value) => value !== ''); }}); }} @@ -408,107 +1008,86 @@ function determineArtifactStatus({{ blockedReason = '', fatalError = '', reasons }} function buildArtifact({{ status, blockedReason = '', fatalError = '', reasons = [], rows = [], args }}) {{ + const defs = inferColumnDefs(rows); return {{ type: 'report-artifact', report_name: REPORT_NAME, status: status || determineArtifactStatus({{ blockedReason, fatalError, reasons, rows }}), period: {{ - mode: args.period_mode, - mode_code: args.period_mode_code, - value: args.period_value, + 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, + org: {{ label: args.org_label || '', code: args.org_code || '' }}, + column_defs: defs, + columns: defs.map(([key]) => key), rows, counts: {{ detail_rows: rows.length }}, - partial_reasons: reasons.filter(r => r && !r.startsWith('api_') && !r.startsWith('validation_')), - reasons: Array.from(new Set(reasons.filter(Boolean))) + partial_reasons: reasons, + reasons }}; }} const defaultDeps = {{ validatePageContext(args) {{ - const host = (globalThis.location?.hostname || '').trim(); const expected = (args.expected_domain || '').trim(); + const host = (globalThis.location?.hostname || '').trim(); + if (!expected) return {{ ok: true }}; if (!host) return {{ ok: false, reason: 'page_context_unavailable' }}; if (host !== expected) return {{ ok: false, reason: 'page_context_mismatch' }}; return {{ ok: true }}; }}, - async queryData(args) {{ + async {query_function_name}(args) {{ const endpoint = API_ENDPOINTS[0]; if (!endpoint) throw new Error('No API endpoint configured'); const request = buildRequest(args, endpoint); - - // Prefer jQuery (internal pages typically have it) if (typeof $ !== 'undefined' && typeof $.ajax === 'function') {{ return new Promise((resolve, reject) => {{ $.ajax({{ url: request.url, type: request.method, data: request.body, - contentType: 'application/json', + contentType: request.headers['Content-Type'], dataType: 'json', success: resolve, - error: (xhr, status, err) => reject(new Error( - `API failed (${{xhr.status}}): ${{err}} | body=${{(xhr.responseText || '').substring(0, 200)}}` - )) + error: (xhr, status, err) => reject(new Error(`API failed (${{xhr.status}}): ${{err}}`)) }}); }}); }} - - // Fallback: fetch API - if (typeof fetch === 'function') {{ - const response = await fetch(request.url, {{ - method: request.method, - headers: request.headers, - body: request.method !== 'GET' ? request.body : undefined - }}); - if (!response.ok) {{ - const text = await response.text().catch(() => ''); - throw new Error(`HTTP ${{response.status}}: ${{text.substring(0, 200)}}`); - }} - return response.json(); - }} - - throw new Error('No HTTP client available (need jQuery or fetch)'); + const response = await fetch(request.url, {{ + method: request.method, + headers: request.headers, + body: request.method === 'GET' ? undefined : request.body + }}); + if (!response.ok) throw new Error(`HTTP ${{response.status}}`); + return response.json(); }} }}; async function buildBrowserEntrypointResult(args, deps = defaultDeps) {{ - // 1. Parameter validation - const validation = validateArgs(args); - if (!validation.valid) {{ - return buildArtifact({{ - status: 'blocked', - blockedReason: 'validation_failed', - reasons: validation.errors, - rows: [], - args - }}); - }} - - // 2. Page context validation const pageValidation = typeof deps.validatePageContext === 'function' ? deps.validatePageContext(args) : {{ ok: true }}; - if (!pageValidation?.ok) {{ + if (!pageValidation.ok) {{ return buildArtifact({{ status: 'blocked', - blockedReason: pageValidation?.reason || 'page_context_mismatch', - reasons: [pageValidation?.reason || 'page_context_mismatch'], + blockedReason: pageValidation.reason, + reasons: [pageValidation.reason], rows: [], args }}); }} - // 3. Data fetching - const reasons = []; - let rawData = null; try {{ - rawData = await (deps.queryData ? deps.queryData(args) : Promise.resolve([])); + const raw = await deps.{query_function_name}(args); + const extracted = safeGet(raw, RESPONSE_PATH) ?? raw; + const rows = normalizeRows(extracted); + const reasons = rows.length === 0 && Array.isArray(extracted) && extracted.length > 0 + ? ['row_normalization_partial'] + : []; + return buildArtifact({{ rows, reasons, args }}); }} catch (error) {{ return buildArtifact({{ status: 'error', @@ -518,42 +1097,36 @@ async function buildBrowserEntrypointResult(args, deps = defaultDeps) {{ args }}); }} - - // 4. Row normalization - const rows = normalizeRows(rawData); - if (rows.length === 0 && Array.isArray(rawData) && rawData.length > 0) {{ - reasons.push('row_normalization_partial'); - }} - - // 5. Build artifact - return buildArtifact({{ reasons, rows, args }}); }} if (typeof module !== 'undefined') {{ - module.exports = {{ buildBrowserEntrypointResult, normalizePayload, validateArgs, buildRequest, normalizeRows, buildArtifact, determineArtifactStatus, API_ENDPOINTS, STATIC_PARAMS, COLUMN_DEFS, COLUMNS, REPORT_NAME }}; + module.exports = {{ buildBrowserEntrypointResult, buildRequest, normalizeRows, buildArtifact, determineArtifactStatus }}; }} 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 + report_name = serde_json::to_string(&scene_ir.scene_name).unwrap_or_else(|_| "\"report\"".to_string()), + endpoints = endpoints, + static_params = static_params, + column_defs = column_defs, + request_template = request_template, + response_path = response_path, + normalize_rules = normalize_rules, + query_function_name = query_function_name ) } -fn browser_script_with_modes(scene_id: &str, scene_info: &SceneInfoJson) -> String { - let modes_json = serde_json::to_string_pretty(&scene_info.modes).unwrap_or_else(|_| "[]".to_string()); - let default_mode = scene_info.default_mode.as_deref().unwrap_or("month"); - let mode_switch_field = scene_info.mode_switch_field.as_deref().unwrap_or("period_mode"); - - format!(r#"const REPORT_NAME = '{scene_id}'; +fn compile_multi_mode_request(scene_ir: &SceneIr) -> String { + let modes_json = serde_json::to_string_pretty(&scene_ir.modes).unwrap_or_else(|_| "[]".to_string()); + let default_mode = scene_ir.default_mode.as_deref().unwrap_or("month"); + let mode_switch_field = scene_ir.mode_switch_field.as_deref().unwrap_or("period_mode"); + format!( + r#"const REPORT_NAME = {report_name}; const MODES = {modes_json}; -const DEFAULT_MODE = '{default_mode}'; -const MODE_SWITCH_FIELD = '{mode_switch_field}'; +const DEFAULT_MODE = {default_mode}; +const MODE_SWITCH_FIELD = {mode_switch_field}; function normalizePayload(payload) {{ if (typeof payload === 'string') {{ @@ -562,76 +1135,60 @@ function normalizePayload(payload) {{ 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 safeGet(obj, path) {{ + if (!path) return obj; + return path.split('.').reduce((acc, key) => (acc && acc[key] !== undefined ? acc[key] : undefined), obj); }} -function detectMode(args) {{ - if (!MODES || MODES.length === 0) {{ - throw new Error('No modes configured for this scene'); - }} - const modeValue = args[MODE_SWITCH_FIELD] || DEFAULT_MODE; - return MODES.find(m => m.condition.value === modeValue) || MODES[0]; +function resolveTemplateValue(value, args) {{ + if (typeof value !== 'string') return value; + const match = value.match(/^\$\{{args\.(\w+)}}$/); + if (match) return args[match[1]]; + return value; }} function buildModeRequest(args, mode) {{ const endpoint = mode.apiEndpoint; const template = mode.requestTemplate || {{}}; const contentType = endpoint.contentType || 'application/json'; - const url = endpoint.url; - const method = endpoint.method || 'POST'; - - // Safe template resolver - supports args.fieldName and args['fieldName'] - function resolveTemplateValue(value) {{ - if (typeof value !== 'string') return value; - if (!value.startsWith('${{') || !value.endsWith('}}')) return value; - const expr = value.slice(2, -1).trim(); - // Support: args.fieldName - const dotMatch = expr.match(/^args\\.(\w+)$/); - if (dotMatch) return args[dotMatch[1]]; - // Support: args['fieldName'] - const bracketMatch = expr.match(/^args\['(\w+)'\]$/); - if (bracketMatch) return args[bracketMatch[1]]; - // Fallback: return raw value - return value; + const requestBody = {{}}; + for (const [key, value] of Object.entries(template)) {{ + requestBody[key] = resolveTemplateValue(value, args); }} - - let body; - if (contentType === 'application/x-www-form-urlencoded') {{ - body = {{}}; - for (const [key, value] of Object.entries(template)) {{ - body[key] = resolveTemplateValue(value); - }} - if (!body.orgno) body.orgno = args.org_code; - }} else {{ - body = JSON.stringify({{ ...template, ...args }}); + for (const [key, value] of Object.entries(args)) {{ + if (value !== undefined && value !== null && value !== '') requestBody[key] = value; }} - - return {{ url, method, headers: {{ 'Content-Type': contentType }}, body }}; + const body = contentType === 'application/x-www-form-urlencoded' + ? Object.entries(requestBody).map(([key, value]) => encodeURIComponent(key) + '=' + encodeURIComponent(String(value))).join('&') + : JSON.stringify(requestBody); + return {{ + url: endpoint.url, + method: (endpoint.method || 'POST').toUpperCase(), + headers: {{ 'Content-Type': contentType }}, + body + }}; }} -function normalizeModeRows(data, mode) {{ - const rules = mode.normalizeRules || {{ type: 'validate_all_columns', filterNull: true }}; - const columns = mode.columnDefs.map(([key]) => key); +function detectMode(args) {{ + const requested = args[MODE_SWITCH_FIELD] || DEFAULT_MODE; + return MODES.find((mode) => mode?.condition?.value === requested) || MODES[0]; +}} - if (!Array.isArray(data)) return []; - - return data.map(row => {{ - const result = {{}}; - for (const key of columns) {{ - const v = row[key]; - result[key] = (v === null || v === undefined || v === '') ? '' : String(v).trim(); +function normalizeRows(rawRows, mode) {{ + const rows = Array.isArray(rawRows) ? rawRows : []; + const defs = mode.columnDefs || []; + const required = mode.normalizeRules?.requiredFields || []; + const filterNull = mode.normalizeRules?.filterNull !== false; + return rows.map((row) => {{ + const normalized = {{}}; + for (const [key] of defs) {{ + normalized[key] = row?.[key] ?? ''; }} - return result; - }}).filter(row => {{ - if (!rules.filterNull) return true; - if (rules.type === 'validate_required' && rules.requiredFields) {{ - return rules.requiredFields.every(f => row[f] !== ''); - }} - return columns.every(k => row[k] !== ''); + return normalized; + }}).filter((row) => {{ + if (!filterNull) return true; + if (required.length > 0) return required.every((field) => row[field] !== ''); + return Object.values(row).some((value) => value !== ''); }}); }} @@ -643,117 +1200,83 @@ function determineArtifactStatus({{ blockedReason = '', fatalError = '', reasons return 'ok'; }} -function buildArtifact({{ status, blockedReason = '', fatalError = '', reasons = [], rows = [], args, columnDefs, columns }}) {{ +function buildArtifact({{ status, blockedReason = '', fatalError = '', reasons = [], rows = [], args, mode }}) {{ + const defs = mode?.columnDefs || []; return {{ type: 'report-artifact', report_name: REPORT_NAME, status: status || determineArtifactStatus({{ blockedReason, fatalError, reasons, rows }}), period: {{ - mode: args.period_mode, - mode_code: args.period_mode_code, - value: args.period_value, + 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: columnDefs || [], - columns: columns || [], + org: {{ label: args.org_label || '', code: args.org_code || '' }}, + column_defs: defs, + columns: defs.map(([key]) => key), rows, counts: {{ detail_rows: rows.length }}, - partial_reasons: reasons.filter(r => r && !r.startsWith('api_') && !r.startsWith('validation_')), - reasons: Array.from(new Set(reasons.filter(Boolean))) + partial_reasons: reasons, + reasons }}; }} const defaultDeps = {{ validatePageContext(args) {{ - const host = (globalThis.location?.hostname || '').trim(); const expected = (args.expected_domain || '').trim(); + const host = (globalThis.location?.hostname || '').trim(); + if (!expected) return {{ ok: true }}; if (!host) return {{ ok: false, reason: 'page_context_unavailable' }}; if (host !== expected) return {{ ok: false, reason: 'page_context_mismatch' }}; return {{ ok: true }}; }}, async queryModeData(args, mode) {{ - const endpoint = mode.apiEndpoint; const request = buildModeRequest(args, mode); - const contentType = endpoint.contentType || 'application/json'; - - // Prefer jQuery if (typeof $ !== 'undefined' && typeof $.ajax === 'function') {{ return new Promise((resolve, reject) => {{ $.ajax({{ url: request.url, type: request.method, data: request.body, - contentType: contentType, + contentType: request.headers['Content-Type'], dataType: 'json', success: resolve, - error: (xhr, status, err) => reject(new Error( - `API failed (${{xhr.status}}): ${{err}} | body=${{(xhr.responseText || '').substring(0, 200)}}` - )) + error: (xhr, status, err) => reject(new Error(`API failed (${{xhr.status}}): ${{err}}`)) }}); }}); }} - - // Fallback: fetch - if (typeof fetch === 'function') {{ - const response = await fetch(request.url, {{ - method: request.method, - headers: request.headers, - body: request.method !== 'GET' ? request.body : undefined - }}); - if (!response.ok) {{ - const text = await response.text().catch(() => ''); - throw new Error(`HTTP ${{response.status}}: ${{text.substring(0, 200)}}`); - }} - return response.json(); - }} - - throw new Error('No HTTP client available (need jQuery or fetch)'); + const response = await fetch(request.url, {{ + method: request.method, + headers: request.headers, + body: request.method === 'GET' ? undefined : request.body + }}); + if (!response.ok) throw new Error(`HTTP ${{response.status}}`); + return response.json(); }} }}; async function buildBrowserEntrypointResult(args, deps = defaultDeps) {{ - // 1. Parameter validation - const validation = validateArgs(args); - if (!validation.valid) {{ - const mode = detectMode(args); - return buildArtifact({{ - status: 'blocked', - blockedReason: 'validation_failed', - reasons: validation.errors, - rows: [], - args, - columnDefs: mode.columnDefs, - columns: mode.columnDefs.map(([key]) => key) - }}); - }} - - // 2. Page context validation + const mode = detectMode(args); const pageValidation = typeof deps.validatePageContext === 'function' ? deps.validatePageContext(args) : {{ ok: true }}; - if (!pageValidation?.ok) {{ - const mode = detectMode(args); + if (!pageValidation.ok) {{ return buildArtifact({{ status: 'blocked', - blockedReason: pageValidation?.reason || 'page_context_mismatch', - reasons: [pageValidation?.reason || 'page_context_mismatch'], + blockedReason: pageValidation.reason, + reasons: [pageValidation.reason], rows: [], args, - columnDefs: mode.columnDefs, - columns: mode.columnDefs.map(([key]) => key) + mode }}); }} - - // 3. Detect mode - const mode = detectMode(args); - - // 4. Data fetching - const reasons = []; - let rawData = null; try {{ - rawData = await (deps.queryModeData ? deps.queryModeData(args, mode) : Promise.resolve([])); + const raw = await deps.queryModeData(args, mode); + const extracted = safeGet(raw, mode.responsePath || '') ?? raw; + const rows = normalizeRows(extracted, mode); + return buildArtifact({{ rows, reasons: [], args, mode }}); }} catch (error) {{ return buildArtifact({{ status: 'error', @@ -761,51 +1284,273 @@ async function buildBrowserEntrypointResult(args, deps = defaultDeps) {{ reasons: ['api_query_failed:' + error.message], rows: [], args, - columnDefs: mode.columnDefs, - columns: mode.columnDefs.map(([key]) => key) + mode }}); }} - - // 5. Extract response data - const responsePath = mode.responsePath || ''; - let data = rawData; - if (responsePath && rawData) {{ - data = rawData[responsePath] || rawData; - }} - - // 6. Row normalization - const rows = normalizeModeRows(data, mode); - if (rows.length === 0 && Array.isArray(data) && data.length > 0) {{ - reasons.push('row_normalization_partial'); - }} - - // 7. Build artifact - return buildArtifact({{ - reasons, - rows, - args, - columnDefs: mode.columnDefs, - columns: mode.columnDefs.map(([key]) => key) - }}); }} if (typeof module !== 'undefined') {{ - module.exports = {{ buildBrowserEntrypointResult, normalizePayload, validateArgs, detectMode, buildModeRequest, normalizeModeRows, buildArtifact, determineArtifactStatus, MODES, REPORT_NAME }}; + module.exports = {{ buildBrowserEntrypointResult, detectMode, buildModeRequest }}; }} if (typeof args !== 'undefined') {{ return buildBrowserEntrypointResult(args); }} -"#, scene_id = scene_id, modes_json = modes_json, default_mode = default_mode, mode_switch_field = mode_switch_field) +"#, + report_name = serde_json::to_string(&scene_ir.scene_name).unwrap_or_else(|_| "\"report\"".to_string()), + modes_json = modes_json, + default_mode = serde_json::to_string(default_mode).unwrap_or_else(|_| "\"month\"".to_string()), + mode_switch_field = serde_json::to_string(mode_switch_field).unwrap_or_else(|_| "\"period_mode\"".to_string()) + ) } -fn browser_script_test(tool_name: &str, _analysis: &SceneSourceAnalysis) -> String { +fn compile_paginated_enrichment(scene_ir: &SceneIr) -> String { + let endpoints = serde_json::to_string_pretty(&scene_ir.api_endpoints).unwrap_or_else(|_| "[]".to_string()); + let filter_expr = scene_ir + .workflow_steps + .iter() + .find(|step| step.step_type == "filter") + .and_then(|step| step.expr.clone()) + .unwrap_or_default() + .replace("item.", "row."); + let response_path = serde_json::to_string(&scene_ir.response_path).unwrap_or_else(|_| "\"\"".to_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 + r#"const REPORT_NAME = {report_name}; +const API_ENDPOINTS = {endpoints}; +const RESPONSE_PATH = {response_path}; +const FILTER_EXPR = {filter_expr}; + +function safeGet(obj, path) {{ + if (!path) return obj; + return path.split('.').reduce((acc, key) => (acc && acc[key] !== undefined ? acc[key] : undefined), obj); +}} + +function buildRequest(endpoint, args, extra = {{}}) {{ + const body = JSON.stringify({{ ...args, ...extra }}); + return {{ + url: endpoint.url, + method: (endpoint.method || 'POST').toUpperCase(), + headers: {{ 'Content-Type': endpoint.contentType || 'application/json' }}, + body + }}; +}} + +function determineArtifactStatus({{ blockedReason = '', fatalError = '', reasons = [], rows = [] }}) {{ + if (blockedReason) return 'blocked'; + if (fatalError) return 'error'; + if (reasons.length > 0) return 'partial'; + if (!rows.length) return 'empty'; + return 'ok'; +}} + +function buildArtifact({{ status, blockedReason = '', fatalError = '', reasons = [], rows = [], args }}) {{ + const defs = rows[0] ? Object.keys(rows[0]).map((key) => [key, key]) : []; + return {{ + type: 'report-artifact', + report_name: REPORT_NAME, + status: status || determineArtifactStatus({{ blockedReason, fatalError, reasons, rows }}), + period: {{ + mode: args.period_mode || '', + mode_code: args.period_mode_code || '', + value: args.period_value || '', + payload: args.period_payload || {{}} + }}, + org: {{ label: args.org_label || '', code: args.org_code || '' }}, + column_defs: defs, + columns: defs.map(([key]) => key), + rows, + counts: {{ detail_rows: rows.length }}, + partial_reasons: reasons, + reasons + }}; +}} + +async function ajaxRequest(request) {{ + if (typeof $ !== 'undefined' && typeof $.ajax === 'function') {{ + return new Promise((resolve, reject) => {{ + $.ajax({{ + url: request.url, + type: request.method, + data: request.body, + contentType: request.headers['Content-Type'], + dataType: 'json', + success: resolve, + error: (xhr, status, err) => reject(new Error(`API failed (${{xhr.status}}): ${{err}}`)) + }}); + }}); + }} + const response = await fetch(request.url, {{ + method: request.method, + headers: request.headers, + body: request.method === 'GET' ? undefined : request.body + }}); + if (!response.ok) throw new Error(`HTTP ${{response.status}}`); + return response.json(); +}} + +async function queryPrimaryPage(args, page) {{ + const endpoint = API_ENDPOINTS[0]; + if (!endpoint) throw new Error('Primary endpoint missing'); + const request = buildRequest(endpoint, args, {{ page }}); + return ajaxRequest(request); +}} + +async function querySecondary(args, row) {{ + const endpoint = API_ENDPOINTS[1]; + if (!endpoint) return row; + const request = buildRequest(endpoint, args, row); + const response = await ajaxRequest(request); + const detail = safeGet(response, RESPONSE_PATH) ?? response; + if (Array.isArray(detail)) return {{ ...row, ...(detail[0] || {{}}) }}; + if (detail && typeof detail === 'object') return {{ ...row, ...detail }}; + return row; +}} + +function applyFilter(row) {{ + if (!FILTER_EXPR) return true; + try {{ + return Function('row', `return (${{FILTER_EXPR}});`)(row); + }} catch (_) {{ + return true; + }} +}} + +async function buildBrowserEntrypointResult(args) {{ + const reasons = []; + const baseRows = []; + try {{ + for (let page = 1; page <= 50; page += 1) {{ + const response = await queryPrimaryPage(args, page); + const extracted = safeGet(response, RESPONSE_PATH) ?? response; + const pageRows = Array.isArray(extracted) ? extracted : []; + if (!pageRows.length) break; + baseRows.push(...pageRows); + if (pageRows.length < 1) break; + }} + const enriched = []; + for (const row of baseRows) {{ + enriched.push(await querySecondary(args, row)); + }} + const rows = enriched.filter(applyFilter); + if (!rows.length && baseRows.length) {{ + reasons.push('filter_or_enrichment_removed_all_rows'); + }} + return buildArtifact({{ rows, reasons, args }}); + }} catch (error) {{ + return buildArtifact({{ + status: 'error', + fatalError: error.message, + reasons: ['api_query_failed:' + error.message], + rows: [], + args + }}); + }} +}} + +if (typeof module !== 'undefined') {{ + module.exports = {{ buildBrowserEntrypointResult, queryPrimaryPage, querySecondary, applyFilter }}; +}} + +if (typeof args !== 'undefined') {{ + return buildBrowserEntrypointResult(args); +}} +"#, + report_name = serde_json::to_string(&scene_ir.scene_name).unwrap_or_else(|_| "\"report\"".to_string()), + endpoints = endpoints, + response_path = response_path, + filter_expr = serde_json::to_string(&filter_expr).unwrap_or_else(|_| "\"\"".to_string()) ) } +fn browser_script_test(tool_name: &str, scene_ir: &SceneIr) -> String { + let mode = scene_ir.default_mode.as_deref().unwrap_or("month"); + format!( + "const assert = require('assert');\nconst {{ buildBrowserEntrypointResult }} = require('./{}.js');\n\n(async () => {{\n const artifact = await buildBrowserEntrypointResult({{\n org_label: '测试单位',\n org_code: 'TEST001',\n period_mode: '{}',\n period_mode_code: '1',\n period_value: '2026-03',\n period_payload: '{{\"fdate\":\"2026-03\"}}',\n expected_domain: ''\n }}, {{\n validatePageContext: () => ({{ ok: true }}),\n queryData: async () => [],\n queryState: async () => [],\n queryModeData: async () => ({{}})\n }});\n assert.equal(artifact.type, 'report-artifact');\n assert.ok(Array.isArray(artifact.column_defs));\n}})().catch((err) => {{\n console.error(err);\n process.exit(1);\n}});\n", + tool_name, mode + ) +} + +fn generation_report_md(scene_ir: &SceneIr) -> String { + let mut lines = vec![ + format!("# Generation Report"), + String::new(), + format!("- Scene: `{}`", scene_ir.scene_id), + format!( + "- Workflow archetype: `{}`", + scene_ir.workflow_archetype().as_str() + ), + format!("- Readiness: `{}`", scene_ir.readiness.level), + format!("- Expected domain: `{}`", scene_ir.bootstrap.expected_domain), + format!("- Target URL: `{}`", scene_ir.bootstrap.target_url), + ]; + if !scene_ir.readiness.risks.is_empty() { + lines.push(String::new()); + lines.push("## Risks".to_string()); + for risk in &scene_ir.readiness.risks { + lines.push(format!("- `{}`", risk)); + } + } + if !scene_ir.readiness.missing_pieces.is_empty() { + lines.push(String::new()); + lines.push("## Missing Pieces".to_string()); + for item in &scene_ir.readiness.missing_pieces { + lines.push(format!("- `{}`", item)); + } + } + if !scene_ir.evidence.is_empty() { + lines.push(String::new()); + lines.push("## Evidence".to_string()); + for evidence in &scene_ir.evidence { + lines.push(format!("- {}", evidence.summary)); + } + } + lines.join("\n") +} + +fn render_toml_string_array(values: &[String]) -> String { + let rendered = values + .iter() + .map(|value| format!("\"{}\"", escape_toml(value))) + .collect::>(); + format!("[{}]", rendered.join(", ")) +} + +fn render_toml_value(value: &Value) -> String { + match value { + Value::String(raw) => format!("\"{}\"", escape_toml(raw)), + Value::Bool(raw) => raw.to_string(), + Value::Number(raw) => raw.to_string(), + Value::Array(items) => { + let rendered = items.iter().map(render_toml_value).collect::>(); + format!("[{}]", rendered.join(", ")) + } + Value::Object(map) => { + let pairs = map + .iter() + .map(|(key, item)| format!("{key}={}", render_toml_value(item))) + .collect::>(); + format!("{{ {} }}", pairs.join(", ")) + } + Value::Null => "\"\"".to_string(), + } +} + +fn escape_toml(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") +} + +fn placeholder_org_dictionary() -> &'static str { + "[]\n" +} + +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 sanitize_ident(value: &str) -> String { value .chars()