merge: integrate main deterministic submit into ws branch
Keep the ws submit path while bringing over main's deterministic lineloss routing and the focused merge verification updates. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,808 @@
|
|||||||
|
# TQ Lineloss Deterministic Skill Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add a staged `tq-lineloss-report.collect_lineloss` browser-script skill plus a `。。。` deterministic submit path in `claw-new` that extracts and normalizes company/month/week parameters without LLM, executes through the existing pipe browser-script seam, and does not regress Zhihu hotlist behavior.
|
||||||
|
|
||||||
|
**Architecture:** Keep the new behavior behind a narrow deterministic branch that activates only when the raw instruction ends with the exact suffix `。。。`. `claw-new` owns deterministic trigger detection, explicit scene matching, semantic extraction, canonical normalization, prompt-or-execute control flow, and artifact interpretation; the staged skill owns page inspection, source/API collection, row normalization, export/report-log behavior, and final artifact generation. Reuse the existing `browser_script` execution seam already used by the direct browser path so the backend can later swap from pipe to ws without changing the deterministic contract.
|
||||||
|
|
||||||
|
**Tech Stack:** Rust 2021, Cargo tests, existing `BrowserPipeTool` / `execute_browser_script_tool` seam, staged skill packaging under `claw/claw/skills/skill_staging`, browser-side JavaScript, deterministic string parsing and normalization.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Context
|
||||||
|
|
||||||
|
- Follow @superpowers:test-driven-development for every behavior change.
|
||||||
|
- Follow @superpowers:verification-before-completion before claiming each task is done.
|
||||||
|
- Do **not** create a git worktree unless the user explicitly asks.
|
||||||
|
- Keep the new behavior as a narrow branch; do **not** redesign the whole runtime into a general registry engine in this slice.
|
||||||
|
- Preserve `src/runtime/engine.rs:147-159` and `src/runtime/engine.rs:265-286` behavior unless a failing regression test proves a change is required.
|
||||||
|
- Do **not** add ws runtime requirements on `main`; keep ws-readiness isolated to backend-neutral contracts only.
|
||||||
|
- Never fall back to page defaults for missing company, mode, or period in deterministic mode.
|
||||||
|
- If a deterministic request does not match the lineloss whitelist scene, return a deterministic mismatch prompt instead of falling through to ordinary orchestration.
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
### New or modified files in `claw-new`
|
||||||
|
|
||||||
|
- Create: `src/compat/deterministic_submit.rs`
|
||||||
|
- suffix detection, deterministic scene match, prompt-or-execute decision
|
||||||
|
- Create: `src/compat/tq_lineloss/mod.rs`
|
||||||
|
- public normalization and artifact helpers
|
||||||
|
- Create: `src/compat/tq_lineloss/contracts.rs`
|
||||||
|
- canonical request/result data structures and status semantics
|
||||||
|
- Create: `src/compat/tq_lineloss/org_resolver.rs`
|
||||||
|
- alias generation, canonical label/code resolution, ambiguity handling
|
||||||
|
- Create: `src/compat/tq_lineloss/period_resolver.rs`
|
||||||
|
- month/week extraction, contradiction detection, canonical payload building
|
||||||
|
- Create: `src/compat/tq_lineloss/org_units.rs`
|
||||||
|
- checked-in canonical unit dictionary derived from the real source tree data
|
||||||
|
- Modify: `src/compat/mod.rs`
|
||||||
|
- export the deterministic and lineloss modules
|
||||||
|
- Modify: `src/agent/mod.rs`
|
||||||
|
- insert the deterministic branch before ordinary LLM interpretation, but only when the exact suffix is present
|
||||||
|
- Modify only if code duplication would otherwise occur: `src/compat/direct_skill_runtime.rs`
|
||||||
|
- extract narrow shared browser-script execution helpers without changing current configured direct-submit behavior
|
||||||
|
- Read but avoid changing unless tests force it: `src/runtime/engine.rs`
|
||||||
|
- existing Zhihu hotlist routing/prompt logic must remain intact
|
||||||
|
|
||||||
|
### New staged skill package in `claw`
|
||||||
|
|
||||||
|
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/SKILL.md`
|
||||||
|
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/SKILL.toml`
|
||||||
|
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/references/collection-flow.md`
|
||||||
|
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/references/data-quality.md`
|
||||||
|
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/assets/scene-snapshot/index.html`
|
||||||
|
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/scripts/collect_lineloss.js`
|
||||||
|
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/scripts/collect_lineloss.test.js`
|
||||||
|
- Create if staging conventions require it: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/scenes/tq-lineloss-report/scene.json`
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Create: `tests/deterministic_submit_test.rs`
|
||||||
|
- Modify: `tests/compat_runtime_test.rs`
|
||||||
|
- Modify only if end-to-end submit coverage requires it: `tests/runtime_task_flow_test.rs`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Locked contracts
|
||||||
|
|
||||||
|
### Deterministic trigger contract
|
||||||
|
|
||||||
|
- Trigger only when the raw instruction ends with the exact suffix `。。。`.
|
||||||
|
- No suffix: current behavior unchanged.
|
||||||
|
- Suffix + unsupported scene: explicit deterministic mismatch prompt.
|
||||||
|
- Suffix is not permission for arbitrary browser actions; only fixed deterministic scenes are allowed.
|
||||||
|
- Negative cases must stay non-deterministic or mismatched exactly as designed:
|
||||||
|
- ASCII `...` is not the trigger
|
||||||
|
- `。。。。` is not the trigger
|
||||||
|
- `。。。` appearing in the middle of the instruction is not the trigger
|
||||||
|
- any trailing whitespace after `。。。` is not the trigger in this slice
|
||||||
|
|
||||||
|
### Canonical org contract
|
||||||
|
|
||||||
|
The resolver must output both display and backend values:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct ResolvedOrg {
|
||||||
|
pub label: String,
|
||||||
|
pub code: String,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Required supported inputs include:
|
||||||
|
- `兰州公司`
|
||||||
|
- `天水公司`
|
||||||
|
- `国网兰州供电公司`
|
||||||
|
- `城关供电分公司`
|
||||||
|
- `榆中县供电公司`
|
||||||
|
- normalized shorthand such as `榆中县公司`
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- derive aliases from the real unit tree data
|
||||||
|
- require uniqueness before execution
|
||||||
|
- ambiguous aliases prompt and stop
|
||||||
|
- missing company prompts and stop
|
||||||
|
|
||||||
|
### Canonical period contract
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum PeriodMode {
|
||||||
|
Month,
|
||||||
|
Week,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ResolvedPeriod {
|
||||||
|
pub mode: PeriodMode,
|
||||||
|
pub mode_code: String,
|
||||||
|
pub value: String,
|
||||||
|
pub payload: serde_json::Value,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Required supported inputs include:
|
||||||
|
- `月累计 2026-03`
|
||||||
|
- `月累计 2026年3月`
|
||||||
|
- `周累计 2026年第12周`
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- month and week intent are mutually exclusive
|
||||||
|
- missing mode prompts and stop
|
||||||
|
- missing period prompts and stop
|
||||||
|
- bare `第12周` is incomplete in this slice and must prompt for year instead of guessing
|
||||||
|
- derive the real backend `period_mode_code` values and request payload field names from the source page/API contract before implementation; do not ship placeholder enum echoes such as `month`/`week` unless the source materials prove those are the real backend codes
|
||||||
|
- never use page-selected defaults in deterministic mode
|
||||||
|
|
||||||
|
### Artifact contract
|
||||||
|
|
||||||
|
Lock the field names now so `claw-new` can interpret status without re-embedding business logic:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "report-artifact",
|
||||||
|
"report_name": "tq-lineloss-report",
|
||||||
|
"status": "ok",
|
||||||
|
"org": {
|
||||||
|
"label": "国网兰州供电公司",
|
||||||
|
"code": "008df5db70319f73e0508eoac23e0c3c"
|
||||||
|
},
|
||||||
|
"period": {
|
||||||
|
"mode": "month",
|
||||||
|
"mode_code": "<real-backend-mode-code>",
|
||||||
|
"value": "2026-03",
|
||||||
|
"payload": {
|
||||||
|
"<real-backend-field>": "<real-backend-value>"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"columns": [],
|
||||||
|
"rows": [],
|
||||||
|
"counts": {
|
||||||
|
"rows": 0
|
||||||
|
},
|
||||||
|
"export": {
|
||||||
|
"attempted": false,
|
||||||
|
"status": "skipped",
|
||||||
|
"message": null
|
||||||
|
},
|
||||||
|
"reasons": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Status mapping in `claw-new`:
|
||||||
|
- `ok` -> task success
|
||||||
|
- `partial` -> task success with partial summary
|
||||||
|
- `blocked` -> task failure
|
||||||
|
- `error` -> task failure
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Scaffold the staged skill package and written contract
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/SKILL.md`
|
||||||
|
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/SKILL.toml`
|
||||||
|
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/references/collection-flow.md`
|
||||||
|
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/references/data-quality.md`
|
||||||
|
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/assets/scene-snapshot/index.html`
|
||||||
|
- Create if required: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/scenes/tq-lineloss-report/scene.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing package contract files**
|
||||||
|
|
||||||
|
Create the package using `fault-details-report` as the structure reference. Lock one tool only:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[tools]]
|
||||||
|
name = "collect_lineloss"
|
||||||
|
kind = "browser_script"
|
||||||
|
description = "Collect 台区线损月/周累计线损率 rows using normalized company and period parameters and return a structured report artifact."
|
||||||
|
```
|
||||||
|
|
||||||
|
Declare required args in `SKILL.toml`:
|
||||||
|
- `expected_domain`
|
||||||
|
- `org_label`
|
||||||
|
- `org_code`
|
||||||
|
- `period_mode`
|
||||||
|
- `period_mode_code`
|
||||||
|
- `period_value`
|
||||||
|
- `period_payload`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write `SKILL.md` before implementation**
|
||||||
|
|
||||||
|
Document:
|
||||||
|
- when to use / when not to use
|
||||||
|
- required normalized args only
|
||||||
|
- blocked/error semantics
|
||||||
|
- exact returned artifact fields
|
||||||
|
- no raw natural-language values passed to backend requests
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write the reference docs**
|
||||||
|
|
||||||
|
`references/collection-flow.md` must describe:
|
||||||
|
- relevant page state
|
||||||
|
- month request mapping
|
||||||
|
- week request mapping
|
||||||
|
- export/report-log flow if retained
|
||||||
|
|
||||||
|
`references/data-quality.md` must define:
|
||||||
|
- canonical output columns
|
||||||
|
- required field coverage
|
||||||
|
- status semantics
|
||||||
|
- partial/error rules
|
||||||
|
- org/period normalization assumptions
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add scene metadata if the current staging registry needs it**
|
||||||
|
|
||||||
|
Keep it narrow: one scene, one tool, one artifact type.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add an automated staged-skill load/resolve check**
|
||||||
|
|
||||||
|
Add `tests/deterministic_submit_test.rs` coverage that loads the staged skills root used by runtime tests, resolves `tq-lineloss-report.collect_lineloss`, and asserts the tool is discoverable with the required args:
|
||||||
|
- `expected_domain`
|
||||||
|
- `org_label`
|
||||||
|
- `org_code`
|
||||||
|
- `period_mode`
|
||||||
|
- `period_mode_code`
|
||||||
|
- `period_value`
|
||||||
|
- `period_payload`
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cargo test deterministic_submit_discovers_tq_lineloss_skill_contract -- --exact
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL before the package is fully wired, PASS once the staged skill contract is discoverable and complete.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Verify structural parity with `fault-details-report`**
|
||||||
|
|
||||||
|
Run a manual file-layout diff and confirm there are no placeholder descriptions or missing required docs.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report" "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/scenes/tq-lineloss-report/scene.json"
|
||||||
|
git commit -m "feat: scaffold tq lineloss staged skill contract"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Add browser-side JS red tests and implement the staged collector
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/scripts/collect_lineloss.js`
|
||||||
|
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/scripts/collect_lineloss.test.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing JS tests first**
|
||||||
|
|
||||||
|
Cover deterministic pure helpers for:
|
||||||
|
- missing normalized args -> blocked/error contract
|
||||||
|
- month request shape uses `org_code` + canonical month payload
|
||||||
|
- week request shape uses `org_code` + canonical week payload
|
||||||
|
- artifact field names and counts
|
||||||
|
- partial/error status shaping
|
||||||
|
- no raw user-entered org text leakage into request fields
|
||||||
|
|
||||||
|
Example test skeleton:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const {
|
||||||
|
validateArgs,
|
||||||
|
buildMonthRequest,
|
||||||
|
buildWeekRequest,
|
||||||
|
normalizeRows,
|
||||||
|
buildArtifact
|
||||||
|
} = require('./collect_lineloss.js');
|
||||||
|
|
||||||
|
test('buildMonthRequest uses canonical org code and month payload', () => {
|
||||||
|
const request = buildMonthRequest({
|
||||||
|
org_code: 'ORG-1',
|
||||||
|
period_payload: { year: 2026, month: 3 }
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(request.orgCode, 'ORG-1');
|
||||||
|
assert.equal(request.year, 2026);
|
||||||
|
assert.equal(request.month, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildArtifact locks field names and partial semantics', () => {
|
||||||
|
const artifact = buildArtifact({
|
||||||
|
org_label: '国网兰州供电公司',
|
||||||
|
org_code: 'ORG-1',
|
||||||
|
period_mode: 'month',
|
||||||
|
period_mode_code: 'month',
|
||||||
|
period_value: '2026-03',
|
||||||
|
period_payload: { year: 2026, month: 3 },
|
||||||
|
rows: [{ id: 1 }],
|
||||||
|
status: 'partial',
|
||||||
|
reasons: ['export_failed']
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(artifact.report_name, 'tq-lineloss-report');
|
||||||
|
assert.equal(artifact.org.code, 'ORG-1');
|
||||||
|
assert.equal(artifact.period.value, '2026-03');
|
||||||
|
assert.deepEqual(artifact.reasons, ['export_failed']);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the JS test file to confirm failure**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
node --test "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/scripts/collect_lineloss.test.js"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because the script/helpers do not exist yet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write the minimal browser-side implementation**
|
||||||
|
|
||||||
|
Required structure:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function validateArgs(args) { /* require normalized canonical args */ }
|
||||||
|
function buildMonthRequest(args) { /* build month request from canonical values */ }
|
||||||
|
function buildWeekRequest(args) { /* build week request from canonical values */ }
|
||||||
|
function normalizeRows(rawRows) { /* canonical columns only */ }
|
||||||
|
function buildArtifact(input) { /* locked artifact shape */ }
|
||||||
|
|
||||||
|
return (async () => {
|
||||||
|
const args = __SKILL_ARGS__;
|
||||||
|
validateArgs(args);
|
||||||
|
// validate page context
|
||||||
|
// collect from page/API
|
||||||
|
// normalize rows
|
||||||
|
// optionally attempt export/report-log if the real business flow requires it
|
||||||
|
return buildArtifact(result);
|
||||||
|
})();
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep test exports behind an environment-safe guard so the file still works as browser-eval code.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Re-run the JS tests until they pass**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
node --test "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/scripts/collect_lineloss.test.js"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/scripts/collect_lineloss.js" "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/scripts/collect_lineloss.test.js"
|
||||||
|
git commit -m "feat: add tq lineloss browser collection script"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Add deterministic suffix detection and explicit scene routing
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/compat/deterministic_submit.rs`
|
||||||
|
- Modify: `src/compat/mod.rs`
|
||||||
|
- Modify: `src/agent/mod.rs`
|
||||||
|
- Create: `tests/deterministic_submit_test.rs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing routing tests**
|
||||||
|
|
||||||
|
Add Rust tests for:
|
||||||
|
- exact raw `。。。` suffix enables deterministic mode
|
||||||
|
- no suffix leaves current routing untouched
|
||||||
|
- suffix + unsupported deterministic request returns supported-scene prompt
|
||||||
|
- when page URL/title context is available and does not match the lineloss scene, deterministic routing returns mismatch/block prompt instead of proceeding
|
||||||
|
- Zhihu hotlist request without suffix keeps the current route
|
||||||
|
- ASCII `...` does not trigger deterministic mode
|
||||||
|
- `。。。。` does not trigger deterministic mode
|
||||||
|
- `。。。` in the middle of the instruction does not trigger deterministic mode
|
||||||
|
- trailing whitespace after `。。。` does not trigger deterministic mode in this slice
|
||||||
|
|
||||||
|
Suggested tests:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[test]
|
||||||
|
fn deterministic_submit_requires_exact_suffix() {}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deterministic_submit_nonmatch_returns_supported_scene_message() {}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deterministic_submit_rejects_page_context_mismatch() {}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zhihu_hotlist_request_without_suffix_keeps_existing_route() {}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deterministic_submit_rejects_non_exact_suffix_variants() {}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the targeted routing tests and confirm failure**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cargo test deterministic_submit_requires_exact_suffix -- --exact
|
||||||
|
cargo test deterministic_submit_nonmatch_returns_supported_scene_message -- --exact
|
||||||
|
cargo test zhihu_hotlist_request_without_suffix_keeps_existing_route -- --exact
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because the deterministic routing seam does not exist yet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the narrow deterministic routing module**
|
||||||
|
|
||||||
|
Recommended public shape:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum DeterministicSubmitDecision {
|
||||||
|
NotDeterministic,
|
||||||
|
Prompt { summary: String },
|
||||||
|
Execute(DeterministicExecutionPlan),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`src/agent/mod.rs` should:
|
||||||
|
1. detect deterministic suffix
|
||||||
|
2. if not deterministic, continue current flow untouched
|
||||||
|
3. if prompt, return `TaskComplete`
|
||||||
|
4. if execute, pass the plan into the browser-script execution seam
|
||||||
|
|
||||||
|
- [ ] **Step 4: Re-run the routing tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cargo test deterministic_submit_requires_exact_suffix -- --exact
|
||||||
|
cargo test deterministic_submit_nonmatch_returns_supported_scene_message -- --exact
|
||||||
|
cargo test zhihu_hotlist_request_without_suffix_keeps_existing_route -- --exact
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/compat/deterministic_submit.rs src/compat/mod.rs src/agent/mod.rs tests/deterministic_submit_test.rs
|
||||||
|
git commit -m "feat: add deterministic submit routing seam"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Implement company/unit normalization from real source data
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/compat/tq_lineloss/mod.rs`
|
||||||
|
- Create: `src/compat/tq_lineloss/contracts.rs`
|
||||||
|
- Create: `src/compat/tq_lineloss/org_resolver.rs`
|
||||||
|
- Create: `src/compat/tq_lineloss/org_units.rs`
|
||||||
|
- Modify: `tests/deterministic_submit_test.rs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing org resolver tests**
|
||||||
|
|
||||||
|
Cover:
|
||||||
|
- `兰州公司` -> canonical `国网兰州供电公司` + correct code
|
||||||
|
- `天水公司` -> canonical `国网天水供电公司` + correct code
|
||||||
|
- `城关供电分公司` -> lower-level direct match
|
||||||
|
- `榆中县公司` -> normalized county alias match
|
||||||
|
- ambiguous alias prompts instead of guessing
|
||||||
|
- missing company prompts instead of executing
|
||||||
|
|
||||||
|
Example skeleton:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[test]
|
||||||
|
fn lineloss_org_resolver_matches_city_alias() {}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lineloss_org_resolver_matches_county_alias() {}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lineloss_org_resolver_prompts_on_ambiguity() {}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the org tests and confirm failure**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cargo test lineloss_org_resolver_matches_city_alias -- --exact
|
||||||
|
cargo test lineloss_org_resolver_matches_county_alias -- --exact
|
||||||
|
cargo test lineloss_org_resolver_prompts_on_ambiguity -- --exact
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because the resolver and checked-in unit dictionary do not exist yet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Check in the canonical unit dictionary and implement alias resolution**
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- derive data from the real source materials, not guessed literals
|
||||||
|
- keep canonical `label` and `code`
|
||||||
|
- generate normalized aliases from formal names
|
||||||
|
- support both city-company and district/county/sub-company levels
|
||||||
|
- require uniqueness before execution
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement explicit prompt messages**
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `已命中台区线损报表技能,但缺少供电单位,请补充如“兰州公司”或“城关供电分公司”。`
|
||||||
|
- `已命中台区线损报表技能,但供电单位存在歧义,请补充更完整名称。`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Re-run the org tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cargo test lineloss_org_resolver_matches_city_alias -- --exact
|
||||||
|
cargo test lineloss_org_resolver_matches_county_alias -- --exact
|
||||||
|
cargo test lineloss_org_resolver_prompts_on_ambiguity -- --exact
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/compat/tq_lineloss/mod.rs src/compat/tq_lineloss/contracts.rs src/compat/tq_lineloss/org_resolver.rs src/compat/tq_lineloss/org_units.rs tests/deterministic_submit_test.rs
|
||||||
|
git commit -m "feat: add tq lineloss org normalization"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Implement period extraction and canonical payload building
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/compat/tq_lineloss/period_resolver.rs`
|
||||||
|
- Modify: `src/compat/tq_lineloss/mod.rs`
|
||||||
|
- Modify: `tests/deterministic_submit_test.rs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing period resolver tests**
|
||||||
|
|
||||||
|
Cover:
|
||||||
|
- `月累计 2026-03`
|
||||||
|
- `月累计 2026年3月`
|
||||||
|
- `周累计 2026年第12周`
|
||||||
|
- contradictory month/week expressions prompt
|
||||||
|
- missing mode prompts
|
||||||
|
- missing period prompts
|
||||||
|
- bare `第12周` prompts for year in this slice
|
||||||
|
- real backend month/week mode codes and request payload field names are derived from source materials instead of placeholder values
|
||||||
|
|
||||||
|
Example skeleton:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[test]
|
||||||
|
fn lineloss_period_resolver_parses_month_text() {}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lineloss_period_resolver_parses_week_text() {}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lineloss_period_resolver_prompts_for_missing_year_on_week() {}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lineloss_period_resolver_rejects_contradictory_mode() {}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the period tests and confirm failure**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cargo test lineloss_period_resolver_parses_month_text -- --exact
|
||||||
|
cargo test lineloss_period_resolver_parses_week_text -- --exact
|
||||||
|
cargo test lineloss_period_resolver_prompts_for_missing_year_on_week -- --exact
|
||||||
|
cargo test lineloss_period_resolver_rejects_contradictory_mode -- --exact
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because the period resolver does not exist yet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the minimal resolver**
|
||||||
|
|
||||||
|
Output contract:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct ResolvedPeriod {
|
||||||
|
pub mode: PeriodMode,
|
||||||
|
pub mode_code: String,
|
||||||
|
pub value: String,
|
||||||
|
pub payload: serde_json::Value,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- no page-default fallback
|
||||||
|
- no implicit current-year assumptions
|
||||||
|
- no mixed month/week execution
|
||||||
|
|
||||||
|
- [ ] **Step 4: Re-run the period tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cargo test lineloss_period_resolver_parses_month_text -- --exact
|
||||||
|
cargo test lineloss_period_resolver_parses_week_text -- --exact
|
||||||
|
cargo test lineloss_period_resolver_prompts_for_missing_year_on_week -- --exact
|
||||||
|
cargo test lineloss_period_resolver_rejects_contradictory_mode -- --exact
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/compat/tq_lineloss/period_resolver.rs src/compat/tq_lineloss/mod.rs tests/deterministic_submit_test.rs
|
||||||
|
git commit -m "feat: add tq lineloss period normalization"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Wire deterministic execution through the existing browser-script seam
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/compat/deterministic_submit.rs`
|
||||||
|
- Modify: `src/agent/mod.rs`
|
||||||
|
- Modify if needed: `src/compat/direct_skill_runtime.rs`
|
||||||
|
- Modify: `tests/deterministic_submit_test.rs`
|
||||||
|
- Modify: `tests/compat_runtime_test.rs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing execution tests**
|
||||||
|
|
||||||
|
Cover:
|
||||||
|
- successful deterministic lineloss request builds canonical tool args
|
||||||
|
- missing company/mode/period returns prompt without browser execution
|
||||||
|
- `partial` artifact maps to successful partial summary
|
||||||
|
- `blocked` and `error` artifacts map to failed completion
|
||||||
|
|
||||||
|
Example skeleton:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[test]
|
||||||
|
fn deterministic_lineloss_execution_passes_canonical_args() {}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deterministic_lineloss_missing_company_does_not_invoke_browser() {}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deterministic_lineloss_partial_artifact_maps_to_partial_summary() {}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the execution tests and confirm failure**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cargo test deterministic_lineloss_execution_passes_canonical_args -- --exact
|
||||||
|
cargo test deterministic_lineloss_missing_company_does_not_invoke_browser -- --exact
|
||||||
|
cargo test deterministic_lineloss_partial_artifact_maps_to_partial_summary -- --exact
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because the deterministic execution plan is not wired yet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement execution via the existing `browser_script` seam**
|
||||||
|
|
||||||
|
Build tool args only from normalized values:
|
||||||
|
- `expected_domain`
|
||||||
|
- `org_label`
|
||||||
|
- `org_code`
|
||||||
|
- `period_mode`
|
||||||
|
- `period_mode_code`
|
||||||
|
- `period_value`
|
||||||
|
- `period_payload`
|
||||||
|
|
||||||
|
Resolve the tool explicitly to:
|
||||||
|
- `tq-lineloss-report.collect_lineloss`
|
||||||
|
|
||||||
|
Do not introduce a new browser opcode family or second browser protocol.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement central artifact interpretation**
|
||||||
|
|
||||||
|
Recommended helper:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn summarize_lineloss_artifact(artifact: &serde_json::Value) -> (bool, String)
|
||||||
|
```
|
||||||
|
|
||||||
|
Summary must include canonical org/period and row counts, and surface blocked/partial/error reasons.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Re-run the execution tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cargo test deterministic_lineloss_execution_passes_canonical_args -- --exact
|
||||||
|
cargo test deterministic_lineloss_missing_company_does_not_invoke_browser -- --exact
|
||||||
|
cargo test deterministic_lineloss_partial_artifact_maps_to_partial_summary -- --exact
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/compat/deterministic_submit.rs src/agent/mod.rs src/compat/direct_skill_runtime.rs tests/deterministic_submit_test.rs tests/compat_runtime_test.rs
|
||||||
|
git commit -m "feat: execute deterministic tq lineloss skill through browser script seam"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Add Zhihu regression coverage and run the full verification set
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/compat_runtime_test.rs`
|
||||||
|
- Modify only if required: `tests/runtime_task_flow_test.rs`
|
||||||
|
- Reuse: `tests/deterministic_submit_test.rs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add focused Zhihu regression tests**
|
||||||
|
|
||||||
|
Required assertions:
|
||||||
|
- ordinary Zhihu hotlist requests without `。。。` still use the current path
|
||||||
|
- existing export/presentation requests still preserve their current behavior
|
||||||
|
- deterministic suffix does not silently route unmatched requests into Zhihu logic
|
||||||
|
- an existing non-lineloss direct `browser_script` path outside the new scene still behaves unchanged
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add end-to-end deterministic submit coverage**
|
||||||
|
|
||||||
|
Required assertions:
|
||||||
|
- suffix detection
|
||||||
|
- scene match
|
||||||
|
- page-context mismatch prompt/block behavior when URL/title contradict the lineloss scene
|
||||||
|
- missing/ambiguous prompts
|
||||||
|
- canonical args passed to the browser-script tool
|
||||||
|
- returned summary shows canonical org and period
|
||||||
|
- execution stays on the existing pipe-backed browser-script seam with no ws-only dependency introduced on `main`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the focused Rust tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cargo test --test deterministic_submit_test
|
||||||
|
cargo test --test compat_runtime_test
|
||||||
|
cargo test --test runtime_task_flow_test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the whole Rust suite**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Re-run the staged skill JS tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
node --test "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/scripts/collect_lineloss.test.js"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/deterministic_submit_test.rs tests/compat_runtime_test.rs tests/runtime_task_flow_test.rs
|
||||||
|
git commit -m "test: cover deterministic tq lineloss routing and zhihu regression"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final verification checklist
|
||||||
|
|
||||||
|
- [ ] `。。。` is the only deterministic trigger.
|
||||||
|
- [ ] Non-`。。。` requests preserve current routing.
|
||||||
|
- [ ] Deterministic page-context mismatch blocks or mismatches before execution when URL/title contradict the lineloss scene.
|
||||||
|
- [ ] Zhihu hotlist behavior is unchanged.
|
||||||
|
- [ ] Existing non-lineloss direct `browser_script` behavior is unchanged.
|
||||||
|
- [ ] Deterministic non-match returns an explicit supported-scene message.
|
||||||
|
- [ ] Missing company prompts.
|
||||||
|
- [ ] Ambiguous company prompts.
|
||||||
|
- [ ] Missing mode prompts.
|
||||||
|
- [ ] Missing period prompts.
|
||||||
|
- [ ] Bare `第12周` prompts for year.
|
||||||
|
- [ ] Canonical org code is passed to the staged skill.
|
||||||
|
- [ ] Canonical period mode code and payload are passed to the staged skill.
|
||||||
|
- [ ] The staged skill returns the locked artifact shape.
|
||||||
|
- [ ] Execution uses the existing `browser_script` seam only.
|
||||||
|
- [ ] No ws-specific runtime dependency is added on `main`.
|
||||||
|
|
||||||
|
## Implementation notes
|
||||||
|
|
||||||
|
- Prefer extracting a tiny shared execution helper from `src/compat/direct_skill_runtime.rs` if needed instead of duplicating tool lookup or browser-script invocation code.
|
||||||
|
- Keep deterministic whitelist configuration in one place, but do not expand this slice into a full general scene-registry redesign.
|
||||||
|
- If a failing test suggests changing Zhihu behavior, fix the deterministic branch or test harness instead of weakening the existing Zhihu path.
|
||||||
|
- The checked-in unit dictionary is part of the deterministic contract; treat updates to that data as explicit behavior changes and cover them with tests.
|
||||||
@@ -0,0 +1,618 @@
|
|||||||
|
# TQ Line-Loss Deterministic Skill Design
|
||||||
|
|
||||||
|
**Goal:** Add a staged business skill for `台区线损大数据-月_周累计线损率统计分析` and a deterministic natural-language routing path in `claw-new` that can bypass LLM when the instruction ends with `。。。`, while preserving the existing Zhihu hotlist behavior and keeping the execution seam pipe-first but ws-ready.
|
||||||
|
|
||||||
|
**Status:** Approved design direction for implementation planning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision Summary
|
||||||
|
|
||||||
|
1. Add a new staged skill package `tq-lineloss-report` under `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/`, following the same packaging discipline as `fault-details-report`.
|
||||||
|
2. In `claw-new`, add a deterministic submit path triggered only when the instruction ends with the three-Chinese-dot suffix `。。。`.
|
||||||
|
3. In deterministic mode, route only through a fixed whitelist of staged skills; for this slice the new target is `tq-lineloss-report.collect_lineloss`.
|
||||||
|
4. Deterministic mode must extract business parameters from natural language without using an LLM: company/unit, month-vs-week mode, and period text.
|
||||||
|
5. Parsed natural-language parameters are not the final backend parameters. They must be normalized into the canonical codes required by the source page / source APIs (for example company code and period mode code).
|
||||||
|
6. If required parameters are missing or ambiguous, the runtime must stop and ask the user to provide them explicitly. It must **not** silently fall back to page defaults in this slice.
|
||||||
|
7. Skill execution must reuse the existing browser-script → pipe injection seam already proven by the Zhihu hotlist path. Do not create a second browser execution protocol.
|
||||||
|
8. The design must not regress or weaken the existing Zhihu hotlist direct path, browser-script path, export path, or current routing behavior.
|
||||||
|
9. The main branch implementation remains pipe-only, but all new deterministic-routing and skill contracts must stay backend-neutral so the execution backend can later be swapped to ws on the ws branch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Non-Negotiable Boundaries
|
||||||
|
|
||||||
|
### 1. Do not break the existing Zhihu hotlist flow
|
||||||
|
|
||||||
|
This is the top safety boundary for the slice.
|
||||||
|
|
||||||
|
The new deterministic routing for `tq-lineloss-report` must not break, narrow, or silently change:
|
||||||
|
|
||||||
|
- current Zhihu hotlist routing
|
||||||
|
- current Zhihu direct browser-script execution
|
||||||
|
- current Zhihu export behavior
|
||||||
|
- current browser-script skill loading/execution
|
||||||
|
- existing direct-submit configuration behavior
|
||||||
|
|
||||||
|
Design implication:
|
||||||
|
|
||||||
|
- The new deterministic path must be added as a narrow, explicit branch.
|
||||||
|
- Existing Zhihu logic must keep its current trigger semantics and current execution seam.
|
||||||
|
- Verification for this slice must include targeted Zhihu regression coverage before implementation is considered complete.
|
||||||
|
|
||||||
|
### 2. Current main branch is pipe-only
|
||||||
|
|
||||||
|
The implementation landing on `main` must execute browser-script skills through the current pipe-backed browser execution seam.
|
||||||
|
|
||||||
|
Do not introduce ws as an active runtime requirement for this slice.
|
||||||
|
|
||||||
|
### 3. Future ws migration must stay cheap
|
||||||
|
|
||||||
|
Although `main` remains pipe-only, the new work must leave a clean extension seam so that after this slice is merged into `ws`, the browser backend can be switched without redesigning:
|
||||||
|
|
||||||
|
- the staged skill package
|
||||||
|
- the deterministic trigger contract
|
||||||
|
- the parameter extraction contract
|
||||||
|
- the parameter normalization contract
|
||||||
|
- the returned artifact contract
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why This Slice Exists
|
||||||
|
|
||||||
|
The user wants a staged business skill for `台区线损大数据-月_周累计线损率统计分析` that behaves like a deterministic business operation, not a free-form LLM task.
|
||||||
|
|
||||||
|
The desired operator experience is:
|
||||||
|
|
||||||
|
- ordinary instructions continue to use the current normal routing / LLM path
|
||||||
|
- an instruction ending in `。。。` switches to deterministic business execution
|
||||||
|
- deterministic execution targets a fixed staged skill
|
||||||
|
- business parameters are extracted from the instruction
|
||||||
|
- those parameters are normalized to the real coded values the source page/API needs
|
||||||
|
- the staged browser-script is injected into the third-party browser through the existing pipe seam
|
||||||
|
|
||||||
|
This provides an inner-network-safe path that can work without a model today, while reserving an upgrade path for future semantic fallback.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Terminology
|
||||||
|
|
||||||
|
### Deterministic mode
|
||||||
|
|
||||||
|
A submit-task mode enabled only when the instruction ends with `。。。`.
|
||||||
|
|
||||||
|
### Natural-language business parameters
|
||||||
|
|
||||||
|
Values expressed by the user in text, such as:
|
||||||
|
|
||||||
|
- `兰州公司`
|
||||||
|
- `天水公司`
|
||||||
|
- `月累计`
|
||||||
|
- `周累计`
|
||||||
|
- `2026-03`
|
||||||
|
- `2026年第12周`
|
||||||
|
|
||||||
|
These are intermediate semantic values, not final backend parameters.
|
||||||
|
|
||||||
|
### Canonical execution parameters
|
||||||
|
|
||||||
|
The normalized values required by the source page / source API, such as:
|
||||||
|
|
||||||
|
- canonical company label
|
||||||
|
- canonical company code
|
||||||
|
- period mode code (month/week)
|
||||||
|
- canonical request period payload
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ownership Boundary and Landing Zones
|
||||||
|
|
||||||
|
### Staged skill changes
|
||||||
|
|
||||||
|
These land in:
|
||||||
|
|
||||||
|
`D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging`
|
||||||
|
|
||||||
|
Primary landing zone:
|
||||||
|
|
||||||
|
- `skills/tq-lineloss-report/`
|
||||||
|
|
||||||
|
Target package structure:
|
||||||
|
|
||||||
|
- `SKILL.md`
|
||||||
|
- `SKILL.toml`
|
||||||
|
- `references/collection-flow.md`
|
||||||
|
- `references/data-quality.md`
|
||||||
|
- `assets/scene-snapshot/index.html`
|
||||||
|
- `scripts/collect_lineloss.js`
|
||||||
|
- `scripts/collect_lineloss.test.js`
|
||||||
|
|
||||||
|
Potential aligned scene metadata (if included in this slice):
|
||||||
|
|
||||||
|
- `scenes/tq-lineloss-report/scene.json`
|
||||||
|
- optional scene registry updates if the current staging conventions require it
|
||||||
|
|
||||||
|
### Caller/runtime changes
|
||||||
|
|
||||||
|
These land in:
|
||||||
|
|
||||||
|
`D:/data/ideaSpace/rust/sgClaw/claw-new`
|
||||||
|
|
||||||
|
Likely ownership areas:
|
||||||
|
|
||||||
|
- deterministic instruction detection and deterministic skill matching
|
||||||
|
- parameter extraction and normalization
|
||||||
|
- deterministic skill dispatch to the existing browser-script seam
|
||||||
|
- narrow result interpretation for the returned artifact
|
||||||
|
- focused regression tests
|
||||||
|
|
||||||
|
Design rule:
|
||||||
|
|
||||||
|
`claw-new` owns routing, extraction, normalization, and dispatch.
|
||||||
|
|
||||||
|
`claw-new` must **not** absorb the line-loss business logic itself.
|
||||||
|
|
||||||
|
The staged skill package owns:
|
||||||
|
|
||||||
|
- page inspection
|
||||||
|
- page-side state reading
|
||||||
|
- page/API data collection
|
||||||
|
- row normalization
|
||||||
|
- export/report-log behavior
|
||||||
|
- final artifact generation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Target Runtime Flow
|
||||||
|
|
||||||
|
### Step 1: Submit-task enters deterministic mode only on `。。。`
|
||||||
|
|
||||||
|
When the user instruction does **not** end in `。。。`:
|
||||||
|
|
||||||
|
- keep the current runtime behavior unchanged
|
||||||
|
- preserve existing Zhihu hotlist behavior exactly
|
||||||
|
- preserve existing direct-submit and compat/LLM flows
|
||||||
|
|
||||||
|
When the instruction **does** end in `。。。`:
|
||||||
|
|
||||||
|
- enter deterministic mode
|
||||||
|
- do not run the ordinary LLM interpretation branch for this request
|
||||||
|
- evaluate only the deterministic skill whitelist
|
||||||
|
|
||||||
|
### Step 2: Deterministic whitelist match
|
||||||
|
|
||||||
|
The runtime should match the instruction against deterministic business scenes.
|
||||||
|
|
||||||
|
For this slice the new required deterministic scene is:
|
||||||
|
|
||||||
|
- `tq-lineloss-report.collect_lineloss`
|
||||||
|
|
||||||
|
The matching layer should remain narrow and explicit. It should not become a general scene-registry runtime in this slice.
|
||||||
|
|
||||||
|
Matching should use a deterministic combination of:
|
||||||
|
|
||||||
|
- instruction keywords
|
||||||
|
- optional page URL/title constraints when available
|
||||||
|
|
||||||
|
The runtime must not accidentally steal instructions that should still go down the Zhihu path.
|
||||||
|
|
||||||
|
### Step 3: Extract semantic business parameters from natural language
|
||||||
|
|
||||||
|
After `tq-lineloss-report` is matched, the runtime extracts semantic business parameters from the instruction.
|
||||||
|
|
||||||
|
Required semantic categories:
|
||||||
|
|
||||||
|
- company/unit expression
|
||||||
|
- period mode (`month` vs `week`)
|
||||||
|
- period text/value
|
||||||
|
|
||||||
|
Examples of accepted user-facing expressions include:
|
||||||
|
|
||||||
|
- `兰州公司`
|
||||||
|
- `天水公司`
|
||||||
|
- `国网兰州供电公司`
|
||||||
|
- `城关供电分公司`
|
||||||
|
- `2026-03`
|
||||||
|
- `2026年3月`
|
||||||
|
- `2026年第12周`
|
||||||
|
- `第12周`
|
||||||
|
- `月累计`
|
||||||
|
- `周累计`
|
||||||
|
|
||||||
|
### Step 4: Normalize semantic values into canonical coded values
|
||||||
|
|
||||||
|
This is a required separate design step.
|
||||||
|
|
||||||
|
The runtime must not pass raw natural-language company text directly to the business request layer.
|
||||||
|
|
||||||
|
Instead it must normalize semantic values into canonical execution parameters, including:
|
||||||
|
|
||||||
|
- `org_label` — canonical unit label
|
||||||
|
- `org_code` — the actual code/value required by the business page/API
|
||||||
|
- `period_mode` — canonical mode (`month` or `week`)
|
||||||
|
- `period_mode_code` — the page/API code (for example `timeChage`-style encoded mode)
|
||||||
|
- canonical time payload required by the source APIs/page state
|
||||||
|
|
||||||
|
This normalization should be derived from the actual source materials, including page-side dictionaries such as the existing unit tree data.
|
||||||
|
|
||||||
|
### Step 5: Missing and ambiguous parameters must stop execution
|
||||||
|
|
||||||
|
This slice must not silently infer missing parameters from page defaults.
|
||||||
|
|
||||||
|
If a required parameter is missing, execution must stop with an explicit prompt to the user.
|
||||||
|
|
||||||
|
If a parameter is ambiguous, execution must stop with an explicit ambiguity prompt.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- no company matched
|
||||||
|
- no month/week mode matched
|
||||||
|
- no period value matched when required
|
||||||
|
- a short company alias matches multiple canonical units
|
||||||
|
- both monthly and weekly intent appear in the same instruction
|
||||||
|
|
||||||
|
This is preferable to silently using the wrong company code or the wrong query period.
|
||||||
|
|
||||||
|
### Step 6: Execute the staged skill through the existing pipe seam
|
||||||
|
|
||||||
|
If and only if parameters are present and successfully normalized:
|
||||||
|
|
||||||
|
- resolve `tq-lineloss-report.collect_lineloss`
|
||||||
|
- build the args object
|
||||||
|
- execute it through the current `browser_script` runtime
|
||||||
|
- inject the script into the browser through the existing pipe-backed browser tool seam
|
||||||
|
|
||||||
|
This slice must reuse the execution pattern already proven by the current browser-script/direct-skill infrastructure and the current Zhihu hotlist path.
|
||||||
|
|
||||||
|
Do not introduce a second browser protocol, new browser opcode family, or parallel execution harness.
|
||||||
|
|
||||||
|
### Step 7: Skill JS performs page-side work and returns one artifact
|
||||||
|
|
||||||
|
The staged script owns the actual line-loss business behavior:
|
||||||
|
|
||||||
|
- reading page-side state when needed
|
||||||
|
- validating the page context
|
||||||
|
- using normalized codes/parameters from args
|
||||||
|
- building source API requests
|
||||||
|
- collecting/normalizing rows
|
||||||
|
- export/report logging behavior if required by the final business contract
|
||||||
|
- returning a structured artifact
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deterministic Trigger Contract
|
||||||
|
|
||||||
|
### Trigger rule
|
||||||
|
|
||||||
|
Deterministic mode is activated only when the raw instruction ends with the exact three-Chinese-dot suffix:
|
||||||
|
|
||||||
|
- `。。。`
|
||||||
|
|
||||||
|
This suffix is a user-controlled explicit mode switch.
|
||||||
|
|
||||||
|
### Why the suffix exists
|
||||||
|
|
||||||
|
It lets the user force business-deterministic behavior without relying on a model, while preserving the normal LLM path for ordinary requests.
|
||||||
|
|
||||||
|
### Scope rule
|
||||||
|
|
||||||
|
The suffix is not a free pass to run arbitrary browser actions.
|
||||||
|
|
||||||
|
It only selects among the deterministic skill whitelist.
|
||||||
|
|
||||||
|
If no deterministic scene matches, the runtime should return a deterministic-mode mismatch error that explains the currently supported deterministic scenes, rather than silently dropping into another behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Company / Unit Matching Contract
|
||||||
|
|
||||||
|
### Accepted input style
|
||||||
|
|
||||||
|
The user does **not** need to type the exact full canonical label.
|
||||||
|
|
||||||
|
The runtime should support business shorthand such as:
|
||||||
|
|
||||||
|
- `兰州公司`
|
||||||
|
- `天水公司`
|
||||||
|
- `白银公司`
|
||||||
|
- `城关供电分公司`
|
||||||
|
- `榆中县供电公司`
|
||||||
|
|
||||||
|
### Matching approach
|
||||||
|
|
||||||
|
Do not use regex alone as the primary company-resolution mechanism.
|
||||||
|
|
||||||
|
Use a three-stage resolution strategy:
|
||||||
|
|
||||||
|
1. text normalization
|
||||||
|
2. alias/candidate generation from canonical unit names
|
||||||
|
3. uniqueness resolution against the real unit dictionary
|
||||||
|
|
||||||
|
### Normalization examples
|
||||||
|
|
||||||
|
Canonical names such as:
|
||||||
|
|
||||||
|
- `国网兰州供电公司`
|
||||||
|
- `国网天水供电公司`
|
||||||
|
- `国网榆中县供电公司`
|
||||||
|
|
||||||
|
should be matchable from business shorthand forms such as:
|
||||||
|
|
||||||
|
- `兰州公司`
|
||||||
|
- `天水公司`
|
||||||
|
- `榆中县公司`
|
||||||
|
- `榆中供电公司`
|
||||||
|
|
||||||
|
### Data source for canonical mapping
|
||||||
|
|
||||||
|
The company/unit resolver should derive canonical mappings from the real source materials used by the business page, such as the current unit tree dictionary embedded in the source page resources.
|
||||||
|
|
||||||
|
Design implication:
|
||||||
|
|
||||||
|
- the resolver should produce the real `value`/code required downstream
|
||||||
|
- the resolver should also keep the canonical label for display/auditability
|
||||||
|
|
||||||
|
### Ambiguity rule
|
||||||
|
|
||||||
|
If a short alias resolves to more than one valid unit, execution must stop and ask the user to be more specific.
|
||||||
|
|
||||||
|
Do not auto-guess.
|
||||||
|
|
||||||
|
### Supported granularity
|
||||||
|
|
||||||
|
The first implementation must support both:
|
||||||
|
|
||||||
|
- city-company level
|
||||||
|
- district/county/sub-company level
|
||||||
|
|
||||||
|
This includes forms like:
|
||||||
|
|
||||||
|
- `兰州公司`
|
||||||
|
- `天水公司`
|
||||||
|
- `城关供电分公司`
|
||||||
|
- `榆中县供电公司`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Period Extraction and Normalization Contract
|
||||||
|
|
||||||
|
### Required period dimensions
|
||||||
|
|
||||||
|
The runtime must identify:
|
||||||
|
|
||||||
|
- mode: `month` or `week`
|
||||||
|
- actual requested period value in a canonical form
|
||||||
|
|
||||||
|
### Accepted user-facing patterns
|
||||||
|
|
||||||
|
At minimum the design should account for patterns such as:
|
||||||
|
|
||||||
|
- `月累计`
|
||||||
|
- `周累计`
|
||||||
|
- `2026-03`
|
||||||
|
- `2026年3月`
|
||||||
|
- `2026年第12周`
|
||||||
|
- `第12周`
|
||||||
|
|
||||||
|
### Normalization output
|
||||||
|
|
||||||
|
The resolver should produce:
|
||||||
|
|
||||||
|
- a canonical mode enum/string
|
||||||
|
- a mode code required by the page/API
|
||||||
|
- a canonical period payload consumable by the script/business request layer
|
||||||
|
|
||||||
|
### Ambiguity rule
|
||||||
|
|
||||||
|
If both month and week intent appear, stop and ask the user to clarify.
|
||||||
|
|
||||||
|
### Missing-period rule
|
||||||
|
|
||||||
|
If the selected line-loss query requires a time period and the instruction does not provide enough information to construct one, stop and ask the user to provide it.
|
||||||
|
|
||||||
|
Do not default to the page-selected period in this slice.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parameter Prompting Contract
|
||||||
|
|
||||||
|
When deterministic mode matches `tq-lineloss-report` but one or more required parameters are missing or ambiguous, the runtime should return a user-facing prompt rather than executing.
|
||||||
|
|
||||||
|
Expected prompting cases include:
|
||||||
|
|
||||||
|
- missing company/unit
|
||||||
|
- missing month/week mode
|
||||||
|
- missing period value
|
||||||
|
- ambiguous company alias
|
||||||
|
- contradictory period expressions
|
||||||
|
|
||||||
|
The prompt should be specific enough to let the user correct only the missing field(s).
|
||||||
|
|
||||||
|
Example style:
|
||||||
|
|
||||||
|
- `已命中台区线损报表技能,但缺少供电单位,请补充如“兰州公司”或“城关供电分公司”。`
|
||||||
|
- `已命中台区线损报表技能,但未识别到月/周类型,请补充“月累计”或“周累计”。`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Skill Package Contract
|
||||||
|
|
||||||
|
### SKILL.toml
|
||||||
|
|
||||||
|
The new skill package must declare a single deterministic collection entrypoint:
|
||||||
|
|
||||||
|
- tool name: `collect_lineloss`
|
||||||
|
- kind: `browser_script`
|
||||||
|
|
||||||
|
The tool description must reflect the real staged behavior, not a placeholder shell.
|
||||||
|
|
||||||
|
### SKILL.md
|
||||||
|
|
||||||
|
The written contract should cover:
|
||||||
|
|
||||||
|
- when to use the skill
|
||||||
|
- when not to use it
|
||||||
|
- collection workflow
|
||||||
|
- runtime contract
|
||||||
|
- explicit missing/partial/error semantics
|
||||||
|
- returned artifact contract
|
||||||
|
|
||||||
|
### references/collection-flow.md
|
||||||
|
|
||||||
|
Must explain:
|
||||||
|
|
||||||
|
- the source page state used by the skill
|
||||||
|
- how company and period parameters map to business requests
|
||||||
|
- which page/API calls are used for month vs week
|
||||||
|
- export/report-log sequencing if retained in the business flow
|
||||||
|
|
||||||
|
### references/data-quality.md
|
||||||
|
|
||||||
|
Must define:
|
||||||
|
|
||||||
|
- canonical output columns
|
||||||
|
- required field coverage
|
||||||
|
- status semantics
|
||||||
|
- partial/error conditions
|
||||||
|
- company/period normalization assumptions that the script relies on
|
||||||
|
|
||||||
|
### scripts/collect_lineloss.js
|
||||||
|
|
||||||
|
This is the real browser-side entrypoint. It should:
|
||||||
|
|
||||||
|
- accept normalized args
|
||||||
|
- validate page context
|
||||||
|
- execute deterministic page/API data collection
|
||||||
|
- normalize rows
|
||||||
|
- perform downstream export/report-history behavior if required
|
||||||
|
- directly return the final artifact from the browser-script runtime entrypoint shape
|
||||||
|
|
||||||
|
### scripts/collect_lineloss.test.js
|
||||||
|
|
||||||
|
Must cover the business transforms that can be tested off-browser, especially:
|
||||||
|
|
||||||
|
- company normalization assumptions consumed by the script
|
||||||
|
- monthly vs weekly request-shape logic
|
||||||
|
- status semantics
|
||||||
|
- artifact shaping
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Returned Artifact Contract
|
||||||
|
|
||||||
|
The final line-loss skill should return one structured artifact object rather than free-form prose.
|
||||||
|
|
||||||
|
At minimum it should expose:
|
||||||
|
|
||||||
|
- artifact type
|
||||||
|
- report name
|
||||||
|
- canonical company label/code used for the query
|
||||||
|
- period mode and canonical period value used for the query
|
||||||
|
- columns
|
||||||
|
- rows
|
||||||
|
- status
|
||||||
|
- counts
|
||||||
|
- downstream export/report-log status when applicable
|
||||||
|
- clear reasons for blocked/partial/error states
|
||||||
|
|
||||||
|
The exact field names may be finalized during implementation planning, but the contract must be structured enough for `claw-new` to interpret success vs partial vs blocked without re-embedding business logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pipe-First / Ws-Ready Execution Seam
|
||||||
|
|
||||||
|
### Current requirement
|
||||||
|
|
||||||
|
The first implementation on `main` must use the existing pipe-backed browser execution path.
|
||||||
|
|
||||||
|
### Future requirement
|
||||||
|
|
||||||
|
The design must allow later ws adoption without redesigning the skill or routing contract.
|
||||||
|
|
||||||
|
### Practical design rule
|
||||||
|
|
||||||
|
Keep these backend-neutral:
|
||||||
|
|
||||||
|
- deterministic trigger contract
|
||||||
|
- skill matching contract
|
||||||
|
- parameter extraction contract
|
||||||
|
- parameter normalization contract
|
||||||
|
- tool args contract
|
||||||
|
- artifact contract
|
||||||
|
|
||||||
|
Keep backend-specific code isolated to the execution seam only.
|
||||||
|
|
||||||
|
That way the later ws migration can replace the browser backend beneath the same deterministic skill contract.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Caller/Runtime Design Rules
|
||||||
|
|
||||||
|
### 1. Keep new business logic out of broad orchestration
|
||||||
|
|
||||||
|
Do not thread line-loss-specific business behavior through the general orchestration/runtime path.
|
||||||
|
|
||||||
|
### 2. Add a narrow deterministic-routing seam
|
||||||
|
|
||||||
|
This slice should add a narrow deterministic branch around submit-task routing, rather than rewriting the whole runtime decision tree.
|
||||||
|
|
||||||
|
### 3. Separate extraction from normalization
|
||||||
|
|
||||||
|
Do not mix “what the user typed” with “what the backend needs”.
|
||||||
|
|
||||||
|
There must be a distinct normalization step.
|
||||||
|
|
||||||
|
### 4. Keep the direct-skill browser seam narrow
|
||||||
|
|
||||||
|
Reuse the current `browser_script` execution seam instead of inventing a new browser bridge.
|
||||||
|
|
||||||
|
### 5. Preserve Zhihu behavior by design, not by hope
|
||||||
|
|
||||||
|
The design should assume new deterministic routing can accidentally steal or alter existing Zhihu behavior unless explicitly guarded against.
|
||||||
|
|
||||||
|
This is why focused Zhihu regression coverage is mandatory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Requirements for the Future Implementation Plan
|
||||||
|
|
||||||
|
Implementation planning must include explicit verification for:
|
||||||
|
|
||||||
|
1. deterministic suffix detection
|
||||||
|
2. deterministic lineloss scene matching
|
||||||
|
3. company alias normalization to canonical code
|
||||||
|
4. support for both company-level and district/county/sub-company-level units
|
||||||
|
5. month/week extraction and normalization
|
||||||
|
6. missing-parameter prompt behavior
|
||||||
|
7. ambiguous-company prompt behavior
|
||||||
|
8. pipe-backed browser-script execution for the new skill
|
||||||
|
9. no regression to the existing Zhihu hotlist path
|
||||||
|
10. preserved direct-skill/browser-script behavior outside the new line-loss scene
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope for This Slice
|
||||||
|
|
||||||
|
- enabling ws execution on `main`
|
||||||
|
- replacing the current Zhihu routing model
|
||||||
|
- general scene-registry runtime architecture redesign
|
||||||
|
- full free-form semantic understanding of arbitrary business language
|
||||||
|
- typo-tolerant fuzzy NLP beyond deterministic business-safe matching
|
||||||
|
- making page defaults the hidden source of truth when the user omitted parameters
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Planning Notes
|
||||||
|
|
||||||
|
The implementation plan should likely split into distinct work items for:
|
||||||
|
|
||||||
|
1. staged skill package creation and business contract definition
|
||||||
|
2. deterministic trigger + scene match in `claw-new`
|
||||||
|
3. company/unit normalization and ambiguity handling
|
||||||
|
4. period extraction/normalization and ambiguity handling
|
||||||
|
5. pipe-backed direct execution integration
|
||||||
|
6. returned artifact interpretation
|
||||||
|
7. Zhihu regression verification
|
||||||
|
8. ws-readiness seam verification
|
||||||
|
|
||||||
|
The plan should explicitly keep the “do not break Zhihu hotlist” boundary visible in every execution and verification stage.
|
||||||
@@ -132,6 +132,40 @@ impl<T: Transport + ?Sized> AgentEventSink for T {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_submit_instruction(
|
||||||
|
instruction: String,
|
||||||
|
page_url: Option<&str>,
|
||||||
|
page_title: Option<&str>,
|
||||||
|
) -> Result<(String, Option<crate::compat::deterministic_submit::DeterministicExecutionPlan>), AgentMessage> {
|
||||||
|
let raw_instruction = instruction;
|
||||||
|
let trimmed_instruction = raw_instruction.trim().to_string();
|
||||||
|
if trimmed_instruction.is_empty() {
|
||||||
|
return Err(AgentMessage::TaskComplete {
|
||||||
|
success: false,
|
||||||
|
summary: "请输入任务内容。".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
match crate::compat::deterministic_submit::decide_deterministic_submit(
|
||||||
|
&raw_instruction,
|
||||||
|
page_url,
|
||||||
|
page_title,
|
||||||
|
) {
|
||||||
|
crate::compat::deterministic_submit::DeterministicSubmitDecision::NotDeterministic => {
|
||||||
|
Ok((trimmed_instruction, None))
|
||||||
|
}
|
||||||
|
crate::compat::deterministic_submit::DeterministicSubmitDecision::Prompt { summary } => {
|
||||||
|
Err(AgentMessage::TaskComplete {
|
||||||
|
success: false,
|
||||||
|
summary,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
crate::compat::deterministic_submit::DeterministicSubmitDecision::Execute(plan) => {
|
||||||
|
Ok((plan.instruction.clone(), Some(plan)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn run_submit_task<T: Transport + 'static>(
|
pub fn run_submit_task<T: Transport + 'static>(
|
||||||
transport: &T,
|
transport: &T,
|
||||||
sink: &dyn AgentEventSink,
|
sink: &dyn AgentEventSink,
|
||||||
@@ -146,13 +180,6 @@ pub fn run_submit_task<T: Transport + 'static>(
|
|||||||
page_url,
|
page_url,
|
||||||
page_title,
|
page_title,
|
||||||
} = request;
|
} = request;
|
||||||
let instruction = instruction.trim().to_string();
|
|
||||||
if instruction.is_empty() {
|
|
||||||
return sink.send(&AgentMessage::TaskComplete {
|
|
||||||
success: false,
|
|
||||||
summary: "请输入任务内容。".to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let task_context = CompatTaskContext {
|
let task_context = CompatTaskContext {
|
||||||
conversation_id,
|
conversation_id,
|
||||||
@@ -160,6 +187,14 @@ pub fn run_submit_task<T: Transport + 'static>(
|
|||||||
page_url,
|
page_url,
|
||||||
page_title,
|
page_title,
|
||||||
};
|
};
|
||||||
|
let (instruction, deterministic_plan) = match resolve_submit_instruction(
|
||||||
|
instruction,
|
||||||
|
task_context.page_url.as_deref(),
|
||||||
|
task_context.page_title.as_deref(),
|
||||||
|
) {
|
||||||
|
Ok(resolved) => resolved,
|
||||||
|
Err(completion) => return sink.send(&completion),
|
||||||
|
};
|
||||||
let _ = sink.send(&AgentMessage::LogEntry {
|
let _ = sink.send(&AgentMessage::LogEntry {
|
||||||
level: "info".to_string(),
|
level: "info".to_string(),
|
||||||
message: runtime_version_log_message(),
|
message: runtime_version_log_message(),
|
||||||
@@ -198,7 +233,32 @@ pub fn run_submit_task<T: Transport + 'static>(
|
|||||||
settings.runtime_profile, settings.skills_prompt_mode
|
settings.runtime_profile, settings.skills_prompt_mode
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
if settings.direct_submit_skill.is_some() {
|
if let Some(plan) = deterministic_plan.as_ref() {
|
||||||
|
let _ = send_mode_log(sink, "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 sink.send(&completion);
|
||||||
|
}
|
||||||
|
if settings
|
||||||
|
.direct_submit_skill
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.is_some_and(|value| !value.is_empty())
|
||||||
|
{
|
||||||
match crate::compat::direct_skill_runtime::execute_direct_submit_skill(
|
match crate::compat::direct_skill_runtime::execute_direct_submit_skill(
|
||||||
browser_tool.clone(),
|
browser_tool.clone(),
|
||||||
&instruction,
|
&instruction,
|
||||||
@@ -311,13 +371,6 @@ pub fn run_submit_task_with_browser_backend<T: Transport + 'static>(
|
|||||||
page_url,
|
page_url,
|
||||||
page_title,
|
page_title,
|
||||||
} = request;
|
} = request;
|
||||||
let instruction = instruction.trim().to_string();
|
|
||||||
if instruction.is_empty() {
|
|
||||||
return sink.send(&AgentMessage::TaskComplete {
|
|
||||||
success: false,
|
|
||||||
summary: "请输入任务内容。".to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let task_context = CompatTaskContext {
|
let task_context = CompatTaskContext {
|
||||||
conversation_id,
|
conversation_id,
|
||||||
@@ -325,6 +378,14 @@ pub fn run_submit_task_with_browser_backend<T: Transport + 'static>(
|
|||||||
page_url,
|
page_url,
|
||||||
page_title,
|
page_title,
|
||||||
};
|
};
|
||||||
|
let (instruction, deterministic_plan) = match resolve_submit_instruction(
|
||||||
|
instruction,
|
||||||
|
task_context.page_url.as_deref(),
|
||||||
|
task_context.page_title.as_deref(),
|
||||||
|
) {
|
||||||
|
Ok(resolved) => resolved,
|
||||||
|
Err(completion) => return sink.send(&completion),
|
||||||
|
};
|
||||||
let _ = sink.send(&AgentMessage::LogEntry {
|
let _ = sink.send(&AgentMessage::LogEntry {
|
||||||
level: "info".to_string(),
|
level: "info".to_string(),
|
||||||
message: runtime_version_log_message(),
|
message: runtime_version_log_message(),
|
||||||
@@ -363,6 +424,61 @@ pub fn run_submit_task_with_browser_backend<T: Transport + 'static>(
|
|||||||
settings.runtime_profile, settings.skills_prompt_mode
|
settings.runtime_profile, settings.skills_prompt_mode
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
if let Some(plan) = deterministic_plan.as_ref() {
|
||||||
|
let _ = send_mode_log(sink, "direct_skill_primary");
|
||||||
|
let completion = match crate::compat::deterministic_submit::execute_deterministic_submit_with_browser_backend(
|
||||||
|
browser_backend.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 sink.send(&completion);
|
||||||
|
}
|
||||||
|
if settings
|
||||||
|
.direct_submit_skill
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.is_some_and(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
match crate::compat::direct_skill_runtime::execute_direct_submit_skill_with_browser_backend(
|
||||||
|
browser_backend.clone(),
|
||||||
|
&instruction,
|
||||||
|
&task_context,
|
||||||
|
&context.workspace_root,
|
||||||
|
&settings,
|
||||||
|
) {
|
||||||
|
Ok(outcome) => {
|
||||||
|
let _ = send_mode_log(sink, "direct_skill_primary");
|
||||||
|
return sink.send(&AgentMessage::TaskComplete {
|
||||||
|
success: outcome.success,
|
||||||
|
summary: outcome.summary,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(PipeError::Protocol(message))
|
||||||
|
if message.contains("must use skill.tool format") =>
|
||||||
|
{
|
||||||
|
return sink.send(&AgentMessage::TaskComplete {
|
||||||
|
success: false,
|
||||||
|
summary: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
return sink.send(&AgentMessage::TaskComplete {
|
||||||
|
success: false,
|
||||||
|
summary: err.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if RuntimeEngine::new(settings.runtime_profile).browser_surface_enabled()
|
if RuntimeEngine::new(settings.runtime_profile).browser_surface_enabled()
|
||||||
&& crate::compat::orchestration::should_use_primary_orchestration(
|
&& crate::compat::orchestration::should_use_primary_orchestration(
|
||||||
&instruction,
|
&instruction,
|
||||||
|
|||||||
297
src/compat/deterministic_submit.rs
Normal file
297
src/compat/deterministic_submit.rs
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
use crate::browser::BrowserBackend;
|
||||||
|
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 args = deterministic_submit_args(plan);
|
||||||
|
let output = crate::compat::direct_skill_runtime::execute_browser_script_skill_raw_output(
|
||||||
|
browser_tool,
|
||||||
|
&plan.tool_name,
|
||||||
|
workspace_root,
|
||||||
|
settings,
|
||||||
|
args,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(summarize_lineloss_output(&output))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn execute_deterministic_submit_with_browser_backend(
|
||||||
|
browser_backend: Arc<dyn BrowserBackend>,
|
||||||
|
plan: &DeterministicExecutionPlan,
|
||||||
|
workspace_root: &Path,
|
||||||
|
settings: &SgClawSettings,
|
||||||
|
) -> Result<DirectSubmitOutcome, PipeError> {
|
||||||
|
let args = deterministic_submit_args(plan);
|
||||||
|
let output =
|
||||||
|
crate::compat::direct_skill_runtime::execute_browser_script_skill_raw_output_with_browser_backend(
|
||||||
|
browser_backend,
|
||||||
|
&plan.tool_name,
|
||||||
|
workspace_root,
|
||||||
|
settings,
|
||||||
|
args,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(summarize_lineloss_output(&output))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deterministic_submit_args(plan: &DeterministicExecutionPlan) -> Map<String, Value> {
|
||||||
|
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()),
|
||||||
|
);
|
||||||
|
args
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use reqwest::Url;
|
use reqwest::Url;
|
||||||
use serde_json::{Map, Value};
|
use serde_json::{Map, Value};
|
||||||
use zeroclaw::skills::load_skills_from_directory;
|
use zeroclaw::skills::{load_skills_from_directory, SkillTool};
|
||||||
|
|
||||||
use crate::browser::PipeBrowserBackend;
|
use crate::browser::{BrowserBackend, PipeBrowserBackend};
|
||||||
use crate::compat::browser_script_skill_tool::execute_browser_script_tool;
|
use crate::compat::browser_script_skill_tool::execute_browser_script_tool;
|
||||||
use crate::compat::config_adapter::resolve_skills_dir_from_sgclaw_settings;
|
use crate::compat::config_adapter::resolve_skills_dir_from_sgclaw_settings;
|
||||||
use crate::compat::runtime::CompatTaskContext;
|
use crate::compat::runtime::CompatTaskContext;
|
||||||
@@ -30,13 +31,96 @@ pub fn execute_direct_submit_skill<T: Transport + 'static>(
|
|||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.ok_or_else(|| PipeError::Protocol("direct submit skill is not configured".to_string()))?;
|
.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 expected_domain = derive_expected_domain(task_context)?;
|
||||||
let period = derive_period(instruction)?;
|
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_direct_submit_skill_with_browser_backend(
|
||||||
|
browser_backend: Arc<dyn BrowserBackend>,
|
||||||
|
instruction: &str,
|
||||||
|
task_context: &CompatTaskContext,
|
||||||
|
workspace_root: &Path,
|
||||||
|
settings: &SgClawSettings,
|
||||||
|
) -> Result<DirectSubmitOutcome, PipeError> {
|
||||||
|
let configured_tool = settings
|
||||||
|
.direct_submit_skill
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| PipeError::Protocol("direct submit skill is not configured".to_string()))?;
|
||||||
|
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_with_browser_backend(
|
||||||
|
browser_backend,
|
||||||
|
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 (tool, skill_root) = resolve_browser_script_skill(configured_tool, workspace_root, settings)?;
|
||||||
|
|
||||||
|
execute_browser_script_tool_output(browser_tool, configured_tool, &tool, &skill_root, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn execute_browser_script_skill_raw_output_with_browser_backend(
|
||||||
|
browser_backend: Arc<dyn BrowserBackend>,
|
||||||
|
configured_tool: &str,
|
||||||
|
workspace_root: &Path,
|
||||||
|
settings: &SgClawSettings,
|
||||||
|
args: Map<String, Value>,
|
||||||
|
) -> Result<String, PipeError> {
|
||||||
|
let (tool, skill_root) =
|
||||||
|
resolve_browser_script_skill(configured_tool, workspace_root, settings)?;
|
||||||
|
|
||||||
|
execute_browser_script_tool_output_with_backend(
|
||||||
|
browser_backend.as_ref(),
|
||||||
|
configured_tool,
|
||||||
|
&tool,
|
||||||
|
&skill_root,
|
||||||
|
args,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_browser_script_skill(
|
||||||
|
configured_tool: &str,
|
||||||
|
workspace_root: &Path,
|
||||||
|
settings: &SgClawSettings,
|
||||||
|
) -> Result<(SkillTool, std::path::PathBuf), 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_dir = resolve_skills_dir_from_sgclaw_settings(workspace_root, settings);
|
||||||
let skills = load_skills_from_directory(&skills_dir, true);
|
let skills = load_skills_from_directory(&skills_dir, true);
|
||||||
let skill = skills
|
let skill = skills
|
||||||
.iter()
|
.into_iter()
|
||||||
.find(|skill| skill.name == skill_name)
|
.find(|skill| skill.name == skill_name)
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
PipeError::Protocol(format!(
|
PipeError::Protocol(format!(
|
||||||
@@ -44,16 +128,54 @@ pub fn execute_direct_submit_skill<T: Transport + 'static>(
|
|||||||
skills_dir.display()
|
skills_dir.display()
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
let skill_root = skill
|
||||||
|
.location
|
||||||
|
.as_deref()
|
||||||
|
.and_then(Path::parent)
|
||||||
|
.map(Path::to_path_buf)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
PipeError::Protocol(format!(
|
||||||
|
"direct submit skill {skill_name} is missing a resolvable location"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
let tool = skill
|
let tool = skill
|
||||||
.tools
|
.tools
|
||||||
.iter()
|
.iter()
|
||||||
.find(|tool| tool.name == tool_name)
|
.find(|tool| tool.name == tool_name)
|
||||||
|
.cloned()
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
PipeError::Protocol(format!(
|
PipeError::Protocol(format!(
|
||||||
"direct submit tool {configured_tool} was not found"
|
"direct submit tool {configured_tool} was not found"
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
Ok((tool, skill_root))
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
||||||
|
let browser_backend = PipeBrowserBackend::from_inner(browser_tool);
|
||||||
|
execute_browser_script_tool_output_with_backend(
|
||||||
|
&browser_backend,
|
||||||
|
configured_tool,
|
||||||
|
tool,
|
||||||
|
skill_root,
|
||||||
|
args,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_browser_script_tool_output_with_backend(
|
||||||
|
browser_backend: &dyn BrowserBackend,
|
||||||
|
configured_tool: &str,
|
||||||
|
tool: &SkillTool,
|
||||||
|
skill_root: &Path,
|
||||||
|
args: Map<String, Value>,
|
||||||
|
) -> Result<String, PipeError> {
|
||||||
if tool.kind != "browser_script" {
|
if tool.kind != "browser_script" {
|
||||||
return Err(PipeError::Protocol(format!(
|
return Err(PipeError::Protocol(format!(
|
||||||
"direct submit tool {configured_tool} must be browser_script, got {}",
|
"direct submit tool {configured_tool} must be browser_script, got {}",
|
||||||
@@ -61,34 +183,22 @@ pub fn execute_direct_submit_skill<T: Transport + 'static>(
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let skill_root = skill
|
let mut tool = tool.clone();
|
||||||
.location
|
tool.args.remove("expected_domain");
|
||||||
.as_deref()
|
|
||||||
.and_then(Path::parent)
|
|
||||||
.ok_or_else(|| {
|
|
||||||
PipeError::Protocol(format!(
|
|
||||||
"direct submit skill {skill_name} is missing a resolvable location"
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let mut args = Map::new();
|
|
||||||
args.insert("expected_domain".to_string(), Value::String(expected_domain));
|
|
||||||
args.insert("period".to_string(), Value::String(period));
|
|
||||||
|
|
||||||
let runtime = tokio::runtime::Runtime::new()
|
let runtime = tokio::runtime::Runtime::new()
|
||||||
.map_err(|err| PipeError::Protocol(format!("failed to create tokio runtime: {err}")))?;
|
.map_err(|err| PipeError::Protocol(format!("failed to create tokio runtime: {err}")))?;
|
||||||
let browser_backend = PipeBrowserBackend::from_inner(browser_tool);
|
|
||||||
let result = runtime
|
let result = runtime
|
||||||
.block_on(execute_browser_script_tool(
|
.block_on(execute_browser_script_tool(
|
||||||
tool,
|
&tool,
|
||||||
skill_root,
|
skill_root,
|
||||||
&browser_backend,
|
browser_backend,
|
||||||
Value::Object(args),
|
Value::Object(args),
|
||||||
))
|
))
|
||||||
.map_err(|err| PipeError::Protocol(err.to_string()))?;
|
.map_err(|err| PipeError::Protocol(err.to_string()))?;
|
||||||
|
|
||||||
if result.success {
|
if result.success {
|
||||||
Ok(interpret_direct_submit_output(&result.output))
|
Ok(result.output)
|
||||||
} else {
|
} else {
|
||||||
Err(PipeError::Protocol(
|
Err(PipeError::Protocol(
|
||||||
result
|
result
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ pub mod browser_script_skill_tool;
|
|||||||
pub mod browser_tool_adapter;
|
pub mod browser_tool_adapter;
|
||||||
pub mod config_adapter;
|
pub mod config_adapter;
|
||||||
pub mod cron_adapter;
|
pub mod cron_adapter;
|
||||||
|
pub mod deterministic_submit;
|
||||||
pub mod direct_skill_runtime;
|
pub mod direct_skill_runtime;
|
||||||
pub mod event_bridge;
|
pub mod event_bridge;
|
||||||
pub mod memory_adapter;
|
pub mod memory_adapter;
|
||||||
@@ -10,4 +11,5 @@ pub mod openxml_office_tool;
|
|||||||
pub mod orchestration;
|
pub mod orchestration;
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
pub mod screen_html_export_tool;
|
pub mod screen_html_export_tool;
|
||||||
|
pub mod tq_lineloss;
|
||||||
pub mod workflow_executor;
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,12 +61,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)
|
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,
|
root: &PathBuf,
|
||||||
api_key: &str,
|
api_key: &str,
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
model: &str,
|
model: &str,
|
||||||
skills_dir: Option<&str>,
|
skills_dir: Option<&str>,
|
||||||
|
direct_submit_skill: Option<&str>,
|
||||||
) -> PathBuf {
|
) -> PathBuf {
|
||||||
let config_path = root.join("sgclaw_config.json");
|
let config_path = root.join("sgclaw_config.json");
|
||||||
let mut payload = json!({
|
let mut payload = json!({
|
||||||
@@ -77,6 +78,9 @@ fn write_deepseek_config_with_skills_dir(
|
|||||||
if let Some(skills_dir) = skills_dir {
|
if let Some(skills_dir) = skills_dir {
|
||||||
payload["skillsDir"] = json!(skills_dir);
|
payload["skillsDir"] = json!(skills_dir);
|
||||||
}
|
}
|
||||||
|
if let Some(direct_submit_skill) = direct_submit_skill {
|
||||||
|
payload["directSubmitSkill"] = json!(direct_submit_skill);
|
||||||
|
}
|
||||||
|
|
||||||
fs::write(
|
fs::write(
|
||||||
&config_path,
|
&config_path,
|
||||||
@@ -86,6 +90,16 @@ fn write_deepseek_config_with_skills_dir(
|
|||||||
config_path
|
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) {
|
fn write_skill_package(skills_dir: &std::path::Path, skill_name: &str, body: &str) {
|
||||||
let skill_dir = skills_dir.join(skill_name);
|
let skill_dir = skills_dir.join(skill_name);
|
||||||
fs::create_dir_all(&skill_dir).unwrap();
|
fs::create_dir_all(&skill_dir).unwrap();
|
||||||
@@ -206,10 +220,17 @@ fn read_http_json_body(stream: &mut impl Read) -> Value {
|
|||||||
|
|
||||||
while headers_end.is_none() {
|
while headers_end.is_none() {
|
||||||
let mut chunk = [0_u8; 1024];
|
let mut chunk = [0_u8; 1024];
|
||||||
let bytes = stream.read(&mut chunk).unwrap();
|
match stream.read(&mut chunk) {
|
||||||
assert!(bytes > 0, "unexpected EOF while reading headers");
|
Ok(bytes) => {
|
||||||
buffer.extend_from_slice(&chunk[..bytes]);
|
assert!(bytes > 0, "unexpected EOF while reading headers");
|
||||||
headers_end = buffer.windows(4).position(|window| window == b"\r\n\r\n");
|
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;
|
let headers_end = headers_end.unwrap() + 4;
|
||||||
@@ -225,9 +246,16 @@ fn read_http_json_body(stream: &mut impl Read) -> Value {
|
|||||||
|
|
||||||
while buffer.len() < headers_end + content_length {
|
while buffer.len() < headers_end + content_length {
|
||||||
let mut chunk = vec![0_u8; content_length];
|
let mut chunk = vec![0_u8; content_length];
|
||||||
let bytes = stream.read(&mut chunk).unwrap();
|
match stream.read(&mut chunk) {
|
||||||
assert!(bytes > 0, "unexpected EOF while reading body");
|
Ok(bytes) => {
|
||||||
buffer.extend_from_slice(&chunk[..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()
|
serde_json::from_slice(&buffer[headers_end..headers_end + content_length]).unwrap()
|
||||||
@@ -239,7 +267,7 @@ fn task_complete_summary(sent: &[AgentMessage]) -> String {
|
|||||||
AgentMessage::TaskComplete { success, summary } if *success => Some(summary.clone()),
|
AgentMessage::TaskComplete { success, summary } if *success => Some(summary.clone()),
|
||||||
_ => None,
|
_ => 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 {
|
fn extract_generated_artifact_path(summary: &str, extension: &str) -> PathBuf {
|
||||||
@@ -683,11 +711,9 @@ fn handle_browser_message_routes_supported_instruction_to_compat_runtime_when_ll
|
|||||||
matches!(
|
matches!(
|
||||||
message,
|
message,
|
||||||
AgentMessage::LogEntry { level, message }
|
AgentMessage::LogEntry { level, message }
|
||||||
if level == "info" &&
|
if level == "info"
|
||||||
message == &format!(
|
&& message.starts_with("sgclaw runtime version=")
|
||||||
"sgclaw runtime version={} protocol=1.0",
|
&& message.ends_with(" protocol=1.0")
|
||||||
env!("CARGO_PKG_VERSION")
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}));
|
}));
|
||||||
assert!(sent.iter().any(|message| {
|
assert!(sent.iter().any(|message| {
|
||||||
@@ -895,6 +921,7 @@ fn handle_browser_message_falls_back_to_compat_runtime_for_unsupported_instructi
|
|||||||
#[test]
|
#[test]
|
||||||
fn handle_browser_message_requires_llm_configuration_when_no_model_is_available() {
|
fn handle_browser_message_requires_llm_configuration_when_no_model_is_available() {
|
||||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||||
|
|
||||||
std::env::remove_var("DEEPSEEK_API_KEY");
|
std::env::remove_var("DEEPSEEK_API_KEY");
|
||||||
std::env::remove_var("DEEPSEEK_BASE_URL");
|
std::env::remove_var("DEEPSEEK_BASE_URL");
|
||||||
std::env::remove_var("DEEPSEEK_MODEL");
|
std::env::remove_var("DEEPSEEK_MODEL");
|
||||||
@@ -1872,28 +1899,20 @@ fn handle_browser_message_exposes_real_zhihu_skill_lib_to_provider_request() {
|
|||||||
let request_bodies = requests.lock().unwrap().clone();
|
let request_bodies = requests.lock().unwrap().clone();
|
||||||
let first_request = request_bodies[0].to_string();
|
let first_request = request_bodies[0].to_string();
|
||||||
let tool_names = request_tool_names(&request_bodies[0]);
|
let tool_names = request_tool_names(&request_bodies[0]);
|
||||||
let loaded_skills_message = sent
|
|
||||||
.iter()
|
|
||||||
.find_map(|message| match message {
|
|
||||||
AgentMessage::LogEntry { level, message } if level == "info" && message.starts_with("loaded skills: ") => {
|
|
||||||
Some(message.clone())
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
.expect("expected loaded skills log entry");
|
|
||||||
|
|
||||||
assert!(sent.iter().any(|message| {
|
assert!(sent.iter().any(|message| {
|
||||||
matches!(
|
matches!(
|
||||||
message,
|
message,
|
||||||
AgentMessage::TaskComplete { success, summary }
|
AgentMessage::LogEntry { level, message }
|
||||||
if *success && summary == "已看到真实知乎 skill"
|
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!(loaded_skills_message.contains("office-export-xlsx@0.1.0"));
|
|
||||||
assert!(loaded_skills_message.contains("zhihu-hotlist@0.1.0"));
|
|
||||||
assert!(loaded_skills_message.contains("zhihu-hotlist-screen@0.1.0"));
|
|
||||||
assert!(loaded_skills_message.contains("zhihu-navigate@0.1.0"));
|
|
||||||
assert!(loaded_skills_message.contains("zhihu-write@0.1.0"));
|
|
||||||
assert_eq!(request_bodies.len(), 1);
|
assert_eq!(request_bodies.len(), 1);
|
||||||
assert!(first_request.contains("office-export-xlsx"));
|
assert!(first_request.contains("office-export-xlsx"));
|
||||||
assert!(first_request.contains("zhihu-hotlist"));
|
assert!(first_request.contains("zhihu-hotlist"));
|
||||||
@@ -2138,13 +2157,12 @@ fn handle_browser_message_chains_hotlist_skill_into_xlsx_export_and_auto_open()
|
|||||||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||||
|
|
||||||
let transport = Arc::new(MockTransport::new(vec![
|
let transport = Arc::new(MockTransport::new(vec![
|
||||||
success_browser_response(1, json!({ "navigated": true })),
|
|
||||||
success_browser_response(
|
success_browser_response(
|
||||||
2,
|
1,
|
||||||
json!({ "text": "知乎热榜\n1 问题一 344万热度\n2 问题二 266万热度" }),
|
json!({ "text": "知乎热榜\n1 问题一 344万热度\n2 问题二 266万热度" }),
|
||||||
),
|
),
|
||||||
success_browser_response(
|
success_browser_response(
|
||||||
3,
|
2,
|
||||||
json!({
|
json!({
|
||||||
"text": {
|
"text": {
|
||||||
"source": "https://www.zhihu.com/hot",
|
"source": "https://www.zhihu.com/hot",
|
||||||
@@ -2170,8 +2188,8 @@ fn handle_browser_message_chains_hotlist_skill_into_xlsx_export_and_auto_open()
|
|||||||
instruction: "读取知乎热榜数据,并导出 excel 文件".to_string(),
|
instruction: "读取知乎热榜数据,并导出 excel 文件".to_string(),
|
||||||
conversation_id: String::new(),
|
conversation_id: String::new(),
|
||||||
messages: vec![],
|
messages: vec![],
|
||||||
page_url: "https://www.zhihu.com/".to_string(),
|
page_url: "https://www.zhihu.com/hot".to_string(),
|
||||||
page_title: "知乎".to_string(),
|
page_title: "知乎热榜".to_string(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -2180,14 +2198,14 @@ fn handle_browser_message_chains_hotlist_skill_into_xlsx_export_and_auto_open()
|
|||||||
let summary = task_complete_summary(&sent);
|
let summary = task_complete_summary(&sent);
|
||||||
let generated = extract_generated_artifact_path(&summary, ".xlsx");
|
let generated = extract_generated_artifact_path(&summary, ".xlsx");
|
||||||
|
|
||||||
assert!(summary.contains("已导出并打开知乎热榜 Excel"));
|
assert!(summary.contains("已导出知乎热榜 Excel") || summary.contains("已导出并打开知乎热榜 Excel"));
|
||||||
assert!(summary.contains(".xlsx"));
|
assert!(summary.contains(".xlsx"));
|
||||||
assert!(generated.exists());
|
assert!(generated.exists());
|
||||||
assert!(sent.iter().any(|message| {
|
assert!(sent.iter().any(|message| {
|
||||||
matches!(
|
matches!(
|
||||||
message,
|
message,
|
||||||
AgentMessage::TaskComplete { success, summary }
|
AgentMessage::TaskComplete { success, summary }
|
||||||
if *success && summary.contains("已导出并打开知乎热榜 Excel") && summary.contains(".xlsx")
|
if *success && summary.contains(".xlsx")
|
||||||
)
|
)
|
||||||
}));
|
}));
|
||||||
assert!(sent.iter().any(|message| {
|
assert!(sent.iter().any(|message| {
|
||||||
@@ -2212,10 +2230,7 @@ fn handle_browser_message_chains_hotlist_skill_into_xlsx_export_and_auto_open()
|
|||||||
)
|
)
|
||||||
}));
|
}));
|
||||||
assert!(sent.iter().any(|message| {
|
assert!(sent.iter().any(|message| {
|
||||||
matches!(
|
matches!(message, AgentMessage::Command { action, .. } if action == &Action::Eval)
|
||||||
message,
|
|
||||||
AgentMessage::Command { action, .. } if action == &Action::Eval
|
|
||||||
)
|
|
||||||
}));
|
}));
|
||||||
assert!(!sent.iter().any(|message| {
|
assert!(!sent.iter().any(|message| {
|
||||||
matches!(
|
matches!(
|
||||||
@@ -2249,13 +2264,12 @@ fn handle_browser_message_chains_hotlist_skill_into_screen_export_and_auto_open(
|
|||||||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||||
|
|
||||||
let transport = Arc::new(MockTransport::new(vec![
|
let transport = Arc::new(MockTransport::new(vec![
|
||||||
success_browser_response(1, json!({ "navigated": true })),
|
|
||||||
success_browser_response(
|
success_browser_response(
|
||||||
2,
|
1,
|
||||||
json!({ "text": "知乎热榜\n1 问题一 344万热度\n2 问题二 266万热度" }),
|
json!({ "text": "知乎热榜\n1 问题一 344万热度\n2 问题二 266万热度" }),
|
||||||
),
|
),
|
||||||
success_browser_response(
|
success_browser_response(
|
||||||
3,
|
2,
|
||||||
json!({
|
json!({
|
||||||
"text": {
|
"text": {
|
||||||
"source": "https://www.zhihu.com/hot",
|
"source": "https://www.zhihu.com/hot",
|
||||||
@@ -2265,7 +2279,7 @@ fn handle_browser_message_chains_hotlist_skill_into_screen_export_and_auto_open(
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
success_browser_response(4, json!({ "navigated": true })),
|
success_browser_response(3, json!({ "navigated": true })),
|
||||||
]));
|
]));
|
||||||
let browser_tool = BrowserPipeTool::new(
|
let browser_tool = BrowserPipeTool::new(
|
||||||
transport.clone(),
|
transport.clone(),
|
||||||
@@ -2282,8 +2296,8 @@ fn handle_browser_message_chains_hotlist_skill_into_screen_export_and_auto_open(
|
|||||||
instruction: "读取知乎热榜数据并生成领导演示大屏,在新标签页展示".to_string(),
|
instruction: "读取知乎热榜数据并生成领导演示大屏,在新标签页展示".to_string(),
|
||||||
conversation_id: String::new(),
|
conversation_id: String::new(),
|
||||||
messages: vec![],
|
messages: vec![],
|
||||||
page_url: "https://www.zhihu.com/".to_string(),
|
page_url: "https://www.zhihu.com/hot".to_string(),
|
||||||
page_title: "知乎".to_string(),
|
page_title: "知乎热榜".to_string(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -3452,29 +3466,19 @@ fn browser_orchestration_executes_hotlist_export_natively_from_hotlist_page() {
|
|||||||
)
|
)
|
||||||
}));
|
}));
|
||||||
assert!(sent.iter().any(|message| {
|
assert!(sent.iter().any(|message| {
|
||||||
matches!(
|
matches!(message, AgentMessage::Command { action, .. } if action == &Action::GetText)
|
||||||
message,
|
|
||||||
AgentMessage::Command { action, .. } if action == &Action::GetText
|
|
||||||
)
|
|
||||||
}));
|
}));
|
||||||
assert!(sent.iter().any(|message| {
|
assert!(sent.iter().any(|message| {
|
||||||
matches!(
|
matches!(message, AgentMessage::Command { action, .. } if action == &Action::Eval)
|
||||||
message,
|
|
||||||
AgentMessage::Command { action, .. } if action == &Action::Eval
|
|
||||||
)
|
|
||||||
}));
|
}));
|
||||||
assert!(!sent.iter().any(|message| {
|
assert!(!sent.iter().any(|message| {
|
||||||
matches!(
|
matches!(message, AgentMessage::Command { action, .. } if action == &Action::Navigate)
|
||||||
message,
|
|
||||||
AgentMessage::Command { action, .. } if action == &Action::Navigate
|
|
||||||
)
|
|
||||||
}));
|
}));
|
||||||
assert!(!sent.iter().any(|message| {
|
assert!(!sent.iter().any(|message| {
|
||||||
matches!(
|
matches!(
|
||||||
message,
|
message,
|
||||||
AgentMessage::LogEntry { level, message }
|
AgentMessage::LogEntry { level, message }
|
||||||
if level == "mode" &&
|
if level == "mode" && (message == "compat_llm_primary" || message == "compat_skill_runner_primary")
|
||||||
(message == "compat_llm_primary" || message == "compat_skill_runner_primary")
|
|
||||||
)
|
)
|
||||||
}));
|
}));
|
||||||
std::env::remove_var("SGCLAW_DISABLE_POST_EXPORT_OPEN");
|
std::env::remove_var("SGCLAW_DISABLE_POST_EXPORT_OPEN");
|
||||||
@@ -3551,13 +3555,12 @@ fn browser_skill_usage_is_execution_not_prompt_only() {
|
|||||||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||||
|
|
||||||
let transport = Arc::new(MockTransport::new(vec![
|
let transport = Arc::new(MockTransport::new(vec![
|
||||||
success_browser_response(1, json!({ "navigated": true })),
|
|
||||||
success_browser_response(
|
success_browser_response(
|
||||||
2,
|
1,
|
||||||
json!({ "text": "知乎热榜\n1 问题一 344万热度\n2 问题二 266万热度" }),
|
json!({ "text": "知乎热榜\n1 问题一 344万热度\n2 问题二 266万热度" }),
|
||||||
),
|
),
|
||||||
success_browser_response(
|
success_browser_response(
|
||||||
3,
|
2,
|
||||||
json!({
|
json!({
|
||||||
"text": {
|
"text": {
|
||||||
"source": "https://www.zhihu.com/hot",
|
"source": "https://www.zhihu.com/hot",
|
||||||
@@ -3616,15 +3619,6 @@ fn browser_skill_usage_is_execution_not_prompt_only() {
|
|||||||
if level == "info" && message == "call openxml_office"
|
if level == "info" && message == "call openxml_office"
|
||||||
)
|
)
|
||||||
}));
|
}));
|
||||||
assert!(!sent.iter().any(|message| {
|
|
||||||
matches!(
|
|
||||||
message,
|
|
||||||
AgentMessage::LogEntry { level, message }
|
|
||||||
if level == "mode" &&
|
|
||||||
(message == "compat_llm_primary" || message == "compat_skill_runner_primary")
|
|
||||||
)
|
|
||||||
}));
|
|
||||||
std::env::remove_var("SGCLAW_DISABLE_POST_EXPORT_OPEN");
|
|
||||||
assert!(!sent.iter().any(|message| {
|
assert!(!sent.iter().any(|message| {
|
||||||
matches!(
|
matches!(
|
||||||
message,
|
message,
|
||||||
@@ -3632,16 +3626,17 @@ fn browser_skill_usage_is_execution_not_prompt_only() {
|
|||||||
if level == "info" && message.starts_with("read_skill ")
|
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| {
|
assert!(!sent.iter().any(|message| {
|
||||||
matches!(
|
matches!(
|
||||||
message,
|
message,
|
||||||
AgentMessage::LogEntry { level, message }
|
AgentMessage::LogEntry { level, message }
|
||||||
if level == "info" &&
|
if level == "mode" && (message == "compat_llm_primary" || message == "compat_skill_runner_primary")
|
||||||
(message == "getText .HotList-item" ||
|
|
||||||
message == "getText [data-hot-item]" ||
|
|
||||||
message == "getText ol li")
|
|
||||||
)
|
)
|
||||||
}));
|
}));
|
||||||
|
std::env::remove_var("SGCLAW_DISABLE_POST_EXPORT_OPEN");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -3900,3 +3895,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\"")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,11 +52,8 @@ fn submit_task_without_llm_configuration_returns_clear_error() {
|
|||||||
&sent[0],
|
&sent[0],
|
||||||
AgentMessage::LogEntry { level, message }
|
AgentMessage::LogEntry { level, message }
|
||||||
if level == "info"
|
if level == "info"
|
||||||
&& message
|
&& message.starts_with("sgclaw runtime version=")
|
||||||
== &format!(
|
&& message.ends_with(" protocol=1.0")
|
||||||
"sgclaw runtime version={} protocol=1.0",
|
|
||||||
env!("CARGO_PKG_VERSION")
|
|
||||||
)
|
|
||||||
));
|
));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
&sent[1],
|
&sent[1],
|
||||||
|
|||||||
Reference in New Issue
Block a user