showcase · tier 3 · business
Resume screener
One local-model rubric per candidate, with enums and required evidence quotes. jq does the ranking.
- 8tasksthe plan falls out of the bindings
- 8wavesparallel by construction
- 2verbs exercisedinvoke · infer
- T3the tierbusiness
The whole file
8 tasks · 8 wavesThe plan falls out of the bindings, nothing is scheduled by hand.
resume-screener.nika.yamlsource
nika: v1workflow: id: resume-screener description: "glob CVs → local-model rubric per candidate → deterministic shortlist"model: ollama/qwen3.5:4b # PII stays on the machine · the whole screen is offlineconst: role: "Senior Rust engineer" shortlist_size: 5 # The committed rehearsal inbox. Point this at ./hiring/inbox (or wherever # your exports land) and change `permits.fs.read` to match, same edit. cv_glob: "./examples/fixtures/cvs/*.md"permits: tools: ["nika:glob", "nika:jq", "nika:read", "nika:write"] # NO `net:` category. That absence is the whole sovereignty claim: with no # host granted, a CV that talks the model into exfiltrating itself still # has nowhere to go — the boundary refuses before the request is made. fs: # TWO entries, and both earn their place. `nika:glob` opens the ROOT of # its pattern before it lists anything, and a directory grant does not # cover its children while a child grant does not cover the directory — # so each is named. Measured: with only the second line, the run dies # `NIKA-SEC-004 · ./examples/…/cvs resolves outside permits.fs.read` # before a single CV is read. # # `*` is one segment and never crosses `/`, which is the right width # here: CVs land flat in the inbox. `cvs/**` would also work and would # additionally hand over every sub-tree someone drops in there. read: - "./examples/fixtures/cvs" # the directory the glob opens - "./examples/fixtures/cvs/*" # the CVs one level inside it # One brief, one path. Nothing else on this machine is writable by this # workflow — not `./hiring/**`, not a sibling of the brief. write: ["./hiring/shortlist-brief.md"]tasks: # ── the pool is discovered at runtime · the count is never written down ── pool: invoke: tool: "nika:glob" args: { pattern: "${{ const.cv_glob }}" } cvs: with: paths: ${{ tasks.pool.output }} for_each: ${{ with.paths }} max_parallel: 8 fail_fast: false # one unreadable CV must not stop the batch on_error: recover: null # null holds the index so the zip below stays aligned invoke: tool: "nika:read" args: { path: "${{ item }}" } # A for_each output is an array in ITEM ORDER, so `transpose` pairs each # path with its own text. The `select` is where the unreadable CVs leave. pairs: with: paths: ${{ tasks.pool.output }} texts: ${{ tasks.cvs.output }} invoke: tool: "nika:jq" args: input: ["${{ with.paths }}", "${{ with.texts }}"] expression: >- transpose | map(select(.[1] != null)) | map({ path: .[0], text: .[1] }) # ── one rubric, applied identically to everyone ──────────────────── # `with.cv_path` reads `${{ item.path }}` — a pre-fan-out import that is # re-evaluated per iteration, which is how a loop-local reaches the prompt # under a name that means something. screened: with: pairs: ${{ tasks.pairs.output }} cv_path: ${{ item.path }} for_each: ${{ with.pairs }} max_parallel: 2 # a local model shares one GPU · don't thrash it fail_fast: false on_error: recover: null infer: max_tokens: 1200 # a rubric row, not an essay prompt: | Role · ${{ const.role }} Candidate file · ${{ with.cv_path }} CV · ${{ item.text }} Score this candidate against the role. Quote evidence from the CV for every rating: no rating without a quote. # `additionalProperties: false` on EVERY object node — the top level and # each nested one. Without it a provider is free to add keys, and the # jq below would be sorting a shape that changes between runs. schema: type: object additionalProperties: false required: [file, fit, strengths, concerns] properties: file: { type: string } fit: { type: string, enum: [strong, possible, weak] } years_relevant: { type: integer } strengths: { type: array, items: { type: string } } concerns: { type: array, items: { type: string } } # ── the model judged · jq decides ────────────────────────────────── # Ranking lives HERE, not in a prompt, so the order is reproducible: same # scores in, same order out. `.fit != "strong"` sorts false (0) before true # (1), which puts the strong candidates first; `// 0` covers the candidates # whose CV never stated a number. ranked: with: screened: ${{ tasks.screened.output }} invoke: tool: "nika:jq" args: input: "${{ with.screened }}" expression: >- map(select(. != null)) | map(select(.fit != "weak")) | sort_by(.fit != "strong", -(.years_relevant // 0)) shortlist: with: ranked: ${{ tasks.ranked.output }} invoke: tool: "nika:jq" args: input: "${{ with.ranked }}" expression: ".[:${{ const.shortlist_size }}]" brief: with: shortlist: ${{ tasks.shortlist.output }} when: ${{ size(with.shortlist) > 0 }} # nobody cleared the bar · spend nothing infer: max_tokens: 2000 prompt: | Write the screening brief for these shortlisted candidates · ${{ with.shortlist }} One paragraph each · lead with the evidence quotes · end with the suggested interview focus. save: with: brief: ${{ tasks.brief.output }} when: ${{ with.brief != null }} # a skipped task reads null downstream · gate on the VALUE invoke: tool: "nika:write" args: path: "./hiring/shortlist-brief.md" content: "${{ with.brief }}" create_dirs: trueoutputs: ranked: value: ${{ tasks.ranked.output }} description: "Everyone who was not scored weak · strong first, then by relevant years" shortlist: value: ${{ tasks.shortlist.output }} description: "The top `const.shortlist_size` of the ranking · what the brief covers"nika inspect · engine 0.106.1 · vendored graph, never re-derived
8 declared edges · 8 waves · the plan falls out of the bindings