grantforge-api / backend /server.py
GrantForge Bot
Deploy sha-9a5957fcdef15b7e2623f8b147cda6026475aee0 — source build (no GHCR)
3a3734f
Raw
History Blame Contribute Delete
43.9 kB
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 <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."}
@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>
<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"},
)
@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)