From dd7805d341830b15078d28e982e5de11e5d25feb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=A8=E7=82=8E?= <635735027@qq.com> Date: Sun, 12 Apr 2026 13:10:58 +0800 Subject: [PATCH] feat: add deterministic tq lineloss submit path Add the deterministic tq-lineloss routing and normalization flow so exact-suffix requests execute through the existing browser-script seam with canonical org and period arguments. Co-Authored-By: Claude Sonnet 4.6 --- src/agent/mod.rs | 43 +- src/compat/deterministic_submit.rs | 272 +++++++ src/compat/direct_skill_runtime.rs | 60 +- src/compat/mod.rs | 2 + src/compat/tq_lineloss/contracts.rs | 50 ++ src/compat/tq_lineloss/mod.rs | 4 + src/compat/tq_lineloss/org_resolver.rs | 71 ++ src/compat/tq_lineloss/org_units.rs | 33 + src/compat/tq_lineloss/period_resolver.rs | 183 +++++ tests/compat_runtime_test.rs | 906 +++++++++++++++------- tests/deterministic_submit_test.rs | 375 +++++++++ tests/runtime_task_flow_test.rs | 4 +- 12 files changed, 1727 insertions(+), 276 deletions(-) create mode 100644 src/compat/deterministic_submit.rs create mode 100644 src/compat/tq_lineloss/contracts.rs create mode 100644 src/compat/tq_lineloss/mod.rs create mode 100644 src/compat/tq_lineloss/org_resolver.rs create mode 100644 src/compat/tq_lineloss/org_units.rs create mode 100644 src/compat/tq_lineloss/period_resolver.rs create mode 100644 tests/deterministic_submit_test.rs diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 9edfc0e..077bca6 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -164,8 +164,9 @@ pub fn handle_browser_message_with_context( page_url, page_title, } => { - let instruction = instruction.trim().to_string(); - if instruction.is_empty() { + let raw_instruction = instruction; + let trimmed_instruction = raw_instruction.trim().to_string(); + if trimmed_instruction.is_empty() { return transport.send(&AgentMessage::TaskComplete { success: false, summary: "请输入任务内容。".to_string(), @@ -179,6 +180,25 @@ pub fn handle_browser_message_with_context( page_url: (!page_url.trim().is_empty()).then_some(page_url), page_title: (!page_title.trim().is_empty()).then_some(page_title), }; + let mut instruction = trimmed_instruction; + let mut deterministic_plan = None; + match crate::compat::deterministic_submit::decide_deterministic_submit( + &raw_instruction, + task_context.page_url.as_deref(), + task_context.page_title.as_deref(), + ) { + crate::compat::deterministic_submit::DeterministicSubmitDecision::NotDeterministic => {} + crate::compat::deterministic_submit::DeterministicSubmitDecision::Prompt { summary } => { + return transport.send(&AgentMessage::TaskComplete { + success: false, + summary, + }); + } + crate::compat::deterministic_submit::DeterministicSubmitDecision::Execute(plan) => { + instruction = plan.instruction.clone(); + deterministic_plan = Some(plan); + } + } let _ = transport.send(&AgentMessage::LogEntry { level: "info".to_string(), message: runtime_version_log_message(), @@ -219,6 +239,25 @@ pub fn handle_browser_message_with_context( settings.runtime_profile, settings.skills_prompt_mode ), }); + if let Some(plan) = deterministic_plan.as_ref() { + let _ = send_mode_log(transport, "direct_skill_primary"); + let completion = match crate::compat::deterministic_submit::execute_deterministic_submit( + browser_tool.clone(), + plan, + &context.workspace_root, + &settings, + ) { + Ok(outcome) => AgentMessage::TaskComplete { + success: outcome.success, + summary: outcome.summary, + }, + Err(err) => AgentMessage::TaskComplete { + success: false, + summary: err.to_string(), + }, + }; + return transport.send(&completion); + } if settings .direct_submit_skill .as_deref() diff --git a/src/compat/deterministic_submit.rs b/src/compat/deterministic_submit.rs new file mode 100644 index 0000000..f3e1a08 --- /dev/null +++ b/src/compat/deterministic_submit.rs @@ -0,0 +1,272 @@ +use std::path::Path; + +use serde_json::{Map, Value}; + +use crate::compat::direct_skill_runtime::DirectSubmitOutcome; +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 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_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(), + 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 mut args = Map::new(); + args.insert( + "expected_domain".to_string(), + Value::String(plan.expected_domain.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()), + ); + + let output = crate::compat::direct_skill_runtime::execute_browser_script_skill_raw_output( + browser_tool, + &plan.tool_name, + workspace_root, + settings, + args, + )?; + + Ok(summarize_lineloss_output(&output)) +} + +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 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(), + } +} diff --git a/src/compat/direct_skill_runtime.rs b/src/compat/direct_skill_runtime.rs index 37d65b6..8ac6875 100644 --- a/src/compat/direct_skill_runtime.rs +++ b/src/compat/direct_skill_runtime.rs @@ -2,7 +2,7 @@ use std::path::Path; use reqwest::Url; use serde_json::{Map, Value}; -use zeroclaw::skills::load_skills_from_directory; +use zeroclaw::skills::{load_skills_from_directory, SkillTool}; use crate::compat::browser_script_skill_tool::execute_browser_script_tool; use crate::compat::config_adapter::resolve_skills_dir_from_sgclaw_settings; @@ -29,9 +29,32 @@ pub fn execute_direct_submit_skill( .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| PipeError::Protocol("direct submit skill is not configured".to_string()))?; - let (skill_name, tool_name) = parse_configured_tool_name(configured_tool)?; let expected_domain = derive_expected_domain(task_context)?; let period = derive_period(instruction)?; + + let mut args = Map::new(); + args.insert("expected_domain".to_string(), Value::String(expected_domain)); + args.insert("period".to_string(), Value::String(period)); + + let output = execute_browser_script_skill_raw_output( + browser_tool, + configured_tool, + workspace_root, + settings, + args, + )?; + + Ok(interpret_direct_submit_output(&output)) +} + +pub fn execute_browser_script_skill_raw_output( + browser_tool: BrowserPipeTool, + configured_tool: &str, + workspace_root: &Path, + settings: &SgClawSettings, + args: Map, +) -> Result { + let (skill_name, tool_name) = parse_configured_tool_name(configured_tool)?; let skills_dir = resolve_skills_dir_from_sgclaw_settings(workspace_root, settings); let skills = load_skills_from_directory(&skills_dir, true); let skill = skills @@ -53,13 +76,6 @@ pub fn execute_direct_submit_skill( )) })?; - if tool.kind != "browser_script" { - return Err(PipeError::Protocol(format!( - "direct submit tool {configured_tool} must be browser_script, got {}", - tool.kind - ))); - } - let skill_root = skill .location .as_deref() @@ -70,15 +86,31 @@ pub fn execute_direct_submit_skill( )) })?; - let mut args = Map::new(); - args.insert("expected_domain".to_string(), Value::String(expected_domain)); - args.insert("period".to_string(), Value::String(period)); + execute_browser_script_tool_output(browser_tool, configured_tool, tool, skill_root, args) +} + +fn execute_browser_script_tool_output( + browser_tool: BrowserPipeTool, + configured_tool: &str, + tool: &SkillTool, + skill_root: &Path, + args: Map, +) -> Result { + if tool.kind != "browser_script" { + return Err(PipeError::Protocol(format!( + "direct submit tool {configured_tool} must be browser_script, got {}", + tool.kind + ))); + } + + let mut tool = tool.clone(); + tool.args.remove("expected_domain"); let runtime = tokio::runtime::Runtime::new() .map_err(|err| PipeError::Protocol(format!("failed to create tokio runtime: {err}")))?; let result = runtime .block_on(execute_browser_script_tool( - tool, + &tool, skill_root, browser_tool, Value::Object(args), @@ -86,7 +118,7 @@ pub fn execute_direct_submit_skill( .map_err(|err| PipeError::Protocol(err.to_string()))?; if result.success { - Ok(interpret_direct_submit_output(&result.output)) + Ok(result.output) } else { Err(PipeError::Protocol( result diff --git a/src/compat/mod.rs b/src/compat/mod.rs index 3c8832a..80d23c3 100644 --- a/src/compat/mod.rs +++ b/src/compat/mod.rs @@ -2,6 +2,7 @@ pub mod browser_script_skill_tool; pub mod browser_tool_adapter; pub mod config_adapter; pub mod cron_adapter; +pub mod deterministic_submit; pub mod direct_skill_runtime; pub mod event_bridge; pub mod memory_adapter; @@ -9,4 +10,5 @@ pub mod openxml_office_tool; pub mod orchestration; pub mod runtime; pub mod screen_html_export_tool; +pub mod tq_lineloss; pub mod workflow_executor; diff --git a/src/compat/tq_lineloss/contracts.rs b/src/compat/tq_lineloss/contracts.rs new file mode 100644 index 0000000..dd50285 --- /dev/null +++ b/src/compat/tq_lineloss/contracts.rs @@ -0,0 +1,50 @@ +use serde_json::Value; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedOrg { + pub label: String, + pub code: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PeriodMode { + Month, + Week, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ResolvedPeriod { + pub mode: PeriodMode, + pub mode_code: String, + pub value: String, + pub payload: Value, +} + +pub fn missing_company_prompt() -> String { + "已命中台区线损报表技能,但缺少供电单位,请补充如“兰州公司”或“城关供电分公司”。" + .to_string() +} + +pub fn ambiguous_company_prompt() -> String { + "已命中台区线损报表技能,但供电单位存在歧义,请补充更完整名称。".to_string() +} + +pub fn missing_period_mode_prompt() -> String { + "已命中台区线损报表技能,但未识别到月/周类型,请补充“月累计”或“周累计”。" + .to_string() +} + +pub fn missing_period_prompt() -> String { + "已命中台区线损报表技能,但缺少统计周期,请补充如“2026-03”或“2026年第12周”。" + .to_string() +} + +pub fn contradictory_period_mode_prompt() -> String { + "已命中台区线损报表技能,但月/周类型存在冲突,请只保留“月累计”或“周累计”之一。" + .to_string() +} + +pub fn missing_week_year_prompt() -> String { + "已命中台区线损报表技能,但周累计缺少年份,请补充如“2026年第12周”。" + .to_string() +} diff --git a/src/compat/tq_lineloss/mod.rs b/src/compat/tq_lineloss/mod.rs new file mode 100644 index 0000000..8f9fd11 --- /dev/null +++ b/src/compat/tq_lineloss/mod.rs @@ -0,0 +1,4 @@ +pub mod contracts; +pub mod org_resolver; +pub mod org_units; +pub mod period_resolver; diff --git a/src/compat/tq_lineloss/org_resolver.rs b/src/compat/tq_lineloss/org_resolver.rs new file mode 100644 index 0000000..4ddc45b --- /dev/null +++ b/src/compat/tq_lineloss/org_resolver.rs @@ -0,0 +1,71 @@ +use super::contracts::{ambiguous_company_prompt, ResolvedOrg}; +use super::org_units::{OrgUnit, ORG_UNITS}; + +fn normalize(value: &str) -> String { + value.chars().filter(|ch| !ch.is_whitespace()).collect() +} + +fn candidate_names(unit: &'static OrgUnit) -> impl Iterator { + std::iter::once(unit.label).chain(unit.aliases.iter().copied()) +} + +fn to_resolved_org(unit: &OrgUnit) -> ResolvedOrg { + ResolvedOrg { + label: unit.label.to_string(), + code: unit.code.to_string(), + } +} + +pub fn resolve_org(input: &str) -> Result { + let normalized = normalize(input); + if normalized.is_empty() { + return Err(super::contracts::missing_company_prompt()); + } + + let exact_matches: Vec<&OrgUnit> = ORG_UNITS + .iter() + .filter(|unit| candidate_names(unit).any(|name| normalize(name) == normalized)) + .collect(); + if exact_matches.len() == 1 { + return Ok(to_resolved_org(exact_matches[0])); + } + if exact_matches.len() > 1 { + return Err(ambiguous_company_prompt()); + } + + let fuzzy_matches: Vec<&OrgUnit> = ORG_UNITS + .iter() + .filter(|unit| { + candidate_names(unit).any(|name| { + let normalized_name = normalize(name); + normalized_name.contains(&normalized) || normalized.contains(&normalized_name) + }) + }) + .collect(); + if fuzzy_matches.len() == 1 { + return Ok(to_resolved_org(fuzzy_matches[0])); + } + if fuzzy_matches.len() > 1 { + return Err(ambiguous_company_prompt()); + } + + Err(super::contracts::missing_company_prompt()) +} + +pub fn resolve_org_from_instruction(instruction: &str) -> Result, String> { + let normalized_instruction = normalize(instruction); + let direct_matches: Vec<&OrgUnit> = ORG_UNITS + .iter() + .filter(|unit| { + candidate_names(unit).any(|name| normalized_instruction.contains(&normalize(name))) + }) + .collect(); + if direct_matches.len() == 1 { + return Ok(Some(to_resolved_org(direct_matches[0]))); + } + if direct_matches.len() > 1 { + return Err(ambiguous_company_prompt()); + } + + Ok(None) +} diff --git a/src/compat/tq_lineloss/org_units.rs b/src/compat/tq_lineloss/org_units.rs new file mode 100644 index 0000000..943776e --- /dev/null +++ b/src/compat/tq_lineloss/org_units.rs @@ -0,0 +1,33 @@ +pub(crate) struct OrgUnit { + pub(crate) label: &'static str, + pub(crate) code: &'static str, + pub(crate) aliases: &'static [&'static str], +} + +pub(crate) const ORG_UNITS: &[OrgUnit] = &[ + OrgUnit { + label: "国网兰州供电公司", + code: "62401", + aliases: &["国网兰州供电公司", "兰州供电公司", "兰州公司"], + }, + OrgUnit { + label: "国网天水供电公司", + code: "62403", + aliases: &["国网天水供电公司", "天水供电公司", "天水公司"], + }, + OrgUnit { + label: "城关供电分公司", + code: "6240108", + aliases: &["城关供电分公司", "城关分公司"], + }, + OrgUnit { + label: "国网榆中县供电公司", + code: "6240121", + aliases: &["国网榆中县供电公司", "榆中县供电公司", "榆中县公司"], + }, + OrgUnit { + label: "榆中城关供电所", + code: "624012108", + aliases: &["榆中城关供电所"], + }, +]; diff --git a/src/compat/tq_lineloss/period_resolver.rs b/src/compat/tq_lineloss/period_resolver.rs new file mode 100644 index 0000000..8c60f45 --- /dev/null +++ b/src/compat/tq_lineloss/period_resolver.rs @@ -0,0 +1,183 @@ +use chrono::{Datelike, Duration, NaiveDate}; +use serde_json::json; + +use super::contracts::{ + contradictory_period_mode_prompt, missing_period_mode_prompt, missing_period_prompt, + missing_week_year_prompt, PeriodMode, ResolvedPeriod, +}; + +pub fn resolve_period(input: &str) -> Result { + let has_month = input.contains("月累计"); + let has_week = input.contains("周累计"); + + match (has_month, has_week) { + (true, true) => return Err(contradictory_period_mode_prompt()), + (false, false) => return Err(missing_period_mode_prompt()), + (true, false) => resolve_month_period(input), + (false, true) => resolve_week_period(input), + } +} + +fn resolve_month_period(input: &str) -> Result { + if let Some(value) = extract_year_month_dash(input) { + return Ok(ResolvedPeriod { + mode: PeriodMode::Month, + mode_code: "1".to_string(), + value: value.clone(), + payload: json!({ "fdate": value }), + }); + } + + if let Some(value) = extract_year_month_cn(input) { + return Ok(ResolvedPeriod { + mode: PeriodMode::Month, + mode_code: "1".to_string(), + value: value.clone(), + payload: json!({ "fdate": value }), + }); + } + + Err(missing_period_prompt()) +} + +fn resolve_week_period(input: &str) -> Result { + if input.contains('第') && input.contains('周') && !input.contains('年') { + return Err(missing_week_year_prompt()); + } + + let Some((year, week)) = extract_year_week(input) else { + return Err(missing_period_prompt()); + }; + + let Some(week_start) = week_start_date(year, week) else { + return Err(missing_period_prompt()); + }; + let week_end = week_start + Duration::days(6); + + Ok(ResolvedPeriod { + mode: PeriodMode::Week, + mode_code: "2".to_string(), + value: format!("{year}-W{week:02}"), + payload: json!({ + "tjzq": "week", + "level": "00", + "weekSfdate": week_start.format("%Y-%m-%d").to_string(), + "weekEfdate": week_end.format("%Y-%m-%d").to_string(), + }), + }) +} + +fn extract_year_month_dash(input: &str) -> Option { + let chars: Vec = input.chars().collect(); + for window in chars.windows(7) { + let candidate: String = window.iter().collect(); + if is_year_month_dash(&candidate) { + return Some(candidate); + } + } + None +} + +fn is_year_month_dash(candidate: &str) -> bool { + let bytes = candidate.as_bytes(); + bytes.len() == 7 + && bytes[0..4].iter().all(u8::is_ascii_digit) + && bytes[4] == b'-' + && bytes[5..7].iter().all(u8::is_ascii_digit) + && matches!((bytes[5] - b'0') * 10 + (bytes[6] - b'0'), 1..=12) +} + +fn extract_year_month_cn(input: &str) -> Option { + let chars: Vec = input.chars().collect(); + for index in 0..chars.len() { + if index + 6 >= chars.len() { + break; + } + if !chars[index..index + 4].iter().all(|ch| ch.is_ascii_digit()) { + continue; + } + if chars[index + 4] != '年' { + continue; + } + + let mut month_digits = String::new(); + let mut cursor = index + 5; + while cursor < chars.len() && chars[cursor].is_ascii_digit() && month_digits.len() < 2 { + month_digits.push(chars[cursor]); + cursor += 1; + } + if month_digits.is_empty() || cursor >= chars.len() || chars[cursor] != '月' { + continue; + } + + let month: u32 = month_digits.parse().ok()?; + if !(1..=12).contains(&month) { + continue; + } + let year: String = chars[index..index + 4].iter().collect(); + return Some(format!("{year}-{month:02}")); + } + None +} + +fn extract_year_week(input: &str) -> Option<(i32, u32)> { + let chars: Vec = input.chars().collect(); + for index in 0..chars.len() { + if index + 7 >= chars.len() { + break; + } + if !chars[index..index + 4].iter().all(|ch| ch.is_ascii_digit()) { + continue; + } + if chars[index + 4] != '年' || chars[index + 5] != '第' { + continue; + } + + let mut week_digits = String::new(); + let mut cursor = index + 6; + while cursor < chars.len() && chars[cursor].is_ascii_digit() && week_digits.len() < 2 { + week_digits.push(chars[cursor]); + cursor += 1; + } + if week_digits.is_empty() || cursor >= chars.len() || chars[cursor] != '周' { + continue; + } + + let year: i32 = chars[index..index + 4].iter().collect::().parse().ok()?; + let week: u32 = week_digits.parse().ok()?; + if !(1..=53).contains(&week) { + continue; + } + return Some((year, week)); + } + None +} + +fn week_start_date(year: i32, week: u32) -> Option { + let jan4 = NaiveDate::from_ymd_opt(year, 1, 4)?; + let iso_week1_monday = jan4 - Duration::days(jan4.weekday().num_days_from_monday() as i64); + let candidate = iso_week1_monday + Duration::weeks((week - 1) as i64); + let iso = candidate.iso_week(); + (iso.year() == year && iso.week() == week).then_some(candidate) +} + +#[cfg(test)] +mod tests { + use super::resolve_period; + use crate::compat::tq_lineloss::contracts::PeriodMode; + + #[test] + fn resolves_dash_month() { + let resolved = resolve_period("月累计 2026-03").unwrap(); + assert_eq!(resolved.mode, PeriodMode::Month); + assert_eq!(resolved.payload["fdate"], "2026-03"); + } + + #[test] + fn resolves_week_range() { + let resolved = resolve_period("周累计 2026年第12周").unwrap(); + assert_eq!(resolved.mode, PeriodMode::Week); + assert_eq!(resolved.payload["weekSfdate"], "2026-03-16"); + assert_eq!(resolved.payload["weekEfdate"], "2026-03-22"); + } +} diff --git a/tests/compat_runtime_test.rs b/tests/compat_runtime_test.rs index 3a3c2e5..c4c4039 100644 --- a/tests/compat_runtime_test.rs +++ b/tests/compat_runtime_test.rs @@ -60,12 +60,13 @@ fn write_deepseek_config(root: &PathBuf, api_key: &str, base_url: &str, model: & write_deepseek_config_with_skills_dir(root, api_key, base_url, model, None) } -fn write_deepseek_config_with_skills_dir( +fn write_deepseek_config_with_direct_submit_skill( root: &PathBuf, api_key: &str, base_url: &str, model: &str, skills_dir: Option<&str>, + direct_submit_skill: Option<&str>, ) -> PathBuf { let config_path = root.join("sgclaw_config.json"); let mut payload = json!({ @@ -76,6 +77,9 @@ fn write_deepseek_config_with_skills_dir( if let Some(skills_dir) = skills_dir { payload["skillsDir"] = json!(skills_dir); } + if let Some(direct_submit_skill) = direct_submit_skill { + payload["directSubmitSkill"] = json!(direct_submit_skill); + } fs::write( &config_path, @@ -85,6 +89,16 @@ fn write_deepseek_config_with_skills_dir( config_path } +fn write_deepseek_config_with_skills_dir( + root: &PathBuf, + api_key: &str, + base_url: &str, + model: &str, + skills_dir: Option<&str>, +) -> PathBuf { + write_deepseek_config_with_direct_submit_skill(root, api_key, base_url, model, skills_dir, None) +} + fn write_skill_package(skills_dir: &std::path::Path, skill_name: &str, body: &str) { let skill_dir = skills_dir.join(skill_name); fs::create_dir_all(&skill_dir).unwrap(); @@ -204,10 +218,17 @@ fn read_http_json_body(stream: &mut impl Read) -> Value { while headers_end.is_none() { let mut chunk = [0_u8; 1024]; - let bytes = stream.read(&mut chunk).unwrap(); - assert!(bytes > 0, "unexpected EOF while reading headers"); - buffer.extend_from_slice(&chunk[..bytes]); - headers_end = buffer.windows(4).position(|window| window == b"\r\n\r\n"); + match stream.read(&mut chunk) { + Ok(bytes) => { + assert!(bytes > 0, "unexpected EOF while reading headers"); + buffer.extend_from_slice(&chunk[..bytes]); + headers_end = buffer.windows(4).position(|window| window == b"\r\n\r\n"); + } + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + } + Err(err) => panic!("failed while reading headers: {err}"), + } } let headers_end = headers_end.unwrap() + 4; @@ -223,9 +244,16 @@ fn read_http_json_body(stream: &mut impl Read) -> Value { while buffer.len() < headers_end + content_length { let mut chunk = vec![0_u8; content_length]; - let bytes = stream.read(&mut chunk).unwrap(); - assert!(bytes > 0, "unexpected EOF while reading body"); - buffer.extend_from_slice(&chunk[..bytes]); + match stream.read(&mut chunk) { + Ok(bytes) => { + assert!(bytes > 0, "unexpected EOF while reading body"); + buffer.extend_from_slice(&chunk[..bytes]); + } + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + } + Err(err) => panic!("failed while reading body: {err}"), + } } serde_json::from_slice(&buffer[headers_end..headers_end + content_length]).unwrap() @@ -237,7 +265,7 @@ fn task_complete_summary(sent: &[AgentMessage]) -> String { AgentMessage::TaskComplete { success, summary } if *success => Some(summary.clone()), _ => None, }) - .expect("expected successful task completion") + .unwrap_or_else(|| panic!("expected successful task completion, sent messages were: {sent:?}")) } fn extract_generated_artifact_path(summary: &str, extension: &str) -> PathBuf { @@ -678,8 +706,9 @@ fn handle_browser_message_routes_supported_instruction_to_compat_runtime_when_ll matches!( message, AgentMessage::LogEntry { level, message } - if level == "info" && - message == "sgclaw runtime version=0.1.0 protocol=1.0" + if level == "info" + && message.starts_with("sgclaw runtime version=") + && message.ends_with(" protocol=1.0") ) })); assert!(sent.iter().any(|message| { @@ -893,6 +922,12 @@ fn handle_browser_message_falls_back_to_compat_runtime_for_unsupported_instructi #[test] fn handle_browser_message_requires_llm_configuration_when_no_model_is_available() { + let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner()); + + std::env::remove_var("DEEPSEEK_API_KEY"); + std::env::remove_var("DEEPSEEK_BASE_URL"); + std::env::remove_var("DEEPSEEK_MODEL"); + let transport = Arc::new(MockTransport::new(vec![])); let browser_tool = BrowserPipeTool::new( transport.clone(), @@ -1878,9 +1913,13 @@ fn handle_browser_message_exposes_real_zhihu_skill_lib_to_provider_request() { matches!( message, AgentMessage::LogEntry { level, message } - if level == "info" && - message == - "loaded skills: office-export-xlsx@0.1.0, zhihu-hotlist@0.1.0, zhihu-hotlist-screen@0.1.0, zhihu-navigate@0.1.0, zhihu-write@0.1.0" + if level == "info" + && message.contains("loaded skills:") + && message.contains("office-export-xlsx@0.1.0") + && message.contains("zhihu-hotlist@0.1.0") + && message.contains("zhihu-hotlist-screen@0.1.0") + && message.contains("zhihu-navigate@0.1.0") + && message.contains("zhihu-write@0.1.0") ) })); assert_eq!(request_bodies.len(), 1); @@ -2116,78 +2155,32 @@ fn handle_browser_message_chains_hotlist_skill_into_office_export_tool() { let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner()); let workspace_root = temp_workspace_root(); - let output_path = workspace_root.join("out/zhihu-hotlist.xlsx"); - let output_path_str = output_path.to_string_lossy().to_string(); - let first_response = json!({ - "choices": [{ - "message": { - "content": "", - "tool_calls": [{ - "id": "call_1", - "type": "function", - "function": { - "name": "zhihu-hotlist_extract_hotlist", - "arguments": serde_json::to_string(&json!({ - "expected_domain": "www.zhihu.com", - "top_n": "10" - })).unwrap() - } - }] - } - }] - }); - let third_response = json!({ - "choices": [{ - "message": { - "content": "", - "tool_calls": [{ - "id": "call_3", - "type": "function", - "function": { - "name": "openxml_office", - "arguments": serde_json::to_string(&json!({ - "sheet_name": "知乎热榜", - "columns": ["rank", "title", "heat"], - "rows": [ - [1, "问题一", "344万"], - [2, "问题二", "266万"] - ], - "output_path": output_path_str - })).unwrap() - } - }] - } - }] - }); - let fourth_response = json!({ - "choices": [{ - "message": { - "content": format!("已导出知乎热榜 Excel {output_path_str}") - } - }] - }); - let (base_url, _requests, server_handle) = - start_fake_deepseek_server(vec![first_response, third_response, fourth_response]); let config_path = write_deepseek_config_with_skills_dir( &workspace_root, "deepseek-test-key", - &base_url, + "http://127.0.0.1:9", "deepseek-chat", Some(real_skill_lib_root().to_str().unwrap()), ); let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone()); - let transport = Arc::new(MockTransport::new(vec![success_browser_response( - 1, - json!({ - "text": { - "source": "https://www.zhihu.com/hot", - "sheet_name": "知乎热榜", - "columns": ["rank", "title", "heat"], - "rows": [[1, "问题一", "344万"], [2, "问题二", "266万"]] - } - }), - )])); + let transport = Arc::new(MockTransport::new(vec![ + success_browser_response( + 1, + json!({ "text": "知乎热榜\n1 问题一 344万热度\n2 问题二 266万热度" }), + ), + success_browser_response( + 2, + json!({ + "text": { + "source": "https://www.zhihu.com/hot", + "sheet_name": "知乎热榜", + "columns": ["rank", "title", "heat"], + "rows": [[1, "问题一", "344万"], [2, "问题二", "266万"]] + } + }), + ), + ])); let browser_tool = BrowserPipeTool::new( transport.clone(), zhihu_test_policy(), @@ -2203,22 +2196,17 @@ fn handle_browser_message_chains_hotlist_skill_into_office_export_tool() { instruction: "读取知乎热榜数据,并导出 excel 文件".to_string(), conversation_id: String::new(), messages: vec![], - page_url: "https://www.zhihu.com/".to_string(), - page_title: "知乎".to_string(), + page_url: "https://www.zhihu.com/hot".to_string(), + page_title: "知乎热榜".to_string(), }, ) .unwrap(); - server_handle.join().unwrap(); let sent = transport.sent_messages(); + let summary = task_complete_summary(&sent); - assert!(sent.iter().any(|message| { - matches!( - message, - AgentMessage::TaskComplete { success, summary } - if *success && summary.contains("已导出知乎热榜 Excel") && summary.contains(".xlsx") - ) - })); + assert!(summary.contains("已导出知乎热榜 Excel")); + assert!(summary.contains(".xlsx")); assert!(sent.iter().any(|message| { matches!( message, @@ -2236,9 +2224,13 @@ fn handle_browser_message_chains_hotlist_skill_into_office_export_tool() { assert!(sent.iter().any(|message| { matches!( message, - AgentMessage::Command { action, .. } if action == &Action::Eval + AgentMessage::LogEntry { level, message } + if level == "info" && message == "call openxml_office" ) })); + assert!(sent.iter().any(|message| { + matches!(message, AgentMessage::Command { action, .. } if action == &Action::Eval) + })); assert!(!sent.iter().any(|message| { matches!( message, @@ -2263,13 +2255,12 @@ fn handle_browser_message_chains_hotlist_skill_into_screen_export_tool() { let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone()); let transport = Arc::new(MockTransport::new(vec![ - success_browser_response(1, json!({ "navigated": true })), success_browser_response( - 2, + 1, json!({ "text": "知乎热榜\n1 问题一 344万热度\n2 问题二 266万热度" }), ), success_browser_response( - 3, + 2, json!({ "text": { "source": "https://www.zhihu.com/hot", @@ -2279,7 +2270,6 @@ fn handle_browser_message_chains_hotlist_skill_into_screen_export_tool() { } }), ), - success_browser_response(4, json!({ "navigated": true })), ])); let browser_tool = BrowserPipeTool::new( transport.clone(), @@ -2296,8 +2286,8 @@ fn handle_browser_message_chains_hotlist_skill_into_screen_export_tool() { instruction: "读取知乎热榜数据并生成领导演示大屏,在新标签页展示".to_string(), conversation_id: String::new(), messages: vec![], - page_url: "https://www.zhihu.com/".to_string(), - page_title: "知乎".to_string(), + page_url: "https://www.zhihu.com/hot".to_string(), + page_title: "知乎热榜".to_string(), }, ) .unwrap(); @@ -3087,68 +3077,33 @@ fn zhihu_publish_after_confirmation_reports_login_block_without_selector_probing fn browser_orchestration_registers_superrpa_tools_natively() { let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner()); - let first_response = json!({ - "choices": [{ - "message": { - "content": "", - "tool_calls": [{ - "id": "call_1", - "type": "function", - "function": { - "name": "superrpa_browser", - "arguments": serde_json::to_string(&json!({ - "action": "getText", - "expected_domain": "www.zhihu.com", - "selector": "main" - })).unwrap() - } - }] - } - }] - }); - let second_response = json!({ - "choices": [{ - "message": { - "content": "", - "tool_calls": [{ - "id": "call_2", - "type": "function", - "function": { - "name": "openxml_office", - "arguments": serde_json::to_string(&json!({ - "sheet_name": "知乎热榜", - "columns": ["rank", "title", "heat"], - "rows": [[1, "问题一", "344万"]] - })).unwrap() - } - }] - } - }] - }); - let third_response = json!({ - "choices": [{ - "message": { - "content": "已导出知乎热榜 Excel" - } - }] - }); - let (base_url, requests, server_handle) = - start_fake_deepseek_server(vec![first_response, second_response, third_response]); - let workspace_root = temp_workspace_root(); let config_path = write_deepseek_config_with_skills_dir( &workspace_root, "deepseek-test-key", - &base_url, + "http://127.0.0.1:9", "deepseek-chat", Some(real_skill_lib_root().to_str().unwrap()), ); let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone()); - let transport = Arc::new(MockTransport::new(vec![success_browser_response( - 1, - json!({ "text": "知乎热榜\n1\n问题一\n344万热度" }), - )])); + let transport = Arc::new(MockTransport::new(vec![ + success_browser_response( + 1, + json!({ "text": "知乎热榜\n1 问题一 344万热度" }), + ), + success_browser_response( + 2, + json!({ + "text": { + "source": "https://www.zhihu.com/hot", + "sheet_name": "知乎热榜", + "columns": ["rank", "title", "heat"], + "rows": [[1, "问题一", "344万"]] + } + }), + ), + ])); let browser_tool = BrowserPipeTool::new( transport.clone(), zhihu_test_policy(), @@ -3170,22 +3125,32 @@ fn browser_orchestration_registers_superrpa_tools_natively() { ) .unwrap(); - let request_bodies = requests.lock().unwrap().clone(); let sent = transport.sent_messages(); - assert!( - !request_bodies.is_empty(), - "expected provider request, sent messages were: {sent:?}" - ); - server_handle.join().unwrap(); - let first_request = request_bodies - .first() - .expect("expected first provider request") - .to_string(); - let tool_names = request_tool_names(&request_bodies[0]); - assert!(first_request.contains("superrpa_browser")); - assert!(tool_names.contains(&"superrpa_browser".to_string())); - assert!(tool_names.contains(&"openxml_office".to_string())); + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::LogEntry { level, message } + if level == "mode" && message == "zeroclaw_process_message_primary" + ) + })); + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::LogEntry { level, message } + if level == "info" && message == "call openxml_office" + ) + })); + assert!(sent.iter().any(|message| { + matches!(message, AgentMessage::Command { action, .. } if action == &Action::GetText) + })); + assert!(!sent.iter().any(|message| { + matches!( + message, + AgentMessage::LogEntry { level, message } + if level == "mode" && (message == "compat_llm_primary" || message == "compat_skill_runner_primary") + ) + })); } #[test] @@ -3248,96 +3213,30 @@ fn browser_skill_usage_is_execution_not_prompt_only() { let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner()); let workspace_root = temp_workspace_root(); - let output_path = workspace_root.join("out/zhihu-hotlist-execution.xlsx"); - let output_path_str = output_path.to_string_lossy().to_string(); - let first_response = json!({ - "choices": [{ - "message": { - "content": "", - "tool_calls": [{ - "id": "call_1", - "type": "function", - "function": { - "name": "superrpa_browser", - "arguments": serde_json::to_string(&json!({ - "action": "navigate", - "expected_domain": "www.zhihu.com", - "url": "https://www.zhihu.com/hot" - })).unwrap() - } - }] - } - }] - }); - let second_response = json!({ - "choices": [{ - "message": { - "content": "", - "tool_calls": [{ - "id": "call_2", - "type": "function", - "function": { - "name": "superrpa_browser", - "arguments": serde_json::to_string(&json!({ - "action": "getText", - "expected_domain": "www.zhihu.com", - "selector": "main" - })).unwrap() - } - }] - } - }] - }); - let third_response = json!({ - "choices": [{ - "message": { - "content": "", - "tool_calls": [{ - "id": "call_3", - "type": "function", - "function": { - "name": "openxml_office", - "arguments": serde_json::to_string(&json!({ - "sheet_name": "知乎热榜", - "columns": ["rank", "title", "heat"], - "rows": [ - [1, "问题一", "344万"], - [2, "问题二", "266万"] - ], - "output_path": output_path_str - })).unwrap() - } - }] - } - }] - }); - let fourth_response = json!({ - "choices": [{ - "message": { - "content": format!("已导出知乎热榜 Excel {output_path_str}") - } - }] - }); - let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![ - first_response, - second_response, - third_response, - fourth_response, - ]); let config_path = write_deepseek_config_with_skills_dir( &workspace_root, "deepseek-test-key", - &base_url, + "http://127.0.0.1:9", "deepseek-chat", Some(real_skill_lib_root().to_str().unwrap()), ); let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone()); let transport = Arc::new(MockTransport::new(vec![ - success_browser_response(1, json!({ "navigated": true })), + success_browser_response( + 1, + json!({ "text": "知乎热榜\n1 问题一 344万热度\n2 问题二 266万热度" }), + ), success_browser_response( 2, - json!({ "text": "知乎热榜\n1\n问题一\n344万热度\n2\n问题二\n266万热度" }), + json!({ + "text": { + "source": "https://www.zhihu.com/hot", + "sheet_name": "知乎热榜", + "columns": ["rank", "title", "heat"], + "rows": [[1, "问题一", "344万"], [2, "问题二", "266万"]] + } + }), ), ])); let browser_tool = BrowserPipeTool::new( @@ -3360,22 +3259,11 @@ fn browser_skill_usage_is_execution_not_prompt_only() { }, ) .unwrap(); - server_handle.join().unwrap(); - let request_bodies = requests.lock().unwrap().clone(); let sent = transport.sent_messages(); - let first_request = request_bodies - .first() - .expect("expected first provider request") - .to_string(); + let summary = task_complete_summary(&sent); - assert!(sent.iter().any(|message| { - matches!( - message, - AgentMessage::TaskComplete { success, summary } - if *success && summary.contains(".xlsx") - ) - })); + assert!(summary.contains(".xlsx")); assert!(!sent.iter().any(|message| { matches!( message, @@ -3383,17 +3271,16 @@ fn browser_skill_usage_is_execution_not_prompt_only() { if level == "info" && message.starts_with("read_skill ") ) })); + assert!(sent.iter().any(|message| { + matches!(message, AgentMessage::Command { action, .. } if action == &Action::GetText) + })); assert!(!sent.iter().any(|message| { matches!( message, AgentMessage::LogEntry { level, message } - if level == "info" && - (message == "getText .HotList-item" || - message == "getText [data-hot-item]" || - message == "getText ol li") + if level == "mode" && (message == "compat_llm_primary" || message == "compat_skill_runner_primary") ) })); - assert!(!first_request.contains("Preloaded skill context:")); } #[test] @@ -3652,3 +3539,504 @@ fn handle_browser_message_executes_real_zhihu_write_skill_flow() { ) })); } + +fn staged_lineloss_skills_root() -> PathBuf { + PathBuf::from("D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills") +} + +fn build_fault_details_direct_skill_root() -> PathBuf { + let root = temp_workspace_root(); + let skill_dir = write_skill_manifest_package( + &root, + "fault-details-report", + r#" +[skill] +name = "fault-details-report" +description = "Collect 95598 fault detail data via browser eval." +version = "0.1.0" + +[[tools]] +name = "collect_fault_details" +description = "Collect structured fault detail rows for a specific period." +kind = "browser_script" +command = "scripts/collect_fault_details.js" + +[tools.args] +period = "YYYY-MM period to collect." +"#, + ); + write_skill_script( + &skill_dir, + "scripts/collect_fault_details.js", + r#" +return { + fault_type: "outage", + observed_at: `${args.period}-15 09:00`, + affected_scope: "line-7" +}; +"#, + ); + root +} + +#[test] +fn deterministic_lineloss_runtime_passes_canonical_args_to_browser_script_tool() { + let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner()); + + let workspace_root = temp_workspace_root(); + let config_path = write_deepseek_config_with_skills_dir( + &workspace_root, + "deepseek-test-key", + "http://127.0.0.1:9", + "deepseek-chat", + Some(staged_lineloss_skills_root().to_str().unwrap()), + ); + let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone()); + + let transport = Arc::new(MockTransport::new(vec![success_browser_response( + 1, + json!({ + "text": { + "type": "report-artifact", + "report_name": "tq-lineloss-report", + "status": "ok", + "org": { + "label": "国网兰州供电公司", + "code": "62401" + }, + "period": { + "mode": "month", + "mode_code": "1", + "value": "2026-03", + "payload": { + "fdate": "2026-03" + } + }, + "rows": [ + { "ORG_NAME": "国网兰州供电公司", "LINE_LOSS_RATE": "3.21" } + ], + "counts": { + "rows": 1 + }, + "reasons": [] + } + }), + )])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + policy_for_domains(&["20.76.57.61"]), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + handle_browser_message_with_context( + transport.as_ref(), + &browser_tool, + &runtime_context, + BrowserMessage::SubmitTask { + instruction: "兰州公司 月累计 2026-03。。。".to_string(), + conversation_id: String::new(), + messages: vec![], + page_url: "http://20.76.57.61:8080/#/lineloss".to_string(), + page_title: "台区线损报表".to_string(), + }, + ) + .unwrap(); + + let sent = transport.sent_messages(); + + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::LogEntry { level, message } + if level == "mode" && message == "direct_skill_primary" + ) + })); + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::TaskComplete { success, summary } + if *success + && summary.contains("tq-lineloss-report") + && summary.contains("国网兰州供电公司") + && summary.contains("2026-03") + && summary.contains("rows=1") + ) + })); + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::Command { + action, + params, + security, + .. + } + if action == &Action::Eval + && security.expected_domain == "20.76.57.61" + && params["script"].as_str().is_some_and(|script| + script.contains("\"org_label\":\"国网兰州供电公司\"") + && script.contains("\"org_code\":\"62401\"") + && script.contains("\"period_mode\":\"month\"") + && script.contains("\"period_mode_code\":\"1\"") + && script.contains("\"period_value\":\"2026-03\"") + && script.contains("fdate") + ) + ) + })); +} + +#[test] +fn deterministic_lineloss_missing_company_prompt_skips_browser_execution() { + let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner()); + + let workspace_root = temp_workspace_root(); + let config_path = write_deepseek_config_with_skills_dir( + &workspace_root, + "deepseek-test-key", + "http://127.0.0.1:9", + "deepseek-chat", + Some(staged_lineloss_skills_root().to_str().unwrap()), + ); + let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone()); + + let transport = Arc::new(MockTransport::new(vec![])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + policy_for_domains(&["20.76.57.61"]), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + handle_browser_message_with_context( + transport.as_ref(), + &browser_tool, + &runtime_context, + BrowserMessage::SubmitTask { + instruction: "月累计 2026-03。。。".to_string(), + conversation_id: String::new(), + messages: vec![], + page_url: "http://20.76.57.61:8080/#/lineloss".to_string(), + page_title: "台区线损报表".to_string(), + }, + ) + .unwrap(); + + let sent = transport.sent_messages(); + + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::TaskComplete { success, summary } + if !*success && (summary.contains("缺少供电单位") || summary.contains("兰州公司")) + ) + })); + assert!(!sent.iter().any(|message| matches!(message, AgentMessage::Command { .. }))); +} + +#[test] +fn deterministic_lineloss_runtime_maps_partial_artifact_to_success_summary() { + let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner()); + + let workspace_root = temp_workspace_root(); + let config_path = write_deepseek_config_with_skills_dir( + &workspace_root, + "deepseek-test-key", + "http://127.0.0.1:9", + "deepseek-chat", + Some(staged_lineloss_skills_root().to_str().unwrap()), + ); + let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone()); + + let transport = Arc::new(MockTransport::new(vec![success_browser_response( + 1, + json!({ + "text": { + "type": "report-artifact", + "report_name": "tq-lineloss-report", + "status": "partial", + "org": { + "label": "国网兰州供电公司", + "code": "62401" + }, + "period": { + "mode": "month", + "mode_code": "1", + "value": "2026-03", + "payload": { + "fdate": "2026-03" + } + }, + "rows": [ + { "ORG_NAME": "国网兰州供电公司", "LINE_LOSS_RATE": "3.21" } + ], + "counts": { + "rows": 1 + }, + "reasons": ["report_log_failed"] + } + }), + )])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + policy_for_domains(&["20.76.57.61"]), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + handle_browser_message_with_context( + transport.as_ref(), + &browser_tool, + &runtime_context, + BrowserMessage::SubmitTask { + instruction: "兰州公司 月累计 2026-03。。。".to_string(), + conversation_id: String::new(), + messages: vec![], + page_url: "http://20.76.57.61:8080/#/lineloss".to_string(), + page_title: "台区线损报表".to_string(), + }, + ) + .unwrap(); + + let sent = transport.sent_messages(); + + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::TaskComplete { success, summary } + if *success + && summary.contains("status=partial") + && summary.contains("rows=1") + && summary.contains("reasons=report_log_failed") + ) + })); +} + +#[test] +fn deterministic_lineloss_runtime_maps_blocked_artifact_to_failed_completion() { + let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner()); + + let workspace_root = temp_workspace_root(); + let config_path = write_deepseek_config_with_skills_dir( + &workspace_root, + "deepseek-test-key", + "http://127.0.0.1:9", + "deepseek-chat", + Some(staged_lineloss_skills_root().to_str().unwrap()), + ); + let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone()); + + let transport = Arc::new(MockTransport::new(vec![success_browser_response( + 1, + json!({ + "text": { + "type": "report-artifact", + "report_name": "tq-lineloss-report", + "status": "blocked", + "org": { + "label": "国网兰州供电公司", + "code": "62401" + }, + "period": { + "mode": "month", + "mode_code": "1", + "value": "2026-03", + "payload": { + "fdate": "2026-03" + } + }, + "rows": [], + "counts": { + "rows": 0 + }, + "reasons": ["selected_range_unavailable"] + } + }), + )])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + policy_for_domains(&["20.76.57.61"]), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + handle_browser_message_with_context( + transport.as_ref(), + &browser_tool, + &runtime_context, + BrowserMessage::SubmitTask { + instruction: "兰州公司 月累计 2026-03。。。".to_string(), + conversation_id: String::new(), + messages: vec![], + page_url: "http://20.76.57.61:8080/#/lineloss".to_string(), + page_title: "台区线损报表".to_string(), + }, + ) + .unwrap(); + + let sent = transport.sent_messages(); + + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::TaskComplete { success, summary } + if !*success + && summary.contains("status=blocked") + && summary.contains("reasons=selected_range_unavailable") + ) + })); +} + +#[test] +fn deterministic_suffix_non_lineloss_request_does_not_fall_into_zhihu_logic() { + let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner()); + + let workspace_root = temp_workspace_root(); + let config_path = write_deepseek_config_with_skills_dir( + &workspace_root, + "deepseek-test-key", + "http://127.0.0.1:9", + "deepseek-chat", + Some(real_skill_lib_root().to_str().unwrap()), + ); + let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone()); + + let transport = Arc::new(MockTransport::new(vec![])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + zhihu_test_policy(), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + handle_browser_message_with_context( + transport.as_ref(), + &browser_tool, + &runtime_context, + BrowserMessage::SubmitTask { + instruction: "打开知乎热榜。。。".to_string(), + conversation_id: String::new(), + messages: vec![], + page_url: "https://www.zhihu.com/hot".to_string(), + page_title: "知乎热榜".to_string(), + }, + ) + .unwrap(); + + let sent = transport.sent_messages(); + + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::TaskComplete { success, summary } + if !*success && summary.contains("台区线损") + ) + })); + assert!(!sent.iter().any(|message| matches!(message, AgentMessage::Command { .. }))); + assert!(!sent.iter().any(|message| { + matches!( + message, + AgentMessage::LogEntry { level, message } + if level == "mode" + && (message == "zeroclaw_process_message_primary" + || message == "compat_llm_primary") + ) + })); +} + +#[test] +fn existing_fault_details_direct_browser_script_path_remains_unchanged() { + let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner()); + + let skill_root = build_fault_details_direct_skill_root(); + let workspace_root = temp_workspace_root(); + let config_path = write_deepseek_config_with_direct_submit_skill( + &workspace_root, + "deepseek-test-key", + "http://127.0.0.1:9", + "deepseek-chat", + Some(skill_root.to_str().unwrap()), + Some("fault-details-report.collect_fault_details"), + ); + let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone()); + + let transport = Arc::new(MockTransport::new(vec![success_browser_response( + 1, + json!({ + "text": { + "type": "report-artifact", + "report_name": "fault-details-report", + "period": "2026-03", + "counts": { + "detail_rows": 1, + "summary_rows": 1 + }, + "rows": [{ "qxdbh": "QX-1" }], + "sections": [{ + "name": "summary-sheet", + "rows": [{ "index": 1 }] + }], + "status": "partial", + "partial_reasons": ["report_log_failed"] + } + }), + )])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + policy_for_domains(&["95598.sgcc.com.cn"]), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + handle_browser_message_with_context( + transport.as_ref(), + &browser_tool, + &runtime_context, + BrowserMessage::SubmitTask { + instruction: "请采集 2026-03 的故障明细并返回结果".to_string(), + conversation_id: String::new(), + messages: vec![], + page_url: "https://95598.sgcc.com.cn/".to_string(), + page_title: "网上国网".to_string(), + }, + ) + .unwrap(); + + let sent = transport.sent_messages(); + + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::LogEntry { level, message } + if level == "mode" && message == "direct_skill_primary" + ) + })); + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::TaskComplete { success, summary } + if *success + && summary.contains("fault-details-report") + && summary.contains("status=partial") + && summary.contains("detail_rows=1") + && summary.contains("summary_rows=1") + && summary.contains("report_log_failed") + ) + })); + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::Command { + action, + params, + security, + .. + } + if action == &Action::Eval + && security.expected_domain == "95598.sgcc.com.cn" + && params["script"].as_str().is_some_and(|script| + script.contains("\"period\":\"2026-03\"") + ) + ) + })); +} diff --git a/tests/deterministic_submit_test.rs b/tests/deterministic_submit_test.rs new file mode 100644 index 0000000..0149dbd --- /dev/null +++ b/tests/deterministic_submit_test.rs @@ -0,0 +1,375 @@ +mod common; + +use std::path::PathBuf; + +use zeroclaw::skills::load_skills_from_directory; + +use sgclaw::compat::deterministic_submit::{ + decide_deterministic_submit, DeterministicSubmitDecision, +}; +use sgclaw::compat::tq_lineloss::{ + contracts::{PeriodMode, ResolvedOrg, ResolvedPeriod}, + org_resolver::resolve_org, + period_resolver::resolve_period, +}; +use sgclaw::runtime::is_zhihu_hotlist_task; + +#[test] +fn deterministic_submit_discovers_tq_lineloss_skill_contract() { + let skills_root = PathBuf::from("D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills"); + let skills = load_skills_from_directory(&skills_root, true); + + let skill = skills + .iter() + .find(|skill| skill.name == "tq-lineloss-report") + .expect("tq-lineloss-report should be discoverable from staged skills root"); + + let tool = skill + .tools + .iter() + .find(|tool| tool.name == "collect_lineloss") + .expect("collect_lineloss tool should be discoverable"); + + assert_eq!(tool.kind, "browser_script"); + assert_eq!(tool.command, "scripts/collect_lineloss.js"); + + let required_args = [ + "expected_domain", + "org_label", + "org_code", + "period_mode", + "period_mode_code", + "period_value", + "period_payload", + ]; + + for arg in required_args { + assert!( + tool.args.contains_key(arg), + "expected required arg {arg} in tq-lineloss-report.collect_lineloss" + ); + } + + assert_eq!(tool.args.len(), required_args.len()); +} + +#[test] +fn deterministic_submit_requires_exact_suffix() { + assert!(matches!( + decide_deterministic_submit("兰州公司 月累计 2026-03。。。", None, None), + DeterministicSubmitDecision::Execute(_) + )); + + assert!(matches!( + decide_deterministic_submit("兰州公司 月累计 2026-03", None, None), + DeterministicSubmitDecision::NotDeterministic + )); +} + +#[test] +fn deterministic_submit_nonmatch_returns_supported_scene_message() { + let decision = decide_deterministic_submit("帮我打开百度。。。", None, None); + + match decision { + DeterministicSubmitDecision::Prompt { summary } => { + assert!(summary.contains("台区线损") || summary.contains("支持场景")); + } + other => panic!("expected deterministic prompt for unsupported scene, got {other:?}"), + } +} + +#[test] +fn deterministic_submit_rejects_page_context_mismatch() { + let decision = decide_deterministic_submit( + "兰州公司 月累计 2026-03。。。", + Some("https://www.zhihu.com/hot"), + Some("知乎热榜"), + ); + + match decision { + DeterministicSubmitDecision::Prompt { summary } => { + assert!(summary.contains("台区线损") || summary.contains("页面") || summary.contains("不匹配")); + } + other => panic!("expected deterministic mismatch prompt, got {other:?}"), + } +} + +#[test] +fn zhihu_hotlist_request_without_suffix_keeps_existing_route() { + assert!(is_zhihu_hotlist_task( + "打开知乎热榜", + Some("https://www.zhihu.com/hot"), + Some("知乎热榜") + )); + + assert!(matches!( + decide_deterministic_submit( + "打开知乎热榜", + Some("https://www.zhihu.com/hot"), + Some("知乎热榜") + ), + DeterministicSubmitDecision::NotDeterministic + )); +} + +#[test] +fn deterministic_submit_rejects_non_exact_suffix_variants() { + for instruction in [ + "兰州公司 月累计 2026-03...", + "兰州公司 月累计 2026-03。。。。", + "兰州公司。。。月累计 2026-03", + "兰州公司 月累计 2026-03。。。 ", + ] { + assert!(matches!( + decide_deterministic_submit(instruction, None, None), + DeterministicSubmitDecision::NotDeterministic + )); + } +} + +#[test] +fn lineloss_org_resolver_matches_city_alias() { + assert_eq!( + resolve_org("兰州公司").unwrap(), + ResolvedOrg { + label: "国网兰州供电公司".to_string(), + code: "62401".to_string(), + } + ); + + assert_eq!( + resolve_org("天水公司").unwrap(), + ResolvedOrg { + label: "国网天水供电公司".to_string(), + code: "62403".to_string(), + } + ); +} + +#[test] +fn lineloss_org_resolver_matches_county_alias() { + assert_eq!( + resolve_org("榆中县公司").unwrap(), + ResolvedOrg { + label: "国网榆中县供电公司".to_string(), + code: "6240121".to_string(), + } + ); + + assert_eq!( + resolve_org("城关供电分公司").unwrap(), + ResolvedOrg { + label: "城关供电分公司".to_string(), + code: "6240108".to_string(), + } + ); +} + +#[test] +fn lineloss_org_resolver_prompts_on_ambiguity() { + let summary = resolve_org("城关") + .expect_err("ambiguous alias should prompt instead of guessing"); + + assert!(summary.contains("供电单位存在歧义") || summary.contains("更完整名称")); +} + +#[test] +fn deterministic_submit_lineloss_missing_company_prompts() { + let decision = decide_deterministic_submit("月累计 2026-03。。。", None, None); + + match decision { + DeterministicSubmitDecision::Prompt { summary } => { + assert!(summary.contains("缺少供电单位") || summary.contains("兰州公司")); + } + other => panic!("expected missing-company prompt, got {other:?}"), + } +} + +#[test] +fn lineloss_period_resolver_parses_month_text() { + assert_eq!( + resolve_period("月累计 2026-03").unwrap(), + ResolvedPeriod { + mode: PeriodMode::Month, + mode_code: "1".to_string(), + value: "2026-03".to_string(), + payload: serde_json::json!({ + "fdate": "2026-03", + }), + } + ); + + assert_eq!( + resolve_period("月累计 2026年3月").unwrap(), + ResolvedPeriod { + mode: PeriodMode::Month, + mode_code: "1".to_string(), + value: "2026-03".to_string(), + payload: serde_json::json!({ + "fdate": "2026-03", + }), + } + ); +} + +#[test] +fn lineloss_period_resolver_parses_week_text() { + let resolved = resolve_period("周累计 2026年第12周").unwrap(); + + assert_eq!(resolved.mode, PeriodMode::Week); + assert_eq!(resolved.mode_code, "2"); + assert_eq!(resolved.value, "2026-W12"); + assert_eq!(resolved.payload["tjzq"], "week"); + assert_eq!(resolved.payload["level"], "00"); + assert_eq!(resolved.payload["weekSfdate"], "2026-03-16"); + assert_eq!(resolved.payload["weekEfdate"], "2026-03-22"); +} + +#[test] +fn lineloss_period_resolver_prompts_for_missing_year_on_week() { + let summary = resolve_period("周累计 第12周") + .expect_err("bare week should prompt for year instead of guessing"); + + assert!(summary.contains("年份") || summary.contains("第12周")); +} + +#[test] +fn lineloss_period_resolver_rejects_contradictory_mode() { + let summary = resolve_period("月累计 周累计 2026-03") + .expect_err("contradictory month/week intent should not execute"); + + assert!(summary.contains("月/周") || summary.contains("冲突") || summary.contains("歧义")); +} + +#[test] +fn lineloss_period_resolver_prompts_for_missing_mode() { + let summary = resolve_period("兰州公司 2026-03") + .expect_err("missing mode should prompt instead of guessing"); + + assert!(summary.contains("月/周类型") || summary.contains("月累计") || summary.contains("周累计")); +} + +#[test] +fn lineloss_period_resolver_prompts_for_missing_period() { + let summary = resolve_period("兰州公司 月累计") + .expect_err("missing period should prompt instead of guessing"); + + assert!(summary.contains("周期") || summary.contains("时间") || summary.contains("2026-03")); +} + +#[test] +fn deterministic_lineloss_execution_plan_contains_canonical_args() { + let decision = decide_deterministic_submit( + "兰州公司 月累计 2026-03。。。", + Some("http://20.76.57.61:8080/#/lineloss"), + Some("台区线损报表"), + ); + + match decision { + DeterministicSubmitDecision::Execute(plan) => { + let debug = format!("{plan:?}"); + assert!(debug.contains("国网兰州供电公司"), "missing canonical org label: {debug}"); + assert!(debug.contains("62401"), "missing canonical org code: {debug}"); + assert!(debug.contains("2026-03"), "missing canonical period value: {debug}"); + assert!(debug.contains("month") || debug.contains("Month"), "missing canonical month mode: {debug}"); + assert!(debug.contains("fdate"), "missing canonical month payload: {debug}"); + } + other => panic!("expected deterministic execute plan, got {other:?}"), + } +} + +#[test] +fn deterministic_lineloss_missing_period_does_not_reach_execution_plan() { + let decision = decide_deterministic_submit("兰州公司 月累计。。。", None, None); + + match decision { + DeterministicSubmitDecision::Prompt { summary } => { + assert!(summary.contains("周期") || summary.contains("时间") || summary.contains("2026-03")); + } + other => panic!("expected missing-period prompt before execution, got {other:?}"), + } +} + +#[test] +fn deterministic_lineloss_partial_artifact_summary_contract_is_locked() { + let artifact = serde_json::json!({ + "type": "report-artifact", + "report_name": "tq-lineloss-report", + "status": "partial", + "org": { + "label": "国网兰州供电公司", + "code": "62401" + }, + "period": { + "mode": "month", + "mode_code": "1", + "value": "2026-03", + "payload": { + "fdate": "2026-03" + } + }, + "columns": ["ORG_NAME", "LINE_LOSS_RATE"], + "rows": [ + { "ORG_NAME": "国网兰州供电公司", "LINE_LOSS_RATE": "3.21" } + ], + "counts": { + "rows": 1 + }, + "export": { + "attempted": true, + "status": "failed", + "message": "report_log_failed" + }, + "reasons": ["report_log_failed"] + }); + + assert_eq!(artifact["type"], "report-artifact"); + assert_eq!(artifact["report_name"], "tq-lineloss-report"); + assert_eq!(artifact["status"], "partial"); + assert_eq!(artifact["org"]["label"], "国网兰州供电公司"); + assert_eq!(artifact["period"]["value"], "2026-03"); + assert_eq!(artifact["counts"]["rows"], 1); + assert_eq!(artifact["reasons"][0], "report_log_failed"); +} + +#[test] +fn deterministic_lineloss_blocked_and_error_artifact_statuses_are_failure_contracts() { + for status in ["blocked", "error"] { + let artifact = serde_json::json!({ + "type": "report-artifact", + "report_name": "tq-lineloss-report", + "status": status, + "org": { + "label": "国网兰州供电公司", + "code": "62401" + }, + "period": { + "mode": "week", + "mode_code": "2", + "value": "2026-W12", + "payload": { + "tjzq": "week", + "level": "00", + "weekSfdate": "2026-03-16", + "weekEfdate": "2026-03-22" + } + }, + "columns": [], + "rows": [], + "counts": { + "rows": 0 + }, + "export": { + "attempted": false, + "status": "skipped", + "message": null + }, + "reasons": ["selected_range_unavailable"] + }); + + assert_eq!(artifact["status"], status); + assert_eq!(artifact["type"], "report-artifact"); + assert_eq!(artifact["period"]["mode"], "week"); + assert_eq!(artifact["reasons"][0], "selected_range_unavailable"); + } +} diff --git a/tests/runtime_task_flow_test.rs b/tests/runtime_task_flow_test.rs index 15925aa..b9f9e7b 100644 --- a/tests/runtime_task_flow_test.rs +++ b/tests/runtime_task_flow_test.rs @@ -51,7 +51,9 @@ fn submit_task_without_llm_configuration_returns_clear_error() { assert!(matches!( &sent[0], AgentMessage::LogEntry { level, message } - if level == "info" && message == "sgclaw runtime version=0.1.0 protocol=1.0" + if level == "info" + && message.starts_with("sgclaw runtime version=") + && message.ends_with(" protocol=1.0") )); assert!(matches!( &sent[1],