Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .codex/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[mcp_servers.linear-server]
url = "https://mcp.linear.app/mcp"
48 changes: 48 additions & 0 deletions .codex/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "WebSearch|WebFetch",
"hooks": [
{
"type": "command",
"command": "echo '{\"systemMessage\":\"Scope check - what decision does this research change? If there is no answer, it is a bookmark (INE-18), not a task. Current objective: .claude/FOCUS.md\"}'"
}
]
},
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "python3 '/Users/inesarana/screening/.codex/hooks/conventions_reminder.py' 2>/dev/null || true"
}
]
}
],
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "cd \"$CLAUDE_PROJECT_DIR\" 2>/dev/null; python3 -c \"import json,pathlib;print(json.dumps({'hookSpecificOutput':{'hookEventName':'SessionStart','additionalContext':pathlib.Path('.claude/FOCUS.md').read_text()}}))\" 2>/dev/null || true"
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "python3 -c \"import json,time,pathlib;f=pathlib.Path('/tmp/.claude_last_prompt');now=time.time();prev=float(f.read_text()) if f.exists() else now;f.write_text(str(now));g=int(now-prev);print(json.dumps({'hookSpecificOutput':{'hookEventName':'UserPromptSubmit','additionalContext':'[clock] '+time.strftime('%H:%M')+' | '+str(g//60)+'m '+str(g%60)+'s since previous message'}}))\" 2>/dev/null || true"
},
{
"type": "command",
"command": "python3 '/Users/inesarana/screening/.codex/hooks/response_style.py' 2>/dev/null || true"
}
]
}
]
}
}
49 changes: 49 additions & 0 deletions .codex/hooks/conventions_reminder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""PreToolUse hook: restate the project's writing conventions before an edit.

Fires on Write and Edit. Emits nothing for files the conventions do not cover,
so the reminder stays attached to source rather than appearing on every write.
"""

import json
import sys

_EXTENSIONS = (".py", ".yaml", ".yml", ".md")

_REMINDER = (
"screening-conventions (.claude/skills/screening-conventions/SKILL.md):\n"
"- Google-style docstrings: summary line, then Args/Returns/Raises for "
"functions, Attributes for models.\n"
"- State the PROPERTY, not the incident that taught it. No war stories, "
"dates, measurements, or 'we' — those belong in infra/*/README.md or the "
"commit message.\n"
"- Self-contained: never explain one symbol by referring to another.\n"
"- No prose constants: explanation assigned to a module-level string is "
"dead code.\n"
"- app/domain and app/ports stay vendor-free; adapters hold the vendors.\n"
"- Test first: a failing test before the implementation."
)


def main() -> None:
"""Print the reminder when the target file is one the conventions govern."""
try:
payload = json.load(sys.stdin)
except json.JSONDecodeError, ValueError:
return
path = payload.get("tool_input", {}).get("file_path", "")
if not path.endswith(_EXTENSIONS):
return
print(
json.dumps(
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"additionalContext": _REMINDER,
}
}
)
)


if __name__ == "__main__":
main()
34 changes: 34 additions & 0 deletions .codex/hooks/response_style.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""UserPromptSubmit hook: restate how answers to Ines should be written.

Emits on every prompt. Carries the response-shape rules only; the writing
conventions for source files are in conventions_reminder.py.
"""

import json

_REMINDER = (
"Response style:\n"
"- Short. Answer the question asked, then stop.\n"
"- High level first, in plain words. Then the low-level version, so the "
"vocabulary is picked up in context rather than assumed.\n"
"- Define a term the first time it appears, in the same sentence.\n"
"- No jargon without its plain-language equivalent alongside it."
)


def main() -> None:
"""Print the reminder."""
print(
json.dumps(
{
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": _REMINDER,
}
}
)
)


if __name__ == "__main__":
main()
78 changes: 78 additions & 0 deletions app/adapters/detector_readiness.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""Adapter: waiting for the Article 9 detector to start serving.

The detector scales to zero and takes minutes to load its weights. Platform
ingress closes any single request long before that, so readiness is established
by repeating a short request rather than by holding one open.
"""

import time
from collections.abc import Awaitable, Callable
from typing import Any, Protocol


class HttpClientLike(Protocol):
"""The subset of an async HTTP client this module uses."""

async def get(self, url: str) -> Any: ...


def http_probe(client: HttpClientLike, url: str) -> Callable[[], Awaitable[bool]]:
"""Build a readiness probe that calls an endpoint over HTTP.

Args:
client: Issues the request. Injected so the caller owns its lifetime
and its per-request timeout.
url: The endpoint that answers only once the detector is serving.

Returns:
A callable returning True when the endpoint answers 200.
"""

async def probe() -> bool:
return (await client.get(url)).status_code == 200

return probe


async def wait_until_ready(
probe: Callable[[], Awaitable[bool]],
*,
deadline_s: float,
interval_s: float,
sleep: Callable[[float], Awaitable[None]],
now: Callable[[], float] = time.monotonic,
) -> bool:
"""Repeat a readiness probe until it succeeds or the deadline passes.

The deadline covers elapsed time, not time spent sleeping. A probe against
an endpoint that is not listening consumes its own timeout before failing,
so counting only the intervals would let the total reach a multiple of the
deadline.

Args:
probe: Returns True once the detector is serving.
deadline_s: How long to keep trying, in seconds.
interval_s: Seconds between attempts.
sleep: Suspends for the given seconds. Injected so tests need no real
time.
now: Reads a monotonic clock, one that only moves forward and is
unaffected by the system clock being adjusted.

Returns:
True if the detector became ready, False if the deadline passed first.
"""
started = now()
while True:
try:
ready = await probe()
except Exception: # noqa: BLE001 - any failure means "not ready yet"
# The probe is supplied by the caller, so the ways it can fail are
# not knowable here. A detector that has not started refuses the
# connection, which is the expected state while it loads rather
# than a failure to report.
ready = False
if ready:
return True
if now() - started + interval_s > deadline_s:
return False
await sleep(interval_s)
11 changes: 11 additions & 0 deletions app/adapters/job_store_memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,14 @@ async def _settle(
if existing is not None:
settled = settled.model_copy(update={"created_at": existing.created_at})
self._jobs[job_id] = settled

async def fail_if_pending(self, job_id: str, error: str) -> bool:
"""Fail a job only while it is pending. See `JobStore.fail_if_pending`."""
async with self._lock:
existing = self._jobs.get(job_id)
if existing is None or existing.status is not JobStatus.PENDING:
return False
self._jobs[job_id] = Job(
id=job_id, status=JobStatus.FAILED, error=error
).model_copy(update={"created_at": existing.created_at})
return True
38 changes: 37 additions & 1 deletion app/adapters/job_store_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@
from datetime import datetime
from typing import Protocol

from azure.core.exceptions import ResourceNotFoundError
from azure.core import MatchConditions
from azure.core.exceptions import (
ResourceModifiedError,
ResourceNotFoundError,
)
from azure.data.tables import UpdateMode

from app.domain.models import Job, JobStatus, ScreenResult
Expand All @@ -27,6 +31,8 @@ async def upsert_entity(self, entity: dict, **kwargs: object) -> object: ...

async def get_entity(self, partition_key: str, row_key: str) -> dict: ...

async def update_entity(self, entity: dict, **kwargs: object) -> object: ...


def job_to_entity(job: Job) -> dict:
"""Convert a Job into an Azure Table entity.
Expand Down Expand Up @@ -156,3 +162,33 @@ async def _settle(
},
mode=UpdateMode.MERGE,
)

async def fail_if_pending(self, job_id: str, error: str) -> bool:
"""Fail a job only while it is pending. See ``JobStore.fail_if_pending``.

The read supplies the row's etag and the write requires it to be
unchanged, so a completion that lands in between causes the write to be
refused rather than to replace the result.
"""
try:
entity = dict(await self._table.get_entity(job_id, job_id))
except ResourceNotFoundError:
return False
if entity.get("status") != JobStatus.PENDING.value:
return False
try:
await self._table.update_entity(
{
"PartitionKey": job_id,
"RowKey": job_id,
"status": JobStatus.FAILED.value,
"result": "",
"error": error,
},
mode=UpdateMode.MERGE,
etag=entity.get("etag"),
match_condition=MatchConditions.IfNotModified,
)
except ResourceModifiedError:
return False
return True
26 changes: 14 additions & 12 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@ class Settings(BaseSettings):
llm_guardrail_model: Model name to request at that endpoint.
llm_timeout_s: Per-request timeout for the assessment LLM, in seconds.
llm_guardrail_timeout_s: Per-request timeout for the guardrail endpoint.
Deliberately separate and much larger: that endpoint scales to zero,
so the first request after an idle period waits for a GPU to start
and load the model.
Separate from the assessment timeout because that endpoint runs a
larger model, and bounded below the platform's own request limit so
it can actually fire. It does not cover the endpoint starting from
zero; readiness is established by repeated probes before any
screening runs.
jobs_account_url: Table endpoint of the account holding job state.
jobs_queue_url: Queue endpoint of the same account.
jobs_table_name: Table holding one entity per screening job.
Expand All @@ -39,16 +41,16 @@ class Settings(BaseSettings):
llm_guardrail_base_url: str = "http://localhost:8001/v1"
llm_guardrail_model: str = "google/gemma-4-31B-it"
llm_timeout_s: float = 60.0
# 15 minutes, against a measured ~13 minute cold start (2 min image pull,
# 1 min engine init, ~10 min loading 58 GiB of weights off the file share).
# Sharing the 60s assessment timeout meant every request that arrived on a
# cold endpoint timed out, and the recognizer fails closed -- so /screen
# returned 502 on the normal path, not an exceptional one.
# Below the 240 seconds at which ingress severs any single request, internal
# routes included. A larger value cannot fire, so the call would end as a
# transport error from the proxy rather than a timeout naming this endpoint.
#
# A caller waiting 13 minutes is still bad; the real fix is for /screen to
# return 202 and be polled (see infra/gemma/README.md). This makes the
# blocking path correct in the meantime rather than silently broken.
llm_guardrail_timeout_s: float = 900.0
# It does not have to cover the detector's cold start. The worker establishes
# that the endpoint is serving by repeating a short probe before it screens
# anything (see app/worker.py), so this bounds a call to an endpoint already
# known to be up. Larger than the assessment timeout because this endpoint
# runs a 31B model on one replica.
llm_guardrail_timeout_s: float = 200.0
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Job state and the work queue. A separate account from the model-weights
# share: that one is kind=FileStorage, which serves file shares only and has
Expand Down
29 changes: 26 additions & 3 deletions app/domain/models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""The contract for /screen — the Pydantic types every layer depends on."""

import json
from datetime import UTC, datetime
from enum import Enum
from typing import Self
Expand All @@ -22,7 +23,8 @@
# Derived from the 64 KiB ceiling on a queue message, less headroom for the job
# id and the JSON that wraps both fields. The character caps above bound length;
# this bounds size, which is what the ceiling is actually expressed in. A
# character can occupy up to four UTF-8 bytes, so the two are not equivalent.
# character can occupy up to four UTF-8 bytes, and a JSON string expands a
# control character to six, so neither cap implies the other.
MAX_REQUEST_BYTES = 60_000

# How long a job may stay PENDING before it is treated as never going to finish.
Expand All @@ -33,6 +35,20 @@
JOB_DEADLINE_SECONDS = 5 * 60 * 60


def _json_string_bytes(value: str) -> int:
"""Measure a string as it occupies space inside a JSON document.

Args:
value: The text to measure.

Returns:
The UTF-8 byte length of the text escaped as a JSON string, quotes
included. Non-ASCII characters are left as themselves, matching how the
request is published.
"""
return len(json.dumps(value, ensure_ascii=False).encode())


class ScreenRequest(BaseModel):
"""The request body for a screening.

Expand All @@ -58,14 +74,21 @@ class ScreenRequest(BaseModel):
def _fits_in_a_queue_message(self) -> Self:
"""Reject a request too large to publish.

Both fields travel as JSON strings, so the size that counts is the
escaped one: a control character occupies one byte in the field and six
in the message. Measuring the field alone would pass a request that the
transport then rejects, once the job has already been recorded.

Returns:
The request, unchanged.

Raises:
ValueError: If the two fields together exceed MAX_REQUEST_BYTES
once encoded as UTF-8.
once escaped as JSON strings and encoded as UTF-8.
"""
size = len(self.transcript.encode()) + len(self.job_description.encode())
size = _json_string_bytes(self.transcript) + _json_string_bytes(
self.job_description
)
if size > MAX_REQUEST_BYTES:
raise ValueError(
f"encoded request is {size} bytes, over the "
Expand Down
Loading
Loading