Author SHA1 Message Date
weichen 61ad3caac4 Bump migrated dependency pins for the endpoint migration
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
governance / governance (pull_request) Successful in 39s
ci / vet-and-test (pull_request) Successful in 42s
Why: recursive submodule checkout reads pinned dep commits; their
.gitmodules now carries the new Gitea endpoint base only in the
migrated mains.

Evidence: host-verified; see Dev PR #706

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

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

Evidence: host-verified; see Dev PR #706

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

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

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

Impact: configuration only; no runtime behavior change.

Refs: Dev PR #706
2026-10-22 14:17:17 +00:00
18 changed files with 108 additions and 698 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
echo "SUBMODULE_TOKEN is not configured" >&2
exit 1
fi
git config --global url."https://weichen:${SUBMODULE_TOKEN}@git-home.u1680832.nyat.app:30319/".insteadOf "ssh://git@git-ssh.u1680832.nyat.app:31411/"
git config --global url."https://weichen:${SUBMODULE_TOKEN}@gitea.naitangbaobao.cn/".insteadOf "ssh://git@gitea.naitangbaobao.cn:52222/"
git -c fetch.recurseSubmodules=false submodule update --init --recursive
- name: Install Go 1.27.1
run: |
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
parsed_server = urlsplit(SERVER)
if parsed_server.scheme not in {"http", "https"} or not parsed_server.netloc:
sys.exit("Checkout server must be the platform-injected http(s) URL")
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
SSH = "ssh://git@gitea.naitangbaobao.cn:52222/weichen/"
# 审查过的白名单,独立于候选 .gitmodules:仅列出两个治理 gitlink。
REPOSITORIES = {
"deps/development-guidelines": "Development-Guidelines",
+4 -4
View File
@@ -1,12 +1,12 @@
[submodule "deps/development-guidelines"]
path = deps/development-guidelines
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Guidelines.git
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Development-Guidelines.git
[submodule "deps/error-code-system"]
path = deps/error-code-system
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Error-Code-System.git
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Error-Code-System.git
[submodule "deps/logging-system"]
path = deps/logging-system
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Logging-System.git
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Logging-System.git
[submodule "deps/development-manager"]
path = deps/development-manager
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Manager.git
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Development-Manager.git
+3 -3
View File
@@ -23,9 +23,9 @@
历史文档与旧链接里的 `git-panel.u1680832.nyat.app:30319` 已失效(TLS 握手失败),
见到一律按过期处理,不要再试。当前权威通道:
- **Web / API(HTTPS)**:`https://git-home.u1680832.nyat.app:30319`(Gitea 1.27.3,
- **Web / API(HTTPS)**:`https://gitea.naitangbaobao.cn`(Gitea 1.27.3,
API 前缀 `/api/v1`;偶发 broken pipe / 超时,重试即可)。
- **Git over SSH**:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/<Repo>.git`
- **Git over SSH**:`ssh://git@gitea.naitangbaobao.cn:52222/weichen/<Repo>.git`
(各仓 origin 与 gitlink 均为此格式)。
- **API token**:`~/.secrets/gitea`,两行键值 `URL_PREFIX=…` 与
`ACCESS_TOKEN=<40 位 hex>`;取值 `grep ACCESS_TOKEN ~/.secrets/gitea | cut -d= -f2-`,
@@ -33,7 +33,7 @@
(baseline 硬边界 2:只记录来源、字段与可用状态)。
- **SSH key**:`~/.ssh/home_gitea`(ED25519,公钥指纹
`SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20`,Gitea 侧密钥名 GMKtec);
`~/.ssh/config` 已有 `git-ssh.u1680832.nyat.app` 主机条目,克隆/推送直接可用。
`~/.ssh/config` 已有 `gitea.naitangbaobao.cn(端口 52222)` 主机条目,克隆/推送直接可用。
## Agent 执行与新项目准入(强制)
+2 -2
View File
@@ -6,7 +6,7 @@
- 目标/产品边界:通用意图识别基础库。提供意图定义(spec)、注册表与配置合并(registry/loader)、四级漏斗识别器(guard → expectation → rules → adjudicate)、槽位抽取与诊断(conformance/catalogcheck/intentctl)。不做动作执行、不内置 LLM 客户端、不含任何业务意图内容,不依赖 Agent-Native / Agent-Squad / Agent-System。
- 源码、资产、文档、机器契约位置:Go 源码在仓库根包目录;模块路径 `agent-intent`;人类文档在根 `README.md`、`docs/`、`policy/`;过程记录:任务卡与交付证据在 `.task/`,其他过程记录在 `docs/records/`;机器契约以 `intent` 包公开类型为准。
- 权威远端、集成基线、受保护分支:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Intent.git`,集成基线 `main`。
- 权威远端、集成基线、受保护分支:`ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Intent.git`,集成基线 `main`。
- 并行共同基线记录位置、合入通知/主动检查方式:任务主记录在 Gitea Issue/PR;合入前在本仓 `main` 上 `git fetch` 并确认 HEAD。
- 发布线与批准版本/实际制品查询入口:当前无独立制品仓库。可构建性以本仓 `main` 的 `go test` 为准;依赖子模块版本以 gitlink 为准,不把工作区未提交文件当发布组合。
- 提交/审核/合并/发布身份与权限:仓库所有者 weichen。本机提交不代替远端合并。
@@ -28,7 +28,7 @@
克隆:
```bash
git clone --recurse-submodules ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Intent.git
git clone --recurse-submodules ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Intent.git
# 已克隆时:git submodule update --init --recursive
```
+1 -1
View File
@@ -133,7 +133,7 @@ go run ./cmd/intentctl catalog -root configs/intents # 能力目录(注入
消费方把本库固定为 submodule,推荐路径 `deps/agent-intent`;版本以消费方 gitlink 为准,不执行 `submodule update --remote`:
```bash
git submodule add ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Intent.git deps/agent-intent
git submodule add ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Intent.git deps/agent-intent
```
```text
@@ -201,7 +201,7 @@ agent-intent/
- `.gitmodules`:`deps/development-guidelines`(dev-only,不进 go.mod)、`deps/error-code-system`、`deps/logging-system`(build-required)。
- **pin 与生态当前组合一致**:guidelines `26b562d2`、error-code-system `95a03b75`、logging-system `2c5716f`(实现时以 `git submodule status` 实测为准)。理由:消费方的 `submodule-consistency.sh` 校验嵌套副本 gitlink 与本仓一致,Agent-Intent 作为中间层必须与上下游同组合,否则 Squad/Native 接入时一致性校验失败。
- go.mod:`require error-code-system v0.0.0` / `require logging-system v0.0.0` + 两条 `replace => ./deps/...`;无 go.sum、测试带 `GOWORK=off`(同 Squad/Native)。
- 远端:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Intent.git`;初始化流程按 adoption 工作流第 2 节(远端需先建仓,Gitea API token 在 `~/.secrets/gitea`,实现阶段处理)。
- 远端:`ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Intent.git`;初始化流程按 adoption 工作流第 2 节(远端需先建仓,Gitea API token 在 `~/.secrets/gitea`,实现阶段处理)。
### Error-Code-System 上游变更
+3 -4
View File
@@ -33,7 +33,6 @@ 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 {
@@ -56,7 +55,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: cloneStrings(sl.enum), Format: specSlotFormat(cs.spec, sl.key),
Enum: sl.enum, Format: specSlotFormat(cs.spec, sl.key),
})
}
out = append(out, view)
@@ -125,7 +124,7 @@ func specSlotFormat(spec IntentSpec, key string) string {
// CatalogTopK 取目录的前 k 条:按 Priority 降序、ID 升序(确定性排序;
// 排序口径是 priority,不是语义相关度——相关度排序由宿主经 examples 自行计算)。
// k <= 0 时等同 Catalog() 全量。返回值与 Catalog 一样是深拷贝,不共享底层数组。
// k <= 0 时等同 Catalog() 全量。
func (s *Snapshot) CatalogTopK(k int) []CatalogSpec {
all := s.Catalog()
if k <= 0 || len(all) <= k {
@@ -142,5 +141,5 @@ func (s *Snapshot) CatalogTopK(k int) []CatalogSpec {
}
return all[i].ID < all[j].ID
})
return cloneCatalogSpecs(all[:k])
return all[:k]
}
+4 -85
View File
@@ -34,26 +34,6 @@ 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)
@@ -67,76 +47,15 @@ func cloneIntentSpec(in IntentSpec) IntentSpec {
out.Slots[i].Normalize = cloneStrings(in.Slots[i].Normalize)
}
}
out.Metadata = cloneStringMap(in.Metadata)
out.Action = cloneActionRef(in.Action)
out.Examples = cloneStrings(in.Examples)
return out
}
func cloneCatalogSlot(in CatalogSlot) CatalogSlot {
out := in
out.Enum = cloneStrings(in.Enum)
return out
}
func cloneCatalogSpec(in CatalogSpec) CatalogSpec {
out := in
out.Metadata = cloneStringMap(in.Metadata)
if in.Slots != nil {
out.Slots = make([]CatalogSlot, len(in.Slots))
for i := range in.Slots {
out.Slots[i] = cloneCatalogSlot(in.Slots[i])
if in.Metadata != nil {
out.Metadata = make(map[string]string, len(in.Metadata))
for k, v := range in.Metadata {
out.Metadata[k] = v
}
}
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
View File
@@ -7,10 +7,10 @@ import "context"
// 只在规则与语义都未命中(fallback)时被调用;实现自担延迟与成本。
// 返回错误时识别保持 fallback,不会使 Recognize 失败。
//
// 库侧校验:start 的 ActionRef 必须能从同一快照唯一解析回权威 spec
// (spec ID、Action 别名、或唯一 handler 反查);槽位键必须在目标 spec
// 键集内,随后跑与规则路径同款的必填/枚举/format 校验并挂上元数据。
// 解析失败或未知键一律 fail-closed 回 fallback。
// 库侧校验与 deriveCommands 同款:start 命令的 ActionRef 必须可解析——
// auto/command/workflow/blueprint 的目标必须在 catalog 的 spec ID 集合内,
// handler 的目标必须在 catalog 的 Handler 集合内;槽位键必须在目标 spec 的
// 槽位键集合内(可解析时)。生成器幻觉一律 fail-closed 回 fallback。
type CommandGenerator interface {
// Generate 基于输入与能力目录产出命令列表(catalog 由调用方按 top-k 给)。
Generate(ctx context.Context, input string, catalog []CatalogSpec) ([]Command, error)
+64 -140
View File
@@ -120,8 +120,7 @@ func (r *FunnelRecognizer) recognize(ctx context.Context, input string, snap *Sn
}
}
if r.cls != nil {
// 传入候选副本:分类器就地改写不会污染原候选(失败时仍返回未篡改集)。
refined, err := r.cls.Refine(ctx, input, cloneCandidates(cands))
refined, err := r.cls.Refine(ctx, input, 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}
@@ -176,11 +175,10 @@ func validateRefined(original, refined []Candidate) ([]Candidate, error) {
if !ok {
return nil, fmt.Errorf("unknown refined id %s", got.ID)
}
norm, ok := normalizeScore(got.Confidence)
if !ok {
if !validConfidence(got.Confidence) {
return nil, fmt.Errorf("confidence out of range for %s", got.ID)
}
src.Confidence = norm
src.Confidence = got.Confidence
out[i] = src
}
return out, nil
@@ -190,15 +188,6 @@ 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 {
@@ -300,7 +289,6 @@ 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
@@ -312,21 +300,14 @@ func (r *FunnelRecognizer) semanticRerank(ctx context.Context, input string, can
if !ok || len(spec.Examples) == 0 {
continue
}
examples := cloneStrings(spec.Examples)
s, err := r.sem.Score(ctx, input, examples)
s, err := r.sem.Score(ctx, input, spec.Examples)
if err != nil {
r.log.Module("intent").WithContext(ctx).Warn("intent semantic scorer failed; keep ambiguous",
"error", err.Error(), "id", c.ID)
continue
}
norm, ok := normalizeScore(s)
if !ok {
r.log.Module("intent").WithContext(ctx).Warn("intent semantic score rejected; keep ambiguous",
"id", c.ID, "score", s)
continue
}
if norm >= r.thr {
above = append(above, scored{cand: c, score: norm})
if s >= r.thr {
above = append(above, scored{cand: c, score: s})
}
}
if len(above) == 0 {
@@ -346,9 +327,9 @@ func (r *FunnelRecognizer) semanticRerank(ctx context.Context, input string, can
}
// generateCommands 经模型命令生成端口兜底(I2)。校验:
// start 的 ActionRef 必须能从同一快照唯一解析回权威 spec(含 Action 别名与
// handler→spec 反查)、命令种类受限、槽位键合法;解析成功后对生成槽位跑与
// 规则路径同款的全量校验并挂上 risk/confirm/action 元数据。任一项不过则保持 fallback(无 Top)。
// start 的 ActionRef 可解析(spec ID 集或 Handler 集内)、命令种类受限、
// 槽位键在目标 spec 的键集内(可解析时);全部通过则把首个 start 的目标
// spec 合成 Top(refined_by=generator 标注),任一项不过则保持 fallback。
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)
@@ -356,7 +337,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, snap); err != nil {
if err := validateGenerated(cmds, catalog); err != nil {
r.log.Module("intent").WithContext(ctx).Warn("intent generator output rejected; keep fallback", "error", err.Error())
return Result{}, false
}
@@ -365,14 +346,16 @@ func (r *FunnelRecognizer) generateCommands(ctx context.Context, input string, s
if c.Kind != CommandStart {
continue
}
cs, err := snap.resolveActionRef(c.Action)
if err != nil || cs == nil {
break
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}
}
top := candidateFromGenerated(cs, c.Slots)
res.Top = &top
res.Branch = BranchMatched
res.Candidates = []Candidate{top}
break
}
return res, true
@@ -381,53 +364,46 @@ 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)。
// ActionRef 经快照唯一解析;槽位未知键在此拒绝(与规则路径「键集外」同款 fail-closed)。
func validateGenerated(cmds []Command, snap *Snapshot) error {
func validateGenerated(cmds []Command, catalog []CatalogSpec) 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:
cs, err := snap.resolveActionRef(c.Action)
if err != nil {
return fmt.Errorf("cmd[%d] start: %w", i, err)
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)
}
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)
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)
}
}
}
case CommandClarify, CommandCancel, CommandSetSlot, CommandChangeSlot, CommandChitchat, CommandHandoff:
@@ -438,70 +414,18 @@ func validateGenerated(cmds []Command, snap *Snapshot) error {
return nil
}
// resolveActionRef 把 start 的 ActionRef 解析回快照内唯一权威 compiledSpec。
// ActionCommand/Workflow/Blueprint/Auto:先按 spec ID,再按 spec.Action 声明的别名;
// ActionHandler:经 handler→spec 反查,重名拒绝。
func (s *Snapshot) resolveActionRef(ref ActionRef) (*compiledSpec, error) {
switch ref.Kind {
case ActionHandler:
return s.resolveByHandler(ref.ID)
case ActionAuto, ActionCommand, ActionWorkflow, ActionBlueprint:
return s.resolveBySpecOrAlias(ref)
default:
return nil, fmt.Errorf("未知 action.kind %q", ref.Kind)
// 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
}
continue
}
if cs.ID == ref.ID {
return cs, true
}
}
}
func (s *Snapshot) resolveByHandler(handler string) (*compiledSpec, error) {
if strings.TrimSpace(handler) == "" {
return nil, fmt.Errorf("handler 为空")
}
var found *compiledSpec
for _, cs := range s.specs {
if cs.guard {
continue
}
if cs.spec.Handler != handler {
continue
}
if found != nil {
return nil, fmt.Errorf("handler %q 对应多个 spec(歧义)", handler)
}
found = cs
}
if found == nil {
return nil, fmt.Errorf("handler %q 不在快照内", handler)
}
return found, nil
}
func (s *Snapshot) resolveBySpecOrAlias(ref ActionRef) (*compiledSpec, error) {
if strings.TrimSpace(ref.ID) == "" {
return nil, fmt.Errorf("action.id 为空")
}
var found *compiledSpec
for _, cs := range s.specs {
if cs.guard {
continue
}
match := cs.spec.ID == ref.ID
if !match && cs.spec.Action != nil && cs.spec.Action.Kind == ref.Kind && cs.spec.Action.ID == ref.ID {
match = true
}
// ActionAuto 也接受「合法别名」:显式 Action 的 ID 与 kind 匹配任一声明。
if !match && ref.Kind == ActionAuto && cs.spec.Action != nil && cs.spec.Action.ID == ref.ID {
match = true
}
if !match {
continue
}
if found != nil && found != cs {
return nil, fmt.Errorf("action.id %q 对应多个 spec(歧义)", ref.ID)
}
found = cs
}
if found == nil {
return nil, fmt.Errorf("action.id %q 不在快照内", ref.ID)
}
return found, nil
return CatalogSpec{}, false
}
+16 -17
View File
@@ -60,7 +60,7 @@ const (
)
func (o Options) normalized() Options {
n := cloneOptions(o)
n := o
if n.TopN <= 0 {
n.TopN = DefaultTopN
}
@@ -85,6 +85,13 @@ 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
}
@@ -196,7 +203,7 @@ func compileSpec(spec IntentSpec, guard bool, index int, scoped map[string]SlotV
return out
}
cs := &compiledSpec{
spec: cloneIntentSpec(spec),
spec: spec,
termsAny: fold(m.IncludesAny),
termsAll: fold(m.IncludesAll),
termsEx: fold(m.ExcludesAny),
@@ -474,7 +481,6 @@ func NewRegistryBuilder(opts Options) *RegistryBuilder {
}
// Register 登记内置基线 spec,立即校验;重复 id 报 InvalidSpec。
// 登记时深拷贝:调用方之后改入参不会影响已登记定义。
func (b *RegistryBuilder) Register(specs ...IntentSpec) error {
lim := b.opts.limits()
for _, spec := range specs {
@@ -485,7 +491,7 @@ func (b *RegistryBuilder) Register(specs ...IntentSpec) error {
return err
}
b.seen[spec.ID] = true
b.builtin = append(b.builtin, cloneIntentSpec(spec))
b.builtin = append(b.builtin, spec)
}
return nil
}
@@ -520,13 +526,9 @@ 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: cloneOptions(b.opts),
builtin: builtin,
opts: b.opts,
builtin: append([]IntentSpec(nil), b.builtin...),
fsys: fsys,
roots: append([]string(nil), roots...),
hasSource: fsys != nil,
@@ -551,8 +553,8 @@ type Registry struct {
// Snapshot 返回当前生效快照。永不为 nil。
func (r *Registry) Snapshot() *Snapshot { return r.snap.Load() }
// Options 返回生效选项(已填默认值)的深拷贝:改切片/校验器表不影响 Registry。
func (r *Registry) Options() Options { return cloneOptions(r.opts) }
// Options 返回生效选项(已填默认值)。
func (r *Registry) Options() Options { return r.opts }
// Reload 重读配置来源并构建新快照;成功后原子替换并返回新快照。
// 任一步失败保留旧快照,revision 不变,返回带来源与原因的错误。
@@ -588,10 +590,7 @@ func buildSnapshot(opts Options, baseline []IntentSpec, entries []overlayEntry,
guards := opts.guardSet()
lim := opts.limits()
ordered := make([]IntentSpec, len(baseline))
for i, spec := range baseline {
ordered[i] = cloneIntentSpec(spec)
}
ordered := append([]IntentSpec(nil), baseline...)
pos := make(map[string]int, len(ordered))
sources := make(map[string]string, len(ordered))
for i, spec := range ordered {
@@ -621,7 +620,7 @@ func buildSnapshot(opts Options, baseline []IntentSpec, entries []overlayEntry,
sources[e.id] = e.source + " (disabled)"
continue
}
spec := cloneIntentSpec(e.spec)
spec := e.spec
if err := validateSpec(spec, lim, opts.SlotValidators); err != nil {
return nil, err.(*Error).with("source", e.source)
}
-50
View File
@@ -167,56 +167,6 @@ 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 {
-381
View File
@@ -1,381 +0,0 @@
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)
}
})
}