Files
Agent-System-Backend/internal/quality/score.go
T
weichen ef060d4ee5
ci / vet-and-test (pull_request) Successful in 40s
Keep only hard facts in the blocking gate and add fact-consistency checks
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.
2026-10-10 00:04:57 +00:00

494 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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。
// LintRan 为假表示 housestyle.lint 没有实际执行(环境不可用);技能根已注入
// (SetLintEnvRoot)时这本身计为一条 Violations,不再把「没跑」静默当「没问题」。
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"`
LintRan bool `json:"lintRan,omitempty"`
}
// LintIssue 是 housestyle.lint 的一条结果。lint 不存在时留空,不因此判失败。
type LintIssue struct {
Severity string `json:"severity"`
Rule string `json:"rule"`
Message string `json:"message"`
}
// lintEnvRoot 由组合根在启动时注入(<data>/skills)。零值保持旧语义:
// 继承进程环境,lint 不可用时静默跳过(测试与无技能部署)。
var lintEnvRoot string
// SetLintEnvRoot 注入技能根:lint 子进程获得与 run_python 一致的
// AGENT_SKILLS_DIR / PYTHONPATH,否则 housestyle 在后端进程里 import 失败。
// 非空根同时把「lint 必须可运行」设为强约束:跑不起来即 Violations(fail-visible)。
func SetLintEnvRoot(skillsRoot string) {
lintEnvRoot = skillsRoot
}
// 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。
// lint 无法执行时:技能根已注入(SetLintEnvRoot)→ 记 Violations「自检未运行」
// 并判不通过(fail-visible);未注入(测试/无技能部署)→ 保持旧的静默跳过。
func ScoreFile(path string) Report {
text, err := readDeliverable(path)
if err != nil {
return Report{Passed: false, Violations: []string{"无法读取交付物:" + err.Error()}}
}
report := ScoreText(text)
issues, lintErr := lintFile(path)
report.Lint = issues
report.LintRan = lintErr == nil
for _, issue := range issues {
if issue.Severity == "error" {
report.Passed = false
report.Violations = append(report.Violations, "版式 "+issue.Rule+":"+issue.Message)
}
}
if lintErr != nil && lintEnvRoot != "" {
report.Passed = false
report.Violations = append(report.Violations, "自检未运行(housestyle.lint 不可用:"+lintErr.Error()+")")
}
return report
}
// ScoreBytes 把内容落到带扩展名的临时文件,再走 ScoreFile。
// docx 与 housestyle.lint 因此和按路径评分用同一条路径。
func ScoreBytes(name string, content []byte) Report {
ext := filepath.Ext(name)
if ext == "" {
ext = ".txt"
}
f, err := os.CreateTemp("", "score-*"+ext)
if err != nil {
return Report{Passed: false, Violations: []string{"无法写入临时交付物:" + err.Error()}}
}
path := f.Name()
defer os.Remove(path)
if _, err := f.Write(content); err != nil {
f.Close()
return Report{Passed: false, Violations: []string{"无法写入临时交付物:" + err.Error()}}
}
if err := f.Close(); err != nil {
return Report{Passed: false, Violations: []string{"无法写入临时交付物:" + err.Error()}}
}
return ScoreFile(path)
}
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)}
}
// scoreReadable 只检查真正的标题行(# 开头;docx 路径已把标题样式标成 #)。
// 此前对每一行做「栏目名词 + 短行」判断,表格单元格(如「项目概述撰写」)
// 会被当成标题误报,终轮检查因此对合格文档连续判负(实测死循环源之一)。
// 命中时 Note 带行号,模型能定位到要改的那一行。
func scoreReadable(text string) Dimension {
for i, line := range strings.Split(text, "\n") {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "#") {
continue
}
title := strings.TrimSpace(strings.TrimLeft(trimmed, "#"))
if title == "" {
continue
}
if bannedTitle(title) && utf8.RuneCountInString(title) <= 12 {
return Dimension{ID: "可读性", Score: 25, Note: fmt.Sprintf("第 %d 行标题是栏目名,不是判断,改成一句结论", i+1), Evidence: title}
}
}
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
}
// readDocxText 抽取 docx 纯文本,并把标题样式(Title / HeadingN)的段落标成
// Markdown # 前缀——评分卡据此区分「标题行」与「正文/表格单元格」,也让
// scoreLead 等维度拿到真实的标题信息。
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
var para strings.Builder
style := ""
inPara := false
flush := func() {
text := strings.TrimSpace(para.String())
if text != "" {
if lvl := headingLevel(style); lvl > 0 {
b.WriteString(strings.Repeat("#", min(lvl, 6)))
b.WriteByte(' ')
}
b.WriteString(text)
b.WriteByte('\n')
}
para.Reset()
style = ""
}
for {
tok, err := decoder.Token()
if err != nil {
break
}
switch el := tok.(type) {
case xml.StartElement:
switch el.Name.Local {
case "p":
inPara = true
para.Reset()
style = ""
case "pStyle":
if inPara {
for _, a := range el.Attr {
if a.Name.Local == "val" {
style = a.Value
}
}
}
}
case xml.EndElement:
if el.Name.Local == "p" {
inPara = false
flush()
}
case xml.CharData:
if inPara {
para.Write(bytes.TrimSpace(el))
}
}
}
flush()
return strings.TrimRight(b.String(), "\n"), nil
}
// headingLevel 把 docx styleId 映射为标题层级:Heading1..9 → 1..9,
// Title → 1,Subtitle → 2;其余(含表格单元格正文)返回 0。
func headingLevel(style string) int {
switch {
case style == "Title":
return 1
case style == "Subtitle":
return 2
case strings.HasPrefix(style, "Heading"):
n := 0
for _, c := range style[len("Heading"):] {
if c < '0' || c > '9' {
return 0
}
n = n*10 + int(c-'0')
}
if n >= 1 && n <= 9 {
return n
}
}
return 0
}
var lintJSON = regexp.MustCompile(`(?s)\[.*\]`)
// lintFile 运行 housestyle.lint。返回 (issues, nil) 表示 lint 实际执行;
// 非 nil err 表示没有跑起来(解释器缺失、模块不可导入等),调用方决定是否
// 按「自检未运行」暴露。env 与 run_python 的 execEnv 同源语义:继承最小
// 环境并附加 AGENT_SKILLS_DIR / PYTHONPATH,否则后端进程里 import 不了 housestyle。
func lintFile(path string) ([]LintIssue, error) {
cmd := exec.Command("python3", "-m", "housestyle.lint", path)
cmd.Env = lintCommandEnv()
out, err := cmd.CombinedOutput()
if err != nil && !bytes.Contains(out, []byte("\"rule\"")) {
return nil, fmt.Errorf("%s", firstLine(string(out)))
}
raw := out
if loc := lintJSON.Find(out); loc != nil {
raw = loc
}
var issues []LintIssue
if json.Unmarshal(raw, &issues) != nil {
return nil, fmt.Errorf("lint 输出无法解析:%s", firstLine(string(out)))
}
return issues, nil
}
// lintCommandEnv 继承最小环境(PATH/HOME/LANG 等,与 execEnv 的继承面一致)。
// 技能根注入后附加 AGENT_SKILLS_DIR 与 PYTHONPATH=<root>/house-style;进程已有
// PYTHONPATH 时前置合并(测试与二次包装场景不丢自己的路径)。
// lintEnvRoot 为空时返回 nil(不设 cmd.Env,完整继承,保持旧行为)。
func lintCommandEnv() []string {
if lintEnvRoot == "" {
return nil
}
var out []string
var existingPath string
for _, kv := range os.Environ() {
if strings.HasPrefix(kv, "PYTHONPATH=") {
existingPath = strings.TrimPrefix(kv, "PYTHONPATH=")
continue
}
for _, keep := range []string{"PATH=", "HOME=", "LANG=", "LC_", "TZ=", "TERM="} {
if strings.HasPrefix(kv, keep) {
out = append(out, kv)
break
}
}
}
pp := filepath.Join(lintEnvRoot, "house-style")
if existingPath != "" {
pp = pp + string(os.PathListSeparator) + existingPath
}
out = append(out,
"AGENT_SKILLS_DIR="+lintEnvRoot,
"PYTHONPATH="+pp,
)
return out
}
func firstLine(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexByte(s, '\n'); i >= 0 {
s = s[:i]
}
if utf8.RuneCountInString(s) > 160 {
s = string([]rune(s)[:160]) + "…"
}
return s
}
// HardFacts 只评可机械判定的硬事实:lint 实际运行且零 error。
// 关键词维度(ScoreText 六维)从拦截路径移出(裁定 4/P5):它们描述的是
// 写作质量偏好,实测会把正常中文章节标题判成栏目名、把没有"未指明"字样的
// 行动表判成不可执行,造成终轮死循环;评分整体仍由 ScoreBytes 供评测侧使用。
func HardFacts(name string, content []byte) Report {
report := ScoreBytes(name, content)
report.Passed = true
report.Violations = nil
for _, issue := range report.Lint {
if issue.Severity == "error" {
report.Passed = false
report.Violations = append(report.Violations, "版式 "+issue.Rule+":"+issue.Message)
}
}
if !report.LintRan && lintEnvRoot != "" {
report.Passed = false
report.Violations = append(report.Violations, "自检未运行(housestyle.lint 不可用)")
}
return report
}
// DeliverableText 供事实核查抽取工件文本(docx 与 ScoreBytes 同源口径;
// 二进制返回空)。导出给 runtime 包使用。
func DeliverableText(name string, content []byte) string {
dir, err := os.MkdirTemp("", "factcheck")
if err != nil {
return ""
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, name)
if err := os.WriteFile(path, content, 0o644); err != nil {
return ""
}
text, err := readDeliverable(path)
if err != nil {
return ""
}
return text
}