app

package
v1.1.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 31, 2026 License: Apache-2.0 Imports: 14 Imported by: 0

Documentation

Overview

Package app contains the use cases and defines the ports (interfaces) they consume. Adapters implement these ports; the conformance suites in internal/conformance are their executable contracts.

Index

Constants

View Source
const (
	MetricRecall      = "recall"
	MetricPrecision   = "precision"
	MetricMRR         = "mrr"
	MetricNDCG        = "ndcg"
	MetricHitRate     = "hit_rate"
	MetricSupportRate = "support_rate"
)

Metric names used in aggregates and --fail-under thresholds.

View Source
const DefaultIngestConcurrency = 8

DefaultIngestConcurrency bounds how many documents are processed at once.

View Source
const EvalSetVersion = 1

EvalSetVersion is the current eval-set format version. An eval file may begin with a header line {"version": N}; a version newer than this is rejected. Absent header means version 1 (the schema is otherwise self-describing by field name).

Variables

View Source
var (
	ErrNotFound      = errors.New("not found")
	ErrAlreadyExists = errors.New("already exists")
	// ErrNoGrounding is returned by Ask in strict mode when retrieval yields no
	// chunks and no attachments were supplied — there is nothing to ground an
	// answer in, so the LLM is not called.
	ErrNoGrounding = errors.New("no grounding")
	// ErrGateUnmet marks a quality gate that did not pass: ask --verify-strict
	// found unsupported claims, or lore eval missed a --fail-under threshold. It is
	// distinct from a runtime error so CI can tell "the answer/retrieval did not
	// meet the bar" (an actionable, expected signal) from "the tool broke" (exit 1).
	ErrGateUnmet = errors.New("quality gate not met")
	// ErrReproducibleUnsupported is returned for ask --reproducible when the
	// configured generator cannot pin generation for reproducibility (no
	// DeterministicGenerator capability) — an audited exhibit cannot promise a
	// literal re-run, so the run fails rather than silently producing a
	// non-deterministic answer it claims is reproducible.
	ErrReproducibleUnsupported = errors.New("reproducible generation unsupported")
)

Sentinel errors shared across ports. Adapters wrap these with %w so callers can use errors.Is; the CLI maps them to exit codes.

Functions

func NextScorePtr added in v1.0.0

func NextScorePtr(ret Retrieval) *float64

NextScorePtr returns a retrieval's runner-up score as a pointer, or nil when there was no further candidate (so --json renders next_score: null).

Types

type Answer

type Answer struct {
	Text      string
	Citations []domain.Citation
	// Grounded reports whether the answer had any grounding input — at least one
	// retrieved chunk or attachment. False means the model answered from its own
	// knowledge alone (only possible in non-strict mode).
	Grounded bool
	// Provenance records the generation identity and determinism settings the
	// generator used, when it captured them. The openai adapter fills it; in-
	// memory fakes leave it nil. It backs an ask manifest's generation block and
	// is nil-safe everywhere.
	Provenance *Provenance
}

Answer is a grounded synthesis result with citations back to chunks, each carrying the source provenance needed to display it.

type AnswerCache

type AnswerCache interface {
	Get(ctx context.Context, key string, notBefore time.Time) (Answer, bool, error)
	Put(ctx context.Context, key string, answer Answer, storedAt time.Time) error
	Prune(ctx context.Context, cutoff time.Time) error
}

AnswerCache stores synthesized answers keyed by an opaque content hash, for reuse across runs (each lore invocation is a fresh process, so this must be persistent to pay off). It is deliberately time-explicit: the TTL policy lives in the caller, not the store, which keeps the store a dumb time-indexed KV and the conformance suite deterministic.

Semantics:

  • Get returns a hit only when an entry exists AND was stored at or after notBefore; an older entry is a miss (expired), as is an unknown key.
  • Put replaces any existing entry for key.
  • Prune deletes every entry stored before cutoff.

type Asker

type Asker struct {
	// contains filtered or unexported fields
}

Asker answers a question with a synthesis grounded in retrieved chunks.

func NewAsker

func NewAsker(querier *Querier, generator Generator) *Asker

NewAsker wires an Asker over a Querier and a Generator.

func (*Asker) Ask

func (a *Asker) Ask(ctx context.Context, collection, question string, k int, attachments []domain.Attachment, strict bool, source string, filter domain.Predicate) (Answer, error)

Ask retrieves the top-k chunks for the question and synthesizes an answer grounded in them, plus any attachments passed straight to the generator. Retrieval errors short-circuit before the generator is called; generator errors are wrapped. With k <= 0 no chunks are retrieved, so attachments alone ground the answer.

When retrieval yields no chunks and no attachments were supplied there is nothing to ground on: in strict mode this is ErrNoGrounding (the generator is not called, saving the request); otherwise the answer is still produced but marked Grounded=false so the caller can warn that it rests on model knowledge alone. A non-empty source restricts retrieval to documents matching that glob (see Querier.Query).

func (*Asker) AskExplain

func (a *Asker) AskExplain(ctx context.Context, collection, question string, k int, attachments []domain.Attachment, strict bool, source string, filter domain.Predicate) (Answer, Retrieval, error)

AskExplain is Ask, additionally returning the retrieval (the grounding hits plus the runner-up just outside them) so a caller can show which chunks fed the answer, at what scores, and whether the cutoff was arbitrary — distinguishing retrieval starvation (every score low) from a synthesis miss (a high-scoring chunk the model left uncited). Strict/source/attachment semantics match Ask. The hits are the post-filter top-k, best first; the Retrieval is zero when retrieval found nothing or strict mode returns ErrNoGrounding.

func (*Asker) Synthesize

func (a *Asker) Synthesize(ctx context.Context, question string, hits []domain.ChunkHit, attachments []domain.Attachment) (Answer, error)

Synthesize generates an answer from already-retrieved hits and optional attachments, without performing retrieval — the generation half of Ask, exposed so callers can interpose between retrieval and synthesis (filter, re-rank, or merge hits). Grounded reflects whether any hits or attachments were given. Unlike Ask it has no strict mode: the caller chose the hits.

func (*Asker) SynthesizeReproducible added in v1.0.0

func (a *Asker) SynthesizeReproducible(ctx context.Context, question string, hits []domain.ChunkHit, attachments []domain.Attachment) (Answer, error)

SynthesizeReproducible is Synthesize pinned for reproducibility: it requires the generator to implement DeterministicGenerator (temperature 0 + fixed seed, recorded in the answer's Provenance) and fails with ErrReproducibleUnsupported otherwise — an audited exhibit must not silently rest on a non-deterministic generation. It backs ask --reproducible.

func (*Asker) SynthesizeStream

func (a *Asker) SynthesizeStream(ctx context.Context, question string, hits []domain.ChunkHit, attachments []domain.Attachment, onDelta func(string)) (Answer, error)

SynthesizeStream is Synthesize with incremental delivery: when the underlying generator implements StreamingGenerator it emits prose via onDelta as it arrives; otherwise it falls back to a single Synthesize and emits the whole text in one onDelta call. Either way it returns the complete Answer (with Grounded set as Synthesize does), so the caller still gets citations for the post-answer sources, --json, and caching.

type CaseResult added in v1.0.0

type CaseResult struct {
	Question     string
	HasRetrieval bool // the case declared expected_sources/chunks
	Recall       float64
	Precision    float64
	MRR          float64
	NDCG         float64
	HitRate      float64
	HasVerify    bool
	SupportRate  float64
	Claims       []domain.Claim
}

CaseResult is the per-question outcome: the retrieval metrics (when the case declared expectations) and, when verification ran, the answer's claims and support rate.

type Catalog

type Catalog struct {
	// contains filtered or unexported fields
}

Catalog is the collection-management use case: create, list, and inspect the corpora lore knows about — including the documents ingested into a collection.

func NewCatalog

func NewCatalog(collections CollectionRepository, docs DocumentRepository, embedder Embedder, chunkers domain.Registry) *Catalog

NewCatalog wires a Catalog over the collection and document repositories, the embedder whose space new collections are pinned to, and the chunker registry whose spec they are pinned to.

func (*Catalog) ChunksByIDs

func (c *Catalog) ChunksByIDs(ctx context.Context, collection string, ids []string) ([]domain.Chunk, error)

ChunksByIDs returns the collection's chunks with the given IDs, in input order, for inspecting specific or cited chunks. IDs not present are omitted (the caller diffs requested vs returned). It fails with ErrNotFound if the collection does not exist, so an unknown collection is distinguished from merely-absent chunk IDs.

func (*Catalog) Diff added in v1.0.0

func (c *Catalog) Diff(ctx context.Context, from, to string) (CollectionDiff, error)

Diff reports the document-level difference between two collections, comparing by source URI and content hash. It fails with ErrNotFound if either collection does not exist.

func (*Catalog) DocumentChunks

func (c *Catalog) DocumentChunks(ctx context.Context, collection, sourceURI string) ([]domain.Chunk, error)

DocumentChunks returns the stored chunks of one document (by source URI) in seq order — the extracted, chunked text as it was actually indexed, for inspecting what the extractor and chunker produced. It fails with ErrNotFound if no document with that source URI exists in the collection.

func (*Catalog) Get

func (c *Catalog) Get(ctx context.Context, name string) (*domain.Collection, error)

Get returns the named collection, or ErrNotFound.

func (*Catalog) Init

func (c *Catalog) Init(ctx context.Context, name string) (*domain.Collection, error)

Init creates a collection pinned to the embedder's current EmbeddingSpace and the active chunker spec (re-ingest under a different chunker is then refused). It fails with ErrAlreadyExists if the name is taken and ErrInvalidArgument if the name is invalid.

func (*Catalog) List

func (c *Catalog) List(ctx context.Context) ([]*domain.Collection, error)

List returns every collection.

func (*Catalog) ListDocuments

func (c *Catalog) ListDocuments(ctx context.Context, collection string) ([]*domain.Document, error)

ListDocuments returns the documents ingested into the named collection. It fails with ErrNotFound if the collection does not exist, so callers can distinguish an empty collection from a missing one.

type Checker added in v1.0.0

type Checker struct {
	// contains filtered or unexported fields
}

Checker verifies an answer's faithfulness: it segments the answer into claims and judges whether each claim is entailed by the chunks it cites, via the Verifier port. It is the use case behind `ask --verify`.

func NewChecker added in v1.0.0

func NewChecker(verifier Verifier, catalog *Catalog) *Checker

NewChecker wires a Checker over a Verifier and the Catalog (used to fetch the cited chunks' text as evidence).

func (*Checker) Verify added in v1.0.0

func (c *Checker) Verify(ctx context.Context, collection string, ans Answer) ([]domain.Claim, error)

Verify returns the answer's claims with their verdicts filled in. collection is the default collection for citations that carry no origin (single-collection answers); a citation with its own Collection (cross-collection answers) is resolved against that. An uncited claim is left flagged (VerdictUncited) and costs no model call; each cited claim is judged against the concatenated text of the chunks it cites. The aggregate support rate is domain.SupportRate(claims).

func (*Checker) VerifyWithEvidence added in v1.0.0

func (c *Checker) VerifyWithEvidence(ctx context.Context, ans Answer, evidenceByID map[domain.ChunkID]string) ([]domain.Claim, error)

VerifyWithEvidence verifies ans using caller-supplied chunk text as the evidence (keyed by chunk ID) instead of fetching it from a collection. It backs `synthesize --verify`, where the grounding chunks are piped in and may not live in any collection (an external retriever, hand-assembled context). Semantics otherwise match Verify, including the grounding-set fallback for markerless answers.

type CollectionDiff added in v1.0.0

type CollectionDiff struct {
	Added   []DocRef    // present in `to`, absent from `from`
	Removed []DocRef    // present in `from`, absent from `to`
	Changed []DocChange // same source URI, different content hash
}

CollectionDiff is the document-level difference between two collections, compared by source URI and content hash. It is space-independent: it compares what was ingested, not how it was embedded, so two collections pinned to different embedding spaces (e.g. a collection and a re-imported snapshot) can still be diffed. Each slice is ordered by source URI.

func (CollectionDiff) Empty added in v1.0.0

func (d CollectionDiff) Empty() bool

Empty reports whether the two collections hold the same documents at the same content (no additions, removals, or changes).

type CollectionRepository

type CollectionRepository interface {
	// Create fails with ErrAlreadyExists if the name is taken.
	Create(ctx context.Context, c *domain.Collection) error
	// Get fails with ErrNotFound if no such collection exists.
	Get(ctx context.Context, name string) (*domain.Collection, error)
	List(ctx context.Context) ([]*domain.Collection, error)
	// Delete removes the collection record, failing with ErrNotFound if no
	// such collection exists. Cascading removal of its documents and vectors
	// is orchestrated by the use case, which holds the
	// DocumentRepository and VectorIndex; this port cannot reach them.
	Delete(ctx context.Context, name string) error
	// RecordSource adds source to the collection's remembered Sources,
	// idempotently (recording an existing source is a no-op). It fails with
	// ErrNotFound if no such collection exists. Get reflects recorded sources.
	RecordSource(ctx context.Context, name, source string) error
}

CollectionRepository persists Collection aggregates.

type CorpusDrift added in v1.0.0

type CorpusDrift struct {
	Collection string
	Was        domain.ContentHash
	Now        domain.ContentHash
}

CorpusDrift names a collection whose content digest changed since the manifest was recorded — the corpus the exhibit was grounded in no longer exists.

type CorpusRef added in v1.0.0

type CorpusRef struct {
	Collection string             `json:"collection"`
	Digest     domain.ContentHash `json:"digest"`
	Model      string             `json:"embedding_model"`
	Dimensions int                `json:"dimensions"`
}

CorpusRef pins one collection's content identity (CorpusSnapshot digest) and embedding space at ask time. Replay compares each against the live collection; a changed digest means the corpus drifted and the exhibit cannot be reproduced.

func CorpusRefsOf added in v1.0.0

func CorpusRefsOf(ctx context.Context, cat *Catalog, collections []string) ([]CorpusRef, error)

CorpusRefsOf builds a CorpusRef for each named collection, in the given order (the query order — primary collection first — not sorted), so a manifest records what was asked. Each ref's digest is order-independent within the collection (CorpusSnapshot sorts its documents). An unknown collection returns the catalog's error (ErrNotFound), naming nothing the caller has not asked for.

type DeterministicGenerator added in v1.0.0

type DeterministicGenerator interface {
	SynthesizeDeterministic(ctx context.Context, question string, hits []domain.ChunkHit, attachments []domain.Attachment) (Answer, error)
}

DeterministicGenerator is an optional capability a Generator may implement: it synthesizes with the generation pinned for reproducibility (temperature 0 and a fixed seed) and records the pinned values in the returned Answer's Provenance. The audited ask --reproducible path requires it; callers detect support with a type assertion (see Asker.SynthesizeReproducible) and fail with ErrReproducibleUnsupported when it is absent rather than silently producing a non-deterministic answer.

type DocChange added in v1.0.0

type DocChange struct {
	SourceURI string
	From      string
	To        string
}

DocChange is one document whose content hash differs between the two collections (From in `from`, To in `to`).

type DocRef added in v1.0.0

type DocRef struct {
	SourceURI string
	Hash      string
}

DocRef identifies one document by its source URI and content hash.

type DocumentRepository

type DocumentRepository interface {
	// Upsert stores the document and replaces its chunks atomically.
	Upsert(ctx context.Context, doc *domain.Document, chunks []domain.Chunk) error
	// GetBySource fails with ErrNotFound if the source was never ingested.
	GetBySource(ctx context.Context, collection, sourceURI string) (*domain.Document, error)
	// GetChunks hydrates chunks by ID, preserving input order. IDs with no
	// stored chunk are skipped, so the result may be shorter than the input.
	GetChunks(ctx context.Context, ids []domain.ChunkID) ([]domain.Chunk, error)
	// GetChunksByDocument returns all chunks of one document in seq order. An
	// unknown document (or collection) yields no chunks and no error; existence
	// is the caller's concern (resolve the document via GetBySource first).
	GetChunksByDocument(ctx context.Context, collection string, id domain.DocumentID) ([]domain.Chunk, error)
	// GetChunksByIDs returns the chunks with the given IDs that belong to the
	// collection, in input order. IDs with no stored chunk in the collection are
	// omitted (the caller diffs requested vs returned). An unknown collection
	// yields no chunks and no error; collection existence is the caller's concern.
	GetChunksByIDs(ctx context.Context, collection string, ids []string) ([]domain.Chunk, error)
	// GetDocuments hydrates documents by ID, preserving input order. IDs with no
	// stored document are skipped, so the result may be shorter than the input.
	// Used to attach source provenance to retrieval results.
	GetDocuments(ctx context.Context, ids []domain.DocumentID) ([]*domain.Document, error)
	// ListDocuments returns every document in the collection. An unknown or empty
	// collection yields no documents and no error; collection existence is the
	// CollectionRepository's concern. Order is unspecified.
	ListDocuments(ctx context.Context, collection string) ([]*domain.Document, error)
	// Delete removes the document and its chunks, returning the removed chunk
	// IDs so the use case can delete their vectors via the VectorIndex
	// (this port cannot reach it). Fails with ErrNotFound if no
	// such document exists in the collection.
	Delete(ctx context.Context, collection string, id domain.DocumentID) ([]domain.ChunkID, error)
	// DeleteChunks removes the chunks with the given IDs that belong to the
	// collection, returning the IDs actually removed so the use case can delete
	// their vectors. The owning documents are left in place — a
	// document keeps its record even after losing chunks (sub-document
	// redaction, not document deletion). IDs absent from the collection
	// (unknown, or owned by another collection) are skipped, so the caller can
	// diff requested vs removed; an unknown collection removes nothing. No error
	// for skips — a malformed-ID guard is the caller's concern.
	DeleteChunks(ctx context.Context, collection string, ids []domain.ChunkID) ([]domain.ChunkID, error)
	// DeleteCollection removes every document and its chunks in the collection,
	// returning all removed chunk IDs for the same cascade. A collection with no
	// documents is a no-op (no IDs, no error); collection existence is the
	// CollectionRepository's concern.
	DeleteCollection(ctx context.Context, collection string) ([]domain.ChunkID, error)
}

DocumentRepository persists Documents and their Chunks.

type Embedder

type Embedder interface {
	Space(ctx context.Context) (domain.EmbeddingSpace, error)
	// Embed returns one vector per input text, in input order.
	Embed(ctx context.Context, texts []string) ([][]float32, error)
}

Embedder turns texts into vectors and reports the space it produces, so use cases can enforce space coherence against the target collection.

type EvalCase added in v1.0.0

type EvalCase struct {
	Question        string   `json:"question"`
	ExpectedSources []string `json:"expected_sources,omitempty"`
	ExpectedChunks  []string `json:"expected_chunks,omitempty"`
	ExpectedAnswer  string   `json:"expected_answer,omitempty"`
}

EvalCase is one question in an eval set with its optional expectations. At least one of ExpectedSources/ExpectedChunks enables retrieval metrics for the case; faithfulness (support rate) is computed when verification is requested.

func ParseEvalSet added in v1.0.0

func ParseEvalSet(r io.Reader) ([]EvalCase, error)

ParseEvalSet reads a JSONL eval set: an optional first header line {"version": N}, then one EvalCase per line (blank lines ignored). A case with an empty question, a malformed line, a too-new version, or an empty set is ErrInvalidArgument.

type EvalReport added in v1.0.0

type EvalReport struct {
	K          int
	Cases      []CaseResult
	Aggregates map[string]float64 // metric name → mean (see metric name constants)
}

EvalReport is the whole-set outcome: per-case results and the aggregate means over the applicable cases.

type Evaluator added in v1.0.0

type Evaluator struct {
	// contains filtered or unexported fields
}

Evaluator runs an eval set against a collection, computing retrieval metrics and — when verify is set — answer faithfulness, via the existing use cases.

func NewEvaluator added in v1.0.0

func NewEvaluator(asker *Asker, checker *Checker) *Evaluator

NewEvaluator wires an Evaluator. asker and checker are needed only for verification; pass them (the CLI always wires them) so --verify works.

func (*Evaluator) Evaluate added in v1.0.0

func (e *Evaluator) Evaluate(ctx context.Context, collection string, cases []EvalCase, k int, verify bool, retrieve func(context.Context, string) ([]domain.ChunkHit, error)) (EvalReport, error)

Evaluate runs every case: retrieve via the supplied retrieve func — the caller builds it from the same Retriever the CLI/MCP use, so eval measures the *actual* configured retrieval (hybrid/rerank/recency/…), not a fixed cosine baseline — score retrieval against the case's expectations, and, when verify, synthesize and verify the answer. Retrieval metrics use expected_chunks when present (exact), else expected_sources.

type Exporter

type Exporter struct {
	// contains filtered or unexported fields
}

Exporter serializes a collection into a portable artifact — its embedding-space and chunker pins, metadata, documents, chunks, and vectors — gathered through the persistence ports so the format stays independent of the storage backend.

func NewExporter

func NewExporter(collections CollectionRepository, docs DocumentRepository, index VectorIndex) *Exporter

NewExporter wires an Exporter over the three persistence ports.

func (*Exporter) Export

func (e *Exporter) Export(ctx context.Context, collection string, w io.Writer) (TransferSummary, error)

Export writes the named collection to w as a self-contained, versioned artifact. The embedding-space pin (and chunker pin, if the collection carries one) travel inside it so the reconstructed collection enforces the same invariants. An unknown collection is ErrNotFound. The bytes written are the plaintext artifact; encryption, if any, wraps this stream in the caller.

type Extractor

type Extractor interface {
	Supports(contentType string) bool
	Extract(contentType string, raw []byte) (string, error)
}

Extractor converts raw content of a supported type into plain text.

type FromQuery

type FromQuery struct {
	From domain.ChunkHit   // the source chunk's identity + source URI (Score unused)
	Hits []domain.ChunkHit // its target hits, best first
}

FromQuery is one source-collection chunk used directly as a query vector and the target-collection hits it retrieved. It backs `query --from-collection`, which feeds a collection's own stored vectors back as queries (never re-embedding) to find each chunk's nearest neighbors in another collection.

type GenerationManifest added in v1.0.0

type GenerationManifest struct {
	ChatModel         string   `json:"chat_model"`
	ResolvedModel     string   `json:"resolved_model,omitempty"`
	SystemFingerprint string   `json:"system_fingerprint,omitempty"`
	Deterministic     bool     `json:"deterministic"`
	Temperature       *float64 `json:"temperature,omitempty"`
	Seed              *int     `json:"seed,omitempty"`
}

GenerationManifest records the generation identity. ChatModel is the configured model name (always present). ResolvedModel and SystemFingerprint are the provider's response-reported identity, present when the adapter captured them. Deterministic, Temperature, and Seed are set only for a --reproducible run that pinned them; nil Temperature/Seed means the run used the provider default (non-deterministic).

type Generator

type Generator interface {
	Synthesize(ctx context.Context, question string, hits []domain.ChunkHit, attachments []domain.Attachment) (Answer, error)
}

Generator synthesizes an answer grounded in retrieved chunks, optionally with raw attachments (images/documents) the model reads directly. Attachments are ephemeral context, not part of the collection.

type Importer

type Importer struct {
	// contains filtered or unexported fields
}

Importer reconstructs a collection from a portable artifact, writing it into the local store through the persistence ports. The chunk and document IDs are re-derived from the (target collection, source URI, seq) rather than carried, so importing under a new name produces a coherent collection.

func NewImporter

func NewImporter(collections CollectionRepository, docs DocumentRepository, index VectorIndex, remover *Remover, lexical LexicalIndex, embedder Embedder) *Importer

NewImporter wires an Importer; the Remover handles the cascade when --force replaces an existing collection. The lexical index is rebuilt from the imported chunks (it is derived content, not carried in the artifact); a nil lexical index imports without one. The embedder is used only by re-embed imports (rebuilding vectors in the local space); a nil embedder makes re-embed a usage error.

func (*Importer) Import

func (im *Importer) Import(ctx context.Context, r io.Reader, name string, force, reEmbed bool) (TransferSummary, error)

Import reconstructs the collection in r (a plaintext artifact; any decryption has already happened upstream). A non-empty name renames it. A target name that already exists is refused with ErrAlreadyExists unless force, which replaces it via the cascade. A malformed or too-new artifact surfaces the artifact package's errors before anything is written.

type IngestCallOption added in v1.0.0

type IngestCallOption func(*ingestCall)

IngestCallOption configures a single Ingest or IngestContent invocation (as distinct from IngestOption, which configures the Ingestor at construction).

func WithExclude added in v1.0.0

func WithExclude(globs ...string) IngestCallOption

WithExclude skips any source whose URI matches one of the given globs, before it is read, extracted, or embedded. Excluded sources are reported in the summary's Excluded count, never silently dropped. It applies to walking a path (add <dir>); it has no effect on IngestContent (stdin yields a single named document with nothing to filter). Empty patterns are ignored.

func WithMeta added in v1.0.0

func WithMeta(meta domain.Metadata) IngestCallOption

WithMeta supplies base metadata applied to every document ingested in this invocation (the `add --meta` pairs). A markdown document's own front matter is merged under it, so an explicit key here overrides the same key from front matter. Metadata is captured only when a document is ingested as new or changed; re-ingesting unchanged content does not update it.

type IngestOption

type IngestOption func(*Ingestor)

IngestOption configures an Ingestor at construction.

func WithConcurrency

func WithConcurrency(n int) IngestOption

WithConcurrency sets the bounded parallelism for ingestion. Values <= 0 are ignored, leaving DefaultIngestConcurrency in place. Lower it for providers with tight rate limits to avoid retry thrash.

type IngestSummary

type IngestSummary struct {
	Added       int // documents ingested (new or changed)
	Skipped     int // unchanged or empty documents (idempotent no-ops)
	Unsupported int // documents whose content type no extractor handles
	Excluded    int // documents skipped by an --exclude glob (never read)
	Chunks      int // chunks embedded and stored
}

IngestSummary reports the outcome of an ingestion run.

type Ingestor

type Ingestor struct {
	// contains filtered or unexported fields
}

Ingestor walks a Source, extracts and chunks each document, embeds the chunks, and stores chunks and their vectors. Ingestion is idempotent: a source whose extracted content is unchanged is a no-op.

func NewIngestor

func NewIngestor(collections CollectionRepository, docs DocumentRepository, index VectorIndex, embedder Embedder, extractor Extractor, source Source, chunkers domain.Registry, lexical LexicalIndex, opts ...IngestOption) *Ingestor

NewIngestor wires an Ingestor from the ports and domain services it needs. The chunker Registry selects a per-format chunking strategy by content type.

func (*Ingestor) Ingest

func (i *Ingestor) Ingest(ctx context.Context, collection, root string, opts ...IngestCallOption) (IngestSummary, error)

Ingest processes every document the Source yields under root into the named collection, concurrently with bounded parallelism. It enforces space coherence up front and fails fast on the first error; because ingestion is idempotent, re-running resumes safely over already-stored documents.

func (*Ingestor) IngestContent

func (i *Ingestor) IngestContent(ctx context.Context, collection, uri, contentType string, content []byte, opts ...IngestCallOption) (IngestSummary, error)

IngestContent ingests a single in-memory document (e.g. read from stdin) into the collection, identified by uri with the given content type. Like Ingest it enforces space coherence and is idempotent by content hash. Unlike Ingest it records no sync source — there is no path to replay — and an unsupported content type is reported, not an error.

type LexicalDoc added in v1.0.0

type LexicalDoc struct {
	ChunkID  domain.ChunkID
	Text     string
	Metadata domain.Metadata
}

LexicalDoc is one chunk's lexical content for the LexicalIndex: its identity, the text to index, and the document metadata a --where filter applies to. It is the lexical-side sibling of VectorEntry.

type LexicalIndex added in v1.0.0

type LexicalIndex interface {
	Upsert(ctx context.Context, collection string, docs []LexicalDoc) error
	Search(ctx context.Context, collection string, query string, k int, filter domain.Predicate) ([]domain.ChunkID, error)
	Delete(ctx context.Context, collection string, ids []domain.ChunkID) error
}

LexicalIndex is a keyword (BM25) index over chunk text — the lexical half of hybrid retrieval, a sibling of VectorIndex kept separate so each port stays small and every adapter implements it honestly (memstore in-memory BM25, sqlite FTS5). Results feed rank-based fusion (domain.FuseRRF) with the vector results, so Search returns only the ranked identities, not scores.

Semantics:

  • Upsert replaces entries with the same ChunkID.
  • Search returns up to k chunk IDs ranked by lexical relevance, best first, considering only entries whose Metadata satisfies filter. An empty query, unknown collection, or k <= 0 yields no matches, no error. A chunk is a candidate when it contains at least one query term.
  • Delete of absent IDs is a no-op.

type Manifest added in v1.0.0

type Manifest struct {
	Question     string             `json:"question"`
	AskedAt      time.Time          `json:"asked_at"`
	Corpus       []CorpusRef        `json:"corpus"`
	Retrieval    RetrievalManifest  `json:"retrieval"`
	Generation   GenerationManifest `json:"generation"`
	CitedChunks  []string           `json:"cited_chunks"`
	AnswerDigest domain.ContentHash `json:"answer_digest"`
}

Manifest is the reproducible provenance record of one `ask`: the corpus state, retrieval configuration, and generation identity that produced an answer, the chunks it actually cited, and a digest of the answer text. It turns "trust me, it was grounded" into an auditable, re-runnable exhibit: `lore replay` checks the corpus has not drifted, re-runs the recorded retrieval, and re-synthesizes from the cited chunks to prove the answer reproduces.

type Provenance added in v1.0.0

type Provenance struct {
	ResolvedModel     string
	SystemFingerprint string
	Deterministic     bool
	Temperature       float64
	Seed              int
}

Provenance records how an answer was generated: the model the provider actually served (ResolvedModel) and its SystemFingerprint, plus whether the request was pinned for reproducibility (Deterministic, with the Temperature and Seed used). It is the generation half of a reproducible ask manifest.

type Querier

type Querier struct {
	// contains filtered or unexported fields
}

Querier retrieves the chunks most similar to a query within a Collection.

func NewQuerier

func NewQuerier(collections CollectionRepository, index VectorIndex, docs DocumentRepository, embedder Embedder, lexical LexicalIndex) *Querier

NewQuerier wires a Querier from the ports it needs. The lexical index powers hybrid retrieval; a nil lexical index degrades --hybrid to vector-only.

func (*Querier) Explain

func (q *Querier) Explain(ctx context.Context, collection, query string, k int, source string, filter domain.Predicate) (Retrieval, error)

Explain runs the same retrieval as Query but also reports the best candidate just outside the returned top-k (the runner-up) for --explain diagnostics. It fetches one extra candidate (k+1) to surface that runner-up — no extra model call beyond the single query embed. Same errors as Query.

func (*Querier) ExplainAcross

func (q *Querier) ExplainAcross(ctx context.Context, collections []string, query string, k int, source string, filter domain.Predicate) (Retrieval, error)

ExplainAcross is QueryAcross with the runner-up just outside the merged top-k, for --explain over a multi-collection query.

func (*Querier) ExplainHybrid added in v1.0.0

func (q *Querier) ExplainHybrid(ctx context.Context, collection, query string, k int, source string, filter domain.Predicate) (Retrieval, error)

ExplainHybrid is QueryHybrid plus the runner-up just outside the fused top-k, for --explain. The over-fetched fusion pool makes the runner-up available without an extra search.

func (*Querier) ExplainLexical added in v1.0.0

func (q *Querier) ExplainLexical(ctx context.Context, collection, query string, k int, source string, filter domain.Predicate) (Retrieval, error)

ExplainLexical is QueryLexical plus the runner-up just outside the top-k, for --explain. Scores are 0 in this mode, so the runner-up marks position only.

func (*Querier) Query

func (q *Querier) Query(ctx context.Context, collection, query string, k int, source string, filter domain.Predicate) ([]domain.ChunkHit, error)

Query embeds the question and returns up to k ChunkHits from the collection, best match first. It enforces space coherence: the embedder must produce vectors in the collection's space, or it fails with ErrSpaceMismatch before touching the index.

A non-empty source restricts hits to documents whose source URI matches that glob (matched against the basename, or the path when the pattern contains a "/"); Query over-fetches to fill k, approximately (see sourceOverfetch).

An empty query is ErrInvalidArgument; an unknown collection is ErrNotFound. No matching chunks yields no hits and no error. A non-zero filter restricts hits to chunks whose document metadata satisfies it (the --where predicate), applied inside the index before the top-k cut.

func (*Querier) QueryAcross

func (q *Querier) QueryAcross(ctx context.Context, collections []string, query string, k int, source string, filter domain.Predicate) ([]domain.ChunkHit, error)

QueryAcross merges retrieval over several collections into one ranked top-k. All collections must share an embedding space (domain.SameSpace) — vectors are only comparable within a space — so the query is embedded once, each collection is searched, and the hits are merged by score. Each returned hit carries its origin Collection. Same per-collection source/over-fetch semantics as Query. Mismatched spaces fail with ErrSpaceMismatch before any search.

func (*Querier) QueryFrom

func (q *Querier) QueryFrom(ctx context.Context, target, sourceCollection string, k int, source string, filter domain.Predicate) ([]FromQuery, error)

QueryFrom searches the target collection using the source collection's own stored chunk vectors as queries — one search per source chunk, grouped by source chunk. Nothing is re-embedded: source vectors are read straight from the index, so the comparison is sound only if source and target share an embedding space, which is enforced (domain.SameSpace → ErrSpaceMismatch) before any index access. A non-empty source glob restricts the *target* hits. An unknown source or target collection is ErrNotFound; an empty source collection yields no groups.

func (*Querier) QueryHybrid added in v1.0.0

func (q *Querier) QueryHybrid(ctx context.Context, collection, query string, k int, source string, filter domain.Predicate) ([]domain.ChunkHit, error)

QueryHybrid retrieves with hybrid retrieval: a vector search and a BM25 lexical search, each over a candidate pool, merged by Reciprocal Rank Fusion (domain.FuseRRF) into the top-k. A hit's Score stays its cosine similarity (0.0 for a chunk found only by lexical match, where no vector similarity was computed); the returned order is the fusion order. The --where filter and --source compose as in Query. With no lexical index wired it degrades to vector-only ranking. Unknown collection is ErrNotFound; empty query is ErrInvalidArgument.

func (*Querier) QueryLexical added in v1.0.0

func (q *Querier) QueryLexical(ctx context.Context, collection, query string, k int, source string, filter domain.Predicate) ([]domain.ChunkHit, error)

QueryLexical retrieves by BM25 lexical match only — no query embedding, so it works with no embedder or API key configured (e.g. an imported collection whose embedding space you cannot serve). Results are ranked by FTS5 BM25; a hit's Score is 0 (no vector similarity is computed). Unknown collection is ErrNotFound; empty query is ErrInvalidArgument; a backend with no lexical index is ErrInvalidArgument.

type RankResult

type RankResult struct {
	Index int
	Score float64
}

RankResult is one scored document from a RerankProvider: Index points into the documents slice the provider was given; Score is its relevance (higher = more relevant).

type Remover

type Remover struct {
	// contains filtered or unexported fields
}

Remover owns the cross-port deletion cascade: a document's chunks live in the DocumentRepository, their vectors in the VectorIndex, and the collection record in the CollectionRepository. No single port can reach the others, so this use case orchestrates them.

func NewRemover

func NewRemover(collections CollectionRepository, docs DocumentRepository, index VectorIndex, lexical LexicalIndex) *Remover

NewRemover wires a Remover over the persistence ports. The lexical index is part of the cascade so a removed chunk leaves no lexical entry behind.

func (*Remover) RemoveChunks

func (r *Remover) RemoveChunks(ctx context.Context, collection string, ids []domain.ChunkID) ([]domain.ChunkID, error)

RemoveChunks deletes specific chunks by ID and their vectors, returning the IDs actually removed. This is sub-document redaction: the chunks' documents stay in place. IDs absent from the collection are skipped, so the caller can diff requested vs removed and report the misses. Fails with ErrNotFound if no such collection exists.

func (*Remover) RemoveCollection

func (r *Remover) RemoveCollection(ctx context.Context, name string) error

RemoveCollection deletes the collection and everything in it: its documents, their chunks, and their vectors. Fails with ErrNotFound if no such collection exists.

func (*Remover) RemoveDocument

func (r *Remover) RemoveDocument(ctx context.Context, collection, sourceURI string) error

RemoveDocument deletes one document, its chunks, and their vectors. Fails with ErrNotFound if the document was never ingested into the collection.

type ReplayReport added in v1.0.0

type ReplayReport struct {
	Drift          []CorpusDrift
	Attempted      bool
	RetrievalMatch bool
	MissingCited   []string
	AnswerMatch    bool
	AnswerDigest   domain.ContentHash
	ExpectedAnswer domain.ContentHash
}

ReplayReport is the outcome of re-running a manifest. Attempted is false when corpus drift aborted reproduction (the fail-closed default); RetrievalMatch is true when every cited chunk still surfaced in re-retrieval; AnswerMatch is true when the re-synthesized answer's digest equals the manifest's.

func (ReplayReport) Reproduced added in v1.0.0

func (r ReplayReport) Reproduced(allowDrift bool) bool

Reproduced reports whether the exhibit faithfully reproduced. Drift fails it unless allowDrift was set (the caller explicitly accepted a changed corpus); otherwise it requires that reproduction was attempted and both the retrieval and the answer matched.

type Replayer added in v1.0.0

type Replayer struct {
	// contains filtered or unexported fields
}

Replayer re-runs an ask Manifest to prove its answer reproduces: it checks the corpus has not drifted (each collection's CorpusSnapshot digest still matches), re-runs the recorded retrieval to confirm the cited evidence still surfaces, and re-synthesizes from the pinned cited chunks to confirm the answer text reproduces. It is the verification half of the provenance exhibit.

func NewReplayer added in v1.0.0

func NewReplayer(catalog *Catalog, retriever *Retriever, asker *Asker, docs DocumentRepository) *Replayer

NewReplayer wires a Replayer over the catalog (corpus digests), the retriever (re-retrieval), the asker (re-synthesis), and the document repository (to rehydrate the pinned cited chunks into grounding hits).

func (*Replayer) Replay added in v1.0.0

func (r *Replayer) Replay(ctx context.Context, m Manifest, allowDrift bool) (ReplayReport, error)

Replay re-runs the manifest. With drift present and allowDrift false it stops after the digest check (fail closed: an exhibit cannot be faithfully reproduced against a changed corpus); otherwise it re-retrieves and re-synthesizes and reports both fidelities. A missing collection or repository error is returned as-is.

type RerankProvider

type RerankProvider interface {
	// Rerank scores documents against query and returns results best-first. topN
	// is a hint passed to providers that support it; the Reranker use case is the
	// source of truth for final ordering and truncation.
	Rerank(ctx context.Context, query string, documents []string, topN int) ([]RankResult, error)
}

RerankProvider scores (query, document) pairs jointly with a cross-encoder — the rerank half of two-stage retrieval. It is a distinct port from Embedder/ Generator because rerank APIs are their own thing (Cohere-style /rerank), not the OpenAI chat/embeddings shape, and are usually a separate provider.

type Reranker

type Reranker struct {
	// contains filtered or unexported fields
}

Reranker is the two-stage-retrieval use case: it reranks already-retrieved hits against the query via a RerankProvider, reordering them by relevance and attaching each hit's rerank score (the original similarity score is kept).

func NewReranker

func NewReranker(provider RerankProvider) *Reranker

NewReranker wires a Reranker over a RerankProvider.

func (*Reranker) Rerank

func (r *Reranker) Rerank(ctx context.Context, query string, hits []domain.ChunkHit, topN int) ([]domain.ChunkHit, error)

Rerank reorders hits by cross-encoder relevance to query and returns them with RerankScore attached, best first. topN > 0 truncates to that many after reranking; topN <= 0 re-emits all hits reordered. Empty hits is a no-op (the provider is not called). The use case owns ordering and truncation, so the result is identical regardless of whether the provider honored topN.

type Retrieval

type Retrieval struct {
	Hits      []domain.ChunkHit
	NextScore float64
	HasNext   bool
}

Retrieval is what Explain returns: the top-k Hits plus the best candidate just outside them (the runner-up), for --explain diagnostics. HasNext is false when there is no further candidate (fewer than k+1 matched after filtering).

type RetrievalManifest added in v1.0.0

type RetrievalManifest struct {
	K            int      `json:"k"`
	Candidates   int      `json:"candidates,omitempty"`
	Source       string   `json:"source,omitempty"`
	Where        []string `json:"where,omitempty"`
	Rerank       bool     `json:"rerank,omitempty"`
	Hybrid       bool     `json:"hybrid,omitempty"`
	MMR          bool     `json:"mmr,omitempty"`
	MMRLambda    float64  `json:"mmr_lambda,omitempty"`
	Recency      bool     `json:"recency,omitempty"`
	HalfLifeDays float64  `json:"half_life_days,omitempty"`
	MaxPerSource int      `json:"max_per_source,omitempty"`
	Budget       int      `json:"budget,omitempty"`
}

RetrievalManifest records the retrieval levers exactly as resolved, so replay reconstructs the same RetrieveOptions and re-runs an identical pipeline.

type RetrieveOptions added in v1.0.0

type RetrieveOptions struct {
	Collections []string
	Query       string
	K           int
	Candidates  int // rerank candidate pool (must be >= K when Rerank)
	Source      string
	Filter      domain.Predicate
	Rerank      bool
	Explain     bool // surface the runner-up just outside the top-k
	Hybrid      bool
	// Lexical selects BM25-only retrieval (no query embedding), for querying with
	// no usable embedder. It is a distinct mode, mutually exclusive with the
	// vector-based reorderings (Hybrid/Rerank/MMR/Recency) and single-collection.
	Lexical      bool
	MMR          bool
	MMRLambda    float64
	Recency      bool
	HalfLife     time.Duration
	MaxPerSource int // 0 = no cap
}

RetrieveOptions configures Retriever.Resolve — the single source of truth for retrieval composition shared by the CLI, the eval harness, and the MCP server. Rerank, MMR, and Recency each reorder the candidate pool and are mutually exclusive. Hybrid, Source, Filter, and MaxPerSource compose with any mode.

type Retriever added in v1.0.0

type Retriever struct {
	// contains filtered or unexported fields
}

Retriever resolves a query into ranked hits, composing vector search with optional hybrid fusion, cross-encoder rerank, MMR diversification, recency decay, and a per-source cap. It is the one place that composition lives; drivers (CLI/eval/MCP) translate their flags into RetrieveOptions and call it.

func NewRetriever added in v1.0.0

func NewRetriever(querier *Querier, rerank *Reranker, index VectorIndex) *Retriever

NewRetriever wires a Retriever. rerank may be nil (a Rerank request then errors).

func (*Retriever) Resolve added in v1.0.0

func (r *Retriever) Resolve(ctx context.Context, opts RetrieveOptions) ([]domain.ChunkHit, *float64, error)

Resolve runs the retrieval pipeline for opts and returns the ranked hits plus, when Explain is set, the runner-up score (the best candidate just outside the returned set). The composition order is rerank/MMR/recency → per-source cap.

type Source

type Source interface {
	Walk(ctx context.Context, root string, fn func(SourceItem) error) error
}

Source yields raw documents from somewhere (filesystem walk, stdin, URL). Walk stops and returns the first error from fn, or ctx.Err on cancellation.

type SourceItem

type SourceItem struct {
	URI         string
	ContentType string
	Fingerprint string
	// ModTime is the source's last-modified time, recorded as recency provenance
	// (the reserved `mtime` metadata key). Zero when the source has no meaningful
	// timestamp (e.g. stdin).
	ModTime time.Time
	Open    func() ([]byte, error)
}

SourceItem is one raw document yielded by a Source. Content is read lazily via Open so a consumer can decide — from the cheap Fingerprint — not to read at all. Fingerprint is a source-side signature (size + sampled-content hash) that changes when the file changes; an empty Fingerprint disables fast-skip.

type StreamingGenerator

type StreamingGenerator interface {
	SynthesizeStream(ctx context.Context, question string, hits []domain.ChunkHit, attachments []domain.Attachment, onDelta func(string)) (Answer, error)
}

StreamingGenerator is an optional capability a Generator may implement: it emits the answer's prose incrementally via onDelta (called in order, once per token or chunk) while still returning the complete Answer — the full text and citations a caller needs for the post-answer sources, --json, and caching. A generator that cannot stream a given request (structured-output mode, or a cache hit) may call onDelta once with the whole text. Callers detect support with a type assertion; the CLI uses it only for interactive streaming.

type SyncSummary

type SyncSummary struct {
	Added       int      // documents ingested (new or changed)
	Skipped     int      // unchanged or empty documents (idempotent no-ops)
	Unsupported int      // documents whose content type no extractor handles
	Chunks      int      // chunks embedded and stored
	Pruned      int      // documents removed (or, in a dry run, that would be)
	PrunedURIs  []string // source URIs of the pruned documents, sorted
}

SyncSummary reports the outcome of a sync run.

type Syncer

type Syncer struct {
	// contains filtered or unexported fields
}

Syncer brings a collection into line with its source(s). It re-ingests (add/update, idempotently) and, when prune is set, removes documents whose source files no longer exist. With no paths it replays the sources the collection remembers from prior add/sync runs.

func NewSyncer

func NewSyncer(catalog *Catalog, ingestor *Ingestor, remover *Remover, source Source) *Syncer

NewSyncer wires a Syncer over the use cases and the Source it walks for prune.

func (*Syncer) Sync

func (s *Syncer) Sync(ctx context.Context, collection string, paths []string, prune, dryRun bool) (SyncSummary, error)

Sync ingests paths into the collection and, when prune is set, removes documents no longer present at the source. With no paths, the collection's remembered sources are replayed; with none recorded it fails with ErrInvalidArgument. An unknown collection is ErrNotFound.

dryRun previews the prune set and changes nothing — no ingestion, no removal; it only applies to --prune (ErrInvalidArgument otherwise). The prune set does not depend on ingesting first: a freshly added document is always present at its source, so it is never in the removal set.

type TokenCounter

type TokenCounter interface {
	Count(text string) int
}

TokenCounter approximates how many tokens a piece of text occupies in an embedding/generation model's context window. It backs the entire token-awareness story: chunk sizing (the domain chunkers take its Count as an injected func, keeping the tokenizer dependency out of stdlib-only domain) and budget-bounded retrieval. Counts are approximate-but-stable across models, not exact per-model — enough to size windows predictably.

type TransferSummary

type TransferSummary struct {
	Collection string
	Model      string
	Dimensions int
	Documents  int
	Chunks     int
}

TransferSummary describes a collection that was exported or imported.

type VectorEntry

type VectorEntry struct {
	ChunkID  domain.ChunkID
	Vector   []float32
	Metadata domain.Metadata
}

VectorEntry pairs a chunk identity with its vector for indexing. Metadata is the chunk's document-level attributes, carried on the entry so the index can apply a --where filter during Search without reaching into the DocumentRepository (the two are separate ports, possibly separate engines).

type VectorIndex

type VectorIndex interface {
	Upsert(ctx context.Context, collection string, entries []VectorEntry) error
	Search(ctx context.Context, collection string, query []float32, k int, filter domain.Predicate) ([]domain.VectorMatch, error)
	// Entries returns every stored (ChunkID, Vector) for the collection, in
	// unspecified order, as copies the caller may retain. An unknown collection
	// yields no entries and no error (mirrors Search). It feeds a collection's
	// own vectors back as queries (query --from-collection) without re-embedding.
	Entries(ctx context.Context, collection string) ([]VectorEntry, error)
	Delete(ctx context.Context, collection string, ids []domain.ChunkID) error
}

VectorIndex stores and searches vectors. It is deliberately dumb: space coherence is enforced by the use cases via Collection.AcceptsSpace before anything reaches the index.

Semantics:

  • Upsert replaces entries with the same ChunkID.
  • Search returns up to k matches, best first (higher score = more similar), considering only entries whose Metadata satisfies filter; the filter is applied before the top-k cut, so the result is exact (no over-fetch). The zero Predicate matches every entry, i.e. no filtering. Unknown collection or k <= 0 yields no matches, no error.
  • Delete of absent IDs is a no-op.

type Verdict added in v1.0.0

type Verdict struct {
	Supported bool
	Rationale string
}

Verdict is the entailment judgment for one claim against candidate evidence: whether the evidence supports the claim, with an optional short rationale.

type Verifier added in v1.0.0

type Verifier interface {
	Verify(ctx context.Context, claim, evidence string) (Verdict, error)
}

Verifier judges whether a claim is entailed by evidence text — the model call behind faithfulness verification (ask --verify). The default implementation reuses the chat model via a structured entailment prompt, so it needs no new dependency and inherits the chat endpoint configuration.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL