From 6319fadb5cc07e94a3ebfdf8bc6f4e3eb3388d29 Mon Sep 17 00:00:00 2001 From: Nick Liebmann <53795413+Liebmann5@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:11:35 -0700 Subject: [PATCH 1/5] Query builder core: search-surface catalog, query composer, builder service; engine URLs read from the catalog --- .../docs/adr/004_ats_platform_registry.md | 3 +- .../secondary/discovery/providers/google.py | 32 +- .../discovery/search_surface_registry.py | 170 +++++++ .../discovery/strategies/engine_strategies.py | 107 ++--- .../discovery/strategies/selector_loader.py | 43 +- .../application/services/query_builder.py | 226 ++++++++++ .../services/session_controller.py | 20 + .../application/services/uninstall/engine.py | 1 + .../auto_apply/domain/models/search_query.py | 89 ++++ .../domain/models/search_surface.py | 194 ++++++++ .../domain/services/query_composer.py | 421 ++++++++++++++++++ .../infrastructure/composition_root.py | 15 + .../auto_apply/resources/engines/google.yaml | 9 +- .../search_surfaces/bing.surface.yaml | 131 ++++++ .../search_surfaces/duckduckgo.surface.yaml | 84 ++++ .../search_surfaces/google.surface.yaml | 199 +++++++++ .../search_surfaces/indeed.surface.yaml | 84 ++++ .../search_surfaces/linkedin.surface.yaml | 55 +++ .../adapters/test_discovery_providers.py | 56 ++- .../adapters/test_engine_url_byte_identity.py | 102 +++++ .../application/test_query_builder_service.py | 178 ++++++++ .../tests/domain/test_query_composer.py | 311 +++++++++++++ .../test_search_surface_registry.py | 212 +++++++++ 23 files changed, 2638 insertions(+), 104 deletions(-) create mode 100644 packages/auto_apply/src/auto_apply/adapters/secondary/discovery/search_surface_registry.py create mode 100644 packages/auto_apply/src/auto_apply/application/services/query_builder.py create mode 100644 packages/auto_apply/src/auto_apply/domain/models/search_query.py create mode 100644 packages/auto_apply/src/auto_apply/domain/models/search_surface.py create mode 100644 packages/auto_apply/src/auto_apply/domain/services/query_composer.py create mode 100644 packages/auto_apply/src/auto_apply/resources/search_surfaces/bing.surface.yaml create mode 100644 packages/auto_apply/src/auto_apply/resources/search_surfaces/duckduckgo.surface.yaml create mode 100644 packages/auto_apply/src/auto_apply/resources/search_surfaces/google.surface.yaml create mode 100644 packages/auto_apply/src/auto_apply/resources/search_surfaces/indeed.surface.yaml create mode 100644 packages/auto_apply/src/auto_apply/resources/search_surfaces/linkedin.surface.yaml create mode 100644 packages/auto_apply/tests/adapters/test_engine_url_byte_identity.py create mode 100644 packages/auto_apply/tests/application/test_query_builder_service.py create mode 100644 packages/auto_apply/tests/domain/test_query_composer.py create mode 100644 packages/auto_apply/tests/infrastructure/test_search_surface_registry.py diff --git a/packages/auto_apply/docs/adr/004_ats_platform_registry.md b/packages/auto_apply/docs/adr/004_ats_platform_registry.md index 7ec0123..c221f0d 100644 --- a/packages/auto_apply/docs/adr/004_ats_platform_registry.md +++ b/packages/auto_apply/docs/adr/004_ats_platform_registry.md @@ -53,6 +53,7 @@ class ATSDescriptor: form_root_selector: str # CSS selector for the form root submit_button_selector: str # CSS selector for the submit / next button multi_step: bool # True if the ATS uses a wizard pattern + hosts: tuple[str, ...] = () # the platform's own domains (e.g. "myworkdayjobs.com"); empty = none declared ``` ### YAML File Format @@ -82,7 +83,7 @@ The glob‑style patterns (`*.greenhouse.io/jobs/*`) are converted to case‑ins ### How Providers Use the Registry -`GoogleProvider.find_company_career_page()` no longer uses a hardcoded site‑filter list. Instead, it calls `_ats_site_filters(registry)`, which extracts root domains (e.g. `"greenhouse.io"`) from every loaded descriptor’s URL patterns. Adding a new ATS descriptor automatically includes its domain in company career‑page searches — zero code changes required. +`GoogleProvider.find_company_career_page()` calls `_ats_site_filters(registry)`, which reads each descriptor's declared `hosts` — the platform's own domains, written deliberately in the YAML — and derives a root domain from URL patterns only when a descriptor declares none. There is no fabricated fallback list: the earlier code named `workday.com` while the workday descriptor's hosts say `myworkdayjobs.com`. Adding a new ATS descriptor automatically includes its declared domains in company career-page searches — zero code changes required. --- 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 5ffc15c..286aab9 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 @@ -222,8 +222,8 @@ def find_company_career_page(self, company_name: str) -> str | None: The URL of the career page, or None if not found. """ site_filters = _ats_site_filters(self._ats_registry) - site_query = " OR ".join(f"site:{d}" for d in site_filters) - query = f"{company_name} careers ({site_query} OR site:{company_name}.com)" + parts = [f"site:{d}" for d in site_filters] + [f"site:{company_name}.com"] + query = f"{company_name} careers ({' OR '.join(parts)})" encoded_query = urllib.parse.quote_plus(query) search_url = ( f"https://www.google.com/search?q={encoded_query}&hl=en&gl=us" @@ -263,21 +263,29 @@ def find_company_career_page(self, company_name: str) -> str | None: # ───────────────────────────────────────────────────────────────────────────── def _ats_site_filters(registry) -> list[str]: - """Returns root domains for all loaded ATS descriptors. + """Returns the site: search domains for all loaded ATS descriptors. - Extracts the last two dot‑separated parts of each descriptor's first URL - pattern hostname (e.g. ``*.greenhouse.io/jobs/*`` → ``greenhouse.io``). - Falls back to a hardcoded list when no registry is provided or when - domain extraction yields no results. - """ - _FALLBACK = ["greenhouse.io", "lever.co", "workday.com", "ashbyhq.com"] + Reads each descriptor's declared ``hosts`` field — the platform's own + domains, written deliberately in the YAML. When a descriptor declares no + hosts, the root domain is derived from its URL patterns as a fallback. + There is deliberately NO fabricated fallback list: the old code named + ``workday.com`` while the workday descriptor's hosts say + ``myworkdayjobs.com`` — an invented answer where a real one exists. No + registry means no filters (an empty list), never an invented one. + """ if registry is None: - return _FALLBACK + return [] domains: list[str] = [] seen: set[str] = set() for descriptor in registry.all_descriptors(): + for host in descriptor.hosts: + if host not in seen: + seen.add(host) + domains.append(host) + if descriptor.hosts: + continue for pattern in descriptor.url_patterns: host = pattern.split("/")[0].lstrip("*").lstrip(".") parts = host.split(".") @@ -286,6 +294,6 @@ def _ats_site_filters(registry) -> list[str]: if root not in seen: seen.add(root) domains.append(root) - break # one root domain per descriptor is enough + break # one derived root per descriptor is enough - return domains or _FALLBACK + return domains diff --git a/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/search_surface_registry.py b/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/search_surface_registry.py new file mode 100644 index 0000000..012fdd4 --- /dev/null +++ b/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/search_surface_registry.py @@ -0,0 +1,170 @@ +"""Loading and validation for the search-surface catalog. + +The catalog (resources/search_surfaces/*.surface.yaml) is the single source +of truth for what every search surface accepts — typed operators, address-bar +parameters, URL construction — shared by the automated engine strategies and +the user-facing query builder. + +File reading and user-override merging are REUSED from SelectorLoader (the +engines' loader), including its prepend semantics for user lists. The failure +rules are deliberately unlike the pay-transparency loader's silent hidden +fallback (a known defect): a missing, unreadable or invalid BUNDLED +definition raises SearchSurfaceError; a malformed or invalid USER override +logs a warning and falls back to the bundled definition. + +Two read paths, on purpose: + + * ``load_surface`` (merged) — the builder's user-facing path; user + overrides apply, so a user can extend the surfaces they search by hand. + * ``load_surface_bundled`` (bundled only) — the automation path. AA's + automated discovery must send the same URL bytes no matter what a user + has overridden for their own manual searches, so engine_strategies reads + only the bundled catalog until chain 2's provenance records a catalog + digest on every job. +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +from pydantic import ValidationError + +from auto_apply.adapters.secondary.discovery.strategies.selector_loader import ( + SelectorLoader, +) +from auto_apply.domain.config import USER_DATA_DIR +from auto_apply.domain.models.search_surface import SearchSurfaceSpec + +logger = logging.getLogger(__name__) + +SURFACE_SUFFIX = ".surface.yaml" + +_BUNDLED_DIR: Path = ( + Path(__file__).resolve().parent # discovery/ + .parent # secondary/ + .parent # adapters/ + .parent # auto_apply/ + / "resources" + / "search_surfaces" +) + +_USER_OVERRIDE_DIR: Path = USER_DATA_DIR / "search_surfaces" + + +class SearchSurfaceError(RuntimeError): + """The search-surface catalog could not be loaded or validated.""" + + +_default_loader: SelectorLoader | None = None +_spec_cache: dict[str, SearchSurfaceSpec] = {} +_bundled_cache: dict[str, SearchSurfaceSpec] = {} + + +def _loader() -> SelectorLoader: + global _default_loader + if _default_loader is None: + _default_loader = SelectorLoader( + bundled_dir=_BUNDLED_DIR, + user_override_dir=_USER_OVERRIDE_DIR, + ) + return _default_loader + + +def _bundled_spec(name: str, loader: SelectorLoader) -> SearchSurfaceSpec: + bundled = loader.load_bundled(name, suffix=SURFACE_SUFFIX) + if not bundled: + raise SearchSurfaceError( + f"no bundled search-surface definition for {name!r} " + f"(expected {_BUNDLED_DIR / (name + SURFACE_SUFFIX)})" + ) + try: + return SearchSurfaceSpec.model_validate(bundled) + except ValidationError as exc: + raise SearchSurfaceError( + f"the bundled search-surface definition for {name!r} is invalid: " + f"{exc}" + ) from exc + + +def load_surface( + name: str, *, loader: SelectorLoader | None = None +) -> SearchSurfaceSpec: + """Load and validate one surface's catalog entry (user overrides applied). + + This is the builder's user-facing path. For AA's automated discovery use + load_surface_bundled() — automation bytes must never depend on a user + override. + + Raises: + SearchSurfaceError: no bundled definition exists, or the bundled + definition is unreadable or invalid. User overrides never raise; + a bad override is ignored with a warning. + """ + loader = loader or _loader() + cached = loader is _default_loader and name in _spec_cache + if cached: + return _spec_cache[name] + + bundled_spec = _bundled_spec(name, loader) + + merged = loader.load(name, suffix=SURFACE_SUFFIX) + if merged == bundled_spec.model_dump(): + spec = bundled_spec + else: + try: + spec = SearchSurfaceSpec.model_validate(merged) + except ValidationError as exc: + logger.warning( + "Search-surface override for %r is invalid and was ignored: %s", + name, + exc, + ) + spec = bundled_spec + + if loader is _default_loader: + _spec_cache[name] = spec + return spec + + +def load_surface_bundled( + name: str, *, loader: SelectorLoader | None = None +) -> SearchSurfaceSpec: + """Load ONLY the bundled catalog entry — the automation path. + + AA's automated discovery must send the same URL bytes regardless of any + user override in /search_surfaces, so this path never reads the + user override directory. The byte-identity pin + (tests/adapters/test_engine_url_byte_identity.py) covers what automation + sends; tests/infrastructure/test_search_surface_registry.py pins that + overrides cannot reach it. + + Raises: + SearchSurfaceError: no bundled definition exists, or it is invalid. + """ + loader = loader or _loader() + cached = loader is _default_loader and name in _bundled_cache + if cached: + return _bundled_cache[name] + + spec = _bundled_spec(name, loader) + + if loader is _default_loader: + _bundled_cache[name] = spec + return spec + + +def list_surface_names(loader: SelectorLoader | None = None) -> list[str]: + """Every surface name with a definition, bundled first, then user-only.""" + loader = loader or _loader() + names: list[str] = [] + seen: set[str] = set() + for directory in (loader.bundled_dir, loader.user_override_dir): + if not directory.is_dir(): + continue + for path in sorted(directory.glob(f"*{SURFACE_SUFFIX}")): + name = path.name[: -len(SURFACE_SUFFIX)] + if name not in seen: + seen.add(name) + names.append(name) + return names 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 43c37ee..20e3d56 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 @@ -31,16 +31,33 @@ import logging import time from abc import ABC, abstractmethod -from urllib.parse import urlencode from auto_apply.domain.models.search_instruction import SearchInstruction -from auto_apply.domain.ports.interaction_primitives_port import PageActionPrimitives +from auto_apply.domain.models.search_surface import SearchSurfaceSpec from auto_apply.domain.ports.browser_port import BrowserInterface +from auto_apply.domain.ports.interaction_primitives_port import PageActionPrimitives +from auto_apply.domain.services.query_composer import build_automation_url from auto_apply.domain.types import Locator logger = logging.getLogger(__name__) +def _surface_spec(name: str) -> SearchSurfaceSpec: + """The bundled-only catalog spec for one automated engine. + + Automation reads the bundled catalog ONLY: a user override under + /search_surfaces must never change the URLs AA's automation sends + (the builder's user-facing path uses load_surface(), which applies + overrides). The import is lazy so the catalog is only touched when a + search URL is actually built, and so module import stays light. + """ + from auto_apply.adapters.secondary.discovery.search_surface_registry import ( # noqa: PLC0415 + load_surface_bundled, + ) + + return load_surface_bundled(name) + + # ═════════════════════════════════════════════════════════════════════════════ # Abstract base # ═════════════════════════════════════════════════════════════════════════════ @@ -300,16 +317,6 @@ def _apply_toolbar_filters_legacy( # Google # ═════════════════════════════════════════════════════════════════════════════ -# Google's ``tbs=qdr:`` parameter mapping for time‑windowed searches. -_GOOGLE_DATE_RANGE_MAP: dict[str, str] = { - "hour": "h", - "day": "d", - "week": "w", - "month": "m", - "year": "y", -} - - class GoogleSearchStrategy(SearchEngineStrategy): """Google‑specific search URL construction and toolbar interactions.""" @@ -329,19 +336,15 @@ def engine_name(self) -> str: return "google" def build_search_url(self, instruction: SearchInstruction) -> str: - query = instruction.effective_query - params: dict[str, str | int] = { - "q": query, - "ibp": "htl;jobs", - "hl": "en", - "gl": "us", - "start": 0, - } - if instruction.date_range is not None: - qdr_code = _GOOGLE_DATE_RANGE_MAP.get(instruction.date_range) - if qdr_code is not None: - params["tbs"] = f"qdr:{qdr_code}" - return f"https://www.google.com/search?{urlencode(params)}" + # The catalog (resources/search_surfaces/google.surface.yaml) is the + # single source of truth for this URL, bundled-only: automation bytes + # cannot be changed by a user override. + return build_automation_url( + _surface_spec(self.engine_name), + query=instruction.effective_query, + location=instruction.location, + date_range=instruction.date_range, + ) # ── Legacy hardcoded selectors (used when no locator is injected) ──── @@ -414,15 +417,6 @@ def _apply_toolbar_filters_legacy( # Bing # ═════════════════════════════════════════════════════════════════════════════ -_BING_DATE_RANGE_MAP: dict[str, str] = { - "hour": "h", - "day": "d", - "week": "w", - "month": "m", - "year": "y", -} - - class BingSearchStrategy(SearchEngineStrategy): """Bing‑specific search URL construction and toolbar interactions.""" @@ -442,14 +436,13 @@ def engine_name(self) -> str: return "bing" def build_search_url(self, instruction: SearchInstruction) -> str: - query = instruction.effective_query - params = {"q": query} - url = f"https://www.bing.com/jobs?{urlencode(params)}" - if instruction.date_range is not None: - interval_code = _BING_DATE_RANGE_MAP.get(instruction.date_range) - if interval_code is not None: - url += f"&filters=ex1%3A%22{interval_code}%22" - return url + # Bundled-only catalog — see GoogleSearchStrategy.build_search_url. + return build_automation_url( + _surface_spec(self.engine_name), + query=instruction.effective_query, + location=instruction.location, + date_range=instruction.date_range, + ) # ── Legacy hardcoded selectors ────────────────────────────────────── @@ -508,15 +501,6 @@ def _apply_toolbar_filters_legacy( # Indeed # ═════════════════════════════════════════════════════════════════════════════ -_INDEED_DATE_RANGE_DAYS: dict[str, int] = { - "hour": 1, - "day": 1, - "week": 7, - "month": 30, - "year": 365, -} - - class IndeedSearchStrategy(SearchEngineStrategy): """Indeed‑specific search URL construction and toolbar interactions.""" @@ -536,20 +520,13 @@ def engine_name(self) -> str: return "indeed" def build_search_url(self, instruction: SearchInstruction) -> str: - params: dict[str, str] = { - "q": instruction.effective_query, - "l": instruction.location, - } - if instruction.date_range is not None: - days = _INDEED_DATE_RANGE_DAYS.get(instruction.date_range) - if days is not None: - params["fromage"] = str(days) - logger.debug( - "IndeedSearchStrategy: date_range=%s → fromage=%d", - instruction.date_range, - days, - ) - return f"https://www.indeed.com/jobs?{urlencode(params)}" + # Bundled-only catalog — see GoogleSearchStrategy.build_search_url. + return build_automation_url( + _surface_spec(self.engine_name), + query=instruction.effective_query, + location=instruction.location, + date_range=instruction.date_range, + ) # ── Legacy hardcoded selectors ────────────────────────────────────── diff --git a/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/strategies/selector_loader.py b/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/strategies/selector_loader.py index 15b82df..776e533 100644 --- a/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/strategies/selector_loader.py +++ b/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/strategies/selector_loader.py @@ -88,11 +88,21 @@ def __init__( self._user_override_dir = user_override_dir or _USER_OVERRIDE_DIR self._cache: dict[str, dict[str, Any]] = {} + @property + def bundled_dir(self) -> Path: + """The bundled YAML directory (read-only).""" + return self._bundled_dir + + @property + def user_override_dir(self) -> Path: + """The user override YAML directory (read-only).""" + return self._user_override_dir + # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ - def load(self, engine_name: str) -> dict[str, Any]: + def load(self, engine_name: str, suffix: str = ".yaml") -> dict[str, Any]: """Return the merged selector configuration for *engine_name*. On first call the YAML files are read, merged, and cached. Subsequent @@ -100,16 +110,19 @@ def load(self, engine_name: str) -> dict[str, Any]: Args: engine_name: Lowercase engine identifier (e.g. ``"google"``). + suffix: File-name suffix, ``.yaml`` for engines and + ``.surface.yaml`` for the search-surface catalog. Returns: A dict with the merged configuration. Returns an empty dict when the bundled YAML is missing or unparseable. """ - if engine_name in self._cache: - return self._cache[engine_name] + key = f"{engine_name}{suffix}" + if key in self._cache: + return self._cache[key] - config = self._load_and_merge(engine_name) - self._cache[engine_name] = config + config = self._load_and_merge(engine_name, suffix) + self._cache[key] = config return config def reload(self, engine_name: str) -> dict[str, Any]: @@ -124,20 +137,32 @@ def reload(self, engine_name: str) -> dict[str, Any]: Returns: Freshly merged configuration dict. """ - self._cache.pop(engine_name, None) + self._cache.pop(f"{engine_name}.yaml", None) return self.load(engine_name) def clear_cache(self) -> None: """Drop all cached configurations.""" self._cache.clear() + def load_bundled(self, name: str, suffix: str = ".yaml") -> dict[str, Any]: + """Return the bundled configuration ONLY — user overrides never apply. + + Automation (engine_strategies' URL construction) reads this so that a + user editing the catalog for their own manual searches can never + change the queries AA's automation sends. User-facing features use + load(). A missing or unreadable file returns an empty dict; whether + that is an error is the caller's decision (the search-surface + registry raises on it; the engines' toolbar path tolerates it). + """ + return self._read_yaml(self._bundled_dir / f"{name}{suffix}") or {} + # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ - def _load_and_merge(self, engine_name: str) -> dict[str, Any]: + def _load_and_merge(self, engine_name: str, suffix: str = ".yaml") -> dict[str, Any]: """Load bundled and user YAML, then merge.""" - bundled = self._read_yaml(self._bundled_dir / f"{engine_name}.yaml") + bundled = self._read_yaml(self._bundled_dir / f"{engine_name}{suffix}") if bundled is None: logger.warning( "SelectorLoader: bundled config for %r not found — " @@ -146,7 +171,7 @@ def _load_and_merge(self, engine_name: str) -> dict[str, Any]: ) return {} - user = self._read_yaml(self._user_override_dir / f"{engine_name}.yaml") + user = self._read_yaml(self._user_override_dir / f"{engine_name}{suffix}") if user is not None: logger.info( "SelectorLoader: merging user overrides for engine %r", 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 new file mode 100644 index 0000000..0a628dc --- /dev/null +++ b/packages/auto_apply/src/auto_apply/application/services/query_builder.py @@ -0,0 +1,226 @@ +"""The query builder service — the application seam the surfaces call (turn 3). + +What this service owns: + + * the offered surfaces, with the verification rule applied (an unverified + or not-yet-composable capability is never offered); + * defaults read from the user's profile (saved job titles, locations, + workplace types) — strictly read-only; building a query never writes to + the profile; + * the last few queries the user built (default 5), stored at + /query_builder_recent.json — deduplicated, capped, clearable, + and known to the uninstaller. + +What it deliberately does not do: + + * write anything on build. ``build()`` composes and returns; it never + touches the disk. A surface may rebuild the preview on every keystroke, + so recording there would store half-typed queries and write to the device + without a deliberate user action. Recording is explicit: surfaces call + ``remember()`` only when the user copies or opens a result. + * open or drive a browser, or touch the network — nothing this module or + the composer imports can do either; + * write anywhere else. In particular nothing here ever reaches the research + store, and the recent-queries file is removed by --uninstall (it is in + the uninstaller's known data-home entries); + * decide anything about research. If a future, separately-consented + research feature ever studies how people build searches, it will need its + own versioned consent through the existing research-consent mechanism — + this file contains no hook, port or placeholder for it. + +On the recent-queries file's protection level, said plainly: it is plaintext, +the same protection class as the logs, checkpoints and session reports that +already live in the data home. It is not vault-encrypted, because the vault +is opt-in and per-profile — conditional encryption would protect some users +and silently not others. +""" + +from __future__ import annotations + +import json +import logging +import os +from collections.abc import Callable +from pathlib import Path + +from auto_apply.domain.config import USER_DATA_DIR +from auto_apply.domain.models.search_query import ( + BuilderDefaults, + OfferedCapability, + OfferedChoice, + QueryRequest, + QueryResult, + SurfaceOffer, +) +from auto_apply.domain.models.search_surface import ( + OFFERED_STATUSES, + SearchSurfaceSpec, +) +from auto_apply.domain.services.query_composer import compose + +logger = logging.getLogger(__name__) + +DEFAULT_RECENT_PATH = USER_DATA_DIR / "query_builder_recent.json" +DEFAULT_MAX_RECENT = 5 + + +class QueryBuilderError(RuntimeError): + """The builder could not complete the request.""" + + +class QueryBuilderService: + """Profile defaults, offered surfaces, and remembered queries.""" + + def __init__( + self, + *, + profile=None, + load_spec: Callable[[str], SearchSurfaceSpec] | None = None, + list_names: Callable[[], list[str]] | None = None, + recent_path: Path = DEFAULT_RECENT_PATH, + max_recent: int = DEFAULT_MAX_RECENT, + ) -> None: + self._profile = profile + self._load_spec = load_spec + self._list_names = list_names + self._recent_path = recent_path + self._max_recent = max(1, int(max_recent)) + + # ------------------------------------------------------------------ + # Offered surfaces — the verification rule applied + # ------------------------------------------------------------------ + + def surfaces(self) -> tuple[SurfaceOffer, ...]: + """Every catalogued surface, with unverified capabilities removed. + + A catalog defect propagates (SearchSurfaceError) — a wrong offer is + worse than a loud failure. + """ + if self._list_names is None or self._load_spec is None: + return () + return tuple(self._offer(self._load_spec(name)) for name in self._list_names()) + + @staticmethod + def _offer(spec: SearchSurfaceSpec) -> SurfaceOffer: + operators = tuple( + OfferedCapability( + key=op.name, + label=op.label, + explanation=op.explanation, + channel=op.channel, + status=op.status.value, + ) + for op in spec.operators + if op.composable and op.status in OFFERED_STATUSES + ) + options = tuple( + OfferedCapability( + key=opt.option, + label=opt.label, + explanation=opt.explanation, + channel=opt.channel, + status=opt.status.value, + choices=tuple( + OfferedChoice(key=k, label=v) + for k, v in (opt.option_labels or {k: k for k in opt.codes}).items() + ), + ) + for opt in spec.url_options + if opt.composable and opt.status in OFFERED_STATUSES + ) + return SurfaceOffer( + name=spec.name, + display_name=spec.display_name, + kind=spec.kind, + summary=spec.summary, + usage_note=spec.usage_note, + operators=operators, + options=options, + templates=tuple(spec.urls), + ) + + # ------------------------------------------------------------------ + # Profile defaults — strictly read-only + # ------------------------------------------------------------------ + + def defaults(self) -> BuilderDefaults: + """The profile's saved search values, offered as one-click choices.""" + prefs = getattr(self._profile, "search_preferences", None) + return BuilderDefaults( + titles=tuple(getattr(prefs, "desired_job_titles", ()) or ()), + locations=tuple(getattr(prefs, "preferred_locations", ()) or ()), + workplace_types=tuple(getattr(prefs, "workplace_types", ()) or ()), + ) + + # ------------------------------------------------------------------ + # Build (writes nothing) and remember (explicit) + # ------------------------------------------------------------------ + + def build(self, request: QueryRequest) -> QueryResult: + """Compose the bar text and URL. Writes NOTHING. + + Turn 3's surfaces may rebuild the preview as the user types, so + building must not touch the disk. Recording is the surfaces' explicit + choice via remember(), called only when the user copies or opens a + result. + """ + if self._load_spec is None: + raise QueryBuilderError( + "no search-surface catalog is wired into the query builder" + ) + spec = self._load_spec(request.surface) + return compose(request, spec) + + def remember(self, request: QueryRequest) -> None: + """Record a query as one of the recent few. + + Called by the surfaces only when the user copies or opens a result — + never on preview builds. + """ + self._record_recent(request) + + # ------------------------------------------------------------------ + # Recent queries + # ------------------------------------------------------------------ + + def recent(self) -> tuple[QueryRequest, ...]: + """The remembered queries, newest first.""" + out: list[QueryRequest] = [] + for item in self._read_recent(): + try: + out.append(QueryRequest.model_validate(item)) + except Exception: # noqa: BLE001 — a stale or partial entry is skipped + continue + return tuple(out) + + def clear_recent(self) -> None: + """Forget every remembered query. The file is removed, not emptied.""" + try: + self._recent_path.unlink(missing_ok=True) + except OSError as exc: + logger.warning("could not clear recent queries: %s", exc) + + def _record_recent(self, request: QueryRequest) -> None: + entry = json.loads(request.model_dump_json()) + entries = [entry] + for item in self._read_recent(): + if item != entry: + entries.append(item) + entries = entries[: self._max_recent] + payload = ( + json.dumps(entries, ensure_ascii=False, sort_keys=True) + "\n" + ).encode("utf-8") + try: + self._recent_path.parent.mkdir(parents=True, exist_ok=True) + tmp = self._recent_path.with_suffix(".tmp") + tmp.write_bytes(payload) + os.replace(tmp, self._recent_path) + except OSError as exc: + logger.warning("could not record a recent query: %s", exc) + + def _read_recent(self) -> list[dict]: + try: + data = json.loads(self._recent_path.read_bytes().decode("utf-8")) + except (OSError, ValueError): + return [] + return data if isinstance(data, list) else [] 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 e5575c2..6c96f62 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 @@ -174,6 +174,7 @@ from auto_apply.domain.ports.work_queue_port import WorkQueuePort if TYPE_CHECKING: + from auto_apply.application.services.query_builder import QueryBuilderService from auto_apply.application.services.research_consent import ResearchConsentManager logger = logging.getLogger(__name__) @@ -431,6 +432,10 @@ class SessionController: session's research observer registered with, so a mid-session withdrawal stops collection immediately. shutdown() stops the observer (flushing its queue) whether or not a run is active. + 11. Query builder (turn 1 of the query-builder chain): the injected + QueryBuilderService is exposed as the query_builder property for + the surfaces (turn 3). It composes user-authored searches from + the search-surface catalog; nothing about it is automated. The controller is stateful — one instance per session. When the session ends, call shutdown() — it releases the browser whether or not a run is @@ -450,6 +455,7 @@ def __init__( orchestrator: AgentOrchestrator, profile_repo: ProfileRepositoryPort | None = None, research_consent: "ResearchConsentManager | None" = None, + query_builder: "QueryBuilderService | None" = None, ) -> None: """Stores pre-built dependencies and starts the event-fed projections. @@ -468,12 +474,16 @@ def __init__( registered with it, so withdrawal and shutdown stop collection. None only for tests and legacy construction — shutdown then has no observer to stop. + query_builder: The query-builder service, built by + composition_root. None in tests and legacy constructions — + surfaces then degrade to no builder. """ self.registry = registry self.db = db self.orchestrator = orchestrator self._profile_repo = profile_repo self._research_consent = research_consent + self._query_builder = query_builder self._agent_thread: threading.Thread | None = None # HITL: maps context_id → (gate_event, chosen_value_holder, payload). self._pending_approvals: dict[ @@ -1677,6 +1687,16 @@ def research_consent(self) -> "ResearchConsentManager | None": """ return self._research_consent + @property + def query_builder(self) -> "QueryBuilderService | None": + """The query-builder service, if one was injected. + + Built by the composition root from the search-surface catalog; the + builder surfaces (turn 3) read it through here. None in tests and + legacy constructions — those surfaces degrade to no builder. + """ + return self._query_builder + def get_queue_stats(self) -> dict[str, int]: """Returns work queue status counts from the database. diff --git a/packages/auto_apply/src/auto_apply/application/services/uninstall/engine.py b/packages/auto_apply/src/auto_apply/application/services/uninstall/engine.py index aedc2ad..e3912ce 100644 --- a/packages/auto_apply/src/auto_apply/application/services/uninstall/engine.py +++ b/packages/auto_apply/src/auto_apply/application/services/uninstall/engine.py @@ -80,6 +80,7 @@ class UninstallRefused(RuntimeError): "tmp", "annotations", "detector_samples", + "query_builder_recent.json", "page_feedback.db", "harvest_baselines.db", "selector_confidence.json", 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 new file mode 100644 index 0000000..cfc3324 --- /dev/null +++ b/packages/auto_apply/src/auto_apply/domain/models/search_query.py @@ -0,0 +1,89 @@ +"""The query builder's request/result/offer vocabulary — frozen data, no I/O. + +QueryRequest is the typed intermediate form every consumer shares: the +builder's surfaces produce one, the composer consumes one, and the second +chain's automated planner will manipulate the same shape. Nothing here holds +a live object (no profile, no driver, no store). +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict + + +class _Frozen(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + +class QueryRequest(_Frozen): + """What the user wants to search for, on one named surface.""" + + surface: str + phrases: tuple[str, ...] = () + any_of: tuple[str, ...] = () + required: tuple[str, ...] = () + exclude: tuple[str, ...] = () + in_title: tuple[str, ...] = () + site: str | None = None + location: str = "" + options: tuple[tuple[str, str], ...] = () + template: str | None = None + + +class QueryNote(_Frozen): + """One plain-language sentence about what was dropped or refused, and why.""" + + severity: Literal["info", "refused"] + topic: str + message: str + + +class QueryResult(_Frozen): + """The bar text and/or URL, plus every note the user must see.""" + + surface: str + display_name: str + bar_text: str | None + url: str | None + notes: tuple[QueryNote, ...] = () + + +class OfferedChoice(_Frozen): + """One labelled choice for an option (e.g. day -> "Past 24 hours").""" + + key: str + label: str + + +class OfferedCapability(_Frozen): + """One capability a surface offers, after the verification rule is applied.""" + + key: str + label: str + explanation: str + channel: str + status: str + choices: tuple[OfferedChoice, ...] = () + + +class SurfaceOffer(_Frozen): + """A search surface as the builder may offer it.""" + + name: str + display_name: str + kind: str + summary: str + usage_note: str + operators: tuple[OfferedCapability, ...] = () + options: tuple[OfferedCapability, ...] = () + templates: tuple[str, ...] = () + + +class BuilderDefaults(_Frozen): + """One-click starting points read from the user's profile. Read-only.""" + + titles: tuple[str, ...] = () + locations: tuple[str, ...] = () + workplace_types: tuple[str, ...] = () diff --git a/packages/auto_apply/src/auto_apply/domain/models/search_surface.py b/packages/auto_apply/src/auto_apply/domain/models/search_surface.py new file mode 100644 index 0000000..fbe8043 --- /dev/null +++ b/packages/auto_apply/src/auto_apply/domain/models/search_surface.py @@ -0,0 +1,194 @@ +"""What a search surface accepts — the catalog's typed vocabulary. + +This model is the single source of truth for "what can this search bar or +search URL do", shared by AA's automated engine strategies +(engine_strategies.py) and the user-facing query builder (turns 1-3 of the +query-builder chain). It used to live as three hardcoded maps in +engine_strategies.py; the byte-identity pin in +tests/adapters/test_engine_url_byte_identity.py proves the move changed no +automation output. + +Verification is data, not prose. Every operator and URL option records a +status (vendor / measured / third_party / unverified), a source, and — for +"measured" — a date. An unverified capability is recorded so its absence from +the offered set is deliberate; it is never offered and never composed. A +second axis, ``composable``, separates "is it verified" from "can AA's +composer write it today", so a vendor-documented operator the builder cannot +emit yet is recorded honestly instead of being offered. + +Unknown fields are a load error (extra="forbid"): a typo in bundled data +fails loudly at load time, never silently passes. There is no hidden in-code +fallback table anywhere in this design (the pay-transparency loader's silent +fallback is a known defect, deliberately not copied). +""" + +from __future__ import annotations + +from enum import Enum +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class VerificationStatus(str, Enum): + """How AA knows a capability works. See the module docstring.""" + + VENDOR = "vendor" + MEASURED = "measured" + THIRD_PARTY = "third_party" + UNVERIFIED = "unverified" + + +#: Statuses the builder may offer by default. Unverified is never offered. +OFFERED_STATUSES = frozenset( + { + VerificationStatus.VENDOR, + VerificationStatus.MEASURED, + VerificationStatus.THIRD_PARTY, + } +) + + +class _Frozen(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + +class CitedSpec(_Frozen): + """The evidence fields every catalog entry carries.""" + + status: VerificationStatus + source: str = "" + verified_on: str | None = None + caveats: tuple[str, ...] = () + composable: bool = True + + @model_validator(mode="after") + def _measured_needs_a_date(self) -> "CitedSpec": + if self.status is VerificationStatus.MEASURED and not self.verified_on: + raise ValueError( + "status 'measured' requires verified_on — a measurement without " + "a date is a claim, not a measurement" + ) + return self + + +class OperatorSpec(CitedSpec): + """One typed-search operator (e.g. site:, phrase quoting, intitle:).""" + + name: str + label: str + explanation: str + syntax: str + channel: Literal["typed", "url", "both"] = "typed" + accepts: str = "text" + + +class UrlOptionSpec(CitedSpec): + """One option a surface accepts in its address (e.g. Google's tbs).""" + + name: str + option: str + label: str + explanation: str + channel: Literal["url", "both"] = "url" + value_format: str = "{code}" + codes: dict[str, str] = Field(default_factory=dict) + option_labels: dict[str, str] = Field(default_factory=dict) + typed_equivalent: str | None = None + typed_equivalent_note: str = "" + merge: Literal["comma", "append"] = "comma" + free_form: bool = False + + @model_validator(mode="after") + def _codes_and_free_form_agree(self) -> "UrlOptionSpec": + if self.free_form and self.codes: + raise ValueError("a free-form option takes the value verbatim; codes must be empty") + if not self.free_form and not self.codes: + raise ValueError("a coded option needs at least one code") + return self + + +class TemplateParameterSpec(_Frozen): + """One ordered URL parameter: a static value, or the query/location.""" + + name: str + value: str | None = None + source: Literal["query", "location"] | None = None + + +class UrlTemplateSpec(_Frozen): + """How a search URL is built for one view of a surface.""" + + base: str + parameters: tuple[TemplateParameterSpec, ...] = () + date_parameter: str | None = None + + +class TextQuerySpec(_Frozen): + """How typed search text is composed for a surface.""" + + or_token: str | None = None + or_uppercase: bool = True + phrase_quote: str = '"' + subject_group: str = "({content})" + exclude_prefix: str = "-" + max_words: int | None = None + max_words_status: VerificationStatus | None = None + max_words_source: str = "" + + @model_validator(mode="after") + def _limit_needs_a_status(self) -> "TextQuerySpec": + if self.max_words is not None and self.max_words_status is None: + raise ValueError( + "max_words needs max_words_status — a limit without a source " + "class is a guess" + ) + return self + + +class SearchSurfaceSpec(_Frozen): + """Everything AA knows about one search surface.""" + + name: str + display_name: str + kind: Literal["engine", "job_board"] + homepage: str + summary: str = "" + usage_note: str = "" + text_query: TextQuerySpec = Field(default_factory=TextQuerySpec) + urls: dict[str, UrlTemplateSpec] = Field(default_factory=dict) + default_url: str | None = None + automation_url: str | None = None + operators: tuple[OperatorSpec, ...] = () + url_options: tuple[UrlOptionSpec, ...] = () + + @model_validator(mode="after") + def _references_resolve(self) -> "SearchSurfaceSpec": + if self.default_url is not None and self.default_url not in self.urls: + raise ValueError(f"{self.name}: default_url {self.default_url!r} is not a url template") + if self.automation_url is not None and self.automation_url not in self.urls: + raise ValueError(f"{self.name}: automation_url {self.automation_url!r} is not a url template") + for key, template in self.urls.items(): + if template.date_parameter is not None and not any( + o.name == template.date_parameter and o.option == "date" + for o in self.url_options + ): + raise ValueError( + f"{self.name}: url template {key!r} names date_parameter " + f"{template.date_parameter!r}, which no date url_option defines" + ) + return self + + @model_validator(mode="after") + def _any_of_needs_a_token(self) -> "SearchSurfaceSpec": + for op in self.operators: + if ( + op.name == "any_of" + and op.composable + and op.status is not VerificationStatus.UNVERIFIED + and not self.text_query.or_token + ): + raise ValueError( + f"{self.name}: an offered any_of operator needs text_query.or_token" + ) + return self 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 new file mode 100644 index 0000000..c662c3e --- /dev/null +++ b/packages/auto_apply/src/auto_apply/domain/services/query_composer.py @@ -0,0 +1,421 @@ +"""Composes user-authored search queries from the search-surface catalog. + +Pure: no I/O, no clock, no randomness, no browser, no network. The same +composer core serves the user-facing query builder (``compose``) and AA's +automated discovery (``build_automation_url``) — one capability model, one +composition path, so the two can never drift. + +The composer never approximates. Anything a surface cannot express becomes a +plain-language note in the result, and content that cannot be expressed is +refused, never silently rewritten. A length limit is enforced by refusing to +write anything rather than by truncation. +""" + +from __future__ import annotations + +import re +from urllib.parse import urlencode + +from auto_apply.domain.models.search_query import ( + QueryNote, + QueryRequest, + QueryResult, +) +from auto_apply.domain.models.search_surface import ( + OFFERED_STATUSES, + OperatorSpec, + SearchSurfaceSpec, + UrlOptionSpec, + UrlTemplateSpec, +) + +#: Typed operators the composer can emit today. The registry pins every +#: surface's offered operators to a subset of this set, so "offered" can +#: never outrun "composable". +EXPRESSIBLE_OPERATORS = frozenset({"phrase", "any_of", "exclude", "site", "intitle"}) + +_CONFUSABLES = str.maketrans( + { + "\u2018": "'", + "\u2019": "'", + "\u201b": "'", + "\u201c": '"', + "\u201d": '"', + "\u201e": '"', + "\u2013": "-", + "\u2014": "-", + "\u2212": "-", + "\u00a0": " ", + "\u202f": " ", + "\uff02": '"', + "\uff07": "'", + } +) + +_SAFE_OPTION_VALUE = re.compile(r"[A-Za-z0-9._~\-]+") +_SITE_SCHEME = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*://") + + +def normalize_confusables(text: str) -> str: + """Fold typographic quotes/dashes/spaces to ASCII and collapse whitespace.""" + return " ".join((text or "").translate(_CONFUSABLES).split()) + + +def _clean_content(text: str) -> str: + # '"' is dropped from content: the composer adds its own quoting, and a + # nested quote would corrupt it. Apostrophes are kept. + return normalize_confusables(text).replace('"', "") + + +def _clean_site(text: str) -> str: + site = normalize_confusables(text) + site = _SITE_SCHEME.sub("", site) + return site.strip().rstrip("/") + + +def _info(topic: str, message: str) -> QueryNote: + return QueryNote(severity="info", topic=topic, message=message) + + +def _refused(topic: str, message: str) -> QueryNote: + return QueryNote(severity="refused", topic=topic, message=message) + + +def _offered_operators(spec: SearchSurfaceSpec) -> dict[str, OperatorSpec]: + return { + op.name: op + for op in spec.operators + if op.composable and op.status in OFFERED_STATUSES + } + + +def _offered_url_options(spec: SearchSurfaceSpec) -> dict[str, UrlOptionSpec]: + offered: dict[str, UrlOptionSpec] = {} + for opt in spec.url_options: + if opt.composable and opt.status in OFFERED_STATUSES: + offered.setdefault(opt.option, opt) + return offered + + +def _template_params( + template: UrlTemplateSpec, query: str, location: str +) -> list[tuple[str, str]]: + params: list[tuple[str, str]] = [] + for p in template.parameters: + if p.source == "query": + params.append((p.name, query)) + elif p.source == "location": + params.append((p.name, location or "")) + else: + params.append((p.name, p.value or "")) + return params + + +def _format_option_value(opt: UrlOptionSpec, code: str) -> str: + return opt.value_format.format(code=code) if "{code}" in opt.value_format else opt.value_format + + +def _compose_bar( + request: QueryRequest, + spec: SearchSurfaceSpec, + ops: dict[str, OperatorSpec], +) -> tuple[str | None, list[QueryNote], bool]: + """Returns (bar_text, notes, hard_stop). hard_stop means the URL must not + be built either (a length refusal). A content refusal stops the bar but + lets the link carry what the surface honestly can.""" + tq = spec.text_query + notes: list[QueryNote] = [] + + if request.site and "site" not in ops: + return None, [_refused( + "site", + f"{spec.display_name} has no way to limit a typed search to one " + "site, so no search text was written — writing one without the " + "site limit would not be the search you asked for.", + )], False + if request.any_of and ("any_of" not in ops or not tq.or_token): + return None, [_refused( + "any_of", + f"{spec.display_name} has no way to say 'any of these' in its " + "search bar, so no search text was written.", + )], False + if request.exclude and "exclude" not in ops: + return None, [_refused( + "exclude", + f"{spec.display_name} has no way to exclude words in its search " + "bar, so no search text was written.", + )], False + if request.in_title and "intitle" not in ops: + return None, [_refused( + "intitle", + f"{spec.display_name} has no title filter in its search bar, so " + "no search text was written.", + )], False + + quote = tq.phrase_quote + subject_bits: list[str] = [] + if request.phrases and "phrase" not in ops: + notes.append(_info( + "phrase", + f"{spec.display_name} has no exact-phrase quoting; your phrase " + "was written without quotes.", + )) + for phrase in request.phrases: + content = _clean_content(phrase) + subject_bits.append(f"{quote}{content}{quote}" if "phrase" in ops else content) + for term in request.required: + subject_bits.append(normalize_confusables(term)) + if request.any_of: + token = tq.or_token + assert token is not None # guaranteed by the refusal above + if tq.or_uppercase: + token = token.upper() + alternatives = f" {token} ".join( + f"{quote}{_clean_content(a)}{quote}" for a in request.any_of + ) + # Own parentheses only beside other terms; alone, the subject + # group's parentheses already enclose it. + subject_bits.append(f"({alternatives})" if subject_bits else alternatives) + + parts: list[str] = [] + if request.site: + parts.append(ops["site"].syntax.format(value=_clean_site(request.site))) + if subject_bits: + parts.append(tq.subject_group.format(content=" ".join(subject_bits))) + for term in request.exclude: + cleaned = _clean_content(term) + if " " in cleaned: + cleaned = f"{quote}{cleaned}{quote}" + parts.append(ops["exclude"].syntax.format(value=cleaned)) + for term in request.in_title: + cleaned = normalize_confusables(term) + if " " in cleaned: + cleaned = f"{quote}{cleaned}{quote}" + parts.append(ops["intitle"].syntax.format(value=cleaned)) + + bar = " ".join(parts) + if not bar: + return None, notes, False + if tq.max_words is not None and len(bar.split()) > tq.max_words: + source = f" ({tq.max_words_source})" if tq.max_words_source else "" + notes.append(_refused( + "length", + f"That search is {len(bar.split())} words; {spec.display_name} " + f"is reported to stop reading after {tq.max_words}{source}. " + "Nothing was written — cutting it down would not be the search " + "you asked for.", + )) + return None, notes, True + return bar, notes, False + + +def _compose_url( + request: QueryRequest, + spec: SearchSurfaceSpec, + bar: str | None, + ops: dict[str, OperatorSpec], + offered_options: dict[str, UrlOptionSpec], +) -> tuple[str | None, list[QueryNote]]: + notes: list[QueryNote] = [] + template_name = request.template or spec.default_url or next(iter(spec.urls), None) + if template_name is None: + return None, notes + template = spec.urls.get(template_name) + if template is None: + return None, [_refused( + "template", + f"{spec.display_name} has no link form called '{template_name}'. " + f"Choices: {', '.join(spec.urls)}.", + )] + + if bar is not None: + query_value = bar + else: + # No bar (refused or never writable): the link carries the best form + # the surface honestly offers, and every dropped part is named. + bits: list[str] = [] + quote = spec.text_query.phrase_quote + for phrase in request.phrases: + content = _clean_content(phrase) + bits.append(f"{quote}{content}{quote}" if "phrase" in ops else content) + bits.extend(normalize_confusables(t) for t in request.required) + dropped: list[str] = [] + if request.any_of: + dropped.append("the 'any of these' part") + if request.site: + dropped.append("the site limit") + if request.exclude: + dropped.append("the exclusions") + if request.in_title: + dropped.append("the title filter") + if dropped: + notes.append(_info( + "dropped", + f"Only some of your search has a form on {spec.display_name}; " + f"{', '.join(dropped)} " + f"{'was' if len(dropped) == 1 else 'were'} left out of the link.", + )) + query_value = " ".join(bits) + + if not query_value: + if request.options: + notes.append(_refused( + "options", + "Settings without any search terms make no link, so they were " + "not applied.", + )) + else: + notes.append(_info("empty", "Nothing to build yet — add at least one term.")) + return None, notes + + params = _template_params(template, query_value, request.location) + + for key, value in request.options: + opt = offered_options.get(key) + if opt is None: + known = next((o for o in spec.url_options if o.option == key), None) + if known is not None: + notes.append(_refused( + key, + f"{spec.display_name}'s '{key}' setting is not offered: " + f"{known.explanation}", + )) + else: + notes.append(_refused( + key, + f"{spec.display_name} has no '{key}' setting, so it was left out.", + )) + continue + if opt.free_form: + cleaned = normalize_confusables(value) + if not _SAFE_OPTION_VALUE.fullmatch(cleaned): + notes.append(_refused( + key, + f"'{value}' is not a value {spec.display_name}'s '{key}' " + "setting can carry.", + )) + continue + code = cleaned + else: + if opt.option_labels and value not in opt.option_labels: + choices = ", ".join(opt.option_labels.values()) + notes.append(_refused( + key, + f"'{value}' is not offered for {opt.label}. Choices: {choices}.", + )) + continue + looked_up = opt.codes.get(value) + if looked_up is None: + choices = ", ".join( + opt.option_labels.get(k, k) for k in opt.codes + ) + notes.append(_refused( + key, + f"'{value}' is not a choice for {opt.label}. Choices: {choices}.", + )) + continue + code = looked_up + formatted = _format_option_value(opt, code) + if opt.merge == "comma": + for index, (pname, pvalue) in enumerate(params): + if pname == opt.name: + params[index] = (pname, f"{pvalue},{formatted}") + break + else: + params.append((opt.name, formatted)) + else: + params.append((opt.name, formatted)) + if opt.typed_equivalent and opt.typed_equivalent_note: + notes.append(_info( + key, + f"{opt.label} cannot be typed into {spec.display_name}'s " + f"search bar — the link carries it. {opt.typed_equivalent_note}", + )) + elif opt.channel == "url": + notes.append(_info( + key, + f"{opt.label} cannot be typed into {spec.display_name}'s " + "search bar — the link carries it.", + )) + + return f"{template.base}?{urlencode(params)}", notes + + +def compose(request: QueryRequest, spec: SearchSurfaceSpec) -> QueryResult: + """Compose the bar text and/or URL for one surface. Never raises on + content; anything the surface cannot express becomes a note.""" + notes: list[QueryNote] = [] + ops = _offered_operators(spec) + offered_options = _offered_url_options(spec) + + bar: str | None = None + hard_stop = False + if ops: + bar, bar_notes, hard_stop = _compose_bar(request, spec, ops) + notes.extend(bar_notes) + elif any( + ( + request.phrases, + request.any_of, + request.required, + request.exclude, + request.in_title, + request.site, + ) + ): + notes.append(_info( + "bar", + f"{spec.display_name} has no typed search form AA can write for " + "you — the link below is the way to use it.", + )) + + url: str | None = None + if not hard_stop and spec.urls: + url, url_notes = _compose_url(request, spec, bar, ops, offered_options) + notes.extend(url_notes) + + if bar is None and url is None and not notes: + notes.append(_info("empty", "Nothing to build yet — add at least one term.")) + + return QueryResult( + surface=spec.name, + display_name=spec.display_name, + bar_text=bar, + url=url, + notes=tuple(notes), + ) + + +def build_automation_url( + spec: SearchSurfaceSpec, + *, + query: str, + location: str = "", + date_range: str | None = None, +) -> str: + """The URL AA's automated discovery sends. + + Byte-compatible with the pre-catalog engine strategies — pinned by + tests/adapters/test_engine_url_byte_identity.py. Automation parameters + 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. + """ + if spec.automation_url 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: + option = next( + ( + o + for o in spec.url_options + if o.name == template.date_parameter and o.option == "date" + ), + None, + ) + if option is not None: + 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)}" 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 4c456d7..4e6986b 100644 --- a/packages/auto_apply/src/auto_apply/infrastructure/composition_root.py +++ b/packages/auto_apply/src/auto_apply/infrastructure/composition_root.py @@ -1633,6 +1633,20 @@ def build_session_controller( research_consent = build_research_consent(registry) orchestrator = build_orchestrator(registry, research_consent=research_consent) + # The query builder serves the user-facing surfaces (turn 3); it reads + # the profile only as one-click defaults and never writes to it. + from auto_apply.application.services.query_builder import QueryBuilderService # noqa: PLC0415 + from auto_apply.adapters.secondary.discovery.search_surface_registry import ( # noqa: PLC0415 + list_surface_names, + load_surface, + ) + + query_builder = QueryBuilderService( + profile=profile, + load_spec=load_surface, + list_names=list_surface_names, + ) + # 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. @@ -1643,6 +1657,7 @@ def build_session_controller( orchestrator=orchestrator, profile_repo=profile_repo, research_consent=research_consent, + query_builder=query_builder, ) except Exception: orchestrator.shutdown() diff --git a/packages/auto_apply/src/auto_apply/resources/engines/google.yaml b/packages/auto_apply/src/auto_apply/resources/engines/google.yaml index d81c49e..339a72e 100644 --- a/packages/auto_apply/src/auto_apply/resources/engines/google.yaml +++ b/packages/auto_apply/src/auto_apply/resources/engines/google.yaml @@ -121,8 +121,13 @@ toolbar: pagination: # The one page parameter the codebase already proves real: the search - # strategy builds URLs with "start": 0 (engine_strategies.py). Page N is - # value = first + step * (N - 1), so page 2 is start=10. + # strategy sends "start" (now via the catalog — + # resources/search_surfaces/google.surface.yaml, which ALSO carries + # start=0 in its automation template). Two sites, one fact, by design for + # now: this file owns the live pagination contract (composition_root + # builds UrlPageTemplate from it); the catalog copy exists so a built + # URL names its first page. Follow-up: make one read the other. + # Page N is value = first + step * (N - 1), so page 2 is start=10. url_template: param: start first: 0 diff --git a/packages/auto_apply/src/auto_apply/resources/search_surfaces/bing.surface.yaml b/packages/auto_apply/src/auto_apply/resources/search_surfaces/bing.surface.yaml new file mode 100644 index 0000000..2a80ef6 --- /dev/null +++ b/packages/auto_apply/src/auto_apply/resources/search_surfaces/bing.surface.yaml @@ -0,0 +1,131 @@ +# Bing — what this search surface accepts. See google.surface.yaml for the +# verification levels. +name: bing +display_name: Bing +kind: engine +homepage: "https://www.bing.com" +summary: "Microsoft's search engine. Typed operators are vendor documented; the date filter is not." +usage_note: "" + +text_query: + or_token: "OR" + or_uppercase: true + phrase_quote: '"' + subject_group: "({content})" + exclude_prefix: "-" + +urls: + web: + base: "https://www.bing.com/search" + parameters: + - {name: q, source: query} + jobs: + base: "https://www.bing.com/jobs" + parameters: + - {name: q, source: query} + date_parameter: filters +default_url: jobs +automation_url: jobs + +operators: + - name: site + label: "Only this site" + explanation: "Shows results only from the site you name. A folder works up to two levels deep, for example site:example.com/careers." + syntax: "site:{value}" + channel: typed + accepts: domain_or_path + status: vendor + source: "Microsoft 'Advanced search keywords' page" + - name: phrase + label: "Exact words" + explanation: "Results must contain these words in exactly this order." + syntax: '"{value}"' + channel: typed + accepts: text + status: vendor + source: "Microsoft 'Advanced search keywords' page" + - name: exclude + label: "Without this" + explanation: "Results must not contain this word or phrase." + syntax: "-{value}" + channel: typed + accepts: text + status: vendor + source: "Microsoft 'Advanced search keywords' page" + - name: any_of + label: "Any of these" + explanation: "Results can match any one of these. Written as (A OR B); OR must be capital letters." + syntax: "({content})" + channel: typed + accepts: text + status: vendor + source: "Microsoft 'Advanced search keywords' page" + caveats: + - "OR must be capital letters; a lower-case 'or' is read as an ordinary word." + - name: intitle + label: "Word in the title" + explanation: "Results whose page title contains this word. One word per operator; chain them for several." + syntax: "intitle:{value}" + channel: typed + accepts: text + status: vendor + source: "Microsoft 'Advanced search keywords' page" + # Recorded, not offered: AA's composer cannot write these yet. + - name: inbody + label: "Word in the page" + explanation: "Results whose page text contains this word. Not yet offered by AA's builder." + syntax: "inbody:{value}" + channel: typed + accepts: text + status: vendor + source: "Microsoft 'Advanced search keywords' page" + composable: false + - name: inanchor + label: "Word in link text" + explanation: "Results whose incoming links contain this word. Not yet offered by AA's builder." + syntax: "inanchor:{value}" + channel: typed + accepts: text + status: vendor + source: "Microsoft 'Advanced search keywords' page" + composable: false + - name: filetype + label: "Only this file type" + explanation: "Results that are files of the type you name. Not yet offered by AA's builder." + syntax: "filetype:{value}" + channel: typed + accepts: file_type + status: vendor + source: "Microsoft 'Advanced search keywords' page" + composable: false + - name: language + label: "Only this language" + explanation: "Results in the language you name. Not yet offered by AA's builder." + syntax: "language:{value}" + channel: typed + accepts: text + status: vendor + source: "Microsoft 'Advanced search keywords' page" + composable: false + - name: loc + label: "Only this region" + explanation: "Results for the region you name. Not yet offered by AA's builder." + syntax: "loc:{value}" + channel: typed + accepts: text + status: vendor + source: "Microsoft 'Advanced search keywords' page" + composable: false + +url_options: + - name: filters + option: date + label: "Newer than" + explanation: "Limits results to the past day, week or month. This lives only in the web address — there is nothing to type for it." + channel: url + value_format: 'ex1:"{code}"' + codes: {hour: h, day: d, week: w, month: m, year: y} + option_labels: {day: "Past 24 hours", week: "Past week", month: "Past month"} + status: third_party + source: "Reported; AA's automation sends these letter codes. Third parties also report ez1/ez2/ez3 forms and a day-count custom range." + merge: comma diff --git a/packages/auto_apply/src/auto_apply/resources/search_surfaces/duckduckgo.surface.yaml b/packages/auto_apply/src/auto_apply/resources/search_surfaces/duckduckgo.surface.yaml new file mode 100644 index 0000000..958b066 --- /dev/null +++ b/packages/auto_apply/src/auto_apply/resources/search_surfaces/duckduckgo.surface.yaml @@ -0,0 +1,84 @@ +# DuckDuckGo — what this search surface accepts. Not automated by AA. +# See google.surface.yaml for the verification levels. +name: duckduckgo +display_name: DuckDuckGo +kind: engine +homepage: "https://duckduckgo.com" +summary: "A privacy-focused search engine. Not automated by AA." +usage_note: "DuckDuckGo cautions that advanced syntax does not work correctly on every query, and that a search with no matches can silently show related results instead." + +text_query: + or_token: null + phrase_quote: '"' + subject_group: "({content})" + exclude_prefix: "-" + +urls: + web: + base: "https://duckduckgo.com/" + parameters: + - {name: q, source: query} +default_url: web + +operators: + - name: phrase + label: "Exact words" + explanation: "Results must contain these words in exactly this order." + syntax: '"{value}"' + channel: typed + accepts: text + status: vendor + source: "DuckDuckGo help pages" + - name: exclude + label: "Without this" + explanation: "Results must not contain this word or phrase." + syntax: "-{value}" + channel: typed + accepts: text + status: vendor + source: "DuckDuckGo help pages" + - name: site + label: "Only this site" + explanation: "Shows results only from the site you name." + syntax: "site:{value}" + channel: typed + accepts: domain_or_path + status: vendor + source: "DuckDuckGo help pages" + - name: intitle + label: "Word in the title" + explanation: "Results whose page title contains this word or phrase." + syntax: "intitle:{value}" + channel: typed + accepts: text + status: vendor + source: "DuckDuckGo help pages" + - name: any_of + label: "Any of these" + explanation: "DuckDuckGo documents no 'any of these' operator, so it is not offered." + syntax: "({content})" + channel: typed + accepts: text + status: unverified + source: "Absent from DuckDuckGo's help pages" + composable: false + +url_options: + - name: kl + option: region + label: "Results region" + explanation: "Asks for results from a region, written like us-en or uk-en. This lives only in the link — there is nothing to type for it." + channel: url + value_format: "{code}" + free_form: true + status: vendor + source: "DuckDuckGo help pages" + - name: df + option: date + label: "Newer than" + explanation: "A date filter is reported to exist, but it is not on DuckDuckGo's documented parameter list — so it is not offered until verified." + channel: url + value_format: "{code}" + codes: {day: d, week: w, month: m} + status: unverified + source: "Reported, not on the documented list" 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 new file mode 100644 index 0000000..2a1de1e --- /dev/null +++ b/packages/auto_apply/src/auto_apply/resources/search_surfaces/google.surface.yaml @@ -0,0 +1,199 @@ +# Google — what this search surface accepts. +# +# Verification levels: vendor (the site's own documentation) / measured (a +# person observed it, with a date) / third_party (reported or reverse- +# engineered by others) / unverified (recorded, never offered). +name: google +display_name: Google +kind: engine +homepage: "https://www.google.com" +summary: "General web search: typed operators, plus filters that live only in the web address." +usage_note: "AA's automated job searches ask Google for English/US results (hl=en, gl=us) no matter where you are. A search you build here carries no such setting — your own browser's language and region apply." + +text_query: + or_token: "OR" + or_uppercase: true + phrase_quote: '"' + subject_group: "({content})" + exclude_prefix: "-" + max_words: 32 + max_words_status: third_party + max_words_source: "Commonly reported limit; not on Google's operator page" + +urls: + web: + base: "https://www.google.com/search" + parameters: + - {name: q, source: query} + jobs: + base: "https://www.google.com/search" + parameters: + - {name: q, source: query} + - {name: ibp, value: "htl;jobs"} + - {name: hl, value: "en"} + - {name: gl, value: "us"} + # NOTE: start=0 is duplicated with resources/engines/google.yaml's + # pagination.url_template (param start, first 0, step 10). One fact in + # two files, by design for now; the engine YAML owns live pagination + # (composition_root builds UrlPageTemplate from it), this copy exists + # so a built URL names its first page. Follow-up: make one read the + # other. + - {name: start, value: "0"} + date_parameter: tbs +default_url: web +automation_url: jobs + +operators: + - name: site + label: "Only this site" + explanation: "Shows results only from the site you name. A folder works too, for example site:jobs.gusto.com/boards." + syntax: "site:{value}" + channel: typed + accepts: domain_or_path + status: vendor + source: "Google Search documentation: search operators" + - name: phrase + label: "Exact words" + explanation: "Results must contain these words in exactly this order." + syntax: '"{value}"' + channel: typed + accepts: text + status: vendor + source: "Google Search documentation: search operators" + - name: exclude + label: "Without this" + explanation: "Results must not contain this word or phrase." + syntax: "-{value}" + channel: typed + accepts: text + status: vendor + source: "Google Search documentation: search operators" + - name: any_of + label: "Any of these" + explanation: "Results can match any one of these. Written as (A OR B); OR must be capital letters." + syntax: "({content})" + channel: typed + accepts: text + status: vendor + source: "Google Search documentation: search operators" + caveats: + - "OR must be capital letters; a lower-case 'or' is read as an ordinary word." + - name: intitle + label: "Word in the title" + explanation: "Results whose page title contains this word or phrase." + syntax: "intitle:{value}" + channel: typed + accepts: text + status: vendor + source: "Google Search documentation: search operators" + # Recorded, not offered: AA's composer cannot write these yet. + - name: exclude_site + label: "Not this site" + explanation: "Leaves one site out of the results. Not yet offered by AA's builder." + syntax: "-site:{value}" + channel: typed + accepts: domain_or_path + status: vendor + source: "Google Search documentation: search operators" + composable: false + - name: inurl + label: "Word in the address" + explanation: "Results whose page address contains this word. Not yet offered by AA's builder." + syntax: "inurl:{value}" + channel: typed + accepts: text + status: vendor + source: "Google Search documentation: search operators" + composable: false + - name: after + label: "Posted after a date" + explanation: "Results Google believes were published after a fixed date (YYYY-MM-DD). Not the same filter as 'past 24 hours'. Not yet offered by AA's builder." + syntax: "after:{value}" + channel: typed + accepts: date + status: vendor + source: "Google Search documentation: search operators" + composable: false + - name: before + label: "Posted before a date" + explanation: "Results Google believes were published before a fixed date (YYYY-MM-DD). Not yet offered by AA's builder." + syntax: "before:{value}" + channel: typed + accepts: date + status: vendor + source: "Google Search documentation: search operators" + composable: false + - name: filetype + label: "Only this file type" + explanation: "Results that are files of the type you name, for example filetype:pdf. Not yet offered by AA's builder." + syntax: "filetype:{value}" + channel: typed + accepts: file_type + status: vendor + source: "Google Search documentation: search operators" + composable: false + # Recorded, never offered: retired or unreliable. + - name: link + label: "Pages linking to an address" + explanation: "Retired by Google. Recorded so its absence is deliberate, not forgotten." + syntax: "link:{value}" + channel: typed + accepts: domain + status: unverified + source: "Google retired this operator" + composable: false + - name: cache + label: "Cached copy" + explanation: "Scaled back by Google in 2024. Recorded so its absence is deliberate." + syntax: "cache:{value}" + channel: typed + accepts: domain + status: unverified + source: "Reported scaled back in 2024" + composable: false + - name: inanchor + label: "Word in link text" + explanation: "Reported unreliable. Recorded so its absence is deliberate." + syntax: "inanchor:{value}" + channel: typed + accepts: text + status: unverified + source: "Reported unreliable" + composable: false + +url_options: + - name: tbs + option: date + label: "Newer than" + explanation: "Limits results to the past hour, 24 hours, week, month or year. This lives only in the web address — there is nothing you can type into the search bar that does exactly this." + channel: url + value_format: "qdr:{code}" + codes: {hour: h, day: d, week: w, month: m, year: y} + option_labels: {hour: "Past hour", day: "Past 24 hours", week: "Past week", month: "Past month", year: "Past year"} + status: third_party + source: "Reverse-engineered; Google does not document address parameters. AA's automation sends these codes." + typed_equivalent: after + typed_equivalent_note: "The nearest thing you can type is after:YYYY-MM-DD — a fixed date, not a rolling window, and Google chooses which date it reads." + merge: comma + - name: tbs + option: sort_date + label: "Newest first" + explanation: "Sorts results by date instead of relevance. This lives only in the web address." + channel: url + value_format: "sbd:1" + codes: {"on": "1"} + option_labels: {"on": "Newest first"} + status: third_party + source: "Reverse-engineered" + merge: comma + - name: udm + option: plain_web + label: "Plain web results" + explanation: "Asks for the plain list of web results. This parameter's values are undocumented and could change without notice, so it is not offered." + channel: url + value_format: "14" + codes: {"on": "14"} + option_labels: {"on": "Plain web results"} + status: unverified + source: "Undocumented parameter" + merge: comma diff --git a/packages/auto_apply/src/auto_apply/resources/search_surfaces/indeed.surface.yaml b/packages/auto_apply/src/auto_apply/resources/search_surfaces/indeed.surface.yaml new file mode 100644 index 0000000..29fd82d --- /dev/null +++ b/packages/auto_apply/src/auto_apply/resources/search_surfaces/indeed.surface.yaml @@ -0,0 +1,84 @@ +# Indeed — what this search surface accepts. See google.surface.yaml for the +# verification levels. +name: indeed +display_name: Indeed +kind: job_board +homepage: "https://www.indeed.com" +summary: "A job board with its own search. AA automates this surface." +usage_note: "" + +text_query: + or_token: "OR" + or_uppercase: true + phrase_quote: '"' + subject_group: "({content})" + exclude_prefix: "-" + +urls: + jobs: + base: "https://www.indeed.com/jobs" + parameters: + - {name: q, source: query} + - {name: l, source: location} + date_parameter: fromage +default_url: jobs +automation_url: jobs + +operators: + - name: phrase + label: "Exact words" + explanation: "Results must contain these words in exactly this order." + syntax: '"{value}"' + channel: typed + accepts: text + status: third_party + source: "Reported by third-party job-search guides" + - name: exclude + label: "Without this" + explanation: "Results must not contain this word or phrase." + syntax: "-{value}" + channel: typed + accepts: text + status: third_party + source: "Reported by third-party job-search guides" + - name: any_of + label: "Any of these" + explanation: "Results can match any one of these. Written as (A OR B)." + syntax: "({content})" + channel: typed + accepts: text + status: third_party + source: "Reported by third-party job-search guides" + caveats: + - "OR is written in capital letters inside parentheses, as the guides describe." + - name: title + label: "Word in the job title" + explanation: "Results whose job title contains this word. Not yet offered by AA's builder." + syntax: "title:{value}" + channel: typed + accepts: text + status: third_party + source: "Reported by third-party job-search guides" + composable: false + - name: company + label: "From this company" + explanation: "Results from the company you name. Not yet offered by AA's builder." + syntax: "company:{value}" + channel: typed + accepts: text + status: third_party + source: "Reported by third-party job-search guides" + composable: false + +url_options: + - name: fromage + option: date + label: "Posted within" + explanation: "Limits results to the last day, week or month. This lives only in the web address — there is nothing to type for it." + channel: url + value_format: "{code}" + codes: {hour: "1", day: "1", week: "7", month: "30", year: "365"} + option_labels: {day: "Past 24 hours", week: "Past week", month: "Past month"} + status: third_party + source: "Reported; AA's own automation sends this parameter" + merge: append diff --git a/packages/auto_apply/src/auto_apply/resources/search_surfaces/linkedin.surface.yaml b/packages/auto_apply/src/auto_apply/resources/search_surfaces/linkedin.surface.yaml new file mode 100644 index 0000000..19f6561 --- /dev/null +++ b/packages/auto_apply/src/auto_apply/resources/search_surfaces/linkedin.surface.yaml @@ -0,0 +1,55 @@ +# LinkedIn Jobs — what this search surface accepts. Not automated by AA. +# See google.surface.yaml for the verification levels. +name: linkedin +display_name: LinkedIn Jobs +kind: job_board +homepage: "https://www.linkedin.com/jobs/" +summary: "LinkedIn's job board. Not automated by AA." +usage_note: "LinkedIn's terms restrict automated access. A link you build here and open yourself, in your own browser, is ordinary use." + +text_query: + or_token: null + phrase_quote: '"' + subject_group: "({content})" + exclude_prefix: "-" + +urls: + jobs: + base: "https://www.linkedin.com/jobs/search/" + parameters: + - {name: keywords, source: query} +default_url: jobs + +operators: + - name: phrase + label: "Exact words" + explanation: "Reported to work in LinkedIn's keywords box, but not verified — so it is not offered, and your terms go into the link without quotes." + syntax: '"{value}"' + channel: typed + accepts: text + status: unverified + source: "Third-party reports" + +url_options: + - name: f_TPR + option: date + label: "Posted within" + explanation: "Limits results to the past day, week or month. This lives only in the link — there is nothing to type for it." + channel: url + value_format: "r{code}" + codes: {day: "86400", week: "604800", month: "2592000"} + option_labels: {day: "Past 24 hours", week: "Past week", month: "Past month"} + status: third_party + source: "Third-party reports" + merge: append + - name: f_WT + option: workplace + label: "Workplace" + explanation: "On-site, remote or hybrid. This lives only in the link." + channel: url + value_format: "{code}" + codes: {on_site: "1", remote: "2", hybrid: "3"} + option_labels: {on_site: "On-site", remote: "Remote", hybrid: "Hybrid"} + status: third_party + source: "Third-party reports" + merge: append diff --git a/packages/auto_apply/tests/adapters/test_discovery_providers.py b/packages/auto_apply/tests/adapters/test_discovery_providers.py index 534ef21..7ad5946 100644 --- a/packages/auto_apply/tests/adapters/test_discovery_providers.py +++ b/packages/auto_apply/tests/adapters/test_discovery_providers.py @@ -34,7 +34,6 @@ BingSearchStrategy, IndeedSearchStrategy, SearchEngineStrategy, - _GOOGLE_DATE_RANGE_MAP, ) from auto_apply.adapters.secondary.discovery.strategies.selector_loader import ( SelectorLoader, @@ -62,7 +61,7 @@ def _mock_browser() -> MagicMock: return b -def _descriptor(name: str, *patterns: str) -> ATSDescriptor: +def _descriptor(name: str, *patterns: str, hosts: tuple[str, ...] = ()) -> ATSDescriptor: return ATSDescriptor( name=name, url_patterns=patterns, @@ -71,6 +70,7 @@ def _descriptor(name: str, *patterns: str) -> ATSDescriptor: form_root_selector="", submit_button_selector="", multi_step=False, + hosts=hosts, ) @@ -253,8 +253,20 @@ def test_all_strategies_implement_required_methods(self): assert len(strat.search_bar_selectors) > 0 def test_google_date_range_map_coverage(self): + """The Google catalog covers every date_range the instruction allows.""" + from auto_apply.adapters.secondary.discovery.search_surface_registry import ( # noqa: PLC0415 + load_surface_bundled, + ) + + spec = load_surface_bundled("google") + template = spec.urls[spec.automation_url] + option = next( + o + for o in spec.url_options + if o.name == template.date_parameter and o.option == "date" + ) expected = {"hour", "day", "week", "month", "year"} - assert set(_GOOGLE_DATE_RANGE_MAP.keys()) == expected + assert set(option.codes.keys()) == expected # ═════════════════════════════════════════════════════════════════════════════ @@ -432,8 +444,8 @@ class TestFindCompanyCareerPage: def test_uses_registry_domains(self): reg = _mock_registry( - _descriptor("greenhouse", "*.greenhouse.io/jobs/*"), - _descriptor("lever", "jobs.lever.co/*"), + _descriptor("greenhouse", "*.greenhouse.io/jobs/*", hosts=("greenhouse.io",)), + _descriptor("lever", "jobs.lever.co/*", hosts=("lever.co",)), ) browser = _mock_browser() browser.find_element.return_value = None @@ -443,13 +455,16 @@ def test_uses_registry_domains(self): assert "greenhouse.io" in called_url assert "lever.co" in called_url - def test_fallback_without_registry(self): + def test_no_registry_queries_only_the_company_domain(self): + """No fabricated ATS list: with no registry the query names only the company.""" browser = _mock_browser() browser.find_element.return_value = None provider = GoogleProvider(browser) provider.find_company_career_page("Acme") called_url: str = browser.get.call_args[0][0] - assert "greenhouse.io" in called_url + assert "greenhouse.io" not in called_url + assert "workday.com" not in called_url + assert "Acme.com" in called_url def test_returns_none_when_no_result(self): browser = _mock_browser() @@ -470,11 +485,24 @@ def test_returns_none_on_exception(self): class TestAtsSiteFilters: - def test_returns_fallback_when_no_registry(self): - result = _ats_site_filters(None) - assert "greenhouse.io" in result + def test_returns_empty_when_no_registry(self): + """No fabricated list: no registry means no filters at all.""" + assert _ats_site_filters(None) == [] - def test_extracts_root_domain_from_wildcard(self): + def test_reads_declared_hosts_first(self): + """The YAML hosts field wins — a descriptor's own domains, never a guess.""" + reg = _mock_registry( + _descriptor( + "workday", + "*.myworkdayjobs.com/*/job/*", + hosts=("myworkdayjobs.com", "myworkday.com"), + ), + ) + result = _ats_site_filters(reg) + assert result == ["myworkdayjobs.com", "myworkday.com"] + assert "workday.com" not in result + + def test_derives_root_domain_from_patterns_when_no_hosts(self): reg = _mock_registry(_descriptor("greenhouse", "*.greenhouse.io/jobs/*")) result = _ats_site_filters(reg) assert "greenhouse.io" in result @@ -486,10 +514,8 @@ def test_deduplicates_same_root(self): result = _ats_site_filters(reg) assert result.count("greenhouse.io") == 1 - def test_returns_fallback_when_registry_empty(self): - reg = _mock_registry() - result = _ats_site_filters(reg) - assert "greenhouse.io" in result + def test_returns_empty_when_registry_empty(self): + assert _ats_site_filters(_mock_registry()) == [] # ═════════════════════════════════════════════════════════════════════════════ diff --git a/packages/auto_apply/tests/adapters/test_engine_url_byte_identity.py b/packages/auto_apply/tests/adapters/test_engine_url_byte_identity.py new file mode 100644 index 0000000..fb802ee --- /dev/null +++ b/packages/auto_apply/tests/adapters/test_engine_url_byte_identity.py @@ -0,0 +1,102 @@ +"""Byte-identity pin: the catalog-driven engine URL builders produce exactly +the bytes the hardcoded builders produced before the move. + +GUARD (regression), not teeth: the expected strings are the pre-change +outputs, written by hand from the pre-move code. This pin passes on the old +tree's OUTPUT and must keep passing on the new one. The mutations it exists +to catch: any drift between the catalog and what automation sends (a catalog +edit changing hl, the parameter order, or a date code), and any refactor +reintroducing a second source of URL truth. +""" +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 + +_GOOGLE_BASE = ( + "https://www.google.com/search?q=Engineer+jobs+in+Remote" + "&ibp=htl%3Bjobs&hl=en&gl=us&start=0" +) +_BING_BASE = "https://www.bing.com/jobs?q=Engineer+jobs+in+Remote" +_INDEED_BASE = "https://www.indeed.com/jobs?q=Engineer+jobs+in+Remote&l=Remote" + +_CASES = [ + ("google", None, _GOOGLE_BASE), + ("google", "hour", _GOOGLE_BASE + "&tbs=qdr%3Ah"), + ("google", "day", _GOOGLE_BASE + "&tbs=qdr%3Ad"), + ("google", "week", _GOOGLE_BASE + "&tbs=qdr%3Aw"), + ("google", "month", _GOOGLE_BASE + "&tbs=qdr%3Am"), + ("google", "year", _GOOGLE_BASE + "&tbs=qdr%3Ay"), + ("bing", None, _BING_BASE), + ("bing", "hour", _BING_BASE + "&filters=ex1%3A%22h%22"), + ("bing", "day", _BING_BASE + "&filters=ex1%3A%22d%22"), + ("bing", "week", _BING_BASE + "&filters=ex1%3A%22w%22"), + ("bing", "month", _BING_BASE + "&filters=ex1%3A%22m%22"), + ("bing", "year", _BING_BASE + "&filters=ex1%3A%22y%22"), + ("indeed", None, _INDEED_BASE), + ("indeed", "hour", _INDEED_BASE + "&fromage=1"), + ("indeed", "day", _INDEED_BASE + "&fromage=1"), + ("indeed", "week", _INDEED_BASE + "&fromage=7"), + ("indeed", "month", _INDEED_BASE + "&fromage=30"), + ("indeed", "year", _INDEED_BASE + "&fromage=365"), +] + +_RAW = 'site:jobs.lever.co "software engineer"' +_RAW_Q = "site%3Ajobs.lever.co+%22software+engineer%22" + +_RAW_CASES = [ + ("google", f"https://www.google.com/search?q={_RAW_Q}&ibp=htl%3Bjobs&hl=en&gl=us&start=0"), + ("bing", f"https://www.bing.com/jobs?q={_RAW_Q}"), + ("indeed", f"https://www.indeed.com/jobs?q={_RAW_Q}&l=Remote"), +] + +_STRATEGIES = { + "google": GoogleSearchStrategy, + "bing": BingSearchStrategy, + "indeed": IndeedSearchStrategy, +} + + +def _instruction(date_range=None, raw=None): + return SearchInstruction( + title="Engineer", + location="Remote", + raw_query_string=raw, + date_range=date_range, + ) + + +@pytest.mark.parametrize("engine,date_range,expected", _CASES) +def test_engine_urls_are_byte_identical(engine, date_range, expected): + strategy = _STRATEGIES[engine]() + assert strategy.build_search_url(_instruction(date_range=date_range)) == expected + + +@pytest.mark.parametrize("engine,expected", _RAW_CASES) +def test_raw_query_urls_are_byte_identical(engine, expected): + strategy = _STRATEGIES[engine]() + assert strategy.build_search_url(_instruction(raw=_RAW)) == expected + + +def test_an_unknown_date_range_is_omitted_never_invented(): + """Automation omits an unknown date_range (the pre-catalog behaviour), + never invents a code for it. Mutation caught: the composer writing a + parameter for a code it does not know.""" + from auto_apply.adapters.secondary.discovery.search_surface_registry import ( + load_surface_bundled, + ) + from auto_apply.domain.services.query_composer import build_automation_url + + url = build_automation_url( + load_surface_bundled("google"), + query="Engineer jobs in Remote", + location="Remote", + date_range="bogus", + ) + assert "tbs" not in url diff --git a/packages/auto_apply/tests/application/test_query_builder_service.py b/packages/auto_apply/tests/application/test_query_builder_service.py new file mode 100644 index 0000000..8a265a7 --- /dev/null +++ b/packages/auto_apply/tests/application/test_query_builder_service.py @@ -0,0 +1,178 @@ +"""Pins for the query-builder service (turn 1 of the query-builder chain). + +Label honesty: + GUARD (new surface) — the service did not exist before this change, so no + pin could be run against the old tree. Mutation coverage is named per pin. +""" +from __future__ import annotations + +import ast +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from auto_apply.adapters.secondary.discovery.search_surface_registry import ( + SearchSurfaceError, + list_surface_names, + load_surface, +) +from auto_apply.application.services.query_builder import ( + DEFAULT_RECENT_PATH, + QueryBuilderService, +) +from auto_apply.application.services.uninstall.engine import ( + DATA_ROOT_KNOWN_ENTRIES, +) +from auto_apply.domain.config import USER_DATA_DIR +from auto_apply.domain.models.search_query import QueryRequest + + +def _service(tmp_path: Path, profile=None) -> QueryBuilderService: + return QueryBuilderService( + profile=profile, + load_spec=load_surface, + list_names=list_surface_names, + recent_path=tmp_path / "query_builder_recent.json", + ) + + +def test_defaults_offer_the_profiles_saved_titles(tmp_path, minimal_valid_profile): + """GUARD. Mutation caught: reading a different field, or none.""" + service = _service(tmp_path, profile=minimal_valid_profile) + assert service.defaults().titles == ("Software Engineer",) + + +def test_defaults_are_empty_without_a_profile(tmp_path): + """GUARD. Mutation caught: inventing defaults.""" + assert _service(tmp_path).defaults().titles == () + + +def test_building_a_query_never_writes_to_the_profile(tmp_path, minimal_valid_profile): + """GUARD (nothing is written to the profile by building). Mutation + caught: any mutation of the profile during build.""" + service = _service(tmp_path, profile=minimal_valid_profile) + before = minimal_valid_profile.model_dump_json() + service.build(QueryRequest(surface="google", phrases=("x",))) + assert minimal_valid_profile.model_dump_json() == before + + +def test_unverified_and_uncomposable_capabilities_are_never_offered(tmp_path): + """GUARD (the verification rule). Mutation caught: offering link:/cache:/ + inanchor:/udm, which the catalog records as unverified.""" + offers = {o.name: o for o in _service(tmp_path).surfaces()} + google = offers["google"] + operator_keys = {c.key for c in google.operators} + assert {"site", "phrase", "exclude", "any_of", "intitle"} <= operator_keys + assert "link" not in operator_keys + assert "cache" not in operator_keys + assert "inanchor" not in operator_keys + option_keys = {c.key for c in google.options} + assert "date" in option_keys + assert "plain_web" not in option_keys + + +def test_build_leaves_the_recent_file_untouched(tmp_path): + """GUARD (the preview rule): building writes nothing — turn 3's surfaces + may rebuild a preview on every keystroke, so recording on build would + store half-typed queries. Mutation caught: build() calling _record_recent.""" + service = _service(tmp_path) + service.build(QueryRequest(surface="google", phrases=("x",))) + service.build(QueryRequest(surface="google", phrases=("y",))) + assert service.recent() == () + assert not (tmp_path / "query_builder_recent.json").exists() + + +def test_remember_caps_at_five(tmp_path): + """GUARD. Mutation caught: the cap growing or being removed.""" + service = _service(tmp_path) + for i in range(7): + service.remember(QueryRequest(surface="google", phrases=(f"term-{i}",))) + recent = service.recent() + assert len(recent) == 5 + assert recent[0].phrases == ("term-6",) + + +def test_remember_deduplicates(tmp_path): + """GUARD. Mutation caught: duplicates accumulating.""" + service = _service(tmp_path) + service.remember(QueryRequest(surface="google", phrases=("same",))) + service.remember(QueryRequest(surface="google", phrases=("same",))) + assert len(service.recent()) == 1 + + +def test_clear_recent_removes_the_file(tmp_path): + """GUARD. Mutation caught: leaving the file (empty or not) behind.""" + service = _service(tmp_path) + service.remember(QueryRequest(surface="google", phrases=("x",))) + service.clear_recent() + assert service.recent() == () + assert not (tmp_path / "query_builder_recent.json").exists() + + +def test_the_recent_file_lives_in_the_data_home_and_uninstall_knows_it(): + """GUARD (ruling 4): the file is in the data home and in the + uninstaller's known entries. Mutation caught: moving the file or dropping + the uninstall entry.""" + assert DEFAULT_RECENT_PATH.parent == USER_DATA_DIR + assert DEFAULT_RECENT_PATH.name in DATA_ROOT_KNOWN_ENTRIES + + +def test_build_returns_bar_url_and_plain_notes(tmp_path): + """GUARD. Mutation caught: the service dropping the notes or a form.""" + result = _service(tmp_path).build( + QueryRequest( + surface="google", + phrases=("Software Engineer",), + options=(("date", "day"),), + ) + ) + assert result.bar_text == '("Software Engineer")' + assert result.url is not None and "tbs=qdr%3Ad" in result.url + assert result.notes + + +def test_an_unknown_surface_fails_loudly(tmp_path): + """GUARD. Mutation caught: a quiet empty result for an unknown surface.""" + with pytest.raises(SearchSurfaceError): + _service(tmp_path).build(QueryRequest(surface="nonsense", phrases=("x",))) + + +def test_the_builder_modules_import_no_network_browser_or_research_code(): + """GUARD (structural no-network / no-research pin). Mutation caught: + any of the builder modules importing a network, browser or research + module.""" + root = Path(__file__).resolve().parents[2] / "src" / "auto_apply" + modules = [ + root / "domain" / "models" / "search_surface.py", + root / "domain" / "models" / "search_query.py", + root / "domain" / "services" / "query_composer.py", + root / "application" / "services" / "query_builder.py", + root / "adapters" / "secondary" / "discovery" / "search_surface_registry.py", + ] + banned = ("socket", "requests", "httpx", "webbrowser", "selenium", "playwright", "urllib.request") + for path in modules: + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + assert not alias.name.startswith(banned), f"{path.name}: {alias.name}" + elif isinstance(node, ast.ImportFrom) and node.module: + assert not node.module.startswith(banned), f"{path.name}: {node.module}" + assert ".browser" not in node.module, f"{path.name}: {node.module}" + assert ".network" not in node.module, f"{path.name}: {node.module}" + assert "research" not in node.module, f"{path.name}: {node.module}" + + +def test_the_session_controller_exposes_the_builder(): + """GUARD (turn-3 seam): the controller holds the injected service. + Mutation caught: dropping the dependency or the property.""" + from auto_apply.application.services.session_controller import SessionController + + controller = SessionController( + registry=MagicMock(), + db=MagicMock(), + orchestrator=MagicMock(), + query_builder="SENTINEL", + ) + assert controller.query_builder == "SENTINEL" diff --git a/packages/auto_apply/tests/domain/test_query_composer.py b/packages/auto_apply/tests/domain/test_query_composer.py new file mode 100644 index 0000000..1e37c42 --- /dev/null +++ b/packages/auto_apply/tests/domain/test_query_composer.py @@ -0,0 +1,311 @@ +"""Pins for the query composer (turn 1 of the query-builder chain). + +Label honesty: + GUARD (new surface) — every pin here covers behaviour that did not exist + before this change, so none could be run against the old tree. Each pin's + docstring names the deliberate mutation it catches. +""" +from __future__ import annotations + +import pytest + +from auto_apply.adapters.secondary.discovery.search_surface_registry import ( + load_surface, +) +from auto_apply.domain.models.search_query import QueryRequest +from auto_apply.domain.models.search_surface import VerificationStatus +from auto_apply.domain.services.query_composer import compose + + +def _spec(name: str): + return load_surface(name) + + +@pytest.mark.parametrize( + "site,phrase,expected", + [ + ( + "bamboohr.com/careers", + "software engineer", + 'site:bamboohr.com/careers ("software engineer")', + ), + ( + "jobs.jobvite.com", + "Software Engineer", + 'site:jobs.jobvite.com ("Software Engineer")', + ), + ( + "jobs.gusto.com/boards", + "Software Engineer", + 'site:jobs.gusto.com/boards ("Software Engineer")', + ), + ], +) +def test_the_three_reported_queries_compose_exactly(site, phrase, expected): + """GUARD: the three queries reported as reliable compose byte-exactly. + Mutation caught: any change to site/phrase/group formatting.""" + result = compose( + QueryRequest(surface="google", site=site, phrases=(phrase,)), + _spec("google"), + ) + assert result.bar_text == expected + + +def test_confusables_are_normalised_before_composing(): + """GUARD: a typographic closing quote (U+201D) in pasted text does not + corrupt the query. Mutation caught: dropping the normalisation pass.""" + phrase = "Software Engineer\u201d" + result = compose( + QueryRequest(surface="google", site="jobs.jobvite.com", phrases=(phrase,)), + _spec("google"), + ) + assert result.bar_text == 'site:jobs.jobvite.com ("Software Engineer")' + + +def test_or_is_uppercase_where_the_surface_requires_it(): + """GUARD. Mutation caught: emitting a lower-case 'or' on Google.""" + result = compose( + QueryRequest(surface="google", any_of=("Alpha", "Beta")), + _spec("google"), + ) + assert result.bar_text is not None + assert " OR " in result.bar_text + assert " or " not in result.bar_text + + +def test_no_space_after_an_operator_colon(): + """GUARD. Mutation caught: 'site: example.com' or 'intitle: engineer'.""" + result = compose( + QueryRequest(surface="google", site="example.com", in_title=("engineer",)), + _spec("google"), + ) + assert result.bar_text is not None + assert "site:example.com" in result.bar_text + assert "site: " not in result.bar_text + assert "intitle:engineer" in result.bar_text + assert "intitle: " not in result.bar_text + + +def test_a_length_limit_is_never_exceeded(): + """GUARD: over the reported limit, nothing is written — never truncated. + Mutation caught: removing the max_words check, or trimming instead.""" + words = tuple(f"w{i}" for i in range(40)) + result = compose(QueryRequest(surface="google", required=words), _spec("google")) + assert result.bar_text is None + assert result.url is None + assert any(n.topic == "length" and n.severity == "refused" for n in result.notes) + + +def test_past_24_hours_lives_in_the_url_not_the_bar(): + """GUARD: Google's date filter is URL-only, and the note says the typed + nearest (after:) is a different filter. Mutation caught: putting the date + in the bar text, or dropping the explanation.""" + result = compose( + QueryRequest( + surface="google", + phrases=("Software Engineer",), + options=(("date", "day"),), + ), + _spec("google"), + ) + assert result.bar_text == '("Software Engineer")' + assert result.url is not None + assert "tbs=qdr%3Ad" in result.url + text = " ".join(n.message for n in result.notes) + assert "cannot be typed" in text + assert "after:" in text + assert "fixed date" in text + + +def test_an_option_the_surface_lacks_is_refused_with_a_note(): + """GUARD. Mutation caught: silently ignoring an unknown option.""" + result = compose( + QueryRequest( + surface="google", + phrases=("x",), + options=(("colour", "blue"),), + ), + _spec("google"), + ) + assert result.url is not None + assert "colour" not in result.url + assert any(n.severity == "refused" and "colour" in n.message for n in result.notes) + + +def test_a_site_limit_on_linkedin_is_refused_not_silently_dropped(): + """GUARD. Mutation caught: composing LinkedIn text with site:, or + dropping the note that says the site limit went nowhere.""" + result = compose( + QueryRequest(surface="linkedin", site="example.com", phrases=("Software Engineer",)), + _spec("linkedin"), + ) + assert result.bar_text is None + assert result.url is not None + assert "keywords=Software+Engineer" in result.url + text = " ".join(n.message for n in result.notes) + assert "site" in text.lower() + + +def test_any_of_on_duckduckgo_is_refused_not_rewritten(): + """GUARD: there is no documented OR on DuckDuckGo, so alternatives are + never silently ANDed. Mutation caught: plain-joining any_of into the + link, or answering with a bar.""" + result = compose( + QueryRequest( + surface="duckduckgo", + phrases=("Software Engineer",), + any_of=("Alpha", "Beta"), + ), + _spec("duckduckgo"), + ) + assert result.bar_text is None + assert result.url is not None + assert "Alpha" not in result.url + assert "%22Software+Engineer%22" in result.url + assert any(n.severity == "refused" and "any of these" in n.message for n in result.notes) + + +def test_exclusions_compose_single_and_multi_word_forms(): + """GUARD. Mutation caught: quoting a single word or not quoting a phrase.""" + result = compose( + QueryRequest( + surface="google", + phrases=("developer",), + exclude=("intern", "senior manager"), + ), + _spec("google"), + ) + assert result.bar_text is not None + assert "-intern" in result.bar_text + assert '-"senior manager"' in result.bar_text + + +def test_any_of_composes_as_a_capitalised_or_group(): + """GUARD. Mutation caught: losing the parentheses or the quoted terms.""" + result = compose( + QueryRequest(surface="google", any_of=("Software Engineer", "Backend Engineer")), + _spec("google"), + ) + assert result.bar_text == '("Software Engineer" OR "Backend Engineer")' + + +def test_intitle_clause_composes(): + """GUARD. Mutation caught: any change to intitle formatting.""" + result = compose( + QueryRequest(surface="google", in_title=("engineer",)), + _spec("google"), + ) + assert result.bar_text == "intitle:engineer" + + +def test_linkedin_link_uses_plain_keywords_not_quotes(): + """GUARD: LinkedIn's quoting is unverified, so no quotes are emitted. + Mutation caught: quoting the keywords anyway.""" + result = compose( + QueryRequest(surface="linkedin", phrases=("Software Engineer",)), + _spec("linkedin"), + ) + assert result.bar_text is None + assert result.url == "https://www.linkedin.com/jobs/search/?keywords=Software+Engineer" + + +def test_linkedin_date_and_workplace_options_apply(): + """GUARD. Mutation caught: wrong f_TPR/f_WT formats or codes.""" + result = compose( + QueryRequest( + surface="linkedin", + phrases=("Software Engineer",), + options=(("date", "day"), ("workplace", "remote")), + ), + _spec("linkedin"), + ) + assert result.url is not None + assert "f_TPR=r86400" in result.url + assert "f_WT=2" in result.url + + +def test_indeed_url_carries_location_and_date(): + """GUARD. Mutation caught: dropping l= or mis-formatting fromage.""" + result = compose( + QueryRequest( + surface="indeed", + phrases=("Software Engineer",), + location="Austin", + options=(("date", "week"),), + ), + _spec("indeed"), + ) + assert result.url == ( + "https://www.indeed.com/jobs" + "?q=%28%22Software+Engineer%22%29&l=Austin&fromage=7" + ) + + +def test_an_empty_request_builds_nothing_with_a_note(): + """GUARD. Mutation caught: producing a '?q=' link for an empty request.""" + result = compose(QueryRequest(surface="google"), _spec("google")) + assert result.bar_text is None + assert result.url is None + assert result.notes + + +def test_the_url_carries_the_bar_text_url_encoded(): + """GUARD. Mutation caught: quoting rules differing between bar and URL.""" + result = compose( + QueryRequest(surface="google", phrases=("Software Engineer",)), + _spec("google"), + ) + assert result.url == "https://www.google.com/search?q=%28%22Software+Engineer%22%29" + + +def test_an_unverified_operator_is_never_used(): + """GUARD (verification rule): a site operator marked unverified in a + mutated spec is refused, never composed. Mutation caught: composing on + status alone or ignoring status entirely.""" + spec = _spec("google") + mutated = spec.model_copy( + update={ + "operators": tuple( + op.model_copy(update={"status": VerificationStatus.UNVERIFIED}) + if op.name == "site" + else op + for op in spec.operators + ) + } + ) + result = compose( + QueryRequest(surface="google", site="example.com", phrases=("x",)), + mutated, + ) + assert result.bar_text is None + assert any(n.severity == "refused" and n.topic == "site" for n in result.notes) + + +def test_a_surface_without_quoting_gets_unquoted_terms_with_a_note(): + """GUARD: LinkedIn records phrase quoting as unverified, so the link + carries the terms bare and the notes explain why. Mutation caught: + quoting without verification, or dropping the explanation.""" + result = compose( + QueryRequest(surface="linkedin", phrases=("Software Engineer",)), + _spec("linkedin"), + ) + assert result.bar_text is None + assert result.url is not None + assert "%22" not in result.url + assert result.notes + + +def test_date_and_sort_merge_on_one_parameter(): + """GUARD: two options sharing a URL parameter merge with a comma, as + Google's multivalue tbs requires. Mutation caught: appending a second + tbs=, or overwriting the first.""" + result = compose( + QueryRequest( + surface="google", + phrases=("x",), + options=(("date", "day"), ("sort_date", "on")), + ), + _spec("google"), + ) + assert result.url is not None + assert "tbs=qdr%3Ad%2Csbd%3A1" in result.url diff --git a/packages/auto_apply/tests/infrastructure/test_search_surface_registry.py b/packages/auto_apply/tests/infrastructure/test_search_surface_registry.py new file mode 100644 index 0000000..74a8828 --- /dev/null +++ b/packages/auto_apply/tests/infrastructure/test_search_surface_registry.py @@ -0,0 +1,212 @@ +"""Pins for the search-surface catalog loader. + +Label honesty: + GUARD (new surface) — the catalog did not exist before this change, so no + pin could be run against the old tree. Mutation coverage is named per pin. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from auto_apply.adapters.secondary.discovery.search_surface_registry import ( + SearchSurfaceError, + list_surface_names, + load_surface, + load_surface_bundled, +) +from auto_apply.adapters.secondary.discovery.strategies.selector_loader import ( + SelectorLoader, +) +from auto_apply.domain.models.search_surface import VerificationStatus +from auto_apply.domain.services.query_composer import EXPRESSIBLE_OPERATORS + +_ALL_SURFACES = ["google", "bing", "indeed", "duckduckgo", "linkedin"] + +_MINI = """name: mini +display_name: Mini +kind: engine +homepage: "https://mini.example" +text_query: + or_token: "OR" +urls: + web: + base: "https://mini.example/s" + parameters: + - {name: q, source: query} +default_url: web +operators: + - name: phrase + label: "Exact" + explanation: "Exact words." + syntax: '"{value}"' + status: vendor +""" + + +def _write(directory: Path, name: str, text: str) -> None: + directory.mkdir(parents=True, exist_ok=True) + (directory / f"{name}.surface.yaml").write_text(text, encoding="utf-8") + + +@pytest.mark.parametrize("name", _ALL_SURFACES) +def test_every_bundled_surface_loads_and_validates(name): + """GUARD. Mutation caught: a malformed or incomplete bundled entry.""" + spec = load_surface(name) + assert spec.name == name + assert spec.display_name + assert spec.default_url in spec.urls + + +def test_a_missing_surface_fails_loudly(): + """GUARD. Mutation caught: returning an empty/default spec instead.""" + with pytest.raises(SearchSurfaceError): + load_surface("does-not-exist") + + +def test_an_unknown_field_in_bundled_data_fails_loudly(tmp_path): + """GUARD (no hidden fallback). Mutation caught: ignoring unknown keys.""" + bundled = tmp_path / "bundled" + _write(bundled, "mini", _MINI + "bogus_key: true\n") + loader = SelectorLoader(bundled_dir=bundled, user_override_dir=tmp_path / "user") + with pytest.raises(SearchSurfaceError): + load_surface("mini", loader=loader) + + +def test_malformed_bundled_yaml_fails_loudly(tmp_path): + """GUARD. Mutation caught: swallowing the parse error.""" + bundled = tmp_path / "bundled" + _write(bundled, "mini", "{not: valid: yaml: [") + loader = SelectorLoader(bundled_dir=bundled, user_override_dir=tmp_path / "user") + with pytest.raises(SearchSurfaceError): + load_surface("mini", loader=loader) + + +def test_a_malformed_user_override_falls_back_to_bundled(tmp_path, caplog): + """GUARD (user-data precedent): a bad user override warns and is ignored. + Mutation caught: letting the bad override break loading, or silently + applying it.""" + bundled = tmp_path / "bundled" + user = tmp_path / "user" + _write(bundled, "mini", _MINI) + _write(user, "mini", "{not: valid: yaml: [") + loader = SelectorLoader(bundled_dir=bundled, user_override_dir=user) + with caplog.at_level("WARNING"): + spec = load_surface("mini", loader=loader) + assert spec.name == "mini" + + +def test_a_user_override_prepends_operators(tmp_path): + """GUARD (engines' merge semantics reused). Mutation caught: the user + entry landing anywhere but first, or replacing the list.""" + bundled = tmp_path / "bundled" + user = tmp_path / "user" + _write(bundled, "mini", _MINI) + _write( + user, + "mini", + "operators:\n" + " - name: custom_op\n" + ' label: "Custom"\n' + ' explanation: "A custom operator."\n' + ' syntax: "custom:{value}"\n' + " status: vendor\n", + ) + loader = SelectorLoader(bundled_dir=bundled, user_override_dir=user) + spec = load_surface("mini", loader=loader) + assert spec.operators[0].name == "custom_op" + assert spec.operators[-1].name == "phrase" + + +def test_a_measured_entry_without_a_date_fails_loudly(tmp_path): + """GUARD: a measurement without a date is a claim, not a measurement. + Mutation caught: letting status=measured slide without verified_on.""" + bundled = tmp_path / "bundled" + _write( + bundled, + "mini", + _MINI.replace("status: vendor", "status: measured"), + ) + loader = SelectorLoader(bundled_dir=bundled, user_override_dir=tmp_path / "user") + with pytest.raises(SearchSurfaceError): + load_surface("mini", loader=loader) + + +@pytest.mark.parametrize("name", _ALL_SURFACES) +def test_every_entry_has_a_status_and_measured_entries_have_a_date(name): + """GUARD. Mutation caught: an entry without a status, or measured + without verified_on, getting into bundled data.""" + spec = load_surface(name) + for entry in (*spec.operators, *spec.url_options): + assert entry.status in set(VerificationStatus) + if entry.status is VerificationStatus.MEASURED: + assert entry.verified_on + assert entry.source + + +@pytest.mark.parametrize("name", _ALL_SURFACES) +def test_template_references_resolve(name): + """GUARD. Mutation caught: default_url/automation_url/date_parameter + pointing at nothing.""" + spec = load_surface(name) + assert spec.default_url in spec.urls + if spec.automation_url is not None: + template = spec.urls[spec.automation_url] + if template.date_parameter is not None: + assert any( + o.name == template.date_parameter and o.option == "date" + for o in spec.url_options + ) + + +@pytest.mark.parametrize("name", ["google", "bing", "indeed"]) +def test_automation_date_codes_cover_every_instruction_range(name): + """GUARD (byte identity depends on it). Mutation caught: a date_range + value SearchInstruction allows missing from the catalog.""" + spec = load_surface(name) + template = spec.urls[spec.automation_url] + option = next( + o + for o in spec.url_options + if o.name == template.date_parameter and o.option == "date" + ) + assert {"hour", "day", "week", "month", "year"} <= set(option.codes) + + +@pytest.mark.parametrize("name", _ALL_SURFACES) +def test_offered_operators_are_composable(name): + """GUARD (offered can never outrun composable). Mutation caught: an + offered operator the composer cannot emit.""" + spec = load_surface(name) + offered = { + VerificationStatus.VENDOR, + VerificationStatus.MEASURED, + VerificationStatus.THIRD_PARTY, + } + for op in spec.operators: + if op.composable and op.status in offered: + assert op.name in EXPRESSIBLE_OPERATORS + + +def test_list_surface_names_covers_the_five(): + """GUARD. Mutation caught: a bundled file never being listed.""" + names = list_surface_names() + for name in _ALL_SURFACES: + assert name in names + + +def test_automation_reads_the_bundled_catalog_only(tmp_path): + """GUARD (review ruling): a user override must never change what + automation sends. load_surface_bundled ignores the override even when + the merged load would apply it. Mutation caught: the automation read + consulting the user override directory.""" + bundled = tmp_path / "bundled" + user = tmp_path / "user" + _write(bundled, "mini", _MINI) + _write(user, "mini", _MINI.replace('"https://mini.example/s"', '"https://evil.example/s"')) + loader = SelectorLoader(bundled_dir=bundled, user_override_dir=user) + merged = load_surface("mini", loader=loader) + assert merged.urls["web"].base == "https://evil.example/s" + bundled_spec = load_surface_bundled("mini", loader=loader) + assert bundled_spec.urls["web"].base == "https://mini.example/s" From 1ca8ba995cd09817adfd8ec882eb9cf20cf26270 Mon Sep 17 00:00:00 2001 From: Nick Liebmann <53795413+Liebmann5@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:25:33 -0700 Subject: [PATCH 2/5] Query builder platforms: 34 hiring platforms as ATS descriptors, terminal pages excluded, company-page mining gated, builder fixes --- CHANGELOG.md | 35 ++++ .../auto_apply/docs/AA_ARCHITECTURE_BIBLE.md | 98 ++++++--- .../developer_guide/adding_an_ats_platform.md | 13 ++ .../verifying_platform_search.md | 92 ++++++++ packages/auto_apply/mkdocs.yml | 1 + .../secondary/discovery/ats_registry.py | 40 +++- .../application/services/query_builder.py | 89 ++++++++ .../workflows/applications_workflow.py | 19 ++ .../workflows/discovery_workflow.py | 23 +- .../domain/models/search_surface.py | 6 + .../src/auto_apply/domain/ports/ats_port.py | 118 +++++++++++ .../domain/services/query_composer.py | 107 ++++++---- .../infrastructure/composition_root.py | 52 ++--- .../src/auto_apply/resources/ats/adp.yaml | 16 ++ .../src/auto_apply/resources/ats/ashby.yaml | 7 + .../auto_apply/resources/ats/bamboohr.yaml | 15 ++ .../resources/ats/careers_page.yaml | 15 ++ .../src/auto_apply/resources/ats/catsone.yaml | 16 ++ .../src/auto_apply/resources/ats/comeet.yaml | 15 ++ .../src/auto_apply/resources/ats/crelate.yaml | 15 ++ .../auto_apply/resources/ats/dayforce.yaml | 15 ++ .../src/auto_apply/resources/ats/dover.yaml | 15 ++ .../auto_apply/resources/ats/eightfold.yaml | 15 ++ .../auto_apply/resources/ats/freshteam.yaml | 15 ++ .../src/auto_apply/resources/ats/gohire.yaml | 15 ++ .../auto_apply/resources/ats/greenhouse.yaml | 7 + .../src/auto_apply/resources/ats/gusto.yaml | 15 ++ .../src/auto_apply/resources/ats/homerun.yaml | 15 ++ .../src/auto_apply/resources/ats/icims.yaml | 7 + .../src/auto_apply/resources/ats/jazzhr.yaml | 21 ++ .../auto_apply/resources/ats/jobscore.yaml | 15 ++ .../src/auto_apply/resources/ats/jobvite.yaml | 15 ++ .../src/auto_apply/resources/ats/keka.yaml | 16 ++ .../src/auto_apply/resources/ats/lever.yaml | 7 + .../src/auto_apply/resources/ats/loxo.yaml | 16 ++ .../resources/ats/oasisrecruit.yaml | 15 ++ .../auto_apply/resources/ats/paylocity.yaml | 15 ++ .../auto_apply/resources/ats/pinpointhq.yaml | 15 ++ .../auto_apply/resources/ats/recruitee.yaml | 15 ++ .../resources/ats/recruiterflow.yaml | 16 ++ .../auto_apply/resources/ats/rippling.yaml | 15 ++ .../src/auto_apply/resources/ats/saashr.yaml | 15 ++ .../resources/ats/smartrecruiters.yaml | 15 ++ .../src/auto_apply/resources/ats/taleo.yaml | 7 + .../auto_apply/resources/ats/workable.yaml | 15 ++ .../src/auto_apply/resources/ats/workday.yaml | 7 + .../auto_apply/resources/ats/zohorecruit.yaml | 15 ++ .../search_surfaces/bing.surface.yaml | 4 + .../search_surfaces/google.surface.yaml | 2 +- .../test_query_builder_platforms.py | 166 +++++++++++++++ .../tests/domain/test_query_composer.py | 13 +- .../infrastructure/test_ats_platforms.py | 198 ++++++++++++++++++ .../tests/workflows/test_apply_route.py | 32 +++ .../workflows/test_discovery_workflow.py | 57 ++++- 54 files changed, 1515 insertions(+), 123 deletions(-) create mode 100644 packages/auto_apply/docs/developer_guide/verifying_platform_search.md create mode 100644 packages/auto_apply/src/auto_apply/resources/ats/adp.yaml create mode 100644 packages/auto_apply/src/auto_apply/resources/ats/bamboohr.yaml create mode 100644 packages/auto_apply/src/auto_apply/resources/ats/careers_page.yaml create mode 100644 packages/auto_apply/src/auto_apply/resources/ats/catsone.yaml create mode 100644 packages/auto_apply/src/auto_apply/resources/ats/comeet.yaml create mode 100644 packages/auto_apply/src/auto_apply/resources/ats/crelate.yaml create mode 100644 packages/auto_apply/src/auto_apply/resources/ats/dayforce.yaml create mode 100644 packages/auto_apply/src/auto_apply/resources/ats/dover.yaml create mode 100644 packages/auto_apply/src/auto_apply/resources/ats/eightfold.yaml create mode 100644 packages/auto_apply/src/auto_apply/resources/ats/freshteam.yaml create mode 100644 packages/auto_apply/src/auto_apply/resources/ats/gohire.yaml create mode 100644 packages/auto_apply/src/auto_apply/resources/ats/gusto.yaml create mode 100644 packages/auto_apply/src/auto_apply/resources/ats/homerun.yaml create mode 100644 packages/auto_apply/src/auto_apply/resources/ats/jazzhr.yaml create mode 100644 packages/auto_apply/src/auto_apply/resources/ats/jobscore.yaml create mode 100644 packages/auto_apply/src/auto_apply/resources/ats/jobvite.yaml create mode 100644 packages/auto_apply/src/auto_apply/resources/ats/keka.yaml create mode 100644 packages/auto_apply/src/auto_apply/resources/ats/loxo.yaml create mode 100644 packages/auto_apply/src/auto_apply/resources/ats/oasisrecruit.yaml create mode 100644 packages/auto_apply/src/auto_apply/resources/ats/paylocity.yaml create mode 100644 packages/auto_apply/src/auto_apply/resources/ats/pinpointhq.yaml create mode 100644 packages/auto_apply/src/auto_apply/resources/ats/recruitee.yaml create mode 100644 packages/auto_apply/src/auto_apply/resources/ats/recruiterflow.yaml create mode 100644 packages/auto_apply/src/auto_apply/resources/ats/rippling.yaml create mode 100644 packages/auto_apply/src/auto_apply/resources/ats/saashr.yaml create mode 100644 packages/auto_apply/src/auto_apply/resources/ats/smartrecruiters.yaml create mode 100644 packages/auto_apply/src/auto_apply/resources/ats/workable.yaml create mode 100644 packages/auto_apply/src/auto_apply/resources/ats/zohorecruit.yaml create mode 100644 packages/auto_apply/tests/application/test_query_builder_platforms.py create mode 100644 packages/auto_apply/tests/infrastructure/test_ats_platforms.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d933814..90395f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,41 @@ from its first tag onward. ### Added +- **ATS platform registry expansion + builder platform search.** 28 new + platform descriptors (BambooHR, Rippling, Workable, SmartRecruiters, + Jobvite, Recruitee, Gusto, PinpointHQ, Eightfold, Keka, Dover, Zoho + Recruit, JazzHR — covering theresumator.com and applytojob.com — GoHire, + Recruiterflow, Comeet, UKG/SaaShr, CATSone, ADP, Freshteam, + careers-page.com, Paylocity, Oasis Recruit, JobScore, Crelate, Homerun, + Loxo, Dayforce) join the existing six. Every descriptor records its + tenancy, a `site:` search restriction with an evidence status + (vendor / measured / third_party / unverified, source and date), and + terminal-page patterns where a sample proves them. The query builder can + now target a platform (`build_platform_search`) or a pasted company URL + (`build_company_search`) with a correct domain-restricted engine query, + and terminal pages are never treated as application destinations. +- **A verification checklist for platform search data** + (`docs/developer_guide/verifying_platform_search.md`): the exact query to + paste per platform, what a correct result looks like, and how to record + the outcome so the descriptor's status upgrades. + +### Changed + +- **Builder correctness.** A domain-restricted Bing search now composes + against web results, not the jobs vertical; empty parameters (`l=`) are + no longer emitted; refusal notes say when no link was written. +- **Company-page mining is gated off by default** on + `SessionPlan.enable_company_page_mining`. The old path failed on every + ATS-matched job (a URL was passed where a browser was expected) and mined + nothing; when enabled, mining uses the navigating careers-page scraper. + +### Fixed + +- **A restriction the surface cannot express no longer produces a link that + silently drops it.** The composer refuses the link as well as the search + text, with an explanatory note (was: link returned with the restriction + dropped). Automation URLs are unchanged. + - **Download-and-double-click install.** `INSTALL-WINDOWS.bat`, `INSTALL-MAC.command` and `INSTALL-LINUX.sh` at the top of the repository run the bootstrap from an extracted ZIP, with a plain-language plan, a diff --git a/packages/auto_apply/docs/AA_ARCHITECTURE_BIBLE.md b/packages/auto_apply/docs/AA_ARCHITECTURE_BIBLE.md index e3c63d7..e5cd307 100644 --- a/packages/auto_apply/docs/AA_ARCHITECTURE_BIBLE.md +++ b/packages/auto_apply/docs/AA_ARCHITECTURE_BIBLE.md @@ -23,6 +23,11 @@ audience: contributors > > Chains A–E landed between 2026-09-15 and 2026-09-16. Stage U5 was planned as > a deletion and is **cancelled** — see the note in §34.3. +> +> **1.2 (2026-10-28)** — §15 rewritten for the platform expansion: 28 +> platforms added to the ATS registry, each descriptor now carrying tenancy, +> a search restriction with evidence status, and terminal-page patterns; the +> registry is the single source of platform identity for the query builder. > This document is the single authoritative reference for every architectural decision, > layer boundary, subsystem contract, control flow, and integration rule in AA. @@ -1335,43 +1340,74 @@ in `resources/ats/`. | iCIMS | `icims.yaml` | `careers.icims.com` | Multi-step, legacy | | Taleo | `taleo.yaml` | `*.taleo.net` | Very legacy, complex | | Ashby | `ashby.yaml` | `jobs.ashbyhq.com` | Modern, clean | - -### 15.3 Platforms To Add (From Job Websites Table) - -Based on the user's job website table, these need YAML files and adapters: - -| Platform | Domain | Priority | -|---|---|---| -| BambooHR | `bamboohr.com/careers` | High | -| Rippling | `ats.rippling.com` | High | -| Workable | `apply.workable.com` | High | -| SmartRecruiters | `jobs.smartrecruiters.com` | High | -| Jobvite | `jobs.jobvite.com` | High | -| Recruitee | `*.recruitee.com` | Medium | -| Gusto | `jobs.gusto.com` | Medium | -| PinpointHQ | `*.pinpointhq.com` | Medium | -| Eightfold | `*.eightfold.ai/careers` | Medium | -| Keka | `*.keka.com/careers` | Low | -| Dover | `app.dover.io/apply` | Low | -| ZohoRecruit | `*.zohorecruit.com/jobs` | Low | +| BambooHR | `bamboohr.yaml` | `*.bamboohr.com/careers` | Unverified selectors | +| Rippling | `rippling.yaml` | `ats.rippling.com/*/jobs/*` | Unverified selectors | +| Workable | `workable.yaml` | `apply.workable.com/*` | Unverified selectors | +| SmartRecruiters | `smartrecruiters.yaml` | `jobs.smartrecruiters.com/*/*` | Unverified selectors | +| Jobvite | `jobvite.yaml` | `jobs.jobvite.com/*/job/*` | Unverified selectors | +| Recruitee | `recruitee.yaml` | `*.recruitee.com/o/*` | Unverified selectors | +| Gusto | `gusto.yaml` | `jobs.gusto.com/boards/*` | Unverified selectors | +| PinpointHQ | `pinpointhq.yaml` | `*.pinpointhq.com/postings/*` | Unverified selectors | +| Eightfold | `eightfold.yaml` | `*.eightfold.ai/careers*` | Unverified selectors | +| Keka | `keka.yaml` | `*.keka.com/careers*` | Unverified selectors | +| Dover | `dover.yaml` | `app.dover.io/apply/*` | Unverified selectors | +| Zoho Recruit | `zohorecruit.yaml` | `*.zohorecruit.com/jobs/*` | Unverified selectors | +| JazzHR | `jazzhr.yaml` | `*.applytojob.com/*`, `*.theresumator.com/*` | Unverified selectors | +| GoHire | `gohire.yaml` | `jobs.gohire.io/*/*` | Unverified selectors | +| Recruiterflow | `recruiterflow.yaml` | `recruiterflow.com/db_*/jobs/*` | Unverified selectors | +| Comeet | `comeet.yaml` | `www.comeet.com/jobs/*` | Unverified selectors | +| UKG / SaaShr | `saashr.yaml` | `*.saashr.com/ta/*` | Unverified selectors | +| CATSone | `catsone.yaml` | `*.catsone.com/careers*` | Unverified selectors | +| ADP | `adp.yaml` | `workforcenow.adp.com/*` | Unverified selectors | +| Freshteam | `freshteam.yaml` | `*.freshteam.com/jobs/*` | Unverified selectors | +| careers-page.com | `careers_page.yaml` | `www.careers-page.com/*` | Unverified selectors | +| Paylocity | `paylocity.yaml` | `recruiting.paylocity.com/Recruiting/Jobs/*` | Unverified selectors | +| Oasis Recruit | `oasisrecruit.yaml` | `*.oasisrecruit.com/job/*` | Unverified selectors | +| JobScore | `jobscore.yaml` | `careers.jobscore.com/careers/*` | Unverified selectors | +| Crelate | `crelate.yaml` | `jobs.crelate.com/portal/*` | Unverified selectors | +| Homerun | `homerun.yaml` | `*.homerun.co/*` | Unverified selectors | +| Loxo | `loxo.yaml` | `app.loxo.co/job/*` | Unverified selectors | +| Dayforce | `dayforce.yaml` | `jobs.dayforcehcm.com/*` | Unverified selectors | + +New-platform descriptors ship with empty form selectors, so they classify +pages for discovery, routing and research grouping but never receive the +`KNOWN_PLATFORM` analysis tier (that tier requires a `submit_button_selector`, +which is only written after a hand-verified apply run — see +`docs/developer_guide/verifying_platform_search.md`). + +### 15.3 Platforms Added (2026-10-28) + +The twelve platforms above that this section once listed as "to add" are now +registered — see §15.2. Sixteen more from the job-website table joined them +(JazzHR covering both theresumator.com and applytojob.com, GoHire, +Recruiterflow, Comeet, UKG/SaaShr, CATSone, ADP, Freshteam, careers-page.com, +Paylocity, Oasis Recruit, JobScore, Crelate, Homerun, Loxo, Dayforce). Every +descriptor records its tenancy, its `site:` search restriction with an +evidence status (`unverified` until hand-measured), and terminal-page +patterns where a sample URL proves them. New platforms are verified by hand +per `docs/developer_guide/verifying_platform_search.md`, not by assumption. ### 15.4 How to Add a New ATS Platform -1. Create `resources/ats/platform_name.yaml`: +1. Create `resources/ats/platform_name.yaml` (the real, current schema): ```yaml name: bamboohr -display_name: BambooHR +description: "BambooHR — subdomain tenancy (.bamboohr.com/careers)." url_patterns: - - "*.bamboohr.com/careers/*" - - "*.bamboohr.com/jobs/*" -site_search_domain: "bamboohr.com/careers" -form_strategy: "single_page_iframe" -known_challenges: - - type: "file_upload" - selector: "input[type=file][name=resume]" - - type: "cover_letter" - selector: "textarea[name=coverLetter]" -pagination: null + - "*.bamboohr.com/careers*" +hosts: + - "bamboohr.com" +login_wall_signals: [] +success_signals: [] +form_root_selector: "" +submit_button_selector: "" +multi_step: false +tenancy: subdomain +search_restriction: "bamboohr.com/careers" +search_restriction_status: unverified +search_restriction_verified_on: "" +search_restriction_source: "Host from a sample posting URL; not yet hand-measured" +terminal_patterns: [] ``` 2. ATSRegistry automatically loads it at startup — no code changes needed. diff --git a/packages/auto_apply/docs/developer_guide/adding_an_ats_platform.md b/packages/auto_apply/docs/developer_guide/adding_an_ats_platform.md index 3393cfc..f8c3540 100644 --- a/packages/auto_apply/docs/developer_guide/adding_an_ats_platform.md +++ b/packages/auto_apply/docs/developer_guide/adding_an_ats_platform.md @@ -77,6 +77,13 @@ submit_button_selector: > # CSS selector for the submit / nex input[type='submit'] multi_step: false # true if the platform uses a multi‑page wizard + +tenancy: subdomain # how tenants are addressed (see the table above) +search_restriction: "example.com/jobs" # the site: restriction for engine searches +search_restriction_status: unverified # upgrade per docs/developer_guide/verifying_platform_search.md +search_restriction_verified_on: "" +search_restriction_source: "Host from a sample posting URL; not yet hand-measured" +terminal_patterns: [] # e.g. ["*.example.com/*/apply/success*"] ``` ### Field details @@ -91,6 +98,12 @@ multi_step: false # true if the platform uses a multi‑page | `form_root_selector` | No | CSS selector for the form container. Defaults to `"form"`. Used to scope field searches. | | `submit_button_selector` | No | CSS selector for the submit or next button. Defaults to `"button[type='submit']"`. | | `multi_step` | No | Set to `true` if the platform splits the application across multiple pages (like Workday or Taleo). | +| `tenancy` | No | How tenants are addressed: `subdomain` (`acme.platform.com`), `path` (`platform.com/acme`), `opaque` (a path slug that is an ID, not a company name), `enterprise` (client identified by query/deep path only), or `none`. Used by the query builder to scope company searches. | +| `search_restriction` | No | The `site:` restriction that scopes an engine search to this platform — a host, or host/path when the path is shared by every tenant (e.g. `bamboohr.com/careers`). | +| `search_restriction_status` | No | Evidence for the restriction: `vendor`, `measured`, `third_party`, or `unverified` (the default). | +| `search_restriction_verified_on` | No | Date the restriction was hand-measured. Required when the status is `measured`. | +| `search_restriction_source` | No | One line saying where the restriction came from (a sample URL, a hand-run query). | +| `terminal_patterns` | No | Glob patterns for terminal pages (success / confirm / closed). A matched terminal page is never treated as an application destination. See `docs/developer_guide/verifying_platform_search.md` for how these get verified. | --- diff --git a/packages/auto_apply/docs/developer_guide/verifying_platform_search.md b/packages/auto_apply/docs/developer_guide/verifying_platform_search.md new file mode 100644 index 0000000..c4c989d --- /dev/null +++ b/packages/auto_apply/docs/developer_guide/verifying_platform_search.md @@ -0,0 +1,92 @@ +--- +title: "Verifying Platform Search Data" +status: needs-review +last_verified: 2026-10-28 +verified_against: "resources/ats/*.yaml as of the query-builder platform expansion" +audience: contributors +--- + +# Verifying Platform Search Data + +Every platform descriptor in `resources/ats/` carries a `search_restriction` +with a status — `vendor`, `measured`, `third_party`, or `unverified` — a +source, and a date when measured. Most start `unverified`. This checklist is +how a person upgrades one, by hand, in a browser. Nothing in AA automates this. + +## How to record an outcome + +In the platform's YAML file: + +- The query works as written → set `search_restriction_status: measured`, + set `search_restriction_verified_on: ""`, and set + `search_restriction_source` to one line saying what you did + (e.g. `"Hand-run on Google, 2026-11-02: results were all Jobvite postings"`). +- The query works only in a different form → fix `search_restriction` to the + form that worked, keep the status `third_party` until a second run confirms + it, and note the old form in the source line. +- 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. + +## Two measurements that apply to many platforms + +1. **Subdomain tenancy + shared path.** For platforms like BambooHR + (`.bamboohr.com/careers`), check whether + `site:bamboohr.com/careers ("Software Engineer")` on Google actually covers + the tenant subdomains, or whether `site:bamboohr.com inurl:careers ("Software Engineer")` + is required. Record which form worked in the descriptor. +2. **Bing's two-level limit.** Bing documents `site:` as working only to two + directory levels. For restrictions with a path (`jobs.gusto.com/boards`), + paste the same query into Bing and check whether results come back at all; + if not, that platform's Bing searches stay host-only. + +## Per-platform checklist + +For each platform: paste the Google query, then the Bing query. A correct +result shows only postings from that platform's hosts. + +| Platform | Paste into Google | Paste into Bing | +|---|---|---| +| greenhouse | `site:boards.greenhouse.io ("Software Engineer")` | same, on bing.com (web results) | +| lever | `site:jobs.lever.co ("Software Engineer")` | same | +| workday | `site:myworkdayjobs.com ("Software Engineer")` | same | +| icims | `site:icims.com ("Software Engineer")` | same | +| taleo | `site:taleo.net ("Software Engineer")` | same | +| ashby | `site:jobs.ashbyhq.com ("Software Engineer")` | same | +| bamboohr | `site:bamboohr.com/careers ("Software Engineer")` + the `inurl:careers` variant | host-only variant | +| rippling | `site:ats.rippling.com ("Software Engineer")` | same | +| workable | `site:apply.workable.com ("Software Engineer")` | same | +| smartrecruiters | `site:jobs.smartrecruiters.com ("Software Engineer")` | same | +| jobvite | `site:jobs.jobvite.com ("Software Engineer")` — measured (reported 2026-10-27) | same | +| recruitee | `site:recruitee.com ("Software Engineer")` | same | +| gusto | `site:jobs.gusto.com/boards ("Software Engineer")` | host-only variant | +| pinpointhq | `site:pinpointhq.com ("Software Engineer")` | same | +| eightfold | `site:eightfold.ai ("Software Engineer")` | same | +| keka | `site:keka.com ("Software Engineer")` | same | +| dover | `site:app.dover.io ("Software Engineer")` | same | +| zohorecruit | `site:zohorecruit.com ("Software Engineer")` | same | +| jazzhr | `site:applytojob.com ("Software Engineer")`; repeat with `site:theresumator.com` | same | +| gohire | `site:jobs.gohire.io ("Software Engineer")` | same | +| recruiterflow | `site:recruiterflow.com ("Software Engineer")` | same | +| comeet | `site:comeet.com ("Software Engineer")` | same | +| saashr | `site:saashr.com ("Software Engineer")` | same | +| catsone | `site:catsone.com ("Software Engineer")` | same | +| adp | `site:workforcenow.adp.com ("Software Engineer")` | same | +| freshteam | `site:freshteam.com ("Software Engineer")` | same | +| careers-page | `site:careers-page.com ("Software Engineer")` | same | +| paylocity | `site:recruiting.paylocity.com ("Software Engineer")` | same | +| oasisrecruit | `site:oasisrecruit.com ("Software Engineer")` | same | +| jobscore | `site:careers.jobscore.com ("Software Engineer")` | same | +| crelate | `site:jobs.crelate.com ("Software Engineer")` | same | +| homerun | `site:homerun.co ("Software Engineer")` | same | +| loxo | `site:app.loxo.co ("Software Engineer")` | same | +| dayforce | `site:jobs.dayforcehcm.com ("Software Engineer")` | same | + +## Not platforms (recorded so the absence is deliberate) + +- **AP Recruit** (`aprecruit.berkeley.edu`) — one institution's own portal, + not an ATS. A `site:aprecruit.berkeley.edu` engine query works without a + descriptor; none is registered. +- **gitjobs.dev, techjobsforgood.com** — job boards, not ATS platforms. If + their search URLs are verified, they become catalog surfaces + (`resources/search_surfaces/`), not ATS descriptors. +- **Bing jobs vertical** — already a catalog surface (`bing.surface.yaml`). diff --git a/packages/auto_apply/mkdocs.yml b/packages/auto_apply/mkdocs.yml index a52f257..70454c5 100644 --- a/packages/auto_apply/mkdocs.yml +++ b/packages/auto_apply/mkdocs.yml @@ -97,6 +97,7 @@ nav: - Running Tests: developer_guide/running_tests.md - Contribution Workflow: developer_guide/contribution_workflow.md - Adding an ATS Platform: developer_guide/adding_an_ats_platform.md + - Verifying Platform Search: developer_guide/verifying_platform_search.md - Documentation Standards: DOCUMENTATION_STANDARDS.md - Releasing: developer_guide/releasing.md diff --git a/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/ats_registry.py b/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/ats_registry.py index 94076ef..e36340a 100644 --- a/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/ats_registry.py +++ b/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/ats_registry.py @@ -24,7 +24,7 @@ import logging import re -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from auto_apply.domain.ports.ats_port import ATSDescriptor @@ -57,6 +57,7 @@ class _CompiledEntry: descriptor: ATSDescriptor patterns: list[re.Pattern[str]] + terminal_patterns: list[re.Pattern[str]] = field(default_factory=list) class ATSRegistry: @@ -128,6 +129,21 @@ def all_descriptors(self) -> list[ATSDescriptor]: """Returns every loaded ATSDescriptor, in load order.""" return [e.descriptor for e in self._entries] + def is_terminal_page(self, url: str) -> bool: + """True when *url* matches any descriptor's terminal_patterns. + + Terminal pages (success / confirm / closed) are never apply-route + destinations and never builder suggestions. Classification via + match() is unaffected — a terminal page still belongs to its platform + for metadata and research grouping. + """ + normalised = _strip_scheme(url) + for entry in self._entries: + for pat in entry.terminal_patterns: + if pat.search(normalised): + return True + return False + def __len__(self) -> int: return len(self._entries) @@ -202,12 +218,16 @@ def _load_file(self, path: Path) -> _CompiledEntry | None: ) return None + terminal = _compile_patterns(descriptor.terminal_patterns) logger.debug( - "ATSRegistry: loaded ats=%s patterns=%d", + "ATSRegistry: loaded ats=%s patterns=%d terminal=%d", descriptor.name, len(patterns), + len(terminal), + ) + return _CompiledEntry( + descriptor=descriptor, patterns=patterns, terminal_patterns=terminal ) - return _CompiledEntry(descriptor=descriptor, patterns=patterns) # ───────────────────────────────────────────────────────────────────────────── @@ -231,6 +251,8 @@ def _build_descriptor(data: dict, filename: str) -> ATSDescriptor: submit_selector = _normalise_selector(data.get("submit_button_selector", "")) form_selector = _normalise_selector(data.get("form_root_selector", "")) + raw_terminal = data.get("terminal_patterns") or [] + return ATSDescriptor( name=name, url_patterns=tuple(str(p).strip() for p in raw_patterns if p), @@ -242,6 +264,18 @@ def _build_descriptor(data: dict, filename: str) -> ATSDescriptor: hosts=tuple( str(h).strip().lower().strip(".") for h in raw_hosts if str(h).strip(" .") ), + tenancy=str(data.get("tenancy") or "none").strip().lower(), + search_restriction=str(data.get("search_restriction") or "").strip(), + search_restriction_status=str( + data.get("search_restriction_status") or "unverified" + ).strip(), + search_restriction_verified_on=str( + data.get("search_restriction_verified_on") or "" + ).strip(), + search_restriction_source=str( + data.get("search_restriction_source") or "" + ).strip(), + terminal_patterns=tuple(str(p).strip() for p in raw_terminal if p), ) 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 0a628dc..69fd528 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 @@ -48,10 +48,12 @@ BuilderDefaults, OfferedCapability, OfferedChoice, + QueryNote, QueryRequest, QueryResult, SurfaceOffer, ) +from auto_apply.domain.ports.ats_port import ATSDescriptor, ATSRegistryPort from auto_apply.domain.models.search_surface import ( OFFERED_STATUSES, SearchSurfaceSpec, @@ -77,12 +79,14 @@ def __init__( profile=None, load_spec: Callable[[str], SearchSurfaceSpec] | None = None, list_names: Callable[[], list[str]] | None = None, + ats_registry: ATSRegistryPort | None = None, recent_path: Path = DEFAULT_RECENT_PATH, max_recent: int = DEFAULT_MAX_RECENT, ) -> None: self._profile = profile self._load_spec = load_spec self._list_names = list_names + self._ats_registry = ats_registry self._recent_path = recent_path self._max_recent = max(1, int(max_recent)) @@ -171,6 +175,91 @@ def build(self, request: QueryRequest) -> QueryResult: spec = self._load_spec(request.surface) return compose(request, spec) + # ------------------------------------------------------------------ + # Platform searches — platform identity comes from the injected ATS + # registry; the engine's site: syntax comes from the catalog. Nothing + # here imports the registry adapter. + # ------------------------------------------------------------------ + + def platforms(self) -> tuple[ATSDescriptor, ...]: + """The hiring platforms the builder can target: every descriptor + with at least one host. Search reliability is descriptor data + (search_restriction_status), shown by the surfaces — not an offer + gate: the capability being offered is the engine's vendor-documented + site: operator applied to a host that demonstrably exists.""" + if self._ats_registry is None: + return () + return tuple(d for d in self._ats_registry.all_descriptors() if d.hosts) + + def build_platform_search( + self, platform: str, request: QueryRequest + ) -> QueryResult: + """A domain-restricted engine search for one platform. + + The restriction is composed through the normal composer, so the + same refusals, notes and length limits apply. Writes nothing. + """ + descriptor = self._descriptor_for(platform) + if request.site: + raise QueryBuilderError( + "the request already carries a site restriction" + ) + restricted = request.model_copy( + update={"site": descriptor.search_restriction_for()} + ) + return self.build(restricted) + + def build_company_search(self, url: str, request: QueryRequest) -> QueryResult: + """A company-scoped engine search from a pasted tenant URL. + + The URL's platform is matched and its tenant slug narrows the + restriction (holisticai.bamboohr.com, jobs.lever.co/acme, + recruiterflow.com/db_). A URL on no known platform gets a refusal + note, never a guess. Writes nothing. + """ + if self._ats_registry is None: + raise QueryBuilderError( + "no ATS registry is wired into the query builder" + ) + descriptor = self._ats_registry.match(url) + if descriptor is None: + return QueryResult( + surface=request.surface, + display_name=request.surface, + bar_text=None, + url=None, + notes=( + QueryNote( + severity="refused", + topic="platform", + message=( + "that address is not on a hiring platform AA " + "knows, so no search was built." + ), + ), + ), + ) + if request.site: + raise QueryBuilderError( + "the request already carries a site restriction" + ) + restricted = request.model_copy( + update={"site": descriptor.search_restriction_for(url)} + ) + return self.build(restricted) + + def _descriptor_for(self, platform: str) -> ATSDescriptor: + """One descriptor by name, or a loud error — a quiet empty result + for an unknown platform would be a different-search lie.""" + if self._ats_registry is None: + raise QueryBuilderError( + "no ATS registry is wired into the query builder" + ) + for descriptor in self._ats_registry.all_descriptors(): + if descriptor.name == platform: + return descriptor + raise QueryBuilderError(f"unknown platform {platform!r}") + def remember(self, request: QueryRequest) -> None: """Record a query as one of the recent few. diff --git a/packages/auto_apply/src/auto_apply/application/workflows/applications_workflow.py b/packages/auto_apply/src/auto_apply/application/workflows/applications_workflow.py index 642f761..02f9b94 100644 --- a/packages/auto_apply/src/auto_apply/application/workflows/applications_workflow.py +++ b/packages/auto_apply/src/auto_apply/application/workflows/applications_workflow.py @@ -799,6 +799,25 @@ def _match_landed_ats( descriptor = None if descriptor is None: return evidence, False + # A terminal page (success / confirm / closed) is never a route + # destination: a descriptor can match it by host while the page + # carries no form to fill. The route continues to the honest no-form + # outcome instead of stopping on a confirmation page. The `is True` + # (not truthiness) is deliberate: a mock registry returns a truthy + # MagicMock for the method, and only a real terminal verdict may + # change routing. + try: + terminal = self._ats_registry.is_terminal_page(url) is True + except AttributeError: + terminal = False + if terminal: + logger.info( + "ApplicationsWorkflow: landed URL is a terminal page on %s — " + "not a route stop | url=%s", + descriptor.name, + url, + ) + return evidence, False if hasattr(job, "metadata"): job.metadata["ats"] = descriptor.name return evidence.model_copy(update={"ats_platform": descriptor.name}), True 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 2604238..c87c8e7 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 @@ -88,7 +88,13 @@ def __init__( dedup: DeduplicationManager — URL fingerprinting and seen-URL tracking. text_matcher: TextMatcher — entity extraction for pre-filtering. ats_registry: ATSRegistry | None — ATS platform detection. - company_page_miner: Callable | None — direct company page scraper. + company_page_miner: Deprecated and unused. The previous mining + call passed a URL into a parameter that composition_root filled + with a function expecting a browser, and failed on every + ATS-matched job; mining now goes through company_page_scraper + under the SessionPlan.enable_company_page_mining gate. The + parameter remains accepted for one release; the automated- + search chain removes it. company_page_scraper: Callable[[str], list[Job]] | None — given a single careers‑page URL, returns the jobs found on it. Used by :meth:`discover_company_page` to service DISCOVER_COMPANY tasks. @@ -314,7 +320,18 @@ def _run_provider(provider, instr: SearchInstruction) -> list[Job]: return all_jobs def _scrape_company_pages(self, discovered_jobs: list[Job]) -> list[Job]: - if self._company_page_miner is None: + """Optionally mine company careers pages for ATS-matched discoveries. + + Off by default: SessionPlan.enable_company_page_mining is False + unless a plan sets it, and no config currently sets it. The previous + path called a composition-root helper that treated the job URL as a + browser object — a measured AttributeError on every ATS-matched job + that mined nothing, ever. When enabled, mining goes through the + careers-page scraper, which actually navigates to the URL. + """ + if not self._plan.enable_company_page_mining: + return [] + if self._company_page_scraper is None: return [] extra: list[Job] = [] for job in discovered_jobs: @@ -324,7 +341,7 @@ def _scrape_company_pages(self, discovered_jobs: list[Job]) -> list[Job]: descriptor = self._ats_registry.match(job.url) if descriptor is None: continue - mined = self._company_page_miner(job.url) + mined = self._company_page_scraper(job.url) if mined: extra.extend(mined) except Exception as exc: diff --git a/packages/auto_apply/src/auto_apply/domain/models/search_surface.py b/packages/auto_apply/src/auto_apply/domain/models/search_surface.py index fbe8043..81b3d25 100644 --- a/packages/auto_apply/src/auto_apply/domain/models/search_surface.py +++ b/packages/auto_apply/src/auto_apply/domain/models/search_surface.py @@ -159,6 +159,10 @@ class SearchSurfaceSpec(_Frozen): urls: dict[str, UrlTemplateSpec] = Field(default_factory=dict) default_url: str | None = None automation_url: str | None = None + # The template to use when a request carries a site: restriction. A + # domain-restricted search is a web search, not a vertical search (Bing: + # web, not jobs). Automation never reads this — it uses automation_url. + site_template: str | None = None operators: tuple[OperatorSpec, ...] = () url_options: tuple[UrlOptionSpec, ...] = () @@ -168,6 +172,8 @@ def _references_resolve(self) -> "SearchSurfaceSpec": raise ValueError(f"{self.name}: default_url {self.default_url!r} is not a url template") if self.automation_url is not None and self.automation_url not in self.urls: raise ValueError(f"{self.name}: automation_url {self.automation_url!r} is not a url template") + if self.site_template is not None and self.site_template not in self.urls: + raise ValueError(f"{self.name}: site_template {self.site_template!r} is not a url template") for key, template in self.urls.items(): if template.date_parameter is not None and not any( o.name == template.date_parameter and o.option == "date" diff --git a/packages/auto_apply/src/auto_apply/domain/ports/ats_port.py b/packages/auto_apply/src/auto_apply/domain/ports/ats_port.py index ae5e3ba..8d8f34f 100644 --- a/packages/auto_apply/src/auto_apply/domain/ports/ats_port.py +++ b/packages/auto_apply/src/auto_apply/domain/ports/ats_port.py @@ -12,6 +12,7 @@ from dataclasses import dataclass from typing import Protocol, runtime_checkable +from urllib.parse import urlsplit @runtime_checkable @@ -25,6 +26,16 @@ def match(self, url: str) -> ATSDescriptor | None: """Return the ATSDescriptor whose url_patterns match *url*, if any.""" ... + def all_descriptors(self) -> list["ATSDescriptor"]: + """Return every loaded descriptor, in load order.""" + ... + + def is_terminal_page(self, url: str) -> bool: + """True when *url* matches a terminal pattern (success / confirm / + closed) of any descriptor. Terminal pages are never apply-route + destinations and never builder suggestions.""" + ... + @dataclass(frozen=True) class ATSDescriptor: @@ -61,6 +72,25 @@ class ATSDescriptor: submit_button_selector: str multi_step: bool hosts: tuple[str, ...] = () + # ── Search and routing data (query-builder chain, call 2) ──────────── + # tenancy: how tenants are addressed — "subdomain" (acme.platform.com), + # "path" (platform.com/acme), "opaque" (a path slug that is an ID, not a + # name), "enterprise" (client identified by query/deep path only), or + # "none" (no per-tenant addressing). + tenancy: str = "none" + # The site: restriction that scopes an engine search to this platform — + # host, or host/path where the path is shared by every tenant + # (e.g. "bamboohr.com/careers", "jobs.gusto.com/boards"). Empty when the + # platform has no usable restriction. + search_restriction: str = "" + # Evidence about the restriction: vendor | measured | third_party | + # unverified — the same vocabulary as the search-surface catalog. + search_restriction_status: str = "unverified" + search_restriction_verified_on: str = "" + search_restriction_source: str = "" + # URL shapes that are terminal pages (success / confirm / closed): never + # an apply-route destination, never a builder suggestion. + terminal_patterns: tuple[str, ...] = () def __repr__(self) -> str: return ( @@ -69,6 +99,94 @@ def __repr__(self) -> str: f"patterns={len(self.url_patterns)})" ) + # ── Tenancy and search restrictions (pure: no I/O, no clock) ────────── + + @property + def search_host(self) -> str: + """The bare host of the search restriction (or of the first host).""" + base = self.search_restriction or (self.hosts[0] if self.hosts else "") + return base.split("/", 1)[0].lower() + + def _path_prefix_segments(self) -> list[str]: + """The static path segments of the first url_pattern, up to the + first wildcard — the part of the path BEFORE the tenant slug + ("boards" in "jobs.gusto.com/boards/*").""" + if not self.url_patterns or "/" not in self.url_patterns[0]: + return [] + segments: list[str] = [] + for segment in self.url_patterns[0].split("/", 1)[1].split("/"): + if "*" in segment: + break + if segment: + segments.append(segment.lower()) + return segments + + def tenant_slug(self, url: str) -> str | None: + """The tenant identifier for *url*, or None when there is none. + + subdomain: the label(s) before this platform's host + ("holisticai" in "holisticai.bamboohr.com"). + path / opaque: the path segment right after the pattern's static + prefix ("ziffdavis" in "jobs.jobvite.com/ziffdavis/job/123"; + "db_cae6…" on recruiterflow — opaque is the same shape with an + ID instead of a company name). + None for "none"/"enterprise" tenancy, an unrelated URL, or a URL + with no tenant segment. + """ + base_host = self.search_host + if not base_host: + return None + try: + parts = urlsplit(url if "://" in url else f"https://{url}") + except ValueError: + return None + host = parts.netloc.lower().split("@")[-1].split(":")[0] + if not host or not (host == base_host or host.endswith("." + base_host)): + return None + if self.tenancy == "subdomain": + if host == base_host: + return None + return host[: -len(base_host)].rstrip(".") or None + if self.tenancy in ("path", "opaque"): + prefix = self._path_prefix_segments() + segments = [s for s in parts.path.lower().split("/") if s] + if segments[: len(prefix)] != prefix: + return None + remaining = segments[len(prefix):] + return remaining[0] if remaining else None + return None + + def search_restriction_for(self, url: str | None = None) -> str: + """The site: restriction for this platform — its shared restriction + by default, narrowed to one tenant when *url* is one of its tenant + pages ("holisticai.bamboohr.com/careers", "jobs.lever.co/acme"). + Empty when the platform declares no hosts.""" + base = self.search_restriction or (self.hosts[0] if self.hosts else "") + if not base or url is None: + return base + slug = self.tenant_slug(url) + if not slug: + return base + if self.tenancy == "subdomain": + try: + host = urlsplit(url if "://" in url else f"https://{url}").netloc.lower() + except ValueError: + return base + host = host.split("@")[-1].split(":")[0] + if not host: + return base + suffix = ( + self.search_restriction.split("/", 1)[1] + if "/" in self.search_restriction + else "" + ) + return f"{host}/{suffix}" if suffix else host + if self.tenancy in ("path", "opaque"): + prefix = "/".join(self._path_prefix_segments()) + host0 = self.search_host + return f"{host0}/{prefix}/{slug}" if prefix else f"{host0}/{slug}" + return base + @runtime_checkable class ATSPort(Protocol): 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 c662c3e..d27ef3a 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 @@ -121,8 +121,8 @@ def _compose_bar( ops: dict[str, OperatorSpec], ) -> tuple[str | None, list[QueryNote], bool]: """Returns (bar_text, notes, hard_stop). hard_stop means the URL must not - be built either (a length refusal). A content refusal stops the bar but - lets the link carry what the surface honestly can.""" + be built either: a length refusal, or a restriction the surface cannot + express — a link without it would be a different search.""" tq = spec.text_query notes: list[QueryNote] = [] @@ -130,27 +130,27 @@ def _compose_bar( return None, [_refused( "site", f"{spec.display_name} has no way to limit a typed search to one " - "site, so no search text was written — writing one without the " - "site limit would not be the search you asked for.", - )], False + "site, so no search text or link was written — one without the " + "site limit would be a different search.", + )], True if request.any_of and ("any_of" not in ops or not tq.or_token): return None, [_refused( "any_of", f"{spec.display_name} has no way to say 'any of these' in its " - "search bar, so no search text was written.", - )], False + "search bar, so no search text or link was written.", + )], True if request.exclude and "exclude" not in ops: return None, [_refused( "exclude", f"{spec.display_name} has no way to exclude words in its search " - "bar, so no search text was written.", - )], False + "bar, so no search text or link was written.", + )], True if request.in_title and "intitle" not in ops: return None, [_refused( "intitle", f"{spec.display_name} has no title filter in its search bar, so " - "no search text was written.", - )], False + "no search text or link was written.", + )], True quote = tq.phrase_quote subject_bits: list[str] = [] @@ -217,7 +217,16 @@ def _compose_url( offered_options: dict[str, UrlOptionSpec], ) -> tuple[str | None, list[QueryNote]]: notes: list[QueryNote] = [] - template_name = request.template or spec.default_url or next(iter(spec.urls), None) + # A site: restriction is a web search, not a vertical search: when the + # request carries one, the surface's site template (Bing: web, not the + # jobs vertical) wins over the surface default. Automation reads only + # automation_url and never reaches this path. + template_name = ( + request.template + or (spec.site_template if request.site else None) + or spec.default_url + or next(iter(spec.urls), None) + ) if template_name is None: return None, notes template = spec.urls.get(template_name) @@ -231,30 +240,16 @@ def _compose_url( if bar is not None: query_value = bar else: - # No bar (refused or never writable): the link carries the best form - # the surface honestly offers, and every dropped part is named. + # No bar because the surface has no typed form: the link carries the + # terms in the plain form the surface accepts. bits: list[str] = [] quote = spec.text_query.phrase_quote for phrase in request.phrases: content = _clean_content(phrase) bits.append(f"{quote}{content}{quote}" if "phrase" in ops else content) bits.extend(normalize_confusables(t) for t in request.required) - dropped: list[str] = [] - if request.any_of: - dropped.append("the 'any of these' part") - if request.site: - dropped.append("the site limit") - if request.exclude: - dropped.append("the exclusions") - if request.in_title: - dropped.append("the title filter") - if dropped: - notes.append(_info( - "dropped", - f"Only some of your search has a form on {spec.display_name}; " - f"{', '.join(dropped)} " - f"{'was' if len(dropped) == 1 else 'were'} left out of the link.", - )) + # Every restriction a surface cannot carry stops the link in + # compose() (refusal-both), so nothing is ever dropped here. query_value = " ".join(bits) if not query_value: @@ -268,7 +263,14 @@ def _compose_url( notes.append(_info("empty", "Nothing to build yet — add at least one term.")) return None, notes - params = _template_params(template, query_value, request.location) + # Empty parameters are never written (an empty "l=" claims a location + # criterion that does not exist). Automation keeps its own byte-identical + # builder and never reaches this filter. + params = [ + (name, value) + for name, value in _template_params(template, query_value, request.location) + if value != "" + ] for key, value in request.options: opt = offered_options.get(key) @@ -353,21 +355,34 @@ def compose(request: QueryRequest, spec: SearchSurfaceSpec) -> QueryResult: if ops: bar, bar_notes, hard_stop = _compose_bar(request, spec, ops) notes.extend(bar_notes) - elif any( - ( - request.phrases, - request.any_of, - request.required, - request.exclude, - request.in_title, - request.site, - ) - ): - notes.append(_info( - "bar", - f"{spec.display_name} has no typed search form AA can write for " - "you — the link below is the way to use it.", - )) + else: + # No typed form at all (e.g. LinkedIn). Refusal-both applies here too: + # a restriction this surface cannot carry means no link either - a + # link without it would be a different search. + unexpressible = [ + label + for wanted, label in ( + (request.any_of, "the 'any of these' part"), + (request.site, "the site limit"), + (request.exclude, "the exclusions"), + (request.in_title, "the title filter"), + ) + if wanted + ] + if unexpressible: + notes.append(_refused( + "unexpressible", + f"{spec.display_name} has no way to carry " + f"{', '.join(unexpressible)}, so no search text or link was " + "written — one without it would be a different search.", + )) + hard_stop = True + elif request.phrases or request.required: + notes.append(_info( + "bar", + f"{spec.display_name} has no typed search form AA can write for " + "you — the link below is the way to use it.", + )) url: str | None = None if not hard_stop and spec.urls: 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 4e6986b..49a8e31 100644 --- a/packages/auto_apply/src/auto_apply/infrastructure/composition_root.py +++ b/packages/auto_apply/src/auto_apply/infrastructure/composition_root.py @@ -128,6 +128,25 @@ _MAX_LEASES_PER_SHARED_DRIVER = 1 +def _build_ats_registry(): + """Load the ATS descriptor registry once per build site. + + Called by build_orchestrator (for the workflows) and by + build_session_controller (for the query builder), so platform identity + stays single-sourced in resources/ats/*.yaml and neither site constructs + its own list. Returns None when the descriptors cannot be loaded — every + consumer degrades on None already. + """ + try: + from auto_apply.adapters.secondary.discovery.ats_registry import ( # noqa: PLC0415 + ATSRegistry, + ) + return ATSRegistry() + except Exception as _exc: + logger.debug("composition_root: ATSRegistry unavailable: %s", _exc) + return None + + def _warn_if_legacy_research_db() -> None: """Warn when a pre-relocation research database exists. @@ -816,14 +835,7 @@ def build_orchestrator( # noqa: PLR0914 _configured_checkpoints = getattr(app_config, "human_review_checkpoints", None) interrupt_policy = ProfileBasedInterruptPolicy(_configured_checkpoints) - _ats_registry = None - try: - from auto_apply.adapters.secondary.discovery.ats_registry import ( # noqa: PLC0415 - ATSRegistry, - ) - _ats_registry = ATSRegistry() - except Exception as _exc: - logger.debug("build_orchestrator: ATSRegistry unavailable: %s", _exc) + _ats_registry = _build_ats_registry() from auto_apply.domain.ports.page_understanding_port import PageUnderstandingPort # noqa: PLC0415 @@ -1120,28 +1132,6 @@ def _build() -> VerifiedPageAdvancer: ), ] - from auto_apply.adapters.secondary.discovery.strategies.serp_strategy import ( # noqa: PLC0415 - GenericSERPStrategy, - ) - - search_prefs_for_miner = profile.search_preferences if hasattr(profile, "search_preferences") else None - - def _company_page_miner(browser: BrowserInterface) -> list: - return GenericSERPStrategy( - browser=browser, - search_prefs=search_prefs_for_miner, - source_tag="CompanyDirect", - scroller=_page_scroller, - advancer=( - _advancer_factory("")() if _advancer_factory is not None else None - ), - max_pages=_max_pages_per_query, - observer=_extraction_observer, - reporter=_audit_reporter, - forced_tier=_forced_tier, - page_action=page_action_tool, - ).execute() - # Single-URL careers-page scraper for DISCOVER_COMPANY tasks: navigate the # live browser to the URL and extract listings via the math subsystem. # Omitted (None) without a browser — DiscoveryWorkflow degrades gracefully. @@ -1227,7 +1217,6 @@ def _company_page_scraper(careers_url: str) -> list: # noqa: F811 dedup=dedup, text_matcher=text_matcher, ats_registry=_ats_registry, - company_page_miner=_company_page_miner, company_page_scraper=_company_page_scraper, plan=plan, browser_lease=browser_lease, @@ -1645,6 +1634,7 @@ def build_session_controller( profile=profile, load_spec=load_surface, list_names=list_surface_names, + ats_registry=_build_ats_registry(), ) # 3. Assemble the controller (all deps injected). If assembly fails after diff --git a/packages/auto_apply/src/auto_apply/resources/ats/adp.yaml b/packages/auto_apply/src/auto_apply/resources/ats/adp.yaml new file mode 100644 index 0000000..6224ef3 --- /dev/null +++ b/packages/auto_apply/src/auto_apply/resources/ats/adp.yaml @@ -0,0 +1,16 @@ +name: adp +description: > + ADP Workforce Now — enterprise addressing (client identified by query + parameters, not the host). + Sample: https://workforcenow.adp.com/mascsr/default/mdf/recruitment/postLogin.html?client=...&jobId=472846 +url_patterns: + - "workforcenow.adp.com/*" +hosts: + - "adp.com" +multi_step: false +tenancy: enterprise +search_restriction: "workforcenow.adp.com" +search_restriction_status: unverified +search_restriction_verified_on: "" +search_restriction_source: "Host from Nick's sample URL; not yet hand-measured" +terminal_patterns: [] diff --git a/packages/auto_apply/src/auto_apply/resources/ats/ashby.yaml b/packages/auto_apply/src/auto_apply/resources/ats/ashby.yaml index 05f30e9..174c127 100644 --- a/packages/auto_apply/src/auto_apply/resources/ats/ashby.yaml +++ b/packages/auto_apply/src/auto_apply/resources/ats/ashby.yaml @@ -37,6 +37,13 @@ submit_button_selector: > button[aria-label*='apply'] multi_step: false +tenancy: path +search_restriction: "jobs.ashbyhq.com" +search_restriction_status: unverified +search_restriction_verified_on: "" +search_restriction_source: "Host declared in this descriptor; not yet hand-measured (docs/developer_guide/verifying_platform_search.md)" +terminal_patterns: [] + notes: > Ashby forms are typically one page with all fields visible. Resume upload uses input[type=file] or a drag-and-drop zone. LinkedIn profile URL field diff --git a/packages/auto_apply/src/auto_apply/resources/ats/bamboohr.yaml b/packages/auto_apply/src/auto_apply/resources/ats/bamboohr.yaml new file mode 100644 index 0000000..e43df80 --- /dev/null +++ b/packages/auto_apply/src/auto_apply/resources/ats/bamboohr.yaml @@ -0,0 +1,15 @@ +name: bamboohr +description: > + BambooHR — subdomain tenancy (.bamboohr.com/careers/...). + Sample: https://holisticai.bamboohr.com/careers/59 +url_patterns: + - "*.bamboohr.com/careers*" +hosts: + - "bamboohr.com" +multi_step: false +tenancy: subdomain +search_restriction: "bamboohr.com/careers" +search_restriction_status: unverified +search_restriction_verified_on: "" +search_restriction_source: "Query from Nick's Job Websites table; not hand-measured yet" +terminal_patterns: [] diff --git a/packages/auto_apply/src/auto_apply/resources/ats/careers_page.yaml b/packages/auto_apply/src/auto_apply/resources/ats/careers_page.yaml new file mode 100644 index 0000000..42fcf0d --- /dev/null +++ b/packages/auto_apply/src/auto_apply/resources/ats/careers_page.yaml @@ -0,0 +1,15 @@ +name: careers-page +description: > + careers-page.com — path tenancy (www.careers-page.com/). + Sample: https://www.careers-page.com/amorservllc +url_patterns: + - "www.careers-page.com/*" +hosts: + - "careers-page.com" +multi_step: false +tenancy: path +search_restriction: "careers-page.com" +search_restriction_status: unverified +search_restriction_verified_on: "" +search_restriction_source: "Host from Nick's sample URL; not yet hand-measured" +terminal_patterns: [] diff --git a/packages/auto_apply/src/auto_apply/resources/ats/catsone.yaml b/packages/auto_apply/src/auto_apply/resources/ats/catsone.yaml new file mode 100644 index 0000000..da2cbfd --- /dev/null +++ b/packages/auto_apply/src/auto_apply/resources/ats/catsone.yaml @@ -0,0 +1,16 @@ +name: catsone +description: > + CATSone — subdomain tenancy (.catsone.com/careers/...). + Sample: https://cerecore.catsone.com/careers/56866-General/jobs/16364730-System-Analyst/apply/success +url_patterns: + - "*.catsone.com/careers*" +hosts: + - "catsone.com" +multi_step: false +tenancy: subdomain +search_restriction: "catsone.com" +search_restriction_status: unverified +search_restriction_verified_on: "" +search_restriction_source: "Host from Nick's sample URL; not yet hand-measured" +terminal_patterns: + - "*.catsone.com/*/apply/success*" diff --git a/packages/auto_apply/src/auto_apply/resources/ats/comeet.yaml b/packages/auto_apply/src/auto_apply/resources/ats/comeet.yaml new file mode 100644 index 0000000..4f7dacf --- /dev/null +++ b/packages/auto_apply/src/auto_apply/resources/ats/comeet.yaml @@ -0,0 +1,15 @@ +name: comeet +description: > + Comeet — path tenancy (www.comeet.com/jobs//...). + Sample: https://www.comeet.com/jobs/starkware/C6.00E/software-engineer-in-the-product-exploration-team_/2F.739 +url_patterns: + - "www.comeet.com/jobs/*" +hosts: + - "comeet.com" +multi_step: false +tenancy: path +search_restriction: "comeet.com" +search_restriction_status: unverified +search_restriction_verified_on: "" +search_restriction_source: "Host from Nick's sample URL; not yet hand-measured" +terminal_patterns: [] diff --git a/packages/auto_apply/src/auto_apply/resources/ats/crelate.yaml b/packages/auto_apply/src/auto_apply/resources/ats/crelate.yaml new file mode 100644 index 0000000..4c36a15 --- /dev/null +++ b/packages/auto_apply/src/auto_apply/resources/ats/crelate.yaml @@ -0,0 +1,15 @@ +name: crelate +description: > + Crelate — path tenancy (jobs.crelate.com/portal//). + Sample: https://jobs.crelate.com/portal/strategicemployment/ +url_patterns: + - "jobs.crelate.com/portal/*" +hosts: + - "crelate.com" +multi_step: false +tenancy: path +search_restriction: "jobs.crelate.com" +search_restriction_status: unverified +search_restriction_verified_on: "" +search_restriction_source: "Host from Nick's sample URL; not yet hand-measured" +terminal_patterns: [] diff --git a/packages/auto_apply/src/auto_apply/resources/ats/dayforce.yaml b/packages/auto_apply/src/auto_apply/resources/ats/dayforce.yaml new file mode 100644 index 0000000..b9e4162 --- /dev/null +++ b/packages/auto_apply/src/auto_apply/resources/ats/dayforce.yaml @@ -0,0 +1,15 @@ +name: dayforce +description: > + Dayforce (Ceridian) — jobs.dayforcehcm.com///... . + Sample: https://jobs.dayforcehcm.com/en-US/isone/CANDIDATEPORTAL +url_patterns: + - "jobs.dayforcehcm.com/*" +hosts: + - "dayforcehcm.com" +multi_step: false +tenancy: path +search_restriction: "jobs.dayforcehcm.com" +search_restriction_status: unverified +search_restriction_verified_on: "" +search_restriction_source: "Host from Nick's sample URL; not yet hand-measured" +terminal_patterns: [] diff --git a/packages/auto_apply/src/auto_apply/resources/ats/dover.yaml b/packages/auto_apply/src/auto_apply/resources/ats/dover.yaml new file mode 100644 index 0000000..1b14eb4 --- /dev/null +++ b/packages/auto_apply/src/auto_apply/resources/ats/dover.yaml @@ -0,0 +1,15 @@ +name: dover +description: > + Dover — path tenancy (app.dover.io/apply///). + Sample: https://app.dover.io/apply/Roboflow/455c79fd-c2e2-4be8-b9ea-8a1d551dbfa0/ +url_patterns: + - "app.dover.io/apply/*" +hosts: + - "dover.io" +multi_step: false +tenancy: path +search_restriction: "app.dover.io" +search_restriction_status: unverified +search_restriction_verified_on: "" +search_restriction_source: "Host from Nick's sample URL; not yet hand-measured" +terminal_patterns: [] diff --git a/packages/auto_apply/src/auto_apply/resources/ats/eightfold.yaml b/packages/auto_apply/src/auto_apply/resources/ats/eightfold.yaml new file mode 100644 index 0000000..78c4490 --- /dev/null +++ b/packages/auto_apply/src/auto_apply/resources/ats/eightfold.yaml @@ -0,0 +1,15 @@ +name: eightfold +description: > + Eightfold — subdomain tenancy (.eightfold.ai/careers/job/). + Sample: https://symetra.eightfold.ai/careers/job/446696479895 +url_patterns: + - "*.eightfold.ai/careers*" +hosts: + - "eightfold.ai" +multi_step: false +tenancy: subdomain +search_restriction: "eightfold.ai" +search_restriction_status: unverified +search_restriction_verified_on: "" +search_restriction_source: "Host from Nick's sample URL; not yet hand-measured" +terminal_patterns: [] diff --git a/packages/auto_apply/src/auto_apply/resources/ats/freshteam.yaml b/packages/auto_apply/src/auto_apply/resources/ats/freshteam.yaml new file mode 100644 index 0000000..aadae9b --- /dev/null +++ b/packages/auto_apply/src/auto_apply/resources/ats/freshteam.yaml @@ -0,0 +1,15 @@ +name: freshteam +description: > + Freshteam — subdomain tenancy (.freshteam.com/jobs/...). + Sample: https://findmine.freshteam.com/jobs/2lSE7VcqfO0g/software-engineer-remote +url_patterns: + - "*.freshteam.com/jobs/*" +hosts: + - "freshteam.com" +multi_step: false +tenancy: subdomain +search_restriction: "freshteam.com" +search_restriction_status: unverified +search_restriction_verified_on: "" +search_restriction_source: "Host from Nick's sample URL; not yet hand-measured" +terminal_patterns: [] diff --git a/packages/auto_apply/src/auto_apply/resources/ats/gohire.yaml b/packages/auto_apply/src/auto_apply/resources/ats/gohire.yaml new file mode 100644 index 0000000..202f55b --- /dev/null +++ b/packages/auto_apply/src/auto_apply/resources/ats/gohire.yaml @@ -0,0 +1,15 @@ +name: gohire +description: > + GoHire — path tenancy (jobs.gohire.io/-/). + Sample: https://jobs.gohire.io/mundiware-9xhfbtvt/software-engineer-153972/ +url_patterns: + - "jobs.gohire.io/*/*" +hosts: + - "gohire.io" +multi_step: false +tenancy: path +search_restriction: "jobs.gohire.io" +search_restriction_status: unverified +search_restriction_verified_on: "" +search_restriction_source: "Host from Nick's sample URL; not yet hand-measured" +terminal_patterns: [] diff --git a/packages/auto_apply/src/auto_apply/resources/ats/greenhouse.yaml b/packages/auto_apply/src/auto_apply/resources/ats/greenhouse.yaml index f2e07b7..811103e 100644 --- a/packages/auto_apply/src/auto_apply/resources/ats/greenhouse.yaml +++ b/packages/auto_apply/src/auto_apply/resources/ats/greenhouse.yaml @@ -34,6 +34,13 @@ form_root_selector: "#application_form" submit_button_selector: "button#submit_app, input[type='submit'], button[type='submit']" multi_step: false +tenancy: path +search_restriction: "boards.greenhouse.io" +search_restriction_status: unverified +search_restriction_verified_on: "" +search_restriction_source: "Host declared in this descriptor; not yet hand-measured (docs/developer_guide/verifying_platform_search.md)" +terminal_patterns: [] + notes: > Greenhouse forms are server-rendered. Rarely requires login for external applicants. Resume upload uses input[type=file][name=resume]. diff --git a/packages/auto_apply/src/auto_apply/resources/ats/gusto.yaml b/packages/auto_apply/src/auto_apply/resources/ats/gusto.yaml new file mode 100644 index 0000000..b6ac47c --- /dev/null +++ b/packages/auto_apply/src/auto_apply/resources/ats/gusto.yaml @@ -0,0 +1,15 @@ +name: gusto +description: > + Gusto — path tenancy with a shared prefix (jobs.gusto.com/boards/). + Sample: https://jobs.gusto.com/boards/possip-9d7e6ab2-bfc6-4b25-8517-fa812738ec56 +url_patterns: + - "jobs.gusto.com/boards/*" +hosts: + - "gusto.com" +multi_step: false +tenancy: path +search_restriction: "jobs.gusto.com/boards" +search_restriction_status: unverified +search_restriction_verified_on: "" +search_restriction_source: "Query from Nick's Job Websites table; not hand-measured yet" +terminal_patterns: [] diff --git a/packages/auto_apply/src/auto_apply/resources/ats/homerun.yaml b/packages/auto_apply/src/auto_apply/resources/ats/homerun.yaml new file mode 100644 index 0000000..6eb3640 --- /dev/null +++ b/packages/auto_apply/src/auto_apply/resources/ats/homerun.yaml @@ -0,0 +1,15 @@ +name: homerun +description: > + Homerun — subdomain tenancy (.homerun.co//en/apply). + Sample: https://designfiles.homerun.co/full-stack-ruby-on-rails-developer/en/apply +url_patterns: + - "*.homerun.co/*" +hosts: + - "homerun.co" +multi_step: false +tenancy: subdomain +search_restriction: "homerun.co" +search_restriction_status: unverified +search_restriction_verified_on: "" +search_restriction_source: "Host from Nick's sample URL; not yet hand-measured" +terminal_patterns: [] diff --git a/packages/auto_apply/src/auto_apply/resources/ats/icims.yaml b/packages/auto_apply/src/auto_apply/resources/ats/icims.yaml index b2dcf8a..cf827f8 100644 --- a/packages/auto_apply/src/auto_apply/resources/ats/icims.yaml +++ b/packages/auto_apply/src/auto_apply/resources/ats/icims.yaml @@ -45,6 +45,13 @@ submit_button_selector: > button[data-icims-action='next'] multi_step: true +tenancy: subdomain +search_restriction: "icims.com" +search_restriction_status: unverified +search_restriction_verified_on: "" +search_restriction_source: "Host declared in this descriptor; not yet hand-measured (docs/developer_guide/verifying_platform_search.md)" +terminal_patterns: [] + notes: > iCIMS Classic uses iframes for form steps; iCIMS Connect and Apply use single-page rendering. Resume upload typically uses input[type=file] diff --git a/packages/auto_apply/src/auto_apply/resources/ats/jazzhr.yaml b/packages/auto_apply/src/auto_apply/resources/ats/jazzhr.yaml new file mode 100644 index 0000000..e328e39 --- /dev/null +++ b/packages/auto_apply/src/auto_apply/resources/ats/jazzhr.yaml @@ -0,0 +1,21 @@ +name: jazzhr +description: > + JazzHR — one platform, two hosts: applytojob.com (current) and + theresumator.com (legacy name). Subdomain tenancy on both. + Samples: https://openplay.applytojob.com/apply/ , + https://dealeron.theresumator.com/apply/confirm/G3gue6dy4D +url_patterns: + - "*.applytojob.com/apply*" + - "*.theresumator.com/apply*" +hosts: + - "applytojob.com" + - "theresumator.com" +multi_step: false +tenancy: subdomain +search_restriction: "applytojob.com" +search_restriction_status: unverified +search_restriction_verified_on: "" +search_restriction_source: "Hosts from Nick's sample URLs; not yet hand-measured" +terminal_patterns: + - "*.theresumator.com/apply/confirm/*" + - "*.applytojob.com/apply/confirm/*" diff --git a/packages/auto_apply/src/auto_apply/resources/ats/jobscore.yaml b/packages/auto_apply/src/auto_apply/resources/ats/jobscore.yaml new file mode 100644 index 0000000..10ded35 --- /dev/null +++ b/packages/auto_apply/src/auto_apply/resources/ats/jobscore.yaml @@ -0,0 +1,15 @@ +name: jobscore +description: > + JobScore — path tenancy (careers.jobscore.com/careers/). + Sample: https://careers.jobscore.com/careers/xoitechnologies +url_patterns: + - "careers.jobscore.com/careers/*" +hosts: + - "jobscore.com" +multi_step: false +tenancy: path +search_restriction: "careers.jobscore.com" +search_restriction_status: unverified +search_restriction_verified_on: "" +search_restriction_source: "Host from Nick's sample URL; not yet hand-measured" +terminal_patterns: [] diff --git a/packages/auto_apply/src/auto_apply/resources/ats/jobvite.yaml b/packages/auto_apply/src/auto_apply/resources/ats/jobvite.yaml new file mode 100644 index 0000000..59365b9 --- /dev/null +++ b/packages/auto_apply/src/auto_apply/resources/ats/jobvite.yaml @@ -0,0 +1,15 @@ +name: jobvite +description: > + Jobvite — path tenancy (jobs.jobvite.com//job/). + Sample: https://jobs.jobvite.com/ziffdavis/job/orCdqfwq/apply +url_patterns: + - "jobs.jobvite.com/*/job/*" +hosts: + - "jobvite.com" +multi_step: false +tenancy: path +search_restriction: "jobs.jobvite.com" +search_restriction_status: measured +search_restriction_verified_on: "2026-10-27" +search_restriction_source: "Reported reliable on Google by Nick on 2026-10-27: site:jobs.jobvite.com (\"Software Engineer\"); date of his original check not recorded" +terminal_patterns: [] diff --git a/packages/auto_apply/src/auto_apply/resources/ats/keka.yaml b/packages/auto_apply/src/auto_apply/resources/ats/keka.yaml new file mode 100644 index 0000000..6338246 --- /dev/null +++ b/packages/auto_apply/src/auto_apply/resources/ats/keka.yaml @@ -0,0 +1,16 @@ +name: keka +description: > + Keka — subdomain tenancy (.keka.com/careers/...). Region: EU per + Nick's job-website table (metadata only; filters nothing). + Samples: https://cubet.keka.com/careers/jobdetails/48781 , https://zuddl.keka.com/careers/ +url_patterns: + - "*.keka.com/careers*" +hosts: + - "keka.com" +multi_step: false +tenancy: subdomain +search_restriction: "keka.com" +search_restriction_status: unverified +search_restriction_verified_on: "" +search_restriction_source: "Host from Nick's sample URLs; not yet hand-measured" +terminal_patterns: [] diff --git a/packages/auto_apply/src/auto_apply/resources/ats/lever.yaml b/packages/auto_apply/src/auto_apply/resources/ats/lever.yaml index 6bf7298..9a09c55 100644 --- a/packages/auto_apply/src/auto_apply/resources/ats/lever.yaml +++ b/packages/auto_apply/src/auto_apply/resources/ats/lever.yaml @@ -36,6 +36,13 @@ submit_button_selector: > input[type='submit'] multi_step: false +tenancy: path +search_restriction: "jobs.lever.co" +search_restriction_status: unverified +search_restriction_verified_on: "" +search_restriction_source: "Host declared in this descriptor; not yet hand-measured (docs/developer_guide/verifying_platform_search.md)" +terminal_patterns: [] + notes: > Lever forms are React SPAs. The submit button has a data-qa attribute in most versions. Resume upload: input[type=file] inside .resume-upload. diff --git a/packages/auto_apply/src/auto_apply/resources/ats/loxo.yaml b/packages/auto_apply/src/auto_apply/resources/ats/loxo.yaml new file mode 100644 index 0000000..e252330 --- /dev/null +++ b/packages/auto_apply/src/auto_apply/resources/ats/loxo.yaml @@ -0,0 +1,16 @@ +name: loxo +description: > + Loxo — opaque addressing (app.loxo.co/job/); the slug is an + opaque ID, not a company name. + Sample: https://app.loxo.co/job/MjM3NDctamdyZzRnMGRrbmM5OGtraA== +url_patterns: + - "app.loxo.co/job/*" +hosts: + - "loxo.co" +multi_step: false +tenancy: opaque +search_restriction: "app.loxo.co" +search_restriction_status: unverified +search_restriction_verified_on: "" +search_restriction_source: "Host from Nick's sample URL; not yet hand-measured" +terminal_patterns: [] diff --git a/packages/auto_apply/src/auto_apply/resources/ats/oasisrecruit.yaml b/packages/auto_apply/src/auto_apply/resources/ats/oasisrecruit.yaml new file mode 100644 index 0000000..a6f589b --- /dev/null +++ b/packages/auto_apply/src/auto_apply/resources/ats/oasisrecruit.yaml @@ -0,0 +1,15 @@ +name: oasisrecruit +description: > + Oasis Recruit — subdomain tenancy (.oasisrecruit.com/job//...). + Sample: https://team-outsider.oasisrecruit.com/job/688537/security-night-host +url_patterns: + - "*.oasisrecruit.com/job/*" +hosts: + - "oasisrecruit.com" +multi_step: false +tenancy: subdomain +search_restriction: "oasisrecruit.com" +search_restriction_status: unverified +search_restriction_verified_on: "" +search_restriction_source: "Host from Nick's sample URL; not yet hand-measured" +terminal_patterns: [] diff --git a/packages/auto_apply/src/auto_apply/resources/ats/paylocity.yaml b/packages/auto_apply/src/auto_apply/resources/ats/paylocity.yaml new file mode 100644 index 0000000..74c094d --- /dev/null +++ b/packages/auto_apply/src/auto_apply/resources/ats/paylocity.yaml @@ -0,0 +1,15 @@ +name: paylocity +description: > + Paylocity — recruiting.paylocity.com/Recruiting/Jobs/... (path/enterprise). + Sample: https://recruiting.paylocity.com/Recruiting/Jobs/Apply/2213197 +url_patterns: + - "recruiting.paylocity.com/Recruiting/Jobs/*" +hosts: + - "paylocity.com" +multi_step: false +tenancy: path +search_restriction: "recruiting.paylocity.com" +search_restriction_status: unverified +search_restriction_verified_on: "" +search_restriction_source: "Host from Nick's sample URL; not yet hand-measured" +terminal_patterns: [] diff --git a/packages/auto_apply/src/auto_apply/resources/ats/pinpointhq.yaml b/packages/auto_apply/src/auto_apply/resources/ats/pinpointhq.yaml new file mode 100644 index 0000000..b7d474e --- /dev/null +++ b/packages/auto_apply/src/auto_apply/resources/ats/pinpointhq.yaml @@ -0,0 +1,15 @@ +name: pinpointhq +description: > + PinpointHQ — subdomain tenancy (.pinpointhq.com/postings/). + Sample: https://sparkadvisors.pinpointhq.com/postings/b97e5f0b-6a74-4b2d-806f-779561b26685/applications/new +url_patterns: + - "*.pinpointhq.com/postings/*" +hosts: + - "pinpointhq.com" +multi_step: false +tenancy: subdomain +search_restriction: "pinpointhq.com" +search_restriction_status: unverified +search_restriction_verified_on: "" +search_restriction_source: "Host from Nick's sample URL; not yet hand-measured" +terminal_patterns: [] diff --git a/packages/auto_apply/src/auto_apply/resources/ats/recruitee.yaml b/packages/auto_apply/src/auto_apply/resources/ats/recruitee.yaml new file mode 100644 index 0000000..24ff5aa --- /dev/null +++ b/packages/auto_apply/src/auto_apply/resources/ats/recruitee.yaml @@ -0,0 +1,15 @@ +name: recruitee +description: > + Recruitee — subdomain tenancy (.recruitee.com/o/). + Sample: https://flexrentalsolutionsinc.recruitee.com/o/full-stack-engineer-2/c/new +url_patterns: + - "*.recruitee.com/o/*" +hosts: + - "recruitee.com" +multi_step: false +tenancy: subdomain +search_restriction: "recruitee.com" +search_restriction_status: unverified +search_restriction_verified_on: "" +search_restriction_source: "Host from Nick's sample URL; not yet hand-measured" +terminal_patterns: [] diff --git a/packages/auto_apply/src/auto_apply/resources/ats/recruiterflow.yaml b/packages/auto_apply/src/auto_apply/resources/ats/recruiterflow.yaml new file mode 100644 index 0000000..9e9f6e4 --- /dev/null +++ b/packages/auto_apply/src/auto_apply/resources/ats/recruiterflow.yaml @@ -0,0 +1,16 @@ +name: recruiterflow +description: > + Recruiterflow — opaque path tenancy (recruiterflow.com/db_/jobs/); + the slug is an opaque ID, not a company name. + Sample: https://recruiterflow.com/db_cae6aa6c42ee795815955c2dab38824c/jobs/241 +url_patterns: + - "recruiterflow.com/db_*/jobs/*" +hosts: + - "recruiterflow.com" +multi_step: false +tenancy: opaque +search_restriction: "recruiterflow.com" +search_restriction_status: unverified +search_restriction_verified_on: "" +search_restriction_source: "Host from Nick's sample URL; not yet hand-measured" +terminal_patterns: [] diff --git a/packages/auto_apply/src/auto_apply/resources/ats/rippling.yaml b/packages/auto_apply/src/auto_apply/resources/ats/rippling.yaml new file mode 100644 index 0000000..0588ec4 --- /dev/null +++ b/packages/auto_apply/src/auto_apply/resources/ats/rippling.yaml @@ -0,0 +1,15 @@ +name: rippling +description: > + Rippling — path tenancy (ats.rippling.com//jobs/). + Sample: https://ats.rippling.com/botbuilt/jobs/682ecaab-80d2-4f06-824d-2e4678cbb312 +url_patterns: + - "ats.rippling.com/*/jobs/*" +hosts: + - "rippling.com" +multi_step: false +tenancy: path +search_restriction: "ats.rippling.com" +search_restriction_status: unverified +search_restriction_verified_on: "" +search_restriction_source: "Host from Nick's sample URL; not yet hand-measured" +terminal_patterns: [] diff --git a/packages/auto_apply/src/auto_apply/resources/ats/saashr.yaml b/packages/auto_apply/src/auto_apply/resources/ats/saashr.yaml new file mode 100644 index 0000000..4e0a7e8 --- /dev/null +++ b/packages/auto_apply/src/auto_apply/resources/ats/saashr.yaml @@ -0,0 +1,15 @@ +name: saashr +description: > + UKG / SaaShr — subdomain + enterprise addressing (secure6.saashr.com/ta/.careers). + Sample: https://secure6.saashr.com/ta/6189920.careers?CareersSearch +url_patterns: + - "*.saashr.com/ta/*" +hosts: + - "saashr.com" +multi_step: false +tenancy: subdomain +search_restriction: "saashr.com" +search_restriction_status: unverified +search_restriction_verified_on: "" +search_restriction_source: "Host from Nick's sample URL; not yet hand-measured" +terminal_patterns: [] diff --git a/packages/auto_apply/src/auto_apply/resources/ats/smartrecruiters.yaml b/packages/auto_apply/src/auto_apply/resources/ats/smartrecruiters.yaml new file mode 100644 index 0000000..2038c9e --- /dev/null +++ b/packages/auto_apply/src/auto_apply/resources/ats/smartrecruiters.yaml @@ -0,0 +1,15 @@ +name: smartrecruiters +description: > + SmartRecruiters — path tenancy (jobs.smartrecruiters.com//). + Sample: https://jobs.smartrecruiters.com/Jitterbit/743999952832064-web-developer +url_patterns: + - "jobs.smartrecruiters.com/*/*" +hosts: + - "smartrecruiters.com" +multi_step: false +tenancy: path +search_restriction: "jobs.smartrecruiters.com" +search_restriction_status: unverified +search_restriction_verified_on: "" +search_restriction_source: "Host from Nick's sample URL; not yet hand-measured" +terminal_patterns: [] diff --git a/packages/auto_apply/src/auto_apply/resources/ats/taleo.yaml b/packages/auto_apply/src/auto_apply/resources/ats/taleo.yaml index be924be..cfe6f4a 100644 --- a/packages/auto_apply/src/auto_apply/resources/ats/taleo.yaml +++ b/packages/auto_apply/src/auto_apply/resources/ats/taleo.yaml @@ -44,6 +44,13 @@ submit_button_selector: > button[id*='next'] multi_step: true +tenancy: subdomain +search_restriction: "taleo.net" +search_restriction_status: unverified +search_restriction_verified_on: "" +search_restriction_source: "Host declared in this descriptor; not yet hand-measured (docs/developer_guide/verifying_platform_search.md)" +terminal_patterns: [] + notes: > Taleo uses a wizard pattern with "Next" buttons on each step. The final step has a "Submit" action. Session timeouts are aggressive diff --git a/packages/auto_apply/src/auto_apply/resources/ats/workable.yaml b/packages/auto_apply/src/auto_apply/resources/ats/workable.yaml new file mode 100644 index 0000000..db59de2 --- /dev/null +++ b/packages/auto_apply/src/auto_apply/resources/ats/workable.yaml @@ -0,0 +1,15 @@ +name: workable +description: > + Workable — path tenancy (apply.workable.com//j//apply/). + Sample: https://apply.workable.com/last-call-media/j/FF37BE66C5/apply/ +url_patterns: + - "apply.workable.com/*" +hosts: + - "workable.com" +multi_step: false +tenancy: path +search_restriction: "apply.workable.com" +search_restriction_status: unverified +search_restriction_verified_on: "" +search_restriction_source: "Host from Nick's sample URL; not yet hand-measured" +terminal_patterns: [] diff --git a/packages/auto_apply/src/auto_apply/resources/ats/workday.yaml b/packages/auto_apply/src/auto_apply/resources/ats/workday.yaml index 993b634..0bec8a8 100644 --- a/packages/auto_apply/src/auto_apply/resources/ats/workday.yaml +++ b/packages/auto_apply/src/auto_apply/resources/ats/workday.yaml @@ -42,6 +42,13 @@ submit_button_selector: > button[data-automation-id*='next'] multi_step: true +tenancy: subdomain +search_restriction: "myworkdayjobs.com" +search_restriction_status: unverified +search_restriction_verified_on: "" +search_restriction_source: "Host declared in this descriptor; not yet hand-measured (docs/developer_guide/verifying_platform_search.md)" +terminal_patterns: [] + notes: > Workday forms typically have 3–7 steps: My Information, My Experience, Application Questions, Voluntary Disclosures, and Review. Each step uses diff --git a/packages/auto_apply/src/auto_apply/resources/ats/zohorecruit.yaml b/packages/auto_apply/src/auto_apply/resources/ats/zohorecruit.yaml new file mode 100644 index 0000000..2521395 --- /dev/null +++ b/packages/auto_apply/src/auto_apply/resources/ats/zohorecruit.yaml @@ -0,0 +1,15 @@ +name: zohorecruit +description: > + Zoho Recruit — subdomain tenancy (.zohorecruit.com/jobs/...). + Sample: https://vpnforandroid.zohorecruit.com/jobs/Careers/751906000003134273/Junior-Java-developer-US-Only +url_patterns: + - "*.zohorecruit.com/jobs/*" +hosts: + - "zohorecruit.com" +multi_step: false +tenancy: subdomain +search_restriction: "zohorecruit.com" +search_restriction_status: unverified +search_restriction_verified_on: "" +search_restriction_source: "Host from Nick's sample URL; not yet hand-measured" +terminal_patterns: [] diff --git a/packages/auto_apply/src/auto_apply/resources/search_surfaces/bing.surface.yaml b/packages/auto_apply/src/auto_apply/resources/search_surfaces/bing.surface.yaml index 2a80ef6..0bacd98 100644 --- a/packages/auto_apply/src/auto_apply/resources/search_surfaces/bing.surface.yaml +++ b/packages/auto_apply/src/auto_apply/resources/search_surfaces/bing.surface.yaml @@ -26,6 +26,10 @@ urls: date_parameter: filters default_url: jobs automation_url: jobs +# A site: restriction is a web search, not a jobs-vertical search: the +# composer uses this template whenever the request carries one. Automation +# never reads this (it uses automation_url). +site_template: web operators: - name: site 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 2a1de1e..8db769e 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 @@ -46,7 +46,7 @@ automation_url: jobs operators: - name: site label: "Only this site" - explanation: "Shows results only from the site you name. A folder works too, for example site:jobs.gusto.com/boards." + explanation: "Shows results only from the site you name. A folder works too, for example site:example.com/jobs." syntax: "site:{value}" channel: typed accepts: domain_or_path diff --git a/packages/auto_apply/tests/application/test_query_builder_platforms.py b/packages/auto_apply/tests/application/test_query_builder_platforms.py new file mode 100644 index 0000000..7110baf --- /dev/null +++ b/packages/auto_apply/tests/application/test_query_builder_platforms.py @@ -0,0 +1,166 @@ +"""Pins for the builder's platform search (query-builder chain, call 2). + +Label honesty per pin: TEETH fail on the pre-change tree (no platform +methods, no registry injection, no site_template, no refusal-both); GUARDs +hold call-1 behaviour the expansion must not break. I have not executed +these; each names the mutation it catches. +""" +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest + +from auto_apply.adapters.secondary.discovery.ats_registry import ATSRegistry +from auto_apply.adapters.secondary.discovery.search_surface_registry import ( + list_surface_names, + load_surface, +) +from auto_apply.application.services.query_builder import ( + QueryBuilderError, + QueryBuilderService, +) +from auto_apply.domain.models.search_query import QueryRequest + + +def _service(registry=None) -> QueryBuilderService: + return QueryBuilderService( + profile=None, + load_spec=load_surface, + list_names=list_surface_names, + ats_registry=ATSRegistry() if registry is None else registry, + recent_path=Path("/nonexistent/recent.json"), + ) + + +@pytest.mark.parametrize( + ("platform", "expected"), + [ + ("jobvite", 'site:jobs.jobvite.com ("Software Engineer")'), + ("bamboohr", 'site:bamboohr.com/careers ("Software Engineer")'), + ("gusto", 'site:jobs.gusto.com/boards ("Software Engineer")'), + ], +) +def test_nicks_three_queries_compose_exactly_through_platform_data(platform, expected): + """TEETH — the platform data did not exist. Mutation caught: a + restriction differing from Nick's measured/reported strings by one + character, or the composer reformatting them.""" + result = _service().build_platform_search( + platform, QueryRequest(surface="google", phrases=("Software Engineer",)) + ) + assert result.bar_text == expected + assert result.url is not None and "google.com/search" in result.url + + +def test_a_domain_restricted_bing_search_uses_web_results(): + """TEETH — Bing defaulted to the jobs vertical. Mutation caught: the + site template selection ignored or removed.""" + result = _service().build_platform_search( + "workday", QueryRequest(surface="bing", phrases=("Software Engineer",)) + ) + assert result.url is not None + assert "bing.com/search" in result.url + assert "bing.com/jobs" not in result.url + + +def test_a_restriction_a_surface_cannot_express_refuses_the_link_too(): + """TEETH — the link used to survive with the restriction silently + dropped. Mutation caught: hard_stop ignored for content refusals.""" + result = _service().build_platform_search( + "workday", QueryRequest(surface="indeed", phrases=("x",)) + ) + assert result.bar_text is None + assert result.url is None + assert any(n.severity == "refused" and n.topic == "site" for n in result.notes) + + +def test_a_platform_search_on_a_surface_without_site_refuses_cleanly(): + """TEETH. Mutation caught: LinkedIn composing anything from a site + restriction.""" + result = _service().build_platform_search( + "greenhouse", QueryRequest(surface="linkedin", phrases=("x",)) + ) + assert result.bar_text is None + assert result.url is None + + +def test_an_indeed_search_without_a_location_has_no_empty_parameter(): + """TEETH — an empty l= used to be emitted. Mutation caught: the + empty-parameter filter removed.""" + result = _service().build(QueryRequest(surface="indeed", phrases=("x",))) + assert result.url is not None + assert "l=" not in result.url + + +@pytest.mark.parametrize( + ("url", "expected_bar"), + [ + ( + "https://holisticai.bamboohr.com/careers/59", + 'site:holisticai.bamboohr.com/careers ("x")', + ), + ( + "https://jobs.jobvite.com/ziffdavis/job/orCdqfwq/apply", + 'site:jobs.jobvite.com/ziffdavis ("x")', + ), + ( + "https://recruiterflow.com/db_cae6aa6c42ee795815955c2dab38824c/jobs/241", + 'site:recruiterflow.com/db_cae6aa6c42ee795815955c2dab38824c ("x")', + ), + ], +) +def test_a_company_url_scopes_the_search_to_the_tenant(url, expected_bar): + """TEETH — one subdomain, one path, one opaque tenancy. Mutation caught: + the slug ignored, or the shared path prefix dropped.""" + result = _service().build_company_search( + url, QueryRequest(surface="google", phrases=("x",)) + ) + assert result.bar_text == expected_bar + + +def test_a_company_url_on_no_known_platform_refuses_without_a_guess(): + """TEETH. Mutation caught: composing a restriction from the raw host.""" + result = _service().build_company_search( + "https://example.org/jobs/1", QueryRequest(surface="google", phrases=("x",)) + ) + assert result.bar_text is None + assert result.url is None + assert any(n.severity == "refused" for n in result.notes) + + +def test_the_platform_list_comes_from_the_injected_registry(): + """TEETH (the method did not exist). Mutation caught: a second, + hardcoded platform list in the service.""" + names = {d.name for d in _service().platforms()} + assert {"greenhouse", "lever", "workday", "icims", "taleo", "ashby"} <= names + assert "jobvite" in names and "jazzhr" in names + assert len(names) >= 30 + + +def test_a_builder_without_a_registry_refuses_platform_searches_loudly(): + """TEETH. Mutation caught: a quiet empty result where the platform list + should be.""" + service = QueryBuilderService( + profile=None, + load_spec=load_surface, + list_names=list_surface_names, + recent_path=Path("/nonexistent/recent.json"), + ) + assert service.platforms() == () + with pytest.raises(QueryBuilderError): + service.build_platform_search("jobvite", QueryRequest(surface="google", phrases=("x",))) + + +def test_the_builder_modules_do_not_import_the_registry_adapter(): + """GUARD (hexagonal boundary). Mutation caught: query_builder importing + adapters.secondary.discovery.ats_registry instead of the domain port.""" + root = Path(__file__).resolve().parents[2] / "src" / "auto_apply" + path = root / "application" / "services" / "query_builder.py" + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + assert ".adapters." not in alias.name, alias.name + elif isinstance(node, ast.ImportFrom) and node.module: + assert ".adapters." not in node.module, node.module diff --git a/packages/auto_apply/tests/domain/test_query_composer.py b/packages/auto_apply/tests/domain/test_query_composer.py index 1e37c42..5930782 100644 --- a/packages/auto_apply/tests/domain/test_query_composer.py +++ b/packages/auto_apply/tests/domain/test_query_composer.py @@ -133,15 +133,16 @@ def test_an_option_the_surface_lacks_is_refused_with_a_note(): def test_a_site_limit_on_linkedin_is_refused_not_silently_dropped(): - """GUARD. Mutation caught: composing LinkedIn text with site:, or - dropping the note that says the site limit went nowhere.""" + """GUARD. Mutation caught: composing LinkedIn text with site:, dropping + the note that says the site limit went nowhere, or building a LINK that + silently drops it (the refusal-both rule: a link without the restriction + is a different search).""" result = compose( QueryRequest(surface="linkedin", site="example.com", phrases=("Software Engineer",)), _spec("linkedin"), ) assert result.bar_text is None - assert result.url is not None - assert "keywords=Software+Engineer" in result.url + assert result.url is None text = " ".join(n.message for n in result.notes) assert "site" in text.lower() @@ -159,9 +160,7 @@ def test_any_of_on_duckduckgo_is_refused_not_rewritten(): _spec("duckduckgo"), ) assert result.bar_text is None - assert result.url is not None - assert "Alpha" not in result.url - assert "%22Software+Engineer%22" in result.url + assert result.url is None assert any(n.severity == "refused" and "any of these" in n.message for n in result.notes) diff --git a/packages/auto_apply/tests/infrastructure/test_ats_platforms.py b/packages/auto_apply/tests/infrastructure/test_ats_platforms.py new file mode 100644 index 0000000..f34e0fb --- /dev/null +++ b/packages/auto_apply/tests/infrastructure/test_ats_platforms.py @@ -0,0 +1,198 @@ +"""Pins for the platform-identity expansion (query-builder chain, call 2). + +Label honesty — each pin's docstring says which it is: + + TEETH — fails on the pre-change tree (no tenancy fields, no + is_terminal_page, six descriptors, no checklist doc). + GUARD — holds a property already true, so the expansion cannot silently + break it. + +I have not executed these; each names the mutation it catches. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from auto_apply.adapters.secondary.discovery.ats_registry import ATSRegistry +from auto_apply.application.services.page_analysis_router import PageAnalysisTier + +_ATS_DIR = ( + Path(__file__).resolve().parents[2] + / "src" / "auto_apply" / "resources" / "ats" +) +_SURFACES_DIR = ( + Path(__file__).resolve().parents[2] + / "src" / "auto_apply" / "resources" / "search_surfaces" +) +_CHECKLIST = ( + Path(__file__).resolve().parents[2] + / "docs" / "developer_guide" / "verifying_platform_search.md" +) + +_TENANCIES = {"none", "subdomain", "path", "opaque", "enterprise"} +_STATUSES = {"vendor", "measured", "third_party", "unverified"} + + +def test_no_platform_host_appears_in_the_search_surface_catalog(): + """GUARD (single source). The catalog holds engines and job boards; + platform hosts live only in resources/ats/. Mutation caught: a host list + copied into search_surfaces/.""" + registry = ATSRegistry() + for surface_path in _SURFACES_DIR.glob("*.yaml"): + text = surface_path.read_text(encoding="utf-8") + for descriptor in registry.all_descriptors(): + for host in descriptor.hosts: + assert host not in text, ( + f"{host!r} ({descriptor.name}) appears in {surface_path.name} " + "— platform hosts belong only in resources/ats/" + ) + + +def test_no_two_platforms_claim_the_same_host(): + """GUARD (data integrity). Mutation caught: two descriptors declaring + the same host — platform_for_host would silently pick the first.""" + seen: dict[str, str] = {} + for descriptor in ATSRegistry().all_descriptors(): + for host in descriptor.hosts: + assert host not in seen, f"{host!r} claimed by {seen[host]!r} and {descriptor.name!r}" + seen[host] = descriptor.name + + +def test_every_descriptor_carries_valid_search_data(): + """TEETH (the new fields did not exist). Mutation caught: an unknown + tenancy word, an unknown status, a measured status with no date, an + empty restriction, or a restriction whose host is not a declared host.""" + registry = ATSRegistry() + descriptors = registry.all_descriptors() + assert len(descriptors) >= 30, "the platform expansion did not land" + for d in descriptors: + assert d.tenancy in _TENANCIES, f"{d.name}: tenancy {d.tenancy!r}" + assert d.search_restriction_status in _STATUSES, ( + f"{d.name}: status {d.search_restriction_status!r}" + ) + if d.search_restriction_status == "measured": + assert d.search_restriction_verified_on, ( + f"{d.name}: measured without verified_on" + ) + assert d.search_restriction_source + assert d.search_restriction, f"{d.name}: no search_restriction" + restriction_host = d.search_restriction.split("/", 1)[0].lower() + assert any( + restriction_host == h or restriction_host.endswith("." + h) + for h in d.hosts + ), f"{d.name}: restriction {d.search_restriction!r} matches no declared host" + + +@pytest.mark.parametrize( + ("url", "expected"), + [ + ("https://boards.greenhouse.io/acme/jobs/123", "greenhouse"), + ("https://jobs.lever.co/acme/apply", "lever"), + ("https://acme.myworkdayjobs.com/en-US/corp/job/1", "workday"), + ("https://careers-acme.icims.com/jobs/123/job", "icims"), + ("https://acme.taleo.net/careersection/1/jobdetail.ftl?job=1", "taleo"), + ("https://jobs.ashbyhq.com/acme/apply", "ashby"), + ], +) +def test_the_existing_six_still_match_their_sample_urls(url, expected): + """GUARD. Mutation caught: a new pattern shadowing an existing platform + in sorted load order.""" + descriptor = ATSRegistry().match(url) + assert descriptor is not None and descriptor.name == expected + + +def test_a_discovery_only_platform_never_gets_the_known_platform_tier(): + """GUARD (tier safety). New descriptors ship with empty selectors, so + none may route to KNOWN_PLATFORM. Mutation caught: the router keying the + tier on match alone.""" + from auto_apply.application.services.page_analysis_router import ( + PageAnalysisRouter, + ) + + router = PageAnalysisRouter(ats_registry=ATSRegistry()) + tier = router.determine_tier("https://apply.workable.com/last-call-media/j/FF37BE66C5/apply/") + assert tier != PageAnalysisTier.KNOWN_PLATFORM + + +def test_a_platform_with_selectors_still_gets_the_known_platform_tier(): + """GUARD. Mutation caught: the terminal check or new fields breaking the + existing tier rule for the six.""" + from auto_apply.application.services.page_analysis_router import ( + PageAnalysisRouter, + ) + + router = PageAnalysisRouter(ats_registry=ATSRegistry()) + tier = router.determine_tier("https://boards.greenhouse.io/acme/jobs/123") + assert tier == PageAnalysisTier.KNOWN_PLATFORM + + +@pytest.mark.parametrize( + ("url", "terminal"), + [ + ("https://cerecore.catsone.com/careers/56866-General/jobs/16364730-System-Analyst/apply/success", True), + ("https://dealeron.theresumator.com/apply/confirm/G3gue6dy4D", True), + ("https://cerecore.catsone.com/careers/56866-General/jobs/16364730-System-Analyst", False), + ("https://boards.greenhouse.io/acme/jobs/123", False), + ], +) +def test_terminal_pages_are_recognised_and_postings_are_not(url, terminal): + """TEETH (is_terminal_page did not exist). Mutation caught: a terminal + pattern swallowing postings, or a success/confirm page reading as a + destination.""" + assert ATSRegistry().is_terminal_page(url) is terminal + + +@pytest.mark.parametrize( + ("url", "platform", "slug"), + [ + ("https://holisticai.bamboohr.com/careers/59", "bamboohr", "holisticai"), + ("https://jobs.jobvite.com/ziffdavis/job/orCdqfwq/apply", "jobvite", "ziffdavis"), + ( + "https://recruiterflow.com/db_cae6aa6c42ee795815955c2dab38824c/jobs/241", + "recruiterflow", + "db_cae6aa6c42ee795815955c2dab38824c", + ), + ( + "https://jobs.gusto.com/boards/possip-9d7e6ab2-bfc6-4b25-8517-fa812738ec56", + "gusto", + "possip-9d7e6ab2-bfc6-4b25-8517-fa812738ec56", + ), + ], +) +def test_tenant_slug_extraction(url, platform, slug): + """TEETH (the method did not exist): one subdomain tenancy, one path + tenancy, one opaque-ID platform from Nick's table, plus a path tenancy + with a shared static prefix. Mutation caught: returning the static + prefix, the host, or None.""" + descriptor = ATSRegistry().match(url) + assert descriptor is not None and descriptor.name == platform + assert descriptor.tenant_slug(url) == slug + + +@pytest.mark.parametrize( + ("url", "platform", "restriction"), + [ + ("https://holisticai.bamboohr.com/careers/59", "bamboohr", "holisticai.bamboohr.com/careers"), + ("https://jobs.jobvite.com/ziffdavis/job/orCdqfwq/apply", "jobvite", "jobs.jobvite.com/ziffdavis"), + (None, "jobvite", "jobs.jobvite.com"), + ], +) +def test_search_restriction_for_a_tenant_url(url, platform, restriction): + """TEETH. Mutation caught: ignoring the tenant, or returning the wrong + base when no URL is given.""" + descriptor = next( + d for d in ATSRegistry().all_descriptors() if d.name == platform + ) + assert descriptor.search_restriction_for(url) == restriction + + +def test_the_verification_checklist_covers_every_platform(): + """TEETH (the doc did not exist). Mutation caught: a platform added + without its checklist row.""" + text = _CHECKLIST.read_text(encoding="utf-8") + for descriptor in ATSRegistry().all_descriptors(): + assert descriptor.name in text, ( + f"{descriptor.name!r} missing from verifying_platform_search.md" + ) diff --git a/packages/auto_apply/tests/workflows/test_apply_route.py b/packages/auto_apply/tests/workflows/test_apply_route.py index b81eb43..349df68 100644 --- a/packages/auto_apply/tests/workflows/test_apply_route.py +++ b/packages/auto_apply/tests/workflows/test_apply_route.py @@ -7,7 +7,9 @@ """ from unittest.mock import MagicMock +from auto_apply.adapters.secondary.discovery.ats_registry import ATSRegistry from auto_apply.application.workflows.applications_workflow import ApplicationsWorkflow +from auto_apply.domain.models.application_evidence import ApplicationEvidence from auto_apply.domain.models.session_plan import SessionPlan @@ -530,6 +532,36 @@ def test_hop_bound_ends_with_honest_outcome( assert len(browser.get_calls) == 4 # initial load + 3 hops, then refusal +def test_a_terminal_page_is_never_a_route_destination(): + """TEETH — before the registry's terminal patterns, a landed URL that + matched a descriptor by host was a route stop even when the page was a + success/confirm page with no form. Mutation caught: is_terminal_page + ignored, or truthiness (a mock's MagicMock return) treated as terminal.""" + registry = ATSRegistry() + wf = object.__new__(ApplicationsWorkflow) + wf._ats_registry = registry + + job = MagicMock() + job.metadata = {} + + terminal_url = ( + "https://cerecore.catsone.com/careers/56866-General/" + "jobs/16364730-System-Analyst/apply/success" + ) + posting_url = ( + "https://cerecore.catsone.com/careers/56866-General/" + "jobs/16364730-System-Analyst" + ) + + _ev, matched = wf._match_landed_ats(job, terminal_url, ApplicationEvidence()) + assert matched is False + assert job.metadata.get("ats") is None + + _ev, matched = wf._match_landed_ats(job, posting_url, ApplicationEvidence()) + assert matched is True + assert job.metadata.get("ats") == "catsone" + + def test_jsless_fallback_needs_a_dialog_to_appear(): """GUARD: with no visibility probe (a JS-less driver), a sign-in modal that was already in the markup before the click is not a blocking dialog; one diff --git a/packages/auto_apply/tests/workflows/test_discovery_workflow.py b/packages/auto_apply/tests/workflows/test_discovery_workflow.py index 1b3d6fa..408e942 100644 --- a/packages/auto_apply/tests/workflows/test_discovery_workflow.py +++ b/packages/auto_apply/tests/workflows/test_discovery_workflow.py @@ -158,4 +158,59 @@ def test_handles_missing_optional_dependency_gracefully( # No SearchInstructions and no providers → 0 enqueued, no crash. count = wf.run() - assert count == 0 \ No newline at end of file + assert count == 0 + + +def test_company_page_mining_is_gated_off_by_default( + mock_profile, mock_event_bus, mock_task_queue, mock_text_matcher +): + """TEETH — before the gate, the mining loop called the (broken) miner + for every ATS-matched job and failed each time. Mutation caught: the + plan gate ignored, or the scraper called with the gate off.""" + ats_registry = MagicMock() + ats_registry.match.return_value = MagicMock() # every URL matches + scraper = MagicMock() + wf = DiscoveryWorkflow( + profile=mock_profile, + providers=[], + task_queue=mock_task_queue, + event_bus=mock_event_bus, + dedup=MagicMock(), + text_matcher=mock_text_matcher, + ats_registry=ats_registry, + company_page_scraper=scraper, + ) + jobs = [ + Job(title="SWE", company="Acme", url="https://boards.greenhouse.io/acme/jobs/1", source="test"), + ] + assert wf._scrape_company_pages(jobs) == [] + scraper.assert_not_called() + + +def test_company_page_mining_when_enabled_uses_the_navigating_scraper( + mock_profile, mock_event_bus, mock_task_queue, mock_text_matcher +): + """TEETH — when enabled, mining calls the careers-page scraper with the + job URL, which is the measured URL-as-browser AttributeError being fixed. + Mutation caught: calling the deprecated miner param, or passing anything + but the URL.""" + from auto_apply.domain.models.session_plan import SessionPlan + + ats_registry = MagicMock() + ats_registry.match.return_value = MagicMock() + scraper = MagicMock(return_value=[]) + plan = SessionPlan(session_id="t", enable_company_page_mining=True) + wf = DiscoveryWorkflow( + profile=mock_profile, + providers=[], + task_queue=mock_task_queue, + event_bus=mock_event_bus, + dedup=MagicMock(), + text_matcher=mock_text_matcher, + ats_registry=ats_registry, + company_page_scraper=scraper, + plan=plan, + ) + job = Job(title="SWE", company="Acme", url="https://boards.greenhouse.io/acme/jobs/1", source="test") + wf._scrape_company_pages([job]) + scraper.assert_called_once_with("https://boards.greenhouse.io/acme/jobs/1") \ No newline at end of file From e8525a216bd1a22ef9e7cd6f87605238e26ee6c8 Mon Sep 17 00:00:00 2001 From: Nick Liebmann <53795413+Liebmann5@users.noreply.github.com> Date: Wed, 28 Oct 2026 06:30:56 -0700 Subject: [PATCH 3/5] Query builder: UI port methods, shared domain helpers, CLI search screen --- CHANGELOG.md | 15 + packages/auto_apply/docs/reference/cli.md | 38 +- .../primary/cli/search_builder_screen.py | 322 ++++++++++ .../discovery/search_surface_registry.py | 4 +- .../application/services/query_builder.py | 74 ++- .../services/session_controller.py | 77 ++- .../src/auto_apply/domain/exceptions.py | 10 + .../auto_apply/domain/models/search_query.py | 118 +++- .../domain/models/search_surface.py | 4 +- .../src/auto_apply/domain/ports/ui_port.py | 65 +- .../infrastructure/composition_root.py | 46 +- packages/auto_apply/src/auto_apply/main.py | 128 ++++ .../src/auto_apply/resources/locales/en.json | 36 ++ .../src/auto_apply/resources/locales/es.json | 36 ++ .../tests/adapters/test_search_builder_cli.py | 565 ++++++++++++++++++ .../test_query_builder_platforms.py | 6 +- .../application/test_query_builder_service.py | 8 +- .../application/test_ui_port_conformance.py | 18 +- .../tests/architecture/test_safety_pins.py | 9 + .../tests/domain/test_query_composer.py | 2 +- .../tests/domain/test_ui_contract.py | 19 +- 21 files changed, 1538 insertions(+), 62 deletions(-) create mode 100644 packages/auto_apply/src/auto_apply/adapters/primary/cli/search_builder_screen.py create mode 100644 packages/auto_apply/tests/adapters/test_search_builder_cli.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 90395f4..52bb1f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,21 @@ from its first tag onward. (`docs/developer_guide/verifying_platform_search.md`): the exact query to paste per platform, what a correct result looks like, and how to record the outcome so the descriptor's status upgrades. +- **Query builder on the CLI.** `--search` opens an interactive screen; the + scripted flags (`--search-surface`, `--search-platform`, `--search-company`, + `--search-terms`, `--search-any-of`, `--search-exclude`, `--search-in-title`, + `--search-location`, `--search-option KEY=VALUE`, `--search-open`, + `--search-keep`, `--search-recent`, `--search-clear-recent`) build the same + verified queries — engine searches, `site:`-restricted platform searches and + tenant-scoped company searches. A query the surface cannot express is + refused in plain words with a non-zero exit, never a blank result. Copy, + open and keep are the only actions that remember a query, and a remembered + platform or company query is stored resolved and replays identically. +- **`UIPort` grows from 17 to 26 methods** for the query builder, and its + data-only rule now names two frozen DTO modules (`ui_contract`, + `search_query`). `QueryBuilderError` moved to `domain/exceptions.py` and is + re-exported from `application/services/query_builder.py`, so existing + imports keep working. ### Changed diff --git a/packages/auto_apply/docs/reference/cli.md b/packages/auto_apply/docs/reference/cli.md index b553601..29d3d76 100644 --- a/packages/auto_apply/docs/reference/cli.md +++ b/packages/auto_apply/docs/reference/cli.md @@ -1,7 +1,7 @@ --- title: Command-Line Reference status: reviewed -last_verified: 2026-09-19 +last_verified: 2026-10-28 verified_against: "src/auto_apply/main.py argument parser" audience: everyone --- @@ -32,8 +32,37 @@ auto-apply # console script, available after install | `--replay-out` | folder | Where `--replay` writes. Default: `reports/replay_` in AA's data folder | | `--research` | — | Open the interactive research screen (see whether research is on, read the consent text, agree, withdraw with optional export-then-delete, page copies, export), then exit. Needs no profile; starts no session | | `--label` | — | Label saved pages and log the applications you make by hand. See [Labelling](../user_guide/labelling.md) | +| `--search` | — | Open the interactive search builder (compose a verified engine, platform or company search), then exit. No session, no automation. Scripted flags take precedence | +| `--search-surface` | `NAME` | Scripted builder: search surface (`google`, `bing`, `duckduckgo`, `indeed`, `linkedin`; default `google`) | +| `--search-platform` | `NAME` | Scripted builder: a hiring platform — builds a `site:`-restricted engine search | +| `--search-company` | `URL` | Scripted builder: a company careers-page URL — scopes the search to that tenant | +| `--search-terms` | `TEXT` | Exact words or phrases, comma separated | +| `--search-any-of` | `TEXT` | Alternatives, comma separated (where the surface supports them) | +| `--search-exclude` | `TEXT` | Words to exclude, comma separated | +| `--search-in-title` | `TEXT` | Words that must appear in the title | +| `--search-location` | `TEXT` | Location, for surfaces that take one | +| `--search-option` | `KEY=VALUE` | A surface option (repeatable), e.g. `date=day` | +| `--search-open` | — | Open the built link in your browser (remembers the query) | +| `--search-keep` | — | Remember the built query without opening it | +| `--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 | +## Building a search + +```bash +python -m auto_apply --search +python -m auto_apply --search-platform workday --search-terms "Software Engineer" +python -m auto_apply --search-company "https://holisticai.bamboohr.com/careers/59" --search-open +python -m auto_apply --search-recent +``` + +Composes verified engine, platform (`site:`-restricted) and tenant-scoped +company searches. What a surface cannot express is refused in plain words — +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). + ## Flags parsed before anything else `--portable` and `--seed` are read by a **pre-parser** that runs before any @@ -111,9 +140,10 @@ 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` and -`--encrypt-profile` exit after their work; the others run until you stop the -session. +`--export-research`, `--research-summary`, `--research`, `--replay`, `--label`, +`--encrypt-profile` 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. `[PLANNED]` — a documented exit-code table. Do not script against exit codes until it exists. diff --git a/packages/auto_apply/src/auto_apply/adapters/primary/cli/search_builder_screen.py b/packages/auto_apply/src/auto_apply/adapters/primary/cli/search_builder_screen.py new file mode 100644 index 0000000..d4a997b --- /dev/null +++ b/packages/auto_apply/src/auto_apply/adapters/primary/cli/search_builder_screen.py @@ -0,0 +1,322 @@ +"""The query-builder screen for the CLI (--search and the --search-* flags). + +Composition stays in main.py; every print and every input lives here, so +main.py's pinned print-site count does not move (the consent-screen +precedent). The screen is offline by construction: the only outward call is +webbrowser.open, and only on an explicit user action. + +Remembering is deliberate: a preview or a build writes nothing to the +device. Copy, open and keep are the only actions that remember a query, and +a platform or company query is remembered resolved — its site: restriction +folded in — so it replays identically as an engine query. +""" + +from __future__ import annotations + +import webbrowser + +from auto_apply.application.services.i18n import get_text +from auto_apply.domain.models.search_query import ( + build_request, + describe_request, + format_result_lines, + platform_display_text, + resolved_remember_request, +) +from auto_apply.infrastructure.composition_root import build_query_builder + +__all__ = ["run_search"] + + +class _Quit(Exception): + """The person asked to stop.""" + + +def _ask(prompt: str) -> str: + """input() that treats EOF and Ctrl-C as "quit" — never an answer.""" + text = prompt.rstrip() + if not text.endswith(":"): + text += ":" + try: + return input(text + " ").strip() + except (EOFError, KeyboardInterrupt): + print() # noqa: T201 — newline after ^C/^D + raise _Quit from None + + +def _parse_options(raw: list[str] | None) -> tuple[tuple[str, str], ...]: + """--search-option KEY=VALUE, repeatable; a malformed one is noted and + skipped, never silently dropped.""" + options: list[tuple[str, str]] = [] + for item in raw or []: + key, sep, value = item.partition("=") + if not sep or not key.strip(): + print(f" {get_text('builder.bad_option')} {item}") # noqa: T201 + continue + options.append((key.strip(), value.strip())) + return tuple(options) + + +def _render(result) -> None: + """Print the result: labels through the locale, notes verbatim (they are + the composer's plain-language sentences).""" + for line in format_result_lines(result, translate=get_text): + print(line) # noqa: T201 + + +def _build(service, request, platform, company_url): + """Build for the chosen target; returns None after printing the error.""" + try: + if platform: + return service.build_platform_search(platform, request) + if company_url: + return service.build_company_search(company_url, request) + return service.build(request) + except Exception as exc: # noqa: BLE001 — the builder's message is the user-facing contract + print(f" {get_text('builder.error')}: {exc}") # noqa: T201 + return None + + +def _fold(service, request, platform, company_url): + """Resolve a platform or company query to the engine query to remember. + + The restriction comes from the service's one resolver + (platform_restriction_for), so the remembered request replays + identically. + """ + if platform: + return resolved_remember_request( + request, service.platform_restriction_for(platform) + ) + if company_url: + return resolved_remember_request( + request, service.platform_restriction_for(url=company_url) + ) + return request + + +def _remember(service, request, platform, company_url) -> None: + service.remember(_fold(service, request, platform, company_url)) + + +def _scripted(args, service) -> int: + request = build_request( + surface=args.search_surface or "google", + terms=args.search_terms or "", + any_of=args.search_any_of or "", + exclude=args.search_exclude or "", + in_title=args.search_in_title or "", + location=args.search_location or "", + options=_parse_options(args.search_option), + ) + result = _build(service, request, args.search_platform, args.search_company) + if result is None: + return 2 + _render(result) + if result.bar_text is None and result.url is None: + return 1 + if args.search_keep or args.search_open: + _remember(service, request, args.search_platform, args.search_company) + print(f" {get_text('builder.kept')}") # noqa: T201 + if args.search_open: + if result.url: + webbrowser.open(result.url) + print(f" {get_text('builder.opened')}") # noqa: T201 + else: + print(f" {get_text('builder.no_link')}") # noqa: T201 + return 0 + + +def _choose_named(prompt: str, names: list[str], display) -> str | None: + """A picker that accepts a number or the name itself; q or Enter quits.""" + while True: + print(f" {prompt}") # noqa: T201 + for index, name in enumerate(names, 1): + print(f" {index}) {display(name)}") # noqa: T201 + raw = _ask(f" {get_text('builder.choose')}") + if raw.lower() in ("", "q", "quit"): + return None + if raw.isdigit() and 1 <= int(raw) <= len(names): + return names[int(raw) - 1] + if raw in names: + return raw + print(f" {get_text('builder.not_on_list')} {raw}") # noqa: T201 + + +def _choose_surface(service) -> str | None: + offers = service.surfaces() + names = [o.name for o in offers] + display = {o.name: o.display_name for o in offers} + return _choose_named( + get_text("builder.surface_prompt"), names, lambda n: display[n] + ) + + +def _choose_target() -> str | None: + options = ( + get_text("builder.target_surface"), + get_text("builder.target_platform"), + get_text("builder.target_company"), + ) + while True: + print(f" {get_text('builder.target_prompt')}") # noqa: T201 + for index, label in enumerate(options, 1): + print(f" {index}) {label}") # noqa: T201 + raw = _ask(f" {get_text('builder.choose')}") + if raw.lower() in ("", "q", "quit"): + return None + if raw.isdigit() and 1 <= int(raw) <= 3: + return ("surface", "platform", "company")[int(raw) - 1] + print(f" {get_text('builder.not_on_list')} {raw}") # noqa: T201 + + +def _choose_platform(service) -> str | None: + offers = service.platform_offers() + names = [o.name for o in offers] + display = {o.name: platform_display_text(o) for o in offers} + return _choose_named( + get_text("builder.platform_prompt"), names, lambda n: display[n] + ) + + +def _collect_inputs(service, surface: str): + defaults = service.defaults() + terms_default = ", ".join(defaults.titles) + if terms_default: + print(f" {get_text('builder.saved_note')} {terms_default}") # noqa: T201 + terms = _ask( + f" {get_text('builder.terms_prompt')}" + + (f" [{terms_default}]" if terms_default else "") + ) + if not terms: + terms = terms_default + location_default = defaults.locations[0] if defaults.locations else "" + location = _ask( + f" {get_text('builder.location_prompt')}" + + (f" [{location_default}]" if location_default else "") + ) + if not location: + location = location_default + any_of = _ask(f" {get_text('builder.any_of_prompt')}") + exclude = _ask(f" {get_text('builder.exclude_prompt')}") + in_title = _ask(f" {get_text('builder.in_title_prompt')}") + return build_request( + surface=surface, + terms=terms, + any_of=any_of, + exclude=exclude, + in_title=in_title, + location=location, + ) + + +def _actions(service, request, result, platform, company_url) -> str: + """The post-preview loop. Returns "rebuild" or "quit".""" + has_output = result.bar_text is not None or result.url is not None + while True: + print(f" {get_text('builder.actions_line')}") # noqa: T201 + choice = _ask(f" {get_text('builder.choose')}").lower() + if choice in ("", "q"): + return "quit" + if choice == "r": + return "rebuild" + if choice in ("c", "o", "k") and not has_output: + print(f" {get_text('builder.nothing_built')}") # noqa: T201 + continue + if choice == "c": + if result.bar_text: + print(f" {result.bar_text}") # noqa: T201 + if result.url: + print(f" {result.url}") # noqa: T201 + _remember(service, request, platform, company_url) + print(f" {get_text('builder.kept')}") # noqa: T201 + elif choice == "o": + if not result.url: + print(f" {get_text('builder.no_link')}") # noqa: T201 + continue + webbrowser.open(result.url) + _remember(service, request, platform, company_url) + print(f" {get_text('builder.opened')}") # noqa: T201 + elif choice == "k": + _remember(service, request, platform, company_url) + print(f" {get_text('builder.kept')}") # noqa: T201 + else: + print(f" {get_text('builder.not_on_list')} {choice}") # noqa: T201 + + +def _interactive(service) -> int: + print() # noqa: T201 + print(f" {get_text('builder.title')}") # noqa: T201 + while True: + surface = _choose_surface(service) + if surface is None: + return 0 + target = _choose_target() + if target is None: + return 0 + platform = company_url = None + if target == "platform": + platform = _choose_platform(service) + if platform is None: + return 0 + elif target == "company": + company_url = _ask(f" {get_text('builder.company_prompt')}") + if not company_url: + return 0 + while True: + request = _collect_inputs(service, surface) + result = _build(service, request, platform, company_url) + if result is None: + break + _render(result) + if _actions(service, request, result, platform, company_url) == "quit": + return 0 + + +def _recent(service) -> int: + recent = service.recent() + if not recent: + print(get_text("builder.recent_empty")) # noqa: T201 + return 0 + print(get_text("builder.recent_title")) # noqa: T201 + for request in recent: + print(f" {describe_request(request)}") # noqa: T201 + return 0 + + +def run_search(args, *, profile=None, builder=None) -> int: + """Run the search builder. Returns the exit code. + + Args: + args: the parsed CLI arguments (the --search-* flags). + profile: the profile one-click defaults are read from (read-only). + builder: a query-builder service; built pre-session when omitted. + """ + service = builder if builder is not None else build_query_builder(profile) + try: + if args.search_clear_recent: + service.clear_recent() + print(get_text("builder.recent_cleared")) # noqa: T201 + return 0 + if args.search_recent: + return _recent(service) + scripted = any( + ( + args.search_surface, + args.search_platform, + args.search_company, + args.search_terms, + args.search_any_of, + args.search_exclude, + args.search_in_title, + args.search_location, + args.search_option, + args.search_open, + args.search_keep, + ) + ) + if scripted: + return _scripted(args, service) + return _interactive(service) + except _Quit: + return 0 diff --git a/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/search_surface_registry.py b/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/search_surface_registry.py index 012fdd4..6ddee72 100644 --- a/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/search_surface_registry.py +++ b/packages/auto_apply/src/auto_apply/adapters/secondary/discovery/search_surface_registry.py @@ -19,8 +19,8 @@ * ``load_surface_bundled`` (bundled only) — the automation path. AA's automated discovery must send the same URL bytes no matter what a user has overridden for their own manual searches, so engine_strategies reads - only the bundled catalog until chain 2's provenance records a catalog - digest on every job. + only the bundled catalog until job provenance records a catalog digest + on every job (planned; not yet built). """ from __future__ import annotations 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 69fd528..5bd5720 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 @@ -1,4 +1,4 @@ -"""The query builder service — the application seam the surfaces call (turn 3). +"""The query builder service — the application seam the surfaces call. What this service owns: @@ -43,11 +43,14 @@ from collections.abc import Callable from pathlib import Path +from auto_apply.application.services.i18n import get_text from auto_apply.domain.config import USER_DATA_DIR +from auto_apply.domain.exceptions import QueryBuilderError from auto_apply.domain.models.search_query import ( BuilderDefaults, OfferedCapability, OfferedChoice, + PlatformOffer, QueryNote, QueryRequest, QueryResult, @@ -66,8 +69,8 @@ DEFAULT_MAX_RECENT = 5 -class QueryBuilderError(RuntimeError): - """The builder could not complete the request.""" +# QueryBuilderError lives in domain.exceptions (imported above); existing +# imports from this module keep working through that re-export. class QueryBuilderService: @@ -163,10 +166,9 @@ def defaults(self) -> BuilderDefaults: def build(self, request: QueryRequest) -> QueryResult: """Compose the bar text and URL. Writes NOTHING. - Turn 3's surfaces may rebuild the preview as the user types, so - building must not touch the disk. Recording is the surfaces' explicit - choice via remember(), called only when the user copies or opens a - result. + The surfaces may rebuild the preview as the user types, so building + must not touch the disk. Recording is the surfaces' explicit choice + via remember(), called only when the user copies or opens a result. """ if self._load_spec is None: raise QueryBuilderError( @@ -191,6 +193,59 @@ def platforms(self) -> tuple[ATSDescriptor, ...]: return () return tuple(d for d in self._ats_registry.all_descriptors() if d.hosts) + def platform_offers(self) -> tuple[PlatformOffer, ...]: + """Every targetable platform with its verification label. + + The label is computed here — once, from the shared locale strings — + so every surface renders the same words and carries no status + vocabulary of its own. Measured and vendor-verified platforms carry + no badge. + """ + offers: list[PlatformOffer] = [] + for descriptor in self.platforms(): + status = descriptor.search_restriction_status + if status == "unverified": + label = get_text("builder.platform.unverified") + elif status == "third_party": + label = get_text("builder.platform.third_party") + else: + label = "" + offers.append( + PlatformOffer(name=descriptor.name, status=status, label=label) + ) + return tuple(offers) + + def platform_restriction_for( + self, platform: str | None = None, url: str | None = None + ) -> str: + """The resolved site: restriction — for a platform name, optionally + narrowed to a tenant URL, or for a tenant URL alone (the platform is + matched from the URL). + + This is the one definition of what a platform or company query + resolves to, so a remembered query replays identically. + """ + if url is not None: + if self._ats_registry is None: + raise QueryBuilderError( + "no ATS registry is wired into the query builder" + ) + descriptor = self._ats_registry.match(url) + if descriptor is None: + raise QueryBuilderError( + f"no hiring platform is known for {url!r}" + ) + if platform is not None and descriptor.name != platform: + raise QueryBuilderError( + f"{url!r} is on {descriptor.name!r}, not {platform!r}" + ) + return descriptor.search_restriction_for(url) + if platform is None: + raise QueryBuilderError( + "platform_restriction_for needs a platform or a URL" + ) + return self._descriptor_for(platform).search_restriction_for() + def build_platform_search( self, platform: str, request: QueryRequest ) -> QueryResult: @@ -199,13 +254,12 @@ def build_platform_search( The restriction is composed through the normal composer, so the same refusals, notes and length limits apply. Writes nothing. """ - descriptor = self._descriptor_for(platform) if request.site: raise QueryBuilderError( "the request already carries a site restriction" ) restricted = request.model_copy( - update={"site": descriptor.search_restriction_for()} + update={"site": self.platform_restriction_for(platform)} ) return self.build(restricted) @@ -244,7 +298,7 @@ def build_company_search(self, url: str, request: QueryRequest) -> QueryResult: "the request already carries a site restriction" ) restricted = request.model_copy( - update={"site": descriptor.search_restriction_for(url)} + update={"site": self.platform_restriction_for(url=url)} ) return self.build(restricted) 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 6c96f62..152fc79 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 @@ -144,9 +144,17 @@ ) from auto_apply.domain.config import USER_DATA_DIR from auto_apply.domain.events import Event +from auto_apply.domain.exceptions import QueryBuilderError from auto_apply.domain.models.application_evidence import ApplicationEvidence from auto_apply.domain.models.job import Job from auto_apply.domain.models.profile import UserProfile +from auto_apply.domain.models.search_query import ( + BuilderDefaults, + PlatformOffer, + QueryRequest, + QueryResult, + SurfaceOffer, +) from auto_apply.domain.models.session_plan import SessionExecutionMode, SessionPlan from auto_apply.domain.models.session_report import SessionReport from auto_apply.domain.models.task_priority import TaskPriority @@ -432,10 +440,10 @@ class SessionController: session's research observer registered with, so a mid-session withdrawal stops collection immediately. shutdown() stops the observer (flushing its queue) whether or not a run is active. - 11. Query builder (turn 1 of the query-builder chain): the injected - QueryBuilderService is exposed as the query_builder property for - the surfaces (turn 3). It composes user-authored searches from - the search-surface catalog; nothing about it is automated. + 11. Query builder: the injected QueryBuilderService is exposed as + the query_builder property and through the builder port methods. + It composes user-authored searches from the search-surface + catalog; nothing about it is automated. The controller is stateful — one instance per session. When the session ends, call shutdown() — it releases the browser whether or not a run is @@ -1692,11 +1700,68 @@ def query_builder(self) -> "QueryBuilderService | None": """The query-builder service, if one was injected. Built by the composition root from the search-surface catalog; the - builder surfaces (turn 3) read it through here. None in tests and - legacy constructions — those surfaces degrade to no builder. + builder surfaces read it through here. None in tests and legacy + constructions — the port methods below then degrade to empty + answers, and the build calls raise. """ return self._query_builder + # ── Query builder (the port's builder surface) ─────────────────────────── + + def query_surfaces(self) -> tuple[SurfaceOffer, ...]: + """Every search surface the builder may offer, verification applied.""" + builder = self._query_builder + return builder.surfaces() if builder is not None else () + + def query_platforms(self) -> tuple[PlatformOffer, ...]: + """Every targetable hiring platform, with its verification label.""" + builder = self._query_builder + return builder.platform_offers() if builder is not None else () + + def query_defaults(self) -> BuilderDefaults: + """The profile's saved search values as one-click choices.""" + builder = self._query_builder + return builder.defaults() if builder is not None else BuilderDefaults() + + def build_query(self, request: QueryRequest) -> QueryResult: + """Compose bar text and/or a URL for one surface.""" + return self._require_builder().build(request) + + def build_platform_query(self, platform: str, request: QueryRequest) -> QueryResult: + """A domain-restricted engine search for one hiring platform.""" + return self._require_builder().build_platform_search(platform, request) + + def build_company_query(self, url: str, request: QueryRequest) -> QueryResult: + """A company-scoped engine search from a pasted tenant URL.""" + return self._require_builder().build_company_search(url, request) + + def remember_query(self, request: QueryRequest) -> None: + """Record a built query as one of the recent few (explicit only).""" + builder = self._query_builder + if builder is not None: + builder.remember(request) + + def recent_queries(self) -> tuple[QueryRequest, ...]: + """The remembered queries, newest first.""" + builder = self._query_builder + return builder.recent() if builder is not None else () + + def clear_recent_queries(self) -> None: + """Forget every remembered query.""" + builder = self._query_builder + if builder is not None: + builder.clear_recent() + + def _require_builder(self) -> "QueryBuilderService": + """The builder or a clear error — a build with no builder is a bug.""" + builder = self._query_builder + if builder is None: + raise QueryBuilderError( + "no query builder is wired to this controller — build the " + "controller via build_session_controller, which injects one." + ) + return builder + def get_queue_stats(self) -> dict[str, int]: """Returns work queue status counts from the database. diff --git a/packages/auto_apply/src/auto_apply/domain/exceptions.py b/packages/auto_apply/src/auto_apply/domain/exceptions.py index 01a443a..59d8518 100644 --- a/packages/auto_apply/src/auto_apply/domain/exceptions.py +++ b/packages/auto_apply/src/auto_apply/domain/exceptions.py @@ -164,6 +164,16 @@ class LogicError(AutoApplyException): pass +class QueryBuilderError(AutoApplyException): + """The query builder could not complete the request. + + Raised for an unknown surface or platform, a conflicting site + restriction, or a missing registry — always with a plain-language + message the surfaces can print verbatim. + """ + pass + + # ── Application runtime (separate root — preserved deliberately) ──────────── 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 cfc3324..b341257 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 @@ -1,13 +1,18 @@ """The query builder's request/result/offer vocabulary — frozen data, no I/O. QueryRequest is the typed intermediate form every consumer shares: the -builder's surfaces produce one, the composer consumes one, and the second -chain's automated planner will manipulate the same shape. Nothing here holds -a live object (no profile, no driver, no store). +builder's surfaces produce one, the composer consumes one, and a future +automated planner manipulates the same shape. Nothing here holds a live +object (no profile, no driver, no store). + +The helpers below shape and render that vocabulary. They take strings and +frozen values, never live objects; the locale-dependent renderer takes an +injected translator, so every helper stays pure. """ from __future__ import annotations +from collections.abc import Callable from typing import Literal from pydantic import BaseModel, ConfigDict @@ -87,3 +92,110 @@ class BuilderDefaults(_Frozen): titles: tuple[str, ...] = () locations: tuple[str, ...] = () workplace_types: tuple[str, ...] = () + + +class PlatformOffer(_Frozen): + """A hiring platform the builder can target, with its verification label. + + The label is computed once by the service, from the shared locale + strings, so every surface renders the same words; a measured or + vendor-verified platform carries an empty label (no badge). + """ + + name: str + status: str + label: str = "" + + +def _split_terms(text: str) -> tuple[str, ...]: + return tuple(part.strip() for part in text.split(",") if part.strip()) + + +def build_request( + *, + surface: str, + terms: str = "", + any_of: str = "", + exclude: str = "", + in_title: str = "", + location: str = "", + site: str | None = None, + options: tuple[tuple[str, str], ...] = (), + template: str | None = None, +) -> QueryRequest: + """Shape one QueryRequest from a surface's raw strings. + + Search words are exact phrases: the comma-separated terms — typed, or + picked from the saved-titles one-clicks a surface shows — map to + ``phrases``. ``required`` stays in the model; the surfaces never + produce it. + """ + return QueryRequest( + surface=surface, + phrases=_split_terms(terms), + any_of=_split_terms(any_of), + exclude=_split_terms(exclude), + in_title=_split_terms(in_title), + location=location.strip(), + site=site, + options=options, + template=template, + ) + + +def describe_request(request: QueryRequest) -> str: + """One line for the recent list: the surface, then what was asked for.""" + parts: list[str] = [] + parts.extend(request.phrases) + if request.any_of: + parts.append(" | ".join(request.any_of)) + parts.extend(f"-{term}" for term in request.exclude) + parts.extend(f"intitle:{term}" for term in request.in_title) + if request.site: + parts.append(f"site:{request.site}") + if request.location: + parts.append(request.location) + body = " ".join(parts) if parts else "(no terms)" + if request.options: + body += " [" + " ".join(f"{key}={value}" for key, value in request.options) + "]" + return f"{request.surface}: {body}" + + +def platform_display_text(offer: PlatformOffer) -> str: + """The one picker line for a platform: its name, plus its label if any.""" + if not offer.label: + return offer.name + return f"{offer.name} — {offer.label}" + + +def format_result_lines( + result: QueryResult, + translate: Callable[[str], str] = lambda key: key, +) -> list[str]: + """The result as display lines. + + Notes render verbatim: they are the composer's plain-language sentences + and carry their own wording. The two labels are locale-dependent, so the + caller injects its translator; the default returns keys unchanged. + """ + lines: list[str] = [] + if result.bar_text is not None: + lines.append(f"{translate('builder.result.bar')}: {result.bar_text}") + if result.url is not None: + lines.append(f"{translate('builder.result.link')}: {result.url}") + for note in result.notes: + lines.append(f" {note.message}") + return lines + + +def resolved_remember_request( + request: QueryRequest, restriction: str | None +) -> QueryRequest: + """Fold a resolved site: restriction into the request being remembered. + + A remembered platform or company query is stored resolved, so it replays + identically as an engine query. + """ + if not restriction: + return request + return request.model_copy(update={"site": restriction}) diff --git a/packages/auto_apply/src/auto_apply/domain/models/search_surface.py b/packages/auto_apply/src/auto_apply/domain/models/search_surface.py index 81b3d25..9307656 100644 --- a/packages/auto_apply/src/auto_apply/domain/models/search_surface.py +++ b/packages/auto_apply/src/auto_apply/domain/models/search_surface.py @@ -2,8 +2,8 @@ This model is the single source of truth for "what can this search bar or search URL do", shared by AA's automated engine strategies -(engine_strategies.py) and the user-facing query builder (turns 1-3 of the -query-builder chain). It used to live as three hardcoded maps in +(engine_strategies.py) and the user-facing query builder +(application/services/query_builder.py). It used to live as three hardcoded maps in engine_strategies.py; the byte-identity pin in tests/adapters/test_engine_url_byte_identity.py proves the move changed no automation output. 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 233d226..b336115 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,17 +25,21 @@ 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: 17 methods. +Whole surface, deliberately: 26 methods. ----------------------------------------- AA's idiom prefers narrow ports, but this port describes a whole surface, -not a capability, and the ProfileRepositoryPort precedent applies. The 17 +not a capability, and the ProfileRepositoryPort precedent applies. The 26 methods are measured from the real consumed surface — the 11 legacy calls both adapters actually make today (initialize_session, start, stop, pause, resume, get_stats, get_current_state, is_running, get_queue_stats, get_pending_approvals, provide_approval) plus one new stream query (recent_events), plus three custody methods added when the results surface landed (export_session_results, export_profile, list_session_history), -plus the two autonomy methods added at stage E1 (autonomy, set_autonomy). +plus the two autonomy methods added at stage E1 (autonomy, set_autonomy), +plus the nine query-builder methods (query_surfaces, query_platforms, +query_defaults, build_query, build_platform_query, build_company_query, +remember_query, recent_queries, clear_recent_queries), added when the +builder's surfaces landed. tests/domain/test_ui_contract.py and tests/application/test_ui_port_conformance.py pin this exact set, so growth is mechanical, not accidental. If the consumers only ever take halves, splitting then is cheap — the types do @@ -58,6 +62,11 @@ frozen answer) ; set_autonomy(enabled, acknowledgements) → bool (write: the profile, persisted, effective next session) + (query builder) → query_surfaces / query_platforms / + query_defaults / build_query / + build_platform_query / build_company_query / + remember_query / recent_queries / + clear_recent_queries Ruling: "there is no session right now" is a VALUE, not None, not an exception. -------------------------------------------------------------------------------- @@ -108,8 +117,9 @@ Types only ---------- -Every parameter and return type in this port is a frozen DTO from -ui_contract, an Enum, a Path, or a builtin. No UserProfile, no driver, no +Every parameter and return type in this port is a frozen DTO from the +named DTO modules (ui_contract, search_query), an Enum, a Path, or a +builtin. No UserProfile, no driver, no repository, no orchestrator, no callback. tests/domain/test_ui_contract.py enforces it mechanically against this Protocol, and proves the checker discriminates by running it against the CURRENT SessionController, which @@ -127,6 +137,13 @@ from pathlib import Path from typing import Protocol, runtime_checkable +from auto_apply.domain.models.search_query import ( + BuilderDefaults, + PlatformOffer, + QueryRequest, + QueryResult, + SurfaceOffer, +) from auto_apply.domain.models.ui_contract import ( ApprovalRequest, QueueSnapshot, @@ -280,3 +297,41 @@ def recent_events(self, limit: int = 50) -> tuple[SessionEventRecord, ...]: Empty tuple when no session exists. """ ... + + # ── Query builder ───────────────────────────────────────────────────── + + def query_surfaces(self) -> tuple[SurfaceOffer, ...]: + """Every search surface the builder may offer (verification applied).""" + ... + + def query_platforms(self) -> tuple[PlatformOffer, ...]: + """Every hiring platform the builder can target, with its label.""" + ... + + def query_defaults(self) -> BuilderDefaults: + """The profile's saved search values as one-click choices.""" + ... + + def build_query(self, request: QueryRequest) -> QueryResult: + """Compose bar text and/or a URL for one surface.""" + ... + + def build_platform_query(self, platform: str, request: QueryRequest) -> QueryResult: + """A domain-restricted engine search for one hiring platform.""" + ... + + def build_company_query(self, url: str, request: QueryRequest) -> QueryResult: + """A company-scoped engine search from a pasted tenant URL.""" + ... + + def remember_query(self, request: QueryRequest) -> None: + """Record a built query as one of the recent few (explicit only).""" + ... + + def recent_queries(self) -> tuple[QueryRequest, ...]: + """The remembered queries, newest first.""" + ... + + def clear_recent_queries(self) -> None: + """Forget every remembered query.""" + ... 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 49a8e31..455e519 100644 --- a/packages/auto_apply/src/auto_apply/infrastructure/composition_root.py +++ b/packages/auto_apply/src/auto_apply/infrastructure/composition_root.py @@ -89,6 +89,7 @@ VerifyResult, ) from auto_apply.domain.models.replay import ReplayReport + from auto_apply.application.services.query_builder import QueryBuilderService # Re-export so existing callers don't break. __all__ = [ @@ -104,6 +105,7 @@ "UninstallReport", "build_orchestrator", "build_page_copier", + "build_query_builder", "build_research_consent", "build_session", "build_session_controller", @@ -1532,6 +1534,33 @@ def _refuse_no_browser(registry: CapabilitiesRegistry, cascade: BrowserCascade) raise BrowserSetupError(message) +def build_query_builder(profile: "UserProfile | None" = None) -> "QueryBuilderService": + """Build the query-builder service — the surfaces' one route to it. + + Session path: build_session_controller passes the session's profile. + Pre-session path (the CLI search screen): called with profile=None. + + configure_locale(language=None) auto-detects the OS locale and is a + no-op once any caller configured one, so the builder's labels render as + text, never as raw keys, whether or not a session is running. It logs + one INFO line ("Locale configured") the first time it runs. + """ + from auto_apply.application.services.i18n import configure_locale # noqa: PLC0415 + from auto_apply.application.services.query_builder import QueryBuilderService # noqa: PLC0415 + from auto_apply.adapters.secondary.discovery.search_surface_registry import ( # noqa: PLC0415 + list_surface_names, + load_surface, + ) + + configure_locale(language=None) + return QueryBuilderService( + profile=profile, + load_spec=load_surface, + list_names=list_surface_names, + ats_registry=_build_ats_registry(), + ) + + def _register_exit_shutdown( controller: "SessionController", instances: "InstanceRegistry | None" = None, @@ -1622,20 +1651,9 @@ def build_session_controller( research_consent = build_research_consent(registry) orchestrator = build_orchestrator(registry, research_consent=research_consent) - # The query builder serves the user-facing surfaces (turn 3); it reads - # the profile only as one-click defaults and never writes to it. - from auto_apply.application.services.query_builder import QueryBuilderService # noqa: PLC0415 - from auto_apply.adapters.secondary.discovery.search_surface_registry import ( # noqa: PLC0415 - list_surface_names, - load_surface, - ) - - query_builder = QueryBuilderService( - profile=profile, - load_spec=load_surface, - list_names=list_surface_names, - ats_registry=_build_ats_registry(), - ) + # The query builder serves the user-facing surfaces; it reads the + # profile only as one-click defaults and never writes to it. + query_builder = build_query_builder(profile) # 3. Assemble the controller (all deps injected). If assembly fails after # the orchestrator exists, its browser has no owner yet — release it here diff --git a/packages/auto_apply/src/auto_apply/main.py b/packages/auto_apply/src/auto_apply/main.py index c78a344..f628bb2 100644 --- a/packages/auto_apply/src/auto_apply/main.py +++ b/packages/auto_apply/src/auto_apply/main.py @@ -546,6 +546,30 @@ def _handle_verify_research(args) -> None: sys.exit(run_verify_research(Path(args.verify_research).expanduser())) +def _handle_search(args, profile_repo) -> None: + """Build a search query (interactive or scripted), then exit. + + Composition only, mirroring --label: every print and every input lives + in the CLI adapter, so this file's pinned print-site count does not + move. Pre-session: no orchestrator and no browser. The builder reads the + first user profile (else the template) for one-click defaults only, and + never writes to it. + """ + from auto_apply.adapters.primary.cli.search_builder_screen import ( # noqa: PLC0415 + run_search, + ) + + profile = None + if profile_repo is not 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 + sys.exit(run_search(args, profile=profile)) + + def _handle_label() -> None: """Run the labelling tool (item 5), then exit. @@ -917,6 +941,91 @@ def _sigint_handler(sig, frame): "Example: python -m auto_apply --encrypt-profile" ), ) + parser.add_argument( + "--search", + action="store_true", + help=( + "Open the interactive search builder (compose a verified engine, " + "platform or company search), then exit. No session, no " + "automation. Scripted flags below take precedence over the " + "interactive screen." + ), + ) + parser.add_argument( + "--search-surface", + metavar="NAME", + default=None, + help="Scripted builder: search surface (google, bing, duckduckgo, indeed, linkedin; default google).", + ) + parser.add_argument( + "--search-platform", + metavar="NAME", + default=None, + help="Scripted builder: a hiring platform — builds a site:-restricted engine search.", + ) + parser.add_argument( + "--search-company", + metavar="URL", + default=None, + help="Scripted builder: a company careers-page URL — scopes the search to that tenant.", + ) + parser.add_argument( + "--search-terms", + metavar="TEXT", + default=None, + help="Exact words or phrases, comma separated.", + ) + parser.add_argument( + "--search-any-of", + metavar="TEXT", + default=None, + help="Alternatives, comma separated (where the surface supports them).", + ) + parser.add_argument( + "--search-exclude", + metavar="TEXT", + default=None, + help="Words to exclude, comma separated.", + ) + parser.add_argument( + "--search-in-title", + metavar="TEXT", + default=None, + help="Words that must appear in the title.", + ) + parser.add_argument( + "--search-location", + metavar="TEXT", + default=None, + help="Location, for surfaces that take one.", + ) + parser.add_argument( + "--search-option", + action="append", + metavar="KEY=VALUE", + default=None, + help="A surface option (repeatable), e.g. --search-option date=day.", + ) + parser.add_argument( + "--search-open", + action="store_true", + help="Open the built link in your browser (remembers the query).", + ) + parser.add_argument( + "--search-keep", + action="store_true", + help="Remember the built query without opening it.", + ) + parser.add_argument( + "--search-recent", + action="store_true", + help="List your recent built searches, then exit.", + ) + parser.add_argument( + "--search-clear-recent", + action="store_true", + help="Forget every remembered search, then exit.", + ) parser.add_argument( "--uninstall", action="store_true", @@ -1106,6 +1215,25 @@ def _sigint_handler(sig, frame): if args.verify_research: _handle_verify_research(args) + # 4g. Search builder (exits after building — no session started) + if ( + args.search + or args.search_recent + or args.search_clear_recent + or args.search_surface + or args.search_platform + or args.search_company + or args.search_terms + or args.search_any_of + or args.search_exclude + or args.search_in_title + or args.search_location + or args.search_option + or args.search_open + or args.search_keep + ): + _handle_search(args, profile_repo) + # 5. Profile encryption mode (exits after encryption) if args.encrypt_profile: _handle_encrypt_profile(profile_repo) 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 52505fb..268a492 100644 --- a/packages/auto_apply/src/auto_apply/resources/locales/en.json +++ b/packages/auto_apply/src/auto_apply/resources/locales/en.json @@ -180,5 +180,41 @@ "stats_section_title": "Session Metrics", "log_section_title": "Live Activity Feed", "status_idle": "Ready to start..." + }, + "builder": { + "title": "Search builder", + "surface_prompt": "Search with:", + "target_prompt": "Search:", + "target_surface": "The search site itself", + "target_platform": "One hiring platform", + "target_company": "A company careers page (paste its link)", + "platform_prompt": "Hiring platform:", + "company_prompt": "Company careers link:", + "terms_prompt": "Exact words or phrases (comma separated)", + "location_prompt": "Location", + "any_of_prompt": "Any of these (comma separated)", + "exclude_prompt": "Without these (comma separated)", + "in_title_prompt": "In the title only (comma separated)", + "saved_note": "Saved:", + "actions_line": "[c] copy [o] open [k] keep [r] rebuild [q] quit", + "choose": "Choose:", + "not_on_list": "Not on the list:", + "kept": "Kept in your recent searches.", + "opened": "Opened in your browser — kept in your recent searches.", + "no_link": "This surface has no link to open — paste the search bar text yourself.", + "nothing_built": "Nothing was built — nothing to copy, open or keep.", + "recent_title": "Recent searches", + "recent_empty": "No recent searches yet.", + "recent_cleared": "Recent searches cleared.", + "error": "Could not build that search", + "bad_option": "Not a KEY=VALUE option, skipped:", + "result": { + "bar": "Search bar text", + "link": "Link" + }, + "platform": { + "unverified": "Unverified: the search mechanism is documented, but how much of this platform it reaches is not yet known. Open the link yourself and check that results come from the platform; the user guide shows how to report the outcome.", + "third_party": "Reported by others; AA has not checked it." + } } } \ 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 fbb0d76..73a20fd 100644 --- a/packages/auto_apply/src/auto_apply/resources/locales/es.json +++ b/packages/auto_apply/src/auto_apply/resources/locales/es.json @@ -142,5 +142,41 @@ "stats_section_title": "Métricas de Sesión", "log_section_title": "Actividad en Vivo", "status_idle": "Listo para comenzar..." + }, + "builder": { + "title": "Constructor de búsquedas", + "surface_prompt": "Buscar con:", + "target_prompt": "Buscar en:", + "target_surface": "El propio sitio de búsqueda", + "target_platform": "Una plataforma de contratación", + "target_company": "La página de empleo de una empresa (pegue su enlace)", + "platform_prompt": "Plataforma de contratación:", + "company_prompt": "Enlace de la página de empleo de la empresa:", + "terms_prompt": "Palabras o frases exactas (separadas por comas)", + "location_prompt": "Ubicación", + "any_of_prompt": "Cualquiera de estas (separadas por comas)", + "exclude_prompt": "Sin estas (separadas por comas)", + "in_title_prompt": "Solo en el título (separadas por comas)", + "saved_note": "Guardado:", + "actions_line": "[c] copiar [o] abrir [k] guardar [r] reconstruir [q] salir", + "choose": "Elija:", + "not_on_list": "No está en la lista:", + "kept": "Guardada en sus búsquedas recientes.", + "opened": "Abierta en su navegador — guardada en sus búsquedas recientes.", + "no_link": "Esta superficie no tiene enlace para abrir — pegue usted mismo el texto de búsqueda.", + "nothing_built": "No se construyó nada — no hay nada que copiar, abrir o guardar.", + "recent_title": "Búsquedas recientes", + "recent_empty": "Aún no hay búsquedas recientes.", + "recent_cleared": "Búsquedas recientes borradas.", + "error": "No se pudo construir esa búsqueda", + "bad_option": "No es una opción CLAVE=VALOR, omitida:", + "result": { + "bar": "Texto de la barra de búsqueda", + "link": "Enlace" + }, + "platform": { + "unverified": "Sin verificar: el mecanismo de búsqueda está documentado, pero aún no se sabe qué parte de esta plataforma alcanza. Abra el enlace usted mismo y compruebe que los resultados provienen de la plataforma; la guía del usuario muestra cómo informar del resultado.", + "third_party": "Reportado por otros; AA no lo ha comprobado." + } } } \ No newline at end of file diff --git a/packages/auto_apply/tests/adapters/test_search_builder_cli.py b/packages/auto_apply/tests/adapters/test_search_builder_cli.py new file mode 100644 index 0000000..a584831 --- /dev/null +++ b/packages/auto_apply/tests/adapters/test_search_builder_cli.py @@ -0,0 +1,565 @@ +"""Pins for the query builder's CLI screen and the shared domain helpers. + +Label honesty per pin: + TEETH — fail against the pre-change tree by construction: the screen, the + helpers, the port methods and the service methods did not exist there. + GUARD — hold behaviour this change must not break (the locale bundles' + structure, the main-parser registration pattern, the screen's offline and + GUI-free rules). +I have not executed these; each names the mutation it catches. +""" + +from __future__ import annotations + +import ast +import json +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from auto_apply.adapters.primary.cli import search_builder_screen as screen +from auto_apply.adapters.secondary.discovery.ats_registry import ATSRegistry +from auto_apply.adapters.secondary.discovery.search_surface_registry import ( + list_surface_names, + load_surface, +) +from auto_apply.application.agent.context import SessionStatistics +from auto_apply.application.services.i18n import get_text +from auto_apply.application.services.query_builder import QueryBuilderService +from auto_apply.application.services.session_controller import SessionController +from auto_apply.domain.exceptions import QueryBuilderError +from auto_apply.domain.models.search_query import ( + BuilderDefaults, + PlatformOffer, + QueryNote, + QueryResult, + build_request, + describe_request, + format_result_lines, + platform_display_text, + resolved_remember_request, +) +from auto_apply.domain.models.session_plan import SessionPlan + +_PKG_ROOT = Path(__file__).resolve().parents[2] +_LOCALES = _PKG_ROOT / "src" / "auto_apply" / "resources" / "locales" + + +def _service(tmp_path: Path) -> QueryBuilderService: + """A real service over a tmp recent file — real catalog, real registry.""" + return QueryBuilderService( + profile=None, + load_spec=load_surface, + list_names=list_surface_names, + ats_registry=ATSRegistry(), + recent_path=tmp_path / "query_builder_recent.json", + ) + + +def _args(**over): + base = dict( + search=False, + search_surface=None, + search_platform=None, + search_company=None, + search_terms=None, + search_any_of=None, + search_exclude=None, + search_in_title=None, + search_location=None, + search_option=None, + search_open=False, + search_keep=False, + search_recent=False, + search_clear_recent=False, + ) + base.update(over) + return SimpleNamespace(**base) + + +class _Script: + """Scripted stdin; exhaustion is EOF, which the screen reads as quit.""" + + def __init__(self, answers: list[str]) -> None: + self._answers = iter(answers) + + def __call__(self, prompt: str = "") -> str: + try: + return next(self._answers) + except StopIteration: + raise EOFError from None + + +def _controller(builder) -> SessionController: + plan = SessionPlan(session_id="qb-pin") + registry = MagicMock() + registry.get_session_plan.return_value = plan + registry.get_active_profile.return_value = MagicMock() + db = MagicMock() + db.get_queue_stats.return_value = { + "pending": 0, "in_progress": 0, "completed": 0, + "failed": 0, "skipped": 0, "permanently_failed": 0, + } + orchestrator = MagicMock() + orchestrator.session_plan = plan + orchestrator.context = MagicMock() + orchestrator.context.session_id = "qb-pin" + orchestrator.context.stats = SessionStatistics() + orchestrator.state_machine = MagicMock() + orchestrator.task_queue = db + return SessionController( + registry=registry, db=db, orchestrator=orchestrator, query_builder=builder + ) + + +# ── Domain helpers ─────────────────────────────────────────────────────────── + +def test_build_request_maps_terms_to_phrases__teeth() -> None: + """TEETH: comma-separated search words — typed, or one-click saved + titles — map to phrases. Mutation caught: terms landing in `required` + or `any_of`, or whitespace surviving.""" + request = build_request(surface="google", terms="Software Engineer, QA Lead") + assert request.phrases == ("Software Engineer", "QA Lead") + assert request.required == () + assert request.any_of == () + assert build_request(surface="google", terms=" ").phrases == () + + +def test_build_request_shapes_the_rest_and_never_produces_required__teeth() -> None: + """TEETH: the surfaces never produce `required` — the field stays in the + model for the automated path. Mutation caught: a term string writing + anywhere but its own field.""" + request = build_request( + surface="bing", + terms="x", + any_of="a, b", + exclude="c", + in_title="d", + location="Remote", + site="example.com", + options=(("date", "day"),), + ) + assert request.required == () + assert request.any_of == ("a", "b") + assert request.exclude == ("c",) + assert request.in_title == ("d",) + assert request.location == "Remote" + assert request.site == "example.com" + assert request.options == (("date", "day"),) + + +def test_describe_request_renders_terms_site_and_options__teeth() -> None: + """TEETH: the recent list's one line. Mutation caught: a renderer that + drops the site or the options.""" + request = build_request( + surface="bing", terms="x", site="example.com", options=(("date", "day"),) + ) + line = describe_request(request) + assert line.startswith("bing: ") + assert "x" in line and "site:example.com" in line and "date=day" in line + + +def test_platform_display_text_badge_rules__teeth() -> None: + """TEETH: name only when no badge; name plus label when one exists. + Mutation caught: badges on everything, or none ever.""" + bare = PlatformOffer(name="jobvite", status="measured", label="") + assert platform_display_text(bare) == "jobvite" + badged = PlatformOffer(name="workday", status="unverified", label="Unverified: x") + shown = platform_display_text(badged) + assert shown.startswith("workday") and "Unverified" in shown and shown != "workday" + + +def test_format_result_lines_injects_translator__teeth() -> None: + """TEETH: the renderer's labels come from the injected translator, so + the domain helper stays pure. Mutation caught: a hardcoded label.""" + result = QueryResult( + surface="google", display_name="Google", + bar_text='("x")', url="https://www.google.com/search?q=x", notes=(), + ) + lines = format_result_lines(result, translate=lambda key: f"T:{key}") + assert lines[0].startswith("T:builder.result.bar:") + assert lines[1].startswith("T:builder.result.link:") + default_lines = format_result_lines(result) + assert default_lines[0].startswith("builder.result.bar:") + + +def test_format_result_lines_renders_refusal_notes_when_nothing_built__teeth() -> None: + """TEETH (never a blank result): no bar, no link — the refusal notes + still render. Mutation caught: notes dropped when both outputs are None.""" + result = QueryResult( + surface="indeed", display_name="Indeed", bar_text=None, url=None, + notes=(QueryNote(severity="refused", topic="site", message="no site limit exists"),), + ) + lines = format_result_lines(result, translate=get_text) + assert lines == [" no site limit exists"] + + +def test_resolved_remember_request_folds_site_only__teeth() -> None: + """TEETH (remembered platform/company queries are stored resolved). + Mutation caught: folding anything but `site`, or folding an empty + restriction.""" + request = build_request(surface="google", terms="x") + folded = resolved_remember_request(request, "jobs.lever.co") + assert folded.site == "jobs.lever.co" + assert folded.phrases == request.phrases + assert resolved_remember_request(request, "") is request + assert resolved_remember_request(request, None) is request + + +# ── Service ────────────────────────────────────────────────────────────────── + +def test_platform_offers_labels_unverified_only__teeth(tmp_path) -> None: + """TEETH: unverified platforms carry the shared label; measured and + vendor-verified carry none. Mutation caught: badges inverted or absent. + Label text is compared against the locale lookup, not hardcoded.""" + offers = {o.name: o for o in _service(tmp_path).platform_offers()} + assert offers["workday"].status == "unverified" + assert offers["workday"].label == get_text("builder.platform.unverified") + assert offers["workday"].label + assert offers["jobvite"].status == "measured" + assert offers["jobvite"].label == "" + + +def test_platform_offers_covers_the_registry__teeth(tmp_path) -> None: + """TEETH: every registered platform with a host is offered — no second + list. Mutation caught: a filtered or hardcoded subset.""" + names = {o.name for o in _service(tmp_path).platform_offers()} + expected = {d.name for d in ATSRegistry().all_descriptors() if d.hosts} + assert names == expected + assert len(names) >= 30 + + +def test_platform_restriction_for_platform_url_and_tenant__teeth(tmp_path) -> None: + """TEETH: the one restriction resolver — platform, tenant URL, and + platform+URL agree with the ATS data. Mutation caught: the slug ignored, + or the shared path prefix dropped.""" + service = _service(tmp_path) + assert service.platform_restriction_for("jobvite") == "jobs.jobvite.com" + assert ( + service.platform_restriction_for(url="https://holisticai.bamboohr.com/careers/59") + == "holisticai.bamboohr.com/careers" + ) + assert ( + service.platform_restriction_for( + "bamboohr", "https://holisticai.bamboohr.com/careers/59" + ) + == "holisticai.bamboohr.com/careers" + ) + + +def test_platform_restriction_for_refuses_unknowns_loudly__teeth(tmp_path) -> None: + """TEETH. Mutation caught: a quiet empty string where the answer should + be a loud refusal.""" + service = _service(tmp_path) + with pytest.raises(QueryBuilderError): + service.platform_restriction_for("nonsense") + with pytest.raises(QueryBuilderError): + service.platform_restriction_for(url="https://example.org/jobs/1") + with pytest.raises(QueryBuilderError): + service.platform_restriction_for() + + +# ── Controller (the port's builder surface) ────────────────────────────────── + +def test_controller_delegates_the_builder_methods__teeth() -> None: + """TEETH: all nine port methods delegate to the injected service. + Mutation caught: any one delegating to the wrong method or swallowing + the result.""" + service = MagicMock() + controller = _controller(service) + request = build_request(surface="google", terms="x") + + assert controller.query_surfaces() is service.surfaces.return_value + assert controller.query_platforms() is service.platform_offers.return_value + assert controller.query_defaults() is service.defaults.return_value + assert controller.build_query(request) is service.build.return_value + assert ( + controller.build_platform_query("workday", request) + is service.build_platform_search.return_value + ) + assert ( + controller.build_company_query("https://x", request) + is service.build_company_search.return_value + ) + controller.remember_query(request) + service.remember.assert_called_once_with(request) + assert controller.recent_queries() is service.recent.return_value + controller.clear_recent_queries() + service.clear_recent.assert_called_once_with() + + +def test_controller_without_a_builder_defaults_and_raises__teeth() -> None: + """TEETH (the no-builder ruling): queries degrade to empty answers; the + three build calls raise a clear error. Mutation caught: a build that + quietly returns a fake result.""" + controller = _controller(None) + assert controller.query_surfaces() == () + assert controller.query_platforms() == () + assert controller.query_defaults() == BuilderDefaults() + assert controller.recent_queries() == () + controller.clear_recent_queries() # no-op, no raise + request = build_request(surface="google", terms="x") + with pytest.raises(QueryBuilderError): + controller.build_query(request) + with pytest.raises(QueryBuilderError): + controller.build_platform_query("workday", request) + with pytest.raises(QueryBuilderError): + controller.build_company_query("https://x", request) + + +# ── Scripted CLI ───────────────────────────────────────────────────────────── + +@pytest.mark.parametrize( + ("platform", "expected"), + [ + ("jobvite", 'site:jobs.jobvite.com ("Software Engineer")'), + ("bamboohr", 'site:bamboohr.com/careers ("Software Engineer")'), + ("gusto", 'site:jobs.gusto.com/boards ("Software Engineer")'), + ], +) +def test_three_queries_via_the_flag_path__teeth(tmp_path, capsys, platform, expected) -> None: + """TEETH: the three known-good platform queries, through the CLI flags. + Mutation caught: any one-character drift in the restriction or the + composer's format.""" + code = screen.run_search( + _args(search_platform=platform, search_terms="Software Engineer"), + builder=_service(tmp_path), + ) + assert code == 0 + out = capsys.readouterr().out + assert expected in out + assert "google.com/search" in out + + +def test_flag_refusal_renders_and_exits_nonzero__teeth(tmp_path, capsys) -> None: + """TEETH: a restriction the surface cannot express is refused — rendered, + non-zero, never a blank result. Mutation caught: exit 0, or silence.""" + code = screen.run_search( + _args(search_surface="indeed", search_platform="workday", search_terms="x"), + builder=_service(tmp_path), + ) + out = capsys.readouterr().out + assert code == 1 + assert out.strip(), "a refusal printed nothing — the user saw a blank result" + + code = screen.run_search( + _args(search_platform="nonsense", search_terms="x"), + builder=_service(tmp_path), + ) + assert code == 2 + assert "nonsense" in capsys.readouterr().out + + +def test_open_calls_webbrowser_only_on_explicit_action__teeth(tmp_path) -> None: + """TEETH: webbrowser.open fires only on --search-open. Mutation caught: + opening on build or preview.""" + with patch.object(screen.webbrowser, "open") as mock_open: + assert screen.run_search( + _args(search_platform="jobvite", search_terms="x"), + builder=_service(tmp_path), + ) == 0 + mock_open.assert_not_called() + + with patch.object(screen.webbrowser, "open") as mock_open: + assert screen.run_search( + _args(search_platform="jobvite", search_terms="x", search_open=True), + builder=_service(tmp_path), + ) == 0 + mock_open.assert_called_once() + assert "google.com/search" in mock_open.call_args.args[0] + + +def test_keep_remembers_resolved_and_replays_identically__teeth(tmp_path) -> None: + """TEETH (copy/open/keep call remember — never build or preview; a + remembered platform or company query replays identically). Mutation + caught: remember on build, or a remembered query that builds to + something else.""" + service = _service(tmp_path) + assert screen.run_search( + _args(search_platform="jobvite", search_terms="Software Engineer"), + builder=service, + ) == 0 + assert service.recent() == (), "a build with no action remembered something" + + assert screen.run_search( + _args(search_platform="jobvite", search_terms="Software Engineer", search_keep=True), + builder=service, + ) == 0 + remembered = service.recent() + assert len(remembered) == 1 + assert remembered[0].site == "jobs.jobvite.com" + replayed = service.build(remembered[0]) + assert replayed.bar_text == 'site:jobs.jobvite.com ("Software Engineer")' + + assert screen.run_search( + _args( + search_company="https://holisticai.bamboohr.com/careers/59", + search_terms="x", + search_keep=True, + ), + builder=service, + ) == 0 + remembered = service.recent() + assert len(remembered) == 2 + company_entry = remembered[0] # newest first + assert company_entry.site == "holisticai.bamboohr.com/careers" + assert service.build(company_entry).bar_text == 'site:holisticai.bamboohr.com/careers ("x")' + + +def test_recent_then_clear__teeth(tmp_path, capsys) -> None: + """TEETH: --search-recent lists, --search-clear-recent forgets. + Mutation caught: a list that writes, or a clear that leaves the file.""" + service = _service(tmp_path) + screen.run_search( + _args(search_platform="jobvite", search_terms="x", search_keep=True), + builder=service, + ) + capsys.readouterr() + assert screen.run_search(_args(search_recent=True), builder=service) == 0 + out = capsys.readouterr().out + assert "jobs.jobvite.com" in out + assert screen.run_search(_args(search_clear_recent=True), builder=service) == 0 + assert service.recent() == () + + +# ── Interactive CLI ────────────────────────────────────────────────────────── + +def test_same_query_interactive_matches_the_flag_path__teeth(tmp_path, capsys, monkeypatch) -> None: + """TEETH: the interactive screen builds the identical query from a + scripted session. Mutation caught: the interactive path composing + differently from the flag path.""" + monkeypatch.setattr("builtins.input", _Script([ + "google", "2", "jobvite", "Software Engineer", "", "", "", "", "q", + ])) + code = screen.run_search(_args(search=True), builder=_service(tmp_path)) + assert code == 0 + out = capsys.readouterr().out + assert 'site:jobs.jobvite.com ("Software Engineer")' in out + + +def test_preview_never_remembers_and_copy_does__teeth(tmp_path, monkeypatch) -> None: + """TEETH: preview and quit write nothing; copy remembers exactly once. + Mutation caught: remember on preview, or copy forgetting.""" + service = _service(tmp_path) + monkeypatch.setattr("builtins.input", _Script([ + "google", "1", "x", "", "", "", "", "q", + ])) + assert screen.run_search(_args(search=True), builder=service) == 0 + assert service.recent() == () + + monkeypatch.setattr("builtins.input", _Script([ + "google", "1", "x", "", "", "", "", "c", "q", + ])) + assert screen.run_search(_args(search=True), builder=service) == 0 + remembered = service.recent() + assert len(remembered) == 1 + assert remembered[0].phrases == ("x",) + assert remembered[0].site is None + + +def test_interactive_quit_is_clean__teeth(tmp_path, monkeypatch) -> None: + """TEETH: q at the first prompt exits 0 with nothing built and nothing + remembered. Mutation caught: a quit that builds or writes.""" + service = _service(tmp_path) + monkeypatch.setattr("builtins.input", _Script(["q"])) + assert screen.run_search(_args(search=True), builder=service) == 0 + assert service.recent() == () + + +# ── Static guarantees ──────────────────────────────────────────────────────── + +def test_screen_is_gui_free_and_offline__guard() -> None: + """GUARD (rules: CLI is GUI-free and offline): no tkinter, no network + library, no secondary-adapter import; webbrowser is the one allowed + outward call. Mutation caught: any new import of those.""" + source = Path(screen.__file__).read_text(encoding="utf-8") + banned = ("tkinter", "socket", "requests", "httpx", "selenium", "playwright", "urllib") + for node in ast.walk(ast.parse(source)): + if isinstance(node, ast.Import): + for alias in node.names: + assert not alias.name.startswith(banned), alias.name + assert ".adapters.secondary" not in alias.name, alias.name + elif isinstance(node, ast.ImportFrom) and node.module: + assert not node.module.startswith(banned), node.module + assert ".adapters.secondary" not in node.module, node.module + + +def test_screen_carries_no_status_words__teeth() -> None: + """TEETH (the surfaces carry no status words of their own): the + verification vocabulary lives in the locale strings via the service, + never in the screen. Mutation caught: a hardcoded badge in the screen.""" + source = Path(screen.__file__).read_text(encoding="utf-8").lower() + for word in ("unverified", "third_party", "measured", "vendor"): + assert word not in source, f"the CLI screen carries the status word {word!r}" + + +def test_builder_locale_key_sets_identical_en_es__guard() -> None: + """GUARD (the locales rule): the en and es builder sections carry the + same keys. Mutation caught: a key added to one locale only. (Whole-file + equality is NOT asserted: es.json legitimately lags en.json elsewhere.)""" + en = json.loads((_LOCALES / "en.json").read_text(encoding="utf-8")) + es = json.loads((_LOCALES / "es.json").read_text(encoding="utf-8")) + + def _flat(section: dict) -> set[str]: + out: set[str] = set() + + def walk(node: dict, prefix: str) -> None: + for key, value in node.items(): + if isinstance(value, dict): + walk(value, f"{prefix}{key}.") + else: + out.add(f"{prefix}{key}") + + walk(section, "") + return out + + assert "builder" in en and "builder" in es + assert _flat(en["builder"]) == _flat(es["builder"]) + + +@pytest.mark.parametrize( + "argv", + [ + ["--search"], + ["--search-recent"], + ["--search-clear-recent"], + ["--search-platform", "jobvite", "--search-terms", "x"], + [ + "--search-surface", "google", + "--search-company", "https://holisticai.bamboohr.com/careers/59", + "--search-any-of", "a,b", + "--search-exclude", "c", + "--search-in-title", "d", + "--search-location", "Remote", + "--search-option", "date=day", + "--search-open", "--search-keep", + ], + ], +) +def test_main_registers_and_dispatches_the_search_flags__teeth(monkeypatch, argv) -> None: + """TEETH: every flag is registered (argparse exits 2 on an unregistered + one) and any of them dispatches to the search handler before any session + starts. Mutation caught: a flag added to the screen but never parsed, or + a dispatch condition missing one.""" + import auto_apply.main as main_mod + + dispatched = {} + + def _fake_handle(args, repo): + dispatched["args"] = args + raise SystemExit(0) + + monkeypatch.setattr(main_mod, "setup_logging", lambda **kw: None) + monkeypatch.setattr(main_mod, "build_session", lambda: MagicMock()) + monkeypatch.setattr(main_mod, "_handle_search", _fake_handle) + monkeypatch.setattr("sys.argv", ["auto_apply", *argv]) + + with pytest.raises(SystemExit) as exc: + main_mod.main() + assert exc.value.code == 0, ( + "the parse or dispatch did not complete — a flag is unregistered " + "(argparse exits 2) or the dispatch condition missed it" + ) + assert "args" in dispatched diff --git a/packages/auto_apply/tests/application/test_query_builder_platforms.py b/packages/auto_apply/tests/application/test_query_builder_platforms.py index 7110baf..1b7fd14 100644 --- a/packages/auto_apply/tests/application/test_query_builder_platforms.py +++ b/packages/auto_apply/tests/application/test_query_builder_platforms.py @@ -1,9 +1,9 @@ -"""Pins for the builder's platform search (query-builder chain, call 2). +"""Pins for the builder's platform search. Label honesty per pin: TEETH fail on the pre-change tree (no platform methods, no registry injection, no site_template, no refusal-both); GUARDs -hold call-1 behaviour the expansion must not break. I have not executed -these; each names the mutation it catches. +hold the surface-level behaviour the platform expansion must not break. +I have not executed these; each names the mutation it catches. """ from __future__ import annotations diff --git a/packages/auto_apply/tests/application/test_query_builder_service.py b/packages/auto_apply/tests/application/test_query_builder_service.py index 8a265a7..fec4ec3 100644 --- a/packages/auto_apply/tests/application/test_query_builder_service.py +++ b/packages/auto_apply/tests/application/test_query_builder_service.py @@ -1,4 +1,4 @@ -"""Pins for the query-builder service (turn 1 of the query-builder chain). +"""Pins for the query-builder service. Label honesty: GUARD (new surface) — the service did not exist before this change, so no @@ -73,8 +73,8 @@ def test_unverified_and_uncomposable_capabilities_are_never_offered(tmp_path): def test_build_leaves_the_recent_file_untouched(tmp_path): - """GUARD (the preview rule): building writes nothing — turn 3's surfaces - may rebuild a preview on every keystroke, so recording on build would + """GUARD (the preview rule): building writes nothing — the GUI and CLI + surfaces may rebuild a preview on every keystroke, so recording on build would store half-typed queries. Mutation caught: build() calling _record_recent.""" service = _service(tmp_path) service.build(QueryRequest(surface="google", phrases=("x",))) @@ -165,7 +165,7 @@ def test_the_builder_modules_import_no_network_browser_or_research_code(): def test_the_session_controller_exposes_the_builder(): - """GUARD (turn-3 seam): the controller holds the injected service. + """GUARD: the controller holds the injected builder service. Mutation caught: dropping the dependency or the property.""" from auto_apply.application.services.session_controller import SessionController 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 0520432..40c4df0 100644 --- a/packages/auto_apply/tests/application/test_ui_port_conformance.py +++ b/packages/auto_apply/tests/application/test_ui_port_conformance.py @@ -73,6 +73,11 @@ _UI_CONTRACT_MODULE = "auto_apply.domain.models.ui_contract" +#: The data-only rule admits a named, pinned set of frozen DTO modules. +_ALLOWED_DTO_MODULES = frozenset( + {_UI_CONTRACT_MODULE, "auto_apply.domain.models.search_query"} +) + _EXPECTED_PORT_METHODS = { "initialize_session", "start", "stop", "pause", "resume", "provide_approval", "snapshot", "summary", "queue_snapshot", @@ -81,6 +86,11 @@ # E1: the autonomy read and write. The port went from 15 methods to 17; # the controller must satisfy the wider surface structurally, no wrapper. "autonomy", "set_autonomy", + # The query builder grew the port from 17 to 26 — the controller must + # satisfy the wider surface structurally, no wrapper. + "query_surfaces", "query_platforms", "query_defaults", + "build_query", "build_platform_query", "build_company_query", + "remember_query", "recent_queries", "clear_recent_queries", } @@ -591,7 +601,7 @@ def _is_data_type(t: object) -> bool: if isinstance(t, type): if issubclass(t, Enum): return True - if issubclass(t, BaseModel) and t.__module__ == _UI_CONTRACT_MODULE: + if issubclass(t, BaseModel) and t.__module__ in _ALLOWED_DTO_MODULES: return True return False return False @@ -660,9 +670,9 @@ def test_session_request_is_frozen() -> None: def test_ui_port_type_hints_are_data_only() -> None: - """TEETH: every method on UIPort resolves to frozen DTOs from - ui_contract, enums, Path, or builtins — no live objects. The - discrimination proof that this check has teeth is in + """TEETH: every method on UIPort resolves to frozen DTOs from the named + DTO modules (_ALLOWED_DTO_MODULES), enums, Path, or builtins — no live + objects. The discrimination proof that this check has teeth is in test_type_checker_rejects_session_controller_today.""" methods = _port_methods(UIPort) assert set(methods) == _EXPECTED_PORT_METHODS, ( diff --git a/packages/auto_apply/tests/architecture/test_safety_pins.py b/packages/auto_apply/tests/architecture/test_safety_pins.py index 6947610..f372121 100644 --- a/packages/auto_apply/tests/architecture/test_safety_pins.py +++ b/packages/auto_apply/tests/architecture/test_safety_pins.py @@ -210,6 +210,15 @@ def test_print_sites_outside_the_primary_adapters() -> None: # import the secondary verifier itself. "auto_apply.infrastructure.composition_root", }, + "cli/search_builder_screen.py": { + # The search screen reaches the wiring layer for the pre-session + # builder factory (composition_root.build_query_builder) — the + # consent-screen precedent — and application.services.i18n for its + # locale path: the first CLI screen with translated chrome (the + # consent screen's text is untranslatable by design). + "auto_apply.infrastructure.composition_root", + "auto_apply.application.services.i18n", + }, "cli/lifecycle_screen.py": { # The install/uninstall screen reaches the wiring layer for the # lifecycle engines and wording (composition_root) — the same diff --git a/packages/auto_apply/tests/domain/test_query_composer.py b/packages/auto_apply/tests/domain/test_query_composer.py index 5930782..d41dc5a 100644 --- a/packages/auto_apply/tests/domain/test_query_composer.py +++ b/packages/auto_apply/tests/domain/test_query_composer.py @@ -1,4 +1,4 @@ -"""Pins for the query composer (turn 1 of the query-builder chain). +"""Pins for the query composer (domain/services/query_composer.py). Label honesty: GUARD (new surface) — every pin here covers behaviour that did not exist diff --git a/packages/auto_apply/tests/domain/test_ui_contract.py b/packages/auto_apply/tests/domain/test_ui_contract.py index fe20ba9..cd24aaa 100644 --- a/packages/auto_apply/tests/domain/test_ui_contract.py +++ b/packages/auto_apply/tests/domain/test_ui_contract.py @@ -42,6 +42,11 @@ _UI_CONTRACT_MODULE = "auto_apply.domain.models.ui_contract" +#: The data-only rule admits a named, pinned set of frozen DTO modules. +_ALLOWED_DTO_MODULES = frozenset( + {_UI_CONTRACT_MODULE, "auto_apply.domain.models.search_query"} +) + _EXPECTED_PORT_METHODS = { "initialize_session", "start", "stop", "pause", "resume", "provide_approval", "snapshot", "summary", "queue_snapshot", @@ -59,6 +64,12 @@ # set_autonomy — the write: the control both surfaces use to change the # choice, guarded by two acknowledgements. "autonomy", "set_autonomy", + # The query builder grew the port from 17 to 26: surfaces, platforms and + # defaults queries; the three build calls; the three recent-queries + # commands. Each returns data only. + "query_surfaces", "query_platforms", "query_defaults", + "build_query", "build_platform_query", "build_company_query", + "remember_query", "recent_queries", "clear_recent_queries", } @@ -97,7 +108,7 @@ def _is_data_type(t: object) -> bool: if isinstance(t, type): if issubclass(t, Enum): return True - if issubclass(t, BaseModel) and t.__module__ == _UI_CONTRACT_MODULE: + if issubclass(t, BaseModel) and t.__module__ in _ALLOWED_DTO_MODULES: return True return False return False @@ -166,9 +177,9 @@ def test_session_request_is_frozen() -> None: def test_ui_port_type_hints_are_data_only() -> None: - """TEETH: every method on UIPort resolves to frozen DTOs from - ui_contract, enums, Path, or builtins — no live objects. The - discrimination proof that this check has teeth is in + """TEETH: every method on UIPort resolves to frozen DTOs from the named + DTO modules (_ALLOWED_DTO_MODULES), enums, Path, or builtins — no live + objects. The discrimination proof that this check has teeth is in test_type_checker_rejects_session_controller_today.""" methods = _port_methods(UIPort) assert set(methods) == _EXPECTED_PORT_METHODS, ( From e390096d8f0f0e4e9fd88533e635734e0edf036a Mon Sep 17 00:00:00 2001 From: Nick Liebmann <53795413+Liebmann5@users.noreply.github.com> Date: Wed, 28 Oct 2026 06:43:48 -0700 Subject: [PATCH 4/5] kimicli: per-run model choice, saved default model, K3.1 Pro pricing --- kimicli.py | 777 ++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 708 insertions(+), 69 deletions(-) diff --git a/kimicli.py b/kimicli.py index f429ff0..e4418c6 100644 --- a/kimicli.py +++ b/kimicli.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 r""" -kimicli.py - Kimi K3 workbench (v3) +kimicli.py - Kimi workbench (v3): K3 (default), K3.1 Pro, K3.1 Pro Highspeed Design rules, in priority order: @@ -37,7 +37,8 @@ Verified against the Kimi platform docs on 2026-08-05: - context caching is automatic; prefix must be byte-identical -> codebase first - - usage.cached_tokens is TOP-LEVEL in usage (not prompt_tokens_details) + - usage.cached_tokens is TOP-LEVEL in usage (2026-10: now documented under + prompt_tokens_details instead; both places are read) - prompt_cache_key improves hit rate across separate invocations - 504 after 900s on long non-streaming requests -> stream=True always - reasoning_effort is top-level: low | high | max (default max) @@ -45,8 +46,28 @@ - max_completion_tokens (max_tokens is deprecated) - K3 uses Preserved Thinking -> keep reasoning_content in history +Re-verified for Kimi K3.1 Pro on 2026-10-28 (platform.kimi.ai quickstart, +context-caching guide, pricing): + - kimi-k3.1-pro / kimi-k3.1-pro-highspeed keep every K3 constraint above; + 1,048,576 context, max_completion_tokens default 131,072, thinking always on + - cached_tokens and cache_write_tokens are now documented under + usage.prompt_tokens_details (both locations are read) + - cache writes bill separately at a 5m or 1h tier, on K3 and K3.1 Pro alike; + prompt_cache_options {"mode": "implicit", "ttl": "1h"} selects 1h. The + tier is locked by the first write; a hit renews it. -> --cache-ttl + - the API echoes the serving model in every chunk -> mismatch warned + +Models: K3 stays the default. K3.1 Pro is a third cheaper per token but, in +independent coding tests, reasons about twice as long, so its bills and its +risk of a cut-off reply are not a third lower. Use it per run (--model pro) +and make it the default only when it has earned it (--default-model pro). + Quick start: python kimicli.py --selftest # prove apply/undo on THIS machine + python kimicli.py --models # what this key can call, with prices + python kimicli.py --model pro --prompt task.md # one run on K3.1 Pro + python kimicli.py --default-model # show the default model + python kimicli.py --default-model pro # make K3.1 Pro the default (k3 = back) python kimicli.py --prompt task.md --request-code python kimicli.py --apply-fixes last --dry-run python kimicli.py --apply-fixes last @@ -110,8 +131,6 @@ def load_dotenv(*_a: Any, **_k: Any) -> bool: # type: ignore[misc] API_KEY = os.getenv("MOONSHOT_API_KEY") or os.getenv("KIMI_API_KEY") or "" BASE_URL = os.getenv("KIMI_BASE_URL", "https://api.moonshot.ai/v1") -MODEL = os.getenv("KIMI_MODEL", "kimi-k3") - DEFAULT_CODEBASE = Path(os.getenv("KIMI_CODEBASE", r"D:\Downloads\AA-kimi.txt")) # AA's engineering philosophy rides along with EVERY call (Nick's standing rule, @@ -130,18 +149,151 @@ def load_dotenv(*_a: Any, **_k: Any) -> bool: # type: ignore[misc] BACKUP_DIR = PROJECT_ROOT / ".kimi_backups" # backups + manifests LEDGER = OUT_DIR / "ledger.jsonl" -# Kimi K3 pricing, USD per 1M tokens (docs/pricing/chat-k3) -PRICE_IN_CACHED = 0.30 -PRICE_IN_FRESH = 3.00 -PRICE_OUT = 15.00 +# -------------------------------------------------------------------------- +# Models +# +# Every price, window and cache rule is looked up per model, so switching +# model can never leave the preflight, the spend cap or the ledger quoting the +# wrong model's prices. Prices are USD per 1M tokens, from Moonshot's pricing +# page (platform.kimi.ai/docs/pricing/chat) as of PRICES_CHECKED. They are a +# hand-kept table - Moonshot publishes no pricing endpoint - so the preflight +# prints the date beside every quote. Re-check the page when it is old. +# +# Cache billing since 2026-09: a prefix WRITTEN to the cache is billed as a +# separate item, at the 5m or 1h tier. The 5m write rate equals the normal +# input rate, so a 5m write costs nothing extra; a 1h write costs 2x input, +# once, and every hit inside the hour is billed at the cached rate and resets +# the hour. The TTL is fixed by the FIRST write of a prefix; asking for 1h on +# a prefix already cached at 5m does not upgrade it. +# -------------------------------------------------------------------------- + +PRICES_CHECKED = "2026-10-28" + + +@dataclass(frozen=True) +class ModelInfo: + id: str + label: str + hit: float # input, served from the cache + miss: float # input, not cached + write_5m: float # input written to the cache, 5-minute tier + write_1h: Optional[float] # input written to the cache, 1-hour tier; None = no 1h tier + out: float # output, reasoning included + context: int = 1_048_576 + note: str = "" + + +MODEL_INFO: Dict[str, ModelInfo] = {m.id: m for m in ( + ModelInfo("kimi-k3", "Kimi K3", 0.30, 3.00, 3.00, 6.00, 15.00, + note="the default: the model this workflow's prompts are tuned and measured on"), + ModelInfo("kimi-k3.1-pro", "Kimi K3.1 Pro", 0.20, 2.00, 2.00, 4.00, 10.00, + note="post-trained K3 (2026-10-23): a third cheaper per token, but reasons ~2x " + "as long in independent tests - use --effort high"), + ModelInfo("kimi-k3.1-pro-highspeed", "Kimi K3.1 Pro Highspeed", 0.20, 4.00, 4.00, 8.00, 20.00, + note="same model as K3.1 Pro, 2-3x faster output, twice the price"), +)} + +#: Short names accepted anywhere a model is named (--model, --default-model). +MODEL_ALIASES: Dict[str, str] = { + "k3": "kimi-k3", + "pro": "kimi-k3.1-pro", "k3.1": "kimi-k3.1-pro", "k3.1-pro": "kimi-k3.1-pro", + "highspeed": "kimi-k3.1-pro-highspeed", "fast": "kimi-k3.1-pro-highspeed", + "hs": "kimi-k3.1-pro-highspeed", +} + +#: The model a run uses when nothing else names one. Change the default with +#: --default-model, not here: that survives updates to this file. +BUILTIN_DEFAULT_MODEL = "kimi-k3" +#: Every session made before models were recorded per session ran on this. +LEGACY_SESSION_MODEL = "kimi-k3" +SETTINGS_FILE = OUT_DIR / "settings.json" + + +def resolve_model_name(name: str) -> str: + """'highspeed' -> 'kimi-k3.1-pro-highspeed'. Anything not an alias is + returned as written (trimmed), never guessed at.""" + s = (name or "").strip() + return MODEL_ALIASES.get(s.casefold(), s) + + +def _read_settings() -> Dict[str, Any]: + try: + blob = json.loads(SETTINGS_FILE.read_text(encoding="utf-8")) + return blob if isinstance(blob, dict) else {} + except (OSError, ValueError): + return {} + + +def configured_model() -> Tuple[str, str]: + """(model, where it came from) when --model is not given. -CONTEXT_WINDOW = 1_048_576 + Order: the default saved with --default-model, then KIMI_MODEL in the + environment/.env (the older way to pick one), then the built-in default. + The saved default wins because it is the newer, deliberate choice: the + one command that changes it is the one that is obeyed.""" + saved = str(_read_settings().get("model") or "").strip() + if saved: + return resolve_model_name(saved), f"--default-model ({SETTINGS_FILE.name})" + env = (os.getenv("KIMI_MODEL") or "").strip() + if env: + return resolve_model_name(env), "KIMI_MODEL in .env" + return BUILTIN_DEFAULT_MODEL, "built-in default" + + +def pricing_for(model: Optional[str]) -> Tuple[ModelInfo, bool]: + """(prices, known). An unknown model is priced at the HIGHEST rate on file + for every item, so the spend cap errs toward refusing, never toward an + unexpected bill.""" + m = MODEL_INFO.get(resolve_model_name(model or "")) + if m: + return m, True + allm = list(MODEL_INFO.values()) + return ModelInfo( + id=model or "?", label=f"{model} (no prices on file - worst case assumed)", + hit=max(x.hit for x in allm), miss=max(x.miss for x in allm), + write_5m=max(x.write_5m for x in allm), + write_1h=max((x.write_1h or 0) for x in allm), + out=max(x.out for x in allm), context=1_048_576), False + + +def supports_cache_ttl(model: str) -> bool: + m = MODEL_INFO.get(model) + return bool(m and m.write_1h is not None) + + +def served_differs(asked: str, served: Optional[str]) -> bool: + """True when the model that answered is not the one asked for. A dated + snapshot of the same model ('kimi-k3-0715') is not a difference; another + model on file is, even when its name starts the same ('kimi-k3.1-pro' + answering for 'kimi-k3').""" + if not served or served == asked: + return False + return served in MODEL_INFO or not served.startswith(asked) + + +def alternative_model(model: str) -> Optional[str]: + """The model the preflight prices beside this one: K3 against K3.1 Pro + and back, so the cost of switching is always one glance away.""" + if model == "kimi-k3": + return "kimi-k3.1-pro" + if model in MODEL_INFO: + return "kimi-k3" + return None + + +def short_name(model: str) -> str: + """The shortest alias for a model ('kimi-k3.1-pro' -> 'pro'), else the id.""" + names = [a for a, m in MODEL_ALIASES.items() if m == model] + return min(names, key=len) if names else model + + +CONTEXT_WINDOW = 1_048_576 # fallback for a model with no entry above # Your organisation's tokens-per-minute ceiling, from # platform.kimi.ai/console/limits. Override with KIMI_TPM in .env when the # tier changes. The preflight warns only when a request exceeds THIS, not a # hardcoded guess at someone else's tier. ACCOUNT_TPM = int(os.getenv("KIMI_TPM", "3000000")) -DEFAULT_MAX_COMPLETION = 131_072 # K3's own default +DEFAULT_MAX_COMPLETION = 131_072 # K3's and K3.1 Pro's own default REQUEST_TIMEOUT_S = float(os.getenv("KIMI_TIMEOUT", "2400")) # Files the model is never allowed to touch, even with --apply-fixes. @@ -225,9 +377,39 @@ def money(x: float) -> str: return f"${x:,.4f}" if x < 1 else f"${x:,.2f}" -def cost_of(prompt: int, cached: int, completion: int) -> float: - fresh = max(prompt - cached, 0) - return (cached * PRICE_IN_CACHED + fresh * PRICE_IN_FRESH + completion * PRICE_OUT) / 1e6 +def cost_of(prompt: int, cached: int, completion: int, model: Optional[str], + cache_write: Optional[int] = None, ttl: str = "5m") -> float: + """USD for one call on `model`. prompt counts ALL input tokens (cache + reads and writes included, as Moonshot reports it). cache_write=None means + the response did not say how much was written: at the 5m tier that changes + nothing (write rate == input rate); at the 1h tier every uncached token is + assumed written, so the figure is an upper bound, never an under-count.""" + p, _ = pricing_for(model) + write_rate = p.write_1h if (ttl == "1h" and p.write_1h is not None) else p.write_5m + uncached = max(prompt - cached, 0) + if cache_write is None: + writes = uncached if ttl == "1h" else 0 + else: + writes = min(max(int(cache_write), 0), uncached) + fresh = uncached - writes + return (cached * p.hit + fresh * p.miss + writes * write_rate + completion * p.out) / 1e6 + + +def usage_numbers(u: Dict[str, Any]) -> Tuple[int, int, int, Optional[int]]: + """(prompt, cached, completion, cache_write) from a usage dict. + + Moonshot documents cache reads and writes under prompt_tokens_details; + earlier responses put cached_tokens at the top level. Both are read, so + neither number is ever silently zero. cache_write is None when absent.""" + details = u.get("prompt_tokens_details") or {} + cached = u.get("cached_tokens") + if cached is None: + cached = details.get("cached_tokens") + write = details.get("cache_write_tokens") + if write is None: + write = u.get("cache_write_tokens") + return (int(u.get("prompt_tokens") or 0), int(cached or 0), + int(u.get("completion_tokens") or 0), None if write is None else int(write)) def _attach_label(p: Path) -> str: @@ -308,7 +490,7 @@ def split_dump(dump: str) -> Tuple[List[Tuple[str, str]], str]: return out, best_pat -def filter_dump(dump: str, excludes: Sequence[str]) -> Tuple[str, str]: +def filter_dump(dump: str, excludes: Sequence[str], model: Optional[str] = None) -> Tuple[str, str]: """Drop sections whose path matches any exclude prefix/substring. Returns (new_dump, human_report).""" if not excludes: @@ -329,7 +511,7 @@ def filter_dump(dump: str, excludes: Sequence[str]) -> Tuple[str, str]: new_dump = preamble + "".join(kept) report = (f" - filtered out {dropped} of {len(sections)} files " f"(~{est_tokens('x' * dropped_chars):,} tokens, " - f"~{money(est_tokens('x' * dropped_chars) / 1e6 * PRICE_IN_FRESH)} per uncached call)\n" + f"~{money(est_tokens('x' * dropped_chars) / 1e6 * pricing_for(model)[0].miss)} per uncached call)\n" f" keep the same --exclude flags every run: changing them changes the " f"prefix, which costs you the cache once.") return new_dump, report @@ -346,21 +528,26 @@ class Usage: cached: int = 0 completion: int = 0 calls: int = 0 - - def add(self, u: Dict[str, Any]) -> None: - self.prompt += int(u.get("prompt_tokens") or 0) - self.completion += int(u.get("completion_tokens") or 0) - # Kimi returns cached_tokens at the top level of usage. Older/other - # gateways nest it; accept both so the number is never silently zero. - cached = u.get("cached_tokens") - if cached is None: - cached = (u.get("prompt_tokens_details") or {}).get("cached_tokens") - self.cached += int(cached or 0) + cache_write: int = 0 + # Dollars accumulated call by call, each priced on the model and cache + # tier THAT call used - a session can change model, so the token totals + # alone cannot be priced afterwards. + cost_usd: float = 0.0 + + def add(self, u: Dict[str, Any], model: Optional[str], ttl: str = "5m") -> float: + p, c, o, w = usage_numbers(u) + self.prompt += p + self.cached += c + self.completion += o + self.cache_write += w or 0 self.calls += 1 + this = cost_of(p, c, o, model, w, ttl) + self.cost_usd += this + return this @property def cost(self) -> float: - return cost_of(self.prompt, self.cached, self.completion) + return self.cost_usd class Session: @@ -381,6 +568,13 @@ def __init__(self, session_id: Optional[str] = None): # --resume sends the SAME key as turn 1: a different key is a # different cache bucket, and the prefix you already paid for misses. self.cache_key: Optional[str] = None + # The model, reasoning effort and cache TTL the conversation runs on, + # kept for the same reason as the cache key: a resume that silently + # changed any of them would re-pay for the whole prefix. None = a + # session made before these were recorded (it ran on kimi-k3). + self.model: Optional[str] = None + self.effort: Optional[str] = None + self.cache_ttl: Optional[str] = None self.printed_stage_summary = False self.load_error: Optional[str] = None self._load() @@ -393,8 +587,17 @@ def _load(self) -> None: self.messages = blob["messages"] self.turn = blob.get("turn", 0) self.cache_key = blob.get("cache_key") + self.model = blob.get("model") + self.effort = blob.get("effort") + self.cache_ttl = blob.get("cache_ttl") u = blob.get("usage", {}) - self.usage = Usage(**{k: u.get(k, 0) for k in ("prompt", "cached", "completion", "calls")}) + self.usage = Usage(**{k: u.get(k, 0) for k in + ("prompt", "cached", "completion", "calls", "cache_write")}) + if "cost_usd" in u: + self.usage.cost_usd = float(u["cost_usd"]) + else: # recorded before per-call pricing: it ran on kimi-k3 + self.usage.cost_usd = cost_of(self.usage.prompt, self.usage.cached, + self.usage.completion, LEGACY_SESSION_MODEL) except Exception as exc: # Recorded, not just logged: resuming a session that failed to # load would send the new prompt with NO codebase and NO history. @@ -420,6 +623,9 @@ def save(self) -> None: "session": self.id, "turn": self.turn, "cache_key": self.cache_key, + "model": self.model, + "effort": self.effort, + "cache_ttl": self.cache_ttl, "usage": self.usage.__dict__, "messages": self.messages, } @@ -519,17 +725,21 @@ def turns_with_reports(self) -> List[int]: out.append(int(m.group(1))) return sorted(out) - def ledger_entry(self, u: Dict[str, Any], effort: str, note: str = "", estimated: bool = False) -> None: - cached = u.get("cached_tokens") or (u.get("prompt_tokens_details") or {}).get("cached_tokens") or 0 + def ledger_entry(self, u: Dict[str, Any], effort: str, note: str = "", estimated: bool = False, + model: Optional[str] = None, ttl: str = "5m") -> None: + prompt, cached, completion, write = usage_numbers(u) row = { "ts": time.strftime("%Y-%m-%d %H:%M:%S"), "session": self.id, "turn": self.turn, + "model": model, "effort": effort, - "prompt": u.get("prompt_tokens", 0), + "cache_ttl": ttl, + "prompt": prompt, "cached": cached, - "completion": u.get("completion_tokens", 0), - "cost_usd": round(cost_of(u.get("prompt_tokens", 0), cached, u.get("completion_tokens", 0)), 5), + "cache_write": write, + "completion": completion, + "cost_usd": round(cost_of(prompt, cached, completion, model, write, ttl), 5), "note": note, } if estimated: @@ -2886,7 +3096,13 @@ def reply_shape_problems(order: List[Tuple[int, str]], reply: str) -> List[str]: out: List[str] = [] found: List[Tuple[int, int, str]] = [] # (index into heads, number, name) last = -1 + # The change blocks are their own proof: a section asking for "the blocks, + # then ### END CHANGES" is present when the reply has blocks or the END + # marker, whatever heading (if any) introduces them. + has_changes = any(HEADER_RE.match(ln) or END_RE.match(ln) for ln in lines) for n, name in order: + if has_changes and ("END CHANGES" in name.upper() or "blocks" in name.lower()): + continue key = _shape_words(name)[:3] hits = [j for j, (_i, num, label, _r, _md) in enumerate(heads) if set(key) <= set(_shape_words(label)) @@ -2908,7 +3124,9 @@ def reply_shape_problems(order: List[Tuple[int, str]], reply: str) -> List[str]: if j == final and not later else f"section {n}. {name} has nothing under it") # Any reply, REPLY ORDER or not: one that ends on a heading was cut short. - tail = [i for i, ln in enumerate(lines) if ln.strip() and not _RULE_RE.match(ln)] + # '### END CHANGES' is the contract's own last line, not a cut-off heading. + tail = [i for i, ln in enumerate(lines) + if ln.strip() and not _RULE_RE.match(ln) and not END_RE.match(ln)] end_head = next((h for h in heads if tail and h[0] == tail[-1] and h[4]), None) if end_head and not any("stops at its last heading" in p for p in out): out.append(f"the reply stops at a heading ('{lines[end_head[0]].strip()}') " @@ -3792,7 +4010,7 @@ def build_context(args: argparse.Namespace) -> Context: if cb.is_file(): dump = read_text(cb) if args.exclude: - dump, report = filter_dump(dump, args.exclude) + dump, report = filter_dump(dump, args.exclude, args.model) if report: print(report) dump_text = dump @@ -3872,6 +4090,25 @@ def balance(self) -> Optional[Dict[str, Any]]: logger.warning("balance check failed: %s", exc) return None + def list_models(self) -> Optional[List[Dict[str, Any]]]: + """The models this key can call, live from GET /v1/models (free: no + tokens, no billing). None when the endpoint cannot be reached. Fetched + once per run.""" + if hasattr(self, "_models"): + return self._models + try: + import httpx + r = httpx.get(f"{self.base_url}/models", + headers={"Authorization": f"Bearer {self.api_key}"}, timeout=30) + r.raise_for_status() + data = r.json().get("data") + self._models: Optional[List[Dict[str, Any]]] = ( + [d for d in data if isinstance(d, dict) and d.get("id")] if isinstance(data, list) else None) + except Exception as exc: + logger.warning("could not list models: %s", exc) + self._models = None + return self._models + def count_tokens(self, messages: List[Dict[str, Any]], model: str) -> Optional[int]: """Exact prompt size from Moonshot's tokenizer, cached by content hash so a 3 MB dump is only ever uploaded once per version.""" @@ -3903,7 +4140,8 @@ def count_tokens(self, messages: List[Dict[str, Any]], model: str) -> Optional[i # -- the call ----------------------------------------------------- def stream(self, messages: List[Dict[str, Any]], session: Session, *, model: str, effort: str, max_completion: int, cache_key: str, - show_thinking: bool, prediction: Optional[str] = None) -> Dict[str, Any]: + show_thinking: bool, prediction: Optional[str] = None, + cache_ttl: str = "5m") -> Dict[str, Any]: """Stream a completion. Every token is written to disk as it arrives.""" kwargs: Dict[str, Any] = { "model": model, @@ -3916,7 +4154,11 @@ def stream(self, messages: List[Dict[str, Any]], session: Session, *, } if prediction: kwargs["prediction"] = {"type": "content", "content": prediction} - # temperature / top_p / n / penalties are fixed on K3 - never send them. + # The 5m tier is Moonshot's default when the field is omitted, so it is + # sent only for 1h - a model without cache tiers never sees it. + if cache_ttl == "1h": + kwargs["extra_body"] = {"prompt_cache_options": {"mode": "implicit", "ttl": "1h"}} + # temperature / top_p / n / penalties are fixed on K3 and K3.1 - never send them. content: List[str] = [] reasoning: List[str] = [] @@ -3925,6 +4167,7 @@ def stream(self, messages: List[Dict[str, Any]], session: Session, *, started = time.time() first_token_at: Optional[float] = None printed_thinking_header = False + served: Optional[str] = None try: stream = self.client.chat.completions.create(**kwargs) @@ -3934,6 +4177,8 @@ def stream(self, messages: List[Dict[str, Any]], session: Session, *, try: for chunk in stream: + if served is None and getattr(chunk, "model", None): + served = str(chunk.model) if getattr(chunk, "usage", None): try: usage = chunk.usage.model_dump() @@ -3991,6 +4236,7 @@ def stream(self, messages: List[Dict[str, Any]], session: Session, *, "finish_reason": finish, "elapsed": elapsed, "ttft": (first_token_at - started) if first_token_at else None, + "served_model": served, } @@ -4008,8 +4254,9 @@ def stream(self, messages: List[Dict[str, Any]], session: Session, *, "match the base URL.", "incorrect_api_key_error": "Key missing or wrong. Check MOONSHOT_API_KEY in your .env.", "permission_denied_error": "Not permitted. Often an IP allowlist on the organisation.", - "resource_not_found_error": "Model name wrong, or your account cannot access it. K3 unlocks " - "after a top-up of at least $1.", + "resource_not_found_error": "Model name wrong, or your account cannot access it. List what " + "this key can call with: python kimicli.py --models. K3 and " + "K3.1 Pro unlock after a top-up of at least $1.", "engine_overloaded_error": "Moonshot's side is saturated - this is the 'too many users' one. " "Wait and retry; topping up does not help.", "exceeded_current_quota_error": "Balance is empty or the account is disabled. " @@ -4089,31 +4336,61 @@ def preflight(ctx: Context, kimi: Kimi, args: argparse.Namespace) -> bool: # Below this there is no room for a useful answer: send nothing rather # than pay for the input and get an answer cut off in its first lines. + info, known = pricing_for(args.model) + window = info.context if known else CONTEXT_WINDOW MIN_OUTPUT = 8192 - if n_in > CONTEXT_WINDOW - MIN_OUTPUT: + likely_cut_off = False + if n_in > window - MIN_OUTPUT: print(f"\n STOP: {n_in:,} input tokens leaves no room for an answer in the " - f"{CONTEXT_WINDOW:,} context window.") + f"{window:,} context window.") print(" Fresh call: --exclude docs, fewer --attach, or --no-codebase. On a --resume " "chain: start a fresh session instead of adding another turn.") return False - if n_in + args.max_completion > CONTEXT_WINDOW: + if n_in + args.max_completion > window: asked = args.max_completion # Never cap to a figure that still overflows: the old floor of 4096 # could put input + output past the window. - args.max_completion = CONTEXT_WINDOW - n_in - 1024 + args.max_completion = window - n_in - 1024 print(f"\n ! OUTPUT CAPPED at {args.max_completion:,} tokens (asked {asked:,}): the input " f"fills the rest of the window.") if args.max_completion < 65_536: + likely_cut_off = True print(" A multi-file code reply this size is likely to be CUT OFF. On a --resume " "chain, a fresh\n session (not another resume) gets the full output budget back.") - - miss = cost_of(n_in, 0, 0) - hit = cost_of(n_in, n_in, 0) - out_max = args.max_completion * PRICE_OUT / 1e6 - print(f"\n input if cache MISSES : {money(miss)}") - print(f" input if cache HITS : {money(hit)} (10x cheaper - identical prefix required)") - print(f" output, worst case : {money(out_max)} ({args.max_completion:,} tok @ ${PRICE_OUT}/M)") + print(" Because of this, pressing Enter at the prompt below means NO; type y to send.") + + ttl = getattr(args, "cache_ttl", None) or "5m" + miss = cost_of(n_in, 0, 0, args.model, None, ttl) + hit = cost_of(n_in, n_in, 0, args.model) + out_max = args.max_completion * info.out / 1e6 + print(f"\n model : {args.model} ({info.label}; " + f"${info.miss:g} in / ${info.out:g} out / ${info.hit:g} cached per 1M, " + f"prices as of {PRICES_CHECKED})") + if not known: + print(" ! no prices on file for this model: every figure below assumes the highest " + "rate on file,\n and the context window is assumed to be " + f"{window:,}. Add it to MODEL_INFO if you keep using it.") + print(f" input if cache MISSES : {money(miss)} (and writes the cache, {ttl} tier)") + print(f" input if cache HITS : {money(hit)} (identical prefix, within the cache lifetime)") + is_resume = getattr(args, "is_resume", False) + if ttl == "5m" and supports_cache_ttl(args.model) and not is_resume: + miss_1h = cost_of(n_in, 0, 0, args.model, None, "1h") + print(f" 1h cache tier : {money(miss_1h)} on a miss instead. Worth it only if this " + f"prefix is reused\n at least twice, each within an hour of " + f"the last (--cache-ttl 1h)") + print(f" output, worst case : {money(out_max)} ({args.max_completion:,} tok @ ${info.out:g}/M)") print(f" reasoning effort : {args.effort} (reasoning bills as output)") + other = alternative_model(args.model) + if other and not is_resume: + o_info, _k = pricing_for(other) + o_miss = cost_of(n_in, 0, 0, other, None, ttl) + o_out = args.max_completion * o_info.out / 1e6 + print(f" on {other:<18}: {money(o_miss)} input if it misses, {money(o_out)} output " + f"worst case (--model {short_name(other)})") + if args.model.startswith("kimi-k3.1") and args.effort == "max" and getattr(args, "request_code", False): + print(" note: independent tests found K3.1 Pro reasons about twice as long as K3. At effort " + "max a\n large code reply is more likely to be cut off; --effort high is the " + "safer choice.") if n_in > ACCOUNT_TPM: print(f"\n ! {n_in:,} tokens in one request exceeds this account's " @@ -4139,7 +4416,7 @@ def preflight(ctx: Context, kimi: Kimi, args: argparse.Namespace) -> bool: if args.yes: return True - return confirm("Send this request?", default_no=False) + return confirm("Send this request?", default_no=likely_cut_off) # ========================================================================== @@ -4153,7 +4430,8 @@ def do_turn(kimi: Kimi, session: Session, args: argparse.Namespace, label: str = if last_user: text = last_user["content"] session.write(text if len(text) < 8000 else text[:8000] + "\n[... prompt truncated in transcript ...]\n") - session.banner("KIMI", f"effort={args.effort}", also_thinking=True) + ttl = getattr(args, "cache_ttl", None) or "5m" + session.banner("KIMI", f"{args.model} - effort={args.effort} - cache={ttl}", also_thinking=True) print() # The input this request carries: preflight's exact count for the first @@ -4165,7 +4443,7 @@ def do_turn(kimi: Kimi, session: Session, args: argparse.Namespace, label: str = session.messages, session, model=args.model, effort=args.effort, max_completion=args.max_completion, cache_key=args.cache_key_resolved, show_thinking=args.show_thinking, - prediction=args.prediction_text, + prediction=args.prediction_text, cache_ttl=ttl, ) except BaseException: session.turn -= 1 # nothing was answered; keep turn numbers contiguous @@ -4178,22 +4456,30 @@ def do_turn(kimi: Kimi, session: Session, args: argparse.Namespace, label: str = assistant["reasoning_content"] = result["reasoning"] session.messages.append(assistant) + # Bill on the model that ANSWERED. Moonshot echoes it in every chunk; if it + # differs from the one asked for, the reply came from somewhere else and + # both the cost and the answer need a second look. + served = result.get("served_model") + billed = served if served in MODEL_INFO else args.model + if served_differs(args.model, served): + result["model_mismatch"] = served usage = result["usage"] or {} if usage: - session.usage.add(usage) - session.ledger_entry(usage, args.effort, note=label) + result["cost"] = session.usage.add(usage, billed, ttl) + session.ledger_entry(usage, args.effort, note=label, model=billed, ttl=ttl) elif result.get("ttft") is not None: # The stream died after tokens arrived, so usage never came - but # Moonshot still bills it (measured 2026-09-15: ~$7 across two died # streams that recorded nothing). Ledger it as an estimate, assuming # 0% cached, so lifetime spend is an upper bound rather than a gap. est_out = est_tokens(result["content"] or "") + est_tokens(result["reasoning"] or "") - result["estimated_cost"] = cost_of(input_hint, 0, est_out) + result["estimated_cost"] = cost_of(input_hint, 0, est_out, billed, None, ttl) session.ledger_entry({"prompt_tokens": input_hint, "completion_tokens": est_out, "cached_tokens": 0}, args.effort, note=(label + " " if label else "") + "ESTIMATE: usage not reported " f"(finish_reason={result['finish_reason']})", - estimated=True) + estimated=True, model=billed, ttl=ttl) + session.write(call_record(result, args, ttl)) session.save() summary: Optional[StageSummary] = None @@ -4214,19 +4500,39 @@ def do_turn(kimi: Kimi, session: Session, args: argparse.Namespace, label: str = return result +def call_record(result: Dict[str, Any], args: argparse.Namespace, ttl: str) -> str: + """One line closing each Kimi turn in transcript.md: which model answered, + how, and what it cost. Whoever reviews the transcript sees it without the + terminal. It follows the reply, so it is never inside a change block.""" + p, c, o, w = usage_numbers(result.get("usage") or {}) + served = result.get("served_model") or "not reported" + if p: + money_part = f"in {p:,} tok ({c:,} cached) | out {o:,} tok | {money(result.get('cost') or 0)}" + elif result.get("estimated_cost") is not None: + money_part = f"usage not reported | up to {money(result['estimated_cost'])} (estimate)" + else: + money_part = "usage not reported" + return (f"\n\n[kimicli call record] asked {args.model} | answered by {served} | " + f"effort {args.effort} | cache {ttl} | finish {result.get('finish_reason')} | " + f"{money_part}\n") + + def print_turn_summary(result: Dict[str, Any], session: Session) -> None: u = result["usage"] or {} - p = int(u.get("prompt_tokens") or 0) - c = int(u.get("cached_tokens") or (u.get("prompt_tokens_details") or {}).get("cached_tokens") or 0) - o = int(u.get("completion_tokens") or 0) + p, c, o, w = usage_numbers(u) print("\n" + "-" * 70) + if result.get("model_mismatch"): + print(f" ! ANSWERED BY {result['model_mismatch']}, not {session.model}. Moonshot served a " + f"different model than\n the one requested; read this reply with that in mind. " + f"Check the name with --models.") if result["finish_reason"] == "length": print(" ! finish_reason=length - the answer hit max_completion_tokens and is CUT OFF.") print(" Raise --max-completion, or ask for fewer files per turn.") if p: pct = (c / p * 100) if p else 0 - print(f" input {p:,} tok ({c:,} cached = {pct:.0f}%) output {o:,} tok" - f" this turn {money(cost_of(p, c, o))}") + wrote = f", {w:,} written to cache" if w else "" + print(f" input {p:,} tok ({c:,} cached = {pct:.0f}%{wrote}) output {o:,} tok" + f" this turn {money(result.get('cost') or 0)}") print(f" session total {money(session.usage.cost)} over {session.usage.calls} call(s)") elif result.get("estimated_cost") is not None: print(" usage not reported (stream ended early) - this call is STILL BILLED.") @@ -4248,6 +4554,7 @@ def print_turn_summary(result: Dict[str, Any], session: Session) -> None: /exit end the session (everything is already saved) /cost spend for this session and lifetime /effort low|high|max change reasoning effort for the next turn + /model [name] show the model, or switch it for the next turn (aliases: pro, highspeed, k3) /files list the change blocks staged this session /apply dry-run the applier on this session (writes nothing) /remember append a line to .kimi_out/memory.md for future sessions @@ -4259,7 +4566,7 @@ def print_turn_summary(result: Dict[str, Any], session: Session) -> None: def chat_loop(kimi: Kimi, session: Session, args: argparse.Namespace) -> None: print("\n" + "=" * 70) - print(f"KIMI K3 - session {session.id}") + print(f"KIMI - {args.model} - session {session.id}") print(f"Everything is written to {session.dir}") print("Type /help for commands.") print("=" * 70) @@ -4288,11 +4595,31 @@ def chat_loop(kimi: Kimi, session: Session, args: argparse.Namespace) -> None: continue if cmd == "/effort": if rest.strip() in ("low", "high", "max"): - args.effort = rest.strip() + if rest.strip() != args.effort and session.turn: + print(" note: a different effort can miss the cache on the next turn " + "(that turn's input bills at the full rate).") + args.effort = session.effort = rest.strip() print(f" reasoning effort -> {args.effort}") else: print(" usage: /effort low|high|max") continue + if cmd == "/model": + want = resolve_model_name(rest) + if not want: + info, _k = pricing_for(args.model) + print(f" model: {args.model} ({info.label}) list them all: " + f"python kimicli.py --models") + elif want != args.model: + info, known = pricing_for(want) + print(f" model -> {want} ({info.label}). The next turn re-sends the whole " + f"conversation\n to a model that has never cached it: its input bills " + f"at the full rate. Moonshot\n recommends a fresh session over " + f"switching mid-conversation.") + if not known: + print(" ! no prices on file for that name - check it with --models; a " + "wrong name is refused by the API.") + args.model = session.model = want + continue if cmd == "/files": rows = session.rows() if rows: @@ -4411,11 +4738,22 @@ def print_stats() -> None: print(f" of which : {money(sum(r.get('cost_usd', 0) for r in est_rows))} estimated " f"for {len(est_rows)} died stream(s) (upper bound, 0% cached assumed)") if prompt: - saved = (cached * (PRICE_IN_FRESH - PRICE_IN_CACHED)) / 1e6 + # Rows from before models were recorded all ran on kimi-k3. + saved = sum(r.get("cached", 0) * (pricing_for(r.get("model") or LEGACY_SESSION_MODEL)[0].miss + - pricing_for(r.get("model") or LEGACY_SESSION_MODEL)[0].hit) + for r in rows) / 1e6 print(f" saved by cache : {money(saved)}") + by_model: Dict[str, List[float]] = {} + for r in rows: + k = r.get("model") or f"{LEGACY_SESSION_MODEL} (before models were recorded)" + by_model.setdefault(k, []).append(float(r.get("cost_usd", 0))) + if len(by_model) > 1 or any(r.get("model") for r in rows): + print("\n by model:") + for k, costs in sorted(by_model.items(), key=lambda kv: -sum(kv[1])): + print(f" {k:<46} {len(costs):>4} call(s) {money(sum(costs))}") print("\n last 5 calls:") for r in rows[-5:]: - print(f" {r['ts']} {r.get('effort', '?'):<4} " + print(f" {r['ts']} {(r.get('model') or LEGACY_SESSION_MODEL):<24} {r.get('effort', '?'):<4} " f"in {r.get('prompt', 0):>9,} ({r.get('cached', 0):>9,} cached) " f"out {r.get('completion', 0):>7,} {money(r.get('cost_usd', 0))}") print() @@ -4905,6 +5243,141 @@ def balance(self) -> None: check("a window with no room left for an answer stops the call", sent is False) check("a resume's size includes the reasoning it sends back", est_messages([{"role": "assistant", "content": "", "reasoning_content": "r" * 4450}]) == 1000) + + # ---- 14. models: names, prices, cache tiers, defaults, sessions + M = 1_000_000 + check("model aliases resolve; an unknown name passes through untouched", + resolve_model_name(" HighSpeed ") == "kimi-k3.1-pro-highspeed" + and resolve_model_name("pro") == "kimi-k3.1-pro" and resolve_model_name("k3") == "kimi-k3" + and resolve_model_name("kimi-k9-x") == "kimi-k9-x") + c31 = cost_of(M, 0, 0, "kimi-k3.1-pro") + c31_1h = cost_of(M, 0, 0, "kimi-k3.1-pro", None, "1h") + c31_hit = cost_of(M, M, M, "kimi-k3.1-pro") + c31_part = cost_of(M, 400_000, 0, "kimi-k3.1-pro", 500_000, "1h") + c3 = cost_of(M, 0, M, "kimi-k3") + chs = cost_of(M, 0, M, "kimi-k3.1-pro-highspeed") + cunk = cost_of(M, 0, M, "kimi-k9-x") + check("each model is priced on its own rates, cache writes and the 1h tier included; an " + "unknown model is priced at the worst case", + abs(c31 - 2.0) < 1e-9 and abs(c31_1h - 4.0) < 1e-9 and abs(c31_hit - 10.2) < 1e-9 + and abs(c31_part - (0.4 * 0.2 + 0.5 * 4.0 + 0.1 * 2.0)) < 1e-9 + and abs(c3 - 18.0) < 1e-9 and abs(chs - 24.0) < 1e-9 and cunk >= chs + and not pricing_for("kimi-k9-x")[1], + f"{c31} {c31_1h} {c31_hit} {c31_part} {c3} {chs} {cunk}") + un = usage_numbers({"prompt_tokens": 10, "completion_tokens": 3, + "prompt_tokens_details": {"cached_tokens": 6, "cache_write_tokens": 4}}) + un_top = usage_numbers({"prompt_tokens": 10, "cached_tokens": 7}) + check("cache reads and writes are read from prompt_tokens_details and from the top level", + un == (10, 6, 3, 4) and un_top == (10, 7, 0, None), f"{un} {un_top}") + check("only K3-class models offer the 1h cache tier", + supports_cache_ttl("kimi-k3.1-pro") and supports_cache_ttl("kimi-k3") + and not supports_cache_ttl("kimi-k9-x")) + + g3 = globals() + saved3 = {k: g3[k] for k in ("OUT_DIR", "SETTINGS_FILE")} + saved_env = os.environ.pop("KIMI_MODEL", None) + g3["OUT_DIR"] = root / ".kimi_out_models" + g3["SETTINGS_FILE"] = g3["OUT_DIR"] / "settings.json" + g3["OUT_DIR"].mkdir(parents=True, exist_ok=True) + try: + d0 = configured_model() + os.environ["KIMI_MODEL"] = "pro" + d_env = configured_model() + with contextlib.redirect_stdout(quiet): + rc_set = set_default_model(None, "highspeed") + rc_bad = set_default_model(None, "kimi-k9-x") + rc_show = show_default_model() + d1 = configured_model() + del os.environ["KIMI_MODEL"] + with contextlib.redirect_stdout(quiet): + rc_back = set_default_model(None, "k3") + d2 = configured_model() + check("default model: kimi-k3 built in; KIMI_MODEL in .env only when nothing is saved; " + "--default-model wins, switches back, and never saves an unknown name", + d0 == ("kimi-k3", "built-in default") and BUILTIN_DEFAULT_MODEL == "kimi-k3" + and d_env == ("kimi-k3.1-pro", "KIMI_MODEL in .env") + and rc_set == 0 and rc_bad == 1 and rc_show == 0 and rc_back == 0 + and d1[0] == "kimi-k3.1-pro-highspeed" and d2[0] == "kimi-k3" + and d2[1].startswith("--default-model"), + f"{d0} {d_env} {d1} {d2} {rc_set} {rc_bad}") + rec = call_record({"usage": {"prompt_tokens": 1000, "completion_tokens": 10, + "prompt_tokens_details": {"cached_tokens": 900}}, + "served_model": "kimi-k3", "finish_reason": "stop", "cost": 0.0012}, + argparse.Namespace(model="kimi-k3", effort="high"), "5m") + split = split_transcript_turns("== TURN 1 - KIMI - kimi-k3 - effort=high - cache=5m\n" + "### END CHANGES" + rec) + check("each Kimi turn ends with a call record (model asked/answered, effort, cache, " + "cost) that stays outside the change blocks", + "answered by kimi-k3" in rec and "effort high" in rec and "900 cached" in rec + and split is not None and parse_changes(split[1]).end_marker + and alternative_model("kimi-k3") == "kimi-k3.1-pro" + and alternative_model("kimi-k3.1-pro") == "kimi-k3" + and short_name("kimi-k3.1-pro") == "pro" and short_name("kimi-k3") == "k3", + rec) + check("a reply from another model is flagged, even one whose name starts the same; " + "a dated snapshot of the asked model is not", + served_differs("kimi-k3", "kimi-k3.1-pro") + and served_differs("kimi-k3.1-pro", "kimi-k3.1-pro-highspeed") + and served_differs("kimi-k3.1-pro", "kimi-k2.7") + and not served_differs("kimi-k3", "kimi-k3") and not served_differs("kimi-k3", None) + and not served_differs("kimi-k3", "kimi-k3-0715")) + check("a reply that ends with '### END CHANGES' is not reported as cut off at a " + "heading; one that ends on a real heading still is", + not reply_shape_problems([], "0. PHILOSOPHY CHECK ok\n\n### END CHANGES\n") + and bool(reply_shape_problems([], "Some text\n\n## 4. BETTER IDEA?\n"))) + blocks_order = [(4, "BETTER IDEA?"), (5, "The blocks, then ### END CHANGES")] + check("a reply whose change blocks sit under another heading still has its blocks " + "section; a reply with none is still told it is missing", + reply_shape_problems(blocks_order, "## 4. BETTER IDEA?\nNone.\n\n## Files\n- a.py\n\n" + "### EDIT: a.py\n<<<<<<< SEARCH\nx\n=======\ny\n" + ">>>>>>> REPLACE\n\n### END CHANGES\n") == [] + and any("5." in m for m in reply_shape_problems(blocks_order, + "## 4. BETTER IDEA?\nNone.\n"))) + + s_models = Session("selftest_models") + s_models.messages, s_models.turn = [{"role": "user", "content": "q"}], 1 + s_models.model, s_models.effort, s_models.cache_ttl = "kimi-k3.1-pro", "high", "1h" + s_models.usage.add({"prompt_tokens": M, "completion_tokens": 0, + "prompt_tokens_details": {"cached_tokens": 0, "cache_write_tokens": M}}, + "kimi-k3.1-pro", "1h") + s_models.save() + back = Session("selftest_models") + legacy = Session("selftest_legacy") + legacy.messages, legacy.turn = [{"role": "user", "content": "q"}], 1 + legacy.save() + blob = json.loads(legacy.messages_path.read_text(encoding="utf-8")) + for k in ("model", "effort", "cache_ttl"): + blob.pop(k, None) + blob["usage"] = {"prompt": M, "cached": 0, "completion": 0, "calls": 1} + legacy.messages_path.write_text(json.dumps(blob), encoding="utf-8") + old = Session("selftest_legacy") + check("a session keeps its model, effort, cache tier and per-call cost; an older " + "session is costed as the kimi-k3 it ran on", + (back.model, back.effort, back.cache_ttl) == ("kimi-k3.1-pro", "high", "1h") + and abs(back.usage.cost - 4.0) < 1e-9 and back.usage.cache_write == M + and old.model is None and abs(old.usage.cost - 3.0) < 1e-9, + f"{back.model} {back.usage.cost} {old.usage.cost}") + finally: + g3.update(saved3) + if saved_env is not None: + os.environ["KIMI_MODEL"] = saved_env + + class _Lister: + def __init__(self, ids: Optional[List[str]]) -> None: + self._models = [{"id": i} for i in ids] if ids is not None else None + + def list_models(self) -> Optional[List[Dict[str, Any]]]: + return self._models + + live = _Lister(["kimi-k3.1-pro", "kimi-k3.1-pro-highspeed", "kimi-k3", "kimi-k2.7-code"]) + bad_name = model_problem(live, "kimi-k3.1-por", "5m") # type: ignore[arg-type] + check("a misspelled or unavailable model is refused before anything is sent, with the " + "near names offered; 1h on a model without the tier is refused; no list, no refusal", + model_problem(live, "kimi-k3.1-pro", "1h") is None # type: ignore[arg-type] + and bool(bad_name) and "kimi-k3.1-pro" in (bad_name or "") + and bool(model_problem(live, "kimi-k2.7-code", "1h")) # type: ignore[arg-type] + and model_problem(_Lister(None), "kimi-k9-x", "5m") is None, # type: ignore[arg-type] + str(bad_name)) except Exception as exc: check("self-test ran to completion", False, f"{type(exc).__name__}: {exc}") finally: @@ -4926,13 +5399,18 @@ def balance(self) -> None: def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser( prog="kimicli.py", - description="Kimi K3 workbench: streams, saves everything, and never lets the model " + description="Kimi workbench: streams, saves everything, and never lets the model " "write to your repo.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="""examples: python kimicli.py --selftest python kimicli.py --balance + python kimicli.py --models + python kimicli.py --default-model + python kimicli.py --default-model pro python kimicli.py --prompt task.md --request-code + python kimicli.py --model pro --prompt task.md --request-code --effort high + python kimicli.py --prompt task.md --request-code --cache-ttl 1h python kimicli.py --resume last --prompt followup.md --request-code python kimicli.py --apply-fixes last --dry-run python kimicli.py --apply-fixes last --allow-new-files @@ -4979,9 +5457,26 @@ def build_parser() -> argparse.ArgumentParser: "shape of the answer. Speeds up whole-file rewrites with small changes.") g = p.add_argument_group("model") - g.add_argument("--model", default=MODEL) - g.add_argument("--effort", choices=["low", "high", "max"], default="max", - help="reasoning effort (default max, which is also Kimi's default)") + g.add_argument("--model", metavar="NAME", + help="model for this run: a full id or an alias (k3, pro, fast). Default: " + f"the one saved with --default-model, else {BUILTIN_DEFAULT_MODEL}. A " + "--resume keeps its session's model") + g.add_argument("--models", action="store_true", + help="list the models this API key can call, with prices, and exit (free)") + g.add_argument("--default-model", dest="default_model", metavar="NAME", + nargs="?", const="", + help="with no NAME: show the default model and where it comes from. With " + "NAME (k3, pro, fast or a full id): make it the default for every new " + "run, and exit. Sends nothing, costs nothing") + g.add_argument("--effort", choices=["low", "high", "max"], default=None, + help="reasoning effort (default max, Kimi's own default; a --resume keeps " + "its session's effort)") + g.add_argument("--cache-ttl", choices=["5m", "1h"], default=None, + help="cache lifetime for this conversation's prefix. 5m (default) costs " + "nothing extra; 1h bills the first write at 2x input but keeps the " + "prefix cached for an hour after every hit - worth it only when the " + "prefix is reused at least twice, each within an hour of the last. A " + "--resume keeps its session's tier") g.add_argument("--max-completion", type=int, default=DEFAULT_MAX_COMPLETION, help=f"max output tokens (default {DEFAULT_MAX_COMPLETION:,}, ceiling 1,048,576)") g.add_argument("--cache-key", help="prompt_cache_key override (default: hash of the dump)") @@ -5030,6 +5525,103 @@ def build_parser() -> argparse.ArgumentParser: return p +# ========================================================================== +# Models: list, save a default, check before sending +# ========================================================================== + +def print_models(kimi: Optional["Kimi"]) -> int: + """Every model the key can call (live, free), with the prices on file.""" + current, source = configured_model() + live = kimi.list_models() if kimi else None + if live is None: + why = ("MOONSHOT_API_KEY is not set" if kimi is None + else "the model list could not be fetched (see the warning above)") + print(f"\n Live list unavailable: {why}. Showing the models kimicli has prices for.\n") + ids = list(MODEL_INFO) + else: + live_ids = [str(d["id"]) for d in live] + ids = [m for m in MODEL_INFO if m in live_ids] + sorted(i for i in live_ids if i not in MODEL_INFO) + print(f"\n Models this API key can call ({len(live_ids)}, live from {BASE_URL}/models):\n") + width = max(len(i) for i in ids) if ids else 10 + for mid in ids: + info = MODEL_INFO.get(mid) + mark = "*" if mid == current else " " + if info: + price = (f"${info.miss:g} in / ${info.out:g} out / ${info.hit:g} cached" + + (f" / 1h cache write ${info.write_1h:g}" if info.write_1h else "")) + print(f" {mark} {mid:<{width}} {price} {info.context:,} ctx") + if info.note: + print(f" {'':<{width}} {info.note}") + else: + extra = live and next((d for d in live if d.get("id") == mid), None) or {} + ctx = extra.get("context_length") + print(f" {mark} {mid:<{width}} (no prices on file - the preflight assumes the worst case)" + + (f" {int(ctx):,} ctx" if isinstance(ctx, (int, float)) else "")) + if live is not None: + gone = [m for m in MODEL_INFO if m not in [str(d["id"]) for d in live]] + if gone: + print(f"\n ! on file but NOT callable with this key: {', '.join(gone)}") + aliases: Dict[str, List[str]] = {} + for a, mid in MODEL_ALIASES.items(): + aliases.setdefault(mid, []).append(a) + print("\n aliases : " + "; ".join(f"{', '.join(v)} = {k}" for k, v in aliases.items())) + print(f" default : {current} (from {source}){' * marks it' if current in ids else ''}") + print(f" prices : as of {PRICES_CHECKED}; check platform.kimi.ai/docs/pricing/chat if that is old") + print("\n one run : python kimicli.py --model pro --prompt task.md --request-code --effort high") + print(" make default : python kimicli.py --default-model pro (back: --default-model k3)\n") + return 0 + + +def show_default_model() -> int: + current, source = configured_model() + info, known = pricing_for(current) + print(f"Default model: {current} ({info.label if known else 'no prices on file'}), from {source}.") + env = (os.getenv("KIMI_MODEL") or "").strip() + if env and not source.startswith("KIMI_MODEL"): + print(f" (KIMI_MODEL={env} in your .env is ignored while a saved default exists.)") + print("Change it: python kimicli.py --default-model pro (or k3, fast, a full id)") + return 0 + + +def set_default_model(kimi: Optional["Kimi"], name: str) -> int: + want = resolve_model_name(name) + live = kimi.list_models() if kimi else None + if live is not None and want not in [str(d["id"]) for d in live]: + print(f"'{want}' is not a model this API key can call. Nothing saved. See: " + f"python kimicli.py --models") + return 1 + if live is None and want not in MODEL_INFO: + print(f"'{want}' is not one kimicli knows and the live list could not be checked. " + f"Nothing saved.") + return 1 + before, _src = configured_model() + blob = _read_settings() + blob["model"] = want + atomic_write(SETTINGS_FILE, json.dumps(blob, indent=1)) + info, _k = pricing_for(want) + print(f"Default model: {before} -> {want} ({info.label}).\nEvery new run uses it unless " + f"--model says otherwise; a --resume keeps the model its\nsession started with. " + f"Saved in {SETTINGS_FILE} (never committed).") + if want != BUILTIN_DEFAULT_MODEL: + print(f"Switch back any time: python kimicli.py --default-model {short_name(BUILTIN_DEFAULT_MODEL)}") + return 0 + + +def model_problem(kimi: "Kimi", model: str, ttl: str) -> Optional[str]: + """Why this request must not go out on this model, or None. A wrong model + name is refused for free here instead of after the codebase is built.""" + if ttl == "1h" and not supports_cache_ttl(model): + return (f"--cache-ttl 1h: {model} has no 1-hour cache tier on file " + f"(only {', '.join(m for m in MODEL_INFO if supports_cache_ttl(m))} do).") + live = kimi.list_models() + if live is not None and model not in [str(d["id"]) for d in live]: + close = difflib.get_close_matches(model, [str(d["id"]) for d in live], n=3, cutoff=0.5) + return (f"'{model}' is not a model this API key can call" + + (f" - did you mean {' or '.join(close)}?" if close else "") + + " See: python kimicli.py --models") + return None + + def main() -> int: args = build_parser().parse_args() OUT_DIR.mkdir(parents=True, exist_ok=True) @@ -5097,6 +5689,15 @@ def main() -> int: return run_apply(props, args, blocking=blocking, preview_source=f, meta={"source": f"file {f.name}"}) + # ---- model listing / default (free: no tokens are sent) ------------- + if args.models or args.default_model is not None: + kimi0 = Kimi(API_KEY, BASE_URL) if API_KEY else None + if args.models: + return print_models(kimi0) + if not args.default_model.strip(): + return show_default_model() + return set_default_model(kimi0, args.default_model) + # ---- API modes ----------------------------------------------------- if not API_KEY: print("MOONSHOT_API_KEY is not set. Put it in .env next to this script.") @@ -5135,6 +5736,29 @@ def main() -> int: return 1 print(f"Resumed {session.id}: {len(session.messages)} messages, " f"{money(session.usage.cost)} spent so far.") + # Model, effort and cache tier follow the session unless named. A + # change re-sends the whole conversation uncached, so it is said aloud. + was_model = session.model or LEGACY_SESSION_MODEL + args.model = resolve_model_name(args.model) if args.model else was_model + if args.model != was_model: + print(f" ! model {was_model} -> {args.model}: the next turn re-sends the whole " + f"conversation to a model\n that has never cached it, so its input bills at " + f"the full rate. Moonshot recommends\n starting a fresh session over " + f"switching models mid-conversation.") + else: + print(f" model: {args.model}" + ("" if session.model else + " (this session predates per-session models; it ran on it)")) + was_effort = session.effort or "max" + args.effort = args.effort or was_effort + if args.effort != was_effort and session.effort: + print(f" note: effort {was_effort} -> {args.effort} can miss the cache on this turn.") + was_ttl = session.cache_ttl or "5m" + args.cache_ttl = args.cache_ttl or was_ttl + if args.cache_ttl != was_ttl: + print(f" note: cache tier {was_ttl} -> {args.cache_ttl} applies to NEW cache writes; a " + f"prefix still cached keeps\n the tier it was written with.") + args.is_resume = True + session.model, session.effort, session.cache_ttl = args.model, args.effort, args.cache_ttl # Reuse the key this session was opened with. Minting a fresh # "aa-resume-" here was the bug that made every resumed turn # report 0% cached against a byte-identical prefix. @@ -5174,6 +5798,13 @@ def main() -> int: return 1 ctx = Context(messages=session.messages, parts=parts, cache_key=args.cache_key_resolved) else: + if args.model: + args.model = resolve_model_name(args.model) + else: + args.model, source = configured_model() + logger.info("model %s (from %s)", args.model, source) + args.effort = args.effort or "max" + args.cache_ttl = args.cache_ttl or "5m" ctx = build_context(args) if ctx.errors: for e in ctx.errors: @@ -5190,6 +5821,13 @@ def main() -> int: # as it stands was a billed call for an "I'm waiting" reply ($2.30). has_new = bool(ctx.messages) and ctx.messages[-1].get("role") == "user" + problem = model_problem(kimi, args.model, args.cache_ttl) + if problem: + if session is not None and has_new: + session.messages.pop() # the resume prompt was never sent + print(f" !! {problem}\nNot sent.") + return 1 + if args.estimate_only: args.yes = True # a cost check should never prompt to send preflight(ctx, kimi, args) @@ -5207,6 +5845,7 @@ def main() -> int: session = Session() session.messages = ctx.messages session.cache_key = ctx.cache_key + session.model, session.effort, session.cache_ttl = args.model, args.effort, args.cache_ttl if has_new: try: From 5b46a464c575bf6eeda2cb6804c58f6479dfe84f Mon Sep 17 00:00:00 2001 From: Nick Liebmann <53795413+Liebmann5@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:21:45 -0700 Subject: [PATCH 5/5] Query builder: GUI window, one builder vocabulary, silent drops noted, docs and ADR-019 --- CHANGELOG.md | 17 +- README.md | 6 +- .../auto_apply/docs/AA_ARCHITECTURE_BIBLE.md | 5 +- ...earch_surface_catalog_and_query_builder.md | 107 ++++ packages/auto_apply/docs/adr/index.md | 4 +- .../docs/architecture/ui_port_layer.md | 2 +- .../docs/user_guide/building_searches.md | 69 +++ packages/auto_apply/docs/user_guide/index.md | 1 + packages/auto_apply/mkdocs.yml | 2 + .../primary/cli/search_builder_screen.py | 42 +- .../auto_apply/adapters/primary/gui/app.py | 26 +- .../adapters/primary/gui/search_window.py | 523 ++++++++++++++++++ .../adapters/primary/gui/strings.py | 41 ++ .../application/services/query_builder.py | 44 +- .../services/session_controller.py | 33 +- .../auto_apply/domain/models/search_query.py | 22 + .../src/auto_apply/domain/ports/ui_port.py | 21 +- .../domain/services/query_composer.py | 24 + .../src/auto_apply/resources/locales/en.json | 39 ++ .../src/auto_apply/resources/locales/es.json | 39 ++ .../tests/adapters/test_gui_search_window.py | 162 ++++++ .../tests/adapters/test_search_builder_cli.py | 46 +- .../test_query_builder_platforms.py | 16 +- .../application/test_query_builder_service.py | 34 +- .../application/test_ui_port_conformance.py | 3 +- .../tests/domain/test_query_composer.py | 74 +++ .../tests/domain/test_ui_contract.py | 3 +- 27 files changed, 1299 insertions(+), 106 deletions(-) create mode 100644 packages/auto_apply/docs/adr/019_search_surface_catalog_and_query_builder.md create mode 100644 packages/auto_apply/docs/user_guide/building_searches.md create mode 100644 packages/auto_apply/src/auto_apply/adapters/primary/gui/search_window.py create mode 100644 packages/auto_apply/tests/adapters/test_gui_search_window.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 52bb1f8..b61dbf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,16 @@ from its first tag onward. ### Added +- **The query builder is finished: the GUI window, one vocabulary on both + surfaces, and the last silent drops said aloud.** File → Build a Search… + offers everything the CLI offers (engine, platform and company searches, + the profile's saved titles and locations as one-click defaults, copy to the + clipboard, open the link, keep / reuse / clear recent queries). Both + surfaces now speak the UI port's builder vocabulary — the service's methods + were renamed to the port's names, and a new `resolve_query_target` step + folds a platform or company target into the remembered query so it replays + identically from either surface. Parity is pinned, not intended. + - **ATS platform registry expansion + builder platform search.** 28 new platform descriptors (BambooHR, Rippling, Workable, SmartRecruiters, Jobvite, Recruitee, Gusto, PinpointHQ, Eightfold, Keka, Dover, Zoho @@ -49,7 +59,7 @@ from its first tag onward. refused in plain words with a non-zero exit, never a blank result. Copy, open and keep are the only actions that remember a query, and a remembered platform or company query is stored resolved and replays identically. -- **`UIPort` grows from 17 to 26 methods** for the query builder, and its +- **`UIPort` grows from 17 to 27 methods** for the query builder, and its data-only rule now names two frozen DTO modules (`ui_contract`, `search_query`). `QueryBuilderError` moved to `domain/exceptions.py` and is re-exported from `application/services/query_builder.py`, so existing @@ -67,6 +77,11 @@ from its first tag onward. ### Fixed +- **The builder no longer drops anything silently.** A location a surface + cannot carry (Google, Bing, DuckDuckGo, LinkedIn) now produces a + plain-language note instead of vanishing, and LinkedIn's unverified + exact-phrase terms are noted when they go into a link unquoted. Automated + discovery's URLs are byte-identical (the byte-identity pin holds). - **A restriction the surface cannot express no longer produces a link that silently drops it.** The composer refuses the link as well as the search text, with an explanatory note (was: link returned with the restriction diff --git a/README.md b/README.md index 88e65d6..88be4da 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ along with what evidence would falsify it. | Run from a USB stick with no admin rights | **Working** | | Record an auditable, opt-in research dataset | **Working** | | Operate with no browser at all | **Removed** — see [ADR-013](packages/auto_apply/docs/adr/013_static_path_retirement.md) | +| Build a search by hand (engine, platform, or one company) | **Working** — composes the link; you open it. Automates nothing | A live browser is now **required**. AA refuses to start a session rather than pretending to run without one. @@ -174,7 +175,10 @@ Reproduction instructions are in Automating interaction with job boards may conflict with their terms of service, and using AA is your decision and your responsibility. AA does not solve CAPTCHAs, does not store credentials by default, and does not invent -claims on your behalf. Read the +claims on your behalf. The query builder composes search links but automates +nothing — you open them yourself, which is ordinary use. Search sites change +their query syntax without notice, so a surface's "verified" mark is only as +good as its verification date (each surface records one). Read the **[DISCLAIMER](DISCLAIMER.md)** and **[ETHICS](packages/auto_apply/docs/ETHICS.md)** before your first run. diff --git a/packages/auto_apply/docs/AA_ARCHITECTURE_BIBLE.md b/packages/auto_apply/docs/AA_ARCHITECTURE_BIBLE.md index e5cd307..7a4f1a3 100644 --- a/packages/auto_apply/docs/AA_ARCHITECTURE_BIBLE.md +++ b/packages/auto_apply/docs/AA_ARCHITECTURE_BIBLE.md @@ -2595,8 +2595,9 @@ be re-proposed. ### 34.8 What this layer does not yet do -- The port is 15 methods and session-shaped. Identity and custody parity - cannot be pinned until it widens. +- The port is 27 methods. Ten of them are the query builder — the only part + that works before any session exists — so "session-shaped" no longer fits + the whole port. Identity and custody parity remain unpinned. - The GUI still cannot switch profiles; it loads `user_profiles[0]`. - Post-submission outcome tracking is **deferred, not cut**. Three things must stay true so it remains possible: the `attempt_id` + `page_index` join key diff --git a/packages/auto_apply/docs/adr/019_search_surface_catalog_and_query_builder.md b/packages/auto_apply/docs/adr/019_search_surface_catalog_and_query_builder.md new file mode 100644 index 0000000..438ec03 --- /dev/null +++ b/packages/auto_apply/docs/adr/019_search_surface_catalog_and_query_builder.md @@ -0,0 +1,107 @@ +--- +title: "ADR-019: The Search-Surface Catalog and the Query Builder" +status: needs-review +last_verified: 2026-10-28 +verified_against: "domain/models/search_surface.py, domain/services/query_composer.py, application/services/query_builder.py, adapters/secondary/discovery/search_surface_registry.py" +audience: contributors +--- + +# ADR-019: The Search-Surface Catalog and the Query Builder + +**Status:** Accepted +**Date:** 2026-10-28 +**Deciders:** Nicholas Liebmann + +## Context + +AA composes search queries in two places that must never disagree: the +automated discovery engines (which send URLs no user sees) and the +user-facing query builder (which composes a search a person runs themselves). +Before the catalog, each engine strategy hardcoded its own URL construction — +the exact "knowledge scattered across provider files" defect [ADR-004](004_ats_platform_registry.md) +recorded for ATS platforms. And the builder, once it existed, faced a sharper +version of the same question: what is a site *allowed* to claim it supports? + +## Decision + +**One catalog, two read paths, one composer.** + +1. **The search-surface catalog is the single source of truth for what a + surface accepts** — typed operators, address-bar parameters, URL + construction — as typed, validated YAML under `resources/search_surfaces/`. + Every operator and option records a *verification status* + (`vendor` / `measured` / `third_party` / `unverified`), a source, and for + `measured` a date. An unverified capability is recorded so its absence from + the offered set is deliberate; it is never offered and never composed. + +2. **Platforms come from the ATS descriptors**, not a second list — + `resources/ats/*.yaml` carries the tenancy, the `site:` restriction and its + own evidence status. Platform identity is single-sourced the way + [ADR-004](004_ats_platform_registry.md) intended. + +3. **Automation reads the bundled catalog only** (`load_surface_bundled`). A + user override in the data directory can extend the surfaces a person + searches by hand, but it can never change the URLs AA's automation sends. + The byte-identity pin (`tests/adapters/test_engine_url_byte_identity.py`) + holds this. + +4. **Refusal-both, and a location ruling.** Anything a surface cannot express + is refused or noted, never silently dropped. A *restriction* whose loss + changes what the search *is* (a `site:` limit, an `any_of` the surface + cannot carry) is refused — no bar text, no link, because a link without it + would be a different search. A *hint* whose loss only broadens the same + search — a location on a surface with no location field — is **noted**, + with the remedy named, because refusing it would strand a worst-case user + inside an error they cannot act on. This distinction is decided once, in + the domain (`query_composer._compose_url`), for every surface. + +5. **The port shape: one vocabulary, two objects.** The builder's nine + methods live on `UIPort`, satisfied by `SessionController` in-session. + Because the builder is useful *before* any session exists — when no + controller does — the pre-session `QueryBuilderService` exposes the + identical method names, and the GUI reads whichever object is present + (controller mid-session, service otherwise) without branching on which. + Drift is pinned, not trusted: a test asserts the service, the port and the + controller share the same names, and that the CLI and GUI produce the same + `QueryResult` for the same input. A tenth method, `resolve_query_target`, + folds a platform or company target into the request being remembered, so a + query kept on either surface replays identically. + +## Options considered + +**Keep the engine strategies' hardcoded URL builders.** Rejected — two sources +of query truth is the defect the catalog exists to remove, and the byte-identity +pin proves the move changed nothing automation sends. + +**A wrapper port-implementation for the pre-session builder.** Rejected — a +second class that only forwards to the service is the wrapper ADR-014 +explicitly avoided; the service speaking the port's names gives one vocabulary +with no wrapper. + +**Refuse on a dropped location, like a dropped site: limit.** Rejected — a +location is a hint, not a restriction; a locationless engine search is the +same search, broadened. The user sees a note and the remedy, not a dead end. + +## Consequences + +- Easier: a new surface is one YAML file; a new platform is one descriptor; + verification status is data, and upgrading it is a hand-measurement, not a + code change. Both surfaces can never drift into two vocabularies. +- Harder: a new port method means touching the port, the controller, the + service, both surfaces and two pin sets — deliberately, since a divergent + surface is the defect class this removes. +- **Research option, deferred — not closed.** Whether people find AA's + automated search or the hand-built searches more useful is a research + question (the paired-audit arm could compare outcomes). It is recorded here + as a deferral: it would need its own versioned consent through the existing + research-consent mechanism, and nothing in the builder writes toward a + research store. + +## References + +- `domain/models/search_surface.py`, `domain/services/query_composer.py` +- `application/services/query_builder.py` +- `adapters/secondary/discovery/search_surface_registry.py` +- `tests/adapters/test_engine_url_byte_identity.py` +- [ADR-004](004_ats_platform_registry.md), [ADR-014](014_typed_ui_port.md) +- [ui_port_layer.md](../architecture/ui_port_layer.md) diff --git a/packages/auto_apply/docs/adr/index.md b/packages/auto_apply/docs/adr/index.md index e9238e0..3eb49c7 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 `019`.** Take it by creating the file; if two people +3. **The next number is `020`.** 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. @@ -50,6 +50,7 @@ and here is why not" from "nobody thought of it". | [016](016_retirement_over_deletion.md) | Retirement replaces deletion | Accepted | 2026-08-30 | | [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 | `tests/infrastructure/test_docs_gate.py` asserts that this table and the directory contain the same set of records. @@ -64,6 +65,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. | ## Decisions still unrecorded diff --git a/packages/auto_apply/docs/architecture/ui_port_layer.md b/packages/auto_apply/docs/architecture/ui_port_layer.md index 330668c..3ba69e0 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 17 methods and session-shaped | Identity and custody parity cannot be pinned until it widens | +| `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` 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/user_guide/building_searches.md b/packages/auto_apply/docs/user_guide/building_searches.md new file mode 100644 index 0000000..c0e50f5 --- /dev/null +++ b/packages/auto_apply/docs/user_guide/building_searches.md @@ -0,0 +1,69 @@ +--- +title: "Building a Search" +status: needs-review +last_verified: 2026-10-28 +verified_against: "application/services/query_builder.py, adapters/primary/cli/search_builder_screen.py, adapters/primary/gui/search_window.py" +audience: users +--- + +# Building a Search + +The query builder composes a search you run yourself, in your own browser. It +automates nothing: it never opens a page, never logs in, and never applies +anywhere. It writes the search-bar text and a link for the site you choose, +and then you decide what to do with them. + +## What it is for + +AA's automated discovery is the agent's job. The builder is the opposite: a +way for you to write a precise search by hand — one site, one hiring platform, +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. + +## Search-bar text versus the link + +Most sites give you two forms of the same search: + +- **Search-bar text** — what you paste into the site's own search box. +- **A link** — the same search as a web address you can open or share. + +Some settings exist **only in the link** — there is nothing you can type that +does the same thing. Google's "newer than 24 hours" filter is the example: +the link carries it (`tbs=qdr:d`); the nearest thing you can type is +`after:2026-01-01`, which is a fixed date, not a rolling window. When a +setting is link-only, the builder says so. + +## What the platform labels mean + +Every hiring platform in the picker records how well its search restriction is +verified: + +- **No label** — measured by hand, or vendor-documented. +- **Unverified** — recorded from a sample URL but not yet hand-measured. +- **Third-party** — reported by others, not yet confirmed by AA. + +A platform's status is raised by a person, not by AA: follow the checklist in +[Verifying Platform Search Data](../developer_guide/verifying_platform_search.md) +— paste the query, check the results are all from that platform, and record +the outcome in the platform's descriptor. + +## Recent searches + +The builder remembers your five most recent searches, but **only** when you +copy, open, or keep one — never as you type. They are stored as plain text in +`query_builder_recent.json` inside AA's data folder (the same folder as your +logs and reports). "Clear recent" forgets all of them. Uninstalling AA removes +the file with the rest of the data folder. + +## The two ways in + +- **GUI:** File → Build a Search… . Works before you have a profile or a + session. Pick an engine, choose what to search within (everything it finds, + a hiring platform, or one company), and the result previews as you type. +- **CLI:** `python -m auto_apply --search`, or the scripted + `--search-*` flags. The full flag list is in the + [command-line reference](../reference/cli.md). + +Both produce the same search and remember it the same way — that is pinned by +the test suite, not just intended. diff --git a/packages/auto_apply/docs/user_guide/index.md b/packages/auto_apply/docs/user_guide/index.md index d13597e..aa665ee 100644 --- a/packages/auto_apply/docs/user_guide/index.md +++ b/packages/auto_apply/docs/user_guide/index.md @@ -21,6 +21,7 @@ looking for architectural details, head to the [Developer Guide](../developer_gu | Guide | What you'll learn | | ----- | ----------------- | | [Running a Job Hunt](running_a_job_hunt.md) | Session modes (discovery, direct links, company pages), GUI vs CLI, how to pause/resume/stop, reading the live dashboard. | +| [Building a Search](building_searches.md) | Compose a search link yourself (engine, platform, or one company) — no automation; copy, open, or reuse recent queries. | | [Understanding the Output](understanding_output.md) | Logs, session reports, CSV exports, screenshots on failure, and the optional research data file. | | [Profiles & Privacy](profiles_and_privacy.md) | Creating and editing profiles, encrypting your data with a master password, storing profiles on external drives, what AA collects and how to delete it. | | [Admin Policy](admin_policy.md) | How IT administrators can lock down AA on shared computers — allowed browsers, headless enforcement, rate limits, research opt‑out. | diff --git a/packages/auto_apply/mkdocs.yml b/packages/auto_apply/mkdocs.yml index 70454c5..41710d5 100644 --- a/packages/auto_apply/mkdocs.yml +++ b/packages/auto_apply/mkdocs.yml @@ -77,6 +77,7 @@ nav: - Installing AutoApply: user_guide/installing.md - Uninstalling AutoApply: user_guide/uninstalling.md - Running a Job Hunt: user_guide/running_a_job_hunt.md + - Building a Search: user_guide/building_searches.md - Understanding the Output: user_guide/understanding_output.md - Labelling: user_guide/labelling.md - Profiles and Privacy: user_guide/profiles_and_privacy.md @@ -134,6 +135,7 @@ nav: - "016 Retirement over deletion": adr/016_retirement_over_deletion.md - "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 - Research: - Overview: research_module/index.md diff --git a/packages/auto_apply/src/auto_apply/adapters/primary/cli/search_builder_screen.py b/packages/auto_apply/src/auto_apply/adapters/primary/cli/search_builder_screen.py index d4a997b..5e32a24 100644 --- a/packages/auto_apply/src/auto_apply/adapters/primary/cli/search_builder_screen.py +++ b/packages/auto_apply/src/auto_apply/adapters/primary/cli/search_builder_screen.py @@ -21,7 +21,7 @@ describe_request, format_result_lines, platform_display_text, - resolved_remember_request, + resolve_and_remember, ) from auto_apply.infrastructure.composition_root import build_query_builder @@ -68,35 +68,21 @@ def _build(service, request, platform, company_url): """Build for the chosen target; returns None after printing the error.""" try: if platform: - return service.build_platform_search(platform, request) + return service.build_platform_query(platform, request) if company_url: - return service.build_company_search(company_url, request) - return service.build(request) + return service.build_company_query(company_url, request) + return service.build_query(request) except Exception as exc: # noqa: BLE001 — the builder's message is the user-facing contract print(f" {get_text('builder.error')}: {exc}") # noqa: T201 return None -def _fold(service, request, platform, company_url): - """Resolve a platform or company query to the engine query to remember. - - The restriction comes from the service's one resolver - (platform_restriction_for), so the remembered request replays - identically. - """ - if platform: - return resolved_remember_request( - request, service.platform_restriction_for(platform) - ) - if company_url: - return resolved_remember_request( - request, service.platform_restriction_for(url=company_url) - ) - return request - - def _remember(service, request, platform, company_url) -> None: - service.remember(_fold(service, request, platform, company_url)) + """Remember via the shared resolve-then-remember step, so a query kept + here replays identically to one kept from the GUI.""" + resolve_and_remember( + service, request, platform=platform, company_url=company_url + ) def _scripted(args, service) -> int: @@ -144,7 +130,7 @@ def _choose_named(prompt: str, names: list[str], display) -> str | None: def _choose_surface(service) -> str | None: - offers = service.surfaces() + offers = service.query_surfaces() names = [o.name for o in offers] display = {o.name: o.display_name for o in offers} return _choose_named( @@ -171,7 +157,7 @@ def _choose_target() -> str | None: def _choose_platform(service) -> str | None: - offers = service.platform_offers() + offers = service.query_platforms() names = [o.name for o in offers] display = {o.name: platform_display_text(o) for o in offers} return _choose_named( @@ -180,7 +166,7 @@ def _choose_platform(service) -> str | None: def _collect_inputs(service, surface: str): - defaults = service.defaults() + defaults = service.query_defaults() terms_default = ", ".join(defaults.titles) if terms_default: print(f" {get_text('builder.saved_note')} {terms_default}") # noqa: T201 @@ -274,7 +260,7 @@ def _interactive(service) -> int: def _recent(service) -> int: - recent = service.recent() + recent = service.recent_queries() if not recent: print(get_text("builder.recent_empty")) # noqa: T201 return 0 @@ -295,7 +281,7 @@ def run_search(args, *, profile=None, builder=None) -> int: service = builder if builder is not None else build_query_builder(profile) try: if args.search_clear_recent: - service.clear_recent() + service.clear_recent_queries() print(get_text("builder.recent_cleared")) # noqa: T201 return 0 if args.search_recent: 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 c49db78..4fa689f 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 @@ -89,6 +89,7 @@ from auto_apply.adapters.primary.gui.dashboard import Dashboard as GUIDashboard from auto_apply.adapters.primary.gui.research_window import ResearchWindow +from auto_apply.adapters.primary.gui.search_window import SearchBuilderWindow from auto_apply.adapters.primary.gui.settings_editor import SettingsEditor from auto_apply.adapters.primary.gui.strings import get_strings from auto_apply.adapters.primary.gui.wizard import SessionConfigWizard as GUIWizard @@ -101,7 +102,10 @@ SessionSummary, ) from auto_apply.domain.ports.profile_repository_port import ProfileRepositoryPort -from auto_apply.infrastructure.composition_root import build_research_consent +from auto_apply.infrastructure.composition_root import ( + build_query_builder, + build_research_consent, +) if TYPE_CHECKING: from auto_apply.application.services.session_controller import SessionController @@ -389,6 +393,9 @@ def _build_menu_bar(self) -> None: file_menu.add_command( label="Research…", command=self._open_research, ) + file_menu.add_command( + label="Build a Search…", command=self._open_search_builder, + ) file_menu.add_command( label="Install / Repair…", command=self._open_install, ) @@ -743,6 +750,23 @@ def _open_research(self) -> None: consent = build_research_consent() ResearchWindow(self, consent) + def _open_search_builder(self) -> None: + """File → Build a Search…: the query-builder window (device-scoped). + + Works before any profile or session, exactly like Research…. During + a session the window is handed the controller — the UI port; before + or between sessions it is handed the pre-session builder service. + Both expose the identical builder method names, so the window reads + and writes through one vocabulary either way. + """ + builder: Any = None + if self.controller is not None and self.controller.query_builder is not None: + builder = self.controller + if builder is None: + builder = build_query_builder(self.profile) + strings = get_strings() + SearchBuilderWindow(self, builder, translate=lambda key: strings.get(key, key)) + # ===================================================================== # LIFECYCLE (Install / Repair…, Uninstall…) # ===================================================================== diff --git a/packages/auto_apply/src/auto_apply/adapters/primary/gui/search_window.py b/packages/auto_apply/src/auto_apply/adapters/primary/gui/search_window.py new file mode 100644 index 0000000..79bda09 --- /dev/null +++ b/packages/auto_apply/src/auto_apply/adapters/primary/gui/search_window.py @@ -0,0 +1,523 @@ +"""The GUI query-builder window (File → Build a Search…). + +The second builder surface; the CLI search screen is the first, and parity is +pinned: both read their offers through the same builder method names — the UI +port's builder methods, resolve_query_target included — and render results +through the same domain helpers (domain/models/search_query.py). Every +testable rule lives in the widget-free helpers below, so no pin constructs Tk +(the research_window.py precedent). + +Rules this window exists to keep: + + * Preview never writes to the device. Copy, open and keep are the only + actions that remember a query, and platform/company queries are + remembered resolved, so they replay identically from either surface. + * The only outward call is webbrowser.open, on an explicit click. Nothing + else touches the network. + * A refusal renders in plain words, never a blank result. + * Accessibility (WCAG 2.1 AA): every control is labelled and reachable by + keyboard; copy, keep and refusal outcomes are announced as text in the + status line, not by colour alone; Escape and the window-close button + decline without remembering anything. + +What the source-level pins CANNOT check headless: real Tab traversal and +screen-reader behaviour. The guarantees above are structural; the rest is +manual testing, said plainly. +""" + +from __future__ import annotations + +import tkinter as tk +import webbrowser +from collections.abc import Callable +from tkinter import ttk +from tkinter.scrolledtext import ScrolledText +from typing import Any + +from auto_apply.domain.models.search_query import ( + build_request, + describe_request, + format_result_lines, + platform_display_text, + resolve_and_remember, +) + +__all__ = [ + "SearchBuilderWindow", + "TARGET_KINDS", + "build_for_target", + "offered_input_fields", + "offered_option_choices", + "platform_choices", + "recent_choices", + "result_lines", + "surface_choices", +] + +#: The three search targets, in display order. Labels come from the locale at +#: render time; these keys never change. +TARGET_KINDS = ("surface", "platform", "company") + + +def surface_choices(offers) -> list[tuple[str, str]]: + """(name, display name) pairs for the engine combobox.""" + return [(o.name, o.display_name) for o in offers] + + +def platform_choices(offers) -> list[tuple[str, str]]: + """(name, label) pairs; the label is the service's shared verification label.""" + return [(o.name, platform_display_text(o)) for o in offers] + + +def offered_input_fields(offer) -> frozenset[str]: + """The operator keys a surface offers, driving which optional fields show.""" + if offer is None: + return frozenset() + return frozenset(op.key for op in offer.operators) + + +def offered_option_choices(offer) -> list[tuple[str, str, list[tuple[str, str]]]]: + """(key, label, [(choice key, choice label)]) for a surface's URL options.""" + if offer is None: + return [] + return [ + (opt.key, opt.label, [(c.key, c.label) for c in opt.choices]) + for opt in offer.options + ] + + +def build_for_target(builder, request, *, platform=None, company_url=None): + """The one dispatch the preview and every action share — the same three + branches the CLI takes.""" + if platform: + return builder.build_platform_query(platform, request) + if company_url: + return builder.build_company_query(company_url, request) + return builder.build_query(request) + + +def result_lines(result, translate) -> list[str]: + """The result as display lines — the same domain helper the CLI uses.""" + return format_result_lines(result, translate=translate) + + +def recent_choices(recent) -> list[str]: + """One line per remembered query, for the recent combobox.""" + return [describe_request(r) for r in recent] + + +class SearchBuilderWindow(tk.Toplevel): + """The window. Wires widgets to the helpers above; owns no policy.""" + + def __init__( + self, + parent: tk.Misc, + builder: Any, + *, + translate: Callable[[str], str] | None = None, + ) -> None: + super().__init__(parent) + self._builder = builder + self._t = translate if translate is not None else (lambda key: key) + self._last_result = None + self._last_request = None + self._last_platform = None + self._last_company_url = None + self._recent: tuple = () + + self._surfaces = tuple(builder.query_surfaces()) + self._platforms = tuple(builder.query_platforms()) + self._defaults = builder.query_defaults() + self._engine_names = [n for n, _ in surface_choices(self._surfaces)] + self._engine_display = [d for _, d in surface_choices(self._surfaces)] + self._platform_names = [n for n, _ in platform_choices(self._platforms)] + self._platform_display = [d for _, d in platform_choices(self._platforms)] + + self._engine_var = tk.StringVar() + self._target_var = tk.StringVar(value="surface") + self._platform_var = tk.StringVar() + self._company_var = tk.StringVar() + self._terms_var = tk.StringVar() + self._location_var = tk.StringVar() + self._anyof_var = tk.StringVar() + self._exclude_var = tk.StringVar() + self._intitle_var = tk.StringVar() + self._recent_var = tk.StringVar() + self._status_var = tk.StringVar() + self._option_vars: dict[str, tk.StringVar] = {} + self._option_choice_maps: dict[str, dict[str, str]] = {} + self._operator_rows: dict[str, ttk.Frame] = {} + + self.title(self._t("builder.window_title")) + self.geometry("780x720") + self.minsize(640, 540) + self.transient(parent) # type: ignore[call-overload] + self.protocol("WM_DELETE_WINDOW", self._close) + self.bind("", lambda _event: self._close()) + + self._build_widgets() + self._prefill_defaults() + if self._engine_display: + self._engine_box.current(0) + self._on_engine_change() + self._refresh_recent() + + # ── layout ────────────────────────────────────────────────────────── + + def _labelled_entry(self, parent, key: str, var) -> ttk.Frame: + row = ttk.Frame(parent) + row.pack(fill=tk.X, pady=2) + ttk.Label(row, text=self._t(key)).pack(side=tk.LEFT) + entry = ttk.Entry(row, textvariable=var, width=52) + entry.pack(side=tk.LEFT, padx=(8, 0), fill=tk.X, expand=True) + var.trace_add("write", lambda *_: self._on_change()) + return row + + def _quick_pick(self, parent, values, var) -> None: + box = ttk.Combobox(parent, values=values, state="readonly", width=26) + box.set(self._t("builder.pick_one")) + box.pack(side=tk.LEFT, padx=(6, 0)) + + def _apply(_event=None) -> None: + var.set(box.get()) + self._on_change() + + box.bind("<>", _apply) + + def _build_widgets(self) -> None: + # The button row packs side=BOTTOM BEFORE the expanding content so it + # reserves its height — the Settings dialog's load-bearing lesson. + buttons = ttk.Frame(self, padding=(12, 0, 12, 12)) + buttons.pack(fill=tk.X, side=tk.BOTTOM) + self._copy_text_btn = ttk.Button(buttons, text=self._t("builder.copy_text"), command=self._copy_text) + self._copy_link_btn = ttk.Button(buttons, text=self._t("builder.copy_link"), command=self._copy_link) + self._open_link_btn = ttk.Button(buttons, text=self._t("builder.open_link"), command=self._open_link) + self._keep_btn = ttk.Button(buttons, text=self._t("builder.keep"), command=self._keep) + for button in (self._copy_text_btn, self._copy_link_btn, self._open_link_btn, self._keep_btn): + button.pack(side=tk.LEFT, padx=(0, 8)) + ttk.Button(buttons, text=self._t("builder.close"), command=self._close).pack(side=tk.RIGHT) + + outer = ttk.Frame(self, padding=12) + outer.pack(fill=tk.BOTH, expand=True) + + row = ttk.Frame(outer) + row.pack(fill=tk.X, pady=2) + ttk.Label(row, text=self._t("builder.engine_label")).pack(side=tk.LEFT) + self._engine_box = ttk.Combobox( + row, textvariable=self._engine_var, + values=self._engine_display, state="readonly", width=30, + ) + self._engine_box.pack(side=tk.LEFT, padx=(8, 0)) + self._engine_box.bind("<>", self._on_engine_change) + + row = ttk.Frame(outer) + row.pack(fill=tk.X, pady=2) + ttk.Label(row, text=self._t("builder.target_label")).pack(side=tk.LEFT) + for kind in TARGET_KINDS: + ttk.Radiobutton( + row, + text=self._t(f"builder.target.{kind}"), + value=kind, + variable=self._target_var, + command=self._on_target_change, + ).pack(side=tk.LEFT, padx=(8, 0)) + + self._platform_row = ttk.Frame(outer) + ttk.Label(self._platform_row, text=self._t("builder.platform_label")).pack(side=tk.LEFT) + self._platform_box = ttk.Combobox( + self._platform_row, textvariable=self._platform_var, + values=self._platform_display, state="readonly", width=44, + ) + self._platform_box.pack(side=tk.LEFT, padx=(8, 0)) + self._platform_box.bind("<>", lambda _e: self._on_change()) + + self._company_row = ttk.Frame(outer) + ttk.Label(self._company_row, text=self._t("builder.company_label")).pack(side=tk.LEFT) + ttk.Entry(self._company_row, textvariable=self._company_var, width=52).pack( + side=tk.LEFT, padx=(8, 0), fill=tk.X, expand=True + ) + self._company_var.trace_add("write", lambda *_: self._on_change()) + + self._labelled_entry(outer, "builder.terms_label", self._terms_var) + self._labelled_entry(outer, "builder.location_label", self._location_var) + + if self._defaults and (self._defaults.titles or self._defaults.locations): + picks = ttk.Frame(outer) + picks.pack(fill=tk.X, pady=2) + ttk.Label(picks, text=self._t("builder.defaults_label")).pack(side=tk.LEFT) + if self._defaults.titles: + self._quick_pick(picks, list(self._defaults.titles), self._terms_var) + if self._defaults.locations: + self._quick_pick(picks, list(self._defaults.locations), self._location_var) + + self._operator_rows["any_of"] = self._labelled_entry(outer, "builder.any_of_label", self._anyof_var) + self._operator_rows["exclude"] = self._labelled_entry(outer, "builder.exclude_label", self._exclude_var) + self._operator_rows["intitle"] = self._labelled_entry(outer, "builder.in_title_label", self._intitle_var) + + self._options_frame = ttk.Frame(outer) + self._options_frame.pack(fill=tk.X) + + ttk.Label(outer, text=self._t("builder.preview_label")).pack(anchor=tk.W, pady=(8, 0)) + self._preview = ScrolledText(outer, height=12, wrap=tk.WORD, state="disabled", takefocus=True) + self._preview.pack(fill=tk.BOTH, expand=True, pady=(0, 6)) + + recent_row = ttk.Frame(outer) + recent_row.pack(fill=tk.X, pady=2) + ttk.Label(recent_row, text=self._t("builder.recent_label")).pack(side=tk.LEFT) + self._recent_box = ttk.Combobox(recent_row, textvariable=self._recent_var, state="readonly", width=46) + self._recent_box.pack(side=tk.LEFT, padx=(8, 0)) + self._recent_box.bind("<>", self._on_recent_selected) + ttk.Button(recent_row, text=self._t("builder.clear_recent"), command=self._clear_recent).pack(side=tk.LEFT, padx=(8, 0)) + + ttk.Label(outer, textvariable=self._status_var, wraplength=720).pack(anchor=tk.W, pady=(4, 0)) + + # ── offers / visibility ─────────────────────────────────────────────── + + def _current_engine_name(self) -> str: + idx = self._engine_box.current() + if 0 <= idx < len(self._engine_names): + return self._engine_names[idx] + return self._engine_names[0] if self._engine_names else "" + + def _current_offer(self): + name = self._current_engine_name() + for offer in self._surfaces: + if offer.name == name: + return offer + return None + + def _rebuild_options(self) -> None: + for child in self._options_frame.winfo_children(): + child.destroy() + self._option_vars = {} + self._option_choice_maps = {} + choices = offered_option_choices(self._current_offer()) + if not choices: + return + ttk.Label(self._options_frame, text=self._t("builder.options_label")).pack(anchor=tk.W) + for key, label, opts in choices: + row = ttk.Frame(self._options_frame) + row.pack(fill=tk.X, pady=2) + ttk.Label(row, text=f"{label}:").pack(side=tk.LEFT) + if opts: + not_set = self._t("builder.option_not_set") + var = tk.StringVar(value=not_set) + self._option_vars[key] = var + self._option_choice_maps[key] = {c_label: c_key for c_key, c_label in opts} + box = ttk.Combobox( + row, textvariable=var, state="readonly", width=26, + values=[not_set] + [c_label for _, c_label in opts], + ) + box.pack(side=tk.LEFT, padx=(8, 0)) + box.bind("<>", lambda _e: self._on_change()) + else: + var = tk.StringVar() + self._option_vars[key] = var + ttk.Entry(row, textvariable=var, width=28).pack(side=tk.LEFT, padx=(8, 0)) + var.trace_add("write", lambda *_: self._on_change()) + + def _refresh_field_visibility(self) -> None: + supported = offered_input_fields(self._current_offer()) + for key, row in self._operator_rows.items(): + if key in supported: + row.pack(fill=tk.X, pady=2) + else: + row.pack_forget() + + def _prefill_defaults(self) -> None: + if self._defaults is not None: + if self._defaults.titles: + self._terms_var.set(", ".join(self._defaults.titles)) + if self._defaults.locations: + self._location_var.set(self._defaults.locations[0]) + + # ── building ────────────────────────────────────────────────────────── + + def _on_engine_change(self, _event=None) -> None: + self._rebuild_options() + self._refresh_field_visibility() + self._on_change() + + def _on_target_change(self) -> None: + kind = self._target_var.get() + self._platform_row.pack_forget() + self._company_row.pack_forget() + if kind == "platform": + self._platform_row.pack(fill=tk.X, pady=2) + if self._platform_display and self._platform_box.current() < 0: + self._platform_box.current(0) + elif kind == "company": + self._company_row.pack(fill=tk.X, pady=2) + self._on_change() + + def _current_options(self) -> tuple[tuple[str, str], ...]: + out: list[tuple[str, str]] = [] + not_set = self._t("builder.option_not_set") + for key, var in self._option_vars.items(): + value = var.get().strip() + if not value or value == not_set: + continue + choice_map = self._option_choice_maps.get(key) + if choice_map is not None: + choice_key = choice_map.get(value) + if choice_key is not None: + out.append((key, choice_key)) + else: + out.append((key, value)) + return tuple(out) + + def _current_request(self): + engine = self._current_engine_name() + if not engine: + return None + return build_request( + surface=engine, + terms=self._terms_var.get(), + any_of=self._anyof_var.get(), + exclude=self._exclude_var.get(), + in_title=self._intitle_var.get(), + location=self._location_var.get(), + options=self._current_options(), + ) + + def _target_args(self) -> tuple[str | None, str | None]: + kind = self._target_var.get() + if kind == "platform": + idx = self._platform_box.current() + if 0 <= idx < len(self._platform_names): + return self._platform_names[idx], None + return None, None + if kind == "company": + url = self._company_var.get().strip() + return (None, url) if url else (None, None) + return None, None + + def _on_change(self, *_args) -> None: + request = self._current_request() + if request is None: + self._show_lines([self._t("builder.nothing_built")]) + self._last_result = None + self._update_action_buttons() + return + platform, company_url = self._target_args() + try: + result = build_for_target( + self._builder, request, platform=platform, company_url=company_url + ) + except Exception as exc: # noqa: BLE001 — the builder's message is user-facing text + self._show_lines([f"{self._t('builder.error')}: {exc}"]) + self._last_result = None + self._update_action_buttons() + return + self._last_result = result + self._last_request = request + self._last_platform = platform + self._last_company_url = company_url + self._show_lines(result_lines(result, self._t)) + self._update_action_buttons() + + def _show_lines(self, lines: list[str]) -> None: + self._preview.configure(state="normal") + self._preview.delete("1.0", tk.END) + self._preview.insert(tk.END, "\n".join(lines) if lines else self._t("builder.nothing_built")) + self._preview.configure(state="disabled") + + def _update_action_buttons(self) -> None: + result = self._last_result + has_text = bool(result and result.bar_text) + has_url = bool(result and result.url) + self._copy_text_btn.state(["!disabled"] if has_text else ["disabled"]) + self._copy_link_btn.state(["!disabled"] if has_url else ["disabled"]) + self._open_link_btn.state(["!disabled"] if has_url else ["disabled"]) + self._keep_btn.state(["!disabled"] if (has_text or has_url) else ["disabled"]) + + # ── actions (the only writes, and the only outward call) ───────────── + + def _copy_text(self) -> None: + if not (self._last_result and self._last_result.bar_text): + self._announce("builder.status.no_result") + return + self.clipboard_clear() + self.clipboard_append(self._last_result.bar_text) + self._remember_last() + self._announce("builder.status.copied_text") + + def _copy_link(self) -> None: + if not (self._last_result and self._last_result.url): + self._announce("builder.status.no_result") + return + self.clipboard_clear() + self.clipboard_append(self._last_result.url) + self._remember_last() + self._announce("builder.status.copied_link") + + def _open_link(self) -> None: + if not (self._last_result and self._last_result.url): + self._announce("builder.status.no_result") + return + webbrowser.open(self._last_result.url) + self._remember_last() + self._announce("builder.status.opened") + + def _keep(self) -> None: + if not (self._last_result and (self._last_result.bar_text or self._last_result.url)): + self._announce("builder.status.no_result") + return + self._remember_last() + self._announce("builder.status.kept") + + def _remember_last(self) -> None: + if self._last_request is None: + return + resolve_and_remember( + self._builder, + self._last_request, + platform=self._last_platform, + company_url=self._last_company_url, + ) + self._refresh_recent() + + # ── recent ──────────────────────────────────────────────────────────── + + def _refresh_recent(self) -> None: + recent = tuple(self._builder.recent_queries()) + self._recent = recent + lines = recent_choices(recent) + self._recent_box.configure(values=lines) + self._recent_var.set( + self._t("builder.recent_placeholder") if lines else self._t("builder.recent_empty_label") + ) + + def _on_recent_selected(self, _event=None) -> None: + idx = self._recent_box.current() + if not (0 <= idx < len(self._recent)): + return + request = self._recent[idx] + if request.surface not in self._engine_names: + self._announce("builder.status.recent_unavailable") + return + try: + result = self._builder.build_query(request) + except Exception as exc: # noqa: BLE001 + self._show_lines([f"{self._t('builder.error')}: {exc}"]) + return + self._last_result = result + self._last_request = request + self._last_platform = None + self._last_company_url = None + self._show_lines(result_lines(result, self._t)) + self._update_action_buttons() + self._announce("builder.status.recent_loaded") + + def _clear_recent(self) -> None: + self._builder.clear_recent_queries() + self._refresh_recent() + self._announce("builder.status.cleared") + + # ── status / close ──────────────────────────────────────────────────── + + def _announce(self, key: str) -> None: + self._status_var.set(self._t(key)) + + def _close(self) -> None: + self.destroy() 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 7e66776..6879b78 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 @@ -102,4 +102,45 @@ def get_strings(lang_code: str | None = None) -> dict[str, str]: "settings.stealth_driver_note", "Chrome only, needs undetected-chromedriver. Does not change pacing — pacing comes from the behaviour profile above.", ), + + # --- Query builder window (File → Build a Search…) --- + "builder.window_title": _tr("builder.window_title", "Build a Search"), + "builder.engine_label": _tr("builder.engine_label", "Search engine:"), + "builder.target_label": _tr("builder.target_label", "Search within:"), + "builder.target.surface": _tr("builder.target.surface", "Everything it finds"), + "builder.target.platform": _tr("builder.target.platform", "A hiring platform"), + "builder.target.company": _tr("builder.target.company", "One company"), + "builder.platform_label": _tr("builder.platform_label", "Platform:"), + "builder.company_label": _tr("builder.company_label", "Company careers page link:"), + "builder.terms_label": _tr("builder.terms_label", "Search words:"), + "builder.location_label": _tr("builder.location_label", "Location:"), + "builder.any_of_label": _tr("builder.any_of_label", "Any of these:"), + "builder.exclude_label": _tr("builder.exclude_label", "Without:"), + "builder.in_title_label": _tr("builder.in_title_label", "In the title:"), + "builder.defaults_label": _tr("builder.defaults_label", "From your profile:"), + "builder.pick_one": _tr("builder.pick_one", "Pick one…"), + "builder.options_label": _tr("builder.options_label", "Options:"), + "builder.option_not_set": _tr("builder.option_not_set", "(not set)"), + "builder.preview_label": _tr("builder.preview_label", "Result:"), + "builder.recent_label": _tr("builder.recent_label", "Recent searches:"), + "builder.recent_placeholder": _tr("builder.recent_placeholder", "Choose one to reuse…"), + "builder.recent_empty_label": _tr("builder.recent_empty_label", "No recent searches yet"), + "builder.copy_text": _tr("builder.copy_text", "Copy search text"), + "builder.copy_link": _tr("builder.copy_link", "Copy link"), + "builder.open_link": _tr("builder.open_link", "Open link"), + "builder.keep": _tr("builder.keep", "Keep"), + "builder.clear_recent": _tr("builder.clear_recent", "Clear recent"), + "builder.close": _tr("builder.close", "Close"), + "builder.error": _tr("builder.error", "Could not build that search"), + "builder.nothing_built": _tr("builder.nothing_built", "Nothing to build yet — add at least one search word."), + "builder.status.no_result": _tr("builder.status.no_result", "Build a search first."), + "builder.status.copied_text": _tr("builder.status.copied_text", "Search text copied and kept."), + "builder.status.copied_link": _tr("builder.status.copied_link", "Link copied and kept."), + "builder.status.opened": _tr("builder.status.opened", "Link opened in your browser; the search was kept."), + "builder.status.kept": _tr("builder.status.kept", "Search kept."), + "builder.status.cleared": _tr("builder.status.cleared", "Recent searches cleared."), + "builder.status.recent_loaded": _tr("builder.status.recent_loaded", "Showing a remembered search. Edit the fields to build a new one."), + "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"), } \ No newline at end of file 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 5bd5720..000697d 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 @@ -55,6 +55,7 @@ QueryRequest, QueryResult, SurfaceOffer, + resolved_remember_request, ) from auto_apply.domain.ports.ats_port import ATSDescriptor, ATSRegistryPort from auto_apply.domain.models.search_surface import ( @@ -97,7 +98,7 @@ def __init__( # Offered surfaces — the verification rule applied # ------------------------------------------------------------------ - def surfaces(self) -> tuple[SurfaceOffer, ...]: + def query_surfaces(self) -> tuple[SurfaceOffer, ...]: """Every catalogued surface, with unverified capabilities removed. A catalog defect propagates (SearchSurfaceError) — a wrong offer is @@ -150,7 +151,7 @@ def _offer(spec: SearchSurfaceSpec) -> SurfaceOffer: # Profile defaults — strictly read-only # ------------------------------------------------------------------ - def defaults(self) -> BuilderDefaults: + def query_defaults(self) -> BuilderDefaults: """The profile's saved search values, offered as one-click choices.""" prefs = getattr(self._profile, "search_preferences", None) return BuilderDefaults( @@ -163,7 +164,7 @@ def defaults(self) -> BuilderDefaults: # Build (writes nothing) and remember (explicit) # ------------------------------------------------------------------ - def build(self, request: QueryRequest) -> QueryResult: + def build_query(self, request: QueryRequest) -> QueryResult: """Compose the bar text and URL. Writes NOTHING. The surfaces may rebuild the preview as the user types, so building @@ -193,7 +194,7 @@ def platforms(self) -> tuple[ATSDescriptor, ...]: return () return tuple(d for d in self._ats_registry.all_descriptors() if d.hosts) - def platform_offers(self) -> tuple[PlatformOffer, ...]: + def query_platforms(self) -> tuple[PlatformOffer, ...]: """Every targetable platform with its verification label. The label is computed here — once, from the shared locale strings — @@ -246,7 +247,28 @@ def platform_restriction_for( ) return self._descriptor_for(platform).search_restriction_for() - def build_platform_search( + def resolve_query_target( + self, + request: QueryRequest, + *, + platform: str | None = None, + company_url: str | None = None, + ) -> QueryRequest: + """The request as it should be remembered: a platform or company + target folded to its resolved site: restriction, so a remembered + query replays identically as an engine search. Plain engine queries + are returned unchanged.""" + if platform: + return resolved_remember_request( + request, self.platform_restriction_for(platform) + ) + if company_url: + return resolved_remember_request( + request, self.platform_restriction_for(url=company_url) + ) + return request + + def build_platform_query( self, platform: str, request: QueryRequest ) -> QueryResult: """A domain-restricted engine search for one platform. @@ -261,9 +283,9 @@ def build_platform_search( restricted = request.model_copy( update={"site": self.platform_restriction_for(platform)} ) - return self.build(restricted) + return self.build_query(restricted) - def build_company_search(self, url: str, request: QueryRequest) -> QueryResult: + def build_company_query(self, url: str, request: QueryRequest) -> QueryResult: """A company-scoped engine search from a pasted tenant URL. The URL's platform is matched and its tenant slug narrows the @@ -300,7 +322,7 @@ def build_company_search(self, url: str, request: QueryRequest) -> QueryResult: restricted = request.model_copy( update={"site": self.platform_restriction_for(url=url)} ) - return self.build(restricted) + return self.build_query(restricted) def _descriptor_for(self, platform: str) -> ATSDescriptor: """One descriptor by name, or a loud error — a quiet empty result @@ -314,7 +336,7 @@ def _descriptor_for(self, platform: str) -> ATSDescriptor: return descriptor raise QueryBuilderError(f"unknown platform {platform!r}") - def remember(self, request: QueryRequest) -> None: + def remember_query(self, request: QueryRequest) -> None: """Record a query as one of the recent few. Called by the surfaces only when the user copies or opens a result — @@ -326,7 +348,7 @@ def remember(self, request: QueryRequest) -> None: # Recent queries # ------------------------------------------------------------------ - def recent(self) -> tuple[QueryRequest, ...]: + def recent_queries(self) -> tuple[QueryRequest, ...]: """The remembered queries, newest first.""" out: list[QueryRequest] = [] for item in self._read_recent(): @@ -336,7 +358,7 @@ def recent(self) -> tuple[QueryRequest, ...]: continue return tuple(out) - def clear_recent(self) -> None: + def clear_recent_queries(self) -> None: """Forget every remembered query. The file is removed, not emptied.""" try: self._recent_path.unlink(missing_ok=True) 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 152fc79..d674bf3 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 @@ -1711,46 +1711,61 @@ def query_builder(self) -> "QueryBuilderService | None": def query_surfaces(self) -> tuple[SurfaceOffer, ...]: """Every search surface the builder may offer, verification applied.""" builder = self._query_builder - return builder.surfaces() if builder is not None else () + return builder.query_surfaces() if builder is not None else () def query_platforms(self) -> tuple[PlatformOffer, ...]: """Every targetable hiring platform, with its verification label.""" builder = self._query_builder - return builder.platform_offers() if builder is not None else () + return builder.query_platforms() if builder is not None else () def query_defaults(self) -> BuilderDefaults: """The profile's saved search values as one-click choices.""" builder = self._query_builder - return builder.defaults() if builder is not None else BuilderDefaults() + return builder.query_defaults() if builder is not None else BuilderDefaults() def build_query(self, request: QueryRequest) -> QueryResult: """Compose bar text and/or a URL for one surface.""" - return self._require_builder().build(request) + return self._require_builder().build_query(request) def build_platform_query(self, platform: str, request: QueryRequest) -> QueryResult: """A domain-restricted engine search for one hiring platform.""" - return self._require_builder().build_platform_search(platform, request) + return self._require_builder().build_platform_query(platform, request) def build_company_query(self, url: str, request: QueryRequest) -> QueryResult: """A company-scoped engine search from a pasted tenant URL.""" - return self._require_builder().build_company_search(url, request) + return self._require_builder().build_company_query(url, request) + + def resolve_query_target( + self, + request: QueryRequest, + *, + platform: str | None = None, + company_url: str | None = None, + ) -> QueryRequest: + """The request as it should be remembered: a platform or company + target folded to its resolved site: restriction, so a remembered + query replays identically as an engine search. Plain engine queries + are returned unchanged.""" + return self._require_builder().resolve_query_target( + request, platform=platform, company_url=company_url + ) def remember_query(self, request: QueryRequest) -> None: """Record a built query as one of the recent few (explicit only).""" builder = self._query_builder if builder is not None: - builder.remember(request) + builder.remember_query(request) def recent_queries(self) -> tuple[QueryRequest, ...]: """The remembered queries, newest first.""" builder = self._query_builder - return builder.recent() if builder is not None else () + return builder.recent_queries() if builder is not None else () def clear_recent_queries(self) -> None: """Forget every remembered query.""" builder = self._query_builder if builder is not None: - builder.clear_recent() + builder.clear_recent_queries() def _require_builder(self) -> "QueryBuilderService": """The builder or a clear error — a build with no builder is a bug.""" 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 b341257..2e5db74 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 @@ -199,3 +199,25 @@ def resolved_remember_request( if not restriction: return request return request.model_copy(update={"site": restriction}) + + +def resolve_and_remember( + builder, + request: QueryRequest, + *, + platform: str | None = None, + company_url: str | None = None, +) -> QueryRequest: + """Resolve a target, then remember the resolved query — the ONE pairing. + + Every surface (CLI screen, GUI window) remembers through this helper so a + query kept on one surface replays identically on the other. ``builder`` is + any object exposing the UI port's ``resolve_query_target`` and + ``remember_query`` (the in-session controller, or the pre-session + service). Returns the request that was remembered, for display. + """ + resolved = builder.resolve_query_target( + request, platform=platform, company_url=company_url + ) + builder.remember_query(resolved) + return resolved 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 b336115..42dc11c 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: 26 methods. +Whole surface, deliberately: 27 methods. ----------------------------------------- AA's idiom prefers narrow ports, but this port describes a whole surface, not a capability, and the ProfileRepositoryPort precedent applies. The 26 @@ -36,10 +36,10 @@ (recent_events), plus three custody methods added when the results surface landed (export_session_results, export_profile, list_session_history), plus the two autonomy methods added at stage E1 (autonomy, set_autonomy), -plus the nine query-builder methods (query_surfaces, query_platforms, +plus the ten query-builder methods (query_surfaces, query_platforms, query_defaults, build_query, build_platform_query, build_company_query, -remember_query, recent_queries, clear_recent_queries), added when the -builder's surfaces landed. +resolve_query_target, remember_query, recent_queries, clear_recent_queries) — +the builder's surfaces plus the resolve-then-remember step. tests/domain/test_ui_contract.py and tests/application/test_ui_port_conformance.py pin this exact set, so growth is mechanical, not accidental. If the consumers only ever take halves, splitting then is cheap — the types do @@ -324,6 +324,19 @@ def build_company_query(self, url: str, request: QueryRequest) -> QueryResult: """A company-scoped engine search from a pasted tenant URL.""" ... + def resolve_query_target( + self, + request: QueryRequest, + *, + platform: str | None = None, + company_url: str | None = None, + ) -> QueryRequest: + """The request as it should be remembered: a platform or company + target folded to its resolved site: restriction, so a remembered + query replays identically as an engine search. Plain engine queries + are returned unchanged.""" + ... + def remember_query(self, request: QueryRequest) -> None: """Record a built query as one of the recent few (explicit only).""" ... 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 d27ef3a..a19a343 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 @@ -247,6 +247,15 @@ def _compose_url( for phrase in request.phrases: content = _clean_content(phrase) bits.append(f"{quote}{content}{quote}" if "phrase" in ops else content) + if request.phrases and "phrase" not in ops: + # Exact-phrase quoting could not be verified for this surface, so + # the words went in bare. Say so: unquoted terms can match more + # than the phrase, and silence here would misreport the search. + notes.append(_info( + "phrase", + f"{spec.display_name} has no exact-phrase quoting AA can " + "verify, so your words went into the link without quotes.", + )) bits.extend(normalize_confusables(t) for t in request.required) # Every restriction a surface cannot carry stops the link in # compose() (refusal-both), so nothing is ever dropped here. @@ -272,6 +281,21 @@ def _compose_url( if value != "" ] + # A location the surface has no field for is dropped by the template, so + # say so. A locationless search is broader, not different — that is a + # note, not a refusal. Automation never reaches this note (it reads the + # template directly), so automated query bytes cannot change. + if request.location and not any( + p.source == "location" for p in template.parameters + ): + notes.append(_info( + "location", + f"{spec.display_name} has no place for a location, so " + f"{request.location!r} was left out of the search. " + "Add the place to your search words, or pick a surface that " + "takes a location.", + )) + for key, value in request.options: opt = offered_options.get(key) if opt is None: 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 268a492..0b2b391 100644 --- a/packages/auto_apply/src/auto_apply/resources/locales/en.json +++ b/packages/auto_apply/src/auto_apply/resources/locales/en.json @@ -215,6 +215,45 @@ "platform": { "unverified": "Unverified: the search mechanism is documented, but how much of this platform it reaches is not yet known. Open the link yourself and check that results come from the platform; the user guide shows how to report the outcome.", "third_party": "Reported by others; AA has not checked it." + }, + "window_title": "Build a Search", + "engine_label": "Search engine:", + "target_label": "Search within:", + "target": { + "surface": "Everything it finds", + "platform": "A hiring platform", + "company": "One company" + }, + "platform_label": "Platform:", + "company_label": "Company careers page link:", + "terms_label": "Search words:", + "location_label": "Location:", + "any_of_label": "Any of these:", + "exclude_label": "Without:", + "in_title_label": "In the title:", + "defaults_label": "From your profile:", + "pick_one": "Pick one…", + "options_label": "Options:", + "option_not_set": "(not set)", + "preview_label": "Result:", + "recent_label": "Recent searches:", + "recent_placeholder": "Choose one to reuse…", + "recent_empty_label": "No recent searches yet", + "copy_text": "Copy search text", + "copy_link": "Copy link", + "open_link": "Open link", + "keep": "Keep", + "clear_recent": "Clear recent", + "close": "Close", + "status": { + "no_result": "Build a search first.", + "copied_text": "Search text copied and kept.", + "copied_link": "Link copied and kept.", + "opened": "Link opened in your browser; the search was kept.", + "kept": "Search kept.", + "cleared": "Recent searches cleared.", + "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." } } } \ 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 73a20fd..c338945 100644 --- a/packages/auto_apply/src/auto_apply/resources/locales/es.json +++ b/packages/auto_apply/src/auto_apply/resources/locales/es.json @@ -177,6 +177,45 @@ "platform": { "unverified": "Sin verificar: el mecanismo de búsqueda está documentado, pero aún no se sabe qué parte de esta plataforma alcanza. Abra el enlace usted mismo y compruebe que los resultados provienen de la plataforma; la guía del usuario muestra cómo informar del resultado.", "third_party": "Reportado por otros; AA no lo ha comprobado." + }, + "window_title": "Crear una búsqueda", + "engine_label": "Buscador:", + "target_label": "Buscar en:", + "target": { + "surface": "Todo lo que encuentre", + "platform": "Una plataforma de contratación", + "company": "Una sola empresa" + }, + "platform_label": "Plataforma:", + "company_label": "Enlace a la página de empleo de la empresa:", + "terms_label": "Palabras de búsqueda:", + "location_label": "Ubicación:", + "any_of_label": "Cualquiera de estas:", + "exclude_label": "Sin:", + "in_title_label": "En el título:", + "defaults_label": "De su perfil:", + "pick_one": "Elija una…", + "options_label": "Opciones:", + "option_not_set": "(sin definir)", + "preview_label": "Resultado:", + "recent_label": "Búsquedas recientes:", + "recent_placeholder": "Elija una para reutilizarla…", + "recent_empty_label": "Aún no hay búsquedas recientes", + "copy_text": "Copiar texto de búsqueda", + "copy_link": "Copiar enlace", + "open_link": "Abrir enlace", + "keep": "Guardar", + "clear_recent": "Borrar recientes", + "close": "Cerrar", + "status": { + "no_result": "Primero cree una búsqueda.", + "copied_text": "Texto de búsqueda copiado y guardado.", + "copied_link": "Enlace copiado y guardado.", + "opened": "Enlace abierto en su navegador; la búsqueda se guardó.", + "kept": "Búsqueda guardada.", + "cleared": "Búsquedas recientes borradas.", + "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í." } } } \ No newline at end of file diff --git a/packages/auto_apply/tests/adapters/test_gui_search_window.py b/packages/auto_apply/tests/adapters/test_gui_search_window.py new file mode 100644 index 0000000..da16f8c --- /dev/null +++ b/packages/auto_apply/tests/adapters/test_gui_search_window.py @@ -0,0 +1,162 @@ +"""Pins for the GUI query-builder window and its parity with the CLI screen. + +Every pin exercises the widget-free helpers in gui/search_window.py or the +service/port/controller vocabulary; none constructs Tk (the +test_gui_research_window.py precedent). The module under test imports tkinter, +which is safe on the CI baseline — the window itself is never instantiated. + +Labels are honest: the window did not exist before this change, so the GUI +pins fail against the old tree by import error (coverage of a new surface). +The vocabulary-alignment and parity pins are TEETH for the rulings this change +makes — they fail if the two surfaces drift into two vocabularies, or if a +remembered platform query stops replaying identically. +""" + +from __future__ import annotations + +import ast +from pathlib import Path +from unittest.mock import MagicMock + +from auto_apply.adapters.primary.cli.search_builder_screen import ( + _build as cli_build, + _remember as cli_remember, +) +from auto_apply.adapters.primary.gui import search_window as gw +from auto_apply.adapters.secondary.discovery.ats_registry import ATSRegistry +from auto_apply.adapters.secondary.discovery.search_surface_registry import ( + list_surface_names, + load_surface, +) +from auto_apply.application.services.query_builder import QueryBuilderService +from auto_apply.domain.models.search_query import build_request, resolve_and_remember + +_PORT_BUILDER_METHODS = ( + "query_surfaces", + "query_platforms", + "query_defaults", + "build_query", + "build_platform_query", + "build_company_query", + "resolve_query_target", + "remember_query", + "recent_queries", + "clear_recent_queries", +) + + +def _service(recent_path: Path) -> QueryBuilderService: + """A real builder service over a throwaway recent file.""" + return QueryBuilderService( + profile=None, + load_spec=load_surface, + list_names=list_surface_names, + ats_registry=ATSRegistry(), + recent_path=recent_path, + ) + + +def test_service_port_and_controller_share_the_builder_vocabulary(): + """TEETH: the two surfaces must not drift into two vocabularies. + + The pre-session service, the port, and the controller all expose the + identical builder method names. Mutation caught: renaming one side, or a + port method the service does not implement.""" + from auto_apply.application.services.session_controller import SessionController + from auto_apply.domain.ports.ui_port import UIPort + + for name in _PORT_BUILDER_METHODS: + assert callable(getattr(QueryBuilderService, name, None)), f"service missing {name}" + assert callable(getattr(UIPort, name, None)), f"port missing {name}" + assert callable(getattr(SessionController, name, None)), f"controller missing {name}" + + +def test_gui_and_cli_build_identically(tmp_path): + """TEETH (parity): the same platform search built through the GUI helper + and through the CLI's build path produces the identical result.""" + service = _service(tmp_path / "recent.json") + request = build_request(surface="google", terms="Software Engineer") + gui_result = gw.build_for_target(service, request, platform="jobvite") + cli_result = cli_build(service, request, "jobvite", None) + assert gui_result == cli_result + assert gui_result.bar_text == 'site:jobs.jobvite.com ("Software Engineer")' + + +def test_remembered_platform_query_replays_identically_across_surfaces(tmp_path): + """TEETH (ruling 2): a platform query remembered from the GUI helper and + one remembered from the CLI replay identically, resolved.""" + gui_service = _service(tmp_path / "gui_recent.json") + cli_service = _service(tmp_path / "cli_recent.json") + request = build_request(surface="google", terms="Software Engineer") + resolve_and_remember(gui_service, request, platform="jobvite") + cli_remember(cli_service, request, "jobvite", None) + gui_recent = gui_service.recent_queries() + cli_recent = cli_service.recent_queries() + assert gui_recent == cli_recent + assert gui_recent[0].site == "jobs.jobvite.com" + assert ( + gui_service.build_query(gui_recent[0]).bar_text + == cli_service.build_query(cli_recent[0]).bar_text + ) + + +def test_preview_never_remembers_and_explicit_actions_do(): + """TEETH: building a preview calls no write; an explicit action remembers + exactly once, resolved. Mutation caught: remember on build, or a copy + forgetting.""" + builder = MagicMock() + request = build_request(surface="google", terms="x") + gw.build_for_target(builder, request) + builder.remember_query.assert_not_called() + resolve_and_remember(builder, request) + builder.resolve_query_target.assert_called_once() + builder.remember_query.assert_called_once() + + +def test_offered_input_fields_hide_unsupported_operators(tmp_path): + """TEETH: LinkedIn offers no any_of/exclude/intitle, so the GUI hides those + fields; Google offers all three. Mutation caught: showing a field the + surface cannot express, which would silently drop it on compose.""" + service = _service(tmp_path / "recent.json") + offers = {o.name: o for o in service.query_surfaces()} + google_fields = gw.offered_input_fields(offers["google"]) + assert {"any_of", "exclude", "intitle"} <= google_fields + linkedin_fields = gw.offered_input_fields(offers["linkedin"]) + assert not ({"any_of", "exclude", "intitle"} & linkedin_fields) + + +def test_result_lines_render_refusals_and_never_blank(tmp_path): + """TEETH: a refusal result renders its note lines, never a blank result.""" + service = _service(tmp_path / "recent.json") + result = service.build_platform_query( + "workday", build_request(surface="linkedin", terms="x") + ) + lines = gw.result_lines(result, lambda key: key) + assert lines + assert any("site" in line.lower() for line in lines) + + +def test_window_is_offline_except_explicit_open(): + """GUARD (source): the window imports no network/browser library; the only + outward call is webbrowser.open. Mutation caught: an added network import.""" + source = Path(gw.__file__).read_text(encoding="utf-8") + banned = ("socket", "requests", "httpx", "selenium", "playwright", "urllib") + for node in ast.walk(ast.parse(source)): + 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 + assert "webbrowser.open" in source + + +def test_window_announces_and_closes_accessibly(): + """GUARD (source): Escape and the close button decline; outcomes are + announced as text in a status label; the preview takes keyboard focus. + What this pin CANNOT check headless: real Tab traversal and screen-reader + behaviour — said plainly, mirroring the lifecycle window.""" + source = Path(gw.__file__).read_text(encoding="utf-8") + assert '""' in source + assert "WM_DELETE_WINDOW" in source + assert "takefocus=True" in source + assert "_status_var.set" in source diff --git a/packages/auto_apply/tests/adapters/test_search_builder_cli.py b/packages/auto_apply/tests/adapters/test_search_builder_cli.py index a584831..bd671a3 100644 --- a/packages/auto_apply/tests/adapters/test_search_builder_cli.py +++ b/packages/auto_apply/tests/adapters/test_search_builder_cli.py @@ -214,7 +214,7 @@ def test_platform_offers_labels_unverified_only__teeth(tmp_path) -> None: """TEETH: unverified platforms carry the shared label; measured and vendor-verified carry none. Mutation caught: badges inverted or absent. Label text is compared against the locale lookup, not hardcoded.""" - offers = {o.name: o for o in _service(tmp_path).platform_offers()} + offers = {o.name: o for o in _service(tmp_path).query_platforms()} assert offers["workday"].status == "unverified" assert offers["workday"].label == get_text("builder.platform.unverified") assert offers["workday"].label @@ -225,7 +225,7 @@ def test_platform_offers_labels_unverified_only__teeth(tmp_path) -> None: def test_platform_offers_covers_the_registry__teeth(tmp_path) -> None: """TEETH: every registered platform with a host is offered — no second list. Mutation caught: a filtered or hardcoded subset.""" - names = {o.name for o in _service(tmp_path).platform_offers()} + names = {o.name for o in _service(tmp_path).query_platforms()} expected = {d.name for d in ATSRegistry().all_descriptors() if d.hosts} assert names == expected assert len(names) >= 30 @@ -271,23 +271,27 @@ def test_controller_delegates_the_builder_methods__teeth() -> None: controller = _controller(service) request = build_request(surface="google", terms="x") - assert controller.query_surfaces() is service.surfaces.return_value - assert controller.query_platforms() is service.platform_offers.return_value - assert controller.query_defaults() is service.defaults.return_value - assert controller.build_query(request) is service.build.return_value + assert controller.query_surfaces() is service.query_surfaces.return_value + assert controller.query_platforms() is service.query_platforms.return_value + assert controller.query_defaults() is service.query_defaults.return_value + assert controller.build_query(request) is service.build_query.return_value assert ( controller.build_platform_query("workday", request) - is service.build_platform_search.return_value + is service.build_platform_query.return_value ) assert ( controller.build_company_query("https://x", request) - is service.build_company_search.return_value + is service.build_company_query.return_value + ) + assert ( + controller.resolve_query_target(request, platform="workday") + is service.resolve_query_target.return_value ) controller.remember_query(request) - service.remember.assert_called_once_with(request) - assert controller.recent_queries() is service.recent.return_value + service.remember_query.assert_called_once_with(request) + assert controller.recent_queries() is service.recent_queries.return_value controller.clear_recent_queries() - service.clear_recent.assert_called_once_with() + service.clear_recent_queries.assert_called_once_with() def test_controller_without_a_builder_defaults_and_raises__teeth() -> None: @@ -307,6 +311,8 @@ def test_controller_without_a_builder_defaults_and_raises__teeth() -> None: controller.build_platform_query("workday", request) with pytest.raises(QueryBuilderError): controller.build_company_query("https://x", request) + with pytest.raises(QueryBuilderError): + controller.resolve_query_target(request, platform="workday") # ── Scripted CLI ───────────────────────────────────────────────────────────── @@ -381,16 +387,16 @@ def test_keep_remembers_resolved_and_replays_identically__teeth(tmp_path) -> Non _args(search_platform="jobvite", search_terms="Software Engineer"), builder=service, ) == 0 - assert service.recent() == (), "a build with no action remembered something" + assert service.recent_queries() == (), "a build with no action remembered something" assert screen.run_search( _args(search_platform="jobvite", search_terms="Software Engineer", search_keep=True), builder=service, ) == 0 - remembered = service.recent() + remembered = service.recent_queries() assert len(remembered) == 1 assert remembered[0].site == "jobs.jobvite.com" - replayed = service.build(remembered[0]) + replayed = service.build_query(remembered[0]) assert replayed.bar_text == 'site:jobs.jobvite.com ("Software Engineer")' assert screen.run_search( @@ -401,11 +407,11 @@ def test_keep_remembers_resolved_and_replays_identically__teeth(tmp_path) -> Non ), builder=service, ) == 0 - remembered = service.recent() + remembered = service.recent_queries() assert len(remembered) == 2 company_entry = remembered[0] # newest first assert company_entry.site == "holisticai.bamboohr.com/careers" - assert service.build(company_entry).bar_text == 'site:holisticai.bamboohr.com/careers ("x")' + assert service.build_query(company_entry).bar_text == 'site:holisticai.bamboohr.com/careers ("x")' def test_recent_then_clear__teeth(tmp_path, capsys) -> None: @@ -421,7 +427,7 @@ def test_recent_then_clear__teeth(tmp_path, capsys) -> None: out = capsys.readouterr().out assert "jobs.jobvite.com" in out assert screen.run_search(_args(search_clear_recent=True), builder=service) == 0 - assert service.recent() == () + assert service.recent_queries() == () # ── Interactive CLI ────────────────────────────────────────────────────────── @@ -447,13 +453,13 @@ def test_preview_never_remembers_and_copy_does__teeth(tmp_path, monkeypatch) -> "google", "1", "x", "", "", "", "", "q", ])) assert screen.run_search(_args(search=True), builder=service) == 0 - assert service.recent() == () + assert service.recent_queries() == () monkeypatch.setattr("builtins.input", _Script([ "google", "1", "x", "", "", "", "", "c", "q", ])) assert screen.run_search(_args(search=True), builder=service) == 0 - remembered = service.recent() + remembered = service.recent_queries() assert len(remembered) == 1 assert remembered[0].phrases == ("x",) assert remembered[0].site is None @@ -465,7 +471,7 @@ def test_interactive_quit_is_clean__teeth(tmp_path, monkeypatch) -> None: service = _service(tmp_path) monkeypatch.setattr("builtins.input", _Script(["q"])) assert screen.run_search(_args(search=True), builder=service) == 0 - assert service.recent() == () + assert service.recent_queries() == () # ── Static guarantees ──────────────────────────────────────────────────────── diff --git a/packages/auto_apply/tests/application/test_query_builder_platforms.py b/packages/auto_apply/tests/application/test_query_builder_platforms.py index 1b7fd14..444527c 100644 --- a/packages/auto_apply/tests/application/test_query_builder_platforms.py +++ b/packages/auto_apply/tests/application/test_query_builder_platforms.py @@ -46,7 +46,7 @@ def test_nicks_three_queries_compose_exactly_through_platform_data(platform, exp """TEETH — the platform data did not exist. Mutation caught: a restriction differing from Nick's measured/reported strings by one character, or the composer reformatting them.""" - result = _service().build_platform_search( + result = _service().build_platform_query( platform, QueryRequest(surface="google", phrases=("Software Engineer",)) ) assert result.bar_text == expected @@ -56,7 +56,7 @@ def test_nicks_three_queries_compose_exactly_through_platform_data(platform, exp def test_a_domain_restricted_bing_search_uses_web_results(): """TEETH — Bing defaulted to the jobs vertical. Mutation caught: the site template selection ignored or removed.""" - result = _service().build_platform_search( + result = _service().build_platform_query( "workday", QueryRequest(surface="bing", phrases=("Software Engineer",)) ) assert result.url is not None @@ -67,7 +67,7 @@ def test_a_domain_restricted_bing_search_uses_web_results(): def test_a_restriction_a_surface_cannot_express_refuses_the_link_too(): """TEETH — the link used to survive with the restriction silently dropped. Mutation caught: hard_stop ignored for content refusals.""" - result = _service().build_platform_search( + result = _service().build_platform_query( "workday", QueryRequest(surface="indeed", phrases=("x",)) ) assert result.bar_text is None @@ -78,7 +78,7 @@ def test_a_restriction_a_surface_cannot_express_refuses_the_link_too(): def test_a_platform_search_on_a_surface_without_site_refuses_cleanly(): """TEETH. Mutation caught: LinkedIn composing anything from a site restriction.""" - result = _service().build_platform_search( + result = _service().build_platform_query( "greenhouse", QueryRequest(surface="linkedin", phrases=("x",)) ) assert result.bar_text is None @@ -88,7 +88,7 @@ def test_a_platform_search_on_a_surface_without_site_refuses_cleanly(): def test_an_indeed_search_without_a_location_has_no_empty_parameter(): """TEETH — an empty l= used to be emitted. Mutation caught: the empty-parameter filter removed.""" - result = _service().build(QueryRequest(surface="indeed", phrases=("x",))) + result = _service().build_query(QueryRequest(surface="indeed", phrases=("x",))) assert result.url is not None assert "l=" not in result.url @@ -113,7 +113,7 @@ def test_an_indeed_search_without_a_location_has_no_empty_parameter(): def test_a_company_url_scopes_the_search_to_the_tenant(url, expected_bar): """TEETH — one subdomain, one path, one opaque tenancy. Mutation caught: the slug ignored, or the shared path prefix dropped.""" - result = _service().build_company_search( + result = _service().build_company_query( url, QueryRequest(surface="google", phrases=("x",)) ) assert result.bar_text == expected_bar @@ -121,7 +121,7 @@ def test_a_company_url_scopes_the_search_to_the_tenant(url, expected_bar): def test_a_company_url_on_no_known_platform_refuses_without_a_guess(): """TEETH. Mutation caught: composing a restriction from the raw host.""" - result = _service().build_company_search( + result = _service().build_company_query( "https://example.org/jobs/1", QueryRequest(surface="google", phrases=("x",)) ) assert result.bar_text is None @@ -149,7 +149,7 @@ def test_a_builder_without_a_registry_refuses_platform_searches_loudly(): ) assert service.platforms() == () with pytest.raises(QueryBuilderError): - service.build_platform_search("jobvite", QueryRequest(surface="google", phrases=("x",))) + service.build_platform_query("jobvite", QueryRequest(surface="google", phrases=("x",))) def test_the_builder_modules_do_not_import_the_registry_adapter(): diff --git a/packages/auto_apply/tests/application/test_query_builder_service.py b/packages/auto_apply/tests/application/test_query_builder_service.py index fec4ec3..4b8b83c 100644 --- a/packages/auto_apply/tests/application/test_query_builder_service.py +++ b/packages/auto_apply/tests/application/test_query_builder_service.py @@ -40,12 +40,12 @@ def _service(tmp_path: Path, profile=None) -> QueryBuilderService: def test_defaults_offer_the_profiles_saved_titles(tmp_path, minimal_valid_profile): """GUARD. Mutation caught: reading a different field, or none.""" service = _service(tmp_path, profile=minimal_valid_profile) - assert service.defaults().titles == ("Software Engineer",) + assert service.query_defaults().titles == ("Software Engineer",) def test_defaults_are_empty_without_a_profile(tmp_path): """GUARD. Mutation caught: inventing defaults.""" - assert _service(tmp_path).defaults().titles == () + assert _service(tmp_path).query_defaults().titles == () def test_building_a_query_never_writes_to_the_profile(tmp_path, minimal_valid_profile): @@ -53,14 +53,14 @@ def test_building_a_query_never_writes_to_the_profile(tmp_path, minimal_valid_pr caught: any mutation of the profile during build.""" service = _service(tmp_path, profile=minimal_valid_profile) before = minimal_valid_profile.model_dump_json() - service.build(QueryRequest(surface="google", phrases=("x",))) + service.build_query(QueryRequest(surface="google", phrases=("x",))) assert minimal_valid_profile.model_dump_json() == before def test_unverified_and_uncomposable_capabilities_are_never_offered(tmp_path): """GUARD (the verification rule). Mutation caught: offering link:/cache:/ inanchor:/udm, which the catalog records as unverified.""" - offers = {o.name: o for o in _service(tmp_path).surfaces()} + offers = {o.name: o for o in _service(tmp_path).query_surfaces()} google = offers["google"] operator_keys = {c.key for c in google.operators} assert {"site", "phrase", "exclude", "any_of", "intitle"} <= operator_keys @@ -77,9 +77,9 @@ def test_build_leaves_the_recent_file_untouched(tmp_path): surfaces may rebuild a preview on every keystroke, so recording on build would store half-typed queries. Mutation caught: build() calling _record_recent.""" service = _service(tmp_path) - service.build(QueryRequest(surface="google", phrases=("x",))) - service.build(QueryRequest(surface="google", phrases=("y",))) - assert service.recent() == () + service.build_query(QueryRequest(surface="google", phrases=("x",))) + service.build_query(QueryRequest(surface="google", phrases=("y",))) + assert service.recent_queries() == () assert not (tmp_path / "query_builder_recent.json").exists() @@ -87,8 +87,8 @@ def test_remember_caps_at_five(tmp_path): """GUARD. Mutation caught: the cap growing or being removed.""" service = _service(tmp_path) for i in range(7): - service.remember(QueryRequest(surface="google", phrases=(f"term-{i}",))) - recent = service.recent() + service.remember_query(QueryRequest(surface="google", phrases=(f"term-{i}",))) + recent = service.recent_queries() assert len(recent) == 5 assert recent[0].phrases == ("term-6",) @@ -96,17 +96,17 @@ def test_remember_caps_at_five(tmp_path): def test_remember_deduplicates(tmp_path): """GUARD. Mutation caught: duplicates accumulating.""" service = _service(tmp_path) - service.remember(QueryRequest(surface="google", phrases=("same",))) - service.remember(QueryRequest(surface="google", phrases=("same",))) - assert len(service.recent()) == 1 + service.remember_query(QueryRequest(surface="google", phrases=("same",))) + service.remember_query(QueryRequest(surface="google", phrases=("same",))) + assert len(service.recent_queries()) == 1 def test_clear_recent_removes_the_file(tmp_path): """GUARD. Mutation caught: leaving the file (empty or not) behind.""" service = _service(tmp_path) - service.remember(QueryRequest(surface="google", phrases=("x",))) - service.clear_recent() - assert service.recent() == () + service.remember_query(QueryRequest(surface="google", phrases=("x",))) + service.clear_recent_queries() + assert service.recent_queries() == () assert not (tmp_path / "query_builder_recent.json").exists() @@ -120,7 +120,7 @@ def test_the_recent_file_lives_in_the_data_home_and_uninstall_knows_it(): def test_build_returns_bar_url_and_plain_notes(tmp_path): """GUARD. Mutation caught: the service dropping the notes or a form.""" - result = _service(tmp_path).build( + result = _service(tmp_path).build_query( QueryRequest( surface="google", phrases=("Software Engineer",), @@ -135,7 +135,7 @@ def test_build_returns_bar_url_and_plain_notes(tmp_path): def test_an_unknown_surface_fails_loudly(tmp_path): """GUARD. Mutation caught: a quiet empty result for an unknown surface.""" with pytest.raises(SearchSurfaceError): - _service(tmp_path).build(QueryRequest(surface="nonsense", phrases=("x",))) + _service(tmp_path).build_query(QueryRequest(surface="nonsense", phrases=("x",))) def test_the_builder_modules_import_no_network_browser_or_research_code(): 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 40c4df0..8e0a5ae 100644 --- a/packages/auto_apply/tests/application/test_ui_port_conformance.py +++ b/packages/auto_apply/tests/application/test_ui_port_conformance.py @@ -90,7 +90,8 @@ # satisfy the wider surface structurally, no wrapper. "query_surfaces", "query_platforms", "query_defaults", "build_query", "build_platform_query", "build_company_query", - "remember_query", "recent_queries", "clear_recent_queries", + "resolve_query_target", "remember_query", "recent_queries", + "clear_recent_queries", } diff --git a/packages/auto_apply/tests/domain/test_query_composer.py b/packages/auto_apply/tests/domain/test_query_composer.py index d41dc5a..ec2cae3 100644 --- a/packages/auto_apply/tests/domain/test_query_composer.py +++ b/packages/auto_apply/tests/domain/test_query_composer.py @@ -308,3 +308,77 @@ def test_date_and_sort_merge_on_one_parameter(): ) assert result.url is not None assert "tbs=qdr%3Ad%2Csbd%3A1" in result.url + + +def test_a_location_a_surface_cannot_take_is_noted_not_dropped_silently(): + """TEETH (new surface): Google has no location field, so a location used to + vanish without a word. Now it is noted, and the note names the remedy. + Mutation caught: dropping the location note, or refusing instead of noting.""" + result = compose( + QueryRequest( + surface="google", + phrases=("Software Engineer",), + location="Seattle, WA", + ), + _spec("google"), + ) + assert result.url is not None + assert any(n.topic == "location" for n in result.notes) + assert any("Seattle, WA" in n.message for n in result.notes) + + +def test_a_location_a_surface_takes_is_carried_without_a_note(): + """GUARD: Indeed carries the location in l= — no location note, no regression.""" + result = compose( + QueryRequest( + surface="indeed", + phrases=("Software Engineer",), + location="Austin", + ), + _spec("indeed"), + ) + assert result.url is not None and "l=Austin" in result.url + assert not any(n.topic == "location" for n in result.notes) + + +def test_linkedin_unquoting_is_noted(): + """TEETH (new surface): LinkedIn's quoting is unverified, so exact-phrase + terms go in bare — and now that is said. Mutation caught: composing the + link without the phrase note, or quoting anyway.""" + result = compose( + QueryRequest(surface="linkedin", phrases=("Software Engineer",)), + _spec("linkedin"), + ) + assert result.url is not None + assert "%22" not in result.url + assert any(n.topic == "phrase" for n in result.notes) + + +def test_google_quoting_carries_no_unquote_note(): + """GUARD: Google verifies quoting, so quotes are present and no phrase note.""" + result = compose( + QueryRequest(surface="google", phrases=("Software Engineer",)), + _spec("google"), + ) + assert result.bar_text == '("Software Engineer")' + assert not any(n.topic == "phrase" for n in result.notes) + + +def test_automation_url_is_unaffected_by_the_composer_notes(): + """GUARD (byte identity): the user-facing notes live only in compose(); the + automation builder reads the template directly and its bytes are unchanged.""" + from auto_apply.adapters.secondary.discovery.search_surface_registry import ( + load_surface_bundled, + ) + from auto_apply.domain.services.query_composer import build_automation_url + + url = build_automation_url( + load_surface_bundled("google"), + query="Engineer jobs in Remote", + location="Remote", + date_range="day", + ) + assert url == ( + "https://www.google.com/search?q=Engineer+jobs+in+Remote" + "&ibp=htl%3Bjobs&hl=en&gl=us&start=0&tbs=qdr%3Ad" + ) diff --git a/packages/auto_apply/tests/domain/test_ui_contract.py b/packages/auto_apply/tests/domain/test_ui_contract.py index cd24aaa..e47b840 100644 --- a/packages/auto_apply/tests/domain/test_ui_contract.py +++ b/packages/auto_apply/tests/domain/test_ui_contract.py @@ -69,7 +69,8 @@ # commands. Each returns data only. "query_surfaces", "query_platforms", "query_defaults", "build_query", "build_platform_query", "build_company_query", - "remember_query", "recent_queries", "clear_recent_queries", + "resolve_query_target", "remember_query", "recent_queries", + "clear_recent_queries", }