huginn sources --front: search only a repo's front-page files #27
11 changed files with 141 additions and 18 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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") };
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue