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 __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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user