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:
木炎
2026-04-12 13:10:58 +08:00
parent 311cc1fee6
commit dd7805d341
12 changed files with 1727 additions and 276 deletions

View File

@@ -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()

View 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(),
}
}

View File

@@ -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

View File

@@ -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;

View 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()
}

View File

@@ -0,0 +1,4 @@
pub mod contracts;
pub mod org_resolver;
pub mod org_units;
pub mod period_resolver;

View 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)
}

View 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: &["榆中城关供电所"],
},
];

View 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");
}
}