Skip to content

tech-debt(captcha): two captcha_solver modules whose CaptchaType enums disagree — a kind one can classify is UNKNOWN to the other #18131

Open

Description

@mrveiss

Two modules named captcha_solver.py each implement CAPTCHA solving, and their shared CaptchaType enum disagrees about what kinds of CAPTCHA exist.

path lines top-level symbols importers
autobot-backend/services/captcha_solver.py 593 CaptchaType, SolverConfidence, CaptchaSolveResult, CaptchaSolver 2
autobot-backend/agents/browser/captcha_solver.py 765 CaptchaType, SolveResult, CaptchaDetector, LocalCaptchaSolver, _image_hash, CaptchaSolverPipeline 7

They differ by 1038 lines, so neither is a copy of the other — two independent implementations.

The enum fork is the load-bearing part

services/captcha_solver.py:61          agents/browser/captcha_solver.py:91
  TEXT = "text"                          TEXT_IMAGE = "text_image"
  MATH = "math"                           MATH = "math"
  IMAGE_SELECT = "image_select"           SLIDER = "slider"
  RECAPTCHA = "recaptcha"                 RECAPTCHA = "recaptcha"
  HCAPTCHA = "hcaptcha"                   HCAPTCHA = "hcaptcha"
  CLOUDFLARE = "cloudflare"               UNKNOWN = "unknown"
  UNKNOWN = "unknown"

Three members are shared. Beyond those:

  • The same concept has two spellings. TEXT = "text" and TEXT_IMAGE = "text_image" are both "distorted text rendered as an image" per their own comments — two different values on the wire for one thing.
  • IMAGE_SELECT and CLOUDFLARE exist only in services. A Cloudflare challenge detected by the browser pipeline has no representation; it becomes UNKNOWN.
  • SLIDER exists only in agents/browser. A drag-slider challenge is unrepresentable in the services enum.

So a CAPTCHA type one module can classify is not expressible in the other, and the loss is silent — it lands as UNKNOWN.

These are wire values, not internal labels

Both serialize .value:

  • services/captcha_human_loop.py:592 — "type": result.captcha_type.value
  • agents/browser/captcha_solver.py:746 — captcha_type=captcha_type.value
  • agents/browser/captcha_solver.py:738, services/captcha_solver.py:178 — into log and error text

So text and text_image are both already in recorded data for the same kind of challenge. Unifying the enum is therefore a wire-format decision, not a rename — the same class of decision as the five held-back severity subsets on #18098, and Severity.score_ladder() is the precedent for narrowing an emitted set without breaking a consumer.

Already known to the ratchet, which is the point

repo_tests/symbol_fork_baseline.json already carries both CaptchaType and CaptchaSolver among its 330 clusters. The name-based detector sees them. What it cannot see is that the two enums disagree on their members — a name clash is reported, a semantic divergence is not. CaptchaSolveResult vs SolveResult is invisible to it entirely, being the same concept under two names.

Residue worth noting

agents/browser/captcha_solver.py:84 declares _NON_ALNUM_RE = re.compile(r"[^A-Za-z0-9]") and never uses it — measured, one occurrence in the file. Its twin at services/captcha_solver.py:56 is used at :498. Dead leftover of the split, not deleted here because the decision below should cover it.

Proposed

  1. Decide which module owns CAPTCHA solving. On importers, agents/browser leads 7 to 2 — but 5 of its 7 are its own test file, so measure real consumers before concluding.
  2. One CaptchaType carrying the union, with the duplicate concept resolved to one spelling and a documented mapping for any value already recorded.
  3. One result type. CaptchaSolveResult and SolveResult are the same concept under two names.
  4. The losing module becomes a facade, not a deletion — both are reachable and services/captcha_human_loop depends on one of them.
  5. Add the member-set discipline from repo_tests/severity_enum_fork_guard_18098_test.py: a guard that compares enum MEMBERS, not names, so the next divergence fails CI instead of silently mapping to UNKNOWN.

Acceptance criteria

Related: #17312 (forks umbrella), #18098 (the member-set guard pattern and the wire-shape precedent).

Activity

  1. mrveiss commented on Oct 12, 2026

    @mrveiss
    OwnerAuthor

    Decision record (PR #18418)

    One CaptchaType, in autobot_shared/captcha_types.py, the union of both former enums:

    member wire value came from
    TEXT text services (and browser, as TEXT_IMAGE)
    MATH math both
    IMAGE_SELECT image_select services only
    SLIDER slider browser only
    RECAPTCHA recaptcha both
    HCAPTCHA hcaptcha both
    CLOUDFLARE cloudflare services only
    UNKNOWN unknown both

    TEXT decision: TEXT = "text" is the single spelling of distorted-text-as-image. TEXT_IMAGE is dropped as a member; the already-recorded wire value "text_image" resolves to TEXT through CaptchaType._missing_, so old data still parses.

    IMAGE_SELECT behaviour change: it previously fell through to the text OCR default; it is now human-only (_HUMAN_ONLY_TYPES with RECAPTCHA, HCAPTCHA, CLOUDFLARE and the newly representable SLIDER) and returns requires_human=True.

    One result type: CaptchaSolveResult (same module). Pipeline fields map as answer to solution, error to error_message, float confidence to confidence_score, method_used kept; the OCR solver's confidence band and processing_time_ms are unchanged.

    Guard: repo_tests/captcha_type_fork_guard_18131_test.py fails on a second CAPTCHA-type enum (by member set) or a second CAPTCHA result class (by field set).

  2. mrveiss commented on Oct 12, 2026

    @mrveiss
    OwnerAuthor

    Owner acknowledgement (2026-10-12): the behaviour change is accepted. IMAGE_SELECT and SLIDER captchas always go to a human and are never attempted by the text OCR solver.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions