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>
286 lines
9.4 KiB
Python
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)
|