grantforge-api / backend /core /search /regulation_engine.py
GrantForge Bot
Deploy sha-93ab5d396350b3e6db35d3bd315ec00724aa6c32 — source build (no GHCR)
5ecb84c
Raw
History Blame Contribute Delete
44.5 kB
"""
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"(?<![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": [],
}
@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()