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:
52
src/psyc/cockpit/inference.py
Normal file
52
src/psyc/cockpit/inference.py
Normal 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
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user