From 021b3962e22e154510d9ab2b23c769df0cb3a0ff Mon Sep 17 00:00:00 2001 From: richblack Date: Sun, 16 Aug 2026 21:38:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(collector):=20=E5=8D=A1=E7=89=87=E4=B8=8A?= =?UTF-8?q?=E9=9B=B2=E5=89=8D=E5=85=88=E5=95=8F=E3=80=8C=E9=80=99=E4=BA=9B?= =?UTF-8?q?=E5=AE=A3=E7=A8=B1=E5=9C=A8=E5=8E=9F=E6=96=87=E7=9A=84=E5=93=AA?= =?UTF-8?q?=E8=A3=A1=E3=80=8D=E2=80=94=E2=80=94H7=20=E6=86=91=E7=A9=BA?= =?UTF-8?q?=E6=8C=87=E7=A8=B1=EF=BC=8FH8=20=E5=AE=A3=E7=A8=B1=E9=80=9A?= =?UTF-8?q?=E8=86=A8=EF=BC=88InkStoneCo#44=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-08-16 實測:同一個 220 bytes 的來源檔、同一個模型(workers-ai)、同一個 雲端版本(1.4.46),geek6688 萃出四條全部追得回原文,youlin 萃出八條、其中四條 (Windows/macOS/Linux/每 30 天)在原文零命中——**而那張假卡帶著真實存在的 source_path**。出處在,內容是編的;引用沒有阻止幻覺,反而讓幻覺更可信。 缺的不是更好的模型(機率性,換模型只會降低頻率、讓它更難被發現), 也不是更嚴的提示詞(同一件事的較弱版本,且無法驗證)。 缺的是一道驗證:卡片產出之後、寫進知識庫之前,沒有一步問過「原文的哪裡」。 線畫在哪(grounding.go 檔頭全文):**改寫改的是「怎麼說」,編造加的是「在說什麼」**。 摘要/換句話說/把 EARS 句翻成人話全是正當加工,所以刻意**不比對 CJK 詞** (原文是英文、卡片是中文時,逐字比對會把忠實翻譯全部誤殺)。 只檢查翻譯與改寫都帶不走的錨點:拉丁專名、兩位數以上的數字。 另加 H8 從數量那一軸看:宣稱數 > 原文斷言數 × 3 = 輸入貧乏卻產出豐富。 分級:兩項都是**軟項**不是硬缺——使用者的知識被丟掉比被標記可疑更糟。 掛載點沿用 direct.go:1206 既有的 LintCard(萃完、POST 前),不新增資料寫入路徑。 順手修:`collector lint <卡> --source <原稿>` 的旗標被 Go flag 靜默忽略, H6/H7/H8 全跳過還印出漂亮的空結果 `{}`——查幻覺的工具自己靜默不查。 未動(依 SDD 生命週期第 3 條寫成 pending-changes.md P12 等 confirm): - H6 長度上限對薄原稿是反的,正把**忠實的卡**打成「疑似整段照抄」(實測 469 字 vs 上限 132) - 軟項沒有出海口:ingest 工作流一個 quality 欄位都沒讀,標了等於沒標 驗證(離線夾具 collector/testdata/grounding/,不依賴任何線上實例): - 正面:youlin 卡 → H7 抓到 windows/macos/linux/30,H8 抓到 8 條 vs 上限 6 - 反面:geek6688 卡 → H7/H8 零命中 - 邊界:跨語言(英→中)與同語言整段換詞兩組合理改寫 → 零誤殺 --- grounding.go | 320 ++++++++++++++++++ grounding_test.go | 245 ++++++++++++++ lint.go | 33 +- main.go | 32 +- testdata/grounding/card-access-paraphrase.md | 27 ++ .../grounding/card-delivery-paraphrase.md | 26 ++ testdata/grounding/card-geek6688-faithful.md | 28 ++ testdata/grounding/card-youlin-fabricated.md | 32 ++ testdata/grounding/source-access-en.md | 5 + testdata/grounding/source-delivery-zh.md | 4 + testdata/grounding/source-requirements.md | 8 + 11 files changed, 750 insertions(+), 10 deletions(-) create mode 100644 grounding.go create mode 100644 grounding_test.go create mode 100644 testdata/grounding/card-access-paraphrase.md create mode 100644 testdata/grounding/card-delivery-paraphrase.md create mode 100644 testdata/grounding/card-geek6688-faithful.md create mode 100644 testdata/grounding/card-youlin-fabricated.md create mode 100644 testdata/grounding/source-access-en.md create mode 100644 testdata/grounding/source-delivery-zh.md create mode 100644 testdata/grounding/source-requirements.md diff --git a/grounding.go b/grounding.go new file mode 100644 index 0000000..f5cd2a5 --- /dev/null +++ b/grounding.go @@ -0,0 +1,320 @@ +// grounding.go — 出處落地檢查(InkStoneCo#44,2026-08-16 實測事故)。 +// +// 🔴 為什麼有這一層:2026-08-16 同一個 220 bytes 的來源檔、同一個模型(workers-ai)、 +// 同一個雲端版本(1.4.46),在 geek6688 上萃出四條全部追得回原文,在 youlin 上萃出 +// 八條、其中四條的關鍵詞在原文**零命中**——而那張卡帶著真實存在的 `source_path`。 +// +// ⇒ **出處在,內容是編的。引用沒有阻止幻覺,反而讓幻覺更可信。** +// +// 缺的不是「更好的模型」(萃取是機率性的,換模型只會降低頻率、讓它更難被發現), +// 也不是「更嚴的提示詞」(同一件事的較弱版本,且無法驗證)。 +// **缺的是一道驗證**:卡片產出之後、寫進知識庫之前,從來沒有任何一步問過 +// 「這些宣稱在原文的哪裡」。那個檢查總管當天用 grep 三秒就做完了。 +// +// ── 這條線畫在哪(本檔最重要的一段,先讀完再改程式)───────────────────── +// +// ❌ **不是「卡片不准出現原文沒有的字」。** 摘要、換句話說、把 EARS 句翻成人話、 +// 把英文原稿寫成正體中文卡——**全部是正當的加工**,而且是本產品的價值本身。 +// 真的照抄反而是 H6 在擋的病(D16 精耕非 RAG)。 +// +// ✅ **要抓的是「憑空多出來的事實宣稱」。** 判準: +// +// **改寫改的是「怎麼說」,編造加的是「在說什麼」。** +// +// 一句話的措辭可以整個換掉,但它不會憑空長出一個新的**具體指稱**—— +// 一個專名、一個型號、一個數量。所以本檔只檢查「**翻譯與改寫都帶不走的錨點**」: +// +// 錨點① 拉丁字母串(Windows/macOS/Linux/CONSTITUTION.md/OAuth2…) +// ——這是**指稱**不是措辭。原文若真的提到它,它會**逐字**在原文裡。 +// 錨點② 兩位數以上的數字(30/2026/1.4/80%) +// ——「每 30 天檢查一次」裡的 30 不可能是改寫的產物。 +// +// 🔴 **刻意不檢查 CJK 詞**:原文可能是英文、卡片是中文(本案就是), +// 逐字比對中文詞會把**忠實的翻譯全部誤判成編造**——那正好違反紅線。 +// 代價是「日誌」「敏感資料」這種**純中文的憑空宣稱** H7 抓不到, +// 由 H8 從另一個角度補(見下),且**這個盲區是明說的,不假裝沒有**。 +// +// H8(宣稱通膨)走的是完全不同的軸:**數量**。 +// 事故的 ⚠️ 那段講得很清楚——「輸入越少,編得越多,而卡片看起來越專業」。 +// 改寫可以把原文一句拆成兩句,但拆成四句就是在增添。 +// ⇒ 卡片的宣稱數 > 原文自身斷言數 × 2 = 通膨警示。 +// 本案:原文 2 條斷言 ⇒ 上限 4;geek6688 4 條(過)、youlin 8 條(不過)。 +// +// ── 分級:兩項都是**軟項**,不是硬缺 ────────────────────────────── +// +// 🔴 **不確定就標記,不要靜默丟棄**——使用者的知識被丟掉比被標記可疑更糟。 +// 硬缺=拒收不 POST=知識被丟掉,所以 H7/H8 一律走軟項:照送、標 `quality: low` +// + `quality_warnings`(direct.go 既有的那條路,不新增第二套寫入路徑)。 +// 要讓它們變成擋牆=跑 `--strict`(測試/CI)。 +// +// ── 誠實的限制(照 lint.go 檔頭自陳的規矩寫)────────────────────── +// +// - 中文數字(「三十天」)不正規化 ⇒ 錨點②漏。 +// - 單一位數不算錨點(卡片自己數東西時太常出現「1」「3」)⇒ 「每 5 次」漏。 +// - 原文若本身就含該拉丁串(哪怕語意完全不同)就算落地 ⇒ 偽陰。 +// - H8 的斷言數是行/句層級的粗估,不是語意單位。 +// +// **這一層擋的是「明顯憑空的具體指稱」與「數量上的通膨」,擋不了語意層的曲解。** +package collector + +import ( + "regexp" + "strings" +) + +// h8ClaimRatio:卡片宣稱數相對原文斷言數的上限倍率。 +// +// 3=「把原文一句拆成定義+前提+結果」是改寫的合理上界;再多就是在增添內容。 +// 🔴 為什麼不是 2:本票的兩張真卡把兩邊的距離釘死了——同一份 2 條斷言的原稿, +// **忠實的那張產 4 條、編造的那張產 8 條**。倍率 2(上限 4)會讓忠實的那張 +// 剛好卡在線上,任何一張多寫一條的好卡都會被誤標;倍率 3(上限 6)讓兩邊 +// 各留 2 條的餘裕。**這個數字是拿事故裡的真實兩端定的,不是憑感覺調的。** +const h8ClaimRatio = 3 + +// h8MinClaims:宣稱數低於此值不報 H8(原文只有一條斷言時,3 條卡片重點是正常的 +// 「拆成定義+前提+結果」,報它只會製造雜訊)。 +const h8MinClaims = 4 + +var ( + // 拉丁錨點:字母開頭,可含數字與 . _ - 連接(CONSTITUTION.md/OAuth2/macOS/v1.4)。 + latinAnchorRe = regexp.MustCompile(`[A-Za-z][A-Za-z0-9]*(?:[._\-][A-Za-z0-9]+)*`) + // 數字錨點:兩位數以上(含小數與百分比)。單一位數雜訊太多,刻意不收。 + numAnchorRe = regexp.MustCompile(`[0-9]{2,}(?:\.[0-9]+)*%?|[0-9]\.[0-9]+`) + // wikishape 機械補的那一行(renderDocCard 的 linked==0 分支)——它是程式寫的,不是模型的宣稱。 + mechConceptCountRe = regexp.MustCompile(`^-\s*本文件整理成\s*[0-9]+\s*張概念卡`) + // 麵包屑(← [[上層]])與 H3 子標題。 + breadcrumbRe = regexp.MustCompile(`^←\s*\[\[`) + h3HeadingRe = regexp.MustCompile(`^###\s+(.+?)\s*$`) + // 句尾(H8 算原文斷言用)。全形標點一律算句尾;半形句點只在**後面接空白或到文末**時 + // 才算——否則 `CONSTITUTION.md`、`v1.4`、`3.5 吋` 會被切成兩句,把原文斷言數灌水, + // H8 的上限跟著變鬆(實測:本票那份原稿會從 2 條被算成 3 條)。 + sentEndRe = regexp.MustCompile(`[。!?;]+|[.!?;]+(?:\s+|$)`) + // 條列行(原文與卡片共用)。 + srcBulletRe = regexp.MustCompile(`^\s*(?:[-*+]|[0-9]+[.)])\s+\S`) +) + +// GroundingReport 一張卡的落地檢查結果。 +type GroundingReport struct { + // Ungrounded=卡片上有、原文查不到的具體指稱(去重保序,最多列前幾個)。 + Ungrounded []string `json:"ungrounded,omitempty"` + // Claims=卡片做出的事實宣稱數(重點條+內文知識關係三元組)。 + Claims int `json:"claims"` + // SrcUnits=原文自身的斷言數(條列行+散文句)。 + SrcUnits int `json:"src_units"` +} + +// Inflated 回答「宣稱數是否超過原文能支撐的量」。 +func (g GroundingReport) Inflated() bool { + return g.Claims >= h8MinClaims && g.Claims > g.SrcUnits*h8ClaimRatio +} + +// CheckGrounding 對一張卡跑落地檢查。source 為空=無從檢查,回空報告。 +// +// 🔴 這是**純函式**:不讀檔、不打網路、不看設定。事故的回歸案例因此可以做成 +// 測試夾具離線重放,不必依賴任何線上實例(本票紅線:不得在既有實例上刪改資料)。 +func CheckGrounding(card, source string) GroundingReport { + var g GroundingReport + if strings.TrimSpace(source) == "" || strings.TrimSpace(card) == "" { + return g + } + body, claims := groundingBody(card) + g.Claims = claims + g.SrcUnits = countSourceUnits(source) + + src := normalizeAnchorText(source) + seen := map[string]bool{} + for _, a := range anchorsOf(body) { + if seen[a] { + continue + } + seen[a] = true + if !strings.Contains(src, a) { + g.Ungrounded = append(g.Ungrounded, a) + } + } + return g +} + +// groundingBody 從卡片切出「該被檢查的那些字」,並順便數出宣稱數。 +// +// 排除的全是**機械產物**(wikishape.go 寫的,不是模型的宣稱): +// frontmatter、標題行、麵包屑、`### 卡片關係`([[A]] >> 整理出 >> [[B]])、 +// `### 出處`(那一行就是 source_path 本身,拿它比對等於自問自答)、 +// 以及「本文件整理成 N 張概念卡」那條補句。 +func groundingBody(card string) (body string, claims int) { + lines := strings.Split(card, "\n") + var b strings.Builder + + inFrontmatter := false + if len(lines) > 0 && strings.TrimSpace(lines[0]) == "---" { + inFrontmatter = true + lines = lines[1:] + } + wikiShape := isWikiShapeCard(card) + sec, sub := "", "" + for _, line := range lines { + t := strings.TrimSpace(line) + if inFrontmatter { + if t == "---" { + inFrontmatter = false + } + continue + } + if m := h2HeadingRe.FindStringSubmatch(line); m != nil { + sec, sub = strings.TrimSpace(m[1]), "" + continue + } + if m := h3HeadingRe.FindStringSubmatch(line); m != nil { + sub = strings.TrimSpace(m[1]) + continue + } + if anyHeadingRe.MatchString(line) || t == "" || breadcrumbRe.MatchString(t) { + continue + } + if sub == "出處" || sub == "卡片關係" || mechConceptCountRe.MatchString(t) { + continue + } + b.WriteString(line) + b.WriteByte('\n') + + if !bulletRe.MatchString(t) { + continue + } + switch { + case sec == nsecPts || sec == secPts: // 重點/要點 + claims++ + case sub == "內文知識關係": // 概念卡的三元組(新格式) + claims++ + case !wikiShape && sec == secRel: // 舊四段格式的「關聯」 + claims++ + } + } + return b.String(), claims +} + +// countSourceUnits 粗估原文自身的斷言數:條列行一條算一個;散文段落按句子數算。 +// 標題行與純標記行(`> [!NOTE]`、`|---|`、圍欄)不是斷言,不計。 +// 下限 1(原文再薄也不會是 0,免得除出無限大的比例)。 +func countSourceUnits(source string) int { + n := 0 + inFence := false + for _, raw := range strings.Split(source, "\n") { + t := strings.TrimSpace(raw) + if strings.HasPrefix(t, "```") || strings.HasPrefix(t, "~~~") { + inFence = !inFence + continue + } + if inFence { + n++ // 程式碼/表格內容:一行算一條,不再切句 + continue + } + // 引言標記剝掉再判斷(`> Inherited from …` 是斷言,`> [!NOTE]` 不是)。 + t = strings.TrimSpace(strings.TrimPrefix(t, ">")) + if t == "" || anyHeadingRe.MatchString(t) || strings.HasPrefix(t, "---") { + continue + } + if strings.HasPrefix(t, "[!") || strings.HasPrefix(t, "|") { + continue + } + if srcBulletRe.MatchString(t) { + n++ + continue + } + // 散文:以句尾符號切;沒有任何句尾符號的一行仍算一句。 + parts := sentEndRe.Split(t, -1) + c := 0 + for _, p := range parts { + if strings.TrimSpace(p) != "" { + c++ + } + } + if c == 0 { + c = 1 + } + n += c + } + if n < 1 { + n = 1 + } + return n +} + +// normalizeAnchorText 把文字壓成可比對的形狀:全形數字→半形、拉丁字母→小寫。 +// 刻意**不**做斷詞——子字串命中就算落地(`logseq` 命中 `Logseq-plugin`), +// 寧可放過也不要誤殺(本檔的偏誤方向:偽陰可接受,偽陽不可接受)。 +func normalizeAnchorText(s string) string { + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + switch { + case r >= '0' && r <= '9': + b.WriteRune('0' + (r - '0')) + case r >= 'A' && r <= 'Z': + b.WriteRune('a' + (r - 'A')) + case r >= 'a' && r <= 'z': + b.WriteRune('a' + (r - 'a')) + case r >= 'A' && r <= 'Z': + b.WriteRune(r + ('a' - 'A')) + default: + b.WriteRune(r) + } + } + return b.String() +} + +// anchorsOf 抽出一段文字裡的「具體指稱」錨點(已正規化)。 +func anchorsOf(text string) []string { + t := normalizeAnchorText(text) + var out []string + seen := map[string]bool{} + add := func(a string) { + if a == "" || seen[a] { + return + } + seen[a] = true + out = append(out, a) + } + for _, m := range latinAnchorRe.FindAllString(t, -1) { + if len([]rune(m)) < 2 || anchorStopwords[m] { + continue + } + add(m) + } + for _, m := range numAnchorRe.FindAllString(t, -1) { + add(m) + } + return out +} + +// anchorStopwords:不當指稱看的拉丁串。 +// 只收**卡片格式自己會產生**或**中文行文的通用連接詞**——不收任何領域名詞 +// (API/JSON/OAuth 都是真的指稱,若原文沒有就該被指出來)。 +var anchorStopwords = map[string]bool{ + "md": true, "txt": true, "pdf": true, "docx": true, "pptx": true, "xlsx": true, // 副檔名(出處行已排除,這是保險) + "http": true, "https": true, "www": true, "kb": true, + "index": true, "tags": true, "gloss": true, "created": true, "updated": true, // frontmatter 鍵(已排除,保險) + "the": true, "and": true, "or": true, "of": true, "to": true, "in": true, "is": true, "a": true, "an": true, +} + +// groundingFindings 把報告攤成 lint 訊息(H7 憑空指稱/H8 宣稱通膨)。 +// 回傳 (code, message) 對;空=全過。 +func groundingFindings(g GroundingReport) [][2]string { + var out [][2]string + if n := len(g.Ungrounded); n > 0 { + shown := g.Ungrounded + if len(shown) > 6 { + shown = shown[:6] + } + msg := "卡上有 " + itoa(n) + " 個原文查不到的具體指稱:" + strings.Join(shown, "、") + if n > len(shown) { + msg += "…" + } + out = append(out, [2]string{"H7", msg + "(改寫可以換措辭,但不會憑空長出專名或數量)"}) + } + if g.Inflated() { + out = append(out, [2]string{"H8", + "卡片有 " + itoa(g.Claims) + " 條宣稱,原文只有 " + itoa(g.SrcUnits) + + " 條斷言(上限 " + itoa(g.SrcUnits*h8ClaimRatio) + ")=輸入貧乏卻產出豐富,是模型拿通用先驗補滿的典型情況"}) + } + return out +} diff --git a/grounding_test.go b/grounding_test.go new file mode 100644 index 0000000..c797c6f --- /dev/null +++ b/grounding_test.go @@ -0,0 +1,245 @@ +// grounding_test.go — InkStoneCo#44 2026-08-16 事故的回歸測試。 +// +// 🔴 夾具全部離線(testdata/grounding/),刻意**不依賴任何線上實例**: +// 本票紅線寫死「不得在 youlin/geek6688/leo21c 任何既有實例上刪改既有資料」, +// 而回歸測試若要靠打實例才能跑,它就永遠不會在 CI 上跑。 +// +// 三組驗證對應票上的三個要求: +// 1. 正面:youlin 那張「四條憑空」的卡必須被抓到。 +// 2. 反面:geek6688 那張「四條全部追得回原文」的卡**一個軟項都不准報**。 +// 3. 邊界:用詞完全不同但意思在原文裡的合理改寫(跨語言+同語言各一)不得誤殺。 +// +// 註:本檔組字串時一律用 triSep 常數而不是字面雙箭頭——arcrun-intent-guard hook +// 會把 .go 裡的字面雙箭頭誤判成 Arcrun 工作流的邊(wikishape.go 檔頭已記同一件事)。 +package collector + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func readFixture(t *testing.T, name string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join("testdata", "grounding", name)) + if err != nil { + t.Fatalf("讀夾具 %s 失敗:%v", name, err) + } + return string(b) +} + +// codesOf 把 findings 攤成代號集合(H7/H8 命中與否用)。 +func codesOf(fs []LintFinding) map[string]bool { + m := map[string]bool{} + for _, f := range fs { + m[f.Code] = true + } + return m +} + +// ── ① 正面:那張編了四條的卡必須被抓到 ────────────────────────── + +func TestGrounding_YoulinFabricated_IsCaught(t *testing.T) { + src := readFixture(t, "source-requirements.md") + card := readFixture(t, "card-youlin-fabricated.md") + + g := CheckGrounding(card, src) + + // 票上實查的八個關鍵詞裡,能被「翻譯與改寫帶不走的錨點」抓到的正是這四個。 + want := []string{"windows", "macos", "linux", "30"} + got := map[string]bool{} + for _, u := range g.Ungrounded { + got[u] = true + } + for _, w := range want { + if !got[w] { + t.Errorf("憑空指稱「%s」沒被抓到;實得 %v", w, g.Ungrounded) + } + } + if !g.Inflated() { + t.Errorf("宣稱通膨沒被抓到:claims=%d src_units=%d(原文只有一條需求,卡上八條)", + g.Claims, g.SrcUnits) + } + + lr := LintCard(card, LintOptions{Source: src}) + codes := codesOf(lr.Soft) + if !codes["H7"] || !codes["H8"] { + t.Fatalf("LintCard 沒把 H7/H8 掛成軟項:soft=%v", lr.SoftMessages()) + } + // 🔴 軟項=照送標 quality:low,不是拒收——「使用者的知識被丟掉比被標記可疑更糟」。 + if len(lr.Hard) != 0 { + t.Fatalf("落地問題不得升成硬缺(會靜默丟掉使用者的知識):hard=%v", lr.HardMessages()) + } + if lr.Blocks(false) { + t.Fatalf("預設模式不該擋下這張卡(該標記,不該丟)") + } + // --strict(CI/測試)下才變成擋牆。 + if !lr.Blocks(true) { + t.Fatalf("--strict 下應該擋得住") + } + t.Logf("youlin 卡:ungrounded=%v claims=%d src_units=%d\nsoft=%v", + g.Ungrounded, g.Claims, g.SrcUnits, lr.SoftMessages()) +} + +// ── ② 反面:忠實的那張不准被誤標 ─────────────────────────────── + +func TestGrounding_Geek6688Faithful_NotFlagged(t *testing.T) { + src := readFixture(t, "source-requirements.md") + card := readFixture(t, "card-geek6688-faithful.md") + + g := CheckGrounding(card, src) + if len(g.Ungrounded) != 0 { + t.Errorf("忠實的卡被指出憑空指稱(偽陽):%v", g.Ungrounded) + } + if g.Inflated() { + t.Errorf("忠實的卡被判宣稱通膨(偽陽):claims=%d src_units=%d", g.Claims, g.SrcUnits) + } + + lr := LintCard(card, LintOptions{Source: src}) + if len(lr.Hard) != 0 { + t.Fatalf("忠實的卡被硬缺擋下:hard=%v", lr.HardMessages()) + } + if c := codesOf(lr.Soft); c["H7"] || c["H8"] { + t.Fatalf("忠實的卡被本票新增的機制誤標:soft=%v", lr.SoftMessages()) + } + t.Logf("geek6688 卡:ungrounded=%v claims=%d src_units=%d(上限 %d)=H7/H8 全過", + g.Ungrounded, g.Claims, g.SrcUnits, g.SrcUnits*h8ClaimRatio) + + // ⚠️ 這張忠實的卡**還是被標了 quality:low**,但兇手不是本票的機制,是既有的 H6: + // 220 字的原稿 ⇒ 長度上限 132 字,而規範形卡的機械骨架本身就 471 字。 + // 🔴 這條屬 B2 §H6 硬標準(且有兩個既有測試寫在該行為上)⇒ 依 SDD 生命週期鐵律 + // 走 pending-changes.md 提案,不在本票自行改。本斷言把現況釘住當證據: + // 提案被 confirm、H6 修好之後,這裡會失敗,那時把它改成「一個軟項都沒有」。 + if !hasCode(lr.Soft, "H6") { + t.Fatalf("預期忠實的卡仍被既有 H6 偽陽命中(提案未 confirm 前的現況);實得 soft=%v", + lr.SoftMessages()) + } + t.Logf("⚠️ 既有 H6 偽陽(非本票機制,提案待裁):%v", lr.SoftMessages()) +} + +// ── ③ 邊界:合理改寫不得誤殺 ──────────────────────────────────── +// +// 🔴 這一組是「線畫在哪」的實證:兩張卡與原文**沒有共用任何一個實詞** +//(一張跨語言、一張同語言換詞),若機制真的在比對字面,這裡一定會炸。 + +func TestGrounding_LegitimateParaphrase_NotFlagged(t *testing.T) { + cases := []struct{ name, src, card string }{ + {"跨語言:英文原稿→中文卡", "source-access-en.md", "card-access-paraphrase.md"}, + {"同語言:整段換詞不換意思", "source-delivery-zh.md", "card-delivery-paraphrase.md"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + src := readFixture(t, c.src) + card := readFixture(t, c.card) + + g := CheckGrounding(card, src) + if len(g.Ungrounded) != 0 { + t.Errorf("合理改寫被指出憑空指稱(偽陽):%v", g.Ungrounded) + } + if g.Inflated() { + t.Errorf("合理改寫被判通膨(偽陽):claims=%d src_units=%d", g.Claims, g.SrcUnits) + } + lr := LintCard(card, LintOptions{Source: src}) + codes := codesOf(lr.Soft) + if codes["H7"] || codes["H8"] { + t.Fatalf("合理改寫被 H7/H8 誤殺:%v", lr.SoftMessages()) + } + t.Logf("%s:claims=%d src_units=%d soft=%v", c.name, g.Claims, g.SrcUnits, lr.SoftMessages()) + }) + } +} + +// ── ④ 單元:錨點與斷言計數的邊界行為 ──────────────────────────── +// 含**刻意留下的盲區**——寫成測試,免得日後被誤當 bug「順手修掉」而失去自陳。 + +func TestGrounding_AnchorRules(t *testing.T) { + cases := []struct { + name string + src, card string + wantGround bool // true=應判定「有憑空指稱」 + }{ + {"拉丁專名原文有=落地", "we use Logseq daily", groundCard("外掛在 Logseq 上執行"), false}, + {"拉丁專名原文無=憑空", "we use Logseq daily", groundCard("外掛支援 Linux"), true}, + {"大小寫不同仍算落地", "we use LOGSEQ daily", groundCard("外掛在 Logseq 上執行"), false}, + {"兩位數原文無=憑空", "check for updates", groundCard("每 30 天檢查一次"), true}, + {"兩位數原文有=落地", "retry after 30 days", groundCard("每 30 天檢查一次"), false}, + {"全形數字視同半形", "retry after 30 days", groundCard("每 30 天檢查一次"), false}, + {"盲區:純中文憑空宣稱抓不到", "check for updates", groundCard("系統必須記錄使用者操作日誌"), false}, + {"盲區:單一位數不算錨點", "no numbers here", groundCard("每 5 次檢查一回"), false}, + {"出處與卡片關係是機械產物,不算宣稱", "no latin at all", groundMechOnly("../foo/Bar.md"), false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + g := CheckGrounding(c.card, c.src) + if got := len(g.Ungrounded) > 0; got != c.wantGround { + t.Fatalf("want ungrounded=%v got=%v(%v)", c.wantGround, got, g.Ungrounded) + } + }) + } +} + +// groundCard 組一張最小的規範形卡,重點只放一行(錨點規則的單元測試用)。 +func groundCard(point string) string { + return strings.Join([]string{ + "---", "tags: [測試]", "gloss: 測試卡", "created: 2026-08-16", "updated: 2026-08-16", "---", + "# 測試卡", "", "← [[00-INDEX]]", "", + "## 摘要", "測試用的卡片。", "", + "## 重點", "- " + point, "", + "## 實體", "- **測試**(概念)— 測試用", "", + "## 關聯", "### 內文知識關係", "### 卡片關係", "### 出處", + "- `../t.md`" + triSep + "提及" + triSep + "測試卡", "", + }, "\n") +} + +// groundMechOnly 驗「出處/卡片關係是機械產物,不該被當成模型的宣稱」—— +// 那兩段裡的路徑與卡名一定不在原文裡,若沒排除就會每張卡都報偽陽。 +func groundMechOnly(srcRel string) string { + return strings.Join([]string{ + "---", "tags: [測試]", "gloss: 測試卡", "created: 2026-08-16", "updated: 2026-08-16", "---", + "# 測試卡", "", "← [[00-INDEX]]", "", + "## 摘要", "測試用的卡片。", "", + "## 重點", "- 一條沒有任何錨點的宣稱", "", + "## 實體", "- **測試**(概念)— 測試用", "", + "## 關聯", "### 內文知識關係", "### 卡片關係", + "- [[測試卡]]" + triSep + "整理出" + triSep + "[[子卡]]", "### 出處", + "- `" + srcRel + "`" + triSep + "提及" + triSep + "測試卡", "", + }, "\n") +} + +// TestGroundingCLI_FlagsAfterFilename 鎖住 `collector lint` 的旗標順序修正。 +// +// 🔴 修之前:Go 的 flag 套件遇到第一個非旗標就停止解析 ⇒ 照用法字串寫的 +// `lint <卡> --source <原稿>` 讓 --source 靜默失效、H6/H7/H8 全跳過、 +// 印出空結果 `{}` 並 exit 0 ——**查幻覺的工具自己靜默不查**。 +// 兩種順序都必須得到同一個結論。 +func TestGroundingCLI_FlagsAfterFilename(t *testing.T) { + card := filepath.Join("testdata", "grounding", "card-youlin-fabricated.md") + src := filepath.Join("testdata", "grounding", "source-requirements.md") + + orders := map[string][]string{ + "旗標在後(票上撞到的寫法)": {card, "--source", src, "--strict"}, + "旗標在前": {"--source", src, "--strict", card}, + } + for name, args := range orders { + t.Run(name, func(t *testing.T) { + if code := runLint(args); code != 1 { + t.Fatalf("編造的卡在 --strict 下應該被擋(exit 1),得 %d——"+ + "若是 0,代表 --source 又被靜默忽略了", code) + } + }) + } +} + +// TestGrounding_NoSource 確認「拿不到原稿時安靜通過」——rag_ingest_card 那一端 +// 天生收不到原稿(同 H6 的既有行為),不能因此把每張卡都打成可疑。 +func TestGrounding_NoSource(t *testing.T) { + card := readFixture(t, "card-youlin-fabricated.md") + if g := CheckGrounding(card, ""); len(g.Ungrounded) != 0 || g.Inflated() { + t.Fatalf("沒有原稿時不該有任何判定:%+v", g) + } + lr := LintCard(card, LintOptions{}) + if c := codesOf(lr.Soft); c["H7"] || c["H8"] { + t.Fatalf("沒有原稿時不該報 H7/H8:%v", lr.SoftMessages()) + } +} diff --git a/lint.go b/lint.go index 01ad39d..f985654 100644 --- a/lint.go +++ b/lint.go @@ -6,11 +6,20 @@ // 落點(草案 §3 第一層「品質迴路」):插在 extract 之後、POST rag_ingest_card 之前。 // 手上有原稿(H6 相似度只有這裡能做);不過的分級依 §6 裁決 T4-C: // - 硬缺(H1 段名/H2 一句話定義/H5 機敏值)=結構壞或洩密 → 拒收(不 POST)。 -// - 軟項(H3 要點條數/H4 關聯漂移/H6 相似度)=現行管線會產生的正常瑕疵 → 照送、標 quality:low。 +// - 軟項(H3 要點條數/H4 關聯漂移/H6 相似度/H7 憑空指稱/H8 宣稱通膨)= +// 現行管線會產生的正常瑕疵 → 照送、標 quality:low。 // - --strict:軟項也擋(測試/CI 用,讓「形檢天花板」外的問題全部現形)。 // +// 🔴 **H7/H8(InkStoneCo#44,2026-08-16 加)是這裡唯一問「內容是不是真的」的一項。** +// H1–H6 全是形檢:段名齊不齊、有沒有照抄、有沒有洩密——**沒有任何一項問過 +// 「卡上宣稱的東西,原文裡到底有沒有」**。而 08-16 實測的事故正是這個洞: +// 同一個 220 bytes 的檔在兩台實例上,一台萃出四條全部追得回原文, +// 一台萃出八條、四條憑空,**而那張假卡帶著真實存在的 source_path**。 +// 判準與刻意留下的盲區全文寫在 `grounding.go` 檔頭,改它之前先讀那一段。 +// // 誠實限制(抄 wiki-secret-scan.sh 的自陳):H5 regex 偵測有偽陰/偽陽,擋的是「明顯特徵的 -// 機敏字串被自動抄進卡」,擋不了刻意混淆;H3/H4/H6 是形檢,擋形不擋神(草案 §H3 ZZ-T7 天花板)。 +// 機敏字串被自動抄進卡」,擋不了刻意混淆;H3/H4/H6 是形檢,擋形不擋神(草案 §H3 ZZ-T7 天花板); +// H7 只認「翻譯與改寫帶不走的錨點」(拉丁專名、兩位數以上的數字),純中文的憑空宣稱抓不到。 package collector import ( @@ -233,6 +242,16 @@ func lintWikiShapeCard(card string, opts LintOptions) LintResult { if msg := checkSimilarity(card, opts.Source); msg != "" { r = r.addSoft("H6", msg) } + r = r.addGrounding(card, opts.Source) + } + return r +} + +// addGrounding 跑 H7/H8 落地檢查並掛成軟項(grounding.go;InkStoneCo#44 2026-08-16)。 +// 🔴 軟項不是「比較不重要」,是「不准靜默丟掉使用者的知識」——見 grounding.go 檔頭分級段。 +func (r LintResult) addGrounding(card, source string) LintResult { + for _, f := range groundingFindings(CheckGrounding(card, source)) { + r = r.addSoft(f[0], f[1]) } return r } @@ -329,10 +348,12 @@ func LintCard(card string, opts LintOptions) LintResult { } // ── H6 不整段複製原文(軟;只有拿得到原稿時做)────────────── + // ── H7/H8 卡上的宣稱在原文有沒有落地(軟;同樣只有拿得到原稿時做)── if opts.Source != "" { if msg := checkSimilarity(card, opts.Source); msg != "" { r = r.addSoft("H6", msg) } + r = r.addGrounding(card, opts.Source) } return r @@ -374,6 +395,14 @@ func checkSimilarity(card, source string) string { srcRunes := runeLen(source) // 絕對上限:卡片字數 ≤ min(4000, 原稿字數 × 60%)——防「萃取失敗就整檔照抄」。 + // + // ⚠️ **已知偽陽(InkStoneCo#44 2026-08-16 實測,提案在 pending-changes.md 等 confirm)**: + // 這條比例規則對**薄原稿是反的**。220 字的原稿 ⇒ 上限 132 字, + // 而一張規範形卡光是機械骨架就三百多字(實測忠實卡 471、編造卡 550) + // ⇒ **兩張都超標,忠實的那張同樣被打成「疑似整段照抄」**。 + // 🔴 這條沒有在本票一併改:它是 B2 §H6 的硬標準,且有兩個既有測試 + //(TestLintH6LengthCapSoft/TestLintH6OverlapSoft)就寫在這個行為上 + // ⇒ 屬規格層變更,依 SDD 生命週期鐵律第 3 條走提案、不自行改。 limit := h6MaxCardRunes if byRatio := int(float64(srcRunes) * h6SrcRatio); byRatio < limit { limit = byRatio diff --git a/main.go b/main.go index 12a7f89..af15cb8 100644 --- a/main.go +++ b/main.go @@ -268,20 +268,36 @@ func fail(err error) int { return 1 } -// runLint 是 `collector lint <卡片.md>` 子命令:對一張卡跑 B2 品質檢查(H1–H6)。 +// runLint 是 `collector lint <卡片.md>` 子命令:對一張卡跑 B2 品質檢查(H1–H8)。 // 印出分級 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 + strict := fs.Bool("strict", false, "軟項(H3/H4/H6/H7/H8)也算擋(測試/CI)") + source := fs.String("source", "", "原稿檔路徑(H6 相似度/H7 落地/H8 通膨都需要;不給=三項全跳過)") + + // 🔴 旗標必須也能出現在檔名**後面**(InkStoneCo#44 2026-08-16 實撞)。 + // Go 的 flag 套件遇到第一個非旗標就停止解析 ⇒ 照上面用法字串寫的 + // `lint <卡> --source <原稿>` 會讓 --source **靜默地不生效**, + // 於是 H6/H7/H8 全部跳過、印出一個漂亮的空結果 `{}`、exit 0。 + // **一個查幻覺的工具自己靜默不查**,正是本票在講的那種病 ⇒ 就地修掉。 + var positional []string + rest := args + for { + if err := fs.Parse(rest); err != nil { + return 2 + } + if fs.NArg() == 0 { + break + } + positional = append(positional, fs.Arg(0)) + rest = fs.Args()[1:] } - if fs.NArg() < 1 { + if len(positional) < 1 { fmt.Fprintln(os.Stderr, "用法:collector lint <卡片.md> [--source <原稿>] [--strict]") return 2 } - card, err := os.ReadFile(fs.Arg(0)) + cardPath := positional[0] + card, err := os.ReadFile(cardPath) if err != nil { return fail(err) } @@ -300,7 +316,7 @@ func runLint(args []string) int { Strict bool `json:"strict"` Blocked bool `json:"blocked"` Result LintResult `json:"result"` - }{fs.Arg(0), *strict, blocked, res}, "", " ") + }{cardPath, *strict, blocked, res}, "", " ") fmt.Println(string(out)) if blocked { return 1 diff --git a/testdata/grounding/card-access-paraphrase.md b/testdata/grounding/card-access-paraphrase.md new file mode 100644 index 0000000..91b7ef0 --- /dev/null +++ b/testdata/grounding/card-access-paraphrase.md @@ -0,0 +1,27 @@ +--- +tags: [權限, 稽核, 外掛] +gloss: 未取得授權就不得讀取,且每次拒絕都要留痕、重開時重驗 +created: 2026-08-16 +updated: 2026-08-16 +--- +# 存取控管政策 + +← [[00-INDEX]] + +## 摘要 +這份政策把「能不能看」這件事拆成三個時間點來管:讀取當下要先確認眼前這個人有沒有被授權、被擋下來的那一刻要留下痕跡讓管理者事後查得到、以及工作區重新開啟時不沿用上一次的判斷而是重新確認一遍。 + +## 重點 +- 眼前這位使用者尚未被授予權限時,外掛不得取用筆記內容 +- 每一次被擋下來都要留痕,讓管理者事後查得到 +- 工作區重新開啟時不沿用舊判斷,權限要重新確認 + +## 實體 +- **外掛**(工具)— 受本政策約束的程式 +- **管理者**(人物)— 事後查閱留痕的人 + +## 關聯 +### 內文知識關係 +### 卡片關係 +### 出處 +- `../access.md` >> 提及 >> 存取控管政策 diff --git a/testdata/grounding/card-delivery-paraphrase.md b/testdata/grounding/card-delivery-paraphrase.md new file mode 100644 index 0000000..0c38370 --- /dev/null +++ b/testdata/grounding/card-delivery-paraphrase.md @@ -0,0 +1,26 @@ +--- +tags: [交件, 複核, 流程] +gloss: 遞交前需第二人複核,事後更動一律重跑審查 +created: 2026-08-16 +updated: 2026-08-16 +--- +# 交件規範 + +← [[00-INDEX]] + +## 摘要 +這條規範把責任放在「交出去之前」與「交出去之後」兩端:前端要求文稿不能由撰寫者一個人決定完成,必須有第二雙眼睛過目;後端則規定既已遞交的內容不存在小修捷徑,任何更動都退回流程起點重新跑一輪。 + +## 重點 +- 文稿不得由撰寫者獨自定案,須經第二人過目才可遞交 +- 已遞交的內容若有更動,必須退回起點重跑審查流程 + +## 實體 +- **文稿**(檔案)— 受本規範約束的產出 +- **第二人**(人物)— 遞交前負責過目的另一位同事 + +## 關聯 +### 內文知識關係 +### 卡片關係 +### 出處 +- `../delivery.md` >> 提及 >> 交件規範 diff --git a/testdata/grounding/card-geek6688-faithful.md b/testdata/grounding/card-geek6688-faithful.md new file mode 100644 index 0000000..9d97e16 --- /dev/null +++ b/testdata/grounding/card-geek6688-faithful.md @@ -0,0 +1,28 @@ +--- +tags: [Logseq, 外掛, 需求] +gloss: Logseq 外掛的需求文件,載明繼承的限制與一條功能需求 +created: 2026-08-16 +updated: 2026-08-16 +--- +# Requirements (Logseq-plugin) + +← [[00-INDEX]] + +## 摘要 +這份文件是一個 Logseq 外掛的需求規格,由兩部分組成:一段宣告本文件的限制繼承自根目錄的 CONSTITUTION.md,以及一條寫成條件句的功能需求——在使用者操作筆記軟體、外掛被啟動的情況下,系統要提供強化後的功能。 + +## 重點 +- 使用者啟用外掛時,系統必須增強功能 +- 外掛必須繼承自根目錄 CONSTITUTION.md 的限制 +- 系統必須在使用者使用 Logseq 時啟用外掛 +- 外掛功能必須在 Logseq 中執行 + +## 實體 +- **Logseq**(工具)— 這份需求所針對的筆記軟體 +- **CONSTITUTION.md**(檔案)— 根目錄的限制來源 + +## 關聯 +### 內文知識關係 +### 卡片關係 +### 出處 +- `../requirements.md` >> 提及 >> Requirements (Logseq-plugin) diff --git a/testdata/grounding/card-youlin-fabricated.md b/testdata/grounding/card-youlin-fabricated.md new file mode 100644 index 0000000..5fcf316 --- /dev/null +++ b/testdata/grounding/card-youlin-fabricated.md @@ -0,0 +1,32 @@ +--- +tags: [Logseq, 外掛, 需求] +gloss: Logseq 外掛的需求文件,涵蓋平台支援、權限、稽核與更新 +created: 2026-08-16 +updated: 2026-08-16 +--- +# Requirements (Logseq-plugin) + +← [[00-INDEX]] + +## 摘要 +這份文件是一個 Logseq 外掛的需求規格,載明外掛繼承根目錄 CONSTITUTION.md 的限制,並列出外掛在啟用後應提供的功能、支援的作業系統、權限控管方式,以及更新與稽核的規定。 + +## 重點 +- 使用者啟用外掛時,系統必須增強功能 +- 外掛必須繼承自根目錄 CONSTITUTION.md 的限制 +- 系統必須在使用者使用 Logseq 時啟用外掛 +- 外掛功能必須在 Logseq 中執行 +- 系統必須在 Windows、macOS 或 Linux 上運行 +- 外掛程式必須在使用者未授權時拒絕存取敏感資料 +- 系統必須記錄使用者操作日誌 +- 外掛程式必須每 30 天檢查一次更新 + +## 實體 +- **Logseq**(工具)— 這份需求所針對的筆記軟體 +- **CONSTITUTION.md**(檔案)— 根目錄的限制來源 + +## 關聯 +### 內文知識關係 +### 卡片關係 +### 出處 +- `../requirements.md` >> 提及 >> Requirements (Logseq-plugin) diff --git a/testdata/grounding/source-access-en.md b/testdata/grounding/source-access-en.md new file mode 100644 index 0000000..62f9aa1 --- /dev/null +++ b/testdata/grounding/source-access-en.md @@ -0,0 +1,5 @@ +# Access Control Policy + +The plugin must refuse to read a note when the current user has not granted permission. +Every refusal is written to the audit trail so that an administrator can review it later. +Permissions are checked again whenever the workspace is reopened. diff --git a/testdata/grounding/source-delivery-zh.md b/testdata/grounding/source-delivery-zh.md new file mode 100644 index 0000000..45a6b20 --- /dev/null +++ b/testdata/grounding/source-delivery-zh.md @@ -0,0 +1,4 @@ +# 交件規範 + +- 每份文件在送出之前,必須由另一位同仁看過一遍。 +- 送出之後若要修改,一律重新走一次送審。 diff --git a/testdata/grounding/source-requirements.md b/testdata/grounding/source-requirements.md new file mode 100644 index 0000000..8e6ccf4 --- /dev/null +++ b/testdata/grounding/source-requirements.md @@ -0,0 +1,8 @@ +# Requirements (Logseq-plugin) + +## Constraints +> [!NOTE] +> Inherited from root CONSTITUTION.md + +## Functional Requirements +- While , When , the shall .