diff --git a/direct.go b/direct.go index 2354300..1b9ca7d 100644 --- a/direct.go +++ b/direct.go @@ -92,6 +92,13 @@ type DirectConfig struct { // 為真時忽略失敗退避與次數上限,一律重送——**人明確要求時不該被機器的退避擋住**。 // 不寫進 config.json(`json:"-"`):它是單次執行旗標,不是使用者設定。 ForceSync bool `json:"-"` + + // B2 萃取品質檢查(daemon 端 lint,草案 §3 第一層;分支 work/b2-quality-lint-0726 移入, + // 2026-08-09 重新接到現行 direct.go——原分支落後 main 130 筆,不能硬併,見 + // wiki status.md:1570-1573):extractor 模式萃完、POST 前跑。 + // LintStrict=軟項也擋(測試/CI);預設 false=硬缺拒收、軟項照送標 quality:low。 + // CLI `--strict` 會覆蓋成 true。 + LintStrict bool `json:"lint_strict,omitempty"` } // librarySlug 把資料夾名轉成合法庫名(A-Za-z0-9_-;中文等非 ASCII 轉為底線分段)。 @@ -1086,15 +1093,30 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta ok = false break } + // B2 品質檢查(草案 §3 第一層):萃完、POST 前跑。原稿在手=H6 可做。 + // 硬缺(H1/H2/H5)=拒收、不 POST、標 failed(manifest 不回寫=下輪重萃自癒); + // 軟項(H3/H4/H6)=照送但帶 quality:low+quality_warnings(裁決 T4-C)。 + lr := LintCard(string(cardData), LintOptions{Source: string(content)}) + if lr.Blocks(cfg.LintStrict) { + res.Status = "rejected" + res.Error = "品質未過(不送):" + strings.Join(append(lr.HardMessages(), lr.SoftMessages()...), ";") + ok = false + break + } // path 帶「原檔路徑」不是卡片路徑(07-24 真機第五枚坑): // source_uri=kb:// 是 takedown 的比對鍵,也是 B4 溯源該指的原文—— // 帶卡片路徑會讓「刪原檔→下架」永遠 0 命中。 - status, _, perr := cfg.postJSON(cfg.triggerURL(cfg.CardIngestWF), map[string]any{ + cardBody := map[string]any{ "page_name": pageNameOf(cardRel), "path": ev.Path, "card_content": string(cardData), "library": cfg.libraryFor(absRoot), - }) + } + if warns := lr.SoftMessages(); len(warns) > 0 { + cardBody["quality"] = "low" + cardBody["quality_warnings"] = warns + } + status, _, perr := cfg.postJSON(cfg.triggerURL(cfg.CardIngestWF), cardBody) res.HTTPStatus = status if perr != nil { res.Status, res.Error = "failed", perr.Error() @@ -1230,6 +1252,7 @@ func runDirect(args []string) int { configPath := fs.String("config", "", "direct 模式設定檔(JSON)路徑(必填)") once := fs.Bool("once", false, "掃一輪即退出(測試/cron;預設常駐輪詢)") dryRun := fs.Bool("dry-run", false, "只列出會送出的動作,不 POST、不寫 manifest") + strict := fs.Bool("strict", false, "B2 品質檢查嚴格模式:軟項(H3/H4/H6)也擋,不只硬缺") if err := fs.Parse(args); err != nil { return 2 } @@ -1242,6 +1265,9 @@ func runDirect(args []string) int { fmt.Fprintln(os.Stderr, "collector direct:", err) return 2 } + if *strict { + cfg.LintStrict = true // CLI 旗標覆蓋 config + } // t176:t92 的 claude_bin 回寫已隨 claude 萃取路一併退役——RunDirectOnce 不再解析 // claude 執行檔,cfg.ClaudeBin 不會被改寫,故沒有東西需要回寫。 diff --git a/direct_extract_test.go b/direct_extract_test.go index 37fb0d4..5cdf77b 100644 --- a/direct_extract_test.go +++ b/direct_extract_test.go @@ -12,12 +12,25 @@ import ( "testing" ) -// cardFixture 組出一張最簡知識卡(含一條三元組關聯)。 +// cardFixture 組出一張 B2 合格四段卡(H1 段名齊全且順序正確+H2 一句話定義恰一行+ +// H3 3–12 條要點+H4 3–8 條端點閉合三元組——2026-08-09 併入品質 lint 後,minimal +// 兩段卡會被 H1 硬擋,這裡改成全段合格卡,讓所有沿用 cardFixture 的既有測試自動過關)。 // 三元組的分隔符在原始碼裡用組字串的方式產生,避免被 arcrun 意圖 guard 誤判成工作流的邊。 func cardFixture(subject, object string) string { sep := strings.Repeat(">", 2) - return "# " + subject + "\n## 一句話定義\n測試卡\n## 關聯\n- " + - subject + " " + sep + " 屬於 " + sep + " " + object + "\n" + return "# " + subject + "\n" + + "## 一句話定義\n" + subject + "是一張測試用的自包含知識卡。\n" + + "## 要點\n" + + "- 第一個要點含具體條件\n" + + "- 第二個要點含具體條件\n" + + "- 第三個要點含具體條件\n" + + "## 關鍵實體\n" + + "- **" + subject + "** — 測試主體\n" + + "- **" + object + "** — 測試客體\n" + + "## 關聯\n" + + "- " + subject + " " + sep + " 屬於 " + sep + " " + object + "\n" + + "- " + subject + " " + sep + " 關聯到 " + sep + " " + object + "\n" + + "- " + object + " " + sep + " 對應 " + sep + " " + subject + "\n" } // gemmaCardStub 讓 Gemini 替身回傳一張以 pageName 命名的卡片(t176 起產品只走 gemma 路, @@ -56,7 +69,8 @@ func TestDirectExtractorModeE2E(t *testing.T) { })) defer srv.Close() - // Gemini 替身:把原稿萃成卡 + // Gemini 替身:把原稿萃成卡(B2 合格四段卡,否則新增的品質 lint 會擋下—— + // 本測試聚焦 ingest 路,非 lint,lint 自身測試見 lint_test.go) defer gemmaCardStub(t, cardFixture("報銷規則", "財務"))() cfg := &DirectConfig{ @@ -119,8 +133,8 @@ func TestDirectExtractorRemovedClearsLocalCard(t *testing.T) { })) defer srv.Close() - // Gemini 替身:萃卡落地 - defer gemmaCardStub(t, "# 報銷規則\n## 一句話定義\n測試卡\n")() + // Gemini 替身:萃卡落地(B2 合格四段卡,過品質 lint) + defer gemmaCardStub(t, cardFixture("報銷規則", "財務"))() cfg := &DirectConfig{ WatchFolders: []string{root}, @@ -174,14 +188,14 @@ func TestDirectExtractorRemovedNoLocalCardOK(t *testing.T) { _ = json.NewEncoder(w).Encode(map[string]any{"success": true}) })) defer srv.Close() - defer gemmaCardStub(t, "# a\n## 一句話定義\n卡\n")() + defer gemmaCardStub(t, cardFixture("a", "b"))() cfg := &DirectConfig{ WatchFolders: []string{root}, Manifest: filepath.Join(t.TempDir(), "m.json"), CypherURL: srv.URL, Namespace: "demo", APIKey: "demo", Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test", CardIngestWF: "rag_ingest_card", RemovedWF: "rag_takedown_direct", - MaxRemoved: 1.0, + MaxRemoved: 1.0, } if _, exit, _ := RunDirectOnce(cfg, false); exit != 0 { t.Fatal("第一輪失敗") diff --git a/direct_pacing_test.go b/direct_pacing_test.go index 94b90a3..224e0f7 100644 --- a/direct_pacing_test.go +++ b/direct_pacing_test.go @@ -119,7 +119,7 @@ func TestDirect_LargeBacklog_ProcessedInNewestFirstBatches(t *testing.T) { // 這條測試只在意「觸發了幾次、依什麼順序送到假 cypher」,卡片內容固定即可。 _ = json.NewEncoder(w).Encode(map[string]any{ "candidates": []map[string]any{{ - "content": map[string]any{"parts": []map[string]any{{"text": "# 卡\n## 一句話定義\n測試\n"}}}, + "content": map[string]any{"parts": []map[string]any{{"text": cardFixture("卡", "測試")}}}, }}, }) })() @@ -236,7 +236,7 @@ func TestDirect_ResumeAfterInterruption(t *testing.T) { defer gemmaStub(t, func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{ "candidates": []map[string]any{{ - "content": map[string]any{"parts": []map[string]any{{"text": "# 卡\n## 一句話定義\n測試\n"}}}, + "content": map[string]any{"parts": []map[string]any{{"text": cardFixture("卡", "測試")}}}, }}, }) })() @@ -322,7 +322,7 @@ func TestDirect_RemovedRetriesOnFailureEvenWithIncrementalSave(t *testing.T) { defer gemmaStub(t, func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{ "candidates": []map[string]any{{ - "content": map[string]any{"parts": []map[string]any{{"text": "# 卡\n## 一句話定義\n測試\n"}}}, + "content": map[string]any{"parts": []map[string]any{{"text": cardFixture("卡", "測試")}}}, }}, }) })() diff --git a/lint.go b/lint.go new file mode 100644 index 0000000..7a4ba87 --- /dev/null +++ b/lint.go @@ -0,0 +1,398 @@ +// lint.go — daemon 端知識卡品質檢查(B2 萃取品質驗收標準)。 +// +// 真相源=InkStoneCo 頂層卷 `system-dev/docs/3-specs/daemon-beta/b2-quality-standard-draft.md` +// (§1 硬標準 H1–H6、§6 總管自裁 2026-07-26 commit 39bbdd2:T1-T5 定案+施工順序)。 +// +// 落點(草案 §3 第一層「品質迴路」):插在 extract 之後、POST rag_ingest_card 之前。 +// 手上有原稿(H6 相似度只有這裡能做);不過的分級依 §6 裁決 T4-C: +// - 硬缺(H1 段名/H2 一句話定義/H5 機敏值)=結構壞或洩密 → 拒收(不 POST)。 +// - 軟項(H3 要點條數/H4 關聯漂移/H6 相似度)=現行管線會產生的正常瑕疵 → 照送、標 quality:low。 +// - --strict:軟項也擋(測試/CI 用,讓「形檢天花板」外的問題全部現形)。 +// +// 誠實限制(抄 wiki-secret-scan.sh 的自陳):H5 regex 偵測有偽陰/偽陽,擋的是「明顯特徵的 +// 機敏字串被自動抄進卡」,擋不了刻意混淆;H3/H4/H6 是形檢,擋形不擋神(草案 §H3 ZZ-T7 天花板)。 +package collector + +import ( + "regexp" + "strings" +) + +// daemon 形卡片四段名(rag-extract-file.md 規約,一字不差)。 +const ( + secDef = "一句話定義" + secPts = "要點" + secEnt = "關鍵實體" + secRel = "關聯" +) + +// H2 一句話定義長度上限(全形字=rune);草案 §H2「建議加長度上限 ≤ 80」。 +const defMaxRunes = 80 + +// H6 相似度門檻(草案 §H6):8-gram 重疊率上限、卡片字數對原稿比例上限、絕對字數上限。 +const ( + h6GramSize = 8 + h6OverlapRatio = 0.40 + h6MaxCardRunes = 4000 + h6SrcRatio = 0.60 +) + +// LintFinding 一條檢查結果(附 H 代號,方便回報與分級)。 +type LintFinding struct { + Code string `json:"code"` // H1..H6 + Message string `json:"message"` // 白話說明 +} + +// LintResult 一張卡的品質檢查結果。 +// - Hard:硬缺(拒收)——H1/H2/H5。 +// - Soft:軟項(收但標 quality:low)——H3/H4/H6。 +type LintResult struct { + Hard []LintFinding `json:"hard,omitempty"` + Soft []LintFinding `json:"soft,omitempty"` +} + +func (r LintResult) addHard(code, msg string) LintResult { + r.Hard = append(r.Hard, LintFinding{code, msg}) + return r +} + +func (r LintResult) addSoft(code, msg string) LintResult { + r.Soft = append(r.Soft, LintFinding{code, msg}) + return r +} + +// Blocks 回傳這張卡是否該被擋下不 POST。 +// 非 strict:只有硬缺擋。strict:硬缺或軟項任一即擋。 +func (r LintResult) Blocks(strict bool) bool { + if len(r.Hard) > 0 { + return true + } + return strict && len(r.Soft) > 0 +} + +// Messages 把 findings 攤成訊息清單(回報用)。 +func messagesOf(fs []LintFinding) []string { + out := make([]string, 0, len(fs)) + for _, f := range fs { + out = append(out, f.Code+": "+f.Message) + } + return out +} + +// HardMessages / SoftMessages 供 direct.go 組回報訊息與 quality_warnings。 +func (r LintResult) HardMessages() []string { return messagesOf(r.Hard) } +func (r LintResult) SoftMessages() []string { return messagesOf(r.Soft) } + +// LintOptions 檢查旁路資訊。 +type LintOptions struct { + Source string // 原稿內容;空=跳過 H6(rag_ingest_card 側天生收不到原稿) +} + +// cardSection 一個 `## 段名` 區塊:名稱、出現序、段內原始行(不含標題行本身)。 +type cardSection struct { + name string + order int + lines []string +} + +var ( + h2HeadingRe = regexp.MustCompile(`^##\s+(.+?)\s*$`) + anyHeadingRe = regexp.MustCompile(`^#{1,6}\s`) + bulletRe = regexp.MustCompile(`^-\s+(.+?)\s*$`) + boldNameRe = regexp.MustCompile(`\*\*(.+?)\*\*`) + // 關聯三元組:`- 主詞 >> 謂詞 >> 受詞`(>> 前後空白寬鬆)。 + relTripleRe = regexp.MustCompile(`^-\s+(.+?)\s*>>\s*(.+?)\s*>>\s*(.+?)\s*$`) +) + +// parseSections 抽出所有 `## ` 段落(保序),供 H1/H2/H3/H4 用。 +func parseSections(card string) []cardSection { + var secs []cardSection + var cur *cardSection + order := 0 + for _, line := range strings.Split(card, "\n") { + if m := h2HeadingRe.FindStringSubmatch(line); m != nil { + secs = append(secs, cardSection{name: strings.TrimSpace(m[1]), order: order}) + cur = &secs[len(secs)-1] + order++ + continue + } + if cur != nil { + cur.lines = append(cur.lines, line) + } + } + return secs +} + +// sectionByName 找指定段名(一字不差);回傳指標與是否存在。 +func sectionByName(secs []cardSection, name string) (*cardSection, bool) { + for i := range secs { + if secs[i].name == name { + return &secs[i], true + } + } + return nil, false +} + +// nonEmptyLines 過濾空白行(trim 後為空)。 +func nonEmptyLines(lines []string) []string { + var out []string + for _, l := range lines { + if strings.TrimSpace(l) != "" { + out = append(out, l) + } + } + return out +} + +// runeLen 全形字數(rune 計數)。 +func runeLen(s string) int { return len([]rune(s)) } + +// LintCard 對一張 daemon 形知識卡跑 H1–H6,回傳分級結果(B2 §1+§6 T4-C)。 +func LintCard(card string, opts LintOptions) LintResult { + var r LintResult + secs := parseSections(card) + + // ── H1 段名齊全且順序正確(硬)────────────────────────────── + required := []string{secDef, secPts, secEnt, secRel} + idx := map[string]int{} + var missing []string + for _, name := range required { + if s, ok := sectionByName(secs, name); ok { + idx[name] = s.order + } else { + missing = append(missing, name) + } + } + if len(missing) > 0 { + r = r.addHard("H1", "缺段名:"+strings.Join(missing, "、")+"(四段須齊:一句話定義→要點→關鍵實體→關聯)") + } else if !(idx[secDef] < idx[secPts] && idx[secPts] < idx[secEnt] && idx[secEnt] < idx[secRel]) { + r = r.addHard("H1", "段名順序錯(須依序:一句話定義→要點→關鍵實體→關聯)") + } + + // ── H2 一句話定義恰一行;> 80 全形字=軟提醒(硬/軟)───────────── + if def, ok := sectionByName(secs, secDef); ok { + body := nonEmptyLines(def.lines) + switch { + case len(body) == 0: + r = r.addHard("H2", "一句話定義段是空的") + case len(body) > 1: + r = r.addHard("H2", "一句話定義有 "+itoa(len(body))+" 行(須恰一行;多行=那是摘要不是定義)") + default: + if n := runeLen(strings.TrimSpace(body[0])); n > defMaxRunes { + r = r.addSoft("H2", "一句話定義 "+itoa(n)+" 全形字(建議 ≤80,過長像小作文)") + } + } + } + + // ── H3 要點 3–12 條、不夾散文(軟)─────────────────────────── + if pts, ok := sectionByName(secs, secPts); ok { + body := nonEmptyLines(pts.lines) + bullets := 0 + prose := false + for _, l := range body { + if bulletRe.MatchString(l) { + bullets++ + } else { + prose = true + } + } + if prose { + r = r.addSoft("H3", "要點段夾了非列點的散文行(要點應為 - 開頭的 bullet)") + } + if bullets < 3 || bullets > 12 { + r = r.addSoft("H3", "要點 "+itoa(bullets)+" 條(應 3–12 條;太少該併回定義、太多是沒消化的地毯複製)") + } + } + + // ── H4 關聯行形+端點閉合(軟;草案裁決 T4-C 全歸軟)────────── + if rel, ok := sectionByName(secs, secRel); ok { + entities := entityNames(secs) + body := nonEmptyLines(rel.lines) + valid := 0 + for _, l := range body { + m := relTripleRe.FindStringSubmatch(l) + if m == nil { + r = r.addSoft("H4", "關聯行不合三元組形(須「- 主詞 >> 謂詞 >> 受詞」):"+trimForMsg(l)) + continue + } + valid++ + subj, obj := strings.TrimSpace(m[1]), strings.TrimSpace(m[3]) + for _, end := range []string{subj, obj} { + // H1 缺關鍵實體段時 entities 為空,端點檢查會全報漂移=雜訊,故僅在有實體清單時查。 + if len(entities) > 0 && !entities[end] { + r = r.addSoft("H4", "關聯端點「"+end+"」不在關鍵實體清單(會長出無 gloss 的孤兒節點)") + } + } + } + if valid < 3 || valid > 8 { + r = r.addSoft("H4", "關聯 "+itoa(valid)+" 條合法三元組(應 3–8 條)") + } + } + + // ── H5 不含機敏值(硬)─────────────────────────────────────── + if hits := scanSecrets(card); len(hits) > 0 { + r = r.addHard("H5", "疑似機敏值:"+strings.Join(hits, "、")+"(依規約該段應改寫成描述,不得照抄)") + } + + // ── H6 不整段複製原文(軟;只有拿得到原稿時做)────────────── + if opts.Source != "" { + if msg := checkSimilarity(card, opts.Source); msg != "" { + r = r.addSoft("H6", msg) + } + } + + return r +} + +// entityNames 從「關鍵實體」段抽出粗體正規名集合(H4 端點閉合用)。 +func entityNames(secs []cardSection) map[string]bool { + set := map[string]bool{} + ent, ok := sectionByName(secs, secEnt) + if !ok { + return set + } + for _, l := range ent.lines { + if m := boldNameRe.FindStringSubmatch(l); m != nil { + set[strings.TrimSpace(m[1])] = true + } + } + return set +} + +// cardBody 取卡片非標題行(H6 比對用;標題行不算複製原文)。 +func cardBody(card string) string { + var b strings.Builder + for _, l := range strings.Split(card, "\n") { + if anyHeadingRe.MatchString(l) { + continue + } + b.WriteString(l) + b.WriteByte('\n') + } + return b.String() +} + +// checkSimilarity 粗檢卡片是否整段複製原文(草案 §H6 (b) 8-gram 重疊+絕對字數上限)。 +// 回傳非空=命中(訊息);空=過。 +func checkSimilarity(card, source string) string { + body := cardBody(card) + cardRunes := runeLen(strings.TrimSpace(body)) + srcRunes := runeLen(source) + + // 絕對上限:卡片字數 ≤ min(4000, 原稿字數 × 60%)——防「萃取失敗就整檔照抄」。 + limit := h6MaxCardRunes + if byRatio := int(float64(srcRunes) * h6SrcRatio); byRatio < limit { + limit = byRatio + } + if cardRunes > limit { + return "卡片 " + itoa(cardRunes) + " 全形字,超過上限 " + itoa(limit) + "(min(4000, 原稿×60%))=疑似整段照抄" + } + + // 8-gram 重疊率。 + cardGrams := runeGrams(body, h6GramSize) + if len(cardGrams) == 0 { + return "" + } + srcSet := gramSet(source, h6GramSize) + matched := 0 + for g := range cardGrams { + if srcSet[g] { + matched++ + } + } + ratio := float64(matched) / float64(len(cardGrams)) + if ratio >= h6OverlapRatio { + return "卡片與原稿 8-gram 重疊率 " + pct(ratio) + "(上限 " + pct(h6OverlapRatio) + ")=地毯複製,違 D16 精耕非 RAG" + } + return "" +} + +// runeGrams 回傳去重後的 rune n-gram 集合(跳過純空白 gram)。 +func runeGrams(s string, n int) map[string]bool { + set := map[string]bool{} + rs := []rune(s) + for i := 0; i+n <= len(rs); i++ { + g := string(rs[i : i+n]) + if strings.TrimSpace(g) == "" { + continue + } + set[g] = true + } + return set +} + +// gramSet 同 runeGrams(別名,語意上是「原稿的 gram 索引」)。 +func gramSet(s string, n int) map[string]bool { return runeGrams(s, n) } + +// ── H5 機敏值 pattern(移植 .claude/hooks/wiki-secret-scan.sh,不重造輪子)── +// 每條一個標籤+regexp;行內含 wiki-secret-ok 標記者略過。RE2 無 backref,原 pattern 皆不需要。 +type secretPattern struct { + label string + re *regexp.Regexp +} + +var secretPatterns = []secretPattern{ + {"密碼/密鑰賦值", regexp.MustCompile(`(?i)(pass(word)?|secret|api[_-]?key|access[_-]?key|auth[_-]?token|priv(ate)?[_-]?key)[[:space:]]*[:=][[:space:]]*[^[:space:]<>"']{6,}`)}, + {"私鑰 PEM 區塊", regexp.MustCompile(`-----BEGIN[[:space:]].*PRIVATE KEY-----`)}, + {"服務金鑰特徵", regexp.MustCompile(`(AKIA[0-9A-Z]{16}|gh[pousr]_[0-9A-Za-z]{20,}|xox[baprs]-[0-9A-Za-z-]{10,}|AIza[0-9A-Za-z_-]{20,}|sk_(live|test)_[0-9A-Za-z]{16,})`)}, + {"JWT token", regexp.MustCompile(`eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}`)}, + {"連線字串內嵌帳密", regexp.MustCompile(`(?i)[a-z][a-z0-9+.-]*://[^[:space:]:/@]+:[^[:space:]:/@]+@`)}, + {"台灣身分證字號", regexp.MustCompile(`(^|[^A-Za-z0-9])[A-Z][12][0-9]{8}([^0-9]|$)`)}, + {"疑似信用卡號", regexp.MustCompile(`(^|[^0-9])[0-9]{4}[ -]?[0-9]{4}[ -]?[0-9]{4}[ -]?[0-9]{0,4}([^0-9]|$)`)}, +} + +// scanSecrets 逐行掃機敏特徵(與 shell hook 的 line-oriented grep 同語意),回傳命中標籤(去重保序)。 +func scanSecrets(card string) []string { + var hits []string + seen := map[string]bool{} + for _, line := range strings.Split(card, "\n") { + if strings.Contains(line, "wiki-secret-ok") { + continue // 行內豁免(示範格式) + } + for _, p := range secretPatterns { + if seen[p.label] { + continue + } + if p.re.MatchString(line) { + hits = append(hits, p.label) + seen[p.label] = true + } + } + } + return hits +} + +// ── 小工具(避免拉進 strconv/fmt 只為兩個轉換)── +func itoa(n int) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + var buf [20]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} + +// pct 把 0–1 比例轉成百分比整數字串(如 0.4→"40%")。 +func pct(f float64) string { return itoa(int(f*100+0.5)) + "%" } + +// trimForMsg 截斷過長的行文於訊息中(避免整段原文塞進回報)。 +func trimForMsg(s string) string { + s = strings.TrimSpace(s) + if runeLen(s) > 40 { + return string([]rune(s)[:40]) + "…" + } + return s +} diff --git a/lint_test.go b/lint_test.go new file mode 100644 index 0000000..63532fd --- /dev/null +++ b/lint_test.go @@ -0,0 +1,309 @@ +// lint_test.go — B2 品質檢查驗收(施工順序步驟 4:拿草案 §1 列的真樣本跑一遍, +// 通過/不過與草案標註一致)。分級依 §6 裁決 T4-C:H1/H2/H5 硬、H3/H4/H6 軟。 +package collector + +import ( + "strings" + "testing" +) + +// 草案 §1 H1/H2/H3/H4 通過真例:琥珀計時沙漏卡(四段齊、定義一行、要點 4 條、關聯端點閉合)。 +const amberCard = `# 琥珀計時沙漏說明 +## 一句話定義 +琥珀計時沙漏是托盤真機驗收用的計時道具,翻轉一次計時 7 分 24 秒,內含蜂蜜結晶作沙粒。 +## 要點 +- 翻轉一次計時 7 分 24 秒 +- 內含蜂蜜結晶作為沙粒 +- 專用於托盤真機驗收流程 +- 外殼為琥珀色樹脂 +## 關鍵實體 +- **琥珀計時沙漏** — 驗收用計時道具 +- **托盤真機驗收** — 使用此道具的場景 +## 關聯 +- 琥珀計時沙漏 >> 用於 >> 托盤真機驗收 +- 托盤真機驗收 >> 使用 >> 琥珀計時沙漏 +- 琥珀計時沙漏 >> 屬於 >> 托盤真機驗收 +` + +func hasCode(fs []LintFinding, code string) bool { + for _, f := range fs { + if f.Code == code { + return true + } + } + return false +} + +// H1/H2/H3/H4/H5 全過的好卡(無原稿=跳過 H6)=零 finding。 +func TestLintAmberCardClean(t *testing.T) { + r := LintCard(amberCard, LintOptions{}) + if len(r.Hard) != 0 || len(r.Soft) != 0 { + t.Fatalf("琥珀卡應零 finding,得 hard=%v soft=%v", r.HardMessages(), r.SoftMessages()) + } + if r.Blocks(false) || r.Blocks(true) { + t.Fatal("好卡不該被擋(strict 亦然)") + } +} + +// 草案 §H3 機檢天花板(真):ZZ-T7 結構全過但內容無意義——形檢應「放行」(擋形不擋神)。 +func TestLintMachineCeilingPassesShape(t *testing.T) { + card := `# ZZ-T7驗收請忽略 +## 一句話定義 +這是一張純為驗收而生的無意義測試卡。 +## 要點 +- 無意義要點甲 +- 無意義要點乙 +- 無意義要點丙 +## 關鍵實體 +- **甲** — 無意義實體一 +- **乙** — 無意義實體二 +## 關聯 +- 甲 >> 關聯 >> 乙 +- 乙 >> 對應 >> 甲 +- 甲 >> 涵蓋 >> 乙 +` + r := LintCard(card, LintOptions{}) + if r.Blocks(false) { + t.Fatalf("結構完整的卡即使無意義,形檢也應放行;得 hard=%v", r.HardMessages()) + } +} + +// 草案 §H1 不過(構造):缺後兩段 → 硬缺 H1。 +func TestLintH1MissingSectionsHard(t *testing.T) { + card := `# x +## 一句話定義 +只有兩段。 +## 要點 +- 一 +- 二 +- 三 +` + r := LintCard(card, LintOptions{}) + if !hasCode(r.Hard, "H1") { + t.Fatalf("缺段名應硬缺 H1,得 hard=%v", r.HardMessages()) + } + if !r.Blocks(false) { + t.Fatal("H1 硬缺應擋(非 strict 亦擋)") + } +} + +// H1 段名順序錯(關聯排到要點前)→ 硬缺 H1。 +func TestLintH1WrongOrderHard(t *testing.T) { + card := `# x +## 一句話定義 +定義一行。 +## 關聯 +- 甲 >> 關聯 >> 乙 +- 乙 >> 對應 >> 甲 +- 甲 >> 涵蓋 >> 乙 +## 要點 +- 一 +- 二 +- 三 +## 關鍵實體 +- **甲** — 說明 +- **乙** — 說明 +` + r := LintCard(card, LintOptions{}) + if !hasCode(r.Hard, "H1") { + t.Fatalf("段名順序錯應硬缺 H1,得 hard=%v", r.HardMessages()) + } +} + +// 草案 §H2 不過(構造):定義三行含背景故事 → 硬缺 H2(那是摘要不是定義)。 +func TestLintH2MultiLineHard(t *testing.T) { + card := `# x +## 一句話定義 +第一行背景故事。 +第二行還在鋪陳。 +第三行才講到重點。 +## 要點 +- 一 +- 二 +- 三 +## 關鍵實體 +- **甲** — 說明 +- **乙** — 說明 +## 關聯 +- 甲 >> 關聯 >> 乙 +- 乙 >> 對應 >> 甲 +- 甲 >> 涵蓋 >> 乙 +` + r := LintCard(card, LintOptions{}) + if !hasCode(r.Hard, "H2") { + t.Fatalf("定義多行應硬缺 H2,得 hard=%v", r.HardMessages()) + } +} + +// 草案 §H3 不過(構造):要點只有 2 條 → 軟項 H3(不擋、標 quality:low)。 +func TestLintH3TooFewSoft(t *testing.T) { + card := `# x +## 一句話定義 +定義一行。 +## 要點 +- 只有兩條 +- 第二條 +## 關鍵實體 +- **甲** — 說明 +- **乙** — 說明 +## 關聯 +- 甲 >> 關聯 >> 乙 +- 乙 >> 對應 >> 甲 +- 甲 >> 涵蓋 >> 乙 +` + r := LintCard(card, LintOptions{}) + if len(r.Hard) != 0 { + t.Fatalf("要點條數越界應為軟項不硬缺,得 hard=%v", r.HardMessages()) + } + if !hasCode(r.Soft, "H3") { + t.Fatalf("要點 2 條應軟項 H3,得 soft=%v", r.SoftMessages()) + } + if r.Blocks(false) { + t.Fatal("軟項非 strict 不該擋") + } + if !r.Blocks(true) { + t.Fatal("軟項在 strict 應擋") + } +} + +// 草案 §H4 不過(真,同一張琥珀卡):`琥珀計時沙漏 >> 每次計時 >> 7 分 24 秒`—— +// 受詞不在關鍵實體 → 端點漂移=軟項 H4(會長孤兒節點)。 +func TestLintH4EndpointDriftSoft(t *testing.T) { + card := `# 琥珀計時沙漏說明 +## 一句話定義 +琥珀計時沙漏是托盤真機驗收用的計時道具。 +## 要點 +- 翻轉一次計時 7 分 24 秒 +- 內含蜂蜜結晶作為沙粒 +- 專用於托盤真機驗收流程 +## 關鍵實體 +- **琥珀計時沙漏** — 驗收用計時道具 +- **托盤真機驗收** — 使用此道具的場景 +## 關聯 +- 琥珀計時沙漏 >> 用於 >> 托盤真機驗收 +- 琥珀計時沙漏 >> 每次計時 >> 7 分 24 秒 +- 托盤真機驗收 >> 使用 >> 琥珀計時沙漏 +` + r := LintCard(card, LintOptions{}) + if len(r.Hard) != 0 { + t.Fatalf("端點漂移應軟項不硬缺,得 hard=%v", r.HardMessages()) + } + if !hasCode(r.Soft, "H4") { + t.Fatalf("端點漂移應軟項 H4,得 soft=%v", r.SoftMessages()) + } + // 訊息須指出漂移的字面值 + if !strings.Contains(strings.Join(r.SoftMessages(), " "), "7 分 24 秒") { + t.Fatalf("H4 訊息應點名漂移端點,得 %v", r.SoftMessages()) + } +} + +// 草案 §H5 不過(構造):要點含 api_key 賦值 → 硬缺 H5(不得照抄機敏值)。 +func TestLintH5SecretHard(t *testing.T) { + card := `# x +## 一句話定義 +定義一行。 +## 要點 +- 設定範例 api_key: sk-ant-api03-abcdef1234567890 +- 第二條要點 +- 第三條要點 +## 關鍵實體 +- **甲** — 說明 +- **乙** — 說明 +## 關聯 +- 甲 >> 關聯 >> 乙 +- 乙 >> 對應 >> 甲 +- 甲 >> 涵蓋 >> 乙 +` + r := LintCard(card, LintOptions{}) + if !hasCode(r.Hard, "H5") { + t.Fatalf("含機敏賦值應硬缺 H5,得 hard=%v", r.HardMessages()) + } + if !r.Blocks(false) { + t.Fatal("H5 硬缺應擋") + } +} + +// H5 行內豁免:wiki-secret-ok 標記行不觸發(示範格式)。 +func TestLintH5ExemptionSkipped(t *testing.T) { + card := `# x +## 一句話定義 +定義一行。 +## 要點 +- 密碼欄位示範 password: hunter2000000 # wiki-secret-ok +- 第二條要點 +- 第三條要點 +## 關鍵實體 +- **甲** — 說明 +- **乙** — 說明 +## 關聯 +- 甲 >> 關聯 >> 乙 +- 乙 >> 對應 >> 甲 +- 甲 >> 涵蓋 >> 乙 +` + r := LintCard(card, LintOptions{}) + if hasCode(r.Hard, "H5") { + t.Fatalf("標記 wiki-secret-ok 的行應豁免,得 hard=%v", r.HardMessages()) + } +} + +// 草案 §H6 不過:卡片字數超過 min(4000, 原稿×60%) → 軟項 H6(整段照抄退化路)。 +func TestLintH6LengthCapSoft(t *testing.T) { + src := "很短的原稿" + r := LintCard(amberCard, LintOptions{Source: src}) + if len(r.Hard) != 0 { + t.Fatalf("H6 相似度應軟項不硬缺,得 hard=%v", r.HardMessages()) + } + if !hasCode(r.Soft, "H6") { + t.Fatalf("卡片遠大於原稿應軟項 H6,得 soft=%v", r.SoftMessages()) + } +} + +// H6 8-gram 重疊:卡片要點逐字照抄原稿長句 → 軟項 H6。 +func TestLintH6OverlapSoft(t *testing.T) { + para := "本公司報銷制度規定所有費用申請必須檢附正本單據並於事實發生後三十日內完成核銷否則視同放棄請款權利" + // 原稿夠長(避免只被長度上限攔到,聚焦 8-gram 重疊路徑) + src := para + para + "另有各部門專屬補充規定與例外情形處理原則詳見附錄說明文件內容" + card := "# x\n## 一句話定義\n報銷制度說明。\n## 要點\n- " + para + "\n- 第二條要點內容\n- 第三條要點內容\n" + + "## 關鍵實體\n- **甲** — 說明\n- **乙** — 說明\n## 關聯\n- 甲 >> 關聯 >> 乙\n- 乙 >> 對應 >> 甲\n- 甲 >> 涵蓋 >> 乙\n" + r := LintCard(card, LintOptions{Source: src}) + if !hasCode(r.Soft, "H6") { + t.Fatalf("逐字照抄長句應軟項 H6,得 soft=%v", r.SoftMessages()) + } +} + +// 草案 §H6 通過(真):財富卡改寫+引用一句,非整段照抄 → 不觸發 H6。 +func TestLintH6RewritePasses(t *testing.T) { + // 真實萃取情境:原稿(一篇 journal 段落)遠長於卡片;卡片是改寫濃縮而非整段照抄。 + src := "今天又想到財富這件事。在我看來,真正的財富從來不是靜止不動的存量數字,而是持續在系統之間流動、" + + "被不斷創造出來的東西。很多人一輩子盯著帳戶餘額看,餘額多一點就開心、少一點就焦慮,卻完全錯過了" + + "那些真正產生價值的流動過程。錢放著不動其實一直在貶值,只有讓它流動、投入到能再創造價值的地方," + + "才會透過複利慢慢滾大。我認識的那些真正有底氣的人,關注的從來不是我現在有多少,而是我的系統每個月" + + "能穩定產生多少流動;存量只是流動的殘影,把因果搞反了就會一輩子為數字焦慮。這也是為什麼我一直提醒" + + "自己,要去建造會生錢的系統,而不是死守一個會被通膨啃食的靜態數字。" + card := "# 財富是流動非存量\n## 一句話定義\n財富的本質是持續流動與創造,而非帳面存量。\n" + + "## 要點\n- 財富來自流動與再投資而非囤積\n- 盯著存量會錯過流動產生的複利\n- 價值在系統間流動時才被創造\n" + + "## 關鍵實體\n- **財富** — 流動中創造的價值\n- **存量** — 靜止的帳面數字\n" + + "## 關聯\n- 財富 >> 對比 >> 存量\n- 財富 >> 來自 >> 存量\n- 存量 >> 誤導 >> 財富\n" + r := LintCard(card, LintOptions{Source: src}) + if hasCode(r.Soft, "H6") { + t.Fatalf("改寫卡不該觸發 H6,得 soft=%v", r.SoftMessages()) + } +} + +// scanSecrets 逐項:各類機敏特徵至少一命中,乾淨文字零命中。 +func TestScanSecretsPatterns(t *testing.T) { + cases := map[string]bool{ + "這是一段乾淨的正常內容沒有任何金鑰": false, + "api_key = abcdef123456": true, + "-----BEGIN RSA PRIVATE KEY-----": true, + "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ012345": true, + "AKIAABCDEFGHIJKLMNOP": true, + "postgres://user:secretpass@db.example.com": true, + } + for text, want := range cases { + got := len(scanSecrets(text)) > 0 + if got != want { + t.Errorf("scanSecrets(%q)=%v,want %v(hits=%v)", text, got, want, scanSecrets(text)) + } + } +} diff --git a/main.go b/main.go index a99173c..b0b3373 100644 --- a/main.go +++ b/main.go @@ -83,6 +83,8 @@ func Run(args []string) int { return run(args[1:], runMode{withUpload: true, withTrigger: true}) case "direct": return runDirect(args[1:]) + case "lint": + return runLint(args[1:]) case "template-install": return runTemplateInstall(args[1:]) default: @@ -244,3 +246,43 @@ func fail(err error) int { fmt.Fprintln(os.Stderr, "collector:", err) return 1 } + +// runLint 是 `collector lint <卡片.md>` 子命令:對一張卡跑 B2 品質檢查(H1–H6)。 +// 印出分級 JSON;被擋(硬缺,或 --strict 下含軟項)=exit 1,方便腳本/CI 銷帳(施工順序步驟 4)。 +func runLint(args []string) int { + fs := newFlagSet() + strict := fs.Bool("strict", false, "軟項(H3/H4/H6)也算擋(測試/CI)") + source := fs.String("source", "", "原稿檔路徑(H6 相似度需要;不給=跳過 H6)") + if err := fs.Parse(args); err != nil { + return 2 + } + if fs.NArg() < 1 { + fmt.Fprintln(os.Stderr, "用法:collector lint <卡片.md> [--source <原稿>] [--strict]") + return 2 + } + card, err := os.ReadFile(fs.Arg(0)) + if err != nil { + return fail(err) + } + var src string + if *source != "" { + b, serr := os.ReadFile(*source) + if serr != nil { + return fail(serr) + } + src = string(b) + } + res := LintCard(string(card), LintOptions{Source: src}) + blocked := res.Blocks(*strict) + out, _ := json.MarshalIndent(struct { + Card string `json:"card"` + Strict bool `json:"strict"` + Blocked bool `json:"blocked"` + Result LintResult `json:"result"` + }{fs.Arg(0), *strict, blocked, res}, "", " ") + fmt.Println(string(out)) + if blocked { + return 1 + } + return 0 +}