diff --git a/src/psyc/cockpit/inference.py b/src/psyc/cockpit/inference.py new file mode 100644 index 0000000..0e35eef --- /dev/null +++ b/src/psyc/cockpit/inference.py @@ -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 diff --git a/src/psyc/cockpit/journey.py b/src/psyc/cockpit/journey.py index 814ac25..e9274e2 100644 --- a/src/psyc/cockpit/journey.py +++ b/src/psyc/cockpit/journey.py @@ -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), ) diff --git a/src/psyc/cockpit/static/cockpit.css b/src/psyc/cockpit/static/cockpit.css index 36fd00e..bf2f1fa 100644 --- a/src/psyc/cockpit/static/cockpit.css +++ b/src/psyc/cockpit/static/cockpit.css @@ -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, diff --git a/src/psyc/cockpit/templates/journey.html b/src/psyc/cockpit/templates/journey.html index b163e55..7ccfb83 100644 --- a/src/psyc/cockpit/templates/journey.html +++ b/src/psyc/cockpit/templates/journey.html @@ -61,6 +61,16 @@ {% if b.facts %} {% endif %} + {% if b.model_answer %} + {% set rule_sev = case.classification.severity.value if case.classification.severity else '' %} + {% set agrees = b.model_answer == rule_sev %} +

+ ⬡ psyc-v4 · live model + severity: {{ b.model_answer | upper }} + {% if agrees %}✓ agrees with the rule + {% else %}✗ differs — rule said {{ rule_sev | upper }}{% endif %} +

+ {% endif %} {% endfor %} diff --git a/src/psyc/lines/train.py b/src/psyc/lines/train.py index 3610c94..49c4d4c 100644 --- a/src/psyc/lines/train.py +++ b/src/psyc/lines/train.py @@ -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,