Author SHA1 Message Date
weichen 765a7e7177 Isolate snapshots and validate generated commands
Why: F07/F08 - registry and catalog accessors shared caller-mutable
nested state, and the generation fallback skipped full slot validation,
risk metadata and mis-keyed handler slot tables.
Evidence: intent package tests pass incl. new isolation/generated cases
(verify-u5.json).
Impact: no exported signature change; rule and generated paths now share
authoritative validation.
Refs: feature/agent-stack-audit-remediation-b1 U5
2026-10-21 15:41:31 +00:00
weichen 651a2e1e03 Merge pull request 'Adopt the shared governance kit (development-governance-v2)' (#12) from feature/governance-adoption into main
ci / docker-checkout-hostgateway (push) Successful in 0s
governance / governance (push) Successful in 14s
ci / vet-and-test (push) Successful in 18s
2026-10-17 09:34:32 +00:00
weichen 660992ab10 Close and archive the governance adoption card
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
governance / governance (pull_request) Successful in 21s
ci / vet-and-test (pull_request) Successful in 24s
Why: the adoption PR is reviewed and its candidate HEAD passed CI; the card closure is folded into the PR so no separate close PR is needed.
Evidence: CI GATE on 6ca6299b39406dd12f21e80462524ba0a9012517; independent Review REPLAY (cursor-agent-stable --mode ask 全新只读会话(fresh_session=true,session id not-observable)闭合重审 VERDICT: APPROVE(对象 6ca6299)).
Impact: task card only (moved to .task/archive/).
Refs: https://git-home.u1680832.nyat.app:30319/weichen/Agent-Intent/pulls/12
2026-10-17 09:31:05 +00:00
weichen 6ca6299b39 docs(task): record review repair run and evidence
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
governance / governance (pull_request) Successful in 18s
ci / vet-and-test (pull_request) Successful in 21s
Why: independent Review blocked the adoption; the repair unit ran through the shared executor and was integrated with agent-exec apply.
Evidence: .task/evidence/governance-adoption/ (SELF): governance check and agent-exec validate on the repaired head.
Impact: task card and evidence logs only.
Refs: .task/feature-governance-adoption.md
2026-10-17 09:21:23 +00:00
agent-exec-transport 0f8e2de85f agent-exec transport 7ab07317ac5d7bc9c5429b0293bab22d/4bbb2a8762ef 2026-10-17 08:59:10 +00:00
weichen c66996e483 docs(task): complete the card and record repair-r2 blocked
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
governance / governance (pull_request) Successful in 16s
ci / vet-and-test (pull_request) Successful in 19s
2026-10-17 08:58:27 +00:00
weichen 8d54bed798 docs(task): record review repair run and evidence
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
governance / governance (pull_request) Successful in 22s
ci / vet-and-test (pull_request) Successful in 25s
Why: independent Review blocked the adoption; the repair unit ran through the shared executor and was integrated with agent-exec apply.
Evidence: .task/evidence/governance-adoption/ (SELF): governance check and agent-exec validate on the repaired head.
Impact: task card and evidence logs only.
Refs: .task/feature-governance-adoption.md
2026-10-17 08:36:54 +00:00
agent-exec-transport 511b8142b7 agent-exec transport 18b6c1a0ed84e5967f252879e75d05d4/5d6475d09de1 2026-10-17 08:35:53 +00:00
weichen 8895b3c877 Record the adoption unit run and positive/negative evidence
ci / docker-checkout-hostgateway (pull_request) Successful in 2s
governance / governance (pull_request) Successful in 42s
ci / vet-and-test (pull_request) Successful in 42s
Why: Phase 6 requires one positive executor run and the negative cases (missing config, bad receipt, out-of-scope path, unknown side effect) before adoption becomes active.
Evidence: .task/evidence/governance-adoption/ (SELF): governance check, agent-exec validate and adoption cases; this commit passed the generated pre-commit hook (admit).
Impact: task card and evidence logs only.
Refs: .task/feature-governance-adoption.md
2026-10-17 07:38:21 +00:00
agent-exec-transport 57f92f919f agent-exec transport 73a75d97d4a11ad672c195da4806208f/c172f14cd48b 2026-10-17 07:20:34 +00:00
weichen 0e0e815f45 Bootstrap AGENT-EXECUTION.md and the governance adoption card
Why: development-governance-v2 Phase 6 adopts the shared governance kit in every formal repository; the executor needs a valid AGENT-EXECUTION.md before any implementation unit can run.
Evidence: generated by dm-server governance init at Development-Manager bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c; dm-server agent-exec validate passes.
Impact: configuration and task card only; no code or CI change yet.
Refs: Agent-System-Dev docs/superpowers/plans/2026-10-16-development-governance-v2-master.md
2026-10-17 07:17:39 +00:00
weichen 343b1e6d9a Merge pull request 'Add precision and recall metrics to the multi-turn evaluation (I2)' (#10) from feature/agent-stack-i2 into main
ci / vet-and-test (push) Successful in 22s
ci / docker-checkout-hostgateway (push) Successful in 1s
2026-10-07 11:05:58 +00:00
weichen ab55b54e9b Merge origin/main into the I2 branch
ci / vet-and-test (pull_request) Successful in 17s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
2026-10-07 11:05:28 +00:00
weichen db79b535ec Add precision and recall metrics to the multi-turn evaluation
ci / vet-and-test (pull_request) Successful in 16s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Why: I2 acceptance - the evaluator must emit metrics over a
multiturn fixture no smaller than the existing corpus, not just
print branch names.

Evidence: go vet clean; full suite ok; intentctl eval prints
precision=1.000 recall=1.000 (TP=12 FP=0 FN=0) over the 11-case
multiturn fixture; MetricsOf unit test pins TP/FP/FN counting
including wrong-match counting as both.

Impact: CorpusMetrics + MetricsOf (expectation_hit counts as a
hit); intentctl eval prints the metrics line after the summary;
corpus_multiturn.json expanded to 11 cases (chained expectation,
tie-break, ambiguous, fallback, handler action, clarify paths).

Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md I2
2026-10-07 11:02:28 +00:00
weichen fcf9ec4b2a Merge pull request 'Add the semantic rerank port, examples, and the command generator port (I2)' (#9) from feature/agent-stack-i2 into main
ci / vet-and-test (push) Successful in 14s
ci / docker-checkout-hostgateway (push) Successful in 1s
2026-10-07 10:57:49 +00:00
weichen 89a43abf87 Add the semantic rerank port, examples, and the command generator port
ci / vet-and-test (pull_request) Successful in 15s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: I2 layering - between rules and the L2 classifier there is now
a deterministic host-scored semantic layer (rules then semantic then
model), and fallback can be rescued by a validated command
generator instead of going straight to the default channel.

Evidence: go vet clean; full suite ok, including ten new tests
(rerank resolve/below-threshold/tie/error-then-L2, generator
valid/invalid/error, catalog top-k).

Impact: IntentSpec gains Examples; Options gains Semantic (with
threshold), Generator; ambiguous candidates rerank through host
scores before L2; fallback validates generated commands
(action/slots/kind fail-closed) and synthesizes Top with slots;
generated commands are no longer overwritten by deriveCommands;
CatalogTopK by priority.

Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md I2
2026-10-07 10:56:38 +00:00
weichen f57645c645 Merge pull request 'Add the multi-turn eval format and self-describing corpora (I1 WP6)' (#8) from feature/agent-stack-i1 into main
ci / vet-and-test (push) Successful in 16s
ci / docker-checkout-hostgateway (push) Successful in 1s
2026-10-07 08:22:33 +00:00
weichen 1d0fcbd1ba Add the multi-turn eval format and self-describing corpora
ci / vet-and-test (pull_request) Successful in 18s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Why: I1 WP6 - eval needs per-turn command expectations to carry the
SetSlot+Start acceptance case, and corpora must reproduce standalone
in intentctl (the guard case has been red there since before I1
because the guard spec only existed as a test builtin).

Evidence: go vet clean; go test ./... all ok; intentctl eval passes
11/11 on the v1 corpus (guard case fixed via self-declaration) and
1/1 on the new two-turn SetSlot+Start corpus.

Impact: new intent/corpus.go (CorpusCase/CorpusTurn/CommandExpect,
RunCorpusCase with chained expectations); intentctl eval consumes
turns and corpus-declared builtin_specs and domains; report.generate
spec and corpus_multiturn.json fixtures added. v1 corpus format and
conformance unchanged.

Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md I1 WP6
2026-10-07 08:12:50 +00:00
weichen 2554b509e8 Merge pull request 'Add the explicit spec.action declaration (I1 WP2)' (#7) from feature/agent-stack-i1 into main
ci / vet-and-test (push) Successful in 16s
ci / docker-checkout-hostgateway (push) Successful in 1s
2026-10-07 07:41:37 +00:00
weichen 0225bdad73 Merge origin/main into the I1 branch
ci / vet-and-test (pull_request) Successful in 18s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
2026-10-07 07:41:06 +00:00
weichen 916ee0733f Add the explicit spec.action declaration for action-key unification
ci / vet-and-test (pull_request) Successful in 17s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Why: I1 WP2 - one explicit action field outranks the four legacy
action keys (Handler, metadata.blueprint, command-id, workflow
IntentID) so hosts migrate to a single derivation path.

Evidence: go vet clean; go test ./... all ok, including declared
action winning the Start command and kind/id validation rejections.

Impact: IntentSpec gains Action (command/workflow/blueprint/handler);
Candidate carries it through; actionOf prefers it, then blueprint,
then Handler, then auto. Legacy keys keep working.

Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md I1 WP2
2026-10-07 07:31:05 +00:00
weichen c407554d6d Merge pull request 'Add command model, priority tie-break, and boundary matching (I1 WP1/WP4/WP5)' (#6) from feature/agent-stack-i1 into main
ci / vet-and-test (push) Successful in 17s
ci / docker-checkout-hostgateway (push) Successful in 0s
2026-10-07 07:28:19 +00:00
weichen a007586f71 Merge origin/main into the I1 branch
ci / vet-and-test (pull_request) Successful in 17s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
2026-10-07 07:27:48 +00:00
weichen fa512ec03c Add command model, priority tie-break, and boundary matching
ci / vet-and-test (pull_request) Successful in 17s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: I1 increment one - recognition output becomes a command list
for the host router, same-score ties resolve by priority (I5), and
CJK substring false positives get an opt-in boundary mode (I3).

Evidence: go vet clean; go test ./... all ok, including new command
(6), boundary (3), and tie-break tests; intentctl lint smoke run
shows boundary hints on short unbounded terms. Existing classifier
and ambiguity fixtures repointed to an equal-priority pair; the
greet corpus case now expects the I5 tie-break outcome.

Impact: Result gains Commands; recognizer derives them at one point
(guard/fallback stay empty); MatchSpec gains Boundary (default off,
legacy behavior unchanged); lint gains boundary hints. Top/Branch
stay for compatibility.

Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md I1 WP1/WP4/WP5
2026-10-07 07:21:55 +00:00
weichen 444d88083f Merge pull request 'Publish an IntentSpec JSON sample' (#5) from feature/agent-stack-d1 into main
ci / vet-and-test (push) Successful in 23s
ci / docker-checkout-hostgateway (push) Successful in 1s
2026-10-03 14:02:32 +00:00
weichenandCursor 65aaebca1e Publish an IntentSpec JSON sample and round-trip test
ci / vet-and-test (pull_request) Successful in 18s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Why: the intent library has to publish the shape it owns, without sharing a Go type with the other libraries.
Evidence: GOWORK=off go test -count=1 ./schema/ ./intent/ passed.
Impact: schema/ is new. Recognition behavior is unchanged.
Refs: D1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 13:40:26 +00:00
weichen 3bf228f4c0 Fold NFKC input and stop catalog metadata leaks
ci / vet-and-test (push) Successful in 18s
ci / docker-checkout-hostgateway (push) Successful in 0s
Merge via API
2026-10-03 13:22:54 +00:00
weichenandCursor 05e74d8e32 Fold NFKC input and stop catalog metadata leaks
ci / vet-and-test (pull_request) Successful in 18s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: fullwidth text missed case-folded terms, SpecByID shared maps, the catalog prompt printed handlers and private metadata, overlapping slots kept both values, and negation was easy to mistake for semantics.
Evidence: GOWORK=off go vet ./... && go test -count=1 -race ./... passed, including TestR2NFKCFoldsFullwidthButNotTraditional, TestR2SpecByIDDeepCopiesMetadata, TestR2CatalogHidesPrivateMetadataAndHandlerInMarkdown, TestR2EvalRulesDocumentsMaxSpecs, TestR2OverlappingSlotsAreAmbiguous, and TestR2NegationIsOnlyExcludesAny.
Impact: NFKC is opt-in and does not convert traditional Chinese; catalog markdown omits handlers and non-allowlisted metadata; overlapping slot spans are invalid.
Refs: I4, I7, I8, I10, I11, I12
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 12:56:55 +00:00
weichen fef81c76b8 Merge pull request #3 from feature/agent-stack-r0-r1 into main
ci / vet-and-test (push) Successful in 17s
ci / docker-checkout-hostgateway (push) Successful in 1s
CI on the head commit is success.
2026-10-03 12:47:35 +00:00
weichenandCursor 3dc87e8afe Clone the repo in CI without GitHub-hosted actions
ci / vet-and-test (pull_request) Successful in 17s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: The runner cannot reach github.com, so cloning actions/checkout timed out before any test ran.
Evidence: mirrors.aliyun.com/golang/go1.27.1.linux-amd64.tar.gz returned HTTP 200. The workflow now fetches GITHUB_SHA with the job token and installs Go from that mirror.
Impact: Submodule checkout still requires SUBMODULE_TOKEN. A missing secret fails the job with that message.
Refs: feature/agent-stack-r0-r1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 10:31:41 +00:00
weichenandCursor 7f7879b06b Fetch CI submodules over HTTPS and run the race detector
ci / vet-and-test (pull_request) Failing after 31s
ci / docker-checkout-hostgateway (pull_request) Failing after 30s
Why: The runner has no SSH key, so recursive checkout failed, and tests had no module proxy or race detector.
Evidence: Workflow matches PROJECT.md vet, test, and race commands, with setup-go 1.27 and GOPROXY=https://goproxy.cn,direct.
Impact: vet-and-test runs on ubuntu-latest. SUBMODULE_TOKEN must be set by the owner before checkout can succeed.
Refs: feature/agent-stack-r0-r1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 10:19:52 +00:00
weichenandCursor 3130190acb Reject invalid classifier refinements and scope slot validators
ci / vet-and-test (pull_request) Failing after 2s
ci / docker-checkout-hostgateway (pull_request) Successful in 2s
Why: Refine could swap handlers or emit out-of-range scores, Ambiguous tops skipped slot extraction, and the process-global validator map coupled independent registries.
Evidence: GOWORK=off go vet ./... && go test -count=1 ./... && go test -race ./... passed, including TestRecognizeClassifierRejectsBadRefinement, TestRecognizeClassifierRebuildsTamperedHandler, TestRecognizeClassifierRefinedTopGetsSlots, and TestScopedSlotValidatorsDoNotInterfere. error-code-system and logging-system advanced to a3ee67b and 49b5d8b (docs-only).
Impact: illegal Refine results stay Ambiguous; a resolved Top is slotted; Options.SlotValidators wins over the deprecated global table.
Refs: I1, I2, I6
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 00:47:16 +00:00
weichen 5d5d99e737 Merge pull request 'Record Gitea access facts in AGENTS.md' (#2) from feature/gitea-access-facts into main
ci / vet-and-test (push) Failing after 1s
ci / docker-checkout-hostgateway (push) Successful in 3s
2026-09-30 16:08:36 +00:00
weichen c19ec430d9 Record Gitea access facts in AGENTS.md
ci / vet-and-test (pull_request) Failing after 30s
ci / docker-checkout-hostgateway (pull_request) Successful in 3s
Why: agents rediscovered Gitea hosts and credential locations from
scratch each session, and the retired git-panel host kept failing with
TLS broken-pipe errors before the right host was recalled.

Evidence: curl git-home...:30319/api/v1/version with token from
~/.secrets/gitea -> {"version":"1.27.3"}; ssh -p 31411 git@git-ssh...
(BatchMode) -> "Hi there, weichen!" with key ~/.ssh/home_gitea (ED25519,
SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20). Same section already
merged in Agent-System-Dev (PR #73).

Impact: AGENTS.md only; records hosts and credential locations, no
secret values (baseline hard boundary 2).

Refs: Agent-System-Dev PR #73
2026-09-30 16:06:18 +00:00
weichen 1dbbe8149c Merge pull request 'Adopt the task-startup gate and commit format in AGENTS.md' (#1) from feature/entry-docs-gate into main
ci / vet-and-test (push) Failing after 1s
ci / docker-checkout-hostgateway (push) Successful in 2s
2026-09-30 03:15:25 +00:00
weichen 036dfd81d2 Adopt the task-startup gate and commit format in AGENTS.md
ci / vet-and-test (pull_request) Failing after 1s
ci / docker-checkout-hostgateway (pull_request) Successful in 3s
Why: the composition adopted the delivery gate (Dev repo ef89fc8); resident
entry rules must carry the gate so agents load them without reading the
guidelines submodule first.
Evidence: text-only entry change; gate mechanics validated in the Dev repo
scratch-repo suite (19/19) and merged as PR weichen/Agent-System-Dev#1.
Impact: entry doc only; no code, contract, or build change in this repo.
Refs: Development-Guidelines 0.1.0-draft.7; Agent-System-Dev ef89fc8
2026-09-30 03:14:39 +00:00
weichen ad447c55de Append the catalog-driven orchestration round to the integration report.
ci / vet-and-test (push) Failing after 1s
ci / docker-checkout-hostgateway (push) Successful in 2s
2026-09-29 16:38:04 +00:00
weichen b2fa5be60f Add deterministic slot constraints with labels and questions
Details (original message):
Add deterministic slot constraints with labels and questions, and the planner-facing capability catalog.
2026-09-29 16:32:50 +00:00
weichen 8f9496d5d8 Record the orchestration capability-catalog design: intent specs as the planner-facing catalog
Details (original message):
Record the orchestration capability-catalog design: intent specs as the planner-facing catalog.
2026-09-29 16:24:59 +00:00
weichen 345c1fdf24 Record the scope boundaries for compound instructions, slot states, and multi-turn dialog
Details (original message):
Record the scope boundaries for compound instructions, slot states, and multi-turn dialog.
2026-09-29 16:17:03 +00:00
weichen 65d0df473a Record the cross-repo integration test report for the Squad gate and Native decorator
Details (original message):
Record the cross-repo integration test report for the Squad gate and Native decorator.
2026-09-29 16:06:13 +00:00
weichen bb2bed2240 Pin error-code-system to dbeed2f for squad.intent_refused. 2026-09-29 15:56:57 +00:00
weichen c3f1ae9209 Correct the positioning: an attachable capability
Details (original message):
Correct the positioning: an attachable capability, never an Agent-System replacement; make Native attachable via an optional decorator.
2026-09-29 15:52:51 +00:00
weichen b06715d80b Record the decoupling tiers and LLM layering design for the Agent-Squad adoption
Details (original message):
Record the decoupling tiers and LLM layering design for the Agent-Squad adoption.
2026-09-29 15:45:37 +00:00
weichen b58595fb38 Implement the intent core
Details (original message):
Implement the intent core, conformance suite, catalog alignment, examples, and the intentctl diagnostics CLI.
2026-09-29 15:35:18 +00:00
weichen b450b03638 Initialize the repository with pinned submodules, project entry documents, and the design record
Details (original message):
Initialize the repository with pinned submodules, project entry documents, and the design record.
2026-09-29 15:20:38 +00:00
58 changed files with 4657 additions and 191 deletions
+38 -7
View File
@@ -6,17 +6,48 @@ on:
branches: [main]
jobs:
vet-and-test:
runs-on: nightly
runs-on: ubuntu-latest
env:
GOPROXY: https://goproxy.cn,direct
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- run: go vet ./... && go test -count=1 ./...
- name: Checkout
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
run: |
set -euo pipefail
git init -q
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git -c http.extraheader="AUTHORIZATION: token ${TOKEN}" fetch --depth=1 origin "${GITHUB_SHA}"
git checkout -q --detach FETCH_HEAD
if [ -z "${SUBMODULE_TOKEN}" ]; then
echo "SUBMODULE_TOKEN is not configured" >&2
exit 1
fi
git config --global url."https://weichen:${SUBMODULE_TOKEN}@git-home.u1680832.nyat.app:30319/".insteadOf "ssh://git@git-ssh.u1680832.nyat.app:31411/"
git -c fetch.recurseSubmodules=false submodule update --init --recursive
- name: Install Go 1.27.1
run: |
set -euo pipefail
curl -fsSL -o "$RUNNER_TEMP/go.tgz" https://mirrors.aliyun.com/golang/go1.27.1.linux-amd64.tar.gz
tar -C /usr/local -xzf "$RUNNER_TEMP/go.tgz"
ln -sfn /usr/local/go/bin/go /usr/local/bin/go
ln -sfn /usr/local/go/bin/gofmt /usr/local/bin/gofmt
- name: Vet, test, and race
run: GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./... && GOWORK=off go test -count=1 -race ./...
docker-checkout-hostgateway:
runs-on: ubuntu-latest
options: --add-host gitea:host-gateway
env:
GITHUB_SERVER_URL: http://gitea:3000
steps:
- uses: actions/checkout@v4
- run: test -f go.mod && echo "docker-mode checkout via host-gateway OK"
- name: Checkout without GitHub actions
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
git init -q
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git -c http.extraheader="AUTHORIZATION: token ${TOKEN}" fetch --depth=1 origin "${GITHUB_SHA}"
git checkout -q --detach FETCH_HEAD
test -f go.mod && echo "docker-mode checkout via host-gateway OK"
+150
View File
@@ -0,0 +1,150 @@
name: governance
on:
pull_request:
push:
branches: [main]
jobs:
governance:
runs-on: ubuntu-latest
env:
GOPROXY: https://goproxy.cn,direct
steps:
- name: Checkout
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
run: |
set -euo pipefail
# 只物化两个治理 gitlink(Guidelines 与 deps/development-manager),非递归;
# 其它 gitlink 不物化、不取回。凭据只以 URL 限定的 extraheader 存在于本进程树内。
python3 - <<'PY_CHECKOUT'
import os
from pathlib import Path
import re
import subprocess
import sys
from urllib.parse import urlsplit
# 服务端基址取运行器注入的 GITHUB_SERVER_URL(平台控制、候选 PR 不可影响)。
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
parsed_server = urlsplit(SERVER)
if parsed_server.scheme not in {"http", "https"} or not parsed_server.netloc:
sys.exit("Checkout server must be the platform-injected http(s) URL")
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
# 审查过的白名单,独立于候选 .gitmodules:仅列出两个治理 gitlink。
REPOSITORIES = {
"deps/development-guidelines": "Development-Guidelines",
"deps/development-manager": "Development-Manager",
}
TARGET = "weichen/Agent-Intent"
root_token = os.environ.pop("TOKEN", "")
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
if not root_token or not component_token:
sys.exit("Checkout requires TOKEN and SUBMODULE_TOKEN")
if os.environ.get("GITHUB_REPOSITORY") != TARGET:
sys.exit("Checkout repository is outside the reviewed allowlist")
sha = os.environ.get("GITHUB_SHA", "")
if not re.fullmatch(r"[0-9a-fA-F]{40}", sha):
sys.exit("Checkout requires a fixed commit SHA")
# 丢弃继承的 Git 追踪/配置/SSH 钩子;凭据只存在于单个 Git 进程树的精确 URL 头中。
clean_env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
clean_env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null",
GIT_CONFIG_GLOBAL="/dev/null", GIT_TERMINAL_PROMPT="0")
config = {
"core.hooksPath": "/dev/null", "core.fsmonitor": "false",
"init.templateDir": "", "credential.helper": "",
"fetch.recurseSubmodules": "false", "submodule.recurse": "false",
"http.followRedirects": "false", "protocol.allow": "never",
"protocol." + urlsplit(SERVER).scheme + ".allow": "always",
}
def git(repo, *args, extra=None):
values = config | (extra or {})
env = clean_env | {"GIT_CONFIG_COUNT": str(len(values))}
for index, (key, value) in enumerate(values.items()):
env[f"GIT_CONFIG_KEY_{index}"] = key
env[f"GIT_CONFIG_VALUE_{index}"] = value
return subprocess.run(["git", "-C", str(repo), *args], env=env,
check=True, stdout=subprocess.PIPE).stdout
def materialize_governance(root):
# -r 列出任意深度的 gitlink,但不进入 submodule 内部;其它 gitlink 只记录、从不取回。
links = {}
for entry in git(root, "ls-tree", "-rz", "HEAD").split(b"\0"):
if not entry:
continue
metadata, path = entry.split(b"\t", 1)
mode, kind, commit = metadata.decode().split()
if mode == "160000":
links[path.decode()] = commit
for name in REPOSITORIES:
if name not in links:
raise ValueError("Governance gitlink is missing from the fixed commit")
raw = git(root, "config", "--no-includes", "--blob", "HEAD:.gitmodules", "--null", "--list")
entries = {}
for item in raw.split(b"\0"):
if not item:
continue
key, value = item.decode().split("\n", 1)
match = re.fullmatch(r"submodule\.(.+)\.(path|url)", key)
if not match:
continue
name, field = match.groups()
if name not in REPOSITORIES:
continue
if field in entries.setdefault(name, {}):
raise ValueError("Duplicate candidate .gitmodules setting")
entries[name][field] = value
# 先整体校验两个治理条目,再进行任何带凭据的取回。
for name, repository in REPOSITORIES.items():
fields = entries.get(name, {})
if fields.get("path") != name:
raise ValueError("Governance submodule path is outside the reviewed allowlist")
trusted_url = f"{SERVER}/weichen/{repository}.git"
if fields.get("url") not in {SSH + repository + ".git", trusted_url}:
raise ValueError("Governance submodule URL is outside the reviewed allowlist")
for name, repository in REPOSITORIES.items():
url = f"{SERVER}/weichen/{repository}.git"
# 仅持久化审查过的公开 URL,使 Git 在进程级配置消失后仍识别该 submodule。
git(root, "config", f"submodule.{name}.url", url)
git(root, "submodule", "update", "--init", "--checkout", "--", name,
extra={f"submodule.{name}.url": url,
f"submodule.{name}.update": "checkout",
f"http.{url}.extraheader": "AUTHORIZATION: token " + component_token})
child = root / name
if git(child, "rev-parse", "HEAD").decode().strip() != links[name]:
raise ValueError("Materialized governance gitlink does not match the fixed SHA")
try:
root = Path.cwd()
# 必须是干净的临时工作区;不复用既有 Git 状态。
if (root / ".git").exists():
raise ValueError("Checkout requires a fresh workspace")
git(root, "init", "-q")
url = f"{SERVER}/{TARGET}.git"
git(root, "remote", "add", "origin", url)
git(root, "fetch", "--depth=1", "origin", sha,
extra={f"http.{url}.extraheader": "AUTHORIZATION: token " + root_token})
git(root, "checkout", "-q", "--detach", "FETCH_HEAD")
materialize_governance(root)
except (ValueError, OSError, subprocess.CalledProcessError) as error:
sys.exit(f"Checkout refused: {error}")
print("Checkout complete; credentials were not persisted in Git configuration")
PY_CHECKOUT
unset TOKEN SUBMODULE_TOKEN
- name: Install Go 1.27.1
run: |
set -euo pipefail
curl -fsSL -o "$RUNNER_TEMP/go.tgz" https://mirrors.aliyun.com/golang/go1.27.1.linux-amd64.tar.gz
tar -C /usr/local -xzf "$RUNNER_TEMP/go.tgz"
ln -sfn /usr/local/go/bin/go /usr/local/bin/go
ln -sfn /usr/local/go/bin/gofmt /usr/local/bin/gofmt
- name: Validate agent execution configuration
run: scripts/agent-exec validate --repo .
- name: Governance check
run: scripts/dm governance check --repo .
+3
View File
@@ -7,3 +7,6 @@
[submodule "deps/logging-system"]
path = deps/logging-system
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Logging-System.git
[submodule "deps/development-manager"]
path = deps/development-manager
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Manager.git
@@ -0,0 +1,61 @@
# Agent-Intent 采纳 development-governance-v2 治理套件 任务卡
任务主记录:[Agent-Intent PR #12](https://git-home.u1680832.nyat.app:30319/weichen/Agent-Intent/pulls/12)(本文件随 PR 描述维护)。
状态:已完成(闭合随本 PR 合入;合入以平台记录为准);owner:weichen。
## 基线与授权
- 权威仓:weichen/Agent-Intent;root_target:main;immediate_target:main
- branch_kind:initiative;initiative_id:governance-adoption-Agent-Intent
- R0(initial_main_sha):343b1e6d9a5b58e47bf7e594826221036ae8175c;工作区:/home/weichen/projects/Agent-Intent-wt-feature-governance-adoption(worktree);分支:feature/governance-adoption
- 父任务信息(仅子任务填写):无
- accepting_requirements:false(单仓采纳 initiative,范围固定为下列十步,不接收新需求)
- 需求分流:verdict=NEW;selected_initiative=无;候选=无;理由=development-governance-v2 Phase 6 每个正式仓库独立 initiative;triage 收据=无(本仓采纳前尚无 triage 入口)
- 授权与交付终点:owner 已授权 development-governance-v2 全部实施(Agent-System-Dev 主记录 docs/superpowers/plans/2026-10-16-development-governance-v2-master.md);终点为本 PR 合入 main 且采纳状态 active。分支保护与 tag 由用户执行。
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md;外部 Agent 一单元一新进程/会话/工作区)
## 目标与非目标
目标(Phase 6 十步):固定 Guidelines SHA(deps/development-guidelines @ 2688fd2be6a52c0bf110e7e6366b6f291ab5e4eb);增加 AGENT-EXECUTION.md;增加共享执行器薄入口(deps/development-manager @ bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c + scripts/dm、scripts/agent-exec、scripts/task);四条路由(implementation/repair/test/review);任务卡模板与分支元数据;CI 中启用 schema 校验(.gitea/workflows/governance.yml:agent-exec validate + governance check);一次正例运行;负例(缺配置、坏收据、越界路径、未知副作用);独立 Review + CI;采纳状态 active。
非目标:不改业务代码与既有测试;不改分支保护(由用户执行);不发布 tag。
引导说明:AGENT-EXECUTION.md 由 host 以固定版本治理套件(dm-server governance init @ bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c)生成并作为引导配置提交(执行器运行前必须存在有效配置;沿用 DM 采纳先例);其余文件由实施单元生成。
## Agent 实施收据
| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|---|---|---|---|---|---|---|---|---|---|
| governance-adoption-Agent-Intent-r1 | 13 个允许路径(治理文件 + 两个 gitlink) | 0e0e815f | implementation | cursor→antigravity→pi | run 73a75d97(dm-server pinned,attempts=1) | cursor / not-observable | success | 57f92f919f5a(apply 快进) | 否 | /tmp/gov-receipt-Agent-Intent-r1.json |
| governance-adoption-Agent-Intent-repair-r1 | PROJECT.md、policy/guidelines-adoption.md | repair | cursor→antigravity→pi | run 18b6c1a0(attempts=1) | cursor / not-observable | success | 511b8142b7e6(apply 快进) | 否 | /tmp/gov-receipt-Agent-Intent-repair-r1.json |
| governance-adoption-Agent-Intent-repair-r2 | AGENT-EXECUTION.md、policy/guidelines-adoption.md | repair | cursor→antigravity→pi | run(attempts=1) | cursor / not-observable | blocked(worker 正确指出卡片证据表尚无 GATE 行,引用将悬空;对账:本 worktree 干净、无 transport;先补卡片 GATE 行后以全新 r3 重跑) | 无 | 否 | /tmp/gov-receipt-Agent-Intent-repair-r2.json |
| governance-adoption-Agent-Intent-repair-r3 | AGENT-EXECUTION.md、policy/guidelines-adoption.md | repair | cursor→antigravity→pi | run 7ab07317(attempts=1) | cursor / not-observable | success | 0f8e2de85f55(apply 快进) | 否 | /tmp/gov-receipt-Agent-Intent-repair-r3.json |
## 同步记录
| 节点 | sync_base_sha | tested_head_sha | checked_at | 结果 |
|---|---|---|---|---|
| 启动 | 343b1e6d9a5b58e47bf7e594826221036ae8175c | 343b1e6d9a5b58e47bf7e594826221036ae8175c | 2026-10-17T07:17:39Z | 一致 |
| 请求审核 | 343b1e6d9a5b58e47bf7e594826221036ae8175c | 8d54bed79833 | 2026-10-17T08:58:27Z | 已含 origin/main |
## 结果与验证(完成后填写)
- 结果:治理套件已采纳——Guidelines 与 Development-Manager gitlink 固定、AGENT-EXECUTION.md 四条路由、共享执行器薄入口、pre-commit admit、governance CI;采纳状态 active 随本 PR 合入生效。
- 验证:见下表(命令输出由 /tmp/gov-neg/ev.sh 写入 .task/evidence/governance-adoption/,SELF 除非另注)。
- 复现:正负例脚本冻结副本 Agent-System-Dev `docs/superpowers/plans/evidence/governance-adopt-cases.py`(sha256 3aa1bf6ea1cd48f3f59c0a307620e4112389a93fe21a113006f54e62eb8f0d06),用法 `python3 governance-adopt-cases.py <仓库根>`;它经本仓 `scripts/agent-exec` 运行假 CLI,不访问网络。
- 影响与剩余项:不改业务代码;本地启用钩子需由人执行 `git config core.hooksPath scripts/hooks`;将 governance 检查设为分支保护必需项属用户双钥匙事项(命令见 Agent-System-Dev 治理主记录)。
| 时间(UTC) | 命令 | 被测 HEAD | exit | 来源 | 摘要/位置 |
|---|---|---|---|---|---|
| 20261017T073820Z | `scripts/dm governance check --repo .` | 57f92f919f5a | 0 | SELF | ok fixtures;[输出](../evidence/governance-adoption/00-20261017T073820Z.log) sha256:1141eb0063f8ad39 |
| 20261017T073820Z | `scripts/agent-exec validate --repo .` | 57f92f919f5a | 0 | SELF | validate: ok;[输出](../evidence/governance-adoption/01-20261017T073820Z.log) sha256:19b56ec1b547dd80 |
| 20261017T073820Z | `python3 /tmp/gov-neg/adopt_cases.py .` | 57f92f919f5a | 0 | SELF | [verdict] ALL PASS cases=5;[输出](../evidence/governance-adoption/02-20261017T073820Z.log) sha256:acdf4ab65c895249 |
| 20261017T083653Z | `scripts/dm governance check --repo .` | 511b8142b7e6 | 0 | SELF | ok fixtures;[输出](../evidence/governance-adoption/03-20261017T083653Z.log) sha256:7040baf88f4510c1 |
| 20261017T083653Z | `scripts/agent-exec validate --repo .` | 511b8142b7e6 | 0 | SELF | validate: ok;[输出](../evidence/governance-adoption/04-20261017T083653Z.log) sha256:ceff66715e93873c |
| 2026-10-17T08:58:27Z | CI `ci / docker-checkout-hostgateway (pull_request)` | 8d54bed79833 | 0 | GATE | success;[运行](https://git-home.u1680832.nyat.app:30319/weichen/Agent-Intent/actions/runs/3588/jobs/5676) |
| 2026-10-17T08:58:27Z | CI `governance / governance (pull_request)` | 8d54bed79833 | 0 | GATE | success;[运行](https://git-home.u1680832.nyat.app:30319/weichen/Agent-Intent/actions/runs/3589/jobs/5677) |
| 2026-10-17T08:58:27Z | CI `ci / vet-and-test (pull_request)` | 8d54bed79833 | 0 | GATE | success;[运行](https://git-home.u1680832.nyat.app:30319/weichen/Agent-Intent/actions/runs/3588/jobs/5675) |
| 20261017T092123Z | `scripts/dm governance check --repo .` | 0f8e2de85f55 | 0 | SELF | ok fixtures;[输出](../evidence/governance-adoption/05-20261017T092123Z.log) sha256:e10c752533fee0f8 |
| 20261017T092123Z | `scripts/agent-exec validate --repo .` | 0f8e2de85f55 | 0 | SELF | validate: ok;[输出](../evidence/governance-adoption/06-20261017T092123Z.log) sha256:6e46dd574cf74ee4 |
| — | 候选 HEAD CI | 6ca6299b3940 | 0 | GATE | success ci / docker-checkout-hostgateway (pull_request)=success(/weichen/Agent-Intent/actions/runs/3633/jobs/5738);governance / governance (pull_request)=success(/weichen/Agent-Intent/actions/runs/3635/jobs/5740);ci / vet-and-test (pull_request)=success(/weichen/Agent-Intent/actions/runs/3633/jobs/5737) |
| — | 独立 Review | 6ca6299b3940 | 0 | REPLAY | cursor-agent-stable --mode ask 全新只读会话(fresh_session=true,session id not-observable)闭合重审 VERDICT: APPROVE(对象 6ca6299) |
@@ -0,0 +1,19 @@
command: scripts/dm governance check --repo .
cwd: /home/weichen/projects/Agent-Intent-wt-feature-governance-adoption
head: 57f92f919f5a38f83031e3810bd5573143b97789
exit: 0
utc: 20261017T073820Z
---
ok gitlink deps/development-guidelines
ok gitlink deps/development-manager
ok agent-execution
ok file AGENTS.md
ok file PROJECT.md
ok file policy/guidelines-adoption.md
ok file .task/templates/task-card.md
ok executable scripts/agent-exec
ok executable scripts/hooks/pre-commit
ok executable scripts/dm
ok executable scripts/task
ok adoption status
ok fixtures
@@ -0,0 +1,7 @@
command: scripts/agent-exec validate --repo .
cwd: /home/weichen/projects/Agent-Intent-wt-feature-governance-adoption
head: 57f92f919f5a38f83031e3810bd5573143b97789
exit: 0
utc: 20261017T073820Z
---
validate: ok
@@ -0,0 +1,13 @@
command: python3 /tmp/gov-neg/adopt_cases.py .
cwd: /home/weichen/projects/Agent-Intent-wt-feature-governance-adoption
head: 57f92f919f5a38f83031e3810bd5573143b97789
exit: 0
utc: 20261017T073820Z
---
[object] Agent-Intent-wt-feature-governance-adoption HEAD=57f92f919f5a38f83031e3810bd5573143b97789
PASS positive-run-apply: run exit=0 terminal=success apply exit=0 head=0bdcc997d3ee
PASS missing-config: run exit=2 terminal=invalid-config validate exit=2
PASS bad-receipt: apply exit=4 head_unchanged=True msg=apply: no local integrity record: changed paths do not match the transport diff
PASS out-of-scope-path: run exit=5 terminal=scope-expansion transport=False
PASS unknown-side-effect: run exit=4 terminal=failed-unknown transport=False
[verdict] ALL PASS cases=5
@@ -0,0 +1,19 @@
command: scripts/dm governance check --repo .
cwd: /home/weichen/projects/Agent-Intent-wt-feature-governance-adoption
head: 511b8142b7e6c2b62a323c08342ca84dbe06ea88
exit: 0
utc: 20261017T083653Z
---
ok gitlink deps/development-guidelines
ok gitlink deps/development-manager
ok agent-execution
ok file AGENTS.md
ok file PROJECT.md
ok file policy/guidelines-adoption.md
ok file .task/templates/task-card.md
ok executable scripts/agent-exec
ok executable scripts/hooks/pre-commit
ok executable scripts/dm
ok executable scripts/task
ok adoption status
ok fixtures
@@ -0,0 +1,7 @@
command: scripts/agent-exec validate --repo .
cwd: /home/weichen/projects/Agent-Intent-wt-feature-governance-adoption
head: 511b8142b7e6c2b62a323c08342ca84dbe06ea88
exit: 0
utc: 20261017T083653Z
---
validate: ok
@@ -0,0 +1,19 @@
command: scripts/dm governance check --repo .
cwd: /home/weichen/projects/Agent-Intent-wt-feature-governance-adoption
head: 0f8e2de85f559a23ee69db402afaf7b69d38c8c4
exit: 0
utc: 20261017T092123Z
---
ok gitlink deps/development-guidelines
ok gitlink deps/development-manager
ok agent-execution
ok file AGENTS.md
ok file PROJECT.md
ok file policy/guidelines-adoption.md
ok file .task/templates/task-card.md
ok executable scripts/agent-exec
ok executable scripts/hooks/pre-commit
ok executable scripts/dm
ok executable scripts/task
ok adoption status
ok fixtures
@@ -0,0 +1,7 @@
command: scripts/agent-exec validate --repo .
cwd: /home/weichen/projects/Agent-Intent-wt-feature-governance-adoption
head: 0f8e2de85f559a23ee69db402afaf7b69d38c8c4
exit: 0
utc: 20261017T092123Z
---
validate: ok
+66
View File
@@ -0,0 +1,66 @@
# <任务标题> 任务卡
任务主记录:<Issue/PR 链接,或注明"本文件随 PR 描述维护">。
状态:<进行中 / 待审核 / 已合入 / 已放弃>;owner:<填写>。
## 基线与授权
- 权威仓:<仓 URL 或逻辑 ID>;root_target:main;immediate_target:<main 或 feature/initiative>
- branch_kind:<initiative | child | standalone>;initiative_id:<稳定 ID,普通任务为空>
- R0(initial_main_sha):<完整 SHA>;工作区:<路径>(worktree / clone);分支:<feature|fix|hotfix/...>
- 父任务信息(仅子任务填写):parent_branch=<父分支>;parent_head_sha=<SHA>;parent_pr=<父 PR>
- accepting_requirements:<true | false>(仅 initiative 可为 true;冻结后为 false)
- 需求分流:verdict=<JOIN | NEW>;selected_initiative=<分支或无>;候选=<分支@HEAD 列表或无>;理由=<不适配/适配依据>;triage 收据=<源码外路径与摘要>
- 授权与交付终点:<本次允许的动作与终点;未授权事项写明>
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md;外部 Agent 一单元一新进程/会话/工作区)
## 目标与非目标
<预期结果、受影响入口/模块/契约;明确不做什么。>
## Agent 实施收据
按根目录 AGENT-EXECUTION.md 路由;外部 Agent 一单元一新进程/会话/隔离工作区。实施、修复与测试必须通过登记的候选执行;未配置或候选安全耗尽停在 unavailable/blocked,禁止 host 隐式代写。
| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|---|---|---|---|---|---|---|---|---|---|
| 待定义 | — | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | — |
## 复用材料(task-start 自动检索结果摘录)
- 相关近期提交:<git log -- <paths> 命中;无则写无>。
- 相关经验条目:<检索命中;无则写无>。
## 同步记录
| 节点 | sync_base_sha | tested_head_sha | checked_at | 结果 |
|---|---|---|---|---|
| 启动 | <R0> | <HEAD> | <日期时间> | <一致/已同步> |
| 验收前 | <填写> | <填写> | <填写> | <填写> |
字段没有发生的写"无",不省略。本卡与 PR 描述同源:开 PR 时将"目标与非目标、结果与验证、影响与剩余项"复制进 PR 描述,不另写第二份。
## 交付调度(有多个就绪任务时)
- 调度记录 / entry_id / 当前 attempt_id:<平台或调度服务链接及标识;未入队写无>。
- 入队源 HEAD / owner / 依赖:<完整 SHA、身份、依赖或无>。
- 固定候选引用:<调度尝试中的 base/head、组件与配置;未固定写无>。
- 窗口、最终 CI、Review 与真实合入结果:<引用调度记录及原始证据,不复制全队列>。
定义见 [合入调度与 CI 分配](../standards/merge-queue.md)。本段在候选固定前整理;固定后先使用源码之外的记录保存运行结果,再按项目闭合流程回填。
## 结果与验证(完成后填写)
- 结果:<交付了什么;未发生写无>。
- 验证:<结论性摘要:PASS/FAIL/SKIP/NOT_RUN 逐项;逐条证据见下表,来源标注与写入规则见 [按影响选择验证与证据](../standards/verification.md)>。
- 影响与剩余项:<影响面、遗留风险、后续任务>。
| 时间(UTC) | 命令 | 被测 HEAD | exit | 来源 | 摘要/位置 |
|---|---|---|---|---|---|
| <时间> | <命令;由项目 evidence 工具写入,无工具时附命令+对象 SHA+输出 digest 并标 SELF> | <HEAD> | <exit> | GATE/REPLAY/SELF | <摘要/位置> |
## 回收(task-finish)
- 合入核对:<分支已包含于目标分支的核对输出>。
- 未跟踪/ignored 文件处置:<归档/可再生/存疑 逐项;无则写无>。
- 回收动作:<worktree remove、分支删除、prune;放弃路径注明原因>。
+67
View File
@@ -0,0 +1,67 @@
# Project Agent Execution Configuration
# 项目工程 Agent 执行路由配置
适用范围:定义本项目中由 AI / Agent 主导的代码实施、修复、测试与审阅所调用的 Agent CLI 路由。
解析器识别正文中唯一一个 ```agent-execution 代码块。正文外的说明文字可根据项目需要调整。
通用硬规则见 Development-Guidelines 的 [Agent 执行与协作契约](deps/development-guidelines/standards/agent-execution.md)。
本配置为项目本地路由权威,声明具体的候选 profile、尝试次数上限、超时、集成入口与豁免路径;
代码块须通过 `deps/development-guidelines/templates/schemas/agent-execution.schema.json` 校验后才能登记为 active。
`fallback_to_host` 只能为 `never`;旧值 `all-unavailable` 仅供迁移工具读取,不能通过准入。
下例的 CLI 名称为维护主机上的实际执行器(cursor-agent-stable、agy、pi、codex);其他主机按实际执行器调整 executable。
```agent-execution
schema: agent-execution/v1
execution:
fresh_process_per_unit: true
permission: run-everything
fallback_to_host: never
max_parallel: 3
timeout_seconds: 7200
idle_timeout_seconds: 600
summary_bytes: 16384
raw_output_bytes: 10485760
max_attempts_per_profile: 3
emergency_profile: codex
roles:
implementation: [cursor, antigravity, pi]
repair: [cursor, antigravity, pi]
test: [pi, antigravity, cursor]
review: [pi, antigravity, cursor, codex]
profiles:
cursor:
adapter: cursor
executable: "cursor-agent-stable"
model: auto
enabled: true
antigravity:
adapter: antigravity
executable: agy
model: auto
enabled: true
pi:
adapter: pi
executable: pi
model: auto
enabled: true
codex:
adapter: codex
executable: codex
model: auto
enabled: true
integration:
exempt_paths: [".task/**", "docs/superpowers/specs/**", "docs/superpowers/plans/**"]
transport:
command: "scripts/agent-exec apply"
admission:
command: "scripts/agent-exec admit"
cleanup:
command: "scripts/agent-exec cleanup"
```
+49 -27
View File
@@ -1,32 +1,54 @@
# 项目工作入口
# Agent 工作说明(Agent-Intent)
本项目采纳的通用规则:[核心规则](deps/development-guidelines/core/baseline.md)。
采纳范围、生效状态及项目差异:[采纳记录](policy/guidelines-adoption.md)。
项目事实、测试和环境入口:[PROJECT.md](PROJECT.md)。
本仓开发遵循 Development-Guidelines(Dev 仓 `deps/development-guidelines`,gitlink 固定版本):
核心规则见其 `core/`,按任务触发阅读 `standards/` 专题;日常交付走 `workflows/delivery.md`。
- 启动:阅读以上短入口;只加载本次任务触发的专题。
- 普通交付:[交付流程](deps/development-guidelines/workflows/delivery.md)。
- 并行开发/正式测试/审核/合入:[共同基线与同步节点](deps/development-guidelines/standards/collaboration.md),必须确认并实际同步最新权威目标。
- Bug/异常:[系统性排障](deps/development-guidelines/workflows/debugging.md)。
- 会话结束/换机器/换客户端/换执行者:[会话、执行者与机器交接](deps/development-guidelines/workflows/handoff.md),状态写入任务主记录,不依赖本地会话历史。
- 测试与验收:[验证标准](deps/development-guidelines/standards/verification.md)。
- 模块/接口:[模块契约](deps/development-guidelines/standards/module-contracts.md)。
- 功能设计/跨模块变更:[设计与验证](deps/development-guidelines/workflows/design-verification.md),按增量明确验收、独立测试及故障范围。
- 错误与过程事件:[可观测性](deps/development-guidelines/standards/observability.md)。
- 配置来源与生效:[配置规范](deps/development-guidelines/standards/configuration.md),spec/overlay 合并、快照 revision 与诊断工具须遵守。
- 拆仓与 submodule:[多仓库与固定版本组合](deps/development-guidelines/standards/multi-repo.md)。
- 其他专题:按 [任务索引](deps/development-guidelines/README.md) 选择。
## 任务启动门禁
项目补充硬边界:
- 任务启动或恢复后,第一次写操作前在 Dev 仓运行 `make precheck` 并附完整输出(本仓作为子仓随父仓编排);
本仓独立开发时核对工作树 clean、与权威 origin/main 同步,并按任务触发读对应专题。
- 从权威 origin/main 固定共同起点 R0,建 feature|fix|hotfix/<slug> 分支(Dev 仓 `make task-start` 自动化);
任务中途不重复 precheck。
- 验收、请求审核、合入前各同步一次最新目标分支并附证据;合入后回收任务工作区与已合入分支。
- 提交主题行:英文祈使句一行(≤100 列,不以句点结尾);body 写 `Why: / Evidence: / Impact: / Refs:`。
- 本仓只做意图识别机制(定义、合并、识别、槽位、诊断),不内置任何业务意图内容;意图词表与正则一律由宿主注册或配置。
- 依赖方向:仅可选接入 error-code-system 与 logging-system;禁止导入 agent-native、agent-squad、agent-system 或任何宿主类型。LLM 分类器、挂起预期、分句等扩展点只定义接口,实现留在宿主。
- 配置/spec 校验失败 fail-closed:拒绝生成新快照并保留上一生效快照。「无匹配」(fallback)是正常识别结果,不是错误。
- 错误码常量与 Error-Code-System 的 `intent.*` / `sys.*` 规范码对齐,`catalogcheck` 防止双权威;未知码不静默当成功。
- 核心识别路径纯内存、无 I/O、确定性:同输入 + 同快照 → 同结果,不含时间与随机因素。
- 依赖仓版本以本仓 gitlink 为准,不执行 `submodule update --remote`;除两个生态子模块外零第三方依赖。
## 仓内规则
任务权威:Gitea 仓库 `weichen/Agent-Intent` 的 Issue 与 Pull Request。
规范性文档目录与过程记录位置:`policy/` 放项目规则;`docs/records/` 为过程记录豁免目录(方案、验证、交接)。项目规范与过程记录不进通用规范库。
默认集成基线和提交身份:`main`;提交身份沿用本机 git 作者,不在文档中写死密钥或个人 fork URL。
共享环境/发布入口及授权:无共享运行环境。权威远端 `ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Intent.git`。
- 模块边界、公开契约与隔离档位见 `policy/modules/`(如有)与 PROJECT.md。
- 跨仓 gitlink 变更须经组合验证后在 Dev 仓推进;受保护分支经 PR 合入。
- 验证命令见 PROJECT.md「验证」节;完成声明须附实际命令输出证据。
## Gitea 与 Git 访问事实(2026-09-30 实测)
历史文档与旧链接里的 `git-panel.u1680832.nyat.app:30319` 已失效(TLS 握手失败),
见到一律按过期处理,不要再试。当前权威通道:
- **Web / API(HTTPS)**:`https://git-home.u1680832.nyat.app:30319`(Gitea 1.27.3,
API 前缀 `/api/v1`;偶发 broken pipe / 超时,重试即可)。
- **Git over SSH**:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/<Repo>.git`
(各仓 origin 与 gitlink 均为此格式)。
- **API token**:`~/.secrets/gitea`,两行键值 `URL_PREFIX=…` 与
`ACCESS_TOKEN=<40 位 hex>`;取值 `grep ACCESS_TOKEN ~/.secrets/gitea | cut -d= -f2-`,
用作 `Authorization: token <值>` 请求头。**token 值不入仓、不回显、不写进产出物**
(baseline 硬边界 2:只记录来源、字段与可用状态)。
- **SSH key**:`~/.ssh/home_gitea`(ED25519,公钥指纹
`SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20`,Gitea 侧密钥名 GMKtec);
`~/.ssh/config` 已有 `git-ssh.u1680832.nyat.app` 主机条目,克隆/推送直接可用。
## Agent 执行与新项目准入(强制)
1. 缺 Guidelines 固定版本(`deps/development-guidelines` gitlink 未就绪或 HEAD 不符)时,Agent 只能只读分析,不得实施。
2. 缺有效 `AGENT-EXECUTION.md`(`scripts/dm governance check` 未通过)时禁止 AI 实施。
3. 缺共享执行器(`scripts/agent-exec` 或 `deps/development-manager` 不可用)时,状态为 `blocked/unavailable`,不得改由 host 替代。
4. 不允许 host 代写实施单元;实施、修复与测试必须经 `AGENT-EXECUTION.md` 登记的候选执行。
5. 人类直接开发按其自身授权与项目规则进行,不受本条款约束。
6. 分支保护与规范 tag 由用户负责设置与维护,Agent 不得代为设置。
入口:
- 需求分流:`scripts/task triage discover|decide|verify`
- 任务启动:`scripts/task start …`
- PR 检查:`scripts/task pr-check …`
- Agent 执行:`scripts/agent-exec run|apply|admit|validate`
AI 实施单元仅通过 `scripts/agent-exec run` 运行(依据 AGENT-EXECUTION.md),并经 `scripts/agent-exec apply` 集成。
+5 -3
View File
@@ -5,15 +5,15 @@
## 仓库与操作
- 目标/产品边界:通用意图识别基础库。提供意图定义(spec)、注册表与配置合并(registry/loader)、四级漏斗识别器(guard → expectation → rules → adjudicate)、槽位抽取与诊断(conformance/catalogcheck/intentctl)。不做动作执行、不内置 LLM 客户端、不含任何业务意图内容,不依赖 Agent-Native / Agent-Squad / Agent-System。
- 源码、资产、文档、机器契约位置:Go 源码在仓库根包目录;模块路径 `agent-intent`;人类文档在根 `README.md`、`docs/`、`policy/`;过程记录在 `docs/records/`;机器契约以 `intent` 包公开类型为准。
- 源码、资产、文档、机器契约位置:Go 源码在仓库根包目录;模块路径 `agent-intent`;人类文档在根 `README.md`、`docs/`、`policy/`;过程记录:任务卡与交付证据在 `.task/`,其他过程记录在 `docs/records/`;机器契约以 `intent` 包公开类型为准。
- 权威远端、集成基线、受保护分支:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Intent.git`,集成基线 `main`。
- 并行共同基线记录位置、合入通知/主动检查方式:任务主记录在 Gitea Issue/PR;合入前在本仓 `main` 上 `git fetch` 并确认 HEAD。
- 发布线与批准版本/实际制品查询入口:当前无独立制品仓库。可构建性以本仓 `main` 的 `go test` 为准;依赖子模块版本以 gitlink 为准,不把工作区未提交文件当发布组合。
- 提交/审核/合并/发布身份与权限:仓库所有者 weichen。本机提交不代替远端合并。
- 任务唯一来源:Gitea `weichen/Agent-Intent` 的 Issue 与 Pull Request。
- 规范性文档目录:`policy/`。
- 过程文档仓/目录:`docs/records/`(项目内豁免,理由见 [采纳记录](policy/guidelines-adoption.md))。
- 交接记录位置:随任务主记录;需要落盘的交接放入 `docs/records/`。
- 过程文档仓/目录:任务卡与交付证据在 `.task/`;其他过程记录在 `docs/records/`(项目内豁免,理由见 [采纳记录](policy/guidelines-adoption.md))。
- 交接记录位置:随任务主记录;需要落盘的交接放入 `docs/records/`;任务卡与交付证据在 `.task/`。
- 凭证查找入口及所需字段:无凭证。本仓不保存密钥字段的值。
## 依赖组合
@@ -23,6 +23,7 @@
| development-guidelines | `deps/development-guidelines` | development-only,规范文本 |
| error-code-system | `deps/error-code-system` | build-required,规范码目录(catalogcheck 对齐 + `intent.*` 常量值) |
| logging-system | `deps/logging-system` | build-required,可选日志器 |
| development-manager | `deps/development-manager` | development-only,共享 Agent 执行器与治理脚手架(`scripts/dm`、`scripts/agent-exec` 的钉住来源) |
克隆:
@@ -44,6 +45,7 @@ git clone --recurse-submodules ssh://git@git-ssh.u1680832.nyat.app:31411/weichen
| 并发/快照隔离 | 仓库根:`GOWORK=off go test -count=1 -race ./...` | 覆盖 Reload 与识别并发 |
| 依赖纪律 | 仓库根:`GOWORK=off go list -m all` 仅标准库 + 两个生态模块 | 不证明宿主接入正确性 |
| 宿主行为验收 | 消费方仓内跑 `conformance.Run` | 本命令不覆盖真实宿主配置 |
| 治理与 Agent 执行配置 | 仓库根:`scripts/agent-exec validate --repo .` 与 `scripts/dm governance check --repo .` | 只校验 `AGENT-EXECUTION.md` 与治理 gitlink/文件,不证明执行器真实运行 |
## 环境与数据
+6 -1
View File
@@ -102,19 +102,24 @@ Go 与 JSON 共用同一结构;JSON overlay 文档形如:
| `id` | 全局唯一;首段必须等于 `domain`,各段 `^[a-z][a-z0-9_-]{0,63}$`,可多级(`demo.run.status_query`) |
| `version` | ≥1;overlay 覆盖同 id 时必须递增 |
| `matcher` | 见上文计分规则;至少一个正向条件;`case_sensitive` 只作用于词表,pattern 用 `(?i)` |
| `slots` | `group` 默认 1;`required` 缺失记录到 `MissingRequiredSlots` |
| `slots` | `group` 默认 1;`required` 未捕获记录到 `MissingRequiredSlots`;`label`/`question` 供 HITL 提问与目录展示;`enum`/`format`/`normalize`(trim\|lower\|upper)/`validator`(`RegisterSlotValidator` 注册)构成确定性校验链,非法值进 `Top.InvalidSlots`(与"缺失"区分) |
| `confidence` | 缺省 `base=0.7 / pattern_bonus=0.2 / threshold=0.6` |
| `risk` / `requires_confirm` / `handler` / `metadata` | 模块不解释,透传给宿主做授权、确认与路由 |
| `disabled` | 定义但停用(灰度暂存) |
合并语义:Go 内置为基线;目录 root 内 `*.json` 按文件名字典序、roots 按给定顺序应用;同 id 覆盖需版本递增;`disable` 按 id 禁用(保护域整批失败);未知字段拒绝并定位到文件。数量上限见 `Options`(spec 总数、单 spec 正则/槽位数、输入长度),防止配置膨胀。
## 能力目录(Catalog)
`Snapshot.Catalog()/CatalogMarkdown()` 把启用的非守卫 spec 渲染为规划者/领导 Agent 可消费的声明式目录:有哪些可直接调用的能力、必填槽位与提问建议、风险与确认要求。用于"目录驱动的编排"——规划者据此把子任务短路给系统功能、按槽位 question 生成定向 HITL(Agent-Squad 已按此接入,见其 `squadintent` 与 `docs/records`)。
## 诊断(intentctl)
```bash
go run ./cmd/intentctl lint -root configs/intents # 校验 + 冲突提示
go run ./cmd/intentctl explain -root configs/intents "查询任务进度" # 分支/候选/槽位/裁决轨迹
go run ./cmd/intentctl eval -root configs/intents evals/corpus.json
go run ./cmd/intentctl catalog -root configs/intents # 能力目录(注入规划者提示词)
```
评测集格式见 `intent/testdata/corpus.json`(`name / input / expect_branch / expect_top / expect_slots / expect_missing_slots`)。评测集放在配置目录之外。注意:宿主以 Go 内置注册守卫时,守卫用例只能在宿主自己的测试(或 conformance.Run)中验证;intentctl 只加载 JSON 配置。
+117 -14
View File
@@ -33,6 +33,8 @@ func run(args []string) error {
err = cmdExplain(args[1:])
case "eval":
err = cmdEval(args[1:])
case "catalog":
err = cmdCatalog(args[1:])
default:
err = usage()
}
@@ -40,7 +42,7 @@ func run(args []string) error {
}
func usage() error {
return fmt.Errorf("用法:intentctl <lint|explain|eval> ...\n lint -root <dir> [-protected a,b] [-guard a,b]\n explain -root <dir> [-protected a,b] [-guard a,b] <input>\n eval -root <dir> [-protected a,b] [-guard a,b] <corpus.json>")
return fmt.Errorf("用法:intentctl <lint|explain|eval|catalog> ...\n lint -root <dir> [-protected a,b] [-guard a,b]\n explain -root <dir> [-protected a,b] [-guard a,b] <input>\n eval -root <dir> [-protected a,b] [-guard a,b] <corpus.json>\n catalog -root <dir> [-protected a,b] [-guard a,b] 输出能力目录(注入规划者/领导提示词用)")
}
type commonOpts struct {
@@ -104,15 +106,46 @@ func cmdLint(args []string) error {
conflicts := snap.Conflicts()
if len(conflicts) == 0 {
fmt.Println("共享词条提示:无")
} else {
fmt.Printf("共享词条提示(%d 条,非错误,计分与确定性次序会裁决):\n", len(conflicts))
for _, cf := range conflicts {
fmt.Printf(" %s 与 %s 共享词条 %q\n", cf.A, cf.B, cf.Term)
}
}
// 词界提示(I3):短词条且未开 boundary 的 spec 容易被中文子串误命中
// (如「配置」命中「配置项」)。提示非错误;boundary: true 可压掉。
var hints []string
for _, id := range snap.SpecIDs() {
spec, ok := snap.SpecByID(id)
if !ok || spec.Matcher.Boundary {
continue
}
terms := append(append([]string(nil), spec.Matcher.IncludesAny...), spec.Matcher.IncludesAll...)
for _, term := range terms {
if runeLen(term) <= 2 {
hints = append(hints, fmt.Sprintf(" %s 词条 %q 短且未开 boundary(中文子串误命中风险;可考虑 boundary: true)", id, term))
}
}
}
if len(hints) == 0 {
fmt.Println("词界提示:无")
return nil
}
fmt.Printf("共享词条提示(%d 条,非错误,计分与确定性次序会裁决):\n", len(conflicts))
for _, cf := range conflicts {
fmt.Printf(" %s 与 %s 共享词条 %q\n", cf.A, cf.B, cf.Term)
fmt.Printf("词界提示(%d 条,非错误):\n", len(hints))
for _, h := range hints {
fmt.Println(h)
}
return nil
}
func runeLen(s string) int {
n := 0
for range s {
n++
}
return n
}
func cmdExplain(args []string) error {
fs := flag.NewFlagSet("explain", flag.ContinueOnError)
c := parseCommon(fs)
@@ -149,6 +182,13 @@ func cmdExplain(args []string) error {
if len(res.Top.MissingRequiredSlots) > 0 {
fmt.Printf(" 缺失必填槽位:%s\n", strings.Join(res.Top.MissingRequiredSlots, ","))
}
if len(res.Top.InvalidSlots) > 0 {
fmt.Printf(" 非法槽位:")
for _, k := range sortedKeys(res.Top.InvalidSlots) {
fmt.Printf("%s(%s) ", k, res.Top.InvalidSlots[k])
}
fmt.Println()
}
}
if len(res.Candidates) > 0 {
fmt.Printf("候选(%d):\n", len(res.Candidates))
@@ -180,18 +220,34 @@ func printStage(name string, scored []intent.ScoredSpec, rejected []intent.Rejec
}
}
// evalCase 与 intent 包评测集(testdata/corpus.json)同格式。
type evalCase struct {
Name string `json:"name"`
Input string `json:"input"`
ExpectBranch string `json:"expect_branch"`
ExpectTop string `json:"expect_top"`
ExpectSlots map[string]string `json:"expect_slots"`
ExpectMissingSlots []string `json:"expect_missing_slots"`
func cmdCatalog(args []string) error {
fs := flag.NewFlagSet("catalog", flag.ContinueOnError)
c := parseCommon(fs)
if err := fs.Parse(args); err != nil {
return err
}
reg, err := buildRegistry(c)
if err != nil {
return fmt.Errorf("配置校验失败:%w", err)
}
fmt.Print(reg.Snapshot().CatalogMarkdown())
return nil
}
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
type evalCorpus struct {
Cases []evalCase `json:"cases"`
Cases []intent.CorpusCase `json:"cases"`
BuiltinSpecs []intent.IntentSpec `json:"builtin_specs,omitempty"`
GuardDomains []string `json:"guard_domains,omitempty"`
ProtectedDomains []string `json:"protected_domains,omitempty"`
}
func cmdEval(args []string) error {
@@ -214,7 +270,23 @@ func cmdEval(args []string) error {
if len(corpus.Cases) == 0 {
return fmt.Errorf("评测集为空")
}
reg, err := buildRegistry(c)
// 语料自声明:builtin_specs 先行注册(如 guard 这类 Go 内置形态的 spec),
// 域名与命令行取并集——评测集可以自包含复现,不依赖隐式内置。
var reg *intent.Registry
if len(corpus.BuiltinSpecs) > 0 || len(corpus.GuardDomains) > 0 || len(corpus.ProtectedDomains) > 0 {
if c.root == "" {
return fmt.Errorf("-root 必填")
}
guard := append(append([]string(nil), splitDomains(c.guard)...), corpus.GuardDomains...)
protected := append(append([]string(nil), splitDomains(c.protected)...), corpus.ProtectedDomains...)
b := intent.NewRegistryBuilder(intent.Options{GuardDomains: guard, ProtectedDomains: protected})
if err := b.Register(corpus.BuiltinSpecs...); err != nil {
return fmt.Errorf("语料 builtin_specs 注册失败:%w", err)
}
reg, err = b.BuildFS(context.Background(), os.DirFS(c.root), ".")
} else {
reg, err = buildRegistry(c)
}
if err != nil {
return fmt.Errorf("配置校验失败:%w", err)
}
@@ -223,6 +295,21 @@ func cmdEval(args []string) error {
failed := 0
for _, tc := range corpus.Cases {
if len(tc.Turns) > 0 {
// v2 多轮:链式预期逐轮执行,逐轮命令期望断言(I1 WP6)。
result := intent.RunCorpusCase(context.Background(), snap, tc)
if result.Passed() {
fmt.Printf("PASS %-28s 多轮(%d 轮)\n", tc.Name, len(result.Turns))
continue
}
failed++
for _, tr := range result.Turns {
for _, mm := range tr.Mismatches {
fmt.Printf("FAIL %-28s turn %d 输入=%q:%s\n", tc.Name, tr.Index, tr.Input, mm)
}
}
continue
}
res, err := rec.Recognize(context.Background(), tc.Input, snap)
if err != nil {
fmt.Printf("FAIL %-28s 识别错误:%v\n", tc.Name, err)
@@ -253,6 +340,22 @@ func cmdEval(args []string) error {
fmt.Printf("PASS %-28s %s\n", tc.Name, res.Branch)
}
fmt.Printf("总计 %d 用例:%d 通过,%d 失败\n", len(corpus.Cases), len(corpus.Cases)-failed, failed)
// I2:指标输出(precision/recall 按意图标签计;确定性重跑,不计入通过判定)。
var results []intent.CaseResult
for _, tc := range corpus.Cases {
results = append(results, intent.RunCorpusCase(context.Background(), snap, tc))
}
metrics := intent.MetricsOf(corpus.Cases, results)
p, pok := metrics.Precision()
r, rok := metrics.Recall()
ps, rs := "-", "-"
if pok {
ps = fmt.Sprintf("%.3f", p)
}
if rok {
rs = fmt.Sprintf("%.3f", r)
}
fmt.Printf("指标: precision=%s recall=%s(TP=%d FP=%d FN=%d)\n", ps, rs, metrics.TP, metrics.FP, metrics.FN)
if failed > 0 {
return fmt.Errorf("%d/%d 用例失败", failed, len(corpus.Cases))
}
Vendored Submodule
+1
@@ -0,0 +1,99 @@
# 编排能力目录:意图识别与规划者/小队领导的配合(设计思考)
状态:待审(设计结论,未实施);owner:weichen。
触发:用户对 Squad/Native 分工的模型与核心疑问——理想状态下规划者/领导把任务拆解为单一意图步骤下发给 Native 执行;领导可通过 HITL 收集信息后生成单一意图指令;**但未接入意图识别时,领导如何知道要通过 HITL 收集哪些信息?**
关联:[2026-09-29-scope-boundaries-compound-slots-dialog.md](2026-09-29-scope-boundaries-compound-slots-dialog.md)、[2026-09-29-decoupling-and-llm-layering.md](2026-09-29-decoupling-and-llm-layering.md)。
## 一、先校正分工事实(对齐用户模型)
准确表述是:"**机制在 Squad,智能在 Native 之上**":
| 能力 | 机制(脚手架) | 智能(谁在思考) |
|---|---|---|
| 任务拆解 | Squad:Planner 接口、蓝图、flowDriver | 跑在 Native 上的规划者(LLMPlanner)或领导 Agent(leader 委派循环) |
| 任务分配 | Squad:编制、BackendResolver、委派协议 | 领导 Agent(delegation 决策)或规划器 |
| 任务执行 | Native:循环、工具、工作区、交付物落盘、失败分类 | 执行 Agent |
| 结果校验 | Squad:Checker/Evaluator、质检 HITL、黑板 | 质检 Agent / 规则检查器 |
所以"Squad 是组织功能模组"成立——但拆解/分配/校验的**机制**确实在 Squad;被组织的"智能体"全部跑在 Native 上。这个校正很重要:它决定了意图知识应该注入的位置是 **Squad 的规划入口(PlanInput / 领导上下文)**,而不是 Native 内部。
## 二、"意图识别与 Native 配合"的准确形态
用户理想:被分配的 Native 角色只收到**单一意图**的输入。正确,且落地形态是——下发给执行的每一步只有两种:
1. **系统功能步**:意图已识别、槽位已填齐 → 直接调 handler(不打模型、不进 agent 循环);
2. **Agent 步**:单一明确目标的简报(不含可被系统功能短路的部分),上下文完整。
注意"单一意图"对 agent 步意味着"单一明确目标",不必然是系统功能意图(调研/撰写类步骤天然开放)。
## 三、核心问题:领导如何知道要问什么?
### 现状(未接入目录)
规划者/领导只看得见**角色目录**(RoleRegistry:谁会干什么,Keywords/Responsibilities)与蓝图关键词。它**看不见系统有哪些可直接调用的功能**,因此:
- 拆解时无法把子任务短路给系统功能(明明有 `taskrun.query_status`,却派了个 agent 去"想办法查");
- HITL 提问只能自由发挥(leader 的 escalate 是自由文本):要么问不到点(缺的字段没收集),要么问错(问了不存在的字段);
- 收集到的信息没有契约校验(字段真伪/枚举合法与否无人把关)。
### 答案:意图注册表就是能力目录(Capability Catalog)
IntentSpec 天然携带目录所需的全部声明:
```text
id / description → 有哪些系统能力(人话,供 LLM 匹配)
slots[].required → 每项能力缺什么信息(HITL 提问清单的来源)
slots 的约束/标签 → 问什么、怎么问、什么算合法答案(E2/E4 扩展)
risk / requires_confirm → 哪些能力执行前必须确认(对齐 HITL 确认门)
handler / metadata → 短路调用方式与路由提示
```
对称性很有说服力:**角色目录回答"谁能干活",意图目录回答"有哪些系统能力不必干活(直接调用)"**。今天规划者只有前者;补上后者,拆解才是完备的。
### 三触点架构(意图在 Squad×Native 栈中的完整位置)
```
用户目标
│
▼
[触点 1] 入口闸门(Squad gate,已建)
│ 整句确定性识别:守卫拒绝 / 整句命中系统功能 → 短路 handler / 其余放行
▼
规划者 / 领导 Agent(跑在 Native 上)
│ 注入[触点 2] 能力目录(本篇新增的关键件):
│ · 拆解时把子任务匹配到系统功能(不派 agent)
│ · 按 slots[].required 生成定向 HITL 提问(不再自由发挥)
│ · 产出计划后经确定性校验:意图存在?必填槽齐?枚举合法?——fail-closed 打回
▼
步骤下发
├─ 系统功能步:handler 直调(槽位已填齐,无 agent)
└─ Agent 步:单一目标简报 → Native 执行
│
▼
[触点 3] 执行防线(Native intentgate 装饰器,已建)
步骤 prompt 兜底拦截:守卫命中 / 意外命中系统功能 → 合成结果,模型零调用
```
触点 1 与 3 已建成并验证;**缺的是触点 2**。它同时解决上一轮"复合指令"的执行侧:整句可短路则触点 1 直接办;混合指令由领导携目录拆解;领导拆解结果再被确定性校验——LLM 负责语义匹配,规则负责契约把关,各居其位。
### 防"领导幻觉"的闭环
领导(LLM)可能提议不存在的功能或漏填槽位。确定性校验(用本模块的 SpecByID + 槽位约束)构成闭环:不通过 → 打回重拆或转 HITL 补问,绝不带着残缺契约执行。这与 Squad"未知 backend fail-closed"的既有边界同构。
## 四、落地差距清单
| # | 差距 | 归属 | 性质 |
|---|---|---|---|
| G1 | 目录渲染:把 Snapshot 渲染为规划者/领导可消费的目录文本(Markdown/JSON,含 slot 标签与提问建议) | agent-intent(建议 E4:`Catalog()` 助手)或宿主自渲染 | 小 |
| G2 | SlotSpec 增加人类可读 `label` / `question`(HITL 提问生成直接可用);配合 E2 的 enum/range 约束做答案校验 | agent-intent(E2 扩展的一部分) | 小 |
| G3 | PlanInput / 领导 prompt 注入目录;计划输出经确定性校验(意图存在、必填槽齐、确认门对齐 risk) | **Agent-Squad 演进项** | 中(规划器/leader 提示与校验逻辑) |
| G4 | 系统功能步的执行通道:计划步骤 → handler 调用(不经 BackendResolver/agent) | Agent-Squad 演进项 | 中 |
| G5 | 领导 HITL 提问模板:由 slot question 生成澄清请求(复用既有 HitlClarification/HitlEscalation 机制) | Agent-Squad 演进项 | 小 |
agent-intent 侧只需 E2/E4 两个小扩展(保持机制化、确定性);重头在 Squad 侧的 G3–G5,属"目录驱动的编排"演进。
## 五、结论
- 用户模型成立,校正后更准确:机制在 Squad、智能在 Native 之上;意图知识的注入点是 Squad 的规划入口,不是 Native 内部。
- "领导如何知道问什么"的答案:**意图注册表即能力目录**——slots 声明了缺什么、risk 声明了要不要确认、description 声明了有什么;当前缺的是目录暴露(G1/G2)与消费侧(G3–G5)。
- 三触点齐备后:整句短路(触点1)、目录化拆解 + 定向 HITL + 契约校验(触点2)、执行兜底(触点3)——复合指令、槽位收集、单一意图下发在此架构下闭环,且每一触点都保持"规则定契约、LLM 做匹配"的分工。
@@ -0,0 +1,72 @@
# 复合指令、槽位与多轮对话的职责边界(识别器 / 会话层 / 编排层)
状态:待审(设计结论,未实施);owner:weichen。
触发问题:复合串并行多意图指令、槽位模糊/缺失/错误/复杂、多轮描述与意图切换/叠加——哪些进本模块,哪些由其他层维护。
关联:[2026-09-29-decoupling-and-llm-layering.md](2026-09-29-decoupling-and-llm-layering.md)、[2026-09-29-squad-native-integration-report.md](2026-09-29-squad-native-integration-report.md)。
## 总原则:三层职责
```
L4 对话管理层(有状态):会话上下文、DST、多轮澄清流程、意图切换/叠加决策、超时清理
—— 宿主会话层。Agent-System 的 session/TaskState/intentflow 是现成先例;
Squad / Native 直宿主需要时再抽独立 Dialog-Session 模块(判据见文末)。
L3 任务编排层(有状态):拆解、串行/并行依赖、系统功能×通用智能体混合调度、执行与故障
—— Agent-Squad(Planner/蓝图/leader 委派)本来就是这一层。
L2 意图识别层(无状态,本模块):单句(或单段)→ 分支/意图/槽位;提供分句、挂起预期、
L2 分类器等机制接口;同输入同快照同结果。
```
经验证据:Agent-System 中 `internal/intent`(识别器)无状态逐句;多意图计划(intentflow 的 HandleTurn/DispatchEligible/RulesCanDecide 门控)与对话状态机(TaskState.UpdateFromTurn、PendingExpectation)全部位于 session/coordinator 层。**即便在意图层最重的 Agent-System 里,识别与会话管理也是分开的**——本模块沿用同一边界。
## 一、复合指令("先 A,然后 B,接着 C,最后 D,并且要满足 E")
**本模块做的**:分句(`Segmenter`)→ 逐段 `Recognize` → 每段得到独立的(分支/意图/槽位/候选)。"这段是系统功能还是自由任务"正是逐段识别的直接输出:`Matched + handler` = 系统特有功能;`Fallback` = 交给通用智能体。模块可补一个组合助手(见建议 E1),本质仍是纯函数。
**不进本模块、归编排层的**:段间串行/并行/依赖决策、执行顺序、失败传播、系统功能段与 agent 段的混合调度。这些是任务规划的状态与责任,天然属于 Agent-Squad(Planner/蓝图/leader)。Squad 侧的未来增强形态:分段结果 → 系统功能段转 handler/蓝图步骤、自由段聚合为自主 goal,由 Planner 统一定序。这是 Squad 的演进项,不是 agent-intent 的内部功能。
**为什么**:串并行判断需要的是规划语义(依赖、资源、故障责任),不是识别语义;把它塞进识别器会让无状态确定性承诺破裂(识别结果依赖执行状态)。
## 二、槽位四态的归属
| 槽位状态 | 归属 | 机制 |
|---|---|---|
| 缺失 | **模块已支持** | `MissingRequiredSlots` + `ExpectationProvider`:宿主据缺失发起澄清,下一轮 expectation 命中即续上。澄清对话流程本身属宿主 |
| 表面模糊(大小写、别名、格式不一致) | **模块可确定性扩展(建议 E2)** | SlotSpec 增加归一化/约束(enum / range / format / Go 注册的 validator、normalizer),编译期确定性不变 |
| 语义模糊(指代"它"、"上次的任务"、相对时间"下周") | 宿主 LLM 层或功能方 | 需要会话上下文与模型判断(LLM 分层文档的异步重层)或系统功能自身 API 的解析;模块只负责抽出原文 span |
| 用户输入错误 | 模块做确定性校验(E2 的一部分);业务真伪的权威在系统功能方 | 校验失败与"缺失"同路处理(澄清),但带 `invalid:value` 原因,宿主可区分"没给"与"给错了" |
| 极复杂槽位(嵌套结构、多字段) | 系统功能方(handler) | 模块抽取 span/整段;结构化解析依据 handler 自己的契约(只有它知道 schema) |
原则:**槽位的"识别与确定性校验"进模块,槽位的"语义消解与结构化"不进**——后者要么需要上下文(会话层),要么需要领域 schema(功能方)。
## 三、多轮描述、意图切换、意图叠加
**判断信号本模块已在单次识别里全部给出**:
| 用户行为 | 识别输出信号 | 谁决策 |
|---|---|---|
| 多轮描述同一意图(逐轮补细节) | 上一轮澄清后:本轮 `ExpectationHit`;或 `Matched` 同一意图 + `MissingRequiredSlots` 持续收敛 | 宿主 DST 维护"活动意图",决定何时收敛执行 |
| 说一半切新意图 | `ExpectationProvider` 有挂起但未命中 + 本轮 `Matched` 不同意图 | 宿主决定:丢弃旧意图 / 挂起保留 / 询问用户 |
| 在原意图上叠加新要求 | `ExpectationHit` + 候选列表里出现其他过阈意图;或多段分句结果混合 | 宿主决定合并方式(并入活动意图 / 拆两个任务) |
**不进本模块的**:活动意图状态、切换/叠加策略、多轮上下文存储、澄清超时清理——这是 DST(对话状态管理),有状态且策略随产品而异,属宿主会话层。模块刻意不持有会话状态(挂起预期都是宿主经接口注入的),这正是它能被 Squad、Native、Agent-System 等不同形态宿主共用的前提。
**如果未来需要独立的会话层模块**(如 Squad 直宿主做对话产品时):抽一个小仓(暂名 Dialog-Session),组合 agent-intent 为识别引擎 + 自维护 DST 与期望管理。抽仓判据沿用模块契约规范:**至少两个宿主需要同一套 DST 时才抽**,不为单一宿主预建公共仓。
## 结论
| 问题 | 本模块 | 其他层 |
|---|---|---|
| 复合串并行多指令 | 分句 + 逐段识别(机制已有,补组合助手 E1) | 串并行与混合调度 → Agent-Squad/编排层 |
| 槽位缺失 | MissingRequiredSlots + 挂起预期(已有) | 澄清对话流程 → 会话层 |
| 槽位模糊/错误 | 确定性归一与校验(建议扩展 E2) | 语义消解/复杂结构 → LLM 层 / 功能方 |
| 多轮/切换/叠加 | 单次识别给出全部判断信号(已有) | DST 与策略 → 会话层(Agent-System 先例;必要时抽 Dialog-Session) |
### 建议的模块内扩展(待批,均保持机制化与确定性)
- **E1** `SegmentAndRecognize`:分句 + 逐段识别的组合助手,结果带段序号与原文段,宿主免写循环。
- **E2** SlotSpec 确定性约束与归一:`enum` / `range` / `format` 声明式约束 + Go 注册自定义 validator/normalizer;校验失败区分 `missing` 与 `invalid`。
- **E3**(可选)Result 附带 segment 元数据,便于宿主对账与审计。
### 明确不做清单
串并行规划、系统功能×agent 混合调度(→编排层);会话状态、DST、切换/叠加策略、澄清流程(→会话层);热路径 LLM 槽位解析(→LLM 分层策略的异步层)。
@@ -0,0 +1,112 @@
# Agent-Intent × Agent-Squad × Agent-Native 接入与集成测试报告
日期:2026-09-29;执行:weichen(会话内自动化 + 手工 E2E)。
设计依据:[2026-09-29-decoupling-and-llm-layering.md](2026-09-29-decoupling-and-llm-layering.md)(D1–D7 全部按推荐执行)。
提交组合:Error-Code-System `dbeed2f` ← Agent-Intent `55780ae` ← Agent-Native `c659dc1` ← Agent-Squad `6a36308`(guidelines `26b562d2` / logging-system `2c5716f` 全链一致,各仓一致性脚本全绿)。
## 一、外挂了什么意图(本报告全部用例共用的意图集)
E2E 与 intentctl 用同一份 JSON 配置(`/tmp/squad-intents/intents.json`);单测另用等效 Go/MapFS 定义。共 4 条:
| # | 意图 ID | 域 | 触发词(includes_any) | 置信度参数 | 元数据(宿主解释) |
|---|---|---|---|---|---|
| 1 | `guard.config_change` | guard(守卫域,Stage 0) | 系统配置 / 网关 / 改密码 | 默认(base 0.7 / threshold 0.6) | risk=high;guidance="系统配置请在管理界面修改,任务入口不受理" |
| 2 | `demo.report.competitor` | demo | 竞品 / 对标分析 | base 0.8 / threshold 0.7 | handler=report.competitor;**blueprint=competitor_report**(定向模板) |
| 3 | `demo.greet.hello` | demo | 你好 | 默认 | priority=10(无 blueprint → 命中也不干预) |
| 4 | `demo.greet.hi` | demo | 你好 | 默认 | priority=5(与 3 同词同分 → 制造歧义分支) |
配套蓝图(Squad 数据目录):`competitor_report`,关键词 `竞品分析报告`(注意:E2E 目标文本**不含**该关键词),1 步(researcher → output.md)。
## 二、意图识别层用例(intentctl,纯模块层)
命令:`go run ./cmd/intentctl eval -root /tmp/squad-intents /tmp/squad-intents-corpus.json` → **5/5 PASS**。
| 用例 | 输入 | 识别结果(分支 / Top / 置信度) | 期望 | 判定 |
|---|---|---|---|---|
| guard_refuse | `帮我把系统网关地址改一下` | guard_hit / `guard.config_change` / 0.7 | guard_hit + 该 Top | ✅ |
| route_blueprint | `给我做一份竞品情况的分析报告` | matched / `demo.report.competitor` / 0.8(terms=竞品) | matched + 该 Top | ✅ |
| ambiguous_greet | `你好呀` | ambiguous(hello 与 hi 同分 0.7,priority 10>5 排序) | ambiguous | ✅ |
| free_fallback | `帮我整理一下这周的会议纪要` | fallback | fallback(交给原路径) | ✅ |
| hard_negative_excluded | `查询系统状态` | fallback(无 spec 过阈;守卫词"系统配置"未整词命中) | fallback(不误触) | ✅ |
explain 摘录(route_blueprint 输入):`守卫 排除 guard.config_change no_positive_match;规则 命中 demo.report.competitor 0.8000 竞品`。
## 三、Agent-Squad 端到端(真实 CLI,`--hitl-mode auto`,stub 后端)
四组对照实验,同一数据目录与蓝图,唯一变量是是否 `--intent-root /tmp/squad-intents`:
| 用例 | 输入(goal) | 外挂意图 | 实际结果 | 期望 | 判定 |
|---|---|---|---|---|---|
| A 守卫拒绝 | `帮我把系统网关地址改一下` | 有 | `failed`,错误 `squad.intent_refused: 命中守卫意图 guard.config_change`,exit 1 | 拒绝且带稳定码 | ✅ |
| B 同输入对照 | 同上 | 无 | `completed`,4 步自主规划,质检 100,exit 0 | 正常执行(证明拒绝只来自外挂意图) | ✅ |
| C 意图定向 | `给我做一份竞品情况的分析报告`(**不含**蓝图关键词) | 有 | `completed`,**1 步**(= 蓝图步骤 b1);state.json 记录 `task_key=competitor_report`、`planning_mode=blueprint`、`intent_id=demo.report.competitor`、`intent_blueprint=competitor_report` | 定向到模板并审计落盘 | ✅ |
| D 同输入对照 | 同上 | 无 | `completed`,**4 步**自主规划 | 不定向(证明路由变化来自意图) | ✅ |
| E 自由透传 | `帮我整理一下这周的会议纪要` | 有(不命中) | `completed`,4 步自主规划,质检 100 | 与无意图行为一致(意图只增不删) | ✅ |
真实输出摘录:
```text
# A(有意图)
▶ 运行 run_20260929T160446.234228623 终态:failed
错误: squad.intent_refused: 命中守卫意图 guard.config_change exit=1
# B(无意图,同文本)
▶ 运行 run_20260929T160446.236881964 终态:completed
步骤 4 个(完成 4/跳过 0/失败 0)。质检得分 100。 exit=0
# C(有意图)
▶ 运行 run_20260929T160453.123945617 终态:completed
步骤 1 个(完成 1/跳过 0/失败 0)。质检得分 100。 exit=0
# D(无意图,同文本)
▶ 运行 run_20260929T160453.126982316 终态:completed
步骤 4 个(完成 4/跳过 0/失败 0)。质检得分 100。 exit=0
```
## 四、Agent-Native 装饰器(adapters/intentgate,单测层)
后端为计数 stub;外挂意图与第一节同源(守卫 spec 等效定义):
| 用例 | 外挂意图 | 输入(Prompt) | 识别结果 | 期望 | 判定 |
|---|---|---|---|---|---|
| 零 spec 透传逐字节等价 | 无(零 spec 快照) | `帮我改一下系统网关地址` | fallback | Wrap 与裸后端 Result 深比较相等;Handler 零触发 | ✅ |
| 守卫拦截不经模型 | guard.config_change | `帮我改一下系统网关地址` | guard_hit | Handler 收到 GuardHit;返回拒绝合成结果;**后端零调用** | ✅ |
| fallback 透传 | 同上 | `写一份周报` | fallback | 透传,后端恰好 1 次调用 | ✅ |
| Handler 放弃回退 | 同上 | `帮我改一下系统网关地址` | guard_hit | 透传原后端 | ✅ |
| 组件为 nil 原样返回 | n/a | n/a | n/a | Wrap 返回原后端 | ✅ |
| 识别错误 fail-closed | MaxInputLen=8 | 超长输入 | sys.invalid_argument | 返回错误,后端零调用 | ✅ |
## 五、自动化测试与解耦证明汇总
| 验证项 | 位置 | 结果 |
|---|---|---|
| 既有全链路契约回归(等价证明) | Agent-Squad `conformance` | 通过(未改动即过) |
| 零配置等价(nil 闸门 / 零内容闸门 / 无闸门引擎三路对照) | Squad `squad` + `squadintent` | 通过 |
| 守卫拒绝稳定码 / 蓝图缺失 fail-closed / 闸门错误 fail-closed / TaskKey 旁路 | Squad `squad` 引擎级 | 通过 |
| 适配器→引擎→蓝图→执行→质检全链路(含 -race) | Squad `squadintent.TestEngineEndToEndWithAdapterGate` | 通过 |
| 结构断言:`go list -deps ./squad ./server ./store` 与 Native 核心 | 两仓 | agent-intent 0 命中 |
| submodule 一致性(Squad 8 项比对 / Native 4 项 / Agent-Intent 3 项) | 各仓脚本 | 全绿 |
| 全仓 `go vet` + `go test -count=1`(三仓)+ 关键包 `-race` | 三仓 | 全绿 |
## 六、结论与回退
- 结论:Squad(引擎级闸门)与 Native(Backend 装饰器)均以 T1 形态接入;未配置意图时行为与原实现一致(T0 已由测试与对照实验双重证明);守卫拒绝与配置失败均 fail-closed 且带稳定码。
- 回退:各仓删除对应适配器包 + 接线 + submodule(清单见各仓 docs/records 采纳记录),核心包零改动。
过程记录:Agent-Squad `docs/records/2026-09-29-intent-gate-adoption.md`;Agent-Native `docs/records/2026-09-29-intentgate-adoption.md`。
---
## 七、追加轮:目录驱动的编排(E2/E4 + G3–G5,组合 AI `3674dc4` ← N `292142a` ← S `5454f74`)
本轮外挂意图在第一节基础上新增 `demo.run.status_query`(必填槽 run_id:pattern `run-\d+`、label 运行编号、提问建议"要查询哪个运行?请提供 run 编号")。
| 用例 | 输入 / 场景 | 实际结果 | 期望 | 判定 |
|---|---|---|---|---|
| 槽位归一+校验链 | `查询任务 RUN-42 的进度` | slot run_id 归一为 `run-42`,通过自定义 validator | 抽取→归一→校验链生效 | ✅ |
| 槽位非法区分 | `查询任务 42 的进度` | `InvalidSlots[run_id]="validator: 须以 run- 开头"`,不进 Missing | "给错了"与"没给"分离 | ✅ |
| 枚举/格式校验 | `环境: dev 日期: 明天` | env 枚举不合法、date 格式不合法,均进 InvalidSlots | 确定性校验 | ✅ |
| 目录渲染 | intentctl catalog | 目录含能力/必填槽位/提问建议;守卫 spec 不出现 | 规划者可消费 | ✅ |
| LLM 规划器/leader 提示词 | buildPrompt/leaderPrompt | 目录与提问建议注入;未配置时不出现 | G5 提问模板落地 | ✅ |
| 计划确定性校验 | 缺目录/未知意图/缺必填槽/枚举违规 | 四类全部 fail-closed 拒绝计划(错误含澄清建议) | 防领导幻觉闭环 | ✅ |
| E2E 混合步 F | `做一次混合演示`(蓝图=系统功能步+agent 步,EchoRunner) | completed 2 步:sys1 物化系统功能调用(intent_id/handler/slot 落盘),write1 走 agent 后端 | 系统功能不经智能体 | ✅ |
| E2E fail-closed G | 同输入但未注册 handler | failed:`handler "taskrun.query_status" 未注册(fail-closed,不静默降级)` | 不静默降级 | ✅ |
三仓 vet/test/-race 全绿;既有 conformance 套件两轮均未改动即通过(等价回归持续成立);核心包 agent-intent 依赖 0 命中;一致性脚本全绿(Squad 8 项比对)。
+2
View File
@@ -7,6 +7,8 @@ require (
logging-system v0.0.0
)
require golang.org/x/text v0.29.0 // indirect
replace error-code-system => ./deps/error-code-system
replace logging-system => ./deps/logging-system
+2
View File
@@ -0,0 +1,2 @@
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
+62
View File
@@ -0,0 +1,62 @@
package intent
import (
"strings"
"unicode"
"unicode/utf8"
)
// boundedContains 词界匹配。规则分两边:
// - 右缘(必须):命中的右侧不得与词条末字符同类——汉字对汉字、词字符对词字符。
// 「配置」不命中「配置项」(右邻「项」为汉字)、「write」不命中「rewrites」。
// - 左缘(仅拉丁词条):汉字结尾的词条不要求左界——中文没有天然词界,
// 「改配置」「系统配置」里的「配置」仍是用户想说那个词;
// 拉丁词条按经典 \b 要求两侧(rewrites 两侧的 write 都不算命中)。
func boundedContains(haystack, term string) bool {
if term == "" {
return false
}
first, _ := utf8.DecodeRuneInString(term)
last, _ := utf8.DecodeLastRuneInString(term)
checkLeft := isWord(first) && !isHan(first) // 仅拉丁开头的词条要求左界
for i := strings.Index(haystack, term); i >= 0; {
end := i + len(term)
if (!checkLeft || leftBounded(haystack, i, first)) && rightBounded(haystack, end, last) {
return true
}
j := strings.Index(haystack[i+1:], term)
if j < 0 {
return false
}
i += 1 + j
}
return false
}
func leftBounded(s string, i int, termFirst rune) bool {
if i == 0 {
return true
}
prev, _ := utf8.DecodeLastRuneInString(s[:i])
return !sameClass(prev, termFirst)
}
func rightBounded(s string, end int, termLast rune) bool {
if end >= len(s) {
return true
}
next, _ := utf8.DecodeRuneInString(s[end:])
return !sameClass(next, termLast)
}
func sameClass(a, b rune) bool {
return (isHan(a) && isHan(b)) || (isWord(a) && isWord(b))
}
func isHan(r rune) bool {
return unicode.Is(unicode.Han, r)
}
func isWord(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_'
}
+115
View File
@@ -0,0 +1,115 @@
package intent
import (
"context"
"testing"
)
// TestBoundaryCJKFalsePositiveSuppressed:词界开启后,「配置」不命中「配置项」(I3)。
func TestBoundaryCJKFalsePositiveSuppressed(t *testing.T) {
mk := func(boundary bool) *Snapshot {
b := NewRegistryBuilder(Options{})
if err := b.Register(IntentSpec{
ID: "demo.configure", Domain: "demo", Version: 1,
Matcher: MatchSpec{IncludesAny: []string{"配置"}, Boundary: boundary},
}); err != nil {
t.Fatal(err)
}
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
return reg.Snapshot()
}
res, err := NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), "配置项在哪里", mk(true))
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchFallback {
t.Fatalf("boundary on: branch = %s, want fallback", res.Branch)
}
// 词界关闭(旧行为):子串命中依旧,证明差异来自 boundary。
res, err = NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), "配置项在哪里", mk(false))
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchMatched {
t.Fatalf("boundary off: branch = %s, want matched", res.Branch)
}
// 词界开启时,独立成词的「配置」照常命中。
res, err = NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), "我要改配置", mk(true))
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchMatched {
t.Fatalf("boundary on, standalone term: branch = %s, want matched", res.Branch)
}
}
// TestBoundaryLatinWordFlank:拉丁词界——write 不命中 rewrites。
func TestBoundaryLatinWordFlank(t *testing.T) {
b := NewRegistryBuilder(Options{})
if err := b.Register(IntentSpec{
ID: "demo.write", Domain: "demo", Version: 1,
Matcher: MatchSpec{IncludesAny: []string{"write"}, Boundary: true},
}); err != nil {
t.Fatal(err)
}
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
snap := reg.Snapshot()
res, err := NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), "rewrites everything", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchFallback {
t.Fatalf("branch = %s, want fallback", res.Branch)
}
res, err = NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), "please write this", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchMatched {
t.Fatalf("branch = %s, want matched", res.Branch)
}
}
// TestTieBreakByPriority:同分时高 Priority 胜出(I5);同分同 Priority 仍标歧。
func TestTieBreakByPriority(t *testing.T) {
mk := func(p1, p2 int) *Snapshot {
b := NewRegistryBuilder(Options{})
if err := b.Register(IntentSpec{ID: "demo.alpha", Domain: "demo", Version: 1, Priority: p1, Matcher: MatchSpec{IncludesAny: []string{"同分词"}}}); err != nil {
t.Fatal(err)
}
if err := b.Register(IntentSpec{ID: "demo.beta", Domain: "demo", Version: 1, Priority: p2, Matcher: MatchSpec{IncludesAny: []string{"同分词"}}}); err != nil {
t.Fatal(err)
}
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
return reg.Snapshot()
}
res, err := NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), "同分词", mk(10, 0))
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "demo.alpha" {
t.Fatalf("tie-break: branch = %s top = %+v", res.Branch, res.Top)
}
// 同分同 Priority:仍 ambiguous。
res, err = NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), "同分词", mk(5, 5))
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchAmbiguous {
t.Fatalf("same priority: branch = %s, want ambiguous", res.Branch)
}
// Explain 与裁决同规则。
tr := mk(10, 0).Explain("同分词")
if tr.Branch != BranchMatched || tr.TopID != "demo.alpha" {
t.Fatalf("explain = %+v", tr)
}
}
+146
View File
@@ -0,0 +1,146 @@
package intent
import (
"sort"
"fmt"
"strings"
)
// CatalogSlot 目录中的槽位视图:给规划者/领导 Agent 看的声明式信息
// (有哪些能力、缺什么信息、建议怎么问),不暴露匹配实现细节。
type CatalogSlot struct {
Key string `json:"key"`
Required bool `json:"required,omitempty"`
Label string `json:"label,omitempty"`
Question string `json:"question,omitempty"`
Enum []string `json:"enum,omitempty"`
Format string `json:"format,omitempty"`
}
// CatalogSpec 目录中的单条能力。
type CatalogSpec struct {
ID string `json:"id"`
Domain string `json:"domain,omitempty"`
Version int `json:"version,omitempty"`
Description string `json:"description,omitempty"`
Risk string `json:"risk,omitempty"`
RequiresConfirm bool `json:"requires_confirm,omitempty"`
Handler string `json:"handler,omitempty"`
Slots []CatalogSlot `json:"slots,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
// Catalog 返回能力目录:全部启用的非守卫 spec,按合并序(确定性)。
// 守卫域 spec 是拦截器而非可调用能力,不进目录;需要全量时用 SpecIDs/SpecByID。
// 返回值为深拷贝:调用方改切片/枚举/metadata 不会改到快照。
func (s *Snapshot) Catalog() []CatalogSpec {
out := make([]CatalogSpec, 0, len(s.specs))
for _, cs := range s.specs {
if cs.guard {
continue
}
view := CatalogSpec{
ID: cs.spec.ID,
Domain: cs.spec.Domain,
Version: cs.spec.Version,
Description: cs.spec.Description,
Risk: cs.spec.Risk,
RequiresConfirm: cs.spec.RequiresConfirm,
Handler: cs.spec.Handler,
Metadata: catalogMetadata(cs.spec.Metadata),
}
if view.Risk == "" {
view.Risk = RiskLow
}
for _, sl := range cs.slots {
view.Slots = append(view.Slots, CatalogSlot{
Key: sl.key, Required: sl.required, Label: sl.label, Question: sl.question,
Enum: cloneStrings(sl.enum), Format: specSlotFormat(cs.spec, sl.key),
})
}
out = append(out, view)
}
return out
}
// CatalogMarkdown 渲染注入规划者/领导提示词用的目录文本(确定性)。
// 文案面向 LLM:说明可直接调用、必填槽位与提问建议。
func (s *Snapshot) CatalogMarkdown() string {
var b strings.Builder
fmt.Fprintf(&b, "# 系统能力目录(意图快照 revision=%d)\n\n", s.rev)
b.WriteString("以下能力可直接调用,无需派智能体执行。子任务命中其一时,输出携带 intent_id 的步骤并填齐必填槽位;\n")
b.WriteString("必填槽位信息不足时不要编造,改为向用户澄清(优先使用各槽位的提问建议)。\n\n")
specs := s.Catalog()
if len(specs) == 0 {
b.WriteString("(目录为空:当前没有可直接调用的系统能力)\n")
return b.String()
}
for _, sp := range specs {
desc := sp.Description
if desc == "" {
desc = sp.ID
}
fmt.Fprintf(&b, "## %s — %s\n", sp.ID, desc)
confirm := "否"
if sp.RequiresConfirm {
confirm = "是"
}
fmt.Fprintf(&b, "- 风险:%s;执行前需人工确认:%s\n", sp.Risk, confirm)
for _, sl := range sp.Slots {
req := "选填"
if sl.Required {
req = "必填"
}
label := sl.Label
if label == "" {
label = sl.Key
}
fmt.Fprintf(&b, "- 槽位 %s(%s,%s)", sl.Key, req, label)
if len(sl.Enum) > 0 {
fmt.Fprintf(&b, ";取值:%s", strings.Join(sl.Enum, " | "))
}
if sl.Format != "" {
fmt.Fprintf(&b, ";格式:%s", sl.Format)
}
b.WriteString("\n")
if sl.Question != "" {
fmt.Fprintf(&b, " - 提问建议:%s\n", sl.Question)
}
}
b.WriteString("\n")
}
return b.String()
}
// specSlotFormat 从原始 spec 取槽位的 Format 声明(编译产物不保留字符串正则源)。
func specSlotFormat(spec IntentSpec, key string) string {
for _, sl := range spec.Slots {
if sl.Key == key {
return sl.Format
}
}
return ""
}
// CatalogTopK 取目录的前 k 条:按 Priority 降序、ID 升序(确定性排序;
// 排序口径是 priority,不是语义相关度——相关度排序由宿主经 examples 自行计算)。
// k <= 0 时等同 Catalog() 全量。返回值与 Catalog 一样是深拷贝,不共享底层数组。
func (s *Snapshot) CatalogTopK(k int) []CatalogSpec {
all := s.Catalog()
if k <= 0 || len(all) <= k {
return all
}
// Priority 不在目录视图里;用 spec 表内的 priority 排序。
prio := map[string]int{}
for _, cs := range s.specs {
prio[cs.spec.ID] = cs.spec.Priority
}
sort.SliceStable(all, func(i, j int) bool {
if prio[all[i].ID] != prio[all[j].ID] {
return prio[all[i].ID] > prio[all[j].ID]
}
return all[i].ID < all[j].ID
})
return cloneCatalogSpecs(all[:k])
}
+4 -2
View File
@@ -7,7 +7,9 @@ import "context"
// 返回错误时识别保持 Ambiguous,不会使 Recognize 失败(识别确定性不受实现影响)。
// 宿主应保证 Refine 幂等且无副作用:同样的输入与候选应得到同样的重排结果。
type Classifier interface {
// Refine 对候选重打分/重排。返回的切片应包含与输入相同的一组候选
// (可更新 Confidence 与顺序);快照信息已随候选 ID 携带。
// Refine 对候选重打分/重排。返回的 ID 集合必须与输入完全相同且不能重复;
// Confidence 必须是有限值并落在 [0,1],越界会被拒绝而不是截断。
// 除 Confidence 以外的字段会被按 ID 从原候选重建,改写不生效。
// 校验失败时识别保持 Ambiguous。
Refine(ctx context.Context, input string, candidates []Candidate) ([]Candidate, error)
}
+156
View File
@@ -0,0 +1,156 @@
package intent
import (
"sort"
"strings"
)
// CommandKind 是一条路由指令的类型。
type CommandKind string
const (
// CommandStart 启动一个动作(流程/命令/蓝图/处理器),携带已抽取槽位。
CommandStart CommandKind = "start"
// CommandSetSlot 记录一个槽位值(多轮澄清/预期回答的落槽)。
CommandSetSlot CommandKind = "set_slot"
// CommandChangeSlot 改写一个已有槽位值。
CommandChangeSlot CommandKind = "change_slot"
// CommandCancel 取消某个进行中的动作(否定语义的命令层表达)。
CommandCancel CommandKind = "cancel"
// CommandClarify 向用户澄清:缺槽位或候选消歧。
CommandClarify CommandKind = "clarify"
// CommandChitchat 闲聊(不由规则漏斗产出;留给语义层与宿主映射)。
CommandChitchat CommandKind = "chitchat"
// CommandHandoff 转人工(不由规则漏斗产出;留给语义层与宿主映射)。
CommandHandoff CommandKind = "handoff"
)
// ActionRef 指向一个可执行动作。auto 表示由宿主在命令总线与工作流之间
// 裁决(与现网「意图 ID 同时是命令 ID 与 workflow IntentID」的现状兼容);
// handler 与 blueprint 来自 spec 字段的显式声明;command/workflow 预留给
// spec.action 显式声明(WP2)。
type ActionRef struct {
Kind string `json:"kind"`
ID string `json:"id"`
}
const (
ActionAuto = "auto"
ActionHandler = "handler"
ActionBlueprint = "blueprint"
ActionCommand = "command"
ActionWorkflow = "workflow"
)
// Command 是一条对宿主路由层的指令。一次识别可产出多条命令
// (如先 SetSlot 后 Start),宿主按序消费。
type Command struct {
Kind CommandKind `json:"kind"`
Action ActionRef `json:"action,omitempty"`
Slots map[string]string `json:"slots,omitempty"`
Name string `json:"name,omitempty"`
Value string `json:"value,omitempty"`
Missing []string `json:"missing,omitempty"`
Question string `json:"question,omitempty"`
Candidates []string `json:"candidates,omitempty"`
Scope string `json:"scope,omitempty"`
Target string `json:"target,omitempty"`
}
// actionOf 从候选推导动作引用:spec.action 显式声明优先,
// 其后是 blueprint 键、Handler,都没有时回落 auto(宿主裁决)。
func actionOf(c Candidate) ActionRef {
if c.Action != nil {
return *c.Action
}
if bp := strings.TrimSpace(c.Metadata["blueprint"]); bp != "" {
return ActionRef{Kind: ActionBlueprint, ID: bp}
}
if h := strings.TrimSpace(c.Handler); h != "" {
return ActionRef{Kind: ActionHandler, ID: h}
}
return ActionRef{Kind: ActionAuto, ID: c.ID}
}
// deriveCommands 把识别结果展开为命令列表(纯函数、确定性)。
// 规则:
// - guard_hit:不产出命令(守卫语义是「识别即拒绝」,由宿主处理;
// Cancel 命令留给声明 action 的意图与语义层,不在规则漏斗内代为表达)。
// - expectation_hit:每个已填槽位一条 SetSlot;槽位齐全且合法追加 Start,
// 否则追加 Clarify(缺槽/非法)。
// - matched:槽位齐全且合法 → 单条 Start;有缺失/非法 → Start(携带已抽槽,
// 宿主可带槽启动并追问,与现网行为一致)+ Clarify。
// - ambiguous:单条 Clarify(候选消歧,按现有候选序)。
// - fallback:不产出命令(交给宿主默认通道;Chitchat/Handoff 不由规则漏斗产出)。
func deriveCommands(res Result, snap *Snapshot) []Command {
if res.Top == nil {
if res.Branch == BranchAmbiguous && len(res.Candidates) > 1 {
ids := make([]string, 0, len(res.Candidates))
for _, c := range res.Candidates {
ids = append(ids, c.ID)
}
return []Command{{Kind: CommandClarify, Candidates: ids}}
}
return nil
}
top := res.Top
complete := len(top.MissingRequiredSlots) == 0 && len(top.InvalidSlots) == 0
switch res.Branch {
case BranchExpectationHit:
var cmds []Command
for _, k := range sortedKeys(top.Slots) {
cmds = append(cmds, Command{Kind: CommandSetSlot, Name: k, Value: top.Slots[k]})
}
if complete {
cmds = append(cmds, Command{Kind: CommandStart, Action: actionOf(*top), Slots: top.Slots})
} else {
cmds = append(cmds, clarifyOf(*top, snap))
}
return cmds
case BranchMatched:
start := Command{Kind: CommandStart, Action: actionOf(*top), Slots: top.Slots}
if complete {
return []Command{start}
}
return []Command{start, clarifyOf(*top, snap)}
}
return nil
}
// clarifyOf 组装澄清命令:Missing 为必填缺失与非法槽位的合并(确定性排序);
// Question 取第一个有 Question 配置的缺失/非法槽位的建议提问(查得到 spec 时)。
func clarifyOf(top Candidate, snap *Snapshot) Command {
invalid := sortedKeys(top.InvalidSlots)
missing := append(append([]string(nil), top.MissingRequiredSlots...), invalid...)
var question string
if snap != nil {
if spec, ok := snap.SpecByID(top.ID); ok {
asked := map[string]bool{}
for _, k := range missing {
if asked[k] {
continue
}
asked[k] = true
for _, s := range spec.Slots {
if s.Key == k && strings.TrimSpace(s.Question) != "" {
question = s.Question
break
}
}
if question != "" {
break
}
}
}
}
return Command{Kind: CommandClarify, Missing: missing, Question: question}
}
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
+205
View File
@@ -0,0 +1,205 @@
package intent
import (
"context"
"testing"
)
// buildCommandSnap 组装命令测试用快照:一个带必填槽位的意图与一个无槽位意图。
func buildCommandSnap(t *testing.T) *Snapshot {
t.Helper()
b := NewRegistryBuilder(Options{})
if err := b.Register(IntentSpec{
ID: "report.generate", Domain: "report", Version: 1,
Matcher: MatchSpec{IncludesAny: []string{"生成报告"}},
Slots: []SlotSpec{
{Key: "topic", Required: true, Pattern: `主题是(.+)`, Question: "要查询哪个主题?"},
},
Handler: "reportHandler",
}); err != nil {
t.Fatal(err)
}
if err := b.Register(IntentSpec{
ID: "report.chat", Domain: "report", Version: 1,
Matcher: MatchSpec{IncludesAny: []string{"闲聊一下"}},
}); err != nil {
t.Fatal(err)
}
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
return reg.Snapshot()
}
func recognize(t *testing.T, snap *Snapshot, input string) Result {
t.Helper()
res, err := NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), input, snap)
if err != nil {
t.Fatal(err)
}
return res
}
// TestCommandsMatchedFullSlots:槽位齐全 → 单条 Start,ActionRef 取 Handler。
func TestCommandsMatchedFullSlots(t *testing.T) {
snap := buildCommandSnap(t)
res := recognize(t, snap, "帮我生成报告,主题是电机")
if res.Branch != BranchMatched {
t.Fatalf("branch = %s", res.Branch)
}
if len(res.Commands) != 1 {
t.Fatalf("commands = %+v", res.Commands)
}
cmd := res.Commands[0]
if cmd.Kind != CommandStart || cmd.Action.Kind != ActionHandler || cmd.Action.ID != "reportHandler" {
t.Fatalf("start = %+v", cmd)
}
if cmd.Slots["topic"] != "电机" {
t.Fatalf("slots = %v", cmd.Slots)
}
}
// TestCommandsMatchedMissingSlot:必填缺失 → Start(带已抽槽)+ Clarify(含建议提问)。
func TestCommandsMatchedMissingSlot(t *testing.T) {
snap := buildCommandSnap(t)
res := recognize(t, snap, "帮我生成报告")
if len(res.Commands) != 2 {
t.Fatalf("commands = %+v", res.Commands)
}
start, clarify := res.Commands[0], res.Commands[1]
if start.Kind != CommandStart {
t.Fatalf("first = %+v", start)
}
if clarify.Kind != CommandClarify || len(clarify.Missing) != 1 || clarify.Missing[0] != "topic" {
t.Fatalf("clarify = %+v", clarify)
}
if clarify.Question != "要查询哪个主题?" {
t.Fatalf("question = %q", clarify.Question)
}
}
// TestCommandsExpectationSetSlotPlusStart:预期命中 → SetSlot + Start(验收形态)。
func TestCommandsExpectationSetSlotPlusStart(t *testing.T) {
snap := buildCommandSnap(t)
exp := listExpectation{ids: []string{"report.generate"}}
res, err := NewRecognizer(Options{}, exp, nil).Recognize(context.Background(), "生成报告,主题是电机", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchExpectationHit {
t.Fatalf("branch = %s", res.Branch)
}
var setSlot, start *Command
for i := range res.Commands {
switch res.Commands[i].Kind {
case CommandSetSlot:
setSlot = &res.Commands[i]
case CommandStart:
start = &res.Commands[i]
}
}
if setSlot == nil || start == nil {
t.Fatalf("commands = %+v", res.Commands)
}
if setSlot.Name != "topic" || setSlot.Value != "电机" {
t.Fatalf("set_slot = %+v", *setSlot)
}
if start.Action.Kind != ActionHandler || start.Slots["topic"] != "电机" {
t.Fatalf("start = %+v", *start)
}
}
// TestCommandsAmbiguousClarify:标歧 → 单条 Clarify 携带候选 ID。
func TestCommandsAmbiguousClarify(t *testing.T) {
b := NewRegistryBuilder(Options{})
if err := b.Register(IntentSpec{ID: "demo.alpha", Domain: "demo", Version: 1, Matcher: MatchSpec{IncludesAny: []string{"模糊词"}}}); err != nil {
t.Fatal(err)
}
if err := b.Register(IntentSpec{ID: "demo.beta", Domain: "demo", Version: 1, Matcher: MatchSpec{IncludesAny: []string{"模糊词"}}}); err != nil {
t.Fatal(err)
}
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
res := recognize(t, reg.Snapshot(), "模糊词")
if res.Branch != BranchAmbiguous {
t.Fatalf("branch = %s", res.Branch)
}
if len(res.Commands) != 1 || res.Commands[0].Kind != CommandClarify {
t.Fatalf("commands = %+v", res.Commands)
}
if len(res.Commands[0].Candidates) != 2 {
t.Fatalf("candidates = %v", res.Commands[0].Candidates)
}
}
// TestCommandsFallbackEmpty:fallback 不产出命令。
func TestCommandsFallbackEmpty(t *testing.T) {
snap := buildCommandSnap(t)
res := recognize(t, snap, "完全不相关的一句话")
if res.Branch != BranchFallback || len(res.Commands) != 0 {
t.Fatalf("fallback commands = %+v", res.Commands)
}
}
// TestActionRefDerivation:blueprint 键优先于 Handler;都没有回落 auto。
func TestActionRefDerivation(t *testing.T) {
if got := actionOf(Candidate{ID: "x", Metadata: map[string]string{"blueprint": "bp-1"}, Handler: "h"}); got.Kind != ActionBlueprint || got.ID != "bp-1" {
t.Fatalf("blueprint = %+v", got)
}
if got := actionOf(Candidate{ID: "x", Handler: "h"}); got.Kind != ActionHandler || got.ID != "h" {
t.Fatalf("handler = %+v", got)
}
if got := actionOf(Candidate{ID: "x"}); got.Kind != ActionAuto || got.ID != "x" {
t.Fatalf("auto = %+v", got)
}
}
type listExpectation struct{ ids []string }
func (s listExpectation) Pending(ctx context.Context) *PendingExpectation {
return &PendingExpectation{IntentIDs: s.ids}
}
// TestSpecActionDeclaredWins:spec.action 显式声明在 ActionRef 推导中胜出(WP2)。
func TestSpecActionDeclaredWins(t *testing.T) {
b := NewRegistryBuilder(Options{})
if err := b.Register(IntentSpec{
ID: "report.explicit", Domain: "report", Version: 1,
Matcher: MatchSpec{IncludesAny: []string{"显式动作"}},
Action: &ActionRef{Kind: ActionWorkflow, ID: "wf-report"},
}); err != nil {
t.Fatal(err)
}
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
res := recognize(t, reg.Snapshot(), "显式动作")
if len(res.Commands) != 1 || res.Commands[0].Kind != CommandStart {
t.Fatalf("commands = %+v", res.Commands)
}
if got := res.Commands[0].Action; got.Kind != ActionWorkflow || got.ID != "wf-report" {
t.Fatalf("action = %+v", got)
}
}
// TestSpecActionValidation:action.kind 非法与 id 为空都被拒绝。
func TestSpecActionValidation(t *testing.T) {
bad := IntentSpec{
ID: "report.bad", Domain: "report", Version: 1,
Matcher: MatchSpec{IncludesAny: []string{"x"}},
Action: &ActionRef{Kind: "bogus", ID: "y"},
}
b := NewRegistryBuilder(Options{})
if err := b.Register(bad); err == nil {
t.Fatal("bogus action kind accepted")
}
bad.Action = &ActionRef{Kind: ActionCommand, ID: ""}
b2 := NewRegistryBuilder(Options{})
if err := b2.Register(bad); err == nil {
t.Fatal("empty action id accepted")
}
}
+252
View File
@@ -0,0 +1,252 @@
package intent
import (
"context"
"fmt"
)
// CorpusCase 是评测用例:v1 单轮(input/expect_*)与 v2 多轮(turns)共用。
// v1 字段在 Turns 为空时生效,并自动等价于一个单 turn 用例(不含命令断言)。
type CorpusCase struct {
Name string `json:"name"`
Input string `json:"input,omitempty"`
ExpectBranch string `json:"expect_branch,omitempty"`
ExpectTop string `json:"expect_top,omitempty"`
ExpectSlots map[string]string `json:"expect_slots,omitempty"`
ExpectMissingSlots []string `json:"expect_missing_slots,omitempty"`
Turns []CorpusTurn `json:"turns,omitempty"`
}
// CorpusTurn 是多轮用例中的一轮:输入与逐轮期望(分支/top/命令列表)。
type CorpusTurn struct {
Input string `json:"input"`
ExpectBranch string `json:"expect_branch,omitempty"`
ExpectTop string `json:"expect_top,omitempty"`
ExpectCommands []CommandExpect `json:"expect_commands,omitempty"`
}
// CommandExpect 是对一条命令的期望;只有写出的字段参与断言。
type CommandExpect struct {
Kind string `json:"kind,omitempty"`
ActionKind string `json:"action_kind,omitempty"`
ActionID string `json:"action_id,omitempty"`
Slots map[string]string `json:"slots,omitempty"`
Name string `json:"name,omitempty"`
Value string `json:"value,omitempty"`
Missing []string `json:"missing,omitempty"`
Question string `json:"question,omitempty"`
Candidates []string `json:"candidates,omitempty"`
}
// TurnResult 一轮的实际结果与差异。
type TurnResult struct {
Index int
Input string
Branch string
TopID string
Commands []Command
Mismatches []string
}
// CaseResult 一个用例的全部轮次结果。Passed = 所有轮次无差异。
type CaseResult struct {
Name string
Turns []TurnResult
}
// Passed 报告用例是否全部轮次通过。
func (c CaseResult) Passed() bool {
for _, t := range c.Turns {
if len(t.Mismatches) > 0 {
return false
}
}
return true
}
// chainedExpectation 模拟宿主的挂起预期链:上一轮 Top 有必填缺失或非法
// 槽位时,下一轮挂起该意图(与产品「带槽启动后追问」的行为对应)。
type chainedExpectation struct {
next *PendingExpectation
}
func (c *chainedExpectation) Pending(ctx context.Context) *PendingExpectation {
return c.next
}
// RunCorpusCase 执行一个用例的全部轮次(v1 用例自动包装为单轮)。
// 识别器按轮次构建在链式预期上,保证 SetSlot+Start 这类组合可在评测中到达。
func RunCorpusCase(ctx context.Context, snap *Snapshot, tc CorpusCase) CaseResult {
turns := tc.Turns
if len(turns) == 0 {
turns = []CorpusTurn{{Input: tc.Input, ExpectBranch: tc.ExpectBranch, ExpectTop: tc.ExpectTop}}
}
exp := &chainedExpectation{}
rec := NewRecognizer(Options{}, exp, nil)
out := CaseResult{Name: tc.Name}
for i, turn := range turns {
tr := TurnResult{Index: i, Input: turn.Input}
res, err := rec.Recognize(ctx, turn.Input, snap)
if err != nil {
tr.Mismatches = append(tr.Mismatches, "识别错误:"+err.Error())
out.Turns = append(out.Turns, tr)
break
}
tr.Branch = string(res.Branch)
if res.Top != nil {
tr.TopID = res.Top.ID
}
tr.Commands = res.Commands
tr.Mismatches = matchTurn(turn, res)
out.Turns = append(out.Turns, tr)
exp.next = nil
if res.Top != nil && (len(res.Top.MissingRequiredSlots) > 0 || len(res.Top.InvalidSlots) > 0) {
exp.next = &PendingExpectation{IntentIDs: []string{res.Top.ID}, Reason: "eval chained"}
}
}
return out
}
func matchTurn(turn CorpusTurn, res Result) []string {
var mm []string
if turn.ExpectBranch != "" && string(res.Branch) != turn.ExpectBranch {
mm = append(mm, fmt.Sprintf("branch=%s(期望 %s)", res.Branch, turn.ExpectBranch))
}
if turn.ExpectTop != "" {
got := ""
if res.Top != nil {
got = res.Top.ID
}
if got != turn.ExpectTop {
mm = append(mm, fmt.Sprintf("top=%s(期望 %s)", got, turn.ExpectTop))
}
}
if turn.ExpectCommands != nil {
mm = append(mm, matchCommands(turn.ExpectCommands, res.Commands)...)
}
return mm
}
func matchCommands(want []CommandExpect, got []Command) []string {
if len(want) != len(got) {
return []string{fmt.Sprintf("commands 数量=%d(期望 %d)", len(got), len(want))}
}
var mm []string
for i, w := range want {
g := got[i]
if w.Kind != "" && string(g.Kind) != w.Kind {
mm = append(mm, fmt.Sprintf("cmd[%d].kind=%s(期望 %s)", i, g.Kind, w.Kind))
continue
}
if w.ActionKind != "" && g.Action.Kind != w.ActionKind {
mm = append(mm, fmt.Sprintf("cmd[%d].action.kind=%s(期望 %s)", i, g.Action.Kind, w.ActionKind))
}
if w.ActionID != "" && g.Action.ID != w.ActionID {
mm = append(mm, fmt.Sprintf("cmd[%d].action.id=%s(期望 %s)", i, g.Action.ID, w.ActionID))
}
for k, v := range w.Slots {
if g.Slots[k] != v {
mm = append(mm, fmt.Sprintf("cmd[%d].slots[%s]=%q(期望 %q)", i, k, g.Slots[k], v))
}
}
if w.Name != "" && g.Name != w.Name {
mm = append(mm, fmt.Sprintf("cmd[%d].name=%s(期望 %s)", i, g.Name, w.Name))
}
if w.Value != "" && g.Value != w.Value {
mm = append(mm, fmt.Sprintf("cmd[%d].value=%s(期望 %s)", i, g.Value, w.Value))
}
for _, m := range w.Missing {
if !containsString(g.Missing, m) {
mm = append(mm, fmt.Sprintf("cmd[%d].missing 缺 %s", i, m))
}
}
if w.Question != "" && g.Question != w.Question {
mm = append(mm, fmt.Sprintf("cmd[%d].question=%q(期望 %q)", i, g.Question, w.Question))
}
for _, c := range w.Candidates {
if !containsString(g.Candidates, c) {
mm = append(mm, fmt.Sprintf("cmd[%d].candidates 缺 %s", i, c))
}
}
}
return mm
}
func containsString(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
// CorpusMetrics 评测指标(I2):按意图标签(matched/top)计 TP/FP/FN。
// 期望非 matched(fallback/ambiguous)的轮次作负例、不进指标。
type CorpusMetrics struct {
TP, FP, FN int
}
// Precision 精确率;分母为 0 时返回 (0, false)(未定义,由调用方决定展示)。
func (m CorpusMetrics) Precision() (float64, bool) {
if m.TP+m.FP == 0 {
return 0, false
}
return float64(m.TP) / float64(m.TP+m.FP), true
}
// Recall 召回率;分母为 0 时返回 (0, false)。
func (m CorpusMetrics) Recall() (float64, bool) {
if m.TP+m.FN == 0 {
return 0, false
}
return float64(m.TP) / float64(m.TP+m.FN), true
}
// MetricsOf 逐轮累计指标(I2):
// - TP:期望 matched/top=X 且实际 matched/top=X;
// - FP:实际 matched/top=Y 但期望的标签不是 matched/top=Y
// (期望别的 top、期望 fallback/ambiguous、或期望 matched 但 top 不同);
// - FN:期望 matched/top=X 但实际没拿到它(branch 不对或 top 不同)。
// 只有写了 expect_top 的轮次进入指标(意图识别通行口径)。
func MetricsOf(cases []CorpusCase, results []CaseResult) CorpusMetrics {
var m CorpusMetrics
byName := map[string]CorpusCase{}
for _, tc := range cases {
byName[tc.Name] = tc
}
for _, res := range results {
tc, ok := byName[res.Name]
if !ok {
continue
}
turns := tc.Turns
if len(turns) == 0 {
turns = []CorpusTurn{{ExpectBranch: tc.ExpectBranch, ExpectTop: tc.ExpectTop}}
}
for i, turn := range turns {
if turn.ExpectTop == "" || i >= len(res.Turns) {
continue
}
got := res.Turns[i]
wantMatched := turn.ExpectBranch == "" || turn.ExpectBranch == "matched" || turn.ExpectBranch == "expectation_hit"
gotMatched := got.Branch == "matched" || got.Branch == "expectation_hit"
if gotMatched && got.TopID == turn.ExpectTop && wantMatched {
m.TP++
continue
}
if gotMatched && got.TopID != "" {
// 错配同时是假阳(拿了错的)与假阴(期望的没拿到)。
m.FP++
if wantMatched {
m.FN++
}
continue
}
if wantMatched {
m.FN++
}
}
}
return m
}
+127
View File
@@ -0,0 +1,127 @@
package intent
import (
"context"
"encoding/json"
"os"
"strings"
"testing"
)
// TestEvalMultiTurnCorpus 是 I1 验收载体:v2 多轮语料含「SetSlot 加 Start」
// 用例并通过(链式预期把第二轮转进 expectation_hit)。
func TestEvalMultiTurnCorpus(t *testing.T) {
data, err := os.ReadFile("testdata/corpus_multiturn.json")
if err != nil {
t.Fatal(err)
}
var doc struct {
Cases []CorpusCase `json:"cases"`
}
if err := json.Unmarshal(data, &doc); err != nil {
t.Fatal(err)
}
if len(doc.Cases) == 0 {
t.Fatal("empty corpus")
}
_, snap := buildDemoRegistry(t)
for _, tc := range doc.Cases {
result := RunCorpusCase(context.Background(), snap, tc)
if !result.Passed() {
for _, tr := range result.Turns {
for _, mm := range tr.Mismatches {
t.Errorf("case %s turn %d (%q): %s", tc.Name, tr.Index, tr.Input, mm)
}
}
}
}
}
// TestCorpusV1WrapsAsSingleTurn:v1 单轮形态经同一执行器等价运行(兼容承诺)。
func TestCorpusV1WrapsAsSingleTurn(t *testing.T) {
_, snap := buildDemoRegistry(t)
tc := CorpusCase{Name: "v1", Input: "你好呀", ExpectBranch: "matched", ExpectTop: "demo.greet.hello"}
result := RunCorpusCase(context.Background(), snap, tc)
if !result.Passed() {
t.Fatalf("v1 wrap: %+v", result.Turns)
}
if len(result.Turns) != 1 || result.Turns[0].Branch != "matched" {
t.Fatalf("turns = %+v", result.Turns)
}
}
// TestMatchCommandsFindsMismatches:命令断言的差异定位(kind/action/slot/name)。
func TestMatchCommandsFindsMismatches(t *testing.T) {
got := []Command{
{Kind: CommandStart, Action: ActionRef{Kind: ActionHandler, ID: "h"}, Slots: map[string]string{"topic": "电机"}},
}
mm := matchCommands([]CommandExpect{
{Kind: "start", ActionKind: ActionHandler, ActionID: "other"},
}, got)
if len(mm) != 1 || !strings.Contains(mm[0], "action.id") {
t.Fatalf("mm = %v", mm)
}
mm = matchCommands([]CommandExpect{
{Kind: "start", Slots: map[string]string{"topic": "水泵"}},
}, got)
if len(mm) != 1 || !strings.Contains(mm[0], "slots") {
t.Fatalf("mm = %v", mm)
}
mm = matchCommands([]CommandExpect{
{Kind: "start", ActionKind: ActionHandler, ActionID: "h", Slots: map[string]string{"topic": "电机"}},
}, got)
if len(mm) != 0 {
t.Fatalf("mm = %v", mm)
}
if mm := matchCommands([]CommandExpect{{Kind: "start"}, {Kind: "clarify"}}, got); len(mm) != 1 {
t.Fatalf("count mismatch = %v", mm)
}
}
// TestMetricsOfCounts:指标按意图标签计 TP/FP/FN(I2 验收的指标形态)。
func TestMetricsOfCounts(t *testing.T) {
_, snap := buildDemoRegistry(t)
data, err := os.ReadFile("testdata/corpus_multiturn.json")
if err != nil {
t.Fatal(err)
}
var doc struct {
Cases []CorpusCase `json:"cases"`
}
if err := json.Unmarshal(data, &doc); err != nil {
t.Fatal(err)
}
var results []CaseResult
for _, tc := range doc.Cases {
results = append(results, RunCorpusCase(context.Background(), snap, tc))
}
m := MetricsOf(doc.Cases, results)
// 通过的夹具:指标应当全 TP(precision=recall=1)。
if m.FP != 0 || m.FN != 0 {
t.Fatalf("metrics = %+v", m)
}
p, pok := m.Precision()
r, rok := m.Recall()
if !pok || !rok || p != 1.0 || r != 1.0 {
t.Fatalf("precision=%v recall=%v (m=%+v)", p, r, m)
}
if m.TP < 8 {
t.Fatalf("TP = %d, want >= 8(夹具内带 top 期望的轮次)", m.TP)
}
// 手工构造一个 FP+FN 用例验证计数方向。
bad := []CorpusCase{{Name: "bad", Turns: []CorpusTurn{
{Input: "查询任务 42 的进度", ExpectBranch: "matched", ExpectTop: "demo.greet.hello"},
}}}
got := []CaseResult{RunCorpusCase(context.Background(), snap, bad[0])}
m2 := MetricsOf(bad, got)
// 实际 matched status_query 但期望 greet.hello → 1 FP + 1 FN。
if m2.TP != 0 || m2.FP != 1 || m2.FN != 1 {
t.Fatalf("bad metrics = %+v", m2)
}
p2, _ := m2.Precision()
r2, _ := m2.Recall()
if p2 != 0 || r2 != 0 {
t.Fatalf("p=%v r=%v, want 0/0", p2, r2)
}
}
+161
View File
@@ -0,0 +1,161 @@
package intent
import (
"strings"
"golang.org/x/text/unicode/norm"
)
// foldMatchText 做识别用折叠。nfkc 只做兼容等价(全角 ASCII、组合字符),
// 不做繁简转换。caseSensitive 为真时保留大小写。
func foldMatchText(s string, nfkc, caseSensitive bool) string {
if nfkc {
s = norm.NFKC.String(s)
}
if !caseSensitive {
s = strings.ToLower(s)
}
return s
}
// foldInput 返回用于匹配的文本:第一段保留大小写(可选 NFKC),第二段是其小写。
func (s *Snapshot) foldInput(input string) (matchInput, folded string) {
if s == nil {
return input, strings.ToLower(input)
}
matchInput = foldMatchText(input, s.nfkc, true)
return matchInput, strings.ToLower(matchInput)
}
func cloneStrings(in []string) []string {
if in == nil {
return nil
}
return append([]string(nil), in...)
}
func cloneStringMap(in map[string]string) map[string]string {
if in == nil {
return nil
}
out := make(map[string]string, len(in))
for k, v := range in {
out[k] = v
}
return out
}
func cloneActionRef(in *ActionRef) *ActionRef {
if in == nil {
return nil
}
cp := *in
return &cp
}
// cloneIntentSpec 深拷贝 IntentSpec,切断切片/map/指针与调用方或快照内部的共享。
func cloneIntentSpec(in IntentSpec) IntentSpec {
out := in
out.Matcher.IncludesAny = cloneStrings(in.Matcher.IncludesAny)
out.Matcher.IncludesAll = cloneStrings(in.Matcher.IncludesAll)
out.Matcher.ExcludesAny = cloneStrings(in.Matcher.ExcludesAny)
out.Matcher.Patterns = cloneStrings(in.Matcher.Patterns)
if in.Slots != nil {
out.Slots = append([]SlotSpec(nil), in.Slots...)
for i := range out.Slots {
out.Slots[i].Enum = cloneStrings(in.Slots[i].Enum)
out.Slots[i].Normalize = cloneStrings(in.Slots[i].Normalize)
}
}
out.Metadata = cloneStringMap(in.Metadata)
out.Action = cloneActionRef(in.Action)
out.Examples = cloneStrings(in.Examples)
return out
}
func cloneCatalogSlot(in CatalogSlot) CatalogSlot {
out := in
out.Enum = cloneStrings(in.Enum)
return out
}
func cloneCatalogSpec(in CatalogSpec) CatalogSpec {
out := in
out.Metadata = cloneStringMap(in.Metadata)
if in.Slots != nil {
out.Slots = make([]CatalogSlot, len(in.Slots))
for i := range in.Slots {
out.Slots[i] = cloneCatalogSlot(in.Slots[i])
}
}
return out
}
func cloneCatalogSpecs(in []CatalogSpec) []CatalogSpec {
if in == nil {
return nil
}
out := make([]CatalogSpec, len(in))
for i := range in {
out[i] = cloneCatalogSpec(in[i])
}
return out
}
func cloneOptions(in Options) Options {
out := in
out.ProtectedDomains = cloneStrings(in.ProtectedDomains)
out.GuardDomains = cloneStrings(in.GuardDomains)
if in.SlotValidators != nil {
cp := make(map[string]SlotValidator, len(in.SlotValidators))
for k, v := range in.SlotValidators {
cp[k] = v
}
out.SlotValidators = cp
}
return out
}
func cloneCandidate(in Candidate) Candidate {
out := in
out.Metadata = cloneStringMap(in.Metadata)
out.Slots = cloneStringMap(in.Slots)
out.MissingRequiredSlots = cloneStrings(in.MissingRequiredSlots)
out.InvalidSlots = cloneStringMap(in.InvalidSlots)
out.Action = cloneActionRef(in.Action)
return out
}
func cloneCandidates(in []Candidate) []Candidate {
if in == nil {
return nil
}
out := make([]Candidate, len(in))
for i := range in {
out[i] = cloneCandidate(in[i])
}
return out
}
// catalogMetadataAllow 是目录与 Markdown 允许带出的 metadata 键。
// handler 仍留在 CatalogSpec 供执行器按名分派,但不写入 Markdown。
var catalogMetadataAllow = map[string]bool{
"blueprint": true,
"guidance": true,
}
func catalogMetadata(in map[string]string) map[string]string {
if len(in) == 0 {
return nil
}
out := map[string]string{}
for k, v := range in {
if catalogMetadataAllow[k] {
out[k] = v
}
}
if len(out) == 0 {
return nil
}
return out
}
+17
View File
@@ -0,0 +1,17 @@
package intent
import "context"
// CommandGenerator 模型命令生成端口(I2):宿主用模型实现
// (借鉴 Rasa 的 LLM command generators:只把相关流程交给生成器)。
// 只在规则与语义都未命中(fallback)时被调用;实现自担延迟与成本。
// 返回错误时识别保持 fallback,不会使 Recognize 失败。
//
// 库侧校验:start 的 ActionRef 必须能从同一快照唯一解析回权威 spec
// (spec ID、Action 别名、或唯一 handler 反查);槽位键必须在目标 spec
// 键集内,随后跑与规则路径同款的必填/枚举/format 校验并挂上元数据。
// 解析失败或未知键一律 fail-closed 回 fallback。
type CommandGenerator interface {
// Generate 基于输入与能力目录产出命令列表(catalog 由调用方按 top-k 给)。
Generate(ctx context.Context, input string, catalog []CatalogSpec) ([]Command, error)
}
+234
View File
@@ -0,0 +1,234 @@
package intent
import (
"context"
"errors"
"strings"
"testing"
)
// fakeScorer 按 examples 里是否包含特定子串给分(测试用确定性打分器)。
type fakeScorer struct {
scores map[string]float64 // key: examples[0]
err error
}
func (f fakeScorer) Score(ctx context.Context, input string, examples []string) (float64, error) {
if f.err != nil {
return 0, f.err
}
if len(examples) == 0 {
return 0, nil
}
if s, ok := f.scores[examples[0]]; ok {
return s, nil
}
return 0, nil
}
func ambiguousFixture(t *testing.T, exA, exB []string) *Snapshot {
t.Helper()
b := NewRegistryBuilder(Options{})
if err := b.Register(IntentSpec{ID: "demo.alpha", Domain: "demo", Version: 1, Priority: 5,
Matcher: MatchSpec{IncludesAny: []string{"模糊词"}}, Examples: exA}); err != nil {
t.Fatal(err)
}
if err := b.Register(IntentSpec{ID: "demo.beta", Domain: "demo", Version: 1, Priority: 5,
Matcher: MatchSpec{IncludesAny: []string{"模糊词"}}, Examples: exB}); err != nil {
t.Fatal(err)
}
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
return reg.Snapshot()
}
// TestSemanticRerankResolvesAmbiguous:语义过阈且分差够 → matched(I2 分层)。
func TestSemanticRerankResolvesAmbiguous(t *testing.T) {
snap := ambiguousFixture(t, []string{"查运行进度"}, []string{"闲聊问候"})
rec := NewRecognizer(Options{Semantic: fakeScorer{scores: map[string]float64{"查运行进度": 0.95, "闲聊问候": 0.4}}}, nil, nil)
res, err := rec.Recognize(context.Background(), "模糊词", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "demo.alpha" {
t.Fatalf("semantic rerank: %+v", res)
}
if !strings.Contains(res.Top.Reason, "refined_by=semantic") {
t.Fatalf("reason = %q", res.Top.Reason)
}
}
// TestSemanticBelowThresholdKeepsAmbiguous:全部不达阈 → 保持 ambiguous。
func TestSemanticBelowThresholdKeepsAmbiguous(t *testing.T) {
snap := ambiguousFixture(t, []string{"查运行进度"}, []string{"闲聊问候"})
rec := NewRecognizer(Options{Semantic: fakeScorer{scores: map[string]float64{"查运行进度": 0.5}}}, nil, nil)
res, err := rec.Recognize(context.Background(), "模糊词", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchAmbiguous {
t.Fatalf("below threshold: branch = %s", res.Branch)
}
}
// TestSemanticTieKeepsAmbiguous:两个过阈但分差不足 → 保持 ambiguous。
func TestSemanticTieKeepsAmbiguous(t *testing.T) {
snap := ambiguousFixture(t, []string{"查运行进度"}, []string{"查运行状态"})
rec := NewRecognizer(Options{Semantic: fakeScorer{scores: map[string]float64{"查运行进度": 0.9, "查运行状态": 0.89}}}, nil, nil)
res, err := rec.Recognize(context.Background(), "模糊词", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchAmbiguous {
t.Fatalf("tie: branch = %s", res.Branch)
}
}
// TestSemanticErrorKeepsAmbiguousAndCallsL2Next:打分错误 → ambiguous,且 L2 接管。
func TestSemanticErrorKeepsAmbiguousAndCallsL2Next(t *testing.T) {
snap := ambiguousFixture(t, []string{"查运行进度"}, nil)
called := false
cls := rewriteClassifier{fn: func(c []Candidate) []Candidate {
called = true
out := append([]Candidate(nil), c...)
out[0].Confidence = 0.99
out[1].Confidence = 0.1
return out
}}
rec := NewRecognizer(Options{Semantic: fakeScorer{err: errors.New("embed down")}}, nil, cls)
res, err := rec.Recognize(context.Background(), "模糊词", snap)
if err != nil {
t.Fatal(err)
}
if !called {
t.Fatal("L2 should be consulted after semantic failure")
}
if res.Branch != BranchMatched || res.Top.ID != "demo.alpha" {
t.Fatalf("L2 fallback after semantic error: %+v", res)
}
}
// TestGeneratorValidStartBecomesMatched:合法 start 命令 → matched + 合成 Top(I2)。
func TestGeneratorValidStartBecomesMatched(t *testing.T) {
b := NewRegistryBuilder(Options{})
if err := b.Register(IntentSpec{ID: "report.generate", Domain: "report", Version: 1,
Matcher: MatchSpec{IncludesAny: []string{"生成报告"}},
Slots: []SlotSpec{{Key: "topic", Pattern: `主题是(.+)`}},
Handler: "reportHandler"}); err != nil {
t.Fatal(err)
}
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
gen := stubGenerator{cmds: []Command{
{Kind: CommandStart, Action: ActionRef{Kind: ActionWorkflow, ID: "report.generate"},
Slots: map[string]string{"topic": "电机"}},
}}
rec := NewRecognizer(Options{Generator: gen}, nil, nil)
res, err := rec.Recognize(context.Background(), "完全不相关的一句话", reg.Snapshot())
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "report.generate" {
t.Fatalf("generated: %+v", res)
}
if len(res.Commands) != 1 || res.Commands[0].Kind != CommandStart || res.Commands[0].Slots["topic"] != "电机" {
t.Fatalf("commands = %+v", res.Commands)
}
}
// TestGeneratorInvalidOutputFallsBack:幻觉动作/槽位 → 保持 fallback(fail-closed)。
func TestGeneratorInvalidOutputFallsBack(t *testing.T) {
b := NewRegistryBuilder(Options{})
if err := b.Register(IntentSpec{ID: "report.generate", Domain: "report", Version: 1,
Matcher: MatchSpec{IncludesAny: []string{"生成报告"}},
Slots: []SlotSpec{{Key: "topic", Pattern: `(.+)`}}}); err != nil {
t.Fatal(err)
}
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
snap := reg.Snapshot()
cases := map[string][]Command{
"unknown spec id": {
{Kind: CommandStart, Action: ActionRef{Kind: ActionWorkflow, ID: "ghost.flow"}},
},
"unknown kind": {
{Kind: CommandStart, Action: ActionRef{Kind: "bogus", ID: "report.generate"}},
},
"unknown slot key": {
{Kind: CommandStart, Action: ActionRef{Kind: ActionWorkflow, ID: "report.generate"}, Slots: map[string]string{"bogus": "v"}},
},
"unknown cmd kind": {
{Kind: "teleport"},
},
"empty": {},
}
for name, cmds := range cases {
rec := NewRecognizer(Options{Generator: stubGenerator{cmds: cmds}}, nil, nil)
res, err := rec.Recognize(context.Background(), "完全不相关的一句话", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchFallback {
t.Fatalf("%s: branch = %s, want fallback", name, res.Branch)
}
}
}
// TestGeneratorErrorKeepsFallback:生成器错误 → 保持 fallback。
func TestGeneratorErrorKeepsFallback(t *testing.T) {
reg, err := NewRegistryBuilder(Options{}).Build()
if err != nil {
t.Fatal(err)
}
rec := NewRecognizer(Options{Generator: stubGenerator{err: errors.New("model down")}}, nil, nil)
res, err := rec.Recognize(context.Background(), "完全不相关的一句话", reg.Snapshot())
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchFallback {
t.Fatalf("branch = %s", res.Branch)
}
}
// TestCatalogTopK:按 priority 降序取 k(确定性口径)。
func TestCatalogTopK(t *testing.T) {
b := NewRegistryBuilder(Options{})
if err := b.Register(IntentSpec{ID: "demo.low", Domain: "demo", Version: 1, Priority: 1, Matcher: MatchSpec{IncludesAny: []string{"a"}}}); err != nil {
t.Fatal(err)
}
if err := b.Register(IntentSpec{ID: "demo.high", Domain: "demo", Version: 1, Priority: 9, Matcher: MatchSpec{IncludesAny: []string{"b"}}}); err != nil {
t.Fatal(err)
}
if err := b.Register(IntentSpec{ID: "demo.mid", Domain: "demo", Version: 1, Priority: 5, Matcher: MatchSpec{IncludesAny: []string{"c"}}}); err != nil {
t.Fatal(err)
}
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
top := reg.Snapshot().CatalogTopK(2)
if len(top) != 2 || top[0].ID != "demo.high" || top[1].ID != "demo.mid" {
t.Fatalf("topk = %+v", top)
}
if all := reg.Snapshot().CatalogTopK(0); len(all) != 3 {
t.Fatalf("k=0 should be full catalog: %d", len(all))
}
}
type stubGenerator struct {
cmds []Command
err error
}
func (s stubGenerator) Generate(ctx context.Context, input string, catalog []CatalogSpec) ([]Command, error) {
if s.err != nil {
return nil, s.err
}
return s.cmds, nil
}
+173
View File
@@ -0,0 +1,173 @@
package intent
import (
"context"
"strings"
"testing"
)
func TestR2NFKCFoldsFullwidthButNotTraditional(t *testing.T) {
spec := IntentSpec{
ID: "demo.greet.hello", Domain: "demo", Version: 1,
Matcher: MatchSpec{IncludesAny: []string{"hello"}},
}
b := NewRegistryBuilder(Options{NFKC: true})
if err := b.Register(spec); err != nil {
t.Fatal(err)
}
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
rec := NewRecognizer(Options{NFKC: true}, nil, nil)
res, err := rec.Recognize(context.Background(), "Hello", reg.Snapshot())
if err != nil || res.Top == nil || res.Top.ID != spec.ID {
t.Fatalf("fullwidth hello: %+v %v", res, err)
}
trad, err := rec.Recognize(context.Background(), "寫入", reg.Snapshot())
if err != nil {
t.Fatal(err)
}
if trad.Branch != BranchFallback {
t.Fatalf("NFKC must not convert traditional Chinese, got %+v", trad)
}
}
func TestR2SpecByIDDeepCopiesMetadata(t *testing.T) {
b := NewRegistryBuilder(Options{})
if err := b.Register(IntentSpec{
ID: "demo.meta.copy", Domain: "demo", Version: 1,
Matcher: MatchSpec{IncludesAny: []string{"复制"}},
Metadata: map[string]string{"guidance": "keep"},
}); err != nil {
t.Fatal(err)
}
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
got, ok := reg.Snapshot().SpecByID("demo.meta.copy")
if !ok {
t.Fatal("missing spec")
}
got.Metadata["guidance"] = "mutated"
again, _ := reg.Snapshot().SpecByID("demo.meta.copy")
if again.Metadata["guidance"] != "keep" {
t.Fatalf("snapshot metadata changed: %q", again.Metadata["guidance"])
}
}
func TestR2CatalogHidesPrivateMetadataAndHandlerInMarkdown(t *testing.T) {
b := NewRegistryBuilder(Options{})
if err := b.Register(IntentSpec{
ID: "demo.catalog.public", Domain: "demo", Version: 1,
Handler: "secret.handler",
Matcher: MatchSpec{IncludesAny: []string{"目录"}},
Metadata: map[string]string{
"blueprint": "bp",
"token": "do-not-leak",
},
}); err != nil {
t.Fatal(err)
}
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
snap := reg.Snapshot()
cat := snap.Catalog()
if len(cat) != 1 {
t.Fatalf("catalog: %+v", cat)
}
if cat[0].Metadata["blueprint"] != "bp" || cat[0].Metadata["token"] != "" {
t.Fatalf("metadata allowlist: %+v", cat[0].Metadata)
}
md := snap.CatalogMarkdown()
if strings.Contains(md, "secret.handler") || strings.Contains(md, "do-not-leak") || strings.Contains(md, "handler:") {
t.Fatalf("markdown leaked private fields:\n%s", md)
}
}
func TestR2EvalRulesDocumentsMaxSpecs(t *testing.T) {
if DefaultMaxSpecs != 2048 {
t.Fatalf("linear scan cap changed: %d", DefaultMaxSpecs)
}
b := NewRegistryBuilder(Options{MaxSpecs: 1})
if err := b.Register(
IntentSpec{ID: "demo.cap.a", Domain: "demo", Version: 1, Matcher: MatchSpec{IncludesAny: []string{"a"}}},
IntentSpec{ID: "demo.cap.b", Domain: "demo", Version: 1, Matcher: MatchSpec{IncludesAny: []string{"b"}}},
); err != nil {
t.Fatal(err)
}
if _, err := b.Build(); err == nil {
t.Fatal("build must reject specs above MaxSpecs")
}
}
func TestR2OverlappingSlotsAreAmbiguous(t *testing.T) {
b := NewRegistryBuilder(Options{})
if err := b.Register(IntentSpec{
ID: "demo.slot.overlap", Domain: "demo", Version: 1,
Matcher: MatchSpec{IncludesAny: []string{"abcd"}},
Slots: []SlotSpec{
{Key: "left", Pattern: "(abc)"},
{Key: "right", Pattern: "(bcd)"},
},
}); err != nil {
t.Fatal(err)
}
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
res, err := NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), "abcd", reg.Snapshot())
if err != nil || res.Top == nil {
t.Fatalf("recognize: %+v %v", res, err)
}
if len(res.Top.Slots) != 0 {
t.Fatalf("overlapping slots must not be kept: %+v", res.Top.Slots)
}
if res.Top.InvalidSlots["left"] != "span overlap" || res.Top.InvalidSlots["right"] != "span overlap" {
t.Fatalf("invalid slots: %+v", res.Top.InvalidSlots)
}
}
func TestR2NegationIsOnlyExcludesAny(t *testing.T) {
plain := IntentSpec{
ID: "demo.write.file", Domain: "demo", Version: 1,
Matcher: MatchSpec{IncludesAny: []string{"写入"}},
}
excluded := plain
excluded.ID = "demo.write.excluded"
excluded.Matcher.ExcludesAny = []string{"不要"}
rec := NewRecognizer(Options{}, nil, nil)
plainBuilder := NewRegistryBuilder(Options{})
if err := plainBuilder.Register(plain); err != nil {
t.Fatal(err)
}
built, err := plainBuilder.Build()
if err != nil {
t.Fatal(err)
}
hit, err := rec.Recognize(context.Background(), "不要写入文件", built.Snapshot())
if err != nil || hit.Top == nil || hit.Top.ID != plain.ID {
t.Fatalf("without excludes_any the negated phrase still matches: %+v %v", hit, err)
}
exBuilder := NewRegistryBuilder(Options{})
if err := exBuilder.Register(excluded); err != nil {
t.Fatal(err)
}
builtEx, err := exBuilder.Build()
if err != nil {
t.Fatal(err)
}
miss, err := rec.Recognize(context.Background(), "不要写入文件", builtEx.Snapshot())
if err != nil {
t.Fatal(err)
}
if miss.Branch != BranchFallback {
t.Fatalf("excludes_any must reject, got %+v", miss)
}
}
+317 -18
View File
@@ -2,6 +2,8 @@ package intent
import (
"context"
"fmt"
"math"
"sort"
"strings"
"time"
@@ -17,17 +19,22 @@ type Recognizer interface {
// FunnelRecognizer 四级漏斗实现:guard → expectation → rules → adjudicate,
// 可选注入挂起预期提供者与 Ambiguous 分支的 L2 分类器。
// I2 的分层:歧义先经语义重排(确定性宿主打分)再到 L2 分类器(模型)。
type FunnelRecognizer struct {
log *logsys.Logger
exp ExpectationProvider
cls Classifier
sem SemanticScorer
thr float64
gen CommandGenerator
}
// NewRecognizer 构建漏斗识别器。opts 仅使用 Log 字段(阈值类参数在快照内);
// exp / cls 可为 nil(对应阶段不启用)。
// exp / cls 可为 nil(对应阶段不启用)。语义与生成端口经 Options 注入(I2)。
func NewRecognizer(opts Options, exp ExpectationProvider, cls Classifier) *FunnelRecognizer {
o := opts.normalized()
return &FunnelRecognizer{log: o.Log, exp: exp, cls: cls}
return &FunnelRecognizer{log: o.Log, exp: exp, cls: cls,
sem: o.Semantic, thr: o.SemanticThreshold, gen: o.Generator}
}
// Recognize 识别一次输入。确定性:同输入 + 同快照 → 同结果(日志中的耗时除外)。
@@ -44,6 +51,10 @@ func (r *FunnelRecognizer) Recognize(ctx context.Context, input string, snap *Sn
}
start := time.Now()
res := r.recognize(ctx, input, snap)
// 生成端口已经给出经过校验的命令列表时不再用规则展开覆盖(I2)。
if res.Commands == nil {
res.Commands = deriveCommands(res, snap)
}
topID, conf := "", 0.0
if res.Top != nil {
topID, conf = res.Top.ID, res.Top.Confidence
@@ -61,10 +72,11 @@ func (r *FunnelRecognizer) recognize(ctx context.Context, input string, snap *Sn
if strings.TrimSpace(input) == "" {
return Result{Input: input, Branch: BranchFallback, SnapshotRevision: snap.rev}
}
folded := strings.ToLower(input)
// 否定只认 excludes_any 子串,不做语义否定;「不要写入」这类说法交给命令层 Cancel。
matchInput, folded := snap.foldInput(input)
// Stage 0:守卫命中即短路,可表达"识别即拒绝"。
if guardHits := snap.evalGuard(input, folded); len(guardHits) > 0 {
if guardHits := snap.evalGuard(matchInput, folded); len(guardHits) > 0 {
cands := candidatesOf(trimHits(guardHits, snap.topN), input, true)
top := cands[0]
return Result{Input: input, Branch: BranchGuardHit, Top: &top, Candidates: cands, SnapshotRevision: snap.rev}
@@ -73,7 +85,7 @@ func (r *FunnelRecognizer) recognize(ctx context.Context, input string, snap *Sn
// Stage 1:会话挂起预期优先于一般规则(宿主可选注入)。
if r.exp != nil {
if pending := r.exp.Pending(ctx); pending != nil && len(pending.IntentIDs) > 0 {
if hits := snap.evalIDs(pending.IntentIDs, input, folded); len(hits) > 0 {
if hits := snap.evalIDs(pending.IntentIDs, matchInput, folded); len(hits) > 0 {
cands := candidatesOf(trimHits(hits, snap.topN), input, true)
top := cands[0]
return Result{Input: input, Branch: BranchExpectationHit, Top: &top, Candidates: cands, SnapshotRevision: snap.rev}
@@ -82,34 +94,111 @@ func (r *FunnelRecognizer) recognize(ctx context.Context, input string, snap *Sn
}
// Stage 2 + 3:规则计分与置信度裁决。
hits := snap.evalRules(input, folded)
hits := snap.evalRules(matchInput, folded)
switch {
case len(hits) == 0:
// I2:规则与语义都未命中时,可选经模型命令生成端口兜底(校验同款)。
if r.gen != nil {
if res, ok := r.generateCommands(ctx, input, snap); ok {
return res
}
}
return Result{Input: input, Branch: BranchFallback, SnapshotRevision: snap.rev}
case len(hits) == 1 || hits[0].score-hits[1].score >= snap.margin:
case len(hits) == 1 || hits[0].score-hits[1].score >= snap.margin || tieBreakByPriority(hits):
cands := candidatesOf(trimHits(hits, snap.topN), input, true)
top := cands[0]
return Result{Input: input, Branch: BranchMatched, Top: &top, Candidates: cands, SnapshotRevision: snap.rev}
}
// Ambiguous:可选 L2 分类器重排;失败保持 Ambiguous,不影响识别确定性承诺的失败语义。
// Ambiguous:先语义重排(I2 确定性宿主打分),失败或不达阈才到 L2 分类器。
cands := candidatesOf(trimHits(hits, snap.topN), input, false)
if r.sem != nil {
if top, ok := r.semanticRerank(ctx, input, cands, snap); ok {
snap.fillTopSlots(&top, input)
top.Reason = top.Reason + "; refined_by=semantic"
return Result{Input: input, Branch: BranchMatched, Top: &top, Candidates: cands, SnapshotRevision: snap.rev}
}
}
if r.cls != nil {
refined, err := r.cls.Refine(ctx, input, cands)
// 传入候选副本:分类器就地改写不会污染原候选(失败时仍返回未篡改集)。
refined, err := r.cls.Refine(ctx, input, cloneCandidates(cands))
if err != nil {
r.log.Module("intent").WithContext(ctx).Warn("intent classifier failed; keep ambiguous", "error", err.Error())
return Result{Input: input, Branch: BranchAmbiguous, Candidates: cands, SnapshotRevision: snap.rev}
}
if top, ok := adjudicateRefined(refined, snap); ok {
cands = refined
top.Reason = top.Reason + "; refined_by=classifier"
return Result{Input: input, Branch: BranchMatched, Top: &top, Candidates: cands, SnapshotRevision: snap.rev}
rebuilt, err := validateRefined(cands, refined)
if err != nil {
r.log.Module("intent").WithContext(ctx).Warn("intent classifier result rejected; keep ambiguous", "error", err.Error())
return Result{Input: input, Branch: BranchAmbiguous, Candidates: cands, SnapshotRevision: snap.rev}
}
cands = refined
if top, ok := adjudicateRefined(rebuilt, snap); ok {
snap.fillTopSlots(&top, input)
top.Reason = top.Reason + "; refined_by=classifier"
return Result{Input: input, Branch: BranchMatched, Top: &top, Candidates: rebuilt, SnapshotRevision: snap.rev}
}
cands = rebuilt
}
return Result{Input: input, Branch: BranchAmbiguous, Candidates: cands, SnapshotRevision: snap.rev}
}
// tieBreakByPriority 报告同分(分差为 0)且前两名 Priority 不同:
// 此时高 Priority 者直接胜出,不再标歧(I5)。分差小于 margin 但非零时
// 仍按 ambiguous 处理(margin 语义不变)。
func tieBreakByPriority(hits []specHit) bool {
if len(hits) < 2 {
return false
}
return hits[0].score == hits[1].score && hits[0].cs.spec.Priority > hits[1].cs.spec.Priority
}
// validateRefined 要求分类器返回与原候选完全相同的 ID 集合且无重复。
// 置信度必须是有限值并落在 [0,1];越界判为不合法,不截断。
// 除 Confidence 外的字段一律按 ID 从原候选重建,分类器改写的 Handler 等不生效。
func validateRefined(original, refined []Candidate) ([]Candidate, error) {
byID := make(map[string]Candidate, len(original))
for _, c := range original {
if _, dup := byID[c.ID]; dup {
return nil, fmt.Errorf("duplicate original id %s", c.ID)
}
byID[c.ID] = c
}
if len(refined) != len(original) {
return nil, fmt.Errorf("refined candidate count %d != %d", len(refined), len(original))
}
seen := make(map[string]struct{}, len(refined))
out := make([]Candidate, len(refined))
for i, got := range refined {
if _, dup := seen[got.ID]; dup {
return nil, fmt.Errorf("duplicate refined id %s", got.ID)
}
seen[got.ID] = struct{}{}
src, ok := byID[got.ID]
if !ok {
return nil, fmt.Errorf("unknown refined id %s", got.ID)
}
norm, ok := normalizeScore(got.Confidence)
if !ok {
return nil, fmt.Errorf("confidence out of range for %s", got.ID)
}
src.Confidence = norm
out[i] = src
}
return out, nil
}
func validConfidence(v float64) bool {
return !math.IsNaN(v) && !math.IsInf(v, 0) && v >= 0 && v <= 1
}
// normalizeScore 拒绝非有限/越界分数,并通过与规则计分同款的定点舍入后再交给阈值比较,
// 防止篡改或噪声分数绕过阈值进入 matched。
func normalizeScore(v float64) (float64, bool) {
if !validConfidence(v) {
return 0, false
}
return math.Round(v*10000) / 10000, true
}
// adjudicateRefined 对分类器结果重排后应用同一裁决规则;分差仍贴近则返回 false。
func adjudicateRefined(refined []Candidate, snap *Snapshot) (Candidate, bool) {
if len(refined) == 0 {
@@ -151,11 +240,11 @@ func (s *Snapshot) Explain(input string) Trace {
trace.Branch = BranchFallback
return trace
}
folded := strings.ToLower(input)
matchInput, folded := s.foldInput(input)
for _, idx := range s.guardIdx {
cs := s.specs[idx]
o := cs.evaluate(input, folded)
o := cs.evaluate(matchInput, folded)
if o.passed {
trace.Guard = append(trace.Guard, ScoredSpec{ID: cs.spec.ID, Score: o.score, HitTerms: o.hitTerms, HitPatterns: o.hitPat})
} else {
@@ -175,7 +264,7 @@ func (s *Snapshot) Explain(input string) Trace {
if cs.guard {
continue
}
o := cs.evaluate(input, folded)
o := cs.evaluate(matchInput, folded)
if o.passed {
hits = append(hits, specHit{cs: cs, score: o.score, terms: o.hitTerms, pats: o.hitPat})
trace.Rules = append(trace.Rules, ScoredSpec{ID: cs.spec.ID, Score: o.score, HitTerms: o.hitTerms, HitPatterns: o.hitPat})
@@ -190,7 +279,7 @@ func (s *Snapshot) Explain(input string) Trace {
switch {
case len(hits) == 0:
trace.Branch = BranchFallback
case len(hits) == 1 || hits[0].score-hits[1].score >= s.margin:
case len(hits) == 1 || hits[0].score-hits[1].score >= s.margin || tieBreakByPriority(hits):
trace.Branch = BranchMatched
trace.TopID = hits[0].cs.spec.ID
default:
@@ -206,3 +295,213 @@ func sortScored(ss []ScoredSpec) {
func sortRejected(rs []RejectedSpec) {
sort.SliceStable(rs, func(i, j int) bool { return rs[i].ID < rs[j].ID })
}
// semanticRerank 用宿主打分器对歧义候选按 examples 重排(I2)。
// 规则:候选必须来自当前候选集(构造上不可能引入未知 ID);
// 只取相似度 ≥ 阈值的候选——唯一过阈或前两名分差 ≥ margin 才判 matched,
// 否则保持 ambiguous(与规则裁决同款语义)。打分错误一律记日志并保持 ambiguous。
// examples 以副本传入打分器;非有限/越界分数拒绝;定点舍入后再与阈值比较。
func (r *FunnelRecognizer) semanticRerank(ctx context.Context, input string, cands []Candidate, snap *Snapshot) (Candidate, bool) {
type scored struct {
cand Candidate
score float64
}
var above []scored
for _, c := range cands {
spec, ok := snap.SpecByID(c.ID)
if !ok || len(spec.Examples) == 0 {
continue
}
examples := cloneStrings(spec.Examples)
s, err := r.sem.Score(ctx, input, examples)
if err != nil {
r.log.Module("intent").WithContext(ctx).Warn("intent semantic scorer failed; keep ambiguous",
"error", err.Error(), "id", c.ID)
continue
}
norm, ok := normalizeScore(s)
if !ok {
r.log.Module("intent").WithContext(ctx).Warn("intent semantic score rejected; keep ambiguous",
"id", c.ID, "score", s)
continue
}
if norm >= r.thr {
above = append(above, scored{cand: c, score: norm})
}
}
if len(above) == 0 {
return Candidate{}, false
}
for i := 0; i+1 < len(above); i++ {
for j := i + 1; j < len(above); j++ {
if above[j].score > above[i].score {
above[i], above[j] = above[j], above[i]
}
}
}
if len(above) == 1 || above[0].score-above[1].score >= snap.margin {
return above[0].cand, true
}
return Candidate{}, false
}
// generateCommands 经模型命令生成端口兜底(I2)。校验:
// start 的 ActionRef 必须能从同一快照唯一解析回权威 spec(含 Action 别名与
// handler→spec 反查)、命令种类受限、槽位键合法;解析成功后对生成槽位跑与
// 规则路径同款的全量校验并挂上 risk/confirm/action 元数据。任一项不过则保持 fallback(无 Top)。
func (r *FunnelRecognizer) generateCommands(ctx context.Context, input string, snap *Snapshot) (Result, bool) {
catalog := snap.CatalogTopK(DefaultGeneratorCatalogK)
cmds, err := r.gen.Generate(ctx, input, catalog)
if err != nil {
r.log.Module("intent").WithContext(ctx).Warn("intent command generator failed; keep fallback", "error", err.Error())
return Result{}, false
}
if err := validateGenerated(cmds, snap); err != nil {
r.log.Module("intent").WithContext(ctx).Warn("intent generator output rejected; keep fallback", "error", err.Error())
return Result{}, false
}
res := Result{Input: input, Branch: BranchFallback, Commands: cmds, SnapshotRevision: snap.rev}
for _, c := range cmds {
if c.Kind != CommandStart {
continue
}
cs, err := snap.resolveActionRef(c.Action)
if err != nil || cs == nil {
break
}
top := candidateFromGenerated(cs, c.Slots)
res.Top = &top
res.Branch = BranchMatched
res.Candidates = []Candidate{top}
break
}
return res, true
}
// DefaultGeneratorCatalogK 生成器目录默认 top-k。
const DefaultGeneratorCatalogK = 8
// candidateFromGenerated 用权威 spec 元数据 + 生成槽位的全量校验合成 Top(与规则路径同款字段)。
func candidateFromGenerated(cs *compiledSpec, provided map[string]string) Candidate {
c := Candidate{
ID: cs.spec.ID,
Domain: cs.spec.Domain,
Version: cs.spec.Version,
Confidence: 0,
Priority: cs.spec.Priority,
Risk: cs.spec.Risk,
RequiresConfirm: cs.spec.RequiresConfirm,
Handler: cs.spec.Handler,
Reason: "generated_by=command_generator",
}
if c.Risk == "" {
c.Risk = RiskLow
}
if cs.spec.Action != nil {
ref := *cs.spec.Action
c.Action = &ref
}
if len(cs.spec.Metadata) > 0 {
c.Metadata = cloneStringMap(cs.spec.Metadata)
}
c.Slots, c.MissingRequiredSlots, c.InvalidSlots = cs.validateProvidedSlots(provided)
return c
}
// validateGenerated 校验生成命令(I2;与 spec 级校验同款 fail-closed)。
// ActionRef 经快照唯一解析;槽位未知键在此拒绝(与规则路径「键集外」同款 fail-closed)。
func validateGenerated(cmds []Command, snap *Snapshot) error {
if len(cmds) == 0 {
return fmt.Errorf("empty command list")
}
for i, c := range cmds {
switch c.Kind {
case CommandStart:
cs, err := snap.resolveActionRef(c.Action)
if err != nil {
return fmt.Errorf("cmd[%d] start: %w", i, err)
}
keys := map[string]bool{}
for _, sl := range cs.slots {
keys[sl.key] = true
}
for k := range c.Slots {
if !keys[k] {
return fmt.Errorf("cmd[%d] start: 槽位 %q 不在 %s 的槽位键集内", i, k, cs.spec.ID)
}
}
case CommandClarify, CommandCancel, CommandSetSlot, CommandChangeSlot, CommandChitchat, CommandHandoff:
default:
return fmt.Errorf("cmd[%d]: 未知命令种类 %q", i, c.Kind)
}
}
return nil
}
// resolveActionRef 把 start 的 ActionRef 解析回快照内唯一权威 compiledSpec。
// ActionCommand/Workflow/Blueprint/Auto:先按 spec ID,再按 spec.Action 声明的别名;
// ActionHandler:经 handler→spec 反查,重名拒绝。
func (s *Snapshot) resolveActionRef(ref ActionRef) (*compiledSpec, error) {
switch ref.Kind {
case ActionHandler:
return s.resolveByHandler(ref.ID)
case ActionAuto, ActionCommand, ActionWorkflow, ActionBlueprint:
return s.resolveBySpecOrAlias(ref)
default:
return nil, fmt.Errorf("未知 action.kind %q", ref.Kind)
}
}
func (s *Snapshot) resolveByHandler(handler string) (*compiledSpec, error) {
if strings.TrimSpace(handler) == "" {
return nil, fmt.Errorf("handler 为空")
}
var found *compiledSpec
for _, cs := range s.specs {
if cs.guard {
continue
}
if cs.spec.Handler != handler {
continue
}
if found != nil {
return nil, fmt.Errorf("handler %q 对应多个 spec(歧义)", handler)
}
found = cs
}
if found == nil {
return nil, fmt.Errorf("handler %q 不在快照内", handler)
}
return found, nil
}
func (s *Snapshot) resolveBySpecOrAlias(ref ActionRef) (*compiledSpec, error) {
if strings.TrimSpace(ref.ID) == "" {
return nil, fmt.Errorf("action.id 为空")
}
var found *compiledSpec
for _, cs := range s.specs {
if cs.guard {
continue
}
match := cs.spec.ID == ref.ID
if !match && cs.spec.Action != nil && cs.spec.Action.Kind == ref.Kind && cs.spec.Action.ID == ref.ID {
match = true
}
// ActionAuto 也接受「合法别名」:显式 Action 的 ID 与 kind 匹配任一声明。
if !match && ref.Kind == ActionAuto && cs.spec.Action != nil && cs.spec.Action.ID == ref.ID {
match = true
}
if !match {
continue
}
if found != nil && found != cs {
return nil, fmt.Errorf("action.id %q 对应多个 spec(歧义)", ref.ID)
}
found = cs
}
if found == nil {
return nil, fmt.Errorf("action.id %q 不在快照内", ref.ID)
}
return found, nil
}
+140 -8
View File
@@ -3,6 +3,7 @@ package intent
import (
"context"
"errors"
"math"
"os"
"reflect"
"strings"
@@ -111,20 +112,21 @@ func TestRecognizePatternBonus(t *testing.T) {
}
}
func TestRecognizeAmbiguousAndPriorityOrder(t *testing.T) {
func TestRecognizeTieBreakPrefersPriority(t *testing.T) {
_, snap := buildDemoRegistry(t)
rec := NewRecognizer(Options{}, nil, nil)
res, err := rec.Recognize(context.Background(), "你好呀", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchAmbiguous || res.Top != nil {
t.Fatalf("expected ambiguous without top: %+v", res)
// I5:同分(0.7)且 Priority 不同(10 > 5)时高优先者直接胜出,不再标歧。
if res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "demo.greet.hello" {
t.Fatalf("tie-break should match hello: %+v", res)
}
if len(res.Candidates) < 2 {
t.Fatalf("expected >=2 candidates: %+v", res.Candidates)
}
// 同分按 priority 降序:hello(10) 在 hi(5) 前。
// 候选次序仍按 priority 降序:hello(10) 在 hi(5) 前。
if res.Candidates[0].ID != "demo.greet.hello" || res.Candidates[1].ID != "demo.greet.hi" {
t.Fatalf("priority order: %+v", res.Candidates)
}
@@ -150,13 +152,13 @@ func (s stubClassifier) Refine(ctx context.Context, input string, cands []Candid
func TestRecognizeClassifierResolvesAmbiguity(t *testing.T) {
_, snap := buildDemoRegistry(t)
cls := stubClassifier{scores: map[string]float64{"demo.greet.hello": 0.9, "demo.greet.hi": 0.3}}
cls := stubClassifier{scores: map[string]float64{"demo.tie.alpha": 0.9, "demo.tie.beta": 0.3}}
rec := NewRecognizer(Options{}, nil, cls)
res, err := rec.Recognize(context.Background(), "你好呀", snap)
res, err := rec.Recognize(context.Background(), "打结", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "demo.greet.hello" {
if res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "demo.tie.alpha" {
t.Fatalf("classifier should resolve: %+v", res)
}
if !strings.Contains(res.Top.Reason, "refined_by=classifier") {
@@ -167,7 +169,7 @@ func TestRecognizeClassifierResolvesAmbiguity(t *testing.T) {
func TestRecognizeClassifierFailureKeepsAmbiguous(t *testing.T) {
_, snap := buildDemoRegistry(t)
rec := NewRecognizer(Options{}, nil, stubClassifier{err: errors.New("llm down")})
res, err := rec.Recognize(context.Background(), "你好呀", snap)
res, err := rec.Recognize(context.Background(), "打结", snap)
if err != nil {
t.Fatalf("classifier failure must not fail recognition: %v", err)
}
@@ -176,6 +178,136 @@ func TestRecognizeClassifierFailureKeepsAmbiguous(t *testing.T) {
}
}
type rewriteClassifier struct {
fn func([]Candidate) []Candidate
}
func (s rewriteClassifier) Refine(ctx context.Context, input string, cands []Candidate) ([]Candidate, error) {
return s.fn(cands), nil
}
func TestRecognizeClassifierRejectsBadRefinement(t *testing.T) {
_, snap := buildDemoRegistry(t)
cases := []struct {
name string
fn func([]Candidate) []Candidate
}{
{"unknown_id", func(cands []Candidate) []Candidate {
out := append([]Candidate(nil), cands...)
out[0].ID = "not.a.real.id"
return out
}},
{"missing_id", func(cands []Candidate) []Candidate {
return cands[:1]
}},
{"duplicate_id", func(cands []Candidate) []Candidate {
return []Candidate{cands[0], cands[0]}
}},
{"confidence_above_one", func(cands []Candidate) []Candidate {
out := append([]Candidate(nil), cands...)
out[0].Confidence = 1.2
return out
}},
{"confidence_negative", func(cands []Candidate) []Candidate {
out := append([]Candidate(nil), cands...)
out[0].Confidence = -0.1
return out
}},
{"confidence_nan", func(cands []Candidate) []Candidate {
out := append([]Candidate(nil), cands...)
out[0].Confidence = math.NaN()
return out
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := NewRecognizer(Options{}, nil, rewriteClassifier{fn: tc.fn})
res, err := rec.Recognize(context.Background(), "打结", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchAmbiguous || res.Top != nil {
t.Fatalf("expected ambiguous kept: %+v", res)
}
if len(res.Candidates) < 2 || res.Candidates[0].ID != "demo.tie.alpha" {
t.Fatalf("original candidates kept: %+v", res.Candidates)
}
})
}
}
func TestRecognizeClassifierRebuildsTamperedHandler(t *testing.T) {
_, snap := buildDemoRegistry(t)
plain, err := NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), "打结", snap)
if err != nil {
t.Fatal(err)
}
wantHandler := plain.Candidates[0].Handler
rec := NewRecognizer(Options{}, nil, rewriteClassifier{fn: func(cands []Candidate) []Candidate {
out := append([]Candidate(nil), cands...)
for i := range out {
out[i].Handler = "tampered.handler"
if out[i].ID == "demo.tie.alpha" {
out[i].Confidence = 0.95
} else {
out[i].Confidence = 0.2
}
}
return out
}})
res, err := rec.Recognize(context.Background(), "打结", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "demo.tie.alpha" {
t.Fatalf("expected resolved match: %+v", res)
}
if res.Top.Handler != wantHandler {
t.Fatalf("handler rebuilt from original %q, got %q", wantHandler, res.Top.Handler)
}
}
func TestRecognizeClassifierRefinedTopGetsSlots(t *testing.T) {
b := NewRegistryBuilder(Options{})
if err := b.Register(
IntentSpec{
ID: "demo.slot.a", Domain: "demo", Version: 1,
Matcher: MatchSpec{IncludesAny: []string{"查槽"}},
Handler: "handler.a",
Slots: []SlotSpec{{Key: "n", Pattern: `查槽[: ]*(\d+)`, Group: 1}},
Confidence: ConfidenceSpec{Base: 0.8, Threshold: 0.5},
},
IntentSpec{
ID: "demo.slot.b", Domain: "demo", Version: 1,
Matcher: MatchSpec{IncludesAny: []string{"查槽"}},
Handler: "handler.b",
Confidence: ConfidenceSpec{Base: 0.8, Threshold: 0.5},
},
); err != nil {
t.Fatal(err)
}
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
snap := reg.Snapshot()
plain, _ := NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), "查槽 7", snap)
if plain.Branch != BranchAmbiguous || plain.Top != nil {
t.Fatalf("expected ambiguous before refine: %+v", plain)
}
rec := NewRecognizer(Options{}, nil, stubClassifier{scores: map[string]float64{"demo.slot.a": 0.95, "demo.slot.b": 0.2}})
res, err := rec.Recognize(context.Background(), "查槽 7", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "demo.slot.a" {
t.Fatalf("refined top: %+v", res)
}
if res.Top.Slots["n"] != "7" {
t.Fatalf("top slots: %+v", res.Top.Slots)
}
}
type stubExpectation struct {
pending *PendingExpectation
}
+75 -52
View File
@@ -35,6 +35,18 @@ type Options struct {
MaxSlotsPerSpec int
// Log 可选日志器;nil 时使用 Nop。识别日志不记录原始输入。
Log *logsys.Logger
// Semantic 语义相似度端口(I2):nil 时语义层不启用。
Semantic SemanticScorer
// SemanticThreshold 语义重排的过阈值(默认 0.8)。
SemanticThreshold float64
// Generator 模型命令生成端口(I2):nil 时 fallback 不生成。
Generator CommandGenerator
// SlotValidators 作用域槽位校验器。构建与校验时先查此表,查不到再回落
// 已弃用的全局表(RegisterSlotValidator)。不同 Registry 的同名校验器互不影响。
SlotValidators map[string]SlotValidator
// NFKC 为真时,词条与输入先做兼容等价归一(全角 ASCII、组合字符),再按
// case_sensitive 决定是否小写。NFKC 不做繁简转换。
NFKC bool
}
// Options 字段默认值。
@@ -48,13 +60,16 @@ const (
)
func (o Options) normalized() Options {
n := o
n := cloneOptions(o)
if n.TopN <= 0 {
n.TopN = DefaultTopN
}
if n.AmbiguityMargin <= 0 {
n.AmbiguityMargin = DefaultAmbiguityMargin
}
if n.SemanticThreshold <= 0 {
n.SemanticThreshold = DefaultSemanticThreshold
}
if n.MaxInputLen <= 0 {
n.MaxInputLen = DefaultMaxInputLen
}
@@ -94,13 +109,7 @@ func (o Options) limits() limits {
}
// ---- 编译产物 ----
type compiledSlot struct {
key string
required bool
re *regexp.Regexp
group int
}
// 槽位的编译产物与抽取/校验逻辑见 slots.go。
type compiledSpec struct {
spec IntentSpec
@@ -132,19 +141,19 @@ func (cs *compiledSpec) evaluate(input, folded string) matchOutcome {
hay = folded
}
for _, t := range cs.termsEx {
if strings.Contains(hay, t) {
if cs.contains(hay, t) {
return matchOutcome{reject: "excluded_term:" + t}
}
}
for _, t := range cs.termsAll {
if !strings.Contains(hay, t) {
if !cs.contains(hay, t) {
return matchOutcome{reject: "missing_includes_all:" + t}
}
}
var hitTerms, hitPat []string
positive := false
for _, t := range cs.termsAny {
if strings.Contains(hay, t) {
if cs.contains(hay, t) {
positive = true
hitTerms = append(hitTerms, t)
}
@@ -169,41 +178,25 @@ func (cs *compiledSpec) evaluate(input, folded string) matchOutcome {
return matchOutcome{score: math.Round(score*10000) / 10000, passed: score >= cs.threshold, hitTerms: hitTerms, hitPat: hitPat}
}
// extractSlots 从原文抽取槽位;返回缺失的必填槽位键。
func (cs *compiledSpec) extractSlots(input string) (map[string]string, []string) {
if len(cs.slots) == 0 {
return nil, nil
// contains 按 spec 的 boundary 配置选择子串或词界匹配(I3)。
func (cs *compiledSpec) contains(hay, term string) bool {
if cs.spec.Matcher.Boundary {
return boundedContains(hay, term)
}
slots := make(map[string]string, len(cs.slots))
var missing []string
for _, s := range cs.slots {
m := s.re.FindStringSubmatch(input)
if m != nil && s.group < len(m) && m[s.group] != "" {
slots[s.key] = m[s.group]
continue
}
if s.required {
missing = append(missing, s.key)
}
}
return slots, missing
return strings.Contains(hay, term)
}
func compileSpec(spec IntentSpec, guard bool, index int) (*compiledSpec, error) {
func compileSpec(spec IntentSpec, guard bool, index int, scoped map[string]SlotValidator, nfkc bool) (*compiledSpec, error) {
m := spec.Matcher
fold := func(terms []string) []string {
out := make([]string, len(terms))
for i, t := range terms {
if m.CaseSensitive {
out[i] = t
} else {
out[i] = strings.ToLower(t)
}
out[i] = foldMatchText(t, nfkc, m.CaseSensitive)
}
return out
}
cs := &compiledSpec{
spec: spec,
spec: cloneIntentSpec(spec),
termsAny: fold(m.IncludesAny),
termsAll: fold(m.IncludesAll),
termsEx: fold(m.ExcludesAny),
@@ -231,11 +224,11 @@ func compileSpec(spec IntentSpec, guard bool, index int) (*compiledSpec, error)
cs.patterns[i] = re
}
for _, s := range spec.Slots {
re, err := regexp.Compile(s.Pattern)
cs2, err := compileSlot(s, scoped)
if err != nil {
return nil, newError(InvalidSpec, "slot pattern 编译失败", "field", "slots", "key", s.Key, "id", spec.ID, "error", err.Error())
return nil, err.(*Error).with("id", spec.ID)
}
cs.slots = append(cs.slots, compiledSlot{key: s.Key, required: s.Required, re: re, group: effectiveGroup(s.Group)})
cs.slots = append(cs.slots, cs2)
}
return cs, nil
}
@@ -255,6 +248,7 @@ type Snapshot struct {
disabled []string // 已禁用 id(overlay disable 与 spec.disabled 两个入口)
sources map[string]string
total int // 含禁用
nfkc bool
}
// Revision 返回快照版本号,随每次成功 Reload 递增。
@@ -283,10 +277,11 @@ func (s *Snapshot) Source(id string) (string, bool) {
return src, ok
}
// SpecByID 返回指定 id 的启用 spec 定义副本。不存在或已禁用时第二个返回值为 false。
// SpecByID 返回指定 id 的启用 spec 定义副本。切片与 Metadata 深拷贝,
// 调用方改返回值不会改到快照。不存在或已禁用时第二个返回值为 false。
func (s *Snapshot) SpecByID(id string) (IntentSpec, bool) {
if idx, ok := s.byID[id]; ok {
return s.specs[idx].spec, true
return cloneIntentSpec(s.specs[idx].spec), true
}
return IntentSpec{}, false
}
@@ -383,7 +378,9 @@ func (s *Snapshot) evalIDs(ids []string, input, folded string) []specHit {
return hits
}
// evalRules 评估全部非守卫 spec,返回过阈命中(已排序)。
// evalRules 对全部非守卫 spec 做线性扫描,返回过阈命中(已排序)。
// 规模上限是 Options.MaxSpecs(默认 DefaultMaxSpecs = 2048)。倒排索引留到
// 超过该上限仍不够用时再做;构建期已经拒绝超过上限的快照。
func (s *Snapshot) evalRules(input, folded string) []specHit {
var hits []specHit
for _, cs := range s.specs {
@@ -425,6 +422,10 @@ func (h specHit) candidate(input string, withSlots bool) Candidate {
if c.Risk == "" {
c.Risk = RiskLow
}
if cs.spec.Action != nil {
ref := *cs.spec.Action
c.Action = &ref
}
if len(cs.spec.Metadata) > 0 {
c.Metadata = make(map[string]string, len(cs.spec.Metadata))
for k, v := range cs.spec.Metadata {
@@ -432,11 +433,24 @@ func (h specHit) candidate(input string, withSlots bool) Candidate {
}
}
if withSlots {
c.Slots, c.MissingRequiredSlots = cs.extractSlots(input)
c.Slots, c.MissingRequiredSlots, c.InvalidSlots = cs.extractSlots(input)
}
return c
}
// fillTopSlots 给裁决后的 Top 补抽槽位。Ambiguous 路径建候选时不抽槽位,
// 分类器选定 Top 之后才知道该抽哪一条。
func (s *Snapshot) fillTopSlots(c *Candidate, input string) {
idx, ok := s.byID[c.ID]
if !ok {
return
}
slots, missing, invalid := s.specs[idx].extractSlots(input)
c.Slots = slots
c.MissingRequiredSlots = missing
c.InvalidSlots = invalid
}
func candidatesOf(hits []specHit, input string, topSlots bool) []Candidate {
out := make([]Candidate, 0, len(hits))
for i, h := range hits {
@@ -460,17 +474,18 @@ func NewRegistryBuilder(opts Options) *RegistryBuilder {
}
// Register 登记内置基线 spec,立即校验;重复 id 报 InvalidSpec。
// 登记时深拷贝:调用方之后改入参不会影响已登记定义。
func (b *RegistryBuilder) Register(specs ...IntentSpec) error {
lim := b.opts.limits()
for _, spec := range specs {
if b.seen[spec.ID] {
return newError(InvalidSpec, "内置 spec id 重复", "id", spec.ID)
}
if err := validateSpec(spec, lim); err != nil {
if err := validateSpec(spec, lim, b.opts.SlotValidators); err != nil {
return err
}
b.seen[spec.ID] = true
b.builtin = append(b.builtin, spec)
b.builtin = append(b.builtin, cloneIntentSpec(spec))
}
return nil
}
@@ -505,9 +520,13 @@ func (b *RegistryBuilder) buildWithSource(fsys fs.FS, roots []string) (*Registry
if err != nil {
return nil, err
}
builtin := make([]IntentSpec, len(b.builtin))
for i, spec := range b.builtin {
builtin[i] = cloneIntentSpec(spec)
}
r := &Registry{
opts: b.opts,
builtin: append([]IntentSpec(nil), b.builtin...),
opts: cloneOptions(b.opts),
builtin: builtin,
fsys: fsys,
roots: append([]string(nil), roots...),
hasSource: fsys != nil,
@@ -532,8 +551,8 @@ type Registry struct {
// Snapshot 返回当前生效快照。永不为 nil。
func (r *Registry) Snapshot() *Snapshot { return r.snap.Load() }
// Options 返回生效选项(已填默认值)。
func (r *Registry) Options() Options { return r.opts }
// Options 返回生效选项(已填默认值)的深拷贝:改切片/校验器表不影响 Registry。
func (r *Registry) Options() Options { return cloneOptions(r.opts) }
// Reload 重读配置来源并构建新快照;成功后原子替换并返回新快照。
// 任一步失败保留旧快照,revision 不变,返回带来源与原因的错误。
@@ -569,7 +588,10 @@ func buildSnapshot(opts Options, baseline []IntentSpec, entries []overlayEntry,
guards := opts.guardSet()
lim := opts.limits()
ordered := append([]IntentSpec(nil), baseline...)
ordered := make([]IntentSpec, len(baseline))
for i, spec := range baseline {
ordered[i] = cloneIntentSpec(spec)
}
pos := make(map[string]int, len(ordered))
sources := make(map[string]string, len(ordered))
for i, spec := range ordered {
@@ -599,8 +621,8 @@ func buildSnapshot(opts Options, baseline []IntentSpec, entries []overlayEntry,
sources[e.id] = e.source + " (disabled)"
continue
}
spec := e.spec
if err := validateSpec(spec, lim); err != nil {
spec := cloneIntentSpec(e.spec)
if err := validateSpec(spec, lim, opts.SlotValidators); err != nil {
return nil, err.(*Error).with("source", e.source)
}
if idx, ok := pos[spec.ID]; ok {
@@ -635,6 +657,7 @@ func buildSnapshot(opts Options, baseline []IntentSpec, entries []overlayEntry,
disabled: disabledList,
sources: sources,
total: total,
nfkc: opts.NFKC,
}
nextIndex := 0
for _, spec := range ordered {
@@ -642,7 +665,7 @@ func buildSnapshot(opts Options, baseline []IntentSpec, entries []overlayEntry,
snap.disabled = append(snap.disabled, spec.ID)
continue
}
cs, err := compileSpec(spec, guards[spec.Domain], nextIndex)
cs, err := compileSpec(spec, guards[spec.Domain], nextIndex, opts.SlotValidators, opts.NFKC)
if err != nil {
return nil, err
}
+10
View File
@@ -32,6 +32,12 @@ type Candidate struct {
Metadata map[string]string
Slots map[string]string
MissingRequiredSlots []string
// InvalidSlots 捕获到但未通过确定性校验的槽位(key→原因,如枚举不合法)。
// 必填且非法只进此处(不重复进 Missing),宿主可区分"没给"与"给错了"。
InvalidSlots map[string]string
// Action 是 spec 的显式动作声明(I1 WP2),随候选透传;nil 表示未声明,
// ActionRef 推导回落 blueprint/Handler/auto 顺序。
Action *ActionRef
// Reason 命中解释:命中词与模式,供宿主审计与 intentctl explain 复用。
Reason string
}
@@ -44,6 +50,10 @@ type Result struct {
Top *Candidate
Candidates []Candidate
SnapshotRevision int64
// Commands 是把本结果展开的路由指令列表(I1 命令化识别);
// 可能为空(fallback / guard_hit / 宿主默认通道)。Branch 与 Top 保留,
// 供诊断与尚未迁移到命令的消费方使用。
Commands []Command
}
// ScoredSpec 诊断用:单个 spec 在某个阶段的得分与命中明细。
+13
View File
@@ -0,0 +1,13 @@
package intent
import "context"
// SemanticScorer 语义相似度端口(I2 分层与评测):宿主注入向量模型,
// 核心不做 I/O——打分由宿主计算,库只消费分数。
// 只在规则裁决为 Ambiguous 时被调用(规则→语义→模型分层的中位);
// 实现应幂等且无副作用(同一输入与示例集应得到同一分数)。
type SemanticScorer interface {
// Score 返回 input 与 examples 的最高相似度,取值 [0,1]。
// 实现返回错误时识别保持 Ambiguous,不会使 Recognize 失败。
Score(ctx context.Context, input string, examples []string) (float64, error)
}
+260
View File
@@ -0,0 +1,260 @@
package intent
import (
"regexp"
"strings"
"sync"
)
// SlotValidator 宿主注册的槽位值校验器。必须纯函数、确定性、无 I/O。
type SlotValidator func(value string) error
var slotValidators sync.Map // name → SlotValidator
// RegisterSlotValidator 注册进程级具名校验器;spec 通过 SlotSpec.Validator 引用。
// 必须在快照构建前注册(构建期会校验名字存在)。
//
// Deprecated: 校验器应随 Registry 隔离。请改用 Options.SlotValidators。
// 全局表只作回落:构建与校验先查作用域表,查不到再查这里。
func RegisterSlotValidator(name string, fn SlotValidator) {
slotValidators.Store(name, fn)
}
// LookupSlotValidator 按名查找已注册的全局校验器。
//
// Deprecated: 请改用 Options.SlotValidators。本函数只服务全局回落。
func LookupSlotValidator(name string) (SlotValidator, bool) {
fn, ok := slotValidators.Load(name)
if !ok {
return nil, false
}
v, ok := fn.(SlotValidator)
return v, ok
}
// resolveSlotValidator 先查作用域表,查不到再回落已弃用的全局表。
func resolveSlotValidator(scoped map[string]SlotValidator, name string) (SlotValidator, bool) {
if fn, ok := scoped[name]; ok && fn != nil {
return fn, true
}
return LookupSlotValidator(name)
}
// 内置归一化步骤:trim / lower / upper。
var builtinNormalizers = map[string]func(string) string{
"trim": strings.TrimSpace,
"lower": strings.ToLower,
"upper": strings.ToUpper,
}
func normalizeSlotValue(v string, ops []string) string {
for _, op := range ops {
if fn, ok := builtinNormalizers[op]; ok {
v = fn(v)
}
}
return v
}
// compiledSlot 编译后的槽位抽取与校验产物。
type compiledSlot struct {
key string
required bool
label string
question string
re *regexp.Regexp
group int
enumSet map[string]bool
enum []string
formatRe *regexp.Regexp
normalize []string
validator SlotValidator
}
// validate 对归一化后的值做确定性校验;返回(原因, 是否合法)。
func (s *compiledSlot) validate(v string) (string, bool) {
if s.formatRe != nil && !s.formatRe.MatchString(v) {
return "format mismatch: " + s.formatRe.String(), false
}
if s.enumSet != nil && !s.enumSet[v] {
return "value not in enum", false
}
if s.validator != nil {
if err := s.validator(v); err != nil {
return "validator: " + err.Error(), false
}
}
return "", true
}
// slotCapture 一次槽位抽取的捕获区间。多槽位各自第一次匹配;区间重叠时
// 两边都标 span overlap,不写入 slots。库内不做重叠消解。
type slotCapture struct {
key string
start int
end int
value string
ok bool
reason string
required bool
matched bool
}
// extractSlots 抽取并归一化槽位。
// missing:必填槽位未捕获(模式没匹配上);invalid:捕获到但校验不合法,或与另一槽位区间重叠(key→原因)。
// 必填且非法只进 invalid(宿主可区分"没给"与"给错了"来决定澄清话术)。
func (cs *compiledSpec) extractSlots(input string) (slots map[string]string, missing []string, invalid map[string]string) {
if len(cs.slots) == 0 {
return nil, nil, nil
}
caps := make([]slotCapture, 0, len(cs.slots))
for _, s := range cs.slots {
loc := s.re.FindStringSubmatchIndex(input)
g := s.group
if loc == nil || 2*g+1 >= len(loc) || loc[2*g] < 0 || loc[2*g] == loc[2*g+1] {
caps = append(caps, slotCapture{key: s.key, required: s.required})
continue
}
raw := input[loc[2*g]:loc[2*g+1]]
v := normalizeSlotValue(raw, s.normalize)
reason, ok := s.validate(v)
caps = append(caps, slotCapture{
key: s.key, start: loc[2*g], end: loc[2*g+1], value: v,
ok: ok, reason: reason, required: s.required, matched: true,
})
}
overlap := map[int]bool{}
for i := range caps {
if !caps[i].matched {
continue
}
for j := i + 1; j < len(caps); j++ {
if !caps[j].matched {
continue
}
if caps[i].start < caps[j].end && caps[j].start < caps[i].end {
overlap[i] = true
overlap[j] = true
}
}
}
for i, c := range caps {
if !c.matched {
if c.required {
missing = append(missing, c.key)
}
continue
}
if overlap[i] {
if invalid == nil {
invalid = map[string]string{}
}
invalid[c.key] = "span overlap"
continue
}
if !c.ok {
if invalid == nil {
invalid = map[string]string{}
}
invalid[c.key] = c.reason
continue
}
if slots == nil {
slots = map[string]string{}
}
slots[c.key] = c.value
}
return slots, missing, invalid
}
// validateProvidedSlots 校验调用方直接给出的槽位表(生成器路径)。
// 与 extractSlots 同款结果形态:归一化 → format/enum/validator;必填缺失进 missing;
// 捕获到但不合法进 invalid;未知键进 invalid(reason=unknown slot key)。
// 非法或未知键的值不写入 slots。
func (cs *compiledSpec) validateProvidedSlots(provided map[string]string) (slots map[string]string, missing []string, invalid map[string]string) {
byKey := make(map[string]*compiledSlot, len(cs.slots))
for i := range cs.slots {
byKey[cs.slots[i].key] = &cs.slots[i]
}
for k, raw := range provided {
s, ok := byKey[k]
if !ok {
if invalid == nil {
invalid = map[string]string{}
}
invalid[k] = "unknown slot key"
continue
}
v := normalizeSlotValue(raw, s.normalize)
reason, valid := s.validate(v)
if !valid {
if invalid == nil {
invalid = map[string]string{}
}
invalid[k] = reason
continue
}
if slots == nil {
slots = map[string]string{}
}
slots[k] = v
}
for _, s := range cs.slots {
if !s.required {
continue
}
if _, ok := slots[s.key]; ok {
continue
}
// 必填且已标 invalid 的不重复进 missing(与 extractSlots 同款)。
if invalid != nil {
if _, bad := invalid[s.key]; bad {
continue
}
}
missing = append(missing, s.key)
}
return slots, missing, invalid
}
func compileSlot(spec SlotSpec, scoped map[string]SlotValidator) (compiledSlot, error) {
re, err := regexp.Compile(spec.Pattern)
if err != nil {
return compiledSlot{}, newError(InvalidSpec, "slot pattern 编译失败",
"field", "slots", "key", spec.Key, "error", err.Error())
}
cs := compiledSlot{
key: spec.Key,
required: spec.Required,
label: spec.Label,
question: spec.Question,
re: re,
group: effectiveGroup(spec.Group),
normalize: append([]string(nil), spec.Normalize...),
}
if len(spec.Enum) > 0 {
cs.enum = append([]string(nil), spec.Enum...)
cs.enumSet = make(map[string]bool, len(spec.Enum))
for _, e := range spec.Enum {
cs.enumSet[e] = true
}
}
if spec.Format != "" {
fre, err := regexp.Compile(spec.Format)
if err != nil {
return compiledSlot{}, newError(InvalidSpec, "slot format 编译失败",
"field", "slots", "key", spec.Key, "format", spec.Format, "error", err.Error())
}
cs.formatRe = fre
}
if spec.Validator != "" {
fn, ok := resolveSlotValidator(scoped, spec.Validator)
if !ok {
return compiledSlot{}, newError(InvalidSpec, "slot validator 未注册",
"field", "slots", "key", spec.Key, "validator", spec.Validator,
"hint", "在 Options.SlotValidators 登记,或调用已弃用的 intent.RegisterSlotValidator")
}
cs.validator = fn
}
return cs, nil
}
+219
View File
@@ -0,0 +1,219 @@
package intent
import (
"context"
"errors"
"strings"
"testing"
)
func slotSpecWith(overrides ...func(*IntentSpec)) IntentSpec {
spec := IntentSpec{
ID: "demo.run.status_query", Domain: "demo", Version: 1,
Matcher: MatchSpec{IncludesAny: []string{"进度"}},
}
for _, f := range overrides {
f(&spec)
}
return spec
}
func TestSlotNormalizeAndValidateChain(t *testing.T) {
RegisterSlotValidator("runid", func(v string) error {
if !strings.HasPrefix(v, "run-") {
return errors.New("须以 run- 开头")
}
return nil
})
b := NewRegistryBuilder(Options{})
if err := b.Register(slotSpecWith(func(s *IntentSpec) {
s.Slots = []SlotSpec{{
Key: "run_id", Required: true,
Pattern: "(?:run|任务)[:# ]*([A-Za-z0-9-]+)", Group: 1,
Normalize: []string{"trim", "lower"},
Validator: "runid",
}}
})); err != nil {
t.Fatal(err)
}
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
rec := NewRecognizer(Options{}, nil, nil)
// 归一化 + 校验通过:大写、带空白 → trim+lower 后通过自定义校验。
res, _ := rec.Recognize(context.Background(), "查询任务 RUN-42 的进度", reg.Snapshot())
if res.Top == nil || res.Top.Slots["run_id"] != "run-42" {
t.Fatalf("normalized slot: %+v", res.Top)
}
if len(res.Top.InvalidSlots) != 0 || len(res.Top.MissingRequiredSlots) != 0 {
t.Fatalf("unexpected missing/invalid: %+v", res.Top)
}
// 捕获到但校验不合法:只进 InvalidSlots,不进 Missing。
res, _ = rec.Recognize(context.Background(), "查询任务 42 的进度", reg.Snapshot())
if res.Top == nil {
t.Fatal("expected match")
}
if res.Top.InvalidSlots["run_id"] == "" {
t.Fatalf("expected invalid slot with reason, got %+v", res.Top.InvalidSlots)
}
if len(res.Top.MissingRequiredSlots) != 0 {
t.Fatalf("invalid must not duplicate into missing: %+v", res.Top.MissingRequiredSlots)
}
if res.Top.Slots["run_id"] != "" {
t.Fatalf("invalid slot must not be filled: %+v", res.Top.Slots)
}
}
func TestScopedSlotValidatorsDoNotInterfere(t *testing.T) {
spec := slotSpecWith(func(s *IntentSpec) {
s.Slots = []SlotSpec{{
Key: "run_id", Required: true,
Pattern: `(?:run|任务)[:# ]*([A-Za-z0-9-]+)`, Group: 1,
Validator: "shared",
}}
})
reject := SlotValidator(func(v string) error {
if v == "42" {
return errors.New("rejected by a")
}
return nil
})
accept := SlotValidator(func(string) error { return nil })
build := func(fn SlotValidator) *Snapshot {
t.Helper()
b := NewRegistryBuilder(Options{SlotValidators: map[string]SlotValidator{"shared": fn}})
if err := b.Register(spec); err != nil {
t.Fatal(err)
}
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
return reg.Snapshot()
}
snapA := build(reject)
snapB := build(accept)
rec := NewRecognizer(Options{}, nil, nil)
resA, _ := rec.Recognize(context.Background(), "查询任务 42 的进度", snapA)
resB, _ := rec.Recognize(context.Background(), "查询任务 42 的进度", snapB)
if resA.Top == nil || resA.Top.InvalidSlots["run_id"] == "" {
t.Fatalf("registry A should reject 42: %+v", resA.Top)
}
if resB.Top == nil || resB.Top.Slots["run_id"] != "42" {
t.Fatalf("registry B should accept 42: %+v", resB.Top)
}
if len(resB.Top.InvalidSlots) != 0 {
t.Fatalf("registry B invalid: %+v", resB.Top.InvalidSlots)
}
}
func TestSlotEnumAndFormat(t *testing.T) {
b := NewRegistryBuilder(Options{})
if err := b.Register(slotSpecWith(func(s *IntentSpec) {
s.Slots = []SlotSpec{
{Key: "env", Pattern: "环境[: ]*(\\w+)", Group: 1, Enum: []string{"prod", "staging"}},
{Key: "date", Pattern: "日期[: ]*(\\S+)", Group: 1, Format: `^\d{4}-\d{2}-\d{2}$`},
}
})); err != nil {
t.Fatal(err)
}
reg, _ := b.Build()
rec := NewRecognizer(Options{}, nil, nil)
res, _ := rec.Recognize(context.Background(), "查进度 环境: prod 日期: 2026-09-29", reg.Snapshot())
if res.Top.InvalidSlots != nil && len(res.Top.InvalidSlots) != 0 {
t.Fatalf("valid values flagged: %+v", res.Top.InvalidSlots)
}
if res.Top.Slots["env"] != "prod" || res.Top.Slots["date"] != "2026-09-29" {
t.Fatalf("slots: %+v", res.Top.Slots)
}
res, _ = rec.Recognize(context.Background(), "查进度 环境: dev 日期: 明天", reg.Snapshot())
if res.Top.InvalidSlots["env"] != "value not in enum" {
t.Fatalf("enum check: %+v", res.Top.InvalidSlots)
}
if !strings.Contains(res.Top.InvalidSlots["date"], "format mismatch") {
t.Fatalf("format check: %+v", res.Top.InvalidSlots)
}
}
func TestSlotSpecValidationRejections(t *testing.T) {
lim := Options{}.normalized().limits()
cases := []struct {
name string
slot SlotSpec
field string
}{
{"bad_format", SlotSpec{Key: "k", Pattern: "(x)", Format: "["}, "slots[0].format"},
{"bad_normalize", SlotSpec{Key: "k", Pattern: "(x)", Normalize: []string{"camel"}}, "slots[0].normalize"},
{"unregistered_validator", SlotSpec{Key: "k", Pattern: "(x)", Validator: "nope"}, "slots[0].validator"},
{"empty_enum", SlotSpec{Key: "k", Pattern: "(x)", Enum: []string{" "}}, "slots[0].enum"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
spec := slotSpecWith(func(s *IntentSpec) { s.Slots = []SlotSpec{tc.slot} })
err := validateSpec(spec, lim, nil)
var e *Error
if !errors.As(err, &e) || e.Code != InvalidSpec {
t.Fatalf("expected invalid_spec, got %v", err)
}
if got, _ := e.Fields["field"].(string); got != tc.field {
t.Fatalf("field %q, got %q (%v)", tc.field, got, e.Fields)
}
})
}
}
func TestCatalogExcludesGuardAndIncludesSlots(t *testing.T) {
b := NewRegistryBuilder(Options{GuardDomains: []string{"guard"}})
if err := b.Register(
IntentSpec{ID: "guard.config_change", Domain: "guard", Version: 1, Matcher: MatchSpec{IncludesAny: []string{"网关"}}},
slotSpecWith(func(s *IntentSpec) {
s.Description = "查询任务运行进度"
s.Handler = "taskrun.query_status"
s.Slots = []SlotSpec{{
Key: "run_id", Required: true, Pattern: "(run-\\d+)",
Label: "运行编号", Question: "要查询哪个运行?请提供 run 编号",
}}
}),
); err != nil {
t.Fatal(err)
}
reg, _ := b.Build()
snap := reg.Snapshot()
catalog := snap.Catalog()
if len(catalog) != 1 || catalog[0].ID != "demo.run.status_query" {
t.Fatalf("catalog must exclude guard specs: %+v", catalog)
}
cs := catalog[0]
if cs.Handler != "taskrun.query_status" || cs.Risk != RiskLow || len(cs.Slots) != 1 {
t.Fatalf("catalog spec: %+v", cs)
}
if cs.Slots[0].Label != "运行编号" || cs.Slots[0].Question == "" || !cs.Slots[0].Required {
t.Fatalf("catalog slot: %+v", cs.Slots[0])
}
md := snap.CatalogMarkdown()
for _, want := range []string{"demo.run.status_query", "查询任务运行进度", "必填", "运行编号", "提问建议:要查询哪个运行"} {
if !strings.Contains(md, want) {
t.Fatalf("markdown missing %q:\n%s", want, md)
}
}
if strings.Contains(md, "guard.config_change") {
t.Fatalf("markdown must exclude guard specs:\n%s", md)
}
}
func TestCatalogEmpty(t *testing.T) {
reg, _ := NewRegistryBuilder(Options{}).Build()
if got := reg.Snapshot().Catalog(); len(got) != 0 {
t.Fatalf("expected empty catalog, got %+v", got)
}
if md := reg.Snapshot().CatalogMarkdown(); !strings.Contains(md, "目录为空") {
t.Fatalf("empty markdown: %q", md)
}
}
+59 -1
View File
@@ -18,6 +18,8 @@ const (
DefaultBase = 0.7
DefaultPatternBonus = 0.2
DefaultThreshold = 0.6
// DefaultSemanticThreshold 语义重排默认过阈值。
DefaultSemanticThreshold = 0.8
)
// MatchSpec 声明式匹配规则。
@@ -25,20 +27,38 @@ const (
// 命中条件:excludes_any 全不命中 && includes_all 全命中 &&(includes_any 至少一词命中
// 或任一 pattern 命中;仅配置 includes_all 时以全命中为准)。
// case_sensitive 只作用于 includes/excludes 词表;pattern 自行用 (?i) 表达大小写。
// boundary 为真时 includes/excludes 词表走词界匹配(命中两侧不得与词条边缘
// 同类:汉字对汉字、词字符对词字符),用于压住中文子串误命中(I3);默认 false 保持旧行为。
type MatchSpec struct {
IncludesAny []string `json:"includes_any,omitempty"`
IncludesAll []string `json:"includes_all,omitempty"`
ExcludesAny []string `json:"excludes_any,omitempty"`
Patterns []string `json:"patterns,omitempty"`
CaseSensitive bool `json:"case_sensitive,omitempty"`
Boundary bool `json:"boundary,omitempty"`
}
// SlotSpec 槽位抽取规则。group 未填默认取 1(第一个捕获组)。
// label/question 供宿主做 HITL 提问与目录展示;enum/format/normalize/validator
// 构成确定性校验链:抽取 → 归一化 → 校验,校验失败进 Candidate.InvalidSlots。
type SlotSpec struct {
Key string `json:"key"`
Required bool `json:"required,omitempty"`
Pattern string `json:"pattern"`
Group int `json:"group,omitempty"`
// Label 槽位的人类可读名(目录/HITL 展示用)。
Label string `json:"label,omitempty"`
// Question 建议的澄清提问(HITL 生成用),如"要查询哪个运行?请提供 run 编号"。
Question string `json:"question,omitempty"`
// Enum 合法值枚举(归一化后校验)。
Enum []string `json:"enum,omitempty"`
// Format 整段匹配正则(校验,区别于 Pattern 的抽取用途)。
Format string `json:"format,omitempty"`
// Normalize 归一化步骤,按序应用:trim | lower | upper。
Normalize []string `json:"normalize,omitempty"`
// Validator 槽位校验器名。优先解析 Options.SlotValidators,查不到再回落
// 已弃用的全局表(RegisterSlotValidator)。
Validator string `json:"validator,omitempty"`
}
// ConfidenceSpec 置信度参数。Base 初始分;PatternBonus 模式命中加成;
@@ -65,6 +85,13 @@ type IntentSpec struct {
RequiresConfirm bool `json:"requires_confirm,omitempty"`
Handler string `json:"handler,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
// Action 为显式动作声明(I1 WP2 四键统一的首选来源):
// 声明后优先于 Handler 与 Metadata["blueprint"] 被 ActionRef 采用。
// kind 必须是 command/workflow/blueprint/handler 之一。
Action *ActionRef `json:"action,omitempty"`
// Examples 示例句(I2 语义端口):歧义时语义打分器按它们路由
// (semantic-router 借鉴;向量模型由宿主注入,库不做 I/O)。
Examples []string `json:"examples,omitempty"`
}
var (
@@ -81,7 +108,8 @@ type limits struct {
}
// validateSpec 校验单条 spec 的结构合法性,错误可定位到字段。limits 为零值时跳过数量上限。
func validateSpec(spec IntentSpec, lim limits) error {
// scoped 为 Options.SlotValidators;查不到名字时回落已弃用的全局表。
func validateSpec(spec IntentSpec, lim limits, scoped map[string]SlotValidator) error {
if spec.Domain == "" {
return newError(InvalidSpec, "domain 为空", "field", "domain")
}
@@ -112,6 +140,16 @@ func validateSpec(spec IntentSpec, lim limits) error {
default:
return newError(InvalidSpec, "risk 只能是 low/medium/high 或留空", "field", "risk", "risk", spec.Risk)
}
if spec.Action != nil {
switch spec.Action.Kind {
case ActionCommand, ActionWorkflow, ActionBlueprint, ActionHandler:
default:
return newError(InvalidSpec, "action.kind 必须是 command/workflow/blueprint/handler", "field", "action.kind", "action.kind", spec.Action.Kind)
}
if strings.TrimSpace(spec.Action.ID) == "" {
return newError(InvalidSpec, "action.id 为空", "field", "action.id", "id", spec.ID)
}
}
m := spec.Matcher
for _, terms := range []struct {
@@ -151,6 +189,26 @@ func validateSpec(spec IntentSpec, lim limits) error {
if g < 1 || g > re.NumSubexp() {
return newError(InvalidSpec, "slot group 超出捕获组范围", "field", fmt.Sprintf("slots[%d].group", i), "key", s.Key, "group", s.Group, "groups", re.NumSubexp())
}
for _, e := range s.Enum {
if strings.TrimSpace(e) == "" {
return newError(InvalidSpec, "slot enum 含空值", "field", fmt.Sprintf("slots[%d].enum", i), "key", s.Key)
}
}
if s.Format != "" {
if _, err := regexp.Compile(s.Format); err != nil {
return newError(InvalidSpec, "slot format 编译失败", "field", fmt.Sprintf("slots[%d].format", i), "key", s.Key, "error", err.Error())
}
}
for _, op := range s.Normalize {
if _, ok := builtinNormalizers[op]; !ok {
return newError(InvalidSpec, "slot normalize 步骤不合法(trim|lower|upper)", "field", fmt.Sprintf("slots[%d].normalize", i), "key", s.Key, "op", op)
}
}
if s.Validator != "" {
if _, ok := resolveSlotValidator(scoped, s.Validator); !ok {
return newError(InvalidSpec, "slot validator 未注册", "field", fmt.Sprintf("slots[%d].validator", i), "key", s.Key, "validator", s.Validator, "hint", "在 Options.SlotValidators 登记,或调用已弃用的 intent.RegisterSlotValidator")
}
}
}
c := spec.Confidence
+4 -4
View File
@@ -16,14 +16,14 @@ func validSpec() IntentSpec {
func TestValidateSpecAcceptsValid(t *testing.T) {
lim := Options{}.normalized().limits()
if err := validateSpec(validSpec(), lim); err != nil {
if err := validateSpec(validSpec(), lim, nil); err != nil {
t.Fatalf("valid spec rejected: %v", err)
}
}
func TestValidateSpecDefaultsNoLimits(t *testing.T) {
// 零值 limits 跳过数量上限(供意图较少的宿主直接校验)。
if err := validateSpec(validSpec(), limits{}); err != nil {
if err := validateSpec(validSpec(), limits{}, nil); err != nil {
t.Fatalf("valid spec rejected without limits: %v", err)
}
}
@@ -73,7 +73,7 @@ func TestValidateSpecRejections(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
spec := validSpec()
tc.mut(&spec)
err := validateSpec(spec, lim)
err := validateSpec(spec, lim, nil)
if err == nil {
t.Fatalf("expected rejection")
}
@@ -96,7 +96,7 @@ func TestValidateSpecIncludesAllOnly(t *testing.T) {
// 仅 includes_all 也是合法正向条件。
spec := validSpec()
spec.Matcher = MatchSpec{IncludesAll: []string{"任务", "进度"}}
if err := validateSpec(spec, Options{}.normalized().limits()); err != nil {
if err := validateSpec(spec, Options{}.normalized().limits(), nil); err != nil {
t.Fatalf("includes_all-only spec rejected: %v", err)
}
}
+92 -11
View File
@@ -1,14 +1,95 @@
{
"cases": [
{"name": "status_with_slot", "input": "查询任务 42 的进度", "expect_branch": "matched", "expect_top": "demo.run.status_query", "expect_slots": {"run_id": "42"}},
{"name": "status_term_only", "input": "帮我看下进度", "expect_branch": "matched", "expect_top": "demo.run.status_query"},
{"name": "guard_config_query", "input": "查询系统配置", "expect_branch": "guard_hit", "expect_top": "guard.config_change"},
{"name": "negated_write_readonly", "input": "怎么配置查询进度", "expect_branch": "fallback"},
{"name": "restart_task", "input": "把任务重启一下", "expect_branch": "matched", "expect_top": "demo.run.restart"},
{"name": "greet_ambiguous", "input": "你好呀", "expect_branch": "ambiguous"},
{"name": "cancel_missing_required_slot", "input": "取消一下", "expect_branch": "matched", "expect_top": "demo.run.cancel", "expect_missing_slots": ["run_id"]},
{"name": "case_fold_deploy", "input": "please DEPLOY it", "expect_branch": "matched", "expect_top": "demo.misc.deploy"},
{"name": "casual_fallback", "input": "今天天气不错", "expect_branch": "fallback"},
{"name": "blank_fallback", "input": " ", "expect_branch": "fallback"}
{
"name": "status_with_slot",
"input": "查询任务 42 的进度",
"expect_branch": "matched",
"expect_top": "demo.run.status_query",
"expect_slots": {
"run_id": "42"
}
},
{
"name": "status_term_only",
"input": "帮我看下进度",
"expect_branch": "matched",
"expect_top": "demo.run.status_query"
},
{
"name": "guard_config_query",
"input": "查询系统配置",
"expect_branch": "guard_hit",
"expect_top": "guard.config_change"
},
{
"name": "negated_write_readonly",
"input": "怎么配置查询进度",
"expect_branch": "fallback"
},
{
"name": "restart_task",
"input": "把任务重启一下",
"expect_branch": "matched",
"expect_top": "demo.run.restart"
},
{
"name": "greet_tiebreak_priority",
"input": "你好呀",
"expect_branch": "matched",
"expect_top": "demo.greet.hello"
},
{
"name": "cancel_missing_required_slot",
"input": "取消一下",
"expect_branch": "matched",
"expect_top": "demo.run.cancel",
"expect_missing_slots": [
"run_id"
]
},
{
"name": "case_fold_deploy",
"input": "please DEPLOY it",
"expect_branch": "matched",
"expect_top": "demo.misc.deploy"
},
{
"name": "casual_fallback",
"input": "今天天气不错",
"expect_branch": "fallback"
},
{
"name": "blank_fallback",
"input": " ",
"expect_branch": "fallback"
},
{
"name": "tie_ambiguous",
"input": "打结",
"expect_branch": "ambiguous"
}
],
"guard_domains": [
"guard"
],
"protected_domains": [
"guard"
],
"builtin_specs": [
{
"id": "guard.config_change",
"domain": "guard",
"version": 1,
"priority": 1000,
"matcher": {
"includes_any": [
"系统配置",
"网关",
"改密码"
]
},
"risk": "high",
"handler": "refuse.and_guide"
}
]
}
}
+238
View File
@@ -0,0 +1,238 @@
{
"cases": [
{
"name": "report_fill_slot_two_turns",
"turns": [
{
"input": "帮我生成报告",
"expect_branch": "matched",
"expect_top": "report.generate",
"expect_commands": [
{
"kind": "start",
"action_kind": "handler",
"action_id": "reportHandler"
},
{
"kind": "clarify",
"missing": [
"topic"
],
"question": "要查询哪个主题?"
}
]
},
{
"input": "生成报告,主题是电机",
"expect_branch": "expectation_hit",
"expect_top": "report.generate",
"expect_commands": [
{
"kind": "set_slot",
"name": "topic",
"value": "电机"
},
{
"kind": "start",
"action_kind": "handler",
"action_id": "reportHandler",
"slots": {
"topic": "电机"
}
}
]
}
]
},
{
"name": "ambiguous_then_tiebreak_match",
"turns": [
{
"input": "打结",
"expect_branch": "ambiguous",
"expect_commands": [
{
"kind": "clarify",
"candidates": [
"demo.tie.alpha",
"demo.tie.beta"
]
}
]
},
{
"input": "你好呀",
"expect_branch": "matched",
"expect_top": "demo.greet.hello"
}
]
},
{
"name": "single_matched_full_slots",
"turns": [
{
"input": "查询任务 42 的进度",
"expect_branch": "matched",
"expect_top": "demo.run.status_query",
"expect_commands": [
{
"kind": "start",
"action_kind": "handler",
"action_id": "taskrun.query_status",
"slots": {
"run_id": "42"
}
}
]
}
]
},
{
"name": "single_fallback",
"turns": [
{
"input": "天气不错",
"expect_branch": "fallback",
"expect_commands": []
}
]
},
{
"name": "single_ambiguous_tie",
"turns": [
{
"input": "打结",
"expect_branch": "ambiguous",
"expect_commands": [
{
"kind": "clarify",
"candidates": [
"demo.tie.alpha",
"demo.tie.beta"
]
}
]
}
]
},
{
"name": "single_tiebreak_priority",
"turns": [
{
"input": "你好呀",
"expect_branch": "matched",
"expect_top": "demo.greet.hello"
}
]
},
{
"name": "single_matched_handler_action",
"turns": [
{
"input": "查询任务 7 的进度",
"expect_branch": "matched",
"expect_top": "demo.run.status_query",
"expect_commands": [
{
"kind": "start",
"action_kind": "handler",
"action_id": "taskrun.query_status"
}
]
}
]
},
{
"name": "two_turn_complete_then_fallback",
"turns": [
{
"input": "生成报告,主题是电机",
"expect_branch": "matched",
"expect_top": "report.generate",
"expect_commands": [
{
"kind": "start",
"action_kind": "handler",
"action_id": "reportHandler",
"slots": {
"topic": "电机"
}
}
]
},
{
"input": "天气不错",
"expect_branch": "fallback",
"expect_commands": []
}
]
},
{
"name": "two_turn_missing_then_other_intent",
"turns": [
{
"input": "帮我生成报告",
"expect_branch": "matched",
"expect_top": "report.generate"
},
{
"input": "查询任务 42 的进度",
"expect_branch": "matched",
"expect_top": "demo.run.status_query"
}
]
},
{
"name": "single_clarify_missing_with_question",
"turns": [
{
"input": "帮我生成报告",
"expect_branch": "matched",
"expect_top": "report.generate",
"expect_commands": [
{
"kind": "start"
},
{
"kind": "clarify",
"missing": [
"topic"
],
"question": "要查询哪个主题?"
}
]
}
]
},
{
"name": "two_turn_set_slot_pump",
"turns": [
{
"input": "帮我生成报告",
"expect_branch": "matched",
"expect_top": "report.generate"
},
{
"input": "生成报告,主题是水泵",
"expect_branch": "expectation_hit",
"expect_top": "report.generate",
"expect_commands": [
{
"kind": "set_slot",
"name": "topic",
"value": "水泵"
},
{
"kind": "start",
"action_kind": "handler",
"action_id": "reportHandler",
"slots": {
"topic": "水泵"
}
}
]
}
]
}
]
}
+103 -13
View File
@@ -6,24 +6,49 @@
"version": 1,
"description": "查询任务运行进度",
"matcher": {
"includes_any": ["进度", "状态"],
"excludes_any": ["配置"]
"includes_any": [
"进度",
"状态"
],
"excludes_any": [
"配置"
]
},
"slots": [
{"key": "run_id", "required": false, "pattern": "(?:run|任务)[:# ]*([A-Za-z0-9-]+)", "group": 1}
{
"key": "run_id",
"required": false,
"pattern": "(?:run|任务)[:# ]*([A-Za-z0-9-]+)",
"group": 1
}
],
"confidence": {"base": 0.8, "threshold": 0.7},
"confidence": {
"base": 0.8,
"threshold": 0.7
},
"risk": "low",
"handler": "taskrun.query_status",
"metadata": {"channel": "dialogue"}
"metadata": {
"channel": "dialogue"
}
},
{
"id": "demo.run.restart",
"domain": "demo",
"version": 1,
"description": "重启任务",
"matcher": {"includes_any": ["重启"], "includes_all": ["任务"]},
"confidence": {"base": 0.75, "threshold": 0.7},
"matcher": {
"includes_any": [
"重启"
],
"includes_all": [
"任务"
]
},
"confidence": {
"base": 0.75,
"threshold": 0.7
},
"risk": "medium",
"requires_confirm": true,
"handler": "taskrun.restart"
@@ -33,9 +58,18 @@
"domain": "demo",
"version": 1,
"description": "取消任务",
"matcher": {"includes_any": ["取消"]},
"matcher": {
"includes_any": [
"取消"
]
},
"slots": [
{"key": "run_id", "required": true, "pattern": "(?:run|任务)[:# ]*([A-Za-z0-9-]+)", "group": 1}
{
"key": "run_id",
"required": true,
"pattern": "(?:run|任务)[:# ]*([A-Za-z0-9-]+)",
"group": 1
}
],
"handler": "taskrun.cancel"
},
@@ -43,23 +77,79 @@
"id": "demo.greet.hello",
"domain": "demo",
"version": 1,
"matcher": {"includes_any": ["你好"]},
"matcher": {
"includes_any": [
"你好"
]
},
"priority": 10
},
{
"id": "demo.greet.hi",
"domain": "demo",
"version": 1,
"matcher": {"includes_any": ["你好"]},
"matcher": {
"includes_any": [
"你好"
]
},
"priority": 5
},
{
"id": "demo.misc.deploy",
"domain": "demo",
"version": 1,
"matcher": {"includes_any": ["deploy", "发布"], "case_sensitive": false},
"matcher": {
"includes_any": [
"deploy",
"发布"
],
"case_sensitive": false
},
"handler": "deploy.run"
},
{
"id": "demo.tie.alpha",
"domain": "demo",
"version": 1,
"matcher": {
"includes_any": [
"打结"
]
},
"priority": 5
},
{
"id": "demo.tie.beta",
"domain": "demo",
"version": 1,
"matcher": {
"includes_any": [
"打结"
]
},
"priority": 5
},
{
"id": "report.generate",
"domain": "report",
"version": 1,
"matcher": {
"includes_any": [
"生成报告"
]
},
"slots": [
{
"key": "topic",
"required": true,
"pattern": "主题是(.+)",
"question": "要查询哪个主题?",
"label": "主题"
}
],
"handler": "reportHandler"
}
],
"disable": []
}
}
+381
View File
@@ -0,0 +1,381 @@
package intent
import (
"context"
"math"
"testing"
)
// TestU5RegisterMutationDoesNotAffectSnapshot:登记后改原 spec 不影响 SpecByID/Catalog。
func TestU5RegisterMutationDoesNotAffectSnapshot(t *testing.T) {
spec := IntentSpec{
ID: "demo.iso.reg", Domain: "demo", Version: 1,
Matcher: MatchSpec{IncludesAny: []string{"隔离登记"}},
Risk: RiskHigh,
RequiresConfirm: true,
Handler: "iso.handler",
Examples: []string{"示例甲"},
Action: &ActionRef{Kind: ActionWorkflow, ID: "wf-iso"},
Slots: []SlotSpec{{
Key: "env", Pattern: `env[: ]*(\w+)`, Group: 1,
Enum: []string{"prod", "dev"}, Normalize: []string{"trim"},
}},
Metadata: map[string]string{"guidance": "keep", "token": "secret"},
}
b := NewRegistryBuilder(Options{ProtectedDomains: []string{"demo"}, GuardDomains: []string{"guard"}})
if err := b.Register(spec); err != nil {
t.Fatal(err)
}
// 登记后篡改原对象与嵌套可变字段。
spec.Risk = RiskLow
spec.RequiresConfirm = false
spec.Handler = "tampered"
spec.Examples[0] = "被改"
spec.Action.ID = "wf-tampered"
spec.Slots[0].Enum[0] = "evil"
spec.Metadata["guidance"] = "mutated"
spec.Matcher.IncludesAny[0] = "被改词"
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
snap := reg.Snapshot()
got, ok := snap.SpecByID("demo.iso.reg")
if !ok {
t.Fatal("missing spec")
}
if got.Risk != RiskHigh || !got.RequiresConfirm || got.Handler != "iso.handler" {
t.Fatalf("register isolation broken: %+v", got)
}
if got.Examples[0] != "示例甲" || got.Action == nil || got.Action.ID != "wf-iso" {
t.Fatalf("nested isolation broken: examples=%v action=%+v", got.Examples, got.Action)
}
if got.Slots[0].Enum[0] != "prod" || got.Metadata["guidance"] != "keep" {
t.Fatalf("slot/meta isolation broken: %+v", got)
}
cat := snap.Catalog()
if len(cat) != 1 || cat[0].Handler != "iso.handler" || cat[0].Slots[0].Enum[0] != "prod" {
t.Fatalf("catalog isolation after register mutate: %+v", cat)
}
opts := reg.Options()
opts.ProtectedDomains[0] = "hacked"
opts2 := reg.Options()
if opts2.ProtectedDomains[0] != "demo" {
t.Fatalf("Options isolation broken: %v", opts2.ProtectedDomains)
}
}
// TestU5ReturnedSpecMutationDoesNotAffectRegistry:改返回值不影响快照。
func TestU5ReturnedSpecMutationDoesNotAffectRegistry(t *testing.T) {
b := NewRegistryBuilder(Options{})
if err := b.Register(IntentSpec{
ID: "demo.iso.ret", Domain: "demo", Version: 1,
Matcher: MatchSpec{IncludesAny: []string{"隔离返回"}, Patterns: []string{`隔离返回`}},
Examples: []string{"例"},
Action: &ActionRef{Kind: ActionCommand, ID: "cmd.iso"},
Slots: []SlotSpec{{Key: "x", Pattern: `(x)`, Enum: []string{"a", "b"}}},
Metadata: map[string]string{"guidance": "g"},
}); err != nil {
t.Fatal(err)
}
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
snap := reg.Snapshot()
got, _ := snap.SpecByID("demo.iso.ret")
got.Examples[0] = "mut"
got.Action.ID = "cmd.mut"
got.Slots[0].Enum[0] = "z"
got.Metadata["guidance"] = "mut"
got.Matcher.Patterns[0] = "mut"
again, _ := snap.SpecByID("demo.iso.ret")
if again.Examples[0] != "例" || again.Action.ID != "cmd.iso" || again.Slots[0].Enum[0] != "a" {
t.Fatalf("SpecByID leak: %+v", again)
}
if again.Metadata["guidance"] != "g" || again.Matcher.Patterns[0] != `隔离返回` {
t.Fatalf("SpecByID matcher/meta leak: %+v", again)
}
cat := snap.Catalog()
cat[0].Slots[0].Enum[0] = "z"
cat[0].Metadata["guidance"] = "mut"
cat2 := snap.Catalog()
if cat2[0].Slots[0].Enum[0] != "a" || cat2[0].Metadata["guidance"] != "g" {
t.Fatalf("Catalog leak: %+v", cat2)
}
}
// TestU5GeneratedMatchesRuleValidationAndMetadata:同 action+slots 时生成路径与规则路径校验/元数据一致。
func TestU5GeneratedMatchesRuleValidationAndMetadata(t *testing.T) {
spec := IntentSpec{
ID: "report.generate", Domain: "report", Version: 1,
Matcher: MatchSpec{IncludesAny: []string{"生成报告"}},
Slots: []SlotSpec{{
Key: "topic", Required: true, Pattern: `主题是(.+)`, Group: 1,
Enum: []string{"电机", "泵"}, Normalize: []string{"trim"},
}},
Risk: RiskMedium,
RequiresConfirm: true,
Handler: "reportHandler",
Action: &ActionRef{Kind: ActionWorkflow, ID: "wf-report"},
Metadata: map[string]string{"guidance": "ask topic", "blueprint": "bp"},
}
b := NewRegistryBuilder(Options{})
if err := b.Register(spec); err != nil {
t.Fatal(err)
}
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
snap := reg.Snapshot()
rec := NewRecognizer(Options{}, nil, nil)
rule, err := rec.Recognize(context.Background(), "生成报告 主题是电机", snap)
if err != nil {
t.Fatal(err)
}
if rule.Branch != BranchMatched || rule.Top == nil {
t.Fatalf("rule: %+v", rule)
}
gen := stubGenerator{cmds: []Command{
{Kind: CommandStart, Action: ActionRef{Kind: ActionWorkflow, ID: "wf-report"},
Slots: map[string]string{"topic": "电机"}},
}}
genRec := NewRecognizer(Options{Generator: gen}, nil, nil)
out, err := genRec.Recognize(context.Background(), "完全不相关的一句话", snap)
if err != nil {
t.Fatal(err)
}
if out.Branch != BranchMatched || out.Top == nil {
t.Fatalf("generated: %+v", out)
}
if out.Top.ID != rule.Top.ID || out.Top.Risk != rule.Top.Risk || out.Top.RequiresConfirm != rule.Top.RequiresConfirm {
t.Fatalf("metadata mismatch rule=%+v gen=%+v", rule.Top, out.Top)
}
if out.Top.Handler != rule.Top.Handler {
t.Fatalf("handler mismatch rule=%q gen=%q", rule.Top.Handler, out.Top.Handler)
}
if out.Top.Action == nil || rule.Top.Action == nil || out.Top.Action.Kind != rule.Top.Action.Kind || out.Top.Action.ID != rule.Top.Action.ID {
t.Fatalf("action mismatch rule=%+v gen=%+v", rule.Top.Action, out.Top.Action)
}
if out.Top.Slots["topic"] != rule.Top.Slots["topic"] {
t.Fatalf("slots mismatch rule=%v gen=%v", rule.Top.Slots, out.Top.Slots)
}
if len(out.Top.MissingRequiredSlots) != 0 || len(out.Top.InvalidSlots) != 0 {
t.Fatalf("unexpected missing/invalid: %+v", out.Top)
}
}
// TestU5GeneratedSlotValidationStable:缺必填/非法枚举/未知键/重复 handler 在生成路径稳定处理。
func TestU5GeneratedSlotValidationStable(t *testing.T) {
mk := func(t *testing.T, specs ...IntentSpec) *Snapshot {
t.Helper()
b := NewRegistryBuilder(Options{})
if err := b.Register(specs...); err != nil {
t.Fatal(err)
}
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
return reg.Snapshot()
}
base := IntentSpec{
ID: "report.generate", Domain: "report", Version: 1,
Matcher: MatchSpec{IncludesAny: []string{"生成报告"}},
Slots: []SlotSpec{{
Key: "topic", Required: true, Pattern: `主题是(.+)`,
Enum: []string{"电机", "泵"},
}},
Risk: RiskHigh, RequiresConfirm: true, Handler: "reportHandler",
Action: &ActionRef{Kind: ActionCommand, ID: "cmd.report"},
}
t.Run("missing_required", func(t *testing.T) {
snap := mk(t, base)
rec := NewRecognizer(Options{Generator: stubGenerator{cmds: []Command{
{Kind: CommandStart, Action: ActionRef{Kind: ActionCommand, ID: "cmd.report"}, Slots: map[string]string{}},
}}}, nil, nil)
res, err := rec.Recognize(context.Background(), "无关输入", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchMatched || res.Top == nil {
t.Fatalf("want matched with missing: %+v", res)
}
if len(res.Top.MissingRequiredSlots) != 1 || res.Top.MissingRequiredSlots[0] != "topic" {
t.Fatalf("missing: %+v", res.Top.MissingRequiredSlots)
}
if res.Top.Risk != RiskHigh || !res.Top.RequiresConfirm {
t.Fatalf("metadata lost: %+v", res.Top)
}
})
t.Run("invalid_enum", func(t *testing.T) {
snap := mk(t, base)
rec := NewRecognizer(Options{Generator: stubGenerator{cmds: []Command{
{Kind: CommandStart, Action: ActionRef{Kind: ActionHandler, ID: "reportHandler"},
Slots: map[string]string{"topic": "涡轮"}},
}}}, nil, nil)
res, err := rec.Recognize(context.Background(), "无关输入", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchMatched || res.Top == nil {
t.Fatalf("want matched with invalid: %+v", res)
}
if res.Top.InvalidSlots["topic"] == "" {
t.Fatalf("invalid slots: %+v", res.Top.InvalidSlots)
}
if res.Top.Slots["topic"] != "" {
t.Fatalf("invalid value must not fill slots: %+v", res.Top.Slots)
}
})
t.Run("unknown_slot", func(t *testing.T) {
snap := mk(t, base)
rec := NewRecognizer(Options{Generator: stubGenerator{cmds: []Command{
{Kind: CommandStart, Action: ActionRef{Kind: ActionWorkflow, ID: "report.generate"},
Slots: map[string]string{"bogus": "v"}},
}}}, nil, nil)
res, err := rec.Recognize(context.Background(), "无关输入", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchFallback || res.Top != nil {
t.Fatalf("unknown slot must fallback: %+v", res)
}
})
t.Run("duplicate_handler", func(t *testing.T) {
a := base
a.ID = "report.a"
a.Handler = "shared"
bSpec := base
bSpec.ID = "report.b"
bSpec.Handler = "shared"
bSpec.Action = &ActionRef{Kind: ActionCommand, ID: "cmd.b"}
snap := mk(t, a, bSpec)
rec := NewRecognizer(Options{Generator: stubGenerator{cmds: []Command{
{Kind: CommandStart, Action: ActionRef{Kind: ActionHandler, ID: "shared"},
Slots: map[string]string{"topic": "电机"}},
}}}, nil, nil)
res, err := rec.Recognize(context.Background(), "无关输入", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchFallback || res.Top != nil {
t.Fatalf("ambiguous handler must fallback: %+v", res)
}
})
}
// TestU5GeneratedActionAliasRoutesToSpec:Action 指向不同 command ID 时仍解析到权威 spec。
func TestU5GeneratedActionAliasRoutesToSpec(t *testing.T) {
b := NewRegistryBuilder(Options{})
if err := b.Register(IntentSpec{
ID: "report.generate", Domain: "report", Version: 1,
Matcher: MatchSpec{IncludesAny: []string{"生成报告"}},
Slots: []SlotSpec{{Key: "topic", Pattern: `(.+)`}},
Action: &ActionRef{Kind: ActionCommand, ID: "cmd.other-id"},
Handler: "h",
Risk: RiskLow,
}); err != nil {
t.Fatal(err)
}
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
rec := NewRecognizer(Options{Generator: stubGenerator{cmds: []Command{
{Kind: CommandStart, Action: ActionRef{Kind: ActionCommand, ID: "cmd.other-id"},
Slots: map[string]string{"topic": "x"}},
}}}, nil, nil)
res, err := rec.Recognize(context.Background(), "无关输入", reg.Snapshot())
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "report.generate" {
t.Fatalf("alias resolve: %+v", res)
}
if res.Top.Action == nil || res.Top.Action.ID != "cmd.other-id" {
t.Fatalf("action metadata: %+v", res.Top.Action)
}
}
// TestU5ClassifierAndSemanticRejectBadScores:非有限/越界分数不得变成 matched。
func TestU5ClassifierAndSemanticRejectBadScores(t *testing.T) {
snap := ambiguousFixture(t, []string{"查运行进度"}, []string{"闲聊问候"})
t.Run("classifier_inf", func(t *testing.T) {
rec := NewRecognizer(Options{}, nil, rewriteClassifier{fn: func(cands []Candidate) []Candidate {
out := append([]Candidate(nil), cands...)
out[0].Confidence = math.Inf(1)
out[1].Confidence = 0.1
return out
}})
res, err := rec.Recognize(context.Background(), "模糊词", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchAmbiguous || res.Top != nil {
t.Fatalf("inf must stay ambiguous: %+v", res)
}
})
t.Run("classifier_in_place_mutation", func(t *testing.T) {
rec := NewRecognizer(Options{}, nil, rewriteClassifier{fn: func(cands []Candidate) []Candidate {
cands[0].Handler = "tampered"
cands[0].Confidence = 0.99
cands[1].Confidence = 0.1
return cands
}})
res, err := rec.Recognize(context.Background(), "模糊词", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchMatched || res.Top == nil {
t.Fatalf("expected matched: %+v", res)
}
if res.Top.Handler == "tampered" {
t.Fatalf("classifier in-place mutation leaked into top: %+v", res.Top)
}
})
t.Run("semantic_nan", func(t *testing.T) {
rec := NewRecognizer(Options{Semantic: fakeScorer{scores: map[string]float64{"查运行进度": math.NaN()}}}, nil, nil)
res, err := rec.Recognize(context.Background(), "模糊词", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchAmbiguous || res.Top != nil {
t.Fatalf("nan semantic must stay ambiguous: %+v", res)
}
})
t.Run("semantic_above_one", func(t *testing.T) {
rec := NewRecognizer(Options{Semantic: fakeScorer{scores: map[string]float64{"查运行进度": 1.5}}}, nil, nil)
res, err := rec.Recognize(context.Background(), "模糊词", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchAmbiguous || res.Top != nil {
t.Fatalf("out-of-range semantic must stay ambiguous: %+v", res)
}
})
t.Run("semantic_inf_not_matched", func(t *testing.T) {
rec := NewRecognizer(Options{Semantic: fakeScorer{scores: map[string]float64{"查运行进度": math.Inf(1)}}}, nil, nil)
res, err := rec.Recognize(context.Background(), "模糊词", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchAmbiguous || res.Top != nil {
t.Fatalf("inf semantic must stay ambiguous: %+v", res)
}
})
}
+36 -27
View File
@@ -1,44 +1,53 @@
# 通用规范采纳记录
- 项目/负责人:Agent-Intent / 仓库所有者 weichen
- 项目/负责人:Agent-Intent / weichen
- 状态:active
- 规范位置:`deps/development-guidelines`
- 规范性文档目录:`policy/`
- 过程文档位置:`docs/records/`(项目内固定目录豁免。当前为个人维护的基础库,单独过程仓会让每份记录都变成跨仓 gitlink;任务主记录仍在 Gitea Issue/PR。豁免一经登记,过程记录不得再写入 `policy/` 或规范库。)
- 版本权威:父仓 gitlink;可读标签:无(内容版本 `0.1.0-draft.6`,提交 `26b562d2e34fbc560711296a309c91c74ffc55b1`)
- 批准/生效依据:本仓接入提交;草案审阅完成后随该提交生效
- 替代范围和迁移说明:本仓为新建仓库,无既往规则需要迁移。
- 规范性文档目录:`policy/`(项目规则与制度放此处)
- 过程文档位置:`.task/` 存放任务卡与交付证据(模板 `.task/templates/task-card.md`,本次任务卡 `.task/feature-governance-adoption.md`);`docs/records/` 存放其他过程记录,为既有项目内豁免目录,保持不变(理由见 PROJECT.md「仓库与操作」节与下方「项目差异」);任务主记录在 PR 描述与 Gitea Issue/PR。
- 版本权威:本仓 gitlink deps/development-guidelines @ 2688fd2be6a5(内容版本 `0.1.0-draft.12`;可读标签:无)
- 批准/生效依据:feature/governance-adoption PR(Agent-Intent)
- 替代范围和迁移说明:本次替代此前的 Guidelines gitlink `26b562d2e34f`(内容版本 `0.1.0-draft.6`),固定为 `2688fd2be6a5`。新增 `AGENT-EXECUTION.md` 执行路由、`deps/development-manager` 治理 gitlink(@ `bd3a7de57a3a`)、`scripts/dm`、`scripts/agent-exec`、`scripts/task`、`scripts/hooks/pre-commit`、`.gitea/workflows/governance.yml`。本仓无既往 Agent 执行路由需迁移。
| 规范/工作流 | 适用条件与范围 | 采纳或不适用理由 | 执行方式/证据 |
|---|---|---|---|
| core/baseline.md | 日常工作 | 所有改动都要守硬边界 | manual |
| workflows/delivery.md、debugging.md、handoff.md | 交付、排障与交接 | 单人维护仍需要可交接的任务记录 | manual |
| standards/collaboration.md | 并行共同起点、节点同步与陈旧候选拒绝 | 本仓会被 Agent-Squad / Agent-Native 以 gitlink 消费 | manual |
| standards/verification.md | 验证与结论 | 仓库根 `go vet` / `go test`(含 `-race`) | manual;命令见 PROJECT.md |
| workflows/design-verification.md、standards/testability.md、standards/failure-containment.md | 识别漏斗、配置合并与快照切换 | 识别必须可脱离外部 I/O 独立验证;配置失败保留上一快照 | manual |
| standards/module-contracts.md | intent/conformance/catalogcheck/examples 边界 | 公开端口按层拆分;扩展点只留接口 | manual |
| standards/observability.md | 错误码、识别事件日志 | 与 Error-Code-System / Logging-System 对齐 | manual |
| standards/configuration.md | spec/overlay 双源合并、快照 revision、生效时机与诊断 | 本仓核心就是配置驱动模块;check/explain 由 `intentctl` 承担 | manual |
| standards/multi-repo.md | submodule 与固定组合 | 规范与基础库以 gitlink 锁定 | manual |
| standards/security.md | 凭证、配置信任边界 | 配置文件属宿主可信输入;本仓不含凭证 | manual |
| standards/documentation.md | 三层文档 | `policy/` 与 `docs/records/` 已分开 | manual |
| standards/data-reliability.md | 本仓自有数据库 | 无数据库;快照替换的原子性由并发测试覆盖 | 不适用 |
| standards/agent-execution.md | Agent 执行契约 | 本仓不做执行,只做 LLM 前的确定性识别 | 不适用 |
| standards/release-operations.md | 独立发布流水线 | 当前无制品仓库与部署入口 | 不适用 |
| workflows/automation.md | 定时与后台任务 | 本仓无 daemon | 不适用 |
| core/baseline.md | 日常工作 | 所有改动守硬边界;集成分支不直接提交,经 feature 分支与 PR 合入 | manual;`scripts/hooks/pre-commit` 本地拒绝直提 `main` 与非法分支名(需 `core.hooksPath` 启用,由人执行);未验证(尚无直提与非法分支名的负例记录) |
| workflows/delivery.md、debugging.md、handoff.md | 交付、排障与交接 | 单人维护仍需要可交接的任务记录 | manual;任务卡 `.task/`,主记录在 PR |
| standards/collaboration.md | 并行共同起点、节点同步与陈旧候选拒绝 | 本仓被 Agent-Squad / Agent-Native 以 gitlink 消费,需固定共同基线 | manual;合入前在 `main` 上 `git fetch` 并确认 HEAD |
| standards/merge-queue.md | 多个就绪任务的最终同步、CI、Review 与合入调度 | 不适用:当前为单维护者,无多任务同时合入的调度需求;出现并行合入时复查 | 不适用 |
| standards/agent-execution.md | 工程 Agent CLI 执行路由、会话隔离与受控集成 | 凡 AI 协助实施均经执行路由:配置 `AGENT-EXECUTION.md`,执行器入口 `scripts/agent-exec`(validate / run / apply / admit / cleanup) | advisory(CI 运行 `scripts/agent-exec validate` 与 `scripts/dm governance check`,分支保护尚未设为必需检查;设为必需检查并有阻断负例后再登记 enforced);证据:governance check + 正负例(.task/feature-governance-adoption.md) |
| standards/verification.md | 验证与结论 | 仓库根 `go vet` / `go test`(含 `-race`)为正式验证 | 命令见 PROJECT.md「验证命令」;CI `.gitea/workflows/ci.yml` |
| workflows/design-verification.md、standards/testability.md、standards/failure-containment.md | 识别漏斗、配置合并与快照切换的设计/验证、模块测试与故障控制 | 识别必须可脱离外部 I/O 独立验证;配置失败保留上一快照 | manual;快照并发测试(`-race`) |
| standards/module-contracts.md | intent / conformance / catalogcheck / examples 的边界与公开契约 | 公开端口按层拆分;扩展点只留接口 | manual |
| standards/observability.md | 错误码、识别事件日志 | 与 Error-Code-System / Logging-System 对齐 | manual;`catalogcheck` 测试 |
| standards/configuration.md | spec/overlay 双源合并、快照 revision、生效时机与诊断 | 本仓核心即配置驱动模块;诊断由 `intentctl` 承担 | manual;`intentctl lint` / `explain` |
| standards/multi-repo.md | submodule 与固定组合 | 规范、执行器与基础库均以 gitlink 锁定;不执行 `submodule update --remote` | manual;`scripts/submodule-consistency.sh`;`scripts/dm governance check` |
| standards/security.md | 凭证、配置信任边界 | 配置文件属宿主可信输入;本仓不保存凭证 | manual |
| standards/documentation.md | 三层文档(入口、规范性文档、过程记录) | `policy/` 为规范性文档;任务卡与交付证据放 `.task/`,其他过程记录按既有豁免放 `docs/records/` | manual;见下方「项目差异」 |
| standards/tool-adaptation.md | 新工具/执行器适配 | 适用:仓库配置 Agent CLI profile(AGENT-EXECUTION.md)、pre-commit 钩子与 Gitea CI 任务,逐项登记见下方「工具适配矩阵」 | 适用,见下方矩阵 |
| standards/data-reliability.md | 本仓自有数据库 | 不适用:无数据库;快照替换的原子性由并发测试覆盖 | 不适用 |
| standards/release-operations.md | 独立发布流水线 | 不适用:当前无制品仓库与部署入口;可构建性以 `main` 的 `go test` 为准 | 不适用 |
| workflows/automation.md | 定时与后台任务 | 不适用:本仓无 daemon | 不适用 |
### 工具适配矩阵(standards/tool-adaptation.md)
| 要求 | 级别 | 机制 | 正例证据 | 负例证据 |
|---|---|---|---|---|
| (a) 本地 pre-commit 准入 `scripts/agent-exec admit` | advisory(本地钩子,需 `core.hooksPath` 启用,同 OS 用户可绕过,不是门禁) | `scripts/hooks/pre-commit` 调用 `scripts/agent-exec admit` | SELF:adoption cases 的 positive-run-apply 通过(`.task/evidence/governance-adoption/02-20261017T073820Z.log`) | SELF:bad-receipt、out-of-scope-path 被拒绝(同上日志;记录见 `.task/feature-governance-adoption.md`) |
| (b) governance CI(`.gitea/workflows/governance.yml`:`agent-exec validate` 与 `dm governance check`) | advisory(分支保护设为必需检查前不阻断合并) | Gitea Actions job `governance`,在 PR 与 `main` 推送时运行 | GATE:PR 头 CI(governance 与既有 ci 工作流)通过,运行链接见 .task/feature-governance-adoption.md 证据表 | 未验证 |
## 项目差异
| 原规则/章节 | 项目采用方式 | 原因与风险 | 负责人/复查条件 |
|---|---|---|---|
| documentation.md 过程仓默认独立仓库 | 过程记录放 `docs/records/` | 个人基础库;风险是记录与代码同 PR 时容易把证据写成第二权威。任务状态仍只认 Gitea Issue/PR | 出现第二名维护者或对外发布流程时复查 |
| documentation.md 过程仓默认独立仓库 | 其他过程记录放 `docs/records/`(既有豁免) | 个人基础库;风险是记录与代码同 PR 时容易把证据写成第二权威。任务状态仍只认 Gitea Issue/PR | 出现第二名维护者或对外发布流程时复查 |
| documentation.md 过程仓默认独立仓库 | 任务卡与交付证据放 `.task/`(模板 `.task/templates/task-card.md`) | 个人基础库;任务卡与证据随代码同仓,风险是证据被写成第二个任务状态权威。任务状态仍只认 Gitea Issue/PR | 出现第二名维护者或对外发布流程时复查 |
默认做法差异说明理由;硬边界冲突须有明确有效决策,不能用此表自授权。建议不采用无需登记。
## 本次升级或首次接入验证
- 入口:根 `AGENTS.md`、`PROJECT.md` 指向 `deps/development-guidelines`。
- 子模块:`deps/development-guidelines` 固定 `26b562d2e34fbc560711296a309c91c74ffc55b1`;`deps/error-code-system` 固定 `28fa06eaa56a19906732681cc02474a87ac9d408`(含 `intent` 种子域);`deps/logging-system` 固定 `2c5716fdcffbc4696fc0371afc70f39c44174201`。三者与 Agent-Squad / Agent-Native 当前组合一致(error-code-system 为新增 intent 域后的新提交,属追加式变更)。
- `.gitmodules` 使用批准的上游 SSH URL,不含本机路径或凭证。
- 执行方式保持 manual:挂载 Markdown 不自动改变 CI。
- 回退:恢复本提交之前的 gitlink,并删除根入口与 `policy/guidelines-adoption.md` 中对应条款;无已生效的 CI 或权限变更需要另恢复。
- 入口:根 `AGENTS.md` 与 `PROJECT.md` 指向 `deps/development-guidelines`;`AGENTS.md` 新增「Agent 执行与新项目准入(强制)」段落,`AGENT-EXECUTION.md` 为执行路由唯一权威。
- 子模块:`deps/development-guidelines` 固定 `2688fd2be6a52c0bf110e7e6366b6f291ab5e4eb`;`deps/development-manager` 固定 `bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c`;`deps/error-code-system` 与 `deps/logging-system` 不变。
- 门禁:`.gitea/workflows/governance.yml` 在 PR 与 `main` 推送时运行 `scripts/agent-exec validate --repo .` 与 `scripts/dm governance check --repo .`;实际正负例与 Review/CI 证据记录在 `.task/feature-governance-adoption.md`。
- 回退:恢复 `deps/development-guidelines` gitlink 为 `26b562d2e34f`,删除 `AGENTS.md` 新增段落、`deps/development-manager` 条目、`scripts/dm`、`scripts/agent-exec`、`scripts/task`、`scripts/hooks/pre-commit`、`.gitea/workflows/governance.yml` 与 `.task/templates/task-card.md`,并将本文件状态改回 proposed。分支保护与规范 tag 由用户设置,回退时同步撤销。
+1
View File
@@ -0,0 +1 @@
{"id":"demo.write","domain":"demo","version":1,"matcher":{"includes_any":["写"]}}
+34
View File
@@ -0,0 +1,34 @@
package schema
import (
"encoding/json"
"os"
"testing"
"agent-intent/intent"
)
func TestIntentSpecRoundTrip(t *testing.T) {
raw, err := os.ReadFile("intentspec.sample.json")
if err != nil {
t.Fatal(err)
}
var spec intent.IntentSpec
if err := json.Unmarshal(raw, &spec); err != nil {
t.Fatal(err)
}
if spec.ID != "demo.write" || spec.Matcher.IncludesAny[0] != "写" {
t.Fatalf("%+v", spec)
}
back, err := json.Marshal(spec)
if err != nil {
t.Fatal(err)
}
var again intent.IntentSpec
if err := json.Unmarshal(back, &again); err != nil {
t.Fatal(err)
}
if again.ID != spec.ID || again.Version != 1 {
t.Fatalf("%+v", again)
}
}
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
# 共享 Agent 执行器薄入口:转调 scripts/dm 的 agent-exec 子命令(validate / run / apply / admit / cleanup)。
set -euo pipefail
exec "$(dirname "$0")/dm" agent-exec "$@"
Executable
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# 共享 dm-server 薄入口:读取 deps/development-manager 的 gitlink 钉住提交,
# 按该提交构建并缓存 dm-server,再 exec 其子命令(agent-exec、task、governance)。执行语义与退出码以该二进制为准。
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
dm="$root/deps/development-manager"
die() { echo "dm: $*" >&2; exit 2; }
# git 钩子会导出仓库本地变量(GIT_DIR、GIT_INDEX_FILE 等),它们会让 `git -C "$dm"` 解析到外层仓库。
# 所有针对 DM 检出的 git 与 go 调用都在清除这些变量后执行;读取外层 gitlink 与最终 exec 仍保留继承环境。
dm_local_env="GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_PREFIX GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_GRAFT_FILE GIT_NAMESPACE GIT_CONFIG GIT_CONFIG_PARAMETERS GIT_CONFIG_COUNT GIT_SHALLOW_FILE GIT_IMPLICIT_WORK_TREE $(git rev-parse --local-env-vars 2>/dev/null || true)"
dm_clean_env() { for v in $dm_local_env; do unset "$v"; done; }
dmgit() ( dm_clean_env; git -C "$dm" "$@" )
# 1. 钉住版本只来自索引中的 gitlink(mode 160000),不读取工作区文件。
entry=$(git -C "$root" ls-files -s -- deps/development-manager 2>/dev/null || true)
[ -n "$entry" ] || die "索引中缺少 deps/development-manager 的 gitlink,无法确定钉住的 DM 版本"
read -r mode pin _stage _path <<<"$entry"
[ "$mode" = 160000 ] || die "deps/development-manager 不是 gitlink(mode=$mode)"
[[ "$pin" =~ ^[0-9a-f]{40}$ ]] || die "gitlink 提交格式无效:$pin"
# 2. 检出必须存在且确为 DM 独立仓库(避免 git -C 误解析到外层仓库),HEAD 必须等于钉住提交。
[ -e "$dm/.git" ] || die "deps/development-manager 未检出;请按钉住提交 $pin 检出该 gitlink"
top=$(dmgit rev-parse --show-toplevel 2>/dev/null || true)
[ -n "$top" ] && [ "$top" = "$(cd "$dm" && pwd -P)" ] \
|| die "deps/development-manager 不是独立检出;请按钉住提交 $pin 检出该 gitlink"
head=$(dmgit rev-parse HEAD 2>/dev/null || true)
[ "$head" = "$pin" ] \
|| die "deps/development-manager HEAD(${head:-未知})与钉住提交 $pin 不一致;请按钉住提交检出"
# 3. 检出工作树必须干净(忽略嵌套 submodule 状态)。
dirty=$(dmgit status --porcelain --ignore-submodules=all) \
|| die "无法读取 deps/development-manager 的工作树状态"
[ -z "$dirty" ] || die "deps/development-manager 存在未提交改动,拒绝运行(请先清理该检出)"
# 4. 二进制按钉住提交缓存;缺失时在缓存目录内临时构建,完成后原子改名,避免留下半成品。
cache="${DM_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/development-manager/dm-server}"
bin="$cache/dm-server-$pin"
if [ ! -x "$bin" ]; then
command -v go >/dev/null 2>&1 || die "缺少 go,无法构建 dm-server($pin)"
mkdir -p "$cache"
cache=$(cd "$cache" && pwd -P)
bin="$cache/dm-server-$pin"
work=$(mktemp -d "$cache/build-$pin.XXXXXX")
if ! (dm_clean_env; cd "$dm" && GOWORK=off go build -trimpath -o "$work/dm-server" ./cmd/dm-server); then
rm -rf "$work"
die "构建 dm-server 失败($pin)"
fi
mv -f "$work/dm-server" "$bin"
rm -rf "$work"
fi
exec "$bin" "$@"
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# 本地检查(pre-commit):集成分支直提拒绝 + 分支命名 + 本地完整性核对。权威准入在 PR(baseline.md:26)。
branch=$(git rev-parse --abbrev-ref HEAD)
case "$branch" in
main|master)
echo "pre-commit: 拒绝——集成分支 ${branch} 不得直接提交,走 feature 分支 + PR(collaboration.md)" >&2
exit 1 ;;
esac
echo "$branch" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || {
echo "pre-commit: 拒绝——分支名须为 feature|fix|hotfix|release/<slug>,当前:${branch}" >&2
exit 1
}
root=$(git rev-parse --show-toplevel)
# admit 只是本地完整性/篡改证据(SELF):同一 OS 用户可读取本地 HMAC 密钥并绕过本钩子,
# 它不是 GATE,也不是可信身份。缺失或无效的本地记录仍拒绝;交付信任来自精确 HEAD 的 CI 或独立 REPLAY。
"$root/scripts/agent-exec" admit --repo "$root"
Executable
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
# 通用任务入口:转调 scripts/dm 的 task 子命令(triage / start / pr-check)。
set -euo pipefail
exec "$(dirname "$0")/dm" task "$@"