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_candidate、merge_or_create_project、merge_or_create_task、apply_entities、entity_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=true、kind=suggested_kind,让 CI、单测、端到端测试都不依赖在线 LLM。 - 终态不可逆:classifier 判为 noise 的实体转为
archived,entity_overrides写入status=archived。updater::update_existing检测到终态直接短路;之后的 sighting 只用于防重计数,不会把status、summary或 OKF 内容反推回active。
2. ClassifyInput
Section titled “2. ClassifyInput”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。
3. ClassifyVerdict
Section titled “3. ClassifyVerdict”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=app 或 kind=noise 时,is_real_entity 永远为 false。即便 LLM 在其他地方误判,prompt 也明确要求这两类不能进 active entity 表。
4. Prompt 规则
Section titled “4. Prompt 规则”prompt 在 classifier::build_prompt(input) 中拼装,其内容是规则描述,而不是硬编码词库:
- 把
raw_text、suggested_kind、app_metadata.category、surrounding_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(此时 suggestedproject=Cursor被拒);在 “和 Cursor 团队对接” 这句里同样被判为app,而不是person,即便单看大写字母像是一个人名。
5. 调用点
Section titled “5. 调用点”| 入口 | 触发 | 失败行为 |
|---|---|---|
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,已有实体也会被定期复判,因此噪声不会长期残留。
6. Fallback 链
Section titled “6. Fallback 链”LLM 可达 -> classifier::classify (语义防线) | v 失败 (LlmFailed / 超时 / DryRun=None)noise_filter::is_noise (结构防线: 空串 / 单字符 / 纯数字 / 路径段) | v 命中mark_candidate_rejected (终态)- DryRun 透明放过,让单测和端到端测试不依赖 LLM 即可跑通。
- 真实 LLM 失败时,fallback 不会过滤掉所有候选,只过滤明显的结构性垃圾,保证
observe不被阻塞。
7. DryRun Backend 行为
Section titled “7. DryRun Backend 行为”在 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。
8. 与 noise_filter 的关系
Section titled “8. 与 noise_filter 的关系”noise_filter 只保留结构层职责:
is_meaningful_summary:检查 summary 不是 UUID、纯路径或机器 dump。is_plausible_project_path:检查路径中是否含Code、Volumes等真实工作目录段。normalize_project_path:去掉路径末尾的斜杠、文件名等噪声。
语义判断(例如 “Cursor” 算不算 project)完全不在 noise_filter 内处理,而是全权交给 classifier。这个模块里没有可供 grep 的关键字或域名数组。
9. Sighting 防重(Schema 层)
Section titled “9. Sighting 防重(Schema 层)”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