""" 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.", ) ], )