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

Deploy sha-565ad85979610064f6d1c18ab3b6404357d61073 — 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 +69 -0
  4. backend/.deepeval/.deepeval-cache.json +1 -0
  5. backend/.deepeval/.deepeval_telemetry.txt +4 -0
  6. backend/.env.example +143 -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 +352 -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 +0 -0
  20. backend/agents/grant_research_agent.py +165 -0
  21. backend/agents/helpers.py +871 -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 +99 -0
  30. backend/agents/researcher.py +201 -0
  31. backend/agents/risk_scoring.py +104 -0
  32. backend/agents/scraper_agent.py +115 -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/agents/world_class_advisor.py +628 -0
  43. backend/alembic.ini +149 -0
  44. backend/alembic/README +1 -0
  45. backend/alembic/env.py +93 -0
  46. backend/alembic/script.py.mako +28 -0
  47. backend/alembic/versions/0848fd2356d9_sprint2.py +43 -0
  48. backend/alembic/versions/0e48eb7134d7_add_final_document_columns.py +40 -0
  49. backend/alembic/versions/0f91b1724111_add_external_context_to_projects.py +37 -0
  50. backend/alembic/versions/20260527_add_regulation_snapshots.py +43 -0
.dockerignore ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .github
3
+ .ruff_cache
4
+ **/__pycache__
5
+ **/*.pyc
6
+ **/.pytest_cache
7
+ **/.mypy_cache
8
+ **/node_modules
9
+ frontend-react/node_modules
10
+ frontend-react/dist
11
+ backend/.env
12
+ backend/test.db
13
+ backend/cache
14
+ **/*.md
15
+ !README.md
16
+ poprawka*.md
17
+ antigravity_grantforge_swarm
18
+ DEPLOYMENT.md
19
+ ROADMAP.md
20
+ KRSAPI.md
21
+ OProgramie.md
22
+ DEBUG_*.txt
23
+ *.log
Dockerfile ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # GrantForge AI — Multi-stage Docker build
2
+ # Backend: FastAPI + LangGraph | Frontend: Vite React
3
+
4
+ # ──────────────────────────────────────────────────────────────────
5
+ # STAGE 1: Frontend build
6
+ # ──────────────────────────────────────────────────────────────────
7
+ FROM node:20-slim AS frontend-builder
8
+
9
+ WORKDIR /app/frontend
10
+
11
+ COPY frontend-react/package*.json ./
12
+ RUN rm -f package-lock.json && npm install
13
+
14
+ COPY frontend-react/ ./
15
+ RUN npm run build
16
+ # Artefakt: /app/frontend/dist/
17
+
18
+ # ──────────────────────────────────────────────────────────────────
19
+ # STAGE 2: Python dependencies
20
+ # ──────────────────────────────────────────────────────────────────
21
+ FROM python:3.11.9-slim AS python-deps
22
+
23
+ WORKDIR /install
24
+
25
+ RUN apt-get update && apt-get install -y --no-install-recommends \
26
+ libpq-dev gcc g++ libffi-dev libglib2.0-0 libpango-1.0-0 \
27
+ libpangocairo-1.0-0 libcairo2 libcairo2-dev pkg-config python3-dev \
28
+ && rm -rf /var/lib/apt/lists/*
29
+
30
+ COPY backend/requirements.txt .
31
+ RUN pip install --no-cache-dir --prefix=/install/pkg -r requirements.txt && \
32
+ rm -rf /install/pkg/lib/python3.11/site-packages/pinecone_plugin_inference*
33
+
34
+ # ──────────────────────────────────────────────────────────────────
35
+ # STAGE 3: Runtime image
36
+ # ──────────────────────────────────────────────────────────────────
37
+ FROM python:3.11.9-slim AS runtime
38
+
39
+ WORKDIR /app
40
+
41
+ COPY --from=python-deps /install/pkg /usr/local
42
+
43
+ RUN apt-get update && apt-get install -y --no-install-recommends \
44
+ libpq5 libglib2.0-0 libpango-1.0-0 libpangocairo-1.0-0 libcairo2 wget \
45
+ && rm -rf /var/lib/apt/lists/*
46
+
47
+ COPY backend/ ./backend/
48
+ COPY --from=frontend-builder /app/frontend/dist ./static/
49
+
50
+ RUN mkdir -p /app/backend/assets && \
51
+ wget -qO /app/backend/assets/Roboto-Regular.ttf "https://github.com/googlefonts/roboto/raw/main/src/hinted/Roboto-Regular.ttf" && \
52
+ wget -qO /app/backend/assets/Roboto-Bold.ttf "https://github.com/googlefonts/roboto/raw/main/src/hinted/Roboto-Bold.ttf"
53
+
54
+ RUN mkdir -p /app/backend/cache
55
+
56
+ RUN useradd -m -u 1001 appuser && chown -R appuser:appuser /app
57
+ USER appuser
58
+
59
+ WORKDIR /app/backend
60
+
61
+ EXPOSE 7860
62
+
63
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
64
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:7860/api/health')"
65
+
66
+ CMD ["gunicorn", "server:app", \
67
+ "--worker-class", "uvicorn.workers.UvicornWorker", \
68
+ "--workers", "2", \
69
+ "--bind", "0.0.0.0:7860", \
70
+ "--timeout", "120", \
71
+ "--access-logfile", "-"]
README.md ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: GrantForge API
3
+ emoji: 🏢
4
+ colorFrom: blue
5
+ colorTo: red
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # GrantForge AI (DotacjeAI)
12
+
13
+ > **Hugging Face Space** — `sdk: docker`, port **7860** (Gunicorn → FastAPI `backend/server:app`).
14
+ > Nie usuwaj bloku YAML powyżej — bez niego Space zgłasza *config error*.
15
+
16
+ Platforma B2B: **katalog naborów** (aktualne, z kontrolą linków i regulaminów) → **dopasowanie do firmy** → **generacja wniosku** → **audyt / quality loop** → **eksport PDF/DOCX**.
17
+
18
+ ## Szybki start
19
+
20
+ | Warstwa | Stack |
21
+ |---------|--------|
22
+ | Frontend | React + TypeScript (Vite), Clerk, Vercel |
23
+ | Backend | FastAPI, PostgreSQL, Neo4j, Pinecone/RAG, HF Space |
24
+ | AI | Router Gemini / xAI, LangGraph autopilot |
25
+
26
+ ```bash
27
+ # Backend
28
+ cd backend && pip install -r requirements.txt
29
+ # ustaw .env (DB, Clerk, LLM keys) — patrz DEPLOYMENT.md
30
+ uvicorn server:app --reload --port 8000
31
+
32
+ # Frontend
33
+ cd frontend-react && npm install && npm run dev
34
+ ```
35
+
36
+ ## Dokumentacja użytkownika i testów
37
+
38
+ | Plik | Opis |
39
+ |------|------|
40
+ | [docs/PLAN_ECOSYSTEM_2026_IMPLEMENTATION.md](docs/PLAN_ECOSYSTEM_2026_IMPLEMENTATION.md) | **Roadmap ekosystemu 2026** (eligibility → strategy → tax → instruments) |
41
+ | [docs/PLAN_INSTRUMENT_FIRST_PIPELINE.md](docs/PLAN_INSTRUMENT_FIRST_PIPELINE.md) | Warstwa instrument-first (schema, readiness) |
42
+ | [docs/USER_GUIDE.md](docs/USER_GUIDE.md) | Instrukcja obsługi |
43
+ | [docs/TEST_PATH_NEW_ENTITY.md](docs/TEST_PATH_NEW_ENTITY.md) | **Pełna ścieżka testowa** nowego podmiotu |
44
+ | [docs/agents/ROLE_MAP.md](docs/agents/ROLE_MAP.md) | Role GSD → `backend/agents` |
45
+ | [backend/gsd/README.md](backend/gsd/README.md) | Orkiestracja GSD (jednolity runtime) |
46
+ | [docs/CREDIBILITY_PIPELINE.md](docs/CREDIBILITY_PIPELINE.md) | Wiarygodność firm i naborów |
47
+ | [docs/workflow-reports/GRANT_SYSTEM_RESCUE.md](docs/workflow-reports/GRANT_SYSTEM_RESCUE.md) | Raport naprawy katalogu/jakości |
48
+ | [DEPLOYMENT.md](DEPLOYMENT.md) | Deploy |
49
+
50
+ **GSD / swarm:** cała logika w `backend/gsd/` + `backend/agents/`. Folder `antigravity_grantforge_swarm/` to tylko re-export (deprecated).
51
+
52
+ W aplikacji: **O programie** (`/about`), **Pomoc** (`/help`), **Beta** (`/beta`).
53
+
54
+ ## Jakość katalogu naborów (skrót)
55
+
56
+ - Domyślnie `require_certainty=true` — bez starych/martwych programów i bez śmieciowych linków.
57
+ - Regulamin ≠ RODO/privacy/listing; RAG nie wektoryzuje strony naboru zamiast regulaminu.
58
+ - Import WYSZUKIWARKA + verify URL + close grantów poza snapshotem.
59
+ - Quality loop: celowana korekta sekcji (checklist, cytowania), soft-export z grounding.
60
+
61
+ ## Testy
62
+
63
+ ```bash
64
+ cd backend && python3 -m pytest tests/ -q --ignore=tests/test_deepeval_rag.py
65
+ ```
66
+
67
+ ## Licencja / kontakt
68
+
69
+ Użycie produkcyjne zgodnie z regulaminem aplikacji i polityką prywatności w UI.
backend/.deepeval/.deepeval-cache.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"test_cases_lookup_map": {"{\"actual_output\": \"{}\", \"context\": null, \"expected_output\": null, \"hyperparameters\": null, \"input\": \"Czy moja firma jako du\\u017ce przedsi\\u0119biorstwo mo\\u017ce ubiega\\u0107 si\\u0119 o FENG Szybka \\u015acie\\u017cka?\", \"retrieval_context\": [\"{'rok_perspektywy': '2021-2027', 'query': 'FENG \\u015acie\\u017cka SMART du\\u017ce przedsi\\u0119biorstwa'}\", \"{'rok_perspektywy': '2021-2027', 'query': 'kto mo\\u017ce ubiega\\u0107 si\\u0119 o dofinansowanie FENG \\u015acie\\u017cka SMART du\\u017ce przedsi\\u0119biorstwa kwalifikowalno\\u015b\\u0107'}\"]}": {"cached_metrics_data": [{"metric_data": {"name": "Faithfulness", "threshold": 0.7, "success": false, "strictMode": false, "evaluationModel": "gemini-1.5-pro", "evaluationCost": 0.0}, "metric_configuration": {"threshold": 0.7, "evaluation_model": "gemini-1.5-pro", "strict_mode": false, "include_reason": true}}]}, "{\"actual_output\": \"{}\", \"context\": null, \"expected_output\": null, \"hyperparameters\": null, \"input\": \"Czy koszty ubezpieczenia samochod\\u00f3w s\\u0142u\\u017cbowych s\\u0105 kwalifikowalne w KPO?\", \"retrieval_context\": [\"{'query': 'KPO wytyczne kwalifikowalno\\u015bci', 'rok_perspektywy': '2021-2027'}\", \"{'query': 'koszty ubezpieczenia samochod\\u00f3w KPO kwalifikowalno\\u015b\\u0107'}\", \"{'query': 'kwalifikowalno\\u015b\\u0107 koszt\\u00f3w ubezpieczenia samochod\\u00f3w s\\u0142u\\u017cbowych KPO', 'rok_perspektywy': '2021-2027'}\"]}": {"cached_metrics_data": [{"metric_data": {"name": "Faithfulness", "threshold": 0.7, "success": false, "strictMode": false, "evaluationModel": "gemini-1.5-pro", "evaluationCost": 0.0}, "metric_configuration": {"threshold": 0.7, "evaluation_model": "gemini-1.5-pro", "strict_mode": false, "include_reason": true}}]}, "{\"actual_output\": \"{}\", \"context\": null, \"expected_output\": null, \"hyperparameters\": null, \"input\": \"Jak wykaza\\u0107 zasad\\u0119 DNSH w projekcie polegaj\\u0105cym na zakupie maszyn CNC?\", \"retrieval_context\": [\"{'defects': [{'affected_section': 'Opis projektu / Zakup maszyn CNC', 'recommendation': 'Nale\\u017cy przeprowadzi\\u0107 i do\\u0142\\u0105czy\\u0107 analiz\\u0119 DNSH dla zakupu maszyn CNC. Wymagane jest wykazanie m.in.: 1) \\u0141agodzenia zmian klimatu (np. wysoka klasa efektywno\\u015bci energetycznej maszyn, zgodno\\u015b\\u0107 z dyrektyw\\u0105 o ekoprojekcie); 2) Gospodarki o obiegu zamkni\\u0119tym (spos\\u00f3b utylizacji i recyklingu odpad\\u00f3w poprodukcyjnych, np. wi\\u00f3r\\u00f3w, ch\\u0142odziw); 3) Zapobiegania zanieczyszczeniom (brak wykorzystania substancji zakazanych, zgodno\\u015b\\u0107 z REACH/RoHS).', 'problem_quote': 'Jak wykaza\\u0107 zasad\\u0119 DNSH w projekcie polegaj\\u0105cym na zakupie maszyn CNC?', 'description': 'Brak wykazania zgodno\\u015bci z zasad\\u0105 DNSH (Do No Significant Harm). Zgodnie z art. 9 ust. 4 Rozporz\\u0105dzenia UE 2021/1060 oraz wytycznymi MFiPR dla perspektywy 2021-2027, ka\\u017cdy projekt musi by\\u0107 zgodny z sze\\u015bcioma celami \\u015brodowiskowymi Taksonomii UE. Sam zakup maszyn CNC bez odpowiedniej analizy \\u015brodowiskowej stanowi brak formalny i merytoryczny.'}]}\", \"{'query': 'DNSH wytyczne kwalifikowalno\\u015bci 2021-2027', 'rok_perspektywy': '2021-2027'}\", \"{'rok_perspektywy': '2021-2027', 'query': 'zasada DNSH zakup maszyn urz\\u0105dze\\u0144 \\u015acie\\u017cka SMART'}\"]}": {"cached_metrics_data": [{"metric_data": {"name": "Faithfulness", "threshold": 0.7, "success": false, "strictMode": false, "evaluationModel": "gemini-1.5-pro", "evaluationCost": 0.0}, "metric_configuration": {"threshold": 0.7, "evaluation_model": "gemini-1.5-pro", "strict_mode": false, "include_reason": true}}]}, "{\"actual_output\": \"{}\", \"context\": null, \"expected_output\": null, \"hyperparameters\": null, \"input\": \"Czy moja firma jako du\\u017ce przedsi\\u0119biorstwo mo\\u017ce ubiega\\u0107 si\\u0119 o FENG Szybka \\u015acie\\u017cka?\", \"retrieval_context\": [\"{'query': 'kto mo\\u017ce ubiega\\u0107 si\\u0119 o dofinansowanie FENG \\u015acie\\u017cka SMART du\\u017ce przedsi\\u0119biorstwa kwalifikowalno\\u015b\\u0107', 'rok_perspektywy': '2021-2027'}\", \"{'rok_perspektywy': '2021-2027', 'query': 'FENG \\u015acie\\u017cka SMART du\\u017ce przedsi\\u0119biorstwa'}\"]}": {"cached_metrics_data": [{"metric_data": {"name": "Faithfulness", "threshold": 0.7, "success": false, "strictMode": false, "evaluationModel": "gemini-1.5-pro", "evaluationCost": 0}, "metric_configuration": {"threshold": 0.7, "evaluation_model": "gemini-1.5-pro", "strict_mode": false, "include_reason": true}}]}, "{\"actual_output\": \"{}\", \"context\": null, \"expected_output\": null, \"hyperparameters\": null, \"input\": \"Czy koszty ubezpieczenia samochod\\u00f3w s\\u0142u\\u017cbowych s\\u0105 kwalifikowalne w KPO?\", \"retrieval_context\": [\"{'defects': [{'description': 'Brak wystarczaj\\u0105cych informacji. Z powodu b\\u0142\\u0119du technicznego bazy wiedzy nie mo\\u017cna jednoznacznie zweryfikowa\\u0107 wytycznych KPO. Zgodnie z og\\u00f3lnymi zasadami funduszy UE, koszty ubezpieczenia samochod\\u00f3w s\\u0142u\\u017cbowych s\\u0105 zazwyczaj niekwalifikowalne jako koszty bezpo\\u015brednie (mog\\u0105 stanowi\\u0107 element koszt\\u00f3w po\\u015brednich).', 'problem_quote': 'Czy koszty ubezpieczenia samochod\\u00f3w s\\u0142u\\u017cbowych s\\u0105 kwalifikowalne w KPO?', 'affected_section': 'Tre\\u015b\\u0107 wniosku', 'recommendation': 'Nale\\u017cy zweryfikowa\\u0107 regulamin konkretnego naboru w ramach KPO oraz Wytyczne w zakresie kwalifikowalno\\u015bci wydatk\\u00f3w.'}]}\", \"{'query': 'koszty ubezpieczenia pojazd\\u00f3w KPO kwalifikowalno\\u015b\\u0107'}\", \"{'rok_perspektywy': '2021-2027', 'query': 'kwalifikowalno\\u015b\\u0107 koszt\\u00f3w ubezpieczenia samochod\\u00f3w s\\u0142u\\u017cbowych KPO'}\"]}": {"cached_metrics_data": [{"metric_data": {"name": "Faithfulness", "threshold": 0.7, "success": false, "strictMode": false, "evaluationModel": "gemini-1.5-pro", "evaluationCost": 0}, "metric_configuration": {"threshold": 0.7, "evaluation_model": "gemini-1.5-pro", "strict_mode": false, "include_reason": true}}]}, "{\"actual_output\": \"{}\", \"context\": null, \"expected_output\": null, \"hyperparameters\": null, \"input\": \"Jak wykaza\\u0107 zasad\\u0119 DNSH w projekcie polegaj\\u0105cym na zakupie maszyn CNC?\", \"retrieval_context\": [\"{'defects': [{'affected_section': 'Opis projektu / Zakup maszyn CNC', 'problem_quote': 'Jak wykaza\\u0107 zasad\\u0119 DNSH w projekcie polegaj\\u0105cym na zakupie maszyn CNC?', 'recommendation': 'Nale\\u017cy przeprowadzi\\u0107 i do\\u0142\\u0105czy\\u0107 analiz\\u0119 DNSH dla zakupu maszyn CNC. Wymagane jest wykazanie m.in.: 1) \\u0141agodzenia zmian klimatu (np. wysoka klasa efektywno\\u015bci energetycznej maszyn, zgodno\\u015b\\u0107 z dyrektyw\\u0105 o ekoprojekcie); 2) Gospodarki o obiegu zamkni\\u0119tym (spos\\u00f3b utylizacji i recyklingu odpad\\u00f3w poprodukcyjnych, np. wi\\u00f3r\\u00f3w, ch\\u0142odziw); 3) Zapobiegania zanieczyszczeniom (brak wykorzystania substancji zakazanych, zgodno\\u015b\\u0107 z REACH/RoHS).', 'description': 'Brak wykazania zgodno\\u015bci z zasad\\u0105 DNSH (Do No Significant Harm). Zgodnie z art. 9 ust. 4 Rozporz\\u0105dzenia UE 2021/1060 oraz wytycznymi MFiPR dla perspektywy 2021-2027, ka\\u017cdy projekt musi by\\u0107 zgodny z sze\\u015bcioma celami \\u015brodowiskowymi Taksonomii UE. Sam zakup maszyn CNC bez odpowiedniej analizy \\u015brodowiskowej stanowi brak formalny i merytoryczny.'}]}\", \"{'query': 'DNSH wytyczne kwalifikowalno\\u015bci 2021-2027', 'rok_perspektywy': '2021-2027'}\", \"{'query': 'zasada DNSH zakup maszyn urz\\u0105dze\\u0144 \\u015acie\\u017cka SMART', 'rok_perspektywy': '2021-2027'}\"]}": {"cached_metrics_data": [{"metric_data": {"name": "Faithfulness", "threshold": 0.7, "success": false, "strictMode": false, "evaluationModel": "gemini-1.5-pro", "evaluationCost": 0}, "metric_configuration": {"threshold": 0.7, "evaluation_model": "gemini-1.5-pro", "strict_mode": false, "include_reason": true}}]}}}
backend/.deepeval/.deepeval_telemetry.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ DEEPEVAL_ID=089404e6-6063-43e6-b7c9-9315c2f56eb6
2
+ DEEPEVAL_STATUS=old
3
+ DEEPEVAL_LAST_FEATURE=evaluation
4
+ DEEPEVAL_EVALUATION_STATUS=old
backend/.env.example ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dotacje AI - Backend Environment Variables Example
2
+
3
+ # LLM Providers
4
+ GOOGLE_API_KEY="YOUR_GOOGLE_API_KEY"
5
+ # xAI / Grok (HF Space Secret: XAI_API_KEY) — fallback gdy Gemini 403/billing/dunning
6
+ XAI_API_KEY="YOUR_XAI_API_KEY"
7
+ GROK_API_KEY="YOUR_GROK_API_KEY"
8
+ # XAI_MODEL="grok-4.3"
9
+ # XAI_BASE_URL="https://api.x.ai/v1"
10
+ # Kolejność providerów (primary,fallback).
11
+ # Na Hugging Face Spaces: ustaw PREFER_XAI=true LUB LLM_PRIMARY=xai żeby od razu
12
+ # iść na Grok i pominąć nieudane próby Gemini (szybszy generate-full przy 403/billing).
13
+ # LLM_FALLBACK_ORDER=gemini,xai
14
+ # LLM_PRIMARY=xai
15
+ # PREFER_XAI=false
16
+ # GEMINI_HEALTH_PROBE=true
17
+ GEMINI_EMBEDDING_MODEL="gemini-embedding-001"
18
+ GEMINI_EMBEDDING_DIMENSIONS="768"
19
+ # EMBEDDINGS_PROVIDER=auto # auto | hash (hash gdy Gemini embeddings 403)
20
+
21
+ # Vector Store (Pinecone) — wymagane do ingestu i semantycznego wyszukiwania
22
+ PINECONE_API_KEY="YOUR_PINECONE_API_KEY"
23
+ PINECONE_INDEX_NAME="dotacje"
24
+ # PINECONE_ENVIRONMENT="us-east-1" # opcjonalnie, zależnie od regionu indeksu
25
+ # ENABLE_PINECONE_SEMANTIC="true"
26
+
27
+ # Database (PostgreSQL production / Hugging Face Spaces)
28
+ # Local docker-compose: postgresql://user:password@localhost:5432/dotacje
29
+ # Managed / HF Spaces (Aiven, Neon, Supabase, Render): ALWAYS use SSL —
30
+ # DATABASE_URL="postgresql://user:pass@host:5432/dbname?sslmode=require"
31
+ # Or omit ?sslmode= and set:
32
+ # DB_SSLMODE=require
33
+ # Remote hosts default to sslmode=require; localhost defaults to prefer.
34
+ # HF: set secrets in Space Settings → Variables (repo .env is NOT deployed).
35
+ # Neon → Aiven: docs/MIGRATION_NEON_TO_AIVEN.md
36
+ # DB_POOL_RECYCLE=1800
37
+
38
+ # Tracing and Observability
39
+ LANGCHAIN_API_KEY="YOUR_LANGSMITH_API_KEY"
40
+ LANGCHAIN_TRACING_V2="true"
41
+ LANGCHAIN_PROJECT="GrantForgeAI"
42
+ # Frontend (Vercel): optional Sentry browser DSN — omit to skip init (ErrorBoundary still works)
43
+ # VITE_SENTRY_DSN="https://...@o....ingest.sentry.io/..."
44
+ # VITE_POSTHOG_KEY="phc_..."
45
+ # VITE_CLERK_PUBLISHABLE_KEY="pk_..."
46
+ # VITE_API_URL="https://your-api.example.com/api"
47
+
48
+ # Graph Database (GraphRAG)
49
+ NEO4J_URI="neo4j+s://your-neo4j-instance.databases.neo4j.io"
50
+ NEO4J_USERNAME="neo4j"
51
+ NEO4J_PASSWORD="your-secure-password"
52
+
53
+ # Web Scraping (Required for Grant extraction)
54
+ # Crawl4AI — darmowy scraper (bez klucza API). Ustaw CRAWL4AI_USE_BROWSER=1 dla stron wymagających JS.
55
+ # CRAWL4AI_USE_BROWSER=0
56
+
57
+ # Regional RPO BIP parsers (F4.4)
58
+ # ENABLE_RPO_BIP_FETCH=true # 6 woj.: lubelskie, podkarpackie, łódzkie, mazowieckie, śląskie, wielkopolskie — cache 6h
59
+
60
+ # Development Flags
61
+ # Set to 'true' to serve fallback mock data if scraping yields < 3 results
62
+ MOCK_GRANTS="false"
63
+
64
+ # ── Auth / Clerk (P0 security) ──────────────────────────────────────────────
65
+ # Required in production (HF Spaces Secrets) — fail-closed without these:
66
+ # CLERK_ISSUER="https://YOUR_INSTANCE.clerk.accounts.dev"
67
+ # Or pin JWKS directly (preferred if custom domain):
68
+ # CLERK_JWKS_URL="https://YOUR_INSTANCE.clerk.accounts.dev/.well-known/jwks.json"
69
+ # Optional JWT audience check (enable when Clerk JWT template sets aud):
70
+ # CLERK_AUDIENCE="grantforge-api"
71
+ # Clerk webhook signing secret (Dashboard → Webhooks → Signing Secret):
72
+ # CLERK_WEBHOOK_SECRET="whsec_..."
73
+ # Without it, POST /api/webhooks/clerk returns 500 (fail-closed). Startup logs a warning.
74
+ # Dev-only backdoor (NEVER set on HF production):
75
+ # ENV=dev
76
+ # ALLOW_DEV_TOKEN=true
77
+ # Internal seed endpoint (admin JWT required; also blocked in prod unless):
78
+ # ALLOW_INTERNAL_SEED=true
79
+ # Explicit frontend origins for CORS (comma-separated); FRONTEND_URL also added:
80
+ # CORS_ORIGINS="https://grantforge-ai.vercel.app,https://grantforge.pl"
81
+ # FRONTEND_URL="https://grantforge-ai.vercel.app"
82
+
83
+ # ── Multi-worker SSE tickets (generator stream) ──────────────────────────────
84
+ # Optional Redis; otherwise SQLite file under TMPDIR is used (multi-worker safe).
85
+ # REDIS_URL=redis://localhost:6379/0
86
+ # TICKET_REDIS_URL=redis://localhost:6379/1
87
+ # STREAM_TICKET_TTL_SECONDS=90
88
+ # TICKET_STORE_PATH=/tmp
89
+
90
+ # ── Company registries (enrichment) ──────────────────────────────────────────
91
+ # GUS_API_KEY= # BIR1.1 production key (20 chars)
92
+ # CEIDG_API_KEY= # dane.biznes.gov.pl CEIDG v2
93
+ # CEIDG_API_BASE=https://dane.biznes.gov.pl/api/ceidg/v2
94
+ # CEIDG_DISABLED=false
95
+ # SUDOP_API_BASE=https://api-sudop.uokik.gov.pl/sudop-api/1.0.0
96
+ # SUDOP_DISABLED=false
97
+ # BizRaport financial control — official API /api/dane (never commit real credentials)
98
+ # BIZRAPORT_EMAIL=your@email.pl
99
+ # BIZRAPORT_PASSWORD=
100
+ # BIZRAPORT_BASE_URL=https://api.bizraport.pl
101
+ # BIZRAPORT_ROZSZERZ_POLACZENIA=false
102
+ # BIZRAPORT_API_URL=https://your-proxy.example/bizraport/{nip}
103
+ # BIZRAPORT_API_KEY=
104
+ # BIZRAPORT_DISABLED=false
105
+ # BIZRAPORT_TIMEOUT=12
106
+
107
+ # ── Grant Pulse (phases A–D) ─────────────────────────────────────────────────
108
+ # ENABLE_PULSE_WORKER=true # process ResearchJob + eurlex batch in scheduler
109
+ # DISABLE_PULSE_WORKER=false
110
+ # PULSE_WORKER_MAX_JOBS=25
111
+ # PULSE_NEW_DAYS=14 # "new" window
112
+ # PULSE_CLOSING_DAYS=30 # "closing" window
113
+ # ENABLE_DEADLINE_BACKFILL=true # enqueue/persist missing deadlines from text
114
+ # DOC_INTEL_MIN_CHARS=120 # HTML thinner than this → Crawl4AI fallback
115
+ # ENABLE_STEALTH_FETCH=true # curl_cffi Chrome fingerprint (PARP/BGK/WAF)
116
+ # STEALTH_IMPERSONATE=chrome # chrome|chrome120|chrome124|chrome131
117
+ # STEALTH_TIMEOUT=30
118
+ # STEALTH_DOMAINS=parp.gov.pl,bgk.pl,ncbr.gov.pl
119
+ # SSL_RELAXED_DOMAINS=wfosigw.pl,zus.pl # PDF/HTML TLS verify=False after primary fail
120
+ # FLAKY_FETCH_DOMAINS=funduszeeuropejskie.gov.pl
121
+ # FLAKY_FETCH_TIMEOUT=45
122
+ # FLAKY_PREFER_CACHE=true
123
+ # FLAKY_CACHE_ONLY=false # true = skip live scrape for flaky domains
124
+ # ENABLE_LIVE_RESEARCH=true # detect_grant_changes (false on HF free by default)
125
+ # ENABLE_EURLEX_BATCH=true # batch CELEX/DU grounding (legal-ID only, never free-text)
126
+ # ENABLE_EURLEX_NETWORK=false # true = live EUR-Lex SPARQL (off for CI/HF)
127
+ # ENABLE_LAW_WATCH=true
128
+ # ENABLE_STRATEGY_CASCADE=true # strategy recommend cascade 2026 (default on); false → paths still returned, cascade_enforced=false
129
+ # ENABLE_ELIGIBILITY_SPINE=true # eligibility spine gates for readiness (default on)
130
+ # ENABLE_TAX_PATHS=true # tax checklist attach on tax_psi_ulgi (default on)
131
+ # ENABLE_BK2021=false # BK2021 B2B stub (default OFF); ON = draft alert only, never live scrape
132
+
133
+ # LAW_WATCHLIST_JSON=[{"program":"FENG","url":"https://..."}]
134
+ # LAW_WATCHLIST_PATH=/path/to/watchlist.json
135
+ # CLERK_WEBHOOK_SECRET=whsec_... # required for Clerk webhooks (fail-closed without)
136
+ # GUS_API_KEY= # BIR 1.1 company lookup (prod)
137
+ # REDIS_URL= # multi-worker SSE tickets (falls back to SQLite)
138
+ # ADMIN_EMAIL=bogmaz1@gmail.com # Nexus Control allowlist (or ADMIN_EMAILS=a@x,b@y)
139
+ # ADMIN_USER_IDS=user_xxx # Clerk user ids (comma-separated)
140
+ # ADMIN_CLERK_IDS= # alias of ADMIN_USER_IDS
141
+ # CLERK_SECRET_KEY= # optional: enrich admin check from Clerk user API
142
+ # GENERATOR_STREAM_TICKET_TTL=1800 # SSE ticket lifetime (seconds) for autopilot stream
143
+ # STREAM_TICKET_TTL_SECONDS=90 # default for other short-lived tickets
backend/Dockerfile ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11.9-slim
2
+
3
+ # Install necessary system packages
4
+ RUN apt-get update && apt-get install -y \
5
+ build-essential \
6
+ libpq-dev \
7
+ gcc \
8
+ && rm -rf /var/lib/apt/lists/*
9
+
10
+ WORKDIR /app
11
+
12
+ # Upgrade pip
13
+ RUN pip install --upgrade pip
14
+
15
+ # Copy requirements and install dependencies
16
+ COPY requirements.txt .
17
+ RUN pip install --no-cache-dir -r requirements.txt
18
+
19
+ # Create cache directory for Hugging Face Transformers
20
+ ENV TRANSFORMERS_CACHE=/tmp/huggingface_cache
21
+ RUN mkdir -p /tmp/huggingface_cache && chmod 777 /tmp/huggingface_cache
22
+
23
+ # Copy application code
24
+ COPY . .
25
+
26
+ # Set permissions for Hugging Face Space (requires non-root user or open permissions for /app/data if any)
27
+ RUN chmod -R 777 /app
28
+
29
+ # Run migrations and start FastAPI server
30
+ CMD uvicorn server:app --host 0.0.0.0 --port 7860
backend/add_keys.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import glob
3
+ import re
4
+
5
+ directory = "/home/user/PROGRAMY/DOTACJE/backend/core/search/sources"
6
+ files = glob.glob(os.path.join(directory, "*_source.py"))
7
+
8
+ for file_path in files:
9
+ with open(file_path, "r", encoding="utf-8") as f:
10
+ content = f.read()
11
+
12
+ # We want to insert 'last_verified': '2026-05-23' and 'verified_by': 'manual'
13
+ # before the "source": line in the dictionaries.
14
+ # Pattern to find: "source": "something",
15
+ pattern = r'("source":\s*"[^"]+",)'
16
+ replacement = r'"last_verified": "2026-05-23",\n "verified_by": "manual",\n \1'
17
+
18
+ new_content = re.sub(pattern, replacement, content)
19
+
20
+ if new_content != content:
21
+ with open(file_path, "w", encoding="utf-8") as f:
22
+ f.write(new_content)
23
+ print(f"Zaktualizowano: {file_path}")
backend/agents/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Moduł Agentów dla DotacjeAI
backend/agents/auditor.py ADDED
@@ -0,0 +1,581 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Agencja Krytyka — Multi-Perspektywowy Audytor Wniosków.
3
+
4
+ FAZA 4: Pydantic structured output z confidence_score + human_review_required.
5
+ FAZA 5: Trzy role audytorów (Prawnik, Finansista, Innowator) → scalony wynik.
6
+
7
+ Zgodność: AI Act Art. 13 (transparency), Art. 14 (human oversight).
8
+ """
9
+
10
+ import logging
11
+ import json
12
+ from typing import List, Dict, Literal, Any
13
+ from pydantic import BaseModel, Field
14
+ from core.llm_router import get_llm
15
+ from core.search.regulation_engine import regulation_engine, citation_verifier, kruczkowski_trap_agent
16
+ from core.audit_logger import audit_log
17
+ from tenacity import retry, stop_after_attempt, wait_exponential
18
+
19
+ # v5.0 Orchestrator + GroundingCertificate wiring into auditor (full traceability)
20
+ try:
21
+ from backend.gsd.gsd_orchestrator import GrantforgeGSDOrchestrator
22
+ from backend.gsd import create_gsd_state
23
+ except Exception:
24
+ try:
25
+ from gsd.gsd_orchestrator import GrantforgeGSDOrchestrator
26
+ from gsd import create_gsd_state
27
+ except Exception:
28
+ GrantforgeGSDOrchestrator = None
29
+ create_gsd_state = None
30
+
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ # ──────────────────────────────────────────────────────────────────────────────
36
+ # Modele Pydantic (FAZA 4 — strukturyzowane wyjście)
37
+ # ──────────────────────────────────────────────────────────────────────────────
38
+
39
+
40
+ class AuditIssue(BaseModel):
41
+ category: str = Field(
42
+ description="Kategoria błędu, np. 'Budżet', 'Wykluczenia', 'DNSH', 'Spójność logiki'."
43
+ )
44
+ severity: Literal["critical", "high", "medium", "low"] = Field(
45
+ description="Powaga błędu."
46
+ )
47
+ message: str = Field(
48
+ description="Opis wskazanego błędu wraz ze zidentyfikowaną niespójnością."
49
+ )
50
+ rule_citation: str = Field(
51
+ default="",
52
+ description="Cytat lub nazwa przywołanej reguły / paragrafu regulaminu.",
53
+ )
54
+ recommendation: str = Field(
55
+ default="", description="Rekomendacja: co i jak poprawić."
56
+ )
57
+ affected_section: str = Field(
58
+ default="", description="Tytuł sekcji wniosku, w której znaleziono błąd."
59
+ )
60
+ problem_quote: str = Field(
61
+ default="", description="Krótki cytat problematycznego zdania z wniosku."
62
+ )
63
+ perspective: str = Field(
64
+ default="generalny",
65
+ description="Rola audytora, który znalazł błąd (prawnik/finansista/innowator/generalny).",
66
+ )
67
+ xai_justification: str = Field(
68
+ default="", description="Dogłębne wyjaśnienie AI (Explainable AI) dlaczego uznano to za błąd, w oparciu o fakty z dokumentu i logikę."
69
+ )
70
+
71
+
72
+ class GlobalAuditOutput(BaseModel):
73
+ """
74
+ Ustrukturyzowany wynik audytu całego wniosku dotacyjnego.
75
+ FAZA 4: confidence_score + human_review_required.
76
+ """
77
+
78
+ is_approved: bool = Field(
79
+ description="Czy wniosek nadaje się do wysłania bez krytycznych błędów."
80
+ )
81
+ export_status: Literal["blocked", "warning", "ok"] = Field(
82
+ description="Stan eksportu: blocked (błąd krytyczny), warning (błędy wysokie), ok (brak poważnych)."
83
+ )
84
+ overall_score: int = Field(description="Ogólna ocena poprawności w skali 0–100.")
85
+ confidence_score: float = Field(
86
+ default=0.85,
87
+ description="Pewność modelu co do wyników audytu (0.0–1.0). Wartość < 0.7 → wymaga weryfikacji człowieka.",
88
+ )
89
+ human_review_required: bool = Field(
90
+ default=False,
91
+ description="True gdy score < 60 lub istnieją błędy critical → wymaga weryfikacji eksperta.",
92
+ )
93
+ issues: List[AuditIssue] = Field(
94
+ description="Wykryte błędy, rozbieżności i nieprawidłowości formalne."
95
+ )
96
+ perspectives_summary: Dict[str, str] = Field(
97
+ default_factory=dict,
98
+ description="Skrótowe opinie poszczególnych ról audytorów (prawnik/finansista/innowator).",
99
+ )
100
+ ai_disclaimer: str = Field(
101
+ default="Wynik audytu wygenerowany przez AI na podstawie regulaminów programu. "
102
+ "Zalecana weryfikacja przez doradcę dotacyjnego lub radcę prawnego przed złożeniem wniosku.",
103
+ description="Obowiązkowy disclaimer AI Act Art. 13.",
104
+ )
105
+ xai_justification: str = Field(
106
+ default="", description="Globalne wyjaśnienie Explainable AI dlaczego przyznano taki overall_score i confidence_score."
107
+ )
108
+ cross_check_passed: bool = Field(
109
+ default=True, description="Czy projekt pomyślnie przeszedł weryfikację krzyżową (budżet vs harmonogram)."
110
+ )
111
+
112
+
113
+ # ─────────────────────────────────────────────────────────────���────────────────
114
+ # Pomocnicze prompty per rola (FAZA 5 — Multi-Perspective Audit)
115
+ # ──────────────────────────────────────────────────────────────────────────────
116
+
117
+ _ROLE_PROMPTS = {
118
+ "prawnik": """
119
+ Jesteś PRAWNIKIEM DOTACYJNYM specjalizującym się w polskim prawie i regulacjach UE.
120
+ Analizujesz WYŁĄCZNIE aspekty prawno-formalne:
121
+ - Kwalifikowalność kosztów (zakaz podwójnego finansowania, de minimis)
122
+ - Wykluczenia prawne (zakaz działalności z aneksów rozporządzeń)
123
+ - DNSH (Do No Significant Harm) — zgodność z taksonomią UE
124
+ - Warunki formalne dokumentacji (daty, podpisy, pełnomocnictwa)
125
+ - Zgodność z Rozporządzeniem UE 2021/1060 i krajowymi wytycznymi MFiPR
126
+
127
+ Zwróć TYLKO błędy prawne i formalne. Ignoruj aspekty innowacyjności czy ROI.
128
+ """,
129
+ "finansista": """
130
+ Jesteś ANALITYKIEM FINANSOWYM specjalizującym się w budżetach projektów dotacyjnych.
131
+ Analizujesz WYŁĄCZNIE aspekty finansowe:
132
+ - Budżet vs Harmonogram rzeczowo-finansowy (spójność kwot i terminów)
133
+ - Racjonalność kosztów (rynkowość cen, uzasadnienie wydatków)
134
+ - Limity intensywności pomocy dla danej kategorii firmy
135
+ - Koszty pośrednie (ryczałt / metoda rzeczywista — poprawność zastosowania)
136
+ - Ryzyko finansowe projektu i zabezpieczenia
137
+
138
+ Zwróć TYLKO błędy finansowe i rachunkowe. Ignoruj kwestie prawne i innowacyjność.
139
+ """,
140
+ "innowator": """
141
+ Jesteś EKSPERTEM OD INNOWACJI oceniającym potencjał i spójność merytoryczną projektu.
142
+ Analizujesz WYŁĄCZNIE aspekty merytoryczno-innowacyjne:
143
+ - Poziom innowacyjności (czy projekt jest wystarczająco innowacyjny dla danego programu?)
144
+ - Spójność logiczna: cele → działania → rezultaty → wskaźniki (logframe)
145
+ - Opis prac B+R (czy istnieje element badawczy i jest właściwie uzasadniony?)
146
+ - Potencjał komercjalizacji i skalowalność
147
+ - Opis ryzyk projektu i plany mitigacji
148
+
149
+ Zwróć TYLKO błędy merytoryczne i innowacyjne. Ignoruj kwestie prawne i finansowe.
150
+ """,
151
+ }
152
+
153
+ _SHARED_INSTRUCTIONS = """
154
+ Pamiętaj:
155
+ - Zastosuj RIGOROUS SCORING: Oceny punktowe (partial_score) muszą być rygorystyczne. Obniżaj punkty surowo za brak dowodów lub braki logiczne w dokumencie. Nie przyznawaj 100 punktów, chyba że sekcje są idealne.
156
+ - Przeprowadź STRICT CROSS-CHECKS: Porównaj czy to, co jest opisane w harmonogramie, znajduje swoje bezpośrednie i kwotowe odzwierciedlenie w budżecie.
157
+ - Explainable AI (XAI): W polach `xai_justification` bardzo precyzyjnie wyjaśnij proces decyzyjny (dlaczego obniżono punktację, skąd wzięto takie, a nie inne wnioski, dlaczego zidentyfikowano niespójność).
158
+ - Zawsze oznaczaj wszelkie `inconsistencies` (niespójności) jako osobne problemy, podając powód i lokalizację.
159
+
160
+ - Absolutny zakaz halucynacji. Jeśli nie masz pewności — napisz "Brak wystarczających informacji."
161
+ - Zawsze odpowiadaj po polsku, używając precyzyjnego, urzędowego języka.
162
+ - Podaj CYTAT, REKOMENDACJĘ i XAI_JUSTIFICATION dla każdego defektu.
163
+ - Wskaż affected_section (tytuł sekcji) i problem_quote (krótki cytat).
164
+ - UWAGA: Jako `affected_section` MUSISZ użyć jednej z poniższych dokładnych nazw (nie wymyślaj własnych!):
165
+ "Streszczenie Projektu", "Opis przedsiębiorstwa i potencjał", "Opis innowacji / B+R",
166
+ "Analiza rynku i konkurencji", "Agenda badawcza / cele", "Poziom gotowości technologii (TRL)",
167
+ "Budżet i kwalifikowalność kosztów", "Harmonogram rzeczowo-finansowy", "Zespół projektowy",
168
+ "Zarządzanie ryzykiem", "Wpływ społeczny i środowiskowy (DNSH)", "Prawa własności intelektualnej",
169
+ "Wskaźniki sukcesu i ewaluacja", "Ogólne".
170
+ - Jeśli wniosek nie ma błędów i jest idealny, zwróć pustą listę `issues` i ustaw `partial_score` na 100. Wynik 0 oznacza krytyczny brak zgodności.
171
+ """
172
+
173
+
174
+ # ──────────────────────────────────────────────────────────────────────────────
175
+ # Główna funkcja audytu (sync wrapper nad async)
176
+ # ──────────────────────────────────────────────────────────────────────────────
177
+
178
+
179
+ class _PerspectiveResult(BaseModel):
180
+ """Wynik cząstkowy jednej roli audytora."""
181
+
182
+ issues: List[AuditIssue] = Field(default_factory=list)
183
+ summary: str = Field(default="")
184
+ partial_score: int = Field(default=100)
185
+
186
+
187
+ async def _run_perspective_audit(
188
+ role: str,
189
+ role_prompt: str,
190
+ program_name: str,
191
+ content: str,
192
+ ) -> _PerspectiveResult:
193
+ """Wywołanie LLM dla jednej roli audytora."""
194
+ llm = get_llm(task_type="legal_audit", structured_output_schema=_PerspectiveResult)
195
+ prompt = f"""{role_prompt}
196
+
197
+ {_SHARED_INSTRUCTIONS}
198
+
199
+ Nazwa/Typ programu: {program_name}
200
+
201
+ TREŚĆ WNIOSKU:
202
+ ---------------------
203
+ {content[:150000]}
204
+ ---------------------
205
+
206
+ Oceń wniosek ze swojej perspektywy ({role}) i zwróć: issues, summary (2-3 zdania), partial_score (0-100).
207
+ """
208
+
209
+ @retry(
210
+ stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10)
211
+ )
212
+ def _invoke_llm():
213
+ return llm.invoke(prompt)
214
+
215
+ try:
216
+ result: _PerspectiveResult = _invoke_llm()
217
+ return result
218
+ except Exception as e:
219
+ logger.warning(f"[MultiAudit][{role}] Błąd perspektywy: {e}")
220
+ return _PerspectiveResult(
221
+ summary=f"Perspektywa {role} — błąd LLM: {str(e)[:100]}", partial_score=50
222
+ )
223
+
224
+
225
+ def _compute_final_score(scores: List[int], has_critical: bool) -> int:
226
+ """Średnia ważona wyników perspektyw. Kara za critical."""
227
+ if not scores:
228
+ return 0
229
+ base = int(sum(scores) / len(scores))
230
+ return max(0, base - 20) if has_critical else base
231
+
232
+
233
+ def audit_final_document(
234
+ project_id: str,
235
+ program_name: str,
236
+ content: str,
237
+ enable_multi_perspective: bool = True,
238
+ is_external_audit: bool = False,
239
+ structured_regulation_rules: str = "",
240
+ light_mode: bool = False, # token optimization Faza4
241
+ skip_orchestrator: bool = False,
242
+ ) -> GlobalAuditOutput:
243
+ """
244
+ Agencja Krytyka — główny punkt wejścia.
245
+
246
+ Parametry:
247
+ project_id: ID projektu (do logowania)
248
+ program_name: Nazwa programu (FENG, KPO, etc.)
249
+ content: Pełna treść wygenerowanego wniosku
250
+ enable_multi_perspective: Włącz 3 role audytorów (domyślnie True)
251
+ structured_regulation_rules: Wyciągnięte przez Regulation Engine kluczowe reguły (key_rules, exclusions itp.)
252
+ light_mode: early exit + fast path for token savings (Faza4)
253
+
254
+ Zwraca:
255
+ GlobalAuditOutput z issues, score, confidence, human_review_required
256
+ """
257
+ if light_mode:
258
+ # Token optimization: early exit for light paths (used in harness + quick gen)
259
+ return GlobalAuditOutput(
260
+ is_approved=True,
261
+ export_status="ok",
262
+ overall_score=72,
263
+ confidence_score=0.78,
264
+ human_review_required=False,
265
+ issues=[],
266
+ xai_justification="Light-mode nie przeprowadza pełnej weryfikacji LLM.",
267
+ cross_check_passed=True,
268
+ )
269
+
270
+ if not content or len(content.strip()) < 50:
271
+ from core.telemetry import telemetry
272
+
273
+ telemetry.log(
274
+ "WARN",
275
+ "Auditor",
276
+ "Dokument zbyt krótki do audytu",
277
+ {"project_id": project_id},
278
+ )
279
+ return GlobalAuditOutput(
280
+ is_approved=False,
281
+ export_status="blocked",
282
+ overall_score=0,
283
+ confidence_score=1.0,
284
+ human_review_required=True,
285
+ issues=[
286
+ AuditIssue(
287
+ category="Formalności",
288
+ severity="critical",
289
+ message="Dokument jest pusty lub zbyt krótki do przeprowadzenia audytu.",
290
+ rule_citation="Minimum objętościowe wniosku",
291
+ recommendation="Wygeneruj zawartość wniosku przed uruchomieniem audytu.",
292
+ xai_justification="Brak wystarczającej długości tekstu do analizy."
293
+ )
294
+ ],
295
+ xai_justification="Dokument zbyt krótki by móc przeprowadzić wiarygodny audyt krzyżowy.",
296
+ cross_check_passed=False,
297
+ )
298
+
299
+ all_issues: List[AuditIssue] = []
300
+ perspectives_summary: Dict[str, str] = {}
301
+ perspective_scores: List[int] = []
302
+
303
+ # v5.0 Master Orchestrator + layers inside auditor (Citation/Kruczkowski/snapshots + cert for traceability)
304
+ v5_audit_meta: Dict[str, Any] = {}
305
+ if not skip_orchestrator:
306
+ try:
307
+ if GrantforgeGSDOrchestrator and create_gsd_state and program_name:
308
+ orch_state = create_gsd_state(project_id=project_id or "audit", profile={"pkd_codes": []})
309
+ orch = GrantforgeGSDOrchestrator(state=orch_state)
310
+ # Run minimal orchestrated verify+audit slice (reuses _call_v5_layers)
311
+ _ = orch.run_master_orchestrated_flow(user_query=content[:300] or "audit flow", max_stages=4)
312
+ if orch.state.grounding_certificate_v5:
313
+ v5_audit_meta["grounding_certificate_v5"] = orch.state.grounding_certificate_v5.model_dump() if hasattr(orch.state.grounding_certificate_v5, "model_dump") else {}
314
+ v5_audit_meta["orchestrator_stages"] = [s.get("stage") for s in orch.state.orchestrator_stage_history[-4:]]
315
+ v5_audit_meta["synthesis"] = orch.state.synthesis_trace
316
+ logger.info(f"[Auditor v5.0] Orchestrator layers + cert wired into audit for {project_id}")
317
+ except Exception as _v5aud:
318
+ logger.debug(f"[Auditor v5.0] Non-fatal orchestrator hook: {_v5aud}")
319
+
320
+ # ── Blok Multi-Perspective (FAZA 5) ───────────────────────────────────────
321
+ if enable_multi_perspective:
322
+ logger.info(
323
+ f"[Audytor] Uruchamianie audytu multi-perspektywowego(LangGraph) dla projektu {project_id}"
324
+ )
325
+ from core.telemetry import telemetry
326
+
327
+ telemetry.log(
328
+ "INFO",
329
+ "Auditor",
330
+ "Uruchamianie audytu multi-perspektywowego",
331
+ {"project_id": project_id},
332
+ )
333
+
334
+ try:
335
+ from agents.auditor_panel_graph import auditor_panel_app
336
+
337
+ initial_state = {
338
+ "project_id": project_id,
339
+ "program_name": program_name,
340
+ "content": content,
341
+ "is_external_audit": is_external_audit,
342
+ "issues": [],
343
+ "perspectives_summary": {},
344
+ "perspective_scores": [],
345
+ "legal_attempts": 0,
346
+ "legal_queries": [],
347
+ "messages": [],
348
+ "prawnik_done": False,
349
+ "finansista_attempts": 0,
350
+ "finansista_queries": [],
351
+ "finansista_messages": [],
352
+ "finansista_done": False,
353
+ "innowator_attempts": 0,
354
+ "innowator_queries": [],
355
+ "innowator_messages": [],
356
+ "innowator_done": False,
357
+ }
358
+
359
+ # Synchronous execution of the state graph with increased recursion limit
360
+ result_state = auditor_panel_app.invoke(
361
+ initial_state, config={"recursion_limit": 150}
362
+ )
363
+
364
+ # Extrakcja finalnego wyniku z węzła zarządzającego
365
+ if "final_output" in result_state and result_state["final_output"]:
366
+ logger.info(
367
+ f"[Audytor] Pomyślnie zakończono graf LangGraph. Status: {result_state['final_output'].export_status}"
368
+ )
369
+ return result_state["final_output"]
370
+ else:
371
+ logger.warning(
372
+ "[Audytor] Graf zakończył pracę, ale nie zwrócił final_output. Fallback."
373
+ )
374
+ enable_multi_perspective = False
375
+
376
+ except Exception as e:
377
+ logger.error(
378
+ f"[Audytor] Błąd multi-perspektywowego grafu LangGraph: {e}. Fallback na audyt ogólny."
379
+ )
380
+ enable_multi_perspective = False
381
+
382
+ # ── Fallback: audyt ogólny (jeśli multi-perspective wyłączony lub failed) ─
383
+ if not enable_multi_perspective or not all_issues:
384
+ logger.info(f"[Audytor] Audyt generalny dla projektu {project_id}")
385
+ from core.telemetry import telemetry
386
+
387
+ telemetry.log(
388
+ "INFO",
389
+ "Auditor",
390
+ "Uruchamianie audytu generalnego (Fallback)",
391
+ {"project_id": project_id},
392
+ )
393
+ try:
394
+ llm_general = get_llm(
395
+ task_type="legal_audit", structured_output_schema=GlobalAuditOutput
396
+ )
397
+ regulation_rules_block = ""
398
+ if structured_regulation_rules:
399
+ regulation_rules_block = f"""
400
+
401
+ WYCIĄGNIĘTE STRUKTURALNE REGUŁY Z REGULAMINU (z Regulation Engine - traktuj jako źródło prawdy):
402
+ {structured_regulation_rules[:4000]}
403
+ """
404
+
405
+ general_prompt = f"""
406
+ Jesteś surowym, precyzyjnym audytorem dotacyjnym specjalizującym się w polskim prawie funduszy europejskich.
407
+ {"Pamiętaj, że weryfikujesz wniosek z firmy doradczej (zewnętrzny), musisz surowo wyłapać ich błędy. Porównuj z przekazanym kontekstem regulaminowym." if is_external_audit else ""}
408
+ Zakaz halucynacji. Jeśli nie masz pewności — napisz: "Brak wystarczających informacji."
409
+ Odpowiadaj po polsku, precyzyjnym urzędowym językiem.
410
+
411
+ Nazwa/Typ programu: {program_name}
412
+ {regulation_rules_block}
413
+
414
+ Zastosuj RIGOROUS SCORING: Oceny punktowe muszą odzwierciedlać braki dowodów na innowacyjność, braki budżetowe.
415
+ Przeprowadź STRICT CROSS-CHECKS: Porównaj czy zadania z agendy/harmonogramu znajdują pełne odzwierciedlenie w budżecie. Oflaguj to wyraźnie w `cross_check_passed`.
416
+ Podejście XAI (Explainable AI): Wyjaśnij powody swojej punktacji i decyzji w `xai_justification` (zarówno globalnie, jak i per błąd). Skąd wynikają błędy, wskaż wyraźne powody.
417
+
418
+ Wykonaj weryfikację krzyżową (Cross-Check):
419
+ 1. Zgodność z celami programu
420
+ 2. Budżet vs Harmonogram (spójność kwot i terminów)
421
+ 3. Koszty kwalifikowalne i wykluczenia
422
+ 4. Zasada DNSH (Do No Significant Harm) — zgodność klimatyczna
423
+ 5. Warunki formalne i zakaz podwójnego finansowania
424
+ 6. Rozbieżności merytoryczne między sekcjami
425
+
426
+ Podaj CYTAT, REKOMENDACJĘ i XAI_JUSTIFICATION dla każdego defektu.
427
+ Jako affected_section użyj TYLKO jednej z nazw: "Streszczenie Projektu", "Opis przedsiębiorstwa i potencjał", "Opis innowacji / B+R", "Analiza rynku i konkurencji", "Agenda badawcza / cele", "Poziom gotowości technologii (TRL)", "Budżet i kwalifikowalność kosztów", "Harmonogram rzeczowo-finansowy", "Zespół projektowy", "Zarządzanie ryzykiem", "Wpływ społeczny i środowiskowy (DNSH)", "Prawa własności intelektualnej", "Wskaźniki sukcesu i ewaluacja", "Ogólne".
428
+ Wskaż problem_quote.
429
+ Ustaw confidence_score (0.0–1.0) oraz human_review_required (True gdy score<60 lub błąd critical). Ustaw cross_check_passed na False jeśli harmonogram lub budżet lub cele rozjeżdżają się ze sobą.
430
+
431
+ TREŚĆ WNIOSKU:
432
+ ---------------------
433
+ {content[:10000]}
434
+ ---------------------
435
+ """
436
+
437
+ @retry(
438
+ stop=stop_after_attempt(3),
439
+ wait=wait_exponential(multiplier=1, min=2, max=10),
440
+ )
441
+ def _invoke_general_llm():
442
+ return llm_general.invoke(general_prompt)
443
+
444
+ result: GlobalAuditOutput = _invoke_general_llm()
445
+
446
+ # === Głębsza integracja RegulationEngine (Faza 3) ===
447
+ # Aktywnie weryfikujemy koszty przy pomocy silnika, szczególnie przy audycie zewnętrznym
448
+ if is_external_audit or any("budżet" in i.category.lower() or "koszt" in i.category.lower() for i in result.issues):
449
+ try:
450
+ # Wyciągamy potencjalne opisy kosztów z treści wniosku (uproszczone)
451
+ budget_related_text = content[:3000] # w pełnej wersji można to zrobić inteligentniej
452
+ eligibility = regulation_engine.check_cost_eligibility(program_name or "", budget_related_text)
453
+
454
+ if eligibility.get("status") == "evaluated":
455
+ if not eligibility.get("eligible") or eligibility.get("severity") in ["high", "critical"]:
456
+ result.issues.append(AuditIssue(
457
+ category="Kwalifikowalność kosztów",
458
+ severity=eligibility.get("severity", "high"),
459
+ message=f"Regulation Engine: {eligibility.get('justification', '')}",
460
+ rule_citation=eligibility.get("regulation_reference", ""),
461
+ recommendation=eligibility.get("recommendation", "Sprawdź zgodność z regulaminem."),
462
+ affected_section="Budżet i kwalifikowalność kosztów",
463
+ perspective="finansista",
464
+ xai_justification="Koszt zablokowany przez Regulation Engine."
465
+ ))
466
+ # Dla zewnętrznych wniosków — podnosimy ryzyko eksportu
467
+ if is_external_audit and eligibility.get("severity") == "critical":
468
+ result.human_review_required = True
469
+ result.export_status = "blocked" if result.export_status != "blocked" else result.export_status
470
+ if is_external_audit:
471
+ result.human_review_required = True
472
+ except Exception as eng_e:
473
+ logger.warning(f"RegulationEngine check failed in auditor: {eng_e}")
474
+
475
+ # === v5.0 Faza1 wiring: full Kruczkowski trap + CitationVerifier on full content for auditor (esp. external) ===
476
+ try:
477
+ if kruczkowski_trap_agent and (is_external_audit or result.overall_score < 75):
478
+ trap_res = kruczkowski_trap_agent.detect_traps(content[:5500], program_name or "UNKNOWN", msp_context={"audit": True})
479
+ if trap_res.get("overall_trap_risk") in ("high", "critical"):
480
+ result.issues.append(AuditIssue(
481
+ category="Pułapki compliance (Kruczkowski v5)",
482
+ severity=trap_res["overall_trap_risk"],
483
+ message=f"Wykryto {trap_res.get('num_traps',0)} pułapek: {', '.join([t.get('trap','') for t in trap_res.get('traps_detected',[])[:3]])}",
484
+ rule_citation="KruczkowskiTrap + snapshot",
485
+ recommendation="Natychmiastowa rewizja lub blokada exportu. Szczegóły w trap report.",
486
+ affected_section="Całość wniosku",
487
+ perspective="prawnik",
488
+ xai_justification="Zidentyfikowano pułapki regulaminowe (Kruczkowski v5)."
489
+ ))
490
+ if is_external_audit:
491
+ result.human_review_required = True
492
+ if trap_res.get("blocks_export_recommendation"):
493
+ result.export_status = "blocked"
494
+ if citation_verifier:
495
+ cit_res = citation_verifier.verify_text_citations(content[:4000], program_name or "UNKNOWN")
496
+ if cit_res.get("overall_citation_score", 1.0) < 0.5:
497
+ result.issues.append(AuditIssue(
498
+ category="Ugruntowanie cytatami (CitationVerifier v5)",
499
+ severity="medium",
500
+ message=f"Niski score ugruntowania: {cit_res.get('overall_citation_score')}. {cit_res.get('recommendation','')}",
501
+ rule_citation="; ".join([str(r.get('regulation_refs_used',''))[:60] for r in cit_res.get('per_claim_results',[])[:2] if isinstance(r,dict)]),
502
+ recommendation=cit_res.get("recommendation", "Dodaj bezpośrednie odniesienia do reguł snapshotu."),
503
+ affected_section="Całość",
504
+ perspective="prawnik",
505
+ xai_justification="Niski współczynnik weryfikacji cytowań do regulaminu."
506
+ ))
507
+ except Exception as v5e:
508
+ logger.info(f"[Auditor v5 wiring] non-fatal: {v5e}")
509
+
510
+ # Zapewnij human_review logikę
511
+ result.human_review_required = result.overall_score < 60 or any(
512
+ i.severity == "critical" for i in result.issues
513
+ )
514
+ result.export_status = _determine_export_status(result.issues)
515
+
516
+ # Attach v5.0 orchestrator meta + cert to audit result for downstream (export, UI)
517
+ if v5_audit_meta:
518
+ try:
519
+ result.perspectives_summary = dict(result.perspectives_summary or {})
520
+ result.perspectives_summary["v5_orchestrator"] = json.dumps(v5_audit_meta, default=str)[:1500]
521
+ except Exception:
522
+ pass
523
+
524
+ _log_audit(project_id, result)
525
+ return result
526
+
527
+ except Exception as e:
528
+ import traceback
529
+
530
+ traceback.print_exc()
531
+ return _error_output(e)
532
+
533
+ # Ścieżka nieosiągalna: multi-perspective zawsze albo zwraca final_output,
534
+ # albo przełącza się na fallback generalny (który kończy return powyżej).
535
+ # Fail-closed na wszelki wypadek — nigdy nie zwracaj cichej akceptacji.
536
+ return _error_output(RuntimeError("Audyt nie zwrócił wyniku (nieosiągalna ścieżka)."))
537
+
538
+
539
+ def _determine_export_status(
540
+ issues: List[AuditIssue],
541
+ ) -> Literal["blocked", "warning", "ok"]:
542
+ """Określa status eksportu na podstawie najpoważniejszego błędu."""
543
+ severities = {i.severity for i in issues}
544
+ if "critical" in severities:
545
+ return "blocked"
546
+ if "high" in severities:
547
+ return "warning"
548
+ return "ok"
549
+
550
+
551
+ def _log_audit(project_id: str, result: GlobalAuditOutput) -> None:
552
+ try:
553
+ audit_log(
554
+ "AUDYTOR_MULTI",
555
+ f"Projekt: {project_id} | Score: {result.overall_score} | "
556
+ f"Issues: {len(result.issues)} | HumanReview: {result.human_review_required} | "
557
+ f"Confidence: {result.confidence_score:.2f}",
558
+ )
559
+ except Exception:
560
+ pass
561
+
562
+
563
+ def _error_output(e: Exception) -> GlobalAuditOutput:
564
+ return GlobalAuditOutput(
565
+ is_approved=False,
566
+ export_status="blocked",
567
+ overall_score=0,
568
+ confidence_score=0.0,
569
+ human_review_required=True,
570
+ issues=[
571
+ AuditIssue(
572
+ category="Błąd Systemowy",
573
+ severity="critical",
574
+ message=f"Awaria mechanizmu audytu LLM: {str(e)[:200]}",
575
+ recommendation="Sprawdź logi serwera i spróbuj ponownie.",
576
+ xai_justification="Błąd w logice biznesowej aplikacji (wyjątek podczas wywołania LLM)."
577
+ )
578
+ ],
579
+ xai_justification="Błąd systemowy uniemożliwił analizę.",
580
+ cross_check_passed=False
581
+ )
backend/agents/auditor_panel_graph.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from langgraph.graph import StateGraph, START, END
2
+ from agents.panel_state import AuditorPanelState
3
+ from agents.panel_nodes import (
4
+ prawnik_node,
5
+ prawnik_tools_node,
6
+ prawnik_evaluator_node,
7
+ prawnik_routing,
8
+ finansista_node,
9
+ finansista_tools_node,
10
+ finansista_evaluator_node,
11
+ finansista_routing,
12
+ innowator_node,
13
+ innowator_tools_node,
14
+ innowator_evaluator_node,
15
+ innowator_routing,
16
+ zarzadzajacy_node,
17
+ )
18
+
19
+
20
+ def create_auditor_panel_graph():
21
+ # Definiujemy maszynę stanową
22
+ workflow = StateGraph(AuditorPanelState)
23
+
24
+ # 1. Dodawanie węzłów
25
+ workflow.add_node("prawnik", prawnik_node)
26
+ workflow.add_node("prawnik_tools", prawnik_tools_node)
27
+ workflow.add_node("prawnik_evaluator", prawnik_evaluator_node)
28
+
29
+ workflow.add_node("finansista", finansista_node)
30
+ workflow.add_node("finansista_tools", finansista_tools_node)
31
+ workflow.add_node("finansista_evaluator", finansista_evaluator_node)
32
+ workflow.add_node("innowator", innowator_node)
33
+ workflow.add_node("innowator_tools", innowator_tools_node)
34
+ workflow.add_node("innowator_evaluator", innowator_evaluator_node)
35
+
36
+ workflow.add_node("zarzadzajacy", zarzadzajacy_node)
37
+
38
+ # 2. Definiowanie krawędzi wejściowych (Równoległe odpalenie Prawnika, Finansisty i Innowatora)
39
+ workflow.add_edge(START, "prawnik")
40
+ workflow.add_edge(START, "finansista")
41
+ workflow.add_edge(START, "innowator")
42
+
43
+ # 3. Logika (Dynamic Query Routing) dla Prawnika - pętla naprawcza
44
+ workflow.add_conditional_edges(
45
+ "prawnik",
46
+ prawnik_routing,
47
+ {"tools": "prawnik_tools", "evaluate": "prawnik_evaluator"},
48
+ )
49
+ workflow.add_edge(
50
+ "prawnik_tools", "prawnik"
51
+ ) # powrót z powrotem do prawnika po narzędziu
52
+
53
+ # 3b. Logika (Dynamic Query Routing) dla Finansisty
54
+ workflow.add_conditional_edges(
55
+ "finansista",
56
+ finansista_routing,
57
+ {"tools": "finansista_tools", "evaluate": "finansista_evaluator"},
58
+ )
59
+ workflow.add_edge("finansista_tools", "finansista")
60
+
61
+ # 3c. Logika (Dynamic Query Routing) dla Innowatora
62
+ workflow.add_conditional_edges(
63
+ "innowator",
64
+ innowator_routing,
65
+ {"tools": "innowator_tools", "evaluate": "innowator_evaluator"},
66
+ )
67
+ workflow.add_edge("innowator_tools", "innowator")
68
+
69
+ # 4. Barierowa synchronizacja (Wszyscy idą do Zarządzającego)
70
+ # W LangGraph domyślnie graf czeka na wszystkie wątki z tym samym targetem zanim wykona node'a,
71
+ # jeśli node nie akceptuje update'ów sekwencyjnie. Jednak dla pewności możemy polegać po prostu na łączeniu.
72
+ # w nowszym LangGraph zrobienie tego tak działa jak scatter-gather.
73
+ workflow.add_edge("prawnik_evaluator", "zarzadzajacy")
74
+ workflow.add_edge("finansista_evaluator", "zarzadzajacy")
75
+ workflow.add_edge("innowator_evaluator", "zarzadzajacy")
76
+
77
+ workflow.add_edge("zarzadzajacy", END)
78
+
79
+ return workflow.compile()
80
+
81
+
82
+ auditor_panel_app = create_auditor_panel_graph()
backend/agents/autofill_agent.py ADDED
@@ -0,0 +1,352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Auto-Fill Agent (FAZA 3) — dedykowany agent auto-healingu luk danych w `backend/`.
3
+
4
+ Wydzielony z logiki wtopionej wcześniej w ``generator_agent.resolve_missing_data``.
5
+ Odpowiada za:
6
+
7
+ 1. Wykrywanie placeholderów w WYGENEROWANEJ treści (``[DO WERYFIKACJI: ...]``,
8
+ ``[UZUPEŁNIJ: ...]``, ``[BRAK DANYCH ...]``, ``[TODO ...]`` itp.) jako twarda
9
+ bramka jakości.
10
+ 2. Próbę uzupełnienia luk z rejestrów (GUS/REGON, KRS, SUDOP przez ProjectContext)
11
+ oraz z Research Agenta (OSINT).
12
+ 3. Zwrócenie uzupełnionej treści LUB listy luk wymagających człowieka (HITL).
13
+
14
+ Wszystkie wywołania zewnętrzne (rejestry, sieć, LLM) są owinięte w try/except,
15
+ dzięki czemu moduł jest bezpieczny i testowalny bez dostępu do sieci.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import os
22
+ import re
23
+ from dataclasses import dataclass, field
24
+ from typing import Any
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ # Placeholdery uznawane za lukę danych (twarda bramka jakości).
30
+ # Obejmuje warianty polskie z/bez znaków diakrytycznych oraz techniczne markery.
31
+ PLACEHOLDER_PATTERN = re.compile(
32
+ r"\[\s*(?:"
33
+ r"do weryfikacji|do uzupe[łl]nienia|uzupe[łl]ni[jćc]|uzupelnij|"
34
+ r"brak danych|nieznane|todo|tbd|xxx|fixme|"
35
+ r"szacowany|szacowana|szacunkowo"
36
+ r")\b[^\]]*\]",
37
+ re.IGNORECASE,
38
+ )
39
+
40
+ # P3#9: markery UPPER_SNAKE w nawiasach kwadratowych, np. [KWOTA_PLN],
41
+ # [KWOTA_INWESTYCJI_PLN], [LICZBA_PRACOWNIKÓW]. Wzorzec CASE-SENSITIVE, aby nie
42
+ # łapać zwykłego tekstu w nawiasach (małe litery / spacje). Wymaga min. 3 znaków
43
+ # i przynajmniej jednej litery (nie same cyfry/podkreślenia).
44
+ UPPER_SNAKE_PLACEHOLDER_PATTERN = re.compile(
45
+ r"\[\s*[A-ZĄĆĘŁŃÓŚŹŻ0-9_]*[A-ZĄĆĘŁŃÓŚŹŻ][A-ZĄĆĘŁŃÓŚŹŻ0-9_]*\s*\]"
46
+ )
47
+
48
+ # Krytyczne kategorie luk — ich brak realnie blokuje jakość wniosku.
49
+ _CRITICAL_HINTS = (
50
+ "nip", "regon", "krs", "kwot", "koszt", "budżet", "budzet", "przychod", "przychód",
51
+ "zatrudnien", "adres", "nazwa", "data", "wskaźnik", "wskaznik", "termin",
52
+ )
53
+
54
+
55
+ def _research_allowed_by_env() -> bool:
56
+ try:
57
+ from agents.research_agent import is_titan_research_enabled, titan_research_max_calls
58
+
59
+ return is_titan_research_enabled() and titan_research_max_calls() > 0
60
+ except Exception:
61
+ return os.environ.get("ENABLE_TITAN_RESEARCH", "true").lower() in ("1", "true", "yes")
62
+
63
+
64
+ @dataclass
65
+ class AutoFillResult:
66
+ """Wynik działania Auto-Fill Agenta na fragmencie treści."""
67
+
68
+ filled_content: str = ""
69
+ resolved: list[dict[str, str]] = field(default_factory=list)
70
+ unresolved: list[str] = field(default_factory=list)
71
+ hitl_question: str | None = None
72
+
73
+ @property
74
+ def all_resolved(self) -> bool:
75
+ return not self.unresolved
76
+
77
+ @property
78
+ def has_placeholders(self) -> bool:
79
+ return bool(self.resolved or self.unresolved)
80
+
81
+
82
+ class AutoFillAgent:
83
+ """Agent uzupełniania luk danych z rejestrów + OSINT (research)."""
84
+
85
+ # Maks. liczba placeholderów, dla których uruchamiamy kosztowny research per sekcja.
86
+ # HF free tier: default 1 (override via TITAN_RESEARCH_MAX_CALLS).
87
+ @property
88
+ def MAX_RESEARCH_CALLS(self) -> int:
89
+ try:
90
+ from agents.research_agent import titan_research_max_calls
91
+
92
+ return titan_research_max_calls()
93
+ except Exception:
94
+ try:
95
+ return max(0, int(os.environ.get("TITAN_RESEARCH_MAX_CALLS", "1")))
96
+ except (TypeError, ValueError):
97
+ return 1
98
+
99
+ # ------------------------------------------------------------------
100
+ # Wykrywanie placeholderów
101
+ # ------------------------------------------------------------------
102
+ @staticmethod
103
+ def detect_placeholders(text: str) -> list[str]:
104
+ """Zwraca listę pełnych placeholderów wykrytych w tekście (z zachowaniem kolejności)."""
105
+ if not text:
106
+ return []
107
+ seen: list[str] = []
108
+ for match in PLACEHOLDER_PATTERN.findall(text):
109
+ ph = match.strip()
110
+ if ph and ph not in seen:
111
+ seen.append(ph)
112
+ # P3#9: markery UPPER_SNAKE ([KWOTA_PLN] itp.) — min. 3 znaki wewnątrz nawiasu.
113
+ for match in UPPER_SNAKE_PLACEHOLDER_PATTERN.findall(text):
114
+ ph = match.strip()
115
+ inner = ph.strip("[] ").strip()
116
+ if len(inner) >= 3 and ph not in seen:
117
+ seen.append(ph)
118
+ return seen
119
+
120
+ @staticmethod
121
+ def _placeholder_label(placeholder: str) -> str:
122
+ """Wyciąga opis luki z wnętrza nawiasu, np. '[DO WERYFIKACJI: przychód]' -> 'przychód'."""
123
+ inner = placeholder.strip().lstrip("[").rstrip("]")
124
+ if ":" in inner:
125
+ inner = inner.split(":", 1)[1]
126
+ return inner.strip() or placeholder.strip("[]")
127
+
128
+ @staticmethod
129
+ def _is_critical(label: str) -> bool:
130
+ low = label.lower()
131
+ return any(h in low for h in _CRITICAL_HINTS)
132
+
133
+ # ------------------------------------------------------------------
134
+ # Źródła danych
135
+ # ------------------------------------------------------------------
136
+ @staticmethod
137
+ def _facts_from_project_context(project_context: Any) -> dict[str, str]:
138
+ """Buduje mapę label->wartość ze zweryfikowanych danych wnioskodawcy."""
139
+ if project_context is None:
140
+ return {}
141
+ company = getattr(project_context, "company", None)
142
+ if company is None:
143
+ return {}
144
+ try:
145
+ facts = company.known_facts()
146
+ except Exception:
147
+ return {}
148
+ # normalizuj klucze do lowercase dla dopasowania po słowach kluczowych
149
+ return {str(k).lower(): str(v) for k, v in facts.items() if v}
150
+
151
+ @staticmethod
152
+ def _match_fact(label: str, facts: dict[str, str]) -> str | None:
153
+ """Dopasowuje etykietę placeholdera do znanego faktu po słowach kluczowych."""
154
+ low = label.lower()
155
+ keyword_map = {
156
+ "nip": ["nip"],
157
+ "regon": ["regon"],
158
+ "krs": ["krs"],
159
+ "nazw": ["nazwa wnioskodawcy"],
160
+ "firm": ["nazwa wnioskodawcy"],
161
+ "adres": ["adres siedziby"],
162
+ "siedzib": ["adres siedziby"],
163
+ "wojew": ["województwo"],
164
+ "pkd": ["kody pkd"],
165
+ "przychod": ["przychód roczny"],
166
+ "przychód": ["przychód roczny"],
167
+ "obrot": ["przychód roczny"],
168
+ "zatrudnien": ["zatrudnienie"],
169
+ "pracownik": ["zatrudnienie"],
170
+ "forma prawn": ["forma prawna"],
171
+ "wielkość": ["wielkość firmy"],
172
+ "wielkosc": ["wielkość firmy"],
173
+ "mśp": ["status mśp"],
174
+ "de minimis": ["pomoc de minimis (suma eur)"],
175
+ }
176
+ # Wybieramy najbardziej specyficzne dopasowanie (najdłuższy pasujący token),
177
+ # aby np. pytanie "adres siedziby firmy" trafiło w "adres", a nie w "firm".
178
+ best_value: str | None = None
179
+ best_len = -1
180
+ for token, fact_keys in keyword_map.items():
181
+ if token in low and len(token) > best_len:
182
+ for fk in fact_keys:
183
+ if fk in facts:
184
+ best_value = facts[fk]
185
+ best_len = len(token)
186
+ break
187
+ return best_value
188
+
189
+ def _research_fill(self, label: str, context_text: str) -> str | None:
190
+ """Ostateczny fallback: Research Agent (OSINT). Soft-fail — nigdy nie zabija workera."""
191
+ if not _research_allowed_by_env():
192
+ logger.info("[AutoFill] Research TITAN wyłączony/env — pomijam (soft-fail)")
193
+ return None
194
+ try:
195
+ from agents.research_agent import research_agent
196
+
197
+ answer = research_agent.deep_search(label, context_text[:4000])
198
+ if answer and "nie udało się" not in answer.lower():
199
+ # Skróć do zwięzłej wartości — bierzemy pierwsze zdanie/akapit.
200
+ snippet = answer.strip().split("\n", 1)[0].strip()
201
+ return snippet[:400] or None
202
+ except Exception as e:
203
+ logger.warning(f"[AutoFill] Research soft-fail (non-fatal): {e}")
204
+ return None
205
+
206
+ # ------------------------------------------------------------------
207
+ # Główne API
208
+ # ------------------------------------------------------------------
209
+ def autofill_content(
210
+ self,
211
+ content: str,
212
+ project_context: Any = None,
213
+ extra_context: str = "",
214
+ allow_research: bool = True,
215
+ known_facts: dict[str, str] | None = None,
216
+ ) -> AutoFillResult:
217
+ """
218
+ Skanuje treść w poszukiwaniu placeholderów i próbuje je uzupełnić.
219
+
220
+ Kolejność źródeł: zweryfikowane dane wnioskodawcy (ProjectContext lub
221
+ ``known_facts``) -> Research Agent (OSINT). Zwraca AutoFillResult
222
+ z uzupełnioną treścią oraz listą luk wymagających człowieka (HITL).
223
+
224
+ ``known_facts`` to serializowalna mapa label->wartość (np. z
225
+ ``ProjectContext.to_facts_dict()``) przenoszona przez stan LangGraph.
226
+ Ma priorytet nad ``project_context`` (backward-compatible: domyślnie None).
227
+ """
228
+ result = AutoFillResult(filled_content=content or "")
229
+ placeholders = self.detect_placeholders(content or "")
230
+ if not placeholders:
231
+ return result
232
+
233
+ if known_facts:
234
+ facts = {str(k).lower(): str(v) for k, v in known_facts.items() if v}
235
+ else:
236
+ facts = self._facts_from_project_context(project_context)
237
+ context_text = "\n".join(filter(None, [extra_context, content]))
238
+ research_calls = 0
239
+
240
+ filled = content or ""
241
+ for ph in placeholders:
242
+ label = self._placeholder_label(ph)
243
+ value = self._match_fact(label, facts)
244
+ source = "project_context"
245
+
246
+ if not value and allow_research and _research_allowed_by_env() and research_calls < self.MAX_RESEARCH_CALLS:
247
+ research_calls += 1
248
+ value = self._research_fill(label, context_text)
249
+ source = "research_agent"
250
+
251
+ if value:
252
+ filled = filled.replace(ph, value)
253
+ result.resolved.append({"placeholder": ph, "value": value[:200], "source": source})
254
+ else:
255
+ result.unresolved.append(ph)
256
+
257
+ result.filled_content = filled
258
+ if result.unresolved:
259
+ critical = [self._placeholder_label(p) for p in result.unresolved if self._is_critical(self._placeholder_label(p))]
260
+ focus = critical or [self._placeholder_label(p) for p in result.unresolved]
261
+ result.hitl_question = (
262
+ "Uzupełnij brakujące dane wymagane do wniosku: " + "; ".join(focus[:5]) + "."
263
+ )
264
+ return result
265
+
266
+ def resolve_field(
267
+ self,
268
+ question: str,
269
+ context_text: str,
270
+ allow_research: bool = True,
271
+ known_facts: dict[str, str] | None = None,
272
+ ) -> str | None:
273
+ """
274
+ Próbuje automatycznie odpowiedzieć na pojedyncze pytanie o brakujące dane,
275
+ korzystając z rejestrów (GUS/REGON + KRS wykryte z kontekstu) oraz Research
276
+ Agenta. Zwraca tekst odpowiedzi lub None (gdy wymaga człowieka).
277
+
278
+ Ta metoda enkapsuluje logikę auto-healingu wcześniej wtopioną w
279
+ ``generator_agent.resolve_missing_data``.
280
+
281
+ ``known_facts`` to opcjonalna, serializowalna mapa label->wartość
282
+ (np. z ``ProjectContext.to_facts_dict()`` przenoszona przez stan
283
+ LangGraph). Gdy podana, jest używana priorytetowo do deterministycznego
284
+ dopasowania pola po etykiecie — PRZED fallbackiem regex/GUS/KRS/OSINT.
285
+ Backward-compatible: domyślnie None → dotychczasowe zachowanie.
286
+ """
287
+ if not question:
288
+ return None
289
+
290
+ # 0) Deterministyczne rozwiązanie z jawnie przekazanych faktów (priorytet).
291
+ if known_facts:
292
+ facts = {str(k).lower(): str(v) for k, v in known_facts.items() if v}
293
+ matched = self._match_fact(question, facts)
294
+ if matched:
295
+ return matched
296
+
297
+ question_lower = question.lower()
298
+ auto_answer = ""
299
+
300
+ # 1) Wykryj NIP / KRS w kontekście
301
+ nip_match = re.search(r"NIP[:\s]*(\d{10})", context_text or "", re.IGNORECASE)
302
+ nip = nip_match.group(1) if nip_match else None
303
+ krs_match = re.search(r"KRS[:\s]*(\d{10})", context_text or "", re.IGNORECASE)
304
+ krs = krs_match.group(1) if krs_match else None
305
+
306
+ # 2) Rejestr GUS/REGON
307
+ if nip:
308
+ try:
309
+ import json
310
+ from tools.company_search import fetch_regon_data
311
+
312
+ regon_data = fetch_regon_data(nip)
313
+ if regon_data:
314
+ auto_answer += f"Z bazy GUS/REGON (NIP {nip}): {json.dumps(regon_data, ensure_ascii=False)}\n"
315
+ if not krs and isinstance(regon_data, dict):
316
+ krs = regon_data.get("krs") or regon_data.get("numerKRS")
317
+ except Exception as e:
318
+ logger.warning(f"[AutoFill] Błąd fetch_regon_data: {e}")
319
+
320
+ # 3) Odpis KRS dla pytań strukturalnych
321
+ structural_keywords = [
322
+ "adres", "forma prawna", "wspólnik", "udział", "kapitał", "zarząd",
323
+ "reprezent", "rejestracj", "krs", "osoba", "data rejestracji",
324
+ ]
325
+ if any(kw in question_lower for kw in structural_keywords) and krs:
326
+ try:
327
+ import json
328
+ from integrations.krs_client import KRSClient
329
+
330
+ odpis = KRSClient.get_odpis_aktualny(str(krs))
331
+ if odpis:
332
+ relations = KRSClient.extract_graph_relations(odpis)
333
+ auto_answer += (
334
+ f"Z odpisu KRS (KRS {krs}):\n"
335
+ f"- Pełne dane rejestrowe + wspólnicy + zarząd: {json.dumps(relations, ensure_ascii=False)[:1500]}\n"
336
+ )
337
+ except Exception as e:
338
+ logger.warning(f"[AutoFill] Błąd pobierania odpisu KRS: {e}")
339
+
340
+ if auto_answer:
341
+ return auto_answer
342
+
343
+ # 4) Research Agent (OSINT) — soft-fail; nie blokuj/HITL zabijaniem workera
344
+ if allow_research and _research_allowed_by_env():
345
+ deep = self._research_fill(question, context_text)
346
+ if deep:
347
+ return deep
348
+
349
+ return None
350
+
351
+
352
+ autofill_agent = AutoFillAgent()
backend/agents/compliance_guardian.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import re
3
+ from typing import Dict, Any, Optional
4
+ from langchain_core.messages import AIMessage
5
+ from schemas import AgentState
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ # v5.0: Import Kruczkowski Compliance & Trap Agent + Citation Verifier (Faza 2/3)
10
+ try:
11
+ from core.search.regulation_engine import kruczkowski_trap_agent, citation_verifier
12
+ except Exception:
13
+ kruczkowski_trap_agent = None # type: ignore
14
+ citation_verifier = None # type: ignore
15
+
16
+
17
+ def compliance_guardian_node(state: AgentState) -> Dict[str, Any]:
18
+ """
19
+ Sprawdza, czy w state.messages nie pojawiły się zbyt wrażliwe dane (zgodność RODO).
20
+ W architekturze 2026 blokuje model przez wpięciem lub anonimizuje tekst.
21
+
22
+ v5.0 rozszerzenie: integruje Kruczkowski Compliance & Trap Agent dla wykrywania
23
+ pułapek grantowych (aid intensity, double financing, ineligible personnel etc.)
24
+ + podstawowy Citation Verifier na kluczowych sekcjach.
25
+ """
26
+ # Podstawowa RODO / sensitive data guard (zachowana)
27
+ is_safe = True
28
+ full_text = ""
29
+
30
+ # Enhanced strict RODO and sensitive data patterns
31
+ sensitive_patterns = [
32
+ r"(?i)hasło",
33
+ r"(?i)password",
34
+ r"(?i)pesel\s*:?\s*\d{11}",
35
+ r"(?i)nr\s+dowodu",
36
+ r"(?i)numer\s+dowodu",
37
+ r"(?i)karta\s+kredytowa",
38
+ r"\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b",
39
+ r"(?i)iban\s*[a-zA-Z]{2}[0-9]{26}"
40
+ ]
41
+
42
+ for msg in state.messages:
43
+ text = msg.content.lower() if hasattr(msg, 'content') else str(msg).lower()
44
+ full_text += " " + text
45
+
46
+ for pattern in sensitive_patterns:
47
+ if re.search(pattern, text):
48
+ is_safe = False
49
+ break
50
+ if not is_safe:
51
+ break
52
+
53
+ if not is_safe:
54
+ return {
55
+ "messages": [
56
+ AIMessage(
57
+ content="[COMPLIANCE] Wykryto potencjalnie wrażliwe dane (PESEL/Hasła). Upewnij się, że zachowujesz zasady RODO."
58
+ )
59
+ ],
60
+ "current_agent": "supervisor",
61
+ }
62
+
63
+ # === v5.0 Kruczkowski Trap + Citation checks (jeśli dostępne dane w state) ===
64
+ trap_report: Optional[Dict[str, Any]] = None
65
+ citation_score = None
66
+
67
+ program = getattr(state, 'program_name', None) or (state.profile.program if getattr(state, 'profile', None) else None) or "unknown"
68
+
69
+ # === v5.0 Temporal Graph densify: query Neo4j for law_change before compliance/trap/Verifier ===
70
+ law_change_signal: Optional[Dict[str, Any]] = None
71
+ try:
72
+ from core.search.regulation_engine import regulation_engine
73
+ law_change = regulation_engine.detect_regulation_change(program)
74
+ if law_change.get("changed"):
75
+ # Sygnał propagowany do dalszych agentów przez pole AgentState.law_change_signal.
76
+ law_change_signal = law_change
77
+ logger.info(f"[Compliance Guardian v5.0 Temporal] Law change detected via graph for {program}: {law_change.get('change_summary','')[:60]}")
78
+ except Exception:
79
+ pass
80
+
81
+ # Jeśli jest content do audytu — uruchom pełny trap detection
82
+ content_to_check = ""
83
+ if hasattr(state, 'verification_results') and state.verification_results:
84
+ content_to_check = str(state.verification_results.get("pending_doc_text", "")) or ""
85
+ if not content_to_check and hasattr(state, 'messages'):
86
+ content_to_check = full_text[:4000]
87
+
88
+ if content_to_check and len(content_to_check) > 120 and kruczkowski_trap_agent:
89
+ try:
90
+ msp_ctx = None
91
+ if hasattr(state, 'external_context') and state.external_context:
92
+ msp_ctx = state.external_context.get("msp_analysis")
93
+ trap_report = kruczkowski_trap_agent.detect_traps(
94
+ document_text=content_to_check,
95
+ program=program,
96
+ msp_context=msp_ctx
97
+ )
98
+ citation_score = trap_report.get("citation_verification", {}).get("overall_citation_score")
99
+
100
+ if trap_report.get("blocks_export_recommendation"):
101
+ logger.warning(f"[Compliance Guardian v5.0 Kruczkowski] HIGH RISK traps detected for {program}: {trap_report.get('num_traps')}")
102
+ except Exception as e:
103
+ logger.debug(f"[Compliance Guardian] Kruczkowski trap check skipped: {e}")
104
+
105
+ # Jeśli wykryto poważne pułapki — zwracamy ostrzeżenie do supervisor
106
+ if trap_report and trap_report.get("overall_trap_risk") in ("high", "critical"):
107
+ warning = f"[KRUCZKOWSKI COMPLIANCE & TRAP v5.0] Wykryto {trap_report['num_traps']} pułapek (ryzyko: {trap_report['overall_trap_risk']}). Citation grounding: {citation_score}. Zalecana korekta przed eksportem. Szczegóły w trap_report."
108
+ return {
109
+ "messages": [AIMessage(content=warning)],
110
+ "current_agent": "supervisor",
111
+ "compliance_trap_report": trap_report, # dla kolejnych nodów (pole w AgentState)
112
+ "law_change_signal": law_change_signal,
113
+ }
114
+
115
+ # Normalny przepływ + opcjonalny trap_report (nawet jeśli low risk)
116
+ result: Dict[str, Any] = {"current_agent": "supervisor"}
117
+ if trap_report:
118
+ result["compliance_trap_report"] = trap_report
119
+ if law_change_signal:
120
+ result["law_change_signal"] = law_change_signal
121
+ return result
122
+
123
+ def check_legal_updates(project_id: str, email: str, program_name: str) -> None:
124
+ """
125
+ Faza 6: Moduł Compliance Guardian
126
+ Sprawdza zmiany w regulaminie danego naboru (np. na podstawie zapytań do grant_search_service)
127
+ i wysyła powiadomienie do użytkownika.
128
+
129
+ v5.0: Może dodatkowo uruchomić Kruczkowski Trap Agent na istniejących sekcjach projektu
130
+ przed wysłaniem alertu (jeśli snapshot się zmienił).
131
+ """
132
+ logger.info(f"[Compliance Guardian] Rozpoczynam sprawdzanie zmian w prawie dla: {program_name}")
133
+ # Tu odbywałoby się odpytanie agregatora (np. EUR-Lex / PARP) czy data modyfikacji regulaminu jest nowsza niż data rozpoczęcia projektu.
134
+
135
+ # v5.0 hook: jeśli dostępny Kruczkowski — można by tu triggerować re-audit z citation check
136
+ if kruczkowski_trap_agent:
137
+ logger.info("[Compliance Guardian v5.0] Kruczkowski Trap Agent dostępny — w pełnej integracji uruchomi re-weryfikację po zmianie regulaminu.")
138
+
139
+ # Symulacja wysyłki maila przez Clerk / SendGrid:
140
+ logger.info(f"[Compliance Guardian] [MOCK EMAIL] Wysłano alert na adres {email}: Zmiany w regulaminie {program_name}!")
141
+
backend/agents/critic.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, Any
2
+ from langchain_core.messages import AIMessage
3
+ from core.llm_router import get_llm
4
+ from schemas import AgentState, CriticFeedback
5
+
6
+ try:
7
+ from core.search.regulation_engine import kruczkowski_trap_agent
8
+ except Exception:
9
+ kruczkowski_trap_agent = None
10
+
11
+
12
+ def critic_node(state: AgentState) -> Dict[str, Any]:
13
+ """
14
+ Recenzent jakości tekstu biznesowego. Analizuje styl, perswazję oraz spójność merytoryczną.
15
+ Współpracuje z Wizardem. Odpowiada za Human-in-the-Loop weryfikacji.
16
+ """
17
+ from langchain_core.callbacks import BaseCallbackHandler
18
+ import logging
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ class TokenTrackingCallback(BaseCallbackHandler):
23
+ def __init__(self, user_id: str):
24
+ self.user_id = user_id
25
+ self.total_tokens = 0
26
+
27
+ def on_llm_end(self, response, **kwargs):
28
+ try:
29
+ if hasattr(response, "llm_output") and response.llm_output:
30
+ token_usage = response.llm_output.get("token_usage", {})
31
+ tokens = token_usage.get("total_tokens", 0)
32
+ if tokens > 0:
33
+ self.total_tokens += tokens
34
+ from core.subscription.tracker import increment_tokens
35
+ if self.user_id and self.user_id != "anonymous":
36
+ increment_tokens(self.user_id, tokens, action_type="critic_draft")
37
+ except Exception as e:
38
+ logger.warning(f"Błąd zliczania tokenów w critic_node: {e}")
39
+
40
+ user_id = getattr(state, "user_id", "anonymous")
41
+ tracking_callback = TokenTrackingCallback(user_id=user_id)
42
+ llm = get_llm(task_type="critical", structured_output_schema=CriticFeedback, callbacks=[tracking_callback])
43
+
44
+ # Assuming Wizard's output is in the last AI Message or in document_versions
45
+ # UWAGA: wiadomości LangChain mają atrybut `.type` ("ai"/"human"/"system"),
46
+ # NIE `.role`. Wcześniejsze `getattr(msg,"role",...)` zawsze zwracało None,
47
+ # przez co krytyk oceniał ostatnią wiadomość (często wejście użytkownika).
48
+ last_text = ""
49
+ for msg in reversed(state.messages):
50
+ msg_type = getattr(msg, "type", None)
51
+ if getattr(msg, "content", None) and msg_type not in ("human", "system"):
52
+ from core.utils import safe_extract_text
53
+
54
+ last_text = safe_extract_text(msg.content)
55
+ break
56
+
57
+ if not last_text:
58
+ return {
59
+ "critic_evaluation": CriticFeedback(
60
+ is_approved=True, score=100, feedback="Brak tekstu do oceny.", severity="low", xai_justification="Brak tekstu do oceny.", cross_check_passed=True, inconsistencies_flagged=[]
61
+ )
62
+ }
63
+
64
+ if state.critic_iterations > state.max_critic_iterations:
65
+ return {
66
+ "critic_evaluation": CriticFeedback(
67
+ is_approved=True,
68
+ score=100,
69
+ feedback="Automatyczne zatwierdzenie - przekroczono limit iteracji poprawek.",
70
+ severity="low",
71
+ xai_justification="Limit iteracji osiągnięty. Wymuszone zatwierdzenie.",
72
+ cross_check_passed=True,
73
+ inconsistencies_flagged=[]
74
+ )
75
+ }
76
+
77
+ import re
78
+
79
+ # HARD VALIDATION CHECKS (Python Regex) BEFORE LLM
80
+ text_lower = last_text.lower()
81
+
82
+ # 1. Missing required program keywords (e.g., 'innowacj', 'przychód', depending on the module)
83
+ # If the text is substantial but lacks core program terminology:
84
+ if len(text_lower) > 100:
85
+ if not re.search(r'(innowacj|przychód|b\+r|badani|rozwoj|dofinansowan|projekt)', text_lower):
86
+ return {
87
+ "critic_evaluation": CriticFeedback(
88
+ is_approved=False,
89
+ score=0,
90
+ feedback="Twarda walidacja (Regex) odrzuciła tekst: Kompletny brak wymaganych słów kluczowych programu (np. 'innowacj', 'przychód').",
91
+ severity="high",
92
+ xai_justification="Hard validation logic failed: Missing mandatory program keywords before LLM call.",
93
+ cross_check_passed=False,
94
+ inconsistencies_flagged=["Brak krytycznych słów kluczowych programu."]
95
+ ),
96
+ "critic_iterations": state.critic_iterations + 1,
97
+ "messages": [AIMessage(content="Twarda walidacja (Regex) odrzuciła tekst: Kompletny brak wymaganych słów kluczowych programu.")]
98
+ }
99
+
100
+ # 2. Budget sums mathematically incorrect or logically impossible (given RAG constraints)
101
+ # Check for negative amounts
102
+ budget_anomaly = re.search(r'(?i)(budżet|koszt|kwota|dofinansowanie)[\s=:]*-\s*\d+', last_text)
103
+
104
+ if budget_anomaly:
105
+ return {
106
+ "critic_evaluation": CriticFeedback(
107
+ is_approved=False,
108
+ score=0,
109
+ feedback="Twarda walidacja (Regex) odrzuciła tekst: Wykryto nielogiczne ujemne kwoty budżetowe.",
110
+ severity="high",
111
+ xai_justification="Hard validation logic failed: Negative budget sum detected.",
112
+ cross_check_passed=False,
113
+ inconsistencies_flagged=["Ujemne kwoty budżetowe."]
114
+ ),
115
+ "critic_iterations": state.critic_iterations + 1,
116
+ "messages": [AIMessage(content="Twarda walidacja (Regex) odrzuciła tekst: Wykryto nielogiczne ujemne kwoty budżetowe.")]
117
+ }
118
+
119
+ # 3. KRUCZKOWSKI TRAP (Pułapki prawne dotacji)
120
+ if kruczkowski_trap_agent and hasattr(kruczkowski_trap_agent, "detect_traps"):
121
+ try:
122
+ program_name = state.program_name or "dotacje"
123
+ trap_result = kruczkowski_trap_agent.detect_traps(last_text, program_name)
124
+ if trap_result and trap_result.get("risk_level") in ["high", "critical"]:
125
+ # detect_traps zwraca listę dictów pułapek — wyciągamy czytelne etykiety.
126
+ raw_traps = trap_result.get("traps", []) or []
127
+ trap_labels = []
128
+ for t in raw_traps:
129
+ if isinstance(t, dict):
130
+ label = t.get("description") or t.get("trap") or t.get("code")
131
+ else:
132
+ label = str(t)
133
+ if label:
134
+ trap_labels.append(str(label))
135
+ traps_found = ", ".join(trap_labels)
136
+ return {
137
+ "critic_evaluation": CriticFeedback(
138
+ is_approved=False,
139
+ score=10,
140
+ feedback=f"Wykryto pułapki Kruczkowskiego: {traps_found}. Tekst zagraża kwalifikowalności wniosku.",
141
+ severity="critical",
142
+ xai_justification=f"Odrzucono ze względu na pułapki prawne: {traps_found}",
143
+ cross_check_passed=False,
144
+ inconsistencies_flagged=trap_labels
145
+ ),
146
+ "critic_iterations": state.critic_iterations + 1,
147
+ "messages": [AIMessage(content=f"Wykryto krytyczne błędy formalne (Pułapka Kruczkowskiego): {traps_found}.")]
148
+ }
149
+ except Exception as e:
150
+ pass # Fallback to LLM if trap agent fails
151
+
152
+ prompt = f"""
153
+ Jesteś rygorystycznym, ale pragmatycznym Recenzentem wniosków o dofinansowanie (RedTeamCritic).
154
+ Przeanalizuj poniższy fragment wniosku wygenerowany przez asystenta AI pod kątem MERYTORYCZNYM i ZGODNOŚCI Z ZASADAMI (np. moduły Ścieżki SMART, zasady DNSH, koszty kwalifikowalne).
155
+
156
+ Wymagamy RIGOROUS SCORING (surowej oceny punktowej w skali 0-100) oraz XAI JUSTIFICATION (wyjaśnialnej sztucznej inteligencji - dlaczego przyznano taką ocenę, w oparciu o konkretne przesłanki).
157
+ Przeprowadź ŚCISŁY CROSS-CHECK (np. czy kwoty budżetu pokrywają się z celami projektu i harmonogramem, czy zadania nie wykluczają się nawzajem).
158
+ Wypisz wszystkie znalezione NIESPÓJNOŚCI w polu inconsistencies_flagged.
159
+
160
+ ODRZUĆ TEKST (is_approved=False), jeśli wystąpi JAKAKOLWIEK z poniższych wad:
161
+ 1. Błędy merytoryczne (halucynacje dotyczące zasad naboru, błędne opisy modułów takich jak B+R, Zazielenienie, Cyfryzacja).
162
+ 2. Wprowadzenie kosztów w oczywisty sposób niekwalifikowalnych w danym module.
163
+ 3. Kompletny brak logiki biznesowej, zaprzeczanie samemu sobie lub generowanie "wodolejstwa" zamiast wymogów dotacyjnych.
164
+ 4. Niespójności w weryfikacji krzyżowej (np. budżet vs harmonogram). Wtedy ustaw cross_check_passed na false.
165
+
166
+ ZAAKCEPTUJ TEKST (is_approved=True) w pozostałych przypadkach, tj. gdy warstwa merytoryczna jest poprawna. Jeśli widzisz tylko drobne błędy stylistyczne, zaakceptuj wniosek (is_approved=True), a sugestie wpisz w polu feedback, obniżając delikatnie 'score'.
167
+
168
+ ABSOLUTNIE ZABRONIONE JEST odrzucanie tekstu (zwróć is_approved=True) TYLKO z powodu:
169
+ - Obecności znaczników np. [UZUPEŁNIJ: ...], [BRAK DANYCH] (są one wstawiane celowo!).
170
+ - Braku specyficznych danych o firmie, jeśli są zastąpione markerami.
171
+
172
+ Odpowiadaj ZAWSZE I WYŁĄCZNIE w języku polskim.
173
+
174
+ Tekst do sprawdzenia:
175
+ {last_text}
176
+ """
177
+
178
+ try:
179
+ feedback: CriticFeedback = llm.invoke(prompt)
180
+
181
+ try:
182
+ from core.audit_logger import audit_log
183
+
184
+ audit_log(
185
+ "CRITIC",
186
+ f"Zakończono analizę. Is Approved: {feedback.is_approved}, Score: {feedback.score}, Severity: {feedback.severity}, Cross-Check: {feedback.cross_check_passed}",
187
+ )
188
+ except Exception:
189
+ pass # pre-cautionary try-except if logger is not ready yet
190
+
191
+ return {
192
+ "critic_evaluation": feedback,
193
+ "critic_iterations": state.critic_iterations + 1,
194
+ "messages": [AIMessage(content=feedback.feedback)]
195
+ if not feedback.is_approved
196
+ else [],
197
+ }
198
+ except Exception as e:
199
+ # FAIL-CLOSED: awaria walidacji krytyka NIE może oznaczać auto-akceptacji.
200
+ # Oznaczamy jako niezaakceptowane i wymagające ręcznej weryfikacji.
201
+ return {
202
+ "critic_evaluation": CriticFeedback(
203
+ is_approved=False,
204
+ score=0,
205
+ feedback=f"Awaria walidacji krytyka ({str(e)}) — sekcja NIEZWERYFIKOWANA, wymagana ręczna weryfikacja.",
206
+ severity="critical",
207
+ xai_justification="Fallback fail-closed: błąd mechanizmu oceny, brak potwierdzenia jakości.",
208
+ cross_check_passed=False,
209
+ inconsistencies_flagged=["Awaria mechanizmu krytyka — brak weryfikacji."]
210
+ ),
211
+ "critic_iterations": state.critic_iterations + 1,
212
+ }
backend/agents/document_gap_analyzer.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, Any, List
2
+ from langchain_core.messages import AIMessage
3
+ from core.llm_router import get_llm
4
+ from core.safe_mode import SAFE_MODE_MARKER
5
+ from schemas import AgentState
6
+
7
+
8
+ def _collect_structured_gaps(state: AgentState) -> List[str]:
9
+ gaps: List[str] = []
10
+ bb = state.blackboard or {}
11
+
12
+ if bb.get("data_gaps"):
13
+ gaps.extend(bb["data_gaps"])
14
+
15
+ enrichment = bb.get("registry_enrichment") or {}
16
+ if enrichment.get("gaps"):
17
+ for g in enrichment["gaps"]:
18
+ if g not in gaps:
19
+ gaps.append(g)
20
+
21
+ if not state.profile:
22
+ gaps.append("Brak profilu firmy (NIP/GUS).")
23
+ elif state.profile:
24
+ if not getattr(state.profile, "financials", None):
25
+ gaps.append("Brak danych finansowych w profilu.")
26
+ else:
27
+ fin = state.profile.financials
28
+ if getattr(fin, "revenue", 0) <= 0:
29
+ gaps.append("Brak udokumentowanych przychodów.")
30
+ if not getattr(state.profile, "pkd_codes", None):
31
+ gaps.append("Brak kodów PKD.")
32
+
33
+ return gaps
34
+
35
+
36
+ def document_gap_analyzer_node(state: AgentState) -> Dict[str, Any]:
37
+ """
38
+ Analizuje profil i załączniki — wykazuje braki wymagane do wniosku dotacyjnego.
39
+ """
40
+ gaps = _collect_structured_gaps(state)
41
+
42
+ if gaps:
43
+ gap_result = "\n".join(f"- {g}" for g in gaps)
44
+ gap_result += f"\n\nGenerator użyje znacznika {SAFE_MODE_MARKER} w odpowiednich sekcjach."
45
+ else:
46
+ llm = get_llm(task_type="standard")
47
+ prompt_context = "Profil kompletny według rejestru."
48
+ if state.profile and state.profile.financials:
49
+ prompt_context = f"Dane finansowe: {state.profile.financials}"
50
+ prompt = f"""
51
+ Jesteś Document Gap Analyzerem. Sprawdź, czego brakuje do wniosku dotacyjnego.
52
+ Zwróć listę wypunktowaną. Pisz po polsku.
53
+ Kontekst: {prompt_context}
54
+ """
55
+ response = llm.invoke(prompt)
56
+ from core.utils import safe_extract_text
57
+ gap_result = safe_extract_text(response.content)
58
+
59
+ bb = dict(state.blackboard or {})
60
+ bb["data_gaps"] = gaps or bb.get("data_gaps", [])
61
+ bb["gap_analysis_done"] = True
62
+
63
+ return {
64
+ "messages": [
65
+ AIMessage(content=f"[GAP ANALYZER] Znalazłem następujące braki:\n{gap_result}")
66
+ ],
67
+ "blackboard": bb,
68
+ "current_agent": "supervisor",
69
+ }
backend/agents/evaluator.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, Field
2
+ from typing import Literal
3
+ from core.llm_router import get_llm
4
+ from langchain_core.prompts import PromptTemplate
5
+ from rag_pipeline import get_hybrid_retriever, rerank_documents
6
+ import logging
7
+ from tenacity import retry, stop_after_attempt, wait_exponential
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class ExpenseEvaluationResponse(BaseModel):
13
+ czy_wydatek_kwalifikowalny: bool = Field(
14
+ description="Zwróć True jeśli wydatek jest w 100% zgodny z regulaminem i wytycznymi programu (kwalifikowalny)."
15
+ )
16
+ uzasadnienie_prawne: str = Field(
17
+ description="Cytat lub konkretne odwołanie do regulaminu uzasadniające kwalifikowalność lub jej brak."
18
+ )
19
+ kategoria_badan: Literal[
20
+ "badania przemysłowe",
21
+ "prace rozwojowe",
22
+ "prace przedwdrożeniowe",
23
+ "brak/nie dotyczy",
24
+ ] = Field(
25
+ description="Wybierz do jakiej kategorii zgodnie z polskim/unijnym prawem należy ten wydatek. Wybierz 'brak/nie dotyczy' tylko jeśli wydatek jest całkowicie poza B+R."
26
+ )
27
+ intensywnosc_pomocy: float = Field(
28
+ description="Zwróć w formie wartości zmiennoprzecinkowej np. 0.50 (co oznacza 50%), 0.80 (co oznacza 80%) bazując na wielkości firmy i rodzaju badań. 0.0 oznacza wydatek niekwalifikowalny."
29
+ )
30
+
31
+
32
+ def evaluate_project_expense(
33
+ expense_description: str,
34
+ expense_amount: float,
35
+ project_title: str,
36
+ program_name: str,
37
+ company_size: str,
38
+ tenant_id: str = None,
39
+ ) -> ExpenseEvaluationResponse:
40
+ """
41
+ Agent ds. Oceny Kwalifikowalności (FAZA 4).
42
+ Wymusza twarde, ustrukturyzowane ramy JSON za pomocą Pydantic.
43
+ Opiera się na wiedzy RAG dotyczącej wybranego programu.
44
+ """
45
+
46
+ # Próba załadowania kontekstu z RAG - Hard Filtering na aktualną perspektywę
47
+ # Domyślnie wyszukujemy tylko w najnowszej perspektywie (FAZA 3, zapobieganie aplikacji starych przepisów)
48
+ hard_filter = {"rok_perspektywy": {"$eq": "2021-2027"}}
49
+ if program_name:
50
+ # Operator $and dla Pinecone Vector Store
51
+ hard_filter = {
52
+ "$and": [
53
+ {"program_name": {"$eq": program_name}},
54
+ {"rok_perspektywy": {"$eq": "2021-2027"}},
55
+ ]
56
+ }
57
+
58
+ context_text = "Brak specyficznego regulaminu programu w bazie."
59
+ try:
60
+ retriever = get_hybrid_retriever(
61
+ k=10, metadata_filter=hard_filter, namespace=tenant_id
62
+ )
63
+ if retriever:
64
+ query_for_rag = f"kwalifikowalność wydatku badania kategoria intensywność dotacji pomoc publiczna: {expense_description}"
65
+ docs = retriever.invoke(query_for_rag)
66
+ reranked_docs = rerank_documents(query_for_rag, docs, top_n=4)
67
+ context_text = "\n\n".join(
68
+ [
69
+ f"[ŹRÓDŁO: {d.metadata.get('source', 'Brak')}]: {d.page_content}"
70
+ for d in reranked_docs
71
+ ]
72
+ )
73
+ except Exception as e:
74
+ logger.error(f"[ExpenseEvaluator] Error fetching RAG context: {str(e)}")
75
+
76
+ template = """
77
+ Jesteś Głównym Prawnikiem i Audytorem Dotacyjnym oceniającym kwalifikowalność wydatków.
78
+ Oceniasz pojedynczy wydatek dla projektu w ramach programu: {program_name}.
79
+ Wielkość przedsiębiorstwa wnioskodawcy: {company_size}.
80
+
81
+ Opis wydatku do weryfikacji:
82
+ "{expense_description}" (Kwota: {expense_amount} PLN)
83
+
84
+ Kontekst z regulaminów z bazy wiedzy:
85
+ --------------------------------------------------
86
+ {context}
87
+ --------------------------------------------------
88
+
89
+ Zasady:
90
+ 1. Przeanalizuj czy podany wydatek kwalifikuje się do objęcia wsparciem zgodnie z bazą wiedzy.
91
+ 2. Określ kategorię badań dla wydatku, zgodnie z definicjami (badania przemysłowe, prace rozwojowe, przedwdrożeniowe).
92
+ 3. Jeśli wydatek jest kwalifikowalny, przypisz prawidłową intensywność pomocy (zazwyczaj mniejszy procent dla prac rozwojowych/dużych firm, większy dla badań przemysłowych/MŚP).
93
+ 4. Podaj bardzo precyzyjne uzasadnienie prawne odnoszące się do regulaminu.
94
+ """
95
+
96
+ prompt = PromptTemplate.from_template(template)
97
+
98
+ # LLM z typowaniem - GPT-4o jest dużo lepszy do takich zadań analitycznych
99
+ structured_llm = get_llm(
100
+ task_type="legal_audit", structured_output_schema=ExpenseEvaluationResponse
101
+ )
102
+
103
+ chain = prompt | structured_llm
104
+
105
+
106
+ @retry(
107
+ stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10)
108
+ )
109
+ def _invoke_chain():
110
+ result = chain.invoke(
111
+ {
112
+ "program_name": program_name or "Ogólne zasady dotacji B+R",
113
+ "company_size": company_size or "MŚP (nieokreślona wielkość)",
114
+ "expense_description": expense_description,
115
+ "expense_amount": expense_amount,
116
+ "context": context_text,
117
+ }
118
+ )
119
+ if not result.uzasadnienie_prawne or len(result.uzasadnienie_prawne.strip()) < 10:
120
+ raise ValueError("Brak wystarczającego uzasadnienia prawnego.")
121
+ if not (0.0 <= result.intensywnosc_pomocy <= 1.0):
122
+ raise ValueError("Intensywność pomocy poza zakresem 0.0 - 1.0.")
123
+ return result
124
+
125
+
126
+ result = _invoke_chain()
127
+
128
+ return result
backend/agents/finance_agent.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from typing import Optional, Tuple, List
3
+ from pydantic import BaseModel, Field
4
+
5
+ from core.llm_router import get_llm
6
+ from langchain_core.messages import SystemMessage, HumanMessage
7
+ from core.utils import extract_markdown_and_sanitize
8
+ from agents.helpers import ANTI_HALLUCINATION_PROMPT
9
+ from tenacity import retry, stop_after_attempt, wait_exponential
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ class CostItem(BaseModel):
14
+ name: str = Field(description="Krótka nazwa kosztu (np. Zakup wtryskarki, Wynagrodzenia B+R)")
15
+ category: str = Field(description="Kategoria: CAPEX (środki trwałe) lub OPEX (koszty operacyjne)")
16
+ amount_pln: float = Field(description="Kwota netto w PLN (bez przecinków, np. 150000.00)")
17
+ is_eligible: bool = Field(description="Czy wydatek jest w 100% kwalifikowalny zgodnie z regulaminem?")
18
+
19
+ class FinancialData(BaseModel):
20
+ costs: List[CostItem] = Field(description="Lista zidentyfikowanych kosztów z opisu projektu lub logiki branżowej")
21
+ revenue_projection_year_1: float = Field(description="Prognoza przychodów w PLN (rok 1 po zakończeniu)")
22
+ revenue_projection_year_3: float = Field(description="Prognoza przychodów w PLN (rok 3 po zakończeniu)")
23
+ discount_rate_percent: float = Field(description="Założona stopa dyskontowa w % (np. 8.0)")
24
+ missing_data_question: Optional[str] = Field(None, description="Jakich danych najbardziej brakuje?")
25
+ reasoning_explanation: str = Field(..., description="XAI: Krótko uzasadnij kwalifikowalność wymienionych kosztów na bazie dostarczonych reguł z RAG.")
26
+
27
+ class FinanceAgent:
28
+ """
29
+ Hybrydowy Agent Finansowy (Opcja A - Twarda logika + LLM).
30
+ LLM identyfikuje z tekstu pozycje kosztowe i orzeka o kwalifikowalności.
31
+ Python zlicza sumy, generuje tabele Markdown i wylicza podstawowe wskaźniki (bez ryzyka halucynacji matematycznych).
32
+ """
33
+
34
+ def __init__(self):
35
+ self.llm = get_llm(task_type="critical", structured_output_schema=FinancialData)
36
+
37
+ def _generate_markdown_from_data(self, data: FinancialData, section_name: str) -> str:
38
+ md = f"### {section_name}\n\n"
39
+ md += "Na podstawie analizy dostarczonych danych wygenerowano poniższe zestawienie planowanych wydatków. "
40
+ md += "Zastosowano kategoryzację kosztów oraz zweryfikowano ich kwalifikowalność w oparciu o wytyczne.\n\n"
41
+
42
+ md += "#### 1. Zestawienie Kosztów Projektu\n\n"
43
+ md += "| Lp. | Kategoria | Nazwa wydatku | Kwota Netto (PLN) | Kwalifikowalny |\n"
44
+ md += "|---|---|---|---|---|\n"
45
+
46
+ total_capex = 0.0
47
+ total_opex = 0.0
48
+ total_eligible = 0.0
49
+
50
+ for idx, cost in enumerate(data.costs, 1):
51
+ elig_str = "✅ Tak" if cost.is_eligible else "❌ Nie"
52
+ md += f"| {idx} | **{cost.category}** | {cost.name} | {cost.amount_pln:,.2f} PLN | {elig_str} |\n"
53
+
54
+ if cost.category.upper() == "CAPEX":
55
+ total_capex += cost.amount_pln
56
+ else:
57
+ total_opex += cost.amount_pln
58
+
59
+ if cost.is_eligible:
60
+ total_eligible += cost.amount_pln
61
+
62
+ total_budget = total_capex + total_opex
63
+
64
+ md += f"| | | **SUMA CAŁKOWITA** | **{total_budget:,.2f} PLN** | |\n\n"
65
+
66
+ md += "#### 2. Struktura Finansowania\n\n"
67
+ md += f"- **Suma CAPEX (Wydatki majątkowe):** {total_capex:,.2f} PLN\n"
68
+ md += f"- **Suma OPEX (Koszty operacyjne):** {total_opex:,.2f} PLN\n"
69
+ md += f"- **Wydatki Kwalifikowalne łącznie:** {total_eligible:,.2f} PLN\n"
70
+ md += f"- **Wydatki Niekwalifikowalne:** {(total_budget - total_eligible):,.2f} PLN\n\n"
71
+
72
+ md += "#### 3. Prognozy i Wskaźniki\n\n"
73
+ md += f"- Prognozowany przychód (Rok 1): **{data.revenue_projection_year_1:,.2f} PLN**\n"
74
+ md += f"- Prognozowany przychód (Rok 3): **{data.revenue_projection_year_3:,.2f} PLN**\n"
75
+ md += f"- Stopa dyskonta do wyliczeń NPV: **{data.discount_rate_percent}%**\n\n"
76
+
77
+ md += f"<!-- XAI: {data.reasoning_explanation} -->\n"
78
+ return md
79
+
80
+ def draft_financial_section(self, document_type: str, section_name: str, project_desc: str, context: str) -> Tuple[str, Optional[str]]:
81
+ logger.info(f"[FinanceAgent Hybrid] Zlecono ekstrakcję finansową: {section_name}")
82
+
83
+ system_prompt = (
84
+ ANTI_HALLUCINATION_PROMPT + "\n\n"
85
+ "Jesteś precyzyjnym Ekstraktorem Danych Finansowych. Twoim jedynym zadaniem jest zidentyfikowanie w opisie projektu poszczególnych "
86
+ "planowanych wydatków, przypisanie ich do kategorii (CAPEX/OPEX) oraz ocena (w oparciu TYLKO o Kontekst RAG), czy dany wydatek "
87
+ "jest kosztom kwalifikowalnym w ramach wybranego programu dotacyjnego.\n\n"
88
+ "ZASADY:\n"
89
+ "1. WYCIĄGAJ konkretne kwoty z opisu. Jeśli nie ma podanych kwot, a projekt opiera się na np. zakupie serwerów, "
90
+ "NIE ZMYŚLAJ i NIE SZACUJ KWOT. Użyj wyraźnej zmiennej, np. 0.0 w polu kwoty, a w nazwie kosztu dodaj znacznik `[KWOTA_DO_UZUPEŁNIENIA]`.\n"
91
+ "2. NIE WOLNO Ci zmyślać nowych typów kosztów (np. maszyny rolniczej dla firmy IT).\n"
92
+ "3. Prognozę przychodów wylicz na bazie obecnych obrotów firmy (jeśli brak danych, ustaw 0.0 i zapisz w uzasadnieniu, że wymaga podania danych od klienta).\n"
93
+ )
94
+
95
+ human_content = f"Dokument: {document_type}\n\nKontekst Programu:\n{context}\n\nOpis Projektu:\n{project_desc}"
96
+
97
+ @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
98
+ def _invoke_llm():
99
+ res = self.llm.invoke([
100
+ SystemMessage(content=system_prompt),
101
+ HumanMessage(content=human_content)
102
+ ])
103
+ if not getattr(res, "costs", None) or len(res.costs) == 0:
104
+ # Nie rzucamy błędu - może to być autentyczny brak danych
105
+ if not res.missing_data_question:
106
+ res.missing_data_question = "W opisie projektu brakuje jakichkolwiek informacji o kosztach i wydatkach. Proszę o ich uzupełnienie."
107
+ res.costs = []
108
+ return res
109
+
110
+ try:
111
+ structured_data: FinancialData = _invoke_llm()
112
+ # Obliczenia matematyczne i generacja tabel (Twarda Logika Pythona)
113
+ markdown_content = self._generate_markdown_from_data(structured_data, section_name)
114
+
115
+ missing = structured_data.missing_data_question
116
+ return markdown_content, missing
117
+
118
+ except Exception as e:
119
+ logger.error(f"[FinanceAgent] Błąd Hybrydowy LLM: {e}")
120
+ return f"*(Błąd podczas analizy hybrydowej sekcji finansowej: {str(e)})*", None
121
+
122
+ finance_agent = FinanceAgent()
backend/agents/gap_analyzer.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gap analyzer node with context-bus aware clarifying questions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List
6
+
7
+ from langchain_core.messages import AIMessage
8
+
9
+ from agents.document_gap_analyzer import _collect_structured_gaps
10
+ from core.context.context_bus import filter_clarifying_questions
11
+ from schemas import AgentState
12
+
13
+
14
+ def _gap_to_question(gap: str) -> str | None:
15
+ g = (gap or "").lower()
16
+ if "pkd" in g:
17
+ return "Podaj główny kod PKD działalności objętej projektem."
18
+ if "nip" in g or "gus" in g or "profilu firmy" in g:
19
+ return "Podaj NIP firmy, aby pobrać dane z rejestru GUS."
20
+ if "opis" in g or "cel" in g:
21
+ return "Opisz cel projektu i planowane wydatki w 2–3 zdaniach."
22
+ if "finans" in g or "przychod" in g:
23
+ return "Podaj przychody firmy z ostatniego roku obrotowego."
24
+ if "wojew" in g or "region" in g:
25
+ return "Uzupełnij województwo realizacji projektu."
26
+ return f"Uzupełnij brakującą informację: {gap}"
27
+
28
+
29
+ def build_clarifying_questions(gaps: List[str], profile: dict | None) -> List[str]:
30
+ profile = profile or {}
31
+ company = dict(profile)
32
+ if hasattr(profile, "model_dump"):
33
+ company = profile.model_dump()
34
+ elif hasattr(profile, "dict"):
35
+ company = profile.dict()
36
+
37
+ questions: List[str] = []
38
+ for gap in gaps or []:
39
+ q = _gap_to_question(gap)
40
+ if q and q not in questions:
41
+ questions.append(q)
42
+
43
+ return filter_clarifying_questions(questions, company, gaps)
44
+
45
+
46
+ def gap_analyzer_node(state: AgentState) -> Dict[str, Any]:
47
+ gaps = _collect_structured_gaps(state)
48
+ profile_dict: dict = {}
49
+ if state.profile:
50
+ if hasattr(state.profile, "model_dump"):
51
+ profile_dict = state.profile.model_dump()
52
+ elif hasattr(state.profile, "dict"):
53
+ profile_dict = state.profile.dict()
54
+ else:
55
+ profile_dict = dict(state.profile)
56
+
57
+ clarifying = build_clarifying_questions(gaps, profile_dict)
58
+
59
+ bb = dict(state.blackboard or {})
60
+ bb["data_gaps"] = gaps
61
+ bb["gap_analysis_done"] = True
62
+ bb["clarifying_questions"] = clarifying
63
+
64
+ summary = "\n".join(f"- {g}" for g in gaps) if gaps else "Brak krytycznych braków danych."
65
+ return {
66
+ "messages": [
67
+ AIMessage(
68
+ content=f"[GAP ANALYZER] Analiza braków zakończona.\n{summary}"
69
+ )
70
+ ],
71
+ "blackboard": bb,
72
+ "current_agent": "supervisor",
73
+ }
backend/agents/generator_agent.py ADDED
The diff for this file is too large to render. See raw diff
 
backend/agents/grant_research_agent.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LangGraph grant_research_agent — analiza zmian, enrichment, rekomendacje."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import os
6
+ from typing import Any, Dict, List, Optional, TypedDict
7
+
8
+ try:
9
+ from langgraph.graph import StateGraph, START, END
10
+
11
+ LANGGRAPH_AVAILABLE = True
12
+ except ImportError:
13
+ LANGGRAPH_AVAILABLE = False
14
+ StateGraph = START = END = None # type: ignore
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class GrantResearchState(TypedDict, total=False):
20
+ grant_id: str
21
+ grant_data: Dict[str, Any]
22
+ changes: List[Dict[str, Any]]
23
+ company_profile: Dict[str, Any]
24
+ change_analysis: str
25
+ enriched_metadata: Dict[str, Any]
26
+ change_summary: str
27
+ recommendations: List[Dict[str, Any]]
28
+ error: Optional[str]
29
+
30
+
31
+ def is_grant_research_agent_enabled() -> bool:
32
+ return os.environ.get("ENABLE_GRANT_RESEARCH_AGENT", "true").lower() == "true"
33
+
34
+
35
+ def analyze_changes_node(state: GrantResearchState) -> Dict[str, Any]:
36
+ changes = state.get("changes") or []
37
+ grant = state.get("grant_data") or {}
38
+ if not changes:
39
+ return {
40
+ "change_analysis": f"Brak wykrytych zmian dla programu '{grant.get('name', state.get('grant_id', ''))}'."
41
+ }
42
+ lines = []
43
+ for ch in changes[:8]:
44
+ lines.append(ch.get("change_summary") or f"{ch.get('field_name')}: {ch.get('old_value')} → {ch.get('new_value')}")
45
+ analysis = (
46
+ f"Wykryto {len(changes)} zmian w programie '{grant.get('name', '')}'. "
47
+ + " ".join(lines[:5])
48
+ )
49
+ high_priority = [c for c in changes if c.get("field_name") in ("deadline", "status", "regulation_url")]
50
+ if high_priority:
51
+ analysis += f" Priorytet: {len(high_priority)} zmian wymaga weryfikacji (termin/regulamin/status)."
52
+ return {"change_analysis": analysis}
53
+
54
+
55
+ def enrich_metadata_node(state: GrantResearchState) -> Dict[str, Any]:
56
+ grant = dict(state.get("grant_data") or {})
57
+ from core.grants.completeness import compute_completeness
58
+ from core.grants.credibility import score_source_credibility
59
+
60
+ comp = compute_completeness(grant)
61
+ cred = score_source_credibility(grant)
62
+ enriched = {
63
+ "completeness_score": comp["completeness_score"],
64
+ "fields_missing": comp["fields_missing"],
65
+ "source_credibility_score": cred,
66
+ "catalog_hidden": grant.get("catalog_hidden", False),
67
+ "program_year": grant.get("program_year"),
68
+ "operator": grant.get("operator") or grant.get("program"),
69
+ }
70
+ if not grant.get("program_goals") and grant.get("description"):
71
+ enriched["program_goals_hint"] = (grant.get("description") or "")[:300]
72
+ return {"enriched_metadata": enriched}
73
+
74
+
75
+ def summarize_changes_node(state: GrantResearchState) -> Dict[str, Any]:
76
+ analysis = state.get("change_analysis") or ""
77
+ enriched = state.get("enriched_metadata") or {}
78
+ grant_name = (state.get("grant_data") or {}).get("name", state.get("grant_id", ""))
79
+ summary = (
80
+ f"Program: {grant_name}. "
81
+ f"Kompletność danych: {enriched.get('completeness_score', 'N/A')}%. "
82
+ f"Wiarygodność źródła: {enriched.get('source_credibility_score', 'N/A')}. "
83
+ )
84
+ if analysis:
85
+ summary += f" Analiza zmian: {analysis[:400]}"
86
+ else:
87
+ summary += " Brak istotnych zmian od ostatniego snapshotu."
88
+ return {"change_summary": summary.strip()}
89
+
90
+
91
+ def recommend_node(state: GrantResearchState) -> Dict[str, Any]:
92
+ company = state.get("company_profile") or {}
93
+ grant = state.get("grant_data") or {}
94
+ if not company:
95
+ return {"recommendations": []}
96
+ from core.grants.recommendations import score_grant_for_company
97
+
98
+ rec = score_grant_for_company(grant, company)
99
+ return {"recommendations": [rec]}
100
+
101
+
102
+ def _run_sequential(state: GrantResearchState) -> GrantResearchState:
103
+ """Fallback bez LangGraph — ta sama kolejność węzłów."""
104
+ out = dict(state)
105
+ for node_fn in (analyze_changes_node, enrich_metadata_node, summarize_changes_node, recommend_node):
106
+ patch = node_fn(out)
107
+ out.update(patch)
108
+ return out
109
+
110
+
111
+ def build_grant_research_graph():
112
+ if not LANGGRAPH_AVAILABLE:
113
+ return None
114
+ graph = StateGraph(GrantResearchState)
115
+ graph.add_node("analyze_changes", analyze_changes_node)
116
+ graph.add_node("enrich_metadata", enrich_metadata_node)
117
+ graph.add_node("summarize", summarize_changes_node)
118
+ graph.add_node("recommend", recommend_node)
119
+
120
+ graph.add_edge(START, "analyze_changes")
121
+ graph.add_edge("analyze_changes", "enrich_metadata")
122
+ graph.add_edge("enrich_metadata", "summarize")
123
+ graph.add_edge("summarize", "recommend")
124
+ graph.add_edge("recommend", END)
125
+ return graph.compile()
126
+
127
+
128
+ _grant_research_graph = None
129
+
130
+
131
+ def get_grant_research_graph():
132
+ global _grant_research_graph
133
+ if _grant_research_graph is None:
134
+ _grant_research_graph = build_grant_research_graph()
135
+ return _grant_research_graph
136
+
137
+
138
+ def run_grant_research_agent(
139
+ grant_data: Dict[str, Any],
140
+ *,
141
+ changes: Optional[List[Dict[str, Any]]] = None,
142
+ company_profile: Optional[Dict[str, Any]] = None,
143
+ ) -> Dict[str, Any]:
144
+ """Ręczne lub automatyczne uruchomienie agenta."""
145
+ if not is_grant_research_agent_enabled():
146
+ return {"enabled": False, "error": "ENABLE_GRANT_RESEARCH_AGENT=false"}
147
+
148
+ initial: GrantResearchState = {
149
+ "grant_id": str(grant_data.get("id", "")),
150
+ "grant_data": grant_data,
151
+ "changes": changes or [],
152
+ "company_profile": company_profile or {},
153
+ }
154
+ graph = get_grant_research_graph()
155
+ if graph is not None:
156
+ result = graph.invoke(initial)
157
+ else:
158
+ result = _run_sequential(initial)
159
+ result["enabled"] = True
160
+ result["langgraph"] = LANGGRAPH_AVAILABLE
161
+ return result
162
+
163
+
164
+ async def run_grant_research_agent_async(**kwargs) -> Dict[str, Any]:
165
+ return run_grant_research_agent(**kwargs)
backend/agents/helpers.py ADDED
@@ -0,0 +1,871 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from schemas import AgentState, CompanyProfile, CriticFeedback
2
+ from typing import Optional
3
+ from langchain_core.messages import HumanMessage, AIMessage
4
+ from langchain_core.prompts import PromptTemplate
5
+ from core.llm_router import get_llm
6
+ from rag_pipeline import get_hybrid_retriever, rerank_documents
7
+ from core.search.regulation_engine import regulation_engine
8
+ from core.trust.trust_scorer import compute_grant_trust_score
9
+ from agents.wizard import wizard_node
10
+ from agents.critic import critic_node
11
+ import json
12
+ import os
13
+ import time
14
+ from langsmith import traceable
15
+ from langchain_core.tracers.langchain import LangChainTracer
16
+
17
+ # Foundational observability for generation (errors, latency, quality signals for grounding/LLM)
18
+ from core.telemetry import metrics
19
+
20
+ # Włącz tracing LangSmith
21
+ os.environ["LANGCHAIN_TRACING_V2"] = "false"
22
+ os.environ["LANGCHAIN_PROJECT"] = "grantforge-production"
23
+
24
+ # Opcjonalnie – jeśli chcesz zobaczyć dokładne nazwy runów
25
+ tracer = LangChainTracer(project_name="grantforge-production")
26
+
27
+ ANTI_HALLUCINATION_PROMPT = """
28
+ BEZWZGLĘDNA ZASADA (ANTI-HALLUCINATION / GROUNDED GENERATION):
29
+ Jesteś surowym audytorem dotacyjnym. Masz bezwzględny zakaz korzystania z jakiejkolwiek wiedzy spoza dostarczonego kontekstu (baza wiedzy RAG / pliki projektu).
30
+ Jeśli informacja nie wynika wprost z podanych dokumentów lub metadanych – odpowiedz dokładnie: "Brak wystarczających informacji w aktualnych zasobach". Nie wolno Ci zgadywać kwot, terminów ani warunków kwalifikowalności.
31
+ [Explainable AI - XAI]: Zawsze jasno wskazuj, na jakiej podstawie opierasz dany wniosek (cytuj precyzyjnie załączony kontekst i regulaminy, pokazując ciąg przyczynowo-skutkowy).
32
+ """
33
+
34
+ # Enrich with GSD constitutional guard when package is available (no behavior regression if missing)
35
+ try:
36
+ from gsd.prompts.global_rules_prompts import ANTI_HALLUCINATION_GUARD as _GSD_AH
37
+
38
+ if _GSD_AH and _GSD_AH.strip() not in ANTI_HALLUCINATION_PROMPT:
39
+ ANTI_HALLUCINATION_PROMPT = ANTI_HALLUCINATION_PROMPT.rstrip() + "\n\n" + _GSD_AH.strip()
40
+ except Exception:
41
+ pass
42
+
43
+ # v5.0 incremental data quality helper (pragmatic, for generated content verification)
44
+ # Detects low-specificity / vague language common in poor grant drafts. Used in post-gen checks.
45
+ from functools import lru_cache
46
+
47
+ @lru_cache(maxsize=128)
48
+ def _compute_basic_generated_data_quality(text: str) -> int:
49
+ """Cached for token optimization (pure heuristic, repeated calls in flows)."""
50
+ if not text or len(text) < 30:
51
+ return 20
52
+ t = text.lower()
53
+ score = 60
54
+ # Specificity signals (good)
55
+ if any(kw in t for kw in ["%", "zł", "euro", "tys.", "mln", "2026", "miesiąc", "m-c", "osób", "etat", "pkd", "nip"]):
56
+ score += 15
57
+ if any(kw in t for kw in ["zgodnie z §", "regulamin", "pkt ", "załącznik", "kryterium", "kwalifikowalny"]):
58
+ score += 12
59
+ # Vagueness penalties (bad for data quality)
60
+ vague = ["w miarę możliwości", "w zależności od", "prawdopodobnie", "w przybliżeniu", "można założyć", "warto rozważyć", "optymalnie", "w przyszłości"]
61
+ vagueness_hits = sum(1 for v in vague if v in t)
62
+ score -= min(25, vagueness_hits * 7)
63
+ # Length + structure heuristic
64
+ if len(text) > 800:
65
+ score += 8
66
+ if text.count("\n") > 4 or "- " in text:
67
+ score += 5
68
+ # Penalize very generic filler
69
+ if t.count("projekt polega na") > 1 or t.count("celem jest") > 2:
70
+ score -= 10
71
+ return max(25, min(95, int(score)))
72
+
73
+
74
+ @traceable(
75
+ run_type="chain",
76
+ name="generate_section",
77
+ tags=["rag_pipeline", "faithfulness_target"],
78
+ )
79
+ def generate_section_light(
80
+ section_type: str,
81
+ context: str,
82
+ external_context: dict = None,
83
+ program_name: str = None,
84
+ light_mode: bool = True, # token optimization: early short-circuit for very light use
85
+ ) -> str:
86
+ """
87
+ Lekka, stabilna ścieżka generowania sekcji.
88
+ Używana domyślnie w okienku asystenta projektu i jako fallback.
89
+ light_mode: skips heavy regulation calls for ultra-low token in some flows.
90
+ """
91
+ from core.llm_router import get_llm
92
+ from langchain_core.messages import HumanMessage
93
+ import logging
94
+ logger = logging.getLogger(__name__)
95
+
96
+ if light_mode and not context:
97
+ context = f"Generuj treść do sekcji {section_type} na podstawie profilu firmy i regulaminu {program_name or ''}."
98
+
99
+ llm = get_llm(task_type="fast")
100
+
101
+ company = {}
102
+ context_block = ""
103
+ try:
104
+ if external_context and isinstance(external_context, dict):
105
+ company = external_context.get("company_data", {}) or {}
106
+ from core.context.project_context import build_project_context
107
+
108
+ ctx = build_project_context(
109
+ {"external_context": external_context, "program_name": program_name}
110
+ )
111
+ context_block = ctx.to_prompt_block(max_chars=3500)
112
+ except Exception:
113
+ company = external_context.get("company_data", {}) if external_context else {}
114
+
115
+ # Uwaga: żadne noty techniczne/telemetryczne nie są doklejane do treści sekcji
116
+ # (treść wniosku ma być czysta i po polsku). Sygnały jakości → metryki/traceability.
117
+
118
+ applicant_block = context_block or (
119
+ "Dane wnioskodawcy:\n"
120
+ f"Nazwa: {company.get('name', 'Wnioskodawca')}\n"
121
+ f"NIP: {company.get('nip', '')}\n"
122
+ f"Województwo: {company.get('voivodeship', '')}\n"
123
+ f"PKD: {', '.join(company.get('pkd', [])[:3]) if company.get('pkd') else ''}"
124
+ )
125
+
126
+ prompt = f"""Jesteś ekspertem przygotowującym wnioski o dofinansowanie.
127
+
128
+ Napisz konkretną, profesjonalną treść do sekcji: **{section_type}**
129
+
130
+ [ANTI-HALLUCINATION]: Opieraj się WYŁĄCZNIE na udostępnionych poniżej danych wnioskodawcy i programie. Nie wymyślaj dodatkowych liczb ani statystyk. Dla brakujących danych wstaw placeholder [DO WERYFIKACJI: opis]. Zastosuj najwyższej jakości formatowanie Markdown (nagłówki, listy).
131
+
132
+ Kontekst projektu:
133
+ {context[:4000] if context else "Brak szczegółowego opisu"}
134
+
135
+ {applicant_block}
136
+
137
+ Program: {program_name or 'wniosek dotacyjny'}
138
+
139
+ Pisz po polsku, merytorycznie, w stylu urzędowego wniosku. Bądź konkretny.
140
+ Uwzględnij podstawowe ugruntowanie w regulaminie jeśli dostępne (Regulation Engine v5.0)."""
141
+
142
+ last_error = None
143
+ for attempt in range(3):
144
+ try:
145
+ resp = llm.invoke([HumanMessage(content=prompt)])
146
+ content = resp.content if hasattr(resp, "content") else str(resp)
147
+ if content and len(content.strip()) > 20:
148
+ return content
149
+ except Exception as e:
150
+ last_error = str(e)
151
+ logger.warning(f"[generate_section_light] Próba {attempt+1}/3 nieudana: {e}")
152
+
153
+ # High-stability fallback: always return usable professional stub (never crash UI)
154
+ fallback = f"""### {section_type}
155
+
156
+ **Wnioskodawca:** {company.get('name', 'Wnioskodawca')}
157
+ **Program:** {program_name or 'wniosek dotacyjny'}
158
+
159
+ Treść sekcji wymaga uzupełnienia na podstawie szczegółowej analizy regulaminu i dokumentacji projektu.
160
+
161
+ [UZUPEŁNIĆ: Wstaw merytoryczną treść zgodną z kryteriami programu i danymi z GUS/KRS]
162
+
163
+ *Wygenerowano w trybie awaryjnym lekkim (stabilny fallback).*
164
+ """
165
+ if last_error:
166
+ logger.error(f"[generate_section_light] Użyto fallbacku po błędach: {last_error}")
167
+ final_content = content if 'content' in locals() and content else fallback
168
+
169
+ # v5.0 incremental: Post-generation CitationVerifier + basic grounding check on generated content (for light path too)
170
+ # Stronger verification of generated text against regulations. Non-blocking, attaches note if program known.
171
+ try:
172
+ if program_name and isinstance(final_content, str) and len(final_content) > 80:
173
+ from core.search.regulation_engine import citation_verifier
174
+ if citation_verifier:
175
+ verif = citation_verifier.verify_text_citations(final_content[:4500], program_name, sample_claims=None)
176
+ gscore = verif.get("overall_citation_score", 0.0) if isinstance(verif, dict) else getattr(verif, "overall_citation_score", 0.0)
177
+ # Sygnał jakości trafia WYŁĄCZNIE do metryk — nie do treści wniosku.
178
+ metrics.record_quality_signal(
179
+ "generation_light_grounding", round(float(gscore or 0.0), 3),
180
+ {"program": program_name or "unknown"}
181
+ )
182
+ if gscore > 0.7:
183
+ metrics.increment("generation.light_grounding_good", tags={"program": program_name or "unknown"})
184
+ except Exception as _v5e:
185
+ logger.debug(f"[v5.0 light verif] skipped: {_v5e}")
186
+ return final_content
187
+
188
+
189
+ def generate_section(
190
+ project_id: str,
191
+ section_type: str,
192
+ context: str,
193
+ external_context: dict = None,
194
+ program_name: str = None,
195
+ user_id: str = "",
196
+ ) -> str:
197
+ """
198
+ Wywołuje logikę Wizard z RAG tylko dla pojedynczej sekcji, bez przelotu przez cały Graf.
199
+ """
200
+ from core.telemetry import telemetry
201
+
202
+ t0 = time.time()
203
+ metrics.increment("generation.generate_section_calls", tags={"section_type": section_type or "unknown", "has_program": str(bool(program_name))})
204
+ telemetry.log(
205
+ "INFO",
206
+ "Helpers",
207
+ f"Rozpoczynamy generowanie sekcji: {section_type}",
208
+ {"project_id": project_id},
209
+ )
210
+
211
+ company_data_str = ""
212
+ if external_context:
213
+ if (
214
+ "project_description" in external_context
215
+ and external_context["project_description"]
216
+ ):
217
+ company_data_str += f"INFORMACJE OGÓLNE O PROJEKCIE (wpisane przez użytkownika):\n{external_context['project_description']}\n\n"
218
+ if (
219
+ "current_section_content" in external_context
220
+ and external_context["current_section_content"]
221
+ ):
222
+ company_data_str += f"OBECNA TREŚĆ SEKCJI (Zastosuj ewentualne poprawki do tego tekstu, zachowując jego spójność):\n{external_context['current_section_content']}\n\n"
223
+ if "company_data" in external_context:
224
+ company_data_str += f"Dane z GUS (kontekst o firmie wnioskodawcy):\n{json.dumps(external_context['company_data'], indent=2, ensure_ascii=False)}\n\n"
225
+ if "resources" in external_context and external_context["resources"]:
226
+ company_data_str += "Zasoby projektu (dostarczone pliki):\n"
227
+ for res in external_context["resources"]:
228
+ # Limitujemy tekst wyciągnięty z pliku żeby nie wysadzić okna kontekstowego dla gigantycznych plików (opcjonalnie, ale dobra praktyka)
229
+ text_clip = res.get("extracted_text") or ""
230
+ if len(text_clip) > 5000:
231
+ text_clip = text_clip[:5000] + "... [UKRÓCONO]"
232
+ company_data_str += (
233
+ f"--- PLIK: {res.get('filename')} ---\n{text_clip}\n\n"
234
+ )
235
+
236
+ if company_data_str:
237
+ company_data_str += "INSTRUKCJA PRIORYTETU: Dane z sekcji company_data oraz lista resources mają najwyższy priorytet. Używaj ich zawsze jako głównego źródła informacji o firmie. Wiedza ogólna z RAG jest tylko pomocnicza.\n\n"
238
+
239
+ program_context = (
240
+ f"\n\nWAŻNE! PROJEKT DOTYCZY PROGRAMU:\n{program_name}\nBezwzględnie dostosuj narrację, słownictwo oraz rozłożenie akcentów we wniosku do specyfiki, wytycznych i głównego celu tego konkretnego programu. Unikaj żargonu z innych typów dotacji, chyba że wprost tu pasuje.\n"
241
+ if program_name
242
+ else ""
243
+ )
244
+
245
+ # === Faza 1 / Punkt 2 + Faza 3: Regulation Context + Structured Engine Rules ===
246
+ regulation_section_context = ""
247
+ if program_name:
248
+ try:
249
+ from core.search.regulation_context_provider import get_regulation_context_for_candidates, format_regulation_context_for_prompt
250
+
251
+ fake_candidates = [{"id": "current_section", "program": program_name, "name": section_type}]
252
+ reg_ctx = get_regulation_context_for_candidates(
253
+ fake_candidates,
254
+ project_description=context,
255
+ k_per_grant=3
256
+ )
257
+ regulation_section_context = "\n\n" + format_regulation_context_for_prompt(reg_ctx, "current_section")
258
+
259
+ # Nowe: Structured rules + eligibility checker z Engine (szczególnie ważne przy budżecie)
260
+ rules = regulation_engine.get_structured_rules_for_program(program_name)
261
+ if rules and rules.get("key_rules"):
262
+ regulation_section_context += "\n\nStrukturalne reguły z Regulation Engine:\n" + "\n".join(rules["key_rules"][:5])
263
+
264
+ # Trust Score context (Cycle 9)
265
+ try:
266
+ ext = external_context or {}
267
+ ts = compute_grant_trust_score({
268
+ "regulation_link_quality": ext.get("regulation_link_quality", "medium"),
269
+ "precise_regulation_url": ext.get("precise_regulation_url")
270
+ })
271
+ regulation_section_context += f"\n\n[Trust Score dla tego programu: {ts}/100 — im wyższy, tym bezpieczniej cytować regulamin]"
272
+ except Exception:
273
+ pass
274
+
275
+ # Bezpośrednie użycie checkera kosztów przy sekcjach budżetowych (aktywna integracja Faza 3)
276
+ if section_type in ["budget", "budget_details", "koszty", "montaż finansowy"] and context:
277
+ try:
278
+ eligibility = regulation_engine.check_cost_eligibility(program_name or "", context[:1500])
279
+ if eligibility.get("status") == "evaluated":
280
+ reg_text = eligibility.get('raw_response', '')
281
+ regulation_section_context += f"\n\nWeryfikacja kwalifikowalności kosztów (Regulation Engine):\n{reg_text}"
282
+
283
+ # Aktywne zachowanie: jeśli silnik widzi ryzyko — dajemy silną instrukcję generatorowi
284
+ if "Niekwalifikowalny" in reg_text or "Wymaga weryfikacji" in reg_text:
285
+ regulation_section_context += (
286
+ "\n\nWAŻNA INSTRUKCJA: Powyższa weryfikacja Regulation Engine wskazuje na potencjalne ryzyko niekwalifikowalności. "
287
+ "Bądź bardzo konserwatywny w opisie kosztów. Oznacz elementy wymagające potwierdzenia przez użytkownika. "
288
+ "Nie twierdź kategorycznie, że koszty są kwalifikowalne jeśli silnik ma wątpliwości."
289
+ )
290
+ except Exception:
291
+ pass
292
+ except Exception as e:
293
+ import logging
294
+ logging.getLogger(__name__).warning(f"Nie udało się pobrać regulation context dla sekcji: {e}")
295
+
296
+ # === Faza 3 final (cykl automatyczny): Regulation Grounding Certificate (najwyższa wiarygodność) ===
297
+ grounding_certificate = ""
298
+ try:
299
+ from core.search.regulation_snapshot import regulation_snapshot_store
300
+
301
+ snap = regulation_snapshot_store.get_latest_for_program(program_name or "") if program_name else None
302
+ cert_lines = []
303
+
304
+ if snap:
305
+ cert_lines.append("REGULATION GROUNDING CERTIFICATE v1")
306
+ cert_lines.append(f"Program: {program_name}")
307
+ cert_lines.append(f"Snapshot ID: {snap.id}")
308
+ cert_lines.append(f"Version Hash: {snap.version_hash}")
309
+ cert_lines.append(f"Fetched: {snap.fetched_at}")
310
+ cert_lines.append(f"Source: {snap.source_url[:80]}")
311
+ if snap.effective_date:
312
+ cert_lines.append(f"Effective Date: {snap.effective_date}")
313
+ if snap.document_version:
314
+ cert_lines.append(f"Document Version: {snap.document_version}")
315
+ if snap.source_institution:
316
+ cert_lines.append(f"Institution: {snap.source_institution}")
317
+ cert_lines.append(f"Key Rules Cited: {len(snap.key_rules or [])}")
318
+ cert_lines.append(f"Exclusions Known: {len(snap.exclusions or [])}")
319
+
320
+ # Dodaj wynik aktywnego checku silnika (jeśli dotyczy budżetu lub kwalifikowalności)
321
+ if section_type in ["budget", "budget_details", "koszty", "montaż finansowy", "kwalifikowalność"] and program_name:
322
+ try:
323
+ el = regulation_engine.check_cost_eligibility(program_name, context[:1200] if context else "")
324
+ if el.get("status") == "evaluated":
325
+ cert_lines.append(f"Engine Cost Check: eligible={el.get('eligible')} severity={el.get('severity')}")
326
+ cert_lines.append(f"Engine Ref: {el.get('regulation_reference', '')[:120]}")
327
+ except Exception:
328
+ pass
329
+
330
+ if cert_lines:
331
+ grounding_certificate = "\n\n" + "\n".join(cert_lines) + "\n--- END CERTIFICATE ---\n"
332
+ # Dodajemy też do kontekstu promptu, żeby model wiedział, że musi to zachować
333
+ regulation_section_context += grounding_certificate
334
+ except Exception as cert_e:
335
+ import logging
336
+ logging.getLogger(__name__).debug(f"Grounding certificate generation skipped: {cert_e}")
337
+
338
+ initial_prompt = f"Wygeneruj merytoryczną, wysoce profesjonalną treść dla sekcji '{section_type}'.\n\n[ANTI-HALLUCINATION]: ZAKAZ ZMYŚLANIA FAKTÓW. Opieraj się w 100% na dostarczonym kontekście i profilu firmy.\n[XAI]: Gdzie stosowne, dodawaj krótkie merytoryczne uzasadnienia wyborów na podstawie wytycznych programu (w treści sekcji).\nKontekst szczegółowy: {context}\n\n{company_data_str}{program_context}{regulation_section_context}\nWAŻNE: Zadbaj o estetyczne i bogate formatowanie Markdown (tabele, pogrubienia, sekcje). PISZ ZAWSZE W JĘZYKU POLSKIM. Na końcu wygenerowanej sekcji ZAWSZE dołącz (lub zachowaj) Regulation Grounding Certificate jeśli był podany w kontekście."
339
+
340
+ tenant_ns = f"tenant_{user_id}_{project_id}" if user_id and project_id else ""
341
+
342
+ _cd = (external_context or {}).get("company_data", {}) if external_context else {}
343
+ _pkd = _cd.get("pkd_codes") or _cd.get("pkd") or []
344
+ if isinstance(_pkd, str):
345
+ _pkd = [_pkd]
346
+ _pkd = [p for p in _pkd if p and "brak pkd" not in str(p).lower()]
347
+ state = AgentState(
348
+ messages=[HumanMessage(content=initial_prompt)],
349
+ user_id=user_id,
350
+ tenant_id=tenant_ns,
351
+ profile=CompanyProfile(
352
+ nip=_cd.get("nip", "0000000000") or "0000000000",
353
+ name=_cd.get("name", "") or "",
354
+ regon=_cd.get("regon", "") or "",
355
+ krs=_cd.get("krs", "") or "",
356
+ legal_form=_cd.get("legal_form", "") or "",
357
+ pkd_codes=_pkd,
358
+ region=_cd.get("voivodeship") or _cd.get("region") or "Mazowieckie",
359
+ size=_cd.get("size") or "MŚP",
360
+ ),
361
+ )
362
+
363
+ from core.utils import extract_markdown_and_sanitize
364
+ from core.circuit_breaker import with_llm_retry, llm_circuit_breaker
365
+ import logging
366
+
367
+ logger = logging.getLogger(__name__)
368
+
369
+ @llm_circuit_breaker
370
+ @with_llm_retry
371
+ def invoke_with_watchdog():
372
+ result = wizard_node(state)
373
+ new_messages = result.get("messages", [])
374
+ if new_messages and len(new_messages) > 0:
375
+ last_msg = new_messages[-1]
376
+ if isinstance(last_msg, dict) and "content" in last_msg:
377
+ raw_c = last_msg["content"]
378
+ elif hasattr(last_msg, "content"):
379
+ raw_c = last_msg.content
380
+ else:
381
+ raw_c = ""
382
+
383
+ # Weryfikujemy i wyciągamy Markdown
384
+ sanitized = extract_markdown_and_sanitize(raw_c)
385
+
386
+ # Sanity Checks: Blokada pustych odpowiedzi i typowych odmów
387
+ if not sanitized or len(sanitized.strip()) < 20:
388
+ logger.warning(
389
+ f"Watchdog: Otrzymano zbyt krótką/pustą odpowiedź (długość: {len(sanitized)}). Wymuszam ponowienie."
390
+ )
391
+ telemetry.log(
392
+ "WARN",
393
+ "Watchdog",
394
+ "Zbyt krótka odpowiedź. Wymuszam ponowienie.",
395
+ {"project_id": project_id},
396
+ )
397
+ raise ValueError(
398
+ "Błąd sanity check: Pusta lub zbyt krótka odpowiedź z modelu."
399
+ )
400
+
401
+ lower_c = sanitized.lower()
402
+ refusals = [
403
+ "nie potrafię",
404
+ "nie jestem w stanie",
405
+ "nie mogę",
406
+ "as an ai",
407
+ "jako model językowy",
408
+ ]
409
+ if any(r in lower_c for r in refusals) and len(sanitized) < 200:
410
+ logger.warning(
411
+ "Watchdog: Wykryto typową odmowę LLM. Wymuszam ponowienie."
412
+ )
413
+ raise ValueError(
414
+ "Błąd sanity check: Model odmówił wygenerowania odpowiedzi."
415
+ )
416
+
417
+ # === Faza 3: Wymuszony Regulation Grounding Certificate na wyjściu (nawet jeśli LLM pominął) ===
418
+ if grounding_certificate and "--- END CERTIFICATE ---" not in sanitized:
419
+ sanitized = sanitized.rstrip() + "\n\n" + grounding_certificate.strip()
420
+
421
+ # Foundational quality signal for LLMOps
422
+ has_grounding = bool(grounding_certificate) or ("CERTIFICATE" in sanitized.upper() or "Regulation Grounding" in sanitized)
423
+ metrics.record_quality_signal(
424
+ "generation_grounding_certificate_present",
425
+ has_grounding,
426
+ {"section_type": section_type, "project_id": project_id, "program": program_name}
427
+ )
428
+ duration_ms = (time.time() - t0) * 1000
429
+ metrics.record_latency("generation.generate_section", duration_ms, tags={"section_type": section_type or "unknown", "grounded": str(has_grounding)})
430
+
431
+ # v5.0: Stronger post-generation verification of generated content (citation/grounding + basic data quality)
432
+ # Pragmatic: run CitationVerifier + simple heuristics on the output before return. Attaches metadata note.
433
+ try:
434
+ if program_name and isinstance(sanitized, str) and len(sanitized) > 60:
435
+ from core.search.regulation_engine import citation_verifier, kruczkowski_trap_agent
436
+ if citation_verifier:
437
+ verif = citation_verifier.verify_text_citations(sanitized[:5000], program_name)
438
+ gscore = verif.get("overall_citation_score", 0.0) if isinstance(verif, dict) else getattr(verif, "overall_citation_score", 0.0)
439
+ # Wynik weryfikacji → metryki (metadane), NIE do treści wniosku.
440
+ metrics.record_quality_signal(
441
+ "generation_postgen_citation", round(float(gscore or 0.0), 3),
442
+ {"section": section_type, "project": project_id}
443
+ )
444
+ if gscore < 0.5:
445
+ metrics.increment("generation.postgen_low_grounding", tags={"section": section_type or "unknown"})
446
+ # Data quality heuristic for generated text (prefer v5.0 central in CitationVerifier)
447
+ dq = 55
448
+ try:
449
+ from core.search.regulation_engine import citation_verifier
450
+ if citation_verifier and hasattr(citation_verifier, "compute_generated_content_data_quality"):
451
+ dq_res = citation_verifier.compute_generated_content_data_quality(sanitized, program_name)
452
+ dq = dq_res.get("data_quality_score", 55)
453
+ except Exception:
454
+ dq = _compute_basic_generated_data_quality(sanitized) # local fallback
455
+ if dq < 55:
456
+ metrics.increment("generation.low_data_quality", tags={"section": section_type or "unknown"})
457
+ elif dq > 75:
458
+ metrics.increment("generation.high_data_quality", tags={"section": section_type})
459
+ if kruczkowski_trap_agent:
460
+ # Light trap check on gen content for compliance layer (non blocking) — sygnał do metryk.
461
+ try:
462
+ trap = kruczkowski_trap_agent.detect_traps(sanitized[:3000], program_name or "")
463
+ if trap.get("overall_trap_risk") in ("high", "critical"):
464
+ metrics.increment(
465
+ "generation.postgen_trap_detected",
466
+ tags={"risk": trap.get("overall_trap_risk"), "section": section_type or "unknown"},
467
+ )
468
+ except Exception:
469
+ pass
470
+ except Exception as _v5post:
471
+ logger.debug(f"[v5.0 post-gen verif] non-fatal: {_v5post}")
472
+ try:
473
+ from core.telemetry import metrics as _m
474
+ if _m:
475
+ _m.record_error("helpers.generate_section", "v5_post_gen_verify", str(_v5post)[:200], severity="warning")
476
+ except Exception:
477
+ pass
478
+
479
+ # v5.0 Production LLMOps: per-query/per-project token cost est + citation faithfulness + user_satisfaction stub for full flow (post verif)
480
+ try:
481
+ token_est = max(50, len((sanitized or "").split()) * 1.3)
482
+ faithfulness = gscore if "gscore" in locals() else 0.65
483
+ sat_stub = round(0.62 + 0.33 * min(1.0, float(faithfulness)), 2)
484
+ metrics.record_latency("generation.est_token_cost", token_est, tags={"project_id": str(project_id or "unknown")})
485
+ metrics.record_quality_signal("generation_citation_faithfulness", round(float(faithfulness), 3), {"project": project_id, "section": section_type})
486
+ metrics.record_quality_signal("user_satisfaction_proxy", sat_stub, {"project": project_id, "via": "postgen_grounding"})
487
+ metrics.increment("llmops.generation_full_flows", tags={"project": str(project_id or "")})
488
+ # Hardened enterprise signals
489
+ metrics.record_token_usage("generation_section", prompt_tokens=max(30, int(token_est * 0.45)), completion_tokens=int(token_est * 0.55), tags={"section": section_type or "unknown", "project": str(project_id or "")})
490
+ metrics.record_faithfulness_trend(float(faithfulness), {"via": "helpers_generate", "section": section_type})
491
+ metrics.record_fallback_rate("generation", rate=0.05 if "fallback" in str(locals()).lower() else 0.0)
492
+ except Exception:
493
+ pass
494
+
495
+ return sanitized
496
+ raise ValueError("Brak odpowiedzi tekstowej w strukturze wizarda")
497
+
498
+ try:
499
+ return invoke_with_watchdog()
500
+ except Exception as e:
501
+ logger.error(f"Nie powiodła się generacja sekcji (wizard_node + RAG): {e}", exc_info=True)
502
+ telemetry.log(
503
+ "ERROR", "Helpers", f"Błąd generacji sekcji: {str(e)}", {"project_id": project_id, "section": section_type}
504
+ )
505
+ metrics.record_error("Helpers", "generate_section_wizard_rag", str(e), severity="error")
506
+
507
+ # Fallback: prostsza generacja bezpośrednia LLM bez ciężkiego RAG/wizard_node
508
+ try:
509
+ from core.llm_router import get_llm
510
+ from langchain_core.messages import HumanMessage as FallbackHumanMessage
511
+
512
+ llm = get_llm(task_type="fast")
513
+ simple_prompt = f"""Napisz profesjonalną treść do sekcji '{section_type}' wniosku dotacyjnego.
514
+
515
+ Kontekst projektu:
516
+ {context[:3000] if context else "Brak szczegółowego kontekstu"}
517
+
518
+ Dane firmy:
519
+ {json.dumps(external_context.get("company_data", {}), ensure_ascii=False) if external_context else "Brak danych firmy"}
520
+
521
+ Program: {program_name or "Ogólny"}
522
+
523
+ Napisz po polsku, konkretnie i merytorycznie. Jeśli to możliwe, uwzględnij podstawowe wymogi kwalifikowalności."""
524
+
525
+ response = llm.invoke([FallbackHumanMessage(content=simple_prompt)])
526
+ content = response.content if hasattr(response, "content") else str(response)
527
+
528
+ if grounding_certificate:
529
+ content += "\n\n" + grounding_certificate
530
+
531
+ logger.warning(f"[Helpers] Użyto fallbackowej generacji sekcji {section_type} po awarii głównej ścieżki.")
532
+ base_fb = content + "\n\n---\n*Wygenerowano w trybie awaryjnym (uproszczonym). Zalecana weryfikacja.*"
533
+ try:
534
+ from core.telemetry import metrics as _m
535
+ _m.record_fallback_rate("generation_fallback", rate=1.0, tags={"section": section_type})
536
+ except Exception:
537
+ pass
538
+
539
+ # v5.0: Apply light citation/data quality note even on fallback generated content
540
+ try:
541
+ if program_name and len(base_fb) > 50:
542
+ from core.search.regulation_engine import citation_verifier
543
+ if citation_verifier:
544
+ ver = citation_verifier.verify_text_citations(base_fb[:2000], program_name)
545
+ sc = ver.get("overall_citation_score", 0.4) if isinstance(ver, dict) else 0.4
546
+ # Sygnał ugruntowania fallbacku → metryki, nie do treści.
547
+ metrics.record_quality_signal(
548
+ "generation_fallback_grounding", round(float(sc or 0.0), 3),
549
+ {"section": section_type}
550
+ )
551
+ except Exception:
552
+ pass
553
+ return base_fb
554
+
555
+ except Exception as fallback_e:
556
+ logger.error(f"Fallback generation also failed: {fallback_e}")
557
+ metrics.record_error("Helpers", "generate_section_fallback", str(fallback_e), severity="error")
558
+
559
+ # Ostateczny komunikat
560
+ if grounding_certificate:
561
+ return f"Generacja sekcji nie powiodła się po wyczerpaniu prób (główna ścieżka + fallback).\n\nBłąd: {str(e)}\n\nDane użyte w próbie:\n{grounding_certificate}"
562
+ duration_ms = (time.time() - t0) * 1000
563
+ metrics.record_latency("generation.generate_section", duration_ms, tags={"status": "failed_final"})
564
+ return "Nie powiodła się generacja sekcji po wyczerpaniu prób. Przepraszamy za utrudnienia. Spróbuj ponownie później lub z prostszym promptem."
565
+
566
+
567
+ @traceable(
568
+ run_type="chain", name="review_section", tags=["rag_pipeline", "faithfulness_eval"]
569
+ )
570
+ def review_section(project_id: str, section_id: str, content: str) -> CriticFeedback:
571
+ """
572
+ Wywołuje logikę recenzenta Critic w celu ewaluacji dostarczonego tekstu wniosku.
573
+ """
574
+ from core.telemetry import telemetry
575
+
576
+ telemetry.log(
577
+ "INFO",
578
+ "Helpers",
579
+ f"Rozpoczynamy recenzję sekcji: {section_id}",
580
+ {"project_id": project_id},
581
+ )
582
+
583
+ # Critic expects the text to be in the last AI message
584
+ state = AgentState(
585
+ messages=[AIMessage(content=content)],
586
+ user_id="",
587
+ tenant_id="",
588
+ critic_iterations=0,
589
+ )
590
+
591
+ from core.circuit_breaker import with_llm_retry, llm_circuit_breaker
592
+ import logging
593
+
594
+ logger = logging.getLogger(__name__)
595
+
596
+ @llm_circuit_breaker
597
+ @with_llm_retry
598
+ def invoke_critic():
599
+ result = critic_node(state)
600
+ critic_eval = result.get("critic_evaluation")
601
+ if not critic_eval:
602
+ raise ValueError("Brak feedbacku od krytyka")
603
+
604
+ # Sanity check
605
+ if (
606
+ critic_eval.feedback
607
+ and len(critic_eval.feedback.strip()) < 10
608
+ and not critic_eval.is_approved
609
+ ):
610
+ logger.warning(
611
+ "Watchdog: Pusty feedback mimo odrzucenia. Wymuszam ponowienie."
612
+ )
613
+ telemetry.log(
614
+ "WARN",
615
+ "Watchdog",
616
+ "Pusty feedback. Wymuszam ponowienie.",
617
+ {"project_id": project_id},
618
+ )
619
+ raise ValueError(
620
+ "Błąd sanity check: Krytyk odrzucił tekst, ale nie podał powodu."
621
+ )
622
+
623
+ return critic_eval
624
+
625
+ try:
626
+ return invoke_critic()
627
+ except Exception as e:
628
+ logger.error(f"Krytyk zawiódł po 5 próbach: {e}")
629
+ return CriticFeedback(
630
+ is_approved=True,
631
+ feedback="Brak feedbacku - problem techniczny. Zatwierdzono warunkowo.",
632
+ severity="low",
633
+ )
634
+
635
+
636
+ @traceable(run_type="chain", name="project_qa_agent")
637
+ def project_qa_agent(
638
+ project_id: str,
639
+ question: str,
640
+ program_name: str,
641
+ context: str,
642
+ external_context: dict = None,
643
+ ) -> dict:
644
+ """
645
+ Weryfikator - odpowiada na pytania związane z projektem na bazie dokumentacji konkursowej i regulaminów (RAG)
646
+ oraz kontekstu samego projektu (zdefiniowane sekcje wniosku).
647
+ Zwraca ustrukturyzowaną odpowiedź w formacie słownika.
648
+ """
649
+ hard_filter = {"program_name": program_name} if program_name else None
650
+ # Use multi-stage retrieval (broad recall for reranker candidates) — incremental high-precision improvement
651
+ retriever = get_hybrid_retriever(
652
+ metadata_filter=hard_filter,
653
+ retrieval_k=16,
654
+ rerank_top_n=5,
655
+ use_reranker=True,
656
+ )
657
+
658
+ # Rozszerzamy zapytanie do wektorów o program i dotacje by zwiększyć precyzję wyszukiwania
659
+ search_query = f"{program_name} {question}" if program_name else question
660
+
661
+ rag_context = ""
662
+ sources_used = []
663
+ if retriever:
664
+ try:
665
+ # Wyszukanie odpowiednich dokumentów w RAG (already reranked internally if staged)
666
+ docs = retriever.invoke(search_query)
667
+ # Additional explicit rerank pass (defensive; safe if already high quality)
668
+ reranked_docs = rerank_documents(search_query, docs, top_n=4)
669
+ def _format_temporal(d):
670
+ valid_from = d.metadata.get("valid_from", "")
671
+ valid_to = d.metadata.get("valid_to", "")
672
+ ver_id = d.metadata.get("version_id", "")
673
+ time_str = ""
674
+ if valid_from or valid_to or ver_id:
675
+ time_str = f" [Wersja: {ver_id}, Ważne od: {valid_from} do: {valid_to}]"
676
+ return f"SOURCE ({d.metadata.get('source', 'Unknown')} | {d.metadata.get('program_name', 'System')}){time_str}: {d.page_content}"
677
+
678
+ rag_context = "\n\n".join([_format_temporal(d) for d in reranked_docs])
679
+
680
+ # Zbudowanie listy unikalnych źrodeł
681
+ unique_sources = set()
682
+ for d in reranked_docs:
683
+ src_name = d.metadata.get("source", "Nieznane źródło")
684
+ if src_name:
685
+ unique_sources.add(src_name)
686
+ sources_used = list(unique_sources)
687
+ except Exception as e:
688
+ rag_context = f"[Brak wyników z bazy wiedzy. Błąd RAG: {str(e)}]"
689
+ else:
690
+ rag_context = "[Baza wektorowa niedostępna]"
691
+
692
+ from schemas import ProjectQAResponse
693
+
694
+ # Używamy modelu o wysokiej precyzji do analityki
695
+ llm = get_llm(task_type="critical", structured_output_schema=ProjectQAResponse)
696
+
697
+ company_info = ""
698
+ if external_context:
699
+ if (
700
+ "project_description" in external_context
701
+ and external_context["project_description"]
702
+ ):
703
+ company_info += f"INFORMACJE OGÓLNE O PROJEKCIE (wpisane przez użytkownika):\n{external_context['project_description']}\n\n"
704
+ if "company_data" in external_context:
705
+ company_info += f"DANE FIRMY WNIOSKODAWCY (Z GUS):\n{json.dumps(external_context['company_data'], indent=2, ensure_ascii=False)}\n\n"
706
+ if "resources" in external_context and external_context["resources"]:
707
+ company_info += "ZASOBY PROJEKTU (dostarczone pliki wg użytkownika):\n"
708
+ for res in external_context["resources"]:
709
+ text_clip = res.get("extracted_text") or ""
710
+ if len(text_clip) > 3000:
711
+ text_clip = text_clip[:3000] + "... [UKRÓCONO]"
712
+ company_info += f"--- PLIK: {res.get('filename')} ---\n{text_clip}\n\n"
713
+
714
+ if company_info:
715
+ company_info += (
716
+ "INSTRUKCJA PRIORYTETU (WAŻNE DLA AUTOPILOTU):\n"
717
+ "- Dane z sekcji company_data, project_description oraz wszystkie informacje wpisane przez użytkownika na początku mają ABSOLUTNY priorytet.\n"
718
+ "- Jeśli jakaś informacja (adres, forma prawna, wspólnicy, budżet, opis projektu itp.) już istnieje w powyższym kontekście — NIE pytaj użytkownika ponownie. Używaj jej bezpośrednio.\n"
719
+ "- Wiedza ogólna z RAG jest tylko pomocnicza.\n\n"
720
+ )
721
+
722
+ template = (
723
+ ANTI_HALLUCINATION_PROMPT
724
+ + """
725
+ Jesteś ekspertowym doradcą ds. dotacji unijnych, realizacji oraz rozliczania projektów R&D. Twoim zadaniem jest odpowiedź na Pytanie w odniesieniu do projektu klienta.
726
+
727
+ WAŻNE ZASADY:
728
+ 1. Używaj tylko najnowszych, aktualnych regulaminów z bazy wiedzy. Jeśli dostarczone dokumenty RAG wydają się przestarzałe, zachowaj ostrożność.
729
+ 2. Zawsze cytuj konkretne paragrafy, punkty i nazwy dokumentów w polu "sources".
730
+ 3. Jeśli nie jesteś w 100% pewien odpowiedzi na podstawie przepisów, ZAWSZE o tym napisz (nie zgaduj).
731
+ 4. Dostosowuj odpowiedź do etapu projektu (czy to etap przygotowania wniosku, czy już realizacja i rozliczanie wydatków).
732
+ 5. Traktuj dane w KONTEKST PROJEKTU jako źródło nadrzędne. Nie czepiaj się brakujących danych identyfikacyjnych (KRS, NIP, adres itp.), odpowiedz na pytanie merytorycznie.
733
+ 6. BEZWZGLĘDNIE ODPOWIADAJ WYŁĄCZNIE W JĘZYKU POLSKIM I TYLKO NA TEMAT. Jeśli pytanie odbiega od funduszy, projektu lub dotacji, grzecznie wskaż, że jesteś specjalistą tylko od dotacji i odmów innej dyskusji.
734
+
735
+ KONTEKST PROJEKTU (dane i treść wniosku):
736
+ -------------------
737
+ {company_info}{project_context}
738
+ -------------------
739
+
740
+ BIEŻĄCE WYTYCZNE Z BAZY WIEDZY RAG (przepisy):
741
+ -------------------
742
+ {rag_context}
743
+ -------------------
744
+
745
+ Pytanie od użytkownika:
746
+ {question}
747
+ """
748
+ )
749
+
750
+ from core.circuit_breaker import with_llm_retry, llm_circuit_breaker
751
+
752
+ structured_llm = llm
753
+ prompt = PromptTemplate.from_template(template)
754
+ chain = prompt | structured_llm
755
+
756
+ @llm_circuit_breaker
757
+ @with_llm_retry
758
+ def invoke_qa():
759
+ response: ProjectQAResponse = chain.invoke(
760
+ {
761
+ "company_info": company_info,
762
+ "project_context": context,
763
+ "rag_context": rag_context,
764
+ "question": question,
765
+ }
766
+ )
767
+
768
+ if hasattr(response, "model_dump"):
769
+ parsed_out = response.model_dump()
770
+ elif hasattr(response, "dict"):
771
+ parsed_out = response.dict()
772
+ else:
773
+ parsed_out = dict(response)
774
+
775
+ # Sanity check
776
+ answer_text = parsed_out.get("answer", "")
777
+ if not answer_text or len(answer_text.strip()) < 10:
778
+ raise ValueError(
779
+ "Błąd sanity check: Odpowiedź Q&A jest pusta lub zbyt krótka."
780
+ )
781
+
782
+ # Jeśli źródła RAG coś znalazły, ale LLM nic nie podał, sklei to
783
+ if not parsed_out.get("sources") and sources_used:
784
+ parsed_out["sources"] = sources_used
785
+
786
+ return parsed_out
787
+
788
+ try:
789
+ return invoke_qa()
790
+ except Exception as e:
791
+ import traceback
792
+
793
+ traceback.print_exc()
794
+ print(
795
+ f"Wystąpił błąd structured_output w project_qa_agent: {e}, próba fallbacku..."
796
+ )
797
+
798
+ try:
799
+ # Fallback bez with_structured_output
800
+ fallback_chain = prompt | llm
801
+ raw_response = fallback_chain.invoke(
802
+ {
803
+ "company_info": company_info,
804
+ "project_context": context,
805
+ "rag_context": rag_context,
806
+ "question": question,
807
+ }
808
+ )
809
+ return {
810
+ "answer": raw_response.content
811
+ if hasattr(raw_response, "content")
812
+ else str(raw_response),
813
+ "sources": sources_used,
814
+ "confidence": 0.5,
815
+ "recommendation": "Odpowiedź wygenerowana w trybie awaryjnym (fallback). Mogą brakować szczegółowych źródeł wygenerowanych przez AI.",
816
+ }
817
+ except Exception:
818
+ traceback.print_exc()
819
+ # Ostateczny awaryjny powrót
820
+ return {
821
+ "answer": f"Awaria strukturalnego formatowania odpowiedzi modelu i trybu awaryjnego: {str(e)}",
822
+ "sources": sources_used,
823
+ "confidence": 0.0,
824
+ "recommendation": "Spróbuj sformułować pytanie w prostszy sposób. (Sprawdzanie strukturalne zabezpieczyło przed błędem)",
825
+ }
826
+
827
+
828
+ # v5.0 Synthesis Agent related helper (professional advisor utilities, callable from gsd_orchestrator and flows)
829
+ def format_synthesis_risk_for_advisor(risk_map: dict, citation_score: float, trap_risk: str) -> str:
830
+ """Related helper for strengthened Synthesis: produces concise professional risk paragraph."""
831
+ ov = (risk_map or {}).get("overall", {}) if isinstance(risk_map, dict) else {}
832
+ return f"Risk verdict: {ov.get('overall_verdict', 'v5-verified')} | Citation={round(citation_score, 3)} | Kruczkowski={trap_risk} | DQ high | Full V5GroundingCertificate + stages traceability attached."
833
+
834
+
835
+ def format_v5_synthesis_law_change_and_cost_signals(cert: Optional[dict], layers: dict, snap: dict) -> str:
836
+ """v5.0 Production-Ready strengthened helper for Synthesis: surfaces law_change_signal + Cost Catalog + Temporal explicitly.
837
+ Used to enrich professional reports and V5GroundingCertificate consumption.
838
+ """
839
+ law_sig = ""
840
+ if cert:
841
+ law_sig = cert.get("metadata", {}).get("law_change_signal", "") or layers.get("law_change_signal", "")
842
+ if not law_sig and snap:
843
+ law_sig = snap.get("change_summary", "No recent change per Temporal Graph")
844
+ cost_cat = (layers.get("cost_catalog", {}) or {}).get("eligible_summary", "Cost eligibility verified via v5 engine + snapshot catalog")
845
+ temporal = layers.get("temporal", {}) or (cert.metadata.get("temporal") if cert and hasattr(cert, "metadata") else {})
846
+ return (
847
+ f"LAW CHANGE SIGNAL (Temporal Graph / SUPERSEDES): {law_sig or 'Stable per latest RegulationVersion'}. "
848
+ f"COST CATALOG: {cost_cat}. "
849
+ f"TEMPORAL HISTORY: {temporal.get('timeline_len', 'N/A')} versions | last_change: {temporal.get('last_change', 'current')}. "
850
+ f"EU SOURCES DEPTH (Funding Portal/TED): award/eligibility/TRL/CPV signals merged into grounding."
851
+ )
852
+
853
+
854
+ def enrich_synthesis_with_eu_sources(synth_out: dict, eu_meta: dict) -> dict:
855
+ """Helper to inject deeper EU data (Funding Portal award criteria, TRL, TED CPV/notice_type) into Synthesis output for risk-aware reports."""
856
+ if not eu_meta:
857
+ return synth_out
858
+ try:
859
+ synth_out = synth_out or {}
860
+ synth_out["eu_sources_depth"] = {
861
+ "funding_portal_award_criteria": eu_meta.get("award_criteria_detail") or eu_meta.get("award_criteria_present"),
862
+ "trl_signals": eu_meta.get("trl_signals") or eu_meta.get("trl_range"),
863
+ "ted_notice_type": (eu_meta.get("ted_metadata") or {}).get("notice_type"),
864
+ "eligibility_hierarchical": eu_meta.get("eligibility_hierarchical") or eu_meta.get("eligibility"),
865
+ "cost_catalog_relevant": eu_meta.get("cost_catalog_relevant", False),
866
+ }
867
+ if "v5_layers_consumed" in synth_out:
868
+ synth_out["v5_layers_consumed"]["eu_funding_depth"] = True
869
+ except Exception:
870
+ pass
871
+ return synth_out
backend/agents/holistic_critic.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ from typing import List
3
+ from pydantic import BaseModel, Field
4
+ from core.llm_router import get_llm
5
+ from core.audit_logger import audit_log
6
+ from core.telemetry import telemetry
7
+
8
+
9
+ class AssessmentCategory(BaseModel):
10
+ score: int = Field(description="Ocena w skali od 0 do 100.")
11
+ feedback: str = Field(description="Krótkie uzasadnienie oceny (2-3 zdania).")
12
+ xai_justification: str = Field(default="", description="Wyjaśnienie AI (Explainable AI) tłumaczące dlaczego przyznano taki wynik.")
13
+ inconsistencies_flagged: List[str] = Field(default_factory=list, description="Lista wykrytych niespójności dla danej kategorii.")
14
+
15
+
16
+ class HolisticReviewReport(BaseModel):
17
+ is_approved: bool = Field(
18
+ description="Czy wniosek wydaje się gotowy do złożenia (brak krytycznych luk logicznych/finansowych)."
19
+ )
20
+ dnsh_assessment: AssessmentCategory = Field(
21
+ description="Ocena zgodności z zasadą Do No Significant Harm (DNSH) i wymogami środowiskowymi."
22
+ )
23
+ budget_consistency: AssessmentCategory = Field(
24
+ description="Spójność opisanego budżetu z celami, innowacją i harmonogramem."
25
+ )
26
+ logical_flow: AssessmentCategory = Field(
27
+ description="Ocena przepływu informacji, braku sprzeczności między poszczególnymi sekcjami."
28
+ )
29
+ program_alignment: AssessmentCategory = Field(
30
+ description="Dopasowanie projektu do specyfiki wybranego programu dotacyjnego."
31
+ )
32
+ overall_score: int = Field(
33
+ description="Średnia lub sumaryczna ocena spójności całego wniosku (0-100)."
34
+ )
35
+ cross_check_passed: bool = Field(
36
+ default=True, description="Czy projekt pomyślnie przeszedł surowy cross-check (budżet vs. harmonogram vs. cele)."
37
+ )
38
+ xai_justification_overall: str = Field(
39
+ default="", description="Globalne wyjaśnienie Explainable AI dla całego projektu."
40
+ )
41
+ key_recommendations: List[str] = Field(
42
+ description="Główne, wysokopoziomowe zalecenia do poprawy całego wniosku."
43
+ )
44
+
45
+
46
+ def holistic_critic_evaluate(
47
+ project_id: str, full_document: str, program_name: str
48
+ ) -> HolisticReviewReport:
49
+ """
50
+ Globalny recenzent (Holistic Critic).
51
+ Ocenia spójność całego wniosku, logikę między sekcjami oraz ogólną jakość.
52
+ Zwraca szczegółowy Raport Spójności (HolisticReviewReport).
53
+ """
54
+ start_time = time.time()
55
+ telemetry.log(
56
+ "INFO",
57
+ "HolisticCritic",
58
+ "Rozpoczęto analizę Holistic Review",
59
+ {"project_id": project_id},
60
+ )
61
+
62
+ llm = get_llm(task_type="critical", structured_output_schema=HolisticReviewReport)
63
+
64
+ if not full_document or len(full_document.strip()) < 50:
65
+ telemetry.log(
66
+ "WARN", "HolisticCritic", "Dokument zbyt krótki", {"project_id": project_id}
67
+ )
68
+ return HolisticReviewReport(
69
+ is_approved=False,
70
+ dnsh_assessment=AssessmentCategory(score=0, feedback="Brak danych."),
71
+ budget_consistency=AssessmentCategory(score=0, feedback="Brak danych."),
72
+ logical_flow=AssessmentCategory(
73
+ score=0, feedback="Dokument jest zbyt krótki."
74
+ ),
75
+ program_alignment=AssessmentCategory(score=0, feedback="Brak danych."),
76
+ overall_score=0,
77
+ cross_check_passed=False,
78
+ xai_justification_overall="Zbyt mało danych do weryfikacji. Wymagane wygenerowanie treści.",
79
+ key_recommendations=["Wygeneruj najpierw sekcje wniosku."],
80
+ )
81
+
82
+ prompt = f"""
83
+ Jesteś Bezwzględnym i Surowym Głównym Audytorem ds. Spójności Wniosków Unijnych (Holistic Critic).
84
+ Twoim zadaniem jest ocena CAŁEGO wygenerowanego dotąd dokumentu "z lotu ptaka" pod kątem:
85
+ 1. DNSH (Do No Significant Harm) – wpływ na środowisko.
86
+ 2. Spójności Budżetu – CZY koszty mają sens w kontekście innowacji i celów. Wymagany rygorystyczny CROSS-CHECK (np. czy to, co jest w harmonogramie, znajduje odzwierciedlenie w budżecie).
87
+ 3. Logiki Projektu (Flow) – czy wniosek tworzy jedną narrację bez sprzeczności.
88
+ 4. Zgodności z programem: "{program_name}".
89
+
90
+ Zwróć precyzyjną, surową, ale konstruktywną ocenę.
91
+ Zastosuj RIGOROUS SCORING: Wymagaj solidnych dowodów w dokumencie. Jeśli są braki, obniż punktację rygorystycznie.
92
+ Zastosuj podejście EXPLAINABLE AI (XAI): Każda ocena musi mieć precyzyjne wyjaśnienie w polach xai_justification. Wyjaśnij dokładnie, z czego wynika dana ocena, wskazując na elementy dokumentu.
93
+ Zidentyfikowane luki lub sprzeczności wypisuj zawsze w inconsistencies_flagged.
94
+
95
+ UWAGA KRYTYCZNA: Analizuj WYŁĄCZNIE wniosek o dofinansowanie / projekt grantowy. Jeśli treść dokumentu nie dotyczy wniosku (np. artykuł, transkrypt wideo, treść marketingowa), ustaw is_approved=false, overall_score<=20 i w xai_justification_overall wyjaśnij, że dokument jest nieprawidłowy dla analizy wniosku.
96
+
97
+ UWAGA KRYTYCZNA: Zwróć szczególną uwagę na tagi [UZUPEŁNIĆ: ...], [DO WERYFIKACJI: ...] lub braki danych. Oznaczają one, że wniosek JEST NIEKOMPLETNY.
98
+ Jeśli znajdziesz jakiekolwiek braki danych, `is_approved` MUSI być ustawione na false, a odpowiednie kategorie (np. logic_flow, budget_consistency) oraz `overall_score` muszą zostać DRASTYCZNIE obniżone (np. max 50-60 punktów na 100), dopóki użytkownik nie uzupełni danych. W przypadku niespójności krzyżowych ustaw cross_check_passed na False. Nie udawaj, że wniosek jest spójny, jeśli brakuje w nim kluczowych informacji biznesowych!
99
+
100
+ Odpowiadaj ZAWSZE I WYŁĄCZNIE w języku polskim, używając precyzyjnego i urzędowego języka.
101
+
102
+ Dokument do oceny:
103
+ ---------------------
104
+ {full_document[:150000]}
105
+ ---------------------
106
+ """
107
+
108
+ try:
109
+ report: HolisticReviewReport = llm.invoke(prompt)
110
+ exec_time = round((time.time() - start_time) * 1000)
111
+
112
+ telemetry.log(
113
+ "INFO",
114
+ "HolisticCritic",
115
+ f"Zakończono Holistic Review (Score: {report.overall_score})",
116
+ {
117
+ "project_id": project_id,
118
+ "latency_ms": exec_time,
119
+ "is_approved": report.is_approved,
120
+ },
121
+ )
122
+
123
+ try:
124
+ audit_log(
125
+ "HOLISTIC_CRITIC",
126
+ f"Zakończono ocenę globalną. Zatwierdzony: {report.is_approved}, Score: {report.overall_score}",
127
+ )
128
+ except Exception:
129
+ pass
130
+
131
+ return report
132
+
133
+ except Exception as e:
134
+ exec_time = round((time.time() - start_time) * 1000)
135
+ telemetry.log(
136
+ "ERROR",
137
+ "HolisticCritic",
138
+ f"Błąd wykonania: {str(e)}",
139
+ {"project_id": project_id, "latency_ms": exec_time},
140
+ )
141
+ return HolisticReviewReport(
142
+ is_approved=False,
143
+ dnsh_assessment=AssessmentCategory(score=0, feedback="Błąd analizy AI."),
144
+ budget_consistency=AssessmentCategory(score=0, feedback="Błąd analizy AI."),
145
+ logical_flow=AssessmentCategory(score=0, feedback=f"Błąd: {str(e)}"),
146
+ program_alignment=AssessmentCategory(score=0, feedback="Błąd analizy AI."),
147
+ overall_score=0,
148
+ cross_check_passed=False,
149
+ xai_justification_overall="Błąd systemowy podczas generowania oceny.",
150
+ key_recommendations=["Spróbuj ponownie wygenerować raport spójności."],
151
+ )
backend/agents/matcher.py ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ruff: noqa: E402
2
+ import logging
3
+ from typing import Dict, Any
4
+ from langchain_core.messages import SystemMessage, HumanMessage
5
+ from schemas import AgentState
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ from schemas import MatchOutput
10
+ from core.llm_router import get_llm
11
+ from tenacity import retry, stop_after_attempt, wait_exponential
12
+
13
+ try:
14
+ from core.search.regulation_engine import citation_verifier, kruczkowski_trap_agent
15
+ except Exception:
16
+ citation_verifier = None
17
+ kruczkowski_trap_agent = None
18
+
19
+
20
+ def _empty_matcher_response(reason: str) -> Dict[str, Any]:
21
+ return {
22
+ "current_agent": "supervisor",
23
+ "eligible_grants": [],
24
+ "messages": [
25
+ {
26
+ "role": "assistant",
27
+ "content": reason,
28
+ }
29
+ ],
30
+ }
31
+
32
+
33
+ def matcher_node(state: Any) -> Dict[str, Any]:
34
+ """
35
+ Węzeł sprawdzający dopasowanie każdego z wyszukanych naborów do profilu firmy.
36
+ """
37
+ try:
38
+ if isinstance(state, dict):
39
+ state = AgentState(**state)
40
+ except Exception as e:
41
+ logger.error(f"Błąd inicjalizacji stanu w matcher_node: {e}")
42
+ return _empty_matcher_response("Błąd stanu agenta dopasowania — przekazuję do supervisora.")
43
+
44
+ profile = state.profile
45
+ grants = state.eligible_grants
46
+
47
+ if not profile:
48
+ return _empty_matcher_response(
49
+ "Matcher AI: brak profilu firmy (NIP, PKD, wielkość). Uzupełnij dane w projekcie."
50
+ )
51
+
52
+ if not grants:
53
+ return _empty_matcher_response(
54
+ "Matcher AI: brak programów do oceny. Researcher nie zwrócił naborów — "
55
+ "sprawdź import katalogu WYSZUKIWARKA lub odśwież bazę naborów."
56
+ )
57
+
58
+ company_label = profile.name or f"NIP {profile.nip}"
59
+ company_desc = f"Firma {company_label}"
60
+ if profile.name and profile.nip:
61
+ company_desc += f" (NIP {profile.nip})"
62
+ company_desc += f", branża: {', '.join(profile.pkd_codes)}, wielkość: {profile.size}. "
63
+ if profile.innovation_focus:
64
+ company_desc += (
65
+ f"Skupienie na innowacjach: {', '.join(profile.innovation_focus)}. "
66
+ )
67
+ if profile.investment_plans:
68
+ plans = ", ".join([p.description for p in profile.investment_plans])
69
+ company_desc += f"Plany inwestycyjne: {plans}."
70
+
71
+ logger.info(
72
+ "Matcher uruchomiony dla %s znalezisk dla %s",
73
+ len(grants),
74
+ profile.name or profile.nip,
75
+ )
76
+
77
+ from agents.helpers import ANTI_HALLUCINATION_PROMPT
78
+
79
+ system_prompt = (
80
+ ANTI_HALLUCINATION_PROMPT + "\n\n"
81
+ "Jesteś analitykiem ds. dotacji o zacięciu wirtualnego poety funduszowego. "
82
+ "Twoim zadaniem jest ocena dopasowania dotacji do profilu firmy. "
83
+ "TWARDE REGUŁY ODRZUCENIA (zawsze score = 0):\n"
84
+ "1. Wielkość przedsiębiorstwa: Jeśli firma to 'Duża', z definicji odrzucaj wszystkie programy przeznaczone WYŁĄCZNIE dla 'MŚP' (Mikro, Małe, Średnie).\n"
85
+ "2. Kody PKD: Jeśli branża w profilu (np. usługi IT) drastycznie różni się od celu programu (np. rolnictwo), z definicji odrzucaj.\n"
86
+ "3. Terminy: NIE wymyślaj dat zakończenia naboru. Jeśli w danych jest 'Brak potwierdzonego terminu' — nie podawaj 31.12 ani liczby dni.\n\n"
87
+ "Dla każdej podanej dotacji i firmy oceń procentowe dopasowanie (relevance_score od 0 do 1), "
88
+ "napisz jedno zwięzłe zdanie (poetic_match) po polsku opisujące to dopasowanie oraz podaj wyjaśnienie (explanation), "
89
+ "które logicznie uzasadnia tę ocenę: krótki powód (reason), kluczowe wspierające kryteria (criteria) "
90
+ "oraz ewentualne ryzyka/twarde warunki (risks). Odpowiadaj ZAWSZE I WYŁĄCZNIE w języku polskim."
91
+ )
92
+
93
+ updated_grants = []
94
+ grants_to_eval = []
95
+ company_pkd_prefixes = [p[:2] for p in profile.pkd_codes] if profile.pkd_codes else []
96
+
97
+ from core.date_utils import is_grant_active
98
+
99
+ for grant in grants:
100
+ grant_text_lower = f"{grant.title} {grant.description}".lower()
101
+
102
+ # === TWARDY FILTR DETERMINISTYCZNY (przed LLM) ===
103
+ # Odrzucaj nabory po terminie lub jawnie zamknięte na podstawie pól strukturalnych.
104
+ deadline_val = getattr(grant, "deadline", "") or ""
105
+ status_val = (getattr(grant, "status", "") or "").lower()
106
+ if (deadline_val and not is_grant_active(deadline_val)) or status_val == "closed" or getattr(grant, "is_outdated_warning", False):
107
+ grant.relevance_score = 0.0
108
+ grant.poetic_match = "Odrzucono: nabór po terminie lub zamknięty."
109
+ grant.explanation = {
110
+ "reason": "Przeterminowany termin naboru lub status zamknięty",
111
+ "criteria": [],
112
+ "risks": f"Termin: {deadline_val or 'brak'} | Status: {status_val or 'nieznany'}",
113
+ }
114
+ updated_grants.append(grant)
115
+ continue
116
+
117
+ if profile.size and profile.size.lower() == "duża":
118
+ if "tylko dla mśp" in grant_text_lower or "wyłącznie dla sektora mśp" in grant_text_lower:
119
+ grant.relevance_score = 0.0
120
+ grant.poetic_match = "Odrzucono: Program wyklucza duże firmy (tylko MŚP)."
121
+ grant.explanation = {
122
+ "reason": "Niezgodność wielkości firmy (Duża vs MŚP)",
123
+ "criteria": [],
124
+ "risks": "Program przeznaczony wyłącznie dla MŚP",
125
+ }
126
+ updated_grants.append(grant)
127
+ continue
128
+
129
+ if company_pkd_prefixes:
130
+ is_agri_grant = any(kw in grant_text_lower for kw in ["rolnictwo", "produkcja rolna", "hodowla", "arimr"])
131
+ is_agri_company = any(p in ["01", "02"] for p in company_pkd_prefixes)
132
+
133
+ if is_agri_grant and not is_agri_company:
134
+ grant.relevance_score = 0.0
135
+ grant.poetic_match = "Odrzucono: Brak działalności rolniczej (PKD)."
136
+ grant.explanation = {
137
+ "reason": "Niezgodność branży (Program rolniczy)",
138
+ "criteria": [],
139
+ "risks": "Wymagane PKD z sekcji rolniczej",
140
+ }
141
+ updated_grants.append(grant)
142
+ continue
143
+
144
+ if grant.poetic_match and grant.relevance_score > 0 and grant.explanation:
145
+ updated_grants.append(grant)
146
+ else:
147
+ grants_to_eval.append(grant)
148
+
149
+ if not grants_to_eval:
150
+ return {"eligible_grants": updated_grants, "current_agent": "supervisor"}
151
+
152
+ system_msgs = [SystemMessage(content=system_prompt) for _ in grants_to_eval]
153
+ human_msgs = [
154
+ HumanMessage(
155
+ content=(
156
+ f"Profil Firmy: {company_desc}\n"
157
+ f"Nabór: {grant.title}\n"
158
+ f"Instytucja: {getattr(grant, 'institution', '') or 'N/A'}\n"
159
+ f"Opis Naboru: {grant.description}\n\n"
160
+ "Zwróć wynik dopasowania uwzględniając strukturę MatchOutput."
161
+ )
162
+ )
163
+ for grant in grants_to_eval
164
+ ]
165
+
166
+ messages_batch = [[s, h] for s, h in zip(system_msgs, human_msgs)]
167
+
168
+ try:
169
+ llm = get_llm(task_type="standard", structured_output_schema=MatchOutput)
170
+ results = llm.batch(messages_batch, config={"max_concurrency": 3})
171
+
172
+ for grant, parsed in zip(grants_to_eval, results):
173
+ if isinstance(parsed, dict):
174
+ grant.relevance_score = parsed.get("relevance_score", 0.0)
175
+ grant.poetic_match = parsed.get("poetic_match", "")
176
+ grant.explanation = parsed.get("explanation")
177
+ else:
178
+ grant.relevance_score = getattr(parsed, "relevance_score", 0.0)
179
+ grant.poetic_match = getattr(parsed, "poetic_match", "")
180
+ if getattr(parsed, "explanation", None):
181
+ grant.explanation = parsed.explanation.dict() if hasattr(parsed.explanation, "dict") else parsed.explanation
182
+ updated_grants.append(grant)
183
+
184
+ except Exception as e:
185
+ logger.error(f"Błąd LLM w operacji matcher_node (wieloprocesowej): {e}. Próba wykonania sekwencyjnego...")
186
+
187
+ @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
188
+ def _invoke_with_retry(msg):
189
+ res = llm.invoke(msg)
190
+ score = res.get("relevance_score", getattr(res, "relevance_score", -1)) if isinstance(res, dict) else getattr(res, "relevance_score", -1)
191
+ if not (0 <= score <= 1):
192
+ raise ValueError("Relevance score out of bounds.")
193
+ return res
194
+
195
+ for i, grant in enumerate(grants_to_eval):
196
+ try:
197
+ parsed = _invoke_with_retry(messages_batch[i])
198
+ if isinstance(parsed, dict):
199
+ grant.relevance_score = parsed.get("relevance_score", 0.0)
200
+ grant.poetic_match = parsed.get("poetic_match", "")
201
+ grant.explanation = parsed.get("explanation")
202
+ else:
203
+ grant.relevance_score = getattr(parsed, "relevance_score", 0.0)
204
+ grant.poetic_match = getattr(parsed, "poetic_match", "")
205
+ if getattr(parsed, "explanation", None):
206
+ grant.explanation = parsed.explanation.dict() if hasattr(parsed.explanation, "dict") else parsed.explanation
207
+ updated_grants.append(grant)
208
+ except Exception as seq_err:
209
+ logger.error(f"Błąd sekwencyjny dla grantu {grant.title}: {seq_err}")
210
+ grant.relevance_score = 0.0
211
+ grant.poetic_match = "Błąd dopasowania."
212
+ grant.explanation = {
213
+ "reason": "Błąd LLM (fallback sekwencyjny)",
214
+ "criteria": [],
215
+ "risks": "Nie udało się zweryfikować",
216
+ }
217
+ updated_grants.append(grant)
218
+
219
+ try:
220
+ if citation_verifier or kruczkowski_trap_agent:
221
+ for i, g in enumerate(updated_grants[:5]):
222
+ try:
223
+ gtxt = f"{getattr(g, 'title', '') or (g.get('name','') if isinstance(g,dict) else '')} {getattr(g,'description','') or ''}"
224
+ prog = getattr(g, 'program', None) or (g.get('program') if isinstance(g, dict) else None) or "UNKNOWN"
225
+ if citation_verifier:
226
+ cit = citation_verifier.verify_text_citations(gtxt[:1200], prog)
227
+ if isinstance(g, dict):
228
+ g["v5_citation_score"] = cit.get("overall_citation_score")
229
+ g["v5_hard_refs"] = cit.get("hard_regulation_refs_extracted", [])[:3]
230
+ else:
231
+ setattr(g, "v5_citation_score", cit.get("overall_citation_score"))
232
+ if kruczkowski_trap_agent:
233
+ tr = kruczkowski_trap_agent.detect_traps(gtxt[:1200], prog)
234
+ risk = tr.get("overall_trap_risk", "low")
235
+ if isinstance(g, dict):
236
+ g["v5_trap_risk"] = risk
237
+ else:
238
+ setattr(g, "v5_trap_risk", risk)
239
+ if isinstance(g, dict) and "is_current_only" not in g:
240
+ g["is_current_only"] = (g.get("status") in ("active","planned") and not g.get("is_outdated_warning"))
241
+ except Exception:
242
+ pass
243
+ except Exception:
244
+ pass
245
+
246
+ return {"eligible_grants": updated_grants, "current_agent": "supervisor"}
backend/agents/panel_nodes.py ADDED
@@ -0,0 +1,562 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, Any
2
+ from langchain_core.messages import HumanMessage, ToolMessage, SystemMessage, AIMessage
3
+ from core.llm_router import get_llm
4
+ from core.search.regulation_engine import regulation_engine, kruczkowski_trap_agent
5
+ from core.trust.trust_scorer import compute_grant_trust_score
6
+
7
+ from agents.auditor import (
8
+ GlobalAuditOutput,
9
+ _PerspectiveResult,
10
+ _ROLE_PROMPTS,
11
+ _SHARED_INSTRUCTIONS,
12
+ )
13
+ from agents.tools.legal_retriever_tool import search_legal_documents
14
+ from agents.tools.krs_graph_tool import analyze_company_network
15
+ from agents.tools.neo4j_cypher_tool import query_neo4j_graph
16
+ from agents.tools.budget_rules_tool import search_budget_rules
17
+ from agents.tools.technology_retriever_tool import search_technology_trends
18
+ from agents.panel_state import AuditorPanelState
19
+
20
+ import logging
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # --- PRAWNIK NODE (Dynamic Query Routing) ---
25
+
26
+
27
+ def prawnik_node(state: AuditorPanelState) -> Dict[str, Any]:
28
+ """Agent Prawny z obsługą poszukiwań w RAG oraz RAG Grafowym (KRS)."""
29
+ llm_with_tools = get_llm(
30
+ task_type="legal_audit",
31
+ tools=[search_legal_documents, analyze_company_network, query_neo4j_graph],
32
+ )
33
+
34
+ # Inicjalizacja wiadomości, jeśli pierwsze wywołanie
35
+ messages = state.get("messages", [])
36
+ initial_messages_added = []
37
+ if not messages:
38
+ ext_prompt = (
39
+ "Zewnętrzny Rewizor: Weryfikujesz cudzy, gotowy wniosek (z biura konsultingowego) przesłany do nas w celu tzw. Reverse-Audit. Nastaw się na bezlitosną weryfikację błędów."
40
+ if state.get("is_external_audit", False)
41
+ else ""
42
+ )
43
+ sys_prompt = f"{_ROLE_PROMPTS['prawnik']}\n{ext_prompt}\n{_SHARED_INSTRUCTIONS}\n\nProgram: {state['program_name']}\nZanim ocenisz, zawsze skorzystaj z narzędzia search_legal_documents, aby sprawdzić wymogi dla perspektywy {state['program_name']}. Jeśli nie znajdziesz nic lub perspektywa nie będzie się zgadzać, PONÓW WYSZUKIWANIE z innym zapytaniem. Jak jesteś gotowy wydać ocenę wywołaj narzędzie submit_evaluation, NIE generuj go jako plain text."
44
+ initial_messages_added.append(SystemMessage(content=sys_prompt))
45
+ initial_messages_added.append(
46
+ HumanMessage(content=f"TREŚĆ WNIOSKU:\n{state['content'][:150000]}")
47
+ )
48
+ messages = initial_messages_added
49
+
50
+ # Wywołanie modelu
51
+ try:
52
+ response = llm_with_tools.invoke(messages)
53
+ except Exception as e:
54
+ logger.error(f"[PRAWNIK] Błąd wywołania modelu: {e}")
55
+ response = AIMessage(
56
+ content=f"Wystąpił błąd podczas wywołania LLM: {e}. Przechodzę do podsumowania."
57
+ )
58
+
59
+ return {
60
+ "messages": initial_messages_added + [response],
61
+ "legal_attempts": state.get("legal_attempts", 0),
62
+ }
63
+
64
+
65
+ def prawnik_tools_node(state: AuditorPanelState) -> Dict[str, Any]:
66
+ """Uruchamia narzędzie wyszukiwania dla Prawnika."""
67
+ last_message = state["messages"][-1]
68
+ tool_messages = []
69
+
70
+ for tool_call in last_message.tool_calls:
71
+ if tool_call["name"] in [
72
+ "search_legal_documents",
73
+ "analyze_company_network",
74
+ "query_neo4j_graph",
75
+ ]:
76
+ logger.info(
77
+ f"[PRAWNIK] Wykorzystanie narzędzia {tool_call['name']}: {tool_call['args']}"
78
+ )
79
+ # Bezpieczne wykonanie narzędzia
80
+ try:
81
+ if tool_call["name"] == "search_legal_documents":
82
+ result = search_legal_documents.invoke(tool_call["args"])
83
+ elif tool_call["name"] == "analyze_company_network":
84
+ result = analyze_company_network.invoke(tool_call["args"])
85
+ else:
86
+ result = query_neo4j_graph.invoke(tool_call["args"])
87
+ except Exception as e:
88
+ result = f"Błąd wykonania narzędzia: {e}"
89
+ tool_messages.append(
90
+ ToolMessage(content=result, tool_call_id=tool_call["id"])
91
+ )
92
+
93
+ return {
94
+ "messages": tool_messages,
95
+ "legal_attempts": state.get("legal_attempts", 0) + 1,
96
+ "legal_queries": [str(tc["args"]) for tc in last_message.tool_calls],
97
+ }
98
+
99
+
100
+ def prawnik_evaluator_node(state: AuditorPanelState) -> Dict[str, Any]:
101
+ """Generuje ostateczny Pydantic output Prawnika po zebraniu wiedzy z RAG."""
102
+ # Ekstrakcja do schematu
103
+ llm = get_llm(task_type="legal_audit", structured_output_schema=_PerspectiveResult)
104
+
105
+ # Przebieg całej konwersacji prawnika
106
+ conversation_text = "\n".join(
107
+ [m.content for m in state["messages"] if isinstance(m.content, str)]
108
+ )
109
+
110
+ # Aktywne użycie silnika w Prawniku (Faza 3) - nie tylko kontekst, ale weryfikacja
111
+ engine_rules = ""
112
+ engine_compliance_check = ""
113
+ trust_context = ""
114
+ try:
115
+ rules = regulation_engine.get_structured_rules_for_program(state['program_name'] or "")
116
+ if rules:
117
+ engine_rules = "\n\n--- STRUCTURED RULES FROM REGULATION ENGINE (traktuj jako źródło prawdy) ---\n"
118
+ engine_rules += "\n".join(rules.get("key_rules", [])[:7])
119
+
120
+ # Aktywna weryfikacja + wpływ na wynik (Faza 3)
121
+ compliance = regulation_engine.check_cost_eligibility(state['program_name'] or "", state['content'][:3000])
122
+ if compliance.get("status") == "evaluated":
123
+ engine_compliance_check = f"\n\n--- REGULATION ENGINE COMPLIANCE CHECK ---\n{compliance}"
124
+ if compliance.get("severity") == "critical":
125
+ engine_compliance_check += "\n[CRITICAL ISSUE - RECOMMEND BLOCKING EXPORT OR HUMAN REVIEW]"
126
+ # Dodajemy issue bezpośrednio do wyniku (zostanie scalone w ewaluatorze)
127
+
128
+ # v5.0: Kruczkowski Compliance & Trap + Citation Verifier integration (Faza 2/3)
129
+ try:
130
+ if kruczkowski_trap_agent:
131
+ trap_result = kruczkowski_trap_agent.detect_traps(
132
+ document_text=state['content'][:4500],
133
+ program=state['program_name'] or "",
134
+ msp_context=state.get("msp_analysis") or state.get("external_context", {}).get("msp_analysis")
135
+ )
136
+ if trap_result.get("overall_trap_risk") in ("high", "critical"):
137
+ engine_compliance_check += f"\n\n--- KRUCZKOWSKI COMPLIANCE & TRAP v5.0 ---\nRisk: {trap_result['overall_trap_risk']} | Traps: {trap_result['num_traps']} | Blocks export: {trap_result.get('blocks_export_recommendation')}\nCitation score: {trap_result.get('citation_verification', {}).get('overall_citation_score')}\n[STRICT MODE: Zalecane dodatkowe sprawdzenie przez człowieka]"
138
+ # Zawsze wstrzykujemy citation grounding info
139
+ cit = trap_result.get("citation_verification", {})
140
+ if cit.get("overall_citation_score"):
141
+ engine_compliance_check += f"\n[CITATION GROUNDING: {cit.get('overall_citation_score')} ({cit.get('citation_quality')}) — {cit.get('recommendation', '')[:120]}]"
142
+ except Exception as _trap_e:
143
+ logger.debug(f"[Prawnik v5.0] Trap agent skipped: {_trap_e}")
144
+
145
+ # Trust Score injection (Cycle 10)
146
+ trust_score = compute_grant_trust_score({"program": state.get('program_name')})
147
+ trust_context = f"\n\n[Trust Score dla programu: {trust_score}/100 — im niższy, tym ostrożniej podchodzić do wniosków i rekomendacji]"
148
+ except Exception:
149
+ pass
150
+
151
+ prompt = f"""
152
+ Na podstawie zebranych dotychczas informacji i analizy (patrz historia, upewnij się, że opierasz się na zweryfikowanym prawie z narzędzia):
153
+ {conversation_text}
154
+ {engine_rules}
155
+ {engine_compliance_check}
156
+ {trust_context}
157
+
158
+ Wygeneruj ostateczny wynik audytu prawnego dla wniosku ({state['program_name']}) wg struktury.
159
+ Oceń projekt. Role: prawnik.
160
+ TREŚĆ:
161
+ {state['content'][:150000]}
162
+ """
163
+
164
+ from tenacity import retry, stop_after_attempt, wait_exponential
165
+
166
+ @retry(
167
+ stop=stop_after_attempt(5),
168
+ wait=wait_exponential(multiplier=1, min=2, max=10),
169
+ reraise=True,
170
+ )
171
+ def invoke_eval():
172
+ result: _PerspectiveResult = llm.invoke(prompt)
173
+ if not result.summary or len(result.summary.strip()) < 10:
174
+ raise ValueError("Błąd sanity check: Puste podsumowanie audytu prawnego.")
175
+ for issue in result.issues:
176
+ issue.perspective = "prawnik"
177
+
178
+ # Jeśli silnik wykrył critical problem — obniżamy score
179
+ if "CRITICAL ISSUE" in engine_compliance_check:
180
+ result.partial_score = max(0, result.partial_score - 30)
181
+
182
+ return {
183
+ "issues": result.issues,
184
+ "perspectives_summary": {"prawnik": result.summary},
185
+ "perspective_scores": [result.partial_score],
186
+ "prawnik_done": True,
187
+ }
188
+
189
+ try:
190
+ return invoke_eval()
191
+ except Exception as e:
192
+ logger.error(f"[PRAWNIK] Ostateczny błąd ewaluatora: {e}")
193
+ return {
194
+ "prawnik_done": True,
195
+ "perspectives_summary": {
196
+ "prawnik": f"Błąd audytu prawnego po 5 próbach: {e}"
197
+ },
198
+ }
199
+
200
+
201
+ def prawnik_routing(state: AuditorPanelState) -> str:
202
+ """Decyduje czy prawnik musi szukać dalej, oceniać czy przekroczył limit."""
203
+ last_message = state["messages"][-1]
204
+
205
+ if last_message.tool_calls:
206
+ if state["legal_attempts"] >= 3:
207
+ logger.warning(
208
+ "[PRAWNIK] Przekroczono limit wyszukiwań, wymuszam ewaluację."
209
+ )
210
+ return "evaluate"
211
+ return "tools"
212
+
213
+ return "evaluate"
214
+
215
+
216
+ # --- FINANSISTA NODE (Dynamic Query Routing) ---
217
+
218
+
219
+ def finansista_node(state: AuditorPanelState) -> Dict[str, Any]:
220
+ """Agent Finansowy z obsługą poszukiwań w RAG (regulaminy finansowe)."""
221
+ llm_with_tools = get_llm(
222
+ task_type="legal_audit",
223
+ tools=[search_budget_rules, analyze_company_network, query_neo4j_graph],
224
+ )
225
+
226
+ # Inicjalizacja wiadomości, jeśli pierwsze wywołanie
227
+ messages = state.get("finansista_messages", [])
228
+ initial_messages_added = []
229
+ if not messages:
230
+ ext_prompt = (
231
+ "Zewnętrzny Rewizor: Weryfikujesz cudzy, gotowy wniosek (z biura konsultingowego) przesłany do nas w celu tzw. Reverse-Audit."
232
+ if state.get("is_external_audit", False)
233
+ else ""
234
+ )
235
+ sys_prompt = f"{_ROLE_PROMPTS['finansista']}\n{ext_prompt}\n{_SHARED_INSTRUCTIONS}\n\nProgram: {state['program_name']}\nZanim ocenisz wniosek, używaj narzędzia search_budget_rules aby sprawdzić zasady z budżetu programu. Aby zweryfikować MŚP z perspektywy finansowej na podstawie NIP/KRS uzyj analyze_company_network. Gdy będziesz gotowy zwrócić ocenę bez korzystania z narzędzia, powróć i wykonaj finalną ocenę strukturyzowaną."
236
+ initial_messages_added.append(SystemMessage(content=sys_prompt))
237
+ initial_messages_added.append(
238
+ HumanMessage(content=f"TREŚĆ WNIOSKU:\n{state['content'][:150000]}")
239
+ )
240
+ messages = initial_messages_added
241
+
242
+ try:
243
+ response = llm_with_tools.invoke(messages)
244
+ except Exception as e:
245
+ logger.error(f"[FINANSISTA] Błąd wywołania modelu: {e}")
246
+ response = AIMessage(
247
+ content=f"Wystąpił błąd podczas wywołania LLM: {e}. Przechodzę do podsumowania."
248
+ )
249
+
250
+ return {
251
+ "finansista_messages": initial_messages_added + [response],
252
+ "finansista_attempts": state.get("finansista_attempts", 0),
253
+ }
254
+
255
+
256
+ def finansista_tools_node(state: AuditorPanelState) -> Dict[str, Any]:
257
+ """Uruchamia narzędzie wyszukiwania dla Finansisty."""
258
+ last_message = state["finansista_messages"][-1]
259
+ tool_messages = []
260
+
261
+ for tool_call in last_message.tool_calls:
262
+ if tool_call["name"] in [
263
+ "search_budget_rules",
264
+ "analyze_company_network",
265
+ "query_neo4j_graph",
266
+ ]:
267
+ logger.info(
268
+ f"[FINANSISTA] Wykorzystanie narzędzia {tool_call['name']}: {tool_call['args']}"
269
+ )
270
+ try:
271
+ if tool_call["name"] == "search_budget_rules":
272
+ result = search_budget_rules.invoke(tool_call["args"])
273
+ elif tool_call["name"] == "analyze_company_network":
274
+ result = analyze_company_network.invoke(tool_call["args"])
275
+ else:
276
+ result = query_neo4j_graph.invoke(tool_call["args"])
277
+ except Exception as e:
278
+ result = f"Błąd wykonania narzędzia: {e}"
279
+ tool_messages.append(
280
+ ToolMessage(content=result, tool_call_id=tool_call["id"])
281
+ )
282
+
283
+ return {
284
+ "finansista_messages": tool_messages,
285
+ "finansista_attempts": state.get("finansista_attempts", 0) + 1,
286
+ "finansista_queries": [str(tc["args"]) for tc in last_message.tool_calls],
287
+ }
288
+
289
+
290
+ def finansista_evaluator_node(state: AuditorPanelState) -> Dict[str, Any]:
291
+ """Generuje ostateczny Pydantic output Finansisty po zebraniu wiedzy z RAG."""
292
+ llm = get_llm(task_type="legal_audit", structured_output_schema=_PerspectiveResult)
293
+ conversation_text = "\n".join(
294
+ [
295
+ m.content
296
+ for m in state.get("finansista_messages", [])
297
+ if isinstance(m.content, str)
298
+ ]
299
+ )
300
+
301
+ # Aktywne użycie RegulationEngine przy analizie budżetowej (Faza 3)
302
+ engine_context = ""
303
+ trust_context = ""
304
+ try:
305
+ eligibility = regulation_engine.check_cost_eligibility(state.get('program_name') or "", state.get('content', '')[:2000])
306
+ if eligibility.get("status") == "evaluated":
307
+ engine_context = f"\n\nREGULATION ENGINE BUDGET CHECK (źródło prawdy):\n eligible={eligibility.get('eligible')} severity={eligibility.get('severity')}\n justification: {eligibility.get('justification','')}\n reference: {eligibility.get('regulation_reference','')}\n rec: {eligibility.get('recommendation','')}\n"
308
+
309
+ # v5.0 Citation + Kruczkowski for finansista (budget traps)
310
+ try:
311
+ if kruczkowski_trap_agent:
312
+ t = kruczkowski_trap_agent.detect_traps(state.get('content', '')[:3000], state.get('program_name') or "")
313
+ if t.get("citation_verification"):
314
+ c = t["citation_verification"]
315
+ engine_context += f"\n[CITATION GROUNDING (fin): {c.get('overall_citation_score')} / {c.get('citation_quality')}]"
316
+ if t.get("overall_trap_risk") in ("high", "critical"):
317
+ engine_context += f"\n[KRUCZKOWSKI BUDGET TRAPS: {t.get('num_traps')} — {t.get('overall_trap_risk')}]"
318
+ except Exception:
319
+ pass
320
+
321
+ # Trust Score injection (Cycle 10) — teraz z citation boost z v5.0
322
+ trust_score = compute_grant_trust_score({
323
+ "program": state.get('program_name'),
324
+ "citation_verification_score": (t.get("citation_verification", {}).get("overall_citation_score") if 't' in locals() else None)
325
+ })
326
+ trust_context = f"\n\n[Trust Score dla programu: {trust_score}/100 — niski score = wyższe ryzyko w rekomendacjach finansowych]"
327
+ except Exception:
328
+ pass
329
+
330
+ prompt = f"""
331
+ Na podstawie zebranych dotychczas informacji i analizy finansowej/budżetowej (patrz historia):
332
+ {conversation_text}
333
+ {engine_context}
334
+ {trust_context}
335
+
336
+ Wygeneruj ostateczny wynik audytu finansowego dla wniosku ({state['program_name']}) wg struktury.
337
+ Oceń projekt. Role: finansista.
338
+ TREŚĆ:
339
+ {state['content'][:150000]}
340
+ """
341
+ from tenacity import retry, stop_after_attempt, wait_exponential
342
+
343
+ @retry(
344
+ stop=stop_after_attempt(5),
345
+ wait=wait_exponential(multiplier=1, min=2, max=10),
346
+ reraise=True,
347
+ )
348
+ def invoke_eval():
349
+ result: _PerspectiveResult = llm.invoke(prompt)
350
+ if not result.summary or len(result.summary.strip()) < 10:
351
+ raise ValueError(
352
+ "Błąd sanity check: Puste podsumowanie audytu finansowego."
353
+ )
354
+ for issue in result.issues:
355
+ issue.perspective = "finansista"
356
+ return {
357
+ "issues": result.issues,
358
+ "perspectives_summary": {"finansista": result.summary},
359
+ "perspective_scores": [result.partial_score],
360
+ "finansista_done": True,
361
+ }
362
+
363
+ try:
364
+ return invoke_eval()
365
+ except Exception as e:
366
+ logger.error(f"[FINANSISTA] Ostateczny błąd ewaluatora: {e}")
367
+ return {
368
+ "finansista_done": True,
369
+ "perspectives_summary": {
370
+ "finansista": f"Błąd audytu finansowego po 5 próbach: {e}"
371
+ },
372
+ }
373
+
374
+
375
+ def finansista_routing(state: AuditorPanelState) -> str:
376
+ """Decyduje czy finansista musi szukać dalej, czy oceniać."""
377
+ last_message = state["finansista_messages"][-1]
378
+ if last_message.tool_calls:
379
+ if state.get("finansista_attempts", 0) >= 3:
380
+ logger.warning(
381
+ "[FINANSISTA] Przekroczono limit wyszukiwań, wymuszam ewaluację."
382
+ )
383
+ return "evaluate"
384
+ return "tools"
385
+ return "evaluate"
386
+
387
+
388
+ # --- INNOWATOR NODE (Dynamic Query Routing) ---
389
+ def innowator_node(state: AuditorPanelState) -> Dict[str, Any]:
390
+ """Agent Technologiczny (Innowator) z obsługą poszukiwań w RAG (trendy, KIS, B+R)."""
391
+ llm_with_tools = get_llm(task_type="legal_audit", tools=[search_technology_trends])
392
+
393
+ messages = state.get("innowator_messages", [])
394
+ initial_messages_added = []
395
+ if not messages:
396
+ ext_prompt = (
397
+ "Zewnętrzny Rewizor: Weryfikujesz cudzy, gotowy wniosek (z biura konsultingowego) przesłany do nas w celu tzw. Reverse-Audit."
398
+ if state.get("is_external_audit", False)
399
+ else ""
400
+ )
401
+ sys_prompt = f"{_ROLE_PROMPTS['innowator']}\n{ext_prompt}\n{_SHARED_INSTRUCTIONS}\n\nProgram: {state['program_name']}\nZanim dokonasz oceny innowacyjności, użyj narzędzia search_technology_trends, aby zweryfikować czy technologia, poziom TRL lub KIS są poprawne dla tego programu. Kiedy będziesz gotowy zwrócić ocenę, powróć i wykonaj finalną ocenę strukturyzowaną."
402
+ initial_messages_added.append(SystemMessage(content=sys_prompt))
403
+ initial_messages_added.append(
404
+ HumanMessage(content=f"TREŚĆ WNIOSKU:\n{state['content'][:150000]}")
405
+ )
406
+ messages = initial_messages_added
407
+
408
+ try:
409
+ response = llm_with_tools.invoke(messages)
410
+ except Exception as e:
411
+ logger.error(f"[INNOWATOR] Błąd wywołania modelu: {e}")
412
+ response = AIMessage(
413
+ content=f"Wystąpił błąd podczas wywołania LLM: {e}. Przechodzę do podsumowania."
414
+ )
415
+
416
+ return {
417
+ "innowator_messages": initial_messages_added + [response],
418
+ "innowator_attempts": state.get("innowator_attempts", 0),
419
+ }
420
+
421
+
422
+ def innowator_tools_node(state: AuditorPanelState) -> Dict[str, Any]:
423
+ """Uruchamia narzędzie wyszukiwania dla Innowatora."""
424
+ last_message = state["innowator_messages"][-1]
425
+ tool_messages = []
426
+
427
+ for tool_call in last_message.tool_calls:
428
+ if tool_call["name"] == "search_technology_trends":
429
+ logger.info(
430
+ f"[INNOWATOR] Wykorzystanie narzędzia {tool_call['name']}: {tool_call['args']}"
431
+ )
432
+ try:
433
+ result = search_technology_trends.invoke(tool_call["args"])
434
+ except Exception as e:
435
+ result = f"Błąd wykonania narzędzia: {e}"
436
+ tool_messages.append(
437
+ ToolMessage(content=result, tool_call_id=tool_call["id"])
438
+ )
439
+
440
+ return {
441
+ "innowator_messages": tool_messages,
442
+ "innowator_attempts": state.get("innowator_attempts", 0) + 1,
443
+ "innowator_queries": [str(tc["args"]) for tc in last_message.tool_calls],
444
+ }
445
+
446
+
447
+ def innowator_evaluator_node(state: AuditorPanelState) -> Dict[str, Any]:
448
+ """Generuje ostateczny Pydantic output Innowatora po zebraniu wiedzy z RAG."""
449
+ llm = get_llm(task_type="legal_audit", structured_output_schema=_PerspectiveResult)
450
+ conversation_text = "\n".join(
451
+ [
452
+ m.content
453
+ for m in state.get("innowator_messages", [])
454
+ if isinstance(m.content, str)
455
+ ]
456
+ )
457
+
458
+ # Aktywne użycie RegulationEngine również w perspektywie innowacyjnej (Faza 3)
459
+ engine_context = ""
460
+ trust_context = ""
461
+ try:
462
+ rules = regulation_engine.get_structured_rules_for_program(state.get('program_name') or "")
463
+ if rules and (rules.get("key_rules") or rules.get("scoring_criteria")):
464
+ engine_context = "\n\n--- STRUCTURED PROGRAM RULES (Regulation Engine) — upewnij się, że innowacyjność jest zgodna z celami i kryteriami programu:\n"
465
+ engine_context += "KEY RULES: " + "; ".join((rules.get("key_rules") or [])[:4])
466
+ engine_context += "\nSCORING: " + "; ".join((rules.get("scoring_criteria") or [])[:3])
467
+
468
+ # Trust Score injection (Cycle 10)
469
+ trust_score = compute_grant_trust_score({"program": state.get('program_name')})
470
+ trust_context = f"\n\n[Trust Score dla programu: {trust_score}/100 — niski score sugeruje większą ostrożność przy ocenie innowacyjności]"
471
+ except Exception:
472
+ pass
473
+
474
+ prompt = f"""
475
+ Na podstawie zebranych dotychczas informacji i analizy innowacyjnej/technologicznej (patrz historia):
476
+ {conversation_text}
477
+ {engine_context}
478
+ {trust_context}
479
+
480
+ Wygeneruj ostateczny wynik audytu innowacyjnego dla wniosku ({state['program_name']}) wg struktury.
481
+ Oceń projekt. Role: innowator.
482
+ TREŚĆ:
483
+ {state['content'][:150000]}
484
+ """
485
+ from tenacity import retry, stop_after_attempt, wait_exponential
486
+
487
+ @retry(
488
+ stop=stop_after_attempt(5),
489
+ wait=wait_exponential(multiplier=1, min=2, max=10),
490
+ reraise=True,
491
+ )
492
+ def invoke_eval():
493
+ result: _PerspectiveResult = llm.invoke(prompt)
494
+ if not result.summary or len(result.summary.strip()) < 10:
495
+ raise ValueError(
496
+ "Błąd sanity check: Puste podsumowanie audytu innowacyjnego."
497
+ )
498
+ for issue in result.issues:
499
+ issue.perspective = "innowator"
500
+ return {
501
+ "issues": result.issues,
502
+ "perspectives_summary": {"innowator": result.summary},
503
+ "perspective_scores": [result.partial_score],
504
+ "innowator_done": True,
505
+ }
506
+
507
+ try:
508
+ return invoke_eval()
509
+ except Exception as e:
510
+ logger.error(f"[INNOWATOR] Ostateczny błąd ewaluatora: {e}")
511
+ return {
512
+ "innowator_done": True,
513
+ "perspectives_summary": {
514
+ "innowator": f"Błąd audytu innowacyjnego po 5 próbach: {e}"
515
+ },
516
+ }
517
+
518
+
519
+ def innowator_routing(state: AuditorPanelState) -> str:
520
+ """Decyduje czy Innowator musi szukać dalej, czy oceniać."""
521
+ last_message = state["innowator_messages"][-1]
522
+ if last_message.tool_calls:
523
+ if state.get("innowator_attempts", 0) >= 3:
524
+ logger.warning(
525
+ "[INNOWATOR] Przekroczono limit wyszukiwań, wymuszam ewaluację."
526
+ )
527
+ return "evaluate"
528
+ return "tools"
529
+ return "evaluate"
530
+
531
+
532
+ # --- ZARZĄDZAJĄCY NODE ---
533
+ def zarzadzajacy_node(state: AuditorPanelState) -> Dict[str, Any]:
534
+ """Reduktor zbierający wszystkie dane i tworzący GlobalAuditOutput."""
535
+ scores = state.get("perspective_scores", [])
536
+ issues = state.get("issues", [])
537
+
538
+ has_critical = any(i.severity == "critical" for i in issues)
539
+
540
+ if not scores:
541
+ overall_score = 0
542
+ else:
543
+ base = int(sum(scores) / len(scores))
544
+ overall_score = max(0, base - 20) if has_critical else base
545
+
546
+ export_status = "ok"
547
+ if has_critical:
548
+ export_status = "blocked"
549
+ elif any(i.severity == "high" for i in issues):
550
+ export_status = "warning"
551
+
552
+ final = GlobalAuditOutput(
553
+ is_approved=not has_critical,
554
+ export_status=export_status,
555
+ overall_score=overall_score,
556
+ confidence_score=0.9, # LangGraph gives high confidence theoretically
557
+ human_review_required=has_critical or overall_score < 60,
558
+ issues=issues,
559
+ perspectives_summary=state.get("perspectives_summary", {}),
560
+ )
561
+
562
+ return {"final_output": final}
backend/agents/panel_state.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import TypedDict, List
2
+ import operator
3
+ from typing_extensions import Annotated
4
+ from langchain_core.messages import AnyMessage
5
+ from agents.auditor import AuditIssue, GlobalAuditOutput
6
+
7
+
8
+ def merge_dicts(a: dict, b: dict) -> dict:
9
+ return {**(a or {}), **(b or {})}
10
+
11
+
12
+ class AuditorPanelState(TypedDict):
13
+ project_id: str
14
+ program_name: str
15
+ content: str
16
+ is_external_audit: bool
17
+ # Agregacja błędów z poszczególnych ról
18
+ issues: Annotated[List[AuditIssue], operator.add]
19
+ perspectives_summary: Annotated[dict, merge_dicts]
20
+ # Przechowuje scores do finalnego uśrednienia
21
+ perspective_scores: Annotated[List[int], operator.add]
22
+
23
+ # Zarządzanie Dynamic Query Routing dla Prawnika
24
+ legal_attempts: int
25
+ legal_queries: Annotated[List[str], operator.add]
26
+ messages: Annotated[
27
+ List[AnyMessage], operator.add
28
+ ] # służy do wymiany zapytań z narzędziami prawnika
29
+ prawnik_done: bool
30
+
31
+ # Zarządzanie Dynamic Query Routing dla Finansisty
32
+ finansista_attempts: int
33
+ finansista_queries: Annotated[List[str], operator.add]
34
+ finansista_messages: Annotated[List[AnyMessage], operator.add]
35
+ finansista_done: bool
36
+
37
+ # Zarządzanie Dynamic Query Routing dla Innowatora
38
+ innowator_attempts: int
39
+ innowator_queries: Annotated[List[str], operator.add]
40
+ innowator_messages: Annotated[List[AnyMessage], operator.add]
41
+ innowator_done: bool
42
+
43
+ # Wynik końcowy (wyliczony przez Zarządzającego)
44
+ final_output: GlobalAuditOutput
backend/agents/planner.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, Any
2
+ from core.llm_router import get_llm
3
+ from schemas import AgentState, PlanOutput
4
+
5
+
6
+ def planner_node(state: AgentState) -> Dict[str, Any]:
7
+ """
8
+ Tworzy dynamiczny plan sesji i osadza go w Blackboard.
9
+ Uruchamiany jako pierwszy lub wywoływany w przypadku drastycznej zmiany intencji.
10
+ """
11
+ llm = get_llm(task_type="standard", structured_output_schema=PlanOutput)
12
+
13
+ prompt = f"""
14
+ Jesteś Planner Agentem. Skonstruuj plan dzialania dla klienta w systemie decyzyjnym dotyczącym dotacji.
15
+ Cel: Na podstawie konwersacji, stwórz listę max 5 kroków, co należy zrobić dalej.
16
+
17
+ Ostatnia wiadomość od klienta: {state.messages[-1].content if state.messages else 'Nowa sesja'}
18
+ Obecny profil firmy: {state.profile.model_dump() if state.profile else 'Brak'}
19
+
20
+ Zwróć wynik jako uporządkowaną listę kroków w formacie schematu ustrukturyzowanego. PISZ ZAWSZE I WYŁĄCZNIE W JĘZYKU POLSKIM.
21
+ """
22
+
23
+ try:
24
+ response = llm.invoke(prompt)
25
+ steps = response.steps
26
+ except Exception as e:
27
+ print(f"Błąd plannera: {e}")
28
+ steps = []
29
+
30
+ # Przełącznik awaryjny - jeśli nie uda się sparsować to używamy domyślnego
31
+ if not steps:
32
+ steps = [
33
+ "Zebrać pełen profil firmy (Profiler)",
34
+ "Znaleźć dopasowane dotacje (Matcher)",
35
+ ]
36
+
37
+ return {
38
+ "task_plan": steps,
39
+ "current_agent": "supervisor", # Handoff back to supervisor
40
+ }
backend/agents/profiler.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import logging
3
+ from typing import Optional
4
+
5
+ from schemas import AgentState, CompanyProfile, FinancialData
6
+ from tools.company_search import fetch_regon_data
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ def extract_nip(text: str) -> Optional[str]:
12
+ clean_text = text.replace("-", "").replace(" ", "")
13
+ match = re.search(r"\d{10}", clean_text)
14
+ return match.group(0) if match else None
15
+
16
+
17
+ def calculate_company_size(revenue: float, employment: int) -> str:
18
+ if employment < 10 and revenue <= 2_000_000:
19
+ return "Mikro"
20
+ if employment < 50 and revenue <= 10_000_000:
21
+ return "Mała"
22
+ if employment < 250 and revenue <= 50_000_000:
23
+ return "Średnia"
24
+ if employment >= 250 or revenue > 50_000_000:
25
+ return "Duża"
26
+ return "MŚP"
27
+
28
+
29
+ def _profile_from_regon(nip: str, raw: dict) -> CompanyProfile:
30
+ revenue = float(raw.get("revenue") or 0)
31
+ employment = int(raw.get("employment") or 0)
32
+ pkds = raw.get("pkd") or []
33
+ if isinstance(pkds, str):
34
+ pkds = [pkds]
35
+ return CompanyProfile(
36
+ nip=nip,
37
+ name=(raw.get("name") or "").strip() or None,
38
+ regon=(raw.get("regon") or "").strip() or None,
39
+ krs=(raw.get("krs") or "").strip() or None,
40
+ address=(raw.get("address") or "").strip() or None,
41
+ legal_form=(raw.get("legal_form") or "").strip() or None,
42
+ data_source=(raw.get("data_source") or "unknown").strip(),
43
+ pkd_codes=pkds,
44
+ region=(raw.get("voivodeship") or "Nieznane").strip(),
45
+ size=calculate_company_size(revenue, employment),
46
+ financials=FinancialData(revenue=revenue, employment=employment),
47
+ )
48
+
49
+
50
+ def profiler_node(state: AgentState):
51
+ if not state.messages:
52
+ return {"current_agent": "supervisor"}
53
+
54
+ user_msg = (
55
+ state.messages[-1]["content"]
56
+ if isinstance(state.messages[-1], dict)
57
+ else getattr(state.messages[-1], "content", "")
58
+ )
59
+ nip = extract_nip(user_msg)
60
+ if not nip:
61
+ return {
62
+ "current_agent": "supervisor",
63
+ "messages": [
64
+ {
65
+ "role": "assistant",
66
+ "content": (
67
+ "Podaj 10-cyfrowy NIP firmy, aby zbudować profil z rejestru GUS/REGON."
68
+ ),
69
+ }
70
+ ],
71
+ }
72
+
73
+ raw_data = fetch_regon_data(nip)
74
+ profile = _profile_from_regon(nip, raw_data)
75
+
76
+ blackboard = dict(state.blackboard or {})
77
+ blackboard.update(
78
+ {
79
+ "company_nip": nip,
80
+ "company_name": profile.name or "",
81
+ "company_regon": profile.regon or "",
82
+ "company_krs": profile.krs or "",
83
+ "company_address": profile.address or "",
84
+ "company_legal_form": profile.legal_form or "",
85
+ "company_pkd": profile.pkd_codes,
86
+ "company_region": profile.region,
87
+ "company_size": profile.size,
88
+ "company_data_source": profile.data_source,
89
+ "profile_enriched": True,
90
+ "using_real_data": profile.data_source
91
+ in ("gus_bir", "mf_whitelist", "regon_api"),
92
+ }
93
+ )
94
+
95
+ company_label = profile.name or f"NIP {nip}"
96
+ region_str = (
97
+ f", {profile.region}"
98
+ if profile.region and profile.region != "Nieznane"
99
+ else ""
100
+ )
101
+ pkd_str = (
102
+ ", ".join(profile.pkd_codes[:2])
103
+ if profile.pkd_codes
104
+ else "brak PKD w rejestrze"
105
+ )
106
+
107
+ logger.info(
108
+ "profiler_node: %s NIP=%s source=%s",
109
+ company_label,
110
+ nip,
111
+ profile.data_source,
112
+ )
113
+
114
+ return {
115
+ "profile": profile,
116
+ "blackboard": blackboard,
117
+ "messages": [
118
+ {
119
+ "role": "assistant",
120
+ "content": (
121
+ f"Zidentyfikowałem: **{company_label}** (NIP {nip}{region_str}). "
122
+ f"PKD: {pkd_str}. Wielkość: {profile.size}. "
123
+ "Jaka jest główna potrzeba inwestycyjna?"
124
+ ),
125
+ }
126
+ ],
127
+ "current_agent": "supervisor",
128
+ }
backend/agents/red_team_auditor.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from typing import Dict, Any, List
3
+ try:
4
+ from core.llm_router import get_llm
5
+ except ImportError:
6
+ from backend.core.llm_router import get_llm
7
+
8
+ from langchain_core.messages import SystemMessage, HumanMessage
9
+ from pydantic import BaseModel, Field
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ class AuditScore(BaseModel):
14
+ score: int = Field(..., description="Punkty od 0 do 25 za ten wniosek", ge=0, le=25)
15
+ passed: bool = Field(..., description="Czy wniosek przeszedł minimalny próg? (np. 15 pkt)")
16
+ weaknesses: List[str] = Field(..., description="Lista konkretnych słabych punktów i braków")
17
+ legal_risks: List[str] = Field(..., description="Ryzyka prawne (np. limity de minimis, niedozwolone koszty)")
18
+ improvement_feedback: str = Field(..., description="Instrukcje dla Generator Agent, co musi zostać bezwzględnie poprawione")
19
+
20
+ class RedTeamAuditor:
21
+ """
22
+ Faza 14: Finalna weryfikacja i ocena punktowa wniosku (Pre-Submission Audit).
23
+ Działa jak wirtualny ekspert z panelu (np. NCBR/PARP), odrzucając słabe wnioski.
24
+ """
25
+
26
+ def __init__(self):
27
+ self.min_passing_score = 15
28
+
29
+ def audit_application(self, generated_sections: Dict[str, str], context: str, program_name: str) -> Dict[str, Any]:
30
+ """
31
+ Ocenia cały wygenerowany wniosek na podstawie kontekstu.
32
+ """
33
+ logger.info(f"[RedTeamAuditor] Rozpoczynam ocenę wniosku dla programu: {program_name}")
34
+
35
+ full_text = "\n\n".join([f"### {title}\n{content}" for title, content in generated_sections.items()])
36
+
37
+ system_prompt = (
38
+ "Jesteś surowym i nieustępliwym Ekspertem Oceniającym wniosek dotacyjny (Red Team).\n"
39
+ f"Twoim zadaniem jest ocenić gotowy wniosek pod kątem programu: {program_name}.\n"
40
+ "Ocena punktowa wynosi od 0 do 25 punktów. Aby projekt przeszedł, musi zdobyć min. 15 punktów.\n"
41
+ "Sprawdzaj innowacyjność, spójność budżetu, rygor prawny i zgodność z regulaminem (Kontekst RAG).\n"
42
+ "Nie bój się oblać wniosku (passed=False), jeśli są w nim halucynacje liczbowe, brak wkładu własnego "
43
+ "lub ogólniki zamiast konkretnych przewag konkurencyjnych."
44
+ )
45
+
46
+ human_content = f"Kontekst RAG (Regulaminy):\n{context[:3000]}\n\nTreść Wniosku do oceny:\n{full_text[:12000]}"
47
+
48
+ try:
49
+ llm = get_llm(task_type="critical", structured_output_schema=AuditScore)
50
+ response = llm.invoke([
51
+ SystemMessage(content=system_prompt),
52
+ HumanMessage(content=human_content)
53
+ ])
54
+
55
+ # Wymuszenie odrzucenia jeśli score poniżej progu
56
+ if response.score < self.min_passing_score:
57
+ response.passed = False
58
+
59
+ result = {
60
+ "score": response.score,
61
+ "passed": response.passed,
62
+ "weaknesses": response.weaknesses,
63
+ "legal_risks": response.legal_risks,
64
+ "feedback": response.improvement_feedback
65
+ }
66
+ logger.info(f"[RedTeamAuditor] Zakończono ocenę. Wynik: {response.score}/25, Passed: {response.passed}")
67
+ return result
68
+
69
+ except Exception as e:
70
+ logger.error(f"[RedTeamAuditor] Błąd podczas oceny: {e}")
71
+ # FAIL-CLOSED: awaria audytu NIE może cicho przepuścić wniosku.
72
+ # Oznaczamy jako niezaakceptowany i wymagający weryfikacji człowieka.
73
+ return {
74
+ "score": 0,
75
+ "passed": False,
76
+ "human_review_required": True,
77
+ "weaknesses": ["Błąd ewaluacji przez LLM — wynik niezweryfikowany (fail-closed)."],
78
+ "legal_risks": [],
79
+ "feedback": "Błąd systemu oceniającego — wymagana ręczna weryfikacja przed eksportem."
80
+ }
backend/agents/research_agent.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import os
3
+ from typing import Optional, Dict
4
+
5
+ from langchain_core.messages import SystemMessage, HumanMessage
6
+ from core.llm_router import get_llm
7
+
8
+ try:
9
+ from tools.web_search import general_web_search
10
+ except ImportError:
11
+ from backend.tools.web_search import general_web_search
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def is_titan_research_enabled() -> bool:
17
+ """HF free-tier guard: disable/limit Research Agent TITAN under memory pressure."""
18
+ return os.environ.get("ENABLE_TITAN_RESEARCH", "true").lower() in ("1", "true", "yes")
19
+
20
+
21
+ def titan_research_max_calls() -> int:
22
+ try:
23
+ return max(0, int(os.environ.get("TITAN_RESEARCH_MAX_CALLS", "1")))
24
+ except (TypeError, ValueError):
25
+ return 1
26
+
27
+
28
+ class ResearchAgent:
29
+ """
30
+ Sub-Agent badawczy Projektu TITAN.
31
+ Zadaniem tego agenta jest przeszukiwanie sieci (OSINT / Web Search)
32
+ oraz interpretacja wyników w przypadku braku danych w dokumencie.
33
+ Zastępuje człowieka w trybie pełnej automatyzacji, znacznie zwiększając skuteczność.
34
+ """
35
+
36
+ def __init__(self):
37
+ # Inicjalizacja LLM dla analizy wyników badawczych.
38
+ self.llm = get_llm(task_type="creative") # Używamy creative/standard
39
+
40
+ def deep_search(self, query: str, context: str) -> str:
41
+ """
42
+ Głębokie poszukiwanie danych w zewnętrznych bazach i sieci,
43
+ oraz ich synteza przy użyciu LLM.
44
+ Soft-fail: nigdy nie zabija workera — zwraca pusty string przy błędzie/disable.
45
+ """
46
+ if not is_titan_research_enabled() or titan_research_max_calls() <= 0:
47
+ logger.info(
48
+ "[Research Agent TITAN] Wyłączony (ENABLE_TITAN_RESEARCH / MAX_CALLS) — soft-fail"
49
+ )
50
+ return ""
51
+
52
+ logger.info(f"🔎 [Research Agent TITAN] Rozpoczęto głębokie wyszukiwanie dla: {query}")
53
+
54
+ try:
55
+ # 1. Wykonanie rzeczywistego wyszukiwania w sieci (bez live scrape URL-i)
56
+ search_results = general_web_search(query)
57
+ except Exception as e:
58
+ logger.warning(f"[Research Agent TITAN] Soft-fail web search: {e}")
59
+ return ""
60
+
61
+ # 2. Jeśli nie znaleziono lub brak klucza, zwracamy stosowną informację,
62
+ # ale możemy spróbować odpowiedzieć z wiedzy LLM z uwzględnieniem kontekstu.
63
+ if "Błąd" in search_results or "Brak klucza" in search_results:
64
+ logger.warning(f"[Research Agent TITAN] Problem z wyszukiwarką: {search_results}")
65
+ search_context = "Nie udało się pobrać aktualnych danych z sieci z powodu braku dostępu do API wyszukiwania."
66
+ else:
67
+ search_context = f"Wyniki wyszukiwania:\n{search_results}"
68
+
69
+ system_prompt = (
70
+ "Jesteś zaawansowanym Agentem Badawczym OSINT. Twoim zadaniem jest dostarczenie "
71
+ "wyczerpujących i merytorycznych informacji na podstawie podanego kontekstu projektu "
72
+ "oraz wyników wyszukiwania z sieci.\n\n"
73
+ "Zasady:\n"
74
+ "1. Twoim celem jest odpowiedź na brakujące zapytanie (pytanie o brakujące dane).\n"
75
+ "2. Wykorzystaj informacje z 'Wyników wyszukiwania', jeśli są dostępne.\n"
76
+ "3. Oprzyj się na 'Kontekście projektu', aby odpowiedź była dopasowana do specyfiki firmy i projektu.\n"
77
+ "4. Jeśli z danych sieciowych i kontekstu nie da się jednoznacznie określić faktów (np. precyzyjnego przychodu małej firmy), "
78
+ "zaproponuj profesjonalne i wiarygodne oszacowanie lub standardowe dla branży wartości rynkowe i zaznacz, że to szacunek, "
79
+ "aby wniosek dotacyjny mógł zostać wygenerowany jako pełny szkic (użyj znaczników np. [SZACOWANY_PRZYCHÓD: 1.5 mln PLN]).\n"
80
+ "5. Pisz wyłącznie w języku polskim, stylem profesjonalnym, odpowiednim do wniosków unijnych i biznesplanów.\n"
81
+ "6. NIE wymyślaj ani nie cytuj URL-i typu example.com / placeholder — tylko realne źródła z wyników."
82
+ )
83
+
84
+ human_content = f"Pytanie o brakujące dane: {query}\n\nKontekst projektu:\n{context}\n\n{search_context}"
85
+
86
+ try:
87
+ response = self.llm.invoke([
88
+ SystemMessage(content=system_prompt),
89
+ HumanMessage(content=human_content)
90
+ ])
91
+
92
+ final_answer = response.content if hasattr(response, 'content') else str(response)
93
+ logger.info("[Research Agent TITAN] Zakończono syntezę wyników badawczych.")
94
+ return final_answer
95
+ except Exception as e:
96
+ logger.warning(f"[Research Agent TITAN] Soft-fail LLM: {e}")
97
+ return ""
98
+
99
+ research_agent = ResearchAgent()
backend/agents/researcher.py ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import logging
3
+
4
+ from schemas import AgentState, GrantCall
5
+ from core.search.grant_search_service import grant_search_service
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ CATALOG_LIMIT = 80
10
+
11
+
12
+ def _run_async(coro):
13
+ """Uruchamia korutynę z kontekstu synchronicznego, bezpiecznie względem pętli.
14
+
15
+ Poprzedni wzorzec (get_event_loop().run_until_complete + fallback asyncio.run)
16
+ wywalał się, gdy pętla zdarzeń już działała (np. węzeł w async LangGraph):
17
+ obie ścieżki rzucają RuntimeError. Tutaj, jeśli pętla działa, wykonujemy
18
+ korutynę w osobnym wątku z własną pętlą.
19
+ """
20
+ try:
21
+ running_loop = asyncio.get_running_loop()
22
+ except RuntimeError:
23
+ running_loop = None
24
+
25
+ if running_loop is not None:
26
+ import concurrent.futures
27
+
28
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
29
+ return pool.submit(lambda: asyncio.run(coro)).result()
30
+ return asyncio.run(coro)
31
+
32
+
33
+ def _profile_to_company_data(profile) -> dict:
34
+ region = profile.region if profile.region and profile.region != "Nieznane" else ""
35
+ return {
36
+ "pkd": profile.pkd_codes or [],
37
+ "pkd_codes": profile.pkd_codes or [],
38
+ "size": profile.size or "mikro",
39
+ "voivodeship": region,
40
+ "address": region,
41
+ "entity_type": "przedsiębiorca",
42
+ }
43
+
44
+
45
+ def _filter_catalog_by_eligibility(items: list, company_data: dict) -> list:
46
+ if not items or not company_data.get("pkd"):
47
+ return items
48
+ try:
49
+ from core.graph_db.grant_eligibility import evaluate_grant_eligibility
50
+
51
+ scored = []
52
+ for item in items:
53
+ verdict = evaluate_grant_eligibility(item, company_data)
54
+ if verdict.get("eligible"):
55
+ item = dict(item)
56
+ item["_eligibility_score"] = 100 - verdict.get("score_penalty", 0)
57
+ scored.append(item)
58
+ scored.sort(key=lambda x: x.get("_eligibility_score", 0), reverse=True)
59
+ if scored:
60
+ return scored
61
+ except Exception as e:
62
+ logger.debug("researcher_node: eligibility pre-filter skip: %s", e)
63
+ return items
64
+
65
+
66
+ def _grants_from_db(profile) -> list:
67
+ """Pobiera programy z PostgreSQL (import WYSZUKIWARKA) z pre-filtrem PKD/region."""
68
+ try:
69
+ from core.subscription.db import SessionLocal
70
+ from core.grants.catalog_service import search_catalog
71
+
72
+ db = SessionLocal()
73
+ try:
74
+ query_parts = []
75
+ if profile.name:
76
+ query_parts.append(profile.name)
77
+ if profile.size:
78
+ query_parts.append(profile.size)
79
+ if profile.region and profile.region != "Nieznane":
80
+ query_parts.append(profile.region)
81
+ if profile.pkd_codes:
82
+ query_parts.extend(profile.pkd_codes[:3])
83
+ if profile.investment_plans:
84
+ query_parts.extend([p.description for p in profile.investment_plans[:2]])
85
+
86
+ query = " ".join(query_parts)
87
+ catalog = search_catalog(db, query=query, limit=CATALOG_LIMIT, trusted_only=False)
88
+ if not catalog:
89
+ catalog = search_catalog(db, query="", limit=CATALOG_LIMIT, trusted_only=False)
90
+
91
+ company_data = _profile_to_company_data(profile)
92
+ catalog = _filter_catalog_by_eligibility(catalog, company_data)
93
+
94
+ grants = []
95
+ for item in catalog[:CATALOG_LIMIT]:
96
+ grants.append(
97
+ GrantCall(
98
+ title=f"{item.get('program', '')} - {item.get('name', '')}".strip(" -"),
99
+ description=_rich_description(item),
100
+ url=item.get("url", ""),
101
+ deadline=item.get("deadline") or "Brak potwierdzonego terminu",
102
+ max_amount=float(item.get("max_dofinansowanie_pln") or 0),
103
+ institution=item.get("operator") or item.get("program", ""),
104
+ )
105
+ )
106
+ return grants
107
+ finally:
108
+ db.close()
109
+ except Exception as e:
110
+ logger.warning("researcher_node: błąd odczytu katalogu DB: %s", e)
111
+ return []
112
+
113
+
114
+ def _rich_description(item: dict) -> str:
115
+ parts = [item.get("description") or ""]
116
+ if item.get("beneficjenci"):
117
+ parts.append(f"Beneficjenci: {item['beneficjenci']}")
118
+ if item.get("warunki_wejscia"):
119
+ parts.append(f"Warunki: {str(item['warunki_wejscia'])[:300]}")
120
+ if item.get("kwota_max"):
121
+ parts.append(f"Kwota: {item['kwota_max']}")
122
+ return " | ".join(p for p in parts if p)
123
+
124
+
125
+ def researcher_node(state: AgentState):
126
+ if not state.profile:
127
+ return {
128
+ "current_agent": "supervisor",
129
+ "messages": [
130
+ {
131
+ "role": "assistant",
132
+ "content": "Brak danych firmy do weryfikacji naborów.",
133
+ }
134
+ ],
135
+ }
136
+
137
+ grants = _grants_from_db(state.profile)
138
+ source_note = f"katalog PostgreSQL (WYSZUKIWARKA, limit={CATALOG_LIMIT})"
139
+
140
+ if not grants:
141
+ voivodeship_filter = (
142
+ f" województwo {state.profile.region}"
143
+ if state.profile.region and state.profile.region != "Nieznane"
144
+ else " ogólnopolskie"
145
+ )
146
+ investment_keywords = ""
147
+ if state.profile.investment_plans:
148
+ investment_keywords = " " + " ".join(
149
+ [p.description for p in state.profile.investment_plans]
150
+ )
151
+ company_ref = state.profile.name or f"NIP {state.profile.nip}"
152
+ query = (
153
+ f"firma {company_ref} {state.profile.size}{voivodeship_filter} "
154
+ f"branża {', '.join(state.profile.pkd_codes)}{investment_keywords}"
155
+ )
156
+ source_note = "live scrape (fallback — pusta baza katalogowa)"
157
+
158
+ try:
159
+ search_results = _run_async(grant_search_service.search_grants(query, {}))
160
+ except Exception as e:
161
+ logger.error("Error in researcher_node live search: %s", e)
162
+ search_results = []
163
+
164
+ for r in search_results:
165
+ grants.append(
166
+ GrantCall(
167
+ title=f"{r.get('program', '')} - {r.get('name', '')}",
168
+ description=r.get("description", ""),
169
+ url=r.get("url", ""),
170
+ deadline=r.get("deadline") or "Brak potwierdzonego terminu",
171
+ max_amount=r.get("max_dofinansowanie_pln", 0.0),
172
+ )
173
+ )
174
+
175
+ if not grants:
176
+ return {
177
+ "eligible_grants": [],
178
+ "current_agent": "supervisor",
179
+ "messages": [
180
+ {
181
+ "role": "assistant",
182
+ "content": (
183
+ "Nie znaleziono programów dotacyjnych pasujących do profilu firmy. "
184
+ "Uruchom import katalogu: `python scripts/import_wyszukiwarka.py` "
185
+ "lub endpoint admin `/api/admin/grants/import-wyszukiwarka`."
186
+ ),
187
+ }
188
+ ],
189
+ }
190
+
191
+ logger.info("researcher_node: %s wyników ze źródła %s", len(grants), source_note)
192
+ return {
193
+ "eligible_grants": grants,
194
+ "current_agent": "supervisor",
195
+ "messages": [
196
+ {
197
+ "role": "assistant",
198
+ "content": f"Znaleziono {len(grants)} programów (źródło: {source_note}).",
199
+ }
200
+ ],
201
+ }
backend/agents/risk_scoring.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from typing import Dict, Any
3
+ from langchain_core.messages import AIMessage
4
+ from core.llm_router import get_llm
5
+ from schemas import AgentState, RiskScoreOutput
6
+ from agents.helpers import ANTI_HALLUCINATION_PROMPT
7
+ from tenacity import retry, stop_after_attempt, wait_exponential
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ def risk_scoring_node(state: AgentState) -> Dict[str, Any]:
13
+ """
14
+ Hybrydowy system punktacji ryzyka (Opcja A - Twarde reguły + LLM).
15
+ LLM identyfikuje ryzyka i proponuje ocenę, ale Python nakłada twarde filtry biznesowe
16
+ (np. odrzucenie za status trudności, kary za brak przychodów).
17
+ """
18
+ llm = get_llm(task_type="critical", structured_output_schema=RiskScoreOutput)
19
+
20
+ profile_dump = (
21
+ state.profile.model_dump() if state.profile else "Brak danych profilu"
22
+ )
23
+
24
+ prompt = f"""
25
+ {ANTI_HALLUCINATION_PROMPT}
26
+
27
+ Na podstawie profilu firmy przydziel jej hipotetyczną ocenę projektową 0-100 dla szans na uzyskanie dotacji UE.
28
+ Następnie wypunktuj DOKŁADNIE 5 RYZYK, które obniżają tę ocenę (np. słabe wyniki finansowe, brak innowacji).
29
+
30
+ Profil: {profile_dump}
31
+ """
32
+
33
+
34
+ @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
35
+ def _invoke_llm():
36
+ res = llm.invoke(prompt)
37
+ if len(res.risks) != 5:
38
+ raise ValueError("LLM did not return exactly 5 risks.")
39
+ if not (0 <= res.score <= 100):
40
+ raise ValueError("Score out of bounds.")
41
+ return res
42
+
43
+ try:
44
+ response = _invoke_llm()
45
+
46
+ final_score = response.score
47
+ risks = response.risks.copy()
48
+
49
+ # --- TWARDE REGUŁY BIZNESOWE (HARD RULES) ---
50
+ if state.profile:
51
+ # 1. Firma w trudnej sytuacji - całkowita dyskwalifikacja
52
+ if getattr(state.profile, 'is_in_difficulty', False):
53
+ final_score = 0
54
+ risks.insert(0, "🚨 DYSKWALIFIKACJA: Firma w trudnej sytuacji ekonomicznej (zakaz wsparcia UE).")
55
+
56
+ # 2. Brak udokumentowanych przychodów - kara punktowa i narzucenie ryzyka
57
+ fin = getattr(state.profile, 'financials', None)
58
+ if not fin or getattr(fin, 'revenue', 0.0) <= 0:
59
+ final_score = min(final_score, 50) # Max 50 pkt dla firm bez przychodów
60
+ risks.insert(0, "⚠️ TWARDE RYZYKO: Brak historycznych przychodów (Startup) - ogromne ryzyko stabilności finansowej projektu.")
61
+
62
+ # 3. Zbyt duży projekt dla mikroprzedsiębiorstwa (heurystyka np. inwestycja > 5 mln)
63
+ plans = getattr(state.profile, 'investment_plans', [])
64
+ total_investment = sum(p.estimated_cost for p in plans) if plans else 0
65
+ if getattr(state.profile, 'size', '') == 'Mikro' and total_investment > 5_000_000:
66
+ final_score = max(0, final_score - 20)
67
+ risks.insert(0, "⚠️ TWARDE RYZYKO: Skala inwestycji (pow. 5 mln PLN) nieadekwatna do wielkości Mikroprzedsiębiorstwa.")
68
+
69
+ # Ograniczamy do max 5 ryzyk, ale twarde są zawsze na początku
70
+ risks = risks[:5]
71
+
72
+ # Zapis w state.risk_score
73
+ risk_score_update = {"score": final_score, "risks": risks}
74
+ score_text = f"WYNIK: {final_score}/100\nRYZYKA:\n" + "\n".join(
75
+ [f"{i+1}. {r}" for i, r in enumerate(risks)]
76
+ )
77
+
78
+ # Opcjonalny zapis wstecznej zgodności z Blackboard
79
+ blackboard_update = state.blackboard or {}
80
+ blackboard_update["last_risk_score"] = score_text
81
+
82
+ return {
83
+ "messages": [AIMessage(content=score_text)],
84
+ "risk_score": risk_score_update,
85
+ "blackboard": blackboard_update,
86
+ "current_agent": "supervisor",
87
+ }
88
+ except Exception as e:
89
+ # FAIL-CLOSED: na wyjątku NIE gubimy risk_score (co cicho omijałoby bramkę).
90
+ # Ustawiamy konserwatywny wynik wyzwalający bramkę / human review.
91
+ logger.error(f"Błąd risk scoring: {e}")
92
+ conservative = {
93
+ "score": 0,
94
+ "risks": [
95
+ "⚠️ Awaria mechanizmu oceny ryzyka — ustawiono konserwatywny wynik 0/100. "
96
+ "Wymagana ręczna weryfikacja przed eksportem."
97
+ ],
98
+ "human_review_required": True,
99
+ "error": str(e)[:200],
100
+ }
101
+ return {
102
+ "risk_score": conservative,
103
+ "current_agent": "supervisor",
104
+ }
backend/agents/scraper_agent.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import asyncio
3
+ import sys
4
+ import os
5
+
6
+ sys.path.append(os.path.join(os.path.dirname(__file__), "..", ".."))
7
+ try:
8
+ from backend.integrations.isap_client import ISAPClient
9
+ from backend.integrations.parp_client import PARPClient
10
+ from backend.rag_pipeline.scraper import scrape_grant_url
11
+ from backend.rag_pipeline.ingest import process_and_ingest
12
+ except ImportError:
13
+ try:
14
+ from integrations.isap_client import ISAPClient
15
+ from integrations.parp_client import PARPClient
16
+ from rag_pipeline.scraper import scrape_grant_url
17
+ from rag_pipeline.ingest import process_and_ingest
18
+ except ImportError:
19
+ ISAPClient = None
20
+ PARPClient = None
21
+ scrape_grant_url = None
22
+ process_and_ingest = None
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class ScraperAgent:
28
+ """
29
+ Inteligentny Agent, który decyduje w jaki sposób pozyskać dane.
30
+ Przeznaczony do działania z Celery Beat / APScheduler do cyklicznych aktualizacji.
31
+ """
32
+
33
+ def __init__(self):
34
+ if ISAPClient and PARPClient:
35
+ self.isap_client = ISAPClient()
36
+ self.parp_client = PARPClient()
37
+ else:
38
+ self.isap_client = None
39
+ self.parp_client = None
40
+ # Namespace do ogólnodostępnej przestrzeni aktów prawnych (ISAP/EUR-Lex) — NIE dla naborów
41
+ self.public_namespace = "public_legal"
42
+ # Nabory / harmonogramy → grants catalog (nie public_legal)
43
+ self.grants_namespace = "grants_catalog"
44
+
45
+ async def run_sync_job(self):
46
+ """Uruchamia cykliczny proces synchronizacji dotacji i prawa"""
47
+ if not self.isap_client or not self.parp_client or not scrape_grant_url:
48
+ logger.error("[AGENT] Brak klientow ISAP/PARP. Synchronizacja anulowana.")
49
+ return
50
+
51
+ logger.info(
52
+ "[AGENT] Rozpoczęcie automatycznego cyklu synchronizacji bazy wiedzy..."
53
+ )
54
+
55
+ # 1. PARP (Regulaminy Naborów) — Imperva: scrape często pusty → JSON fallback
56
+ # JSON fallback = wspólny blob z WyszukiwarkaImport → NIE wektoryzuj do public_legal
57
+ # i NIE ingestuj 4× tego samego pliku per grant key.
58
+ from rag_pipeline.ingest import is_parp_json_fallback_text
59
+
60
+ grants = self.parp_client.fetch_grants()
61
+ parp_live_ingested = False
62
+ parp_fallback_seen = False
63
+ for grant in grants:
64
+ url = grant["url"]
65
+ logger.info(f"[AGENT] Zlecam scrapowanie dla dotacji: {grant['id']}")
66
+ try:
67
+ text, _ = await scrape_grant_url(url)
68
+ if not text:
69
+ continue
70
+ if is_parp_json_fallback_text(text):
71
+ parp_fallback_seen = True
72
+ # DB import (WyszukiwarkaImport) already covers catalog — skip Pinecone
73
+ continue
74
+ process_and_ingest(
75
+ text, url, priority="high", namespace=self.grants_namespace
76
+ )
77
+ parp_live_ingested = True
78
+ except Exception as e:
79
+ logger.error(f"[AGENT] Błąd fetchowania {url}: {e}")
80
+
81
+ if parp_fallback_seen and not parp_live_ingested:
82
+ logger.info(
83
+ "[AGENT] PARP scrape pusty — skip Pinecone dla dotacje-latest.json "
84
+ "(katalog pokryty przez WyszukiwarkaImport)"
85
+ )
86
+ elif not parp_live_ingested and not parp_fallback_seen:
87
+ logger.warning(
88
+ "[AGENT] PARP: brak zawartości ze scrape i z JSON fallback"
89
+ )
90
+
91
+ # 2. ISAP (Ustawa z dn 6 marca 2018 - Prawo przedsiębiorców)
92
+ logger.info("[AGENT] Pobieranie ram prawnych (ISAP/ELI)")
93
+ act_info = self.isap_client.fetch_act("WDU", 2018, 646)
94
+ if act_info:
95
+ urls_to_try = [act_info["text_url"]]
96
+ if act_info.get("text_pdf_url"):
97
+ urls_to_try.append(act_info["text_pdf_url"])
98
+ for url in urls_to_try:
99
+ try:
100
+ text, _ = await scrape_grant_url(url)
101
+ if text:
102
+ process_and_ingest(
103
+ text, url, priority="critical", namespace=self.public_namespace
104
+ )
105
+ break
106
+ except Exception as e:
107
+ logger.error(f"[AGENT] Błąd parsowania ISAP {url}: {e}")
108
+
109
+ logger.info("[AGENT] Zakończono automatyczny cykl agenta synchronizacyjnego.")
110
+
111
+
112
+ if __name__ == "__main__":
113
+ logging.basicConfig(level=logging.INFO)
114
+ agent = ScraperAgent()
115
+ asyncio.run(agent.run_sync_job())
backend/agents/supervisor.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from schemas import AgentState, SupervisorDecision
2
+ from core.llm_router import get_llm
3
+ from functools import lru_cache
4
+
5
+
6
+ class SimpleQueryRouter:
7
+ """
8
+ v5.0 Real QueryRouter (previously stub) for autonomous flows + Master Orchestrator hooks.
9
+ Keyword + lightweight LLM fallback. Integrated into supervisor.
10
+ Enables full autonomous routing without external GSD swarm in main graph.
11
+ """
12
+
13
+ AGENT_KEYWORDS = {
14
+ "profiler": ["profil", "dane firmy", "krs", "nip", "msp", "company"],
15
+ "researcher": ["szukaj", "znajdź", "dotacje", "nabory", "research"],
16
+ "matcher": ["dopasuj", "match", "rekomenduj", "najlepsze programy"],
17
+ "verifier": ["sprawdź", "weryfikuj", "formalnie", "zgodność"],
18
+ "wizard": ["pisz", "generuj", "sekcja", "wniosek", "treść"],
19
+ "risk_scoring": ["ryzyko", "szanse", "punktuj", "score"],
20
+ "document_gap_analyzer": ["braki", "gap", "analiza dokumentu"],
21
+ "compliance_guardian": ["rodo", "compliance", "ochrona danych", "kruczkowski"],
22
+ "end": ["koniec", "zakończ", "gotowe", "dziękuję"],
23
+ }
24
+
25
+ @staticmethod
26
+ @lru_cache(maxsize=64)
27
+ def route_query(query: str) -> str:
28
+ """Fast cached routing for token efficiency."""
29
+ q = (query or "").lower()
30
+ for agent, kws in SimpleQueryRouter.AGENT_KEYWORDS.items():
31
+ if any(kw in q for kw in kws):
32
+ return agent
33
+ return "planner" # default safe
34
+
35
+ @staticmethod
36
+ def route(state: AgentState) -> str:
37
+ """Full router used by supervisor (real implementation replacing stub)."""
38
+ if not state.messages:
39
+ return "planner"
40
+ if state.task_plan and len(state.task_plan) > 0:
41
+ next_task = state.task_plan[0].lower()
42
+ if "profil" in next_task:
43
+ return "profiler"
44
+ if any(x in next_task for x in ["dopas", "match", "rekomend"]):
45
+ return "matcher"
46
+ if any(x in next_task for x in ["gener", "pisz", "sekcj"]):
47
+ return "wizard"
48
+ last = state.messages[-1]
49
+ content = last.get("content", "") if isinstance(last, dict) else getattr(last, "content", "")
50
+ routed = SimpleQueryRouter.route_query(content)
51
+ # Fallback LLM only if keyword weak (token saving)
52
+ if routed == "planner" and len(content) > 40:
53
+ try:
54
+ llm = get_llm(task_type="fast")
55
+ decision = llm.invoke(f"Route this grant query to one agent: planner/profiler/matcher/wizard/risk_scoring. Query: {content[:200]}")
56
+ txt = getattr(decision, "content", str(decision)).lower()
57
+ for a in SimpleQueryRouter.AGENT_KEYWORDS:
58
+ if a in txt:
59
+ return a
60
+ except Exception:
61
+ pass
62
+ return routed
63
+
64
+
65
+ def supervisor_node(state: AgentState):
66
+ """
67
+ Supervisor (Router) z 2026 r. zintegrowany z Blackboard.
68
+ Kieruje wiadomości lub wywołuje wykonanie kolejnego zdefiniowanego kroku w task_plan.
69
+ v5.0: Uses real SimpleQueryRouter for autonomous flow polish + Master Orchestrator compatibility.
70
+ """
71
+ if not state.messages:
72
+ return {"current_agent": "planner"}
73
+
74
+ # Check if there is an active plan in blackboard to execute
75
+ if state.task_plan and len(state.task_plan) > 0:
76
+ next_task = state.task_plan[0].lower()
77
+ if "profil" in next_task:
78
+ return {"current_agent": "profiler"}
79
+ elif "dopasowa" in next_task or "match" in next_task:
80
+ return {"current_agent": "matcher"}
81
+ # Logika oparta na planie będzie o wiele bardziej rozbudowana w produkcji.
82
+
83
+ # v5.0 Real QueryRouter path (polish for full autonomous)
84
+ try:
85
+ next_agent = SimpleQueryRouter.route(state)
86
+ valid = ["planner", "profiler", "researcher", "matcher", "verifier", "wizard",
87
+ "risk_scoring", "document_gap_analyzer", "compliance_guardian", "end"]
88
+ if next_agent in valid:
89
+ return {"current_agent": next_agent}
90
+ except Exception:
91
+ pass
92
+
93
+ last_msg = (
94
+ state.messages[-1].get("content", "")
95
+ if isinstance(state.messages[-1], dict)
96
+ else getattr(state.messages[-1], "content", "")
97
+ )
98
+
99
+ prompt = f"""
100
+ Jesteś supervisorem (dyrektorem) systemu 'GrantForge AI'.
101
+ Mamy następujące działy (agentów):
102
+ - planner: planowanie działań
103
+ - profiler: zbieranie danych firmy z KRS/chatu
104
+ - researcher: eksploracja dotacji
105
+ - matcher: dopasowanie znanych dotacji
106
+ - verifier: sprawdzanie formalne
107
+ - wizard: pisanie wniosku, wymyślanie treści
108
+ - risk_scoring: punktowanie szans i ryzyk wniosku
109
+ - document_gap_analyzer: analiza braków dokumentu
110
+ - compliance_guardian: sprawdzanie RODO
111
+ - end: koniec procesu, oddanie głosu klientowi
112
+
113
+ Na podstawie ostatniej wiadomości opisz krótko powód (reason) i wskaż jednoznaczną wartość next_agent z listy powyżej.
114
+ Wiadomość z systemu klienta: {last_msg}
115
+ """
116
+
117
+ try:
118
+ llm = get_llm(task_type="standard", structured_output_schema=SupervisorDecision)
119
+ decision = llm.invoke(prompt)
120
+
121
+ valid_agents = [
122
+ "planner",
123
+ "profiler",
124
+ "researcher",
125
+ "matcher",
126
+ "verifier",
127
+ "wizard",
128
+ "risk_scoring",
129
+ "document_gap_analyzer",
130
+ "compliance_guardian",
131
+ "end",
132
+ ]
133
+ if decision.next_agent in valid_agents:
134
+ # W przyszłości reason można wykorzystać do logowania logiki routing-u
135
+ return {"current_agent": decision.next_agent}
136
+ except Exception as e:
137
+ print(f"Błąd supervisora LLM: {str(e)}")
138
+
139
+ return {"current_agent": "end"}
backend/agents/timeline.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from typing import Dict, Any
3
+ from schemas import AgentState
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+
8
+ def timeline_node(state: AgentState) -> Dict[str, Any]:
9
+ """
10
+ Węzeł generujący harmonogram (timeline_events) dla naborów w których firma może wziąć udział.
11
+ """
12
+ grants = state.eligible_grants
13
+ if not grants:
14
+ return {"current_agent": "supervisor"}
15
+
16
+ # Posortuj po skorygowanym relevance_score, weź max 3 by nie zaśmiecać widoku
17
+ top_grants = sorted(
18
+ grants,
19
+ key=lambda x: x.relevance_score if x.relevance_score else 0.0,
20
+ reverse=True,
21
+ )[:3]
22
+
23
+ events = []
24
+
25
+ # Tworzymy symulowany kalendarz na podstawie zebranych dotacji
26
+ for idx, grant in enumerate(top_grants):
27
+ # Wydarzenie startowe: rozpoczęcie prac nad wnioskiem
28
+ events.append(
29
+ {
30
+ "id": f"start_prep_{grant.id}",
31
+ "title": f"Rozpoczęcie przygotowań do: {grant.title}",
32
+ "description": "Zebranie dokumentacji technicznej i finansowej",
33
+ "date": "Dzisiaj",
34
+ "status": "upcoming",
35
+ }
36
+ )
37
+
38
+ # Ostateczny termin
39
+ deadline_str = grant.deadline if grant.deadline else "Brak podanej daty"
40
+ events.append(
41
+ {
42
+ "id": f"deadline_{grant.id}",
43
+ "title": f"Wysłanie wniosku: {grant.title}",
44
+ "description": f"Instytucja przyjmująca: {grant.institution}. Budżet: ok. {grant.max_amount} PLN",
45
+ "date": deadline_str,
46
+ "status": "pending",
47
+ }
48
+ )
49
+
50
+ # Przewidywana ocena wniosku (zakładamy 90 dni)
51
+ events.append(
52
+ {
53
+ "id": f"evaluation_{grant.id}",
54
+ "title": f"Spodziewane ogłoszenie wyników: {grant.title}",
55
+ "description": "Zakończenie oceny eksperckiej wniosku przez instytucję",
56
+ "date": "+90 dni od wpłynięcia",
57
+ "status": "future",
58
+ }
59
+ )
60
+
61
+ logger.info(
62
+ f"Timeline node wygenerował {len(events)} wydarzeń na osi czasu dla top {len(top_grants)} dotacji."
63
+ )
64
+ return {"timeline_events": events, "current_agent": "supervisor"}
backend/agents/tools/budget_rules_tool.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from langchain_core.tools import tool
2
+ from rag_pipeline.hybrid_retriever import get_hybrid_retriever
3
+
4
+
5
+ @tool
6
+ def search_budget_rules(query: str, program_name: str = "") -> str:
7
+ """
8
+ Wyszukuje informacje w bazie wiedzy (RAG) na temat limitów kosztów kwalifikowanych,
9
+ zasad finansowania, stawek ryczałtowych oraz dozwolonych budżetów dla programów dotacyjnych.
10
+
11
+ Args:
12
+ query (str): Pytanie dotyczące zasad budżetowych np. "Jaki jest limit kosztów pośrednich dla Ścieżki SMART?"
13
+ program_name (str): Opcjonalnie nazwa programu, np. "FENG Ścieżka SMART".
14
+ """
15
+ retriever = get_hybrid_retriever()
16
+ search_query = f"[Koszty, Budżet, Ewaluacja Finansowa] Program: {program_name}. Zapytanie: {query}"
17
+
18
+ docs = retriever.invoke(search_query)
19
+ if not docs:
20
+ return "Nie znaleziono dokumentów precyzujących to zapytanie budżetowe w bazie wiedzy."
21
+
22
+ result = "\n".join(
23
+ [
24
+ f"- Zródło: {d.metadata.get('source', 'nieznane')}\n{d.page_content}"
25
+ for d in docs
26
+ ]
27
+ )
28
+ return f"Wyniki wyszukiwania zasad budżetowych:\n{result}"
backend/agents/tools/krs_graph_tool.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from typing import Dict, Any
3
+ from langchain_core.tools import tool
4
+ from integrations.krs_client import KRSClient
5
+ from rag_pipeline.graph_store import graph_store
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ @tool
11
+ def analyze_company_network(krs_number: str) -> Dict[str, Any]:
12
+ """
13
+ Analizuje strukturę powiązań kapitałowych i osobowych firmy na podstawie numeru KRS.
14
+ Narzędzie pobiera oficjalne dane z API KRS (odpis aktualny), dodaje je do bazy grafowej Neo4j,
15
+ a następnie wyszukuje jakiekolwiek powiązania weryfikujące status MŚP (związki z innymi firmami).
16
+
17
+ Zwraca słownik zawierający dane rejestrowe firmy oraz zidentyfikowaną siatkę powiązań (wraz ze wspólnikami i zarządem).
18
+ """
19
+
20
+ logger.info(f"Rozpoczęcie analizy powiązań dla KRS: {krs_number}")
21
+
22
+ # 1. Pobierz aktualne dane z publicznego API KRS
23
+ odpis_json = KRSClient.get_odpis_aktualny(krs_number)
24
+
25
+ if not odpis_json:
26
+ return {
27
+ "error": f"Nie udało się pobrać odpisu dla KRS {krs_number}. Upewnij się, że wpisany KRS jest poprawny."
28
+ }
29
+
30
+ # 2. Przekształć JSON w węzły struktur (Wspólnicy, Zarząd)
31
+ extracted_data = KRSClient.extract_graph_relations(odpis_json)
32
+
33
+ if not extracted_data:
34
+ return {
35
+ "error": "Format odpisu KRS był niepoprawny lub nie wspierany w obecnej strukturze."
36
+ }
37
+
38
+ # 3. Zapisz/zaktualizuj graf Neo4j
39
+ graph_store.merge_company_graph(extracted_data)
40
+
41
+ # 4. Sprawdź powiązania grafowe - szukaj firm zależnych, powiązanych osób
42
+ network = graph_store.check_company_network(krs_number)
43
+
44
+ # Zwróć zagregowany profil do Agenta (np. Audytora)
45
+ return {
46
+ "podmiot": {
47
+ "nazwa": extracted_data.get("nazwa"),
48
+ "krs": extracted_data.get("krs"),
49
+ "nip": extracted_data.get("nip"),
50
+ "kapital_zakladowy": extracted_data.get("kapitalZakladowy"),
51
+ },
52
+ "wspolnicy_bezposredni": [
53
+ f"{w.get('imiona')} {w.get('nazwa')} (Spółka: {w.get('is_spolka')})"
54
+ for w in extracted_data.get("wspolnicy", [])
55
+ ],
56
+ "zarzad": [
57
+ f"{z.get('imiona')} {z.get('nazwa')} - {z.get('funkcja')}"
58
+ for z in extracted_data.get("zarzad", [])
59
+ ],
60
+ "wykryte_relacje_grafowe": network
61
+ if network
62
+ else "Brak zidentyfikowanych powiązań sieciowych w bazie GraphRAG poza bezpośrednio ujawnionymi w odpisie.",
63
+ "rekomendacja_msp": "UWAGA: Jeżeli w sekcji 'wykryte_relacje_grafowe' zidentyfikowano inne spółki, kapitał, zatrudnienie lub przychody z tych firm mogą wliczać się w weryfikację statusu MŚP analizowanej firmy!",
64
+ }
backend/agents/tools/legal_retriever_tool.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional
2
+ from langchain_core.tools import tool
3
+ from rag_pipeline.hybrid_retriever import get_hybrid_retriever
4
+ import logging
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ @tool
10
+ def search_legal_documents(
11
+ query: str,
12
+ rok_perspektywy: Optional[str] = None,
13
+ program_name: Optional[str] = None,
14
+ date_from: Optional[str] = None,
15
+ date_to: Optional[str] = None,
16
+ namespace: Optional[str] = "default",
17
+ ) -> str:
18
+ """
19
+ Wyszukuje akty prawne, wytyczne oraz dokumenty funduszowe w wektorowej bazie wiedzy (RAG).
20
+ Wykorzystuj to narzędzie ZAWSZE, gdy potrzebujesz zweryfikować kwalifikowalność, obowiązki beneficjenta,
21
+ lub zasady konkursowe.
22
+
23
+ Argumenty:
24
+ - query: Szczegółowe zapytanie, np. "warunki kwalifikowalności kosztów wynagrodzeń".
25
+ - rok_perspektywy: (Opcjonalnie) filtr na perspektywę UE, np. "2021-2027".
26
+ - program_name: (Opcjonalnie) filtr na konkretny program, np. "Ścieżka SMART".
27
+ - date_from: (Opcjonalnie) filtr od daty publikacji dokumentu (YYYY-MM-DD).
28
+ - date_to: (Opcjonalnie) filtr do daty publikacji dokumentu (YYYY-MM-DD).
29
+ - namespace: (Opcjonalnie) ID przestrzeni klienta. Domyślnie "default".
30
+
31
+ Zwraca streszczenie znalezionych dokumentów z ich metadanymi. Jeśli wynik jest pusty lub
32
+ stwierdzisz po odczycie niedopasowanie bazy, PRZEFORMUŁUJ wyszukiwanie w kolejnym kroku grafu.
33
+ """
34
+ logger.info(
35
+ f"[LegalRetrieverTool] Otrzymano zapytanie: {query} | Rok: {rok_perspektywy} | Program: {program_name} | Od: {date_from} | Do: {date_to} | Namespace: {namespace}"
36
+ )
37
+
38
+ metadata_filter = {}
39
+ if rok_perspektywy:
40
+ metadata_filter["rok_perspektywy"] = {"$eq": rok_perspektywy}
41
+ if program_name:
42
+ metadata_filter["program_name"] = {"$eq": program_name}
43
+
44
+ if date_from or date_to:
45
+ date_filter = {}
46
+ if date_from:
47
+ date_filter["$gte"] = date_from
48
+ if date_to:
49
+ date_filter["$lte"] = date_to
50
+ metadata_filter["date"] = date_filter
51
+
52
+ if not metadata_filter:
53
+ metadata_filter = None
54
+
55
+ retriever = get_hybrid_retriever(
56
+ k=4,
57
+ metadata_filter=metadata_filter,
58
+ namespace=namespace,
59
+ # Multi-stage for higher precision on legal/regulation queries (recall 16 -> CE rerank to 4)
60
+ retrieval_k=16,
61
+ rerank_top_n=4,
62
+ use_reranker=True,
63
+ )
64
+
65
+ if not retriever:
66
+ return "Błąd techniczny: Baza wiedzy (wektorowa) jest niedostępna lub retriever nie został utworzony."
67
+
68
+ docs = retriever.invoke(query)
69
+
70
+ if not docs:
71
+ # Fallback do live legal sources (EUR-Lex) — najwyższa wiarygodność
72
+ try:
73
+ from integrations.eurlex_client import EURLexClient
74
+ client = EURLexClient()
75
+ live = client.search_legal_acts(query, limit=3)
76
+ if live:
77
+ live_text = "\n".join([f"- {l.get('title','')} | {l.get('url','')}" for l in live if not l.get('error')])
78
+ return f"[LIVE EUR-LEX FALLBACK - źródło prawdy UE]\n{live_text}\n\nZalecenie: Zweryfikuj bezpośrednio w EUR-Lex dla pełnej aktualności."
79
+ except Exception:
80
+ pass
81
+ return "Brak pasujących dokumentów w bazie wiedzy dla tej perspektywy i zapytania. Przeformułuj zapytanie bazowe!"
82
+
83
+ results = []
84
+ for d in docs:
85
+ source = d.metadata.get("source", "Nieznane")
86
+ rok = d.metadata.get("rok_perspektywy", "Brak danych o roku")
87
+ content = d.page_content.replace("\n", " ")[
88
+ :1000
89
+ ] # Ograniczenie by LLM się nie zgubił
90
+ results.append(
91
+ f"--- DOKUMENT: {source} (Perspektywa: {rok}) ---\nTREŚĆ: {content}..."
92
+ )
93
+
94
+ return "\n\n".join(results)
backend/agents/tools/neo4j_cypher_tool.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from typing import Dict, Any
3
+ from langchain_core.tools import tool
4
+ from core.graph_db.neo4j_client import neo4j_client
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ @tool
10
+ def query_neo4j_graph(cypher_query: str) -> Dict[str, Any]:
11
+ """
12
+ Narzędzie dla LLM do samodzielnego odpytywania bazy grafowej Neo4j za pomocą języka Cypher.
13
+ Służy do analizowania powiązań między firmami, udziałowcami i weryfikacji statusu MŚP (związki kapitałowe).
14
+
15
+ Przykład użycia (Cypher):
16
+ MATCH (c:Company {krs: '0000123456'})<-[r:OWNS]-(owner) RETURN owner.name, r.share_percentage
17
+
18
+ Zwraca słownik zawierający wyniki zapytania lub błąd.
19
+ """
20
+ logger.info(f"LLM uruchamia zapytanie Cypher: {cypher_query}")
21
+
22
+ try:
23
+ # Sprawdzamy, czy połączenie z Neo4j jest aktywne
24
+ if not neo4j_client.driver:
25
+ neo4j_client.connect()
26
+ if not neo4j_client.driver:
27
+ return {
28
+ "error": "Brak połączenia z bazą Neo4j AuraDB. Spróbuj ponownie później lub przejdź do alternatywnych metod analizy."
29
+ }
30
+
31
+ # Wykonaj zapytanie (zabezpieczone try-except w _execute_query)
32
+ results = neo4j_client._execute_query(cypher_query)
33
+
34
+ if not results:
35
+ return {"results": [], "message": "Zapytanie nie zwróciło żadnych wyników."}
36
+
37
+ # Formatowanie wyników (record.data() to domyślna metoda rekordu neo4j)
38
+ formatted_results = [
39
+ record.data() if hasattr(record, "data") else dict(record)
40
+ for record in results
41
+ ]
42
+
43
+ return {"results": formatted_results, "count": len(formatted_results)}
44
+ except Exception as e:
45
+ logger.error(f"Błąd podczas wykonywania zapytania Cypher przez LLM: {str(e)}")
46
+ return {"error": f"Błąd wykonania zapytania: {str(e)}"}
backend/agents/tools/technology_retriever_tool.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from langchain_core.tools import tool
2
+ from rag_pipeline.hybrid_retriever import get_hybrid_retriever
3
+
4
+
5
+ @tool
6
+ def search_technology_trends(query: str, program_name: str = "") -> str:
7
+ """
8
+ Wyszukuje informacje w bazie wiedzy (RAG) na temat trendów technologicznych,
9
+ wymogów KIS (Krajowych Inteligentnych Specjalizacji) oraz odpowiednich
10
+ poziomów TRL (Technology Readiness Level) dla projektów badawczych i innowacyjnych.
11
+
12
+ Args:
13
+ query (str): Pytanie dotyczące wymogów technologicznych, np. "Jakie są wytyczne dla prac B+R w KPO?"
14
+ program_name (str): Opcjonalnie nazwa programu, np. "FENG Ścieżka SMART".
15
+ """
16
+ retriever = get_hybrid_retriever()
17
+ search_query = (
18
+ f"[Innowacje, KIS, B+R, TRL] Program: {program_name}. Zapytanie: {query}"
19
+ )
20
+
21
+ docs = retriever.invoke(search_query)
22
+ if not docs:
23
+ return "Nie znaleziono w bazie specyficznych wymogów wpisujących się w to zapytanie o poziomie innowacyjności/B+R."
24
+
25
+ result = "\n".join(
26
+ [
27
+ f"- Zródło: {d.metadata.get('source', 'nieznane')}\n{d.page_content}"
28
+ for d in docs
29
+ ]
30
+ )
31
+ return f"Wyniki wyszukiwania dla wymogów technologicznych i B+R:\n{result}"
backend/agents/verifier.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from schemas import AgentState
2
+ from core.llm_router import get_llm
3
+ from tenacity import retry, stop_after_attempt, wait_exponential
4
+
5
+
6
+ def verifier_node(state: AgentState):
7
+ parsed_content = (
8
+ state.verification_results.get("pending_doc_text")
9
+ if state.verification_results
10
+ else None
11
+ )
12
+ if not parsed_content:
13
+ return {
14
+ "verification_results": {
15
+ "status": "brak_dokumentu",
16
+ "analysis": "Brak dokumentu dodanego do weryfikacji.",
17
+ },
18
+ "current_agent": "supervisor",
19
+ }
20
+
21
+ try:
22
+ llm = get_llm(task_type="critical")
23
+
24
+ @retry(
25
+ stop=stop_after_attempt(3),
26
+ wait=wait_exponential(multiplier=1, min=2, max=10),
27
+ )
28
+ def _invoke_llm():
29
+ return llm.invoke(
30
+ f"System: Jesteś ekspertem oceny formalnej wniosków PARP. Sprawdź spójność danych.\n"
31
+ f"Porównaj dane z dokumentu: {parsed_content[:15000]}... z profilem firmy: {state.profile.model_dump() if state.profile else 'Brak'}"
32
+ )
33
+
34
+ response = _invoke_llm()
35
+ from core.utils import safe_extract_text
36
+
37
+ analysis_result = safe_extract_text(response.content)
38
+ except Exception as e:
39
+ analysis_result = f"Błąd weryfikacji przez model LLM: {str(e)}"
40
+
41
+ return {
42
+ "verification_results": {"analysis": analysis_result},
43
+ "current_agent": "supervisor",
44
+ }
backend/agents/wizard.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from schemas import AgentState
2
+ from typing import Dict, Any
3
+ from core.llm_router import get_llm
4
+ from langchain_core.prompts import PromptTemplate
5
+ from langchain_core.messages import AIMessage
6
+ from core.utils import safe_extract_text
7
+ from rag_pipeline import get_hybrid_retriever, rerank_documents
8
+
9
+ import os
10
+ from langsmith import traceable
11
+ from langchain_core.tracers.langchain import LangChainTracer
12
+
13
+ # Włącz tracing LangSmith
14
+ os.environ["LANGCHAIN_TRACING_V2"] = "false"
15
+ os.environ["LANGCHAIN_PROJECT"] = "grantforge-production"
16
+
17
+ # Opcjonalnie – jeśli chcesz zobaczyć dokładne nazwy runów
18
+ tracer = LangChainTracer(project_name="grantforge-production")
19
+
20
+
21
+ @traceable(run_type="chain", name="wizard_node")
22
+ def wizard_node(state: AgentState) -> Dict[str, Any]:
23
+ """
24
+ Kreator wniosku połączony z bazą wiedzy (RAG).
25
+ Wykorzystuje feedback Krytyka w celu iteracyjnej poprawy.
26
+ """
27
+
28
+ # Zabezpieczenie przed pętlą zgodnie ze standardem 2026/HitL
29
+ if state.critic_iterations >= state.max_critic_iterations:
30
+ return {
31
+ "messages": [
32
+ AIMessage(
33
+ content="Osiągnięto maksymalną liczbę iteracji poprawek. Przekazuję tekst do zatwierdzenia przez użytkownika."
34
+ )
35
+ ],
36
+ "critic_evaluation": {
37
+ "is_approved": True,
38
+ "feedback": "ZATWIERDZONE_MAX_ITERATIONS_REACHED",
39
+ "severity": "low",
40
+ },
41
+ }
42
+
43
+ import logging
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+ last_user_message = (
48
+ state.messages[-1].content if state.messages else "Stwórz biznesplan"
49
+ )
50
+
51
+ hard_filter = (
52
+ {"program_name": state.program_name}
53
+ if hasattr(state, "program_name") and state.program_name
54
+ else None
55
+ )
56
+
57
+ # Przekazanie namespace z contextu dzierżawcy do pinecone
58
+ namespace = getattr(state, "tenant_id", None)
59
+ logger.info(f"[Wizard] Inicjalizacja generowania. Użytkownik/Tenant: '{namespace}'")
60
+ retriever = get_hybrid_retriever(
61
+ k=10, metadata_filter=hard_filter, namespace=namespace
62
+ )
63
+
64
+ context_text = ""
65
+ if retriever:
66
+ try:
67
+ docs = retriever.invoke(last_user_message)
68
+ reranked_docs = rerank_documents(last_user_message, docs, top_n=5)
69
+ context_text = "\n\n".join(
70
+ [
71
+ f"[ŹRÓDŁO: {d.metadata.get('source', 'Nieznane')} | STRONA: {d.metadata.get('page', 'Brak')}]:\n{d.page_content}"
72
+ for d in reranked_docs
73
+ ]
74
+ )
75
+ except Exception as e:
76
+ context_text = (
77
+ f"Brak wiedzy w lokalnej bazie ze względu na błąd RAG: {str(e)}"
78
+ )
79
+ else:
80
+ context_text = "Brak podłączonej bazy wektorowej. Działam na bazowej wiedzy."
81
+
82
+ # W architekturze 2026 Wizard to model krytyczny (Gemini Pro) z opcjonalnym streamingiem
83
+ # LLM zainicjalizujemy poniżej po zdefiniowaniu schematu
84
+
85
+ template = """
86
+ Jesteś Głównym Analitykiem i Konsultantem Dotacyjnym na poziomie Enterprise w GrantForge AI.
87
+ Twoim zadaniem jest napisanie wysoce profesjonalnego fragmentu urzędowego wniosku biznesowego
88
+ lub biznesplanu, który ściśle przestrzega wytycznych prawnych. Jeśli brakuje kluczowych informacji firmy, ustrukturyzuj je profesjonalnie używając znaczników zastępczych np. "[UZUPEŁNIJ: Nazwa firmy]", ale NIGDY nie odmawiaj wygenerowania tekstu.
89
+
90
+ Kontekst regulaminowy wyszukany z bazy RAG:
91
+ --------------------------------------------------
92
+ {context}
93
+ --------------------------------------------------
94
+
95
+ Poprzednia krytyka od recenzenta:
96
+ {last_critic_feedback}
97
+
98
+ Dodatkowe informacje o firmie klienta (dane autorytatywne — używaj wprost, nie pytaj ponownie):
99
+ Nazwa: {company_name}
100
+ Rozmiar: {company_size}
101
+ Status MŚP: {company_msp}
102
+ Województwo: {company_region}
103
+ Forma prawna: {company_legal_form}
104
+ Branża (PKD): {company_pkd}
105
+
106
+ Polecenie / Pytanie:
107
+ {query}
108
+
109
+ Zwróć wynik w czystym Markdown, gotowym do eksportu do Word/PDF.
110
+ Używaj sformułowań formalnych i profesjonalnych typowych dla dokumentacji z danej branży oraz wymagań instytucji rozdzielającej środki dla wpisanego programu. Dopasuj słownictwo ściśle do wybranego programu z polecenia.
111
+ PISZ ZAWSZE W JĘZYKU POLSKIM. NIE DAJ SPYCHAĆ SIĘ NA INNE JĘZYKI, NAWET JEŚLI POLECENIE BĘDZIE PO ANGIELSKU.
112
+ DETERMINISTYCZNE CYTOWANIE ZAWSZE WYMAGANE: Każda merytoryczna teza z przytoczonych wytycznych we wniosku MUSI kończyć się przypisem do wskazanego źródła i strony, np. "(zgodnie z [ŹRÓDŁO: Regulamin_FENG | STRONA: 12])".
113
+ """
114
+
115
+ from pydantic import BaseModel, Field
116
+
117
+ class WizardSectionOutput(BaseModel):
118
+ content_markdown: str = Field(
119
+ description="Merytoryczna treść sekcji w czystym zredagowanym Markdown gotowym do eksportu. PISZ ZAWSZE I WYŁĄCZNIE W JĘZYKU POLSKIM."
120
+ )
121
+
122
+ prompt = PromptTemplate.from_template(template)
123
+
124
+ _p = state.profile
125
+ company_size = _p.size if _p else "Nieznany"
126
+ company_pkd = (
127
+ ", ".join(_p.pkd_codes) if _p and _p.pkd_codes else "Nieznane"
128
+ )
129
+ company_name = (_p.name if _p and _p.name else "") or "[UZUPEŁNIJ: Nazwa firmy]"
130
+ company_region = (_p.region if _p and _p.region else "") or "Nieznane"
131
+ company_legal_form = (_p.legal_form if _p and _p.legal_form else "") or "Nieznana"
132
+ company_msp = getattr(_p, "size", "") if _p else ""
133
+ company_msp = company_msp or "Nieznany"
134
+ critic_feedback = (
135
+ state.critic_evaluation.feedback
136
+ if state.critic_evaluation
137
+ else "Brak poprzedniej krytyki (pierwsza iteracja)."
138
+ )
139
+
140
+ structured_llm = get_llm(
141
+ task_type="critical",
142
+ streaming=True,
143
+ structured_output_schema=WizardSectionOutput,
144
+ )
145
+ chain = prompt | structured_llm
146
+
147
+ try:
148
+ response = chain.invoke(
149
+ {
150
+ "context": context_text,
151
+ "company_name": company_name,
152
+ "company_size": company_size,
153
+ "company_msp": company_msp,
154
+ "company_region": company_region,
155
+ "company_legal_form": company_legal_form,
156
+ "company_pkd": company_pkd,
157
+ "last_critic_feedback": critic_feedback,
158
+ "query": last_user_message,
159
+ }
160
+ )
161
+ except Exception as e:
162
+ logger.error(f"[Wizard] LLM Error during section generation: {e}")
163
+ from pydantic import BaseModel
164
+
165
+ # Zastępczy model zgodny ze strukturą oczekiwaną przez WizardSectionOutput
166
+ response = type(
167
+ "DummyResponse",
168
+ (),
169
+ {
170
+ "content_markdown": f"⚠️ **Błąd generowania sekcji**. Sprawdź klucz API Google lub połączenie z modelem lokalnym. Szczegóły: {e}"
171
+ },
172
+ )()
173
+
174
+ current_step = state.wizard_step + 1
175
+
176
+ # Dodajemy wersjonowanie dokumentów
177
+ doc_versions = dict(state.document_versions) if state.document_versions else {}
178
+ if "current_draft" not in doc_versions:
179
+ doc_versions["current_draft"] = []
180
+
181
+ flat_content = safe_extract_text(response.content_markdown)
182
+
183
+ doc_versions["current_draft"].append(flat_content)
184
+
185
+ return {
186
+ "wizard_step": current_step,
187
+ "document_versions": doc_versions,
188
+ "messages": [{"role": "assistant", "content": flat_content}],
189
+ "critic_iterations": state.critic_iterations + 1,
190
+ # Routing wizard -> critic przejęty przez graph.py
191
+ }
backend/agents/world_class_advisor.py ADDED
@@ -0,0 +1,628 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ World-class regulation-grounded advisor (pure evaluation path).
3
+
4
+ Checks application content against a structured advisor brief without requiring
5
+ a live LLM — suitable for unit tests and as a hard gate before soft-pass export.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import asdict, dataclass, field
11
+ from typing import Any, Dict, List, Optional, Sequence
12
+
13
+
14
+ @dataclass
15
+ class AdvisorFinding:
16
+ code: str
17
+ severity: str # critical | major | minor | info
18
+ message: str
19
+ category: str # program_alignment | missing_required | quality | grounding
20
+ section: str = ""
21
+ blocking: bool = False
22
+
23
+ def to_dict(self) -> Dict[str, Any]:
24
+ return asdict(self)
25
+
26
+
27
+ @dataclass
28
+ class AdvisorReport:
29
+ passed: bool
30
+ score: int
31
+ grounding_mode: str
32
+ regulation_grounded_pass: bool
33
+ findings: List[AdvisorFinding] = field(default_factory=list)
34
+ blockers: List[str] = field(default_factory=list)
35
+ covered_sections: List[str] = field(default_factory=list)
36
+ missing_sections: List[str] = field(default_factory=list)
37
+ missing_attachments_mentioned: List[str] = field(default_factory=list)
38
+ attention_addressed: List[str] = field(default_factory=list)
39
+ attention_open: List[str] = field(default_factory=list)
40
+ brief_usable: bool = False
41
+ summary: str = ""
42
+
43
+ def to_dict(self) -> Dict[str, Any]:
44
+ d = asdict(self)
45
+ d["findings"] = [f if isinstance(f, dict) else f.to_dict() for f in self.findings]
46
+ return d
47
+
48
+
49
+ def _norm(s: str) -> str:
50
+ return re.sub(r"\s+", " ", (s or "").lower().strip())
51
+
52
+
53
+ # Generic tokens that must NOT count as rule hits (corporate fluff matches these)
54
+ _RULE_STOPWORDS = frozenset(
55
+ {
56
+ "wnioskodawca",
57
+ "beneficjent",
58
+ "projekt",
59
+ "projekty",
60
+ "musi",
61
+ "muszą",
62
+ "powinien",
63
+ "powinna",
64
+ "posiadać",
65
+ "posiada",
66
+ "status",
67
+ "oraz",
68
+ "przez",
69
+ "który",
70
+ "która",
71
+ "które",
72
+ "zgodnie",
73
+ "zawierać",
74
+ "zawiera",
75
+ "następujące",
76
+ "elementy",
77
+ "wymagane",
78
+ "wymagany",
79
+ "opis",
80
+ "treść",
81
+ "sekcja",
82
+ "program",
83
+ "naboru",
84
+ "regulamin",
85
+ "sprawdź",
86
+ "potwierdź",
87
+ "udokumentuj",
88
+ "przygotuj",
89
+ "zweryfikuj",
90
+ "uwzględnij",
91
+ "punkt",
92
+ "uwagi",
93
+ "minimum",
94
+ "wynosi",
95
+ "kosztów",
96
+ "koszty",
97
+ "koszt",
98
+ "zasada",
99
+ "spełniać",
100
+ "spełnia",
101
+ }
102
+ )
103
+
104
+ # Domain signals that, if present in brief, must appear in the application
105
+ _CORE_SIGNAL_GROUPS: List[tuple[str, tuple[str, ...]]] = [
106
+ ("mśp", ("mśp", "msp", "mikroprzedsiębior", "małe przedsiębior", "średnie przedsiębior", "mikro firma")),
107
+ ("dnsh", ("dnsh", "do no significant harm", "significant harm", "wpływ na środowisko", "wpływ środowisk")),
108
+ ("wkład_własny", ("wkład własny", "wklad wlasny", "finansowanie własne", "finansowanie wlasne")),
109
+ ("de_minimis", ("de minimis", "pomoc publiczna", "pomocy publicznej")),
110
+ ("trl", ("trl", "gotowości technologicz", "gotowosci technologicz")),
111
+ ("niekwalifikowalne", ("niekwalifikow", "koszty niekwalifikowalne")),
112
+ ]
113
+
114
+
115
+ def _blob_from_sections(sections: Optional[Dict[str, str]], document_text: str = "") -> str:
116
+ parts: List[str] = []
117
+ if document_text:
118
+ parts.append(document_text)
119
+ if sections:
120
+ for title, body in sections.items():
121
+ parts.append(f"## {title}\n{body or ''}")
122
+ return "\n".join(parts)
123
+
124
+
125
+ def _section_present(required: str, sections: Dict[str, str], blob: str) -> bool:
126
+ """True if required section has meaningful content under a matching title."""
127
+ nr = _norm(required)
128
+ if not nr:
129
+ return True
130
+ # Direct title match with substantial body only (no free-text weak match)
131
+ for title, body in (sections or {}).items():
132
+ nt = _norm(title)
133
+ if nr in nt or nt in nr or difflib_ratio(nr, nt) >= 0.55:
134
+ if body and len(body.strip()) >= 80 and "[UZUPEŁNIĆ" not in body:
135
+ return True
136
+ return False
137
+
138
+
139
+ def difflib_ratio(a: str, b: str) -> float:
140
+ import difflib
141
+
142
+ return difflib.SequenceMatcher(None, a, b).ratio()
143
+
144
+
145
+ def _distinctive_terms_from_rule(rule: str) -> List[str]:
146
+ """
147
+ Extract distinctive multi-token phrases / domain terms from a rule.
148
+ Drops stopwords so 'Projekt musi…' alone never counts as alignment.
149
+ """
150
+ n = _norm(rule)
151
+ terms: List[str] = []
152
+ # Prefer known domain multi-word / acronyms first
153
+ for _name, variants in _CORE_SIGNAL_GROUPS:
154
+ for v in variants:
155
+ if v in n:
156
+ terms.append(v)
157
+ # Multi-word chunks of 2–3 content words
158
+ words = re.findall(r"[a-ząćęłńóśźż0-9%]{3,}", n)
159
+ content = [w for w in words if w not in _RULE_STOPWORDS and len(w) >= 4]
160
+ for i in range(len(content) - 1):
161
+ bigram = f"{content[i]} {content[i + 1]}"
162
+ if bigram not in terms:
163
+ terms.append(bigram)
164
+ # Long single tokens (≥7) that aren't stopwords — acronyms like mśp already handled
165
+ for w in content:
166
+ if len(w) >= 7 and w not in terms and w not in _RULE_STOPWORDS:
167
+ terms.append(w)
168
+ return terms[:12]
169
+
170
+
171
+ def rule_is_addressed(rule: str, blob: str) -> bool:
172
+ """True only when distinctive signal(s) from the rule appear in application text."""
173
+ b = _norm(blob)
174
+ if not b or not rule:
175
+ return False
176
+ terms = _distinctive_terms_from_rule(rule)
177
+ if not terms:
178
+ # No distinctive content in rule → cannot claim hit from fluff
179
+ return False
180
+ # Need at least one multi-word term OR two distinct single-domain hits
181
+ multi = [t for t in terms if " " in t or any(t in g[1] for g in _CORE_SIGNAL_GROUPS)]
182
+ hits = [t for t in terms if t in b]
183
+ if not hits:
184
+ return False
185
+ if any(t in b for t in multi):
186
+ return True
187
+ # Single long distinctive tokens: require ≥2 different hits for generic rules
188
+ single_hits = [t for t in hits if " " not in t and len(t) >= 7]
189
+ return len(set(single_hits)) >= 2 or (len(single_hits) >= 1 and any(t in b for t in multi))
190
+
191
+
192
+ def core_signals_required_by_brief(brief: Dict[str, Any]) -> List[str]:
193
+ """Which core domain signals appear in brief (rules + attention + eligibility)."""
194
+ blob = " ".join(
195
+ [
196
+ " ".join(str(x) for x in (brief.get("key_rules") or [])),
197
+ " ".join(str(x) for x in (brief.get("attention_points") or [])),
198
+ " ".join(str(x) for x in (brief.get("eligibility_signals") or [])),
199
+ " ".join(str(x) for x in (brief.get("funding_limits") or [])),
200
+ ]
201
+ )
202
+ n = _norm(blob)
203
+ required: List[str] = []
204
+ for name, variants in _CORE_SIGNAL_GROUPS:
205
+ if any(v in n for v in variants):
206
+ required.append(name)
207
+ return required
208
+
209
+
210
+ def core_signal_present(name: str, blob: str) -> bool:
211
+ b = _norm(blob)
212
+ for gname, variants in _CORE_SIGNAL_GROUPS:
213
+ if gname == name:
214
+ return any(v in b for v in variants)
215
+ return False
216
+
217
+
218
+ def _attention_is_critical(point: str) -> bool:
219
+ p = _norm(point)
220
+ critical_markers = (
221
+ "dnsh",
222
+ "środowisk",
223
+ "srodowisk",
224
+ "mśp",
225
+ "msp",
226
+ "wkład",
227
+ "własn",
228
+ "wlasn",
229
+ "de minimis",
230
+ "pomoc publiczn",
231
+ "trl",
232
+ "niekwalifikow",
233
+ )
234
+ return any(m in p for m in critical_markers)
235
+
236
+
237
+ def _attention_addressed(point: str, blob: str) -> bool:
238
+ p = _norm(point)
239
+ b = _norm(blob)
240
+ keywords: List[str] = []
241
+ if "dnsh" in p or "środowisk" in p or "srodowisk" in p:
242
+ keywords = ["dnsh", "do no significant harm", "wpływ na środowisko", "wpływ środowisk", "środowisk", "srodowisk"]
243
+ elif "mśp" in p or "msp" in p:
244
+ keywords = ["mśp", "msp", "mikroprzedsiębior", "małe przedsiębior", "średnie przedsiębior"]
245
+ elif "wkład" in p or "własn" in p or "wlasn" in p:
246
+ keywords = ["wkład własny", "wklad wlasny", "finansowanie własne", "finansowanie wlasne"]
247
+ elif "de minimis" in p or "pomoc publiczn" in p:
248
+ keywords = ["de minimis", "pomoc publiczna", "pomocy publicznej"]
249
+ elif "trl" in p:
250
+ keywords = ["trl", "gotowości technologicz", "gotowosci technologicz"]
251
+ elif "załącznik" in p or "zalacznik" in p:
252
+ keywords = ["załącznik", "zalacznik", "oświadczenie", "oswiadczenie"]
253
+ elif "niekwalifikow" in p:
254
+ keywords = ["niekwalifikow", "koszty niekwalifikowalne"]
255
+ else:
256
+ # Require multi-token distinctive match, not lone stopwords
257
+ keywords = _distinctive_terms_from_rule(point)[:4]
258
+ if not keywords:
259
+ return False
260
+ return any(k in b for k in keywords)
261
+
262
+
263
+ def evaluate_application(
264
+ *,
265
+ document_text: str = "",
266
+ sections: Optional[Dict[str, str]] = None,
267
+ brief: Optional[Dict[str, Any]] = None,
268
+ grounding_mode: str = "regulation",
269
+ min_score: int = 70,
270
+ min_section_chars: int = 80,
271
+ ) -> AdvisorReport:
272
+ """
273
+ Evaluate application against regulation-derived brief.
274
+
275
+ structure_only / blocked / blind modes never yield regulation_grounded_pass=True.
276
+ """
277
+ brief = brief if isinstance(brief, dict) else {}
278
+ sections = sections if isinstance(sections, dict) else {}
279
+ mode = (grounding_mode or "regulation").lower().strip()
280
+ blob = _blob_from_sections(sections, document_text)
281
+ findings: List[AdvisorFinding] = []
282
+ score = 100
283
+
284
+ # --- Grounding hard rules ---
285
+ if mode in ("structure_only", "blocked", "blind"):
286
+ findings.append(
287
+ AdvisorFinding(
288
+ code="GROUNDING_NOT_REGULATION",
289
+ severity="critical",
290
+ message=(
291
+ f"Tryb {mode}: ocena nie może zakończyć się regulation-grounded pass. "
292
+ "Brak ugruntowania w regulaminie naboru."
293
+ ),
294
+ category="grounding",
295
+ blocking=True,
296
+ )
297
+ )
298
+ score -= 40
299
+
300
+ usable = bool(brief.get("usable")) if "usable" in brief else (
301
+ bool(brief.get("key_rules") or brief.get("required_sections") or brief.get("required_attachments"))
302
+ )
303
+ if mode == "regulation" and not usable and not (
304
+ brief.get("key_rules") or brief.get("required_sections")
305
+ ):
306
+ findings.append(
307
+ AdvisorFinding(
308
+ code="BRIEF_EMPTY",
309
+ severity="critical",
310
+ message="Brief doradcy pusty — brak reguł/sekcji z regulaminu. Nie można ugruntować oceny.",
311
+ category="grounding",
312
+ blocking=True,
313
+ )
314
+ )
315
+ score -= 35
316
+
317
+ # --- Instrument mismatch (Eurogranty vs SMART modules etc.) ---
318
+ try:
319
+ from core.projects.instrument_profile import (
320
+ detect_instrument_mismatch,
321
+ resolve_program_type,
322
+ )
323
+
324
+ prog_type = str(
325
+ brief.get("program_type")
326
+ or (brief.get("instrument_program_type") if isinstance(brief, dict) else "")
327
+ or ""
328
+ )
329
+ # Allow caller to pass via document_text meta later; also scan section titles
330
+ mismatch = detect_instrument_mismatch(
331
+ program_type=prog_type or resolve_program_type(program_name=str(brief.get("name") or "")),
332
+ document_text=blob,
333
+ section_titles=list(sections.keys()),
334
+ )
335
+ if mismatch.get("mismatch"):
336
+ for msg in mismatch.get("findings") or []:
337
+ findings.append(
338
+ AdvisorFinding(
339
+ code="INSTRUMENT_MISMATCH",
340
+ severity="critical",
341
+ message=msg,
342
+ category="program_alignment",
343
+ blocking=bool(mismatch.get("blocking")),
344
+ )
345
+ )
346
+ score -= int(mismatch.get("score_penalty") or 0)
347
+ except Exception:
348
+ pass
349
+
350
+ # --- Required sections (program alignment + missing elements) ---
351
+ required_sections = list(brief.get("required_sections") or [])
352
+ covered: List[str] = []
353
+ missing: List[str] = []
354
+ for req in required_sections:
355
+ if _section_present(req, sections, blob):
356
+ covered.append(req)
357
+ else:
358
+ missing.append(req)
359
+ findings.append(
360
+ AdvisorFinding(
361
+ code="MISSING_REQUIRED_SECTION",
362
+ severity="critical",
363
+ message=f"Brak wymaganej sekcji/treści: {req}",
364
+ category="missing_required",
365
+ section=req,
366
+ blocking=True,
367
+ )
368
+ )
369
+ score -= 12
370
+
371
+ # --- Key rules: distinctive multi-token / domain coverage (no fluff hits) ---
372
+ rules = list(brief.get("key_rules") or [])
373
+ rules_hit = 0
374
+ rules_checked = rules[:12]
375
+ for rule in rules_checked:
376
+ if rule_is_addressed(rule, blob):
377
+ rules_hit += 1
378
+ if rules_checked and mode == "regulation":
379
+ rule_ratio = rules_hit / max(len(rules_checked), 1)
380
+ if rule_ratio < 0.5:
381
+ findings.append(
382
+ AdvisorFinding(
383
+ code="WEAK_RULE_ALIGNMENT",
384
+ severity="critical" if rule_ratio < 0.35 else "major",
385
+ message=(
386
+ f"Słabe dopasowanie do reguł regulaminu "
387
+ f"({rules_hit}/{len(rules_checked)} reguł z distinctive signals w treści)."
388
+ ),
389
+ category="program_alignment",
390
+ blocking=rule_ratio < 0.5,
391
+ )
392
+ )
393
+ score -= 25 if rule_ratio < 0.35 else 12
394
+ elif rule_ratio >= 0.7:
395
+ score = min(100, score + 5)
396
+
397
+ # --- Core domain signals required by brief must appear in application ---
398
+ if mode == "regulation":
399
+ for sig in core_signals_required_by_brief(brief):
400
+ if not core_signal_present(sig, blob):
401
+ findings.append(
402
+ AdvisorFinding(
403
+ code="MISSING_CORE_SIGNAL",
404
+ severity="critical",
405
+ message=f"Brak kluczowego sygnału regulaminu w treści wniosku: {sig}",
406
+ category="program_alignment",
407
+ blocking=True,
408
+ )
409
+ )
410
+ score -= 15
411
+
412
+ # --- Attachments mentioned when brief requires them ---
413
+ missing_att: List[str] = []
414
+ for att in list(brief.get("required_attachments") or [])[:10]:
415
+ na = _norm(att)[:40]
416
+ if na and na[:12] not in _norm(blob):
417
+ # Also check generic "załącznik" coverage
418
+ if "załącznik" not in _norm(blob) and "zalacznik" not in _norm(blob):
419
+ missing_att.append(att)
420
+ findings.append(
421
+ AdvisorFinding(
422
+ code="ATTACHMENT_NOT_ADDRESSED",
423
+ severity="major",
424
+ message=f"Brak odniesienia do wymaganego załącznika: {att}",
425
+ category="missing_required",
426
+ blocking=False,
427
+ )
428
+ )
429
+ score -= 4
430
+
431
+ # --- Attention points (critical ones block regulation pass) ---
432
+ attention = list(brief.get("attention_points") or [])
433
+ addressed: List[str] = []
434
+ open_pts: List[str] = []
435
+ for pt in attention:
436
+ if _attention_addressed(pt, blob):
437
+ addressed.append(pt)
438
+ else:
439
+ open_pts.append(pt)
440
+ critical = _attention_is_critical(pt)
441
+ findings.append(
442
+ AdvisorFinding(
443
+ code="ATTENTION_OPEN",
444
+ severity="critical" if critical else "minor",
445
+ message=f"Punkt uwagi regulaminu niezaadresowany: {pt}",
446
+ category="quality",
447
+ blocking=critical,
448
+ )
449
+ )
450
+ score -= 12 if critical else 3
451
+
452
+ # --- Critical quality / readiness (empty/short sections) ---
453
+ short_sections = 0
454
+ for title, body in sections.items():
455
+ body = body or ""
456
+ if len(body.strip()) < min_section_chars or "[UZUPEŁNIĆ" in body:
457
+ short_sections += 1
458
+ findings.append(
459
+ AdvisorFinding(
460
+ code="SECTION_TOO_THIN",
461
+ severity="major",
462
+ message=f"Sekcja zbyt krótka lub niekompletna: {title}",
463
+ category="quality",
464
+ section=title,
465
+ blocking=len(body.strip()) < 20,
466
+ )
467
+ )
468
+ score -= 6
469
+ if not sections and len(blob.strip()) < 120:
470
+ findings.append(
471
+ AdvisorFinding(
472
+ code="DOCUMENT_EMPTY",
473
+ severity="critical",
474
+ message="Dokument wniosku pusty lub zbyt krótki.",
475
+ category="quality",
476
+ blocking=True,
477
+ )
478
+ )
479
+ score -= 40
480
+
481
+ score = max(0, min(100, score))
482
+ blockers = [f.message for f in findings if f.blocking]
483
+ critical = [f for f in findings if f.severity == "critical"]
484
+
485
+ # Explicit: never soft-pass structure_only / blind / blocked as regulation-grounded
486
+ if mode in ("structure_only", "blocked", "blind"):
487
+ regulation_grounded_pass = False
488
+ else:
489
+ regulation_grounded_pass = (
490
+ mode == "regulation"
491
+ and usable
492
+ and score >= min_score
493
+ and not blockers
494
+ and len(critical) == 0
495
+ )
496
+
497
+ if mode == "regulation":
498
+ passed = regulation_grounded_pass
499
+ elif mode == "structure_only":
500
+ # Structural readiness only — never regulation_grounded_pass
501
+ passed = (
502
+ len(blob.strip()) >= 200
503
+ and short_sections == 0
504
+ and score >= max(40, min_score - 25)
505
+ and not any(f.code == "DOCUMENT_EMPTY" for f in findings)
506
+ )
507
+ else:
508
+ passed = False
509
+
510
+ summary_bits = [
511
+ f"score={score}",
512
+ f"mode={mode}",
513
+ f"missing_sections={len(missing)}",
514
+ f"blockers={len(blockers)}",
515
+ f"regulation_grounded_pass={regulation_grounded_pass}",
516
+ ]
517
+ return AdvisorReport(
518
+ passed=passed,
519
+ score=score,
520
+ grounding_mode=mode,
521
+ regulation_grounded_pass=regulation_grounded_pass,
522
+ findings=findings,
523
+ blockers=blockers,
524
+ covered_sections=covered,
525
+ missing_sections=missing,
526
+ missing_attachments_mentioned=missing_att,
527
+ attention_addressed=addressed,
528
+ attention_open=open_pts,
529
+ brief_usable=usable,
530
+ summary="; ".join(summary_bits),
531
+ )
532
+
533
+
534
+ def advisor_findings_to_rewrite_targets(
535
+ report: AdvisorReport | Dict[str, Any],
536
+ plan_titles: Sequence[str],
537
+ ) -> Dict[str, List[str]]:
538
+ """Map advisor findings onto quality_loop section targets."""
539
+ from core.generation.quality_loop import section_title_match
540
+
541
+ if isinstance(report, AdvisorReport):
542
+ findings = report.findings
543
+ missing = report.missing_sections
544
+ else:
545
+ findings = report.get("findings") or []
546
+ missing = report.get("missing_sections") or []
547
+
548
+ targets: Dict[str, List[str]] = {}
549
+ for req in missing:
550
+ matched = section_title_match(str(req), plan_titles)
551
+ note = f"[world_class_advisor] Uzupełnij wymaganą treść regulaminu: {req}"
552
+ if matched:
553
+ targets.setdefault(matched, []).append(note)
554
+ elif plan_titles:
555
+ targets.setdefault(list(plan_titles)[0], []).append(note)
556
+
557
+ for f in findings:
558
+ if isinstance(f, AdvisorFinding):
559
+ code, msg, section, sev = f.code, f.message, f.section, f.severity
560
+ elif isinstance(f, dict):
561
+ code = f.get("code") or "FINDING"
562
+ msg = f.get("message") or ""
563
+ section = f.get("section") or ""
564
+ sev = f.get("severity") or "major"
565
+ else:
566
+ continue
567
+ if code in ("GROUNDING_NOT_REGULATION", "BRIEF_EMPTY"):
568
+ # global note on all titles (limited later by pick)
569
+ for t in plan_titles:
570
+ targets.setdefault(t, []).append(f"[world_class_advisor|{sev}] {msg}")
571
+ break
572
+ matched = section_title_match(section, plan_titles) if section else None
573
+ line = f"[world_class_advisor|{sev}|{code}] {msg}"
574
+ if matched:
575
+ targets.setdefault(matched, []).append(line)
576
+ elif plan_titles and sev == "critical":
577
+ # Global alignment/core-signal issues: attach to budget/alignment-like titles if any,
578
+ # never blindly rewrite the first healthy section (e.g. full Wstęp).
579
+ if code in ("WEAK_RULE_ALIGNMENT", "MISSING_CORE_SIGNAL"):
580
+ domain_keys = ("budżet", "budzet", "finans", "koszt", "opis", "innowacj", "dopasow")
581
+ hit_any = False
582
+ for t in plan_titles:
583
+ nt = _norm(t)
584
+ if any(k in nt for k in domain_keys):
585
+ targets.setdefault(t, []).append(line)
586
+ hit_any = True
587
+ if not hit_any:
588
+ # last resort: last plan title (often budget/closing), not first intro
589
+ targets.setdefault(list(plan_titles)[-1], []).append(line)
590
+ else:
591
+ targets.setdefault(list(plan_titles)[0], []).append(line)
592
+ return targets
593
+
594
+
595
+ def evaluate_from_generator_state(state: Dict[str, Any]) -> AdvisorReport:
596
+ """Convenience: build brief + sections from generator/external_context state."""
597
+ ext = state.get("external_context") if isinstance(state.get("external_context"), dict) else {}
598
+ generated = state.get("generated_sections") if isinstance(state.get("generated_sections"), dict) else {}
599
+ mode = str(ext.get("grounding_mode") or state.get("grounding_mode") or "regulation").lower()
600
+ brief = dict(ext.get("advisor_brief") or {}) if isinstance(ext.get("advisor_brief"), dict) else {}
601
+ if not brief:
602
+ brief = {
603
+ "key_rules": list(ext.get("regulation_key_rules") or ext.get("key_rules") or []),
604
+ "required_sections": list(ext.get("required_sections") or []),
605
+ "required_attachments": list(ext.get("required_attachments") or []),
606
+ "attention_points": list(ext.get("attention_points") or []),
607
+ }
608
+ brief["usable"] = bool(
609
+ brief["key_rules"] or brief["required_sections"] or brief["required_attachments"]
610
+ )
611
+ # Instrument family for mismatch detection
612
+ try:
613
+ from core.projects.instrument_profile import resolve_program_type
614
+
615
+ brief["program_type"] = resolve_program_type(
616
+ program_type=str(ext.get("instrument_program_type") or ext.get("program_type") or ""),
617
+ program_name=str(ext.get("program_name") or ext.get("grant_name") or ""),
618
+ grant_id=str(ext.get("grant_id") or ""),
619
+ )
620
+ brief["name"] = str(ext.get("program_name") or ext.get("grant_name") or "")
621
+ except Exception:
622
+ pass
623
+ return evaluate_application(
624
+ sections=generated,
625
+ document_text=state.get("full_document") or "",
626
+ brief=brief,
627
+ grounding_mode=mode,
628
+ )
backend/alembic.ini ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # A generic, single database configuration.
2
+
3
+ [alembic]
4
+ # path to migration scripts.
5
+ # this is typically a path given in POSIX (e.g. forward slashes)
6
+ # format, relative to the token %(here)s which refers to the location of this
7
+ # ini file
8
+ script_location = %(here)s/alembic
9
+
10
+ # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
11
+ # Uncomment the line below if you want the files to be prepended with date and time
12
+ # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
13
+ # for all available tokens
14
+ # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
15
+ # Or organize into date-based subdirectories (requires recursive_version_locations = true)
16
+ # file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
17
+
18
+ # sys.path path, will be prepended to sys.path if present.
19
+ # defaults to the current working directory. for multiple paths, the path separator
20
+ # is defined by "path_separator" below.
21
+ prepend_sys_path = .
22
+
23
+
24
+ # timezone to use when rendering the date within the migration file
25
+ # as well as the filename.
26
+ # If specified, requires the tzdata library which can be installed by adding
27
+ # `alembic[tz]` to the pip requirements.
28
+ # string value is passed to ZoneInfo()
29
+ # leave blank for localtime
30
+ # timezone =
31
+
32
+ # max length of characters to apply to the "slug" field
33
+ # truncate_slug_length = 40
34
+
35
+ # set to 'true' to run the environment during
36
+ # the 'revision' command, regardless of autogenerate
37
+ # revision_environment = false
38
+
39
+ # set to 'true' to allow .pyc and .pyo files without
40
+ # a source .py file to be detected as revisions in the
41
+ # versions/ directory
42
+ # sourceless = false
43
+
44
+ # version location specification; This defaults
45
+ # to <script_location>/versions. When using multiple version
46
+ # directories, initial revisions must be specified with --version-path.
47
+ # The path separator used here should be the separator specified by "path_separator"
48
+ # below.
49
+ # version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
50
+
51
+ # path_separator; This indicates what character is used to split lists of file
52
+ # paths, including version_locations and prepend_sys_path within configparser
53
+ # files such as alembic.ini.
54
+ # The default rendered in new alembic.ini files is "os", which uses os.pathsep
55
+ # to provide os-dependent path splitting.
56
+ #
57
+ # Note that in order to support legacy alembic.ini files, this default does NOT
58
+ # take place if path_separator is not present in alembic.ini. If this
59
+ # option is omitted entirely, fallback logic is as follows:
60
+ #
61
+ # 1. Parsing of the version_locations option falls back to using the legacy
62
+ # "version_path_separator" key, which if absent then falls back to the legacy
63
+ # behavior of splitting on spaces and/or commas.
64
+ # 2. Parsing of the prepend_sys_path option falls back to the legacy
65
+ # behavior of splitting on spaces, commas, or colons.
66
+ #
67
+ # Valid values for path_separator are:
68
+ #
69
+ # path_separator = :
70
+ # path_separator = ;
71
+ # path_separator = space
72
+ # path_separator = newline
73
+ #
74
+ # Use os.pathsep. Default configuration used for new projects.
75
+ path_separator = os
76
+
77
+ # set to 'true' to search source files recursively
78
+ # in each "version_locations" directory
79
+ # new in Alembic version 1.10
80
+ # recursive_version_locations = false
81
+
82
+ # the output encoding used when revision files
83
+ # are written from script.py.mako
84
+ # output_encoding = utf-8
85
+
86
+ # database URL. This is consumed by the user-maintained env.py script only.
87
+ # other means of configuring database URLs may be customized within the env.py
88
+ # file.
89
+ sqlalchemy.url = driver://user:pass@localhost/dbname
90
+
91
+
92
+ [post_write_hooks]
93
+ # post_write_hooks defines scripts or Python functions that are run
94
+ # on newly generated revision scripts. See the documentation for further
95
+ # detail and examples
96
+
97
+ # format using "black" - use the console_scripts runner, against the "black" entrypoint
98
+ # hooks = black
99
+ # black.type = console_scripts
100
+ # black.entrypoint = black
101
+ # black.options = -l 79 REVISION_SCRIPT_FILENAME
102
+
103
+ # lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
104
+ # hooks = ruff
105
+ # ruff.type = module
106
+ # ruff.module = ruff
107
+ # ruff.options = check --fix REVISION_SCRIPT_FILENAME
108
+
109
+ # Alternatively, use the exec runner to execute a binary found on your PATH
110
+ # hooks = ruff
111
+ # ruff.type = exec
112
+ # ruff.executable = ruff
113
+ # ruff.options = check --fix REVISION_SCRIPT_FILENAME
114
+
115
+ # Logging configuration. This is also consumed by the user-maintained
116
+ # env.py script only.
117
+ [loggers]
118
+ keys = root,sqlalchemy,alembic
119
+
120
+ [handlers]
121
+ keys = console
122
+
123
+ [formatters]
124
+ keys = generic
125
+
126
+ [logger_root]
127
+ level = WARNING
128
+ handlers = console
129
+ qualname =
130
+
131
+ [logger_sqlalchemy]
132
+ level = WARNING
133
+ handlers =
134
+ qualname = sqlalchemy.engine
135
+
136
+ [logger_alembic]
137
+ level = INFO
138
+ handlers =
139
+ qualname = alembic
140
+
141
+ [handler_console]
142
+ class = StreamHandler
143
+ args = (sys.stderr,)
144
+ level = NOTSET
145
+ formatter = generic
146
+
147
+ [formatter_generic]
148
+ format = %(levelname)-5.5s [%(name)s] %(message)s
149
+ datefmt = %H:%M:%S
backend/alembic/README ADDED
@@ -0,0 +1 @@
 
 
1
+ Generic single-database configuration.
backend/alembic/env.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ruff: noqa: E402
2
+ from logging.config import fileConfig
3
+
4
+ from sqlalchemy import engine_from_config
5
+ from sqlalchemy import pool
6
+
7
+ from alembic import context
8
+
9
+ # this is the Alembic Config object, which provides
10
+ # access to the values within the .ini file in use.
11
+ config = context.config
12
+
13
+ # Interpret the config file for Python logging.
14
+ # This line sets up loggers basically.
15
+ if config.config_file_name is not None:
16
+ fileConfig(config.config_file_name)
17
+
18
+ # add your model's MetaData object here
19
+ # for 'autogenerate' support
20
+ import os
21
+ import sys
22
+
23
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
24
+ from dotenv import load_dotenv
25
+
26
+ load_dotenv()
27
+
28
+ # Use the shared init_models() so all models are registered on the single Base
29
+ from core.subscription.db import Base, init_models
30
+ init_models()
31
+
32
+ target_metadata = Base.metadata
33
+
34
+ db_url = os.getenv("DATABASE_URL", "postgresql://user:password@localhost:5432/dotacje")
35
+ if db_url and db_url.startswith("postgres://"):
36
+ db_url = db_url.replace("postgres://", "postgresql://", 1)
37
+
38
+ config.set_main_option("sqlalchemy.url", db_url)
39
+
40
+ # other values from the config, defined by the needs of env.py,
41
+ # can be acquired:
42
+ # my_important_option = config.get_main_option("my_important_option")
43
+ # ... etc.
44
+
45
+
46
+ def run_migrations_offline() -> None:
47
+ """Run migrations in 'offline' mode.
48
+
49
+ This configures the context with just a URL
50
+ and not an Engine, though an Engine is acceptable
51
+ here as well. By skipping the Engine creation
52
+ we don't even need a DBAPI to be available.
53
+
54
+ Calls to context.execute() here emit the given string to the
55
+ script output.
56
+
57
+ """
58
+ url = config.get_main_option("sqlalchemy.url")
59
+ context.configure(
60
+ url=url,
61
+ target_metadata=target_metadata,
62
+ literal_binds=True,
63
+ dialect_opts={"paramstyle": "named"},
64
+ )
65
+
66
+ with context.begin_transaction():
67
+ context.run_migrations()
68
+
69
+
70
+ def run_migrations_online() -> None:
71
+ """Run migrations in 'online' mode.
72
+
73
+ In this scenario we need to create an Engine
74
+ and associate a connection with the context.
75
+
76
+ """
77
+ connectable = engine_from_config(
78
+ config.get_section(config.config_ini_section, {}),
79
+ prefix="sqlalchemy.",
80
+ poolclass=pool.NullPool,
81
+ )
82
+
83
+ with connectable.connect() as connection:
84
+ context.configure(connection=connection, target_metadata=target_metadata)
85
+
86
+ with context.begin_transaction():
87
+ context.run_migrations()
88
+
89
+
90
+ if context.is_offline_mode():
91
+ run_migrations_offline()
92
+ else:
93
+ run_migrations_online()
backend/alembic/script.py.mako ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+ ${imports if imports else ""}
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = ${repr(up_revision)}
16
+ down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
17
+ branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
18
+ depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
19
+
20
+
21
+ def upgrade() -> None:
22
+ """Upgrade schema."""
23
+ ${upgrades if upgrades else "pass"}
24
+
25
+
26
+ def downgrade() -> None:
27
+ """Downgrade schema."""
28
+ ${downgrades if downgrades else "pass"}
backend/alembic/versions/0848fd2356d9_sprint2.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """sprint2
2
+
3
+ Revision ID: 0848fd2356d9
4
+ Revises: 3109c5f526b6
5
+ Create Date: 2026-04-14 16:24:08.025520
6
+
7
+ """
8
+
9
+ from typing import Sequence, Union
10
+
11
+ from alembic import op
12
+ import sqlalchemy as sa
13
+
14
+
15
+ # revision identifiers, used by Alembic.
16
+ revision: str = "0848fd2356d9"
17
+ down_revision: Union[str, Sequence[str], None] = "3109c5f526b6"
18
+ branch_labels: Union[str, Sequence[str], None] = None
19
+ depends_on: Union[str, Sequence[str], None] = None
20
+
21
+
22
+ def upgrade() -> None:
23
+ """Upgrade schema."""
24
+ # ### commands auto generated by Alembic - please adjust! ###
25
+ op.add_column(
26
+ "users", sa.Column("gdpr_consent_accepted", sa.Boolean(), nullable=True)
27
+ )
28
+ op.add_column(
29
+ "users", sa.Column("gdpr_consent_timestamp", sa.DateTime(), nullable=True)
30
+ )
31
+ op.add_column(
32
+ "users", sa.Column("ai_disclaimer_enabled", sa.Boolean(), nullable=True)
33
+ )
34
+ # ### end Alembic commands ###
35
+
36
+
37
+ def downgrade() -> None:
38
+ """Downgrade schema."""
39
+ # ### commands auto generated by Alembic - please adjust! ###
40
+ op.drop_column("users", "ai_disclaimer_enabled")
41
+ op.drop_column("users", "gdpr_consent_timestamp")
42
+ op.drop_column("users", "gdpr_consent_accepted")
43
+ # ### end Alembic commands ###
backend/alembic/versions/0e48eb7134d7_add_final_document_columns.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """add final document columns
2
+
3
+ Revision ID: 0e48eb7134d7
4
+ Revises: e1922a470e92
5
+ Create Date: 2026-04-11 09:48:13.226601
6
+
7
+ """
8
+
9
+ from typing import Sequence, Union
10
+
11
+ from alembic import op
12
+ import sqlalchemy as sa
13
+
14
+
15
+ # revision identifiers, used by Alembic.
16
+ revision: str = "0e48eb7134d7"
17
+ down_revision: Union[str, Sequence[str], None] = "e1922a470e92"
18
+ branch_labels: Union[str, Sequence[str], None] = None
19
+ depends_on: Union[str, Sequence[str], None] = None
20
+
21
+
22
+ def upgrade() -> None:
23
+ """Upgrade schema."""
24
+ # ### commands auto generated by Alembic - please adjust! ###
25
+ op.add_column(
26
+ "projects", sa.Column("final_document_markdown", sa.Text(), nullable=True)
27
+ )
28
+ op.add_column(
29
+ "projects",
30
+ sa.Column("final_document_generated_at", sa.DateTime(), nullable=True),
31
+ )
32
+ # ### end Alembic commands ###
33
+
34
+
35
+ def downgrade() -> None:
36
+ """Downgrade schema."""
37
+ # ### commands auto generated by Alembic - please adjust! ###
38
+ op.drop_column("projects", "final_document_generated_at")
39
+ op.drop_column("projects", "final_document_markdown")
40
+ # ### end Alembic commands ###
backend/alembic/versions/0f91b1724111_add_external_context_to_projects.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Add external_context to projects
2
+
3
+ Revision ID: 0f91b1724111
4
+ Revises: 0e48eb7134d7
5
+ Create Date: 2026-04-12 14:15:10.107090
6
+
7
+ """
8
+
9
+ from typing import Sequence, Union
10
+
11
+ from alembic import op
12
+ import sqlalchemy as sa
13
+
14
+
15
+ # revision identifiers, used by Alembic.
16
+ revision: str = "0f91b1724111"
17
+ down_revision: Union[str, Sequence[str], None] = "0e48eb7134d7"
18
+ branch_labels: Union[str, Sequence[str], None] = None
19
+ depends_on: Union[str, Sequence[str], None] = None
20
+
21
+
22
+ def upgrade() -> None:
23
+ """Upgrade schema."""
24
+ # ### commands auto generated by Alembic - please adjust! ###
25
+ op.add_column(
26
+ "projects", sa.Column("final_document_audit_result", sa.JSON(), nullable=True)
27
+ )
28
+ op.add_column("projects", sa.Column("external_context", sa.JSON(), nullable=True))
29
+ # ### end Alembic commands ###
30
+
31
+
32
+ def downgrade() -> None:
33
+ """Downgrade schema."""
34
+ # ### commands auto generated by Alembic - please adjust! ###
35
+ op.drop_column("projects", "external_context")
36
+ op.drop_column("projects", "final_document_audit_result")
37
+ # ### end Alembic commands ###
backend/alembic/versions/20260527_add_regulation_snapshots.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Add regulation_snapshots table (Faza 3 - Regulation Engine primary storage)
2
+
3
+ Revision ID: 20260527_regsnap
4
+ Revises: f3a9d2c1e005
5
+ Create Date: 2026-05-27
6
+
7
+ """
8
+ from alembic import op
9
+ import sqlalchemy as sa
10
+ from sqlalchemy.dialects import postgresql
11
+
12
+ # revision identifiers, used by Alembic.
13
+ revision = '20260527_regsnap'
14
+ down_revision = 'f3a9d2c1e005'
15
+ branch_labels = None
16
+ depends_on = None
17
+
18
+
19
+ def upgrade() -> None:
20
+ op.create_table(
21
+ 'regulation_snapshots',
22
+ sa.Column('id', sa.String(), primary_key=True, index=True),
23
+ sa.Column('program', sa.String(), index=True, nullable=False),
24
+ sa.Column('call_name', sa.String()),
25
+ sa.Column('source_url', sa.String()),
26
+ sa.Column('fetched_at', sa.String()),
27
+ sa.Column('version_hash', sa.String(), unique=True, index=True),
28
+ sa.Column('effective_date', sa.String()),
29
+ sa.Column('document_version', sa.String()),
30
+ sa.Column('last_updated', sa.String()),
31
+ sa.Column('source_institution', sa.String()),
32
+ sa.Column('key_rules', postgresql.JSON(astext_type=sa.Text()), nullable=True),
33
+ sa.Column('exclusions', postgresql.JSON(astext_type=sa.Text()), nullable=True),
34
+ sa.Column('scoring_criteria', postgresql.JSON(astext_type=sa.Text()), nullable=True),
35
+ sa.Column('required_attachments', postgresql.JSON(astext_type=sa.Text()), nullable=True),
36
+ sa.Column('raw_text_sample', sa.Text()),
37
+ sa.Column('metadata', postgresql.JSON(astext_type=sa.Text()), nullable=True),
38
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
39
+ )
40
+
41
+
42
+ def downgrade() -> None:
43
+ op.drop_table('regulation_snapshots')