跳转到内容

Classifier Architecture

完整规格见 00-product-spec.md

本文档描述实体 classifier:一个基于 LLM 的单一语义判定器,负责判断从用户活动中观察到的候选是否为真实的 person、project、task、app 实体,还是 noise。内容涵盖输入输出 struct、prompt 规则、经由该函数的调用点、fallback 链、DryRun backend 行为,以及它与 sighting 防重、importer 幂等的关系。

  • 单一判定器crates/yzos-core/src/entity/classifier.rs::classify() 是 entity promote 的唯一语义防线。所有五个 promote / 复判通路(promote_candidatemerge_or_create_projectmerge_or_create_taskapply_entitiesentity_purge_v06)全部经过这个函数。
  • 语义判断不硬编码:classifier 的设计原则是把“这是不是真实 entity”的每一次判断都交给 LLM,而不是代码里维护关键字或域名词库。这让 classifier 能应对新出现的工具、app 和命名习惯,而无需改代码。
  • 结构防线分层noise_filter 只判结构(空串、单字符、纯数字、路径段),不判语义。classifier 不可达时,promote 退到 noise_filter(LLM 失败时的 fallback)。
  • DryRun 透明放过:当 scheduler.active_model_id() 返回 None 时,classifier 返回 is_real_entity=truekind=suggested_kind,让 CI、单测、端到端测试都不依赖在线 LLM。
  • 终态不可逆:classifier 判为 noise 的实体转为 archivedentity_overrides 写入 status=archivedupdater::update_existing 检测到终态直接短路;之后的 sighting 只用于防重计数,不会把 statussummary 或 OKF 内容反推回 active
pub struct ClassifyInput {
pub raw_text: String, // 待判定文本 (display_name / hypothesized_name)
pub source_kind: String, // 来源: cron / llm_triage / clipboard / ai_tool
pub source_id: String, // 来源 ID (candidate_id / session_id)
pub suggested_kind: Option<String>, // 上游提议的 kind (project / task / person)
pub app_bundle_id: Option<String>, // 命中 bundle (com.figma.Desktop / com.tdesktop.Telegram)
pub app_metadata: Option<AppMetadata>, // Info.plist LSApplicationCategoryType + CFBundleName
pub surrounding_text: Option<String>, // 最近 sighting / source_text 片段, 给 LLM 上下文
}

app_metadata 是设计中的关键字段:macOS Info.plist 的 LSApplicationCategoryType(例如 public.app-category.social-networking)让 classifier 一眼看出 Telegram、Discord 是社交 app 而不是 project。observe/active_window 在前台 app 切换时读取该值并 UPSERT 到 app_metadata 表,classifier 的 prompt 拼装逻辑会查表并把结果带入 prompt。

pub struct ClassifyVerdict {
pub is_real_entity: bool,
pub kind: Option<String>, // person / project / task / app / noise
pub display_name: Option<String>, // LLM 规范化后的名字 (例: "YZOS Core")
pub confidence: f32,
pub reason: String,
}

五类 kind:

kind 含义 处理
person 真实人名 落 entity
project 真实项目(代码仓库、文档项目) 落 entity
task 具体可追踪的工作项 落 entity
app 工具或应用本身(Telegram、Cursor) 强制 reject
noise 命令片段、UI 文案、模板字符串 强制 reject

kind=appkind=noise 时,is_real_entity 永远为 false。即便 LLM 在其他地方误判,prompt 也明确要求这两类不能进 active entity 表。

prompt 在 classifier::build_prompt(input) 中拼装,其内容是规则描述,而不是硬编码词库:

  • raw_textsuggested_kindapp_metadata.categorysurrounding_text 都给 LLM 看,让它判断这条候选是否是真实的 person、project、task。
  • 明确告诉它:工具名、单 token 命令、UUID、路径段、模板字符串都是 noise;social-networking、utilities、developer-tools 这类分类下的 app 本身永远不是 project。
  • 输出通过 GBNF 加 JSON schema 强约束,kind 限定在 {person, project, task, app, noise}
  • 边界情况由 LLM 结合上下文自行判断。例如 “Cursor” 在 surrounding_text 含 “用 Cursor 改 yzos 代码” 时会被判为 app(此时 suggested project=Cursor 被拒);在 “和 Cursor 团队对接” 这句里同样被判为 app,而不是 person,即便单看大写字母像是一个人名。
入口 触发 失败行为
updater::promote_candidate cron entity_promote fallback 到 noise_filter
engine::merge_or_create_project apply_session_triage 跳过(skipped)
engine::merge_or_create_task apply_session_triage 跳过(skipped)
supply::pipeline::apply_entities process_session_end 跳过 inline promote
maintain::jobs::run_entity_purge_v06 cron 每 6h 一次 保留原状态

其中三个是 promote 通路,在新候选进入前拦截;第四个是复判通路,定期重新审视已有的 active 实体。四者共同构成一个闭环:新候选必须经过 LLM 判定才能变为 active,已有实体也会被定期复判,因此噪声不会长期残留。

LLM 可达 -> classifier::classify (语义防线)
|
v 失败 (LlmFailed / 超时 / DryRun=None)
noise_filter::is_noise (结构防线: 空串 / 单字符 / 纯数字 / 路径段)
|
v 命中
mark_candidate_rejected (终态)
  • DryRun 透明放过,让单测和端到端测试不依赖 LLM 即可跑通。
  • 真实 LLM 失败时,fallback 不会过滤掉所有候选,只过滤明显的结构性垃圾,保证 observe 不被阻塞。

InferenceProfile { llm_backend: LlmBackend::DryRun, .. } 下:

  • scheduler.active_model_id() 返回 None
  • classifier::classify 的第一步检测到 None,直接返回 ClassifyVerdict { is_real_entity: true, kind: input.suggested_kind, ... }
  • 三个 promote 入口都不会被阻塞,candidate 会以原始 suggested_kind 落成 entity。
  • CI、单测、cargo test --workspace 全程不需要 LLM sidecar。

noise_filter 只保留结构层职责:

  • is_meaningful_summary:检查 summary 不是 UUID、纯路径或机器 dump。
  • is_plausible_project_path:检查路径中是否含 CodeVolumes 等真实工作目录段。
  • normalize_project_path:去掉路径末尾的斜杠、文件名等噪声。

语义判断(例如 “Cursor” 算不算 project)完全不在 noise_filter 内处理,而是全权交给 classifier。这个模块里没有可供 grep 的关键字或域名数组。

migration 018 在 entity_sightings 上加了 UNIQUE (entity_id, source_kind, source_id, fingerprint) 约束。fingerprint = blake3(source_id || summary)。这条 UNIQUE 约束让代码不必依赖“先 SELECT 再 INSERT”这种 check-then-act 模式:同一条记录的二次写入会被 PostgreSQL 直接拒绝。add_sighting 的九个调用点都改为传入 fingerprint。

10. Importer 跨重启幂等(Schema + 应用层)

Section titled “10. Importer 跨重启幂等(Schema + 应用层)”

migration 020 引入了 importer_seen (importer_name, session_id, record_id, last_seen)。aider importer 按 session segment 加锁;通用 importer 按 record_id 加锁。进程重启后,importer 从 WHERE record_id NOT IN (SELECT record_id FROM importer_seen) 继续处理,不会重新扫描整个历史 session,也不会产生重复的 sighting。

  • migrations/018_entity_sighting_unique.sql
  • migrations/019_app_metadata.sql
  • migrations/020_importer_seen.sql
  • crates/yzos-core/src/entity/classifier.rs
  • crates/yzos-core/src/entity/updater.rs#promote_candidate
  • crates/yzos-core/src/entity/engine.rs#merge_or_create_project
  • crates/yzos-core/src/maintain/jobs.rs#run_entity_purge_v06
  • crates/yzos-core/src/observe/app_meta.rs
  • crates/yzos-core/src/supply/pipeline.rs#apply_entities