""" Regulation Engine (v5.0) — źródło prawdy dla reguł kwalifikowalności, cytowań i pułapek compliance. Ten moduł dostarcza trzy współpracujące komponenty: * ``CitationVerifier`` — ugruntowanie twierdzeń w regułach regulaminu (citation grounding) oraz heurystyczna ocena jakości danych w wygenerowanej treści. * ``KruczkowskiComplianceTrapAgent`` — wykrywanie pułapek regulaminowych (koszty niekwalifikowalne, cross-financing, intensywność pomocy, de minimis, podmioty powiązane). * ``RegulationEngine`` — ocena kwalifikowalności kosztów, strukturalne reguły dla programu oraz detekcja zmian regulaminu (Temporal / snapshot). Kontrakty zwracanych typów są dopasowane do wszystkich callerów w repozytorium (panel_nodes, generator_agent, helpers, auditor, external_audit_reporter, grants, projects, gsd_orchestrator, grant_search_service, matcher, compliance_guardian). """ from __future__ import annotations import re from statistics import mean from typing import Any from pydantic import BaseModel, Field # --------------------------------------------------------------------------- # Wzorce twardych odniesień regulacyjnych (§, art., ust., Dz.U., CELEX, ELI...) # --------------------------------------------------------------------------- _HARD_REF_PATTERNS = [ re.compile(r"§\s*\d+[a-z]?(?:\s*ust\.?\s*\d+)?", re.IGNORECASE), re.compile(r"\bart\.?\s*\d+[a-z]?", re.IGNORECASE), re.compile(r"\bust\.?\s*\d+", re.IGNORECASE), re.compile(r"\bpkt\.?\s*\d+", re.IGNORECASE), re.compile(r"Dz\.?\s?U\.?\s*(?:z\s*)?\d{4}", re.IGNORECASE), re.compile(r"\bCELEX[:\s]*\d*[A-Z]?\d+", re.IGNORECASE), re.compile(r"\bELI[:/\s]", re.IGNORECASE), re.compile(r"rozporządzeni[a-ząć]+\s+(?:KE|Komisji|\(UE\))", re.IGNORECASE), re.compile(r"\b\d{3,4}/\d{4}\b"), # np. 651/2014 ] def _extract_hard_refs(text: str) -> list[str]: """Wyciąga twarde odniesienia regulacyjne z tekstu (deduplikacja, limit).""" if not text: return [] found: list[str] = [] for pat in _HARD_REF_PATTERNS: for m in pat.findall(text): ref = (m if isinstance(m, str) else " ".join(m)).strip() if ref and ref not in found: found.append(ref) return found[:12] class CitationVerificationResult(BaseModel): claim: str = "" program: str = "" support_score: float = Field(default=0.5, ge=0.0, le=1.0) matched_rules: list[str] = Field(default_factory=list) verdict: str = "inconclusive" details: str = "" class CitationVerifier: """Weryfikuje ugruntowanie twierdzeń w regułach regulaminu.""" # Frazy zwiększające ryzyko halucynacji (twierdzenia bez pokrycia w regulaminie) _UNSUPPORTED_MARKERS = ( "w pełni kwalifikowalne", "100% kwalifikowalne", "zawsze kwalifikowalne", "bez ograniczeń", "dowolny", "dowolne", "gwarantuje", "na pewno", ) # Cache tekstu reguł snapshotu per program (unikamy wielokrotnych zapytań DB). _snapshot_rules_cache: dict[str, str] = {} @classmethod def _get_snapshot_rules_text(cls, program: str) -> str: """Pobiera tekst reguł z aktualnego snapshotu regulaminu (jeśli dostępny). To źródło REALNEGO groundingu — jeśli snapshot istnieje, twierdzenia z cytatami są weryfikowane względem jego treści, a nie zaszytych stałych. """ key = (program or "").strip().upper() if not key: return "" if key in cls._snapshot_rules_cache: return cls._snapshot_rules_cache[key] text = "" try: from core.search.regulation_snapshot import regulation_snapshot_store snap = regulation_snapshot_store.get_latest_for_program(program) if snap is not None: parts: list[str] = [] for attr in ("key_rules", "exclusions", "scoring_criteria", "extracted_rules"): val = getattr(snap, attr, None) if isinstance(val, list): for r in val: parts.append(r if isinstance(r, str) else str(r.get("rule") or r.get("text") or r) if isinstance(r, dict) else str(r)) raw = getattr(snap, "raw_text", None) if isinstance(raw, str) and raw: parts.append(raw) text = "\n".join(p for p in parts if p) except Exception: text = "" cls._snapshot_rules_cache[key] = text return text def verify_claim_against_rules( self, claim_text: str, program: str = "" ) -> CitationVerificationResult: """Ocena UGRUNTOWANIA twierdzenia w regułach regulaminu. Grounding opiera się na obecności TWARDYCH odniesień regulacyjnych (§, art., Dz.U., CELEX...) oraz — gdy dostępny — na REALNEJ weryfikacji względem snapshotu regulaminu. Samo dopasowanie tematyczne (np. „wynagrodzenie") NIE jest już traktowane jako ugruntowanie (koniec z fałszywym wysokim score 0.55/0.82). """ text = (claim_text or "") lower = text.lower() refs = _extract_hard_refs(text) rules: list[str] = [] snapshot_rules = self._get_snapshot_rules_text(program) grounded = False if snapshot_rules and refs: snap_lower = snapshot_rules.lower() for r in refs: # Normalizacja odniesienia do porównania (usuń nadmiar spacji) norm = re.sub(r"\s+", " ", r.strip().lower()) if norm and norm in snap_lower: grounded = True rules.append(r) if grounded: score = 0.85 details = "Odniesienie potwierdzone w aktualnym snapshocie regulaminu." elif refs and snapshot_rules and not grounded: # Cytat obecny, ale NIE potwierdzony w snapshocie → możliwa halucynacja cytatu. score = 0.3 details = "Twarde odniesienie NIE znalezione w snapshocie — możliwa błędna/niezweryfikowana cytacja." elif refs and not snapshot_rules: # Są odniesienia, brak snapshotu do porównania — sygnał umiarkowany (heurystyka). score = 0.55 details = "Obecne twarde odniesienia regulacyjne, brak snapshotu do pełnej weryfikacji (sygnał heurystyczny)." else: # Brak twardego odniesienia = brak realnego cytatu → niskie ugruntowanie. score = 0.3 details = "Brak twardych odniesień (§/art./Dz.U./CELEX) — brak ugruntowania cytatami." # Twierdzenia absolutne obniżają wiarygodność (brak niuansu regulaminowego) if any(m in lower for m in self._UNSUPPORTED_MARKERS): score = min(score, 0.25) details += " Wykryto twierdzenie absolutne (ryzyko nadinterpretacji)." return CitationVerificationResult( claim=claim_text, program=program, support_score=round(score, 3), matched_rules=rules, verdict="supported" if score >= 0.6 else "weak", details=details, ) @staticmethod def _split_claims(text: str) -> list[str]: if not text: return [] sentences = re.split(r"(?<=[.!?])\s+|\n+", text) return [s.strip() for s in sentences if len(s.strip()) > 20] def verify_text_citations( self, full_text: str = "", program: str = "", sample_claims: list[str] | None = None, **kwargs: Any, ) -> dict[str, Any]: """ Weryfikuje ugruntowanie całego tekstu. Przyjmuje ``full_text`` lub pierwszy argument pozycyjny (kompatybilność z ``verify_text_citations(text, program)``). Zwraca dict zgodny z callerami (overall_citation_score, per_claim_results, ...). """ text = full_text or kwargs.get("text", "") or "" program = program or kwargs.get("program", "") claims = sample_claims if sample_claims else self._split_claims(text) claims = claims[:8] per_claim_results: list[dict[str, Any]] = [] scores: list[float] = [] for claim in claims: res = self.verify_claim_against_rules(claim, program) refs = _extract_hard_refs(claim) issues: list[str] = [] if res.support_score < 0.5: issues.append("Niskie ugruntowanie twierdzenia w regułach regulaminu.") if not res.matched_rules and not refs: issues.append("Brak dopasowanej reguły i twardego odniesienia (§/art./Dz.U.).") per_claim_results.append( { "claim": claim[:220], "support_score": res.support_score, "verdict": res.verdict, "matched_rules": res.matched_rules, "regulation_refs_used": refs, "issues": issues, } ) scores.append(res.support_score) hard_refs = _extract_hard_refs(text) overall = round(mean(scores), 3) if scores else 0.0 # Bonus za obecność twardych odniesień w całym dokumencie if hard_refs: overall = round(min(1.0, overall + min(0.1, 0.02 * len(hard_refs))), 3) if overall >= 0.75 and hard_refs: quality = "high" elif overall >= 0.6: quality = "medium" elif overall >= 0.45: quality = "medium-low" else: quality = "low" if quality in ("low", "medium-low"): recommendation = ( "Dodaj bezpośrednie odniesienia do reguł regulaminu (§/art./Dz.U./CELEX) " "i zweryfikuj twierdzenia względem aktualnego snapshotu." ) else: recommendation = "Ugruntowanie na akceptowalnym poziomie — utrzymaj cytowania regulaminowe." grounding_method = "snapshot" if self._get_snapshot_rules_text(program) else "heuristic" return { "overall_citation_score": overall, "citation_quality": quality, "quality_level": quality, "grounding_method": grounding_method, "per_claim_results": per_claim_results, "claims_checked": len(claims), "hard_regulation_refs_extracted": hard_refs, "recommendation": recommendation, "program": program, } def compute_generated_content_data_quality( self, text: str, program: str = "" ) -> dict[str, Any]: """ Heurystyczna ocena jakości danych w wygenerowanej treści (0-100). Karze wodolejstwo/ogólniki i placeholdery, nagradza konkretne liczby, daty i odniesienia regulacyjne. Zwraca dict z ``data_quality_score``, ``quality_level``, ``signals`` i ``recommendation``. """ content = text or "" lowered = content.lower() signals: list[str] = [] score = 55 # neutralny start word_count = len(content.split()) # Konkrety: liczby, procenty, kwoty, daty numbers = re.findall(r"\d[\d\s.,]*", content) if len(numbers) >= 5: score += 12 signals.append("Wysoka gęstość konkretnych liczb/kwot.") elif len(numbers) >= 1: score += 5 else: score -= 12 signals.append("Brak konkretnych liczb — treść zbyt ogólna.") if re.search(r"\d+\s*(%|proc|zł|pln|eur|mln|tys)", lowered): score += 6 signals.append("Obecne wartości finansowe/procentowe.") # Odniesienia regulacyjne hard_refs = _extract_hard_refs(content) if hard_refs: score += min(15, 4 * len(hard_refs)) signals.append(f"Wykryto {len(hard_refs)} twardych odniesień regulacyjnych.") else: score -= 6 # Ogólniki / wodolejstwo vague_terms = [ "innowacyjny", "nowoczesny", "kompleksowy", "synerg", "holistyczn", "dynamiczn", "efektywn", "optymaln", "znaczący", "wysokiej jakości", ] vague_hits = sum(lowered.count(v) for v in vague_terms) if vague_hits >= 6: score -= 14 signals.append("Nadmiar ogólników marketingowych (niska specyficzność).") elif vague_hits >= 3: score -= 6 # Placeholdery = poważny sygnał niekompletności placeholder_hits = len( re.findall(r"\[(?:do (?:uzupełni|weryfikacj)|uzupełnij|todo|xxx|\.\.\.)[^\]]*\]", lowered) ) if placeholder_hits: score -= 10 * placeholder_hits signals.append(f"Wykryto {placeholder_hits} placeholderów do uzupełnienia.") # Zbyt krótka treść if word_count < 40: score -= 8 signals.append("Treść bardzo krótka — mało danych do oceny.") score = int(max(0, min(100, score))) if score >= 75: level = "high" elif score >= 55: level = "medium" elif score >= 45: level = "medium-low" else: level = "low" if level in ("low", "medium-low"): recommendation = ( "Zwiększ specyficzność: dodaj konkretne liczby, kwoty, daty i odniesienia " "do regulaminu; usuń placeholdery i ogólniki." ) else: recommendation = "Jakość danych na dobrym poziomie." return { "data_quality_score": score, "quality_level": level, "signals": signals, "recommendation": recommendation, "program": program, } class KruczkowskiComplianceTrapAgent: """Wykrywa pułapki regulaminowe w treści (koszty, cross-financing, pomoc publiczna). Dopasowanie odbywa się po GRANICACH słów / wyrażeniach regularnych (nie gołych podłańcuchach), z listą wyjątków (whitelist) legalnych fraz. Każda pułapka ma wagę, a próg ``blocks_export`` jest liczony na podstawie ważonego wyniku ryzyka — dzięki czemu ograniczono fałszywe alarmy (np. „zarząd" ⊄ „zarządzanie", „reklam" ⊄ „reklamacja", poprawne „de minimis" bez sygnału przekroczenia limitu nie jest pułapką critical). """ # (kod, severity, lista wzorców regex, opis, [opcjonalne wzorce whitelist]) # Wzorce dopasowywane są na tekście zmniejszonym do lower-case, z użyciem re. _TRAP_RULES: list[dict[str, Any]] = [ { "code": "trap_personnel", "severity": "high", # „zarząd/zarządu/zarządem" TAK, ale „zarządzanie/zarządzać" NIE. "patterns": [ r"\bprezes\w*", r"\bzarząd(?:u|em|zie|y|ów|owi|a)?\b", r"\bkadr\w*\s+kierownicz\w*", r"\bwspó?lnik\w*", r"istniej[ąa]c\w*\s+(?:etat|pracownik|stanowisk)\w*", r"już\s+zatrudnion\w*", r"ju[żz]\s+pracuj[ąa]c\w*", ], "description": "Koszty personelu zarządzającego / istniejących etatów mogą być niekwalifikowalne.", }, { "code": "ineligible_cost_category", "severity": "high", "patterns": [ r"\bsamoch[oó]d\w*", r"\bpojazd\w*", r"środ\w*\s+transportu", r"\bmarketing\w*", r"\bpromocj\w*", # „reklama/reklamy/reklamowy" TAK, ale „reklamacja" NIE. r"\breklam(?:a|y|ę|owy|owa|owe|owych|owej|owym|ie|ow)?\b", r"\bmebl\w*", r"\brecepcj\w*", r"\bu[żz]ywan\w*", ], "description": "Kategoria kosztu typowo wykluczona z katalogu kosztów kwalifikowalnych.", }, { "code": "aid_intensity_exceeded", "severity": "critical", "patterns": [ r"\b100\s*%", r"\b1[12]\d\s*%", r"bez\s+wk[łl]adu\s+w[łl]asn\w*", r"pe[łl]n\w*\s+finansowani\w*", r"\b9\d\s*%\s*(?:pokryci|dofinansow|finansow)\w*", ], # Whitelist: „zgodnie z dopuszczalną intensywnością" itp. nie jest pułapką. "whitelist": [ r"zgodni\w*\s+z\s+(?:dopuszczaln\w*\s+)?intensywno", r"w\s+ramach\s+dopuszczaln\w*\s+intensywno", ], "description": "Deklarowana intensywność pomocy przekracza dopuszczalne limity.", }, { "code": "de_minimis_violation", "severity": "critical", # Tylko sygnały PRZEKROCZENIA / kumulacji — samo „de minimis" NIE jest pułapką. "patterns": [ r"przekrocz\w*\s+(?:limit\w*\s+)?(?:pomoc\w*|de\s*minimis)", r"de\s*minimis[^.]{0,60}\bprzekrocz\w*", r"kumulac\w*\s+(?:pomoc\w*|de\s*minimis|dotacj\w*)", r"(?:powy[żz]ej|ponad)\s+300\s*(?:tys|000)", r"przekrocz\w*\s+300\s*(?:tys|000)", ], "whitelist": [ r"nie\s+przekracz\w*\s+limit", r"w\s+ramach\s+limitu\s+de\s*minimis", r"zgodni\w*\s+z\s+limitem\s+de\s*minimis", r"poni[żz]ej\s+limitu", ], "description": "Ryzyko przekroczenia limitu / kumulacji pomocy de minimis.", }, { "code": "double_financing", "severity": "critical", "patterns": [ r"podw[oó]jn\w*\s+finansow\w*", r"ten\s+sam\s+cel", r"tego\s+samego\s+zadania", r"finansowan\w*\s+wcześniej", r"z\s+innej\s+instytucji[^.]{0,40}ten\s+sam", ], "description": "Ryzyko podwójnego finansowania tego samego zakresu.", }, { "code": "cross_financing_risk", "severity": "high", "patterns": [ r"cross[\s-]?financing", r"r[óo]wnoleg[łl]\w*\s+gwarancj\w*", r"mieszan\w*\s+środk\w*", r"na\s+ten\s+sam\s+(?:zakup|kredyt)", ], "description": "Ryzyko niedozwolonego cross-financingu instrumentów.", }, { "code": "related_party", "severity": "high", # Tylko w kontekście podmiotu powiązanego / braku przetargu (nie gołe „powiązany"). "patterns": [ r"podmiot\w*\s+powi[ąa]zan\w*", r"powi[ąa]zan\w*\s+kapita[łl]owo", r"bez\s+przetargu", ], "description": "Transakcja z podmiotem powiązanym — ryzyko zawyżenia i braku konkurencyjności.", }, { "code": "ip_ownership_mismatch", "severity": "medium", "patterns": [ r"nie\s+nale[żz][ąa]\s+do\s+wnioskodawcy", r"prawa\s+autorskie\s+os[óo]b\s+trzecich", r"licencj\w*\s+od\s+podmiotu\s+powi[ąa]zan\w*", ], "description": "Niejasna własność praw IP — ryzyko dla kwalifikowalności.", }, { "code": "dnsh_overclaim", "severity": "medium", "patterns": [ r"naruszeni\w*\s+zasad\w*\s+dnsh", r"naruszeni\w*\s+dnsh", r"\bbez\s+dnsh\b", ], "description": "Ryzyko naruszenia zasady 'nie czyń poważnych szkód' (DNSH).", }, { "code": "consulting_ineligible", "severity": "medium", "patterns": [ r"przygotowani\w*\s+(?:dokumentacj\w*|wniosku)", r"dokumentacj\w*\s+aplikacyjn\w*", ], "description": "Koszty przygotowania wniosku zwykle nie są kwalifikowalne.", }, ] _RISK_ORDER = {"low": 0, "medium": 1, "high": 2, "critical": 3} # Wagi ryzyka do progu blokady eksportu (ważony wynik zamiast binarnego trafienia). _RISK_WEIGHT = {"low": 0, "medium": 1, "high": 2, "critical": 4} # Cache skompilowanych wzorców (per instancja/proces). _compiled_cache: dict[str, Any] | None = None @classmethod def _compiled_rules(cls) -> list[dict[str, Any]]: if cls._compiled_cache is None: compiled = [] for rule in cls._TRAP_RULES: compiled.append( { "code": rule["code"], "severity": rule["severity"], "description": rule["description"], "patterns": [re.compile(p, re.IGNORECASE) for p in rule["patterns"]], "whitelist": [re.compile(p, re.IGNORECASE) for p in rule.get("whitelist", [])], } ) cls._compiled_cache = compiled # type: ignore[assignment] return cls._compiled_cache # type: ignore[return-value] def detect_traps( self, document_text: str = "", program: str = "", msp_context: Any = None, **kwargs: Any, ) -> dict[str, Any]: """ Wykrywa pułapki compliance w tekście. Pierwszy argument pozycyjny to treść (kompatybilność z ``detect_traps(text, program)`` i ``detect_traps(text, program, context)``). ``msp_context`` może zawierać kontekst MŚP/analizy (dict) i wpływa na severity. """ text = document_text or kwargs.get("cost_description", "") or kwargs.get("text", "") lowered = (text or "").lower() program = program or kwargs.get("program", "") traps: list[dict[str, Any]] = [] max_risk = "low" for rule in self._compiled_rules(): # Whitelist: jeśli obecna legalna fraza kontekstowa — pomiń tę pułapkę. if any(wl.search(lowered) for wl in rule["whitelist"]): continue for pat in rule["patterns"]: m = pat.search(lowered) if m: traps.append( { "trap": rule["code"], "code": rule["code"], "severity": rule["severity"], "description": rule["description"], "evidence": m.group(0)[:80], } ) if self._RISK_ORDER[rule["severity"]] > self._RISK_ORDER[max_risk]: max_risk = rule["severity"] break # jedno trafienie na regułę wystarczy # Kontekst MŚP: duże przedsiębiorstwo => surowsza ocena intensywności pomocy if isinstance(msp_context, dict): size = str( msp_context.get("company_size") or msp_context.get("size") or msp_context.get("enterprise_size") or "" ).lower() if size in ("duża", "duza", "large") and any( t["code"] in ("aid_intensity_exceeded", "cross_financing_risk") for t in traps ): max_risk = "critical" num_traps = len(traps) # Ważony wynik ryzyka — próg blokady zamiast binarnego trafienia. weighted_score = sum(self._RISK_WEIGHT.get(t["severity"], 0) for t in traps) blocks_export = (max_risk == "critical") or (weighted_score >= 6) or (num_traps >= 4) # Citation grounding zawsze dołączony (oczekiwany przez callerów) citation_verification = citation_verifier.verify_text_citations( full_text=text[:6000], program=program ) return { "overall_trap_risk": max_risk, "risk_level": max_risk, "traps": traps, "traps_detected": traps, "num_traps": num_traps, "weighted_risk_score": weighted_score, "blocks_export_recommendation": blocks_export, "citation_verification": citation_verification, "program": program, } def check_section_for_traps( self, section_content: str, program: str = "", **kwargs: Any ) -> dict[str, Any]: """Alias sekcyjny — używany w generator_agent._run_v5_verification.""" return self.detect_traps(section_content, program, **kwargs) class RegulationEngine: # Uwaga (P2#5): usunięto zbyt ogólne słowa („wniosku", „powiązany") powodujące # masowe fałszywe alarmy. Dopasowanie odbywa się po granicach słów (regex), a # ocena stosowana jest na WYODRĘBNIONYCH pozycjach kosztowych, nie całym dokumencie. INELIGIBLE_KEYWORDS = [ "samochód", "pojazd", "marketing", "reklama", "przygotowania dokumentacji", "dokumentacji aplikacyjnej", "używan", "podwójne finansowanie", "kumulacja", "100% pokrycia", "spłata własnego", "istniejącego pracownika", "podmiot powiązany", ] ELIGIBLE_KEYWORDS = [ "wynagrodzen", "personel badaw", "b+r", "badani", "patent", "certyfikac", "modernizac", "maszyn", "szkolen", "oze", "fotowolta", "led", "oprogramowanie", "adaptacja stanowiska", "refundacja kosztów wyposażenia", ] # Wskaźniki, że linia/zdanie dotyczy pozycji kosztowej (do wyodrębnienia). _COST_LINE_HINTS = re.compile( r"(koszt|wydatek|wydatki|budżet|budzet|kwot|cena|zakup|nakład|naklad|" r"\d[\d\s.,]*\s*(?:zł|zl|pln|eur|tys|mln|%))", re.IGNORECASE, ) @staticmethod def _extract_cost_items(text: str) -> list[str]: """Wyodrębnia z dokumentu prawdopodobne pozycje kosztowe (linie/zdania). Ocenę kwalifikowalności stosujemy tylko na tych fragmentach, a nie na całym dokumencie — dzięki temu np. słowo pojawiające się w opisie merytorycznym nie wywołuje fałszywego alarmu kosztowego. """ if not text: return [] items: list[str] = [] for raw_line in re.split(r"[\n\r]+", text): line = raw_line.strip(" \t-•*|") if len(line) < 4: continue if RegulationEngine._COST_LINE_HINTS.search(line): items.append(line[:400]) # Fallback: jeśli brak wyraźnych linii kosztowych, potnij na zdania kosztowe. if not items: for sent in re.split(r"(?<=[.!?])\s+", text): if RegulationEngine._COST_LINE_HINTS.search(sent): items.append(sent.strip()[:400]) return items[:40] # Mapa usuwania polskich znaków diakrytycznych (dopasowanie odporne na fleksję ó/o itp.) _PL_DIACRITICS = str.maketrans("ąćęłńóśźż", "acelnoszz") @staticmethod def _kw_hit(keyword: str, text_lower: str) -> str | None: """Dopasowanie słowa kluczowego po granicy słowa (stem + końcówka fleksyjna). Odporne na polskie znaki diakrytyczne (np. „samochód" dopasuje „samochodu"), ale nadal wymaga granicy słowa (nie gołego podłańcucha). """ norm_text = text_lower.translate(RegulationEngine._PL_DIACRITICS) norm_kw = keyword.lower().translate(RegulationEngine._PL_DIACRITICS) stem = re.escape(norm_kw) pat = re.compile(rf"(? dict[str, Any]: """Ocena kwalifikowalności kosztów na WYODRĘBNIONYCH pozycjach kosztowych. Zwraca podsumowanie (kompatybilne z dotychczasowymi callerami) oraz listę trafień z kontekstem (``hits``) i ``recommendation`` (czytany m.in. w panel_nodes.py). """ prog = (program or "").upper() items = self._extract_cost_items(cost_description or "") if not items: # brak wyraźnych pozycji kosztowych — analizuj całość jako pojedynczą pozycję items = [(cost_description or "")[:400]] if cost_description else [] ineligible_hits: list[dict[str, Any]] = [] eligible_hits: list[dict[str, Any]] = [] for item in items: item_lower = item.lower() for kw in self.INELIGIBLE_KEYWORDS: hit = self._kw_hit(kw, item_lower) if hit: ineligible_hits.append({ "keyword": kw, "matched": hit, "context": item.strip()[:200], "severity": "high", }) break # jedna kategoria niekwalifikowalna na pozycję wystarczy else: for kw in self.ELIGIBLE_KEYWORDS: hit = self._kw_hit(kw, item_lower) if hit: eligible_hits.append({ "keyword": kw, "matched": hit, "context": item.strip()[:200], }) break if ineligible_hits: severity = "critical" if len(ineligible_hits) >= 3 else "high" joined = "; ".join(f"'{h['matched']}' w: \"{h['context']}\"" for h in ineligible_hits[:5]) return { "status": "evaluated", "eligible": False, "severity": severity, "justification": f"Wykryto {len(ineligible_hits)} potencjalnie niekwalifikowalnych pozycji wg regulaminu {prog}: {joined}", "regulation_reference": f"{prog} — wykluczenia kosztów", "recommendation": ( "Przenieś wskazane koszty poza budżet kwalifikowalny lub udokumentuj " "podstawę kwalifikowalności zgodnie z aktualnym regulaminem/snapshotem." ), "hits": ineligible_hits, } if eligible_hits: return { "status": "evaluated", "eligible": True, "severity": "low", "justification": f"Pozycje kosztowe zgodne z katalogiem kwalifikowalnym {prog} ({len(eligible_hits)} trafień).", "regulation_reference": f"{prog} — katalog kosztów kwalifikowalnych", "recommendation": "Utrzymaj powiązanie kosztów z celami projektu i regulaminem.", "hits": eligible_hits, } return { "status": "no_rules", "eligible": None, "severity": "medium", "justification": "Brak dopasowania do katalogu kosztów — wymaga weryfikacji manualnej względem regulaminu.", "regulation_reference": "", "recommendation": "Zweryfikuj pozycje kosztowe względem aktualnego regulaminu/snapshotu programu.", "hits": [], } @staticmethod def _match_kb_key(prog_upper: str) -> str | None: for key in RegulationEngine._PROGRAM_KB: if key in prog_upper: return key return None def get_structured_rules_for_program( self, program: str, call_name: str = "", module: str = "", **kwargs: Any ) -> dict[str, Any]: """ Zwraca strukturalne reguły dla programu. Źródła (w kolejności priorytetu): 1. Snapshot regulaminu (DB / in-memory) — jeśli dostępny (źródło prawdy). 2. Baza wiedzy klas programów (fallback deterministyczny). 3. Heurystyka na słowach kluczowych eligible/ineligible. Zwraca dict z: ``key_rules``, ``exclusions``, ``eligible_costs``, ``ineligible_costs``, ``scoring_criteria``, ``required_attachments``, ``required_sections``, ``limits``, ``source``, ``snapshot_backed``. """ prog = (program or "").strip() result: dict[str, Any] = { "program": prog, "module": module or "", "key_rules": [], "exclusions": [], "eligible_costs": [], "ineligible_costs": [], "scoring_criteria": [], "required_attachments": [], "required_sections": [], "limits": {"de_minimis_eur": 300000}, "source": "heuristic", "snapshot_backed": False, } if not prog: return result prog_upper = prog.upper() # 1) Snapshot (DB-backed / in-memory) try: from core.search.regulation_snapshot import regulation_snapshot_store snap = regulation_snapshot_store.get_latest_for_program(prog) if snap is not None: result["snapshot_backed"] = True result["source"] = "snapshot" for attr, key in ( ("key_rules", "key_rules"), ("exclusions", "exclusions"), ("scoring_criteria", "scoring_criteria"), ("required_attachments", "required_attachments"), ("required_sections", "required_sections"), ): val = getattr(snap, attr, None) if isinstance(val, list) and val: result[key] = list(val) # extracted_rules jako lista stringów/dictów -> zasil key_rules extracted = getattr(snap, "extracted_rules", None) if extracted and not result["key_rules"]: parsed: list[str] = [] for r in extracted: if isinstance(r, str): parsed.append(r) elif isinstance(r, dict): parsed.append(str(r.get("rule") or r.get("text") or r)) if parsed: result["key_rules"] = parsed[:20] eff = getattr(snap, "effective_date", "") if eff: result["effective_date"] = eff except Exception: pass # 2) Baza wiedzy klas programów (uzupełnia brakujące pola) kb_key = self._match_kb_key(prog_upper) if kb_key: kb = self._PROGRAM_KB[kb_key] for field_name, values in kb.items(): if not result.get(field_name): result[field_name] = list(values) if result["source"] == "heuristic": result["source"] = "knowledge_base" # 3) Heurystyka na słowach kluczowych (zawsze dostępna jako uzupełnienie) if not result["ineligible_costs"]: result["ineligible_costs"] = list(self.INELIGIBLE_KEYWORDS) if not result["eligible_costs"]: result["eligible_costs"] = list(self.ELIGIBLE_KEYWORDS) # Zawsze zapewnij minimalny zestaw reguł/wykluczeń, by callerzy dostali dane if not result["key_rules"]: result["key_rules"] = [ f"Program {prog}: koszty muszą być bezpośrednio powiązane z celami projektu.", "Wymagane udokumentowanie kwalifikowalności każdego kosztu.", self._DE_MINIMIS_RULE, ] if not result["exclusions"]: result["exclusions"] = [ "Koszty niekwalifikowalne: " + ", ".join(self.INELIGIBLE_KEYWORDS[:8]) + ".", "Podwójne finansowanie tego samego zakresu.", ] return result def detect_regulation_change(self, program: str, **kwargs: Any) -> dict[str, Any]: """ Wykrywa zmianę regulaminu dla programu. Źródła (w kolejności): 1. Neo4j Temporal Graph (:RegulationVersion / :SUPERSEDES) — jeśli dostępny. 2. Historia snapshotów (RegulationSnapshotStore) — porównanie ostatnich wersji. Zwraca dict z: ``changed``, ``signal``, ``change_summary``, ``timeline_len``, ``last_change``, ``latest_version``, ``previous_version``, ``graph_sourced``. """ prog = (program or "unknown").strip() base: dict[str, Any] = { "changed": False, "program": prog, "signal": "stable", "change_summary": "", "timeline_len": 0, "last_change": "", "latest_version": "", "previous_version": "", "graph_sourced": False, } # 1) Neo4j Temporal Graph try: from core.graph_db.neo4j_client import neo4j_client graph_res = neo4j_client.detect_regulation_change_via_graph(prog) if isinstance(graph_res, dict) and graph_res.get("graph_sourced"): changed = bool(graph_res.get("changed")) base.update( { "changed": changed, "graph_sourced": True, "latest_version": graph_res.get("latest_version", "") or "", "previous_version": graph_res.get("previous_version", "") or "", "last_change": graph_res.get("latest_fetched", "") or "", "change_summary": graph_res.get("change_summary", "") or "", "signal": "changed" if changed else "stable", } ) if changed: return base except Exception: pass # 2) Historia snapshotów try: from core.search.regulation_snapshot import regulation_snapshot_store history = regulation_snapshot_store.get_version_history_for_program(prog, limit=5) base["timeline_len"] = len(history) if history: latest = history[0] base["latest_version"] = latest.get("version_hash", "") or base["latest_version"] base["last_change"] = latest.get("fetched_at", "") or base["last_change"] if len(history) > 1: prev = history[1] base["previous_version"] = prev.get("version_hash", "") or base["previous_version"] if latest.get("version_hash") != prev.get("version_hash"): base["changed"] = True base["signal"] = "changed" base["change_summary"] = ( latest.get("change_summary") or f"Wykryto zmianę wersji regulaminu dla {prog}." ) except Exception: pass return base regulation_engine = RegulationEngine() citation_verifier = CitationVerifier() kruczkowski_trap_agent = KruczkowskiComplianceTrapAgent()