feat(m1): add kandidatvurdering skill that persists nothing

This commit is contained in:
Kjell Tore Guttormsen 2026-09-04 21:30:14 +02:00
commit cdb4a8fda9
3 changed files with 268 additions and 0 deletions

View file

@ -0,0 +1,73 @@
---
name: kandidatvurdering
description: "Scorer en stillingsannonse mot kandidatprofilen, kjoerer hardfiltrene foerst og leverer score, begrunnelse og bekymringer paa norsk. Skriver ingenting. Utloeses av vurder denne annonsen, score denne stillingen, hva er scoren på denne, hva er scoren pa denne, passer denne stillingen for meg, kjør hardfiltrene og kjor hardfiltrene."
triggers:
- vurder denne annonsen
- score denne stillingen
- hva er scoren på denne
- hva er scoren pa denne
- passer denne stillingen for meg
- kjør hardfiltrene
- kjor hardfiltrene
---
# kandidatvurdering
Scorer én annonse mot `profil/kandidat.md`. **Skriver ingenting.** Ingen fil,
ingen logg, ingen sak. I M1 finnes ingen ingest-vakt ennå, så teksten som
vurderes er utrygg — og det eneste som holder den utenfor arbeidsmappa, er at
denne stien ikke skriver i det hele tatt. Skal noe lagres, er det `beslutning`
og `sak` som gjør det, etter at operatøren har bestemt seg.
All operatørrettet tekst er norsk bokmål.
## Arbeidsdelingen
Regnestykket er ikke ditt. Du leverer skjønn per kriterium; scriptet
`${CLAUDE_PLUGIN_ROOT}/scripts/vurdering.py` gjør aritmetikken. En score du
regner ut selv er ikke reproduserbar, og M6 leser den som et tall på en
vektavhengig skala.
Gjør dette, i denne rekkefølgen:
1. Les profilen med `les_profil_fil(root, "profil", "kandidat.md")`.
2. Sett delscore for hvert kriterium — `fagomrade`, `oppgavetype`,
`teknologi`, `selskapstype` — som et heltall 0-100. Alle fire må være med;
et manglende eller ugyldig kriterium blir avvist ved navn.
3. Skriv bekymringene dine som fri tekst i `bekymringer`. Scriptet leverer dem
tilbake urørt — det oppfinner ingen.
4. Kall `vurder(profil, annonse, brodtekst, last)` og les resultatet.
## Les resultatet slik
- **`verdikt`** er `avvist` så snart minst én hardfilter slo til, ellers
`vurderes`.
- **`avvisninger`** navngir frontmatter-nøkkelen hver avvisning kom fra. Gjengi
nøkkelen ordrett. Annonsen får en score uansett, slik at operatøren kan veie
avslaget mot et tall.
- **`advarsler`** er filtre som ikke kunne kjøre. Lønn som ikke er oppgitt er
ikke lønn under gulvet, og en stillingskode rangtabellen ikke kjenner er
ikke lav senioritet. Si hva som ikke ble sjekket.
- **`vekt_hash`** identifiserer vektvektoren scoren ble regnet med. Ta den med
når scoren refereres videre.
Står arbeidsstedet i profilens `arbeidssteder`, blir reisetid en advarsel og
ikke et avslag. Det er operatørens eget valg, kodet — ikke en lekkasje i
filteret.
## Lever på norsk
Gi operatøren fire ting, i denne rekkefølgen: score, verdikt med de
navngitte nøklene, begrunnelse for delscorene, og bekymringene. Ikke pynt på
et avslag, og ikke gjett på lønn eller reisetid annonsen ikke oppgir.
## Uten verktøyserveren
Er `jobbsok-tools` ikke tilgjengelig, degraderer skillen i stedet for å stoppe:
les profilen direkte, sett delscorene som vanlig, og be operatøren kjøre
scriptet fra kommandolinja for aritmetikken. Ikke regn ut summen selv som en
mellomløsning — da er tallet ikke lenger reproduserbart, og det er hele
poenget med delingen.
Vektene og frontmatter-kontrakten står i
`${CLAUDE_PLUGIN_ROOT}/skills/kandidatvurdering/references/vekter.md`.

View file

@ -0,0 +1,49 @@
# Vekter for kandidatvurdering
## Kriteriene
Fire, og bare fire. Delscore settes per kriterium som et heltall 0-100.
Mangler ett, eller ligger ett utenfor intervallet, blir hele lasten avvist ved
navn — det er med vilje, siden en score regnet på tre av fire kriterier ser
akkurat ut som en score regnet på alle fire.
| Kriterium | Hva det måler | Standardvekt |
| --- | --- | --- |
| `fagomrade` | treff mot dybden i profilen | 40 |
| `oppgavetype` | er oppgavene de operatøren vil ha | 30 |
| `teknologi` | teknologisk overlapp | 20 |
| `selskapstype` | organisasjonsform og modenhet | 10 |
Tallene summerer til 100 for at vektoren skal leses som en fordeling.
Aritmetikken normaliserer på summen, så en hvilken som helst ikke-negativ
heltallsvektor virker like godt.
## Frontmatter-kontrakten
Profilen kan overstyre vektene med en `vekter`-nøkkel i frontmatter
(operatørbeslutning 14.3):
```
vekter:
fagomrade: 4
oppgavetype: 3
teknologi: 2
selskapstype: 1
```
Rapporten fra skjemasjekken sier hvor vektene kom fra:
- `vekter_kilde: profil` — profilen oppga dem
- `vekter_kilde: default` — profilen oppga dem ikke, og standardvektene over
gjelder
Manglende kriterium under `vekter` arver standarden og gir en advarsel. Et
ukjent kriterium gir en advarsel og leses ikke. En vekt som ikke er et heltall
≥ 0 er en feil.
## Vekthashen
Hver score følges av `vekt_hash`, en sha256 over vektvektoren. Den skrives ned
sammen med scoren ved beslutningstidspunktet, slik at en senere endring av
vektene blir synlig i stedet for å reskalere historikken i stillhet. To scorer
med ulik `vekt_hash` er ikke sammenlignbare tall.

View file

@ -0,0 +1,146 @@
"""`kandidatvurdering` writes nothing, and this is the proof (plan Step 11).
Build-brief 7 says the skill's Writes column is `nothing`, and in M1 that is
not a tidiness preference. M1 scores pasted listing text with no ingestion
guard in front of it -- the guard arrives in M3 -- so the only thing standing
between an untrusted paste and the workspace is that nothing on this path
writes at all. A test that merely checked the score would leave that property
unasserted.
So the check is a content digest of the whole workspace tree, taken before and
after a full scoring run, including reading the profile off disk. Content
rather than size and mtime: a rewrite of the same length inside the same
second is exactly the kind of write this test exists to catch.
Style note: this file follows tests/test_skills_contract.py.
"""
import hashlib
import os
import kandidat_schema
import test_skills_contract as kontrakt
import vurdering
from jobbsok_lib import frontmatter
SKILL = "kandidatvurdering"
PROFIL = """---
oppdatert: 2026-09-04
geografi:
base: Tromsø
maks_reisetid_min: 45
hjemmekontor_min_dager: 2
vurderer_flytting: nei
lonn:
gulv_nok: 850000
onsket_nok: 950000
kommentar: gulvet avviser mekanisk
ansettelsesform:
aksepterer: fast, konsulent via byrå
avviser: åremål/engasjement, vikariat
absolutte_nei:
- turnus
senioritet:
min: seniorrådgiver
maks: fagdirektør/sjefsarkitekt
---
## Kjerne
## Kompetanse
## Retning
## Signaturprosjekter
## Kjente svakheter
## Formuleringer som virker
## Profilgap
"""
def digest(root):
"""A content digest of every file under ``root``, path included."""
summer = hashlib.sha256()
for dirpath, dirnames, filenames in os.walk(root):
dirnames.sort()
for name in sorted(filenames):
full = os.path.join(dirpath, name)
summer.update(os.path.relpath(full, root).encode("utf-8"))
with open(full, "rb") as handle:
summer.update(handle.read())
return summer.hexdigest()
def test_scoring_a_listing_leaves_the_workspace_byte_identical(
empty_workspace, fixtures_dir
):
profil_sti = os.path.join(empty_workspace, "profil", "kandidat.md")
with open(profil_sti, "w", encoding="utf-8") as handle:
handle.write(PROFIL)
annonse = os.path.join(fixtures_dir, "listings", "01-alt-passer.md")
with open(annonse, "r", encoding="utf-8") as handle:
meta, body = frontmatter.parse(handle.read())
last = {
"delscore": {k: 70 for k in kandidat_schema.STANDARDVEKTER},
"bekymringer": ["konseptfase, ikke drift"],
}
for_ = digest(empty_workspace)
profil = vurdering.les_profil_fil(empty_workspace, "profil", "kandidat.md")
resultat = vurdering.vurder(profil, meta, body, last)
etter = digest(empty_workspace)
assert resultat["score"] == 70
assert etter == for_, "kandidatvurdering wrote into the workspace"
# And it did not create the workspace-adjacent files either: the tree is
# exactly what the scaffold left.
assert sorted(os.listdir(os.path.join(empty_workspace, "profil"))) == ["cv", "kandidat.md"]
def test_the_skill_contract_covers_this_skill():
assert SKILL in kontrakt.ALLE, "%s is not discovered by the skills contract" % SKILL
meta, body, text = kontrakt.load(SKILL)
skill = {"name": SKILL, "meta": meta, "body": body, "text": text}
kontrakt.test_frontmatter_parses_and_name_matches_the_directory(skill)
kontrakt.test_description_is_third_person_and_names_every_trigger(skill)
kontrakt.test_every_accented_trigger_has_an_ascii_twin(skill)
kontrakt.test_body_is_under_the_word_budget(skill)
kontrakt.test_every_intra_plugin_path_uses_the_plugin_root(skill)
kontrakt.test_no_absolute_home_path_is_baked_into_a_skill(skill)
kontrakt.test_reference_files_resolve_in_both_directions(skill)
kontrakt.test_skill_files_are_valid_utf8_and_carry_norwegian(skill)
kontrakt.test_trigger_phrases_are_unique_across_skills()
def test_the_skill_states_that_it_writes_nothing():
_meta, _body, text = kontrakt.load(SKILL)
assert "skriver ingenting" in text.lower()
# The degradation is part of the contract: with the host MCP server
# absent the skill still reads the profile and asks for the CLI, rather
# than guessing at the arithmetic.
assert "jobbsok-tools" in text
def test_vekter_reference_documents_every_weight_the_script_reads():
sti = os.path.join(kontrakt.SKILLS, SKILL, "references", "vekter.md")
with open(sti, "r", encoding="utf-8") as handle:
tekst = handle.read()
# Criterion and its default on the same line, not merely somewhere in
# the file: every criterion name also appears in the override example, so
# a token search would pass over a criterion whose row had been deleted.
linjer = tekst.split("\n")
for kriterium, standard in kandidat_schema.STANDARDVEKTER.items():
rader = [line for line in linjer if kriterium in line and str(standard) in line]
assert rader, (
"vekter.md has no line documenting %s with its shipped default %d"
% (kriterium, standard)
)
# The frontmatter contract, not only the numbers: a reader has to know
# that a profile may override them and what the report then says.
assert "vekter_kilde" in tekst
assert "profil" in tekst and "default" in tekst