use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Feed { pub id: Uuid, pub url: String, pub title: String, pub last_fetched_at: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Article { pub id: Uuid, pub feed_id: Uuid, pub url: String, pub title: String, pub summary: String, pub published_at: Option>, /// Short canonical tags assigned at ingest time (e.g. by the LLM stage), /// used both for display and as keys into `TopicAffinities`. pub topics: Vec, /// Cosine similarity between the article embedding and the user's /// preference-profile embedding. Cheap first-pass filter score. pub embedding_score: Option, /// 0.0-1.0 relevance judgment from the LLM stage, only computed for /// articles that clear the embedding-score shortlist threshold. pub llm_score: Option, /// Final blended score actually used for ranking/surfacing, see /// `scoring::score_article`. pub final_score: Option, pub estimated_read_seconds: Option, } /// A single recorded interaction between the user and an article. Every /// event is appended to an immutable log (see `feedsignal-db`); affinities /// are derived from replaying/aggregating these, never mutated in place, /// so the scoring model can be recomputed or tuned retroactively. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ReadingEvent { pub id: Uuid, pub article_id: Uuid, pub occurred_at: DateTime, pub outcome: ReadingOutcome, } #[derive(Debug, Clone, Serialize, Deserialize)] pub enum ReadingOutcome { /// Shown in the feed list but no further interaction (yet). Impression, /// User opened the article. `dwell_seconds` is filled in later via an /// update event (e.g. on tab close / navigation away) once known. Opened { dwell_seconds: Option }, Starred, /// Explicitly marked not relevant, independent of whether it was opened. Dismissed, }