From 0d6fa880b8b67184c11b6d927a4bf36b4662e60f Mon Sep 17 00:00:00 2001 From: Autumn <53844290+Autumn-27@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:40:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(intercept):=20=E6=A8=A1=E5=9E=8B=E8=A3=81?= =?UTF-8?q?=E5=86=B3=E8=A2=AB=E4=BB=A3=E7=A0=81=E5=9D=97=E5=8C=85=E8=A3=B9?= =?UTF-8?q?=E6=97=B6=E4=B8=8D=E5=86=8D=E9=9D=99=E9=BB=98=E6=94=BE=E8=A1=8C?= =?UTF-8?q?=20(#126)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ParseVerdict 要求裸 JSON,而模型把 JSON 套进 markdown 围栏是常见偏差。 围栏导致解析失败后,reviewer 返回错误、Judge 走 FailAction,其默认值是 allow —— 模型判定的 DENY 会被静默降级为放行,审批记录只显示“模型输出 无法解析”,看不到模型其实给了拒绝。 解析入口前置一次围栏解包。这是确定性解包而非修复:载荷仍交由原 ParseVerdict 严格校验,重复字段、多余字段、多个对象、尾部残留的判定 全部不变。 要求开围栏与闭围栏同时存在。回复在 MaxTokens 处被截断时的形态正是 “有开围栏、无闭围栏”,对其补全会造出模型从未给出的裁决,因此这种输入 继续视为无法解析。 测试:原先断言“围栏必须拒绝”的用例改为接受,另补未闭合围栏、围栏后 带尾随文本、散文夹 JSON 三条必拒用例。 --- intercept/prompt.go | 25 ++++++++++++++++++++++++- intercept/prompt_test.go | 25 ++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/intercept/prompt.go b/intercept/prompt.go index 342597a..e7390ef 100644 --- a/intercept/prompt.go +++ b/intercept/prompt.go @@ -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{} } diff --git a/intercept/prompt_test.go b/intercept/prompt_test.go index 28cf0a2..562a56e 100644 --- a/intercept/prompt_test.go +++ b/intercept/prompt_test.go @@ -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})