Wiki 内容摄入管道:架构设计与工程实践

背景

个人知识库的内容来源分散在 RSS、书签、社交网络时间线。之前的内容归档方式是"手动复制粘贴到编辑器",效率低且质量不可控。

目标:构建一条自动化的 web → wiki 摄入管道,满足:

  • 从 Inbox(URL 列表)自动抓取网页内容
  • AI 分类并归入正确的知识库主题
  • 以结构化 Markdown 写入 wiki
  • 整个过程可观测、可审计、失败可回溯

1. Pipeline 全景

flowchart LR
    INBOX[Inbox.md<br/>URL List] --> PARSE[Parse Inbox<br/>goldmark + xurls]
    PARSE --> SCRAPE[Scrape Layer<br/>Content Acquisition]
    SCRAPE --> EXTRACT[Extract Layer<br/>AI Classification]
    EXTRACT --> ARCHIVE[Archive Layer<br/>Persistence]
    ARCHIVE --> AUDIT[Audit Layer<br/>Validation]

    style INBOX fill:#f9f,stroke:#333
    style PARSE fill:#bbf,stroke:#333
    style SCRAPE fill:#f96,stroke:#333
    style EXTRACT fill:#69f,stroke:#333
    style ARCHIVE fill:#6c9,stroke:#333
    style AUDIT fill:#fc6,stroke:#333

每层职责:

输入 输出 核心关注点
Parse Inbox.md 原始行 去重 URL 列表 精确 URL 提取、Markdown 链接解析、行索引追踪
Scrape URL 洗净的正文(含视频字幕) 三层降级抓取、内容质量判定、媒体转录
Extract 正文 + URL + 标题 结构化分类结果(topic/type/summary) 单次 AI 调用、候选白名单校验、JSON schema 约束
Archive 分类结果 summary.md / failed-*.md 两阶段并发模型、per-path 写锁、失败分类收集
Audit wiki 目录 问题列表 全量/增量扫描、CI 友好

2. Scrape 层 —— 三层降级内容获取

核心问题:网页内容不可靠。HTTP 200 不意味着拿到了可读文章——可能是登录壳、验证页、导航壳、空壳 SPA。必须有一套系统性的质量判定流程,而不是"拿到 HTML 就直接喂给 AI"。

架构

flowchart TD
    URL[URL Input] --> CT{Content Type Detection}

    CT -->|text/html<br/>default path| HTTP[HTTP GET<br/>timeout: 30s]
    HTTP -->|network err| RETRY{Retry?<br/>max 3 attempts<br/>5s backoff}
    RETRY -->|yes| HTTP
    RETRY -->|exhausted| FALLBACK{OpenCLI<br/>enabled?}

    HTTP -->|HTTP 200| READ{readability<br/>extractable?}

    READ -->|yes| QG[Quality Gate<br/>assessContentQuality]
    READ -->|no body| GQ[goquery → Markdown<br/>整页降级]

    GQ --> QG

    QG -->|pass| DONE[✅ Clean 正文]
    QG -->|fail| FALLBACK

    FALLBACK -->|yes| OC[opencli web read<br/>timeout: 45s]
    FALLBACK -->|no| FAIL[❌ fetch-failed]

    OC -->|success| QG2[Quality Gate<br/>再次判定]
    QG2 -->|pass| DONE
    QG2 -->|fail| FAIL
    OC -->|fail| FAIL

    CT -->|video<br/>youtube/bilibili| YT[yt-dlp --dump-json<br/>--skip-download]
    YT --> SUB[Subtitle Language Match<br/>优先级: zh → en → any]
    SUB --> NORM[Subtitle Normalize<br/>SRT/VTT → Plain Text]
    NORM --> DONE

    CT -->|audio<br/>xiaoyuzhou/podcast| RSS[Parse RSS Feed<br/>gofeed]
    RSS --> ITEM[Match Episode<br/>exact URL → transcript signal → fallback]
    ITEM --> TRANS[Transcript Pipeline<br/>RSS tag → description link → fallback]
    TRANS --> NORM

路由逻辑

FetchContent 的调用入口 service/wiki/fetch.go:94 按优先级路由:

  1. GitHub 仓库 — 如果 urlutil.GitHubOwnerRepo(urlStr) 返回 true,走 GitHub API (Repositories.Get),返回 stars/language/license/topics/description 等结构化元数据,不经过网页抓取
  2. 视频 URL — isVideoURL() 检测 YouTube/Bilibili/Shorts,进入 yt-dlp 字幕管道
  3. 音频/播客 URL — isPodcastLikeURL() 检测小宇宙/podcast/RSS feed,进入 RSS transcript 管道
  4. 默认 — 普通网页,走三层降级 HTTP → quality gate → opencli

三层降级细节

降级级别 触发条件 实现 典型场景
一级 默认路径 Readability.js(Go binding go-readability)提取正文 博客、新闻、技术文章(覆盖 80%+)
二级 Readability 返回 nil 或空正文 goquery 全文 .ToMarkdown()textutil.TruncateUTF8(body, MaxBodySize) 文档站、API 文档、非标准 HTML
三级 Quality Gate 判定不通过 且 OpenCLI 启用 exec.CommandContext(ctx, "opencli", "web", "read", rawURL) 浏览器驱动获取 SPA、需 JS 渲染、Twitter/知乎反爬

Quality Gate 判定

assessContentQuality() 是一个多层判定函数,返回 contentQuality{OK bool, Reason string}。判定链路:

第 1 层:空内容。body 为空白直接拒绝。

第 2 层:18 条低质量关键词匹配lowQualityPatterns() 的规则集分组:

类型 示例关键词
JS 要求 "this page requires javascript", "javascript is not available", "enable javascript", "please enable js"
登录壳 "please log in", "log in to continue", "sign in to continue", "sign up for"
访问拒绝 "access denied", "forbidden", "captcha", "checking your browser", "just a moment"
错误页 "400 bad request", "404 not found", "page not found", "something went wrong"
占位符 "video content requires manual review"

关键词统一 strings.ToLower 后匹配,大小写不敏感。

第 3 层:社交媒体登录壳检测。对 x.com/twitter/instagram 域名,检查正文是否包含 "log in" / "sign up" / "javascript" 且正文长度 < 500 rune——这种往往是未登录时的空壳页面。

第 4 层:内容长度门禁。正文中文长度 < 120 rune 直接拒绝——Readability 或 goquery 可能只提取到了导航条。

第 5 层:链接密度检测isLinkHeavy() 统计正文中 Markdown 链接和裸 URL 数量,如果 links * 80 > textLen(超过 80 个字符一个链接),判定为导航壳——这种页面没有一个链接指向自己,全是目录/聚合/导航页。

视频字幕管道细节

yt-dlp 命令行调用(fetch.go:228):

yt-dlp --ignore-config --dump-json --skip-download --no-warnings <URL>
  • --dump-json 输出 ytdlpMetadata 完整 JSON(包含 subtitlesautomatic_captionstitledescription
  • --skip-download 只取元数据,不下载视频文件
  • 从 JSON 的 SubtitlesAutomaticCaptions 两个字段中按语言优先级选取字幕 URL

语言匹配策略(videoSubtitleLangs):

  • Bilibili URL 或 metadata 检测到中文特征时:["zh-Hans", "zh-CN", "zh", "zh-Hant", "en"]
  • 其他:["en", "en-US", "en-GB", "zh-Hans", "en"]
  • langMatches() 函数做模糊匹配,比如 zh-CN 匹配 zhen-US 匹配 en

字幕获取后通过 transcript.NormalizeContent() 将 SRT/VTT 格式归一化为纯文本,然后嵌入一个 wrapper 前缀:

Title: <title>
URL: <url>
Transcript source: subtitle:manual/<auto>:<lang>

<normalized text>

媒体内容的 MaxBodySize 是普通网页的 4 倍(默认 20,000 rune vs 5,000 byte),因为字幕内容天然更长。

播客音频管道细节

对播客 URL 的 RSS feed 解析策略(fetch.go:424):

  1. 首选精确匹配 — 遍历 RSS items,按 item.Linkitem.GUID 与原始 URL 精确匹配的 episode
  2. 次选 transcript 信号hasTranscriptSignal() 检测:是否有 podcast:transcript 标签,或 description/content 包含 "transcript" 关键词
  3. 兜底 — 全部 RSS items 按顺序尝试

transcript 获取通过 transcript.NewPipeline(providers...) 串联多个 Provider:

  • RssTranscriptProvider — 提取 RSS 中的 podcast:transcript 标签
  • DescriptionLinkProvider — 从 description 文本中提取 transcript 链接

3. Extract 层 —— AI 结构化分类

核心问题:LLM 输出不可控。返回的 topic 路径可能不存在、类型可能乱写、格式可能不是预期结构。这次重构的核心思路是:不给 AI 自由发挥空间,用工程约束限制输出边界

架构

flowchart TD
    INPUT[Body + Title + URL<br/>+ ContentType] --> CANIDATES[Load Topic Candidates<br/>从 gh.yml 生成<br/>80~120 paths]
    CANIDATES --> PROMPT[rendered prompt<br/>classify-json.txt]

    INPUT --> PROMPT
    PROMPT --> AI[1x LLM Call<br/>一次性输出结构化 JSON]
    AI --> RAW[JSON Response]

    RAW --> PARSE{parseAIClassification<br/>strip code fence +<br/>json.Unmarshal}

    PARSE -->|Unmarshal 失败| REPAIR{repairInvalidJSON<br/>Escape 修复}
    REPAIR -->|成功| PARSE
    REPAIR -->|仍然失败| C_FAIL[❌ classify-failed<br/>group-failed.md]

    PARSE -->|valid JSON| BASICS{validateBasics<br/>confidence >= 0.45?<br/>type/contentType 合法?<br/>needsManualReview?}

    BASICS -->|rejected| C_FAIL

    BASICS -->|passed| TOPIC{validateTopic<br/>topicPath 在<br/>candidate set 中?}

    TOPIC -->|no| FALLBACK[↩ inbox<br/>needsManualReview=true]

    TOPIC -->|yes| SUMMARY{validateSummary<br/>overview/keyPoints<br/>非空?}

    SUMMARY -->|empty| C_FAIL
    SUMMARY -->|valid| DONE[✅ Archive-Ready<br/>topicPath + wikiType +<br/>summary + metadata]

从 3 次调用到 1 次

这是成本和质量双重提升的关键决策。

之前:每 URL 3 次独立 AI 调用:

classify-topic.txt   → topicPath
classify-type.txt    → wikiType (repo_eval / deep_dive / inbox)
summarize-text.txt   → summary (自由文本)

问题:3 次调用成本高(3× token),且 topic 和 type 的输出可能矛盾(比如 topic 选了 "golang-conc" 但 type 返回了 "deep_dive",实际上 goclang-conc 是一个 golang 代码仓库分类)。

现在:合并为 1 次调用,prompt 要求输出包含所有字段的完整 JSON:

classify-json.txt    → {
  "topicPath": "...",
  "wikiType": "repo_eval|deep_dive|inbox",
  "contentType": "text|video|audio",
  "summary": {
    "overview": "...",
    "keyPoints": ["...", "..."],
    "actionableAdvice": ["..."],
    "worthNoting": "..."
  },
  "metadata": {
    "contentType": "text|media|repo",
    "tags": ["tag1", "tag2"],
    "quality": "4/5",
    "author": "...",
    "uncertainties": "...",
    "duration": "15:23(仅media)",
    "transcriptQuality": "good(仅media)",
    "verdict": "watch(仅repo)",
    "stars": 2564(仅repo),
    "language": "Python(仅repo)"
  },
  "confidence": 0.0,
  "needsManualReview": false
}

AI 呼叫数减少 66% (3 → 1),且 topic、type、summary、metadata 在同一次推理中做一致性决策,不会出现矛盾。

候选白名单

这是分类质量的最关键措施。ghindex/topic_catalog.go 从 gh.yml 的完整配置树中提取所有 topic path:

ConfigRepos[] → TopicCatalog()
  │
  ├── cfg.Topics           → appendTopicCandidates(base, "gh:config")
  ├── cfg.Using.Topics     → appendTopicCandidates(base, "gh:using")
  └── cfg.Repos[]          → appendRepoTopicCandidates()
        ├── repo.Topics    → appendTopicCandidates(base+repoName, "gh:repo")
        ├── repo.SubRepos[]
        ├── repo.ReplacedRepos[]
        └── repo.RelatedRepos[]
              └── (递归)

canonicalTopicPath() 生成唯一 topicPath,去重存入 []TopicCandidate{Path, Display, Source}。最终约 80-120 个候选路径作为 {{.CandidateTree}} 嵌入 prompt。

Prompt 明确要求:"topicPath must exactly match one of the candidate paths;不要创造新 path"。代码层二次校验:candidatePathSet(candidates)[topicPath] 逐字匹配,不存在的路径直接进入 failure 而非静默创建目录。

JSON 解析容错机制

parseAIClassification() 有三层容错:

  1. Code fence 剥离:自动去除 LLM 经常额外输出的 ```json``` 包装
  2. JSON 对象定位strings.Index(raw, "{") ~ strings.LastIndex(raw, "}"),忽略前后的散装文本
  3. 非法转义修复repairInvalidJSONStringEscapes() 逐个扫描 JSON 字符串,将非法转义序列(如 \1.)自动修复(转义为 \\\\1. 或丢弃)

这个修复器是必要的——因为 content 中的 Markdown 编号 1. 在 JSON 字符串中不是合法转义,LLM 有时不会正确地加双反斜杠。

三级验证

AI 输出通过 JSON 解析后,走三层校验:

第一层:validateBasics — 字段基本约束

  • NeedsManualReview = true 直接拒绝(AI 自己判断到内容不可靠)
  • Confidence < 0.45 直接拒绝(AI 自评分太低)
  • WikiType 必须是枚举值之一(repo_eval / deep_dive / inbox
  • ContentType 如果提供,必须是枚举值(text / video / audio

第二层:validateTopic — 路径安全 + 候选合法性

  • ValidateRelativeWikiPath() 检查路径安全(防止 path traversal)
  • candidatePathSet(candidates)[topicPath] 检查是否在白名单中

第三层:validateSummary — 摘要完整性

  • Summary 不能为 nil
  • renderStructuredSummary() 渲染后不能为空

Prompt 的约束工程

完整 prompt(classify-json.txt)的结构:

## 候选 topic path
(150+ 行候选列表,从 gh.yml 实时生成)

## 待处理条目
URL / 标题 / 内容类型

## 正文内容
(经 truncate 后的正文)

## 分类约束
wikiType 枚举定义、contentType 说明、metadata 字段说明

## 输出要求
完整 JSON schema + 显式枚举值 + 条件约束

关键约束点:

  • "不要为了提高归档率而选择泛化、牵强或仅有弱关联的 topic" —— 控制 false positive
  • "topicPath must exactly match one of the candidate paths;不要创造新 path"
  • "所有字符串值必须是合法 JSON string;不要把 Markdown 编号写成 \1.\2.\3. 这类非法 JSON 转义"
  • 条件字段标注:verdict 仅 repo 类型、duration 仅 media 类型

4. Archive 层 —— 并发安全的写入模式

核心问题:多个 URL 可能归入同一个 topic,如果每个 goroutine 独立写 summary.md,后写的会覆盖先写的。

架构

sequenceDiagram
    participant Main as Main Goroutine
    participant Pool as errgroup Pool (N concurrent)
    participant File as File System

    Main->>Main: ParseInbox → URL entries
    Main->>Main: g.SetLimit(concurrency=5)

    rect rgb(240, 248, 255)
    Note over Pool: Phase 1: Concurrent Prepare
    par entry 0 (goroutine)
        Pool->>Pool: prepareInboxEntry()
        Note over Pool: HTTP fetch + AI classify<br/>per-URL timeout: 180s<br/>retry: 3 attempts, backoff
    and entry 1
        Pool->>Pool: prepareInboxEntry()
    and entry N
        Pool->>Pool: prepareInboxEntry()
    end
    end

    Pool-->>Main: pendingURLWrite slice<br/>(收集结果,不写文件)

    Note over Main: errgroup.Wait()<br/>— 障碍点 —

    rect rgb(255, 248, 240)
    Note over Main: Phase 2: Sequential Commit
    loop For each entry in inbox order
        Main->>File: writePendingURL(entry[i])
        Note over Main: lockPath(summaryPath)<br/>sync.Mutex per path
        File-->>Main: ✅ / ❌
        Note over Main: unlockPath()<br/>defer unlock
    end
    end

    Main->>File: FlushInbox<br/>精确移除已处理 URL

两阶段模型

这是最核心的架构决策。将每条 URL 的处理拆为两个阶段:

阶段 函数 操作 执行方式
Phase 1: prepare prepareInboxEntry HTTP fetch + AI classify 并发 — errgroup pool, g.SetLimit(N)
Phase 2: write writePendingURL 写 summary.md / 写 failed 文件 顺序 — 单 goroutine 循环

为什么不能直接在 goroutine 里写?

同一 topic 的两个 URL 同时处理:

goroutine A: ReadFile summary.md → append entry A → AtomicWrite (OK)
goroutine B: ReadFile summary.md → append entry B → AtomicWrite (overwrites A!)

AtomicWrite 是文件级别的原子操作,但读 → 改 → 写不是原子序列。后完成的 goroutine 会覆盖先完成的成果。

为什么不是 per-goroutine mutex?

同一 topic 的多个 URL 可能分布在不同 goroutine 中(goroutine 0 处理 topic=X 的 URL 5,goroutine 3 也处理 topic=X 的 URL 12),goroutine 之间不共享一个 mutex。如果给每个 topic 路径分配一个全局 mutex(per-path lock 方案),那么需要在 goroutine 之间共享这些锁变量——而这正是 lockPath() 实现的。

双重防护

  1. 架构层:所有并发 goroutine 只做 fetch + classify,不写文件。写文件是顺序执行的,从根本上消除竞争。
  2. 锁层WriteSummaryWriteFailureEntry 内部各有一个 lockPath(path) 调用(sync.Mutex per absolute path),防止未来如果再有其他代码路径绕过了两阶段模型、直接调用写函数时的竞争。

Per-path Lock 实现

var pathLocks = struct {
    locks map[string]*sync.Mutex
    mu    sync.Mutex
}{locks: make(map[string]*sync.Mutex)}

func lockPath(path string) func() {
    key, _ := filepath.Abs(path)

    pathLocks.mu.Lock()
    mu := pathLocks.locks[key]
    if mu == nil {
        mu = &sync.Mutex{}
        pathLocks.locks[key] = mu
    }
    pathLocks.mu.Unlock()

    mu.Lock()
    return mu.Unlock   // 返回解锁函数,defer 调用
}

关键设计:返回 func() 作为解锁闭包,调用方 defer lockPath(path)() 即可,保证成对加解锁。

summary.md 格式

每个 topic 目录下的 summary.md 维护 YAML frontmatter + 多日期区间体:

---
title: golang-conc
date: "2026-06-12"
source: rss2nl-wiki
batch_id: wiki-2026-06-12
total_urls: 5
succeeded: 3
failed: 2
---

## 2026-06-12

### 文章标题

```markdown
URL: https://example.com/article
Type: deep_dive
tags: go, concurrency
quality: 4/5
author: John Doe

body content...

另一个条目

...


`WriteSummary` 不是覆盖写,而是读现有的 frontmatter、增加计数器、定位到正确的日期区间插入新条目、再写回。用到 `appendEntryBody` 来维护日期区间的顺序。

### 失败分类

不再全部写到一个文件,而是按失败阶段分类:

| 失败类型 | 语义 | 审计文件 |
|---------|------|---------|
| `fetch` | 网络级错误(DNS 解析失败、连接超时) | `fetch-failed.md` |
| `resolve` | HTTP 级错误(403 反爬、Access Denied),opencli 也拿不到 | `resolve-failed.md` |
| `extract` | HTTP 200 但内容不可用(登录壳、JS 空壳、quality gate 判定不通过) | `extract-failed.md` |
| `classify` | 内容可读但 AI 无法分类(confidence 不足、无匹配 topic、JSON 解析失败) | `group-failed.md` |

fetch vs resolve 的区分逻辑在 `fetchHTTPPage` 末尾:`isHTTPBlockError(err)` 检查 error 是否包含 "HTTP " 字符串——是则说明是 HTTP 状态码错误(403/404),归为 resolve;否则是网络层错误(超时/DNS),归为 fetch。

### Inbox Flush 精确移除

Inbox 清洗不再是按行索引删除整行。新实现(`service/wiki/write.go:FlushInbox`):

1. `parseInboxLineURLRefs()` 解析行内所有 URL 引用及其 `[start, end)` 位置索引(同时处理 Markdown 链接 `[text](url)` 和裸 URL)
2. `normalizedURLSet(handledURLsByLine[i])` 构建本行已处理 URL 的规范化集合
3. `flushInboxLine()` 只移除已处理的 URL,保留未处理的部分(slice `line[:ref.Start] + line[ref.End:]`)
4. 整行为空时才删除该行

这样,一行有两个 URL 的场景下,一个成功、一个失败时,失败 URL 保留,不会被误删。

---

## 5. Audit 层 —— 事后校验

Audit 子系统是 pipeline 之外的校验层,检测 wiki 内容的历史污染。

### 扫描方式
- `AuditWiki(wikiRoot)` — WalkDir 整个 wiki,全量扫描
- `AuditWikiPaths(wikiRoot, paths)` — 只扫描指定路径,配合 ChangedOnly 模式使用
- ChangedOnly 模式通过 `changedWikiMarkdownPaths()` 用 git 命令定位变更文件:

git diff --name-only --cached (staged changes) git diff --name-only (unstaged changes) git ls-files --others --exclude-standard (untracked files)


### 检查项
对每个 Markdown 文件做规格检查:
- **summary.md 检查**:section heading 是否使用了规范化标题(`概述`/`关键要点`/`可执行建议`/`值得关注`),codeblock 字段名是否合法
- **失败文件检查**:failure 文件中的 URL 格式是否正常,是否有截断/编码问题
- **General 检查**:文件读取错误、walk 错误

---

## 6. 配置与参数

通过 `data-cli` 和 `internal/wikiingest/wiki.go` 的 `WikiConfig` 控制全部参数:

| 参数 | 默认值 | 说明 |
|------|--------|------|
| `wikiRoot` | `wiki` | wiki 根目录 |
| `ghTopicsURL` | `https://cdn.lucc.dev/gh.yml` | 远端 topic 候选来源 |
| `ghTopicsMaxAge` | `24h` | 远端 gh.yml 缓存 TTL |
| `concurrency` | `5` | 并行 goroutine 数量 |
| `perURLTimeout` | `180s` | 单 URL 处理超时(含重试) |
| `maxRetries` | `3` | 失败重试次数 |
| `opencliFallback` | `true` | 是否启用 opencli 浏览器兜底 |
| `media.enabled` | `true` | 是否启用视频/音频抓取 |
| `media.subtitleCLIPath` | `yt-dlp` | 字幕提取 CLI 路径 |
| `media.subtitleLangs` | (自动) | 字幕语言优先级列表 |

Retry 行为:

```go
retry.Do(
  func() error { ... },
  retry.Attempts(maxRetries),     // 默认 3
  retry.Delay(5 * time.Second),   // 固定 5s
  retry.DelayType(BackOffDelay),  // 回退模式
  retry.RetryIf(func(err error) bool {
      // 只重试 fetch 层错误,不重试 classify 错误
      return errors.As(err, &fetchFailureError{})
  }),
)

7. 实跑结果

85 条真实 inbox(来自 RSS + 书签 + 社交分享):

指标 数值
总计 85
✅ 成功写入 50
❌ Handled failure 35
❌ Unhandled error 0

失败分布:

类型 数量 典型原因
extract 12 登录壳、导航壳、错误页被 quality gate 判定拦截
classify 10 视频无字幕、内容过短、confidence 不足
fetch 8 网络超时、DNS 解析失败
resolve 5 403 反爬(opencli 也拿不到)

关键指标:0 条泄漏,没有一条污染 wiki 的错误内容写入了 summary。分类错误页在 prompt 层(quality gate 判定)被拦截,拓扑路径不在白名单的在 validate 层被拦截,confidence 不足的在 validateBasics 层被降级。


8. 总结

这条管道的不同之处在于,AI 不是 pipeline 的终点,而只是 extract 层中的一个组件。真正的架构重心在:

  1. Scrape 层的三层降级 + 五层质量判定 —— 用 engineering 解决"网页不可靠"问题,而不是依赖 AI 去判断。质量判定(空内容检测 → 关键词匹配 → 社交壳检测 → 长度门禁 → 链接密度分析)在所有 AI 调用之前拦截低质量内容。

  2. Extract 层的前置白名单 + 三级验证 —— 不给 AI 自由发挥的空间,从 prompt 约束到 JSON schema 校验到 candidate path 逐字匹配到 confidence 阈值,全程对 AI 输出设防。

  3. Archive 层的两阶段模型 —— 把"并发抓取"和"顺序写入"拆开,从架构层面消除竞争。per-path lock 提供额外的防御深度。

  4. 失败分类 —— 不吞错误、不混为一谈。fetch/resolve/extract/classify 四类失败各自有独立的审计文件,便于回溯排查。