Files
Agent-System/internal/squad/quality_gate.go
T
agent-bot 2cd52da85d
CI / go-vet (pull_request) Successful in 12s
CI / short-tests-report (pull_request) Failing after 35s
CI / backup-restore-drill (pull_request) Successful in 36s
CI / web (pull_request) Successful in 33s
refactor(squad): typed ProgressSink port replaces direct stream dependency
KTD1 second step (behavior preserved):
- squad defines ProgressSink (EmitPhase/Detail/Deliverable/Summary/Briefing)
  with its own DetailEvent/ToolEvent types; zero internal/stream imports left
- bizdev assembly maps port to SSE streamer (squad_progress_adapter) and
  injects via ctx at the four squad entry points
- process_bridge tests rewritten as port-contract tests (captureSink)
- verified: build+vet clean, squad/bizdev/archtest green
2026-09-29 05:51:32 +00:00

830 lines
25 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 squad
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"unicode"
"unicode/utf8"
"chat-agent-system/internal/agent"
"chat-agent-system/internal/workflowdef"
)
// ProhibitedPhrases 医疗器械文档小队严禁出现的敷衍套话与省略词
var ProhibitedPhrases = []string{
"不做展开",
"不展开",
"篇幅有限",
"从略",
"不赘述",
"略过细节",
"仅作概要",
"待续",
"…(略)",
"(略)",
}
// QualityGate 负责小队交付物的三层质量门禁与合规审计
type QualityGate struct{}
func NewQualityGate() *QualityGate {
return &QualityGate{}
}
// Validate 对交付正文执行静态 AST 与质量合规校验
func (q *QualityGate) Validate(wsDir string, taskKey string, content string) (*QualityReport, error) {
now := time.Now()
report := &QualityReport{
Passed: true,
Score: 100.0,
TitleCheck: true,
MissingSections: make([]string, 0),
ProhibitedPhrases: make([]string, 0),
Violations: make([]string, 0),
QuoteMatches: true,
CheckedAt: now,
}
// 1. 检查标题完整性。模板 H2 与正文 H2–H4 按归一化标题比对:
// 空白(含全角空格)去掉、全角 ASCII 折半角、英文字母大小写折叠。
// 同名但只出现在 H3/H4 时记层级软警告,不扣分、不阻断。
// 归一化后仍无命中才算缺章。禁止用近义标题顶替。
tmplPath := filepath.Join(wsDir, "template.md")
tmplData, err := os.ReadFile(tmplPath)
if err == nil {
expectedHeadings := parseMarkdownHeadings(string(tmplData), 2, 2)
actualHeadings := parseMarkdownHeadings(content, 2, 4)
actualByNorm := make(map[string][]mdHeading, len(actualHeadings))
for _, act := range actualHeadings {
actualByNorm[act.Norm] = append(actualByNorm[act.Norm], act)
}
seenNorm := make(map[string]struct{}, len(expectedHeadings))
for _, h := range expectedHeadings {
if _, dup := seenNorm[h.Norm]; dup {
continue
}
seenNorm[h.Norm] = struct{}{}
matches := actualByNorm[h.Norm]
if len(matches) == 0 {
report.MissingSections = append(report.MissingSections, h.Title)
report.Violations = append(report.Violations, fmt.Sprintf("缺少必填章节: %s", h.Title))
report.Score -= 15.0
continue
}
hasH2 := false
for _, m := range matches {
if m.Level == 2 {
hasH2 = true
break
}
}
if !hasH2 {
report.Violations = append(report.Violations, fmt.Sprintf("章节层级与模板不一致: %s", h.Title))
}
}
if len(report.MissingSections) > 0 {
report.TitleCheck = false
report.Passed = false
}
}
// 2. 检查敷衍违规套话(否定性自检/禁令复述不计入,避免门禁自污染)
for _, phrase := range ProhibitedPhrases {
if containsProhibitedUsage(content, phrase) {
report.ProhibitedPhrases = append(report.ProhibitedPhrases, phrase)
// 违规说明勿回写禁用原词,避免修复指令/自检再次自污染
report.Violations = append(report.Violations, "检测到正文中的省略/敷衍写法(须实质展开;自检勿复述禁用原词)")
report.Score -= 20.0
report.Passed = false
}
}
// 3. 检查未渲染的双大括号模板占位符
if strings.Contains(content, "{{") && strings.Contains(content, "}}") {
report.Violations = append(report.Violations, "检测到未替换的双大括号占位符 {{...}}")
report.Score -= 10.0
report.Passed = false
}
// 新键与旧键共用同一套门禁;rd_* 不在别名表里,不会被改写。
taskKey = workflowdef.ImplementationTaskKey(taskKey)
// 4. CDO 报价任务检查
if taskKey == "cdo_quote" {
xlsxPath := filepath.Join(wsDir, "CDO报价核算-V01.xlsx")
if _, err := os.Stat(xlsxPath); err != nil {
report.QuoteMatches = false
report.Violations = append(report.Violations, "CDO 报价任务缺少配套核算文件 CDO报价核算-V01.xlsx")
report.Score -= 25.0
report.Passed = false
}
}
// 5. 智能产品经理工作流:系统需求规格书检查
if taskKey == "system_reqs" {
if !strings.Contains(content, "REQ-") && !strings.Contains(content, "SYS-REQ") {
report.Violations = append(report.Violations, "系统需求规格书缺少规范的需求编号(如 REQ-01 / SYS-REQ-01)")
report.Score -= 20.0
report.Passed = false
}
hasVerification := strings.Contains(content, "测试") || strings.Contains(content, "分析") ||
strings.Contains(content, "审查") || strings.Contains(content, "演示") ||
strings.Contains(content, "检验") || strings.Contains(content, "检") || strings.Contains(content, "测")
if !hasVerification {
report.Violations = append(report.Violations, "系统需求清单缺少验证方式字段(测试/分析/审查/演示)")
report.Score -= 15.0
report.Passed = false
}
}
// 6. 智能测试经理工作流:系统验证方案及计划检查
if taskKey == "test_manager" {
if !strings.Contains(content, "TC-") && !strings.Contains(content, "TEST-") {
report.Violations = append(report.Violations, "系统验证方案缺少规范的测试用例编号(如 TC-01 / TEST-01)")
report.Score -= 20.0
report.Passed = false
}
if !strings.Contains(content, "REQ-") {
report.Violations = append(report.Violations, "系统验证方案缺少对上游系统需求编号(REQ-)的追溯覆盖")
report.Score -= 15.0
report.Passed = false
}
tcPath := filepath.Join(wsDir, "artifacts", "test_cases.json")
if tcData, err := os.ReadFile(tcPath); err == nil && len(tcData) > 0 {
var dummy any
if err := json.Unmarshal(tcData, &dummy); err != nil {
report.Violations = append(report.Violations, "artifacts/test_cases.json 不是合法的 JSON 格式")
report.Score -= 10.0
report.Passed = false
}
}
}
for _, v := range rdevDeterministicViolations(wsDir, taskKey, content) {
report.Violations = append(report.Violations, v)
report.Score -= 20.0
report.Passed = false
}
if report.Score < 0 {
report.Score = 0
}
if report.Score < 80.0 {
report.Passed = false
}
return report, nil
}
// sanitizeViolationForAgent 修复指令中不得原样回写禁用套话,避免二次自污染。
func sanitizeViolationForAgent(v string) string {
out := v
for _, phrase := range ProhibitedPhrases {
if phrase != "" && strings.Contains(out, phrase) {
out = strings.ReplaceAll(out, phrase, "「省略/敷衍写法」")
}
}
return out
}
// containsProhibitedUsage 仅当禁词被用作正文省略/敷衍时命中。
// 否定性自检、写作禁令复述(如:无"…"等敷衍套话)不得误报。
func containsProhibitedUsage(content, phrase string) bool {
if phrase == "" || content == "" {
return false
}
searchFrom := 0
for {
rel := strings.Index(content[searchFrom:], phrase)
if rel < 0 {
return false
}
abs := searchFrom + rel
if isNegatedOrMetaProhibitedCitation(content, abs, phrase) ||
isActionPlanStatusCell(content, abs, phrase) {
searchFrom = abs + len(phrase)
continue
}
return true
}
}
func isNegatedOrMetaProhibitedCitation(content string, idx int, phrase string) bool {
lineStart := strings.LastIndex(content[:idx], "\n") + 1
lineEnd := strings.Index(content[idx:], "\n")
if lineEnd < 0 {
lineEnd = len(content)
} else {
lineEnd = idx + lineEnd
}
line := content[lineStart:lineEnd]
localIdx := idx - lineStart
if localIdx < 0 || localIdx > len(line) {
return false
}
// 仅同一自检/规则行,且须同时具备元标注 + 否定词紧邻或引号引用结构。
// 禁止用「48 字窗口出现 无」豁免:正文「此处无更多数据,不做展开」必须仍命中。
if !lineHasProhibitedMetaMarker(line) {
return false
}
return phraseQuotedOnLine(line, phrase) || negationAdjacentToPhrase(line, localIdx)
}
func lineHasProhibitedMetaMarker(line string) bool {
metaMarkers := []string{
"写作禁令", "敷衍套话", "省略套话", "省略敷衍", "禁止使用", "严禁写",
"负向红线", "审核结论", "遵守情况", "交付契约", "写作纪律", "验收标准",
}
for _, m := range metaMarkers {
if strings.Contains(line, m) {
return true
}
}
return false
}
func phraseQuotedOnLine(line, phrase string) bool {
if phrase == "" {
return false
}
wrappers := [][2]string{
{`"`, `"`},
{"「", "」"},
{`'`, `'`},
{"“", "”"},
{"‘", "’"},
}
for _, w := range wrappers {
if strings.Contains(line, w[0]+phrase+w[1]) {
return true
}
}
return false
}
var (
actionPlanStatusHeaders = []string{"阶段", "状态", "进度"}
actionPlanActionHeaders = []string{"行动项", "行动", "动作", "后续动作", "后续行动", "工作内容"}
actionPlanOwnerHeaders = []string{"负责方", "责任人", "负责人"}
actionPlanTriggerHeaders = []string{"触发条件", "触发", "完成条件", "前置条件"}
)
// isActionPlanStatusCell 仅豁免行动计划/后续动作表中「阶段/状态/进度」列的独立单元格「待续」。
// 命中列必须与表头状态列索引一致;动作/负责/触发按表头取值。不得凭同行任意列或邻近词放宽。
// 只接受同一连续表格:表头后须紧跟合法 separator,且数据行列数与表头相等。
func isActionPlanStatusCell(content string, idx int, phrase string) bool {
if phrase != "待续" || idx < 0 || idx >= len(content) {
return false
}
lineStart := strings.LastIndex(content[:idx], "\n") + 1
lineEnd := strings.Index(content[idx:], "\n")
if lineEnd < 0 {
lineEnd = len(content)
} else {
lineEnd = idx + lineEnd
}
line := content[lineStart:lineEnd]
cells := markdownTableCells(line)
if len(cells) < 4 || isMarkdownTableSeparator(cells) {
return false
}
header := findPrecedingTableHeader(content, lineStart)
if len(header) == 0 || len(cells) != len(header) {
return false
}
cols, ok := parseActionPlanColumns(header)
if !ok {
return false
}
cell, hitIdx, ok := tableCellAt(line, idx-lineStart)
if !ok || hitIdx != cols.status || stripMarkdownEmphasis(cell) != "待续" {
return false
}
if cols.action >= len(cells) || cols.owner >= len(cells) || cols.trigger >= len(cells) || cols.status >= len(cells) {
return false
}
if strings.TrimSpace(stripMarkdownEmphasis(cells[cols.owner])) == "" ||
strings.TrimSpace(stripMarkdownEmphasis(cells[cols.trigger])) == "" {
return false
}
return hasSubstantialActionText(stripMarkdownEmphasis(cells[cols.action]))
}
func markdownTableCells(line string) []string {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "|") {
return nil
}
parts := strings.Split(trimmed, "|")
if len(parts) > 0 && parts[0] == "" {
parts = parts[1:]
}
if len(parts) > 0 && strings.TrimSpace(parts[len(parts)-1]) == "" {
parts = parts[:len(parts)-1]
}
if len(parts) == 0 {
return nil
}
return parts
}
func tableCellAt(line string, localIdx int) (string, int, bool) {
if localIdx < 0 || localIdx >= len(line) {
return "", -1, false
}
var pipes []int
for i := 0; i < len(line); i++ {
if line[i] == '|' {
pipes = append(pipes, i)
}
}
if len(pipes) < 2 {
return "", -1, false
}
for i := 0; i < len(pipes)-1; i++ {
start, end := pipes[i], pipes[i+1]
if localIdx > start && localIdx < end {
return line[start+1 : end], i, true
}
}
return "", -1, false
}
func isMarkdownTableSeparator(cells []string) bool {
if len(cells) == 0 {
return false
}
for _, c := range cells {
s := strings.Trim(strings.TrimSpace(c), ":")
s = strings.TrimSpace(s)
if s == "" || strings.Trim(s, "-") != "" {
return false
}
}
return true
}
func stripMarkdownEmphasis(s string) string {
s = strings.TrimSpace(s)
s = strings.ReplaceAll(s, "**", "")
s = strings.ReplaceAll(s, "__", "")
s = strings.TrimSpace(s)
if len(s) >= 2 && strings.HasPrefix(s, "*") && strings.HasSuffix(s, "*") {
s = strings.TrimSpace(s[1 : len(s)-1])
}
return strings.TrimSpace(s)
}
func hasSubstantialActionText(s string) bool {
s = strings.TrimSpace(s)
if s == "" || strings.Contains(s, "待续") {
return false
}
if utf8.RuneCountInString(s) < 12 {
return false
}
letters := 0
for _, r := range s {
if unicode.Is(unicode.Han, r) || unicode.IsLetter(r) {
letters++
}
}
return letters >= 10
}
func findPrecedingTableHeader(content string, lineStart int) []string {
var above []string
prevEnd := lineStart - 1
for prevEnd >= 0 {
prevStart := 0
if prevEnd > 0 {
if i := strings.LastIndex(content[:prevEnd], "\n"); i >= 0 {
prevStart = i + 1
}
}
line := strings.TrimSpace(content[prevStart:prevEnd])
if line == "" {
break
}
cells := markdownTableCells(line)
if cells == nil {
break
}
above = append(above, line)
prevEnd = prevStart - 1
}
if len(above) < 2 {
return nil
}
header := markdownTableCells(above[len(above)-1])
sep := markdownTableCells(above[len(above)-2])
if len(header) == 0 || isMarkdownTableSeparator(header) {
return nil
}
if !isMarkdownTableSeparator(sep) || len(sep) != len(header) {
return nil
}
return header
}
type actionPlanColumns struct {
status int
action int
owner int
trigger int
}
func headerColumnIndex(header []string, names []string) int {
want := make(map[string]struct{}, len(names))
for _, n := range names {
want[n] = struct{}{}
}
for i, c := range header {
if _, ok := want[stripMarkdownEmphasis(c)]; ok {
return i
}
}
return -1
}
func parseActionPlanColumns(header []string) (actionPlanColumns, bool) {
cols := actionPlanColumns{
status: headerColumnIndex(header, actionPlanStatusHeaders),
action: headerColumnIndex(header, actionPlanActionHeaders),
owner: headerColumnIndex(header, actionPlanOwnerHeaders),
trigger: headerColumnIndex(header, actionPlanTriggerHeaders),
}
if cols.status < 0 || cols.action < 0 || cols.owner < 0 || cols.trigger < 0 {
return actionPlanColumns{}, false
}
seen := map[int]struct{}{cols.status: {}, cols.action: {}, cols.owner: {}, cols.trigger: {}}
if len(seen) != 4 {
return actionPlanColumns{}, false
}
return cols, true
}
func isActionPlanTableHeader(cells []string) bool {
_, ok := parseActionPlanColumns(cells)
return ok
}
func negationAdjacentToPhrase(line string, phraseIdx int) bool {
if phraseIdx <= 0 || phraseIdx > len(line) {
return false
}
prefix := strings.TrimRight(line[:phraseIdx], " \t\"'「“‘")
negMarkers := []string{"无", "禁止", "严禁", "不得", "勿", "杜绝", "没有", "不含", "未使用", "不出现", "不应出现"}
for _, n := range negMarkers {
if strings.HasSuffix(prefix, n) {
return true
}
}
return false
}
// GenerateManifest 扫描工作区核心文件生成 SHA-256 指纹与防篡改审计清单
func (q *QualityGate) GenerateManifest(wsDir string, docKey string, report QualityReport) (*Manifest, error) {
now := time.Now()
files := make(map[string]string)
trackedPaths := []string{
"workspace.json",
"squad.json",
"workflow.json",
"template.md",
"blackboard.json",
"output.md",
filepath.Join("context", "index.json"),
filepath.Join("context", "user.md"),
filepath.Join("context", "project.md"),
filepath.Join("context", "agent.md"),
}
for _, rel := range trackedPaths {
p := filepath.Join(wsDir, rel)
if hash, err := computeSHA256(p); err == nil {
files[filepath.ToSlash(rel)] = hash
}
}
// 扫描 artifacts/
artDir := filepath.Join(wsDir, "artifacts")
if entries, err := os.ReadDir(artDir); err == nil {
for _, e := range entries {
if !e.IsDir() {
rel := filepath.Join("artifacts", e.Name())
p := filepath.Join(wsDir, rel)
if hash, err := computeSHA256(p); err == nil {
files[filepath.ToSlash(rel)] = hash
}
}
}
}
// 扫描 tasks/*/output.md
tasksDir := filepath.Join(wsDir, "tasks")
if entries, err := os.ReadDir(tasksDir); err == nil {
for _, e := range entries {
if e.IsDir() {
outPath := filepath.Join(tasksDir, e.Name(), "output.md")
if hash, err := computeSHA256(outPath); err == nil {
rel := filepath.Join("tasks", e.Name(), "output.md")
files[filepath.ToSlash(rel)] = hash
}
}
}
}
manifest := &Manifest{
ManifestVersion: "1.0.0",
WorkspaceID: filepath.Base(wsDir),
DocKey: docKey,
GeneratedAt: now,
Deliverable: filepath.Join(wsDir, "output.md"),
Files: files,
QualityReport: report,
}
manifestPath := filepath.Join(wsDir, "manifest.json")
if err := writeJSON(manifestPath, manifest); err != nil {
return nil, fmt.Errorf("write manifest.json: %w", err)
}
return manifest, nil
}
// EnsureQualityOrRepair 质量门禁自动闭环:若质检未通过,动态向工作流追加修复步骤直到达标或达到重试上限。
// 提升根 output.md 的唯一条件:runErr==nil、无受保护父级越界、本地目标可信。
func (q *QualityGate) EnsureQualityOrRepair(
ctx context.Context,
wsDir string,
taskKey string,
driver *WorkflowDriver,
backend agent.Backend,
maxRetries int,
opts ExecuteOptions,
) (*QualityReport, error) {
outPath := filepath.Join(wsDir, "output.md")
contentBytes, err := os.ReadFile(outPath)
if err != nil {
return nil, fmt.Errorf("read output.md: %w", err)
}
report, err := q.Validate(wsDir, taskKey, string(contentBytes))
if err != nil {
return nil, err
}
if report.Passed || maxRetries <= 0 {
_, _ = q.GenerateManifest(wsDir, taskKey, *report)
return report, nil
}
// 治理:加载预算账本(修复轮预算预扣 + 跨轮停滞检测)
ledger := LoadOrCreateLedger(wsDir, DefaultBudgetPolicy())
prevRepairContent := string(contentBytes)
stallHinted := false
stallBreak := false
for attempt := 1; attempt <= maxRetries; attempt++ {
// 治理:修复轮预算预扣(超限硬停,杜绝机械空转烧成本)
if lErr := ledger.ReserveRepair(); lErr != nil {
_ = ledger.Save(wsDir)
if st := ProgressFromContext(ctx); st != nil {
st.EmitPhase("governance", "修复预算硬停: "+lErr.Error(), "taskgov", 0, 0, 0)
}
break
}
repairStepID := fmt.Sprintf("step_repair_%d", attempt)
subDir := fmt.Sprintf("tasks/repair_%d", attempt)
var acceptCriteria []string
if len(report.MissingSections) > 0 {
acceptCriteria = append(acceptCriteria, "补齐缺失章节: "+strings.Join(report.MissingSections, ", "))
}
if len(report.ProhibitedPhrases) > 0 {
acceptCriteria = append(acceptCriteria, "清除正文中实际使用的省略/敷衍写法;自检表勿复述禁用原词")
}
for _, v := range report.Violations {
acceptCriteria = append(acceptCriteria, "解决违规点: "+v)
}
repairStep := StepSpec{
ID: repairStepID,
Role: "writer",
Title: fmt.Sprintf("根据质量门禁反馈修复交付初稿 (第%d次修正)", attempt),
SubWorkspace: subDir,
Mode: "execute",
Needs: []string{"output.md"},
InputLayout: InputLayoutSnapshot,
Accept: acceptCriteria,
}
parentSnap := SnapshotWorkspaceInputs(wsDir)
if err := driver.AddStep(wsDir, repairStep); err != nil {
return report, fmt.Errorf("add repair step: %w", err)
}
repairAbs := filepath.Join(wsDir, filepath.FromSlash(subDir))
if err := WriteRepairTaskMarkdown(repairAbs, attempt, report, stallHinted); err != nil {
return report, fmt.Errorf("write repair TASK.md: %w", err)
}
// 执行修复步骤(治理入账:轨迹 + 预算)
runErr := driver.runOneStep(ctx, wsDir, repairStep, backend, opts.Timeout, opts.StepNotify, nil, &[]string{}, ledger)
repairedBytes, localOK := TrustedLocalDeliverable(repairAbs, RepairTargetName)
mutated := ParentInputsMutated(wsDir, parentSnap)
if len(mutated) > 0 {
recordContainmentViolation(wsDir, attempt, mutated, localOK, parentSnap)
if rerr := restoreWorkspaceInputsFn(wsDir, parentSnap); rerr != nil {
appendGovAudit(wsDir, GovAuditRestoreFailure, map[string]string{
"attempt": fmt.Sprintf("%d", attempt),
"error": rerr.Error(),
"mutated": strings.Join(mutated, ","),
})
if st := ProgressFromContext(ctx); st != nil {
st.EmitPhase("governance", "修复 containment 恢复失败: "+rerr.Error(), "taskgov", 0, 0, 0)
}
return report, fmt.Errorf("restore parent inputs after containment: %w", rerr)
}
if st := ProgressFromContext(ctx); st != nil {
st.EmitPhase("governance", "修复 containment 违规: 父级输入已被越界改写,已恢复运行前字节,本轮作废", "taskgov", 0, 0, 0)
}
}
promote, reason := DecideRepairPromote(runErr, mutated, localOK)
if !promote {
detail := map[string]string{"attempt": fmt.Sprintf("%d", attempt), "reason": reason}
if runErr != nil {
detail["error"] = runErr.Error()
}
appendGovAudit(wsDir, "repair_round_discarded", detail)
continue
}
// 仅 runErr==nil、无 containment、本地目标可信时才覆盖根 output.md 并 Validate。
sim := OutputSimilarity(prevRepairContent, string(repairedBytes))
if sim >= 0.98 {
if stallHinted {
if st := ProgressFromContext(ctx); st != nil {
st.EmitPhase("governance", fmt.Sprintf("修复停滞止损: 连续两轮修复产物相似度 %.1f%%,终止机械重试", sim*100), "taskgov", 0, 0, 0)
}
appendGovAudit(wsDir, "repair_stall_break", map[string]string{"attempt": fmt.Sprintf("%d", attempt), "similarity": fmt.Sprintf("%.3f", sim)})
stallBreak = true
} else {
stallHinted = true
appendGovAudit(wsDir, "repair_stall_hinted", map[string]string{"attempt": fmt.Sprintf("%d", attempt), "similarity": fmt.Sprintf("%.3f", sim)})
}
}
prevRepairContent = string(repairedBytes)
if err := writeRegularFile(outPath, repairedBytes); err != nil {
return report, fmt.Errorf("promote repair output: %w", err)
}
report, _ = q.Validate(wsDir, taskKey, string(repairedBytes))
if report.Passed || stallBreak {
break
}
}
_ = ledger.Save(wsDir)
_, _ = q.GenerateManifest(wsDir, taskKey, *report)
return report, nil
}
type mdHeading struct {
Level int
Title string
Norm string
}
// parseMarkdownHeadings 提取闭区间 [minLevel, maxLevel] 的 ATX 标题。
// 标题文本先去序号,再做门禁比对用的归一化。
func parseMarkdownHeadings(md string, minLevel, maxLevel int) []mdHeading {
var headings []mdHeading
for _, line := range strings.Split(md, "\n") {
trimmed := strings.TrimSpace(line)
level := 0
for level < len(trimmed) && trimmed[level] == '#' {
level++
}
if level < minLevel || level > maxLevel || level >= len(trimmed) || trimmed[level] != ' ' {
continue
}
clean := cleanHeadingTitle(strings.TrimSpace(trimmed[level+1:]))
if clean == "" {
continue
}
headings = append(headings, mdHeading{
Level: level,
Title: clean,
Norm: normalizeHeadingTitle(clean),
})
}
return headings
}
// normalizeHeadingTitle 生成章节比对键:去掉全部空白、全角 ASCII 折半角、英文字母小写。
// 缺空格(「与 State」对「与State」)与大小写漂移不再被当成整章缺失。
func normalizeHeadingTitle(title string) string {
t := cleanHeadingTitle(title)
var b strings.Builder
b.Grow(len(t))
for _, r := range t {
if unicode.IsSpace(r) {
continue
}
if r >= 0xFF01 && r <= 0xFF5E {
r -= 0xFEE0
}
b.WriteRune(r)
}
return strings.ToLower(b.String())
}
func isChineseNumeralRune(r rune) bool {
switch r {
case '零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '百', '千', '两':
return true
default:
return false
}
}
// stripChineseOrdinalPrefix 去掉「一、」「十一、」「二十三、」等中文序号前缀。
// 旧实现只枚举到「十、」,长文档 LLM 常用「十一、签署」会导致与模板「签署」对不上。
func stripChineseOrdinalPrefix(t string) string {
runes := []rune(t)
if len(runes) == 0 || !isChineseNumeralRune(runes[0]) {
return t
}
i := 0
for i < len(runes) && isChineseNumeralRune(runes[i]) {
i++
}
if i >= len(runes) {
return t
}
switch runes[i] {
case '、', '.', '.':
i++
default:
return t
}
for i < len(runes) && (runes[i] == ' ' || runes[i] == '\t') {
i++
}
return string(runes[i:])
}
func stripArabicOrdinalPrefix(t string) string {
runes := []rune(t)
// 去掉数字序号如 "1. ", "1、", "1.1 "
for len(runes) > 0 && ((runes[0] >= '0' && runes[0] <= '9') || runes[0] == '.' || runes[0] == '、' || runes[0] == ' ') {
t = strings.TrimSpace(string(runes[1:]))
runes = []rune(t)
}
return t
}
func cleanHeadingTitle(title string) string {
// 去除前缀序号如 "一、", "十一、", "1.", "1.1" 等
t := strings.TrimSpace(title)
t = stripChineseOrdinalPrefix(t)
t = stripArabicOrdinalPrefix(t)
// 去除可能括号后缀
if idx := strings.Index(t, "("); idx > 0 {
t = strings.TrimSpace(t[:idx])
}
if idx := strings.Index(t, "("); idx > 0 {
t = strings.TrimSpace(t[:idx])
}
return strings.TrimSpace(t)
}
func computeSHA256(filePath string) (string, error) {
f, err := os.Open(filePath)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}