From 45b8a38710fd5a4688a27297d2513b1619fbf9be Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 1 Oct 2026 09:06:36 +0000 Subject: [PATCH] Add cross-task content methods, visuals, and scoring Why: Layout rules alone do not produce conclusion-first documents, extra views, or infographics, and nothing scored a deliverable before it was returned. Evidence: go test ./internal/prompts/ ./internal/quality/ ./internal/tools/ ./internal/squadadapter/ ./eval/ -count=1 and the house-style unittest passed in the previous session. Impact: Every task inherits content rules. Meetings, reports, and comparisons can name a view family. Long documents without a visual summary warn. quality_review and the squad checker share one scorecard. Refs: deliverable-quality-system A1-A5 Co-authored-by: Cursor --- eval/README.md | 9 + eval/compare.go | 14 + eval/deliverable_test.go | 62 ++++ eval/testdata/bad.md | 3 + eval/testdata/good.md | 16 + internal/prompts/library/base-deliverables.md | 13 +- internal/prompts/library/base-quality.md | 8 +- internal/prompts/library/method-distill.md | 15 + internal/prompts/library/method-insight.md | 15 + internal/prompts/library/method-timeline.md | 15 + internal/prompts/library/task-compare.md | 4 +- internal/prompts/library/task-doc-minutes.md | 6 +- internal/prompts/library/task-insight.md | 25 ++ internal/prompts/library/task-report.md | 4 +- .../prompts/library/tool-quality-review.md | 8 + internal/prompts/prompts_test.go | 23 +- internal/prompts/service.go | 21 ++ internal/quality/checker.go | 32 ++ internal/quality/score.go | 288 ++++++++++++++++++ internal/quality/score_test.go | 29 ++ internal/skills/recipes/house-style/SKILL.md | 6 + .../recipes/house-style/housestyle/lint.py | 15 + .../recipes/house-style/housestyle/visuals.py | 233 ++++++++++++++ .../house-style/tests/test_housestyle.py | 48 ++- internal/squadadapter/adapter.go | 3 + internal/tools/quality_review.go | 51 ++++ internal/tools/quality_review_test.go | 52 ++++ internal/tools/registry.go | 3 + 28 files changed, 1008 insertions(+), 13 deletions(-) create mode 100644 eval/README.md create mode 100644 eval/compare.go create mode 100644 eval/deliverable_test.go create mode 100644 eval/testdata/bad.md create mode 100644 eval/testdata/good.md create mode 100644 internal/prompts/library/method-distill.md create mode 100644 internal/prompts/library/method-insight.md create mode 100644 internal/prompts/library/method-timeline.md create mode 100644 internal/prompts/library/task-insight.md create mode 100644 internal/prompts/library/tool-quality-review.md create mode 100644 internal/quality/checker.go create mode 100644 internal/quality/score.go create mode 100644 internal/quality/score_test.go create mode 100644 internal/skills/recipes/house-style/housestyle/visuals.py create mode 100644 internal/tools/quality_review.go create mode 100644 internal/tools/quality_review_test.go diff --git a/eval/README.md b/eval/README.md new file mode 100644 index 0000000..094687b --- /dev/null +++ b/eval/README.md @@ -0,0 +1,9 @@ +# 交付物评测 + +仓内 `testdata/` 是脱敏短样例,用来确认评分卡能区分“结论先行”和“栏目式笔记”。 + +真实会议材料不要放进仓库。放到 `~/.agent-system/eval/`,或设置 `EVAL_DIR`。其中的 `.md` 会在 `go test ./eval/` 里被打分并打印,缺失该目录时测试跳过。 + +``` +cd modules/backend && go test ./eval/ ./internal/quality/ -count=1 +``` diff --git a/eval/compare.go b/eval/compare.go new file mode 100644 index 0000000..257979e --- /dev/null +++ b/eval/compare.go @@ -0,0 +1,14 @@ +package eval + +import "strings" + +// MissingThemes 报告候选文本没有覆盖的参考主题。主题是短词,不是整句照抄。 +func MissingThemes(candidate string, themes []string) []string { + var missing []string + for _, theme := range themes { + if !strings.Contains(candidate, theme) { + missing = append(missing, theme) + } + } + return missing +} diff --git a/eval/deliverable_test.go b/eval/deliverable_test.go new file mode 100644 index 0000000..1764acd --- /dev/null +++ b/eval/deliverable_test.go @@ -0,0 +1,62 @@ +// Package eval 用固定样例比较交付质量。真实会议材料放在仓外 +// ~/.agent-system/eval/,或用 EVAL_DIR 指向该目录;仓内只放脱敏短样例。 +package eval + +import ( + "os" + "path/filepath" + "testing" + + "agent-system-backend/internal/quality" +) + +func TestDesensitizedSampleBeatsColumnNotes(t *testing.T) { + good := scoreFixture(t, "good.md") + bad := scoreFixture(t, "bad.md") + if !good.Passed { + t.Fatalf("good fixture should pass: %+v", good) + } + if bad.Score >= good.Score { + t.Fatalf("bad %d should be below good %d", bad.Score, good.Score) + } + missing := MissingThemes(readFixture(t, "good.md"), []string{"相关性", "标签", "未指明"}) + if len(missing) > 0 { + t.Fatalf("good fixture missed themes %v", missing) + } +} + +func TestExternalEvalDirOptional(t *testing.T) { + dir := os.Getenv("EVAL_DIR") + if dir == "" { + home, err := os.UserHomeDir() + if err != nil { + t.Skip(err) + } + dir = filepath.Join(home, ".agent-system", "eval") + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Skip("no external eval dir") + } + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".md" { + continue + } + report := quality.ScoreFile(filepath.Join(dir, entry.Name())) + t.Logf("%s score=%d passed=%v", entry.Name(), report.Score, report.Passed) + } +} + +func scoreFixture(t *testing.T, name string) quality.Report { + t.Helper() + return quality.ScoreText(readFixture(t, name)) +} + +func readFixture(t *testing.T, name string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join("testdata", name)) + if err != nil { + t.Fatal(err) + } + return string(data) +} diff --git a/eval/testdata/bad.md b/eval/testdata/bad.md new file mode 100644 index 0000000..711af83 --- /dev/null +++ b/eval/testdata/bad.md @@ -0,0 +1,3 @@ +# 讨论过程 + +今天围绕文档上传聊了一会儿。大家说了很多,也有一些不同看法。后续再看吧。 diff --git a/eval/testdata/good.md b/eval/testdata/good.md new file mode 100644 index 0000000..714041f --- /dev/null +++ b/eval/testdata/good.md @@ -0,0 +1,16 @@ +# 上传文档应先判断相关性,再打标签入库 + +## 先校验,再生成 +说话人1指出,上传后的第一步是判断文档与项目是否相关。确认后再拆开打标签。 +一句话:没有这层记忆,后面的摘要和追溯都没有依据。 + +## 不要在上传时提示缺什么 +依据会议原话,缺项提示放到体系成型之后。 +一句话:上传节点是收集,不是验收。 + +## 基线要能迭代 +新内容和已有基线比对,冲突时先问人。 +一句话:一份基线反复补,而不是每次上传都新开一篇。 + +## 下一步 +事项是写出第一版需求。负责人是陈威。期限是周五前。说不清的一律写未指明。 diff --git a/internal/prompts/library/base-deliverables.md b/internal/prompts/library/base-deliverables.md index bdf5339..24f82ed 100644 --- a/internal/prompts/library/base-deliverables.md +++ b/internal/prompts/library/base-deliverables.md @@ -1,7 +1,7 @@ --- id: base.deliverables layer: base -version: 3 +version: 4 title: 交付物工作流 description: 生成文档、表格、演示稿的工作流、样式决策与技能入口(常驻基座) --- @@ -18,6 +18,17 @@ description: 生成文档、表格、演示稿的工作流、样式决策与技 没有调用工具、没有登记工件时,禁止说「已生成 / 已保存 / 已导出」。回复里不要写工件 ID(art_…)。 +交付物族(所有任务共用,不限于会议): + +- 会议:主视图是纪要摘要(method.distill)。还可生成智能笔记(method.timeline)和洞察报告(method.insight)。 +- 报告:主视图是完整版。还可生成执行摘要和一页纸。 +- 方案:主视图是方案正文。还可生成评审要点。 +- 对比:主视图是对比矩阵加分场景结论。还可生成一页纸决策说明。 + +默认只生成主视图。登记之后,如果有 submit_card,用它交一张标题为「还可以生成」的卡片,正文每行列出一个其余视图、它回答的问题,以及用户怎样一句话让你再生成。没有 submit_card 时,把同一份清单写在回复末尾。用户点名某一视图再生成那一篇,不要一次把整族都写完。 + +长文档(一级标题达到四节)在正文里放至少一个视觉摘要:状态卡、卡片网格、三栏要求或流程图。用 housestyle.visuals,不要用文字方框假装图形。生成后用 quality_review 自检,不通过就按意见改一版再登记。 + 技能与路径: - 技能目录是环境变量 `AGENT_SKILLS_DIR`(环境段会写出绝对路径)。`PYTHONPATH` 已包含其中的 `house-style`,脚本里直接 `import housestyle`。 diff --git a/internal/prompts/library/base-quality.md b/internal/prompts/library/base-quality.md index b160d36..8cf616b 100644 --- a/internal/prompts/library/base-quality.md +++ b/internal/prompts/library/base-quality.md @@ -1,7 +1,7 @@ --- id: base.quality layer: base -version: 1 +version: 2 title: 专业交付标准 description: 对回复、文档、表格、演示、代码与数据一律适用的结构、语言、数据与版式标准(常驻基座) --- @@ -9,9 +9,11 @@ description: 对回复、文档、表格、演示、代码与数据一律适用 结构: -- 结论先行:先给结论、建议或交付物,再给依据。按金字塔组织,只看开头也能决策。 -- 同一层级互斥且穷尽:要点不重叠、不遗漏。标题写成判断或主题,不用“概述”“其他”充数。 +- 结论先行:先给结论、建议或交付物,再给依据。标题写成判断,读者只看标题也能知道这一节主张什么。 +- 每一组要点后面用一句话收束:这句话要能独立成立,不要复述要点原文。 +- 同一层级互斥且穷尽:要点不重叠、不遗漏。不用“概述”“其他”“讨论过程”“补充说明”这种栏目名当标题。 - 层级不超过三级。每一节都有实质内容;没有内容就不要开这一节。 +- 行动项写清事项、负责人和时间。原文没有的写“未指明”,不编造人名或日期。 - 先规划结构再落笔。修改已有内容时先通读再改,范围与请求一致。 语言: diff --git a/internal/prompts/library/method-distill.md b/internal/prompts/library/method-distill.md new file mode 100644 index 0000000..ca568c9 --- /dev/null +++ b/internal/prompts/library/method-distill.md @@ -0,0 +1,15 @@ +--- +id: method.distill +layer: aux +version: 1 +title: 提炼成主题 +description: 把材料收成互斥的主题,主题名就是结论,每组要点后有一句话总结 +--- +按提炼法组织内容,适用于纪要摘要、报告正文、方案要点,不限于某一种文档。 + +- 先通读全部材料,再按读者要做的决定分组。不要按发言顺序或原文小标题照抄。 +- 每个主题的标题写成一句判断。禁止用“讨论过程”“概述”“其他”“补充”当标题。 +- 同一层主题互斥且穷尽:一条信息只进一个主题;材料里影响决定的内容都要有去处。 +- 每个主题先列要点,再写一行“一句话:……”。这一行必须能脱离要点单独成立。 +- 要点里的判断要能回到材料:写明说话人、章节或文件名。材料没说的标“未指明”。 +- 行动放在最后一节,用表格:事项、负责人、期限。缺了就写“未指明”。 diff --git a/internal/prompts/library/method-insight.md b/internal/prompts/library/method-insight.md new file mode 100644 index 0000000..3a80569 --- /dev/null +++ b/internal/prompts/library/method-insight.md @@ -0,0 +1,15 @@ +--- +id: method.insight +layer: aux +version: 1 +title: 洞察四层 +description: 用一句话洞察开头,每个发现按现象、影响、隐性顾虑、建议展开 +--- +按洞察法组织内容。它回答“所以呢”,不是复述发生了什么。会议、报告、方案、复盘都可以用。 + +- 开头先写“一句话洞察”:一句就能让决策者知道症结和建议方向。不要用背景铺垫开头。 +- 每个发现用四层,缺一不可:现象(材料里实际说了什么)、影响(若不处理会怎样)、隐性顾虑(团队可能没说出口的假设)、建议(下一步具体做什么)。 +- 发现的标题写成判断,可以带一个语义符号帮助扫读,但正文仍然是书面语。 +- 建议必须可执行:谁、做什么、做到什么程度算完成。材料没指定负责人时写“未指明”,并说明建议由谁确认。 +- 结尾单列“下一步行动”,每条一个动作,不把四层分析再抄一遍。 +- 不把转录里的口误、重复和跑题写进洞察。和决定无关的细节留给时间线视图。 diff --git a/internal/prompts/library/method-timeline.md b/internal/prompts/library/method-timeline.md new file mode 100644 index 0000000..fd65d91 --- /dev/null +++ b/internal/prompts/library/method-timeline.md @@ -0,0 +1,15 @@ +--- +id: method.timeline +layer: aux +version: 1 +title: 时间线保留原意 +description: 按时间顺序保留发言人和原意,便于回查,不把讨论改写成结论 +--- +按时间线组织内容。它负责可回查,不负责下结论。会议记录、访谈、评审过程都适用。 + +- 用材料里的时间戳或顺序分节。没有时间戳时按材料顺序,并注明“原文无时间戳”。 +- 保留发言人。原文是“说话人1”就保留,不猜测真名;文末说明称谓来源。 +- 每条尽量用原意,可以删口头禅和重复,不可以改成你的结论或纠正说话人。 +- 同一时段里换了议题就新开一节,节标题写这一段实际在谈的主题,不用“讨论过程”。 +- 数字、专名、否定(“不要”“不是”)必须和原文一致。听不清或互相矛盾的地方照录,并标“原文如此”。 +- 不在这一视图里写建议或决议。需要判断时改用提炼法或洞察法,另成一篇。 diff --git a/internal/prompts/library/task-compare.md b/internal/prompts/library/task-compare.md index ee434fb..1e72204 100644 --- a/internal/prompts/library/task-compare.md +++ b/internal/prompts/library/task-compare.md @@ -1,7 +1,7 @@ --- id: task.compare layer: task -version: 2 +version: 3 title: 对比分析 description: 对多个对象(产品/方案/数据集)做多维对比并给结论 priority: 55 @@ -17,7 +17,7 @@ triggers: - kind: command command: /compare --- -用户要求对比分析。执行要求: +用户要求对比分析。主视图按 method.distill 写成“该选什么”的判断,不要写成功能清单。执行要求: - 对比对象与维度先对齐:用户指定的按用户;未指定的按任务本质选影响决策的维度,说明选择理由。 - 数据可比性优先:同一维度下口径必须一致(时间范围、统计方式);口径不一致时先归一或标注不可比。 diff --git a/internal/prompts/library/task-doc-minutes.md b/internal/prompts/library/task-doc-minutes.md index 1530c5f..f3c23a3 100644 --- a/internal/prompts/library/task-doc-minutes.md +++ b/internal/prompts/library/task-doc-minutes.md @@ -1,7 +1,7 @@ --- id: task.doc-minutes layer: task -version: 2 +version: 3 title: 会议纪要生成 description: 依据上传的会议记录/录音稿生成结构完整的会议纪要 priority: 70 @@ -19,11 +19,11 @@ triggers: - kind: command command: /minutes --- -用户要求依据上传文档生成会议纪要。严格按以下要求执行: +用户要求依据上传文档生成会议纪要。主视图按 method.distill 写纪要摘要。严格按以下要求执行: - 以文档全文为依据,通读后再动笔;覆盖全部议题,不遗漏要点,不臆造文档之外的内容;无法确认的信息标注"文档未明确"。 - 按输出契约的结构组织;人名、时间、数字必须与原文一致,不确定的保留原文写法。 -- 区分三件事:讨论过程(谁说了什么)、形成的决议(集体结论)、待办事项(谁在何时前做什么)——不要把发言当决议。 +- 区分三件事:谁说了什么、集体结论、待办事项。不要把发言当决议,也不要开一节叫“讨论过程”。谁说了什么放在要点的出处里;需要逐句回查时,另生成智能笔记(method.timeline),不要塞进摘要。 - 内容完整详实,除非用户明确要求精简,不要压缩篇幅;原文模糊或矛盾的点在"未决问题"中列出而不是擅自裁决。 - 待办事项若原文未指明负责人或期限,写"未指明",不要编造。 - 版面:标题下用键值表写会议信息(时间、地点、主持人、参会人、缺席)。讨论按议题分节,每节含要点、分歧和结论。决议用真编号列表,不要再手写“1.”。待办用表格,列:序号、事项、负责人、期限、状态;原文没有的写“未指明”。发言人用统一称谓(职务或姓名);原文只有“说话人X”时保留并在文末说明。引语只留关键句,并标注是谁说的。 diff --git a/internal/prompts/library/task-insight.md b/internal/prompts/library/task-insight.md new file mode 100644 index 0000000..39cd6f4 --- /dev/null +++ b/internal/prompts/library/task-insight.md @@ -0,0 +1,25 @@ +--- +id: task.insight +layer: task +version: 1 +title: 洞察报告 +description: 从材料里抽出决策者需要知道的判断,而不是复述过程 +priority: 72 +requiresAttachments: true +outputContract: | + 1. 一句话洞察 + 2. 若干发现:现象、影响、隐性顾虑、建议 + 3. 下一步行动 +triggers: +- kind: intent + keywords: 洞察, 洞察报告 +- kind: command + command: /insight +--- +用户要的是判断,不是会议记录。按 method.insight 写洞察报告。 + +- 开头就是一句话洞察。背景最多一句,而且要服务于这句判断。 +- 每个发现四层都要有。建议写到可以交给人去做的程度。 +- 标题是结论。不要用“讨论要点”“过程回顾”。 +- 材料里的口误和跑题不进入正文。需要保留原话时,另做时间线(method.timeline)。 +- 交付走交付物工作流。一级标题达到四节时放一个视觉摘要。quality_review 通过后再登记。回复只复述一句话洞察和所用样式。 diff --git a/internal/prompts/library/task-report.md b/internal/prompts/library/task-report.md index 773000c..3fe6b48 100644 --- a/internal/prompts/library/task-report.md +++ b/internal/prompts/library/task-report.md @@ -1,7 +1,7 @@ --- id: task.report layer: task -version: 2 +version: 3 title: 报告撰写 description: 把材料组织成正式报告/公文(含执行摘要与附录建议) priority: 55 @@ -17,7 +17,7 @@ triggers: - kind: command command: /report --- -用户要求撰写正式报告。执行要求: +用户要求撰写正式报告。主视图是完整版,正文按 method.distill 组织;若用户要的是判断和风险而不是叙述,改用 method.insight。执行要求: - 先明确报告的受众与用途(对上汇报/对外发布/内部存档),文风与详略随之调整;未说明时按内部正式报告处理并标注假设。 - 事实与观点分离:分析章节可以论证,事实陈述要有出处(文档段落/数据表)。 diff --git a/internal/prompts/library/tool-quality-review.md b/internal/prompts/library/tool-quality-review.md new file mode 100644 index 0000000..a6e7bfd --- /dev/null +++ b/internal/prompts/library/tool-quality-review.md @@ -0,0 +1,8 @@ +--- +id: tool.quality_review +layer: tool +version: 1 +title: quality_review 工具描述 +description: 交付物评分卡。登记前调用,不通过就按意见改 +--- +给工作区内的交付物打分。path 用相对路径。返回 JSON:passed、score、六个维度(含引用原文的 evidence)和 violations。passed 为 false 时先改再登记,不要带着失败结果声称完成。 diff --git a/internal/prompts/prompts_test.go b/internal/prompts/prompts_test.go index 8e3aa6b..9a63ee0 100644 --- a/internal/prompts/prompts_test.go +++ b/internal/prompts/prompts_test.go @@ -46,7 +46,8 @@ func TestDefaultLibraryParses(t *testing.T) { } // 关键模块在位:基座五块 + 环境 + 纪要技能 + 通用子代理。 for _, id := range []string{"base.identity", "base.behavior", "base.caution", "base.tools", "base.style", "base.quality", - "env.runtime", "task.doc-minutes", "agent.sub.general", "aux.session-compact", "tool.tavily_search", "tool.web_search"} { + "env.runtime", "task.doc-minutes", "task.insight", "method.distill", "method.insight", "method.timeline", + "agent.sub.general", "aux.session-compact", "tool.tavily_search", "tool.web_search"} { if !seen[id] { t.Errorf("required module missing: %s", id) } @@ -153,6 +154,26 @@ func TestResolveSystemSkillTriggers(t *testing.T) { } } +func TestTaskInjectsNamedMethods(t *testing.T) { + svc := mustService(t) + _, hits, err := svc.ResolveSystem(core.AgentProfile{}, TriggerCtx{Text: "根据文档生成会议纪要", HasAttachments: true}, EnvData{}, "") + if err != nil { + t.Fatal(err) + } + injected := map[string]bool{} + for _, h := range hits { + if h.Injected { + injected[h.ModuleID] = true + } + } + if !injected["method.distill"] || !injected["method.timeline"] { + t.Fatalf("minutes should pull its methods, got %+v", hits) + } + if injected["method.insight"] { + t.Fatal("minutes must not pull the insight method") + } +} + // 同轮多技能命中:只注入优先级最高者,其余记录让位(可观测)。 func TestResolveSystemSkillPriority(t *testing.T) { svc := mustService(t) diff --git a/internal/prompts/service.go b/internal/prompts/service.go index 34b1d70..36f1e92 100644 --- a/internal/prompts/service.go +++ b/internal/prompts/service.go @@ -4,6 +4,7 @@ import ( "embed" "fmt" "io/fs" + "regexp" "sort" "strings" @@ -265,6 +266,7 @@ func (s *Service) ResolveSystem(prof core.AgentProfile, tc TriggerCtx, env EnvDa for i, c := range matched { if i == 0 { addBlock(c.m, c.reason, true) + injectMethods(c.m.Body, byID, addBlock) continue } addBlock(c.m, "superseded-by:"+matched[0].m.ID, false) @@ -272,6 +274,25 @@ func (s *Service) ResolveSystem(prof core.AgentProfile, tc TriggerCtx, env EnvDa return blocks, hits, nil } +var methodRef = regexp.MustCompile(`method\.[a-z0-9-]+`) + +// injectMethods 把任务正文点名的方法模块一并注入。方法是 aux 层, +// 不参与 task 优先级竞争,所以纪要可以同时带上提炼和时间线,而不会挤掉任务本身。 +func injectMethods(body string, byID map[string]core.PromptModule, add func(core.PromptModule, string, bool)) { + seen := map[string]bool{} + for _, id := range methodRef.FindAllString(body, -1) { + if seen[id] { + continue + } + seen[id] = true + m, ok := byID[id] + if !ok || m.Disabled || m.Layer != core.LayerAux { + continue + } + add(m, "method-of-task", true) + } +} + // priority 缺省 50;大者优先。 func priority(m core.PromptModule) int { if m.Priority == 0 { diff --git a/internal/quality/checker.go b/internal/quality/checker.go new file mode 100644 index 0000000..e565416 --- /dev/null +++ b/internal/quality/checker.go @@ -0,0 +1,32 @@ +package quality + +import ( + "os" + "path/filepath" + "strings" + "time" + + "agent-squad/squad" +) + +// Checker 把评分卡接到小队质检。不通过时把意见写到工作区 quality-feedback.md, +// 供返修步骤读入。 +type Checker struct{} + +func (Checker) Check(req squad.CheckRequest) *squad.QualityReport { + path := filepath.Join(req.Workspace, req.Deliverable) + scored := ScoreFile(path) + report := &squad.QualityReport{ + CheckedAt: time.Now(), + Score: float64(scored.Score), + Passed: scored.Passed, + Violations: scored.Violations, + } + if !scored.Passed { + body := "质检未通过,请按下列意见改一版,不要解释规则本身。\n\n" + body += strings.Join(scored.Violations, "\n") + body += "\n" + _ = os.WriteFile(filepath.Join(req.Workspace, "quality-feedback.md"), []byte(body), 0o644) + } + return report +} diff --git a/internal/quality/score.go b/internal/quality/score.go new file mode 100644 index 0000000..46d48cc --- /dev/null +++ b/internal/quality/score.go @@ -0,0 +1,288 @@ +// Package quality 给交付物打分。直通路径的 quality_review 和小队 Checker 用同一套规则。 +// 默认是可复现的启发式评分;调用方可以注入 Judge,用轻量模型覆盖启发式结果。 +package quality + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "encoding/xml" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "unicode/utf8" +) + +// Dimension 是评分卡上的一维。Score 为 0–100。Evidence 必须引用被评文本里的原句。 +type Dimension struct { + ID string `json:"id"` + Score int `json:"score"` + Note string `json:"note"` + Evidence string `json:"evidence"` +} + +// Report 是一次评分。Passed 要求总分不低于 70,且没有任何一维低于 40。 +type Report struct { + Passed bool `json:"passed"` + Score int `json:"score"` + Dimensions []Dimension `json:"dimensions"` + Violations []string `json:"violations,omitempty"` + Lint []LintIssue `json:"lint,omitempty"` +} + +// LintIssue 是 housestyle.lint 的一条结果。lint 不存在时留空,不因此判失败。 +type LintIssue struct { + Severity string `json:"severity"` + Rule string `json:"rule"` + Message string `json:"message"` +} + +// Judge 用模型给同一份正文打分。返回错误时调用方回落启发式。 +type Judge func(ctx context.Context, text string) (Report, error) + +// ScoreText 按六个维度给纯文本打分。 +func ScoreText(text string) Report { + text = strings.TrimSpace(text) + dims := []Dimension{ + scoreLead(text), + scoreStructure(text), + scoreComplete(text), + scoreEvidence(text), + scoreActionable(text), + scoreReadable(text), + } + total := 0 + low := false + var violations []string + for _, d := range dims { + total += d.Score + if d.Score < 40 { + low = true + violations = append(violations, d.ID+":"+d.Note) + } + } + score := 0 + if len(dims) > 0 { + score = total / len(dims) + } + if score < 70 { + violations = append(violations, fmt.Sprintf("总分 %d,低于 70", score)) + } + return Report{ + Passed: score >= 70 && !low, + Score: score, + Dimensions: dims, + Violations: violations, + } +} + +// ScoreFile 读取文件正文再打分。docx 取段落文本;其他文件按 UTF-8 文本读。 +// 能跑 housestyle.lint 时,error 级问题会使 Passed 变为 false。 +func ScoreFile(path string) Report { + text, err := readDeliverable(path) + if err != nil { + return Report{Passed: false, Violations: []string{"无法读取交付物:" + err.Error()}} + } + report := ScoreText(text) + issues := lintFile(path) + report.Lint = issues + for _, issue := range issues { + if issue.Severity == "error" { + report.Passed = false + report.Violations = append(report.Violations, "版式 "+issue.Rule+":"+issue.Message) + } + } + return report +} + +func scoreLead(text string) Dimension { + title := firstHeading(text) + if title == "" { + return Dimension{ID: "结论先行", Score: 30, Note: "开头没有标题或判断", Evidence: snippet(text, 40)} + } + if bannedTitle(title) { + return Dimension{ID: "结论先行", Score: 20, Note: "标题是栏目名,不是判断", Evidence: title} + } + if containsAny(title, "应", "先", "不要", "建议", "必须", "改为", "停止") || utf8.RuneCountInString(title) >= 8 { + return Dimension{ID: "结论先行", Score: 90, Note: "标题本身是判断", Evidence: title} + } + return Dimension{ID: "结论先行", Score: 55, Note: "有标题,但还不是一句判断", Evidence: title} +} + +func scoreStructure(text string) Dimension { + if strings.Contains(text, "一句话") { + return Dimension{ID: "结构", Score: 90, Note: "要点组有收束句", Evidence: evidenceLine(text, "一句话")} + } + if strings.Count(text, "#") >= 2 || strings.Count(text, "\n") >= 4 { + return Dimension{ID: "结构", Score: 60, Note: "有分层,但没有一句话收束", Evidence: snippet(text, 40)} + } + return Dimension{ID: "结构", Score: 30, Note: "几乎没有结构", Evidence: snippet(text, 40)} +} + +func scoreComplete(text string) Dimension { + n := utf8.RuneCountInString(text) + switch { + case n >= 180: + return Dimension{ID: "完整性", Score: 85, Note: "篇幅足以覆盖一个决定", Evidence: fmt.Sprintf("%d 字", n)} + case n >= 80: + return Dimension{ID: "完整性", Score: 60, Note: "偏短,可能漏了材料里的决定", Evidence: fmt.Sprintf("%d 字", n)} + default: + return Dimension{ID: "完整性", Score: 25, Note: "内容过短", Evidence: fmt.Sprintf("%d 字", n)} + } +} + +func scoreEvidence(text string) Dimension { + for _, word := range []string{"说话人", "依据", "出处", "原文", "文件"} { + if line := evidenceLine(text, word); line != "" { + return Dimension{ID: "证据", Score: 85, Note: "判断能回到材料", Evidence: line} + } + } + return Dimension{ID: "证据", Score: 30, Note: "没有出处", Evidence: snippet(text, 40)} +} + +func scoreActionable(text string) Dimension { + for _, word := range []string{"未指明", "负责人", "期限", "之前完成", "周五"} { + if line := evidenceLine(text, word); line != "" { + return Dimension{ID: "可执行性", Score: 85, Note: "行动写了人或时间", Evidence: line} + } + } + return Dimension{ID: "可执行性", Score: 35, Note: "没有可交给人的行动", Evidence: snippet(text, 40)} +} + +func scoreReadable(text string) Dimension { + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(strings.TrimLeft(line, "#")) + if bannedTitle(line) && utf8.RuneCountInString(line) <= 12 { + return Dimension{ID: "可读性", Score: 25, Note: "使用了栏目式标题", Evidence: line} + } + } + if strings.Contains(text, "。。") { + return Dimension{ID: "可读性", Score: 40, Note: "有连续句号", Evidence: "。。"} + } + return Dimension{ID: "可读性", Score: 85, Note: "标题可扫读", Evidence: firstHeading(text)} +} + +func firstHeading(text string) string { + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(strings.TrimLeft(line, "#")) + line = strings.TrimSpace(line) + if line != "" { + return line + } + } + return "" +} + +func bannedTitle(title string) bool { + for _, word := range []string{"讨论过程", "概述", "补充说明", "其他"} { + if strings.Contains(title, word) { + return true + } + } + return false +} + +func containsAny(s string, words ...string) bool { + for _, word := range words { + if strings.Contains(s, word) { + return true + } + } + return false +} + +func evidenceLine(text, word string) string { + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if strings.Contains(line, word) { + return snippet(line, 80) + } + } + return "" +} + +func snippet(text string, n int) string { + text = strings.TrimSpace(strings.ReplaceAll(text, "\n", " ")) + if utf8.RuneCountInString(text) <= n { + return text + } + runes := []rune(text) + return string(runes[:n]) + "…" +} + +func readDeliverable(path string) (string, error) { + if strings.EqualFold(filepath.Ext(path), ".docx") { + return readDocxText(path) + } + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + if !utf8.Valid(data) { + return "", fmt.Errorf("不是文本文件") + } + return string(data), nil +} + +func readDocxText(path string) (string, error) { + zr, err := zip.OpenReader(path) + if err != nil { + return "", err + } + defer zr.Close() + var doc *zip.File + for _, f := range zr.File { + if f.Name == "word/document.xml" { + doc = f + break + } + } + if doc == nil { + return "", fmt.Errorf("docx 没有 document.xml") + } + rc, err := doc.Open() + if err != nil { + return "", err + } + defer rc.Close() + decoder := xml.NewDecoder(rc) + var b strings.Builder + for { + tok, err := decoder.Token() + if err != nil { + break + } + switch el := tok.(type) { + case xml.StartElement: + if el.Name.Local == "p" && b.Len() > 0 { + b.WriteByte('\n') + } + case xml.CharData: + b.Write(bytes.TrimSpace(el)) + } + } + return b.String(), nil +} + +var lintJSON = regexp.MustCompile(`(?s)\[.*\]`) + +func lintFile(path string) []LintIssue { + cmd := exec.Command("python3", "-m", "housestyle.lint", path) + out, err := cmd.CombinedOutput() + if err != nil && !bytes.Contains(out, []byte("\"rule\"")) { + return nil + } + raw := out + if loc := lintJSON.Find(out); loc != nil { + raw = loc + } + var issues []LintIssue + if json.Unmarshal(raw, &issues) != nil { + return nil + } + return issues +} diff --git a/internal/quality/score_test.go b/internal/quality/score_test.go new file mode 100644 index 0000000..a9d5f7e --- /dev/null +++ b/internal/quality/score_test.go @@ -0,0 +1,29 @@ +package quality + +import "testing" + +func TestScoreTextPrefersJudgementOverColumnHeading(t *testing.T) { + good := `# 上传文档应先判断相关性再入库 + +## 先校验,再生成 +- 说话人1:上传后第一步是判断是否与项目相关。 +一句话:没有记忆库,后面的生成和追溯都没有依据。 + +## 下一步 +- 事项:写出第一版需求。负责人:陈威。期限:周五前;说不清的写未指明。 +` + bad := "# 讨论过程\n\n今天大家聊了很多,感觉还要再看看。\n" + gotGood := ScoreText(good) + gotBad := ScoreText(bad) + if !gotGood.Passed || gotGood.Score < 70 { + t.Fatalf("good text should pass: %+v", gotGood) + } + if gotBad.Passed || gotBad.Score >= gotGood.Score { + t.Fatalf("column heading should score lower: bad=%+v good=%d", gotBad, gotGood.Score) + } + for _, d := range gotGood.Dimensions { + if d.Evidence == "" { + t.Errorf("%s missing evidence", d.ID) + } + } +} diff --git a/internal/skills/recipes/house-style/SKILL.md b/internal/skills/recipes/house-style/SKILL.md index 963ca72..91053b6 100644 --- a/internal/skills/recipes/house-style/SKILL.md +++ b/internal/skills/recipes/house-style/SKILL.md @@ -46,6 +46,12 @@ dx.add_numbers(doc, ["步骤"]) # 文本里不要再写 1. dx.add_table(doc, ["列"], [["值"]], ["left"]) dx.add_callout(doc, "给决策者的一段话。", "摘要") dx.add_quote(doc, "关键原话。", "出处") +from housestyle import visuals +visuals.status_row(doc, [{"title": "已修复", "badge": "历史", "body": "一句说明。", "tone": "ok"}]) +visuals.card_grid(doc, [{"title": "判断", "points": ["要点"], "footnote": "一句话总结", "tone": "info"}]) +visuals.column_row(doc, [{"title": "要求", "points": ["事项"], "footnote": "收束", "tone": "warn"}]) +png = visuals.render_flow("/tmp/flow.png", [{"title": "先入库", "body": "再输出", "tone": "info"}]) +visuals.add_picture(doc, png) dx.save(doc, "交付物.docx") prs = px.new_presentation(theme="modern", title="汇报") diff --git a/internal/skills/recipes/house-style/housestyle/lint.py b/internal/skills/recipes/house-style/housestyle/lint.py index 804c71a..0163705 100644 --- a/internal/skills/recipes/house-style/housestyle/lint.py +++ b/internal/skills/recipes/house-style/housestyle/lint.py @@ -97,6 +97,7 @@ def lint_docx(path: str) -> list[dict]: if "PAGE" not in footer_xml and "页" not in footer_xml: add("page-number", "error", "页脚没有页码") break + _check_visual_summary(doc, add) return issues @@ -199,6 +200,20 @@ def _heading_level(style_name: str) -> int: return 0 +def _check_visual_summary(doc, add) -> None: + headings = 0 + for paragraph in doc.paragraphs: + style = paragraph.style.name if paragraph.style is not None else "" + if _heading_level(style) == 1: + headings += 1 + if headings < 4: + return + comments = doc.core_properties.comments or "" + if "visual-summary" in comments or "w:drawing" in doc.element.body.xml: + return + add("visual-summary", "warning", "一级标题已有四节,正文没有视觉摘要(状态卡、网格、流程图或时间线)") + + def _check_outline(doc, add) -> None: blocks = [] for child in doc.element.body: diff --git a/internal/skills/recipes/house-style/housestyle/visuals.py b/internal/skills/recipes/house-style/housestyle/visuals.py new file mode 100644 index 0000000..f82092e --- /dev/null +++ b/internal/skills/recipes/house-style/housestyle/visuals.py @@ -0,0 +1,233 @@ +"""信息图:可编辑的卡片块,以及用 PIL 渲染的流程图和时间线。 + +卡片是带底色的表格,用户可以在 Word 里直接改字。流程图是 PNG, +因为箭头和多色胶囊用表格表达不稳定。 +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont +from docx.shared import Cm +from pptx.dml.color import RGBColor +from pptx.enum.shapes import MSO_SHAPE +from pptx.util import Inches as PptInches + +from housestyle import docx_kit, pptx_kit +from housestyle.themes import resolve_theme + +_TONES = { + "ok": ("1F7A4D", "E7F6EE"), + "info": ("1B3A4B", "E7EEF2"), + "warn": ("C05621", "FDF1E7"), + "risk": ("9F1239", "FDE8EE"), +} + + +def mark_visual(doc) -> None: + # keywords 存的是主题色,不能把标记写进去,否则下一节会把颜色解析失败。 + comments = doc.core_properties.comments or "" + if "visual-summary" in comments: + return + doc.core_properties.comments = (comments + " visual-summary").strip() + + +def status_row(doc, cards: list[dict]): + """横排状态卡。每张卡:title、badge、body,tone 取 ok/info/warn/risk。""" + spec = docx_kit._theme(doc) + count = max(len(cards), 1) + table = doc.add_table(rows=1, cols=count) + docx_kit._format_table(table, spec, header=False) + width = docx_kit._content_width(doc, spec) + docx_kit._set_widths(table, [width / count] * count) + for index, card in enumerate(cards): + tone, fill = _tone(card.get("tone") or "info", spec.accent, spec.accent_soft) + body = _join(card.get("title", ""), card.get("badge", ""), card.get("body", "")) + docx_kit._write_cell(table.cell(0, index), body, spec, fill=fill, align="left") + _paint_first_line(table.cell(0, index), spec, tone) + mark_visual(doc) + return table + + +def card_grid(doc, cards: list[dict]): + """两列卡片。每张卡:title、points、footnote。""" + spec = docx_kit._theme(doc) + rows = (len(cards) + 1) // 2 + table = doc.add_table(rows=rows, cols=2) + docx_kit._format_table(table, spec, header=False) + width = docx_kit._content_width(doc, spec) + docx_kit._set_widths(table, [width / 2, width / 2]) + for index, card in enumerate(cards): + tone, fill = _tone(card.get("tone") or "info", spec.accent, spec.accent_soft) + text = _card_text(card) + cell = table.cell(index // 2, index % 2) + docx_kit._write_cell(cell, text, spec, fill=fill, align="left") + _paint_first_line(cell, spec, tone) + mark_visual(doc) + return table + + +def column_row(doc, columns: list[dict]): + """三栏或更多栏的要求卡。每栏:title、points、footnote。""" + spec = docx_kit._theme(doc) + count = max(len(columns), 1) + table = doc.add_table(rows=1, cols=count) + docx_kit._format_table(table, spec, header=False) + width = docx_kit._content_width(doc, spec) + docx_kit._set_widths(table, [width / count] * count) + for index, card in enumerate(columns): + tone, fill = _tone(card.get("tone") or "info", spec.accent, spec.accent_soft) + cell = table.cell(0, index) + docx_kit._write_cell(cell, _card_text(card), spec, fill=fill, align="left") + _paint_first_line(cell, spec, tone) + mark_visual(doc) + return table + + +def add_picture(doc, path: str, width_cm: float = 16): + doc.add_picture(path, width=Cm(width_cm)) + mark_visual(doc) + + +def render_flow(path: str, steps: list[dict], theme: str = "modern") -> str: + """把步骤画成横向流程图 PNG。steps 含 title、body、tone。""" + return _render_pills(path, steps, theme, arrow=True) + + +def render_timeline(path: str, steps: list[dict], theme: str = "modern") -> str: + """把节点画成横向时间线 PNG。steps 含 title、body。""" + return _render_pills(path, steps, theme, arrow=False) + + +def add_card_slide(prs, title: str, cards: list[dict]): + """演示稿上的卡片页,最多四张。""" + spec = pptx_kit._theme(prs) + slide = prs.slides.add_slide(pptx_kit._blank(prs)) + if not pptx_kit._templated(prs): + pptx_kit._title(slide, prs, spec, title) + count = max(len(cards), 1) + gap = PptInches(0.18) + left = PptInches(0.45) + top = PptInches(1.55) + width = (prs.slide_width - PptInches(0.9) - gap * (count - 1)) / count + height = PptInches(4.8) + for index, card in enumerate(cards): + tone, fill = _tone(card.get("tone") or "info", spec.accent, spec.accent_soft) + shape = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, left + (width + gap) * index, top, width, height) + shape.fill.solid() + shape.fill.fore_color.rgb = RGBColor.from_string(fill) + shape.line.fill.background() + frame = shape.text_frame + frame.word_wrap = True + lines = _card_lines(card) + for i, line in enumerate(lines): + paragraph = frame.paragraphs[0] if i == 0 else frame.add_paragraph() + run = paragraph.add_run() + run.text = line + pptx_kit._run_font(run, spec.heading_font if i == 0 else spec.font, 16 if i == 0 else 13, i == 0, tone if i == 0 else spec.text) + if not pptx_kit._templated(prs): + pptx_kit._number(slide, prs, spec, len(prs.slides)) + comments = prs.core_properties.comments or "" + if "visual-summary" not in comments: + prs.core_properties.comments = (comments + " visual-summary").strip() + return slide + + +def _tone(name: str, accent: str, soft: str) -> tuple[str, str]: + if name in _TONES: + return _TONES[name] + return accent, soft + + +def _join(*parts: str) -> str: + return "\n".join(part for part in parts if part) + + +def _card_text(card: dict) -> str: + return "\n".join(_card_lines(card)) + + +def _card_lines(card: dict) -> list[str]: + lines = [] + if card.get("title"): + lines.append(str(card["title"])) + if card.get("badge"): + lines.append(str(card["badge"])) + for point in card.get("points") or []: + lines.append(str(point)) + if card.get("body"): + lines.append(str(card["body"])) + if card.get("footnote"): + lines.append(str(card["footnote"])) + return lines or [" "] + + +def _paint_first_line(cell, spec, color: str) -> None: + paragraph = cell.paragraphs[0] + for run in paragraph.runs: + docx_kit._font_run(run, spec.heading_font, spec.font_latin, spec.body_pt, True, color) + + +def _render_pills(path: str, steps: list[dict], theme: str, arrow: bool) -> str: + spec = resolve_theme(theme) + count = max(len(steps), 1) + width, height = 360 * count, 420 + image = Image.new("RGB", (width, height), "#FFFFFF") + draw = ImageDraw.Draw(image) + font = _font(22) + small = _font(16) + for index, step in enumerate(steps): + tone, fill = _tone(step.get("tone") or "info", spec.accent, spec.accent_soft) + x = 24 + index * 360 + draw.rounded_rectangle((x, 40, x + 300, 360), radius=18, fill="#" + fill, outline="#" + tone, width=3) + title = str(step.get("title") or "") + body = str(step.get("body") or "") + draw.text((x + 18, 64), _wrap(title, 12), font=font, fill="#" + tone) + draw.text((x + 18, 150), _wrap(body, 14), font=small, fill="#" + spec.text) + if arrow and index < len(steps) - 1: + draw.line((x + 308, 190, x + 348, 190), fill="#" + spec.accent, width=4) + Path(path).parent.mkdir(parents=True, exist_ok=True) + image.save(path, "PNG") + return path + + +def _wrap(text: str, width: int) -> str: + text = " ".join(text.split()) + if not text: + return "" + lines = [] + current = "" + for char in text: + current += char + if len(current) >= width: + lines.append(current) + current = "" + if len(lines) >= 6: + break + if current and len(lines) < 6: + lines.append(current) + return "\n".join(lines) + + +def _font(size: int): + path = _cjk_font_path() + if path: + try: + return ImageFont.truetype(path, size) + except OSError: + pass + return ImageFont.load_default() + + +def _cjk_font_path() -> str | None: + try: + out = subprocess.check_output(["fc-list", ":lang=zh", "file"], text=True, timeout=3) + except (OSError, subprocess.TimeoutExpired): + return None + for line in out.splitlines(): + candidate = line.split(":", 1)[0].strip() + if candidate.lower().endswith((".ttf", ".otf", ".ttc")): + return candidate + return None diff --git a/internal/skills/recipes/house-style/tests/test_housestyle.py b/internal/skills/recipes/house-style/tests/test_housestyle.py index 07624b3..9fa944d 100644 --- a/internal/skills/recipes/house-style/tests/test_housestyle.py +++ b/internal/skills/recipes/house-style/tests/test_housestyle.py @@ -5,7 +5,7 @@ from pathlib import Path from docx import Document from docx.shared import RGBColor -from housestyle import docx_kit, pptx_kit, resolve_theme, summarize, xlsx_kit +from housestyle import docx_kit, pptx_kit, resolve_theme, summarize, visuals, xlsx_kit from housestyle.extract import extract from housestyle.lint import lint_docx, lint_pptx, lint_xlsx @@ -120,5 +120,51 @@ class BadDocumentTests(unittest.TestCase): self.assertTrue(expected <= rules, sorted(expected - rules)) +class VisualTests(unittest.TestCase): + def test_cards_mark_visual_and_long_doc_warns_without_them(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + plain = docx_kit.new_document("modern", title="长文") + docx_kit.add_title(plain, "长文", "2026年10月1日") + for title in ("先判断相关性", "再打标签入库", "然后比对基线", "最后确认变更"): + docx_kit.add_heading(plain, title, 1) + docx_kit.add_paragraph(plain, "这一节有实质内容,但没有视觉摘要。") + plain_path = root / "plain.docx" + docx_kit.save(plain, str(plain_path)) + rules = {item["rule"] for item in lint_docx(str(plain_path)) if item["severity"] == "warning"} + self.assertIn("visual-summary", rules) + + visual = docx_kit.new_document("modern", title="长文") + docx_kit.add_title(visual, "长文", "2026年10月1日") + visuals.status_row(visual, [ + {"title": "已修复", "badge": "历史", "body": "上传可以不带指令。", "tone": "ok"}, + {"title": "当前逻辑", "badge": "现状", "body": "先摘要再提示缺口。", "tone": "info"}, + {"title": "待调整", "badge": "建议", "body": "先入库再输出。", "tone": "warn"}, + ]) + for title in ("先判断相关性", "再打标签入库", "然后比对基线", "最后确认变更"): + docx_kit.add_heading(visual, title, 1) + docx_kit.add_paragraph(visual, "这一节有实质内容。") + visual_path = root / "visual.docx" + docx_kit.save(visual, str(visual_path)) + warned = {item["rule"] for item in lint_docx(str(visual_path))} + self.assertNotIn("visual-summary", warned) + self.assertEqual(_errors(lint_docx(str(visual_path))), []) + + png = visuals.render_flow(str(root / "flow.png"), [ + {"title": "判断相关", "body": "存疑先问人", "tone": "info"}, + {"title": "打标签", "body": "切片入库", "tone": "ok"}, + ]) + self.assertGreater(Path(png).stat().st_size, 500) + + prs = pptx_kit.new_presentation("modern", title="卡片") + visuals.add_card_slide(prs, "三种处理", [ + {"title": "摘要", "points": ["结论先行"], "tone": "info"}, + {"title": "笔记", "points": ["保留原意"], "tone": "ok"}, + ]) + ppt = root / "cards.pptx" + pptx_kit.save(prs, str(ppt)) + self.assertEqual(_errors(lint_pptx(str(ppt))), []) + + if __name__ == "__main__": unittest.main() diff --git a/internal/squadadapter/adapter.go b/internal/squadadapter/adapter.go index 218c501..b7f9efe 100644 --- a/internal/squadadapter/adapter.go +++ b/internal/squadadapter/adapter.go @@ -21,6 +21,7 @@ import ( "agent-native/exec" "agent-native/runtime/subagent" "agent-squad/squad" + "agent-system-backend/internal/quality" core "agent-system-core" ) @@ -79,6 +80,8 @@ func Orchestrate(rc Ctx, dataDir, goalText string) (string, error) { DefaultBackend: "agent-system", StepTimeout: 5 * time.Minute, HitlPolicy: squad.HitlPolicy{Mode: squad.HitlInteractive}, + Checker: quality.Checker{}, + RepairBudget: 2, Sink: squad.ProgressFunc(func(ev squad.Event) { onSquadEvent(rc, ev) }), }) if err != nil { diff --git a/internal/tools/quality_review.go b/internal/tools/quality_review.go new file mode 100644 index 0000000..5277d78 --- /dev/null +++ b/internal/tools/quality_review.go @@ -0,0 +1,51 @@ +package tools + +import ( + "encoding/json" + "fmt" + "path/filepath" + "strings" + + "agent-system-backend/internal/quality" + core "agent-system-core" +) + +func registerQualityReview(r *Registry) error { + return r.Register(core.ToolDesc{ + Name: "quality_review", + Description: "按结论先行、结构、完整性、证据、可执行性、可读性给交付物打分,并附上版式检查。不通过时按 violations 改一版再登记", + Source: core.SourceBuiltin, + Parameters: json.RawMessage(`{"type":"object","properties":{"path":{"type":"string","description":"会话工作区内的相对路径"}},"required":["path"]}`), + }, func(rc ToolExecCtx, args json.RawMessage) (string, error) { + var a struct { + Path string `json:"path"` + } + if err := json.Unmarshal(args, &a); err != nil { + return "", err + } + path, err := workspaceFile(rc.WorkspaceRoot(), a.Path) + if err != nil { + return "", err + } + report := quality.ScoreFile(path) + out, err := json.Marshal(report) + if err != nil { + return "", err + } + return string(out), nil + }) +} + +func workspaceFile(root, rel string) (string, error) { + if strings.TrimSpace(root) == "" { + return "", fmt.Errorf("quality_review: workspace is not configured") + } + if rel == "" || filepath.IsAbs(rel) { + return "", fmt.Errorf("quality_review: path must be a relative path inside the workspace") + } + clean := filepath.Clean(rel) + if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("quality_review: path escapes the workspace") + } + return filepath.Join(root, clean), nil +} diff --git a/internal/tools/quality_review_test.go b/internal/tools/quality_review_test.go new file mode 100644 index 0000000..8e6f5a2 --- /dev/null +++ b/internal/tools/quality_review_test.go @@ -0,0 +1,52 @@ +package tools + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + core "agent-system-core" +) + +type qualityCtx struct{ root string } + +func (c qualityCtx) ProjectID() string { return "p" } +func (c qualityCtx) ConversationID() string { return "c" } +func (c qualityCtx) Retrieve(core.RetrievalQuery) ([]core.RetrievalHit, error) { + return nil, nil +} +func (c qualityCtx) WriteArtifact(string, string, string, []byte) (core.Artifact, error) { + return core.Artifact{}, nil +} +func (c qualityCtx) PeerConversations() ([]core.Conversation, error) { return nil, nil } +func (c qualityCtx) PeerConversationMessages(string, int) ([]core.Message, error) { + return nil, nil +} +func (c qualityCtx) OwnerID() string { return "o" } +func (c qualityCtx) WorkspaceRoot() string { return c.root } +func (c qualityCtx) SettingBool(string, bool) bool { return false } + +func TestQualityReviewStaysInsideWorkspace(t *testing.T) { + dir := t.TempDir() + body := "# 上传应先入库再生成\n\n## 判断\n说话人1认为要先看相关性。\n一句话:先建记忆。\n\n## 行动\n负责人陈威,期限周五;不清楚的写未指明。这份说明写长一点,保证完整性维度能够通过,因为过短的正文会被当成没有覆盖决定。需要把依据、行动和收束都写清楚,读者只看标题也知道下一步是什么。\n" + if err := os.WriteFile(filepath.Join(dir, "note.md"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + reg := NewRegistry() + if err := reg.RegisterBuiltin(); err != nil { + t.Fatal(err) + } + res := reg.Execute(qualityCtx{root: dir}, core.ToolCallRequest{ + CallID: "q", Name: "quality_review", Arguments: json.RawMessage(`{"path":"note.md"}`), + }) + if res.Status != "ok" { + t.Fatalf("review: %s", res.Output) + } + escaped := reg.Execute(qualityCtx{root: dir}, core.ToolCallRequest{ + CallID: "q2", Name: "quality_review", Arguments: json.RawMessage(`{"path":"../secret.md"}`), + }) + if escaped.Status != "error" { + t.Fatalf("escape should fail, got %s", escaped.Output) + } +} diff --git a/internal/tools/registry.go b/internal/tools/registry.go index 62a6295..7720fb6 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -183,5 +183,8 @@ func (r *Registry) RegisterBuiltin() error { }); err != nil { return err } + if err := registerQualityReview(r); err != nil { + return err + } return registerPeerTools(r) }