fix(intercept): 模型裁决被代码块包裹时不再静默放行 (#126)

ParseVerdict 要求裸 JSON,而模型把 JSON 套进 markdown 围栏是常见偏差。
围栏导致解析失败后,reviewer 返回错误、Judge 走 FailAction,其默认值是
allow —— 模型判定的 DENY 会被静默降级为放行,审批记录只显示“模型输出
无法解析”,看不到模型其实给了拒绝。

解析入口前置一次围栏解包。这是确定性解包而非修复:载荷仍交由原
ParseVerdict 严格校验,重复字段、多余字段、多个对象、尾部残留的判定
全部不变。

要求开围栏与闭围栏同时存在。回复在 MaxTokens 处被截断时的形态正是
“有开围栏、无闭围栏”,对其补全会造出模型从未给出的裁决,因此这种输入
继续视为无法解析。

测试:原先断言“围栏必须拒绝”的用例改为接受,另补未闭合围栏、围栏后
带尾随文本、散文夹 JSON 三条必拒用例。
This commit is contained in:
Autumn authored and GitHub committed 2026-09-15 20:40:41 +08:00
1 parent c0d1aa6f37
commit 0d6fa880b8
2 files changed
+48 -2

No files matched your search

+24 -1
View File
@@ -136,11 +136,34 @@ type Verdict struct {
Reason string
}
// stripCodeFence unwraps a fenced reply (```json … ```) before strict parsing.
// This is a deterministic unwrap, not a repair: the payload still goes through
// ParseVerdict unchanged, so truncated, ambiguous or prose replies stay
// unparseable. A reply cut off at MaxTokens has no closing fence and is left
// alone on purpose — completing it would invent a verdict the model never gave.
//
// It exists because the fail action defaults to allow: without it a model that
// merely wraps its JSON in markdown turns a DENY into a silent allow.
func stripCodeFence(text string) string {
t := strings.TrimSpace(text)
if len(t) <= 6 || !strings.HasPrefix(t, "```") || !strings.HasSuffix(t, "```") {
return t
}
t = strings.TrimSpace(t[3 : len(t)-3])
if !strings.HasPrefix(t, "{") {
// Drop the opening fence's language tag line (```json).
if _, rest, ok := strings.Cut(t, "\n"); ok {
t = strings.TrimSpace(rest)
}
}
return t
}
// ParseVerdict requires a complete verdict and explanation for every action.
// Never extract a decision keyword from prose, arguments, or a broken JSON
// reply. Invalid/incomplete responses follow the configured model-failure path.
func ParseVerdict(text string) Verdict {
d := json.NewDecoder(strings.NewReader(text))
d := json.NewDecoder(strings.NewReader(stripCodeFence(text)))
if tok, err := d.Token(); err != nil || tok != json.Delim('{') {
return Verdict{}
}
+24 -1
View File
@@ -31,7 +31,12 @@ func TestParseVerdictRejectsIncompleteOrAmbiguousReplies(t *testing.T) {
strings.Replace(valid, ";命中规则:A5", "", 1),
strings.Replace(valid, `"decision":"allow"`, `"decision":"deny","decision":"allow"`, 1),
strings.Replace(valid, `"decision":"allow"`, `"extra":true,"decision":"allow"`, 1),
valid + valid, "```json\n" + valid + "\n```", valid[:len(valid)-1],
valid + valid, valid[:len(valid)-1],
// A fence the model never closed is what a reply truncated at MaxTokens
// looks like; completing it would invent a verdict.
"```json\n" + valid[:len(valid)-1],
"```json\n" + valid + "\n```\n此外我建议后续人工复核。",
"我的裁决是:\n" + valid,
} {
if got := ParseVerdict(reply); got.Action != "" {
t.Errorf("accepted incomplete/ambiguous verdict: %q => %+v", reply, got)
@@ -39,6 +44,24 @@ func TestParseVerdictRejectsIncompleteOrAmbiguousReplies(t *testing.T) {
}
}
// Wrapping JSON in markdown is the one deviation models make routinely. Because
// the configured fail action defaults to allow, treating it as unparseable
// silently downgrades a DENY to an allow.
func TestParseVerdictUnwrapsCodeFence(t *testing.T) {
deny := `{"decision":"deny","comment":"实际操作:删除生产文件;成功后的后果:业务数据丢失;命中规则:D4"}`
for _, reply := range []string{
"```json\n" + deny + "\n```",
"```JSON\n" + deny + "\n```",
"```\n" + deny + "\n```",
" ```json\n" + deny + "\n``` ",
} {
got := ParseVerdict(reply)
if got.Action != "deny" || !strings.HasSuffix(got.Reason, "命中规则:D4") {
t.Errorf("fenced verdict lost: %q => %+v", reply, got)
}
}
}
func TestParseVerdictKeepsCompleteChineseExplanation(t *testing.T) {
reason := "实际操作:" + strings.Repeat("写入报告", 30) + ";成功后的后果:只保存文件;命中规则:A2"
raw, _ := json.Marshal(map[string]string{"decision": "allow", "comment": reason})