From f4ec49254a58dab8f9bea4c7c55a989ba06e1892 Mon Sep 17 00:00:00 2001 From: znetsixe Date: Wed, 8 Apr 2026 15:07:51 +0200 Subject: [PATCH] Full sweep: fix broken features, redesign NodePreview, wire AI service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ai-service/app/main.py | 264 ++++++++++++++---- ai-service/requirements.txt | 1 + app/Http/Controllers/AiController.php | 36 +++ config/services.php | 4 + docker-compose.yml | 3 + resources/js/Components/Cli/CliBar.vue | 74 ++++- .../js/Components/Forms/CommitmentForm.vue | 1 + .../js/Components/MetroMap/NodePreview.vue | 226 +++++++++++++-- resources/js/Pages/Map/MetroMap.vue | 90 +++++- routes/web.php | 4 + 10 files changed, 619 insertions(+), 84 deletions(-) create mode 100644 app/Http/Controllers/AiController.php diff --git a/ai-service/app/main.py b/ai-service/app/main.py index ec8572b..17bfe7a 100644 --- a/ai-service/app/main.py +++ b/ai-service/app/main.py @@ -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) diff --git a/ai-service/requirements.txt b/ai-service/requirements.txt index 8dac375..fca1d80 100644 --- a/ai-service/requirements.txt +++ b/ai-service/requirements.txt @@ -5,6 +5,7 @@ langchain>=0.1.0 anthropic>=0.30.0 pgvector>=0.2.0 psycopg2-binary>=2.9.0 +asyncpg>=0.29.0 numpy>=1.26.0 pydantic>=2.0.0 python-dotenv>=1.0.0 diff --git a/app/Http/Controllers/AiController.php b/app/Http/Controllers/AiController.php new file mode 100644 index 0000000..aad7f3d --- /dev/null +++ b/app/Http/Controllers/AiController.php @@ -0,0 +1,36 @@ +validate([ + 'message' => 'required|string|max:2000', + 'project_id' => 'nullable|integer', + 'conversation_history' => 'nullable|array', + ]); + + $aiServiceUrl = config('services.ai.url', 'http://ai-service:8000'); + + try { + $response = Http::timeout(30)->post("{$aiServiceUrl}/api/chat", $validated); + + if ($response->successful()) { + return response()->json($response->json()); + } + + return response()->json([ + 'reply' => 'AI service fout: ' . $response->body(), + ], 500); + } catch (\Exception $e) { + return response()->json([ + 'reply' => 'AI service niet bereikbaar: ' . $e->getMessage(), + ], 503); + } + } +} diff --git a/config/services.php b/config/services.php index 6a90eb8..9ba027f 100644 --- a/config/services.php +++ b/config/services.php @@ -35,4 +35,8 @@ return [ ], ], + 'ai' => [ + 'url' => env('AI_SERVICE_URL', 'http://ai-service:8000'), + ], + ]; diff --git a/docker-compose.yml b/docker-compose.yml index 4d55e80..d86cb68 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -132,6 +132,9 @@ services: volumes: - ./ai-service:/app environment: + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} + - ANTHROPIC_MODEL=${ANTHROPIC_MODEL:-claude-haiku-4-5-20251001} + - DATABASE_URL=postgresql://${DB_USERNAME:-innovatie}:${DB_PASSWORD:-secret}@postgresql:5432/${DB_DATABASE:-innovatieplatform} - DB_HOST=postgresql - DB_PORT=5432 - DB_DATABASE=${DB_DATABASE:-innovatieplatform} diff --git a/resources/js/Components/Cli/CliBar.vue b/resources/js/Components/Cli/CliBar.vue index e9ef0b8..8284a6b 100644 --- a/resources/js/Components/Cli/CliBar.vue +++ b/resources/js/Components/Cli/CliBar.vue @@ -1,23 +1,42 @@ @@ -434,6 +499,11 @@ const editingProject = ref(null) box-shadow: 0 0 8px rgba(0, 210, 255, 0.2); } +.form-textarea { + resize: vertical; + min-height: 60px; +} + .form-actions { display: flex; justify-content: flex-end; diff --git a/routes/web.php b/routes/web.php index 58412b9..bd90ec4 100644 --- a/routes/web.php +++ b/routes/web.php @@ -7,6 +7,7 @@ use App\Http\Controllers\MetroLineController; use App\Http\Controllers\MetroNodeController; use App\Http\Controllers\ProjectController; use App\Http\Controllers\ThemaController; +use App\Http\Controllers\AiController; use Illuminate\Support\Facades\Route; // Redirect root to map @@ -59,6 +60,9 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::put('/metro-nodes/{metroNode}', [MetroNodeController::class, 'update'])->name('metro-nodes.update'); Route::delete('/metro-nodes/{metroNode}', [MetroNodeController::class, 'destroy'])->name('metro-nodes.destroy'); + // AI Chat + Route::post('/api/ai/chat', [AiController::class, 'chat'])->name('ai.chat'); + // Dashboard (redirects to map) Route::get('/dashboard', fn () => redirect('/map'))->name('dashboard'); });