Phase 1
MOSS v2 实施计划(详细版)
MOSS v2 实施计划(详细版)
状态:已实施并通过 v2 验收 日期:2026-07-24
前置文档:
- 05-v2-architecture-plan.md - 架构设计
- 06-content-items-redesign-plan.md - content_items 重构设计
- data-model-diagram.html - 数据模型关系图
- architecture-diagrams.html - 架构全景图
总览
10 个步骤,自底向上:数据模型 → 类型 → 规则引擎 → 查询层 → 路由壳 → 视图 ×3 → 迁移校准 → API。
每个步骤列出:要改的文件、具体操作、验证方式。先改底层不碰 UI,确保每步后 npm run build 不报错。
Step 1:重构数据模型(bookmarks → content_items + 新表 DDL + 迁移)
目标
- 旧表
bookmarks→ 通用content_items(source + metadata JSON) feedback_events.bookmark_id→content_id- 新增 3 张表:
signal_runs/signals/audit_events posted_at(X 文本日期)→created_at(ISO 8601)- 存量 996 条数据 + 2 条 feedback 零损失迁移
文件:src/lib/db.ts
1.1 替换 DDL(getDatabase 函数内)
删除旧的 CREATE TABLE bookmarks + feedback_events + import_runs 的 DDL,替换为:
-- 通用内容表(替代 bookmarks)
CREATE TABLE IF NOT EXISTS content_items (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
source_id TEXT NOT NULL,
domain TEXT NOT NULL DEFAULT 'knowledge',
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
url TEXT,
tags TEXT NOT NULL DEFAULT '[]',
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
imported_at TEXT NOT NULL,
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);
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';
-- 反馈事件(FK 更新)
-- ⚠️ 不在此处创建 feedback_content_idx 索引!
-- 旧 DB 的 feedback_events 仍有 bookmark_id 列,CREATE INDEX 引用 content_id 会崩溃。
-- 索引在迁移完成后统一创建(见下方 1.2 末尾)。
CREATE TABLE IF NOT EXISTS feedback_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content_id TEXT NOT NULL REFERENCES content_items(id) ON DELETE CASCADE,
value TEXT NOT NULL,
created_at TEXT NOT NULL
);
-- 导入批次(不变)
CREATE TABLE IF NOT EXISTS import_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL,
item_count INTEGER NOT NULL,
created_at TEXT NOT NULL
);
-- 信号批次(新增)
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';
-- 信号(新增)
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
);
-- 审计事件(新增)
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
);规则引擎需要复用同一个数据库连接,因此同时把当前私有函数改为模块内导出:
export function getDatabase(): Database.Database {
// 保留现有 lazy-init singleton、WAL 和 foreign_keys 配置
}1.2 添加迁移函数
getDatabase() 的初始化顺序必须是:
- 在任何 DDL 之前读取
PRAGMA user_version,高于 2 时立即拒绝打开。 - 对版本 0/1 执行与旧表兼容的目标 DDL;此时仍不能创建引用旧列不存在字段的索引。
- 调用迁移函数。
- 迁移完成后补建索引并执行完整 schema 校验。
迁移函数仍重复检查版本,作为防御性保护:
function migrateFromBookmarks(db: Database.Database): void {
const version = db.pragma("user_version", { simple: true }) as number;
if (version > 2) {
throw new Error(`数据库版本 ${version} 高于当前应用支持的版本 2`);
}
if (version === 2) {
validateV2Schema(db);
return;
}
// 检测旧表是否存在
const hasBookmarks = db.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='bookmarks'"
).get();
if (!hasBookmarks) {
// 全新数据库或已具有目标表但尚未标记版本的数据库:
// 先校验目标表、关键列和索引,再原子写入版本号。
db.transaction(() => {
validateV2Schema(db);
db.pragma("user_version = 2");
})();
return;
}
db.transaction(() => {
// 1. 复制数据到 content_items,构造 metadata JSON
// posted_at 转 ISO 8601 用 JS 实现(SQLite 无法解析 X 日期格式)
type LegacyBookmarkRow = {
id: string;
ordinal: number;
title: string;
text: string;
author_name: string;
author_handle: string;
posted_at: string;
url: string;
category: string;
category_label: string;
imported_at: string;
};
const rows = db.prepare(
"SELECT * FROM bookmarks"
).all() as LegacyBookmarkRow[];
const expectedFeedbackCount = (
db.prepare("SELECT COUNT(*) AS count FROM feedback_events").get()
as { count: number }
).count;
const insert = db.prepare(`
INSERT INTO content_items
(id, source, source_id, domain, title, body, url, tags, metadata, created_at, imported_at)
VALUES
(@id, 'x_bookmark', @id, 'knowledge', @title, @body, @url, '[]', @metadata, @createdAt, @importedAt)
`);
for (const row of rows) {
const metadata = JSON.stringify({
author_name: row.author_name,
author_handle: row.author_handle,
category: row.category,
category_label: row.category_label,
ordinal: row.ordinal,
});
const createdAt = parseXDateStrict(row.posted_at, row.id);
insert.run({
id: row.id,
title: row.title,
body: row.text,
url: row.url,
metadata,
createdAt,
importedAt: row.imported_at,
});
}
// 2. 重建 feedback_events(bookmark_id → content_id)
db.exec(`
CREATE TABLE feedback_events_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content_id TEXT NOT NULL REFERENCES content_items(id) ON DELETE CASCADE,
value TEXT NOT NULL,
created_at TEXT NOT NULL
);
INSERT INTO feedback_events_new (id, content_id, value, created_at)
SELECT id, bookmark_id, value, created_at FROM feedback_events;
DROP TABLE feedback_events;
ALTER TABLE feedback_events_new RENAME TO feedback_events;
CREATE INDEX feedback_content_idx ON feedback_events(content_id, created_at DESC);
`);
// 3. 删除旧表前完成强校验;任何不一致都会抛错并回滚。
const missingItems = db.prepare(`
SELECT COUNT(*) AS count
FROM bookmarks b
LEFT JOIN content_items c
ON c.id = b.id
AND c.source = 'x_bookmark'
AND c.source_id = b.id
WHERE c.id IS NULL
`).get() as { count: number };
const orphanFeedback = db.prepare(`
SELECT COUNT(*) AS count
FROM feedback_events fe
LEFT JOIN content_items c ON c.id = fe.content_id
WHERE c.id IS NULL
`).get() as { count: number };
const migratedFeedbackCount = (
db.prepare("SELECT COUNT(*) AS count FROM feedback_events").get()
as { count: number }
).count;
if (
missingItems.count > 0 ||
orphanFeedback.count > 0 ||
migratedFeedbackCount !== expectedFeedbackCount
) {
throw new Error(
`迁移校验失败:缺失内容 ${missingItems.count},孤儿反馈 ${orphanFeedback.count},反馈 ${migratedFeedbackCount}/${expectedFeedbackCount}`
);
}
// 4. 删除旧表
db.exec("DROP TABLE bookmarks");
// 5. 从已有数据回填 audit_events
db.exec(`
INSERT INTO audit_events (kind, title, body, ref_type, ref_id, created_at)
SELECT 'import', '书签导入', '导入 ' || item_count || ' 条内容',
'import_run', CAST(id AS TEXT), created_at
FROM import_runs;
`);
db.exec(`
INSERT INTO audit_events (kind, title, body, ref_type, ref_id, created_at)
SELECT 'feedback', '内容反馈', value,
'content', content_id, created_at
FROM feedback_events;
`);
// 版本号必须是迁移事务的最后一次 schema 写入。
db.pragma("user_version = 2");
})();
}全新数据库创建完目标 schema 后同样设置 user_version = 2。已是版本 2 时只执行幂等 DDL,不重复迁移或回填;高于 2 的数据库必须 fail closed,不能尝试降级解释。
1.3 src/lib/parsers/x-date.ts:严格日期辅助函数
X 日期格式:Wed Jul 22 12:57:33 +0000 2026,转为 ISO 8601:
// X 日期格式 "Wed Jul 22 12:57:33 +0000 2026" → ISO 8601。
// 不依赖 Date 对非标准字符串的宽松解析,也不允许用当前时间兜底。
export function parseXDateStrict(xDate: string, contentId: string): string {
const match = xDate.match(
/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{2}) (\d{2}):(\d{2}):(\d{2}) ([+-]\d{4}) (\d{4})$/
);
if (!match) {
throw new Error(`无法解析内容 ${contentId} 的 X 日期:${xDate}`);
}
const [, weekday, monthName, dayText, hourText, minuteText, secondText, offset, yearText] = match;
const month = [
'Jan','Feb','Mar','Apr','May','Jun',
'Jul','Aug','Sep','Oct','Nov','Dec'
].indexOf(monthName) + 1;
const year = Number(yearText);
const day = Number(dayText);
const hour = Number(hourText);
const minute = Number(minuteText);
const second = Number(secondText);
const offsetHour = Number(offset.slice(1, 3));
const offsetMinute = Number(offset.slice(3, 5));
const wallClock = new Date(Date.UTC(year, month - 1, day, hour, minute, second));
const weekdays = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
const semanticMatch =
wallClock.getUTCFullYear() === year &&
wallClock.getUTCMonth() === month - 1 &&
wallClock.getUTCDate() === day &&
wallClock.getUTCHours() === hour &&
wallClock.getUTCMinutes() === minute &&
wallClock.getUTCSeconds() === second &&
weekdays[wallClock.getUTCDay()] === weekday &&
offsetHour <= 23 &&
offsetMinute <= 59;
if (!semanticMatch) {
throw new Error(`内容 ${contentId} 的 X 日期超出有效范围:${xDate}`);
}
const offsetSign = offset[0] === '+' ? 1 : -1;
const offsetMs = offsetSign * (offsetHour * 60 + offsetMinute) * 60_000;
return new Date(wallClock.getTime() - offsetMs).toISOString();
}不能只检查 Date#getTime() 是否为 NaN:JavaScript 会把部分非法日期自动归一化,例如把 2 月 31 日滚到 3 月。实现必须逐项回读年月日、时间、星期和时区偏移,确认与输入完全一致。
该函数必须导出,并由以下两处共同调用:
// src/lib/db.ts:迁移旧 bookmarks
import { parseXDateStrict } from "@/lib/parsers/x-date";
// src/lib/parsers/x-bookmarks.ts:解析新导入
import { parseXDateStrict } from "./x-date";不要分别维护两份日期实现,也不要把函数留在 db.ts 私有作用域。
1.3b 迁移后统一创建索引
在 getDatabase() 中,migrateFromBookmarks(db) 调用之后,补建 feedback_content_idx:
// 迁移完成后再建索引--避免旧 DB 的 feedback_events 还有 bookmark_id 时崩溃
db.exec(`
CREATE INDEX IF NOT EXISTS feedback_content_idx
ON feedback_events(content_id, created_at DESC);
`);- 旧 DB 路径:迁移重建了
feedback_events,已在事务中创建索引 →IF NOT EXISTS跳过。 - 新 DB 路径:DDL 创建了空表,此处正常创建索引。
文件:src/lib/bookmarks.ts → src/lib/parsers/x-bookmarks.ts
1.4 重命名文件
mv src/lib/bookmarks.ts src/lib/parsers/x-bookmarks.ts创建 src/lib/parsers/ 目录。
1.5 修改返回类型
函数签名和返回值改为 ContentItemRecord:
import type { ContentItemRecord } from "@/lib/types";
import { randomUUID } from "node:crypto";
import { parseXDateStrict } from "./x-date";
export function parseXBookmarkFile(filePath: string): ContentItemRecord[] {
// 解析逻辑不变(正则不变)
// 构造返回值时组装 metadata JSON + ISO 8601 created_at:
records.push({
id: randomUUID(), // UUID;重复导入通过 source + sourceId 命中
source: 'x_bookmark',
sourceId: id,
domain: 'knowledge',
title: title.trim(),
body: text.trim(),
url: url.trim(),
tags: [],
metadata: {
author_name: authorName.trim(),
author_handle: authorHandle.trim(),
category,
category_label: categoryLabel,
ordinal: Number(ordinal),
},
createdAt: parseXDateStrict(postedAt.trim(), id),
importedAt: '', // 由 importContentItems 填充
});
}
export function parseXBookmarkDirectory(directory: string): ContentItemRecord[] {
// 逻辑不变,调用 parseXBookmarkFile
}验证
npm run build # 此时会报大量类型错误--预期内,Step 2 修复先不要求 build 通过。Step 1 + Step 2 一起完成后验证。
Step 2:类型定义更新
目标
所有 bookmark 命名的类型替换为通用名称。
文件:src/lib/types.ts
2.1 替换核心类型
// ── Feedback ──
export const feedbackValues = [
"worth_following", "deep_dive", "read_later",
"not_interested", "duplicate_or_known",
] as const;
export type FeedbackValue = (typeof feedbackValues)[number];
// ── X Bookmark 专用 metadata ──
export type XBookmarkMeta = {
author_name: string;
author_handle: string;
category: string;
category_label: string;
ordinal: number;
};
// ── 通用内容记录(DB 写入/解析器输出)──
export type ContentItemRecord = {
id: string;
source: string;
sourceId: string;
domain: string;
title: string;
body: string;
url?: string;
tags: string[];
metadata: Record<string, unknown>;
createdAt: string;
importedAt: string;
};
// ── 通用内容项(UI 渲染用,含计算字段)──
export type ContentItem = ContentItemRecord & {
authorItemCount?: number; // 同源作者内容数(SQL 子查询计算)
feedback: FeedbackValue | null;
score: number;
reason: string;
};
// ── Profile ──
export type ProfileTopic = {
category: string;
categoryLabel: string;
itemCount: number; // 原 bookmarkCount
positiveFeedbackCount: number;
negativeFeedbackCount: number;
weight: number;
};
export type ProfileAuthor = {
name: string;
handle: string;
itemCount: number; // 原 bookmarkCount
};
// ── Events ──
export type FeedbackEvent = {
id: number;
contentId: string; // 原 bookmarkId
contentTitle: string; // 原 bookmarkTitle
value: FeedbackValue;
createdAt: string;
};
export type ImportRun = {
id: number;
kind: string;
itemCount: number;
createdAt: string;
};
export type ProfileSnapshot = {
topics: ProfileTopic[];
authors: ProfileAuthor[];
feedbackCount: number;
};
// ── Signal(新增)──
export type SignalRecord = {
id: number;
runId: number;
ruleId: string;
domain: string;
title: string;
body: string;
urgency: 'normal' | 'warn' | 'danger';
tags: string[];
metadata: Record<string, unknown>;
createdAt: string;
};
export type SignalRun = {
id: number;
generationKey: string;
baseKey: string;
inputVersion: string;
ruleVersion: string;
status: 'active' | 'superseded';
ruleCount: number;
signalCount: number;
createdAt: string;
activatedAt?: string;
};
// ── Audit Event(新增)──
export type AuditEvent = {
id: number;
kind: string;
title: string;
body: string;
refType?: string;
refId?: string;
createdAt: string;
};
// ── Domain Coverage(新增)──
export type DomainCoverage = {
domain: string;
label: string;
itemCount: number;
signalCount: number;
feedbackCount: number;
};验证(Step 1 + 2 合并验证)
已知 bug 修复:当前
getCalibrationBookmarks()使用ORDER BY datetime(b.posted_at) DESC,但 SQLite 的datetime()无法解析 X 日期格式(Wed Jul 22 ...),对所有 996 行返回 null,排序实际无效。迁移到 ISO 8601 的created_at后,datetime(c.created_at)能正确解析,排序将按时间倒序生效。
npm run build
# 预期:db.ts 内部函数已改但 moss-workbench.tsx / page.tsx 等仍用旧名 → 报类型错误
# 这是正常的,Step 5/9 会修复 UI 层实际上,为了保持每步可编译,Step 1-2 期间需要同步更新 db.ts 中的函数签名(见下方 Step 1 补充)。
Step 1 补充:更新 db.ts 中的所有查询函数
文件:src/lib/db.ts
以下是 6 个需要更新的函数,逐一说明:
S1.A importBookmarks() → importContentItems()
export function importContentItems(records: ContentItemRecord[]): void {
const db = getDatabase();
const upsert = db.prepare(`
INSERT INTO content_items
(id, source, source_id, domain, title, body, url, tags, metadata, created_at, imported_at)
VALUES
(@id, @source, @sourceId, @domain, @title, @body, @url, @tags, @metadata, @createdAt, @importedAt)
ON CONFLICT(source, source_id) DO UPDATE SET
domain = excluded.domain,
title = excluded.title,
body = excluded.body,
url = excluded.url,
tags = excluded.tags,
metadata = excluded.metadata,
created_at = excluded.created_at,
imported_at = excluded.imported_at
`);
const addRun = db.prepare(
"INSERT INTO import_runs (kind, item_count, created_at) VALUES (?, ?, ?)"
);
const addAudit = db.prepare(`
INSERT INTO audit_events
(kind, title, body, ref_type, ref_id, created_at)
VALUES
('import', '内容导入', ?, 'import_run', ?, ?)
`);
const importedAt = new Date().toISOString();
db.transaction(() => {
for (const record of records) {
upsert.run({
id: record.id,
source: record.source,
sourceId: record.sourceId,
domain: record.domain,
title: record.title,
body: record.body,
url: record.url ?? null,
tags: JSON.stringify(record.tags),
metadata: JSON.stringify(record.metadata),
createdAt: record.createdAt,
importedAt,
});
}
const run = addRun.run("content_import", records.length, importedAt);
addAudit.run(
`导入 ${records.length} 条内容`,
String(run.lastInsertRowid),
importedAt,
);
})();
}注意:
tags和metadata是 JS 对象,写入时JSON.stringify。- 内容、导入批次和审计事件在同一事务中提交。
- 新生成的 UUID 只用于首次插入;重复导入通过
(source, source_id)更新原记录,不改变内部id。 - 每个解析器必须生成稳定
sourceId:原生 ID 优先;无原生 ID 时按统一的 URL v1 契约规范化后计算 SHA-256,并带url:v1:sha256:前缀;禁止使用随机值或由各连接器自行发明规范化规则。
S1.B getBookmarkCount() → getContentItemCount()
export function getContentItemCount(): number {
const row = getDatabase()
.prepare("SELECT COUNT(*) AS count FROM content_items")
.get() as { count: number };
return row.count;
}S1.C getCalibrationBookmarks() → getCalibrationItems()
关键变化:author_name / author_handle / category_label 改用 json_extract(metadata, ...)。
export function getCalibrationItems(): ContentItem[] {
const rows = getDatabase()
.prepare(`
SELECT
c.id,
c.source,
c.source_id AS sourceId,
c.domain,
c.title,
c.body,
c.url,
c.tags,
c.metadata,
c.created_at AS createdAt,
c.imported_at AS importedAt,
(SELECT COUNT(*) FROM content_items c2
WHERE c2.source = 'x_bookmark'
AND json_extract(c2.metadata, '$.author_handle') =
json_extract(c.metadata, '$.author_handle')
) AS authorItemCount,
(SELECT value FROM feedback_events fe
WHERE fe.content_id = c.id
ORDER BY fe.created_at DESC, fe.id DESC LIMIT 1
) AS feedback
FROM content_items c
ORDER BY datetime(c.created_at) DESC, c.id DESC
`)
.all() as Array<any>;
return rows.map((row) => {
const meta = JSON.parse(row.metadata || '{}');
const authorItemCount = row.authorItemCount ?? 0;
return {
id: row.id,
source: row.source,
sourceId: row.sourceId,
domain: row.domain,
title: row.title,
body: row.body,
url: row.url,
tags: JSON.parse(row.tags || '[]'),
metadata: meta,
createdAt: row.createdAt,
importedAt: row.importedAt,
authorItemCount,
feedback: row.feedback,
score: Math.min(0.95, 0.45 + Math.min(authorItemCount, 20) / 40),
reason: `分类为${meta.category_label ?? '未知'},已收藏该作者 ${authorItemCount} 次`,
};
});
}S1.D addFeedback() - Feedback 与审计原子写入
export function addFeedback(contentId: string, value: FeedbackValue): FeedbackEvent {
const db = getDatabase();
const item = db.prepare("SELECT title FROM content_items WHERE id = ?")
.get(contentId) as { title: string } | undefined;
if (!item) throw new Error("未找到对应内容");
return db.transaction(() => {
const createdAt = new Date().toISOString();
const result = db.prepare(
"INSERT INTO feedback_events (content_id, value, created_at) VALUES (?, ?, ?)"
).run(contentId, value, createdAt);
db.prepare(`
INSERT INTO audit_events
(kind, title, body, ref_type, ref_id, created_at)
VALUES
('feedback', ?, ?, 'content', ?, ?)
`).run(`反馈:${value}`, item.title, contentId, createdAt);
return {
id: Number(result.lastInsertRowid),
contentId,
contentTitle: item.title,
value,
createdAt,
};
})();
}API 层不得在 addFeedback() 返回后再单独补写审计。任一写入失败时,Feedback 和 Audit Event 必须共同回滚。
S1.E getProfileSnapshot() - json_extract 替代直接列引用
export function getProfileSnapshot(): ProfileSnapshot {
const db = getDatabase();
const topics = db.prepare(`
SELECT
json_extract(c.metadata, '$.category') AS category,
json_extract(c.metadata, '$.category_label') AS categoryLabel,
COUNT(*) AS itemCount,
SUM(CASE WHEN latest.value IN ('worth_following','deep_dive','read_later') THEN 1 ELSE 0 END) AS positiveFeedbackCount,
SUM(CASE WHEN latest.value IN ('not_interested','duplicate_or_known') THEN 1 ELSE 0 END) AS negativeFeedbackCount
FROM content_items c
LEFT JOIN (
SELECT fe.content_id, fe.value
FROM feedback_events fe
INNER JOIN (
SELECT content_id, MAX(id) AS latest_id
FROM feedback_events GROUP BY content_id
) li ON li.latest_id = fe.id
) latest ON latest.content_id = c.id
GROUP BY category, categoryLabel
ORDER BY itemCount DESC
LIMIT 8
`).all() as Array<Omit<ProfileTopic, 'weight'>>;
const maxCount = Math.max(...topics.map(t => t.itemCount), 1);
const normalizedTopics = topics.map(t => ({
...t,
weight: Math.max(0.08, Math.min(0.96,
t.itemCount / maxCount + t.positiveFeedbackCount * 0.04 - t.negativeFeedbackCount * 0.05
)),
}));
const authors = db.prepare(`
SELECT
json_extract(metadata, '$.author_name') AS name,
json_extract(metadata, '$.author_handle') AS handle,
COUNT(*) AS itemCount
FROM content_items
WHERE source = 'x_bookmark'
AND json_extract(metadata, '$.author_handle') IS NOT NULL
GROUP BY handle, name
ORDER BY itemCount DESC, name ASC
LIMIT 6
`).all() as ProfileAuthor[];
const feedbackCount = (db.prepare(
"SELECT COUNT(*) AS count FROM feedback_events"
).get() as { count: number }).count;
return { topics: normalizedTopics, authors, feedbackCount };
}S1.F getFeedbackEvents() - 列名更新
export function getFeedbackEvents(limit = 20): FeedbackEvent[] {
return getDatabase().prepare(`
SELECT
fe.id,
fe.content_id AS contentId,
c.title AS contentTitle,
fe.value,
fe.created_at AS createdAt
FROM feedback_events fe
INNER JOIN content_items c ON c.id = fe.content_id
ORDER BY fe.created_at DESC, fe.id DESC
LIMIT ?
`).all(limit) as FeedbackEvent[];
}S1.G getLatestImportRun() - 不变
函数签名和实现已通用,无需修改。
Step 1+2 合并验证
npm run build
# 预期:db.ts + types.ts + parsers/x-bookmarks.ts 内部一致
# 但 page.tsx / moss-workbench.tsx / api/feedback/route.ts 仍用旧名 → 类型错误
# 这些会在 Step 5 / Step 9 / Step 10 修复为了保持步骤独立可验证,可以先在 page.tsx 中临时更新导入(见 Step 9),或者选择 Step 1-2-9-10 作为一个原子提交。
推荐做法:Step 1+2 同时完成,然后立即执行 Step 9+10(更新现有 UI 引用),确保 npm run build 通过后再继续 Step 3-8(新功能)。
Step 3:实现规则引擎
目标
5 条声明式规则 + runner,SSR 时按需执行。
文件结构
src/lib/rules/
types.ts
index.ts
uncalibrated-categories.ts
stale-import.ts
feedback-coverage.ts
author-review.ts
category-skew.ts3.1 src/lib/rules/types.ts
定义 Rule 和 RuleContext 接口:
import type Database from "better-sqlite3";
import type { ImportRun } from "@/lib/types";
export 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>;
};
export type RuleContext = {
db: Database.Database;
now: Date;
latestImport: ImportRun | null;
};
export type Rule = {
id: string;
name: string;
evaluate: (ctx: RuleContext) => Signal[];
};3.2 src/lib/rules/index.ts
引擎 runner:
实施前置:先完成 Step 4.1 的
getLatestSignals()。因此实际提交顺序是 Step 4 → Step 3;章节编号按架构模块保留,不代表代码提交顺序。
import { randomUUID } from "node:crypto";
import {
addAuditEvent,
getDatabase,
getLatestImportRun,
getLatestSignals,
} from "@/lib/db";
import type { SignalRecord } from "@/lib/types";
import type { Rule, Signal } from "./types";
// import 5 条规则
export function runSignalEngine(opts?: { force?: boolean }): SignalRecord[] {
const db = getDatabase();
const now = new Date();
const latestImportId = getLatestImportRun()?.id ?? 0;
const latestFeedbackId = (
db.prepare("SELECT COALESCE(MAX(id), 0) AS id FROM feedback_events").get()
as { id: number }
).id;
const ruleVersion = "v2.1";
const localDate = [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, "0"),
String(now.getDate()).padStart(2, "0"),
].join("-");
const inputVersion = `${latestImportId}:${latestFeedbackId}`;
const baseKey = `${localDate}:${inputVersion}:${ruleVersion}`;
const refreshNonce = opts?.force ? randomUUID() : "cached";
const generationKey = `${baseKey}:${refreshNonce}`;
const activeRun = db.prepare(`
SELECT id, generation_key AS generationKey, base_key AS baseKey
FROM signal_runs
WHERE status = 'active'
`).get() as { id: number; generationKey: string; baseKey: string } | undefined;
// 强制刷新绕过缓存;普通访问按 base key 命中。
// 这样强制刷新后的 active run 仍可被随后的普通访问复用。
if (!opts?.force && activeRun?.baseKey === baseKey) {
return getLatestSignals();
}
// 先在事务外计算候选结果,计算失败时当前 active run 完全不受影响。
const ctx = { db, now, latestImport: getLatestImportRun() };
// 3. 评估全部规则
const rules: Rule[] = [
uncalibratedCategories, staleImport, feedbackCoverage,
authorReview, categorySkew
];
const candidateSignals: Signal[] = rules.flatMap(r => r.evaluate(ctx));
const createdAt = now.toISOString();
db.transaction(() => {
// 另一个进程可能已在规则计算期间发布相同版本;事务内再次检查。
const alreadyPublished = db.prepare(
"SELECT id FROM signal_runs WHERE generation_key = ?"
).get(generationKey);
if (alreadyPublished) return;
// append-and-publish:先将旧批次降级,再插入新 active 批次。
// 任一步失败时整个事务回滚,旧 active 批次恢复。
db.prepare(
"UPDATE signal_runs SET status = 'superseded' WHERE status = 'active'"
).run();
const runResult = db.prepare(
`INSERT INTO signal_runs
(generation_key, base_key, input_version, rule_version, status,
rule_count, signal_count, created_at, activated_at)
VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?)`
).run(
generationKey, baseKey, inputVersion, ruleVersion,
rules.length, candidateSignals.length, createdAt, createdAt
);
const runId = Number(runResult.lastInsertRowid);
const insertSignal = db.prepare(`
INSERT INTO signals (run_id, rule_id, domain, title, body, urgency, tags, metadata, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const s of candidateSignals) {
insertSignal.run(runId, s.ruleId, s.domain, s.title, s.body,
s.urgency, JSON.stringify(s.tags), JSON.stringify(s.metadata), createdAt);
}
// 审计事件(使用 Step 10 定义的 addAuditEvent)
addAuditEvent({
kind: 'signal_run',
title: '信号引擎运行',
body: `评估 ${rules.length} 条规则,生成 ${candidateSignals.length} 条信号`,
refType: 'signal_run',
refId: String(runId),
});
})();
return getLatestSignals();
}getLatestSignals() 必须读取 status = 'active' 的 run,而不是简单使用 MAX(id)。历史批次保留用于追溯,可以在独立维护任务中按保留策略清理。
3.3 五条规则实现要点
每条规则都是一个导出 Rule 对象的模块。SQL 查询使用 json_extract(metadata, '$.xxx') 访问源专属字段。
uncalibrated-categories.ts:
SELECT
json_extract(c.metadata, '$.category') AS cat,
json_extract(c.metadata, '$.category_label') AS catLabel,
COUNT(*) AS total,
COUNT(DISTINCT fe.content_id) AS withFeedback
FROM content_items c
LEFT JOIN feedback_events fe ON fe.content_id = c.id
WHERE c.source = 'x_bookmark'
GROUP BY cat
HAVING total > 20 AND (CAST(withFeedback AS REAL) / total) < 0.1- 每个命中类别生成一条 signal
- total > 50 且覆盖 < 5% → urgency = 'warn',否则 'normal'
stale-import.ts:
SELECT created_at FROM import_runs ORDER BY id DESC LIMIT 1- JS 计算天数差:> 30 天 → 'danger',> 7 天 → 'warn',否则不生成
feedback-coverage.ts:
SELECT
(SELECT COUNT(*) FROM content_items) AS totalItems,
(SELECT COUNT(DISTINCT content_id) FROM feedback_events) AS withFeedback- 覆盖率 < 10% → 'warn',否则 'normal'
- body 显示
"校准进度:X%(Y/Z)"
author-review.ts:
SELECT
json_extract(c.metadata, '$.author_handle') AS handle,
json_extract(c.metadata, '$.author_name') AS name,
COUNT(*) AS total,
COUNT(DISTINCT CASE WHEN fe.value IN ('worth_following','deep_dive','read_later')
THEN fe.content_id END) AS positiveCount
FROM content_items c
LEFT JOIN feedback_events fe ON fe.content_id = c.id
WHERE c.source = 'x_bookmark'
GROUP BY handle
HAVING total >= 5 AND positiveCount = 0- 每个命中作者生成一条 signal,urgency = 'normal'
category-skew.ts:
SELECT
json_extract(c.metadata, '$.category') AS cat,
json_extract(c.metadata, '$.category_label') AS catLabel,
COUNT(*) AS catCount,
(SELECT COUNT(*) FROM content_items WHERE source = 'x_bookmark') AS sourceTotal
FROM content_items c
WHERE c.source = 'x_bookmark'
GROUP BY cat
HAVING (CAST(catCount AS REAL) / sourceTotal) > 0.3- 注意:
sourceTotal只统计同源内容(不含 RSS 等其他源),避免多源时百分比被稀释 - 每个偏斜类别一条 signal,urgency = 'normal'
验证
npm run build # 规则引擎模块编译通过
# 手动测试:在 Node REPL 中 import { runSignalEngine } 并执行
# 确认 signal_runs 表有 1 条记录,signals 表有 N 条Step 4:新增数据库查询函数
目标
Today / Knowledge / Governance 视图的 SSR 数据查询。
文件:src/lib/db.ts
新增 5 个导出函数:
4.1 getLatestSignals()
export function getLatestSignals(): SignalRecord[] {
const db = getDatabase();
const latestRun = db.prepare(
"SELECT id FROM signal_runs WHERE status = 'active' LIMIT 1"
).get() as { id: number } | undefined;
if (!latestRun) return [];
type SignalDbRow = Omit<SignalRecord, 'tags' | 'metadata'> & {
tags: string;
metadata: string;
};
const rows = db.prepare(`
SELECT id, run_id AS runId, rule_id AS ruleId, domain, title, body,
urgency, tags, metadata, created_at AS createdAt
FROM signals WHERE run_id = ?
ORDER BY
CASE urgency WHEN 'danger' THEN 0 WHEN 'warn' THEN 1 ELSE 2 END,
id ASC
`).all(latestRun.id) as SignalDbRow[];
return rows.map(row => ({
...row,
tags: JSON.parse(row.tags),
metadata: JSON.parse(row.metadata),
}));
}4.2 getDomainCoverage()
export function getDomainCoverage(): DomainCoverage[] {
const db = getDatabase();
const domains = ['knowledge','action','context','self','asset','governance'];
const domainLabels: Record<string, string> = {
knowledge: 'Knowledge', action: 'Action', context: 'Context',
self: 'Self', asset: 'Asset', governance: 'Governance',
};
return domains.map(domain => {
const itemCount = (db.prepare(
"SELECT COUNT(*) AS c FROM content_items WHERE domain = ?"
).get(domain) as { c: number }).c;
const signalCount = (db.prepare(`
SELECT COUNT(*) AS c FROM signals s
JOIN signal_runs sr ON sr.id = s.run_id
WHERE s.domain = ? AND sr.status = 'active'
`).get(domain) as { c: number }).c;
const feedbackCount = (db.prepare(`
SELECT COUNT(DISTINCT fe.content_id) AS c
FROM feedback_events fe
JOIN content_items ci ON ci.id = fe.content_id
WHERE ci.domain = ?
`).get(domain) as { c: number }).c;
return { domain, label: domainLabels[domain], itemCount, signalCount, feedbackCount };
});
}4.3 getAuditEvents()
export function getAuditEvents(limit = 20): AuditEvent[] {
return getDatabase().prepare(`
SELECT id, kind, title, body,
ref_type AS refType, ref_id AS refId,
created_at AS createdAt
FROM audit_events
ORDER BY created_at DESC, id DESC
LIMIT ?
`).all(limit) as AuditEvent[];
}4.4 getContentFlowStats()
export type ContentFlowStats = {
totalItems: number;
totalFeedback: number;
feedbackCoverage: number;
sourceBreakdown: Array<{ source: string; count: number }>;
recentImport: ImportRun | null;
};
export function getContentFlowStats(): ContentFlowStats {
const db = getDatabase();
const totalItems = (db.prepare("SELECT COUNT(*) AS c FROM content_items").get() as { c: number }).c;
const totalFeedback = (db.prepare("SELECT COUNT(*) AS c FROM feedback_events").get() as { c: number }).c;
const withFeedback = (db.prepare("SELECT COUNT(DISTINCT content_id) AS c FROM feedback_events").get() as { c: number }).c;
const sourceBreakdown = db.prepare(
"SELECT source, COUNT(*) AS count FROM content_items GROUP BY source ORDER BY count DESC"
).all() as Array<{ source: string; count: number }>;
return {
totalItems,
totalFeedback,
feedbackCoverage: totalItems > 0 ? withFeedback / totalItems : 0,
sourceBreakdown,
recentImport: getLatestImportRun(),
};
}4.5 getCategoryBreakdown()
export function getCategoryBreakdown(): Array<{ category: string; label: string; count: number }> {
return getDatabase().prepare(`
SELECT
json_extract(metadata, '$.category') AS category,
json_extract(metadata, '$.category_label') AS label,
COUNT(*) AS count
FROM content_items
WHERE source = 'x_bookmark'
GROUP BY category
ORDER BY count DESC
`).all() as Array<{ category: string; label: string; count: number }>;
}验证
npm run build # 编译通过Step 5:路由组 + 共享 Layout
目标
创建 (workbench) 路由组,提取 Sidebar / Topbar / Inspector 为独立组件。
文件结构
src/app/(workbench)/
layout.tsx ← 共享壳
page.tsx ← redirect → /today
src/components/shell/
sidebar.tsx
topbar.tsx
inspector.tsx5.1 (workbench)/layout.tsx
Server Component。渲染三列布局:Sidebar | main (children) | Inspector。
export default function WorkbenchLayout({ children }) {
return (
<div className="workbench-shell">
<Sidebar />
<main className="workbench-main">
<Topbar />
{children}
</main>
<Inspector />
</div>
);
}5.2 (workbench)/page.tsx
import { redirect } from "next/navigation";
export default function WorkbenchRoot() {
redirect("/today");
}5.3 shell/sidebar.tsx
从 moss-workbench.tsx 的 Sidebar 函数提取。
5.7 ⚠️ 拆除 MossWorkbench 的外壳
关键:当前 MossWorkbench 渲染完整 app shell(app-shell grid + Sidebar + MobileNav + Topbar + context-panel)。放入 WorkbenchLayout 后会出现双重导航。
必须从 MossWorkbench 中移除:
<div className="app-shell ...">最外层 grid → 替换为<div className="calibration-view"><Sidebar>和<MobileNav>组件定义及调用 → 已提取到shell/sidebar.tsx<header className="topbar">→ 已提取到shell/topbar.tsx<aside className="context-panel">→ 移入shell/inspector.tsx(校准路由注入内容)viewstate + navItems → 校准/画像/历史改为组件内部 tab 切换(不再需要 Sidebar 级导航)
改造后 MossWorkbench 只渲染:
<>
<TabBar activeTab={tab} onChange={setTab} /> {/* calibration | profile | history */}
{tab === "calibration" && <CalibrationView ... />}
{tab === "profile" && <ProfileView ... />}
{tab === "history" && <HistoryView ... />}
</>Sheet(移动端上下文面板)和 ContextPanel 通过 React Context 或 props 注入到 shell/inspector.tsx。
导航项:
- Today(
/today) - Knowledge(
/knowledge) - Governance(
/governance) - 校准(
/calibration)
域列表:6 个域 + 颜色标识。
使用 usePathname() 高亮当前路由。
5.4 shell/topbar.tsx
面包屑(基于 pathname) + 操作按钮区域(刷新信号、主题切换)。
5.5 shell/inspector.tsx
右侧上下文面板,初始版本显示静态占位,后续各视图通过 React Context 注入内容。
5.6 CSS
在 globals.css 中添加 .workbench-shell(grid 三列布局)和 .workbench-main 样式。复用已有的 .sidebar / .context-panel CSS 类。
验证
npm run dev
# 访问 / → 自动跳转 /today
# 左侧 Sidebar 显示 4 个导航项
# 顶部 Topbar 显示面包屑
# 右侧 Inspector 显示占位Step 6:Today 视图
目标
信号驱动的日视图。Hero + FocusStack + SignalCards + CoverageMeter。
文件
src/app/(workbench)/today/page.tsx ← SSR
src/components/today/
hero-section.tsx
focus-stack.tsx
signal-cards.tsx
coverage-meter.tsx6.1 today/page.tsx(Server Component)
export const dynamic = "force-dynamic";
import { runSignalEngine } from "@/lib/rules";
import { getDomainCoverage } from "@/lib/db";
import { HeroSection } from "@/components/today/hero-section";
import { FocusStack } from "@/components/today/focus-stack";
import { SignalCards } from "@/components/today/signal-cards";
import { CoverageMeter } from "@/components/today/coverage-meter";
export default function TodayPage() {
const signals = runSignalEngine(); // 触发引擎(有缓存)
const coverage = getDomainCoverage();
const focusSignals = signals.filter(s => s.urgency !== 'normal');
const today = new Date();
return (
<>
<HeroSection date={today} signalCount={signals.length} focusCount={focusSignals.length} />
{focusSignals.length > 0 && <FocusStack signals={focusSignals} />}
<SignalCards signals={signals} />
<CoverageMeter coverage={coverage} />
</>
);
}6.2 组件说明
hero-section.tsx:显示日期(中文格式)、信号总数 / focus 数、状态芯片("X 条待处理")。参考 v2 原型的 hero 区域。
focus-stack.tsx:urgency=warn|danger 的信号列表,每项显示域色标、标题、body 摘要、urgency badge。
signal-cards.tsx:所有信号的卡片网格,按域分组或平铺。域色编码边框。点击可展开详情。
coverage-meter.tsx:6 个域的覆盖度条形图。domain.itemCount 和 domain.signalCount 可视化。
验证
npm run dev
# /today:
# - Hero 显示今天日期和信号数量
# - FocusStack 有 warn/danger 信号(至少 stale-import 会触发)
# - SignalCards 显示全部信号
# - CoverageMeter 显示 6 域(只有 Knowledge 和 Governance 有数据)Step 7:Knowledge 视图
目标
内容流统计 + 5 模块网格(1 实 + 4 placeholder)。
文件
src/app/(workbench)/knowledge/page.tsx
src/components/knowledge/
module-grid.tsx7.1 knowledge/page.tsx
export const dynamic = "force-dynamic";
export default function KnowledgePage() {
const stats = getContentFlowStats();
const categories = getCategoryBreakdown();
return <ModuleGrid stats={stats} categories={categories} />;
}7.2 module-grid.tsx
5 个模块卡片:
- Content Flow(实数据):总内容数、反馈覆盖率、来源分布、最近导入时间。使用
statsprops。 - Subscriptions(placeholder):标题 + "即将到来" 占位
- Learning(placeholder)
- Research(placeholder)
- Output(placeholder)
类别分布展示:categories 列表,每项显示 label + count + 百分比条。
验证
npm run dev
# /knowledge:Content Flow 模块显示 996 条内容、反馈覆盖率、11 个分类
# 其他 4 个模块显示 placeholderStep 8:Governance 视图
目标
策略卡片(静态)+ 审计事件列表(实数据)。
文件
src/app/(workbench)/governance/page.tsx
src/components/governance/
policy-list.tsx
audit-box.tsx8.1 governance/page.tsx
export const dynamic = "force-dynamic";
export default function GovernancePage() {
const auditEvents = getAuditEvents(20);
return (
<>
<PolicyList />
<AuditBox events={auditEvents} />
</>
);
}8.2 policy-list.tsx
静态策略卡片,参考 v2 原型:
- 数据本地化政策
- X.com 只读政策
- 反馈独立事件流
- 审计 append-only 应用约束
每张卡片:标题 + 描述 + 状态("已执行" / "待实施")。
8.3 audit-box.tsx
审计事件列表组件:
- 每项显示:kind badge + title + body + 时间戳
- kind 用不同颜色:
import绿、feedback蓝、signal_run橙、system灰 - 时间显示相对时间("3 小时前")
验证
npm run dev
# /governance:
# - 策略卡片显示 4 条静态策略
# - 审计事件列表显示导入和反馈记录(从迁移回填的数据)
# 提交一条反馈后刷新 → 审计事件列表出现新条目Step 9:迁移校准视图到 /calibration
目标
把根 page.tsx 的校准功能迁移到 /calibration,更新 moss-workbench.tsx 中的所有 bookmark 引用。
分阶段执行
Step 9 不能作为一个独立提交一次完成:
- Step 9A(紧跟 Step 1 + 2):只更新当前根
page.tsx、moss-workbench.tsx、导入脚本和测试中的类型、查询函数及文案;保留现有 app shell 和根路由,使数据重构后立即恢复可编译状态。 - Step 9B(与 Step 5 同一提交):创建
/calibration,提取共享壳,删除旧根page.tsx,并从MossWorkbench移除重复 Sidebar、Topbar 和 Inspector。
如果在 Step 5 之前执行 9B,shell/inspector.tsx 尚不存在;如果只执行 Step 5 而未执行 9B,根路由会冲突并出现双重外壳。因此 Step 5 + 9B 必须原子交付。
9.1 src/app/(workbench)/calibration/page.tsx
export const dynamic = "force-dynamic";
import { getCalibrationItems, getContentItemCount, getFeedbackEvents,
getLatestImportRun, getProfileSnapshot } from "@/lib/db";
import MossWorkbench from "@/components/moss-workbench";
export default function CalibrationPage() {
const itemCount = getContentItemCount();
if (itemCount === 0) {
return <div>尚未导入内容,执行 npm run import:content 后刷新。</div>;
}
return (
<MossWorkbench
initialItems={getCalibrationItems()}
profile={getProfileSnapshot()}
feedbackEvents={getFeedbackEvents()}
latestImport={getLatestImportRun()}
itemCount={itemCount}
referenceTime={Date.now()}
/>
);
}9.2 src/components/moss-workbench.tsx - 全面更新
Props 接口:
type Props = {
initialItems: ContentItem[]; // 原 initialBookmarks
profile: ProfileSnapshot;
feedbackEvents: FeedbackEvent[];
latestImport: ImportRun | null;
itemCount: number; // 原 bookmarkCount
referenceTime: number;
};批量替换清单(约 50 处):
| 原 | 新 | 出现位置 |
|---|---|---|
BookmarkItem |
ContentItem |
import、Props、函数参数 |
initialBookmarks |
initialItems |
Props、destructure、useState |
bookmarks (state) |
items |
useState、filter、map、find |
setBookmarks |
setItems |
setState 调用 |
bookmarkCount |
itemCount |
Props、Sidebar、计数显示 |
visibleBookmarks |
visibleItems |
过滤结果变量 |
bookmark (loop var) |
item |
map/find 回调参数 |
bookmark.text |
item.body |
ContextPanel 正文、摘要生成 |
bookmark.postedAt |
item.createdAt |
scoreFactors 时效计算(顶层字段,不走 getXMeta) |
bookmark.authorHandle |
getXMeta(item).author_handle |
ContextPanel、作者链接 |
bookmark.authorName |
getXMeta(item).author_name |
作者显示 |
bookmark.categoryLabel |
getXMeta(item).category_label |
ContextPanel、filter 标签 |
bookmark.category |
getXMeta(item).category |
scoreFactors(topic 匹配)、filter 构造(L211)、filter 匹配(L215) |
bookmark.authorBookmarkCount |
item.authorItemCount |
scoreFactors 作者权重 |
topic.bookmarkCount |
topic.itemCount |
ProfileView 话题统计 |
author.bookmarkCount |
author.itemCount |
ProfileView 作者统计 |
event.bookmarkTitle |
event.contentTitle |
HistoryView 事件标题 |
event.bookmarkId |
event.contentId |
HistoryView(如有引用) |
bookmarkId (API 调用) |
contentId |
saveFeedback 函数参数 + JSON body |
data-testid="bookmark-filters" |
data-testid="content-filters" |
过滤栏 |
辅助函数(在组件内定义):
function getXMeta(item: ContentItem): XBookmarkMeta {
return item.metadata as XBookmarkMeta;
}中文 UI 字符串更新:
| 原 | 新 |
|---|---|
"本地书签" |
"本地内容" |
"书签校准" |
"内容校准" |
"本地 X Bookmarks · 冷启动校准" |
"本地 X Bookmarks · 冷启动校准"(保留,这是准确描述) |
"N / M 条书签" |
"N / M 条内容" |
"N 条书签" (作者) |
"N 条内容" |
"书签导入" |
"内容导入" |
"画像仅描述书签与反馈..." |
"画像仅描述内容与反馈..." |
"当前画像由本地书签..." |
"当前画像由本地内容..." |
"书签分类为..." (reason) |
已在 db.ts 中更新 |
"未找到对应书签" |
已在 db.ts 中更新为 "未找到对应内容" |
data-testid="bookmark-filters" |
data-testid="content-filters" |
scoreFactors 函数:参数改为 item: ContentItem,内部用 getXMeta(item) 访问 category/author。
ContextPanel 函数:参数改为 item: ContentItem,内部用 getXMeta(item) 访问作者等信息。
saveFeedback 调用(显式代码):
// 原:
async function saveFeedback(bookmarkId: string, value: FeedbackValue) {
await fetch("/api/feedback", {
method: "POST",
body: JSON.stringify({ bookmarkId, value }),
});
}
// 调用:saveFeedback(bookmark.id, value)
// 新:
async function saveFeedback(contentId: string, value: FeedbackValue) {
await fetch("/api/feedback", {
method: "POST",
body: JSON.stringify({ contentId, value }),
});
}
// 调用:saveFeedback(item.id, value)9.3 删除旧的根 page.tsx
旧的 src/app/page.tsx 可以删除,因为 (workbench)/page.tsx 已处理根路由重定向。
9.4 其他文件
scripts/import-bookmarks.ts → scripts/import-content.ts:
import path from "node:path";
import { parseXBookmarkDirectory } from "../src/lib/parsers/x-bookmarks";
import { importContentItems } from "../src/lib/db";
const directory = path.join(process.cwd(), "data", "phase-1a-input");
const records = parseXBookmarkDirectory(directory);
importContentItems(records);
console.log(`Imported ${records.length} content items from ${directory}.`);tests/bookmarks.test.ts → tests/content-items.test.ts:
import { parseXBookmarkDirectory } from "../src/lib/parsers/x-bookmarks";
test("解析本地 X Bookmarks Markdown 为 ContentItemRecord", () => {
const records = parseXBookmarkDirectory(...);
assert.equal(records.length, 996);
assert.ok(records.every(r => r.source === 'x_bookmark'));
assert.ok(records.every(r => r.id && r.url?.startsWith("https://x.com/")));
assert.ok(records.every(r => (r.metadata as any).author_handle));
assert.ok(records.some(r => (r.metadata as any).category_label === "AI、Agent 与编程"));
});package.json:
"import:content": "tsx scripts/import-content.ts"保留 import:bookmarks 作为别名指向同一脚本(向后兼容):
"import:bookmarks": "tsx scripts/import-content.ts"验证
npm run build # 零类型错误
npm test # 解析测试通过(996 条)
npm run import:content # 导入成功
npm run dev
# /calibration:校准功能完全正常
# 反馈提交正常(API 字段已改为 contentId)Step 10:API 更新
同样分两阶段:
- Step 10A(紧跟 Step 1 + 2):更新 Feedback API 为
contentId,使用事务化addFeedback(),并新增通用addAuditEvent()。 - Step 10B(Step 3 完成后):新增 signals refresh API;在规则引擎存在之前不得提前创建不可用路由。
10.1 src/app/api/feedback/route.ts
export async function POST(request: Request) {
const body = await request.json().catch(() => null) as
{ contentId?: unknown; value?: unknown } | null;
if (
typeof body?.contentId !== "string" ||
typeof body.value !== "string" ||
!feedbackValues.includes(body.value as FeedbackValue)
) {
return NextResponse.json({ error: "无效的 Feedback 请求" }, { status: 400 });
}
try {
const event = addFeedback(body.contentId, body.value as FeedbackValue);
return NextResponse.json({ event }); // 保留现有响应 envelope
} catch { ... }
}addFeedback() 已在数据库事务中同时写入 Feedback Event 与 Audit Event。通用的 addAuditEvent() 仅用于调用方已经处于同一事务或没有配套业务写入的场景:
export function addAuditEvent(event: Omit<AuditEvent, 'id' | 'createdAt'>): void {
getDatabase().prepare(`
INSERT INTO audit_events (kind, title, body, ref_type, ref_id, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(event.kind, event.title, event.body ?? '',
event.refType ?? null, event.refId ?? null, new Date().toISOString());
}10.2 src/app/api/signals/refresh/route.ts(新增)
import { NextResponse } from "next/server";
import { runSignalEngine } from "@/lib/rules";
export async function POST() {
const signals = runSignalEngine({ force: true });
return NextResponse.json({ count: signals.length });
}验证
npm run dev
# POST /api/feedback 用 contentId 提交反馈 → 返回 { event: FeedbackEvent }
# /governance 审计事件列表出现新的 feedback 条目
# POST /api/signals/refresh → 返回 { count: N }
# /today 信号列表更新最终验证清单
| # | 检查项 | 命令/操作 |
|---|---|---|
| 1 | 编译通过 | npm run build |
| 2 | 测试通过 | npm test |
| 3 | 导入正常 | npm run import:content → 996 条写入 content_items |
| 4 | 迁移正常 | 用旧 DB 启动 → 自动迁移 → SELECT * FROM content_items LIMIT 1 有 metadata JSON |
| 5 | / 重定向 |
访问 / → 跳转 /today |
| 6 | /today 信号 |
Hero + FocusStack + SignalCards + CoverageMeter 正常渲染 |
| 7 | /knowledge 统计 |
Content Flow 模块显示 996 条、11 个分类 |
| 8 | /governance 审计 |
策略卡片 + 审计事件列表显示迁移回填的数据 |
| 9 | /calibration 校准 |
反馈提交正常、画像统计正常、历史记录正常 |
| 10 | 审计联动 | 提交反馈 → /governance 审计事件列表出现新条目 |
| 11 | 信号刷新 | POST /api/signals/refresh → /today 信号更新 |
| 12 | 暗色模式 | 主题切换正常,所有新组件在暗色下可读 |
| 13 | 多源身份隔离 | 两个来源使用相同 source_id → 生成两个内容对象;同源重复导入 → 更新原对象且内部 id 不变 |
| 14 | 日期迁移失败 | 注入非法 X 日期 → 整个迁移回滚,旧表和原数据保持不变 |
| 15 | Feedback 原子性 | 模拟审计写入失败 → Feedback 不落库,API 可安全重试 |
| 16 | 导入原子性 | 导入后同时出现 import_runs 与对应 audit_events;任一写入失败全部回滚 |
| 17 | 信号发布安全 | 模拟规则或新批次写入失败 → 旧 active run 与 signals 保持可读 |
| 18 | 信号自动失效 | 新导入或新 Feedback 后访问 /today → generation key 改变并发布新 active run |
推荐实施顺序(调整)
为了保持每步可编译,建议实际操作顺序:
Step 1 + 2 + 9A + 10A → 数据模型、类型、现有根页面与 Feedback API 原子更新
Step 4 → 新查询函数(为规则引擎提供 getLatestSignals)
Step 3 + 10B → 规则引擎 + signals refresh API
Step 5 + 9B → 路由组、共享 Layout、校准搬迁和旧外壳拆除
Step 6 → Today 视图
Step 7 → Knowledge 视图
Step 8 → Governance 视图每步一个 commit,commit message 格式:feat(moss): step N - 描述。