fix(captcha): strip reasoning-model <think> blocks before json extraction

Reasoning models prefix responses with <think>...</think> (or
<thinking>...</thinking>) scratchpad that can itself contain a JSON-looking
object. The brace-counting extractor would grab that inner object instead of
the real answer. Strip think/thinking blocks via a package-level regexp before
the balanced-object scan. Adds table cases covering both tag spellings while
keeping the plain, prose-wrapped, and unbalanced cases intact.
This commit is contained in:
2026-06-07 14:34:35 +01:00
parent f0e1264edf
commit 4ffe555bfd
2 changed files with 22 additions and 1 deletions
+12 -1
View File
@@ -1,13 +1,24 @@
package captcha package captcha
import "strings" import (
"regexp"
"strings"
)
// thinkRe matches reasoning-model scratchpad blocks (<think>…</think> or
// <thinking>…</thinking>). These can themselves contain JSON-looking objects,
// so they are stripped before the brace scan to avoid grabbing the wrong object.
var thinkRe = regexp.MustCompile(`(?s)<think(?:ing)?>.*?</think(?:ing)?>`)
// extractJSON returns the first balanced {...} object found in s, or s unchanged // extractJSON returns the first balanced {...} object found in s, or s unchanged
// if no opening brace is present. It scans by brace depth so prose, markdown // if no opening brace is present. It scans by brace depth so prose, markdown
// fences, or an example object before the real one do not corrupt parsing. // fences, or an example object before the real one do not corrupt parsing.
// Reasoning-model <think>…</think> blocks are stripped first so a JSON-looking
// object inside the scratchpad is not mistaken for the answer.
// Note: it does not account for braces inside string literals; that is // Note: it does not account for braces inside string literals; that is
// acceptable for the short, structured captcha responses this handles. // acceptable for the short, structured captcha responses this handles.
func extractJSON(s string) string { func extractJSON(s string) string {
s = thinkRe.ReplaceAllString(s, "")
start := strings.IndexByte(s, '{') start := strings.IndexByte(s, '{')
if start < 0 { if start < 0 {
return s return s
+10
View File
@@ -27,6 +27,16 @@ func TestExtractJSON(t *testing.T) {
}, },
{"no brace", `no json here`, `no json here`}, {"no brace", `no json here`, `no json here`},
{"unbalanced returns from start", `tail {"text":"x"`, `{"text":"x"`}, {"unbalanced returns from start", `tail {"text":"x"`, `{"text":"x"`},
{
"think block stripped, real object wins",
"<think>reasoning with {\"tiles\":[9]}</think>\n{\"tiles\":[0,1,2],\"confidence\":0.9}",
`{"tiles":[0,1,2],"confidence":0.9}`,
},
{
"thinking block stripped",
`<thinking>let me reason {"tiles":[5]}</thinking>{"text":"ab12"}`,
`{"text":"ab12"}`,
},
} }
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {