Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6aa4925301 | ||
|
|
765a7e7177 | ||
|
|
651a2e1e03 | ||
|
|
660992ab10 | ||
|
|
6ca6299b39 | ||
|
|
0f8e2de85f | ||
|
|
c66996e483 | ||
|
|
8d54bed798 | ||
|
|
511b8142b7 | ||
|
|
8895b3c877 | ||
|
|
57f92f919f | ||
|
|
0e0e815f45 | ||
|
|
343b1e6d9a |
@@ -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 .
|
||||
@@ -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
|
||||
@@ -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;放弃路径注明原因>。
|
||||
@@ -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"
|
||||
```
|
||||
@@ -34,3 +34,21 @@
|
||||
- **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
@@ -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/文件,不证明执行器真实运行 |
|
||||
|
||||
## 环境与数据
|
||||
|
||||
|
||||
+39
-6
@@ -1,5 +1,6 @@
|
||||
// intentctl 是意图配置的只读诊断 CLI:lint 校验配置并报告冲突,
|
||||
// explain 输出单条输入的裁决轨迹,eval 跑评测集。所有命令不改配置、不写文件。
|
||||
// explain 输出单条输入的裁决轨迹,eval 跑评测集。所有命令不改配置;
|
||||
// 唯一的写文件入口是 eval 显式给出的 -metrics-out(指标 JSON)。
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -42,7 +43,7 @@ func run(args []string) error {
|
||||
}
|
||||
|
||||
func usage() error {
|
||||
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] 输出能力目录(注入规划者/领导提示词用)")
|
||||
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] [-metrics-out <file|->] <corpus.json>\n catalog -root <dir> [-protected a,b] [-guard a,b] 输出能力目录(注入规划者/领导提示词用)")
|
||||
}
|
||||
|
||||
type commonOpts struct {
|
||||
@@ -243,16 +244,20 @@ func sortedKeys(m map[string]string) []string {
|
||||
return keys
|
||||
}
|
||||
|
||||
// evalCorpus 的 record_only 声明语料为基线评测集:期望描述理想行为,
|
||||
// 差异照常打印并计入指标,但不使 eval 失败(不设质量阈值)。
|
||||
type evalCorpus struct {
|
||||
Cases []intent.CorpusCase `json:"cases"`
|
||||
BuiltinSpecs []intent.IntentSpec `json:"builtin_specs,omitempty"`
|
||||
GuardDomains []string `json:"guard_domains,omitempty"`
|
||||
ProtectedDomains []string `json:"protected_domains,omitempty"`
|
||||
Cases []intent.CorpusCase `json:"cases"`
|
||||
BuiltinSpecs []intent.IntentSpec `json:"builtin_specs,omitempty"`
|
||||
GuardDomains []string `json:"guard_domains,omitempty"`
|
||||
ProtectedDomains []string `json:"protected_domains,omitempty"`
|
||||
RecordOnly bool `json:"record_only,omitempty"`
|
||||
}
|
||||
|
||||
func cmdEval(args []string) error {
|
||||
fs := flag.NewFlagSet("eval", flag.ContinueOnError)
|
||||
c := parseCommon(fs)
|
||||
metricsOut := fs.String("metrics-out", "", "指标 JSON 输出路径(- 为标准输出;留空不输出)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -356,8 +361,36 @@ func cmdEval(args []string) error {
|
||||
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)
|
||||
summary := intent.SummarizeCorpus(intent.CorpusDigest(data), corpus.Cases, results)
|
||||
fmt.Print(summary.Table())
|
||||
if *metricsOut != "" {
|
||||
if err := writeMetrics(*metricsOut, summary); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if failed > 0 {
|
||||
if corpus.RecordOnly {
|
||||
fmt.Printf("基线语料(record_only):%d/%d 用例与理想期望不符,只记录不判失败\n", failed, len(corpus.Cases))
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%d/%d 用例失败", failed, len(corpus.Cases))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeMetrics(path string, summary intent.EvalSummary) error {
|
||||
out, err := json.MarshalIndent(summary, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, '\n')
|
||||
if path == "-" {
|
||||
_, err = os.Stdout.Write(out)
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(path, out, 0o644); err != nil {
|
||||
return fmt.Errorf("写指标文件失败:%w", err)
|
||||
}
|
||||
fmt.Printf("指标 JSON 已写入 %s\n", path)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-intent/intent"
|
||||
)
|
||||
|
||||
const testSpecJSON = `{
|
||||
@@ -71,3 +75,38 @@ func TestEvalFailsOnWrongExpectation(t *testing.T) {
|
||||
t.Fatal("expected eval failure on wrong expectation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalRecordOnlyWritesMetrics(t *testing.T) {
|
||||
dir := writeTemp(t, "specs.json", testSpecJSON)
|
||||
tmp := t.TempDir()
|
||||
corpus := filepath.Join(tmp, "corpus.json")
|
||||
doc := `{"record_only": true, "cases": [
|
||||
{"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)
|
||||
}
|
||||
out := filepath.Join(tmp, "metrics.json")
|
||||
if err := run([]string{"eval", "-root", dir, "-protected", "", "-metrics-out", out, corpus}); err != nil {
|
||||
t.Fatalf("record_only eval should not fail: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var s intent.EvalSummary
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Schema != intent.EvalMetricsSchema || s.Overall.Cases != 2 || len(s.Categories) != 2 || s.Overall.MisExecutions != 1 {
|
||||
t.Fatalf("summary = %+v", s)
|
||||
}
|
||||
|
||||
strict := filepath.Join(tmp, "strict.json")
|
||||
if err := os.WriteFile(strict, []byte(strings.Replace(doc, `"record_only": true, `, "", 1)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := run([]string{"eval", "-root", dir, "-protected", "", strict}); err == nil {
|
||||
t.Fatal("corpus without record_only must still fail on mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
-1
Submodule deps/development-guidelines updated: 26b562d2e3...2688fd2be6
+1
Submodule deps/development-manager added at bd3a7de57a
@@ -0,0 +1,85 @@
|
||||
# 能力召回评测基线(A3)
|
||||
|
||||
本文记录召回评测集在当前识别器上的**指标基线**。只记录,不设质量阈值、不做通过判定;
|
||||
据此决定后续方向(检索、模型调优等)不在本基线范围内。机器可读基线为同目录
|
||||
[`metrics.json`](metrics.json)(schema `agent-intent.eval-metrics/v1`),本表数字与之一致。
|
||||
|
||||
## 语料
|
||||
|
||||
- 位置:[`intent/testdata/recall_corpus.json`](../intent/testdata/recall_corpus.json),
|
||||
与 `intent/testdata/corpus.json`、`corpus_multiturn.json` 同一格式(`CorpusCase`),
|
||||
由 `intentctl eval` 与 `go test` 共用。
|
||||
- 摘要:`sha256:53d26cfc4caad994074db7d49924e3eabcba7c8ada4d6389a9a23f3085bc2885`
|
||||
(语料文件原始字节的 sha256;语料变更后须重跑并更新本文与 `metrics.json`)。
|
||||
- 数据:全部为合成用例,只复用测试夹具 `intent/testdata/specs/base.json` 的域、动作与槽位形态,
|
||||
以及语料自声明的守卫 spec `guard.config_change`;不含真实用户数据。
|
||||
- 期望写的是**理想行为**(不是当前行为的快照),语料声明 `record_only: true`:
|
||||
与期望不符的用例照常打印 FAIL 并计入指标,但不使 eval 失败。
|
||||
- 离线:生成路径用确定性回放生成器(回放用例 `generator_commands`),无模型、无网络调用。
|
||||
|
||||
### 类别覆盖
|
||||
|
||||
| 类别 | 用例 | 轮次 | 覆盖内容 |
|
||||
|---|---|---|---|
|
||||
| `negation_reference` | 8 | 10 | 否定指令(不要/别/不用/先不要)、`excludes_any` 否定、「还是刚才那个」类指代(单轮无上下文与多轮) |
|
||||
| `multi_intent` | 7 | 7 | 一句含两个意图信号:动作+动作(应澄清)、寒暄+动作(应取动作)、动作+守卫(应守卫拦截) |
|
||||
| `wrong_slot` | 7 | 7 | 槽位缺失、中文数字、两个候选值、越出字符集、仅符号、空主题 |
|
||||
| `expired_confirmation` | 7 | 14 | 上一轮挂起预期(链式或宿主声明)在下一轮到达前过期:裸回答应回落、完整新指令应走规则 matched 而非 expectation_hit;含 1 例未过期对照 |
|
||||
| `generation_consistency` | 8 | 8 | 同一输入同时走规则路径与生成路径(回放生成器 + 识别器兜底同款校验),比对 Top 与槽位;含槽位分歧、错动作、幻觉动作、闲聊 |
|
||||
| 合计 | 37 | 46 | |
|
||||
|
||||
## 指标口径
|
||||
|
||||
按轮次计(v1 单轮用例即一轮);比率保留 4 位,分母为 0 记 `-`(JSON `null`)。
|
||||
|
||||
- **pass**:全部轮次与理想期望一致的用例数。
|
||||
- **top1**:写了 `expect_top` 的轮次中,实际 Top 等于期望的比例。
|
||||
- **mis_exec(误执行)**:实际 `matched`/`expectation_hit` 出了 Top,但与期望动作不同
|
||||
(期望别的 Top,或期望 `fallback`/`ambiguous`/`guard_hit` 却执行了)——危险项。
|
||||
- **clarify(澄清率)**:`guard_hit` 或产出 `clarify` 命令的轮次占比。
|
||||
- **fallback(回落率)**:实际 `fallback` 的轮次占比。
|
||||
- **gen_agree(生成一致率)**:`generation_consistency` 用例中规则路径与生成路径
|
||||
Top ID 与槽位都一致的比例(两边都无 Top 也算一致)。
|
||||
|
||||
## 基线(本次运行)
|
||||
|
||||
| 类别 | 用例 | pass | 轮次 | top1 | mis_exec | clarify | fallback | gen_agree |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| `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 |
|
||||
|
||||
同次运行的既有 I2 指标:precision=0.967 recall=0.967(TP=29 FP=1 FN=1)。
|
||||
|
||||
### 记录到的差异(事实,不含判定)
|
||||
|
||||
- `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。
|
||||
- `expired_confirmation`:过期后裸回答回落、完整指令走规则 matched,均与期望一致。
|
||||
- `generation_consistency`:3 例不一致——生成槽位 `run_id=43` 与规则 `42` 不同、生成错动作
|
||||
`taskrun.restart`(规则为 status_query)、幻觉动作 `ghost.flow` 被校验 fail-closed 后无 Top。
|
||||
|
||||
## 重跑
|
||||
|
||||
在仓库根(依赖子模块已按 gitlink 初始化):
|
||||
|
||||
```bash
|
||||
# 打印逐例 PASS/FAIL、指标表,并写机器基线(-metrics-out - 则输出到标准输出)
|
||||
GOWORK=off go run ./cmd/intentctl eval -root intent/testdata/specs \
|
||||
-metrics-out evaluation/metrics.json intent/testdata/recall_corpus.json
|
||||
|
||||
# 结构覆盖测试(≥30 例、五类齐全、每例可执行、指标 JSON 合法且与计数一致、基线摘要与语料一致)
|
||||
GOWORK=off go test -count=1 -run 'TestRecallCorpusStructure' -v ./intent/
|
||||
```
|
||||
|
||||
`-root` 指向测试同款夹具目录;守卫 spec 与守卫/保护域由语料 `builtin_specs`、`guard_domains`、
|
||||
`protected_domains` 自声明,测试按同样方式建快照,因此两条命令的指标同源。
|
||||
识别是确定性的:同语料、同代码重跑得到逐字节相同的 `metrics.json`。
|
||||
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"schema": "agent-intent.eval-metrics/v1",
|
||||
"corpus_digest": "sha256:53d26cfc4caad994074db7d49924e3eabcba7c8ada4d6389a9a23f3085bc2885",
|
||||
"overall": {
|
||||
"category": "overall",
|
||||
"cases": 37,
|
||||
"passed_cases": 27,
|
||||
"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,
|
||||
"generation_cases": 8,
|
||||
"generation_agree": 5,
|
||||
"generation_agreement_rate": 0.625
|
||||
},
|
||||
"categories": [
|
||||
{
|
||||
"category": "expired_confirmation",
|
||||
"cases": 7,
|
||||
"passed_cases": 7,
|
||||
"turns": 14,
|
||||
"top1_expected": 11,
|
||||
"top1_hits": 11,
|
||||
"top1_hit_rate": 1,
|
||||
"mis_executions": 0,
|
||||
"clarifications": 5,
|
||||
"clarification_rate": 0.3571,
|
||||
"fallbacks": 3,
|
||||
"fallback_rate": 0.2143,
|
||||
"generation_cases": 0,
|
||||
"generation_agree": 0,
|
||||
"generation_agreement_rate": null
|
||||
},
|
||||
{
|
||||
"category": "generation_consistency",
|
||||
"cases": 8,
|
||||
"passed_cases": 8,
|
||||
"turns": 8,
|
||||
"top1_expected": 7,
|
||||
"top1_hits": 7,
|
||||
"top1_hit_rate": 1,
|
||||
"mis_executions": 0,
|
||||
"clarifications": 0,
|
||||
"clarification_rate": 0,
|
||||
"fallbacks": 1,
|
||||
"fallback_rate": 0.125,
|
||||
"generation_cases": 8,
|
||||
"generation_agree": 5,
|
||||
"generation_agreement_rate": 0.625
|
||||
},
|
||||
{
|
||||
"category": "multi_intent",
|
||||
"cases": 7,
|
||||
"passed_cases": 6,
|
||||
"turns": 7,
|
||||
"top1_expected": 3,
|
||||
"top1_hits": 2,
|
||||
"top1_hit_rate": 0.6667,
|
||||
"mis_executions": 1,
|
||||
"clarifications": 5,
|
||||
"clarification_rate": 0.7143,
|
||||
"fallbacks": 0,
|
||||
"fallback_rate": 0,
|
||||
"generation_cases": 0,
|
||||
"generation_agree": 0,
|
||||
"generation_agreement_rate": null
|
||||
},
|
||||
{
|
||||
"category": "negation_reference",
|
||||
"cases": 8,
|
||||
"passed_cases": 3,
|
||||
"turns": 10,
|
||||
"top1_expected": 3,
|
||||
"top1_hits": 3,
|
||||
"top1_hit_rate": 1,
|
||||
"mis_executions": 5,
|
||||
"clarifications": 0,
|
||||
"clarification_rate": 0,
|
||||
"fallbacks": 2,
|
||||
"fallback_rate": 0.2,
|
||||
"generation_cases": 0,
|
||||
"generation_agree": 0,
|
||||
"generation_agreement_rate": null
|
||||
},
|
||||
{
|
||||
"category": "wrong_slot",
|
||||
"cases": 7,
|
||||
"passed_cases": 3,
|
||||
"turns": 7,
|
||||
"top1_expected": 7,
|
||||
"top1_hits": 7,
|
||||
"top1_hit_rate": 1,
|
||||
"mis_executions": 0,
|
||||
"clarifications": 3,
|
||||
"clarification_rate": 0.4286,
|
||||
"fallbacks": 0,
|
||||
"fallback_rate": 0,
|
||||
"generation_cases": 0,
|
||||
"generation_agree": 0,
|
||||
"generation_agreement_rate": null
|
||||
}
|
||||
]
|
||||
}
|
||||
+4
-3
@@ -33,6 +33,7 @@ type CatalogSpec struct {
|
||||
|
||||
// Catalog 返回能力目录:全部启用的非守卫 spec,按合并序(确定性)。
|
||||
// 守卫域 spec 是拦截器而非可调用能力,不进目录;需要全量时用 SpecIDs/SpecByID。
|
||||
// 返回值为深拷贝:调用方改切片/枚举/metadata 不会改到快照。
|
||||
func (s *Snapshot) Catalog() []CatalogSpec {
|
||||
out := make([]CatalogSpec, 0, len(s.specs))
|
||||
for _, cs := range s.specs {
|
||||
@@ -55,7 +56,7 @@ func (s *Snapshot) Catalog() []CatalogSpec {
|
||||
for _, sl := range cs.slots {
|
||||
view.Slots = append(view.Slots, CatalogSlot{
|
||||
Key: sl.key, Required: sl.required, Label: sl.label, Question: sl.question,
|
||||
Enum: sl.enum, Format: specSlotFormat(cs.spec, sl.key),
|
||||
Enum: cloneStrings(sl.enum), Format: specSlotFormat(cs.spec, sl.key),
|
||||
})
|
||||
}
|
||||
out = append(out, view)
|
||||
@@ -124,7 +125,7 @@ func specSlotFormat(spec IntentSpec, key string) string {
|
||||
|
||||
// CatalogTopK 取目录的前 k 条:按 Priority 降序、ID 升序(确定性排序;
|
||||
// 排序口径是 priority,不是语义相关度——相关度排序由宿主经 examples 自行计算)。
|
||||
// k <= 0 时等同 Catalog() 全量。
|
||||
// k <= 0 时等同 Catalog() 全量。返回值与 Catalog 一样是深拷贝,不共享底层数组。
|
||||
func (s *Snapshot) CatalogTopK(k int) []CatalogSpec {
|
||||
all := s.Catalog()
|
||||
if k <= 0 || len(all) <= k {
|
||||
@@ -141,5 +142,5 @@ func (s *Snapshot) CatalogTopK(k int) []CatalogSpec {
|
||||
}
|
||||
return all[i].ID < all[j].ID
|
||||
})
|
||||
return all[:k]
|
||||
return cloneCatalogSpecs(all[:k])
|
||||
}
|
||||
|
||||
+116
-13
@@ -7,22 +7,33 @@ import (
|
||||
|
||||
// CorpusCase 是评测用例:v1 单轮(input/expect_*)与 v2 多轮(turns)共用。
|
||||
// v1 字段在 Turns 为空时生效,并自动等价于一个单 turn 用例(不含命令断言)。
|
||||
// Category 与 GeneratorCommands 为可选字段(A3 召回评测):旧语料不写即按
|
||||
// 未分类处理;GeneratorCommands 是确定性回放生成器的脚本输出,写了才在
|
||||
// 首轮输入上额外跑一遍生成路径并与规则路径比对。
|
||||
type CorpusCase struct {
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category,omitempty"`
|
||||
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"`
|
||||
GeneratorCommands []Command `json:"generator_commands,omitempty"`
|
||||
}
|
||||
|
||||
// CorpusTurn 是多轮用例中的一轮:输入与逐轮期望(分支/top/命令列表)。
|
||||
// CorpusTurn 是多轮用例中的一轮:输入与逐轮期望(分支/top/槽位/命令列表)。
|
||||
// PendingExpectation 声明本轮到达前宿主挂起的预期意图(覆盖上一轮的链式预期);
|
||||
// ExpectationExpired 表示本轮到达时挂起预期已过期(声明的或链式的都作废)。
|
||||
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"`
|
||||
Input string `json:"input"`
|
||||
PendingExpectation []string `json:"pending_expectation,omitempty"`
|
||||
ExpectationExpired bool `json:"expectation_expired,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"`
|
||||
ExpectCommands []CommandExpect `json:"expect_commands,omitempty"`
|
||||
}
|
||||
|
||||
// CommandExpect 是对一条命令的期望;只有写出的字段参与断言。
|
||||
@@ -44,14 +55,29 @@ type TurnResult struct {
|
||||
Input string
|
||||
Branch string
|
||||
TopID string
|
||||
Slots map[string]string
|
||||
Commands []Command
|
||||
Mismatches []string
|
||||
}
|
||||
|
||||
// GeneratorComparison 是同一输入的规则路径与生成路径(确定性回放生成器,
|
||||
// 经与识别器兜底同款的校验)的 Top 比对。Agree = Top ID 与槽位都一致
|
||||
// (两边都没有 Top 也算一致)。
|
||||
type GeneratorComparison struct {
|
||||
Input string
|
||||
RulesTopID string
|
||||
GeneratorTopID string
|
||||
RulesSlots map[string]string
|
||||
GeneratorSlots map[string]string
|
||||
Agree bool
|
||||
}
|
||||
|
||||
// CaseResult 一个用例的全部轮次结果。Passed = 所有轮次无差异。
|
||||
// Generator 仅在用例写了 generator_commands 时非 nil;比对结果不计入 Passed。
|
||||
type CaseResult struct {
|
||||
Name string
|
||||
Turns []TurnResult
|
||||
Name string
|
||||
Turns []TurnResult
|
||||
Generator *GeneratorComparison
|
||||
}
|
||||
|
||||
// Passed 报告用例是否全部轮次通过。
|
||||
@@ -74,17 +100,31 @@ 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}}
|
||||
// corpusTurns 返回用例的轮次(v1 用例自动包装为单轮,携带 v1 槽位期望)。
|
||||
func corpusTurns(tc CorpusCase) []CorpusTurn {
|
||||
if len(tc.Turns) > 0 {
|
||||
return tc.Turns
|
||||
}
|
||||
return []CorpusTurn{{Input: tc.Input, ExpectBranch: tc.ExpectBranch, ExpectTop: tc.ExpectTop,
|
||||
ExpectSlots: tc.ExpectSlots, ExpectMissingSlots: tc.ExpectMissingSlots}}
|
||||
}
|
||||
|
||||
// RunCorpusCase 执行一个用例的全部轮次(v1 用例自动包装为单轮)。
|
||||
// 识别器按轮次构建在链式预期上,保证 SetSlot+Start 这类组合可在评测中到达;
|
||||
// 轮次可显式声明挂起预期或声明其已过期。写了 generator_commands 的用例
|
||||
// 另在首轮输入上跑生成路径并与规则路径比对(离线、确定性回放)。
|
||||
func RunCorpusCase(ctx context.Context, snap *Snapshot, tc CorpusCase) CaseResult {
|
||||
turns := corpusTurns(tc)
|
||||
exp := &chainedExpectation{}
|
||||
rec := NewRecognizer(Options{}, exp, nil)
|
||||
out := CaseResult{Name: tc.Name}
|
||||
for i, turn := range turns {
|
||||
if len(turn.PendingExpectation) > 0 {
|
||||
exp.next = &PendingExpectation{IntentIDs: append([]string(nil), turn.PendingExpectation...), Reason: "eval declared"}
|
||||
}
|
||||
if turn.ExpectationExpired {
|
||||
exp.next = nil
|
||||
}
|
||||
tr := TurnResult{Index: i, Input: turn.Input}
|
||||
res, err := rec.Recognize(ctx, turn.Input, snap)
|
||||
if err != nil {
|
||||
@@ -95,6 +135,7 @@ func RunCorpusCase(ctx context.Context, snap *Snapshot, tc CorpusCase) CaseResul
|
||||
tr.Branch = string(res.Branch)
|
||||
if res.Top != nil {
|
||||
tr.TopID = res.Top.ID
|
||||
tr.Slots = res.Top.Slots
|
||||
}
|
||||
tr.Commands = res.Commands
|
||||
tr.Mismatches = matchTurn(turn, res)
|
||||
@@ -104,9 +145,52 @@ func RunCorpusCase(ctx context.Context, snap *Snapshot, tc CorpusCase) CaseResul
|
||||
exp.next = &PendingExpectation{IntentIDs: []string{res.Top.ID}, Reason: "eval chained"}
|
||||
}
|
||||
}
|
||||
if len(tc.GeneratorCommands) > 0 && len(out.Turns) > 0 {
|
||||
out.Generator = compareGeneratorPath(ctx, snap, out.Turns[0], tc.GeneratorCommands)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// replayGenerator 是评测用确定性生成器:忽略输入与目录,回放用例脚本中的命令
|
||||
// (无模型、无网络)。
|
||||
type replayGenerator struct {
|
||||
cmds []Command
|
||||
}
|
||||
|
||||
func (g replayGenerator) Generate(ctx context.Context, input string, catalog []CatalogSpec) ([]Command, error) {
|
||||
out := make([]Command, len(g.cmds))
|
||||
for i, c := range g.cmds {
|
||||
c.Slots = cloneStringMap(c.Slots)
|
||||
out[i] = c
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// compareGeneratorPath 绕过规则阶段,直接走识别器兜底同款的生成路径
|
||||
// (目录 top-k → 生成 → 校验 → 解析回权威 spec),再与规则路径首轮比对。
|
||||
func compareGeneratorPath(ctx context.Context, snap *Snapshot, rules TurnResult, cmds []Command) *GeneratorComparison {
|
||||
cmp := &GeneratorComparison{Input: rules.Input, RulesTopID: rules.TopID, RulesSlots: rules.Slots}
|
||||
rec := NewRecognizer(Options{Generator: replayGenerator{cmds: cmds}}, nil, nil)
|
||||
if res, ok := rec.generateCommands(ctx, rules.Input, snap); ok && res.Top != nil {
|
||||
cmp.GeneratorTopID = res.Top.ID
|
||||
cmp.GeneratorSlots = res.Top.Slots
|
||||
}
|
||||
cmp.Agree = cmp.RulesTopID == cmp.GeneratorTopID && sameSlots(cmp.RulesSlots, cmp.GeneratorSlots)
|
||||
return cmp
|
||||
}
|
||||
|
||||
func sameSlots(a, b map[string]string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for k, v := range a {
|
||||
if w, ok := b[k]; !ok || w != v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func matchTurn(turn CorpusTurn, res Result) []string {
|
||||
var mm []string
|
||||
if turn.ExpectBranch != "" && string(res.Branch) != turn.ExpectBranch {
|
||||
@@ -121,6 +205,24 @@ func matchTurn(turn CorpusTurn, res Result) []string {
|
||||
mm = append(mm, fmt.Sprintf("top=%s(期望 %s)", got, turn.ExpectTop))
|
||||
}
|
||||
}
|
||||
for _, k := range sortedKeys(turn.ExpectSlots) {
|
||||
got := ""
|
||||
if res.Top != nil {
|
||||
got = res.Top.Slots[k]
|
||||
}
|
||||
if got != turn.ExpectSlots[k] {
|
||||
mm = append(mm, fmt.Sprintf("slots[%s]=%q(期望 %q)", k, got, turn.ExpectSlots[k]))
|
||||
}
|
||||
}
|
||||
if len(turn.ExpectMissingSlots) > 0 {
|
||||
var got []string
|
||||
if res.Top != nil {
|
||||
got = res.Top.MissingRequiredSlots
|
||||
}
|
||||
if fmt.Sprint(got) != fmt.Sprint(turn.ExpectMissingSlots) {
|
||||
mm = append(mm, fmt.Sprintf("missing=%v(期望 %v)", got, turn.ExpectMissingSlots))
|
||||
}
|
||||
}
|
||||
if turn.ExpectCommands != nil {
|
||||
mm = append(mm, matchCommands(turn.ExpectCommands, res.Commands)...)
|
||||
}
|
||||
@@ -208,6 +310,7 @@ func (m CorpusMetrics) Recall() (float64, bool) {
|
||||
// - 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
|
||||
|
||||
@@ -75,4 +75,16 @@ func TestEvalCorpus(t *testing.T) {
|
||||
_, snap := buildDemoRegistry(t)
|
||||
rec := NewRecognizer(Options{}, nil, nil)
|
||||
runCorpus(t, rec, snap, doc.Cases)
|
||||
|
||||
var shared struct {
|
||||
Cases []CorpusCase `json:"cases"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &shared); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var results []CaseResult
|
||||
for _, tc := range shared.Cases {
|
||||
results = append(results, RunCorpusCase(context.Background(), snap, tc))
|
||||
}
|
||||
t.Log("\n" + SummarizeCorpus(CorpusDigest(data), shared.Cases, results).Table())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package intent
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EvalMetricsSchema 是机器可读评测指标(EvalSummary JSON)的格式标识。
|
||||
const EvalMetricsSchema = "agent-intent.eval-metrics/v1"
|
||||
|
||||
// UncategorizedCategory 是未写 category 的用例归入的类别(旧语料兼容)。
|
||||
const UncategorizedCategory = "uncategorized"
|
||||
|
||||
// CategoryMetrics 是一个类别(或全量)的评测指标(A3 召回基线)。
|
||||
// 计数口径按轮次(v1 用例即一轮),比率的分母为 0 时为 nil(JSON null):
|
||||
// - top1:写了 expect_top 的轮次中实际 Top 等于期望的比例;
|
||||
// - mis_executions:实际 matched/expectation_hit 出 Top,但与期望动作不同
|
||||
// (期望别的 top,或期望 fallback/ambiguous/guard_hit 却执行了)——危险项;
|
||||
// - clarification:guard_hit 或产出 clarify 命令的轮次;
|
||||
// - fallback:实际 fallback 的轮次;
|
||||
// - generation:写了 generator_commands 的用例中,规则路径与生成路径一致的比例。
|
||||
type CategoryMetrics struct {
|
||||
Category string `json:"category"`
|
||||
Cases int `json:"cases"`
|
||||
PassedCases int `json:"passed_cases"`
|
||||
Turns int `json:"turns"`
|
||||
Top1Expected int `json:"top1_expected"`
|
||||
Top1Hits int `json:"top1_hits"`
|
||||
Top1HitRate *float64 `json:"top1_hit_rate"`
|
||||
MisExecutions int `json:"mis_executions"`
|
||||
Clarifications int `json:"clarifications"`
|
||||
ClarificationRate *float64 `json:"clarification_rate"`
|
||||
Fallbacks int `json:"fallbacks"`
|
||||
FallbackRate *float64 `json:"fallback_rate"`
|
||||
GenerationCases int `json:"generation_cases"`
|
||||
GenerationAgree int `json:"generation_agree"`
|
||||
GenerationAgreementRate *float64 `json:"generation_agreement_rate"`
|
||||
}
|
||||
|
||||
// EvalSummary 是一次评测的指标汇总:按类别(名称升序)与全量,附语料摘要。
|
||||
// 只记录基线,不含阈值与通过判定。
|
||||
type EvalSummary struct {
|
||||
Schema string `json:"schema"`
|
||||
CorpusDigest string `json:"corpus_digest"`
|
||||
Overall CategoryMetrics `json:"overall"`
|
||||
Categories []CategoryMetrics `json:"categories"`
|
||||
}
|
||||
|
||||
// CorpusDigest 返回语料文件原始字节的摘要(sha256:<hex>)。
|
||||
func CorpusDigest(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// SummarizeCorpus 汇总评测指标。results 须与 cases 按下标一一对应
|
||||
// (即 RunCorpusCase 逐个执行 cases 的结果)。
|
||||
func SummarizeCorpus(digest string, cases []CorpusCase, results []CaseResult) EvalSummary {
|
||||
byCat := map[string]*CategoryMetrics{}
|
||||
overall := &CategoryMetrics{Category: "overall"}
|
||||
for i, tc := range cases {
|
||||
if i >= len(results) {
|
||||
break
|
||||
}
|
||||
cat := tc.Category
|
||||
if strings.TrimSpace(cat) == "" {
|
||||
cat = UncategorizedCategory
|
||||
}
|
||||
m, ok := byCat[cat]
|
||||
if !ok {
|
||||
m = &CategoryMetrics{Category: cat}
|
||||
byCat[cat] = m
|
||||
}
|
||||
accumulateCase(m, tc, results[i])
|
||||
accumulateCase(overall, tc, results[i])
|
||||
}
|
||||
out := EvalSummary{Schema: EvalMetricsSchema, CorpusDigest: digest, Categories: []CategoryMetrics{}}
|
||||
for _, m := range byCat {
|
||||
finishRates(m)
|
||||
out.Categories = append(out.Categories, *m)
|
||||
}
|
||||
sort.Slice(out.Categories, func(i, j int) bool { return out.Categories[i].Category < out.Categories[j].Category })
|
||||
finishRates(overall)
|
||||
out.Overall = *overall
|
||||
return out
|
||||
}
|
||||
|
||||
func accumulateCase(m *CategoryMetrics, tc CorpusCase, res CaseResult) {
|
||||
m.Cases++
|
||||
if res.Passed() {
|
||||
m.PassedCases++
|
||||
}
|
||||
turns := corpusTurns(tc)
|
||||
for i, tr := range res.Turns {
|
||||
if i >= len(turns) {
|
||||
break
|
||||
}
|
||||
want := turns[i]
|
||||
m.Turns++
|
||||
if want.ExpectTop != "" {
|
||||
m.Top1Expected++
|
||||
if tr.TopID == want.ExpectTop {
|
||||
m.Top1Hits++
|
||||
}
|
||||
}
|
||||
if misExecuted(want, tr) {
|
||||
m.MisExecutions++
|
||||
}
|
||||
if tr.Branch == string(BranchGuardHit) || hasClarify(tr.Commands) {
|
||||
m.Clarifications++
|
||||
}
|
||||
if tr.Branch == string(BranchFallback) {
|
||||
m.Fallbacks++
|
||||
}
|
||||
}
|
||||
if res.Generator != nil {
|
||||
m.GenerationCases++
|
||||
if res.Generator.Agree {
|
||||
m.GenerationAgree++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// misExecuted 报告该轮是否执行了与期望不同的动作(危险项)。
|
||||
// 期望 matched/expectation_hit 但未写 expect_top 的轮次不约束动作,不计。
|
||||
func misExecuted(want CorpusTurn, got TurnResult) bool {
|
||||
executed := got.TopID != "" && (got.Branch == string(BranchMatched) || got.Branch == string(BranchExpectationHit))
|
||||
if !executed {
|
||||
return false
|
||||
}
|
||||
if want.ExpectTop != "" {
|
||||
return got.TopID != want.ExpectTop
|
||||
}
|
||||
switch Branch(want.ExpectBranch) {
|
||||
case BranchFallback, BranchAmbiguous, BranchGuardHit:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasClarify(cmds []Command) bool {
|
||||
for _, c := range cmds {
|
||||
if c.Kind == CommandClarify {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func finishRates(m *CategoryMetrics) {
|
||||
m.Top1HitRate = ratio(m.Top1Hits, m.Top1Expected)
|
||||
m.ClarificationRate = ratio(m.Clarifications, m.Turns)
|
||||
m.FallbackRate = ratio(m.Fallbacks, m.Turns)
|
||||
m.GenerationAgreementRate = ratio(m.GenerationAgree, m.GenerationCases)
|
||||
}
|
||||
|
||||
// ratio 定点 4 位舍入,保证重跑 JSON 字节稳定;分母为 0 返回 nil。
|
||||
func ratio(n, d int) *float64 {
|
||||
if d == 0 {
|
||||
return nil
|
||||
}
|
||||
v := math.Round(float64(n)/float64(d)*10000) / 10000
|
||||
return &v
|
||||
}
|
||||
|
||||
// Table 返回人类可读的指标表(每类别一行 + overall 行)。
|
||||
func (s EvalSummary) Table() string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "指标表(corpus %s):\n", s.CorpusDigest)
|
||||
fmt.Fprintf(&b, " %-24s %5s %5s %5s %-14s %8s %-14s %-14s %s\n",
|
||||
"category", "cases", "pass", "turns", "top1", "mis_exec", "clarify", "fallback", "gen_agree")
|
||||
rows := append(append([]CategoryMetrics(nil), s.Categories...), s.Overall)
|
||||
for _, m := range rows {
|
||||
fmt.Fprintf(&b, " %-24s %5d %5d %5d %-14s %8d %-14s %-14s %s\n",
|
||||
m.Category, m.Cases, m.PassedCases, m.Turns,
|
||||
rateCell(m.Top1Hits, m.Top1Expected, m.Top1HitRate),
|
||||
m.MisExecutions,
|
||||
rateCell(m.Clarifications, m.Turns, m.ClarificationRate),
|
||||
rateCell(m.Fallbacks, m.Turns, m.FallbackRate),
|
||||
rateCell(m.GenerationAgree, m.GenerationCases, m.GenerationAgreementRate))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func rateCell(n, d int, r *float64) string {
|
||||
if r == nil {
|
||||
return "-"
|
||||
}
|
||||
return fmt.Sprintf("%d/%d=%.3f", n, d, *r)
|
||||
}
|
||||
+85
-4
@@ -34,6 +34,26 @@ func cloneStrings(in []string) []string {
|
||||
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)
|
||||
@@ -47,15 +67,76 @@ func cloneIntentSpec(in IntentSpec) IntentSpec {
|
||||
out.Slots[i].Normalize = cloneStrings(in.Slots[i].Normalize)
|
||||
}
|
||||
}
|
||||
if in.Metadata != nil {
|
||||
out.Metadata = make(map[string]string, len(in.Metadata))
|
||||
for k, v := range in.Metadata {
|
||||
out.Metadata[k] = v
|
||||
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{
|
||||
|
||||
+4
-4
@@ -7,10 +7,10 @@ import "context"
|
||||
// 只在规则与语义都未命中(fallback)时被调用;实现自担延迟与成本。
|
||||
// 返回错误时识别保持 fallback,不会使 Recognize 失败。
|
||||
//
|
||||
// 库侧校验与 deriveCommands 同款:start 命令的 ActionRef 必须可解析——
|
||||
// auto/command/workflow/blueprint 的目标必须在 catalog 的 spec ID 集合内,
|
||||
// handler 的目标必须在 catalog 的 Handler 集合内;槽位键必须在目标 spec 的
|
||||
// 槽位键集合内(可解析时)。生成器幻觉一律 fail-closed 回 fallback。
|
||||
// 库侧校验: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)
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package intent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// recallCorpusDoc 与 intentctl eval 读取的语料结构一致(自声明守卫 spec 与域)。
|
||||
type recallCorpusDoc struct {
|
||||
RecordOnly bool `json:"record_only"`
|
||||
Cases []CorpusCase `json:"cases"`
|
||||
BuiltinSpecs []IntentSpec `json:"builtin_specs"`
|
||||
GuardDomains []string `json:"guard_domains"`
|
||||
ProtectedDomains []string `json:"protected_domains"`
|
||||
}
|
||||
|
||||
var recallCategories = []string{
|
||||
"expired_confirmation",
|
||||
"generation_consistency",
|
||||
"multi_intent",
|
||||
"negation_reference",
|
||||
"wrong_slot",
|
||||
}
|
||||
|
||||
const (
|
||||
recallCorpusPath = "testdata/recall_corpus.json"
|
||||
recallBaselinePath = "../evaluation/metrics.json"
|
||||
)
|
||||
|
||||
// runRecallCorpus 按 intentctl eval 同款方式建快照(语料 builtin_specs + 夹具 specs)
|
||||
// 并逐例执行,保证测试与 CLI 的指标同源。
|
||||
func runRecallCorpus(t *testing.T) ([]byte, recallCorpusDoc, []CaseResult) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(recallCorpusPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var doc recallCorpusDoc
|
||||
if err := json.Unmarshal(data, &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := NewRegistryBuilder(Options{GuardDomains: doc.GuardDomains, ProtectedDomains: doc.ProtectedDomains})
|
||||
if err := b.Register(doc.BuiltinSpecs...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, err := b.BuildFS(context.Background(), os.DirFS("testdata"), "specs")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snap := reg.Snapshot()
|
||||
results := make([]CaseResult, 0, len(doc.Cases))
|
||||
for _, tc := range doc.Cases {
|
||||
results = append(results, RunCorpusCase(context.Background(), snap, tc))
|
||||
}
|
||||
return data, doc, results
|
||||
}
|
||||
|
||||
// TestRecallCorpusStructure 只断言结构覆盖(A3):≥30 例、五类齐全、每例可执行、
|
||||
// 指标 JSON 合法且与用例计数一致;不断言任何质量数值(基线只记录)。
|
||||
func TestRecallCorpusStructure(t *testing.T) {
|
||||
data, doc, results := runRecallCorpus(t)
|
||||
if !doc.RecordOnly {
|
||||
t.Fatal("召回基线语料须声明 record_only")
|
||||
}
|
||||
if len(doc.Cases) < 30 {
|
||||
t.Fatalf("cases = %d, want >= 30", len(doc.Cases))
|
||||
}
|
||||
perCat := map[string]int{}
|
||||
for i, tc := range doc.Cases {
|
||||
perCat[tc.Category]++
|
||||
turns := corpusTurns(tc)
|
||||
res := results[i]
|
||||
if len(res.Turns) != len(turns) {
|
||||
t.Fatalf("case %s executed %d/%d turns", tc.Name, len(res.Turns), len(turns))
|
||||
}
|
||||
for _, tr := range res.Turns {
|
||||
for _, mm := range tr.Mismatches {
|
||||
if strings.HasPrefix(mm, "识别错误") {
|
||||
t.Fatalf("case %s turn %d: %s", tc.Name, tr.Index, mm)
|
||||
}
|
||||
}
|
||||
}
|
||||
if tc.Category == "generation_consistency" {
|
||||
if len(tc.GeneratorCommands) == 0 || len(turns) != 1 || res.Generator == nil {
|
||||
t.Fatalf("case %s: generation_consistency 须为带 generator_commands 的单轮用例", tc.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, cat := range recallCategories {
|
||||
if perCat[cat] == 0 {
|
||||
t.Fatalf("category %s missing (have %v)", cat, perCat)
|
||||
}
|
||||
}
|
||||
if len(perCat) != len(recallCategories) {
|
||||
t.Fatalf("unexpected categories %v, want exactly %v", perCat, recallCategories)
|
||||
}
|
||||
|
||||
summary := SummarizeCorpus(CorpusDigest(data), doc.Cases, results)
|
||||
t.Log("\n" + summary.Table())
|
||||
raw, err := json.Marshal(summary)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkSummaryJSON(t, "fresh", raw, CorpusDigest(data), doc.Cases)
|
||||
|
||||
baseline, err := os.ReadFile(recallBaselinePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read committed baseline: %v", err)
|
||||
}
|
||||
checkSummaryJSON(t, "baseline", baseline, CorpusDigest(data), doc.Cases)
|
||||
}
|
||||
|
||||
// checkSummaryJSON 校验指标 JSON 结构与计数一致性(不比较质量数值)。
|
||||
// 已提交基线的摘要必须等于当前语料摘要:改语料后须按 evaluation/BASELINE.md 重跑。
|
||||
func checkSummaryJSON(t *testing.T, label string, raw []byte, digest string, cases []CorpusCase) {
|
||||
t.Helper()
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
var s EvalSummary
|
||||
if err := dec.Decode(&s); err != nil {
|
||||
t.Fatalf("%s: metrics JSON malformed: %v", label, err)
|
||||
}
|
||||
if s.Schema != EvalMetricsSchema {
|
||||
t.Fatalf("%s: schema = %q", label, s.Schema)
|
||||
}
|
||||
if s.CorpusDigest != digest {
|
||||
t.Fatalf("%s: corpus_digest = %s, want %s(语料已变更,需重跑基线)", label, s.CorpusDigest, digest)
|
||||
}
|
||||
wantCases, wantTurns, wantGen := map[string]int{}, map[string]int{}, map[string]int{}
|
||||
totalTurns, totalGen := 0, 0
|
||||
for _, tc := range cases {
|
||||
n := len(corpusTurns(tc))
|
||||
wantCases[tc.Category]++
|
||||
wantTurns[tc.Category] += n
|
||||
totalTurns += n
|
||||
if len(tc.GeneratorCommands) > 0 {
|
||||
wantGen[tc.Category]++
|
||||
totalGen++
|
||||
}
|
||||
}
|
||||
if len(s.Categories) != len(wantCases) {
|
||||
t.Fatalf("%s: categories = %d, want %d", label, len(s.Categories), len(wantCases))
|
||||
}
|
||||
sumCases, sumTurns := 0, 0
|
||||
for _, m := range s.Categories {
|
||||
if m.Cases != wantCases[m.Category] || m.Turns != wantTurns[m.Category] || m.GenerationCases != wantGen[m.Category] {
|
||||
t.Fatalf("%s: %s counts = %+v, want cases=%d turns=%d gen=%d", label, m.Category, m, wantCases[m.Category], wantTurns[m.Category], wantGen[m.Category])
|
||||
}
|
||||
checkMetricsBounds(t, label, m)
|
||||
sumCases += m.Cases
|
||||
sumTurns += m.Turns
|
||||
}
|
||||
o := s.Overall
|
||||
if o.Cases != len(cases) || sumCases != o.Cases || o.Turns != totalTurns || sumTurns != o.Turns || o.GenerationCases != totalGen {
|
||||
t.Fatalf("%s: overall = %+v, want cases=%d turns=%d gen=%d", label, o, len(cases), totalTurns, totalGen)
|
||||
}
|
||||
checkMetricsBounds(t, label, o)
|
||||
}
|
||||
|
||||
func checkMetricsBounds(t *testing.T, label string, m CategoryMetrics) {
|
||||
t.Helper()
|
||||
pairs := []struct {
|
||||
name string
|
||||
n, d int
|
||||
rate *float64
|
||||
}{
|
||||
{"top1", m.Top1Hits, m.Top1Expected, m.Top1HitRate},
|
||||
{"clarification", m.Clarifications, m.Turns, m.ClarificationRate},
|
||||
{"fallback", m.Fallbacks, m.Turns, m.FallbackRate},
|
||||
{"generation", m.GenerationAgree, m.GenerationCases, m.GenerationAgreementRate},
|
||||
}
|
||||
for _, p := range pairs {
|
||||
if p.n < 0 || p.n > p.d {
|
||||
t.Fatalf("%s: %s %s = %d/%d out of range", label, m.Category, p.name, p.n, p.d)
|
||||
}
|
||||
want := ratio(p.n, p.d)
|
||||
if (want == nil) != (p.rate == nil) || (want != nil && *want != *p.rate) {
|
||||
t.Fatalf("%s: %s %s rate inconsistent with %d/%d", label, m.Category, p.name, p.n, p.d)
|
||||
}
|
||||
}
|
||||
if m.PassedCases > m.Cases || m.MisExecutions > m.Turns || m.Top1Expected > m.Turns {
|
||||
t.Fatalf("%s: %s counts out of range: %+v", label, m.Category, m)
|
||||
}
|
||||
}
|
||||
+137
-61
@@ -120,7 +120,8 @@ func (r *FunnelRecognizer) recognize(ctx context.Context, input string, snap *Sn
|
||||
}
|
||||
}
|
||||
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}
|
||||
@@ -175,10 +176,11 @@ func validateRefined(original, refined []Candidate) ([]Candidate, error) {
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown refined id %s", got.ID)
|
||||
}
|
||||
if !validConfidence(got.Confidence) {
|
||||
norm, ok := normalizeScore(got.Confidence)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("confidence out of range for %s", got.ID)
|
||||
}
|
||||
src.Confidence = got.Confidence
|
||||
src.Confidence = norm
|
||||
out[i] = src
|
||||
}
|
||||
return out, nil
|
||||
@@ -188,6 +190,15 @@ 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 {
|
||||
@@ -289,6 +300,7 @@ func sortRejected(rs []RejectedSpec) {
|
||||
// 规则:候选必须来自当前候选集(构造上不可能引入未知 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
|
||||
@@ -300,14 +312,21 @@ func (r *FunnelRecognizer) semanticRerank(ctx context.Context, input string, can
|
||||
if !ok || len(spec.Examples) == 0 {
|
||||
continue
|
||||
}
|
||||
s, err := r.sem.Score(ctx, input, spec.Examples)
|
||||
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
|
||||
}
|
||||
if s >= r.thr {
|
||||
above = append(above, scored{cand: c, score: s})
|
||||
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 {
|
||||
@@ -327,9 +346,9 @@ func (r *FunnelRecognizer) semanticRerank(ctx context.Context, input string, can
|
||||
}
|
||||
|
||||
// generateCommands 经模型命令生成端口兜底(I2)。校验:
|
||||
// start 的 ActionRef 可解析(spec ID 集或 Handler 集内)、命令种类受限、
|
||||
// 槽位键在目标 spec 的键集内(可解析时);全部通过则把首个 start 的目标
|
||||
// spec 合成 Top(refined_by=generator 标注),任一项不过则保持 fallback。
|
||||
// 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)
|
||||
@@ -337,7 +356,7 @@ func (r *FunnelRecognizer) generateCommands(ctx context.Context, input string, s
|
||||
r.log.Module("intent").WithContext(ctx).Warn("intent command generator failed; keep fallback", "error", err.Error())
|
||||
return Result{}, false
|
||||
}
|
||||
if err := validateGenerated(cmds, catalog); err != nil {
|
||||
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
|
||||
}
|
||||
@@ -346,16 +365,14 @@ func (r *FunnelRecognizer) generateCommands(ctx context.Context, input string, s
|
||||
if c.Kind != CommandStart {
|
||||
continue
|
||||
}
|
||||
if spec, ok := generatedSpecOf(c.Action, catalog); ok {
|
||||
top := Candidate{
|
||||
ID: spec.ID, Domain: spec.Domain, Version: spec.Version,
|
||||
Confidence: 0, Reason: "generated_by=command_generator",
|
||||
Slots: c.Slots,
|
||||
}
|
||||
res.Top = &top
|
||||
res.Branch = BranchMatched
|
||||
res.Candidates = []Candidate{top}
|
||||
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
|
||||
@@ -364,46 +381,53 @@ func (r *FunnelRecognizer) generateCommands(ctx context.Context, input string, s
|
||||
// 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)。
|
||||
func validateGenerated(cmds []Command, catalog []CatalogSpec) error {
|
||||
// ActionRef 经快照唯一解析;槽位未知键在此拒绝(与规则路径「键集外」同款 fail-closed)。
|
||||
func validateGenerated(cmds []Command, snap *Snapshot) error {
|
||||
if len(cmds) == 0 {
|
||||
return fmt.Errorf("empty command list")
|
||||
}
|
||||
specIDs := map[string]bool{}
|
||||
handlers := map[string]bool{}
|
||||
slotKeys := map[string]map[string]bool{}
|
||||
for _, cs := range catalog {
|
||||
specIDs[cs.ID] = true
|
||||
if cs.Handler != "" {
|
||||
handlers[cs.Handler] = true
|
||||
}
|
||||
keys := map[string]bool{}
|
||||
for _, sl := range cs.Slots {
|
||||
keys[sl.Key] = true
|
||||
}
|
||||
slotKeys[cs.ID] = keys
|
||||
}
|
||||
for i, c := range cmds {
|
||||
switch c.Kind {
|
||||
case CommandStart:
|
||||
ref := c.Action
|
||||
switch ref.Kind {
|
||||
case ActionAuto, ActionCommand, ActionWorkflow, ActionBlueprint:
|
||||
if !specIDs[ref.ID] {
|
||||
return fmt.Errorf("cmd[%d] start: action.id %q 不在目录内", i, ref.ID)
|
||||
}
|
||||
case ActionHandler:
|
||||
if !handlers[ref.ID] {
|
||||
return fmt.Errorf("cmd[%d] start: handler %q 不在目录内", i, ref.ID)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("cmd[%d] start: 未知 action.kind %q", i, ref.Kind)
|
||||
cs, err := snap.resolveActionRef(c.Action)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cmd[%d] start: %w", i, err)
|
||||
}
|
||||
if keys, ok := slotKeys[ref.ID]; ok {
|
||||
for k := range c.Slots {
|
||||
if !keys[k] {
|
||||
return fmt.Errorf("cmd[%d] start: 槽位 %q 不在 %s 的槽位键集内", i, k, ref.ID)
|
||||
}
|
||||
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:
|
||||
@@ -414,18 +438,70 @@ func validateGenerated(cmds []Command, catalog []CatalogSpec) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// generatedSpecOf 把 start 的 ActionRef 解析回目录 spec(handler 键按 Handler 反查)。
|
||||
func generatedSpecOf(ref ActionRef, catalog []CatalogSpec) (CatalogSpec, bool) {
|
||||
for _, cs := range catalog {
|
||||
if ref.Kind == ActionHandler {
|
||||
if cs.Handler == ref.ID {
|
||||
return cs, true
|
||||
}
|
||||
// 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.ID == ref.ID {
|
||||
return cs, true
|
||||
if cs.spec.Handler != handler {
|
||||
continue
|
||||
}
|
||||
if found != nil {
|
||||
return nil, fmt.Errorf("handler %q 对应多个 spec(歧义)", handler)
|
||||
}
|
||||
found = cs
|
||||
}
|
||||
return CatalogSpec{}, false
|
||||
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
|
||||
}
|
||||
|
||||
+17
-16
@@ -60,7 +60,7 @@ const (
|
||||
)
|
||||
|
||||
func (o Options) normalized() Options {
|
||||
n := o
|
||||
n := cloneOptions(o)
|
||||
if n.TopN <= 0 {
|
||||
n.TopN = DefaultTopN
|
||||
}
|
||||
@@ -85,13 +85,6 @@ func (o Options) normalized() Options {
|
||||
if n.Log == nil {
|
||||
n.Log = logsys.Nop()
|
||||
}
|
||||
if len(n.SlotValidators) > 0 {
|
||||
cp := make(map[string]SlotValidator, len(n.SlotValidators))
|
||||
for k, v := range n.SlotValidators {
|
||||
cp[k] = v
|
||||
}
|
||||
n.SlotValidators = cp
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
@@ -203,7 +196,7 @@ func compileSpec(spec IntentSpec, guard bool, index int, scoped map[string]SlotV
|
||||
return out
|
||||
}
|
||||
cs := &compiledSpec{
|
||||
spec: spec,
|
||||
spec: cloneIntentSpec(spec),
|
||||
termsAny: fold(m.IncludesAny),
|
||||
termsAll: fold(m.IncludesAll),
|
||||
termsEx: fold(m.ExcludesAny),
|
||||
@@ -481,6 +474,7 @@ func NewRegistryBuilder(opts Options) *RegistryBuilder {
|
||||
}
|
||||
|
||||
// Register 登记内置基线 spec,立即校验;重复 id 报 InvalidSpec。
|
||||
// 登记时深拷贝:调用方之后改入参不会影响已登记定义。
|
||||
func (b *RegistryBuilder) Register(specs ...IntentSpec) error {
|
||||
lim := b.opts.limits()
|
||||
for _, spec := range specs {
|
||||
@@ -491,7 +485,7 @@ func (b *RegistryBuilder) Register(specs ...IntentSpec) error {
|
||||
return err
|
||||
}
|
||||
b.seen[spec.ID] = true
|
||||
b.builtin = append(b.builtin, spec)
|
||||
b.builtin = append(b.builtin, cloneIntentSpec(spec))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -526,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,
|
||||
@@ -553,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 不变,返回带来源与原因的错误。
|
||||
@@ -590,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 {
|
||||
@@ -620,7 +621,7 @@ func buildSnapshot(opts Options, baseline []IntentSpec, entries []overlayEntry,
|
||||
sources[e.id] = e.source + " (disabled)"
|
||||
continue
|
||||
}
|
||||
spec := e.spec
|
||||
spec := cloneIntentSpec(e.spec)
|
||||
if err := validateSpec(spec, lim, opts.SlotValidators); err != nil {
|
||||
return nil, err.(*Error).with("source", e.source)
|
||||
}
|
||||
|
||||
@@ -167,6 +167,56 @@ func (cs *compiledSpec) extractSlots(input string) (slots map[string]string, mis
|
||||
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 {
|
||||
|
||||
Vendored
+575
@@ -0,0 +1,575 @@
|
||||
{
|
||||
"record_only": true,
|
||||
"cases": [
|
||||
{
|
||||
"name": "neg_do_not_restart",
|
||||
"category": "negation_reference",
|
||||
"input": "不要重启任务",
|
||||
"expect_branch": "fallback"
|
||||
},
|
||||
{
|
||||
"name": "neg_do_not_cancel_run",
|
||||
"category": "negation_reference",
|
||||
"input": "别取消任务 42",
|
||||
"expect_branch": "fallback"
|
||||
},
|
||||
{
|
||||
"name": "neg_no_need_progress",
|
||||
"category": "negation_reference",
|
||||
"input": "不用查进度了",
|
||||
"expect_branch": "fallback"
|
||||
},
|
||||
{
|
||||
"name": "neg_do_not_deploy",
|
||||
"category": "negation_reference",
|
||||
"input": "先不要发布",
|
||||
"expect_branch": "fallback"
|
||||
},
|
||||
{
|
||||
"name": "neg_excludes_config_progress",
|
||||
"category": "negation_reference",
|
||||
"input": "进度配置在哪里看",
|
||||
"expect_branch": "fallback"
|
||||
},
|
||||
{
|
||||
"name": "ref_same_as_before_no_context",
|
||||
"category": "negation_reference",
|
||||
"input": "还是刚才那个",
|
||||
"expect_branch": "fallback"
|
||||
},
|
||||
{
|
||||
"name": "ref_same_after_status",
|
||||
"category": "negation_reference",
|
||||
"turns": [
|
||||
{
|
||||
"input": "查询任务 42 的进度",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "demo.run.status_query",
|
||||
"expect_slots": {
|
||||
"run_id": "42"
|
||||
}
|
||||
},
|
||||
{
|
||||
"input": "还是刚才那个,再看下进度",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "demo.run.status_query"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ref_previous_task_hold_restart",
|
||||
"category": "negation_reference",
|
||||
"turns": [
|
||||
{
|
||||
"input": "查询任务 42 的进度",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "demo.run.status_query"
|
||||
},
|
||||
{
|
||||
"input": "刚才那个任务先别重启",
|
||||
"expect_branch": "fallback"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "multi_status_then_restart",
|
||||
"category": "multi_intent",
|
||||
"input": "查询任务 42 的进度,然后重启任务",
|
||||
"expect_branch": "ambiguous"
|
||||
},
|
||||
{
|
||||
"name": "multi_greet_then_report",
|
||||
"category": "multi_intent",
|
||||
"input": "你好,帮我生成报告,主题是电机",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "report.generate",
|
||||
"expect_slots": {
|
||||
"topic": "电机"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "multi_cancel_and_deploy",
|
||||
"category": "multi_intent",
|
||||
"input": "取消任务 42 并发布",
|
||||
"expect_branch": "ambiguous"
|
||||
},
|
||||
{
|
||||
"name": "multi_greet_then_status",
|
||||
"category": "multi_intent",
|
||||
"input": "你好,查一下任务 7 的进度",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "demo.run.status_query",
|
||||
"expect_slots": {
|
||||
"run_id": "7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "multi_restart_and_deploy",
|
||||
"category": "multi_intent",
|
||||
"input": "重启任务后再发布",
|
||||
"expect_branch": "ambiguous"
|
||||
},
|
||||
{
|
||||
"name": "multi_status_and_guard",
|
||||
"category": "multi_intent",
|
||||
"input": "查一下进度,顺便改密码",
|
||||
"expect_branch": "guard_hit",
|
||||
"expect_top": "guard.config_change"
|
||||
},
|
||||
{
|
||||
"name": "multi_report_and_cancel",
|
||||
"category": "multi_intent",
|
||||
"input": "生成报告,主题是电机,再取消任务 9",
|
||||
"expect_branch": "ambiguous"
|
||||
},
|
||||
{
|
||||
"name": "slot_cancel_no_id",
|
||||
"category": "wrong_slot",
|
||||
"turns": [
|
||||
{
|
||||
"input": "取消任务",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "demo.run.cancel",
|
||||
"expect_missing_slots": [
|
||||
"run_id"
|
||||
],
|
||||
"expect_commands": [
|
||||
{
|
||||
"kind": "start"
|
||||
},
|
||||
{
|
||||
"kind": "clarify",
|
||||
"missing": [
|
||||
"run_id"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "slot_cancel_chinese_numeral",
|
||||
"category": "wrong_slot",
|
||||
"turns": [
|
||||
{
|
||||
"input": "取消任务四十二",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "demo.run.cancel",
|
||||
"expect_missing_slots": [
|
||||
"run_id"
|
||||
],
|
||||
"expect_commands": [
|
||||
{
|
||||
"kind": "start"
|
||||
},
|
||||
{
|
||||
"kind": "clarify",
|
||||
"missing": [
|
||||
"run_id"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "slot_cancel_two_ids",
|
||||
"category": "wrong_slot",
|
||||
"turns": [
|
||||
{
|
||||
"input": "取消任务 42 和任务 43",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "demo.run.cancel",
|
||||
"expect_commands": [
|
||||
{
|
||||
"kind": "start"
|
||||
},
|
||||
{
|
||||
"kind": "clarify"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "slot_status_two_ids",
|
||||
"category": "wrong_slot",
|
||||
"turns": [
|
||||
{
|
||||
"input": "查询任务 42 还是任务 43 的进度",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "demo.run.status_query",
|
||||
"expect_commands": [
|
||||
{
|
||||
"kind": "start"
|
||||
},
|
||||
{
|
||||
"kind": "clarify"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "slot_status_id_underscore",
|
||||
"category": "wrong_slot",
|
||||
"turns": [
|
||||
{
|
||||
"input": "查询任务 abc_def 的进度",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "demo.run.status_query",
|
||||
"expect_commands": [
|
||||
{
|
||||
"kind": "start"
|
||||
},
|
||||
{
|
||||
"kind": "clarify"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "slot_cancel_dash_only",
|
||||
"category": "wrong_slot",
|
||||
"turns": [
|
||||
{
|
||||
"input": "取消任务 --",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "demo.run.cancel",
|
||||
"expect_commands": [
|
||||
{
|
||||
"kind": "start"
|
||||
},
|
||||
{
|
||||
"kind": "clarify"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "slot_report_empty_topic",
|
||||
"category": "wrong_slot",
|
||||
"turns": [
|
||||
{
|
||||
"input": "生成报告,主题是",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "report.generate",
|
||||
"expect_missing_slots": [
|
||||
"topic"
|
||||
],
|
||||
"expect_commands": [
|
||||
{
|
||||
"kind": "start"
|
||||
},
|
||||
{
|
||||
"kind": "clarify",
|
||||
"missing": [
|
||||
"topic"
|
||||
],
|
||||
"question": "要查询哪个主题?"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "exp_cancel_live_control",
|
||||
"category": "expired_confirmation",
|
||||
"turns": [
|
||||
{
|
||||
"input": "取消一下",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "demo.run.cancel",
|
||||
"expect_missing_slots": [
|
||||
"run_id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"input": "取消任务 42",
|
||||
"expect_branch": "expectation_hit",
|
||||
"expect_top": "demo.run.cancel",
|
||||
"expect_slots": {
|
||||
"run_id": "42"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "exp_cancel_expired_full_command",
|
||||
"category": "expired_confirmation",
|
||||
"turns": [
|
||||
{
|
||||
"input": "取消一下",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "demo.run.cancel"
|
||||
},
|
||||
{
|
||||
"input": "取消任务 42",
|
||||
"expectation_expired": true,
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "demo.run.cancel",
|
||||
"expect_slots": {
|
||||
"run_id": "42"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "exp_cancel_expired_bare_answer",
|
||||
"category": "expired_confirmation",
|
||||
"turns": [
|
||||
{
|
||||
"input": "取消一下",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "demo.run.cancel"
|
||||
},
|
||||
{
|
||||
"input": "就是任务 42",
|
||||
"expectation_expired": true,
|
||||
"expect_branch": "fallback"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "exp_report_expired_bare_topic",
|
||||
"category": "expired_confirmation",
|
||||
"turns": [
|
||||
{
|
||||
"input": "帮我生成报告",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "report.generate"
|
||||
},
|
||||
{
|
||||
"input": "主题是电机",
|
||||
"expectation_expired": true,
|
||||
"expect_branch": "fallback"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "exp_report_expired_full_command",
|
||||
"category": "expired_confirmation",
|
||||
"turns": [
|
||||
{
|
||||
"input": "帮我生成报告",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "report.generate"
|
||||
},
|
||||
{
|
||||
"input": "生成报告,主题是水泵",
|
||||
"expectation_expired": true,
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "report.generate",
|
||||
"expect_slots": {
|
||||
"topic": "水泵"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "exp_restart_confirm_expired",
|
||||
"category": "expired_confirmation",
|
||||
"turns": [
|
||||
{
|
||||
"input": "把任务重启一下",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "demo.run.restart"
|
||||
},
|
||||
{
|
||||
"input": "好的,确认",
|
||||
"pending_expectation": [
|
||||
"demo.run.restart"
|
||||
],
|
||||
"expectation_expired": true,
|
||||
"expect_branch": "fallback"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "exp_restart_confirm_expired_restated",
|
||||
"category": "expired_confirmation",
|
||||
"turns": [
|
||||
{
|
||||
"input": "把任务重启一下",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "demo.run.restart"
|
||||
},
|
||||
{
|
||||
"input": "确认重启任务",
|
||||
"pending_expectation": [
|
||||
"demo.run.restart"
|
||||
],
|
||||
"expectation_expired": true,
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "demo.run.restart"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "gen_status_agree",
|
||||
"category": "generation_consistency",
|
||||
"input": "查询任务 42 的进度",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "demo.run.status_query",
|
||||
"expect_slots": {
|
||||
"run_id": "42"
|
||||
},
|
||||
"generator_commands": [
|
||||
{
|
||||
"kind": "start",
|
||||
"action": {
|
||||
"kind": "handler",
|
||||
"id": "taskrun.query_status"
|
||||
},
|
||||
"slots": {
|
||||
"run_id": "42"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "gen_restart_agree",
|
||||
"category": "generation_consistency",
|
||||
"input": "把任务重启一下",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "demo.run.restart",
|
||||
"generator_commands": [
|
||||
{
|
||||
"kind": "start",
|
||||
"action": {
|
||||
"kind": "auto",
|
||||
"id": "demo.run.restart"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "gen_report_agree",
|
||||
"category": "generation_consistency",
|
||||
"input": "生成报告,主题是电机",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "report.generate",
|
||||
"expect_slots": {
|
||||
"topic": "电机"
|
||||
},
|
||||
"generator_commands": [
|
||||
{
|
||||
"kind": "start",
|
||||
"action": {
|
||||
"kind": "handler",
|
||||
"id": "reportHandler"
|
||||
},
|
||||
"slots": {
|
||||
"topic": "电机"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "gen_deploy_agree",
|
||||
"category": "generation_consistency",
|
||||
"input": "please DEPLOY it",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "demo.misc.deploy",
|
||||
"generator_commands": [
|
||||
{
|
||||
"kind": "start",
|
||||
"action": {
|
||||
"kind": "handler",
|
||||
"id": "deploy.run"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "gen_cancel_slot_disagree",
|
||||
"category": "generation_consistency",
|
||||
"input": "取消任务 42",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "demo.run.cancel",
|
||||
"expect_slots": {
|
||||
"run_id": "42"
|
||||
},
|
||||
"generator_commands": [
|
||||
{
|
||||
"kind": "start",
|
||||
"action": {
|
||||
"kind": "auto",
|
||||
"id": "demo.run.cancel"
|
||||
},
|
||||
"slots": {
|
||||
"run_id": "43"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "gen_progress_wrong_action",
|
||||
"category": "generation_consistency",
|
||||
"input": "帮我看下进度",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "demo.run.status_query",
|
||||
"generator_commands": [
|
||||
{
|
||||
"kind": "start",
|
||||
"action": {
|
||||
"kind": "handler",
|
||||
"id": "taskrun.restart"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "gen_greet_hallucinated_action",
|
||||
"category": "generation_consistency",
|
||||
"input": "你好呀",
|
||||
"expect_branch": "matched",
|
||||
"expect_top": "demo.greet.hello",
|
||||
"generator_commands": [
|
||||
{
|
||||
"kind": "start",
|
||||
"action": {
|
||||
"kind": "workflow",
|
||||
"id": "ghost.flow"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "gen_chitchat_agree",
|
||||
"category": "generation_consistency",
|
||||
"input": "今天天气不错",
|
||||
"expect_branch": "fallback",
|
||||
"generator_commands": [
|
||||
{
|
||||
"kind": "chitchat"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 由用户设置,回退时同步撤销。
|
||||
|
||||
Executable
+4
@@ -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
@@ -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" "$@"
|
||||
Executable
+16
@@ -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
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
# 通用任务入口:转调 scripts/dm 的 task 子命令(triage / start / pr-check)。
|
||||
set -euo pipefail
|
||||
exec "$(dirname "$0")/dm" task "$@"
|
||||
Reference in New Issue
Block a user