Phase 1
MOSS v2 子集:技术架构与数据模型
MOSS v2 子集:技术架构与数据模型
Context
MOSS 当前是一个书签校准工具(3 张表、1 个页面、996 条 X Bookmarks)。目标是将其转变为 v2 原型中展示的个人上下文工作台的子集:Today + Knowledge + Governance 三个视图,由 声明式规则引擎 驱动信号生成,数据来源仍为文件导入。
当前代码与 v2 原型的核心差距不是"缺页面",而是缺数据模型(信号、审计事件)和缺计算层(规则引擎)。此外,原 bookmarks 表完全绑定 X 书签,不满足多源内容接入需求,需重构为通用 content_items 表。
一、数据模型
重构:bookmarks → content_items(通用内容表)
原 bookmarks 表的所有字段都是 X 书签专用(author_handle、posted_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%" |
执行策略
- 计算
base_key:本地日期、最近import_run.id、最近feedback_event.id和规则版本 - 普通访问时,若 active run 的
base_key一致 → 直接读取当前信号;即使该批次来自之前的强制刷新也必须命中 - 强制刷新或
base_key变化时,先在内存中完成全部规则评估,不删除当前 active run - 在单个事务中写入新 run 与 signals、将旧 run 标记为
superseded、激活新 run 并写审计事件 - 任一步失败则回滚,旧 active run 保持可用
- 强制刷新只使用新 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 层不再分两次写入。feedbackLabels 从 components/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() 初始化时检测并执行迁移:
- 读取
PRAGMA user_version;旧版未标记数据库按版本 0 处理,目标版本为 2 - 初始化目标 schema:
content_items、新版feedback_eventsDDL 以及新增三表 SELECT name FROM sqlite_master WHERE type='table' AND name='bookmarks'检测旧表是否存在- 在迁移事务中严格解析
posted_at并写入content_items;格式、日历日期、时区偏移或星期不一致时报告内容 ID 并回滚 - 重建
feedback_events(bookmark_id→content_id):新表 → 复制 → 删旧 → 改名 - 校验内容数、反馈数、孤儿外键数和日期转换结果
DROP TABLE bookmarks- 从已有
import_runs/feedback_events回填audit_events - 仅在上述步骤全部成功后,于同一事务中把
user_version写为 2 - 事务失败时保留全部旧表、原始数据和旧版本号,禁止用当前时间兜底;遇到高于应用支持范围的版本时拒绝启动并给出错误
文件变更:
src/lib/bookmarks.ts→src/lib/parsers/x-bookmarks.ts:解析时直接构造ContentItemRecord(metadata JSON + ISO 8601)src/lib/parsers/x-date.ts:迁移与新导入共用的严格 X 日期解析器scripts/import-bookmarks.ts→scripts/import-content.tspackage.json:import:bookmarks→import: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 搬迁,避免路由冲突或双重外壳。
八、验证
npm run import:content- 确认 996 条内容写入content_items,json_extract(metadata, '$.author_handle')返回正确值npm run dev→ 打开/today:- Hero 显示当天日期和信号数量
- FocusStack 显示 urgency=warn/danger 的信号
- SignalCards 显示全部信号
- CoverageMeter 显示 6 个域(当前只有 Knowledge 和 Governance 有数据)
/knowledge:Content Flow 模块显示内容统计,其他 4 个模块为 placeholder/governance:策略列表为静态数据,审计事件列表显示导入和反馈记录/calibration:已有校准功能不变,反馈仍可正常提交(API 字段已改为 contentId)- 提交反馈后 →
/governance审计事件列表中出现新条目 - 已有 2 条 feedback_events 的
content_id正确关联 - 不同来源使用相同
source_id时不会覆盖;同源重复导入保持内部id不变 - Feedback、导入及其审计事件通过故障注入验证原子性
- 强制刷新或规则运行失败时,旧 active 信号批次仍可读取
- 新导入或新 Feedback 会改变 generation key 并自动发布新信号批次
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