LLM Evidence Highlighting — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: On-demand LLM evidence extraction that highlights the attack chain nodes in the graph visualizer and surfaces malicious events in the investigation panel, with per-step interactive rationale linkage.
Architecture: When an analyst clicks Investigate on an attack, the frontend calls a new evidence endpoint on the API service. The API service fetches the persisted graph + inference result from ES, sends both (plus the original verdict and rationale) to the same LLM for grounded annotation, validates the returned node IDs against the graph, caches the result in ES, and returns structured per-step evidence. The frontend renders this as highlighted events (reordered to the top) and highlighted graph nodes (with clickable rationale steps that spotlight per-step nodes).
Tech Stack: Python/FastAPI (API service), Elasticsearch (caching), OpenAI-compatible LLM client (httpx), React 19 (dashboard client), Express (dashboard server proxy), Cytoscape.js (graph visualization)
Spec: docs/superpowers/specs/2026-09-20-evidence-highlighting-design.md
Global Constraints
- The GNN is a dummy stage — do not read, branch on, or extend
gnn_*fields. - The LLM (LogsterClassifier) is the sole verdict authority — the evidence call is a grounded annotation of its prior verdict, not a re-analysis.
OPENROUTER_API_KEYflows from env, never YAML.persist_graphs.enabled: trueis required for this feature. Gracefully degrade when graphs aren't available.- Config loads through
logster_common.config_source.load_raw_config. Don't introduce parallel config systems. - Logging: use
logster_common.logging.configure_logging. Never calllogging.basicConfig. - ES access in the API service: use the existing
WhitelistStorepattern (stdliburllib), or extend it. - Dashboard server proxy: follow the
routes/whitelist.jspattern (built-infetch, feature-gated onENABLE_GRAPH_VIEW). - Dashboard client context: follow the
DashboardContext.jsxpattern (createContext+ provider +useEvidence()hook). - Commit only when explicitly asked.
Task 1: Backend Pure Logic — Graph Serializer, Response Parser, Prompt Builder
Files:
- Create: services/api/src/logster_api/evidence.py
- Create: services/api/tests/__init__.py
- Create: services/api/tests/test_evidence.py
Interfaces:
- Consumes: Nothing (first task — pure functions, no dependencies)
- Produces:
- serialize_graph_doc(nodes: list[dict], edges: list[dict], format: str) -> str — takes graph doc arrays, returns adjacency or cypher text
- build_evidence_prompt(graph_text: str, verdict: str, attack_prob: float, confidence: float, rationale: str, techniques: list[str]) -> tuple[str, str] — returns (system_message, user_message)
- parse_evidence_response(raw_response: str, graph_node_ids: set[str], node_event_map: dict[str, str | None]) -> dict — parses LLM JSON, validates node IDs, enriches with event_ids, returns response dict with status, evidence, all_evidence_node_ids, all_evidence_event_ids, validation
- [ ] Step 1: Write failing test for adjacency serialization
# services/api/tests/test_evidence.py
import pytest
def test_serialize_adjacency_basic():
from logster_api.evidence import serialize_graph_doc
nodes = [
{"id": "{AAA}", "type": "process", "attributes": {"executable_name": "cmd.exe", "command_line": "cmd /c dir"}},
{"id": "{BBB}", "type": "file", "attributes": {"file_path": "C:\\temp\\out.txt"}},
]
edges = [
{"src": "{AAA}", "dst": "{BBB}", "type": "WROTE_TO", "timestamp": "2026-09-20T10:00:00Z"},
]
text = serialize_graph_doc(nodes, edges, format="adjacency")
assert "[{AAA}]" in text
assert "type=process" in text
assert "executable_name=cmd.exe" in text
assert "[{BBB}]" in text
assert "type=file" in text
assert "--WROTE_TO-->" in text
assert "timestamp=2026-09-20T10:00:00Z" in text
def test_serialize_adjacency_empty_attributes():
from logster_api.evidence import serialize_graph_doc
nodes = [{"id": "{AAA}", "type": "process", "attributes": {}}]
edges = []
text = serialize_graph_doc(nodes, edges, format="adjacency")
assert "[{AAA}]" in text
assert "type=process" in text
def test_serialize_adjacency_none_attributes():
from logster_api.evidence import serialize_graph_doc
nodes = [{"id": "{AAA}", "type": "process", "attributes": None}]
edges = []
text = serialize_graph_doc(nodes, edges, format="adjacency")
assert "[{AAA}]" in text
- [ ] Step 2: Run test to verify it fails
Run: cd services/api && python -m pytest tests/test_evidence.py::test_serialize_adjacency_basic -v
Expected: FAIL — ImportError: cannot import name 'serialize_graph_doc'
- [ ] Step 3: Implement adjacency serializer
# services/api/src/logster_api/evidence.py
EVIDENCE_SYSTEM_PROMPT = """You are an evidence annotator for a threat detection system. You will receive:
1. A graph of system activity from an endpoint (processes, files, network connections, etc.)
2. A previous analysis verdict and rationale for this graph
Your task: for each numbered step in the provided rationale, identify the specific
node IDs from the graph that are involved in that step. You are NOT re-analyzing
the graph — the verdict and rationale are authoritative. You are mapping the prose
steps to concrete graph node IDs.
Return a JSON object:
{
"evidence": [
{
"step": <step number from the rationale>,
"description": "<brief description of what this step shows>",
"node_ids": ["<node_id_1>", "<node_id_2>"]
}
]
}
Rules:
- Use ONLY node IDs that appear in the graph. Do not invent IDs.
- Each step should reference the nodes directly involved in that step's activity.
- Include both the actor node (e.g., the process) and the target node (e.g., the file written to).
- If a rationale step mentions activity you cannot find specific nodes for, include the step
with an empty node_ids array.
- Return valid JSON only, no markdown formatting."""
def serialize_graph_doc(nodes: list[dict], edges: list[dict], format: str = "adjacency") -> str:
if format == "adjacency":
return _serialize_adjacency(nodes, edges)
elif format == "cypher":
return _serialize_cypher(nodes, edges)
raise ValueError(f"Unknown graph format: {format}")
def _serialize_adjacency(nodes: list[dict], edges: list[dict]) -> str:
lines = ["Nodes:"]
for n in nodes:
attrs = n.get("attributes") or {}
attr_str = " ".join(f"{k}={v}" for k, v in attrs.items() if v is not None)
parts = [f"[{n['id']}]", f"type={n['type']}"]
if attr_str:
parts.append(attr_str)
lines.append(" ".join(parts))
lines.append("")
lines.append("Edges:")
for e in edges:
ts = f" timestamp={e['timestamp']}" if e.get("timestamp") else ""
lines.append(f"[{e['src']}] --{e['type']}--> [{e['dst']}]{ts}")
return "\n".join(lines)
def _serialize_cypher(nodes: list[dict], edges: list[dict]) -> str:
lines = []
id_to_var = {}
for i, n in enumerate(nodes):
var = f"n{i}"
id_to_var[n["id"]] = var
label = n["type"].capitalize()
props = {"id": n["id"]}
props.update({k: v for k, v in (n.get("attributes") or {}).items() if v is not None})
props_str = ", ".join(f'{k}: "{v}"' if isinstance(v, str) else f"{k}: {v}" for k, v in props.items())
lines.append(f"({var}:{label} {{{props_str}}})")
for e in edges:
src_var = id_to_var.get(e["src"], e["src"])
dst_var = id_to_var.get(e["dst"], e["dst"])
props = {}
if e.get("timestamp"):
props["timestamp"] = e["timestamp"]
props_str = f" {{{', '.join(f'{k}: \"{v}\"' for k, v in props.items())}}}" if props else ""
lines.append(f"({src_var})-[:{e['type']}{props_str}]->({dst_var})")
return "\n".join(lines)
- [ ] Step 4: Run tests to verify they pass
Run: cd services/api && python -m pytest tests/test_evidence.py -k "serialize" -v
Expected: 3 PASS
- [ ] Step 5: Write failing test for cypher serialization
# Append to services/api/tests/test_evidence.py
def test_serialize_cypher_basic():
from logster_api.evidence import serialize_graph_doc
nodes = [
{"id": "{AAA}", "type": "process", "attributes": {"executable_name": "cmd.exe"}},
{"id": "{BBB}", "type": "file", "attributes": {"file_path": "C:\\temp\\out.txt"}},
]
edges = [
{"src": "{AAA}", "dst": "{BBB}", "type": "WROTE_TO", "timestamp": "2026-09-20T10:00:00Z"},
]
text = serialize_graph_doc(nodes, edges, format="cypher")
assert "(n0:Process" in text
assert 'id: "{AAA}"' in text
assert "(n1:File" in text
assert "[:WROTE_TO" in text
assert "(n0)-" in text
assert "->(n1)" in text
- [ ] Step 6: Run test to verify it passes (implementation was included in step 3)
Run: cd services/api && python -m pytest tests/test_evidence.py::test_serialize_cypher_basic -v
Expected: PASS
- [ ] Step 7: Write failing test for prompt builder
# Append to services/api/tests/test_evidence.py
def test_build_evidence_prompt():
from logster_api.evidence import build_evidence_prompt
system_msg, user_msg = build_evidence_prompt(
graph_text="[{AAA}] type=process executable_name=cmd.exe",
verdict="attack",
attack_prob=0.92,
confidence=0.88,
rationale="1. cmd.exe spawned powershell.exe\n2. powershell.exe wrote to startup",
techniques=["T1059.001", "T1547.001"],
)
assert "evidence annotator" in system_msg.lower()
assert "node_ids" in system_msg
assert "[{AAA}]" in user_msg
assert "Verdict: attack" in user_msg
assert "cmd.exe spawned powershell.exe" in user_msg
assert "T1059.001" in user_msg
- [ ] Step 8: Implement prompt builder
# Add to services/api/src/logster_api/evidence.py
def build_evidence_prompt(
graph_text: str,
verdict: str,
attack_prob: float,
confidence: float,
rationale: str,
techniques: list[str],
) -> tuple[str, str]:
techniques_str = ", ".join(techniques) if techniques else "none"
user_message = f"""## Graph
{graph_text}
## Previous Analysis
Verdict: {verdict}
Attack probability: {attack_prob}
Confidence: {confidence}
Rationale:
{rationale}
Techniques: {techniques_str}
## Task
For each numbered step in the rationale above, identify the specific node IDs
from the graph that are involved. Return the JSON evidence structure."""
return EVIDENCE_SYSTEM_PROMPT, user_message
- [ ] Step 9: Run test to verify it passes
Run: cd services/api && python -m pytest tests/test_evidence.py::test_build_evidence_prompt -v
Expected: PASS
- [ ] Step 10: Write failing tests for response parser
# Append to services/api/tests/test_evidence.py
import json
def test_parse_evidence_response_valid():
from logster_api.evidence import parse_evidence_response
raw = json.dumps({
"evidence": [
{"step": 1, "description": "cmd spawned powershell", "node_ids": ["{AAA}", "{BBB}"]},
{"step": 2, "description": "powershell wrote file", "node_ids": ["{BBB}", "{CCC}"]},
]
})
graph_node_ids = {"{AAA}", "{BBB}", "{CCC}", "{DDD}"}
node_event_map = {"{AAA}": "evt-1", "{BBB}": "evt-2", "{CCC}": "evt-3", "{DDD}": None}
result = parse_evidence_response(raw, graph_node_ids, node_event_map)
assert result["status"] == "success"
assert len(result["evidence"]) == 2
assert result["evidence"][0]["node_ids"] == ["{AAA}", "{BBB}"]
assert result["evidence"][0]["event_ids"] == ["evt-1", "evt-2"]
assert set(result["all_evidence_node_ids"]) == {"{AAA}", "{BBB}", "{CCC}"}
assert set(result["all_evidence_event_ids"]) == {"evt-1", "evt-2", "evt-3"}
assert result["validation"]["valid_node_ids"] == 4
assert result["validation"]["dropped_node_ids"] == 0
def test_parse_evidence_response_drops_invalid_node_ids():
from logster_api.evidence import parse_evidence_response
raw = json.dumps({
"evidence": [
{"step": 1, "description": "test", "node_ids": ["{AAA}", "{INVALID}"]},
]
})
graph_node_ids = {"{AAA}", "{BBB}"}
node_event_map = {"{AAA}": "evt-1", "{BBB}": "evt-2"}
result = parse_evidence_response(raw, graph_node_ids, node_event_map)
assert result["status"] == "success"
assert result["evidence"][0]["node_ids"] == ["{AAA}"]
assert result["validation"]["dropped_node_ids"] == 1
def test_parse_evidence_response_all_invalid_returns_error():
from logster_api.evidence import parse_evidence_response
raw = json.dumps({
"evidence": [
{"step": 1, "description": "test", "node_ids": ["{X}", "{Y}"]},
]
})
result = parse_evidence_response(raw, set(), {})
assert result["status"] == "error"
assert "no valid" in result["reason"].lower()
def test_parse_evidence_response_invalid_json():
from logster_api.evidence import parse_evidence_response
result = parse_evidence_response("not json at all", set(), {})
assert result["status"] == "error"
assert result["reason"] == "parse_failed"
def test_parse_evidence_response_missing_evidence_key():
from logster_api.evidence import parse_evidence_response
raw = json.dumps({"something_else": []})
result = parse_evidence_response(raw, set(), {})
assert result["status"] == "error"
assert result["reason"] == "parse_failed"
def test_parse_evidence_response_markdown_wrapped_json():
from logster_api.evidence import parse_evidence_response
raw = '```json\n{"evidence": [{"step": 1, "description": "test", "node_ids": ["{AAA}"]}]}\n```'
result = parse_evidence_response(raw, {"{AAA}"}, {"{AAA}": "evt-1"})
assert result["status"] == "success"
assert result["evidence"][0]["node_ids"] == ["{AAA}"]
- [ ] Step 11: Implement response parser
# Add to services/api/src/logster_api/evidence.py
import json
import logging
import re
logger = logging.getLogger(__name__)
def _extract_json(raw: str) -> str:
stripped = raw.strip()
match = re.search(r"```(?:json)?\s*\n?(.*?)\n?```", stripped, re.DOTALL)
if match:
return match.group(1).strip()
return stripped
def parse_evidence_response(
raw_response: str,
graph_node_ids: set[str],
node_event_map: dict[str, str | None],
) -> dict:
try:
data = json.loads(_extract_json(raw_response))
except (json.JSONDecodeError, ValueError) as e:
return {"status": "error", "reason": "parse_failed", "error": str(e)}
if "evidence" not in data or not isinstance(data["evidence"], list):
return {"status": "error", "reason": "parse_failed", "error": "missing 'evidence' array"}
validated_steps = []
all_node_ids = set()
all_event_ids = set()
total_returned = 0
total_dropped = 0
for step in data["evidence"]:
step_num = step.get("step")
description = step.get("description", "")
raw_node_ids = step.get("node_ids", [])
if not isinstance(raw_node_ids, list):
raw_node_ids = []
total_returned += len(raw_node_ids)
valid_ids = [nid for nid in raw_node_ids if nid in graph_node_ids]
total_dropped += len(raw_node_ids) - len(valid_ids)
if total_dropped > 0 and len(valid_ids) < len(raw_node_ids):
dropped = [nid for nid in raw_node_ids if nid not in graph_node_ids]
logger.warning("Evidence step %s: dropped invalid node_ids: %s", step_num, dropped)
event_ids = [node_event_map[nid] for nid in valid_ids if node_event_map.get(nid)]
all_node_ids.update(valid_ids)
all_event_ids.update(event_ids)
validated_steps.append({
"step": step_num,
"description": description,
"node_ids": valid_ids,
"event_ids": event_ids,
})
if total_returned > 0 and len(all_node_ids) == 0:
return {
"status": "error",
"reason": "no valid node IDs in evidence",
"error": f"all {total_returned} returned node_ids were invalid",
}
return {
"status": "success",
"evidence": validated_steps,
"all_evidence_node_ids": sorted(all_node_ids),
"all_evidence_event_ids": sorted(all_event_ids),
"validation": {
"total_node_ids_returned": total_returned,
"valid_node_ids": total_returned - total_dropped,
"dropped_node_ids": total_dropped,
},
}
- [ ] Step 12: Run all tests to verify they pass
Run: cd services/api && python -m pytest tests/test_evidence.py -v
Expected: All PASS
- [ ] Step 13: Commit
git add services/api/src/logster_api/evidence.py services/api/tests/__init__.py services/api/tests/test_evidence.py
git commit -m "feat(api): evidence extraction pure logic — serializer, parser, prompt builder"
Task 2: Backend — Evidence Extraction Endpoint
Files:
- Modify: services/api/src/logster_api/app.py — add evidence route
- Modify: services/api/src/logster_api/evidence.py — add extract_evidence() orchestrator and call_llm() function
- Modify: services/api/src/logster_api/main.py — load LogsterClassifierConfig
- Modify: services/api/src/logster_api/whitelist_store.py — add get_inference_doc() and update_graph_doc() methods
- Create: services/api/tests/test_evidence_endpoint.py
- Modify: deploy/docker-compose.yml — add OPENROUTER_API_KEY to api service
- Modify: deploy/elasticsearch/logster-graphs.template.json — add evidence mapping
Interfaces:
- Consumes:
- serialize_graph_doc(nodes, edges, format) from Task 1
- build_evidence_prompt(graph_text, verdict, attack_prob, confidence, rationale, techniques) from Task 1
- parse_evidence_response(raw_response, graph_node_ids, node_event_map) from Task 1
- WhitelistStore.get_graph_doc(inference_id) — already exists
- Produces:
- POST /api/evidence/{inference_id} endpoint returning the evidence response schema from the spec
- extract_evidence(inference_id, store, lc_config) — orchestrator function
- WhitelistStore.get_inference_doc(inference_id) — fetches from logster-inferences
- WhitelistStore.update_graph_doc(doc_id, partial) — ES partial update
- [ ] Step 1: Write failing test for the endpoint — cache hit path
# services/api/tests/test_evidence_endpoint.py
import json
from unittest.mock import MagicMock, patch
import pytest
from logster_api.app import create_app
@pytest.fixture
def mock_store():
store = MagicMock()
store.get_graph_doc.return_value = None
store.get_inference_doc.return_value = None
store.update_graph_doc.return_value = None
return store
@pytest.fixture
def lc_config():
from logster_common.config import LogsterClassifierConfig
return LogsterClassifierConfig(
enabled=True,
service_url="http://fake-llm:8000/v1",
model="test-model",
api_key="test-key",
graph_format="adjacency",
)
@pytest.fixture
def app(mock_store, lc_config):
application = create_app(whitelist_store=mock_store)
application.state.lc_config = lc_config
return application
@pytest.fixture
def client(app):
from fastapi.testclient import TestClient
return TestClient(app)
def test_evidence_cache_hit(client, mock_store):
cached_evidence = {
"status": "success",
"evidence": [{"step": 1, "description": "test", "node_ids": ["{A}"], "event_ids": ["e1"]}],
"all_evidence_node_ids": ["{A}"],
"all_evidence_event_ids": ["e1"],
"validation": {"total_node_ids_returned": 1, "valid_node_ids": 1, "dropped_node_ids": 0},
}
mock_store.get_graph_doc.return_value = {
"_id": "doc-1",
"inference_id": "inf-1",
"evidence": cached_evidence,
"nodes": [],
"edges": [],
}
resp = client.post("/api/evidence/inf-1")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "success"
assert len(data["evidence"]) == 1
mock_store.update_graph_doc.assert_not_called()
- [ ] Step 2: Run test to verify it fails
Run: cd services/api && python -m pytest tests/test_evidence_endpoint.py::test_evidence_cache_hit -v
Expected: FAIL — endpoint does not exist
- [ ] Step 3: Write failing test for graph-not-found path
# Append to services/api/tests/test_evidence_endpoint.py
def test_evidence_graph_not_found(client, mock_store):
mock_store.get_graph_doc.return_value = None
resp = client.post("/api/evidence/inf-missing")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "unavailable"
assert "graph_not_persisted" in data["reason"]
- [ ] Step 4: Write failing test for benign-verdict path
# Append to services/api/tests/test_evidence_endpoint.py
def test_evidence_benign_verdict(client, mock_store):
mock_store.get_graph_doc.return_value = {
"_id": "doc-1",
"inference_id": "inf-1",
"nodes": [],
"edges": [],
}
mock_store.get_inference_doc.return_value = {
"inference_id": "inf-1",
"lc_verdict": "benign",
"lc_rationale": "",
"lc_techniques": [],
"lc_attack_prob": 0.1,
"lc_confidence": 0.9,
}
resp = client.post("/api/evidence/inf-1")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "not_applicable"
- [ ] Step 5: Write failing test for full extraction path (mocked LLM)
# Append to services/api/tests/test_evidence_endpoint.py
def test_evidence_full_extraction(client, mock_store):
mock_store.get_graph_doc.return_value = {
"_id": "doc-1",
"inference_id": "inf-1",
"nodes": [
{"id": "{AAA}", "type": "process", "attributes": {"executable_name": "cmd.exe"}, "originating_event_id": "evt-1"},
{"id": "{BBB}", "type": "file", "attributes": {"file_path": "C:\\out.txt"}, "originating_event_id": "evt-2"},
],
"edges": [
{"src": "{AAA}", "dst": "{BBB}", "type": "WROTE_TO", "timestamp": "2026-09-20T10:00:00Z"},
],
}
mock_store.get_inference_doc.return_value = {
"inference_id": "inf-1",
"lc_verdict": "attack",
"lc_rationale": "1. cmd.exe wrote malicious file",
"lc_techniques": ["T1059.001"],
"lc_attack_prob": 0.92,
"lc_confidence": 0.88,
"lc_prompt": None,
}
llm_response = json.dumps({
"evidence": [{"step": 1, "description": "cmd.exe wrote file", "node_ids": ["{AAA}", "{BBB}"]}]
})
with patch("logster_api.evidence.call_llm", return_value=llm_response):
resp = client.post("/api/evidence/inf-1")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "success"
assert len(data["evidence"]) == 1
assert data["evidence"][0]["node_ids"] == ["{AAA}", "{BBB}"]
assert data["evidence"][0]["event_ids"] == ["evt-1", "evt-2"]
mock_store.update_graph_doc.assert_called_once()
- [ ] Step 6: Write failing test for LLM failure path
# Append to services/api/tests/test_evidence_endpoint.py
def test_evidence_llm_failure(client, mock_store):
mock_store.get_graph_doc.return_value = {
"_id": "doc-1",
"inference_id": "inf-1",
"nodes": [{"id": "{AAA}", "type": "process", "attributes": {}, "originating_event_id": "evt-1"}],
"edges": [],
}
mock_store.get_inference_doc.return_value = {
"inference_id": "inf-1",
"lc_verdict": "attack",
"lc_rationale": "1. suspicious process",
"lc_techniques": [],
"lc_attack_prob": 0.85,
"lc_confidence": 0.90,
"lc_prompt": None,
}
with patch("logster_api.evidence.call_llm", side_effect=Exception("LLM timeout")):
resp = client.post("/api/evidence/inf-1")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "error"
assert "llm" in data["reason"].lower() or "timeout" in data.get("error", "").lower()
- [ ] Step 7: Implement the endpoint and orchestrator
Add get_inference_doc and update_graph_doc to WhitelistStore:
# Add to services/api/src/logster_api/whitelist_store.py
def get_inference_doc(self, inference_id: str) -> dict | None:
try:
body = {"query": {"term": {"inference_id": inference_id}}, "size": 1}
resp = self._request("POST", f"{self._inferences_index}/_search", body)
hits = resp.get("hits", {}).get("hits", [])
return hits[0]["_source"] if hits else None
except Exception:
return None
def update_graph_doc(self, doc_id: str, partial: dict) -> None:
self._request("POST", f"{self._graphs_index}/_update/{doc_id}?refresh=true", {"doc": partial})
Add call_llm and extract_evidence to evidence.py:
# Add to services/api/src/logster_api/evidence.py
import urllib.request
import urllib.error
from datetime import datetime, timezone
def call_llm(system_message: str, user_message: str, config) -> str:
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {config.api_key}",
}
payload = json.dumps({
"model": config.model,
"messages": [
{"role": "system", "content": system_message},
{"role": "user", "content": user_message},
],
"temperature": 0.0,
})
url = f"{config.service_url}/chat/completions"
if config.use_https and url.startswith("http://"):
url = "https://" + url[7:]
req = urllib.request.Request(url, data=payload.encode(), headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=config.timeout) as resp:
data = json.loads(resp.read().decode())
return data["choices"][0]["message"]["content"]
def extract_evidence(inference_id: str, store, lc_config) -> dict:
graph_doc = store.get_graph_doc(inference_id)
if graph_doc is None:
return {"status": "unavailable", "reason": "graph_not_persisted"}
doc_id = graph_doc.get("_id", inference_id)
cached = graph_doc.get("evidence")
if cached and cached.get("status") == "success":
return cached
inference = store.get_inference_doc(inference_id)
if inference is None:
return {"status": "unavailable", "reason": "inference_not_found"}
verdict = inference.get("lc_verdict", "")
if verdict != "attack":
return {"status": "not_applicable", "reason": "benign_verdict"}
rationale = inference.get("lc_rationale", "")
techniques = inference.get("lc_techniques") or []
attack_prob = inference.get("lc_attack_prob", 0.0)
confidence = inference.get("lc_confidence", 0.0)
graph_text = inference.get("lc_prompt")
if not graph_text:
graph_text = serialize_graph_doc(
graph_doc.get("nodes", []),
graph_doc.get("edges", []),
format=lc_config.graph_format,
)
nodes = graph_doc.get("nodes", [])
graph_node_ids = {n["id"] for n in nodes}
node_event_map = {n["id"]: n.get("originating_event_id") for n in nodes}
system_msg, user_msg = build_evidence_prompt(
graph_text=graph_text,
verdict=verdict,
attack_prob=attack_prob,
confidence=confidence,
rationale=rationale,
techniques=techniques,
)
try:
raw_response = call_llm(system_msg, user_msg, lc_config)
except Exception as e:
logger.error("Evidence LLM call failed for %s: %s", inference_id, e)
return {"status": "error", "reason": "llm_call_failed", "error": str(e)}
result = parse_evidence_response(raw_response, graph_node_ids, node_event_map)
if result["status"] == "success":
cache_doc = {
**result,
"extracted_at": datetime.now(timezone.utc).isoformat(),
"model": lc_config.model,
}
try:
store.update_graph_doc(doc_id, {"evidence": cache_doc})
except Exception as e:
logger.warning("Failed to cache evidence for %s: %s", inference_id, e)
return result
Add the route to app.py:
# Add inside create_app() in services/api/src/logster_api/app.py
@app.post("/api/evidence/{inference_id}")
async def post_evidence(inference_id: str):
store = _whitelist_store()
lc_config = getattr(app.state, "lc_config", None)
if lc_config is None or not lc_config.enabled:
raise HTTPException(503, "Evidence extraction not configured")
from logster_api.evidence import extract_evidence
return extract_evidence(inference_id, store, lc_config)
Update main.py to load LogsterClassifierConfig and set it on app.state:
# In services/api/src/logster_api/main.py, after loading other config:
from logster_common.config import LogsterClassifierConfig
from logster_common.config_hydrate import hydrate
lc_config = hydrate(LogsterClassifierConfig, raw.get("logster_classifier") or {}, env=os.environ)
# After create_app():
app.state.lc_config = lc_config
- [ ] Step 8: Run tests to verify they pass
Run: cd services/api && python -m pytest tests/test_evidence_endpoint.py -v
Expected: All PASS
- [ ] Step 9: Update ES index template
Add to deploy/elasticsearch/logster-graphs.template.json inside the properties block:
- [ ] Step 10: Update docker-compose.yml
Add OPENROUTER_API_KEY to the api service environment block in deploy/docker-compose.yml:
- [ ] Step 11: Run all tests
Run: cd services/api && python -m pytest tests/ -v
Expected: All PASS
- [ ] Step 12: Commit
git add services/api/src/logster_api/app.py services/api/src/logster_api/evidence.py \
services/api/src/logster_api/main.py services/api/src/logster_api/whitelist_store.py \
services/api/tests/test_evidence_endpoint.py \
deploy/elasticsearch/logster-graphs.template.json deploy/docker-compose.yml
git commit -m "feat(api): evidence extraction endpoint with ES caching and LLM call"
Task 3: Dashboard Server — Express Proxy Route
Files:
- Create: services/dashboard/server/src/routes/evidence.js
- Modify: services/dashboard/server/src/routes/index.js — register evidence route
- Create: services/dashboard/server/test/evidence.test.js
Interfaces:
- Consumes: POST /api/evidence/{inference_id} from the Python API service (Task 2)
- Produces: POST /api/evidence/:inferenceId on the Express server — proxies to the API service
- [ ] Step 1: Write failing test for the proxy route
// services/dashboard/server/test/evidence.test.js
import { describe, it, mock, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
describe('evidence proxy route', () => {
it('should forward POST to API service and return response', async () => {
const mockResponse = {
status: 'success',
evidence: [{ step: 1, description: 'test', node_ids: ['{A}'], event_ids: ['e1'] }],
all_evidence_node_ids: ['{A}'],
all_evidence_event_ids: ['e1'],
validation: { total_node_ids_returned: 1, valid_node_ids: 1, dropped_node_ids: 0 },
};
const originalFetch = globalThis.fetch;
globalThis.fetch = mock.fn(async () => ({
ok: true,
json: async () => mockResponse,
}));
try {
const { default: router } = await import('../src/routes/evidence.js');
assert.ok(router, 'evidence route module should export a router');
} finally {
globalThis.fetch = originalFetch;
}
});
});
- [ ] Step 2: Run test to verify it fails
Run: cd services/dashboard/server && node --test test/evidence.test.js
Expected: FAIL — module not found
- [ ] Step 3: Implement the proxy route
// services/dashboard/server/src/routes/evidence.js
import { Router } from 'express';
import { LOGSTER_API_URL, ENABLE_GRAPH_VIEW } from '../config.js';
const router = Router();
router.post('/evidence/:inferenceId', async (req, res) => {
if (!ENABLE_GRAPH_VIEW) {
return res.status(404).json({ error: 'Graph view not enabled' });
}
const { inferenceId } = req.params;
const url = `${LOGSTER_API_URL}/api/evidence/${encodeURIComponent(inferenceId)}`;
try {
const upstream = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const data = await upstream.json();
res.status(upstream.status).json(data);
} catch (err) {
console.error('Evidence proxy error:', err.message);
res.status(502).json({ status: 'error', reason: 'api_unreachable', error: err.message });
}
});
export default router;
- [ ] Step 4: Register the route in the routes index
Add to services/dashboard/server/src/routes/index.js:
- [ ] Step 5: Run test to verify it passes
Run: cd services/dashboard/server && node --test test/evidence.test.js
Expected: PASS
- [ ] Step 6: Commit
git add services/dashboard/server/src/routes/evidence.js services/dashboard/server/src/routes/index.js \
services/dashboard/server/test/evidence.test.js
git commit -m "feat(dashboard): express proxy route for evidence extraction"
Task 4: Dashboard Client — EvidenceContext + API Helper
Files:
- Create: services/dashboard/client/src/context/EvidenceContext.jsx
- Modify: services/dashboard/client/src/lib/api.js — add fetchEvidence helper
- Modify: services/dashboard/client/src/App.jsx — wrap with EvidenceProvider
Interfaces:
- Consumes: POST /api/evidence/:inferenceId from Express proxy (Task 3)
- Produces:
- EvidenceProvider — React context provider wrapping the modal layer
- useEvidence() — hook returning { getEvidence(id), fetchEvidence(id) } where getEvidence returns { status: 'idle'|'loading'|'success'|'error', evidence: EvidenceResponse|null, error: string|null } and fetchEvidence triggers the fetch (no-op if already loading)
- [ ] Step 1: Add fetchEvidence API helper
Add to services/dashboard/client/src/lib/api.js:
export async function fetchEvidenceApi(inferenceId, { signal } = {}) {
return apiMutate(`/api/evidence/${inferenceId}`, { method: 'POST', signal });
}
- [ ] Step 2: Create EvidenceContext
// services/dashboard/client/src/context/EvidenceContext.jsx
import { createContext, useCallback, useContext, useRef, useState } from 'react';
import { fetchEvidenceApi } from '../lib/api.js';
const EvidenceContext = createContext();
export function EvidenceProvider({ children }) {
const [state, setState] = useState({});
const inflightRef = useRef({});
const getEvidence = useCallback(
(inferenceId) => state[inferenceId] || { status: 'idle', evidence: null, error: null },
[state],
);
const fetchEvidence = useCallback((inferenceId) => {
if (inflightRef.current[inferenceId]) return;
setState((prev) => ({
...prev,
[inferenceId]: { status: 'loading', evidence: null, error: null },
}));
inflightRef.current[inferenceId] = true;
fetchEvidenceApi(inferenceId)
.then((data) => {
setState((prev) => ({
...prev,
[inferenceId]: {
status: data.status === 'success' ? 'success' : 'error',
evidence: data.status === 'success' ? data : null,
error: data.status !== 'success' ? (data.reason || data.error || 'Unknown error') : null,
},
}));
})
.catch((err) => {
setState((prev) => ({
...prev,
[inferenceId]: { status: 'error', evidence: null, error: err.message },
}));
})
.finally(() => {
delete inflightRef.current[inferenceId];
});
}, []);
const retryEvidence = useCallback((inferenceId) => {
delete inflightRef.current[inferenceId];
setState((prev) => {
const next = { ...prev };
delete next[inferenceId];
return next;
});
fetchEvidence(inferenceId);
}, [fetchEvidence]);
return (
<EvidenceContext.Provider value={{ getEvidence, fetchEvidence, retryEvidence }}>
{children}
</EvidenceContext.Provider>
);
}
export function useEvidence() {
const ctx = useContext(EvidenceContext);
if (!ctx) throw new Error('useEvidence must be used within EvidenceProvider');
return ctx;
}
- [ ] Step 3: Wrap modals with EvidenceProvider in App.jsx
In services/dashboard/client/src/App.jsx, import and wrap the modal section:
import { EvidenceProvider } from './context/EvidenceContext.jsx';
// Inside Dashboard() return, wrap the modals:
<EvidenceProvider>
<InvestigationModal />
<GraphModal />
<SearchModal />
</EvidenceProvider>
- [ ] Step 4: Verify — start dev server, open browser, check console for no errors
Run: cd services/dashboard/client && npm run dev
Open http://localhost:5001 — confirm no React errors in console, dashboard loads normally.
- [ ] Step 5: Commit
git add services/dashboard/client/src/context/EvidenceContext.jsx \
services/dashboard/client/src/lib/api.js services/dashboard/client/src/App.jsx
git commit -m "feat(dashboard): evidence context provider and API helper"
Task 5: Dashboard Client — InteractiveRationale + EvidenceProgressBar Components
Files:
- Create: services/dashboard/client/src/components/common/InteractiveRationale.jsx
- Create: services/dashboard/client/src/components/common/InteractiveRationale.css
- Create: services/dashboard/client/src/components/modal/EvidenceProgressBar.jsx
- Create: services/dashboard/client/src/components/modal/EvidenceProgressBar.css
Interfaces:
- Consumes:
- splitRationale(text) from lib/formatters.js (existing)
- Evidence response shape from EvidenceContext (Task 4)
- Produces:
- InteractiveRationale({ text, evidence, activeStep, onStepClick, className }) — renders rationale text with clickable steps when evidence is present; falls back to plain RationaleText behavior when no evidence
- EvidenceProgressBar({ status }) — slim indeterminate progress bar visible during loading state
- [ ] Step 1: Create EvidenceProgressBar
/* services/dashboard/client/src/components/modal/EvidenceProgressBar.css */
.evidence-progress-bar {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
overflow: hidden;
z-index: 10;
}
.evidence-progress-bar .bar {
height: 100%;
width: 40%;
background: var(--accent-yellow, #fbbf24);
border-radius: 2px;
animation: evidence-slide 1.5s ease-in-out infinite;
}
@keyframes evidence-slide {
0% { transform: translateX(-100%); }
100% { transform: translateX(350%); }
}
// services/dashboard/client/src/components/modal/EvidenceProgressBar.jsx
import './EvidenceProgressBar.css';
export default function EvidenceProgressBar({ status }) {
if (status !== 'loading') return null;
return (
<div className="evidence-progress-bar">
<div className="bar" />
</div>
);
}
- [ ] Step 2: Create InteractiveRationale
/* services/dashboard/client/src/components/common/InteractiveRationale.css */
.interactive-rationale .rationale-step {
padding: 4px 6px;
margin: 2px 0;
border-radius: 4px;
cursor: pointer;
padding-left: 1.35em;
text-indent: -1.35em;
transition: background 0.15s;
}
.interactive-rationale .rationale-step:hover {
background: var(--surface-hover, rgba(255, 255, 255, 0.06));
}
.interactive-rationale .rationale-step.active {
background: var(--surface-active, rgba(251, 191, 36, 0.12));
border-left: 3px solid var(--accent-yellow, #fbbf24);
padding-left: calc(1.35em - 3px);
}
.interactive-rationale .rationale-step .step-nodes-hint {
font-size: 0.75em;
opacity: 0.6;
margin-left: 0.5em;
}
// services/dashboard/client/src/components/common/InteractiveRationale.jsx
import { splitRationale } from '../../lib/formatters.js';
import RationaleText from './RationaleText.jsx';
import './InteractiveRationale.css';
export default function InteractiveRationale({
text,
evidence,
activeStep,
onStepClick,
className = '',
style,
}) {
if (!evidence || !evidence.evidence || evidence.evidence.length === 0) {
return <RationaleText text={text} className={className} style={style} />;
}
const parts = splitRationale(text);
if (parts.length === 0) return null;
const handleClick = (stepIndex) => {
const step = evidence.evidence[stepIndex];
if (!step) return;
if (activeStep === step.step) {
onStepClick?.(null, [], []);
} else {
onStepClick?.(step.step, step.node_ids || [], step.event_ids || []);
}
};
return (
<div className={`interactive-rationale ${className}`} style={style}>
{parts.map((part, i) => {
const step = evidence.evidence[i];
const isActive = step && activeStep === step.step;
const nodeCount = step?.node_ids?.length || 0;
return (
<div
key={i}
className={`rationale-step${isActive ? ' active' : ''}`}
onClick={() => handleClick(i)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && handleClick(i)}
>
{part}
{nodeCount > 0 && (
<span className="step-nodes-hint">({nodeCount} nodes)</span>
)}
</div>
);
})}
</div>
);
}
- [ ] Step 3: Verify — start dev server, confirm no import errors
Run: cd services/dashboard/client && npm run dev
Check console — no errors. Components are created but not yet wired into modals (that's Tasks 6 and 7).
- [ ] Step 4: Commit
git add services/dashboard/client/src/components/common/InteractiveRationale.jsx \
services/dashboard/client/src/components/common/InteractiveRationale.css \
services/dashboard/client/src/components/modal/EvidenceProgressBar.jsx \
services/dashboard/client/src/components/modal/EvidenceProgressBar.css
git commit -m "feat(dashboard): InteractiveRationale and EvidenceProgressBar components"
Task 6: Dashboard Client — InvestigationModal Evidence Integration
Files:
- Modify: services/dashboard/client/src/components/modal/InvestigationModal.jsx — trigger evidence fetch, reorder events, integrate progress bar
- Modify: services/dashboard/client/src/components/modal/InferenceSummary.jsx — use InteractiveRationale, Graph button enhanced state
- Modify: services/dashboard/client/src/components/modal/EventCard.jsx — accept evidence highlight props
- Modify: services/dashboard/client/src/components/modal/EventCard.css (or equivalent) — evidence highlight styles
Interfaces:
- Consumes:
- useEvidence() from EvidenceContext (Task 4)
- InteractiveRationale component (Task 5)
- EvidenceProgressBar component (Task 5)
- Produces:
- InvestigationModal triggers evidence fetch on mount for attack predictions
- Events reordered: evidence-linked events first, with visual highlighting and step badges
- Rationale steps are clickable — clicking scrolls to the first event in that step
- [ ] Step 1: Modify EventCard to accept evidence props
Add to services/dashboard/client/src/components/modal/EventCard.jsx:
- New props: isEvidence (boolean), stepLabel (string like "Step 1")
- When isEvidence is true: add evidence-event CSS class for left border + background tint
- Show stepLabel as a small pill badge
// Modify EventCard signature:
export default function EventCard({ event, isEvidence = false, stepLabel = null, cardRef }) {
return (
<div className={`event-card${isEvidence ? ' evidence-event' : ''}`} ref={cardRef}>
{stepLabel && <span className="evidence-step-badge">{stepLabel}</span>}
{/* ... existing content unchanged ... */}
</div>
);
}
Add CSS for evidence highlighting:
/* Add to the EventCard CSS file */
.event-card.evidence-event {
border-left: 3px solid var(--accent-yellow, #fbbf24);
background: rgba(251, 191, 36, 0.06);
}
.evidence-step-badge {
display: inline-block;
font-size: 0.7em;
font-weight: 600;
padding: 1px 8px;
border-radius: 10px;
background: var(--accent-yellow, #fbbf24);
color: #1a1a1a;
margin-bottom: 6px;
}
.evidence-divider {
border: none;
border-top: 1px dashed var(--border, rgba(255, 255, 255, 0.1));
margin: 12px 0;
position: relative;
}
.evidence-divider::after {
content: 'Other events';
position: absolute;
top: -8px;
left: 12px;
font-size: 0.7em;
opacity: 0.5;
background: var(--surface, #1a1a2e);
padding: 0 6px;
}
- [ ] Step 2: Modify InvestigationModal to trigger fetch and reorder events
// In services/dashboard/client/src/components/modal/InvestigationModal.jsx
import { useEvidence } from '../../context/EvidenceContext.jsx';
import EvidenceProgressBar from './EvidenceProgressBar.jsx';
import { useEffect, useRef, useMemo, useState, useCallback } from 'react';
// Inside the component:
const { getEvidence, fetchEvidence } = useEvidence();
const [activeStep, setActiveStep] = useState(null);
const eventRefs = useRef({});
// Trigger fetch on mount for attack predictions
useEffect(() => {
if (data?.inference?.prediction === 'attack' && data.inference.inference_id) {
fetchEvidence(data.inference.inference_id);
}
}, [data?.inference?.inference_id, data?.inference?.prediction, fetchEvidence]);
const evidenceState = data?.inference?.inference_id
? getEvidence(data.inference.inference_id)
: { status: 'idle', evidence: null };
// Build evidence event ID set and step mapping
const { orderedEvents, evidenceEventIds, eventStepMap } = useMemo(() => {
if (!data?.events || evidenceState.status !== 'success' || !evidenceState.evidence) {
return { orderedEvents: data?.events || [], evidenceEventIds: new Set(), eventStepMap: {} };
}
const evIds = new Set(evidenceState.evidence.all_evidence_event_ids || []);
const stepMap = {};
for (const step of evidenceState.evidence.evidence || []) {
for (const eid of step.event_ids || []) {
if (!stepMap[eid]) stepMap[eid] = `Step ${step.step}`;
}
}
const evidenceEvents = data.events.filter((e) => evIds.has(e.event_id));
const otherEvents = data.events.filter((e) => !evIds.has(e.event_id));
return {
orderedEvents: [...evidenceEvents, ...(evidenceEvents.length > 0 && otherEvents.length > 0 ? [null] : []), ...otherEvents],
evidenceEventIds: evIds,
eventStepMap: stepMap,
};
}, [data?.events, evidenceState]);
const handleStepClick = useCallback((step, nodeIds, eventIds) => {
setActiveStep(step);
if (eventIds?.length > 0) {
const firstRef = eventRefs.current[eventIds[0]];
if (firstRef) firstRef.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, []);
// In the JSX return, add progress bar and pass evidence to InferenceSummary:
// <div style={{ position: 'relative' }}>
// <EvidenceProgressBar status={evidenceState.status} />
// <InferenceSummary
// inference={data.inference}
// evidence={evidenceState.evidence}
// activeStep={activeStep}
// onStepClick={handleStepClick}
// />
// </div>
// Replace the events map with:
// {orderedEvents.map((ev) =>
// ev === null ? (
// <hr key="divider" className="evidence-divider" />
// ) : (
// <EventCard
// key={ev.event_id}
// event={ev}
// isEvidence={evidenceEventIds.has(ev.event_id)}
// stepLabel={eventStepMap[ev.event_id] || null}
// cardRef={(el) => { if (el) eventRefs.current[ev.event_id] = el; }}
// />
// )
// )}
- [ ] Step 3: Modify InferenceSummary to use InteractiveRationale
// In services/dashboard/client/src/components/modal/InferenceSummary.jsx
import InteractiveRationale from '../common/InteractiveRationale.jsx';
// Add props: evidence, activeStep, onStepClick
// Replace the RationaleText usage:
// <InteractiveRationale
// text={rationale}
// evidence={evidence}
// activeStep={activeStep}
// onStepClick={onStepClick}
// className="inf-stat-value inf-stat-ttp-explanation"
// />
- [ ] Step 4: Verify in browser — open an attack inference's Investigate panel
Run: cd deploy && docker compose --profile services up -d
Generate attack traffic, open dashboard, click Investigate on an attack.
Verify:
- Progress bar appears during LLM call
- Events reorder when evidence arrives (evidence events on top)
- Evidence events have amber left border and step badge
- Divider separates evidence events from others
- Clicking a rationale step scrolls to the corresponding event
- [ ] Step 5: Commit
git add services/dashboard/client/src/components/modal/InvestigationModal.jsx \
services/dashboard/client/src/components/modal/InferenceSummary.jsx \
services/dashboard/client/src/components/modal/EventCard.jsx \
services/dashboard/client/src/components/modal/EventCard.css
git commit -m "feat(dashboard): investigation modal evidence highlighting and event reordering"
Task 7: Dashboard Client — GraphModal Evidence Highlight Mode
Files:
- Modify: services/dashboard/client/src/components/graph/CytoscapeGraph.jsx — add evidence highlight mode
- Modify: services/dashboard/client/src/components/graph/GraphModal.jsx — pass evidence data, wire toggle + step click
- Modify: services/dashboard/client/src/components/graph/DebugPanel.jsx — use InteractiveRationale
- Modify: services/dashboard/client/src/theme.js (or equivalent theme file) — add evidence highlight color
Interfaces:
- Consumes:
- useEvidence() from EvidenceContext (Task 4)
- InteractiveRationale component (Task 5)
- Existing CytoscapeGraph highlight pattern (.dimmed class, cy.batch(), useEffect)
- Produces:
- New highlightEvidenceNodes prop on CytoscapeGraph — { allNodeIds: Set, activeStepNodeIds: Set | null }
- "Evidence" toggle button in GraphModal Inspect tab sidebar
- Per-step spotlight from DebugPanel rationale clicks
- [ ] Step 1: Add evidence color to theme
In the theme file (find the GRAPH_CANVAS or CANVAS object), add:
evidence: '#fbbf24', // amber — used for evidence highlight border + glow
evidenceDim: 'rgba(251, 191, 36, 0.3)',
- [ ] Step 2: Add evidence highlight mode to CytoscapeGraph
Modify services/dashboard/client/src/components/graph/CytoscapeGraph.jsx:
Add new prop:
CytoscapeGraph({
doc,
highlightId = null,
highlightSubgraphIndex = null,
highlightComponentOf = null,
highlightTopAttention = false,
highlightEvidenceNodes = null, // { allNodeIds: Set, activeStepNodeIds: Set|null }
onNodeClick,
onEdgeClick,
})
Add Cytoscape stylesheet entries (inside the style array):
{
selector: 'node.evidence-node',
style: {
'border-color': CANVAS.evidence,
'border-width': 3,
'border-opacity': 1,
},
},
{
selector: 'edge.evidence-edge',
style: {
'line-color': CANVAS.evidence,
'opacity': 1,
'width': 2.5,
},
},
Add useEffect for evidence highlighting (modeled on highlightSubgraphIndex pattern):
useEffect(() => {
const cy = cyRef.current;
if (!cy) return;
cy.batch(() => {
cy.nodes().removeClass('evidence-node');
cy.edges().removeClass('evidence-edge');
if (!highlightEvidenceNodes) {
cy.elements().removeClass('dimmed');
return;
}
const targetIds = highlightEvidenceNodes.activeStepNodeIds || highlightEvidenceNodes.allNodeIds;
if (!targetIds || targetIds.size === 0) return;
cy.nodes().forEach((node) => {
if (targetIds.has(node.id())) {
node.addClass('evidence-node');
node.removeClass('dimmed');
} else {
node.addClass('dimmed');
}
});
cy.edges().forEach((edge) => {
const srcMatch = targetIds.has(edge.source().id());
const dstMatch = targetIds.has(edge.target().id());
if (srcMatch && dstMatch) {
edge.addClass('evidence-edge');
edge.removeClass('dimmed');
} else {
edge.addClass('dimmed');
}
});
});
if (highlightEvidenceNodes.activeStepNodeIds && highlightEvidenceNodes.activeStepNodeIds.size > 0) {
const ids = [...highlightEvidenceNodes.activeStepNodeIds];
const collection = cy.nodes().filter((n) => ids.includes(n.id()));
if (collection.length > 0) {
cy.animate({ fit: { eles: collection, padding: 60 }, duration: 400 });
}
}
}, [highlightEvidenceNodes, elements]);
Update spotlightRef to include evidence:
spotlightRef.current = highlightSubgraphIndex != null || !!highlightComponentOf || !!highlightEvidenceNodes;
- [ ] Step 3: Wire evidence into GraphModal
Modify services/dashboard/client/src/components/graph/GraphModal.jsx:
import { useEvidence } from '../../context/EvidenceContext.jsx';
import InteractiveRationale from '../common/InteractiveRationale.jsx';
// Inside the component:
const { getEvidence, fetchEvidence } = useEvidence();
const [evidenceActive, setEvidenceActive] = useState(false);
const [activeStep, setActiveStep] = useState(null);
// Trigger fetch if not already done (fallback for direct Graph button click)
useEffect(() => {
if (doc?.inference_id) {
const ev = getEvidence(doc.inference_id);
if (ev.status === 'idle') {
// Check if inference is attack by looking at the doc
if (doc.prediction === 'attack') {
fetchEvidence(doc.inference_id);
}
}
}
}, [doc?.inference_id, doc?.prediction, getEvidence, fetchEvidence]);
const evidenceState = doc?.inference_id ? getEvidence(doc.inference_id) : { status: 'idle' };
// Build highlight data
const highlightEvidenceNodes = useMemo(() => {
if (!evidenceActive || evidenceState.status !== 'success' || !evidenceState.evidence) return null;
const allIds = new Set(evidenceState.evidence.all_evidence_node_ids || []);
let stepIds = null;
if (activeStep != null) {
const step = (evidenceState.evidence.evidence || []).find((s) => s.step === activeStep);
if (step) stepIds = new Set(step.node_ids || []);
}
return { allNodeIds: allIds, activeStepNodeIds: stepIds };
}, [evidenceActive, evidenceState, activeStep]);
const handleEvidenceStepClick = useCallback((step, nodeIds, eventIds) => {
setActiveStep(step);
if (!evidenceActive) setEvidenceActive(true);
}, [evidenceActive]);
// Pass to CytoscapeGraph:
// <CytoscapeGraph
// ...existing props
// highlightEvidenceNodes={highlightEvidenceNodes}
// />
// Add evidence toggle button in the Inspect tab (alongside "Spotlight winning subgraph"):
// {evidenceState.status === 'success' && (
// <button
// className={`graph-emphasis-btn${evidenceActive ? ' on' : ''}`}
// onClick={() => { setEvidenceActive(!evidenceActive); setActiveStep(null); }}
// >
// {evidenceActive ? 'Hide evidence' : 'Show evidence chain'}
// </button>
// )}
// {evidenceState.status === 'loading' && (
// <span className="graph-emphasis-hint">Extracting evidence...</span>
// )}
- [ ] Step 4: Wire InteractiveRationale into DebugPanel
Modify services/dashboard/client/src/components/graph/DebugPanel.jsx:
In LogsterClassifierSection, add props for evidence interaction:
// DebugPanel receives new props:
// { doc, highlightSubgraphIndex, onSelectSubgraph, evidence, activeStep, onStepClick }
// In LogsterClassifierSection, replace RationaleText with:
// <InteractiveRationale
// text={lc.rationale}
// evidence={evidence}
// activeStep={activeStep}
// onStepClick={onStepClick}
// className="mono"
// />
Pass the new props from GraphModal to DebugPanel:
// <DebugPanel
// ...existing props
// evidence={evidenceState.evidence}
// activeStep={activeStep}
// onStepClick={handleEvidenceStepClick}
// />
- [ ] Step 5: Verify in browser — open Graph view on an attack inference
Generate attack traffic, open dashboard, click Graph on an attack inference. Verify: - "Extracting evidence..." hint appears during LLM call - "Show evidence chain" button appears when evidence arrives - Clicking it highlights evidence nodes (amber border) and dims everything else - Clicking a rationale step in the Debug tab spotlights those specific nodes and pans to them - Clicking the same step again returns to "all evidence" view - Evidence toggle can be turned off — graph returns to normal - Existing highlight modes (subgraph spotlight, attention) still work
- [ ] Step 6: Commit
git add services/dashboard/client/src/components/graph/CytoscapeGraph.jsx \
services/dashboard/client/src/components/graph/GraphModal.jsx \
services/dashboard/client/src/components/graph/DebugPanel.jsx \
services/dashboard/client/src/theme.js
git commit -m "feat(dashboard): graph evidence highlight mode with per-step spotlight"
Self-Review Checklist
Spec coverage: - [x] On-demand evidence extraction endpoint (Task 2) - [x] Graph re-serialization — adjacency + cypher (Task 1) - [x] LLM prompt with grounded annotation (verdict + rationale included) (Task 1) - [x] Response parsing + validation + node ID enrichment (Task 1) - [x] ES caching on graph doc (Task 2) - [x] Express proxy route (Task 3) - [x] EvidenceContext state management (Task 4) - [x] Progress bar in InvestigationModal (Task 6) - [x] Event reordering + highlighting (Task 6) - [x] InteractiveRationale component (Task 5) - [x] Graph evidence highlight mode (Task 7) - [x] Evidence toggle button (Task 7) - [x] Per-step spotlight from DebugPanel (Task 7) - [x] Graph button enhanced state indicator (Task 6, InferenceSummary) - [x] Docker Compose + ES template changes (Task 2) - [x] Error handling / graceful degradation (spread across Tasks 2, 4, 6, 7)
Placeholder scan: No TBD/TODO. All code blocks contain actual implementation.
Type consistency:
- serialize_graph_doc(nodes, edges, format) — consistent across Tasks 1 and 2
- build_evidence_prompt(...) — consistent across Tasks 1 and 2
- parse_evidence_response(raw, ids, map) — consistent across Tasks 1 and 2
- evidence.evidence (the evidence array) — consistent shape across Tasks 2, 4, 5, 6, 7
- onStepClick(step, nodeIds, eventIds) — consistent callback signature across Tasks 5, 6, 7
- highlightEvidenceNodes { allNodeIds, activeStepNodeIds } — consistent prop shape in Tasks 7