use std::path::{Path, PathBuf}; use std::sync::Arc; use serde_json::{Map, Value}; use crate::browser::BrowserBackend; use crate::compat::artifact_open::{open_exported_xlsx, PostExportOpen}; use crate::compat::direct_skill_runtime::DirectSubmitOutcome; use crate::compat::lineloss_xlsx_export::{export_lineloss_xlsx, LinelossExportRequest}; use crate::config::SgClawSettings; use crate::pipe::{BrowserPipeTool, PipeError, Transport}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct DeterministicExecutionPlan { pub instruction: String, pub tool_name: String, pub expected_domain: String, pub target_url: String, pub org_label: String, pub org_code: String, pub period_mode: String, pub period_mode_code: String, pub period_value: String, pub period_payload: String, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum DeterministicSubmitDecision { NotDeterministic, Prompt { summary: String }, Execute(DeterministicExecutionPlan), } const DETERMINISTIC_SUFFIX: &str = "。。。"; const LINELLOSS_HOST: &str = "20.76.57.61"; const LINELLOSS_TARGET_URL: &str = "http://20.76.57.61:18080/gsllys/tqLinelossStatis/tqQualifyRateMonitor"; const LINELLOSS_TOOL: &str = "tq-lineloss-report.collect_lineloss"; pub fn decide_deterministic_submit( raw_instruction: &str, page_url: Option<&str>, page_title: Option<&str>, ) -> DeterministicSubmitDecision { let Some(instruction) = strip_exact_deterministic_suffix(raw_instruction) else { return DeterministicSubmitDecision::NotDeterministic; }; let normalized_instruction = instruction.trim(); if normalized_instruction.is_empty() { return unsupported_scene_prompt(); } if !matches_lineloss_scene(normalized_instruction) { return unsupported_scene_prompt(); } let resolved_org = match crate::compat::tq_lineloss::org_resolver::resolve_org_from_instruction( normalized_instruction, ) { Ok(Some(resolved_org)) => resolved_org, Ok(None) => { return DeterministicSubmitDecision::Prompt { summary: crate::compat::tq_lineloss::contracts::missing_company_prompt(), }; } Err(summary) => { return DeterministicSubmitDecision::Prompt { summary }; } }; let resolved_period = match crate::compat::tq_lineloss::period_resolver::resolve_period( normalized_instruction, ) { Ok(resolved_period) => resolved_period, Err(summary) => { return DeterministicSubmitDecision::Prompt { summary }; } }; if page_context_conflicts_with_lineloss(page_url, page_title) { return DeterministicSubmitDecision::Prompt { summary: "已命中台区线损报表技能,但当前页面与台区线损场景不匹配,请切换到线损页面后重试。" .to_string(), }; } DeterministicSubmitDecision::Execute(DeterministicExecutionPlan { instruction: normalized_instruction.to_string(), tool_name: LINELLOSS_TOOL.to_string(), expected_domain: LINELLOSS_HOST.to_string(), target_url: LINELLOSS_TARGET_URL.to_string(), org_label: resolved_org.label, org_code: resolved_org.code, period_mode: period_mode_name(&resolved_period.mode).to_string(), period_mode_code: resolved_period.mode_code, period_value: resolved_period.value, period_payload: serde_json::to_string(&resolved_period.payload) .unwrap_or_else(|_| "{}".to_string()), }) } pub fn execute_deterministic_submit( browser_tool: BrowserPipeTool, plan: &DeterministicExecutionPlan, workspace_root: &Path, settings: &SgClawSettings, ) -> Result { let args = deterministic_submit_args(plan); let output = crate::compat::direct_skill_runtime::execute_browser_script_skill_raw_output( browser_tool, &plan.tool_name, workspace_root, settings, args, )?; let export_path = try_export_lineloss_xlsx(&output, workspace_root); Ok(summarize_lineloss_output_with_export(&output, export_path.as_deref())) } pub fn execute_deterministic_submit_with_browser_backend( browser_backend: Arc, plan: &DeterministicExecutionPlan, workspace_root: &Path, settings: &SgClawSettings, ) -> Result { let args = deterministic_submit_args(plan); let output = crate::compat::direct_skill_runtime::execute_browser_script_skill_raw_output_with_browser_backend( browser_backend, &plan.tool_name, workspace_root, settings, args, )?; let export_path = try_export_lineloss_xlsx(&output, workspace_root); Ok(summarize_lineloss_output_with_export(&output, export_path.as_deref())) } fn deterministic_submit_args(plan: &DeterministicExecutionPlan) -> Map { let mut args = Map::new(); args.insert( "expected_domain".to_string(), Value::String(plan.expected_domain.clone()), ); args.insert( "target_url".to_string(), Value::String(plan.target_url.clone()), ); args.insert( "org_label".to_string(), Value::String(plan.org_label.clone()), ); args.insert( "org_code".to_string(), Value::String(plan.org_code.clone()), ); args.insert( "period_mode".to_string(), Value::String(plan.period_mode.clone()), ); args.insert( "period_mode_code".to_string(), Value::String(plan.period_mode_code.clone()), ); args.insert( "period_value".to_string(), Value::String(plan.period_value.clone()), ); args.insert( "period_payload".to_string(), Value::String(plan.period_payload.clone()), ); args } fn summarize_lineloss_output(output: &str) -> DirectSubmitOutcome { let Some(payload) = serde_json::from_str::(output).ok() else { return DirectSubmitOutcome { success: true, summary: output.to_string(), }; }; let artifact = payload .as_object() .and_then(|object| object.get("text")) .unwrap_or(&payload); summarize_lineloss_artifact(artifact) } fn summarize_lineloss_artifact(artifact: &Value) -> DirectSubmitOutcome { let Some(artifact) = artifact.as_object() else { return DirectSubmitOutcome { success: true, summary: artifact.to_string(), }; }; if artifact.get("type").and_then(Value::as_str) != Some("report-artifact") { return DirectSubmitOutcome { success: true, summary: Value::Object(artifact.clone()).to_string(), }; } let status = artifact .get("status") .and_then(Value::as_str) .unwrap_or("ok"); let success = matches!(status, "ok" | "partial" | "empty"); let report_name = artifact .get("report_name") .and_then(Value::as_str) .unwrap_or("tq-lineloss-report"); let org_label = artifact .get("org") .and_then(Value::as_object) .and_then(|org| org.get("label")) .and_then(Value::as_str) .unwrap_or(""); let period_value = artifact .get("period") .and_then(Value::as_object) .and_then(|period| period.get("value")) .and_then(Value::as_str) .unwrap_or(""); let rows = artifact .get("counts") .and_then(Value::as_object) .and_then(|counts| counts.get("rows")) .and_then(Value::as_u64) .map(|value| value as usize) .or_else(|| artifact.get("rows").and_then(Value::as_array).map(Vec::len)) .unwrap_or(0); let reasons = artifact .get("reasons") .and_then(Value::as_array) .map(|reasons| { reasons .iter() .filter_map(Value::as_str) .filter(|value| !value.trim().is_empty()) .collect::>() }) .unwrap_or_default(); let mut parts = vec![report_name.to_string()]; if !org_label.is_empty() { parts.push(org_label.to_string()); } if !period_value.is_empty() { parts.push(period_value.to_string()); } parts.push(format!("status={status}")); parts.push(format!("rows={rows}")); if !reasons.is_empty() { parts.push(format!("reasons={}", reasons.join(","))); } DirectSubmitOutcome { success, summary: parts.join(" "), } } fn summarize_lineloss_output_with_export(output: &str, export_path: Option<&Path>) -> DirectSubmitOutcome { let mut outcome = summarize_lineloss_output(output); if let Some(path) = export_path { outcome.summary.push_str(&format!(" export_path={}", path.display())); match open_exported_xlsx(path) { PostExportOpen::Opened => { outcome.summary.push_str(" 已自动打开Excel"); } PostExportOpen::Failed(reason) => { outcome.summary.push_str(&format!(" 自动打开Excel失败: {}", reason)); } } } outcome } struct LinelossArtifactExportData { sheet_name: String, column_defs: Vec<(String, String)>, rows: Vec>, } fn extract_export_data(output: &str) -> Option { let payload: Value = serde_json::from_str(output).ok()?; let artifact = payload .as_object() .and_then(|object| object.get("text")) .unwrap_or(&payload); let artifact = artifact.as_object()?; if artifact.get("type").and_then(Value::as_str) != Some("report-artifact") { return None; } let status = artifact.get("status").and_then(Value::as_str).unwrap_or(""); if !matches!(status, "ok" | "partial") { return None; } let rows = artifact .get("rows") .and_then(Value::as_array)?; if rows.is_empty() { return None; } let rows: Vec> = rows .iter() .filter_map(|row| row.as_object().cloned()) .collect(); if rows.is_empty() { return None; } let column_defs: Vec<(String, String)> = artifact .get("column_defs") .and_then(Value::as_array) .map(|defs| { defs.iter() .filter_map(|def| { let arr = def.as_array()?; let key = arr.first()?.as_str()?.to_string(); let label = arr.get(1)?.as_str()?.to_string(); Some((key, label)) }) .collect() }) .unwrap_or_default(); // Fallback: if column_defs not in artifact, try "columns" array as keys let column_defs = if column_defs.is_empty() { let columns = artifact .get("columns") .and_then(Value::as_array)?; columns .iter() .filter_map(|col| { let key = col.as_str()?.to_string(); Some((key.clone(), key)) }) .collect() } else { column_defs }; if column_defs.is_empty() { return None; } let org_label = artifact .get("org") .and_then(Value::as_object) .and_then(|org| org.get("label")) .and_then(Value::as_str) .unwrap_or("lineloss"); let period_mode = artifact .get("period") .and_then(Value::as_object) .and_then(|p| p.get("mode")) .and_then(Value::as_str) .unwrap_or("month"); let period_value = artifact .get("period") .and_then(Value::as_object) .and_then(|p| p.get("value")) .and_then(Value::as_str) .unwrap_or(""); let mode_label = if period_mode == "week" { "周度" } else { "月度" }; let sheet_name = format!("{org_label}{mode_label}线损分析报表({period_value})"); Some(LinelossArtifactExportData { sheet_name, column_defs, rows, }) } fn try_export_lineloss_xlsx( output: &str, workspace_root: &Path, ) -> Option { let data = extract_export_data(output)?; let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or_default(); let out_dir = workspace_root.join("out"); let output_path = out_dir.join(format!("tq-lineloss-{nanos}.xlsx")); let request = LinelossExportRequest { sheet_name: data.sheet_name, column_defs: data.column_defs, rows: data.rows, output_path, }; match export_lineloss_xlsx(&request) { Ok(path) => { eprintln!("[deterministic_submit] XLSX exported to: {}", path.display()); Some(path) } Err(err) => { eprintln!("[deterministic_submit] XLSX export failed: {err}"); None } } } fn strip_exact_deterministic_suffix(raw_instruction: &str) -> Option<&str> { let without_suffix = raw_instruction.strip_suffix(DETERMINISTIC_SUFFIX)?; if without_suffix.ends_with('。') { return None; } Some(without_suffix) } fn matches_lineloss_scene(instruction: &str) -> bool { instruction.contains("线损") || instruction.contains("月累计") || instruction.contains("周累计") } fn page_context_conflicts_with_lineloss(page_url: Option<&str>, page_title: Option<&str>) -> bool { let url = page_url.unwrap_or_default().to_ascii_lowercase(); let title = page_title.unwrap_or_default(); let has_context = !url.is_empty() || !title.is_empty(); if !has_context { return false; } let url_matches = url.contains(LINELLOSS_HOST) || url.contains("lineloss"); let title_matches = title.contains("线损"); !(url_matches || title_matches) } fn period_mode_name(mode: &crate::compat::tq_lineloss::contracts::PeriodMode) -> &'static str { match mode { crate::compat::tq_lineloss::contracts::PeriodMode::Month => "month", crate::compat::tq_lineloss::contracts::PeriodMode::Week => "week", } } fn unsupported_scene_prompt() -> DeterministicSubmitDecision { DeterministicSubmitDecision::Prompt { summary: "确定性提交当前只支持台区线损月/周累计线损率报表场景,请补充台区线损请求。" .to_string(), } }