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

522 lines
19 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.
// lint.go — daemon 端知識卡品質檢查(B2 萃取品質驗收標準)。
//
// 真相源=InkStoneCo 頂層卷 `system-dev/docs/3-specs/daemon-beta/b2-quality-standard-draft.md`
// (§1 硬標準 H1H6、§6 總管自裁 2026-07-26 commit 39bbdd2T1-T5 定案+施工順序)。
//
// 落點(草案 §3 第一層「品質迴路」):插在 extract 之後、POST rag_ingest_card 之前。
// 手上有原稿(H6 相似度只有這裡能做);不過的分級依 §6 裁決 T4-C:
// - 硬缺(H1 段名/H2 一句話定義/H5 機敏值)=結構壞或洩密 → 拒收(不 POST)。
// - 軟項(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 天花板);
// H7 只認「翻譯與改寫帶不走的錨點」(拉丁專名、兩位數以上的數字),純中文的憑空宣稱抓不到。
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 // 原稿內容;空=跳過 H6rag_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)) }
// ── 新格式(InkStoneCo#44 ④,2026-08-15):wikishape.go 產的規範形卡 ──
// frontmattertags/gloss/created/updated)+「## 摘要/## 重點/## 實體/## 關聯」。
// 舊四段格式(一句話定義/要點/關鍵實體/關聯)仍由 workers-ai 雲端路產出,雙軌並存:
// 雲端 prompt 升級歸 matrix/arcrun(B 類),在那之前這裡不能把舊卡全打成硬缺。
const (
nsecSum = "摘要"
nsecPts = "重點"
nsecEnt = "實體"
nsecRel = "關聯"
)
var glossRe = regexp.MustCompile(`(?m)^gloss:\s*(.*)$`)
// isWikiShapeCard 認新格式:frontmatter 開頭且帶 gloss。
func isWikiShapeCard(card string) bool {
return strings.HasPrefix(card, "---\n") && glossRe.MatchString(card)
}
// lintWikiShapeCard 對新格式卡跑等價的 H1–H6(代號沿用,回報端不用改)。
func lintWikiShapeCard(card string, opts LintOptions) LintResult {
var r LintResult
secs := parseSections(card)
// H1 四段齊且順序對(硬)。
required := []string{nsecSum, nsecPts, nsecEnt, nsecRel}
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[nsecSum] < idx[nsecPts] && idx[nsecPts] < idx[nsecEnt] && idx[nsecEnt] < idx[nsecRel]) {
r = r.addHard("H1", "段名順序錯(須依序:摘要→重點→實體→關聯)")
}
// H2 gloss 恰一句、非空(硬);過長軟提醒。
if m := glossRe.FindStringSubmatch(card); m != nil {
g := strings.TrimSpace(m[1])
switch {
case g == "":
r = r.addHard("H2", "frontmatter 的 gloss 是空的")
case runeLen(g) > defMaxRunes:
r = r.addSoft("H2", "gloss "+itoa(runeLen(g))+" 全形字(建議 ≤80,過長像小作文)")
}
} else {
r = r.addHard("H2", "frontmatter 缺 gloss")
}
// H3 重點條數(軟):原子卡自然落在 1–8,超過 12 是沒消化的地毯複製。
if pts, ok := sectionByName(secs, nsecPts); ok {
bullets := 0
for _, l := range nonEmptyLines(pts.lines) {
if bulletRe.MatchString(l) {
bullets++
}
}
if bullets < 1 || bullets > 12 {
r = r.addSoft("H3", "重點 "+itoa(bullets)+" 條(應 112 條)")
}
}
// H4 三元組行形(軟):每行恰三項,述詞裡不得再夾分隔符。
if rel, ok := sectionByName(secs, nsecRel); ok {
for _, l := range nonEmptyLines(rel.lines) {
if !strings.HasPrefix(strings.TrimSpace(l), "- ") {
continue
}
if strings.Count(l, triSep) > 2 {
r = r.addSoft("H4", "三元組不是三項(述詞裡夾了分隔符):"+trimForMsg(l))
}
}
}
// H5 機敏值(硬)與 H6 相似度(軟):與舊格式同一套。
if hits := scanSecrets(card); len(hits) > 0 {
r = r.addHard("H5", "疑似機敏值:"+strings.Join(hits, "、")+"(依規約該段應改寫成描述,不得照抄)")
}
if opts.Source != "" {
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
}
// LintCard 對一張 daemon 形知識卡跑 H1–H6,回傳分級結果(B2 §1+§6 T4-C)。
// 新格式(wikishape)與舊格式(workers-ai 雲端路)雙軌:先認格式再挑對應規則。
func LintCard(card string, opts LintOptions) LintResult {
if isWikiShapeCard(card) {
return lintWikiShapeCard(card, opts)
}
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)+" 條合法三元組(應 38 條)")
}
}
// ── H5 不含機敏值(硬)───────────────────────────────────────
if hits := scanSecrets(card); len(hits) > 0 {
r = r.addHard("H5", "疑似機敏值:"+strings.Join(hits, "、")+"(依規約該段應改寫成描述,不得照抄)")
}
// ── 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
}
// 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%)——防「萃取失敗就整檔照抄」。
//
// ⚠️ **已知偽陽(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
}
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
}