package validator import ( "context" "encoding/json" "fmt" "strings" ) // LLMValidator delegates verdicting to an LLM. The LLM receives a // rendered prompt containing DiffInput fields and must return a JSON // object shaped like Verdict. LLMValidator parses the response (lenient // to markdown fencing and prose preamble) and stamps ValidatorName / // PolicyVersion for audit before returning. // // Prompt template is deliberately minimal and provider-agnostic: user // message contains the diff plus an explicit JSON-only instruction. // Structured-output variants (Anthropic tool-use, OpenAI json_schema) // are left to future subtypes; this reference implementation covers // the broadest provider set with plain JSON. // // Errors: // - ErrValidatorBackend: LLM provider call failed // - ErrVerdictParse: response could not be unmarshalled into Verdict // - ctx.Err(): context cancellation bubbles up // // Every error path returns a Verdict with Severity=Block so a caller // using errors.Is to ignore error class still sees a fail-closed // verdict. // // LLMValidator 把审批决策委托给 LLM. LLM 接收渲染后的 prompt (含 // DiffInput 字段) 返回 JSON 对象 (Verdict 形状). LLMValidator 解析 // 响应 (宽松处理 markdown 围栏和 prose 前后缀), 返回前打入 ValidatorName // / PolicyVersion 审计字段. // // Prompt template 刻意简洁且 provider 无关: user message 含 diff + 显式 // JSON-only 指令. 结构化输出变体 (Anthropic tool-use / OpenAI // json_schema) 留给未来子类; 本参考实现采纯 JSON 覆盖最广 provider. // // 错误: // - ErrValidatorBackend: LLM provider 调用失败 // - ErrVerdictParse: 响应无法 Unmarshal 为 Verdict // - ctx.Err(): context 取消冒泡 // // 所有错误路径返回 Severity=Block Verdict, 调用方即便用 errors.Is // 忽略 error class 也能看到 fail-closed 结果. type LLMValidator struct { name string policyVersion string client *FlytoLLMClient model string maxTokens int } // NewLLMValidator constructs an LLMValidator. name / policyVersion // flow into every Verdict for audit. client is the provider adapter. // model overrides the client's default model for calls from this // Validator (empty string uses the client default). maxTokens caps the // response length (0 leaves at flyto.Request default). // // NewLLMValidator 构造 LLMValidator. name / policyVersion 流进每个 // Verdict 供审计. client 是 provider adapter. model 覆盖 client 默认 // (空串则用 client 默认). maxTokens 限制响应长度 (0 用 flyto.Request // 默认值). func NewLLMValidator(name, policyVersion string, client *FlytoLLMClient, model string, maxTokens int) *LLMValidator { return &LLMValidator{ name: name, policyVersion: policyVersion, client: client, model: model, maxTokens: maxTokens, } } // Name returns the validator identifier. // // Name 返回 validator 标识符. func (v *LLMValidator) Name() string { return v.name } // Validate renders a prompt from the diff, calls the LLM, parses the // response as a Verdict, and stamps ValidatorName / PolicyVersion // before returning. See the type-level godoc for error semantics. // // Validate 从 diff 渲染 prompt, 调 LLM, 解析响应为 Verdict, 返回前打入 // ValidatorName / PolicyVersion. 错误语义见类型级 godoc. func (v *LLMValidator) Validate(ctx context.Context, diff DiffInput) (Verdict, error) { if err := ctx.Err(); err != nil { return Verdict{ Approved: false, Severity: SeverityBlock, Reason: "context cancelled", ValidatorName: v.name, PolicyVersion: v.policyVersion, }, err } prompt := v.renderPrompt(diff) resp, err := v.client.Complete(ctx, prompt, v.model, v.maxTokens) if err != nil { return Verdict{ Approved: false, Severity: SeverityBlock, Reason: fmt.Sprintf("LLM call failed: %v", err), ValidatorName: v.name, PolicyVersion: v.policyVersion, }, err } parsed, err := parseLLMVerdict(resp) if err != nil { return Verdict{ Approved: false, Severity: SeverityBlock, Reason: fmt.Sprintf("LLM response parse failed: %v", err), ValidatorName: v.name, PolicyVersion: v.policyVersion, Details: map[string]any{"raw_response": resp}, }, err } // Stamp audit fields (the LLM may have sent its own; overwrite for // truth -- identity of the Validator is not something the LLM can // legitimately self-report). // // 打入审计字段 (LLM 可能自报, 无条件覆盖 -- Validator 身份不是 LLM // 能合法自报的). parsed.ValidatorName = v.name parsed.PolicyVersion = v.policyVersion if parsed.Severity == "" { parsed.Severity = SeverityWarn } return parsed, nil } // renderPrompt assembles the LLM prompt from the diff. The response // instruction is verbatim so providers without a JSON-mode flag still // comply via plain text. // // renderPrompt 从 diff 组装 LLM prompt. 响应指令是字面约束, 不支持 // JSON mode 的 provider 也能按普通文本输出 JSON. func (v *LLMValidator) renderPrompt(diff DiffInput) string { var sb strings.Builder sb.WriteString("Review the staged diff below and return a JSON verdict.\n\n") sb.WriteString("Source tool: ") sb.WriteString(diff.SourceTool) sb.WriteString("\n") if len(diff.Metadata) > 0 { if meta, err := json.Marshal(diff.Metadata); err == nil { sb.WriteString("Metadata: ") sb.Write(meta) sb.WriteString("\n") } } sb.WriteString("\nDiff:\n```\n") sb.Write(diff.Raw) sb.WriteString("\n```\n\n") sb.WriteString("Return ONLY a JSON object matching this shape (no prose, no fencing):\n") sb.WriteString(`{"approved": , "score": <0.0-1.0>, "severity": "warn"|"block", "reason": "", "details": {}}`) return sb.String() } // parseLLMVerdict extracts the first balanced JSON object from resp // and unmarshals it into a Verdict. LLMs often wrap JSON in prose or // markdown fences; the extractor tolerates both. An unrecognised // Severity value is normalised to "" (empty = Warn per interfaces.go // convention). // // parseLLMVerdict 从 resp 提取首个平衡的 JSON object 并 Unmarshal 为 // Verdict. LLM 常把 JSON 包在 prose 或 markdown 围栏里; 提取器两种都 // 容忍. 未识别的 Severity 归零为 "" (空 = Warn, interfaces.go 约定). func parseLLMVerdict(resp string) (Verdict, error) { jsonBlock := extractJSONObject(strings.TrimSpace(resp)) if jsonBlock == "" { return Verdict{}, fmt.Errorf("%w: no JSON object in response", ErrVerdictParse) } var raw struct { Approved bool `json:"approved"` Score float64 `json:"score"` Reason string `json:"reason"` Severity string `json:"severity"` Details map[string]any `json:"details"` } if err := json.Unmarshal([]byte(jsonBlock), &raw); err != nil { return Verdict{}, fmt.Errorf("%w: %v", ErrVerdictParse, err) } sev := Severity(raw.Severity) if sev != SeverityWarn && sev != SeverityBlock { sev = "" } return Verdict{ Approved: raw.Approved, Score: raw.Score, Reason: raw.Reason, Severity: sev, Details: raw.Details, }, nil } // extractJSONObject returns the substring from the first '{' to the // matching '}' via balanced-brace scan. Braces inside JSON string // literals (including backslash-escaped quotes) do not count. Returns // "" if no balanced object is found. // // extractJSONObject 返回从首个 '{' 到匹配 '}' 的子串 (平衡花括号扫描). // JSON 字符串字面量内的花括号 (含反斜杠转义引号) 不计入. 未找到平衡 // object 返回 "". func extractJSONObject(s string) string { start := strings.IndexByte(s, '{') if start < 0 { return "" } depth := 0 inString := false escape := false for i := start; i < len(s); i++ { c := s[i] if escape { escape = false continue } if inString { switch c { case '\\': escape = true case '"': inString = false } continue } switch c { case '"': inString = true case '{': depth++ case '}': depth-- if depth == 0 { return s[start : i+1] } } } return "" }