import grantforge_bootstrap # ruff: noqa: E402 import os import json import jwt from datetime import datetime, timezone # Włącz tracing LangSmith # Ostrzeżenie: Plik konfiguracyjny (core.langsmith_config) zajmie się ustawieniem "true" # os.environ["LANGCHAIN_TRACING_V2"] = "false" os.environ["LANGCHAIN_PROJECT"] = "grantforge-production" from dotenv import load_dotenv load_dotenv() # Initialize all SQLAlchemy models on the shared Base early # This prevents "Table already defined" errors when models are imported from multiple places from core.subscription.db import init_models init_models() from fastapi import FastAPI, Depends, HTTPException, Request, BackgroundTasks from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from typing import List, Dict, Any, Optional from contextlib import asynccontextmanager from fastapi.responses import JSONResponse from starlette.middleware.base import BaseHTTPMiddleware from core.logging_config import setup_logging, set_request_id from core.rate_limiter import RateLimitMiddleware from core.scheduler import start_scheduler, stop_scheduler from core.langsmith_config import configure_langsmith from langserve import add_routes from graph import app as langgraph_app from core.subscription.middleware import verify_token, check_api_quota from core.subscription.callbacks import TokenUsageCallback from core.subscription.tracker import get_or_create_usage from core.subscription.db import SessionLocal from core.subscription.checker import SubscriptionChecker from core.subscription.webhooks import router as stripe_router from endpoints.projects import router as projects_router from endpoints.generator import router as generator_router from endpoints.documents import router as documents_router from endpoints.stripe_webhooks import stripe_router as stripe_checkout_router from endpoints.grants import router as grants_router from endpoints.admin import router as admin_router from endpoints.admin_diagnostics import router as admin_diagnostics_router from endpoints.graph_analysis import router as graph_analysis_router from endpoints.company import router as company_router from endpoints.audit import router as audit_router from endpoints.trust_center import router as trust_center_router from endpoints.notifications import router as notifications_router # Konfiguracja bezpiecznego logowania i formatowania dla środowisk produkcyjnych (Platformy chmurowe np. Render) logger = setup_logging() import sentry_sdk sentry_dsn = os.environ.get("SENTRY_DSN") if sentry_dsn: sentry_sdk.init( dsn=sentry_dsn, traces_sample_rate=1.0, profiles_sample_rate=1.0, ) logger.info("[Startup] Sentry SDK zainicjalizowane.") # Lifespan: uruchamiany przy starcie i zamknięciu serwera @asynccontextmanager async def lifespan(app: FastAPI): logger.info("[Startup] GrantForge AI backend uruchamiany...") bootstrap_status = grantforge_bootstrap.bootstrap_startup() logger.info( "[Startup] Bootstrap patch-set %s — włączone: %s", bootstrap_status.get("patch_set_version"), bootstrap_status.get("enabled_patches"), ) from core.orchestration_entry import register_legacy_langgraph_route register_legacy_langgraph_route(route="/api") # Weryfikacja sekretów dla środowisk produkcyjnych (HF Spaces / Render) required_secrets = ["PINECONE_API_KEY", "NEO4J_URI", "GOOGLE_API_KEY", "GROK_API_KEY"] for secret in required_secrets: if not os.environ.get(secret): logger.warning(f"[Startup] Brak zmiennej środowiskowej: {secret}. Niektóre usługi (np. Vector DB) mogą działać w trybie fallback lub nie działać poprawnie.") # FAZA 6: LLMOps — LangSmith tracing langsmith_active = configure_langsmith() if langsmith_active: logger.info("[Startup] LangSmith tracing: AKTYWNY") else: logger.warning("[Startup] LangSmith tracing: WYŁĄCZONY (brak klucza)") # Foundational v5.0 observability (metrics, search/gen error logging, quality signals) logger.info("[Startup] Foundational ObservabilityMetrics initialized (search+generation focus, /api/admin/metrics available).") # Inicjalizacja bazy grafowej (Knowledge Graph - Bug 19) from core.graph_db.neo4j_client import neo4j_client try: neo4j_client.connect() logger.info("[Startup] Połączenie z Neo4j Graph Database ustanowione.") except Exception as e: logger.error(f"[Startup] Błąd inicjalizacji Neo4j: {e}") # Tworzenie struktur w bazie relacyjnej (Postgres/SQLite) jeśli uruchamiamy na czysto (np. Hugging Face) from core.subscription.db import Base, engine try: Base.metadata.create_all(bind=engine) logger.info("[Startup] Struktura bazy danych SQL zsynchronizowana pomyślnie.") except Exception as e: logger.error(f"[Startup] Błąd synchronizacji bazy SQL: {e}") # Background scheduler PARP/NCBR cache start_scheduler() # Check if grants table is empty, if so trigger background sync try: from core.subscription.db import grant_count_with_retry from core.grants.sync_service import sync_grants_to_db import asyncio count = grant_count_with_retry() if count == 0: logger.info("[Startup] Baza naborów jest pusta! Uruchamiam asynchroniczne pobieranie naborów w tle...") asyncio.create_task(sync_grants_to_db()) else: logger.info(f"[Startup] Baza naborów zawiera {count} rekordów.") except Exception as e: logger.error(f"[Startup] Błąd sprawdzania bazy naborów na starcie: {e}") yield logger.info("[Shutdown] Zamykanie połączeń i schedulera...") stop_scheduler() try: neo4j_client.close() except Exception as e: logger.error(f"[Shutdown] Błąd zamykania Neo4j: {e}") # Tworzymy aplikację FastAPI (backend) app = FastAPI( title="DotacjeAI Backend", version="1.2.0", description="Serwer LangGraph udostępniający logikę agentową przez REST API.", lifespan=lifespan, ) app.add_middleware( CORSMiddleware, allow_origins=[ "https://grantforge.pl", "https://www.grantforge.pl", "https://grantforge-frontend.onrender.com", "http://localhost:5173", "http://localhost:5174", "http://localhost:8000", "http://localhost:3000", ], allow_origin_regex=r"https://.*\.vercel\.app|https://.*\.hf\.space", allow_credentials=True, allow_methods=["*"], allow_headers=["*"], expose_headers=["*"], ) class RequestIDMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): # Generujemy / ustalamy ID na całe żądanie i dla kontekstu asyncio req_id = set_request_id() response = await call_next(request) response.headers["X-Request-ID"] = req_id return response app.add_middleware(RequestIDMiddleware) app.add_middleware(RateLimitMiddleware) app.include_router(stripe_router) app.include_router(projects_router) app.include_router(generator_router, prefix="/api/generator") app.include_router(documents_router) # POST /api/projects/{id}/documents app.include_router(grants_router) app.include_router(admin_router, prefix="/api/admin") app.include_router(admin_diagnostics_router, prefix="/api/admin/diagnostics") app.include_router(graph_analysis_router) app.include_router(company_router) app.include_router(audit_router) app.include_router(trust_center_router) app.include_router(notifications_router) @app.exception_handler(Exception) async def global_exception_handler(request: Request, exc: Exception): if isinstance(exc, HTTPException): return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) # Logujemy błąd do konsoli lub zewnętrznego systemu typu Sentry logger.error( f"Global Error On {request.method} {request.url.path} - Exception: {str(exc)}", exc_info=True, ) # Zwracamy ogólny komunikat, by nie demaskować konfiguracji lub stacktrace'a return JSONResponse( status_code=500, content={"detail": f"Wewnętrzny błąd serwera: {str(exc)}"} ) async def config_modifier(config: dict, request: Request) -> dict: """Wstrzykuje thread_id oraz ładuje callbacki dla LLMa do LangGraph pod maską API.""" user_id = "anonymous" # Wyciągamy token sub w locie dla wstrzyknięcia do callbacku auth_header = request.headers.get("Authorization") if auth_header and auth_header.startswith("Bearer "): token = auth_header.split(" ")[1] try: if token == "dev_test_token": user_id = "test_dev_user" else: decoded = jwt.decode(token, options={"verify_signature": False}) user_id = decoded.get("sub", "anonymous") except Exception: pass # Rejestrujemy TokenUsageCallback aby podsłuchiwał stream LLMa if "callbacks" not in config: config["callbacks"] = [] config["callbacks"].append(TokenUsageCallback(user_id=user_id)) try: body = await request.body() if body: parsed = json.loads(body) thread_id = ( parsed.get("config", {}).get("configurable", {}).get("thread_id") ) if thread_id: if "configurable" not in config: config["configurable"] = {} config["configurable"]["thread_id"] = thread_id except Exception: pass return config class GraphInput(BaseModel): messages: List[Dict[str, Any]] user_id: str tenant_id: str verification_results: Optional[Dict[str, Any]] = None app.include_router( stripe_checkout_router, prefix="/api" ) # POST /api/subscription/checkout # === ENDPOINTY SUBSKRYPCJI === @app.get("/api/subscription/status") def get_subscription_status(token_data: dict = Depends(verify_token)): user_id = token_data.get("sub", "anonymous") checker = SubscriptionChecker(user_id) tier = checker.get_tier().value limits = checker.get_current_limits() db = SessionLocal() usage = get_or_create_usage(db, user_id) data = { "user_id": user_id, "tier": tier, "limits": limits, "wizard_iterations_today": usage.wizard_iterations_today, "tokens_used_month": usage.tokens_used_month, } db.close() return data # /api/subscription/checkout jest w endpoints/stripe_webhooks.py (używa STRIPE_SECRET_KEY + price_id) @app.get("/api/me") def get_current_user_info(token_data: dict = Depends(verify_token)): """Zwraca tier, quota i limity zalogowanego użytkownika.""" user_id = token_data.get("sub") if not user_id or user_id == "anonymous": raise HTTPException(status_code=401, detail="Nieuprawniony") db = SessionLocal() try: user = ( db.query(__import__("core.subscription.models", fromlist=["User"]).User) .filter_by(clerk_id=user_id) .first() ) tier = user.tier if user else "free" from core.subscription.tracker import get_or_create_usage from core.subscription.checker import SubscriptionChecker usage = get_or_create_usage(db, user_id) checker = SubscriptionChecker(user_id) limits = checker.get_current_limits() doc_limit = 50 if tier == "pro" else 3 return { "clerk_id": user_id, "tier": tier, "stripe_customer_id": user.stripe_customer_id if user else None, "quota": { "documents_per_project": doc_limit, "wizard_iterations_today": usage.wizard_iterations_today, "tokens_used_month": usage.tokens_used_month, }, "limits": limits, "settings": { "gdpr_consent_accepted": user.gdpr_consent_accepted if user else False, "ai_disclaimer_enabled": user.ai_disclaimer_enabled if user else True, } } finally: db.close() class FeedbackRequest(BaseModel): text: str type: str = "feedback" @app.post("/api/feedback") def submit_feedback(data: FeedbackRequest, token_data: dict = Depends(verify_token)): user_id = token_data.get("sub") if not user_id or user_id == "anonymous": raise HTTPException(status_code=401, detail="Nieuprawniony") # Log the feedback logger.info(f"Otrzymano opinię od {user_id} typu {data.type}: {data.text}") # Wysłanie emaila w rzeczywistym przypadku (wymaga skonfigurowanych zmiennych środowiskowych) import smtplib from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart smtp_host = os.environ.get("SMTP_HOST", "smtp.gmail.com") smtp_port = int(os.environ.get("SMTP_PORT", 587)) smtp_user = os.environ.get("SMTP_USER", "") smtp_pass = os.environ.get("SMTP_PASS", "") resend_api_key = os.environ.get("RESEND_API_KEY", "") target_email = "bogmaz1@gmail.com" body = f"Typ zgłoszenia: {data.type}\nOd użytkownika: {user_id}\n\nTreść zgłoszenia:\n{data.text}" subject = f"Dotacje AI - Zgłoszenie błędu / Feedback od: {user_id}" if resend_api_key: try: import requests headers = { "Authorization": f"Bearer {resend_api_key}", "Content-Type": "application/json" } payload = { "from": "Dotacje AI ", "to": target_email, "subject": subject, "text": body } resp = requests.post("https://api.resend.com/emails", json=payload, headers=headers) resp.raise_for_status() logger.info("Wysłano e-mail ze zgłoszeniem błędu do administratora przez Resend API.") except Exception as e: logger.error(f"Nie udało się wysłać e-maila ze zgłoszeniem błędu przez Resend: {e}") # Nie rzucamy wyjątku 500, żeby nie blokować UI użytkownikowi. Feedback zapisuje się w logach. elif smtp_user and smtp_pass: try: msg = MIMEMultipart() msg['From'] = smtp_user msg['To'] = target_email msg['Subject'] = subject msg.attach(MIMEText(body, 'plain', 'utf-8')) server = smtplib.SMTP(smtp_host, smtp_port) server.starttls() server.login(smtp_user, smtp_pass) text = msg.as_string() server.sendmail(smtp_user, target_email, text) server.quit() logger.info("Wysłano e-mail ze zgłoszeniem błędu do administratora przez SMTP.") except Exception as e: logger.error(f"Nie udało się wysłać e-maila ze zgłoszeniem błędu przez SMTP: {e}") # Nie rzucamy wyjątku, żeby nie psuć UX else: logger.warning("Brak konfiguracji SMTP lub Resend API w zmiennych środowiskowych. Zgłoszenie zapisano tylko w logach.") # Nie rzucamy wyjątku 500, logi wystarczą. return {"status": "ok", "message": "Opinia została zapisana i przesłana."} @app.delete("/api/account") def delete_user_account(token_data: dict = Depends(verify_token)): from core.subscription.db import SessionLocal from core.subscription.models import User, UserUsage, UsageLog from core.projects.models import Project from rag_pipeline.vector_store import delete_user_documents user_id = token_data.get("sub") if not user_id or user_id == "anonymous": raise HTTPException( status_code=401, detail="Nieprawidłowy token uwierzytelniający." ) db = SessionLocal() try: logger.info( f"Rozpoczynam proces 'Prawa do zapomnienia' dla użytkownika {user_id}" ) # 1. Usuwamy dane wektorowe z Pinecone (jeśli istnieją) delete_user_documents(user_id) # 2. Usuwamy projekty (baza cascade usunie też sekcje, wersje, pytania) projects = db.query(Project).filter(Project.clerk_user_id == user_id).all() for p in projects: db.delete(p) # 3. Usuwamy logi i zużycie subskrypcji usage = db.query(UserUsage).filter(UserUsage.user_id == user_id).first() if usage: db.delete(usage) logs = db.query(UsageLog).filter(UsageLog.user_id == user_id).all() for log_entry in logs: db.delete(log_entry) # 4. Usuwamy sam rekord User user = db.query(User).filter(User.clerk_id == user_id).first() if user: db.delete(user) db.commit() logger.info( f"✅ Poprawnie usunięto wszystkie dane użytkownika {user_id} (RODO)." ) # Zewnętrzny log RODO do pliku dla celów audytu import os log_path = os.path.join(os.path.dirname(__file__), "logs") os.makedirs(log_path, exist_ok=True) with open(os.path.join(log_path, "gdpr_audit.log"), "a", encoding="utf-8") as f: f.write( f"[{datetime.now(timezone.utc).isoformat()}Z] DELETE_ACCOUNT: W pełni wymazano dane użytkownika {user_id}.\\n" ) return { "status": "ok", "message": "Konto i wszystkie powiązane dane zostały trwale usunięte. Zostaniesz wylogowany.", } except Exception as e: db.rollback() logger.error(f"❌ Błąd podczas usuwania konta {user_id}: {e}") raise HTTPException( status_code=500, detail="Wystąpił błąd podczas kompletnego usuwania konta." ) finally: db.close() class AccountSettingsUpdate(BaseModel): gdpr_consent_accepted: Optional[bool] = None ai_disclaimer_enabled: Optional[bool] = None @app.post("/api/account/settings") def update_account_settings( data: AccountSettingsUpdate, token_data: dict = Depends(verify_token) ): from core.subscription.db import SessionLocal from core.subscription.models import User import os user_id = token_data.get("sub") if not user_id or user_id == "anonymous": raise HTTPException( status_code=401, detail="Nieprawidłowy token uwierzytelniający." ) db = SessionLocal() try: user = db.query(User).filter(User.clerk_id == user_id).first() if not user: user = User(clerk_id=user_id) db.add(user) changes = [] if data.gdpr_consent_accepted is not None: user.gdpr_consent_accepted = data.gdpr_consent_accepted user.gdpr_consent_timestamp = datetime.now(timezone.utc) changes.append(f"GDPR_CONSENT={data.gdpr_consent_accepted}") if data.ai_disclaimer_enabled is not None: user.ai_disclaimer_enabled = data.ai_disclaimer_enabled changes.append(f"AI_DISCLAIMER={data.ai_disclaimer_enabled}") db.commit() if changes: log_path = os.path.join(os.path.dirname(__file__), "logs") os.makedirs(log_path, exist_ok=True) with open( os.path.join(log_path, "gdpr_audit.log"), "a", encoding="utf-8" ) as f: f.write( f"[{datetime.now(timezone.utc).isoformat()}Z] UPDATE_SETTINGS: {user_id} zmodyfikował ustawienia: {', '.join(changes)}\n" ) return {"status": "ok", "message": "Zaktualizowano ustawienia konta."} except Exception as e: db.rollback() logger.error(f"❌ Błąd aktualizacji ustawień konta {user_id}: {e}") raise HTTPException( status_code=500, detail="Wystąpił błąd podczas zapisywania ustawień." ) finally: db.close() @app.get("/api/account/export") def export_user_data(token_data: dict = Depends(verify_token)): from core.subscription.db import SessionLocal from core.projects.models import Project user_id = token_data.get("sub") if not user_id or user_id == "anonymous": raise HTTPException( status_code=401, detail="Nieprawidłowy token uwierzytelniający." ) db = SessionLocal() try: logger.info(f"Rozpoczynam eksport danych RODO dla użytkownika {user_id}") projects = db.query(Project).filter(Project.clerk_user_id == user_id).all() export_data = { "user_id": user_id, "export_date": datetime.now(timezone.utc).isoformat() + "Z", "projects": [ { "id": p.id, "title": p.title, "program_type": p.program_type, "created_at": p.created_at.isoformat() if p.created_at else None, "sections": [ { "id": s.id, "section_type": s.section_type, "content": s.content, } for s in p.sections ], } for p in projects ], } # Zewnętrzny log RODO do pliku dla celów audytu import os log_path = os.path.join(os.path.dirname(__file__), "logs") os.makedirs(log_path, exist_ok=True) with open(os.path.join(log_path, "gdpr_audit.log"), "a", encoding="utf-8") as f: f.write( f"[{datetime.now(timezone.utc).isoformat()}Z] EXPORT_DATA: Wygenerowano eksport danych dla użytkownika {user_id}.\\n" ) return export_data except Exception as e: logger.error(f"❌ Błąd podczas eksportu danych {user_id}: {e}") raise HTTPException( status_code=500, detail="Wystąpił błąd podczas eksportu danych konta." ) finally: db.close() # === ENDPOINTY DASHBOARDU V2 (MOCK PROJEKTÓW I SESJI) === @app.get("/api/session/current") def get_current_session(token_data: dict = Depends(verify_token)): return { "thread_id": "session-1234", "status": "wizard", "agent": "wizard", "critic_evaluation": { "is_approved": False, "feedback": "Brak precyzyjnego ujęcia cyklu życia opisywanych czujników (DNSH).", }, "tokens_used": 1500, "active_step": 4, } @app.get("/api/projects") def get_projects(token_data: dict = Depends(verify_token)): from core.subscription.db import SessionLocal from core.projects.models import Project db = SessionLocal() user_id = token_data.get("sub") # Zwrócenie prawdziwych projektów zamiast mocka projects = ( db.query(Project) .filter(Project.clerk_user_id == user_id) .order_by(Project.created_at.desc()) .all() ) res = [] for p in projects: res.append( { "id": p.id, "title": p.title, "description": p.description, "program_name": p.program_name, "estimated_value": p.estimated_value, "status": p.status, "created_at": p.created_at.isoformat() if p.created_at else None, "updated_at": p.updated_at.isoformat() if p.updated_at else None, "clerk_user_id": p.clerk_user_id, "sections": [{"is_approved": s.is_approved} for s in p.sections] if p.sections else [], } ) db.close() return res class RagSyncRequest(BaseModel): category: str = "SMART" @app.post("/api/rag/sync") def sync_rag_knowledge( data: RagSyncRequest, background_tasks: BackgroundTasks, token_data: dict = Depends(verify_token), ): from rag_pipeline.refresh_job import run_daily_cron background_tasks.add_task(run_daily_cron) return { "status": "ok", "message": f"Rozpoczęto synchronizację bazy wektorowej w tle dla {data.category}. Może to potrwać kilka minut.", } class CreateVersionRequest(BaseModel): title: Optional[str] = None @app.post("/api/projects/{project_id}/versions") def create_project_version( project_id: str, data: CreateVersionRequest, token_data: dict = Depends(verify_token), ): from core.subscription.db import SessionLocal from core.projects.models import Project, ProjectSection, ProjectExportVersion db = SessionLocal() user_id = token_data.get("sub") project = ( db.query(Project) .filter(Project.id == project_id, Project.clerk_user_id == user_id) .first() ) if not project: db.close() raise HTTPException(status_code=404, detail="Brak projektu") sections = ( db.query(ProjectSection) .filter(ProjectSection.project_id == project_id, ProjectSection.is_approved) .order_by(ProjectSection.order.asc()) .all() ) if not sections: sections = ( db.query(ProjectSection) .filter(ProjectSection.project_id == project_id) .order_by(ProjectSection.order.asc()) .all() ) markdown_content = f"# {project.title}\\n\\n" for s in sections: markdown_content += ( f"## {s.section_type.replace('_',' ').title()}\\n\\n{s.content or ''}\\n\\n" ) last_ver = ( db.query(ProjectExportVersion) .filter(ProjectExportVersion.project_id == project_id) .order_by(ProjectExportVersion.version_number.desc()) .first() ) next_num = (last_ver.version_number + 1) if last_ver else 1 new_version = ProjectExportVersion( project_id=project_id, version_number=next_num, title=data.title or f"Wersja {next_num}", content_markdown=markdown_content, ) db.add(new_version) db.commit() db.refresh(new_version) res = { "id": new_version.id, "version_number": new_version.version_number, "title": new_version.title, "created_at": new_version.created_at.isoformat() + "Z", } db.close() return res @app.get("/api/projects/{project_id}/versions") def list_project_versions(project_id: str, token_data: dict = Depends(verify_token)): from core.subscription.db import SessionLocal from core.projects.models import ProjectExportVersion, Project db = SessionLocal() user_id = token_data.get("sub") project = ( db.query(Project) .filter(Project.id == project_id, Project.clerk_user_id == user_id) .first() ) if not project: db.close() raise HTTPException(status_code=404, detail="Brak projektu") versions = ( db.query(ProjectExportVersion) .filter(ProjectExportVersion.project_id == project_id) .order_by(ProjectExportVersion.version_number.desc()) .all() ) res = [] for v in versions: res.append( { "id": v.id, "version_number": v.version_number, "title": v.title, "created_at": v.created_at.isoformat() + "Z", "export_type": v.export_type, } ) db.close() return res @app.get("/api/projects/{project_id}/export-pdf") def export_project_pdf( project_id: str, version_id: Optional[str] = None, token_data: dict = Depends(verify_token), ): from core.subscription.db import SessionLocal from core.projects.models import Project, ProjectSection, ProjectExportVersion from io import BytesIO from fastapi.responses import StreamingResponse import markdown db = SessionLocal() user_id = token_data.get("sub") project = ( db.query(Project) .filter(Project.id == project_id, Project.clerk_user_id == user_id) .first() ) if not project: db.close() raise HTTPException(status_code=404, detail="Brak projektu") if version_id: version = ( db.query(ProjectExportVersion) .filter( ProjectExportVersion.id == version_id, ProjectExportVersion.project_id == project_id, ) .first() ) if not version: db.close() raise HTTPException(status_code=404, detail="Brak wersji") html_body = markdown.markdown(version.content_markdown) else: from core.project_markdown import build_markdown_from_sections, get_template_titles_for_project sections = ( db.query(ProjectSection) .filter(ProjectSection.project_id == project_id, ProjectSection.is_approved) .order_by(ProjectSection.order.asc()) .all() ) if not sections: sections = ( db.query(ProjectSection) .filter(ProjectSection.project_id == project_id) .order_by(ProjectSection.order.asc()) .all() ) template_titles = get_template_titles_for_project(project) md_text = build_markdown_from_sections(project, sections, template_titles) if not md_text: db.close() raise HTTPException(status_code=400, detail="Brak treści w sekcjach projektu.") html_body = markdown.markdown(md_text) db.close() import os import urllib.request from reportlab.pdfbase.ttfonts import TTFont from reportlab.pdfbase import pdfmetrics import xhtml2pdf.default # Pobieranie fontu darmowego, aby obsłużyć poprawne Polskie Znaki w utf-8 dla xhtml2pdf backend_dir = os.path.dirname(os.path.abspath(__file__)) dejavu_path = os.path.join(backend_dir, "DejaVuSans.ttf") if not os.path.exists(dejavu_path): try: urllib.request.urlretrieve( "https://cdn.jsdelivr.net/npm/@vintproykt/dejavu-fonts-ttf/ttf/DejaVuSans.ttf", dejavu_path, ) except Exception as err: print(f"Nie powiodlo sie sciagniecie dejavu font: {err}") if os.path.exists(dejavu_path): pdfmetrics.registerFont(TTFont("DejaVu Sans", dejavu_path)) xhtml2pdf.default.DEFAULT_FONT["helvetica"] = "DejaVu Sans" xhtml2pdf.default.DEFAULT_FONT["sans-serif"] = "DejaVu Sans" xhtml2pdf.default.DEFAULT_FONT["arial"] = "DejaVu Sans" font_family_css = "font-family: 'DejaVu Sans', 'Times New Roman', serif;" font_face_css = f"@font-face {{ font-family: 'DejaVu Sans'; src: url('{dejavu_path}'); }}" else: font_family_css = "font-family: 'Times New Roman', serif;" font_face_css = "" html_content = f""" {html_body} """ try: from xhtml2pdf import pisa pdf_file = BytesIO() # Ważne: podajemy kodowanie by parsowało się jako bytes UTF-8 pisa.CreatePDF(html_content.encode("utf-8"), dest=pdf_file, encoding="utf-8") pdf_bytes = pdf_file.getvalue() filename = ( f"dotacja_{project_id}.pdf" if not version_id else f"dotacja_v{version_id[:6]}.pdf" ) return StreamingResponse( BytesIO(pdf_bytes), media_type="application/pdf", headers={"Content-Disposition": f"attachment; filename={filename}"}, ) except Exception: import traceback print("Blad generowania PDF: ", traceback.format_exc()) return StreamingResponse( BytesIO(html_content.encode("utf-8")), media_type="text/html", headers={"Content-Disposition": "attachment; filename=dotacja.html"}, ) @app.get("/api/projects/{project_id}/export-docx") def export_project_docx( project_id: str, version_id: Optional[str] = None, approved_only: bool = False, token_data: dict = Depends(verify_token), ): from core.subscription.db import SessionLocal from core.projects.models import Project, ProjectSection, ProjectExportVersion from io import BytesIO from fastapi.responses import StreamingResponse import docx from docx.shared import Pt from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.oxml import OxmlElement from docx.oxml.ns import qn import markdown from bs4 import BeautifulSoup db = SessionLocal() user_id = token_data.get("sub") project = ( db.query(Project) .filter(Project.id == project_id, Project.clerk_user_id == user_id) .first() ) if not project: db.close() raise HTTPException(status_code=404, detail="Brak projektu") if version_id: version = ( db.query(ProjectExportVersion) .filter( ProjectExportVersion.id == version_id, ProjectExportVersion.project_id == project_id, ) .first() ) if not version: db.close() raise HTTPException(status_code=404, detail="Brak wersji") md_text = version.content_markdown else: from core.project_markdown import build_markdown_from_sections, get_template_titles_for_project query = db.query(ProjectSection).filter(ProjectSection.project_id == project_id) if approved_only: query = query.filter(ProjectSection.is_approved) sections = query.order_by(ProjectSection.order.asc()).all() if not sections: db.close() raise HTTPException(status_code=400, detail="Brak sekcji w projekcie.") template_titles = get_template_titles_for_project(project) md_text = build_markdown_from_sections(project, sections, template_titles) if not md_text: db.close() raise HTTPException(status_code=400, detail="Brak treści w sekcjach projektu.") db.close() doc = docx.Document() style = doc.styles["Normal"] font = style.font font.name = "Times New Roman" font.size = Pt(11) # Strona tytułowa title = doc.add_heading(project.title, 0) title.alignment = WD_ALIGN_PARAGRAPH.CENTER doc.add_paragraph().add_run().add_break(docx.enum.text.WD_BREAK.PAGE) # Informacja o spisie treści p_info = doc.add_paragraph() run_info = p_info.add_run( "Po otwarciu dokumentu naciśnij F9, aby zaktualizować spis treści." ) run_info.italic = True run_info.font.color.rgb = docx.shared.RGBColor(128, 128, 128) # Spis treści TOC doc.add_heading("Spis treści", level=1) p_toc = doc.add_paragraph() run_toc = p_toc.add_run() fldChar1 = OxmlElement("w:fldChar") fldChar1.set(qn("w:fldCharType"), "begin") instrText = OxmlElement("w:instrText") instrText.set(qn("xml:space"), "preserve") instrText.text = 'TOC \\o "1-3" \\h \\z \\u' fldChar2 = OxmlElement("w:fldChar") fldChar2.set(qn("w:fldCharType"), "separate") fldChar3 = OxmlElement("w:fldChar") fldChar3.set(qn("w:fldCharType"), "end") run_toc._r.append(fldChar1) run_toc._r.append(instrText) run_toc._r.append(fldChar2) run_toc._r.append(fldChar3) doc.add_paragraph().add_run().add_break(docx.enum.text.WD_BREAK.PAGE) html = markdown.markdown(md_text) soup = BeautifulSoup(html, "html.parser") for element in soup: if element.name in ["h1", "h2", "h3", "h4", "h5", "h6"]: level = int(element.name[1]) # pomijamy h1 dla nazwy projektu która jest już dodana manually, ale my zrobilismy ja w md_text tez # wipe out the first h1 if it matches project title exactly to not duplicate if level == 1 and element.text == project.title: continue doc.add_heading(element.text, level=level) elif element.name == "p": p = doc.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY for child in element.contents: if child.name in ["strong", "b"]: p.add_run(child.text).bold = True elif child.name in ["em", "i"]: p.add_run(child.text).italic = True elif child.name == "br": p.add_run().add_break() else: if getattr(child, "text", None): p.add_run(child.text) elif isinstance(child, str): p.add_run(child) elif element.name in ["ul", "ol"]: for li in element.find_all("li"): p = doc.add_paragraph(style="List Bullet") for child in li.contents: if child.name in ["strong", "b"]: p.add_run(child.text).bold = True elif getattr(child, "text", None): p.add_run(child.text) elif isinstance(child, str): p.add_run(child) f = BytesIO() doc.save(f) f.seek(0) filename = ( f"dotacja_{project_id}.docx" if not version_id else f"dotacja_v{version_id[:6]}.docx" ) return StreamingResponse( f, media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", headers={"Content-Disposition": f"attachment; filename={filename}"}, ) # Udostępnienie Grafu LangGraph pod bazowym adresem /api zabezpieczonym przez token Clerk. add_routes( app, langgraph_app.with_types(input_type=GraphInput), path="/api", dependencies=[Depends(check_api_quota)], per_req_config_modifier=config_modifier, ) @app.get("/health") def healthcheck(): return { "status": "healthy", "v5_readiness": "green", "version_marker": "v5.0-production-readiness", "features": ["golden_dataset", "citation+trap+data_quality", "light_paths", "query_router"] } @app.get("/api/health") def api_health(): """Rozszerzony health check z weryfikacją statusów serwisów.""" import time result = { "status": "healthy", "timestamp": datetime.now(timezone.utc).isoformat(), "version": "1.3.0", "services": {}, } # 1. Baza danych try: t0 = time.monotonic() db = SessionLocal() try: db.execute(__import__("sqlalchemy").text("SELECT 1")) finally: db.close() result["services"]["database"] = { "status": "ok", "latency_ms": round((time.monotonic() - t0) * 1000, 1), } except Exception as e: result["services"]["database"] = {"status": "error", "detail": str(e)} result["status"] = "degraded" # 2. LLM konfiguracja (Gemini) try: from core.llm_router import get_llm llm = get_llm(task_type="fast") result["services"]["llm"] = { "status": "ok", "model": getattr(llm, "model_name", "gemini"), } except Exception as e: result["services"]["llm"] = {"status": "error", "detail": str(e)} result["status"] = "degraded" # 3. Bielik (FAZA 4 — dedykowany model audytu prawnego) try: from core.llm_router import get_bielik_status bielik_status = get_bielik_status() result["services"]["bielik"] = bielik_status except Exception as e: result["services"]["bielik"] = {"available": False, "reason": str(e)} # 4. Pinecone pinecone_api_key = os.environ.get("PINECONE_API_KEY") result["services"]["pinecone"] = { "status": "ok" if pinecone_api_key else "not_configured" } # 5. LlamaParse (FAZA 2 — zaawansowane parsowanie PDF) result["services"]["llamaparse"] = { "status": "ok" if os.environ.get("LLAMA_CLOUD_API_KEY") else "not_configured", "note": "Wymagany LLAMA_CLOUD_API_KEY dla pełnego parsowania PDF", } # 6. LangSmith (FAZA 6 — LLMOps tracing) result["services"]["langsmith"] = { "status": "ok" if os.environ.get("LANGCHAIN_API_KEY") else "not_configured", "project": os.environ.get("LANGCHAIN_PROJECT", "—"), } # 7. Klucze zewnętrzne result["services"]["external_keys"] = { "crawl4ai": "configured", "gus": "configured" if os.environ.get("GUS_API_KEY") else "missing", } status_code = 200 if result["status"] == "healthy" else 503 return JSONResponse(content=result, status_code=status_code) @app.get("/") def read_root(): return {"status": "ok", "message": "DotacjeAI Backend API działa."} # === INTERNAL/ADMIN === @app.post("/api/internal/seed-smart") def api_seed_smart_sections(): from scripts.seed_sections import seed_smart_sections try: seed_smart_sections() return {"status": "ok", "message": "SMART sections seeded successfully"} except Exception as e: return {"status": "error", "message": str(e)} if __name__ == "__main__": import uvicorn # Uruchomienie serwera. Na Renderze zmienna PORT określi działający port, a lokalnie 8001. port = int(os.environ.get("PORT", 8001)) uvicorn.run(app, host="0.0.0.0", port=port)