Files
Leo 021b3962e2 feat(collector): 卡片上雲前先問「這些宣稱在原文的哪裡」——H7 憑空指稱/H8 宣稱通膨(InkStoneCo#44)
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 零命中
- 邊界:跨語言(英→中)與同語言整段換詞兩組合理改寫 → 零誤殺
2026-08-16 21:38:52 +08:00

246 lines
11 KiB
Go
Raw Permalink 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.
// grounding_test.go — InkStoneCo#44 2026-08-16 事故的回歸測試。
//
// 🔴 夾具全部離線(testdata/grounding/),刻意**不依賴任何線上實例**:
// 本票紅線寫死「不得在 youlingeek6688leo21c 任何既有實例上刪改既有資料」,
// 而回歸測試若要靠打實例才能跑,它就永遠不會在 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("預設模式不該擋下這張卡(該標記,不該丟)")
}
// --strictCI/測試)下才變成擋牆。
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("%sclaims=%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())
}
}