lantern 0.3.0

Local-first, provenance-aware semantic search for agent activity
Documentation
//! User-feedback signal for chunks.
//!
//! A chunk's `feedback_score` is a signed net count — thumbs-up adds, thumbs-down
//! subtracts, neutral leaves it alone. `0` is the default and must produce
//! identical confidence to a store that never records any feedback. Scoring
//! lives in [`crate::search::compute_confidence`]; this module is just the
//! write path and a thin read helper for tests and library callers.

use anyhow::{Context, Result};
use rusqlite::OptionalExtension;
use serde::Serialize;

use crate::inspect::now_unix;
use crate::search::{
    ConfidenceBreakdown, compute_confidence_breakdown, format_confidence_breakdown_token,
};
use crate::store::Store;

/// A single feedback event applied to a chunk.
///
/// `Up` and `Down` are the common cases; `Custom` exists so callers with a
/// richer signal (e.g. a weighted rating) can apply it without parsing strings.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Feedback {
    Up,
    Down,
    Custom(i64),
}

impl Feedback {
    pub fn delta(self) -> i64 {
        match self {
            Feedback::Up => 1,
            Feedback::Down => -1,
            Feedback::Custom(n) => n,
        }
    }
}

/// Apply `feedback` to `chunk_id` and return the chunk's new net score.
///
/// Errors if the chunk does not exist — callers that want a silent upsert
/// should check first. The update is a single UPDATE statement so concurrent
/// feedback from multiple writers composes additively without losing votes.
pub fn record_feedback(store: &Store, chunk_id: &str, feedback: Feedback) -> Result<i64> {
    let delta = feedback.delta();
    let conn = store.conn();
    let rows = conn
        .execute(
            "UPDATE chunks SET feedback_score = feedback_score + ?1 WHERE id = ?2",
            rusqlite::params![delta, chunk_id],
        )
        .with_context(|| format!("applying feedback to chunk {chunk_id}"))?;
    if rows == 0 {
        anyhow::bail!("no chunk with id {chunk_id}");
    }
    get_feedback_score(store, chunk_id)?
        .ok_or_else(|| anyhow::anyhow!("chunk {chunk_id} disappeared after feedback update"))
}

/// Read the current feedback score for `chunk_id`, or `None` if the chunk is
/// not in the store.
pub fn get_feedback_score(store: &Store, chunk_id: &str) -> Result<Option<i64>> {
    let conn = store.conn();
    let score = conn
        .query_row(
            "SELECT feedback_score FROM chunks WHERE id = ?1",
            rusqlite::params![chunk_id],
            |row| row.get::<_, i64>(0),
        )
        .optional()?;
    Ok(score)
}

/// Result of a feedback write, shaped for the `lantern feedback` CLI output.
///
/// Carries the signed `delta` that was applied so callers with custom weights
/// get something more precise than the `up`/`down` label, alongside the new
/// net `score` read back from the row and the updated confidence snapshot.
#[derive(Debug, Clone, Serialize)]
pub struct FeedbackReport {
    pub chunk_id: String,
    pub delta: i64,
    pub score: i64,
    pub confidence: f64,
    pub confidence_breakdown: ConfidenceBreakdown,
    pub access_decay_at: Option<i64>,
}

fn current_confidence(
    store: &Store,
    chunk_id: &str,
    at_unix: i64,
) -> Result<(f64, ConfidenceBreakdown, Option<i64>)> {
    let conn = store.conn();
    let (access_decay_at, last_accessed_at, timestamp_unix, access_count, feedback_score, query_success_count) =
        conn.query_row(
            "SELECT access_decay_at, last_accessed_at, timestamp_unix, access_count, feedback_score, query_success_count FROM chunks WHERE id = ?1",
            rusqlite::params![chunk_id],
            |row| {
                Ok((
                    row.get(0)?,
                    row.get(1)?,
                    row.get(2)?,
                    row.get(3)?,
                    row.get(4)?,
                    row.get(5)?,
                ))
            },
        )?;
    let (confidence, confidence_breakdown) = compute_confidence_breakdown(
        at_unix,
        last_accessed_at,
        timestamp_unix,
        access_count,
        feedback_score,
        query_success_count,
    );
    Ok((confidence, confidence_breakdown, access_decay_at))
}

/// Apply `feedback` and package the result for display. Thin wrapper around
/// [`record_feedback`] so the CLI and MCP layers don't each re-derive the
/// report shape.
pub fn apply_feedback(store: &Store, chunk_id: &str, feedback: Feedback) -> Result<FeedbackReport> {
    let delta = feedback.delta();
    let score = record_feedback(store, chunk_id, feedback)?;
    let (confidence, confidence_breakdown, access_decay_at) =
        current_confidence(store, chunk_id, now_unix())?;
    Ok(FeedbackReport {
        chunk_id: chunk_id.to_string(),
        delta,
        score,
        confidence,
        confidence_breakdown,
        access_decay_at,
    })
}

pub(crate) fn format_text(report: &FeedbackReport) -> String {
    let mut text = format!(
        "feedback recorded: chunk={} delta={:+} score={} confidence={:.3} freshness_source={} {}",
        report.chunk_id,
        report.delta,
        report.score,
        report.confidence,
        report.confidence_breakdown.freshness_source.as_str(),
        format_confidence_breakdown_token(&report.confidence_breakdown),
    );
    if let Some(access_decay_at) = report.access_decay_at {
        text.push_str(&format!(" access_decay_at={access_decay_at}"));
    }
    text
}

pub fn print_text(report: &FeedbackReport) {
    println!("{}", format_text(report));
}

pub fn print_json(report: &FeedbackReport) -> Result<()> {
    println!("{}", serde_json::to_string_pretty(report)?);
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn sample_report() -> FeedbackReport {
        FeedbackReport {
            chunk_id: "chunk-1".into(),
            delta: 1,
            score: 3,
            confidence: 0.732,
            confidence_breakdown: ConfidenceBreakdown {
                freshness: 0.5,
                freshness_source: crate::search::FreshnessSource::TimestampUnix,
                access_boost: 0.5,
                base: 0.5,
                feedback_factor: 0.0,
                query_success_factor: 0.0,
            },
            access_decay_at: Some(1_700_000_800),
        }
    }

    #[test]
    fn format_text_surfaces_freshness_source_token() {
        let text = format_text(&sample_report());
        assert!(text.contains("freshness_source=timestamp_unix"), "{text}");
        assert!(text.contains("confidence=0.732"), "{text}");
        assert!(text.contains("access_decay_at=1700000800"), "{text}");
    }
}