// 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 由组合根在启动时注入(/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=/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 }