併 feat/44-grounding-check(總管已逐項驗過)

This commit is contained in:
2026-08-16 22:58:25 +08:00
11 changed files with 750 additions and 10 deletions
+320
View File
@@ -0,0 +1,320 @@
// grounding.go — 出處落地檢查(InkStoneCo#442026-08-16 實測事故)。
//
// 🔴 為什麼有這一層:2026-08-16 同一個 220 bytes 的來源檔、同一個模型(workers-ai)、
// 同一個雲端版本(1.4.46),在 geek6688 上萃出四條全部追得回原文,在 youlin 上萃出
// 八條、其中四條的關鍵詞在原文**零命中**——而那張卡帶著真實存在的 `source_path`。
//
// ⇒ **出處在,內容是編的。引用沒有阻止幻覺,反而讓幻覺更可信。**
//
// 缺的不是「更好的模型」(萃取是機率性的,換模型只會降低頻率、讓它更難被發現),
// 也不是「更嚴的提示詞」(同一件事的較弱版本,且無法驗證)。
// **缺的是一道驗證**:卡片產出之後、寫進知識庫之前,從來沒有任何一步問過
// 「這些宣稱在原文的哪裡」。那個檢查總管當天用 grep 三秒就做完了。
//
// ── 這條線畫在哪(本檔最重要的一段,先讀完再改程式)─────────────────────
//
// ❌ **不是「卡片不准出現原文沒有的字」。** 摘要、換句話說、把 EARS 句翻成人話、
// 把英文原稿寫成正體中文卡——**全部是正當的加工**,而且是本產品的價值本身。
// 真的照抄反而是 H6 在擋的病(D16 精耕非 RAG)。
//
// ✅ **要抓的是「憑空多出來的事實宣稱」。** 判準:
//
// **改寫改的是「怎麼說」,編造加的是「在說什麼」。**
//
// 一句話的措辭可以整個換掉,但它不會憑空長出一個新的**具體指稱**——
// 一個專名、一個型號、一個數量。所以本檔只檢查「**翻譯與改寫都帶不走的錨點**」:
//
// 錨點① 拉丁字母串(WindowsmacOSLinuxCONSTITUTION.mdOAuth2…)
// ——這是**指稱**不是措辭。原文若真的提到它,它會**逐字**在原文裡。
// 錨點② 兩位數以上的數字(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.mdOAuth2macOSv1.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 >= '' && r <= '':
b.WriteRune('0' + (r - ''))
case r >= '' && r <= '':
b.WriteRune('a' + (r - ''))
case r >= '' && r <= '':
b.WriteRune('a' + (r - ''))
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:不當指稱看的拉丁串。
// 只收**卡片格式自己會產生**或**中文行文的通用連接詞**——不收任何領域名詞
// APIJSON/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
}
+245
View File
@@ -0,0 +1,245 @@
// 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())
}
}
+31 -2
View File
@@ -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/H8InkStoneCo#442026-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.goInkStoneCo#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 的硬標準,且有兩個既有測試
//TestLintH6LengthCapSoftTestLintH6OverlapSoft)就寫在這個行為上
// ⇒ 屬規格層變更,依 SDD 生命週期鐵律第 3 條走提案、不自行改。
limit := h6MaxCardRunes
if byRatio := int(float64(srcRunes) * h6SrcRatio); byRatio < limit {
limit = byRatio
+24 -8
View File
@@ -280,20 +280,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)
}
@@ -312,7 +328,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
+27
View File
@@ -0,0 +1,27 @@
---
tags: [權限, 稽核, 外掛]
gloss: 未取得授權就不得讀取,且每次拒絕都要留痕、重開時重驗
created: 2026-08-16
updated: 2026-08-16
---
# 存取控管政策
← [[00-INDEX]]
## 摘要
這份政策把「能不能看」這件事拆成三個時間點來管:讀取當下要先確認眼前這個人有沒有被授權、被擋下來的那一刻要留下痕跡讓管理者事後查得到、以及工作區重新開啟時不沿用上一次的判斷而是重新確認一遍。
## 重點
- 眼前這位使用者尚未被授予權限時,外掛不得取用筆記內容
- 每一次被擋下來都要留痕,讓管理者事後查得到
- 工作區重新開啟時不沿用舊判斷,權限要重新確認
## 實體
- **外掛**(工具)— 受本政策約束的程式
- **管理者**(人物)— 事後查閱留痕的人
## 關聯
### 內文知識關係
### 卡片關係
### 出處
- `../access.md` >> 提及 >> 存取控管政策
+26
View File
@@ -0,0 +1,26 @@
---
tags: [交件, 複核, 流程]
gloss: 遞交前需第二人複核,事後更動一律重跑審查
created: 2026-08-16
updated: 2026-08-16
---
# 交件規範
← [[00-INDEX]]
## 摘要
這條規範把責任放在「交出去之前」與「交出去之後」兩端:前端要求文稿不能由撰寫者一個人決定完成,必須有第二雙眼睛過目;後端則規定既已遞交的內容不存在小修捷徑,任何更動都退回流程起點重新跑一輪。
## 重點
- 文稿不得由撰寫者獨自定案,須經第二人過目才可遞交
- 已遞交的內容若有更動,必須退回起點重跑審查流程
## 實體
- **文稿**(檔案)— 受本規範約束的產出
- **第二人**(人物)— 遞交前負責過目的另一位同事
## 關聯
### 內文知識關係
### 卡片關係
### 出處
- `../delivery.md` >> 提及 >> 交件規範
+28
View File
@@ -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)
+32
View File
@@ -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)
+5
View File
@@ -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.
+4
View File
@@ -0,0 +1,4 @@
# 交件規範
- 每份文件在送出之前,必須由另一位同仁看過一遍。
- 送出之後若要修改,一律重新走一次送審。
+8
View File
@@ -0,0 +1,8 @@
# Requirements (Logseq-plugin)
## Constraints
> [!NOTE]
> Inherited from root CONSTITUTION.md
## Functional Requirements
- While <User uses Logseq>, When <Plugin activated>, the <System> shall <Enhance functionality>.