stage-6: wire the Classifier bot to the live model

The Classifier bot in the Worker Mesh now shows the real fine-tuned model's
severity verdict beside the rule's. cockpit/inference.py calls serve_model.py
over HTTP; if the server is down it returns None and the bot silently falls
back to rules — the mesh never breaks. SEVERITY_INSTRUCTION + severity_features
are shared from lines/train.py so the live prompt matches what the model
trained on. The model is now genuinely in operation, not animation over rules.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m17hr1l
2026-05-18 21:10:12 +02:00
parent 2a9c0bf34a
commit 67f26f271e
5 changed files with 100 additions and 7 deletions

View File

@@ -0,0 +1,52 @@
"""Inference client — calls the psyc model server if it is running.
The cockpit venv has no torch; the fine-tuned model only runs inside the CUDA
container behind serve_model.py. This client reaches it over HTTP and degrades
gracefully — if the server is down, callers get None and fall back to rules.
"""
from __future__ import annotations
import json
from typing import Optional
import httpx
from psyc import log
from psyc.lines.train import SEVERITY_INSTRUCTION, severity_features
from psyc.models import Case
INFERENCE_URL = "http://127.0.0.1:8771"
_log = log.get(__name__)
def server_adapter(timeout: float = 2.0) -> Optional[str]:
"""Return the adapter the server is running, or None if it is unreachable."""
try:
with httpx.Client(timeout=timeout) as client:
resp = client.get(f"{INFERENCE_URL}/healthz")
resp.raise_for_status()
return resp.json().get("adapter")
except httpx.HTTPError:
return None
def model_severity(case: Case, timeout: float = 15.0) -> Optional[str]:
"""Ask the live model to classify case severity. None if the server is down."""
payload = {
"instruction": SEVERITY_INSTRUCTION,
"input": json.dumps(severity_features(case), ensure_ascii=False),
"max_new_tokens": 16,
}
try:
with httpx.Client(timeout=timeout) as client:
resp = client.post(f"{INFERENCE_URL}/infer", json=payload)
resp.raise_for_status()
output = str(resp.json().get("output", "")).strip().lower()
except httpx.HTTPError as exc:
_log.info("inference.unavailable", error=str(exc))
return None
_log.info("inference.severity", case_id=case.case_id, model_answer=output)
return output

View File

@@ -7,10 +7,11 @@ a script. Beats that did not happen for a case are returned with occurred=False.
from __future__ import annotations
from typing import List
from typing import List, Optional
from pydantic import BaseModel, Field
from psyc.cockpit import inference
from psyc.lines import ledger as ledger_line
from psyc.lines import route as route_line
from psyc.lines import seal as seal_line
@@ -42,6 +43,7 @@ class Beat(BaseModel):
occurred: bool
caption: str
facts: List[str] = Field(default_factory=list)
model_answer: Optional[str] = None # live model prediction (Classifier bot only)
def build_journey(case: Case) -> List[Beat]:
@@ -89,6 +91,7 @@ def _classified(case: Case) -> Beat:
index=0, line="Classifyline", title="Classified", occurred=True,
caption=f"Classifyline rated it {severity} severity, TLP:{c.tlp.value}, internal class {internal}.",
facts=[f"incident type: {c.incident_type.value}"],
model_answer=inference.model_severity(case),
)

View File

@@ -190,6 +190,23 @@ tr.sev-low .sev-badge { color: var(--muted); }
.bot-facts { margin: 8px 0 0; padding-left: 16px; font-size: 12px; color: var(--muted); }
.bot-facts li { margin: 2px 0; }
/* live-model verdict on the Classifier bot */
.bot-model {
margin: 10px 0 0; padding: 8px 10px; font-size: 13px;
border: 1px solid var(--border); border-radius: 6px; background: var(--navy-700);
}
.model-chip {
display: inline-block; font-size: 10px; font-weight: 700; letter-spacing: 0.6px;
text-transform: uppercase; padding: 2px 7px; border-radius: 4px; margin-right: 6px;
background: var(--cyan-700); color: var(--cyan-100); border: 1px solid var(--cyan-500);
}
.bot-model strong { color: var(--text); }
.model-verdict { font-size: 12px; }
.model-agree { border-color: color-mix(in oklab, var(--out-actioned) 45%, var(--border)); }
.model-agree .model-verdict { color: var(--out-actioned); }
.model-differ { border-color: color-mix(in oklab, var(--sev-medium) 45%, var(--border)); }
.model-differ .model-verdict { color: var(--sev-medium); }
.mesh.playing .bot-done .bot-working { animation: work-flash 1.5s ease both; animation-delay: calc(var(--i) * 0.95s); }
@keyframes work-flash { 0%, 12% { opacity: 0; } 22%, 60% { opacity: 1; } 74%, 100% { opacity: 0; } }
.mesh.playing .bot-done .bot-answer,

View File

@@ -61,6 +61,16 @@
{% if b.facts %}
<ul class="bot-facts">{% for f in b.facts %}<li>{{ f }}</li>{% endfor %}</ul>
{% endif %}
{% if b.model_answer %}
{% set rule_sev = case.classification.severity.value if case.classification.severity else '' %}
{% set agrees = b.model_answer == rule_sev %}
<p class="bot-model {{ 'model-agree' if agrees else 'model-differ' }}">
<span class="model-chip">⬡ psyc-v4 · live model</span>
severity: <strong>{{ b.model_answer | upper }}</strong>
{% if agrees %}<span class="model-verdict">✓ agrees with the rule</span>
{% else %}<span class="model-verdict">✗ differs — rule said {{ rule_sev | upper }}</span>{% endif %}
</p>
{% endif %}
</div>
</li>
{% endfor %}

View File

@@ -98,10 +98,16 @@ def _ex_ioc_extraction(case: Case) -> Optional[Example]:
)
def _ex_severity_classification(case: Case) -> Optional[Example]:
if case.classification.severity is None:
return None
input_obj = {
# Shared so the cockpit's live inference sends the exact prompt the model
# trained on — keep _ex_severity_classification and the inference client in sync.
SEVERITY_INSTRUCTION = (
"Classify the defensive severity of this case as one of: "
"low | medium | high | critical. Return only the label."
)
def severity_features(case: Case) -> Dict[str, object]:
return {
"summary": case.summary,
"source_type": case.source_type,
"incident_type": case.classification.incident_type.value if case.classification.incident_type else None,
@@ -111,9 +117,14 @@ def _ex_severity_classification(case: Case) -> Optional[Example]:
"url_count": len(case.observables.urls),
"ip_count": len(case.observables.ips),
}
def _ex_severity_classification(case: Case) -> Optional[Example]:
if case.classification.severity is None:
return None
return Example(
instruction="Classify the defensive severity of this case as one of: low | medium | high | critical. Return only the label.",
input=json.dumps(input_obj, ensure_ascii=False),
instruction=SEVERITY_INSTRUCTION,
input=json.dumps(severity_features(case), ensure_ascii=False),
output=case.classification.severity.value,
task="severity_classification",
case_id=case.case_id,