Files
innovatieplatform/ai-service/app/main.py
znetsixe f4ec49254a Full sweep: fix broken features, redesign NodePreview, wire AI service
FIXES (from comprehensive audit):
- NodePreview: complete rewrite — 380px left panel with document
  summaries, commitment list, phase track visualization, scrollable.
  Fixed children count bug (was showing [object Object]).
  Slides in from left (not right) to not overlap branch handles.
- CommitmentForm: added required validation on eigenaar_id field
- MetroMap: wired custom metro node creation with form + POST /metro-nodes
- MetroMap: removed dead handleCliCommand console.log
- MetroMap: added metro node creation modal (naam + beschrijving)

NEW — AI Service integration:
- ai-service/main.py: real Anthropic API integration via httpx
  - Reads ANTHROPIC_API_KEY from env, uses claude-haiku-4-5-20251001
  - /api/chat fetches project context from PostgreSQL (docs, commitments)
  - /api/summarize sends content to Claude for summarization
  - /api/search does basic text search on documents + kennis_artikelen
- AiController.php: Laravel proxy for /api/ai/chat → ai-service
- CliBar.vue: complete rewrite with async API calls, processing state,
  error handling, conversation history, auto-scroll
  - Receives projectId prop for context-scoped AI queries
  - Shows "denken..." animation while waiting for response
- docker-compose.yml: passes ANTHROPIC_API_KEY to ai-service container
- config/services.php: ai service URL configuration

To activate AI: set ANTHROPIC_API_KEY in .env and rebuild ai-service.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:07:51 +02:00

286 lines
9.4 KiB
Python

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)