Repository navigation
tech-debt(captcha): two captcha_solver modules whose CaptchaType enums disagree — a kind one can classify is UNKNOWN to the other #18131
Description
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"andTEXT_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_SELECTandCLOUDFLAREexist only inservices. A Cloudflare challenge detected by the browser pipeline has no representation; it becomesUNKNOWN.SLIDERexists only inagents/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.valueagents/browser/captcha_solver.py:746—captcha_type=captcha_type.valueagents/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
- Decide which module owns CAPTCHA solving. On importers,
agents/browserleads 7 to 2 — but 5 of its 7 are its own test file, so measure real consumers before concluding. - One
CaptchaTypecarrying the union, with the duplicate concept resolved to one spelling and a documented mapping for any value already recorded. - One result type.
CaptchaSolveResultandSolveResultare the same concept under two names. - The losing module becomes a facade, not a deletion — both are reachable and
services/captcha_human_loopdepends on one of them. - 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 toUNKNOWN.
Acceptance criteria
- one
CaptchaType, one result type; the other module imports them - the union is recorded here member by member, with the
TEXT/TEXT_IMAGEdecision stated and any already-emitted value mapped - no CAPTCHA kind one module can classify is unrepresentable in the other
-
_NON_ALNUM_REis either used or gone, and not duplicated - a member-set guard fails if a second CAPTCHA-type enum appears, the way canonical(enums): 10 *Severity(Enum) forks of the shared Severity survive #13597, and no guard blocks the next one #18098's does for
Severity
Related: #17312 (forks umbrella), #18098 (the member-set guard pattern and the wire-shape precedent).
Activity
mrveiss commentedon Oct 12, 2026
Decision record (PR #18418)
One
CaptchaType, inautobot_shared/captcha_types.py, the union of both former enums:textmathimage_selectsliderrecaptchahcaptchacloudflareunknownTEXT decision:
TEXT = "text"is the single spelling of distorted-text-as-image.TEXT_IMAGEis dropped as a member; the already-recorded wire value"text_image"resolves toTEXTthroughCaptchaType._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_TYPESwith RECAPTCHA, HCAPTCHA, CLOUDFLARE and the newly representable SLIDER) and returnsrequires_human=True.One result type:
CaptchaSolveResult(same module). Pipeline fields map asanswertosolution,errortoerror_message, floatconfidencetoconfidence_score,method_usedkept; the OCR solver'sconfidenceband andprocessing_time_msare unchanged.Guard:
repo_tests/captcha_type_fork_guard_18131_test.pyfails on a second CAPTCHA-type enum (by member set) or a second CAPTCHA result class (by field set).refactor(captcha): one CaptchaSolveResult for the OCR solver and the …
mrveiss commentedon Oct 12, 2026
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.
refactor(captcha): one human-only set drives the solver and the human…
refactor(captcha): the empty-tree guard test uses a real empty git re…
feat(captcha): human-only CAPTCHA kinds get translated labels in all …
refactor(captcha): both solvers evaluate MATH captchas through the on…