GrantForge Bot
Deploy to Hugging Face
afd56bc
# ruff: noqa: E402
import uuid
from typing import List, Optional, Any
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel, Field, ConfigDict
from core.subscription.db import SessionLocal
from core.subscription.middleware import verify_token
from core.projects.models import (
Project,
ProjectSection,
ProjectSectionVersion,
ProjectQuestion,
ProjectSectionTemplate,
ProjectChatMessage,
ProjectExportVersion,
)
from core.subscription.models import User
from fastapi import BackgroundTasks
from agents.helpers import generate_section, review_section, project_qa_agent
from agents.auditor import audit_final_document
from core.llm_router import get_llm
from core.subscription.tracker import increment_wizard_iteration
from langchain_core.prompts import PromptTemplate
import json
import os
from langchain_core.tracers.langchain import LangChainTracer
import logging
logger = logging.getLogger(__name__)
# Włącz tracing LangSmith
os.environ["LANGCHAIN_TRACING_V2"] = "false"
os.environ["LANGCHAIN_PROJECT"] = "grantforge-production"
# Opcjonalnie – jeśli chcesz zobaczyć dokładne nazwy runów
tracer = LangChainTracer(project_name="grantforge-production")
router = APIRouter(prefix="/api/projects", tags=["projects"])
UNIVERSAL_FALLBACK_MAP = {
# Legacy & common sections
"project_summary": "Streszczenie Projektu",
"company_potential": "Opis przedsiębiorstwa i potencjał",
"innovation_description": "Opis innowacji / B+R",
"market_analysis": "Analiza rynku i konkurencji",
"research_agenda": "Agenda badawcza / cele",
"trl_levels": "Poziom gotowości technologii (TRL)",
"budget_and_costs": "Budżet i kwalifikowalność kosztów",
"work_schedule": "Harmonogram rzeczowo-finansowy",
"project_team": "Zespół projektowy",
"risk_management": "Zarządzanie ryzykiem",
"social_impact_dnsh": "Wpływ społeczny i środowiskowy (DNSH)",
"intellectual_property": "Prawa własności intelektualnej",
"success_metrics": "Wskaźniki sukcesu i ewaluacja",
"final_document": "Dokument końcowy",
"company_overview": "Przegląd firmy",
"company": "Informacje o firmie",
"dnsh": "Zasada DNSH",
"risk": "Analiza Ryzyka",
"schedule": "Harmonogram Projektu",
"kpi": "Wskaźniki KPI",
"budget_details": "Szczegóły Budżetu",
# SMART sections
"applicant": "Wnioskodawca i powiązania",
"team": "Zespół zarządzający i projektowy",
"market": "Analiza zapotrzebowania i rynku",
"innovation": "Innowacyjność (TRL) i znaczenie projektu",
"module_br": "Moduł B+R: Plan prac",
"module_implementation": "Moduł Wdrożenie Innowacji",
"module_infrastructure": "Moduł Infrastruktura B+R",
"module_digitalization": "Moduł Cyfryzacja",
"module_green": "Moduł Zazielenienie przedsiębiorstw",
"module_internationalization": "Moduł Internacjonalizacja",
"module_competence": "Moduł Kompetencje",
"sustainable": "Zrównoważony rozwój i zasada DNSH",
"budget": "Budżet",
"kpi_risk": "Wskaźniki KPI i analiza ryzyka",
"attachments": "Załączniki i Oświadczenia",
# ARIMR
"description": "Opis inwestycji",
"animals": "Dobrostan zwierząt / modernizacja",
"profitability": "Analiza opłacalności",
"environment": "Wpływ na środowisko",
"technical_attachments": "Załączniki techniczne",
# ZUS_BHP
"risk_assessment": "Ocena ryzyka zawodowego",
"improvement_plan": "Plan poprawy warunków pracy",
"scope": "Zakres inwestycji BHP",
"results": "Oczekiwane efekty",
}
# Schemas
class ProjectCreate(BaseModel):
title: str = Field(..., title="Tytuł wniosku")
program_type: str = Field(
..., title="Identyfikator modułu/rodzaju programu (np. SMART)"
)
description: Optional[str] = None
program_name: Optional[str] = None
estimated_value: Optional[float] = None
external_context: Optional[dict] = None
class ProjectUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
status: Optional[str] = None
program_name: Optional[str] = None
estimated_value: Optional[float] = None
last_generated_at: Optional[datetime] = None
external_context: Optional[dict] = None
class ProjectSectionResponse(BaseModel):
id: str
project_id: str
order: int
section_type: str
title: Optional[str] = None
content: Optional[str]
is_approved: bool
generated_by_ai: bool
last_reviewed_at: Optional[datetime]
model_config = ConfigDict(from_attributes=True)
class SectionGenerateRequest(BaseModel):
section_type: str
prompt_context: Optional[str] = None
class SectionReviewRequest(BaseModel):
section_type: str
content: str
class ExportRequest(BaseModel):
format: str = Field(..., title="Format eksportu", description="pdf lub docx")
template: str = Field(
"standard", title="Szablon", description="standard, official, modern"
)
version_id: Optional[str] = None
class SectionVersionResponse(BaseModel):
id: str
section_id: str
old_content: str
timestamp: datetime
model_config = ConfigDict(from_attributes=True)
class ProjectAskRequest(BaseModel):
question: str
class ProjectAskResponse(BaseModel):
id: str
question: str
answer: str
sources: List[str]
confidence: float
recommendation: str
created_at: Optional[datetime] = None
class FinalDocumentCompileRequest(BaseModel):
approved_only: bool = False
class FinalDocumentCompileResponse(BaseModel):
final_markdown: str
generated_at: datetime
sections_used: int
approved_only: bool
class AuditIssueResponse(BaseModel):
category: str
severity: str
message: str
rule_citation: Optional[str] = ""
recommendation: Optional[str] = ""
class GlobalAuditResponse(BaseModel):
status: Optional[str] = "completed"
is_approved: Optional[bool] = False
export_status: Optional[str] = ""
overall_score: Optional[int] = 0
issues: Optional[List[AuditIssueResponse]] = []
class ProjectResponse(BaseModel):
id: str
clerk_user_id: str
title: str
description: Optional[str]
status: str
estimated_value: Optional[float]
program_name: Optional[str]
last_generated_at: Optional[datetime]
final_document_markdown: Optional[str] = None
final_document_generated_at: Optional[datetime] = None
final_document_audit_result: Optional[dict] = None
external_context: Optional[dict] = None
created_at: datetime
updated_at: datetime
sections: List[ProjectSectionResponse] = []
model_config = ConfigDict(from_attributes=True)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
# Endpoints
@router.get("", response_model=List[ProjectResponse])
async def list_projects(token_data: dict = Depends(verify_token), db=Depends(get_db)):
clerk_id = token_data.get("sub")
if not clerk_id:
raise HTTPException(status_code=401, detail="Brak clerk_id w tokenie")
projects = db.query(Project).filter(Project.clerk_user_id == clerk_id).all()
return projects
@router.delete("/{project_id}", response_model=dict)
async def delete_project(
project_id: str, token_data: dict = Depends(verify_token), db=Depends(get_db)
):
clerk_id = token_data.get("sub")
if not clerk_id:
raise HTTPException(status_code=401, detail="Brak clerk_id w tokenie")
project = (
db.query(Project)
.filter(Project.id == project_id, Project.clerk_user_id == clerk_id)
.first()
)
if not project:
raise HTTPException(
status_code=404, detail="Projekt nie istnieje lub brak uprawnień"
)
try:
# Oczyszczanie wektorów z Pinecone przed skasowaniem dokumentów
docs = project.documents
if docs:
try:
from rag_pipeline.vector_store import get_vector_store
for doc in docs:
if doc.rag_namespace:
store = get_vector_store(namespace=doc.rag_namespace)
if store:
store._index.delete(
delete_all=True, namespace=doc.rag_namespace
)
logger.info(
f"[Delete] Usunięto namespace Pinecone: {doc.rag_namespace}"
)
except Exception as e_pinecone:
logger.error(f"[Delete] Błąd usuwania namespace RAG: {e_pinecone}")
# Kaskadowe usuwanie encji przez ORM (relacje mają cascade="all, delete-orphan")
db.delete(project)
db.commit()
except Exception as e:
db.rollback()
logger.error(f"Error during cascading delete: {e}")
raise HTTPException(
status_code=500, detail=f"Nie można usunąć projektu: {str(e)}"
)
return {"status": "ok", "message": "Projekt usunięty z powodzeniem."}
@router.post("", response_model=ProjectResponse)
async def create_project(
data: ProjectCreate, token_data: dict = Depends(verify_token), db=Depends(get_db)
):
clerk_id = token_data.get("sub")
if not clerk_id:
raise HTTPException(status_code=401, detail="Brak clerk_id w tokenie")
# Upewnij się że użytkownik istnieje w bazie (inaczej poleci FK violation Constraint)
user = db.query(User).filter(User.clerk_id == clerk_id).first()
if not user:
user = User(clerk_id=clerk_id)
db.add(user)
db.commit()
db.refresh(user)
# Pełne pobranie z GUS na starcie projektu, by LLM miał od początku pełen obraz firmy
enriched_context = data.external_context or {}
if "company_data" in enriched_context and enriched_context["company_data"].get("nip"):
try:
from tools.company_search import fetch_regon_data
nip = enriched_context["company_data"]["nip"]
full_data = fetch_regon_data(nip)
if full_data:
enriched_context["company_data"] = full_data
logger.info(f"[Projects] Pomyślnie rozszerzono dane GUS/KRS na starcie dla NIP: {nip}")
except Exception as gus_e:
logger.warning(f"[Projects] Błąd pobierania pełnych danych GUS dla nowo tworzonego projektu: {gus_e}")
new_project = Project(
id=str(uuid.uuid4()),
clerk_user_id=clerk_id,
title=data.title,
description=data.description,
program_type=data.program_type,
program_name=data.program_name,
estimated_value=data.estimated_value,
external_context=enriched_context,
status="draft",
)
db.add(new_project)
db.commit() # bezpiecznie zapisujemy projekt najpierw
db.refresh(new_project)
# Auto-generowanie sekcji na bazie przypisanego programu z użyciem bazy
templates = (
db.query(ProjectSectionTemplate)
.filter(ProjectSectionTemplate.program_type == data.program_type)
.order_by(ProjectSectionTemplate.order.asc())
.all()
)
# Przechywyć fallback jeśli nie ma w bazie rekordu dla wybranego programu
if not templates and data.program_type != "SMART":
templates = (
db.query(ProjectSectionTemplate)
.filter(ProjectSectionTemplate.program_type == "SMART")
.order_by(ProjectSectionTemplate.order.asc())
.all()
)
if templates:
for tmpl in templates:
sec = ProjectSection(
project_id=new_project.id,
section_type=tmpl.section_type,
order=tmpl.order,
content="",
is_approved=False,
generated_by_ai=False,
)
db.add(sec)
else:
# Ultimate fallback (safety net) - direct from memory without modifying template tables
from scripts.seed_section_templates import TEMPLATES
templates_dicts = [
t for t in TEMPLATES if t.get("program_type") == data.program_type
]
if not templates_dicts:
templates_dicts = [t for t in TEMPLATES if t.get("program_type") == "SMART"]
for tmpl in templates_dicts:
db.add(
ProjectSection(
project_id=new_project.id,
section_type=tmpl.get("section_type"),
order=tmpl.get("order"),
content="",
is_approved=False,
generated_by_ai=False,
)
)
db.commit()
db.refresh(new_project)
# Przygotuj poprawną mapę by ProjectResponse objęło nowo stworzone sekcje z odpowiednim tytułem
template_map = {}
if templates:
template_map = {t.section_type: t.title for t in templates}
else:
from scripts.seed_section_templates import TEMPLATES
template_map = {t.get("section_type"): t.get("title") for t in TEMPLATES}
project_dict = new_project.__dict__.copy()
project_dict["sections"] = []
# Skoro dopisywaliśmy obiekty do sesji, project.sections może nie być odświeżone z bazy, więc wrzucamy lokalne
for s in (
db.query(ProjectSection)
.filter(ProjectSection.project_id == new_project.id)
.order_by(ProjectSection.order.asc())
.all()
):
sec_dict = s.__dict__.copy()
sec_dict["title"] = template_map.get(
s.section_type, s.section_type.replace("_", " ").title()
)
project_dict["sections"].append(sec_dict)
# ============================================================
# GSD — Główny Tryb Działania Grantforge (od maja 2026)
# ============================================================
try:
from gsd.integration import start_gsd_for_project
gsd_result = start_gsd_for_project(
project_id=new_project.id,
user_id=clerk_id,
tenant_id=clerk_id,
profile=data.external_context or {},
program_type=data.program_type,
)
project_dict["gsd"] = gsd_result
logger.info(f"[GSD] Projekt {new_project.id} uruchomiony w głównym trybie GSD")
except Exception as e:
logger.warning(f"[GSD] Błąd startu GSD dla {new_project.id}: {e}")
project_dict["gsd"] = {"gsd_mode": False, "error": "GSD temporarily unavailable"}
return project_dict
@router.post("/welcome-seed", response_model=Optional[ProjectResponse])
async def create_welcome_seed(
token_data: dict = Depends(verify_token), db=Depends(get_db)
):
"""
Tworzy przykładowy projekt onboardingowy ('Wzór: Innowacje SMART') jeśli użytkownik
nie ma jeszcze utworzonego żadnego projektu.
"""
clerk_id = token_data.get("sub")
if not clerk_id:
raise HTTPException(status_code=401, detail="Brak clerk_id w tokenie")
has_projects = db.query(Project).filter(Project.clerk_user_id == clerk_id).first()
if has_projects:
return None # Seed tylko gdy konto jest w całości puste (Empty State)
# Upewnij się że użytkownik istnieje w bazie
user = db.query(User).filter(User.clerk_id == clerk_id).first()
if not user:
user = User(clerk_id=clerk_id)
db.add(user)
db.commit()
db.refresh(user)
new_project = Project(
id=str(uuid.uuid4()),
clerk_user_id=clerk_id,
title="Wzór: Innowacja Cyfrowa SMART",
description="Projekt demonstracyjny wdrożenia infrastruktury chmurowej AI w przedsiębiorstwie produkcyjnym.",
program_type="SMART",
program_name="Ścieżka SMART (FENG)",
estimated_value=1250000.0,
status="draft",
)
db.add(new_project)
db.flush()
templates = (
db.query(ProjectSectionTemplate)
.filter(ProjectSectionTemplate.program_type == "SMART")
.order_by(ProjectSectionTemplate.order)
.all()
)
sections = []
if templates:
for t in templates:
content = ""
is_appr = False
is_ai = False
if t.section_type == "project_summary":
content = "Głównym celem projektu jest opracowanie i rynkowe wdrożenie innowacyjnej dedykowanej platformy opartej o uczenie maszynowe, wspierającej procesy kontroli jakości..."
is_appr = True
is_ai = True
elif t.section_type in ["applicant", "company_potential"]:
content = "Firma posiada 10-letnie doświadczenie z infrastrukturą AWS oraz zespół R&D składający się z 5 inżynierów MLOps."
sections.append(
ProjectSection(
project_id=new_project.id,
section_type=t.section_type,
order=t.order,
content=content,
is_approved=is_appr,
generated_by_ai=is_ai,
)
)
else:
from scripts.seed_section_templates import TEMPLATES
# Use project.program_type instead of hardcoding SMART
target_program = (
new_project.program_type if new_project.program_type else "SMART"
)
for t in TEMPLATES:
if t.get("program_type") == target_program:
content = ""
is_appr = False
is_ai = False
# Optionally set some default content for known template types
if t.get("section_type") == "project_summary":
content = "Głównym celem projektu..."
is_appr = True
is_ai = True
sections.append(
ProjectSection(
project_id=new_project.id,
section_type=t.get("section_type"),
order=t.get("order"),
content=content,
is_approved=is_appr,
generated_by_ai=is_ai,
)
)
db.add_all(sections)
db.commit()
db.refresh(new_project)
template_map = {}
if templates:
template_map = {t.section_type: t.title for t in templates}
else:
from scripts.seed_section_templates import TEMPLATES
template_map = {
t.get("section_type"): t.get("title")
for t in TEMPLATES
if t.get("program_type") == target_program
}
project_dict = new_project.__dict__.copy()
project_dict["sections"] = []
for s in (
db.query(ProjectSection)
.filter(ProjectSection.project_id == new_project.id)
.order_by(ProjectSection.order.asc())
.all()
):
sec_dict = s.__dict__.copy()
sec_dict["title"] = template_map.get(
s.section_type, s.section_type.replace("_", " ").title()
)
project_dict["sections"].append(sec_dict)
return project_dict
class LookupCompanyResponse(BaseModel):
nip: str
name: str
status: str
class MatchProgramRequest(BaseModel):
nip: Optional[str] = None
description: str
@router.get("/lookup-company", response_model=LookupCompanyResponse)
async def lookup_company(nip: str, token_data: dict = Depends(verify_token)):
if len(nip) != 10 or not nip.isdigit():
raise HTTPException(
status_code=400, detail="Nieprawidłowy NIP. Wymagane 10 cyfr."
)
try:
from tools.company_search import fetch_regon_data
result = fetch_regon_data(nip)
if (
result
and result.get("name")
and result.get("name") != "Firma (Błąd pobierania)"
):
return LookupCompanyResponse(
nip=nip,
name=result["name"],
status=f"Zidentyfikowano • {result.get('voivodeship', 'Brak danych')} Województwo",
)
else:
raise HTTPException(
status_code=404,
detail="Nie znaleziono podmiotu w rejestrze GUS/MF dla podanego NIP.",
)
except HTTPException:
raise
except Exception as e:
# Prawdziwy fallback: jeśli GUS leży (timeout, błąd integracji), wysyłamy 503,
# by frontend mógł przełączyć na ręczne wypełnianie formularza.
raise HTTPException(
status_code=503,
detail=f"Serwery rejestrowe są niedostępne. Wpisz dane ręcznie. (Szczegóły: {str(e)})",
)
@router.post("/match-program")
async def match_program(
data: MatchProgramRequest, token_data: dict = Depends(verify_token)
):
from pydantic import BaseModel
import logging
logger = logging.getLogger(__name__)
class ProgramExplanation(BaseModel):
reason: str
criteria: List[str]
risks: str
class ProgramMatch(BaseModel):
id: int
name: str
type: str
match: int
chance: str
amount: str
shortDesc: str
fullDesc: str
url: Optional[str] = None
explanation: ProgramExplanation
class MatchProgramOutput(BaseModel):
programs: List[ProgramMatch]
clarifying_questions: List[str] = Field(default_factory=list)
from core.search.grant_search_service import grant_search_service
import asyncio
from tools.company_search import fetch_regon_data
# Pobieranie danych GUS
company_context = ""
if data.nip:
try:
c_data = fetch_regon_data(data.nip)
if c_data and c_data.get("name") != "Firma (Błąd pobierania)":
pkd_list = ", ".join(c_data.get("pkd", []))
company_context = f"\n\n--- DANE Z BAZY GUS ---\nFirma: {c_data.get('name')}\nWojewództwo: {c_data.get('voivodeship')}\nKody PKD (TWARDE FILTROWANIE!): {pkd_list}\n"
except Exception as e:
logger.warning(f"Błąd pobierania danych GUS w match_program: {e}")
# Pobieranie wszystkich naborów z nowej Kaskady Wyszukiwania
all_grants = await grant_search_service.get_all_grants()
nabory_list: list[dict[str, Any]] = all_grants
nabory_context = ""
for index, n in enumerate(nabory_list[:30]): # Limit context size to 30 grants
nabory_context += f"--- PROGRAM {index+1} ---\nNazwa: {n.get('name')}\nURL: {n.get('url', 'Brak info')}\nTermin: {n.get('deadline', '?')}\nZasady/Opis: {n.get('description', 'Brak info')}\n\n"
if not nabory_context.strip():
nabory_context = "Brak aktywnych naborów w bazie. Zaproponuj standardowe dotacje historyczne."
template = """
Jesteś głównym ekspertem ds. funduszy europejskich i państwowych.
Otrzymujesz zapytanie klienta oraz listę WSZYSTKICH obecnie trwających naborów.
TWARDA REGUŁA: Jeśli w danych GUS podano kody PKD (np. 62.01.Z), musisz BEZWZGLĘDNIE sprawdzić czy te kody kwalifikują się do programu (np. programy dla rolników odrzucaj dla branży IT). Jeśli firma ma wielobranżowe kody (ponad 3 różne branże), zadaj pytanie doprecyzowujące o dominujący profil projektu.
Opis inwestycji klienta: {description}
{company_context}
BAZA AKTYWNYCH NABORÓW (tylko z tego dobieraj):
{nabory_context}
Wybierz 3 do 6 najbardziej pasujących programów. Jeśli brakuje kluczowych danych (wielkość firmy, status MŚP, budżet, lokalizacja poza woj. {company_context}), wygeneruj od 1 do 3 pytań doprecyzowujących (w 'clarifying_questions').
Zwróć wynik jako czysty JSON:
{{
"programs": [
{{
"id": 1,
"name": "nazwa",
"type": "SMART",
"match": 85,
"chance": "Wysoka",
"amount": "do 70%",
"shortDesc": "krótki opis",
"fullDesc": "pełny",
"url": "TUTAJ PRZEPISZ DOKŁADNIE URL PODANY W BAZIE",
"explanation": {{
"reason": "dlaczego?",
"criteria": ["kryterium 1"],
"risks": "ryzyka"
}}
}}
],
"clarifying_questions": ["Pytanie 1?"]
}}
Upewnij się, że "match" to liczba z przedziału 0-100 bez znaku %.
Odpowiedz tylko i wyłącznie kodem JSON, bez bloków ```json.
"""
try:
structured_llm = get_llm(
task_type="creative", structured_output_schema=MatchProgramOutput
)
prompt = PromptTemplate.from_template(template)
chain = prompt | structured_llm
res = chain.invoke(
{"description": data.description, "nabory_context": nabory_context}
)
programs_out: list[dict[str, Any]] = []
questions_out: list[str] = []
if hasattr(res, "programs") and res.programs:
programs_out = []
for p in res.programs:
if hasattr(p, "model_dump"):
programs_out.append(p.model_dump())
elif hasattr(p, "dict") and callable(getattr(p, "dict")):
try:
programs_out.append(p.dict())
except Exception:
programs_out.append(
dict(p)
if isinstance(p, dict)
else getattr(p, "__dict__", {})
)
elif isinstance(p, dict):
programs_out.append(p)
else:
try:
programs_out.append(dict(p))
except Exception:
programs_out.append(getattr(p, "__dict__", {}))
questions_out = getattr(res, "clarifying_questions", [])
elif isinstance(res, dict) and "programs" in res:
if isinstance(res["programs"], list) and len(res["programs"]) > 0:
programs_out = []
for p in res["programs"]:
if hasattr(p, "model_dump"):
programs_out.append(p.model_dump())
elif hasattr(p, "dict") and callable(getattr(p, "dict")):
try:
programs_out.append(p.dict())
except Exception:
programs_out.append(
dict(p)
if isinstance(p, dict)
else getattr(p, "__dict__", {})
)
elif isinstance(p, dict):
programs_out.append(p)
else:
try:
programs_out.append(dict(p))
except Exception:
programs_out.append(getattr(p, "__dict__", {}))
questions_out = res.get("clarifying_questions", [])
except Exception as e:
logger.error(f"LLM Error in match_program: {e}")
# Domyślny fallback na wypadek błędu serwisu LLM - zapewnia status 200 dla frontu
programs_out = [
{
"id": 1,
"name": "Ścieżka SMART (FENG)",
"type": "SMART",
"match": 85,
"chance": "Wysoka",
"amount": "do 70%",
"shortDesc": "Fundusz na innowacje i B+R.",
"fullDesc": "Przeciążenie serwera analizy AI. Podajemy uniwersalny program z historii: Ścieżka SMART na projekty modułowe.",
"url": "https://www.parp.gov.pl/component/grants/grants/sciezka-smart",
"explanation": {
"reason": "Program wspiera szerokie innowacje w MŚP. Idealny punkt startowy.",
"criteria": ["Innowacja produktowa/procesowa", "Prace badawczo-rozwojowe"],
"risks": "Wymaga wkładu własnego i solidnego komponentu badawczego.",
},
},
{
"id": 2,
"name": "Inwestycje ARiMR",
"type": "ARIMR",
"match": 60,
"chance": "Średnia",
"amount": "do 60%",
"shortDesc": "Fundusz obszarów wiejskich",
"fullDesc": "Spróbuj ponownie - to jest program zastępczy podany przez system.",
"url": "https://www.gov.pl/web/arimr",
"explanation": {
"reason": "Z powodu awarii serwera podano wariant uniwersalny rolniczy.",
"criteria": ["Działalność na obszarach M-W"],
"risks": "Brak bezpośrednich powiązań.",
},
}
]
questions_out = ["Z powodu przeciążenia serwera, AI nie mogło przeanalizować Twojego profilu. Spróbuj ponownie za chwilę."]
return {"programs": programs_out, "clarifying_questions": questions_out}
@router.get("/{project_id}", response_model=ProjectResponse)
async def get_project(
project_id: str, token_data: dict = Depends(verify_token), db=Depends(get_db)
):
clerk_id = token_data.get("sub")
project = (
db.query(Project)
.filter(Project.id == project_id, Project.clerk_user_id == clerk_id)
.first()
)
if not project:
raise HTTPException(
status_code=404, detail="Nie znaleziono projektu z autoryzacją"
)
templates = (
db.query(ProjectSectionTemplate)
.filter(ProjectSectionTemplate.program_type == project.program_type)
.all()
)
template_map = {t.section_type: t.title for t in templates}
project_dict = project.__dict__.copy()
project_dict["sections"] = []
for s in (
db.query(ProjectSection)
.filter(ProjectSection.project_id == project_id)
.order_by(ProjectSection.order.asc())
.all()
):
sec_dict = s.__dict__.copy()
sec_dict["title"] = template_map.get(s.section_type) or UNIVERSAL_FALLBACK_MAP.get(
s.section_type, s.section_type.replace("_", " ").title()
)
project_dict["sections"].append(sec_dict)
return project_dict
@router.put("/{project_id}", response_model=ProjectResponse)
async def update_project(
project_id: str,
data: ProjectUpdate,
token_data: dict = Depends(verify_token),
db=Depends(get_db),
):
clerk_id = token_data.get("sub")
project = (
db.query(Project)
.filter(Project.id == project_id, Project.clerk_user_id == clerk_id)
.first()
)
if not project:
raise HTTPException(
status_code=404, detail="Nie znaleziono projektu z autoryzacją"
)
if data.title is not None:
project.title = data.title
if data.description is not None:
project.description = data.description
if data.status is not None:
project.status = data.status
if data.program_name is not None:
project.program_name = data.program_name
if data.estimated_value is not None:
project.estimated_value = data.estimated_value
if data.last_generated_at is not None:
project.last_generated_at = data.last_generated_at
if data.external_context is not None:
project.external_context = data.external_context
db.commit()
db.refresh(project)
return project
@router.post("/{project_id}/compile-final", response_model=FinalDocumentCompileResponse)
async def compile_final_document(
project_id: str,
data: FinalDocumentCompileRequest,
token_data: dict = Depends(verify_token),
db=Depends(get_db),
):
clerk_id = token_data.get("sub")
project = (
db.query(Project)
.filter(Project.id == project_id, Project.clerk_user_id == clerk_id)
.first()
)
if not project:
raise HTTPException(status_code=404, detail="Brak dostępu do projektu")
# Pobieranie sekcji posortowanych
query = db.query(ProjectSection).filter(ProjectSection.project_id == project_id)
if data.approved_only:
query = query.filter(ProjectSection.is_approved)
sections = query.order_by(ProjectSection.order.asc()).all()
if not sections:
raise HTTPException(
status_code=400, detail="Brak sekcji spełniających kryteria kompilacji."
)
# Pobieranie tytułów z szablonu dla danego programu
from scripts.seed_section_templates import TEMPLATES
template_titles = {}
for tmpl in TEMPLATES:
if tmpl["program_type"] == project.program_type:
template_titles[tmpl["section_type"]] = tmpl["title"]
raw_document = []
toc = ["## Spis Treści\n"]
# Informacja o firmie
company_name = ""
if project.external_context and "company_data" in project.external_context:
company_name = project.external_context["company_data"].get("name", "")
if company_name:
raw_document.append(f"# Wniosek o dofinansowanie - {company_name}")
else:
raw_document.append("# Wniosek o dofinansowanie")
for i, sec in enumerate(sections, 1):
if sec.content and sec.content.strip():
# Pobieramy tytul: najpierw z obiektu sekcji, jesli nie, z szablonu, na koncu fallback
title = (
sec.title
if hasattr(sec, "title") and sec.title
else template_titles.get(sec.section_type, sec.section_type.upper())
)
toc.append(f"{i}. {title}")
raw_document.append(f"## {i}. {title}\n\n{sec.content.strip()}")
if len(raw_document) <= 1:
raise HTTPException(
status_code=400, detail="Wszystkie pobrane sekcje są puste."
)
# Złożenie ostatecznego dokumentu: Tytuł, Spis Treści, Treść
final_markdown = (
raw_document[0]
+ "\n\n"
+ "\n".join(toc)
+ "\n\n"
+ "\n\n".join(raw_document[1:])
)
FORBIDDEN_PHRASES = [
"nie posiadam informacji",
"nie mogę wygenerować",
"as an ai",
"jako model językowy",
]
actual_length = len(final_markdown)
has_table = "|" in final_markdown
found_forbidden_phrases = [
phrase
for phrase in FORBIDDEN_PHRASES
if phrase.lower() in final_markdown.lower()
]
# Złagodzona weryfikacja - pozwalamy na eksport draftów (brak tabeli, mniejsza długość),
# ale blokujemy w przypadku ewidentnych halucynacji/odmów asystenta.
if found_forbidden_phrases or actual_length < 500:
if found_forbidden_phrases:
reason_msg = f"Dokument zawiera niedozwolone frazy (odmowa AI): {', '.join(found_forbidden_phrases)}"
else:
reason_msg = f"Dokument jest podejrzanie krótki (tylko {actual_length} znaków)."
raise HTTPException(
status_code=400,
detail={
"error": "DOCUMENT_VALIDATION_FAILED",
"message": "Wygenerowany dokument nie spełnia minimalnych wymagań jakościowych.",
"details": {
"min_length_required": 500,
"actual_length": actual_length,
"has_table": has_table,
"forbidden_phrases_found": found_forbidden_phrases,
"reason": reason_msg,
},
"action": "System automatycznie ponowi generację z dodatkowym kontekstem. Jeśli problem będzie się powtarzał, skontaktuj się z administratorem.",
"retry_available": True,
},
)
project.final_document_markdown = final_markdown
project.final_document_generated_at = datetime.now(timezone.utc)
db.commit()
return FinalDocumentCompileResponse(
final_markdown=final_markdown,
generated_at=project.final_document_generated_at,
sections_used=len(sections),
approved_only=data.approved_only,
)
def background_audit(
project_id: str, program_name: str, document_to_audit: str, is_external_audit: bool
):
db = SessionLocal()
try:
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
return
from agents.auditor import audit_final_document
result = audit_final_document(
project_id,
program_name,
document_to_audit,
is_external_audit=is_external_audit,
)
# Konwersja na standard
if hasattr(result, "model_dump"):
dict_res = result.model_dump()
elif hasattr(result, "dict"):
dict_res = result.dict()
elif isinstance(result, dict):
dict_res = result
else:
dict_res = dict(result)
dict_res = jsonable_encoder(dict_res)
dict_res["status"] = "completed"
project.final_document_audit_result = dict_res
db.commit()
except Exception as e:
logger.error(f"Global Audit Background padł: {e}")
project.final_document_audit_result = {"status": "error", "message": str(e)}
db.commit()
finally:
db.close()
@router.post("/{project_id}/global-audit", response_model=GlobalAuditResponse)
async def perform_global_audit(
project_id: str,
background_tasks: BackgroundTasks,
token_data: dict = Depends(verify_token),
db=Depends(get_db),
):
clerk_id = token_data.get("sub")
project = (
db.query(Project)
.filter(Project.id == project_id, Project.clerk_user_id == clerk_id)
.first()
)
if not project:
raise HTTPException(status_code=404, detail="Brak dostępu do projektu")
if project.foreign_grant_extract_text:
# Retroaktywny audyt dokumentu zewnętrznego
document_to_audit = project.foreign_grant_extract_text
is_external_audit = True
else:
if not project.final_document_markdown:
raise HTTPException(
status_code=400,
detail="Brak skompilowanego dokumentu do audytu. Najpierw wygeneruj certyfikat kompilacji.",
)
document_to_audit = project.final_document_markdown
is_external_audit = False
project.final_document_audit_result = {"status": "pending"}
db.commit()
background_tasks.add_task(
background_audit,
project_id,
str(project.program_name or project.program_type),
document_to_audit,
is_external_audit,
)
return {"status": "pending"}
@router.delete("/{project_id}/global-audit", status_code=204)
async def clear_global_audit(
project_id: str, token_data: dict = Depends(verify_token), db=Depends(get_db)
):
clerk_id = token_data.get("sub")
project = (
db.query(Project)
.filter(Project.id == project_id, Project.clerk_user_id == clerk_id)
.first()
)
if not project:
raise HTTPException(status_code=404, detail="Brak dostępu do projektu")
project.final_document_audit_result = None
db.commit()
def background_holistic_review(
project_id: str, program_name: str, document_to_audit: str
):
db = SessionLocal()
try:
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
return
from agents.holistic_critic import holistic_critic_evaluate
result = holistic_critic_evaluate(project_id, document_to_audit, program_name)
dict_res = (
result.model_dump() if hasattr(result, "model_dump") else result.dict()
)
dict_res["status"] = "completed"
# Save to external context
ext_ctx = dict(project.external_context) if project.external_context else {}
ext_ctx["holistic_review"] = dict_res
from sqlalchemy.orm.attributes import flag_modified
project.external_context = ext_ctx
flag_modified(project, "external_context")
db.commit()
except Exception as e:
logger.error(f"Holistic Review Background padł: {e}")
project = db.query(Project).filter(Project.id == project_id).first()
if project:
ext_ctx = dict(project.external_context) if project.external_context else {}
ext_ctx["holistic_review"] = {"status": "error", "message": str(e)}
from sqlalchemy.orm.attributes import flag_modified
project.external_context = ext_ctx
flag_modified(project, "external_context")
db.commit()
finally:
db.close()
@router.post("/{project_id}/holistic-review")
async def perform_holistic_review(
project_id: str,
background_tasks: BackgroundTasks,
token_data: dict = Depends(verify_token),
db=Depends(get_db),
):
clerk_id = token_data.get("sub")
project = (
db.query(Project)
.filter(Project.id == project_id, Project.clerk_user_id == clerk_id)
.first()
)
if not project:
raise HTTPException(status_code=404, detail="Brak dostępu do projektu")
if project.foreign_grant_extract_text:
document_to_audit = project.foreign_grant_extract_text
else:
if not project.final_document_markdown:
raise HTTPException(
status_code=400,
detail="Brak skompilowanego dokumentu do audytu. Najpierw wygeneruj wniosek.",
)
document_to_audit = project.final_document_markdown
ext_ctx = dict(project.external_context) if project.external_context else {}
ext_ctx["holistic_review"] = {"status": "pending"}
from sqlalchemy.orm.attributes import flag_modified
project.external_context = ext_ctx
flag_modified(project, "external_context")
db.commit()
background_tasks.add_task(
background_holistic_review,
project_id,
str(project.program_name or project.program_type),
document_to_audit,
)
return {"status": "pending"}
@router.get("/{project_id}/holistic-review")
async def get_holistic_review(
project_id: str, token_data: dict = Depends(verify_token), db=Depends(get_db)
):
clerk_id = token_data.get("sub")
project = (
db.query(Project)
.filter(Project.id == project_id, Project.clerk_user_id == clerk_id)
.first()
)
if not project:
raise HTTPException(status_code=404, detail="Brak dostępu do projektu")
ext_ctx = project.external_context or {}
review = ext_ctx.get("holistic_review")
if not review:
raise HTTPException(status_code=404, detail="Brak raportu spójności")
return {
"status": review.get("status", "completed"),
"report": review
}
@router.post("/{project_id}/generate-section", response_model=ProjectSectionResponse)
def generate_project_section(
project_id: str,
data: SectionGenerateRequest,
token_data: dict = Depends(verify_token),
db=Depends(get_db),
):
clerk_id = token_data.get("sub")
project = (
db.query(Project)
.filter(Project.id == project_id, Project.clerk_user_id == clerk_id)
.first()
)
if not project:
raise HTTPException(status_code=404, detail="Brak dostępu do projektu")
# Prepare external context including project description
ext_ctx = dict(project.external_context) if project.external_context else {}
if project.description:
ext_ctx["project_description"] = project.description
# Pobierz aktualną sekcję (żeby przekazać jej zawartość LLM-owi do ewentualnych poprawek)
section = (
db.query(ProjectSection)
.filter(
ProjectSection.project_id == project_id,
ProjectSection.section_type == data.section_type,
)
.first()
)
if section and section.content:
ext_ctx["current_section_content"] = section.content
generated_markdown = generate_section(
project_id=project_id,
section_type=data.section_type,
context=data.prompt_context
or "Generuj treść ogólną do urzędowego wniosku unijnego.",
external_context=ext_ctx,
program_name=project.program_name,
user_id=clerk_id,
)
from core.subscription.models import User
from datetime import datetime
user_record = db.query(User).filter(User.clerk_id == clerk_id).first()
disclaimer_enabled = getattr(user_record, "ai_disclaimer_enabled", True)
if disclaimer_enabled:
generated_markdown += "\n\n---\n*Treść wygenerowana przez AI na podstawie bazy wiedzy. Zalecana ostateczna weryfikacja przez doradcę/prawnika.*"
project.last_generated_at = datetime.now(timezone.utc)
project.updated_at = datetime.now(timezone.utc)
if section:
# Zapisz starą wersję
version = ProjectSectionVersion(
section_id=section.id,
old_content=section.content,
author="Asystent AI",
summary="Wygenerowanie z AI na podstawie baz RAG",
)
db.add(version)
section.content = generated_markdown
section.generated_by_ai = True
section.is_approved = False
else:
# Utwórz nową
section = ProjectSection(
project_id=project_id,
section_type=data.section_type,
content=generated_markdown,
generated_by_ai=True,
)
db.add(section)
# Rejestracja limitów
increment_wizard_iteration(clerk_id)
db.commit()
db.refresh(section)
return section
@router.post("/{project_id}/review-section")
async def review_project_section(
project_id: str,
data: SectionReviewRequest,
token_data: dict = Depends(verify_token),
db=Depends(get_db),
):
clerk_id = token_data.get("sub")
project = (
db.query(Project)
.filter(Project.id == project_id, Project.clerk_user_id == clerk_id)
.first()
)
if not project:
raise HTTPException(status_code=404, detail="Brak dostępu do projektu")
critic_eval = review_section(project_id, data.section_type, data.content)
# Save status if approved
if critic_eval.is_approved:
section = (
db.query(ProjectSection)
.filter(
ProjectSection.project_id == project_id,
ProjectSection.section_type == data.section_type,
)
.first()
)
if section:
section.is_approved = True
db.commit()
return {
"is_approved": critic_eval.is_approved,
"feedback": critic_eval.feedback,
"severity": critic_eval.severity,
}
@router.get("/{project_id}/preview")
async def preview_project(
project_id: str, token_data: dict = Depends(verify_token), db=Depends(get_db)
):
clerk_id = token_data.get("sub")
project = (
db.query(Project)
.filter(Project.id == project_id, Project.clerk_user_id == clerk_id)
.first()
)
if not project:
raise HTTPException(status_code=404, detail="Brak dostępu")
sections = (
db.query(ProjectSection)
.filter(ProjectSection.project_id == project_id)
.order_by(ProjectSection.order.asc())
.all()
)
markdown_content = f"# WNIOSEK O DOFINANSOWANIE: {project.title}\n\n"
old_markdown_content = f"# WNIOSEK O DOFINANSOWANIE: {project.title}\n\n"
for s in sections:
markdown_content += f"## {s.section_type.upper()}\n\n{s.content}\n\n"
# Odszukaj ostatnią wersję historyczną
last_version = (
db.query(ProjectSectionVersion)
.filter(ProjectSectionVersion.section_id == s.id)
.order_by(ProjectSectionVersion.timestamp.desc())
.first()
)
if last_version:
old_markdown_content += (
f"## {s.section_type.upper()}\n\n{last_version.old_content}\n\n"
)
else:
old_markdown_content += f"## {s.section_type.upper()}\n\n{s.content}\n\n"
return {"markdown": markdown_content, "old_markdown": old_markdown_content}
@router.get("/{project_id}/sections", response_model=List[ProjectSectionResponse])
async def get_project_sections(
project_id: str, token_data: dict = Depends(verify_token), db=Depends(get_db)
):
clerk_id = token_data.get("sub")
project = (
db.query(Project)
.filter(Project.id == project_id, Project.clerk_user_id == clerk_id)
.first()
)
if not project:
raise HTTPException(status_code=404, detail="Brak dostępu")
sections = (
db.query(ProjectSection)
.filter(ProjectSection.project_id == project_id)
.order_by(ProjectSection.order.asc())
.all()
)
templates = (
db.query(ProjectSectionTemplate)
.filter(ProjectSectionTemplate.program_type == project.program_type)
.all()
)
if not templates:
templates = (
db.query(ProjectSectionTemplate)
.filter(ProjectSectionTemplate.program_type == "SMART")
.all()
)
template_map = (
{t.section_type: t.title for t in templates} if templates else UNIVERSAL_FALLBACK_MAP
)
result = []
for s in sections:
sec_dict = s.__dict__.copy()
sec_dict["title"] = template_map.get(
s.section_type,
UNIVERSAL_FALLBACK_MAP.get(s.section_type, s.section_type.replace("_", " ").title()),
)
result.append(sec_dict)
return result
class SectionUpdateRequest(BaseModel):
content: str
@router.put(
"/{project_id}/sections/{section_id}", response_model=ProjectSectionResponse
)
async def update_project_section(
project_id: str,
section_id: str,
data: SectionUpdateRequest,
token_data: dict = Depends(verify_token),
db=Depends(get_db),
):
clerk_id = token_data.get("sub")
# Zabezpieczenie
section = (
db.query(ProjectSection)
.join(Project)
.filter(
ProjectSection.id == section_id,
ProjectSection.project_id == project_id,
Project.clerk_user_id == clerk_id,
)
.first()
)
if not section:
raise HTTPException(status_code=404, detail="Nie znaleziono sekcji")
# Zapisz loga historycznego
version = ProjectSectionVersion(
section_id=section.id,
old_content=section.content,
author="Edycja ręczna",
summary="Ręczny auto-save użytkownika",
)
db.add(version)
section.content = data.content
section.is_approved = False # Reset po edycji manualnej
db.commit()
db.refresh(section)
return section
@router.post(
"/{project_id}/sections/{section_id}/autofix", response_model=ProjectSectionResponse
)
async def autofix_project_section(
project_id: str,
section_id: str,
token_data: dict = Depends(verify_token),
db=Depends(get_db),
):
clerk_id = token_data.get("sub")
project = (
db.query(Project)
.filter(Project.id == project_id, Project.clerk_user_id == clerk_id)
.first()
)
section = (
db.query(ProjectSection)
.filter(
ProjectSection.id == section_id, ProjectSection.project_id == project_id
)
.first()
)
if not project or not section:
raise HTTPException(
status_code=404, detail="Nie znaleziono projektu lub sekcji"
)
# Audit wyniki są przechowywane w final_document_audit_result (z globalnego audytu)
# Fallback na external_context["last_audit"] dla starszych audytów
audit_data = project.final_document_audit_result
if not audit_data:
ext_context = project.external_context or {}
audit_data = ext_context.get("last_audit")
if not audit_data or "issues" not in audit_data or len(audit_data["issues"]) == 0:
return section
# Uzyskaj prawidłowy tytuł sekcji (Polish) aby LLM powiązał go z wynikami audytu
from core.projects.models import ProjectSectionTemplate
templates = (
db.query(ProjectSectionTemplate)
.filter(ProjectSectionTemplate.program_type == project.program_type)
.all()
)
if not templates:
templates = (
db.query(ProjectSectionTemplate)
.filter(ProjectSectionTemplate.program_type == "SMART")
.all()
)
template_map = (
{t.section_type: t.title for t in templates} if templates else UNIVERSAL_FALLBACK_MAP
)
section_t = template_map.get(
section.section_type,
UNIVERSAL_FALLBACK_MAP.get(
section.section_type, section.section_type.replace("_", " ").title()
),
)
from core.utils import safe_extract_text, extract_markdown_and_sanitize
import difflib
import time
import os
def log_watchdog(msg: str):
try:
os.makedirs("logs", exist_ok=True)
with open("logs/watchdog.log", "a", encoding="utf-8") as f:
f.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}\n")
except Exception:
pass
from core.telemetry import telemetry
llm = get_llm(task_type="writing")
issues_list = []
ext_context = project.external_context or {}
holistic_review = ext_context.get("holistic_review")
if holistic_review and isinstance(holistic_review, dict):
recs = holistic_review.get("key_recommendations", [])
for r in recs:
issues_list.append(f"- [KRYTYK GLOBALNY] Rekomendacja: {r}")
direct_issues_count = 0
for i in audit_data["issues"]:
if isinstance(i, dict):
affected = i.get("affected_section", "Ogólne")
is_general = affected.lower() in [
"ogólne",
"całość",
"ogolne",
"calosc",
"all",
"general",
]
similarity = difflib.SequenceMatcher(
None, affected.lower(), section_t.lower()
).ratio()
if is_general or similarity > 0.35:
direct_issues_count += 1
issues_list.append(
f"- [BEZPOŚREDNIO DOTYCZY TEJ SEKCJI] [{i.get('severity', 'UNKNOWN').upper()}] Kategoria: {i.get('category', '')}\n Błąd: {i.get('message', '')}\n Rekomendacja: {i.get('recommendation', '')}"
)
else:
issues_list.append(
f"- [KONTEKST Z INNEJ SEKCJI: {affected}] Błąd: {i.get('message', '')}"
)
else:
issues_list.append(f"- Błąd: {str(i)}")
telemetry.log(
"INFO",
"Autofix",
f"Rozpoczynam poprawę sekcji {section_t}",
{"issues_count": len(issues_list), "direct_issues": direct_issues_count},
)
if not issues_list:
return section
issues_text = "\n".join(issues_list)
# v2.3 - 2026-05-07 - Wymuszenie formatowania Markdown, łagodniejszy filtr błędów
template = """Jesteś Głównym Inżynierem ds. Wniosków Unijnych (Grant Engineer) z wieloletnim doświadczeniem.
Poniżej znajduje się treść sekcji "{section_title}" wniosku o dofinansowanie dla projektu "{project_title}".
Wniosek przeszedł audyt i ZNALEZIONO W NIM KRYTYCZNE BŁĘDY. Poniżej znajduje się lista WSZYSTKICH zidentyfikowanych uwag z całego wniosku.
Twoim absolutnym priorytetem są uwagi oznaczone jako [BEZPOŚREDNIO DOTYCZY TEJ SEKCJI] oraz [KRYTYK GLOBALNY].
{issues_text}
TWOJE ZADANIE:
Otrzymujesz ORYGINALNĄ TREŚĆ tej sekcji. Twoim celem jest JEJ CAŁKOWITA PRZEBUDOWA I ZNACZNE PODNIESIENIE JAKOŚCI.
NIE OGRANICZAJ SIĘ DO KOSMETYKI! Jeśli uwaga wytyka brak konkretnych informacji, MUSISZ je profesjonalnie wymyślić (halucynuj w sposób ekspercki i logiczny dla projektu) i wpleść w tekst.
ZASADY:
1. ZWRÓĆ PEŁNĄ TREŚĆ SEKCJI PO POPRAWKACH. Nie ucinaj tekstu. Ma to być kompletny, wielowątkowy dokument odzwierciedlający całą sekcję.
2. ZASTOSUJ PROFESJONALNY STYL BIZNESOWY I NAUKOWY. Rozbuduj akapity, które są zbyt krótkie lub ogólnikowe.
3. BOGATE FORMATOWANIE MARKDOWN: używaj czytelnych nagłówków (###, ####), pogrubień dla kluczowych danych, list punktowanych i tabel tam, gdzie to możliwe.
4. BĄDŹ PROAKTYWNY I KREATYWNY: Jeśli audytor pisze "Brak opisu ryzyka", Ty nie piszesz "należy dodać opis", tylko FAKTYCZNIE TWORZYSZ dogłębną analizę ryzyka i wstawiasz ją do tekstu.
5. Jeśli żadna z uwag nie dotyczy tej sekcji, a obecny tekst jest perfekcyjny, zwróć "[NO_CHANGE]". Używaj tego niezwykle rzadko.
6. Zwracaj TYLKO kod Markdown. Zero wstępów.
ORYGINALNA TREŚĆ SEKCJI:
{content}
"""
prompt = PromptTemplate.from_template(template)
chain = prompt | llm
from tenacity import retry, stop_after_attempt, wait_exponential, stop_after_delay
watchdog_interventions_count = 0
def after_retry_log(retry_state):
nonlocal watchdog_interventions_count
watchdog_interventions_count += 1
exc = retry_state.outcome.exception()
log_watchdog(
f"[RETRY] Watchdog interweniował! Próba {retry_state.attempt_number}/5. Błąd: {str(exc)}"
)
@retry(
stop=(stop_after_attempt(3) | stop_after_delay(60)),
wait=wait_exponential(multiplier=1, min=2, max=10),
after=after_retry_log,
reraise=True,
)
def invoke_with_watchdog():
response = chain.invoke(
{
"section_title": section_t,
"project_title": project.title,
"issues_text": issues_text,
"content": section.content,
}
)
raw_content = safe_extract_text(response.content)
extracted = extract_markdown_and_sanitize(raw_content, min_length=50)
# GSD Rule: Jeśli wynik Autofix ma mniej niż 40% zmienionego tekstu przy uwagach >= medium -> odrzucenie.
# Agent Autofix nie może zwracać [NO_CHANGE], jeśli istnieją uwagi o severity medium lub high.
has_critical = any(
isinstance(i, dict) and i.get("severity", "low").lower() in ["medium", "high", "critical"]
for i in audit_data.get("issues", [])
)
if has_critical:
if extracted.strip() == "[NO_CHANGE]":
raise ValueError("Watchdog GSD: Zwrócono [NO_CHANGE] przy obecności błędów medium/high. Odrzucam wynik.")
if section.content and extracted.strip() != "[NO_CHANGE]":
similarity = difflib.SequenceMatcher(None, section.content, extracted).ratio()
if similarity > 0.60:
changed_pct = 100 - (similarity * 100)
raise ValueError(f"Watchdog GSD: Zbyt mała zmiana tekstu ({changed_pct:.1f}%). Wymagane min. 40% zmiany.")
return extracted
try:
new_content = invoke_with_watchdog()
if new_content != "[NO_CHANGE]":
original_len = len(section.content) if section.content else 0
min_expected_length = max(300, int(original_len * 0.70))
if len(new_content) < min_expected_length:
telemetry.log(
"WARN",
"Autofix",
f"LLM zwrócił odpowiedź krótszą niż oczekiwano ({len(new_content)} znaków vs {original_len} oryginału). Wymuszam ponowną próbę.",
{"length": len(new_content), "expected_min": min_expected_length},
)
# Ponawiamy zapytanie jednorazowo ze wzmocnionym wymogiem
chain_fallback = (
PromptTemplate.from_template(
template
+ "\n\nOSTRZEŻENIE: Twoja poprzednia odpowiedź była stanowczo za krótka w stosunku do oryginalnej sekcji! MUSISZ ZWRÓCIĆ CAŁĄ TREŚĆ SEKCJI, ZACHOWUJĄC JEJ SZCZEGÓŁOWOŚĆ. Nie ucinaj i nie streszczaj tekstu!"
)
| llm
)
res = chain_fallback.invoke(
{
"section_title": section_t,
"project_title": project.title,
"issues_text": issues_text,
"content": section.content,
}
)
new_content = extract_markdown_and_sanitize(
safe_extract_text(res.content), min_length=50
)
telemetry.log(
"INFO",
"Autofix",
f"Sukces dla sekcji {section_id}. Zwrócono {len(new_content)} znaków.",
{
"is_no_change": new_content == "[NO_CHANGE]",
"watchdog_interventions": watchdog_interventions_count,
},
)
log_watchdog(
f"[METRIC] Sukces dla sekcji {section_id}. Łączna liczba interwencji Watchdoga: {watchdog_interventions_count}"
)
except Exception as e:
error_msg = str(e)
if (
"429" in error_msg
or "ResourceExhausted" in error_msg
or "Quota" in error_msg
):
status_code = 429
detail = "Przekroczono limit zapytań do AI (Rate Limit). Spróbuj ponownie później."
else:
status_code = 500
detail = f"Sztuczna inteligencja nie mogła poprawnie wygenerować tej sekcji po 5 próbach. Błąd: {error_msg}"
telemetry.log(
"ERROR",
"Autofix",
f"Błąd w autofix: {error_msg}",
{"section_id": section_id},
)
log_watchdog(
f"[ABORT] Sekcja {section_id} - Przekroczono limit prób. Ostatni błąd: {error_msg}"
)
raise HTTPException(
status_code=status_code,
detail=detail,
)
if new_content and new_content != section.content and new_content != "[NO_CHANGE]":
# Zapisz starą wersję
version = ProjectSectionVersion(
section_id=section.id,
old_content=section.content,
author="Autofix",
summary="Automatyczna korekta audytu",
)
db.add(version)
section.content = new_content
section.is_approved = False
db.commit()
db.refresh(section)
return section
@router.get("/{project_id}/sections/{section_id}/versions")
async def list_section_versions(
project_id: str,
section_id: str,
token_data: dict = Depends(verify_token),
db=Depends(get_db),
):
clerk_id = token_data.get("sub")
section = (
db.query(ProjectSection)
.join(Project)
.filter(
ProjectSection.id == section_id,
ProjectSection.project_id == project_id,
Project.clerk_user_id == clerk_id,
)
.first()
)
if not section:
raise HTTPException(status_code=404, detail="Nie znaleziono sekcji")
versions = (
db.query(ProjectSectionVersion)
.filter(ProjectSectionVersion.section_id == section_id)
.order_by(ProjectSectionVersion.timestamp.desc())
.limit(20)
.all()
)
return [
{
"id": v.id,
"old_content": v.old_content,
"author": v.author,
"summary": v.summary,
"timestamp": v.timestamp.isoformat() + "Z",
}
for v in versions
]
@router.post(
"/{project_id}/sections/{section_id}/versions/{version_id}/restore",
response_model=ProjectSectionResponse,
)
async def restore_section_version(
project_id: str,
section_id: str,
version_id: str,
token_data: dict = Depends(verify_token),
db=Depends(get_db),
):
clerk_id = token_data.get("sub")
section = (
db.query(ProjectSection)
.join(Project)
.filter(
ProjectSection.id == section_id,
ProjectSection.project_id == project_id,
Project.clerk_user_id == clerk_id,
)
.first()
)
if not section:
raise HTTPException(status_code=404, detail="Nie znaleziono sekcji")
version = (
db.query(ProjectSectionVersion)
.filter(
ProjectSectionVersion.id == version_id,
ProjectSectionVersion.section_id == section_id,
)
.first()
)
if not version:
raise HTTPException(
status_code=404, detail="Nie znaleziono wersji historycznej"
)
# Tworzymy nową wersję obecnego tekstu przed nadpisaniem
new_version = ProjectSectionVersion(
section_id=section.id,
old_content=section.content,
author="Ręczna",
summary="Cofnięcie do historii ujęte jako checkpoint",
)
db.add(new_version)
section.content = version.old_content
section.is_approved = False
db.commit()
db.refresh(section)
return section
@router.post("/{project_id}/ask", response_model=ProjectAskResponse)
async def ask_project_question(
project_id: str,
data: ProjectAskRequest,
token_data: dict = Depends(verify_token),
db=Depends(get_db),
):
clerk_id = token_data.get("sub")
project = (
db.query(Project)
.filter(Project.id == project_id, Project.clerk_user_id == clerk_id)
.first()
)
if not project:
raise HTTPException(status_code=404, detail="Brak dostępu")
sections = (
db.query(ProjectSection).filter(ProjectSection.project_id == project_id).all()
)
# Budowanie zagregowanego kontekstu z obecnego etapu pracy
project_context_lines = [
f"Informacje Ogólne. Tytuł: {project.title}, Wartość: {project.estimated_value or 'Brak'}\n"
]
for s in sections:
if s.content and len(s.content) > 10:
project_context_lines.append(f"--- Sekcja: {s.section_type.upper()} ---")
project_context_lines.append(s.content)
project_context = "\n".join(project_context_lines)
result_dict = project_qa_agent(
project_id=project_id,
question=data.question,
program_name=project.program_name or "Ogólne dotacje unijne UE",
context=project_context,
external_context=project.external_context,
)
answer_val = result_dict.get("answer", "Brak odpowiedzi")
sources_val = result_dict.get("sources", [])
conf_val = float(result_dict.get("confidence", 0.0))
rec_val = result_dict.get("recommendation", "")
new_q = ProjectQuestion(
project_id=project_id,
question=data.question,
answer=answer_val,
sources=json.dumps(sources_val),
confidence=conf_val,
recommendation=rec_val,
)
db.add(new_q)
db.commit()
db.refresh(new_q)
return ProjectAskResponse(
id=new_q.id,
question=new_q.question,
answer=answer_val,
sources=sources_val,
confidence=conf_val,
recommendation=rec_val,
created_at=new_q.created_at,
)
@router.get("/{project_id}/ask/history", response_model=List[ProjectAskResponse])
async def get_project_questions_history(
project_id: str, token_data: dict = Depends(verify_token), db=Depends(get_db)
):
clerk_id = token_data.get("sub")
project = (
db.query(Project)
.filter(Project.id == project_id, Project.clerk_user_id == clerk_id)
.first()
)
if not project:
raise HTTPException(status_code=404, detail="Brak dostępu")
history_items = (
db.query(ProjectQuestion)
.filter(ProjectQuestion.project_id == project_id)
.order_by(ProjectQuestion.created_at.asc())
.all()
)
response = []
for h in history_items:
sources_list = []
if h.sources:
try:
sources_list = json.loads(h.sources)
except Exception:
pass
response.append(
ProjectAskResponse(
id=h.id,
question=h.question,
answer=h.answer,
sources=sources_list,
confidence=h.confidence or 0.0,
recommendation=h.recommendation or "",
created_at=h.created_at,
)
)
return response
@router.delete("/{project_id}/ask/history")
async def clear_project_questions_history(
project_id: str, token_data: dict = Depends(verify_token), db=Depends(get_db)
):
clerk_id = token_data.get("sub")
project = (
db.query(Project)
.filter(Project.id == project_id, Project.clerk_user_id == clerk_id)
.first()
)
if not project:
raise HTTPException(status_code=404, detail="Brak dostępu")
db.query(ProjectQuestion).filter(ProjectQuestion.project_id == project_id).delete()
db.commit()
return {
"status": "success",
"message": "Historia weryfikatora została wyczyszczona.",
}
# --- ZASOBY I DOKUMENTY (EXTERNAL CONTEXT & RAG) ---
import io
class ProjectResourceResponse(BaseModel):
id: str
filename: str
mime_type: str
uploaded_at: datetime
size_bytes: int
extracted_text: Optional[str] = None
def parse_docx(file_bytes: bytes) -> str:
import docx
doc = docx.Document(io.BytesIO(file_bytes))
return "\n".join([para.text for para in doc.paragraphs])
def parse_pdf(file_bytes: bytes) -> str:
from pypdf import PdfReader
reader = PdfReader(io.BytesIO(file_bytes))
text = ""
for page in reader.pages:
extracted = page.extract_text()
if extracted:
text += extracted + "\n"
return text
@router.post("/{project_id}/documents", response_model=ProjectResourceResponse)
async def upload_project_document(
project_id: str,
file: UploadFile = File(...),
token_data: dict = Depends(verify_token),
db=Depends(get_db),
):
clerk_id = token_data.get("sub")
project = (
db.query(Project)
.filter(Project.id == project_id, Project.clerk_user_id == clerk_id)
.first()
)
if not project:
raise HTTPException(
status_code=404, detail="Nie znaleziono projektu z autoryzacją"
)
ext_context = project.external_context or {}
# Przeniesienie starych documents do resources dla komptybilności
if "documents" in ext_context and "resources" not in ext_context:
ext_context["resources"] = ext_context.pop("documents")
resources = ext_context.get("resources", [])
from core.subscription.checker import SubscriptionChecker
checker = SubscriptionChecker(clerk_id)
user_tier = checker.get_tier().value.capitalize()
limits = checker.get_current_limits()
max_resources = limits.get("max_documents_per_project", 5)
if len(resources) >= max_resources:
if user_tier == "Free":
raise HTTPException(
status_code=400,
detail=f"Osiągnąłeś limit {max_resources} dokumentów w planie Free.\nPrzejdź na plan Pro, aby dodawać więcej umów, ofert i materiałów do projektu.",
)
else:
raise HTTPException(
status_code=400,
detail=f"Przekroczono limit wgranych zasobów ({max_resources}) dla Twojego planu ({user_tier}).",
)
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
file_bytes = await file.read()
if len(file_bytes) > MAX_FILE_SIZE:
raise HTTPException(
status_code=400, detail="Plik przekracza dozwolony limit 10 MB."
)
ext = file.filename.split(".")[-1].lower() if file.filename else ""
if ext not in ["pdf", "docx", "doc", "txt", "md"]:
raise HTTPException(
status_code=400,
detail="Niedozwolony format pliku. Obsługiwane to PDF, DOCX, DOC, TXT, MD.",
)
# Ekstrakcja teksu
text_content = ""
try:
if ext == "pdf":
text_content = parse_pdf(file_bytes)
elif ext in ["docx", "doc"]:
text_content = parse_docx(file_bytes)
else:
text_content = file_bytes.decode("utf-8")
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Bład poczas ekstrakcji tekstu z pliku: {str(e)}"
)
import uuid
doc_id = str(uuid.uuid4())
doc_meta = {
"id": doc_id,
"filename": file.filename,
"mime_type": file.content_type or "application/octet-stream",
"uploaded_at": datetime.now(timezone.utc).isoformat(),
"size_bytes": len(file_bytes),
"extracted_text": text_content,
"type": "user_upload",
}
# Wektoryzacja tekstu za pomocą Pinecone
try:
from rag_pipeline.vector_store import ingest_documents
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=150)
chunks = splitter.create_documents(
texts=[text_content],
metadatas=[
{
"source": file.filename,
"project_id": project_id,
"type": "user_upload",
}
],
)
if chunks:
# Użycie parametru namespace dla pełnej partycjonowanej izolacji tenantów
tenant_namespace = f"tenant_{clerk_id}_{project_id}"
ingest_documents(chunks, namespace=tenant_namespace)
except Exception as e:
# Fallback jeśli RAG jest offnięty - plik dodany tylko do DB
print(f"Warning: RAG upload failed: {e}")
# Aktualizacja bazy
# Usunięcie starych jeśli plik ma tą samą nazwę
ext_context["resources"] = [
d for d in resources if d.get("filename") != file.filename
]
ext_context["resources"].append(doc_meta)
# Ponownie przypisz (SQLAlchemy JSON type behavior)
project.external_context = dict(ext_context)
from sqlalchemy.orm.attributes import flag_modified
flag_modified(project, "external_context")
db.commit()
return ProjectResourceResponse(
id=doc_meta["id"],
filename=doc_meta["filename"],
mime_type=doc_meta["mime_type"],
uploaded_at=datetime.fromisoformat(doc_meta["uploaded_at"]),
size_bytes=doc_meta["size_bytes"],
extracted_text=doc_meta["extracted_text"],
)
@router.get("/{project_id}/documents", response_model=List[ProjectResourceResponse])
async def get_project_documents(
project_id: str, token_data: dict = Depends(verify_token), db=Depends(get_db)
):
clerk_id = token_data.get("sub")
project = (
db.query(Project)
.filter(Project.id == project_id, Project.clerk_user_id == clerk_id)
.first()
)
if not project:
raise HTTPException(status_code=404, detail="Brak dostępu do projektu")
ext_context = project.external_context or {}
# Legacy fallback
if "documents" in ext_context and "resources" not in ext_context:
ext_context["resources"] = ext_context.pop("documents")
docs = ext_context.get("resources", [])
return [
ProjectResourceResponse(
id=d.get("id", "legacy-id"),
filename=d.get("filename"),
mime_type=d.get("mime_type", "application/octet-stream"),
uploaded_at=datetime.fromisoformat(d.get("uploaded_at"))
if d.get("uploaded_at")
else datetime.now(timezone.utc),
size_bytes=d.get("size_bytes", d.get("size", 0)),
extracted_text=d.get("extracted_text", d.get("text", "")),
)
for d in docs
]
@router.delete("/{project_id}/documents/{filename}")
async def delete_project_document(
project_id: str,
filename: str,
token_data: dict = Depends(verify_token),
db=Depends(get_db),
):
clerk_id = token_data.get("sub")
project = (
db.query(Project)
.filter(Project.id == project_id, Project.clerk_user_id == clerk_id)
.first()
)
if not project:
raise HTTPException(status_code=404, detail="Brak dostępu do projektu")
ext_context = project.external_context or {}
if "documents" in ext_context and "resources" not in ext_context:
ext_context["resources"] = ext_context.pop("documents")
docs = ext_context.get("resources", [])
ext_context["resources"] = [d for d in docs if d.get("filename") != filename]
project.external_context = dict(ext_context)
from sqlalchemy.orm.attributes import flag_modified
flag_modified(project, "external_context")
db.commit()
# Próba usunięcia z wektorów RAG
try:
from rag_pipeline.vector_store import get_vector_store
tenant_namespace = f"tenant_{clerk_id}_{project_id}"
store = get_vector_store(namespace=tenant_namespace)
if store and store._index:
store._index.delete(
filter={"source": filename, "project_id": project_id},
namespace=tenant_namespace,
)
except Exception as e:
print(f"Warning: RAG deletion failed: {e}")
return {"status": "deleted"}
@router.get("/{project_id}/audit")
async def get_project_audit(
project_id: str, token_data: dict = Depends(verify_token), db=Depends(get_db)
):
clerk_id = token_data.get("sub")
project = (
db.query(Project)
.filter(Project.id == project_id, Project.clerk_user_id == clerk_id)
.first()
)
if not project:
raise HTTPException(status_code=404, detail="Brak dostępu do projektu")
ext_context = project.external_context or {}
last_audit = ext_context.get("last_audit")
if not last_audit:
return {"status": "no_audit"}
return last_audit
@router.post("/{project_id}/audit")
async def run_project_audit(
project_id: str, token_data: dict = Depends(verify_token), db=Depends(get_db)
):
clerk_id = token_data.get("sub")
project = (
db.query(Project)
.filter(Project.id == project_id, Project.clerk_user_id == clerk_id)
.first()
)
if not project:
raise HTTPException(status_code=404, detail="Brak dostępu do projektu")
# Pobieranie kontentu ze wszystkich sekcji
sections = (
db.query(ProjectSection)
.filter(ProjectSection.project_id == project_id)
.order_by(ProjectSection.order)
.all()
)
if not sections:
raise HTTPException(
status_code=400, detail="Brak sekcji w projekcie. Nie można wykonać audytu."
)
full_content = ""
for sec in sections:
if sec.content:
sec_title_name = getattr(sec, "title", sec.section_type)
full_content += f"\n\n### {sec_title_name} ###\n"
full_content += sec.content
# Uruchomienie agenta audytu
audit_output = audit_final_document(
project.id, project.program_name or "Ogólny", full_content
)
# Zapis do bazy jako dict uodporniony na surowe słowniki zwracane przez Langchain
if hasattr(audit_output, "model_dump"):
audit_dict = audit_output.model_dump()
elif hasattr(audit_output, "dict"):
audit_dict = audit_output.dict()
elif isinstance(audit_output, dict):
audit_dict = audit_output
elif audit_output is None:
audit_dict = {
"is_approved": False,
"export_status": "blocked",
"overall_score": 0,
"issues": [
{
"category": "Błąd",
"severity": "critical",
"message": "Pusta odpowiedź z modelu",
}
],
}
else:
audit_dict = dict(audit_output)
ext_context = project.external_context or {}
ext_context["last_audit"] = audit_dict
project.external_context = dict(ext_context)
db.commit()
return ext_context["last_audit"]
@router.delete("/{project_id}/audit")
async def clear_project_audit(
project_id: str, token_data: dict = Depends(verify_token), db=Depends(get_db)
):
clerk_id = token_data.get("sub")
project = (
db.query(Project)
.filter(Project.id == project_id, Project.clerk_user_id == clerk_id)
.first()
)
if not project:
raise HTTPException(status_code=404, detail="Brak dostępu do projektu")
ext_context = project.external_context or {}
ext_context.pop("last_audit", None)
project.external_context = dict(ext_context)
db.commit()
return {"status": "cleared"}
# --- CHATBOT PROJECT ---
class ChatMessageResponse(BaseModel):
id: str
role: str
content: str
created_at: datetime
class ChatMessageRequest(BaseModel):
content: str
active_section: Optional[str] = None
active_section_title: Optional[str] = None
@router.get("/{project_id}/chat", response_model=List[ChatMessageResponse])
async def get_project_chat(
project_id: str, token_data: dict = Depends(verify_token), db=Depends(get_db)
):
clerk_id = token_data.get("sub")
project = (
db.query(Project)
.filter(Project.id == project_id, Project.clerk_user_id == clerk_id)
.first()
)
if not project:
raise HTTPException(status_code=404, detail="Brak dostępu do projektu")
messages = (
db.query(ProjectChatMessage)
.filter(ProjectChatMessage.project_id == project_id)
.order_by(ProjectChatMessage.created_at.asc())
.all()
)
return [
ChatMessageResponse(
id=m.id, role=m.role, content=m.content, created_at=m.created_at
)
for m in messages
]
@router.delete("/{project_id}/chat")
async def clear_project_chat(
project_id: str, token_data: dict = Depends(verify_token), db=Depends(get_db)
):
clerk_id = token_data.get("sub")
project = (
db.query(Project)
.filter(Project.id == project_id, Project.clerk_user_id == clerk_id)
.first()
)
if not project:
raise HTTPException(status_code=404, detail="Brak dostępu do projektu")
db.query(ProjectChatMessage).filter(
ProjectChatMessage.project_id == project_id
).delete()
db.commit()
return {"status": "success", "message": "Historia czatu została wyczyszczona."}
@router.post("/{project_id}/chat", response_model=ChatMessageResponse)
async def post_project_chat(
project_id: str,
data: ChatMessageRequest,
token_data: dict = Depends(verify_token),
db=Depends(get_db),
):
clerk_id = token_data.get("sub")
project = (
db.query(Project)
.filter(Project.id == project_id, Project.clerk_user_id == clerk_id)
.first()
)
if not project:
raise HTTPException(status_code=404, detail="Brak dostępu do projektu")
# Zapisz wiadomość użytkownika
user_msg = ProjectChatMessage(
project_id=project_id, role="user", content=data.content
)
db.add(user_msg)
# Pobierz historię
messages = (
db.query(ProjectChatMessage)
.filter(ProjectChatMessage.project_id == project_id)
.order_by(ProjectChatMessage.created_at.asc())
.all()
)
# Zbuduj kontekst projektu
sections = (
db.query(ProjectSection).filter(ProjectSection.project_id == project_id).all()
)
project_context = f"Tytuł: {project.title}\nStatus: {project.status}\n"
for s in sections:
if s.content and len(s.content) > 10:
project_context += f"--- {s.section_type.upper()} ---\n{s.content[:3000]}\n" # limit per section to avoid context overflow
# Kontekst od RAG / External
ext_context = project.external_context or {}
last_audit = ext_context.get("last_audit")
resources = ext_context.get("resources", [])
extra_info = ""
if ext_context:
extra_info += f"\nZewnętrzny kontekst (np. GUS): {json.dumps({k:v for k,v in ext_context.items() if k not in ['last_audit', 'resources']}, ensure_ascii=False)}\n"
if last_audit:
extra_info += f"\nWynik ostatniego audytu: Ocena ogólna {last_audit.get('overall_score')}/100. Problemy: {len(last_audit.get('issues', []))}\n"
if resources:
extra_info += "\nZAWARTOSĆ WGRANYCH PLIKÓW (używaj ich jako głównego źródła wiedzy na prośbę użytkownika):\n"
for r in resources:
text = r.get('extracted_text', r.get('text', ''))
# Ograniczenie wielkości tekstu by nie wyczerpać kontekstu
truncated_text = text[:4000] + ("..." if len(text) > 4000 else "")
extra_info += f"\n--- Plik: {r.get('filename')} ({r.get('size_bytes', 0)} bajtów) ---\n{truncated_text}\n"
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage
llm = get_llm(task_type="chat")
active_section_info = ""
if hasattr(data, "active_section") and data.active_section:
active_section_info = f"\nUżytkownik obecnie edytuje sekcję: {data.active_section_title or data.active_section}. Skup się na doradzaniu w jej kontekście."
system_prompt = f"""Jesteś wirtualnym asystentem pomagającym użytkownikowi napisać i dopracować wniosek o dofinansowanie unijne.
Masz pełen dostęp do obecnego tekstu projektu użytkownika. Twoim celem jest odpowiadać na pytania, sugerować poprawki i wspierać pisanie fragmentów tekstu na podstawie tego co już wiadomo.
Zawsze pisz bardzo profesjonalnie, precyzyjnie i pomocnie w języku polskim.
{active_section_info}
Jeśli użytkownik prosi o sugestię tekstu (lub chcesz mu pomóc zastąpić/wstawić fragment), zawsze odpowiadaj normalną wiadomością, a propozycję fragmentu umieść wyłącznie wewnątrz tagu XML o formacie <SUGGESTION section="nazwa_sekcji">...treść...</SUGGESTION>.
BARDZO WAŻNE: Wewnątrz tagu <SUGGESTION> musi znaleźć się kompletny tekst do podmiany, zawsze rozpoczynający się od odpowiedniego nagłówka Markdown (np. "### Tytuł Sekcji"), tak aby po wklejeniu do edytora tekst miał od razu nadany tytuł. Zwracaj dokładną uwagę, do jakiej sekcji sugerujesz tekst.
Dzięki temu system automatycznie pozwoli użytkownikowi na wstrzyknięcie tekstu pod kursorem z poziomu czatu. Nie dodawaj niczego poza tagiem wewnątrz proponowanego wycinka!
KONTEKST PROJEKTU:
{project_context} {extra_info}
"""
langchain_msgs = [SystemMessage(content=system_prompt)]
for m in messages:
if m.role == "user":
langchain_msgs.append(HumanMessage(content=m.content))
else:
langchain_msgs.append(AIMessage(content=m.content))
# Zabezpieczenie przed zbyt długim contextem (zostawiamy system + np 10 ostatnich)
if len(langchain_msgs) > 11:
langchain_msgs = [langchain_msgs[0]] + langchain_msgs[-10:]
try:
response = llm.invoke(langchain_msgs)
from core.utils import safe_extract_text
ai_content = safe_extract_text(response.content)
except Exception as e:
ai_content = f"Przepraszam, wystąpił techniczny błąd poczas generowania odpowiedzi: {str(e)}"
ai_msg = ProjectChatMessage(
project_id=project_id, role="assistant", content=ai_content
)
db.add(ai_msg)
db.commit()
db.refresh(ai_msg)
return ChatMessageResponse(
id=ai_msg.id,
role=ai_msg.role,
content=ai_msg.content,
created_at=ai_msg.created_at,
)
from fastapi import BackgroundTasks
from fastapi.responses import FileResponse
from utils.export_documents import export_to_pdf, export_to_docx
import tempfile
import os
import uuid
import logging
from datetime import datetime
logger = logging.getLogger(__name__)
EXPORT_TASKS = {}
def background_export_task(task_id, format_ext, content, filepath, request_template, project_title, company_name, version, date_str, extra_ctx):
try:
if format_ext == "pdf":
success = export_to_pdf(content, filepath, template=request_template, project_title=project_title, company_name=company_name, version=version, date_str=date_str)
else:
success = export_to_docx(content, filepath, template=request_template, project_title=project_title, company_name=company_name, version=version, date_str=date_str, extra_context=extra_ctx)
if success:
EXPORT_TASKS[task_id]["status"] = "completed"
logger.info(f"[ExportTask] Sukces dla zadania {task_id}")
else:
EXPORT_TASKS[task_id]["status"] = "error"
EXPORT_TASKS[task_id]["error"] = "Błąd generowania wewnętrznego"
logger.error(f"[ExportTask] Błąd generowania zadania {task_id}")
except Exception as e:
EXPORT_TASKS[task_id]["status"] = "error"
EXPORT_TASKS[task_id]["error"] = str(e)
logger.error(f"[ExportTask] Wyjątek dla zadania {task_id}: {e}")
@router.post("/{project_id}/export")
def export_project_document(
project_id: str, request: ExportRequest, background_tasks: BackgroundTasks, token_data: dict = Depends(verify_token)
):
db = SessionLocal()
try:
clerk_id = token_data.get("sub")
project = (
db.query(Project)
.filter(Project.id == project_id, Project.clerk_user_id == clerk_id)
.first()
)
if not project:
raise HTTPException(status_code=404, detail="Projekt nie znaleziony")
content = project.final_document_markdown
if request.version_id:
version = (
db.query(ProjectExportVersion)
.filter(ProjectExportVersion.id == request.version_id, ProjectExportVersion.project_id == project_id)
.first()
)
if not version:
raise HTTPException(status_code=404, detail="Wersja projektu nie znaleziona")
content = version.content_markdown
if not content:
sections = (
db.query(ProjectSection)
.filter(ProjectSection.project_id == project_id)
.order_by(ProjectSection.order)
.all()
)
if not sections:
raise HTTPException(
status_code=400,
detail="Brak sekcji we wniosku. Uzupełnij treść najpierw.",
)
content = f"# {project.title}\n\n"
for s in sections:
sec_title = UNIVERSAL_FALLBACK_MAP.get(
s.section_type, getattr(s, "title", s.section_type.replace("_", " ").title())
)
content += f"## {sec_title}\n\n"
if s.content:
content += f"{s.content}\n\n"
format_ext = request.format.lower()
if format_ext not in ["pdf", "docx"]:
raise HTTPException(
status_code=400, detail="Format musi być 'pdf' lub 'docx'"
)
temp_dir = tempfile.gettempdir()
safe_title = (
"".join(c for c in project.title if c.isalnum() or c in " _-")
.strip()
.replace(" ", "_")
)
if not safe_title:
safe_title = "Wniosek"
filename = f"{safe_title}.{format_ext}"
filepath = os.path.join(temp_dir, f"export_{uuid.uuid4().hex}.{format_ext}")
company_name = "Brak nazwy firmy"
if project.external_context and "company_data" in project.external_context:
company_data = project.external_context["company_data"]
company_name = company_data.get("name", "Brak nazwy firmy")
if "nip" in company_data:
company_name += f" (NIP: {company_data['nip']})"
elif project.external_context and "name" in project.external_context:
company_name = project.external_context["name"]
if "nip" in project.external_context:
company_name += f" (NIP: {project.external_context['nip']})"
elif project.external_context and "company_name" in project.external_context:
company_name = project.external_context["company_name"]
if "nip" in project.external_context:
company_name += f" (NIP: {project.external_context['nip']})"
version = "1.0"
date_str = datetime.now().strftime("%d.%m.%Y")
extra_ctx = {}
if project.external_context:
extra_ctx = dict(project.external_context)
if "company_data" in project.external_context:
cd = project.external_context["company_data"]
extra_ctx["nip"] = cd.get("nip", extra_ctx.get("nip"))
extra_ctx["krs"] = cd.get("krs", extra_ctx.get("krs"))
extra_ctx["regon"] = cd.get("regon", extra_ctx.get("regon"))
extra_ctx["beneficjent"] = {
"nazwa": company_name,
"nip": extra_ctx.get("nip", "Brak danych"),
"krs": extra_ctx.get("krs", "Brak danych"),
"regon": extra_ctx.get("regon", "Brak danych"),
}
extra_ctx["projekt"] = {
"tytul": project.title,
"akronim": "".join(
[word[0] for word in project.title.split() if word]
).upper()
if project.title
else "",
"program": project.program_name or project.program_type,
"budzet_calkowity": project.estimated_value or "Brak danych",
"dofinansowanie": "Zgodnie z wnioskiem",
"koszty_posrednie": "Zgodnie z limitem dla programu ryczałtowego",
}
task_id = uuid.uuid4().hex
EXPORT_TASKS[task_id] = {
"status": "processing",
"filepath": filepath,
"filename": filename,
"format": format_ext
}
background_tasks.add_task(
background_export_task,
task_id,
format_ext,
content,
filepath,
request.template,
project.title,
company_name,
version,
date_str,
extra_ctx
)
return {
"task_id": task_id,
"status": "processing",
"message": "Dokument generuje się w tle. Odpytuj endpoint /export/status/{task_id}."
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Błąd eksportu: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
finally:
db.close()
@router.get("/{project_id}/export/status/{task_id}")
def get_export_status(project_id: str, task_id: str, token_data: dict = Depends(verify_token)):
if task_id not in EXPORT_TASKS:
raise HTTPException(404, "Zadanie nie istnieje")
task = EXPORT_TASKS[task_id]
if task["status"] == "completed":
return {
"status": "completed",
"download_url": f"/api/projects/{project_id}/export/download/{task_id}"
}
return {"status": task["status"], "error": task.get("error")}
@router.get("/{project_id}/export/download/{task_id}")
def download_export(project_id: str, task_id: str, token_data: dict = Depends(verify_token)):
if task_id not in EXPORT_TASKS:
raise HTTPException(404, "Zadanie nie istnieje")
task = EXPORT_TASKS[task_id]
if task["status"] != "completed":
raise HTTPException(400, "Dokument nie jest gotowy")
media_type = "application/pdf" if task["format"] == "pdf" else "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
return FileResponse(
task["filepath"],
media_type=media_type,
filename=task["filename"]
)
@router.get("/{project_id}/versions")
async def get_project_export_versions(
project_id: str, token_data: dict = Depends(verify_token), db=Depends(get_db)
):
clerk_id = token_data.get("sub")
project = (
db.query(Project)
.filter(Project.id == project_id, Project.clerk_user_id == clerk_id)
.first()
)
if not project:
raise HTTPException(status_code=404, detail="Projekt nie znaleziony")
versions = (
db.query(ProjectExportVersion)
.filter(ProjectExportVersion.project_id == project_id)
.order_by(ProjectExportVersion.version_number.desc())
.all()
)
result = []
for v in versions:
result.append(
{
"id": v.id,
"version_number": v.version_number,
"title": v.title,
"created_at": v.created_at.isoformat() if v.created_at else None,
}
)
return result
class CreateVersionRequest(BaseModel):
title: Optional[str] = None
@router.post("/{project_id}/versions")
async def create_project_export_version(
project_id: str,
data: CreateVersionRequest,
token_data: dict = Depends(verify_token),
db=Depends(get_db),
):
clerk_id = token_data.get("sub")
project = (
db.query(Project)
.filter(Project.id == project_id, Project.clerk_user_id == clerk_id)
.first()
)
if not project:
raise HTTPException(status_code=404, detail="Projekt nie znaleziony")
if not project.final_document_markdown:
# Auto-compile current doc if empty
sections = (
db.query(ProjectSection)
.filter(ProjectSection.project_id == project_id)
.order_by(ProjectSection.order)
.all()
)
content = f"# {project.title}\n\n"
for s in sections:
sec_title = UNIVERSAL_FALLBACK_MAP.get(s.section_type, getattr(s, "title", s.section_type.upper()))
content += f"## {sec_title}\n\n"
if s.content:
content += f"{s.content}\n\n"
project.final_document_markdown = content
db.commit()
last_v = (
db.query(ProjectExportVersion)
.filter(ProjectExportVersion.project_id == project_id)
.order_by(ProjectExportVersion.version_number.desc())
.first()
)
new_v_number = (last_v.version_number + 1) if last_v else 1
new_version = ProjectExportVersion(
id=str(uuid.uuid4()),
project_id=project_id,
version_number=new_v_number,
title=data.title or f"Wersja {new_v_number}",
content_markdown=project.final_document_markdown,
export_type="archived",
)
db.add(new_version)
db.commit()
db.refresh(new_version)
return {
"id": new_version.id,
"version_number": new_version.version_number,
"title": new_version.title,
"created_at": new_version.created_at.isoformat()
if new_version.created_at
else None,
}
class ExpenseEvaluateRequest(BaseModel):
expense_description: str = Field(..., title="Opis wydatku")
expense_amount: float = Field(..., title="Kwota wydatku")
@router.post("/{project_id}/evaluate-expense")
async def evaluate_project_expense_endpoint(
project_id: str,
request: ExpenseEvaluateRequest,
token_data: dict = Depends(verify_token),
db=Depends(get_db),
):
clerk_id = token_data.get("sub")
project = (
db.query(Project)
.filter(Project.id == project_id, Project.clerk_user_id == clerk_id)
.first()
)
if not project:
raise HTTPException(status_code=404, detail="Projekt nie znaleziony")
# Call evaluator agent with strict Pydantic parsing (Faza 4)
from agents.evaluator import evaluate_project_expense
try:
company_size = "MŚP"
if project.external_context and "size" in project.external_context:
company_size = project.external_context["size"]
tenant_id = token_data.get("sub")
evaluation_result = evaluate_project_expense(
expense_description=request.expense_description,
expense_amount=request.expense_amount,
project_title=project.title,
program_name=project.program_name,
company_size=company_size,
tenant_id=tenant_id,
)
return {
"status": "ok",
"project_id": project_id,
"evaluation": evaluation_result.dict(),
}
except Exception as e:
logger.error(f"Evaluating expense failed: {str(e)}", exc_info=True)
raise HTTPException(
status_code=500, detail=f"Błąd silnika AI podczas oceny wydatku: {str(e)}"
)
class MSMERequest(BaseModel):
krs_number: str = Field(..., title="Numer KRS firmy do weryfikacji powiązań")
@router.post("/{project_id}/analyze-msme")
async def analyze_project_msme_endpoint(
project_id: str,
request: MSMERequest,
token_data: dict = Depends(verify_token),
db=Depends(get_db),
):
"""
Endpoint pozwalający na badanie wielkości przedsiębiorstwa
przy pomocy analizy wielowymiarowej z bazy Neo4j i KRS_Graph_RAG.
"""
clerk_id = token_data.get("sub")
project = (
db.query(Project)
.filter(Project.id == project_id, Project.clerk_user_id == clerk_id)
.first()
)
if not project:
raise HTTPException(status_code=404, detail="Projekt nie znaleziony")
from agents.tools.krs_graph_tool import analyze_company_network
try:
# Analiza po stronie agencji analitycznej
analysis_result = analyze_company_network.invoke(request.krs_number)
# Pamiętajmy, by wpisać wstępną analizę do bazy aby Finansista i Prawnik nie musieli robić tego samemu
if (
"size" not in project.external_context
or project.external_context.get("krs_analysis") is None
):
context = project.external_context or {}
context["krs_analysis"] = analysis_result
project.external_context = context
db.commit()
return {
"status": "ok",
"project_id": project_id,
"krs_network_analysis": analysis_result,
}
except Exception as e:
logger.error(f"Evaluating MSME failed: {str(e)}", exc_info=True)
raise HTTPException(
status_code=500, detail=f"Błąd narzędzi Neo4j/KRS podczas oceny: {str(e)}"
)