use crate::database::schema::{ entities, entity_facts, entity_photo_links, favorites, image_exif, insight_generation_jobs, libraries, personas, photo_insights, precomputed_reels, user_ai_prefs, users, video_preview_clips, }; use serde::Serialize; /// Possible statuses for an insight generation job. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, FromSqlRow)] #[serde(rename_all = "snake_case")] pub enum InsightJobStatus { Running, Completed, Failed, Cancelled, } impl InsightJobStatus { pub fn as_str(&self) -> &'static str { match self { Self::Running => "running", Self::Completed => "completed", Self::Failed => "failed", Self::Cancelled => "cancelled", } } pub fn parse(s: &str) -> Self { match s { "running" => Self::Running, "completed" => Self::Completed, "failed" => Self::Failed, "cancelled" => Self::Cancelled, other => { log::warn!( "Unknown InsightJobStatus value: {:?}, treating as failed", other ); Self::Failed } } } } impl std::fmt::Display for InsightJobStatus { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.as_str()) } } /// Type of insight generation (standard vs agentic). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum InsightGenerationType { Standard, Agentic, } impl InsightGenerationType { pub fn as_str(&self) -> &'static str { match self { Self::Standard => "standard", Self::Agentic => "agentic", } } } impl std::fmt::Display for InsightGenerationType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.as_str()) } } #[derive(Insertable)] #[diesel(table_name = users)] pub struct InsertUser<'a> { pub username: &'a str, pub password: &'a str, } #[derive(Serialize, Queryable, Clone, Debug)] pub struct User { pub id: i32, pub username: String, #[serde(skip_serializing)] pub password: String, } #[derive(Insertable)] #[diesel(table_name = favorites)] pub struct InsertFavorite<'a> { pub userid: &'a i32, #[diesel(column_name = rel_path)] pub path: &'a str, } #[derive(Serialize, Queryable, Clone, Debug)] pub struct Favorite { pub id: i32, pub userid: i32, #[diesel(column_name = rel_path)] pub path: String, } #[derive(Insertable)] #[diesel(table_name = image_exif)] pub struct InsertImageExif { pub library_id: i32, #[diesel(column_name = rel_path)] pub file_path: String, pub camera_make: Option, pub camera_model: Option, pub lens_model: Option, pub width: Option, pub height: Option, pub orientation: Option, pub gps_latitude: Option, pub gps_longitude: Option, pub gps_altitude: Option, pub focal_length: Option, pub aperture: Option, pub shutter_speed: Option, pub iso: Option, pub date_taken: Option, pub created_time: i64, pub last_modified: i64, pub content_hash: Option, pub size_bytes: Option, /// 64-bit pHash (DCT) packed as i64. NULL for videos and decode failures. pub phash_64: Option, /// 64-bit dHash (gradient). NULL for videos and decode failures. pub dhash_64: Option, /// Which step of the canonical-date waterfall populated `date_taken`: /// `"exif"` | `"exiftool"` | `"filename"` | `"fs_time"`. NULL when /// `date_taken` is NULL (no source resolved it). The per-tick backfill /// drain re-resolves rows whose source is `"fs_time"` once exiftool /// has had a chance to run. pub date_taken_source: Option, /// True UTC instant (unix seconds) under the new date contract /// (ADR-0001). NULL while the row is still on the legacy convention — /// either unclassified (filename branch, ticket #116) or pending the /// backfill drain (#118). pub date_utc: Option, /// Camera offset (local − UTC, minutes) that produced `date_utc`. /// NULL for direct-true-UTC branches and unclassified rows. pub camera_offset_minutes: Option, /// Why the offset is what it is: `offset_tag` | `gps` | /// `assumed_server` | `utc_direct` | `manual`. See `camera_tz::TzSource`. pub date_taken_tz_source: Option, } // Field order matches the post-migration column order in `image_exif`. #[derive(Serialize, Queryable, Clone, Debug)] pub struct ImageExif { pub id: i32, pub library_id: i32, #[diesel(column_name = rel_path)] pub file_path: String, pub camera_make: Option, pub camera_model: Option, pub lens_model: Option, pub width: Option, pub height: Option, pub orientation: Option, pub gps_latitude: Option, pub gps_longitude: Option, pub gps_altitude: Option, pub focal_length: Option, pub aperture: Option, pub shutter_speed: Option, pub iso: Option, pub date_taken: Option, pub created_time: i64, pub last_modified: i64, pub content_hash: Option, pub size_bytes: Option, pub phash_64: Option, pub dhash_64: Option, /// When non-null, this row is a soft-marked duplicate of the file /// whose `content_hash` matches this value. The default `/photos` /// listing filters such rows out. pub duplicate_of_hash: Option, /// Unix seconds at which the resolve was committed. pub duplicate_decided_at: Option, /// Which step of the canonical-date waterfall populated `date_taken`. /// Plus `"manual"` when the operator has set it via POST /image/exif/date. pub date_taken_source: Option, /// Snapshot of the prior `date_taken` taken on first manual override. /// NULL when no override is active. POST /image/exif/date/clear restores /// `date_taken` from this column and nulls it back out. pub original_date_taken: Option, /// Snapshot of the prior `date_taken_source` taken on first manual /// override. NULL when no override is active. pub original_date_taken_source: Option, /// L2-normalized CLIP image embedding (raw little-endian float32 bytes; /// length depends on the model — 768×4 for ViT-L/14, 512×4 for ViT-B/32). /// NULL until Apollo's CLIP service has encoded this photo via the /// backfill drain. Used by `/photos/search` for semantic queries. pub clip_embedding: Option>, /// Which CLIP model produced `clip_embedding` (e.g. `"ViT-L/14"`). A /// swap of `APOLLO_CLIP_MODEL` re-eligibilizes rows whose stored /// version differs so the drain rebuilds them. pub clip_model_version: Option, /// True UTC instant (unix seconds) under the new date contract /// (ADR-0001). NULL while the row is still on the legacy convention. pub date_utc: Option, /// Camera offset (local − UTC, minutes) that produced `date_utc`. pub camera_offset_minutes: Option, /// How the offset was determined. See `camera_tz::TzSource`. pub date_taken_tz_source: Option, /// Snapshot of the prior `date_utc` taken on first manual override. /// NULL when no override is active. POST /image/exif/date/clear /// restores the new-contract triple from these columns. pub original_date_utc: Option, /// Snapshot of the prior `camera_offset_minutes` (first override). pub original_camera_offset_minutes: Option, /// Snapshot of the prior `date_taken_tz_source` (first override). pub original_date_taken_tz_source: Option, } #[derive(Insertable, Clone)] #[diesel(table_name = photo_insights)] pub struct InsertPhotoInsight { pub library_id: i32, #[diesel(column_name = rel_path)] pub file_path: String, pub title: String, pub summary: String, pub generated_at: i64, pub model_version: String, pub is_current: bool, pub training_messages: Option, /// `"local"` (Ollama with images) | `"hybrid"` (local vision + OpenRouter chat). pub backend: String, /// JSON array of insight ids whose `training_messages` were compressed /// and injected into the system prompt as few-shot exemplars when this /// row was generated. `None` means no few-shot was used (pristine /// generation). Used downstream to filter out contaminated rows when /// assembling an unbiased training / evaluation set. pub fewshot_source_ids: Option, /// Bytes-keyed identity. When present, this insight is considered /// to belong to the content rather than the path — see CLAUDE.md /// "Multi-library data model". The DAO populates this from /// `image_exif.content_hash` at insert time when known; rows /// inserted before the hash is available stay null and the /// reconciliation pass backfills them. pub content_hash: Option, pub num_ctx: Option, pub temperature: Option, pub top_p: Option, pub top_k: Option, pub min_p: Option, pub system_prompt: Option, pub persona_id: Option, pub prompt_eval_count: Option, pub eval_count: Option, } #[derive(Serialize, Queryable, Clone, Debug)] pub struct PhotoInsight { pub id: i32, pub library_id: i32, #[diesel(column_name = rel_path)] pub file_path: String, pub title: String, pub summary: String, pub generated_at: i64, pub model_version: String, pub is_current: bool, pub training_messages: Option, pub approved: Option, /// `"local"` (Ollama with images) | `"hybrid"` (local vision + OpenRouter chat). pub backend: String, pub fewshot_source_ids: Option, pub content_hash: Option, pub num_ctx: Option, pub temperature: Option, pub top_p: Option, pub top_k: Option, pub min_p: Option, pub system_prompt: Option, pub persona_id: Option, pub prompt_eval_count: Option, pub eval_count: Option, } // --- Libraries --- #[derive(Serialize, Queryable, Clone, Debug)] pub struct LibraryRow { pub id: i32, pub name: String, pub root_path: String, pub created_at: i64, /// Operator kill switch. `false` = the watcher skips this library /// entirely (no probe, no ingest, no maintenance) and orphan-GC /// treats it as out-of-scope for the all-online consensus rule. /// Toggle via SQL today — there is intentionally no HTTP endpoint /// for library mutation (see CLAUDE.md "Multi-library data model"). pub enabled: bool, /// Per-library excluded paths/patterns, stored comma-separated /// (same shape as the global `EXCLUDED_DIRS` env var). NULL = no /// extra excludes for this library; the global env var still /// applies. The runtime `Library` struct parses this into a /// `Vec` and the walker applies the union of (global, /// library) excludes when scanning. Use case: mount a parent /// directory while another library covers a child subtree. pub excluded_dirs: Option, } #[derive(Insertable)] #[diesel(table_name = libraries)] pub struct InsertLibrary<'a> { pub name: &'a str, pub root_path: &'a str, pub created_at: i64, pub enabled: bool, pub excluded_dirs: Option<&'a str>, } // --- Knowledge memory models --- #[derive(Insertable, Clone)] #[diesel(table_name = entities)] pub struct InsertEntity { pub name: String, pub entity_type: String, pub description: String, pub embedding: Option>, pub confidence: f32, pub status: String, pub created_at: i64, pub updated_at: i64, } #[derive(Serialize, Queryable, Clone, Debug)] pub struct Entity { pub id: i32, pub name: String, pub entity_type: String, pub description: String, pub embedding: Option>, pub confidence: f32, pub status: String, pub created_at: i64, pub updated_at: i64, } #[derive(Insertable, Clone)] #[diesel(table_name = entity_facts)] pub struct InsertEntityFact { pub subject_entity_id: i32, pub predicate: String, pub object_entity_id: Option, pub object_value: Option, pub source_photo: Option, pub source_insight_id: Option, pub confidence: f32, pub status: String, pub created_at: i64, /// Which persona authored this fact. Shared entities, persona-tagged /// facts: each persona accumulates its own voice over the same /// real-world referents. Defaults to `'default'` for legacy rows /// (see migration 2026-05-09-000000). pub persona_id: String, /// Author's user_id. Required for the composite FK to /// `personas(user_id, persona_id)` (migration 2026-05-10-000000) and /// for cross-user fact isolation: two users with the same 'default' /// persona must not see each other's facts. Always paired with /// `persona_id` — they're a unit. pub user_id: i32, /// Real-world period the fact is/was true (unix seconds). NULL on /// either side = unbounded — `valid_from IS NULL` reads as /// "always-true-back-to-the-beginning", `valid_until IS NULL` as /// "still-true-now-or-unknown". Distinguishes valid time from /// transaction time (`created_at` is when we recorded the fact, /// not when it was true in the world). See migration /// 2026-05-10-000100. pub valid_from: Option, pub valid_until: Option, /// Points at the entity_facts.id that replaced this one. Set by /// the supersede endpoint; status flips to 'superseded' in the /// same transaction. See migration 2026-05-10-000200. pub superseded_by: Option, /// Provenance for model audit — see migration 2026-05-10-000300. /// `created_by_model` is the LLM identifier (e.g. "qwen2.5:7b", /// "anthropic/claude-sonnet-4") or NULL for legacy / manual rows. /// `created_by_backend` is "local" / "hybrid" / "manual" / NULL. pub created_by_model: Option, pub created_by_backend: Option, /// Audit trail for mutations after creation — see migration /// 2026-05-10-000500. `last_modified_*` stamp on any update /// (status flip, valid-time edit, supersede, manual PATCH); /// `last_modified_at` is unix seconds. NULL on rows that have /// never been touched since creation. pub last_modified_by_model: Option, pub last_modified_by_backend: Option, pub last_modified_at: Option, } #[derive(Serialize, Queryable, Clone, Debug)] pub struct EntityFact { pub id: i32, pub subject_entity_id: i32, pub predicate: String, pub object_entity_id: Option, pub object_value: Option, pub source_photo: Option, pub source_insight_id: Option, pub confidence: f32, pub status: String, pub created_at: i64, pub persona_id: String, pub user_id: i32, pub valid_from: Option, pub valid_until: Option, pub superseded_by: Option, pub created_by_model: Option, pub created_by_backend: Option, pub last_modified_by_model: Option, pub last_modified_by_backend: Option, pub last_modified_at: Option, } #[derive(Insertable, Clone)] #[diesel(table_name = entity_photo_links)] pub struct InsertEntityPhotoLink { pub entity_id: i32, pub library_id: i32, #[diesel(column_name = rel_path)] pub file_path: String, pub role: String, } #[derive(Serialize, Queryable, Clone, Debug)] pub struct EntityPhotoLink { pub id: i32, pub entity_id: i32, pub library_id: i32, #[diesel(column_name = rel_path)] pub file_path: String, pub role: String, } // --- Personas --- #[derive(Insertable)] #[diesel(table_name = personas)] pub struct InsertPersona<'a> { pub user_id: i32, pub persona_id: &'a str, pub name: &'a str, pub system_prompt: &'a str, pub is_built_in: bool, pub include_all_memories: bool, pub created_at: i64, pub updated_at: i64, /// "Strict mode" — agent reads only see facts with status = /// 'reviewed' (human-verified). Default false. See migration /// 2026-05-10-000400. pub reviewed_only_facts: bool, /// Gate for the agent's update_fact / supersede_fact tools. /// Default false — fresh personas let the agent create but not /// alter or replace. Operator opts in once a model has earned /// trust. See migration 2026-05-10-000500. pub allow_agent_corrections: bool, } #[derive(Serialize, Queryable, Clone, Debug)] pub struct Persona { pub id: i32, pub user_id: i32, pub persona_id: String, pub name: String, pub system_prompt: String, pub is_built_in: bool, pub include_all_memories: bool, pub created_at: i64, pub updated_at: i64, pub reviewed_only_facts: bool, pub allow_agent_corrections: bool, } #[derive(Insertable)] #[diesel(table_name = video_preview_clips)] pub struct InsertVideoPreviewClip { pub library_id: i32, #[diesel(column_name = rel_path)] pub file_path: String, pub status: String, pub created_at: String, pub updated_at: String, } #[derive(Serialize, Queryable, Clone, Debug)] pub struct VideoPreviewClip { pub id: i32, pub library_id: i32, #[diesel(column_name = rel_path)] pub file_path: String, pub status: String, pub duration_seconds: Option, pub file_size_bytes: Option, pub error_message: Option, pub created_at: String, pub updated_at: String, } #[derive(Insertable)] #[diesel(table_name = insight_generation_jobs)] pub struct InsertInsightGenerationJob { pub library_id: i32, #[diesel(column_name = file_path)] pub path: String, #[diesel(column_name = generation_type)] pub gen_type: String, pub status: String, pub started_at: i64, } #[derive(Queryable, Serialize, Clone, Debug)] pub struct InsightGenerationJob { pub id: i32, pub library_id: i32, #[diesel(column_name = file_path)] pub path: String, #[diesel(column_name = generation_type)] pub gen_type: String, pub status: String, pub started_at: i64, pub completed_at: Option, pub result_insight_id: Option, pub error_message: Option, } // --- Precomputed reels ------------------------------------------------------- #[derive(Insertable)] #[diesel(table_name = precomputed_reels)] pub struct InsertablePrecomputedReel { pub span: String, pub library_key: String, pub cache_key: String, pub output_path: String, pub title: String, pub media_count: i32, pub render_version: i32, pub tz_offset_minutes: i32, pub voice: Option, pub generated_at: i64, } #[derive(Serialize, Queryable, Clone, Debug)] pub struct PrecomputedReel { pub id: i32, pub span: String, pub library_key: String, pub cache_key: String, pub output_path: String, pub title: String, pub media_count: i32, pub render_version: i32, pub tz_offset_minutes: i32, pub voice: Option, pub generated_at: i64, } // --- User AI preferences (Section E) ---------------------------------------- #[derive(Queryable, Insertable, Debug, Clone, serde::Deserialize, serde::Serialize)] #[diesel(table_name = user_ai_prefs)] pub struct UserAiPrefs { pub id: i32, pub voice: Option, pub tz_offset_minutes: Option, pub library: Option, pub updated_at: i64, /// User's display time zone as an IANA name (DST-proof). NULL = app /// default (`camera_tz::DEFAULT_DISPLAY_TZ`). The legacy /// `tz_offset_minutes` column stays frozen for rollback. pub display_tz: Option, /// Last/explicit insight + chat model choice (local id or OpenRouter id). /// NULL = current per-request / env default behavior. pub default_model: Option, /// `"local"` | `"hybrid"` — insight/chat backend. NULL = per-request / /// hard default ("local"). pub default_backend: Option, /// Active persona for persona chat. NULL = hard default ("default"). pub default_persona_id: Option, /// Reel span: `"day"` | `"week"` | `"month"`. NULL = per-request / /// hard default (day). pub reel_span: Option, /// Reel segment cap. NULL = per-request / hard default. pub reel_max_segments: Option, } #[derive(Insertable, Debug, Clone, serde::Deserialize, serde::Serialize)] #[diesel(table_name = user_ai_prefs)] pub struct UpsertUserAiPrefs { pub voice: Option, pub tz_offset_minutes: Option, pub library: Option, pub updated_at: i64, pub display_tz: Option, pub default_model: Option, pub default_backend: Option, pub default_persona_id: Option, pub reel_span: Option, pub reel_max_segments: Option, }