Spaces:
Sleeping
Sleeping
| """ | |
| 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] = {} | |
| 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, | |
| ) | |
| 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 | |
| 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, | |
| ) | |
| 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") | |
| 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"(?<![a-z0-9_]){stem}[a-z0-9]*") | |
| m = pat.search(norm_text) | |
| return m.group(0) if m else None | |
| # Lekka baza wiedzy o klasach programów (fallback gdy brak snapshotu). | |
| # Klucz = substring dopasowany do nazwy programu (upper-case). | |
| _PROGRAM_KB: dict[str, dict[str, list[str]]] = { | |
| "FENG": { | |
| "key_rules": [ | |
| "Wsparcie modułowe (B+R, wdrożenie, infrastruktura B+R, cyfryzacja, zazielenienie).", | |
| "Koszty personelu B+R kwalifikowalne przy udokumentowanym zaangażowaniu w projekt.", | |
| "Wymagany komponent innowacyjności co najmniej na poziomie kraju.", | |
| ], | |
| "exclusions": [ | |
| "Zakup samochodów osobowych i środków transportu (poza uzasadnionymi wyjątkami).", | |
| "Koszty przygotowania dokumentacji aplikacyjnej.", | |
| "Koszty marketingu i promocji jako koszt główny projektu.", | |
| ], | |
| "scoring_criteria": [ | |
| "Innowacyjność i poziom TRL.", | |
| "Potencjał rynkowy i wdrożeniowy.", | |
| "Zgodność z inteligentnymi specjalizacjami.", | |
| ], | |
| "required_attachments": [ | |
| "Biznesplan / opis projektu.", | |
| "Analiza finansowa i kosztorys.", | |
| "Oświadczenia o pomocy publicznej / de minimis.", | |
| ], | |
| "required_sections": [ | |
| "Opis projektu i innowacji", | |
| "Harmonogram i kamienie milowe", | |
| "Budżet i kwalifikowalność kosztów", | |
| "Analiza rynku i wdrożenie", | |
| ], | |
| }, | |
| "PARP": { | |
| "key_rules": [ | |
| "Wsparcie MŚP na inwestycje, B+R i internacjonalizację.", | |
| "Wymagany wkład własny beneficjenta.", | |
| ], | |
| "exclusions": [ | |
| "Zakup samochodów osobowych dla kadry zarządzającej.", | |
| "Koszty bieżącej działalności i marketingu ogólnego.", | |
| ], | |
| "scoring_criteria": ["Efektywność ekonomiczna", "Innowacyjność", "Trwałość rezultatów"], | |
| "required_attachments": ["Kosztorys", "Oświadczenia MŚP i de minimis"], | |
| "required_sections": ["Opis przedsięwzięcia", "Budżet", "Wskaźniki rezultatu"], | |
| }, | |
| "NCBR": { | |
| "key_rules": [ | |
| "Finansowanie badań przemysłowych i prac rozwojowych.", | |
| "Kwalifikowalne koszty personelu badawczego, aparatury, podwykonawstwa (z limitami).", | |
| ], | |
| "exclusions": [ | |
| "Podwykonawstwo od podmiotów powiązanych bez zachowania konkurencyjności.", | |
| "Koszty tego samego zadania finansowanego z innego źródła.", | |
| ], | |
| "scoring_criteria": ["Nowość naukowa", "Poziom TRL", "Zespół badawczy"], | |
| "required_attachments": ["Opis prac B+R", "Budżet zadaniowy"], | |
| "required_sections": ["Aspekty naukowe", "Plan prac B+R", "Budżet"], | |
| }, | |
| "BGK": { | |
| "key_rules": [ | |
| "Instrumenty finansowe (kredyt technologiczny, gwarancje, dopłaty do odsetek).", | |
| "Premia technologiczna na spłatę części kredytu na nowe technologie.", | |
| ], | |
| "exclusions": [ | |
| "Łączenie instrumentów przekraczające dopuszczalną intensywność pomocy.", | |
| "Cross-financing na ten sam zakup bez zgody.", | |
| ], | |
| "scoring_criteria": ["Zdolność kredytowa", "Poziom innowacji technologicznej"], | |
| "required_attachments": ["Promesa/umowa kredytowa", "Kosztorys inwestycji"], | |
| "required_sections": ["Opis technologii", "Plan finansowy"], | |
| }, | |
| "NFOŚIGW": { | |
| "key_rules": [ | |
| "Finansowanie inwestycji proekologicznych (OZE, efektywność energetyczna).", | |
| "Wymóg zgodności z zasadą DNSH.", | |
| ], | |
| "exclusions": [ | |
| "Inwestycje naruszające zasadę DNSH.", | |
| "Finansowanie 100% bez wkładu własnego (poza wyjątkami).", | |
| ], | |
| "scoring_criteria": ["Efekt ekologiczny", "Efektywność kosztowa"], | |
| "required_attachments": ["Audyt energetyczny", "Dokumentacja techniczna"], | |
| "required_sections": ["Efekt ekologiczny", "Budżet", "Harmonogram"], | |
| }, | |
| } | |
| _DE_MINIMIS_RULE = "Limit pomocy de minimis: 300 000 EUR w okresie 3 lat (kumulacja z innymi programami)." | |
| def check_cost_eligibility(self, program: str, cost_description: str) -> 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": [], | |
| } | |
| 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() | |