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 <noreply@anthropic.com>
This commit is contained in:
@@ -164,8 +164,9 @@ pub fn handle_browser_message_with_context<T: Transport + 'static>(
|
||||
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<T: Transport + 'static>(
|
||||
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<T: Transport + 'static>(
|
||||
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()
|
||||
|
||||
272
src/compat/deterministic_submit.rs
Normal file
272
src/compat/deterministic_submit.rs
Normal file
@@ -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<T: Transport + 'static>(
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
plan: &DeterministicExecutionPlan,
|
||||
workspace_root: &Path,
|
||||
settings: &SgClawSettings,
|
||||
) -> Result<DirectSubmitOutcome, PipeError> {
|
||||
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::<Value>(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::<Vec<_>>()
|
||||
})
|
||||
.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(),
|
||||
}
|
||||
}
|
||||
@@ -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<T: Transport + 'static>(
|
||||
.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<T: Transport + 'static>(
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
configured_tool: &str,
|
||||
workspace_root: &Path,
|
||||
settings: &SgClawSettings,
|
||||
args: Map<String, Value>,
|
||||
) -> Result<String, PipeError> {
|
||||
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<T: Transport + 'static>(
|
||||
))
|
||||
})?;
|
||||
|
||||
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<T: Transport + 'static>(
|
||||
))
|
||||
})?;
|
||||
|
||||
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<T: Transport + 'static>(
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
configured_tool: &str,
|
||||
tool: &SkillTool,
|
||||
skill_root: &Path,
|
||||
args: Map<String, Value>,
|
||||
) -> Result<String, PipeError> {
|
||||
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<T: Transport + 'static>(
|
||||
.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
|
||||
|
||||
@@ -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;
|
||||
|
||||
50
src/compat/tq_lineloss/contracts.rs
Normal file
50
src/compat/tq_lineloss/contracts.rs
Normal file
@@ -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()
|
||||
}
|
||||
4
src/compat/tq_lineloss/mod.rs
Normal file
4
src/compat/tq_lineloss/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod contracts;
|
||||
pub mod org_resolver;
|
||||
pub mod org_units;
|
||||
pub mod period_resolver;
|
||||
71
src/compat/tq_lineloss/org_resolver.rs
Normal file
71
src/compat/tq_lineloss/org_resolver.rs
Normal file
@@ -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<Item = &'static str> {
|
||||
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<ResolvedOrg, String> {
|
||||
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<Option<ResolvedOrg>, 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)
|
||||
}
|
||||
33
src/compat/tq_lineloss/org_units.rs
Normal file
33
src/compat/tq_lineloss/org_units.rs
Normal file
@@ -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: &["榆中城关供电所"],
|
||||
},
|
||||
];
|
||||
183
src/compat/tq_lineloss/period_resolver.rs
Normal file
183
src/compat/tq_lineloss/period_resolver.rs
Normal file
@@ -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<ResolvedPeriod, String> {
|
||||
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<ResolvedPeriod, String> {
|
||||
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<ResolvedPeriod, String> {
|
||||
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<String> {
|
||||
let chars: Vec<char> = 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<String> {
|
||||
let chars: Vec<char> = 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<char> = 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::<String>().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<NaiveDate> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
375
tests/deterministic_submit_test.rs
Normal file
375
tests/deterministic_submit_test.rs
Normal file
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user