Repair the quality-gate failure chain observed on real runs
Why: Real transcripts (dev instance, 62 assistant turns) showed the artifact gate silently off and self-deadlocking. lintFile ran python3 -m housestyle.lint without the skills PYTHONPATH, so the import failed and nil was treated as "no issues" (same docx: 9 lint errors with PYTHONPATH, 0 without). stop() re-scored every artifact registered in the run, including superseded versions, so a fixed document could never pass and the composed reply reported the old verdict. ctxasm rendered history artifacts as "[产出工件 …]" - the exact pattern ClaimGate treats as a forged tool marker, so models copying the format were rejected. scoreReadable flagged table cells like "项目概述撰写" as column headings. Prompts and recipes said "python -m" (no python on PATH; exit 127) and housestyle.summarize was invoked as a module that does not exist. Evidence: SELF - new tests TestLintUnavailableFailsVisibleWhenSkillsRootSet, TestLintCommandEnvCarriesSkillsRoot, TestDocxHeadingMarkedAndTableCellNotFlagged, TestLatestDeliverablesKeepsLastPerName, TestRecipesUseRunnablePythonCommands, TestLibraryUsesRunnablePythonCommands; go test ./internal/... passes; manual: python3 -m housestyle.outline_cli reads a real artifact docx cleanly, lint without PYTHONPATH exits 1 with ModuleNotFoundError and is now surfaced as 自检未运行 when the skills root is configured. Impact: compose root injects quality.SetLintEnvRoot(<data>/skills). Final-turn checks score only the newest artifact per name; deliverable lists and reply cards follow the same dedupe. History artifact placeholders no longer collide with the forged-marker regex. docx text extraction marks Title/HeadingN styles so readability only judges real headings, with line numbers in the reason. Check reminders no longer invite the model to ignore failed checks. Oversized tool output is truncated by runes, not bytes. Refs: Dev task feature/agent-quality-phase01 (quality gap diagnosis 2026-10-09).
This commit is contained in:
@@ -41,6 +41,7 @@ import (
|
||||
"agent-system-backend/internal/plugins"
|
||||
"agent-system-backend/internal/profiles"
|
||||
"agent-system-backend/internal/prompts"
|
||||
"agent-system-backend/internal/quality"
|
||||
"agent-system-backend/internal/rag"
|
||||
"agent-system-backend/internal/ratelimit"
|
||||
"agent-system-backend/internal/runmgr"
|
||||
@@ -204,6 +205,9 @@ func Run(opt Options) error {
|
||||
}
|
||||
}
|
||||
tools.SetSkillsRoot(skillsRoot)
|
||||
// 终轮评分的 lint 子进程与 run_python 同一技能根:不注入的话 housestyle
|
||||
// 在后端进程里 import 失败,lint 静默变空结果(评分门禁半边失效)。
|
||||
quality.SetLintEnvRoot(skillsRoot)
|
||||
// 小队的 agent-native 内核工厂(组合根装配,逐次运行时从 settings 解析):
|
||||
// 模型走 settings.models.chat 同一路由;搜索/解释器/MCP/技能根复用产品配置;
|
||||
// 委派桥接产品 SpawnSubagent。主模型未配置时返回未启用(不阻塞产品)。
|
||||
|
||||
@@ -381,19 +381,22 @@ func renderCardPart(cp *core.CardPart, resolveCard CardResolver, limit int, rep
|
||||
// renderArtifactRef 渲染 artifact_ref 部件:有 resolver 且内容为 UTF-8 文本时
|
||||
// 回填正文(与卡同 limit,超限截断留痕);二进制/不可读/无 resolver 留占位行
|
||||
// (可见即信号,不静默丢弃)。
|
||||
// 占位文案不带「[产出工件 …]」方括号形态:那正是 ClaimGate 用来识别伪造调用
|
||||
// 标记的模式——历史里自己产出这种格式,下一轮模型照抄就会被终轮检查误判为
|
||||
// 「正文里出现了工具调用标记」。
|
||||
func renderArtifactRef(a *core.ArtifactRef, resolve ArtifactResolver, limit int, rep *Report, backfilled *int) string {
|
||||
if a == nil {
|
||||
return ""
|
||||
}
|
||||
if resolve == nil {
|
||||
return fmt.Sprintf("[产出工件 %s]\n", a.Name)
|
||||
return fmt.Sprintf("此前已生成文件:%s。\n", a.Name)
|
||||
}
|
||||
art, content, ok := resolve(a.ArtifactID)
|
||||
if !ok {
|
||||
return fmt.Sprintf("[产出工件 %s]\n", a.Name)
|
||||
return fmt.Sprintf("此前已生成文件:%s。\n", a.Name)
|
||||
}
|
||||
if !utf8.Valid(content) {
|
||||
return fmt.Sprintf("[产出工件 %s(二进制内容,正文见工件查看器)]\n", art.Name)
|
||||
return fmt.Sprintf("此前已生成文件:%s(二进制内容,正文见工件查看器)。\n", art.Name)
|
||||
}
|
||||
*backfilled++
|
||||
text := string(content)
|
||||
@@ -401,7 +404,7 @@ func renderArtifactRef(a *core.ArtifactRef, resolve ArtifactResolver, limit int,
|
||||
text = string(runes[:limit]) + "…(工件超长截断)"
|
||||
rep.Truncations = append(rep.Truncations, fmt.Sprintf("工件 %s 超 %d 字符截断", art.Name, limit))
|
||||
}
|
||||
return fmt.Sprintf("[产出工件 %s]\n%s\n", art.Name, text)
|
||||
return fmt.Sprintf("此前已生成文件 %s,内容如下:\n%s\n", art.Name, text)
|
||||
}
|
||||
|
||||
// extractiveSummary 把窗口外历史压成短摘要(不调用模型)。summarizeBeyond 打开时注入。
|
||||
|
||||
@@ -160,7 +160,7 @@ func TestAssembleInputArtifactBackfill(t *testing.T) {
|
||||
return core.Artifact{}, nil, false
|
||||
}
|
||||
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, resolve)
|
||||
if !strings.Contains(msgs[1].Content, "[产出工件 检索汇总.md]") || !strings.Contains(msgs[1].Content, "关键结论一。") {
|
||||
if !strings.Contains(msgs[1].Content, "此前已生成文件 检索汇总.md,内容如下:") || !strings.Contains(msgs[1].Content, "关键结论一。") {
|
||||
t.Errorf("artifact body not backfilled: %q", msgs[1].Content)
|
||||
}
|
||||
if !contains(rep.Items, "artifact(1)") {
|
||||
@@ -190,7 +190,7 @@ func TestAssembleInputArtifactBackfill(t *testing.T) {
|
||||
|
||||
// 无 resolver:占位可见。
|
||||
msgs4, _ := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, nil)
|
||||
if !strings.Contains(msgs4[1].Content, "[产出工件 检索汇总.md]") {
|
||||
if !strings.Contains(msgs4[1].Content, "此前已生成文件:检索汇总.md。") {
|
||||
t.Errorf("artifact placeholder missing without resolver: %q", msgs4[1].Content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ description: 生成文档、表格、演示稿的工作流、样式决策与技
|
||||
1. **定样式**,按下面的优先级只取第一个命中项:本轮用户的明确指令(例如改成公文、换成品牌色)→ 本轮上传的模板(.docx/.dotx/.pptx/.potx/.xlsx/.xltx)→ 本轮要求仿制的参考文档(`from housestyle.extract import extract`,抽取字体、字号、色板、页边距,得到自定义主题)→ 本会话已经确定的样式 → 设置中的默认模板 → 设置中的 deliverables.style(modern / formal / minimal)和强调色。未指定时用环境段给出的默认样式。
|
||||
2. **规划版面**:先定读者、结构和每一节的内容,再写脚本。不要边生成边补空节。
|
||||
3. **生成**:`import housestyle`,用 docx_kit / pptx_kit / xlsx_kit 的构件写文件。文件落在会话工作区。模板文件和参考原件在 `inputs/`(环境段列出相对路径)。
|
||||
4. **自检**:`python -m housestyle.lint <文件>`。输出为 JSON;存在 severity=error 时必须修好再继续,不要带着 error 登记。然后用 `housestyle.summarize` 回读标题、表格和页数,核对与规划一致。
|
||||
4. **自检**:`python3 -m housestyle.lint <文件>`。输出为 JSON;存在 severity=error 时必须修好再继续,不要带着 error 登记。然后用 `python3 -m housestyle.outline_cli <文件>` 回读标题、表格和页数,核对与规划一致。
|
||||
5. **登记**:`register_artifact`(path 传相对路径)。登记后对话里出现可预览的卡片。
|
||||
6. **收尾**:回复只写结论摘要,并说明这次用的样式(主题、是否模板、是否仿制)以及用户还能怎么调。不要在回复里复述全文,也不要只口头说文件名。
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package prompts
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var barePythonRe = regexp.MustCompile(`(?:^|[^0-9A-Za-z_.-])python -(?:m|c)\b`)
|
||||
|
||||
// 提示词库里的命令必须能在执行环境跑起来:解释器只有 python3。
|
||||
// 实测模型照提示词执行 `python -m housestyle.lint` 得到 exit 127。
|
||||
func TestLibraryUsesRunnablePythonCommands(t *testing.T) {
|
||||
names, err := fs.Glob(libraryFS, "library/*.md")
|
||||
if err != nil {
|
||||
t.Fatalf("glob: %v", err)
|
||||
}
|
||||
var problems []string
|
||||
for _, name := range names {
|
||||
raw, err := libraryFS.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
body := string(raw)
|
||||
for _, loc := range barePythonRe.FindAllStringIndex(body, -1) {
|
||||
problems = append(problems, name+": bare `python -` command near: "+strings.TrimSpace(body[loc[0]:min(loc[0]+60, len(body))]))
|
||||
}
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
t.Fatalf("unrunnable python commands in prompt library:\n%s", strings.Join(problems, "\n"))
|
||||
}
|
||||
}
|
||||
+154
-12
@@ -26,12 +26,15 @@ type Dimension struct {
|
||||
}
|
||||
|
||||
// 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 不存在时留空,不因此判失败。
|
||||
@@ -41,6 +44,17 @@ type LintIssue struct {
|
||||
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)
|
||||
|
||||
@@ -82,20 +96,27 @@ func ScoreText(text string) Report {
|
||||
|
||||
// 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 := lintFile(path)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -176,11 +197,22 @@ func scoreActionable(text string) Dimension {
|
||||
return Dimension{ID: "可执行性", Score: 35, Note: "没有可交给人的行动", Evidence: snippet(text, 40)}
|
||||
}
|
||||
|
||||
// scoreReadable 只检查真正的标题行(# 开头;docx 路径已把标题样式标成 #)。
|
||||
// 此前对每一行做「栏目名词 + 短行」判断,表格单元格(如「项目概述撰写」)
|
||||
// 会被当成标题误报,终轮检查因此对合格文档连续判负(实测死循环源之一)。
|
||||
// 命中时 Note 带行号,模型能定位到要改的那一行。
|
||||
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}
|
||||
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, "。。") {
|
||||
@@ -251,6 +283,9 @@ func readDeliverable(path string) (string, error) {
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// readDocxText 抽取 docx 纯文本,并把标题样式(Title / HeadingN)的段落标成
|
||||
// Markdown # 前缀——评分卡据此区分「标题行」与「正文/表格单元格」,也让
|
||||
// scoreLead 等维度拿到真实的标题信息。
|
||||
func readDocxText(path string) (string, error) {
|
||||
zr, err := zip.OpenReader(path)
|
||||
if err != nil {
|
||||
@@ -274,6 +309,22 @@ func readDocxText(path string) (string, error) {
|
||||
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 {
|
||||
@@ -281,23 +332,70 @@ func readDocxText(path string) (string, error) {
|
||||
}
|
||||
switch el := tok.(type) {
|
||||
case xml.StartElement:
|
||||
if el.Name.Local == "p" && b.Len() > 0 {
|
||||
b.WriteByte('\n')
|
||||
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:
|
||||
b.Write(bytes.TrimSpace(el))
|
||||
if inPara {
|
||||
para.Write(bytes.TrimSpace(el))
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String(), nil
|
||||
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)\[.*\]`)
|
||||
|
||||
func lintFile(path string) []LintIssue {
|
||||
// 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
|
||||
return nil, fmt.Errorf("%s", firstLine(string(out)))
|
||||
}
|
||||
raw := out
|
||||
if loc := lintJSON.Find(out); loc != nil {
|
||||
@@ -305,7 +403,51 @@ func lintFile(path string) []LintIssue {
|
||||
}
|
||||
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
|
||||
}
|
||||
return issues
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
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
|
||||
}
|
||||
@@ -101,7 +101,7 @@ func (st *directRun) composeUserPrompt(draft, notice string) string {
|
||||
}
|
||||
b.WriteString("\n交付物:\n")
|
||||
names := 0
|
||||
for _, ref := range st.deliverables {
|
||||
for _, ref := range st.latestDeliverables() {
|
||||
if ref == nil || strings.TrimSpace(ref.Name) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// 修订(同名再登记)后,终轮检查与交付清单只看最新版本;
|
||||
// 无名工件互不合并。
|
||||
func TestLatestDeliverablesKeepsLastPerName(t *testing.T) {
|
||||
st := &directRun{deliverables: []*core.ArtifactRef{
|
||||
{ArtifactID: "art_aaa1", Name: "方案.docx"},
|
||||
{ArtifactID: "art_bbb2", Name: "纪要.docx"},
|
||||
{ArtifactID: "art_ccc3", Name: "方案.docx"},
|
||||
{ArtifactID: "art_ddd4", Name: ""},
|
||||
}}
|
||||
got := st.latestDeliverables()
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("want 3 deliverables, got %d: %+v", len(got), got)
|
||||
}
|
||||
if got[0].ArtifactID != "art_ccc3" || got[0].Name != "方案.docx" {
|
||||
t.Fatalf("方案.docx should resolve to latest: %+v", got[0])
|
||||
}
|
||||
if got[1].ArtifactID != "art_bbb2" {
|
||||
t.Fatalf("纪要.docx should be untouched: %+v", got[1])
|
||||
}
|
||||
if got[2].ArtifactID != "art_ddd4" {
|
||||
t.Fatalf("unnamed artifact must be kept as-is: %+v", got[2])
|
||||
}
|
||||
}
|
||||
@@ -227,7 +227,7 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
|
||||
if err == nil && res.Err == nil && len(st.finalParts) == 0 {
|
||||
st.finalParts = append(st.finalParts, core.Part{Type: core.PartText, Text: "(模型未返回内容)"})
|
||||
}
|
||||
for _, ref := range st.deliverables {
|
||||
for _, ref := range st.latestDeliverables() {
|
||||
st.finalParts = append(st.finalParts, core.Part{Type: core.PartArtifactRef, Artifact: ref})
|
||||
}
|
||||
st.switchMu.Lock()
|
||||
@@ -394,11 +394,14 @@ func (st *directRun) stop(last agentllm.Message, info nruntime.StopInfo) nruntim
|
||||
if !st.eff.GetBool("runtime.verify", false) {
|
||||
return nruntime.StopDecision{}
|
||||
}
|
||||
// 终轮检查只评每个文件的最新版本:修订产生新工件后,被替代的旧版本
|
||||
// 已不是交付物,继续按旧版评分会让检查永远无法通过(实测死循环根因)。
|
||||
latest := st.latestDeliverables()
|
||||
// N1 WP5:工件存在/非空/质量检查走下沉的 CheckArtifacts(端口在 llm.go 投影)。
|
||||
if reasons := gates.CheckArtifacts(artifactRefsOf(st.deliverables), artifactLookupOf(st.rc), qualityScorer()); len(reasons) > 0 {
|
||||
if reasons := gates.CheckArtifacts(artifactRefsOf(latest), artifactLookupOf(st.rc), qualityScorer()); len(reasons) > 0 {
|
||||
return st.continueOrNotice(info, strings.Join(reasons, ";"))
|
||||
}
|
||||
verdict, err := st.l.runVerifier(st.rc, st.turnBody, st.evidence, st.deliverables)
|
||||
verdict, err := st.l.runVerifier(st.rc, st.turnBody, st.evidence, latest)
|
||||
if err != nil {
|
||||
st.addVerifyNote(err.Error())
|
||||
return nruntime.StopDecision{Notice: verifierUnavailableNotice}
|
||||
@@ -428,6 +431,27 @@ func (st *directRun) demoteLastText() int {
|
||||
return -1
|
||||
}
|
||||
|
||||
// latestDeliverables 同名工件只保留最后登记的版本,顺序不变。
|
||||
// 修订(同名再登记)后旧版本不再是交付物:不进终轮检查、不重复出现在
|
||||
// 回复的交付清单里。无名工件互不合并。
|
||||
func (st *directRun) latestDeliverables() []*core.ArtifactRef {
|
||||
idx := map[string]int{}
|
||||
var out []*core.ArtifactRef
|
||||
for _, ref := range st.deliverables {
|
||||
if ref == nil || strings.TrimSpace(ref.Name) == "" {
|
||||
out = append(out, ref)
|
||||
continue
|
||||
}
|
||||
if i, ok := idx[ref.Name]; ok {
|
||||
out[i] = ref
|
||||
continue
|
||||
}
|
||||
idx[ref.Name] = len(out)
|
||||
out = append(out, ref)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (st *directRun) addVerifyNote(reason string) {
|
||||
st.addCheck(false, reason)
|
||||
}
|
||||
@@ -588,8 +612,8 @@ func (st *directRun) runTool(name, args string) agenttools.Result {
|
||||
st.inflight.status = status
|
||||
st.inflight.verifyText = tailRunes(out, 600)
|
||||
}
|
||||
if len(out) > 16000 {
|
||||
out = out[:16000] + "…(超长截断)"
|
||||
if r := []rune(out); len(r) > 16000 {
|
||||
out = string(r[:16000]) + "…(超长截断)"
|
||||
}
|
||||
return agenttools.Result{Content: out, IsError: err != nil}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ const verifyFallbackPrompt = `你是独立验证器,不调用任何工具。
|
||||
// 不用这层包裹的话,模型会以为是用户自己提出的意见。
|
||||
func systemCheckReason(body string) string {
|
||||
body = strings.TrimSpace(body)
|
||||
return "<system-reminder>\n这是系统的终轮自动检查,不是用户发言。先用工具核实;如果检查有误就直接收尾,不要向用户提起这条检查。\n" + body + "\n</system-reminder>"
|
||||
return "<system-reminder>\n这是系统的终轮自动检查,不是用户发言。先用工具核实并修复指出的问题。检查带位置时先修那一处;确认检查本身有误时,在最终答复里用一句话说明原因(不要引用检查原文),然后如实收尾——既不要默默无视未通过的检查,也不要在没核实的情况下反复空转。\n" + body + "\n</system-reminder>"
|
||||
}
|
||||
|
||||
func (l *LLMRunner) runVerifier(rc RunCtx, body string, evidence []toolEvidence, refs []*core.ArtifactRef) (gates.Verdict, error) {
|
||||
@@ -107,9 +107,9 @@ func evidencePrompt(user, body string, evidence []toolEvidence, refs []*core.Art
|
||||
}
|
||||
fmt.Fprintf(&b, "- %s %s\n", ref.ArtifactID, ref.Name)
|
||||
}
|
||||
b.WriteString("\n内置质量检查:已通过。")
|
||||
b.WriteString("\n内置质量检查:上面列出的每个文件的最新版本均已检查:存在且非空。")
|
||||
if len(refs) > 0 {
|
||||
b.WriteString("工件存在且非空;md、txt、docx 的版式评分已通过。这是强证据,不要用更早的工具输出推翻它。")
|
||||
b.WriteString("文本类工件(md、txt、docx)的版式与内容评分也已通过;若某项检查未实际运行,会在结论里单独标注「自检未运行」。这是强证据,不要用更早的工具输出推翻它。")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
return b.String()
|
||||
|
||||
@@ -23,7 +23,7 @@ dx.add_callout(doc, "只看这一段也能知道要做什么。", "摘要")
|
||||
dx.save(doc, "交付物.docx")
|
||||
```
|
||||
|
||||
然后执行 `python -m housestyle.lint 交付物.docx`。没有 error 再 `register_artifact`。修订用新文件名。
|
||||
然后执行 `python3 -m housestyle.lint 交付物.docx`。没有 error 再 `register_artifact`。修订用新文件名。
|
||||
|
||||
版面范式:
|
||||
|
||||
|
||||
@@ -74,8 +74,8 @@ xs.save(wb, "交付物.xlsx", ["单位:元。口径见源表。"])
|
||||
## 自检
|
||||
|
||||
```bash
|
||||
python -m housestyle.lint 交付物.docx
|
||||
python -c "import housestyle; print(housestyle.summarize('交付物.docx'))"
|
||||
python3 -m housestyle.lint 交付物.docx
|
||||
python3 -m housestyle.outline_cli 交付物.docx
|
||||
```
|
||||
|
||||
lint 打印 JSON。`severity` 为 `error` 时退出码是 1,必须修好再 `register_artifact`。模板生成的文件会把纸张、字体、主题色、页码降为 warning,结构问题(空段、空节、手打符号、跳级)仍然是 error。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""回读交付物结构,供生成后核对,不代替 lint。"""
|
||||
"""回读交付物结构,供生成后核对,不代替 lint。命令行入口在 outline_cli。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""命令行入口:python3 -m housestyle.outline_cli <文件>。
|
||||
|
||||
独立模块而不是直接挂在 outline.__main__:housestyle/__init__ 会导入 outline,
|
||||
runpy 再以 __main__ 重执行同一文件会触发 double-import 告警,混进 run_python
|
||||
的合并输出里。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from housestyle.outline import summarize
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 2:
|
||||
print("usage: python3 -m housestyle.outline_cli <file>", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
print(summarize(sys.argv[1]))
|
||||
@@ -20,6 +20,6 @@ px.add_chart_slide(prs, "进度", ["上周", "本周"], {"完成项": [2, 5]})
|
||||
px.save(prs, "交付物.pptx")
|
||||
```
|
||||
|
||||
然后 `python -m housestyle.lint 交付物.pptx`。没有 error 再登记。修订用新文件名。
|
||||
然后 `python3 -m housestyle.lint 交付物.pptx`。没有 error 再登记。修订用新文件名。
|
||||
|
||||
规则:一页一个论点,要点不超过 5 条,不把纪要全文贴进幻灯片。标题页、分节页、要点页、对比页、表或图,按叙事需要取用,不堆版式。
|
||||
|
||||
@@ -23,6 +23,6 @@ xs.save(wb, "交付物.xlsx", ["金额 = 数量 × 单价,单位元。来源
|
||||
|
||||
`add_sheet` 会加表头底色、冻结首行、筛选、列宽、A4 横向打印和千分位。`save` 会补一张“说明”表,用来写口径、单位和来源。公式写成以 `=` 开头的字符串。
|
||||
|
||||
然后 `python -m housestyle.lint 交付物.xlsx`。没有 error 再登记。修订用新文件名。
|
||||
然后 `python3 -m housestyle.lint 交付物.xlsx`。没有 error 再登记。修订用新文件名。
|
||||
|
||||
数值列右对齐。不要把说明文字塞进数据表的空行里。
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var (
|
||||
barePythonRe = regexp.MustCompile(`(?:^|[^0-9A-Za-z_.-])python -(?:m|c)\b`)
|
||||
housestyleRe = regexp.MustCompile(`python3 -m housestyle\.([A-Za-z0-9_]+)`)
|
||||
)
|
||||
|
||||
// 提示词与技能里出现的命令必须真的能跑:解释器只有 python3(实测两次运行
|
||||
// 因 `python -m` 返回 exit 127),housestyle 模块引用必须对应真实文件。
|
||||
// 真实执行环境没有 `python` 这个名字,写 `python -m …` 的指引必然失败。
|
||||
func TestRecipesUseRunnablePythonCommands(t *testing.T) {
|
||||
recipes := "recipes"
|
||||
var problems []string
|
||||
err := filepath.WalkDir(recipes, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".md") {
|
||||
return err
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := string(raw)
|
||||
for _, loc := range barePythonRe.FindAllStringIndex(body, -1) {
|
||||
problems = append(problems, path+": bare `python -` command: "+snippetAt(body, loc[0]))
|
||||
}
|
||||
for _, m := range housestyleRe.FindAllStringSubmatch(body, -1) {
|
||||
mod := m[1]
|
||||
if _, err := os.Stat(filepath.Join(recipes, "house-style", "housestyle", mod+".py")); err != nil {
|
||||
problems = append(problems, path+": references missing module housestyle/"+mod)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk recipes: %v", err)
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
t.Fatalf("unrunnable python commands in recipes:\n%s", strings.Join(problems, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
func snippetAt(body string, at int) string {
|
||||
start := at
|
||||
end := at + 60
|
||||
if start > 0 && start > len(body) {
|
||||
start = len(body)
|
||||
}
|
||||
if end > len(body) {
|
||||
end = len(body)
|
||||
}
|
||||
return strings.ReplaceAll(body[start:end], "\n", " ")
|
||||
}
|
||||
Reference in New Issue
Block a user