ci / vet-and-test (pull_request) Successful in 40s
Why: User rulings 4/5 plus the P2-P4 live evidence. Keyword scoring dimensions (readability, actionability) kept failing valid documents - normal Chinese section headings flagged as column titles, loops of 4 corrections - because they grade writing preferences, not verifiable facts. Unverified numbers (~10-13 per run) remained the largest quality leak through every phase. And the verifier runs on the same model as the worker (lightweight falls back to chat), so "independent" verification was self-review. Separately, svc seeding after boot never registered the tavily tools until a restart because keys were snapshotted at startup. Changes: quality.HardFacts (lint actually ran + zero errors) replaces the full ScoreBytes projection in CheckArtifacts - keyword dimensions remain in ScoreBytes for eval visibility only. New fact-consistency check (runtime.factCheck, default on): extract percentages/dates/large numbers from deliverable text, trace each against the material corpus (attachment text + tool evidence); misses get exactly one correction round (fix, drop, or mark as target/estimate), never a loop. runVerifier skips same-source model verification unless runtime.verifierSameModel is set (hard facts + fact check still run). Search tools now register lazily, reading tavily keys per call (settings/seeding take effect immediately). Evidence: SELF - TestHardFactsIgnoresKeywordDimensions, TestHardFactsKeepsLintUnavailableBlocking, TestUnverifiedFactsAndNotice, TestFactCheckContinuationOnce (fact note fires, corrected run passes); go vet + go test ./... pass; live before/after on the golden task set recorded on the phase05 task card per the policy clause. Impact: Gate stops rejecting valid documents on stylistic grounds; invented figures get one forced correction round; same-model verification no longer pretends to be independent; tavily config is hot. No contract or storage changes.
133 lines
4.2 KiB
Go
133 lines
4.2 KiB
Go
package quality
|
||
|
||
import (
|
||
"archive/zip"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
func TestLintCommandEnvCarriesSkillsRoot(t *testing.T) {
|
||
root := t.TempDir()
|
||
SetLintEnvRoot(root)
|
||
t.Cleanup(func() { SetLintEnvRoot("") })
|
||
env := lintCommandEnv()
|
||
var sawSkills, sawPath bool
|
||
for _, kv := range env {
|
||
if kv == "AGENT_SKILLS_DIR="+root {
|
||
sawSkills = true
|
||
}
|
||
if kv == "PYTHONPATH="+filepath.Join(root, "house-style") {
|
||
sawPath = true
|
||
}
|
||
}
|
||
if !sawSkills || !sawPath {
|
||
t.Fatalf("lint env missing skills root entries: %v", env)
|
||
}
|
||
}
|
||
|
||
// 技能根已注入但 housestyle 不可导入时,评分必须显式报「自检未运行」,
|
||
// 不能把没跑当成没问题(实测:后端进程无 PYTHONPATH,lint 静默失效)。
|
||
func TestLintUnavailableFailsVisibleWhenSkillsRootSet(t *testing.T) {
|
||
SetLintEnvRoot(t.TempDir())
|
||
t.Cleanup(func() { SetLintEnvRoot("") })
|
||
good := "# 上传文档应先判断相关性再入库\n\n## 先校验,再生成\n- 说话人1:上传后第一步是判断是否与项目相关。\n一句话:没有记忆库,后面的生成和追溯都没有依据。\n\n## 下一步\n- 事项:写出第一版需求。负责人:陈威。期限:周五前;说不清的写未指明。\n"
|
||
rep := ScoreBytes("note.md", []byte(good))
|
||
if rep.Passed {
|
||
t.Fatalf("lint unavailable must fail visibly when skills root is set: %+v", rep)
|
||
}
|
||
found := false
|
||
for _, v := range rep.Violations {
|
||
if strings.Contains(v, "自检未运行") {
|
||
found = true
|
||
}
|
||
}
|
||
if !found {
|
||
t.Fatalf("expected 自检未运行 violation, got: %v", rep.Violations)
|
||
}
|
||
if rep.LintRan {
|
||
t.Fatalf("LintRan should be false when lint could not run")
|
||
}
|
||
}
|
||
|
||
// docx 标题样式要被标成 # 前缀;表格单元格/正文不参与「栏目式标题」判定
|
||
// (实测误报:单元格「项目概述撰写」被当成标题,检查连续判负)。
|
||
func TestDocxHeadingMarkedAndTableCellNotFlagged(t *testing.T) {
|
||
docx := buildDocx(t, []paraSpec{
|
||
{style: "Heading1", text: "概述"},
|
||
{style: "", text: "项目概述撰写"},
|
||
{style: "Heading2", text: "先把需求写清楚再开发"},
|
||
})
|
||
text, err := readDocxText(docx)
|
||
if err != nil {
|
||
t.Fatalf("readDocxText: %v", err)
|
||
}
|
||
if !strings.Contains(text, "# 概述") || !strings.Contains(text, "## 先把需求写清楚再开发") {
|
||
t.Fatalf("headings not marked with #: %q", text)
|
||
}
|
||
if !strings.Contains(text, "项目概述撰写") {
|
||
t.Fatalf("body paragraph missing: %q", text)
|
||
}
|
||
|
||
// 标题是栏目名 → 仍要被判低分。
|
||
withBadHeading := ScoreText(text)
|
||
if withBadHeading.Passed {
|
||
t.Fatalf("banned heading should fail: %+v", withBadHeading)
|
||
}
|
||
|
||
// 同一个「概述」字样只出现在非标题行时,不应触发栏目式标题。
|
||
cellOnly := buildDocx(t, []paraSpec{
|
||
{style: "Heading1", text: "先把需求写清楚再开发"},
|
||
{style: "", text: "项目概述撰写"},
|
||
{style: "", text: "一句话:先写需求。负责人:陈威。期限:周五前。"},
|
||
})
|
||
cellText, err := readDocxText(cellOnly)
|
||
if err != nil {
|
||
t.Fatalf("readDocxText: %v", err)
|
||
}
|
||
for _, d := range ScoreText(cellText).Dimensions {
|
||
if d.ID == "可读性" && d.Score < 60 {
|
||
t.Fatalf("cell text must not be flagged as column heading: %+v", d)
|
||
}
|
||
}
|
||
}
|
||
|
||
type paraSpec struct {
|
||
style string
|
||
text string
|
||
}
|
||
|
||
// buildDocx 构造最小 docx:只含 word/document.xml(readDocxText 的唯一输入)。
|
||
func buildDocx(t *testing.T, paras []paraSpec) string {
|
||
t.Helper()
|
||
var b strings.Builder
|
||
b.WriteString(`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>`)
|
||
for _, p := range paras {
|
||
b.WriteString(`<w:p>`)
|
||
if p.style != "" {
|
||
b.WriteString(`<w:pPr><w:pStyle w:val="` + p.style + `"/></w:pPr>`)
|
||
}
|
||
b.WriteString(`<w:r><w:t>` + p.text + `</w:t></w:r></w:p>`)
|
||
}
|
||
b.WriteString(`</w:body></w:document>`)
|
||
path := filepath.Join(t.TempDir(), "test.docx")
|
||
f, err := os.Create(path)
|
||
if err != nil {
|
||
t.Fatalf("create docx: %v", err)
|
||
}
|
||
defer f.Close()
|
||
w := zip.NewWriter(f)
|
||
entry, err := w.Create("word/document.xml")
|
||
if err != nil {
|
||
t.Fatalf("zip entry: %v", err)
|
||
}
|
||
if _, err := entry.Write([]byte(b.String())); err != nil {
|
||
t.Fatalf("zip write: %v", err)
|
||
}
|
||
if err := w.Close(); err != nil {
|
||
t.Fatalf("zip close: %v", err)
|
||
}
|
||
return path
|
||
}
|