195 lines
6.3 KiB
Go
195 lines
6.3 KiB
Go
// Package diagnostics provides bounded, safe text for deterministic D&D
|
|
// decisions and findings.
|
|
package diagnostics
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
|
frameworkdiagnostics "gitea.maximumdirect.net/eric/notarius/internal/framework/diagnostics"
|
|
)
|
|
|
|
const (
|
|
MaxIssues = 20
|
|
MaxDisplayedRunes = 128
|
|
MaxMessageBytes = 4096
|
|
)
|
|
|
|
// Finding is a local, ungrouped D&D diagnostic fact. Producers convert these
|
|
// to bounded framework diagnostics at their result boundary.
|
|
type Finding struct {
|
|
Scope string
|
|
ReasonCode string
|
|
Message string
|
|
}
|
|
|
|
// Corrections collects domain-owned model instructions and contextual record
|
|
// descriptions without coupling them to operator-facing diagnostics. Rules are
|
|
// emitted before affected records so the bounded result remains useful even
|
|
// when a large candidate exceeds the display budget.
|
|
type Corrections struct {
|
|
groups []correctionGroup
|
|
indexes map[string]int
|
|
}
|
|
|
|
type correctionGroup struct {
|
|
rule string
|
|
records []string
|
|
recordsSeen map[string]struct{}
|
|
}
|
|
|
|
// Add records one semantic rule and, when non-empty, one contextual record to
|
|
// which it applies. key is local grouping state and is never rendered.
|
|
func (c *Corrections) Add(key, rule, record string) {
|
|
if c.indexes == nil {
|
|
c.indexes = make(map[string]int)
|
|
}
|
|
index, ok := c.indexes[key]
|
|
if !ok {
|
|
index = len(c.groups)
|
|
c.indexes[key] = index
|
|
c.groups = append(c.groups, correctionGroup{rule: rule, recordsSeen: make(map[string]struct{})})
|
|
}
|
|
if record == "" {
|
|
return
|
|
}
|
|
group := &c.groups[index]
|
|
if _, seen := group.recordsSeen[record]; seen {
|
|
return
|
|
}
|
|
group.recordsSeen[record] = struct{}{}
|
|
group.records = append(group.records, record)
|
|
}
|
|
|
|
// Guidance returns one bounded correction request. It deliberately renders
|
|
// neither grouping keys nor operator diagnostics.
|
|
func (c Corrections) Guidance(prefix string) string {
|
|
issues := make([]string, 0, len(c.groups)*2)
|
|
for _, group := range c.groups {
|
|
issues = append(issues, group.rule)
|
|
}
|
|
for _, group := range c.groups {
|
|
issues = append(issues, group.records...)
|
|
}
|
|
return Aggregate(prefix, issues)
|
|
}
|
|
|
|
// SourceRange describes cited transcript positions without exposing source
|
|
// identities or application entity IDs.
|
|
func SourceRange(refs []source.SourceRef) string {
|
|
if len(refs) == 0 {
|
|
return "without a cited source range"
|
|
}
|
|
description := SourceRefRange(refs[0])
|
|
if len(refs) > 1 {
|
|
description += fmt.Sprintf(" (first of %d cited ranges)", len(refs))
|
|
}
|
|
return description
|
|
}
|
|
|
|
// SourceRefRange describes one transcript range without exposing its source
|
|
// identity.
|
|
func SourceRefRange(ref source.SourceRef) string {
|
|
if ref.StartUnitID == ref.EndUnitID {
|
|
return "at source unit " + strconv.Itoa(ref.StartUnitID)
|
|
}
|
|
return fmt.Sprintf("at source units %d-%d", ref.StartUnitID, ref.EndUnitID)
|
|
}
|
|
|
|
// DataQualityResult converts accepted source-quality findings into bounded,
|
|
// locally grouped advisories. These findings do not indicate process
|
|
// degradation.
|
|
func DataQualityResult(findings []Finding) (contracts.ValidationResult, error) {
|
|
diagnostics, err := Collect(findings, contracts.DiagnosticDispositionAdvisory, contracts.DiagnosticCategoryDataQuality)
|
|
if err != nil {
|
|
return contracts.ValidationResult{}, err
|
|
}
|
|
return contracts.ValidationResult{Approved: true, Diagnostics: diagnostics}, nil
|
|
}
|
|
|
|
// Collect converts individual deterministic findings into bounded,
|
|
// occurrence-grouped producer diagnostics with one consistent classification.
|
|
func Collect(findings []Finding, disposition contracts.DiagnosticDisposition, category contracts.DiagnosticCategory) ([]contracts.ProducerDiagnostic, error) {
|
|
collector := frameworkdiagnostics.NewCollector()
|
|
for _, finding := range findings {
|
|
if err := collector.Add(contracts.ProducerDiagnostic{
|
|
Disposition: disposition,
|
|
Category: category,
|
|
ReasonCode: finding.ReasonCode,
|
|
OccurrenceCount: 1,
|
|
Samples: []contracts.DiagnosticSample{{Scope: finding.Scope, Message: finding.Message}},
|
|
}); err != nil {
|
|
return nil, fmt.Errorf("collect diagnostic: %w", err)
|
|
}
|
|
}
|
|
return collector.Diagnostics(), nil
|
|
}
|
|
|
|
// NormalizationDiagnostics classifies deterministic normalization findings as
|
|
// observations, except for explicitly identified unresolved-quality findings.
|
|
func NormalizationDiagnostics(findings []Finding, advisoryReasonCodes ...string) ([]contracts.ProducerDiagnostic, error) {
|
|
advisoryReasons := make(map[string]struct{}, len(advisoryReasonCodes))
|
|
for _, reasonCode := range advisoryReasonCodes {
|
|
advisoryReasons[reasonCode] = struct{}{}
|
|
}
|
|
observations := make([]Finding, 0, len(findings))
|
|
advisories := make([]Finding, 0)
|
|
for _, finding := range findings {
|
|
if _, advisory := advisoryReasons[finding.ReasonCode]; advisory {
|
|
advisories = append(advisories, finding)
|
|
continue
|
|
}
|
|
observations = append(observations, finding)
|
|
}
|
|
diagnostics, err := Collect(observations, contracts.DiagnosticDispositionObservation, contracts.DiagnosticCategoryNormalization)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
qualityDiagnostics, err := Collect(advisories, contracts.DiagnosticDispositionAdvisory, contracts.DiagnosticCategoryDataQuality)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return append(diagnostics, qualityDiagnostics...), nil
|
|
}
|
|
|
|
func Truncate(value string) string {
|
|
runes := []rune(value)
|
|
if len(runes) <= MaxDisplayedRunes {
|
|
return value
|
|
}
|
|
return string(runes[:MaxDisplayedRunes-1]) + "…"
|
|
}
|
|
|
|
func Quote(value string) string { return strconv.Quote(Truncate(value)) }
|
|
|
|
func Aggregate(prefix string, issues []string) string {
|
|
displayed := make([]string, 0, min(len(issues), MaxIssues))
|
|
for len(displayed) < len(issues) && len(displayed) < MaxIssues {
|
|
issue := Truncate(issues[len(displayed)])
|
|
candidate := aggregateMessage(prefix, append(displayed, issue), len(issues)-len(displayed)-1)
|
|
if len([]byte(candidate)) > MaxMessageBytes {
|
|
break
|
|
}
|
|
displayed = append(displayed, issue)
|
|
}
|
|
return aggregateMessage(prefix, displayed, len(issues)-len(displayed))
|
|
}
|
|
|
|
func aggregateMessage(prefix string, issues []string, omitted int) string {
|
|
message := prefix + ": " + strings.Join(issues, ", ")
|
|
if omitted > 0 {
|
|
message += fmt.Sprintf("; %d additional issue(s) omitted", omitted)
|
|
}
|
|
return message
|
|
}
|
|
|
|
func min(left, right int) int {
|
|
if left < right {
|
|
return left
|
|
}
|
|
return right
|
|
}
|