GrantForge Bot commited on
Commit
3a3734f
·
0 Parent(s):

Deploy sha-9a5957fcdef15b7e2623f8b147cda6026475aee0 — source build (no GHCR)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +23 -0
  2. Dockerfile +71 -0
  3. README.md +80 -0
  4. backend/.deepeval/.deepeval-cache.json +1 -0
  5. backend/.deepeval/.deepeval_telemetry.txt +4 -0
  6. backend/.env.example +39 -0
  7. backend/Dockerfile +30 -0
  8. backend/add_keys.py +23 -0
  9. backend/agents/__init__.py +1 -0
  10. backend/agents/auditor.py +581 -0
  11. backend/agents/auditor_panel_graph.py +82 -0
  12. backend/agents/autofill_agent.py +328 -0
  13. backend/agents/compliance_guardian.py +141 -0
  14. backend/agents/critic.py +212 -0
  15. backend/agents/document_gap_analyzer.py +69 -0
  16. backend/agents/evaluator.py +128 -0
  17. backend/agents/finance_agent.py +122 -0
  18. backend/agents/gap_analyzer.py +73 -0
  19. backend/agents/generator_agent.py +1615 -0
  20. backend/agents/grant_research_agent.py +165 -0
  21. backend/agents/helpers.py +862 -0
  22. backend/agents/holistic_critic.py +151 -0
  23. backend/agents/matcher.py +246 -0
  24. backend/agents/panel_nodes.py +562 -0
  25. backend/agents/panel_state.py +44 -0
  26. backend/agents/planner.py +40 -0
  27. backend/agents/profiler.py +128 -0
  28. backend/agents/red_team_auditor.py +80 -0
  29. backend/agents/research_agent.py +74 -0
  30. backend/agents/researcher.py +201 -0
  31. backend/agents/risk_scoring.py +104 -0
  32. backend/agents/scraper_agent.py +83 -0
  33. backend/agents/supervisor.py +139 -0
  34. backend/agents/timeline.py +64 -0
  35. backend/agents/tools/budget_rules_tool.py +28 -0
  36. backend/agents/tools/krs_graph_tool.py +64 -0
  37. backend/agents/tools/legal_retriever_tool.py +94 -0
  38. backend/agents/tools/neo4j_cypher_tool.py +46 -0
  39. backend/agents/tools/technology_retriever_tool.py +31 -0
  40. backend/agents/verifier.py +44 -0
  41. backend/agents/wizard.py +191 -0
  42. backend/alembic.ini +149 -0
  43. backend/alembic/README +1 -0
  44. backend/alembic/env.py +93 -0
  45. backend/alembic/script.py.mako +28 -0
  46. backend/alembic/versions/0848fd2356d9_sprint2.py +43 -0
  47. backend/alembic/versions/0e48eb7134d7_add_final_document_columns.py +40 -0
  48. backend/alembic/versions/0f91b1724111_add_external_context_to_projects.py +37 -0
  49. backend/alembic/versions/20260527_add_regulation_snapshots.py +43 -0
  50. 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')