feat(m1): add kandidatvurdering skill that persists nothing
This commit is contained in:
parent
3c74968ec1
commit
cdb4a8fda9
3 changed files with 268 additions and 0 deletions
73
skills/kandidatvurdering/SKILL.md
Normal file
73
skills/kandidatvurdering/SKILL.md
Normal 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`.
|
||||||
49
skills/kandidatvurdering/references/vekter.md
Normal file
49
skills/kandidatvurdering/references/vekter.md
Normal 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.
|
||||||
146
tests/test_kandidatvurdering_nowrite.py
Normal file
146
tests/test_kandidatvurdering_nowrite.py
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue