Phase 1

MOSS v2 实施计划(详细版)

MOSS v2 实施计划(详细版)

状态:已实施并通过 v2 验收 日期:2026-07-24
前置文档:


总览

10 个步骤,自底向上:数据模型 → 类型 → 规则引擎 → 查询层 → 路由壳 → 视图 ×3 → 迁移校准 → API

每个步骤列出:要改的文件、具体操作、验证方式。先改底层不碰 UI,确保每步后 npm run build 不报错。


Step 1:重构数据模型(bookmarks → content_items + 新表 DDL + 迁移)

目标

  • 旧表 bookmarks → 通用 content_items(source + metadata JSON)
  • feedback_events.bookmark_idcontent_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() 的初始化顺序必须是:

  1. 在任何 DDL 之前读取 PRAGMA user_version,高于 2 时立即拒绝打开。
  2. 对版本 0/1 执行与旧表兼容的目标 DDL;此时仍不能创建引用旧列不存在字段的索引。
  3. 调用迁移函数。
  4. 迁移完成后补建索引并执行完整 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.tssrc/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,
    );
  })();
}

注意:

  • tagsmetadata 是 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.ts

3.1 src/lib/rules/types.ts

定义 RuleRuleContext 接口:

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.tsx

5.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.tsxSidebar 函数提取。

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(校准路由注入内容)
  • view state + 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.tsx

6.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.tsx

7.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 个模块卡片:

  1. Content Flow(实数据):总内容数、反馈覆盖率、来源分布、最近导入时间。使用 stats props。
  2. Subscriptions(placeholder):标题 + "即将到来" 占位
  3. Learning(placeholder)
  4. Research(placeholder)
  5. Output(placeholder)

类别分布展示:categories 列表,每项显示 label + count + 百分比条。

验证

npm run dev
# /knowledge:Content Flow 模块显示 996 条内容、反馈覆盖率、11 个分类
# 其他 4 个模块显示 placeholder

Step 8:Governance 视图

目标

策略卡片(静态)+ 审计事件列表(实数据)。

文件

src/app/(workbench)/governance/page.tsx
src/components/governance/
  policy-list.tsx
  audit-box.tsx

8.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.tsxmoss-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.tsscripts/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.tstests/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 - 描述