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
33 changes: 30 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,11 @@ Shared by every sub-app:
would re-judge leads a human has already decided about, every run.
`unjudgeable` belongs in it because that IS the retry: the cache no longer
persists a fetch that produced no JD (see `dossier.py` below), so the next
run refetches.
run refetches. The retry ends, when `triage.unjudgeable_give_up_after` is
set, after that many `unjudgeable` results in a row (#402): such a lead is
still selected and classified, but never fetched or judged again until its
`triage_unjudgeable_count` is reset or its posting is pasted under
`## Job description`.
- `roletype.py` (#223): the `role_type` closed set, its provenance ladder, and
`observe_role_type`, which reads a pay basis off a job description. Pure apart
from one thing, named because this page qualifies purity precisely everywhere
Expand Down Expand Up @@ -230,8 +234,8 @@ Shared by every sub-app:
upgrading -- bounded, not data loss, since the default `ttl_days: 7` would
have expired them inside a week anyway.

`DossierCache.jd_arrived(dossier)` (#169) is the single owner of "did a JD
actually arrive" -- a predicate over the fetched `jd.markdown`, not a
`DossierCache.jd_arrived(dossier)` (#169) is the single owner of what the
cache PERSISTS -- "did a JD actually arrive" -- a predicate over the fetched `jd.markdown`, not a
marker key riding along in the returned dict (which `slim()` would have to
exclude by name, the way it already excludes `lead_snapshot`/`page_title`/
`structured_data`). `get_or_build` asks it before PERSISTING a fetch: the
Expand Down Expand Up @@ -1535,6 +1539,29 @@ so a lead changed since the listing abstains. Residual: a lead moved off `resear
verdict write clears it, so a lead later hand-set back
to `research` is listed with that older date.

**Giving up on `unjudgeable` leads** (#402). `jd_arrived` cannot tell page chrome long enough to
pass it from a posting, so a board that keeps serving a bot check or a sign-in wall had its lead
re-judged on every run, on the same cached bytes for the whole ttl. With
`triage.unjudgeable_give_up_after` (0, the default, is off), each landed `unjudgeable` from either
producer adds one to the lead's `triage_unjudgeable_count`, in the same `update_fields` call as the
status, so it lands only where `_DECISION_REQUIRE`/`_VERDICT_REQUIRE` let the status write land
(`new`, `unjudgeable`). `triage/apply.py::_count_fields` merges it into the writers' dicts beside
`triage_blocked`'s clear, under `require_unchanged` on the raw snapshot value, blank included, so a
count a human resets or types mid-judge is never overwritten; when that entry refuses,
`_write_counted` retries once without the counter, so the status still lands (a hand-typed block
list in the key survives the same way), and the key is in `_HAND_EDITABLE_KEYS`. A result decided
on the note's own section involved no fetch and does not count. A landed judge `unjudgeable` also
calls `DossierCache.forget(note.fm)`, so every count after the first is a fresh fetch (the first
can be a page cached before the setting was on): `jd_arrived` owns what is persisted, and the
triage engine may evict. `engine.py::_given_up` withholds a lead at or above the threshold AFTER
its free classification and before any fetch or judge call (company resolution included), splits
such leads off before `--limit` so they never use up its budget, and counts them under
`TriageReport.counts["given_up"]` instead of `keep`. Any other landed `apply_classification` or
`apply_verdict` write clears a count above 0, whatever the setting, under its own
`require_unchanged`; the engine's company and `role_type` writes and `_stamp_blocked` leave it
alone. Residual, stated: a lead that heals after it has given up is never seen to, since nothing
fetches it again.

**`job-sluice leads dismiss --lead SLUG --reason REASON`** (#131) writes `dismiss`
by the same route as `leads expire` -- `require_status=_DISMISSABLE_FROM` re-read
inside the CAS transform, `require_blank={"pending_cv"}` refusing a lead holding
Expand Down
1 change: 1 addition & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ that ever open a browser tab or spend a backend call.
|---|---|---|
| `company_resolve_fetch` | `false` | opt-in: lets a blank/placeholder-company `needs_review` lead trigger a real (no-LLM) page visit to try to identify the employer from the page itself, feeding tiers 2 AND (if also enabled) 3 below; off by default so an unconfigured install never opens a browser tab it wasn't asked to. Rejects non-bool values: quoted, `"false"` is a non-empty STRING, which reads as true, so the spelling meant to keep the key off would switch it on |
| `company_resolve_llm` | `false` | opt-in: tier 3 of the same resolution, an LLM read of the page data tier 2 already fetched (no second visit) when tiers 0, 1, and 2 abstain. Runs on `resolve_backend`/`resolve_model` (default: triage's own) regardless of `--backend`, since it is bulk extraction rather than judgement and a cheaper model may suit it. **Requires `company_resolve_fetch: true`** — set alone the loader raises, because tier 3 reads what tier 2 fetches and could never fire. Off under `--no-llm`. Rejects non-bool values: quoted, `"false"` is a non-empty STRING, which reads as true, so the spelling meant to keep the key off would switch it on |
| `unjudgeable_give_up_after` | `0` | #402: stop fetching and judging a lead after this many CONSECUTIVE `unjudgeable` results (a page that came back as a bot check, a sign-in wall or anything else that is not a posting, or no text at all). Every counted result after the first is a fresh fetch: once this is on, a judge `unjudgeable` drops that lead's cached page (the first count can be a page cached before it was on). The count is the lead's `triage_unjudgeable_count` key; delete it, or paste the posting under `## Job description` in the note, to retry, and any real verdict clears it. A count taken on the note's own pasted section never counts. `0` is OFF: nothing is counted, dropped or skipped. Rejects YAML bools, floats and quoted numbers |
| `evidence` | `false` | Show the judge your VERIFIED experience and skills, one compact line per entry (which fields: `EvidenceKind.judge_fields`). Off by default and staying off: turning it on sends those lines to **triage's** backend, which may be a different provider from `cv.backend`, with every batch (the whole system prompt is repeated per batch; a 100-entry record is roughly 3-6k tokens per batch), dry runs included. A requirement your record does not cover is reported in `concerns` and is never a reason to lower a score. The repo's live eval (#196, `scripts/triage_evidence_eval/`) was run once on claude-max with claude-sonnet-4-5: with a partial record no lead's median score or median verdict band fell below its no-record baseline, and every uncovered requirement was reported in `concerns`. Its saved output also showed the record raising the median score of a lead it does not support, so the record can lift a score as well as qualify it. Turning the key on is recommended once your verified record is reasonably complete. If the provider rejects every batch, for example because the prompt is too large, the run exits 1 and names the record as a possible cause. Rejects non-bool values: quoted, `"false"` is a non-empty STRING, which reads as true, so the spelling meant to keep the key off would switch it on |

**The pay floors are denominated in GBP, and an advert is not.** Every floor above is a
Expand Down
10 changes: 10 additions & 0 deletions docs/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,16 @@ after it, so everything below reads as code. Close it in Obsidian with a line of
character, at least as many as opened it (a fence opened with four backticks is not closed by
three, nor a tilde fence by backticks), and the recorded answer becomes visible to triage.

## A lead stays `unjudgeable` and the digest says it was given up

With `triage.unjudgeable_give_up_after` set, triage stops fetching and judging a lead after that
many `unjudgeable` results in a row: each time, the page that came back was a bot check, a
sign-in wall or something else that is not a posting. The count is the note's
`triage_unjudgeable_count`. To try again, delete that key (the next run fetches and judges the
lead afresh), or open the posting yourself and paste it under a `## Job description` heading in
the note: a lead carrying its own job description is always judged, from that text. Moving the
lead back to `new` by hand buys one more attempt only, since the count is kept.

## Store / vault problems

- **vault missing**: the vault directory doesn't exist. Blocks every pipeline command, and the
Expand Down
12 changes: 9 additions & 3 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,14 +148,19 @@ Classify leads.
| Flag | Default | Notes |
|---|---|---|
| `--status` | `new,research,unjudgeable` | comma-separated statuses to consider |
| `--limit` | none | cap the number processed |
| `--limit` | none | cap the leads fetched or judged. Leads triage has given up on (`triage.unjudgeable_give_up_after`) do not count towards it, and are still classified |
| `--backend` | none | a provider (`claude-max`, `anthropic`, `deepseek`, `openai`) to judge with for this run instead of `triage.backend`. The configured provider keeps `triage.model`; another provider uses its own default model. Tier-3 company resolution (`triage.company_resolve_llm`) keeps its own `triage.resolve_backend` regardless of this flag. The values `auto`, `primary` and `fallback` were retired with the fallback provider (#333) and are refused |
| `--no-llm` | off | deterministic rules only; touches no backend at all, judge or resolution |

Deterministic rules resolve obvious cases; ambiguous leads go to the LLM judge (skipped
entirely under `--no-llm`). A `research` lead you have marked asked (`leads mark-asked`) is
left out and counted, and the digest carries a `Research queue:` line counting every research
lead, how many are marked asked, and how long ago the oldest was first seen. A blank/placeholder-company `needs_review` lead gets one resolution
lead, how many are marked asked, and how long ago the oldest was first seen. With
`triage.unjudgeable_give_up_after` set, an `unjudgeable` lead that has had that many
`unjudgeable` results in a row is no longer fetched or judged; it is still classified, so a
reject rule can still dismiss it, and the digest counts it as given up and says how to retry
(delete the lead's `triage_unjudgeable_count`, or paste the posting under `## Job description`).
A blank/placeholder-company `needs_review` lead gets one resolution
attempt first: a free regex over the role text's own trailing `"<role> at <Company>"`
clause, tier 0, then a free URL-pattern tier 1, an opt-in real page-visit tier 2
(`triage.company_resolve_fetch`), then an opt-in LLM read of that SAME page data, tier 3
Expand Down Expand Up @@ -362,7 +367,8 @@ both), the composer is shown them in a TRIAGE NOTES section, as framing for whic
to lead with. It may neither cite nor mention them, and the fabrication gate never treats them as a
source. To steer a lead triage never judged, set `status: shortlist` first, then write each key as
one quoted line: `culture_flags: "positive: a, negative: b"` and `triage_concerns: "a; b"`. A
default `triage run` re-judges `new`, `research` and `unjudgeable` leads and replaces both keys.
default `triage run` re-judges `new`, `research` and `unjudgeable` leads (an `unjudgeable` one until
triage gives up on it, if `triage.unjudgeable_give_up_after` is set) and replaces both keys.
A value typed across several lines is left alone by triage. As a YAML block list or block scalar
it frames nothing, unless the key's own line holds only a comment, or a YAML tag or anchor: that
line's text is framed instead. As a plain value continued on an indented line, it frames only its
Expand Down
Loading
Loading