// Package entityreconcile provides safe, D&D-specific duplicate proposal // materials shared by entity normalizers. package entityreconcile import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "sort" "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" ) const candidateKeyFormat = "candidate-%06d" // Candidate is one domain-neutral entity candidate supplied by a normalizer. // BuildContext never retains or mutates its source references. type Candidate struct { Name string SourceRefs []source.SourceRef } // Materials contains owned prompt inputs and opaque candidate-key mappings. type Materials struct { Candidates contracts.LLMInputMaterial Transcript contracts.LLMInputMaterial candidateKeys []string eligible map[string]struct{} } // CandidateKeys returns all deterministic keys in candidate input order. func (m Materials) CandidateKeys() []string { return append([]string(nil), m.candidateKeys...) } // EligibleCandidateKeys returns only candidates whose evidence safely produced // transcript context, preserving candidate input order. func (m Materials) EligibleCandidateKeys() []string { keys := make([]string, 0, len(m.eligible)) for _, key := range m.candidateKeys { if _, ok := m.eligible[key]; ok { keys = append(keys, key) } } return keys } type candidateInput struct { Candidates []candidateView `json:"candidates"` } type candidateView struct { Key string `json:"key"` Name string `json:"name"` SourceRefs []candidateSourceRef `json:"source_refs"` } type candidateSourceRef struct { StartUnitID int `json:"start_unit_id"` EndUnitID int `json:"end_unit_id"` } type transcriptInput struct { Windows []transcriptWindow `json:"windows"` } type transcriptWindow struct { Units []transcriptUnit `json:"units"` } type transcriptUnit struct { ID int `json:"id"` Kind string `json:"kind"` Text string `json:"text"` Metadata map[string]any `json:"metadata,omitempty"` Cited bool `json:"cited"` } type sourceInterval struct { start int end int } // BuildContext constructs bounded, source-ordered prompt inputs. It returns // ready=false when fewer than two candidates have safe context. func BuildContext(doc *source.SourceDocument, candidates []Candidate, radius int) (Materials, bool, error) { if radius < 0 { return Materials{}, false, fmt.Errorf("build entity reconciliation context: radius must not be negative") } materials := Materials{ candidateKeys: make([]string, len(candidates)), eligible: make(map[string]struct{}), } for index := range candidates { key := fmt.Sprintf(candidateKeyFormat, index+1) materials.candidateKeys[index] = key } if doc == nil { return materials, false, nil } index := source.NewDocumentIndex(doc) views := make([]candidateView, 0, len(candidates)) intervals := make([]sourceInterval, 0) cited := make([]bool, len(doc.Units)) for candidateIndex, candidate := range candidates { references, candidateIntervals, valid := candidateReferences(index, candidate.SourceRefs) if !valid { continue } key := materials.candidateKeys[candidateIndex] materials.eligible[key] = struct{}{} views = append(views, candidateView{Key: key, Name: candidate.Name, SourceRefs: references}) for _, interval := range candidateIntervals { for position := interval.start; position <= interval.end; position++ { cited[position] = true } intervals = append(intervals, sourceInterval{ start: maxInt(0, interval.start-radius), end: minInt(len(doc.Units)-1, interval.end+radius), }) } } if len(views) < 2 { return materials, false, nil } windows, err := contextWindows(doc.Units, coalesceIntervals(intervals), cited) if err != nil { return Materials{}, false, fmt.Errorf("build entity reconciliation context: invalid source metadata") } candidateContent, err := json.Marshal(candidateInput{Candidates: views}) if err != nil { return Materials{}, false, fmt.Errorf("build entity reconciliation context: invalid candidate material") } transcriptContent, err := json.Marshal(transcriptInput{Windows: windows}) if err != nil { return Materials{}, false, fmt.Errorf("build entity reconciliation context: invalid transcript material") } materials.Candidates = newInputMaterial("candidates", candidateContent) materials.Transcript = newInputMaterial("transcript", transcriptContent) return materials, true, nil } func candidateReferences(index source.DocumentIndex, refs []source.SourceRef) ([]candidateSourceRef, []sourceInterval, bool) { if len(refs) == 0 { return nil, nil, false } references := make([]candidateSourceRef, 0, len(refs)) intervals := make([]sourceInterval, 0, len(refs)) for _, ref := range refs { if err := index.ValidateRef(ref); err != nil { return nil, nil, false } start, _ := index.Position(ref.StartUnitID) end, _ := index.Position(ref.EndUnitID) references = append(references, candidateSourceRef{StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID}) intervals = append(intervals, sourceInterval{start: start, end: end}) } return references, intervals, true } func coalesceIntervals(intervals []sourceInterval) []sourceInterval { if len(intervals) == 0 { return nil } ordered := append([]sourceInterval(nil), intervals...) sort.Slice(ordered, func(left, right int) bool { if ordered[left].start != ordered[right].start { return ordered[left].start < ordered[right].start } return ordered[left].end < ordered[right].end }) coalesced := make([]sourceInterval, 0, len(ordered)) for _, interval := range ordered { if len(coalesced) == 0 || interval.start > coalesced[len(coalesced)-1].end+1 { coalesced = append(coalesced, interval) continue } if interval.end > coalesced[len(coalesced)-1].end { coalesced[len(coalesced)-1].end = interval.end } } return coalesced } func contextWindows(units []source.SourceUnit, intervals []sourceInterval, cited []bool) ([]transcriptWindow, error) { windows := make([]transcriptWindow, 0, len(intervals)) for _, interval := range intervals { window := transcriptWindow{Units: make([]transcriptUnit, 0, interval.end-interval.start+1)} for position := interval.start; position <= interval.end; position++ { unit := units[position] metadata, err := source.CloneMetadata(unit.Metadata) if err != nil { return nil, err } window.Units = append(window.Units, transcriptUnit{ ID: unit.ID, Kind: unit.Kind, Text: unit.Text, Metadata: metadata, Cited: cited[position], }) } windows = append(windows, window) } return windows, nil } func newInputMaterial(name string, content []byte) contracts.LLMInputMaterial { digest := sha256.Sum256(content) return contracts.NewLLMInputMaterial(name, "application/json", content, "sha256:"+hex.EncodeToString(digest[:]), "") } func minInt(left, right int) int { if left < right { return left } return right } func maxInt(left, right int) int { if left > right { return left } return right }