404 lines
12 KiB
Go
404 lines
12 KiB
Go
package semanticreconcile
|
|
|
|
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 (
|
|
candidateInputName = "candidates"
|
|
transcriptInputName = "transcript"
|
|
jsonMediaType = "application/json"
|
|
)
|
|
|
|
var defaultLimits = Limits{
|
|
ContextRadius: 2,
|
|
MaximumCandidates: 128,
|
|
MaximumMaterialBytes: 262144,
|
|
}
|
|
|
|
// Candidate is contextual source-backed input supplied by a typed consumer.
|
|
// Prepare does not retain or mutate Label or SourceRefs.
|
|
type Candidate struct {
|
|
Label string
|
|
SourceRefs []source.SourceRef
|
|
}
|
|
|
|
// Limits bounds source context and serialized model input.
|
|
type Limits struct {
|
|
ContextRadius int
|
|
MaximumCandidates int
|
|
MaximumMaterialBytes int
|
|
}
|
|
|
|
// DefaultLimits returns the core-owned production limits.
|
|
func DefaultLimits() Limits {
|
|
return defaultLimits
|
|
}
|
|
|
|
// Validate rejects limits that cannot safely bound preparation.
|
|
func (limits Limits) Validate() error {
|
|
if limits.ContextRadius < 0 {
|
|
return fmt.Errorf("semantic reconciliation limits: context radius must not be negative")
|
|
}
|
|
if limits.MaximumCandidates <= 0 {
|
|
return fmt.Errorf("semantic reconciliation limits: maximum candidates must be positive")
|
|
}
|
|
if limits.MaximumMaterialBytes <= 0 {
|
|
return fmt.Errorf("semantic reconciliation limits: maximum bytes must be positive")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Disposition describes whether prepared materials may be sent to a model.
|
|
type Disposition uint8
|
|
|
|
const (
|
|
// Ready indicates that the result contains complete bounded materials.
|
|
Ready Disposition = iota + 1
|
|
// InsufficientCandidates indicates that fewer than two candidates were
|
|
// eligible after source-reference validation.
|
|
InsufficientCandidates
|
|
// LimitExceeded indicates that a candidate or serialized-material bound was
|
|
// exceeded and no request should be split or sent.
|
|
LimitExceeded
|
|
)
|
|
|
|
// CandidateMapping relates one model-visible request-local ID to the
|
|
// corresponding zero-based position in the caller's candidate slice.
|
|
type CandidateMapping struct {
|
|
CandidateID int
|
|
CandidatePosition int
|
|
}
|
|
|
|
// Preparation owns the visible candidate mapping and prompt materials.
|
|
type Preparation struct {
|
|
disposition Disposition
|
|
mappings []CandidateMapping
|
|
materials contracts.LLMInputSet
|
|
}
|
|
|
|
// Disposition returns the preparation outcome.
|
|
func (preparation Preparation) Disposition() Disposition {
|
|
return preparation.disposition
|
|
}
|
|
|
|
// CandidateMappings returns an owned copy in model-visible candidate order.
|
|
func (preparation Preparation) CandidateMappings() []CandidateMapping {
|
|
return append([]CandidateMapping(nil), preparation.mappings...)
|
|
}
|
|
|
|
// Materials returns independently owned candidate and transcript materials.
|
|
// It is empty unless Disposition returns Ready.
|
|
func (preparation Preparation) Materials() contracts.LLMInputSet {
|
|
return preparation.materials.Clone()
|
|
}
|
|
|
|
type sourceRange struct {
|
|
StartUnitID int `json:"start_unit_id"`
|
|
EndUnitID int `json:"end_unit_id"`
|
|
}
|
|
|
|
type visibleCandidate struct {
|
|
CandidateID int `json:"candidate_id"`
|
|
Label string `json:"label"`
|
|
SourceRefs []sourceRange `json:"source_refs"`
|
|
}
|
|
|
|
type candidateInput struct {
|
|
Candidates []visibleCandidate `json:"candidates"`
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
type preparedCandidate struct {
|
|
position int
|
|
references []sourceRange
|
|
intervals []sourceInterval
|
|
}
|
|
|
|
// Prepare validates candidates and constructs bounded, source-ordered model
|
|
// inputs. Deterministic skip conditions are represented by the returned
|
|
// disposition rather than an error.
|
|
func Prepare(document *source.SourceDocument, candidates []Candidate, limits Limits) (Preparation, error) {
|
|
if err := limits.Validate(); err != nil {
|
|
return Preparation{}, err
|
|
}
|
|
|
|
documentIndex := source.NewDocumentIndex(document)
|
|
prepared := make([]preparedCandidate, 0, len(candidates))
|
|
for candidatePosition, candidate := range candidates {
|
|
references, intervals, valid := prepareReferences(documentIndex, candidate.SourceRefs)
|
|
if !valid {
|
|
continue
|
|
}
|
|
prepared = append(prepared, preparedCandidate{
|
|
position: candidatePosition,
|
|
references: references,
|
|
intervals: intervals,
|
|
})
|
|
}
|
|
|
|
result := Preparation{
|
|
disposition: InsufficientCandidates,
|
|
mappings: make([]CandidateMapping, len(prepared)),
|
|
}
|
|
views := make([]visibleCandidate, len(prepared))
|
|
for index, candidate := range prepared {
|
|
candidateID := index + 1
|
|
result.mappings[index] = CandidateMapping{
|
|
CandidateID: candidateID,
|
|
CandidatePosition: candidate.position,
|
|
}
|
|
views[index] = visibleCandidate{
|
|
CandidateID: candidateID,
|
|
Label: candidates[candidate.position].Label,
|
|
SourceRefs: cloneSourceRanges(candidate.references),
|
|
}
|
|
}
|
|
if len(prepared) < 2 {
|
|
return result, nil
|
|
}
|
|
if len(prepared) > limits.MaximumCandidates {
|
|
result.disposition = LimitExceeded
|
|
return result, nil
|
|
}
|
|
|
|
candidateContent, withinLimit, err := marshalCandidateInput(views, limits.MaximumMaterialBytes)
|
|
if err != nil {
|
|
return Preparation{}, fmt.Errorf("prepare semantic reconciliation: encode candidate material: %w", err)
|
|
}
|
|
if !withinLimit {
|
|
result.disposition = LimitExceeded
|
|
return result, nil
|
|
}
|
|
|
|
contextIntervals := make([]sourceInterval, 0)
|
|
citedIntervals := make([]sourceInterval, 0)
|
|
for _, candidate := range prepared {
|
|
for _, interval := range candidate.intervals {
|
|
citedIntervals = append(citedIntervals, interval)
|
|
contextIntervals = append(contextIntervals, sourceInterval{
|
|
start: max(0, interval.start-limits.ContextRadius),
|
|
end: min(len(document.Units)-1, interval.end+limits.ContextRadius),
|
|
})
|
|
}
|
|
}
|
|
transcriptContent, withinLimit, err := marshalTranscriptInput(
|
|
document.Units,
|
|
coalesceIntervals(contextIntervals),
|
|
coalesceIntervals(citedIntervals),
|
|
limits.MaximumMaterialBytes-len(candidateContent),
|
|
)
|
|
if err != nil {
|
|
return Preparation{}, fmt.Errorf("prepare semantic reconciliation: build transcript material: invalid source metadata")
|
|
}
|
|
if !withinLimit {
|
|
result.disposition = LimitExceeded
|
|
return result, nil
|
|
}
|
|
|
|
result.disposition = Ready
|
|
result.materials = contracts.LLMInputSet{
|
|
candidateInputName: newInputMaterial(candidateInputName, candidateContent),
|
|
transcriptInputName: newInputMaterial(transcriptInputName, transcriptContent),
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func prepareReferences(index source.DocumentIndex, references []source.SourceRef) ([]sourceRange, []sourceInterval, bool) {
|
|
if len(references) == 0 {
|
|
return nil, nil, false
|
|
}
|
|
type referencedInterval struct {
|
|
reference sourceRange
|
|
interval sourceInterval
|
|
}
|
|
prepared := make([]referencedInterval, 0, len(references))
|
|
for _, reference := range references {
|
|
if err := index.ValidateRef(reference); err != nil {
|
|
return nil, nil, false
|
|
}
|
|
start, _ := index.Position(reference.StartUnitID)
|
|
end, _ := index.Position(reference.EndUnitID)
|
|
prepared = append(prepared, referencedInterval{
|
|
reference: sourceRange{StartUnitID: reference.StartUnitID, EndUnitID: reference.EndUnitID},
|
|
interval: sourceInterval{start: start, end: end},
|
|
})
|
|
}
|
|
sort.Slice(prepared, func(left, right int) bool {
|
|
if prepared[left].interval.start != prepared[right].interval.start {
|
|
return prepared[left].interval.start < prepared[right].interval.start
|
|
}
|
|
return prepared[left].interval.end < prepared[right].interval.end
|
|
})
|
|
|
|
canonicalReferences := make([]sourceRange, 0, len(prepared))
|
|
intervals := make([]sourceInterval, 0, len(prepared))
|
|
for _, item := range prepared {
|
|
if len(canonicalReferences) > 0 && canonicalReferences[len(canonicalReferences)-1] == item.reference {
|
|
continue
|
|
}
|
|
canonicalReferences = append(canonicalReferences, item.reference)
|
|
intervals = append(intervals, item.interval)
|
|
}
|
|
return canonicalReferences, intervals, true
|
|
}
|
|
|
|
func cloneSourceRanges(ranges []sourceRange) []sourceRange {
|
|
if len(ranges) == 0 {
|
|
return []sourceRange{}
|
|
}
|
|
return append([]sourceRange(nil), ranges...)
|
|
}
|
|
|
|
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 marshalCandidateInput(candidates []visibleCandidate, maximumBytes int) ([]byte, bool, error) {
|
|
content := make([]byte, 0, min(maximumBytes, 4096))
|
|
var withinLimit bool
|
|
content, withinLimit = appendWithinLimit(content, maximumBytes, []byte(`{"candidates":[`))
|
|
if !withinLimit {
|
|
return nil, false, nil
|
|
}
|
|
for index, candidate := range candidates {
|
|
encoded, err := json.Marshal(candidate)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
separator := []byte(nil)
|
|
if index > 0 {
|
|
separator = []byte(",")
|
|
}
|
|
content, withinLimit = appendWithinLimit(content, maximumBytes, separator, encoded)
|
|
if !withinLimit {
|
|
return nil, false, nil
|
|
}
|
|
}
|
|
content, withinLimit = appendWithinLimit(content, maximumBytes, []byte("]}"))
|
|
return content, withinLimit, nil
|
|
}
|
|
|
|
// marshalTranscriptInput retains at most maximumBytes while visiting source
|
|
// units in order. It deliberately serializes one unit at a time so an oversized
|
|
// request does not require a document-sized transcript copy before rejection.
|
|
func marshalTranscriptInput(units []source.SourceUnit, contextIntervals, citedIntervals []sourceInterval, maximumBytes int) ([]byte, bool, error) {
|
|
content := make([]byte, 0, min(maximumBytes, 4096))
|
|
content, withinLimit := appendWithinLimit(content, maximumBytes, []byte(`{"windows":[`))
|
|
if !withinLimit {
|
|
return nil, false, nil
|
|
}
|
|
|
|
citedIndex := 0
|
|
for windowIndex, interval := range contextIntervals {
|
|
separator := []byte(nil)
|
|
if windowIndex > 0 {
|
|
separator = []byte(",")
|
|
}
|
|
content, withinLimit = appendWithinLimit(content, maximumBytes, separator, []byte(`{"units":[`))
|
|
if !withinLimit {
|
|
return nil, false, nil
|
|
}
|
|
for position := interval.start; position <= interval.end; position++ {
|
|
for citedIndex < len(citedIntervals) && citedIntervals[citedIndex].end < position {
|
|
citedIndex++
|
|
}
|
|
cited := citedIndex < len(citedIntervals) && citedIntervals[citedIndex].start <= position
|
|
unit := units[position]
|
|
metadata, err := source.CloneMetadata(unit.Metadata)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
encoded, err := json.Marshal(transcriptUnit{
|
|
ID: unit.ID, Kind: unit.Kind, Text: unit.Text, Metadata: metadata, Cited: cited,
|
|
})
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
separator = nil
|
|
if position > interval.start {
|
|
separator = []byte(",")
|
|
}
|
|
content, withinLimit = appendWithinLimit(content, maximumBytes, separator, encoded)
|
|
if !withinLimit {
|
|
return nil, false, nil
|
|
}
|
|
}
|
|
content, withinLimit = appendWithinLimit(content, maximumBytes, []byte("]}"))
|
|
if !withinLimit {
|
|
return nil, false, nil
|
|
}
|
|
}
|
|
content, withinLimit = appendWithinLimit(content, maximumBytes, []byte("]}"))
|
|
return content, withinLimit, nil
|
|
}
|
|
|
|
func appendWithinLimit(content []byte, maximumBytes int, parts ...[]byte) ([]byte, bool) {
|
|
for _, part := range parts {
|
|
if len(content) > maximumBytes || len(part) > maximumBytes-len(content) {
|
|
return content, false
|
|
}
|
|
content = append(content, part...)
|
|
}
|
|
return content, true
|
|
}
|
|
|
|
func newInputMaterial(name string, content []byte) contracts.LLMInputMaterial {
|
|
digest := sha256.Sum256(content)
|
|
return contracts.NewLLMInputMaterial(
|
|
name,
|
|
jsonMediaType,
|
|
content,
|
|
"sha256:"+hex.EncodeToString(digest[:]),
|
|
"",
|
|
)
|
|
}
|