diff --git a/CHANGELOG.md b/CHANGELOG.md index 62b0943..33441b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,58 @@ from its first tag onward. ### Added +- **Automated search gains its surfaces: platform choice, a plan preview and + an honest outcome report.** Both wizards now ask which hiring platforms to + target for the session (only measured or vendor-verified platforms are + selectable; the rest show as a count with their reasons), the persistent + default lives on the profile (`ApplicationConfig.search_platforms`), and an + admin can lock the set (`AdminPolicy.search_platforms`; an empty list locks + the feature off). Precedence: admin lock, then the session's choice, then + the profile, then the YAML. A plan preview shows exactly what a session + will search before anything runs — a "Preview plan…" button in the GUI, an + automatic confirm step in the CLI wizard, and `--print-plan` offline from + the shell — computed by the same planner the session uses, so the preview + cannot drift from the run. `--platforms NAMES` targets platforms for one + CLI session (`none` disables targeting). The end-of-session results on both + surfaces now report the engines stopped this session and why, the + off-shape/terminal/routed counts, and any searches skipped over budget, + from one shared formatter. `UIPort` grows to 28 methods with + `preview_search_plan`. +- **Automated search runs safely and says where every job came from.** + `max_queries_per_session` now caps the whole session, not each search + task (a session with more searches than the cap stops at the cap, with a + warning), and engine rotation carries across tasks. One per-host pacer + (`application/services/host_pacing.py`) spaces consecutive contacts to + any one host - engines, and the hiring-platform hosts vetting and + applying visit - using `discovery.between_provider_pause_min/max` (read + for the first time) with seeded jitter. An engine that answers with a + CAPTCHA or login wall is stopped for the rest of the session before any + further request is sent, and the saved session report names it. + Site-restricted harvests keep their own degradation baseline (stored + baseline keys are now lowercase, so old ones re-collect). Hits that are + not postings of the platform searched are dropped or, with company-page + mining on, routed to it, and counted; terminal pages are never followed. + Each discovered job's provenance (provider, query, platform, engine form, + route, page and rank) is stored with it; the results CSV shows all of it + except the route. + Behind research consent, a new `search_plan_frames` table records the + sampling frame - which engines and platforms were searched and how often, + never the words searched for (research schema 5, consent text 2.7). The + discovery verifier's engine hosts now come from the search-surface + catalog. +- **ATS-targeted automated search (opt-in).** A pure, deterministic planner + (`domain/services/search_planner.py`) turns the session's titles, locations, + enabled engines and chosen ATS platforms into engine-bound, site-restricted + queries — every one composed by the search-surface catalog's composer, never + hand-formatted. Off by default (`discovery.use_ats_site_search` plus + `discovery.ats_platforms`); with it off, automated queries are byte-identical + to before. Only platforms whose restriction is measured or vendor-documented + are planned; engines rotate innermost so no engine host is queried twice in + a row while another target is available; the session query budget is a hard + cap and dropped targets are reported. Jobs found this way carry the engine, + the engine form, the platform and the query in `job.metadata`, and every + discovered job now carries the query that produced it. + `SessionPlan.search_pairs` and `use_ats_site_search` are now live. - **The query builder's notes follow the user's language.** Every note the composer or the builder service writes now carries a locale key and its values (`QueryNote.message_key`, `message_params`); both surfaces render it diff --git a/packages/auto_apply/docs/REPRODUCIBILITY.md b/packages/auto_apply/docs/REPRODUCIBILITY.md index 8faf35c..e780e6f 100644 --- a/packages/auto_apply/docs/REPRODUCIBILITY.md +++ b/packages/auto_apply/docs/REPRODUCIBILITY.md @@ -54,6 +54,17 @@ needs randomness. Components use `self._rng.uniform()` instead of the global `secrets.SystemRandom()` directly — if you find one that does, it is a bug and should be reported. +### The plan-side record: `--print-plan` + +`--print-plan` prints what a session will search — the engines, the +platforms, the composed queries and the session budget — and exits, offline, +before any browser or network work. Because the planner is deterministic (no +clock, no RNG), the same profile, flags and AA version print the same plan; +pairing it with `--seed` (`python -m auto_apply --print-plan --seed 42`) +records the planned half of a reproducible run, just as `--replay` +reproduces the analysis half. It is the before-the-fact complement of +`--replay`. + ### Replay: the reproducible step Two live sessions cannot produce the same research rows: the web changes @@ -404,9 +415,9 @@ When publishing results derived from AA data, please include: collection (the YAML file itself is ideal). 3. **Seed** — the `--seed` value if deterministic mode was used. 4. **Signal schema version** — `RESEARCH_SCHEMA_VERSION` from - `domain/constants.py` (currently `3`). + `domain/constants.py` (currently `5`). 5. **Consent version** — `CURRENT_CONSENT_VERSION` from - `domain/constants.py` (currently `"2.5"`). + `domain/constants.py` (currently `"2.7"`). 6. **Citation** — use the `CITATION.cff` file in the repository root. --- diff --git a/packages/auto_apply/docs/RESEARCH_CONSENT_DIALOG.md b/packages/auto_apply/docs/RESEARCH_CONSENT_DIALOG.md index daa9eca..536cfb8 100644 --- a/packages/auto_apply/docs/RESEARCH_CONSENT_DIALOG.md +++ b/packages/auto_apply/docs/RESEARCH_CONSENT_DIALOG.md @@ -1,12 +1,12 @@ --- -title: "Research Consent Dialog — Exact UI Text (v2.6)" +title: "Research Consent Dialog — Exact UI Text (v2.7)" status: needs-review last_verified: 2026-09-27 verified_against: "bulk provenance stamp 2026-09-27; content not individually re-verified against code" audience: researchers --- -# Research Consent Dialog — Exact UI Text (v2.6) +# Research Consent Dialog — Exact UI Text (v2.7) This document is the AUTHORITATIVE SOURCE for the consent dialog text shown to users when they enable research data collection in the Research screen — @@ -68,6 +68,10 @@ way that changes what data is collected or how, increment > "20 Python jobs in Sacramento") is stored as shown too. > - Which detectors ran on each posting and how each concluded, so rates > in published research have a trustworthy denominator +> - Which search engines and hiring platforms were searched, and how many +> searches each received — the sampling frame for automated site-restricted +> search — with counts of planned, dropped and skipped targets. The words +> you searched for are never included. > - A public verification key that lets recipients confirm exported rows > came from an unmodified AutoApply (the private key never leaves your > data folder) diff --git a/packages/auto_apply/docs/STATUS.md b/packages/auto_apply/docs/STATUS.md index e4e8735..16c259f 100644 --- a/packages/auto_apply/docs/STATUS.md +++ b/packages/auto_apply/docs/STATUS.md @@ -1,8 +1,8 @@ --- title: Project Status status: reviewed -last_verified: 2026-09-19 -verified_against: "suite 1384 passed / 2 skipped; CI green on 6 legs" +last_verified: 2026-10-29 +verified_against: "suite 2537 passed / 5 skipped / 1 xfailed on Windows" audience: everyone --- @@ -94,6 +94,7 @@ a defect in that document.** | SERP extraction behind a port | `[LIVE]` | DOM miner retained as a committed fallback | | URL resolution (7-stage, vendor-free) | `[LIVE]` | detect → climb → identity → static → relocate → activate → classify | | Discovery output persistence | `[LIVE]` | Stored at discovery; survives a run that skips vetting | +| ATS-targeted site-restricted search | `[WIRED]` | Planner, session-wide query budget, per-host pacing, engine benching and the pre-run plan preview are unit-pinned; **never exercised on a live run**. Only measured/vendor-verified platforms are planned (one today) | | Company career-page mining | `[PARTIAL]` | Reachable for pasted careers URLs only | | Pagination ("next page") | `[ORPHAN]` | Handlers exist, nothing constructs them | @@ -157,7 +158,7 @@ a defect in that document.** | Gate | State | | --- | --- | -| Test suite | **1384 passed, 2 skipped** | +| Test suite | **2537 passed, 5 skipped, 1 xfailed** | | `ruff check src --select F821` | Clean | | `mypy src/auto_apply` | Clean across 264 files | | `mypy --explicit-package-bases tests` | Clean | diff --git a/packages/auto_apply/docs/adr/020_automated_site_search.md b/packages/auto_apply/docs/adr/020_automated_site_search.md new file mode 100644 index 0000000..43f9402 --- /dev/null +++ b/packages/auto_apply/docs/adr/020_automated_site_search.md @@ -0,0 +1,203 @@ +--- +title: "ADR-020: Automated Site-Restricted Search — Planning, Pacing, Preview and Refusal" +status: needs-review +last_verified: 2026-10-29 +verified_against: "domain/services/search_planner.py, application/services/host_pacing.py, application/services/search_preview.py, application/workflows/discovery_workflow.py, application/services/auditing/degradation_detector.py, infrastructure/registry.py" +audience: contributors +--- + +# ADR-020: Automated Site-Restricted Search — Planning, Pacing, Preview and Refusal + +**Status:** Accepted +**Date:** 2026-10-29 +**Deciders:** Nicholas Liebmann +**Supersedes / Superseded by:** none + +## Context + +[ADR-019](019_search_surface_catalog_and_query_builder.md) built the +search-surface catalog and the query builder: what a surface accepts, +composed once, for searches a person runs by hand. The obvious next step was +to let *automation* use the same knowledge — aim discovery at specific hiring +platforms with `site:`-restricted queries. That step carries risks the +hand-built search never faces: + +- Automation acts with **nobody reading a verification label** mid-run. +- Every host AA queries becomes part of the research record's sampling + frame, so an unverified target is not merely a wasted query, it is + contamination. +- A `site:`-restricted search concentrates a session's traffic onto a few + ATS hosts — the pattern that gets a client throttled or challenged. +- A restriction that silently degrades — an engine that cannot carry it, a + result that is not a posting of the platform searched — would produce jobs + AA claims came from a platform they did not come from. + +A second, older measurement pressed from the other side: the wizards asked +users to choose things and then showed them nothing of what would happen — +the same "shape checked, binding not" defect class +[ADR-014](014_typed_ui_port.md) removed from session configuration. + +## Decision + +**1. Planning is a pure function, composed once.** `SearchPlanner` +(`domain/services/search_planner.py`) is deterministic — no clock, no I/O, +no randomness — and every query it emits is built by the catalog's composer. +Nothing in the planning path formats a `site:` clause by hand. Engines +rotate innermost and platforms outermost, so no engine host receives two +consecutive queries while another enabled target exists. The default +strategy is one query per platform per engine per (title, location); the +strategy is pluggable, and a strategy that cannot be composed yet degrades +to per-platform with a warning, never to silence. + +**2. The automation floor is stricter than the builder's.** Only platforms +whose restriction is `measured` or `vendor`-documented may be planned +(`AUTOMATION_STATUSES`). The builder shows unverified platforms with a label +because a person can judge a label; automation cannot, so unverified +platforms are never planned, and the checklist in +[verifying_platform_search.md](../developer_guide/verifying_platform_search.md) +is the only way a platform earns admission. + +**3. Engines get a query they can carry, or no query.** Refusal-both extends +to automation: an engine whose catalog form cannot carry a site restriction +receives nothing — never a degraded version of the query. Concretely: +Google's site-restricted searches use the **web** form +(`site_template: web`), because the jobs vertical's cards carry no +followable per-card URL (measured 2026-10-07, recorded in +`google.surface.yaml`); Indeed declares no site form and is never sent one. + +**4. The session budget is session-wide and never reallocates.** +`max_queries_per_session` caps the whole session, owned by the one component +that spans discovery tasks (the workflow); engine rotation carries across +task boundaries. The plan is a pure function of its inputs and reads no +yield, no history and no clock — so it cannot shift budget mid-session, +which is what keeps a seeded run and a replay comparable. + +**5. One pacer owns every contact.** `HostPacer` +(`application/services/host_pacing.py`) spaces consecutive contacts to any +single host — search engines during discovery, ATS hosts during vetting and +applying — by a floor with seeded jitter, from +`discovery.between_provider_pause_min/max` (1.0–2.0 s by default). +Reservations are taken under a lock so interleaving cannot violate the +floor. robots.txt is deliberately not consulted on the interactive path: +interactive browsing is not bulk crawling, and a robots fetch would itself +be a request. + +**6. A challenged engine is benched, not retried.** An engine that answers +with a CAPTCHA or a login wall receives no further requests for the rest of +the session — the check happens *before* navigation — while the other +engines continue; the end-of-session report names it. The silent-degradation +guard benches on a harvest collapse only when yield *and* page size collapse +together against the provider's own recorded baseline, and site-restricted +harvests keep their own baseline scope, so a sparse `site:` harvest can +never bench an engine's generic searches. + +**7. Off-shape hits are refused loudly, never laundered.** A result that is +not a posting of the platform searched is dropped and counted — or routed to +company-page mining when that is enabled — and terminal pages +(success/confirm/closed) are never followed. + +**8. Provenance is stored with the job.** Every discovered job carries the +provider, the exact query, the target platform, the engine form, the route, +and the page and rank, persisted in `job_history.metadata_json`; the results +CSV shows all of it except the route. + +**9. The sampling frame is research data; the query text is not.** Behind +consent, `search_plan_frames` records which engines and platforms were +searched and how many queries each received — names and counts, never the +words searched for. Research schema 5, consent text 2.7. + +**10. The user sees the plan before it runs, from the same computation.** +`SessionRequest.platforms` is a tri-state (unset = inherit, empty = +explicitly generic, names = targeted). Precedence: admin lock +(`AdminPolicy.search_platforms`, a field-level lock in the +[ADR-018](018_layered_settings_and_motion_profile.md) pattern, applied after +`config_overrides` so the named lock wins) → this session's choice → profile +default (`ApplicationConfig.search_platforms`) → YAML. The plan preview is +computed from the session plan after the request and any lock are applied, +by the same planner the workflow uses — one shared computation +(`plan_updates_for_request`), so the preview cannot drift from the run. It +is offline by construction, appears as a "Preview plan…" button (GUI) and an +automatic confirm step (CLI), and `--print-plan` prints it without starting +anything. Both surfaces read one vocabulary +(`QueryBuilderService.automation_platform_vocabulary()`) and render the +outcome through one formatter (`format_discovery_outcome_lines`). `UIPort` +grows to 28 methods with `preview_search_plan`. + +**Feeds: accepted, not built.** Public ATS posting feeds are accepted in +design as a future discovery provider (`requires_live_browser = False`), +never a replacement for the browser path. The accepted shape: a feed +response is captured as a WARC `resource` record of the raw bytes; replay +re-runs the feed parser and the detectors over it, never the DOM extractor; +a feed whose content type or shape fails the parser contract yields zero +items and a recorded observation, never a guessed harvest; a per-endpoint +shape hash gives a first-seen baseline; because the request carries the +user's terms, no query text reaches research; and it is built after the +user-facing surfaces, not before them. No code exists for this, and none of the +documents outside this ADR claim otherwise. + +## Options considered + +**Hand-formatted `site:` clauses in the provider files.** Rejected — the +"knowledge scattered across provider files" defect ADR-004 recorded for ATS +platforms and ADR-019 removed for hand-built searches, recreated one layer +down. + +**The builder's evidence floor for automation.** Rejected — a label is a +control only when someone reads it; mid-run nobody does, and every host +queried enters the research sampling frame. + +**Reallocating budget by yield** (queries flow to whichever engine is +producing). Rejected — the plan would stop being a pure function of its +inputs, and a seeded run and its replay would no longer be comparable. The +plan is fixed at planning time; the bench, not reallocation, is the answer +to a bad engine. + +**Aborting the session when an engine challenges.** Rejected — one +challenged host says nothing about the others; benching isolates the signal +and the session continues degraded but honest. + +**Retrying a challenged engine.** Rejected — a challenge is the host's +answer; repeating the question is the pattern rate-limiters exist to punish, +on the worst-case user's metered connection. + +**Per-call-site pacing.** Rejected — pacing literals scatter across +providers, are unmeasurable in one place, and cannot cover the ATS hosts +that vetting and applying contact. One owner, one config pair, one seeded +jitter stream. + +**Consulting robots.txt before each contact.** Rejected for the interactive +path — a robots fetch is itself a request, and the throttler/robots +machinery remains available for a future bulk-fetch path where the question +applies. + +**Showing the plan only on request.** Rejected for the CLI — the wizard +already asks the questions; showing what they mean costs one screen and +catches a wrong plan before it costs a session. The GUI keeps it a button +because a modal between every field and Start would punish exploration. + +## Consequences + +- Easier: targeting a platform is one wizard answer; verifying a platform is + one checklist; the plan is inspectable before anything runs and the + outcome is reported after; a challenged engine costs one benching, not a + session. +- Harder: a new platform is inadmissible to automation until a human + measures it — deliberately. The planner, the pacer and the preview are + three more deterministic components whose behaviour must be pinned rather + than assumed. +- **Nothing in this ADR has been proven on a live run.** Every behaviour + here is unit-pinned; the first live session with targeting engaged is its + test. [STATUS.md](../STATUS.md) carries the same sentence. +- The feeds design is recorded so a future implementer does not re-litigate + it; it has no code. + +## References + +- `domain/services/search_planner.py`, `domain/services/query_composer.py` +- `application/services/host_pacing.py`, `application/services/search_preview.py` +- `application/workflows/discovery_workflow.py` +- `application/services/auditing/degradation_detector.py` +- `infrastructure/registry.py` (the search-platform fold and the admin lock) +- `resources/search_surfaces/google.surface.yaml` (`site_template: web` and its measurement) +- [ADR-004](004_ats_platform_registry.md), [ADR-014](014_typed_ui_port.md), [ADR-018](018_layered_settings_and_motion_profile.md), [ADR-019](019_search_surface_catalog_and_query_builder.md) +- [verifying_platform_search.md](../developer_guide/verifying_platform_search.md) diff --git a/packages/auto_apply/docs/adr/index.md b/packages/auto_apply/docs/adr/index.md index 3eb49c7..0dc3f2b 100644 --- a/packages/auto_apply/docs/adr/index.md +++ b/packages/auto_apply/docs/adr/index.md @@ -20,7 +20,7 @@ and here is why not" from "nobody thought of it". 1. **ADRs are immutable once accepted.** They record history, not current state. 2. **A reversed decision gets a new ADR**, and the old one is marked `Superseded` with a link. It is never edited to look correct in hindsight. -3. **The next number is `020`.** Take it by creating the file; if two people +3. **The next number is `021`.** Take it by creating the file; if two people collide, the second renumbers. 4. Write an ADR when a change alters a layer boundary, a port contract, a data format, a user-visible guarantee, or reverses an earlier decision. @@ -51,6 +51,7 @@ and here is why not" from "nobody thought of it". | [017](017_documentation_gate.md) | Documentation is enforced by a gate | Accepted | 2026-09-19 | | [018](018_layered_settings_and_motion_profile.md) | Layered settings disclosure and the per-user motion profile | Accepted | 2026-10-05 | | [019](019_search_surface_catalog_and_query_builder.md) | Search-surface catalog and the query builder | Accepted | 2026-10-28 | +| [020](020_automated_site_search.md) | Automated site-restricted search: planning, pacing, preview and refusal | Accepted | 2026-10-29 | `tests/infrastructure/test_docs_gate.py` asserts that this table and the directory contain the same set of records. @@ -65,7 +66,7 @@ worse than no register. | 003 | Names `application/use_cases/applications_use_case.py` and `domain/applications/fsm/universal.py`. **Neither file exists.** The decision's intent survives; its implementation references do not. Needs a superseding record describing the loop as actually built. | | 010 | A historical audit changelog rather than a decision. Accurate for its date; several files it names have since been retired. Kept as history, not as guidance. | | 006 | Superseded by 013. Left unedited, as the rules require. | -| 014 | Names `UIPort` at 17 methods; the query builder and the resolve step grew it to 27 (see ADR-019). The decision stands — only the count moved. Left unedited, as the rules require. | +| 014 | Names `UIPort` at 17 methods; the query builder and the resolve step grew it to 27 (see ADR-019), and the plan preview grew it to 28 (see ADR-020). The decision stands — only the count moved. Left unedited, as the rules require. | ## Decisions still unrecorded @@ -87,18 +88,18 @@ somebody will otherwise re-litigate from scratch. ## Template -Copy this into `adr/018_your_decision.md`. +Copy this into `adr/021_your_decision.md`. ````markdown --- -title: "ADR-018: Title" +title: "ADR-021: Title" status: reviewed last_verified: YYYY-MM-DD verified_against: "what you checked it against" audience: contributors --- -# ADR-018: Title +# ADR-021: Title **Status:** Proposed | Accepted | Rejected | Superseded | Deprecated **Date:** YYYY-MM-DD diff --git a/packages/auto_apply/docs/architecture/discovery_strategies.md b/packages/auto_apply/docs/architecture/discovery_strategies.md index f954165..72fd278 100644 --- a/packages/auto_apply/docs/architecture/discovery_strategies.md +++ b/packages/auto_apply/docs/architecture/discovery_strategies.md @@ -1,8 +1,8 @@ --- title: "Discovery Strategies" status: needs-review -last_verified: 2026-09-27 -verified_against: "bulk provenance stamp 2026-09-27; content not individually re-verified against code" +last_verified: 2026-10-28 +verified_against: "application/workflows/discovery_workflow.py, domain/services/search_planner.py, application/services/host_pacing.py, application/services/auditing/degradation_detector.py" audience: contributors --- @@ -27,8 +27,9 @@ Bing, Indeed, or a company’s careers page. Every provider must satisfy the `DiscoveryProviderPort` contract, which the Discovery Engine depends on. The engine is a pure orchestrator: it asks -each injected provider to `run()`, collects the results, deduplicates by URL, -and returns them to the AgentOrchestrator. +each injected provider to `run(instruction)`, collects the results, +deduplicates by URL, persists them, and enqueues one VET WorkUnit per +unique job. ```mermaid graph TD @@ -51,10 +52,13 @@ graph TD ``` The composition root builds the list of providers and injects it into the -engine. The engine calls `provider.run()` on each one, in order. If a -provider fails (CAPTCHA, network error, layout change), the engine logs the -error and continues to the next provider — one failure never aborts the -entire search. +workflow. For each search instruction, the workflow calls +`provider.run(instruction)` on **every** active provider, in a seeded order +that rotates between searches. If a provider fails (network error, layout +change), the workflow logs the error and continues to the next provider — +one failure never aborts the entire search. An engine that answers with a +CAPTCHA or a login wall is benched for the rest of the session instead (see +the fan-out section below). ### The DiscoveryProviderPort Contract @@ -66,31 +70,45 @@ class DiscoveryProviderPort(ABC): @property def requires_live_browser(self) -> bool: ... - def run(self, override_criteria: dict | None = None) -> list[Job]: ... + def run(self, instruction: SearchInstruction) -> list[Job]: ... ``` | Method | Purpose | | ------ | ------- | | `name` | Canonical lowercase identifier: `"google"`, `"bing"`, `"indeed"`. | | `requires_live_browser` | Returns `True` if this provider needs a real browser. The composition root excludes browser‑dependent providers when no browser is available. | -| `run(criteria)` | Execute the search and return a list of Job objects. May be empty; never `None`. | +| `run(instruction)` | Execute one `SearchInstruction` and return a list of Job objects. May be empty; never `None`. | --- ## How a Provider Works: Inside GoogleProvider -Let’s trace what happens when the engine calls `GoogleProvider.run()`. +Let’s trace what happens when the workflow calls +`GoogleProvider.run(instruction)`. -### 1. Construct the Search URL +### 1. Compose the Search URL from the Instruction -The provider reads the user’s desired job titles and locations from the -profile, then builds a Google Jobs URL: +The provider never reads the user profile. It receives one +`SearchInstruction` — title, location, workplace type, optional raw query +string, engine form, date range and result cap — built by the +`DiscoveryWorkflow`, either from the profile's search matrix or, when +ATS-targeted search is engaged, from the deterministic `SearchPlanner`. The +provider composes the URL from it through its engine strategy +(`engine_strategies.build_search_url`): ```python -query = f"{title} jobs in {location}" -url = f"https://www.google.com/search?q={query}&ibp=htl;jobs" +# generic: the query text is built from the instruction's title and +# location, and sent on the engine's configured form +# site-restricted: instruction.raw_query_string — composed by the catalog's +# query composer and bound to this one engine — is sent verbatim ``` +The generic Google URL carries `q` plus `ibp=htl;jobs`, `hl=en`, `gl=us` and +`start=0`, the parameters recorded in +`resources/search_surfaces/google.surface.yaml`. A byte-identity pin +(`tests/adapters/test_engine_url_byte_identity.py`) asserts that introducing +the catalog changed none of the bytes automation sends. + ### 2. Navigate — with a Fallback Plan Instead of a single `browser.get(url)` call, the provider uses a @@ -173,44 +191,81 @@ reach the same results page, dramatically increasing the chance of success. --- -## Adaptive Search Manager +## Fan-out, Pacing and Benching -The `DiscoveryEngine` functions as an **Adaptive Search Manager**. It does -not run all providers unconditionally. Instead, it loops through them in -priority order and **stops as soon as it gets results**. +The `DiscoveryWorkflow` does **not** stop at the first provider that +succeeds. Every instruction is offered to every active provider, and the +results are merged, pre-filtered and deduplicated across all of them. The +order providers are tried rotates between searches (a seeded shuffle), so +runs are not always Google-first. ```mermaid graph TD - A[Start Discovery] --> B{Try GoogleProvider} - B -- Returns ≥1 job --> E[Return results] - B -- Returns 0 or fails --> C{Try BingProvider} - C -- Returns ≥1 job --> E - C -- Returns 0 or fails --> D{Try IndeedProvider} - D -- Returns ≥1 job --> E - D -- Returns 0 or fails --> F[Return empty list] + A[One SearchInstruction] --> B{Every active provider, order rotated} + B --> C[Provider 1] + B --> D[Provider 2] + B --> E[Provider 3] + C --> F[Merge, pre-filter, dedupe across ALL providers] + D --> F + E --> F + F --> G[VET task per unique job] ``` -This means: - -- If Google returns 50 jobs, AA never even touches Bing or Indeed — saving - bandwidth and reducing the footprint on job boards. -- If Google is down or blocking, AA moves silently to Bing. -- If all providers fail, AA returns an empty list and the orchestrator logs - a warning. The session is not aborted — the user can retry later or switch - to Direct Links mode. +Three session-wide guards sit in front of the fan-out: + +- **Per-host pacing.** One `HostPacer` + (`application/services/host_pacing.py`) spaces consecutive contacts to any + single host — engines here, ATS hosts in vetting and applying — by + `discovery.between_provider_pause_min/max` (1.0–2.0 s by default) with + seeded jitter. Reservations are taken under a lock, so concurrent workers + cannot interleave past the floor. robots.txt is not consulted on this + interactive path: a robots fetch would itself be a request. +- **Benching.** An engine that answers with a CAPTCHA or a login wall is + benched for the rest of the session, *before any further request is sent*; + the other engines continue, and the end-of-session summary names the + benched engine and the reason. A separate guard + (`SilentDegradationDetector`) benches a provider whose first harvest + collapses against its own recorded baseline — only when yield *and* page + size fall together, so a legitimately sparse query never benches anyone. + Site-restricted harvests keep their own baseline scope, so a thin `site:` + harvest cannot bench an engine's generic searches. +- **Off-shape handling.** A result that is not a posting of the platform a + site-restricted search targeted is dropped and counted — or, when + company-page mining is enabled, routed to it as a `DISCOVER_COMPANY` task. + Terminal pages (success/confirmation/closed) are never followed. Every + kept job is stamped with its provenance — provider, query, platform, + engine form, route, page and rank — stored with the job in + `job_history.metadata_json`; the results CSV shows all of it except the + route. + +If all providers return nothing, the workflow enqueues no VET tasks and the +orchestrator logs a warning; the session is not aborted. --- ## The Multi‑Query Loop -The Discovery Engine does not perform a single search. For every combination -of **desired job title × preferred location × workplace type**, it generates a -separate query. If the user’s profile has two job titles, three locations, and -two workplace types, the engine runs up to 12 queries — each potentially -returning dozens of results. +The Discovery Engine does not perform a single search. By default the +workflow builds one instruction for every combination of **desired job title +× preferred location × workplace type** from the profile. When ATS-targeted +search is engaged (`discovery.use_ats_site_search` plus +`discovery.ats_platforms`), the `SearchPlanner` instead plans one +`site:`-restricted query per **platform × engine × (title, location)** — +each one composed by the search-surface catalog's composer, never +hand-formatted, and only for platforms whose restriction is measured or +vendor-documented. Planning is deterministic: engines rotate innermost, +platforms outermost, and no query is ever reallocated by yield. + +Two budgets bind, both session-wide: + +- `max_queries_per_session` (a `SessionPlan` field, default 20) caps the + **total** queries a session sends, across every search task — the workflow + owns the counter, and instructions beyond the cap are skipped with a + warning, never silently. Engine rotation carries across task boundaries. +- `max_discovery_results_per_query` (in `runtime_defaults.yaml`) caps how + many results each provider may return per query. -This is capped by `max_discovery_results_per_query` and `max_queries_per_session` -in `runtime_defaults.yaml` to prevent resource exhaustion on low‑end machines. +Both exist to protect low‑end machines and metered connections. --- @@ -221,6 +276,7 @@ and register it in one place. 1. Create a new file in `adapters/secondary/discovery/providers/`: ```python + from auto_apply.domain.models.search_instruction import SearchInstruction from auto_apply.domain.ports.discovery_port import DiscoveryProviderPort from auto_apply.adapters.secondary.discovery.strategies.navigators import ( DirectURLNavigation, @@ -240,8 +296,8 @@ and register it in one place. HumanSearchNavigation(browser), ]) - def run(self, override_criteria=None): - # build LinkedIn search URL, navigate, mine jobs... + def run(self, instruction: SearchInstruction) -> list[Job]: + # compose the URL from the instruction, navigate, mine jobs... return jobs ``` @@ -283,7 +339,7 @@ The Discovery layer respects AA’s worst‑case‑first philosophy at every lev | Condition | Behaviour | | --------- | --------- | -| No live browser available | Browser‑dependent providers are excluded from the provider list. Only static‑fetch providers (future) run. | +| No live browser available | The session refuses to start with an explained error ([ADR‑013](../adr/013_static_path_retirement.md)); no provider is ever run browserless. | | A provider raises an exception | The exception is caught, logged, and the engine moves to the next provider. | | A provider returns zero results | The engine tries the next provider. | | `SpaCy` is not installed | The `TextMatcher` falls back to `difflib.SequenceMatcher` for title matching inside providers. | diff --git a/packages/auto_apply/docs/architecture/ui_port_layer.md b/packages/auto_apply/docs/architecture/ui_port_layer.md index 3ba69e0..b71dfaa 100644 --- a/packages/auto_apply/docs/architecture/ui_port_layer.md +++ b/packages/auto_apply/docs/architecture/ui_port_layer.md @@ -129,7 +129,7 @@ import `SessionController` directly, so the port carries WIRE-LATER exemptions. | Gap | Consequence | | --- | --- | -| `UIPort` is 27 methods — and ten of them are the query builder, which answers *before* any session exists | The builder is part of the layer, so "session-shaped" no longer describes the whole port; identity and custody parity remain unpinned | +| `UIPort` is 28 methods — ten of them are the query builder, which answers *before* any session exists, and `preview_search_plan` answers before *Start* | The builder and the preview are part of the layer, so "session-shaped" no longer describes the whole port; identity and custody parity remain unpinned | | `UIPort` has no typed consumer | Two WIRE-LATER exemptions, two raised ceilings | | `approval_evidence()` is in-memory only | Authorisation stamps exist during a run and not after it — a research-durability gap | | Five print sites sit outside the primary adapters | Four are the Profile Check advisory, which goes to stdout, so **a GUI user never sees it** | diff --git a/packages/auto_apply/docs/developer_guide/verifying_platform_search.md b/packages/auto_apply/docs/developer_guide/verifying_platform_search.md index c4c989d..e5f5b2b 100644 --- a/packages/auto_apply/docs/developer_guide/verifying_platform_search.md +++ b/packages/auto_apply/docs/developer_guide/verifying_platform_search.md @@ -27,6 +27,23 @@ In the platform's YAML file: - The query does not work → keep `unverified` and say what happened in the source line. A wrong restriction is worse than an honest unverified one. +## What verification admits + +A platform whose restriction reaches `measured` or `vendor` becomes eligible +for **automated** site-restricted search: it turns selectable in the session +wizards and the settings editor, and the planner may build queries for it. +(`third_party` and `unverified` platforms stay visible in the query builder — +a person can judge a label — but automation never plans them.) + +Confirm admission without starting a session, offline: + +```bash +python -m auto_apply --print-plan --platforms +``` + +The preview prints the composed `site:`-restricted query the session would +send for that platform, per enabled engine — or says why none was planned. + ## Two measurements that apply to many platforms 1. **Subdomain tenancy + shared path.** For platforms like BambooHR diff --git a/packages/auto_apply/docs/getting_started/configuration.md b/packages/auto_apply/docs/getting_started/configuration.md index 916c017..6181966 100644 --- a/packages/auto_apply/docs/getting_started/configuration.md +++ b/packages/auto_apply/docs/getting_started/configuration.md @@ -107,6 +107,7 @@ A minimal profile looks like this: | `app_config` | `preferred_browser` | `"chrome"`, `"firefox"`, `"edge"`, `"any"` | | `app_config` | `run_headless` | `true` to hide the browser window | | `app_config` | `daily_application_limit` | Hard cap on applications per session | +| `app_config` | `search_platforms` | Hiring platforms AA may target with automated site-restricted search. `null` (or absent) inherits the app-wide default; `[]` disables targeting for this profile | | `politeness_settings` | `respect_robots_txt` | `true` to obey `robots.txt` (recommended) | | `politeness_settings` | `default_delay` | Seconds between browser actions (min 0.5) | @@ -213,6 +214,8 @@ A policy can restrict: - Whether the browser must run headless - Minimum action delay - Whether research data collection is permitted +- Which hiring platforms automated search may target (`search_platforms`; + an empty list disables the feature on that machine) The full syntax and deployment instructions are in the [Admin Policy Guide](../user_guide/admin_policy.md) and @@ -220,6 +223,40 @@ The full syntax and deployment instructions are in the --- +## Automated site-restricted search + +AA can aim its automated discovery at specific hiring platforms, composing +`site:`-restricted queries from the search-surface catalog. The feature is +off by default and layered: + +| Layer | Setting | Default | +| ----- | ------- | ------- | +| App-wide | `discovery.use_ats_site_search` | `false` | +| App-wide | `discovery.ats_platforms` | `[]` — platform names from `resources/ats/` | +| App-wide | `discovery.site_search_strategy` | `per_platform` — one query per platform per engine per title/location | +| Per profile | `app_config.search_platforms` | `null` inherits; `[]` disables; a list targets exactly those platforms | +| Per session | the wizard's platform question, or `--platforms` | unset inherits | +| Per machine | `AdminPolicy.search_platforms` | `null` (no lock); a list locks the set, `[]` locks it off | + +Precedence, highest first: **admin lock → this session's choice → profile +default → YAML**. The admin lock is applied after `config_overrides`, so the +named lock wins even over a full-section override. + +Only platforms whose search restriction is **measured by hand or documented +by the vendor** are ever planned; the rest are refused, and the developer +guide's verification checklist is how a platform earns admission. Unknown +platform names are dropped with a printed note at session setup, never +silently. + +Two related session-wide guards: + +| Key | Default | Meaning | +| --- | ------- | ------- | +| `max_queries_per_session` | 20 | Total queries a session may send, across every search task. A `SessionPlan` field; there is no `runtime_defaults.yaml` key for it. | +| `discovery.between_provider_pause_min` / `discovery.between_provider_pause_max` | 1.0 / 2.0 seconds | Floor and ceiling of the per-host pacer: the spacing between two contacts to the *same* host — search engines during discovery, hiring-platform hosts during vetting and applying alike. Seeded jitter picks within the range. | + +--- + ## Configuration verification Run AA with the `--check-config` flag to validate your setup without starting diff --git a/packages/auto_apply/docs/reference/cli.md b/packages/auto_apply/docs/reference/cli.md index 29d3d76..07dff6c 100644 --- a/packages/auto_apply/docs/reference/cli.md +++ b/packages/auto_apply/docs/reference/cli.md @@ -47,6 +47,8 @@ auto-apply # console script, available after install | `--search-recent` | — | List your recent built searches, then exit | | `--search-clear-recent` | — | Forget every remembered search, then exit | | `--encrypt-profile` | — | Encrypt the current plaintext profile into a `.vault` file behind a master password | +| `--platforms` | `NAMES` | Hiring platforms for automated site-restricted search this session, comma-separated (e.g. `--platforms jobvite`). `none` disables platform targeting. Only measured or vendor-verified platforms are ever planned | +| `--print-plan` | — | Print what a session would search (engines, platforms, queries, budget) and exit. Offline; starts no session and no browser. Composes with `--seed` and `--platforms` | ## Building a search @@ -63,6 +65,22 @@ never silently dropped. Copy, open and keep are the only actions that remember a query (five, deduplicated, in your data home; `--search-clear-recent` forgets them). +## Previewing the plan + +```bash +python -m auto_apply --print-plan +python -m auto_apply --print-plan --platforms jobvite +python -m auto_apply --print-plan --seed 42 +``` + +Prints what a session would search — the engines, the platforms, the composed +queries and the session budget — then exits. Offline: no session, no browser, +no network. With no platforms chosen it shows the generic searches the session +will actually run. A profile must exist; without one the command says so and +exits non-zero. `--platforms` also applies to a real CLI session +(`python -m auto_apply --cli --platforms jobvite`); `none` disables platform +targeting for that session. + ## Flags parsed before anything else `--portable` and `--seed` are read by a **pre-parser** that runs before any @@ -141,7 +159,7 @@ you forget the password — that is a property of the encryption, not an oversig AA is a long-running interactive agent rather than a batch tool, and **does not yet publish a stable exit-code contract**. `--check-config`, `--export-research`, `--research-summary`, `--research`, `--replay`, `--label`, -`--encrypt-profile` and the `--search*` builder flags exit after their work +`--encrypt-profile`, `--print-plan` and the `--search*` builder flags exit after their work (a builder refusal prints a plain-language note and exits non-zero); the others run until you stop the session. diff --git a/packages/auto_apply/docs/reference/glossary.md b/packages/auto_apply/docs/reference/glossary.md index 9c46ba5..e40a112 100644 --- a/packages/auto_apply/docs/reference/glossary.md +++ b/packages/auto_apply/docs/reference/glossary.md @@ -60,6 +60,12 @@ form‑filling strategies. A YAML‑driven registry (`ATSRegistry`) that loads ATS descriptors from `resources/ats/*.yaml` and matches job URLs to known platforms. +### Automation floor +The evidence level a hiring platform's search restriction must reach before +automation may target it: **measured by hand or documented by the vendor**. +Below the floor a platform may appear in the query builder (a person can +judge the label) but is never planned by automated site-restricted search. + --- ## B @@ -75,6 +81,12 @@ The core **Port** that abstracts a web browser. Every browser automation tool (Selenium, Playwright, etc.) is wrapped in an **Adapter** that implements this interface. +### Benched engine +A search engine that answered AA with a CAPTCHA or a login wall and so +receives no further requests for the rest of the session. The check happens +before navigation; the other engines continue, and the end-of-session +summary names the benched engine and the reason. + --- ## C @@ -206,10 +218,28 @@ See **AgentOrchestrator**. The central controller that manages the entire session lifecycle: dequeues tasks, dispatches them to engines, handles errors and retries, and coordinates health monitors. +### Off‑shape hit +A discovery result that is not a posting of the platform a site-restricted +search targeted — a board, a listing page or noise. Dropped and counted, +or routed to company-page mining when that is enabled; terminal pages +(success/confirmation/closed) are never followed. + --- ## P +### Per‑host pacing +The single `HostPacer` that spaces consecutive contacts AA makes to any one +host — search engines during discovery, hiring-platform hosts during vetting +and applying — by a configured floor and ceiling +(`discovery.between_provider_pause_min/max`) with seeded jitter. + +### Plan preview +The offline, before-Start view of exactly what a session will search — +engines, platforms, composed queries and budget — computed by the same +planner the session runs. Shown by the GUI "Preview plan…" button, the CLI +wizard's confirm step, and `--print-plan`. + ### Port An abstract interface defined in `domain/ports/` that describes a capability the domain needs. Examples: `BrowserInterface`, @@ -243,9 +273,23 @@ automatic popup dismissal, iframe‑traversal search, and health checks. ## S +### Sampling frame +The record of which engines and platforms AA searched and how many queries +each received during a session — names and counts only, never the words +searched for. Written behind research consent to the `search_plan_frames` +table (research schema 5). + ### Scan‑Plan‑Act Loop See **PRA Loop**. +### Site‑restricted search +Automated discovery aimed at specific hiring platforms: one `site:`-restricted +query per platform per engine per title/location, composed from the +search-surface catalog and the ATS registry. Off by default; enabled per +session (the wizard or `--platforms`), per profile +(`app_config.search_platforms`), or locked by an administrator +(`AdminPolicy.search_platforms`). + ### State Machine See **FSM**. diff --git a/packages/auto_apply/docs/research_module/data_format.md b/packages/auto_apply/docs/research_module/data_format.md index 30e1244..c9c1b5d 100644 --- a/packages/auto_apply/docs/research_module/data_format.md +++ b/packages/auto_apply/docs/research_module/data_format.md @@ -89,7 +89,7 @@ accumulated data (lifecycle tracking, salary benchmarking, form analysis, application outcomes). Two more tables exist and are not yet documented column-by-column here: `detector_examinations` and `detector_outcomes` (one accounting row per detector run). `detector_examinations` carries the same -`page_copy_id` column as `research_signals`. All eleven tables are covered by research +`page_copy_id` column as `research_signals`. All twelve tables are covered by research export and by consent withdrawal's delete-all. ### Discovery tables: `discovery_pages`, `discovery_cards`, `discovery_candidates` @@ -221,6 +221,28 @@ Tracks whether submitted applications receive any acknowledgment, used by the | `acknowledgment_date` | TEXT | Date of acknowledgment, if received. | | `schema_version` | INTEGER | Schema version. | +### `search_plan_frames` + +The sampling frame of automated site-restricted search: one row per planning +pass recording which engines and platforms were searched and how many queries +each received, plus dropped/skip counts and the session budget before and +after the pass. **Names and counts only — the words searched for are never +stored, and the observation type has no field that could carry them.** + +| Column | Type | Description | +| ------ | ---- | ----------- | +| `frame_id` | TEXT PRIMARY KEY | Random surrogate ID minted at write time. | +| `planned_date` | TEXT | ISO‑8601 date of the planning pass. | +| `engines` | TEXT | JSON list of engine names planned (e.g. `["bing","google"]`). | +| `platforms` | TEXT | JSON list of platform names planned. | +| `queries_planned` | INTEGER | Queries the pass produced. | +| `per_engine` | TEXT | JSON map of engine → query count. | +| `per_platform` | TEXT | JSON map of platform → query count. | +| `dropped_count` | INTEGER | Targets dropped by the budget cap. | +| `notes_info` / `notes_warning` | INTEGER | Planner note counts by severity (never the note text). | +| `budget_before` / `budget_after` | INTEGER | Session query budget around the pass. | +| `schema_version` | INTEGER | Schema version. | + ### `research_provenance` Stores the public key of the Ed25519 key‑pair used to sign every signal. diff --git a/packages/auto_apply/docs/user_guide/building_searches.md b/packages/auto_apply/docs/user_guide/building_searches.md index c0e50f5..753ee2f 100644 --- a/packages/auto_apply/docs/user_guide/building_searches.md +++ b/packages/auto_apply/docs/user_guide/building_searches.md @@ -21,6 +21,28 @@ or one company's careers page — using only the syntax AA has verified for that site. You get exactly what the site supports, and anything it cannot express is refused or noted in plain words, never silently dropped. +## The builder and the automation + +The builder and AA's automated discovery share one catalogue of platform +knowledge, but they are different actions and they answer to different rules. + +- **The builder** composes a search *you* run yourself, in your own browser. + Because you are there to read a label, the builder shows every platform, + including unverified ones — clearly marked. +- **Automation** is AA searching on your behalf during a session + (site-restricted search across the hiring platforms you choose). Nobody is + reading a label mid-run, and every site AA queries becomes part of the + research record's sampling frame, so automation only ever targets platforms + whose restriction has been **measured by hand or documented by the + vendor**. Unverified platforms are never planned — the session wizards show + them as a count, not a choice. + +Verifying a platform by hand is exactly what moves it from the first list to +the second: follow +[Verifying Platform Search Data](../developer_guide/verifying_platform_search.md), +record the outcome in the platform's descriptor, and it becomes selectable in +the session wizards and visible in the plan preview (`--print-plan`). + ## Search-bar text versus the link Most sites give you two forms of the same search: diff --git a/packages/auto_apply/docs/user_guide/running_a_job_hunt.md b/packages/auto_apply/docs/user_guide/running_a_job_hunt.md index 8885219..c0b6d4e 100644 --- a/packages/auto_apply/docs/user_guide/running_a_job_hunt.md +++ b/packages/auto_apply/docs/user_guide/running_a_job_hunt.md @@ -126,6 +126,45 @@ shared machine. Engineers can tune every individual value through refused with a plain message naming the field and its allowed range, and `--check-config` shows the resolved values. +### Hiring platforms (automated search) + +AA can also aim its search at specific hiring platforms instead of searching +the open web. This is off by default, and it only ever uses platforms whose +search restriction has been verified — measured by hand, or documented by +the platform's vendor — so the list you can pick from is short on purpose. + +You choose platforms in three places, and the more specific choice wins: + +- **Each session**, in the wizard: tick platforms (GUI) or type a + comma-separated list (CLI). Blank means "use my saved default"; `none` + means "not this session". `python -m auto_apply --cli --platforms jobvite` + answers that question in advance; the wizard still asks the rest. +- **Your profile**, in Settings (GUI) — the saved default for every session. +- **An administrator**, on a shared machine, can lock the set. A locked + choice is shown, not offered, and an empty lock turns the feature off. + +Platforms that are not yet verified are never offered for automation. The +wizards show how many exist and, on request, why each one is waiting; the +remedy is a short hand check, described in the developer guide's +verification checklist. + +### The plan preview + +Before anything runs, you can see exactly what AA will search: + +- **GUI:** the "Preview plan…" button on the session wizard. +- **CLI:** the wizard shows the preview automatically (when the search + catalog can be loaded) and asks `Start this search? [Y/n/r]` — `r` goes + back and re-asks the search questions, `n` cancels. +- **Without starting anything:** `python -m auto_apply --print-plan` + (add `--platforms ` to preview a targeted run). + +The preview names the engines, the platforms, the actual queries and the +session budget. It is computed offline — no browser, no network — by the +same planner the session uses, so what it shows is what runs. When platform +targeting is off, it shows the generic searches the session will actually +send. + --- ## 4. Watch it run @@ -150,6 +189,18 @@ payloads never appear in front of you ([ADR-015](../adr/015_polled_ui_state.md)) None of that is your setup. +### Pacing, budgets and stopped engines + +- AA never contacts the same site twice in quick succession. A per-host + pacer spaces every repeat contact — about one to two seconds, varied — + for search engines and for hiring-platform pages alike. +- A session sends at most a fixed number of searches (20 by default). + Planned searches beyond that are skipped, and the end-of-session summary + says how many. +- If a search engine answers with a CAPTCHA or a login wall, AA stops asking + that engine anything for the rest of the session and carries on with the + others. The summary names the engine and why it was stopped. + --- ## 5. Approvals diff --git a/packages/auto_apply/docs/user_guide/understanding_output.md b/packages/auto_apply/docs/user_guide/understanding_output.md index e8a8afb..380d247 100644 --- a/packages/auto_apply/docs/user_guide/understanding_output.md +++ b/packages/auto_apply/docs/user_guide/understanding_output.md @@ -106,6 +106,36 @@ dashboard's "Export" button or the `--export` CLI flag. The report includes: This data is ideal for tracking your application pipeline over time. +### The automation outcome block + +When a session ends, both the CLI summary and the GUI results view report +how the automated search itself went — the same lines, from one shared +formatter: + +- **Search engines stopped this session** — if an engine answered AA with a + CAPTCHA or a login wall, AA stopped sending it requests for the rest of + the session. The summary names the engine and the reason; the other + engines kept running. +- **Dropped and routed results** — how many results were dropped because + they were not postings of the platform being searched, how many terminal + pages (success, confirmation or closed pages, which AA never follows) were + skipped, and how many results were routed to company-page mining when that + is enabled. +- **Budget skips** — how many planned searches were not sent because the + session's query budget ran out. + +A session with none of these prints none of these lines. + +### What each result row records + +Every discovered job is stored with its provenance, and the results CSV +shows it: which site found the job (`source`), the exact query that +produced it (`search_query`), the platform it targeted when the search was +site-restricted (`target_platform`) and the engine form used +(`engine_form`), and the page and rank where it appeared (`page_index`, +`rank`). If you ever ask "where did this job come from", those columns are +the answer. + --- ## 3. Screenshots diff --git a/packages/auto_apply/mkdocs.yml b/packages/auto_apply/mkdocs.yml index 41710d5..67da498 100644 --- a/packages/auto_apply/mkdocs.yml +++ b/packages/auto_apply/mkdocs.yml @@ -136,6 +136,7 @@ nav: - "017 Documentation gate": adr/017_documentation_gate.md - "018 Layered settings": adr/018_layered_settings_and_motion_profile.md - "019 Search surface catalog and query builder": adr/019_search_surface_catalog_and_query_builder.md + - "020 Automated site-restricted search": adr/020_automated_site_search.md - Research: - Overview: research_module/index.md diff --git a/packages/auto_apply/src/auto_apply/adapters/primary/cli/plan_screen.py b/packages/auto_apply/src/auto_apply/adapters/primary/cli/plan_screen.py new file mode 100644 index 0000000..5465321 --- /dev/null +++ b/packages/auto_apply/src/auto_apply/adapters/primary/cli/plan_screen.py @@ -0,0 +1,43 @@ +"""The --print-plan surface: show what a session would search, then exit. + +Composition stays in main.py; every print lives here (the consent-screen +precedent), so main.py's pinned print-site count does not move. Offline by +construction: no browser, no network — the preview is computed from the +session plan and the planner only. +""" + +from __future__ import annotations + +from auto_apply.application.services.i18n import configure_locale, get_text +from auto_apply.domain.models.ui_contract import ( + EntryPoint, + SessionRequest, + format_search_plan_preview, + parse_platform_names, +) +from auto_apply.infrastructure.composition_root import build_search_plan_preview + +__all__ = ["run_print_plan"] + + +def run_print_plan(args, profile_repo) -> int: + """Render the plan preview for the given flags. Returns the exit code.""" + configure_locale(language=None) + profile = None + try: + names = [n for n in profile_repo.list_profiles() if n != "default_profile"] + chosen = names[0] if names else "default_profile" + profile = profile_repo.load_profile(chosen) + except Exception: + profile = None + if profile is None: + print(get_text("searchplan.cli.no_profile")) # noqa: T201 + return 1 + request = SessionRequest( + entry=EntryPoint.SEARCH, + platforms=parse_platform_names(getattr(args, "platforms", None)), + ) + preview = build_search_plan_preview(profile, request) + for line in format_search_plan_preview(preview, translate=get_text): + print(line) # noqa: T201 + return 0 diff --git a/packages/auto_apply/src/auto_apply/adapters/primary/cli/startup.py b/packages/auto_apply/src/auto_apply/adapters/primary/cli/startup.py index 47db125..ab79bfa 100644 --- a/packages/auto_apply/src/auto_apply/adapters/primary/cli/startup.py +++ b/packages/auto_apply/src/auto_apply/adapters/primary/cli/startup.py @@ -87,7 +87,13 @@ apply_autonomy, autonomy_enabled_for_profile, ) -from auto_apply.infrastructure.composition_root import build_session_controller +from auto_apply.application.services.i18n import configure_locale, get_text +from auto_apply.domain.models.ui_contract import format_discovery_outcome_lines +from auto_apply.infrastructure.composition_root import ( + build_query_builder, + build_search_plan_preview, + build_session_controller, +) from auto_apply.domain.models.profile import UserProfile, make_portable_path from auto_apply.domain.ports.profile_repository_port import ProfileRepositoryPort @@ -114,9 +120,11 @@ def __init__( self, profile_repo_factory: Callable[..., ProfileRepositoryPort], profile_override: str | None = None, + platforms_override: tuple[str, ...] | None = None, ) -> None: self._repo_factory = profile_repo_factory self._profile_override = profile_override + self._platforms_override = platforms_override self.repo: ProfileRepositoryPort = profile_repo_factory() def run(self) -> None: @@ -149,7 +157,31 @@ def run(self) -> None: print(" Autonomy: OFF — AutoApply will ask before each submission.") # noqa: T201 # 2. Session Configuration - wizard = CLIWizard() + # The platform vocabulary and the plan preview factory are built once + # here and handed to the wizard. If the registry or catalog cannot be + # loaded, both degrade to nothing and the wizard runs a normal session. + configure_locale(language=None) + platforms_vocabulary: tuple = () + preview_factory = None + try: + platforms_vocabulary = ( + build_query_builder(profile).automation_platform_vocabulary() + ) + + def preview_factory(request): # noqa: E731 + return build_search_plan_preview(profile, request) + + except Exception as exc: + logger.warning( + "CLI startup: platform vocabulary/preview unavailable: %s", exc + ) + + wizard = CLIWizard( + profile=profile, + platforms_vocabulary=platforms_vocabulary, + preview_factory=preview_factory, + platforms_override=self._platforms_override, + ) session_config = wizard.run() # The wizard returns a typed SessionRequest (stage U3); None means the @@ -756,6 +788,11 @@ def _print_results(self, controller: "SessionController") -> None: for line in summary.research.lines: print(f" {line}") # noqa: T201 + # Automation outcome (blocked engines, off-shape counts, budget + # skips): the same lines the GUI results window shows. + for line in format_discovery_outcome_lines(summary, translate=get_text): + print(f" {line}") # noqa: T201 + if gate_remedy: print(f"\n {gate_remedy}") # noqa: T201 diff --git a/packages/auto_apply/src/auto_apply/adapters/primary/cli/wizard.py b/packages/auto_apply/src/auto_apply/adapters/primary/cli/wizard.py index 420dcf5..d81cf54 100644 --- a/packages/auto_apply/src/auto_apply/adapters/primary/cli/wizard.py +++ b/packages/auto_apply/src/auto_apply/adapters/primary/cli/wizard.py @@ -70,12 +70,15 @@ from auto_apply.application.services.session_controller import ( _LEGACY_ENTRY_TO_EXECUTION_MODE, ) +from auto_apply.application.services.i18n import get_text from auto_apply.application.services.ui_schema import UIField, build_ui_schema from auto_apply.domain.models.profile import UserProfile from auto_apply.domain.models.session_plan import SessionExecutionMode +from auto_apply.domain.models.search_query import AutomationPlatformOffer from auto_apply.domain.models.ui_contract import ( EntryPoint, SessionRequest, + format_search_plan_preview, outcome_options_for_entry, provider_vocabulary, ) @@ -92,8 +95,17 @@ class CLIWizard: populated from the user's saved search preferences. """ - def __init__(self, profile: UserProfile | None = None) -> None: + def __init__( + self, + profile: UserProfile | None = None, + platforms_vocabulary: tuple[AutomationPlatformOffer, ...] = (), + preview_factory=None, + platforms_override: tuple[str, ...] | None = None, + ) -> None: self._profile = profile + self._platforms_vocabulary = platforms_vocabulary + self._preview_factory = preview_factory + self._platforms_override = platforms_override try: self._ui_schema: list[UIField] = build_ui_schema(UserProfile, "en") except Exception: @@ -126,17 +138,28 @@ def run(self) -> SessionRequest | None: urls=urls, ) - execution_mode = self._ask_outcome_for_search() - providers = self._ask_providers() - keywords, location, max_results = self._fill_discovery_params() - return SessionRequest( - entry=EntryPoint.SEARCH, - execution_mode=execution_mode, - keywords=keywords, - location=location, - max_results=max_results, - providers=providers, - ) + while True: + execution_mode = self._ask_outcome_for_search() + providers = self._ask_providers() + platforms = self._ask_platforms() + keywords, location, max_results = self._fill_discovery_params() + request = SessionRequest( + entry=EntryPoint.SEARCH, + execution_mode=execution_mode, + keywords=keywords, + location=location, + max_results=max_results, + providers=providers, + platforms=platforms, + ) + if self._preview_factory is None: + return request + verdict = self._show_preview(request) + if verdict == "start": + return request + if verdict == "cancel": + return None + # "revise" — ask everything again. # ───────────────────────────────────────────────────────────────────────── # Exit axis (per entry point — see the module docstring for the ruling) @@ -255,6 +278,93 @@ def _fill_discovery_params(self) -> tuple[tuple[str, ...], str, int]: keywords = tuple(t.strip() for t in titles_raw.split(",") if t.strip()) return keywords, location, max_results + def _ask_platforms(self) -> tuple[str, ...] | None: + """Asks which hiring platforms to target. None = inherit the default. + + Only platforms whose search restriction is measured or vendor- + documented are offered — anything else would be planned into queries + that do not work. The rest are counted, and listed on request (the + ``v`` answer) with their reasons. Unknown names are dropped with a + printed note, never silently. "none" disables targeting this session. + """ + if self._platforms_override is not None: + shown = ", ".join(self._platforms_override) or get_text( + "searchplan.pick.default_off" + ) + print( # noqa: T201 + f" {get_text('searchplan.cli.override_note').format(platforms=shown)}" + ) + return self._platforms_override + vocabulary = self._platforms_vocabulary + if not vocabulary: + return None + eligible = [offer for offer in vocabulary if offer.eligible] + ineligible = [offer for offer in vocabulary if not offer.eligible] + if not eligible: + print(f" {get_text('searchplan.pick.none_eligible')}") # noqa: T201 + return None + saved = tuple( + getattr( + getattr(self._profile, "app_config", None), "search_platforms", None + ) + or () + ) + hint = ", ".join(saved) if saved else get_text("searchplan.pick.default_off") + print(f" {get_text('searchplan.pick.prompt')}") # noqa: T201 + print(f" {', '.join(offer.name for offer in eligible)}") # noqa: T201 + if ineligible: + print( # noqa: T201 + f" {get_text('searchplan.pick.unverified_count').format(count=len(ineligible))}" + ) + while True: + raw = input(f" {get_text('searchplan.pick.choose')} [{hint}]: ").strip().lower() + if not raw: + return None + if raw in ("none", "off"): + return () + if raw == "v": + for offer in ineligible: + print(f" {offer.name} — {offer.reason}") # noqa: T201 + continue + names = {offer.name for offer in eligible} + chosen: list[str] = [] + dropped: list[str] = [] + for part in raw.split(","): + name = part.strip() + if not name: + continue + if name in names: + chosen.append(name) + else: + dropped.append(name) + for name in dropped: + print( # noqa: T201 + f" {get_text('searchplan.pick.unknown').format(name=name)}" + ) + return tuple(chosen) + + def _show_preview(self, request: SessionRequest) -> str: + """Prints the plan preview and asks whether to start. + + Returns "start", "revise" or "cancel". A preview failure is a note, + never a crash — the session can still start. + """ + try: + preview = self._preview_factory(request) + print() # noqa: T201 + for line in format_search_plan_preview(preview, translate=get_text): + print(f" {line}") # noqa: T201 + except Exception as exc: # noqa: BLE001 + print( # noqa: T201 + f" {get_text('searchplan.preview.unavailable').format(reason=exc)}" + ) + raw = input(f" {get_text('searchplan.preview.confirm')} ").strip().lower() + if raw in ("n", "no"): + return "cancel" + if raw in ("r", "revise"): + return "revise" + return "start" + def _schema_label(self, key: str, fallback: str) -> str: """Returns the i18n-resolved label for *key*, or *fallback* on miss.""" field = next((f for f in self._ui_schema if f.key == key), None) diff --git a/packages/auto_apply/src/auto_apply/adapters/primary/gui/app.py b/packages/auto_apply/src/auto_apply/adapters/primary/gui/app.py index 02d1af1..53fd6c4 100644 --- a/packages/auto_apply/src/auto_apply/adapters/primary/gui/app.py +++ b/packages/auto_apply/src/auto_apply/adapters/primary/gui/app.py @@ -96,15 +96,18 @@ from auto_apply.application.services.profile_validator import TEMPLATE_PROFILE_NAME from auto_apply.application.services.ui_schema import UIField, build_ui_schema from auto_apply.domain.models.profile import UserProfile, make_portable_path +from auto_apply.adapters.primary.gui.strings import locale_text from auto_apply.domain.models.ui_contract import ( SessionHistoryEntry, SessionRequest, SessionSummary, + format_discovery_outcome_lines, ) from auto_apply.domain.ports.profile_repository_port import ProfileRepositoryPort from auto_apply.infrastructure.composition_root import ( build_query_builder, build_research_consent, + build_search_plan_preview, ) if TYPE_CHECKING: @@ -224,6 +227,13 @@ def format_results_lines(summary: SessionSummary, limit: int = 200) -> list[str] ) # Research accounting (item 3): the same lines the CLI prints. lines.extend(summary.research.lines) + # Automation outcome (blocked engines, off-shape counts, budget skips): + # the same lines the CLI prints, from one shared formatter. + lines.extend( + format_discovery_outcome_lines( + summary, translate=lambda key: locale_text(key) + ) + ) lines.append("") if summary.discovered: lines.append("Discovered jobs:") @@ -729,7 +739,22 @@ def _on_save(): self.profile = self.registry.get_active_profile() logger.info("Settings saved — registry rebuilt") - SettingsEditor(self, registry=self.registry, on_save=_on_save, profile_repo=self._repo, on_research=self._open_research) + vocabulary: tuple = () + try: + if self.registry is not None and self.profile is not None: + vocabulary = ( + build_query_builder(self.profile).automation_platform_vocabulary() + ) + except Exception as exc: # noqa: BLE001 — the editor must still open + logger.warning("Settings: platform vocabulary unavailable: %s", exc) + SettingsEditor( + self, + registry=self.registry, + on_save=_on_save, + profile_repo=self._repo, + on_research=self._open_research, + platforms_vocabulary=vocabulary, + ) def _open_research(self) -> None: """Opens the research-consent window (File -> Research… and the @@ -950,12 +975,39 @@ def _show_session_config(self) -> None: offers the control before every Start (stage E1). """ self._clear_view() + platforms_vocabulary: tuple = () + preview_factory = None + locked_platforms = None + try: + policy = self.registry.get_admin_policy() if self.registry else None + locked = getattr(policy, "search_platforms", None) if policy else None + if locked is not None: + locked_platforms = tuple(locked) + else: + platforms_vocabulary = ( + build_query_builder(self.profile).automation_platform_vocabulary() + ) + registry = self.registry + profile = self.profile + + def preview_factory(request): # noqa: E731 + return build_search_plan_preview( + profile, request, registry=registry + ) + + except Exception as exc: + logger.warning( + "Session wizard: platform vocabulary/preview unavailable: %s", exc + ) wizard = GUIWizard( parent=self._main_container, on_complete=self._on_session_start, profile=self.profile, profile_repo=self._repo, controller=self.controller, + platforms_vocabulary=platforms_vocabulary, + preview_factory=preview_factory, + locked_platforms=locked_platforms, ) wizard.pack(fill=tk.BOTH, expand=True) diff --git a/packages/auto_apply/src/auto_apply/adapters/primary/gui/settings_editor.py b/packages/auto_apply/src/auto_apply/adapters/primary/gui/settings_editor.py index 584c11e..056ce1a 100644 --- a/packages/auto_apply/src/auto_apply/adapters/primary/gui/settings_editor.py +++ b/packages/auto_apply/src/auto_apply/adapters/primary/gui/settings_editor.py @@ -145,6 +145,7 @@ def __init__( on_save: Callable[[], None], profile_repo: ProfileRepositoryPort, on_research: "Callable[[], None] | None" = None, + platforms_vocabulary: tuple = (), ) -> None: super().__init__(parent) self.registry = registry @@ -154,6 +155,8 @@ def __init__( self.on_save_callback = on_save self.strings = get_strings() self.repo = profile_repo + self._platforms_vocabulary = tuple(platforms_vocabulary) + self._platform_vars: dict[str, tk.BooleanVar] = {} self.title(f"Settings - {self.profile.profile_name}") self.geometry("700x650") @@ -510,6 +513,43 @@ def _build_search_tab(self, notebook: ttk.Notebook) -> None: opt in current_et, ) + # Hiring platforms for automated site-restricted search — the + # persistent default the session wizard can override, admin-lockable + # like the other safety settings (the layered-settings pattern). + platforms_locked = self.admin_policy.is_field_locked("search_platforms") + ttk.Label(frame, text=self.strings["platforms_settings_label"]).pack( + anchor=tk.W, pady=(15, 5) + ) + if platforms_locked: + locked = self.admin_policy.search_platforms or [] + self._add_note( + frame, + self.strings["platforms_settings_locked"].format( + platforms=", ".join(locked) or "—" + ), + ) + elif not self._platforms_vocabulary: + self._add_note(frame, self.strings["platforms_unavailable"]) + else: + current = set(self.profile.app_config.search_platforms or []) + for offer in self._platforms_vocabulary: + label = offer.name + ( + f" {self.strings['platforms_unverified_suffix']}" + if not offer.eligible + else "" + ) + var = tk.BooleanVar(value=offer.name in current) + chk = ttk.Checkbutton( + frame, + text=label, + variable=var, + state=tk.NORMAL if offer.eligible else tk.DISABLED, + ) + chk.pack(anchor=tk.W, pady=2) + if offer.eligible: + self._platform_vars[offer.name] = var + self._add_note(frame, self.strings["platforms_settings_note"]) + def _build_safety_tab(self, notebook: ttk.Notebook) -> None: """Tab: Rate Limiting & Safety. @@ -763,6 +803,19 @@ def _save_changes(self) -> None: and bool(self._vars[f"et_{et}"].get()) ] + # Hiring platforms — skipped entirely when admin-locked, so the + # locked value is never written back over the user's setting, and + # skipped when unchanged, so "inherit" (None) is never clobbered. + if self._platform_vars and not self.admin_policy.is_field_locked( + "search_platforms" + ): + checked = [ + name for name, var in self._platform_vars.items() if var.get() + ] + baseline = list(self.profile.app_config.search_platforms or []) + if set(checked) != set(baseline): + self.profile.app_config.search_platforms = checked + # -- Documents tab --------------------------------------------- # Store portable strings, never raw Path objects: relative to # PROFILES_DIR when the file lives under it (USB-drive portable), diff --git a/packages/auto_apply/src/auto_apply/adapters/primary/gui/strings.py b/packages/auto_apply/src/auto_apply/adapters/primary/gui/strings.py index b681802..bccc54d 100644 --- a/packages/auto_apply/src/auto_apply/adapters/primary/gui/strings.py +++ b/packages/auto_apply/src/auto_apply/adapters/primary/gui/strings.py @@ -152,4 +152,17 @@ def get_strings(lang_code: str | None = None) -> dict[str, str]: "builder.status.recent_unavailable": _tr("builder.status.recent_unavailable", "That search used a site that is not available here."), "builder.result.bar": _tr("builder.result.bar", "Search bar"), "builder.result.link": _tr("builder.result.link", "Link"), + + # --- Automated site-restricted search (picker, preview, settings) --- + "platforms_frame": _tr("searchplan.gui.frame", "Hiring platforms (automatic search)"), + "platforms_locked": _tr("searchplan.gui.locked", "Locked by your administrator: {platforms}"), + "platforms_unavailable": _tr("searchplan.gui.unavailable", "Platform targeting unavailable (platform registry not loaded)"), + "platforms_unverified_count": _tr("searchplan.gui.unverified_count", "{count} more platforms are not yet verified for automated search."), + "platforms_unverified_suffix": _tr("searchplan.gui.unverified_suffix", "(not yet verified)"), + "platforms_none": _tr("searchplan.gui.none", "none"), + "platforms_preview_button": _tr("searchplan.gui.preview_button", "Preview plan…"), + "platforms_preview_title": _tr("searchplan.gui.preview_title", "Planned searches"), + "platforms_settings_label": _tr("searchplan.gui.settings_label", "Hiring platforms for automatic search:"), + "platforms_settings_locked": _tr("searchplan.gui.settings_locked", "Locked by your administrator: {platforms}"), + "platforms_settings_note": _tr("searchplan.gui.settings_note", "AA searches these platforms' postings directly. Only verified platforms can be chosen."), } \ No newline at end of file diff --git a/packages/auto_apply/src/auto_apply/adapters/primary/gui/wizard.py b/packages/auto_apply/src/auto_apply/adapters/primary/gui/wizard.py index 8485466..efada28 100644 --- a/packages/auto_apply/src/auto_apply/adapters/primary/gui/wizard.py +++ b/packages/auto_apply/src/auto_apply/adapters/primary/gui/wizard.py @@ -43,16 +43,19 @@ import tkinter as tk from collections.abc import Callable from tkinter import messagebox, ttk +from tkinter.scrolledtext import ScrolledText from auto_apply.application.services.autonomy import ( apply_autonomy, autonomy_enabled_for_profile, ) +from auto_apply.adapters.primary.gui.strings import get_strings, locale_text from auto_apply.domain.models.session_plan import SessionExecutionMode from auto_apply.domain.models.ui_contract import ( ENTRY_POINT_LABELS, EntryPoint, SessionRequest, + format_search_plan_preview, outcome_options_for_entry, provider_vocabulary, ) @@ -69,6 +72,7 @@ def build_session_request( max_results: int | None = None, urls_text: str = "", providers: tuple[str, ...] = (), + platforms: tuple[str, ...] | None = None, ) -> SessionRequest: """Assembles the SessionRequest from wizard field values. @@ -92,6 +96,7 @@ def build_session_request( location=location.strip(), max_results=max_results, providers=providers, + platforms=platforms, ) return SessionRequest( entry=entry, @@ -128,12 +133,24 @@ def __init__( profile=None, profile_repo=None, controller=None, + platforms_vocabulary=(), + preview_factory=None, + locked_platforms=None, ) -> None: super().__init__(parent) self.on_complete = on_complete self._profile = profile self._profile_repo = profile_repo self._controller = controller + self._platforms_vocabulary = tuple(platforms_vocabulary) + self._preview_factory = preview_factory + self._locked_platforms = locked_platforms + self._platform_vars: dict[str, tk.BooleanVar] = {} + self._profile_platforms: tuple[str, ...] = tuple( + getattr(getattr(profile, "app_config", None), "search_platforms", None) + or () + ) + self.strings = get_strings() self._entry_var = tk.StringVar(value=EntryPoint.SEARCH.name) self._outcome_var = tk.IntVar(value=0) @@ -221,6 +238,53 @@ def _build_layout(self) -> None: ttk.Label(self._criteria_box, text="Job sites:").pack(anchor=tk.W) for name, var in self._provider_vars.items(): ttk.Checkbutton(self._criteria_box, text=name, variable=var).pack(anchor=tk.W) + + # Hiring platforms for automated site-restricted search. Only + # platforms whose restriction is measured or vendor-documented are + # selectable; the rest are a count note. An admin lock replaces the + # picker with the locked set; an empty vocabulary (registry + # unavailable) degrades to a note — the session still starts. + ttk.Label( + self._criteria_box, text=self.strings["platforms_frame"] + ).pack(anchor=tk.W, pady=(8, 0)) + if self._locked_platforms is not None: + locked_text = ", ".join(self._locked_platforms) or self.strings[ + "platforms_none" + ] + ttk.Label( + self._criteria_box, + text=self.strings["platforms_locked"].format(platforms=locked_text), + foreground="#B8860B", + wraplength=280, + ).pack(anchor=tk.W) + elif not self._platforms_vocabulary: + ttk.Label( + self._criteria_box, + text=self.strings["platforms_unavailable"], + foreground="gray", + wraplength=280, + ).pack(anchor=tk.W) + else: + ineligible = 0 + for offer in self._platforms_vocabulary: + if offer.eligible: + var = tk.BooleanVar(value=offer.name in self._profile_platforms) + self._platform_vars[offer.name] = var + ttk.Checkbutton( + self._criteria_box, text=offer.name, variable=var + ).pack(anchor=tk.W) + else: + ineligible += 1 + if ineligible: + ttk.Label( + self._criteria_box, + text=self.strings["platforms_unverified_count"].format( + count=ineligible + ), + foreground="gray", + wraplength=280, + ).pack(anchor=tk.W) + ttk.Label( self._criteria_box, text="Leave titles and location blank to use your profile's defaults.", @@ -237,7 +301,17 @@ def _build_layout(self) -> None: ttk.Label( outer, textvariable=self._status_var, foreground="#B8860B" ).pack(anchor=tk.W, pady=(10, 2)) - ttk.Button(outer, text="Start Session 🚀", command=self._finish).pack(anchor=tk.W) + buttons = ttk.Frame(outer) + buttons.pack(anchor=tk.W) + if self._preview_factory is not None: + ttk.Button( + buttons, + text=self.strings["platforms_preview_button"], + command=self._show_preview, + ).pack(side=tk.LEFT, padx=(0, 8)) + ttk.Button( + buttons, text="Start Session 🚀", command=self._finish + ).pack(side=tk.LEFT) def _render_for_entry(self) -> None: entry = EntryPoint[self._entry_var.get()] @@ -326,7 +400,23 @@ def _apply_autonomy(self, enabled: bool, acknowledgements: tuple[str, ...]) -> N # ── finish ──────────────────────────────────────────────────────────────── - def _finish(self) -> None: + def _chosen_platforms(self, entry: EntryPoint) -> tuple[str, ...] | None: + """The request's platform tri-state from the picker. + + Unchanged from the profile default means "not declared" (None), so a + user who touches nothing inherits; any change — including unchecking + everything — is explicit. + """ + if entry is not EntryPoint.SEARCH or not self._platform_vars: + return None + checked = tuple(n for n, v in self._platform_vars.items() if v.get()) + if checked == self._profile_platforms: + return None + return checked + + def _current_request(self) -> SessionRequest: + """The request the current field values describe — the one assembly + shared by Start and the preview, so what is previewed is what runs.""" entry = EntryPoint[self._entry_var.get()] options = outcome_options_for_entry(entry) idx = self._outcome_var.get() @@ -336,7 +426,7 @@ def _finish(self) -> None: if entry is EntryPoint.SEARCH else () ) - request = build_session_request( + return build_session_request( entry=entry, execution_mode=mode, keywords_text=self._keywords_var.get(), @@ -344,7 +434,37 @@ def _finish(self) -> None: max_results=self._max_var.get() if entry is EntryPoint.SEARCH else None, urls_text=self._links_text.get("1.0", "end-1c"), providers=providers, + platforms=self._chosen_platforms(entry), ) + + def _show_preview(self) -> None: + """Shows the planned searches in a modal before Start.""" + try: + preview = self._preview_factory(self._current_request()) + except Exception as exc: # noqa: BLE001 — a preview must never break Start + self._status_var.set(f"Preview unavailable: {exc}") + return + lines = format_search_plan_preview( + preview, + translate=lambda key: self.strings.get(key) or locale_text(key), + ) + win = tk.Toplevel(self) + win.title(self.strings["platforms_preview_title"]) + win.geometry("640x480") + win.transient(self) # type: ignore[call-overload] + viewer = ScrolledText( + win, state="disabled", wrap=tk.WORD, font=("Consolas", 9) + ) + viewer.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + viewer.configure(state="normal") + viewer.insert(tk.END, "\n".join(lines)) + viewer.configure(state="disabled") + ttk.Button( + win, text=self.strings.get("btn_close", "Close"), command=win.destroy + ).pack(pady=(0, 10)) + + def _finish(self) -> None: + request = self._current_request() # URL entries with no links are the GUI's "nothing to do" case — the # CLI returns None there. Block Start rather than begin a zero-task # session that idles forever (the old GUI's failure mode). diff --git a/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/providers/base_provider.py b/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/providers/base_provider.py index 7c4fd32..135732f 100644 --- a/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/providers/base_provider.py +++ b/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/providers/base_provider.py @@ -6,6 +6,7 @@ underlying complexity. """ +import logging from abc import abstractmethod from auto_apply.domain.models.job import Job @@ -13,6 +14,8 @@ from auto_apply.domain.ports.browser_port import BrowserInterface from auto_apply.domain.ports.discovery_port import DiscoveryProviderPort +logger = logging.getLogger(__name__) + class BaseSearchProvider(DiscoveryProviderPort): """The abstract contract for a specific job board provider (e.g., Google). @@ -31,6 +34,7 @@ def __init__( observer=None, reporter=None, forced_tier=None, + host_pacer=None, ) -> None: """Initializes the provider with the browser and its page collaborators. @@ -57,6 +61,47 @@ def __init__( self._observer = observer self._reporter = reporter self._forced_tier = forced_tier + self._host_pacer = host_pacer + + def _baseline_scope_for(self, instruction) -> str: + """"#site" for planner-produced (platform-targeted) instructions, else "".""" + return "#site" if getattr(instruction, "platform", None) else "" + + def _session_guard_blocks(self, instruction) -> bool: + """True when this engine was benched this session — no request is sent. + + Checks the base name (a challenged host challenges every query shape) + and the instruction's scope (a degraded site-restricted path stops + only site-restricted queries). This check runs BEFORE navigation. + """ + detector = getattr(self, "_degradation_detector", None) + if detector is None: + return False + name = self.name + if detector.is_benched(name) or detector.is_benched( + name + self._baseline_scope_for(instruction) + ): + logger.warning( + "%s: benched for this session — skipping without navigating | title=%r", + name, + getattr(instruction, "title", ""), + ) + return True + return False + + def _pace_for(self, instruction) -> None: + """Per-host pre-navigation pacing; a no-op when no pacer is wired.""" + pacer = self._host_pacer + if pacer is None: + return + strategy = getattr(self, "_engine_strategy", None) + if strategy is None: + return + try: + url = strategy.build_search_url(instruction) + except Exception: + return + pacer.wait_for_url(url) @property def name(self) -> str: @@ -86,26 +131,12 @@ def run(self, instruction: SearchInstruction) -> list[Job]: ... def safe_navigate(self, url: str) -> bool: - """Navigates to a URL with built-in safety checks. - - Enforces rate limiting and page-safety validation when the relevant - services are available. Degrades gracefully when they are not — - the navigation itself still proceeds, but without rate-limit - enforcement or CAPTCHA detection. This matches the project's - worst‑case‑first deployment philosophy. - - Graceful degradation contract: - - If rate‑limit enforcement is unavailable, checks are skipped. - - If evasion checking is unavailable, page‑safety checks are skipped. + """Navigates to a URL, returning False instead of raising on failure. - Args: - url: The target URL. - - Returns: - True if navigation succeeded and the page passed safety checks - (or checks were skipped due to unavailable services). - False if rate limiting blocked the request, navigation raised - an exception, or the safety check flagged the page. + This method performs no rate limiting and no page-safety validation. + Per-host pacing lives in ``_pace_for`` (wired from the composition + root); page-safety verdicts live in the one page verdict used by the + SERP block gate and the provider health checks. """ # ── 1. Navigate ─────────────────────────────────────────────────── try: diff --git a/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/providers/bing.py b/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/providers/bing.py index 25d3aa0..a559797 100644 --- a/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/providers/bing.py +++ b/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/providers/bing.py @@ -59,6 +59,7 @@ def __init__( research_observer=None, readiness=None, page_action=None, + host_pacer=None, ) -> None: super().__init__( browser, @@ -68,6 +69,7 @@ def __init__( observer, reporter, forced_tier, + host_pacer, ) self._degradation_detector = degradation_detector self._page_understanding = page_understanding_port @@ -133,6 +135,11 @@ def run(self, instruction: SearchInstruction) -> list[Job]: instruction.date_range or "none", ) + if self._session_guard_blocks(instruction): + return [] + + self._pace_for(instruction) + if not self.navigator.navigate_with_fallback( self._engine_strategy, instruction, self._is_page_healthy ): @@ -168,6 +175,7 @@ def run(self, instruction: SearchInstruction) -> list[Job]: forced_tier=self._forced_tier, research_observer=self._research_observer, page_action=self._page_action, + baseline_scope=self._baseline_scope_for(instruction), title_parser=SmartTextExtractor( strategies=[ "h2", diff --git a/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/providers/google.py b/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/providers/google.py index 286aab9..617c154 100644 --- a/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/providers/google.py +++ b/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/providers/google.py @@ -64,6 +64,7 @@ def __init__( research_observer=None, readiness=None, page_action=None, + host_pacer=None, ) -> None: super().__init__( browser, @@ -73,6 +74,7 @@ def __init__( observer, reporter, forced_tier, + host_pacer, ) self._degradation_detector = degradation_detector # Optional ATSRegistry: when provided, find_company_career_page() builds @@ -146,6 +148,11 @@ def run(self, instruction: SearchInstruction) -> list[Job]: instruction.date_range or "none", ) + if self._session_guard_blocks(instruction): + return [] + + self._pace_for(instruction) + if not self.navigator.navigate_with_fallback( self._engine_strategy, instruction, self._is_page_healthy ): @@ -181,6 +188,7 @@ def run(self, instruction: SearchInstruction) -> list[Job]: forced_tier=self._forced_tier, research_observer=self._research_observer, page_action=self._page_action, + baseline_scope=self._baseline_scope_for(instruction), title_parser=SmartTextExtractor( strategies=[ "div[role='heading']", @@ -231,6 +239,9 @@ def find_company_career_page(self, company_name: str) -> str | None: logger.info("GoogleProvider: Hunting for ATS link: %s", search_url) + if self._host_pacer is not None: + self._host_pacer.wait_for_url(search_url) + try: self.browser.get(search_url) diff --git a/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/providers/indeed.py b/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/providers/indeed.py index 3acefc8..a2dbc02 100644 --- a/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/providers/indeed.py +++ b/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/providers/indeed.py @@ -62,6 +62,7 @@ def __init__( research_observer=None, readiness=None, page_action=None, + host_pacer=None, ) -> None: super().__init__( browser, @@ -71,6 +72,7 @@ def __init__( observer, reporter, forced_tier, + host_pacer, ) self._degradation_detector = degradation_detector self._page_understanding = page_understanding_port @@ -150,6 +152,11 @@ def run(self, instruction: SearchInstruction) -> list[Job]: instruction.date_range or "none", ) + if self._session_guard_blocks(instruction): + return [] + + self._pace_for(instruction) + self._blocked_observation_emitted = False if not self.navigator.navigate_with_fallback( self._engine_strategy, instruction, self._is_page_healthy @@ -183,6 +190,7 @@ def run(self, instruction: SearchInstruction) -> list[Job]: forced_tier=self._forced_tier, degradation_detector=self._degradation_detector, research_observer=self._research_observer, + baseline_scope=self._baseline_scope_for(instruction), title_parser=SmartTextExtractor( strategies=[ "h2.jobTitle", @@ -233,4 +241,13 @@ def _is_page_healthy(self) -> bool: research_observer=self._research_observer, page_type=assessment.kind, ) + # A blocked health check benches this engine for the session: the + # next instruction is refused before any navigation happens. + if self._degradation_detector is not None: + try: + self._degradation_detector.mark_blocked( + self.name, assessment.kind.name + ) + except Exception: + pass return False diff --git a/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/strategies/engine_strategies.py b/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/strategies/engine_strategies.py index 20e3d56..58832a5 100644 --- a/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/strategies/engine_strategies.py +++ b/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/strategies/engine_strategies.py @@ -344,6 +344,7 @@ def build_search_url(self, instruction: SearchInstruction) -> str: query=instruction.effective_query, location=instruction.location, date_range=instruction.date_range, + template=instruction.surface_template, ) # ── Legacy hardcoded selectors (used when no locator is injected) ──── @@ -442,6 +443,7 @@ def build_search_url(self, instruction: SearchInstruction) -> str: query=instruction.effective_query, location=instruction.location, date_range=instruction.date_range, + template=instruction.surface_template, ) # ── Legacy hardcoded selectors ────────────────────────────────────── @@ -526,6 +528,7 @@ def build_search_url(self, instruction: SearchInstruction) -> str: query=instruction.effective_query, location=instruction.location, date_range=instruction.date_range, + template=instruction.surface_template, ) # ── Legacy hardcoded selectors ────────────────────────────────────── diff --git a/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/strategies/serp_strategy.py b/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/strategies/serp_strategy.py index 17fe0a9..58893b0 100644 --- a/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/strategies/serp_strategy.py +++ b/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/strategies/serp_strategy.py @@ -139,6 +139,7 @@ def __init__( degradation_detector=None, research_observer=None, page_action=None, + baseline_scope: str = "", ): """Initializes the strategy with specific parsing tools. @@ -183,6 +184,11 @@ def __init__( self._degradation_detector = degradation_detector self._research_observer = research_observer or NullResearchObserver() self._page_action = page_action + # Degradation-baseline scope: "#site" for planner-produced + # (platform-targeted) harvests, "" for generic ones — a sparse + # site-restricted harvest is judged against its own kind and never + # moves the engine's generic baseline. + self._baseline_scope = baseline_scope self.title_parser = title_parser or SmartTextExtractor() self.company_parser = company_parser or SmartTextExtractor(strategies=["div.company", "span.company", "a.company"]) @@ -241,6 +247,16 @@ def _abort_blocked(self, page_type: PageType, context: str) -> list[Job]: page_type.name, ) self._emit_blocked_observation(page_type) + # A challenge from this engine's host stops the engine for the rest + # of the session. The next instruction checks before navigating, so + # no further request is sent; the others continue. + if self._degradation_detector is not None: + try: + self._degradation_detector.mark_blocked( + self.source_tag, page_type.name + ) + except Exception: + pass return [] def _emit_blocked_observation(self, page_type: PageType) -> None: @@ -446,7 +462,11 @@ def _is_provider_benched(self) -> bool: """True if the silent-degradation guard benched this provider.""" if self._degradation_detector is None: return False - benched = self._degradation_detector.is_benched(self.source_tag) + benched = self._degradation_detector.is_benched( + self.source_tag + ) or self._degradation_detector.is_benched( + self.source_tag + self._baseline_scope + ) if benched: logger.warning( "%s: provider is benched this session (silent-degradation " @@ -469,13 +489,15 @@ def _evaluate_first_harvest(self, visible: list[Job], elapsed_s: float) -> bool: except Exception: page_bytes = 0 self._degradation_detector.evaluate_first_harvest( - provider=self.source_tag, + provider=self.source_tag + self._baseline_scope, visible_count=len(visible), page_bytes=page_bytes, elapsed_seconds=elapsed_s, route=self._extractor_label(), ) - if self._degradation_detector.is_benched(self.source_tag): + if self._degradation_detector.is_benched( + self.source_tag + self._baseline_scope + ): logger.warning( "%s: harvest discarded — %d record(s) in %.1fs over %d bytes " "collapsed versus this provider's baseline. Treated as " diff --git a/packages/auto_apply/src/auto_apply/adapters/secondary/persistence/database.py b/packages/auto_apply/src/auto_apply/adapters/secondary/persistence/database.py index 96250b2..e95c3ee 100644 --- a/packages/auto_apply/src/auto_apply/adapters/secondary/persistence/database.py +++ b/packages/auto_apply/src/auto_apply/adapters/secondary/persistence/database.py @@ -57,6 +57,17 @@ REACTIVE_TASK_TYPES: tuple[str, ...] = (TaskType.HANDLE_CAPTCHA.value,) +def _row_metadata(raw: Any) -> dict: + """Parse a stored metadata_json value. Absent or corrupt reads as {}.""" + if not raw: + return {} + try: + data = json.loads(raw) + except (TypeError, ValueError): + return {} + return data if isinstance(data, dict) else {} + + class DatabaseManager(WorkQueuePort): """Singleton manager for SQLite database interactions. @@ -187,6 +198,17 @@ def _init_schema(self) -> None: ) """) + # ── Migration: add metadata_json if upgrading from older schema ── + try: + conn.execute("SELECT metadata_json FROM job_history LIMIT 1") + except sqlite3.OperationalError: + conn.execute( + "ALTER TABLE job_history ADD COLUMN metadata_json TEXT" + ) + logger.info( + "DatabaseManager: added metadata_json column to job_history" + ) + # 3. Applied Jobs — permanent application log; NEVER cleared. conn.execute(""" CREATE TABLE IF NOT EXISTS applied_jobs ( @@ -754,16 +776,19 @@ def record_job_discovery(self, job_obj: Any, session_id: str | None = None) -> b title = getattr(job_obj, "title", "Unknown") url_hash = hashlib.sha256(url.encode()).hexdigest()[:32] now = datetime.now(timezone.utc).isoformat() + metadata_json = json.dumps( + getattr(job_obj, "metadata", None) or {}, default=str, sort_keys=True + ) try: with self.get_connection() as conn: cursor = conn.execute( """ INSERT OR IGNORE INTO job_history - (url_hash, url, company, title, status, applied_at, session_id) - VALUES (?, ?, ?, ?, 'DISCOVERED', ?, ?) + (url_hash, url, company, title, status, applied_at, session_id, metadata_json) + VALUES (?, ?, ?, ?, 'DISCOVERED', ?, ?, ?) """, - (url_hash, url, company, title, now, session_id), + (url_hash, url, company, title, now, session_id, metadata_json), ) return cursor.rowcount > 0 except sqlite3.Error as exc: @@ -861,10 +886,12 @@ def get_jobs_for_session(self, session_id: str, status: str | None = None) -> li (session_id, status), ).fetchall() for row in rows: + metadata = _row_metadata(row["metadata_json"]) jobs.append(Job( title=row["title"], company=row["company"] or "Unknown", url=row["url"], - source="history", + source=metadata.get("provider") or "history", + metadata=metadata, )) return jobs diff --git a/packages/auto_apply/src/auto_apply/adapters/secondary/research/research_exporter.py b/packages/auto_apply/src/auto_apply/adapters/secondary/research/research_exporter.py index 550414e..294dcdb 100644 --- a/packages/auto_apply/src/auto_apply/adapters/secondary/research/research_exporter.py +++ b/packages/auto_apply/src/auto_apply/adapters/secondary/research/research_exporter.py @@ -116,6 +116,7 @@ class TableSpec: TableSpec("discovery_pages", ("observed_date", "page_id")), TableSpec("discovery_cards", ("page_id", "card_index", "card_id")), TableSpec("discovery_candidates", ("card_id", "candidate_id")), + TableSpec("search_plan_frames", ("planned_date", "frame_id")), # Item 5 — detector outcome accounting. Orders end at each primary key # so two exports of the same database byte-match (R2), as for every # other rows table. diff --git a/packages/auto_apply/src/auto_apply/adapters/secondary/research/signal_aggregator.py b/packages/auto_apply/src/auto_apply/adapters/secondary/research/signal_aggregator.py index 7c442ad..3f9f59c 100644 --- a/packages/auto_apply/src/auto_apply/adapters/secondary/research/signal_aggregator.py +++ b/packages/auto_apply/src/auto_apply/adapters/secondary/research/signal_aggregator.py @@ -39,6 +39,7 @@ JobPostingObservation, ResearchAccounting, ResearchObserverPort, + SearchPlanObservation, ) from auto_apply.domain.services.job_lifecycle_tracker import ( JobLifecycleRecord, @@ -226,6 +227,25 @@ class _DetectorExamination: -- outcomes only. detectors_roster is a JSON array of the signal_types that -- ran, in registry order, so "clean" stays derivable as roster minus -- recorded outcomes even after the registry grows. +-- The session's sampling frame for automated site-restricted search: which +-- engines and platforms were searched and how many queries each received. +-- Names and counts only — the words searched for are never stored. +CREATE TABLE IF NOT EXISTS search_plan_frames ( + frame_id TEXT PRIMARY KEY, + planned_date TEXT NOT NULL, + engines TEXT NOT NULL, + platforms TEXT NOT NULL, + queries_planned INTEGER NOT NULL, + per_engine TEXT NOT NULL, + per_platform TEXT NOT NULL, + dropped_count INTEGER NOT NULL DEFAULT 0, + notes_info INTEGER NOT NULL DEFAULT 0, + notes_warning INTEGER NOT NULL DEFAULT 0, + budget_before INTEGER NOT NULL, + budget_after INTEGER NOT NULL, + schema_version INTEGER DEFAULT 2 +); + CREATE TABLE IF NOT EXISTS detector_examinations ( examination_id TEXT PRIMARY KEY, posting_hash TEXT, @@ -374,7 +394,7 @@ def __init__( self._consent_version = consent_version self._flush_interval = flush_interval_seconds self._macro_signal_interval = macro_signal_interval_seconds - self._queue: queue.Queue[ResearchSignal | DiscoveryObservation | _DetectorExamination | None] = queue.Queue() + self._queue: queue.Queue[ResearchSignal | DiscoveryObservation | _DetectorExamination | SearchPlanObservation | None] = queue.Queue() self._running = False self._thread: threading.Thread | None = None self._enabled = consent_version is not None @@ -953,6 +973,20 @@ def observe_discovery(self, observation: DiscoveryObservation) -> None: self._record_failure("observe_discovery") logger.debug("ResearchSignalAggregator | observe_discovery error: %s", exc) + def observe_search_plan(self, observation: SearchPlanObservation) -> None: + """Accept the session's sampling frame (names and counts only). + + Same queue-plus-daemon pipeline as every other record: no I/O on the + calling discovery thread, and the consent gate above is unchanged. + """ + if not self._enabled: + return + try: + self._queue.put_nowait(observation) + except Exception as exc: + self._record_failure("observe_search_plan") + logger.debug("ResearchSignalAggregator | observe_search_plan error: %s", exc) + # ── Job lifecycle persistence (GJ-02, GJ-03) ───────────────────────────── def _load_lifecycle( @@ -1459,6 +1493,7 @@ def _flush_loop(self) -> None: batch: list[ResearchSignal] = [] discovery_batch: list[DiscoveryObservation] = [] examination_batch: list[_DetectorExamination] = [] + plan_batch: list[SearchPlanObservation] = [] while self._running: try: item = self._queue.get(timeout=self._flush_interval) @@ -1468,6 +1503,8 @@ def _flush_loop(self) -> None: discovery_batch.append(item) elif isinstance(item, _DetectorExamination): examination_batch.append(item) + elif isinstance(item, SearchPlanObservation): + plan_batch.append(item) else: batch.append(item) # Drain additional items without waiting @@ -1480,6 +1517,8 @@ def _flush_loop(self) -> None: discovery_batch.append(nxt) elif isinstance(nxt, _DetectorExamination): examination_batch.append(nxt) + elif isinstance(nxt, SearchPlanObservation): + plan_batch.append(nxt) else: batch.append(nxt) except queue.Empty: @@ -1496,6 +1535,9 @@ def _flush_loop(self) -> None: if examination_batch: self._write_examination_batch(examination_batch) examination_batch = [] + if plan_batch: + self._write_plan_batch(plan_batch) + plan_batch = [] # ── Periodic macro‑signal computation ──────────────────────── now = time.monotonic() @@ -1527,6 +1569,7 @@ def _flush_loop(self) -> None: remaining: list[ResearchSignal] = [] remaining_discovery: list[DiscoveryObservation] = [] remaining_examination: list[_DetectorExamination] = [] + remaining_plan: list[SearchPlanObservation] = [] while True: try: item = self._queue.get_nowait() @@ -1535,6 +1578,8 @@ def _flush_loop(self) -> None: remaining_discovery.append(item) elif isinstance(item, _DetectorExamination): remaining_examination.append(item) + elif isinstance(item, SearchPlanObservation): + remaining_plan.append(item) else: remaining.append(item) except queue.Empty: @@ -1551,6 +1596,10 @@ def _flush_loop(self) -> None: self._write_examination_batch(examination_batch) if remaining_examination: self._write_examination_batch(remaining_examination) + if plan_batch: + self._write_plan_batch(plan_batch) + if remaining_plan: + self._write_plan_batch(remaining_plan) def _key_path(self) -> Path: """The provenance key's path: injected, else the config constant. @@ -1844,6 +1893,54 @@ def _write_discovery_batch(self, observations: list[DiscoveryObservation]) -> No "ResearchSignalAggregator | Discovery write batch failed: %s", exc ) + def _write_plan_batch(self, observations: list[SearchPlanObservation]) -> None: + """Persist sampling frames: one row per planning pass. + + Identity follows the 4c ruling: frame_id is a random surrogate minted + at write time; nothing content-derived. Query text is never written — + the observation has no field that could carry it. Deliberately not + provenance-signed, mirroring the discovery tables. + """ + rows: list[tuple] = [] + planned_date = date.today().isoformat() + for obs in observations: + rows.append(( + uuid.uuid4().hex, + planned_date, + json.dumps(list(obs.engines)), + json.dumps(list(obs.platforms)), + obs.queries_planned, + json.dumps(dict(obs.per_engine)), + json.dumps(dict(obs.per_platform)), + obs.dropped_count, + obs.notes_info, + obs.notes_warning, + obs.budget_before, + obs.budget_after, + RESEARCH_SCHEMA_VERSION, + )) + try: + with self._get_connection() as conn: + conn.executemany( + """INSERT INTO search_plan_frames + (frame_id, planned_date, engines, platforms, + queries_planned, per_engine, per_platform, + dropped_count, notes_info, notes_warning, + budget_before, budget_after, schema_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""", + rows, + ) + self._record_written("search_plan_frames", len(rows)) + logger.debug( + "ResearchSignalAggregator | Wrote %d sampling frame(s) to DB", + len(observations), + ) + except Exception as exc: + self._record_failure("plan_write", len(observations)) + logger.error( + "ResearchSignalAggregator | Plan write batch failed: %s", exc + ) + def _write_examination_batch(self, examinations: list[_DetectorExamination]) -> None: """Persist detector examinations (item 5): one examination row plus one row per non-clean outcome, in a single transaction. diff --git a/packages/auto_apply/src/auto_apply/application/agent/orchestrator.py b/packages/auto_apply/src/auto_apply/application/agent/orchestrator.py index d3d7304..d6e1ab6 100644 --- a/packages/auto_apply/src/auto_apply/application/agent/orchestrator.py +++ b/packages/auto_apply/src/auto_apply/application/agent/orchestrator.py @@ -1388,6 +1388,7 @@ def _register_event_handlers(self) -> None: self.event_bus.subscribe(Event.HUMAN_APPROVAL_GRANTED, self._on_human_approval_granted) self.event_bus.subscribe(Event.CAPTCHA_REQUIRES_MANUAL_SOLVE, self._on_captcha_manual_solve_requested) self.event_bus.subscribe(Event.PROVIDER_TIMED_OUT, self._on_provider_timed_out) + self.event_bus.subscribe(Event.PROVIDER_BENCHED, self._on_provider_benched) self.event_bus.subscribe(Event.REDIRECT_TO_LIST_DETECTED, self._on_redirect_to_list_detected) logger.debug("Orchestrator event handlers registered") @@ -1575,6 +1576,24 @@ def _on_provider_timed_out(self, payload: Any) -> None: current.id[:8], ) + def _on_provider_benched(self, payload: Any) -> None: + """Records a benched engine in the session report's discovery section. + + Consumer for PROVIDER_BENCHED. The session continues on the other + engines; the report names the one that stopped being contacted, with + the reason when the guard supplied one. + """ + payload = payload or {} + provider = str(payload.get("provider") or "unknown") + reason = str(payload.get("reason") or "") + entry = f"{provider} ({reason})" if reason else provider + try: + if entry not in self._session_report.blocked_providers: + self._session_report.blocked_providers.append(entry) + except Exception as exc: + logger.debug("SessionReport bench record failed (non-fatal) | %s", exc) + logger.warning("Engine benched for this session | %s", entry) + def _on_redirect_to_list_detected(self, payload: Any) -> None: """Handles an application redirect to a job-listing page. diff --git a/packages/auto_apply/src/auto_apply/application/services/auditing/degradation_detector.py b/packages/auto_apply/src/auto_apply/application/services/auditing/degradation_detector.py index aae27ca..144e6e4 100644 --- a/packages/auto_apply/src/auto_apply/application/services/auditing/degradation_detector.py +++ b/packages/auto_apply/src/auto_apply/application/services/auditing/degradation_detector.py @@ -81,8 +81,39 @@ def __init__( self._benched: set[str] = set() def is_benched(self, provider: str) -> bool: - """True if *provider* has been benched this session.""" - return provider in self._benched + """True if *provider* has been benched this session. + + Keys are case-insensitive: the strategy's source tag ("Google") and + the provider's canonical name ("google") name the same engine. + """ + return provider.lower() in self._benched + + def mark_blocked(self, provider: str, reason: str) -> None: + """Bench *provider* for the session because its host challenged us. + + A CAPTCHA or login wall is a hard signal from the engine's host: the + engine stops receiving requests for the rest of the session, while + other engines continue. This is deliberately the base provider name — + a challenged host challenges every query shape, not one scope. Idempotent. + """ + provider_key = provider.lower() + if provider_key in self._benched: + return + self._benched.add(provider_key) + logger.warning( + "%s blocked this session (reason=%s) — no further requests will " + "be sent to it; other engines continue.", + provider_key, + reason, + ) + if self._event_bus is not None: + try: + self._event_bus.publish( + Event.PROVIDER_BENCHED, + {"provider": provider_key, "reason": reason}, + ) + except Exception as exc: + logger.debug("degradation guard: event publish failed: %s", exc) def evaluate_first_harvest( self, @@ -98,16 +129,17 @@ def evaluate_first_harvest( Only the first harvest is meaningful: the dry-scroll tail is expected to thin out and must never reach the baseline. Never raises. """ - if provider in self._benched or self._deterministic: + provider_key = provider.lower() + if provider_key in self._benched or self._deterministic: return try: - baseline = self._store.get_baseline(provider) if self._store else None + baseline = self._store.get_baseline(provider_key) if self._store else None except Exception as exc: logger.debug("degradation guard: baseline read failed: %s", exc) baseline = None if baseline is None or baseline.sample_count < self._min_samples: - self._record(provider, visible_count, page_bytes, elapsed_seconds) + self._record(provider_key, visible_count, page_bytes, elapsed_seconds) logger.info( "%s: degradation guard collecting baseline (%d sample(s)); " "harvest accepted (%d visible, %d bytes, %.1fs, via %s)", @@ -123,14 +155,14 @@ def evaluate_first_harvest( if baseline.avg_visible < 1.0 or baseline.avg_page_bytes < 1.0: # Degenerate baseline (e.g. built entirely from empty harvests) — # it cannot support a collapse judgment. Refresh and move on. - self._record(provider, visible_count, page_bytes, elapsed_seconds) + self._record(provider_key, visible_count, page_bytes, elapsed_seconds) return yield_ratio = visible_count / baseline.avg_visible bytes_ratio = page_bytes / baseline.avg_page_bytes if yield_ratio <= self._collapse_ratio and bytes_ratio <= self._bytes_ratio: - self._benched.add(provider) + self._benched.add(provider_key) logger.warning( "SILENT DEGRADATION — benching %s for this session: " "yield %d vs baseline %.1f (ratio %.3f <= %.2f) AND page " @@ -153,7 +185,7 @@ def evaluate_first_harvest( self._event_bus.publish( Event.PROVIDER_BENCHED, { - "provider": provider, + "provider": provider_key, "yield_ratio": round(yield_ratio, 4), "bytes_ratio": round(bytes_ratio, 4), "baseline_samples": baseline.sample_count, diff --git a/packages/auto_apply/src/auto_apply/application/services/auditing/discovery_verification.py b/packages/auto_apply/src/auto_apply/application/services/auditing/discovery_verification.py index 2f66503..f60332f 100644 --- a/packages/auto_apply/src/auto_apply/application/services/auditing/discovery_verification.py +++ b/packages/auto_apply/src/auto_apply/application/services/auditing/discovery_verification.py @@ -44,10 +44,17 @@ def _is_valid_url(url: object) -> bool: return parsed.scheme in {"http", "https"} and bool(parsed.netloc) -_SEARCH_ENGINE_HOSTS = ("google.", "bing.", "duckduckgo.", "search.yahoo.") +# Last-resort set used only when no catalog-derived list is injected. It is +# pinned equal to the catalog's engine hosts in +# tests/application/test_discovery_verification.py, so it cannot drift +# silently. The catalog is the single source of truth: the composition root +# derives the set from it and injects it through the discovery workflow. +# Job boards (Indeed, LinkedIn) are deliberately absent: real postings live +# on their hosts. +_FALLBACK_ENGINE_HOSTS = ("google.com", "bing.com", "duckduckgo.com") -def _is_navigation_chrome(job: Job) -> str: +def _is_navigation_chrome(job: Job, engine_hosts: tuple[str, ...]) -> str: """Return why this record looks like site furniture, or "" if it does not. Two rules, both learned from live runs rather than guessed: @@ -56,8 +63,9 @@ def _is_navigation_chrome(job: Job) -> str: they are not the same string. Every one of Indeed's ``Find jobs`` / ``Company Reviews`` and Bing's ``Rewards`` records has this shape. * a job URL hosted on a search engine — a posting is never served from - google.com or bing.com. Indeed is deliberately absent from that list: - real Indeed postings *do* live on indeed.com. + google.com or bing.com. Matching is suffix-exact (``evilgoogle.com`` + does not match ``google.com``). Indeed is deliberately absent from the + host set: real Indeed postings *do* live on indeed.com. """ title = (job.title or "").strip() company = (job.company or "").strip() @@ -67,8 +75,9 @@ def _is_navigation_chrome(job: Job) -> str: host = urlparse((job.url or "").strip()).netloc.lower() except ValueError: return "" - if any(part in host for part in _SEARCH_ENGINE_HOSTS): - return f"job URL is hosted on a search engine ({host})" + for engine_host in engine_hosts: + if host == engine_host or host.endswith("." + engine_host): + return f"job URL is hosted on a search engine ({host})" return "" @@ -133,9 +142,17 @@ class DiscoveryVerifier: sample_size: how many offending items to name in a failure detail. """ - def __init__(self, max_results: int | None = None, sample_size: int = 5) -> None: + def __init__( + self, + max_results: int | None = None, + sample_size: int = 5, + engine_hosts: tuple[str, ...] | None = None, + ) -> None: self._cap = max_results self._sample = max(1, sample_size) + # Catalog-derived when wired by the composition root; the pinned + # fallback otherwise. + self._engine_hosts = engine_hosts or _FALLBACK_ENGINE_HOSTS def verify(self, jobs: list[Job]) -> VerificationReport: checks = [ @@ -176,7 +193,11 @@ def _check_chrome(self, jobs: list[Job]) -> CheckResult: tab bar should say so instead of reporting ``[PASS]``, which is what happened for four live runs. """ - offenders = [(j, why) for j in jobs if (why := _is_navigation_chrome(j))] + offenders = [ + (j, why) + for j in jobs + if (why := _is_navigation_chrome(j, self._engine_hosts)) + ] if not offenders: return CheckResult( "chrome", True, f"none of {len(jobs)} jobs look like navigation" diff --git a/packages/auto_apply/src/auto_apply/application/services/host_pacing.py b/packages/auto_apply/src/auto_apply/application/services/host_pacing.py new file mode 100644 index 0000000..bc6f054 --- /dev/null +++ b/packages/auto_apply/src/auto_apply/application/services/host_pacing.py @@ -0,0 +1,106 @@ +"""Per-host navigation pacing — one owner for every contact AA makes. + +Several sessions can hammer one host: engines during discovery, and one ATS +host when a site-restricted search concentrates postings on it. This service +spaces consecutive contacts to any single host by a configured floor with +seeded jitter, so "how fast AA talks to a site" is config-driven, seeded, and +measured in one place instead of scattered literals. + +Thread safety: reservations are taken under a lock. A second caller for the +same host reserves the slot AFTER the first caller's, so interleaving cannot +violate the floor even when discovery fans out across worker threads. + +Determinism: jitter comes from the injected RNG (BehaviorParameters.make_rng +namespace "discovery.host_pacing"). When the floor equals the ceiling no +draw is made at all, and an unseeded RNG simply widens the range. + +Not consulted here: robots.txt. Interactive browsing is not bulk crawling; +a robots fetch would itself be a request. The throttler/robots machinery +remains available for a future bulk-fetch path where that question applies. +""" + +from __future__ import annotations + +import logging +import threading +import time +from collections.abc import Callable +from urllib.parse import urlparse + +logger = logging.getLogger(__name__) + + +class HostPacer: + """Spaces consecutive contacts to one host by a configured floor. + + Args: + min_interval_s: Floor in seconds between two contacts to the same host. + max_interval_s: Ceiling; the jitter range is [floor, ceiling]. + rng: Seeded RNG for the jitter draw. None means the floor is used + whenever a wait is needed beyond the first contact. + sleeper: Injected sleep (tests pass a recorder). + clock: Injected monotonic clock (tests pass a controllable one). + """ + + def __init__( + self, + min_interval_s: float, + max_interval_s: float, + *, + rng=None, + sleeper: Callable[[float], None] = time.sleep, + clock: Callable[[], float] = time.monotonic, + ) -> None: + self._min = max(0.0, float(min_interval_s)) + self._max = max(self._min, float(max_interval_s)) + self._rng = rng + self._sleep = sleeper + self._now = clock + self._ledger: dict[str, float] = {} + self._lock = threading.Lock() + + def wait_for_url(self, url: str) -> float: + """Reserve the next contact slot for *url*'s host and wait it out. + + Returns the seconds waited (0.0 for a first contact or a fresh host). + Never raises on a malformed URL — an unparseable URL is not paced. + """ + host = _host_of(url) + if not host: + return 0.0 + interval = self._interval() + now = self._now() + with self._lock: + last = self._ledger.get(host) + if last is None: + self._ledger[host] = now + return 0.0 + earliest = last + interval + wait = earliest - now + if wait <= 0.0: + self._ledger[host] = now + return 0.0 + # Reserve BEFORE sleeping, so a concurrent caller queues behind. + self._ledger[host] = earliest + self._sleep(wait) + logger.debug("HostPacer: waited %.2fs before contacting %s again", wait, host) + return wait + + def _interval(self) -> float: + """This contact's spacing: the floor plus a seeded jitter draw.""" + if self._max <= self._min: + return self._min + if self._rng is None: + return self._min + return self._min + self._rng.uniform(0.0, self._max - self._min) + + +def _host_of(url: str) -> str: + """The contact key for a URL: lowercased host, userinfo and port stripped.""" + try: + netloc = urlparse(url or "").netloc.lower() + except Exception: + return "" + if not netloc: + return "" + return netloc.split("@")[-1].split(":")[0] diff --git a/packages/auto_apply/src/auto_apply/application/services/page_action/service.py b/packages/auto_apply/src/auto_apply/application/services/page_action/service.py index bc3aabe..62e8681 100644 --- a/packages/auto_apply/src/auto_apply/application/services/page_action/service.py +++ b/packages/auto_apply/src/auto_apply/application/services/page_action/service.py @@ -47,7 +47,10 @@ import logging import random import time -from typing import Callable +from typing import TYPE_CHECKING, Callable + +if TYPE_CHECKING: + from auto_apply.application.services.host_pacing import HostPacer from auto_apply.application.services.page_action.clicking import Clicker from auto_apply.application.services.page_action.pacing import Pacing @@ -95,6 +98,7 @@ def __init__( pointer_rng: random.Random | None = None, wheel_rng: random.Random | None = None, readiness: DomReadinessPort | None = None, + host_pacer: "HostPacer | None" = None, ) -> None: self._browser = browser self._state = PageActionContext( @@ -110,6 +114,7 @@ def __init__( self._scroller = Scroller(self._state, self._probe, self._pacing) self._clicker = Clicker(self._state, self._probe, self._pacing, self._scroller) self._typer = Typer(self._state, self._pacing, self._clicker) + self._host_pacer = host_pacer logger.debug( "PageActionService ready | micro_peak=%.0fms " @@ -182,6 +187,13 @@ def navigate( # Warm up once before the very first load of the session (CAPTCHA # mitigation). No-op on every subsequent navigation. self.warmup_pause() + # Per-host floor, once per navigate call (retries to the same host + # are error recovery, not new contacts). + if self._host_pacer is not None: + try: + self._host_pacer.wait_for_url(target) + except Exception as exc: + logger.debug("navigate: host pacing failed (non-fatal): %s", exc) for attempt in range(1, attempts + 1): try: logger.debug("navigate | attempt=%d/%d url=%s", attempt, attempts, target) diff --git a/packages/auto_apply/src/auto_apply/application/services/query_builder.py b/packages/auto_apply/src/auto_apply/application/services/query_builder.py index fc81bcc..808a2ff 100644 --- a/packages/auto_apply/src/auto_apply/application/services/query_builder.py +++ b/packages/auto_apply/src/auto_apply/application/services/query_builder.py @@ -47,6 +47,7 @@ from auto_apply.domain.config import USER_DATA_DIR from auto_apply.domain.exceptions import QueryBuilderError from auto_apply.domain.models.search_query import ( + AutomationPlatformOffer, BuilderDefaults, OfferedCapability, OfferedChoice, @@ -61,7 +62,9 @@ from auto_apply.domain.models.search_surface import ( OFFERED_STATUSES, SearchSurfaceSpec, + VerificationStatus, ) +from auto_apply.domain.services.search_planner import AUTOMATION_STATUSES from auto_apply.domain.services.query_composer import compose logger = logging.getLogger(__name__) @@ -216,6 +219,35 @@ def query_platforms(self) -> tuple[PlatformOffer, ...]: ) return tuple(offers) + def automation_platform_vocabulary(self) -> tuple[AutomationPlatformOffer, ...]: + """Every registered platform with its automation eligibility. + + This is the one vocabulary both wizards read — no adapter holds its + own list. Eligibility is the automation floor from the search + planner: only measured or vendor-documented restrictions may be + planned, because automation acts with no one reading a label. + Ineligible platforms carry a plain-language reason (and the remedy + is the hand-verification checklist), so a surface never invents one. + An unknown status reads as unverified — never as eligible. + """ + offers: list[AutomationPlatformOffer] = [] + for descriptor in self.platforms(): + try: + status = VerificationStatus(descriptor.search_restriction_status) + except ValueError: + status = VerificationStatus.UNVERIFIED + eligible = status in AUTOMATION_STATUSES + reason = "" if eligible else get_text("searchplan.platform.not_eligible") + offers.append( + AutomationPlatformOffer( + name=descriptor.name, + status=status.value, + eligible=eligible, + reason=reason, + ) + ) + return tuple(offers) + def platform_restriction_for( self, platform: str | None = None, url: str | None = None ) -> str: diff --git a/packages/auto_apply/src/auto_apply/application/services/search_preview.py b/packages/auto_apply/src/auto_apply/application/services/search_preview.py new file mode 100644 index 0000000..711f9b4 --- /dev/null +++ b/packages/auto_apply/src/auto_apply/application/services/search_preview.py @@ -0,0 +1,169 @@ +"""The plan preview: what a session will search, computed before it starts. + +One builder computes the preview from the same inputs the session runs with +— the session plan after the request and any admin lock are applied, and +the same planner the workflow uses — so the preview cannot drift from the +run. The parity pin proves it against the workflow itself. Offline by +construction: no browser, no network. + +The plan-update rule lives here exactly once (plan_updates_for_request): +SessionController applies it to the live session plan, the preview applies +it to a copy. The platform rule is a tri-state: None inherits the +profile/config default, () turns targeting off for the session, and a named +list targets exactly those platforms. An admin lock wins over the request, +exactly as it wins over the profile in the registry merge. +""" + +from __future__ import annotations + +from typing import Any + +from auto_apply.domain.models.session_plan import SessionPlan +from auto_apply.domain.models.ui_contract import ( + PlannedQueryView, + SearchPlanPreview, + SessionRequest, +) + +__all__ = ["plan_updates_for_request", "preview_search_plan"] + + +def plan_updates_for_request( + request: SessionRequest, admin_policy: Any +) -> dict[str, Any]: + """The plan updates a SessionRequest implies — the ONE computation. + + Anything that is not a list/tuple is treated as "no lock": a partial + double (a MagicMock in tests, an unwired registry) must not be able to + turn a request into a plan error. + """ + updates: dict[str, Any] = {"execution_mode": request.execution_mode} + if isinstance(request.max_results, int) and request.max_results > 0: + updates["max_results_per_query"] = request.max_results + if request.providers: + updates["active_providers"] = request.providers + if request.platforms is not None: + locked = getattr(admin_policy, "search_platforms", None) + if isinstance(locked, (list, tuple)): + updates["ats_platforms"] = tuple(locked) + updates["use_ats_site_search"] = bool(locked) + else: + updates["ats_platforms"] = tuple(request.platforms) + updates["use_ats_site_search"] = bool(request.platforms) + return updates + + +def _request_bases(profile: Any, request: SessionRequest) -> list[tuple[str, str]]: + """The (title, location) pairs the session will search. + + The request wins; an empty request falls back to the profile's saved + titles and locations — the same fallback the task seeder uses, so the + preview shows the same bases the session would run. + """ + prefs = getattr(profile, "search_preferences", None) + titles = list(request.keywords) or list( + getattr(prefs, "desired_job_titles", None) or [] + ) + if request.location: + locations = [request.location] + else: + locations = list(getattr(prefs, "preferred_locations", None) or []) + if not locations: + locations = ["Remote"] + return [(title, location) for title in titles for location in locations] + + +def preview_search_plan( + profile: Any, + plan: SessionPlan, + planner: Any, + request: SessionRequest, + admin_policy: Any = None, +) -> SearchPlanPreview: + """Build the preview from the same inputs the session runs with. + + With targeting engaged, each base is planned exactly the way the + workflow plans it: one planner call per base, the session budget + decrementing, engines rotating innermost. Without it, the preview shows + the generic broadcast every enabled engine receives. A missing planner + degrades to the generic view with the reason named — never an exception. + """ + updates = plan_updates_for_request(request, admin_policy) + effective = plan.model_copy(update=updates) + + engines = tuple(effective.active_providers) + budget = int(effective.max_queries_per_session) + locked = getattr(admin_policy, "search_platforms", None) + locked_platforms = tuple(locked) if isinstance(locked, (list, tuple)) else () + + site_engaged = bool(effective.use_ats_site_search and effective.ats_platforms) + reason_key = "" + if site_engaged and planner is None: + site_engaged = False + reason_key = "searchplan.preview.reason_no_planner" + + queries: list[PlannedQueryView] = [] + notes: list[str] = [] + dropped: list[str] = [] + bases = _request_bases(profile, request) + + if site_engaged: + remaining = budget + offset = 0 + for title, location in bases: + if remaining <= 0: + dropped.append(f"{title} — {location}") + continue + call_engines = engines + if call_engines and offset: + off = offset % len(call_engines) + call_engines = call_engines[off:] + call_engines[:off] + planned = planner.plan( + bases=((title, location, "remote"),), + engines=call_engines, + platforms=tuple(effective.ats_platforms), + date_range=effective.date_range, + max_results=effective.max_results_per_query, + budget=remaining, + ) + notes.extend(note.message for note in planned.notes) + for query in planned.queries: + queries.append( + PlannedQueryView( + engine=query.engine or "", + platform=query.platform, + form=query.surface_template or "", + query=query.raw_query_string, + date_range=query.date_range, + max_results=query.max_results, + ) + ) + dropped.extend(planned.dropped_targets) + remaining -= len(planned.queries) + if engines: + bound = [q.engine for q in planned.queries if q.engine] + if bound and bound[-1] in engines: + offset = (engines.index(bound[-1]) + 1) % len(engines) + else: + for title, location in bases[:budget]: + queries.append( + PlannedQueryView( + engine="", + query=f"{title} jobs in {location}", + max_results=effective.max_results_per_query, + ) + ) + for title, location in bases[budget:]: + dropped.append(f"{title} — {location}") + + return SearchPlanPreview( + site_search=site_engaged, + queries=tuple(queries), + notes=tuple(dict.fromkeys(notes)), + dropped=tuple(dropped), + engines=engines, + budget=budget, + planned_count=len(queries), + reason_key=reason_key, + locked_platforms=locked_platforms, + ) diff --git a/packages/auto_apply/src/auto_apply/application/services/session_controller.py b/packages/auto_apply/src/auto_apply/application/services/session_controller.py index d674bf3..74eb1ba 100644 --- a/packages/auto_apply/src/auto_apply/application/services/session_controller.py +++ b/packages/auto_apply/src/auto_apply/application/services/session_controller.py @@ -142,6 +142,7 @@ apply_autonomy, autonomy_enabled_for_profile, ) +from auto_apply.application.services.search_preview import plan_updates_for_request from auto_apply.domain.config import USER_DATA_DIR from auto_apply.domain.events import Event from auto_apply.domain.exceptions import QueryBuilderError @@ -161,10 +162,12 @@ from auto_apply.domain.models.ui_contract import ( ActivityKind, ApprovalRequest, + BlockedEngine, DiscoveredJob, EntryPoint, QueueSnapshot, ResearchSessionView, + SearchPlanPreview, SessionEventRecord, SessionHistoryEntry, SessionRequest, @@ -303,10 +306,22 @@ class ApprovalAnswer: # ───────────────────────────────────────────────────────────────────────────── def _render_discovery_complete(p: dict) -> str: - return ( + line = ( f"Discovery round complete: {p.get('enqueued', 0)} new job(s) " f"from {p.get('raw_found', 0)} found" ) + extras = [] + if p.get("off_shape_dropped"): + extras.append(f"{p['off_shape_dropped']} off-shape dropped") + if p.get("terminal_dropped"): + extras.append(f"{p['terminal_dropped']} terminal skipped") + if p.get("routed_to_mining"): + extras.append(f"{p['routed_to_mining']} routed to company pages") + if p.get("budget_skipped"): + extras.append(f"{p['budget_skipped']} over budget") + if extras: + line += " (" + "; ".join(extras) + ")" + return line def _render_captcha_manual_solve(p: dict) -> str: @@ -464,6 +479,7 @@ def __init__( profile_repo: ProfileRepositoryPort | None = None, research_consent: "ResearchConsentManager | None" = None, query_builder: "QueryBuilderService | None" = None, + plan_previewer=None, ) -> None: """Stores pre-built dependencies and starts the event-fed projections. @@ -492,6 +508,15 @@ def __init__( self._profile_repo = profile_repo self._research_consent = research_consent self._query_builder = query_builder + self._plan_previewer = plan_previewer + # Automation outcome accumulators (event-fed; read by summary()). + self._blocked_engines: list[BlockedEngine] = [] + self._discovery_outcome_counts = { + "off_shape_dropped": 0, + "terminal_dropped": 0, + "routed_to_mining": 0, + "budget_skipped": 0, + } self._agent_thread: threading.Thread | None = None # HITL: maps context_id → (gate_event, chosen_value_holder, payload). self._pending_approvals: dict[ @@ -721,6 +746,11 @@ def _record_event(self, event: Event, payload: Any) -> None: exists so that never happens in production; a raise here would be swallowed by the EventBus into a log line nobody reads. """ + if event is Event.PROVIDER_BENCHED: + self._accumulate_blocked_engine(payload) + elif event is Event.DISCOVERY_COMPLETE: + self._accumulate_discovery_counts(payload) + kind = _EVENT_KIND_MAP.get(event) renderer = _STREAM_TEXT_RENDERERS.get(event) if kind is None or renderer is None: @@ -920,21 +950,23 @@ def _rebuild_plan_for_request(self, request: SessionRequest) -> None: max_results used to reach the plan but never the scraper. """ base_plan = self.registry.get_session_plan() - updates: dict[str, Any] = {"execution_mode": request.execution_mode} - if isinstance(request.max_results, int) and request.max_results > 0: - updates["max_results_per_query"] = request.max_results - if request.providers: - updates["active_providers"] = request.providers + # One computation, shared with the plan preview — the live rebuild + # and the preview cannot drift. + updates = plan_updates_for_request( + request, self.registry.get_admin_policy() + ) try: new_plan = base_plan.model_copy(update=updates) self.orchestrator.session_plan = new_plan self._refresh_workflow_plans(new_plan) logger.debug( "SessionController: session plan rebuilt | mode=%s cap=%s " - "providers=%s", + "providers=%s platforms=%s site_search=%s", new_plan.execution_mode.value, new_plan.max_results_per_query, new_plan.active_providers, + new_plan.ats_platforms, + new_plan.use_ats_site_search, ) except Exception as exc: # noqa: BLE001 logger.warning( @@ -1521,6 +1553,12 @@ def summary(self) -> SessionSummary: report_path=report.report_path, autonomy_enabled=self.autonomy(), research=self._research_view(), + blocked_engines=tuple(self._blocked_engines), + discovery_off_shape_dropped=self._discovery_outcome_counts["off_shape_dropped"], + discovery_terminal_dropped=self._discovery_outcome_counts["terminal_dropped"], + discovery_routed_to_mining=self._discovery_outcome_counts["routed_to_mining"], + discovery_budget_skipped=self._discovery_outcome_counts["budget_skipped"], + search_budget=self._search_budget(), ) def _research_view(self) -> ResearchSessionView: @@ -1585,6 +1623,61 @@ def queue_snapshot(self) -> QueueSnapshot: permanently_failed=int(stats.get("permanently_failed", 0) or 0), ) + def preview_search_plan(self, request: SessionRequest) -> SearchPlanPreview: + """What this session plans to search, computed before Start. + + Delegates to the injected previewer (the composition root's closure + over the session's own registry and planner). A missing or failing + previewer degrades to a generic preview with the reason named — + never None, never an exception. + """ + previewer = self._plan_previewer + if previewer is None: + return SearchPlanPreview( + reason_key="searchplan.preview.reason_no_planner" + ) + try: + return previewer(request) + except Exception as exc: # noqa: BLE001 — a preview must never break a session + logger.warning("SessionController: plan preview failed | %s", exc) + return SearchPlanPreview( + reason_key="searchplan.preview.reason_no_planner" + ) + + def _accumulate_blocked_engine(self, payload: Any) -> None: + """Records a benched engine for the end-of-session summary. + + Dedupes by provider name — the bench is session-scoped, so a second + event for the same engine is the same fact, not a new one. + """ + if not isinstance(payload, dict): + return + provider = str(payload.get("provider") or "unknown") + reason = str(payload.get("reason") or "") + if not reason and "yield_ratio" in payload: + reason = "harvest collapsed against this engine's own baseline" + if provider not in {e.provider for e in self._blocked_engines}: + self._blocked_engines.append( + BlockedEngine(provider=provider, reason=reason) + ) + + def _accumulate_discovery_counts(self, payload: Any) -> None: + """Sums the per-round discovery outcome counts for the summary.""" + if not isinstance(payload, dict): + return + for key in self._discovery_outcome_counts: + try: + self._discovery_outcome_counts[key] += int(payload.get(key) or 0) + except (TypeError, ValueError): + continue + + def _search_budget(self) -> int: + """The session's query budget, read from the live plan.""" + try: + return int(self.orchestrator.session_plan.max_queries_per_session) + except Exception: # noqa: BLE001 + return 0 + def pending_approvals(self) -> tuple[ApprovalRequest, ...]: """Returns all currently open HITL gates as typed data.""" return tuple( @@ -1828,13 +1921,21 @@ def export_session_results(self, destination_dir: Path, *, overwrite: bool = Fal ) jobs = self.db.get_jobs_for_session(session_id) - rows = [["title", "company", "url", "source"]] + rows = [[ + "title", "company", "url", "source", + "search_query", "target_platform", "engine_form", "page_index", "rank", + ]] rows.extend( [ str(getattr(j, "title", "") or ""), str(getattr(j, "company", "") or ""), str(getattr(j, "url", "") or ""), str(getattr(j, "source", "") or ""), + str(getattr(j, "metadata", {}).get("search_query", "") or ""), + str(getattr(j, "metadata", {}).get("target_platform", "") or ""), + str(getattr(j, "metadata", {}).get("engine_form", "") or ""), + str(getattr(j, "metadata", {}).get("page_index", "")), + str(getattr(j, "metadata", {}).get("rank", "")), ] for j in jobs ) diff --git a/packages/auto_apply/src/auto_apply/application/workflows/discovery_workflow.py b/packages/auto_apply/src/auto_apply/application/workflows/discovery_workflow.py index c87c8e7..6288a16 100644 --- a/packages/auto_apply/src/auto_apply/application/workflows/discovery_workflow.py +++ b/packages/auto_apply/src/auto_apply/application/workflows/discovery_workflow.py @@ -16,8 +16,9 @@ 4. _scrape_company_pages — optional direct-careers-page mining 5. _prefilter_with_spacy — drop blocked vocab/company/location matches cheaply 6. _normalize_and_deduplicate — skip URL-duplicates, emit TASK_SKIPPED_DUPLICATE - 7. _persist_discovered_jobs — record unique jobs with session_id (mode-independent) - 8. _classify_job_source — stamp job.metadata["ats"] and ["provider"] + 7. _classify_job_source — stamp job.metadata["ats"] and ["provider"] + 8. _persist_discovered_jobs — record unique jobs with session_id and their + provenance (mode-independent) 9. _enqueue_vet_tasks — push WorkUnit(VET) into task queue 10. _emit_completion_summary — publish DISCOVERY_COMPLETE with aggregate stats @@ -40,7 +41,11 @@ from auto_apply.domain.models.search_instruction import SearchInstruction from auto_apply.domain.models.session_plan import SessionExecutionMode, SessionPlan from auto_apply.domain.models.work_unit import TaskType, WorkUnit -from auto_apply.domain.ports.research_port import ResearchObserverPort, NullResearchObserver +from auto_apply.domain.ports.research_port import ( + NullResearchObserver, + ResearchObserverPort, + SearchPlanObservation, +) logger = logging.getLogger(__name__) @@ -54,6 +59,10 @@ class _DiscoveryStats: prefiltered_dropped: int = 0 deduped_dropped: int = 0 enqueued: int = 0 + budget_skipped: int = 0 + off_shape_dropped: int = 0 + terminal_dropped: int = 0 + routed_to_mining: int = 0 class DiscoveryWorkflow: @@ -74,6 +83,8 @@ def __init__( browser_lease=None, research_observer: ResearchObserverPort | None = None, provider_order_rng: random.Random | None = None, + search_planner=None, + engine_hosts: tuple[str, ...] | None = None, ) -> None: """Initialize with all dependencies injected. @@ -102,6 +113,9 @@ def __init__( When None, a minimal fallback plan is created. browser_lease: Optional BrowserLeaseManager for concurrency control. research_observer: ResearchObserverPort | None — new research signal pipeline. + search_planner: SearchPlanner | None — plans site-restricted + (ATS-targeted) queries when the session enables them. None + leaves the default query path untouched. """ self._profile = profile self._providers = providers @@ -119,6 +133,14 @@ def __init__( self._company_page_miner = company_page_miner self._company_page_scraper = company_page_scraper self._browser_lease = browser_lease + self._search_planner = search_planner + self._site_search_warned = False + self._engine_hosts = engine_hosts + # The session query budget and the engine rotation are owned HERE — + # this workflow is the one component that spans DISCOVER tasks, and + # the planner is deliberately stateless. + self._queries_sent_this_session: int = 0 + self._engine_rotation_offset: int = 0 # Fallback: a minimal plan is always available if plan is not None: @@ -194,12 +216,21 @@ def _build_search_instructions( the orchestrator's ``_handle_discovery`` communicates the user's intent. + Precedence: explicit overrides (user-specified, via the orchestrator) + > plan-declared ``search_pairs`` > site-restricted planning (when + ``use_ats_site_search`` is engaged) > the profile-derived matrix. + Returns: Ordered list of SearchInstruction objects, capped at ``max_queries_per_session``. """ # ── Override path: user‑specified instructions from the orchestrator ─ + # When ATS site search is engaged, a generic seeded override (title + # and location only) is expanded through the planner instead of run + # as-is; an override carrying a raw query string is a verbatim + # power-user query and is never touched. if override_instructions: + override_instructions = self._expand_overrides(override_instructions) cap = self._max_queries_per_session if len(override_instructions) > cap: logger.info( @@ -220,6 +251,33 @@ def _build_search_instructions( # Propagate the session‑level date_range into every instruction. plan_date_range = self._plan.date_range if self._plan else None + # ── Plan-declared pairs: an explicit query set (``search_pairs``) + # overrides the profile-derived matrix, after user overrides. ── + if self._plan is not None and self._plan.search_pairs: + paired = [ + SearchInstruction( + title=pair.title, + location=pair.location, + workplace_type=pair.workplace_type, + date_range=plan_date_range, + max_results=self._plan.max_results_per_query, + ) + for pair in self._plan.search_pairs + ] + return paired[: self._max_queries_per_session] + + # ── Site-restricted planning (ATS-targeted search) ── + if self._site_search_engaged(): + planned = self._plan_site_restricted( + titles, locations, workplace_types, plan_date_range + ) + logger.info( + "DiscoveryWorkflow: site-restricted planning produced %d instructions", + len(planned), + ) + return planned + self._warn_site_search_inactive_once() + instructions: list[SearchInstruction] = [] for title in titles: for location in locations: @@ -244,6 +302,149 @@ def _build_search_instructions( return instructions + # ------------------------------------------------------------------ + # Site-restricted (ATS-targeted) planning + # ------------------------------------------------------------------ + + def _site_search_engaged(self) -> bool: + """True when ATS-targeted search is configured and a planner exists.""" + return bool( + self._plan is not None + and self._plan.use_ats_site_search + and self._plan.ats_platforms + and self._search_planner is not None + ) + + def _warn_site_search_inactive_once(self) -> None: + """Warn once when the flag is on but no platforms can be planned.""" + if self._site_search_warned: + return + self._site_search_warned = True + if self._plan is not None and self._plan.use_ats_site_search: + logger.warning( + "DiscoveryWorkflow: discovery.use_ats_site_search is on but " + "no platforms are usable (ats_platforms empty or planner " + "unavailable) — running the normal queries." + ) + + def _expand_overrides( + self, instructions: list[SearchInstruction] + ) -> list[SearchInstruction]: + """Expand seeded generic overrides through the planner when engaged. + + An override carrying a raw query string is a verbatim power-user + query and passes through untouched. + """ + if not self._site_search_engaged(): + return list(instructions) + expanded: list[SearchInstruction] = [] + for instruction in instructions: + if instruction.raw_query_string: + expanded.append(instruction) + continue + expanded.extend( + self._plan_for_bases( + ( + ( + instruction.title, + instruction.location, + instruction.workplace_type, + ), + ) + ) + ) + return expanded + + def _plan_site_restricted( + self, + titles: list[str], + locations: list[str], + workplace_types: list[str], + plan_date_range, + ) -> list[SearchInstruction]: + """Plan site-restricted instructions for the profile's search bases. + + Bases are titles × locations. No engine strategy reads the workplace + type (verified in engine_strategies.build_search_url), so each base + is planned once with the profile's first workplace type rather than + duplicated per type the way the generic matrix multiplies identical + queries. + """ + bases = tuple( + (title, location, workplace_types[0]) + for title in titles + for location in locations + ) + return self._plan_for_bases(bases, plan_date_range) + + def _plan_for_bases( + self, + bases: tuple[tuple[str, str, str], ...], + plan_date_range=None, + ) -> list[SearchInstruction]: + """Run the planner over bases and convert its output to instructions. + + The session budget is owned here: the planner is told how many + queries remain for the WHOLE session, not a fresh cap per task, and + the engine rotation carries across task boundaries so no engine gets + two consecutive planned queries while another enabled engine exists. + """ + remaining = max( + 0, self._max_queries_per_session - self._queries_sent_this_session + ) + if remaining <= 0: + logger.warning( + "DiscoveryWorkflow: session query budget (%d) already spent — " + "no planning for this task", + self._max_queries_per_session, + ) + return [] + base_engines = tuple(self._plan.active_providers) + engines = base_engines + if base_engines and self._engine_rotation_offset: + off = self._engine_rotation_offset % len(base_engines) + engines = base_engines[off:] + base_engines[:off] + planned = self._search_planner.plan( + bases=bases, + engines=engines, + platforms=tuple(self._plan.ats_platforms), + date_range=( + self._plan.date_range if plan_date_range is None else plan_date_range + ), + max_results=self._plan.max_results_per_query, + budget=remaining, + ) + for note in planned.notes: + if note.severity == "warning": + logger.warning("SearchPlanner: %s", note.message) + else: + logger.info("SearchPlanner: %s", note.message) + self._emit_search_plan_frame( + planned, engines=engines, budget_before=remaining + ) + if base_engines: + bound = [q.engine for q in planned.queries if q.engine] + if bound and bound[-1] in base_engines: + self._engine_rotation_offset = ( + base_engines.index(bound[-1]) + 1 + ) % len(base_engines) + return [self._instruction_from_planned(query) for query in planned.queries] + + @staticmethod + def _instruction_from_planned(query) -> SearchInstruction: + """Convert a planner query into the SearchInstruction providers run.""" + return SearchInstruction( + title=query.title, + location=query.location, + workplace_type=query.workplace_type, + raw_query_string=query.raw_query_string, + date_range=query.date_range, + max_results=query.max_results, + engine=query.engine, + surface_template=query.surface_template, + platform=query.platform, + ) + def _order_providers(self, active_providers: list) -> list: """Return a seeded shuffle of the active SERP providers. @@ -263,7 +464,10 @@ def _order_providers(self, active_providers: list) -> list: return ordered def _execute_serp_discovery( - self, instructions: list[SearchInstruction], active_providers: list + self, + instructions: list[SearchInstruction], + active_providers: list, + stats: _DiscoveryStats | None = None, ) -> list[Job]: """Fan out to all active providers, passing one instruction per call. @@ -281,14 +485,64 @@ def _execute_serp_discovery( all_jobs: list[Job] = [] active_providers = self._order_providers(active_providers) + # The session budget binds at the fan-out — the one place every + # instruction (planned or generic) passes through on its way out. + affordable: list[SearchInstruction] = [] + skipped = 0 + for instr in instructions: + if self._queries_sent_this_session >= self._max_queries_per_session: + skipped += 1 + continue + self._queries_sent_this_session += 1 + affordable.append(instr) + if skipped: + if stats is not None: + stats.budget_skipped += skipped + logger.warning( + "DiscoveryWorkflow: session query budget (%d) reached — %d " + "instruction(s) not sent", + self._max_queries_per_session, + skipped, + ) + instructions = affordable + def _run_provider(provider, instr: SearchInstruction) -> list[Job]: name = getattr(provider, "name", type(provider).__name__) + # Engine-bound instructions (from site-restricted planning) run + # only on their engine's provider. An engine that cannot carry + # the query gets nothing, never a degraded version of it. + if instr.engine is not None and str(name).lower() != instr.engine: + return [] try: if self._browser_lease: with self._browser_lease.acquire(): results = provider.run(instr) else: results = provider.run(instr) + # Provenance: every job states which query, platform and + # engine form produced it. setdefault — a provider's own + # stamp wins. + for job in results or []: + try: + job.metadata.setdefault( + "search_query", instr.effective_query + ) + job.metadata.setdefault( + "route", + "engine_site_search" + if instr.platform + else "provider_serp", + ) + if instr.platform: + job.metadata.setdefault( + "target_platform", instr.platform + ) + if instr.surface_template: + job.metadata.setdefault( + "engine_form", instr.surface_template + ) + except Exception: + pass logger.info( "DiscoveryWorkflow: provider=%s instruction=%r found=%d", name, @@ -447,6 +701,135 @@ def _persist_discovered_jobs(self, jobs: list[Job]) -> int: ) return recorded + def _handle_off_shape_hits(self, jobs: list[Job]) -> tuple[list[Job], dict[str, int]]: + """Handle hits that are not postings of the platform they targeted. + + Only jobs stamped ``target_platform`` by the planner path are + examined; everything else passes through untouched. A hit on one of + the platform's terminal shapes (success/confirm/closed) is dropped + and never followed. A hit that matches no posting pattern for its + target platform is a board, a listing page or noise: when company-page + mining is enabled it becomes a DISCOVER_COMPANY task; otherwise it is + dropped. Each outcome is counted and reported. + """ + counts = {"dropped": 0, "terminal": 0, "mined": 0} + if self._ats_registry is None: + return jobs, counts + kept: list[Job] = [] + for job in jobs: + try: + platform = str(job.metadata.get("target_platform") or "") + except Exception: + kept.append(job) + continue + if not platform: + kept.append(job) + continue + try: + if self._ats_registry.is_terminal_page(job.url): + counts["terminal"] += 1 + logger.info( + "DiscoveryWorkflow: terminal page never followed | url=%s", + job.url, + ) + continue + descriptor = self._ats_registry.match(job.url) + except Exception as exc: + logger.debug("DiscoveryWorkflow: registry match failed: %s", exc) + kept.append(job) + continue + if descriptor is not None and descriptor.name == platform: + kept.append(job) + continue + if ( + self._plan.enable_company_page_mining + and self._plan.execution_mode.includes_discovery + ): + try: + self._task_queue.queue_task( + WorkUnit( + priority=4, + task_type=TaskType.DISCOVER_COMPANY, + payload={ + "careers_url": job.url, + "company_name": job.company or "Unknown", + }, + source="site_search_off_shape", + context_data={"target_platform": platform}, + ) + ) + counts["mined"] += 1 + logger.info( + "DiscoveryWorkflow: off-shape hit routed to company " + "mining | platform=%s url=%s", + platform, + job.url, + ) + except Exception as exc: + counts["dropped"] += 1 + logger.warning( + "DiscoveryWorkflow: off-shape routing failed | url=%s error=%s", + job.url, + exc, + ) + else: + counts["dropped"] += 1 + logger.info( + "DiscoveryWorkflow: off-shape hit dropped | platform=%s url=%s", + platform, + job.url, + ) + return kept, counts + + def _emit_search_plan_frame( + self, + planned, + *, + engines: tuple[str, ...], + budget_before: int, + ) -> None: + """Emit the session's sampling frame to research (consent-gated). + + The frame is names and counts only — which engines and platforms were + searched, how many queries each received, how many targets were + dropped. Query text, titles and locations NEVER appear in it: those + are the user's search, and the research store does not carry them. + """ + observer = self._research_observer + if not getattr(observer, "is_enabled", False): + return + try: + per_engine: dict[str, int] = {} + per_platform: dict[str, int] = {} + for query in planned.queries: + if query.engine: + per_engine[query.engine] = per_engine.get(query.engine, 0) + 1 + if query.platform: + per_platform[query.platform] = ( + per_platform.get(query.platform, 0) + 1 + ) + observer.observe_search_plan( + SearchPlanObservation( + engines=tuple(sorted(engines)), + platforms=tuple(sorted(per_platform)), + queries_planned=len(planned.queries), + per_engine=tuple(sorted(per_engine.items())), + per_platform=tuple(sorted(per_platform.items())), + dropped_count=len(planned.dropped_targets), + notes_info=sum(1 for n in planned.notes if n.severity == "info"), + notes_warning=sum( + 1 for n in planned.notes if n.severity == "warning" + ), + budget_before=budget_before, + budget_after=budget_before - len(planned.queries), + ) + ) + except Exception as exc: + logger.debug( + "DiscoveryWorkflow: sampling-frame observation failed (non-fatal): %s", + exc, + ) + def _classify_job_source(self, jobs: list[Job]) -> None: for job in jobs: if not hasattr(job, "metadata"): @@ -498,6 +881,10 @@ def _emit_completion_summary(self, stats: _DiscoveryStats) -> None: "prefiltered_dropped": stats.prefiltered_dropped, "deduped_dropped": stats.deduped_dropped, "enqueued": stats.enqueued, + "budget_skipped": stats.budget_skipped, + "off_shape_dropped": stats.off_shape_dropped, + "terminal_dropped": stats.terminal_dropped, + "routed_to_mining": stats.routed_to_mining, } try: self._event_bus.publish(Event.DISCOVERY_COMPLETE, payload) @@ -565,7 +952,9 @@ def run( stats.providers_attempted = len(active_providers) all_jobs: list[Job] = [] - all_jobs.extend(self._execute_serp_discovery(search_instructions, active_providers)) + all_jobs.extend( + self._execute_serp_discovery(search_instructions, active_providers, stats) + ) all_jobs.extend(self._scrape_company_pages(all_jobs)) stats.raw_found = len(all_jobs) @@ -575,13 +964,18 @@ def run( all_jobs = self._normalize_and_deduplicate(all_jobs) stats.deduped_dropped = (stats.raw_found - stats.prefiltered_dropped) - len(all_jobs) + all_jobs, off_shape = self._handle_off_shape_hits(all_jobs) + stats.off_shape_dropped = off_shape["dropped"] + stats.terminal_dropped = off_shape["terminal"] + stats.routed_to_mining = off_shape["mined"] + # ── Persist before the mode branch (C1) ───────────────────────────── # A job is persisted the moment it is known, so results survive # DISCOVER_ONLY sessions and process death alike. This is history, # not a skip list. - self._persist_discovered_jobs(all_jobs) - + # Stamp provider and platform first, so the stored row carries them. self._classify_job_source(all_jobs) + self._persist_discovered_jobs(all_jobs) # Verify the output is real before enqueueing: valid fields, no # duplicates, and within the resolved per-query ceiling. "Know, not @@ -591,7 +985,9 @@ def run( len(active_providers) * sum(instr.max_results for instr in search_instructions) ) or None - report = DiscoveryVerifier(max_results=ceiling).verify(all_jobs) + report = DiscoveryVerifier( + max_results=ceiling, engine_hosts=self._engine_hosts + ).verify(all_jobs) if report.passed: logger.info(report.summary()) else: diff --git a/packages/auto_apply/src/auto_apply/application/workflows/vetting_workflow.py b/packages/auto_apply/src/auto_apply/application/workflows/vetting_workflow.py index 0cfa374..76afc95 100644 --- a/packages/auto_apply/src/auto_apply/application/workflows/vetting_workflow.py +++ b/packages/auto_apply/src/auto_apply/application/workflows/vetting_workflow.py @@ -142,6 +142,7 @@ def __init__( weights: dict[str, float] | None = None, research_observer: ResearchObserverPort | None = None, page_copier: PageCopierPort | None = None, + host_pacer=None, ) -> None: """Initialize with all dependencies injected. @@ -177,6 +178,7 @@ def __init__( self._weights = weights or self.DEFAULT_WEIGHTS self._research_observer = research_observer or NullResearchObserver() self._page_copier: PageCopierPort = page_copier or NullPageCopier() + self._host_pacer = host_pacer def _cfg(self, key: str, default: Any) -> Any: """Read a dot-path config key from self._config with a default fallback.""" @@ -207,6 +209,14 @@ def _fetch_job_description(self, job: Job) -> FetchedDescription: if self._perception_port is None: return FetchedDescription(text=job.title or "", from_page=False) + if self._host_pacer is not None: + try: + self._host_pacer.wait_for_url(job.url) + except Exception as exc: + logger.debug( + "VettingWorkflow: host pacing failed (non-fatal) | %s", exc + ) + try: self._perception_port.navigate(job.url) # Canonical text path (PerceptionPort.get_page_text). Every diff --git a/packages/auto_apply/src/auto_apply/domain/constants.py b/packages/auto_apply/src/auto_apply/domain/constants.py index 0a5d763..65817be 100644 --- a/packages/auto_apply/src/auto_apply/domain/constants.py +++ b/packages/auto_apply/src/auto_apply/domain/constants.py @@ -99,7 +99,10 @@ # 4: discovery_pages gained page_index, advance_method, stop_reason and # page_count (verified pagination). Rows written at 2 and 3 stay valid. -RESEARCH_SCHEMA_VERSION: int = 4 +# 5: search_plan_frames added (the sampling frame of automated site-restricted +# search: engines/platforms searched and per-target query counts). Rows +# written at 2, 3 and 4 stay valid. +RESEARCH_SCHEMA_VERSION: int = 5 RESEARCH_SALT_ENV_VAR: str = "AA_RESEARCH_SALT" # ── EventBus Event Names (Research Module) ─────────────────────────────────── @@ -122,7 +125,10 @@ # practices documented in docs/ETHICS.md change. Stored with every signal. # 2.6: the dialog now states what uninstalling AA does to collected data # (kept by default; deletion only by explicit choice; retention holds). -CURRENT_CONSENT_VERSION: str = "2.6" +# 2.7: the dialog now also states that the sampling frame of automated +# site-restricted search is recorded (engines/platforms searched, counts +# only — never the words searched for). +CURRENT_CONSENT_VERSION: str = "2.7" # The page-copies consent (item 6) is a separate, specific consent with its # own text and version: domain/services/research_consent_text.py diff --git a/packages/auto_apply/src/auto_apply/domain/models/effective_config.py b/packages/auto_apply/src/auto_apply/domain/models/effective_config.py index 1a432d7..7a4a87d 100644 --- a/packages/auto_apply/src/auto_apply/domain/models/effective_config.py +++ b/packages/auto_apply/src/auto_apply/domain/models/effective_config.py @@ -40,6 +40,9 @@ class DiscoverySettings(_Frozen): max_pages_per_query: int between_provider_pause_min: float between_provider_pause_max: float + use_ats_site_search: bool + ats_platforms: list[str] + site_search_strategy: str class ApplicationTimingSettings(_Frozen): diff --git a/packages/auto_apply/src/auto_apply/domain/models/policy.py b/packages/auto_apply/src/auto_apply/domain/models/policy.py index e76840f..2613731 100644 --- a/packages/auto_apply/src/auto_apply/domain/models/policy.py +++ b/packages/auto_apply/src/auto_apply/domain/models/policy.py @@ -126,6 +126,13 @@ class AdminPolicy: # unknown name is refused at registry build, naming the allowed names. motion_profile: str | None = None + # -- Search scope --------------------------------------------------------- + # Field-level lock on which hiring platforms automated site-restricted + # search may target. None = user-controlled. A list locks the set (an + # empty list disables targeting on this device), is applied after + # config_overrides so the named lock wins, and shows as locked in the UI. + search_platforms: list[str] | None = None + # -- Data collection ---------------------------------------------------- disable_research_collection: bool | None = None @@ -244,6 +251,7 @@ def has_any_constraint(self) -> bool: self.force_respect_robots_txt is not None, self.min_action_delay_seconds is not None, self.motion_profile is not None, + self.search_platforms is not None, self.disable_research_collection is not None, bool(self.config_overrides), ]) @@ -259,6 +267,7 @@ def __repr__(self) -> str: "force_robots_txt" if self.force_respect_robots_txt else None, f"min_delay={self.min_action_delay_seconds}s" if self.min_action_delay_seconds is not None else None, # noqa: E501 f"motion={self.motion_profile}" if self.motion_profile is not None else None, + f"platforms={len(self.search_platforms)}" if self.search_platforms is not None else None, "no_research" if self.disable_research_collection else None, f"overrides({len(self.config_overrides)})" if self.config_overrides else None, # noqa: E501 ] diff --git a/packages/auto_apply/src/auto_apply/domain/models/profile.py b/packages/auto_apply/src/auto_apply/domain/models/profile.py index 9f8bf77..a7d2d09 100644 --- a/packages/auto_apply/src/auto_apply/domain/models/profile.py +++ b/packages/auto_apply/src/auto_apply/domain/models/profile.py @@ -625,6 +625,40 @@ def _validate_motion_overrides(cls, v: Any, info: ValidationInfo) -> Any: except ValueError as exc: raise ValueError(f"Invalid motion override: {exc}") from exc return dict(v) + + # The persistent default for ATS-targeted (site-restricted) search — the + # layered-settings pattern (the per-session wizard choice overrides it, + # and AdminPolicy.search_platforms locks it). None inherits + # runtime_defaults.yaml's discovery.ats_platforms; an empty list turns + # the feature off for this profile. + search_platforms: list[str] | None = Field( + None, + description=( + "Hiring platforms AA may target with automated site-restricted " + "search (names from resources/ats/). None inherits the app-wide " + "default; an empty list disables targeting for this profile. " + "Only measured or vendor-verified platforms are planned." + ), + ) + + @field_validator("search_platforms") + @classmethod + def _validate_search_platforms(cls, v: Any) -> Any: + """Normalise the platform list; unknown names are refused downstream.""" + if v is None: + return v + if not isinstance(v, list): + raise ValueError( + "search_platforms must be a list of platform names, " + f"got {type(v).__name__}." + ) + out: list[str] = [] + for item in v: + name = str(item).strip().lower() + if name and name not in out: + out.append(name) + return out + rotate_user_agent: bool = Field( False, description=( @@ -838,6 +872,11 @@ def settings(self) -> dict: result["motion_profile"] = ac.motion_profile if ac.motion_overrides: result["motion_overrides"] = dict(ac.motion_overrides) + # Emitted only when set: None means "inherit the YAML default", + # and a flat None key would shadow it. The registry folds this + # into the nested discovery section. + if ac.search_platforms is not None: + result["search_platforms"] = list(ac.search_platforms) # ── Politeness settings ─────────────────────────────────────── if self.politeness: diff --git a/packages/auto_apply/src/auto_apply/domain/models/search_instruction.py b/packages/auto_apply/src/auto_apply/domain/models/search_instruction.py index 0745da3..6be66cc 100644 --- a/packages/auto_apply/src/auto_apply/domain/models/search_instruction.py +++ b/packages/auto_apply/src/auto_apply/domain/models/search_instruction.py @@ -107,6 +107,32 @@ class SearchInstruction(BaseModel): ), ) + # ── Automated-search targeting (planner output) ───────────────────── + engine: str | None = Field( + default=None, + description=( + "When set, this instruction runs ONLY on the provider with this " + "name (e.g. 'google'). None = broadcast to every active provider " + "(the default, and the only behaviour before engine-bound " + "planning existed)." + ), + ) + surface_template: str | None = Field( + default=None, + description=( + "The search-surface URL form to use (a key in the surface's " + "catalog urls, e.g. 'web'). None = the surface's automation " + "form, unchanged from before." + ), + ) + platform: str | None = Field( + default=None, + description=( + "The ATS platform this query targets (e.g. 'greenhouse'), for " + "provenance. None when the query is not platform-targeted." + ), + ) + # ── Derived properties ────────────────────────────────────────────── @property diff --git a/packages/auto_apply/src/auto_apply/domain/models/search_query.py b/packages/auto_apply/src/auto_apply/domain/models/search_query.py index 36a27f8..1f4fd1e 100644 --- a/packages/auto_apply/src/auto_apply/domain/models/search_query.py +++ b/packages/auto_apply/src/auto_apply/domain/models/search_query.py @@ -109,6 +109,25 @@ class PlatformOffer(_Frozen): label: str = "" +class AutomationPlatformOffer(_Frozen): + """A hiring platform as a target for AUTOMATED search. + + Distinct from PlatformOffer (the query builder): the builder's label + describes hand-verification for searches a person runs, while + ``eligible`` here answers the automation floor — only measured or + vendor-documented restrictions may be planned, because automation acts + with no one reading a label. Same registry data, two readings; the + reading is decided once, in the application service, never in an + adapter. Ineligible platforms carry a plain-language reason, so a + surface never has to invent one. + """ + + name: str + status: str + eligible: bool + reason: str = "" + + def _split_terms(text: str) -> tuple[str, ...]: return tuple(part.strip() for part in text.split(",") if part.strip()) diff --git a/packages/auto_apply/src/auto_apply/domain/models/session_plan.py b/packages/auto_apply/src/auto_apply/domain/models/session_plan.py index 95ea6df..e93d611 100644 --- a/packages/auto_apply/src/auto_apply/domain/models/session_plan.py +++ b/packages/auto_apply/src/auto_apply/domain/models/session_plan.py @@ -6,7 +6,7 @@ from __future__ import annotations from datetime import datetime, timezone from enum import Enum -from typing import Literal +from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field @@ -70,6 +70,26 @@ class SearchPair(BaseModel): workplace_type: str = Field("remote", description="On‑site, hybrid, or remote.") +def _coerce_search_pairs(raw: Any) -> tuple[SearchPair, ...]: + """Coerce session-section search_pairs entries into SearchPair objects. + + Entries may be SearchPair instances or plain dicts (as JSON/YAML supplies + them). Unknown shapes are skipped rather than failing session startup. + """ + if not raw: + return () + pairs: list[SearchPair] = [] + for entry in raw: + if isinstance(entry, SearchPair): + pairs.append(entry) + elif isinstance(entry, dict): + try: + pairs.append(SearchPair(**entry)) + except Exception: + continue + return tuple(pairs) + + class SessionPlan(BaseModel): """Immutable session config assembled at startup, never changes during a run. @@ -146,6 +166,12 @@ class SessionPlan(BaseModel): # Pre‑computed search pairs (empty = derive from profile) search_pairs: tuple[SearchPair, ...] = Field(default=(), description="Explicit search tuples; overrides profile-derived queries.") + # ATS-targeted (site-restricted) automated search. The engagement flag is + # declared above with the feature flags (use_ats_site_search); the targets + # and the composition strategy live here. + ats_platforms: tuple[str, ...] = Field(default=(), description="ATS platform names (resources/ats/*.yaml) targeted when use_ats_site_search is on.") + site_search_strategy: str = Field(default="per_platform", description="Composition strategy name for site-restricted queries; the planner falls back to per_platform with a warning on an unknown name.") + @property def is_deterministic(self) -> bool: """True when this session will produce reproducible outputs.""" @@ -193,4 +219,10 @@ def from_config( browser_framework=browser_framework, has_live_browser=settings.discovery_strategy != "static_fetch", execution_mode=execution_mode, + use_ats_site_search=settings.discovery.use_ats_site_search, + ats_platforms=tuple(settings.discovery.ats_platforms), + site_search_strategy=settings.discovery.site_search_strategy, + search_pairs=_coerce_search_pairs( + config.get("session", {}).get("search_pairs", ()) + ), ) \ No newline at end of file diff --git a/packages/auto_apply/src/auto_apply/domain/models/session_report.py b/packages/auto_apply/src/auto_apply/domain/models/session_report.py index 18e60fb..b021619 100644 --- a/packages/auto_apply/src/auto_apply/domain/models/session_report.py +++ b/packages/auto_apply/src/auto_apply/domain/models/session_report.py @@ -119,6 +119,9 @@ class SessionReport: new_jobs_identified: int = 0 deduplicated_count: int = 0 discovery_sources: list[str] = field(default_factory=list) + # Engines benched this session (challenge-blocked or degraded), with + # reason where the guard supplied one. + blocked_providers: list[str] = field(default_factory=list) # Vetting stats pending_jobs_to_apply_for: int = 0 @@ -317,6 +320,7 @@ def save(self, reports_dir: Path) -> Path: "raw_results_found": self.raw_results_found, "new_jobs_identified": self.new_jobs_identified, "sources": self.discovery_sources, + "blocked_providers": self.blocked_providers, }, "vetting": { "jobs_approved": self.pending_jobs_to_apply_for, diff --git a/packages/auto_apply/src/auto_apply/domain/models/ui_contract.py b/packages/auto_apply/src/auto_apply/domain/models/ui_contract.py index c84e144..ee0a5fb 100644 --- a/packages/auto_apply/src/auto_apply/domain/models/ui_contract.py +++ b/packages/auto_apply/src/auto_apply/domain/models/ui_contract.py @@ -317,6 +317,15 @@ class SessionRequest(_FrozenModel): tuple into the rebuilt plan's ``active_providers``, and DiscoveryWorkflow's fan-out filters by that field — so a request naming one engine runs one engine. + + ``platforms`` names the hiring platforms for ATS-targeted (site- + restricted) search, as a tri-state: None means "not declared — inherit + the profile's saved choice or the configured default"; an empty tuple + means "explicitly generic search — targeting is off this session"; a + non-empty tuple targets exactly those platforms and engages targeting. + Only platforms whose search restriction is measured or vendor- + documented are ever planned; the wizards refuse the rest, and an admin + policy can lock the set outright. """ entry: EntryPoint @@ -326,6 +335,7 @@ class SessionRequest(_FrozenModel): max_results: int | None = None urls: tuple[str, ...] = () providers: tuple[str, ...] = () + platforms: tuple[str, ...] | None = None @property def seed_priority(self) -> int: @@ -444,6 +454,13 @@ def lines(self) -> tuple[str, ...]: return tuple(out) +class BlockedEngine(_FrozenModel): + """One search engine that was benched for the rest of a session.""" + + provider: str + reason: str = "" + + class SessionSummary(_FrozenModel): """The end-of-session / results view. @@ -470,6 +487,12 @@ class SessionSummary(_FrozenModel): report_path: Path | None = None autonomy_enabled: bool = False research: ResearchSessionView = ResearchSessionView() + blocked_engines: tuple[BlockedEngine, ...] = () + discovery_off_shape_dropped: int = 0 + discovery_terminal_dropped: int = 0 + discovery_routed_to_mining: int = 0 + discovery_budget_skipped: int = 0 + search_budget: int = 0 @property def duration_str(self) -> str: @@ -550,6 +573,40 @@ class SessionHistoryEntry(_FrozenModel): report_path: Path | None = None +class PlannedQueryView(_FrozenModel): + """One query the session will send, as the plan preview shows it. + + ``engine`` empty means every enabled engine (a broadcast); ``platform`` + empty means no site restriction; ``form`` is the surface's URL form name. + """ + + engine: str = "" + platform: str = "" + form: str = "" + query: str = "" + date_range: str | None = None + max_results: int = 30 + + +class SearchPlanPreview(_FrozenModel): + """What a session plans to search, computed before Start. + + Never None and never an exception — the same null-object ruling as the + rest of the port. When the planner or catalog is unavailable this is a + valid preview of the generic search with ``reason_key`` set. + """ + + site_search: bool = False + queries: tuple[PlannedQueryView, ...] = () + notes: tuple[str, ...] = () + dropped: tuple[str, ...] = () + engines: tuple[str, ...] = () + budget: int = 0 + planned_count: int = 0 + reason_key: str = "" + locked_platforms: tuple[str, ...] = () + + # ───────────────────────────────────────────────────────────────────────────── # Shared menu vocabulary (stage U3, turn 3) # ───────────────────────────────────────────────────────────────────────────── @@ -629,6 +686,27 @@ def provider_vocabulary() -> tuple[str, ...]: return tuple(SessionPlan.model_fields["active_providers"].default) +def parse_platform_names(raw: str | None) -> tuple[str, ...] | None: + """Parse a comma-separated platform list into the request's tri-state. + + None means the flag was not given (inherit the profile/config default); + "" or "none"/"off" means explicitly none this session; anything else is + the named platforms, lowercased and deduplicated. The one parser — the + CLI flag, the wizard, and the plan screen all read it. + """ + if raw is None: + return None + text = raw.strip().lower() + if text in ("", "none", "off"): + return () + out: list[str] = [] + for part in text.split(","): + name = part.strip() + if name and name not in out: + out.append(name) + return tuple(out) + + # ───────────────────────────────────────────────────────────────────────────── # Shared display vocabulary for the activity stream (stage D2) # ───────────────────────────────────────────────────────────────────────────── @@ -694,3 +772,97 @@ def activity_new( except ValueError: return tuple(records) return tuple(records[index + 1:]) + + +# ───────────────────────────────────────────────────────────────────────────── +# Shared display: the plan preview and the automation outcome block +# ───────────────────────────────────────────────────────────────────────────── +# +# Both surfaces render the plan preview and the end-of-session automation +# outcome. One formatter each, shared by the CLI and the GUI, so the two can +# never describe the same plan or the same session two different ways. The +# planner's own notes arrive as English sentences from the planner — a +# documented limitation (see the automated-search ADR); everything the +# preview itself says goes through the locale. + + +def format_search_plan_preview( + preview: SearchPlanPreview, + translate=lambda key: key, +) -> list[str]: + """The one rendering of the plan preview, shared by both surfaces.""" + lines: list[str] = [translate("searchplan.preview.title")] + if preview.reason_key: + lines.append(translate(preview.reason_key)) + if preview.locked_platforms: + lines.append( + translate("searchplan.preview.admin_lock").format( + platforms=", ".join(preview.locked_platforms) + ) + ) + if preview.site_search: + lines.append(translate("searchplan.preview.mode_targeted")) + else: + lines.append(translate("searchplan.preview.mode_generic")) + for query in preview.queries: + engine = query.engine or translate("searchplan.preview.engine_all") + scope = query.platform or translate("searchplan.preview.scope_all") + form = f" [{query.form}]" if query.form else "" + lines.append(f" {engine} · {scope}{form}: {query.query}") + lines.append( + translate("searchplan.preview.budget").format( + planned=preview.planned_count, budget=preview.budget + ) + ) + for note in preview.notes: + lines.append(f" {translate('searchplan.preview.note_prefix')} {note}") + if preview.dropped: + lines.append( + translate("searchplan.preview.dropped").format( + targets="; ".join(preview.dropped) + ) + ) + return lines + + +def format_discovery_outcome_lines( + summary: SessionSummary, + translate=lambda key: key, +) -> list[str]: + """The automation outcome block on the results view — one formatter, two + surfaces (the CLI summary and the GUI results window), parity pinned. + + Lines appear only when there is something to report. + """ + lines: list[str] = [] + if summary.blocked_engines: + lines.append(translate("searchplan.results.blocked_header")) + for engine in summary.blocked_engines: + reason = engine.reason or translate( + "searchplan.results.blocked_reason_unknown" + ) + lines.append( + translate("searchplan.results.blocked_line").format( + engine=engine.provider, reason=reason + ) + ) + if ( + summary.discovery_off_shape_dropped + or summary.discovery_terminal_dropped + or summary.discovery_routed_to_mining + ): + lines.append( + translate("searchplan.results.off_shape").format( + dropped=summary.discovery_off_shape_dropped, + terminal=summary.discovery_terminal_dropped, + routed=summary.discovery_routed_to_mining, + ) + ) + if summary.discovery_budget_skipped: + lines.append( + translate("searchplan.results.budget").format( + skipped=summary.discovery_budget_skipped, + budget=summary.search_budget, + ) + ) + return lines diff --git a/packages/auto_apply/src/auto_apply/domain/ports/research_port.py b/packages/auto_apply/src/auto_apply/domain/ports/research_port.py index 15bb69f..296649f 100644 --- a/packages/auto_apply/src/auto_apply/domain/ports/research_port.py +++ b/packages/auto_apply/src/auto_apply/domain/ports/research_port.py @@ -260,6 +260,29 @@ class DiscoveryObservation: cards: tuple[DiscoveryCardObservation, ...] = () +@dataclass(frozen=True) +class SearchPlanObservation: + """One planning pass's sampling frame — names and counts, never query text. + + Records which engines and platforms a session planned to search, how many + queries each received, how many targets were dropped, and the budget the + pass started and ended with. Privacy ruling: engines and platforms are + public identifiers; query text, titles and locations are the user's + search and are structurally absent (no field exists to hold them). + """ + + engines: tuple[str, ...] = () + platforms: tuple[str, ...] = () + queries_planned: int = 0 + per_engine: tuple[tuple[str, int], ...] = () + per_platform: tuple[tuple[str, int], ...] = () + dropped_count: int = 0 + notes_info: int = 0 + notes_warning: int = 0 + budget_before: int = 0 + budget_after: int = 0 + + # --------------------------------------------------------------------------- # Session accounting (item 3). # --------------------------------------------------------------------------- @@ -417,6 +440,16 @@ def observe_discovery(self, observation: DiscoveryObservation) -> None: """ ... + def observe_search_plan(self, observation: SearchPlanObservation) -> None: + """Submit a sampling frame: which engines/platforms were searched and + how many queries each received, plus dropped/skip counts. Names and + counts only — never query text. + + Args: + observation: The sampling-frame record for one planning pass. + """ + ... + @property def is_enabled(self) -> bool: """Whether research collection is currently active (consent given).""" @@ -445,6 +478,9 @@ def observe_application_outcome( def observe_discovery(self, observation: DiscoveryObservation) -> None: pass + def observe_search_plan(self, observation: SearchPlanObservation) -> None: + pass + @property def is_enabled(self) -> bool: return False diff --git a/packages/auto_apply/src/auto_apply/domain/ports/ui_port.py b/packages/auto_apply/src/auto_apply/domain/ports/ui_port.py index 42dc11c..f8341d9 100644 --- a/packages/auto_apply/src/auto_apply/domain/ports/ui_port.py +++ b/packages/auto_apply/src/auto_apply/domain/ports/ui_port.py @@ -25,7 +25,7 @@ import direction (tests/test_architecture.py:119-125), so the port sits in domain/ports where both adapters can see it. Verified. -Whole surface, deliberately: 27 methods. +Whole surface, deliberately: 28 methods. ----------------------------------------- AA's idiom prefers narrow ports, but this port describes a whole surface, not a capability, and the ProfileRepositoryPort precedent applies. The 26 @@ -147,6 +147,7 @@ from auto_apply.domain.models.ui_contract import ( ApprovalRequest, QueueSnapshot, + SearchPlanPreview, SessionEventRecord, SessionHistoryEntry, SessionRequest, @@ -252,6 +253,15 @@ def summary(self) -> SessionSummary: """The end-of-session results view. Never None; NO_SESSION when idle.""" ... + def preview_search_plan(self, request: SessionRequest) -> SearchPlanPreview: + """What this session plans to search, computed before Start. + + Pure and offline: no browser, no network. Never None and never + raises — when the planner or catalog is unavailable the preview + shows the generic search with the reason named. + """ + ... + def queue_snapshot(self) -> QueueSnapshot: """Work-queue status counts. Zeroed when no session exists.""" ... diff --git a/packages/auto_apply/src/auto_apply/domain/services/query_composer.py b/packages/auto_apply/src/auto_apply/domain/services/query_composer.py index a1f757b..9be7715 100644 --- a/packages/auto_apply/src/auto_apply/domain/services/query_composer.py +++ b/packages/auto_apply/src/auto_apply/domain/services/query_composer.py @@ -507,6 +507,7 @@ def build_automation_url( query: str, location: str = "", date_range: str | None = None, + template: str | None = None, ) -> str: """The URL AA's automated discovery sends. @@ -515,17 +516,31 @@ def build_automation_url( come from the catalog's codes regardless of user-facing offer status: these are the parameters AA already sends in production. An unknown date_range is omitted (the pre-catalog behaviour), never invented. + + ``template`` selects a URL form by name (a key in ``spec.urls``) instead + of the surface's automation form. Site-restricted planning uses this to + route queries to a surface's web form; None keeps the automation form + and the pinned bytes. A form that declares no date_parameter drops + date_range (documented, never invented). An unknown template name + raises — it is a configuration error, not a runtime condition. """ - if spec.automation_url is None: + if template is None: + template = spec.automation_url + if template is None: raise ValueError(f"{spec.name} has no automation URL template") - template = spec.urls[spec.automation_url] - params = _template_params(template, query, location) - if date_range is not None and template.date_parameter is not None: + if template not in spec.urls: + raise ValueError( + f"{spec.name} has no URL template {template!r} " + f"(available: {', '.join(spec.urls)})" + ) + url_template = spec.urls[template] + params = _template_params(url_template, query, location) + if date_range is not None and url_template.date_parameter is not None: option = next( ( o for o in spec.url_options - if o.name == template.date_parameter and o.option == "date" + if o.name == url_template.date_parameter and o.option == "date" ), None, ) @@ -533,4 +548,4 @@ def build_automation_url( code = option.codes.get(date_range) if code is not None: params.append((option.name, _format_option_value(option, code))) - return f"{template.base}?{urlencode(params)}" + return f"{url_template.base}?{urlencode(params)}" diff --git a/packages/auto_apply/src/auto_apply/domain/services/research_consent_text.py b/packages/auto_apply/src/auto_apply/domain/services/research_consent_text.py index 67ca148..57ef86e 100644 --- a/packages/auto_apply/src/auto_apply/domain/services/research_consent_text.py +++ b/packages/auto_apply/src/auto_apply/domain/services/research_consent_text.py @@ -69,6 +69,10 @@ "20 Python jobs in Sacramento") is stored as shown too. - Which detectors ran on each posting and how each concluded, so rates in published research have a trustworthy denominator +- Which search engines and hiring platforms were searched, and how many + searches each received — the sampling frame for automated site-restricted + search — with counts of planned, dropped and skipped targets. The words + you searched for are never included. - A public verification key that lets recipients confirm exported rows came from an unmodified AutoApply (the private key never leaves your data folder) diff --git a/packages/auto_apply/src/auto_apply/domain/services/search_planner.py b/packages/auto_apply/src/auto_apply/domain/services/search_planner.py new file mode 100644 index 0000000..0934a68 --- /dev/null +++ b/packages/auto_apply/src/auto_apply/domain/services/search_planner.py @@ -0,0 +1,436 @@ +"""Automated site-restricted search planning. + +When a session enables ATS-targeted search (``discovery.use_ats_site_search``), +this planner turns the session's titles, locations, enabled engines and +enabled platforms into an ordered set of engine-bound, site-restricted +queries. + +The rules this module exists to keep: + +* **Composition is single-sourced.** Every query string is built by + :func:`auto_apply.domain.services.query_composer.compose` from the + bundled search-surface catalog. Nothing here formats a ``site:`` clause + by hand, and nothing hard-codes an engine or a host. +* **Refusal-both, carried into automation.** An engine that cannot carry a + site restriction gets no query at all — never a degraded one — with a + note saying why. A platform below the automation evidence level is not + planned. A restriction shaped like a terminal page is never planned. +* **Deterministic.** Planning involves no chance: engines rotate innermost + and platforms outermost in caller order, so no engine host receives + consecutive queries while another enabled target is available, and the + same inputs always produce the same plan. That is also why there is no + seeded-RNG namespace for this module — there is nothing to seed. +* **Budgets are hard.** The planner never emits more queries than the + session budget, and reports exactly which targets were dropped. +* **No reallocation.** The plan is a pure function of its inputs. It reads + no yield, no history and no clock, so by construction it cannot shift + budget mid-session — which is what keeps a seeded run and a replay + comparable. + +Pure domain code: no I/O, no clock, no randomness. Engine and platform data +arrive as plain values from the caller, so the planner never imports an +adapter. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable, Iterable, Mapping, Protocol, Sequence + +from auto_apply.domain.models.search_query import QueryRequest +from auto_apply.domain.models.search_surface import ( + SearchSurfaceSpec, + VerificationStatus, +) +from auto_apply.domain.services.query_composer import compose + +#: Platform restriction statuses admitted into automated plans. +#: +#: Stricter than the interactive builder's floor by design: a person building +#: a search by hand is shown the evidence label and decides; automation acts +#: with no one reading a label, and every host it queries becomes part of +#: the research record's sampling frame. Platforms measured by hand or +#: documented by the platform itself qualify; third-party reports and +#: sample-derived guesses stay out until someone measures them +#: (docs/developer_guide/verifying_platform_search.md). +AUTOMATION_STATUSES: frozenset[VerificationStatus] = frozenset( + {VerificationStatus.MEASURED, VerificationStatus.VENDOR} +) + + +@dataclass(frozen=True) +class PlatformTarget: + """One ATS platform as a planning target. + + Plain data built from an ATS descriptor by the caller, so the planner + never imports the registry adapter. + + Attributes: + name: The descriptor name (e.g. ``"greenhouse"``). + restriction: The ``site:`` restriction (e.g. + ``"boards.greenhouse.io"``). + status: The descriptor's restriction status (a + :class:`VerificationStatus` value). + """ + + name: str + restriction: str + status: str + + +@dataclass(frozen=True) +class PlannedQuery: + """One composed, engine-bound query, ready to become a SearchInstruction.""" + + engine: str + platform: str + title: str + location: str + workplace_type: str + raw_query_string: str + surface_template: str + date_range: str | None + max_results: int + + +@dataclass(frozen=True) +class PlannerNote: + """A human-readable note about what was not planned, and why.""" + + severity: str # "info" | "warning" + message: str + + +@dataclass(frozen=True) +class PlannedSearch: + """The planner's output: the ordered queries and the notes behind them.""" + + queries: tuple[PlannedQuery, ...] = () + notes: tuple[PlannerNote, ...] = () + dropped_targets: tuple[str, ...] = () + + +class SiteSearchStrategy(Protocol): + """How platform targets are grouped and ordered for planning. + + Selected by configuration. A strategy decides grouping and order, never + query text — the composition of every query still goes through the + catalog's composer. + + Grouped composition (several restrictions in one query) needs composer + support that does not exist yet, so a strategy returning a group of more + than one platform is refused loudly and planning falls back to one query + per platform. + """ + + @property + def name(self) -> str: + """The strategy's registry name.""" + ... + + def group( + self, platforms: list[PlatformTarget] + ) -> list[tuple[PlatformTarget, ...]]: + """The platforms to plan, as ordered groups.""" + ... + + +class PerPlatformStrategy: + """One query per platform per engine — the default strategy.""" + + name = "per_platform" + + def group( + self, platforms: list[PlatformTarget] + ) -> list[tuple[PlatformTarget, ...]]: + return [(platform,) for platform in platforms] + + +class SearchPlanner: + """Plans site-restricted searches across engines and platforms. + + Constructed once per session with catalog and platform data as plain + values; :meth:`plan` is then called for each search base set. + + Args: + engine_specs: Catalog specs for automatable engines, by engine name. + platform_targets: Planning data for known platforms, by name. + is_terminal: Optional predicate — true when a URL is a terminal + (success/confirm/closed) page. Restrictions shaped like one are + never planned. + strategy_name: The configured composition strategy. Unknown names + fall back to ``per_platform`` with a warning note. + strategies: Extra strategies to register beyond the default. + """ + + def __init__( + self, + *, + engine_specs: Mapping[str, SearchSurfaceSpec], + platform_targets: Mapping[str, PlatformTarget], + is_terminal: Callable[[str], bool] | None = None, + strategy_name: str = "per_platform", + strategies: Iterable[SiteSearchStrategy] = (), + ) -> None: + self._engine_specs = dict(engine_specs) + self._platform_targets = dict(platform_targets) + self._is_terminal = is_terminal or (lambda _url: False) + self._strategies: dict[str, SiteSearchStrategy] = { + PerPlatformStrategy.name: PerPlatformStrategy(), + } + for strategy in strategies: + self._strategies[strategy.name] = strategy + self._strategy_name = strategy_name + + def plan( + self, + *, + bases: Sequence[tuple[str, str, str]], + engines: Sequence[str], + platforms: Sequence[str], + date_range: str | None, + max_results: int, + budget: int, + ) -> PlannedSearch: + """Plan the searches for the given bases. + + Args: + bases: ``(title, location, workplace_type)`` triples. + engines: The session's enabled engines, in the caller's order. + platforms: The session's enabled platform names, in caller order. + date_range: Carried onto every query; applied at run time only + where the engine's form supports it. + max_results: Per-query result cap (already resource-clamped). + budget: Hard cap on queries produced. Truncation is reported, + never silent. + + Returns: + The ordered queries plus notes naming every exclusion. An empty + query list with notes is a valid, honest answer. + """ + notes: list[PlannerNote] = [] + queries: list[PlannedQuery] = [] + dropped: list[str] = [] + + usable_engines = self._usable_engines(engines, notes) + usable_platforms = self._usable_platforms(platforms, notes) + clean_bases = [b for b in bases if b[0].strip()] + if len(clean_bases) != len(bases): + notes.append( + PlannerNote("info", "Bases with an empty title were skipped.") + ) + if not usable_engines or not usable_platforms or not clean_bases: + return PlannedSearch((), tuple(notes), ()) + + strategy = self._strategies.get(self._strategy_name) + if strategy is None: + notes.append( + PlannerNote( + "warning", + f"Unknown site-search strategy {self._strategy_name!r}; " + "using 'per_platform'.", + ) + ) + strategy = self._strategies[PerPlatformStrategy.name] + + for title, location, workplace_type in clean_bases: + groups = strategy.group(list(usable_platforms)) + if any(len(group) != 1 for group in groups): + notes.append( + PlannerNote( + "warning", + f"Strategy {strategy.name!r} produced a multi-platform " + "group, which the composer cannot combine into one " + "query yet; planning one query per platform instead.", + ) + ) + groups = [(platform,) for platform in usable_platforms] + for group in groups: + target = group[0] + for engine in usable_engines: + label = f"{target.name} on {engine} ({title})" + if len(queries) >= budget: + dropped.append(label) + continue + planned = self._compose_query( + engine=engine, + target=target, + title=title, + location=location, + workplace_type=workplace_type, + date_range=date_range, + max_results=max_results, + notes=notes, + ) + if planned is not None: + queries.append(planned) + + if dropped: + shown = "; ".join(dropped[:5]) + (" ..." if len(dropped) > 5 else "") + notes.append( + PlannerNote( + "warning", + f"The query budget ({budget}) was reached; " + f"{len(dropped)} planned search(es) were not scheduled: " + f"{shown}", + ) + ) + return PlannedSearch(tuple(queries), tuple(notes), tuple(dropped)) + + # ------------------------------------------------------------------ + # Admission + # ------------------------------------------------------------------ + + def _usable_engines( + self, engines: Sequence[str], notes: list[PlannerNote] + ) -> list[str]: + usable: list[str] = [] + for name in engines: + spec = self._engine_specs.get(name) + if spec is None: + notes.append( + PlannerNote( + "warning", + f"Engine {name!r} is not in the search-surface " + "catalog; skipped.", + ) + ) + continue + if spec.automation_url is None: + notes.append( + PlannerNote( + "info", + f"Engine {name!r} has no automated form in the " + "catalog; skipped.", + ) + ) + continue + usable.append(name) + return usable + + def _usable_platforms( + self, platforms: Sequence[str], notes: list[PlannerNote] + ) -> list[PlatformTarget]: + usable: list[PlatformTarget] = [] + for name in platforms: + target = self._platform_targets.get(name) + if target is None: + notes.append( + PlannerNote( + "warning", + f"Platform {name!r} has no ATS descriptor; skipped.", + ) + ) + continue + if not target.restriction: + notes.append( + PlannerNote( + "info", + f"Platform {name!r} declares no search restriction; " + "skipped.", + ) + ) + continue + try: + status = VerificationStatus(target.status) + except ValueError: + notes.append( + PlannerNote( + "warning", + f"Platform {name!r} has an unknown restriction " + f"status {target.status!r}; skipped.", + ) + ) + continue + if status not in AUTOMATION_STATUSES: + notes.append( + PlannerNote( + "info", + f"Platform {name!r} is {status.value}-verified, below " + "the level automation requires (measured or " + "vendor-documented). It is not planned until " + "verified.", + ) + ) + continue + if self._is_terminal(f"https://{target.restriction}"): + notes.append( + PlannerNote( + "warning", + f"Platform {name!r}'s restriction looks like a " + "terminal page (success/confirm); never planned.", + ) + ) + continue + usable.append(target) + return usable + + # ------------------------------------------------------------------ + # Composition (single-sourced through the catalog's composer) + # ------------------------------------------------------------------ + + def _compose_query( + self, + *, + engine: str, + target: PlatformTarget, + title: str, + location: str, + workplace_type: str, + date_range: str | None, + max_results: int, + notes: list[PlannerNote], + ) -> PlannedQuery | None: + spec = self._engine_specs[engine] + template = spec.site_template + if template is None: + notes.append( + PlannerNote( + "warning", + f"Engine {engine!r} declares no form for site-restricted " + f"searches; {target.name} is not planned on it.", + ) + ) + return None + result = compose( + QueryRequest( + surface=engine, + site=target.restriction, + phrases=(title,), + ), + spec, + ) + if result.bar_text is None: + refusal = next( + (n.message for n in result.notes if n.severity == "refused"), + "the engine cannot carry a site restriction", + ) + notes.append( + PlannerNote( + "info", + f"{target.name} on {engine}: not planned — {refusal}", + ) + ) + return None + bar_text = result.bar_text + # Location is user intent. Engines whose form has a location field + # get it through the URL at run time; engines without one get the + # place as bare words, the way a person would type it. + url_template = spec.urls[template] + has_location_field = any( + parameter.source == "location" + for parameter in url_template.parameters + ) + if location.strip() and not has_location_field: + bar_text = f"{bar_text} {location.strip()}" + return PlannedQuery( + engine=engine, + platform=target.name, + title=title, + location=location, + workplace_type=workplace_type, + raw_query_string=bar_text, + surface_template=template, + date_range=date_range, + max_results=max_results, + ) diff --git a/packages/auto_apply/src/auto_apply/infrastructure/composition_root.py b/packages/auto_apply/src/auto_apply/infrastructure/composition_root.py index 455e519..ffa9c73 100644 --- a/packages/auto_apply/src/auto_apply/infrastructure/composition_root.py +++ b/packages/auto_apply/src/auto_apply/infrastructure/composition_root.py @@ -74,6 +74,10 @@ ) if TYPE_CHECKING: + from auto_apply.domain.models.ui_contract import ( + SearchPlanPreview, + SessionRequest, + ) from auto_apply.application.agent.orchestrator import AgentOrchestrator #from auto_apply.application.agent.task_kernel import TaskKernel from auto_apply.domain.models.profile import UserProfile @@ -107,6 +111,7 @@ "build_page_copier", "build_query_builder", "build_research_consent", + "build_search_plan_preview", "build_session", "build_session_controller", "export_research_bundle", @@ -149,6 +154,60 @@ def _build_ats_registry(): return None +def _build_search_planner(plan, ats_registry): + """Build the site-restricted search planner — one builder, shared by the + session (build_orchestrator) and the plan preview. + + Plain data in, nothing else: engine specs come from the bundled catalog + (a user override must never change automated bytes), platform data comes + from the ATS registry. Any failure degrades to no planner, which leaves + the default query path untouched. + """ + try: + from auto_apply.adapters.secondary.discovery.search_surface_registry import ( # noqa: PLC0415 + list_surface_names, + load_surface_bundled, + ) + from auto_apply.domain.services.search_planner import ( # noqa: PLC0415 + PlatformTarget, + SearchPlanner, + ) + + engine_specs = {} + for surface_name in list_surface_names(): + try: + spec = load_surface_bundled(surface_name) + except Exception: + continue + if spec.automation_url is not None: + engine_specs[surface_name] = spec + + platform_targets = {} + if ats_registry is not None: + for descriptor in ats_registry.all_descriptors(): + platform_targets[descriptor.name] = PlatformTarget( + name=descriptor.name, + restriction=descriptor.search_restriction, + status=descriptor.search_restriction_status, + ) + + return SearchPlanner( + engine_specs=engine_specs, + platform_targets=platform_targets, + is_terminal=( + ats_registry.is_terminal_page if ats_registry is not None else None + ), + strategy_name=plan.site_search_strategy, + ) + except Exception as exc: + logger.warning( + "composition_root: search planner unavailable — ATS-targeted " + "planning is disabled: %s", + exc, + ) + return None + + def _warn_if_legacy_research_db() -> None: """Warn when a pre-relocation research database exists. @@ -780,6 +839,27 @@ def build_orchestrator( # noqa: PLR0914 interaction_pacing_rng = behavior_params.make_rng("interaction.pacing") motion_pointer_rng = behavior_params.make_rng("motion.pointer") motion_wheel_rng = behavior_params.make_rng("motion.wheel") + host_pacing_rng = behavior_params.make_rng("discovery.host_pacing") + + # The one per-host pacing owner, shared by discovery, vetting and + # application navigation. Its floor is the (previously unread) config + # pair discovery.between_provider_pause_min/max; jitter is seeded. + _host_pacer = None + try: + from auto_apply.application.services.host_pacing import HostPacer # noqa: PLC0415 + + _discovery_settings = registry.get_effective_settings().discovery + _host_pacer = HostPacer( + min_interval_s=_discovery_settings.between_provider_pause_min, + max_interval_s=_discovery_settings.between_provider_pause_max, + rng=host_pacing_rng, + ) + except Exception as _exc: + logger.warning( + "build_orchestrator: host pacing unavailable — navigations are " + "not per-host paced this session | %s", + _exc, + ) page_action_tool = ( PageActionService( @@ -789,6 +869,7 @@ def build_orchestrator( # noqa: PLR0914 pointer_rng=motion_pointer_rng, wheel_rng=motion_wheel_rng, readiness=dom_readiness, + host_pacer=_host_pacer, ) if driver is not None else None @@ -1103,6 +1184,7 @@ def _build() -> VerifiedPageAdvancer: research_observer=research_observer, readiness=dom_readiness, page_action=page_action_tool, + host_pacer=_host_pacer, ), BingProvider( browser=driver, @@ -1117,6 +1199,7 @@ def _build() -> VerifiedPageAdvancer: research_observer=research_observer, readiness=dom_readiness, page_action=page_action_tool, + host_pacer=_host_pacer, ), IndeedProvider( browser=driver, @@ -1131,6 +1214,7 @@ def _build() -> VerifiedPageAdvancer: research_observer=research_observer, readiness=dom_readiness, page_action=page_action_tool, + host_pacer=_host_pacer, ), ] @@ -1176,6 +1260,43 @@ def _company_page_scraper(careers_url: str) -> list: # noqa: F811 dedup = DeduplicationManager() + # ── Site-restricted search planner (ATS-targeted automated search) ────── + # One builder, shared with the plan preview: engine specs come from the + # bundled catalog (a user override must never change automated bytes), + # platform data from the ATS registry. Any failure degrades to no + # planner, which leaves the default query path untouched. + _search_planner = _build_search_planner(plan, _ats_registry) + + # The discovery verifier's engine-host set, derived from the bundled + # catalog (job boards are not engines). When unavailable, the verifier's + # pinned fallback applies. + _engine_hosts = None + try: + from urllib.parse import urlparse # noqa: PLC0415 + + from auto_apply.adapters.secondary.discovery.search_surface_registry import ( # noqa: PLC0415 + list_surface_names, + load_surface_bundled, + ) + + _hosts: set[str] = set() + for _surface_name in list_surface_names(): + try: + _spec = load_surface_bundled(_surface_name) + except Exception: + continue + if _spec.kind != "engine": + continue + _host = urlparse(_spec.homepage).netloc.lower().removeprefix("www.") + if _host: + _hosts.add(_host) + if _hosts: + _engine_hosts = tuple(sorted(_hosts)) + except Exception as _exc: + logger.debug( + "composition_root: engine-host derivation unavailable: %s", _exc + ) + # ── Deterministic RNG streams ────────────────────────────────────────────── # BehaviorParameters is already constructed above (see cascade section). apps_workflow_rng = behavior_params.make_rng("applications_workflow") @@ -1224,6 +1345,8 @@ def _company_page_scraper(careers_url: str) -> list: # noqa: F811 browser_lease=browser_lease, research_observer=research_observer, provider_order_rng=behavior_params.make_rng("discovery.provider_order"), + search_planner=_search_planner, + engine_hosts=_engine_hosts, ) vetting_workflow = VettingWorkflow( @@ -1243,6 +1366,7 @@ def _company_page_scraper(careers_url: str) -> list: # noqa: F811 profile, research_active=not isinstance(research_observer, NullResearchObserver), ), + host_pacer=_host_pacer, ) # ── Context manager — tab/window switching for offsite apply clicks ──── @@ -1561,6 +1685,37 @@ def build_query_builder(profile: "UserProfile | None" = None) -> "QueryBuilderSe ) +def build_search_plan_preview( + profile: "UserProfile", + request: "SessionRequest", + registry: CapabilitiesRegistry | None = None, +) -> "SearchPlanPreview": + """Build the pre-session plan preview for both surfaces. + + Builds (or reuses) the capabilities registry so the preview reflects the + same three-tier config merge and any admin lock the session would get, + then plans with the same planner build_orchestrator uses. Offline: no + browser is launched and no network is touched. A registry or catalog + failure degrades to a generic preview with the reason named — it never + raises into a surface. + """ + from auto_apply.application.services.search_preview import ( # noqa: PLC0415 + preview_search_plan, + ) + + if registry is None: + registry = CapabilitiesRegistry.build(user_profile=profile) + plan = registry.get_session_plan() + planner = _build_search_planner(plan, _build_ats_registry()) + return preview_search_plan( + profile, + plan, + planner, + request, + admin_policy=registry.get_admin_policy(), + ) + + def _register_exit_shutdown( controller: "SessionController", instances: "InstanceRegistry | None" = None, @@ -1655,6 +1810,22 @@ def build_session_controller( # profile only as one-click defaults and never writes to it. query_builder = build_query_builder(profile) + # The in-session plan previewer: the same preview the wizards show, + # computed against the session's own registry and the shared planner + # builder, so the port's preview can never drift from the run. + def _plan_previewer(request: "SessionRequest") -> "SearchPlanPreview": + from auto_apply.application.services.search_preview import ( # noqa: PLC0415 + preview_search_plan, + ) + + return preview_search_plan( + profile, + registry.get_session_plan(), + _build_search_planner(registry.get_session_plan(), _build_ats_registry()), + request, + admin_policy=registry.get_admin_policy(), + ) + # 3. Assemble the controller (all deps injected). If assembly fails after # the orchestrator exists, its browser has no owner yet — release it here # rather than stranding it against the shared profile directory. @@ -1666,6 +1837,7 @@ def build_session_controller( profile_repo=profile_repo, research_consent=research_consent, query_builder=query_builder, + plan_previewer=_plan_previewer, ) except Exception: orchestrator.shutdown() diff --git a/packages/auto_apply/src/auto_apply/infrastructure/registry.py b/packages/auto_apply/src/auto_apply/infrastructure/registry.py index 50cd99c..3d3bbf7 100644 --- a/packages/auto_apply/src/auto_apply/infrastructure/registry.py +++ b/packages/auto_apply/src/auto_apply/infrastructure/registry.py @@ -168,6 +168,9 @@ def _new_session_id() -> str: "max_pages_per_query": 1, "between_provider_pause_min": 1.0, "between_provider_pause_max": 2.0, + "use_ats_site_search": False, + "ats_platforms": [], + "site_search_strategy": "per_platform", "degradation_collapse_ratio": 0.15, "degradation_page_bytes_ratio": 0.25, "degradation_min_samples": 3, @@ -455,6 +458,26 @@ def _merge_config( motion_section["overrides"] = overrides merged["motion"] = motion_section + # Search-platform reconciliation (the same input -> resolution -> + # resolved pattern as the browser pick and the motion profile). The + # profile carries the user's choice as a flat declarative input; + # SessionPlan reads the nested discovery section. Fold it in here so + # the two can never drift. AdminPolicy.search_platforms is applied + # below with the other admin overrides, so the named lock beats even + # a full-section config_overrides replacement. + platforms_pick = merged.get("search_platforms") + merged.pop("search_platforms", None) + if platforms_pick is not None: + if not isinstance(platforms_pick, list): + raise ValueError( + "search_platforms must be a list of platform names, got " + f"{type(platforms_pick).__name__}" + ) + discovery_section = dict(merged.get("discovery") or {}) + discovery_section["ats_platforms"] = list(platforms_pick) + discovery_section["use_ats_site_search"] = bool(platforms_pick) + merged["discovery"] = discovery_section + if admin_policy: for key, value in admin_policy.config_overrides.items(): merged[key] = value @@ -467,6 +490,21 @@ def _merge_config( "Admin policy locks motion profile | profile=%s", admin_policy.motion_profile, ) + if admin_policy.search_platforms is not None: + locked_platforms = list(admin_policy.search_platforms) + if platforms_pick: + logger.info( + "Admin policy locks search platforms; user " + "search-platform settings ignored" + ) + discovery_section = dict(merged.get("discovery") or {}) + discovery_section["ats_platforms"] = locked_platforms + discovery_section["use_ats_site_search"] = bool(locked_platforms) + merged["discovery"] = discovery_section + logger.info( + "Admin policy locks search platforms | platforms=%s", + locked_platforms, + ) if is_low_resource: # NOTE: "static_fetch" was removed from these overrides on diff --git a/packages/auto_apply/src/auto_apply/main.py b/packages/auto_apply/src/auto_apply/main.py index f628bb2..6d8ca71 100644 --- a/packages/auto_apply/src/auto_apply/main.py +++ b/packages/auto_apply/src/auto_apply/main.py @@ -128,6 +128,7 @@ def _pre_import_parse() -> None: # Now it's safe to import auto_apply modules — config paths are resolved. # ═════════════════════════════════════════════════════════════════════════════ +from auto_apply.domain.models.ui_contract import parse_platform_names from auto_apply.infrastructure.logging_setup import setup_logging from auto_apply.infrastructure.composition_root import build_session @@ -257,12 +258,14 @@ def launch_gui(profile_repo, profile_override=None) -> None: sys.exit(1) -def launch_cli(profile_repo_factory, profile_override=None) -> None: +def launch_cli(profile_repo_factory, profile_override=None, platforms_override=None) -> None: """Launches the command-line interface. Args: profile_repo_factory: Callable that returns a ProfileRepository. profile_override: Optional profile name or path from ``--profile``. + platforms_override: Optional platform tuple from ``--platforms`` + (() disables targeting; None leaves the question to the wizard). """ try: from auto_apply.adapters.primary.cli.startup import CLIStartup # noqa: PLC0415 @@ -270,6 +273,7 @@ def launch_cli(profile_repo_factory, profile_override=None) -> None: cli = CLIStartup( profile_repo_factory=profile_repo_factory, profile_override=profile_override, + platforms_override=platforms_override, ) cli.run() except KeyboardInterrupt: @@ -546,6 +550,19 @@ def _handle_verify_research(args) -> None: sys.exit(run_verify_research(Path(args.verify_research).expanduser())) +def _handle_print_plan(args, profile_repo) -> None: + """Print the session plan preview and exit (no session, no browser). + + Composition only — rendering lives in the CLI adapter, so this file's + pinned print-site count does not move (the research-summary precedent). + """ + from auto_apply.adapters.primary.cli.plan_screen import ( # noqa: PLC0415 + run_print_plan, + ) + + sys.exit(run_print_plan(args, profile_repo)) + + def _handle_search(args, profile_repo) -> None: """Build a search query (interactive or scripted), then exit. @@ -1026,6 +1043,26 @@ def _sigint_handler(sig, frame): action="store_true", help="Forget every remembered search, then exit.", ) + parser.add_argument( + "--platforms", + metavar="NAMES", + default=None, + help=( + "Hiring platforms for automated site-restricted search this " + "session, comma-separated (e.g. --platforms jobvite). 'none' " + "disables platform targeting. Only measured or vendor-verified " + "platforms are ever planned." + ), + ) + parser.add_argument( + "--print-plan", + action="store_true", + help=( + "Print what a session would search (engines, platforms, queries, " + "budget) and exit. Offline; starts no session and no browser. " + "Composes with --seed and --platforms." + ), + ) parser.add_argument( "--uninstall", action="store_true", @@ -1215,6 +1252,10 @@ def _sigint_handler(sig, frame): if args.verify_research: _handle_verify_research(args) + # 4h. Plan preview (exits after printing — no session, no browser) + if args.print_plan: + _handle_print_plan(args, profile_repo) + # 4g. Search builder (exits after building — no session started) if ( args.search @@ -1251,6 +1292,7 @@ def _sigint_handler(sig, frame): launch_cli( profile_repo_factory=build_session, profile_override=profile_override, + platforms_override=parse_platform_names(args.platforms), ) else: logger.info("Launching GUI") diff --git a/packages/auto_apply/src/auto_apply/resources/config/runtime_defaults.yaml b/packages/auto_apply/src/auto_apply/resources/config/runtime_defaults.yaml index 193a520..5a64b50 100644 --- a/packages/auto_apply/src/auto_apply/resources/config/runtime_defaults.yaml +++ b/packages/auto_apply/src/auto_apply/resources/config/runtime_defaults.yaml @@ -164,6 +164,22 @@ discovery: between_provider_pause_min: 1.0 # ↓ 2.0 → 1.0 between_provider_pause_max: 2.0 # ↓ 5.0 → 2.0 + # ── ATS-targeted (site-restricted) automated search ────────────────────── + #! Off by default: automated queries stay byte-identical to before this + #! feature existed. When on, discovery plans site:-restricted queries across + #! ats_platforms instead of the generic "{title} jobs in {location}" query. + use_ats_site_search: false + #! ATS platform names to target (a file name in resources/ats/, e.g. + #! greenhouse). Only platforms whose restriction is vendor-documented or + #! hand-measured are planned; 'unverified' platforms are excluded from + #! automation until verified (see + #! docs/developer_guide/verifying_platform_search.md). + ats_platforms: [] + #! Composition strategy for site-restricted queries. 'per_platform' writes + #! one site:-restricted query per platform per engine; further strategies + #! plug in through the planner's registry. + site_search_strategy: per_platform + # ───────────────────────────────────────────────────────────────────────────── # Applications workflow tuning # ───────────────────────────────────────────────────────────────────────────── diff --git a/packages/auto_apply/src/auto_apply/resources/locales/en.json b/packages/auto_apply/src/auto_apply/resources/locales/en.json index 9c65c74..53bdeac 100644 --- a/packages/auto_apply/src/auto_apply/resources/locales/en.json +++ b/packages/auto_apply/src/auto_apply/resources/locales/en.json @@ -277,5 +277,57 @@ "recent_loaded": "Showing a remembered search. Edit the fields to build a new one.", "recent_unavailable": "That search used a site that is not available here." } + }, + "searchplan": { + "platform": { + "not_eligible": "Not yet verified for automated search — the user guide's verification checklist shows how to verify it by hand." + }, + "pick": { + "prompt": "Hiring platforms to target automatically (comma-separated):", + "choose": "Choose", + "default_off": "none", + "unverified_count": "{count} more platforms are not yet verified for automated search (type v to list them).", + "unknown": "Not a searchable platform, skipped: {name}", + "none_eligible": "No hiring platforms are verified for automated search yet — a standard search will run.", + "unavailable": "The platform list could not be loaded — a standard search will run." + }, + "preview": { + "title": "What AutoApply will search", + "mode_targeted": "Targeted search — only the platforms you chose:", + "mode_generic": "Standard search — every enabled engine, for each title and location:", + "engine_all": "all engines", + "scope_all": "everywhere", + "budget": "{planned} searches planned — session budget {budget}.", + "note_prefix": "note:", + "dropped": "Not planned: {targets}", + "unavailable": "Platform targeting is unavailable: {reason}. A standard search will run.", + "admin_lock": "Locked by your administrator: {platforms}", + "confirm": "Start this search? [Y/n/r]:", + "reason_no_planner": "the search catalog could not be loaded" + }, + "results": { + "blocked_header": "Search engines stopped this session:", + "blocked_line": "{engine} — {reason}", + "blocked_reason_unknown": "no reason recorded", + "off_shape": "Dropped {dropped} off-platform results, skipped {terminal} terminal pages, routed {routed} to company pages.", + "budget": "Search budget: {skipped} planned searches were skipped once the budget of {budget} was reached." + }, + "gui": { + "frame": "Hiring platforms (automatic search)", + "unverified_suffix": "(not yet verified)", + "unverified_count": "{count} more platforms are not yet verified for automated search.", + "locked": "Locked by your administrator: {platforms}", + "unavailable": "Platform targeting unavailable (platform registry not loaded)", + "none": "none", + "preview_button": "Preview plan…", + "preview_title": "Planned searches", + "settings_label": "Hiring platforms for automatic search:", + "settings_locked": "Locked by your administrator: {platforms}", + "settings_note": "AA searches these platforms' postings directly. Only verified platforms can be chosen." + }, + "cli": { + "no_profile": "No profile found — cannot show the plan without one.", + "override_note": "Platforms from --platforms: {platforms}" + } } } \ No newline at end of file diff --git a/packages/auto_apply/src/auto_apply/resources/locales/es.json b/packages/auto_apply/src/auto_apply/resources/locales/es.json index 3975fa9..b566518 100644 --- a/packages/auto_apply/src/auto_apply/resources/locales/es.json +++ b/packages/auto_apply/src/auto_apply/resources/locales/es.json @@ -239,5 +239,57 @@ "recent_loaded": "Se muestra una búsqueda guardada. Edite los campos para crear una nueva.", "recent_unavailable": "Esa búsqueda usaba un sitio que no está disponible aquí." } + }, + "searchplan": { + "platform": { + "not_eligible": "Aún no está verificada para la búsqueda automática — la lista de verificación de la guía del usuario muestra cómo verificarla a mano." + }, + "pick": { + "prompt": "Plataformas de contratación a buscar automáticamente (separadas por comas):", + "choose": "Elija", + "default_off": "ninguna", + "unverified_count": "{count} plataformas más aún no están verificadas para la búsqueda automática (escriba v para verlas).", + "unknown": "No es una plataforma válida, se omitió: {name}", + "none_eligible": "Aún no hay plataformas verificadas para la búsqueda automática — se hará una búsqueda normal.", + "unavailable": "No se pudo cargar la lista de plataformas — se hará una búsqueda normal." + }, + "preview": { + "title": "Lo que AutoApply buscará", + "mode_targeted": "Búsqueda dirigida — solo las plataformas que eligió:", + "mode_generic": "Búsqueda normal — todos los buscadores activos, por cada puesto y ubicación:", + "engine_all": "todos los buscadores", + "scope_all": "en todas partes", + "budget": "{planned} búsquedas planeadas — límite de sesión: {budget}.", + "note_prefix": "nota:", + "dropped": "No planeadas: {targets}", + "unavailable": "La búsqueda dirigida no está disponible: {reason}. Se hará una búsqueda normal.", + "admin_lock": "Bloqueado por su administrador: {platforms}", + "confirm": "¿Iniciar esta búsqueda? [Y/n/r]:", + "reason_no_planner": "no se pudo cargar el catálogo de búsqueda" + }, + "results": { + "blocked_header": "Buscadores detenidos en esta sesión:", + "blocked_line": "{engine} — {reason}", + "blocked_reason_unknown": "sin motivo registrado", + "off_shape": "Se descartaron {dropped} resultados fuera de la plataforma, se omitieron {terminal} páginas terminales, se enviaron {routed} a páginas de empresa.", + "budget": "Límite de búsqueda: {skipped} búsquedas planeadas se omitieron al alcanzar el límite de {budget}." + }, + "gui": { + "frame": "Plataformas de contratación (búsqueda automática)", + "unverified_suffix": "(aún no verificada)", + "unverified_count": "{count} plataformas más aún no están verificadas para la búsqueda automática.", + "locked": "Bloqueado por su administrador: {platforms}", + "unavailable": "Búsqueda dirigida no disponible (no se cargó el registro de plataformas)", + "none": "ninguna", + "preview_button": "Ver plan…", + "preview_title": "Búsquedas planeadas", + "settings_label": "Plataformas de contratación para búsqueda automática:", + "settings_locked": "Bloqueado por su administrador: {platforms}", + "settings_note": "AA busca directamente en estas plataformas. Solo se pueden elegir plataformas verificadas." + }, + "cli": { + "no_profile": "No se encontró un perfil — no se puede mostrar el plan sin uno.", + "override_note": "Plataformas de --platforms: {platforms}" + } } } \ No newline at end of file diff --git a/packages/auto_apply/src/auto_apply/resources/search_surfaces/google.surface.yaml b/packages/auto_apply/src/auto_apply/resources/search_surfaces/google.surface.yaml index 8db769e..54c4efb 100644 --- a/packages/auto_apply/src/auto_apply/resources/search_surfaces/google.surface.yaml +++ b/packages/auto_apply/src/auto_apply/resources/search_surfaces/google.surface.yaml @@ -25,6 +25,7 @@ urls: base: "https://www.google.com/search" parameters: - {name: q, source: query} + date_parameter: tbs jobs: base: "https://www.google.com/search" parameters: @@ -42,6 +43,10 @@ urls: date_parameter: tbs default_url: web automation_url: jobs +# Site-restricted automation runs on web results: the jobs vertical's cards +# carry no followable per-card URL (measured 2026-10-07), and site: is a +# web-search operator. +site_template: web operators: - name: site diff --git a/packages/auto_apply/tests/adapters/test_cli_wizard.py b/packages/auto_apply/tests/adapters/test_cli_wizard.py index 7004927..d0a2210 100644 --- a/packages/auto_apply/tests/adapters/test_cli_wizard.py +++ b/packages/auto_apply/tests/adapters/test_cli_wizard.py @@ -317,3 +317,148 @@ def test_max_results_defaults_to_100_on_bad_input(): result = _run_wizard(["", "", "", "", "", "not-a-number"]) assert result is not None assert result.max_results == 100 + + +# ───────────────────────────────────────────────────────────────────────────── +# Platforms + preview (new surface; every pin names the mutation it catches) +# ───────────────────────────────────────────────────────────────────────────── + +from auto_apply.domain.models.search_query import AutomationPlatformOffer # noqa: E402 +from auto_apply.domain.models.ui_contract import SearchPlanPreview # noqa: E402 + + +def _offer(name, eligible=True, status="measured", reason=""): + return AutomationPlatformOffer( + name=name, status=status, eligible=eligible, reason=reason + ) + + +def _wizard(vocabulary=(), preview_factory=None, override=None, profile=None): + return CLIWizard( + profile=profile, + platforms_vocabulary=vocabulary, + preview_factory=preview_factory, + platforms_override=override, + ) + + +def test_platforms_blank_inherits__teeth(): + """TEETH: a blank answer inherits the profile/config default (None). + Mutation caught: returning () for blank.""" + wizard = _wizard(vocabulary=(_offer("jobvite"),)) + with patch("builtins.input", side_effect=["", "", "", "", "", "", ""]): + result = wizard.run() + assert result is not None + assert result.platforms is None + + +def test_platforms_none_disables__teeth(): + """TEETH: 'none' is an explicit off (()). Mutation caught: treating it + as inherit or as a platform name.""" + wizard = _wizard(vocabulary=(_offer("jobvite"),)) + with patch("builtins.input", side_effect=["", "", "", "none", "", "", ""]): + result = wizard.run() + assert result is not None + assert result.platforms == () + + +def test_platforms_names_chosen_and_unknown_dropped__teeth(capsys): + """TEETH: chosen names land on the request; unknown names are dropped + with a printed note, never silently. Mutation caught: silently accepting + or silently dropping.""" + wizard = _wizard(vocabulary=(_offer("jobvite"), _offer("greenhouse"))) + with patch( + "builtins.input", + side_effect=["", "", "", "jobvite, notreal", "", "", ""], + ): + result = wizard.run() + assert result is not None + assert result.platforms == ("jobvite",) + assert "notreal" in capsys.readouterr().out + + +def test_platforms_unverified_are_not_selectable__teeth(capsys): + """TEETH: an ineligible platform name is refused with a note — the + wizard never offers what the planner would refuse. Mutation caught: + selecting an unverified platform anyway.""" + wizard = _wizard( + vocabulary=( + _offer("jobvite"), + _offer("workday", eligible=False, status="unverified", reason="x"), + ) + ) + with patch("builtins.input", side_effect=["", "", "", "workday", "", "", ""]): + result = wizard.run() + assert result is not None + assert result.platforms == () + assert "workday" in capsys.readouterr().out + + +def test_platforms_override_short_circuits_the_question__teeth(): + """TEETH: --platforms pre-answers the question even with blank input. + Mutation caught: asking anyway or ignoring the override.""" + wizard = _wizard(vocabulary=(_offer("jobvite"),), override=("jobvite",)) + with patch("builtins.input", side_effect=["", "", "", "", "", ""]): + result = wizard.run() + assert result is not None + assert result.platforms == ("jobvite",) + + +def test_preview_shown_and_start_returns_the_request__teeth(): + """TEETH: the preview factory is called with the built request, and + confirming returns it. Mutation caught: previewing a different request + than the one returned, or skipping the preview.""" + seen = [] + + def factory(request): + seen.append(request) + return SearchPlanPreview() + + wizard = _wizard(vocabulary=(_offer("jobvite"),), preview_factory=factory) + with patch( + "builtins.input", + side_effect=["", "", "", "jobvite", "", "", "", "y"], + ): + result = wizard.run() + assert result is not None + assert result.platforms == ("jobvite",) + assert seen == [result] + + +def test_preview_cancel_returns_none__teeth(): + """TEETH: answering 'n' at the preview cancels the session. Mutation + caught: starting anyway.""" + wizard = _wizard( + vocabulary=(_offer("jobvite",),), + preview_factory=lambda request: SearchPlanPreview(), + ) + with patch( + "builtins.input", + side_effect=["", "", "", "", "", "", "", "n"], + ): + result = wizard.run() + assert result is None + + +def test_preview_revise_restarts_the_questions__teeth(): + """TEETH: 'r' at the preview re-asks the questions; the second request + is the one returned. Mutation caught: a loop that reuses the first + request.""" + calls = [] + + def factory(request): + calls.append(request) + return SearchPlanPreview() + + wizard = _wizard(vocabulary=(_offer("jobvite"),), preview_factory=factory) + with patch( + "builtins.input", + side_effect=[ + "", "", "", "", "", "", "", "r", + "", "", "jobvite", "", "", "", "y", + ], + ): + result = wizard.run() + assert result is not None + assert result.platforms == ("jobvite",) + assert len(calls) == 2 diff --git a/packages/auto_apply/tests/adapters/test_engine_site_template.py b/packages/auto_apply/tests/adapters/test_engine_site_template.py new file mode 100644 index 0000000..6d26695 --- /dev/null +++ b/packages/auto_apply/tests/adapters/test_engine_site_template.py @@ -0,0 +1,107 @@ +"""Pins for engine-bound URL forms (SearchInstruction.surface_template). + +Label honesty: surface_template did not exist before this change, so these +pins cover new surface; each docstring names the mutation caught. The +default-form pin (test_the_default_form_is_unaffected) is a GUARD for the +pre-existing byte contract. +""" + +from __future__ import annotations + +import pytest + +from auto_apply.adapters.secondary.discovery.strategies.engine_strategies import ( + BingSearchStrategy, + GoogleSearchStrategy, + IndeedSearchStrategy, +) +from auto_apply.domain.models.search_instruction import SearchInstruction + + +def test_google_site_query_uses_the_web_form() -> None: + """NEW SURFACE — a site-restricted Google query goes to web results, not + the jobs vertical. + + Mutation caught: routing site queries to automation_url (jobs), whose + cards carry no followable URL. + """ + instruction = SearchInstruction( + title="Engineer", + raw_query_string='site:boards.greenhouse.io ("Engineer")', + surface_template="web", + ) + url = GoogleSearchStrategy().build_search_url(instruction) + assert url == ( + "https://www.google.com/search?q=" + "site%3Aboards.greenhouse.io+%28%22Engineer%22%29" + ) + assert "ibp=htl" not in url + + +def test_date_range_maps_on_the_web_form() -> None: + """NEW SURFACE — the web form carries the date parameter. + + Mutation caught: dropping date_range when a non-default template is used. + """ + instruction = SearchInstruction( + title="Engineer", + raw_query_string="site:x.example engineer", + surface_template="web", + date_range="week", + ) + url = GoogleSearchStrategy().build_search_url(instruction) + assert "tbs=qdr%3Aw" in url + + +def test_the_default_form_is_unaffected() -> None: + """GUARD — an instruction with no surface_template produces exactly the + automation URL it always did. + + Mutation caught: changing the template=None default path. + """ + instruction = SearchInstruction( + title="Engineer", location="Remote", date_range="day" + ) + url = GoogleSearchStrategy().build_search_url(instruction) + assert url == ( + "https://www.google.com/search?q=Engineer+jobs+in+Remote" + "&ibp=htl%3Bjobs&hl=en&gl=us&start=0&tbs=qdr%3Ad" + ) + + +def test_an_unknown_template_raises() -> None: + """NEW SURFACE — a template name the surface does not declare is a + configuration error, never a silent fallback. + + Mutation caught: falling back to the default form on a bad name. + """ + with pytest.raises(ValueError, match="no URL template"): + GoogleSearchStrategy().build_search_url( + SearchInstruction(title="E", surface_template="nope") + ) + + +def test_bing_site_query_uses_its_web_form() -> None: + """NEW SURFACE — Bing's declared site form is its web search. + + Mutation caught: sending site queries to the Bing jobs vertical. + """ + instruction = SearchInstruction( + title="E", + raw_query_string="site:jobs.lever.co engineer", + surface_template="web", + ) + url = BingSearchStrategy().build_search_url(instruction) + assert url == "https://www.bing.com/search?q=site%3Ajobs.lever.co+engineer" + + +def test_indeed_refuses_a_web_form_query() -> None: + """NEW SURFACE — Indeed declares no web form, so an engine-bound web + query is refused loudly rather than silently rerouted. + + Mutation caught: silently substituting the jobs form. + """ + with pytest.raises(ValueError): + IndeedSearchStrategy().build_search_url( + SearchInstruction(title="E", surface_template="web") + ) diff --git a/packages/auto_apply/tests/adapters/test_gui_wizard.py b/packages/auto_apply/tests/adapters/test_gui_wizard.py index d015bfc..ff92772 100644 --- a/packages/auto_apply/tests/adapters/test_gui_wizard.py +++ b/packages/auto_apply/tests/adapters/test_gui_wizard.py @@ -231,3 +231,38 @@ def test_gui_wizard_module_imports_without_a_display() -> None: suite would fail at collection.""" assert callable(gui_wizard.build_session_request) assert gui_wizard.SessionConfigWizard is not None + + +def test_build_session_request_carries_platforms__teeth() -> None: + """TEETH: the GUI assembly forwards the platform choice to the request. + Mutation caught: dropping platforms on the SEARCH branch.""" + request = gui_wizard.build_session_request( + entry=EntryPoint.SEARCH, + execution_mode=SessionExecutionMode.FULL_PIPELINE, + keywords_text="Engineer", + platforms=("jobvite",), + ) + assert request.platforms == ("jobvite",) + + +def test_build_session_request_platforms_default_is_none__guard() -> None: + """GUARD: undeclared platforms stay None (inherit) — the behaviour- + preserving default. Mutation caught: defaulting to ().""" + request = gui_wizard.build_session_request( + entry=EntryPoint.SEARCH, + execution_mode=SessionExecutionMode.FULL_PIPELINE, + ) + assert request.platforms is None + + +def test_platforms_are_dropped_for_non_search_entries__teeth() -> None: + """TEETH: pasted links do not search platforms — the choice is dropped + for non-SEARCH entries, the same rule the CLI enforces by not asking. + Mutation caught: forwarding platforms on the URL path.""" + request = gui_wizard.build_session_request( + entry=EntryPoint.DIRECT_URLS, + execution_mode=SessionExecutionMode.APPLY_ONLY, + urls_text="https://example.com/j/1", + platforms=("jobvite",), + ) + assert request.platforms is None diff --git a/packages/auto_apply/tests/adapters/test_platform_vocabulary.py b/packages/auto_apply/tests/adapters/test_platform_vocabulary.py new file mode 100644 index 0000000..de87fd8 --- /dev/null +++ b/packages/auto_apply/tests/adapters/test_platform_vocabulary.py @@ -0,0 +1,101 @@ +"""Pins for the automation platform vocabulary (QueryBuilderService). + +Label honesty: the vocabulary is new surface. Each pin names the mutation it +catches. The structural pin (no platform list in an adapter) is the one the +architecture depends on. +""" + +from __future__ import annotations + +import inspect +import re +from pathlib import Path + +import pytest + +from auto_apply.adapters.primary.cli.wizard import CLIWizard +from auto_apply.adapters.primary.gui.wizard import SessionConfigWizard +from auto_apply.adapters.secondary.discovery.ats_registry import ATSRegistry +from auto_apply.application.services.query_builder import QueryBuilderService + +_PKG_ROOT = Path(__file__).resolve().parents[2] +_CLI_WIZARD = _PKG_ROOT / "src" / "auto_apply" / "adapters" / "primary" / "cli" / "wizard.py" +_GUI_WIZARD = _PKG_ROOT / "src" / "auto_apply" / "adapters" / "primary" / "gui" / "wizard.py" + + +def _service(registry) -> QueryBuilderService: + return QueryBuilderService( + profile=None, + load_spec=None, + list_names=None, + ats_registry=registry, + recent_path=Path("/nonexistent/recent.json"), + ) + + +def test_vocabulary_covers_every_registered_platform() -> None: + """TEETH: the picker and the registry can never drift. + + Mutation caught: a filtered or hardcoded subset.""" + registry = ATSRegistry() + offers = _service(registry).automation_platform_vocabulary() + assert {o.name for o in offers} == { + d.name for d in registry.all_descriptors() if d.hosts + } + assert len(offers) >= 30 + + +def test_eligibility_matches_the_automation_floor_exactly() -> None: + """TEETH: only measured/vendor are eligible — the rule, not today's data. + + Mutation caught: widening the floor (e.g. admitting third_party), or + inverting it.""" + for offer in _service(ATSRegistry()).automation_platform_vocabulary(): + assert offer.eligible == (offer.status in {"measured", "vendor"}), offer.name + + +def test_ineligible_platforms_carry_a_reason() -> None: + """TEETH: no surface ever has to invent a refusal reason. + + Mutation caught: an empty reason on an ineligible platform.""" + offers = _service(ATSRegistry()).automation_platform_vocabulary() + ineligible = [o for o in offers if not o.eligible] + assert ineligible, "the registry currently has unverified platforms" + assert all(o.reason for o in ineligible) + + +def test_vocabulary_degrades_empty_without_a_registry() -> None: + """TEETH: no registry → empty vocabulary, never an exception. + + Mutation caught: raising on a missing registry.""" + assert _service(None).automation_platform_vocabulary() == () + + +@pytest.mark.parametrize("wizard_path", [_CLI_WIZARD, _GUI_WIZARD]) +def test_no_hardcoded_platform_names_in_wizards(wizard_path) -> None: + """TEETH (structural): no adapter holds its own platform list. + + Mutation caught: any registry platform name appearing as a literal in a + wizard.""" + source = wizard_path.read_text(encoding="utf-8") + names = {d.name for d in ATSRegistry().all_descriptors()} + offenders = [ + name + for name in names + if re.search(rf"(? None: + """TEETH: both surfaces receive the vocabulary, neither computes it. + + Mutation caught: a wizard dropping the parameter.""" + assert "platforms_vocabulary" in inspect.signature(CLIWizard.__init__).parameters + assert ( + "platforms_vocabulary" + in inspect.signature(SessionConfigWizard.__init__).parameters + ) diff --git a/packages/auto_apply/tests/adapters/test_provider_session_guard.py b/packages/auto_apply/tests/adapters/test_provider_session_guard.py new file mode 100644 index 0000000..3c5ba1d --- /dev/null +++ b/packages/auto_apply/tests/adapters/test_provider_session_guard.py @@ -0,0 +1,80 @@ +"""Pins for bench-before-navigation and pace-at-the-navigation-seam. + +New-surface pins. Mutations caught: a benched engine still being navigated +to; the pacer running after (or never around) navigation; a site-scoped +bench stopping generic queries; a provider without pacer/guard regressing. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from auto_apply.adapters.secondary.discovery.providers import google as google_module +from auto_apply.adapters.secondary.discovery.providers.google import GoogleProvider +from auto_apply.domain.models.search_instruction import SearchInstruction + + +class _SpyStrategy: + def __init__(self, *args, **kwargs) -> None: + pass + + def execute(self): + return [] + + +def _provider(monkeypatch, detector=None, pacer=None) -> GoogleProvider: + monkeypatch.setattr(google_module, "GenericSERPStrategy", _SpyStrategy) + provider = GoogleProvider( + browser=MagicMock(), + degradation_detector=detector, + host_pacer=pacer, + ) + provider.navigator = MagicMock() + provider.navigator.navigate_with_fallback.return_value = True + return provider + + +def test_a_benched_engine_sends_no_request(monkeypatch): + """TEETH: pre-change, a benched engine was still navigated to.""" + detector = MagicMock() + detector.is_benched.side_effect = lambda name: name == "google" + provider = _provider(monkeypatch, detector=detector) + assert provider.run(SearchInstruction(title="Engineer")) == [] + provider.navigator.navigate_with_fallback.assert_not_called() + + +def test_pacing_precedes_navigation(monkeypatch): + """The per-host floor applies before the engine is contacted.""" + calls: list[str] = [] + pacer = MagicMock() + pacer.wait_for_url.side_effect = lambda url: calls.append("pace") + provider = _provider(monkeypatch, pacer=pacer) + provider.navigator.navigate_with_fallback.side_effect = ( + lambda *a, **k: calls.append("nav") or True + ) + provider.run(SearchInstruction(title="Engineer")) + assert calls == ["pace", "nav"] + assert pacer.wait_for_url.call_args.args[0].startswith( + "https://www.google.com/search" + ) + + +def test_a_site_scoped_bench_stops_only_site_queries(monkeypatch): + """A degraded site-search path never benches the engine's generic path.""" + detector = MagicMock() + detector.is_benched.side_effect = lambda name: name == "google#site" + provider = _provider(monkeypatch, detector=detector) + generic = SearchInstruction(title="Engineer") + assert provider.run(generic) == [] + provider.navigator.navigate_with_fallback.assert_called_once() + provider.navigator.navigate_with_fallback.reset_mock() + site = SearchInstruction(title="Engineer", platform="jobvite") + assert provider.run(site) == [] + provider.navigator.navigate_with_fallback.assert_not_called() + + +def test_no_guard_and_no_pacer_runs_as_before(monkeypatch): + """Default path: with neither wired, the provider navigates normally.""" + provider = _provider(monkeypatch) + assert provider.run(SearchInstruction(title="Engineer")) == [] + provider.navigator.navigate_with_fallback.assert_called_once() diff --git a/packages/auto_apply/tests/adapters/test_serp_block_gate.py b/packages/auto_apply/tests/adapters/test_serp_block_gate.py index daaaa7e..5a53e40 100644 --- a/packages/auto_apply/tests/adapters/test_serp_block_gate.py +++ b/packages/auto_apply/tests/adapters/test_serp_block_gate.py @@ -72,6 +72,7 @@ def mine_jobs(self, source_name: str): class _FakeDegradationDetector: def __init__(self) -> None: self.evaluations: list = [] + self.blocked_calls: list = [] def is_benched(self, provider) -> bool: return False @@ -79,6 +80,9 @@ def is_benched(self, provider) -> bool: def evaluate_first_harvest(self, **kwargs) -> None: self.evaluations.append(kwargs) + def mark_blocked(self, provider, reason) -> None: + self.blocked_calls.append((provider, reason)) + class _FakeResearchObserver: def __init__(self) -> None: @@ -168,6 +172,34 @@ def test_non_blocked_page_proceeds_and_evaluates(monkeypatch) -> None: assert len(degradation.evaluations) == 1 +def test_a_blocked_page_benches_the_engine_for_the_session(monkeypatch) -> None: + """TEETH: pre-change, the gate returned no jobs and nothing stopped.""" + strategy, _fast, degradation = _strategy(monkeypatch, PageType.CAPTCHA_BLOCK) + + assert strategy.execute() == [] + assert degradation.blocked_calls == [("TestProvider", "CAPTCHA_BLOCK")] + + +def test_a_site_scoped_harvest_is_judged_under_its_own_key(monkeypatch) -> None: + """TEETH: a sparse site-restricted harvest must not bench the generic path.""" + strategy, _fast, degradation = _strategy(monkeypatch, PageType.SERP) + strategy._baseline_scope = "#site" + + strategy.execute() + + assert degradation.evaluations + assert degradation.evaluations[0]["provider"] == "TestProvider#site" + + +def test_a_generic_harvest_keeps_the_unchanged_key(monkeypatch) -> None: + """Behaviour-preserving: the default scope string is empty.""" + strategy, _fast, degradation = _strategy(monkeypatch, PageType.SERP) + + strategy.execute() + + assert degradation.evaluations[0]["provider"] == "TestProvider" + + def test_execute_mines_once_per_harvest_not_once_extra(monkeypatch) -> None: """The strategy pays for extraction only inside the harvest loop. diff --git a/packages/auto_apply/tests/application/test_discovery_verification.py b/packages/auto_apply/tests/application/test_discovery_verification.py index 160c993..ac0a4ba 100644 --- a/packages/auto_apply/tests/application/test_discovery_verification.py +++ b/packages/auto_apply/tests/application/test_discovery_verification.py @@ -110,3 +110,53 @@ def test_empty_input_passes_trivially(): report = DiscoveryVerifier(max_results=30).verify([]) assert report.passed assert report.total_jobs == 0 + + +def test_fallback_hosts_equal_the_catalog_engine_hosts(): + """TEETH: the fallback cannot drift from the catalog undetected.""" + from urllib.parse import urlparse # noqa: PLC0415 + + from auto_apply.adapters.secondary.discovery.search_surface_registry import ( # noqa: PLC0415 + list_surface_names, + load_surface_bundled, + ) + from auto_apply.application.services.auditing.discovery_verification import ( + _FALLBACK_ENGINE_HOSTS, + ) + + derived = set() + for name in list_surface_names(): + spec = load_surface_bundled(name) + if spec.kind != "engine": + continue + host = urlparse(spec.homepage).netloc.lower().removeprefix("www.") + if host: + derived.add(host) + assert derived == set(_FALLBACK_ENGINE_HOSTS) + + +def test_no_hand_written_engine_host_list_remains(): + """Structural: the host list is not a second hand-written literal.""" + import inspect # noqa: PLC0415 + + import auto_apply.application.services.auditing.discovery_verification as module + + src = inspect.getsource(module) + assert "_SEARCH_ENGINE_HOSTS" not in src + assert "search.yahoo" not in src + + +def test_a_lookalike_host_is_not_flagged(): + """TEETH: substring matching flagged evilgoogle.com; suffix-exact does not.""" + report = DiscoveryVerifier(max_results=None).verify( + [_job(url="https://evilgoogle.com/jobs/1")] + ) + assert report.passed, report.details() + + +def test_injected_hosts_are_used_over_the_fallback(): + """TEETH: the wired (catalog-derived) set drives the check when injected.""" + verifier = DiscoveryVerifier(max_results=None, engine_hosts=("engine.example",)) + assert verifier.verify([_job(url="https://www.google.com/vid")]).passed + flagged = verifier.verify([_job(url="https://engine.example/jobs/9")]) + assert not flagged.passed diff --git a/packages/auto_apply/tests/application/test_host_pacing.py b/packages/auto_apply/tests/application/test_host_pacing.py new file mode 100644 index 0000000..8c5b708 --- /dev/null +++ b/packages/auto_apply/tests/application/test_host_pacing.py @@ -0,0 +1,104 @@ +"""Pins for the per-host navigation floor (host pacing). + +New-surface pins; each names the mutation it catches. The floor comes from +config, jitter from the seeded RNG — never a literal on this path. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from auto_apply.application.services.host_pacing import HostPacer + + +class _Clock: + def __init__(self) -> None: + self.now = 0.0 + + def __call__(self) -> float: + return self.now + + +def _pacer(clock, sleeps, **kwargs): + return HostPacer(sleeper=sleeps.append, clock=clock, **kwargs) + + +def test_first_contact_to_a_host_does_not_wait(): + """Catches: a floor applied to first contact.""" + clock = _Clock() + sleeps: list[float] = [] + pacer = _pacer(clock, sleeps, min_interval_s=1.0, max_interval_s=2.0) + assert pacer.wait_for_url("https://google.com/search?q=x") == 0.0 + assert sleeps == [] + + +def test_second_contact_too_soon_waits_for_the_floor(): + """Catches: no wait between consecutive same-host navigations.""" + clock = _Clock() + sleeps: list[float] = [] + pacer = _pacer(clock, sleeps, min_interval_s=2.0, max_interval_s=2.0) + pacer.wait_for_url("https://google.com/a") + clock.now += 0.5 + waited = pacer.wait_for_url("https://google.com/b") + assert waited == 1.5 + assert sleeps == [1.5] + + +def test_different_hosts_are_not_spaced(): + """Catches: a global (not per-host) floor.""" + clock = _Clock() + sleeps: list[float] = [] + pacer = _pacer(clock, sleeps, min_interval_s=2.0, max_interval_s=2.0) + pacer.wait_for_url("https://google.com/a") + assert pacer.wait_for_url("https://bing.com/a") == 0.0 + assert sleeps == [] + + +def test_host_keys_normalise_case_and_port(): + """Catches: 'GOOGLE.com:443' and 'google.com' treated as different hosts.""" + clock = _Clock() + sleeps: list[float] = [] + pacer = _pacer(clock, sleeps, min_interval_s=2.0, max_interval_s=2.0) + pacer.wait_for_url("https://GOOGLE.com:443/a") + waited = pacer.wait_for_url("https://google.com/b") + assert waited == 2.0 + + +def test_floor_equal_to_ceiling_draws_no_randomness(): + """Catches: an RNG draw when the range is empty (would break determinism).""" + clock = _Clock() + sleeps: list[float] = [] + rng = MagicMock() + rng.uniform.side_effect = AssertionError("no draw expected") + pacer = _pacer(clock, sleeps, min_interval_s=1.5, max_interval_s=1.5, rng=rng) + pacer.wait_for_url("https://google.com/a") + pacer.wait_for_url("https://google.com/b") + assert sleeps == [1.5] + + +def test_jitter_uses_the_seeded_rng(): + """Catches: jitter from anywhere but the injected RNG. One draw per call, + every call, so the number of draws never depends on timing.""" + clock = _Clock() + sleeps: list[float] = [] + rng = MagicMock() + rng.uniform.return_value = 0.25 + pacer = _pacer(clock, sleeps, min_interval_s=1.0, max_interval_s=2.0, rng=rng) + pacer.wait_for_url("https://google.com/a") + waited = pacer.wait_for_url("https://google.com/b") + assert rng.uniform.call_count == 2 + assert all(c.args == (0.0, 1.0) for c in rng.uniform.call_args_list) + assert waited == 1.25 + + +def test_reservations_accumulate_without_clock_advance(): + """Catches: the reservation not recorded before sleeping (thread race). + With the clock frozen, the third call must queue behind the second's + reserved slot (2.0s), not behind the first contact (1.0s).""" + clock = _Clock() + sleeps: list[float] = [] + pacer = _pacer(clock, sleeps, min_interval_s=1.0, max_interval_s=1.0) + pacer.wait_for_url("https://google.com/a") + pacer.wait_for_url("https://google.com/b") + pacer.wait_for_url("https://google.com/c") + assert sleeps == [1.0, 2.0] diff --git a/packages/auto_apply/tests/application/test_results_surface.py b/packages/auto_apply/tests/application/test_results_surface.py index fc0ba5a..dbc6d06 100644 --- a/packages/auto_apply/tests/application/test_results_surface.py +++ b/packages/auto_apply/tests/application/test_results_surface.py @@ -490,3 +490,120 @@ def test_gui_format_helpers_are_pure_and_correct(): assert "applied 3" in history_text assert format_history_lines(()) == ["No past sessions found."] + + +def test_the_results_csv_carries_search_provenance(fresh_db, tmp_path): + """TEETH: pre-change, the export was title/company/url/source only.""" + job = _job(0) + job.metadata.update({ + "search_query": 'site:jobs.jobvite.com ("Engineer")', + "target_platform": "jobvite", + "engine_form": "web", + "page_index": 0, + "rank": 1, + }) + fresh_db.record_job_discovery(job, session_id="s1") + controller = _controller(fresh_db, "s1") + out_dir = tmp_path / "out" + out_dir.mkdir() + + target = controller.export_session_results(out_dir) + + lines = target.read_text(encoding="utf-8").splitlines() + assert "search_query" in lines[0] + assert "target_platform" in lines[0] + assert "engine_form" in lines[0] + assert "page_index" in lines[0] + assert "rank" in lines[0] + assert "jobvite" in lines[1] + + +# ───────────────────────────────────────────────────────────────────────────── +# Automation outcome block — one formatter, both surfaces +# ───────────────────────────────────────────────────────────────────────────── + + +def _outcome_summary() -> SessionSummary: + from auto_apply.domain.models.ui_contract import BlockedEngine # noqa: PLC0415 + + return SessionSummary( + blocked_engines=( + BlockedEngine(provider="google", reason="CAPTCHA_BLOCK"), + BlockedEngine(provider="indeed", reason=""), + ), + discovery_off_shape_dropped=2, + discovery_terminal_dropped=1, + discovery_routed_to_mining=3, + discovery_budget_skipped=4, + search_budget=20, + ) + + +def test_discovery_outcome_lines_render_blocked_engines_and_counts__teeth() -> None: + """TEETH: blocked engines (with their reason, or a stated "no reason"), + the off-shape counts and the budget skips all render with their values. + Mutation caught: a dropped line, a dropped value, or a blank reason.""" + from auto_apply.application.services.i18n import configure_locale, get_text # noqa: PLC0415 + from auto_apply.domain.models.ui_contract import format_discovery_outcome_lines # noqa: PLC0415 + + configure_locale(language="en") + text = "\n".join(format_discovery_outcome_lines(_outcome_summary(), translate=get_text)) + assert "Search engines stopped this session:" in text + assert "google — CAPTCHA_BLOCK" in text + assert "indeed — no reason recorded" in text + assert "Dropped 2 off-platform results" in text + assert "skipped 1 terminal pages" in text + assert "routed 3 to company pages" in text + assert "4 planned searches were skipped" in text + assert "budget of 20" in text + + +def test_discovery_outcome_lines_are_empty_when_nothing_happened() -> None: + """GUARD: an ordinary session adds nothing to the results view.""" + from auto_apply.domain.models.ui_contract import format_discovery_outcome_lines # noqa: PLC0415 + + assert format_discovery_outcome_lines(SessionSummary()) == [] + + +def test_discovery_outcome_lines_follow_the_locale__teeth() -> None: + """TEETH: the block renders in Spanish through the real locale. + Mutation caught: hard-coded English, or an es.json key missing.""" + from auto_apply.application.services.i18n import configure_locale, get_text # noqa: PLC0415 + from auto_apply.domain.models.ui_contract import format_discovery_outcome_lines # noqa: PLC0415 + + try: + configure_locale(language="es") + text = "\n".join(format_discovery_outcome_lines(_outcome_summary(), translate=get_text)) + finally: + configure_locale(language="en") # restore default for other tests + assert "Buscadores detenidos en esta sesión:" in text + assert "sin motivo registrado" in text + assert "Search engines stopped" not in text + + +def test_both_results_surfaces_render_the_outcome_from_one_formatter__teeth() -> None: + """TEETH (parity): the GUI results lines contain the shared block, and + the CLI results printer calls the same formatter. Mutation caught: either + surface dropping the block, or growing its own copy of it.""" + from auto_apply.adapters.primary.gui import app as gui_app # noqa: PLC0415 + from auto_apply.application.services.i18n import configure_locale # noqa: PLC0415 + from auto_apply.domain.models.ui_contract import format_discovery_outcome_lines # noqa: PLC0415 + + configure_locale(language="en") + gui_lines = gui_app.format_results_lines(_outcome_summary()) + shared = format_discovery_outcome_lines( + _outcome_summary(), translate=gui_app.locale_text + ) + assert shared and all(line in gui_lines for line in shared) + + startup = ( + Path(__file__).resolve().parents[2] + / "src/auto_apply/adapters/primary/cli/startup.py" + ) + tree = ast.parse(startup.read_text(encoding="utf-8")) + called = { + node.func.id + for node in ast.walk(tree) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + } + assert "format_discovery_outcome_lines" in called diff --git a/packages/auto_apply/tests/application/test_search_platform_settings.py b/packages/auto_apply/tests/application/test_search_platform_settings.py new file mode 100644 index 0000000..971e8c0 --- /dev/null +++ b/packages/auto_apply/tests/application/test_search_platform_settings.py @@ -0,0 +1,92 @@ +"""Pins for the persistent search-platform setting and the admin lock. + +Label honesty: the setting is new surface. Each pin names the mutation it +catches. The merge-fold pins are the ones the config ledger depends on. +""" + +from __future__ import annotations + +from auto_apply.domain.models.policy import AdminPolicy +from auto_apply.domain.models.profile import ApplicationConfig +from auto_apply.infrastructure.registry import ( + CapabilitiesRegistry, + _RUNTIME_DEFAULTS, +) + + +def test_profile_field_normalizes_and_dedupes() -> None: + """TEETH: stored values are lowercase, stripped, deduplicated. + + Mutation caught: storing raw input (a registry name would never match + ' Jobvite ').""" + config = ApplicationConfig( + search_platforms=[" Jobvite ", "JOBVITE", "greenhouse", ""] + ) + assert config.search_platforms == ["jobvite", "greenhouse"] + + +def test_profile_settings_emit_platforms_only_when_set(minimal_valid_profile) -> None: + """TEETH: None is never emitted (it would shadow the YAML default); + a set list is. + + Mutation caught: emitting None, or dropping the key entirely.""" + profile = minimal_valid_profile + assert "search_platforms" not in profile.settings + profile.app_config.search_platforms = ["jobvite"] + assert profile.settings["search_platforms"] == ["jobvite"] + + +def test_merge_folds_profile_platforms_into_discovery_section() -> None: + """TEETH: the flat profile input resolves into the nested discovery + section the plan reads, and the flat key is gone. + + Mutation caught: leaving the flat key (two sources of truth), or + folding without setting the engagement flag.""" + merged = CapabilitiesRegistry._merge_config( + runtime_defaults=dict(_RUNTIME_DEFAULTS), + user_settings={"search_platforms": ["jobvite"]}, + admin_policy=None, + is_low_resource=False, + ) + assert "search_platforms" not in merged + assert merged["discovery"]["ats_platforms"] == ["jobvite"] + assert merged["discovery"]["use_ats_site_search"] is True + + +def test_admin_lock_beats_the_profile() -> None: + """TEETH: the admin lock wins over the profile, including locking the + feature off with an empty list. + + Mutation caught: applying the profile after the lock, or treating [] + as 'no lock'.""" + merged = CapabilitiesRegistry._merge_config( + runtime_defaults=dict(_RUNTIME_DEFAULTS), + user_settings={"search_platforms": ["jobvite"]}, + admin_policy=AdminPolicy(search_platforms=[]), + is_low_resource=False, + ) + assert merged["discovery"]["ats_platforms"] == [] + assert merged["discovery"]["use_ats_site_search"] is False + + +def test_admin_lock_is_applied_after_config_overrides() -> None: + """TEETH: the named lock beats even a full-section config_overrides + replacement (the documented rule). + + Mutation caught: applying the lock before config_overrides.""" + merged = CapabilitiesRegistry._merge_config( + runtime_defaults=dict(_RUNTIME_DEFAULTS), + user_settings={}, + admin_policy=AdminPolicy( + search_platforms=["jobvite"], + config_overrides={ + "discovery": { + "ats_platforms": ["greenhouse"], + "use_ats_site_search": False, + } + }, + ), + is_low_resource=False, + ) + assert merged["discovery"]["ats_platforms"] == ["jobvite"] + assert merged["discovery"]["use_ats_site_search"] is True diff --git a/packages/auto_apply/tests/application/test_search_preview.py b/packages/auto_apply/tests/application/test_search_preview.py new file mode 100644 index 0000000..1325e2a --- /dev/null +++ b/packages/auto_apply/tests/application/test_search_preview.py @@ -0,0 +1,334 @@ +"""Pins for the plan preview (application/services/search_preview.py). + +Label honesty: the preview is new surface; every pin is mutation-checked and +its docstring names the mutation it catches. The workflow-parity pin is the +one that matters most: the preview must equal what the session will do. +""" + +from __future__ import annotations + +import ast +from pathlib import Path +from unittest.mock import MagicMock + +from auto_apply.application.services.search_preview import ( + plan_updates_for_request, + preview_search_plan, +) +from auto_apply.application.workflows.discovery_workflow import DiscoveryWorkflow +from auto_apply.domain.models.session_plan import SessionPlan +from auto_apply.domain.models.ui_contract import ( + EntryPoint, + SearchPlanPreview, + SessionRequest, + format_search_plan_preview, +) +from auto_apply.domain.models.search_instruction import SearchInstruction +from auto_apply.domain.services.search_planner import PlannedQuery, PlannedSearch + + +def _pq(engine: str, platform: str = "jobvite") -> PlannedQuery: + return PlannedQuery( + engine=engine, + platform=platform, + title="Engineer", + location="Remote", + workplace_type="remote", + raw_query_string='site:jobs.jobvite.com ("Engineer")', + surface_template="web", + date_range=None, + max_results=30, + ) + + +class _RecordingPlanner: + """Records plan() calls and returns canned output (the site-search + wiring tests' pattern — kept local so the modules stay independent).""" + + def __init__(self, queries_per_call) -> None: + self._queries = tuple(queries_per_call) + self.calls: list[dict] = [] + + def plan(self, **kwargs) -> PlannedSearch: + self.calls.append(kwargs) + return PlannedSearch(queries=self._queries, notes=(), dropped_targets=()) + + +def _profile() -> MagicMock: + profile = MagicMock() + profile.search_preferences.desired_job_titles = ["Engineer", "Analyst"] + profile.search_preferences.preferred_locations = ["Remote"] + profile.search_preferences.workplace_types = ["remote"] + return profile + + +def _plan(**overrides) -> SessionPlan: + kwargs: dict = { + "session_id": "preview-pin", + "max_concurrency": 1, + "active_providers": ("google", "bing"), + "max_queries_per_session": 10, + } + kwargs.update(overrides) + return SessionPlan(**kwargs) + + +def test_preview_module_is_offline() -> None: + """TEETH: the preview must never touch a browser or the network. + + Mutation caught: any import of a browser or network library in the + preview module.""" + import auto_apply.application.services.search_preview as mod + + tree = ast.parse(Path(mod.__file__).read_text(encoding="utf-8")) + banned = ("selenium", "playwright", "requests", "urllib", "socket", "httpx") + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + assert not alias.name.startswith(banned), alias.name + elif isinstance(node, ast.ImportFrom) and node.module: + assert not node.module.startswith(banned), node.module + + +def test_generic_preview_lists_every_base() -> None: + """TEETH: with targeting off, the preview shows the broadcast the + session will run — one line per (title, location), engine=ALL. + + Mutation caught: showing nothing when site search is off, or inventing + engine bindings the workflow would not make.""" + preview = preview_search_plan( + _profile(), + _plan(), + planner=None, + request=SessionRequest(entry=EntryPoint.SEARCH), + ) + assert preview.site_search is False + assert preview.planned_count == 2 + assert [q.query for q in preview.queries] == [ + "Engineer jobs in Remote", + "Analyst jobs in Remote", + ] + assert all(q.engine == "" for q in preview.queries) + assert preview.budget == 10 + + +def test_site_search_preview_lists_planner_queries() -> None: + """TEETH: with targeting on, the preview lists the planner's own output + with engine, platform and form. + + Mutation caught: dropping the engine/platform/form, or reordering.""" + planner = _RecordingPlanner([_pq("google"), _pq("bing")]) + preview = preview_search_plan( + _profile(), + _plan(use_ats_site_search=True, ats_platforms=("jobvite",)), + planner, + SessionRequest( + entry=EntryPoint.SEARCH, + keywords=("Engineer",), + location="Remote", + platforms=("jobvite",), + ), + ) + assert preview.site_search is True + assert [q.engine for q in preview.queries] == ["google", "bing"] + assert all(q.platform == "jobvite" for q in preview.queries) + assert all(q.form == "web" for q in preview.queries) + assert planner.calls[0]["platforms"] == ("jobvite",) + + +def test_preview_matches_the_workflows_own_planning() -> None: + """TEETH (the parity pin): the preview equals what the session will do — + same per-base planner calls, same budget sequence, same engine rotation, + same queries in the same order. + + Mutation caught: the preview re-implementing the workflow's budget or + rotation instead of mirroring it.""" + plan = _plan(use_ats_site_search=True, ats_platforms=("jobvite",)) + planner_wf = _RecordingPlanner([_pq("google"), _pq("bing")]) + workflow = DiscoveryWorkflow( + profile=_profile(), + providers=[], + task_queue=MagicMock(), + event_bus=MagicMock(), + dedup=MagicMock(), + text_matcher=MagicMock(), + plan=plan, + search_planner=planner_wf, + ) + wf_out_1 = workflow._build_search_instructions( + override_instructions=[SearchInstruction(title="Engineer", location="Remote")] + ) + # The fan-out's own accounting, simulated: the session counts what it sent. + workflow._queries_sent_this_session = len(wf_out_1) + wf_out_2 = workflow._build_search_instructions( + override_instructions=[SearchInstruction(title="Analyst", location="Remote")] + ) + + planner_pv = _RecordingPlanner([_pq("google"), _pq("bing")]) + preview = preview_search_plan( + _profile(), + plan, + planner_pv, + SessionRequest( + entry=EntryPoint.SEARCH, + keywords=("Engineer", "Analyst"), + location="Remote", + platforms=("jobvite",), + ), + ) + + assert [q.query for q in preview.queries] == [ + i.raw_query_string for i in [*wf_out_1, *wf_out_2] + ] + assert [c["budget"] for c in planner_wf.calls] == [ + c["budget"] for c in planner_pv.calls + ] + assert [c["engines"] for c in planner_wf.calls] == [ + c["engines"] for c in planner_pv.calls + ] + assert [c["bases"] for c in planner_wf.calls] == [ + c["bases"] for c in planner_pv.calls + ] + + +def test_budget_truncation_names_dropped_bases() -> None: + """TEETH: when the session budget runs out, later bases are named as + dropped and the planner is not called again. + + Mutation caught: an extra planner call past budget, or a silent drop.""" + planner = _RecordingPlanner([_pq("google")]) + preview = preview_search_plan( + _profile(), + _plan(use_ats_site_search=True, ats_platforms=("jobvite",), + max_queries_per_session=1), + planner, + SessionRequest( + entry=EntryPoint.SEARCH, + keywords=("Engineer", "Analyst", "Clerk"), + location="Remote", + platforms=("jobvite",), + ), + ) + assert len(planner.calls) == 1 + assert preview.planned_count == 1 + assert preview.dropped == ("Analyst — Remote", "Clerk — Remote") + + +def test_engine_rotation_carries_across_bases() -> None: + """TEETH: the engine after a call's last bound engine leads the next + call, exactly as the session rotates. + + Mutation caught: restarting the rotation at every base.""" + planner = _RecordingPlanner([_pq("google")]) + preview_search_plan( + _profile(), + _plan(use_ats_site_search=True, ats_platforms=("jobvite",)), + planner, + SessionRequest( + entry=EntryPoint.SEARCH, + keywords=("Engineer", "Analyst"), + location="Remote", + platforms=("jobvite",), + ), + ) + assert planner.calls[0]["engines"] == ("google", "bing") + assert planner.calls[1]["engines"] == ("bing", "google") + + +def test_admin_lock_overrides_request_platforms() -> None: + """TEETH: an admin lock beats the request — and a request-shaped object + that is not a real policy cannot fake a lock. + + Mutation caught: reading the lock without the isinstance guard (a + MagicMock policy would then fabricate one).""" + from auto_apply.domain.models.policy import AdminPolicy + + planner = _RecordingPlanner([_pq("google")]) + preview = preview_search_plan( + _profile(), + _plan(), + planner, + SessionRequest( + entry=EntryPoint.SEARCH, + keywords=("Engineer",), + platforms=("jobvite",), + ), + admin_policy=AdminPolicy(search_platforms=["greenhouse"]), + ) + assert planner.calls[0]["platforms"] == ("greenhouse",) + assert preview.locked_platforms == ("greenhouse",) + + +def test_missing_planner_falls_back_to_generic_with_reason() -> None: + """TEETH: targeting engaged but no planner — the preview shows the + generic search and names the reason, never raises. + + Mutation caught: raising, or showing an empty preview with no reason.""" + preview = preview_search_plan( + _profile(), + _plan(use_ats_site_search=True, ats_platforms=("jobvite",)), + None, + SessionRequest( + entry=EntryPoint.SEARCH, + keywords=("Engineer",), + platforms=("jobvite",), + ), + ) + assert preview.site_search is False + assert preview.reason_key == "searchplan.preview.reason_no_planner" + assert preview.planned_count == 1 + + +def test_formatter_renders_all_sections_and_translates() -> None: + """TEETH: the shared formatter fills every placeholder and renders + blocked content deterministically for a given locale. + + Mutation caught: an unfilled placeholder, a missing section, or a line + that does not come from the locale.""" + templates = { + "searchplan.preview.title": "TITLE", + "searchplan.preview.mode_targeted": "TARGETED", + "searchplan.preview.budget": "{planned} of {budget}", + "searchplan.preview.note_prefix": "NOTE", + "searchplan.preview.dropped": "DROPPED {targets}", + "searchplan.preview.admin_lock": "LOCKED {platforms}", + "searchplan.preview.reason_no_planner": "REASON", + } + preview = SearchPlanPreview( + site_search=True, + queries=( + __import__("auto_apply.domain.models.ui_contract", fromlist=["PlannedQueryView"]).PlannedQueryView( + engine="google", platform="jobvite", form="web", query="q" + ), + ), + notes=("n1",), + dropped=("x",), + budget=10, + planned_count=1, + reason_key="searchplan.preview.reason_no_planner", + locked_platforms=("greenhouse",), + ) + lines = format_search_plan_preview(preview, translate=templates.get) + text = "\n".join(lines) + assert "TITLE" in text and "TARGETED" in text + assert "1 of 10" in text + assert "NOTE n1" in text + assert "DROPPED x" in text + assert "LOCKED greenhouse" in text + assert "REASON" in text + assert "{" not in text + + +def test_factory_builds_offline(minimal_valid_profile) -> None: + """TEETH: the composition-root factory produces a preview with no + browser and no network. + + Mutation caught: the factory requiring a driver or raising on a plain + profile.""" + from auto_apply.infrastructure.composition_root import build_search_plan_preview + + preview = build_search_plan_preview( + minimal_valid_profile, SessionRequest(entry=EntryPoint.SEARCH) + ) + assert preview.planned_count >= 1 + assert preview.site_search is False diff --git a/packages/auto_apply/tests/application/test_silent_degradation.py b/packages/auto_apply/tests/application/test_silent_degradation.py index 91db1cf..d4b756b 100644 --- a/packages/auto_apply/tests/application/test_silent_degradation.py +++ b/packages/auto_apply/tests/application/test_silent_degradation.py @@ -140,7 +140,7 @@ def test_benched_provider_short_circuits_before_mining(mock_assess_page): mock_assess_page.return_value = _not_blocked_verdict() det = SilentDegradationDetector(None) - det._benched.add("Google") + det._benched.add("google") # keys are lowercase miner_spy = MagicMock() miner_spy.mine_jobs.return_value = [_job()] diff --git a/packages/auto_apply/tests/application/test_ui_port_conformance.py b/packages/auto_apply/tests/application/test_ui_port_conformance.py index 8e0a5ae..fd0ea9b 100644 --- a/packages/auto_apply/tests/application/test_ui_port_conformance.py +++ b/packages/auto_apply/tests/application/test_ui_port_conformance.py @@ -55,6 +55,7 @@ ApprovalRequest, EntryPoint, QueueSnapshot, + SearchPlanPreview, SessionEventRecord, SessionRequest, SessionSnapshot, @@ -92,6 +93,8 @@ "build_query", "build_platform_query", "build_company_query", "resolve_query_target", "remember_query", "recent_queries", "clear_recent_queries", + # The plan preview grew the port from 27 to 28. + "preview_search_plan", } @@ -797,10 +800,11 @@ def test_session_request_fields_do_not_shadow_profile() -> None: """GUARD: the one-source-per-value ruling. Adding salary floor, skills, daily limits or identity fields to SessionRequest fails this pin. ``providers`` is admitted because which engines THIS run uses is - session-scoped, not a property of the person.""" + session-scoped, not a property of the person. ``platforms`` is admitted + for the same reason: which hiring platforms THIS run searches.""" assert set(SessionRequest.model_fields) == { "entry", "execution_mode", "keywords", "location", - "max_results", "urls", "providers", + "max_results", "urls", "providers", "platforms", } @@ -1656,3 +1660,127 @@ def test_results_lines_quiet_when_off__guard(self) -> None: lines = format_results_lines(SessionSummary(autonomy_enabled=False)) assert not any("Autonomy" in line for line in lines) + + +class TestPlatformTargeting: + """The request → plan path for ATS-targeted search (session surface).""" + + def test_platforms_inherit_leaves_the_plan_untouched__teeth(self) -> None: + """TEETH: platforms=None means 'not declared' — the plan keeps its + configured default. Mutation caught: writing an empty list for None.""" + controller = _controller() + controller.start_session( + SessionRequest(entry=EntryPoint.SEARCH, keywords=("python",)) + ) + plan = controller.orchestrator.session_plan + assert plan.use_ats_site_search is False + assert plan.ats_platforms == () + + def test_platforms_explicit_empty_disables_site_search__teeth(self) -> None: + """TEETH: platforms=() is an explicit 'generic search this session' + and turns targeting off even when the plan would engage it. + Mutation caught: treating () like None.""" + plan0 = SessionPlan( + session_id="pin", + use_ats_site_search=True, + ats_platforms=("jobvite",), + ) + controller = _controller(plan=plan0) + controller.start_session( + SessionRequest( + entry=EntryPoint.SEARCH, keywords=("python",), platforms=() + ) + ) + plan = controller.orchestrator.session_plan + assert plan.use_ats_site_search is False + + def test_platforms_named_engage_site_search_with_exactly_those__teeth(self) -> None: + """TEETH: a named list engages targeting with exactly those + platforms. Mutation caught: engaging without the names, or + substituting the configured list.""" + controller = _controller() + controller.start_session( + SessionRequest( + entry=EntryPoint.SEARCH, + keywords=("python",), + platforms=("jobvite",), + ) + ) + plan = controller.orchestrator.session_plan + assert plan.use_ats_site_search is True + assert plan.ats_platforms == ("jobvite",) + + def test_admin_lock_overrides_the_request_platforms__teeth(self) -> None: + """TEETH: the admin lock wins over the session's choice. + Mutation caught: applying the request after the lock.""" + from auto_apply.domain.models.policy import AdminPolicy + + controller = _controller() + controller.registry.get_admin_policy.return_value = AdminPolicy( + search_platforms=["greenhouse"] + ) + controller.start_session( + SessionRequest( + entry=EntryPoint.SEARCH, + keywords=("python",), + platforms=("jobvite",), + ) + ) + plan = controller.orchestrator.session_plan + assert plan.ats_platforms == ("greenhouse",) + + +class TestPreviewAndOutcome: + """The preview port method and the event-fed outcome accumulators.""" + + def test_preview_search_plan_returns_a_typed_preview__teeth(self) -> None: + """TEETH: the port method returns the previewer's DTO. + Mutation caught: returning None or a dict.""" + controller = _controller() + sentinel = SearchPlanPreview(site_search=True, planned_count=3) + controller._plan_previewer = lambda req: sentinel + result = controller.preview_search_plan( + SessionRequest(entry=EntryPoint.SEARCH) + ) + assert result is sentinel + + def test_preview_degrades_without_a_previewer__teeth(self) -> None: + """TEETH: no previewer → a generic preview with the reason named, + never an exception. Mutation caught: raising or returning None.""" + controller = _controller() + controller._plan_previewer = None + preview = controller.preview_search_plan( + SessionRequest(entry=EntryPoint.SEARCH) + ) + assert preview.site_search is False + assert preview.reason_key == "searchplan.preview.reason_no_planner" + + def test_summary_accumulates_blocked_engines_and_counts__teeth(self) -> None: + """TEETH: benched engines dedupe by name, reasons survive, and the + discovery outcome counts sum across rounds into the typed summary. + Mutation caught: double-counting a re-benched engine, or dropping + the counts.""" + controller, bus = _projection_controller() + bus.publish(Event.PROVIDER_BENCHED, {"provider": "google", "reason": "captcha"}) + bus.publish(Event.PROVIDER_BENCHED, {"provider": "google", "reason": "captcha"}) + bus.publish( + Event.PROVIDER_BENCHED, + {"provider": "indeed", "yield_ratio": 0.1, "bytes_ratio": 0.2}, + ) + bus.publish( + Event.DISCOVERY_COMPLETE, + { + "off_shape_dropped": 2, + "terminal_dropped": 1, + "routed_to_mining": 1, + "budget_skipped": 3, + }, + ) + summary = controller.summary() + assert [e.provider for e in summary.blocked_engines] == ["google", "indeed"] + assert summary.blocked_engines[0].reason == "captcha" + assert summary.blocked_engines[1].reason + assert summary.discovery_off_shape_dropped == 2 + assert summary.discovery_terminal_dropped == 1 + assert summary.discovery_routed_to_mining == 1 + assert summary.discovery_budget_skipped == 3 diff --git a/packages/auto_apply/tests/application/test_uninstall_engine.py b/packages/auto_apply/tests/application/test_uninstall_engine.py index 99f93e9..1380e88 100644 --- a/packages/auto_apply/tests/application/test_uninstall_engine.py +++ b/packages/auto_apply/tests/application/test_uninstall_engine.py @@ -554,7 +554,7 @@ def test_consent_text_states_the_uninstall_rules_and_versions_moved() -> None: assert "uninstall" in research_consent_text.DIALOG_BODY.lower() assert "kept by default" in research_consent_text.DIALOG_BODY assert "retention hold" in research_consent_text.DIALOG_BODY - assert CURRENT_CONSENT_VERSION == "2.6" + assert tuple(int(p) for p in CURRENT_CONSENT_VERSION.split(".")) >= (2, 6) assert "retention hold" in research_consent_text.PAGE_COPIES_BODY assert CURRENT_PAGE_COPIES_VERSION == "1.1" assert WITHDRAWAL_NOTICE_FILENAME == "aa_withdrawal_notice.json" diff --git a/packages/auto_apply/tests/architecture/test_safety_pins.py b/packages/auto_apply/tests/architecture/test_safety_pins.py index f372121..cdff6e7 100644 --- a/packages/auto_apply/tests/architecture/test_safety_pins.py +++ b/packages/auto_apply/tests/architecture/test_safety_pins.py @@ -187,13 +187,24 @@ def test_print_sites_outside_the_primary_adapters() -> None: }, "cli/startup.py": { "auto_apply.application.services.autonomy", + # i18n: the CLI configures the locale before its first question. + "auto_apply.application.services.i18n", "auto_apply.application.services.session_controller", "auto_apply.infrastructure.composition_root", }, "cli/wizard.py": { + # i18n: the platform question and the plan preview are translated. + "auto_apply.application.services.i18n", "auto_apply.application.services.session_controller", "auto_apply.application.services.ui_schema", }, + "cli/plan_screen.py": { + # The print-the-plan screen reaches the wiring layer for the + # pre-session preview factory (the search-screen precedent) and + # application.services.i18n for its translated text. + "auto_apply.application.services.i18n", + "auto_apply.infrastructure.composition_root", + }, "gui/app.py": { "auto_apply.application.services.profile_validator", "auto_apply.application.services.session_controller", diff --git a/packages/auto_apply/tests/domain/test_search_planner.py b/packages/auto_apply/tests/domain/test_search_planner.py new file mode 100644 index 0000000..e1d7a58 --- /dev/null +++ b/packages/auto_apply/tests/domain/test_search_planner.py @@ -0,0 +1,447 @@ +"""Pins for the automated site-restricted search planner. + +Label honesty: the planner did not exist before this change, so no pin here +could have been run against the old tree. Every pin covers new behaviour and +its docstring names the mutation it catches. The pins marked GUARD protect a +property the feature depends on from future edits. +""" + +from __future__ import annotations + +import pytest + +from auto_apply.domain.models.search_query import QueryRequest +from auto_apply.domain.models.search_surface import ( + SearchSurfaceSpec, + VerificationStatus, +) +from auto_apply.domain.services.query_composer import compose +from auto_apply.domain.services.search_planner import ( + AUTOMATION_STATUSES, + PlatformTarget, + SearchPlanner, +) + + +def _engine_spec( + name: str, + *, + site_template: str | None = "web", + with_site_operator: bool = True, + with_location_field: bool = False, +) -> SearchSurfaceSpec: + """A minimal valid engine spec: a jobs form and a web form.""" + operators = [ + { + "name": "phrase", + "label": "Exact words", + "explanation": "x", + "syntax": '"{value}"', + "status": "vendor", + }, + ] + if with_site_operator: + operators.append( + { + "name": "site", + "label": "Only this site", + "explanation": "x", + "syntax": "site:{value}", + "status": "vendor", + } + ) + web_params: list[dict] = [{"name": "q", "source": "query"}] + if with_location_field: + web_params.append({"name": "l", "source": "location"}) + data: dict = { + "name": name, + "display_name": name.title(), + "kind": "engine", + "homepage": f"https://{name}.example", + "text_query": { + "or_token": "OR", + "subject_group": "({content})", + "phrase_quote": '"', + "exclude_prefix": "-", + }, + "urls": { + "jobs": { + "base": f"https://{name}.example/jobs", + "parameters": [{"name": "q", "source": "query"}], + }, + "web": { + "base": f"https://{name}.example/search", + "parameters": web_params, + }, + }, + "default_url": "web", + "automation_url": "jobs", + "operators": operators, + } + if site_template is not None: + data["site_template"] = site_template + return SearchSurfaceSpec.model_validate(data) + + +def _target( + name: str, status: str = "measured", restriction: str | None = None +) -> PlatformTarget: + return PlatformTarget( + name=name, + restriction=( + restriction if restriction is not None else f"boards.{name}.example" + ), + status=status, + ) + + +def _planner(**overrides) -> SearchPlanner: + kwargs: dict = { + "engine_specs": { + "engone": _engine_spec("engone"), + "engtwo": _engine_spec("engtwo"), + }, + "platform_targets": {}, + "is_terminal": None, + "strategy_name": "per_platform", + } + kwargs.update(overrides) + return SearchPlanner(**kwargs) + + +def _plan(planner: SearchPlanner, **overrides): + kwargs: dict = { + "bases": (("Engineer", "", "remote"),), + "engines": ("engone", "engtwo"), + "platforms": (), + "date_range": None, + "max_results": 30, + "budget": 50, + } + kwargs.update(overrides) + return planner.plan(**kwargs) + + +def test_only_measured_or_vendor_platforms_are_planned() -> None: + """NEW SURFACE — the automation verification gate. + + Mutation caught: widening AUTOMATION_STATUSES (e.g. adding third_party or + unverified), or dropping the status check entirely. + """ + planner = _planner( + platform_targets={ + "measuredco": _target("measuredco", "measured"), + "vendorco": _target("vendorco", "vendor"), + "thirdco": _target("thirdco", "third_party"), + "unknownco": _target("unknownco", "unverified"), + } + ) + out = _plan( + planner, + engines=("engone",), + platforms=("measuredco", "vendorco", "thirdco", "unknownco"), + ) + assert [q.platform for q in out.queries] == ["measuredco", "vendorco"] + notes = " ".join(n.message for n in out.notes) + assert "thirdco" in notes and "unknownco" in notes + assert set(AUTOMATION_STATUSES) == { + VerificationStatus.MEASURED, + VerificationStatus.VENDOR, + } + + +def test_engine_without_a_site_operator_gets_no_site_queries() -> None: + """NEW SURFACE — refusal-both in automation. + + Mutation caught: emitting a degraded, restriction-less query instead of + skipping the engine. + """ + planner = _planner( + engine_specs={"plain": _engine_spec("plain", with_site_operator=False)}, + platform_targets={"measuredco": _target("measuredco")}, + ) + out = _plan(planner, engines=("plain",), platforms=("measuredco",)) + assert out.queries == () + assert any("not planned" in n.message for n in out.notes) + + +def test_engine_without_a_site_template_gets_no_site_queries() -> None: + """NEW SURFACE — an engine must declare which form carries site queries. + + Mutation caught: defaulting site queries to the automation (jobs) form. + """ + planner = _planner( + engine_specs={"engone": _engine_spec("engone", site_template=None)}, + platform_targets={"measuredco": _target("measuredco")}, + ) + out = _plan(planner, engines=("engone",), platforms=("measuredco",)) + assert out.queries == () + assert any("no form for site-restricted" in n.message for n in out.notes) + + +def test_queries_are_composed_through_the_catalog_composer() -> None: + """NEW SURFACE (differential) — the planner's text equals compose()'s + output for the same inputs, byte for byte. + + Mutation caught: hand-formatting a site: clause anywhere in the planner. + """ + spec = _engine_spec("engone") + planner = _planner( + engine_specs={"engone": spec}, + platform_targets={ + "greenhouse": _target("greenhouse", restriction="boards.greenhouse.io") + }, + ) + out = _plan( + planner, + bases=(("Software Engineer", "", "remote"),), + engines=("engone",), + platforms=("greenhouse",), + ) + expected = compose( + QueryRequest( + surface="engone", + site="boards.greenhouse.io", + phrases=("Software Engineer",), + ), + spec, + ).bar_text + assert [q.raw_query_string for q in out.queries] == [expected] + + +def test_the_planner_formats_no_site_clause_itself() -> None: + """GUARD — composition is single-sourced in the catalog composer. + + Mutation caught: any hand-built site: clause appearing in the planner + module, or the compose import disappearing. + """ + import inspect + + import auto_apply.domain.services.search_planner as planner_module + + source = inspect.getsource(planner_module) + assert "site:{" not in source + assert '"site:" +' not in source + assert "'site:' +" not in source + assert callable(getattr(planner_module, "compose", None)) + + +def test_engines_rotate_innermost_platforms_outermost() -> None: + """NEW SURFACE — no engine host receives consecutive queries while + another enabled target remains. + + Mutation caught: swapping the loop nesting (engine-outer). + """ + planner = _planner( + platform_targets={ + "greenhouse": _target("greenhouse"), + "lever": _target("lever"), + } + ) + out = _plan(planner, platforms=("greenhouse", "lever")) + assert [(q.platform, q.engine) for q in out.queries] == [ + ("greenhouse", "engone"), + ("greenhouse", "engtwo"), + ("lever", "engone"), + ("lever", "engtwo"), + ] + + +def test_budget_truncates_and_reports_dropped_targets() -> None: + """NEW SURFACE — the budget is a hard cap, never exceeded, never silent. + + Mutation caught: an off-by-one (>= vs >), or dropping without a note. + """ + planner = _planner( + platform_targets={ + "greenhouse": _target("greenhouse"), + "lever": _target("lever"), + } + ) + out = _plan(planner, platforms=("greenhouse", "lever"), budget=3) + assert len(out.queries) == 3 + assert len(out.dropped_targets) == 1 + assert any("budget" in n.message.lower() for n in out.notes) + + +def test_planning_is_deterministic() -> None: + """NEW SURFACE — same inputs, same plan, twice. + + Mutation caught: introducing unordered iteration (sets) or chance into + planning. + """ + planner = _planner( + platform_targets={ + "greenhouse": _target("greenhouse"), + "lever": _target("lever"), + } + ) + first = _plan(planner, platforms=("greenhouse", "lever")) + second = _plan(planner, platforms=("greenhouse", "lever")) + assert first == second + + +def test_location_is_appended_only_when_the_engine_form_lacks_a_location_field() -> None: + """NEW SURFACE — location handling follows the engine form's fields. + + Mutation caught: always appending (duplicating the location into engines + that carry it in the URL) or never appending (dropping it on web forms). + """ + planner = _planner( + engine_specs={ + "withloc": _engine_spec("withloc", with_location_field=True), + "withoutloc": _engine_spec("withoutloc"), + }, + platform_targets={"greenhouse": _target("greenhouse")}, + ) + out = _plan( + planner, + bases=(("Engineer", "Austin", "remote"),), + engines=("withloc", "withoutloc"), + platforms=("greenhouse",), + ) + by_engine = {q.engine: q for q in out.queries} + assert "Austin" not in by_engine["withloc"].raw_query_string + assert by_engine["withloc"].location == "Austin" + assert by_engine["withoutloc"].raw_query_string.endswith("Austin") + + +def test_terminal_restrictions_are_never_planned() -> None: + """NEW SURFACE — a success/confirm page shape is never a search target. + + Mutation caught: dropping the is_terminal check. + """ + planner = _planner( + platform_targets={ + "doneco": _target( + "doneco", restriction="boards.example.com/apply/success" + ) + }, + is_terminal=lambda url: "success" in url, + ) + out = _plan(planner, engines=("engone",), platforms=("doneco",)) + assert out.queries == () + assert any("terminal" in n.message for n in out.notes) + + +def test_an_unknown_strategy_falls_back_to_per_platform_with_a_warning() -> None: + """NEW SURFACE — a bad strategy name degrades, loudly. + + Mutation caught: raising on an unknown strategy, or using it silently. + """ + planner = _planner( + strategy_name="grouped_or", + platform_targets={"greenhouse": _target("greenhouse")}, + ) + out = _plan(planner, platforms=("greenhouse",)) + assert [q.platform for q in out.queries] == ["greenhouse", "greenhouse"] + assert any("Unknown site-search strategy" in n.message for n in out.notes) + + +def test_a_multi_platform_group_is_refused_to_per_platform() -> None: + """NEW SURFACE — grouped composition is refused loudly until the + composer supports it. + + Mutation caught: silently splitting a multi-platform group, or blindly + trusting a strategy's groups. + """ + + class GroupAll: + name = "group_all" + + def group(self, platforms): + return [tuple(platforms)] + + planner = _planner( + strategies=[GroupAll()], + strategy_name="group_all", + platform_targets={ + "greenhouse": _target("greenhouse"), + "lever": _target("lever"), + }, + engine_specs={"engone": _engine_spec("engone")}, + ) + out = _plan(planner, engines=("engone",), platforms=("greenhouse", "lever")) + assert [q.platform for q in out.queries] == ["greenhouse", "lever"] + assert any("multi-platform group" in n.message for n in out.notes) + + +def test_an_unknown_platform_is_skipped_with_a_warning() -> None: + """NEW SURFACE. + + Mutation caught: planning a platform with no descriptor (an invented + restriction). + """ + out = _plan(_planner(), engines=("engone",), platforms=("nosuchco",)) + assert out.queries == () + assert any("nosuchco" in n.message for n in out.notes) + + +def test_an_engine_missing_from_the_catalog_is_skipped() -> None: + """NEW SURFACE. + + Mutation caught: planning on an engine with no catalog entry. + """ + planner = _planner(platform_targets={"greenhouse": _target("greenhouse")}) + out = _plan(planner, engines=("noengine",), platforms=("greenhouse",)) + assert out.queries == () + assert any("noengine" in n.message for n in out.notes) + + +def test_nothing_to_target_plans_nothing_without_raising() -> None: + """NEW SURFACE — degradation: empty inputs produce an empty plan, not an + exception. + + Mutation caught: indexing into an empty list, or treating empty input as + an error. + """ + planner = _planner(platform_targets={"greenhouse": _target("greenhouse")}) + assert _plan(planner, engines=(), platforms=("greenhouse",)).queries == () + assert _plan(planner, platforms=()).queries == () + assert _plan(planner, bases=()).queries == () + + +def test_date_range_and_result_cap_are_carried_onto_every_query() -> None: + """NEW SURFACE. + + Mutation caught: dropping date_range/max_results when converting planner + output. + """ + planner = _planner(platform_targets={"greenhouse": _target("greenhouse")}) + out = _plan( + planner, + engines=("engone",), + platforms=("greenhouse",), + date_range="week", + max_results=15, + ) + assert out.queries + for query in out.queries: + assert query.date_range == "week" + assert query.max_results == 15 + assert query.engine == "engone" + assert query.surface_template == "web" + + +def test_engines_and_platforms_preserve_the_caller_order() -> None: + """GUARD — planning order follows the caller's order, not dict/set order. + + Mutation caught: iterating a set of targets or re-sorting them. + """ + planner = _planner( + platform_targets={ + "zeta": _target("zeta"), + "alpha": _target("alpha"), + } + ) + out = _plan( + planner, + engines=("engtwo", "engone"), + platforms=("zeta", "alpha"), + ) + assert [(q.platform, q.engine) for q in out.queries] == [ + ("zeta", "engtwo"), + ("zeta", "engone"), + ("alpha", "engtwo"), + ("alpha", "engone"), + ] diff --git a/packages/auto_apply/tests/domain/test_ui_contract.py b/packages/auto_apply/tests/domain/test_ui_contract.py index e47b840..c8701b7 100644 --- a/packages/auto_apply/tests/domain/test_ui_contract.py +++ b/packages/auto_apply/tests/domain/test_ui_contract.py @@ -27,8 +27,11 @@ from auto_apply.domain.models.ui_contract import ( ActivityKind, ApprovalRequest, + BlockedEngine, EntryPoint, + PlannedQueryView, QueueSnapshot, + SearchPlanPreview, SessionEventRecord, SessionRequest, SessionSnapshot, @@ -71,6 +74,7 @@ "build_query", "build_platform_query", "build_company_query", "resolve_query_target", "remember_query", "recent_queries", "clear_recent_queries", + "preview_search_plan", } @@ -307,7 +311,7 @@ def test_session_request_fields_do_not_shadow_profile() -> None: session-scoped, not a property of the person.""" assert set(SessionRequest.model_fields) == { "entry", "execution_mode", "keywords", "location", - "max_results", "urls", "providers", + "max_results", "urls", "providers", "platforms", } @@ -373,3 +377,29 @@ def test_activity_kind_membership_is_exactly_the_ui_vocabulary() -> None: ActivityKind.INFO, ActivityKind.WAITING, } + + +def test_preview_and_outcome_dtos_are_frozen() -> None: + """TEETH: the preview and outcome DTOs reject assignment like the rest. + + Mutation caught: a mutable DTO crossing the port.""" + for instance in ( + SearchPlanPreview(), + PlannedQueryView(query="q"), + BlockedEngine(provider="google"), + ): + field_name = next(iter(type(instance).model_fields)) + with pytest.raises(ValidationError): + setattr(instance, field_name, getattr(instance, field_name)) + + +def test_summary_carries_the_automation_outcome_fields() -> None: + """TEETH: the results view carries the automation outcome — zeroed when + nothing happened. Mutation caught: a missing or non-defaulted field.""" + summary = SessionSummary() + assert summary.blocked_engines == () + assert summary.discovery_off_shape_dropped == 0 + assert summary.discovery_terminal_dropped == 0 + assert summary.discovery_routed_to_mining == 0 + assert summary.discovery_budget_skipped == 0 + assert summary.search_budget == 0 diff --git a/packages/auto_apply/tests/fixtures/replay/expected/manifest.json b/packages/auto_apply/tests/fixtures/replay/expected/manifest.json index fe31476..3eb2d7a 100644 --- a/packages/auto_apply/tests/fixtures/replay/expected/manifest.json +++ b/packages/auto_apply/tests/fixtures/replay/expected/manifest.json @@ -106,7 +106,7 @@ "why": "application forms are not kept as page copies" } ], - "research_schema_version": 4, + "research_schema_version": 5, "signals": 7, "skipped": [ { diff --git a/packages/auto_apply/tests/infrastructure/test_config_contract.py b/packages/auto_apply/tests/infrastructure/test_config_contract.py index 8211030..a756aae 100644 --- a/packages/auto_apply/tests/infrastructure/test_config_contract.py +++ b/packages/auto_apply/tests/infrastructure/test_config_contract.py @@ -366,8 +366,9 @@ def test_session_plan_sources_config_from_typed_effective_config() -> None: f"fields — a renamed field would silently vanish." ) - # execution_mode is an admin grant injected under a 'session' section, not a - # YAML knob, so those are the only raw dict reads permitted to remain. + # execution_mode and search_pairs are session-scoped values injected under + # the 'session' section (admin grants and research harnesses), not YAML + # knobs, so only those raw dict reads are permitted to remain. raw_get_keys = { n.args[0].value for n in ast.walk(tree) @@ -378,7 +379,7 @@ def test_session_plan_sources_config_from_typed_effective_config() -> None: and isinstance(n.args[0], ast.Constant) and isinstance(n.args[0].value, str) } - stray = sorted(raw_get_keys - {"session", "execution_mode"}) + stray = sorted(raw_get_keys - {"session", "execution_mode", "search_pairs"}) assert not stray, ( f"from_config still reads raw config keys {stray} instead of the typed " f"EffectiveConfig — each is a dict.get that can silently default." diff --git a/packages/auto_apply/tests/infrastructure/test_reproducibility.py b/packages/auto_apply/tests/infrastructure/test_reproducibility.py index e31103a..b21358e 100644 --- a/packages/auto_apply/tests/infrastructure/test_reproducibility.py +++ b/packages/auto_apply/tests/infrastructure/test_reproducibility.py @@ -448,6 +448,7 @@ def _fake_acquire_driver(self): ("interaction.pacing",), ("motion.pointer",), ("motion.wheel",), + ("discovery.host_pacing",), } missing = required - namespaces assert not missing, ( diff --git a/packages/auto_apply/tests/integration/test_browser_lifecycle.py b/packages/auto_apply/tests/integration/test_browser_lifecycle.py index 2f05b45..3718b89 100644 --- a/packages/auto_apply/tests/integration/test_browser_lifecycle.py +++ b/packages/auto_apply/tests/integration/test_browser_lifecycle.py @@ -245,6 +245,9 @@ def test_system_exit_in_monitor_loop_still_shuts_down(self, monkeypatch): ) class FakeWizard: + def __init__(self, **kwargs): + pass + def run(self): return session_config diff --git a/packages/auto_apply/tests/research/test_research_accounting.py b/packages/auto_apply/tests/research/test_research_accounting.py index 6d78a13..0af7aa9 100644 --- a/packages/auto_apply/tests/research/test_research_accounting.py +++ b/packages/auto_apply/tests/research/test_research_accounting.py @@ -356,6 +356,11 @@ def test_controller_summary_carries_the_accounting() -> None: controller._view_state = lambda: idle # type: ignore[method-assign] controller._load_discovered_jobs = lambda: () # type: ignore[method-assign] controller.autonomy = lambda: False # type: ignore[method-assign] + controller._blocked_engines = [] + controller._discovery_outcome_counts = { + "off_shape_dropped": 0, "terminal_dropped": 0, + "routed_to_mining": 0, "budget_skipped": 0, + } assert controller.summary().research == _lost_view() controller.orchestrator.research_accounting.side_effect = RuntimeError("gone") diff --git a/packages/auto_apply/tests/research/test_research_consent.py b/packages/auto_apply/tests/research/test_research_consent.py index 6799997..c610a93 100644 --- a/packages/auto_apply/tests/research/test_research_consent.py +++ b/packages/auto_apply/tests/research/test_research_consent.py @@ -756,6 +756,7 @@ def test_every_schema_table_is_disclosed_in_the_consent_text(): "detector_examinations": "Which detectors ran", "detector_outcomes": "how each concluded", "research_provenance": "public verification key", + "search_plan_frames": "sampling frame", } assert tables == set(disclosure), ( f"schema/consent-text drift — update the dialog AND this map: " diff --git a/packages/auto_apply/tests/research/test_sampling_frame.py b/packages/auto_apply/tests/research/test_sampling_frame.py new file mode 100644 index 0000000..f5e17de --- /dev/null +++ b/packages/auto_apply/tests/research/test_sampling_frame.py @@ -0,0 +1,113 @@ +"""Pins for the consent-gated sampling frame (research schema v5). + +Teeth against the pre-change tree (no table, no observation type, no write +path). Mutations caught: query text reaching the frame; the frame written +without consent; the table missing from exports. +""" + +from __future__ import annotations + +import dataclasses +import json +import sqlite3 +from pathlib import Path + +import pytest + +from auto_apply.adapters.secondary.research import signal_aggregator +from auto_apply.adapters.secondary.research.research_exporter import ResearchExporter +from auto_apply.adapters.secondary.research.signal_aggregator import ( + ResearchSignalAggregator, +) +from auto_apply.domain.constants import CURRENT_CONSENT_VERSION, RESEARCH_SCHEMA_VERSION +from auto_apply.domain.ports.research_port import SearchPlanObservation + + +def _frame() -> SearchPlanObservation: + return SearchPlanObservation( + engines=("bing", "google"), + platforms=("jobvite",), + queries_planned=2, + per_engine=(("bing", 1), ("google", 1)), + per_platform=(("jobvite", 2),), + dropped_count=1, + notes_info=1, + notes_warning=0, + budget_before=5, + budget_after=3, + ) + + +def _aggregator(db_path: Path, monkeypatch: pytest.MonkeyPatch, consent: bool): + monkeypatch.setattr( + signal_aggregator, "resolve_research_salt", lambda: "test-salt" + ) + agg = ResearchSignalAggregator( + db_path=db_path, + consent_version=CURRENT_CONSENT_VERSION if consent else None, + flush_interval_seconds=0.05, + macro_signal_interval_seconds=3600.0, + provenance_key_path=db_path.parent / "provenance_key.pem", + ) + agg.start() + return agg + + +def test_the_frame_persists_names_and_counts(tmp_path, monkeypatch): + db = tmp_path / "research" / "r.db" + agg = _aggregator(db, monkeypatch, consent=True) + agg.observe_search_plan(_frame()) + agg.stop() + conn = sqlite3.connect(str(db)) + try: + rows = conn.execute("SELECT * FROM search_plan_frames").fetchall() + assert len(rows) == 1 + cols = [d[1] for d in conn.execute("PRAGMA table_info(search_plan_frames)")] + row = dict(zip(cols, rows[0], strict=True)) + assert json.loads(row["engines"]) == ["bing", "google"] + assert json.loads(row["per_platform"]) == {"jobvite": 2} + assert row["queries_planned"] == 2 + assert row["budget_before"] == 5 + assert row["budget_after"] == 3 + assert row["dropped_count"] == 1 + assert row["schema_version"] == RESEARCH_SCHEMA_VERSION + finally: + conn.close() + + +def test_the_frame_has_no_field_that_could_carry_query_text(): + """Structural: the observation cannot hold a query, title or location.""" + names = {f.name for f in dataclasses.fields(SearchPlanObservation)} + for forbidden in ( + "query", "raw_query_string", "title", "location", "search_query", + ): + assert forbidden not in names + + +def test_no_frame_is_written_without_consent(tmp_path, monkeypatch): + db = tmp_path / "research" / "r.db" + agg = _aggregator(db, monkeypatch, consent=False) + agg.observe_search_plan(_frame()) + agg.stop() + assert not db.exists() + + +def test_the_frame_reaches_the_export_bundle(tmp_path, monkeypatch): + db = tmp_path / "research" / "r.db" + agg = _aggregator(db, monkeypatch, consent=True) + agg.observe_search_plan(_frame()) + agg.stop() + result = ResearchExporter( + db_path=db, + export_root=tmp_path / "exports", + provenance_key_path=tmp_path / "research" / "provenance_key.pem", + ).export("csv") + index = json.loads((result.directory / "index.json").read_text(encoding="utf-8")) + by_table = {entry["table"]: entry for entry in index["tables"]} + assert by_table["search_plan_frames"]["rows"] == 1 + assert (result.directory / by_table["search_plan_frames"]["file"]).exists() + + +def test_schema_version_is_at_least_five(): + """The frame table's introduction bumped the schema to 5.""" + assert RESEARCH_SCHEMA_VERSION >= 5 diff --git a/packages/auto_apply/tests/workflows/test_discovery_persistence.py b/packages/auto_apply/tests/workflows/test_discovery_persistence.py index b340cf7..e355694 100644 --- a/packages/auto_apply/tests/workflows/test_discovery_persistence.py +++ b/packages/auto_apply/tests/workflows/test_discovery_persistence.py @@ -290,3 +290,55 @@ def test_get_recent_jobs_orders_by_applied_at_without_error(fresh_db): recent = JobRepository(fresh_db).get_recent_jobs(limit=10) assert {j.url for j in recent} == {j.url for j in jobs} + + +def test_discovery_metadata_round_trips_through_storage(fresh_db): + """TEETH: pre-change, everything the planner stamped died in memory.""" + job = _job(1) + job.metadata.update({ + "provider": "Bing", + "search_query": 'site:jobs.jobvite.com ("Engineer")', + "target_platform": "jobvite", + "engine_form": "web", + "route": "engine_site_search", + "page_index": 0, + "rank": 2, + }) + fresh_db.record_job_discovery(job, session_id="s1") + + found = JobRepository(fresh_db).get_jobs_for_session("s1") + + assert len(found) == 1 + assert found[0].metadata["target_platform"] == "jobvite" + assert found[0].metadata["engine_form"] == "web" + assert found[0].metadata["page_index"] == 0 + assert found[0].metadata["rank"] == 2 + assert found[0].source == "Bing" + + +def test_a_real_run_stores_the_provider_and_provenance__teeth(fresh_db): + """TEETH: the stored row carries what discovery stamped - the provider + and the query - read back through the session read. Mutation caught: + storing the jobs before _classify_job_source stamps them (the stored + source then reads "history" and "provider" is missing).""" + jobs = [_job(i) for i in range(2)] + wf = _workflow(fresh_db, jobs, session_id="s1", mode=SessionExecutionMode.DISCOVER_ONLY) + + wf.run(instructions=[SearchInstruction(title="Engineer", location="Remote")]) + + found = JobRepository(fresh_db).get_jobs_for_session("s1") + assert len(found) == 2 + assert {j.source for j in found} == {"bing"} + assert all(j.metadata.get("provider") == "bing" for j in found) + assert all(j.metadata.get("search_query") for j in found) + assert all(j.metadata.get("route") == "provider_serp" for j in found) + + +def test_a_job_without_metadata_reads_back_as_history(fresh_db): + """Behaviour-preserving: rows without provenance read exactly as before.""" + fresh_db.record_job_discovery(_job(1), session_id="s1") + + found = JobRepository(fresh_db).get_jobs_for_session("s1") + + assert found[0].metadata == {} + assert found[0].source == "history" diff --git a/packages/auto_apply/tests/workflows/test_session_budget.py b/packages/auto_apply/tests/workflows/test_session_budget.py new file mode 100644 index 0000000..ee45217 --- /dev/null +++ b/packages/auto_apply/tests/workflows/test_session_budget.py @@ -0,0 +1,134 @@ +"""Pins for the session-owned query budget and cross-task engine rotation. + +New-surface pins. Mutations caught: the budget reverting to a per-task cap; +the rotation restarting at every task boundary; the budget counting planned +but never-sent instructions; generic sessions being truncated under the cap. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from auto_apply.application.workflows.discovery_workflow import DiscoveryWorkflow +from auto_apply.domain.models.search_instruction import SearchInstruction +from auto_apply.domain.models.session_plan import SessionPlan +from auto_apply.domain.services.search_planner import ( + PlannedQuery, + PlannedSearch, +) + + +def _pq(engine: str, platform: str = "jobvite") -> PlannedQuery: + return PlannedQuery( + engine=engine, + platform=platform, + title="Engineer", + location="Remote", + workplace_type="remote", + raw_query_string='site:jobs.jobvite.com ("Engineer")', + surface_template="web", + date_range=None, + max_results=30, + ) + + +class _RecordingPlanner: + def __init__(self, engines_per_call: list[str]) -> None: + self.calls: list[dict] = [] + self._engines_per_call = engines_per_call + + def plan(self, **kwargs) -> PlannedSearch: + self.calls.append(kwargs) + queries = tuple(_pq(engine) for engine in self._engines_per_call) + return PlannedSearch(queries=queries, notes=(), dropped_targets=()) + + +def _engine(name: str) -> MagicMock: + provider = MagicMock() + provider.name = name + provider.requires_live_browser = False + provider.run.return_value = [] + return provider + + +def _workflow(planner, plan: SessionPlan) -> DiscoveryWorkflow: + return DiscoveryWorkflow( + profile=MagicMock(), + providers=[_engine("google"), _engine("bing")], + task_queue=MagicMock(), + event_bus=MagicMock(), + dedup=MagicMock(), + text_matcher=MagicMock(), + plan=plan, + search_planner=planner, + ) + + +def _plan(cap: int) -> SessionPlan: + return SessionPlan( + session_id="budget-pin", + max_queries_per_session=cap, + use_ats_site_search=True, + ats_platforms=("jobvite",), + active_providers=("google", "bing"), + max_concurrency=1, + ) + + +def _instruction() -> SearchInstruction: + return SearchInstruction(title="Engineer", location="Remote") + + +def test_the_budget_is_shared_across_discover_tasks(): + """The second task is planned with what the first left, not a fresh cap.""" + planner = _RecordingPlanner(["google", "bing"]) + workflow = _workflow(planner, _plan(cap=5)) + workflow.run(instructions=[_instruction()]) + workflow.run(instructions=[_instruction()]) + assert planner.calls[0]["budget"] == 5 + assert planner.calls[1]["budget"] == 3 + + +def test_engine_rotation_continues_across_a_task_boundary(): + """No engine gets two consecutive planned queries across tasks.""" + planner = _RecordingPlanner(["google"]) + workflow = _workflow(planner, _plan(cap=10)) + workflow.run(instructions=[_instruction()]) + workflow.run(instructions=[_instruction()]) + assert planner.calls[0]["engines"] == ("google", "bing") + assert planner.calls[1]["engines"] == ("bing", "google") + + +def test_rotation_resumes_after_the_last_engine_used(): + """Two queries ending on bing: the next task starts on google.""" + planner = _RecordingPlanner(["google", "bing"]) + workflow = _workflow(planner, _plan(cap=10)) + workflow.run(instructions=[_instruction()]) + workflow.run(instructions=[_instruction()]) + assert planner.calls[1]["engines"] == ("google", "bing") + + +def test_an_exhausted_budget_never_reaches_the_planner_or_a_provider(): + planner = _RecordingPlanner(["google", "bing"]) + workflow = _workflow(planner, _plan(cap=2)) + workflow.run(instructions=[_instruction()]) + second = workflow.run(instructions=[_instruction()]) + assert len(planner.calls) == 1 + assert second == 0 + + +def test_generic_queries_count_toward_the_budget_but_are_not_truncated_under_it(): + """Default path: the counter runs, nothing is cut below the cap.""" + plan = SessionPlan(session_id="budget-generic", max_queries_per_session=5) + workflow = DiscoveryWorkflow( + profile=MagicMock(), + providers=[_engine("google")], + task_queue=MagicMock(), + event_bus=MagicMock(), + dedup=MagicMock(), + text_matcher=MagicMock(), + plan=plan, + ) + for _ in range(3): + workflow.run(instructions=[_instruction()]) + assert workflow._queries_sent_this_session == 3 diff --git a/packages/auto_apply/tests/workflows/test_site_search_wiring.py b/packages/auto_apply/tests/workflows/test_site_search_wiring.py new file mode 100644 index 0000000..69d00b8 --- /dev/null +++ b/packages/auto_apply/tests/workflows/test_site_search_wiring.py @@ -0,0 +1,379 @@ +"""Wiring pins for ATS-targeted search inside DiscoveryWorkflow. + +Label honesty: the planner integration did not exist before this change. +Pins marked GUARD protect the default path (site search OFF) — those run +against behavior that must not change. Pins marked NEW SURFACE cover the new +behavior and name the mutation each catches. +""" + +from __future__ import annotations + +import logging +from unittest.mock import MagicMock + +from auto_apply.application.workflows.discovery_workflow import ( + DiscoveryWorkflow, + _DiscoveryStats, +) +from auto_apply.domain.models.job import Job +from auto_apply.domain.models.search_instruction import SearchInstruction +from auto_apply.domain.models.session_plan import SearchPair, SessionPlan +from auto_apply.domain.models.work_unit import TaskType +from auto_apply.domain.services.search_planner import ( + PlannedQuery, + PlannedSearch, + PlannerNote, +) + + +def _planned_query(**overrides) -> PlannedQuery: + kwargs: dict = { + "engine": "google", + "platform": "greenhouse", + "title": "Engineer", + "location": "Remote", + "workplace_type": "remote", + "raw_query_string": 'site:boards.greenhouse.io ("Engineer")', + "surface_template": "web", + "date_range": None, + "max_results": 30, + } + kwargs.update(overrides) + return PlannedQuery(**kwargs) + + +class _FakePlanner: + """Records plan() calls and returns canned output.""" + + def __init__(self, queries=(), notes=()): + self._queries = tuple(queries) + self._notes = tuple(notes) + self.calls: list[dict] = [] + + def plan(self, **kwargs) -> PlannedSearch: + self.calls.append(kwargs) + return PlannedSearch(queries=self._queries, notes=self._notes) + + +def _profile(titles=("Engineer",), locations=("Remote",)): + profile = MagicMock() + profile.search_preferences.desired_job_titles = list(titles) + profile.search_preferences.preferred_locations = list(locations) + profile.search_preferences.workplace_types = ["remote"] + return profile + + +def _plan(**overrides) -> SessionPlan: + kwargs: dict = {"session_id": "site-search-wiring", "max_concurrency": 1} + kwargs.update(overrides) + return SessionPlan(**kwargs) + + +def _workflow(plan, planner=None, providers=(), profile=None) -> DiscoveryWorkflow: + return DiscoveryWorkflow( + profile=profile if profile is not None else _profile(), + providers=list(providers), + task_queue=MagicMock(), + event_bus=MagicMock(), + dedup=MagicMock(), + text_matcher=MagicMock(), + plan=plan, + search_planner=planner, + ) + + +def test_default_path_is_untouched_when_site_search_is_off() -> None: + """GUARD — with the flag off, overrides pass through unchanged and the + planner is never consulted, even when one is injected. + + Mutation caught: consulting the planner when the flag is off. + """ + planner = _FakePlanner(queries=[_planned_query()]) + workflow = _workflow(plan=_plan(), planner=planner) + base = [SearchInstruction(title="Engineer", location="Remote")] + out = workflow._build_search_instructions(override_instructions=base) + assert out == base + assert planner.calls == [] + + +def test_generic_overrides_expand_through_the_planner() -> None: + """NEW SURFACE — a seeded (title, location) task becomes planner output + when site search is engaged. + + Mutation caught: running the generic query alongside (or instead of) the + planned queries, or passing the wrong bases/platforms to the planner. + """ + planner = _FakePlanner( + queries=[_planned_query(), _planned_query(engine="bing")] + ) + workflow = _workflow( + plan=_plan(use_ats_site_search=True, ats_platforms=("greenhouse",)), + planner=planner, + ) + out = workflow._build_search_instructions( + override_instructions=[SearchInstruction(title="Engineer", location="Remote")] + ) + assert len(planner.calls) == 1 + assert planner.calls[0]["bases"] == (("Engineer", "Remote", "remote"),) + assert planner.calls[0]["platforms"] == ("greenhouse",) + assert [i.raw_query_string for i in out] == [ + 'site:boards.greenhouse.io ("Engineer")' + ] * 2 + assert {i.engine for i in out} == {"google", "bing"} + + +def test_verbatim_overrides_pass_through_even_when_site_search_is_on() -> None: + """NEW SURFACE — a raw_query_string override is a verbatim power-user + query; the planner must never rewrite it. + + Mutation caught: expanding or replacing verbatim overrides. + """ + planner = _FakePlanner(queries=[_planned_query()]) + workflow = _workflow( + plan=_plan(use_ats_site_search=True, ats_platforms=("greenhouse",)), + planner=planner, + ) + verbatim = SearchInstruction( + title="Custom", raw_query_string="site:jobs.lever.co engineer" + ) + out = workflow._build_search_instructions(override_instructions=[verbatim]) + assert out == [verbatim] + assert planner.calls == [] + + +def test_search_pairs_override_the_profile_matrix() -> None: + """NEW SURFACE — plan-declared pairs take precedence over the profile + matrix when no user override exists. + + Mutation caught: reading the profile first, or ignoring search_pairs. + """ + plan = _plan( + search_pairs=( + SearchPair(title="Direct", location="Austin", workplace_type="hybrid"), + ) + ) + workflow = _workflow( + plan=plan, profile=_profile(titles=("Wrong",), locations=("Nowhere",)) + ) + out = workflow._build_search_instructions() + assert [(i.title, i.location, i.workplace_type) for i in out] == [ + ("Direct", "Austin", "hybrid") + ] + + +def test_engine_bound_instructions_reach_only_their_engine() -> None: + """NEW SURFACE — engine routing in the fan-out; None still broadcasts. + + Mutation caught: matching on the provider class name instead of .name, + or treating a None engine as bound. + """ + p_google = MagicMock() + p_google.name = "google" + p_google.run.return_value = [] + p_bing = MagicMock() + p_bing.name = "bing" + p_bing.run.return_value = [] + workflow = _workflow(plan=_plan(), providers=(p_google, p_bing)) + google_only = SearchInstruction(title="E", raw_query_string="q", engine="google") + broadcast = SearchInstruction(title="E2", raw_query_string="q2") + bing_only = SearchInstruction(title="E3", raw_query_string="q3", engine="bing") + workflow._execute_serp_discovery( + [google_only, broadcast, bing_only], [p_google, p_bing] + ) + assert [c.args[0] for c in p_google.run.call_args_list] == [ + google_only, + broadcast, + ] + assert [c.args[0] for c in p_bing.run.call_args_list] == [broadcast, bing_only] + + +def test_jobs_carry_search_provenance() -> None: + """NEW SURFACE — every found job states the engine form, platform and + query that produced it. + + Mutation caught: dropping any of the three stamps. + """ + job = Job( + title="T", company="C", url="https://x.example/1", source="google" + ) + provider = MagicMock() + provider.name = "google" + provider.run.return_value = [job] + workflow = _workflow(plan=_plan(), providers=(provider,)) + instruction = SearchInstruction( + title="E", + raw_query_string='site:boards.greenhouse.io ("E")', + engine="google", + platform="greenhouse", + surface_template="web", + ) + found = workflow._execute_serp_discovery([instruction], [provider]) + assert found[0].metadata["search_query"] == 'site:boards.greenhouse.io ("E")' + assert found[0].metadata["target_platform"] == "greenhouse" + assert found[0].metadata["engine_form"] == "web" + + +def test_no_planner_degrades_to_the_normal_path() -> None: + """NEW SURFACE — flag on but no planner wired: overrides pass through, + nothing raises. + + Mutation caught: raising when the planner is absent. + """ + workflow = _workflow( + plan=_plan(use_ats_site_search=True, ats_platforms=("greenhouse",)), + planner=None, + ) + out = workflow._build_search_instructions( + override_instructions=[SearchInstruction(title="Engineer", location="Remote")] + ) + assert [i.title for i in out] == ["Engineer"] + assert out[0].raw_query_string is None + + +def test_site_search_on_without_platforms_falls_back_and_warns_once(caplog) -> None: + """NEW SURFACE — the flag with no platforms warns once per workflow, then + runs the normal matrix. + + Mutation caught: warning per call, or silently producing nothing. + """ + workflow = _workflow( + plan=_plan(use_ats_site_search=True, ats_platforms=()), + planner=_FakePlanner(), + ) + with caplog.at_level(logging.WARNING): + first = workflow._build_search_instructions() + second = workflow._build_search_instructions() + warnings = [ + r + for r in caplog.records + if r.levelno >= logging.WARNING and "use_ats_site_search" in r.getMessage() + ] + assert len(warnings) == 1 + assert first and second + + +def test_planner_notes_reach_the_log(caplog) -> None: + """NEW SURFACE — exclusions the planner reports are visible in the log. + + Mutation caught: swallowing planner notes. + """ + planner = _FakePlanner( + queries=[_planned_query()], + notes=[ + PlannerNote("warning", "budget reached"), + PlannerNote("info", "skipped x"), + ], + ) + workflow = _workflow( + plan=_plan(use_ats_site_search=True, ats_platforms=("greenhouse",)), + planner=planner, + ) + with caplog.at_level(logging.INFO): + workflow._build_search_instructions( + override_instructions=[SearchInstruction(title="E", location="")] + ) + messages = [r.getMessage() for r in caplog.records] + assert any("budget reached" in m for m in messages) + assert any("skipped x" in m for m in messages) + + +def _off_shape_workflow(registry, plan=None) -> DiscoveryWorkflow: + return DiscoveryWorkflow( + profile=_profile(), + providers=[], + task_queue=MagicMock(), + event_bus=MagicMock(), + dedup=MagicMock(), + text_matcher=MagicMock(), + ats_registry=registry, + plan=plan if plan is not None else _plan(), + ) + + +def _hit(url: str, platform: str = "jobvite") -> Job: + job = Job(title="Engineer", company="Acme", url=url, source="google") + job.metadata["target_platform"] = platform + return job + + +def test_a_terminal_hit_is_dropped_and_never_followed(): + """A success/confirm page is never routed anywhere — mining included.""" + registry = MagicMock() + registry.is_terminal_page.return_value = True + workflow = _off_shape_workflow(registry) + kept, counts = workflow._handle_off_shape_hits( + [_hit("https://jobs.jobvite.com/acme/job/1/apply/success")] + ) + assert kept == [] + assert counts == {"dropped": 0, "terminal": 1, "mined": 0} + registry.match.assert_not_called() + workflow._task_queue.queue_task.assert_not_called() + + +def test_an_off_shape_hit_is_dropped_and_counted_by_default(): + """A board URL with mining off: dropped, counted, never enqueued.""" + registry = MagicMock() + registry.is_terminal_page.return_value = False + registry.match.return_value = None + workflow = _off_shape_workflow(registry) + kept, counts = workflow._handle_off_shape_hits( + [_hit("https://jobs.jobvite.com/acme")] + ) + assert kept == [] + assert counts == {"dropped": 1, "terminal": 0, "mined": 0} + workflow._task_queue.queue_task.assert_not_called() + + +def test_an_off_shape_hit_routes_to_company_mining_when_enabled(): + """A board URL with mining on becomes a DISCOVER_COMPANY task.""" + registry = MagicMock() + registry.is_terminal_page.return_value = False + registry.match.return_value = None + plan = _plan(enable_company_page_mining=True) + workflow = _off_shape_workflow(registry, plan=plan) + kept, counts = workflow._handle_off_shape_hits( + [_hit("https://jobs.jobvite.com/acme")] + ) + assert kept == [] + assert counts == {"dropped": 0, "terminal": 0, "mined": 1} + workflow._task_queue.queue_task.assert_called_once() + task = workflow._task_queue.queue_task.call_args.args[0] + assert task.task_type == TaskType.DISCOVER_COMPANY + assert task.payload["careers_url"] == "https://jobs.jobvite.com/acme" + + +def test_an_on_shape_posting_is_kept(): + """A URL matching the targeted platform's posting shape passes through.""" + registry = MagicMock() + registry.is_terminal_page.return_value = False + registry.match.return_value = MagicMock(name="jobvite") + registry.match.return_value.name = "jobvite" + workflow = _off_shape_workflow(registry) + hit = _hit("https://jobs.jobvite.com/acme/job/123") + kept, counts = workflow._handle_off_shape_hits([hit]) + assert kept == [hit] + assert counts == {"dropped": 0, "terminal": 0, "mined": 0} + + +def test_unstamped_jobs_pass_untouched(): + """The default path never stamps target_platform — it is never filtered.""" + registry = MagicMock() + workflow = _off_shape_workflow(registry) + job = Job(title="Engineer", company="Acme", url="https://acme.example/1", source="bing") + kept, counts = workflow._handle_off_shape_hits([job]) + assert kept == [job] + assert counts == {"dropped": 0, "terminal": 0, "mined": 0} + registry.is_terminal_page.assert_not_called() + + +def test_off_shape_counts_reach_the_completion_summary(): + """The discovery report names what was dropped and why.""" + workflow = _off_shape_workflow(MagicMock()) + stats = _DiscoveryStats( + off_shape_dropped=2, terminal_dropped=1, routed_to_mining=1 + ) + workflow._emit_completion_summary(stats) + payload = workflow._event_bus.publish.call_args.args[1] + assert payload["off_shape_dropped"] == 2 + assert payload["terminal_dropped"] == 1 + assert payload["routed_to_mining"] == 1