Classifier Architecture
このコンテンツはまだ日本語訳がありません。
Full spec: 00-product-spec.md.
This document describes the entity classifier: the single LLM-based semantic judge that decides whether a candidate observed from the user’s activity is a real person, project, task, or app entity, or noise. It covers the input and output structs, the prompt rules, the call sites that route through it, the fallback chain, DryRun backend behavior, and how it relates to sighting dedup and importer idempotency.
1. Design
Section titled “1. Design”- Single judge:
crates/yzos-core/src/entity/classifier.rs::classify()is the only semantic gate for entity promotion. All five promotion and re-evaluation paths (promote_candidate,merge_or_create_project,merge_or_create_task,apply_entities,entity_purge_v06) go through this function. - Semantic judgment is not hardcoded: the classifier is designed to make every “is this a real entity” decision through the LLM rather than through keyword or domain lists baked into the code. This keeps the classifier adaptable to new tools, apps, and naming conventions without code changes.
- Layered defense:
noise_filteronly checks structural properties (empty strings, single characters, pure numbers, path segments); it does not judge semantics. When the classifier is unreachable, promotion falls back tonoise_filter(the LLM-failure fallback). - DryRun passthrough: when
scheduler.active_model_id()returnsNone, the classifier returnsis_real_entity=truewithkind=suggested_kind, so CI, unit, and end-to-end tests do not depend on a live LLM. - Terminal state is not reversible: an entity the classifier marks as
noise transitions to
archived, andentity_overridesrecordsstatus=archived.updater::update_existingshort-circuits on a terminal state; later sightings are only counted for dedup, never used to pushstatusorsummaryor OKF content back toactive.
2. ClassifyInput
Section titled “2. ClassifyInput”pub struct ClassifyInput { pub raw_text: String, // candidate text (display_name / hypothesized_name) pub source_kind: String, // origin: cron / llm_triage / clipboard / ai_tool pub source_id: String, // origin id (candidate_id / session_id) pub suggested_kind: Option<String>, // upstream-proposed kind (project / task / person) pub app_bundle_id: Option<String>, // matched bundle (com.figma.Desktop / com.tdesktop.Telegram) pub app_metadata: Option<AppMetadata>, // Info.plist LSApplicationCategoryType + CFBundleName pub surrounding_text: Option<String>, // recent sighting / source_text snippet, given to the LLM as context}app_metadata is central to the design: the macOS Info.plist
LSApplicationCategoryType (for example
public.app-category.social-networking) lets the classifier immediately
recognize that Telegram or Discord is a social app rather than a project.
observe/active_window reads this value on every foreground app switch and
upserts it into the app_metadata table; the classifier’s prompt builder
looks it up and includes it in the 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-normalized name (e.g. "YZOS Core") pub confidence: f32, pub reason: String,}Five kind values:
| kind | Meaning | Handling |
|---|---|---|
| person | a real person | promoted to entity |
| project | a real project (code repository, documentation project) | promoted to entity |
| task | a concrete, trackable work item | promoted to entity |
| app | a tool or application itself (Telegram, Cursor) | forced reject |
| noise | a command fragment, UI text, or template string | forced reject |
When kind=app or kind=noise, is_real_entity is always false. Even if
the LLM misjudges elsewhere, the prompt explicitly instructs that neither
value may enter the active entity table.
4. Prompt Rules
Section titled “4. Prompt Rules”The prompt is assembled in classifier::build_prompt(input). It carries
rules rather than a hardcoded keyword list:
- The LLM is shown
raw_text,suggested_kind,app_metadata.category, andsurrounding_text, and asked whether the candidate is a real person, project, or task. - It is told explicitly: tool names, single-token commands, UUIDs, path segments, and template strings are noise; apps in categories such as social-networking, utilities, or developer-tools are never a project in their own right.
- Output is constrained with GBNF plus a JSON schema;
kindis restricted to{person, project, task, app, noise}. - The LLM decides ambiguous cases from context. For example, “Cursor”
mentioned in surrounding text such as “used Cursor to edit the yzos code”
is judged
app(a suggestedproject=Cursoris rejected); the same name in “syncing with the Cursor team” is also judgedapp, notperson, even though it looks like a proper name by capitalization alone.
5. Call Sites
Section titled “5. Call Sites”| Entry point | Trigger | Failure behavior |
|---|---|---|
updater::promote_candidate |
cron entity_promote |
fallback to noise_filter |
engine::merge_or_create_project |
apply_session_triage |
skipped |
engine::merge_or_create_task |
apply_session_triage |
skipped |
supply::pipeline::apply_entities |
inside process_session_end |
inline promotion skipped |
maintain::jobs::run_entity_purge_v06 |
cron, every 6h | state left unchanged |
Three of these are promotion paths that gate new candidates before they become entities; the fourth is a re-evaluation path that periodically revisits existing active entities. Together they form a closed loop: a new candidate must pass LLM judgment to become active, and existing entities are periodically re-checked, so noise cannot persist indefinitely.
6. Fallback Chain
Section titled “6. Fallback Chain”LLM reachable -> classifier::classify (semantic gate) | v failure (LlmFailed / timeout / DryRun=None)noise_filter::is_noise (structural gate: empty string / single char / pure number / path segment) | v matchmark_candidate_rejected (terminal state)- DryRun passes candidates through transparently, so unit and end-to-end tests run without an LLM.
- When the live LLM fails, the fallback does not reject every candidate; it
only filters out obviously structural garbage, so
observeis not blocked.
7. DryRun Backend Behavior
Section titled “7. DryRun Backend Behavior”Under InferenceProfile { llm_backend: LlmBackend::DryRun, .. }:
scheduler.active_model_id()returnsNone.- The first check in
classifier::classifydetectsNoneand returnsClassifyVerdict { is_real_entity: true, kind: input.suggested_kind, ... }immediately. - None of the three promotion entry points block; candidates are written to
entities using their original
suggested_kind. - CI, unit tests, and
cargo test --workspacerun end to end without an LLM sidecar.
8. Relationship to noise_filter
Section titled “8. Relationship to noise_filter”noise_filter retains only structural responsibilities:
is_meaningful_summary: checks that a summary is not a UUID, a bare path, or a raw machine dump.is_plausible_project_path: checks that a path contains segments likeCode,Volumes, or another real working-directory marker.normalize_project_path: strips trailing slashes, file names, and similar noise from a path.
Semantic judgment, such as whether “Cursor” counts as a project, is not
handled in noise_filter at all; it is entirely delegated to the
classifier. There are no keyword or domain arrays in this module to grep
for.
9. Sighting Dedup (Schema Layer)
Section titled “9. Sighting Dedup (Schema Layer)”Migration 018 adds a UNIQUE (entity_id, source_kind, source_id, fingerprint) constraint on entity_sightings. fingerprint = blake3(source_id || summary). This constraint means the code does not rely
on a “SELECT then INSERT” check-then-act pattern; a duplicate write for the
same record is rejected directly by PostgreSQL. All nine add_sighting call
sites pass a fingerprint.
10. Importer Idempotency Across Restarts (Schema + Application Layer)
Section titled “10. Importer Idempotency Across Restarts (Schema + Application Layer)”Migration 020 introduces importer_seen (importer_name, session_id, record_id, last_seen). The aider importer locks by session segment; generic
importers lock by record_id. After a process restart, an importer resumes
from WHERE record_id NOT IN (SELECT record_id FROM importer_seen) instead
of rescanning full session history, and does not produce duplicate
sightings.
See Also
Section titled “See Also”- 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