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 零命中
- 邊界:跨語言(英→中)與同語言整段換詞兩組合理改寫 → 零誤殺
This commit is contained in:
+320
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
---
|
||||
tags: [權限, 稽核, 外掛]
|
||||
gloss: 未取得授權就不得讀取,且每次拒絕都要留痕、重開時重驗
|
||||
created: 2026-08-16
|
||||
updated: 2026-08-16
|
||||
---
|
||||
# 存取控管政策
|
||||
|
||||
← [[00-INDEX]]
|
||||
|
||||
## 摘要
|
||||
這份政策把「能不能看」這件事拆成三個時間點來管:讀取當下要先確認眼前這個人有沒有被授權、被擋下來的那一刻要留下痕跡讓管理者事後查得到、以及工作區重新開啟時不沿用上一次的判斷而是重新確認一遍。
|
||||
|
||||
## 重點
|
||||
- 眼前這位使用者尚未被授予權限時,外掛不得取用筆記內容
|
||||
- 每一次被擋下來都要留痕,讓管理者事後查得到
|
||||
- 工作區重新開啟時不沿用舊判斷,權限要重新確認
|
||||
|
||||
## 實體
|
||||
- **外掛**(工具)— 受本政策約束的程式
|
||||
- **管理者**(人物)— 事後查閱留痕的人
|
||||
|
||||
## 關聯
|
||||
### 內文知識關係
|
||||
### 卡片關係
|
||||
### 出處
|
||||
- `../access.md` >> 提及 >> 存取控管政策
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
---
|
||||
tags: [交件, 複核, 流程]
|
||||
gloss: 遞交前需第二人複核,事後更動一律重跑審查
|
||||
created: 2026-08-16
|
||||
updated: 2026-08-16
|
||||
---
|
||||
# 交件規範
|
||||
|
||||
← [[00-INDEX]]
|
||||
|
||||
## 摘要
|
||||
這條規範把責任放在「交出去之前」與「交出去之後」兩端:前端要求文稿不能由撰寫者一個人決定完成,必須有第二雙眼睛過目;後端則規定既已遞交的內容不存在小修捷徑,任何更動都退回流程起點重新跑一輪。
|
||||
|
||||
## 重點
|
||||
- 文稿不得由撰寫者獨自定案,須經第二人過目才可遞交
|
||||
- 已遞交的內容若有更動,必須退回起點重跑審查流程
|
||||
|
||||
## 實體
|
||||
- **文稿**(檔案)— 受本規範約束的產出
|
||||
- **第二人**(人物)— 遞交前負責過目的另一位同事
|
||||
|
||||
## 關聯
|
||||
### 內文知識關係
|
||||
### 卡片關係
|
||||
### 出處
|
||||
- `../delivery.md` >> 提及 >> 交件規範
|
||||
+28
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,4 @@
|
||||
# 交件規範
|
||||
|
||||
- 每份文件在送出之前,必須由另一位同仁看過一遍。
|
||||
- 送出之後若要修改,一律重新走一次送審。
|
||||
+8
@@ -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>.
|
||||
Reference in New Issue
Block a user