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 __future__ import annotations
from typing import List from typing import List, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from psyc.cockpit import inference
from psyc.lines import ledger as ledger_line from psyc.lines import ledger as ledger_line
from psyc.lines import route as route_line from psyc.lines import route as route_line
from psyc.lines import seal as seal_line from psyc.lines import seal as seal_line
@@ -42,6 +43,7 @@ class Beat(BaseModel):
occurred: bool occurred: bool
caption: str caption: str
facts: List[str] = Field(default_factory=list) 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]: def build_journey(case: Case) -> List[Beat]:
@@ -89,6 +91,7 @@ def _classified(case: Case) -> Beat:
index=0, line="Classifyline", title="Classified", occurred=True, index=0, line="Classifyline", title="Classified", occurred=True,
caption=f"Classifyline rated it {severity} severity, TLP:{c.tlp.value}, internal class {internal}.", caption=f"Classifyline rated it {severity} severity, TLP:{c.tlp.value}, internal class {internal}.",
facts=[f"incident type: {c.incident_type.value}"], 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 { margin: 8px 0 0; padding-left: 16px; font-size: 12px; color: var(--muted); }
.bot-facts li { margin: 2px 0; } .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); } .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; } } @keyframes work-flash { 0%, 12% { opacity: 0; } 22%, 60% { opacity: 1; } 74%, 100% { opacity: 0; } }
.mesh.playing .bot-done .bot-answer, .mesh.playing .bot-done .bot-answer,

View File

@@ -61,6 +61,16 @@
{% if b.facts %} {% if b.facts %}
<ul class="bot-facts">{% for f in b.facts %}<li>{{ f }}</li>{% endfor %}</ul> <ul class="bot-facts">{% for f in b.facts %}<li>{{ f }}</li>{% endfor %}</ul>
{% endif %} {% 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> </div>
</li> </li>
{% endfor %} {% endfor %}

View File

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