From f118fe20787cb5cfcf845f25a4576bd96a621880 Mon Sep 17 00:00:00 2001 From: Alessandro Siniscalchi Date: Fri, 12 Jun 2026 17:26:19 +0000 Subject: [PATCH 1/2] feat(review): carry yesterday's tomorrow-attention signals into the daily review Each daily review was generated blind to the points of attention the previous review flagged for that day, so follow-through or recurrence could never be noticed. The service now loads yesterday's tomorrow_attention signals (best-effort: a failed or not-yet-extracted lookup degrades to none) and the prompt presents them as a provenance- marked context block. Prompt bumped to daily_review_with_entry_extractions_v2 with rules to note follow-through only when today's entries support it. Co-Authored-By: Claude Fable 5 --- .../daily_review_with_entry_extractions_v2.md | 28 +++ src/journal/review/generator.rs | 173 ++++++++++++++---- src/journal/review/prompt.rs | 2 +- src/journal/review/service.rs | 124 ++++++++++++- src/journal/review/wiring.rs | 4 +- src/journal/service/tests.rs | 3 +- src/workers/daily_review.rs | 5 +- 7 files changed, 292 insertions(+), 47 deletions(-) create mode 100644 prompts/daily_review_with_entry_extractions_v2.md diff --git a/prompts/daily_review_with_entry_extractions_v2.md b/prompts/daily_review_with_entry_extractions_v2.md new file mode 100644 index 00000000..9a50f9e9 --- /dev/null +++ b/prompts/daily_review_with_entry_extractions_v2.md @@ -0,0 +1,28 @@ +# Daily Review Prompt (with Entry Extractions) v2 + +You generate concise daily journal reviews. + +## Context + +You receive raw journal notes and, when available, structured extractions for those notes. +- Raw journal notes are the **source of truth**. +- Structured extractions are **analytical aids** provided by another process to help you notice patterns. + +You may also receive points of attention carried over from yesterday's review. +- Carried-over points are **context**, not facts about today. + +## Rules + +- Use only the data provided in the prompt. +- **Trust the raw journal notes if they conflict with the structured extractions.** +- Extractions may be imperfect; use them to notice repeated emotions, behaviors, needs, domains, and signals, but do not blindly repeat them. +- Summarize emotional and practical themes from today only. +- Identify notable patterns or tensions from today only. +- Suggest one or two practical points of attention for tomorrow. +- If today's entries clearly address or repeat a carried-over point of attention, note the follow-through or recurrence briefly; otherwise ignore the carried-over point. Do not invent progress or guilt the user about it. +- Keep the review concise, readable, and grounded. +- Match the main language used in the journal entries. +- If there are too few entries to identify meaningful themes, say so briefly. +- Avoid clinical diagnosis or therapy-style overreach. +- Do not overstate patterns or claim long-term patterns from one day of evidence. +- Do not include a top-level "Today's review" heading; the application adds it. diff --git a/src/journal/review/generator.rs b/src/journal/review/generator.rs index fe064fbb..17dda481 100644 --- a/src/journal/review/generator.rs +++ b/src/journal/review/generator.rs @@ -9,7 +9,10 @@ use rig::{ use thiserror::Error; use crate::{ - journal::review::{DailyReviewPrompt, DailyReviewPromptError, JournalEntryWithExtraction}, + journal::review::{ + DailyReviewPrompt, DailyReviewPromptError, JournalEntryWithExtraction, + signals::types::DailyReviewSignal, + }, prompts::{PromptSource, ResolvedPrompt}, }; @@ -61,6 +64,7 @@ pub trait ReviewGenerator: Send + Sync { async fn generate_daily_review( &self, entries: &[JournalEntryWithExtraction], + carried_attention: &[DailyReviewSignal], ) -> Result; } @@ -197,9 +201,10 @@ impl ReviewGenerator for RigOpenAiReviewGenerator { async fn generate_daily_review( &self, entries: &[JournalEntryWithExtraction], + carried_attention: &[DailyReviewSignal], ) -> Result { self.refresh_prompt().await?; - let prompt = build_daily_review_prompt(entries); + let prompt = build_daily_review_prompt(entries, carried_attention); let instructions = self.prompt.read().unwrap().text.clone(); self.provider .complete_daily_review(&self.config.model, &instructions, &prompt) @@ -208,7 +213,10 @@ impl ReviewGenerator for RigOpenAiReviewGenerator { } } -fn build_daily_review_prompt(entries: &[JournalEntryWithExtraction]) -> String { +fn build_daily_review_prompt( + entries: &[JournalEntryWithExtraction], + carried_attention: &[DailyReviewSignal], +) -> String { let formatted_entries = entries .iter() .map(|entry_with_ext| { @@ -232,6 +240,24 @@ fn build_daily_review_prompt(entries: &[JournalEntryWithExtraction]) -> String { .collect::>() .join("\n"); + let carried_section = if carried_attention.is_empty() { + String::new() + } else { + let points = carried_attention + .iter() + .map(|signal| { + format!( + "- {} (flagged on {}): \"{}\"", + signal.label, signal.review_date, signal.evidence + ) + }) + .collect::>() + .join("\n"); + format!( + "\n\nCarried over from yesterday's review — points of attention previously flagged for today:\n{points}" + ) + }; + format!( r#"Write a daily review using only these journal entries. @@ -244,7 +270,7 @@ Themes: - ... Pay attention tomorrow: -- ... +- ...{carried_section} Journal entries: {formatted_entries}"# @@ -264,7 +290,7 @@ pub mod fake { use async_trait::async_trait; use super::{ReviewGenerationError, ReviewGenerator}; - use crate::journal::review::JournalEntryWithExtraction; + use crate::journal::review::{JournalEntryWithExtraction, signals::types::DailyReviewSignal}; #[derive(Debug, Clone)] pub struct FakeReviewGenerator { @@ -273,6 +299,7 @@ pub mod fake { results: Arc>>>, calls: Arc, entries_seen: Arc>>>, + carried_seen: Arc>>>, } impl FakeReviewGenerator { @@ -291,6 +318,7 @@ pub mod fake { results: Arc::new(Mutex::new(VecDeque::from(results))), calls: Arc::new(AtomicUsize::new(0)), entries_seen: Arc::new(Mutex::new(Vec::new())), + carried_seen: Arc::new(Mutex::new(Vec::new())), } } @@ -301,6 +329,10 @@ pub mod fake { pub fn entries_seen(&self) -> Vec> { self.entries_seen.lock().unwrap().clone() } + + pub fn carried_seen(&self) -> Vec> { + self.carried_seen.lock().unwrap().clone() + } } #[async_trait] @@ -316,9 +348,14 @@ pub mod fake { async fn generate_daily_review( &self, entries: &[JournalEntryWithExtraction], + carried_attention: &[DailyReviewSignal], ) -> Result { self.calls.fetch_add(1, Ordering::SeqCst); self.entries_seen.lock().unwrap().push(entries.to_vec()); + self.carried_seen + .lock() + .unwrap() + .push(carried_attention.to_vec()); self.results .lock() @@ -454,11 +491,14 @@ mod tests { assert_eq!(generator.prompt_version(), "custom-prompt"); assert_eq!( generator - .generate_daily_review(&[JournalEntryWithExtraction { - id: "1".to_string(), - entry: entry(28, "wrote a test"), - extraction: None, - }]) + .generate_daily_review( + &[JournalEntryWithExtraction { + id: "1".to_string(), + entry: entry(28, "wrote a test"), + extraction: None, + }], + &[] + ) .await .unwrap(), "review text" @@ -480,11 +520,14 @@ mod tests { ); generator - .generate_daily_review(&[JournalEntryWithExtraction { - id: "1".to_string(), - entry: entry(28, "requested date entry"), - extraction: None, - }]) + .generate_daily_review( + &[JournalEntryWithExtraction { + id: "1".to_string(), + entry: entry(28, "requested date entry"), + extraction: None, + }], + &[], + ) .await .unwrap(); @@ -504,11 +547,14 @@ mod tests { ); let error = generator - .generate_daily_review(&[JournalEntryWithExtraction { - id: "1".to_string(), - entry: entry(28, "wrote a test"), - extraction: None, - }]) + .generate_daily_review( + &[JournalEntryWithExtraction { + id: "1".to_string(), + entry: entry(28, "wrote a test"), + extraction: None, + }], + &[], + ) .await .unwrap_err(); @@ -517,16 +563,54 @@ mod tests { #[test] fn generated_prompt_requests_review_format() { - let prompt_text = build_daily_review_prompt(&[JournalEntryWithExtraction { - id: "1".to_string(), - entry: entry(28, "finished the feature"), - extraction: None, - }]); + let prompt_text = build_daily_review_prompt( + &[JournalEntryWithExtraction { + id: "1".to_string(), + entry: entry(28, "finished the feature"), + extraction: None, + }], + &[], + ); assert!(prompt_text.contains("Summary:")); assert!(prompt_text.contains("Themes:")); assert!(prompt_text.contains("Pay attention tomorrow:")); assert!(prompt_text.contains("finished the feature")); + assert!(!prompt_text.contains("Carried over from yesterday")); + } + + #[test] + fn generated_prompt_includes_carried_over_attention_points() { + let carried = DailyReviewSignal { + id: 1, + daily_review_id: 1, + review_date: chrono::NaiveDate::from_ymd_opt(2026, 4, 27).unwrap(), + signal_type: crate::journal::review::signals::types::SignalType::TomorrowAttention, + label: "protect the morning focus block".to_string(), + status: None, + valence: None, + strength: 0.8, + confidence: 0.9, + evidence: "Planned to keep mornings meeting-free.".to_string(), + model: "m".to_string(), + prompt_version: "v1".to_string(), + created_at: Utc.with_ymd_and_hms(2026, 4, 27, 22, 0, 0).unwrap(), + updated_at: Utc.with_ymd_and_hms(2026, 4, 27, 22, 0, 0).unwrap(), + }; + + let prompt_text = build_daily_review_prompt( + &[JournalEntryWithExtraction { + id: "1".to_string(), + entry: entry(28, "finished the feature"), + extraction: None, + }], + &[carried], + ); + + assert!(prompt_text.contains("Carried over from yesterday's review")); + assert!(prompt_text.contains("protect the morning focus block")); + assert!(prompt_text.contains("flagged on 2026-04-27")); + assert!(prompt_text.contains("Planned to keep mornings meeting-free.")); } #[tokio::test] @@ -561,11 +645,14 @@ mod tests { .with_prompt_source(source); generator - .generate_daily_review(&[JournalEntryWithExtraction { - id: "1".to_string(), - entry: entry(28, "first"), - extraction: None, - }]) + .generate_daily_review( + &[JournalEntryWithExtraction { + id: "1".to_string(), + entry: entry(28, "first"), + extraction: None, + }], + &[], + ) .await .unwrap(); @@ -586,11 +673,14 @@ mod tests { .unwrap(); generator - .generate_daily_review(&[JournalEntryWithExtraction { - id: "1".to_string(), - entry: entry(28, "second"), - extraction: None, - }]) + .generate_daily_review( + &[JournalEntryWithExtraction { + id: "1".to_string(), + entry: entry(28, "second"), + extraction: None, + }], + &[], + ) .await .unwrap(); @@ -613,11 +703,14 @@ mod tests { needs: vec![], possible_patterns: vec![], }; - let prompt_text = build_daily_review_prompt(&[JournalEntryWithExtraction { - id: "1".to_string(), - entry: entry(28, "entry with extraction"), - extraction: Some(extraction), - }]); + let prompt_text = build_daily_review_prompt( + &[JournalEntryWithExtraction { + id: "1".to_string(), + entry: entry(28, "entry with extraction"), + extraction: Some(extraction), + }], + &[], + ); assert!(prompt_text.contains("entry with extraction")); assert!(prompt_text.contains("Entry #1")); diff --git a/src/journal/review/prompt.rs b/src/journal/review/prompt.rs index df1f6306..5ede9b69 100644 --- a/src/journal/review/prompt.rs +++ b/src/journal/review/prompt.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use crate::prompts::file::{self, PromptFile, PromptFileError}; -pub const DEFAULT_REVIEW_PROMPT_PATH: &str = "prompts/daily_review_with_entry_extractions_v1.md"; +pub const DEFAULT_REVIEW_PROMPT_PATH: &str = "prompts/daily_review_with_entry_extractions_v2.md"; const PROMPT_KIND: &str = "daily review"; diff --git a/src/journal/review/service.rs b/src/journal/review/service.rs index 2ce716a0..ce133662 100644 --- a/src/journal/review/service.rs +++ b/src/journal/review/service.rs @@ -1,7 +1,8 @@ use std::sync::Arc; -use chrono::NaiveDate; +use chrono::{Duration, NaiveDate}; use thiserror::Error; +use tracing::warn; use crate::errors::from_error_string; @@ -13,6 +14,10 @@ use crate::journal::{ JournalEntryWithExtraction, generator::ReviewGenerator, repository::{DailyReviewRepository, DailyReviewRepositoryError}, + signals::{ + repository::DailyReviewSignalRepository, + types::{DailyReviewSignal, SignalType}, + }, }, }; @@ -36,6 +41,7 @@ pub struct DailyReviewService { daily_reviews: DailyReviewRepository, journal_entries: JournalRepository, extractions: JournalEntryExtractionRepository, + signals: DailyReviewSignalRepository, generator: Arc, } @@ -57,6 +63,7 @@ impl DailyReviewService { daily_reviews: DailyReviewRepository, journal_entries: JournalRepository, extractions: JournalEntryExtractionRepository, + signals: DailyReviewSignalRepository, generator: G, ) -> Self where @@ -66,6 +73,7 @@ impl DailyReviewService { daily_reviews, journal_entries, extractions, + signals, generator: Arc::new(generator), } } @@ -92,11 +100,13 @@ impl DailyReviewService { return Ok(DailyReviewResult::EmptyDay); } + let carried_attention = self.carried_attention_from_previous_day(utc_date).await; + let model = self.generator.model(); match self .generator - .generate_daily_review(&entries_with_extractions) + .generate_daily_review(&entries_with_extractions, &carried_attention) .await { Ok(review_text) => { @@ -136,6 +146,30 @@ impl DailyReviewService { })) } + /// Yesterday's `tomorrow_attention` signals, carried into today's review + /// as context. Best-effort: the review is still generated when the lookup + /// fails or the signals have not been extracted yet. + async fn carried_attention_from_previous_day( + &self, + utc_date: NaiveDate, + ) -> Vec { + let previous_day = utc_date - Duration::days(1); + match self.signals.find_by_user_and_date(previous_day).await { + Ok(signals) => signals + .into_iter() + .filter(|signal| signal.signal_type == SignalType::TomorrowAttention) + .collect(), + Err(error) => { + warn!( + review_date = %utc_date, + error = %error, + "failed to load carried-over attention signals; generating review without them" + ); + Vec::new() + } + } + } + async fn fetch_entries_with_extractions( &self, date: NaiveDate, @@ -231,11 +265,12 @@ mod tests { let daily_reviews = DailyReviewRepository::new(pool.clone()); let journal_entries = JournalRepository::new(pool.clone()); - let extractions = JournalEntryExtractionRepository::new(pool); + let extractions = JournalEntryExtractionRepository::new(pool.clone()); let service = DailyReviewService::new( daily_reviews.clone(), journal_entries.clone(), extractions.clone(), + DailyReviewSignalRepository::new(pool), generator.clone(), ); @@ -606,6 +641,7 @@ mod tests { daily_reviews, journal_entries, extractions, + DailyReviewSignalRepository::new(pool.clone()), PoolClosingGenerator { pool }, ); @@ -631,12 +667,94 @@ mod tests { async fn generate_daily_review( &self, _entries: &[JournalEntryWithExtraction], + _carried_attention: &[DailyReviewSignal], ) -> Result { self.pool.close().await; Err(ReviewGenerationError::new("provider down")) } } + #[tokio::test] + async fn review_day_carries_yesterdays_tomorrow_attention_signals_into_generation() { + let pool = crate::database::test_pool().await; + let daily_reviews = DailyReviewRepository::new(pool.clone()); + let journal_entries = JournalRepository::new(pool.clone()); + let extractions = JournalEntryExtractionRepository::new(pool.clone()); + let signals = DailyReviewSignalRepository::new(pool); + let generator = FakeReviewGenerator::succeeding("review with carryover"); + let service = DailyReviewService::new( + daily_reviews.clone(), + journal_entries.clone(), + extractions, + signals.clone(), + generator.clone(), + ); + + // Yesterday: a completed review with one tomorrow_attention signal + // and one unrelated theme signal. + let yesterday = date() - Duration::days(1); + let yesterdays_review = daily_reviews + .upsert_completed(yesterday, "yesterday text", "m", "v1") + .await + .unwrap(); + signals + .replace_in_transaction( + yesterdays_review.id, + yesterday, + &[ + crate::journal::review::signals::types::DailyReviewSignalCandidate { + signal_type: SignalType::TomorrowAttention, + label: "protect the focus block".to_string(), + status: None, + valence: None, + strength: 0.8, + confidence: 0.9, + evidence: "Planned to keep mornings meeting-free.".to_string(), + }, + crate::journal::review::signals::types::DailyReviewSignalCandidate { + signal_type: SignalType::Theme, + label: "work pressure".to_string(), + status: None, + valence: None, + strength: 0.7, + confidence: 0.8, + evidence: "Mentions deadlines.".to_string(), + }, + ], + "m", + "v1", + ) + .await + .unwrap(); + + journal_entries + .store(&at_date(28, "1", "kept the morning free")) + .await + .unwrap(); + + service.review_day(date()).await.unwrap(); + + let carried = generator.carried_seen(); + assert_eq!(carried.len(), 1); + assert_eq!(carried[0].len(), 1, "only tomorrow_attention is carried"); + assert_eq!(carried[0][0].label, "protect the focus block"); + assert_eq!(carried[0][0].signal_type, SignalType::TomorrowAttention); + } + + #[tokio::test] + async fn review_day_passes_empty_carryover_when_yesterday_has_no_signals() { + let (service, _daily_reviews, journal_entries, _extractions, generator) = + setup(FakeReviewGenerator::succeeding("plain review")).await; + journal_entries + .store(&at_date(28, "1", "an entry")) + .await + .unwrap(); + + service.review_day(date()).await.unwrap(); + + assert_eq!(generator.carried_seen(), vec![Vec::new()]); + } + #[tokio::test] async fn review_day_fetches_completed_extractions_and_passes_them_to_generator() { let (service, _daily_reviews, journal_entries, extractions, generator) = diff --git a/src/journal/review/wiring.rs b/src/journal/review/wiring.rs index 698e4476..00c84cd1 100644 --- a/src/journal/review/wiring.rs +++ b/src/journal/review/wiring.rs @@ -8,6 +8,7 @@ use crate::{ review::{ DailyReviewPromptConfig, ReviewConfig, RigOpenAiReviewGenerator, repository::DailyReviewRepository, service::DailyReviewService, + signals::repository::DailyReviewSignalRepository, }, service::JournalService, }, @@ -72,7 +73,8 @@ pub fn build_daily_review_service( let daily_review_service = DailyReviewService::new( DailyReviewRepository::new(pool.clone()), JournalRepository::new(pool.clone()), - JournalEntryExtractionRepository::new(pool), + JournalEntryExtractionRepository::new(pool.clone()), + DailyReviewSignalRepository::new(pool), review_generator, ); diff --git a/src/journal/service/tests.rs b/src/journal/service/tests.rs index b429fc1c..4355eb39 100644 --- a/src/journal/service/tests.rs +++ b/src/journal/service/tests.rs @@ -85,8 +85,9 @@ async fn setup_with_daily_review_service( let extractions = JournalEntryExtractionRepository::new(pool.clone()); let daily_review_service = DailyReviewService::new( daily_review_repo.clone(), - JournalRepository::new(pool), + JournalRepository::new(pool.clone()), extractions, + crate::journal::review::signals::repository::DailyReviewSignalRepository::new(pool), generator, ); let service = diff --git a/src/workers/daily_review.rs b/src/workers/daily_review.rs index b7f51043..07121962 100644 --- a/src/workers/daily_review.rs +++ b/src/workers/daily_review.rs @@ -201,11 +201,14 @@ mod tests { let journal_entries = JournalRepository::new(pool.clone()); let daily_reviews = DailyReviewRepository::new(pool.clone()); - let extractions = JournalEntryExtractionRepository::new(pool); + let extractions = JournalEntryExtractionRepository::new(pool.clone()); let service = DailyReviewService::new( daily_reviews.clone(), journal_entries.clone(), extractions, + crate::journal::review::signals::repository::DailyReviewSignalRepository::new( + pool.clone(), + ), generator, ); let worker = DailyReviewDeliveryWorker::new( From c4c1ed267bc4a3f2bf525301e799d525450d01f7 Mon Sep 17 00:00:00 2001 From: Alessandro Siniscalchi Date: Fri, 12 Jun 2026 17:26:19 +0000 Subject: [PATCH 2/2] feat(signals): offer recent labels to the generator for vocabulary reuse Signal labels were normalized only by prompt aspiration, so semantically identical signals drifted across days ("plan switching" vs "switching plans") and never aggregated. The generator now receives the distinct labels used in the previous 90 days (most-used first, capped at 100) and the v2 prompt instructs it to reuse an existing label when the meaning matches, minting new labels only when none fits. Co-Authored-By: Claude Fable 5 --- prompts/daily_review_signal_extraction_v2.md | 47 ++++++ src/journal/review/signals/generator.rs | 82 ++++++++-- src/journal/review/signals/prompt.rs | 2 +- src/journal/review/signals/repository.rs | 153 ++++++++++++++++++- src/journal/review/signals/service.rs | 80 +++++++++- src/journal/review/signals/types.rs | 8 + 6 files changed, 358 insertions(+), 14 deletions(-) create mode 100644 prompts/daily_review_signal_extraction_v2.md diff --git a/prompts/daily_review_signal_extraction_v2.md b/prompts/daily_review_signal_extraction_v2.md new file mode 100644 index 00000000..0a6b0863 --- /dev/null +++ b/prompts/daily_review_signal_extraction_v2.md @@ -0,0 +1,47 @@ +You analyze a completed daily review, supported by the journal entries and structured extractions from the same day. + +Your task is to extract normalized day-level signals from the daily review. +Do not give advice. +Do not diagnose. +Do not write a reflection. +Do not invent claims that are not supported by the daily review or the source entries. +Do not infer cross-day trends. Each signal must represent that day only. + +Return a JSON object with a "signals" array. Each element is a signal with these fields: +- signal_type: one of "theme", "emotion", "behavior", "need", "tension", "pattern", "tomorrow_attention" +- label: short normalized label for the signal (must not be empty) +- status: need status (use only for need signals), null otherwise — one of: "activated", "unmet", "fulfilled", "unclear" +- valence: behavior valence (use only for behavior signals), null otherwise — one of: "positive", "negative", "ambiguous", "neutral", "unclear" +- strength: number 0.0–1.0, how strongly this signal appeared that day +- confidence: number 0.0–1.0, how certain you are that the signal is supported by the source material +- evidence: short, specific sentence grounded in the daily review or entries + +Signal type rules: +- theme: a recurring topic or domain that shaped the day +- emotion: a felt emotional state with clear presence in the review or entries +- behavior: something the user did or consistently did not do +- need: a psychological need or value that was salient, unmet, or fulfilled +- tension: an internal conflict or competing pull +- pattern: a day-level repeated pattern — only if the review or entries explicitly support it; never infer cross-day patterns +- tomorrow_attention: a specific point of attention or intention suggested for the next day + +Field constraints: +- behavior signals must have a valence; all other types must have valence null +- need signals must have a status; all other types must have status null +- strength and confidence must be between 0.0 and 1.0 +- evidence must be short and directly grounded in the provided material + +Label reuse: +- You may receive a list of known labels from previous days, grouped by signal type. +- When a signal you are about to emit has the same meaning as a known label of the same type, reuse that exact label instead of minting a new variant (e.g., reuse "plan switching" instead of writing "switching plans"). +- Only create a new label when no known label fits the meaning. +- Never force a known label onto a signal with a different meaning. + +Quality rules: +- Do not create a signal from a weak hint; omit it instead, or use very low confidence +- Do not create diagnosis-like signals (e.g., "anxiety disorder", "depression") +- Do not make identity-level claims about the user (e.g., "user is a perfectionist") +- Do not create a pattern signal unless the daily review text explicitly supports a within-day repetition +- Keep labels short and normalized (2–5 words) +- Keep evidence to one sentence +- Prefer omitting uncertain signals over generating them with low confidence labels diff --git a/src/journal/review/signals/generator.rs b/src/journal/review/signals/generator.rs index 1ebd4219..db4dd329 100644 --- a/src/journal/review/signals/generator.rs +++ b/src/journal/review/signals/generator.rs @@ -8,7 +8,10 @@ use rig::{ use thiserror::Error; use crate::{ - journal::review::{JournalEntryWithExtraction, signals::types::DailyReviewSignalsOutput}, + journal::review::{ + JournalEntryWithExtraction, + signals::types::{DailyReviewSignalsOutput, KnownSignalLabel}, + }, prompts::{PromptSource, ResolvedPrompt}, }; @@ -63,6 +66,7 @@ pub trait DailyReviewSignalGenerator: Send + Sync { &self, review_text: &str, entries: &[JournalEntryWithExtraction], + known_labels: &[KnownSignalLabel], ) -> Result; } @@ -204,9 +208,10 @@ impl DailyReviewSignalGenerator for RigOpenAiDailyReviewSignalGenerator { &self, review_text: &str, entries: &[JournalEntryWithExtraction], + known_labels: &[KnownSignalLabel], ) -> Result { self.refresh_prompt().await?; - let prompt = build_signal_extraction_prompt(review_text, entries); + let prompt = build_signal_extraction_prompt(review_text, entries, known_labels); let instructions = self.prompt.read().unwrap().text.clone(); self.provider .complete_signal_extraction(&self.config.model, &instructions, &prompt) @@ -218,6 +223,7 @@ impl DailyReviewSignalGenerator for RigOpenAiDailyReviewSignalGenerator { fn build_signal_extraction_prompt( review_text: &str, entries: &[JournalEntryWithExtraction], + known_labels: &[KnownSignalLabel], ) -> String { let formatted_entries = entries .iter() @@ -242,12 +248,25 @@ fn build_signal_extraction_prompt( .collect::>() .join("\n"); + let known_labels_section = if known_labels.is_empty() { + String::new() + } else { + let labels = known_labels + .iter() + .map(|known| format!("- {}: {}", known.signal_type.as_str(), known.label)) + .collect::>() + .join("\n"); + format!( + "\n\nKnown labels from previous days (reuse the exact label when the meaning matches):\n{labels}" + ) + }; + format!( r#"Daily review: {review_text} Journal entries: -{formatted_entries}"# +{formatted_entries}{known_labels_section}"# ) } @@ -263,7 +282,8 @@ pub mod fake { use super::{DailyReviewSignalGenerationError, DailyReviewSignalGenerator}; use crate::journal::review::{ - JournalEntryWithExtraction, signals::types::DailyReviewSignalsOutput, + JournalEntryWithExtraction, + signals::types::{DailyReviewSignalsOutput, KnownSignalLabel}, }; #[derive(Debug, Clone)] @@ -272,6 +292,7 @@ pub mod fake { prompt_version: String, result: Arc>>, calls: Arc, + known_labels_seen: Arc>>>, } impl FakeSignalGenerator { @@ -281,6 +302,7 @@ pub mod fake { prompt_version: "fake-signal-prompt-v1".to_string(), result: Arc::new(Mutex::new(Ok(output))), calls: Arc::new(AtomicUsize::new(0)), + known_labels_seen: Arc::new(Mutex::new(Vec::new())), } } @@ -292,12 +314,17 @@ pub mod fake { message, )))), calls: Arc::new(AtomicUsize::new(0)), + known_labels_seen: Arc::new(Mutex::new(Vec::new())), } } pub fn calls(&self) -> usize { self.calls.load(Ordering::SeqCst) } + + pub fn known_labels_seen(&self) -> Vec> { + self.known_labels_seen.lock().unwrap().clone() + } } #[async_trait] @@ -314,8 +341,13 @@ pub mod fake { &self, _review_text: &str, _entries: &[JournalEntryWithExtraction], + known_labels: &[KnownSignalLabel], ) -> Result { self.calls.fetch_add(1, Ordering::SeqCst); + self.known_labels_seen + .lock() + .unwrap() + .push(known_labels.to_vec()); self.result.lock().unwrap().clone() } } @@ -454,7 +486,7 @@ mod tests { ); let output = generator - .generate_signals("review text", &[entry("entry text")]) + .generate_signals("review text", &[entry("entry text")], &[]) .await .unwrap(); let calls = provider.calls(); @@ -482,7 +514,7 @@ mod tests { ); let error = generator - .generate_signals("review text", &[]) + .generate_signals("review text", &[], &[]) .await .unwrap_err(); @@ -502,17 +534,47 @@ mod tests { ); let output = generator - .generate_signals("review text", &[]) + .generate_signals("review text", &[], &[]) .await .unwrap(); assert!(output.signals.is_empty()); } + #[test] + fn build_prompt_includes_known_labels_when_present() { + let known = vec![ + KnownSignalLabel { + signal_type: SignalType::Theme, + label: "physical appearance".to_string(), + }, + KnownSignalLabel { + signal_type: SignalType::Behavior, + label: "plan switching".to_string(), + }, + ]; + + let prompt = build_signal_extraction_prompt("review text", &[], &known); + + assert!(prompt.contains("Known labels from previous days")); + assert!(prompt.contains("- theme: physical appearance")); + assert!(prompt.contains("- behavior: plan switching")); + } + + #[test] + fn build_prompt_omits_known_labels_section_when_empty() { + let prompt = build_signal_extraction_prompt("review text", &[], &[]); + + assert!(!prompt.contains("Known labels")); + } + #[test] fn build_prompt_includes_review_and_entries() { - let prompt = - build_signal_extraction_prompt("Today was hard.", &[entry("Felt anxious at work.")]); + let prompt = build_signal_extraction_prompt( + "Today was hard.", + &[entry("Felt anxious at work.")], + &[], + ); assert!(prompt.contains("Today was hard.")); assert!(prompt.contains("Felt anxious at work.")); @@ -541,7 +603,7 @@ mod tests { extraction: Some(extraction), }; - let prompt = build_signal_extraction_prompt("review text", &[entry_with_extraction]); + let prompt = build_signal_extraction_prompt("review text", &[entry_with_extraction], &[]); assert!(prompt.contains("Structured extraction:")); assert!(prompt.contains("Work stress")); diff --git a/src/journal/review/signals/prompt.rs b/src/journal/review/signals/prompt.rs index 96205f78..318e84d5 100644 --- a/src/journal/review/signals/prompt.rs +++ b/src/journal/review/signals/prompt.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use crate::prompts::file::{self, PromptFile, PromptFileError}; pub const DEFAULT_SIGNAL_EXTRACTION_PROMPT_PATH: &str = - "prompts/daily_review_signal_extraction_v1.md"; + "prompts/daily_review_signal_extraction_v2.md"; const PROMPT_KIND: &str = "signal extraction"; diff --git a/src/journal/review/signals/repository.rs b/src/journal/review/signals/repository.rs index 95759f18..933350ed 100644 --- a/src/journal/review/signals/repository.rs +++ b/src/journal/review/signals/repository.rs @@ -6,7 +6,7 @@ use crate::errors::from_error_string; use crate::journal::extraction::{BehaviorValence, NeedStatus}; -use super::types::{DailyReviewSignal, DailyReviewSignalCandidate, SignalType}; +use super::types::{DailyReviewSignal, DailyReviewSignalCandidate, KnownSignalLabel, SignalType}; #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum DailyReviewSignalRepositoryError { @@ -160,6 +160,46 @@ impl DailyReviewSignalRepository { Ok(count as u32) } + /// Distinct labels used by signals in the `days_back` days before + /// `before_date` (exclusive), most-used first. Feeds label reuse during + /// signal generation. + pub async fn find_recent_labels( + &self, + before_date: NaiveDate, + days_back: u32, + limit: u32, + ) -> Result, DailyReviewSignalRepositoryError> { + let window_start = before_date - chrono::Duration::days(i64::from(days_back)); + let rows = sqlx::query( + r#" + SELECT signal_type, label, COUNT(*) AS uses, MAX(review_date) AS last_seen + FROM daily_review_signals + WHERE review_date < ? AND review_date >= ? + GROUP BY signal_type, label + ORDER BY uses DESC, last_seen DESC, label ASC + LIMIT ? + "#, + ) + .bind(before_date.to_string()) + .bind(window_start.to_string()) + .bind(limit) + .fetch_all(&self.pool) + .await?; + + rows.into_iter() + .map(|row| { + let raw_type = row.get::("signal_type"); + let signal_type = SignalType::from_str(&raw_type).ok_or( + DailyReviewSignalRepositoryError::InvalidSignalType(raw_type), + )?; + Ok(KnownSignalLabel { + signal_type, + label: row.get("label"), + }) + }) + .collect() + } + pub async fn find_by_user_and_date( &self, review_date: NaiveDate, @@ -433,6 +473,117 @@ mod tests { } } + #[tokio::test] + async fn find_recent_labels_orders_by_use_count_and_excludes_the_target_date() { + let pool = crate::database::test_pool().await; + let repo = DailyReviewSignalRepository::new(pool.clone()); + let today = NaiveDate::from_ymd_opt(2026, 4, 28).unwrap(); + + // "plan switching" appears on two prior days, "control" on one. + for offset in [3i64, 2] { + let date = today - chrono::Duration::days(offset); + let review_id = insert_daily_review_for(&pool, date).await; + repo.replace_in_transaction( + review_id, + date, + &[DailyReviewSignalCandidate { + signal_type: SignalType::Behavior, + label: "plan switching".to_string(), + status: None, + valence: Some(BehaviorValence::Negative), + strength: 0.5, + confidence: 0.8, + evidence: "evidence".to_string(), + }], + "m", + "v1", + ) + .await + .unwrap(); + } + let yesterday = today - chrono::Duration::days(1); + let review_id = insert_daily_review_for(&pool, yesterday).await; + repo.replace_in_transaction( + review_id, + yesterday, + &[DailyReviewSignalCandidate { + signal_type: SignalType::Need, + label: "control".to_string(), + status: Some(NeedStatus::Unmet), + valence: None, + strength: 0.5, + confidence: 0.8, + evidence: "evidence".to_string(), + }], + "m", + "v1", + ) + .await + .unwrap(); + // A signal on the target date itself must not be offered. + let todays_review = insert_daily_review_for(&pool, today).await; + repo.replace_in_transaction( + todays_review, + today, + &[DailyReviewSignalCandidate { + signal_type: SignalType::Theme, + label: "same day".to_string(), + status: None, + valence: None, + strength: 0.5, + confidence: 0.8, + evidence: "evidence".to_string(), + }], + "m", + "v1", + ) + .await + .unwrap(); + + let labels = repo.find_recent_labels(today, 90, 10).await.unwrap(); + + assert_eq!( + labels, + vec![ + KnownSignalLabel { + signal_type: SignalType::Behavior, + label: "plan switching".to_string(), + }, + KnownSignalLabel { + signal_type: SignalType::Need, + label: "control".to_string(), + }, + ] + ); + } + + #[tokio::test] + async fn find_recent_labels_respects_the_limit() { + let pool = crate::database::test_pool().await; + let repo = DailyReviewSignalRepository::new(pool.clone()); + let today = NaiveDate::from_ymd_opt(2026, 4, 28).unwrap(); + let yesterday = today - chrono::Duration::days(1); + let review_id = insert_daily_review_for(&pool, yesterday).await; + let candidates: Vec = (0..5) + .map(|i| DailyReviewSignalCandidate { + signal_type: SignalType::Theme, + label: format!("theme {i}"), + status: None, + valence: None, + strength: 0.5, + confidence: 0.8, + evidence: "evidence".to_string(), + }) + .collect(); + repo.replace_in_transaction(review_id, yesterday, &candidates, "m", "v1") + .await + .unwrap(); + + let labels = repo.find_recent_labels(today, 90, 2).await.unwrap(); + + assert_eq!(labels.len(), 2); + } + #[tokio::test] async fn replace_inserts_signals_and_returns_them() { let (repo, _reviews, pool) = setup().await; diff --git a/src/journal/review/signals/service.rs b/src/journal/review/signals/service.rs index 117a4646..52862610 100644 --- a/src/journal/review/signals/service.rs +++ b/src/journal/review/signals/service.rs @@ -6,6 +6,11 @@ use tracing::{info, warn}; use crate::errors::from_error_string; +/// How far back the label vocabulary offered for reuse reaches. +const KNOWN_LABEL_WINDOW_DAYS: u32 = 90; +/// Cap on the vocabulary size included in the signal-extraction prompt. +const KNOWN_LABEL_LIMIT: u32 = 100; + use crate::journal::{ extraction::repository::JournalEntryExtractionRepository, repository::JournalRepository, @@ -110,9 +115,14 @@ impl DailyReviewSignalService { let entries = self.fetch_entries_with_extractions(review_date).await?; + let known_labels = self + .signals + .find_recent_labels(review_date, KNOWN_LABEL_WINDOW_DAYS, KNOWN_LABEL_LIMIT) + .await?; + let generation_result = self .generator - .generate_signals(&review_text, &entries) + .generate_signals(&review_text, &entries, &known_labels) .await; let output = match generation_result { @@ -231,7 +241,10 @@ mod tests { signals::{ generator::fake::FakeSignalGenerator, repository::DailyReviewSignalRepository, - types::{DailyReviewSignalCandidate, DailyReviewSignalsOutput, SignalType}, + types::{ + DailyReviewSignalCandidate, DailyReviewSignalsOutput, KnownSignalLabel, + SignalType, + }, }, }, }, @@ -306,6 +319,69 @@ mod tests { DailyReviewSignalsOutput { signals } } + #[tokio::test] + async fn generation_offers_recent_labels_for_reuse() { + let pool = crate::database::test_pool().await; + let daily_reviews = DailyReviewRepository::new(pool.clone()); + let journal_entries = JournalRepository::new(pool.clone()); + let extractions = + crate::journal::extraction::repository::JournalEntryExtractionRepository::new( + pool.clone(), + ); + let signals = DailyReviewSignalRepository::new(pool.clone()); + let generator = FakeSignalGenerator::succeeding(output_with(vec![theme_signal()])); + let service = DailyReviewSignalService::new( + daily_reviews.clone(), + journal_entries.clone(), + extractions, + signals.clone(), + generator.clone(), + ); + + // A signal stored three days earlier provides the vocabulary. + let earlier = date() - chrono::Duration::days(3); + let earlier_review = daily_reviews + .upsert_completed(earlier, "earlier review", "m", "v1") + .await + .unwrap(); + signals + .replace_in_transaction( + earlier_review.id, + earlier, + &[DailyReviewSignalCandidate { + signal_type: SignalType::Behavior, + label: "plan switching".to_string(), + status: None, + valence: Some(crate::journal::extraction::BehaviorValence::Negative), + strength: 0.6, + confidence: 0.8, + evidence: "Changed plans twice.".to_string(), + }], + "m", + "v1", + ) + .await + .unwrap(); + + journal_entries + .store(&incoming("today entry")) + .await + .unwrap(); + daily_reviews + .upsert_completed(date(), "today review", "m", "v1") + .await + .unwrap(); + + service.generate_signals_for_review(date()).await.unwrap(); + + let seen = generator.known_labels_seen(); + assert_eq!(seen.len(), 1); + assert!(seen[0].contains(&KnownSignalLabel { + signal_type: SignalType::Behavior, + label: "plan switching".to_string(), + })); + } + #[tokio::test] async fn returns_no_daily_review_when_review_does_not_exist() { let (service, _, _) = setup(FakeSignalGenerator::succeeding(output_with(vec![]))).await; diff --git a/src/journal/review/signals/types.rs b/src/journal/review/signals/types.rs index 4b1aa533..b5c61bbe 100644 --- a/src/journal/review/signals/types.rs +++ b/src/journal/review/signals/types.rs @@ -61,6 +61,14 @@ pub struct DailyReviewSignalsOutput { pub signals: Vec, } +/// A label already used by stored signals, offered to the generator so new +/// signals reuse existing vocabulary instead of minting near-duplicates. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KnownSignalLabel { + pub signal_type: SignalType, + pub label: String, +} + /// A persisted signal, linked to its source daily review. #[derive(Debug, Clone, PartialEq, Serialize)] pub struct DailyReviewSignal {