mirror of
https://github.com/lukaszraczylo/mcp-captcharesolver.git
synced 2026-10-12 00:45:28 +00:00
feat(captcha): LLM text-image OCR solver with strict-retry
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
package captcha
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// extractJSON returns the first {...} JSON object found in s, or s unchanged.
|
||||||
|
func extractJSON(s string) string {
|
||||||
|
start := strings.Index(s, "{")
|
||||||
|
end := strings.LastIndex(s, "}")
|
||||||
|
if start >= 0 && end > start {
|
||||||
|
return s[start : end+1]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package captcha
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/lukaszraczylo/captcha-solver-mcp/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TextOptions tunes text-image OCR.
|
||||||
|
type TextOptions struct {
|
||||||
|
Hint string
|
||||||
|
Charset string
|
||||||
|
Length int
|
||||||
|
CaseSensitive bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextResult is the OCR answer.
|
||||||
|
type TextResult struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Confidence float64 `json:"confidence"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func textPrompt(o TextOptions, strict bool) string {
|
||||||
|
p := "You are solving an image text captcha. Read the exact characters shown. "
|
||||||
|
if o.Charset != "" {
|
||||||
|
p += "Allowed characters: " + o.Charset + ". "
|
||||||
|
}
|
||||||
|
if o.Length > 0 {
|
||||||
|
p += fmt.Sprintf("Expected length: %d. ", o.Length)
|
||||||
|
}
|
||||||
|
if !o.CaseSensitive {
|
||||||
|
p += "Case-insensitive. "
|
||||||
|
}
|
||||||
|
if o.Hint != "" {
|
||||||
|
p += "Hint: " + o.Hint + ". "
|
||||||
|
}
|
||||||
|
p += `Respond ONLY with compact JSON: {"text":"<chars>","confidence":<0..1>}.`
|
||||||
|
if strict {
|
||||||
|
p += " You MUST output valid JSON and nothing else. Do not explain."
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// SolveText runs OCR via the vision model with one stricter retry on parse failure.
|
||||||
|
func SolveText(ctx context.Context, c llm.Client, img []byte, mediaType string, o TextOptions) (TextResult, error) {
|
||||||
|
images := []llm.Image{{MediaType: mediaType, Data: img}}
|
||||||
|
for attempt := 0; attempt < 2; attempt++ {
|
||||||
|
raw, err := c.Vision(ctx, textPrompt(o, attempt > 0), images, llm.Options{})
|
||||||
|
if err != nil {
|
||||||
|
return TextResult{}, err
|
||||||
|
}
|
||||||
|
var res TextResult
|
||||||
|
if err := json.Unmarshal([]byte(extractJSON(raw)), &res); err == nil && res.Text != "" {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return TextResult{}, fmt.Errorf("model did not return parseable captcha text")
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package captcha
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/lukaszraczylo/captcha-solver-mcp/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSolveText(t *testing.T) {
|
||||||
|
f := &llm.Fake{VisionResp: `{"text":"ab12","confidence":0.9}`}
|
||||||
|
res, err := SolveText(context.Background(), f, []byte{1}, "image/png", TextOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Text != "ab12" || res.Confidence < 0.89 {
|
||||||
|
t.Fatalf("got %+v", res)
|
||||||
|
}
|
||||||
|
if len(f.VisionCalls) != 1 {
|
||||||
|
t.Fatalf("expected 1 vision call")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSolveTextRetriesOnGarbage(t *testing.T) {
|
||||||
|
f := &llm.Fake{VisionResp: "I cannot read this"}
|
||||||
|
_, err := SolveText(context.Background(), f, []byte{1}, "image/png", TextOptions{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("want error on unparseable output")
|
||||||
|
}
|
||||||
|
if len(f.VisionCalls) != 2 {
|
||||||
|
t.Fatalf("expected 2 attempts (one retry), got %d", len(f.VisionCalls))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user