# 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 ...treść.... BARDZO WAŻNE: Wewnątrz tagu 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)}" )