Judge with Jev through OrcaRouter only: drop the local download, the process and the fallback

This commit is contained in:
feder-cr committed 2026-11-04 18:25:32 +01:00
1 parent 5c676d8151
commit 1a837c5c72
12 files changed
+133 -441

No files matched your search

-5
View File
@@ -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.
+2 -2
View File
@@ -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
-2
View File
@@ -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]
+6 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)