First run: a refused key, quitting, 80 columns, plain errors, BOM files, install and CI

A key that OrcaRouter refuses is forgotten when it came from the key file, and asked for again; a key from the
environment is named by its variable and not forgotten. Ctrl+C leaves at every prompt, and leaving while a prompt
is open no longer raises NoMatches from the heading. Log lines wrap to the whole width, and Quit stays on the
footer at 80 columns. Database and model failures say what happened in plain words, with the detail after them.
Files saved with a byte-order mark are read. The README gives the install commands checked in new venvs, the
index size from the manifest, and how to change the key. CI runs the unit job on Python 3.11 and 3.12 on Ubuntu,
Windows and macOS; gate still needs all of it. The classifiers name the three systems.
This commit is contained in:
feder-cr committed 2026-11-04 22:52:08 +01:00
1 parent d2f93b5850
commit f7ef2564bb
15 files changed
+292 -56

No files matched your search

+4 -3
View File
@@ -13,17 +13,18 @@ permissions:
jobs:
unit:
name: lint and unit tests (${{ matrix.os }})
name: lint and unit tests (${{ matrix.os }}, Python ${{ matrix.python }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
os: [ubuntu-latest, windows-latest, macos-latest]
python: ["3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
python-version: ${{ matrix.python }}
- run: pip install -e ".[dev]"
- run: ruff check .
- run: pytest -m "not browser"
+22 -22
View File
@@ -1,14 +1,21 @@
# AIHawk
Give it your resume, get the jobs that fit, select them and apply. Runs in your terminal, with a browser you can watch.
Needs Python 3.11 or newer.
Give it your resume, get the jobs that fit, select them and apply. Runs in your terminal, with a browser you can watch. Needs Python 3.11 or newer.
```sh
pip install git+https://github.com/feder-cr/Jobs_Applier_AI_Agent_AIHawk
aihawk resume.pdf
pipx install git+https://github.com/feder-cr/Jobs_Applier_AI_Agent_AIHawk
```
Or `uv tool install git+https://github.com/feder-cr/Jobs_Applier_AI_Agent_AIHawk`; uv leaves `aihawk` off your PATH until you run `uv tool update-shell` and open a new terminal. Or, in a virtual environment (on Windows, `aihawk-env\Scripts\pip` and `aihawk-env\Scripts\aihawk`):
```sh
python -m venv aihawk-env
aihawk-env/bin/pip install git+https://github.com/feder-cr/Jobs_Applier_AI_Agent_AIHawk
aihawk-env/bin/aihawk resume.pdf
```
The resume can be anywhere on your disk: a PDF, a `.docx` or a text file. A scanned PDF has no text to read; export it with text, or save it as `.docx`.
```
AIHawk Ada Rossi · Python Engineer, Backend Developer · Milano, Italy [scoring 12/40]
┌───┬───────┬───────┬──────────┬─────────────────────────┬──────────┬────────┐
@@ -19,7 +26,9 @@ aihawk resume.pdf
└───┴───────┴───────┴──────────┴─────────────────────────┴──────────┴────────┘
```
The first time, it asks for an [OrcaRouter](https://orcarouter.ai) key and keeps it in `~/.aihawk`. After that, `aihawk` alone reopens your list.
The first time, AIHawk asks for an [OrcaRouter](https://orcarouter.ai) key and keeps it in `~/.aihawk/key`; after that, `aihawk` alone reopens your list. To change the key, edit or delete that file, or set `ORCAROUTER_API_KEY`, which wins over the file. If OrcaRouter refuses a key from the file, AIHawk forgets it and asks again.
The first start downloads the job index, 672 MB, once per database, into `~/.aihawk/data/index`. A search reads it on your computer; the descriptions of the jobs AIHawk judges are read from the release over the internet.
## Keys
@@ -33,39 +42,30 @@ The first time, it asks for an [OrcaRouter](https://orcarouter.ai) key and keeps
| m | score more jobs |
| r | give a new resume |
| l | open the browser to sign in; press Enter in the terminal when done |
| q | quit |
| q | quit (Ctrl+C quits too) |
## What it does
- **Reads** your resume, writes a search plan from it, and takes your contact details from it.
- **Finds** the postings whose titles fit the plan, in a job database published as a release: 10,223,753 postings in the manifest of 2026-11-04. Postings older than 60 days are left out by default. The daily refresh is scheduled and has not run yet.
- **Finds** the postings whose titles fit the plan, in a job database published as a release: 10,223,753 postings in the manifest of 2026-11-04. Postings older than 60 days are left out by default. The daily refresh is [`jobs-database.yml`](.github/workflows/jobs-database.yml): it is on main and has not run yet.
- **Shortlists** the best 100 by title: Jev, the decision model, answers six questions about each, and the best 25 are kept.
- **Scores** each shortlisted job from 1 to 5 against your resume, with its gaps.
- **Applies**: writes a CV for the job and fills the form in a visible browser through [invisible-playwright-mcp](https://github.com/feder-cr/invisible_playwright_mcp). What a form asks and your files do not say, it asks you, and remembers.
## Sending
The model fills the form and names the submit button. It is told not to send, and clicks that would send are refused. You select the jobs and press `a` and Enter: that one yes sends the selection, and AIHawk clicks the control the model named. As a last resort the model can run a script, which could send a form without asking; see [SECURITY.md](SECURITY.md). Each send keeps what the page said in `applied.csv` and `data/proofs/`; `s` lists them.
The model fills the form and names the submit button; it is told not to send, and clicks that would send are refused. Your `a` and Enter is the one yes: AIHawk clicks the control the model named. As a last resort the model can run a script, which could send a form without asking; see [SECURITY.md](SECURITY.md). Each send keeps what the page said in `applied.csv` and `data/proofs/`; `s` lists them.
## Sites
Plugins cover Ashby, BambooHR, Breezy, Greenhouse, JazzHR, Lever, Paylocity, Personio, Recruitee, SmartRecruiters, Teamtailor and Workable. Other postings are listed for applying by hand. The Gmail and Outlook plugins take a code or a link from your mailbox, read only; they have not been tried on a real mailbox yet.
A plugin is a Markdown file in [`src/aihawk/plugins/`](src/aihawk/plugins) that explains one site's form to the model. Anyone can add a site with a file and no code: see [docs/plugins.md](docs/plugins.md). Your own go in `~/.aihawk/plugins/`.
A plugin is a Markdown file in [`src/aihawk/plugins/`](src/aihawk/plugins) that explains one site's form to the model. Add a site with a file and no code: see [docs/plugins.md](docs/plugins.md). Your own go in `~/.aihawk/plugins/`.
## Plan and models
The search plan is an editable graph in `filters.plan` of `~/.aihawk/aihawk.yaml`; restart `aihawk` after editing it.
The search plan is an editable graph in `filters.plan` of `~/.aihawk/aihawk.yaml`; restart `aihawk` after editing it. Models go through OrcaRouter: `model` (default `deepseek/deepseek-v4.1-flash`) writes the CV and fills forms; `quick_model` (default `google/gemini-3.5-flash`) writes the plan; `decision_model` (default `typesafe/jev-1.14`, which is Jev) shortlists the jobs; `judge_model` (default `deepseek/deepseek-v4.1-flash`) scores them. `base_url` can name another gateway, which must serve OpenAI's chat API and also the decision model's `/systemone` call.
Models go through OrcaRouter: `model` (default `deepseek/deepseek-v4.1-flash`) writes the CV and fills forms; `quick_model` (default `google/gemini-3.5-flash`) writes the plan; `decision_model` (default `typesafe/jev-1.14`, which is Jev) shortlists the jobs; `judge_model` (default `deepseek/deepseek-v4.1-flash`) scores them. `base_url` can name another gateway, which must serve OpenAI's chat API and also the decision model's `/systemone` call.
More: the [wiki](https://github.com/feder-cr/Jobs_Applier_AI_Agent_AIHawk/wiki), [CONTRIBUTING.md](CONTRIBUTING.md), [SECURITY.md](SECURITY.md), [CHANGELOG.md](CHANGELOG.md). The job database merges public sources, whose licences and posting links are in [NOTICE](NOTICE); each posting belongs to its employer. The report's shape is adapted from [career-ops](https://github.com/santifer/career-ops) (MIT).
## More
- The [wiki](https://github.com/feder-cr/Jobs_Applier_AI_Agent_AIHawk/wiki): how matching works, sites and limits.
- [CONTRIBUTING.md](CONTRIBUTING.md), [SECURITY.md](SECURITY.md), [CHANGELOG.md](CHANGELOG.md).
The job database merges public job databases. Their sources, licences and the postings' links are in [NOTICE](NOTICE); each posting belongs to its employer. The job report's shape is adapted from [career-ops](https://github.com/santifer/career-ops) (MIT).
[MIT](LICENSE)
The former Dots project is at [feder-cr/invisible_dots](https://github.com/feder-cr/invisible_dots).
[MIT](LICENSE). The former Dots project is at [feder-cr/invisible_dots](https://github.com/feder-cr/invisible_dots).
+1 -1
View File
@@ -21,7 +21,7 @@ AIHawk reads the plugins when it starts, so restart it after you change one.
| `when` | Only `always`. The body is then added to every form's instructions, beside the site's own plugin. Such a plugin has no `hosts` and does not make a site count as one AIHawk can apply to. |
| `reaches` | Only in a `when: always` plugin: the sites the model may visit with it, such as `[mail.google.com]`. A site plugin's `reaches` is read and ignored. |
A file that does not parse (no front matter, `hosts` missing, a `when` other than `always`, empty instructions) stops AIHawk at start with a message that names the plugin. Save the file as UTF-8 without a byte-order mark: a file that starts with one is refused with "it must start with a front matter", because the first line must be `---`.
A file that does not parse (no front matter, `hosts` missing, a `when` other than `always`, empty instructions) stops AIHawk at start with a message that names the plugin. Save the file as UTF-8. A byte-order mark at its start, as some Windows editors write, is fine: AIHawk skips it.
## The order of sections
+4 -1
View File
@@ -9,7 +9,10 @@ readme = "README.md"
keywords = ["jobs", "job-search", "resume", "job-applications", "browser-automation"]
classifiers = [
"Environment :: Console",
"Programming Language :: Python :: 3",
"Operating System :: MacOS",
"Operating System :: Microsoft :: Windows",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
+5 -2
View File
@@ -120,7 +120,10 @@ class Config:
from_environment = os.environ.get(self.api_key_env, "").strip()
if from_environment:
return from_environment
return self.key_file.read_text(encoding="utf-8").strip() if self.key_file.is_file() else ""
return self.key_file.read_text(encoding="utf-8-sig").strip() if self.key_file.is_file() else ""
def key_from_environment(self) -> bool:
return bool(os.environ.get(self.api_key_env, "").strip())
def save_key(self, key: str) -> None:
self.workspace.mkdir(parents=True, exist_ok=True)
@@ -215,7 +218,7 @@ def load_config(workspace: Path) -> Config:
if not path.is_file():
return Config(workspace=workspace)
try:
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
raw = yaml.safe_load(path.read_text(encoding="utf-8-sig")) or {}
except yaml.YAMLError as error:
raise ConfigError(f"{path} is not valid YAML: {error}") from error
if not isinstance(raw, dict):
+9 -2
View File
@@ -137,13 +137,20 @@ def with_description(job: Job, timeout: float = 30.0) -> Job:
return job
def unreachable(error: BaseException) -> str:
"""What a database that cannot be downloaded says: in plain words, the detail kept after them."""
return f"The job database cannot be reached: check your internet connection, then start AIHawk again. ({error})"
def load_manifest(location: str) -> dict[str, Any]:
try:
if location.startswith(("http://", "https://")):
with urllib.request.urlopen(location, timeout=60) as response:
return json.load(response)
return json.loads(Path(location).read_text(encoding="utf-8"))
return json.loads(Path(location).read_text(encoding="utf-8-sig"))
except (OSError, ValueError) as error:
if isinstance(error, OSError) and location.startswith(("http://", "https://")):
raise DatabaseError(unreachable(error)) from error
raise DatabaseError(f"the job database's manifest at {location} cannot be read: {error}") from error
@@ -169,7 +176,7 @@ def local_index(manifest: dict[str, Any], location: str, cache: Path, log: Calla
shutil.copyfileobj(response, out, 1 << 20)
except OSError as error:
partial.unlink(missing_ok=True)
raise DatabaseError(f"the job index at {source} cannot be downloaded: {error}") from error
raise DatabaseError(unreachable(error)) from error
partial.replace(path)
for old in cache.glob("index-*.parquet"):
if old != path:
+6 -1
View File
@@ -12,6 +12,7 @@ from typing import Any
from openai import AsyncOpenAI
from aihawk.database import Job
from aihawk.llm import refused
DESCRIPTION_CHARS = 24000
DECISION_ENDPOINT = "/systemone"
@@ -72,7 +73,11 @@ async def _read(client: AsyncOpenAI, model: str, cv: str, jobs: list[Job], asked
"""One request's values. A request that fails is split in two and each half tried again; a posting that fails alone is a failed verdict."""
try:
reply = await client.post(DECISION_ENDPOINT, body=request_body(model, cv, jobs, asked), cast_to=dict)
except Exception: # noqa: BLE001 Whatever the failure, one bad posting must not cost the others their verdicts.
except Exception as error:
# Whatever the failure, one bad posting must not cost the others their verdicts. A refused key fails every request
# alike: splitting would only ask again, so it is raised for the screen.
if refused(error):
raise
if len(jobs) == 1:
return [None]
middle = len(jobs) // 2
+26 -1
View File
@@ -5,7 +5,14 @@ from __future__ import annotations
import json
from typing import Any
from openai import AsyncOpenAI
from openai import (
APIConnectionError,
APITimeoutError,
AsyncOpenAI,
AuthenticationError,
InternalServerError,
RateLimitError,
)
from aihawk.config import Config, ConfigError
@@ -13,6 +20,24 @@ from aihawk.config import Config, ConfigError
TIMEOUT = 180.0
def refused(error: BaseException) -> bool:
"""Whether OrcaRouter refused the key: the one failure that the person must fix before anything can work."""
return isinstance(error, AuthenticationError)
def explain(error: Exception) -> str:
"""What a failed model call says in plain words. The detail stays after it, and the key is never in it."""
if isinstance(error, APITimeoutError):
return f"OrcaRouter did not answer in time. Try again in a moment. ({error})"
if isinstance(error, APIConnectionError):
return f"OrcaRouter cannot be reached: check your internet connection and try again. ({error})"
if isinstance(error, RateLimitError):
return f"OrcaRouter is asking AIHawk to slow down: wait a few minutes and try again. ({error})"
if isinstance(error, InternalServerError):
return f"OrcaRouter failed on its side: try again in a few minutes. ({error})"
return f"Something failed: {error}"
def make_client(config: Config) -> AsyncOpenAI:
key = config.api_key()
if not key:
+1 -1
View File
@@ -78,7 +78,7 @@ def parse_plugin(name: str, text: str) -> Plugin:
def _files(folder: Traversable | Path) -> dict[str, str]:
return {item.name: item.read_text(encoding="utf-8") for item in folder.iterdir() if item.name.endswith(".md")}
return {item.name: item.read_text(encoding="utf-8-sig") for item in folder.iterdir() if item.name.endswith(".md")}
def load_plugins(workspace: Path | None = None) -> tuple[Plugin, ...]:
+3 -3
View File
@@ -152,7 +152,7 @@ def read_resume(path: Path) -> str:
elif suffix == ".doc":
raise ConfigError(f"{path.name}: save it as PDF or .docx first")
else:
text = path.read_text(encoding="utf-8", errors="replace")
text = path.read_text(encoding="utf-8-sig", errors="replace")
except (OSError, PyPdfError, zipfile.BadZipFile, KeyError, ElementTree.ParseError) as error:
raise ConfigError(f"{path.name} cannot be read: {error}") from error
text = re.sub(r"\n\s*\n+", "\n\n", text).strip()
@@ -169,7 +169,7 @@ async def install_resume(client: AsyncOpenAI, config: Config, path: Path) -> dic
config.cv.write_text(cv, encoding="utf-8")
settings_path = config.workspace / CONFIG_FILE
settings = (yaml.safe_load(settings_path.read_text(encoding="utf-8")) or {}) if settings_path.is_file() else {}
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"],
@@ -192,7 +192,7 @@ async def install_resume(client: AsyncOpenAI, config: Config, path: Path) -> dic
def read_answers(config: Config) -> dict[str, str]:
if not config.answers.is_file():
return {}
data = yaml.safe_load(config.answers.read_text(encoding="utf-8"))
data = yaml.safe_load(config.answers.read_text(encoding="utf-8-sig"))
return data if isinstance(data, dict) else {}
+31 -11
View File
@@ -15,7 +15,7 @@ from aihawk.config import Config, ConfigError, load_config
from aihawk.database import search, stored_descriptions, with_description
from aihawk.documents import score_job, tailor_cv, write_cv_pdf
from aihawk.judge import evidence, fuse, questions
from aihawk.llm import make_client
from aihawk.llm import explain, make_client, refused
from aihawk.plugins import Plugin, always_plugins, load_plugins, plugin_for
from aihawk.profile import answer_question, install_resume
from aihawk.store import SUBMITTED, Store, Stored, safe_name, write_applied
@@ -56,6 +56,14 @@ class Session:
self.config.save_key(key)
self._client = None
def refuse_key(self) -> str:
"""OrcaRouter refused the key. One typed in is forgotten, so the next question asks for another; one from the environment is named."""
self._client = None
if self.config.key_from_environment():
return f"OrcaRouter refused the key in the {self.config.api_key_env} variable. Set it to a key from https://orcarouter.ai, then start AIHawk again."
self.config.key_file.unlink(missing_ok=True)
return "OrcaRouter refused the key, so AIHawk forgot it. Get one at https://orcarouter.ai and type it in again."
def needs_resume(self) -> bool:
return not self.config.cv.is_file() or not self.config.filters.include
@@ -91,7 +99,7 @@ class Session:
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)]
cv = self.config.cv.read_text(encoding="utf-8")
cv = self.config.cv.read_text(encoding="utf-8-sig")
asked = questions(self.config.goals, _places(self.config))
def shown(done: int) -> None:
status(f"Jev reads {done}/{len(jobs)}")
@@ -99,7 +107,10 @@ class Session:
try:
# The client is read here: with no key every verdict fails, the title alone decides, and the log says so once.
values = await evidence(self.client, self.config.decision_model, cv, jobs, asked, shown)
except Exception: # noqa: BLE001 Only the client can fail here; a batch that fails is a failed verdict inside evidence.
except Exception as error:
# Only the client can fail here; a batch that fails is a failed verdict inside evidence.
if refused(error):
raise
values = [None] * len(jobs)
failed = sum(value is None for value in values)
if failed:
@@ -114,20 +125,26 @@ class Session:
return len(best)
async def score(self, items: list[Stored], on_scored: Callable[[], None], log: Log) -> None:
cv = self.config.cv.read_text(encoding="utf-8")
cv = self.config.cv.read_text(encoding="utf-8-sig")
limit = asyncio.Semaphore(SCORING_AT_ONCE)
async def one(item: Stored) -> None:
async with limit:
try:
report = await score_job(self.client, self.config.judge_model, item.job, cv, self.config.goals)
except Exception as error: # noqa: BLE001 One posting that cannot be judged must not stop the others.
log(f"{item.job.company}: not judged: {error}")
except Exception as error:
# One posting that cannot be judged must not stop the others.
if refused(error):
raise
log(f"{item.job.company}: not judged: {explain(error)}")
return
self.store.set_score(item.job.id, report["score"], report)
on_scored()
await asyncio.gather(*(one(item) for item in items))
# A refused key is raised after the others, so the screen can ask for another one.
for result in await asyncio.gather(*(one(item) for item in items), return_exceptions=True):
if isinstance(result, Exception):
raise result
async def _cv_for(self, item: Stored, cv: str, log: Log) -> Path:
if item.cv_path and Path(item.cv_path).is_file():
@@ -177,18 +194,21 @@ class Session:
plugin = self.plugin(item)
if plugin is None:
raise ConfigError(f"no plugin knows this site. Apply by hand: {item.job.url}")
cv = self.config.cv.read_text(encoding="utf-8")
cv = self.config.cv.read_text(encoding="utf-8-sig")
cv_file = await self._cv_for(item, cv, log)
job = replace(item.job, apply_url=plugin.form_url(item.job.url))
answers = self.config.answers.read_text(encoding="utf-8") if self.config.answers.is_file() else ""
answers = self.config.answers.read_text(encoding="utf-8-sig") if self.config.answers.is_file() else ""
log(f"filling the form on {plugin.name}")
extras = always_plugins(self.plugins)
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)
except Exception as error: # noqa: BLE001 One job's failure is reported and recorded; the next job still runs.
outcome = Outcome("failed", str(error) or type(error).__name__)
except Exception as error:
# One job's failure is reported and recorded; the next job still runs.
if refused(error):
raise
outcome = Outcome("failed", explain(error))
self.store.set_status(item.job.id, outcome.status, outcome.summary)
self.store.set_proof(item.job.id, outcome.proof)
# The file for the person outside the app. One that another program holds open must not stop the next send.
+29 -7
View File
@@ -17,6 +17,7 @@ from textual.widgets import DataTable, Footer, Input, RichLog, Static
from aihawk.config import ConfigError
from aihawk.database import DatabaseError
from aihawk.documents import QUESTIONS
from aihawk.llm import explain, refused
from aihawk.session import Session
from aihawk.store import SUBMITTED, Sent, Stored, said, sent_word
@@ -49,7 +50,7 @@ class TextPrompt(ModalScreen[str | None]):
with Vertical(id="dialog"):
yield Static(self.question, id="question", markup=False)
yield Input(placeholder=self.placeholder, password=self.password, id="answer")
yield Static("Enter to answer · Esc to skip", classes="hint")
yield Static("Enter to answer · Esc to skip · Ctrl+C quits", classes="hint")
@on(Input.Submitted)
def answered(self, event: Input.Submitted) -> None:
@@ -73,7 +74,7 @@ class SendBatch(ModalScreen[bool]):
lines = [f"{index}. {item.job.company}: {item.job.title}" for index, item in enumerate(self.items, 1)]
with Vertical(id="dialog"):
yield Static(f"Send {count} application{'' if count == 1 else 's'} now?", id="question", markup=False)
yield Static("They will be sent now, in the browser window. Enter sends them all; Escape cancels.", classes="hint")
yield Static("They will be sent now, in the browser window. Enter sends them all; Escape cancels; Ctrl+C quits.", classes="hint")
with VerticalScroll(id="summary"):
yield Static("\n".join(lines), markup=False)
yield Footer()
@@ -189,7 +190,7 @@ class SignIn(ModalScreen[None]):
def compose(self) -> ComposeResult:
with Vertical(id="dialog"):
yield Static("The browser window is open. Sign in there to your email, and to any site you apply on.", id="question", markup=False)
yield Static("Press Enter here when you are done. The window closes then.", classes="hint")
yield Static("Press Enter here when you are done. The window closes then. Ctrl+C quits.", classes="hint")
def action_done(self) -> None:
self.dismiss(None)
@@ -197,6 +198,8 @@ class SignIn(ModalScreen[None]):
class AIHawk(App[None]):
TITLE = "AIHawk"
# The footer of 80 columns has room for the keys only; with the palette's key, it hid Quit.
ENABLE_COMMAND_PALETTE = False
CSS = """
#heading { height: 1; padding: 0 1; background: $primary; color: $text; text-style: bold; }
#jobs { height: 1fr; }
@@ -214,11 +217,13 @@ class AIHawk(App[None]):
Binding("enter", "details", "Details"),
Binding("a", "apply", "Apply"),
Binding("s", "sent", "Sent"),
Binding("o", "open_link", "Open link"),
Binding("m", "more", "More jobs"),
Binding("o", "open_link", "Open"),
Binding("m", "more", "More"),
Binding("r", "new_resume", "New resume"),
Binding("l", "sign_in", "Sign in"),
Binding("q", "quit", "Quit"),
# Textual's own Ctrl+C only says "press ctrl+q", and an input under it copies: at any prompt Ctrl+C now leaves.
Binding("ctrl+c", "quit", show=False, priority=True),
]
def __init__(self, workspace: Path, resume: Path | None = None) -> None:
@@ -232,7 +237,8 @@ class AIHawk(App[None]):
def compose(self) -> ComposeResult:
yield Static("AIHawk", id="heading", markup=False)
yield DataTable(id="jobs", cursor_type="row", zebra_stripes=True)
yield RichLog(id="log", wrap=True, markup=False)
# min_width=1: RichLog wraps at 78 columns by default, under its own scrollbar, so the last words were hidden.
yield RichLog(id="log", wrap=True, min_width=1, markup=False)
yield Footer()
def on_mount(self) -> None:
@@ -251,7 +257,19 @@ class AIHawk(App[None]):
def say(self, line: str) -> None:
self.query_one(RichLog).write(line)
def failed(self, error: Exception) -> None:
"""Says what went wrong, in plain words. A key OrcaRouter refused is forgotten when it came from the file, and asked for again."""
if not refused(error):
self.say(explain(error))
return
self.say(self.session.refuse_key())
if self.session.needs_key():
self.start()
def show_heading(self, progress: str = "") -> None:
# A flow that ends while the app quits finds its screen gone: there is no heading left to write to.
if not self.is_running:
return
config = self.session.config
wanted = config.filters.titles() or "no resume yet"
place = ", ".join(config.filters.locations[:3])
@@ -326,7 +344,7 @@ class AIHawk(App[None]):
except (ConfigError, DatabaseError) as error:
self.say(str(error))
except Exception as error: # noqa: BLE001 The screen stays up and says what went wrong, instead of a traceback over it.
self.say(f"Something failed: {error}")
self.failed(error)
finally:
self.show_heading()
@@ -384,6 +402,8 @@ class AIHawk(App[None]):
async def more_jobs(self) -> None:
try:
await self.judge()
except Exception as error: # noqa: BLE001 Said on the screen, as in start.
self.failed(error)
finally:
self.show_heading()
@@ -456,6 +476,8 @@ class AIHawk(App[None]):
self.show_heading("applying")
try:
await self.session.send(ready, self.answer, self.say, self.refresh_jobs)
except Exception as error: # noqa: BLE001 Said on the screen, as in start; the batch stops where it failed.
self.failed(error)
finally:
self.busy = False
self.selected -= {item.job.id for item in chosen}
+12
View File
@@ -4,7 +4,9 @@ from __future__ import annotations
import csv
import json
import socket
import textwrap
import urllib.error
from pathlib import Path
import duckdb
@@ -271,6 +273,16 @@ def test_a_database_that_cannot_be_read_says_so(tmp_path: Path) -> None:
search(str(tmp_path / "none.json"), Filters(include=("x",)), 60, tmp_path)
def test_a_database_that_cannot_be_reached_says_so_in_plain_words_and_keeps_the_detail(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
def no_network(*args: object, **kwargs: object) -> None:
raise urllib.error.URLError(socket.gaierror(11001, "getaddrinfo failed"))
monkeypatch.setattr("aihawk.database.urllib.request.urlopen", no_network)
with pytest.raises(DatabaseError, match="cannot be reached: check your internet connection") as error:
search("https://example.test/manifest.json", Filters(include=("x",)), 60, tmp_path)
assert "<urlopen error [Errno 11001] getaddrinfo failed>" in str(error.value)
def test_page_text_keeps_the_words_and_drops_scripts() -> None:
text = page_text("<html><head><title>x</title><style>a{}</style></head><body><nav>menu</nav><p>Build &amp; ship</p><script>evil()</script><li>Python</li></body></html>")
assert text.split() == ["Build", "&", "ship", "Python"]
+6
View File
@@ -105,6 +105,12 @@ def test_your_own_mail_plugin_wins_over_the_bundled_one(tmp_path: Path) -> None:
assert set(always) == {"gmail", "outlook"}
def test_a_plugin_saved_with_a_byte_order_mark_is_read(tmp_path: Path) -> None:
(tmp_path / "plugins").mkdir()
(tmp_path / "plugins" / "mysite.md").write_bytes(b"\xef\xbb\xbf---\nhosts: [my.example]\n---\nMine.")
assert plugin_for("https://my.example/1", load_plugins(tmp_path)).instructions == "Mine."
def test_a_site_no_plugin_knows_has_no_plugin() -> None:
assert plugin_for("https://nobody.example/1", load_plugins()) is None
+133 -1
View File
@@ -11,16 +11,19 @@ from types import SimpleNamespace
from typing import Any
import duckdb
import httpx
import pytest
from click.testing import CliRunner
from openai import APIConnectionError, APITimeoutError, AuthenticationError, InternalServerError, RateLimitError
from pypdf import PdfWriter
from textual.widgets import DataTable, Input
from textual.widgets import DataTable, Input, RichLog, Static
from aihawk import apply as apply_module
from aihawk.apply import Outcome
from aihawk.cli import main as cli_main
from aihawk.config import ConfigError, load_config
from aihawk.database import Job
from aihawk.llm import explain
from aihawk.profile import add_answers, answer_question, install_resume, read_answers, read_resume
from aihawk.store import Proof, Stored
from aihawk.tui import AIHawk, JobDetails, SendBatch, SentDetails, SentList, SignIn, TextPrompt
@@ -457,6 +460,21 @@ def test_a_text_resume_is_read(tmp_path: Path, name: str) -> None:
assert read_resume(path).startswith("# Ada")
def test_a_resume_and_the_files_beside_it_saved_with_a_byte_order_mark_are_read(home: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
# Windows editors write a byte-order mark at the start of a text file: it is not part of what the file says.
bom = b"\xef\xbb\xbf"
path = tmp_path / "cv.txt"
path.write_bytes(bom + CV_TEXT.encode("utf-8"))
assert read_resume(path).startswith("# Ada")
(home / "answers.yaml").write_bytes(bom + b"email: [email protected]\n")
(home / "aihawk.yaml").write_bytes(bom + f"database: {published(tmp_path)}\n".encode())
monkeypatch.delenv("ORCAROUTER_API_KEY")
(home / "key").write_bytes(bom + b"sk-orca-typed\n")
config = load_config(home)
assert read_answers(config) == {"email": "[email protected]"}
assert config.api_key() == "sk-orca-typed"
def test_a_pdf_with_no_text_says_what_to_do(tmp_path: Path) -> None:
path = tmp_path / "scan.pdf"
writer = PdfWriter()
@@ -590,3 +608,117 @@ def test_the_sign_in_key_is_refused_while_an_application_runs(home: Path, resume
await wait_for_apply(pilot, app)
asyncio.run(run())
REQUEST = httpx.Request("POST", "https://api.orcarouter.ai/v1/chat/completions")
def status_error(kind: type, code: int) -> Exception:
return kind(f"Error code: {code}", response=httpx.Response(code, request=REQUEST), body=None)
class RefusingModel:
"""Every call is refused for its key, as OrcaRouter refuses a wrong one."""
def __init__(self) -> None:
self.chat = SimpleNamespace(completions=SimpleNamespace(create=self._create))
async def _create(self, **request: Any) -> Any:
raise status_error(AuthenticationError, 401)
def log_text(app: AIHawk) -> str:
return "\n".join(line.text for line in app.query_one(RichLog).lines)
def test_a_key_from_the_file_that_orcarouter_refuses_is_forgotten_and_asked_for_again(home: Path, resume: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("ORCAROUTER_API_KEY")
(home / "key").write_text("sk-orca-wrong", encoding="utf-8")
async def run() -> None:
app = AIHawk(home, resume)
app.session._client = RefusingModel() # type: ignore[assignment]
async with app.run_test(size=(140, 40)) as pilot:
await settle(pilot, app)
assert isinstance(app.screen, TextPrompt)
assert "forgot it" in log_text(app) and "orcarouter.ai" in log_text(app)
assert "sk-orca-wrong" not in log_text(app)
await pilot.press("escape")
asyncio.run(run())
assert not (home / "key").exists()
def test_a_key_from_the_environment_that_orcarouter_refuses_is_named_and_not_asked_for(home: Path, resume: Path) -> None:
async def run() -> None:
app = AIHawk(home, resume)
app.session._client = RefusingModel() # type: ignore[assignment]
async with app.run_test(size=(140, 40)) as pilot:
await settle(pilot, app)
assert not isinstance(app.screen, TextPrompt)
assert "ORCAROUTER_API_KEY variable" in log_text(app)
assert "test-key" not in log_text(app)
asyncio.run(run())
@pytest.mark.parametrize(
("error", "words"),
[
(APIConnectionError(request=REQUEST), "cannot be reached"),
(APITimeoutError(request=REQUEST), "did not answer in time"),
(status_error(RateLimitError, 429), "slow down"),
(status_error(InternalServerError, 503), "failed on its side"),
],
)
def test_a_failed_model_call_says_in_plain_words_what_happened_and_keeps_the_detail(error: Exception, words: str) -> None:
text = explain(error)
assert words in text
assert text.endswith(f"({error})")
def test_ctrl_c_at_the_key_prompt_leaves_and_the_prompt_says_how(home: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("ORCAROUTER_API_KEY")
async def run() -> None:
app = AIHawk(home, None)
async with app.run_test(size=(80, 24)) as pilot:
await settle(pilot, app)
assert isinstance(app.screen, TextPrompt)
assert "Ctrl+C quits" in str(app.screen.query_one(".hint", Static).content)
await pilot.press("ctrl+c")
await pilot.pause(0.1)
assert not app.is_running
asyncio.run(run())
def test_leaving_while_a_prompt_is_open_raises_nothing(home: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("ORCAROUTER_API_KEY")
async def run() -> None:
app = AIHawk(home, None)
async with app.run_test(size=(80, 24)) as pilot:
await settle(pilot, app)
assert isinstance(app.screen, TextPrompt)
asyncio.run(run())
def test_at_80_columns_a_message_wraps_whole_and_quit_is_on_the_footer(home: Path, tmp_path: Path) -> None:
scan = tmp_path / "scanned.pdf"
writer = PdfWriter()
writer.add_blank_page(100, 100)
with scan.open("wb") as out:
writer.write(out)
async def run() -> None:
app = scripted_app(home, scan)
async with app.run_test(size=(80, 24)) as pilot:
await settle(pilot, app)
await pilot.pause(0.3) # settle waits for the flow, not for the first paint of the footer
assert "exported with text, or saved as .docx." in " ".join(line.text.strip() for line in app.query_one(RichLog).lines)
rows = ["".join(segment.text for segment in strip) for strip in app.screen._compositor.render_strips()]
assert any("q Quit" in row for row in rows)
asyncio.run(run())