diff --git a/internal/app/app.go b/internal/app/app.go index 89016d8..a982ed4 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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。主模型未配置时返回未启用(不阻塞产品)。 diff --git a/internal/ctxasm/ctxasm.go b/internal/ctxasm/ctxasm.go index e2ca562..1712c84 100644 --- a/internal/ctxasm/ctxasm.go +++ b/internal/ctxasm/ctxasm.go @@ -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 打开时注入。 diff --git a/internal/ctxasm/ctxasm_test.go b/internal/ctxasm/ctxasm_test.go index 92c22de..06b61cf 100644 --- a/internal/ctxasm/ctxasm_test.go +++ b/internal/ctxasm/ctxasm_test.go @@ -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) } } diff --git a/internal/prompts/library/base-deliverables.md b/internal/prompts/library/base-deliverables.md index 19e7147..b0e80c8 100644 --- a/internal/prompts/library/base-deliverables.md +++ b/internal/prompts/library/base-deliverables.md @@ -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. **收尾**:回复只写结论摘要,并说明这次用的样式(主题、是否模板、是否仿制)以及用户还能怎么调。不要在回复里复述全文,也不要只口头说文件名。 diff --git a/internal/prompts/library_commands_test.go b/internal/prompts/library_commands_test.go new file mode 100644 index 0000000..d5288ee --- /dev/null +++ b/internal/prompts/library_commands_test.go @@ -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")) + } +} diff --git a/internal/quality/score.go b/internal/quality/score.go index af7ea2a..27ed7ca 100644 --- a/internal/quality/score.go +++ b/internal/quality/score.go @@ -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 由组合根在启动时注入(/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=/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 } diff --git a/internal/quality/score_lint_test.go b/internal/quality/score_lint_test.go new file mode 100644 index 0000000..8b3bee2 --- /dev/null +++ b/internal/quality/score_lint_test.go @@ -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(``) + for _, p := range paras { + b.WriteString(``) + if p.style != "" { + b.WriteString(``) + } + b.WriteString(`` + p.text + ``) + } + b.WriteString(``) + 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 +} diff --git a/internal/runtime/compose.go b/internal/runtime/compose.go index 01b33ca..8682e2b 100644 --- a/internal/runtime/compose.go +++ b/internal/runtime/compose.go @@ -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 } diff --git a/internal/runtime/deliverable_latest_test.go b/internal/runtime/deliverable_latest_test.go new file mode 100644 index 0000000..7ff8c24 --- /dev/null +++ b/internal/runtime/deliverable_latest_test.go @@ -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]) + } +} diff --git a/internal/runtime/llm_direct.go b/internal/runtime/llm_direct.go index 6bd4a8b..d1c3d81 100644 --- a/internal/runtime/llm_direct.go +++ b/internal/runtime/llm_direct.go @@ -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} } diff --git a/internal/runtime/verify.go b/internal/runtime/verify.go index 37df912..157669d 100644 --- a/internal/runtime/verify.go +++ b/internal/runtime/verify.go @@ -22,7 +22,7 @@ const verifyFallbackPrompt = `你是独立验证器,不调用任何工具。 // 不用这层包裹的话,模型会以为是用户自己提出的意见。 func systemCheckReason(body string) string { body = strings.TrimSpace(body) - return "\n这是系统的终轮自动检查,不是用户发言。先用工具核实;如果检查有误就直接收尾,不要向用户提起这条检查。\n" + body + "\n" + return "\n这是系统的终轮自动检查,不是用户发言。先用工具核实并修复指出的问题。检查带位置时先修那一处;确认检查本身有误时,在最终答复里用一句话说明原因(不要引用检查原文),然后如实收尾——既不要默默无视未通过的检查,也不要在没核实的情况下反复空转。\n" + body + "\n" } 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() diff --git a/internal/skills/recipes/docx-author/SKILL.md b/internal/skills/recipes/docx-author/SKILL.md index 91836ff..782c836 100644 --- a/internal/skills/recipes/docx-author/SKILL.md +++ b/internal/skills/recipes/docx-author/SKILL.md @@ -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`。修订用新文件名。 版面范式: diff --git a/internal/skills/recipes/house-style/SKILL.md b/internal/skills/recipes/house-style/SKILL.md index 1d34897..980840c 100644 --- a/internal/skills/recipes/house-style/SKILL.md +++ b/internal/skills/recipes/house-style/SKILL.md @@ -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。 diff --git a/internal/skills/recipes/house-style/housestyle/outline.py b/internal/skills/recipes/house-style/housestyle/outline.py index 5f56bcc..3e80ee7 100644 --- a/internal/skills/recipes/house-style/housestyle/outline.py +++ b/internal/skills/recipes/house-style/housestyle/outline.py @@ -1,4 +1,4 @@ -"""回读交付物结构,供生成后核对,不代替 lint。""" +"""回读交付物结构,供生成后核对,不代替 lint。命令行入口在 outline_cli。""" from __future__ import annotations diff --git a/internal/skills/recipes/house-style/housestyle/outline_cli.py b/internal/skills/recipes/house-style/housestyle/outline_cli.py new file mode 100644 index 0000000..5101c37 --- /dev/null +++ b/internal/skills/recipes/house-style/housestyle/outline_cli.py @@ -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=sys.stderr) + raise SystemExit(2) + print(summarize(sys.argv[1])) diff --git a/internal/skills/recipes/pptx-author/SKILL.md b/internal/skills/recipes/pptx-author/SKILL.md index 377d4d1..72c8d5b 100644 --- a/internal/skills/recipes/pptx-author/SKILL.md +++ b/internal/skills/recipes/pptx-author/SKILL.md @@ -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 条,不把纪要全文贴进幻灯片。标题页、分节页、要点页、对比页、表或图,按叙事需要取用,不堆版式。 diff --git a/internal/skills/recipes/xlsx-author/SKILL.md b/internal/skills/recipes/xlsx-author/SKILL.md index e99439e..de8ce5a 100644 --- a/internal/skills/recipes/xlsx-author/SKILL.md +++ b/internal/skills/recipes/xlsx-author/SKILL.md @@ -23,6 +23,6 @@ xs.save(wb, "交付物.xlsx", ["金额 = 数量 × 单价,单位元。来源 `add_sheet` 会加表头底色、冻结首行、筛选、列宽、A4 横向打印和千分位。`save` 会补一张“说明”表,用来写口径、单位和来源。公式写成以 `=` 开头的字符串。 -然后 `python -m housestyle.lint 交付物.xlsx`。没有 error 再登记。修订用新文件名。 +然后 `python3 -m housestyle.lint 交付物.xlsx`。没有 error 再登记。修订用新文件名。 数值列右对齐。不要把说明文字塞进数据表的空行里。 diff --git a/internal/skills/recipes_commands_test.go b/internal/skills/recipes_commands_test.go new file mode 100644 index 0000000..ff9c02f --- /dev/null +++ b/internal/skills/recipes_commands_test.go @@ -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", " ") +}