huginn sources --front: search only a repo's front-page files #27

Merged
eureka merged 1 commit from eureka/prose-index-map into main 2026-11-10 14:51:21 +00:00
11 changed files with 141 additions and 18 deletions

View file

@ -104,6 +104,10 @@ enum Command {
/// Look in this repo alone (its forge name).
#[arg(long)]
repo: Option<String>,
/// Look only in repos' front-page files (README.md, README, readme.md,
/// README.markdown, CLAUDE.md, AGENTS.md at the repo root).
#[arg(long)]
front: bool,
/// How many chunks (default 10, at most 50).
#[arg(long)]
top_k: Option<u32>,
@ -273,9 +277,10 @@ fn run(settings: &Settings, command: Command) -> Result<u8, Trouble> {
Command::Sources {
text,
repo,
front,
top_k,
json,
} => sources(settings, text, repo, top_k, json),
} => sources(settings, text, repo, front, top_k, json),
Command::Recipe(Recipe::Blocked { spec_id, page }) => {
blocked(settings, &spec_id, page.cursor, page.documents)
}
@ -434,6 +439,7 @@ fn sources(
settings: &Settings,
text: String,
repo: Option<String>,
front: bool,
top_k: Option<u32>,
json: bool,
) -> Result<u8, Trouble> {
@ -441,6 +447,7 @@ fn sources(
text: Line(text),
top_k: top_k.unwrap_or(DEFAULT_TOP_K),
repo: repo.map(Short),
front,
};
match settings.ask(|instance| HuginnMindRequest::SearchSources {
instance: instance.clone(),

View file

@ -1283,6 +1283,7 @@ impl huginn_daemon::sources::SourceIndex for Desk {
&mut self,
_vector: &[f32],
repo: Option<&str>,
front: bool,
limit: u32,
_within: Duration,
) -> anyhow::Result<Vec<huginn_daemon::sources::SourceRow>> {
@ -1290,6 +1291,7 @@ impl huginn_daemon::sources::SourceIndex for Desk {
.held
.iter()
.filter(|payload| repo.is_none_or(|repo| payload.repo == repo))
.filter(|payload| !front || huginn_daemon::sources::FRONT_PAGE_FILES.contains(&payload.path.as_str()))
.take(limit as usize)
.map(|payload| huginn_daemon::sources::SourceRow { score: 0.9, payload: payload.clone() })
.collect())
@ -1370,6 +1372,16 @@ fn sources_prints_a_citation_for_a_search_answered_by_a_daemon() {
assert!(!found.out.contains("README") && !found.out.contains("Other"), "{}", found.out);
}
/// `huginn sources --front` end to end: only the front-page file answers, in
/// the repo asked for, with the line range to cite.
#[test]
fn sources_front_prints_only_the_front_page_with_its_lines() {
let server = source_daemon("d1", "d1");
let found = at(server.addr, &["sources", "what is this", "--front", "--repo", "Demo"], None);
assert_eq!(found.code, 0, "{}", found.err);
assert_eq!(found.out.trim_end(), "Demo:README.md:1-2 @abcdef1 0.900\n# Demo", "{}", found.out);
}
/// A daemon whose embedder is not the model the index was built under refuses
/// the search naming both, and the CLI exits 1 with that word.
#[test]

View file

@ -636,7 +636,7 @@ pub(crate) mod tests {
let mut daemon = Daemon::new(mind, SourcesIndex { asked: Arc::clone(&asked), hits: hits.clone() });
let request = |instance: &str, top_k: u32| HuginnMindRequest::SearchSources {
instance: slug(instance),
query: SourceQuery { text: huginn_mind::eureka_pipeline::Line("find".into()), top_k, repo: None },
query: SourceQuery { text: huginn_mind::eureka_pipeline::Line("find".into()), top_k, repo: None, front: false },
};
let Handled::Searching(search) = daemon.handle(request(INSTANCE, 5), now()) else { panic!("a well-formed source search is a search") };

View file

@ -355,13 +355,14 @@ impl SourceIndex for FakeSources {
Ok(())
}
fn search_sources(&mut self, vector: &[f32], repo: Option<&str>, limit: u32, _within: Duration) -> Result<Vec<SourceRow>> {
fn search_sources(&mut self, vector: &[f32], repo: Option<&str>, front: bool, limit: u32, _within: Duration) -> Result<Vec<SourceRow>> {
let mut state = self.state.lock().unwrap();
state.searches.push((repo.map(str::to_owned), limit));
let mut rows: Vec<SourceRow> = state
.points
.values()
.filter(|point| repo.is_none_or(|repo| point.payload.repo == repo))
.filter(|point| !front || crate::sources::FRONT_PAGE_FILES.contains(&point.payload.path.as_str()))
.map(|point| SourceRow { score: cosine(vector, &point.vector), payload: point.payload.clone() })
.collect();
rows.sort_by(|a, b| b.score.total_cmp(&a.score));
@ -386,7 +387,7 @@ impl SourceIndex for FakeIndex {
fn upsert_sources(&mut self, points: &[SourcePoint]) -> Result<()> {
self.sources.upsert_sources(points)
}
fn search_sources(&mut self, vector: &[f32], repo: Option<&str>, limit: u32, within: Duration) -> Result<Vec<SourceRow>> {
self.sources.search_sources(vector, repo, limit, within)
fn search_sources(&mut self, vector: &[f32], repo: Option<&str>, front: bool, limit: u32, within: Duration) -> Result<Vec<SourceRow>> {
self.sources.search_sources(vector, repo, front, limit, within)
}
}

View file

@ -7,7 +7,7 @@ use std::time::Duration;
use anyhow::{Context, Result, ensure};
use serde_json::{Value, json};
use crate::sources::{COLLECTION, DescribedSources, SourceIndex, SourcePayload, SourcePoint, SourceRow, SourcesMeta};
use crate::sources::{COLLECTION, FRONT_PAGE_FILES, DescribedSources, SourceIndex, SourcePayload, SourcePoint, SourceRow, SourcesMeta};
use super::{CollectionMeta, INDEX_CALL_TIMEOUT, Described, Hit, Http, Method, Point, VectorIndex};
@ -175,6 +175,19 @@ impl VectorIndex for QdrantIndex {
}
}
/// The payload filter of a source search: the repo, and the front-page paths
/// (exact matches on the keyword-indexed `path`), both when both are asked.
fn source_filter(repo: Option<&str>, front: bool) -> Option<Value> {
let mut must = Vec::new();
if let Some(repo) = repo {
must.push(json!({ "key": "repo", "match": { "value": repo } }));
}
if front {
must.push(json!({ "key": "path", "match": { "any": FRONT_PAGE_FILES } }));
}
(!must.is_empty()).then(|| json!({ "must": must }))
}
fn payload_json(payload: &SourcePayload) -> Value {
json!({
"repo": payload.repo,
@ -298,12 +311,12 @@ impl SourceIndex for QdrantIndex {
Ok(())
}
fn search_sources(&mut self, vector: &[f32], repo: Option<&str>, limit: u32, within: Duration) -> Result<Vec<SourceRow>> {
fn search_sources(&mut self, vector: &[f32], repo: Option<&str>, front: bool, limit: u32, within: Duration) -> Result<Vec<SourceRow>> {
let collection = self.sources.clone();
let http = self.http.bounded(within);
let mut body = json!({ "vector": vector, "limit": limit, "with_payload": true, "with_vector": false });
if let Some(repo) = repo {
body["filter"] = json!({ "must": [{ "key": "repo", "match": { "value": repo } }] });
if let Some(filter) = source_filter(repo, front) {
body["filter"] = filter;
}
let (_, answer) =
self.ask_with(&http, Method::Post, &format!("/collections/{collection}/points/search"), Some(&body), &[200])?;
@ -319,3 +332,37 @@ impl SourceIndex for QdrantIndex {
.collect()
}
}
#[cfg(test)]
mod source_filter_tests {
use super::*;
/// No ask, no filter; a repo alone is the repo's match.
#[test]
fn no_ask_is_no_filter_and_a_repo_is_its_own_match() {
assert_eq!(source_filter(None, false), None);
assert_eq!(source_filter(Some("Huginn"), false), Some(json!({ "must": [{ "key": "repo", "match": { "value": "Huginn" } }] })));
}
/// The front filter matches the path against exactly the front-page files
/// and nothing else, and composes with the repo: both conditions must hold.
#[test]
fn the_front_filter_is_exactly_the_front_files_and_composes_with_the_repo() {
let any = |filter: &Value| -> Vec<String> {
let conditions = filter["must"].as_array().unwrap();
let path: Vec<&Value> = conditions.iter().filter(|c| c["key"] == "path").collect();
assert_eq!(path.len(), 1);
path[0]["match"]["any"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_owned()).collect()
};
let front = source_filter(None, true).unwrap();
assert_eq!(any(&front), ["README.md", "README", "readme.md", "README.markdown", "CLAUDE.md", "AGENTS.md"]);
assert_eq!(front["must"].as_array().unwrap().len(), 1, "no repo condition when none was asked");
let both = source_filter(Some("Zyphos"), true).unwrap();
assert_eq!(any(&both), any(&front));
let repos: Vec<&Value> = both["must"].as_array().unwrap().iter().filter(|c| c["key"] == "repo").collect();
assert_eq!(repos.len(), 1);
assert_eq!(repos[0]["match"]["value"], "Zyphos");
assert_eq!(both["must"].as_array().unwrap().len(), 2);
}
}

View file

@ -1995,7 +1995,7 @@ fn the_sink_owns_the_searchs_deadline_and_tells_the_loop() {
}
fn ask_sources(sink: &mut WorkerSink, text: &str) -> Result<SearchTicket, String> {
let query = SourceQuery { text: huginn_mind::eureka_pipeline::Line(text.into()), top_k: 5, repo: None };
let query = SourceQuery { text: huginn_mind::eureka_pipeline::Line(text.into()), top_k: 5, repo: None, front: false };
<WorkerSink as IndexSink<OwnedRedbMessagePackBackingStore>>::search_sources(sink, &query)
}

View file

@ -38,6 +38,10 @@ use git::{BLOB_BYTES_MAX, Mirror, TreeFile};
/// The collection this organ's source index lives in.
pub const COLLECTION: &str = "huginn_sources";
/// The paths, exactly and at a repo's root, that are its front page: what an
/// agent reads first to learn what the repo is. The only owner of the list.
pub const FRONT_PAGE_FILES: [&str; 6] = ["README.md", "README", "readme.md", "README.markdown", "CLAUDE.md", "AGENTS.md"];
/// What the collection says its corpus is.
pub const CORPUS: &str = "forge:GameCult";
@ -121,8 +125,9 @@ pub trait SourceIndex {
fn delete_by(&mut self, repo: &str, paths: Option<&[String]>, keeping: Option<&str>) -> Result<()>;
/// Writes chunks, acknowledged before it returns.
fn upsert_sources(&mut self, points: &[SourcePoint]) -> Result<()>;
/// The `limit` nearest chunks, best first, in `repo` alone when given.
fn search_sources(&mut self, vector: &[f32], repo: Option<&str>, limit: u32, within: Duration) -> Result<Vec<SourceRow>>;
/// The `limit` nearest chunks, best first, in `repo` alone when given, and
/// among the `FRONT_PAGE_FILES` alone when `front`.
fn search_sources(&mut self, vector: &[f32], repo: Option<&str>, front: bool, limit: u32, within: Duration) -> Result<Vec<SourceRow>>;
}
/// A repo the forge lists as indexable.
@ -459,7 +464,7 @@ pub fn search<E: Embedder, S: SourceIndex>(embedder: &mut E, index: &mut S, quer
ensure!(vectors.len() == 1, "the embedder returned {} vectors for one query", vectors.len());
let vector = vectors.remove(0);
ensure!(vector.len() == identity.dimensions as usize, "the embedder returned a query vector of {} where the collection holds {}", vector.len(), identity.dimensions);
let rows = index.search_sources(&vector, query.repo.as_ref().map(|repo| repo.0.as_str()), query.top_k, budget.left()?)?;
let rows = index.search_sources(&vector, query.repo.as_ref().map(|repo| repo.0.as_str()), query.front, query.top_k, budget.left()?)?;
Ok(SourceHits {
indexed_under: SourceModel { name: built.model, digest: built.model_digest },
hits: rows

View file

@ -500,8 +500,12 @@ fn skips_binary_lfs_oversize() {
assert_eq!(rig.index.cursors().get("Demo"), Some(&c));
}
fn front_query(text: &str, top_k: u32, repo: Option<&str>) -> SourceQuery {
SourceQuery { front: true, ..query(text, top_k, repo) }
}
fn query(text: &str, top_k: u32, repo: Option<&str>) -> SourceQuery {
SourceQuery { text: Line(text.into()), top_k, repo: repo.map(|repo| Short(repo.into())) }
SourceQuery { text: Line(text.into()), top_k, repo: repo.map(|repo| Short(repo.into())), front: false }
}
/// A daemon whose embedder is not the one the collection was built under
@ -555,6 +559,28 @@ fn a_source_search_answers_held_chunks_within_a_repo_and_a_limit() {
assert_eq!(asked, format!("Instruct: {SOURCE_QUERY_INSTRUCTION}\nQuery: alpha"));
}
/// A front search answers only the front-page files at a repo's root, in every
/// repo or in the one asked for; a nested README or a look-alike is not one.
#[test]
fn a_front_search_answers_only_root_front_pages_within_the_repo_asked() {
let (one, two) = (Remote::new(), Remote::new());
one.commit(&[("README.md", b"one front\n"), ("docs/README.md", b"nested\n"), ("README.md.bak", b"bak\n"), ("a.rs", b"code\n"), ("AGENTS.md", b"agents\n")], &[]);
two.commit(&[("CLAUDE.md", b"two front\n"), ("src/lib.rs", b"lib\n")], &[]);
let mut rig = Rig::new();
rig.converged(&mut FakeForge::of(&[("One", &one), ("Two", &two)]));
let within = Duration::from_secs(5);
let mut held = |query: &SourceQuery| {
let mut found: Vec<(String, String)> =
search(&mut rig.embedder, &mut rig.index, query, within).unwrap().hits.into_iter().map(|hit| (hit.repo, hit.path)).collect();
found.sort();
found
};
let pair = |repo: &str, path: &str| (repo.to_owned(), path.to_owned());
assert_eq!(held(&front_query("front", 50, None)), [pair("One", "AGENTS.md"), pair("One", "README.md"), pair("Two", "CLAUDE.md")]);
assert_eq!(held(&front_query("front", 50, Some("Two"))), [pair("Two", "CLAUDE.md")]);
assert_eq!(held(&query("front", 50, Some("Two"))).len(), 2, "without front, the repo's other files answer too");
}
/// Chunk ids are the content address: the same blob chunked the same way is
/// the same point, and any part of the address changes it.
#[test]

View file

@ -181,6 +181,7 @@ fn a_small_repo_is_refreshed_into_a_scratch_source_collection_and_found() {
text: huginn_mind::eureka_pipeline::Line("what does this repository do".into()),
top_k: 3,
repo: None,
front: false,
};
huginn_daemon::sources::search(&mut embedder, &mut index, &query, Duration::from_secs(60)).ok()
});

View file

@ -181,13 +181,17 @@ pub struct RepoMoved {
/// The most hits a source search answers.
pub const SOURCE_TOP_K_MAX: u32 = 50;
/// A source search: the text to find chunks near, how many, and optionally the
/// one forge repo (by name) to look in.
/// A source search: the text to find chunks near, how many, optionally the
/// one forge repo (by name) to look in, and whether to look only in repos'
/// front-page files (`FRONT_PAGE_FILES` at the repo root). An old client's
/// request has no `front` and means false.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct SourceQuery {
pub text: Line,
pub top_k: u32,
pub repo: Option<Short>,
#[serde(default)]
pub front: bool,
}
impl SourceQuery {
@ -415,7 +419,7 @@ mod tests {
},
HuginnMindRequest::SearchSources {
instance: slug(INSTANCE),
query: SourceQuery { text: Line("reconcile".into()), top_k: 5, repo: Some(Short("Huginn".into())) },
query: SourceQuery { text: Line("reconcile".into()), top_k: 5, repo: Some(Short("Huginn".into())), front: true },
},
HuginnMindRequest::RootsTouched { instance: slug(INSTANCE) },
]
@ -516,6 +520,7 @@ mod tests {
text: Line(text.into()),
top_k,
repo: repo.map(|repo| Short(repo.into())),
front: false,
};
let field = |query: SourceQuery| match query.check() {
Err(MindRefusal::SelectionInvalid { field, .. }) => field,
@ -529,6 +534,21 @@ mod tests {
assert_eq!(field(query("x", 5, Some(" "))), "repo");
}
/// An old client's search has no `front`: it decodes as the whole-index
/// search it asked for.
#[test]
fn a_source_query_encoded_without_front_decodes_as_false() {
#[derive(Serialize)]
struct OldQuery {
text: Line,
top_k: u32,
repo: Option<Short>,
}
let old = OldQuery { text: Line("x".into()), top_k: 3, repo: None };
let bytes = rmp_serde::to_vec_named(&old).unwrap();
assert!(!rmp_serde::from_slice::<SourceQuery>(&bytes).unwrap().front);
}
/// A new client reads an old daemon's page: the five fields it had, no
/// `scores`. This is what keeps rollback by flip safe.
#[test]

View file

@ -2788,9 +2788,13 @@
"maxLength": 64
},
"SourceQuery": {
"description": "A source search: the text to find chunks near, how many, and optionally the\none forge repo (by name) to look in.",
"description": "A source search: the text to find chunks near, how many, optionally the\none forge repo (by name) to look in, and whether to look only in repos'\nfront-page files (`FRONT_PAGE_FILES` at the repo root). An old client's\nrequest has no `front` and means false.",
"type": "object",
"properties": {
"front": {
"type": "boolean",
"default": false
},
"repo": {
"anyOf": [
{