lantern 0.2.3

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::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.
#[derive(Debug, Clone, Serialize)]
pub struct FeedbackReport {
    pub chunk_id: String,
    pub delta: i64,
    pub score: i64,
}

/// 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)?;
    Ok(FeedbackReport {
        chunk_id: chunk_id.to_string(),
        delta,
        score,
    })
}

pub fn print_text(report: &FeedbackReport) {
    println!(
        "feedback recorded: chunk={} delta={:+} score={}",
        report.chunk_id, report.delta, report.score
    );
}

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