huginn sources: CLI door for SearchSources

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-31 06:25:26 +01:00
commit d77747023e
3 changed files with 95 additions and 2 deletions

View file

@ -31,7 +31,7 @@ use cultnet_rs::{PROJECTION_DOCUMENT, Selection};
use huginn_mind::eureka_pipeline::{Line, PipelineDocument, PipelineRef, Short, Slug, pipeline_key};
use huginn_mind::{
BATCH_MAX, Faculty, HuginnMindRequest, HuginnMindResponse, MindRefusal, PipelineAdmissionBatch, PipelineAdmissionOutcome,
PipelinePageItems, PipelineProvenance, PipelineSelectionPage, SemanticQuery,
PipelinePageItems, PipelineProvenance, PipelineSelectionPage, SemanticQuery, SourceQuery,
};
use serde_json::Value;
@ -83,6 +83,20 @@ enum Command {
#[arg(long)]
documents: bool,
},
/// Search the public forge repos' code and docs by meaning.
Sources {
/// What to find, in words.
text: String,
/// Look in this repo alone (its forge name).
#[arg(long)]
repo: Option<String>,
/// How many chunks (default 10, at most 50).
#[arg(long)]
top_k: Option<u32>,
/// Print the answer as JSON.
#[arg(long)]
json: bool,
},
#[command(flatten)]
Recipe(Recipe),
/// Admit a batch of documents, whole or not at all, and read it back.
@ -168,6 +182,7 @@ fn run(settings: &Settings, command: Command) -> Result<u8, Trouble> {
let semantic = near.map(|text| SemanticQuery { text: Line(text), top_k: top_k.unwrap_or(DEFAULT_TOP_K) });
query(settings, selection, semantic, None, documents)
}
Command::Sources { text, repo, top_k, json } => sources(settings, text, repo, top_k, json),
Command::Recipe(recipe) => {
let ask = recipe.ask()?;
query(settings, door::selection_value(&ask.selection)?, ask.semantic, ask.page.cursor, ask.page.documents)
@ -249,6 +264,22 @@ fn query(
}
}
fn sources(settings: &Settings, text: String, repo: Option<String>, top_k: Option<u32>, json: bool) -> Result<u8, Trouble> {
let query = SourceQuery { text: Line(text), top_k: top_k.unwrap_or(DEFAULT_TOP_K), repo: repo.map(Short) };
match settings.ask(|instance| HuginnMindRequest::SearchSources { instance: instance.clone(), query })? {
HuginnMindResponse::Sources(found) => {
if json {
say(serde_json::to_string_pretty(&found).map_err(|error| Trouble::Internal(error.to_string()))?);
} else {
say(render::source_hits(&found));
}
Ok(0)
}
HuginnMindResponse::Refused(refusal) => Ok(refused(&refusal)),
other => Err(unexpected(&other)),
}
}
fn show(page: &PipelineSelectionPage) {
match &page.items {
PipelinePageItems::Headers(headers) => {
@ -392,6 +423,7 @@ fn unexpected(answer: &HuginnMindResponse) -> Trouble {
HuginnMindResponse::Query(_) => "query",
HuginnMindResponse::Persona(_) => "a persona",
HuginnMindResponse::PersonaPut(_) => "a persona put",
HuginnMindResponse::Sources(_) => "source hits",
HuginnMindResponse::Refused(_) => "a refusal",
HuginnMindResponse::Deferred(_) => "a deferral",
};

View file

@ -2,7 +2,7 @@
use std::fmt::Write;
use huginn_mind::{IndexStatus, PersonaStatus, PipelineDocumentSummary, PipelineDocumentView, PipelineStatus, PipelineStatusSummary};
use huginn_mind::{SourceHits, IndexStatus, PersonaStatus, PipelineDocumentSummary, PipelineDocumentView, PipelineStatus, PipelineStatusSummary};
use serde::Serialize;
use serde_json::Value;
@ -96,6 +96,32 @@ pub fn header(item: &PipelineDocumentSummary) -> String {
line.chars().take(HEADER_MAX_CHARS).collect()
}
/// Source hits as the CLI prints them: per hit a citation line,
/// `repo:path:start-end @commit7 score`, then the chunk's text, and a blank line
/// between hits. Nothing for no hits.
pub fn source_hits(found: &SourceHits) -> String {
found
.hits
.iter()
.map(|hit| {
format!(
"{}:{}:{}-{} @{} {:.3}
{}
",
hit.repo,
hit.path,
hit.line_start,
hit.line_end,
&hit.commit[..hit.commit.len().min(7)],
hit.score,
hit.text
)
})
.collect::<Vec<_>>()
.join("
")
}
/// A fact as one token: a string, a number, a flag, a reference's id, or a
/// list of those joined by commas. Anything deeper is not a header fact.
fn scalar(value: &Value) -> Option<String> {
@ -121,6 +147,35 @@ mod tests {
use super::*;
/// The citation line is what an agent copies: repo, path, the inclusive
/// line range and the first seven of the commit.
#[test]
fn a_source_hit_prints_as_a_citation_then_its_text() {
let hit = |path: &str, text: &str| huginn_mind::SourceHit {
repo: "Huginn".into(),
path: path.into(),
commit: "0123456789abcdef".into(),
line_start: 3,
line_end: 9,
score: 0.87654,
text: text.into(),
};
let found = SourceHits {
indexed_under: huginn_mind::SourceModel { name: "m".into(), digest: "d".into() },
hits: vec![hit("a.rs", "one"), hit("b/c.rs", "two")],
};
assert_eq!(source_hits(&found), "Huginn:a.rs:3-9 @0123456 0.877
one
Huginn:b/c.rs:3-9 @0123456 0.877
two
");
let empty = SourceHits { indexed_under: found.indexed_under.clone(), hits: vec![] };
assert_eq!(source_hits(&empty), "");
let json = serde_json::to_string(&found).unwrap();
assert_eq!(serde_json::from_str::<SourceHits>(&json).unwrap(), found, "--json round-trips");
}
#[test]
fn a_fact_is_one_token_or_nothing() {
assert_eq!(scalar(&json!("")), None);

View file

@ -92,6 +92,12 @@ impl<S: MindStore> IndexSink<S> for Inert {
fn search(&mut self, _text: &str, _top_k: u32, _among: Vec<String>) -> Result<SearchTicket, String> {
Err("this index cannot search".into())
}
fn search_sources(&mut self, _query: &huginn_mind::SourceQuery) -> Result<SearchTicket, String> {
Err("this index cannot search".into())
}
fn sources_searched(&mut self) -> Vec<(SearchTicket, Result<huginn_mind::SourceHits, String>)> {
Vec::new()
}
fn search_deadline(&self) -> Duration {
Duration::ZERO
}