Fix what a cold review found in the engine, the config and the dependencies

The browser gets the environment without the model key: the one the settings name, and the default.
rich is declared, as tui.py imports it.
Dead code removed: Store.to_apply, the threshold setting, and tests of removed names.
A new resume replaces the plan and keeps the person's filters, such as exclude.
An unreadable description database is reported once per search, and the search goes on.
_places has no empty part, so a place without a city no longer reads ", Italy".
The apply protocol is named Browser, not Session.
Confirm, Finish and Complete are held back from any element, as sends do.
Judge comments say why, with no numbers a reader cannot check.
This commit is contained in:
feder-cr committed 2026-11-05 02:39:27 +01:00
1 parent efa34bd88e
commit 4ccd937475
13 files changed
+174 -96

No files matched your search

+1
View File
@@ -30,6 +30,7 @@ dependencies = [
"pypdf>=4",
"pyyaml>=6",
"reportlab>=4",
"rich>=13",
"textual>=0.80",
]
+31 -22
View File
@@ -29,6 +29,7 @@ from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from openai import AsyncOpenAI
from aihawk.config import KEY_ENV
from aihawk.database import Job
from aihawk.llm import structured
from aihawk.store import Proof, safe_name
@@ -143,9 +144,11 @@ def verify_system(guidance: str) -> str:
_SENDS = re.compile(
r"\b(submit|send|invia|inviar|candidati|candidatura|bewerben|absenden|senden|abschicken|envoyer|postuler|soumettre|enviar|solicitar|aplicar"
r"|verzenden|versturen|indienen|solliciteren|skicka|ansök|ansøg|sende|lähetä|wyślij|aplikuj|odeslat|odoslať|küldés|trimite|gönder|başvur"
r"|отправить|откликнуться|إرسال)\b"
r"|confirm|finish|complete|conferma|confermare|completa|completare|termina|confirmar|completar|finalizar|terminar|bestätigen|abschließen"
r"|fertigstellen|beenden|confirmer|terminer|finaliser|bevestigen|voltooien|bekräfta|slutför|bekreft|bekræft|fullfør|fuldfør|potwierdź|zakończ"
r"|potvrdit|dokončit|confirmă|finalizează|onayla|tamamla|подтвердить|завершить|отправить|откликнуться|إرسال)\b"
# These scripts write no space between words, so a word has no edge to look for: 送信する is still 送信.
r"|送信|応募|发送|提交|申请|제출|보내기",
r"|送信|応募|发送|提交|申请|제출|보내기|確認|完了|确认|完成|확인|완료",
re.IGNORECASE,
)
_APPLY = re.compile(r"\b(apply|applica)\b", re.IGNORECASE)
@@ -186,7 +189,9 @@ def _asks_for_enter(element: dict[str, Any]) -> bool:
def sends_form(element: dict[str, Any], form_touched: bool) -> bool:
"""Whether clicking this element would send the application. In doubt, it would."""
"""Whether clicking this element would send the application. Its words decide first: a send word in one of the languages
`_SENDS` has, or Apply once the form is touched or the element is in one. A link is judged by its words alone. Otherwise
a submit button, or a button with no type inside a form, sends unless its words name a step, an answer or a file."""
tag, kind, text = element.get("tag", ""), element.get("type", ""), element.get("text", "")
if _SENDS.search(text):
return True
@@ -200,7 +205,7 @@ def sends_form(element: dict[str, Any], form_touched: bool) -> bool:
return bool(submits) and not _STEPS.search(text)
class Session(Protocol):
class Browser(Protocol):
async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> Any: ...
@@ -241,7 +246,7 @@ NOT_READABLE = (
NO_ELEMENT = "Refused: no element is at that point. Take a screenshot and aim browser_click_at at the centre of the control."
async def _element(session: Session, found: str) -> dict[str, Any] | None:
async def _element(session: Browser, found: str) -> dict[str, Any] | None:
"""What the page says about that element; None when it cannot be told."""
result = await session.call_tool("browser_evaluate", {"expression": _inspect(found)})
try:
@@ -255,7 +260,7 @@ def _host(url: str) -> str:
return (urlsplit(url).hostname or "").lower()
async def refusal(session: Session, name: str, arguments: dict[str, Any], form_touched: bool, hosts: frozenset[str] = frozenset()) -> str | None:
async def refusal(session: Browser, name: str, arguments: dict[str, Any], form_touched: bool, hosts: frozenset[str] = frozenset()) -> str | None:
"""Why the model may not make this call; None when it may."""
if name == "browser_navigate":
# A link can send a form (a GET that confirms), so the model goes only where the form and its mailbox are.
@@ -336,7 +341,7 @@ NAV_TRIES = 10
NAV_WAIT_S = 0.2
async def _page(session: Session) -> dict[str, str] | None:
async def _page(session: Browser) -> dict[str, str] | None:
"""The page's address and text, as the server reads them; None when they cannot be read."""
result = await session.call_tool("browser_evaluate", {"expression": PAGE})
try:
@@ -346,7 +351,7 @@ async def _page(session: Session) -> dict[str, str] | None:
return page if isinstance(page, dict) else None
async def _readable_page(session: Session) -> dict[str, str] | None:
async def _readable_page(session: Browser) -> dict[str, str] | None:
"""The page once it can be read: a page can be loading after a change, so a read that fails is tried again."""
page = await _page(session)
for _ in range(READ_TRIES):
@@ -357,7 +362,7 @@ async def _readable_page(session: Session) -> dict[str, str] | None:
return None
async def _after(session: Session, name: str, form_host: str, before: dict[str, str] | None = None) -> None:
async def _after(session: Browser, name: str, form_host: str, before: dict[str, str] | None = None) -> None:
"""Stops the form when a call moved the page to another site. A same-site send is not detected: a multi-step form
advances by address too, so the address alone cannot tell the two apart. A page that stays unreadable says nothing.
A call that can navigate returns before the navigation has begun, so the page is read again, for a short while, until
@@ -396,7 +401,7 @@ async def _stopped(client: AsyncOpenAI, model: str, messages: list[Any], reason:
return {"status": status, "summary": f"Stopped: {reason}\n{summary}", "questions": report.get("questions") or []}
async def fill_form(session: Session, tools: list[dict[str, Any]], client: AsyncOpenAI, model: str, brief: str, guidance: str = "", page_rules: str = "", form_url: str = "", reaches: frozenset[str] = frozenset()) -> dict[str, Any]:
async def fill_form(session: Browser, tools: list[dict[str, Any]], client: AsyncOpenAI, model: str, brief: str, guidance: str = "", page_rules: str = "", form_url: str = "", reaches: frozenset[str] = frozenset()) -> dict[str, Any]:
"""The model works on the open page until it calls finish_form; returns what it reported. A form it cannot finish is
stopped with a report of what it did, rather than left to run on."""
messages: list[Any] = [{"role": "system", "content": instructions(guidance, page_rules)}, {"role": "user", "content": brief}]
@@ -458,7 +463,7 @@ async def fill_form(session: Session, tools: list[dict[str, Any]], client: Async
return await _stopped(client, model, messages, f"the form was not finished in {MAX_STEPS} steps.", f"The form was not finished in {MAX_STEPS} steps. Last call: {last}")
async def _where(session: Session, shot: Path) -> tuple[str, str]:
async def _where(session: Browser, shot: Path) -> tuple[str, str]:
"""The address the page is on now, and a picture of it, kept at `shot`. What cannot be had is left out: the verdict stands."""
url, screenshot = "", ""
with suppress(Exception):
@@ -471,7 +476,7 @@ async def _where(session: Session, shot: Path) -> tuple[str, str]:
return url, screenshot
async def submit_and_verify(session: Session, client: AsyncOpenAI, model: str, selector: str, summary: str, guidance: str, shot: Path) -> Outcome:
async def submit_and_verify(session: Browser, client: AsyncOpenAI, model: str, selector: str, summary: str, guidance: str, shot: Path) -> Outcome:
at = datetime.now(UTC).isoformat(timespec="seconds")
click = await session.call_tool("browser_click", {"selector": selector})
# An error does not say it stayed: a form that disables its button while sending answers the click with one. What
@@ -495,7 +500,7 @@ async def submit_and_verify(session: Session, client: AsyncOpenAI, model: str, s
return Outcome("unconfirmed", f"{summary}\nClicked, not confirmed by the page: {verdict['evidence']}", proof=proof)
async def run_on_page(session: Session, tools: list[dict[str, Any]], client: AsyncOpenAI, model: str, job: Job, cv_text: str, answers: str, cv_file: Path, shot: Path, guidance: str = "", page_rules: str = "", reaches: frozenset[str] = frozenset()) -> Outcome:
async def run_on_page(session: Browser, tools: list[dict[str, Any]], client: AsyncOpenAI, model: str, job: Job, cv_text: str, answers: str, cv_file: Path, shot: Path, guidance: str = "", page_rules: str = "", reaches: frozenset[str] = frozenset()) -> Outcome:
brief = (
f"The form: {job.apply_url}\nThe job: {job.title} at {job.company}\nThe CV file to upload: {cv_file}\n\n"
f"The CV:\n{cv_text}\n\nThe answers:\n{answers}"
@@ -516,7 +521,7 @@ async def run_on_page(session: Session, tools: list[dict[str, Any]], client: Asy
return await submit_and_verify(session, client, model, str(report["submit_selector"]), summary, guidance, shot)
async def _open_browser(session: Session, profile: Path) -> None:
async def _open_browser(session: Browser, profile: Path) -> None:
# While the engine downloads, the server answers with its progress instead of a browser. Its words are its own to
# change: the browser is ready when it can go to a page, and an action on one that is not open is an error.
# The profile keeps the sign-ins between opens, and the server keeps the seed with it: the same person each time.
@@ -533,9 +538,11 @@ async def _open_browser(session: Session, profile: Path) -> None:
await asyncio.sleep(OPEN_RETRY_S)
def server_parameters(upload_dir: Path, home: Path) -> StdioServerParameters:
def server_parameters(upload_dir: Path, home: Path, key_env: str = KEY_ENV) -> StdioServerParameters:
# The browser is given the environment without the model's key: nothing it does needs that key.
keys = {key_env, KEY_ENV}
environment = {
**os.environ,
**{name: value for name, value in os.environ.items() if name not in keys},
"INVISIBLE_MCP_HOST_MANAGED": "1",
"INVISIBLE_MCP_UPLOAD_DIRS": str(upload_dir),
"INVISIBLE_MCP_HOME": str(home),
@@ -546,20 +553,22 @@ def server_parameters(upload_dir: Path, home: Path) -> StdioServerParameters:
@asynccontextmanager
async def _server(upload_dir: Path, home: Path) -> AsyncIterator[tuple[ClientSession, str]]:
async def _server(upload_dir: Path, home: Path, key_env: str = KEY_ENV) -> AsyncIterator[tuple[ClientSession, str]]:
"""The browser server, started: a session on it, and the instructions it gives about driving a page."""
home.mkdir(parents=True, exist_ok=True)
# The server's own messages go to a file: on the person's terminal they would draw over the screen.
with (home / "server.log").open("a", encoding="utf-8") as server_log:
async with stdio_client(server_parameters(upload_dir, home), errlog=server_log) as (read, write), ClientSession(read, write) as session:
async with stdio_client(server_parameters(upload_dir, home, key_env), errlog=server_log) as (read, write), ClientSession(read, write) as session:
started = await session.initialize()
yield session, started.instructions or ""
async def apply_to_job(client: AsyncOpenAI, model: str, job: Job, cv_text: str, answers: str, cv_file: Path, data_dir: Path, profile: Path, guidance: str = "", reaches: frozenset[str] = frozenset()) -> Outcome:
async def apply_to_job(
client: AsyncOpenAI, model: str, job: Job, cv_text: str, answers: str, cv_file: Path, data_dir: Path, profile: Path, guidance: str = "", reaches: frozenset[str] = frozenset(), key_env: str = KEY_ENV
) -> Outcome:
"""Open the browser on the person's profile, fill the job's form and send it: the person's selection and Enter came before."""
shot = data_dir / "proofs" / f"{safe_name(job.id)}.png"
async with _server(cv_file.parent, data_dir / "browser") as (session, page_rules):
async with _server(cv_file.parent, data_dir / "browser", key_env) as (session, page_rules):
listed = await session.list_tools()
tools = [
{"type": "function", "function": {"name": tool.name, "description": tool.description or tool.name, "parameters": tool.inputSchema}}
@@ -576,12 +585,12 @@ async def apply_to_job(client: AsyncOpenAI, model: str, job: Job, cv_text: str,
await session.call_tool("browser_close", {})
async def sign_in(profile: Path, home: Path, wait: Callable[[], Awaitable[None]]) -> None:
async def sign_in(profile: Path, home: Path, wait: Callable[[], Awaitable[None]], key_env: str = KEY_ENV) -> None:
"""Open the profile's browser on a blank page so the person can sign in; once `wait` returns, the browser closes.
Nothing is typed for the person and no model is involved: the sign-ins are kept in the profile for the applications.
"""
async with _server(home, home) as (session, _):
async with _server(home, home, key_env) as (session, _):
try:
await _open_browser(session, profile)
await wait()
+7 -12
View File
@@ -12,17 +12,18 @@ import yaml
CONFIG_FILE = "aihawk.yaml"
KEY_FILE = "key"
DEFAULT_BASE_URL = "https://api.orcarouter.ai/v1"
# The variable that holds the model key, unless the settings name another. The browser is given no variable that holds a key.
KEY_ENV = "ORCAROUTER_API_KEY"
# Fills the forms and reads their screenshots. Chosen over z-ai/glm-5.3-flash, which was slower and dearer on the same forms.
DEFAULT_MODEL = "deepseek/deepseek-v4.1-flash"
# For the plan and the other short calls of a search. deepseek-v4.1-flash did not keep the plan's nested schema, and
# z-ai/glm-5.3-flash was too slow for a search.
DEFAULT_QUICK_MODEL = "google/gemini-3.5-flash"
# Scores the postings the funnel picks. Chosen against the title's fit on five people's searches: see judge.py.
# Writes the score and the report of each posting the funnel shortlists.
DEFAULT_JUDGE_MODEL = "deepseek/deepseek-v4.1-flash"
# Answers the six questions that shortlist the postings. Kept as the default because the thresholds of judge.evidence() were set on this
# model's answers, and another model answers differently, so a change needs those thresholds set again.
# Answers the six questions that shortlist the postings. Kept as the default: judge.py turns its answers into evidence by rules
# that were checked on this model, and another model answers differently, so those rules need checking again.
DEFAULT_DECISION_MODEL = "typesafe/jev-1.14"
DEFAULT_THRESHOLD = 4.0
DEFAULT_DATABASE = "https://github.com/feder-cr/Jobs_Applier_AI_Agent_AIHawk/releases/download/jobs-latest/manifest.json"
DEFAULT_MAX_AGE_DAYS = 60
@@ -90,8 +91,7 @@ class Config:
judge_model: str = DEFAULT_JUDGE_MODEL
decision_model: str = DEFAULT_DECISION_MODEL
base_url: str = DEFAULT_BASE_URL
api_key_env: str = "ORCAROUTER_API_KEY"
threshold: float = DEFAULT_THRESHOLD
api_key_env: str = KEY_ENV
filters: Filters = field(default_factory=Filters)
@property
@@ -225,10 +225,6 @@ def load_config(workspace: Path) -> Config:
raise ConfigError(f"{path} must be a mapping of settings")
filters = raw.get("filters") or {}
threshold = raw.get("threshold", DEFAULT_THRESHOLD)
if isinstance(threshold, bool) or not isinstance(threshold, (int, float)) or not 1 <= threshold <= 5:
raise ConfigError(f"threshold must be a number from 1 to 5, not {threshold!r}")
return Config(
workspace=workspace,
name=str(raw.get("name") or ""),
@@ -240,8 +236,7 @@ def load_config(workspace: Path) -> Config:
judge_model=str(raw.get("judge_model") or DEFAULT_JUDGE_MODEL),
decision_model=str(raw.get("decision_model") or DEFAULT_DECISION_MODEL),
base_url=str(raw.get("base_url") or DEFAULT_BASE_URL),
api_key_env=str(raw.get("api_key_env") or "ORCAROUTER_API_KEY"),
threshold=float(threshold),
api_key_env=str(raw.get("api_key_env") or KEY_ENV),
filters=Filters(
_words(filters.get("include"), "filters.include"),
_words(filters.get("exclude"), "filters.exclude"),
+6 -10
View File
@@ -76,12 +76,13 @@ def page_text(markup: str) -> str:
return re.sub(r"\n\s*\n+", "\n\n", "".join(parser.parts)).strip()
def stored_descriptions(refs: list[str], done: Callable[[int], None] = lambda _: None) -> list[str]:
def stored_descriptions(refs: list[str], done: Callable[[int], None] = lambda _: None, problem: Callable[[str], None] = lambda _: None) -> list[str]:
"""The descriptions the database holds for these postings, in order; empty where its sources published none.
One connection, and one query per file: a file's footer is fetched once for the search, not once per posting, which
downloads far less over HTTP.
`done` is told how many have been read, as each file is.
`done` is told how many have been read, as each file is. A read that fails is told once to `problem`, and the
descriptions read before it are kept: the search goes on with what it has.
"""
texts = [""] * len(refs)
by_file: dict[str, list[tuple[int, int]]] = {}
@@ -99,19 +100,14 @@ def stored_descriptions(refs: list[str], done: Callable[[int], None] = lambda _:
connection.execute("INSTALL httpfs; LOAD httpfs;")
for file, places in by_file.items():
marks = ", ".join(str(number) for _, number in places)
try:
found = dict(connection.execute(
f"SELECT file_row_number, description FROM read_parquet(?, file_row_number = true) WHERE file_row_number IN ({marks})",
[file],
).fetchall())
except duckdb.Error:
found = {}
query = f"SELECT file_row_number, description FROM read_parquet(?, file_row_number = true) WHERE file_row_number IN ({marks})"
found = dict(connection.execute(query, [file]).fetchall())
for position, number in places:
texts[position] = found.get(number) or ""
count += len(places)
done(count)
except duckdb.Error:
pass
problem("Some job descriptions could not be read from the job database, so those postings are judged without them.")
finally:
connection.close()
return texts
+2 -3
View File
@@ -16,9 +16,8 @@ from aihawk.llm import refused
DESCRIPTION_CHARS = 24000
DECISION_ENDPOINT = "/systemone"
# Batching is kept for its speed: the ranking with batches and with one posting a request differed by less than the noise of five
# people's grades. The endpoint refuses more than 512 questions a request (85 postings at six statements is 510), and the input
# is capped at 180k tokens, below the size the endpoint refused in testing.
# Postings are sent in batches to save requests. The endpoint refuses more than 512 questions in one request: 85 postings at
# six questions each make 510. A batch is also kept to MAX_CHARS, a rough cap on its size at about four characters a token.
MAX_POSTINGS = 85
MAX_CHARS = 180_000 * 4
+10 -12
View File
@@ -170,18 +170,16 @@ async def install_resume(client: AsyncOpenAI, config: Config, path: Path) -> dic
settings_path = config.workspace / CONFIG_FILE
settings = (yaml.safe_load(settings_path.read_text(encoding="utf-8-sig")) or {}) if settings_path.is_file() else {}
settings.update({
"name": profile["name"],
"goals": profile["goals"],
"filters": {
"include": [node["id"] for node in profile["plan"]["nodes"]],
"exclude": [],
"locations": list(dict.fromkeys([*profile["cities"], *profile["locations"]])),
"cities": profile["cities"],
"countries": [code.strip().upper() for code in profile["countries"]],
"plan": profile["plan"],
},
})
# The new resume replaces the plan; what the person set in the filters, such as what to exclude, is kept.
filters = {
**(settings.get("filters") or {}),
"include": [node["id"] for node in profile["plan"]["nodes"]],
"locations": list(dict.fromkeys([*profile["cities"], *profile["locations"]])),
"cities": profile["cities"],
"countries": [code.strip().upper() for code in profile["countries"]],
"plan": profile["plan"],
}
settings.update({"name": profile["name"], "goals": profile["goals"], "filters": filters})
settings_path.write_text(yaml.safe_dump(settings, allow_unicode=True, sort_keys=False), encoding="utf-8")
stated = {key: value for key, value in profile["answers"].items() if value.strip()}
+14 -7
View File
@@ -76,7 +76,7 @@ class Session:
async def sign_in(self, wait: Callable[[], Awaitable[None]]) -> None:
"""Open the browser for the person to sign in, until `wait` returns: the same profile the applications use."""
await sign_in_browser(self.config.browser_profile, self.config.data_dir / "browser", wait)
await sign_in_browser(self.config.browser_profile, self.config.data_dir / "browser", wait, self.config.api_key_env)
async def find(self, log: Log) -> int:
"""Bring today's postings for this search into the store; how many are new."""
@@ -95,7 +95,7 @@ class Session:
batch = self.store.with_status("new")[:WIDE]
if not batch:
return 0
texts = await _read_descriptions([item.job.ref for item in batch], status)
texts = await _read_descriptions([item.job.ref for item in batch], status, log)
for item, text in zip(batch, texts, strict=True):
self.store.set_description(item.job.id, text)
jobs = [replace(item.job, description=text) for item, text in zip(batch, texts, strict=True)]
@@ -203,7 +203,9 @@ class Session:
guidance = "\n\n".join([plugin.instructions, *(extra.instructions for extra in extras)])
reaches = frozenset(host for extra in extras for host in extra.reaches)
try:
outcome = await apply_to_job(self.client, self.config.model, job, cv, answers, cv_file, self.config.data_dir, self.config.browser_profile, guidance, reaches)
outcome = await apply_to_job(
self.client, self.config.model, job, cv, answers, cv_file, self.config.data_dir, self.config.browser_profile, guidance, reaches, self.config.api_key_env
)
except Exception as error:
# One job's failure is reported and recorded; the next job still runs.
if refused(error):
@@ -219,16 +221,21 @@ class Session:
return outcome
async def _read_descriptions(refs: list[str], status: Log) -> list[str]:
"""The descriptions the database holds; the count of those read is said as each file is read."""
async def _read_descriptions(refs: list[str], status: Log, log: Log) -> list[str]:
"""The descriptions the database holds; the count of those read is said as each file is read, and a failure is logged."""
loop = asyncio.get_running_loop()
def shown(done: int) -> None:
# The read runs in a thread: its count is handed to the loop, which alone touches the screen.
loop.call_soon_threadsafe(status, f"reading descriptions {done}/{len(refs)}")
return await asyncio.to_thread(stored_descriptions, refs, shown)
def problem(text: str) -> None:
loop.call_soon_threadsafe(log, text)
return await asyncio.to_thread(stored_descriptions, refs, shown, problem)
def _places(config: Config) -> str:
return " or ".join(config.filters.cities[:3]) + ", " + (config.filters.locations or [""])[-1]
"""The places a posting would name for this search: its cities, then its country, each one only when there is one."""
parts = [" or ".join(config.filters.cities[:3]), (config.filters.locations or [""])[-1]]
return ", ".join(part for part in parts if part)
-8
View File
@@ -189,14 +189,6 @@ class Store:
rest = [item for _, item in sorted(zip(order, rest, strict=True), key=lambda pair: (-pair[0], pair[1].job.id))]
return judged + rest
def to_apply(self, threshold: float, limit: int) -> list[Stored]:
"""The best scored jobs at or above the threshold that were not tried yet or are waiting on an answer."""
rows = self._db.execute(
"SELECT * FROM jobs WHERE status IN ('scored', 'needs_input') AND score >= ? ORDER BY score DESC, id LIMIT ?",
(threshold, limit),
).fetchall()
return [self._stored(row) for row in rows]
def set_evidence(self, job_id: str, evidence: float) -> None:
self._db.execute("UPDATE jobs SET evidence = ?, updated = ? WHERE id = ?", (evidence, _now(), job_id))
self._db.commit()
+43 -1
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
import json
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
@@ -27,6 +28,7 @@ from aihawk.apply import (
refusal,
run_on_page,
sends_form,
server_parameters,
sign_in,
)
from aihawk.database import Job
@@ -81,6 +83,46 @@ def test_a_control_that_sends_in_another_language_is_held_back_even_with_no_form
assert sends_form({"tag": "div", "type": "", "in_form": False, "text": word}, True)
# Confirm, Finish and Complete send from any element, a div or a type=button included. A stepper's "Complete" is refused too.
@pytest.mark.parametrize(
"element",
[
{"tag": "div", "type": "", "in_form": False, "text": "Confirm"},
{"tag": "span", "type": "", "in_form": True, "text": "Finish"},
{"tag": "button", "type": "button", "in_form": True, "text": "Complete"},
{"tag": "div", "type": "", "in_form": True, "text": "Complete"},
{"tag": "div", "type": "", "in_form": False, "text": "Conferma"},
{"tag": "a", "type": "", "in_form": False, "text": "Confirm"},
],
)
def test_a_confirm_finish_or_complete_control_is_held_back_whatever_its_tag(element: dict[str, Any]) -> None:
assert sends_form(element, False)
# Not every clickable sends: a form's step, a link to read on, a button that edits, all pass.
@pytest.mark.parametrize(
"element",
[
{"tag": "div", "type": "", "in_form": True, "text": "Next"},
{"tag": "span", "type": "", "in_form": False, "text": "Show more"},
{"tag": "button", "type": "button", "in_form": True, "text": "Edit details"},
{"tag": "a", "type": "", "in_form": False, "text": "Home"},
],
)
def test_a_clickable_that_does_not_send_passes(element: dict[str, Any]) -> None:
assert not sends_form(element, True)
def test_the_browser_is_given_the_environment_without_the_model_key(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ORCAROUTER_API_KEY", "sk-default-name")
monkeypatch.setenv("MY_MODEL_KEY", "sk-named-in-settings")
environment = server_parameters(tmp_path, tmp_path, "MY_MODEL_KEY").env or {}
assert "ORCAROUTER_API_KEY" not in environment and "MY_MODEL_KEY" not in environment
assert "sk-default-name" not in environment.values() and "sk-named-in-settings" not in environment.values()
# Everything else stays, the PATH included, so the browser can still start.
assert environment["PATH"] == os.environ["PATH"]
@pytest.mark.parametrize("key", ["Enter", "enter", "Control+Enter"])
def test_enter_is_refused(key: str) -> None:
assert asyncio.run(refusal(ScriptedBrowser(), "browser_press_key", {"key": key}, False))
@@ -472,7 +514,7 @@ class ScriptedServer(ScriptedBrowser):
def scripted_server(monkeypatch: pytest.MonkeyPatch, server: ScriptedServer) -> None:
@asynccontextmanager
async def started(upload_dir: Path, home: Path) -> AsyncIterator[tuple[ScriptedServer, str]]:
async def started(upload_dir: Path, home: Path, key_env: str = "") -> AsyncIterator[tuple[ScriptedServer, str]]:
yield server, ""
monkeypatch.setattr(apply_module, "_server", started)
+20 -6
View File
@@ -14,12 +14,12 @@ import httpx
import pytest
from openai import AsyncOpenAI
from aihawk import documents
from aihawk import judge as judge_module
from aihawk import session as session_module
from aihawk.config import Config, Filters
from aihawk.database import Job
from aihawk.judge import fuse
from aihawk.session import JUDGED, WIDE, Session
from aihawk.session import JUDGED, WIDE, Session, _places
@pytest.fixture(autouse=True)
@@ -146,7 +146,21 @@ def test_fuse_is_the_title_z_score_plus_the_evidence() -> None:
assert fuse([], []) == []
def test_the_title_screen_is_gone() -> None:
assert not any(hasattr(documents, name) for name in ("screen_titles", "SCREEN_SCHEMA", "SCREEN_SYSTEM"))
assert not any(hasattr(session_module, name) for name in ("BATCH", "SCREEN_BATCH"))
assert not hasattr(Session, "shortlist") and not hasattr(Session, "next_to_score")
@pytest.mark.parametrize(
("cities", "locations", "places"),
[(["Lyon"], ["France"], "Lyon, France"), ([], ["Italy"], "Italy"), (["Lyon"], [], "Lyon"), ([], [], "")],
)
def test_the_places_a_posting_names_have_no_empty_part(cities: list[str], locations: list[str], places: str) -> None:
assert _places(Config(workspace=Path("."), filters=Filters(cities=tuple(cities), locations=tuple(locations)))) == places
def test_a_description_database_that_cannot_be_read_is_said_once_and_the_funnel_goes_on(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
def unreadable(refs: list[str], done: Any, problem: Any) -> list[str]:
problem("Some job descriptions could not be read from the job database, so those postings are judged without them.")
return [""] * len(refs)
monkeypatch.setattr(session_module, "stored_descriptions", unreadable)
session = session_with(tmp_path, 30)
said: list[str] = []
assert asyncio.run(session.funnel(said.append, lambda line: None)) == JUDGED
assert len(said) == 1 and "could not be read" in said[0]
+18 -12
View File
@@ -303,6 +303,23 @@ def test_many_descriptions_come_in_order_and_empty_where_there_is_no_reference(t
assert sorted(texts[:-2]) == ["Build", "Learn"] and counts[-1] == len(refs)
def test_a_database_that_cannot_be_read_is_reported_once_and_the_search_has_no_descriptions(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
_, jobs = search(database(tmp_path), Filters(include=("python",)), 60, tmp_path)
refs = [job.ref for job in jobs]
class Unreadable:
def execute(self, *args: object) -> None:
raise duckdb.Error("the extension cannot be downloaded")
def close(self) -> None:
pass
monkeypatch.setattr(duckdb, "connect", lambda: Unreadable())
reported: list[str] = []
assert stored_descriptions(refs, problem=reported.append) == [""] * len(refs)
assert len(reported) == 1 and "job database" in reported[0]
def test_a_job_with_a_description_reads_no_page() -> None:
job = Job("a", "c", "t", "l", "http://127.0.0.1:9/never", "u", "Already here")
assert with_description(job) is job
@@ -323,17 +340,6 @@ def test_a_job_seen_again_keeps_its_score_and_status(tmp_path: Path) -> None:
assert item is not None and (item.status, item.score, item.report) == ("needs_input", 4.5, {"score": 4.5})
def test_only_what_is_good_enough_and_not_sent_is_applied_to_best_first(tmp_path: Path) -> None:
store = Store(tmp_path / "jobs.db")
for number, score in enumerate((3.9, 4.0, 4.8, 4.9)):
job = Job(f"j{number}", "A", "T", "L", "u", "a", "d")
store.add(job)
store.set_score(job.id, score, {})
store.set_status("j3", "submitted")
assert [item.job.id for item in store.to_apply(4.0, 5)] == ["j2", "j1"]
assert [item.job.id for item in store.to_apply(4.0, 1)] == ["j2"]
def test_what_was_tried_is_listed_newest_first_in_plain_words_with_what_the_page_said(tmp_path: Path) -> None:
store = Store(tmp_path / "jobs.db")
for name in ("sent", "unsure", "refused", "blocked", "failed", "untried"):
@@ -417,7 +423,7 @@ def test_a_new_folder_defaults_to_orcarouter_and_the_published_database(tmp_path
@pytest.mark.parametrize(
"text",
["threshold: 9", "filters: {include: python}", "a: [", "- a"],
["filters: {include: python}", "a: [", "- a"],
)
def test_settings_that_cannot_be_used_are_refused(tmp_path: Path, text: str) -> None:
(tmp_path / "aihawk.yaml").write_text(text, encoding="utf-8")
+11
View File
@@ -326,6 +326,17 @@ def test_a_select_that_sends_on_the_same_site_is_not_detected_and_the_model_goes
assert len(model.requests) == 2
def test_the_browser_starts_with_the_model_key_kept_out_of_its_environment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ORCAROUTER_API_KEY", "sk-test-not-real")
async def check() -> None:
async with _server(tmp_path, tmp_path / "browser") as (session, _):
await _open_browser(session, tmp_path / "browser-profile")
assert not (await session.call_tool("browser_navigate", {"url": "about:blank"})).isError
asyncio.run(check())
def test_the_newest_server_still_has_what_aihawk_relies_on(tmp_path: Path) -> None:
async def check() -> None:
async with stdio_client(server_parameters(tmp_path, tmp_path), errlog=sys.stderr) as (read, write), ClientSession(read, write) as session:
+11 -3
View File
@@ -200,7 +200,7 @@ def test_no_key_asks_for_one_and_keeps_it_on_this_computer(home: Path, resume: P
def fake_apply(app: AIHawk, outcomes: list[Outcome], calls: list[Any]) -> None:
async def apply_to_job(client: Any, model: str, job: Any, cv: str, answers: str, cv_file: Path, data_dir: Path, profile: Path, guidance: str = "", reaches: frozenset[str] = frozenset()) -> Outcome:
async def apply_to_job(client: Any, model: str, job: Any, cv: str, answers: str, cv_file: Path, data_dir: Path, profile: Path, guidance: str = "", reaches: frozenset[str] = frozenset(), key_env: str = "") -> Outcome:
calls.append({"job": job, "answers": answers, "guidance": guidance, "cv_file": cv_file, "profile": profile})
return outcomes.pop(0)
@@ -518,6 +518,14 @@ def test_a_new_resume_never_invents_a_contact_detail(home: Path, resume: Path) -
assert "phone" not in read_answers(config)
def test_a_new_resume_replaces_the_plan_and_keeps_what_the_person_excluded(home: Path, resume: Path) -> None:
(home / "aihawk.yaml").write_text("filters:\n exclude: [intern]\n", encoding="utf-8")
asyncio.run(install_resume(ScriptedModelForApp(), load_config(home), resume)) # type: ignore[arg-type]
config = load_config(home)
assert config.filters.exclude == ("intern",)
assert config.filters.include == tuple(node.id for node in config.filters.plan.nodes)
def test_the_mail_plugins_join_every_form_after_the_sites_own_and_the_profile_is_the_workspaces(home: Path, resume: Path, restored_apply: Any) -> None:
calls: list[Any] = []
@@ -546,7 +554,7 @@ class ScriptedServer(ScriptedBrowser):
def scripted_server(monkeypatch: pytest.MonkeyPatch, server: ScriptedServer) -> None:
@asynccontextmanager
async def started(upload_dir: Path, home: Path) -> AsyncIterator[tuple[ScriptedServer, str]]:
async def started(upload_dir: Path, home: Path, key_env: str = "") -> AsyncIterator[tuple[ScriptedServer, str]]:
yield server, ""
monkeypatch.setattr(apply_module, "_server", started)
@@ -587,7 +595,7 @@ def test_the_sign_in_key_is_refused_while_an_application_runs(home: Path, resume
async def run() -> None:
release = asyncio.Event()
async def slow_apply(client: Any, model: str, job: Any, cv: str, answers: str, cv_file: Path, data_dir: Path, profile: Path, guidance: str = "", reaches: frozenset[str] = frozenset()) -> Outcome:
async def slow_apply(client: Any, model: str, job: Any, cv: str, answers: str, cv_file: Path, data_dir: Path, profile: Path, guidance: str = "", reaches: frozenset[str] = frozenset(), key_env: str = "") -> Outcome:
await release.wait()
return Outcome("blocked", "not sent")