mirror of
https://github.com/feder-cr/Jobs_Applier_AI_Agent.git
synced 2026-11-07 10:36:05 -10:00
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:
1 parent
d2f93b5850
commit
f7ef2564bb
15 files changed
+292
-56
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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, ...]:
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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 & ship</p><script>evil()</script><li>Python</li></body></html>")
|
||||
assert text.split() == ["Build", "&", "ship", "Python"]
|
||||
|
||||
@@ -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
@@ -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())
|
||||
Reference in new issue
Block a user