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:
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(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user