package opportunities import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "strings" "unicode" ) // Model normalization, directive Section 5.2. The model proposes scope items; this file decides which of // them are allowed to exist. It is a PURE function of the model payload and the source text, with no // database and no I/O, so the grounding rules can be exhaustively tested without a model or a tenant. // // The rule that matters most is the third one below. A model that omits a citation is merely unhelpful; a // model that INVENTS a citation is dangerous, because fabricated support reads exactly like real support to // every reader downstream. So a quote is checked for actual presence in the source text, and an item whose // quote cannot be found is rejected as fabricated rather than accepted with a warning. // scopeTypes is the closed vocabulary from migration 0076. A model that invents a category is refused // rather than being folded into "other", because silently recategorizing model output loses the fact that // the model did not understand the ask. var scopeTypes = map[string]bool{ "objective": true, "task": true, "deliverable": true, "labor_category": true, "staffing_quantity": true, "skill": true, "certification": true, "clearance": true, "customer_experience": true, "location": true, "travel": true, "schedule": true, "security_control": true, "reporting": true, "transition": true, "key_personnel": true, "pricing_instruction": true, "evaluation_factor": true, "compliance_clause": true, "assumption": true, "dependency": true, "risk": true, "other": true, } const ( maxScopeTitleLen = 300 maxScopeDescLen = 4000 minQuoteLen = 12 // a quote shorter than this cannot ground anything; it matches by accident ) // ScopeCandidate is one item as the MODEL proposed it, before any validation. type ScopeCandidate struct { ScopeType string `json:"scope_type"` Title string `json:"title"` Description string `json:"description"` Quote string `json:"quote"` SectionLabel string `json:"section_label"` Confidence float64 `json:"confidence"` } // ValidatedScope is a candidate that survived every grounding rule, carrying the hash of the exact quote // that grounds it. That hash is what the scope item stores as its citation. type ValidatedScope struct { ScopeType string Title string Description string Quote string QuoteHash string SectionLabel string Confidence float64 } // RejectedScope records what was refused and why, so a normalization run can report its own precision // instead of silently dropping most of what the model said. type RejectedScope struct { Title string Reason string } // ValidateScopeCandidates parses a model response and returns only the items that are grounded in the // source text. It fails closed: an unparseable payload is an error and yields nothing, and every individual // rule rejects the item rather than repairing it. func ValidateScopeCandidates(raw []byte, sourceText string) ([]ValidatedScope, []RejectedScope, error) { var payload struct { Items []ScopeCandidate `json:"items"` } trimmed := extractJSONObject(raw) if trimmed == nil { return nil, nil, fmt.Errorf("%w: model response is not a JSON object", ErrInvalidInput) } if err := json.Unmarshal(trimmed, &payload); err != nil { return nil, nil, fmt.Errorf("%w: model response did not match the declared schema: %v", ErrInvalidInput, err) } // Normalizing whitespace on both sides makes containment robust to the line wrapping a model reproduces // differently from the source, without loosening the check into a fuzzy match. haystack := normalizeWS(sourceText) accepted := make([]ValidatedScope, 0, len(payload.Items)) rejected := make([]RejectedScope, 0) for _, c := range payload.Items { title := strings.TrimSpace(c.Title) quote := strings.TrimSpace(c.Quote) switch { case !scopeTypes[c.ScopeType]: rejected = append(rejected, RejectedScope{title, fmt.Sprintf("scope_type %q is not in the closed vocabulary", clipStr(c.ScopeType, 40))}) case title == "": rejected = append(rejected, RejectedScope{title, "title is empty"}) case len(title) > maxScopeTitleLen: rejected = append(rejected, RejectedScope{title, "title exceeds the length cap"}) case len(c.Description) > maxScopeDescLen: rejected = append(rejected, RejectedScope{title, "description exceeds the length cap"}) case len(quote) < minQuoteLen: rejected = append(rejected, RejectedScope{title, "uncited, the item carries no usable source quote"}) case haystack == "" || !strings.Contains(haystack, normalizeWS(quote)): // A fabricated citation. This is the load bearing check. rejected = append(rejected, RejectedScope{title, "the cited quote was not found in the source text, treating it as a fabricated citation"}) case c.Confidence < 0 || c.Confidence > 1: rejected = append(rejected, RejectedScope{title, "confidence is outside 0 to 1"}) default: sum := sha256.Sum256([]byte(normalizeWS(quote))) accepted = append(accepted, ValidatedScope{ ScopeType: c.ScopeType, Title: title, Description: strings.TrimSpace(c.Description), Quote: quote, QuoteHash: hex.EncodeToString(sum[:]), SectionLabel: clipStr(strings.TrimSpace(c.SectionLabel), 120), Confidence: c.Confidence, }) } } return accepted, rejected, nil } // extractJSONObject finds the outermost JSON object in a model response. Instruct models routinely wrap // JSON in prose or a fenced code block, and refusing those outright would reject correct output for a // formatting habit; anything that is not a locatable object still fails closed. func extractJSONObject(raw []byte) []byte { s := strings.TrimSpace(string(raw)) start := strings.Index(s, "{") end := strings.LastIndex(s, "}") if start < 0 || end <= start { return nil } return []byte(s[start : end+1]) } // normalizeWS collapses every run of whitespace to one space and lowercases, so a quote matches the source // despite reflowed lines or a changed case, while still requiring the actual words in the actual order. func normalizeWS(s string) string { var b strings.Builder b.Grow(len(s)) space := false for _, r := range strings.ToLower(s) { if unicode.IsSpace(r) { space = true continue } if space && b.Len() > 0 { b.WriteByte(' ') } space = false b.WriteRune(r) } return b.String() } func clipStr(s string, n int) string { if len(s) > n { return s[:n] } return s }