wire: SearchSources/Sources, SourceQuery with its door, SourceHits (source-search-forge-public)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
21fb9b2b04
commit
5074181ebf
2 changed files with 111 additions and 3 deletions
|
|
@ -56,6 +56,7 @@ pub use store::{MindStore, OwnedRedbMessagePackBackingStore};
|
|||
pub use wire::{
|
||||
DeferredAnswer, HuginnMindRequest, HuginnMindResponse, IndexStatus, MAX_DEFERRED_BODY_BYTES, MIND_REQUEST_SCHEMA, MIND_REQUEST_SCHEMA_JSON,
|
||||
MIND_RESPONSE_SCHEMA, MIND_RESPONSE_SCHEMA_JSON, MIND_SERVICE_ID, MindStatus, PersonaStatus,
|
||||
SOURCE_TOP_K_MAX, SourceHit, SourceHits, SourceModel, SourceQuery,
|
||||
};
|
||||
|
||||
/// The leaf, whole, through the one crate that pins its rev: the daemon and
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
use cultcache_rs::DatabaseEntry;
|
||||
use cultnet_rs::{CultMeshCdnArtifactManifest, Selection};
|
||||
use eureka_pipeline::{PIPELINE_SCHEMA_EPOCH, PipelineKind, PipelineRef, Slug};
|
||||
use eureka_pipeline::{Line, PIPELINE_SCHEMA_EPOCH, PipelineKind, PipelineRef, Short, Slug};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
|
@ -88,6 +88,73 @@ pub enum HuginnMindRequest {
|
|||
state: Value,
|
||||
expected_updated_at: Option<String>,
|
||||
},
|
||||
/// Semantic search over the source index: the public forge repos' code and
|
||||
/// docs, which no mind holds. The daemon embeds the text and asks the
|
||||
/// index it does not write.
|
||||
SearchSources { instance: Slug, query: SourceQuery },
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct SourceQuery {
|
||||
pub text: Line,
|
||||
pub top_k: u32,
|
||||
pub repo: Option<Short>,
|
||||
}
|
||||
|
||||
impl SourceQuery {
|
||||
/// The door: text that says something, and between one and
|
||||
/// `SOURCE_TOP_K_MAX` hits. A refusal names the field.
|
||||
pub fn check(&self) -> Result<(), MindRefusal> {
|
||||
let refused = |field: &str, value: Option<String>, message: &str| MindRefusal::SelectionInvalid {
|
||||
field: field.into(),
|
||||
value,
|
||||
message: message.into(),
|
||||
};
|
||||
if self.text.0.trim().is_empty() {
|
||||
return Err(refused("text", None, "a source search needs text to find"));
|
||||
}
|
||||
if !(1..=SOURCE_TOP_K_MAX).contains(&self.top_k) {
|
||||
return Err(refused("top_k", Some(self.top_k.to_string()), "a source search asks for 1 to 50 hits"));
|
||||
}
|
||||
if self.repo.as_ref().is_some_and(|repo| repo.0.trim().is_empty()) {
|
||||
return Err(refused("repo", None, "a repo filter names a repo or is absent"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The model a source collection was built under, and so the one a query must
|
||||
/// be embedded by. The address of the embedder is no part of it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct SourceModel {
|
||||
pub name: String,
|
||||
pub digest: String,
|
||||
}
|
||||
|
||||
/// One chunk the index holds, as the index holds it: the repo it belongs to,
|
||||
/// the path in that repo, the commit whose blob it was cut from, its 1-based
|
||||
/// inclusive line range, and its text.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct SourceHit {
|
||||
pub repo: String,
|
||||
pub path: String,
|
||||
pub commit: String,
|
||||
pub line_start: u32,
|
||||
pub line_end: u32,
|
||||
pub score: f32,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// The answer to a source search, nearest first.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct SourceHits {
|
||||
pub indexed_under: SourceModel,
|
||||
pub hits: Vec<SourceHit>,
|
||||
}
|
||||
|
||||
impl HuginnMindRequest {
|
||||
|
|
@ -100,6 +167,7 @@ impl HuginnMindRequest {
|
|||
Self::Query { .. } => "query",
|
||||
Self::PersonaGet { .. } => "persona_get",
|
||||
Self::PersonaPut { .. } => "persona_put",
|
||||
Self::SearchSources { .. } => "search_sources",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -111,7 +179,8 @@ impl HuginnMindRequest {
|
|||
Self::View { instance, .. }
|
||||
| Self::Query { instance, .. }
|
||||
| Self::PersonaGet { instance }
|
||||
| Self::PersonaPut { instance, .. } => Some(instance),
|
||||
| Self::PersonaPut { instance, .. }
|
||||
| Self::SearchSources { instance, .. } => Some(instance),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -128,6 +197,7 @@ pub enum HuginnMindResponse {
|
|||
Refused(MindRefusal),
|
||||
Persona(Option<PersonaStateView>),
|
||||
PersonaPut(PersonaPutOutcome),
|
||||
Sources(SourceHits),
|
||||
/// The answer did not fit one send and is being held for the client to
|
||||
/// fetch. Nothing in a mind produces this: the daemon's serve module does,
|
||||
/// in place of an answer, and the answer it stands for is the named
|
||||
|
|
@ -259,6 +329,10 @@ mod tests {
|
|||
state: serde_json::json!({ "updatedAt": "2026-10-10T10:00:00Z", "n": [1, 2.5, null, "x"] }),
|
||||
expected_updated_at: Some("2026-10-10T09:00:00Z".into()),
|
||||
},
|
||||
HuginnMindRequest::SearchSources {
|
||||
instance: slug(INSTANCE),
|
||||
query: SourceQuery { text: Line("reconcile".into()), top_k: 5, repo: Some(Short("Huginn".into())) },
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -296,6 +370,18 @@ mod tests {
|
|||
})),
|
||||
HuginnMindResponse::PersonaPut(PersonaPutOutcome::Committed { receipt_id: "mind-commit-2".into() }),
|
||||
HuginnMindResponse::PersonaPut(PersonaPutOutcome::Refused(MindRefusal::PersonaNotCanonical)),
|
||||
HuginnMindResponse::Sources(SourceHits {
|
||||
indexed_under: SourceModel { name: "m".into(), digest: "d".into() },
|
||||
hits: vec![SourceHit {
|
||||
repo: "Huginn".into(),
|
||||
path: "a.rs".into(),
|
||||
commit: "abc".into(),
|
||||
line_start: 1,
|
||||
line_end: 2,
|
||||
score: 0.5,
|
||||
text: "x".into(),
|
||||
}],
|
||||
}),
|
||||
HuginnMindResponse::Deferred(DeferredAnswer {
|
||||
manifest: cultnet_rs::pack_content("huginn.mind_response", "package", "", "", "", b"body", 4)
|
||||
.unwrap()
|
||||
|
|
@ -310,7 +396,7 @@ mod tests {
|
|||
#[test]
|
||||
fn the_wire_vocabulary_is_the_minds_methods_and_status_is_derived_from_the_response() {
|
||||
let names: Vec<&str> = requests().iter().map(|request| request.operation()).collect();
|
||||
assert_eq!(names, ["whoami", "admit", "view", "query", "persona_get", "persona_put"]);
|
||||
assert_eq!(names, ["whoami", "admit", "view", "query", "persona_get", "persona_put", "search_sources"]);
|
||||
for request in requests() {
|
||||
let declared = request.instance().is_none();
|
||||
assert_eq!(declared, matches!(request, HuginnMindRequest::Whoami), "{request:?}");
|
||||
|
|
@ -336,6 +422,27 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// A source search is refused before any index is asked when it says
|
||||
/// nothing, asks for no hits or more than the cap, or names a blank repo.
|
||||
#[test]
|
||||
fn a_source_query_without_text_or_within_one_to_fifty_hits_is_refused_by_field() {
|
||||
let query = |text: &str, top_k: u32, repo: Option<&str>| SourceQuery {
|
||||
text: Line(text.into()),
|
||||
top_k,
|
||||
repo: repo.map(|repo| Short(repo.into())),
|
||||
};
|
||||
let field = |query: SourceQuery| match query.check() {
|
||||
Err(MindRefusal::SelectionInvalid { field, .. }) => field,
|
||||
other => panic!("expected a refusal, got {other:?}"),
|
||||
};
|
||||
assert_eq!(query("x", 1, None).check(), Ok(()));
|
||||
assert_eq!(query("x", SOURCE_TOP_K_MAX, Some("Huginn")).check(), Ok(()));
|
||||
assert_eq!(field(query(" ", 5, None)), "text");
|
||||
assert_eq!(field(query("x", 0, None)), "top_k");
|
||||
assert_eq!(field(query("x", SOURCE_TOP_K_MAX + 1, None)), "top_k");
|
||||
assert_eq!(field(query("x", 5, Some(" "))), "repo");
|
||||
}
|
||||
|
||||
/// 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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue