from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from typing import Optional import os import httpx app = FastAPI( title="Innovatieplatform AI Service", description="AI service for chat, summarization and semantic search.", version="0.2.0", ) app.add_middleware( CORSMiddleware, allow_origins=["*"], # Simplified for internal Docker network allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "") ANTHROPIC_MODEL = os.getenv("ANTHROPIC_MODEL", "claude-haiku-4-5-20251001") DB_URL = os.getenv("DATABASE_URL", "postgresql://innovatie:secret@postgresql:5432/innovatieplatform") # --- Request/Response models --- class ChatRequest(BaseModel): message: str project_id: Optional[int] = None conversation_history: Optional[list] = [] class ChatResponse(BaseModel): reply: str project_id: Optional[int] = None class SummarizeRequest(BaseModel): content: str project_id: Optional[int] = None summary_type: Optional[str] = "general" class SummarizeResponse(BaseModel): summary: str project_id: Optional[int] = None class SearchRequest(BaseModel): query: str project_id: Optional[int] = None limit: Optional[int] = 10 class SearchResult(BaseModel): id: int content: str score: float metadata: Optional[dict] = {} class SearchResponse(BaseModel): results: list[SearchResult] query: str # --- Database context helper --- async def get_project_context(project_id: int) -> str: """Fetch project details from PostgreSQL for AI context.""" try: import asyncpg conn = await asyncpg.connect(DB_URL) # Get project info project = await conn.fetchrow( "SELECT naam, beschrijving, status, prioriteit FROM projects WHERE id = $1", project_id ) if not project: await conn.close() return "" # Get documents docs = await conn.fetch( "SELECT titel, inhoud FROM documents WHERE project_id = $1 ORDER BY versie DESC LIMIT 5", project_id ) # Get commitments commits = await conn.fetch( "SELECT beschrijving, status, deadline FROM commitments WHERE project_id = $1 ORDER BY deadline LIMIT 5", project_id ) await conn.close() context = f"Project: {project['naam']}\nStatus: {project['status']}\nBeschrijving: {project['beschrijving']}\n" if docs: context += "\nDocumenten:\n" for d in docs: content_preview = (d['inhoud'] or '')[:300] context += f"- {d['titel']}: {content_preview}\n" if commits: context += "\nCommitments:\n" for c in commits: context += f"- {c['beschrijving']} [{c['status']}] (deadline: {c['deadline']})\n" return context except Exception as e: return f"[Context fetch error: {str(e)}]" async def get_global_context() -> str: """Fetch overview of all projects and kennis artikelen.""" try: import asyncpg conn = await asyncpg.connect(DB_URL) projects = await conn.fetch( "SELECT naam, status, beschrijving FROM projects ORDER BY naam LIMIT 20" ) artikelen = await conn.fetch( "SELECT titel, inhoud FROM kennis_artikelen ORDER BY created_at DESC LIMIT 10" ) await conn.close() context = "Projecten overzicht:\n" for p in projects: context += f"- {p['naam']} [{p['status']}]: {(p['beschrijving'] or '')[:100]}\n" if artikelen: context += "\nKennisbasis:\n" for a in artikelen: context += f"- {a['titel']}: {(a['inhoud'] or '')[:200]}\n" return context except Exception as e: return f"[Context fetch error: {str(e)}]" # --- Endpoints --- @app.get("/health") async def health_check(): has_key = bool(ANTHROPIC_API_KEY) return {"status": "ok", "service": "ai-service", "model": ANTHROPIC_MODEL, "has_api_key": has_key} @app.post("/api/chat", response_model=ChatResponse) async def chat(request: ChatRequest): """Chat with AI, optionally scoped to a project.""" if not ANTHROPIC_API_KEY: return ChatResponse( reply="AI niet geconfigureerd. Stel ANTHROPIC_API_KEY in als environment variable.", project_id=request.project_id ) # Build context if request.project_id: context = await get_project_context(request.project_id) else: context = await get_global_context() system_prompt = ( "Je bent de AI-assistent van het Innovatieplatform van Waterschap Brabantse Delta R&D Lab. " "Je helpt met vragen over projecten, onderzoek, architectuur en innovatie. " "Antwoord bondig in het Nederlands tenzij de gebruiker Engels spreekt. " "Gebruik de projectcontext om relevante antwoorden te geven.\n\n" f"Context:\n{context}" ) # Build messages messages = [] for entry in (request.conversation_history or []): role = "user" if entry.get("type") == "input" else "assistant" messages.append({"role": role, "content": entry.get("text", "")}) messages.append({"role": "user", "content": request.message}) try: async with httpx.AsyncClient(timeout=30.0) as client: response = await client.post( "https://api.anthropic.com/v1/messages", headers={ "x-api-key": ANTHROPIC_API_KEY, "anthropic-version": "2023-06-01", "content-type": "application/json", }, json={ "model": ANTHROPIC_MODEL, "max_tokens": 1024, "system": system_prompt, "messages": messages, }, ) if response.status_code != 200: error_detail = response.text[:200] return ChatResponse( reply=f"AI fout ({response.status_code}): {error_detail}", project_id=request.project_id ) data = response.json() reply = data["content"][0]["text"] return ChatResponse(reply=reply, project_id=request.project_id) except Exception as e: return ChatResponse( reply=f"AI verbindingsfout: {str(e)}", project_id=request.project_id ) @app.post("/api/summarize", response_model=SummarizeResponse) async def summarize(request: SummarizeRequest): """Summarize content using AI.""" if not ANTHROPIC_API_KEY: return SummarizeResponse( summary="AI niet geconfigureerd.", project_id=request.project_id ) try: async with httpx.AsyncClient(timeout=30.0) as client: response = await client.post( "https://api.anthropic.com/v1/messages", headers={ "x-api-key": ANTHROPIC_API_KEY, "anthropic-version": "2023-06-01", "content-type": "application/json", }, json={ "model": ANTHROPIC_MODEL, "max_tokens": 512, "system": "Maak een beknopte samenvatting in het Nederlands. Focus op de kernpunten.", "messages": [{"role": "user", "content": request.content}], }, ) if response.status_code != 200: return SummarizeResponse(summary=f"Samenvatting fout: {response.text[:200]}", project_id=request.project_id) data = response.json() return SummarizeResponse(summary=data["content"][0]["text"], project_id=request.project_id) except Exception as e: return SummarizeResponse(summary=f"Fout: {str(e)}", project_id=request.project_id) @app.post("/api/search", response_model=SearchResponse) async def search(request: SearchRequest): """Search projects and documents by keyword (basic text search, no embeddings yet).""" try: import asyncpg conn = await asyncpg.connect(DB_URL) query_pattern = f"%{request.query}%" results = [] # Search documents docs = await conn.fetch( "SELECT id, titel, inhoud FROM documents WHERE inhoud ILIKE $1 OR titel ILIKE $1 LIMIT $2", query_pattern, request.limit ) for d in docs: results.append(SearchResult( id=d['id'], content=f"{d['titel']}: {(d['inhoud'] or '')[:200]}", score=1.0, metadata={"type": "document"} )) # Search kennis artikelen artikelen = await conn.fetch( "SELECT id, titel, inhoud FROM kennis_artikelen WHERE inhoud ILIKE $1 OR titel ILIKE $1 LIMIT $2", query_pattern, request.limit ) for a in artikelen: results.append(SearchResult( id=a['id'], content=f"{a['titel']}: {(a['inhoud'] or '')[:200]}", score=0.9, metadata={"type": "kennis_artikel"} )) await conn.close() return SearchResponse(results=results, query=request.query) except Exception as e: return SearchResponse(results=[], query=request.query)