315 lines
9.9 KiB
Go
315 lines
9.9 KiB
Go
package analysis
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// Rejection reasons, used as metric labels and audit detail. They are a closed
|
|
// set so that cardinality stays bounded.
|
|
const (
|
|
ReasonNotJSON = "not_json"
|
|
ReasonUnknownField = "unknown_field"
|
|
ReasonTrailingData = "trailing_data"
|
|
ReasonTooLarge = "too_large"
|
|
ReasonBadEnum = "bad_enum"
|
|
ReasonUnknownRef = "unknown_reference"
|
|
ReasonMissingField = "missing_field"
|
|
ReasonOutOfRange = "out_of_range"
|
|
ReasonTooManyItems = "too_many_items"
|
|
ReasonEmpty = "empty_response"
|
|
ReasonNotGrounded = "not_grounded"
|
|
)
|
|
|
|
// InvalidOutputError describes model output that failed validation.
|
|
//
|
|
// It is deliberately specific: a permanently invalid response must not be
|
|
// retried, and knowing why lets the caller decide that without guessing.
|
|
type InvalidOutputError struct {
|
|
Task string
|
|
Reason string
|
|
Detail string
|
|
// Sample is a short, truncated excerpt of the offending output, kept for
|
|
// diagnostics. It is bounded because model output is untrusted.
|
|
Sample string
|
|
}
|
|
|
|
func (e *InvalidOutputError) Error() string {
|
|
return fmt.Sprintf("model output rejected for task %s (%s): %s", e.Task, e.Reason, e.Detail)
|
|
}
|
|
|
|
// Is lets callers match with errors.Is(err, domain.ErrModelInvalid) via Unwrap.
|
|
func (e *InvalidOutputError) Unwrap() error { return errInvalidOutput }
|
|
|
|
var errInvalidOutput = errors.New("invalid model output")
|
|
|
|
// ErrInvalidOutput is the sentinel all validation failures unwrap to.
|
|
var ErrInvalidOutput = errInvalidOutput
|
|
|
|
// Reject builds an InvalidOutputError with a bounded sample.
|
|
func Reject(task, reason, detail, raw string) *InvalidOutputError {
|
|
return &InvalidOutputError{
|
|
Task: task,
|
|
Reason: reason,
|
|
Detail: detail,
|
|
Sample: sample(raw, 300),
|
|
}
|
|
}
|
|
|
|
// maxModelOutputBytes bounds any single structured response before decoding.
|
|
const maxModelOutputBytes = 256 << 10
|
|
|
|
// DecodeStrict parses raw model output into dst.
|
|
//
|
|
// It is strict on purpose:
|
|
// - unknown fields are an error, so a model that invents structure is caught
|
|
// rather than silently ignored;
|
|
// - trailing content after the JSON value is an error, so a model that
|
|
// appends commentary or a second object cannot smuggle it past;
|
|
// - the payload is size-bounded before parsing.
|
|
//
|
|
// No regular expressions are used to find or repair JSON. If the response is
|
|
// not a single well-formed JSON value of the expected shape, it is rejected.
|
|
func DecodeStrict(task string, raw string, dst any) error {
|
|
trimmed := strings.TrimSpace(raw)
|
|
if trimmed == "" {
|
|
return Reject(task, ReasonEmpty, "the model returned an empty response", raw)
|
|
}
|
|
if len(trimmed) > maxModelOutputBytes {
|
|
return Reject(task, ReasonTooLarge,
|
|
fmt.Sprintf("response of %d bytes exceeds the %d byte limit", len(trimmed), maxModelOutputBytes), raw)
|
|
}
|
|
if !utf8.ValidString(trimmed) {
|
|
return Reject(task, ReasonNotJSON, "response is not valid UTF-8", raw)
|
|
}
|
|
|
|
// Some models wrap JSON in a markdown fence despite being asked not to.
|
|
// Unwrapping a complete, well-formed fence is a structural operation on a
|
|
// delimiter, not a regex-based repair of malformed JSON: if what remains is
|
|
// not valid JSON it is still rejected below.
|
|
trimmed = unwrapFence(trimmed)
|
|
|
|
dec := json.NewDecoder(strings.NewReader(trimmed))
|
|
dec.DisallowUnknownFields()
|
|
dec.UseNumber()
|
|
|
|
if err := dec.Decode(dst); err != nil {
|
|
reason := ReasonNotJSON
|
|
if strings.Contains(err.Error(), "unknown field") {
|
|
reason = ReasonUnknownField
|
|
}
|
|
return Reject(task, reason, err.Error(), raw)
|
|
}
|
|
if _, err := dec.Token(); err != io.EOF {
|
|
return Reject(task, ReasonTrailingData,
|
|
"the response contains additional content after the JSON value", raw)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// unwrapFence removes a single complete ```/```json fence around the payload.
|
|
func unwrapFence(s string) string {
|
|
if !strings.HasPrefix(s, "```") {
|
|
return s
|
|
}
|
|
// Drop the opening fence line.
|
|
nl := strings.IndexByte(s, '\n')
|
|
if nl < 0 {
|
|
return s
|
|
}
|
|
rest := s[nl+1:]
|
|
end := strings.LastIndex(rest, "```")
|
|
if end < 0 {
|
|
return s
|
|
}
|
|
return strings.TrimSpace(rest[:end])
|
|
}
|
|
|
|
// sample truncates untrusted text to n bytes on a rune boundary, for logging.
|
|
func sample(s string, n int) string {
|
|
s = strings.TrimSpace(s)
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
for n > 0 && !utf8.RuneStart(s[n]) {
|
|
n--
|
|
}
|
|
return s[:n] + "…"
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Reference and value checking
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// RefSet is the set of identifiers a model was actually shown. Any ID in the
|
|
// response that is not in this set is a hallucination and invalidates the
|
|
// record that references it.
|
|
type RefSet map[string]struct{}
|
|
|
|
// NewRefSet builds a reference set from the IDs supplied to the model.
|
|
func NewRefSet(ids ...string) RefSet {
|
|
s := make(RefSet, len(ids))
|
|
for _, id := range ids {
|
|
if id != "" {
|
|
s[id] = struct{}{}
|
|
}
|
|
}
|
|
return s
|
|
}
|
|
|
|
// Add registers an additional valid identifier.
|
|
func (s RefSet) Add(id string) {
|
|
if id != "" {
|
|
s[id] = struct{}{}
|
|
}
|
|
}
|
|
|
|
// Has reports whether id was shown to the model.
|
|
func (s RefSet) Has(id string) bool {
|
|
_, ok := s[id]
|
|
return ok
|
|
}
|
|
|
|
// CheckEnum validates that value is one of allowed, returning a rejection
|
|
// otherwise. Enum checking is what stops a model inventing a relationship type.
|
|
func CheckEnum[T ~string](task, field string, value T, allowed []T, raw string) error {
|
|
for _, a := range allowed {
|
|
if a == value {
|
|
return nil
|
|
}
|
|
}
|
|
names := make([]string, len(allowed))
|
|
for i, a := range allowed {
|
|
names[i] = string(a)
|
|
}
|
|
return Reject(task, ReasonBadEnum,
|
|
fmt.Sprintf("%s=%q is not one of: %s", field, string(value), strings.Join(names, ", ")), raw)
|
|
}
|
|
|
|
// CheckRange validates a numeric field.
|
|
func CheckRange(task, field string, v, lo, hi float64, raw string) error {
|
|
if v < lo || v > hi {
|
|
return Reject(task, ReasonOutOfRange,
|
|
fmt.Sprintf("%s=%v is outside the range %v..%v", field, v, lo, hi), raw)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CheckCount enforces an upper bound on array length before the items are used.
|
|
func CheckCount(task, field string, n, maxN int, raw string) error {
|
|
if n > maxN {
|
|
return Reject(task, ReasonTooManyItems,
|
|
fmt.Sprintf("%s contains %d entries, more than the %d permitted", field, n, maxN), raw)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CleanText normalises a model-supplied string for storage: control characters
|
|
// removed, whitespace collapsed, length bounded.
|
|
//
|
|
// Model output is rendered as escaped HTML everywhere in Relata, so this is not
|
|
// the XSS defence; it exists so that stored text stays displayable and bounded.
|
|
func CleanText(s string, maxLen int) string {
|
|
s = strings.Map(func(r rune) rune {
|
|
switch {
|
|
case r == '\n' || r == '\t':
|
|
return ' '
|
|
case r < 0x20 || r == 0x7f:
|
|
return -1
|
|
}
|
|
return r
|
|
}, s)
|
|
s = strings.Join(strings.Fields(s), " ")
|
|
if maxLen > 0 && len(s) > maxLen {
|
|
s = sample(s, maxLen)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// CompactJSON re-encodes v without whitespace, used to embed schemas in
|
|
// requests.
|
|
func CompactJSON(v any) ([]byte, error) {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out bytes.Buffer
|
|
if err := json.Compact(&out, b); err != nil {
|
|
return nil, err
|
|
}
|
|
return out.Bytes(), nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Degenerate output
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ReasonDegenerate marks output that is structurally valid but carries no
|
|
// content — the model filled the schema without answering.
|
|
const ReasonDegenerate = "degenerate_content"
|
|
|
|
// schemaEchoes are tokens a struggling model emits in place of real content,
|
|
// usually by echoing a field name or a boolean from the schema it was given.
|
|
var schemaEchoes = map[string]bool{
|
|
"answer": true, "sufficient": true, "insufficient": true, "statement": true,
|
|
"citations": true, "conflicts": true, "rationale": true, "name": true,
|
|
"type": true, "quote": true, "subject": true, "value": true, "text": true,
|
|
"true": true, "false": true, "null": true, "nil": true, "none": true,
|
|
"n/a": true, "na": true, "unknown": true, "undefined": true, "string": true,
|
|
"todo": true, "example": true, "...": true, "-": true,
|
|
}
|
|
|
|
// Degenerate reports whether s is too empty to be usable content.
|
|
//
|
|
// A schema constrains a response's *shape*, not its substance: a model can
|
|
// satisfy every structural rule and still return the word "sufficient" where an
|
|
// answer belongs. That is invalid output, and treating it as an answer would
|
|
// present the model's failure to the user as knowledge.
|
|
func Degenerate(s string, minLength int) bool {
|
|
t := strings.ToLower(strings.TrimSpace(s))
|
|
t = strings.Trim(t, ".,;:!?\"'`*_ ")
|
|
|
|
if t == "" {
|
|
return true
|
|
}
|
|
if schemaEchoes[t] {
|
|
return true
|
|
}
|
|
// The length floor applies only to output carrying no digits.
|
|
//
|
|
// "3" is a complete answer to "how many depots?", while "x" is a
|
|
// placeholder — the difference is that a number is substance and a lone
|
|
// letter is not. Without this, the floor that catches "x" also throws away
|
|
// correct numeric answers.
|
|
if !strings.ContainsFunc(t, unicode.IsDigit) && utf8.RuneCountInString(t) < minLength {
|
|
return true
|
|
}
|
|
|
|
// Nothing but punctuation — "...", "-", "??" and the like.
|
|
//
|
|
// Digits count as content. An earlier version of this required a letter,
|
|
// which rejected "£42,000" as an answer to "What is the total project
|
|
// cost?" — a correct answer, and the best possible one. Terse factual
|
|
// answers are exactly what a factual question should produce, so the only
|
|
// thing worth rejecting here is output with no substance at all.
|
|
for _, r := range t {
|
|
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// RejectDegenerate builds the rejection for empty-but-well-formed output.
|
|
func RejectDegenerate(task, field, got string) *InvalidOutputError {
|
|
return Reject(task, ReasonDegenerate,
|
|
fmt.Sprintf("%s contains no usable content — the model satisfied the schema without answering", field),
|
|
got)
|
|
}
|