Code sync, security fixes

This commit is contained in:
Andy Dixon
2026-08-11 10:40:10 +01:00
parent 4e00deedf8
commit 53dbc28383
141 changed files with 36194 additions and 0 deletions
+305
View File
@@ -0,0 +1,305 @@
package search
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/andydixon/relata/internal/analysis"
"github.com/andydixon/relata/internal/domain"
"github.com/andydixon/relata/internal/observability"
)
// AnswerRequest asks a question of the stored material.
type AnswerRequest struct {
Question string
Query
}
// Answer is a generated answer, restricted to stored material and cited.
type Answer struct {
Question string `json:"question"`
// Answer is the generated prose. It is rendered as escaped text, never HTML.
Answer string `json:"answer"`
// Sufficient reports whether the stored material actually supports an
// answer. When false, the answer says so rather than guessing.
Sufficient bool `json:"sufficient"`
// Sources used — the stored items the answer draws on.
Sources []AnswerSource `json:"sources_used"`
// Uncertainty and conflicts — disagreements between sources, and any
// caveats about what the answer does and does not establish.
Uncertainty []string `json:"uncertainty_and_conflicts,omitempty"`
// InferredRelationshipsUsed lists links that informed retrieval but are
// model proposals, not verified facts.
InferredRelationshipsUsed []RelatedItem `json:"inferred_relationships_used,omitempty"`
Model string `json:"model,omitempty"`
Retrieved int `json:"retrieved_items"`
TookMS int64 `json:"took_ms"`
// InjectionNoticed reports that stored content tried to instruct the model.
InjectionNoticed bool `json:"prompt_injection_noticed,omitempty"`
}
// AnswerSource is one cited stored item.
type AnswerSource struct {
Ref string `json:"ref"`
ItemID string `json:"item_id"`
Title string `json:"title"`
Excerpt string `json:"excerpt"`
Date string `json:"date,omitempty"`
// Cited reports whether the generated answer actually referenced it.
Cited bool `json:"cited"`
}
// ErrAnswerDisabled is returned when answer generation is switched off.
var ErrAnswerDisabled = fmt.Errorf("answer generation is not enabled")
// Answerer generates answers from stored material.
type Answerer struct {
search *Service
analysis *analysis.Service
claims domain.ClaimRepository
enabled bool
}
// NewAnswerer builds the answer service.
func NewAnswerer(s *Service, an *analysis.Service, claims domain.ClaimRepository, enabled bool) *Answerer {
return &Answerer{search: s, analysis: an, claims: claims, enabled: enabled}
}
// Enabled reports whether answers can be generated.
func (a *Answerer) Enabled() bool {
return a != nil && a.enabled && a.analysis.Enabled()
}
// Answer retrieves relevant material and generates a cited answer from it.
//
// Several guarantees hold regardless of what the model returns:
// - the answer is built only from retrieved passages;
// - citations are resolved against the passages actually supplied, so a
// fabricated reference cannot point at a real item;
// - conflicting sources are surfaced, never reconciled away;
// - any inferred relationship that influenced retrieval is listed as a
// proposal rather than presented as fact.
func (a *Answerer) Answer(ctx context.Context, p *domain.Principal, req AnswerRequest) (*Answer, error) {
if !a.Enabled() {
return nil, ErrAnswerDisabled
}
started := time.Now()
question := strings.TrimSpace(req.Question)
if question == "" {
return nil, domain.ErrInvalidInput
}
q := req.Query
q.Text = question
q.Expand = true
if q.Limit <= 0 || q.Limit > a.search.cfg.Search.AnswerMaxChunks {
q.Limit = a.search.cfg.Search.AnswerMaxChunks
}
results, err := a.search.Search(ctx, p, q)
if err != nil {
return nil, err
}
out := &Answer{Question: question, Retrieved: len(results.Results)}
if len(results.Results) == 0 {
out.Answer = "There is nothing in the stored material that addresses this question. " +
"Nothing has been inferred or supplied from outside the stored items."
out.Sufficient = false
out.TookMS = time.Since(started).Milliseconds()
a.search.count("answer", "no_material")
return out, nil
}
// Build the passages. The model sees opaque refs (S1, S2…) rather than item
// IDs, so it cannot invent a citation that resolves to a real record.
maxChars := a.search.cfg.Search.AnswerMaxCharsPerChunk
if maxChars <= 0 {
maxChars = 1200
}
passages := make([]analysis.Passage, 0, len(results.Results))
for i, r := range results.Results {
ref := fmt.Sprintf("S%d", i+1)
excerpt := r.Excerpt
if len(r.MatchedChunks) > 0 {
// The matched chunk carries more context than the short excerpt.
excerpt = truncateWords(r.MatchedChunks[0].Text, maxChars)
}
if strings.TrimSpace(excerpt) == "" {
excerpt = truncateWords(r.Item.ExtractedText, maxChars)
}
if strings.TrimSpace(excerpt) == "" {
continue
}
date := ""
if !r.Item.CreatedAt.IsZero() {
date = r.Item.CreatedAt.Format("2 January 2006")
}
passages = append(passages, analysis.Passage{
Ref: ref, ItemID: r.Item.ID, Title: r.Item.Title, Excerpt: excerpt, Date: date,
})
out.Sources = append(out.Sources, AnswerSource{
Ref: ref, ItemID: r.Item.ID, Title: r.Item.Title, Excerpt: excerpt, Date: date,
})
// Record any inferred relationship that pulled this item in, so the
// answer can disclose that it was reached by a proposal.
if r.ViaRelationship != nil && r.ViaRelationship.Provenance == domain.ProvModelInferred {
out.InferredRelationshipsUsed = append(out.InferredRelationshipsUsed, *r.ViaRelationship)
}
}
if len(passages) == 0 {
out.Answer = "Items were found, but none contained readable text to answer from."
out.Sufficient = false
out.TookMS = time.Since(started).Milliseconds()
return out, nil
}
generated, err := a.analysis.GenerateAnswer(ctx, question, passages)
if err != nil {
// The truncated sample of what the model actually said is the whole
// diagnostic here: "rejected as degenerate" tells an operator nothing
// about whether the model is broken, the prompt is wrong, or the check
// is too strict. It is already bounded to 300 characters.
log := observability.LoggerFrom(ctx)
var sampled *analysis.InvalidOutputError
if errors.As(err, &sampled) {
log.Warn("answer generation failed",
"error", err.Error(), "reason", sampled.Reason, "model_output", sampled.Sample)
} else {
log.Warn("answer generation failed", "error", err.Error())
}
// A model that returned something unusable is not a failed request.
// Retrieval already found the material and Relata has already recorded
// any contradictions between those items — the deterministic work is
// the part worth keeping. Report that no prose could be generated and
// hand back the sources and conflicts, as the no-material and
// no-readable-text paths above do.
var invalid *analysis.InvalidOutputError
if errors.As(err, &invalid) {
a.search.count("answer", "invalid_output")
out.Answer = "The answering model did not return a usable answer, so none is shown. " +
"The sources below were retrieved for this question and can be read directly; " +
"nothing has been inferred or supplied from outside the stored items."
out.Sufficient = false
out.Uncertainty = append(out.Uncertainty,
"No answer was generated: the model's response was rejected as invalid "+
"("+invalid.Reason+"). The retrieved sources are unaffected.")
if conflicts := a.storedConflicts(ctx, p, results.Results); len(conflicts) > 0 {
out.Uncertainty = append(out.Uncertainty, conflicts...)
}
out.TookMS = time.Since(started).Milliseconds()
return out, nil
}
// Anything else — a timeout, an unreachable provider — is an
// operational condition the caller needs to see as a failure.
a.search.count("answer", "error")
return nil, err
}
out.Answer = generated.Answer
out.Sufficient = generated.Sufficient
out.Model = generated.Model
out.InjectionNoticed = generated.InjectionNoticed
cited := map[string]bool{}
for _, c := range generated.Citations {
cited[c.Ref] = true
}
for i := range out.Sources {
out.Sources[i].Cited = cited[out.Sources[i].Ref]
}
out.Uncertainty = append(out.Uncertainty, generated.Conflicts...)
// Independently of what the model said, surface contradictions Relata has
// recorded between the retrieved items. A model that quietly picked one
// side must not be able to hide the disagreement.
if conflicts := a.storedConflicts(ctx, p, results.Results); len(conflicts) > 0 {
out.Uncertainty = append(out.Uncertainty, conflicts...)
}
if !out.Sufficient {
out.Uncertainty = append(out.Uncertainty,
"The stored material does not fully answer this question. Treat the answer above as partial.")
}
if len(out.InferredRelationshipsUsed) > 0 {
out.Uncertainty = append(out.Uncertainty, fmt.Sprintf(
"%d of the sources were reached through relationships that Relata inferred and no one has confirmed. "+
"An inferred relationship is a proposal, not an established fact.",
len(out.InferredRelationshipsUsed)))
}
if generated.InjectionNoticed {
out.Uncertainty = append(out.Uncertainty,
"One or more stored documents contained text that attempted to instruct the answering model. "+
"Those instructions were ignored, but the affected sources should be reviewed.")
}
out.TookMS = time.Since(started).Milliseconds()
a.search.count("answer", "success")
return out, nil
}
// storedConflicts reports contradictions Relata recorded between the retrieved
// items, independently of the model's own account.
func (a *Answerer) storedConflicts(ctx context.Context, p *domain.Principal, results []Result) []string {
if len(results) < 2 {
return nil
}
ids := make([]string, 0, len(results))
titles := make(map[string]string, len(results))
for _, r := range results {
ids = append(ids, r.Item.ID)
titles[r.Item.ID] = r.Item.Title
}
rels, err := a.search.rels.ListForItems(ctx, p.WorkspaceID, ids, 0)
if err != nil {
return nil
}
var out []string
seen := map[string]bool{}
for _, rel := range rels {
if rel.Type != domain.RelContradicts || rel.ReviewStatus == domain.ReviewRejected {
continue
}
// Both ends must be in the retrieved set for the conflict to be
// relevant to this answer.
srcTitle, okA := titles[rel.SourceID]
tgtTitle, okB := titles[rel.TargetID]
if !okA || !okB || seen[rel.ID] {
continue
}
seen[rel.ID] = true
detail := ""
for _, s := range rel.Evidence.Signals {
if s.Type == domain.SigClaimContradiction {
detail = " " + s.Detail
break
}
}
out = append(out, fmt.Sprintf(
"%q and %q record conflicting information (relationship strength %.2f, %s).%s",
srcTitle, tgtTitle, rel.Score, provenanceLabel(rel.Provenance), detail))
}
sort.Strings(out)
if len(out) > 6 {
out = out[:6]
}
return out
}
+78
View File
@@ -0,0 +1,78 @@
package search
import (
"testing"
"time"
"unicode/utf8"
"github.com/andydixon/relata/internal/domain"
)
// Probe the pure helpers with adversarial input.
func TestExcerptHelpersOnAdversarialInput(t *testing.T) {
inputs := []string{"", " ", "\x00", "é", "ééééééééééé", "a\nb\tc", "🌍🌍🌍"}
for _, in := range inputs {
for _, n := range []int{0, 1, 2, 5, 1000} {
got := truncateWords(in, n)
for _, r := range got {
if r == '�' {
t.Fatalf("truncateWords(%q,%d) produced a replacement char: %q", in, n, got)
}
}
}
}
}
func TestFindTermWindowOnAdversarialInput(t *testing.T) {
texts := []string{"", "short", "ééééé match ééééé", "🌍 match 🌍"}
for _, txt := range texts {
for _, terms := range [][]string{nil, {}, {""}, {"match"}, {"zzz"}, {"é"}} {
got := findTermWindow(txt, terms, 20)
for _, r := range got {
if r == '�' {
t.Fatalf("findTermWindow(%q,%v) produced a replacement char: %q", txt, terms, got)
}
}
}
}
}
func TestMatchesFilterHandlesZeroValues(t *testing.T) {
it := &domain.Item{}
if !matchesFilter(it, domain.ItemFilter{}, Query{}) {
t.Fatal("an empty filter rejected an item")
}
future := time.Now().Add(time.Hour)
if matchesFilter(it, domain.ItemFilter{CreatedFrom: &future}, Query{}) {
t.Fatal("a zero-time item passed a future CreatedFrom filter")
}
}
// Excerpt building slices UTF-8 text at computed offsets, so it is exactly the
// kind of code that produces mangled output on unusual input.
func FuzzExcerptHelpers(f *testing.F) {
f.Add("The revised delivery date is 14 March 2025.", "delivery", 40)
f.Add("ééééé", "é", 3)
f.Add("🌍🌍🌍", "🌍", 2)
f.Add("", "", 0)
f.Fuzz(func(t *testing.T, text, term string, width int) {
if width < 0 || width > 1<<16 {
return
}
// Slicing valid UTF-8 must not produce invalid UTF-8.
if !utf8.ValidString(text) {
return
}
for name, got := range map[string]string{
"truncateWords": truncateWords(text, width),
"findTermWindow": findTermWindow(text, []string{term}, width),
"collapse": collapse(text),
} {
if !utf8.ValidString(got) {
t.Fatalf("%s produced invalid UTF-8 from text=%q term=%q width=%d -> %q",
name, text, term, width, got)
}
}
})
}