feat(m2): expose status and daily tools through jobbsok-tools
This commit is contained in:
parent
bdb6291042
commit
c7db9f16a0
4 changed files with 578 additions and 19 deletions
|
|
@ -80,6 +80,111 @@
|
|||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sak_status",
|
||||
"description": "Utled status, ventende part, siste aktivitet, neste frist og stillhets-flagg for hver sak, fra logg.jsonl. sak.md er en hurtigbuffer og loggen vinner. Skriver ingenting.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workspace": {
|
||||
"type": "string",
|
||||
"description": "Absolutt sti til arbeidsområdet. Påkrevd. Det finnes ingen implisitt standard — serveren gjetter aldri på hjemmekatalogen."
|
||||
},
|
||||
"today": {
|
||||
"type": "string",
|
||||
"description": "Datoen som skal brukes, YYYY-MM-DD. Utelates den, brukes dagens dato. Oppgi den i alt som skal kunne reproduseres."
|
||||
},
|
||||
"sak": {
|
||||
"type": "string",
|
||||
"description": "Begrens til én sak-id. Utelates den, tas hele arbeidsområdet."
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"tekst",
|
||||
"json"
|
||||
],
|
||||
"description": "Utdataform. Standard: tekst."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"workspace"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sak_status_check",
|
||||
"description": "Samme utledning, men rapporterer om en hurtigbuffer i sak.md er uenig med loggen. Avviket staar i teksten, med saken navngitt; kommandolinja svarer i tillegg med exit-kode 1, som MCP ikke har. Retter ingenting og skriver ingenting.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workspace": {
|
||||
"type": "string",
|
||||
"description": "Absolutt sti til arbeidsområdet. Påkrevd. Det finnes ingen implisitt standard — serveren gjetter aldri på hjemmekatalogen."
|
||||
},
|
||||
"today": {
|
||||
"type": "string",
|
||||
"description": "Datoen som skal brukes, YYYY-MM-DD. Utelates den, brukes dagens dato. Oppgi den i alt som skal kunne reproduseres."
|
||||
},
|
||||
"sak": {
|
||||
"type": "string",
|
||||
"description": "Begrens til én sak-id. Utelates den, tas hele arbeidsområdet."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"workspace"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "dagens",
|
||||
"description": "Dagens arbeidsbilde som ren tekst: hva som krever handling, hva som har gaatt stille, kommende frister og saker per tilstand. Byte-stabil for samme arbeidsomraade og samme dato. Skriver ingenting.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workspace": {
|
||||
"type": "string",
|
||||
"description": "Absolutt sti til arbeidsområdet. Påkrevd. Det finnes ingen implisitt standard — serveren gjetter aldri på hjemmekatalogen."
|
||||
},
|
||||
"today": {
|
||||
"type": "string",
|
||||
"description": "Datoen som skal brukes, YYYY-MM-DD. Utelates den, brukes dagens dato. Oppgi den i alt som skal kunne reproduseres."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"workspace"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "beslutning_append",
|
||||
"description": "Legg til én beslutning i den append-only beslutningsloggen (build-brief 5.4), eller en korrigering som navngir en tidligere beslutning. En retting er alltid en ny linje, aldri en redigering. Returnerer posten som ble skrevet.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workspace": {
|
||||
"type": "string",
|
||||
"description": "Absolutt sti til arbeidsområdet. Påkrevd. Det finnes ingen implisitt standard — serveren gjetter aldri på hjemmekatalogen."
|
||||
},
|
||||
"post": {
|
||||
"type": "object",
|
||||
"description": "Posten. Feltene er build-brief 5.4: id, dato, kilde, url, tittel, arbeidsgiver, beslutning, arsak, notat, score_da, delscore, vekter, vekt_hash, korrigerer."
|
||||
},
|
||||
"korriger": {
|
||||
"type": "boolean",
|
||||
"description": "Posten er en korrigering av en tidligere beslutning. Standard: false."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"workspace",
|
||||
"post"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,21 @@ SCRIPTS = os.path.join(REPO, "scripts")
|
|||
GOLDEN = os.path.join(REPO, "tests", "golden", "jobbsok-tools.tools.json")
|
||||
LAUNCHER = os.path.join(SCRIPTS, "jobbsok_tools_launch.py")
|
||||
|
||||
KORPUS = os.path.join(REPO, "tests", "fixtures", "workspace")
|
||||
TODAY = "2026-09-15"
|
||||
|
||||
#: The tool surface, in the order the server declares it. A tool list is an
|
||||
#: API: M1 shipped the first three, Step 24 added the four M2 tools.
|
||||
VERKTOY = [
|
||||
"kandidat_valider",
|
||||
"annonse_vurder",
|
||||
"selvsjekk",
|
||||
"sak_status",
|
||||
"sak_status_check",
|
||||
"dagens",
|
||||
"beslutning_append",
|
||||
]
|
||||
|
||||
PROFIL_FIXTURE = ("profiles", "01-gyldig.md")
|
||||
ANNONSE_FIXTURE = ("listings", "01-alt-passer.md")
|
||||
|
||||
|
|
@ -96,9 +111,10 @@ def test_initialize_and_tools_list_match_the_golden_surface(klient, golden):
|
|||
verktoy = klient.tools()
|
||||
golden(GOLDEN, json.dumps({"tools": verktoy}, ensure_ascii=False, indent=2) + "\n")
|
||||
|
||||
# The golden pins the shape; this pins the fact that M1 ships exactly three
|
||||
# tools, so Step 24 adding four more is a visible change and not a drift.
|
||||
assert [t["name"] for t in verktoy] == ["kandidat_valider", "annonse_vurder", "selvsjekk"]
|
||||
# The golden pins the shape; this pins the surface, so a tool appearing or
|
||||
# disappearing is a visible change and not a drift. M1 shipped the first
|
||||
# three; Step 24 added the four M2 tools.
|
||||
assert [t["name"] for t in verktoy] == VERKTOY
|
||||
|
||||
|
||||
def test_every_tool_requires_an_explicit_workspace(klient, arbeidsomrade):
|
||||
|
|
@ -206,7 +222,8 @@ def test_importing_the_server_does_not_import_the_capability_modules():
|
|||
"-c",
|
||||
"import sys; import jobbsok_tools; "
|
||||
"print(sorted(m for m in ('kandidat_schema', 'vurdering', "
|
||||
"'llm_ingestion_guard') if m in sys.modules))",
|
||||
"'sak_status', 'dagens', 'beslutninger', 'llm_ingestion_guard') "
|
||||
"if m in sys.modules))",
|
||||
],
|
||||
cwd=REPO,
|
||||
env=dict(os.environ, PYTHONPATH=SCRIPTS),
|
||||
|
|
@ -350,8 +367,177 @@ def test_the_launcher_refuses_an_interpreter_below_3_10_and_serves_on_a_good_one
|
|||
"expected exactly two replies, one per request: %r" % (servert.stdout,)
|
||||
)
|
||||
assert svar[0]["result"]["serverInfo"]["name"] == "jobbsok-tools"
|
||||
assert [t["name"] for t in svar[1]["result"]["tools"]] == [
|
||||
"kandidat_valider",
|
||||
"annonse_vurder",
|
||||
"selvsjekk",
|
||||
]
|
||||
assert [t["name"] for t in svar[1]["result"]["tools"]] == VERKTOY
|
||||
|
||||
|
||||
def cli(script, *argv):
|
||||
"""Run one of the command-line entry points and return its stdout."""
|
||||
env = dict(os.environ)
|
||||
env.pop("JOBBSOK_WORKSPACE", None)
|
||||
env.pop("CLAUDE_PLUGIN_DATA", None)
|
||||
proc = subprocess.run(
|
||||
[sys.executable, os.path.join(SCRIPTS, script)] + list(argv),
|
||||
capture_output=True, text=True, env=env,
|
||||
)
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def test_the_m2_tools_answer_with_the_bytes_the_command_line_produces(
|
||||
klient, empty_workspace, tmp_path
|
||||
):
|
||||
"""Parity is what makes the degradation branch honest.
|
||||
|
||||
Every skill degrades to reading what the CLI produced when the server is
|
||||
absent. That instruction is only true if the two paths produce the same
|
||||
content -- otherwise the degraded operator is reading a different report
|
||||
and does not know it.
|
||||
"""
|
||||
for verktoy, argumenter, script, argv in (
|
||||
("sak_status",
|
||||
{"workspace": KORPUS, "today": TODAY, "format": "json"},
|
||||
"sak_status.py", ["--workspace", KORPUS, "--today", TODAY, "--format", "json"]),
|
||||
("sak_status_check",
|
||||
{"workspace": KORPUS, "today": TODAY},
|
||||
"sak_status.py", ["--workspace", KORPUS, "--today", TODAY, "--check"]),
|
||||
("dagens",
|
||||
{"workspace": KORPUS, "today": TODAY},
|
||||
"dagens.py", ["--workspace", KORPUS, "--today", TODAY]),
|
||||
):
|
||||
fra_server = klient.call_text(verktoy, argumenter)
|
||||
fra_kommandolinja = cli(script, *argv)
|
||||
assert fra_server == fra_kommandolinja, (
|
||||
"%s and the command line disagree; the degradation branch tells "
|
||||
"the operator these are the same content" % verktoy
|
||||
)
|
||||
|
||||
# `sak_status_check` must carry the verdict in the payload: over MCP there
|
||||
# is no exit code, and a check whose answer was only an exit status would
|
||||
# arrive at Cowork as an unqualified report.
|
||||
svar = klient.call_text("sak_status_check", {"workspace": KORPUS, "today": TODAY})
|
||||
assert "utdatert hurtigbuffer" in svar, (
|
||||
"the corpus holds a deliberately divergent case and the check never "
|
||||
"says so: %r" % (svar[-200:],)
|
||||
)
|
||||
|
||||
# The writing tool, appended through each path into its own fresh
|
||||
# workspace, because the log refuses a reused id by design.
|
||||
from jobbsok_lib import paths as _paths
|
||||
|
||||
andre = str(tmp_path / "andre-workspace")
|
||||
_paths.scaffold(andre)
|
||||
post = {
|
||||
"id": "b-0001", "dato": "2026-09-01T09:00:00+02:00", "kilde": "manuell",
|
||||
"url": "https://stillinger.example/sak-1", "tittel": "Dataingeniør",
|
||||
"arbeidsgiver": "Værøy Sjømat AS", "beslutning": "ja",
|
||||
"arsak": ["fagomrade"], "notat": "treffer kjernen", "score_da": 78,
|
||||
"delscore": {"fagomrade": 78, "oppgavetype": 73, "teknologi": 68,
|
||||
"selskapstype": 63},
|
||||
"vekter": {"fagomrade": 40, "oppgavetype": 30, "teknologi": 20,
|
||||
"selskapstype": 10},
|
||||
"vekt_hash": "sha256:" + "e4" * 32,
|
||||
}
|
||||
fra_server = klient.call_text(
|
||||
"beslutning_append", {"workspace": empty_workspace, "post": post}
|
||||
)
|
||||
fra_kommandolinja = cli(
|
||||
"beslutninger.py", "--workspace", andre, "--json", json.dumps(post)
|
||||
)
|
||||
assert fra_server == fra_kommandolinja
|
||||
|
||||
# And it actually wrote, in both places: a parity test over two no-ops
|
||||
# would pass just as quietly.
|
||||
for rot in (empty_workspace, andre):
|
||||
with open(os.path.join(rot, "beslutninger.jsonl"), "r", encoding="utf-8") as handle:
|
||||
assert len(handle.read().splitlines()) == 1
|
||||
|
||||
|
||||
def tolk_uten_vakt(katalog, navn, versjon="3.14.0", har_vakt=False):
|
||||
"""A program that clears the version gate and answers the guard probe.
|
||||
|
||||
Two probes, and the fake has to tell them apart: the version gate runs
|
||||
`sys.version_info >= ...` and the guard gate runs `import
|
||||
llm_ingestion_guard`. With ``har_vakt`` false the second one fails, which
|
||||
is exactly the measured M1 state -- an interpreter at 3.14.0 that clears
|
||||
the floor and has no guard (O4 in docs/cowork-probe.md).
|
||||
|
||||
The Windows form is written but NOT measured; this suite has only ever run
|
||||
on macOS.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
sti = katalog / (navn + ".cmd")
|
||||
linjer = ["@echo off"]
|
||||
if not har_vakt:
|
||||
linjer.append('echo %* | findstr /C:"llm_ingestion_guard" >nul && exit /b 1')
|
||||
linjer.append('echo %%* | findstr /C:"print" >nul && (echo %s & exit /b 0)' % versjon)
|
||||
linjer.append("exit /b 0")
|
||||
sti.write_text("\r\n".join(linjer) + "\r\n")
|
||||
return sti
|
||||
|
||||
avvis = "" if har_vakt else " *llm_ingestion_guard*) exit 1 ;;\n"
|
||||
sti = katalog / navn
|
||||
sti.write_text(
|
||||
"#!/bin/sh\n"
|
||||
"# Clears the version gate at %s; the guard import is the variable.\n"
|
||||
'case "$*" in\n'
|
||||
"%s"
|
||||
" *print*) echo '%s'; exit 0 ;;\n"
|
||||
"esac\n"
|
||||
"exit 0\n" % (versjon, avvis, versjon)
|
||||
)
|
||||
sti.chmod(0o755)
|
||||
return sti
|
||||
|
||||
|
||||
def test_the_launcher_refuses_to_serve_without_the_ingestion_guard(tmp_path):
|
||||
"""O4, decided in docs/cowork-probe.md and implemented here.
|
||||
|
||||
The measured M1 incident: the server served on the host's 3.14.0, cleared
|
||||
the version floor, and reported `guard_versjon: null` because nothing had
|
||||
ever asked. From M2 the case folder and the decision log persist employer
|
||||
names, titles, URLs and notes that came out of a listing, so serving
|
||||
without the guard is a defect rather than an observation.
|
||||
|
||||
Failing closed is cheap here and that is the deciding argument: every
|
||||
skill degrades to manual paste with the server absent, so a launcher that
|
||||
refuses removes a connector -- it does not strand anyone.
|
||||
"""
|
||||
(tmp_path / "uten").mkdir()
|
||||
uten = tolk_uten_vakt(tmp_path / "uten", "python3.14", har_vakt=False)
|
||||
|
||||
avvist = subprocess.run(
|
||||
[sys.executable, LAUNCHER],
|
||||
env=dict(os.environ, JOBBSOK_PYTHON=str(uten)),
|
||||
input="",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert avvist.returncode != 0, (
|
||||
"the launcher served on an interpreter that cannot import the guard"
|
||||
)
|
||||
assert "llm_ingestion_guard" in avvist.stderr, (
|
||||
"the refusal must name what was missing: %r" % (avvist.stderr,)
|
||||
)
|
||||
assert "scripts/bootstrap.py" in avvist.stderr, (
|
||||
"the refusal must name the command that fixes it, or it is a dead end: "
|
||||
"%r" % (avvist.stderr,)
|
||||
)
|
||||
|
||||
# The positive control, without which the assertion above could be passing
|
||||
# on the version gate, on a missing file, or on nothing at all: the same
|
||||
# fake with the guard import answering must NOT be refused.
|
||||
(tmp_path / "med").mkdir()
|
||||
med = tolk_uten_vakt(tmp_path / "med", "python3.14", har_vakt=True)
|
||||
sluppet_gjennom = subprocess.run(
|
||||
[sys.executable, LAUNCHER],
|
||||
env=dict(os.environ, JOBBSOK_PYTHON=str(med)),
|
||||
input="",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert sluppet_gjennom.returncode == 0, (
|
||||
"an interpreter that clears both gates was still refused: %r"
|
||||
% (sluppet_gjennom.stderr,)
|
||||
)
|
||||
assert "refusing" not in sluppet_gjennom.stderr
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue