diff --git a/internal/captcha/json.go b/internal/captcha/json.go new file mode 100644 index 0000000..47c48e9 --- /dev/null +++ b/internal/captcha/json.go @@ -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 +} diff --git a/internal/captcha/text.go b/internal/captcha/text.go new file mode 100644 index 0000000..de70d0c --- /dev/null +++ b/internal/captcha/text.go @@ -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":"","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") +} diff --git a/internal/captcha/text_test.go b/internal/captcha/text_test.go new file mode 100644 index 0000000..ad68658 --- /dev/null +++ b/internal/captcha/text_test.go @@ -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)) + } +}