Author SHA1 Message Date
weichen 28833bcfca Integration: endpoint migration into the recall-eval stack 2026-10-23 04:26:39 +00:00
agent-exec-transport 7f3ff6a187 Make intent matching negation-aware and slots fail closed
Why: the A3 baseline exposed 6 mis-executions - semantic negations
(不要/别/不用/先不要 + action term) matched as positive intents - and
3 invalid-slot cases (multi-candidate values, charset violations,
symbols-only) that started without clarification.
Evidence: recall corpus mis_exec now 0; no regression in
expired_confirmation, generation_consistency or overall top1
(verify-misexec.json, exit=0).
Impact: compiledSpec.evaluate per-hit negation check; adjudicate
specificity tie-break; slot extraction multi-value/charset invalidity.
Refs: feature/recall-misexec-fixes
2026-10-23 03:55:47 +00:00
weichen 8b54508964 Merge pull request 'Update Gitea endpoints after server migration' (#13) from fix/gitea-endpoint-migration into main
ci / docker-checkout-hostgateway (push) Successful in 1s
governance / governance (push) Successful in 20s
ci / vet-and-test (push) Successful in 43s
2026-10-22 14:43:36 +00:00
weichen 61ad3caac4 Bump migrated dependency pins for the endpoint migration
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
governance / governance (pull_request) Successful in 39s
ci / vet-and-test (pull_request) Successful in 42s
Why: recursive submodule checkout reads pinned dep commits; their
.gitmodules now carries the new Gitea endpoint base only in the
migrated mains.

Evidence: host-verified; see Dev PR #706

Impact: pin updates only, part of the coordinated endpoint migration.

Refs: Agent-System-Dev PR #706
2026-10-22 14:36:11 +00:00
weichen ebbd81621a Bump the development-manager pin for the endpoint migration
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
ci / vet-and-test (pull_request) Failing after 8s
governance / governance (pull_request) Successful in 27s
Why: governance checks validate candidate .gitmodules URLs against the
pinned development-manager scaffold, which now carries the new Gitea
endpoint base after its own migration.

Evidence: host-verified; see Dev PR #706

Impact: pin update only, part of the coordinated endpoint migration.

Refs: Agent-System-Dev PR #706
2026-10-22 14:26:08 +00:00
weichen 1f37a9b438 Update Gitea endpoints after server migration
ci / docker-checkout-hostgateway (pull_request) Successful in 2s
ci / vet-and-test (pull_request) Failing after 6s
governance / governance (pull_request) Failing after 24s
Why: the Gitea server moved to https://gitea.naitangbaobao.cn with SSH
on port 52222; endpoint references must follow or superproject CI
checkout refuses the old URLs.

Evidence: host-verified API reach and SSH auth (Dev task card
fix/gitea-endpoint-migration)

Impact: configuration only; no runtime behavior change.

Refs: Dev PR #706
2026-10-22 14:17:17 +00:00
20 changed files with 534 additions and 71 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
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 config --global url."https://weichen:${SUBMODULE_TOKEN}@gitea.naitangbaobao.cn/".insteadOf "ssh://git@gitea.naitangbaobao.cn:52222/"
git -c fetch.recurseSubmodules=false submodule update --init --recursive
- name: Install Go 1.27.1
run: |
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
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/"
SSH = "ssh://git@gitea.naitangbaobao.cn:52222/weichen/"
# 审查过的白名单,独立于候选 .gitmodules:仅列出两个治理 gitlink。
REPOSITORIES = {
"deps/development-guidelines": "Development-Guidelines",
+4 -4
View File
@@ -1,12 +1,12 @@
[submodule "deps/development-guidelines"]
path = deps/development-guidelines
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Guidelines.git
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Development-Guidelines.git
[submodule "deps/error-code-system"]
path = deps/error-code-system
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Error-Code-System.git
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Error-Code-System.git
[submodule "deps/logging-system"]
path = deps/logging-system
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Logging-System.git
url = ssh://git@gitea.naitangbaobao.cn:52222/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
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Development-Manager.git
+3 -3
View File
@@ -23,9 +23,9 @@
历史文档与旧链接里的 `git-panel.u1680832.nyat.app:30319` 已失效(TLS 握手失败),
见到一律按过期处理,不要再试。当前权威通道:
- **Web / API(HTTPS)**:`https://git-home.u1680832.nyat.app:30319`(Gitea 1.27.3,
- **Web / API(HTTPS)**:`https://gitea.naitangbaobao.cn`(Gitea 1.27.3,
API 前缀 `/api/v1`;偶发 broken pipe / 超时,重试即可)。
- **Git over SSH**:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/<Repo>.git`
- **Git over SSH**:`ssh://git@gitea.naitangbaobao.cn:52222/weichen/<Repo>.git`
(各仓 origin 与 gitlink 均为此格式)。
- **API token**:`~/.secrets/gitea`,两行键值 `URL_PREFIX=…` 与
`ACCESS_TOKEN=<40 位 hex>`;取值 `grep ACCESS_TOKEN ~/.secrets/gitea | cut -d= -f2-`,
@@ -33,7 +33,7 @@
(baseline 硬边界 2:只记录来源、字段与可用状态)。
- **SSH key**:`~/.ssh/home_gitea`(ED25519,公钥指纹
`SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20`,Gitea 侧密钥名 GMKtec);
`~/.ssh/config` 已有 `git-ssh.u1680832.nyat.app` 主机条目,克隆/推送直接可用。
`~/.ssh/config` 已有 `gitea.naitangbaobao.cn(端口 52222)` 主机条目,克隆/推送直接可用。
## Agent 执行与新项目准入(强制)
+2 -2
View File
@@ -6,7 +6,7 @@
- 目标/产品边界:通用意图识别基础库。提供意图定义(spec)、注册表与配置合并(registry/loader)、四级漏斗识别器(guard → expectation → rules → adjudicate)、槽位抽取与诊断(conformance/catalogcheck/intentctl)。不做动作执行、不内置 LLM 客户端、不含任何业务意图内容,不依赖 Agent-Native / Agent-Squad / Agent-System。
- 源码、资产、文档、机器契约位置:Go 源码在仓库根包目录;模块路径 `agent-intent`;人类文档在根 `README.md`、`docs/`、`policy/`;过程记录:任务卡与交付证据在 `.task/`,其他过程记录在 `docs/records/`;机器契约以 `intent` 包公开类型为准。
- 权威远端、集成基线、受保护分支:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Intent.git`,集成基线 `main`。
- 权威远端、集成基线、受保护分支:`ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Intent.git`,集成基线 `main`。
- 并行共同基线记录位置、合入通知/主动检查方式:任务主记录在 Gitea Issue/PR;合入前在本仓 `main` 上 `git fetch` 并确认 HEAD。
- 发布线与批准版本/实际制品查询入口:当前无独立制品仓库。可构建性以本仓 `main` 的 `go test` 为准;依赖子模块版本以 gitlink 为准,不把工作区未提交文件当发布组合。
- 提交/审核/合并/发布身份与权限:仓库所有者 weichen。本机提交不代替远端合并。
@@ -28,7 +28,7 @@
克隆:
```bash
git clone --recurse-submodules ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Intent.git
git clone --recurse-submodules ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Intent.git
# 已克隆时:git submodule update --init --recursive
```
+1 -1
View File
@@ -133,7 +133,7 @@ go run ./cmd/intentctl catalog -root configs/intents # 能力目录(注入
消费方把本库固定为 submodule,推荐路径 `deps/agent-intent`;版本以消费方 gitlink 为准,不执行 `submodule update --remote`:
```bash
git submodule add ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Intent.git deps/agent-intent
git submodule add ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Intent.git deps/agent-intent
```
```text
+2 -1
View File
@@ -80,8 +80,9 @@ func TestEvalRecordOnlyWritesMetrics(t *testing.T) {
dir := writeTemp(t, "specs.json", testSpecJSON)
tmp := t.TempDir()
corpus := filepath.Join(tmp, "corpus.json")
// 「不用查进度了」在否定修复后已正确回落,这里换成仍会误执行的输入来覆盖 record_only 计数。
doc := `{"record_only": true, "cases": [
{"name": "miss", "category": "negation_reference", "input": "不用查进度了", "expect_branch": "fallback"},
{"name": "miss", "category": "negation_reference", "input": "进度条的颜色真好看", "expect_branch": "fallback"},
{"name": "hit", "input": "天气不错", "expect_branch": "fallback"}]}`
if err := os.WriteFile(corpus, []byte(doc), 0o644); err != nil {
t.Fatal(err)
@@ -201,7 +201,7 @@ agent-intent/
- `.gitmodules`:`deps/development-guidelines`(dev-only,不进 go.mod)、`deps/error-code-system`、`deps/logging-system`(build-required)。
- **pin 与生态当前组合一致**:guidelines `26b562d2`、error-code-system `95a03b75`、logging-system `2c5716f`(实现时以 `git submodule status` 实测为准)。理由:消费方的 `submodule-consistency.sh` 校验嵌套副本 gitlink 与本仓一致,Agent-Intent 作为中间层必须与上下游同组合,否则 Squad/Native 接入时一致性校验失败。
- go.mod:`require error-code-system v0.0.0` / `require logging-system v0.0.0` + 两条 `replace => ./deps/...`;无 go.sum、测试带 `GOWORK=off`(同 Squad/Native)。
- 远端:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Intent.git`;初始化流程按 adoption 工作流第 2 节(远端需先建仓,Gitea API token 在 `~/.secrets/gitea`,实现阶段处理)。
- 远端:`ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Intent.git`;初始化流程按 adoption 工作流第 2 节(远端需先建仓,Gitea API token 在 `~/.secrets/gitea`,实现阶段处理)。
### Error-Code-System 上游变更
+16 -12
View File
@@ -47,22 +47,26 @@
|---|---|---|---|---|---|---|---|---|
| `expired_confirmation` | 7 | 7 | 14 | 11/11 = 1.0000 | 0 | 5/14 = 0.3571 | 3/14 = 0.2143 | - |
| `generation_consistency` | 8 | 8 | 8 | 7/7 = 1.0000 | 0 | 0/8 = 0.0000 | 1/8 = 0.1250 | 5/8 = 0.6250 |
| `multi_intent` | 7 | 6 | 7 | 2/3 = 0.6667 | 1 | 5/7 = 0.7143 | 0/7 = 0.0000 | - |
| `negation_reference` | 8 | 3 | 10 | 3/3 = 1.0000 | 5 | 0/10 = 0.0000 | 2/10 = 0.2000 | - |
| `wrong_slot` | 7 | 3 | 7 | 7/7 = 1.0000 | 0 | 3/7 = 0.4286 | 0/7 = 0.0000 | - |
| **overall** | 37 | 27 | 46 | 30/31 = 0.9677 | 6 | 13/46 = 0.2826 | 6/46 = 0.1304 | 5/8 = 0.6250 |
| `multi_intent` | 7 | 7 | 7 | 3/3 = 1.0000 | 0 | 5/7 = 0.7143 | 0/7 = 0.0000 | - |
| `negation_reference` | 8 | 8 | 10 | 3/3 = 1.0000 | 0 | 0/10 = 0.0000 | 7/10 = 0.7000 | - |
| `wrong_slot` | 7 | 7 | 7 | 7/7 = 1.0000 | 0 | 7/7 = 1.0000 | 0/7 = 0.0000 | - |
| **overall** | 37 | 37 | 46 | 31/31 = 1.0000 | 0 | 17/46 = 0.3696 | 11/46 = 0.2391 | 5/8 = 0.6250 |
同次运行的既有 I2 指标:precision=0.967 recall=0.967(TP=29 FP=1 FN=1)。
同次运行的既有 I2 指标:precision=1.000 recall=1.000(TP=30 FP=0 FN=0)。
首次基线(规则层修复前)为 pass 27/37、top1 30/31、mis_exec 6、clarify 13/46、fallback 6/46,
差异集中在 `negation_reference`(5 次误执行)、`multi_intent`(1 次误执行)、`wrong_slot`(4 例未澄清)。
语料未变(摘要同上),上表变化全部来自规则层代码修复。
### 记录到的差异(事实,不含判定)
- `negation_reference`:5 次误执行均为语义否定被当作肯定——「不要重启任务」「别取消任务 42」
「不用查进度了」「先不要发布」「刚才那个任务先别重启」分别 matched 到 restart/cancel/status_query/deploy/restart;
只有 `excludes_any` 声明的否定词(「配置」)被拦下。
- `multi_intent`:「你好,帮我生成报告,主题是电机」按 priority 平分裁决到 `demo.greet.hello`(1 次误执行);
两个动作并列的输入均进入 ambiguous 澄清。
- `wrong_slot`:两个候选值(「任务 42 和任务 43」「任务 42 还是任务 43」)、越出字符集(`abc_def` 抽成 `abc`)、
仅符号(`--`)都直接 start、未澄清;缺失与中文数字正确进入 clarify。
- `negation_reference`:「不要重启任务」「别取消任务 42」「不用查进度了」「先不要发布」「刚才那个任务先别重启」
均回落——词条/pattern 命中前紧邻或相隔至多 2 个字符出现否定词(不要、别、不用、先不要、先别、无需、不需要)时,
该次命中不计正向,全部命中被否定的 spec 以 `negated_match` 排除;`excludes_any`(「配置」)照旧整体排除。
- `multi_intent`:「你好,帮我生成报告,主题是电机」裁决到 `report.generate`——同分组内抽到槽位更多的候选
胜过 priority 不同但未抽到槽位的寒暄;priority 相同的两个动作并列时仍进入 ambiguous 澄清。
- `wrong_slot`:两个候选值(「任务 42 和任务 43」「任务 42 还是任务 43」)、越出字符集(`abc_def` 被截成 `abc`)、
仅符号(`--`)均判为非法槽位,产出 start + clarify;缺失与中文数字仍进入 clarify。
- `expired_confirmation`:过期后裸回答回落、完整指令走规则 matched,均与期望一致。
- `generation_consistency`:3 例不一致——生成槽位 `run_id=43` 与规则 `42` 不同、生成错动作
`taskrun.restart`(规则为 status_query)、幻觉动作 `ghost.flow` 被校验 fail-closed 后无 Top。
+19 -19
View File
@@ -4,16 +4,16 @@
"overall": {
"category": "overall",
"cases": 37,
"passed_cases": 27,
"passed_cases": 37,
"turns": 46,
"top1_expected": 31,
"top1_hits": 30,
"top1_hit_rate": 0.9677,
"mis_executions": 6,
"clarifications": 13,
"clarification_rate": 0.2826,
"fallbacks": 6,
"fallback_rate": 0.1304,
"top1_hits": 31,
"top1_hit_rate": 1,
"mis_executions": 0,
"clarifications": 17,
"clarification_rate": 0.3696,
"fallbacks": 11,
"fallback_rate": 0.2391,
"generation_cases": 8,
"generation_agree": 5,
"generation_agreement_rate": 0.625
@@ -56,12 +56,12 @@
{
"category": "multi_intent",
"cases": 7,
"passed_cases": 6,
"passed_cases": 7,
"turns": 7,
"top1_expected": 3,
"top1_hits": 2,
"top1_hit_rate": 0.6667,
"mis_executions": 1,
"top1_hits": 3,
"top1_hit_rate": 1,
"mis_executions": 0,
"clarifications": 5,
"clarification_rate": 0.7143,
"fallbacks": 0,
@@ -73,16 +73,16 @@
{
"category": "negation_reference",
"cases": 8,
"passed_cases": 3,
"passed_cases": 8,
"turns": 10,
"top1_expected": 3,
"top1_hits": 3,
"top1_hit_rate": 1,
"mis_executions": 5,
"mis_executions": 0,
"clarifications": 0,
"clarification_rate": 0,
"fallbacks": 2,
"fallback_rate": 0.2,
"fallbacks": 7,
"fallback_rate": 0.7,
"generation_cases": 0,
"generation_agree": 0,
"generation_agreement_rate": null
@@ -90,14 +90,14 @@
{
"category": "wrong_slot",
"cases": 7,
"passed_cases": 3,
"passed_cases": 7,
"turns": 7,
"top1_expected": 7,
"top1_hits": 7,
"top1_hit_rate": 1,
"mis_executions": 0,
"clarifications": 3,
"clarification_rate": 0.4286,
"clarifications": 7,
"clarification_rate": 1,
"fallbacks": 0,
"fallback_rate": 0,
"generation_cases": 0,
+9 -5
View File
@@ -16,12 +16,8 @@ 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) {
if boundedAt(haystack, i, term) {
return true
}
j := strings.Index(haystack[i+1:], term)
@@ -33,6 +29,14 @@ func boundedContains(haystack, term string) bool {
return false
}
// boundedAt 报告 haystack[i:] 处的 term 出现是否满足 boundedContains 的词界规则。
func boundedAt(haystack string, i int, term string) bool {
first, _ := utf8.DecodeRuneInString(term)
last, _ := utf8.DecodeLastRuneInString(term)
checkLeft := isWord(first) && !isHan(first) // 仅拉丁开头的词条要求左界
return (!checkLeft || leftBounded(haystack, i, first)) && rightBounded(haystack, i+len(term), last)
}
func leftBounded(s string, i int, termFirst rune) bool {
if i == 0 {
return true
+203
View File
@@ -0,0 +1,203 @@
package intent
import (
"context"
"reflect"
"testing"
)
func rejectReason(tr Trace, id string) string {
for _, r := range tr.RulesRejected {
if r.ID == id {
return r.Reason
}
}
return ""
}
func buildSingleSpecSnap(t *testing.T, opts Options, specs ...IntentSpec) *Snapshot {
t.Helper()
b := NewRegistryBuilder(opts)
if err := b.Register(specs...); err != nil {
t.Fatal(err)
}
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
return reg.Snapshot()
}
func TestNegatedHitsRejectSpec(t *testing.T) {
_, snap := buildDemoRegistry(t)
rec := NewRecognizer(Options{}, nil, nil)
cases := []struct{ input, id string }{
{"不要重启任务", "demo.run.restart"},
{"别取消任务 42", "demo.run.cancel"},
{"不用查进度了", "demo.run.status_query"},
{"先不要发布", "demo.misc.deploy"},
{"刚才那个任务先别重启", "demo.run.restart"},
{"无需取消任务 42", "demo.run.cancel"},
{"不需要查看状态", "demo.run.status_query"},
}
for _, tc := range cases {
if got := rejectReason(snap.Explain(tc.input), tc.id); got != "negated_match" {
t.Errorf("%q: %s reject = %q, want negated_match", tc.input, tc.id, got)
}
res, err := rec.Recognize(context.Background(), tc.input, snap)
if err != nil || res.Branch != BranchFallback {
t.Errorf("%q: branch = %s top = %+v err = %v, want fallback", tc.input, res.Branch, res.Top, err)
}
}
}
func TestNegationCountsUnnegatedOccurrence(t *testing.T) {
_, snap := buildDemoRegistry(t)
rec := NewRecognizer(Options{}, nil, nil)
cases := []struct{ input, id string }{
// 同一词条一次被否定、一次未被否定:未被否定的那次照常命中。
{"不要重启任务,还是重启任务吧", "demo.run.restart"},
// 否定词指向别处,与命中相隔超过窗口。
{"重启任务,不要密码", "demo.run.restart"},
{"不要密码,重启任务", "demo.run.restart"},
// 「别」在复合词里不表否定。
{"识别任务进度", "demo.run.status_query"},
{"别的进度", "demo.run.status_query"},
// 非否定输入行为不变。
{"把任务重启一下", "demo.run.restart"},
{"取消任务 42", "demo.run.cancel"},
{"查询任务 42 的进度", "demo.run.status_query"},
{"please DEPLOY it", "demo.misc.deploy"},
}
for _, tc := range cases {
res, err := rec.Recognize(context.Background(), tc.input, snap)
if err != nil || res.Branch != BranchMatched || res.Top == nil || res.Top.ID != tc.id {
t.Errorf("%q: branch = %s top = %+v err = %v, want matched %s", tc.input, res.Branch, res.Top, err, tc.id)
}
}
}
func TestNegatedPatternHit(t *testing.T) {
snap := buildSingleSpecSnap(t, Options{}, IntentSpec{
ID: "demo.run.restart_re", Domain: "demo", Version: 1,
Matcher: MatchSpec{Patterns: []string{`重启\s*任务`}},
})
if got := rejectReason(snap.Explain("别重启 任务"), "demo.run.restart_re"); got != "negated_match" {
t.Fatalf("negated pattern reject = %q", got)
}
tr := snap.Explain("先别重启任务,算了还是重启任务")
if tr.Branch != BranchMatched || tr.TopID != "demo.run.restart_re" {
t.Fatalf("un-negated pattern occurrence must match: %+v", tr)
}
}
func TestGuardIgnoresNegation(t *testing.T) {
snap := buildSingleSpecSnap(t, Options{GuardDomains: []string{"guard"}}, IntentSpec{
ID: "guard.config_change", Domain: "guard", Version: 1,
Matcher: MatchSpec{IncludesAny: []string{"改密码"}},
})
res, err := NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), "不要改密码", snap)
if err != nil || res.Branch != BranchGuardHit {
t.Fatalf("guard must still hit on negated phrase: %+v %v", res, err)
}
}
func TestTieBreakPrefersExtractedSlots(t *testing.T) {
_, snap := buildDemoRegistry(t)
rec := NewRecognizer(Options{}, nil, nil)
// 寒暄(priority 10、无槽位)与报告同分:抽到主题的报告胜出。
in := "你好,帮我生成报告,主题是电机"
res, err := rec.Recognize(context.Background(), in, snap)
if err != nil || res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "report.generate" {
t.Fatalf("greet+report: branch = %s top = %+v err = %v", res.Branch, res.Top, err)
}
if !reflect.DeepEqual(res.Top.Slots, map[string]string{"topic": "电机"}) {
t.Fatalf("slots = %+v", res.Top.Slots)
}
if tr := snap.Explain(in); tr.Branch != BranchMatched || tr.TopID != "report.generate" {
t.Fatalf("explain = %+v", tr)
}
// 同 priority 的两个动作即使槽位数不同也仍澄清;有更高 priority 的寒暄在场也不改变。
for _, in := range []string{"取消任务 42 并发布", "你好,取消任务 42 并发布"} {
res, err = rec.Recognize(context.Background(), in, snap)
if err != nil || res.Branch != BranchAmbiguous {
t.Fatalf("%q: branch = %s top = %+v, want ambiguous", in, res.Branch, res.Top)
}
}
// 槽位数相同时退回 priority(I5 不变)。
res, err = rec.Recognize(context.Background(), "你好呀", snap)
if err != nil || res.Top == nil || res.Top.ID != "demo.greet.hello" {
t.Fatalf("greet priority: %+v %v", res, err)
}
}
func TestSlotInvalidCapturesClarify(t *testing.T) {
_, snap := buildDemoRegistry(t)
rec := NewRecognizer(Options{}, nil, nil)
cases := []struct{ input, top, key, reason string }{
{"取消任务 42 和任务 43", "demo.run.cancel", "run_id", "multiple candidate values"},
{"取消任务 42 和 43", "demo.run.cancel", "run_id", "multiple candidate values"},
{"查询任务 42 还是任务 43 的进度", "demo.run.status_query", "run_id", "multiple candidate values"},
{"查询任务 abc_def 的进度", "demo.run.status_query", "run_id", "value truncated by slot charset"},
{"取消任务 --", "demo.run.cancel", "run_id", "value has no letters or digits"},
{"生成报告,主题是电机和水泵", "report.generate", "topic", "multiple candidate values"},
}
for _, tc := range cases {
res, err := rec.Recognize(context.Background(), tc.input, snap)
if err != nil || res.Branch != BranchMatched || res.Top == nil || res.Top.ID != tc.top {
t.Fatalf("%q: branch = %s top = %+v err = %v", tc.input, res.Branch, res.Top, err)
}
if got := res.Top.InvalidSlots[tc.key]; got != tc.reason {
t.Errorf("%q: invalid[%s] = %q, want %q (slots %+v)", tc.input, tc.key, got, tc.reason, res.Top.Slots)
}
if _, kept := res.Top.Slots[tc.key]; kept {
t.Errorf("%q: invalid value must not be kept: %+v", tc.input, res.Top.Slots)
}
if len(res.Commands) != 2 || res.Commands[1].Kind != CommandClarify || !reflect.DeepEqual(res.Commands[1].Missing, []string{tc.key}) {
t.Errorf("%q: commands = %+v, want start + clarify(%s)", tc.input, res.Commands, tc.key)
}
}
}
func TestSlotValidCapturesUnchanged(t *testing.T) {
_, snap := buildDemoRegistry(t)
rec := NewRecognizer(Options{}, nil, nil)
cases := []struct {
input string
slots map[string]string
}{
{"取消任务 42", map[string]string{"run_id": "42"}},
{"查询任务 42 的进度", map[string]string{"run_id": "42"}},
{"查询任务42的进度", map[string]string{"run_id": "42"}},
{"查询任务 run-7 的进度", map[string]string{"run_id": "run-7"}},
{"生成报告,主题是和平", map[string]string{"topic": "和平"}},
}
for _, tc := range cases {
res, err := rec.Recognize(context.Background(), tc.input, snap)
if err != nil || res.Top == nil || !reflect.DeepEqual(res.Top.Slots, tc.slots) || len(res.Top.InvalidSlots) != 0 {
t.Errorf("%q: top = %+v err = %v, want slots %v", tc.input, res.Top, err, tc.slots)
}
}
// 缺失与中文数字仍进 missing。
for _, in := range []string{"取消任务", "取消任务四十二"} {
res, err := rec.Recognize(context.Background(), in, snap)
if err != nil || res.Top == nil || !reflect.DeepEqual(res.Top.MissingRequiredSlots, []string{"run_id"}) {
t.Errorf("%q: top = %+v err = %v, want missing run_id", in, res.Top, err)
}
}
}
func TestSlotBlankAfterNormalizeIsMissing(t *testing.T) {
snap := buildSingleSpecSnap(t, Options{}, IntentSpec{
ID: "report.generate", Domain: "report", Version: 1,
Matcher: MatchSpec{IncludesAny: []string{"生成报告"}},
Slots: []SlotSpec{{Key: "topic", Required: true, Pattern: `主题是(.+)`, Normalize: []string{"trim"}}},
})
res, err := NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), "生成报告,主题是 ", snap)
if err != nil || res.Top == nil || !reflect.DeepEqual(res.Top.MissingRequiredSlots, []string{"topic"}) || len(res.Top.InvalidSlots) != 0 {
t.Fatalf("blank topic must be missing: %+v %v", res.Top, err)
}
}
+9 -3
View File
@@ -132,7 +132,9 @@ func TestR2OverlappingSlotsAreAmbiguous(t *testing.T) {
}
}
func TestR2NegationIsOnlyExcludesAny(t *testing.T) {
// 否定修复(negationTokens)之前,没有 excludes_any 的 spec 会把「不要写入文件」当肯定命中;
// 现在紧邻否定词的那次命中不计正向,excludes_any 仍按原语义整体排除。
func TestR2NegationTokensAndExcludesAny(t *testing.T) {
plain := IntentSpec{
ID: "demo.write.file", Domain: "demo", Version: 1,
Matcher: MatchSpec{IncludesAny: []string{"写入"}},
@@ -150,9 +152,13 @@ func TestR2NegationIsOnlyExcludesAny(t *testing.T) {
if err != nil {
t.Fatal(err)
}
hit, err := rec.Recognize(context.Background(), "不要写入文件", built.Snapshot())
negated, err := rec.Recognize(context.Background(), "不要写入文件", built.Snapshot())
if err != nil || negated.Branch != BranchFallback {
t.Fatalf("negated phrase must not match even without excludes_any: %+v %v", negated, 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)
t.Fatalf("un-negated phrase must still match: %+v %v", hit, err)
}
exBuilder := NewRegistryBuilder(Options{})
+39 -9
View File
@@ -72,7 +72,8 @@ func (r *FunnelRecognizer) recognize(ctx context.Context, input string, snap *Sn
if strings.TrimSpace(input) == "" {
return Result{Input: input, Branch: BranchFallback, SnapshotRevision: snap.rev}
}
// 否定只认 excludes_any 子串,不做语义否定;「不要写入」这类说法交给命令层 Cancel。
// 否定两处生效:excludes_any 整体排除;includes_any/pattern 命中前紧邻否定词
// (negationTokens)的那次命中不算正向命中。
matchInput, folded := snap.foldInput(input)
// Stage 0:守卫命中即短路,可表达"识别即拒绝"。
@@ -104,7 +105,7 @@ func (r *FunnelRecognizer) recognize(ctx context.Context, input string, snap *Sn
}
}
return Result{Input: input, Branch: BranchFallback, SnapshotRevision: snap.rev}
case len(hits) == 1 || hits[0].score-hits[1].score >= snap.margin || tieBreakByPriority(hits):
case len(hits) == 1 || hits[0].score-hits[1].score >= snap.margin || resolveTie(hits, input):
cands := candidatesOf(trimHits(hits, snap.topN), input, true)
top := cands[0]
return Result{Input: input, Branch: BranchMatched, Top: &top, Candidates: cands, SnapshotRevision: snap.rev}
@@ -141,14 +142,43 @@ func (r *FunnelRecognizer) recognize(ctx context.Context, input string, snap *Sn
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 {
// resolveTie 裁决与第一名同分(分差为 0)的候选组(I5 + 特异度)。
// 组内按(对 input 成功抽取的槽位数降序、Priority 降序、合并序)排名;排名第一者须
// 胜过组内每一个其余候选才直接胜出、不再标歧,此时把它移到 hits 首位并返回 true。
// a 胜过 b 当且仅当二者 Priority 不同,且 a 抽到的槽位更多、或槽位数相同而 Priority 更高:
// 寒暄(高 Priority、无槽位)让位于抽到槽位的动作;Priority 相同的两个动作仍按
// ambiguous 澄清(多意图输入),候选次序不变。分差小于 margin 但非零时仍按
// ambiguous 处理(margin 语义不变)。
func resolveTie(hits []specHit, input string) bool {
if len(hits) < 2 || hits[0].score != hits[1].score {
return false
}
return hits[0].score == hits[1].score && hits[0].cs.spec.Priority > hits[1].cs.spec.Priority
n := 2
for n < len(hits) && hits[n].score == hits[0].score {
n++
}
filled := make(map[*compiledSpec]int, n)
for _, h := range hits[:n] {
slots, _, _ := h.cs.extractSlots(input)
filled[h.cs] = len(slots)
}
ranked := append([]specHit(nil), hits[:n]...)
sort.SliceStable(ranked, func(i, j int) bool {
if a, b := filled[ranked[i].cs], filled[ranked[j].cs]; a != b {
return a > b
}
return lessHit(ranked[i], ranked[j])
})
best := ranked[0]
for _, other := range ranked[1:] {
pa, pb := best.cs.spec.Priority, other.cs.spec.Priority
fa, fb := filled[best.cs], filled[other.cs]
if pa == pb || fa < fb || (fa == fb && pa < pb) {
return false
}
}
copy(hits[:n], ranked)
return true
}
// validateRefined 要求分类器返回与原候选完全相同的 ID 集合且无重复。
@@ -279,7 +309,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 || tieBreakByPriority(hits):
case len(hits) == 1 || hits[0].score-hits[1].score >= s.margin || resolveTie(hits, input):
trace.Branch = BranchMatched
trace.TopID = hits[0].cs.spec.ID
default:
+105 -3
View File
@@ -10,6 +10,7 @@ import (
"strings"
"sync"
"sync/atomic"
"unicode/utf8"
"logging-system/logsys"
)
@@ -151,20 +152,29 @@ func (cs *compiledSpec) evaluate(input, folded string) matchOutcome {
}
}
var hitTerms, hitPat []string
positive := false
positive, negated := false, false
for _, t := range cs.termsAny {
if cs.contains(hay, t) {
switch cs.termHit(hay, t) {
case hitPositive:
positive = true
hitTerms = append(hitTerms, t)
case hitNegated:
negated = true
}
}
for i, p := range cs.patterns {
if p.MatchString(input) {
switch cs.patternHit(p, input) {
case hitPositive:
positive = true
hitPat = append(hitPat, cs.spec.Matcher.Patterns[i])
case hitNegated:
negated = true
}
}
if !positive && (len(cs.termsAny) > 0 || len(cs.patterns) > 0) {
if negated {
return matchOutcome{reject: "negated_match"}
}
return matchOutcome{reject: "no_positive_match"}
}
score := cs.base
@@ -186,6 +196,98 @@ func (cs *compiledSpec) contains(hay, term string) bool {
return strings.Contains(hay, term)
}
// negationTokens 否定词:includes_any 词条或 pattern 的某次命中前紧邻、或相隔至多
// negationWindow 个字符处出现其中之一时,该次命中视为被否定,不计入正向命中
// (「不要重启任务」「别取消任务 42」「不用查进度了」不触发对应 spec)。
// 逐次判断:同一词条只要还有一次未被否定的出现就照常命中。守卫域 spec 不做否定判断
// (守卫宁可多拦),excludes_any 与 includes_all 也不受影响。
var negationTokens = []string{"不要", "别", "不用", "先不要", "先别", "无需", "不需要"}
// negationWindow 否定词末尾与命中起点之间允许间隔的最大字符(rune)数。
const negationWindow = 2
// nonNegatingBie 含「别」但不表否定的常见词:单字否定词「别」落在这些词里时不算否定,
// 避免「识别任务进度」「别的任务进度」被误拦。
var nonNegatingBie = []string{
"区别", "特别", "识别", "类别", "级别", "分别", "个别", "告别", "差别", "性别", "辨别", "鉴别",
"别的", "别人", "别处", "别名",
}
// hitKind 一个词条或 pattern 对输入的命中情况。
type hitKind int
const (
hitNone hitKind = iota
hitNegated // 有出现,但每次出现都被否定
hitPositive // 至少一次未被否定的出现
)
// termHit 逐次检查 term 在 hay 中的出现(按 boundary 配置判词界),区分正向与被否定命中。
func (cs *compiledSpec) termHit(hay, term string) hitKind {
if term == "" {
if cs.contains(hay, term) {
return hitPositive
}
return hitNone
}
kind := hitNone
for i := strings.Index(hay, term); i >= 0; {
if !cs.spec.Matcher.Boundary || boundedAt(hay, i, term) {
if cs.guard || !negatedAt(hay, i) {
return hitPositive
}
kind = hitNegated
}
j := strings.Index(hay[i+1:], term)
if j < 0 {
break
}
i += 1 + j
}
return kind
}
// patternHit 逐个检查 pattern 在 input 中的匹配,按匹配起点判否定。
func (cs *compiledSpec) patternHit(p *regexp.Regexp, input string) hitKind {
kind := hitNone
for _, loc := range p.FindAllStringIndex(input, -1) {
if cs.guard || !negatedAt(input, loc[0]) {
return hitPositive
}
kind = hitNegated
}
return kind
}
// negatedAt 报告 s[pos:] 处的命中前方 negationWindow 个字符内是否以否定词结尾。
func negatedAt(s string, pos int) bool {
prefix := s[:pos]
for gap := 0; gap <= negationWindow; gap++ {
for _, tok := range negationTokens {
if strings.HasSuffix(prefix, tok) && !(tok == "别" && inNonNegatingBie(s, len(prefix)-len(tok))) {
return true
}
}
if prefix == "" {
return false
}
_, size := utf8.DecodeLastRuneInString(prefix)
prefix = prefix[:len(prefix)-size]
}
return false
}
// inNonNegatingBie 报告 s[at:] 处的「别」是否属于 nonNegatingBie 中的某个词。
func inNonNegatingBie(s string, at int) bool {
for _, w := range nonNegatingBie {
k := strings.Index(w, "别")
if at-k >= 0 && strings.HasPrefix(s[at-k:], w) {
return true
}
}
return false
}
func compileSpec(spec IntentSpec, guard bool, index int, scoped map[string]SlotValidator, nfkc bool) (*compiledSpec, error) {
m := spec.Matcher
fold := func(terms []string) []string {
+115 -2
View File
@@ -4,6 +4,8 @@ import (
"regexp"
"strings"
"sync"
"unicode"
"unicode/utf8"
)
// SlotValidator 宿主注册的槽位值校验器。必须纯函数、确定性、无 I/O。
@@ -101,7 +103,8 @@ type slotCapture struct {
}
// extractSlots 抽取并归一化槽位。
// missing:必填槽位未捕获(模式没匹配上);invalid:捕获到但校验不合法,或与另一槽位区间重叠(key→原因)。
// missing:必填槽位未捕获(模式没匹配上,或归一化后为空);invalid:捕获到但不可用(key→原因)——
// 校验不合法、与另一槽位区间重叠、含多个候选值、只有符号、或被槽位字符集截断(见 captureIssue)。
// 必填且非法只进 invalid(宿主可区分"没给"与"给错了"来决定澄清话术)。
func (cs *compiledSpec) extractSlots(input string) (slots map[string]string, missing []string, invalid map[string]string) {
if len(cs.slots) == 0 {
@@ -117,7 +120,14 @@ func (cs *compiledSpec) extractSlots(input string) (slots map[string]string, mis
}
raw := input[loc[2*g]:loc[2*g+1]]
v := normalizeSlotValue(raw, s.normalize)
reason, ok := s.validate(v)
if strings.TrimSpace(v) == "" {
caps = append(caps, slotCapture{key: s.key, required: s.required})
continue
}
reason, ok := s.captureIssue(input, loc, v)
if ok {
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,
@@ -167,6 +177,109 @@ func (cs *compiledSpec) extractSlots(input string) (slots map[string]string, mis
return slots, missing, invalid
}
// slotValueSeparators 多值分隔词:出现在槽位值内部(两侧都有内容),或紧跟在捕获值之后
// 且其后又能抽出同一槽位时,说明用户给了多个候选值(「任务 42 还是任务 43」)。
var slotValueSeparators = []string{"还是", "和", "、", ",", ","}
// captureIssue 检查一次捕获是否可用(在 format/enum/validator 之前);返回(原因, 是否可用)。
// loc 为槽位正则在 input 上的 FindStringSubmatchIndex 结果,v 为归一化后的值。
func (s *compiledSlot) captureIssue(input string, loc []int, v string) (string, bool) {
if captureTruncated(input, loc, s.group) {
return "value truncated by slot charset", false
}
if hasInnerSeparator(v) || s.repeatsAfter(input, loc) {
return "multiple candidate values", false
}
if !hasValueRune(v) {
return "value has no letters or digits", false
}
return "", true
}
// captureTruncated 报告捕获组是否在一个 ASCII 词中间被槽位字符集截断(`abc_def` 只抽到 `abc`)。
// 只在捕获组边界即整个匹配边界时检查:模式自身在组外继续消费的字符不算截断。
func captureTruncated(input string, loc []int, g int) bool {
start, end := loc[2*g], loc[2*g+1]
if end == loc[1] && end < len(input) {
if r, _ := utf8.DecodeRuneInString(input[end:]); isASCIIWord(r) {
return true
}
}
if start == loc[0] && start > 0 {
if r, _ := utf8.DecodeLastRuneInString(input[:start]); isASCIIWord(r) {
return true
}
}
return false
}
func isASCIIWord(r rune) bool {
return r == '_' || (r < utf8.RuneSelf && (unicode.IsLetter(r) || unicode.IsDigit(r)))
}
// hasInnerSeparator 报告 v 内部是否有两侧都非空的多值分隔词(「电机和水泵」)。
func hasInnerSeparator(v string) bool {
for _, sep := range slotValueSeparators {
for i := strings.Index(v, sep); i >= 0; {
if strings.TrimSpace(v[:i]) != "" && strings.TrimSpace(v[i+len(sep):]) != "" {
return true
}
j := strings.Index(v[i+len(sep):], sep)
if j < 0 {
break
}
i += len(sep) + j
}
}
return false
}
// repeatsAfter 报告捕获值之后是否紧跟同一槽位的另一个候选值:
// - 可选分隔词后完整重复槽位模式(「任务 42 还是任务 43」「任务 42 任务 43」);
// 无分隔词时要求模式在捕获组前有前缀,避免把「([a-z]+)」后的普通单词当成候选;
// - 有分隔词时也认省略前缀的写法(「任务 42 和 43」)。
func (s *compiledSlot) repeatsAfter(input string, loc []int) bool {
g := s.group
rest := strings.TrimLeftFunc(input[loc[2*g+1]:], unicode.IsSpace)
sep := false
for _, t := range slotValueSeparators {
if strings.HasPrefix(rest, t) {
rest, sep = strings.TrimLeftFunc(rest[len(t):], unicode.IsSpace), true
break
}
}
if rest == "" {
return false
}
prefix := input[loc[0]:loc[2*g]]
if sep || prefix != "" {
if m := s.re.FindStringSubmatchIndex(rest); m != nil && m[0] == 0 && groupCaptured(m, g) {
return true
}
}
if sep && prefix != "" {
probe := prefix + rest
if m := s.re.FindStringSubmatchIndex(probe); m != nil && m[0] == 0 && groupCaptured(m, g) && m[2*g] == len(prefix) {
return true
}
}
return false
}
func groupCaptured(loc []int, g int) bool {
return 2*g+1 < len(loc) && loc[2*g] >= 0 && loc[2*g] < loc[2*g+1]
}
// hasValueRune 报告 v 是否含字母、数字或汉字(只有符号/标点的值不可用)。
func hasValueRune(v string) bool {
for _, r := range v {
if unicode.IsLetter(r) || unicode.IsNumber(r) {
return true
}
}
return false
}
// validateProvidedSlots 校验调用方直接给出的槽位表(生成器路径)。
// 与 extractSlots 同款结果形态:归一化 → format/enum/validator;必填缺失进 missing;
// 捕获到但不合法进 invalid;未知键进 invalid(reason=unknown slot key)。