update for mcp
This commit is contained in:
@@ -3,14 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from app.config.settings import settings
|
||||
from app.infrastructure.embedding.openai_compatible_embedding_provider import (
|
||||
OpenAICompatibleEmbeddingProvider,
|
||||
from app.infrastructure.perception.regulation_differ import (
|
||||
ParagraphChange,
|
||||
RegulationDiffer,
|
||||
)
|
||||
from app.services.llm.llm_factory import get_llm_client
|
||||
|
||||
@@ -27,21 +27,31 @@ _ASSESS_SYSTEM = (
|
||||
)
|
||||
|
||||
_DIFF_SYSTEM = (
|
||||
"You are a regulatory change analyst. Given an old and new version of a regulation paragraph, "
|
||||
"classify the type of change and summarise it. "
|
||||
"Return JSON only: {\"change_type\": \"tightened|relaxed|added|removed\", \"summary\": \"...\"}"
|
||||
"You are a regulatory change analyst. You are given the OLD and NEW version of "
|
||||
"one regulation paragraph, with the exact edits marked <DEL>removed</DEL> and "
|
||||
"<INS>added</INS>. 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\"}"
|
||||
)
|
||||
|
||||
_SIMILARITY_THRESHOLD = 0.85
|
||||
|
||||
def _marked_diff(change: ParagraphChange) -> str:
|
||||
"""Render a paragraph change with the exact edits marked for the model.
|
||||
|
||||
def _cosine(a: list[float], b: list[float]) -> float:
|
||||
dot = sum(x * y for x, y in zip(a, b))
|
||||
norm_a = math.sqrt(sum(x * x for x in a))
|
||||
norm_b = math.sqrt(sum(x * x for x in b))
|
||||
if norm_a == 0 or norm_b == 0:
|
||||
return 0.0
|
||||
return dot / (norm_a * norm_b)
|
||||
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"<DEL>{text}</DEL>" if op < 0 else f"<INS>{text}</INS>")
|
||||
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:
|
||||
@@ -67,7 +77,9 @@ class LlmPipeline:
|
||||
provider=settings.llm_provider,
|
||||
model=settings.llm_model,
|
||||
)
|
||||
self._embedder = OpenAICompatibleEmbeddingProvider()
|
||||
# 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
|
||||
@@ -166,76 +178,68 @@ For each document, assess impact and recommend action. Return JSON array:
|
||||
return doc_excerpts
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 3: Semantic diff
|
||||
# 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."""
|
||||
old_paras = [p.strip() for p in old_text.split("\n") if p.strip()]
|
||||
new_paras = [p.strip() for p in new_text.split("\n") if p.strip()]
|
||||
"""Compare old and new regulation text; return changed sections and summary.
|
||||
|
||||
if not old_paras or not new_paras:
|
||||
return {"changed_sections": [], "change_summary": "No comparable text."}
|
||||
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.",
|
||||
}
|
||||
|
||||
all_paras = old_paras + new_paras
|
||||
try:
|
||||
all_embeddings = self._embedder.embed_texts(all_paras)
|
||||
except Exception as exc:
|
||||
logger.warning("Embedding for diff failed: {}", exc)
|
||||
return {"changed_sections": [], "change_summary": "Diff unavailable (embedding error)."}
|
||||
changed_sections = [self._describe(change) for change in changes]
|
||||
|
||||
old_embeddings = all_embeddings[: len(old_paras)]
|
||||
new_embeddings = all_embeddings[len(old_paras):]
|
||||
|
||||
changed_sections: list[dict] = []
|
||||
max_len = max(len(old_paras), len(new_paras))
|
||||
|
||||
for i in range(max_len):
|
||||
if i >= len(old_paras):
|
||||
# New paragraph added
|
||||
changed_sections.append({
|
||||
"old_text": "",
|
||||
"new_text": new_paras[i][:300],
|
||||
"similarity": 0.0,
|
||||
"change_type": "added",
|
||||
"summary": "New paragraph added.",
|
||||
})
|
||||
continue
|
||||
if i >= len(new_paras):
|
||||
# Old paragraph removed
|
||||
changed_sections.append({
|
||||
"old_text": old_paras[i][:300],
|
||||
"new_text": "",
|
||||
"similarity": 0.0,
|
||||
"change_type": "removed",
|
||||
"summary": "Paragraph removed.",
|
||||
})
|
||||
continue
|
||||
# Both exist — compare via embeddings
|
||||
sim = _cosine(old_embeddings[i], new_embeddings[i])
|
||||
if sim < _SIMILARITY_THRESHOLD:
|
||||
messages = [
|
||||
{"role": "system", "content": _DIFF_SYSTEM},
|
||||
{"role": "user", "content": f"OLD: {old_paras[i][:500]}\nNEW: {new_paras[i][:500]}"},
|
||||
]
|
||||
classification = _llm_json(self._client, messages) or {}
|
||||
changed_sections.append({
|
||||
"old_text": old_paras[i][:300],
|
||||
"new_text": new_paras[i][:300],
|
||||
"similarity": round(sim, 3),
|
||||
"change_type": classification.get("change_type", "modified"),
|
||||
"summary": classification.get("summary", ""),
|
||||
})
|
||||
|
||||
if not changed_sections:
|
||||
change_summary = "No substantive changes detected between versions."
|
||||
else:
|
||||
types = [s["change_type"] for s in changed_sections]
|
||||
change_summary = (
|
||||
f"{len(changed_sections)} paragraph(s) changed: "
|
||||
+ ", ".join(f"{t}" for t in set(types))
|
||||
+ ". "
|
||||
+ (changed_sections[0].get("summary", "") if changed_sections else "")
|
||||
)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user