Spaces:
Running
Running
| # 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 | |
| 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 | |
| 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."} | |
| 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 | |
| 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 | |
| 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)})", | |
| ) | |
| 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} | |
| 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 | |
| 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 | |
| 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() | |
| 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"} | |
| 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() | |
| 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"} | |
| 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 | |
| } | |
| 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 | |
| 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, | |
| } | |
| 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} | |
| 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 | |
| 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 | |
| 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)}" | |
| ) | |
| 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 | |
| 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 | |
| ] | |
| 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 | |
| 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, | |
| ) | |
| 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 | |
| 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 | |
| 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"], | |
| ) | |
| 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 | |
| ] | |
| 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"} | |
| 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 | |
| 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"] | |
| 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 | |
| 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 | |
| ] | |
| 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."} | |
| 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}") | |
| 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() | |
| 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")} | |
| 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"] | |
| ) | |
| 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 | |
| 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") | |
| 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ń") | |
| 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)}" | |
| ) | |