""" Neo4j Graph Writer Exposes write_threat_event() which executes the Cypher MERGE logic. This should be called asynchronously via the queue in neo4j_client. """ import logging from typing import Optional from src.db.neo4j_client import get_driver, is_connected logger = logging.getLogger("llm_firewall.db.graph_writer") MERGE_THREAT_QUERY_BATCH = """ UNWIND $events AS event MERGE (k:ApiKey {key_id: event.key_id}) MERGE (a:AttackType {name: event.attack_type}) MERGE (l:FlaggedLayer {name: event.flagged_layer}) MERGE (p:FlaggedPattern {text: event.flagged_pattern}) MERGE (h:PromptHash {hash: event.prompt_hash}) MERGE (h_norm:PromptHash {hash: event.normalized_hash}) // Link raw hash and normalized hash to the attack type MERGE (h)-[hr:IS_ATTACK]->(a) ON CREATE SET hr.times_seen = 1 ON MATCH SET hr.times_seen = hr.times_seen + 1 MERGE (h_norm)-[hnr:IS_ATTACK]->(a) ON CREATE SET hnr.times_seen = 1 ON MATCH SET hnr.times_seen = hnr.times_seen + 1 MERGE (k)-[r:TRIGGERED]->(a) ON CREATE SET r.count = 1, r.first_seen = event.timestamp, r.last_seen = event.timestamp, r.max_risk = event.risk_score ON MATCH SET r.count = r.count + 1, r.last_seen = event.timestamp, r.max_risk = CASE WHEN event.risk_score > r.max_risk THEN event.risk_score ELSE r.max_risk END MERGE (a)-[:CAUGHT_BY]->(l) MERGE (l)-[:MATCHED]->(p) WITH k, a, event FOREACH (_ IN CASE WHEN event.provider IS NOT NULL THEN [1] ELSE [] END | MERGE (pv:Provider {name: event.provider}) MERGE (k)-[:TARGETS]->(pv) ) """ async def write_threat_events_batch(events_data: list) -> None: """ Writes a batch of threat events to the Neo4j graph using UNWIND. """ if not await is_connected() or not events_data: return formatted_events = [] for data in events_data: log_entry = data["log_entry"] if log_entry.get("safe", True): continue timestamp = log_entry.get("timestamp") if hasattr(timestamp, "isoformat"): timestamp = timestamp.isoformat() raw_attack = log_entry.get("attack_type") or "unknown_attack" if raw_attack.lower() == "safe": raw_attack = "cumulative_risk_exceeded" normalized_attack = raw_attack.lower().replace(" ", "_") formatted_events.append({ "key_id": str(log_entry.get("api_key_id", "unknown")), "attack_type": normalized_attack, "flagged_layer": log_entry.get("flagged_layer") or "unknown_layer", "flagged_pattern": str(log_entry.get("flagged_pattern") or "none"), "prompt_hash": log_entry.get("prompt_hash", "unknown_hash"), "normalized_hash": data["normalized_hash"], "timestamp": timestamp, "risk_score": float(log_entry.get("risk_score", 0.0)), "provider": log_entry.get("provider") }) if not formatted_events: return try: driver = get_driver() async with driver.session() as session: await session.run(MERGE_THREAT_QUERY_BATCH, events=formatted_events) except Exception as e: logger.error(f"Failed to execute Cypher MERGE batch: {e}") async def write_threat_event(log_entry: dict, normalized_hash: str) -> None: """Fallback single write for backward compatibility.""" await write_threat_events_batch([{"log_entry": log_entry, "normalized_hash": normalized_hash}])