Spaces:
Running
Running
| 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 | |
| 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) | |
| 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 === | |
| 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) | |
| 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" | |
| 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 <onboarding@resend.dev>", | |
| "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."} | |
| 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 | |
| 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() | |
| 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) === | |
| 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, | |
| } | |
| 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" | |
| 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 | |
| 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 | |
| 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 | |
| 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> | |
| <head> | |
| <meta charset="utf-8"> | |
| <style> | |
| {font_face_css} | |
| @page {{ | |
| size: A4; | |
| margin: 2.5cm; | |
| @bottom-right {{ | |
| content: "Strona " counter(page) " z " counter(pages); | |
| {font_family_css} | |
| font-size: 10pt; | |
| color: #666; | |
| }} | |
| }} | |
| body {{ | |
| {font_family_css} | |
| line-height: 1.5; | |
| font-size: 11pt; | |
| color: #000; | |
| text-align: justify; | |
| }} | |
| h1 {{ | |
| font-size: 24pt; | |
| text-align: center; | |
| margin-top: 5cm; | |
| margin-bottom: 2cm; | |
| page-break-after: always; | |
| }} | |
| h2 {{ | |
| font-size: 14pt; | |
| color: #333; | |
| border-bottom: 1px solid #ccc; | |
| padding-bottom: 5px; | |
| page-break-before: always; | |
| margin-top: 0; | |
| }} | |
| h3 {{ font-size: 12pt; color: #444; }} | |
| p {{ margin-bottom: 12px; }} | |
| ul, ol {{ margin-bottom: 12px; padding-left: 20px; }} | |
| li {{ margin-bottom: 6px; }} | |
| </style> | |
| </head> | |
| <body> | |
| {html_body} | |
| </body> | |
| </html> | |
| """ | |
| 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"}, | |
| ) | |
| 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, | |
| ) | |
| 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"] | |
| } | |
| 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) | |
| def read_root(): | |
| return {"status": "ok", "message": "DotacjeAI Backend API działa."} | |
| # === INTERNAL/ADMIN === | |
| 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) | |