"""LLM-driven pipeline for regulatory event enrichment.""" from __future__ import annotations import json from typing import Any from loguru import logger from app.config.settings import settings from app.infrastructure.perception.regulation_differ import ( ParagraphChange, RegulationDiffer, ) from app.services.llm.llm_factory import get_llm_client _EXTRACT_SYSTEM = ( "You are a regulatory compliance expert specialising in automotive standards " "(GB, UN-ECE, ISO, EU). Extract structured information from regulation text. " "Return valid JSON only — no markdown fences, no extra keys." ) _ASSESS_SYSTEM = ( "You are an automotive compliance analyst. Given a regulation and related document excerpts, " "identify which documents are affected and what actions are required. " "Return a JSON array only." ) _DIFF_SYSTEM = ( "You are a regulatory change analyst. You are given the OLD and NEW version of " "one regulation paragraph, with the exact edits marked removed and " "added. Classify the legal effect of the change. " "Return JSON only: {\"change_type\": \"tightened|relaxed|numeric|clarified|scope\", " "\"legal_effect\": \"one sentence on what this means for compliance\"}" ) def _marked_diff(change: ParagraphChange) -> str: """Render a paragraph change with the exact edits marked for the model. The model is shown where the edit is rather than being asked to find it, and is never asked to reproduce the changed text — the differ already computed those spans exactly, so there is nothing for the model to hallucinate. """ marked = "".join( text if op == 0 else (f"{text}" if op < 0 else f"{text}") for op, text in change.diff_spans ) return ( f"OLD: {change.old_text[:500]}\n" f"NEW: {change.new_text[:500]}\n" f"MARKED: {marked[:800]}" ) def _llm_json(client: Any, messages: list[dict]) -> Any: """Call LLM and parse JSON response; return None on failure.""" try: resp = client.chat(messages) text = (resp.content or "").strip() if text.startswith("```"): text = text.split("```")[1] if text.startswith("json"): text = text[4:] return json.loads(text) except Exception as exc: logger.warning("LLM JSON parse failed: {}", exc) return None class LlmPipeline: """Three-step enrichment pipeline for crawled regulatory events.""" def __init__(self) -> None: self._client = get_llm_client( provider=settings.llm_provider, model=settings.llm_model, ) # Change detection is deterministic; the differ needs no model and no # network, so the pipeline no longer constructs an embedding provider. self._differ = RegulationDiffer() # ------------------------------------------------------------------ # Step 1: Structure extraction # ------------------------------------------------------------------ def extract_structure(self, event: dict) -> dict: """Extract obligations, deadlines, scope, penalties, impact_level from event text.""" prompt = f"""Extract structured compliance information from this regulation: Standard: {event.get('standard_code', '')} Title: {event.get('title', '')} Source: {event.get('source_label', '')} Summary: {event.get('summary', '')} Tags: {', '.join(event.get('tags') or [])} Return JSON with exactly these keys: {{ "obligations": [{{"text": "...", "deontic": "must|shall|may|prohibited", "subject": "...", "object": "...", "condition": ""}}], "deadlines": [{{"date": "YYYY-MM-DD or null", "description": "..."}}], "scope": "one sentence describing who/what this applies to", "penalties": "one sentence on consequences of non-compliance, or null", "impact_level": "high|medium|low" }}""" messages = [ {"role": "system", "content": _EXTRACT_SYSTEM}, {"role": "user", "content": prompt}, ] result = _llm_json(self._client, messages) if not isinstance(result, dict): return { "obligations": [], "deadlines": [], "scope": "", "penalties": "", "impact_level": "medium", } return result # ------------------------------------------------------------------ # Step 2: Impact assessment # ------------------------------------------------------------------ def assess_impact(self, event: dict, retrieval_service: Any) -> list[dict]: """Use RAG to find affected documents and generate recommendations.""" obligations = event.get("obligations") or [] obligation_texts = " ".join(o.get("text", "") for o in obligations[:3]) query = f"{event.get('standard_code', '')} {event.get('title', '')} {obligation_texts}" try: chunks = retrieval_service.retrieve(query=query, top_k=5) except Exception as exc: logger.warning("RAG retrieval failed: {}", exc) return [] if not chunks: return [] seen: set[str] = set() doc_excerpts: list[dict] = [] for chunk in chunks: if chunk.doc_id not in seen: seen.add(chunk.doc_id) doc_excerpts.append({ "doc_id": chunk.doc_id, "doc_name": chunk.doc_title, "score": round(float(chunk.score if chunk.score is not None else 0), 4), "snippet": (chunk.text or "")[:300], "clause": getattr(chunk, "section_title", "") or "", }) context = "\n".join( f"[{d['doc_name']} {d['clause']}] score={d['score']}: {d['snippet']}" for d in doc_excerpts ) prompt = f"""Regulation: {event.get('standard_code')} — {event.get('title')} Obligations: {obligation_texts or event.get('summary', '')} Affected documents found in knowledge base: {context} For each document, assess impact and recommend action. Return JSON array: [{{"doc_id":"...","doc_name":"...","score":0.0,"key_clauses":"...","recommendation":"one sentence action"}}]""" messages = [ {"role": "system", "content": _ASSESS_SYSTEM}, {"role": "user", "content": prompt}, ] result = _llm_json(self._client, messages) if isinstance(result, list): score_map = {d["doc_id"]: d["score"] for d in doc_excerpts} for item in result: if isinstance(item, dict) and item.get("doc_id") in score_map: item["score"] = score_map[item["doc_id"]] return result return doc_excerpts # ------------------------------------------------------------------ # Step 3: Deterministic diff with gated LLM classification # ------------------------------------------------------------------ def compute_diff(self, old_text: str, new_text: str) -> dict: """Compare old and new regulation text; return changed sections and summary. Detection is deterministic — see regulation_differ for why embedding similarity was removed. The LLM is called only for paragraphs the differ marked significant, and only to explain the legal effect of a change that has already been located exactly. """ changes = self._differ.diff(old_text, new_text) if not changes: return { "changed_sections": [], "change_summary": "No substantive changes detected between versions.", } changed_sections = [self._describe(change) for change in changes] types = sorted({section["change_type"] for section in changed_sections}) gated = sum(1 for change in changes if change.needs_llm) change_summary = ( f"{len(changed_sections)} paragraph(s) changed ({', '.join(types)}); " f"{gated} significant. " + (changed_sections[0].get("summary") or "") ).strip() return {"changed_sections": changed_sections, "change_summary": change_summary} def _describe(self, change: ParagraphChange) -> dict: """Turn one detected change into the API payload, classifying if warranted.""" section = { "old_text": change.old_text[:300], "new_text": change.new_text[:300], "change_type": change.change_type, "change_ratio": round(change.change_ratio, 3), "numeric_changed": change.numeric_changed, "deontic_changed": change.deontic_changed, "summary": "", } if not change.needs_llm: return section classification = _llm_json( self._client, [ {"role": "system", "content": _DIFF_SYSTEM}, {"role": "user", "content": _marked_diff(change)}, ], ) if isinstance(classification, dict): section["change_type"] = classification.get("change_type") or change.change_type section["summary"] = classification.get("legal_effect") or "" # A failed or malformed model response must not discard a change that # deterministic analysis already proved real; the section keeps its # spans, flags, and alignment-derived type with an empty summary. if change.numeric_changed: # Models routinely label a changed threshold as "clarified". The # deterministic pass already knows a number moved, so it wins. section["change_type"] = "numeric" return section