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/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/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 { 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(