Skip to main content

lantern/
feedback.rs

1//! User-feedback signal for chunks.
2//!
3//! A chunk's `feedback_score` is a signed net count — thumbs-up adds, thumbs-down
4//! subtracts, neutral leaves it alone. `0` is the default and must produce
5//! identical confidence to a store that never records any feedback. Scoring
6//! lives in [`crate::search::compute_confidence`]; this module is just the
7//! write path and a thin read helper for tests and library callers.
8
9use anyhow::{Context, Result};
10use rusqlite::OptionalExtension;
11use serde::Serialize;
12
13use crate::inspect::now_unix;
14use crate::search::{
15    ConfidenceBreakdown, compute_confidence_breakdown, format_confidence_breakdown_token,
16};
17use crate::store::Store;
18
19/// A single feedback event applied to a chunk.
20///
21/// `Up` and `Down` are the common cases; `Custom` exists so callers with a
22/// richer signal (e.g. a weighted rating) can apply it without parsing strings.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Feedback {
25    Up,
26    Down,
27    Custom(i64),
28}
29
30impl Feedback {
31    pub fn delta(self) -> i64 {
32        match self {
33            Feedback::Up => 1,
34            Feedback::Down => -1,
35            Feedback::Custom(n) => n,
36        }
37    }
38}
39
40/// Apply `feedback` to `chunk_id` and return the chunk's new net score.
41///
42/// Errors if the chunk does not exist — callers that want a silent upsert
43/// should check first. The update is a single UPDATE statement so concurrent
44/// feedback from multiple writers composes additively without losing votes.
45pub fn record_feedback(store: &Store, chunk_id: &str, feedback: Feedback) -> Result<i64> {
46    let delta = feedback.delta();
47    let conn = store.conn();
48    let rows = conn
49        .execute(
50            "UPDATE chunks SET feedback_score = feedback_score + ?1 WHERE id = ?2",
51            rusqlite::params![delta, chunk_id],
52        )
53        .with_context(|| format!("applying feedback to chunk {chunk_id}"))?;
54    if rows == 0 {
55        anyhow::bail!("no chunk with id {chunk_id}");
56    }
57    get_feedback_score(store, chunk_id)?
58        .ok_or_else(|| anyhow::anyhow!("chunk {chunk_id} disappeared after feedback update"))
59}
60
61/// Read the current feedback score for `chunk_id`, or `None` if the chunk is
62/// not in the store.
63pub fn get_feedback_score(store: &Store, chunk_id: &str) -> Result<Option<i64>> {
64    let conn = store.conn();
65    let score = conn
66        .query_row(
67            "SELECT feedback_score FROM chunks WHERE id = ?1",
68            rusqlite::params![chunk_id],
69            |row| row.get::<_, i64>(0),
70        )
71        .optional()?;
72    Ok(score)
73}
74
75/// Result of a feedback write, shaped for the `lantern feedback` CLI output.
76///
77/// Carries the signed `delta` that was applied so callers with custom weights
78/// get something more precise than the `up`/`down` label, alongside the new
79/// net `score` read back from the row and the updated confidence snapshot.
80#[derive(Debug, Clone, Serialize)]
81pub struct FeedbackReport {
82    pub chunk_id: String,
83    pub delta: i64,
84    pub score: i64,
85    pub confidence: f64,
86    pub confidence_breakdown: ConfidenceBreakdown,
87    pub access_decay_at: Option<i64>,
88}
89
90fn current_confidence(
91    store: &Store,
92    chunk_id: &str,
93    at_unix: i64,
94) -> Result<(f64, ConfidenceBreakdown, Option<i64>)> {
95    let conn = store.conn();
96    let (access_decay_at, last_accessed_at, timestamp_unix, access_count, feedback_score, query_success_count) =
97        conn.query_row(
98            "SELECT access_decay_at, last_accessed_at, timestamp_unix, access_count, feedback_score, query_success_count FROM chunks WHERE id = ?1",
99            rusqlite::params![chunk_id],
100            |row| {
101                Ok((
102                    row.get(0)?,
103                    row.get(1)?,
104                    row.get(2)?,
105                    row.get(3)?,
106                    row.get(4)?,
107                    row.get(5)?,
108                ))
109            },
110        )?;
111    let (confidence, confidence_breakdown) = compute_confidence_breakdown(
112        at_unix,
113        last_accessed_at,
114        timestamp_unix,
115        access_count,
116        feedback_score,
117        query_success_count,
118    );
119    Ok((confidence, confidence_breakdown, access_decay_at))
120}
121
122/// Apply `feedback` and package the result for display. Thin wrapper around
123/// [`record_feedback`] so the CLI and MCP layers don't each re-derive the
124/// report shape.
125pub fn apply_feedback(store: &Store, chunk_id: &str, feedback: Feedback) -> Result<FeedbackReport> {
126    let delta = feedback.delta();
127    let score = record_feedback(store, chunk_id, feedback)?;
128    let (confidence, confidence_breakdown, access_decay_at) =
129        current_confidence(store, chunk_id, now_unix())?;
130    Ok(FeedbackReport {
131        chunk_id: chunk_id.to_string(),
132        delta,
133        score,
134        confidence,
135        confidence_breakdown,
136        access_decay_at,
137    })
138}
139
140pub(crate) fn format_text(report: &FeedbackReport) -> String {
141    let mut text = format!(
142        "feedback recorded: chunk={} delta={:+} score={} confidence={:.3} freshness_source={} {}",
143        report.chunk_id,
144        report.delta,
145        report.score,
146        report.confidence,
147        report.confidence_breakdown.freshness_source.as_str(),
148        format_confidence_breakdown_token(&report.confidence_breakdown),
149    );
150    if let Some(access_decay_at) = report.access_decay_at {
151        text.push_str(&format!(" access_decay_at={access_decay_at}"));
152    }
153    text
154}
155
156pub fn print_text(report: &FeedbackReport) {
157    println!("{}", format_text(report));
158}
159
160pub fn print_json(report: &FeedbackReport) -> Result<()> {
161    println!("{}", serde_json::to_string_pretty(report)?);
162    Ok(())
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    fn sample_report() -> FeedbackReport {
170        FeedbackReport {
171            chunk_id: "chunk-1".into(),
172            delta: 1,
173            score: 3,
174            confidence: 0.732,
175            confidence_breakdown: ConfidenceBreakdown {
176                freshness: 0.5,
177                freshness_source: crate::search::FreshnessSource::TimestampUnix,
178                access_boost: 0.5,
179                base: 0.5,
180                feedback_factor: 0.0,
181                query_success_factor: 0.0,
182            },
183            access_decay_at: Some(1_700_000_800),
184        }
185    }
186
187    #[test]
188    fn format_text_surfaces_freshness_source_token() {
189        let text = format_text(&sample_report());
190        assert!(text.contains("freshness_source=timestamp_unix"), "{text}");
191        assert!(text.contains("confidence=0.732"), "{text}");
192        assert!(text.contains("access_decay_at=1700000800"), "{text}");
193    }
194}