Spaces:
Sleeping
Sleeping
GrantForge Bot commited on
Commit ·
ce8f04a
0
Parent(s):
Deploy sha-565ad85979610064f6d1c18ab3b6404357d61073 — source build (no GHCR)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +23 -0
- Dockerfile +71 -0
- README.md +69 -0
- backend/.deepeval/.deepeval-cache.json +1 -0
- backend/.deepeval/.deepeval_telemetry.txt +4 -0
- backend/.env.example +143 -0
- backend/Dockerfile +30 -0
- backend/add_keys.py +23 -0
- backend/agents/__init__.py +1 -0
- backend/agents/auditor.py +581 -0
- backend/agents/auditor_panel_graph.py +82 -0
- backend/agents/autofill_agent.py +352 -0
- backend/agents/compliance_guardian.py +141 -0
- backend/agents/critic.py +212 -0
- backend/agents/document_gap_analyzer.py +69 -0
- backend/agents/evaluator.py +128 -0
- backend/agents/finance_agent.py +122 -0
- backend/agents/gap_analyzer.py +73 -0
- backend/agents/generator_agent.py +0 -0
- backend/agents/grant_research_agent.py +165 -0
- backend/agents/helpers.py +871 -0
- backend/agents/holistic_critic.py +151 -0
- backend/agents/matcher.py +246 -0
- backend/agents/panel_nodes.py +562 -0
- backend/agents/panel_state.py +44 -0
- backend/agents/planner.py +40 -0
- backend/agents/profiler.py +128 -0
- backend/agents/red_team_auditor.py +80 -0
- backend/agents/research_agent.py +99 -0
- backend/agents/researcher.py +201 -0
- backend/agents/risk_scoring.py +104 -0
- backend/agents/scraper_agent.py +115 -0
- backend/agents/supervisor.py +139 -0
- backend/agents/timeline.py +64 -0
- backend/agents/tools/budget_rules_tool.py +28 -0
- backend/agents/tools/krs_graph_tool.py +64 -0
- backend/agents/tools/legal_retriever_tool.py +94 -0
- backend/agents/tools/neo4j_cypher_tool.py +46 -0
- backend/agents/tools/technology_retriever_tool.py +31 -0
- backend/agents/verifier.py +44 -0
- backend/agents/wizard.py +191 -0
- backend/agents/world_class_advisor.py +628 -0
- backend/alembic.ini +149 -0
- backend/alembic/README +1 -0
- backend/alembic/env.py +93 -0
- backend/alembic/script.py.mako +28 -0
- backend/alembic/versions/0848fd2356d9_sprint2.py +43 -0
- backend/alembic/versions/0e48eb7134d7_add_final_document_columns.py +40 -0
- backend/alembic/versions/0f91b1724111_add_external_context_to_projects.py +37 -0
- backend/alembic/versions/20260527_add_regulation_snapshots.py +43 -0
.dockerignore
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.github
|
| 3 |
+
.ruff_cache
|
| 4 |
+
**/__pycache__
|
| 5 |
+
**/*.pyc
|
| 6 |
+
**/.pytest_cache
|
| 7 |
+
**/.mypy_cache
|
| 8 |
+
**/node_modules
|
| 9 |
+
frontend-react/node_modules
|
| 10 |
+
frontend-react/dist
|
| 11 |
+
backend/.env
|
| 12 |
+
backend/test.db
|
| 13 |
+
backend/cache
|
| 14 |
+
**/*.md
|
| 15 |
+
!README.md
|
| 16 |
+
poprawka*.md
|
| 17 |
+
antigravity_grantforge_swarm
|
| 18 |
+
DEPLOYMENT.md
|
| 19 |
+
ROADMAP.md
|
| 20 |
+
KRSAPI.md
|
| 21 |
+
OProgramie.md
|
| 22 |
+
DEBUG_*.txt
|
| 23 |
+
*.log
|
Dockerfile
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# GrantForge AI — Multi-stage Docker build
|
| 2 |
+
# Backend: FastAPI + LangGraph | Frontend: Vite React
|
| 3 |
+
|
| 4 |
+
# ──────────────────────────────────────────────────────────────────
|
| 5 |
+
# STAGE 1: Frontend build
|
| 6 |
+
# ──────────────────────────────────────────────────────────────────
|
| 7 |
+
FROM node:20-slim AS frontend-builder
|
| 8 |
+
|
| 9 |
+
WORKDIR /app/frontend
|
| 10 |
+
|
| 11 |
+
COPY frontend-react/package*.json ./
|
| 12 |
+
RUN rm -f package-lock.json && npm install
|
| 13 |
+
|
| 14 |
+
COPY frontend-react/ ./
|
| 15 |
+
RUN npm run build
|
| 16 |
+
# Artefakt: /app/frontend/dist/
|
| 17 |
+
|
| 18 |
+
# ──────────────────────────────────────────────────────────────────
|
| 19 |
+
# STAGE 2: Python dependencies
|
| 20 |
+
# ──────────────────────────────────────────────────────────────────
|
| 21 |
+
FROM python:3.11.9-slim AS python-deps
|
| 22 |
+
|
| 23 |
+
WORKDIR /install
|
| 24 |
+
|
| 25 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 26 |
+
libpq-dev gcc g++ libffi-dev libglib2.0-0 libpango-1.0-0 \
|
| 27 |
+
libpangocairo-1.0-0 libcairo2 libcairo2-dev pkg-config python3-dev \
|
| 28 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 29 |
+
|
| 30 |
+
COPY backend/requirements.txt .
|
| 31 |
+
RUN pip install --no-cache-dir --prefix=/install/pkg -r requirements.txt && \
|
| 32 |
+
rm -rf /install/pkg/lib/python3.11/site-packages/pinecone_plugin_inference*
|
| 33 |
+
|
| 34 |
+
# ──────────────────────────────────────────────────────────────────
|
| 35 |
+
# STAGE 3: Runtime image
|
| 36 |
+
# ──────────────────────────────────────────────────────────────────
|
| 37 |
+
FROM python:3.11.9-slim AS runtime
|
| 38 |
+
|
| 39 |
+
WORKDIR /app
|
| 40 |
+
|
| 41 |
+
COPY --from=python-deps /install/pkg /usr/local
|
| 42 |
+
|
| 43 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 44 |
+
libpq5 libglib2.0-0 libpango-1.0-0 libpangocairo-1.0-0 libcairo2 wget \
|
| 45 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 46 |
+
|
| 47 |
+
COPY backend/ ./backend/
|
| 48 |
+
COPY --from=frontend-builder /app/frontend/dist ./static/
|
| 49 |
+
|
| 50 |
+
RUN mkdir -p /app/backend/assets && \
|
| 51 |
+
wget -qO /app/backend/assets/Roboto-Regular.ttf "https://github.com/googlefonts/roboto/raw/main/src/hinted/Roboto-Regular.ttf" && \
|
| 52 |
+
wget -qO /app/backend/assets/Roboto-Bold.ttf "https://github.com/googlefonts/roboto/raw/main/src/hinted/Roboto-Bold.ttf"
|
| 53 |
+
|
| 54 |
+
RUN mkdir -p /app/backend/cache
|
| 55 |
+
|
| 56 |
+
RUN useradd -m -u 1001 appuser && chown -R appuser:appuser /app
|
| 57 |
+
USER appuser
|
| 58 |
+
|
| 59 |
+
WORKDIR /app/backend
|
| 60 |
+
|
| 61 |
+
EXPOSE 7860
|
| 62 |
+
|
| 63 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
| 64 |
+
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:7860/api/health')"
|
| 65 |
+
|
| 66 |
+
CMD ["gunicorn", "server:app", \
|
| 67 |
+
"--worker-class", "uvicorn.workers.UvicornWorker", \
|
| 68 |
+
"--workers", "2", \
|
| 69 |
+
"--bind", "0.0.0.0:7860", \
|
| 70 |
+
"--timeout", "120", \
|
| 71 |
+
"--access-logfile", "-"]
|
README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: GrantForge API
|
| 3 |
+
emoji: 🏢
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: red
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# GrantForge AI (DotacjeAI)
|
| 12 |
+
|
| 13 |
+
> **Hugging Face Space** — `sdk: docker`, port **7860** (Gunicorn → FastAPI `backend/server:app`).
|
| 14 |
+
> Nie usuwaj bloku YAML powyżej — bez niego Space zgłasza *config error*.
|
| 15 |
+
|
| 16 |
+
Platforma B2B: **katalog naborów** (aktualne, z kontrolą linków i regulaminów) → **dopasowanie do firmy** → **generacja wniosku** → **audyt / quality loop** → **eksport PDF/DOCX**.
|
| 17 |
+
|
| 18 |
+
## Szybki start
|
| 19 |
+
|
| 20 |
+
| Warstwa | Stack |
|
| 21 |
+
|---------|--------|
|
| 22 |
+
| Frontend | React + TypeScript (Vite), Clerk, Vercel |
|
| 23 |
+
| Backend | FastAPI, PostgreSQL, Neo4j, Pinecone/RAG, HF Space |
|
| 24 |
+
| AI | Router Gemini / xAI, LangGraph autopilot |
|
| 25 |
+
|
| 26 |
+
```bash
|
| 27 |
+
# Backend
|
| 28 |
+
cd backend && pip install -r requirements.txt
|
| 29 |
+
# ustaw .env (DB, Clerk, LLM keys) — patrz DEPLOYMENT.md
|
| 30 |
+
uvicorn server:app --reload --port 8000
|
| 31 |
+
|
| 32 |
+
# Frontend
|
| 33 |
+
cd frontend-react && npm install && npm run dev
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
## Dokumentacja użytkownika i testów
|
| 37 |
+
|
| 38 |
+
| Plik | Opis |
|
| 39 |
+
|------|------|
|
| 40 |
+
| [docs/PLAN_ECOSYSTEM_2026_IMPLEMENTATION.md](docs/PLAN_ECOSYSTEM_2026_IMPLEMENTATION.md) | **Roadmap ekosystemu 2026** (eligibility → strategy → tax → instruments) |
|
| 41 |
+
| [docs/PLAN_INSTRUMENT_FIRST_PIPELINE.md](docs/PLAN_INSTRUMENT_FIRST_PIPELINE.md) | Warstwa instrument-first (schema, readiness) |
|
| 42 |
+
| [docs/USER_GUIDE.md](docs/USER_GUIDE.md) | Instrukcja obsługi |
|
| 43 |
+
| [docs/TEST_PATH_NEW_ENTITY.md](docs/TEST_PATH_NEW_ENTITY.md) | **Pełna ścieżka testowa** nowego podmiotu |
|
| 44 |
+
| [docs/agents/ROLE_MAP.md](docs/agents/ROLE_MAP.md) | Role GSD → `backend/agents` |
|
| 45 |
+
| [backend/gsd/README.md](backend/gsd/README.md) | Orkiestracja GSD (jednolity runtime) |
|
| 46 |
+
| [docs/CREDIBILITY_PIPELINE.md](docs/CREDIBILITY_PIPELINE.md) | Wiarygodność firm i naborów |
|
| 47 |
+
| [docs/workflow-reports/GRANT_SYSTEM_RESCUE.md](docs/workflow-reports/GRANT_SYSTEM_RESCUE.md) | Raport naprawy katalogu/jakości |
|
| 48 |
+
| [DEPLOYMENT.md](DEPLOYMENT.md) | Deploy |
|
| 49 |
+
|
| 50 |
+
**GSD / swarm:** cała logika w `backend/gsd/` + `backend/agents/`. Folder `antigravity_grantforge_swarm/` to tylko re-export (deprecated).
|
| 51 |
+
|
| 52 |
+
W aplikacji: **O programie** (`/about`), **Pomoc** (`/help`), **Beta** (`/beta`).
|
| 53 |
+
|
| 54 |
+
## Jakość katalogu naborów (skrót)
|
| 55 |
+
|
| 56 |
+
- Domyślnie `require_certainty=true` — bez starych/martwych programów i bez śmieciowych linków.
|
| 57 |
+
- Regulamin ≠ RODO/privacy/listing; RAG nie wektoryzuje strony naboru zamiast regulaminu.
|
| 58 |
+
- Import WYSZUKIWARKA + verify URL + close grantów poza snapshotem.
|
| 59 |
+
- Quality loop: celowana korekta sekcji (checklist, cytowania), soft-export z grounding.
|
| 60 |
+
|
| 61 |
+
## Testy
|
| 62 |
+
|
| 63 |
+
```bash
|
| 64 |
+
cd backend && python3 -m pytest tests/ -q --ignore=tests/test_deepeval_rag.py
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
## Licencja / kontakt
|
| 68 |
+
|
| 69 |
+
Użycie produkcyjne zgodnie z regulaminem aplikacji i polityką prywatności w UI.
|
backend/.deepeval/.deepeval-cache.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{"test_cases_lookup_map": {"{\"actual_output\": \"{}\", \"context\": null, \"expected_output\": null, \"hyperparameters\": null, \"input\": \"Czy moja firma jako du\\u017ce przedsi\\u0119biorstwo mo\\u017ce ubiega\\u0107 si\\u0119 o FENG Szybka \\u015acie\\u017cka?\", \"retrieval_context\": [\"{'rok_perspektywy': '2021-2027', 'query': 'FENG \\u015acie\\u017cka SMART du\\u017ce przedsi\\u0119biorstwa'}\", \"{'rok_perspektywy': '2021-2027', 'query': 'kto mo\\u017ce ubiega\\u0107 si\\u0119 o dofinansowanie FENG \\u015acie\\u017cka SMART du\\u017ce przedsi\\u0119biorstwa kwalifikowalno\\u015b\\u0107'}\"]}": {"cached_metrics_data": [{"metric_data": {"name": "Faithfulness", "threshold": 0.7, "success": false, "strictMode": false, "evaluationModel": "gemini-1.5-pro", "evaluationCost": 0.0}, "metric_configuration": {"threshold": 0.7, "evaluation_model": "gemini-1.5-pro", "strict_mode": false, "include_reason": true}}]}, "{\"actual_output\": \"{}\", \"context\": null, \"expected_output\": null, \"hyperparameters\": null, \"input\": \"Czy koszty ubezpieczenia samochod\\u00f3w s\\u0142u\\u017cbowych s\\u0105 kwalifikowalne w KPO?\", \"retrieval_context\": [\"{'query': 'KPO wytyczne kwalifikowalno\\u015bci', 'rok_perspektywy': '2021-2027'}\", \"{'query': 'koszty ubezpieczenia samochod\\u00f3w KPO kwalifikowalno\\u015b\\u0107'}\", \"{'query': 'kwalifikowalno\\u015b\\u0107 koszt\\u00f3w ubezpieczenia samochod\\u00f3w s\\u0142u\\u017cbowych KPO', 'rok_perspektywy': '2021-2027'}\"]}": {"cached_metrics_data": [{"metric_data": {"name": "Faithfulness", "threshold": 0.7, "success": false, "strictMode": false, "evaluationModel": "gemini-1.5-pro", "evaluationCost": 0.0}, "metric_configuration": {"threshold": 0.7, "evaluation_model": "gemini-1.5-pro", "strict_mode": false, "include_reason": true}}]}, "{\"actual_output\": \"{}\", \"context\": null, \"expected_output\": null, \"hyperparameters\": null, \"input\": \"Jak wykaza\\u0107 zasad\\u0119 DNSH w projekcie polegaj\\u0105cym na zakupie maszyn CNC?\", \"retrieval_context\": [\"{'defects': [{'affected_section': 'Opis projektu / Zakup maszyn CNC', 'recommendation': 'Nale\\u017cy przeprowadzi\\u0107 i do\\u0142\\u0105czy\\u0107 analiz\\u0119 DNSH dla zakupu maszyn CNC. Wymagane jest wykazanie m.in.: 1) \\u0141agodzenia zmian klimatu (np. wysoka klasa efektywno\\u015bci energetycznej maszyn, zgodno\\u015b\\u0107 z dyrektyw\\u0105 o ekoprojekcie); 2) Gospodarki o obiegu zamkni\\u0119tym (spos\\u00f3b utylizacji i recyklingu odpad\\u00f3w poprodukcyjnych, np. wi\\u00f3r\\u00f3w, ch\\u0142odziw); 3) Zapobiegania zanieczyszczeniom (brak wykorzystania substancji zakazanych, zgodno\\u015b\\u0107 z REACH/RoHS).', 'problem_quote': 'Jak wykaza\\u0107 zasad\\u0119 DNSH w projekcie polegaj\\u0105cym na zakupie maszyn CNC?', 'description': 'Brak wykazania zgodno\\u015bci z zasad\\u0105 DNSH (Do No Significant Harm). Zgodnie z art. 9 ust. 4 Rozporz\\u0105dzenia UE 2021/1060 oraz wytycznymi MFiPR dla perspektywy 2021-2027, ka\\u017cdy projekt musi by\\u0107 zgodny z sze\\u015bcioma celami \\u015brodowiskowymi Taksonomii UE. Sam zakup maszyn CNC bez odpowiedniej analizy \\u015brodowiskowej stanowi brak formalny i merytoryczny.'}]}\", \"{'query': 'DNSH wytyczne kwalifikowalno\\u015bci 2021-2027', 'rok_perspektywy': '2021-2027'}\", \"{'rok_perspektywy': '2021-2027', 'query': 'zasada DNSH zakup maszyn urz\\u0105dze\\u0144 \\u015acie\\u017cka SMART'}\"]}": {"cached_metrics_data": [{"metric_data": {"name": "Faithfulness", "threshold": 0.7, "success": false, "strictMode": false, "evaluationModel": "gemini-1.5-pro", "evaluationCost": 0.0}, "metric_configuration": {"threshold": 0.7, "evaluation_model": "gemini-1.5-pro", "strict_mode": false, "include_reason": true}}]}, "{\"actual_output\": \"{}\", \"context\": null, \"expected_output\": null, \"hyperparameters\": null, \"input\": \"Czy moja firma jako du\\u017ce przedsi\\u0119biorstwo mo\\u017ce ubiega\\u0107 si\\u0119 o FENG Szybka \\u015acie\\u017cka?\", \"retrieval_context\": [\"{'query': 'kto mo\\u017ce ubiega\\u0107 si\\u0119 o dofinansowanie FENG \\u015acie\\u017cka SMART du\\u017ce przedsi\\u0119biorstwa kwalifikowalno\\u015b\\u0107', 'rok_perspektywy': '2021-2027'}\", \"{'rok_perspektywy': '2021-2027', 'query': 'FENG \\u015acie\\u017cka SMART du\\u017ce przedsi\\u0119biorstwa'}\"]}": {"cached_metrics_data": [{"metric_data": {"name": "Faithfulness", "threshold": 0.7, "success": false, "strictMode": false, "evaluationModel": "gemini-1.5-pro", "evaluationCost": 0}, "metric_configuration": {"threshold": 0.7, "evaluation_model": "gemini-1.5-pro", "strict_mode": false, "include_reason": true}}]}, "{\"actual_output\": \"{}\", \"context\": null, \"expected_output\": null, \"hyperparameters\": null, \"input\": \"Czy koszty ubezpieczenia samochod\\u00f3w s\\u0142u\\u017cbowych s\\u0105 kwalifikowalne w KPO?\", \"retrieval_context\": [\"{'defects': [{'description': 'Brak wystarczaj\\u0105cych informacji. Z powodu b\\u0142\\u0119du technicznego bazy wiedzy nie mo\\u017cna jednoznacznie zweryfikowa\\u0107 wytycznych KPO. Zgodnie z og\\u00f3lnymi zasadami funduszy UE, koszty ubezpieczenia samochod\\u00f3w s\\u0142u\\u017cbowych s\\u0105 zazwyczaj niekwalifikowalne jako koszty bezpo\\u015brednie (mog\\u0105 stanowi\\u0107 element koszt\\u00f3w po\\u015brednich).', 'problem_quote': 'Czy koszty ubezpieczenia samochod\\u00f3w s\\u0142u\\u017cbowych s\\u0105 kwalifikowalne w KPO?', 'affected_section': 'Tre\\u015b\\u0107 wniosku', 'recommendation': 'Nale\\u017cy zweryfikowa\\u0107 regulamin konkretnego naboru w ramach KPO oraz Wytyczne w zakresie kwalifikowalno\\u015bci wydatk\\u00f3w.'}]}\", \"{'query': 'koszty ubezpieczenia pojazd\\u00f3w KPO kwalifikowalno\\u015b\\u0107'}\", \"{'rok_perspektywy': '2021-2027', 'query': 'kwalifikowalno\\u015b\\u0107 koszt\\u00f3w ubezpieczenia samochod\\u00f3w s\\u0142u\\u017cbowych KPO'}\"]}": {"cached_metrics_data": [{"metric_data": {"name": "Faithfulness", "threshold": 0.7, "success": false, "strictMode": false, "evaluationModel": "gemini-1.5-pro", "evaluationCost": 0}, "metric_configuration": {"threshold": 0.7, "evaluation_model": "gemini-1.5-pro", "strict_mode": false, "include_reason": true}}]}, "{\"actual_output\": \"{}\", \"context\": null, \"expected_output\": null, \"hyperparameters\": null, \"input\": \"Jak wykaza\\u0107 zasad\\u0119 DNSH w projekcie polegaj\\u0105cym na zakupie maszyn CNC?\", \"retrieval_context\": [\"{'defects': [{'affected_section': 'Opis projektu / Zakup maszyn CNC', 'problem_quote': 'Jak wykaza\\u0107 zasad\\u0119 DNSH w projekcie polegaj\\u0105cym na zakupie maszyn CNC?', 'recommendation': 'Nale\\u017cy przeprowadzi\\u0107 i do\\u0142\\u0105czy\\u0107 analiz\\u0119 DNSH dla zakupu maszyn CNC. Wymagane jest wykazanie m.in.: 1) \\u0141agodzenia zmian klimatu (np. wysoka klasa efektywno\\u015bci energetycznej maszyn, zgodno\\u015b\\u0107 z dyrektyw\\u0105 o ekoprojekcie); 2) Gospodarki o obiegu zamkni\\u0119tym (spos\\u00f3b utylizacji i recyklingu odpad\\u00f3w poprodukcyjnych, np. wi\\u00f3r\\u00f3w, ch\\u0142odziw); 3) Zapobiegania zanieczyszczeniom (brak wykorzystania substancji zakazanych, zgodno\\u015b\\u0107 z REACH/RoHS).', 'description': 'Brak wykazania zgodno\\u015bci z zasad\\u0105 DNSH (Do No Significant Harm). Zgodnie z art. 9 ust. 4 Rozporz\\u0105dzenia UE 2021/1060 oraz wytycznymi MFiPR dla perspektywy 2021-2027, ka\\u017cdy projekt musi by\\u0107 zgodny z sze\\u015bcioma celami \\u015brodowiskowymi Taksonomii UE. Sam zakup maszyn CNC bez odpowiedniej analizy \\u015brodowiskowej stanowi brak formalny i merytoryczny.'}]}\", \"{'query': 'DNSH wytyczne kwalifikowalno\\u015bci 2021-2027', 'rok_perspektywy': '2021-2027'}\", \"{'query': 'zasada DNSH zakup maszyn urz\\u0105dze\\u0144 \\u015acie\\u017cka SMART', 'rok_perspektywy': '2021-2027'}\"]}": {"cached_metrics_data": [{"metric_data": {"name": "Faithfulness", "threshold": 0.7, "success": false, "strictMode": false, "evaluationModel": "gemini-1.5-pro", "evaluationCost": 0}, "metric_configuration": {"threshold": 0.7, "evaluation_model": "gemini-1.5-pro", "strict_mode": false, "include_reason": true}}]}}}
|
backend/.deepeval/.deepeval_telemetry.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
DEEPEVAL_ID=089404e6-6063-43e6-b7c9-9315c2f56eb6
|
| 2 |
+
DEEPEVAL_STATUS=old
|
| 3 |
+
DEEPEVAL_LAST_FEATURE=evaluation
|
| 4 |
+
DEEPEVAL_EVALUATION_STATUS=old
|
backend/.env.example
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dotacje AI - Backend Environment Variables Example
|
| 2 |
+
|
| 3 |
+
# LLM Providers
|
| 4 |
+
GOOGLE_API_KEY="YOUR_GOOGLE_API_KEY"
|
| 5 |
+
# xAI / Grok (HF Space Secret: XAI_API_KEY) — fallback gdy Gemini 403/billing/dunning
|
| 6 |
+
XAI_API_KEY="YOUR_XAI_API_KEY"
|
| 7 |
+
GROK_API_KEY="YOUR_GROK_API_KEY"
|
| 8 |
+
# XAI_MODEL="grok-4.3"
|
| 9 |
+
# XAI_BASE_URL="https://api.x.ai/v1"
|
| 10 |
+
# Kolejność providerów (primary,fallback).
|
| 11 |
+
# Na Hugging Face Spaces: ustaw PREFER_XAI=true LUB LLM_PRIMARY=xai żeby od razu
|
| 12 |
+
# iść na Grok i pominąć nieudane próby Gemini (szybszy generate-full przy 403/billing).
|
| 13 |
+
# LLM_FALLBACK_ORDER=gemini,xai
|
| 14 |
+
# LLM_PRIMARY=xai
|
| 15 |
+
# PREFER_XAI=false
|
| 16 |
+
# GEMINI_HEALTH_PROBE=true
|
| 17 |
+
GEMINI_EMBEDDING_MODEL="gemini-embedding-001"
|
| 18 |
+
GEMINI_EMBEDDING_DIMENSIONS="768"
|
| 19 |
+
# EMBEDDINGS_PROVIDER=auto # auto | hash (hash gdy Gemini embeddings 403)
|
| 20 |
+
|
| 21 |
+
# Vector Store (Pinecone) — wymagane do ingestu i semantycznego wyszukiwania
|
| 22 |
+
PINECONE_API_KEY="YOUR_PINECONE_API_KEY"
|
| 23 |
+
PINECONE_INDEX_NAME="dotacje"
|
| 24 |
+
# PINECONE_ENVIRONMENT="us-east-1" # opcjonalnie, zależnie od regionu indeksu
|
| 25 |
+
# ENABLE_PINECONE_SEMANTIC="true"
|
| 26 |
+
|
| 27 |
+
# Database (PostgreSQL production / Hugging Face Spaces)
|
| 28 |
+
# Local docker-compose: postgresql://user:password@localhost:5432/dotacje
|
| 29 |
+
# Managed / HF Spaces (Aiven, Neon, Supabase, Render): ALWAYS use SSL —
|
| 30 |
+
# DATABASE_URL="postgresql://user:pass@host:5432/dbname?sslmode=require"
|
| 31 |
+
# Or omit ?sslmode= and set:
|
| 32 |
+
# DB_SSLMODE=require
|
| 33 |
+
# Remote hosts default to sslmode=require; localhost defaults to prefer.
|
| 34 |
+
# HF: set secrets in Space Settings → Variables (repo .env is NOT deployed).
|
| 35 |
+
# Neon → Aiven: docs/MIGRATION_NEON_TO_AIVEN.md
|
| 36 |
+
# DB_POOL_RECYCLE=1800
|
| 37 |
+
|
| 38 |
+
# Tracing and Observability
|
| 39 |
+
LANGCHAIN_API_KEY="YOUR_LANGSMITH_API_KEY"
|
| 40 |
+
LANGCHAIN_TRACING_V2="true"
|
| 41 |
+
LANGCHAIN_PROJECT="GrantForgeAI"
|
| 42 |
+
# Frontend (Vercel): optional Sentry browser DSN — omit to skip init (ErrorBoundary still works)
|
| 43 |
+
# VITE_SENTRY_DSN="https://...@o....ingest.sentry.io/..."
|
| 44 |
+
# VITE_POSTHOG_KEY="phc_..."
|
| 45 |
+
# VITE_CLERK_PUBLISHABLE_KEY="pk_..."
|
| 46 |
+
# VITE_API_URL="https://your-api.example.com/api"
|
| 47 |
+
|
| 48 |
+
# Graph Database (GraphRAG)
|
| 49 |
+
NEO4J_URI="neo4j+s://your-neo4j-instance.databases.neo4j.io"
|
| 50 |
+
NEO4J_USERNAME="neo4j"
|
| 51 |
+
NEO4J_PASSWORD="your-secure-password"
|
| 52 |
+
|
| 53 |
+
# Web Scraping (Required for Grant extraction)
|
| 54 |
+
# Crawl4AI — darmowy scraper (bez klucza API). Ustaw CRAWL4AI_USE_BROWSER=1 dla stron wymagających JS.
|
| 55 |
+
# CRAWL4AI_USE_BROWSER=0
|
| 56 |
+
|
| 57 |
+
# Regional RPO BIP parsers (F4.4)
|
| 58 |
+
# ENABLE_RPO_BIP_FETCH=true # 6 woj.: lubelskie, podkarpackie, łódzkie, mazowieckie, śląskie, wielkopolskie — cache 6h
|
| 59 |
+
|
| 60 |
+
# Development Flags
|
| 61 |
+
# Set to 'true' to serve fallback mock data if scraping yields < 3 results
|
| 62 |
+
MOCK_GRANTS="false"
|
| 63 |
+
|
| 64 |
+
# ── Auth / Clerk (P0 security) ──────────────────────────────────────────────
|
| 65 |
+
# Required in production (HF Spaces Secrets) — fail-closed without these:
|
| 66 |
+
# CLERK_ISSUER="https://YOUR_INSTANCE.clerk.accounts.dev"
|
| 67 |
+
# Or pin JWKS directly (preferred if custom domain):
|
| 68 |
+
# CLERK_JWKS_URL="https://YOUR_INSTANCE.clerk.accounts.dev/.well-known/jwks.json"
|
| 69 |
+
# Optional JWT audience check (enable when Clerk JWT template sets aud):
|
| 70 |
+
# CLERK_AUDIENCE="grantforge-api"
|
| 71 |
+
# Clerk webhook signing secret (Dashboard → Webhooks → Signing Secret):
|
| 72 |
+
# CLERK_WEBHOOK_SECRET="whsec_..."
|
| 73 |
+
# Without it, POST /api/webhooks/clerk returns 500 (fail-closed). Startup logs a warning.
|
| 74 |
+
# Dev-only backdoor (NEVER set on HF production):
|
| 75 |
+
# ENV=dev
|
| 76 |
+
# ALLOW_DEV_TOKEN=true
|
| 77 |
+
# Internal seed endpoint (admin JWT required; also blocked in prod unless):
|
| 78 |
+
# ALLOW_INTERNAL_SEED=true
|
| 79 |
+
# Explicit frontend origins for CORS (comma-separated); FRONTEND_URL also added:
|
| 80 |
+
# CORS_ORIGINS="https://grantforge-ai.vercel.app,https://grantforge.pl"
|
| 81 |
+
# FRONTEND_URL="https://grantforge-ai.vercel.app"
|
| 82 |
+
|
| 83 |
+
# ── Multi-worker SSE tickets (generator stream) ──────────────────────────────
|
| 84 |
+
# Optional Redis; otherwise SQLite file under TMPDIR is used (multi-worker safe).
|
| 85 |
+
# REDIS_URL=redis://localhost:6379/0
|
| 86 |
+
# TICKET_REDIS_URL=redis://localhost:6379/1
|
| 87 |
+
# STREAM_TICKET_TTL_SECONDS=90
|
| 88 |
+
# TICKET_STORE_PATH=/tmp
|
| 89 |
+
|
| 90 |
+
# ── Company registries (enrichment) ──────────────────────────────────────────
|
| 91 |
+
# GUS_API_KEY= # BIR1.1 production key (20 chars)
|
| 92 |
+
# CEIDG_API_KEY= # dane.biznes.gov.pl CEIDG v2
|
| 93 |
+
# CEIDG_API_BASE=https://dane.biznes.gov.pl/api/ceidg/v2
|
| 94 |
+
# CEIDG_DISABLED=false
|
| 95 |
+
# SUDOP_API_BASE=https://api-sudop.uokik.gov.pl/sudop-api/1.0.0
|
| 96 |
+
# SUDOP_DISABLED=false
|
| 97 |
+
# BizRaport financial control — official API /api/dane (never commit real credentials)
|
| 98 |
+
# BIZRAPORT_EMAIL=your@email.pl
|
| 99 |
+
# BIZRAPORT_PASSWORD=
|
| 100 |
+
# BIZRAPORT_BASE_URL=https://api.bizraport.pl
|
| 101 |
+
# BIZRAPORT_ROZSZERZ_POLACZENIA=false
|
| 102 |
+
# BIZRAPORT_API_URL=https://your-proxy.example/bizraport/{nip}
|
| 103 |
+
# BIZRAPORT_API_KEY=
|
| 104 |
+
# BIZRAPORT_DISABLED=false
|
| 105 |
+
# BIZRAPORT_TIMEOUT=12
|
| 106 |
+
|
| 107 |
+
# ── Grant Pulse (phases A–D) ─────────────────────────────────────────────────
|
| 108 |
+
# ENABLE_PULSE_WORKER=true # process ResearchJob + eurlex batch in scheduler
|
| 109 |
+
# DISABLE_PULSE_WORKER=false
|
| 110 |
+
# PULSE_WORKER_MAX_JOBS=25
|
| 111 |
+
# PULSE_NEW_DAYS=14 # "new" window
|
| 112 |
+
# PULSE_CLOSING_DAYS=30 # "closing" window
|
| 113 |
+
# ENABLE_DEADLINE_BACKFILL=true # enqueue/persist missing deadlines from text
|
| 114 |
+
# DOC_INTEL_MIN_CHARS=120 # HTML thinner than this → Crawl4AI fallback
|
| 115 |
+
# ENABLE_STEALTH_FETCH=true # curl_cffi Chrome fingerprint (PARP/BGK/WAF)
|
| 116 |
+
# STEALTH_IMPERSONATE=chrome # chrome|chrome120|chrome124|chrome131
|
| 117 |
+
# STEALTH_TIMEOUT=30
|
| 118 |
+
# STEALTH_DOMAINS=parp.gov.pl,bgk.pl,ncbr.gov.pl
|
| 119 |
+
# SSL_RELAXED_DOMAINS=wfosigw.pl,zus.pl # PDF/HTML TLS verify=False after primary fail
|
| 120 |
+
# FLAKY_FETCH_DOMAINS=funduszeeuropejskie.gov.pl
|
| 121 |
+
# FLAKY_FETCH_TIMEOUT=45
|
| 122 |
+
# FLAKY_PREFER_CACHE=true
|
| 123 |
+
# FLAKY_CACHE_ONLY=false # true = skip live scrape for flaky domains
|
| 124 |
+
# ENABLE_LIVE_RESEARCH=true # detect_grant_changes (false on HF free by default)
|
| 125 |
+
# ENABLE_EURLEX_BATCH=true # batch CELEX/DU grounding (legal-ID only, never free-text)
|
| 126 |
+
# ENABLE_EURLEX_NETWORK=false # true = live EUR-Lex SPARQL (off for CI/HF)
|
| 127 |
+
# ENABLE_LAW_WATCH=true
|
| 128 |
+
# ENABLE_STRATEGY_CASCADE=true # strategy recommend cascade 2026 (default on); false → paths still returned, cascade_enforced=false
|
| 129 |
+
# ENABLE_ELIGIBILITY_SPINE=true # eligibility spine gates for readiness (default on)
|
| 130 |
+
# ENABLE_TAX_PATHS=true # tax checklist attach on tax_psi_ulgi (default on)
|
| 131 |
+
# ENABLE_BK2021=false # BK2021 B2B stub (default OFF); ON = draft alert only, never live scrape
|
| 132 |
+
|
| 133 |
+
# LAW_WATCHLIST_JSON=[{"program":"FENG","url":"https://..."}]
|
| 134 |
+
# LAW_WATCHLIST_PATH=/path/to/watchlist.json
|
| 135 |
+
# CLERK_WEBHOOK_SECRET=whsec_... # required for Clerk webhooks (fail-closed without)
|
| 136 |
+
# GUS_API_KEY= # BIR 1.1 company lookup (prod)
|
| 137 |
+
# REDIS_URL= # multi-worker SSE tickets (falls back to SQLite)
|
| 138 |
+
# ADMIN_EMAIL=bogmaz1@gmail.com # Nexus Control allowlist (or ADMIN_EMAILS=a@x,b@y)
|
| 139 |
+
# ADMIN_USER_IDS=user_xxx # Clerk user ids (comma-separated)
|
| 140 |
+
# ADMIN_CLERK_IDS= # alias of ADMIN_USER_IDS
|
| 141 |
+
# CLERK_SECRET_KEY= # optional: enrich admin check from Clerk user API
|
| 142 |
+
# GENERATOR_STREAM_TICKET_TTL=1800 # SSE ticket lifetime (seconds) for autopilot stream
|
| 143 |
+
# STREAM_TICKET_TTL_SECONDS=90 # default for other short-lived tickets
|
backend/Dockerfile
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11.9-slim
|
| 2 |
+
|
| 3 |
+
# Install necessary system packages
|
| 4 |
+
RUN apt-get update && apt-get install -y \
|
| 5 |
+
build-essential \
|
| 6 |
+
libpq-dev \
|
| 7 |
+
gcc \
|
| 8 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
+
|
| 10 |
+
WORKDIR /app
|
| 11 |
+
|
| 12 |
+
# Upgrade pip
|
| 13 |
+
RUN pip install --upgrade pip
|
| 14 |
+
|
| 15 |
+
# Copy requirements and install dependencies
|
| 16 |
+
COPY requirements.txt .
|
| 17 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 18 |
+
|
| 19 |
+
# Create cache directory for Hugging Face Transformers
|
| 20 |
+
ENV TRANSFORMERS_CACHE=/tmp/huggingface_cache
|
| 21 |
+
RUN mkdir -p /tmp/huggingface_cache && chmod 777 /tmp/huggingface_cache
|
| 22 |
+
|
| 23 |
+
# Copy application code
|
| 24 |
+
COPY . .
|
| 25 |
+
|
| 26 |
+
# Set permissions for Hugging Face Space (requires non-root user or open permissions for /app/data if any)
|
| 27 |
+
RUN chmod -R 777 /app
|
| 28 |
+
|
| 29 |
+
# Run migrations and start FastAPI server
|
| 30 |
+
CMD uvicorn server:app --host 0.0.0.0 --port 7860
|
backend/add_keys.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import glob
|
| 3 |
+
import re
|
| 4 |
+
|
| 5 |
+
directory = "/home/user/PROGRAMY/DOTACJE/backend/core/search/sources"
|
| 6 |
+
files = glob.glob(os.path.join(directory, "*_source.py"))
|
| 7 |
+
|
| 8 |
+
for file_path in files:
|
| 9 |
+
with open(file_path, "r", encoding="utf-8") as f:
|
| 10 |
+
content = f.read()
|
| 11 |
+
|
| 12 |
+
# We want to insert 'last_verified': '2026-05-23' and 'verified_by': 'manual'
|
| 13 |
+
# before the "source": line in the dictionaries.
|
| 14 |
+
# Pattern to find: "source": "something",
|
| 15 |
+
pattern = r'("source":\s*"[^"]+",)'
|
| 16 |
+
replacement = r'"last_verified": "2026-05-23",\n "verified_by": "manual",\n \1'
|
| 17 |
+
|
| 18 |
+
new_content = re.sub(pattern, replacement, content)
|
| 19 |
+
|
| 20 |
+
if new_content != content:
|
| 21 |
+
with open(file_path, "w", encoding="utf-8") as f:
|
| 22 |
+
f.write(new_content)
|
| 23 |
+
print(f"Zaktualizowano: {file_path}")
|
backend/agents/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Moduł Agentów dla DotacjeAI
|
backend/agents/auditor.py
ADDED
|
@@ -0,0 +1,581 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Agencja Krytyka — Multi-Perspektywowy Audytor Wniosków.
|
| 3 |
+
|
| 4 |
+
FAZA 4: Pydantic structured output z confidence_score + human_review_required.
|
| 5 |
+
FAZA 5: Trzy role audytorów (Prawnik, Finansista, Innowator) → scalony wynik.
|
| 6 |
+
|
| 7 |
+
Zgodność: AI Act Art. 13 (transparency), Art. 14 (human oversight).
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
import json
|
| 12 |
+
from typing import List, Dict, Literal, Any
|
| 13 |
+
from pydantic import BaseModel, Field
|
| 14 |
+
from core.llm_router import get_llm
|
| 15 |
+
from core.search.regulation_engine import regulation_engine, citation_verifier, kruczkowski_trap_agent
|
| 16 |
+
from core.audit_logger import audit_log
|
| 17 |
+
from tenacity import retry, stop_after_attempt, wait_exponential
|
| 18 |
+
|
| 19 |
+
# v5.0 Orchestrator + GroundingCertificate wiring into auditor (full traceability)
|
| 20 |
+
try:
|
| 21 |
+
from backend.gsd.gsd_orchestrator import GrantforgeGSDOrchestrator
|
| 22 |
+
from backend.gsd import create_gsd_state
|
| 23 |
+
except Exception:
|
| 24 |
+
try:
|
| 25 |
+
from gsd.gsd_orchestrator import GrantforgeGSDOrchestrator
|
| 26 |
+
from gsd import create_gsd_state
|
| 27 |
+
except Exception:
|
| 28 |
+
GrantforgeGSDOrchestrator = None
|
| 29 |
+
create_gsd_state = None
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
logger = logging.getLogger(__name__)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 36 |
+
# Modele Pydantic (FAZA 4 — strukturyzowane wyjście)
|
| 37 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class AuditIssue(BaseModel):
|
| 41 |
+
category: str = Field(
|
| 42 |
+
description="Kategoria błędu, np. 'Budżet', 'Wykluczenia', 'DNSH', 'Spójność logiki'."
|
| 43 |
+
)
|
| 44 |
+
severity: Literal["critical", "high", "medium", "low"] = Field(
|
| 45 |
+
description="Powaga błędu."
|
| 46 |
+
)
|
| 47 |
+
message: str = Field(
|
| 48 |
+
description="Opis wskazanego błędu wraz ze zidentyfikowaną niespójnością."
|
| 49 |
+
)
|
| 50 |
+
rule_citation: str = Field(
|
| 51 |
+
default="",
|
| 52 |
+
description="Cytat lub nazwa przywołanej reguły / paragrafu regulaminu.",
|
| 53 |
+
)
|
| 54 |
+
recommendation: str = Field(
|
| 55 |
+
default="", description="Rekomendacja: co i jak poprawić."
|
| 56 |
+
)
|
| 57 |
+
affected_section: str = Field(
|
| 58 |
+
default="", description="Tytuł sekcji wniosku, w której znaleziono błąd."
|
| 59 |
+
)
|
| 60 |
+
problem_quote: str = Field(
|
| 61 |
+
default="", description="Krótki cytat problematycznego zdania z wniosku."
|
| 62 |
+
)
|
| 63 |
+
perspective: str = Field(
|
| 64 |
+
default="generalny",
|
| 65 |
+
description="Rola audytora, który znalazł błąd (prawnik/finansista/innowator/generalny).",
|
| 66 |
+
)
|
| 67 |
+
xai_justification: str = Field(
|
| 68 |
+
default="", description="Dogłębne wyjaśnienie AI (Explainable AI) dlaczego uznano to za błąd, w oparciu o fakty z dokumentu i logikę."
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class GlobalAuditOutput(BaseModel):
|
| 73 |
+
"""
|
| 74 |
+
Ustrukturyzowany wynik audytu całego wniosku dotacyjnego.
|
| 75 |
+
FAZA 4: confidence_score + human_review_required.
|
| 76 |
+
"""
|
| 77 |
+
|
| 78 |
+
is_approved: bool = Field(
|
| 79 |
+
description="Czy wniosek nadaje się do wysłania bez krytycznych błędów."
|
| 80 |
+
)
|
| 81 |
+
export_status: Literal["blocked", "warning", "ok"] = Field(
|
| 82 |
+
description="Stan eksportu: blocked (błąd krytyczny), warning (błędy wysokie), ok (brak poważnych)."
|
| 83 |
+
)
|
| 84 |
+
overall_score: int = Field(description="Ogólna ocena poprawności w skali 0–100.")
|
| 85 |
+
confidence_score: float = Field(
|
| 86 |
+
default=0.85,
|
| 87 |
+
description="Pewność modelu co do wyników audytu (0.0–1.0). Wartość < 0.7 → wymaga weryfikacji człowieka.",
|
| 88 |
+
)
|
| 89 |
+
human_review_required: bool = Field(
|
| 90 |
+
default=False,
|
| 91 |
+
description="True gdy score < 60 lub istnieją błędy critical → wymaga weryfikacji eksperta.",
|
| 92 |
+
)
|
| 93 |
+
issues: List[AuditIssue] = Field(
|
| 94 |
+
description="Wykryte błędy, rozbieżności i nieprawidłowości formalne."
|
| 95 |
+
)
|
| 96 |
+
perspectives_summary: Dict[str, str] = Field(
|
| 97 |
+
default_factory=dict,
|
| 98 |
+
description="Skrótowe opinie poszczególnych ról audytorów (prawnik/finansista/innowator).",
|
| 99 |
+
)
|
| 100 |
+
ai_disclaimer: str = Field(
|
| 101 |
+
default="Wynik audytu wygenerowany przez AI na podstawie regulaminów programu. "
|
| 102 |
+
"Zalecana weryfikacja przez doradcę dotacyjnego lub radcę prawnego przed złożeniem wniosku.",
|
| 103 |
+
description="Obowiązkowy disclaimer AI Act Art. 13.",
|
| 104 |
+
)
|
| 105 |
+
xai_justification: str = Field(
|
| 106 |
+
default="", description="Globalne wyjaśnienie Explainable AI dlaczego przyznano taki overall_score i confidence_score."
|
| 107 |
+
)
|
| 108 |
+
cross_check_passed: bool = Field(
|
| 109 |
+
default=True, description="Czy projekt pomyślnie przeszedł weryfikację krzyżową (budżet vs harmonogram)."
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
# ─────────────────────────────────────────────────────────────���────────────────
|
| 114 |
+
# Pomocnicze prompty per rola (FAZA 5 — Multi-Perspective Audit)
|
| 115 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 116 |
+
|
| 117 |
+
_ROLE_PROMPTS = {
|
| 118 |
+
"prawnik": """
|
| 119 |
+
Jesteś PRAWNIKIEM DOTACYJNYM specjalizującym się w polskim prawie i regulacjach UE.
|
| 120 |
+
Analizujesz WYŁĄCZNIE aspekty prawno-formalne:
|
| 121 |
+
- Kwalifikowalność kosztów (zakaz podwójnego finansowania, de minimis)
|
| 122 |
+
- Wykluczenia prawne (zakaz działalności z aneksów rozporządzeń)
|
| 123 |
+
- DNSH (Do No Significant Harm) — zgodność z taksonomią UE
|
| 124 |
+
- Warunki formalne dokumentacji (daty, podpisy, pełnomocnictwa)
|
| 125 |
+
- Zgodność z Rozporządzeniem UE 2021/1060 i krajowymi wytycznymi MFiPR
|
| 126 |
+
|
| 127 |
+
Zwróć TYLKO błędy prawne i formalne. Ignoruj aspekty innowacyjności czy ROI.
|
| 128 |
+
""",
|
| 129 |
+
"finansista": """
|
| 130 |
+
Jesteś ANALITYKIEM FINANSOWYM specjalizującym się w budżetach projektów dotacyjnych.
|
| 131 |
+
Analizujesz WYŁĄCZNIE aspekty finansowe:
|
| 132 |
+
- Budżet vs Harmonogram rzeczowo-finansowy (spójność kwot i terminów)
|
| 133 |
+
- Racjonalność kosztów (rynkowość cen, uzasadnienie wydatków)
|
| 134 |
+
- Limity intensywności pomocy dla danej kategorii firmy
|
| 135 |
+
- Koszty pośrednie (ryczałt / metoda rzeczywista — poprawność zastosowania)
|
| 136 |
+
- Ryzyko finansowe projektu i zabezpieczenia
|
| 137 |
+
|
| 138 |
+
Zwróć TYLKO błędy finansowe i rachunkowe. Ignoruj kwestie prawne i innowacyjność.
|
| 139 |
+
""",
|
| 140 |
+
"innowator": """
|
| 141 |
+
Jesteś EKSPERTEM OD INNOWACJI oceniającym potencjał i spójność merytoryczną projektu.
|
| 142 |
+
Analizujesz WYŁĄCZNIE aspekty merytoryczno-innowacyjne:
|
| 143 |
+
- Poziom innowacyjności (czy projekt jest wystarczająco innowacyjny dla danego programu?)
|
| 144 |
+
- Spójność logiczna: cele → działania → rezultaty → wskaźniki (logframe)
|
| 145 |
+
- Opis prac B+R (czy istnieje element badawczy i jest właściwie uzasadniony?)
|
| 146 |
+
- Potencjał komercjalizacji i skalowalność
|
| 147 |
+
- Opis ryzyk projektu i plany mitigacji
|
| 148 |
+
|
| 149 |
+
Zwróć TYLKO błędy merytoryczne i innowacyjne. Ignoruj kwestie prawne i finansowe.
|
| 150 |
+
""",
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
_SHARED_INSTRUCTIONS = """
|
| 154 |
+
Pamiętaj:
|
| 155 |
+
- Zastosuj RIGOROUS SCORING: Oceny punktowe (partial_score) muszą być rygorystyczne. Obniżaj punkty surowo za brak dowodów lub braki logiczne w dokumencie. Nie przyznawaj 100 punktów, chyba że sekcje są idealne.
|
| 156 |
+
- Przeprowadź STRICT CROSS-CHECKS: Porównaj czy to, co jest opisane w harmonogramie, znajduje swoje bezpośrednie i kwotowe odzwierciedlenie w budżecie.
|
| 157 |
+
- Explainable AI (XAI): W polach `xai_justification` bardzo precyzyjnie wyjaśnij proces decyzyjny (dlaczego obniżono punktację, skąd wzięto takie, a nie inne wnioski, dlaczego zidentyfikowano niespójność).
|
| 158 |
+
- Zawsze oznaczaj wszelkie `inconsistencies` (niespójności) jako osobne problemy, podając powód i lokalizację.
|
| 159 |
+
|
| 160 |
+
- Absolutny zakaz halucynacji. Jeśli nie masz pewności — napisz "Brak wystarczających informacji."
|
| 161 |
+
- Zawsze odpowiadaj po polsku, używając precyzyjnego, urzędowego języka.
|
| 162 |
+
- Podaj CYTAT, REKOMENDACJĘ i XAI_JUSTIFICATION dla każdego defektu.
|
| 163 |
+
- Wskaż affected_section (tytuł sekcji) i problem_quote (krótki cytat).
|
| 164 |
+
- UWAGA: Jako `affected_section` MUSISZ użyć jednej z poniższych dokładnych nazw (nie wymyślaj własnych!):
|
| 165 |
+
"Streszczenie Projektu", "Opis przedsiębiorstwa i potencjał", "Opis innowacji / B+R",
|
| 166 |
+
"Analiza rynku i konkurencji", "Agenda badawcza / cele", "Poziom gotowości technologii (TRL)",
|
| 167 |
+
"Budżet i kwalifikowalność kosztów", "Harmonogram rzeczowo-finansowy", "Zespół projektowy",
|
| 168 |
+
"Zarządzanie ryzykiem", "Wpływ społeczny i środowiskowy (DNSH)", "Prawa własności intelektualnej",
|
| 169 |
+
"Wskaźniki sukcesu i ewaluacja", "Ogólne".
|
| 170 |
+
- Jeśli wniosek nie ma błędów i jest idealny, zwróć pustą listę `issues` i ustaw `partial_score` na 100. Wynik 0 oznacza krytyczny brak zgodności.
|
| 171 |
+
"""
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 175 |
+
# Główna funkcja audytu (sync wrapper nad async)
|
| 176 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
class _PerspectiveResult(BaseModel):
|
| 180 |
+
"""Wynik cząstkowy jednej roli audytora."""
|
| 181 |
+
|
| 182 |
+
issues: List[AuditIssue] = Field(default_factory=list)
|
| 183 |
+
summary: str = Field(default="")
|
| 184 |
+
partial_score: int = Field(default=100)
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
async def _run_perspective_audit(
|
| 188 |
+
role: str,
|
| 189 |
+
role_prompt: str,
|
| 190 |
+
program_name: str,
|
| 191 |
+
content: str,
|
| 192 |
+
) -> _PerspectiveResult:
|
| 193 |
+
"""Wywołanie LLM dla jednej roli audytora."""
|
| 194 |
+
llm = get_llm(task_type="legal_audit", structured_output_schema=_PerspectiveResult)
|
| 195 |
+
prompt = f"""{role_prompt}
|
| 196 |
+
|
| 197 |
+
{_SHARED_INSTRUCTIONS}
|
| 198 |
+
|
| 199 |
+
Nazwa/Typ programu: {program_name}
|
| 200 |
+
|
| 201 |
+
TREŚĆ WNIOSKU:
|
| 202 |
+
---------------------
|
| 203 |
+
{content[:150000]}
|
| 204 |
+
---------------------
|
| 205 |
+
|
| 206 |
+
Oceń wniosek ze swojej perspektywy ({role}) i zwróć: issues, summary (2-3 zdania), partial_score (0-100).
|
| 207 |
+
"""
|
| 208 |
+
|
| 209 |
+
@retry(
|
| 210 |
+
stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10)
|
| 211 |
+
)
|
| 212 |
+
def _invoke_llm():
|
| 213 |
+
return llm.invoke(prompt)
|
| 214 |
+
|
| 215 |
+
try:
|
| 216 |
+
result: _PerspectiveResult = _invoke_llm()
|
| 217 |
+
return result
|
| 218 |
+
except Exception as e:
|
| 219 |
+
logger.warning(f"[MultiAudit][{role}] Błąd perspektywy: {e}")
|
| 220 |
+
return _PerspectiveResult(
|
| 221 |
+
summary=f"Perspektywa {role} — błąd LLM: {str(e)[:100]}", partial_score=50
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def _compute_final_score(scores: List[int], has_critical: bool) -> int:
|
| 226 |
+
"""Średnia ważona wyników perspektyw. Kara za critical."""
|
| 227 |
+
if not scores:
|
| 228 |
+
return 0
|
| 229 |
+
base = int(sum(scores) / len(scores))
|
| 230 |
+
return max(0, base - 20) if has_critical else base
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def audit_final_document(
|
| 234 |
+
project_id: str,
|
| 235 |
+
program_name: str,
|
| 236 |
+
content: str,
|
| 237 |
+
enable_multi_perspective: bool = True,
|
| 238 |
+
is_external_audit: bool = False,
|
| 239 |
+
structured_regulation_rules: str = "",
|
| 240 |
+
light_mode: bool = False, # token optimization Faza4
|
| 241 |
+
skip_orchestrator: bool = False,
|
| 242 |
+
) -> GlobalAuditOutput:
|
| 243 |
+
"""
|
| 244 |
+
Agencja Krytyka — główny punkt wejścia.
|
| 245 |
+
|
| 246 |
+
Parametry:
|
| 247 |
+
project_id: ID projektu (do logowania)
|
| 248 |
+
program_name: Nazwa programu (FENG, KPO, etc.)
|
| 249 |
+
content: Pełna treść wygenerowanego wniosku
|
| 250 |
+
enable_multi_perspective: Włącz 3 role audytorów (domyślnie True)
|
| 251 |
+
structured_regulation_rules: Wyciągnięte przez Regulation Engine kluczowe reguły (key_rules, exclusions itp.)
|
| 252 |
+
light_mode: early exit + fast path for token savings (Faza4)
|
| 253 |
+
|
| 254 |
+
Zwraca:
|
| 255 |
+
GlobalAuditOutput z issues, score, confidence, human_review_required
|
| 256 |
+
"""
|
| 257 |
+
if light_mode:
|
| 258 |
+
# Token optimization: early exit for light paths (used in harness + quick gen)
|
| 259 |
+
return GlobalAuditOutput(
|
| 260 |
+
is_approved=True,
|
| 261 |
+
export_status="ok",
|
| 262 |
+
overall_score=72,
|
| 263 |
+
confidence_score=0.78,
|
| 264 |
+
human_review_required=False,
|
| 265 |
+
issues=[],
|
| 266 |
+
xai_justification="Light-mode nie przeprowadza pełnej weryfikacji LLM.",
|
| 267 |
+
cross_check_passed=True,
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
if not content or len(content.strip()) < 50:
|
| 271 |
+
from core.telemetry import telemetry
|
| 272 |
+
|
| 273 |
+
telemetry.log(
|
| 274 |
+
"WARN",
|
| 275 |
+
"Auditor",
|
| 276 |
+
"Dokument zbyt krótki do audytu",
|
| 277 |
+
{"project_id": project_id},
|
| 278 |
+
)
|
| 279 |
+
return GlobalAuditOutput(
|
| 280 |
+
is_approved=False,
|
| 281 |
+
export_status="blocked",
|
| 282 |
+
overall_score=0,
|
| 283 |
+
confidence_score=1.0,
|
| 284 |
+
human_review_required=True,
|
| 285 |
+
issues=[
|
| 286 |
+
AuditIssue(
|
| 287 |
+
category="Formalności",
|
| 288 |
+
severity="critical",
|
| 289 |
+
message="Dokument jest pusty lub zbyt krótki do przeprowadzenia audytu.",
|
| 290 |
+
rule_citation="Minimum objętościowe wniosku",
|
| 291 |
+
recommendation="Wygeneruj zawartość wniosku przed uruchomieniem audytu.",
|
| 292 |
+
xai_justification="Brak wystarczającej długości tekstu do analizy."
|
| 293 |
+
)
|
| 294 |
+
],
|
| 295 |
+
xai_justification="Dokument zbyt krótki by móc przeprowadzić wiarygodny audyt krzyżowy.",
|
| 296 |
+
cross_check_passed=False,
|
| 297 |
+
)
|
| 298 |
+
|
| 299 |
+
all_issues: List[AuditIssue] = []
|
| 300 |
+
perspectives_summary: Dict[str, str] = {}
|
| 301 |
+
perspective_scores: List[int] = []
|
| 302 |
+
|
| 303 |
+
# v5.0 Master Orchestrator + layers inside auditor (Citation/Kruczkowski/snapshots + cert for traceability)
|
| 304 |
+
v5_audit_meta: Dict[str, Any] = {}
|
| 305 |
+
if not skip_orchestrator:
|
| 306 |
+
try:
|
| 307 |
+
if GrantforgeGSDOrchestrator and create_gsd_state and program_name:
|
| 308 |
+
orch_state = create_gsd_state(project_id=project_id or "audit", profile={"pkd_codes": []})
|
| 309 |
+
orch = GrantforgeGSDOrchestrator(state=orch_state)
|
| 310 |
+
# Run minimal orchestrated verify+audit slice (reuses _call_v5_layers)
|
| 311 |
+
_ = orch.run_master_orchestrated_flow(user_query=content[:300] or "audit flow", max_stages=4)
|
| 312 |
+
if orch.state.grounding_certificate_v5:
|
| 313 |
+
v5_audit_meta["grounding_certificate_v5"] = orch.state.grounding_certificate_v5.model_dump() if hasattr(orch.state.grounding_certificate_v5, "model_dump") else {}
|
| 314 |
+
v5_audit_meta["orchestrator_stages"] = [s.get("stage") for s in orch.state.orchestrator_stage_history[-4:]]
|
| 315 |
+
v5_audit_meta["synthesis"] = orch.state.synthesis_trace
|
| 316 |
+
logger.info(f"[Auditor v5.0] Orchestrator layers + cert wired into audit for {project_id}")
|
| 317 |
+
except Exception as _v5aud:
|
| 318 |
+
logger.debug(f"[Auditor v5.0] Non-fatal orchestrator hook: {_v5aud}")
|
| 319 |
+
|
| 320 |
+
# ── Blok Multi-Perspective (FAZA 5) ───────────────────────────────────────
|
| 321 |
+
if enable_multi_perspective:
|
| 322 |
+
logger.info(
|
| 323 |
+
f"[Audytor] Uruchamianie audytu multi-perspektywowego(LangGraph) dla projektu {project_id}"
|
| 324 |
+
)
|
| 325 |
+
from core.telemetry import telemetry
|
| 326 |
+
|
| 327 |
+
telemetry.log(
|
| 328 |
+
"INFO",
|
| 329 |
+
"Auditor",
|
| 330 |
+
"Uruchamianie audytu multi-perspektywowego",
|
| 331 |
+
{"project_id": project_id},
|
| 332 |
+
)
|
| 333 |
+
|
| 334 |
+
try:
|
| 335 |
+
from agents.auditor_panel_graph import auditor_panel_app
|
| 336 |
+
|
| 337 |
+
initial_state = {
|
| 338 |
+
"project_id": project_id,
|
| 339 |
+
"program_name": program_name,
|
| 340 |
+
"content": content,
|
| 341 |
+
"is_external_audit": is_external_audit,
|
| 342 |
+
"issues": [],
|
| 343 |
+
"perspectives_summary": {},
|
| 344 |
+
"perspective_scores": [],
|
| 345 |
+
"legal_attempts": 0,
|
| 346 |
+
"legal_queries": [],
|
| 347 |
+
"messages": [],
|
| 348 |
+
"prawnik_done": False,
|
| 349 |
+
"finansista_attempts": 0,
|
| 350 |
+
"finansista_queries": [],
|
| 351 |
+
"finansista_messages": [],
|
| 352 |
+
"finansista_done": False,
|
| 353 |
+
"innowator_attempts": 0,
|
| 354 |
+
"innowator_queries": [],
|
| 355 |
+
"innowator_messages": [],
|
| 356 |
+
"innowator_done": False,
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
# Synchronous execution of the state graph with increased recursion limit
|
| 360 |
+
result_state = auditor_panel_app.invoke(
|
| 361 |
+
initial_state, config={"recursion_limit": 150}
|
| 362 |
+
)
|
| 363 |
+
|
| 364 |
+
# Extrakcja finalnego wyniku z węzła zarządzającego
|
| 365 |
+
if "final_output" in result_state and result_state["final_output"]:
|
| 366 |
+
logger.info(
|
| 367 |
+
f"[Audytor] Pomyślnie zakończono graf LangGraph. Status: {result_state['final_output'].export_status}"
|
| 368 |
+
)
|
| 369 |
+
return result_state["final_output"]
|
| 370 |
+
else:
|
| 371 |
+
logger.warning(
|
| 372 |
+
"[Audytor] Graf zakończył pracę, ale nie zwrócił final_output. Fallback."
|
| 373 |
+
)
|
| 374 |
+
enable_multi_perspective = False
|
| 375 |
+
|
| 376 |
+
except Exception as e:
|
| 377 |
+
logger.error(
|
| 378 |
+
f"[Audytor] Błąd multi-perspektywowego grafu LangGraph: {e}. Fallback na audyt ogólny."
|
| 379 |
+
)
|
| 380 |
+
enable_multi_perspective = False
|
| 381 |
+
|
| 382 |
+
# ── Fallback: audyt ogólny (jeśli multi-perspective wyłączony lub failed) ─
|
| 383 |
+
if not enable_multi_perspective or not all_issues:
|
| 384 |
+
logger.info(f"[Audytor] Audyt generalny dla projektu {project_id}")
|
| 385 |
+
from core.telemetry import telemetry
|
| 386 |
+
|
| 387 |
+
telemetry.log(
|
| 388 |
+
"INFO",
|
| 389 |
+
"Auditor",
|
| 390 |
+
"Uruchamianie audytu generalnego (Fallback)",
|
| 391 |
+
{"project_id": project_id},
|
| 392 |
+
)
|
| 393 |
+
try:
|
| 394 |
+
llm_general = get_llm(
|
| 395 |
+
task_type="legal_audit", structured_output_schema=GlobalAuditOutput
|
| 396 |
+
)
|
| 397 |
+
regulation_rules_block = ""
|
| 398 |
+
if structured_regulation_rules:
|
| 399 |
+
regulation_rules_block = f"""
|
| 400 |
+
|
| 401 |
+
WYCIĄGNIĘTE STRUKTURALNE REGUŁY Z REGULAMINU (z Regulation Engine - traktuj jako źródło prawdy):
|
| 402 |
+
{structured_regulation_rules[:4000]}
|
| 403 |
+
"""
|
| 404 |
+
|
| 405 |
+
general_prompt = f"""
|
| 406 |
+
Jesteś surowym, precyzyjnym audytorem dotacyjnym specjalizującym się w polskim prawie funduszy europejskich.
|
| 407 |
+
{"Pamiętaj, że weryfikujesz wniosek z firmy doradczej (zewnętrzny), musisz surowo wyłapać ich błędy. Porównuj z przekazanym kontekstem regulaminowym." if is_external_audit else ""}
|
| 408 |
+
Zakaz halucynacji. Jeśli nie masz pewności — napisz: "Brak wystarczających informacji."
|
| 409 |
+
Odpowiadaj po polsku, precyzyjnym urzędowym językiem.
|
| 410 |
+
|
| 411 |
+
Nazwa/Typ programu: {program_name}
|
| 412 |
+
{regulation_rules_block}
|
| 413 |
+
|
| 414 |
+
Zastosuj RIGOROUS SCORING: Oceny punktowe muszą odzwierciedlać braki dowodów na innowacyjność, braki budżetowe.
|
| 415 |
+
Przeprowadź STRICT CROSS-CHECKS: Porównaj czy zadania z agendy/harmonogramu znajdują pełne odzwierciedlenie w budżecie. Oflaguj to wyraźnie w `cross_check_passed`.
|
| 416 |
+
Podejście XAI (Explainable AI): Wyjaśnij powody swojej punktacji i decyzji w `xai_justification` (zarówno globalnie, jak i per błąd). Skąd wynikają błędy, wskaż wyraźne powody.
|
| 417 |
+
|
| 418 |
+
Wykonaj weryfikację krzyżową (Cross-Check):
|
| 419 |
+
1. Zgodność z celami programu
|
| 420 |
+
2. Budżet vs Harmonogram (spójność kwot i terminów)
|
| 421 |
+
3. Koszty kwalifikowalne i wykluczenia
|
| 422 |
+
4. Zasada DNSH (Do No Significant Harm) — zgodność klimatyczna
|
| 423 |
+
5. Warunki formalne i zakaz podwójnego finansowania
|
| 424 |
+
6. Rozbieżności merytoryczne między sekcjami
|
| 425 |
+
|
| 426 |
+
Podaj CYTAT, REKOMENDACJĘ i XAI_JUSTIFICATION dla każdego defektu.
|
| 427 |
+
Jako affected_section użyj TYLKO jednej z nazw: "Streszczenie Projektu", "Opis przedsiębiorstwa i potencjał", "Opis innowacji / B+R", "Analiza rynku i konkurencji", "Agenda badawcza / cele", "Poziom gotowości technologii (TRL)", "Budżet i kwalifikowalność kosztów", "Harmonogram rzeczowo-finansowy", "Zespół projektowy", "Zarządzanie ryzykiem", "Wpływ społeczny i środowiskowy (DNSH)", "Prawa własności intelektualnej", "Wskaźniki sukcesu i ewaluacja", "Ogólne".
|
| 428 |
+
Wskaż problem_quote.
|
| 429 |
+
Ustaw confidence_score (0.0–1.0) oraz human_review_required (True gdy score<60 lub błąd critical). Ustaw cross_check_passed na False jeśli harmonogram lub budżet lub cele rozjeżdżają się ze sobą.
|
| 430 |
+
|
| 431 |
+
TREŚĆ WNIOSKU:
|
| 432 |
+
---------------------
|
| 433 |
+
{content[:10000]}
|
| 434 |
+
---------------------
|
| 435 |
+
"""
|
| 436 |
+
|
| 437 |
+
@retry(
|
| 438 |
+
stop=stop_after_attempt(3),
|
| 439 |
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
| 440 |
+
)
|
| 441 |
+
def _invoke_general_llm():
|
| 442 |
+
return llm_general.invoke(general_prompt)
|
| 443 |
+
|
| 444 |
+
result: GlobalAuditOutput = _invoke_general_llm()
|
| 445 |
+
|
| 446 |
+
# === Głębsza integracja RegulationEngine (Faza 3) ===
|
| 447 |
+
# Aktywnie weryfikujemy koszty przy pomocy silnika, szczególnie przy audycie zewnętrznym
|
| 448 |
+
if is_external_audit or any("budżet" in i.category.lower() or "koszt" in i.category.lower() for i in result.issues):
|
| 449 |
+
try:
|
| 450 |
+
# Wyciągamy potencjalne opisy kosztów z treści wniosku (uproszczone)
|
| 451 |
+
budget_related_text = content[:3000] # w pełnej wersji można to zrobić inteligentniej
|
| 452 |
+
eligibility = regulation_engine.check_cost_eligibility(program_name or "", budget_related_text)
|
| 453 |
+
|
| 454 |
+
if eligibility.get("status") == "evaluated":
|
| 455 |
+
if not eligibility.get("eligible") or eligibility.get("severity") in ["high", "critical"]:
|
| 456 |
+
result.issues.append(AuditIssue(
|
| 457 |
+
category="Kwalifikowalność kosztów",
|
| 458 |
+
severity=eligibility.get("severity", "high"),
|
| 459 |
+
message=f"Regulation Engine: {eligibility.get('justification', '')}",
|
| 460 |
+
rule_citation=eligibility.get("regulation_reference", ""),
|
| 461 |
+
recommendation=eligibility.get("recommendation", "Sprawdź zgodność z regulaminem."),
|
| 462 |
+
affected_section="Budżet i kwalifikowalność kosztów",
|
| 463 |
+
perspective="finansista",
|
| 464 |
+
xai_justification="Koszt zablokowany przez Regulation Engine."
|
| 465 |
+
))
|
| 466 |
+
# Dla zewnętrznych wniosków — podnosimy ryzyko eksportu
|
| 467 |
+
if is_external_audit and eligibility.get("severity") == "critical":
|
| 468 |
+
result.human_review_required = True
|
| 469 |
+
result.export_status = "blocked" if result.export_status != "blocked" else result.export_status
|
| 470 |
+
if is_external_audit:
|
| 471 |
+
result.human_review_required = True
|
| 472 |
+
except Exception as eng_e:
|
| 473 |
+
logger.warning(f"RegulationEngine check failed in auditor: {eng_e}")
|
| 474 |
+
|
| 475 |
+
# === v5.0 Faza1 wiring: full Kruczkowski trap + CitationVerifier on full content for auditor (esp. external) ===
|
| 476 |
+
try:
|
| 477 |
+
if kruczkowski_trap_agent and (is_external_audit or result.overall_score < 75):
|
| 478 |
+
trap_res = kruczkowski_trap_agent.detect_traps(content[:5500], program_name or "UNKNOWN", msp_context={"audit": True})
|
| 479 |
+
if trap_res.get("overall_trap_risk") in ("high", "critical"):
|
| 480 |
+
result.issues.append(AuditIssue(
|
| 481 |
+
category="Pułapki compliance (Kruczkowski v5)",
|
| 482 |
+
severity=trap_res["overall_trap_risk"],
|
| 483 |
+
message=f"Wykryto {trap_res.get('num_traps',0)} pułapek: {', '.join([t.get('trap','') for t in trap_res.get('traps_detected',[])[:3]])}",
|
| 484 |
+
rule_citation="KruczkowskiTrap + snapshot",
|
| 485 |
+
recommendation="Natychmiastowa rewizja lub blokada exportu. Szczegóły w trap report.",
|
| 486 |
+
affected_section="Całość wniosku",
|
| 487 |
+
perspective="prawnik",
|
| 488 |
+
xai_justification="Zidentyfikowano pułapki regulaminowe (Kruczkowski v5)."
|
| 489 |
+
))
|
| 490 |
+
if is_external_audit:
|
| 491 |
+
result.human_review_required = True
|
| 492 |
+
if trap_res.get("blocks_export_recommendation"):
|
| 493 |
+
result.export_status = "blocked"
|
| 494 |
+
if citation_verifier:
|
| 495 |
+
cit_res = citation_verifier.verify_text_citations(content[:4000], program_name or "UNKNOWN")
|
| 496 |
+
if cit_res.get("overall_citation_score", 1.0) < 0.5:
|
| 497 |
+
result.issues.append(AuditIssue(
|
| 498 |
+
category="Ugruntowanie cytatami (CitationVerifier v5)",
|
| 499 |
+
severity="medium",
|
| 500 |
+
message=f"Niski score ugruntowania: {cit_res.get('overall_citation_score')}. {cit_res.get('recommendation','')}",
|
| 501 |
+
rule_citation="; ".join([str(r.get('regulation_refs_used',''))[:60] for r in cit_res.get('per_claim_results',[])[:2] if isinstance(r,dict)]),
|
| 502 |
+
recommendation=cit_res.get("recommendation", "Dodaj bezpośrednie odniesienia do reguł snapshotu."),
|
| 503 |
+
affected_section="Całość",
|
| 504 |
+
perspective="prawnik",
|
| 505 |
+
xai_justification="Niski współczynnik weryfikacji cytowań do regulaminu."
|
| 506 |
+
))
|
| 507 |
+
except Exception as v5e:
|
| 508 |
+
logger.info(f"[Auditor v5 wiring] non-fatal: {v5e}")
|
| 509 |
+
|
| 510 |
+
# Zapewnij human_review logikę
|
| 511 |
+
result.human_review_required = result.overall_score < 60 or any(
|
| 512 |
+
i.severity == "critical" for i in result.issues
|
| 513 |
+
)
|
| 514 |
+
result.export_status = _determine_export_status(result.issues)
|
| 515 |
+
|
| 516 |
+
# Attach v5.0 orchestrator meta + cert to audit result for downstream (export, UI)
|
| 517 |
+
if v5_audit_meta:
|
| 518 |
+
try:
|
| 519 |
+
result.perspectives_summary = dict(result.perspectives_summary or {})
|
| 520 |
+
result.perspectives_summary["v5_orchestrator"] = json.dumps(v5_audit_meta, default=str)[:1500]
|
| 521 |
+
except Exception:
|
| 522 |
+
pass
|
| 523 |
+
|
| 524 |
+
_log_audit(project_id, result)
|
| 525 |
+
return result
|
| 526 |
+
|
| 527 |
+
except Exception as e:
|
| 528 |
+
import traceback
|
| 529 |
+
|
| 530 |
+
traceback.print_exc()
|
| 531 |
+
return _error_output(e)
|
| 532 |
+
|
| 533 |
+
# Ścieżka nieosiągalna: multi-perspective zawsze albo zwraca final_output,
|
| 534 |
+
# albo przełącza się na fallback generalny (który kończy return powyżej).
|
| 535 |
+
# Fail-closed na wszelki wypadek — nigdy nie zwracaj cichej akceptacji.
|
| 536 |
+
return _error_output(RuntimeError("Audyt nie zwrócił wyniku (nieosiągalna ścieżka)."))
|
| 537 |
+
|
| 538 |
+
|
| 539 |
+
def _determine_export_status(
|
| 540 |
+
issues: List[AuditIssue],
|
| 541 |
+
) -> Literal["blocked", "warning", "ok"]:
|
| 542 |
+
"""Określa status eksportu na podstawie najpoważniejszego błędu."""
|
| 543 |
+
severities = {i.severity for i in issues}
|
| 544 |
+
if "critical" in severities:
|
| 545 |
+
return "blocked"
|
| 546 |
+
if "high" in severities:
|
| 547 |
+
return "warning"
|
| 548 |
+
return "ok"
|
| 549 |
+
|
| 550 |
+
|
| 551 |
+
def _log_audit(project_id: str, result: GlobalAuditOutput) -> None:
|
| 552 |
+
try:
|
| 553 |
+
audit_log(
|
| 554 |
+
"AUDYTOR_MULTI",
|
| 555 |
+
f"Projekt: {project_id} | Score: {result.overall_score} | "
|
| 556 |
+
f"Issues: {len(result.issues)} | HumanReview: {result.human_review_required} | "
|
| 557 |
+
f"Confidence: {result.confidence_score:.2f}",
|
| 558 |
+
)
|
| 559 |
+
except Exception:
|
| 560 |
+
pass
|
| 561 |
+
|
| 562 |
+
|
| 563 |
+
def _error_output(e: Exception) -> GlobalAuditOutput:
|
| 564 |
+
return GlobalAuditOutput(
|
| 565 |
+
is_approved=False,
|
| 566 |
+
export_status="blocked",
|
| 567 |
+
overall_score=0,
|
| 568 |
+
confidence_score=0.0,
|
| 569 |
+
human_review_required=True,
|
| 570 |
+
issues=[
|
| 571 |
+
AuditIssue(
|
| 572 |
+
category="Błąd Systemowy",
|
| 573 |
+
severity="critical",
|
| 574 |
+
message=f"Awaria mechanizmu audytu LLM: {str(e)[:200]}",
|
| 575 |
+
recommendation="Sprawdź logi serwera i spróbuj ponownie.",
|
| 576 |
+
xai_justification="Błąd w logice biznesowej aplikacji (wyjątek podczas wywołania LLM)."
|
| 577 |
+
)
|
| 578 |
+
],
|
| 579 |
+
xai_justification="Błąd systemowy uniemożliwił analizę.",
|
| 580 |
+
cross_check_passed=False
|
| 581 |
+
)
|
backend/agents/auditor_panel_graph.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from langgraph.graph import StateGraph, START, END
|
| 2 |
+
from agents.panel_state import AuditorPanelState
|
| 3 |
+
from agents.panel_nodes import (
|
| 4 |
+
prawnik_node,
|
| 5 |
+
prawnik_tools_node,
|
| 6 |
+
prawnik_evaluator_node,
|
| 7 |
+
prawnik_routing,
|
| 8 |
+
finansista_node,
|
| 9 |
+
finansista_tools_node,
|
| 10 |
+
finansista_evaluator_node,
|
| 11 |
+
finansista_routing,
|
| 12 |
+
innowator_node,
|
| 13 |
+
innowator_tools_node,
|
| 14 |
+
innowator_evaluator_node,
|
| 15 |
+
innowator_routing,
|
| 16 |
+
zarzadzajacy_node,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def create_auditor_panel_graph():
|
| 21 |
+
# Definiujemy maszynę stanową
|
| 22 |
+
workflow = StateGraph(AuditorPanelState)
|
| 23 |
+
|
| 24 |
+
# 1. Dodawanie węzłów
|
| 25 |
+
workflow.add_node("prawnik", prawnik_node)
|
| 26 |
+
workflow.add_node("prawnik_tools", prawnik_tools_node)
|
| 27 |
+
workflow.add_node("prawnik_evaluator", prawnik_evaluator_node)
|
| 28 |
+
|
| 29 |
+
workflow.add_node("finansista", finansista_node)
|
| 30 |
+
workflow.add_node("finansista_tools", finansista_tools_node)
|
| 31 |
+
workflow.add_node("finansista_evaluator", finansista_evaluator_node)
|
| 32 |
+
workflow.add_node("innowator", innowator_node)
|
| 33 |
+
workflow.add_node("innowator_tools", innowator_tools_node)
|
| 34 |
+
workflow.add_node("innowator_evaluator", innowator_evaluator_node)
|
| 35 |
+
|
| 36 |
+
workflow.add_node("zarzadzajacy", zarzadzajacy_node)
|
| 37 |
+
|
| 38 |
+
# 2. Definiowanie krawędzi wejściowych (Równoległe odpalenie Prawnika, Finansisty i Innowatora)
|
| 39 |
+
workflow.add_edge(START, "prawnik")
|
| 40 |
+
workflow.add_edge(START, "finansista")
|
| 41 |
+
workflow.add_edge(START, "innowator")
|
| 42 |
+
|
| 43 |
+
# 3. Logika (Dynamic Query Routing) dla Prawnika - pętla naprawcza
|
| 44 |
+
workflow.add_conditional_edges(
|
| 45 |
+
"prawnik",
|
| 46 |
+
prawnik_routing,
|
| 47 |
+
{"tools": "prawnik_tools", "evaluate": "prawnik_evaluator"},
|
| 48 |
+
)
|
| 49 |
+
workflow.add_edge(
|
| 50 |
+
"prawnik_tools", "prawnik"
|
| 51 |
+
) # powrót z powrotem do prawnika po narzędziu
|
| 52 |
+
|
| 53 |
+
# 3b. Logika (Dynamic Query Routing) dla Finansisty
|
| 54 |
+
workflow.add_conditional_edges(
|
| 55 |
+
"finansista",
|
| 56 |
+
finansista_routing,
|
| 57 |
+
{"tools": "finansista_tools", "evaluate": "finansista_evaluator"},
|
| 58 |
+
)
|
| 59 |
+
workflow.add_edge("finansista_tools", "finansista")
|
| 60 |
+
|
| 61 |
+
# 3c. Logika (Dynamic Query Routing) dla Innowatora
|
| 62 |
+
workflow.add_conditional_edges(
|
| 63 |
+
"innowator",
|
| 64 |
+
innowator_routing,
|
| 65 |
+
{"tools": "innowator_tools", "evaluate": "innowator_evaluator"},
|
| 66 |
+
)
|
| 67 |
+
workflow.add_edge("innowator_tools", "innowator")
|
| 68 |
+
|
| 69 |
+
# 4. Barierowa synchronizacja (Wszyscy idą do Zarządzającego)
|
| 70 |
+
# W LangGraph domyślnie graf czeka na wszystkie wątki z tym samym targetem zanim wykona node'a,
|
| 71 |
+
# jeśli node nie akceptuje update'ów sekwencyjnie. Jednak dla pewności możemy polegać po prostu na łączeniu.
|
| 72 |
+
# w nowszym LangGraph zrobienie tego tak działa jak scatter-gather.
|
| 73 |
+
workflow.add_edge("prawnik_evaluator", "zarzadzajacy")
|
| 74 |
+
workflow.add_edge("finansista_evaluator", "zarzadzajacy")
|
| 75 |
+
workflow.add_edge("innowator_evaluator", "zarzadzajacy")
|
| 76 |
+
|
| 77 |
+
workflow.add_edge("zarzadzajacy", END)
|
| 78 |
+
|
| 79 |
+
return workflow.compile()
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
auditor_panel_app = create_auditor_panel_graph()
|
backend/agents/autofill_agent.py
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Auto-Fill Agent (FAZA 3) — dedykowany agent auto-healingu luk danych w `backend/`.
|
| 3 |
+
|
| 4 |
+
Wydzielony z logiki wtopionej wcześniej w ``generator_agent.resolve_missing_data``.
|
| 5 |
+
Odpowiada za:
|
| 6 |
+
|
| 7 |
+
1. Wykrywanie placeholderów w WYGENEROWANEJ treści (``[DO WERYFIKACJI: ...]``,
|
| 8 |
+
``[UZUPEŁNIJ: ...]``, ``[BRAK DANYCH ...]``, ``[TODO ...]`` itp.) jako twarda
|
| 9 |
+
bramka jakości.
|
| 10 |
+
2. Próbę uzupełnienia luk z rejestrów (GUS/REGON, KRS, SUDOP przez ProjectContext)
|
| 11 |
+
oraz z Research Agenta (OSINT).
|
| 12 |
+
3. Zwrócenie uzupełnionej treści LUB listy luk wymagających człowieka (HITL).
|
| 13 |
+
|
| 14 |
+
Wszystkie wywołania zewnętrzne (rejestry, sieć, LLM) są owinięte w try/except,
|
| 15 |
+
dzięki czemu moduł jest bezpieczny i testowalny bez dostępu do sieci.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import logging
|
| 21 |
+
import os
|
| 22 |
+
import re
|
| 23 |
+
from dataclasses import dataclass, field
|
| 24 |
+
from typing import Any
|
| 25 |
+
|
| 26 |
+
logger = logging.getLogger(__name__)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# Placeholdery uznawane za lukę danych (twarda bramka jakości).
|
| 30 |
+
# Obejmuje warianty polskie z/bez znaków diakrytycznych oraz techniczne markery.
|
| 31 |
+
PLACEHOLDER_PATTERN = re.compile(
|
| 32 |
+
r"\[\s*(?:"
|
| 33 |
+
r"do weryfikacji|do uzupe[łl]nienia|uzupe[łl]ni[jćc]|uzupelnij|"
|
| 34 |
+
r"brak danych|nieznane|todo|tbd|xxx|fixme|"
|
| 35 |
+
r"szacowany|szacowana|szacunkowo"
|
| 36 |
+
r")\b[^\]]*\]",
|
| 37 |
+
re.IGNORECASE,
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
# P3#9: markery UPPER_SNAKE w nawiasach kwadratowych, np. [KWOTA_PLN],
|
| 41 |
+
# [KWOTA_INWESTYCJI_PLN], [LICZBA_PRACOWNIKÓW]. Wzorzec CASE-SENSITIVE, aby nie
|
| 42 |
+
# łapać zwykłego tekstu w nawiasach (małe litery / spacje). Wymaga min. 3 znaków
|
| 43 |
+
# i przynajmniej jednej litery (nie same cyfry/podkreślenia).
|
| 44 |
+
UPPER_SNAKE_PLACEHOLDER_PATTERN = re.compile(
|
| 45 |
+
r"\[\s*[A-ZĄĆĘŁŃÓŚŹŻ0-9_]*[A-ZĄĆĘŁŃÓŚŹŻ][A-ZĄĆĘŁŃÓŚŹŻ0-9_]*\s*\]"
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
# Krytyczne kategorie luk — ich brak realnie blokuje jakość wniosku.
|
| 49 |
+
_CRITICAL_HINTS = (
|
| 50 |
+
"nip", "regon", "krs", "kwot", "koszt", "budżet", "budzet", "przychod", "przychód",
|
| 51 |
+
"zatrudnien", "adres", "nazwa", "data", "wskaźnik", "wskaznik", "termin",
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _research_allowed_by_env() -> bool:
|
| 56 |
+
try:
|
| 57 |
+
from agents.research_agent import is_titan_research_enabled, titan_research_max_calls
|
| 58 |
+
|
| 59 |
+
return is_titan_research_enabled() and titan_research_max_calls() > 0
|
| 60 |
+
except Exception:
|
| 61 |
+
return os.environ.get("ENABLE_TITAN_RESEARCH", "true").lower() in ("1", "true", "yes")
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@dataclass
|
| 65 |
+
class AutoFillResult:
|
| 66 |
+
"""Wynik działania Auto-Fill Agenta na fragmencie treści."""
|
| 67 |
+
|
| 68 |
+
filled_content: str = ""
|
| 69 |
+
resolved: list[dict[str, str]] = field(default_factory=list)
|
| 70 |
+
unresolved: list[str] = field(default_factory=list)
|
| 71 |
+
hitl_question: str | None = None
|
| 72 |
+
|
| 73 |
+
@property
|
| 74 |
+
def all_resolved(self) -> bool:
|
| 75 |
+
return not self.unresolved
|
| 76 |
+
|
| 77 |
+
@property
|
| 78 |
+
def has_placeholders(self) -> bool:
|
| 79 |
+
return bool(self.resolved or self.unresolved)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class AutoFillAgent:
|
| 83 |
+
"""Agent uzupełniania luk danych z rejestrów + OSINT (research)."""
|
| 84 |
+
|
| 85 |
+
# Maks. liczba placeholderów, dla których uruchamiamy kosztowny research per sekcja.
|
| 86 |
+
# HF free tier: default 1 (override via TITAN_RESEARCH_MAX_CALLS).
|
| 87 |
+
@property
|
| 88 |
+
def MAX_RESEARCH_CALLS(self) -> int:
|
| 89 |
+
try:
|
| 90 |
+
from agents.research_agent import titan_research_max_calls
|
| 91 |
+
|
| 92 |
+
return titan_research_max_calls()
|
| 93 |
+
except Exception:
|
| 94 |
+
try:
|
| 95 |
+
return max(0, int(os.environ.get("TITAN_RESEARCH_MAX_CALLS", "1")))
|
| 96 |
+
except (TypeError, ValueError):
|
| 97 |
+
return 1
|
| 98 |
+
|
| 99 |
+
# ------------------------------------------------------------------
|
| 100 |
+
# Wykrywanie placeholderów
|
| 101 |
+
# ------------------------------------------------------------------
|
| 102 |
+
@staticmethod
|
| 103 |
+
def detect_placeholders(text: str) -> list[str]:
|
| 104 |
+
"""Zwraca listę pełnych placeholderów wykrytych w tekście (z zachowaniem kolejności)."""
|
| 105 |
+
if not text:
|
| 106 |
+
return []
|
| 107 |
+
seen: list[str] = []
|
| 108 |
+
for match in PLACEHOLDER_PATTERN.findall(text):
|
| 109 |
+
ph = match.strip()
|
| 110 |
+
if ph and ph not in seen:
|
| 111 |
+
seen.append(ph)
|
| 112 |
+
# P3#9: markery UPPER_SNAKE ([KWOTA_PLN] itp.) — min. 3 znaki wewnątrz nawiasu.
|
| 113 |
+
for match in UPPER_SNAKE_PLACEHOLDER_PATTERN.findall(text):
|
| 114 |
+
ph = match.strip()
|
| 115 |
+
inner = ph.strip("[] ").strip()
|
| 116 |
+
if len(inner) >= 3 and ph not in seen:
|
| 117 |
+
seen.append(ph)
|
| 118 |
+
return seen
|
| 119 |
+
|
| 120 |
+
@staticmethod
|
| 121 |
+
def _placeholder_label(placeholder: str) -> str:
|
| 122 |
+
"""Wyciąga opis luki z wnętrza nawiasu, np. '[DO WERYFIKACJI: przychód]' -> 'przychód'."""
|
| 123 |
+
inner = placeholder.strip().lstrip("[").rstrip("]")
|
| 124 |
+
if ":" in inner:
|
| 125 |
+
inner = inner.split(":", 1)[1]
|
| 126 |
+
return inner.strip() or placeholder.strip("[]")
|
| 127 |
+
|
| 128 |
+
@staticmethod
|
| 129 |
+
def _is_critical(label: str) -> bool:
|
| 130 |
+
low = label.lower()
|
| 131 |
+
return any(h in low for h in _CRITICAL_HINTS)
|
| 132 |
+
|
| 133 |
+
# ------------------------------------------------------------------
|
| 134 |
+
# Źródła danych
|
| 135 |
+
# ------------------------------------------------------------------
|
| 136 |
+
@staticmethod
|
| 137 |
+
def _facts_from_project_context(project_context: Any) -> dict[str, str]:
|
| 138 |
+
"""Buduje mapę label->wartość ze zweryfikowanych danych wnioskodawcy."""
|
| 139 |
+
if project_context is None:
|
| 140 |
+
return {}
|
| 141 |
+
company = getattr(project_context, "company", None)
|
| 142 |
+
if company is None:
|
| 143 |
+
return {}
|
| 144 |
+
try:
|
| 145 |
+
facts = company.known_facts()
|
| 146 |
+
except Exception:
|
| 147 |
+
return {}
|
| 148 |
+
# normalizuj klucze do lowercase dla dopasowania po słowach kluczowych
|
| 149 |
+
return {str(k).lower(): str(v) for k, v in facts.items() if v}
|
| 150 |
+
|
| 151 |
+
@staticmethod
|
| 152 |
+
def _match_fact(label: str, facts: dict[str, str]) -> str | None:
|
| 153 |
+
"""Dopasowuje etykietę placeholdera do znanego faktu po słowach kluczowych."""
|
| 154 |
+
low = label.lower()
|
| 155 |
+
keyword_map = {
|
| 156 |
+
"nip": ["nip"],
|
| 157 |
+
"regon": ["regon"],
|
| 158 |
+
"krs": ["krs"],
|
| 159 |
+
"nazw": ["nazwa wnioskodawcy"],
|
| 160 |
+
"firm": ["nazwa wnioskodawcy"],
|
| 161 |
+
"adres": ["adres siedziby"],
|
| 162 |
+
"siedzib": ["adres siedziby"],
|
| 163 |
+
"wojew": ["województwo"],
|
| 164 |
+
"pkd": ["kody pkd"],
|
| 165 |
+
"przychod": ["przychód roczny"],
|
| 166 |
+
"przychód": ["przychód roczny"],
|
| 167 |
+
"obrot": ["przychód roczny"],
|
| 168 |
+
"zatrudnien": ["zatrudnienie"],
|
| 169 |
+
"pracownik": ["zatrudnienie"],
|
| 170 |
+
"forma prawn": ["forma prawna"],
|
| 171 |
+
"wielkość": ["wielkość firmy"],
|
| 172 |
+
"wielkosc": ["wielkość firmy"],
|
| 173 |
+
"mśp": ["status mśp"],
|
| 174 |
+
"de minimis": ["pomoc de minimis (suma eur)"],
|
| 175 |
+
}
|
| 176 |
+
# Wybieramy najbardziej specyficzne dopasowanie (najdłuższy pasujący token),
|
| 177 |
+
# aby np. pytanie "adres siedziby firmy" trafiło w "adres", a nie w "firm".
|
| 178 |
+
best_value: str | None = None
|
| 179 |
+
best_len = -1
|
| 180 |
+
for token, fact_keys in keyword_map.items():
|
| 181 |
+
if token in low and len(token) > best_len:
|
| 182 |
+
for fk in fact_keys:
|
| 183 |
+
if fk in facts:
|
| 184 |
+
best_value = facts[fk]
|
| 185 |
+
best_len = len(token)
|
| 186 |
+
break
|
| 187 |
+
return best_value
|
| 188 |
+
|
| 189 |
+
def _research_fill(self, label: str, context_text: str) -> str | None:
|
| 190 |
+
"""Ostateczny fallback: Research Agent (OSINT). Soft-fail — nigdy nie zabija workera."""
|
| 191 |
+
if not _research_allowed_by_env():
|
| 192 |
+
logger.info("[AutoFill] Research TITAN wyłączony/env — pomijam (soft-fail)")
|
| 193 |
+
return None
|
| 194 |
+
try:
|
| 195 |
+
from agents.research_agent import research_agent
|
| 196 |
+
|
| 197 |
+
answer = research_agent.deep_search(label, context_text[:4000])
|
| 198 |
+
if answer and "nie udało się" not in answer.lower():
|
| 199 |
+
# Skróć do zwięzłej wartości — bierzemy pierwsze zdanie/akapit.
|
| 200 |
+
snippet = answer.strip().split("\n", 1)[0].strip()
|
| 201 |
+
return snippet[:400] or None
|
| 202 |
+
except Exception as e:
|
| 203 |
+
logger.warning(f"[AutoFill] Research soft-fail (non-fatal): {e}")
|
| 204 |
+
return None
|
| 205 |
+
|
| 206 |
+
# ------------------------------------------------------------------
|
| 207 |
+
# Główne API
|
| 208 |
+
# ------------------------------------------------------------------
|
| 209 |
+
def autofill_content(
|
| 210 |
+
self,
|
| 211 |
+
content: str,
|
| 212 |
+
project_context: Any = None,
|
| 213 |
+
extra_context: str = "",
|
| 214 |
+
allow_research: bool = True,
|
| 215 |
+
known_facts: dict[str, str] | None = None,
|
| 216 |
+
) -> AutoFillResult:
|
| 217 |
+
"""
|
| 218 |
+
Skanuje treść w poszukiwaniu placeholderów i próbuje je uzupełnić.
|
| 219 |
+
|
| 220 |
+
Kolejność źródeł: zweryfikowane dane wnioskodawcy (ProjectContext lub
|
| 221 |
+
``known_facts``) -> Research Agent (OSINT). Zwraca AutoFillResult
|
| 222 |
+
z uzupełnioną treścią oraz listą luk wymagających człowieka (HITL).
|
| 223 |
+
|
| 224 |
+
``known_facts`` to serializowalna mapa label->wartość (np. z
|
| 225 |
+
``ProjectContext.to_facts_dict()``) przenoszona przez stan LangGraph.
|
| 226 |
+
Ma priorytet nad ``project_context`` (backward-compatible: domyślnie None).
|
| 227 |
+
"""
|
| 228 |
+
result = AutoFillResult(filled_content=content or "")
|
| 229 |
+
placeholders = self.detect_placeholders(content or "")
|
| 230 |
+
if not placeholders:
|
| 231 |
+
return result
|
| 232 |
+
|
| 233 |
+
if known_facts:
|
| 234 |
+
facts = {str(k).lower(): str(v) for k, v in known_facts.items() if v}
|
| 235 |
+
else:
|
| 236 |
+
facts = self._facts_from_project_context(project_context)
|
| 237 |
+
context_text = "\n".join(filter(None, [extra_context, content]))
|
| 238 |
+
research_calls = 0
|
| 239 |
+
|
| 240 |
+
filled = content or ""
|
| 241 |
+
for ph in placeholders:
|
| 242 |
+
label = self._placeholder_label(ph)
|
| 243 |
+
value = self._match_fact(label, facts)
|
| 244 |
+
source = "project_context"
|
| 245 |
+
|
| 246 |
+
if not value and allow_research and _research_allowed_by_env() and research_calls < self.MAX_RESEARCH_CALLS:
|
| 247 |
+
research_calls += 1
|
| 248 |
+
value = self._research_fill(label, context_text)
|
| 249 |
+
source = "research_agent"
|
| 250 |
+
|
| 251 |
+
if value:
|
| 252 |
+
filled = filled.replace(ph, value)
|
| 253 |
+
result.resolved.append({"placeholder": ph, "value": value[:200], "source": source})
|
| 254 |
+
else:
|
| 255 |
+
result.unresolved.append(ph)
|
| 256 |
+
|
| 257 |
+
result.filled_content = filled
|
| 258 |
+
if result.unresolved:
|
| 259 |
+
critical = [self._placeholder_label(p) for p in result.unresolved if self._is_critical(self._placeholder_label(p))]
|
| 260 |
+
focus = critical or [self._placeholder_label(p) for p in result.unresolved]
|
| 261 |
+
result.hitl_question = (
|
| 262 |
+
"Uzupełnij brakujące dane wymagane do wniosku: " + "; ".join(focus[:5]) + "."
|
| 263 |
+
)
|
| 264 |
+
return result
|
| 265 |
+
|
| 266 |
+
def resolve_field(
|
| 267 |
+
self,
|
| 268 |
+
question: str,
|
| 269 |
+
context_text: str,
|
| 270 |
+
allow_research: bool = True,
|
| 271 |
+
known_facts: dict[str, str] | None = None,
|
| 272 |
+
) -> str | None:
|
| 273 |
+
"""
|
| 274 |
+
Próbuje automatycznie odpowiedzieć na pojedyncze pytanie o brakujące dane,
|
| 275 |
+
korzystając z rejestrów (GUS/REGON + KRS wykryte z kontekstu) oraz Research
|
| 276 |
+
Agenta. Zwraca tekst odpowiedzi lub None (gdy wymaga człowieka).
|
| 277 |
+
|
| 278 |
+
Ta metoda enkapsuluje logikę auto-healingu wcześniej wtopioną w
|
| 279 |
+
``generator_agent.resolve_missing_data``.
|
| 280 |
+
|
| 281 |
+
``known_facts`` to opcjonalna, serializowalna mapa label->wartość
|
| 282 |
+
(np. z ``ProjectContext.to_facts_dict()`` przenoszona przez stan
|
| 283 |
+
LangGraph). Gdy podana, jest używana priorytetowo do deterministycznego
|
| 284 |
+
dopasowania pola po etykiecie — PRZED fallbackiem regex/GUS/KRS/OSINT.
|
| 285 |
+
Backward-compatible: domyślnie None → dotychczasowe zachowanie.
|
| 286 |
+
"""
|
| 287 |
+
if not question:
|
| 288 |
+
return None
|
| 289 |
+
|
| 290 |
+
# 0) Deterministyczne rozwiązanie z jawnie przekazanych faktów (priorytet).
|
| 291 |
+
if known_facts:
|
| 292 |
+
facts = {str(k).lower(): str(v) for k, v in known_facts.items() if v}
|
| 293 |
+
matched = self._match_fact(question, facts)
|
| 294 |
+
if matched:
|
| 295 |
+
return matched
|
| 296 |
+
|
| 297 |
+
question_lower = question.lower()
|
| 298 |
+
auto_answer = ""
|
| 299 |
+
|
| 300 |
+
# 1) Wykryj NIP / KRS w kontekście
|
| 301 |
+
nip_match = re.search(r"NIP[:\s]*(\d{10})", context_text or "", re.IGNORECASE)
|
| 302 |
+
nip = nip_match.group(1) if nip_match else None
|
| 303 |
+
krs_match = re.search(r"KRS[:\s]*(\d{10})", context_text or "", re.IGNORECASE)
|
| 304 |
+
krs = krs_match.group(1) if krs_match else None
|
| 305 |
+
|
| 306 |
+
# 2) Rejestr GUS/REGON
|
| 307 |
+
if nip:
|
| 308 |
+
try:
|
| 309 |
+
import json
|
| 310 |
+
from tools.company_search import fetch_regon_data
|
| 311 |
+
|
| 312 |
+
regon_data = fetch_regon_data(nip)
|
| 313 |
+
if regon_data:
|
| 314 |
+
auto_answer += f"Z bazy GUS/REGON (NIP {nip}): {json.dumps(regon_data, ensure_ascii=False)}\n"
|
| 315 |
+
if not krs and isinstance(regon_data, dict):
|
| 316 |
+
krs = regon_data.get("krs") or regon_data.get("numerKRS")
|
| 317 |
+
except Exception as e:
|
| 318 |
+
logger.warning(f"[AutoFill] Błąd fetch_regon_data: {e}")
|
| 319 |
+
|
| 320 |
+
# 3) Odpis KRS dla pytań strukturalnych
|
| 321 |
+
structural_keywords = [
|
| 322 |
+
"adres", "forma prawna", "wspólnik", "udział", "kapitał", "zarząd",
|
| 323 |
+
"reprezent", "rejestracj", "krs", "osoba", "data rejestracji",
|
| 324 |
+
]
|
| 325 |
+
if any(kw in question_lower for kw in structural_keywords) and krs:
|
| 326 |
+
try:
|
| 327 |
+
import json
|
| 328 |
+
from integrations.krs_client import KRSClient
|
| 329 |
+
|
| 330 |
+
odpis = KRSClient.get_odpis_aktualny(str(krs))
|
| 331 |
+
if odpis:
|
| 332 |
+
relations = KRSClient.extract_graph_relations(odpis)
|
| 333 |
+
auto_answer += (
|
| 334 |
+
f"Z odpisu KRS (KRS {krs}):\n"
|
| 335 |
+
f"- Pełne dane rejestrowe + wspólnicy + zarząd: {json.dumps(relations, ensure_ascii=False)[:1500]}\n"
|
| 336 |
+
)
|
| 337 |
+
except Exception as e:
|
| 338 |
+
logger.warning(f"[AutoFill] Błąd pobierania odpisu KRS: {e}")
|
| 339 |
+
|
| 340 |
+
if auto_answer:
|
| 341 |
+
return auto_answer
|
| 342 |
+
|
| 343 |
+
# 4) Research Agent (OSINT) — soft-fail; nie blokuj/HITL zabijaniem workera
|
| 344 |
+
if allow_research and _research_allowed_by_env():
|
| 345 |
+
deep = self._research_fill(question, context_text)
|
| 346 |
+
if deep:
|
| 347 |
+
return deep
|
| 348 |
+
|
| 349 |
+
return None
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
autofill_agent = AutoFillAgent()
|
backend/agents/compliance_guardian.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import re
|
| 3 |
+
from typing import Dict, Any, Optional
|
| 4 |
+
from langchain_core.messages import AIMessage
|
| 5 |
+
from schemas import AgentState
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
# v5.0: Import Kruczkowski Compliance & Trap Agent + Citation Verifier (Faza 2/3)
|
| 10 |
+
try:
|
| 11 |
+
from core.search.regulation_engine import kruczkowski_trap_agent, citation_verifier
|
| 12 |
+
except Exception:
|
| 13 |
+
kruczkowski_trap_agent = None # type: ignore
|
| 14 |
+
citation_verifier = None # type: ignore
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def compliance_guardian_node(state: AgentState) -> Dict[str, Any]:
|
| 18 |
+
"""
|
| 19 |
+
Sprawdza, czy w state.messages nie pojawiły się zbyt wrażliwe dane (zgodność RODO).
|
| 20 |
+
W architekturze 2026 blokuje model przez wpięciem lub anonimizuje tekst.
|
| 21 |
+
|
| 22 |
+
v5.0 rozszerzenie: integruje Kruczkowski Compliance & Trap Agent dla wykrywania
|
| 23 |
+
pułapek grantowych (aid intensity, double financing, ineligible personnel etc.)
|
| 24 |
+
+ podstawowy Citation Verifier na kluczowych sekcjach.
|
| 25 |
+
"""
|
| 26 |
+
# Podstawowa RODO / sensitive data guard (zachowana)
|
| 27 |
+
is_safe = True
|
| 28 |
+
full_text = ""
|
| 29 |
+
|
| 30 |
+
# Enhanced strict RODO and sensitive data patterns
|
| 31 |
+
sensitive_patterns = [
|
| 32 |
+
r"(?i)hasło",
|
| 33 |
+
r"(?i)password",
|
| 34 |
+
r"(?i)pesel\s*:?\s*\d{11}",
|
| 35 |
+
r"(?i)nr\s+dowodu",
|
| 36 |
+
r"(?i)numer\s+dowodu",
|
| 37 |
+
r"(?i)karta\s+kredytowa",
|
| 38 |
+
r"\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b",
|
| 39 |
+
r"(?i)iban\s*[a-zA-Z]{2}[0-9]{26}"
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
for msg in state.messages:
|
| 43 |
+
text = msg.content.lower() if hasattr(msg, 'content') else str(msg).lower()
|
| 44 |
+
full_text += " " + text
|
| 45 |
+
|
| 46 |
+
for pattern in sensitive_patterns:
|
| 47 |
+
if re.search(pattern, text):
|
| 48 |
+
is_safe = False
|
| 49 |
+
break
|
| 50 |
+
if not is_safe:
|
| 51 |
+
break
|
| 52 |
+
|
| 53 |
+
if not is_safe:
|
| 54 |
+
return {
|
| 55 |
+
"messages": [
|
| 56 |
+
AIMessage(
|
| 57 |
+
content="[COMPLIANCE] Wykryto potencjalnie wrażliwe dane (PESEL/Hasła). Upewnij się, że zachowujesz zasady RODO."
|
| 58 |
+
)
|
| 59 |
+
],
|
| 60 |
+
"current_agent": "supervisor",
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
# === v5.0 Kruczkowski Trap + Citation checks (jeśli dostępne dane w state) ===
|
| 64 |
+
trap_report: Optional[Dict[str, Any]] = None
|
| 65 |
+
citation_score = None
|
| 66 |
+
|
| 67 |
+
program = getattr(state, 'program_name', None) or (state.profile.program if getattr(state, 'profile', None) else None) or "unknown"
|
| 68 |
+
|
| 69 |
+
# === v5.0 Temporal Graph densify: query Neo4j for law_change before compliance/trap/Verifier ===
|
| 70 |
+
law_change_signal: Optional[Dict[str, Any]] = None
|
| 71 |
+
try:
|
| 72 |
+
from core.search.regulation_engine import regulation_engine
|
| 73 |
+
law_change = regulation_engine.detect_regulation_change(program)
|
| 74 |
+
if law_change.get("changed"):
|
| 75 |
+
# Sygnał propagowany do dalszych agentów przez pole AgentState.law_change_signal.
|
| 76 |
+
law_change_signal = law_change
|
| 77 |
+
logger.info(f"[Compliance Guardian v5.0 Temporal] Law change detected via graph for {program}: {law_change.get('change_summary','')[:60]}")
|
| 78 |
+
except Exception:
|
| 79 |
+
pass
|
| 80 |
+
|
| 81 |
+
# Jeśli jest content do audytu — uruchom pełny trap detection
|
| 82 |
+
content_to_check = ""
|
| 83 |
+
if hasattr(state, 'verification_results') and state.verification_results:
|
| 84 |
+
content_to_check = str(state.verification_results.get("pending_doc_text", "")) or ""
|
| 85 |
+
if not content_to_check and hasattr(state, 'messages'):
|
| 86 |
+
content_to_check = full_text[:4000]
|
| 87 |
+
|
| 88 |
+
if content_to_check and len(content_to_check) > 120 and kruczkowski_trap_agent:
|
| 89 |
+
try:
|
| 90 |
+
msp_ctx = None
|
| 91 |
+
if hasattr(state, 'external_context') and state.external_context:
|
| 92 |
+
msp_ctx = state.external_context.get("msp_analysis")
|
| 93 |
+
trap_report = kruczkowski_trap_agent.detect_traps(
|
| 94 |
+
document_text=content_to_check,
|
| 95 |
+
program=program,
|
| 96 |
+
msp_context=msp_ctx
|
| 97 |
+
)
|
| 98 |
+
citation_score = trap_report.get("citation_verification", {}).get("overall_citation_score")
|
| 99 |
+
|
| 100 |
+
if trap_report.get("blocks_export_recommendation"):
|
| 101 |
+
logger.warning(f"[Compliance Guardian v5.0 Kruczkowski] HIGH RISK traps detected for {program}: {trap_report.get('num_traps')}")
|
| 102 |
+
except Exception as e:
|
| 103 |
+
logger.debug(f"[Compliance Guardian] Kruczkowski trap check skipped: {e}")
|
| 104 |
+
|
| 105 |
+
# Jeśli wykryto poważne pułapki — zwracamy ostrzeżenie do supervisor
|
| 106 |
+
if trap_report and trap_report.get("overall_trap_risk") in ("high", "critical"):
|
| 107 |
+
warning = f"[KRUCZKOWSKI COMPLIANCE & TRAP v5.0] Wykryto {trap_report['num_traps']} pułapek (ryzyko: {trap_report['overall_trap_risk']}). Citation grounding: {citation_score}. Zalecana korekta przed eksportem. Szczegóły w trap_report."
|
| 108 |
+
return {
|
| 109 |
+
"messages": [AIMessage(content=warning)],
|
| 110 |
+
"current_agent": "supervisor",
|
| 111 |
+
"compliance_trap_report": trap_report, # dla kolejnych nodów (pole w AgentState)
|
| 112 |
+
"law_change_signal": law_change_signal,
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
# Normalny przepływ + opcjonalny trap_report (nawet jeśli low risk)
|
| 116 |
+
result: Dict[str, Any] = {"current_agent": "supervisor"}
|
| 117 |
+
if trap_report:
|
| 118 |
+
result["compliance_trap_report"] = trap_report
|
| 119 |
+
if law_change_signal:
|
| 120 |
+
result["law_change_signal"] = law_change_signal
|
| 121 |
+
return result
|
| 122 |
+
|
| 123 |
+
def check_legal_updates(project_id: str, email: str, program_name: str) -> None:
|
| 124 |
+
"""
|
| 125 |
+
Faza 6: Moduł Compliance Guardian
|
| 126 |
+
Sprawdza zmiany w regulaminie danego naboru (np. na podstawie zapytań do grant_search_service)
|
| 127 |
+
i wysyła powiadomienie do użytkownika.
|
| 128 |
+
|
| 129 |
+
v5.0: Może dodatkowo uruchomić Kruczkowski Trap Agent na istniejących sekcjach projektu
|
| 130 |
+
przed wysłaniem alertu (jeśli snapshot się zmienił).
|
| 131 |
+
"""
|
| 132 |
+
logger.info(f"[Compliance Guardian] Rozpoczynam sprawdzanie zmian w prawie dla: {program_name}")
|
| 133 |
+
# Tu odbywałoby się odpytanie agregatora (np. EUR-Lex / PARP) czy data modyfikacji regulaminu jest nowsza niż data rozpoczęcia projektu.
|
| 134 |
+
|
| 135 |
+
# v5.0 hook: jeśli dostępny Kruczkowski — można by tu triggerować re-audit z citation check
|
| 136 |
+
if kruczkowski_trap_agent:
|
| 137 |
+
logger.info("[Compliance Guardian v5.0] Kruczkowski Trap Agent dostępny — w pełnej integracji uruchomi re-weryfikację po zmianie regulaminu.")
|
| 138 |
+
|
| 139 |
+
# Symulacja wysyłki maila przez Clerk / SendGrid:
|
| 140 |
+
logger.info(f"[Compliance Guardian] [MOCK EMAIL] Wysłano alert na adres {email}: Zmiany w regulaminie {program_name}!")
|
| 141 |
+
|
backend/agents/critic.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Dict, Any
|
| 2 |
+
from langchain_core.messages import AIMessage
|
| 3 |
+
from core.llm_router import get_llm
|
| 4 |
+
from schemas import AgentState, CriticFeedback
|
| 5 |
+
|
| 6 |
+
try:
|
| 7 |
+
from core.search.regulation_engine import kruczkowski_trap_agent
|
| 8 |
+
except Exception:
|
| 9 |
+
kruczkowski_trap_agent = None
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def critic_node(state: AgentState) -> Dict[str, Any]:
|
| 13 |
+
"""
|
| 14 |
+
Recenzent jakości tekstu biznesowego. Analizuje styl, perswazję oraz spójność merytoryczną.
|
| 15 |
+
Współpracuje z Wizardem. Odpowiada za Human-in-the-Loop weryfikacji.
|
| 16 |
+
"""
|
| 17 |
+
from langchain_core.callbacks import BaseCallbackHandler
|
| 18 |
+
import logging
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
class TokenTrackingCallback(BaseCallbackHandler):
|
| 23 |
+
def __init__(self, user_id: str):
|
| 24 |
+
self.user_id = user_id
|
| 25 |
+
self.total_tokens = 0
|
| 26 |
+
|
| 27 |
+
def on_llm_end(self, response, **kwargs):
|
| 28 |
+
try:
|
| 29 |
+
if hasattr(response, "llm_output") and response.llm_output:
|
| 30 |
+
token_usage = response.llm_output.get("token_usage", {})
|
| 31 |
+
tokens = token_usage.get("total_tokens", 0)
|
| 32 |
+
if tokens > 0:
|
| 33 |
+
self.total_tokens += tokens
|
| 34 |
+
from core.subscription.tracker import increment_tokens
|
| 35 |
+
if self.user_id and self.user_id != "anonymous":
|
| 36 |
+
increment_tokens(self.user_id, tokens, action_type="critic_draft")
|
| 37 |
+
except Exception as e:
|
| 38 |
+
logger.warning(f"Błąd zliczania tokenów w critic_node: {e}")
|
| 39 |
+
|
| 40 |
+
user_id = getattr(state, "user_id", "anonymous")
|
| 41 |
+
tracking_callback = TokenTrackingCallback(user_id=user_id)
|
| 42 |
+
llm = get_llm(task_type="critical", structured_output_schema=CriticFeedback, callbacks=[tracking_callback])
|
| 43 |
+
|
| 44 |
+
# Assuming Wizard's output is in the last AI Message or in document_versions
|
| 45 |
+
# UWAGA: wiadomości LangChain mają atrybut `.type` ("ai"/"human"/"system"),
|
| 46 |
+
# NIE `.role`. Wcześniejsze `getattr(msg,"role",...)` zawsze zwracało None,
|
| 47 |
+
# przez co krytyk oceniał ostatnią wiadomość (często wejście użytkownika).
|
| 48 |
+
last_text = ""
|
| 49 |
+
for msg in reversed(state.messages):
|
| 50 |
+
msg_type = getattr(msg, "type", None)
|
| 51 |
+
if getattr(msg, "content", None) and msg_type not in ("human", "system"):
|
| 52 |
+
from core.utils import safe_extract_text
|
| 53 |
+
|
| 54 |
+
last_text = safe_extract_text(msg.content)
|
| 55 |
+
break
|
| 56 |
+
|
| 57 |
+
if not last_text:
|
| 58 |
+
return {
|
| 59 |
+
"critic_evaluation": CriticFeedback(
|
| 60 |
+
is_approved=True, score=100, feedback="Brak tekstu do oceny.", severity="low", xai_justification="Brak tekstu do oceny.", cross_check_passed=True, inconsistencies_flagged=[]
|
| 61 |
+
)
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
if state.critic_iterations > state.max_critic_iterations:
|
| 65 |
+
return {
|
| 66 |
+
"critic_evaluation": CriticFeedback(
|
| 67 |
+
is_approved=True,
|
| 68 |
+
score=100,
|
| 69 |
+
feedback="Automatyczne zatwierdzenie - przekroczono limit iteracji poprawek.",
|
| 70 |
+
severity="low",
|
| 71 |
+
xai_justification="Limit iteracji osiągnięty. Wymuszone zatwierdzenie.",
|
| 72 |
+
cross_check_passed=True,
|
| 73 |
+
inconsistencies_flagged=[]
|
| 74 |
+
)
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
import re
|
| 78 |
+
|
| 79 |
+
# HARD VALIDATION CHECKS (Python Regex) BEFORE LLM
|
| 80 |
+
text_lower = last_text.lower()
|
| 81 |
+
|
| 82 |
+
# 1. Missing required program keywords (e.g., 'innowacj', 'przychód', depending on the module)
|
| 83 |
+
# If the text is substantial but lacks core program terminology:
|
| 84 |
+
if len(text_lower) > 100:
|
| 85 |
+
if not re.search(r'(innowacj|przychód|b\+r|badani|rozwoj|dofinansowan|projekt)', text_lower):
|
| 86 |
+
return {
|
| 87 |
+
"critic_evaluation": CriticFeedback(
|
| 88 |
+
is_approved=False,
|
| 89 |
+
score=0,
|
| 90 |
+
feedback="Twarda walidacja (Regex) odrzuciła tekst: Kompletny brak wymaganych słów kluczowych programu (np. 'innowacj', 'przychód').",
|
| 91 |
+
severity="high",
|
| 92 |
+
xai_justification="Hard validation logic failed: Missing mandatory program keywords before LLM call.",
|
| 93 |
+
cross_check_passed=False,
|
| 94 |
+
inconsistencies_flagged=["Brak krytycznych słów kluczowych programu."]
|
| 95 |
+
),
|
| 96 |
+
"critic_iterations": state.critic_iterations + 1,
|
| 97 |
+
"messages": [AIMessage(content="Twarda walidacja (Regex) odrzuciła tekst: Kompletny brak wymaganych słów kluczowych programu.")]
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
# 2. Budget sums mathematically incorrect or logically impossible (given RAG constraints)
|
| 101 |
+
# Check for negative amounts
|
| 102 |
+
budget_anomaly = re.search(r'(?i)(budżet|koszt|kwota|dofinansowanie)[\s=:]*-\s*\d+', last_text)
|
| 103 |
+
|
| 104 |
+
if budget_anomaly:
|
| 105 |
+
return {
|
| 106 |
+
"critic_evaluation": CriticFeedback(
|
| 107 |
+
is_approved=False,
|
| 108 |
+
score=0,
|
| 109 |
+
feedback="Twarda walidacja (Regex) odrzuciła tekst: Wykryto nielogiczne ujemne kwoty budżetowe.",
|
| 110 |
+
severity="high",
|
| 111 |
+
xai_justification="Hard validation logic failed: Negative budget sum detected.",
|
| 112 |
+
cross_check_passed=False,
|
| 113 |
+
inconsistencies_flagged=["Ujemne kwoty budżetowe."]
|
| 114 |
+
),
|
| 115 |
+
"critic_iterations": state.critic_iterations + 1,
|
| 116 |
+
"messages": [AIMessage(content="Twarda walidacja (Regex) odrzuciła tekst: Wykryto nielogiczne ujemne kwoty budżetowe.")]
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
# 3. KRUCZKOWSKI TRAP (Pułapki prawne dotacji)
|
| 120 |
+
if kruczkowski_trap_agent and hasattr(kruczkowski_trap_agent, "detect_traps"):
|
| 121 |
+
try:
|
| 122 |
+
program_name = state.program_name or "dotacje"
|
| 123 |
+
trap_result = kruczkowski_trap_agent.detect_traps(last_text, program_name)
|
| 124 |
+
if trap_result and trap_result.get("risk_level") in ["high", "critical"]:
|
| 125 |
+
# detect_traps zwraca listę dictów pułapek — wyciągamy czytelne etykiety.
|
| 126 |
+
raw_traps = trap_result.get("traps", []) or []
|
| 127 |
+
trap_labels = []
|
| 128 |
+
for t in raw_traps:
|
| 129 |
+
if isinstance(t, dict):
|
| 130 |
+
label = t.get("description") or t.get("trap") or t.get("code")
|
| 131 |
+
else:
|
| 132 |
+
label = str(t)
|
| 133 |
+
if label:
|
| 134 |
+
trap_labels.append(str(label))
|
| 135 |
+
traps_found = ", ".join(trap_labels)
|
| 136 |
+
return {
|
| 137 |
+
"critic_evaluation": CriticFeedback(
|
| 138 |
+
is_approved=False,
|
| 139 |
+
score=10,
|
| 140 |
+
feedback=f"Wykryto pułapki Kruczkowskiego: {traps_found}. Tekst zagraża kwalifikowalności wniosku.",
|
| 141 |
+
severity="critical",
|
| 142 |
+
xai_justification=f"Odrzucono ze względu na pułapki prawne: {traps_found}",
|
| 143 |
+
cross_check_passed=False,
|
| 144 |
+
inconsistencies_flagged=trap_labels
|
| 145 |
+
),
|
| 146 |
+
"critic_iterations": state.critic_iterations + 1,
|
| 147 |
+
"messages": [AIMessage(content=f"Wykryto krytyczne błędy formalne (Pułapka Kruczkowskiego): {traps_found}.")]
|
| 148 |
+
}
|
| 149 |
+
except Exception as e:
|
| 150 |
+
pass # Fallback to LLM if trap agent fails
|
| 151 |
+
|
| 152 |
+
prompt = f"""
|
| 153 |
+
Jesteś rygorystycznym, ale pragmatycznym Recenzentem wniosków o dofinansowanie (RedTeamCritic).
|
| 154 |
+
Przeanalizuj poniższy fragment wniosku wygenerowany przez asystenta AI pod kątem MERYTORYCZNYM i ZGODNOŚCI Z ZASADAMI (np. moduły Ścieżki SMART, zasady DNSH, koszty kwalifikowalne).
|
| 155 |
+
|
| 156 |
+
Wymagamy RIGOROUS SCORING (surowej oceny punktowej w skali 0-100) oraz XAI JUSTIFICATION (wyjaśnialnej sztucznej inteligencji - dlaczego przyznano taką ocenę, w oparciu o konkretne przesłanki).
|
| 157 |
+
Przeprowadź ŚCISŁY CROSS-CHECK (np. czy kwoty budżetu pokrywają się z celami projektu i harmonogramem, czy zadania nie wykluczają się nawzajem).
|
| 158 |
+
Wypisz wszystkie znalezione NIESPÓJNOŚCI w polu inconsistencies_flagged.
|
| 159 |
+
|
| 160 |
+
ODRZUĆ TEKST (is_approved=False), jeśli wystąpi JAKAKOLWIEK z poniższych wad:
|
| 161 |
+
1. Błędy merytoryczne (halucynacje dotyczące zasad naboru, błędne opisy modułów takich jak B+R, Zazielenienie, Cyfryzacja).
|
| 162 |
+
2. Wprowadzenie kosztów w oczywisty sposób niekwalifikowalnych w danym module.
|
| 163 |
+
3. Kompletny brak logiki biznesowej, zaprzeczanie samemu sobie lub generowanie "wodolejstwa" zamiast wymogów dotacyjnych.
|
| 164 |
+
4. Niespójności w weryfikacji krzyżowej (np. budżet vs harmonogram). Wtedy ustaw cross_check_passed na false.
|
| 165 |
+
|
| 166 |
+
ZAAKCEPTUJ TEKST (is_approved=True) w pozostałych przypadkach, tj. gdy warstwa merytoryczna jest poprawna. Jeśli widzisz tylko drobne błędy stylistyczne, zaakceptuj wniosek (is_approved=True), a sugestie wpisz w polu feedback, obniżając delikatnie 'score'.
|
| 167 |
+
|
| 168 |
+
ABSOLUTNIE ZABRONIONE JEST odrzucanie tekstu (zwróć is_approved=True) TYLKO z powodu:
|
| 169 |
+
- Obecności znaczników np. [UZUPEŁNIJ: ...], [BRAK DANYCH] (są one wstawiane celowo!).
|
| 170 |
+
- Braku specyficznych danych o firmie, jeśli są zastąpione markerami.
|
| 171 |
+
|
| 172 |
+
Odpowiadaj ZAWSZE I WYŁĄCZNIE w języku polskim.
|
| 173 |
+
|
| 174 |
+
Tekst do sprawdzenia:
|
| 175 |
+
{last_text}
|
| 176 |
+
"""
|
| 177 |
+
|
| 178 |
+
try:
|
| 179 |
+
feedback: CriticFeedback = llm.invoke(prompt)
|
| 180 |
+
|
| 181 |
+
try:
|
| 182 |
+
from core.audit_logger import audit_log
|
| 183 |
+
|
| 184 |
+
audit_log(
|
| 185 |
+
"CRITIC",
|
| 186 |
+
f"Zakończono analizę. Is Approved: {feedback.is_approved}, Score: {feedback.score}, Severity: {feedback.severity}, Cross-Check: {feedback.cross_check_passed}",
|
| 187 |
+
)
|
| 188 |
+
except Exception:
|
| 189 |
+
pass # pre-cautionary try-except if logger is not ready yet
|
| 190 |
+
|
| 191 |
+
return {
|
| 192 |
+
"critic_evaluation": feedback,
|
| 193 |
+
"critic_iterations": state.critic_iterations + 1,
|
| 194 |
+
"messages": [AIMessage(content=feedback.feedback)]
|
| 195 |
+
if not feedback.is_approved
|
| 196 |
+
else [],
|
| 197 |
+
}
|
| 198 |
+
except Exception as e:
|
| 199 |
+
# FAIL-CLOSED: awaria walidacji krytyka NIE może oznaczać auto-akceptacji.
|
| 200 |
+
# Oznaczamy jako niezaakceptowane i wymagające ręcznej weryfikacji.
|
| 201 |
+
return {
|
| 202 |
+
"critic_evaluation": CriticFeedback(
|
| 203 |
+
is_approved=False,
|
| 204 |
+
score=0,
|
| 205 |
+
feedback=f"Awaria walidacji krytyka ({str(e)}) — sekcja NIEZWERYFIKOWANA, wymagana ręczna weryfikacja.",
|
| 206 |
+
severity="critical",
|
| 207 |
+
xai_justification="Fallback fail-closed: błąd mechanizmu oceny, brak potwierdzenia jakości.",
|
| 208 |
+
cross_check_passed=False,
|
| 209 |
+
inconsistencies_flagged=["Awaria mechanizmu krytyka — brak weryfikacji."]
|
| 210 |
+
),
|
| 211 |
+
"critic_iterations": state.critic_iterations + 1,
|
| 212 |
+
}
|
backend/agents/document_gap_analyzer.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Dict, Any, List
|
| 2 |
+
from langchain_core.messages import AIMessage
|
| 3 |
+
from core.llm_router import get_llm
|
| 4 |
+
from core.safe_mode import SAFE_MODE_MARKER
|
| 5 |
+
from schemas import AgentState
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _collect_structured_gaps(state: AgentState) -> List[str]:
|
| 9 |
+
gaps: List[str] = []
|
| 10 |
+
bb = state.blackboard or {}
|
| 11 |
+
|
| 12 |
+
if bb.get("data_gaps"):
|
| 13 |
+
gaps.extend(bb["data_gaps"])
|
| 14 |
+
|
| 15 |
+
enrichment = bb.get("registry_enrichment") or {}
|
| 16 |
+
if enrichment.get("gaps"):
|
| 17 |
+
for g in enrichment["gaps"]:
|
| 18 |
+
if g not in gaps:
|
| 19 |
+
gaps.append(g)
|
| 20 |
+
|
| 21 |
+
if not state.profile:
|
| 22 |
+
gaps.append("Brak profilu firmy (NIP/GUS).")
|
| 23 |
+
elif state.profile:
|
| 24 |
+
if not getattr(state.profile, "financials", None):
|
| 25 |
+
gaps.append("Brak danych finansowych w profilu.")
|
| 26 |
+
else:
|
| 27 |
+
fin = state.profile.financials
|
| 28 |
+
if getattr(fin, "revenue", 0) <= 0:
|
| 29 |
+
gaps.append("Brak udokumentowanych przychodów.")
|
| 30 |
+
if not getattr(state.profile, "pkd_codes", None):
|
| 31 |
+
gaps.append("Brak kodów PKD.")
|
| 32 |
+
|
| 33 |
+
return gaps
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def document_gap_analyzer_node(state: AgentState) -> Dict[str, Any]:
|
| 37 |
+
"""
|
| 38 |
+
Analizuje profil i załączniki — wykazuje braki wymagane do wniosku dotacyjnego.
|
| 39 |
+
"""
|
| 40 |
+
gaps = _collect_structured_gaps(state)
|
| 41 |
+
|
| 42 |
+
if gaps:
|
| 43 |
+
gap_result = "\n".join(f"- {g}" for g in gaps)
|
| 44 |
+
gap_result += f"\n\nGenerator użyje znacznika {SAFE_MODE_MARKER} w odpowiednich sekcjach."
|
| 45 |
+
else:
|
| 46 |
+
llm = get_llm(task_type="standard")
|
| 47 |
+
prompt_context = "Profil kompletny według rejestru."
|
| 48 |
+
if state.profile and state.profile.financials:
|
| 49 |
+
prompt_context = f"Dane finansowe: {state.profile.financials}"
|
| 50 |
+
prompt = f"""
|
| 51 |
+
Jesteś Document Gap Analyzerem. Sprawdź, czego brakuje do wniosku dotacyjnego.
|
| 52 |
+
Zwróć listę wypunktowaną. Pisz po polsku.
|
| 53 |
+
Kontekst: {prompt_context}
|
| 54 |
+
"""
|
| 55 |
+
response = llm.invoke(prompt)
|
| 56 |
+
from core.utils import safe_extract_text
|
| 57 |
+
gap_result = safe_extract_text(response.content)
|
| 58 |
+
|
| 59 |
+
bb = dict(state.blackboard or {})
|
| 60 |
+
bb["data_gaps"] = gaps or bb.get("data_gaps", [])
|
| 61 |
+
bb["gap_analysis_done"] = True
|
| 62 |
+
|
| 63 |
+
return {
|
| 64 |
+
"messages": [
|
| 65 |
+
AIMessage(content=f"[GAP ANALYZER] Znalazłem następujące braki:\n{gap_result}")
|
| 66 |
+
],
|
| 67 |
+
"blackboard": bb,
|
| 68 |
+
"current_agent": "supervisor",
|
| 69 |
+
}
|
backend/agents/evaluator.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field
|
| 2 |
+
from typing import Literal
|
| 3 |
+
from core.llm_router import get_llm
|
| 4 |
+
from langchain_core.prompts import PromptTemplate
|
| 5 |
+
from rag_pipeline import get_hybrid_retriever, rerank_documents
|
| 6 |
+
import logging
|
| 7 |
+
from tenacity import retry, stop_after_attempt, wait_exponential
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class ExpenseEvaluationResponse(BaseModel):
|
| 13 |
+
czy_wydatek_kwalifikowalny: bool = Field(
|
| 14 |
+
description="Zwróć True jeśli wydatek jest w 100% zgodny z regulaminem i wytycznymi programu (kwalifikowalny)."
|
| 15 |
+
)
|
| 16 |
+
uzasadnienie_prawne: str = Field(
|
| 17 |
+
description="Cytat lub konkretne odwołanie do regulaminu uzasadniające kwalifikowalność lub jej brak."
|
| 18 |
+
)
|
| 19 |
+
kategoria_badan: Literal[
|
| 20 |
+
"badania przemysłowe",
|
| 21 |
+
"prace rozwojowe",
|
| 22 |
+
"prace przedwdrożeniowe",
|
| 23 |
+
"brak/nie dotyczy",
|
| 24 |
+
] = Field(
|
| 25 |
+
description="Wybierz do jakiej kategorii zgodnie z polskim/unijnym prawem należy ten wydatek. Wybierz 'brak/nie dotyczy' tylko jeśli wydatek jest całkowicie poza B+R."
|
| 26 |
+
)
|
| 27 |
+
intensywnosc_pomocy: float = Field(
|
| 28 |
+
description="Zwróć w formie wartości zmiennoprzecinkowej np. 0.50 (co oznacza 50%), 0.80 (co oznacza 80%) bazując na wielkości firmy i rodzaju badań. 0.0 oznacza wydatek niekwalifikowalny."
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def evaluate_project_expense(
|
| 33 |
+
expense_description: str,
|
| 34 |
+
expense_amount: float,
|
| 35 |
+
project_title: str,
|
| 36 |
+
program_name: str,
|
| 37 |
+
company_size: str,
|
| 38 |
+
tenant_id: str = None,
|
| 39 |
+
) -> ExpenseEvaluationResponse:
|
| 40 |
+
"""
|
| 41 |
+
Agent ds. Oceny Kwalifikowalności (FAZA 4).
|
| 42 |
+
Wymusza twarde, ustrukturyzowane ramy JSON za pomocą Pydantic.
|
| 43 |
+
Opiera się na wiedzy RAG dotyczącej wybranego programu.
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
# Próba załadowania kontekstu z RAG - Hard Filtering na aktualną perspektywę
|
| 47 |
+
# Domyślnie wyszukujemy tylko w najnowszej perspektywie (FAZA 3, zapobieganie aplikacji starych przepisów)
|
| 48 |
+
hard_filter = {"rok_perspektywy": {"$eq": "2021-2027"}}
|
| 49 |
+
if program_name:
|
| 50 |
+
# Operator $and dla Pinecone Vector Store
|
| 51 |
+
hard_filter = {
|
| 52 |
+
"$and": [
|
| 53 |
+
{"program_name": {"$eq": program_name}},
|
| 54 |
+
{"rok_perspektywy": {"$eq": "2021-2027"}},
|
| 55 |
+
]
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
context_text = "Brak specyficznego regulaminu programu w bazie."
|
| 59 |
+
try:
|
| 60 |
+
retriever = get_hybrid_retriever(
|
| 61 |
+
k=10, metadata_filter=hard_filter, namespace=tenant_id
|
| 62 |
+
)
|
| 63 |
+
if retriever:
|
| 64 |
+
query_for_rag = f"kwalifikowalność wydatku badania kategoria intensywność dotacji pomoc publiczna: {expense_description}"
|
| 65 |
+
docs = retriever.invoke(query_for_rag)
|
| 66 |
+
reranked_docs = rerank_documents(query_for_rag, docs, top_n=4)
|
| 67 |
+
context_text = "\n\n".join(
|
| 68 |
+
[
|
| 69 |
+
f"[ŹRÓDŁO: {d.metadata.get('source', 'Brak')}]: {d.page_content}"
|
| 70 |
+
for d in reranked_docs
|
| 71 |
+
]
|
| 72 |
+
)
|
| 73 |
+
except Exception as e:
|
| 74 |
+
logger.error(f"[ExpenseEvaluator] Error fetching RAG context: {str(e)}")
|
| 75 |
+
|
| 76 |
+
template = """
|
| 77 |
+
Jesteś Głównym Prawnikiem i Audytorem Dotacyjnym oceniającym kwalifikowalność wydatków.
|
| 78 |
+
Oceniasz pojedynczy wydatek dla projektu w ramach programu: {program_name}.
|
| 79 |
+
Wielkość przedsiębiorstwa wnioskodawcy: {company_size}.
|
| 80 |
+
|
| 81 |
+
Opis wydatku do weryfikacji:
|
| 82 |
+
"{expense_description}" (Kwota: {expense_amount} PLN)
|
| 83 |
+
|
| 84 |
+
Kontekst z regulaminów z bazy wiedzy:
|
| 85 |
+
--------------------------------------------------
|
| 86 |
+
{context}
|
| 87 |
+
--------------------------------------------------
|
| 88 |
+
|
| 89 |
+
Zasady:
|
| 90 |
+
1. Przeanalizuj czy podany wydatek kwalifikuje się do objęcia wsparciem zgodnie z bazą wiedzy.
|
| 91 |
+
2. Określ kategorię badań dla wydatku, zgodnie z definicjami (badania przemysłowe, prace rozwojowe, przedwdrożeniowe).
|
| 92 |
+
3. Jeśli wydatek jest kwalifikowalny, przypisz prawidłową intensywność pomocy (zazwyczaj mniejszy procent dla prac rozwojowych/dużych firm, większy dla badań przemysłowych/MŚP).
|
| 93 |
+
4. Podaj bardzo precyzyjne uzasadnienie prawne odnoszące się do regulaminu.
|
| 94 |
+
"""
|
| 95 |
+
|
| 96 |
+
prompt = PromptTemplate.from_template(template)
|
| 97 |
+
|
| 98 |
+
# LLM z typowaniem - GPT-4o jest dużo lepszy do takich zadań analitycznych
|
| 99 |
+
structured_llm = get_llm(
|
| 100 |
+
task_type="legal_audit", structured_output_schema=ExpenseEvaluationResponse
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
chain = prompt | structured_llm
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
@retry(
|
| 107 |
+
stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10)
|
| 108 |
+
)
|
| 109 |
+
def _invoke_chain():
|
| 110 |
+
result = chain.invoke(
|
| 111 |
+
{
|
| 112 |
+
"program_name": program_name or "Ogólne zasady dotacji B+R",
|
| 113 |
+
"company_size": company_size or "MŚP (nieokreślona wielkość)",
|
| 114 |
+
"expense_description": expense_description,
|
| 115 |
+
"expense_amount": expense_amount,
|
| 116 |
+
"context": context_text,
|
| 117 |
+
}
|
| 118 |
+
)
|
| 119 |
+
if not result.uzasadnienie_prawne or len(result.uzasadnienie_prawne.strip()) < 10:
|
| 120 |
+
raise ValueError("Brak wystarczającego uzasadnienia prawnego.")
|
| 121 |
+
if not (0.0 <= result.intensywnosc_pomocy <= 1.0):
|
| 122 |
+
raise ValueError("Intensywność pomocy poza zakresem 0.0 - 1.0.")
|
| 123 |
+
return result
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
result = _invoke_chain()
|
| 127 |
+
|
| 128 |
+
return result
|
backend/agents/finance_agent.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from typing import Optional, Tuple, List
|
| 3 |
+
from pydantic import BaseModel, Field
|
| 4 |
+
|
| 5 |
+
from core.llm_router import get_llm
|
| 6 |
+
from langchain_core.messages import SystemMessage, HumanMessage
|
| 7 |
+
from core.utils import extract_markdown_and_sanitize
|
| 8 |
+
from agents.helpers import ANTI_HALLUCINATION_PROMPT
|
| 9 |
+
from tenacity import retry, stop_after_attempt, wait_exponential
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
class CostItem(BaseModel):
|
| 14 |
+
name: str = Field(description="Krótka nazwa kosztu (np. Zakup wtryskarki, Wynagrodzenia B+R)")
|
| 15 |
+
category: str = Field(description="Kategoria: CAPEX (środki trwałe) lub OPEX (koszty operacyjne)")
|
| 16 |
+
amount_pln: float = Field(description="Kwota netto w PLN (bez przecinków, np. 150000.00)")
|
| 17 |
+
is_eligible: bool = Field(description="Czy wydatek jest w 100% kwalifikowalny zgodnie z regulaminem?")
|
| 18 |
+
|
| 19 |
+
class FinancialData(BaseModel):
|
| 20 |
+
costs: List[CostItem] = Field(description="Lista zidentyfikowanych kosztów z opisu projektu lub logiki branżowej")
|
| 21 |
+
revenue_projection_year_1: float = Field(description="Prognoza przychodów w PLN (rok 1 po zakończeniu)")
|
| 22 |
+
revenue_projection_year_3: float = Field(description="Prognoza przychodów w PLN (rok 3 po zakończeniu)")
|
| 23 |
+
discount_rate_percent: float = Field(description="Założona stopa dyskontowa w % (np. 8.0)")
|
| 24 |
+
missing_data_question: Optional[str] = Field(None, description="Jakich danych najbardziej brakuje?")
|
| 25 |
+
reasoning_explanation: str = Field(..., description="XAI: Krótko uzasadnij kwalifikowalność wymienionych kosztów na bazie dostarczonych reguł z RAG.")
|
| 26 |
+
|
| 27 |
+
class FinanceAgent:
|
| 28 |
+
"""
|
| 29 |
+
Hybrydowy Agent Finansowy (Opcja A - Twarda logika + LLM).
|
| 30 |
+
LLM identyfikuje z tekstu pozycje kosztowe i orzeka o kwalifikowalności.
|
| 31 |
+
Python zlicza sumy, generuje tabele Markdown i wylicza podstawowe wskaźniki (bez ryzyka halucynacji matematycznych).
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
def __init__(self):
|
| 35 |
+
self.llm = get_llm(task_type="critical", structured_output_schema=FinancialData)
|
| 36 |
+
|
| 37 |
+
def _generate_markdown_from_data(self, data: FinancialData, section_name: str) -> str:
|
| 38 |
+
md = f"### {section_name}\n\n"
|
| 39 |
+
md += "Na podstawie analizy dostarczonych danych wygenerowano poniższe zestawienie planowanych wydatków. "
|
| 40 |
+
md += "Zastosowano kategoryzację kosztów oraz zweryfikowano ich kwalifikowalność w oparciu o wytyczne.\n\n"
|
| 41 |
+
|
| 42 |
+
md += "#### 1. Zestawienie Kosztów Projektu\n\n"
|
| 43 |
+
md += "| Lp. | Kategoria | Nazwa wydatku | Kwota Netto (PLN) | Kwalifikowalny |\n"
|
| 44 |
+
md += "|---|---|---|---|---|\n"
|
| 45 |
+
|
| 46 |
+
total_capex = 0.0
|
| 47 |
+
total_opex = 0.0
|
| 48 |
+
total_eligible = 0.0
|
| 49 |
+
|
| 50 |
+
for idx, cost in enumerate(data.costs, 1):
|
| 51 |
+
elig_str = "✅ Tak" if cost.is_eligible else "❌ Nie"
|
| 52 |
+
md += f"| {idx} | **{cost.category}** | {cost.name} | {cost.amount_pln:,.2f} PLN | {elig_str} |\n"
|
| 53 |
+
|
| 54 |
+
if cost.category.upper() == "CAPEX":
|
| 55 |
+
total_capex += cost.amount_pln
|
| 56 |
+
else:
|
| 57 |
+
total_opex += cost.amount_pln
|
| 58 |
+
|
| 59 |
+
if cost.is_eligible:
|
| 60 |
+
total_eligible += cost.amount_pln
|
| 61 |
+
|
| 62 |
+
total_budget = total_capex + total_opex
|
| 63 |
+
|
| 64 |
+
md += f"| | | **SUMA CAŁKOWITA** | **{total_budget:,.2f} PLN** | |\n\n"
|
| 65 |
+
|
| 66 |
+
md += "#### 2. Struktura Finansowania\n\n"
|
| 67 |
+
md += f"- **Suma CAPEX (Wydatki majątkowe):** {total_capex:,.2f} PLN\n"
|
| 68 |
+
md += f"- **Suma OPEX (Koszty operacyjne):** {total_opex:,.2f} PLN\n"
|
| 69 |
+
md += f"- **Wydatki Kwalifikowalne łącznie:** {total_eligible:,.2f} PLN\n"
|
| 70 |
+
md += f"- **Wydatki Niekwalifikowalne:** {(total_budget - total_eligible):,.2f} PLN\n\n"
|
| 71 |
+
|
| 72 |
+
md += "#### 3. Prognozy i Wskaźniki\n\n"
|
| 73 |
+
md += f"- Prognozowany przychód (Rok 1): **{data.revenue_projection_year_1:,.2f} PLN**\n"
|
| 74 |
+
md += f"- Prognozowany przychód (Rok 3): **{data.revenue_projection_year_3:,.2f} PLN**\n"
|
| 75 |
+
md += f"- Stopa dyskonta do wyliczeń NPV: **{data.discount_rate_percent}%**\n\n"
|
| 76 |
+
|
| 77 |
+
md += f"<!-- XAI: {data.reasoning_explanation} -->\n"
|
| 78 |
+
return md
|
| 79 |
+
|
| 80 |
+
def draft_financial_section(self, document_type: str, section_name: str, project_desc: str, context: str) -> Tuple[str, Optional[str]]:
|
| 81 |
+
logger.info(f"[FinanceAgent Hybrid] Zlecono ekstrakcję finansową: {section_name}")
|
| 82 |
+
|
| 83 |
+
system_prompt = (
|
| 84 |
+
ANTI_HALLUCINATION_PROMPT + "\n\n"
|
| 85 |
+
"Jesteś precyzyjnym Ekstraktorem Danych Finansowych. Twoim jedynym zadaniem jest zidentyfikowanie w opisie projektu poszczególnych "
|
| 86 |
+
"planowanych wydatków, przypisanie ich do kategorii (CAPEX/OPEX) oraz ocena (w oparciu TYLKO o Kontekst RAG), czy dany wydatek "
|
| 87 |
+
"jest kosztom kwalifikowalnym w ramach wybranego programu dotacyjnego.\n\n"
|
| 88 |
+
"ZASADY:\n"
|
| 89 |
+
"1. WYCIĄGAJ konkretne kwoty z opisu. Jeśli nie ma podanych kwot, a projekt opiera się na np. zakupie serwerów, "
|
| 90 |
+
"NIE ZMYŚLAJ i NIE SZACUJ KWOT. Użyj wyraźnej zmiennej, np. 0.0 w polu kwoty, a w nazwie kosztu dodaj znacznik `[KWOTA_DO_UZUPEŁNIENIA]`.\n"
|
| 91 |
+
"2. NIE WOLNO Ci zmyślać nowych typów kosztów (np. maszyny rolniczej dla firmy IT).\n"
|
| 92 |
+
"3. Prognozę przychodów wylicz na bazie obecnych obrotów firmy (jeśli brak danych, ustaw 0.0 i zapisz w uzasadnieniu, że wymaga podania danych od klienta).\n"
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
human_content = f"Dokument: {document_type}\n\nKontekst Programu:\n{context}\n\nOpis Projektu:\n{project_desc}"
|
| 96 |
+
|
| 97 |
+
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
|
| 98 |
+
def _invoke_llm():
|
| 99 |
+
res = self.llm.invoke([
|
| 100 |
+
SystemMessage(content=system_prompt),
|
| 101 |
+
HumanMessage(content=human_content)
|
| 102 |
+
])
|
| 103 |
+
if not getattr(res, "costs", None) or len(res.costs) == 0:
|
| 104 |
+
# Nie rzucamy błędu - może to być autentyczny brak danych
|
| 105 |
+
if not res.missing_data_question:
|
| 106 |
+
res.missing_data_question = "W opisie projektu brakuje jakichkolwiek informacji o kosztach i wydatkach. Proszę o ich uzupełnienie."
|
| 107 |
+
res.costs = []
|
| 108 |
+
return res
|
| 109 |
+
|
| 110 |
+
try:
|
| 111 |
+
structured_data: FinancialData = _invoke_llm()
|
| 112 |
+
# Obliczenia matematyczne i generacja tabel (Twarda Logika Pythona)
|
| 113 |
+
markdown_content = self._generate_markdown_from_data(structured_data, section_name)
|
| 114 |
+
|
| 115 |
+
missing = structured_data.missing_data_question
|
| 116 |
+
return markdown_content, missing
|
| 117 |
+
|
| 118 |
+
except Exception as e:
|
| 119 |
+
logger.error(f"[FinanceAgent] Błąd Hybrydowy LLM: {e}")
|
| 120 |
+
return f"*(Błąd podczas analizy hybrydowej sekcji finansowej: {str(e)})*", None
|
| 121 |
+
|
| 122 |
+
finance_agent = FinanceAgent()
|
backend/agents/gap_analyzer.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gap analyzer node with context-bus aware clarifying questions."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Any, Dict, List
|
| 6 |
+
|
| 7 |
+
from langchain_core.messages import AIMessage
|
| 8 |
+
|
| 9 |
+
from agents.document_gap_analyzer import _collect_structured_gaps
|
| 10 |
+
from core.context.context_bus import filter_clarifying_questions
|
| 11 |
+
from schemas import AgentState
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _gap_to_question(gap: str) -> str | None:
|
| 15 |
+
g = (gap or "").lower()
|
| 16 |
+
if "pkd" in g:
|
| 17 |
+
return "Podaj główny kod PKD działalności objętej projektem."
|
| 18 |
+
if "nip" in g or "gus" in g or "profilu firmy" in g:
|
| 19 |
+
return "Podaj NIP firmy, aby pobrać dane z rejestru GUS."
|
| 20 |
+
if "opis" in g or "cel" in g:
|
| 21 |
+
return "Opisz cel projektu i planowane wydatki w 2–3 zdaniach."
|
| 22 |
+
if "finans" in g or "przychod" in g:
|
| 23 |
+
return "Podaj przychody firmy z ostatniego roku obrotowego."
|
| 24 |
+
if "wojew" in g or "region" in g:
|
| 25 |
+
return "Uzupełnij województwo realizacji projektu."
|
| 26 |
+
return f"Uzupełnij brakującą informację: {gap}"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def build_clarifying_questions(gaps: List[str], profile: dict | None) -> List[str]:
|
| 30 |
+
profile = profile or {}
|
| 31 |
+
company = dict(profile)
|
| 32 |
+
if hasattr(profile, "model_dump"):
|
| 33 |
+
company = profile.model_dump()
|
| 34 |
+
elif hasattr(profile, "dict"):
|
| 35 |
+
company = profile.dict()
|
| 36 |
+
|
| 37 |
+
questions: List[str] = []
|
| 38 |
+
for gap in gaps or []:
|
| 39 |
+
q = _gap_to_question(gap)
|
| 40 |
+
if q and q not in questions:
|
| 41 |
+
questions.append(q)
|
| 42 |
+
|
| 43 |
+
return filter_clarifying_questions(questions, company, gaps)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def gap_analyzer_node(state: AgentState) -> Dict[str, Any]:
|
| 47 |
+
gaps = _collect_structured_gaps(state)
|
| 48 |
+
profile_dict: dict = {}
|
| 49 |
+
if state.profile:
|
| 50 |
+
if hasattr(state.profile, "model_dump"):
|
| 51 |
+
profile_dict = state.profile.model_dump()
|
| 52 |
+
elif hasattr(state.profile, "dict"):
|
| 53 |
+
profile_dict = state.profile.dict()
|
| 54 |
+
else:
|
| 55 |
+
profile_dict = dict(state.profile)
|
| 56 |
+
|
| 57 |
+
clarifying = build_clarifying_questions(gaps, profile_dict)
|
| 58 |
+
|
| 59 |
+
bb = dict(state.blackboard or {})
|
| 60 |
+
bb["data_gaps"] = gaps
|
| 61 |
+
bb["gap_analysis_done"] = True
|
| 62 |
+
bb["clarifying_questions"] = clarifying
|
| 63 |
+
|
| 64 |
+
summary = "\n".join(f"- {g}" for g in gaps) if gaps else "Brak krytycznych braków danych."
|
| 65 |
+
return {
|
| 66 |
+
"messages": [
|
| 67 |
+
AIMessage(
|
| 68 |
+
content=f"[GAP ANALYZER] Analiza braków zakończona.\n{summary}"
|
| 69 |
+
)
|
| 70 |
+
],
|
| 71 |
+
"blackboard": bb,
|
| 72 |
+
"current_agent": "supervisor",
|
| 73 |
+
}
|
backend/agents/generator_agent.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
backend/agents/grant_research_agent.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LangGraph grant_research_agent — analiza zmian, enrichment, rekomendacje."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import logging
|
| 5 |
+
import os
|
| 6 |
+
from typing import Any, Dict, List, Optional, TypedDict
|
| 7 |
+
|
| 8 |
+
try:
|
| 9 |
+
from langgraph.graph import StateGraph, START, END
|
| 10 |
+
|
| 11 |
+
LANGGRAPH_AVAILABLE = True
|
| 12 |
+
except ImportError:
|
| 13 |
+
LANGGRAPH_AVAILABLE = False
|
| 14 |
+
StateGraph = START = END = None # type: ignore
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class GrantResearchState(TypedDict, total=False):
|
| 20 |
+
grant_id: str
|
| 21 |
+
grant_data: Dict[str, Any]
|
| 22 |
+
changes: List[Dict[str, Any]]
|
| 23 |
+
company_profile: Dict[str, Any]
|
| 24 |
+
change_analysis: str
|
| 25 |
+
enriched_metadata: Dict[str, Any]
|
| 26 |
+
change_summary: str
|
| 27 |
+
recommendations: List[Dict[str, Any]]
|
| 28 |
+
error: Optional[str]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def is_grant_research_agent_enabled() -> bool:
|
| 32 |
+
return os.environ.get("ENABLE_GRANT_RESEARCH_AGENT", "true").lower() == "true"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def analyze_changes_node(state: GrantResearchState) -> Dict[str, Any]:
|
| 36 |
+
changes = state.get("changes") or []
|
| 37 |
+
grant = state.get("grant_data") or {}
|
| 38 |
+
if not changes:
|
| 39 |
+
return {
|
| 40 |
+
"change_analysis": f"Brak wykrytych zmian dla programu '{grant.get('name', state.get('grant_id', ''))}'."
|
| 41 |
+
}
|
| 42 |
+
lines = []
|
| 43 |
+
for ch in changes[:8]:
|
| 44 |
+
lines.append(ch.get("change_summary") or f"{ch.get('field_name')}: {ch.get('old_value')} → {ch.get('new_value')}")
|
| 45 |
+
analysis = (
|
| 46 |
+
f"Wykryto {len(changes)} zmian w programie '{grant.get('name', '')}'. "
|
| 47 |
+
+ " ".join(lines[:5])
|
| 48 |
+
)
|
| 49 |
+
high_priority = [c for c in changes if c.get("field_name") in ("deadline", "status", "regulation_url")]
|
| 50 |
+
if high_priority:
|
| 51 |
+
analysis += f" Priorytet: {len(high_priority)} zmian wymaga weryfikacji (termin/regulamin/status)."
|
| 52 |
+
return {"change_analysis": analysis}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def enrich_metadata_node(state: GrantResearchState) -> Dict[str, Any]:
|
| 56 |
+
grant = dict(state.get("grant_data") or {})
|
| 57 |
+
from core.grants.completeness import compute_completeness
|
| 58 |
+
from core.grants.credibility import score_source_credibility
|
| 59 |
+
|
| 60 |
+
comp = compute_completeness(grant)
|
| 61 |
+
cred = score_source_credibility(grant)
|
| 62 |
+
enriched = {
|
| 63 |
+
"completeness_score": comp["completeness_score"],
|
| 64 |
+
"fields_missing": comp["fields_missing"],
|
| 65 |
+
"source_credibility_score": cred,
|
| 66 |
+
"catalog_hidden": grant.get("catalog_hidden", False),
|
| 67 |
+
"program_year": grant.get("program_year"),
|
| 68 |
+
"operator": grant.get("operator") or grant.get("program"),
|
| 69 |
+
}
|
| 70 |
+
if not grant.get("program_goals") and grant.get("description"):
|
| 71 |
+
enriched["program_goals_hint"] = (grant.get("description") or "")[:300]
|
| 72 |
+
return {"enriched_metadata": enriched}
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def summarize_changes_node(state: GrantResearchState) -> Dict[str, Any]:
|
| 76 |
+
analysis = state.get("change_analysis") or ""
|
| 77 |
+
enriched = state.get("enriched_metadata") or {}
|
| 78 |
+
grant_name = (state.get("grant_data") or {}).get("name", state.get("grant_id", ""))
|
| 79 |
+
summary = (
|
| 80 |
+
f"Program: {grant_name}. "
|
| 81 |
+
f"Kompletność danych: {enriched.get('completeness_score', 'N/A')}%. "
|
| 82 |
+
f"Wiarygodność źródła: {enriched.get('source_credibility_score', 'N/A')}. "
|
| 83 |
+
)
|
| 84 |
+
if analysis:
|
| 85 |
+
summary += f" Analiza zmian: {analysis[:400]}"
|
| 86 |
+
else:
|
| 87 |
+
summary += " Brak istotnych zmian od ostatniego snapshotu."
|
| 88 |
+
return {"change_summary": summary.strip()}
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def recommend_node(state: GrantResearchState) -> Dict[str, Any]:
|
| 92 |
+
company = state.get("company_profile") or {}
|
| 93 |
+
grant = state.get("grant_data") or {}
|
| 94 |
+
if not company:
|
| 95 |
+
return {"recommendations": []}
|
| 96 |
+
from core.grants.recommendations import score_grant_for_company
|
| 97 |
+
|
| 98 |
+
rec = score_grant_for_company(grant, company)
|
| 99 |
+
return {"recommendations": [rec]}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _run_sequential(state: GrantResearchState) -> GrantResearchState:
|
| 103 |
+
"""Fallback bez LangGraph — ta sama kolejność węzłów."""
|
| 104 |
+
out = dict(state)
|
| 105 |
+
for node_fn in (analyze_changes_node, enrich_metadata_node, summarize_changes_node, recommend_node):
|
| 106 |
+
patch = node_fn(out)
|
| 107 |
+
out.update(patch)
|
| 108 |
+
return out
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def build_grant_research_graph():
|
| 112 |
+
if not LANGGRAPH_AVAILABLE:
|
| 113 |
+
return None
|
| 114 |
+
graph = StateGraph(GrantResearchState)
|
| 115 |
+
graph.add_node("analyze_changes", analyze_changes_node)
|
| 116 |
+
graph.add_node("enrich_metadata", enrich_metadata_node)
|
| 117 |
+
graph.add_node("summarize", summarize_changes_node)
|
| 118 |
+
graph.add_node("recommend", recommend_node)
|
| 119 |
+
|
| 120 |
+
graph.add_edge(START, "analyze_changes")
|
| 121 |
+
graph.add_edge("analyze_changes", "enrich_metadata")
|
| 122 |
+
graph.add_edge("enrich_metadata", "summarize")
|
| 123 |
+
graph.add_edge("summarize", "recommend")
|
| 124 |
+
graph.add_edge("recommend", END)
|
| 125 |
+
return graph.compile()
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
_grant_research_graph = None
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def get_grant_research_graph():
|
| 132 |
+
global _grant_research_graph
|
| 133 |
+
if _grant_research_graph is None:
|
| 134 |
+
_grant_research_graph = build_grant_research_graph()
|
| 135 |
+
return _grant_research_graph
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def run_grant_research_agent(
|
| 139 |
+
grant_data: Dict[str, Any],
|
| 140 |
+
*,
|
| 141 |
+
changes: Optional[List[Dict[str, Any]]] = None,
|
| 142 |
+
company_profile: Optional[Dict[str, Any]] = None,
|
| 143 |
+
) -> Dict[str, Any]:
|
| 144 |
+
"""Ręczne lub automatyczne uruchomienie agenta."""
|
| 145 |
+
if not is_grant_research_agent_enabled():
|
| 146 |
+
return {"enabled": False, "error": "ENABLE_GRANT_RESEARCH_AGENT=false"}
|
| 147 |
+
|
| 148 |
+
initial: GrantResearchState = {
|
| 149 |
+
"grant_id": str(grant_data.get("id", "")),
|
| 150 |
+
"grant_data": grant_data,
|
| 151 |
+
"changes": changes or [],
|
| 152 |
+
"company_profile": company_profile or {},
|
| 153 |
+
}
|
| 154 |
+
graph = get_grant_research_graph()
|
| 155 |
+
if graph is not None:
|
| 156 |
+
result = graph.invoke(initial)
|
| 157 |
+
else:
|
| 158 |
+
result = _run_sequential(initial)
|
| 159 |
+
result["enabled"] = True
|
| 160 |
+
result["langgraph"] = LANGGRAPH_AVAILABLE
|
| 161 |
+
return result
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
async def run_grant_research_agent_async(**kwargs) -> Dict[str, Any]:
|
| 165 |
+
return run_grant_research_agent(**kwargs)
|
backend/agents/helpers.py
ADDED
|
@@ -0,0 +1,871 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from schemas import AgentState, CompanyProfile, CriticFeedback
|
| 2 |
+
from typing import Optional
|
| 3 |
+
from langchain_core.messages import HumanMessage, AIMessage
|
| 4 |
+
from langchain_core.prompts import PromptTemplate
|
| 5 |
+
from core.llm_router import get_llm
|
| 6 |
+
from rag_pipeline import get_hybrid_retriever, rerank_documents
|
| 7 |
+
from core.search.regulation_engine import regulation_engine
|
| 8 |
+
from core.trust.trust_scorer import compute_grant_trust_score
|
| 9 |
+
from agents.wizard import wizard_node
|
| 10 |
+
from agents.critic import critic_node
|
| 11 |
+
import json
|
| 12 |
+
import os
|
| 13 |
+
import time
|
| 14 |
+
from langsmith import traceable
|
| 15 |
+
from langchain_core.tracers.langchain import LangChainTracer
|
| 16 |
+
|
| 17 |
+
# Foundational observability for generation (errors, latency, quality signals for grounding/LLM)
|
| 18 |
+
from core.telemetry import metrics
|
| 19 |
+
|
| 20 |
+
# Włącz tracing LangSmith
|
| 21 |
+
os.environ["LANGCHAIN_TRACING_V2"] = "false"
|
| 22 |
+
os.environ["LANGCHAIN_PROJECT"] = "grantforge-production"
|
| 23 |
+
|
| 24 |
+
# Opcjonalnie – jeśli chcesz zobaczyć dokładne nazwy runów
|
| 25 |
+
tracer = LangChainTracer(project_name="grantforge-production")
|
| 26 |
+
|
| 27 |
+
ANTI_HALLUCINATION_PROMPT = """
|
| 28 |
+
BEZWZGLĘDNA ZASADA (ANTI-HALLUCINATION / GROUNDED GENERATION):
|
| 29 |
+
Jesteś surowym audytorem dotacyjnym. Masz bezwzględny zakaz korzystania z jakiejkolwiek wiedzy spoza dostarczonego kontekstu (baza wiedzy RAG / pliki projektu).
|
| 30 |
+
Jeśli informacja nie wynika wprost z podanych dokumentów lub metadanych – odpowiedz dokładnie: "Brak wystarczających informacji w aktualnych zasobach". Nie wolno Ci zgadywać kwot, terminów ani warunków kwalifikowalności.
|
| 31 |
+
[Explainable AI - XAI]: Zawsze jasno wskazuj, na jakiej podstawie opierasz dany wniosek (cytuj precyzyjnie załączony kontekst i regulaminy, pokazując ciąg przyczynowo-skutkowy).
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
# Enrich with GSD constitutional guard when package is available (no behavior regression if missing)
|
| 35 |
+
try:
|
| 36 |
+
from gsd.prompts.global_rules_prompts import ANTI_HALLUCINATION_GUARD as _GSD_AH
|
| 37 |
+
|
| 38 |
+
if _GSD_AH and _GSD_AH.strip() not in ANTI_HALLUCINATION_PROMPT:
|
| 39 |
+
ANTI_HALLUCINATION_PROMPT = ANTI_HALLUCINATION_PROMPT.rstrip() + "\n\n" + _GSD_AH.strip()
|
| 40 |
+
except Exception:
|
| 41 |
+
pass
|
| 42 |
+
|
| 43 |
+
# v5.0 incremental data quality helper (pragmatic, for generated content verification)
|
| 44 |
+
# Detects low-specificity / vague language common in poor grant drafts. Used in post-gen checks.
|
| 45 |
+
from functools import lru_cache
|
| 46 |
+
|
| 47 |
+
@lru_cache(maxsize=128)
|
| 48 |
+
def _compute_basic_generated_data_quality(text: str) -> int:
|
| 49 |
+
"""Cached for token optimization (pure heuristic, repeated calls in flows)."""
|
| 50 |
+
if not text or len(text) < 30:
|
| 51 |
+
return 20
|
| 52 |
+
t = text.lower()
|
| 53 |
+
score = 60
|
| 54 |
+
# Specificity signals (good)
|
| 55 |
+
if any(kw in t for kw in ["%", "zł", "euro", "tys.", "mln", "2026", "miesiąc", "m-c", "osób", "etat", "pkd", "nip"]):
|
| 56 |
+
score += 15
|
| 57 |
+
if any(kw in t for kw in ["zgodnie z §", "regulamin", "pkt ", "załącznik", "kryterium", "kwalifikowalny"]):
|
| 58 |
+
score += 12
|
| 59 |
+
# Vagueness penalties (bad for data quality)
|
| 60 |
+
vague = ["w miarę możliwości", "w zależności od", "prawdopodobnie", "w przybliżeniu", "można założyć", "warto rozważyć", "optymalnie", "w przyszłości"]
|
| 61 |
+
vagueness_hits = sum(1 for v in vague if v in t)
|
| 62 |
+
score -= min(25, vagueness_hits * 7)
|
| 63 |
+
# Length + structure heuristic
|
| 64 |
+
if len(text) > 800:
|
| 65 |
+
score += 8
|
| 66 |
+
if text.count("\n") > 4 or "- " in text:
|
| 67 |
+
score += 5
|
| 68 |
+
# Penalize very generic filler
|
| 69 |
+
if t.count("projekt polega na") > 1 or t.count("celem jest") > 2:
|
| 70 |
+
score -= 10
|
| 71 |
+
return max(25, min(95, int(score)))
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
@traceable(
|
| 75 |
+
run_type="chain",
|
| 76 |
+
name="generate_section",
|
| 77 |
+
tags=["rag_pipeline", "faithfulness_target"],
|
| 78 |
+
)
|
| 79 |
+
def generate_section_light(
|
| 80 |
+
section_type: str,
|
| 81 |
+
context: str,
|
| 82 |
+
external_context: dict = None,
|
| 83 |
+
program_name: str = None,
|
| 84 |
+
light_mode: bool = True, # token optimization: early short-circuit for very light use
|
| 85 |
+
) -> str:
|
| 86 |
+
"""
|
| 87 |
+
Lekka, stabilna ścieżka generowania sekcji.
|
| 88 |
+
Używana domyślnie w okienku asystenta projektu i jako fallback.
|
| 89 |
+
light_mode: skips heavy regulation calls for ultra-low token in some flows.
|
| 90 |
+
"""
|
| 91 |
+
from core.llm_router import get_llm
|
| 92 |
+
from langchain_core.messages import HumanMessage
|
| 93 |
+
import logging
|
| 94 |
+
logger = logging.getLogger(__name__)
|
| 95 |
+
|
| 96 |
+
if light_mode and not context:
|
| 97 |
+
context = f"Generuj treść do sekcji {section_type} na podstawie profilu firmy i regulaminu {program_name or ''}."
|
| 98 |
+
|
| 99 |
+
llm = get_llm(task_type="fast")
|
| 100 |
+
|
| 101 |
+
company = {}
|
| 102 |
+
context_block = ""
|
| 103 |
+
try:
|
| 104 |
+
if external_context and isinstance(external_context, dict):
|
| 105 |
+
company = external_context.get("company_data", {}) or {}
|
| 106 |
+
from core.context.project_context import build_project_context
|
| 107 |
+
|
| 108 |
+
ctx = build_project_context(
|
| 109 |
+
{"external_context": external_context, "program_name": program_name}
|
| 110 |
+
)
|
| 111 |
+
context_block = ctx.to_prompt_block(max_chars=3500)
|
| 112 |
+
except Exception:
|
| 113 |
+
company = external_context.get("company_data", {}) if external_context else {}
|
| 114 |
+
|
| 115 |
+
# Uwaga: żadne noty techniczne/telemetryczne nie są doklejane do treści sekcji
|
| 116 |
+
# (treść wniosku ma być czysta i po polsku). Sygnały jakości → metryki/traceability.
|
| 117 |
+
|
| 118 |
+
applicant_block = context_block or (
|
| 119 |
+
"Dane wnioskodawcy:\n"
|
| 120 |
+
f"Nazwa: {company.get('name', 'Wnioskodawca')}\n"
|
| 121 |
+
f"NIP: {company.get('nip', '')}\n"
|
| 122 |
+
f"Województwo: {company.get('voivodeship', '')}\n"
|
| 123 |
+
f"PKD: {', '.join(company.get('pkd', [])[:3]) if company.get('pkd') else ''}"
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
prompt = f"""Jesteś ekspertem przygotowującym wnioski o dofinansowanie.
|
| 127 |
+
|
| 128 |
+
Napisz konkretną, profesjonalną treść do sekcji: **{section_type}**
|
| 129 |
+
|
| 130 |
+
[ANTI-HALLUCINATION]: Opieraj się WYŁĄCZNIE na udostępnionych poniżej danych wnioskodawcy i programie. Nie wymyślaj dodatkowych liczb ani statystyk. Dla brakujących danych wstaw placeholder [DO WERYFIKACJI: opis]. Zastosuj najwyższej jakości formatowanie Markdown (nagłówki, listy).
|
| 131 |
+
|
| 132 |
+
Kontekst projektu:
|
| 133 |
+
{context[:4000] if context else "Brak szczegółowego opisu"}
|
| 134 |
+
|
| 135 |
+
{applicant_block}
|
| 136 |
+
|
| 137 |
+
Program: {program_name or 'wniosek dotacyjny'}
|
| 138 |
+
|
| 139 |
+
Pisz po polsku, merytorycznie, w stylu urzędowego wniosku. Bądź konkretny.
|
| 140 |
+
Uwzględnij podstawowe ugruntowanie w regulaminie jeśli dostępne (Regulation Engine v5.0)."""
|
| 141 |
+
|
| 142 |
+
last_error = None
|
| 143 |
+
for attempt in range(3):
|
| 144 |
+
try:
|
| 145 |
+
resp = llm.invoke([HumanMessage(content=prompt)])
|
| 146 |
+
content = resp.content if hasattr(resp, "content") else str(resp)
|
| 147 |
+
if content and len(content.strip()) > 20:
|
| 148 |
+
return content
|
| 149 |
+
except Exception as e:
|
| 150 |
+
last_error = str(e)
|
| 151 |
+
logger.warning(f"[generate_section_light] Próba {attempt+1}/3 nieudana: {e}")
|
| 152 |
+
|
| 153 |
+
# High-stability fallback: always return usable professional stub (never crash UI)
|
| 154 |
+
fallback = f"""### {section_type}
|
| 155 |
+
|
| 156 |
+
**Wnioskodawca:** {company.get('name', 'Wnioskodawca')}
|
| 157 |
+
**Program:** {program_name or 'wniosek dotacyjny'}
|
| 158 |
+
|
| 159 |
+
Treść sekcji wymaga uzupełnienia na podstawie szczegółowej analizy regulaminu i dokumentacji projektu.
|
| 160 |
+
|
| 161 |
+
[UZUPEŁNIĆ: Wstaw merytoryczną treść zgodną z kryteriami programu i danymi z GUS/KRS]
|
| 162 |
+
|
| 163 |
+
*Wygenerowano w trybie awaryjnym lekkim (stabilny fallback).*
|
| 164 |
+
"""
|
| 165 |
+
if last_error:
|
| 166 |
+
logger.error(f"[generate_section_light] Użyto fallbacku po błędach: {last_error}")
|
| 167 |
+
final_content = content if 'content' in locals() and content else fallback
|
| 168 |
+
|
| 169 |
+
# v5.0 incremental: Post-generation CitationVerifier + basic grounding check on generated content (for light path too)
|
| 170 |
+
# Stronger verification of generated text against regulations. Non-blocking, attaches note if program known.
|
| 171 |
+
try:
|
| 172 |
+
if program_name and isinstance(final_content, str) and len(final_content) > 80:
|
| 173 |
+
from core.search.regulation_engine import citation_verifier
|
| 174 |
+
if citation_verifier:
|
| 175 |
+
verif = citation_verifier.verify_text_citations(final_content[:4500], program_name, sample_claims=None)
|
| 176 |
+
gscore = verif.get("overall_citation_score", 0.0) if isinstance(verif, dict) else getattr(verif, "overall_citation_score", 0.0)
|
| 177 |
+
# Sygnał jakości trafia WYŁĄCZNIE do metryk — nie do treści wniosku.
|
| 178 |
+
metrics.record_quality_signal(
|
| 179 |
+
"generation_light_grounding", round(float(gscore or 0.0), 3),
|
| 180 |
+
{"program": program_name or "unknown"}
|
| 181 |
+
)
|
| 182 |
+
if gscore > 0.7:
|
| 183 |
+
metrics.increment("generation.light_grounding_good", tags={"program": program_name or "unknown"})
|
| 184 |
+
except Exception as _v5e:
|
| 185 |
+
logger.debug(f"[v5.0 light verif] skipped: {_v5e}")
|
| 186 |
+
return final_content
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def generate_section(
|
| 190 |
+
project_id: str,
|
| 191 |
+
section_type: str,
|
| 192 |
+
context: str,
|
| 193 |
+
external_context: dict = None,
|
| 194 |
+
program_name: str = None,
|
| 195 |
+
user_id: str = "",
|
| 196 |
+
) -> str:
|
| 197 |
+
"""
|
| 198 |
+
Wywołuje logikę Wizard z RAG tylko dla pojedynczej sekcji, bez przelotu przez cały Graf.
|
| 199 |
+
"""
|
| 200 |
+
from core.telemetry import telemetry
|
| 201 |
+
|
| 202 |
+
t0 = time.time()
|
| 203 |
+
metrics.increment("generation.generate_section_calls", tags={"section_type": section_type or "unknown", "has_program": str(bool(program_name))})
|
| 204 |
+
telemetry.log(
|
| 205 |
+
"INFO",
|
| 206 |
+
"Helpers",
|
| 207 |
+
f"Rozpoczynamy generowanie sekcji: {section_type}",
|
| 208 |
+
{"project_id": project_id},
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
company_data_str = ""
|
| 212 |
+
if external_context:
|
| 213 |
+
if (
|
| 214 |
+
"project_description" in external_context
|
| 215 |
+
and external_context["project_description"]
|
| 216 |
+
):
|
| 217 |
+
company_data_str += f"INFORMACJE OGÓLNE O PROJEKCIE (wpisane przez użytkownika):\n{external_context['project_description']}\n\n"
|
| 218 |
+
if (
|
| 219 |
+
"current_section_content" in external_context
|
| 220 |
+
and external_context["current_section_content"]
|
| 221 |
+
):
|
| 222 |
+
company_data_str += f"OBECNA TREŚĆ SEKCJI (Zastosuj ewentualne poprawki do tego tekstu, zachowując jego spójność):\n{external_context['current_section_content']}\n\n"
|
| 223 |
+
if "company_data" in external_context:
|
| 224 |
+
company_data_str += f"Dane z GUS (kontekst o firmie wnioskodawcy):\n{json.dumps(external_context['company_data'], indent=2, ensure_ascii=False)}\n\n"
|
| 225 |
+
if "resources" in external_context and external_context["resources"]:
|
| 226 |
+
company_data_str += "Zasoby projektu (dostarczone pliki):\n"
|
| 227 |
+
for res in external_context["resources"]:
|
| 228 |
+
# Limitujemy tekst wyciągnięty z pliku żeby nie wysadzić okna kontekstowego dla gigantycznych plików (opcjonalnie, ale dobra praktyka)
|
| 229 |
+
text_clip = res.get("extracted_text") or ""
|
| 230 |
+
if len(text_clip) > 5000:
|
| 231 |
+
text_clip = text_clip[:5000] + "... [UKRÓCONO]"
|
| 232 |
+
company_data_str += (
|
| 233 |
+
f"--- PLIK: {res.get('filename')} ---\n{text_clip}\n\n"
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
if company_data_str:
|
| 237 |
+
company_data_str += "INSTRUKCJA PRIORYTETU: Dane z sekcji company_data oraz lista resources mają najwyższy priorytet. Używaj ich zawsze jako głównego źródła informacji o firmie. Wiedza ogólna z RAG jest tylko pomocnicza.\n\n"
|
| 238 |
+
|
| 239 |
+
program_context = (
|
| 240 |
+
f"\n\nWAŻNE! PROJEKT DOTYCZY PROGRAMU:\n{program_name}\nBezwzględnie dostosuj narrację, słownictwo oraz rozłożenie akcentów we wniosku do specyfiki, wytycznych i głównego celu tego konkretnego programu. Unikaj żargonu z innych typów dotacji, chyba że wprost tu pasuje.\n"
|
| 241 |
+
if program_name
|
| 242 |
+
else ""
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
# === Faza 1 / Punkt 2 + Faza 3: Regulation Context + Structured Engine Rules ===
|
| 246 |
+
regulation_section_context = ""
|
| 247 |
+
if program_name:
|
| 248 |
+
try:
|
| 249 |
+
from core.search.regulation_context_provider import get_regulation_context_for_candidates, format_regulation_context_for_prompt
|
| 250 |
+
|
| 251 |
+
fake_candidates = [{"id": "current_section", "program": program_name, "name": section_type}]
|
| 252 |
+
reg_ctx = get_regulation_context_for_candidates(
|
| 253 |
+
fake_candidates,
|
| 254 |
+
project_description=context,
|
| 255 |
+
k_per_grant=3
|
| 256 |
+
)
|
| 257 |
+
regulation_section_context = "\n\n" + format_regulation_context_for_prompt(reg_ctx, "current_section")
|
| 258 |
+
|
| 259 |
+
# Nowe: Structured rules + eligibility checker z Engine (szczególnie ważne przy budżecie)
|
| 260 |
+
rules = regulation_engine.get_structured_rules_for_program(program_name)
|
| 261 |
+
if rules and rules.get("key_rules"):
|
| 262 |
+
regulation_section_context += "\n\nStrukturalne reguły z Regulation Engine:\n" + "\n".join(rules["key_rules"][:5])
|
| 263 |
+
|
| 264 |
+
# Trust Score context (Cycle 9)
|
| 265 |
+
try:
|
| 266 |
+
ext = external_context or {}
|
| 267 |
+
ts = compute_grant_trust_score({
|
| 268 |
+
"regulation_link_quality": ext.get("regulation_link_quality", "medium"),
|
| 269 |
+
"precise_regulation_url": ext.get("precise_regulation_url")
|
| 270 |
+
})
|
| 271 |
+
regulation_section_context += f"\n\n[Trust Score dla tego programu: {ts}/100 — im wyższy, tym bezpieczniej cytować regulamin]"
|
| 272 |
+
except Exception:
|
| 273 |
+
pass
|
| 274 |
+
|
| 275 |
+
# Bezpośrednie użycie checkera kosztów przy sekcjach budżetowych (aktywna integracja Faza 3)
|
| 276 |
+
if section_type in ["budget", "budget_details", "koszty", "montaż finansowy"] and context:
|
| 277 |
+
try:
|
| 278 |
+
eligibility = regulation_engine.check_cost_eligibility(program_name or "", context[:1500])
|
| 279 |
+
if eligibility.get("status") == "evaluated":
|
| 280 |
+
reg_text = eligibility.get('raw_response', '')
|
| 281 |
+
regulation_section_context += f"\n\nWeryfikacja kwalifikowalności kosztów (Regulation Engine):\n{reg_text}"
|
| 282 |
+
|
| 283 |
+
# Aktywne zachowanie: jeśli silnik widzi ryzyko — dajemy silną instrukcję generatorowi
|
| 284 |
+
if "Niekwalifikowalny" in reg_text or "Wymaga weryfikacji" in reg_text:
|
| 285 |
+
regulation_section_context += (
|
| 286 |
+
"\n\nWAŻNA INSTRUKCJA: Powyższa weryfikacja Regulation Engine wskazuje na potencjalne ryzyko niekwalifikowalności. "
|
| 287 |
+
"Bądź bardzo konserwatywny w opisie kosztów. Oznacz elementy wymagające potwierdzenia przez użytkownika. "
|
| 288 |
+
"Nie twierdź kategorycznie, że koszty są kwalifikowalne jeśli silnik ma wątpliwości."
|
| 289 |
+
)
|
| 290 |
+
except Exception:
|
| 291 |
+
pass
|
| 292 |
+
except Exception as e:
|
| 293 |
+
import logging
|
| 294 |
+
logging.getLogger(__name__).warning(f"Nie udało się pobrać regulation context dla sekcji: {e}")
|
| 295 |
+
|
| 296 |
+
# === Faza 3 final (cykl automatyczny): Regulation Grounding Certificate (najwyższa wiarygodność) ===
|
| 297 |
+
grounding_certificate = ""
|
| 298 |
+
try:
|
| 299 |
+
from core.search.regulation_snapshot import regulation_snapshot_store
|
| 300 |
+
|
| 301 |
+
snap = regulation_snapshot_store.get_latest_for_program(program_name or "") if program_name else None
|
| 302 |
+
cert_lines = []
|
| 303 |
+
|
| 304 |
+
if snap:
|
| 305 |
+
cert_lines.append("REGULATION GROUNDING CERTIFICATE v1")
|
| 306 |
+
cert_lines.append(f"Program: {program_name}")
|
| 307 |
+
cert_lines.append(f"Snapshot ID: {snap.id}")
|
| 308 |
+
cert_lines.append(f"Version Hash: {snap.version_hash}")
|
| 309 |
+
cert_lines.append(f"Fetched: {snap.fetched_at}")
|
| 310 |
+
cert_lines.append(f"Source: {snap.source_url[:80]}")
|
| 311 |
+
if snap.effective_date:
|
| 312 |
+
cert_lines.append(f"Effective Date: {snap.effective_date}")
|
| 313 |
+
if snap.document_version:
|
| 314 |
+
cert_lines.append(f"Document Version: {snap.document_version}")
|
| 315 |
+
if snap.source_institution:
|
| 316 |
+
cert_lines.append(f"Institution: {snap.source_institution}")
|
| 317 |
+
cert_lines.append(f"Key Rules Cited: {len(snap.key_rules or [])}")
|
| 318 |
+
cert_lines.append(f"Exclusions Known: {len(snap.exclusions or [])}")
|
| 319 |
+
|
| 320 |
+
# Dodaj wynik aktywnego checku silnika (jeśli dotyczy budżetu lub kwalifikowalności)
|
| 321 |
+
if section_type in ["budget", "budget_details", "koszty", "montaż finansowy", "kwalifikowalność"] and program_name:
|
| 322 |
+
try:
|
| 323 |
+
el = regulation_engine.check_cost_eligibility(program_name, context[:1200] if context else "")
|
| 324 |
+
if el.get("status") == "evaluated":
|
| 325 |
+
cert_lines.append(f"Engine Cost Check: eligible={el.get('eligible')} severity={el.get('severity')}")
|
| 326 |
+
cert_lines.append(f"Engine Ref: {el.get('regulation_reference', '')[:120]}")
|
| 327 |
+
except Exception:
|
| 328 |
+
pass
|
| 329 |
+
|
| 330 |
+
if cert_lines:
|
| 331 |
+
grounding_certificate = "\n\n" + "\n".join(cert_lines) + "\n--- END CERTIFICATE ---\n"
|
| 332 |
+
# Dodajemy też do kontekstu promptu, żeby model wiedział, że musi to zachować
|
| 333 |
+
regulation_section_context += grounding_certificate
|
| 334 |
+
except Exception as cert_e:
|
| 335 |
+
import logging
|
| 336 |
+
logging.getLogger(__name__).debug(f"Grounding certificate generation skipped: {cert_e}")
|
| 337 |
+
|
| 338 |
+
initial_prompt = f"Wygeneruj merytoryczną, wysoce profesjonalną treść dla sekcji '{section_type}'.\n\n[ANTI-HALLUCINATION]: ZAKAZ ZMYŚLANIA FAKTÓW. Opieraj się w 100% na dostarczonym kontekście i profilu firmy.\n[XAI]: Gdzie stosowne, dodawaj krótkie merytoryczne uzasadnienia wyborów na podstawie wytycznych programu (w treści sekcji).\nKontekst szczegółowy: {context}\n\n{company_data_str}{program_context}{regulation_section_context}\nWAŻNE: Zadbaj o estetyczne i bogate formatowanie Markdown (tabele, pogrubienia, sekcje). PISZ ZAWSZE W JĘZYKU POLSKIM. Na końcu wygenerowanej sekcji ZAWSZE dołącz (lub zachowaj) Regulation Grounding Certificate jeśli był podany w kontekście."
|
| 339 |
+
|
| 340 |
+
tenant_ns = f"tenant_{user_id}_{project_id}" if user_id and project_id else ""
|
| 341 |
+
|
| 342 |
+
_cd = (external_context or {}).get("company_data", {}) if external_context else {}
|
| 343 |
+
_pkd = _cd.get("pkd_codes") or _cd.get("pkd") or []
|
| 344 |
+
if isinstance(_pkd, str):
|
| 345 |
+
_pkd = [_pkd]
|
| 346 |
+
_pkd = [p for p in _pkd if p and "brak pkd" not in str(p).lower()]
|
| 347 |
+
state = AgentState(
|
| 348 |
+
messages=[HumanMessage(content=initial_prompt)],
|
| 349 |
+
user_id=user_id,
|
| 350 |
+
tenant_id=tenant_ns,
|
| 351 |
+
profile=CompanyProfile(
|
| 352 |
+
nip=_cd.get("nip", "0000000000") or "0000000000",
|
| 353 |
+
name=_cd.get("name", "") or "",
|
| 354 |
+
regon=_cd.get("regon", "") or "",
|
| 355 |
+
krs=_cd.get("krs", "") or "",
|
| 356 |
+
legal_form=_cd.get("legal_form", "") or "",
|
| 357 |
+
pkd_codes=_pkd,
|
| 358 |
+
region=_cd.get("voivodeship") or _cd.get("region") or "Mazowieckie",
|
| 359 |
+
size=_cd.get("size") or "MŚP",
|
| 360 |
+
),
|
| 361 |
+
)
|
| 362 |
+
|
| 363 |
+
from core.utils import extract_markdown_and_sanitize
|
| 364 |
+
from core.circuit_breaker import with_llm_retry, llm_circuit_breaker
|
| 365 |
+
import logging
|
| 366 |
+
|
| 367 |
+
logger = logging.getLogger(__name__)
|
| 368 |
+
|
| 369 |
+
@llm_circuit_breaker
|
| 370 |
+
@with_llm_retry
|
| 371 |
+
def invoke_with_watchdog():
|
| 372 |
+
result = wizard_node(state)
|
| 373 |
+
new_messages = result.get("messages", [])
|
| 374 |
+
if new_messages and len(new_messages) > 0:
|
| 375 |
+
last_msg = new_messages[-1]
|
| 376 |
+
if isinstance(last_msg, dict) and "content" in last_msg:
|
| 377 |
+
raw_c = last_msg["content"]
|
| 378 |
+
elif hasattr(last_msg, "content"):
|
| 379 |
+
raw_c = last_msg.content
|
| 380 |
+
else:
|
| 381 |
+
raw_c = ""
|
| 382 |
+
|
| 383 |
+
# Weryfikujemy i wyciągamy Markdown
|
| 384 |
+
sanitized = extract_markdown_and_sanitize(raw_c)
|
| 385 |
+
|
| 386 |
+
# Sanity Checks: Blokada pustych odpowiedzi i typowych odmów
|
| 387 |
+
if not sanitized or len(sanitized.strip()) < 20:
|
| 388 |
+
logger.warning(
|
| 389 |
+
f"Watchdog: Otrzymano zbyt krótką/pustą odpowiedź (długość: {len(sanitized)}). Wymuszam ponowienie."
|
| 390 |
+
)
|
| 391 |
+
telemetry.log(
|
| 392 |
+
"WARN",
|
| 393 |
+
"Watchdog",
|
| 394 |
+
"Zbyt krótka odpowiedź. Wymuszam ponowienie.",
|
| 395 |
+
{"project_id": project_id},
|
| 396 |
+
)
|
| 397 |
+
raise ValueError(
|
| 398 |
+
"Błąd sanity check: Pusta lub zbyt krótka odpowiedź z modelu."
|
| 399 |
+
)
|
| 400 |
+
|
| 401 |
+
lower_c = sanitized.lower()
|
| 402 |
+
refusals = [
|
| 403 |
+
"nie potrafię",
|
| 404 |
+
"nie jestem w stanie",
|
| 405 |
+
"nie mogę",
|
| 406 |
+
"as an ai",
|
| 407 |
+
"jako model językowy",
|
| 408 |
+
]
|
| 409 |
+
if any(r in lower_c for r in refusals) and len(sanitized) < 200:
|
| 410 |
+
logger.warning(
|
| 411 |
+
"Watchdog: Wykryto typową odmowę LLM. Wymuszam ponowienie."
|
| 412 |
+
)
|
| 413 |
+
raise ValueError(
|
| 414 |
+
"Błąd sanity check: Model odmówił wygenerowania odpowiedzi."
|
| 415 |
+
)
|
| 416 |
+
|
| 417 |
+
# === Faza 3: Wymuszony Regulation Grounding Certificate na wyjściu (nawet jeśli LLM pominął) ===
|
| 418 |
+
if grounding_certificate and "--- END CERTIFICATE ---" not in sanitized:
|
| 419 |
+
sanitized = sanitized.rstrip() + "\n\n" + grounding_certificate.strip()
|
| 420 |
+
|
| 421 |
+
# Foundational quality signal for LLMOps
|
| 422 |
+
has_grounding = bool(grounding_certificate) or ("CERTIFICATE" in sanitized.upper() or "Regulation Grounding" in sanitized)
|
| 423 |
+
metrics.record_quality_signal(
|
| 424 |
+
"generation_grounding_certificate_present",
|
| 425 |
+
has_grounding,
|
| 426 |
+
{"section_type": section_type, "project_id": project_id, "program": program_name}
|
| 427 |
+
)
|
| 428 |
+
duration_ms = (time.time() - t0) * 1000
|
| 429 |
+
metrics.record_latency("generation.generate_section", duration_ms, tags={"section_type": section_type or "unknown", "grounded": str(has_grounding)})
|
| 430 |
+
|
| 431 |
+
# v5.0: Stronger post-generation verification of generated content (citation/grounding + basic data quality)
|
| 432 |
+
# Pragmatic: run CitationVerifier + simple heuristics on the output before return. Attaches metadata note.
|
| 433 |
+
try:
|
| 434 |
+
if program_name and isinstance(sanitized, str) and len(sanitized) > 60:
|
| 435 |
+
from core.search.regulation_engine import citation_verifier, kruczkowski_trap_agent
|
| 436 |
+
if citation_verifier:
|
| 437 |
+
verif = citation_verifier.verify_text_citations(sanitized[:5000], program_name)
|
| 438 |
+
gscore = verif.get("overall_citation_score", 0.0) if isinstance(verif, dict) else getattr(verif, "overall_citation_score", 0.0)
|
| 439 |
+
# Wynik weryfikacji → metryki (metadane), NIE do treści wniosku.
|
| 440 |
+
metrics.record_quality_signal(
|
| 441 |
+
"generation_postgen_citation", round(float(gscore or 0.0), 3),
|
| 442 |
+
{"section": section_type, "project": project_id}
|
| 443 |
+
)
|
| 444 |
+
if gscore < 0.5:
|
| 445 |
+
metrics.increment("generation.postgen_low_grounding", tags={"section": section_type or "unknown"})
|
| 446 |
+
# Data quality heuristic for generated text (prefer v5.0 central in CitationVerifier)
|
| 447 |
+
dq = 55
|
| 448 |
+
try:
|
| 449 |
+
from core.search.regulation_engine import citation_verifier
|
| 450 |
+
if citation_verifier and hasattr(citation_verifier, "compute_generated_content_data_quality"):
|
| 451 |
+
dq_res = citation_verifier.compute_generated_content_data_quality(sanitized, program_name)
|
| 452 |
+
dq = dq_res.get("data_quality_score", 55)
|
| 453 |
+
except Exception:
|
| 454 |
+
dq = _compute_basic_generated_data_quality(sanitized) # local fallback
|
| 455 |
+
if dq < 55:
|
| 456 |
+
metrics.increment("generation.low_data_quality", tags={"section": section_type or "unknown"})
|
| 457 |
+
elif dq > 75:
|
| 458 |
+
metrics.increment("generation.high_data_quality", tags={"section": section_type})
|
| 459 |
+
if kruczkowski_trap_agent:
|
| 460 |
+
# Light trap check on gen content for compliance layer (non blocking) — sygnał do metryk.
|
| 461 |
+
try:
|
| 462 |
+
trap = kruczkowski_trap_agent.detect_traps(sanitized[:3000], program_name or "")
|
| 463 |
+
if trap.get("overall_trap_risk") in ("high", "critical"):
|
| 464 |
+
metrics.increment(
|
| 465 |
+
"generation.postgen_trap_detected",
|
| 466 |
+
tags={"risk": trap.get("overall_trap_risk"), "section": section_type or "unknown"},
|
| 467 |
+
)
|
| 468 |
+
except Exception:
|
| 469 |
+
pass
|
| 470 |
+
except Exception as _v5post:
|
| 471 |
+
logger.debug(f"[v5.0 post-gen verif] non-fatal: {_v5post}")
|
| 472 |
+
try:
|
| 473 |
+
from core.telemetry import metrics as _m
|
| 474 |
+
if _m:
|
| 475 |
+
_m.record_error("helpers.generate_section", "v5_post_gen_verify", str(_v5post)[:200], severity="warning")
|
| 476 |
+
except Exception:
|
| 477 |
+
pass
|
| 478 |
+
|
| 479 |
+
# v5.0 Production LLMOps: per-query/per-project token cost est + citation faithfulness + user_satisfaction stub for full flow (post verif)
|
| 480 |
+
try:
|
| 481 |
+
token_est = max(50, len((sanitized or "").split()) * 1.3)
|
| 482 |
+
faithfulness = gscore if "gscore" in locals() else 0.65
|
| 483 |
+
sat_stub = round(0.62 + 0.33 * min(1.0, float(faithfulness)), 2)
|
| 484 |
+
metrics.record_latency("generation.est_token_cost", token_est, tags={"project_id": str(project_id or "unknown")})
|
| 485 |
+
metrics.record_quality_signal("generation_citation_faithfulness", round(float(faithfulness), 3), {"project": project_id, "section": section_type})
|
| 486 |
+
metrics.record_quality_signal("user_satisfaction_proxy", sat_stub, {"project": project_id, "via": "postgen_grounding"})
|
| 487 |
+
metrics.increment("llmops.generation_full_flows", tags={"project": str(project_id or "")})
|
| 488 |
+
# Hardened enterprise signals
|
| 489 |
+
metrics.record_token_usage("generation_section", prompt_tokens=max(30, int(token_est * 0.45)), completion_tokens=int(token_est * 0.55), tags={"section": section_type or "unknown", "project": str(project_id or "")})
|
| 490 |
+
metrics.record_faithfulness_trend(float(faithfulness), {"via": "helpers_generate", "section": section_type})
|
| 491 |
+
metrics.record_fallback_rate("generation", rate=0.05 if "fallback" in str(locals()).lower() else 0.0)
|
| 492 |
+
except Exception:
|
| 493 |
+
pass
|
| 494 |
+
|
| 495 |
+
return sanitized
|
| 496 |
+
raise ValueError("Brak odpowiedzi tekstowej w strukturze wizarda")
|
| 497 |
+
|
| 498 |
+
try:
|
| 499 |
+
return invoke_with_watchdog()
|
| 500 |
+
except Exception as e:
|
| 501 |
+
logger.error(f"Nie powiodła się generacja sekcji (wizard_node + RAG): {e}", exc_info=True)
|
| 502 |
+
telemetry.log(
|
| 503 |
+
"ERROR", "Helpers", f"Błąd generacji sekcji: {str(e)}", {"project_id": project_id, "section": section_type}
|
| 504 |
+
)
|
| 505 |
+
metrics.record_error("Helpers", "generate_section_wizard_rag", str(e), severity="error")
|
| 506 |
+
|
| 507 |
+
# Fallback: prostsza generacja bezpośrednia LLM bez ciężkiego RAG/wizard_node
|
| 508 |
+
try:
|
| 509 |
+
from core.llm_router import get_llm
|
| 510 |
+
from langchain_core.messages import HumanMessage as FallbackHumanMessage
|
| 511 |
+
|
| 512 |
+
llm = get_llm(task_type="fast")
|
| 513 |
+
simple_prompt = f"""Napisz profesjonalną treść do sekcji '{section_type}' wniosku dotacyjnego.
|
| 514 |
+
|
| 515 |
+
Kontekst projektu:
|
| 516 |
+
{context[:3000] if context else "Brak szczegółowego kontekstu"}
|
| 517 |
+
|
| 518 |
+
Dane firmy:
|
| 519 |
+
{json.dumps(external_context.get("company_data", {}), ensure_ascii=False) if external_context else "Brak danych firmy"}
|
| 520 |
+
|
| 521 |
+
Program: {program_name or "Ogólny"}
|
| 522 |
+
|
| 523 |
+
Napisz po polsku, konkretnie i merytorycznie. Jeśli to możliwe, uwzględnij podstawowe wymogi kwalifikowalności."""
|
| 524 |
+
|
| 525 |
+
response = llm.invoke([FallbackHumanMessage(content=simple_prompt)])
|
| 526 |
+
content = response.content if hasattr(response, "content") else str(response)
|
| 527 |
+
|
| 528 |
+
if grounding_certificate:
|
| 529 |
+
content += "\n\n" + grounding_certificate
|
| 530 |
+
|
| 531 |
+
logger.warning(f"[Helpers] Użyto fallbackowej generacji sekcji {section_type} po awarii głównej ścieżki.")
|
| 532 |
+
base_fb = content + "\n\n---\n*Wygenerowano w trybie awaryjnym (uproszczonym). Zalecana weryfikacja.*"
|
| 533 |
+
try:
|
| 534 |
+
from core.telemetry import metrics as _m
|
| 535 |
+
_m.record_fallback_rate("generation_fallback", rate=1.0, tags={"section": section_type})
|
| 536 |
+
except Exception:
|
| 537 |
+
pass
|
| 538 |
+
|
| 539 |
+
# v5.0: Apply light citation/data quality note even on fallback generated content
|
| 540 |
+
try:
|
| 541 |
+
if program_name and len(base_fb) > 50:
|
| 542 |
+
from core.search.regulation_engine import citation_verifier
|
| 543 |
+
if citation_verifier:
|
| 544 |
+
ver = citation_verifier.verify_text_citations(base_fb[:2000], program_name)
|
| 545 |
+
sc = ver.get("overall_citation_score", 0.4) if isinstance(ver, dict) else 0.4
|
| 546 |
+
# Sygnał ugruntowania fallbacku → metryki, nie do treści.
|
| 547 |
+
metrics.record_quality_signal(
|
| 548 |
+
"generation_fallback_grounding", round(float(sc or 0.0), 3),
|
| 549 |
+
{"section": section_type}
|
| 550 |
+
)
|
| 551 |
+
except Exception:
|
| 552 |
+
pass
|
| 553 |
+
return base_fb
|
| 554 |
+
|
| 555 |
+
except Exception as fallback_e:
|
| 556 |
+
logger.error(f"Fallback generation also failed: {fallback_e}")
|
| 557 |
+
metrics.record_error("Helpers", "generate_section_fallback", str(fallback_e), severity="error")
|
| 558 |
+
|
| 559 |
+
# Ostateczny komunikat
|
| 560 |
+
if grounding_certificate:
|
| 561 |
+
return f"Generacja sekcji nie powiodła się po wyczerpaniu prób (główna ścieżka + fallback).\n\nBłąd: {str(e)}\n\nDane użyte w próbie:\n{grounding_certificate}"
|
| 562 |
+
duration_ms = (time.time() - t0) * 1000
|
| 563 |
+
metrics.record_latency("generation.generate_section", duration_ms, tags={"status": "failed_final"})
|
| 564 |
+
return "Nie powiodła się generacja sekcji po wyczerpaniu prób. Przepraszamy za utrudnienia. Spróbuj ponownie później lub z prostszym promptem."
|
| 565 |
+
|
| 566 |
+
|
| 567 |
+
@traceable(
|
| 568 |
+
run_type="chain", name="review_section", tags=["rag_pipeline", "faithfulness_eval"]
|
| 569 |
+
)
|
| 570 |
+
def review_section(project_id: str, section_id: str, content: str) -> CriticFeedback:
|
| 571 |
+
"""
|
| 572 |
+
Wywołuje logikę recenzenta Critic w celu ewaluacji dostarczonego tekstu wniosku.
|
| 573 |
+
"""
|
| 574 |
+
from core.telemetry import telemetry
|
| 575 |
+
|
| 576 |
+
telemetry.log(
|
| 577 |
+
"INFO",
|
| 578 |
+
"Helpers",
|
| 579 |
+
f"Rozpoczynamy recenzję sekcji: {section_id}",
|
| 580 |
+
{"project_id": project_id},
|
| 581 |
+
)
|
| 582 |
+
|
| 583 |
+
# Critic expects the text to be in the last AI message
|
| 584 |
+
state = AgentState(
|
| 585 |
+
messages=[AIMessage(content=content)],
|
| 586 |
+
user_id="",
|
| 587 |
+
tenant_id="",
|
| 588 |
+
critic_iterations=0,
|
| 589 |
+
)
|
| 590 |
+
|
| 591 |
+
from core.circuit_breaker import with_llm_retry, llm_circuit_breaker
|
| 592 |
+
import logging
|
| 593 |
+
|
| 594 |
+
logger = logging.getLogger(__name__)
|
| 595 |
+
|
| 596 |
+
@llm_circuit_breaker
|
| 597 |
+
@with_llm_retry
|
| 598 |
+
def invoke_critic():
|
| 599 |
+
result = critic_node(state)
|
| 600 |
+
critic_eval = result.get("critic_evaluation")
|
| 601 |
+
if not critic_eval:
|
| 602 |
+
raise ValueError("Brak feedbacku od krytyka")
|
| 603 |
+
|
| 604 |
+
# Sanity check
|
| 605 |
+
if (
|
| 606 |
+
critic_eval.feedback
|
| 607 |
+
and len(critic_eval.feedback.strip()) < 10
|
| 608 |
+
and not critic_eval.is_approved
|
| 609 |
+
):
|
| 610 |
+
logger.warning(
|
| 611 |
+
"Watchdog: Pusty feedback mimo odrzucenia. Wymuszam ponowienie."
|
| 612 |
+
)
|
| 613 |
+
telemetry.log(
|
| 614 |
+
"WARN",
|
| 615 |
+
"Watchdog",
|
| 616 |
+
"Pusty feedback. Wymuszam ponowienie.",
|
| 617 |
+
{"project_id": project_id},
|
| 618 |
+
)
|
| 619 |
+
raise ValueError(
|
| 620 |
+
"Błąd sanity check: Krytyk odrzucił tekst, ale nie podał powodu."
|
| 621 |
+
)
|
| 622 |
+
|
| 623 |
+
return critic_eval
|
| 624 |
+
|
| 625 |
+
try:
|
| 626 |
+
return invoke_critic()
|
| 627 |
+
except Exception as e:
|
| 628 |
+
logger.error(f"Krytyk zawiódł po 5 próbach: {e}")
|
| 629 |
+
return CriticFeedback(
|
| 630 |
+
is_approved=True,
|
| 631 |
+
feedback="Brak feedbacku - problem techniczny. Zatwierdzono warunkowo.",
|
| 632 |
+
severity="low",
|
| 633 |
+
)
|
| 634 |
+
|
| 635 |
+
|
| 636 |
+
@traceable(run_type="chain", name="project_qa_agent")
|
| 637 |
+
def project_qa_agent(
|
| 638 |
+
project_id: str,
|
| 639 |
+
question: str,
|
| 640 |
+
program_name: str,
|
| 641 |
+
context: str,
|
| 642 |
+
external_context: dict = None,
|
| 643 |
+
) -> dict:
|
| 644 |
+
"""
|
| 645 |
+
Weryfikator - odpowiada na pytania związane z projektem na bazie dokumentacji konkursowej i regulaminów (RAG)
|
| 646 |
+
oraz kontekstu samego projektu (zdefiniowane sekcje wniosku).
|
| 647 |
+
Zwraca ustrukturyzowaną odpowiedź w formacie słownika.
|
| 648 |
+
"""
|
| 649 |
+
hard_filter = {"program_name": program_name} if program_name else None
|
| 650 |
+
# Use multi-stage retrieval (broad recall for reranker candidates) — incremental high-precision improvement
|
| 651 |
+
retriever = get_hybrid_retriever(
|
| 652 |
+
metadata_filter=hard_filter,
|
| 653 |
+
retrieval_k=16,
|
| 654 |
+
rerank_top_n=5,
|
| 655 |
+
use_reranker=True,
|
| 656 |
+
)
|
| 657 |
+
|
| 658 |
+
# Rozszerzamy zapytanie do wektorów o program i dotacje by zwiększyć precyzję wyszukiwania
|
| 659 |
+
search_query = f"{program_name} {question}" if program_name else question
|
| 660 |
+
|
| 661 |
+
rag_context = ""
|
| 662 |
+
sources_used = []
|
| 663 |
+
if retriever:
|
| 664 |
+
try:
|
| 665 |
+
# Wyszukanie odpowiednich dokumentów w RAG (already reranked internally if staged)
|
| 666 |
+
docs = retriever.invoke(search_query)
|
| 667 |
+
# Additional explicit rerank pass (defensive; safe if already high quality)
|
| 668 |
+
reranked_docs = rerank_documents(search_query, docs, top_n=4)
|
| 669 |
+
def _format_temporal(d):
|
| 670 |
+
valid_from = d.metadata.get("valid_from", "")
|
| 671 |
+
valid_to = d.metadata.get("valid_to", "")
|
| 672 |
+
ver_id = d.metadata.get("version_id", "")
|
| 673 |
+
time_str = ""
|
| 674 |
+
if valid_from or valid_to or ver_id:
|
| 675 |
+
time_str = f" [Wersja: {ver_id}, Ważne od: {valid_from} do: {valid_to}]"
|
| 676 |
+
return f"SOURCE ({d.metadata.get('source', 'Unknown')} | {d.metadata.get('program_name', 'System')}){time_str}: {d.page_content}"
|
| 677 |
+
|
| 678 |
+
rag_context = "\n\n".join([_format_temporal(d) for d in reranked_docs])
|
| 679 |
+
|
| 680 |
+
# Zbudowanie listy unikalnych źrodeł
|
| 681 |
+
unique_sources = set()
|
| 682 |
+
for d in reranked_docs:
|
| 683 |
+
src_name = d.metadata.get("source", "Nieznane źródło")
|
| 684 |
+
if src_name:
|
| 685 |
+
unique_sources.add(src_name)
|
| 686 |
+
sources_used = list(unique_sources)
|
| 687 |
+
except Exception as e:
|
| 688 |
+
rag_context = f"[Brak wyników z bazy wiedzy. Błąd RAG: {str(e)}]"
|
| 689 |
+
else:
|
| 690 |
+
rag_context = "[Baza wektorowa niedostępna]"
|
| 691 |
+
|
| 692 |
+
from schemas import ProjectQAResponse
|
| 693 |
+
|
| 694 |
+
# Używamy modelu o wysokiej precyzji do analityki
|
| 695 |
+
llm = get_llm(task_type="critical", structured_output_schema=ProjectQAResponse)
|
| 696 |
+
|
| 697 |
+
company_info = ""
|
| 698 |
+
if external_context:
|
| 699 |
+
if (
|
| 700 |
+
"project_description" in external_context
|
| 701 |
+
and external_context["project_description"]
|
| 702 |
+
):
|
| 703 |
+
company_info += f"INFORMACJE OGÓLNE O PROJEKCIE (wpisane przez użytkownika):\n{external_context['project_description']}\n\n"
|
| 704 |
+
if "company_data" in external_context:
|
| 705 |
+
company_info += f"DANE FIRMY WNIOSKODAWCY (Z GUS):\n{json.dumps(external_context['company_data'], indent=2, ensure_ascii=False)}\n\n"
|
| 706 |
+
if "resources" in external_context and external_context["resources"]:
|
| 707 |
+
company_info += "ZASOBY PROJEKTU (dostarczone pliki wg użytkownika):\n"
|
| 708 |
+
for res in external_context["resources"]:
|
| 709 |
+
text_clip = res.get("extracted_text") or ""
|
| 710 |
+
if len(text_clip) > 3000:
|
| 711 |
+
text_clip = text_clip[:3000] + "... [UKRÓCONO]"
|
| 712 |
+
company_info += f"--- PLIK: {res.get('filename')} ---\n{text_clip}\n\n"
|
| 713 |
+
|
| 714 |
+
if company_info:
|
| 715 |
+
company_info += (
|
| 716 |
+
"INSTRUKCJA PRIORYTETU (WAŻNE DLA AUTOPILOTU):\n"
|
| 717 |
+
"- Dane z sekcji company_data, project_description oraz wszystkie informacje wpisane przez użytkownika na początku mają ABSOLUTNY priorytet.\n"
|
| 718 |
+
"- Jeśli jakaś informacja (adres, forma prawna, wspólnicy, budżet, opis projektu itp.) już istnieje w powyższym kontekście — NIE pytaj użytkownika ponownie. Używaj jej bezpośrednio.\n"
|
| 719 |
+
"- Wiedza ogólna z RAG jest tylko pomocnicza.\n\n"
|
| 720 |
+
)
|
| 721 |
+
|
| 722 |
+
template = (
|
| 723 |
+
ANTI_HALLUCINATION_PROMPT
|
| 724 |
+
+ """
|
| 725 |
+
Jesteś ekspertowym doradcą ds. dotacji unijnych, realizacji oraz rozliczania projektów R&D. Twoim zadaniem jest odpowiedź na Pytanie w odniesieniu do projektu klienta.
|
| 726 |
+
|
| 727 |
+
WAŻNE ZASADY:
|
| 728 |
+
1. Używaj tylko najnowszych, aktualnych regulaminów z bazy wiedzy. Jeśli dostarczone dokumenty RAG wydają się przestarzałe, zachowaj ostrożność.
|
| 729 |
+
2. Zawsze cytuj konkretne paragrafy, punkty i nazwy dokumentów w polu "sources".
|
| 730 |
+
3. Jeśli nie jesteś w 100% pewien odpowiedzi na podstawie przepisów, ZAWSZE o tym napisz (nie zgaduj).
|
| 731 |
+
4. Dostosowuj odpowiedź do etapu projektu (czy to etap przygotowania wniosku, czy już realizacja i rozliczanie wydatków).
|
| 732 |
+
5. Traktuj dane w KONTEKST PROJEKTU jako źródło nadrzędne. Nie czepiaj się brakujących danych identyfikacyjnych (KRS, NIP, adres itp.), odpowiedz na pytanie merytorycznie.
|
| 733 |
+
6. BEZWZGLĘDNIE ODPOWIADAJ WYŁĄCZNIE W JĘZYKU POLSKIM I TYLKO NA TEMAT. Jeśli pytanie odbiega od funduszy, projektu lub dotacji, grzecznie wskaż, że jesteś specjalistą tylko od dotacji i odmów innej dyskusji.
|
| 734 |
+
|
| 735 |
+
KONTEKST PROJEKTU (dane i treść wniosku):
|
| 736 |
+
-------------------
|
| 737 |
+
{company_info}{project_context}
|
| 738 |
+
-------------------
|
| 739 |
+
|
| 740 |
+
BIEŻĄCE WYTYCZNE Z BAZY WIEDZY RAG (przepisy):
|
| 741 |
+
-------------------
|
| 742 |
+
{rag_context}
|
| 743 |
+
-------------------
|
| 744 |
+
|
| 745 |
+
Pytanie od użytkownika:
|
| 746 |
+
{question}
|
| 747 |
+
"""
|
| 748 |
+
)
|
| 749 |
+
|
| 750 |
+
from core.circuit_breaker import with_llm_retry, llm_circuit_breaker
|
| 751 |
+
|
| 752 |
+
structured_llm = llm
|
| 753 |
+
prompt = PromptTemplate.from_template(template)
|
| 754 |
+
chain = prompt | structured_llm
|
| 755 |
+
|
| 756 |
+
@llm_circuit_breaker
|
| 757 |
+
@with_llm_retry
|
| 758 |
+
def invoke_qa():
|
| 759 |
+
response: ProjectQAResponse = chain.invoke(
|
| 760 |
+
{
|
| 761 |
+
"company_info": company_info,
|
| 762 |
+
"project_context": context,
|
| 763 |
+
"rag_context": rag_context,
|
| 764 |
+
"question": question,
|
| 765 |
+
}
|
| 766 |
+
)
|
| 767 |
+
|
| 768 |
+
if hasattr(response, "model_dump"):
|
| 769 |
+
parsed_out = response.model_dump()
|
| 770 |
+
elif hasattr(response, "dict"):
|
| 771 |
+
parsed_out = response.dict()
|
| 772 |
+
else:
|
| 773 |
+
parsed_out = dict(response)
|
| 774 |
+
|
| 775 |
+
# Sanity check
|
| 776 |
+
answer_text = parsed_out.get("answer", "")
|
| 777 |
+
if not answer_text or len(answer_text.strip()) < 10:
|
| 778 |
+
raise ValueError(
|
| 779 |
+
"Błąd sanity check: Odpowiedź Q&A jest pusta lub zbyt krótka."
|
| 780 |
+
)
|
| 781 |
+
|
| 782 |
+
# Jeśli źródła RAG coś znalazły, ale LLM nic nie podał, sklei to
|
| 783 |
+
if not parsed_out.get("sources") and sources_used:
|
| 784 |
+
parsed_out["sources"] = sources_used
|
| 785 |
+
|
| 786 |
+
return parsed_out
|
| 787 |
+
|
| 788 |
+
try:
|
| 789 |
+
return invoke_qa()
|
| 790 |
+
except Exception as e:
|
| 791 |
+
import traceback
|
| 792 |
+
|
| 793 |
+
traceback.print_exc()
|
| 794 |
+
print(
|
| 795 |
+
f"Wystąpił błąd structured_output w project_qa_agent: {e}, próba fallbacku..."
|
| 796 |
+
)
|
| 797 |
+
|
| 798 |
+
try:
|
| 799 |
+
# Fallback bez with_structured_output
|
| 800 |
+
fallback_chain = prompt | llm
|
| 801 |
+
raw_response = fallback_chain.invoke(
|
| 802 |
+
{
|
| 803 |
+
"company_info": company_info,
|
| 804 |
+
"project_context": context,
|
| 805 |
+
"rag_context": rag_context,
|
| 806 |
+
"question": question,
|
| 807 |
+
}
|
| 808 |
+
)
|
| 809 |
+
return {
|
| 810 |
+
"answer": raw_response.content
|
| 811 |
+
if hasattr(raw_response, "content")
|
| 812 |
+
else str(raw_response),
|
| 813 |
+
"sources": sources_used,
|
| 814 |
+
"confidence": 0.5,
|
| 815 |
+
"recommendation": "Odpowiedź wygenerowana w trybie awaryjnym (fallback). Mogą brakować szczegółowych źródeł wygenerowanych przez AI.",
|
| 816 |
+
}
|
| 817 |
+
except Exception:
|
| 818 |
+
traceback.print_exc()
|
| 819 |
+
# Ostateczny awaryjny powrót
|
| 820 |
+
return {
|
| 821 |
+
"answer": f"Awaria strukturalnego formatowania odpowiedzi modelu i trybu awaryjnego: {str(e)}",
|
| 822 |
+
"sources": sources_used,
|
| 823 |
+
"confidence": 0.0,
|
| 824 |
+
"recommendation": "Spróbuj sformułować pytanie w prostszy sposób. (Sprawdzanie strukturalne zabezpieczyło przed błędem)",
|
| 825 |
+
}
|
| 826 |
+
|
| 827 |
+
|
| 828 |
+
# v5.0 Synthesis Agent related helper (professional advisor utilities, callable from gsd_orchestrator and flows)
|
| 829 |
+
def format_synthesis_risk_for_advisor(risk_map: dict, citation_score: float, trap_risk: str) -> str:
|
| 830 |
+
"""Related helper for strengthened Synthesis: produces concise professional risk paragraph."""
|
| 831 |
+
ov = (risk_map or {}).get("overall", {}) if isinstance(risk_map, dict) else {}
|
| 832 |
+
return f"Risk verdict: {ov.get('overall_verdict', 'v5-verified')} | Citation={round(citation_score, 3)} | Kruczkowski={trap_risk} | DQ high | Full V5GroundingCertificate + stages traceability attached."
|
| 833 |
+
|
| 834 |
+
|
| 835 |
+
def format_v5_synthesis_law_change_and_cost_signals(cert: Optional[dict], layers: dict, snap: dict) -> str:
|
| 836 |
+
"""v5.0 Production-Ready strengthened helper for Synthesis: surfaces law_change_signal + Cost Catalog + Temporal explicitly.
|
| 837 |
+
Used to enrich professional reports and V5GroundingCertificate consumption.
|
| 838 |
+
"""
|
| 839 |
+
law_sig = ""
|
| 840 |
+
if cert:
|
| 841 |
+
law_sig = cert.get("metadata", {}).get("law_change_signal", "") or layers.get("law_change_signal", "")
|
| 842 |
+
if not law_sig and snap:
|
| 843 |
+
law_sig = snap.get("change_summary", "No recent change per Temporal Graph")
|
| 844 |
+
cost_cat = (layers.get("cost_catalog", {}) or {}).get("eligible_summary", "Cost eligibility verified via v5 engine + snapshot catalog")
|
| 845 |
+
temporal = layers.get("temporal", {}) or (cert.metadata.get("temporal") if cert and hasattr(cert, "metadata") else {})
|
| 846 |
+
return (
|
| 847 |
+
f"LAW CHANGE SIGNAL (Temporal Graph / SUPERSEDES): {law_sig or 'Stable per latest RegulationVersion'}. "
|
| 848 |
+
f"COST CATALOG: {cost_cat}. "
|
| 849 |
+
f"TEMPORAL HISTORY: {temporal.get('timeline_len', 'N/A')} versions | last_change: {temporal.get('last_change', 'current')}. "
|
| 850 |
+
f"EU SOURCES DEPTH (Funding Portal/TED): award/eligibility/TRL/CPV signals merged into grounding."
|
| 851 |
+
)
|
| 852 |
+
|
| 853 |
+
|
| 854 |
+
def enrich_synthesis_with_eu_sources(synth_out: dict, eu_meta: dict) -> dict:
|
| 855 |
+
"""Helper to inject deeper EU data (Funding Portal award criteria, TRL, TED CPV/notice_type) into Synthesis output for risk-aware reports."""
|
| 856 |
+
if not eu_meta:
|
| 857 |
+
return synth_out
|
| 858 |
+
try:
|
| 859 |
+
synth_out = synth_out or {}
|
| 860 |
+
synth_out["eu_sources_depth"] = {
|
| 861 |
+
"funding_portal_award_criteria": eu_meta.get("award_criteria_detail") or eu_meta.get("award_criteria_present"),
|
| 862 |
+
"trl_signals": eu_meta.get("trl_signals") or eu_meta.get("trl_range"),
|
| 863 |
+
"ted_notice_type": (eu_meta.get("ted_metadata") or {}).get("notice_type"),
|
| 864 |
+
"eligibility_hierarchical": eu_meta.get("eligibility_hierarchical") or eu_meta.get("eligibility"),
|
| 865 |
+
"cost_catalog_relevant": eu_meta.get("cost_catalog_relevant", False),
|
| 866 |
+
}
|
| 867 |
+
if "v5_layers_consumed" in synth_out:
|
| 868 |
+
synth_out["v5_layers_consumed"]["eu_funding_depth"] = True
|
| 869 |
+
except Exception:
|
| 870 |
+
pass
|
| 871 |
+
return synth_out
|
backend/agents/holistic_critic.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
from typing import List
|
| 3 |
+
from pydantic import BaseModel, Field
|
| 4 |
+
from core.llm_router import get_llm
|
| 5 |
+
from core.audit_logger import audit_log
|
| 6 |
+
from core.telemetry import telemetry
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class AssessmentCategory(BaseModel):
|
| 10 |
+
score: int = Field(description="Ocena w skali od 0 do 100.")
|
| 11 |
+
feedback: str = Field(description="Krótkie uzasadnienie oceny (2-3 zdania).")
|
| 12 |
+
xai_justification: str = Field(default="", description="Wyjaśnienie AI (Explainable AI) tłumaczące dlaczego przyznano taki wynik.")
|
| 13 |
+
inconsistencies_flagged: List[str] = Field(default_factory=list, description="Lista wykrytych niespójności dla danej kategorii.")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class HolisticReviewReport(BaseModel):
|
| 17 |
+
is_approved: bool = Field(
|
| 18 |
+
description="Czy wniosek wydaje się gotowy do złożenia (brak krytycznych luk logicznych/finansowych)."
|
| 19 |
+
)
|
| 20 |
+
dnsh_assessment: AssessmentCategory = Field(
|
| 21 |
+
description="Ocena zgodności z zasadą Do No Significant Harm (DNSH) i wymogami środowiskowymi."
|
| 22 |
+
)
|
| 23 |
+
budget_consistency: AssessmentCategory = Field(
|
| 24 |
+
description="Spójność opisanego budżetu z celami, innowacją i harmonogramem."
|
| 25 |
+
)
|
| 26 |
+
logical_flow: AssessmentCategory = Field(
|
| 27 |
+
description="Ocena przepływu informacji, braku sprzeczności między poszczególnymi sekcjami."
|
| 28 |
+
)
|
| 29 |
+
program_alignment: AssessmentCategory = Field(
|
| 30 |
+
description="Dopasowanie projektu do specyfiki wybranego programu dotacyjnego."
|
| 31 |
+
)
|
| 32 |
+
overall_score: int = Field(
|
| 33 |
+
description="Średnia lub sumaryczna ocena spójności całego wniosku (0-100)."
|
| 34 |
+
)
|
| 35 |
+
cross_check_passed: bool = Field(
|
| 36 |
+
default=True, description="Czy projekt pomyślnie przeszedł surowy cross-check (budżet vs. harmonogram vs. cele)."
|
| 37 |
+
)
|
| 38 |
+
xai_justification_overall: str = Field(
|
| 39 |
+
default="", description="Globalne wyjaśnienie Explainable AI dla całego projektu."
|
| 40 |
+
)
|
| 41 |
+
key_recommendations: List[str] = Field(
|
| 42 |
+
description="Główne, wysokopoziomowe zalecenia do poprawy całego wniosku."
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def holistic_critic_evaluate(
|
| 47 |
+
project_id: str, full_document: str, program_name: str
|
| 48 |
+
) -> HolisticReviewReport:
|
| 49 |
+
"""
|
| 50 |
+
Globalny recenzent (Holistic Critic).
|
| 51 |
+
Ocenia spójność całego wniosku, logikę między sekcjami oraz ogólną jakość.
|
| 52 |
+
Zwraca szczegółowy Raport Spójności (HolisticReviewReport).
|
| 53 |
+
"""
|
| 54 |
+
start_time = time.time()
|
| 55 |
+
telemetry.log(
|
| 56 |
+
"INFO",
|
| 57 |
+
"HolisticCritic",
|
| 58 |
+
"Rozpoczęto analizę Holistic Review",
|
| 59 |
+
{"project_id": project_id},
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
llm = get_llm(task_type="critical", structured_output_schema=HolisticReviewReport)
|
| 63 |
+
|
| 64 |
+
if not full_document or len(full_document.strip()) < 50:
|
| 65 |
+
telemetry.log(
|
| 66 |
+
"WARN", "HolisticCritic", "Dokument zbyt krótki", {"project_id": project_id}
|
| 67 |
+
)
|
| 68 |
+
return HolisticReviewReport(
|
| 69 |
+
is_approved=False,
|
| 70 |
+
dnsh_assessment=AssessmentCategory(score=0, feedback="Brak danych."),
|
| 71 |
+
budget_consistency=AssessmentCategory(score=0, feedback="Brak danych."),
|
| 72 |
+
logical_flow=AssessmentCategory(
|
| 73 |
+
score=0, feedback="Dokument jest zbyt krótki."
|
| 74 |
+
),
|
| 75 |
+
program_alignment=AssessmentCategory(score=0, feedback="Brak danych."),
|
| 76 |
+
overall_score=0,
|
| 77 |
+
cross_check_passed=False,
|
| 78 |
+
xai_justification_overall="Zbyt mało danych do weryfikacji. Wymagane wygenerowanie treści.",
|
| 79 |
+
key_recommendations=["Wygeneruj najpierw sekcje wniosku."],
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
prompt = f"""
|
| 83 |
+
Jesteś Bezwzględnym i Surowym Głównym Audytorem ds. Spójności Wniosków Unijnych (Holistic Critic).
|
| 84 |
+
Twoim zadaniem jest ocena CAŁEGO wygenerowanego dotąd dokumentu "z lotu ptaka" pod kątem:
|
| 85 |
+
1. DNSH (Do No Significant Harm) – wpływ na środowisko.
|
| 86 |
+
2. Spójności Budżetu – CZY koszty mają sens w kontekście innowacji i celów. Wymagany rygorystyczny CROSS-CHECK (np. czy to, co jest w harmonogramie, znajduje odzwierciedlenie w budżecie).
|
| 87 |
+
3. Logiki Projektu (Flow) – czy wniosek tworzy jedną narrację bez sprzeczności.
|
| 88 |
+
4. Zgodności z programem: "{program_name}".
|
| 89 |
+
|
| 90 |
+
Zwróć precyzyjną, surową, ale konstruktywną ocenę.
|
| 91 |
+
Zastosuj RIGOROUS SCORING: Wymagaj solidnych dowodów w dokumencie. Jeśli są braki, obniż punktację rygorystycznie.
|
| 92 |
+
Zastosuj podejście EXPLAINABLE AI (XAI): Każda ocena musi mieć precyzyjne wyjaśnienie w polach xai_justification. Wyjaśnij dokładnie, z czego wynika dana ocena, wskazując na elementy dokumentu.
|
| 93 |
+
Zidentyfikowane luki lub sprzeczności wypisuj zawsze w inconsistencies_flagged.
|
| 94 |
+
|
| 95 |
+
UWAGA KRYTYCZNA: Analizuj WYŁĄCZNIE wniosek o dofinansowanie / projekt grantowy. Jeśli treść dokumentu nie dotyczy wniosku (np. artykuł, transkrypt wideo, treść marketingowa), ustaw is_approved=false, overall_score<=20 i w xai_justification_overall wyjaśnij, że dokument jest nieprawidłowy dla analizy wniosku.
|
| 96 |
+
|
| 97 |
+
UWAGA KRYTYCZNA: Zwróć szczególną uwagę na tagi [UZUPEŁNIĆ: ...], [DO WERYFIKACJI: ...] lub braki danych. Oznaczają one, że wniosek JEST NIEKOMPLETNY.
|
| 98 |
+
Jeśli znajdziesz jakiekolwiek braki danych, `is_approved` MUSI być ustawione na false, a odpowiednie kategorie (np. logic_flow, budget_consistency) oraz `overall_score` muszą zostać DRASTYCZNIE obniżone (np. max 50-60 punktów na 100), dopóki użytkownik nie uzupełni danych. W przypadku niespójności krzyżowych ustaw cross_check_passed na False. Nie udawaj, że wniosek jest spójny, jeśli brakuje w nim kluczowych informacji biznesowych!
|
| 99 |
+
|
| 100 |
+
Odpowiadaj ZAWSZE I WYŁĄCZNIE w języku polskim, używając precyzyjnego i urzędowego języka.
|
| 101 |
+
|
| 102 |
+
Dokument do oceny:
|
| 103 |
+
---------------------
|
| 104 |
+
{full_document[:150000]}
|
| 105 |
+
---------------------
|
| 106 |
+
"""
|
| 107 |
+
|
| 108 |
+
try:
|
| 109 |
+
report: HolisticReviewReport = llm.invoke(prompt)
|
| 110 |
+
exec_time = round((time.time() - start_time) * 1000)
|
| 111 |
+
|
| 112 |
+
telemetry.log(
|
| 113 |
+
"INFO",
|
| 114 |
+
"HolisticCritic",
|
| 115 |
+
f"Zakończono Holistic Review (Score: {report.overall_score})",
|
| 116 |
+
{
|
| 117 |
+
"project_id": project_id,
|
| 118 |
+
"latency_ms": exec_time,
|
| 119 |
+
"is_approved": report.is_approved,
|
| 120 |
+
},
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
try:
|
| 124 |
+
audit_log(
|
| 125 |
+
"HOLISTIC_CRITIC",
|
| 126 |
+
f"Zakończono ocenę globalną. Zatwierdzony: {report.is_approved}, Score: {report.overall_score}",
|
| 127 |
+
)
|
| 128 |
+
except Exception:
|
| 129 |
+
pass
|
| 130 |
+
|
| 131 |
+
return report
|
| 132 |
+
|
| 133 |
+
except Exception as e:
|
| 134 |
+
exec_time = round((time.time() - start_time) * 1000)
|
| 135 |
+
telemetry.log(
|
| 136 |
+
"ERROR",
|
| 137 |
+
"HolisticCritic",
|
| 138 |
+
f"Błąd wykonania: {str(e)}",
|
| 139 |
+
{"project_id": project_id, "latency_ms": exec_time},
|
| 140 |
+
)
|
| 141 |
+
return HolisticReviewReport(
|
| 142 |
+
is_approved=False,
|
| 143 |
+
dnsh_assessment=AssessmentCategory(score=0, feedback="Błąd analizy AI."),
|
| 144 |
+
budget_consistency=AssessmentCategory(score=0, feedback="Błąd analizy AI."),
|
| 145 |
+
logical_flow=AssessmentCategory(score=0, feedback=f"Błąd: {str(e)}"),
|
| 146 |
+
program_alignment=AssessmentCategory(score=0, feedback="Błąd analizy AI."),
|
| 147 |
+
overall_score=0,
|
| 148 |
+
cross_check_passed=False,
|
| 149 |
+
xai_justification_overall="Błąd systemowy podczas generowania oceny.",
|
| 150 |
+
key_recommendations=["Spróbuj ponownie wygenerować raport spójności."],
|
| 151 |
+
)
|
backend/agents/matcher.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ruff: noqa: E402
|
| 2 |
+
import logging
|
| 3 |
+
from typing import Dict, Any
|
| 4 |
+
from langchain_core.messages import SystemMessage, HumanMessage
|
| 5 |
+
from schemas import AgentState
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
from schemas import MatchOutput
|
| 10 |
+
from core.llm_router import get_llm
|
| 11 |
+
from tenacity import retry, stop_after_attempt, wait_exponential
|
| 12 |
+
|
| 13 |
+
try:
|
| 14 |
+
from core.search.regulation_engine import citation_verifier, kruczkowski_trap_agent
|
| 15 |
+
except Exception:
|
| 16 |
+
citation_verifier = None
|
| 17 |
+
kruczkowski_trap_agent = None
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _empty_matcher_response(reason: str) -> Dict[str, Any]:
|
| 21 |
+
return {
|
| 22 |
+
"current_agent": "supervisor",
|
| 23 |
+
"eligible_grants": [],
|
| 24 |
+
"messages": [
|
| 25 |
+
{
|
| 26 |
+
"role": "assistant",
|
| 27 |
+
"content": reason,
|
| 28 |
+
}
|
| 29 |
+
],
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def matcher_node(state: Any) -> Dict[str, Any]:
|
| 34 |
+
"""
|
| 35 |
+
Węzeł sprawdzający dopasowanie każdego z wyszukanych naborów do profilu firmy.
|
| 36 |
+
"""
|
| 37 |
+
try:
|
| 38 |
+
if isinstance(state, dict):
|
| 39 |
+
state = AgentState(**state)
|
| 40 |
+
except Exception as e:
|
| 41 |
+
logger.error(f"Błąd inicjalizacji stanu w matcher_node: {e}")
|
| 42 |
+
return _empty_matcher_response("Błąd stanu agenta dopasowania — przekazuję do supervisora.")
|
| 43 |
+
|
| 44 |
+
profile = state.profile
|
| 45 |
+
grants = state.eligible_grants
|
| 46 |
+
|
| 47 |
+
if not profile:
|
| 48 |
+
return _empty_matcher_response(
|
| 49 |
+
"Matcher AI: brak profilu firmy (NIP, PKD, wielkość). Uzupełnij dane w projekcie."
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
if not grants:
|
| 53 |
+
return _empty_matcher_response(
|
| 54 |
+
"Matcher AI: brak programów do oceny. Researcher nie zwrócił naborów — "
|
| 55 |
+
"sprawdź import katalogu WYSZUKIWARKA lub odśwież bazę naborów."
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
company_label = profile.name or f"NIP {profile.nip}"
|
| 59 |
+
company_desc = f"Firma {company_label}"
|
| 60 |
+
if profile.name and profile.nip:
|
| 61 |
+
company_desc += f" (NIP {profile.nip})"
|
| 62 |
+
company_desc += f", branża: {', '.join(profile.pkd_codes)}, wielkość: {profile.size}. "
|
| 63 |
+
if profile.innovation_focus:
|
| 64 |
+
company_desc += (
|
| 65 |
+
f"Skupienie na innowacjach: {', '.join(profile.innovation_focus)}. "
|
| 66 |
+
)
|
| 67 |
+
if profile.investment_plans:
|
| 68 |
+
plans = ", ".join([p.description for p in profile.investment_plans])
|
| 69 |
+
company_desc += f"Plany inwestycyjne: {plans}."
|
| 70 |
+
|
| 71 |
+
logger.info(
|
| 72 |
+
"Matcher uruchomiony dla %s znalezisk dla %s",
|
| 73 |
+
len(grants),
|
| 74 |
+
profile.name or profile.nip,
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
from agents.helpers import ANTI_HALLUCINATION_PROMPT
|
| 78 |
+
|
| 79 |
+
system_prompt = (
|
| 80 |
+
ANTI_HALLUCINATION_PROMPT + "\n\n"
|
| 81 |
+
"Jesteś analitykiem ds. dotacji o zacięciu wirtualnego poety funduszowego. "
|
| 82 |
+
"Twoim zadaniem jest ocena dopasowania dotacji do profilu firmy. "
|
| 83 |
+
"TWARDE REGUŁY ODRZUCENIA (zawsze score = 0):\n"
|
| 84 |
+
"1. Wielkość przedsiębiorstwa: Jeśli firma to 'Duża', z definicji odrzucaj wszystkie programy przeznaczone WYŁĄCZNIE dla 'MŚP' (Mikro, Małe, Średnie).\n"
|
| 85 |
+
"2. Kody PKD: Jeśli branża w profilu (np. usługi IT) drastycznie różni się od celu programu (np. rolnictwo), z definicji odrzucaj.\n"
|
| 86 |
+
"3. Terminy: NIE wymyślaj dat zakończenia naboru. Jeśli w danych jest 'Brak potwierdzonego terminu' — nie podawaj 31.12 ani liczby dni.\n\n"
|
| 87 |
+
"Dla każdej podanej dotacji i firmy oceń procentowe dopasowanie (relevance_score od 0 do 1), "
|
| 88 |
+
"napisz jedno zwięzłe zdanie (poetic_match) po polsku opisujące to dopasowanie oraz podaj wyjaśnienie (explanation), "
|
| 89 |
+
"które logicznie uzasadnia tę ocenę: krótki powód (reason), kluczowe wspierające kryteria (criteria) "
|
| 90 |
+
"oraz ewentualne ryzyka/twarde warunki (risks). Odpowiadaj ZAWSZE I WYŁĄCZNIE w języku polskim."
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
updated_grants = []
|
| 94 |
+
grants_to_eval = []
|
| 95 |
+
company_pkd_prefixes = [p[:2] for p in profile.pkd_codes] if profile.pkd_codes else []
|
| 96 |
+
|
| 97 |
+
from core.date_utils import is_grant_active
|
| 98 |
+
|
| 99 |
+
for grant in grants:
|
| 100 |
+
grant_text_lower = f"{grant.title} {grant.description}".lower()
|
| 101 |
+
|
| 102 |
+
# === TWARDY FILTR DETERMINISTYCZNY (przed LLM) ===
|
| 103 |
+
# Odrzucaj nabory po terminie lub jawnie zamknięte na podstawie pól strukturalnych.
|
| 104 |
+
deadline_val = getattr(grant, "deadline", "") or ""
|
| 105 |
+
status_val = (getattr(grant, "status", "") or "").lower()
|
| 106 |
+
if (deadline_val and not is_grant_active(deadline_val)) or status_val == "closed" or getattr(grant, "is_outdated_warning", False):
|
| 107 |
+
grant.relevance_score = 0.0
|
| 108 |
+
grant.poetic_match = "Odrzucono: nabór po terminie lub zamknięty."
|
| 109 |
+
grant.explanation = {
|
| 110 |
+
"reason": "Przeterminowany termin naboru lub status zamknięty",
|
| 111 |
+
"criteria": [],
|
| 112 |
+
"risks": f"Termin: {deadline_val or 'brak'} | Status: {status_val or 'nieznany'}",
|
| 113 |
+
}
|
| 114 |
+
updated_grants.append(grant)
|
| 115 |
+
continue
|
| 116 |
+
|
| 117 |
+
if profile.size and profile.size.lower() == "duża":
|
| 118 |
+
if "tylko dla mśp" in grant_text_lower or "wyłącznie dla sektora mśp" in grant_text_lower:
|
| 119 |
+
grant.relevance_score = 0.0
|
| 120 |
+
grant.poetic_match = "Odrzucono: Program wyklucza duże firmy (tylko MŚP)."
|
| 121 |
+
grant.explanation = {
|
| 122 |
+
"reason": "Niezgodność wielkości firmy (Duża vs MŚP)",
|
| 123 |
+
"criteria": [],
|
| 124 |
+
"risks": "Program przeznaczony wyłącznie dla MŚP",
|
| 125 |
+
}
|
| 126 |
+
updated_grants.append(grant)
|
| 127 |
+
continue
|
| 128 |
+
|
| 129 |
+
if company_pkd_prefixes:
|
| 130 |
+
is_agri_grant = any(kw in grant_text_lower for kw in ["rolnictwo", "produkcja rolna", "hodowla", "arimr"])
|
| 131 |
+
is_agri_company = any(p in ["01", "02"] for p in company_pkd_prefixes)
|
| 132 |
+
|
| 133 |
+
if is_agri_grant and not is_agri_company:
|
| 134 |
+
grant.relevance_score = 0.0
|
| 135 |
+
grant.poetic_match = "Odrzucono: Brak działalności rolniczej (PKD)."
|
| 136 |
+
grant.explanation = {
|
| 137 |
+
"reason": "Niezgodność branży (Program rolniczy)",
|
| 138 |
+
"criteria": [],
|
| 139 |
+
"risks": "Wymagane PKD z sekcji rolniczej",
|
| 140 |
+
}
|
| 141 |
+
updated_grants.append(grant)
|
| 142 |
+
continue
|
| 143 |
+
|
| 144 |
+
if grant.poetic_match and grant.relevance_score > 0 and grant.explanation:
|
| 145 |
+
updated_grants.append(grant)
|
| 146 |
+
else:
|
| 147 |
+
grants_to_eval.append(grant)
|
| 148 |
+
|
| 149 |
+
if not grants_to_eval:
|
| 150 |
+
return {"eligible_grants": updated_grants, "current_agent": "supervisor"}
|
| 151 |
+
|
| 152 |
+
system_msgs = [SystemMessage(content=system_prompt) for _ in grants_to_eval]
|
| 153 |
+
human_msgs = [
|
| 154 |
+
HumanMessage(
|
| 155 |
+
content=(
|
| 156 |
+
f"Profil Firmy: {company_desc}\n"
|
| 157 |
+
f"Nabór: {grant.title}\n"
|
| 158 |
+
f"Instytucja: {getattr(grant, 'institution', '') or 'N/A'}\n"
|
| 159 |
+
f"Opis Naboru: {grant.description}\n\n"
|
| 160 |
+
"Zwróć wynik dopasowania uwzględniając strukturę MatchOutput."
|
| 161 |
+
)
|
| 162 |
+
)
|
| 163 |
+
for grant in grants_to_eval
|
| 164 |
+
]
|
| 165 |
+
|
| 166 |
+
messages_batch = [[s, h] for s, h in zip(system_msgs, human_msgs)]
|
| 167 |
+
|
| 168 |
+
try:
|
| 169 |
+
llm = get_llm(task_type="standard", structured_output_schema=MatchOutput)
|
| 170 |
+
results = llm.batch(messages_batch, config={"max_concurrency": 3})
|
| 171 |
+
|
| 172 |
+
for grant, parsed in zip(grants_to_eval, results):
|
| 173 |
+
if isinstance(parsed, dict):
|
| 174 |
+
grant.relevance_score = parsed.get("relevance_score", 0.0)
|
| 175 |
+
grant.poetic_match = parsed.get("poetic_match", "")
|
| 176 |
+
grant.explanation = parsed.get("explanation")
|
| 177 |
+
else:
|
| 178 |
+
grant.relevance_score = getattr(parsed, "relevance_score", 0.0)
|
| 179 |
+
grant.poetic_match = getattr(parsed, "poetic_match", "")
|
| 180 |
+
if getattr(parsed, "explanation", None):
|
| 181 |
+
grant.explanation = parsed.explanation.dict() if hasattr(parsed.explanation, "dict") else parsed.explanation
|
| 182 |
+
updated_grants.append(grant)
|
| 183 |
+
|
| 184 |
+
except Exception as e:
|
| 185 |
+
logger.error(f"Błąd LLM w operacji matcher_node (wieloprocesowej): {e}. Próba wykonania sekwencyjnego...")
|
| 186 |
+
|
| 187 |
+
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
|
| 188 |
+
def _invoke_with_retry(msg):
|
| 189 |
+
res = llm.invoke(msg)
|
| 190 |
+
score = res.get("relevance_score", getattr(res, "relevance_score", -1)) if isinstance(res, dict) else getattr(res, "relevance_score", -1)
|
| 191 |
+
if not (0 <= score <= 1):
|
| 192 |
+
raise ValueError("Relevance score out of bounds.")
|
| 193 |
+
return res
|
| 194 |
+
|
| 195 |
+
for i, grant in enumerate(grants_to_eval):
|
| 196 |
+
try:
|
| 197 |
+
parsed = _invoke_with_retry(messages_batch[i])
|
| 198 |
+
if isinstance(parsed, dict):
|
| 199 |
+
grant.relevance_score = parsed.get("relevance_score", 0.0)
|
| 200 |
+
grant.poetic_match = parsed.get("poetic_match", "")
|
| 201 |
+
grant.explanation = parsed.get("explanation")
|
| 202 |
+
else:
|
| 203 |
+
grant.relevance_score = getattr(parsed, "relevance_score", 0.0)
|
| 204 |
+
grant.poetic_match = getattr(parsed, "poetic_match", "")
|
| 205 |
+
if getattr(parsed, "explanation", None):
|
| 206 |
+
grant.explanation = parsed.explanation.dict() if hasattr(parsed.explanation, "dict") else parsed.explanation
|
| 207 |
+
updated_grants.append(grant)
|
| 208 |
+
except Exception as seq_err:
|
| 209 |
+
logger.error(f"Błąd sekwencyjny dla grantu {grant.title}: {seq_err}")
|
| 210 |
+
grant.relevance_score = 0.0
|
| 211 |
+
grant.poetic_match = "Błąd dopasowania."
|
| 212 |
+
grant.explanation = {
|
| 213 |
+
"reason": "Błąd LLM (fallback sekwencyjny)",
|
| 214 |
+
"criteria": [],
|
| 215 |
+
"risks": "Nie udało się zweryfikować",
|
| 216 |
+
}
|
| 217 |
+
updated_grants.append(grant)
|
| 218 |
+
|
| 219 |
+
try:
|
| 220 |
+
if citation_verifier or kruczkowski_trap_agent:
|
| 221 |
+
for i, g in enumerate(updated_grants[:5]):
|
| 222 |
+
try:
|
| 223 |
+
gtxt = f"{getattr(g, 'title', '') or (g.get('name','') if isinstance(g,dict) else '')} {getattr(g,'description','') or ''}"
|
| 224 |
+
prog = getattr(g, 'program', None) or (g.get('program') if isinstance(g, dict) else None) or "UNKNOWN"
|
| 225 |
+
if citation_verifier:
|
| 226 |
+
cit = citation_verifier.verify_text_citations(gtxt[:1200], prog)
|
| 227 |
+
if isinstance(g, dict):
|
| 228 |
+
g["v5_citation_score"] = cit.get("overall_citation_score")
|
| 229 |
+
g["v5_hard_refs"] = cit.get("hard_regulation_refs_extracted", [])[:3]
|
| 230 |
+
else:
|
| 231 |
+
setattr(g, "v5_citation_score", cit.get("overall_citation_score"))
|
| 232 |
+
if kruczkowski_trap_agent:
|
| 233 |
+
tr = kruczkowski_trap_agent.detect_traps(gtxt[:1200], prog)
|
| 234 |
+
risk = tr.get("overall_trap_risk", "low")
|
| 235 |
+
if isinstance(g, dict):
|
| 236 |
+
g["v5_trap_risk"] = risk
|
| 237 |
+
else:
|
| 238 |
+
setattr(g, "v5_trap_risk", risk)
|
| 239 |
+
if isinstance(g, dict) and "is_current_only" not in g:
|
| 240 |
+
g["is_current_only"] = (g.get("status") in ("active","planned") and not g.get("is_outdated_warning"))
|
| 241 |
+
except Exception:
|
| 242 |
+
pass
|
| 243 |
+
except Exception:
|
| 244 |
+
pass
|
| 245 |
+
|
| 246 |
+
return {"eligible_grants": updated_grants, "current_agent": "supervisor"}
|
backend/agents/panel_nodes.py
ADDED
|
@@ -0,0 +1,562 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Dict, Any
|
| 2 |
+
from langchain_core.messages import HumanMessage, ToolMessage, SystemMessage, AIMessage
|
| 3 |
+
from core.llm_router import get_llm
|
| 4 |
+
from core.search.regulation_engine import regulation_engine, kruczkowski_trap_agent
|
| 5 |
+
from core.trust.trust_scorer import compute_grant_trust_score
|
| 6 |
+
|
| 7 |
+
from agents.auditor import (
|
| 8 |
+
GlobalAuditOutput,
|
| 9 |
+
_PerspectiveResult,
|
| 10 |
+
_ROLE_PROMPTS,
|
| 11 |
+
_SHARED_INSTRUCTIONS,
|
| 12 |
+
)
|
| 13 |
+
from agents.tools.legal_retriever_tool import search_legal_documents
|
| 14 |
+
from agents.tools.krs_graph_tool import analyze_company_network
|
| 15 |
+
from agents.tools.neo4j_cypher_tool import query_neo4j_graph
|
| 16 |
+
from agents.tools.budget_rules_tool import search_budget_rules
|
| 17 |
+
from agents.tools.technology_retriever_tool import search_technology_trends
|
| 18 |
+
from agents.panel_state import AuditorPanelState
|
| 19 |
+
|
| 20 |
+
import logging
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
# --- PRAWNIK NODE (Dynamic Query Routing) ---
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def prawnik_node(state: AuditorPanelState) -> Dict[str, Any]:
|
| 28 |
+
"""Agent Prawny z obsługą poszukiwań w RAG oraz RAG Grafowym (KRS)."""
|
| 29 |
+
llm_with_tools = get_llm(
|
| 30 |
+
task_type="legal_audit",
|
| 31 |
+
tools=[search_legal_documents, analyze_company_network, query_neo4j_graph],
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
# Inicjalizacja wiadomości, jeśli pierwsze wywołanie
|
| 35 |
+
messages = state.get("messages", [])
|
| 36 |
+
initial_messages_added = []
|
| 37 |
+
if not messages:
|
| 38 |
+
ext_prompt = (
|
| 39 |
+
"Zewnętrzny Rewizor: Weryfikujesz cudzy, gotowy wniosek (z biura konsultingowego) przesłany do nas w celu tzw. Reverse-Audit. Nastaw się na bezlitosną weryfikację błędów."
|
| 40 |
+
if state.get("is_external_audit", False)
|
| 41 |
+
else ""
|
| 42 |
+
)
|
| 43 |
+
sys_prompt = f"{_ROLE_PROMPTS['prawnik']}\n{ext_prompt}\n{_SHARED_INSTRUCTIONS}\n\nProgram: {state['program_name']}\nZanim ocenisz, zawsze skorzystaj z narzędzia search_legal_documents, aby sprawdzić wymogi dla perspektywy {state['program_name']}. Jeśli nie znajdziesz nic lub perspektywa nie będzie się zgadzać, PONÓW WYSZUKIWANIE z innym zapytaniem. Jak jesteś gotowy wydać ocenę wywołaj narzędzie submit_evaluation, NIE generuj go jako plain text."
|
| 44 |
+
initial_messages_added.append(SystemMessage(content=sys_prompt))
|
| 45 |
+
initial_messages_added.append(
|
| 46 |
+
HumanMessage(content=f"TREŚĆ WNIOSKU:\n{state['content'][:150000]}")
|
| 47 |
+
)
|
| 48 |
+
messages = initial_messages_added
|
| 49 |
+
|
| 50 |
+
# Wywołanie modelu
|
| 51 |
+
try:
|
| 52 |
+
response = llm_with_tools.invoke(messages)
|
| 53 |
+
except Exception as e:
|
| 54 |
+
logger.error(f"[PRAWNIK] Błąd wywołania modelu: {e}")
|
| 55 |
+
response = AIMessage(
|
| 56 |
+
content=f"Wystąpił błąd podczas wywołania LLM: {e}. Przechodzę do podsumowania."
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
return {
|
| 60 |
+
"messages": initial_messages_added + [response],
|
| 61 |
+
"legal_attempts": state.get("legal_attempts", 0),
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def prawnik_tools_node(state: AuditorPanelState) -> Dict[str, Any]:
|
| 66 |
+
"""Uruchamia narzędzie wyszukiwania dla Prawnika."""
|
| 67 |
+
last_message = state["messages"][-1]
|
| 68 |
+
tool_messages = []
|
| 69 |
+
|
| 70 |
+
for tool_call in last_message.tool_calls:
|
| 71 |
+
if tool_call["name"] in [
|
| 72 |
+
"search_legal_documents",
|
| 73 |
+
"analyze_company_network",
|
| 74 |
+
"query_neo4j_graph",
|
| 75 |
+
]:
|
| 76 |
+
logger.info(
|
| 77 |
+
f"[PRAWNIK] Wykorzystanie narzędzia {tool_call['name']}: {tool_call['args']}"
|
| 78 |
+
)
|
| 79 |
+
# Bezpieczne wykonanie narzędzia
|
| 80 |
+
try:
|
| 81 |
+
if tool_call["name"] == "search_legal_documents":
|
| 82 |
+
result = search_legal_documents.invoke(tool_call["args"])
|
| 83 |
+
elif tool_call["name"] == "analyze_company_network":
|
| 84 |
+
result = analyze_company_network.invoke(tool_call["args"])
|
| 85 |
+
else:
|
| 86 |
+
result = query_neo4j_graph.invoke(tool_call["args"])
|
| 87 |
+
except Exception as e:
|
| 88 |
+
result = f"Błąd wykonania narzędzia: {e}"
|
| 89 |
+
tool_messages.append(
|
| 90 |
+
ToolMessage(content=result, tool_call_id=tool_call["id"])
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
return {
|
| 94 |
+
"messages": tool_messages,
|
| 95 |
+
"legal_attempts": state.get("legal_attempts", 0) + 1,
|
| 96 |
+
"legal_queries": [str(tc["args"]) for tc in last_message.tool_calls],
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def prawnik_evaluator_node(state: AuditorPanelState) -> Dict[str, Any]:
|
| 101 |
+
"""Generuje ostateczny Pydantic output Prawnika po zebraniu wiedzy z RAG."""
|
| 102 |
+
# Ekstrakcja do schematu
|
| 103 |
+
llm = get_llm(task_type="legal_audit", structured_output_schema=_PerspectiveResult)
|
| 104 |
+
|
| 105 |
+
# Przebieg całej konwersacji prawnika
|
| 106 |
+
conversation_text = "\n".join(
|
| 107 |
+
[m.content for m in state["messages"] if isinstance(m.content, str)]
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
# Aktywne użycie silnika w Prawniku (Faza 3) - nie tylko kontekst, ale weryfikacja
|
| 111 |
+
engine_rules = ""
|
| 112 |
+
engine_compliance_check = ""
|
| 113 |
+
trust_context = ""
|
| 114 |
+
try:
|
| 115 |
+
rules = regulation_engine.get_structured_rules_for_program(state['program_name'] or "")
|
| 116 |
+
if rules:
|
| 117 |
+
engine_rules = "\n\n--- STRUCTURED RULES FROM REGULATION ENGINE (traktuj jako źródło prawdy) ---\n"
|
| 118 |
+
engine_rules += "\n".join(rules.get("key_rules", [])[:7])
|
| 119 |
+
|
| 120 |
+
# Aktywna weryfikacja + wpływ na wynik (Faza 3)
|
| 121 |
+
compliance = regulation_engine.check_cost_eligibility(state['program_name'] or "", state['content'][:3000])
|
| 122 |
+
if compliance.get("status") == "evaluated":
|
| 123 |
+
engine_compliance_check = f"\n\n--- REGULATION ENGINE COMPLIANCE CHECK ---\n{compliance}"
|
| 124 |
+
if compliance.get("severity") == "critical":
|
| 125 |
+
engine_compliance_check += "\n[CRITICAL ISSUE - RECOMMEND BLOCKING EXPORT OR HUMAN REVIEW]"
|
| 126 |
+
# Dodajemy issue bezpośrednio do wyniku (zostanie scalone w ewaluatorze)
|
| 127 |
+
|
| 128 |
+
# v5.0: Kruczkowski Compliance & Trap + Citation Verifier integration (Faza 2/3)
|
| 129 |
+
try:
|
| 130 |
+
if kruczkowski_trap_agent:
|
| 131 |
+
trap_result = kruczkowski_trap_agent.detect_traps(
|
| 132 |
+
document_text=state['content'][:4500],
|
| 133 |
+
program=state['program_name'] or "",
|
| 134 |
+
msp_context=state.get("msp_analysis") or state.get("external_context", {}).get("msp_analysis")
|
| 135 |
+
)
|
| 136 |
+
if trap_result.get("overall_trap_risk") in ("high", "critical"):
|
| 137 |
+
engine_compliance_check += f"\n\n--- KRUCZKOWSKI COMPLIANCE & TRAP v5.0 ---\nRisk: {trap_result['overall_trap_risk']} | Traps: {trap_result['num_traps']} | Blocks export: {trap_result.get('blocks_export_recommendation')}\nCitation score: {trap_result.get('citation_verification', {}).get('overall_citation_score')}\n[STRICT MODE: Zalecane dodatkowe sprawdzenie przez człowieka]"
|
| 138 |
+
# Zawsze wstrzykujemy citation grounding info
|
| 139 |
+
cit = trap_result.get("citation_verification", {})
|
| 140 |
+
if cit.get("overall_citation_score"):
|
| 141 |
+
engine_compliance_check += f"\n[CITATION GROUNDING: {cit.get('overall_citation_score')} ({cit.get('citation_quality')}) — {cit.get('recommendation', '')[:120]}]"
|
| 142 |
+
except Exception as _trap_e:
|
| 143 |
+
logger.debug(f"[Prawnik v5.0] Trap agent skipped: {_trap_e}")
|
| 144 |
+
|
| 145 |
+
# Trust Score injection (Cycle 10)
|
| 146 |
+
trust_score = compute_grant_trust_score({"program": state.get('program_name')})
|
| 147 |
+
trust_context = f"\n\n[Trust Score dla programu: {trust_score}/100 — im niższy, tym ostrożniej podchodzić do wniosków i rekomendacji]"
|
| 148 |
+
except Exception:
|
| 149 |
+
pass
|
| 150 |
+
|
| 151 |
+
prompt = f"""
|
| 152 |
+
Na podstawie zebranych dotychczas informacji i analizy (patrz historia, upewnij się, że opierasz się na zweryfikowanym prawie z narzędzia):
|
| 153 |
+
{conversation_text}
|
| 154 |
+
{engine_rules}
|
| 155 |
+
{engine_compliance_check}
|
| 156 |
+
{trust_context}
|
| 157 |
+
|
| 158 |
+
Wygeneruj ostateczny wynik audytu prawnego dla wniosku ({state['program_name']}) wg struktury.
|
| 159 |
+
Oceń projekt. Role: prawnik.
|
| 160 |
+
TREŚĆ:
|
| 161 |
+
{state['content'][:150000]}
|
| 162 |
+
"""
|
| 163 |
+
|
| 164 |
+
from tenacity import retry, stop_after_attempt, wait_exponential
|
| 165 |
+
|
| 166 |
+
@retry(
|
| 167 |
+
stop=stop_after_attempt(5),
|
| 168 |
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
| 169 |
+
reraise=True,
|
| 170 |
+
)
|
| 171 |
+
def invoke_eval():
|
| 172 |
+
result: _PerspectiveResult = llm.invoke(prompt)
|
| 173 |
+
if not result.summary or len(result.summary.strip()) < 10:
|
| 174 |
+
raise ValueError("Błąd sanity check: Puste podsumowanie audytu prawnego.")
|
| 175 |
+
for issue in result.issues:
|
| 176 |
+
issue.perspective = "prawnik"
|
| 177 |
+
|
| 178 |
+
# Jeśli silnik wykrył critical problem — obniżamy score
|
| 179 |
+
if "CRITICAL ISSUE" in engine_compliance_check:
|
| 180 |
+
result.partial_score = max(0, result.partial_score - 30)
|
| 181 |
+
|
| 182 |
+
return {
|
| 183 |
+
"issues": result.issues,
|
| 184 |
+
"perspectives_summary": {"prawnik": result.summary},
|
| 185 |
+
"perspective_scores": [result.partial_score],
|
| 186 |
+
"prawnik_done": True,
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
try:
|
| 190 |
+
return invoke_eval()
|
| 191 |
+
except Exception as e:
|
| 192 |
+
logger.error(f"[PRAWNIK] Ostateczny błąd ewaluatora: {e}")
|
| 193 |
+
return {
|
| 194 |
+
"prawnik_done": True,
|
| 195 |
+
"perspectives_summary": {
|
| 196 |
+
"prawnik": f"Błąd audytu prawnego po 5 próbach: {e}"
|
| 197 |
+
},
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def prawnik_routing(state: AuditorPanelState) -> str:
|
| 202 |
+
"""Decyduje czy prawnik musi szukać dalej, oceniać czy przekroczył limit."""
|
| 203 |
+
last_message = state["messages"][-1]
|
| 204 |
+
|
| 205 |
+
if last_message.tool_calls:
|
| 206 |
+
if state["legal_attempts"] >= 3:
|
| 207 |
+
logger.warning(
|
| 208 |
+
"[PRAWNIK] Przekroczono limit wyszukiwań, wymuszam ewaluację."
|
| 209 |
+
)
|
| 210 |
+
return "evaluate"
|
| 211 |
+
return "tools"
|
| 212 |
+
|
| 213 |
+
return "evaluate"
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
# --- FINANSISTA NODE (Dynamic Query Routing) ---
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def finansista_node(state: AuditorPanelState) -> Dict[str, Any]:
|
| 220 |
+
"""Agent Finansowy z obsługą poszukiwań w RAG (regulaminy finansowe)."""
|
| 221 |
+
llm_with_tools = get_llm(
|
| 222 |
+
task_type="legal_audit",
|
| 223 |
+
tools=[search_budget_rules, analyze_company_network, query_neo4j_graph],
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
# Inicjalizacja wiadomości, jeśli pierwsze wywołanie
|
| 227 |
+
messages = state.get("finansista_messages", [])
|
| 228 |
+
initial_messages_added = []
|
| 229 |
+
if not messages:
|
| 230 |
+
ext_prompt = (
|
| 231 |
+
"Zewnętrzny Rewizor: Weryfikujesz cudzy, gotowy wniosek (z biura konsultingowego) przesłany do nas w celu tzw. Reverse-Audit."
|
| 232 |
+
if state.get("is_external_audit", False)
|
| 233 |
+
else ""
|
| 234 |
+
)
|
| 235 |
+
sys_prompt = f"{_ROLE_PROMPTS['finansista']}\n{ext_prompt}\n{_SHARED_INSTRUCTIONS}\n\nProgram: {state['program_name']}\nZanim ocenisz wniosek, używaj narzędzia search_budget_rules aby sprawdzić zasady z budżetu programu. Aby zweryfikować MŚP z perspektywy finansowej na podstawie NIP/KRS uzyj analyze_company_network. Gdy będziesz gotowy zwrócić ocenę bez korzystania z narzędzia, powróć i wykonaj finalną ocenę strukturyzowaną."
|
| 236 |
+
initial_messages_added.append(SystemMessage(content=sys_prompt))
|
| 237 |
+
initial_messages_added.append(
|
| 238 |
+
HumanMessage(content=f"TREŚĆ WNIOSKU:\n{state['content'][:150000]}")
|
| 239 |
+
)
|
| 240 |
+
messages = initial_messages_added
|
| 241 |
+
|
| 242 |
+
try:
|
| 243 |
+
response = llm_with_tools.invoke(messages)
|
| 244 |
+
except Exception as e:
|
| 245 |
+
logger.error(f"[FINANSISTA] Błąd wywołania modelu: {e}")
|
| 246 |
+
response = AIMessage(
|
| 247 |
+
content=f"Wystąpił błąd podczas wywołania LLM: {e}. Przechodzę do podsumowania."
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
return {
|
| 251 |
+
"finansista_messages": initial_messages_added + [response],
|
| 252 |
+
"finansista_attempts": state.get("finansista_attempts", 0),
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def finansista_tools_node(state: AuditorPanelState) -> Dict[str, Any]:
|
| 257 |
+
"""Uruchamia narzędzie wyszukiwania dla Finansisty."""
|
| 258 |
+
last_message = state["finansista_messages"][-1]
|
| 259 |
+
tool_messages = []
|
| 260 |
+
|
| 261 |
+
for tool_call in last_message.tool_calls:
|
| 262 |
+
if tool_call["name"] in [
|
| 263 |
+
"search_budget_rules",
|
| 264 |
+
"analyze_company_network",
|
| 265 |
+
"query_neo4j_graph",
|
| 266 |
+
]:
|
| 267 |
+
logger.info(
|
| 268 |
+
f"[FINANSISTA] Wykorzystanie narzędzia {tool_call['name']}: {tool_call['args']}"
|
| 269 |
+
)
|
| 270 |
+
try:
|
| 271 |
+
if tool_call["name"] == "search_budget_rules":
|
| 272 |
+
result = search_budget_rules.invoke(tool_call["args"])
|
| 273 |
+
elif tool_call["name"] == "analyze_company_network":
|
| 274 |
+
result = analyze_company_network.invoke(tool_call["args"])
|
| 275 |
+
else:
|
| 276 |
+
result = query_neo4j_graph.invoke(tool_call["args"])
|
| 277 |
+
except Exception as e:
|
| 278 |
+
result = f"Błąd wykonania narzędzia: {e}"
|
| 279 |
+
tool_messages.append(
|
| 280 |
+
ToolMessage(content=result, tool_call_id=tool_call["id"])
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
return {
|
| 284 |
+
"finansista_messages": tool_messages,
|
| 285 |
+
"finansista_attempts": state.get("finansista_attempts", 0) + 1,
|
| 286 |
+
"finansista_queries": [str(tc["args"]) for tc in last_message.tool_calls],
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def finansista_evaluator_node(state: AuditorPanelState) -> Dict[str, Any]:
|
| 291 |
+
"""Generuje ostateczny Pydantic output Finansisty po zebraniu wiedzy z RAG."""
|
| 292 |
+
llm = get_llm(task_type="legal_audit", structured_output_schema=_PerspectiveResult)
|
| 293 |
+
conversation_text = "\n".join(
|
| 294 |
+
[
|
| 295 |
+
m.content
|
| 296 |
+
for m in state.get("finansista_messages", [])
|
| 297 |
+
if isinstance(m.content, str)
|
| 298 |
+
]
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
# Aktywne użycie RegulationEngine przy analizie budżetowej (Faza 3)
|
| 302 |
+
engine_context = ""
|
| 303 |
+
trust_context = ""
|
| 304 |
+
try:
|
| 305 |
+
eligibility = regulation_engine.check_cost_eligibility(state.get('program_name') or "", state.get('content', '')[:2000])
|
| 306 |
+
if eligibility.get("status") == "evaluated":
|
| 307 |
+
engine_context = f"\n\nREGULATION ENGINE BUDGET CHECK (źródło prawdy):\n eligible={eligibility.get('eligible')} severity={eligibility.get('severity')}\n justification: {eligibility.get('justification','')}\n reference: {eligibility.get('regulation_reference','')}\n rec: {eligibility.get('recommendation','')}\n"
|
| 308 |
+
|
| 309 |
+
# v5.0 Citation + Kruczkowski for finansista (budget traps)
|
| 310 |
+
try:
|
| 311 |
+
if kruczkowski_trap_agent:
|
| 312 |
+
t = kruczkowski_trap_agent.detect_traps(state.get('content', '')[:3000], state.get('program_name') or "")
|
| 313 |
+
if t.get("citation_verification"):
|
| 314 |
+
c = t["citation_verification"]
|
| 315 |
+
engine_context += f"\n[CITATION GROUNDING (fin): {c.get('overall_citation_score')} / {c.get('citation_quality')}]"
|
| 316 |
+
if t.get("overall_trap_risk") in ("high", "critical"):
|
| 317 |
+
engine_context += f"\n[KRUCZKOWSKI BUDGET TRAPS: {t.get('num_traps')} — {t.get('overall_trap_risk')}]"
|
| 318 |
+
except Exception:
|
| 319 |
+
pass
|
| 320 |
+
|
| 321 |
+
# Trust Score injection (Cycle 10) — teraz z citation boost z v5.0
|
| 322 |
+
trust_score = compute_grant_trust_score({
|
| 323 |
+
"program": state.get('program_name'),
|
| 324 |
+
"citation_verification_score": (t.get("citation_verification", {}).get("overall_citation_score") if 't' in locals() else None)
|
| 325 |
+
})
|
| 326 |
+
trust_context = f"\n\n[Trust Score dla programu: {trust_score}/100 — niski score = wyższe ryzyko w rekomendacjach finansowych]"
|
| 327 |
+
except Exception:
|
| 328 |
+
pass
|
| 329 |
+
|
| 330 |
+
prompt = f"""
|
| 331 |
+
Na podstawie zebranych dotychczas informacji i analizy finansowej/budżetowej (patrz historia):
|
| 332 |
+
{conversation_text}
|
| 333 |
+
{engine_context}
|
| 334 |
+
{trust_context}
|
| 335 |
+
|
| 336 |
+
Wygeneruj ostateczny wynik audytu finansowego dla wniosku ({state['program_name']}) wg struktury.
|
| 337 |
+
Oceń projekt. Role: finansista.
|
| 338 |
+
TREŚĆ:
|
| 339 |
+
{state['content'][:150000]}
|
| 340 |
+
"""
|
| 341 |
+
from tenacity import retry, stop_after_attempt, wait_exponential
|
| 342 |
+
|
| 343 |
+
@retry(
|
| 344 |
+
stop=stop_after_attempt(5),
|
| 345 |
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
| 346 |
+
reraise=True,
|
| 347 |
+
)
|
| 348 |
+
def invoke_eval():
|
| 349 |
+
result: _PerspectiveResult = llm.invoke(prompt)
|
| 350 |
+
if not result.summary or len(result.summary.strip()) < 10:
|
| 351 |
+
raise ValueError(
|
| 352 |
+
"Błąd sanity check: Puste podsumowanie audytu finansowego."
|
| 353 |
+
)
|
| 354 |
+
for issue in result.issues:
|
| 355 |
+
issue.perspective = "finansista"
|
| 356 |
+
return {
|
| 357 |
+
"issues": result.issues,
|
| 358 |
+
"perspectives_summary": {"finansista": result.summary},
|
| 359 |
+
"perspective_scores": [result.partial_score],
|
| 360 |
+
"finansista_done": True,
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
try:
|
| 364 |
+
return invoke_eval()
|
| 365 |
+
except Exception as e:
|
| 366 |
+
logger.error(f"[FINANSISTA] Ostateczny błąd ewaluatora: {e}")
|
| 367 |
+
return {
|
| 368 |
+
"finansista_done": True,
|
| 369 |
+
"perspectives_summary": {
|
| 370 |
+
"finansista": f"Błąd audytu finansowego po 5 próbach: {e}"
|
| 371 |
+
},
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
def finansista_routing(state: AuditorPanelState) -> str:
|
| 376 |
+
"""Decyduje czy finansista musi szukać dalej, czy oceniać."""
|
| 377 |
+
last_message = state["finansista_messages"][-1]
|
| 378 |
+
if last_message.tool_calls:
|
| 379 |
+
if state.get("finansista_attempts", 0) >= 3:
|
| 380 |
+
logger.warning(
|
| 381 |
+
"[FINANSISTA] Przekroczono limit wyszukiwań, wymuszam ewaluację."
|
| 382 |
+
)
|
| 383 |
+
return "evaluate"
|
| 384 |
+
return "tools"
|
| 385 |
+
return "evaluate"
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
# --- INNOWATOR NODE (Dynamic Query Routing) ---
|
| 389 |
+
def innowator_node(state: AuditorPanelState) -> Dict[str, Any]:
|
| 390 |
+
"""Agent Technologiczny (Innowator) z obsługą poszukiwań w RAG (trendy, KIS, B+R)."""
|
| 391 |
+
llm_with_tools = get_llm(task_type="legal_audit", tools=[search_technology_trends])
|
| 392 |
+
|
| 393 |
+
messages = state.get("innowator_messages", [])
|
| 394 |
+
initial_messages_added = []
|
| 395 |
+
if not messages:
|
| 396 |
+
ext_prompt = (
|
| 397 |
+
"Zewnętrzny Rewizor: Weryfikujesz cudzy, gotowy wniosek (z biura konsultingowego) przesłany do nas w celu tzw. Reverse-Audit."
|
| 398 |
+
if state.get("is_external_audit", False)
|
| 399 |
+
else ""
|
| 400 |
+
)
|
| 401 |
+
sys_prompt = f"{_ROLE_PROMPTS['innowator']}\n{ext_prompt}\n{_SHARED_INSTRUCTIONS}\n\nProgram: {state['program_name']}\nZanim dokonasz oceny innowacyjności, użyj narzędzia search_technology_trends, aby zweryfikować czy technologia, poziom TRL lub KIS są poprawne dla tego programu. Kiedy będziesz gotowy zwrócić ocenę, powróć i wykonaj finalną ocenę strukturyzowaną."
|
| 402 |
+
initial_messages_added.append(SystemMessage(content=sys_prompt))
|
| 403 |
+
initial_messages_added.append(
|
| 404 |
+
HumanMessage(content=f"TREŚĆ WNIOSKU:\n{state['content'][:150000]}")
|
| 405 |
+
)
|
| 406 |
+
messages = initial_messages_added
|
| 407 |
+
|
| 408 |
+
try:
|
| 409 |
+
response = llm_with_tools.invoke(messages)
|
| 410 |
+
except Exception as e:
|
| 411 |
+
logger.error(f"[INNOWATOR] Błąd wywołania modelu: {e}")
|
| 412 |
+
response = AIMessage(
|
| 413 |
+
content=f"Wystąpił błąd podczas wywołania LLM: {e}. Przechodzę do podsumowania."
|
| 414 |
+
)
|
| 415 |
+
|
| 416 |
+
return {
|
| 417 |
+
"innowator_messages": initial_messages_added + [response],
|
| 418 |
+
"innowator_attempts": state.get("innowator_attempts", 0),
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
|
| 422 |
+
def innowator_tools_node(state: AuditorPanelState) -> Dict[str, Any]:
|
| 423 |
+
"""Uruchamia narzędzie wyszukiwania dla Innowatora."""
|
| 424 |
+
last_message = state["innowator_messages"][-1]
|
| 425 |
+
tool_messages = []
|
| 426 |
+
|
| 427 |
+
for tool_call in last_message.tool_calls:
|
| 428 |
+
if tool_call["name"] == "search_technology_trends":
|
| 429 |
+
logger.info(
|
| 430 |
+
f"[INNOWATOR] Wykorzystanie narzędzia {tool_call['name']}: {tool_call['args']}"
|
| 431 |
+
)
|
| 432 |
+
try:
|
| 433 |
+
result = search_technology_trends.invoke(tool_call["args"])
|
| 434 |
+
except Exception as e:
|
| 435 |
+
result = f"Błąd wykonania narzędzia: {e}"
|
| 436 |
+
tool_messages.append(
|
| 437 |
+
ToolMessage(content=result, tool_call_id=tool_call["id"])
|
| 438 |
+
)
|
| 439 |
+
|
| 440 |
+
return {
|
| 441 |
+
"innowator_messages": tool_messages,
|
| 442 |
+
"innowator_attempts": state.get("innowator_attempts", 0) + 1,
|
| 443 |
+
"innowator_queries": [str(tc["args"]) for tc in last_message.tool_calls],
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
def innowator_evaluator_node(state: AuditorPanelState) -> Dict[str, Any]:
|
| 448 |
+
"""Generuje ostateczny Pydantic output Innowatora po zebraniu wiedzy z RAG."""
|
| 449 |
+
llm = get_llm(task_type="legal_audit", structured_output_schema=_PerspectiveResult)
|
| 450 |
+
conversation_text = "\n".join(
|
| 451 |
+
[
|
| 452 |
+
m.content
|
| 453 |
+
for m in state.get("innowator_messages", [])
|
| 454 |
+
if isinstance(m.content, str)
|
| 455 |
+
]
|
| 456 |
+
)
|
| 457 |
+
|
| 458 |
+
# Aktywne użycie RegulationEngine również w perspektywie innowacyjnej (Faza 3)
|
| 459 |
+
engine_context = ""
|
| 460 |
+
trust_context = ""
|
| 461 |
+
try:
|
| 462 |
+
rules = regulation_engine.get_structured_rules_for_program(state.get('program_name') or "")
|
| 463 |
+
if rules and (rules.get("key_rules") or rules.get("scoring_criteria")):
|
| 464 |
+
engine_context = "\n\n--- STRUCTURED PROGRAM RULES (Regulation Engine) — upewnij się, że innowacyjność jest zgodna z celami i kryteriami programu:\n"
|
| 465 |
+
engine_context += "KEY RULES: " + "; ".join((rules.get("key_rules") or [])[:4])
|
| 466 |
+
engine_context += "\nSCORING: " + "; ".join((rules.get("scoring_criteria") or [])[:3])
|
| 467 |
+
|
| 468 |
+
# Trust Score injection (Cycle 10)
|
| 469 |
+
trust_score = compute_grant_trust_score({"program": state.get('program_name')})
|
| 470 |
+
trust_context = f"\n\n[Trust Score dla programu: {trust_score}/100 — niski score sugeruje większą ostrożność przy ocenie innowacyjności]"
|
| 471 |
+
except Exception:
|
| 472 |
+
pass
|
| 473 |
+
|
| 474 |
+
prompt = f"""
|
| 475 |
+
Na podstawie zebranych dotychczas informacji i analizy innowacyjnej/technologicznej (patrz historia):
|
| 476 |
+
{conversation_text}
|
| 477 |
+
{engine_context}
|
| 478 |
+
{trust_context}
|
| 479 |
+
|
| 480 |
+
Wygeneruj ostateczny wynik audytu innowacyjnego dla wniosku ({state['program_name']}) wg struktury.
|
| 481 |
+
Oceń projekt. Role: innowator.
|
| 482 |
+
TREŚĆ:
|
| 483 |
+
{state['content'][:150000]}
|
| 484 |
+
"""
|
| 485 |
+
from tenacity import retry, stop_after_attempt, wait_exponential
|
| 486 |
+
|
| 487 |
+
@retry(
|
| 488 |
+
stop=stop_after_attempt(5),
|
| 489 |
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
| 490 |
+
reraise=True,
|
| 491 |
+
)
|
| 492 |
+
def invoke_eval():
|
| 493 |
+
result: _PerspectiveResult = llm.invoke(prompt)
|
| 494 |
+
if not result.summary or len(result.summary.strip()) < 10:
|
| 495 |
+
raise ValueError(
|
| 496 |
+
"Błąd sanity check: Puste podsumowanie audytu innowacyjnego."
|
| 497 |
+
)
|
| 498 |
+
for issue in result.issues:
|
| 499 |
+
issue.perspective = "innowator"
|
| 500 |
+
return {
|
| 501 |
+
"issues": result.issues,
|
| 502 |
+
"perspectives_summary": {"innowator": result.summary},
|
| 503 |
+
"perspective_scores": [result.partial_score],
|
| 504 |
+
"innowator_done": True,
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
try:
|
| 508 |
+
return invoke_eval()
|
| 509 |
+
except Exception as e:
|
| 510 |
+
logger.error(f"[INNOWATOR] Ostateczny błąd ewaluatora: {e}")
|
| 511 |
+
return {
|
| 512 |
+
"innowator_done": True,
|
| 513 |
+
"perspectives_summary": {
|
| 514 |
+
"innowator": f"Błąd audytu innowacyjnego po 5 próbach: {e}"
|
| 515 |
+
},
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
|
| 519 |
+
def innowator_routing(state: AuditorPanelState) -> str:
|
| 520 |
+
"""Decyduje czy Innowator musi szukać dalej, czy oceniać."""
|
| 521 |
+
last_message = state["innowator_messages"][-1]
|
| 522 |
+
if last_message.tool_calls:
|
| 523 |
+
if state.get("innowator_attempts", 0) >= 3:
|
| 524 |
+
logger.warning(
|
| 525 |
+
"[INNOWATOR] Przekroczono limit wyszukiwań, wymuszam ewaluację."
|
| 526 |
+
)
|
| 527 |
+
return "evaluate"
|
| 528 |
+
return "tools"
|
| 529 |
+
return "evaluate"
|
| 530 |
+
|
| 531 |
+
|
| 532 |
+
# --- ZARZĄDZAJĄCY NODE ---
|
| 533 |
+
def zarzadzajacy_node(state: AuditorPanelState) -> Dict[str, Any]:
|
| 534 |
+
"""Reduktor zbierający wszystkie dane i tworzący GlobalAuditOutput."""
|
| 535 |
+
scores = state.get("perspective_scores", [])
|
| 536 |
+
issues = state.get("issues", [])
|
| 537 |
+
|
| 538 |
+
has_critical = any(i.severity == "critical" for i in issues)
|
| 539 |
+
|
| 540 |
+
if not scores:
|
| 541 |
+
overall_score = 0
|
| 542 |
+
else:
|
| 543 |
+
base = int(sum(scores) / len(scores))
|
| 544 |
+
overall_score = max(0, base - 20) if has_critical else base
|
| 545 |
+
|
| 546 |
+
export_status = "ok"
|
| 547 |
+
if has_critical:
|
| 548 |
+
export_status = "blocked"
|
| 549 |
+
elif any(i.severity == "high" for i in issues):
|
| 550 |
+
export_status = "warning"
|
| 551 |
+
|
| 552 |
+
final = GlobalAuditOutput(
|
| 553 |
+
is_approved=not has_critical,
|
| 554 |
+
export_status=export_status,
|
| 555 |
+
overall_score=overall_score,
|
| 556 |
+
confidence_score=0.9, # LangGraph gives high confidence theoretically
|
| 557 |
+
human_review_required=has_critical or overall_score < 60,
|
| 558 |
+
issues=issues,
|
| 559 |
+
perspectives_summary=state.get("perspectives_summary", {}),
|
| 560 |
+
)
|
| 561 |
+
|
| 562 |
+
return {"final_output": final}
|
backend/agents/panel_state.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import TypedDict, List
|
| 2 |
+
import operator
|
| 3 |
+
from typing_extensions import Annotated
|
| 4 |
+
from langchain_core.messages import AnyMessage
|
| 5 |
+
from agents.auditor import AuditIssue, GlobalAuditOutput
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def merge_dicts(a: dict, b: dict) -> dict:
|
| 9 |
+
return {**(a or {}), **(b or {})}
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class AuditorPanelState(TypedDict):
|
| 13 |
+
project_id: str
|
| 14 |
+
program_name: str
|
| 15 |
+
content: str
|
| 16 |
+
is_external_audit: bool
|
| 17 |
+
# Agregacja błędów z poszczególnych ról
|
| 18 |
+
issues: Annotated[List[AuditIssue], operator.add]
|
| 19 |
+
perspectives_summary: Annotated[dict, merge_dicts]
|
| 20 |
+
# Przechowuje scores do finalnego uśrednienia
|
| 21 |
+
perspective_scores: Annotated[List[int], operator.add]
|
| 22 |
+
|
| 23 |
+
# Zarządzanie Dynamic Query Routing dla Prawnika
|
| 24 |
+
legal_attempts: int
|
| 25 |
+
legal_queries: Annotated[List[str], operator.add]
|
| 26 |
+
messages: Annotated[
|
| 27 |
+
List[AnyMessage], operator.add
|
| 28 |
+
] # służy do wymiany zapytań z narzędziami prawnika
|
| 29 |
+
prawnik_done: bool
|
| 30 |
+
|
| 31 |
+
# Zarządzanie Dynamic Query Routing dla Finansisty
|
| 32 |
+
finansista_attempts: int
|
| 33 |
+
finansista_queries: Annotated[List[str], operator.add]
|
| 34 |
+
finansista_messages: Annotated[List[AnyMessage], operator.add]
|
| 35 |
+
finansista_done: bool
|
| 36 |
+
|
| 37 |
+
# Zarządzanie Dynamic Query Routing dla Innowatora
|
| 38 |
+
innowator_attempts: int
|
| 39 |
+
innowator_queries: Annotated[List[str], operator.add]
|
| 40 |
+
innowator_messages: Annotated[List[AnyMessage], operator.add]
|
| 41 |
+
innowator_done: bool
|
| 42 |
+
|
| 43 |
+
# Wynik końcowy (wyliczony przez Zarządzającego)
|
| 44 |
+
final_output: GlobalAuditOutput
|
backend/agents/planner.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Dict, Any
|
| 2 |
+
from core.llm_router import get_llm
|
| 3 |
+
from schemas import AgentState, PlanOutput
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def planner_node(state: AgentState) -> Dict[str, Any]:
|
| 7 |
+
"""
|
| 8 |
+
Tworzy dynamiczny plan sesji i osadza go w Blackboard.
|
| 9 |
+
Uruchamiany jako pierwszy lub wywoływany w przypadku drastycznej zmiany intencji.
|
| 10 |
+
"""
|
| 11 |
+
llm = get_llm(task_type="standard", structured_output_schema=PlanOutput)
|
| 12 |
+
|
| 13 |
+
prompt = f"""
|
| 14 |
+
Jesteś Planner Agentem. Skonstruuj plan dzialania dla klienta w systemie decyzyjnym dotyczącym dotacji.
|
| 15 |
+
Cel: Na podstawie konwersacji, stwórz listę max 5 kroków, co należy zrobić dalej.
|
| 16 |
+
|
| 17 |
+
Ostatnia wiadomość od klienta: {state.messages[-1].content if state.messages else 'Nowa sesja'}
|
| 18 |
+
Obecny profil firmy: {state.profile.model_dump() if state.profile else 'Brak'}
|
| 19 |
+
|
| 20 |
+
Zwróć wynik jako uporządkowaną listę kroków w formacie schematu ustrukturyzowanego. PISZ ZAWSZE I WYŁĄCZNIE W JĘZYKU POLSKIM.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
try:
|
| 24 |
+
response = llm.invoke(prompt)
|
| 25 |
+
steps = response.steps
|
| 26 |
+
except Exception as e:
|
| 27 |
+
print(f"Błąd plannera: {e}")
|
| 28 |
+
steps = []
|
| 29 |
+
|
| 30 |
+
# Przełącznik awaryjny - jeśli nie uda się sparsować to używamy domyślnego
|
| 31 |
+
if not steps:
|
| 32 |
+
steps = [
|
| 33 |
+
"Zebrać pełen profil firmy (Profiler)",
|
| 34 |
+
"Znaleźć dopasowane dotacje (Matcher)",
|
| 35 |
+
]
|
| 36 |
+
|
| 37 |
+
return {
|
| 38 |
+
"task_plan": steps,
|
| 39 |
+
"current_agent": "supervisor", # Handoff back to supervisor
|
| 40 |
+
}
|
backend/agents/profiler.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
import logging
|
| 3 |
+
from typing import Optional
|
| 4 |
+
|
| 5 |
+
from schemas import AgentState, CompanyProfile, FinancialData
|
| 6 |
+
from tools.company_search import fetch_regon_data
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def extract_nip(text: str) -> Optional[str]:
|
| 12 |
+
clean_text = text.replace("-", "").replace(" ", "")
|
| 13 |
+
match = re.search(r"\d{10}", clean_text)
|
| 14 |
+
return match.group(0) if match else None
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def calculate_company_size(revenue: float, employment: int) -> str:
|
| 18 |
+
if employment < 10 and revenue <= 2_000_000:
|
| 19 |
+
return "Mikro"
|
| 20 |
+
if employment < 50 and revenue <= 10_000_000:
|
| 21 |
+
return "Mała"
|
| 22 |
+
if employment < 250 and revenue <= 50_000_000:
|
| 23 |
+
return "Średnia"
|
| 24 |
+
if employment >= 250 or revenue > 50_000_000:
|
| 25 |
+
return "Duża"
|
| 26 |
+
return "MŚP"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _profile_from_regon(nip: str, raw: dict) -> CompanyProfile:
|
| 30 |
+
revenue = float(raw.get("revenue") or 0)
|
| 31 |
+
employment = int(raw.get("employment") or 0)
|
| 32 |
+
pkds = raw.get("pkd") or []
|
| 33 |
+
if isinstance(pkds, str):
|
| 34 |
+
pkds = [pkds]
|
| 35 |
+
return CompanyProfile(
|
| 36 |
+
nip=nip,
|
| 37 |
+
name=(raw.get("name") or "").strip() or None,
|
| 38 |
+
regon=(raw.get("regon") or "").strip() or None,
|
| 39 |
+
krs=(raw.get("krs") or "").strip() or None,
|
| 40 |
+
address=(raw.get("address") or "").strip() or None,
|
| 41 |
+
legal_form=(raw.get("legal_form") or "").strip() or None,
|
| 42 |
+
data_source=(raw.get("data_source") or "unknown").strip(),
|
| 43 |
+
pkd_codes=pkds,
|
| 44 |
+
region=(raw.get("voivodeship") or "Nieznane").strip(),
|
| 45 |
+
size=calculate_company_size(revenue, employment),
|
| 46 |
+
financials=FinancialData(revenue=revenue, employment=employment),
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def profiler_node(state: AgentState):
|
| 51 |
+
if not state.messages:
|
| 52 |
+
return {"current_agent": "supervisor"}
|
| 53 |
+
|
| 54 |
+
user_msg = (
|
| 55 |
+
state.messages[-1]["content"]
|
| 56 |
+
if isinstance(state.messages[-1], dict)
|
| 57 |
+
else getattr(state.messages[-1], "content", "")
|
| 58 |
+
)
|
| 59 |
+
nip = extract_nip(user_msg)
|
| 60 |
+
if not nip:
|
| 61 |
+
return {
|
| 62 |
+
"current_agent": "supervisor",
|
| 63 |
+
"messages": [
|
| 64 |
+
{
|
| 65 |
+
"role": "assistant",
|
| 66 |
+
"content": (
|
| 67 |
+
"Podaj 10-cyfrowy NIP firmy, aby zbudować profil z rejestru GUS/REGON."
|
| 68 |
+
),
|
| 69 |
+
}
|
| 70 |
+
],
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
raw_data = fetch_regon_data(nip)
|
| 74 |
+
profile = _profile_from_regon(nip, raw_data)
|
| 75 |
+
|
| 76 |
+
blackboard = dict(state.blackboard or {})
|
| 77 |
+
blackboard.update(
|
| 78 |
+
{
|
| 79 |
+
"company_nip": nip,
|
| 80 |
+
"company_name": profile.name or "",
|
| 81 |
+
"company_regon": profile.regon or "",
|
| 82 |
+
"company_krs": profile.krs or "",
|
| 83 |
+
"company_address": profile.address or "",
|
| 84 |
+
"company_legal_form": profile.legal_form or "",
|
| 85 |
+
"company_pkd": profile.pkd_codes,
|
| 86 |
+
"company_region": profile.region,
|
| 87 |
+
"company_size": profile.size,
|
| 88 |
+
"company_data_source": profile.data_source,
|
| 89 |
+
"profile_enriched": True,
|
| 90 |
+
"using_real_data": profile.data_source
|
| 91 |
+
in ("gus_bir", "mf_whitelist", "regon_api"),
|
| 92 |
+
}
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
company_label = profile.name or f"NIP {nip}"
|
| 96 |
+
region_str = (
|
| 97 |
+
f", {profile.region}"
|
| 98 |
+
if profile.region and profile.region != "Nieznane"
|
| 99 |
+
else ""
|
| 100 |
+
)
|
| 101 |
+
pkd_str = (
|
| 102 |
+
", ".join(profile.pkd_codes[:2])
|
| 103 |
+
if profile.pkd_codes
|
| 104 |
+
else "brak PKD w rejestrze"
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
logger.info(
|
| 108 |
+
"profiler_node: %s NIP=%s source=%s",
|
| 109 |
+
company_label,
|
| 110 |
+
nip,
|
| 111 |
+
profile.data_source,
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
return {
|
| 115 |
+
"profile": profile,
|
| 116 |
+
"blackboard": blackboard,
|
| 117 |
+
"messages": [
|
| 118 |
+
{
|
| 119 |
+
"role": "assistant",
|
| 120 |
+
"content": (
|
| 121 |
+
f"Zidentyfikowałem: **{company_label}** (NIP {nip}{region_str}). "
|
| 122 |
+
f"PKD: {pkd_str}. Wielkość: {profile.size}. "
|
| 123 |
+
"Jaka jest główna potrzeba inwestycyjna?"
|
| 124 |
+
),
|
| 125 |
+
}
|
| 126 |
+
],
|
| 127 |
+
"current_agent": "supervisor",
|
| 128 |
+
}
|
backend/agents/red_team_auditor.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from typing import Dict, Any, List
|
| 3 |
+
try:
|
| 4 |
+
from core.llm_router import get_llm
|
| 5 |
+
except ImportError:
|
| 6 |
+
from backend.core.llm_router import get_llm
|
| 7 |
+
|
| 8 |
+
from langchain_core.messages import SystemMessage, HumanMessage
|
| 9 |
+
from pydantic import BaseModel, Field
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
class AuditScore(BaseModel):
|
| 14 |
+
score: int = Field(..., description="Punkty od 0 do 25 za ten wniosek", ge=0, le=25)
|
| 15 |
+
passed: bool = Field(..., description="Czy wniosek przeszedł minimalny próg? (np. 15 pkt)")
|
| 16 |
+
weaknesses: List[str] = Field(..., description="Lista konkretnych słabych punktów i braków")
|
| 17 |
+
legal_risks: List[str] = Field(..., description="Ryzyka prawne (np. limity de minimis, niedozwolone koszty)")
|
| 18 |
+
improvement_feedback: str = Field(..., description="Instrukcje dla Generator Agent, co musi zostać bezwzględnie poprawione")
|
| 19 |
+
|
| 20 |
+
class RedTeamAuditor:
|
| 21 |
+
"""
|
| 22 |
+
Faza 14: Finalna weryfikacja i ocena punktowa wniosku (Pre-Submission Audit).
|
| 23 |
+
Działa jak wirtualny ekspert z panelu (np. NCBR/PARP), odrzucając słabe wnioski.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
def __init__(self):
|
| 27 |
+
self.min_passing_score = 15
|
| 28 |
+
|
| 29 |
+
def audit_application(self, generated_sections: Dict[str, str], context: str, program_name: str) -> Dict[str, Any]:
|
| 30 |
+
"""
|
| 31 |
+
Ocenia cały wygenerowany wniosek na podstawie kontekstu.
|
| 32 |
+
"""
|
| 33 |
+
logger.info(f"[RedTeamAuditor] Rozpoczynam ocenę wniosku dla programu: {program_name}")
|
| 34 |
+
|
| 35 |
+
full_text = "\n\n".join([f"### {title}\n{content}" for title, content in generated_sections.items()])
|
| 36 |
+
|
| 37 |
+
system_prompt = (
|
| 38 |
+
"Jesteś surowym i nieustępliwym Ekspertem Oceniającym wniosek dotacyjny (Red Team).\n"
|
| 39 |
+
f"Twoim zadaniem jest ocenić gotowy wniosek pod kątem programu: {program_name}.\n"
|
| 40 |
+
"Ocena punktowa wynosi od 0 do 25 punktów. Aby projekt przeszedł, musi zdobyć min. 15 punktów.\n"
|
| 41 |
+
"Sprawdzaj innowacyjność, spójność budżetu, rygor prawny i zgodność z regulaminem (Kontekst RAG).\n"
|
| 42 |
+
"Nie bój się oblać wniosku (passed=False), jeśli są w nim halucynacje liczbowe, brak wkładu własnego "
|
| 43 |
+
"lub ogólniki zamiast konkretnych przewag konkurencyjnych."
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
human_content = f"Kontekst RAG (Regulaminy):\n{context[:3000]}\n\nTreść Wniosku do oceny:\n{full_text[:12000]}"
|
| 47 |
+
|
| 48 |
+
try:
|
| 49 |
+
llm = get_llm(task_type="critical", structured_output_schema=AuditScore)
|
| 50 |
+
response = llm.invoke([
|
| 51 |
+
SystemMessage(content=system_prompt),
|
| 52 |
+
HumanMessage(content=human_content)
|
| 53 |
+
])
|
| 54 |
+
|
| 55 |
+
# Wymuszenie odrzucenia jeśli score poniżej progu
|
| 56 |
+
if response.score < self.min_passing_score:
|
| 57 |
+
response.passed = False
|
| 58 |
+
|
| 59 |
+
result = {
|
| 60 |
+
"score": response.score,
|
| 61 |
+
"passed": response.passed,
|
| 62 |
+
"weaknesses": response.weaknesses,
|
| 63 |
+
"legal_risks": response.legal_risks,
|
| 64 |
+
"feedback": response.improvement_feedback
|
| 65 |
+
}
|
| 66 |
+
logger.info(f"[RedTeamAuditor] Zakończono ocenę. Wynik: {response.score}/25, Passed: {response.passed}")
|
| 67 |
+
return result
|
| 68 |
+
|
| 69 |
+
except Exception as e:
|
| 70 |
+
logger.error(f"[RedTeamAuditor] Błąd podczas oceny: {e}")
|
| 71 |
+
# FAIL-CLOSED: awaria audytu NIE może cicho przepuścić wniosku.
|
| 72 |
+
# Oznaczamy jako niezaakceptowany i wymagający weryfikacji człowieka.
|
| 73 |
+
return {
|
| 74 |
+
"score": 0,
|
| 75 |
+
"passed": False,
|
| 76 |
+
"human_review_required": True,
|
| 77 |
+
"weaknesses": ["Błąd ewaluacji przez LLM — wynik niezweryfikowany (fail-closed)."],
|
| 78 |
+
"legal_risks": [],
|
| 79 |
+
"feedback": "Błąd systemu oceniającego — wymagana ręczna weryfikacja przed eksportem."
|
| 80 |
+
}
|
backend/agents/research_agent.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import os
|
| 3 |
+
from typing import Optional, Dict
|
| 4 |
+
|
| 5 |
+
from langchain_core.messages import SystemMessage, HumanMessage
|
| 6 |
+
from core.llm_router import get_llm
|
| 7 |
+
|
| 8 |
+
try:
|
| 9 |
+
from tools.web_search import general_web_search
|
| 10 |
+
except ImportError:
|
| 11 |
+
from backend.tools.web_search import general_web_search
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def is_titan_research_enabled() -> bool:
|
| 17 |
+
"""HF free-tier guard: disable/limit Research Agent TITAN under memory pressure."""
|
| 18 |
+
return os.environ.get("ENABLE_TITAN_RESEARCH", "true").lower() in ("1", "true", "yes")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def titan_research_max_calls() -> int:
|
| 22 |
+
try:
|
| 23 |
+
return max(0, int(os.environ.get("TITAN_RESEARCH_MAX_CALLS", "1")))
|
| 24 |
+
except (TypeError, ValueError):
|
| 25 |
+
return 1
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class ResearchAgent:
|
| 29 |
+
"""
|
| 30 |
+
Sub-Agent badawczy Projektu TITAN.
|
| 31 |
+
Zadaniem tego agenta jest przeszukiwanie sieci (OSINT / Web Search)
|
| 32 |
+
oraz interpretacja wyników w przypadku braku danych w dokumencie.
|
| 33 |
+
Zastępuje człowieka w trybie pełnej automatyzacji, znacznie zwiększając skuteczność.
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
def __init__(self):
|
| 37 |
+
# Inicjalizacja LLM dla analizy wyników badawczych.
|
| 38 |
+
self.llm = get_llm(task_type="creative") # Używamy creative/standard
|
| 39 |
+
|
| 40 |
+
def deep_search(self, query: str, context: str) -> str:
|
| 41 |
+
"""
|
| 42 |
+
Głębokie poszukiwanie danych w zewnętrznych bazach i sieci,
|
| 43 |
+
oraz ich synteza przy użyciu LLM.
|
| 44 |
+
Soft-fail: nigdy nie zabija workera — zwraca pusty string przy błędzie/disable.
|
| 45 |
+
"""
|
| 46 |
+
if not is_titan_research_enabled() or titan_research_max_calls() <= 0:
|
| 47 |
+
logger.info(
|
| 48 |
+
"[Research Agent TITAN] Wyłączony (ENABLE_TITAN_RESEARCH / MAX_CALLS) — soft-fail"
|
| 49 |
+
)
|
| 50 |
+
return ""
|
| 51 |
+
|
| 52 |
+
logger.info(f"🔎 [Research Agent TITAN] Rozpoczęto głębokie wyszukiwanie dla: {query}")
|
| 53 |
+
|
| 54 |
+
try:
|
| 55 |
+
# 1. Wykonanie rzeczywistego wyszukiwania w sieci (bez live scrape URL-i)
|
| 56 |
+
search_results = general_web_search(query)
|
| 57 |
+
except Exception as e:
|
| 58 |
+
logger.warning(f"[Research Agent TITAN] Soft-fail web search: {e}")
|
| 59 |
+
return ""
|
| 60 |
+
|
| 61 |
+
# 2. Jeśli nie znaleziono lub brak klucza, zwracamy stosowną informację,
|
| 62 |
+
# ale możemy spróbować odpowiedzieć z wiedzy LLM z uwzględnieniem kontekstu.
|
| 63 |
+
if "Błąd" in search_results or "Brak klucza" in search_results:
|
| 64 |
+
logger.warning(f"[Research Agent TITAN] Problem z wyszukiwarką: {search_results}")
|
| 65 |
+
search_context = "Nie udało się pobrać aktualnych danych z sieci z powodu braku dostępu do API wyszukiwania."
|
| 66 |
+
else:
|
| 67 |
+
search_context = f"Wyniki wyszukiwania:\n{search_results}"
|
| 68 |
+
|
| 69 |
+
system_prompt = (
|
| 70 |
+
"Jesteś zaawansowanym Agentem Badawczym OSINT. Twoim zadaniem jest dostarczenie "
|
| 71 |
+
"wyczerpujących i merytorycznych informacji na podstawie podanego kontekstu projektu "
|
| 72 |
+
"oraz wyników wyszukiwania z sieci.\n\n"
|
| 73 |
+
"Zasady:\n"
|
| 74 |
+
"1. Twoim celem jest odpowiedź na brakujące zapytanie (pytanie o brakujące dane).\n"
|
| 75 |
+
"2. Wykorzystaj informacje z 'Wyników wyszukiwania', jeśli są dostępne.\n"
|
| 76 |
+
"3. Oprzyj się na 'Kontekście projektu', aby odpowiedź była dopasowana do specyfiki firmy i projektu.\n"
|
| 77 |
+
"4. Jeśli z danych sieciowych i kontekstu nie da się jednoznacznie określić faktów (np. precyzyjnego przychodu małej firmy), "
|
| 78 |
+
"zaproponuj profesjonalne i wiarygodne oszacowanie lub standardowe dla branży wartości rynkowe i zaznacz, że to szacunek, "
|
| 79 |
+
"aby wniosek dotacyjny mógł zostać wygenerowany jako pełny szkic (użyj znaczników np. [SZACOWANY_PRZYCHÓD: 1.5 mln PLN]).\n"
|
| 80 |
+
"5. Pisz wyłącznie w języku polskim, stylem profesjonalnym, odpowiednim do wniosków unijnych i biznesplanów.\n"
|
| 81 |
+
"6. NIE wymyślaj ani nie cytuj URL-i typu example.com / placeholder — tylko realne źródła z wyników."
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
human_content = f"Pytanie o brakujące dane: {query}\n\nKontekst projektu:\n{context}\n\n{search_context}"
|
| 85 |
+
|
| 86 |
+
try:
|
| 87 |
+
response = self.llm.invoke([
|
| 88 |
+
SystemMessage(content=system_prompt),
|
| 89 |
+
HumanMessage(content=human_content)
|
| 90 |
+
])
|
| 91 |
+
|
| 92 |
+
final_answer = response.content if hasattr(response, 'content') else str(response)
|
| 93 |
+
logger.info("[Research Agent TITAN] Zakończono syntezę wyników badawczych.")
|
| 94 |
+
return final_answer
|
| 95 |
+
except Exception as e:
|
| 96 |
+
logger.warning(f"[Research Agent TITAN] Soft-fail LLM: {e}")
|
| 97 |
+
return ""
|
| 98 |
+
|
| 99 |
+
research_agent = ResearchAgent()
|
backend/agents/researcher.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import logging
|
| 3 |
+
|
| 4 |
+
from schemas import AgentState, GrantCall
|
| 5 |
+
from core.search.grant_search_service import grant_search_service
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
CATALOG_LIMIT = 80
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _run_async(coro):
|
| 13 |
+
"""Uruchamia korutynę z kontekstu synchronicznego, bezpiecznie względem pętli.
|
| 14 |
+
|
| 15 |
+
Poprzedni wzorzec (get_event_loop().run_until_complete + fallback asyncio.run)
|
| 16 |
+
wywalał się, gdy pętla zdarzeń już działała (np. węzeł w async LangGraph):
|
| 17 |
+
obie ścieżki rzucają RuntimeError. Tutaj, jeśli pętla działa, wykonujemy
|
| 18 |
+
korutynę w osobnym wątku z własną pętlą.
|
| 19 |
+
"""
|
| 20 |
+
try:
|
| 21 |
+
running_loop = asyncio.get_running_loop()
|
| 22 |
+
except RuntimeError:
|
| 23 |
+
running_loop = None
|
| 24 |
+
|
| 25 |
+
if running_loop is not None:
|
| 26 |
+
import concurrent.futures
|
| 27 |
+
|
| 28 |
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
| 29 |
+
return pool.submit(lambda: asyncio.run(coro)).result()
|
| 30 |
+
return asyncio.run(coro)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _profile_to_company_data(profile) -> dict:
|
| 34 |
+
region = profile.region if profile.region and profile.region != "Nieznane" else ""
|
| 35 |
+
return {
|
| 36 |
+
"pkd": profile.pkd_codes or [],
|
| 37 |
+
"pkd_codes": profile.pkd_codes or [],
|
| 38 |
+
"size": profile.size or "mikro",
|
| 39 |
+
"voivodeship": region,
|
| 40 |
+
"address": region,
|
| 41 |
+
"entity_type": "przedsiębiorca",
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _filter_catalog_by_eligibility(items: list, company_data: dict) -> list:
|
| 46 |
+
if not items or not company_data.get("pkd"):
|
| 47 |
+
return items
|
| 48 |
+
try:
|
| 49 |
+
from core.graph_db.grant_eligibility import evaluate_grant_eligibility
|
| 50 |
+
|
| 51 |
+
scored = []
|
| 52 |
+
for item in items:
|
| 53 |
+
verdict = evaluate_grant_eligibility(item, company_data)
|
| 54 |
+
if verdict.get("eligible"):
|
| 55 |
+
item = dict(item)
|
| 56 |
+
item["_eligibility_score"] = 100 - verdict.get("score_penalty", 0)
|
| 57 |
+
scored.append(item)
|
| 58 |
+
scored.sort(key=lambda x: x.get("_eligibility_score", 0), reverse=True)
|
| 59 |
+
if scored:
|
| 60 |
+
return scored
|
| 61 |
+
except Exception as e:
|
| 62 |
+
logger.debug("researcher_node: eligibility pre-filter skip: %s", e)
|
| 63 |
+
return items
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _grants_from_db(profile) -> list:
|
| 67 |
+
"""Pobiera programy z PostgreSQL (import WYSZUKIWARKA) z pre-filtrem PKD/region."""
|
| 68 |
+
try:
|
| 69 |
+
from core.subscription.db import SessionLocal
|
| 70 |
+
from core.grants.catalog_service import search_catalog
|
| 71 |
+
|
| 72 |
+
db = SessionLocal()
|
| 73 |
+
try:
|
| 74 |
+
query_parts = []
|
| 75 |
+
if profile.name:
|
| 76 |
+
query_parts.append(profile.name)
|
| 77 |
+
if profile.size:
|
| 78 |
+
query_parts.append(profile.size)
|
| 79 |
+
if profile.region and profile.region != "Nieznane":
|
| 80 |
+
query_parts.append(profile.region)
|
| 81 |
+
if profile.pkd_codes:
|
| 82 |
+
query_parts.extend(profile.pkd_codes[:3])
|
| 83 |
+
if profile.investment_plans:
|
| 84 |
+
query_parts.extend([p.description for p in profile.investment_plans[:2]])
|
| 85 |
+
|
| 86 |
+
query = " ".join(query_parts)
|
| 87 |
+
catalog = search_catalog(db, query=query, limit=CATALOG_LIMIT, trusted_only=False)
|
| 88 |
+
if not catalog:
|
| 89 |
+
catalog = search_catalog(db, query="", limit=CATALOG_LIMIT, trusted_only=False)
|
| 90 |
+
|
| 91 |
+
company_data = _profile_to_company_data(profile)
|
| 92 |
+
catalog = _filter_catalog_by_eligibility(catalog, company_data)
|
| 93 |
+
|
| 94 |
+
grants = []
|
| 95 |
+
for item in catalog[:CATALOG_LIMIT]:
|
| 96 |
+
grants.append(
|
| 97 |
+
GrantCall(
|
| 98 |
+
title=f"{item.get('program', '')} - {item.get('name', '')}".strip(" -"),
|
| 99 |
+
description=_rich_description(item),
|
| 100 |
+
url=item.get("url", ""),
|
| 101 |
+
deadline=item.get("deadline") or "Brak potwierdzonego terminu",
|
| 102 |
+
max_amount=float(item.get("max_dofinansowanie_pln") or 0),
|
| 103 |
+
institution=item.get("operator") or item.get("program", ""),
|
| 104 |
+
)
|
| 105 |
+
)
|
| 106 |
+
return grants
|
| 107 |
+
finally:
|
| 108 |
+
db.close()
|
| 109 |
+
except Exception as e:
|
| 110 |
+
logger.warning("researcher_node: błąd odczytu katalogu DB: %s", e)
|
| 111 |
+
return []
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _rich_description(item: dict) -> str:
|
| 115 |
+
parts = [item.get("description") or ""]
|
| 116 |
+
if item.get("beneficjenci"):
|
| 117 |
+
parts.append(f"Beneficjenci: {item['beneficjenci']}")
|
| 118 |
+
if item.get("warunki_wejscia"):
|
| 119 |
+
parts.append(f"Warunki: {str(item['warunki_wejscia'])[:300]}")
|
| 120 |
+
if item.get("kwota_max"):
|
| 121 |
+
parts.append(f"Kwota: {item['kwota_max']}")
|
| 122 |
+
return " | ".join(p for p in parts if p)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def researcher_node(state: AgentState):
|
| 126 |
+
if not state.profile:
|
| 127 |
+
return {
|
| 128 |
+
"current_agent": "supervisor",
|
| 129 |
+
"messages": [
|
| 130 |
+
{
|
| 131 |
+
"role": "assistant",
|
| 132 |
+
"content": "Brak danych firmy do weryfikacji naborów.",
|
| 133 |
+
}
|
| 134 |
+
],
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
grants = _grants_from_db(state.profile)
|
| 138 |
+
source_note = f"katalog PostgreSQL (WYSZUKIWARKA, limit={CATALOG_LIMIT})"
|
| 139 |
+
|
| 140 |
+
if not grants:
|
| 141 |
+
voivodeship_filter = (
|
| 142 |
+
f" województwo {state.profile.region}"
|
| 143 |
+
if state.profile.region and state.profile.region != "Nieznane"
|
| 144 |
+
else " ogólnopolskie"
|
| 145 |
+
)
|
| 146 |
+
investment_keywords = ""
|
| 147 |
+
if state.profile.investment_plans:
|
| 148 |
+
investment_keywords = " " + " ".join(
|
| 149 |
+
[p.description for p in state.profile.investment_plans]
|
| 150 |
+
)
|
| 151 |
+
company_ref = state.profile.name or f"NIP {state.profile.nip}"
|
| 152 |
+
query = (
|
| 153 |
+
f"firma {company_ref} {state.profile.size}{voivodeship_filter} "
|
| 154 |
+
f"branża {', '.join(state.profile.pkd_codes)}{investment_keywords}"
|
| 155 |
+
)
|
| 156 |
+
source_note = "live scrape (fallback — pusta baza katalogowa)"
|
| 157 |
+
|
| 158 |
+
try:
|
| 159 |
+
search_results = _run_async(grant_search_service.search_grants(query, {}))
|
| 160 |
+
except Exception as e:
|
| 161 |
+
logger.error("Error in researcher_node live search: %s", e)
|
| 162 |
+
search_results = []
|
| 163 |
+
|
| 164 |
+
for r in search_results:
|
| 165 |
+
grants.append(
|
| 166 |
+
GrantCall(
|
| 167 |
+
title=f"{r.get('program', '')} - {r.get('name', '')}",
|
| 168 |
+
description=r.get("description", ""),
|
| 169 |
+
url=r.get("url", ""),
|
| 170 |
+
deadline=r.get("deadline") or "Brak potwierdzonego terminu",
|
| 171 |
+
max_amount=r.get("max_dofinansowanie_pln", 0.0),
|
| 172 |
+
)
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
if not grants:
|
| 176 |
+
return {
|
| 177 |
+
"eligible_grants": [],
|
| 178 |
+
"current_agent": "supervisor",
|
| 179 |
+
"messages": [
|
| 180 |
+
{
|
| 181 |
+
"role": "assistant",
|
| 182 |
+
"content": (
|
| 183 |
+
"Nie znaleziono programów dotacyjnych pasujących do profilu firmy. "
|
| 184 |
+
"Uruchom import katalogu: `python scripts/import_wyszukiwarka.py` "
|
| 185 |
+
"lub endpoint admin `/api/admin/grants/import-wyszukiwarka`."
|
| 186 |
+
),
|
| 187 |
+
}
|
| 188 |
+
],
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
logger.info("researcher_node: %s wyników ze źródła %s", len(grants), source_note)
|
| 192 |
+
return {
|
| 193 |
+
"eligible_grants": grants,
|
| 194 |
+
"current_agent": "supervisor",
|
| 195 |
+
"messages": [
|
| 196 |
+
{
|
| 197 |
+
"role": "assistant",
|
| 198 |
+
"content": f"Znaleziono {len(grants)} programów (źródło: {source_note}).",
|
| 199 |
+
}
|
| 200 |
+
],
|
| 201 |
+
}
|
backend/agents/risk_scoring.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from typing import Dict, Any
|
| 3 |
+
from langchain_core.messages import AIMessage
|
| 4 |
+
from core.llm_router import get_llm
|
| 5 |
+
from schemas import AgentState, RiskScoreOutput
|
| 6 |
+
from agents.helpers import ANTI_HALLUCINATION_PROMPT
|
| 7 |
+
from tenacity import retry, stop_after_attempt, wait_exponential
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def risk_scoring_node(state: AgentState) -> Dict[str, Any]:
|
| 13 |
+
"""
|
| 14 |
+
Hybrydowy system punktacji ryzyka (Opcja A - Twarde reguły + LLM).
|
| 15 |
+
LLM identyfikuje ryzyka i proponuje ocenę, ale Python nakłada twarde filtry biznesowe
|
| 16 |
+
(np. odrzucenie za status trudności, kary za brak przychodów).
|
| 17 |
+
"""
|
| 18 |
+
llm = get_llm(task_type="critical", structured_output_schema=RiskScoreOutput)
|
| 19 |
+
|
| 20 |
+
profile_dump = (
|
| 21 |
+
state.profile.model_dump() if state.profile else "Brak danych profilu"
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
prompt = f"""
|
| 25 |
+
{ANTI_HALLUCINATION_PROMPT}
|
| 26 |
+
|
| 27 |
+
Na podstawie profilu firmy przydziel jej hipotetyczną ocenę projektową 0-100 dla szans na uzyskanie dotacji UE.
|
| 28 |
+
Następnie wypunktuj DOKŁADNIE 5 RYZYK, które obniżają tę ocenę (np. słabe wyniki finansowe, brak innowacji).
|
| 29 |
+
|
| 30 |
+
Profil: {profile_dump}
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
|
| 35 |
+
def _invoke_llm():
|
| 36 |
+
res = llm.invoke(prompt)
|
| 37 |
+
if len(res.risks) != 5:
|
| 38 |
+
raise ValueError("LLM did not return exactly 5 risks.")
|
| 39 |
+
if not (0 <= res.score <= 100):
|
| 40 |
+
raise ValueError("Score out of bounds.")
|
| 41 |
+
return res
|
| 42 |
+
|
| 43 |
+
try:
|
| 44 |
+
response = _invoke_llm()
|
| 45 |
+
|
| 46 |
+
final_score = response.score
|
| 47 |
+
risks = response.risks.copy()
|
| 48 |
+
|
| 49 |
+
# --- TWARDE REGUŁY BIZNESOWE (HARD RULES) ---
|
| 50 |
+
if state.profile:
|
| 51 |
+
# 1. Firma w trudnej sytuacji - całkowita dyskwalifikacja
|
| 52 |
+
if getattr(state.profile, 'is_in_difficulty', False):
|
| 53 |
+
final_score = 0
|
| 54 |
+
risks.insert(0, "🚨 DYSKWALIFIKACJA: Firma w trudnej sytuacji ekonomicznej (zakaz wsparcia UE).")
|
| 55 |
+
|
| 56 |
+
# 2. Brak udokumentowanych przychodów - kara punktowa i narzucenie ryzyka
|
| 57 |
+
fin = getattr(state.profile, 'financials', None)
|
| 58 |
+
if not fin or getattr(fin, 'revenue', 0.0) <= 0:
|
| 59 |
+
final_score = min(final_score, 50) # Max 50 pkt dla firm bez przychodów
|
| 60 |
+
risks.insert(0, "⚠️ TWARDE RYZYKO: Brak historycznych przychodów (Startup) - ogromne ryzyko stabilności finansowej projektu.")
|
| 61 |
+
|
| 62 |
+
# 3. Zbyt duży projekt dla mikroprzedsiębiorstwa (heurystyka np. inwestycja > 5 mln)
|
| 63 |
+
plans = getattr(state.profile, 'investment_plans', [])
|
| 64 |
+
total_investment = sum(p.estimated_cost for p in plans) if plans else 0
|
| 65 |
+
if getattr(state.profile, 'size', '') == 'Mikro' and total_investment > 5_000_000:
|
| 66 |
+
final_score = max(0, final_score - 20)
|
| 67 |
+
risks.insert(0, "⚠️ TWARDE RYZYKO: Skala inwestycji (pow. 5 mln PLN) nieadekwatna do wielkości Mikroprzedsiębiorstwa.")
|
| 68 |
+
|
| 69 |
+
# Ograniczamy do max 5 ryzyk, ale twarde są zawsze na początku
|
| 70 |
+
risks = risks[:5]
|
| 71 |
+
|
| 72 |
+
# Zapis w state.risk_score
|
| 73 |
+
risk_score_update = {"score": final_score, "risks": risks}
|
| 74 |
+
score_text = f"WYNIK: {final_score}/100\nRYZYKA:\n" + "\n".join(
|
| 75 |
+
[f"{i+1}. {r}" for i, r in enumerate(risks)]
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
# Opcjonalny zapis wstecznej zgodności z Blackboard
|
| 79 |
+
blackboard_update = state.blackboard or {}
|
| 80 |
+
blackboard_update["last_risk_score"] = score_text
|
| 81 |
+
|
| 82 |
+
return {
|
| 83 |
+
"messages": [AIMessage(content=score_text)],
|
| 84 |
+
"risk_score": risk_score_update,
|
| 85 |
+
"blackboard": blackboard_update,
|
| 86 |
+
"current_agent": "supervisor",
|
| 87 |
+
}
|
| 88 |
+
except Exception as e:
|
| 89 |
+
# FAIL-CLOSED: na wyjątku NIE gubimy risk_score (co cicho omijałoby bramkę).
|
| 90 |
+
# Ustawiamy konserwatywny wynik wyzwalający bramkę / human review.
|
| 91 |
+
logger.error(f"Błąd risk scoring: {e}")
|
| 92 |
+
conservative = {
|
| 93 |
+
"score": 0,
|
| 94 |
+
"risks": [
|
| 95 |
+
"⚠️ Awaria mechanizmu oceny ryzyka — ustawiono konserwatywny wynik 0/100. "
|
| 96 |
+
"Wymagana ręczna weryfikacja przed eksportem."
|
| 97 |
+
],
|
| 98 |
+
"human_review_required": True,
|
| 99 |
+
"error": str(e)[:200],
|
| 100 |
+
}
|
| 101 |
+
return {
|
| 102 |
+
"risk_score": conservative,
|
| 103 |
+
"current_agent": "supervisor",
|
| 104 |
+
}
|
backend/agents/scraper_agent.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import asyncio
|
| 3 |
+
import sys
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
sys.path.append(os.path.join(os.path.dirname(__file__), "..", ".."))
|
| 7 |
+
try:
|
| 8 |
+
from backend.integrations.isap_client import ISAPClient
|
| 9 |
+
from backend.integrations.parp_client import PARPClient
|
| 10 |
+
from backend.rag_pipeline.scraper import scrape_grant_url
|
| 11 |
+
from backend.rag_pipeline.ingest import process_and_ingest
|
| 12 |
+
except ImportError:
|
| 13 |
+
try:
|
| 14 |
+
from integrations.isap_client import ISAPClient
|
| 15 |
+
from integrations.parp_client import PARPClient
|
| 16 |
+
from rag_pipeline.scraper import scrape_grant_url
|
| 17 |
+
from rag_pipeline.ingest import process_and_ingest
|
| 18 |
+
except ImportError:
|
| 19 |
+
ISAPClient = None
|
| 20 |
+
PARPClient = None
|
| 21 |
+
scrape_grant_url = None
|
| 22 |
+
process_and_ingest = None
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class ScraperAgent:
|
| 28 |
+
"""
|
| 29 |
+
Inteligentny Agent, który decyduje w jaki sposób pozyskać dane.
|
| 30 |
+
Przeznaczony do działania z Celery Beat / APScheduler do cyklicznych aktualizacji.
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
def __init__(self):
|
| 34 |
+
if ISAPClient and PARPClient:
|
| 35 |
+
self.isap_client = ISAPClient()
|
| 36 |
+
self.parp_client = PARPClient()
|
| 37 |
+
else:
|
| 38 |
+
self.isap_client = None
|
| 39 |
+
self.parp_client = None
|
| 40 |
+
# Namespace do ogólnodostępnej przestrzeni aktów prawnych (ISAP/EUR-Lex) — NIE dla naborów
|
| 41 |
+
self.public_namespace = "public_legal"
|
| 42 |
+
# Nabory / harmonogramy → grants catalog (nie public_legal)
|
| 43 |
+
self.grants_namespace = "grants_catalog"
|
| 44 |
+
|
| 45 |
+
async def run_sync_job(self):
|
| 46 |
+
"""Uruchamia cykliczny proces synchronizacji dotacji i prawa"""
|
| 47 |
+
if not self.isap_client or not self.parp_client or not scrape_grant_url:
|
| 48 |
+
logger.error("[AGENT] Brak klientow ISAP/PARP. Synchronizacja anulowana.")
|
| 49 |
+
return
|
| 50 |
+
|
| 51 |
+
logger.info(
|
| 52 |
+
"[AGENT] Rozpoczęcie automatycznego cyklu synchronizacji bazy wiedzy..."
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
# 1. PARP (Regulaminy Naborów) — Imperva: scrape często pusty → JSON fallback
|
| 56 |
+
# JSON fallback = wspólny blob z WyszukiwarkaImport → NIE wektoryzuj do public_legal
|
| 57 |
+
# i NIE ingestuj 4× tego samego pliku per grant key.
|
| 58 |
+
from rag_pipeline.ingest import is_parp_json_fallback_text
|
| 59 |
+
|
| 60 |
+
grants = self.parp_client.fetch_grants()
|
| 61 |
+
parp_live_ingested = False
|
| 62 |
+
parp_fallback_seen = False
|
| 63 |
+
for grant in grants:
|
| 64 |
+
url = grant["url"]
|
| 65 |
+
logger.info(f"[AGENT] Zlecam scrapowanie dla dotacji: {grant['id']}")
|
| 66 |
+
try:
|
| 67 |
+
text, _ = await scrape_grant_url(url)
|
| 68 |
+
if not text:
|
| 69 |
+
continue
|
| 70 |
+
if is_parp_json_fallback_text(text):
|
| 71 |
+
parp_fallback_seen = True
|
| 72 |
+
# DB import (WyszukiwarkaImport) already covers catalog — skip Pinecone
|
| 73 |
+
continue
|
| 74 |
+
process_and_ingest(
|
| 75 |
+
text, url, priority="high", namespace=self.grants_namespace
|
| 76 |
+
)
|
| 77 |
+
parp_live_ingested = True
|
| 78 |
+
except Exception as e:
|
| 79 |
+
logger.error(f"[AGENT] Błąd fetchowania {url}: {e}")
|
| 80 |
+
|
| 81 |
+
if parp_fallback_seen and not parp_live_ingested:
|
| 82 |
+
logger.info(
|
| 83 |
+
"[AGENT] PARP scrape pusty — skip Pinecone dla dotacje-latest.json "
|
| 84 |
+
"(katalog pokryty przez WyszukiwarkaImport)"
|
| 85 |
+
)
|
| 86 |
+
elif not parp_live_ingested and not parp_fallback_seen:
|
| 87 |
+
logger.warning(
|
| 88 |
+
"[AGENT] PARP: brak zawartości ze scrape i z JSON fallback"
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
# 2. ISAP (Ustawa z dn 6 marca 2018 - Prawo przedsiębiorców)
|
| 92 |
+
logger.info("[AGENT] Pobieranie ram prawnych (ISAP/ELI)")
|
| 93 |
+
act_info = self.isap_client.fetch_act("WDU", 2018, 646)
|
| 94 |
+
if act_info:
|
| 95 |
+
urls_to_try = [act_info["text_url"]]
|
| 96 |
+
if act_info.get("text_pdf_url"):
|
| 97 |
+
urls_to_try.append(act_info["text_pdf_url"])
|
| 98 |
+
for url in urls_to_try:
|
| 99 |
+
try:
|
| 100 |
+
text, _ = await scrape_grant_url(url)
|
| 101 |
+
if text:
|
| 102 |
+
process_and_ingest(
|
| 103 |
+
text, url, priority="critical", namespace=self.public_namespace
|
| 104 |
+
)
|
| 105 |
+
break
|
| 106 |
+
except Exception as e:
|
| 107 |
+
logger.error(f"[AGENT] Błąd parsowania ISAP {url}: {e}")
|
| 108 |
+
|
| 109 |
+
logger.info("[AGENT] Zakończono automatyczny cykl agenta synchronizacyjnego.")
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
if __name__ == "__main__":
|
| 113 |
+
logging.basicConfig(level=logging.INFO)
|
| 114 |
+
agent = ScraperAgent()
|
| 115 |
+
asyncio.run(agent.run_sync_job())
|
backend/agents/supervisor.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from schemas import AgentState, SupervisorDecision
|
| 2 |
+
from core.llm_router import get_llm
|
| 3 |
+
from functools import lru_cache
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class SimpleQueryRouter:
|
| 7 |
+
"""
|
| 8 |
+
v5.0 Real QueryRouter (previously stub) for autonomous flows + Master Orchestrator hooks.
|
| 9 |
+
Keyword + lightweight LLM fallback. Integrated into supervisor.
|
| 10 |
+
Enables full autonomous routing without external GSD swarm in main graph.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
AGENT_KEYWORDS = {
|
| 14 |
+
"profiler": ["profil", "dane firmy", "krs", "nip", "msp", "company"],
|
| 15 |
+
"researcher": ["szukaj", "znajdź", "dotacje", "nabory", "research"],
|
| 16 |
+
"matcher": ["dopasuj", "match", "rekomenduj", "najlepsze programy"],
|
| 17 |
+
"verifier": ["sprawdź", "weryfikuj", "formalnie", "zgodność"],
|
| 18 |
+
"wizard": ["pisz", "generuj", "sekcja", "wniosek", "treść"],
|
| 19 |
+
"risk_scoring": ["ryzyko", "szanse", "punktuj", "score"],
|
| 20 |
+
"document_gap_analyzer": ["braki", "gap", "analiza dokumentu"],
|
| 21 |
+
"compliance_guardian": ["rodo", "compliance", "ochrona danych", "kruczkowski"],
|
| 22 |
+
"end": ["koniec", "zakończ", "gotowe", "dziękuję"],
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
@staticmethod
|
| 26 |
+
@lru_cache(maxsize=64)
|
| 27 |
+
def route_query(query: str) -> str:
|
| 28 |
+
"""Fast cached routing for token efficiency."""
|
| 29 |
+
q = (query or "").lower()
|
| 30 |
+
for agent, kws in SimpleQueryRouter.AGENT_KEYWORDS.items():
|
| 31 |
+
if any(kw in q for kw in kws):
|
| 32 |
+
return agent
|
| 33 |
+
return "planner" # default safe
|
| 34 |
+
|
| 35 |
+
@staticmethod
|
| 36 |
+
def route(state: AgentState) -> str:
|
| 37 |
+
"""Full router used by supervisor (real implementation replacing stub)."""
|
| 38 |
+
if not state.messages:
|
| 39 |
+
return "planner"
|
| 40 |
+
if state.task_plan and len(state.task_plan) > 0:
|
| 41 |
+
next_task = state.task_plan[0].lower()
|
| 42 |
+
if "profil" in next_task:
|
| 43 |
+
return "profiler"
|
| 44 |
+
if any(x in next_task for x in ["dopas", "match", "rekomend"]):
|
| 45 |
+
return "matcher"
|
| 46 |
+
if any(x in next_task for x in ["gener", "pisz", "sekcj"]):
|
| 47 |
+
return "wizard"
|
| 48 |
+
last = state.messages[-1]
|
| 49 |
+
content = last.get("content", "") if isinstance(last, dict) else getattr(last, "content", "")
|
| 50 |
+
routed = SimpleQueryRouter.route_query(content)
|
| 51 |
+
# Fallback LLM only if keyword weak (token saving)
|
| 52 |
+
if routed == "planner" and len(content) > 40:
|
| 53 |
+
try:
|
| 54 |
+
llm = get_llm(task_type="fast")
|
| 55 |
+
decision = llm.invoke(f"Route this grant query to one agent: planner/profiler/matcher/wizard/risk_scoring. Query: {content[:200]}")
|
| 56 |
+
txt = getattr(decision, "content", str(decision)).lower()
|
| 57 |
+
for a in SimpleQueryRouter.AGENT_KEYWORDS:
|
| 58 |
+
if a in txt:
|
| 59 |
+
return a
|
| 60 |
+
except Exception:
|
| 61 |
+
pass
|
| 62 |
+
return routed
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def supervisor_node(state: AgentState):
|
| 66 |
+
"""
|
| 67 |
+
Supervisor (Router) z 2026 r. zintegrowany z Blackboard.
|
| 68 |
+
Kieruje wiadomości lub wywołuje wykonanie kolejnego zdefiniowanego kroku w task_plan.
|
| 69 |
+
v5.0: Uses real SimpleQueryRouter for autonomous flow polish + Master Orchestrator compatibility.
|
| 70 |
+
"""
|
| 71 |
+
if not state.messages:
|
| 72 |
+
return {"current_agent": "planner"}
|
| 73 |
+
|
| 74 |
+
# Check if there is an active plan in blackboard to execute
|
| 75 |
+
if state.task_plan and len(state.task_plan) > 0:
|
| 76 |
+
next_task = state.task_plan[0].lower()
|
| 77 |
+
if "profil" in next_task:
|
| 78 |
+
return {"current_agent": "profiler"}
|
| 79 |
+
elif "dopasowa" in next_task or "match" in next_task:
|
| 80 |
+
return {"current_agent": "matcher"}
|
| 81 |
+
# Logika oparta na planie będzie o wiele bardziej rozbudowana w produkcji.
|
| 82 |
+
|
| 83 |
+
# v5.0 Real QueryRouter path (polish for full autonomous)
|
| 84 |
+
try:
|
| 85 |
+
next_agent = SimpleQueryRouter.route(state)
|
| 86 |
+
valid = ["planner", "profiler", "researcher", "matcher", "verifier", "wizard",
|
| 87 |
+
"risk_scoring", "document_gap_analyzer", "compliance_guardian", "end"]
|
| 88 |
+
if next_agent in valid:
|
| 89 |
+
return {"current_agent": next_agent}
|
| 90 |
+
except Exception:
|
| 91 |
+
pass
|
| 92 |
+
|
| 93 |
+
last_msg = (
|
| 94 |
+
state.messages[-1].get("content", "")
|
| 95 |
+
if isinstance(state.messages[-1], dict)
|
| 96 |
+
else getattr(state.messages[-1], "content", "")
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
prompt = f"""
|
| 100 |
+
Jesteś supervisorem (dyrektorem) systemu 'GrantForge AI'.
|
| 101 |
+
Mamy następujące działy (agentów):
|
| 102 |
+
- planner: planowanie działań
|
| 103 |
+
- profiler: zbieranie danych firmy z KRS/chatu
|
| 104 |
+
- researcher: eksploracja dotacji
|
| 105 |
+
- matcher: dopasowanie znanych dotacji
|
| 106 |
+
- verifier: sprawdzanie formalne
|
| 107 |
+
- wizard: pisanie wniosku, wymyślanie treści
|
| 108 |
+
- risk_scoring: punktowanie szans i ryzyk wniosku
|
| 109 |
+
- document_gap_analyzer: analiza braków dokumentu
|
| 110 |
+
- compliance_guardian: sprawdzanie RODO
|
| 111 |
+
- end: koniec procesu, oddanie głosu klientowi
|
| 112 |
+
|
| 113 |
+
Na podstawie ostatniej wiadomości opisz krótko powód (reason) i wskaż jednoznaczną wartość next_agent z listy powyżej.
|
| 114 |
+
Wiadomość z systemu klienta: {last_msg}
|
| 115 |
+
"""
|
| 116 |
+
|
| 117 |
+
try:
|
| 118 |
+
llm = get_llm(task_type="standard", structured_output_schema=SupervisorDecision)
|
| 119 |
+
decision = llm.invoke(prompt)
|
| 120 |
+
|
| 121 |
+
valid_agents = [
|
| 122 |
+
"planner",
|
| 123 |
+
"profiler",
|
| 124 |
+
"researcher",
|
| 125 |
+
"matcher",
|
| 126 |
+
"verifier",
|
| 127 |
+
"wizard",
|
| 128 |
+
"risk_scoring",
|
| 129 |
+
"document_gap_analyzer",
|
| 130 |
+
"compliance_guardian",
|
| 131 |
+
"end",
|
| 132 |
+
]
|
| 133 |
+
if decision.next_agent in valid_agents:
|
| 134 |
+
# W przyszłości reason można wykorzystać do logowania logiki routing-u
|
| 135 |
+
return {"current_agent": decision.next_agent}
|
| 136 |
+
except Exception as e:
|
| 137 |
+
print(f"Błąd supervisora LLM: {str(e)}")
|
| 138 |
+
|
| 139 |
+
return {"current_agent": "end"}
|
backend/agents/timeline.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from typing import Dict, Any
|
| 3 |
+
from schemas import AgentState
|
| 4 |
+
|
| 5 |
+
logger = logging.getLogger(__name__)
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def timeline_node(state: AgentState) -> Dict[str, Any]:
|
| 9 |
+
"""
|
| 10 |
+
Węzeł generujący harmonogram (timeline_events) dla naborów w których firma może wziąć udział.
|
| 11 |
+
"""
|
| 12 |
+
grants = state.eligible_grants
|
| 13 |
+
if not grants:
|
| 14 |
+
return {"current_agent": "supervisor"}
|
| 15 |
+
|
| 16 |
+
# Posortuj po skorygowanym relevance_score, weź max 3 by nie zaśmiecać widoku
|
| 17 |
+
top_grants = sorted(
|
| 18 |
+
grants,
|
| 19 |
+
key=lambda x: x.relevance_score if x.relevance_score else 0.0,
|
| 20 |
+
reverse=True,
|
| 21 |
+
)[:3]
|
| 22 |
+
|
| 23 |
+
events = []
|
| 24 |
+
|
| 25 |
+
# Tworzymy symulowany kalendarz na podstawie zebranych dotacji
|
| 26 |
+
for idx, grant in enumerate(top_grants):
|
| 27 |
+
# Wydarzenie startowe: rozpoczęcie prac nad wnioskiem
|
| 28 |
+
events.append(
|
| 29 |
+
{
|
| 30 |
+
"id": f"start_prep_{grant.id}",
|
| 31 |
+
"title": f"Rozpoczęcie przygotowań do: {grant.title}",
|
| 32 |
+
"description": "Zebranie dokumentacji technicznej i finansowej",
|
| 33 |
+
"date": "Dzisiaj",
|
| 34 |
+
"status": "upcoming",
|
| 35 |
+
}
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
# Ostateczny termin
|
| 39 |
+
deadline_str = grant.deadline if grant.deadline else "Brak podanej daty"
|
| 40 |
+
events.append(
|
| 41 |
+
{
|
| 42 |
+
"id": f"deadline_{grant.id}",
|
| 43 |
+
"title": f"Wysłanie wniosku: {grant.title}",
|
| 44 |
+
"description": f"Instytucja przyjmująca: {grant.institution}. Budżet: ok. {grant.max_amount} PLN",
|
| 45 |
+
"date": deadline_str,
|
| 46 |
+
"status": "pending",
|
| 47 |
+
}
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
# Przewidywana ocena wniosku (zakładamy 90 dni)
|
| 51 |
+
events.append(
|
| 52 |
+
{
|
| 53 |
+
"id": f"evaluation_{grant.id}",
|
| 54 |
+
"title": f"Spodziewane ogłoszenie wyników: {grant.title}",
|
| 55 |
+
"description": "Zakończenie oceny eksperckiej wniosku przez instytucję",
|
| 56 |
+
"date": "+90 dni od wpłynięcia",
|
| 57 |
+
"status": "future",
|
| 58 |
+
}
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
logger.info(
|
| 62 |
+
f"Timeline node wygenerował {len(events)} wydarzeń na osi czasu dla top {len(top_grants)} dotacji."
|
| 63 |
+
)
|
| 64 |
+
return {"timeline_events": events, "current_agent": "supervisor"}
|
backend/agents/tools/budget_rules_tool.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from langchain_core.tools import tool
|
| 2 |
+
from rag_pipeline.hybrid_retriever import get_hybrid_retriever
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
@tool
|
| 6 |
+
def search_budget_rules(query: str, program_name: str = "") -> str:
|
| 7 |
+
"""
|
| 8 |
+
Wyszukuje informacje w bazie wiedzy (RAG) na temat limitów kosztów kwalifikowanych,
|
| 9 |
+
zasad finansowania, stawek ryczałtowych oraz dozwolonych budżetów dla programów dotacyjnych.
|
| 10 |
+
|
| 11 |
+
Args:
|
| 12 |
+
query (str): Pytanie dotyczące zasad budżetowych np. "Jaki jest limit kosztów pośrednich dla Ścieżki SMART?"
|
| 13 |
+
program_name (str): Opcjonalnie nazwa programu, np. "FENG Ścieżka SMART".
|
| 14 |
+
"""
|
| 15 |
+
retriever = get_hybrid_retriever()
|
| 16 |
+
search_query = f"[Koszty, Budżet, Ewaluacja Finansowa] Program: {program_name}. Zapytanie: {query}"
|
| 17 |
+
|
| 18 |
+
docs = retriever.invoke(search_query)
|
| 19 |
+
if not docs:
|
| 20 |
+
return "Nie znaleziono dokumentów precyzujących to zapytanie budżetowe w bazie wiedzy."
|
| 21 |
+
|
| 22 |
+
result = "\n".join(
|
| 23 |
+
[
|
| 24 |
+
f"- Zródło: {d.metadata.get('source', 'nieznane')}\n{d.page_content}"
|
| 25 |
+
for d in docs
|
| 26 |
+
]
|
| 27 |
+
)
|
| 28 |
+
return f"Wyniki wyszukiwania zasad budżetowych:\n{result}"
|
backend/agents/tools/krs_graph_tool.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from typing import Dict, Any
|
| 3 |
+
from langchain_core.tools import tool
|
| 4 |
+
from integrations.krs_client import KRSClient
|
| 5 |
+
from rag_pipeline.graph_store import graph_store
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@tool
|
| 11 |
+
def analyze_company_network(krs_number: str) -> Dict[str, Any]:
|
| 12 |
+
"""
|
| 13 |
+
Analizuje strukturę powiązań kapitałowych i osobowych firmy na podstawie numeru KRS.
|
| 14 |
+
Narzędzie pobiera oficjalne dane z API KRS (odpis aktualny), dodaje je do bazy grafowej Neo4j,
|
| 15 |
+
a następnie wyszukuje jakiekolwiek powiązania weryfikujące status MŚP (związki z innymi firmami).
|
| 16 |
+
|
| 17 |
+
Zwraca słownik zawierający dane rejestrowe firmy oraz zidentyfikowaną siatkę powiązań (wraz ze wspólnikami i zarządem).
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
logger.info(f"Rozpoczęcie analizy powiązań dla KRS: {krs_number}")
|
| 21 |
+
|
| 22 |
+
# 1. Pobierz aktualne dane z publicznego API KRS
|
| 23 |
+
odpis_json = KRSClient.get_odpis_aktualny(krs_number)
|
| 24 |
+
|
| 25 |
+
if not odpis_json:
|
| 26 |
+
return {
|
| 27 |
+
"error": f"Nie udało się pobrać odpisu dla KRS {krs_number}. Upewnij się, że wpisany KRS jest poprawny."
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
# 2. Przekształć JSON w węzły struktur (Wspólnicy, Zarząd)
|
| 31 |
+
extracted_data = KRSClient.extract_graph_relations(odpis_json)
|
| 32 |
+
|
| 33 |
+
if not extracted_data:
|
| 34 |
+
return {
|
| 35 |
+
"error": "Format odpisu KRS był niepoprawny lub nie wspierany w obecnej strukturze."
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
# 3. Zapisz/zaktualizuj graf Neo4j
|
| 39 |
+
graph_store.merge_company_graph(extracted_data)
|
| 40 |
+
|
| 41 |
+
# 4. Sprawdź powiązania grafowe - szukaj firm zależnych, powiązanych osób
|
| 42 |
+
network = graph_store.check_company_network(krs_number)
|
| 43 |
+
|
| 44 |
+
# Zwróć zagregowany profil do Agenta (np. Audytora)
|
| 45 |
+
return {
|
| 46 |
+
"podmiot": {
|
| 47 |
+
"nazwa": extracted_data.get("nazwa"),
|
| 48 |
+
"krs": extracted_data.get("krs"),
|
| 49 |
+
"nip": extracted_data.get("nip"),
|
| 50 |
+
"kapital_zakladowy": extracted_data.get("kapitalZakladowy"),
|
| 51 |
+
},
|
| 52 |
+
"wspolnicy_bezposredni": [
|
| 53 |
+
f"{w.get('imiona')} {w.get('nazwa')} (Spółka: {w.get('is_spolka')})"
|
| 54 |
+
for w in extracted_data.get("wspolnicy", [])
|
| 55 |
+
],
|
| 56 |
+
"zarzad": [
|
| 57 |
+
f"{z.get('imiona')} {z.get('nazwa')} - {z.get('funkcja')}"
|
| 58 |
+
for z in extracted_data.get("zarzad", [])
|
| 59 |
+
],
|
| 60 |
+
"wykryte_relacje_grafowe": network
|
| 61 |
+
if network
|
| 62 |
+
else "Brak zidentyfikowanych powiązań sieciowych w bazie GraphRAG poza bezpośrednio ujawnionymi w odpisie.",
|
| 63 |
+
"rekomendacja_msp": "UWAGA: Jeżeli w sekcji 'wykryte_relacje_grafowe' zidentyfikowano inne spółki, kapitał, zatrudnienie lub przychody z tych firm mogą wliczać się w weryfikację statusu MŚP analizowanej firmy!",
|
| 64 |
+
}
|
backend/agents/tools/legal_retriever_tool.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional
|
| 2 |
+
from langchain_core.tools import tool
|
| 3 |
+
from rag_pipeline.hybrid_retriever import get_hybrid_retriever
|
| 4 |
+
import logging
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger(__name__)
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@tool
|
| 10 |
+
def search_legal_documents(
|
| 11 |
+
query: str,
|
| 12 |
+
rok_perspektywy: Optional[str] = None,
|
| 13 |
+
program_name: Optional[str] = None,
|
| 14 |
+
date_from: Optional[str] = None,
|
| 15 |
+
date_to: Optional[str] = None,
|
| 16 |
+
namespace: Optional[str] = "default",
|
| 17 |
+
) -> str:
|
| 18 |
+
"""
|
| 19 |
+
Wyszukuje akty prawne, wytyczne oraz dokumenty funduszowe w wektorowej bazie wiedzy (RAG).
|
| 20 |
+
Wykorzystuj to narzędzie ZAWSZE, gdy potrzebujesz zweryfikować kwalifikowalność, obowiązki beneficjenta,
|
| 21 |
+
lub zasady konkursowe.
|
| 22 |
+
|
| 23 |
+
Argumenty:
|
| 24 |
+
- query: Szczegółowe zapytanie, np. "warunki kwalifikowalności kosztów wynagrodzeń".
|
| 25 |
+
- rok_perspektywy: (Opcjonalnie) filtr na perspektywę UE, np. "2021-2027".
|
| 26 |
+
- program_name: (Opcjonalnie) filtr na konkretny program, np. "Ścieżka SMART".
|
| 27 |
+
- date_from: (Opcjonalnie) filtr od daty publikacji dokumentu (YYYY-MM-DD).
|
| 28 |
+
- date_to: (Opcjonalnie) filtr do daty publikacji dokumentu (YYYY-MM-DD).
|
| 29 |
+
- namespace: (Opcjonalnie) ID przestrzeni klienta. Domyślnie "default".
|
| 30 |
+
|
| 31 |
+
Zwraca streszczenie znalezionych dokumentów z ich metadanymi. Jeśli wynik jest pusty lub
|
| 32 |
+
stwierdzisz po odczycie niedopasowanie bazy, PRZEFORMUŁUJ wyszukiwanie w kolejnym kroku grafu.
|
| 33 |
+
"""
|
| 34 |
+
logger.info(
|
| 35 |
+
f"[LegalRetrieverTool] Otrzymano zapytanie: {query} | Rok: {rok_perspektywy} | Program: {program_name} | Od: {date_from} | Do: {date_to} | Namespace: {namespace}"
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
metadata_filter = {}
|
| 39 |
+
if rok_perspektywy:
|
| 40 |
+
metadata_filter["rok_perspektywy"] = {"$eq": rok_perspektywy}
|
| 41 |
+
if program_name:
|
| 42 |
+
metadata_filter["program_name"] = {"$eq": program_name}
|
| 43 |
+
|
| 44 |
+
if date_from or date_to:
|
| 45 |
+
date_filter = {}
|
| 46 |
+
if date_from:
|
| 47 |
+
date_filter["$gte"] = date_from
|
| 48 |
+
if date_to:
|
| 49 |
+
date_filter["$lte"] = date_to
|
| 50 |
+
metadata_filter["date"] = date_filter
|
| 51 |
+
|
| 52 |
+
if not metadata_filter:
|
| 53 |
+
metadata_filter = None
|
| 54 |
+
|
| 55 |
+
retriever = get_hybrid_retriever(
|
| 56 |
+
k=4,
|
| 57 |
+
metadata_filter=metadata_filter,
|
| 58 |
+
namespace=namespace,
|
| 59 |
+
# Multi-stage for higher precision on legal/regulation queries (recall 16 -> CE rerank to 4)
|
| 60 |
+
retrieval_k=16,
|
| 61 |
+
rerank_top_n=4,
|
| 62 |
+
use_reranker=True,
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
if not retriever:
|
| 66 |
+
return "Błąd techniczny: Baza wiedzy (wektorowa) jest niedostępna lub retriever nie został utworzony."
|
| 67 |
+
|
| 68 |
+
docs = retriever.invoke(query)
|
| 69 |
+
|
| 70 |
+
if not docs:
|
| 71 |
+
# Fallback do live legal sources (EUR-Lex) — najwyższa wiarygodność
|
| 72 |
+
try:
|
| 73 |
+
from integrations.eurlex_client import EURLexClient
|
| 74 |
+
client = EURLexClient()
|
| 75 |
+
live = client.search_legal_acts(query, limit=3)
|
| 76 |
+
if live:
|
| 77 |
+
live_text = "\n".join([f"- {l.get('title','')} | {l.get('url','')}" for l in live if not l.get('error')])
|
| 78 |
+
return f"[LIVE EUR-LEX FALLBACK - źródło prawdy UE]\n{live_text}\n\nZalecenie: Zweryfikuj bezpośrednio w EUR-Lex dla pełnej aktualności."
|
| 79 |
+
except Exception:
|
| 80 |
+
pass
|
| 81 |
+
return "Brak pasujących dokumentów w bazie wiedzy dla tej perspektywy i zapytania. Przeformułuj zapytanie bazowe!"
|
| 82 |
+
|
| 83 |
+
results = []
|
| 84 |
+
for d in docs:
|
| 85 |
+
source = d.metadata.get("source", "Nieznane")
|
| 86 |
+
rok = d.metadata.get("rok_perspektywy", "Brak danych o roku")
|
| 87 |
+
content = d.page_content.replace("\n", " ")[
|
| 88 |
+
:1000
|
| 89 |
+
] # Ograniczenie by LLM się nie zgubił
|
| 90 |
+
results.append(
|
| 91 |
+
f"--- DOKUMENT: {source} (Perspektywa: {rok}) ---\nTREŚĆ: {content}..."
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
return "\n\n".join(results)
|
backend/agents/tools/neo4j_cypher_tool.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from typing import Dict, Any
|
| 3 |
+
from langchain_core.tools import tool
|
| 4 |
+
from core.graph_db.neo4j_client import neo4j_client
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger(__name__)
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@tool
|
| 10 |
+
def query_neo4j_graph(cypher_query: str) -> Dict[str, Any]:
|
| 11 |
+
"""
|
| 12 |
+
Narzędzie dla LLM do samodzielnego odpytywania bazy grafowej Neo4j za pomocą języka Cypher.
|
| 13 |
+
Służy do analizowania powiązań między firmami, udziałowcami i weryfikacji statusu MŚP (związki kapitałowe).
|
| 14 |
+
|
| 15 |
+
Przykład użycia (Cypher):
|
| 16 |
+
MATCH (c:Company {krs: '0000123456'})<-[r:OWNS]-(owner) RETURN owner.name, r.share_percentage
|
| 17 |
+
|
| 18 |
+
Zwraca słownik zawierający wyniki zapytania lub błąd.
|
| 19 |
+
"""
|
| 20 |
+
logger.info(f"LLM uruchamia zapytanie Cypher: {cypher_query}")
|
| 21 |
+
|
| 22 |
+
try:
|
| 23 |
+
# Sprawdzamy, czy połączenie z Neo4j jest aktywne
|
| 24 |
+
if not neo4j_client.driver:
|
| 25 |
+
neo4j_client.connect()
|
| 26 |
+
if not neo4j_client.driver:
|
| 27 |
+
return {
|
| 28 |
+
"error": "Brak połączenia z bazą Neo4j AuraDB. Spróbuj ponownie później lub przejdź do alternatywnych metod analizy."
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
# Wykonaj zapytanie (zabezpieczone try-except w _execute_query)
|
| 32 |
+
results = neo4j_client._execute_query(cypher_query)
|
| 33 |
+
|
| 34 |
+
if not results:
|
| 35 |
+
return {"results": [], "message": "Zapytanie nie zwróciło żadnych wyników."}
|
| 36 |
+
|
| 37 |
+
# Formatowanie wyników (record.data() to domyślna metoda rekordu neo4j)
|
| 38 |
+
formatted_results = [
|
| 39 |
+
record.data() if hasattr(record, "data") else dict(record)
|
| 40 |
+
for record in results
|
| 41 |
+
]
|
| 42 |
+
|
| 43 |
+
return {"results": formatted_results, "count": len(formatted_results)}
|
| 44 |
+
except Exception as e:
|
| 45 |
+
logger.error(f"Błąd podczas wykonywania zapytania Cypher przez LLM: {str(e)}")
|
| 46 |
+
return {"error": f"Błąd wykonania zapytania: {str(e)}"}
|
backend/agents/tools/technology_retriever_tool.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from langchain_core.tools import tool
|
| 2 |
+
from rag_pipeline.hybrid_retriever import get_hybrid_retriever
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
@tool
|
| 6 |
+
def search_technology_trends(query: str, program_name: str = "") -> str:
|
| 7 |
+
"""
|
| 8 |
+
Wyszukuje informacje w bazie wiedzy (RAG) na temat trendów technologicznych,
|
| 9 |
+
wymogów KIS (Krajowych Inteligentnych Specjalizacji) oraz odpowiednich
|
| 10 |
+
poziomów TRL (Technology Readiness Level) dla projektów badawczych i innowacyjnych.
|
| 11 |
+
|
| 12 |
+
Args:
|
| 13 |
+
query (str): Pytanie dotyczące wymogów technologicznych, np. "Jakie są wytyczne dla prac B+R w KPO?"
|
| 14 |
+
program_name (str): Opcjonalnie nazwa programu, np. "FENG Ścieżka SMART".
|
| 15 |
+
"""
|
| 16 |
+
retriever = get_hybrid_retriever()
|
| 17 |
+
search_query = (
|
| 18 |
+
f"[Innowacje, KIS, B+R, TRL] Program: {program_name}. Zapytanie: {query}"
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
docs = retriever.invoke(search_query)
|
| 22 |
+
if not docs:
|
| 23 |
+
return "Nie znaleziono w bazie specyficznych wymogów wpisujących się w to zapytanie o poziomie innowacyjności/B+R."
|
| 24 |
+
|
| 25 |
+
result = "\n".join(
|
| 26 |
+
[
|
| 27 |
+
f"- Zródło: {d.metadata.get('source', 'nieznane')}\n{d.page_content}"
|
| 28 |
+
for d in docs
|
| 29 |
+
]
|
| 30 |
+
)
|
| 31 |
+
return f"Wyniki wyszukiwania dla wymogów technologicznych i B+R:\n{result}"
|
backend/agents/verifier.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from schemas import AgentState
|
| 2 |
+
from core.llm_router import get_llm
|
| 3 |
+
from tenacity import retry, stop_after_attempt, wait_exponential
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def verifier_node(state: AgentState):
|
| 7 |
+
parsed_content = (
|
| 8 |
+
state.verification_results.get("pending_doc_text")
|
| 9 |
+
if state.verification_results
|
| 10 |
+
else None
|
| 11 |
+
)
|
| 12 |
+
if not parsed_content:
|
| 13 |
+
return {
|
| 14 |
+
"verification_results": {
|
| 15 |
+
"status": "brak_dokumentu",
|
| 16 |
+
"analysis": "Brak dokumentu dodanego do weryfikacji.",
|
| 17 |
+
},
|
| 18 |
+
"current_agent": "supervisor",
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
try:
|
| 22 |
+
llm = get_llm(task_type="critical")
|
| 23 |
+
|
| 24 |
+
@retry(
|
| 25 |
+
stop=stop_after_attempt(3),
|
| 26 |
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
| 27 |
+
)
|
| 28 |
+
def _invoke_llm():
|
| 29 |
+
return llm.invoke(
|
| 30 |
+
f"System: Jesteś ekspertem oceny formalnej wniosków PARP. Sprawdź spójność danych.\n"
|
| 31 |
+
f"Porównaj dane z dokumentu: {parsed_content[:15000]}... z profilem firmy: {state.profile.model_dump() if state.profile else 'Brak'}"
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
response = _invoke_llm()
|
| 35 |
+
from core.utils import safe_extract_text
|
| 36 |
+
|
| 37 |
+
analysis_result = safe_extract_text(response.content)
|
| 38 |
+
except Exception as e:
|
| 39 |
+
analysis_result = f"Błąd weryfikacji przez model LLM: {str(e)}"
|
| 40 |
+
|
| 41 |
+
return {
|
| 42 |
+
"verification_results": {"analysis": analysis_result},
|
| 43 |
+
"current_agent": "supervisor",
|
| 44 |
+
}
|
backend/agents/wizard.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from schemas import AgentState
|
| 2 |
+
from typing import Dict, Any
|
| 3 |
+
from core.llm_router import get_llm
|
| 4 |
+
from langchain_core.prompts import PromptTemplate
|
| 5 |
+
from langchain_core.messages import AIMessage
|
| 6 |
+
from core.utils import safe_extract_text
|
| 7 |
+
from rag_pipeline import get_hybrid_retriever, rerank_documents
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
from langsmith import traceable
|
| 11 |
+
from langchain_core.tracers.langchain import LangChainTracer
|
| 12 |
+
|
| 13 |
+
# Włącz tracing LangSmith
|
| 14 |
+
os.environ["LANGCHAIN_TRACING_V2"] = "false"
|
| 15 |
+
os.environ["LANGCHAIN_PROJECT"] = "grantforge-production"
|
| 16 |
+
|
| 17 |
+
# Opcjonalnie – jeśli chcesz zobaczyć dokładne nazwy runów
|
| 18 |
+
tracer = LangChainTracer(project_name="grantforge-production")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@traceable(run_type="chain", name="wizard_node")
|
| 22 |
+
def wizard_node(state: AgentState) -> Dict[str, Any]:
|
| 23 |
+
"""
|
| 24 |
+
Kreator wniosku połączony z bazą wiedzy (RAG).
|
| 25 |
+
Wykorzystuje feedback Krytyka w celu iteracyjnej poprawy.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
# Zabezpieczenie przed pętlą zgodnie ze standardem 2026/HitL
|
| 29 |
+
if state.critic_iterations >= state.max_critic_iterations:
|
| 30 |
+
return {
|
| 31 |
+
"messages": [
|
| 32 |
+
AIMessage(
|
| 33 |
+
content="Osiągnięto maksymalną liczbę iteracji poprawek. Przekazuję tekst do zatwierdzenia przez użytkownika."
|
| 34 |
+
)
|
| 35 |
+
],
|
| 36 |
+
"critic_evaluation": {
|
| 37 |
+
"is_approved": True,
|
| 38 |
+
"feedback": "ZATWIERDZONE_MAX_ITERATIONS_REACHED",
|
| 39 |
+
"severity": "low",
|
| 40 |
+
},
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
import logging
|
| 44 |
+
|
| 45 |
+
logger = logging.getLogger(__name__)
|
| 46 |
+
|
| 47 |
+
last_user_message = (
|
| 48 |
+
state.messages[-1].content if state.messages else "Stwórz biznesplan"
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
hard_filter = (
|
| 52 |
+
{"program_name": state.program_name}
|
| 53 |
+
if hasattr(state, "program_name") and state.program_name
|
| 54 |
+
else None
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
# Przekazanie namespace z contextu dzierżawcy do pinecone
|
| 58 |
+
namespace = getattr(state, "tenant_id", None)
|
| 59 |
+
logger.info(f"[Wizard] Inicjalizacja generowania. Użytkownik/Tenant: '{namespace}'")
|
| 60 |
+
retriever = get_hybrid_retriever(
|
| 61 |
+
k=10, metadata_filter=hard_filter, namespace=namespace
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
context_text = ""
|
| 65 |
+
if retriever:
|
| 66 |
+
try:
|
| 67 |
+
docs = retriever.invoke(last_user_message)
|
| 68 |
+
reranked_docs = rerank_documents(last_user_message, docs, top_n=5)
|
| 69 |
+
context_text = "\n\n".join(
|
| 70 |
+
[
|
| 71 |
+
f"[ŹRÓDŁO: {d.metadata.get('source', 'Nieznane')} | STRONA: {d.metadata.get('page', 'Brak')}]:\n{d.page_content}"
|
| 72 |
+
for d in reranked_docs
|
| 73 |
+
]
|
| 74 |
+
)
|
| 75 |
+
except Exception as e:
|
| 76 |
+
context_text = (
|
| 77 |
+
f"Brak wiedzy w lokalnej bazie ze względu na błąd RAG: {str(e)}"
|
| 78 |
+
)
|
| 79 |
+
else:
|
| 80 |
+
context_text = "Brak podłączonej bazy wektorowej. Działam na bazowej wiedzy."
|
| 81 |
+
|
| 82 |
+
# W architekturze 2026 Wizard to model krytyczny (Gemini Pro) z opcjonalnym streamingiem
|
| 83 |
+
# LLM zainicjalizujemy poniżej po zdefiniowaniu schematu
|
| 84 |
+
|
| 85 |
+
template = """
|
| 86 |
+
Jesteś Głównym Analitykiem i Konsultantem Dotacyjnym na poziomie Enterprise w GrantForge AI.
|
| 87 |
+
Twoim zadaniem jest napisanie wysoce profesjonalnego fragmentu urzędowego wniosku biznesowego
|
| 88 |
+
lub biznesplanu, który ściśle przestrzega wytycznych prawnych. Jeśli brakuje kluczowych informacji firmy, ustrukturyzuj je profesjonalnie używając znaczników zastępczych np. "[UZUPEŁNIJ: Nazwa firmy]", ale NIGDY nie odmawiaj wygenerowania tekstu.
|
| 89 |
+
|
| 90 |
+
Kontekst regulaminowy wyszukany z bazy RAG:
|
| 91 |
+
--------------------------------------------------
|
| 92 |
+
{context}
|
| 93 |
+
--------------------------------------------------
|
| 94 |
+
|
| 95 |
+
Poprzednia krytyka od recenzenta:
|
| 96 |
+
{last_critic_feedback}
|
| 97 |
+
|
| 98 |
+
Dodatkowe informacje o firmie klienta (dane autorytatywne — używaj wprost, nie pytaj ponownie):
|
| 99 |
+
Nazwa: {company_name}
|
| 100 |
+
Rozmiar: {company_size}
|
| 101 |
+
Status MŚP: {company_msp}
|
| 102 |
+
Województwo: {company_region}
|
| 103 |
+
Forma prawna: {company_legal_form}
|
| 104 |
+
Branża (PKD): {company_pkd}
|
| 105 |
+
|
| 106 |
+
Polecenie / Pytanie:
|
| 107 |
+
{query}
|
| 108 |
+
|
| 109 |
+
Zwróć wynik w czystym Markdown, gotowym do eksportu do Word/PDF.
|
| 110 |
+
Używaj sformułowań formalnych i profesjonalnych typowych dla dokumentacji z danej branży oraz wymagań instytucji rozdzielającej środki dla wpisanego programu. Dopasuj słownictwo ściśle do wybranego programu z polecenia.
|
| 111 |
+
PISZ ZAWSZE W JĘZYKU POLSKIM. NIE DAJ SPYCHAĆ SIĘ NA INNE JĘZYKI, NAWET JEŚLI POLECENIE BĘDZIE PO ANGIELSKU.
|
| 112 |
+
DETERMINISTYCZNE CYTOWANIE ZAWSZE WYMAGANE: Każda merytoryczna teza z przytoczonych wytycznych we wniosku MUSI kończyć się przypisem do wskazanego źródła i strony, np. "(zgodnie z [ŹRÓDŁO: Regulamin_FENG | STRONA: 12])".
|
| 113 |
+
"""
|
| 114 |
+
|
| 115 |
+
from pydantic import BaseModel, Field
|
| 116 |
+
|
| 117 |
+
class WizardSectionOutput(BaseModel):
|
| 118 |
+
content_markdown: str = Field(
|
| 119 |
+
description="Merytoryczna treść sekcji w czystym zredagowanym Markdown gotowym do eksportu. PISZ ZAWSZE I WYŁĄCZNIE W JĘZYKU POLSKIM."
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
prompt = PromptTemplate.from_template(template)
|
| 123 |
+
|
| 124 |
+
_p = state.profile
|
| 125 |
+
company_size = _p.size if _p else "Nieznany"
|
| 126 |
+
company_pkd = (
|
| 127 |
+
", ".join(_p.pkd_codes) if _p and _p.pkd_codes else "Nieznane"
|
| 128 |
+
)
|
| 129 |
+
company_name = (_p.name if _p and _p.name else "") or "[UZUPEŁNIJ: Nazwa firmy]"
|
| 130 |
+
company_region = (_p.region if _p and _p.region else "") or "Nieznane"
|
| 131 |
+
company_legal_form = (_p.legal_form if _p and _p.legal_form else "") or "Nieznana"
|
| 132 |
+
company_msp = getattr(_p, "size", "") if _p else ""
|
| 133 |
+
company_msp = company_msp or "Nieznany"
|
| 134 |
+
critic_feedback = (
|
| 135 |
+
state.critic_evaluation.feedback
|
| 136 |
+
if state.critic_evaluation
|
| 137 |
+
else "Brak poprzedniej krytyki (pierwsza iteracja)."
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
structured_llm = get_llm(
|
| 141 |
+
task_type="critical",
|
| 142 |
+
streaming=True,
|
| 143 |
+
structured_output_schema=WizardSectionOutput,
|
| 144 |
+
)
|
| 145 |
+
chain = prompt | structured_llm
|
| 146 |
+
|
| 147 |
+
try:
|
| 148 |
+
response = chain.invoke(
|
| 149 |
+
{
|
| 150 |
+
"context": context_text,
|
| 151 |
+
"company_name": company_name,
|
| 152 |
+
"company_size": company_size,
|
| 153 |
+
"company_msp": company_msp,
|
| 154 |
+
"company_region": company_region,
|
| 155 |
+
"company_legal_form": company_legal_form,
|
| 156 |
+
"company_pkd": company_pkd,
|
| 157 |
+
"last_critic_feedback": critic_feedback,
|
| 158 |
+
"query": last_user_message,
|
| 159 |
+
}
|
| 160 |
+
)
|
| 161 |
+
except Exception as e:
|
| 162 |
+
logger.error(f"[Wizard] LLM Error during section generation: {e}")
|
| 163 |
+
from pydantic import BaseModel
|
| 164 |
+
|
| 165 |
+
# Zastępczy model zgodny ze strukturą oczekiwaną przez WizardSectionOutput
|
| 166 |
+
response = type(
|
| 167 |
+
"DummyResponse",
|
| 168 |
+
(),
|
| 169 |
+
{
|
| 170 |
+
"content_markdown": f"⚠️ **Błąd generowania sekcji**. Sprawdź klucz API Google lub połączenie z modelem lokalnym. Szczegóły: {e}"
|
| 171 |
+
},
|
| 172 |
+
)()
|
| 173 |
+
|
| 174 |
+
current_step = state.wizard_step + 1
|
| 175 |
+
|
| 176 |
+
# Dodajemy wersjonowanie dokumentów
|
| 177 |
+
doc_versions = dict(state.document_versions) if state.document_versions else {}
|
| 178 |
+
if "current_draft" not in doc_versions:
|
| 179 |
+
doc_versions["current_draft"] = []
|
| 180 |
+
|
| 181 |
+
flat_content = safe_extract_text(response.content_markdown)
|
| 182 |
+
|
| 183 |
+
doc_versions["current_draft"].append(flat_content)
|
| 184 |
+
|
| 185 |
+
return {
|
| 186 |
+
"wizard_step": current_step,
|
| 187 |
+
"document_versions": doc_versions,
|
| 188 |
+
"messages": [{"role": "assistant", "content": flat_content}],
|
| 189 |
+
"critic_iterations": state.critic_iterations + 1,
|
| 190 |
+
# Routing wizard -> critic przejęty przez graph.py
|
| 191 |
+
}
|
backend/agents/world_class_advisor.py
ADDED
|
@@ -0,0 +1,628 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
World-class regulation-grounded advisor (pure evaluation path).
|
| 3 |
+
|
| 4 |
+
Checks application content against a structured advisor brief without requiring
|
| 5 |
+
a live LLM — suitable for unit tests and as a hard gate before soft-pass export.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import re
|
| 10 |
+
from dataclasses import asdict, dataclass, field
|
| 11 |
+
from typing import Any, Dict, List, Optional, Sequence
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass
|
| 15 |
+
class AdvisorFinding:
|
| 16 |
+
code: str
|
| 17 |
+
severity: str # critical | major | minor | info
|
| 18 |
+
message: str
|
| 19 |
+
category: str # program_alignment | missing_required | quality | grounding
|
| 20 |
+
section: str = ""
|
| 21 |
+
blocking: bool = False
|
| 22 |
+
|
| 23 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 24 |
+
return asdict(self)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@dataclass
|
| 28 |
+
class AdvisorReport:
|
| 29 |
+
passed: bool
|
| 30 |
+
score: int
|
| 31 |
+
grounding_mode: str
|
| 32 |
+
regulation_grounded_pass: bool
|
| 33 |
+
findings: List[AdvisorFinding] = field(default_factory=list)
|
| 34 |
+
blockers: List[str] = field(default_factory=list)
|
| 35 |
+
covered_sections: List[str] = field(default_factory=list)
|
| 36 |
+
missing_sections: List[str] = field(default_factory=list)
|
| 37 |
+
missing_attachments_mentioned: List[str] = field(default_factory=list)
|
| 38 |
+
attention_addressed: List[str] = field(default_factory=list)
|
| 39 |
+
attention_open: List[str] = field(default_factory=list)
|
| 40 |
+
brief_usable: bool = False
|
| 41 |
+
summary: str = ""
|
| 42 |
+
|
| 43 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 44 |
+
d = asdict(self)
|
| 45 |
+
d["findings"] = [f if isinstance(f, dict) else f.to_dict() for f in self.findings]
|
| 46 |
+
return d
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _norm(s: str) -> str:
|
| 50 |
+
return re.sub(r"\s+", " ", (s or "").lower().strip())
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# Generic tokens that must NOT count as rule hits (corporate fluff matches these)
|
| 54 |
+
_RULE_STOPWORDS = frozenset(
|
| 55 |
+
{
|
| 56 |
+
"wnioskodawca",
|
| 57 |
+
"beneficjent",
|
| 58 |
+
"projekt",
|
| 59 |
+
"projekty",
|
| 60 |
+
"musi",
|
| 61 |
+
"muszą",
|
| 62 |
+
"powinien",
|
| 63 |
+
"powinna",
|
| 64 |
+
"posiadać",
|
| 65 |
+
"posiada",
|
| 66 |
+
"status",
|
| 67 |
+
"oraz",
|
| 68 |
+
"przez",
|
| 69 |
+
"który",
|
| 70 |
+
"która",
|
| 71 |
+
"które",
|
| 72 |
+
"zgodnie",
|
| 73 |
+
"zawierać",
|
| 74 |
+
"zawiera",
|
| 75 |
+
"następujące",
|
| 76 |
+
"elementy",
|
| 77 |
+
"wymagane",
|
| 78 |
+
"wymagany",
|
| 79 |
+
"opis",
|
| 80 |
+
"treść",
|
| 81 |
+
"sekcja",
|
| 82 |
+
"program",
|
| 83 |
+
"naboru",
|
| 84 |
+
"regulamin",
|
| 85 |
+
"sprawdź",
|
| 86 |
+
"potwierdź",
|
| 87 |
+
"udokumentuj",
|
| 88 |
+
"przygotuj",
|
| 89 |
+
"zweryfikuj",
|
| 90 |
+
"uwzględnij",
|
| 91 |
+
"punkt",
|
| 92 |
+
"uwagi",
|
| 93 |
+
"minimum",
|
| 94 |
+
"wynosi",
|
| 95 |
+
"kosztów",
|
| 96 |
+
"koszty",
|
| 97 |
+
"koszt",
|
| 98 |
+
"zasada",
|
| 99 |
+
"spełniać",
|
| 100 |
+
"spełnia",
|
| 101 |
+
}
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
# Domain signals that, if present in brief, must appear in the application
|
| 105 |
+
_CORE_SIGNAL_GROUPS: List[tuple[str, tuple[str, ...]]] = [
|
| 106 |
+
("mśp", ("mśp", "msp", "mikroprzedsiębior", "małe przedsiębior", "średnie przedsiębior", "mikro firma")),
|
| 107 |
+
("dnsh", ("dnsh", "do no significant harm", "significant harm", "wpływ na środowisko", "wpływ środowisk")),
|
| 108 |
+
("wkład_własny", ("wkład własny", "wklad wlasny", "finansowanie własne", "finansowanie wlasne")),
|
| 109 |
+
("de_minimis", ("de minimis", "pomoc publiczna", "pomocy publicznej")),
|
| 110 |
+
("trl", ("trl", "gotowości technologicz", "gotowosci technologicz")),
|
| 111 |
+
("niekwalifikowalne", ("niekwalifikow", "koszty niekwalifikowalne")),
|
| 112 |
+
]
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _blob_from_sections(sections: Optional[Dict[str, str]], document_text: str = "") -> str:
|
| 116 |
+
parts: List[str] = []
|
| 117 |
+
if document_text:
|
| 118 |
+
parts.append(document_text)
|
| 119 |
+
if sections:
|
| 120 |
+
for title, body in sections.items():
|
| 121 |
+
parts.append(f"## {title}\n{body or ''}")
|
| 122 |
+
return "\n".join(parts)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _section_present(required: str, sections: Dict[str, str], blob: str) -> bool:
|
| 126 |
+
"""True if required section has meaningful content under a matching title."""
|
| 127 |
+
nr = _norm(required)
|
| 128 |
+
if not nr:
|
| 129 |
+
return True
|
| 130 |
+
# Direct title match with substantial body only (no free-text weak match)
|
| 131 |
+
for title, body in (sections or {}).items():
|
| 132 |
+
nt = _norm(title)
|
| 133 |
+
if nr in nt or nt in nr or difflib_ratio(nr, nt) >= 0.55:
|
| 134 |
+
if body and len(body.strip()) >= 80 and "[UZUPEŁNIĆ" not in body:
|
| 135 |
+
return True
|
| 136 |
+
return False
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def difflib_ratio(a: str, b: str) -> float:
|
| 140 |
+
import difflib
|
| 141 |
+
|
| 142 |
+
return difflib.SequenceMatcher(None, a, b).ratio()
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _distinctive_terms_from_rule(rule: str) -> List[str]:
|
| 146 |
+
"""
|
| 147 |
+
Extract distinctive multi-token phrases / domain terms from a rule.
|
| 148 |
+
Drops stopwords so 'Projekt musi…' alone never counts as alignment.
|
| 149 |
+
"""
|
| 150 |
+
n = _norm(rule)
|
| 151 |
+
terms: List[str] = []
|
| 152 |
+
# Prefer known domain multi-word / acronyms first
|
| 153 |
+
for _name, variants in _CORE_SIGNAL_GROUPS:
|
| 154 |
+
for v in variants:
|
| 155 |
+
if v in n:
|
| 156 |
+
terms.append(v)
|
| 157 |
+
# Multi-word chunks of 2–3 content words
|
| 158 |
+
words = re.findall(r"[a-ząćęłńóśźż0-9%]{3,}", n)
|
| 159 |
+
content = [w for w in words if w not in _RULE_STOPWORDS and len(w) >= 4]
|
| 160 |
+
for i in range(len(content) - 1):
|
| 161 |
+
bigram = f"{content[i]} {content[i + 1]}"
|
| 162 |
+
if bigram not in terms:
|
| 163 |
+
terms.append(bigram)
|
| 164 |
+
# Long single tokens (≥7) that aren't stopwords — acronyms like mśp already handled
|
| 165 |
+
for w in content:
|
| 166 |
+
if len(w) >= 7 and w not in terms and w not in _RULE_STOPWORDS:
|
| 167 |
+
terms.append(w)
|
| 168 |
+
return terms[:12]
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def rule_is_addressed(rule: str, blob: str) -> bool:
|
| 172 |
+
"""True only when distinctive signal(s) from the rule appear in application text."""
|
| 173 |
+
b = _norm(blob)
|
| 174 |
+
if not b or not rule:
|
| 175 |
+
return False
|
| 176 |
+
terms = _distinctive_terms_from_rule(rule)
|
| 177 |
+
if not terms:
|
| 178 |
+
# No distinctive content in rule → cannot claim hit from fluff
|
| 179 |
+
return False
|
| 180 |
+
# Need at least one multi-word term OR two distinct single-domain hits
|
| 181 |
+
multi = [t for t in terms if " " in t or any(t in g[1] for g in _CORE_SIGNAL_GROUPS)]
|
| 182 |
+
hits = [t for t in terms if t in b]
|
| 183 |
+
if not hits:
|
| 184 |
+
return False
|
| 185 |
+
if any(t in b for t in multi):
|
| 186 |
+
return True
|
| 187 |
+
# Single long distinctive tokens: require ≥2 different hits for generic rules
|
| 188 |
+
single_hits = [t for t in hits if " " not in t and len(t) >= 7]
|
| 189 |
+
return len(set(single_hits)) >= 2 or (len(single_hits) >= 1 and any(t in b for t in multi))
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def core_signals_required_by_brief(brief: Dict[str, Any]) -> List[str]:
|
| 193 |
+
"""Which core domain signals appear in brief (rules + attention + eligibility)."""
|
| 194 |
+
blob = " ".join(
|
| 195 |
+
[
|
| 196 |
+
" ".join(str(x) for x in (brief.get("key_rules") or [])),
|
| 197 |
+
" ".join(str(x) for x in (brief.get("attention_points") or [])),
|
| 198 |
+
" ".join(str(x) for x in (brief.get("eligibility_signals") or [])),
|
| 199 |
+
" ".join(str(x) for x in (brief.get("funding_limits") or [])),
|
| 200 |
+
]
|
| 201 |
+
)
|
| 202 |
+
n = _norm(blob)
|
| 203 |
+
required: List[str] = []
|
| 204 |
+
for name, variants in _CORE_SIGNAL_GROUPS:
|
| 205 |
+
if any(v in n for v in variants):
|
| 206 |
+
required.append(name)
|
| 207 |
+
return required
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def core_signal_present(name: str, blob: str) -> bool:
|
| 211 |
+
b = _norm(blob)
|
| 212 |
+
for gname, variants in _CORE_SIGNAL_GROUPS:
|
| 213 |
+
if gname == name:
|
| 214 |
+
return any(v in b for v in variants)
|
| 215 |
+
return False
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def _attention_is_critical(point: str) -> bool:
|
| 219 |
+
p = _norm(point)
|
| 220 |
+
critical_markers = (
|
| 221 |
+
"dnsh",
|
| 222 |
+
"środowisk",
|
| 223 |
+
"srodowisk",
|
| 224 |
+
"mśp",
|
| 225 |
+
"msp",
|
| 226 |
+
"wkład",
|
| 227 |
+
"własn",
|
| 228 |
+
"wlasn",
|
| 229 |
+
"de minimis",
|
| 230 |
+
"pomoc publiczn",
|
| 231 |
+
"trl",
|
| 232 |
+
"niekwalifikow",
|
| 233 |
+
)
|
| 234 |
+
return any(m in p for m in critical_markers)
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def _attention_addressed(point: str, blob: str) -> bool:
|
| 238 |
+
p = _norm(point)
|
| 239 |
+
b = _norm(blob)
|
| 240 |
+
keywords: List[str] = []
|
| 241 |
+
if "dnsh" in p or "środowisk" in p or "srodowisk" in p:
|
| 242 |
+
keywords = ["dnsh", "do no significant harm", "wpływ na środowisko", "wpływ środowisk", "środowisk", "srodowisk"]
|
| 243 |
+
elif "mśp" in p or "msp" in p:
|
| 244 |
+
keywords = ["mśp", "msp", "mikroprzedsiębior", "małe przedsiębior", "średnie przedsiębior"]
|
| 245 |
+
elif "wkład" in p or "własn" in p or "wlasn" in p:
|
| 246 |
+
keywords = ["wkład własny", "wklad wlasny", "finansowanie własne", "finansowanie wlasne"]
|
| 247 |
+
elif "de minimis" in p or "pomoc publiczn" in p:
|
| 248 |
+
keywords = ["de minimis", "pomoc publiczna", "pomocy publicznej"]
|
| 249 |
+
elif "trl" in p:
|
| 250 |
+
keywords = ["trl", "gotowości technologicz", "gotowosci technologicz"]
|
| 251 |
+
elif "załącznik" in p or "zalacznik" in p:
|
| 252 |
+
keywords = ["załącznik", "zalacznik", "oświadczenie", "oswiadczenie"]
|
| 253 |
+
elif "niekwalifikow" in p:
|
| 254 |
+
keywords = ["niekwalifikow", "koszty niekwalifikowalne"]
|
| 255 |
+
else:
|
| 256 |
+
# Require multi-token distinctive match, not lone stopwords
|
| 257 |
+
keywords = _distinctive_terms_from_rule(point)[:4]
|
| 258 |
+
if not keywords:
|
| 259 |
+
return False
|
| 260 |
+
return any(k in b for k in keywords)
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def evaluate_application(
|
| 264 |
+
*,
|
| 265 |
+
document_text: str = "",
|
| 266 |
+
sections: Optional[Dict[str, str]] = None,
|
| 267 |
+
brief: Optional[Dict[str, Any]] = None,
|
| 268 |
+
grounding_mode: str = "regulation",
|
| 269 |
+
min_score: int = 70,
|
| 270 |
+
min_section_chars: int = 80,
|
| 271 |
+
) -> AdvisorReport:
|
| 272 |
+
"""
|
| 273 |
+
Evaluate application against regulation-derived brief.
|
| 274 |
+
|
| 275 |
+
structure_only / blocked / blind modes never yield regulation_grounded_pass=True.
|
| 276 |
+
"""
|
| 277 |
+
brief = brief if isinstance(brief, dict) else {}
|
| 278 |
+
sections = sections if isinstance(sections, dict) else {}
|
| 279 |
+
mode = (grounding_mode or "regulation").lower().strip()
|
| 280 |
+
blob = _blob_from_sections(sections, document_text)
|
| 281 |
+
findings: List[AdvisorFinding] = []
|
| 282 |
+
score = 100
|
| 283 |
+
|
| 284 |
+
# --- Grounding hard rules ---
|
| 285 |
+
if mode in ("structure_only", "blocked", "blind"):
|
| 286 |
+
findings.append(
|
| 287 |
+
AdvisorFinding(
|
| 288 |
+
code="GROUNDING_NOT_REGULATION",
|
| 289 |
+
severity="critical",
|
| 290 |
+
message=(
|
| 291 |
+
f"Tryb {mode}: ocena nie może zakończyć się regulation-grounded pass. "
|
| 292 |
+
"Brak ugruntowania w regulaminie naboru."
|
| 293 |
+
),
|
| 294 |
+
category="grounding",
|
| 295 |
+
blocking=True,
|
| 296 |
+
)
|
| 297 |
+
)
|
| 298 |
+
score -= 40
|
| 299 |
+
|
| 300 |
+
usable = bool(brief.get("usable")) if "usable" in brief else (
|
| 301 |
+
bool(brief.get("key_rules") or brief.get("required_sections") or brief.get("required_attachments"))
|
| 302 |
+
)
|
| 303 |
+
if mode == "regulation" and not usable and not (
|
| 304 |
+
brief.get("key_rules") or brief.get("required_sections")
|
| 305 |
+
):
|
| 306 |
+
findings.append(
|
| 307 |
+
AdvisorFinding(
|
| 308 |
+
code="BRIEF_EMPTY",
|
| 309 |
+
severity="critical",
|
| 310 |
+
message="Brief doradcy pusty — brak reguł/sekcji z regulaminu. Nie można ugruntować oceny.",
|
| 311 |
+
category="grounding",
|
| 312 |
+
blocking=True,
|
| 313 |
+
)
|
| 314 |
+
)
|
| 315 |
+
score -= 35
|
| 316 |
+
|
| 317 |
+
# --- Instrument mismatch (Eurogranty vs SMART modules etc.) ---
|
| 318 |
+
try:
|
| 319 |
+
from core.projects.instrument_profile import (
|
| 320 |
+
detect_instrument_mismatch,
|
| 321 |
+
resolve_program_type,
|
| 322 |
+
)
|
| 323 |
+
|
| 324 |
+
prog_type = str(
|
| 325 |
+
brief.get("program_type")
|
| 326 |
+
or (brief.get("instrument_program_type") if isinstance(brief, dict) else "")
|
| 327 |
+
or ""
|
| 328 |
+
)
|
| 329 |
+
# Allow caller to pass via document_text meta later; also scan section titles
|
| 330 |
+
mismatch = detect_instrument_mismatch(
|
| 331 |
+
program_type=prog_type or resolve_program_type(program_name=str(brief.get("name") or "")),
|
| 332 |
+
document_text=blob,
|
| 333 |
+
section_titles=list(sections.keys()),
|
| 334 |
+
)
|
| 335 |
+
if mismatch.get("mismatch"):
|
| 336 |
+
for msg in mismatch.get("findings") or []:
|
| 337 |
+
findings.append(
|
| 338 |
+
AdvisorFinding(
|
| 339 |
+
code="INSTRUMENT_MISMATCH",
|
| 340 |
+
severity="critical",
|
| 341 |
+
message=msg,
|
| 342 |
+
category="program_alignment",
|
| 343 |
+
blocking=bool(mismatch.get("blocking")),
|
| 344 |
+
)
|
| 345 |
+
)
|
| 346 |
+
score -= int(mismatch.get("score_penalty") or 0)
|
| 347 |
+
except Exception:
|
| 348 |
+
pass
|
| 349 |
+
|
| 350 |
+
# --- Required sections (program alignment + missing elements) ---
|
| 351 |
+
required_sections = list(brief.get("required_sections") or [])
|
| 352 |
+
covered: List[str] = []
|
| 353 |
+
missing: List[str] = []
|
| 354 |
+
for req in required_sections:
|
| 355 |
+
if _section_present(req, sections, blob):
|
| 356 |
+
covered.append(req)
|
| 357 |
+
else:
|
| 358 |
+
missing.append(req)
|
| 359 |
+
findings.append(
|
| 360 |
+
AdvisorFinding(
|
| 361 |
+
code="MISSING_REQUIRED_SECTION",
|
| 362 |
+
severity="critical",
|
| 363 |
+
message=f"Brak wymaganej sekcji/treści: {req}",
|
| 364 |
+
category="missing_required",
|
| 365 |
+
section=req,
|
| 366 |
+
blocking=True,
|
| 367 |
+
)
|
| 368 |
+
)
|
| 369 |
+
score -= 12
|
| 370 |
+
|
| 371 |
+
# --- Key rules: distinctive multi-token / domain coverage (no fluff hits) ---
|
| 372 |
+
rules = list(brief.get("key_rules") or [])
|
| 373 |
+
rules_hit = 0
|
| 374 |
+
rules_checked = rules[:12]
|
| 375 |
+
for rule in rules_checked:
|
| 376 |
+
if rule_is_addressed(rule, blob):
|
| 377 |
+
rules_hit += 1
|
| 378 |
+
if rules_checked and mode == "regulation":
|
| 379 |
+
rule_ratio = rules_hit / max(len(rules_checked), 1)
|
| 380 |
+
if rule_ratio < 0.5:
|
| 381 |
+
findings.append(
|
| 382 |
+
AdvisorFinding(
|
| 383 |
+
code="WEAK_RULE_ALIGNMENT",
|
| 384 |
+
severity="critical" if rule_ratio < 0.35 else "major",
|
| 385 |
+
message=(
|
| 386 |
+
f"Słabe dopasowanie do reguł regulaminu "
|
| 387 |
+
f"({rules_hit}/{len(rules_checked)} reguł z distinctive signals w treści)."
|
| 388 |
+
),
|
| 389 |
+
category="program_alignment",
|
| 390 |
+
blocking=rule_ratio < 0.5,
|
| 391 |
+
)
|
| 392 |
+
)
|
| 393 |
+
score -= 25 if rule_ratio < 0.35 else 12
|
| 394 |
+
elif rule_ratio >= 0.7:
|
| 395 |
+
score = min(100, score + 5)
|
| 396 |
+
|
| 397 |
+
# --- Core domain signals required by brief must appear in application ---
|
| 398 |
+
if mode == "regulation":
|
| 399 |
+
for sig in core_signals_required_by_brief(brief):
|
| 400 |
+
if not core_signal_present(sig, blob):
|
| 401 |
+
findings.append(
|
| 402 |
+
AdvisorFinding(
|
| 403 |
+
code="MISSING_CORE_SIGNAL",
|
| 404 |
+
severity="critical",
|
| 405 |
+
message=f"Brak kluczowego sygnału regulaminu w treści wniosku: {sig}",
|
| 406 |
+
category="program_alignment",
|
| 407 |
+
blocking=True,
|
| 408 |
+
)
|
| 409 |
+
)
|
| 410 |
+
score -= 15
|
| 411 |
+
|
| 412 |
+
# --- Attachments mentioned when brief requires them ---
|
| 413 |
+
missing_att: List[str] = []
|
| 414 |
+
for att in list(brief.get("required_attachments") or [])[:10]:
|
| 415 |
+
na = _norm(att)[:40]
|
| 416 |
+
if na and na[:12] not in _norm(blob):
|
| 417 |
+
# Also check generic "załącznik" coverage
|
| 418 |
+
if "załącznik" not in _norm(blob) and "zalacznik" not in _norm(blob):
|
| 419 |
+
missing_att.append(att)
|
| 420 |
+
findings.append(
|
| 421 |
+
AdvisorFinding(
|
| 422 |
+
code="ATTACHMENT_NOT_ADDRESSED",
|
| 423 |
+
severity="major",
|
| 424 |
+
message=f"Brak odniesienia do wymaganego załącznika: {att}",
|
| 425 |
+
category="missing_required",
|
| 426 |
+
blocking=False,
|
| 427 |
+
)
|
| 428 |
+
)
|
| 429 |
+
score -= 4
|
| 430 |
+
|
| 431 |
+
# --- Attention points (critical ones block regulation pass) ---
|
| 432 |
+
attention = list(brief.get("attention_points") or [])
|
| 433 |
+
addressed: List[str] = []
|
| 434 |
+
open_pts: List[str] = []
|
| 435 |
+
for pt in attention:
|
| 436 |
+
if _attention_addressed(pt, blob):
|
| 437 |
+
addressed.append(pt)
|
| 438 |
+
else:
|
| 439 |
+
open_pts.append(pt)
|
| 440 |
+
critical = _attention_is_critical(pt)
|
| 441 |
+
findings.append(
|
| 442 |
+
AdvisorFinding(
|
| 443 |
+
code="ATTENTION_OPEN",
|
| 444 |
+
severity="critical" if critical else "minor",
|
| 445 |
+
message=f"Punkt uwagi regulaminu niezaadresowany: {pt}",
|
| 446 |
+
category="quality",
|
| 447 |
+
blocking=critical,
|
| 448 |
+
)
|
| 449 |
+
)
|
| 450 |
+
score -= 12 if critical else 3
|
| 451 |
+
|
| 452 |
+
# --- Critical quality / readiness (empty/short sections) ---
|
| 453 |
+
short_sections = 0
|
| 454 |
+
for title, body in sections.items():
|
| 455 |
+
body = body or ""
|
| 456 |
+
if len(body.strip()) < min_section_chars or "[UZUPEŁNIĆ" in body:
|
| 457 |
+
short_sections += 1
|
| 458 |
+
findings.append(
|
| 459 |
+
AdvisorFinding(
|
| 460 |
+
code="SECTION_TOO_THIN",
|
| 461 |
+
severity="major",
|
| 462 |
+
message=f"Sekcja zbyt krótka lub niekompletna: {title}",
|
| 463 |
+
category="quality",
|
| 464 |
+
section=title,
|
| 465 |
+
blocking=len(body.strip()) < 20,
|
| 466 |
+
)
|
| 467 |
+
)
|
| 468 |
+
score -= 6
|
| 469 |
+
if not sections and len(blob.strip()) < 120:
|
| 470 |
+
findings.append(
|
| 471 |
+
AdvisorFinding(
|
| 472 |
+
code="DOCUMENT_EMPTY",
|
| 473 |
+
severity="critical",
|
| 474 |
+
message="Dokument wniosku pusty lub zbyt krótki.",
|
| 475 |
+
category="quality",
|
| 476 |
+
blocking=True,
|
| 477 |
+
)
|
| 478 |
+
)
|
| 479 |
+
score -= 40
|
| 480 |
+
|
| 481 |
+
score = max(0, min(100, score))
|
| 482 |
+
blockers = [f.message for f in findings if f.blocking]
|
| 483 |
+
critical = [f for f in findings if f.severity == "critical"]
|
| 484 |
+
|
| 485 |
+
# Explicit: never soft-pass structure_only / blind / blocked as regulation-grounded
|
| 486 |
+
if mode in ("structure_only", "blocked", "blind"):
|
| 487 |
+
regulation_grounded_pass = False
|
| 488 |
+
else:
|
| 489 |
+
regulation_grounded_pass = (
|
| 490 |
+
mode == "regulation"
|
| 491 |
+
and usable
|
| 492 |
+
and score >= min_score
|
| 493 |
+
and not blockers
|
| 494 |
+
and len(critical) == 0
|
| 495 |
+
)
|
| 496 |
+
|
| 497 |
+
if mode == "regulation":
|
| 498 |
+
passed = regulation_grounded_pass
|
| 499 |
+
elif mode == "structure_only":
|
| 500 |
+
# Structural readiness only — never regulation_grounded_pass
|
| 501 |
+
passed = (
|
| 502 |
+
len(blob.strip()) >= 200
|
| 503 |
+
and short_sections == 0
|
| 504 |
+
and score >= max(40, min_score - 25)
|
| 505 |
+
and not any(f.code == "DOCUMENT_EMPTY" for f in findings)
|
| 506 |
+
)
|
| 507 |
+
else:
|
| 508 |
+
passed = False
|
| 509 |
+
|
| 510 |
+
summary_bits = [
|
| 511 |
+
f"score={score}",
|
| 512 |
+
f"mode={mode}",
|
| 513 |
+
f"missing_sections={len(missing)}",
|
| 514 |
+
f"blockers={len(blockers)}",
|
| 515 |
+
f"regulation_grounded_pass={regulation_grounded_pass}",
|
| 516 |
+
]
|
| 517 |
+
return AdvisorReport(
|
| 518 |
+
passed=passed,
|
| 519 |
+
score=score,
|
| 520 |
+
grounding_mode=mode,
|
| 521 |
+
regulation_grounded_pass=regulation_grounded_pass,
|
| 522 |
+
findings=findings,
|
| 523 |
+
blockers=blockers,
|
| 524 |
+
covered_sections=covered,
|
| 525 |
+
missing_sections=missing,
|
| 526 |
+
missing_attachments_mentioned=missing_att,
|
| 527 |
+
attention_addressed=addressed,
|
| 528 |
+
attention_open=open_pts,
|
| 529 |
+
brief_usable=usable,
|
| 530 |
+
summary="; ".join(summary_bits),
|
| 531 |
+
)
|
| 532 |
+
|
| 533 |
+
|
| 534 |
+
def advisor_findings_to_rewrite_targets(
|
| 535 |
+
report: AdvisorReport | Dict[str, Any],
|
| 536 |
+
plan_titles: Sequence[str],
|
| 537 |
+
) -> Dict[str, List[str]]:
|
| 538 |
+
"""Map advisor findings onto quality_loop section targets."""
|
| 539 |
+
from core.generation.quality_loop import section_title_match
|
| 540 |
+
|
| 541 |
+
if isinstance(report, AdvisorReport):
|
| 542 |
+
findings = report.findings
|
| 543 |
+
missing = report.missing_sections
|
| 544 |
+
else:
|
| 545 |
+
findings = report.get("findings") or []
|
| 546 |
+
missing = report.get("missing_sections") or []
|
| 547 |
+
|
| 548 |
+
targets: Dict[str, List[str]] = {}
|
| 549 |
+
for req in missing:
|
| 550 |
+
matched = section_title_match(str(req), plan_titles)
|
| 551 |
+
note = f"[world_class_advisor] Uzupełnij wymaganą treść regulaminu: {req}"
|
| 552 |
+
if matched:
|
| 553 |
+
targets.setdefault(matched, []).append(note)
|
| 554 |
+
elif plan_titles:
|
| 555 |
+
targets.setdefault(list(plan_titles)[0], []).append(note)
|
| 556 |
+
|
| 557 |
+
for f in findings:
|
| 558 |
+
if isinstance(f, AdvisorFinding):
|
| 559 |
+
code, msg, section, sev = f.code, f.message, f.section, f.severity
|
| 560 |
+
elif isinstance(f, dict):
|
| 561 |
+
code = f.get("code") or "FINDING"
|
| 562 |
+
msg = f.get("message") or ""
|
| 563 |
+
section = f.get("section") or ""
|
| 564 |
+
sev = f.get("severity") or "major"
|
| 565 |
+
else:
|
| 566 |
+
continue
|
| 567 |
+
if code in ("GROUNDING_NOT_REGULATION", "BRIEF_EMPTY"):
|
| 568 |
+
# global note on all titles (limited later by pick)
|
| 569 |
+
for t in plan_titles:
|
| 570 |
+
targets.setdefault(t, []).append(f"[world_class_advisor|{sev}] {msg}")
|
| 571 |
+
break
|
| 572 |
+
matched = section_title_match(section, plan_titles) if section else None
|
| 573 |
+
line = f"[world_class_advisor|{sev}|{code}] {msg}"
|
| 574 |
+
if matched:
|
| 575 |
+
targets.setdefault(matched, []).append(line)
|
| 576 |
+
elif plan_titles and sev == "critical":
|
| 577 |
+
# Global alignment/core-signal issues: attach to budget/alignment-like titles if any,
|
| 578 |
+
# never blindly rewrite the first healthy section (e.g. full Wstęp).
|
| 579 |
+
if code in ("WEAK_RULE_ALIGNMENT", "MISSING_CORE_SIGNAL"):
|
| 580 |
+
domain_keys = ("budżet", "budzet", "finans", "koszt", "opis", "innowacj", "dopasow")
|
| 581 |
+
hit_any = False
|
| 582 |
+
for t in plan_titles:
|
| 583 |
+
nt = _norm(t)
|
| 584 |
+
if any(k in nt for k in domain_keys):
|
| 585 |
+
targets.setdefault(t, []).append(line)
|
| 586 |
+
hit_any = True
|
| 587 |
+
if not hit_any:
|
| 588 |
+
# last resort: last plan title (often budget/closing), not first intro
|
| 589 |
+
targets.setdefault(list(plan_titles)[-1], []).append(line)
|
| 590 |
+
else:
|
| 591 |
+
targets.setdefault(list(plan_titles)[0], []).append(line)
|
| 592 |
+
return targets
|
| 593 |
+
|
| 594 |
+
|
| 595 |
+
def evaluate_from_generator_state(state: Dict[str, Any]) -> AdvisorReport:
|
| 596 |
+
"""Convenience: build brief + sections from generator/external_context state."""
|
| 597 |
+
ext = state.get("external_context") if isinstance(state.get("external_context"), dict) else {}
|
| 598 |
+
generated = state.get("generated_sections") if isinstance(state.get("generated_sections"), dict) else {}
|
| 599 |
+
mode = str(ext.get("grounding_mode") or state.get("grounding_mode") or "regulation").lower()
|
| 600 |
+
brief = dict(ext.get("advisor_brief") or {}) if isinstance(ext.get("advisor_brief"), dict) else {}
|
| 601 |
+
if not brief:
|
| 602 |
+
brief = {
|
| 603 |
+
"key_rules": list(ext.get("regulation_key_rules") or ext.get("key_rules") or []),
|
| 604 |
+
"required_sections": list(ext.get("required_sections") or []),
|
| 605 |
+
"required_attachments": list(ext.get("required_attachments") or []),
|
| 606 |
+
"attention_points": list(ext.get("attention_points") or []),
|
| 607 |
+
}
|
| 608 |
+
brief["usable"] = bool(
|
| 609 |
+
brief["key_rules"] or brief["required_sections"] or brief["required_attachments"]
|
| 610 |
+
)
|
| 611 |
+
# Instrument family for mismatch detection
|
| 612 |
+
try:
|
| 613 |
+
from core.projects.instrument_profile import resolve_program_type
|
| 614 |
+
|
| 615 |
+
brief["program_type"] = resolve_program_type(
|
| 616 |
+
program_type=str(ext.get("instrument_program_type") or ext.get("program_type") or ""),
|
| 617 |
+
program_name=str(ext.get("program_name") or ext.get("grant_name") or ""),
|
| 618 |
+
grant_id=str(ext.get("grant_id") or ""),
|
| 619 |
+
)
|
| 620 |
+
brief["name"] = str(ext.get("program_name") or ext.get("grant_name") or "")
|
| 621 |
+
except Exception:
|
| 622 |
+
pass
|
| 623 |
+
return evaluate_application(
|
| 624 |
+
sections=generated,
|
| 625 |
+
document_text=state.get("full_document") or "",
|
| 626 |
+
brief=brief,
|
| 627 |
+
grounding_mode=mode,
|
| 628 |
+
)
|
backend/alembic.ini
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# A generic, single database configuration.
|
| 2 |
+
|
| 3 |
+
[alembic]
|
| 4 |
+
# path to migration scripts.
|
| 5 |
+
# this is typically a path given in POSIX (e.g. forward slashes)
|
| 6 |
+
# format, relative to the token %(here)s which refers to the location of this
|
| 7 |
+
# ini file
|
| 8 |
+
script_location = %(here)s/alembic
|
| 9 |
+
|
| 10 |
+
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
| 11 |
+
# Uncomment the line below if you want the files to be prepended with date and time
|
| 12 |
+
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
| 13 |
+
# for all available tokens
|
| 14 |
+
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
| 15 |
+
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
|
| 16 |
+
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
|
| 17 |
+
|
| 18 |
+
# sys.path path, will be prepended to sys.path if present.
|
| 19 |
+
# defaults to the current working directory. for multiple paths, the path separator
|
| 20 |
+
# is defined by "path_separator" below.
|
| 21 |
+
prepend_sys_path = .
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# timezone to use when rendering the date within the migration file
|
| 25 |
+
# as well as the filename.
|
| 26 |
+
# If specified, requires the tzdata library which can be installed by adding
|
| 27 |
+
# `alembic[tz]` to the pip requirements.
|
| 28 |
+
# string value is passed to ZoneInfo()
|
| 29 |
+
# leave blank for localtime
|
| 30 |
+
# timezone =
|
| 31 |
+
|
| 32 |
+
# max length of characters to apply to the "slug" field
|
| 33 |
+
# truncate_slug_length = 40
|
| 34 |
+
|
| 35 |
+
# set to 'true' to run the environment during
|
| 36 |
+
# the 'revision' command, regardless of autogenerate
|
| 37 |
+
# revision_environment = false
|
| 38 |
+
|
| 39 |
+
# set to 'true' to allow .pyc and .pyo files without
|
| 40 |
+
# a source .py file to be detected as revisions in the
|
| 41 |
+
# versions/ directory
|
| 42 |
+
# sourceless = false
|
| 43 |
+
|
| 44 |
+
# version location specification; This defaults
|
| 45 |
+
# to <script_location>/versions. When using multiple version
|
| 46 |
+
# directories, initial revisions must be specified with --version-path.
|
| 47 |
+
# The path separator used here should be the separator specified by "path_separator"
|
| 48 |
+
# below.
|
| 49 |
+
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
| 50 |
+
|
| 51 |
+
# path_separator; This indicates what character is used to split lists of file
|
| 52 |
+
# paths, including version_locations and prepend_sys_path within configparser
|
| 53 |
+
# files such as alembic.ini.
|
| 54 |
+
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
| 55 |
+
# to provide os-dependent path splitting.
|
| 56 |
+
#
|
| 57 |
+
# Note that in order to support legacy alembic.ini files, this default does NOT
|
| 58 |
+
# take place if path_separator is not present in alembic.ini. If this
|
| 59 |
+
# option is omitted entirely, fallback logic is as follows:
|
| 60 |
+
#
|
| 61 |
+
# 1. Parsing of the version_locations option falls back to using the legacy
|
| 62 |
+
# "version_path_separator" key, which if absent then falls back to the legacy
|
| 63 |
+
# behavior of splitting on spaces and/or commas.
|
| 64 |
+
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
| 65 |
+
# behavior of splitting on spaces, commas, or colons.
|
| 66 |
+
#
|
| 67 |
+
# Valid values for path_separator are:
|
| 68 |
+
#
|
| 69 |
+
# path_separator = :
|
| 70 |
+
# path_separator = ;
|
| 71 |
+
# path_separator = space
|
| 72 |
+
# path_separator = newline
|
| 73 |
+
#
|
| 74 |
+
# Use os.pathsep. Default configuration used for new projects.
|
| 75 |
+
path_separator = os
|
| 76 |
+
|
| 77 |
+
# set to 'true' to search source files recursively
|
| 78 |
+
# in each "version_locations" directory
|
| 79 |
+
# new in Alembic version 1.10
|
| 80 |
+
# recursive_version_locations = false
|
| 81 |
+
|
| 82 |
+
# the output encoding used when revision files
|
| 83 |
+
# are written from script.py.mako
|
| 84 |
+
# output_encoding = utf-8
|
| 85 |
+
|
| 86 |
+
# database URL. This is consumed by the user-maintained env.py script only.
|
| 87 |
+
# other means of configuring database URLs may be customized within the env.py
|
| 88 |
+
# file.
|
| 89 |
+
sqlalchemy.url = driver://user:pass@localhost/dbname
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
[post_write_hooks]
|
| 93 |
+
# post_write_hooks defines scripts or Python functions that are run
|
| 94 |
+
# on newly generated revision scripts. See the documentation for further
|
| 95 |
+
# detail and examples
|
| 96 |
+
|
| 97 |
+
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
| 98 |
+
# hooks = black
|
| 99 |
+
# black.type = console_scripts
|
| 100 |
+
# black.entrypoint = black
|
| 101 |
+
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
| 102 |
+
|
| 103 |
+
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
| 104 |
+
# hooks = ruff
|
| 105 |
+
# ruff.type = module
|
| 106 |
+
# ruff.module = ruff
|
| 107 |
+
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
| 108 |
+
|
| 109 |
+
# Alternatively, use the exec runner to execute a binary found on your PATH
|
| 110 |
+
# hooks = ruff
|
| 111 |
+
# ruff.type = exec
|
| 112 |
+
# ruff.executable = ruff
|
| 113 |
+
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
| 114 |
+
|
| 115 |
+
# Logging configuration. This is also consumed by the user-maintained
|
| 116 |
+
# env.py script only.
|
| 117 |
+
[loggers]
|
| 118 |
+
keys = root,sqlalchemy,alembic
|
| 119 |
+
|
| 120 |
+
[handlers]
|
| 121 |
+
keys = console
|
| 122 |
+
|
| 123 |
+
[formatters]
|
| 124 |
+
keys = generic
|
| 125 |
+
|
| 126 |
+
[logger_root]
|
| 127 |
+
level = WARNING
|
| 128 |
+
handlers = console
|
| 129 |
+
qualname =
|
| 130 |
+
|
| 131 |
+
[logger_sqlalchemy]
|
| 132 |
+
level = WARNING
|
| 133 |
+
handlers =
|
| 134 |
+
qualname = sqlalchemy.engine
|
| 135 |
+
|
| 136 |
+
[logger_alembic]
|
| 137 |
+
level = INFO
|
| 138 |
+
handlers =
|
| 139 |
+
qualname = alembic
|
| 140 |
+
|
| 141 |
+
[handler_console]
|
| 142 |
+
class = StreamHandler
|
| 143 |
+
args = (sys.stderr,)
|
| 144 |
+
level = NOTSET
|
| 145 |
+
formatter = generic
|
| 146 |
+
|
| 147 |
+
[formatter_generic]
|
| 148 |
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
| 149 |
+
datefmt = %H:%M:%S
|
backend/alembic/README
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Generic single-database configuration.
|
backend/alembic/env.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ruff: noqa: E402
|
| 2 |
+
from logging.config import fileConfig
|
| 3 |
+
|
| 4 |
+
from sqlalchemy import engine_from_config
|
| 5 |
+
from sqlalchemy import pool
|
| 6 |
+
|
| 7 |
+
from alembic import context
|
| 8 |
+
|
| 9 |
+
# this is the Alembic Config object, which provides
|
| 10 |
+
# access to the values within the .ini file in use.
|
| 11 |
+
config = context.config
|
| 12 |
+
|
| 13 |
+
# Interpret the config file for Python logging.
|
| 14 |
+
# This line sets up loggers basically.
|
| 15 |
+
if config.config_file_name is not None:
|
| 16 |
+
fileConfig(config.config_file_name)
|
| 17 |
+
|
| 18 |
+
# add your model's MetaData object here
|
| 19 |
+
# for 'autogenerate' support
|
| 20 |
+
import os
|
| 21 |
+
import sys
|
| 22 |
+
|
| 23 |
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
| 24 |
+
from dotenv import load_dotenv
|
| 25 |
+
|
| 26 |
+
load_dotenv()
|
| 27 |
+
|
| 28 |
+
# Use the shared init_models() so all models are registered on the single Base
|
| 29 |
+
from core.subscription.db import Base, init_models
|
| 30 |
+
init_models()
|
| 31 |
+
|
| 32 |
+
target_metadata = Base.metadata
|
| 33 |
+
|
| 34 |
+
db_url = os.getenv("DATABASE_URL", "postgresql://user:password@localhost:5432/dotacje")
|
| 35 |
+
if db_url and db_url.startswith("postgres://"):
|
| 36 |
+
db_url = db_url.replace("postgres://", "postgresql://", 1)
|
| 37 |
+
|
| 38 |
+
config.set_main_option("sqlalchemy.url", db_url)
|
| 39 |
+
|
| 40 |
+
# other values from the config, defined by the needs of env.py,
|
| 41 |
+
# can be acquired:
|
| 42 |
+
# my_important_option = config.get_main_option("my_important_option")
|
| 43 |
+
# ... etc.
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def run_migrations_offline() -> None:
|
| 47 |
+
"""Run migrations in 'offline' mode.
|
| 48 |
+
|
| 49 |
+
This configures the context with just a URL
|
| 50 |
+
and not an Engine, though an Engine is acceptable
|
| 51 |
+
here as well. By skipping the Engine creation
|
| 52 |
+
we don't even need a DBAPI to be available.
|
| 53 |
+
|
| 54 |
+
Calls to context.execute() here emit the given string to the
|
| 55 |
+
script output.
|
| 56 |
+
|
| 57 |
+
"""
|
| 58 |
+
url = config.get_main_option("sqlalchemy.url")
|
| 59 |
+
context.configure(
|
| 60 |
+
url=url,
|
| 61 |
+
target_metadata=target_metadata,
|
| 62 |
+
literal_binds=True,
|
| 63 |
+
dialect_opts={"paramstyle": "named"},
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
with context.begin_transaction():
|
| 67 |
+
context.run_migrations()
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def run_migrations_online() -> None:
|
| 71 |
+
"""Run migrations in 'online' mode.
|
| 72 |
+
|
| 73 |
+
In this scenario we need to create an Engine
|
| 74 |
+
and associate a connection with the context.
|
| 75 |
+
|
| 76 |
+
"""
|
| 77 |
+
connectable = engine_from_config(
|
| 78 |
+
config.get_section(config.config_ini_section, {}),
|
| 79 |
+
prefix="sqlalchemy.",
|
| 80 |
+
poolclass=pool.NullPool,
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
with connectable.connect() as connection:
|
| 84 |
+
context.configure(connection=connection, target_metadata=target_metadata)
|
| 85 |
+
|
| 86 |
+
with context.begin_transaction():
|
| 87 |
+
context.run_migrations()
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
if context.is_offline_mode():
|
| 91 |
+
run_migrations_offline()
|
| 92 |
+
else:
|
| 93 |
+
run_migrations_online()
|
backend/alembic/script.py.mako
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""${message}
|
| 2 |
+
|
| 3 |
+
Revision ID: ${up_revision}
|
| 4 |
+
Revises: ${down_revision | comma,n}
|
| 5 |
+
Create Date: ${create_date}
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
${imports if imports else ""}
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = ${repr(up_revision)}
|
| 16 |
+
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
"""Upgrade schema."""
|
| 23 |
+
${upgrades if upgrades else "pass"}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def downgrade() -> None:
|
| 27 |
+
"""Downgrade schema."""
|
| 28 |
+
${downgrades if downgrades else "pass"}
|
backend/alembic/versions/0848fd2356d9_sprint2.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""sprint2
|
| 2 |
+
|
| 3 |
+
Revision ID: 0848fd2356d9
|
| 4 |
+
Revises: 3109c5f526b6
|
| 5 |
+
Create Date: 2026-04-14 16:24:08.025520
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from typing import Sequence, Union
|
| 10 |
+
|
| 11 |
+
from alembic import op
|
| 12 |
+
import sqlalchemy as sa
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# revision identifiers, used by Alembic.
|
| 16 |
+
revision: str = "0848fd2356d9"
|
| 17 |
+
down_revision: Union[str, Sequence[str], None] = "3109c5f526b6"
|
| 18 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 19 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def upgrade() -> None:
|
| 23 |
+
"""Upgrade schema."""
|
| 24 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 25 |
+
op.add_column(
|
| 26 |
+
"users", sa.Column("gdpr_consent_accepted", sa.Boolean(), nullable=True)
|
| 27 |
+
)
|
| 28 |
+
op.add_column(
|
| 29 |
+
"users", sa.Column("gdpr_consent_timestamp", sa.DateTime(), nullable=True)
|
| 30 |
+
)
|
| 31 |
+
op.add_column(
|
| 32 |
+
"users", sa.Column("ai_disclaimer_enabled", sa.Boolean(), nullable=True)
|
| 33 |
+
)
|
| 34 |
+
# ### end Alembic commands ###
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def downgrade() -> None:
|
| 38 |
+
"""Downgrade schema."""
|
| 39 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 40 |
+
op.drop_column("users", "ai_disclaimer_enabled")
|
| 41 |
+
op.drop_column("users", "gdpr_consent_timestamp")
|
| 42 |
+
op.drop_column("users", "gdpr_consent_accepted")
|
| 43 |
+
# ### end Alembic commands ###
|
backend/alembic/versions/0e48eb7134d7_add_final_document_columns.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""add final document columns
|
| 2 |
+
|
| 3 |
+
Revision ID: 0e48eb7134d7
|
| 4 |
+
Revises: e1922a470e92
|
| 5 |
+
Create Date: 2026-04-11 09:48:13.226601
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from typing import Sequence, Union
|
| 10 |
+
|
| 11 |
+
from alembic import op
|
| 12 |
+
import sqlalchemy as sa
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# revision identifiers, used by Alembic.
|
| 16 |
+
revision: str = "0e48eb7134d7"
|
| 17 |
+
down_revision: Union[str, Sequence[str], None] = "e1922a470e92"
|
| 18 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 19 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def upgrade() -> None:
|
| 23 |
+
"""Upgrade schema."""
|
| 24 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 25 |
+
op.add_column(
|
| 26 |
+
"projects", sa.Column("final_document_markdown", sa.Text(), nullable=True)
|
| 27 |
+
)
|
| 28 |
+
op.add_column(
|
| 29 |
+
"projects",
|
| 30 |
+
sa.Column("final_document_generated_at", sa.DateTime(), nullable=True),
|
| 31 |
+
)
|
| 32 |
+
# ### end Alembic commands ###
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def downgrade() -> None:
|
| 36 |
+
"""Downgrade schema."""
|
| 37 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 38 |
+
op.drop_column("projects", "final_document_generated_at")
|
| 39 |
+
op.drop_column("projects", "final_document_markdown")
|
| 40 |
+
# ### end Alembic commands ###
|
backend/alembic/versions/0f91b1724111_add_external_context_to_projects.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Add external_context to projects
|
| 2 |
+
|
| 3 |
+
Revision ID: 0f91b1724111
|
| 4 |
+
Revises: 0e48eb7134d7
|
| 5 |
+
Create Date: 2026-04-12 14:15:10.107090
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from typing import Sequence, Union
|
| 10 |
+
|
| 11 |
+
from alembic import op
|
| 12 |
+
import sqlalchemy as sa
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# revision identifiers, used by Alembic.
|
| 16 |
+
revision: str = "0f91b1724111"
|
| 17 |
+
down_revision: Union[str, Sequence[str], None] = "0e48eb7134d7"
|
| 18 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 19 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def upgrade() -> None:
|
| 23 |
+
"""Upgrade schema."""
|
| 24 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 25 |
+
op.add_column(
|
| 26 |
+
"projects", sa.Column("final_document_audit_result", sa.JSON(), nullable=True)
|
| 27 |
+
)
|
| 28 |
+
op.add_column("projects", sa.Column("external_context", sa.JSON(), nullable=True))
|
| 29 |
+
# ### end Alembic commands ###
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def downgrade() -> None:
|
| 33 |
+
"""Downgrade schema."""
|
| 34 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 35 |
+
op.drop_column("projects", "external_context")
|
| 36 |
+
op.drop_column("projects", "final_document_audit_result")
|
| 37 |
+
# ### end Alembic commands ###
|
backend/alembic/versions/20260527_add_regulation_snapshots.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Add regulation_snapshots table (Faza 3 - Regulation Engine primary storage)
|
| 2 |
+
|
| 3 |
+
Revision ID: 20260527_regsnap
|
| 4 |
+
Revises: f3a9d2c1e005
|
| 5 |
+
Create Date: 2026-05-27
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from alembic import op
|
| 9 |
+
import sqlalchemy as sa
|
| 10 |
+
from sqlalchemy.dialects import postgresql
|
| 11 |
+
|
| 12 |
+
# revision identifiers, used by Alembic.
|
| 13 |
+
revision = '20260527_regsnap'
|
| 14 |
+
down_revision = 'f3a9d2c1e005'
|
| 15 |
+
branch_labels = None
|
| 16 |
+
depends_on = None
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def upgrade() -> None:
|
| 20 |
+
op.create_table(
|
| 21 |
+
'regulation_snapshots',
|
| 22 |
+
sa.Column('id', sa.String(), primary_key=True, index=True),
|
| 23 |
+
sa.Column('program', sa.String(), index=True, nullable=False),
|
| 24 |
+
sa.Column('call_name', sa.String()),
|
| 25 |
+
sa.Column('source_url', sa.String()),
|
| 26 |
+
sa.Column('fetched_at', sa.String()),
|
| 27 |
+
sa.Column('version_hash', sa.String(), unique=True, index=True),
|
| 28 |
+
sa.Column('effective_date', sa.String()),
|
| 29 |
+
sa.Column('document_version', sa.String()),
|
| 30 |
+
sa.Column('last_updated', sa.String()),
|
| 31 |
+
sa.Column('source_institution', sa.String()),
|
| 32 |
+
sa.Column('key_rules', postgresql.JSON(astext_type=sa.Text()), nullable=True),
|
| 33 |
+
sa.Column('exclusions', postgresql.JSON(astext_type=sa.Text()), nullable=True),
|
| 34 |
+
sa.Column('scoring_criteria', postgresql.JSON(astext_type=sa.Text()), nullable=True),
|
| 35 |
+
sa.Column('required_attachments', postgresql.JSON(astext_type=sa.Text()), nullable=True),
|
| 36 |
+
sa.Column('raw_text_sample', sa.Text()),
|
| 37 |
+
sa.Column('metadata', postgresql.JSON(astext_type=sa.Text()), nullable=True),
|
| 38 |
+
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def downgrade() -> None:
|
| 43 |
+
op.drop_table('regulation_snapshots')
|