feat: LLM-agnostic Compliance Agent with tool calling
New agent architecture for intelligent MC evaluation: agent_tools.py (367 LOC): - 5 tools in OpenAI function-calling format - query_controls: async DB query for MCs by doc_type - evaluate_controls_batch: deterministic keyword matching - search_document: text search with context - get_document_stats: word count, sections, language - submit_results: finalize check results compliance_agent.py (398 LOC): - ComplianceAgent class with agent loop - 3 LLM providers: Ollama, OpenAI-compatible (OVH), Anthropic - Tool call dispatch + result collection - System prompt for systematic compliance analysis - run_compliance_check() convenience function Hybrid mode: - COMPLIANCE_USE_AGENT=false (default): deterministic regex - COMPLIANCE_USE_AGENT=true: LLM agent with tool calling - Agent fallback to regex if LLM unavailable Works with Qwen 35B (Ollama), Qwen 120B (OVH vLLM), Claude. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -287,8 +287,10 @@ async def _check_single_document(entry: DocCheckEntry) -> list[DocCheckResult]:
|
|||||||
# binary pass/fail criteria verified by LLM (Qwen)
|
# binary pass/fail criteria verified by LLM (Qwen)
|
||||||
try:
|
try:
|
||||||
from compliance.services.rag_document_checker import check_document_with_controls
|
from compliance.services.rag_document_checker import check_document_with_controls
|
||||||
|
use_agent = os.getenv("COMPLIANCE_USE_AGENT", "false").lower() == "true"
|
||||||
mc_results = await check_document_with_controls(
|
mc_results = await check_document_with_controls(
|
||||||
doc_text, entry.doc_type, entry.label, max_controls=0,
|
doc_text, entry.doc_type, entry.label,
|
||||||
|
max_controls=0, use_agent=use_agent,
|
||||||
)
|
)
|
||||||
if mc_results:
|
if mc_results:
|
||||||
# Add MC results as additional checks to the main result
|
# Add MC results as additional checks to the main result
|
||||||
|
|||||||
@@ -0,0 +1,367 @@
|
|||||||
|
"""
|
||||||
|
Agent Tools — LLM-agnostic tool definitions for the Compliance Agent.
|
||||||
|
|
||||||
|
Provides 5 tools in OpenAI function-calling format:
|
||||||
|
1. query_controls — load Master Controls from DB
|
||||||
|
2. evaluate_controls_batch — deterministic keyword check against doc text
|
||||||
|
3. search_document — find keyword in document with context
|
||||||
|
4. get_document_stats — word count, sections, language
|
||||||
|
5. submit_results — final submission of pass/fail results
|
||||||
|
|
||||||
|
All implementations are deterministic (no LLM). The agent decides
|
||||||
|
which tools to call and in what order.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DATABASE_URL = os.getenv(
|
||||||
|
"DATABASE_URL",
|
||||||
|
"postgresql://breakpilot:breakpilot@bp-core-postgres:5432/breakpilot",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# Tool definitions (OpenAI function-calling format)
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
TOOLS = [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "query_controls",
|
||||||
|
"description": (
|
||||||
|
"Lade Master Controls aus der Datenbank fuer einen Dokumenttyp. "
|
||||||
|
"Gibt eine Liste von Pruefpunkten mit check_question, "
|
||||||
|
"pass_criteria und fail_criteria zurueck."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"doc_type": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Dokumenttyp: dse, agb, impressum, cookie, widerruf, avv, dsfa",
|
||||||
|
},
|
||||||
|
"severity": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional: nur Controls mit dieser Severity (HIGH, MEDIUM, LOW)",
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Max Anzahl Controls (default: alle)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["doc_type"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "evaluate_controls_batch",
|
||||||
|
"description": (
|
||||||
|
"Pruefe mehrere Master Controls gegen den Dokumenttext. "
|
||||||
|
"Deterministisch: Keyword-Matching der pass_criteria. "
|
||||||
|
"Gibt fuer jeden Control pass/fail mit Evidence zurueck."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"controls": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {"type": "string"},
|
||||||
|
"check_question": {"type": "string"},
|
||||||
|
"pass_criteria": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["id", "check_question", "pass_criteria"],
|
||||||
|
},
|
||||||
|
"description": "Liste von Controls zum Pruefen (max 20 pro Batch)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["controls"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "search_document",
|
||||||
|
"description": (
|
||||||
|
"Suche ein Schluesselwort im Dokument und gib die "
|
||||||
|
"Fundstelle mit Kontext zurueck."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"keyword": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Suchbegriff (case-insensitive)",
|
||||||
|
},
|
||||||
|
"context_chars": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Zeichen Kontext um die Fundstelle (default: 200)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["keyword"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "get_document_stats",
|
||||||
|
"description": (
|
||||||
|
"Statistiken zum Dokument: Wortanzahl, erkannte Abschnitte, "
|
||||||
|
"Sprache, Laenge."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "submit_results",
|
||||||
|
"description": (
|
||||||
|
"Reiche die finalen Pruefergebnisse ein. Jedes Ergebnis "
|
||||||
|
"hat id, label, passed, severity und eine optionale Empfehlung."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"results": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {"type": "string"},
|
||||||
|
"label": {"type": "string"},
|
||||||
|
"passed": {"type": "boolean"},
|
||||||
|
"severity": {"type": "string"},
|
||||||
|
"hint": {"type": "string"},
|
||||||
|
"matched_text": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["id", "label", "passed", "severity"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["results"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# Tool dispatcher
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async def execute_tool(name: str, args: dict, context: dict) -> dict:
|
||||||
|
"""Dispatch a tool call to its implementation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Tool function name.
|
||||||
|
args: Parsed arguments dict.
|
||||||
|
context: Shared state with doc_text, doc_type, db_url, results.
|
||||||
|
"""
|
||||||
|
dispatch = {
|
||||||
|
"query_controls": _query_controls,
|
||||||
|
"evaluate_controls_batch": _evaluate_controls_batch,
|
||||||
|
"search_document": _search_document,
|
||||||
|
"get_document_stats": _get_document_stats,
|
||||||
|
"submit_results": _submit_results,
|
||||||
|
}
|
||||||
|
fn = dispatch.get(name)
|
||||||
|
if not fn:
|
||||||
|
return {"error": f"Unbekanntes Tool: {name}"}
|
||||||
|
try:
|
||||||
|
return await fn(args, context)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Tool %s failed", name)
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# Tool implementations
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async def _query_controls(args: dict, ctx: dict) -> dict:
|
||||||
|
"""Load Master Controls from compliance.doc_check_controls."""
|
||||||
|
doc_type = args.get("doc_type", ctx.get("doc_type", "dse"))
|
||||||
|
severity = args.get("severity")
|
||||||
|
limit = args.get("limit", 0)
|
||||||
|
db_url = ctx.get("db_url") or DATABASE_URL
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = await asyncpg.connect(db_url)
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"DB-Verbindung fehlgeschlagen: {e}", "controls": []}
|
||||||
|
|
||||||
|
try:
|
||||||
|
query = (
|
||||||
|
"SELECT id, control_id, title, regulation, check_question, "
|
||||||
|
" pass_criteria, fail_criteria, severity "
|
||||||
|
"FROM compliance.doc_check_controls "
|
||||||
|
"WHERE doc_type = $1"
|
||||||
|
)
|
||||||
|
params: list = [doc_type]
|
||||||
|
if severity:
|
||||||
|
query += " AND UPPER(severity) = $2"
|
||||||
|
params.append(severity.upper())
|
||||||
|
query += " ORDER BY severity DESC, title"
|
||||||
|
if limit and limit > 0:
|
||||||
|
query += f" LIMIT {int(limit)}"
|
||||||
|
|
||||||
|
rows = await conn.fetch(query, *params)
|
||||||
|
controls = []
|
||||||
|
for r in rows:
|
||||||
|
pc = r["pass_criteria"]
|
||||||
|
if isinstance(pc, str):
|
||||||
|
try:
|
||||||
|
pc = json.loads(pc)
|
||||||
|
except Exception:
|
||||||
|
pc = [pc] if pc else []
|
||||||
|
controls.append({
|
||||||
|
"id": str(r["id"]),
|
||||||
|
"control_id": r.get("control_id", ""),
|
||||||
|
"title": r.get("title", ""),
|
||||||
|
"regulation": r.get("regulation", ""),
|
||||||
|
"check_question": r.get("check_question", ""),
|
||||||
|
"pass_criteria": pc if isinstance(pc, list) else [pc],
|
||||||
|
"severity": r.get("severity", "MEDIUM"),
|
||||||
|
})
|
||||||
|
return {"count": len(controls), "controls": controls}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Abfrage fehlgeschlagen: {e}", "controls": []}
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
_STOP = {
|
||||||
|
"oder", "und", "der", "die", "das", "ein", "eine", "von", "vom",
|
||||||
|
"zur", "zum", "mit", "auf", "aus", "bei", "nach", "nicht", "kein",
|
||||||
|
"wird", "werden", "kann", "muss", "ist", "sind", "hat", "dass",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _evaluate_controls_batch(args: dict, ctx: dict) -> dict:
|
||||||
|
"""Keyword-match each control's pass_criteria against doc_text."""
|
||||||
|
controls = args.get("controls", [])[:20]
|
||||||
|
text_lower = ctx.get("doc_text", "").lower().replace("\xad", "")
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for ctrl in controls:
|
||||||
|
criteria = ctrl.get("pass_criteria", [])
|
||||||
|
met = 0
|
||||||
|
evidence = ""
|
||||||
|
for crit in criteria:
|
||||||
|
words = [w for w in re.findall(r"[a-z\u00e4\u00f6\u00fc\u00df]{4,}", crit.lower()) if w not in _STOP]
|
||||||
|
if not words:
|
||||||
|
met += 1
|
||||||
|
continue
|
||||||
|
matched = sum(1 for w in words if w in text_lower)
|
||||||
|
if matched >= len(words) * 0.5:
|
||||||
|
met += 1
|
||||||
|
if not evidence:
|
||||||
|
for w in words:
|
||||||
|
idx = text_lower.find(w)
|
||||||
|
if idx >= 0:
|
||||||
|
s = max(0, idx - 30)
|
||||||
|
e = min(len(text_lower), idx + len(w) + 30)
|
||||||
|
evidence = text_lower[s:e].strip()
|
||||||
|
break
|
||||||
|
passed = met >= len(criteria) * 0.6 if criteria else False
|
||||||
|
results.append({
|
||||||
|
"id": ctrl.get("id", ""),
|
||||||
|
"passed": passed,
|
||||||
|
"criteria_met": f"{met}/{len(criteria)}",
|
||||||
|
"matched_text": evidence[:120],
|
||||||
|
})
|
||||||
|
return {"evaluated": len(results), "results": results}
|
||||||
|
|
||||||
|
|
||||||
|
async def _search_document(args: dict, ctx: dict) -> dict:
|
||||||
|
"""Simple keyword search with context window."""
|
||||||
|
keyword = args.get("keyword", "").lower()
|
||||||
|
context_chars = args.get("context_chars", 200)
|
||||||
|
text = ctx.get("doc_text", "")
|
||||||
|
text_lower = text.lower()
|
||||||
|
|
||||||
|
hits = []
|
||||||
|
start = 0
|
||||||
|
while len(hits) < 5:
|
||||||
|
idx = text_lower.find(keyword, start)
|
||||||
|
if idx < 0:
|
||||||
|
break
|
||||||
|
s = max(0, idx - context_chars)
|
||||||
|
e = min(len(text), idx + len(keyword) + context_chars)
|
||||||
|
hits.append({"position": idx, "context": text[s:e].strip()})
|
||||||
|
start = idx + len(keyword)
|
||||||
|
|
||||||
|
return {"keyword": keyword, "found": len(hits) > 0, "hits": hits}
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_document_stats(args: dict, ctx: dict) -> dict:
|
||||||
|
"""Word count, detected sections, language guess."""
|
||||||
|
text = ctx.get("doc_text", "")
|
||||||
|
words = text.split()
|
||||||
|
|
||||||
|
# Detect sections (lines that look like headings)
|
||||||
|
sections = []
|
||||||
|
for line in text.split("\n"):
|
||||||
|
stripped = line.strip()
|
||||||
|
if 3 < len(stripped) < 120 and stripped[0].isupper() and not stripped.endswith(","):
|
||||||
|
if re.match(r"^(\d+\.?\s+|[IVXLC]+\.?\s+|[a-z]\)\s+)?[A-ZAEOEUE\u00c4\u00d6\u00dc]", stripped):
|
||||||
|
sections.append(stripped[:80])
|
||||||
|
|
||||||
|
# Language guess (DE vs EN heuristic)
|
||||||
|
de_markers = sum(1 for w in ["der", "die", "das", "und", "ist", "werden", "nicht"] if w in text.lower())
|
||||||
|
en_markers = sum(1 for w in ["the", "and", "is", "are", "not", "with", "for"] if w in text.lower())
|
||||||
|
lang = "de" if de_markers > en_markers else "en"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"word_count": len(words),
|
||||||
|
"char_count": len(text),
|
||||||
|
"sections_detected": len(sections),
|
||||||
|
"sections": sections[:20],
|
||||||
|
"language": lang,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _submit_results(args: dict, ctx: dict) -> dict:
|
||||||
|
"""Store final results in context for the caller to retrieve."""
|
||||||
|
results = args.get("results", [])
|
||||||
|
# Normalise into CheckItem-compatible format
|
||||||
|
normalised = []
|
||||||
|
for r in results:
|
||||||
|
normalised.append({
|
||||||
|
"id": r.get("id", ""),
|
||||||
|
"label": r.get("label", ""),
|
||||||
|
"passed": r.get("passed", False),
|
||||||
|
"severity": r.get("severity", "MEDIUM"),
|
||||||
|
"hint": r.get("hint", ""),
|
||||||
|
"matched_text": r.get("matched_text", ""),
|
||||||
|
"level": 2,
|
||||||
|
"parent": None,
|
||||||
|
"skipped": False,
|
||||||
|
"source": "agent",
|
||||||
|
})
|
||||||
|
ctx["results"] = normalised
|
||||||
|
passed = sum(1 for r in normalised if r["passed"])
|
||||||
|
return {
|
||||||
|
"submitted": len(normalised),
|
||||||
|
"passed": passed,
|
||||||
|
"failed": len(normalised) - passed,
|
||||||
|
}
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
"""
|
||||||
|
Compliance Agent — LLM-agnostic agent with tool calling.
|
||||||
|
|
||||||
|
Supports three LLM providers:
|
||||||
|
- ollama: Local Ollama instance (default)
|
||||||
|
- openai: OpenAI-compatible API (OVH vLLM, etc.)
|
||||||
|
- anthropic: Anthropic Messages API
|
||||||
|
|
||||||
|
The agent loop:
|
||||||
|
1. Send messages + tool definitions to the LLM
|
||||||
|
2. If the LLM returns tool_calls → execute them → append results
|
||||||
|
3. Repeat until no more tool_calls or max_iterations reached
|
||||||
|
4. Return the collected results from submit_results()
|
||||||
|
|
||||||
|
All tool execution is deterministic (keyword matching, DB queries).
|
||||||
|
The LLM only decides WHICH tools to call and HOW to interpret results.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
from compliance.services.agent_tools import TOOLS, execute_tool
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# Provider configuration
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://host.docker.internal:11434")
|
||||||
|
OVH_URL = os.getenv("OVH_LLM_URL", "")
|
||||||
|
ANTHROPIC_KEY = os.getenv("ANTHROPIC_API_KEY", "")
|
||||||
|
AGENT_PROVIDER = os.getenv("COMPLIANCE_AGENT_PROVIDER", "ollama")
|
||||||
|
AGENT_MODEL = os.getenv("COMPLIANCE_AGENT_MODEL", "qwen3.5:35b-a3b")
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = (
|
||||||
|
"Du bist ein Datenschutz-Compliance-Analyst. "
|
||||||
|
"Pruefe das Dokument systematisch:\n"
|
||||||
|
"1. Lade alle Master Controls fuer den Dokumenttyp (query_controls)\n"
|
||||||
|
"2. Pruefe die Controls in Batches von 20 (evaluate_controls_batch)\n"
|
||||||
|
"3. Bei unklaren Ergebnissen: suche im Dokument nach "
|
||||||
|
"Schluesselwoertern (search_document)\n"
|
||||||
|
"4. Reiche die finalen Ergebnisse ein (submit_results) "
|
||||||
|
"mit konkreten Empfehlungen\n"
|
||||||
|
"Arbeite gruendlich und pruefe ALLE Controls. "
|
||||||
|
"Antworte immer auf Deutsch."
|
||||||
|
)
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# Agent class
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class ComplianceAgent:
|
||||||
|
"""LLM-agnostic compliance agent with tool calling loop."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
provider_type: str,
|
||||||
|
model: str,
|
||||||
|
doc_text: str,
|
||||||
|
doc_type: str,
|
||||||
|
doc_title: str,
|
||||||
|
db_url: str = "",
|
||||||
|
):
|
||||||
|
self.provider = provider_type or AGENT_PROVIDER
|
||||||
|
self.model = model or AGENT_MODEL
|
||||||
|
self.doc_text = doc_text
|
||||||
|
self.doc_type = doc_type
|
||||||
|
self.doc_title = doc_title
|
||||||
|
self.context = {
|
||||||
|
"doc_text": doc_text,
|
||||||
|
"doc_type": doc_type,
|
||||||
|
"db_url": db_url,
|
||||||
|
"results": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def run(self, max_iterations: int = 15) -> list[dict]:
|
||||||
|
"""Run the agent loop until completion or max iterations."""
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": SYSTEM_PROMPT},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": (
|
||||||
|
f"Pruefe das folgende Dokument vom Typ '{self.doc_type}' "
|
||||||
|
f"(Titel: '{self.doc_title}').\n\n"
|
||||||
|
f"Dokumenttext ({len(self.doc_text)} Zeichen):\n"
|
||||||
|
f"{self.doc_text[:6000]}"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
tools = TOOLS
|
||||||
|
iteration = 0
|
||||||
|
|
||||||
|
while iteration < max_iterations:
|
||||||
|
iteration += 1
|
||||||
|
logger.info(
|
||||||
|
"Agent iteration %d/%d (provider=%s, model=%s)",
|
||||||
|
iteration, max_iterations, self.provider, self.model,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await self._chat_with_tools(messages, tools)
|
||||||
|
content = response.get("content", "")
|
||||||
|
tool_calls = response.get("tool_calls", [])
|
||||||
|
|
||||||
|
if content:
|
||||||
|
messages.append({"role": "assistant", "content": content})
|
||||||
|
|
||||||
|
if not tool_calls:
|
||||||
|
logger.info("Agent finished after %d iterations", iteration)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Execute each tool call and append results
|
||||||
|
for tc in tool_calls:
|
||||||
|
tool_name = tc.get("name", "")
|
||||||
|
tool_args = tc.get("arguments", {})
|
||||||
|
if isinstance(tool_args, str):
|
||||||
|
try:
|
||||||
|
tool_args = json.loads(tool_args)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
tool_args = {}
|
||||||
|
|
||||||
|
logger.info("Tool call: %s(%s)", tool_name, list(tool_args.keys()))
|
||||||
|
result = await self._execute_tool(tc)
|
||||||
|
result_str = json.dumps(result, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
|
# Append tool call + result as messages
|
||||||
|
messages.append({
|
||||||
|
"role": "assistant",
|
||||||
|
"content": None,
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"id": tc.get("id", f"call_{iteration}_{tool_name}"),
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": tool_name, "arguments": json.dumps(tool_args)},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
})
|
||||||
|
messages.append({
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": tc.get("id", f"call_{iteration}_{tool_name}"),
|
||||||
|
"content": result_str[:8000],
|
||||||
|
})
|
||||||
|
|
||||||
|
return self.context.get("results", [])
|
||||||
|
|
||||||
|
async def _chat_with_tools(self, messages: list, tools: list) -> dict:
|
||||||
|
"""Send messages+tools to the LLM. Returns {content, tool_calls}."""
|
||||||
|
if self.provider == "ollama":
|
||||||
|
return await self._chat_ollama(messages, tools)
|
||||||
|
elif self.provider == "openai":
|
||||||
|
return await self._chat_openai(messages, tools)
|
||||||
|
elif self.provider == "anthropic":
|
||||||
|
return await self._chat_anthropic(messages, tools)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unbekannter Provider: {self.provider}")
|
||||||
|
|
||||||
|
# ───────────────────────────────────────────────────────────
|
||||||
|
# Ollama
|
||||||
|
# ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _chat_ollama(self, messages: list, tools: list) -> dict:
|
||||||
|
"""Ollama /api/chat with tools support."""
|
||||||
|
payload = {
|
||||||
|
"model": self.model,
|
||||||
|
"messages": messages,
|
||||||
|
"tools": tools,
|
||||||
|
"stream": False,
|
||||||
|
"options": {"temperature": 0.1, "num_predict": 4000},
|
||||||
|
}
|
||||||
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
|
resp = await client.post(f"{OLLAMA_URL}/api/chat", json=payload)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
msg = data.get("message", {})
|
||||||
|
content = msg.get("content", "")
|
||||||
|
# Strip think tags
|
||||||
|
content = re.sub(r"<think>.*?</think>", "", content, flags=re.DOTALL).strip()
|
||||||
|
raw_calls = msg.get("tool_calls", [])
|
||||||
|
|
||||||
|
tool_calls = []
|
||||||
|
for tc in raw_calls:
|
||||||
|
fn = tc.get("function", {})
|
||||||
|
tool_calls.append({
|
||||||
|
"id": f"ollama_{fn.get('name', '')}",
|
||||||
|
"name": fn.get("name", ""),
|
||||||
|
"arguments": fn.get("arguments", {}),
|
||||||
|
})
|
||||||
|
return {"content": content, "tool_calls": tool_calls}
|
||||||
|
|
||||||
|
# ───────────────────────────────────────────────────────────
|
||||||
|
# OpenAI-compatible (OVH vLLM, etc.)
|
||||||
|
# ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _chat_openai(self, messages: list, tools: list) -> dict:
|
||||||
|
"""OpenAI-compatible /v1/chat/completions with tools."""
|
||||||
|
base_url = OVH_URL or os.getenv("OPENAI_BASE_URL", "")
|
||||||
|
api_key = os.getenv("OVH_LLM_KEY", os.getenv("OPENAI_API_KEY", ""))
|
||||||
|
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": self.model,
|
||||||
|
"messages": messages,
|
||||||
|
"tools": tools,
|
||||||
|
"temperature": 0.1,
|
||||||
|
"max_tokens": 4000,
|
||||||
|
}
|
||||||
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{base_url.rstrip('/')}/v1/chat/completions",
|
||||||
|
json=payload,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
choice = data.get("choices", [{}])[0]
|
||||||
|
msg = choice.get("message", {})
|
||||||
|
|
||||||
|
tool_calls = []
|
||||||
|
for tc in msg.get("tool_calls", []):
|
||||||
|
fn = tc.get("function", {})
|
||||||
|
tool_calls.append({
|
||||||
|
"id": tc.get("id", ""),
|
||||||
|
"name": fn.get("name", ""),
|
||||||
|
"arguments": fn.get("arguments", "{}"),
|
||||||
|
})
|
||||||
|
return {"content": msg.get("content", "") or "", "tool_calls": tool_calls}
|
||||||
|
|
||||||
|
# ───────────────────────────────────────────────────────────
|
||||||
|
# Anthropic
|
||||||
|
# ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _chat_anthropic(self, messages: list, tools: list) -> dict:
|
||||||
|
"""Anthropic Messages API with tools (different format)."""
|
||||||
|
api_key = ANTHROPIC_KEY or os.getenv("ANTHROPIC_API_KEY", "")
|
||||||
|
|
||||||
|
# Convert tools to Anthropic format
|
||||||
|
anthropic_tools = []
|
||||||
|
for t in tools:
|
||||||
|
fn = t.get("function", {})
|
||||||
|
anthropic_tools.append({
|
||||||
|
"name": fn["name"],
|
||||||
|
"description": fn.get("description", ""),
|
||||||
|
"input_schema": fn.get("parameters", {"type": "object", "properties": {}}),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Extract system from messages, convert rest
|
||||||
|
system_text = ""
|
||||||
|
api_messages = []
|
||||||
|
for m in messages:
|
||||||
|
if m["role"] == "system":
|
||||||
|
system_text = m["content"]
|
||||||
|
elif m["role"] == "tool":
|
||||||
|
api_messages.append({
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": m.get("tool_call_id", ""),
|
||||||
|
"content": m.get("content", ""),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
})
|
||||||
|
elif m["role"] == "assistant" and m.get("tool_calls"):
|
||||||
|
blocks = []
|
||||||
|
if m.get("content"):
|
||||||
|
blocks.append({"type": "text", "text": m["content"]})
|
||||||
|
for tc in m["tool_calls"]:
|
||||||
|
fn = tc.get("function", tc)
|
||||||
|
args = fn.get("arguments", {})
|
||||||
|
if isinstance(args, str):
|
||||||
|
try:
|
||||||
|
args = json.loads(args)
|
||||||
|
except Exception:
|
||||||
|
args = {}
|
||||||
|
blocks.append({
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": tc.get("id", ""),
|
||||||
|
"name": fn.get("name", tc.get("name", "")),
|
||||||
|
"input": args,
|
||||||
|
})
|
||||||
|
api_messages.append({"role": "assistant", "content": blocks})
|
||||||
|
else:
|
||||||
|
api_messages.append({"role": m["role"], "content": m.get("content", "")})
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": self.model,
|
||||||
|
"max_tokens": 4000,
|
||||||
|
"system": system_text,
|
||||||
|
"messages": api_messages,
|
||||||
|
"tools": anthropic_tools,
|
||||||
|
"temperature": 0.1,
|
||||||
|
}
|
||||||
|
headers = {
|
||||||
|
"x-api-key": api_key,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
"content-type": "application/json",
|
||||||
|
}
|
||||||
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"https://api.anthropic.com/v1/messages",
|
||||||
|
json=payload,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
content_parts = data.get("content", [])
|
||||||
|
text = ""
|
||||||
|
tool_calls = []
|
||||||
|
for part in content_parts:
|
||||||
|
if part["type"] == "text":
|
||||||
|
text += part.get("text", "")
|
||||||
|
elif part["type"] == "tool_use":
|
||||||
|
tool_calls.append({
|
||||||
|
"id": part.get("id", ""),
|
||||||
|
"name": part.get("name", ""),
|
||||||
|
"arguments": part.get("input", {}),
|
||||||
|
})
|
||||||
|
return {"content": text, "tool_calls": tool_calls}
|
||||||
|
|
||||||
|
# ───────────────────────────────────────────────────────────
|
||||||
|
# Tool execution
|
||||||
|
# ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _execute_tool(self, tool_call: dict) -> dict:
|
||||||
|
"""Execute a single tool call via agent_tools.execute_tool()."""
|
||||||
|
name = tool_call.get("name", "")
|
||||||
|
args = tool_call.get("arguments", {})
|
||||||
|
if isinstance(args, str):
|
||||||
|
try:
|
||||||
|
args = json.loads(args)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
args = {}
|
||||||
|
return await execute_tool(name, args, self.context)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# Convenience function
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
async def run_compliance_check(
|
||||||
|
text: str,
|
||||||
|
doc_type: str,
|
||||||
|
doc_title: str,
|
||||||
|
provider_type: str = "",
|
||||||
|
model: str = "",
|
||||||
|
db_url: str = "",
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Run a full compliance check with the agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Document text to check.
|
||||||
|
doc_type: Document type (dse, agb, impressum, etc.).
|
||||||
|
doc_title: Human-readable document title.
|
||||||
|
provider_type: LLM provider (ollama, openai, anthropic).
|
||||||
|
model: Model name/ID (provider-specific).
|
||||||
|
db_url: PostgreSQL connection string (optional).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of CheckItem-compatible dicts with pass/fail results.
|
||||||
|
"""
|
||||||
|
provider = provider_type or AGENT_PROVIDER
|
||||||
|
mdl = model or AGENT_MODEL
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Starting compliance check: doc_type=%s, title='%s', "
|
||||||
|
"provider=%s, model=%s, text_len=%d",
|
||||||
|
doc_type, doc_title, provider, mdl, len(text),
|
||||||
|
)
|
||||||
|
|
||||||
|
agent = ComplianceAgent(
|
||||||
|
provider_type=provider,
|
||||||
|
model=mdl,
|
||||||
|
doc_text=text,
|
||||||
|
doc_type=doc_type,
|
||||||
|
doc_title=doc_title,
|
||||||
|
db_url=db_url,
|
||||||
|
)
|
||||||
|
results = await agent.run(max_iterations=15)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Compliance check complete: %d results (%d passed, %d failed)",
|
||||||
|
len(results),
|
||||||
|
sum(1 for r in results if r.get("passed")),
|
||||||
|
sum(1 for r in results if not r.get("passed")),
|
||||||
|
)
|
||||||
|
return results
|
||||||
@@ -36,11 +36,20 @@ async def check_document_with_controls(
|
|||||||
doc_title: str,
|
doc_title: str,
|
||||||
db_url: str = "",
|
db_url: str = "",
|
||||||
max_controls: int = 0, # 0 = no limit, check ALL
|
max_controls: int = 0, # 0 = no limit, check ALL
|
||||||
|
use_agent: bool = False, # Use LLM agent for intelligent evaluation
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Check document against ALL doc_check_controls for this doc_type.
|
"""Check document against ALL doc_check_controls for this doc_type.
|
||||||
|
|
||||||
Deterministic: same text + same MCs = same result. No LLM involved.
|
Two modes:
|
||||||
|
- use_agent=False (default): Deterministic keyword matching. Fast, reproducible.
|
||||||
|
- use_agent=True: LLM agent with tool calling. Intelligent, contextual.
|
||||||
"""
|
"""
|
||||||
|
if use_agent:
|
||||||
|
try:
|
||||||
|
from compliance.services.compliance_agent import run_compliance_check
|
||||||
|
return await run_compliance_check(text, doc_type, doc_title)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Agent mode failed, falling back to regex: %s", e)
|
||||||
if not text or len(text) < 100:
|
if not text or len(text) < 100:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user