feedsignal/crates/llm/src/lib.rs
Austin Schaefer 341ff9517a
Some checks failed
CI / test (pull_request) Has been cancelled
CI / audit (pull_request) Has been cancelled
CI / check (pull_request) Has been cancelled
Apply cargo fmt across the workspace
Needed for the new fmt-check CI step to pass; the repo had never had
formatting enforced before. No logic changes.
2026-08-21 13:10:08 +02:00

115 lines
4.2 KiB
Rust

use anyhow::{Context, Result};
use rig_core::client::{CompletionClient, EmbeddingsClient, Nothing};
use rig_core::completion::CompletionModel;
use rig_core::embeddings::EmbeddingModel as _;
use rig_core::providers::ollama;
use serde::Deserialize;
/// Thin wrapper around a local Ollama instance via rig. Two models are used
/// deliberately: a small/cheap embedding model for the first-pass filter
/// over every article, and a larger chat model reserved for the shortlist
/// that clears the embedding threshold (see `feedsignal_core::scoring`).
pub struct Llm {
client: ollama::Client,
embedding_model: String,
embedding_dims: usize,
chat_model: String,
}
#[derive(Debug, Deserialize)]
pub struct RelevanceJudgment {
pub score: f32,
pub rationale: String,
}
impl Llm {
/// `base_url` e.g. "http://localhost:11434". Model names must already
/// be pulled locally, e.g. `ollama pull nomic-embed-text` and
/// `ollama pull llama3.1`. `embedding_dims` must match the pulled
/// embedding model (768 for nomic-embed-text, 384 for all-minilm).
pub fn new(
base_url: &str,
embedding_model: impl Into<String>,
embedding_dims: usize,
chat_model: impl Into<String>,
) -> Result<Self> {
let client = ollama::Client::builder()
.api_key(Nothing)
.base_url(base_url)
.build()
.context("failed to build ollama client")?;
Ok(Self {
client,
embedding_model: embedding_model.into(),
embedding_dims,
chat_model: chat_model.into(),
})
}
pub async fn embed(&self, text: &str) -> Result<Vec<f32>> {
let model = self
.client
.embedding_model_with_ndims(&self.embedding_model, self.embedding_dims);
let embedding = model
.embed_text(text)
.await
.context("ollama embedding request failed")?;
Ok(embedding.vec.into_iter().map(|v| v as f32).collect())
}
/// Runs the second-stage relevance judgment for one article. Called
/// only for the embedding-similarity shortlist, not every article —
/// see `feedsignal-db::shortlist_for_llm_scoring`.
pub async fn judge_relevance(
&self,
preferences_description: &str,
topic_affinities_summary: &str,
article_title: &str,
article_summary: &str,
) -> Result<RelevanceJudgment> {
let model = self.client.completion_model(&self.chat_model);
let prompt = format!(
"Preferences: {preferences_description}\n\
Historical topic affinities: {topic_affinities_summary}\n\n\
Article title: {article_title}\n\
Article summary: {article_summary}"
);
let request = model
.completion_request(prompt)
.preamble(
"You screen articles for a personal RSS reader. Given the \
reader's stated preferences, their historical topic \
affinities, and one article, respond with ONLY a JSON \
object: {\"score\": <0.0-1.0>, \"rationale\": \"<one \
sentence>\"}. score is how relevant this specific article \
is to this specific reader right now."
.to_string(),
)
.build();
let response = model
.completion(request)
.await
.context("ollama chat request failed")?;
let text: String = response
.choice
.into_iter()
.filter_map(|content| match content {
rig_core::completion::AssistantContent::Text(t) => Some(t.text),
_ => None,
})
.collect();
parse_judgment(&text)
}
}
fn parse_judgment(raw: &str) -> Result<RelevanceJudgment> {
// Models sometimes wrap JSON in prose or code fences despite instructions;
// take the outermost {...} span rather than trusting the whole response.
let start = raw.find('{').context("no JSON object in LLM response")?;
let end = raw.rfind('}').context("no JSON object in LLM response")?;
let json = &raw[start..=end];
Ok(serde_json::from_str(json)?)
}