// 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 }