Spaces:
Running
Running
GrantForge Bot commited on
Commit ·
3a3734f
0
Parent(s):
Deploy sha-9a5957fcdef15b7e2623f8b147cda6026475aee0 — 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 +80 -0
- backend/.deepeval/.deepeval-cache.json +1 -0
- backend/.deepeval/.deepeval_telemetry.txt +4 -0
- backend/.env.example +39 -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 +328 -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 +1615 -0
- backend/agents/grant_research_agent.py +165 -0
- backend/agents/helpers.py +862 -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 +74 -0
- backend/agents/researcher.py +201 -0
- backend/agents/risk_scoring.py +104 -0
- backend/agents/scraper_agent.py +83 -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/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
- backend/alembic/versions/20260604_add_grants_company_profiles.py +81 -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,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: "Grantforge Api"
|
| 3 |
+
emoji: "🏢"
|
| 4 |
+
colorFrom: "blue"
|
| 5 |
+
colorTo: "red"
|
| 6 |
+
sdk: "docker"
|
| 7 |
+
app_file: "app.py"
|
| 8 |
+
pinned: false
|
| 9 |
+
app_port: 7860
|
| 10 |
+
---
|
| 11 |
+
# Antigravity Dotacje - Architektura i Założenia
|
| 12 |
+
|
| 13 |
+
Antigravity Dotacje to zaawansowana aplikacja typu SaaS zaprojektowana dla sektora B2B. Służy do automatycznego analizowania szans na uzyskanie dotacji ze środków unijnych oraz generowania wniosków dotacyjnych przy użyciu modeli językowych LLM współpracujących z bazą wektorową RAG (Retrieval-Augmented Generation).
|
| 14 |
+
|
| 15 |
+
## Stos Technologiczny
|
| 16 |
+
|
| 17 |
+
### Frontend (User Interface)
|
| 18 |
+
* **Framework:** React + TypeScript (Vite)
|
| 19 |
+
* **Nawigacja:** `react-router-dom`
|
| 20 |
+
* **Zarządzanie Stanem / Autoryzacja:** Clerk (`@clerk/clerk-react`) użyty jako Identity Provider.
|
| 21 |
+
* **Stylizacja:** Czysty CSS / CSS Modules (z elementami designu inspirowanymi trendem Glassmorphism w edycji Enterprise: głębokie kontrasty, `backdrop-filter: blur`, zgaszone akcenty gradientowe).
|
| 22 |
+
* **Komponenty interaktywne i animacje:** `framer-motion`
|
| 23 |
+
* **Wizualizacje/Grafika:** Ikony `lucide-react`.
|
| 24 |
+
|
| 25 |
+
### Backend (Logic & AI)
|
| 26 |
+
* **Framework:** FastAPI (Python 3.10+) - asynchroniczny z API opartym o Pydantic.
|
| 27 |
+
* **Baza Konwencjonalna:** MongoDB (Motor / PyMongo) do przechowywania profili użytkowników, zapisanych projektów i logów.
|
| 28 |
+
* **Architektura RAG (Baza Wektorowa):** Zintegrowana z silnikami wektorowymi (np. Qdrant lub Pinecone - w zarysie koncepcyjnym LLM przeszukuje embedingi PDF-ów z regulaminami POIR, FENG, ARiMR). W bieżącej fazie demonstracyjnej mechanizmy LLM są zabezpieczone odpowiednimi asercjami i symulowane.
|
| 29 |
+
* **Obsługa CORS:** Skonfigurowana w `main.py` pod domenę frontendu (np. Vercel).
|
| 30 |
+
|
| 31 |
+
## Architektura Systemu
|
| 32 |
+
|
| 33 |
+
System opiera się na wydzielonych blokach modułowych:
|
| 34 |
+
|
| 35 |
+
1. **Discovery (Wizard):** Rejestrowany użytkownik (przez profil Clerk) rozpoczyna proces od wprowadzenia NIP-u i zarysu planowanej inwestycji R&D / MŚP.
|
| 36 |
+
2. **Matchmaking RAG (AI Analyst):** Asystent filtruje aktualne dotacje (SMART, ARiMR, ZUS) oceniając procent "Match" (np. 92% na Ścieżkę SMART).
|
| 37 |
+
3. **Draft Generation (Generowanie Wniosku):** Projekt zostaje utworzony w bazie (`/projects`). Następnie model AI generuje określone wymagane sekcje (np. "Wpływ DNSH", "Kwalifikacja MŚP").
|
| 38 |
+
4. **Critic Loop (Weryfikacja / Recenzja):** Moduł Critic sprawdza gotowy draft pod kątem regulaminów. Zwraca feedback w skali `low`/`medium`/`high` severity nakazując poprawę.
|
| 39 |
+
5. **Finalisation:** Scalenie zaakceptowanych sekcji do postaci dokumentu w formacie Markdown z opcją pobrania do formatu TXT/MD bądź wydruku przez natywny silnik PDF w przeglądarce (`@media print`).
|
| 40 |
+
|
| 41 |
+
## Struktura Katalogów
|
| 42 |
+
|
| 43 |
+
```text
|
| 44 |
+
/
|
| 45 |
+
├── backend/ # API w Python + FastAPI
|
| 46 |
+
│ ├── app/ # Główne pliki backendowe
|
| 47 |
+
│ │ ├── main.py # Inicjalizacja App + Endpointy
|
| 48 |
+
│ │ ├── models.py # Modele Pydantic
|
| 49 |
+
│ │ ├── services.py # Warstwa logiki (symulacje LLM / RAG)
|
| 50 |
+
│ ├── requirements.txt
|
| 51 |
+
│
|
| 52 |
+
├── frontend-react/ # Aplikacja Single Page App (SPA)
|
| 53 |
+
│ ├── src/
|
| 54 |
+
│ │ ├── api/ # Warstwa transportowa Axios -> FastAPI
|
| 55 |
+
│ │ ├── components/ # Moduły wizualne (Dashboard, Modale, Edytor)
|
| 56 |
+
│ │ ├── styles/ # Pliki CSS (dashboard.css, globals.css)
|
| 57 |
+
│ │ ├── App.tsx # Definicje Routingów Frontendowych
|
| 58 |
+
│ ├── package.json
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
## Uruchamianie Lokalne
|
| 62 |
+
|
| 63 |
+
1. **Backend:**
|
| 64 |
+
Skonfiguruj MongoDB URL (np. w `.env` jeśli zaimplementowane, domyślnie łączy do klastra z bazy chmurowej/lokalnej).
|
| 65 |
+
```bash
|
| 66 |
+
cd backend
|
| 67 |
+
pip install -r requirements.txt
|
| 68 |
+
python run.py
|
| 69 |
+
# Backend uruchomi się na porcie 8000
|
| 70 |
+
```
|
| 71 |
+
2. **Frontend:**
|
| 72 |
+
Skonfiguruj zmienną w `.env` powiązaną z instancją CLERK (`VITE_CLERK_PUBLISHABLE_KEY`).
|
| 73 |
+
```bash
|
| 74 |
+
cd frontend-react
|
| 75 |
+
npm install
|
| 76 |
+
npm run dev
|
| 77 |
+
# Frontend uruchomi się na porcie 5173
|
| 78 |
+
```
|
| 79 |
+
|
| 80 |
+
|
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,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dotacje AI - Backend Environment Variables Example
|
| 2 |
+
|
| 3 |
+
# LLM Providers
|
| 4 |
+
GOOGLE_API_KEY="YOUR_GOOGLE_API_KEY"
|
| 5 |
+
GROK_API_KEY="YOUR_GROK_API_KEY"
|
| 6 |
+
GEMINI_EMBEDDING_MODEL="gemini-embedding-001"
|
| 7 |
+
GEMINI_EMBEDDING_DIMENSIONS="768"
|
| 8 |
+
|
| 9 |
+
# Vector Store (Pinecone) — wymagane do ingestu i semantycznego wyszukiwania
|
| 10 |
+
PINECONE_API_KEY="YOUR_PINECONE_API_KEY"
|
| 11 |
+
PINECONE_INDEX_NAME="dotacje"
|
| 12 |
+
# PINECONE_ENVIRONMENT="us-east-1" # opcjonalnie, zależnie od regionu indeksu
|
| 13 |
+
# ENABLE_PINECONE_SEMANTIC="true"
|
| 14 |
+
|
| 15 |
+
# Database (PostgreSQL production)
|
| 16 |
+
# DATABASE_URL="postgresql://user:pass@host:5432/dbname"
|
| 17 |
+
# DB_POOL_RECYCLE=1800
|
| 18 |
+
# DB_SSLMODE=prefer
|
| 19 |
+
|
| 20 |
+
# Tracing and Observability
|
| 21 |
+
LANGCHAIN_API_KEY="YOUR_LANGSMITH_API_KEY"
|
| 22 |
+
LANGCHAIN_TRACING_V2="true"
|
| 23 |
+
LANGCHAIN_PROJECT="GrantForgeAI"
|
| 24 |
+
|
| 25 |
+
# Graph Database (GraphRAG)
|
| 26 |
+
NEO4J_URI="neo4j+s://your-neo4j-instance.databases.neo4j.io"
|
| 27 |
+
NEO4J_USERNAME="neo4j"
|
| 28 |
+
NEO4J_PASSWORD="your-secure-password"
|
| 29 |
+
|
| 30 |
+
# Web Scraping (Required for Grant extraction)
|
| 31 |
+
# Crawl4AI — darmowy scraper (bez klucza API). Ustaw CRAWL4AI_USE_BROWSER=1 dla stron wymagających JS.
|
| 32 |
+
# CRAWL4AI_USE_BROWSER=0
|
| 33 |
+
|
| 34 |
+
# Regional RPO BIP parsers (F4.4)
|
| 35 |
+
# ENABLE_RPO_BIP_FETCH=true # 6 woj.: lubelskie, podkarpackie, łódzkie, mazowieckie, śląskie, wielkopolskie — cache 6h
|
| 36 |
+
|
| 37 |
+
# Development Flags
|
| 38 |
+
# Set to 'true' to serve fallback mock data if scraping yields < 3 results
|
| 39 |
+
MOCK_GRANTS="false"
|
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,328 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 re
|
| 22 |
+
from dataclasses import dataclass, field
|
| 23 |
+
from typing import Any
|
| 24 |
+
|
| 25 |
+
logger = logging.getLogger(__name__)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# Placeholdery uznawane za lukę danych (twarda bramka jakości).
|
| 29 |
+
# Obejmuje warianty polskie z/bez znaków diakrytycznych oraz techniczne markery.
|
| 30 |
+
PLACEHOLDER_PATTERN = re.compile(
|
| 31 |
+
r"\[\s*(?:"
|
| 32 |
+
r"do weryfikacji|do uzupe[łl]nienia|uzupe[łl]ni[jćc]|uzupelnij|"
|
| 33 |
+
r"brak danych|nieznane|todo|tbd|xxx|fixme|"
|
| 34 |
+
r"szacowany|szacowana|szacunkowo"
|
| 35 |
+
r")\b[^\]]*\]",
|
| 36 |
+
re.IGNORECASE,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
# P3#9: markery UPPER_SNAKE w nawiasach kwadratowych, np. [KWOTA_PLN],
|
| 40 |
+
# [KWOTA_INWESTYCJI_PLN], [LICZBA_PRACOWNIKÓW]. Wzorzec CASE-SENSITIVE, aby nie
|
| 41 |
+
# łapać zwykłego tekstu w nawiasach (małe litery / spacje). Wymaga min. 3 znaków
|
| 42 |
+
# i przynajmniej jednej litery (nie same cyfry/podkreślenia).
|
| 43 |
+
UPPER_SNAKE_PLACEHOLDER_PATTERN = re.compile(
|
| 44 |
+
r"\[\s*[A-ZĄĆĘŁŃÓŚŹŻ0-9_]*[A-ZĄĆĘŁŃÓŚŹŻ][A-ZĄĆĘŁŃÓŚŹŻ0-9_]*\s*\]"
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
# Krytyczne kategorie luk — ich brak realnie blokuje jakość wniosku.
|
| 48 |
+
_CRITICAL_HINTS = (
|
| 49 |
+
"nip", "regon", "krs", "kwot", "koszt", "budżet", "budzet", "przychod", "przychód",
|
| 50 |
+
"zatrudnien", "adres", "nazwa", "data", "wskaźnik", "wskaznik", "termin",
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@dataclass
|
| 55 |
+
class AutoFillResult:
|
| 56 |
+
"""Wynik działania Auto-Fill Agenta na fragmencie treści."""
|
| 57 |
+
|
| 58 |
+
filled_content: str = ""
|
| 59 |
+
resolved: list[dict[str, str]] = field(default_factory=list)
|
| 60 |
+
unresolved: list[str] = field(default_factory=list)
|
| 61 |
+
hitl_question: str | None = None
|
| 62 |
+
|
| 63 |
+
@property
|
| 64 |
+
def all_resolved(self) -> bool:
|
| 65 |
+
return not self.unresolved
|
| 66 |
+
|
| 67 |
+
@property
|
| 68 |
+
def has_placeholders(self) -> bool:
|
| 69 |
+
return bool(self.resolved or self.unresolved)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class AutoFillAgent:
|
| 73 |
+
"""Agent uzupełniania luk danych z rejestrów + OSINT (research)."""
|
| 74 |
+
|
| 75 |
+
# Maks. liczba placeholderów, dla których uruchamiamy kosztowny research per sekcja.
|
| 76 |
+
MAX_RESEARCH_CALLS = 3
|
| 77 |
+
|
| 78 |
+
# ------------------------------------------------------------------
|
| 79 |
+
# Wykrywanie placeholderów
|
| 80 |
+
# ------------------------------------------------------------------
|
| 81 |
+
@staticmethod
|
| 82 |
+
def detect_placeholders(text: str) -> list[str]:
|
| 83 |
+
"""Zwraca listę pełnych placeholderów wykrytych w tekście (z zachowaniem kolejności)."""
|
| 84 |
+
if not text:
|
| 85 |
+
return []
|
| 86 |
+
seen: list[str] = []
|
| 87 |
+
for match in PLACEHOLDER_PATTERN.findall(text):
|
| 88 |
+
ph = match.strip()
|
| 89 |
+
if ph and ph not in seen:
|
| 90 |
+
seen.append(ph)
|
| 91 |
+
# P3#9: markery UPPER_SNAKE ([KWOTA_PLN] itp.) — min. 3 znaki wewnątrz nawiasu.
|
| 92 |
+
for match in UPPER_SNAKE_PLACEHOLDER_PATTERN.findall(text):
|
| 93 |
+
ph = match.strip()
|
| 94 |
+
inner = ph.strip("[] ").strip()
|
| 95 |
+
if len(inner) >= 3 and ph not in seen:
|
| 96 |
+
seen.append(ph)
|
| 97 |
+
return seen
|
| 98 |
+
|
| 99 |
+
@staticmethod
|
| 100 |
+
def _placeholder_label(placeholder: str) -> str:
|
| 101 |
+
"""Wyciąga opis luki z wnętrza nawiasu, np. '[DO WERYFIKACJI: przychód]' -> 'przychód'."""
|
| 102 |
+
inner = placeholder.strip().lstrip("[").rstrip("]")
|
| 103 |
+
if ":" in inner:
|
| 104 |
+
inner = inner.split(":", 1)[1]
|
| 105 |
+
return inner.strip() or placeholder.strip("[]")
|
| 106 |
+
|
| 107 |
+
@staticmethod
|
| 108 |
+
def _is_critical(label: str) -> bool:
|
| 109 |
+
low = label.lower()
|
| 110 |
+
return any(h in low for h in _CRITICAL_HINTS)
|
| 111 |
+
|
| 112 |
+
# ------------------------------------------------------------------
|
| 113 |
+
# Źródła danych
|
| 114 |
+
# ------------------------------------------------------------------
|
| 115 |
+
@staticmethod
|
| 116 |
+
def _facts_from_project_context(project_context: Any) -> dict[str, str]:
|
| 117 |
+
"""Buduje mapę label->wartość ze zweryfikowanych danych wnioskodawcy."""
|
| 118 |
+
if project_context is None:
|
| 119 |
+
return {}
|
| 120 |
+
company = getattr(project_context, "company", None)
|
| 121 |
+
if company is None:
|
| 122 |
+
return {}
|
| 123 |
+
try:
|
| 124 |
+
facts = company.known_facts()
|
| 125 |
+
except Exception:
|
| 126 |
+
return {}
|
| 127 |
+
# normalizuj klucze do lowercase dla dopasowania po słowach kluczowych
|
| 128 |
+
return {str(k).lower(): str(v) for k, v in facts.items() if v}
|
| 129 |
+
|
| 130 |
+
@staticmethod
|
| 131 |
+
def _match_fact(label: str, facts: dict[str, str]) -> str | None:
|
| 132 |
+
"""Dopasowuje etykietę placeholdera do znanego faktu po słowach kluczowych."""
|
| 133 |
+
low = label.lower()
|
| 134 |
+
keyword_map = {
|
| 135 |
+
"nip": ["nip"],
|
| 136 |
+
"regon": ["regon"],
|
| 137 |
+
"krs": ["krs"],
|
| 138 |
+
"nazw": ["nazwa wnioskodawcy"],
|
| 139 |
+
"firm": ["nazwa wnioskodawcy"],
|
| 140 |
+
"adres": ["adres siedziby"],
|
| 141 |
+
"siedzib": ["adres siedziby"],
|
| 142 |
+
"wojew": ["województwo"],
|
| 143 |
+
"pkd": ["kody pkd"],
|
| 144 |
+
"przychod": ["przychód roczny"],
|
| 145 |
+
"przychód": ["przychód roczny"],
|
| 146 |
+
"obrot": ["przychód roczny"],
|
| 147 |
+
"zatrudnien": ["zatrudnienie"],
|
| 148 |
+
"pracownik": ["zatrudnienie"],
|
| 149 |
+
"forma prawn": ["forma prawna"],
|
| 150 |
+
"wielkość": ["wielkość firmy"],
|
| 151 |
+
"wielkosc": ["wielkość firmy"],
|
| 152 |
+
"mśp": ["status mśp"],
|
| 153 |
+
"de minimis": ["pomoc de minimis (suma eur)"],
|
| 154 |
+
}
|
| 155 |
+
# Wybieramy najbardziej specyficzne dopasowanie (najdłuższy pasujący token),
|
| 156 |
+
# aby np. pytanie "adres siedziby firmy" trafiło w "adres", a nie w "firm".
|
| 157 |
+
best_value: str | None = None
|
| 158 |
+
best_len = -1
|
| 159 |
+
for token, fact_keys in keyword_map.items():
|
| 160 |
+
if token in low and len(token) > best_len:
|
| 161 |
+
for fk in fact_keys:
|
| 162 |
+
if fk in facts:
|
| 163 |
+
best_value = facts[fk]
|
| 164 |
+
best_len = len(token)
|
| 165 |
+
break
|
| 166 |
+
return best_value
|
| 167 |
+
|
| 168 |
+
def _research_fill(self, label: str, context_text: str) -> str | None:
|
| 169 |
+
"""Ostateczny fallback: Research Agent (OSINT). Bezpieczny na brak sieci/LLM."""
|
| 170 |
+
try:
|
| 171 |
+
from agents.research_agent import research_agent
|
| 172 |
+
|
| 173 |
+
answer = research_agent.deep_search(label, context_text[:4000])
|
| 174 |
+
if answer and "nie udało się" not in answer.lower():
|
| 175 |
+
# Skróć do zwięzłej wartości — bierzemy pierwsze zdanie/akapit.
|
| 176 |
+
snippet = answer.strip().split("\n", 1)[0].strip()
|
| 177 |
+
return snippet[:400] or None
|
| 178 |
+
except Exception as e:
|
| 179 |
+
logger.debug(f"[AutoFill] Research fallback pominięty: {e}")
|
| 180 |
+
return None
|
| 181 |
+
|
| 182 |
+
# ------------------------------------------------------------------
|
| 183 |
+
# Główne API
|
| 184 |
+
# ------------------------------------------------------------------
|
| 185 |
+
def autofill_content(
|
| 186 |
+
self,
|
| 187 |
+
content: str,
|
| 188 |
+
project_context: Any = None,
|
| 189 |
+
extra_context: str = "",
|
| 190 |
+
allow_research: bool = True,
|
| 191 |
+
known_facts: dict[str, str] | None = None,
|
| 192 |
+
) -> AutoFillResult:
|
| 193 |
+
"""
|
| 194 |
+
Skanuje treść w poszukiwaniu placeholderów i próbuje je uzupełnić.
|
| 195 |
+
|
| 196 |
+
Kolejność źródeł: zweryfikowane dane wnioskodawcy (ProjectContext lub
|
| 197 |
+
``known_facts``) -> Research Agent (OSINT). Zwraca AutoFillResult
|
| 198 |
+
z uzupełnioną treścią oraz listą luk wymagających człowieka (HITL).
|
| 199 |
+
|
| 200 |
+
``known_facts`` to serializowalna mapa label->wartość (np. z
|
| 201 |
+
``ProjectContext.to_facts_dict()``) przenoszona przez stan LangGraph.
|
| 202 |
+
Ma priorytet nad ``project_context`` (backward-compatible: domyślnie None).
|
| 203 |
+
"""
|
| 204 |
+
result = AutoFillResult(filled_content=content or "")
|
| 205 |
+
placeholders = self.detect_placeholders(content or "")
|
| 206 |
+
if not placeholders:
|
| 207 |
+
return result
|
| 208 |
+
|
| 209 |
+
if known_facts:
|
| 210 |
+
facts = {str(k).lower(): str(v) for k, v in known_facts.items() if v}
|
| 211 |
+
else:
|
| 212 |
+
facts = self._facts_from_project_context(project_context)
|
| 213 |
+
context_text = "\n".join(filter(None, [extra_context, content]))
|
| 214 |
+
research_calls = 0
|
| 215 |
+
|
| 216 |
+
filled = content or ""
|
| 217 |
+
for ph in placeholders:
|
| 218 |
+
label = self._placeholder_label(ph)
|
| 219 |
+
value = self._match_fact(label, facts)
|
| 220 |
+
source = "project_context"
|
| 221 |
+
|
| 222 |
+
if not value and allow_research and research_calls < self.MAX_RESEARCH_CALLS:
|
| 223 |
+
research_calls += 1
|
| 224 |
+
value = self._research_fill(label, context_text)
|
| 225 |
+
source = "research_agent"
|
| 226 |
+
|
| 227 |
+
if value:
|
| 228 |
+
filled = filled.replace(ph, value)
|
| 229 |
+
result.resolved.append({"placeholder": ph, "value": value[:200], "source": source})
|
| 230 |
+
else:
|
| 231 |
+
result.unresolved.append(ph)
|
| 232 |
+
|
| 233 |
+
result.filled_content = filled
|
| 234 |
+
if result.unresolved:
|
| 235 |
+
critical = [self._placeholder_label(p) for p in result.unresolved if self._is_critical(self._placeholder_label(p))]
|
| 236 |
+
focus = critical or [self._placeholder_label(p) for p in result.unresolved]
|
| 237 |
+
result.hitl_question = (
|
| 238 |
+
"Uzupełnij brakujące dane wymagane do wniosku: " + "; ".join(focus[:5]) + "."
|
| 239 |
+
)
|
| 240 |
+
return result
|
| 241 |
+
|
| 242 |
+
def resolve_field(
|
| 243 |
+
self,
|
| 244 |
+
question: str,
|
| 245 |
+
context_text: str,
|
| 246 |
+
allow_research: bool = True,
|
| 247 |
+
known_facts: dict[str, str] | None = None,
|
| 248 |
+
) -> str | None:
|
| 249 |
+
"""
|
| 250 |
+
Próbuje automatycznie odpowiedzieć na pojedyncze pytanie o brakujące dane,
|
| 251 |
+
korzystając z rejestrów (GUS/REGON + KRS wykryte z kontekstu) oraz Research
|
| 252 |
+
Agenta. Zwraca tekst odpowiedzi lub None (gdy wymaga człowieka).
|
| 253 |
+
|
| 254 |
+
Ta metoda enkapsuluje logikę auto-healingu wcześniej wtopioną w
|
| 255 |
+
``generator_agent.resolve_missing_data``.
|
| 256 |
+
|
| 257 |
+
``known_facts`` to opcjonalna, serializowalna mapa label->wartość
|
| 258 |
+
(np. z ``ProjectContext.to_facts_dict()`` przenoszona przez stan
|
| 259 |
+
LangGraph). Gdy podana, jest używana priorytetowo do deterministycznego
|
| 260 |
+
dopasowania pola po etykiecie — PRZED fallbackiem regex/GUS/KRS/OSINT.
|
| 261 |
+
Backward-compatible: domyślnie None → dotychczasowe zachowanie.
|
| 262 |
+
"""
|
| 263 |
+
if not question:
|
| 264 |
+
return None
|
| 265 |
+
|
| 266 |
+
# 0) Deterministyczne rozwiązanie z jawnie przekazanych faktów (priorytet).
|
| 267 |
+
if known_facts:
|
| 268 |
+
facts = {str(k).lower(): str(v) for k, v in known_facts.items() if v}
|
| 269 |
+
matched = self._match_fact(question, facts)
|
| 270 |
+
if matched:
|
| 271 |
+
return matched
|
| 272 |
+
|
| 273 |
+
question_lower = question.lower()
|
| 274 |
+
auto_answer = ""
|
| 275 |
+
|
| 276 |
+
# 1) Wykryj NIP / KRS w kontekście
|
| 277 |
+
nip_match = re.search(r"NIP[:\s]*(\d{10})", context_text or "", re.IGNORECASE)
|
| 278 |
+
nip = nip_match.group(1) if nip_match else None
|
| 279 |
+
krs_match = re.search(r"KRS[:\s]*(\d{10})", context_text or "", re.IGNORECASE)
|
| 280 |
+
krs = krs_match.group(1) if krs_match else None
|
| 281 |
+
|
| 282 |
+
# 2) Rejestr GUS/REGON
|
| 283 |
+
if nip:
|
| 284 |
+
try:
|
| 285 |
+
import json
|
| 286 |
+
from tools.company_search import fetch_regon_data
|
| 287 |
+
|
| 288 |
+
regon_data = fetch_regon_data(nip)
|
| 289 |
+
if regon_data:
|
| 290 |
+
auto_answer += f"Z bazy GUS/REGON (NIP {nip}): {json.dumps(regon_data, ensure_ascii=False)}\n"
|
| 291 |
+
if not krs and isinstance(regon_data, dict):
|
| 292 |
+
krs = regon_data.get("krs") or regon_data.get("numerKRS")
|
| 293 |
+
except Exception as e:
|
| 294 |
+
logger.warning(f"[AutoFill] Błąd fetch_regon_data: {e}")
|
| 295 |
+
|
| 296 |
+
# 3) Odpis KRS dla pytań strukturalnych
|
| 297 |
+
structural_keywords = [
|
| 298 |
+
"adres", "forma prawna", "wspólnik", "udział", "kapitał", "zarząd",
|
| 299 |
+
"reprezent", "rejestracj", "krs", "osoba", "data rejestracji",
|
| 300 |
+
]
|
| 301 |
+
if any(kw in question_lower for kw in structural_keywords) and krs:
|
| 302 |
+
try:
|
| 303 |
+
import json
|
| 304 |
+
from integrations.krs_client import KRSClient
|
| 305 |
+
|
| 306 |
+
odpis = KRSClient.get_odpis_aktualny(str(krs))
|
| 307 |
+
if odpis:
|
| 308 |
+
relations = KRSClient.extract_graph_relations(odpis)
|
| 309 |
+
auto_answer += (
|
| 310 |
+
f"Z odpisu KRS (KRS {krs}):\n"
|
| 311 |
+
f"- Pełne dane rejestrowe + wspólnicy + zarząd: {json.dumps(relations, ensure_ascii=False)[:1500]}\n"
|
| 312 |
+
)
|
| 313 |
+
except Exception as e:
|
| 314 |
+
logger.warning(f"[AutoFill] Błąd pobierania odpisu KRS: {e}")
|
| 315 |
+
|
| 316 |
+
if auto_answer:
|
| 317 |
+
return auto_answer
|
| 318 |
+
|
| 319 |
+
# 4) Research Agent (OSINT) — ostateczny fallback przed HITL
|
| 320 |
+
if allow_research:
|
| 321 |
+
deep = self._research_fill(question, context_text)
|
| 322 |
+
if deep:
|
| 323 |
+
return deep
|
| 324 |
+
|
| 325 |
+
return None
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
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
|
@@ -0,0 +1,1615 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import os
|
| 3 |
+
from typing import Dict, List, TypedDict, Optional, Any
|
| 4 |
+
from langchain_core.messages import SystemMessage, HumanMessage
|
| 5 |
+
from langgraph.graph import StateGraph, START, END
|
| 6 |
+
|
| 7 |
+
# Importy z rdzenia backendu
|
| 8 |
+
try:
|
| 9 |
+
from core.llm_router import get_llm
|
| 10 |
+
except ImportError:
|
| 11 |
+
from backend.core.llm_router import get_llm
|
| 12 |
+
|
| 13 |
+
try:
|
| 14 |
+
from core.utils import extract_markdown_and_sanitize
|
| 15 |
+
except ImportError:
|
| 16 |
+
from backend.core.utils import extract_markdown_and_sanitize
|
| 17 |
+
|
| 18 |
+
# Foundational observability instrumentation for generation (v5.0)
|
| 19 |
+
import time
|
| 20 |
+
from core.telemetry import metrics
|
| 21 |
+
from core.safe_mode import SAFE_MODE_GENERATOR_RULE
|
| 22 |
+
|
| 23 |
+
from tenacity import retry, stop_after_attempt, wait_exponential
|
| 24 |
+
|
| 25 |
+
# v5.0 advanced verification layers for generation (Faza 3/4 autopilot track)
|
| 26 |
+
# Integrates CitationVerifier + KruczkowskiComplianceTrapAgent directly into drafting
|
| 27 |
+
# for proactive grounding + trap detection (moves beyond post-audit only)
|
| 28 |
+
try:
|
| 29 |
+
from core.search.regulation_engine import citation_verifier, kruczkowski_trap_agent
|
| 30 |
+
except Exception:
|
| 31 |
+
citation_verifier = None
|
| 32 |
+
kruczkowski_trap_agent = None
|
| 33 |
+
|
| 34 |
+
try:
|
| 35 |
+
from rag_pipeline.vector_store import get_parent_document_retriever
|
| 36 |
+
except ImportError:
|
| 37 |
+
from backend.rag_pipeline.vector_store import get_parent_document_retriever
|
| 38 |
+
|
| 39 |
+
try:
|
| 40 |
+
from core.sensitive_data_guard import anonymizer
|
| 41 |
+
except ImportError:
|
| 42 |
+
try:
|
| 43 |
+
from backend.core.sensitive_data_guard import anonymizer
|
| 44 |
+
except ImportError:
|
| 45 |
+
anonymizer = None
|
| 46 |
+
|
| 47 |
+
try:
|
| 48 |
+
from core.audit_logger import audit_log
|
| 49 |
+
except ImportError:
|
| 50 |
+
audit_log = None
|
| 51 |
+
|
| 52 |
+
logger = logging.getLogger(__name__)
|
| 53 |
+
|
| 54 |
+
# Bounded retries for full-autopilot pipeline (krytyk globalny → korekta → audyt panelowy).
|
| 55 |
+
MAX_HOLISTIC_RETRIES = int(os.getenv("FULL_AUTOPILOT_MAX_HOLISTIC_RETRIES", "2"))
|
| 56 |
+
MAX_PANEL_AUDIT_RETRIES = int(os.getenv("FULL_AUTOPILOT_MAX_PANEL_RETRIES", "2"))
|
| 57 |
+
|
| 58 |
+
from langchain_core.callbacks import BaseCallbackHandler
|
| 59 |
+
class TokenTrackingCallback(BaseCallbackHandler):
|
| 60 |
+
def __init__(self, user_id: str):
|
| 61 |
+
self.user_id = user_id
|
| 62 |
+
self.total_tokens = 0
|
| 63 |
+
|
| 64 |
+
def on_llm_end(self, response, **kwargs):
|
| 65 |
+
try:
|
| 66 |
+
if hasattr(response, "llm_output") and response.llm_output:
|
| 67 |
+
token_usage = response.llm_output.get("token_usage", {})
|
| 68 |
+
tokens = token_usage.get("total_tokens", 0)
|
| 69 |
+
if tokens > 0:
|
| 70 |
+
self.total_tokens += tokens
|
| 71 |
+
from core.subscription.tracker import increment_tokens
|
| 72 |
+
if self.user_id and self.user_id != "anonymous":
|
| 73 |
+
increment_tokens(self.user_id, tokens, action_type="generator_draft")
|
| 74 |
+
except Exception as e:
|
| 75 |
+
logger.warning(f"Błąd zliczania tokenów: {e}")
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
class GeneratorState(TypedDict):
|
| 79 |
+
"""Stan przepływu w LangGraph dla generatora wniosków."""
|
| 80 |
+
|
| 81 |
+
project_id: str
|
| 82 |
+
namespace: str
|
| 83 |
+
document_type: str
|
| 84 |
+
|
| 85 |
+
# Opis projektu wczytany z DB — anonymizowany przed wysłaniem do LLM
|
| 86 |
+
project_description: Optional[str]
|
| 87 |
+
|
| 88 |
+
sections_plan: List[dict]
|
| 89 |
+
current_section_idx: int
|
| 90 |
+
generated_sections: Dict[str, str]
|
| 91 |
+
|
| 92 |
+
context: str
|
| 93 |
+
is_completed: bool
|
| 94 |
+
missing_data_question: Optional[str]
|
| 95 |
+
additional_context: Optional[str]
|
| 96 |
+
# Zserializowana mapa zweryfikowanych faktów wnioskodawcy (ProjectContext.to_facts_dict()).
|
| 97 |
+
# JSON-serializowalny słownik (label->wartość) — bezpieczny dla checkpointingu LangGraph,
|
| 98 |
+
# używany przez Auto-Fill Agenta do deterministycznego uzupełniania placeholderów.
|
| 99 |
+
project_context_facts: Optional[Dict[str, str]]
|
| 100 |
+
traceability_data: Optional[Dict[str, List[dict]]]
|
| 101 |
+
audit_retries: int
|
| 102 |
+
section_stall_counts: Optional[Dict[str, int]]
|
| 103 |
+
section_critic_retries: Optional[Dict[str, int]]
|
| 104 |
+
graph_step: int
|
| 105 |
+
# Sygnały bramek jakości (fail-closed) — propagowane do persystencji i eksportu.
|
| 106 |
+
guard_block: Optional[bool]
|
| 107 |
+
human_review_required: Optional[bool]
|
| 108 |
+
unverified_sections: Optional[Dict[str, str]]
|
| 109 |
+
# Unified full-autopilot pipeline (generowanie → krytyk globalny → audyt panelowy).
|
| 110 |
+
full_autopilot: Optional[bool]
|
| 111 |
+
holistic_retries: Optional[int]
|
| 112 |
+
panel_audit_retries: Optional[int]
|
| 113 |
+
holistic_phase_complete: Optional[bool]
|
| 114 |
+
panel_audit_done: Optional[bool]
|
| 115 |
+
pipeline_phase: Optional[str]
|
| 116 |
+
global_audit_result: Optional[Dict[str, Any]]
|
| 117 |
+
# Kontekst projektu (regulamin, grant, required_sections) — JSON-serializowalny pod checkpoint.
|
| 118 |
+
external_context: Optional[Dict[str, Any]]
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
# Async checkpoint dla LangGraph (astream_events wymaga AsyncSqliteSaver).
|
| 122 |
+
import aiosqlite
|
| 123 |
+
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
| 124 |
+
|
| 125 |
+
data_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data")
|
| 126 |
+
os.makedirs(data_dir, exist_ok=True)
|
| 127 |
+
db_path = os.path.join(data_dir, "generator_checkpoints.db")
|
| 128 |
+
|
| 129 |
+
global_memory_saver: AsyncSqliteSaver | None = None
|
| 130 |
+
_sqlite_conn = None
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
async def ensure_async_checkpointer() -> AsyncSqliteSaver:
|
| 134 |
+
global global_memory_saver, _sqlite_conn
|
| 135 |
+
if global_memory_saver is None:
|
| 136 |
+
_sqlite_conn = await aiosqlite.connect(db_path)
|
| 137 |
+
global_memory_saver = AsyncSqliteSaver(_sqlite_conn)
|
| 138 |
+
await global_memory_saver.setup()
|
| 139 |
+
return global_memory_saver
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
async def checkpoint_get(config: dict):
|
| 143 |
+
saver = await ensure_async_checkpointer()
|
| 144 |
+
return await saver.aget(config)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def _company_fields_satisfy(question_lower: str, full_context: str):
|
| 148 |
+
"""Sprawdza czy pytanie o dane firmy jest już pokryte kontekstem GUS/KRS."""
|
| 149 |
+
if not question_lower or not full_context:
|
| 150 |
+
return None
|
| 151 |
+
ctx = full_context.lower()
|
| 152 |
+
# P3#13: dopasowanie po FRAZACH, nie pojedynczych słowach — inaczej pytanie
|
| 153 |
+
# "nazwa produktu" byłoby błędnie tłumione jako dane firmy w kontekście.
|
| 154 |
+
field_checks = [
|
| 155 |
+
(["nip", "numer identyfikacji podatkowej", "regon"], ["nip:", "nip ", "<nip_", "regon", "numer nip"]),
|
| 156 |
+
(["nazwa firmy", "nazwa wnioskodawcy", "nazwa przedsiębiorstwa", "nazwa podmiotu", "firma wnioskodawcy", "pełna nazwa firmy"], ["<firma_", "wnioskodawc", "dane obowiązkowe", "nazwa firmy", "nazwa wnioskodawcy"]),
|
| 157 |
+
(["adres siedziby", "siedziba firmy", "województwo firmy", "miejsce siedziby"], ["adres", "ul.", "siedzib", "województwo", "miejscowość"]),
|
| 158 |
+
(["forma prawna", "numer krs", "rejestr przedsiębiorców"], ["forma prawna", "krs", "sp. z o.o", "spółka akcyjna"]),
|
| 159 |
+
]
|
| 160 |
+
for keywords, signals in field_checks:
|
| 161 |
+
if any(kw in question_lower for kw in keywords):
|
| 162 |
+
if any(sig in ctx for sig in signals):
|
| 163 |
+
return (
|
| 164 |
+
"Dane firmy (NIP/nazwa/adres/forma prawna) są już w kontekście projektu "
|
| 165 |
+
"(GUS/KRS lub dane użytkownika). Nie pytaj ponownie — użyj ich w treści sekcji."
|
| 166 |
+
)
|
| 167 |
+
return None
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
class DocumentGeneratorAgent:
|
| 171 |
+
"""
|
| 172 |
+
Agent korzystający z maszyn stanów LangGraph do wytwarzania długich
|
| 173 |
+
i sformalizowanych dokumentów, zabezpieczony danymi z RAG (Rząd RP/PARP).
|
| 174 |
+
|
| 175 |
+
v5.0 Evolution (Faza 3/4 autopilot track - silent sub-agent work):
|
| 176 |
+
- Direct integration of CitationVerifier + KruczkowskiComplianceTrapAgent in draft path (proactive self-verification).
|
| 177 |
+
- Enhanced context handling: regulation snapshots + safe truncation (_safe_truncate_context).
|
| 178 |
+
- Stability: layered fallbacks, telemetry/metrics for verification/truncation, persistent Sqlite checkpoints.
|
| 179 |
+
- Toward multi-agent: already delegates specialists (finance_agent); verification acts as internal critic step.
|
| 180 |
+
Incremental, high-quality, non-breaking improvements for reliability in later roadmap phases.
|
| 181 |
+
"""
|
| 182 |
+
|
| 183 |
+
def __init__(self):
|
| 184 |
+
# Używamy najlepszego modelu, docelowo task_type="critical" by odpalić np. Gemini 1.5 Pro
|
| 185 |
+
# (albo dedykowany fine-tuned)
|
| 186 |
+
self.llm = get_llm(task_type="critical")
|
| 187 |
+
self.graph = None
|
| 188 |
+
|
| 189 |
+
async def get_graph(self):
|
| 190 |
+
if self.graph is None:
|
| 191 |
+
cp = await ensure_async_checkpointer()
|
| 192 |
+
self.graph = self._build_graph(cp)
|
| 193 |
+
return self.graph
|
| 194 |
+
|
| 195 |
+
def _build_graph(self, checkpointer=None):
|
| 196 |
+
workflow = StateGraph(GeneratorState)
|
| 197 |
+
|
| 198 |
+
# Rejestracja Węzłów
|
| 199 |
+
workflow.add_node("plan_document", self.plan_document)
|
| 200 |
+
workflow.add_node("fetch_context", self.fetch_context)
|
| 201 |
+
workflow.add_node("draft_section", self.draft_section)
|
| 202 |
+
workflow.add_node("resolve_missing_data", self.resolve_missing_data)
|
| 203 |
+
workflow.add_node("ask_missing_data", self.ask_missing_data)
|
| 204 |
+
workflow.add_node("wait_for_human_review", self.wait_for_human_review)
|
| 205 |
+
workflow.add_node("pre_submission_audit", self.pre_submission_audit)
|
| 206 |
+
workflow.add_node("global_holistic_review", self.global_holistic_review)
|
| 207 |
+
workflow.add_node("global_panel_audit", self.global_panel_audit)
|
| 208 |
+
|
| 209 |
+
# Sterowanie przepływem (Graf)
|
| 210 |
+
workflow.add_edge(START, "plan_document")
|
| 211 |
+
workflow.add_edge("plan_document", "fetch_context")
|
| 212 |
+
workflow.add_edge("fetch_context", "draft_section")
|
| 213 |
+
|
| 214 |
+
# Pętla warunkowa (Czy mamy jeszcze sekcje do wygenerowania, albo czy brakuje danych?)
|
| 215 |
+
workflow.add_conditional_edges(
|
| 216 |
+
"draft_section",
|
| 217 |
+
self._should_continue,
|
| 218 |
+
{
|
| 219 |
+
"continue": "fetch_context",
|
| 220 |
+
"resolve": "resolve_missing_data",
|
| 221 |
+
"review": "wait_for_human_review",
|
| 222 |
+
"audit": "pre_submission_audit",
|
| 223 |
+
},
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
# Z audytu Red Team → full-autopilot (holistic + panel) lub koniec / retry / HIL
|
| 227 |
+
workflow.add_conditional_edges(
|
| 228 |
+
"pre_submission_audit",
|
| 229 |
+
self._route_after_pre_submission_audit,
|
| 230 |
+
{
|
| 231 |
+
"end": END,
|
| 232 |
+
"retry": "fetch_context",
|
| 233 |
+
"review": "wait_for_human_review",
|
| 234 |
+
"holistic": "global_holistic_review",
|
| 235 |
+
"panel": "global_panel_audit",
|
| 236 |
+
},
|
| 237 |
+
)
|
| 238 |
+
workflow.add_conditional_edges(
|
| 239 |
+
"global_holistic_review",
|
| 240 |
+
self._route_after_holistic_review,
|
| 241 |
+
{
|
| 242 |
+
"regen": "fetch_context",
|
| 243 |
+
"panel": "global_panel_audit",
|
| 244 |
+
"review": "wait_for_human_review",
|
| 245 |
+
},
|
| 246 |
+
)
|
| 247 |
+
workflow.add_conditional_edges(
|
| 248 |
+
"global_panel_audit",
|
| 249 |
+
self._route_after_panel_audit,
|
| 250 |
+
{
|
| 251 |
+
"end": END,
|
| 252 |
+
"regen": "fetch_context",
|
| 253 |
+
"review": "wait_for_human_review",
|
| 254 |
+
},
|
| 255 |
+
)
|
| 256 |
+
workflow.add_edge("wait_for_human_review", "fetch_context")
|
| 257 |
+
|
| 258 |
+
# Nowy warunek: po próbie auto-rozwiązania (Tool Calling), decyduje czy nadal pytać użytkownika
|
| 259 |
+
workflow.add_conditional_edges(
|
| 260 |
+
"resolve_missing_data",
|
| 261 |
+
lambda s: "pause" if s.get("missing_data_question") else "draft_section",
|
| 262 |
+
{
|
| 263 |
+
"pause": "ask_missing_data",
|
| 264 |
+
"draft_section": "draft_section"
|
| 265 |
+
}
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
workflow.add_edge("ask_missing_data", "draft_section")
|
| 269 |
+
|
| 270 |
+
# Production hardening: tolerate None checkpointer (degraded resume) while keeping interrupt
|
| 271 |
+
cp = checkpointer
|
| 272 |
+
return workflow.compile(
|
| 273 |
+
checkpointer=cp if cp is not None else None, interrupt_before=["ask_missing_data", "wait_for_human_review"]
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
def resolve_missing_data(self, state: GeneratorState):
|
| 277 |
+
"""
|
| 278 |
+
Autopilot-friendly resolver.
|
| 279 |
+
Próbuje jak najwięcej danych wyciągnąć automatycznie z GUS + KRS,
|
| 280 |
+
zanim odda kontrolę człowiekowi.
|
| 281 |
+
"""
|
| 282 |
+
logger.info("[Generator] Próba automatycznego znalezienia brakujących danych (GUS/KRS)...")
|
| 283 |
+
question = state.get("missing_data_question")
|
| 284 |
+
|
| 285 |
+
if not question:
|
| 286 |
+
return {"additional_context": state.get("additional_context")}
|
| 287 |
+
|
| 288 |
+
auto_answer = ""
|
| 289 |
+
|
| 290 |
+
# Najpierw sprawdźmy, czy odpowiedź już nie jest w istniejącym kontekście (project_description + additional_context)
|
| 291 |
+
full_context = (state.get("project_description") or "") + "\n" + (state.get("additional_context") or "")
|
| 292 |
+
question_lower = question.lower()
|
| 293 |
+
|
| 294 |
+
company_msg = _company_fields_satisfy(question_lower, full_context)
|
| 295 |
+
if company_msg:
|
| 296 |
+
logger.info(f"[Generator] Dane firmy już w kontekście (GUS/KRS): {company_msg[:200]}...")
|
| 297 |
+
old_ctx = state.get("additional_context") or ""
|
| 298 |
+
new_ctx = old_ctx + f"\n\n[ZAUTOMATYZOWANA ODPOWIEDŹ / SPRAWDZENIE KONTEKSTU na pytanie '{question}']:\n{company_msg}\nNie pytaj użytkownika ponownie o te informacje – są już dostępne."
|
| 299 |
+
return {
|
| 300 |
+
"missing_data_question": None,
|
| 301 |
+
"additional_context": new_ctx
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
# Prosta heurystyka: jeśli pytanie dotyczy czegoś, co już jest w kontekście, nie pytaj ponownie
|
| 305 |
+
if any(kw in question_lower for kw in ["adres", "siedzib", "miejsce", "województwo", "wojewodztwo"]) and ("adres" in full_context.lower() or "ul." in full_context.lower() or "województwo" in full_context.lower()):
|
| 306 |
+
auto_answer = "Informacja o adresie/siedzibie/województwie firmy jest już dostępna w kontekście projektu (z danych GUS lub wpisanych wcześniej przez użytkownika). Nie trzeba pytać ponownie."
|
| 307 |
+
elif any(kw in question_lower for kw in ["wspólnik", "udział", "kapitał", "właściciel"]) and ("wspólnik" in full_context.lower() or "udział" in full_context.lower() or "krs_full" in full_context.lower()):
|
| 308 |
+
auto_answer = "Dane o wspólnikach/udziałowcach są już w kontekście (z odpisu KRS lub wcześniejszych informacji użytkownika)."
|
| 309 |
+
|
| 310 |
+
# Delegacja auto-healingu (rejestry GUS/KRS + Research OSINT) do dedykowanego
|
| 311 |
+
# Auto-Fill Agenta (FAZA 3). Heurystyki kontekstowe powyżej pozostają w węźle.
|
| 312 |
+
if not auto_answer:
|
| 313 |
+
try:
|
| 314 |
+
from agents.autofill_agent import autofill_agent
|
| 315 |
+
|
| 316 |
+
auto_answer = autofill_agent.resolve_field(
|
| 317 |
+
question,
|
| 318 |
+
full_context,
|
| 319 |
+
known_facts=state.get("project_context_facts"),
|
| 320 |
+
) or ""
|
| 321 |
+
except Exception as e:
|
| 322 |
+
logger.error(f"[TITAN Auto-Healing] Auto-Fill Agent błąd: {e}")
|
| 323 |
+
|
| 324 |
+
if auto_answer:
|
| 325 |
+
logger.info(f"[Generator] Znalazłem dane automatycznie (lub wykryłem, że są już w kontekście): {auto_answer[:400]}...")
|
| 326 |
+
old_ctx = state.get("additional_context") or ""
|
| 327 |
+
new_ctx = old_ctx + f"\n\n[ZAUTOMATYZOWANA ODPOWIEDŹ / SPRAWDZENIE KONTEKSTU na pytanie '{question}']:\n{auto_answer}\nNie pytaj użytkownika ponownie o te informacje – są już dostępne."
|
| 328 |
+
return {
|
| 329 |
+
"missing_data_question": None,
|
| 330 |
+
"additional_context": new_ctx
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
# Jeśli Auto-Fill Agent nie znalazł danych — wracamy do HIL (człowiek decyduje)
|
| 334 |
+
logger.warning(f"[TITAN v1.0] Brak danych, zatrzymuję potok (Human-in-the-Loop) dla: {question}")
|
| 335 |
+
return {"missing_data_question": question}
|
| 336 |
+
|
| 337 |
+
def wait_for_human_review(self, state: GeneratorState):
|
| 338 |
+
"""
|
| 339 |
+
Węzeł pauzujący graf w celu umożliwienia recenzji HIL (Human-in-the-Loop) wygenerowanej sekcji.
|
| 340 |
+
Wykonywany PO wznowieniu przez człowieka (interrupt_before). Czyścimy guard_block,
|
| 341 |
+
by po uwzględnieniu uwag człowieka ponowić przebieg bez zapętlenia na tej samej blokadzie.
|
| 342 |
+
"""
|
| 343 |
+
logger.info(f"[Generator] Recenzja człowieka (HIL) przyjęta dla projektu {state.get('project_id')} — wznawiam.")
|
| 344 |
+
return {
|
| 345 |
+
"guard_block": False,
|
| 346 |
+
"is_completed": False,
|
| 347 |
+
"current_section_idx": 0,
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
def ask_missing_data(self, state: GeneratorState):
|
| 351 |
+
"""Węzeł pauzujący działanie grafu przed wykonaniem (interrupt_before)."""
|
| 352 |
+
logger.info(
|
| 353 |
+
f"Pauza HIL. Oczekiwanie na dane: {state.get('missing_data_question')}"
|
| 354 |
+
)
|
| 355 |
+
# Po wznowieniu przez użytkownika (update state'u o 'additional_context'),
|
| 356 |
+
# czyścimy to zapytanie, żeby nie wpaść w pętlę.
|
| 357 |
+
return {"missing_data_question": None}
|
| 358 |
+
|
| 359 |
+
def _run_v5_verification(self, section_content: str, section_name: str, program_hint: str) -> Dict[str, Any]:
|
| 360 |
+
"""
|
| 361 |
+
v5.0 Faza 3/4: Proactive verification inside generation flow.
|
| 362 |
+
Runs CitationVerifier (claim grounding) + Kruczkowski Trap detection on the just-drafted section.
|
| 363 |
+
Returns structured metadata for traceability + quality signals. Non-fatal on errors.
|
| 364 |
+
This evolves the generator from "produce then audit" to "produce + self-verify".
|
| 365 |
+
"""
|
| 366 |
+
verification: Dict[str, Any] = {
|
| 367 |
+
"verifier_version": "v5.0-citation+kruczkowski",
|
| 368 |
+
"section": section_name,
|
| 369 |
+
"citation": {"overall_score": 0.0, "quality": "unknown", "issues": []},
|
| 370 |
+
"traps": {"detected": [], "risk_level": "unknown"},
|
| 371 |
+
"applied": False,
|
| 372 |
+
}
|
| 373 |
+
if not section_content or len(section_content.strip()) < 30:
|
| 374 |
+
return verification
|
| 375 |
+
|
| 376 |
+
program = program_hint or "unknown"
|
| 377 |
+
try:
|
| 378 |
+
if citation_verifier:
|
| 379 |
+
cit = citation_verifier.verify_text_citations(
|
| 380 |
+
full_text=section_content[:6000], # cap for cost/speed
|
| 381 |
+
program=program,
|
| 382 |
+
sample_claims=None
|
| 383 |
+
)
|
| 384 |
+
verification["citation"] = {
|
| 385 |
+
"overall_score": cit.get("overall_citation_score", 0.0),
|
| 386 |
+
"quality": cit.get("citation_quality", "unknown"),
|
| 387 |
+
"issues": [c.get("issues", []) for c in cit.get("per_claim_results", [])[:2] if c.get("issues")],
|
| 388 |
+
"recommendation": cit.get("recommendation", "")
|
| 389 |
+
}
|
| 390 |
+
# v5.0 data quality extension for generated content (integrated into generator flow)
|
| 391 |
+
if hasattr(citation_verifier, "compute_generated_content_data_quality"):
|
| 392 |
+
try:
|
| 393 |
+
dq = citation_verifier.compute_generated_content_data_quality(section_content, program)
|
| 394 |
+
verification["data_quality"] = {
|
| 395 |
+
"score": dq.get("data_quality_score"),
|
| 396 |
+
"level": dq.get("quality_level"),
|
| 397 |
+
"signals": dq.get("signals", [])[:4],
|
| 398 |
+
"recommendation": dq.get("recommendation", "")
|
| 399 |
+
}
|
| 400 |
+
if dq.get("data_quality_score", 50) < 48:
|
| 401 |
+
metrics.increment("generation.v5_low_data_quality_detected", tags={"section": section_name})
|
| 402 |
+
except Exception:
|
| 403 |
+
pass
|
| 404 |
+
verification["applied"] = True
|
| 405 |
+
metrics.increment("generation.v5_verification_citation_runs", tags={"quality": verification["citation"]["quality"]})
|
| 406 |
+
try:
|
| 407 |
+
csc = verification.get("citation", {}).get("overall_score", 0.65)
|
| 408 |
+
from core.telemetry import metrics as _m2
|
| 409 |
+
_m2.record_faithfulness_trend(csc, {"via": "generator_agent_v5"})
|
| 410 |
+
except Exception:
|
| 411 |
+
pass
|
| 412 |
+
except Exception as e:
|
| 413 |
+
logger.warning(f"[Generator v5.0] CitationVerifier failed in _run_v5_verification: {e}")
|
| 414 |
+
metrics.record_error("GeneratorAgent", "v5_citation_verify", str(e), severity="warning")
|
| 415 |
+
|
| 416 |
+
try:
|
| 417 |
+
if kruczkowski_trap_agent:
|
| 418 |
+
# Use the trap agent's check method if exposed; fallback to direct if available
|
| 419 |
+
trap_result = None
|
| 420 |
+
if hasattr(kruczkowski_trap_agent, "check_section_for_traps"):
|
| 421 |
+
trap_result = kruczkowski_trap_agent.check_section_for_traps(section_content, program)
|
| 422 |
+
elif hasattr(kruczkowski_trap_agent, "detect_traps"):
|
| 423 |
+
trap_result = kruczkowski_trap_agent.detect_traps(section_content, program) # correct: (text, program, msp=None)
|
| 424 |
+
if isinstance(trap_result, dict):
|
| 425 |
+
verification["traps"] = {
|
| 426 |
+
"detected": trap_result.get("traps", [])[:5],
|
| 427 |
+
"risk_level": trap_result.get("risk_level", "low")
|
| 428 |
+
}
|
| 429 |
+
verification["applied"] = True
|
| 430 |
+
metrics.increment("generation.v5_verification_trap_runs", tags={"risk": verification["traps"]["risk_level"]})
|
| 431 |
+
try:
|
| 432 |
+
from core.telemetry import metrics as _m3
|
| 433 |
+
tr = 0.6 if verification.get("traps", {}).get("risk_level") in ("high","critical") else 0.25
|
| 434 |
+
_m3.record_kruczkowski_trap_rate(tr, {"via": "generator_agent"})
|
| 435 |
+
except Exception:
|
| 436 |
+
pass
|
| 437 |
+
except Exception as e:
|
| 438 |
+
logger.warning(f"[Generator v5.0] KruczkowskiTrap failed in _run_v5_verification: {e}")
|
| 439 |
+
metrics.record_error("GeneratorAgent", "v5_kruczkowski_verify", str(e), severity="warning")
|
| 440 |
+
|
| 441 |
+
return verification
|
| 442 |
+
|
| 443 |
+
def _section_critic_verdict(self, section_content: str, section_name: str, program_hint: str) -> Dict[str, Any]:
|
| 444 |
+
"""
|
| 445 |
+
FAZA 5: Krytyk per-sekcja. Łączy weryfikację RegulationEngine
|
| 446 |
+
(CitationVerifier + Kruczkowski Trap) z twardymi walidacjami (regex),
|
| 447 |
+
zwracając werdykt sterujący regeneracją sekcji.
|
| 448 |
+
|
| 449 |
+
Zwraca dict: {passed: bool, score: int, reasons: list[str], verification: dict}.
|
| 450 |
+
"""
|
| 451 |
+
import re as _re
|
| 452 |
+
|
| 453 |
+
verdict: Dict[str, Any] = {"passed": True, "score": 100, "reasons": [], "verification": {}}
|
| 454 |
+
content = section_content or ""
|
| 455 |
+
if len(content.strip()) < 30:
|
| 456 |
+
return verdict
|
| 457 |
+
|
| 458 |
+
verification = self._run_v5_verification(content, section_name, program_hint)
|
| 459 |
+
verdict["verification"] = verification
|
| 460 |
+
|
| 461 |
+
score = 100
|
| 462 |
+
reasons: list[str] = []
|
| 463 |
+
|
| 464 |
+
# 1) Pułapki Kruczkowskiego (koszty niekwalifikowalne / cross-financing / de minimis)
|
| 465 |
+
trap_risk = verification.get("traps", {}).get("risk_level", "low")
|
| 466 |
+
if trap_risk == "critical":
|
| 467 |
+
score -= 60
|
| 468 |
+
reasons.append("Krytyczne pułapki compliance (Kruczkowski).")
|
| 469 |
+
verdict["passed"] = False
|
| 470 |
+
elif trap_risk == "high":
|
| 471 |
+
score -= 40
|
| 472 |
+
reasons.append("Wysokie ryzyko pułapek compliance (Kruczkowski).")
|
| 473 |
+
verdict["passed"] = False
|
| 474 |
+
|
| 475 |
+
# 2) Nielogiczne ujemne kwoty budżetowe
|
| 476 |
+
if _re.search(r"(?i)(budżet|koszt|kwota|dofinansowanie)[\s=:]*-\s*\d+", content):
|
| 477 |
+
score -= 50
|
| 478 |
+
reasons.append("Wykryto ujemne kwoty budżetowe.")
|
| 479 |
+
verdict["passed"] = False
|
| 480 |
+
|
| 481 |
+
# 3) Kompletny brak wymaganych słów kluczowych programu
|
| 482 |
+
low = content.lower()
|
| 483 |
+
if len(low) > 120 and not _re.search(
|
| 484 |
+
r"(innowacj|przychód|b\+r|badani|rozwoj|dofinansowan|projekt|wskaźnik|harmonogram)", low
|
| 485 |
+
):
|
| 486 |
+
score -= 40
|
| 487 |
+
reasons.append("Brak wymaganych słów kluczowych programu.")
|
| 488 |
+
verdict["passed"] = False
|
| 489 |
+
|
| 490 |
+
# 4) Niskie ugruntowanie cytowaniami (sygnał miękki — obniża score)
|
| 491 |
+
citation_score = verification.get("citation", {}).get("overall_score", 0.65)
|
| 492 |
+
if citation_score < 0.35:
|
| 493 |
+
score -= 15
|
| 494 |
+
reasons.append(f"Niskie ugruntowanie cytowaniami ({citation_score:.2f}).")
|
| 495 |
+
|
| 496 |
+
verdict["score"] = max(0, score)
|
| 497 |
+
verdict["reasons"] = reasons
|
| 498 |
+
return verdict
|
| 499 |
+
|
| 500 |
+
def plan_document(self, state: GeneratorState):
|
| 501 |
+
"""Krok 1: Inicjalizuje sekcje w zależności od zadanego wzorca."""
|
| 502 |
+
logger.info(f"Planowanie dokumentu: {state['document_type']}")
|
| 503 |
+
|
| 504 |
+
from core.telemetry import telemetry
|
| 505 |
+
|
| 506 |
+
telemetry.log(
|
| 507 |
+
"INFO",
|
| 508 |
+
"GeneratorAgent",
|
| 509 |
+
f"Tworzę plan dokumentu typu {state['document_type']}",
|
| 510 |
+
{"project_id": state["project_id"]},
|
| 511 |
+
)
|
| 512 |
+
|
| 513 |
+
plan = state.get("sections_plan", [])
|
| 514 |
+
if not plan:
|
| 515 |
+
doc_type = state["document_type"].lower()
|
| 516 |
+
if "wniosek" in doc_type and "smart" in doc_type:
|
| 517 |
+
plan = [
|
| 518 |
+
{"type": "project_summary", "title": "Opis projektu i jego celów"},
|
| 519 |
+
{"type": "innovation", "title": "Uzasadnienie innowacyjności"},
|
| 520 |
+
{"type": "applicant", "title": "Potencjał Wnioskodawcy (doświadczenie i zasoby)"},
|
| 521 |
+
{"type": "budget", "title": "Budżet i harmonogram"},
|
| 522 |
+
]
|
| 523 |
+
elif "biznesplan" in doc_type:
|
| 524 |
+
plan = [
|
| 525 |
+
{
|
| 526 |
+
"type": "executive_summary",
|
| 527 |
+
"title": "Streszczenie projektu",
|
| 528 |
+
},
|
| 529 |
+
{"type": "product", "title": "Opis produktu/usługi"},
|
| 530 |
+
{"type": "market", "title": "Analiza rynku i konkurencji"},
|
| 531 |
+
{"type": "finance", "title": "Plan finansowy"},
|
| 532 |
+
]
|
| 533 |
+
else:
|
| 534 |
+
plan = [
|
| 535 |
+
{"type": "intro", "title": "Wprowadzenie"},
|
| 536 |
+
{"type": "body", "title": "Rozwinięcie merytoryczne"},
|
| 537 |
+
{"type": "outro", "title": "Zakończenie i podsumowanie"},
|
| 538 |
+
]
|
| 539 |
+
|
| 540 |
+
return {
|
| 541 |
+
"sections_plan": plan,
|
| 542 |
+
"current_section_idx": state.get("current_section_idx", 0),
|
| 543 |
+
"generated_sections": state.get("generated_sections", {}),
|
| 544 |
+
"is_completed": False,
|
| 545 |
+
"missing_data_question": None,
|
| 546 |
+
"traceability_data": state.get("traceability_data", {}),
|
| 547 |
+
"section_stall_counts": state.get("section_stall_counts", {}),
|
| 548 |
+
"graph_step": state.get("graph_step", 0),
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
def _safe_truncate_context(self, text: str, max_chars: int = 12000, label: str = "context") -> str:
|
| 552 |
+
"""Stability helper (v5.0): Hard cap on context to prevent token explosions + OOM in long docs.
|
| 553 |
+
Used across fetch + draft paths. Logs when truncation occurs for observability."""
|
| 554 |
+
if not text:
|
| 555 |
+
return ""
|
| 556 |
+
if len(text) <= max_chars:
|
| 557 |
+
return text
|
| 558 |
+
truncated = text[:max_chars] + f"\n\n[... UKRÓCONO dla stabilności (oryginał {len(text)} znaków, limit {max_chars} dla {label}) ...]"
|
| 559 |
+
logger.info(f"[Generator v5.0 stability] Truncated {label} from {len(text)} to {max_chars} chars")
|
| 560 |
+
metrics.increment("generation.context_truncations", tags={"label": label})
|
| 561 |
+
return truncated
|
| 562 |
+
|
| 563 |
+
def _regulation_context_present(self, state: GeneratorState, context: str = "") -> bool:
|
| 564 |
+
"""Czy mamy twarde źródło regulaminu (snapshot / RAG / kontekst projektu)."""
|
| 565 |
+
ctx = context or state.get("context") or ""
|
| 566 |
+
if "[REGULATION SNAPSHOT" in ctx or "Kontekst regulaminowy" in ctx:
|
| 567 |
+
return True
|
| 568 |
+
if "Regulamin" in ctx:
|
| 569 |
+
return True
|
| 570 |
+
ext = state.get("external_context") or {}
|
| 571 |
+
if ext.get("regulation_snapshot_id") or ext.get("required_sections"):
|
| 572 |
+
return True
|
| 573 |
+
add = state.get("additional_context") or ""
|
| 574 |
+
return "Regulamin" in add or "regulation_snapshot" in add.lower()
|
| 575 |
+
|
| 576 |
+
def _build_regulation_boost(self, state: GeneratorState) -> str:
|
| 577 |
+
"""Składa blok regulaminowy z snapshotów projektu (multi-doc aware)."""
|
| 578 |
+
try:
|
| 579 |
+
from core.search.regulation_snapshot import regulation_snapshot_store
|
| 580 |
+
from core.search.regulation_context_provider import (
|
| 581 |
+
_program_keys_for_candidate,
|
| 582 |
+
format_regulation_context_for_prompt,
|
| 583 |
+
get_regulation_context_for_candidates,
|
| 584 |
+
)
|
| 585 |
+
|
| 586 |
+
ext = state.get("external_context") or {}
|
| 587 |
+
doc_type = state.get("document_type", "") or ""
|
| 588 |
+
grant_stub = {
|
| 589 |
+
"id": ext.get("grant_id") or state.get("project_id"),
|
| 590 |
+
"program": ext.get("program_type") or doc_type,
|
| 591 |
+
"name": ext.get("grant_name") or ext.get("program_name") or doc_type,
|
| 592 |
+
"operator": ext.get("operator") or "",
|
| 593 |
+
"regulation_url": ext.get("regulation_url") or ext.get("precise_regulation_url"),
|
| 594 |
+
"regulation_urls": ext.get("regulation_urls") or [],
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
+
# 1) Snapshot przypisany do projektu (najwyższy priorytet)
|
| 598 |
+
snap_id = ext.get("regulation_snapshot_id")
|
| 599 |
+
if snap_id:
|
| 600 |
+
snap = regulation_snapshot_store.get_by_id(str(snap_id))
|
| 601 |
+
if snap and getattr(snap, "key_rules", None):
|
| 602 |
+
rules_preview = "\n".join([f"- {r}" for r in (snap.key_rules or [])[:8]])
|
| 603 |
+
return (
|
| 604 |
+
f"\n\n[REGULATION SNAPSHOT v5.0 - PRZYPISANY REGULAMIN "
|
| 605 |
+
f"(hash: {getattr(snap, 'version_hash', '?')})]:\n{rules_preview}\n"
|
| 606 |
+
)
|
| 607 |
+
|
| 608 |
+
# 2) Multi-doc: zunifikowany kontekst (snapshots + URL-e z projektu)
|
| 609 |
+
reg_ctx = get_regulation_context_for_candidates(
|
| 610 |
+
[grant_stub],
|
| 611 |
+
project_description=state.get("project_description") or "",
|
| 612 |
+
namespace=state.get("namespace"),
|
| 613 |
+
k_per_grant=6,
|
| 614 |
+
)
|
| 615 |
+
grant_id = grant_stub.get("id")
|
| 616 |
+
items = reg_ctx.get(grant_id) or []
|
| 617 |
+
if items:
|
| 618 |
+
formatted = format_regulation_context_for_prompt(reg_ctx, grant_id=grant_id)
|
| 619 |
+
if formatted and "Brak kontekstu" not in formatted:
|
| 620 |
+
return f"\n\n[REGULATION SNAPSHOT v5.0 - WIELE DOKUMENTÓW REGULAMINU]:\n{formatted}\n"
|
| 621 |
+
|
| 622 |
+
# 3) Fallback: pojedynczy snapshot po kluczu programu lub URL
|
| 623 |
+
snap = None
|
| 624 |
+
for key in _program_keys_for_candidate(grant_stub) + [doc_type]:
|
| 625 |
+
if not key:
|
| 626 |
+
continue
|
| 627 |
+
snap = regulation_snapshot_store.get_latest_for_program(key)
|
| 628 |
+
if snap and getattr(snap, "key_rules", None):
|
| 629 |
+
break
|
| 630 |
+
if not snap:
|
| 631 |
+
for url in ext.get("regulation_urls") or []:
|
| 632 |
+
snap = regulation_snapshot_store.get_latest_by_source_url(url)
|
| 633 |
+
if snap:
|
| 634 |
+
break
|
| 635 |
+
if not snap and ext.get("regulation_url"):
|
| 636 |
+
snap = regulation_snapshot_store.get_latest_by_source_url(ext["regulation_url"])
|
| 637 |
+
|
| 638 |
+
if snap and getattr(snap, "key_rules", None):
|
| 639 |
+
rules_preview = "\n".join([f"- {r}" for r in (snap.key_rules or [])[:6]])
|
| 640 |
+
return (
|
| 641 |
+
f"\n\n[REGULATION SNAPSHOT v5.0 - KLUCZOWE REGUŁY DLA "
|
| 642 |
+
f"{doc_type or 'programu'} (hash: {getattr(snap, 'version_hash', '?')})]:\n"
|
| 643 |
+
f"{rules_preview}\n"
|
| 644 |
+
)
|
| 645 |
+
except Exception as reg_err:
|
| 646 |
+
logger.debug("[Generator] Regulation boost skipped: %s", reg_err)
|
| 647 |
+
return ""
|
| 648 |
+
|
| 649 |
+
def fetch_context(self, state: GeneratorState):
|
| 650 |
+
"""Krok 2: Pobiera wycinki wiedzy z wejściowego RAG lub bazy aktów ISAP/PARP.
|
| 651 |
+
v5.0 evolution: + deep regulation snapshot + lightweight GraphRAG injection for better grounding."""
|
| 652 |
+
from core.telemetry import telemetry
|
| 653 |
+
|
| 654 |
+
idx = state.get("current_section_idx", 0)
|
| 655 |
+
sections = state.get("sections_plan", [])
|
| 656 |
+
if not sections or idx >= len(sections):
|
| 657 |
+
logger.error(f"[Generator] IndexError in fetch_context: idx={idx}, sections_plan length={len(sections)}")
|
| 658 |
+
return {"context": "Błąd: Brak planu sekcji."}
|
| 659 |
+
section = sections[idx]
|
| 660 |
+
section_name = section["title"] if isinstance(section, dict) else str(section)
|
| 661 |
+
namespace = state["namespace"]
|
| 662 |
+
|
| 663 |
+
patch: Dict[str, Any] = {}
|
| 664 |
+
stalls = state.get("section_stall_counts", {}) or {}
|
| 665 |
+
if stalls.get(str(idx), 0) >= 2 and state.get("missing_data_question"):
|
| 666 |
+
patch["missing_data_question"] = None
|
| 667 |
+
logger.warning(
|
| 668 |
+
f"[Generator] Czyszczenie zawieszonego pytania dla sekcji idx={idx} (anti-loop)."
|
| 669 |
+
)
|
| 670 |
+
|
| 671 |
+
telemetry.log(
|
| 672 |
+
"INFO",
|
| 673 |
+
"GeneratorAgent",
|
| 674 |
+
f"Przeszukuję bazę wektorową (Pinecone) dla sekcji: {section_name}",
|
| 675 |
+
{"namespace": namespace},
|
| 676 |
+
)
|
| 677 |
+
logger.info(
|
| 678 |
+
f"Pobieranie kontektu RAG dla sekcji '{section_name}' w namespace '{namespace}'."
|
| 679 |
+
)
|
| 680 |
+
|
| 681 |
+
try:
|
| 682 |
+
retriever = get_parent_document_retriever(namespace=namespace)
|
| 683 |
+
|
| 684 |
+
# Wzbogacamy zapytanie o kontekst firmy aby system szukał trafniejszych fragmentów
|
| 685 |
+
project_context = state.get("project_description", "")
|
| 686 |
+
# Ograniczamy długość kontekstu do kluczowych informacji aby nie rozmyć zapytania
|
| 687 |
+
short_context = project_context[:300] if project_context else ""
|
| 688 |
+
|
| 689 |
+
# Retrieval - szukamy najpierw specyfiki projektu w private_namespace
|
| 690 |
+
query = f"Regulamin i wytyczne dla sekcji: {section_name}. Typ dokumentu: {state['document_type']}. Kontekst i informacje do uwzględnienia (Kryteria, Budżet, Kwalifikowalność): {short_context}"
|
| 691 |
+
docs = retriever.invoke(query)
|
| 692 |
+
def _format_temporal(doc):
|
| 693 |
+
valid_from = doc.metadata.get("valid_from", "")
|
| 694 |
+
valid_to = doc.metadata.get("valid_to", "")
|
| 695 |
+
ver_id = doc.metadata.get("version_id", "")
|
| 696 |
+
time_str = ""
|
| 697 |
+
if valid_from or valid_to or ver_id:
|
| 698 |
+
time_str = f" [Wersja: {ver_id}, Ważne od: {valid_from} do: {valid_to}]"
|
| 699 |
+
return f"{doc.page_content}{time_str}"
|
| 700 |
+
|
| 701 |
+
raw_context = "\n\n".join([_format_temporal(doc) for doc in docs])
|
| 702 |
+
|
| 703 |
+
regulation_boost = self._build_regulation_boost(state)
|
| 704 |
+
context = self._safe_truncate_context(raw_context + regulation_boost, max_chars=11000, label="rag+regulation")
|
| 705 |
+
|
| 706 |
+
import hashlib
|
| 707 |
+
from datetime import datetime
|
| 708 |
+
|
| 709 |
+
traceability = state.get("traceability_data", {}) or {}
|
| 710 |
+
current_traces = []
|
| 711 |
+
|
| 712 |
+
for doc in docs:
|
| 713 |
+
content_hash = hashlib.sha256(doc.page_content.encode('utf-8')).hexdigest()
|
| 714 |
+
current_traces.append({
|
| 715 |
+
"source": doc.metadata.get("source", "Własny dokument / RAG"),
|
| 716 |
+
"url": doc.metadata.get("url", "Brak linku"),
|
| 717 |
+
"date": doc.metadata.get("fetch_date", datetime.now().strftime("%Y-%m-%d")),
|
| 718 |
+
"hash": content_hash[:16],
|
| 719 |
+
"version_id": doc.metadata.get("version_id", ""),
|
| 720 |
+
"valid_from": doc.metadata.get("valid_from", ""),
|
| 721 |
+
"valid_to": doc.metadata.get("valid_to", "")
|
| 722 |
+
})
|
| 723 |
+
|
| 724 |
+
traceability[section_name] = current_traces
|
| 725 |
+
|
| 726 |
+
if not context.strip():
|
| 727 |
+
context = "Brak specyficznego kontekstu wgranej dokumentacji w RAG dla tej sekcji."
|
| 728 |
+
telemetry.log(
|
| 729 |
+
"INFO",
|
| 730 |
+
"Pinecone",
|
| 731 |
+
f"Pobrano {len(docs)} fragmentów z wektorowej bazy danych.",
|
| 732 |
+
)
|
| 733 |
+
except Exception as e:
|
| 734 |
+
logger.error(f"Błąd RAG pod względem '{namespace}': {e}")
|
| 735 |
+
metrics.record_error("GeneratorAgent", "rag_fetch", str(e), severity="warning")
|
| 736 |
+
telemetry.log("ERROR", "Pinecone", f"Błąd pobierania wektorów: {str(e)}")
|
| 737 |
+
context = (
|
| 738 |
+
"Brak połączenia z RAG. Generowanie oparto na wiedzy ogólnej modelu."
|
| 739 |
+
)
|
| 740 |
+
traceability = state.get("traceability_data", {}) or {}
|
| 741 |
+
|
| 742 |
+
patch.update({
|
| 743 |
+
"context": context,
|
| 744 |
+
"traceability_data": traceability,
|
| 745 |
+
"graph_step": state.get("graph_step", 0) + 1,
|
| 746 |
+
"needs_holistic_regen": False,
|
| 747 |
+
"needs_audit_regen": False,
|
| 748 |
+
})
|
| 749 |
+
return patch
|
| 750 |
+
|
| 751 |
+
def draft_section(self, state: GeneratorState):
|
| 752 |
+
"""Krok 3: Generowanie sekcji za pomocą LLM.
|
| 753 |
+
|
| 754 |
+
Pipeline RODO (FAZA 1 Enterprise):
|
| 755 |
+
1. Anonymizuj opis projektu (NIP, PESEL, IBAN, nazwiska → tokeny)
|
| 756 |
+
2. Wyślij zanonimizowany tekst do LLM
|
| 757 |
+
3. Deanonymizuj wynik (tokeny → oryginalne wartości)
|
| 758 |
+
|
| 759 |
+
Dzięki temu dane PII nigdy nie trafiają do zewnętrznych API LLM.
|
| 760 |
+
"""
|
| 761 |
+
idx = state.get("current_section_idx", 0)
|
| 762 |
+
sections = state.get("sections_plan", [])
|
| 763 |
+
if not sections or idx >= len(sections):
|
| 764 |
+
logger.error(f"[Generator] IndexError in draft_section: idx={idx}, sections_plan length={len(sections)}")
|
| 765 |
+
metrics.record_error("GeneratorAgent", "draft_section_index", "index error in sections_plan", severity="error")
|
| 766 |
+
return {
|
| 767 |
+
"generated_sections": state.get("generated_sections", {}),
|
| 768 |
+
"is_completed": True,
|
| 769 |
+
"missing_data_question": None
|
| 770 |
+
}
|
| 771 |
+
section = sections[idx]
|
| 772 |
+
section_name = section["title"] if isinstance(section, dict) else str(section)
|
| 773 |
+
section_type = section.get("type") if isinstance(section, dict) else section_name.replace(" ", "_").lower()
|
| 774 |
+
context = state.get("context", "")
|
| 775 |
+
project_desc = state.get("project_description") or ""
|
| 776 |
+
|
| 777 |
+
# Brak snapshotu regulaminu — NIE wchodzimy w pętlę missing_data (główna przyczyna recursion_limit).
|
| 778 |
+
regulation_ok = self._regulation_context_present(state, context)
|
| 779 |
+
if not regulation_ok:
|
| 780 |
+
logger.warning(
|
| 781 |
+
f"[Generator] Brak twardego regulaminu dla '{section_name}' — generuję w trybie awaryjnym (bez blokady)."
|
| 782 |
+
)
|
| 783 |
+
try:
|
| 784 |
+
from agents.helpers import generate_section_light
|
| 785 |
+
|
| 786 |
+
section_content = generate_section_light(
|
| 787 |
+
section_type=section_type,
|
| 788 |
+
context=(context or "") + "\n" + (project_desc or ""),
|
| 789 |
+
external_context={"program": state.get("document_type")},
|
| 790 |
+
program_name=state.get("document_type", "dotacje"),
|
| 791 |
+
)
|
| 792 |
+
section_content = (
|
| 793 |
+
"> *Uwaga: wygenerowano bez pełnego snapshotu regulaminu w RAG. "
|
| 794 |
+
"Zalecane wgranie regulaminu do projektu.*\n\n"
|
| 795 |
+
f"{section_content}"
|
| 796 |
+
)
|
| 797 |
+
current_sections = dict(state.get("generated_sections", {}))
|
| 798 |
+
current_sections[section_name] = section_content
|
| 799 |
+
next_idx = idx + 1
|
| 800 |
+
return {
|
| 801 |
+
"generated_sections": current_sections,
|
| 802 |
+
"current_section_idx": next_idx,
|
| 803 |
+
"is_completed": next_idx >= len(sections),
|
| 804 |
+
"missing_data_question": None,
|
| 805 |
+
"graph_step": state.get("graph_step", 0) + 1,
|
| 806 |
+
"section_stall_counts": state.get("section_stall_counts", {}),
|
| 807 |
+
}
|
| 808 |
+
except Exception as reg_fallback_err:
|
| 809 |
+
logger.error(
|
| 810 |
+
f"[Generator] Awaryjny fallback bez regulaminu nieudany: {reg_fallback_err}"
|
| 811 |
+
)
|
| 812 |
+
|
| 813 |
+
# [FAZA 5 PRODUKCJA] — usunięto twardą blokadę pętli; kontynuujemy normalną ścieżkę LLM.
|
| 814 |
+
# ==============================================================================
|
| 815 |
+
# FAZA 1: Solidna Jakość Podstawowa – Basic Verification Guard przed generacją
|
| 816 |
+
# Stabilizuje generację: jeśli kontekst/granty mają niską jakość weryfikacji (Faza1 signals),
|
| 817 |
+
# dodajemy explicit warning do kontekstu + telemetry. Zapobiega halucynacjom na słabych danych.
|
| 818 |
+
# Używa data_quality_score / verification_level / regulation_grounded z pipeline search.
|
| 819 |
+
# ==============================================================================
|
| 820 |
+
try:
|
| 821 |
+
quality_notes = []
|
| 822 |
+
low_quality_count = 0
|
| 823 |
+
for line in (context or "").split("\n")[:30]: # szybki scan metadanych w kontekście RAG
|
| 824 |
+
if "data_quality_score" in line.lower() or "verification_level" in line.lower():
|
| 825 |
+
if "stale" in line.lower() or ("data_quality_score" in line.lower() and any(x in line for x in ["3", "4", "5"])):
|
| 826 |
+
low_quality_count += 1
|
| 827 |
+
if low_quality_count >= 2:
|
| 828 |
+
quality_notes.append("[FAZA1-VERIFICATION-WARNING] Część kontekstu RAG ma niską jakość danych / verification_level=stale. Generacja będzie bardziej konserwatywna.")
|
| 829 |
+
metrics.increment("generator.verification_warnings", tags={"stage": "draft_section"})
|
| 830 |
+
|
| 831 |
+
if quality_notes:
|
| 832 |
+
context = "\n".join(quality_notes) + "\n\n" + context
|
| 833 |
+
logger.warning(f"[Generator][Faza1-Verif] Wstrzyknięto ostrzeżenia weryfikacyjne do kontekstu sekcji {section_name}")
|
| 834 |
+
except Exception:
|
| 835 |
+
pass # never break generation on verification guard
|
| 836 |
+
|
| 837 |
+
# ── KROK 1: Anonymizuj opis projektu przed LLM ─────────────────────
|
| 838 |
+
anon_desc = project_desc
|
| 839 |
+
if anonymizer and project_desc:
|
| 840 |
+
try:
|
| 841 |
+
anon_desc = anonymizer.anonymize_text(project_desc)
|
| 842 |
+
if anon_desc != project_desc:
|
| 843 |
+
logger.info(
|
| 844 |
+
f"[Generator][PII] Opis projektu '{state['project_id']}' "
|
| 845 |
+
f"zanonimizowany przed wysłaniem do LLM."
|
| 846 |
+
)
|
| 847 |
+
if audit_log:
|
| 848 |
+
audit_log(
|
| 849 |
+
"GENERATOR_PII_ANON",
|
| 850 |
+
f"Projekt: {state['project_id']} | Sekcja: {section_name}",
|
| 851 |
+
)
|
| 852 |
+
except Exception as e:
|
| 853 |
+
logger.warning(
|
| 854 |
+
f"[Generator][PII] Anonimizacja nieudana: {e} — kontynuuję bez maskowania."
|
| 855 |
+
)
|
| 856 |
+
anon_desc = project_desc
|
| 857 |
+
|
| 858 |
+
from pydantic import BaseModel, Field
|
| 859 |
+
from core.telemetry import telemetry
|
| 860 |
+
|
| 861 |
+
class GeneratedSection(BaseModel):
|
| 862 |
+
content_markdown: Optional[str] = Field(
|
| 863 |
+
None,
|
| 864 |
+
description="Zredagowany tekst sekcji w formacie Markdown. ZAWSZE WYPEŁNIJ to pole, nawet jeśli brakuje danych (użyj znaczników [UZUPEŁNIĆ: co brakuje]). BEZWZGLĘDNIE PISZ TYLKO W JĘZYKU POLSKIM (włączając w to nagłówki i tytuły).",
|
| 865 |
+
)
|
| 866 |
+
reasoning_explanation: str = Field(
|
| 867 |
+
...,
|
| 868 |
+
description="Zasada Explainable AI (XAI): Krótki, merytoryczny opis uzasadniający, z których wytycznych (RAG) skorzystałeś tworząc tekst, aby audytor wiedział dlaczego wygenerowano taki a nie inny fragment.",
|
| 869 |
+
)
|
| 870 |
+
missing_data_question: Optional[str] = Field(
|
| 871 |
+
None,
|
| 872 |
+
description="Jeśli brakuje Ci krytycznych danych, wpisz tu ostrzeżenie, ale i tak wygeneruj `content_markdown` ze znacznikami. BEZWZGLĘDNIE PISZ TYLKO W JĘZYKU POLSKIM.",
|
| 873 |
+
)
|
| 874 |
+
|
| 875 |
+
financial_keywords = ["budżet", "harmonogram", "finans", "koszt", "opłacalność", "wskaźniki"]
|
| 876 |
+
rnd_keywords = ["innowacj", "technolog", "badani", "przemysłow", "b+r", "b & r", "stan techniki", "trl"]
|
| 877 |
+
|
| 878 |
+
assigned_role = "SUPERVISOR (Główny Koordynator)"
|
| 879 |
+
specialized_instructions = "TWOJA SPECJALIZACJA: Jesteś głównym koordynatorem wniosku. Zadbaj o spójność opisu firmy, potencjału zespołu, rynków docelowych i ogólnej logiki biznesowej projektu."
|
| 880 |
+
|
| 881 |
+
if any(kw in section_name.lower() or kw in section_type.lower() for kw in financial_keywords):
|
| 882 |
+
assigned_role = "FINANCIAL AGENT (Ekspert ds. Finansów)"
|
| 883 |
+
specialized_instructions = "TWOJA SPECJALIZACJA: Jesteś głównym analitykiem finansowym. Skup się wyłącznie na liczbach, budżecie, rentowności, wkładzie własnym i wskaźnikach finansowych. Zwracaj ogromną uwagę na limity dofinansowania i zgodność matematyczną. Używaj tabel."
|
| 884 |
+
elif any(kw in section_name.lower() or kw in section_type.lower() for kw in rnd_keywords):
|
| 885 |
+
assigned_role = "R&D AGENT (Ekspert ds. Innowacji)"
|
| 886 |
+
specialized_instructions = "TWOJA SPECJALIZACJA: Jesteś głównym inżynierem i ekspertem B+R. Skup się na opisie technologii, przełomowości rozwiązania, poziomach gotowości technologicznej (TRL) oraz pracach badawczo-rozwojowych. Używaj języka technicznego i naukowego."
|
| 887 |
+
|
| 888 |
+
system_prompt = (
|
| 889 |
+
f"Jesteś profesjonalnym doradcą dotacyjnym i pełnisz rolę: {assigned_role}.\n"
|
| 890 |
+
f"{specialized_instructions}\n"
|
| 891 |
+
"Odpowiadasz za najwyższą korporacyjną jakość we wnioskach dotacyjnych.\n"
|
| 892 |
+
f"Mamy dokument typu: '{state['document_type']}'.\n"
|
| 893 |
+
f"Obecnie przygotowujesz dokładnie i wyczerpująco sekcję: '{section_name}'.\n\n"
|
| 894 |
+
"Wytyczne:\n"
|
| 895 |
+
" - [ANTI-HALLUCINATION]: Wykorzystuj WYŁĄCZNIE dostarczony Kontekst RAG (fragmenty regulaminów i wytycznych programu). Masz absolutny zakaz wymyślania reguł prawnych i finansowych.\n"
|
| 896 |
+
" - [CITATION REQUIREMENT]: Kategorycznie WYMAGANE jest umieszczanie w tekście przypisów w nawiasach kwadratowych, gdy powołujesz się na warunki programu (np. limit kwotowy, warunek innowacyjności). Format: `[Źródło: Nazwa Regulaminu/Dokumentu, sekcja/strona]` oparty na metadanych RAG. Wnioski pozbawione precyzyjnych cytowań z podaniem paragrafu będą surowo odrzucane przez recenzenta!\n"
|
| 897 |
+
" - [XAI - Explainable AI]: Twoje decyzje narracyjne muszą być krótko uzasadnione w polu 'reasoning_explanation' dla pełnej przejrzystości audytowej.\n"
|
| 898 |
+
" - Zadbaj o analityczny, sformalizowany ton z punktami i statystykami gdzie to możliwe.\n"
|
| 899 |
+
" - STOSUJ BOGATE FORMATOWANIE MARKDOWN: używaj profesjonalnych nagłówków (###, ####), tabel dla danych liczbowych, list punktowanych i pogrubień (bold) dla kluczowych wskaźników. Dokument ma wyglądać nieskazitelnie, czytelnie i estetycznie!\n"
|
| 900 |
+
" - Jeżeli w zanonimizowanym opisie projektu znajduje się nazwa firmy lub jej token (np. <FIRMA_1>), BEZWZGLĘDNIE i NATURALNIE wplataj go w treść sekcji.\n"
|
| 901 |
+
" - Jeżeli napotkasz tokeny anonimizacji typu <NIP_1>, <OSOBA_1>, UŻYJ ICH dosłownie.\n"
|
| 902 |
+
f" - {SAFE_MODE_GENERATOR_RULE}\n"
|
| 903 |
+
" - >>> BEZWZGLĘDNIE GENERUJ CAŁĄ TREŚĆ WYŁĄCZNIE W JĘZYKU POLSKIM. <<<\n"
|
| 904 |
+
" - ZABRANIA SIĘ używania słów w języku angielskim. Wszystkie nagłówki, tabele, teksty i podsumowania MUSZĄ być po polsku.\n"
|
| 905 |
+
" - NIGDY NIE ZWRACAJ ANGIELSKICH TYTUŁÓW SEKCJI. Masz kategoryczny nakaz użycia oryginalnego, polskiego tytułu sekcji, nad którym pracujesz.\n"
|
| 906 |
+
" - ZADBÓJ O ZWIĘZŁOŚĆ NAGŁÓWKÓW: Ogranicz długość nagłówków/tytułów sekcji do maksymalnie 5 wyrazów.\n"
|
| 907 |
+
" - W PRZYPADKU ODPOWIEDZI UŻYTKOWNIKA NA 'missing_data_question': Pamiętaj, aby ZACHOWAĆ dotychczasowy styl sekcji i wpleść nowe informacje spójnie.\n"
|
| 908 |
+
" - ZACHOWAJ SPÓJNOŚĆ LOGICZNĄ z poprzednimi sekcjami. Jeśli w poprzednich sekcjach opisano już szczegóły projektu (cele, grupę docelową), odwołuj się do nich spójnie i nie twórz sprzecznych informacji.\n"
|
| 909 |
+
" - Płynnie nawiązuj do poprzednio wygenerowanych sekcji dokumentu, by wniosek tworzył jedną logiczną i czytelną całość.\n"
|
| 910 |
+
" - [AUTOPILOT]: Jeśli NIP, nazwa firmy lub adres siedziby są już dostępne w opisie projektu lub kontekście GUS/KRS, NIE zadawaj pytań o brakujące dane dotyczące tych pól — wykorzystaj je bezpośrednio w treści sekcji.\n"
|
| 911 |
+
)
|
| 912 |
+
|
| 913 |
+
additional_context = state.get("additional_context", "")
|
| 914 |
+
|
| 915 |
+
# Odtworzenie poprzednich sekcji dla kontekstu i spójności
|
| 916 |
+
previously_generated = ""
|
| 917 |
+
generated_sections = state.get("generated_sections", {})
|
| 918 |
+
if generated_sections:
|
| 919 |
+
previously_generated = "--- Poprzednie wygenerowane sekcje dokumentu (dla spójności) ---\n"
|
| 920 |
+
for s_name, s_content in generated_sections.items():
|
| 921 |
+
truncated_content = s_content[:1500] + ("..." if len(s_content) > 1500 else "")
|
| 922 |
+
previously_generated += f"\n### Sekcja: {s_name}\n{truncated_content}\n"
|
| 923 |
+
previously_generated += "\n--- Koniec poprzednich sekcji ---\n\n"
|
| 924 |
+
|
| 925 |
+
# v5.0 stability: truncate all inputs to LLM to protect against runaway context
|
| 926 |
+
safe_context = self._safe_truncate_context(context, max_chars=9000, label="draft_context")
|
| 927 |
+
safe_anon = self._safe_truncate_context(anon_desc or "", max_chars=4000, label="project_desc")
|
| 928 |
+
# additional_context niesie strukturalny blok ProjectContext (dane wnioskodawcy
|
| 929 |
+
# jako źródło prawdy, do ~6000 zn.) + ewentualny blackboard — nie obcinamy go
|
| 930 |
+
# agresywnie do 2500, by nie utracić ugruntowanych danych firmy.
|
| 931 |
+
safe_addl = self._safe_truncate_context(additional_context or "", max_chars=8000, label="grounded_context")
|
| 932 |
+
safe_prev = self._safe_truncate_context(previously_generated, max_chars=8000, label="previous_sections")
|
| 933 |
+
|
| 934 |
+
human_content = ""
|
| 935 |
+
if safe_prev:
|
| 936 |
+
human_content += f"{safe_prev}\n"
|
| 937 |
+
human_content += f"Kontekst RAG:\n{safe_context}"
|
| 938 |
+
if safe_anon:
|
| 939 |
+
human_content += f"\n\nOpis projektu (zanonimizowany):\n{safe_anon}"
|
| 940 |
+
if safe_addl:
|
| 941 |
+
human_content += (
|
| 942 |
+
f"\n\nDodatkowe odpowiedzi od użytkownika:\n{safe_addl}"
|
| 943 |
+
)
|
| 944 |
+
|
| 945 |
+
telemetry.log(
|
| 946 |
+
"INFO",
|
| 947 |
+
"GeneratorAgent",
|
| 948 |
+
"Rozpoczynam draftowanie sekcji",
|
| 949 |
+
{"project_id": state["project_id"], "section": section_name},
|
| 950 |
+
)
|
| 951 |
+
logger.info(
|
| 952 |
+
f"[Generator] Draftowanie sekcji: '{section_name}' (projekt: {state['project_id']})"
|
| 953 |
+
)
|
| 954 |
+
|
| 955 |
+
# ── KROK 2: Wywołanie LLM z walidacją schematu i anty-halucynacją ─────
|
| 956 |
+
def _generate(attempt: int = 1):
|
| 957 |
+
current_system_prompt = system_prompt
|
| 958 |
+
if attempt > 1:
|
| 959 |
+
current_system_prompt += (
|
| 960 |
+
"\n\nUWAGA: POPRZEDNIA PRÓBA NIE POWIODŁA SIĘ."
|
| 961 |
+
"\nZamiast zmyślać liczby, używaj WYŁĄCZNIE markerów w formacie "
|
| 962 |
+
"[DO WERYFIKACJI: opis brakującej danej], np. "
|
| 963 |
+
"[DO WERYFIKACJI: kwota inwestycji w PLN] lub [DO WERYFIKACJI: liczba pracowników]."
|
| 964 |
+
"\nTwórz KONKRETNĄ treść (merytoryczny opis), używając tych markerów na brakujące liczby."
|
| 965 |
+
)
|
| 966 |
+
if section_type in ("budget", "finance"):
|
| 967 |
+
current_system_prompt += (
|
| 968 |
+
"\n\nSEKCJA BUDŻETOWA/FINANSOWA — SZCZEGÓLNA OSTROŻNOŚĆ:"
|
| 969 |
+
"\nNIE wymyślaj kwot, stawek, procentów dofinansowania ani sum. Zachowaj wszystkie"
|
| 970 |
+
" kwoty i pozycje kosztowe z poprzedniej wersji, jeśli były poprawne."
|
| 971 |
+
" Brakujące wartości oznaczaj markerem [DO WERYFIKACJI: ...]."
|
| 972 |
+
" Zadbaj o kwalifikowalność kosztów i brak podwójnego finansowania / cross-financingu."
|
| 973 |
+
)
|
| 974 |
+
|
| 975 |
+
user_id = state.get("namespace", "").replace("tenant_", "") if "tenant_" in state.get("namespace", "") else "anonymous"
|
| 976 |
+
tracking_callback = TokenTrackingCallback(user_id=user_id)
|
| 977 |
+
|
| 978 |
+
try:
|
| 979 |
+
structured_llm = get_llm(
|
| 980 |
+
task_type="critical",
|
| 981 |
+
structured_output_schema=GeneratedSection,
|
| 982 |
+
callbacks=[tracking_callback]
|
| 983 |
+
)
|
| 984 |
+
response = structured_llm.invoke(
|
| 985 |
+
[
|
| 986 |
+
SystemMessage(content=current_system_prompt),
|
| 987 |
+
HumanMessage(content=human_content),
|
| 988 |
+
]
|
| 989 |
+
)
|
| 990 |
+
|
| 991 |
+
if response.missing_data_question:
|
| 992 |
+
telemetry.log(
|
| 993 |
+
"WARN",
|
| 994 |
+
"GeneratorAgent",
|
| 995 |
+
"Wykryto brak danych, ale generowanie jest kontynuowane.",
|
| 996 |
+
{"question": response.missing_data_question},
|
| 997 |
+
)
|
| 998 |
+
|
| 999 |
+
section_content_temp = response.content_markdown or ""
|
| 1000 |
+
if not section_content_temp and response.missing_data_question:
|
| 1001 |
+
section_content_temp = f"**Brakujące dane:** {response.missing_data_question}\n\n*Proszę uzupełnić tę sekcję ręcznie lub podać wymagane informacje w opisie projektu.*"
|
| 1002 |
+
else:
|
| 1003 |
+
section_content_temp = extract_markdown_and_sanitize(
|
| 1004 |
+
section_content_temp
|
| 1005 |
+
)
|
| 1006 |
+
|
| 1007 |
+
if hasattr(response, "reasoning_explanation") and response.reasoning_explanation:
|
| 1008 |
+
# XAI trafia do logu/telemetrii — NIE do treści wniosku (czysta treść PL).
|
| 1009 |
+
logger.info(f"[XAI Reasoning] {response.reasoning_explanation}")
|
| 1010 |
+
|
| 1011 |
+
return section_content_temp, response.missing_data_question
|
| 1012 |
+
except Exception as e:
|
| 1013 |
+
logger.warning(
|
| 1014 |
+
f"[Generator] with_structured_output failed for '{section_name}': {e}. Attempting fallback without structured output."
|
| 1015 |
+
)
|
| 1016 |
+
|
| 1017 |
+
fallback_llm = get_llm(task_type="critical", callbacks=[tracking_callback])
|
| 1018 |
+
|
| 1019 |
+
fallback_response = fallback_llm.invoke(
|
| 1020 |
+
[
|
| 1021 |
+
SystemMessage(
|
| 1022 |
+
content=current_system_prompt
|
| 1023 |
+
+ "\nZwróć TYLKO treść sekcji w formacie Markdown (bez pytań o brakujące dane, spróbuj sobie poradzić). "
|
| 1024 |
+
"Twórz konkretny tekst; brakujące dane oznaczaj WYŁĄCZNIE markerem w formacie "
|
| 1025 |
+
"[DO WERYFIKACJI: opis danej], np. [DO WERYFIKACJI: kwota dofinansowania w PLN]. "
|
| 1026 |
+
"BEZWZGLĘDNIE PISZ TYLKO W JĘZYKU POLSKIM."
|
| 1027 |
+
),
|
| 1028 |
+
HumanMessage(content=human_content),
|
| 1029 |
+
]
|
| 1030 |
+
)
|
| 1031 |
+
section_content_temp = (
|
| 1032 |
+
fallback_response.content
|
| 1033 |
+
if hasattr(fallback_response, "content")
|
| 1034 |
+
else str(fallback_response)
|
| 1035 |
+
)
|
| 1036 |
+
section_content_temp = extract_markdown_and_sanitize(section_content_temp)
|
| 1037 |
+
return section_content_temp, None
|
| 1038 |
+
|
| 1039 |
+
try:
|
| 1040 |
+
# INTEGRACJA: Faza 4 - Moduł Analityka Finansowego
|
| 1041 |
+
if section_type in ["budget", "finance"]:
|
| 1042 |
+
try:
|
| 1043 |
+
from backend.agents.finance_agent import finance_agent
|
| 1044 |
+
except ImportError:
|
| 1045 |
+
from agents.finance_agent import finance_agent
|
| 1046 |
+
|
| 1047 |
+
logger.info(f"[Generator] Delegowanie sekcji '{section_name}' do Agenta Finansowego.")
|
| 1048 |
+
section_content, missing_question = finance_agent.draft_financial_section(
|
| 1049 |
+
document_type=state['document_type'],
|
| 1050 |
+
section_name=section_name,
|
| 1051 |
+
project_desc=anon_desc,
|
| 1052 |
+
context=context
|
| 1053 |
+
)
|
| 1054 |
+
else:
|
| 1055 |
+
section_content, missing_question = _generate()
|
| 1056 |
+
except Exception as e_fallback:
|
| 1057 |
+
logger.error(
|
| 1058 |
+
f"[Generator] Fallback LLM failure for section '{section_name}': {e_fallback}"
|
| 1059 |
+
)
|
| 1060 |
+
telemetry.log(
|
| 1061 |
+
"ERROR",
|
| 1062 |
+
"GeneratorAgent",
|
| 1063 |
+
"Błąd LLM podczas generowania",
|
| 1064 |
+
{"error": str(e_fallback)},
|
| 1065 |
+
)
|
| 1066 |
+
# Faza 0/3 v5.0 reliable generation: use generate_section_light as hard fallback (never lose progress)
|
| 1067 |
+
try:
|
| 1068 |
+
from agents.helpers import generate_section_light
|
| 1069 |
+
section_content = generate_section_light(
|
| 1070 |
+
section_type=section_name,
|
| 1071 |
+
context=context or project_desc,
|
| 1072 |
+
external_context={"company_data": {}},
|
| 1073 |
+
program_name=state.get("document_type", "dotacje")
|
| 1074 |
+
)
|
| 1075 |
+
section_content = f"{section_content}\n\n*[Wygenerowano w trybie awaryjnym light + resume checkpoint zachowany]*"
|
| 1076 |
+
except Exception:
|
| 1077 |
+
section_content = (
|
| 1078 |
+
f"*(Wystąpił błąd API podczas generowania sekcji: {str(e_fallback)})*\n\n"
|
| 1079 |
+
"[UZUPEŁNIĆ: Sekcja wymaga ręcznego uzupełnienia. Autopilot checkpoint pozwala wznowić.]"
|
| 1080 |
+
)
|
| 1081 |
+
missing_question = None
|
| 1082 |
+
|
| 1083 |
+
# ── KROK 3: Deanonymizuj wynik (tokeny → oryginalne wartości) ────
|
| 1084 |
+
if anonymizer and project_desc and anon_desc != project_desc:
|
| 1085 |
+
try:
|
| 1086 |
+
section_content = anonymizer.deanonymize_text(section_content)
|
| 1087 |
+
logger.info(
|
| 1088 |
+
f"[Generator][PII] Sekcja '{section_name}' deanonimizowana po LLM."
|
| 1089 |
+
)
|
| 1090 |
+
except Exception as e:
|
| 1091 |
+
logger.warning(
|
| 1092 |
+
f"[Generator][PII] Deanonymizacja nieudana: {e} — zwracam zanonimizowaną wersję."
|
| 1093 |
+
)
|
| 1094 |
+
|
| 1095 |
+
program_hint = state.get("document_type", "") or ""
|
| 1096 |
+
|
| 1097 |
+
# ── FAZA 5: Section-level Critic (realnie wpływa na regenerację) ──────
|
| 1098 |
+
# Krytyk per-sekcja (RegulationEngine + twarde walidacje). Sekcja, która nie
|
| 1099 |
+
# przejdzie bramki, jest jednorazowo regenerowana ze wzmocnionym promptem
|
| 1100 |
+
# (bounded: 1 próba/sekcja), a lepszy wariant zastępuje pierwotny.
|
| 1101 |
+
critic_retries = dict(state.get("section_critic_retries", {}) or {})
|
| 1102 |
+
verdict: Dict[str, Any] = {"passed": True, "score": 100, "reasons": [], "verification": {}}
|
| 1103 |
+
try:
|
| 1104 |
+
already_retried = critic_retries.get(str(idx), 0) >= 1
|
| 1105 |
+
verdict = self._section_critic_verdict(section_content, section_name, program_hint)
|
| 1106 |
+
# P3#16: sekcje budget/finance TEŻ mogą być regenerowane przez krytyka
|
| 1107 |
+
# (z ostrożniejszym promptem w _generate) — tam błędy są najgroźniejsze.
|
| 1108 |
+
if (
|
| 1109 |
+
not verdict["passed"]
|
| 1110 |
+
and not already_retried
|
| 1111 |
+
):
|
| 1112 |
+
logger.warning(
|
| 1113 |
+
f"[Generator][SectionCritic] Sekcja '{section_name}' odrzucona przez krytyka: "
|
| 1114 |
+
f"{verdict['reasons']} (score={verdict['score']}) — regeneruję."
|
| 1115 |
+
)
|
| 1116 |
+
metrics.increment("generation.section_critic_regeneration", tags={"section": section_name})
|
| 1117 |
+
critic_retries[str(idx)] = critic_retries.get(str(idx), 0) + 1
|
| 1118 |
+
try:
|
| 1119 |
+
regen_content, regen_q = _generate(attempt=2)
|
| 1120 |
+
if anonymizer and project_desc and anon_desc != project_desc:
|
| 1121 |
+
try:
|
| 1122 |
+
regen_content = anonymizer.deanonymize_text(regen_content)
|
| 1123 |
+
except Exception:
|
| 1124 |
+
pass
|
| 1125 |
+
regen_verdict = self._section_critic_verdict(regen_content, section_name, program_hint)
|
| 1126 |
+
# Zastąp, jeśli regeneracja jest co najmniej równie dobra (mniej pułapek/lepszy score)
|
| 1127 |
+
if regen_content and regen_verdict["score"] >= verdict["score"]:
|
| 1128 |
+
section_content = regen_content
|
| 1129 |
+
verdict = regen_verdict
|
| 1130 |
+
if regen_q and not missing_question:
|
| 1131 |
+
missing_question = regen_q
|
| 1132 |
+
logger.info(
|
| 1133 |
+
f"[Generator][SectionCritic] Regeneracja przyjęta dla '{section_name}' "
|
| 1134 |
+
f"(nowy score={regen_verdict['score']}, passed={regen_verdict['passed']})."
|
| 1135 |
+
)
|
| 1136 |
+
except Exception as regen_e:
|
| 1137 |
+
logger.warning(f"[Generator][SectionCritic] Regeneracja nieudana (non-fatal): {regen_e}")
|
| 1138 |
+
# Zapisz werdykt krytyka do traceability
|
| 1139 |
+
crit_trace = state.get("traceability_data", {}) or {}
|
| 1140 |
+
crit_trace.setdefault(section_name, [])
|
| 1141 |
+
if isinstance(crit_trace.get(section_name), list):
|
| 1142 |
+
crit_trace[section_name].append({
|
| 1143 |
+
"type": "section_critic",
|
| 1144 |
+
"timestamp": time.time(),
|
| 1145 |
+
"data": {"passed": verdict["passed"], "score": verdict["score"], "reasons": verdict["reasons"]},
|
| 1146 |
+
})
|
| 1147 |
+
state["traceability_data"] = crit_trace
|
| 1148 |
+
except Exception as crit_e:
|
| 1149 |
+
logger.warning(f"[Generator][SectionCritic] Bramka krytyka pominięta (non-fatal): {crit_e}")
|
| 1150 |
+
|
| 1151 |
+
# ── v5.0 Faza 3/4: Proactive Verification Integration (inside generation) ──
|
| 1152 |
+
# P3#14: reużywamy weryfikacji policzonej już przez _section_critic_verdict
|
| 1153 |
+
# (koniec z podwójnym liczeniem CitationVerifier/Kruczkowski). Wszystkie noty
|
| 1154 |
+
# techniczne trafiają WYŁĄCZNIE do traceability — treść wniosku pozostaje czysta.
|
| 1155 |
+
try:
|
| 1156 |
+
v5_verif = verdict.get("verification") or {}
|
| 1157 |
+
if not v5_verif:
|
| 1158 |
+
v5_verif = self._run_v5_verification(section_content, section_name, program_hint)
|
| 1159 |
+
verifiers_available = bool(citation_verifier or kruczkowski_trap_agent)
|
| 1160 |
+
traceability = state.get("traceability_data", {}) or {}
|
| 1161 |
+
if section_name not in traceability or not isinstance(traceability.get(section_name), list):
|
| 1162 |
+
base = traceability.get(section_name)
|
| 1163 |
+
traceability[section_name] = [base] if base else []
|
| 1164 |
+
|
| 1165 |
+
if v5_verif.get("applied"):
|
| 1166 |
+
ver_score = v5_verif.get("citation", {}).get("overall_score", 0.0)
|
| 1167 |
+
trap_count = len(v5_verif.get("traps", {}).get("detected", []))
|
| 1168 |
+
traceability[section_name].append({
|
| 1169 |
+
"type": "v5_verification",
|
| 1170 |
+
"timestamp": time.time(),
|
| 1171 |
+
"data": v5_verif
|
| 1172 |
+
})
|
| 1173 |
+
logger.info(f"[Generator v5.0] Verification recorded for '{section_name}': score={ver_score:.2f}, traps={trap_count}")
|
| 1174 |
+
metrics.increment("generation.v5_sections_verified")
|
| 1175 |
+
else:
|
| 1176 |
+
# P3#11: CICHY NO-OP — weryfikatory niedostępne LUB nie zaaplikowane.
|
| 1177 |
+
# NIE przepuszczamy sekcji po cichu: oznaczamy ją jako NIEZWERYFIKOWANĄ
|
| 1178 |
+
# i wymuszamy human review + metrykę krytyczną.
|
| 1179 |
+
if not verifiers_available:
|
| 1180 |
+
logger.error(
|
| 1181 |
+
f"[Generator v5.0] Weryfikatory (RegulationEngine) niedostępne — "
|
| 1182 |
+
f"sekcja '{section_name}' NIEZWERYFIKOWANA. Wymuszam human review."
|
| 1183 |
+
)
|
| 1184 |
+
metrics.increment("generation.v5_verification_unavailable", tags={"section": section_name})
|
| 1185 |
+
unverified = dict(state.get("unverified_sections", {}) or {})
|
| 1186 |
+
unverified[section_name] = "verifiers_unavailable"
|
| 1187 |
+
state["unverified_sections"] = unverified
|
| 1188 |
+
state["human_review_required"] = True
|
| 1189 |
+
traceability[section_name].append({
|
| 1190 |
+
"type": "v5_verification_skipped",
|
| 1191 |
+
"timestamp": time.time(),
|
| 1192 |
+
"data": {"applied": False, "verifiers_available": verifiers_available},
|
| 1193 |
+
})
|
| 1194 |
+
state["traceability_data"] = traceability
|
| 1195 |
+
except Exception as e:
|
| 1196 |
+
logger.warning(f"[Generator v5.0] Verification integration error (non-fatal): {e}")
|
| 1197 |
+
metrics.record_error("GeneratorAgent", "v5_verif_attach", str(e), severity="warning")
|
| 1198 |
+
|
| 1199 |
+
# ── FAZA 3: Jedna spójna bramka gap/placeholder (twarda bramka jakości) ──
|
| 1200 |
+
# Skanuje WYGENEROWANĄ treść pod kątem placeholderów ([DO WERYFIKACJI: ...],
|
| 1201 |
+
# [UZUPEŁNIJ: ...], [BRAK DANYCH ...] itp.), próbuje je uzupełnić przez
|
| 1202 |
+
# Auto-Fill Agenta (rejestry + research), a krytyczne, nierozwiązane luki
|
| 1203 |
+
# eskaluje do bramki HITL (missing_data_question).
|
| 1204 |
+
try:
|
| 1205 |
+
from agents.autofill_agent import autofill_agent
|
| 1206 |
+
|
| 1207 |
+
if autofill_agent.detect_placeholders(section_content):
|
| 1208 |
+
fill_ctx = (additional_context or "") + "\n" + (project_desc or "")
|
| 1209 |
+
af = autofill_agent.autofill_content(
|
| 1210 |
+
section_content,
|
| 1211 |
+
known_facts=state.get("project_context_facts") or None,
|
| 1212 |
+
extra_context=fill_ctx,
|
| 1213 |
+
allow_research=True,
|
| 1214 |
+
)
|
| 1215 |
+
if af.resolved:
|
| 1216 |
+
section_content = af.filled_content
|
| 1217 |
+
logger.info(
|
| 1218 |
+
f"[Generator][AutoFill] Uzupełniono {len(af.resolved)} placeholderów w sekcji '{section_name}'"
|
| 1219 |
+
)
|
| 1220 |
+
metrics.increment("generation.autofill_resolved", tags={"section": section_name})
|
| 1221 |
+
if af.unresolved:
|
| 1222 |
+
metrics.increment("generation.autofill_unresolved", tags={"section": section_name})
|
| 1223 |
+
critical_unresolved = [
|
| 1224 |
+
p for p in af.unresolved
|
| 1225 |
+
if autofill_agent._is_critical(autofill_agent._placeholder_label(p))
|
| 1226 |
+
]
|
| 1227 |
+
if critical_unresolved and not missing_question:
|
| 1228 |
+
missing_question = af.hitl_question
|
| 1229 |
+
logger.warning(
|
| 1230 |
+
f"[Generator][AutoFill] Krytyczne luki w sekcji '{section_name}' "
|
| 1231 |
+
f"— eskalacja do HITL: {af.hitl_question}"
|
| 1232 |
+
)
|
| 1233 |
+
except Exception as e:
|
| 1234 |
+
logger.warning(f"[Generator][AutoFill] Bramka placeholderów pominięta (non-fatal): {e}")
|
| 1235 |
+
|
| 1236 |
+
# ── Aktualizacja stanu ────────────────────────────────────────────
|
| 1237 |
+
current_sections = dict(state.get("generated_sections", {}))
|
| 1238 |
+
current_sections[section_name] = section_content
|
| 1239 |
+
|
| 1240 |
+
if missing_question:
|
| 1241 |
+
full_ctx_check = (project_desc or "") + "\n" + (additional_context or "")
|
| 1242 |
+
if _company_fields_satisfy(missing_question.lower(), full_ctx_check):
|
| 1243 |
+
missing_question = None
|
| 1244 |
+
|
| 1245 |
+
stall_counts = dict(state.get("section_stall_counts", {}))
|
| 1246 |
+
if missing_question:
|
| 1247 |
+
stall_key = str(idx)
|
| 1248 |
+
stall_counts[stall_key] = stall_counts.get(stall_key, 0) + 1
|
| 1249 |
+
if stall_counts[stall_key] >= 2:
|
| 1250 |
+
logger.warning(
|
| 1251 |
+
f"[Generator] Przerwanie pętli brakujących danych dla sekcji '{section_name}'"
|
| 1252 |
+
)
|
| 1253 |
+
missing_question = None
|
| 1254 |
+
if not section_content or len(section_content.strip()) < 20:
|
| 1255 |
+
section_content = (
|
| 1256 |
+
f"### {section_name}\n\n"
|
| 1257 |
+
f"[UZUPEŁNIĆ: brak danych wejściowych — sekcja wymaga ręcznego uzupełnienia.]"
|
| 1258 |
+
)
|
| 1259 |
+
current_sections[section_name] = section_content
|
| 1260 |
+
|
| 1261 |
+
if missing_question:
|
| 1262 |
+
next_idx = idx
|
| 1263 |
+
is_completed = False
|
| 1264 |
+
else:
|
| 1265 |
+
next_idx = idx + 1
|
| 1266 |
+
is_completed = next_idx >= len(state["sections_plan"])
|
| 1267 |
+
|
| 1268 |
+
telemetry.log(
|
| 1269 |
+
"INFO",
|
| 1270 |
+
"GeneratorAgent",
|
| 1271 |
+
"Zakończono draftowanie sekcji",
|
| 1272 |
+
{"project_id": state["project_id"], "section": section_name},
|
| 1273 |
+
)
|
| 1274 |
+
|
| 1275 |
+
return {
|
| 1276 |
+
"generated_sections": current_sections,
|
| 1277 |
+
"current_section_idx": next_idx,
|
| 1278 |
+
"is_completed": is_completed,
|
| 1279 |
+
"missing_data_question": missing_question,
|
| 1280 |
+
"section_stall_counts": stall_counts,
|
| 1281 |
+
"section_critic_retries": critic_retries,
|
| 1282 |
+
"traceability_data": state.get("traceability_data", {}),
|
| 1283 |
+
"graph_step": state.get("graph_step", 0) + 1,
|
| 1284 |
+
}
|
| 1285 |
+
|
| 1286 |
+
def _build_full_document_markdown(self, state: GeneratorState) -> str:
|
| 1287 |
+
from core.document_builder import DocumentBuilder
|
| 1288 |
+
|
| 1289 |
+
return DocumentBuilder.build_markdown(
|
| 1290 |
+
sections_plan=state.get("sections_plan", []),
|
| 1291 |
+
generated_sections=state.get("generated_sections", {}),
|
| 1292 |
+
document_type=state.get("document_type", ""),
|
| 1293 |
+
traceability_data=state.get("traceability_data", {}),
|
| 1294 |
+
)
|
| 1295 |
+
|
| 1296 |
+
def _route_after_pre_submission_audit(self, state: GeneratorState) -> str:
|
| 1297 |
+
# Full autopilot: nie zatrzymuj na HITL przy guard_block — kontynuuj pipeline
|
| 1298 |
+
# (krytyk globalny + audyt panelowy + zapis final_document z flagami w DB).
|
| 1299 |
+
if state.get("guard_block") and not state.get("full_autopilot"):
|
| 1300 |
+
return "review"
|
| 1301 |
+
if not state.get("is_completed", True):
|
| 1302 |
+
return "retry"
|
| 1303 |
+
if state.get("full_autopilot"):
|
| 1304 |
+
if not state.get("holistic_phase_complete"):
|
| 1305 |
+
return "holistic"
|
| 1306 |
+
if not state.get("panel_audit_done"):
|
| 1307 |
+
return "panel"
|
| 1308 |
+
return "end"
|
| 1309 |
+
|
| 1310 |
+
def _route_after_holistic_review(self, state: GeneratorState) -> str:
|
| 1311 |
+
if state.get("guard_block") and not state.get("full_autopilot"):
|
| 1312 |
+
return "review"
|
| 1313 |
+
if state.get("needs_holistic_regen"):
|
| 1314 |
+
return "regen"
|
| 1315 |
+
return "panel"
|
| 1316 |
+
|
| 1317 |
+
def _route_after_panel_audit(self, state: GeneratorState) -> str:
|
| 1318 |
+
if state.get("needs_audit_regen"):
|
| 1319 |
+
return "regen"
|
| 1320 |
+
if state.get("guard_block") and not state.get("full_autopilot"):
|
| 1321 |
+
return "review"
|
| 1322 |
+
return "end"
|
| 1323 |
+
|
| 1324 |
+
def global_holistic_review(self, state: GeneratorState):
|
| 1325 |
+
"""Faza krytyka globalnego (Holistic Critic) z auto-korektą (bounded retries)."""
|
| 1326 |
+
logger.info(f"[Generator][FullAutopilot] Holistic Critic dla projektu {state['project_id']}")
|
| 1327 |
+
full_md = self._build_full_document_markdown(state)
|
| 1328 |
+
program = state.get("document_type", "Nieznany program")
|
| 1329 |
+
holistic_retries = int(state.get("holistic_retries") or 0)
|
| 1330 |
+
|
| 1331 |
+
try:
|
| 1332 |
+
from agents.holistic_critic import holistic_critic_evaluate
|
| 1333 |
+
|
| 1334 |
+
report = holistic_critic_evaluate(state["project_id"], full_md, program)
|
| 1335 |
+
report_dump = report.model_dump() if hasattr(report, "model_dump") else report.dict()
|
| 1336 |
+
except Exception as e:
|
| 1337 |
+
logger.error(f"[Generator][FullAutopilot] Holistic Critic error: {e}")
|
| 1338 |
+
report_dump = {
|
| 1339 |
+
"is_approved": False,
|
| 1340 |
+
"overall_score": 0,
|
| 1341 |
+
"key_recommendations": [f"Błąd krytyka globalnego: {str(e)[:120]}"],
|
| 1342 |
+
}
|
| 1343 |
+
report = type("_HolisticFallback", (), report_dump)()
|
| 1344 |
+
|
| 1345 |
+
traceability = state.get("traceability_data", {}) or {}
|
| 1346 |
+
traceability["holistic_review"] = [report_dump]
|
| 1347 |
+
|
| 1348 |
+
if not getattr(report, "is_approved", False) and holistic_retries < MAX_HOLISTIC_RETRIES:
|
| 1349 |
+
feedback = "=== KOREKTA WG KRYTYKA GLOBALNEGO (Holistic Critic) ===\n"
|
| 1350 |
+
for rec in getattr(report, "key_recommendations", []) or []:
|
| 1351 |
+
feedback += f"- {rec}\n"
|
| 1352 |
+
for cat_name in ("budget_consistency", "logical_flow", "program_alignment", "dnsh_assessment"):
|
| 1353 |
+
cat = getattr(report, cat_name, None)
|
| 1354 |
+
if cat and hasattr(cat, "feedback") and cat.feedback:
|
| 1355 |
+
feedback += f"\n[{cat_name}] {cat.feedback}\n"
|
| 1356 |
+
logger.info(
|
| 1357 |
+
f"[Generator][FullAutopilot] Holistic niezaliczony — auto-korekta {holistic_retries + 1}/{MAX_HOLISTIC_RETRIES}"
|
| 1358 |
+
)
|
| 1359 |
+
return {
|
| 1360 |
+
"traceability_data": traceability,
|
| 1361 |
+
"additional_context": (state.get("additional_context") or "") + "\n\n" + feedback,
|
| 1362 |
+
"needs_holistic_regen": True,
|
| 1363 |
+
"is_completed": False,
|
| 1364 |
+
"current_section_idx": 0,
|
| 1365 |
+
"generated_sections": {},
|
| 1366 |
+
"holistic_retries": holistic_retries + 1,
|
| 1367 |
+
"pipeline_phase": "holistic_correction",
|
| 1368 |
+
}
|
| 1369 |
+
|
| 1370 |
+
return {
|
| 1371 |
+
"traceability_data": traceability,
|
| 1372 |
+
"needs_holistic_regen": False,
|
| 1373 |
+
"holistic_phase_complete": True,
|
| 1374 |
+
"pipeline_phase": "panel_audit",
|
| 1375 |
+
}
|
| 1376 |
+
|
| 1377 |
+
def global_panel_audit(self, state: GeneratorState):
|
| 1378 |
+
"""Faza audytora panelowego (GlobalAuditOutput) z auto-poprawką sekcji."""
|
| 1379 |
+
logger.info(f"[Generator][FullAutopilot] Panel Audit dla projektu {state['project_id']}")
|
| 1380 |
+
full_md = self._build_full_document_markdown(state)
|
| 1381 |
+
program = state.get("document_type", "Nieznany program")
|
| 1382 |
+
panel_retries = int(state.get("panel_audit_retries") or 0)
|
| 1383 |
+
|
| 1384 |
+
try:
|
| 1385 |
+
from agents.auditor import audit_final_document
|
| 1386 |
+
|
| 1387 |
+
result = audit_final_document(
|
| 1388 |
+
project_id=state["project_id"],
|
| 1389 |
+
program_name=program,
|
| 1390 |
+
content=full_md,
|
| 1391 |
+
enable_multi_perspective=True,
|
| 1392 |
+
light_mode=False,
|
| 1393 |
+
)
|
| 1394 |
+
audit_dump = result.model_dump() if hasattr(result, "model_dump") else result.dict()
|
| 1395 |
+
except Exception as e:
|
| 1396 |
+
logger.error(f"[Generator][FullAutopilot] Panel audit error: {e}")
|
| 1397 |
+
audit_dump = {
|
| 1398 |
+
"is_approved": False,
|
| 1399 |
+
"export_status": "blocked",
|
| 1400 |
+
"human_review_required": True,
|
| 1401 |
+
"overall_score": 0,
|
| 1402 |
+
"issues": [],
|
| 1403 |
+
"xai_justification": f"Awaria audytu panelowego: {str(e)[:200]}",
|
| 1404 |
+
}
|
| 1405 |
+
result = type("_PanelFallback", (), audit_dump)()
|
| 1406 |
+
|
| 1407 |
+
traceability = state.get("traceability_data", {}) or {}
|
| 1408 |
+
traceability["global_panel_audit"] = [audit_dump]
|
| 1409 |
+
|
| 1410 |
+
export_status = getattr(result, "export_status", None) or audit_dump.get("export_status", "blocked")
|
| 1411 |
+
is_approved = getattr(result, "is_approved", False)
|
| 1412 |
+
issues = getattr(result, "issues", None) or audit_dump.get("issues", [])
|
| 1413 |
+
|
| 1414 |
+
needs_fix = (
|
| 1415 |
+
panel_retries < MAX_PANEL_AUDIT_RETRIES
|
| 1416 |
+
and (export_status != "ok" or not is_approved)
|
| 1417 |
+
and issues
|
| 1418 |
+
)
|
| 1419 |
+
|
| 1420 |
+
if needs_fix:
|
| 1421 |
+
feedback = "=== KOREKTA WG AUDYTORA PANELOWEGO ===\n"
|
| 1422 |
+
for issue in issues[:15]:
|
| 1423 |
+
if isinstance(issue, dict):
|
| 1424 |
+
feedback += (
|
| 1425 |
+
f"- [{issue.get('severity', '?')}] {issue.get('affected_section', 'Ogólne')}: "
|
| 1426 |
+
f"{issue.get('message', '')}\n Rek: {issue.get('recommendation', '')}\n"
|
| 1427 |
+
)
|
| 1428 |
+
else:
|
| 1429 |
+
feedback += (
|
| 1430 |
+
f"- [{getattr(issue, 'severity', '?')}] {getattr(issue, 'affected_section', 'Ogólne')}: "
|
| 1431 |
+
f"{getattr(issue, 'message', '')}\n Rek: {getattr(issue, 'recommendation', '')}\n"
|
| 1432 |
+
)
|
| 1433 |
+
logger.info(
|
| 1434 |
+
f"[Generator][FullAutopilot] Audyt wymaga korekty — auto-poprawka {panel_retries + 1}/{MAX_PANEL_AUDIT_RETRIES}"
|
| 1435 |
+
)
|
| 1436 |
+
return {
|
| 1437 |
+
"traceability_data": traceability,
|
| 1438 |
+
"additional_context": (state.get("additional_context") or "") + "\n\n" + feedback,
|
| 1439 |
+
"needs_audit_regen": True,
|
| 1440 |
+
"is_completed": False,
|
| 1441 |
+
"current_section_idx": 0,
|
| 1442 |
+
"generated_sections": {},
|
| 1443 |
+
"panel_audit_retries": panel_retries + 1,
|
| 1444 |
+
"pipeline_phase": "audit_correction",
|
| 1445 |
+
"global_audit_result": audit_dump,
|
| 1446 |
+
}
|
| 1447 |
+
|
| 1448 |
+
guard_block = export_status == "blocked"
|
| 1449 |
+
human_review = bool(getattr(result, "human_review_required", False))
|
| 1450 |
+
if export_status == "warning" or (not is_approved and export_status != "blocked"):
|
| 1451 |
+
human_review = human_review or bool(issues)
|
| 1452 |
+
elif guard_block:
|
| 1453 |
+
human_review = True
|
| 1454 |
+
|
| 1455 |
+
if guard_block and panel_retries >= MAX_PANEL_AUDIT_RETRIES:
|
| 1456 |
+
traceability["final_audit_verdict"] = [{
|
| 1457 |
+
"passed": False,
|
| 1458 |
+
"score": getattr(result, "overall_score", 0),
|
| 1459 |
+
"human_review_required": True,
|
| 1460 |
+
"reason": "Audyt panelowy niezaliczony po wyczerpaniu prób auto-poprawy.",
|
| 1461 |
+
}]
|
| 1462 |
+
|
| 1463 |
+
return {
|
| 1464 |
+
"traceability_data": traceability,
|
| 1465 |
+
"needs_audit_regen": False,
|
| 1466 |
+
"panel_audit_done": True,
|
| 1467 |
+
"pipeline_phase": "completed",
|
| 1468 |
+
"guard_block": guard_block,
|
| 1469 |
+
"human_review_required": human_review,
|
| 1470 |
+
"global_audit_result": audit_dump,
|
| 1471 |
+
"is_completed": True,
|
| 1472 |
+
}
|
| 1473 |
+
|
| 1474 |
+
def pre_submission_audit(self, state: GeneratorState):
|
| 1475 |
+
"""Faza 14: Finalny audyt gotowego wniosku przed eksportem."""
|
| 1476 |
+
logger.info(f"[Generator] Uruchamiam Red Team Audit dla projektu {state['project_id']}")
|
| 1477 |
+
try:
|
| 1478 |
+
from agents.red_team_auditor import RedTeamAuditor
|
| 1479 |
+
auditor = RedTeamAuditor()
|
| 1480 |
+
audit_result = auditor.audit_application(
|
| 1481 |
+
state.get("generated_sections", {}),
|
| 1482 |
+
state.get("context", ""),
|
| 1483 |
+
state.get("document_type", "Nieznany program")
|
| 1484 |
+
)
|
| 1485 |
+
|
| 1486 |
+
traceability = state.get("traceability_data", {}) or {}
|
| 1487 |
+
traceability["final_audit"] = [audit_result]
|
| 1488 |
+
|
| 1489 |
+
logger.info(f"[Generator] Audyt zakończony. Passed: {audit_result.get('passed')}, Score: {audit_result.get('score')}")
|
| 1490 |
+
|
| 1491 |
+
audit_retries = state.get("audit_retries", 0)
|
| 1492 |
+
|
| 1493 |
+
# Autopilot retry loop
|
| 1494 |
+
if not audit_result.get("passed") and audit_retries < 1:
|
| 1495 |
+
logger.info(f"[Generator] Audyt niezaliczony. Auto-poprawa (retry {audit_retries+1}/1).")
|
| 1496 |
+
|
| 1497 |
+
feedback = "Wyniki audytu Red Team do uwzględnienia w poprawkach:\n"
|
| 1498 |
+
feedback += "Słabe punkty:\n"
|
| 1499 |
+
for w in audit_result.get("weaknesses", []):
|
| 1500 |
+
feedback += f"- {w}\n"
|
| 1501 |
+
feedback += "\nRekomendacje (Feedback):\n"
|
| 1502 |
+
feedback += f"{audit_result.get('feedback', '')}\n"
|
| 1503 |
+
|
| 1504 |
+
new_context = (state.get("additional_context") or "") + "\n\n" + feedback
|
| 1505 |
+
|
| 1506 |
+
return {
|
| 1507 |
+
"traceability_data": traceability,
|
| 1508 |
+
"is_completed": False,
|
| 1509 |
+
"current_section_idx": 0,
|
| 1510 |
+
"audit_retries": audit_retries + 1,
|
| 1511 |
+
"additional_context": new_context
|
| 1512 |
+
}
|
| 1513 |
+
|
| 1514 |
+
# P1: po wyczerpaniu retry, jeśli audyt nadal NIE przeszedł — ustaw TRWAŁY
|
| 1515 |
+
# sygnał blokady/human_review (fail-closed), zamiast po cichu kończyć.
|
| 1516 |
+
if not audit_result.get("passed"):
|
| 1517 |
+
logger.warning(
|
| 1518 |
+
f"[Generator] Audyt niezaliczony po wyczerpaniu retry — blokada eksportu / human review."
|
| 1519 |
+
)
|
| 1520 |
+
traceability["final_audit_verdict"] = [{
|
| 1521 |
+
"passed": False,
|
| 1522 |
+
"score": audit_result.get("score"),
|
| 1523 |
+
"human_review_required": True,
|
| 1524 |
+
"reason": "Red Team Audit niezaliczony po wyczerpaniu prób auto-poprawy.",
|
| 1525 |
+
}]
|
| 1526 |
+
return {
|
| 1527 |
+
"traceability_data": traceability,
|
| 1528 |
+
"guard_block": True,
|
| 1529 |
+
"human_review_required": True,
|
| 1530 |
+
}
|
| 1531 |
+
|
| 1532 |
+
return {
|
| 1533 |
+
"traceability_data": traceability
|
| 1534 |
+
}
|
| 1535 |
+
|
| 1536 |
+
except Exception as e:
|
| 1537 |
+
# FAIL-CLOSED: awaria audytu nie może cicho przepuścić wniosku do eksportu.
|
| 1538 |
+
logger.error(f"[Generator] Błąd audytu pre-submission: {e}")
|
| 1539 |
+
traceability = state.get("traceability_data", {}) or {}
|
| 1540 |
+
traceability["final_audit_verdict"] = [{
|
| 1541 |
+
"passed": False,
|
| 1542 |
+
"human_review_required": True,
|
| 1543 |
+
"reason": f"Awaria mechanizmu audytu końcowego: {str(e)[:200]} (fail-closed).",
|
| 1544 |
+
}]
|
| 1545 |
+
return {
|
| 1546 |
+
"traceability_data": traceability,
|
| 1547 |
+
"guard_block": True,
|
| 1548 |
+
"human_review_required": True,
|
| 1549 |
+
}
|
| 1550 |
+
|
| 1551 |
+
def _should_continue(self, state: GeneratorState):
|
| 1552 |
+
"""Sprawdzak czy istnieją kolejne sekcje do procedowania w Grafie."""
|
| 1553 |
+
plan_len = len(state.get("sections_plan", []))
|
| 1554 |
+
graph_step = state.get("graph_step", 0)
|
| 1555 |
+
max_steps = max(30, plan_len * 6)
|
| 1556 |
+
if graph_step >= max_steps:
|
| 1557 |
+
logger.error(
|
| 1558 |
+
f"[Generator] Osiągnięto limit kroków grafu ({graph_step}/{max_steps}) — wymuszam audyt końcowy."
|
| 1559 |
+
)
|
| 1560 |
+
return "audit"
|
| 1561 |
+
|
| 1562 |
+
if state.get("missing_data_question"):
|
| 1563 |
+
stalls = state.get("section_stall_counts", {}) or {}
|
| 1564 |
+
idx_key = str(state.get("current_section_idx", 0))
|
| 1565 |
+
if stalls.get(idx_key, 0) >= 2:
|
| 1566 |
+
logger.warning(
|
| 1567 |
+
f"[Generator] Pomijam resolve_missing_data — sekcja {idx_key} zablokowana w pętli."
|
| 1568 |
+
)
|
| 1569 |
+
return "continue"
|
| 1570 |
+
return "resolve"
|
| 1571 |
+
|
| 1572 |
+
if state.get("guard_block", False) and not state.get("full_autopilot"):
|
| 1573 |
+
return "review"
|
| 1574 |
+
|
| 1575 |
+
return "audit" if state.get("is_completed") else "continue"
|
| 1576 |
+
|
| 1577 |
+
async def provide_human_response(self, thread_id: str, response: str):
|
| 1578 |
+
"""Aktualizuje stan grafu o odpowiedź użytkownika, zachowując dotychczasowy kontekst (np. GUS)."""
|
| 1579 |
+
graph = await self.get_graph()
|
| 1580 |
+
config = {"configurable": {"thread_id": thread_id}}
|
| 1581 |
+
|
| 1582 |
+
# Pobieramy dotychczasowy stan, żeby nie nadpisać danych z GUS
|
| 1583 |
+
state = await graph.aget_state(config)
|
| 1584 |
+
old_context = state.values.get("additional_context") or ""
|
| 1585 |
+
new_context = old_context + f"\n\n[Odpowiedź Użytkownika / Uzupełnienie danych]:\n{response}\n"
|
| 1586 |
+
|
| 1587 |
+
# Aktualizujemy stan i kasujemy pytanie
|
| 1588 |
+
await graph.aupdate_state(
|
| 1589 |
+
config,
|
| 1590 |
+
{"additional_context": new_context, "missing_data_question": None},
|
| 1591 |
+
as_node="ask_missing_data"
|
| 1592 |
+
)
|
| 1593 |
+
|
| 1594 |
+
async def astm_stream(
|
| 1595 |
+
self,
|
| 1596 |
+
initial_state: Optional[GeneratorState],
|
| 1597 |
+
thread_id: str,
|
| 1598 |
+
resume: bool = False,
|
| 1599 |
+
):
|
| 1600 |
+
"""Uruchamia graf w trybie streamingowym zdarzeń żeby zasilić SSE (Server-Sent Events)"""
|
| 1601 |
+
graph = await self.get_graph()
|
| 1602 |
+
config = {"configurable": {"thread_id": thread_id}, "recursion_limit": 80}
|
| 1603 |
+
|
| 1604 |
+
# Ochrona przed błędem `Received no input for __start__` gdy MemorySaver jest wyczyszczony.
|
| 1605 |
+
state = await graph.aget_state(config)
|
| 1606 |
+
if resume and not state.values:
|
| 1607 |
+
logger.warning(f"Zażądano wznowienia dla wątku {thread_id}, ale brak stanu w checkpointerze. Resetujemy strumień z initial_state.")
|
| 1608 |
+
input_data = initial_state
|
| 1609 |
+
else:
|
| 1610 |
+
input_data = None if resume else initial_state
|
| 1611 |
+
|
| 1612 |
+
async for event in graph.astream_events(
|
| 1613 |
+
input_data, version="v2", config=config
|
| 1614 |
+
):
|
| 1615 |
+
yield event
|
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,862 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
# v5.0 incremental data quality helper (pragmatic, for generated content verification)
|
| 35 |
+
# Detects low-specificity / vague language common in poor grant drafts. Used in post-gen checks.
|
| 36 |
+
from functools import lru_cache
|
| 37 |
+
|
| 38 |
+
@lru_cache(maxsize=128)
|
| 39 |
+
def _compute_basic_generated_data_quality(text: str) -> int:
|
| 40 |
+
"""Cached for token optimization (pure heuristic, repeated calls in flows)."""
|
| 41 |
+
if not text or len(text) < 30:
|
| 42 |
+
return 20
|
| 43 |
+
t = text.lower()
|
| 44 |
+
score = 60
|
| 45 |
+
# Specificity signals (good)
|
| 46 |
+
if any(kw in t for kw in ["%", "zł", "euro", "tys.", "mln", "2026", "miesiąc", "m-c", "osób", "etat", "pkd", "nip"]):
|
| 47 |
+
score += 15
|
| 48 |
+
if any(kw in t for kw in ["zgodnie z §", "regulamin", "pkt ", "załącznik", "kryterium", "kwalifikowalny"]):
|
| 49 |
+
score += 12
|
| 50 |
+
# Vagueness penalties (bad for data quality)
|
| 51 |
+
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"]
|
| 52 |
+
vagueness_hits = sum(1 for v in vague if v in t)
|
| 53 |
+
score -= min(25, vagueness_hits * 7)
|
| 54 |
+
# Length + structure heuristic
|
| 55 |
+
if len(text) > 800:
|
| 56 |
+
score += 8
|
| 57 |
+
if text.count("\n") > 4 or "- " in text:
|
| 58 |
+
score += 5
|
| 59 |
+
# Penalize very generic filler
|
| 60 |
+
if t.count("projekt polega na") > 1 or t.count("celem jest") > 2:
|
| 61 |
+
score -= 10
|
| 62 |
+
return max(25, min(95, int(score)))
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@traceable(
|
| 66 |
+
run_type="chain",
|
| 67 |
+
name="generate_section",
|
| 68 |
+
tags=["rag_pipeline", "faithfulness_target"],
|
| 69 |
+
)
|
| 70 |
+
def generate_section_light(
|
| 71 |
+
section_type: str,
|
| 72 |
+
context: str,
|
| 73 |
+
external_context: dict = None,
|
| 74 |
+
program_name: str = None,
|
| 75 |
+
light_mode: bool = True, # token optimization: early short-circuit for very light use
|
| 76 |
+
) -> str:
|
| 77 |
+
"""
|
| 78 |
+
Lekka, stabilna ścieżka generowania sekcji.
|
| 79 |
+
Używana domyślnie w okienku asystenta projektu i jako fallback.
|
| 80 |
+
light_mode: skips heavy regulation calls for ultra-low token in some flows.
|
| 81 |
+
"""
|
| 82 |
+
from core.llm_router import get_llm
|
| 83 |
+
from langchain_core.messages import HumanMessage
|
| 84 |
+
import logging
|
| 85 |
+
logger = logging.getLogger(__name__)
|
| 86 |
+
|
| 87 |
+
if light_mode and not context:
|
| 88 |
+
context = f"Generuj treść do sekcji {section_type} na podstawie profilu firmy i regulaminu {program_name or ''}."
|
| 89 |
+
|
| 90 |
+
llm = get_llm(task_type="fast")
|
| 91 |
+
|
| 92 |
+
company = {}
|
| 93 |
+
context_block = ""
|
| 94 |
+
try:
|
| 95 |
+
if external_context and isinstance(external_context, dict):
|
| 96 |
+
company = external_context.get("company_data", {}) or {}
|
| 97 |
+
from core.context.project_context import build_project_context
|
| 98 |
+
|
| 99 |
+
ctx = build_project_context(
|
| 100 |
+
{"external_context": external_context, "program_name": program_name}
|
| 101 |
+
)
|
| 102 |
+
context_block = ctx.to_prompt_block(max_chars=3500)
|
| 103 |
+
except Exception:
|
| 104 |
+
company = external_context.get("company_data", {}) if external_context else {}
|
| 105 |
+
|
| 106 |
+
# Uwaga: żadne noty techniczne/telemetryczne nie są doklejane do treści sekcji
|
| 107 |
+
# (treść wniosku ma być czysta i po polsku). Sygnały jakości → metryki/traceability.
|
| 108 |
+
|
| 109 |
+
applicant_block = context_block or (
|
| 110 |
+
"Dane wnioskodawcy:\n"
|
| 111 |
+
f"Nazwa: {company.get('name', 'Wnioskodawca')}\n"
|
| 112 |
+
f"NIP: {company.get('nip', '')}\n"
|
| 113 |
+
f"Województwo: {company.get('voivodeship', '')}\n"
|
| 114 |
+
f"PKD: {', '.join(company.get('pkd', [])[:3]) if company.get('pkd') else ''}"
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
prompt = f"""Jesteś ekspertem przygotowującym wnioski o dofinansowanie.
|
| 118 |
+
|
| 119 |
+
Napisz konkretną, profesjonalną treść do sekcji: **{section_type}**
|
| 120 |
+
|
| 121 |
+
[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).
|
| 122 |
+
|
| 123 |
+
Kontekst projektu:
|
| 124 |
+
{context[:4000] if context else "Brak szczegółowego opisu"}
|
| 125 |
+
|
| 126 |
+
{applicant_block}
|
| 127 |
+
|
| 128 |
+
Program: {program_name or 'wniosek dotacyjny'}
|
| 129 |
+
|
| 130 |
+
Pisz po polsku, merytorycznie, w stylu urzędowego wniosku. Bądź konkretny.
|
| 131 |
+
Uwzględnij podstawowe ugruntowanie w regulaminie jeśli dostępne (Regulation Engine v5.0)."""
|
| 132 |
+
|
| 133 |
+
last_error = None
|
| 134 |
+
for attempt in range(3):
|
| 135 |
+
try:
|
| 136 |
+
resp = llm.invoke([HumanMessage(content=prompt)])
|
| 137 |
+
content = resp.content if hasattr(resp, "content") else str(resp)
|
| 138 |
+
if content and len(content.strip()) > 20:
|
| 139 |
+
return content
|
| 140 |
+
except Exception as e:
|
| 141 |
+
last_error = str(e)
|
| 142 |
+
logger.warning(f"[generate_section_light] Próba {attempt+1}/3 nieudana: {e}")
|
| 143 |
+
|
| 144 |
+
# High-stability fallback: always return usable professional stub (never crash UI)
|
| 145 |
+
fallback = f"""### {section_type}
|
| 146 |
+
|
| 147 |
+
**Wnioskodawca:** {company.get('name', 'Wnioskodawca')}
|
| 148 |
+
**Program:** {program_name or 'wniosek dotacyjny'}
|
| 149 |
+
|
| 150 |
+
Treść sekcji wymaga uzupełnienia na podstawie szczegółowej analizy regulaminu i dokumentacji projektu.
|
| 151 |
+
|
| 152 |
+
[UZUPEŁNIĆ: Wstaw merytoryczną treść zgodną z kryteriami programu i danymi z GUS/KRS]
|
| 153 |
+
|
| 154 |
+
*Wygenerowano w trybie awaryjnym lekkim (stabilny fallback).*
|
| 155 |
+
"""
|
| 156 |
+
if last_error:
|
| 157 |
+
logger.error(f"[generate_section_light] Użyto fallbacku po błędach: {last_error}")
|
| 158 |
+
final_content = content if 'content' in locals() and content else fallback
|
| 159 |
+
|
| 160 |
+
# v5.0 incremental: Post-generation CitationVerifier + basic grounding check on generated content (for light path too)
|
| 161 |
+
# Stronger verification of generated text against regulations. Non-blocking, attaches note if program known.
|
| 162 |
+
try:
|
| 163 |
+
if program_name and isinstance(final_content, str) and len(final_content) > 80:
|
| 164 |
+
from core.search.regulation_engine import citation_verifier
|
| 165 |
+
if citation_verifier:
|
| 166 |
+
verif = citation_verifier.verify_text_citations(final_content[:4500], program_name, sample_claims=None)
|
| 167 |
+
gscore = verif.get("overall_citation_score", 0.0) if isinstance(verif, dict) else getattr(verif, "overall_citation_score", 0.0)
|
| 168 |
+
# Sygnał jakości trafia WYŁĄCZNIE do metryk — nie do treści wniosku.
|
| 169 |
+
metrics.record_quality_signal(
|
| 170 |
+
"generation_light_grounding", round(float(gscore or 0.0), 3),
|
| 171 |
+
{"program": program_name or "unknown"}
|
| 172 |
+
)
|
| 173 |
+
if gscore > 0.7:
|
| 174 |
+
metrics.increment("generation.light_grounding_good", tags={"program": program_name or "unknown"})
|
| 175 |
+
except Exception as _v5e:
|
| 176 |
+
logger.debug(f"[v5.0 light verif] skipped: {_v5e}")
|
| 177 |
+
return final_content
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def generate_section(
|
| 181 |
+
project_id: str,
|
| 182 |
+
section_type: str,
|
| 183 |
+
context: str,
|
| 184 |
+
external_context: dict = None,
|
| 185 |
+
program_name: str = None,
|
| 186 |
+
user_id: str = "",
|
| 187 |
+
) -> str:
|
| 188 |
+
"""
|
| 189 |
+
Wywołuje logikę Wizard z RAG tylko dla pojedynczej sekcji, bez przelotu przez cały Graf.
|
| 190 |
+
"""
|
| 191 |
+
from core.telemetry import telemetry
|
| 192 |
+
|
| 193 |
+
t0 = time.time()
|
| 194 |
+
metrics.increment("generation.generate_section_calls", tags={"section_type": section_type or "unknown", "has_program": str(bool(program_name))})
|
| 195 |
+
telemetry.log(
|
| 196 |
+
"INFO",
|
| 197 |
+
"Helpers",
|
| 198 |
+
f"Rozpoczynamy generowanie sekcji: {section_type}",
|
| 199 |
+
{"project_id": project_id},
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
company_data_str = ""
|
| 203 |
+
if external_context:
|
| 204 |
+
if (
|
| 205 |
+
"project_description" in external_context
|
| 206 |
+
and external_context["project_description"]
|
| 207 |
+
):
|
| 208 |
+
company_data_str += f"INFORMACJE OGÓLNE O PROJEKCIE (wpisane przez użytkownika):\n{external_context['project_description']}\n\n"
|
| 209 |
+
if (
|
| 210 |
+
"current_section_content" in external_context
|
| 211 |
+
and external_context["current_section_content"]
|
| 212 |
+
):
|
| 213 |
+
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"
|
| 214 |
+
if "company_data" in external_context:
|
| 215 |
+
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"
|
| 216 |
+
if "resources" in external_context and external_context["resources"]:
|
| 217 |
+
company_data_str += "Zasoby projektu (dostarczone pliki):\n"
|
| 218 |
+
for res in external_context["resources"]:
|
| 219 |
+
# Limitujemy tekst wyciągnięty z pliku żeby nie wysadzić okna kontekstowego dla gigantycznych plików (opcjonalnie, ale dobra praktyka)
|
| 220 |
+
text_clip = res.get("extracted_text") or ""
|
| 221 |
+
if len(text_clip) > 5000:
|
| 222 |
+
text_clip = text_clip[:5000] + "... [UKRÓCONO]"
|
| 223 |
+
company_data_str += (
|
| 224 |
+
f"--- PLIK: {res.get('filename')} ---\n{text_clip}\n\n"
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
if company_data_str:
|
| 228 |
+
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"
|
| 229 |
+
|
| 230 |
+
program_context = (
|
| 231 |
+
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"
|
| 232 |
+
if program_name
|
| 233 |
+
else ""
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
# === Faza 1 / Punkt 2 + Faza 3: Regulation Context + Structured Engine Rules ===
|
| 237 |
+
regulation_section_context = ""
|
| 238 |
+
if program_name:
|
| 239 |
+
try:
|
| 240 |
+
from core.search.regulation_context_provider import get_regulation_context_for_candidates, format_regulation_context_for_prompt
|
| 241 |
+
|
| 242 |
+
fake_candidates = [{"id": "current_section", "program": program_name, "name": section_type}]
|
| 243 |
+
reg_ctx = get_regulation_context_for_candidates(
|
| 244 |
+
fake_candidates,
|
| 245 |
+
project_description=context,
|
| 246 |
+
k_per_grant=3
|
| 247 |
+
)
|
| 248 |
+
regulation_section_context = "\n\n" + format_regulation_context_for_prompt(reg_ctx, "current_section")
|
| 249 |
+
|
| 250 |
+
# Nowe: Structured rules + eligibility checker z Engine (szczególnie ważne przy budżecie)
|
| 251 |
+
rules = regulation_engine.get_structured_rules_for_program(program_name)
|
| 252 |
+
if rules and rules.get("key_rules"):
|
| 253 |
+
regulation_section_context += "\n\nStrukturalne reguły z Regulation Engine:\n" + "\n".join(rules["key_rules"][:5])
|
| 254 |
+
|
| 255 |
+
# Trust Score context (Cycle 9)
|
| 256 |
+
try:
|
| 257 |
+
ext = external_context or {}
|
| 258 |
+
ts = compute_grant_trust_score({
|
| 259 |
+
"regulation_link_quality": ext.get("regulation_link_quality", "medium"),
|
| 260 |
+
"precise_regulation_url": ext.get("precise_regulation_url")
|
| 261 |
+
})
|
| 262 |
+
regulation_section_context += f"\n\n[Trust Score dla tego programu: {ts}/100 — im wyższy, tym bezpieczniej cytować regulamin]"
|
| 263 |
+
except Exception:
|
| 264 |
+
pass
|
| 265 |
+
|
| 266 |
+
# Bezpośrednie użycie checkera kosztów przy sekcjach budżetowych (aktywna integracja Faza 3)
|
| 267 |
+
if section_type in ["budget", "budget_details", "koszty", "montaż finansowy"] and context:
|
| 268 |
+
try:
|
| 269 |
+
eligibility = regulation_engine.check_cost_eligibility(program_name or "", context[:1500])
|
| 270 |
+
if eligibility.get("status") == "evaluated":
|
| 271 |
+
reg_text = eligibility.get('raw_response', '')
|
| 272 |
+
regulation_section_context += f"\n\nWeryfikacja kwalifikowalności kosztów (Regulation Engine):\n{reg_text}"
|
| 273 |
+
|
| 274 |
+
# Aktywne zachowanie: jeśli silnik widzi ryzyko — dajemy silną instrukcję generatorowi
|
| 275 |
+
if "Niekwalifikowalny" in reg_text or "Wymaga weryfikacji" in reg_text:
|
| 276 |
+
regulation_section_context += (
|
| 277 |
+
"\n\nWAŻNA INSTRUKCJA: Powyższa weryfikacja Regulation Engine wskazuje na potencjalne ryzyko niekwalifikowalności. "
|
| 278 |
+
"Bądź bardzo konserwatywny w opisie kosztów. Oznacz elementy wymagające potwierdzenia przez użytkownika. "
|
| 279 |
+
"Nie twierdź kategorycznie, że koszty są kwalifikowalne jeśli silnik ma wątpliwości."
|
| 280 |
+
)
|
| 281 |
+
except Exception:
|
| 282 |
+
pass
|
| 283 |
+
except Exception as e:
|
| 284 |
+
import logging
|
| 285 |
+
logging.getLogger(__name__).warning(f"Nie udało się pobrać regulation context dla sekcji: {e}")
|
| 286 |
+
|
| 287 |
+
# === Faza 3 final (cykl automatyczny): Regulation Grounding Certificate (najwyższa wiarygodność) ===
|
| 288 |
+
grounding_certificate = ""
|
| 289 |
+
try:
|
| 290 |
+
from core.search.regulation_snapshot import regulation_snapshot_store
|
| 291 |
+
|
| 292 |
+
snap = regulation_snapshot_store.get_latest_for_program(program_name or "") if program_name else None
|
| 293 |
+
cert_lines = []
|
| 294 |
+
|
| 295 |
+
if snap:
|
| 296 |
+
cert_lines.append("REGULATION GROUNDING CERTIFICATE v1")
|
| 297 |
+
cert_lines.append(f"Program: {program_name}")
|
| 298 |
+
cert_lines.append(f"Snapshot ID: {snap.id}")
|
| 299 |
+
cert_lines.append(f"Version Hash: {snap.version_hash}")
|
| 300 |
+
cert_lines.append(f"Fetched: {snap.fetched_at}")
|
| 301 |
+
cert_lines.append(f"Source: {snap.source_url[:80]}")
|
| 302 |
+
if snap.effective_date:
|
| 303 |
+
cert_lines.append(f"Effective Date: {snap.effective_date}")
|
| 304 |
+
if snap.document_version:
|
| 305 |
+
cert_lines.append(f"Document Version: {snap.document_version}")
|
| 306 |
+
if snap.source_institution:
|
| 307 |
+
cert_lines.append(f"Institution: {snap.source_institution}")
|
| 308 |
+
cert_lines.append(f"Key Rules Cited: {len(snap.key_rules or [])}")
|
| 309 |
+
cert_lines.append(f"Exclusions Known: {len(snap.exclusions or [])}")
|
| 310 |
+
|
| 311 |
+
# Dodaj wynik aktywnego checku silnika (jeśli dotyczy budżetu lub kwalifikowalności)
|
| 312 |
+
if section_type in ["budget", "budget_details", "koszty", "montaż finansowy", "kwalifikowalność"] and program_name:
|
| 313 |
+
try:
|
| 314 |
+
el = regulation_engine.check_cost_eligibility(program_name, context[:1200] if context else "")
|
| 315 |
+
if el.get("status") == "evaluated":
|
| 316 |
+
cert_lines.append(f"Engine Cost Check: eligible={el.get('eligible')} severity={el.get('severity')}")
|
| 317 |
+
cert_lines.append(f"Engine Ref: {el.get('regulation_reference', '')[:120]}")
|
| 318 |
+
except Exception:
|
| 319 |
+
pass
|
| 320 |
+
|
| 321 |
+
if cert_lines:
|
| 322 |
+
grounding_certificate = "\n\n" + "\n".join(cert_lines) + "\n--- END CERTIFICATE ---\n"
|
| 323 |
+
# Dodajemy też do kontekstu promptu, żeby model wiedział, że musi to zachować
|
| 324 |
+
regulation_section_context += grounding_certificate
|
| 325 |
+
except Exception as cert_e:
|
| 326 |
+
import logging
|
| 327 |
+
logging.getLogger(__name__).debug(f"Grounding certificate generation skipped: {cert_e}")
|
| 328 |
+
|
| 329 |
+
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."
|
| 330 |
+
|
| 331 |
+
tenant_ns = f"tenant_{user_id}_{project_id}" if user_id and project_id else ""
|
| 332 |
+
|
| 333 |
+
_cd = (external_context or {}).get("company_data", {}) if external_context else {}
|
| 334 |
+
_pkd = _cd.get("pkd_codes") or _cd.get("pkd") or []
|
| 335 |
+
if isinstance(_pkd, str):
|
| 336 |
+
_pkd = [_pkd]
|
| 337 |
+
_pkd = [p for p in _pkd if p and "brak pkd" not in str(p).lower()]
|
| 338 |
+
state = AgentState(
|
| 339 |
+
messages=[HumanMessage(content=initial_prompt)],
|
| 340 |
+
user_id=user_id,
|
| 341 |
+
tenant_id=tenant_ns,
|
| 342 |
+
profile=CompanyProfile(
|
| 343 |
+
nip=_cd.get("nip", "0000000000") or "0000000000",
|
| 344 |
+
name=_cd.get("name", "") or "",
|
| 345 |
+
regon=_cd.get("regon", "") or "",
|
| 346 |
+
krs=_cd.get("krs", "") or "",
|
| 347 |
+
legal_form=_cd.get("legal_form", "") or "",
|
| 348 |
+
pkd_codes=_pkd,
|
| 349 |
+
region=_cd.get("voivodeship") or _cd.get("region") or "Mazowieckie",
|
| 350 |
+
size=_cd.get("size") or "MŚP",
|
| 351 |
+
),
|
| 352 |
+
)
|
| 353 |
+
|
| 354 |
+
from core.utils import extract_markdown_and_sanitize
|
| 355 |
+
from core.circuit_breaker import with_llm_retry, llm_circuit_breaker
|
| 356 |
+
import logging
|
| 357 |
+
|
| 358 |
+
logger = logging.getLogger(__name__)
|
| 359 |
+
|
| 360 |
+
@llm_circuit_breaker
|
| 361 |
+
@with_llm_retry
|
| 362 |
+
def invoke_with_watchdog():
|
| 363 |
+
result = wizard_node(state)
|
| 364 |
+
new_messages = result.get("messages", [])
|
| 365 |
+
if new_messages and len(new_messages) > 0:
|
| 366 |
+
last_msg = new_messages[-1]
|
| 367 |
+
if isinstance(last_msg, dict) and "content" in last_msg:
|
| 368 |
+
raw_c = last_msg["content"]
|
| 369 |
+
elif hasattr(last_msg, "content"):
|
| 370 |
+
raw_c = last_msg.content
|
| 371 |
+
else:
|
| 372 |
+
raw_c = ""
|
| 373 |
+
|
| 374 |
+
# Weryfikujemy i wyciągamy Markdown
|
| 375 |
+
sanitized = extract_markdown_and_sanitize(raw_c)
|
| 376 |
+
|
| 377 |
+
# Sanity Checks: Blokada pustych odpowiedzi i typowych odmów
|
| 378 |
+
if not sanitized or len(sanitized.strip()) < 20:
|
| 379 |
+
logger.warning(
|
| 380 |
+
f"Watchdog: Otrzymano zbyt krótką/pustą odpowiedź (długość: {len(sanitized)}). Wymuszam ponowienie."
|
| 381 |
+
)
|
| 382 |
+
telemetry.log(
|
| 383 |
+
"WARN",
|
| 384 |
+
"Watchdog",
|
| 385 |
+
"Zbyt krótka odpowiedź. Wymuszam ponowienie.",
|
| 386 |
+
{"project_id": project_id},
|
| 387 |
+
)
|
| 388 |
+
raise ValueError(
|
| 389 |
+
"Błąd sanity check: Pusta lub zbyt krótka odpowiedź z modelu."
|
| 390 |
+
)
|
| 391 |
+
|
| 392 |
+
lower_c = sanitized.lower()
|
| 393 |
+
refusals = [
|
| 394 |
+
"nie potrafię",
|
| 395 |
+
"nie jestem w stanie",
|
| 396 |
+
"nie mogę",
|
| 397 |
+
"as an ai",
|
| 398 |
+
"jako model językowy",
|
| 399 |
+
]
|
| 400 |
+
if any(r in lower_c for r in refusals) and len(sanitized) < 200:
|
| 401 |
+
logger.warning(
|
| 402 |
+
"Watchdog: Wykryto typową odmowę LLM. Wymuszam ponowienie."
|
| 403 |
+
)
|
| 404 |
+
raise ValueError(
|
| 405 |
+
"Błąd sanity check: Model odmówił wygenerowania odpowiedzi."
|
| 406 |
+
)
|
| 407 |
+
|
| 408 |
+
# === Faza 3: Wymuszony Regulation Grounding Certificate na wyjściu (nawet jeśli LLM pominął) ===
|
| 409 |
+
if grounding_certificate and "--- END CERTIFICATE ---" not in sanitized:
|
| 410 |
+
sanitized = sanitized.rstrip() + "\n\n" + grounding_certificate.strip()
|
| 411 |
+
|
| 412 |
+
# Foundational quality signal for LLMOps
|
| 413 |
+
has_grounding = bool(grounding_certificate) or ("CERTIFICATE" in sanitized.upper() or "Regulation Grounding" in sanitized)
|
| 414 |
+
metrics.record_quality_signal(
|
| 415 |
+
"generation_grounding_certificate_present",
|
| 416 |
+
has_grounding,
|
| 417 |
+
{"section_type": section_type, "project_id": project_id, "program": program_name}
|
| 418 |
+
)
|
| 419 |
+
duration_ms = (time.time() - t0) * 1000
|
| 420 |
+
metrics.record_latency("generation.generate_section", duration_ms, tags={"section_type": section_type or "unknown", "grounded": str(has_grounding)})
|
| 421 |
+
|
| 422 |
+
# v5.0: Stronger post-generation verification of generated content (citation/grounding + basic data quality)
|
| 423 |
+
# Pragmatic: run CitationVerifier + simple heuristics on the output before return. Attaches metadata note.
|
| 424 |
+
try:
|
| 425 |
+
if program_name and isinstance(sanitized, str) and len(sanitized) > 60:
|
| 426 |
+
from core.search.regulation_engine import citation_verifier, kruczkowski_trap_agent
|
| 427 |
+
if citation_verifier:
|
| 428 |
+
verif = citation_verifier.verify_text_citations(sanitized[:5000], program_name)
|
| 429 |
+
gscore = verif.get("overall_citation_score", 0.0) if isinstance(verif, dict) else getattr(verif, "overall_citation_score", 0.0)
|
| 430 |
+
# Wynik weryfikacji → metryki (metadane), NIE do treści wniosku.
|
| 431 |
+
metrics.record_quality_signal(
|
| 432 |
+
"generation_postgen_citation", round(float(gscore or 0.0), 3),
|
| 433 |
+
{"section": section_type, "project": project_id}
|
| 434 |
+
)
|
| 435 |
+
if gscore < 0.5:
|
| 436 |
+
metrics.increment("generation.postgen_low_grounding", tags={"section": section_type or "unknown"})
|
| 437 |
+
# Data quality heuristic for generated text (prefer v5.0 central in CitationVerifier)
|
| 438 |
+
dq = 55
|
| 439 |
+
try:
|
| 440 |
+
from core.search.regulation_engine import citation_verifier
|
| 441 |
+
if citation_verifier and hasattr(citation_verifier, "compute_generated_content_data_quality"):
|
| 442 |
+
dq_res = citation_verifier.compute_generated_content_data_quality(sanitized, program_name)
|
| 443 |
+
dq = dq_res.get("data_quality_score", 55)
|
| 444 |
+
except Exception:
|
| 445 |
+
dq = _compute_basic_generated_data_quality(sanitized) # local fallback
|
| 446 |
+
if dq < 55:
|
| 447 |
+
metrics.increment("generation.low_data_quality", tags={"section": section_type or "unknown"})
|
| 448 |
+
elif dq > 75:
|
| 449 |
+
metrics.increment("generation.high_data_quality", tags={"section": section_type})
|
| 450 |
+
if kruczkowski_trap_agent:
|
| 451 |
+
# Light trap check on gen content for compliance layer (non blocking) — sygnał do metryk.
|
| 452 |
+
try:
|
| 453 |
+
trap = kruczkowski_trap_agent.detect_traps(sanitized[:3000], program_name or "")
|
| 454 |
+
if trap.get("overall_trap_risk") in ("high", "critical"):
|
| 455 |
+
metrics.increment(
|
| 456 |
+
"generation.postgen_trap_detected",
|
| 457 |
+
tags={"risk": trap.get("overall_trap_risk"), "section": section_type or "unknown"},
|
| 458 |
+
)
|
| 459 |
+
except Exception:
|
| 460 |
+
pass
|
| 461 |
+
except Exception as _v5post:
|
| 462 |
+
logger.debug(f"[v5.0 post-gen verif] non-fatal: {_v5post}")
|
| 463 |
+
try:
|
| 464 |
+
from core.telemetry import metrics as _m
|
| 465 |
+
if _m:
|
| 466 |
+
_m.record_error("helpers.generate_section", "v5_post_gen_verify", str(_v5post)[:200], severity="warning")
|
| 467 |
+
except Exception:
|
| 468 |
+
pass
|
| 469 |
+
|
| 470 |
+
# v5.0 Production LLMOps: per-query/per-project token cost est + citation faithfulness + user_satisfaction stub for full flow (post verif)
|
| 471 |
+
try:
|
| 472 |
+
token_est = max(50, len((sanitized or "").split()) * 1.3)
|
| 473 |
+
faithfulness = gscore if "gscore" in locals() else 0.65
|
| 474 |
+
sat_stub = round(0.62 + 0.33 * min(1.0, float(faithfulness)), 2)
|
| 475 |
+
metrics.record_latency("generation.est_token_cost", token_est, tags={"project_id": str(project_id or "unknown")})
|
| 476 |
+
metrics.record_quality_signal("generation_citation_faithfulness", round(float(faithfulness), 3), {"project": project_id, "section": section_type})
|
| 477 |
+
metrics.record_quality_signal("user_satisfaction_proxy", sat_stub, {"project": project_id, "via": "postgen_grounding"})
|
| 478 |
+
metrics.increment("llmops.generation_full_flows", tags={"project": str(project_id or "")})
|
| 479 |
+
# Hardened enterprise signals
|
| 480 |
+
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 "")})
|
| 481 |
+
metrics.record_faithfulness_trend(float(faithfulness), {"via": "helpers_generate", "section": section_type})
|
| 482 |
+
metrics.record_fallback_rate("generation", rate=0.05 if "fallback" in str(locals()).lower() else 0.0)
|
| 483 |
+
except Exception:
|
| 484 |
+
pass
|
| 485 |
+
|
| 486 |
+
return sanitized
|
| 487 |
+
raise ValueError("Brak odpowiedzi tekstowej w strukturze wizarda")
|
| 488 |
+
|
| 489 |
+
try:
|
| 490 |
+
return invoke_with_watchdog()
|
| 491 |
+
except Exception as e:
|
| 492 |
+
logger.error(f"Nie powiodła się generacja sekcji (wizard_node + RAG): {e}", exc_info=True)
|
| 493 |
+
telemetry.log(
|
| 494 |
+
"ERROR", "Helpers", f"Błąd generacji sekcji: {str(e)}", {"project_id": project_id, "section": section_type}
|
| 495 |
+
)
|
| 496 |
+
metrics.record_error("Helpers", "generate_section_wizard_rag", str(e), severity="error")
|
| 497 |
+
|
| 498 |
+
# Fallback: prostsza generacja bezpośrednia LLM bez ciężkiego RAG/wizard_node
|
| 499 |
+
try:
|
| 500 |
+
from core.llm_router import get_llm
|
| 501 |
+
from langchain_core.messages import HumanMessage as FallbackHumanMessage
|
| 502 |
+
|
| 503 |
+
llm = get_llm(task_type="fast")
|
| 504 |
+
simple_prompt = f"""Napisz profesjonalną treść do sekcji '{section_type}' wniosku dotacyjnego.
|
| 505 |
+
|
| 506 |
+
Kontekst projektu:
|
| 507 |
+
{context[:3000] if context else "Brak szczegółowego kontekstu"}
|
| 508 |
+
|
| 509 |
+
Dane firmy:
|
| 510 |
+
{json.dumps(external_context.get("company_data", {}), ensure_ascii=False) if external_context else "Brak danych firmy"}
|
| 511 |
+
|
| 512 |
+
Program: {program_name or "Ogólny"}
|
| 513 |
+
|
| 514 |
+
Napisz po polsku, konkretnie i merytorycznie. Jeśli to możliwe, uwzględnij podstawowe wymogi kwalifikowalności."""
|
| 515 |
+
|
| 516 |
+
response = llm.invoke([FallbackHumanMessage(content=simple_prompt)])
|
| 517 |
+
content = response.content if hasattr(response, "content") else str(response)
|
| 518 |
+
|
| 519 |
+
if grounding_certificate:
|
| 520 |
+
content += "\n\n" + grounding_certificate
|
| 521 |
+
|
| 522 |
+
logger.warning(f"[Helpers] Użyto fallbackowej generacji sekcji {section_type} po awarii głównej ścieżki.")
|
| 523 |
+
base_fb = content + "\n\n---\n*Wygenerowano w trybie awaryjnym (uproszczonym). Zalecana weryfikacja.*"
|
| 524 |
+
try:
|
| 525 |
+
from core.telemetry import metrics as _m
|
| 526 |
+
_m.record_fallback_rate("generation_fallback", rate=1.0, tags={"section": section_type})
|
| 527 |
+
except Exception:
|
| 528 |
+
pass
|
| 529 |
+
|
| 530 |
+
# v5.0: Apply light citation/data quality note even on fallback generated content
|
| 531 |
+
try:
|
| 532 |
+
if program_name and len(base_fb) > 50:
|
| 533 |
+
from core.search.regulation_engine import citation_verifier
|
| 534 |
+
if citation_verifier:
|
| 535 |
+
ver = citation_verifier.verify_text_citations(base_fb[:2000], program_name)
|
| 536 |
+
sc = ver.get("overall_citation_score", 0.4) if isinstance(ver, dict) else 0.4
|
| 537 |
+
# Sygnał ugruntowania fallbacku → metryki, nie do treści.
|
| 538 |
+
metrics.record_quality_signal(
|
| 539 |
+
"generation_fallback_grounding", round(float(sc or 0.0), 3),
|
| 540 |
+
{"section": section_type}
|
| 541 |
+
)
|
| 542 |
+
except Exception:
|
| 543 |
+
pass
|
| 544 |
+
return base_fb
|
| 545 |
+
|
| 546 |
+
except Exception as fallback_e:
|
| 547 |
+
logger.error(f"Fallback generation also failed: {fallback_e}")
|
| 548 |
+
metrics.record_error("Helpers", "generate_section_fallback", str(fallback_e), severity="error")
|
| 549 |
+
|
| 550 |
+
# Ostateczny komunikat
|
| 551 |
+
if grounding_certificate:
|
| 552 |
+
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}"
|
| 553 |
+
duration_ms = (time.time() - t0) * 1000
|
| 554 |
+
metrics.record_latency("generation.generate_section", duration_ms, tags={"status": "failed_final"})
|
| 555 |
+
return "Nie powiodła się generacja sekcji po wyczerpaniu prób. Przepraszamy za utrudnienia. Spróbuj ponownie później lub z prostszym promptem."
|
| 556 |
+
|
| 557 |
+
|
| 558 |
+
@traceable(
|
| 559 |
+
run_type="chain", name="review_section", tags=["rag_pipeline", "faithfulness_eval"]
|
| 560 |
+
)
|
| 561 |
+
def review_section(project_id: str, section_id: str, content: str) -> CriticFeedback:
|
| 562 |
+
"""
|
| 563 |
+
Wywołuje logikę recenzenta Critic w celu ewaluacji dostarczonego tekstu wniosku.
|
| 564 |
+
"""
|
| 565 |
+
from core.telemetry import telemetry
|
| 566 |
+
|
| 567 |
+
telemetry.log(
|
| 568 |
+
"INFO",
|
| 569 |
+
"Helpers",
|
| 570 |
+
f"Rozpoczynamy recenzję sekcji: {section_id}",
|
| 571 |
+
{"project_id": project_id},
|
| 572 |
+
)
|
| 573 |
+
|
| 574 |
+
# Critic expects the text to be in the last AI message
|
| 575 |
+
state = AgentState(
|
| 576 |
+
messages=[AIMessage(content=content)],
|
| 577 |
+
user_id="",
|
| 578 |
+
tenant_id="",
|
| 579 |
+
critic_iterations=0,
|
| 580 |
+
)
|
| 581 |
+
|
| 582 |
+
from core.circuit_breaker import with_llm_retry, llm_circuit_breaker
|
| 583 |
+
import logging
|
| 584 |
+
|
| 585 |
+
logger = logging.getLogger(__name__)
|
| 586 |
+
|
| 587 |
+
@llm_circuit_breaker
|
| 588 |
+
@with_llm_retry
|
| 589 |
+
def invoke_critic():
|
| 590 |
+
result = critic_node(state)
|
| 591 |
+
critic_eval = result.get("critic_evaluation")
|
| 592 |
+
if not critic_eval:
|
| 593 |
+
raise ValueError("Brak feedbacku od krytyka")
|
| 594 |
+
|
| 595 |
+
# Sanity check
|
| 596 |
+
if (
|
| 597 |
+
critic_eval.feedback
|
| 598 |
+
and len(critic_eval.feedback.strip()) < 10
|
| 599 |
+
and not critic_eval.is_approved
|
| 600 |
+
):
|
| 601 |
+
logger.warning(
|
| 602 |
+
"Watchdog: Pusty feedback mimo odrzucenia. Wymuszam ponowienie."
|
| 603 |
+
)
|
| 604 |
+
telemetry.log(
|
| 605 |
+
"WARN",
|
| 606 |
+
"Watchdog",
|
| 607 |
+
"Pusty feedback. Wymuszam ponowienie.",
|
| 608 |
+
{"project_id": project_id},
|
| 609 |
+
)
|
| 610 |
+
raise ValueError(
|
| 611 |
+
"Błąd sanity check: Krytyk odrzucił tekst, ale nie podał powodu."
|
| 612 |
+
)
|
| 613 |
+
|
| 614 |
+
return critic_eval
|
| 615 |
+
|
| 616 |
+
try:
|
| 617 |
+
return invoke_critic()
|
| 618 |
+
except Exception as e:
|
| 619 |
+
logger.error(f"Krytyk zawiódł po 5 próbach: {e}")
|
| 620 |
+
return CriticFeedback(
|
| 621 |
+
is_approved=True,
|
| 622 |
+
feedback="Brak feedbacku - problem techniczny. Zatwierdzono warunkowo.",
|
| 623 |
+
severity="low",
|
| 624 |
+
)
|
| 625 |
+
|
| 626 |
+
|
| 627 |
+
@traceable(run_type="chain", name="project_qa_agent")
|
| 628 |
+
def project_qa_agent(
|
| 629 |
+
project_id: str,
|
| 630 |
+
question: str,
|
| 631 |
+
program_name: str,
|
| 632 |
+
context: str,
|
| 633 |
+
external_context: dict = None,
|
| 634 |
+
) -> dict:
|
| 635 |
+
"""
|
| 636 |
+
Weryfikator - odpowiada na pytania związane z projektem na bazie dokumentacji konkursowej i regulaminów (RAG)
|
| 637 |
+
oraz kontekstu samego projektu (zdefiniowane sekcje wniosku).
|
| 638 |
+
Zwraca ustrukturyzowaną odpowiedź w formacie słownika.
|
| 639 |
+
"""
|
| 640 |
+
hard_filter = {"program_name": program_name} if program_name else None
|
| 641 |
+
# Use multi-stage retrieval (broad recall for reranker candidates) — incremental high-precision improvement
|
| 642 |
+
retriever = get_hybrid_retriever(
|
| 643 |
+
metadata_filter=hard_filter,
|
| 644 |
+
retrieval_k=16,
|
| 645 |
+
rerank_top_n=5,
|
| 646 |
+
use_reranker=True,
|
| 647 |
+
)
|
| 648 |
+
|
| 649 |
+
# Rozszerzamy zapytanie do wektorów o program i dotacje by zwiększyć precyzję wyszukiwania
|
| 650 |
+
search_query = f"{program_name} {question}" if program_name else question
|
| 651 |
+
|
| 652 |
+
rag_context = ""
|
| 653 |
+
sources_used = []
|
| 654 |
+
if retriever:
|
| 655 |
+
try:
|
| 656 |
+
# Wyszukanie odpowiednich dokumentów w RAG (already reranked internally if staged)
|
| 657 |
+
docs = retriever.invoke(search_query)
|
| 658 |
+
# Additional explicit rerank pass (defensive; safe if already high quality)
|
| 659 |
+
reranked_docs = rerank_documents(search_query, docs, top_n=4)
|
| 660 |
+
def _format_temporal(d):
|
| 661 |
+
valid_from = d.metadata.get("valid_from", "")
|
| 662 |
+
valid_to = d.metadata.get("valid_to", "")
|
| 663 |
+
ver_id = d.metadata.get("version_id", "")
|
| 664 |
+
time_str = ""
|
| 665 |
+
if valid_from or valid_to or ver_id:
|
| 666 |
+
time_str = f" [Wersja: {ver_id}, Ważne od: {valid_from} do: {valid_to}]"
|
| 667 |
+
return f"SOURCE ({d.metadata.get('source', 'Unknown')} | {d.metadata.get('program_name', 'System')}){time_str}: {d.page_content}"
|
| 668 |
+
|
| 669 |
+
rag_context = "\n\n".join([_format_temporal(d) for d in reranked_docs])
|
| 670 |
+
|
| 671 |
+
# Zbudowanie listy unikalnych źrodeł
|
| 672 |
+
unique_sources = set()
|
| 673 |
+
for d in reranked_docs:
|
| 674 |
+
src_name = d.metadata.get("source", "Nieznane źródło")
|
| 675 |
+
if src_name:
|
| 676 |
+
unique_sources.add(src_name)
|
| 677 |
+
sources_used = list(unique_sources)
|
| 678 |
+
except Exception as e:
|
| 679 |
+
rag_context = f"[Brak wyników z bazy wiedzy. Błąd RAG: {str(e)}]"
|
| 680 |
+
else:
|
| 681 |
+
rag_context = "[Baza wektorowa niedostępna]"
|
| 682 |
+
|
| 683 |
+
from schemas import ProjectQAResponse
|
| 684 |
+
|
| 685 |
+
# Używamy modelu o wysokiej precyzji do analityki
|
| 686 |
+
llm = get_llm(task_type="critical", structured_output_schema=ProjectQAResponse)
|
| 687 |
+
|
| 688 |
+
company_info = ""
|
| 689 |
+
if external_context:
|
| 690 |
+
if (
|
| 691 |
+
"project_description" in external_context
|
| 692 |
+
and external_context["project_description"]
|
| 693 |
+
):
|
| 694 |
+
company_info += f"INFORMACJE OGÓLNE O PROJEKCIE (wpisane przez użytkownika):\n{external_context['project_description']}\n\n"
|
| 695 |
+
if "company_data" in external_context:
|
| 696 |
+
company_info += f"DANE FIRMY WNIOSKODAWCY (Z GUS):\n{json.dumps(external_context['company_data'], indent=2, ensure_ascii=False)}\n\n"
|
| 697 |
+
if "resources" in external_context and external_context["resources"]:
|
| 698 |
+
company_info += "ZASOBY PROJEKTU (dostarczone pliki wg użytkownika):\n"
|
| 699 |
+
for res in external_context["resources"]:
|
| 700 |
+
text_clip = res.get("extracted_text") or ""
|
| 701 |
+
if len(text_clip) > 3000:
|
| 702 |
+
text_clip = text_clip[:3000] + "... [UKRÓCONO]"
|
| 703 |
+
company_info += f"--- PLIK: {res.get('filename')} ---\n{text_clip}\n\n"
|
| 704 |
+
|
| 705 |
+
if company_info:
|
| 706 |
+
company_info += (
|
| 707 |
+
"INSTRUKCJA PRIORYTETU (WAŻNE DLA AUTOPILOTU):\n"
|
| 708 |
+
"- Dane z sekcji company_data, project_description oraz wszystkie informacje wpisane przez użytkownika na początku mają ABSOLUTNY priorytet.\n"
|
| 709 |
+
"- 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"
|
| 710 |
+
"- Wiedza ogólna z RAG jest tylko pomocnicza.\n\n"
|
| 711 |
+
)
|
| 712 |
+
|
| 713 |
+
template = (
|
| 714 |
+
ANTI_HALLUCINATION_PROMPT
|
| 715 |
+
+ """
|
| 716 |
+
Jesteś ekspertowym doradcą ds. dotacji unijnych, realizacji oraz rozliczania projektów R&D. Twoim zadaniem jest odpowiedź na Pytanie w odniesieniu do projektu klienta.
|
| 717 |
+
|
| 718 |
+
WAŻNE ZASADY:
|
| 719 |
+
1. Używaj tylko najnowszych, aktualnych regulaminów z bazy wiedzy. Jeśli dostarczone dokumenty RAG wydają się przestarzałe, zachowaj ostrożność.
|
| 720 |
+
2. Zawsze cytuj konkretne paragrafy, punkty i nazwy dokumentów w polu "sources".
|
| 721 |
+
3. Jeśli nie jesteś w 100% pewien odpowiedzi na podstawie przepisów, ZAWSZE o tym napisz (nie zgaduj).
|
| 722 |
+
4. Dostosowuj odpowiedź do etapu projektu (czy to etap przygotowania wniosku, czy już realizacja i rozliczanie wydatków).
|
| 723 |
+
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.
|
| 724 |
+
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.
|
| 725 |
+
|
| 726 |
+
KONTEKST PROJEKTU (dane i treść wniosku):
|
| 727 |
+
-------------------
|
| 728 |
+
{company_info}{project_context}
|
| 729 |
+
-------------------
|
| 730 |
+
|
| 731 |
+
BIEŻĄCE WYTYCZNE Z BAZY WIEDZY RAG (przepisy):
|
| 732 |
+
-------------------
|
| 733 |
+
{rag_context}
|
| 734 |
+
-------------------
|
| 735 |
+
|
| 736 |
+
Pytanie od użytkownika:
|
| 737 |
+
{question}
|
| 738 |
+
"""
|
| 739 |
+
)
|
| 740 |
+
|
| 741 |
+
from core.circuit_breaker import with_llm_retry, llm_circuit_breaker
|
| 742 |
+
|
| 743 |
+
structured_llm = llm
|
| 744 |
+
prompt = PromptTemplate.from_template(template)
|
| 745 |
+
chain = prompt | structured_llm
|
| 746 |
+
|
| 747 |
+
@llm_circuit_breaker
|
| 748 |
+
@with_llm_retry
|
| 749 |
+
def invoke_qa():
|
| 750 |
+
response: ProjectQAResponse = chain.invoke(
|
| 751 |
+
{
|
| 752 |
+
"company_info": company_info,
|
| 753 |
+
"project_context": context,
|
| 754 |
+
"rag_context": rag_context,
|
| 755 |
+
"question": question,
|
| 756 |
+
}
|
| 757 |
+
)
|
| 758 |
+
|
| 759 |
+
if hasattr(response, "model_dump"):
|
| 760 |
+
parsed_out = response.model_dump()
|
| 761 |
+
elif hasattr(response, "dict"):
|
| 762 |
+
parsed_out = response.dict()
|
| 763 |
+
else:
|
| 764 |
+
parsed_out = dict(response)
|
| 765 |
+
|
| 766 |
+
# Sanity check
|
| 767 |
+
answer_text = parsed_out.get("answer", "")
|
| 768 |
+
if not answer_text or len(answer_text.strip()) < 10:
|
| 769 |
+
raise ValueError(
|
| 770 |
+
"Błąd sanity check: Odpowiedź Q&A jest pusta lub zbyt krótka."
|
| 771 |
+
)
|
| 772 |
+
|
| 773 |
+
# Jeśli źródła RAG coś znalazły, ale LLM nic nie podał, sklei to
|
| 774 |
+
if not parsed_out.get("sources") and sources_used:
|
| 775 |
+
parsed_out["sources"] = sources_used
|
| 776 |
+
|
| 777 |
+
return parsed_out
|
| 778 |
+
|
| 779 |
+
try:
|
| 780 |
+
return invoke_qa()
|
| 781 |
+
except Exception as e:
|
| 782 |
+
import traceback
|
| 783 |
+
|
| 784 |
+
traceback.print_exc()
|
| 785 |
+
print(
|
| 786 |
+
f"Wystąpił błąd structured_output w project_qa_agent: {e}, próba fallbacku..."
|
| 787 |
+
)
|
| 788 |
+
|
| 789 |
+
try:
|
| 790 |
+
# Fallback bez with_structured_output
|
| 791 |
+
fallback_chain = prompt | llm
|
| 792 |
+
raw_response = fallback_chain.invoke(
|
| 793 |
+
{
|
| 794 |
+
"company_info": company_info,
|
| 795 |
+
"project_context": context,
|
| 796 |
+
"rag_context": rag_context,
|
| 797 |
+
"question": question,
|
| 798 |
+
}
|
| 799 |
+
)
|
| 800 |
+
return {
|
| 801 |
+
"answer": raw_response.content
|
| 802 |
+
if hasattr(raw_response, "content")
|
| 803 |
+
else str(raw_response),
|
| 804 |
+
"sources": sources_used,
|
| 805 |
+
"confidence": 0.5,
|
| 806 |
+
"recommendation": "Odpowiedź wygenerowana w trybie awaryjnym (fallback). Mogą brakować szczegółowych źródeł wygenerowanych przez AI.",
|
| 807 |
+
}
|
| 808 |
+
except Exception:
|
| 809 |
+
traceback.print_exc()
|
| 810 |
+
# Ostateczny awaryjny powrót
|
| 811 |
+
return {
|
| 812 |
+
"answer": f"Awaria strukturalnego formatowania odpowiedzi modelu i trybu awaryjnego: {str(e)}",
|
| 813 |
+
"sources": sources_used,
|
| 814 |
+
"confidence": 0.0,
|
| 815 |
+
"recommendation": "Spróbuj sformułować pytanie w prostszy sposób. (Sprawdzanie strukturalne zabezpieczyło przed błędem)",
|
| 816 |
+
}
|
| 817 |
+
|
| 818 |
+
|
| 819 |
+
# v5.0 Synthesis Agent related helper (professional advisor utilities, callable from gsd_orchestrator and flows)
|
| 820 |
+
def format_synthesis_risk_for_advisor(risk_map: dict, citation_score: float, trap_risk: str) -> str:
|
| 821 |
+
"""Related helper for strengthened Synthesis: produces concise professional risk paragraph."""
|
| 822 |
+
ov = (risk_map or {}).get("overall", {}) if isinstance(risk_map, dict) else {}
|
| 823 |
+
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."
|
| 824 |
+
|
| 825 |
+
|
| 826 |
+
def format_v5_synthesis_law_change_and_cost_signals(cert: Optional[dict], layers: dict, snap: dict) -> str:
|
| 827 |
+
"""v5.0 Production-Ready strengthened helper for Synthesis: surfaces law_change_signal + Cost Catalog + Temporal explicitly.
|
| 828 |
+
Used to enrich professional reports and V5GroundingCertificate consumption.
|
| 829 |
+
"""
|
| 830 |
+
law_sig = ""
|
| 831 |
+
if cert:
|
| 832 |
+
law_sig = cert.get("metadata", {}).get("law_change_signal", "") or layers.get("law_change_signal", "")
|
| 833 |
+
if not law_sig and snap:
|
| 834 |
+
law_sig = snap.get("change_summary", "No recent change per Temporal Graph")
|
| 835 |
+
cost_cat = (layers.get("cost_catalog", {}) or {}).get("eligible_summary", "Cost eligibility verified via v5 engine + snapshot catalog")
|
| 836 |
+
temporal = layers.get("temporal", {}) or (cert.metadata.get("temporal") if cert and hasattr(cert, "metadata") else {})
|
| 837 |
+
return (
|
| 838 |
+
f"LAW CHANGE SIGNAL (Temporal Graph / SUPERSEDES): {law_sig or 'Stable per latest RegulationVersion'}. "
|
| 839 |
+
f"COST CATALOG: {cost_cat}. "
|
| 840 |
+
f"TEMPORAL HISTORY: {temporal.get('timeline_len', 'N/A')} versions | last_change: {temporal.get('last_change', 'current')}. "
|
| 841 |
+
f"EU SOURCES DEPTH (Funding Portal/TED): award/eligibility/TRL/CPV signals merged into grounding."
|
| 842 |
+
)
|
| 843 |
+
|
| 844 |
+
|
| 845 |
+
def enrich_synthesis_with_eu_sources(synth_out: dict, eu_meta: dict) -> dict:
|
| 846 |
+
"""Helper to inject deeper EU data (Funding Portal award criteria, TRL, TED CPV/notice_type) into Synthesis output for risk-aware reports."""
|
| 847 |
+
if not eu_meta:
|
| 848 |
+
return synth_out
|
| 849 |
+
try:
|
| 850 |
+
synth_out = synth_out or {}
|
| 851 |
+
synth_out["eu_sources_depth"] = {
|
| 852 |
+
"funding_portal_award_criteria": eu_meta.get("award_criteria_detail") or eu_meta.get("award_criteria_present"),
|
| 853 |
+
"trl_signals": eu_meta.get("trl_signals") or eu_meta.get("trl_range"),
|
| 854 |
+
"ted_notice_type": (eu_meta.get("ted_metadata") or {}).get("notice_type"),
|
| 855 |
+
"eligibility_hierarchical": eu_meta.get("eligibility_hierarchical") or eu_meta.get("eligibility"),
|
| 856 |
+
"cost_catalog_relevant": eu_meta.get("cost_catalog_relevant", False),
|
| 857 |
+
}
|
| 858 |
+
if "v5_layers_consumed" in synth_out:
|
| 859 |
+
synth_out["v5_layers_consumed"]["eu_funding_depth"] = True
|
| 860 |
+
except Exception:
|
| 861 |
+
pass
|
| 862 |
+
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,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
class ResearchAgent:
|
| 16 |
+
"""
|
| 17 |
+
Sub-Agent badawczy Projektu TITAN.
|
| 18 |
+
Zadaniem tego agenta jest przeszukiwanie sieci (OSINT / Web Search)
|
| 19 |
+
oraz interpretacja wyników w przypadku braku danych w dokumencie.
|
| 20 |
+
Zastępuje człowieka w trybie pełnej automatyzacji, znacznie zwiększając skuteczność.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
def __init__(self):
|
| 24 |
+
# Inicjalizacja LLM dla analizy wyników badawczych.
|
| 25 |
+
self.llm = get_llm(task_type="creative") # Używamy creative/standard
|
| 26 |
+
|
| 27 |
+
def deep_search(self, query: str, context: str) -> str:
|
| 28 |
+
"""
|
| 29 |
+
Głębokie poszukiwanie danych w zewnętrznych bazach i sieci,
|
| 30 |
+
oraz ich synteza przy użyciu LLM.
|
| 31 |
+
"""
|
| 32 |
+
logger.info(f"🔎 [Research Agent TITAN] Rozpoczęto głębokie wyszukiwanie dla: {query}")
|
| 33 |
+
|
| 34 |
+
# 1. Wykonanie rzeczywistego wyszukiwania w sieci
|
| 35 |
+
search_results = general_web_search(query)
|
| 36 |
+
|
| 37 |
+
# 2. Jeśli nie znaleziono lub brak klucza, zwracamy stosowną informację,
|
| 38 |
+
# ale możemy spróbować odpowiedzieć z wiedzy LLM z uwzględnieniem kontekstu.
|
| 39 |
+
if "Błąd" in search_results or "Brak klucza" in search_results:
|
| 40 |
+
logger.warning(f"[Research Agent TITAN] Problem z wyszukiwarką: {search_results}")
|
| 41 |
+
search_context = "Nie udało się pobrać aktualnych danych z sieci z powodu braku dostępu do API wyszukiwania."
|
| 42 |
+
else:
|
| 43 |
+
search_context = f"Wyniki wyszukiwania:\n{search_results}"
|
| 44 |
+
|
| 45 |
+
system_prompt = (
|
| 46 |
+
"Jesteś zaawansowanym Agentem Badawczym OSINT. Twoim zadaniem jest dostarczenie "
|
| 47 |
+
"wyczerpujących i merytorycznych informacji na podstawie podanego kontekstu projektu "
|
| 48 |
+
"oraz wyników wyszukiwania z sieci.\n\n"
|
| 49 |
+
"Zasady:\n"
|
| 50 |
+
"1. Twoim celem jest odpowiedź na brakujące zapytanie (pytanie o brakujące dane).\n"
|
| 51 |
+
"2. Wykorzystaj informacje z 'Wyników wyszukiwania', jeśli są dostępne.\n"
|
| 52 |
+
"3. Oprzyj się na 'Kontekście projektu', aby odpowiedź była dopasowana do specyfiki firmy i projektu.\n"
|
| 53 |
+
"4. Jeśli z danych sieciowych i kontekstu nie da się jednoznacznie określić faktów (np. precyzyjnego przychodu małej firmy), "
|
| 54 |
+
"zaproponuj profesjonalne i wiarygodne oszacowanie lub standardowe dla branży wartości rynkowe i zaznacz, że to szacunek, "
|
| 55 |
+
"aby wniosek dotacyjny mógł zostać wygenerowany jako pełny szkic (użyj znaczników np. [SZACOWANY_PRZYCHÓD: 1.5 mln PLN]).\n"
|
| 56 |
+
"5. Pisz wyłącznie w języku polskim, stylem profesjonalnym, odpowiednim do wniosków unijnych i biznesplanów."
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
human_content = f"Pytanie o brakujące dane: {query}\n\nKontekst projektu:\n{context}\n\n{search_context}"
|
| 60 |
+
|
| 61 |
+
try:
|
| 62 |
+
response = self.llm.invoke([
|
| 63 |
+
SystemMessage(content=system_prompt),
|
| 64 |
+
HumanMessage(content=human_content)
|
| 65 |
+
])
|
| 66 |
+
|
| 67 |
+
final_answer = response.content if hasattr(response, 'content') else str(response)
|
| 68 |
+
logger.info("[Research Agent TITAN] Zakończono syntezę wyników badawczych.")
|
| 69 |
+
return final_answer
|
| 70 |
+
except Exception as e:
|
| 71 |
+
logger.error(f"[Research Agent TITAN] Błąd generowania odpowiedzi przez LLM: {e}")
|
| 72 |
+
return f"Nie udało się wygenerować odpowiedzi analitycznej na temat: {query}. Spróbuj użyć [BRAK DANYCH]."
|
| 73 |
+
|
| 74 |
+
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,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
ISAPClient = None
|
| 14 |
+
PARPClient = None
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class ScraperAgent:
|
| 20 |
+
"""
|
| 21 |
+
Inteligentny Agent, który decyduje w jaki sposób pozyskać dane.
|
| 22 |
+
Przeznaczony do działania z Celery Beat / APScheduler do cyklicznych aktualizacji.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
def __init__(self):
|
| 26 |
+
if ISAPClient and PARPClient:
|
| 27 |
+
self.isap_client = ISAPClient()
|
| 28 |
+
self.parp_client = PARPClient()
|
| 29 |
+
else:
|
| 30 |
+
self.isap_client = None
|
| 31 |
+
self.parp_client = None
|
| 32 |
+
# Namespace do ogólnodostępnej przestrzeni aktów i regulaminów
|
| 33 |
+
self.public_namespace = "public_legal"
|
| 34 |
+
|
| 35 |
+
async def run_sync_job(self):
|
| 36 |
+
"""Uruchamia cykliczny proces synchronizacji dotacji i prawa"""
|
| 37 |
+
if not self.isap_client or not self.parp_client:
|
| 38 |
+
logger.error("[AGENT] Brak klientow ISAP/PARP. Synchronizacja anulowana.")
|
| 39 |
+
return
|
| 40 |
+
|
| 41 |
+
logger.info(
|
| 42 |
+
"[AGENT] Rozpoczęcie automatycznego zadania synchronizacji bazy wiedzy..."
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
# 1. PARP (Regulaminy Naborów)
|
| 46 |
+
grants = self.parp_client.fetch_grants()
|
| 47 |
+
for grant in grants:
|
| 48 |
+
url = grant["url"]
|
| 49 |
+
logger.info(f"[AGENT] Zlecam scrapowanie dla dotacji: {grant['id']}")
|
| 50 |
+
try:
|
| 51 |
+
text, _ = await scrape_grant_url(url)
|
| 52 |
+
if text:
|
| 53 |
+
process_and_ingest(
|
| 54 |
+
text, url, priority="high", namespace=self.public_namespace
|
| 55 |
+
)
|
| 56 |
+
except Exception as e:
|
| 57 |
+
logger.error(f"[AGENT] Błąd fetchowania {url}: {e}")
|
| 58 |
+
|
| 59 |
+
# 2. ISAP (Ustawa z dn 6 marca 2018 - Prawo przedsiębiorców)
|
| 60 |
+
logger.info("[AGENT] Pobieranie ram prawnych (ISAP/ELI)")
|
| 61 |
+
act_info = self.isap_client.fetch_act("WDU", 2018, 646)
|
| 62 |
+
if act_info:
|
| 63 |
+
urls_to_try = [act_info["text_url"]]
|
| 64 |
+
if act_info.get("text_pdf_url"):
|
| 65 |
+
urls_to_try.append(act_info["text_pdf_url"])
|
| 66 |
+
for url in urls_to_try:
|
| 67 |
+
try:
|
| 68 |
+
text, _ = await scrape_grant_url(url)
|
| 69 |
+
if text:
|
| 70 |
+
process_and_ingest(
|
| 71 |
+
text, url, priority="critical", namespace=self.public_namespace
|
| 72 |
+
)
|
| 73 |
+
break
|
| 74 |
+
except Exception as e:
|
| 75 |
+
logger.error(f"[AGENT] Błąd parsowania ISAP {url}: {e}")
|
| 76 |
+
|
| 77 |
+
logger.info("[AGENT] Zakończono automatyczny cykl agenta synchronizacyjnego.")
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
if __name__ == "__main__":
|
| 81 |
+
logging.basicConfig(level=logging.INFO)
|
| 82 |
+
agent = ScraperAgent()
|
| 83 |
+
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/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')
|
backend/alembic/versions/20260604_add_grants_company_profiles.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Add Grants and CompanyProfile models
|
| 2 |
+
|
| 3 |
+
Revision ID: 20260604_add_grants_company
|
| 4 |
+
Revises:
|
| 5 |
+
Create Date: 2026-06-04 22:55:00.000000
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from alembic import op
|
| 9 |
+
import sqlalchemy as sa
|
| 10 |
+
|
| 11 |
+
# revision identifiers, used by Alembic.
|
| 12 |
+
revision = '20260604_add_grants_company'
|
| 13 |
+
down_revision = None
|
| 14 |
+
branch_labels = None
|
| 15 |
+
depends_on = None
|
| 16 |
+
|
| 17 |
+
def upgrade() -> None:
|
| 18 |
+
# Grants table
|
| 19 |
+
op.create_table('grants',
|
| 20 |
+
sa.Column('id', sa.String(), nullable=False),
|
| 21 |
+
sa.Column('source_id', sa.String(), nullable=False),
|
| 22 |
+
sa.Column('name', sa.String(), nullable=False),
|
| 23 |
+
sa.Column('program', sa.String(), nullable=True),
|
| 24 |
+
sa.Column('status', sa.String(), nullable=True),
|
| 25 |
+
sa.Column('url', sa.String(), nullable=True),
|
| 26 |
+
sa.Column('deadline', sa.String(), nullable=True),
|
| 27 |
+
sa.Column('source', sa.String(), nullable=False),
|
| 28 |
+
sa.Column('instrument_type', sa.String(), nullable=True),
|
| 29 |
+
sa.Column('is_financial_instrument', sa.Boolean(), nullable=True),
|
| 30 |
+
sa.Column('eligible_company_sizes', sa.JSON(), nullable=True),
|
| 31 |
+
sa.Column('eligible_regions', sa.JSON(), nullable=True),
|
| 32 |
+
sa.Column('description', sa.Text(), nullable=True),
|
| 33 |
+
sa.Column('eurlex_url', sa.String(), nullable=True),
|
| 34 |
+
sa.Column('official_doc_url', sa.String(), nullable=True),
|
| 35 |
+
sa.Column('precise_regulation_url', sa.String(), nullable=True),
|
| 36 |
+
sa.Column('is_outdated_warning', sa.Boolean(), nullable=True),
|
| 37 |
+
sa.Column('url_warning', sa.String(), nullable=True),
|
| 38 |
+
sa.Column('fetched_at', sa.DateTime(), nullable=True),
|
| 39 |
+
sa.Column('last_verified', sa.DateTime(), nullable=True),
|
| 40 |
+
sa.Column('raw_data', sa.JSON(), nullable=True),
|
| 41 |
+
sa.PrimaryKeyConstraint('id')
|
| 42 |
+
)
|
| 43 |
+
op.create_index(op.f('ix_grants_id'), 'grants', ['id'], unique=False)
|
| 44 |
+
op.create_index(op.f('ix_grants_source_id'), 'grants', ['source_id'], unique=True)
|
| 45 |
+
op.create_index(op.f('ix_grants_status'), 'grants', ['status'], unique=False)
|
| 46 |
+
|
| 47 |
+
# Company Profiles table
|
| 48 |
+
op.create_table('company_profiles',
|
| 49 |
+
sa.Column('id', sa.String(), nullable=False),
|
| 50 |
+
sa.Column('clerk_user_id', sa.String(), nullable=False),
|
| 51 |
+
sa.Column('nip', sa.String(), nullable=True),
|
| 52 |
+
sa.Column('regon', sa.String(), nullable=True),
|
| 53 |
+
sa.Column('krs', sa.String(), nullable=True),
|
| 54 |
+
sa.Column('name', sa.String(), nullable=True),
|
| 55 |
+
sa.Column('size', sa.String(), nullable=True),
|
| 56 |
+
sa.Column('region', sa.String(), nullable=True),
|
| 57 |
+
sa.Column('pkd_codes', sa.JSON(), nullable=True),
|
| 58 |
+
sa.Column('employees', sa.Integer(), nullable=True),
|
| 59 |
+
sa.Column('turnover', sa.Float(), nullable=True),
|
| 60 |
+
sa.Column('related_entities', sa.JSON(), nullable=True),
|
| 61 |
+
sa.Column('last_verified_at', sa.DateTime(), nullable=True),
|
| 62 |
+
sa.Column('created_at', sa.DateTime(), nullable=True),
|
| 63 |
+
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
| 64 |
+
sa.ForeignKeyConstraint(['clerk_user_id'], ['users.clerk_id'], ),
|
| 65 |
+
sa.PrimaryKeyConstraint('id'),
|
| 66 |
+
sa.UniqueConstraint('clerk_user_id')
|
| 67 |
+
)
|
| 68 |
+
op.create_index(op.f('ix_company_profiles_id'), 'company_profiles', ['id'], unique=False)
|
| 69 |
+
op.create_index(op.f('ix_company_profiles_clerk_user_id'), 'company_profiles', ['clerk_user_id'], unique=False)
|
| 70 |
+
op.create_index(op.f('ix_company_profiles_nip'), 'company_profiles', ['nip'], unique=False)
|
| 71 |
+
|
| 72 |
+
def downgrade() -> None:
|
| 73 |
+
op.drop_index(op.f('ix_company_profiles_nip'), table_name='company_profiles')
|
| 74 |
+
op.drop_index(op.f('ix_company_profiles_clerk_user_id'), table_name='company_profiles')
|
| 75 |
+
op.drop_index(op.f('ix_company_profiles_id'), table_name='company_profiles')
|
| 76 |
+
op.drop_table('company_profiles')
|
| 77 |
+
|
| 78 |
+
op.drop_index(op.f('ix_grants_status'), table_name='grants')
|
| 79 |
+
op.drop_index(op.f('ix_grants_source_id'), table_name='grants')
|
| 80 |
+
op.drop_index(op.f('ix_grants_id'), table_name='grants')
|
| 81 |
+
op.drop_table('grants')
|