Tune Filter
Interactive calibration of portals.yml title_filter against cached scan-history
---
description: Interactive calibration of portals.yml title_filter against cached scan-history
argument-hint: ''
---
# /tune-filter
Interactively tune `config/portals.yml.title_filter` (and `candidate-profile.blacklist_companies`) against the cached `data/scan-history.tsv` corpus — no network calls, no re-scan.
## First-run guard
If `config/portals.yml` or `config/candidate-profile.yml` is missing, **stop** and say:
> "No config found. Run `/apply-onboard` first — it will extract your CV, build the configs, and find ~30 target companies for you."
If `data/scan-history.tsv` is missing or empty, **stop** and say:
> "No cached offers to calibrate against. Run `/scan` first."
## State you track in-memory during the loop
- `currentFilter`: `{ positive, negative, required_any, blacklist, companies }` — starts as a copy of what you read from `portals.yml` (positive/negative/required_any, plus the full `tracked_companies` list as `companies` so the simulator can honour per-company `skip_required_any`) and `candidate-profile.yml` (`blacklist_companies` → `blacklist`).
- `lastStats`: result of the most recent simulation against `currentFilter`.
Never write to disk except on an explicit _Save_.
## Loop
Repeat until the user picks Save or Discard:
### 1. Simulate
Run:
```bash
echo '<currentFilter as JSON>' | node src/scan/tune-filter.mjs --history data/scan-history.tsv
Parse the JSON result into lastStats.
2. Render the summary
Print, in this order:
Loaded <total> cached offers from data/scan-history.tsv (<first_seen_min> → <first_seen_max>).
Current filter:
positive: [<comma-joined>]
negative: [<comma-joined>]
required_any: [<comma-joined>]
blacklist: [<comma-joined>]
Effective match: <accepted> / <total> (<ratio_percent>%)
Rejected by reason:
<count> <reason>
...
Sample rejects (up to 10 per reason):
• "<title>" (<company>, <portal>)
...
Top companies passing filter:
<accepted> <company>
...
If scan-history.tsv is older than 7 days (compare latest first_seen to today), print after the summary:
⚠️ Corpus is <N> days old — consider /scan for fresh data.
3. Action menu
Use AskUserQuestion with these options:
- Edit filter → go to 4.
- Suggest keywords → go to 5.
- Test alternative → go to 6.
- Save → go to 7.
- Discard → print "No changes written." and exit.
4. Edit sub-flow
Ask which list to edit: positive, negative, required_any, blacklist.
For the chosen list, offer:
Remove →
AskUserQuestionmultiSelectwith the list's current values as options; drop the selected ones fromcurrentFilter.Add → free-text prompt ("one keyword per line; wrap in
/…/for regex"). For each entered term, run it through a regex-compile check by invoking:node -e 'import("./src/lib/prefilter-rules.mjs").then(({ checkTitle }) => { const r = checkTitle({title:"x"}, {positive:[<JSON_term>]}); if (r.reason && r.reason.includes("invalid title_filter term")) { process.exit(1); } })'If the process exits non-zero, surface the term as invalid and re-prompt. Do not add invalid terms.
Clear → confirm via
AskUserQuestionyes/no; on yes, empty the list.Done → return to step 1 (re-simulate with the mutated
currentFilter).
5. Suggest sub-flow
Select from
lastStats.sampleRejectedall entries whosereasonstarts withtitle:. Collect theirtitlefields. Also pull the full rejected-title list by re-simulating withsampleRejectedbumped — but for v1, the in-memory sample (capped at 10 per reason) is enough.Call:
node -e ' import("./src/lib/title-ngrams.mjs").then(({ suggestNgrams }) => { const titles = <JSON of titles>; const existing = <JSON of all current filter terms>; const STOP = new Set(["the","and","of","in","for","a","to","at","with","on","as","by","or","an","-"]); console.log(JSON.stringify(suggestNgrams(titles, { maxN: 3, minCount: 3, stopWords: STOP, existingTerms: existing }))); }); 'Display the top 10 suggestions:
Suggestion Count Lift research engineer 8 0.16 applied scientist 6 0.12 ...AskUserQuestionmultiSelect— user picks which n-grams to add.AskUserQuestionsingle-select — target list for the picks (defaultrequired_any, alternativespositive,negative).Merge picks into
currentFilter[targetList](deduplicate, preserve existing order, append new at the end).Return to step 1.
6. Test alternative sub-flow
Prompt the user for a YAML snippet (parsed with js-yaml) shaped like:
positive: [Intern]
negative: []
required_any: []
blacklist: []
Merge missing keys with currentFilter, simulate once without mutating currentFilter, print the delta:
Effective match: <oldAccepted> → <newAccepted> (<signed_delta>)
New companies represented: <company (count)>, ...
Return to step 3 with currentFilter unchanged.
7. Save sub-flow
Compute the diff of each list between the loaded filter and
currentFilter; render as:title_filter.positive: + Stagiaire - (nothing removed) title_filter.required_any: + Research + Applied Scientist blacklist: + ReallyBadCorpIf no differences exist, say "No changes to save." and return to step 3.
AskUserQuestionyes/no: "Write changes toconfig/portals.ymlandconfig/candidate-profile.yml?". On no, return to step 3.Apply the writes:
For
title_filter.*changes, call the writer:node -e ' import("./src/lib/portals-writer.mjs").then(({ write }) => { write("config/portals.yml", { title_filter: <JSON of changed title_filter keys> }); }); 'For
blacklistchanges, use the same writer againstcandidate-profile.yml— same rules, different file:node -e ' im
Maintain Tune Filter?
Let people know it's listed here — add the badge (live metrics, light/dark aware) or a plain link to your README or docs.
[Tune Filter on getagentictools](https://getagentictools.com/loops/leolaborie-tune-filter?ref=badge)