Why: Hosts also extract artifact IDs from text outside the gate; sharing one compiled pattern keeps the forms aligned. Evidence: additive export; the gates suite still passes. Impact: ArtifactIDPattern exported; gate internals use it too.
178 lines
5.5 KiB
Go
178 lines
5.5 KiB
Go
// Package gates 提供终轮门禁与进度提醒的组合机制(自产品下沉)。
|
||
// 产品依赖——工件存取、质量评分、验证器模型、提示词、进度快照——全部端口化;
|
||
// 判定逻辑(正则族)、续跑上限与节流策略由本包持有。
|
||
// 端口为 nil 时语义与「未启用该检查」一致,不改变既有行为。
|
||
package gates
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strings"
|
||
)
|
||
|
||
// 续跑与节流上限。与产品现网取值一致;消费方引用本包常量,不再各自硬编码。
|
||
const (
|
||
// MaxDeliverableGuards 是「声称交付但无工件」的纠正轮次上限。
|
||
MaxDeliverableGuards = 2
|
||
// MaxVerifyContinuations 是终轮检查的续跑上限(与内核 Stop 的 3 次上限对齐)。
|
||
MaxVerifyContinuations = 3
|
||
// ReminderToolGap 是连续这么多个工具轮没有更新计划或待办就提醒一次。
|
||
ReminderToolGap = 3
|
||
// MaxChecklistReminders 是每个 run 的进度提醒次数上限。
|
||
MaxChecklistReminders = 3
|
||
)
|
||
|
||
// ArtifactLookup 按 ID 取工件:名称、内容与是否存在。
|
||
type ArtifactLookup func(id string) (name string, content []byte, ok bool)
|
||
|
||
// QualityScorer 对文本类工件评分;nil 表示跳过评分(视为通过)。
|
||
// why 在未通过时给出可读原因。
|
||
type QualityScorer func(name string, content []byte) (passed bool, why string)
|
||
|
||
// ArtifactRef 是一次工件引用(库内最小形态;宿主负责投影自有类型)。
|
||
type ArtifactRef struct {
|
||
ID string
|
||
Name string
|
||
Kind string
|
||
}
|
||
|
||
var (
|
||
// ArtifactIDPattern 是工件编号的文本形态(门禁与宿主的引用提取共用)。
|
||
ArtifactIDPattern = regexp.MustCompile(`art_[0-9a-f]{12}`)
|
||
fileClaimRe = regexp.MustCompile(`(?s)(?:已生成|已保存|已导出|已写入).{0,120}?\.(?:docx|xlsx|pptx|pdf|png|jpe?g|csv|md|txt|json)\b`)
|
||
softClaimRe = regexp.MustCompile(`(?s)(?:文件|文档|工件).{0,40}?已(?:正常)?(?:生成|登记|保存|导出|写入)`)
|
||
forgedCallRe = regexp.MustCompile(`\[(?:调用工具|工具结果|产出工件) `)
|
||
)
|
||
|
||
// ClaimGate 判断终轮正文是否在没有对应工件的情况下声称交付。
|
||
// 引用本轮已产出、或确实存在的工件不算谎称;伪造调用标记始终拦截。
|
||
// lookup 为 nil 时,任何引用未产出工件的 art_ 编号都视为不存在(拦截)。
|
||
func ClaimGate(body string, produced []ArtifactRef, lookup ArtifactLookup) (bool, string) {
|
||
if strings.TrimSpace(body) == "" {
|
||
return false, ""
|
||
}
|
||
if forgedCallRe.MatchString(body) {
|
||
return true, "回复正文里出现了工具调用标记,但本轮没有真实的工具调用"
|
||
}
|
||
producedIDs := map[string]bool{}
|
||
for _, ref := range produced {
|
||
if ref.ID != "" {
|
||
producedIDs[ref.ID] = true
|
||
}
|
||
}
|
||
knownExisting := false
|
||
for _, id := range ArtifactIDPattern.FindAllString(body, -1) {
|
||
if producedIDs[id] {
|
||
continue
|
||
}
|
||
if lookup != nil {
|
||
if _, _, ok := lookup(id); ok {
|
||
knownExisting = true
|
||
continue
|
||
}
|
||
}
|
||
return true, "回复引用了不存在的工件"
|
||
}
|
||
if len(producedIDs) > 0 || knownExisting {
|
||
return false, ""
|
||
}
|
||
if fileClaimRe.MatchString(body) || softClaimRe.MatchString(body) {
|
||
return true, "回复声称已经生成或登记了文件,但这一轮没有产生对应的工件"
|
||
}
|
||
return false, ""
|
||
}
|
||
|
||
// CheckArtifacts 确认登记的工件存在且非空。
|
||
// md、txt、docx 还要过 scorer;其他类型只检查存在与非空。
|
||
func CheckArtifacts(refs []ArtifactRef, lookup ArtifactLookup, scorer QualityScorer) []string {
|
||
var reasons []string
|
||
for _, ref := range refs {
|
||
if ref.ID == "" {
|
||
reasons = append(reasons, "工件引用缺少 ID")
|
||
continue
|
||
}
|
||
name := ref.Name
|
||
if name == "" {
|
||
name = ref.ID
|
||
}
|
||
var (
|
||
content []byte
|
||
ok bool
|
||
artName string
|
||
)
|
||
if lookup != nil {
|
||
artName, content, ok = lookup(ref.ID)
|
||
}
|
||
if !ok {
|
||
reasons = append(reasons, name+" 不存在")
|
||
continue
|
||
}
|
||
if artName != "" {
|
||
name = artName
|
||
}
|
||
if len(content) == 0 {
|
||
reasons = append(reasons, name+" 内容为空")
|
||
continue
|
||
}
|
||
switch strings.ToLower(filepath.Ext(name)) {
|
||
case ".md", ".txt", ".docx":
|
||
if scorer == nil {
|
||
continue
|
||
}
|
||
passed, why := scorer(name, content)
|
||
if !passed {
|
||
if why == "" {
|
||
why = "评分未通过"
|
||
}
|
||
reasons = append(reasons, name+":"+why)
|
||
}
|
||
}
|
||
}
|
||
return reasons
|
||
}
|
||
|
||
// Verdict 是独立验证器的裁决。
|
||
type Verdict struct {
|
||
Passed bool `json:"passed"`
|
||
Reason string `json:"reason"`
|
||
NextAction string `json:"nextAction"`
|
||
}
|
||
|
||
// ParseVerdict 解析验证器输出:剥围栏、取最外层 JSON、必须有 passed 键。
|
||
func ParseVerdict(raw string) (Verdict, error) {
|
||
s := stripFence(raw)
|
||
start := strings.Index(s, "{")
|
||
end := strings.LastIndex(s, "}")
|
||
if start < 0 || end < start {
|
||
return Verdict{}, fmt.Errorf("验证器返回无法解析")
|
||
}
|
||
s = s[start : end+1]
|
||
var probe map[string]json.RawMessage
|
||
if err := json.Unmarshal([]byte(s), &probe); err != nil {
|
||
return Verdict{}, err
|
||
}
|
||
if _, ok := probe["passed"]; !ok {
|
||
return Verdict{}, fmt.Errorf("验证器返回无法解析")
|
||
}
|
||
var verdict Verdict
|
||
if err := json.Unmarshal([]byte(s), &verdict); err != nil {
|
||
return Verdict{}, err
|
||
}
|
||
return verdict, nil
|
||
}
|
||
|
||
func stripFence(s string) string {
|
||
s = strings.TrimSpace(s)
|
||
if !strings.HasPrefix(s, "```") {
|
||
return s
|
||
}
|
||
s = strings.TrimPrefix(s, "```")
|
||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||
s = s[i+1:]
|
||
}
|
||
s = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(s), "```"))
|
||
return s
|
||
}
|