mirror of
https://github.com/feder-cr/Jobs_Applier_AI_Agent.git
synced 2026-11-07 10:36:05 -10:00
Judge with Jev through OrcaRouter only: drop the local download, the process and the fallback
This commit is contained in:
1 parent
5c676d8151
commit
1a837c5c72
12 files changed
+133
-441
No files matched your search
@@ -5,11 +5,6 @@ src/aihawk/documents.py
|
||||
judged apart from the score) is adapted from career-ops (https://github.com/santifer/career-ops),
|
||||
MIT License, Copyright (c) 2026 Santiago Fernandez de Valderrama. No file was copied.
|
||||
|
||||
src/aihawk/judge.py
|
||||
jevos, the local judge of a posting's fit, is downloaded at first use from its release and never copied here.
|
||||
Its binary and model are MIT License, Copyright (c) 2026 Loris Salsi, Federico Elia, built by Federico Elia
|
||||
(https://github.com/feder-cr/jev), with OpenVINO's libraries under their own licences, shipped in its archive.
|
||||
|
||||
src/aihawk/profile.py, src/aihawk/database.py
|
||||
The search plan's rules and the rarity weight follow the planner of the LLDB engine by the same author.
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ The first time, it asks for an [OrcaRouter](https://orcarouter.ai) key and keeps
|
||||
|
||||
- **Reads** your resume and writes a search plan from it, and takes your contact details.
|
||||
- **Finds** postings in a job database published as a release: `jobs-latest` points at a build with 10,223,753 postings (built once by hand on 2026-11-04). The daily refresh starts once the workflow is merged to main. Postings older than 60 days are left out by default.
|
||||
- **Judges** the postings that fit best with [jevos](https://github.com/feder-cr/jev), a model that runs on your computer. The first run downloads it, about 650 MB, once.
|
||||
- **Judges** the postings that fit best, six questions each, with Jev through OrcaRouter.
|
||||
- **Scores** each 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.
|
||||
|
||||
@@ -58,7 +58,7 @@ A plugin is a Markdown file in [`src/aihawk/plugins/`](src/aihawk/plugins): a pr
|
||||
|
||||
The search plan is an editable graph in `filters.plan` of `~/.aihawk/aihawk.yaml`; restart `aihawk` after you change it.
|
||||
|
||||
Models go through OrcaRouter: `model` (default `z-ai/glm-5.3-flash`) writes the CV and fills forms; `quick_model` (default `google/gemini-3.5-flash`) writes the plan; `judge_model` (default `deepseek/deepseek-v4.1-flash`) scores the jobs jevos shortlists. Any OpenAI-compatible endpoint with tool calling and structured output can replace OrcaRouter through `base_url`.
|
||||
Models go through OrcaRouter: `model` (default `z-ai/glm-5.3-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`) shortlists the jobs; `judge_model` (default `deepseek/deepseek-v4.1-flash`) scores them. Any OpenAI-compatible endpoint with tool calling and structured output can replace OrcaRouter through `base_url`.
|
||||
|
||||
## Thanks
|
||||
|
||||
|
||||
@@ -41,10 +41,8 @@ packages = ["src/aihawk"]
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["tests"]
|
||||
addopts = "-m 'not jevos'"
|
||||
markers = [
|
||||
"browser: runs the real invisible-playwright-mcp and its Firefox",
|
||||
"jevos: runs the real jev binary from AIHAWK_JEVOS_FOLDER; deselected by default",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
|
||||
@@ -17,8 +17,11 @@ DEFAULT_MODEL = "z-ai/glm-5.3-flash"
|
||||
# For the plan and the other short calls of a search. Measured on OrcaRouter on five CVs: 10 s a plan, with valid graphs, where
|
||||
# deepseek-v4.1-flash broke the nested schema and glm-5.3-flash took minutes.
|
||||
DEFAULT_QUICK_MODEL = "google/gemini-3.5-flash"
|
||||
# Judges the postings the funnel picks. Measured against the title's fit on five people's searches: see judge.py.
|
||||
# Scores the postings the funnel picks. Measured against the title's fit on five people's searches: see judge.py.
|
||||
DEFAULT_JUDGE_MODEL = "deepseek/deepseek-v4.1-flash"
|
||||
# Answers the six questions that shortlist the postings. Pinned, unlike the other models: the thresholds of judge.evidence() were measured
|
||||
# on this model's answers, and another model answers differently, so a change needs those thresholds measured 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
|
||||
@@ -85,6 +88,7 @@ class Config:
|
||||
model: str = DEFAULT_MODEL
|
||||
quick_model: str = DEFAULT_QUICK_MODEL
|
||||
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
|
||||
@@ -231,6 +235,7 @@ def load_config(workspace: Path) -> Config:
|
||||
model=str(raw.get("model") or DEFAULT_MODEL),
|
||||
quick_model=str(raw.get("quick_model") or DEFAULT_QUICK_MODEL),
|
||||
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),
|
||||
|
||||
+9
-207
@@ -1,160 +1,17 @@
|
||||
"""jevos, the local judge of a posting's fit: six closed questions answered on the person's computer, or by the cloud model when it cannot run there."""
|
||||
"""The decision model's view of a posting's fit: six closed questions answered by Jev through OrcaRouter, as log-odds of evidence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import platform
|
||||
import socket
|
||||
import statistics
|
||||
import subprocess
|
||||
import tarfile
|
||||
import time
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from aihawk.config import Config
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from aihawk.database import Job
|
||||
|
||||
Log = Callable[[str], None]
|
||||
|
||||
# Pinned, unlike the owner's browser library: the thresholds of evidence() belong to one model's answers, and a new model answers differently.
|
||||
VERSION = "jevos-v5"
|
||||
RELEASE = f"https://github.com/feder-cr/jev/releases/download/{VERSION}/"
|
||||
MODEL = ("jevos-v5-openvino-int8.zip", "0a1159c31b9c2abc484af9c866cdcfe5279fdf592b368636625c5a795f970acc")
|
||||
# The archive of each system, with its SHA-256 from the release's SHA256SUMS.txt. No other system has a build.
|
||||
ARCHIVES = {
|
||||
("Windows", "AMD64"): ("jev-windows-x64.zip", "6bbe5344aeb3beabf7b3fb50a459f6ba848e852d5ea192d945b97eed7db7f9eb"),
|
||||
("Linux", "x86_64"): ("jev-linux-x64.tar.gz", "1ce77566405514ed2f20d64f8222cf9eba9b714d332f05f6296e0cad84dd7b04"),
|
||||
("Darwin", "arm64"): ("jev-macos-arm64.tar.gz", "ba71df2da07d4bdf47c72771319b266347a0ae4732d0b74c8e9f227b646e7109"),
|
||||
}
|
||||
# The file that says the install is whole: written last, so an interrupted install is started again.
|
||||
READY = "READY"
|
||||
LOCAL_MODEL = "jev-latest"
|
||||
CLOUD_MODEL = "typesafe/jev-1.14"
|
||||
DESCRIPTION_CHARS = 24000
|
||||
START_TIMEOUT = 300
|
||||
MB = 1_000_000
|
||||
|
||||
|
||||
class JudgeError(Exception):
|
||||
"""jevos cannot be installed or started here."""
|
||||
|
||||
|
||||
def current_platform() -> tuple[str, str]:
|
||||
return platform.system(), platform.machine()
|
||||
|
||||
|
||||
def archive_for(system: str, machine: str) -> tuple[str, str] | None:
|
||||
"""The release archive for this system and its SHA-256; None where there is no build."""
|
||||
return ARCHIVES.get((system, machine))
|
||||
|
||||
|
||||
def _fetch(url: str, sha256: str, target: Path, log: Log) -> None:
|
||||
"""Download `url` to `target`. The file exists only once its SHA-256 is the release's: a partial or wrong download is deleted."""
|
||||
partial = target.with_name(target.name + ".part")
|
||||
digest = hashlib.sha256()
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=60) as response, partial.open("wb") as out:
|
||||
total = int(response.headers.get("Content-Length") or 0)
|
||||
log(f"downloading {target.name}, {total // MB} MB")
|
||||
done, shown = 0, 0
|
||||
for chunk in iter(lambda: response.read(1 << 20), b""):
|
||||
out.write(chunk)
|
||||
digest.update(chunk)
|
||||
done += len(chunk)
|
||||
if total and done * 10 // total > shown:
|
||||
shown = done * 10 // total
|
||||
log(f"{target.name}: {done // MB} of {total // MB} MB")
|
||||
if digest.hexdigest() != sha256:
|
||||
raise JudgeError(f"{target.name} is not the release's file: its SHA-256 differs")
|
||||
partial.replace(target)
|
||||
except (OSError, JudgeError):
|
||||
partial.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def _inside(folder: Path, name: str) -> None:
|
||||
"""An archive's entry must land inside its folder: an entry that would leave it stops the unpacking."""
|
||||
if not (folder / name).resolve().is_relative_to(folder.resolve()):
|
||||
raise JudgeError(f"the archive holds {name!r}, which is outside its folder")
|
||||
|
||||
|
||||
def _unpack(archive: Path, folder: Path) -> None:
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
if archive.name.endswith(".zip"):
|
||||
with zipfile.ZipFile(archive) as zipped:
|
||||
for name in zipped.namelist():
|
||||
_inside(folder, name)
|
||||
zipped.extractall(folder)
|
||||
else:
|
||||
with tarfile.open(archive) as tarred:
|
||||
members = tarred.getmembers()
|
||||
for member in members:
|
||||
_inside(folder, member.name)
|
||||
tarred.extractall(folder, members=members, filter="data")
|
||||
|
||||
|
||||
def install(root: Path, system: str, machine: str, log: Log) -> Path:
|
||||
"""jevos's program and model in `root`, downloaded once and each checked before it is unpacked. Returns the binary."""
|
||||
binary = root / "jev" / ("jev.exe" if system == "Windows" else "jev")
|
||||
if (root / READY).is_file():
|
||||
return binary
|
||||
build = archive_for(system, machine)
|
||||
if build is None:
|
||||
raise JudgeError(f"there is no build for {system} {machine}")
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
# The model goes beside the binary, as `jev serve` finds it there.
|
||||
for name, sha256, folder in ((*build, root), (*MODEL, root / "jev")):
|
||||
archive = root / name
|
||||
_fetch(RELEASE + name, sha256, archive, log)
|
||||
_unpack(archive, folder)
|
||||
archive.unlink()
|
||||
(root / READY).write_text(VERSION, encoding="utf-8")
|
||||
return binary
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return sock.getsockname()[1]
|
||||
|
||||
|
||||
def _healthy(port: int) -> bool:
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=2) as response:
|
||||
return json.load(response).get("status") == "ready"
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def serve(binary: Path, log_file: Path) -> tuple[subprocess.Popen[bytes], int]:
|
||||
"""`jev serve` on a free port, from the binary's own folder, until it answers /health. Its output goes to `log_file`."""
|
||||
port = _free_port()
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with log_file.open("ab") as output:
|
||||
process = subprocess.Popen(
|
||||
[str(binary), "serve", "--port", str(port)],
|
||||
cwd=binary.parent,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=output,
|
||||
stderr=subprocess.STDOUT,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
deadline = time.monotonic() + START_TIMEOUT
|
||||
while not _healthy(port):
|
||||
if process.poll() is not None:
|
||||
raise JudgeError(f"it stopped with code {process.returncode}; see {log_file}")
|
||||
if time.monotonic() > deadline:
|
||||
process.kill()
|
||||
raise JudgeError(f"it was not ready in {START_TIMEOUT} s")
|
||||
time.sleep(0.5)
|
||||
return process, port
|
||||
DECISION_ENDPOINT = "/systemone"
|
||||
|
||||
|
||||
def logit(p: float) -> float:
|
||||
@@ -177,17 +34,12 @@ def questions(goals: str, places: str) -> dict[str, tuple[str, int]]:
|
||||
}
|
||||
|
||||
|
||||
def _post(url: str, body: dict[str, Any], headers: dict[str, str]) -> dict[str, Any]:
|
||||
request = urllib.request.Request(url, json.dumps(body).encode(), {"Content-Type": "application/json", **headers})
|
||||
with urllib.request.urlopen(request, timeout=180) as response:
|
||||
return json.loads(response.read())
|
||||
|
||||
|
||||
def evidence(url: str, model: str, headers: dict[str, str], cv: str, job: Job, asked: dict[str, tuple[str, int]]) -> float:
|
||||
"""The posting's evidence: each answer's log-odds, signed as its question says, added up. A failed call raises: the caller counts it."""
|
||||
async def evidence(client: AsyncOpenAI, model: str, cv: str, job: Job, asked: dict[str, tuple[str, int]]) -> float:
|
||||
"""The posting's evidence: each answer's log-odds, signed as its question says, added up. The client retries what can be retried and
|
||||
raises the rest; the caller counts a failed verdict, so it is never taken for no opinion."""
|
||||
state = {"candidate_cv": cv, "job_posting": f"{job.title} at {job.company}. Location: {job.location}\n\n{job.description[:DESCRIPTION_CHARS]}"}
|
||||
body = {"model": model, "state": state, "questions": {name: {"type": "noul", "instructions": text} for name, (text, _) in asked.items()}}
|
||||
answers = _post(url, body, headers)["answers"]
|
||||
body: dict[str, Any] = {"model": model, "state": state, "questions": {name: {"type": "noul", "instructions": text} for name, (text, _) in asked.items()}}
|
||||
answers = (await client.post(DECISION_ENDPOINT, body=body, cast_to=dict))["answers"]
|
||||
return sum(sign * logit(answers[name]["noul"]) for name, (_, sign) in asked.items())
|
||||
|
||||
|
||||
@@ -197,53 +49,3 @@ def fuse(lexical: list[float], evidence_of: list[float]) -> list[float]:
|
||||
return []
|
||||
mean, spread = statistics.mean(lexical), statistics.pstdev(lexical) or 1.0
|
||||
return [(value - mean) / spread + added for value, added in zip(lexical, evidence_of, strict=True)]
|
||||
|
||||
|
||||
class Judge:
|
||||
"""jevos for the evidence of each posting, or the cloud model when it cannot run here. It says once which one is in use."""
|
||||
|
||||
def __init__(self, config: Config) -> None:
|
||||
self.config = config
|
||||
self.process: subprocess.Popen[bytes] | None = None
|
||||
self.url: str | None = None
|
||||
self.model = ""
|
||||
self.headers: dict[str, str] = {}
|
||||
|
||||
async def start(self, log: Log, status: Log) -> None:
|
||||
root = self.config.workspace / "jevos"
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
system, machine = current_platform()
|
||||
# Said only when a download will really run: a system with no build, or an install already there, downloads nothing.
|
||||
if not (root / READY).is_file() and archive_for(system, machine):
|
||||
status("downloading jevos, about 650 MB, once")
|
||||
binary = await asyncio.to_thread(install, root, system, machine, lambda line: loop.call_soon_threadsafe(log, line))
|
||||
status("starting jevos")
|
||||
self.process, port = await asyncio.to_thread(serve, binary, root / "serve.log")
|
||||
except Exception as error: # noqa: BLE001 Whatever keeps jevos from running, the search goes on with the cloud model.
|
||||
self._cloud(log, str(error) or type(error).__name__)
|
||||
return
|
||||
self.url, self.model = f"http://127.0.0.1:{port}/v1/systemone", LOCAL_MODEL
|
||||
log("judging with jevos, on this computer")
|
||||
|
||||
def _cloud(self, log: Log, reason: str) -> None:
|
||||
key = self.config.api_key()
|
||||
if not key:
|
||||
log(f"jevos is not in use ({reason}), and there is no model key: the title alone decides")
|
||||
return
|
||||
self.url, self.model = f"{self.config.base_url}/systemone", CLOUD_MODEL
|
||||
self.headers = {"Authorization": f"Bearer {key}"}
|
||||
log(f"jevos is not in use ({reason}): judging with {CLOUD_MODEL} in the cloud")
|
||||
|
||||
async def read(self, cv: str, job: Job, asked: dict[str, tuple[str, int]]) -> float:
|
||||
if self.url is None:
|
||||
return 0.0
|
||||
return await asyncio.to_thread(evidence, self.url, self.model, self.headers, cv, job, asked)
|
||||
|
||||
def close(self) -> None:
|
||||
if self.process is not None and self.process.poll() is None:
|
||||
self.process.terminate()
|
||||
try:
|
||||
self.process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.process.kill()
|
||||
+8
-17
@@ -15,13 +15,13 @@ from aihawk.apply import sign_in as sign_in_browser
|
||||
from aihawk.config import Config, ConfigError, load_config
|
||||
from aihawk.database import Job, search, stored_description, with_description
|
||||
from aihawk.documents import score_job, tailor_cv, write_cv_pdf
|
||||
from aihawk.judge import Judge, fuse, questions
|
||||
from aihawk.judge import evidence, fuse, questions
|
||||
from aihawk.llm import make_client
|
||||
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
|
||||
|
||||
# The funnel: the next WIDE postings by title are weighed by jevos, and the best JUDGED of them are scored by the judge model.
|
||||
# The funnel: the next WIDE postings by title are weighed by the decision model, and the best JUDGED of them are scored by the judge model.
|
||||
WIDE = 100
|
||||
JUDGED = 25
|
||||
JUDGE_AT_ONCE = 4
|
||||
@@ -39,11 +39,8 @@ class Session:
|
||||
self.plugins: tuple[Plugin, ...] = load_plugins(workspace)
|
||||
self.store = Store(self.config.data_dir / "jobs.db")
|
||||
self._client: AsyncOpenAI | None = None
|
||||
self._judge: Judge | None = None
|
||||
|
||||
def close(self) -> None:
|
||||
if self._judge is not None:
|
||||
self._judge.close()
|
||||
self.store.close()
|
||||
|
||||
@property
|
||||
@@ -85,7 +82,7 @@ class Session:
|
||||
return new
|
||||
|
||||
async def funnel(self, log: Log, status: Log) -> int:
|
||||
"""Weigh the next WIDE postings by title with jevos, and shortlist the best JUDGED by the title's fit and that evidence.
|
||||
"""Weigh the next WIDE postings by title with the decision model, and shortlist the best JUDGED by the title's fit and that evidence.
|
||||
The others of the batch are passed over: kept, and listed below the judged ones. Returns how many are shortlisted."""
|
||||
batch = self.store.with_status("new")[:WIDE]
|
||||
if not batch:
|
||||
@@ -94,7 +91,6 @@ 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)]
|
||||
judge = await self._judge_for(log, status)
|
||||
cv = self.config.cv.read_text(encoding="utf-8")
|
||||
asked = questions(self.config.goals, _places(self.config))
|
||||
limit = asyncio.Semaphore(JUDGE_AT_ONCE)
|
||||
@@ -104,17 +100,18 @@ class Session:
|
||||
nonlocal done
|
||||
async with limit:
|
||||
try:
|
||||
value = await judge.read(cv, job, asked)
|
||||
except Exception: # noqa: BLE001 One posting jevos cannot read must not stop the others; it is counted below.
|
||||
# The client is read here, so a missing key is one more failed verdict: the title alone decides, and the log says so once.
|
||||
value = await evidence(self.client, self.config.decision_model, cv, job, asked)
|
||||
except Exception: # noqa: BLE001 One posting the model cannot read must not stop the others; it is counted below.
|
||||
value = None
|
||||
done += 1
|
||||
status(f"jevos reads {done}/{len(jobs)}")
|
||||
status(f"Jev reads {done}/{len(jobs)}")
|
||||
return value
|
||||
|
||||
values = await asyncio.gather(*(weigh(job) for job in jobs))
|
||||
failed = sum(value is None for value in values)
|
||||
if failed:
|
||||
log(f"jevos could not read {failed} of {len(jobs)} postings: they are ranked by their title alone")
|
||||
log(f"Jev could not read {failed} of {len(jobs)} postings: they are ranked by their title alone")
|
||||
for job, value in zip(jobs, values, strict=True):
|
||||
if value is not None:
|
||||
self.store.set_evidence(job.id, value)
|
||||
@@ -124,12 +121,6 @@ class Session:
|
||||
self.store.set_statuses([job.id for job in jobs if job.id not in best], "passed_over")
|
||||
return len(best)
|
||||
|
||||
async def _judge_for(self, log: Log, status: Log) -> Judge:
|
||||
if self._judge is None:
|
||||
self._judge = Judge(self.config)
|
||||
await self._judge.start(log, status)
|
||||
return self._judge
|
||||
|
||||
async def score(self, items: list[Stored], on_scored: Callable[[], None], log: Log) -> None:
|
||||
cv = self.config.cv.read_text(encoding="utf-8")
|
||||
limit = asyncio.Semaphore(SCORING_AT_ONCE)
|
||||
|
||||
+1
-1
@@ -91,7 +91,7 @@ class Store:
|
||||
return [self._stored(row) for row in rows]
|
||||
|
||||
def all(self) -> list[Stored]:
|
||||
"""What the person sees, the best first: the judged by the judgment and the title's fit together, then the rest by the title's fit and jevos's evidence."""
|
||||
"""What the person sees, the best first: the judged by the judgment and the title's fit together, then the rest by the title's fit and Jev's evidence."""
|
||||
items = [self._stored(row) for row in self._db.execute("SELECT * FROM jobs").fetchall()]
|
||||
judged = sorted(
|
||||
(item for item in items if item.score is not None),
|
||||
|
||||
+1
-1
@@ -271,7 +271,7 @@ class AIHawk(App[None]):
|
||||
self.refresh_jobs()
|
||||
|
||||
self.show_heading(f"scoring 0/{len(items)}")
|
||||
self.say(f"jevos picked {len(items)} postings to score. Press m for the next batch when they are done.")
|
||||
self.say(f"Jev picked {len(items)} postings to score. Press m for the next batch when they are done.")
|
||||
await session.score(items, scored, self.say)
|
||||
|
||||
# The keys.
|
||||
|
||||
+30
-24
@@ -1,42 +1,47 @@
|
||||
"""The funnel: which postings the title's fit and jevos's evidence shortlist for scoring, batch by batch, and the screen that is gone."""
|
||||
"""The funnel: which postings the title's fit and Jev's evidence shortlist for scoring, batch by batch, and the screen that is gone."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
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
|
||||
from aihawk.database import Job
|
||||
from aihawk.judge import Judge, fuse
|
||||
from aihawk.judge import fuse
|
||||
from aihawk.session import JUDGED, WIDE, Session
|
||||
|
||||
|
||||
class Scripted(Judge):
|
||||
"""A judge whose evidence per posting is given, as jevos would have said it."""
|
||||
@pytest.fixture(autouse=True)
|
||||
def values(monkeypatch: pytest.MonkeyPatch) -> dict[str, float]:
|
||||
"""The evidence per posting, as the decision model would have said it: none, unless a test gives some."""
|
||||
given: dict[str, float] = {}
|
||||
|
||||
def __init__(self, config: Config, values: dict[str, float]) -> None:
|
||||
super().__init__(config)
|
||||
self.values = values
|
||||
async def scripted(client: Any, model: str, cv: str, job: Job, asked: dict[str, Any]) -> float:
|
||||
return given.get(job.id, 0.0)
|
||||
|
||||
async def read(self, cv: str, job: Job, asked: dict[str, Any]) -> float:
|
||||
return self.values.get(job.id, 0.0)
|
||||
monkeypatch.setattr(session_module, "evidence", scripted)
|
||||
return given
|
||||
|
||||
|
||||
def session_with(tmp_path: Path, count: int, values: dict[str, float] | None = None) -> Session:
|
||||
def session_with(tmp_path: Path, count: int) -> Session:
|
||||
"""`count` postings, the title's fit rising with the number: p000 is the worst, p099 the best."""
|
||||
(tmp_path / "cv.md").write_text("# Ada Rossi\n", encoding="utf-8")
|
||||
session = Session(tmp_path)
|
||||
for number in range(count):
|
||||
session.store.add(Job(f"p{number:03d}", "Firm", f"Engineer {number}", "Rome", "u", "a", "", "", number / count))
|
||||
session._judge = Scripted(session.config, values or {})
|
||||
# The scripted evidence never calls the client, so a placeholder stands in for it.
|
||||
session._client = SimpleNamespace() # type: ignore[assignment]
|
||||
return session
|
||||
|
||||
|
||||
@@ -59,9 +64,10 @@ def test_the_title_alone_shortlists_the_best_judged_postings(tmp_path: Path) ->
|
||||
assert {item.job.id for item in session.store.with_status("passed_over")} == ids(range(75))
|
||||
|
||||
|
||||
def test_evidence_moves_a_posting_into_the_shortlist_and_out_of_it_by_its_sign(tmp_path: Path) -> None:
|
||||
def test_evidence_moves_a_posting_into_the_shortlist_and_out_of_it_by_its_sign(tmp_path: Path, values: dict[str, float]) -> None:
|
||||
# p000 has the worst title and strong evidence for it; p099 the best title and evidence against it.
|
||||
session = session_with(tmp_path, WIDE, {"p000": 10.0, "p001": 10.0, "p002": 10.0, "p003": 10.0, "p004": 10.0, "p099": -10.0})
|
||||
values.update({"p000": 10.0, "p001": 10.0, "p002": 10.0, "p003": 10.0, "p004": 10.0, "p099": -10.0})
|
||||
session = session_with(tmp_path, WIDE)
|
||||
funnel(session)
|
||||
assert shortlisted(session) == ids(range(5)) | ids(range(79, 99))
|
||||
|
||||
@@ -86,23 +92,23 @@ def test_the_passed_over_postings_are_kept_and_listed_below_the_judged(tmp_path:
|
||||
assert set(listed[JUDGED:]) == ids(range(75))
|
||||
|
||||
|
||||
def test_a_failed_verdict_leaves_the_others_as_they_were(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def fake_post(url: str, body: dict[str, Any], headers: dict[str, str]) -> dict[str, Any]:
|
||||
def test_a_failed_verdict_is_counted_once_and_the_others_are_as_they_were(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def scripted_endpoint(request: httpx.Request) -> httpx.Response:
|
||||
body = json.loads(request.content)
|
||||
if "Engineer 7 at" in body["state"]["job_posting"]:
|
||||
raise OSError("HTTP 500")
|
||||
return {"answers": {name: {"type": "noul", "noul": 0.9} for name in body["questions"]}}
|
||||
return httpx.Response(400, json={"error": "the request is wrong"})
|
||||
return httpx.Response(200, json={"answers": {name: {"type": "noul", "noul": 0.9} for name in body["questions"]}})
|
||||
|
||||
monkeypatch.setattr(judge_module, "_post", fake_post)
|
||||
# The real evidence, over a scripted endpoint: a refused posting is one failed verdict, not a crash.
|
||||
monkeypatch.setattr(session_module, "evidence", judge_module.evidence)
|
||||
session = session_with(tmp_path, WIDE)
|
||||
live = Judge(session.config)
|
||||
live.url, live.model = "http://127.0.0.1:1/v1/systemone", "jev-latest"
|
||||
session._judge = live
|
||||
session._client = AsyncOpenAI(base_url="https://api.orcarouter.ai/v1", api_key="sk-test", max_retries=0, http_client=httpx.AsyncClient(transport=httpx.MockTransport(scripted_endpoint)))
|
||||
said: list[str] = []
|
||||
asyncio.run(session.funnel(said.append, lambda line: None))
|
||||
evidence = {item.job.id: item.evidence for item in session.store.all()}
|
||||
assert evidence["p007"] is None
|
||||
assert evidence["p008"] == pytest.approx(2 * math.log(9))
|
||||
assert said == ["jevos could not read 1 of 100 postings: they are ranked by their title alone"]
|
||||
assert said == ["Jev could not read 1 of 100 postings: they are ranked by their title alone"]
|
||||
assert len(session.store.with_status("shortlisted")) == JUDGED
|
||||
|
||||
|
||||
@@ -111,7 +117,7 @@ def test_the_funnel_says_how_many_descriptions_and_postings_it_has_read(tmp_path
|
||||
heard: list[str] = []
|
||||
asyncio.run(session.funnel(lambda line: None, heard.append))
|
||||
assert heard[0] == "reading descriptions 1/30" and "reading descriptions 30/30" in heard
|
||||
assert heard[-1] == "jevos reads 30/30"
|
||||
assert heard[-1] == "Jev reads 30/30"
|
||||
|
||||
|
||||
def test_a_scan_that_logs_from_its_thread_says_it_on_the_loop(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
||||
+1
-1
@@ -312,7 +312,7 @@ def test_only_what_is_good_enough_and_not_sent_is_applied_to_best_first(tmp_path
|
||||
assert [item.job.id for item in store.to_apply(4.0, 1)] == ["j2"]
|
||||
|
||||
|
||||
def test_the_list_weighs_the_judgment_and_the_title_together_and_keeps_what_jevos_passed_over_below(tmp_path: Path) -> None:
|
||||
def test_the_list_weighs_the_judgment_and_the_title_together_and_keeps_what_jev_passed_over_below(tmp_path: Path) -> None:
|
||||
store = Store(tmp_path / "jobs.db")
|
||||
for name, lexical in (("judged-fit", 0.2), ("same-score-better-title", 1.0), ("unjudged", 0.9), ("passed", 1.0)):
|
||||
store.add(Job(name, "A", "T", "L", "u", "a", "d", "", lexical))
|
||||
|
||||
+71
-172
@@ -1,168 +1,60 @@
|
||||
"""jevos: its download and checksum, its archives, its fallback to the cloud model and its evidence. No network: the fakes stand in."""
|
||||
"""Jev's decision: the request's exact shape, its gate and signs, the client's retries and the failed verdicts. No network: a scripted endpoint stands in."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import tarfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Self
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from openai import AsyncOpenAI, BadRequestError
|
||||
|
||||
from aihawk import judge as judge_module
|
||||
from aihawk.config import load_config
|
||||
from aihawk.config import DEFAULT_BASE_URL, load_config
|
||||
from aihawk.database import Job
|
||||
from aihawk.judge import Judge, JudgeError, archive_for, evidence, logit, questions
|
||||
from aihawk.judge import evidence, logit, questions
|
||||
|
||||
KEY = "sk-test-key"
|
||||
JOB = Job("g:a:1", "Acme", "Python Engineer", "Rome", "u", "a", "Build Python services.")
|
||||
ASKED = questions("Senior Python work.", "Rome, Italy")
|
||||
ANSWERS = {"role": 0.9, "must_haves": 0.6, "gap": 0.2, "level": 0.5, "location": 0.8, "elsewhere": 0.1}
|
||||
|
||||
|
||||
class Download:
|
||||
"""A download as urllib gives it: its pieces in turn, then the error it stops with, if any."""
|
||||
|
||||
def __init__(self, pieces: list[bytes], then: Exception | None = None) -> None:
|
||||
self.headers = {"Content-Length": str(sum(map(len, pieces)))}
|
||||
self.pieces, self.then = list(pieces), then
|
||||
|
||||
def read(self, size: int = -1) -> bytes:
|
||||
if self.pieces:
|
||||
return self.pieces.pop(0)
|
||||
if self.then is not None:
|
||||
raise self.then
|
||||
return b""
|
||||
|
||||
def __enter__(self) -> Self:
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc: object) -> None:
|
||||
return None
|
||||
def client_with(handler: Any) -> AsyncOpenAI:
|
||||
"""The client the session makes, with the endpoint behind it: the same retries and timeout as llm.make_client."""
|
||||
return AsyncOpenAI(base_url=DEFAULT_BASE_URL, api_key=KEY, max_retries=5, http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
|
||||
|
||||
|
||||
def serve_downloads(monkeypatch: pytest.MonkeyPatch, download: Download) -> None:
|
||||
monkeypatch.setattr(judge_module.urllib.request, "urlopen", lambda url, timeout: download)
|
||||
def answering(probabilities: dict[str, float], seen: list[httpx.Request]) -> Any:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request)
|
||||
body = json.loads(request.content)
|
||||
return httpx.Response(200, json={"answers": {name: {"type": "noul", "noul": probabilities[name]} for name in body["questions"]}})
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
def test_a_download_that_does_not_match_its_checksum_is_deleted_and_refused(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
serve_downloads(monkeypatch, Download([b"tampered"]))
|
||||
target = tmp_path / "jev.zip"
|
||||
with pytest.raises(JudgeError, match="SHA-256"):
|
||||
judge_module._fetch("https://example.invalid/jev.zip", hashlib.sha256(b"genuine").hexdigest(), target, lambda line: None)
|
||||
assert not target.exists()
|
||||
assert not target.with_name("jev.zip.part").exists()
|
||||
def test_the_request_goes_to_the_decision_endpoint_with_the_key_and_the_six_statements() -> None:
|
||||
seen: list[httpx.Request] = []
|
||||
asyncio.run(evidence(client_with(answering(ANSWERS, seen)), "typesafe/jev-1.14", "cv", JOB, ASKED))
|
||||
(request,) = seen
|
||||
assert str(request.url) == "https://api.orcarouter.ai/v1/systemone"
|
||||
assert request.method == "POST"
|
||||
assert request.headers["authorization"] == "Bearer " + KEY
|
||||
body = json.loads(request.content)
|
||||
assert body["model"] == "typesafe/jev-1.14"
|
||||
assert body["state"]["candidate_cv"] == "cv"
|
||||
assert body["state"]["job_posting"].startswith("Python Engineer at Acme. Location: Rome\n\nBuild Python services.")
|
||||
assert body["questions"] == {name: {"type": "noul", "instructions": text} for name, (text, _) in ASKED.items()}
|
||||
assert len(body["questions"]) == 6
|
||||
|
||||
|
||||
def test_a_partial_download_never_counts_as_the_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
serve_downloads(monkeypatch, Download([b"first half"], then=OSError("connection reset")))
|
||||
target = tmp_path / "jev.zip"
|
||||
with pytest.raises(OSError, match="reset"):
|
||||
judge_module._fetch("https://example.invalid/jev.zip", hashlib.sha256(b"first half and the rest").hexdigest(), target, lambda line: None)
|
||||
assert not target.exists()
|
||||
assert not target.with_name("jev.zip.part").exists()
|
||||
|
||||
|
||||
def test_a_download_with_the_release_checksum_becomes_the_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
serve_downloads(monkeypatch, Download([b"genu", b"ine"]))
|
||||
target = tmp_path / "jev.zip"
|
||||
judge_module._fetch("https://example.invalid/jev.zip", hashlib.sha256(b"genuine").hexdigest(), target, lambda line: None)
|
||||
assert target.read_bytes() == b"genuine"
|
||||
assert not target.with_name("jev.zip.part").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["zip", "tar"])
|
||||
def test_an_archive_with_an_entry_outside_its_folder_is_refused(tmp_path: Path, kind: str) -> None:
|
||||
archive = tmp_path / ("bad.zip" if kind == "zip" else "bad.tar.gz")
|
||||
if kind == "zip":
|
||||
with zipfile.ZipFile(archive, "w") as zipped:
|
||||
zipped.writestr("../escape.txt", "x")
|
||||
else:
|
||||
with tarfile.open(archive, "w:gz") as tarred:
|
||||
info = tarfile.TarInfo("../escape.txt")
|
||||
info.size = 1
|
||||
tarred.addfile(info, io.BytesIO(b"x"))
|
||||
with pytest.raises(JudgeError, match="outside"):
|
||||
judge_module._unpack(archive, tmp_path / "jevos")
|
||||
assert not (tmp_path / "escape.txt").exists()
|
||||
|
||||
|
||||
def test_an_archive_inside_its_folder_is_unpacked(tmp_path: Path) -> None:
|
||||
archive = tmp_path / "jev.zip"
|
||||
with zipfile.ZipFile(archive, "w") as zipped:
|
||||
zipped.writestr("jev/jev.exe", "binary")
|
||||
judge_module._unpack(archive, tmp_path / "jevos")
|
||||
assert (tmp_path / "jevos" / "jev" / "jev.exe").read_text(encoding="utf-8") == "binary"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("system", "machine", "archive"),
|
||||
[
|
||||
("Windows", "AMD64", "jev-windows-x64.zip"),
|
||||
("Linux", "x86_64", "jev-linux-x64.tar.gz"),
|
||||
("Darwin", "arm64", "jev-macos-arm64.tar.gz"),
|
||||
("Darwin", "x86_64", None),
|
||||
("Windows", "ARM64", None),
|
||||
],
|
||||
)
|
||||
def test_each_system_gets_its_own_archive_and_one_without_a_build_gets_none(system: str, machine: str, archive: str | None) -> None:
|
||||
build = archive_for(system, machine)
|
||||
assert (build[0] if build else None) == archive
|
||||
assert build is None or len(build[1]) == 64
|
||||
|
||||
|
||||
def test_a_system_with_no_build_uses_the_cloud_model_and_says_so_once(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("ORCAROUTER_API_KEY", "sk-test")
|
||||
monkeypatch.setattr(judge_module, "current_platform", lambda: ("Darwin", "x86_64"))
|
||||
judge = Judge(load_config(tmp_path))
|
||||
lines: list[str] = []
|
||||
statuses: list[str] = []
|
||||
asyncio.run(judge.start(lines.append, statuses.append))
|
||||
assert judge.url == "https://api.orcarouter.ai/v1/systemone"
|
||||
assert (judge.model, judge.headers) == ("typesafe/jev-1.14", {"Authorization": "Bearer sk-test"})
|
||||
assert len(lines) == 1 and "typesafe/jev-1.14" in lines[0]
|
||||
# No build means no download and no start: neither is said.
|
||||
assert statuses == []
|
||||
|
||||
|
||||
def test_a_wrong_download_is_never_used_and_the_cloud_model_takes_over(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("ORCAROUTER_API_KEY", "sk-test")
|
||||
monkeypatch.setattr(judge_module, "current_platform", lambda: ("Linux", "x86_64"))
|
||||
serve_downloads(monkeypatch, Download([b"tampered"]))
|
||||
judge = Judge(load_config(tmp_path))
|
||||
lines: list[str] = []
|
||||
statuses: list[str] = []
|
||||
asyncio.run(judge.start(lines.append, statuses.append))
|
||||
assert judge.model == "typesafe/jev-1.14" and "SHA-256" in lines[-1]
|
||||
# A download was tried, so it is said; jevos never started, so that is not.
|
||||
assert statuses == ["downloading jevos, about 650 MB, once"]
|
||||
assert not (tmp_path / "jevos" / "jev-linux-x64.tar.gz.part").exists()
|
||||
assert not (tmp_path / "jevos" / "READY").exists()
|
||||
|
||||
|
||||
def test_without_jevos_or_a_key_the_evidence_is_zero(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("ORCAROUTER_API_KEY", raising=False)
|
||||
monkeypatch.setattr(judge_module, "current_platform", lambda: ("Darwin", "x86_64"))
|
||||
judge = Judge(load_config(tmp_path))
|
||||
asyncio.run(judge.start(lambda line: None, lambda text: None))
|
||||
assert asyncio.run(judge.read("cv", JOB, ASKED)) == 0.0
|
||||
|
||||
|
||||
def test_the_answers_are_gated_and_signed_as_their_questions_say(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
answers = {"role": 0.9, "must_haves": 0.6, "gap": 0.2, "level": 0.5, "location": 0.8, "elsewhere": 0.1}
|
||||
sent: dict[str, Any] = {}
|
||||
|
||||
def fake_post(url: str, body: dict[str, Any], headers: dict[str, str]) -> dict[str, Any]:
|
||||
sent.update(body)
|
||||
return {"answers": {name: {"type": "noul", "noul": probability} for name, probability in answers.items()}}
|
||||
|
||||
monkeypatch.setattr(judge_module, "_post", fake_post)
|
||||
def test_the_answers_are_gated_and_signed_as_their_questions_say() -> None:
|
||||
# gap and elsewhere count against the job, so their low answers are support; 0.6 and 0.5 fall inside the gate and count for nothing.
|
||||
assert evidence("http://x/v1/systemone", "jev-latest", {}, "cv", JOB, ASKED) == pytest.approx(2 * math.log(9) + 2 * math.log(4))
|
||||
assert set(sent["questions"]) == set(ASKED)
|
||||
value = asyncio.run(evidence(client_with(answering(ANSWERS, [])), "typesafe/jev-1.14", "cv", JOB, ASKED))
|
||||
assert value == pytest.approx(2 * math.log(9) + 2 * math.log(4))
|
||||
|
||||
|
||||
def test_the_log_odds_are_clamped_and_the_middle_is_no_evidence() -> None:
|
||||
@@ -172,36 +64,43 @@ def test_the_log_odds_are_clamped_and_the_middle_is_no_evidence() -> None:
|
||||
assert logit(0.0) == pytest.approx(-math.log(9999))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", [OSError("HTTP 500"), ValueError("not JSON")])
|
||||
def test_a_failed_verdict_raises_so_it_is_never_taken_for_no_opinion(monkeypatch: pytest.MonkeyPatch, failure: Exception) -> None:
|
||||
def fake_post(url: str, body: dict[str, Any], headers: dict[str, str]) -> dict[str, Any]:
|
||||
if "Broken" in body["state"]["job_posting"]:
|
||||
raise failure
|
||||
return {"answers": {name: {"type": "noul", "noul": 0.9} for name in ASKED}}
|
||||
def test_a_retryable_failure_is_retried_by_the_client_and_then_answers() -> None:
|
||||
seen: list[httpx.Request] = []
|
||||
answer = answering({name: 0.9 for name in ASKED}, seen)
|
||||
|
||||
monkeypatch.setattr(judge_module, "_post", fake_post)
|
||||
broken = Job("b", "Acme", "Broken Engineer", "Rome", "u", "a", "")
|
||||
with pytest.raises(type(failure)):
|
||||
evidence("http://x", "jev-latest", {}, "cv", broken, ASKED)
|
||||
def flaky(request: httpx.Request) -> httpx.Response:
|
||||
if not seen:
|
||||
seen.append(request)
|
||||
# A 503 that says to try again at once: the client retries it, as it does for every call the session makes.
|
||||
return httpx.Response(503, headers={"retry-after-ms": "1"}, json={"error": "busy"})
|
||||
return answer(request)
|
||||
|
||||
value = asyncio.run(evidence(client_with(flaky), "typesafe/jev-1.14", "cv", JOB, ASKED))
|
||||
assert len(seen) == 2
|
||||
# Four answers add, two subtract: 0.9 is the same log-odds each time.
|
||||
assert evidence("http://x", "jev-latest", {}, "cv", JOB, ASKED) == pytest.approx(2 * math.log(9))
|
||||
assert value == pytest.approx(2 * math.log(9))
|
||||
|
||||
|
||||
@pytest.mark.jevos
|
||||
def test_the_real_jevos_answers_a_posting(tmp_path: Path) -> None:
|
||||
folder = os.environ.get("AIHAWK_JEVOS_FOLDER")
|
||||
if not folder:
|
||||
pytest.skip("AIHAWK_JEVOS_FOLDER is not set: the folder that holds the jev binary and its model")
|
||||
binary = Path(folder) / ("jev.exe" if os.name == "nt" else "jev")
|
||||
process, port = judge_module.serve(binary, tmp_path / "serve.log")
|
||||
try:
|
||||
body = {
|
||||
"model": "jev-latest",
|
||||
"state": {"candidate_cv": "Python engineer, five years of backend services.", "job_posting": "Python Engineer at Acme. Build Python services."},
|
||||
"questions": {"role": {"type": "noul", "instructions": "Is this the kind of work the candidate does?"}},
|
||||
}
|
||||
probability = judge_module._post(f"http://127.0.0.1:{port}/v1/systemone", body, {})["answers"]["role"]["noul"]
|
||||
assert 0.0 <= probability <= 1.0
|
||||
finally:
|
||||
process.terminate()
|
||||
process.wait(timeout=30)
|
||||
def test_an_error_the_client_does_not_retry_raises_and_never_reads_as_no_opinion() -> None:
|
||||
seen: list[httpx.Request] = []
|
||||
|
||||
def refused(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request)
|
||||
return httpx.Response(400, json={"error": "the request is wrong"})
|
||||
|
||||
with pytest.raises(BadRequestError) as caught:
|
||||
asyncio.run(evidence(client_with(refused), "typesafe/jev-1.14", "cv", JOB, ASKED))
|
||||
assert len(seen) == 1
|
||||
# The key is in the request's header only: no error message carries it.
|
||||
assert KEY not in str(caught.value) and KEY not in repr(caught.value)
|
||||
|
||||
|
||||
def test_an_answer_of_another_shape_raises_and_never_reads_as_no_opinion() -> None:
|
||||
with pytest.raises(KeyError):
|
||||
asyncio.run(evidence(client_with(lambda request: httpx.Response(200, json={"unexpected": 1})), "typesafe/jev-1.14", "cv", JOB, ASKED))
|
||||
|
||||
|
||||
def test_the_decision_model_is_pinned_and_settable_in_the_settings(tmp_path: Path) -> None:
|
||||
assert load_config(tmp_path).decision_model == "typesafe/jev-1.14"
|
||||
(tmp_path / "aihawk.yaml").write_text("decision_model: other/model\n", encoding="utf-8")
|
||||
assert load_config(tmp_path).decision_model == "other/model"
|
||||
+4
-8
@@ -19,7 +19,6 @@ from aihawk import apply as apply_module
|
||||
from aihawk.apply import Outcome
|
||||
from aihawk.config import ConfigError, load_config
|
||||
from aihawk.database import Job
|
||||
from aihawk.judge import Judge
|
||||
from aihawk.profile import add_answers, answer_question, install_resume, read_answers, read_resume
|
||||
from aihawk.store import Stored
|
||||
from aihawk.tui import AIHawk, ConfirmSend, JobDetails, SignIn, TextPrompt
|
||||
@@ -87,8 +86,6 @@ def scripted_app(home: Path, resume: Path | None, **options: Any) -> AIHawk:
|
||||
app = AIHawk(home, resume, **options)
|
||||
model = ScriptedModelForApp()
|
||||
app.session._client = model # noqa: SLF001
|
||||
# No jevos in these tests: a judge with no endpoint gives evidence 0, and nothing is downloaded.
|
||||
app.session._judge = Judge(app.session.config) # noqa: SLF001
|
||||
return app
|
||||
|
||||
|
||||
@@ -96,14 +93,14 @@ class ScriptedModelForApp:
|
||||
"""Answers each kind of request the way a model would."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
from types import SimpleNamespace # noqa: PLC0415
|
||||
|
||||
self.chat = SimpleNamespace(completions=SimpleNamespace(create=self._create))
|
||||
self.requests: list[dict[str, Any]] = []
|
||||
|
||||
async def _create(self, **request: Any) -> Any:
|
||||
from types import SimpleNamespace # noqa: PLC0415
|
||||
async def post(self, path: str, *, body: dict[str, Any], cast_to: type) -> dict[str, Any]:
|
||||
"""The decision endpoint: no opinion on any question, so the evidence is zero and the title alone decides."""
|
||||
return {"answers": {name: {"type": "noul", "noul": 0.5} for name in body["questions"]}}
|
||||
|
||||
async def _create(self, **request: Any) -> Any:
|
||||
self.requests.append(request)
|
||||
name = (request.get("response_format") or {}).get("json_schema", {}).get("name")
|
||||
if name == "search_profile":
|
||||
@@ -183,7 +180,6 @@ def test_no_key_asks_for_one_and_keeps_it_on_this_computer(home: Path, resume: P
|
||||
|
||||
async def run() -> None:
|
||||
app = AIHawk(home, resume)
|
||||
app.session._judge = Judge(app.session.config) # noqa: SLF001 No jevos here: nothing is downloaded.
|
||||
async with app.run_test(size=(140, 40)) as pilot:
|
||||
await settle(pilot, app)
|
||||
assert isinstance(app.screen, TextPrompt)
|
||||
|
||||
Reference in new issue
Block a user