grantforge-api / backend /agents /auditor.py
GrantForge Bot
Deploy to Hugging Face
afd56bc
"""
Agencja Krytyka — Multi-Perspektywowy Audytor Wniosków.
FAZA 4: Pydantic structured output z confidence_score + human_review_required.
FAZA 5: Trzy role audytorów (Prawnik, Finansista, Innowator) → scalony wynik.
Zgodność: AI Act Art. 13 (transparency), Art. 14 (human oversight).
"""
import logging
from typing import List, Dict, Literal
from pydantic import BaseModel, Field
from core.llm_router import get_llm
from core.audit_logger import audit_log
from tenacity import retry, stop_after_attempt, wait_exponential
logger = logging.getLogger(__name__)
# ──────────────────────────────────────────────────────────────────────────────
# Modele Pydantic (FAZA 4 — strukturyzowane wyjście)
# ──────────────────────────────────────────────────────────────────────────────
class AuditIssue(BaseModel):
category: str = Field(
description="Kategoria błędu, np. 'Budżet', 'Wykluczenia', 'DNSH', 'Spójność logiki'."
)
severity: Literal["critical", "high", "medium", "low"] = Field(
description="Powaga błędu."
)
message: str = Field(
description="Opis wskazanego błędu wraz ze zidentyfikowaną niespójnością."
)
rule_citation: str = Field(
default="",
description="Cytat lub nazwa przywołanej reguły / paragrafu regulaminu.",
)
recommendation: str = Field(
default="", description="Rekomendacja: co i jak poprawić."
)
affected_section: str = Field(
default="", description="Tytuł sekcji wniosku, w której znaleziono błąd."
)
problem_quote: str = Field(
default="", description="Krótki cytat problematycznego zdania z wniosku."
)
perspective: str = Field(
default="generalny",
description="Rola audytora, który znalazł błąd (prawnik/finansista/innowator/generalny).",
)
class GlobalAuditOutput(BaseModel):
"""
Ustrukturyzowany wynik audytu całego wniosku dotacyjnego.
FAZA 4: confidence_score + human_review_required.
"""
is_approved: bool = Field(
description="Czy wniosek nadaje się do wysłania bez krytycznych błędów."
)
export_status: Literal["blocked", "warning", "ok"] = Field(
description="Stan eksportu: blocked (błąd krytyczny), warning (błędy wysokie), ok (brak poważnych)."
)
overall_score: int = Field(description="Ogólna ocena poprawności w skali 0–100.")
confidence_score: float = Field(
default=0.85,
description="Pewność modelu co do wyników audytu (0.0–1.0). Wartość < 0.7 → wymaga weryfikacji człowieka.",
)
human_review_required: bool = Field(
default=False,
description="True gdy score < 60 lub istnieją błędy critical → wymaga weryfikacji eksperta.",
)
issues: List[AuditIssue] = Field(
description="Wykryte błędy, rozbieżności i nieprawidłowości formalne."
)
perspectives_summary: Dict[str, str] = Field(
default_factory=dict,
description="Skrótowe opinie poszczególnych ról audytorów (prawnik/finansista/innowator).",
)
ai_disclaimer: str = Field(
default="Wynik audytu wygenerowany przez AI na podstawie regulaminów programu. "
"Zalecana weryfikacja przez doradcę dotacyjnego lub radcę prawnego przed złożeniem wniosku.",
description="Obowiązkowy disclaimer AI Act Art. 13.",
)
# ──────────────────────────────────────────────────────────────────────────────
# Pomocnicze prompty per rola (FAZA 5 — Multi-Perspective Audit)
# ──────────────────────────────────────────────────────────────────────────────
_ROLE_PROMPTS = {
"prawnik": """
Jesteś PRAWNIKIEM DOTACYJNYM specjalizującym się w polskim prawie i regulacjach UE.
Analizujesz WYŁĄCZNIE aspekty prawno-formalne:
- Kwalifikowalność kosztów (zakaz podwójnego finansowania, de minimis)
- Wykluczenia prawne (zakaz działalności z aneksów rozporządzeń)
- DNSH (Do No Significant Harm) — zgodność z taksonomią UE
- Warunki formalne dokumentacji (daty, podpisy, pełnomocnictwa)
- Zgodność z Rozporządzeniem UE 2021/1060 i krajowymi wytycznymi MFiPR
Zwróć TYLKO błędy prawne i formalne. Ignoruj aspekty innowacyjności czy ROI.
""",
"finansista": """
Jesteś ANALITYKIEM FINANSOWYM specjalizującym się w budżetach projektów dotacyjnych.
Analizujesz WYŁĄCZNIE aspekty finansowe:
- Budżet vs Harmonogram rzeczowo-finansowy (spójność kwot i terminów)
- Racjonalność kosztów (rynkowość cen, uzasadnienie wydatków)
- Limity intensywności pomocy dla danej kategorii firmy
- Koszty pośrednie (ryczałt / metoda rzeczywista — poprawność zastosowania)
- Ryzyko finansowe projektu i zabezpieczenia
Zwróć TYLKO błędy finansowe i rachunkowe. Ignoruj kwestie prawne i innowacyjność.
""",
"innowator": """
Jesteś EKSPERTEM OD INNOWACJI oceniającym potencjał i spójność merytoryczną projektu.
Analizujesz WYŁĄCZNIE aspekty merytoryczno-innowacyjne:
- Poziom innowacyjności (czy projekt jest wystarczająco innowacyjny dla danego programu?)
- Spójność logiczna: cele → działania → rezultaty → wskaźniki (logframe)
- Opis prac B+R (czy istnieje element badawczy i jest właściwie uzasadniony?)
- Potencjał komercjalizacji i skalowalność
- Opis ryzyk projektu i plany mitigacji
Zwróć TYLKO błędy merytoryczne i innowacyjne. Ignoruj kwestie prawne i finansowe.
""",
}
_SHARED_INSTRUCTIONS = """
Pamiętaj:
- Absolony zakaz halucynacji. Jeśli nie masz pewności — napisz "Brak wystarczających informacji."
- Zawsze odpowiadaj po polsku, używając precyzyjnego, urzędowego języka.
- Podaj CYTAT i REKOMENDACJĘ dla każdego defektu.
- Wskaż affected_section (tytuł sekcji) i problem_quote (krótki cytat).
- UWAGA: Jako `affected_section` MUSISZ użyć jednej z poniższych dokładnych nazw (nie wymyślaj własnych!):
"Streszczenie Projektu", "Opis przedsiębiorstwa i potencjał", "Opis innowacji / B+R",
"Analiza rynku i konkurencji", "Agenda badawcza / cele", "Poziom gotowości technologii (TRL)",
"Budżet i kwalifikowalność kosztów", "Harmonogram rzeczowo-finansowy", "Zespół projektowy",
"Zarządzanie ryzykiem", "Wpływ społeczny i środowiskowy (DNSH)", "Prawa własności intelektualnej",
"Wskaźniki sukcesu i ewaluacja", "Ogólne".
- Jeśli wniosek nie ma błędów i jest idealny, zwróć pustą listę `issues` i ustaw `partial_score` na 100. Wynik 0 oznacza krytyczny brak zgodności.
"""
# ──────────────────────────────────────────────────────────────────────────────
# Główna funkcja audytu (sync wrapper nad async)
# ──────────────────────────────────────────────────────────────────────────────
class _PerspectiveResult(BaseModel):
"""Wynik cząstkowy jednej roli audytora."""
issues: List[AuditIssue] = Field(default_factory=list)
summary: str = Field(default="")
partial_score: int = Field(default=100)
async def _run_perspective_audit(
role: str,
role_prompt: str,
program_name: str,
content: str,
) -> _PerspectiveResult:
"""Wywołanie LLM dla jednej roli audytora."""
llm = get_llm(task_type="legal_audit", structured_output_schema=_PerspectiveResult)
prompt = f"""{role_prompt}
{_SHARED_INSTRUCTIONS}
Nazwa/Typ programu: {program_name}
TREŚĆ WNIOSKU:
---------------------
{content[:150000]}
---------------------
Oceń wniosek ze swojej perspektywy ({role}) i zwróć: issues, summary (2-3 zdania), partial_score (0-100).
"""
@retry(
stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10)
)
def _invoke_llm():
return llm.invoke(prompt)
try:
result: _PerspectiveResult = _invoke_llm()
return result
except Exception as e:
logger.warning(f"[MultiAudit][{role}] Błąd perspektywy: {e}")
return _PerspectiveResult(
summary=f"Perspektywa {role} — błąd LLM: {str(e)[:100]}", partial_score=50
)
def _compute_final_score(scores: List[int], has_critical: bool) -> int:
"""Średnia ważona wyników perspektyw. Kara za critical."""
if not scores:
return 0
base = int(sum(scores) / len(scores))
return max(0, base - 20) if has_critical else base
def audit_final_document(
project_id: str,
program_name: str,
content: str,
enable_multi_perspective: bool = True,
is_external_audit: bool = False,
) -> GlobalAuditOutput:
"""
Agencja Krytyka — główny punkt wejścia.
Parametry:
project_id: ID projektu (do logowania)
program_name: Nazwa programu (FENG, KPO, etc.)
content: Pełna treść wygenerowanego wniosku
enable_multi_perspective: Włącz 3 role audytorów (domyślnie True)
Zwraca:
GlobalAuditOutput z issues, score, confidence, human_review_required
"""
if not content or len(content.strip()) < 50:
from core.telemetry import telemetry
telemetry.log(
"WARN",
"Auditor",
"Dokument zbyt krótki do audytu",
{"project_id": project_id},
)
return GlobalAuditOutput(
is_approved=False,
export_status="blocked",
overall_score=0,
confidence_score=1.0,
human_review_required=True,
issues=[
AuditIssue(
category="Formalności",
severity="critical",
message="Dokument jest pusty lub zbyt krótki do przeprowadzenia audytu.",
rule_citation="Minimum objętościowe wniosku",
recommendation="Wygeneruj zawartość wniosku przed uruchomieniem audytu.",
)
],
)
all_issues: List[AuditIssue] = []
perspectives_summary: Dict[str, str] = {}
perspective_scores: List[int] = []
# ── Blok Multi-Perspective (FAZA 5) ───────────────────────────────────────
if enable_multi_perspective:
logger.info(
f"[Audytor] Uruchamianie audytu multi-perspektywowego(LangGraph) dla projektu {project_id}"
)
from core.telemetry import telemetry
telemetry.log(
"INFO",
"Auditor",
"Uruchamianie audytu multi-perspektywowego",
{"project_id": project_id},
)
try:
from agents.auditor_panel_graph import auditor_panel_app
initial_state = {
"project_id": project_id,
"program_name": program_name,
"content": content,
"is_external_audit": is_external_audit,
"issues": [],
"perspectives_summary": {},
"perspective_scores": [],
"legal_attempts": 0,
"legal_queries": [],
"messages": [],
"prawnik_done": False,
"finansista_attempts": 0,
"finansista_queries": [],
"finansista_messages": [],
"finansista_done": False,
"innowator_attempts": 0,
"innowator_queries": [],
"innowator_messages": [],
"innowator_done": False,
}
# Synchronous execution of the state graph with increased recursion limit
result_state = auditor_panel_app.invoke(
initial_state, config={"recursion_limit": 150}
)
# Extrakcja finalnego wyniku z węzła zarządzającego
if "final_output" in result_state and result_state["final_output"]:
logger.info(
f"[Audytor] Pomyślnie zakończono graf LangGraph. Status: {result_state['final_output'].export_status}"
)
return result_state["final_output"]
else:
logger.warning(
"[Audytor] Graf zakończył pracę, ale nie zwrócił final_output. Fallback."
)
enable_multi_perspective = False
except Exception as e:
logger.error(
f"[Audytor] Błąd multi-perspektywowego grafu LangGraph: {e}. Fallback na audyt ogólny."
)
enable_multi_perspective = False
# ── Fallback: audyt ogólny (jeśli multi-perspective wyłączony lub failed) ─
if not enable_multi_perspective or not all_issues:
logger.info(f"[Audytor] Audyt generalny dla projektu {project_id}")
from core.telemetry import telemetry
telemetry.log(
"INFO",
"Auditor",
"Uruchamianie audytu generalnego (Fallback)",
{"project_id": project_id},
)
try:
llm_general = get_llm(
task_type="legal_audit", structured_output_schema=GlobalAuditOutput
)
general_prompt = f"""
Jesteś surowym, precyzyjnym audytorem dotacyjnym specjalizującym się w polskim prawie funduszy europejskich.
{"Pamiętaj, że weryfikujesz wniosek z firmy doradczej (zewnętrzny), musisz surowo wyłapać ich błędy." if is_external_audit else ""}
Zakaz halucynacji. Jeśli nie masz pewności — napisz: "Brak wystarczających informacji."
Odpowiadaj po polsku, precyzyjnym urzędowym językiem.
Nazwa/Typ programu: {program_name}
Wykonaj weryfikację krzyżową (Cross-Check):
1. Zgodność z celami programu
2. Budżet vs Harmonogram (spójność kwot i terminów)
3. Koszty kwalifikowalne i wykluczenia
4. Zasada DNSH (Do No Significant Harm) — zgodność klimatyczna
5. Warunki formalne i zakaz podwójnego finansowania
6. Rozbieżności merytoryczne między sekcjami
Podaj CYTAT i REKOMENDACJĘ dla każdego defektu.
Jako affected_section użyj TYLKO jednej z nazw: "Streszczenie Projektu", "Opis przedsiębiorstwa i potencjał", "Opis innowacji / B+R", "Analiza rynku i konkurencji", "Agenda badawcza / cele", "Poziom gotowości technologii (TRL)", "Budżet i kwalifikowalność kosztów", "Harmonogram rzeczowo-finansowy", "Zespół projektowy", "Zarządzanie ryzykiem", "Wpływ społeczny i środowiskowy (DNSH)", "Prawa własności intelektualnej", "Wskaźniki sukcesu i ewaluacja", "Ogólne".
Wskaż problem_quote.
Ustaw confidence_score (0.0–1.0) oraz human_review_required (True gdy score<60 lub błąd critical).
TREŚĆ WNIOSKU:
---------------------
{content[:10000]}
---------------------
"""
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=2, max=10),
)
def _invoke_general_llm():
return llm_general.invoke(general_prompt)
result: GlobalAuditOutput = _invoke_general_llm()
# Zapewnij human_review logikę
result.human_review_required = result.overall_score < 60 or any(
i.severity == "critical" for i in result.issues
)
result.export_status = _determine_export_status(result.issues)
_log_audit(project_id, result)
return result
except Exception as e:
import traceback
traceback.print_exc()
return _error_output(e)
# ── Deduplikacja issues (podobne wiadomości z różnych perspektyw) ──────────
seen_messages = set()
deduplicated: List[AuditIssue] = []
for issue in all_issues:
key = (issue.category, issue.message[:60])
if key not in seen_messages:
seen_messages.add(key)
deduplicated.append(issue)
has_critical = any(i.severity == "critical" for i in deduplicated)
any(i.severity == "high" for i in deduplicated)
overall = _compute_final_score(perspective_scores, has_critical)
output = GlobalAuditOutput(
is_approved=not has_critical,
export_status=_determine_export_status(deduplicated),
overall_score=overall,
confidence_score=round(min(1.0, len(perspective_scores) / 3 * 0.9 + 0.1), 2),
human_review_required=(overall < 60 or has_critical),
issues=deduplicated,
perspectives_summary=perspectives_summary,
)
_log_audit(project_id, output)
return output
def _determine_export_status(
issues: List[AuditIssue],
) -> Literal["blocked", "warning", "ok"]:
"""Określa status eksportu na podstawie najpoważniejszego błędu."""
severities = {i.severity for i in issues}
if "critical" in severities:
return "blocked"
if "high" in severities:
return "warning"
return "ok"
def _log_audit(project_id: str, result: GlobalAuditOutput) -> None:
try:
audit_log(
"AUDYTOR_MULTI",
f"Projekt: {project_id} | Score: {result.overall_score} | "
f"Issues: {len(result.issues)} | HumanReview: {result.human_review_required} | "
f"Confidence: {result.confidence_score:.2f}",
)
except Exception:
pass
def _error_output(e: Exception) -> GlobalAuditOutput:
return GlobalAuditOutput(
is_approved=False,
export_status="blocked",
overall_score=0,
confidence_score=0.0,
human_review_required=True,
issues=[
AuditIssue(
category="Błąd Systemowy",
severity="critical",
message=f"Awaria mechanizmu audytu LLM: {str(e)[:200]}",
recommendation="Sprawdź logi serwera i spróbuj ponownie.",
)
],
)