Phase 1

MOSS v2 子集:技术架构与数据模型

MOSS v2 子集:技术架构与数据模型

Context

MOSS 当前是一个书签校准工具(3 张表、1 个页面、996 条 X Bookmarks)。目标是将其转变为 v2 原型中展示的个人上下文工作台的子集:Today + Knowledge + Governance 三个视图,由 声明式规则引擎 驱动信号生成,数据来源仍为文件导入。

当前代码与 v2 原型的核心差距不是"缺页面",而是缺数据模型(信号、审计事件)和缺计算层(规则引擎)。此外,原 bookmarks 表完全绑定 X 书签,不满足多源内容接入需求,需重构为通用 content_items 表。


一、数据模型

重构:bookmarkscontent_items(通用内容表)

bookmarks 表的所有字段都是 X 书签专用(author_handleposted_at X 格式、category 是文件名),无法复用于 RSS、Pocket、浏览器书签等其他内容源。重构为通用模型,X 特有字段移入 JSON metadata 列。

CREATE TABLE IF NOT EXISTS content_items (
  id TEXT PRIMARY KEY,              -- MOSS 内部 ID;新数据统一使用 UUID
  source TEXT NOT NULL,             -- 'x_bookmark' | 'rss' | 'pocket' | ...
  source_id TEXT NOT NULL,          -- 源系统中的原始 ID;本地内容使用内部 ID
  domain TEXT NOT NULL DEFAULT 'knowledge',  -- 6 域之一
  title TEXT NOT NULL,
  body TEXT NOT NULL DEFAULT '',    -- 原 text 字段
  url TEXT,
  tags TEXT NOT NULL DEFAULT '[]',  -- JSON array,通用标签
  metadata TEXT NOT NULL DEFAULT '{}', -- 源专属字段(JSON object)
  created_at TEXT NOT NULL,         -- ISO 8601,原始创建时间(原 posted_at)
  imported_at TEXT NOT NULL,        -- ISO 8601,导入 MOSS 时间
  UNIQUE(source, source_id)
);

CREATE INDEX IF NOT EXISTS content_items_source_idx ON content_items(source);
CREATE INDEX IF NOT EXISTS content_items_domain_idx ON content_items(domain);
-- X 书签专用表达式索引:
CREATE INDEX IF NOT EXISTS content_items_author_idx
  ON content_items(json_extract(metadata, '$.author_handle'))
  WHERE source = 'x_bookmark';
CREATE INDEX IF NOT EXISTS content_items_category_idx
  ON content_items(json_extract(metadata, '$.category'))
  WHERE source = 'x_bookmark';

X 书签的 metadata 示例:

{
  "author_name": "Tom Huang",
  "author_handle": "tuturetom",
  "category": "01-ai-agents-and-coding",
  "category_label": "AI、Agent 与编程",
  "ordinal": 1
}

source_id 是连接器契约的一部分,必须在同一来源内稳定:

  • 有原生 ID 时直接使用,例如 X snowflake、Pocket item ID、RSS GUID。
  • 没有原生 ID 时使用规范化 URL 的确定性哈希。
  • 本地创建内容使用内部 id 作为 source_id
  • 禁止为每次导入随机生成 source_id,否则无法保证幂等。

URL fallback 的规范化契约必须固定并带版本:使用 URL 解析器,将 scheme 和 host 转为小写、删除 fragment 和默认端口、空 path 归一为 /、query 参数按 key/value 稳定排序;默认保留所有 query 参数,只有源连接器明确声明时才移除跟踪参数。source_id 使用带算法/版本前缀的 SHA-256,例如 url:v1:sha256:<hex>。规范化规则变更视为数据迁移,禁止静默修改。

字段迁移映射

bookmarks (旧) content_items (新)
id TEXT PK id TEXT PK(迁移时保留旧值,新增内容使用 UUID)
- source TEXT('x_bookmark')
- source_id TEXT(= id)
- domain TEXT('knowledge')
title TEXT title TEXT
text TEXT body TEXT
url TEXT url TEXT
- tags TEXT('[]')
ordinal INTEGER metadata.ordinal
author_name TEXT metadata.author_name
author_handle TEXT metadata.author_handle
posted_at TEXT(X 格式) created_at TEXT(ISO 8601)
category TEXT metadata.category
category_label TEXT metadata.category_label
imported_at TEXT imported_at TEXT

更新:feedback_events(FK 变更)

CREATE TABLE IF NOT EXISTS feedback_events (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  content_id TEXT NOT NULL REFERENCES content_items(id) ON DELETE CASCADE,  -- 原 bookmark_id
  value TEXT NOT NULL,
  created_at TEXT NOT NULL
);

CREATE INDEX IF NOT EXISTS feedback_content_idx ON feedback_events(content_id, created_at DESC);

保留:import_runs(不变)

已通用,无需改动。

新增的表

signal_runs - 信号生成批次(类似 import_runs

CREATE TABLE IF NOT EXISTS signal_runs (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  generation_key TEXT NOT NULL UNIQUE,
  base_key TEXT NOT NULL,
  input_version TEXT NOT NULL,
  rule_version TEXT NOT NULL,
  status TEXT NOT NULL CHECK(status IN ('active', 'superseded')),
  rule_count INTEGER NOT NULL,
  signal_count INTEGER NOT NULL,
  created_at TEXT NOT NULL,
  activated_at TEXT
);

CREATE UNIQUE INDEX IF NOT EXISTS signal_runs_active_idx
  ON signal_runs(status) WHERE status = 'active';

signals - 规则引擎输出,每次运行重新生成

CREATE TABLE IF NOT EXISTS signals (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  run_id INTEGER NOT NULL REFERENCES signal_runs(id) ON DELETE CASCADE,
  rule_id TEXT NOT NULL,
  domain TEXT NOT NULL,
  title TEXT NOT NULL,
  body TEXT NOT NULL,
  urgency TEXT NOT NULL DEFAULT 'normal',
  tags TEXT NOT NULL DEFAULT '[]',
  metadata TEXT NOT NULL DEFAULT '{}',
  created_at TEXT NOT NULL
);

audit_events - 系统操作日志

CREATE TABLE IF NOT EXISTS audit_events (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  kind TEXT NOT NULL,
  title TEXT NOT NULL,
  body TEXT NOT NULL DEFAULT '',
  ref_type TEXT,
  ref_id TEXT,
  created_at TEXT NOT NULL
);

设计决策

决策 结论 原因
bookmarks 是否通用化 重构为 content_items 原表完全绑定 X 书签,无法复用于 RSS/Pocket 等多源内容;source + metadata JSON 实现通用
X 特有字段(author/category)放哪 metadata JSON 非通用字段;json_extract() + 表达式索引保障查询性能
PK 策略 内部 ID 与源 ID 分离 id 是 MOSS 内部身份;UNIQUE(source, source_id) 负责幂等导入,避免不同来源的原始 ID 相互覆盖
text 改名 body 避免 SQL 保留字冲突,语义更通用
posted_at 改名 created_at + ISO 8601 通用:原始创建时间,不限于"发帖时间"
bookmark_id 改名 content_id feedback_events FK 跟随内容表重命名
信号持久化还是内存计算 持久化 审计可追溯、Today 视图需要稳定 ID、SSR 快速读取
审计事件独立表还是复用已有表 独立 审计覆盖所有操作类型,不只是导入和反馈

二、规则引擎

架构

声明式规则定义为 TypeScript 模块,每条规则是一个实现 Rule 接口的对象。引擎在 SSR 时按需执行;generation key 未变化时读取 active 缓存,输入或规则变化时安全发布新批次。

src/lib/rules/
  types.ts                        - Rule / Signal / RuleContext 类型
  index.ts                        - evaluateRules() + runSignalEngine()
  uncalibrated-categories.ts      - 类别反馈覆盖率低
  stale-import.ts                 - 导入数据过期
  feedback-coverage.ts            - 整体校准进度
  author-review.ts                - 高频作者无正向反馈
  category-skew.ts                - 类别分布偏斜

Rule 接口

type Rule = {
  id: string;
  name: string;
  evaluate: (ctx: RuleContext) => Signal[];
};

type RuleContext = {
  db: Database.Database;
  now: Date;
  latestImport: ImportRun | null;
};

type Signal = {
  ruleId: string;
  domain: 'knowledge' | 'action' | 'context' | 'self' | 'asset' | 'governance';
  title: string;
  body: string;
  urgency: 'normal' | 'warn' | 'danger';
  tags: string[];
  metadata: Record<string, unknown>;
};

初始 5 条规则

规则 逻辑 示例信号
uncalibrated-categories knowledge json_extract(metadata, '$.category') 聚合,类别 >20 条但反馈 <10% "AI 类别有 365 条内容但仅 1 条有反馈"
stale-import knowledge 最近导入超过 7/30 天 "距上次导入已 14 天"
feedback-coverage governance COUNT(DISTINCT content_id) / COUNT(content_items) "校准进度:0.2%(2/996)"
author-review knowledge json_extract(metadata, '$.author_handle') 聚合,≥5 条无正向反馈 "@example 有 12 条内容但无正向反馈"
category-skew governance json_extract(metadata, '$.category') 占比 >30% "AI 类别占比 36.6%"

执行策略

  1. 计算 base_key:本地日期、最近 import_run.id、最近 feedback_event.id 和规则版本
  2. 普通访问时,若 active run 的 base_key 一致 → 直接读取当前信号;即使该批次来自之前的强制刷新也必须命中
  3. 强制刷新或 base_key 变化时,先在内存中完成全部规则评估,不删除当前 active run
  4. 在单个事务中写入新 run 与 signals、将旧 run 标记为 superseded、激活新 run 并写审计事件
  5. 任一步失败则回滚,旧 active run 保持可用
  6. 强制刷新只使用新 nonce 形成唯一 generation_key,不改变 base_key,也不预先删除稳定结果

generation_key 由以下信息稳定生成:

base_key = localDate + latestImportRunId + latestFeedbackEventId + ruleVersion
generation_key = base_key + refreshNonce

因此导入和反馈会自动使缓存失效,不需要等到 UTC 翻日;强制刷新后的下一次普通访问仍会命中刚发布的 active 批次。


三、路由与数据流

从单页面改为路由组

src/app/
  (workbench)/
    layout.tsx          - 共享壳:Sidebar + main + Inspector
    page.tsx            - 重定向到 /today
    today/page.tsx      - Today 视图 SSR
    knowledge/page.tsx  - Knowledge 视图 SSR
    governance/page.tsx - Governance 视图 SSR
    calibration/page.tsx - 已有校准工作台(从根 page.tsx 迁移)
  api/
    feedback/route.ts   - 已有(字段 bookmarkId → contentId;调用事务化写入)
    signals/
      refresh/route.ts  - POST:通过 nonce 安全发布新批次

各页面数据需求

页面 SSR 查询 传给客户端的数据
/today runSignalEngine() + getLatestSignals() + getDomainCoverage() signals[], coverage[], dayStats
/knowledge getContentFlowStats() + getCategoryBreakdown() contentFlowStats, categories
/governance getAuditEvents(20) + 静态策略数据 auditEvents[], policies[]
/calibration getCalibrationItems() + getProfileSnapshot() + getFeedbackEvents() + getLatestImportRun() 与当前逻辑相同,类型改为 ContentItem

审计事件集成

addFeedback()importContentItems() 的同一数据库事务中追加 audit_events。业务事件与审计事件必须共同提交或共同回滚,API 层不再分两次写入。feedbackLabelscomponents/feedback.tsx 移到 lib/types.ts 共享。


四、组件架构

共享壳

moss-workbench.tsx 提取 Sidebar、Topbar 到独立组件:

src/components/
  shell/
    sidebar.tsx         - 导航侧栏(Today/Knowledge/Governance/校准 + 域列表)
    topbar.tsx          - 面包屑 + 操作按钮
    inspector.tsx       - 右侧上下文面板(数据驱动,各页面传不同内容)
  today/
    hero-section.tsx    - 日期/标题/状态芯片/日概览面板
    focus-stack.tsx     - 优先处理列表(从 signals 中 urgency=warn/danger 提取)
    signal-cards.tsx    - 信号卡片网格
    coverage-meter.tsx  - 六域覆盖度仪表
  knowledge/
    module-grid.tsx     - 5 个模块卡片
    relation-panel.tsx  - 知识网络面板
  governance/
    policy-list.tsx     - 策略卡片(静态数据)
    audit-box.tsx       - 审计事件列表
  feedback.tsx          - 不变
  moss-workbench.tsx    - 重构:去掉壳,只保留校准逻辑,ContentItem 替代 BookmarkItem
  theme-toggle.tsx      - 不变
  ui/                   - 不变

Inspector 数据流

每个页面通过 React Context 设置 Inspector 的默认上下文:

  • Today → 最高优先级信号的详情
  • Knowledge → Knowledge 域概览
  • Governance → 审计/备份概览
  • Calibration → 已有的内容上下文面板

五、迁移方案

零破坏、前向兼容

getDatabase() 初始化时检测并执行迁移:

  1. 读取 PRAGMA user_version;旧版未标记数据库按版本 0 处理,目标版本为 2
  2. 初始化目标 schema:content_items、新版 feedback_events DDL 以及新增三表
  3. SELECT name FROM sqlite_master WHERE type='table' AND name='bookmarks' 检测旧表是否存在
  4. 在迁移事务中严格解析 posted_at 并写入 content_items;格式、日历日期、时区偏移或星期不一致时报告内容 ID 并回滚
  5. 重建 feedback_eventsbookmark_idcontent_id):新表 → 复制 → 删旧 → 改名
  6. 校验内容数、反馈数、孤儿外键数和日期转换结果
  7. DROP TABLE bookmarks
  8. 从已有 import_runs / feedback_events 回填 audit_events
  9. 仅在上述步骤全部成功后,于同一事务中把 user_version 写为 2
  10. 事务失败时保留全部旧表、原始数据和旧版本号,禁止用当前时间兜底;遇到高于应用支持范围的版本时拒绝启动并给出错误

文件变更

  • src/lib/bookmarks.tssrc/lib/parsers/x-bookmarks.ts:解析时直接构造 ContentItemRecord(metadata JSON + ISO 8601)
  • src/lib/parsers/x-date.ts:迁移与新导入共用的严格 X 日期解析器
  • scripts/import-bookmarks.tsscripts/import-content.ts
  • package.jsonimport:bookmarksimport:content

六、类型更新

原类型 新类型 变化
BookmarkRecord ContentItemRecord {id, source, sourceId, domain, title, body, url?, tags, metadata, createdAt, importedAt}
BookmarkItem ContentItem 扩展 ContentItemRecord + {feedback, score, reason}
FeedbackEvent.bookmarkId .contentId
FeedbackEvent.bookmarkTitle .contentTitle
ProfileTopic.bookmarkCount .itemCount
ProfileAuthor.bookmarkCount .itemCount

新增 X 书签 metadata 辅助类型:

type XBookmarkMeta = {
  author_name: string;
  author_handle: string;
  category: string;
  category_label: string;
  ordinal: number;
};

七、实施顺序

步骤 内容 关键文件
1 重构数据模型:bookmarks → content_items + metadata JSON + 严格迁移逻辑 + 新表 DDL src/lib/db.ts, src/lib/parsers/x-bookmarks.ts, src/lib/parsers/x-date.ts
2 新增类型定义:ContentItemRecord, ContentItem, XBookmarkMeta, SignalRun, Signal, AuditEvent 等 src/lib/types.ts
3 实现规则引擎:类型 + 5 条规则 + runner(SQL 用 json_extract src/lib/rules/*
4 新增数据库查询函数:getLatestSignals, getDomainCoverage, getAuditEvents, getContentFlowStats 等 src/lib/db.ts
5 创建路由组 + 共享 Layout(Sidebar/Topbar 提取) src/app/(workbench)/*, src/components/shell/*
6 实现 Today 视图 src/components/today/*, src/app/(workbench)/today/page.tsx
7 实现 Knowledge 视图 src/components/knowledge/*, src/app/(workbench)/knowledge/page.tsx
8 实现 Governance 视图 src/components/governance/*, src/app/(workbench)/governance/page.tsx
9 迁移校准到 /calibration,ContentItem 替代 BookmarkItem src/app/(workbench)/calibration/page.tsx, src/components/moss-workbench.tsx
10 信号刷新 API + feedback API contentId + 导入脚本重命名 src/app/api/signals/refresh/route.ts, src/app/api/feedback/route.ts, scripts/import-content.ts

编号表达架构模块,不是独立提交顺序。实际原子交付顺序为:

1 + 2 + 9A + 10A → 4 → 3 + 10B → 5 + 9B → 6 → 7 → 8

其中 9A/10A 先更新现有根页面与 Feedback API,5+9B 再同时完成共享壳和 /calibration 搬迁,避免路由冲突或双重外壳。


八、验证

  1. npm run import:content - 确认 996 条内容写入 content_itemsjson_extract(metadata, '$.author_handle') 返回正确值
  2. npm run dev → 打开 /today
    • Hero 显示当天日期和信号数量
    • FocusStack 显示 urgency=warn/danger 的信号
    • SignalCards 显示全部信号
    • CoverageMeter 显示 6 个域(当前只有 Knowledge 和 Governance 有数据)
  3. /knowledge:Content Flow 模块显示内容统计,其他 4 个模块为 placeholder
  4. /governance:策略列表为静态数据,审计事件列表显示导入和反馈记录
  5. /calibration:已有校准功能不变,反馈仍可正常提交(API 字段已改为 contentId)
  6. 提交反馈后 → /governance 审计事件列表中出现新条目
  7. 已有 2 条 feedback_events 的 content_id 正确关联
  8. 不同来源使用相同 source_id 时不会覆盖;同源重复导入保持内部 id 不变
  9. Feedback、导入及其审计事件通过故障注入验证原子性
  10. 强制刷新或规则运行失败时,旧 active 信号批次仍可读取
  11. 新导入或新 Feedback 会改变 generation key 并自动发布新信号批次
  12. npm test + npm run build 通过

附录:相关文档

  • 数据模型关系图:docs/moss/phase-1/data-model-diagram.html
  • 架构全景图(7 张):docs/moss/phase-1/architecture-diagrams.html
  • 重构详细计划:docs/moss/phase-1/06-content-items-redesign-plan.md