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>
This commit is contained in:
@@ -3,28 +3,25 @@ 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 providing chat, summarization and semantic search for the Innovatieplatform.",
|
||||
version="0.1.0",
|
||||
description="AI service for chat, summarization and semantic search.",
|
||||
version="0.2.0",
|
||||
)
|
||||
|
||||
# CORS — allow requests from the Laravel app and local development
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"http://laravel-app",
|
||||
"http://nginx",
|
||||
"http://localhost",
|
||||
"http://localhost:80",
|
||||
os.getenv("LARAVEL_APP_URL", "http://localhost"),
|
||||
],
|
||||
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 ---
|
||||
|
||||
@@ -33,85 +30,256 @@ class ChatRequest(BaseModel):
|
||||
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():
|
||||
"""Health check endpoint used by Docker and monitoring."""
|
||||
return {"status": "ok", "service": "ai-service"}
|
||||
|
||||
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):
|
||||
"""
|
||||
Handle a chat message, optionally scoped to a project.
|
||||
Placeholder — wire up LangGraph + Anthropic in the full implementation.
|
||||
"""
|
||||
# TODO: integrate LangGraph agent with Anthropic Claude
|
||||
reply = (
|
||||
f"[AI placeholder] Received: '{request.message}'. "
|
||||
"Full AI integration pending."
|
||||
)
|
||||
return ChatResponse(reply=reply, project_id=request.project_id)
|
||||
"""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 for a given project.
|
||||
Placeholder — wire up Anthropic in the full implementation.
|
||||
"""
|
||||
# TODO: integrate Anthropic Claude summarization
|
||||
summary = (
|
||||
f"[AI placeholder] Summary of {len(request.content)} characters "
|
||||
f"(type: {request.summary_type}). Full AI integration pending."
|
||||
)
|
||||
return SummarizeResponse(summary=summary, project_id=request.project_id)
|
||||
"""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):
|
||||
"""
|
||||
Semantic search using pgvector embeddings.
|
||||
Placeholder — wire up pgvector + embeddings in the full implementation.
|
||||
"""
|
||||
# TODO: integrate pgvector similarity search with embeddings
|
||||
return SearchResponse(
|
||||
results=[],
|
||||
query=request.query,
|
||||
)
|
||||
"""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)
|
||||
|
||||
Reference in New Issue
Block a user