From 17091c26cc28710561974b1e60e88eed53705e81 Mon Sep 17 00:00:00 2001 From: Druppie Date: Thu, 11 Jun 2026 10:33:02 +0000 Subject: [PATCH] feat: scaffold project template --- .gitignore | 8 + Dockerfile | 20 ++ app/__init__.py | 32 ++ app/agent.py | 85 +++++ app/ai.py | 78 +++++ app/chat.py | 224 ++++++++++++++ app/config.py | 10 + app/database.py | 48 +++ app/models.py | 30 ++ app/rag.py | 342 +++++++++++++++++++++ app/routes.py | 131 ++++++++ docker-compose.yaml | 37 +++ docs/platform-functional-standards.md | 98 ++++++ docs/platform-technical-standards.md | 206 +++++++++++++ frontend/components.json | 17 + frontend/index.html | 12 + frontend/package.json | 32 ++ frontend/postcss.config.js | 6 + frontend/src/App.tsx | 144 +++++++++ frontend/src/components/chat/ChatPanel.tsx | 284 +++++++++++++++++ frontend/src/components/chat/api.ts | 40 +++ frontend/src/components/chat/index.ts | 2 + frontend/src/components/chat/types.ts | 22 ++ frontend/src/components/ui/button.tsx | 57 ++++ frontend/src/components/ui/card.tsx | 83 +++++ frontend/src/components/ui/input.tsx | 23 ++ frontend/src/index.css | 55 ++++ frontend/src/lib/utils.ts | 6 + frontend/src/main.tsx | 13 + frontend/src/vite-env.d.ts | 1 + frontend/tailwind.config.js | 46 +++ frontend/tsconfig.app.json | 24 ++ frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 18 ++ frontend/vite.config.ts | 17 + requirements.txt | 7 + 36 files changed, 2265 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app/__init__.py create mode 100644 app/agent.py create mode 100644 app/ai.py create mode 100644 app/chat.py create mode 100644 app/config.py create mode 100644 app/database.py create mode 100644 app/models.py create mode 100644 app/rag.py create mode 100644 app/routes.py create mode 100644 docker-compose.yaml create mode 100644 docs/platform-functional-standards.md create mode 100644 docs/platform-technical-standards.md create mode 100644 frontend/components.json create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/chat/ChatPanel.tsx create mode 100644 frontend/src/components/chat/api.ts create mode 100644 frontend/src/components/chat/index.ts create mode 100644 frontend/src/components/chat/types.ts create mode 100644 frontend/src/components/ui/button.tsx create mode 100644 frontend/src/components/ui/card.tsx create mode 100644 frontend/src/components/ui/input.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/lib/utils.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2fdb258 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +.env +*.egg-info/ +.venv/ +node_modules/ +dist/ +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0dddc02 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +# Stage 1: Build frontend +FROM node:20-alpine AS frontend +WORKDIR /build +COPY frontend/package*.json ./ +RUN npm install +COPY frontend/ . +RUN npm run build + +# Stage 2: Python backend +FROM python:3.11-slim +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY druppie-sdk/ /tmp/druppie-sdk/ +RUN pip install --no-cache-dir /tmp/druppie-sdk/ +COPY app/ ./app/ +COPY --from=frontend /build/dist ./static/ +EXPOSE 8000 +CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "300", "--preload", "app:create_app()"] diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..b0e9d60 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,32 @@ +import os + +from flask import Flask, jsonify, send_from_directory + +from app.database import init_db + + +def create_app(): + static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "static") + app = Flask(__name__) + + from app.routes import api + from app.chat import chat_api + + app.register_blueprint(api, url_prefix="/api") + app.register_blueprint(chat_api, url_prefix="/api") + + @app.route("/health") + def health(): + return jsonify(status="ok") + + @app.route("/", defaults={"path": ""}) + @app.route("/") + def serve_frontend(path): + if path and os.path.isfile(os.path.join(static_dir, path)): + return send_from_directory(static_dir, path) + return send_from_directory(static_dir, "index.html") + + with app.app_context(): + init_db() + + return app diff --git a/app/agent.py b/app/agent.py new file mode 100644 index 0000000..155c5c6 --- /dev/null +++ b/app/agent.py @@ -0,0 +1,85 @@ +"""Agent template — customize this for your domain. + +This is where you define what the AI assistant can do. The default +implementation provides a simple Q&A agent. Replace or extend the +`run_agent` function with your own logic. + +The agent receives: +- question: the user's message +- history: previous messages in the conversation +- db: SQLAlchemy database session (for querying your app's data) + +The agent returns: +- answer: the text response to show the user +- steps: list of transparency steps (shown in the UI) + +Each step has: +- type: "search", "query", "tool", "reasoning", etc. +- label: human-readable description +- detail: the raw data (search terms, query results, etc.) + +Example customization for a permit search app: + + def run_agent(question, history, db): + steps = [] + + # Step 1: Generate search terms + terms = ask_llm_for_search_terms(question) + steps.append({"type": "search", "label": "Zoektermen", "detail": terms}) + + # Step 2: Query database + results = search_permits(db, terms) + steps.append({"type": "query", "label": f"{len(results)} resultaten", "detail": [...]}) + + # Step 3: Synthesize answer + answer = ask_llm_to_answer(question, results) + return {"answer": answer, "steps": steps} +""" + +from druppie_sdk import DruppieClient + +druppie = DruppieClient() + + +def run_agent(question: str, history: list[dict], db) -> dict: + """Process a user question and return an answer with transparency steps. + + Override this function with your domain-specific agent logic. + The default implementation is a simple LLM Q&A with conversation context. + + Args: + question: The user's current message. + history: List of {"role": "user"|"assistant", "content": "..."} dicts. + db: SQLAlchemy database session for querying app data. + + Returns: + {"answer": str, "steps": list[dict]} + """ + steps = [] + + # Build conversation context + context_messages = "" + if history: + recent = history[-6:] # last 3 exchanges + context_messages = "\n".join( + f"{'Gebruiker' if m['role'] == 'user' else 'Assistent'}: {m['content']}" + for m in recent + ) + + # Call LLM + prompt = question + if context_messages: + prompt = f"Eerdere berichten:\n{context_messages}\n\nNieuwe vraag: {question}" + + steps.append({"type": "reasoning", "label": "Vraag naar LLM gestuurd", "detail": None}) + + result = druppie.call("llm", "chat", { + "prompt": prompt, + "system": ( + "Je bent een behulpzame assistent. Beantwoord vragen in het Nederlands. " + "Wees beknopt maar volledig." + ), + }) + + answer = result.get("answer", "Geen antwoord ontvangen.") + return {"answer": answer, "steps": steps} diff --git a/app/ai.py b/app/ai.py new file mode 100644 index 0000000..b0be11f --- /dev/null +++ b/app/ai.py @@ -0,0 +1,78 @@ +"""DeepInfra AI integration using OpenAI-compatible API. + +Available functions: + ai_chat(prompt, system) — LLM chat completion + ocr_extract(image_url) — OCR text extraction from image + +Available models (change these constants as needed): + AI_MODEL — general-purpose LLM for chat, summarization, etc. + OCR_MODEL — vision model optimized for OCR + +Usage: + + from app.ai import ai_chat, ocr_extract + + # Chat completion + answer = ai_chat("What is the capital of France?") + + # Chat with custom system prompt + answer = ai_chat("Summarize this text...", system="You are a summarizer.") + + # OCR: extract text from an image URL + text = ocr_extract("https://example.com/receipt.png") + +The DEEPINFRA_API_KEY env var is injected at deploy time via docker-compose. +You never need to hardcode it. +""" + +from openai import OpenAI + +from app.config import settings + +AI_MODEL = "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8" +OCR_MODEL = "PaddlePaddle/PaddleOCR-VL-0.9B" + +_ai_client: OpenAI | None = None + + +def _get_client() -> OpenAI: + global _ai_client + if _ai_client is None: + if not settings.deepinfra_api_key: + raise RuntimeError( + "DEEPINFRA_API_KEY not set — configure it in .env or docker-compose.yaml" + ) + _ai_client = OpenAI( + api_key=settings.deepinfra_api_key, + base_url="https://api.deepinfra.com/v1/openai", + ) + return _ai_client + + +def ai_chat(prompt: str, system: str = "You are a helpful assistant.") -> str: + """LLM chat completion via DeepInfra.""" + response = _get_client().chat.completions.create( + model=AI_MODEL, + messages=[ + {"role": "system", "content": system}, + {"role": "user", "content": prompt}, + ], + ) + return response.choices[0].message.content + + +def ocr_extract(image_url: str) -> str: + """Extract text from an image using PaddleOCR vision model.""" + response = _get_client().chat.completions.create( + model=OCR_MODEL, + max_tokens=4092, + messages=[ + { + "role": "user", + "content": [ + {"type": "image_url", "image_url": {"url": image_url}}, + ], + } + ], + ) + return response.choices[0].message.content diff --git a/app/chat.py b/app/chat.py new file mode 100644 index 0000000..5873afc --- /dev/null +++ b/app/chat.py @@ -0,0 +1,224 @@ +"""Chat session models and routes. + +Provides a ChatGPT-style conversational interface backed by the Druppie SDK. +Every project gets this out of the box — customize the agent logic in agent.py. + +Architecture: messages are processed asynchronously. When a user sends a +message, the backend saves it, creates a "thinking" placeholder, and +processes the agent in a background thread. The frontend polls for updates. +This way navigating away doesn't cancel the AI response. +""" + +import threading +from datetime import datetime, timezone + +from sqlalchemy import Column, DateTime, Integer, String, Text, ForeignKey, JSON +from sqlalchemy.orm import relationship + +from app.database import Base, get_db, SessionLocal + + +# --------------------------------------------------------------------------- +# Models +# --------------------------------------------------------------------------- + +class ChatSession(Base): + __tablename__ = "chat_sessions" + + id = Column(Integer, primary_key=True, autoincrement=True) + title = Column(String(200), default="Nieuw gesprek") + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc)) + + messages = relationship("ChatMessage", back_populates="session", + cascade="all, delete-orphan", + order_by="ChatMessage.created_at") + + +class ChatMessage(Base): + __tablename__ = "chat_messages" + + id = Column(Integer, primary_key=True, autoincrement=True) + session_id = Column(Integer, ForeignKey("chat_sessions.id", ondelete="CASCADE"), nullable=False, index=True) + role = Column(String(20), nullable=False) # "user" or "assistant" + content = Column(Text, nullable=False, default="") + # "thinking" while agent processes, "done" when complete, "error" on failure + status = Column(String(20), nullable=False, default="done") + # Agent metadata — search terms, found documents, reasoning steps + metadata_ = Column("metadata", JSON, nullable=True) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + + session = relationship("ChatSession", back_populates="messages") + + +# --------------------------------------------------------------------------- +# Background agent processing +# --------------------------------------------------------------------------- + +def _process_message_background(session_id: int, assistant_msg_id: int, user_text: str, history: list[dict]): + """Run the agent in a background thread with its own DB session.""" + db = SessionLocal() + try: + from app.agent import run_agent + result = run_agent(user_text, history, db) + + msg = db.query(ChatMessage).filter(ChatMessage.id == assistant_msg_id).first() + if msg: + msg.content = result.get("answer", "") + msg.status = "done" + msg.metadata_ = result.get("steps") + + session = db.query(ChatSession).filter(ChatSession.id == session_id).first() + if session: + session.updated_at = datetime.now(timezone.utc) + + db.commit() + except Exception as e: + db.rollback() + try: + msg = db.query(ChatMessage).filter(ChatMessage.id == assistant_msg_id).first() + if msg: + msg.content = f"Fout bij verwerking: {e}" + msg.status = "error" + db.commit() + except Exception: + pass + finally: + db.close() + + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- + +from flask import Blueprint, jsonify, request + +chat_api = Blueprint("chat_api", __name__) + + +@chat_api.route("/chat/sessions", methods=["POST"]) +def create_session(): + db = next(get_db()) + session = ChatSession() + db.add(session) + db.commit() + db.refresh(session) + return jsonify(_session_to_dict(session)), 201 + + +@chat_api.route("/chat/sessions") +def list_sessions(): + db = next(get_db()) + sessions = db.query(ChatSession).order_by(ChatSession.updated_at.desc()).all() + return jsonify([_session_to_dict(s) for s in sessions]) + + +@chat_api.route("/chat/sessions/") +def get_session(session_id): + db = next(get_db()) + session = db.query(ChatSession).filter(ChatSession.id == session_id).first() + if not session: + return jsonify(error="Sessie niet gevonden"), 404 + return jsonify({ + **_session_to_dict(session), + "messages": [_message_to_dict(m) for m in session.messages], + }) + + +@chat_api.route("/chat/sessions/", methods=["DELETE"]) +def delete_session(session_id): + db = next(get_db()) + session = db.query(ChatSession).filter(ChatSession.id == session_id).first() + if not session: + return jsonify(error="Sessie niet gevonden"), 404 + db.delete(session) + db.commit() + return jsonify(ok=True) + + +@chat_api.route("/chat/sessions//rename", methods=["POST"]) +def rename_session(session_id): + db = next(get_db()) + session = db.query(ChatSession).filter(ChatSession.id == session_id).first() + if not session: + return jsonify(error="Sessie niet gevonden"), 404 + data = request.get_json(silent=True) + if data and "title" in data: + session.title = data["title"][:200] + db.commit() + return jsonify(_session_to_dict(session)) + + +@chat_api.route("/chat/sessions//messages", methods=["POST"]) +def send_message(session_id): + """Send a message. Returns immediately with a 'thinking' assistant message. + + The agent processes in a background thread. Poll GET /sessions/{id} + to check when the assistant message status changes from 'thinking' to 'done'. + """ + db = next(get_db()) + session = db.query(ChatSession).filter(ChatSession.id == session_id).first() + if not session: + return jsonify(error="Sessie niet gevonden"), 404 + + data = request.get_json(silent=True) + if not data or "message" not in data: + return jsonify(error="Bericht is verplicht"), 400 + + user_text = data["message"].strip() + if not user_text: + return jsonify(error="Leeg bericht"), 400 + + # Save user message + user_msg = ChatMessage(session_id=session_id, role="user", content=user_text) + db.add(user_msg) + + # Auto-title from first message + if session.title == "Nieuw gesprek": + session.title = user_text[:80] + ("..." if len(user_text) > 80 else "") + + # Create placeholder assistant message + assistant_msg = ChatMessage( + session_id=session_id, role="assistant", + content="", status="thinking", + ) + db.add(assistant_msg) + db.commit() + db.refresh(assistant_msg) + + # Get conversation history (excluding the new messages) + history = [{"role": m.role, "content": m.content} + for m in session.messages + if m.id not in (user_msg.id, assistant_msg.id) and m.status == "done"] + + # Process in background thread + thread = threading.Thread( + target=_process_message_background, + args=(session_id, assistant_msg.id, user_text, history), + daemon=True, + ) + thread.start() + + return jsonify(_message_to_dict(assistant_msg)), 202 + + +def _session_to_dict(s): + return { + "id": s.id, + "title": s.title, + "created_at": s.created_at.isoformat() if s.created_at else None, + "updated_at": s.updated_at.isoformat() if s.updated_at else None, + "message_count": len(s.messages) if s.messages else 0, + } + + +def _message_to_dict(m): + return { + "id": m.id, + "role": m.role, + "content": m.content, + "status": m.status, + "steps": m.metadata_, + "created_at": m.created_at.isoformat() if m.created_at else None, + } diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..b229bfb --- /dev/null +++ b/app/config.py @@ -0,0 +1,10 @@ +import os + + +class Settings: + app_name: str = os.getenv("APP_NAME", "My App") + database_url: str = os.getenv("DATABASE_URL", "postgresql://app:app@db:5432/app") + debug: bool = os.getenv("DEBUG", "false").lower() == "true" + + +settings = Settings() diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..1db343e --- /dev/null +++ b/app/database.py @@ -0,0 +1,48 @@ +import logging +from sqlalchemy import create_engine, text +from sqlalchemy.orm import DeclarativeBase, sessionmaker + +from app.config import settings + +logger = logging.getLogger(__name__) + +engine = create_engine(settings.database_url) +SessionLocal = sessionmaker(bind=engine) + + +class Base(DeclarativeBase): + pass + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def init_db(): + """Create tables if they don't exist. + + Uses an advisory lock to prevent race conditions when multiple + gunicorn workers start simultaneously. + """ + import app.models # noqa: F401 + import app.rag # noqa: F401 — register vector_* tables (pgvector) + try: + import app.chat # noqa: F401 — register chat models + except ImportError: + pass + + with engine.connect() as conn: + # PostgreSQL advisory lock to prevent concurrent CREATE TABLE + try: + conn.execute(text("SELECT pg_advisory_lock(42)")) + conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector")) + Base.metadata.create_all(bind=engine) + conn.execute(text("SELECT pg_advisory_unlock(42)")) + conn.commit() + except Exception: + # Fallback for non-PostgreSQL (e.g. SQLite in tests) + Base.metadata.create_all(bind=engine) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..f149266 --- /dev/null +++ b/app/models.py @@ -0,0 +1,30 @@ +"""Database models. + +Define your SQLAlchemy models here. They will be auto-created on startup. + +Example: + + class Category(Base): + __tablename__ = "categories" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4) + name = Column(String(100), nullable=False) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) +""" + +from datetime import datetime, timezone # noqa: F401 +from uuid import uuid4 # noqa: F401 + +from sqlalchemy import ( # noqa: F401 + Boolean, + Column, + DateTime, + ForeignKey, + Integer, + String, + Text, +) +from sqlalchemy.dialects.postgresql import UUID # noqa: F401 +from sqlalchemy.orm import relationship # noqa: F401 + +from app.database import Base # noqa: F401 diff --git a/app/rag.py b/app/rag.py new file mode 100644 index 0000000..86da574 --- /dev/null +++ b/app/rag.py @@ -0,0 +1,342 @@ +"""RAG helper — index and search documents using the app's own database. + +Embeddings are generated via the Druppie platform (module-llm), vector +storage lives in this app's own Postgres with pgvector. No shared +database, no cross-app data leakage. + +Usage: + from app.rag import RAG + from app.database import SessionLocal + from druppie_sdk import DruppieClient + + druppie = DruppieClient() + db = SessionLocal() + + rag = RAG(db, druppie) + rag.create_index("knowledge-base") + rag.index_documents("knowledge-base", [ + {"content": "Full text...", "source_name": "Policy 2024", "source_page": 42}, + ]) + results = rag.search("knowledge-base", "wat is het beleid?") +""" + +import json +import logging +import re +import uuid + +import numpy as np +from pgvector.sqlalchemy import Vector +from sqlalchemy import Column, DateTime, ForeignKey, Index, Integer, String, Text, func, text +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Session + +from app.database import Base + +logger = logging.getLogger(__name__) + +EMBEDDING_BATCH_SIZE = 100 + + +# --------------------------------------------------------------------------- +# SQLAlchemy models (created in the app's own database) +# --------------------------------------------------------------------------- + +class VectorIndex(Base): + __tablename__ = "vector_indices" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name = Column(String, nullable=False, unique=True) + description = Column(String, nullable=False, default="") + embedding_model = Column(String, nullable=False, default="default") + dimensions = Column(Integer, nullable=False, default=0) + chunk_size = Column(Integer, nullable=False, default=2048) + chunk_overlap = Column(Integer, nullable=False, default=256) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class VectorDocument(Base): + __tablename__ = "vector_documents" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + index_id = Column(UUID(as_uuid=True), ForeignKey("vector_indices.id", ondelete="CASCADE"), nullable=False) + source_name = Column(String, nullable=False) + source_type = Column(String, nullable=False, default="text") + chunk_count = Column(Integer, nullable=False, default=0) + metadata_ = Column("metadata", JSONB, nullable=False, default=dict) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +Index("idx_vector_documents_index_id", VectorDocument.index_id) + + +class VectorChunk(Base): + __tablename__ = "vector_chunks" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + index_id = Column(UUID(as_uuid=True), ForeignKey("vector_indices.id", ondelete="CASCADE"), nullable=False) + document_id = Column(UUID(as_uuid=True), ForeignKey("vector_documents.id", ondelete="CASCADE"), nullable=False) + chunk_index = Column(Integer, nullable=False) + content = Column(Text, nullable=False) + embedding = Column(Vector()) + source_name = Column(String, nullable=False, default="") + source_page = Column(Integer) + source_section = Column(String) + metadata_ = Column("metadata", JSONB, nullable=False, default=dict) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +Index("idx_vector_chunks_index_id", VectorChunk.index_id) +Index("idx_vector_chunks_document_id", VectorChunk.document_id) + +_SAFE_KEY_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$") + + +# --------------------------------------------------------------------------- +# RAG class +# --------------------------------------------------------------------------- + +class RAG: + """RAG helper that stores vectors in the app's own database.""" + + def __init__(self, db: Session, druppie): + self._db = db + self._druppie = druppie + + def create_index( + self, + name: str, + description: str = "", + chunk_size: int = 2048, + chunk_overlap: int = 256, + ) -> dict: + idx = self._db.query(VectorIndex).filter(VectorIndex.name == name).first() + if idx: + idx.description = description + idx.chunk_size = chunk_size + idx.chunk_overlap = chunk_overlap + else: + idx = VectorIndex( + name=name, + description=description, + chunk_size=chunk_size, + chunk_overlap=chunk_overlap, + ) + self._db.add(idx) + self._db.commit() + return {"index_id": str(idx.id), "name": name} + + def index_documents( + self, + index_name: str, + documents: list[dict], + embedding_model: str = "default", + ) -> dict: + idx = self._db.query(VectorIndex).filter(VectorIndex.name == index_name).first() + if not idx: + raise ValueError(f"Index '{index_name}' not found — call create_index first") + + chunk_size = idx.chunk_size - idx.chunk_overlap + chunk_overlap = idx.chunk_overlap + + all_chunks = [] + all_texts = [] + + for doc in documents: + content = doc["content"] + source_name = doc.get("source_name", "unknown") + texts = _chunk_text(content, chunk_size, chunk_overlap) + + vdoc = VectorDocument( + index_id=idx.id, + source_name=source_name, + source_type=doc.get("source_type", "text"), + chunk_count=len(texts), + metadata_=doc.get("metadata", {}), + ) + self._db.add(vdoc) + self._db.flush() + + for i, chunk_text in enumerate(texts): + chunk = VectorChunk( + index_id=idx.id, + document_id=vdoc.id, + chunk_index=i, + content=chunk_text, + source_name=source_name, + source_page=doc.get("source_page"), + source_section=doc.get("source_section"), + metadata_=doc.get("metadata", {}), + ) + self._db.add(chunk) + all_chunks.append(chunk) + all_texts.append(chunk_text) + + self._db.flush() + + if all_texts: + embeddings = self._get_embeddings(all_texts, embedding_model) + dimensions = len(embeddings[0]) if embeddings else 0 + + if idx.dimensions == 0 and dimensions > 0: + idx.dimensions = dimensions + + for chunk, emb in zip(all_chunks, embeddings): + chunk.embedding = np.array(emb) + + self._db.commit() + + return { + "index_name": index_name, + "documents_indexed": len(documents), + "chunks_created": len(all_chunks), + } + + def search( + self, + index_name: str, + query: str, + top_k: int = 5, + similarity_threshold: float = 0.0, + filter_metadata: dict | None = None, + ) -> list[dict]: + idx = self._db.query(VectorIndex).filter(VectorIndex.name == index_name).first() + if not idx: + raise ValueError(f"Index '{index_name}' not found") + + if filter_metadata: + for key in filter_metadata: + if not _SAFE_KEY_RE.match(key): + raise ValueError(f"Invalid metadata filter key: '{key}'") + + query_embedding = self._get_embeddings([query], "default")[0] + + sql = """ + SELECT id, content, source_name, source_page, source_section, + metadata, chunk_index, + 1 - (embedding <=> :qvec::vector) AS score + FROM vector_chunks + WHERE index_id = :idx AND embedding IS NOT NULL + """ + params = {"idx": idx.id, "qvec": str(query_embedding)} + + if similarity_threshold > 0: + sql += " AND 1 - (embedding <=> :qvec::vector) >= :threshold" + params["threshold"] = similarity_threshold + + if filter_metadata: + for i, (key, value) in enumerate(filter_metadata.items()): + pname = f"fv{i}" + sql += f" AND metadata->>'{key}' = :{pname}" + params[pname] = str(value) + + sql += " ORDER BY embedding <=> :qvec::vector LIMIT :topk" + params["topk"] = top_k + + rows = self._db.execute(text(sql), params).fetchall() + + return [ + { + "chunk_id": str(row.id), + "content": row.content, + "score": float(row.score), + "source_name": row.source_name, + "source_page": row.source_page, + "source_section": row.source_section, + "metadata": row.metadata if isinstance(row.metadata, dict) else json.loads(row.metadata or "{}"), + "chunk_index": row.chunk_index, + } + for row in rows + ] + + def delete_index(self, name: str) -> dict: + idx = self._db.query(VectorIndex).filter(VectorIndex.name == name).first() + if not idx: + raise ValueError(f"Index '{name}' not found") + self._db.delete(idx) + self._db.commit() + return {"deleted": True, "name": name} + + def list_indices(self) -> list[dict]: + indices = self._db.query(VectorIndex).order_by(VectorIndex.created_at.desc()).all() + return [ + { + "name": idx.name, + "description": idx.description, + "dimensions": idx.dimensions, + "chunk_size": idx.chunk_size, + "chunk_overlap": idx.chunk_overlap, + } + for idx in indices + ] + + def _get_embeddings(self, texts: list[str], model: str) -> list[list[float]]: + all_embeddings = [] + for i in range(0, len(texts), EMBEDDING_BATCH_SIZE): + batch = texts[i:i + EMBEDDING_BATCH_SIZE] + kwargs = {"texts": batch} + if model and model != "default": + kwargs["model"] = model + result = self._druppie.call("llm", "embed", kwargs) + all_embeddings.extend(result.get("embeddings", [])) + return all_embeddings + + +# --------------------------------------------------------------------------- +# Text chunking (recursive split at sentence boundaries with overlap) +# --------------------------------------------------------------------------- + +def _chunk_text(text_content: str, chunk_size: int, chunk_overlap: int) -> list[str]: + if len(text_content) <= chunk_size: + return [text_content] + + separators = ["\n\n", "\n", ". ", "! ", "? ", "; ", ", ", " "] + return _recursive_split(text_content, chunk_size, chunk_overlap, separators) + + +def _recursive_split( + text_content: str, + chunk_size: int, + chunk_overlap: int, + separators: list[str], +) -> list[str]: + if len(text_content) <= chunk_size: + return [text_content.strip()] if text_content.strip() else [] + + separator = separators[0] if separators else "" + remaining = separators[1:] if len(separators) > 1 else [] + + if not separator: + step = max(1, chunk_size - chunk_overlap) + parts = [text_content[i:i + chunk_size] for i in range(0, len(text_content), step)] + return [p.strip() for p in parts if p.strip()] + + parts = text_content.split(separator) + chunks = [] + current = "" + + for part in parts: + candidate = current + separator + part if current else part + if len(candidate) <= chunk_size: + current = candidate + else: + if current.strip(): + chunks.append(current.strip()) + if len(part) > chunk_size and remaining: + chunks.extend(_recursive_split(part, chunk_size, chunk_overlap, remaining)) + current = "" + else: + current = part + + if current.strip(): + chunks.append(current.strip()) + + if chunk_overlap > 0 and len(chunks) > 1: + result = [chunks[0]] + for i in range(1, len(chunks)): + prev_tail = chunks[i - 1][-chunk_overlap:] if len(chunks[i - 1]) > chunk_overlap else chunks[i - 1] + result.append(prev_tail + " " + chunks[i]) + chunks = result + + return chunks diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 0000000..2e55328 --- /dev/null +++ b/app/routes.py @@ -0,0 +1,131 @@ +"""API routes. + +Define your API endpoints here. All routes are prefixed with /api. + +Built-in AI endpoints (via Druppie SDK): + POST /api/ai/chat — LLM chat completion (body: {prompt, system?}) [module-llm] + POST /api/ai/ocr — OCR text extraction (body: {image_url}) [module-vision] + POST /api/ai/search — Web search (body: {query}) [module-web] + +RAG endpoints (vectors stored in THIS app's own database): + POST /api/rag/index — embed + store documents (body: {documents: [...]}) + POST /api/rag/search — semantic similarity search (body: {query}) + +Example adding your own: + + @api.route('/items', methods=['GET']) + def list_items(): + db = next(get_db()) + items = db.query(Item).all() + return jsonify([{'id': str(i.id), 'name': i.name} for i in items]) +""" + +from flask import Blueprint, jsonify, request +from druppie_sdk import DruppieClient + +from app.database import get_db +from app.rag import RAG + +api = Blueprint("api", __name__) + +druppie = DruppieClient() + +RAG_INDEX = "knowledge-base" + + +@api.route("/info") +def info(): + from app.config import settings + + return jsonify(app_name=settings.app_name) + + +# --------------------------------------------------------------------------- +# AI endpoints — via Druppie SDK (calls module-llm and module-vision) +# --------------------------------------------------------------------------- + + +@api.route("/ai/chat", methods=["POST"]) +def ai_chat_endpoint(): + """LLM chat completion. Body: {"prompt": "...", "system": "..."}""" + data = request.get_json(silent=True) + if not data or "prompt" not in data: + return jsonify(error="Missing required field: prompt"), 400 + result = druppie.call("llm", "chat", { + "prompt": data["prompt"], + "system": data.get("system", "You are a helpful assistant."), + }) + return jsonify(answer=result.get("answer", "")) + + +@api.route("/ai/ocr", methods=["POST"]) +def ai_ocr_endpoint(): + """OCR text extraction. Body: {"image_url": "https://..."}""" + data = request.get_json(silent=True) + if not data or "image_url" not in data: + return jsonify(error="Missing required field: image_url"), 400 + result = druppie.call("vision", "ocr", {"image_source": data["image_url"]}) + return jsonify(text=result.get("text", "")) + + +@api.route("/ai/search", methods=["POST"]) +def ai_search_endpoint(): + """Web search. Body: {"query": "search terms"}""" + data = request.get_json(silent=True) + if not data or "query" not in data: + return jsonify(error="Missing required field: query"), 400 + result = druppie.call("web", "search_web", {"query": data["query"]}) + return jsonify(result) + + +# --------------------------------------------------------------------------- +# RAG endpoints — worked example of the embed → store → search loop +# +# Vectors live in THIS app's own Postgres (pgvector); embeddings are +# generated by the stateless module-llm `embed` tool via the SDK. There is +# no shared vectorstore — each app owns its own vectors. The `RAG` helper +# (app/rag.py) handles chunking, the embed call, storage, and search. +# --------------------------------------------------------------------------- + + +@api.route("/rag/index", methods=["POST"]) +def rag_index_endpoint(): + """Embed and store documents in the app's own database. + + Body: {"documents": [{"content": "...", "source_name": "...", + "source_page": 1}, ...]} + + For each document the RAG helper chunks the text, calls + module-llm `embed` to turn each chunk into a vector, and stores the + chunk + vector in this app's `vector_chunks` table (pgvector). + """ + data = request.get_json(silent=True) + if not data or not data.get("documents"): + return jsonify(error="Missing required field: documents"), 400 + + db = next(get_db()) + rag = RAG(db, druppie) + rag.create_index(RAG_INDEX) + result = rag.index_documents(RAG_INDEX, data["documents"]) + return jsonify(result) + + +@api.route("/rag/search", methods=["POST"]) +def rag_search_endpoint(): + """Semantic similarity search over the stored documents. + + Body: {"query": "natural-language question", "top_k": 5} + + The query is embedded with the same module-llm `embed` tool, then + matched against the stored chunks with pgvector's cosine distance + (`embedding <=> :qvec`). Returns the top-k chunks with their source + metadata so the caller can build a cited answer. + """ + data = request.get_json(silent=True) + if not data or "query" not in data: + return jsonify(error="Missing required field: query"), 400 + + db = next(get_db()) + rag = RAG(db, druppie) + results = rag.search(RAG_INDEX, data["query"], top_k=data.get("top_k", 5)) + return jsonify(results=results) diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..291c33a --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,37 @@ +services: + app: + build: . + ports: + - "${APP_PORT:-8000}:8000" + depends_on: + database: + condition: service_healthy + environment: + - APP_NAME=${APP_NAME:-My App} + - DATABASE_URL=postgresql://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-app}@database:5432/${POSTGRES_DB:-app} + - DRUPPIE_URL=${DRUPPIE_URL:-http://druppie-backend:8000} + - DRUPPIE_MODULE_API_TOKEN=${DRUPPIE_MODULE_API_TOKEN:-} + - DEBUG=${DEBUG:-false} + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + + database: + image: pgvector/pgvector:pg16 + environment: + POSTGRES_USER: ${POSTGRES_USER:-app} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-app} + POSTGRES_DB: ${POSTGRES_DB:-app} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-app}"] + interval: 5s + timeout: 3s + retries: 5 + +volumes: + pgdata: diff --git a/docs/platform-functional-standards.md b/docs/platform-functional-standards.md new file mode 100644 index 0000000..50c1cc8 --- /dev/null +++ b/docs/platform-functional-standards.md @@ -0,0 +1,98 @@ +# Platform Functional Standards + +**Revision:** 2026-04-20 + +Every Druppie-created application inherits these functional defaults. The +Business Analyst treats them as givens during elicitation and only writes +specific requirements into the FD when the user needs something different. + +The user sees this file linked from every `functional-design.md`. If anything +here doesn't fit a project, the BA documents the deviation in the FD. + +## 1. Language + +- User-facing text, HITL questions, and the functional design document + are written in the user's language by default. For Dutch public-sector + work that means Dutch. +- Error messages, empty states, and confirmation dialogs follow the same + language as the rest of the app. + +## 2. Performance (user-visible) + +- Pages load within **2 seconds** under normal conditions (home network, + reasonable payload). +- Interactive actions (save, submit, filter) acknowledge the user within + **1 second** — at minimum with a loading indicator if the real work + takes longer. +- Search / filter results appear within **2 seconds** for typical datasets. +- The app works acceptably for at least **25 concurrent users** without + visible slowdown. + +Projects only write NFRs for performance when they need stricter numbers +than these (e.g. realtime, sub-100ms) or when a specific operation has an +unusual cost profile. + +## 3. Responsiveness & device support + +- Works on desktop (≥1280px), tablet, and mobile (down to 320px wide). +- Touch-friendly tap targets on mobile. +- No horizontal scrolling on any supported width. + +## 4. Accessibility + +- Keyboard navigation works for every interactive element. +- Color contrast meets WCAG 2.1 AA for text. +- Every form field has an associated label. +- Focus states are visible. + +The BA does not elicit accessibility requirements for every project — they +are a given. The BA only asks if the user has explicit extra needs (e.g. +screen-reader-only users, high-contrast mode). + +## 5. Error behaviour (user-visible) + +- When something fails, the app shows a clear, human-readable message in + the user's language — never a stack trace, never an error code without + explanation. +- On partial failure, the rest of the app stays usable. +- Destructive actions ask for confirmation. + +## 6. Authentication (user experience) + +- Users log in via the Druppie platform (Keycloak). The project itself + does not show a login page, registration form, password-reset flow, or + role-management UI. +- The BA does NOT elicit login/password/role-management requirements. + If a user asks for project-specific identity features, the BA flags + that as a platform-standard deviation for the Architect to evaluate. + +## 7. Cost and usage (user experience) + +- Apps do not show LLM/model cost, token usage, or spend information to + users. Cost tracking is a platform concern. + +## 8. Explicitly out of scope (functional) + +These topics are platform-level and should NOT become FD requirements +unless the user explicitly wants a deviation: + +- Login / signup / password reset screens +- User/role administration UI +- In-app billing or pricing pages +- Cost / token usage displays +- Notification preferences (platform handles notifications) +- GDPR data-export / right-to-be-forgotten flows (platform feature) + +If the user wants one of these, document it in the FD under +*"Platform standard deviations"* with a short rationale — the Architect +will then build it explicitly. + +## 9. Referencing this file in the FD + +Every `functional-design.md` ends with a **Platform standards applied** +section linking back here with the revision the FD was written against: + +> Platform standards applied: [docs/platform-functional-standards.md](./platform-functional-standards.md) rev 2026-04-20. +> Only deviations are listed below. + +This keeps the user in the loop about what they're getting for free. diff --git a/docs/platform-technical-standards.md b/docs/platform-technical-standards.md new file mode 100644 index 0000000..98388e8 --- /dev/null +++ b/docs/platform-technical-standards.md @@ -0,0 +1,206 @@ +# Platform Technical Standards + +**Revision:** 2026-06-01 + +Every Druppie-created application follows these technical defaults. The +Architect treats them as givens when writing `docs/technical-design.md` and only +documents deviations. + +The TD references this file by name and revision — it does not restate the +defaults. See the user-facing functional defaults in +[`platform-functional-standards.md`](./platform-functional-standards.md). + +## 1. Stack + +| Layer | Default | Notes | +|---|---|---| +| Frontend | React 18 + Vite + TypeScript | From `druppie/templates/project/frontend/` | +| Backend | Python 3.11 + FastAPI | From `druppie/templates/project/app/` | +| Database | Postgres 15 | One DB per project, no sharing | +| Runtime | Docker Compose (dev) + the Druppie deploy pipeline (prod) | | +| LLM access | Druppie SDK (`druppie_sdk.client`) — never direct provider SDKs | Provider swaps handled centrally | +| Module access | Druppie SDK — module discovery + calls | Do NOT reimplement capabilities that exist as modules | + +TDs do not restate this. They only call it out when they deviate. + +## 2. Template is the starting point + +Every app starts from `druppie/templates/project/`. The chat UI, session +management, agent loop, and SDK wiring are already there. The TD assumes +this scaffold exists and only describes: + +- New routes/components/endpoints added on top +- Data model (the project's own tables) +- Domain logic and business rules +- External integrations specific to this project + +The TD does not document: the chat panel, session list, keycloak wiring, +agent framework, SDK setup — these are the template and do not change +per project. + +## 3. Modules before code + +Before designing a capability from scratch, the Architect checks the module +registry: + +- Text generation / reasoning → `module-llm` +- Image / OCR / document vision → `module-vision` +- Web search → `module-web` +- File search inside a codebase → `module-filesearch` +- ArchiMate / architecture reasoning → `module-archimate` +- Shell / coding execution → `module-coding` +- Document-heavy retrieval, knowledge-base search, citation-backed Q&A + → app-local pgvector via `app/rag.py` helper (storage + semantic search) + and `module-llm` `embed` tool (embeddings via SDK). Each app stores + vectors in its own database for full data isolation. + +If an existing module covers the capability, the TD references the module +and the SDK call pattern — it does not design an alternative. Building a +new capability is only valid if no matching module exists; the Architect +notes this in the TD with a short justification. + +## 4. Database & persistence + +- **Postgres is the default.** Reach for it whenever data needs to be + queried, filtered, joined, or aggregated. +- **Use relational tables when you need to query inside the data.** If a + field is filtered, joined on, or aggregated — make it a proper column. +- **JSON / JSONB is fine when the data is only read back whole.** If you + store a blob, look it up by id, and hand it back to the caller as-is, + there's no reason to normalise it. Common examples: opaque third-party + payloads, rendered content, configuration snapshots. +- **Migrations are allowed.** Use them when schema changes need to land + in an existing environment without a reset. + +Programming style — follow the Druppie core: + +- **Summary / Detail domain models** — `FooSummary` for lists, `FooDetail` + for single-item endpoints (see `SessionSummary` / `SessionDetail`). All + domain types live in `app/domain/` and are exported from + `app/domain/__init__.py`. +- **One repository per aggregate.** `FooRepository` owns all data access + for `Foo`; repositories return domain models, not ORM rows. +- **Services compose repositories.** Services hold business logic; they + never touch the DB directly. Route handlers call services. + +## 5. RAG defaults + +For doc-heavy applications (knowledge bases, document search, +citation-backed Q&A, large-document retrieval) the platform defaults are: + +| Topic | Default | +|---|---| +| Vector storage | App's own Postgres with pgvector (`pgvector/pgvector:pg16` image). Use `app/rag.py` helper — never reimplement chunking or vector search from scratch. | +| Embeddings | `module-llm` `embed` tool via the Druppie SDK (stateless, shared). Research recommends `multilingual-e5-large-instruct` (MIT, multilingual, CPU-feasible); `module-llm` config pins the active default. | +| Retrieval | Semantic (cosine similarity) via `rag.py` `search()`. Hybrid (BM25 + dense) with RRF k=60 is a future upgrade — application-layer BM25 fusion until then. | +| BM25 analyzer | Language-specific (`to_tsvector('', ...)`) per field — application-layer | +| Chunking | Recursive splitter, default `chunk_size=2048` / `chunk_overlap=256` characters (≈ 512 tokens, per the 2026 benchmarks in `docs/RAG/rag-patterns.md`) | +| Re-ranking | BGE-reranker-v2-m3 self-hosted — application layer | +| Citation metadata | `source_name` + `source_page` + `source_section` + `chunk_id` returned by every search result | +| Citation format | Footnote style in formal Markdown output + anchor tags for interactive UI | +| Document extraction | Caller-side; `rag.py` `index_documents()` accepts pre-extracted text + metadata | +| Data isolation | Physical — each app owns its vectors in its own database. No shared vectorstore, no cross-app access. | + +Mandatory NFRs in the TD for any RAG component: retrieval latency, +recall on a gold-set, faithfulness, citation precision, hallucination +rate, freshness SLA, named content owner per domain, PII tagging +before indexing, lineage per chunk. Use the `LS / HS / Batch` +archetype defaults from the `rag-patterns` skill. + +The TD does not restate these defaults. It only documents deviations +and the trigger that justifies them. + +For deeper guidance — chunking variants, advanced patterns +(re-ranking, query rewriting, GraphRAG, agentic), NFR archetypes, +anti-patterns — see the `rag-patterns` skill in the Druppie core. + +## 6. API conventions + +- FastAPI routers under `app/api/routes/`. +- Routes are thin: validate input, call a service, return a domain model. + No business logic in route handlers. +- Services under `app/services/` contain the business logic. +- Repositories under `app/repositories/` own data access and return + domain models, not ORM rows. +- All domain types are Pydantic models in `app/domain/`, exported from + `app/domain/__init__.py`. + +## 7. Frontend conventions + +- TypeScript, strict mode on. +- Pages under `src/pages/`, reusable components under `src/components/`. +- API client under `src/services/api.ts` — all HTTP calls go through it. +- Styling: Tailwind via the classes already set up in the template. +- Chat UI: use the template's `ChatPanel` (from + `druppie/templates/project/frontend/src/components/chat/`). Do not + roll a new chat UI. + +## 8. Testing + +- Backend: pytest. Integration tests target a real Postgres (via the + template's `docker-compose.yaml`). Mocks only for external third-party + APIs. +- Frontend: Playwright for end-to-end. Unit tests only where logic is + non-trivial — UI snapshots are usually not worth it. +- The TD names the scenarios that need tests; test implementation + details live in the repo, not the TD. + +## 9. Security (technical) + +- **Auth is handled by Druppie.** Every app is deployed behind the + Druppie platform, which already does Keycloak-based auth. Apps read + the authenticated user from request headers; they do NOT implement + their own login, token issuance, password storage, or role management. +- **Secrets** come from env vars. No secrets in code, no secrets in the + repo. +- **PII** — if an app handles personal data, the Architect calls that + out in the TD with retention + access-restriction notes. Everything + else defaults to "authenticated Druppie user may access their own + data." + +The user-facing side of auth (no login screen, no role-admin UI) lives +in the functional standards file. + +## 10. Explicitly out of scope (for now) + +The following are platform concerns and should NOT appear in individual +project TDs: + +- **Authentication & authorisation implementation.** Handled by Druppie; + apps trust headers. +- **Cost tracking / LLM-spend accounting.** Will be a platform feature. +- **Audit logging of data access.** Platform concern. +- **Rate limiting.** Platform concern (handled at the ingress). +- **Multi-tenancy isolation beyond per-user data scoping.** Single-tenant + per app for now. +- **Backups / disaster recovery.** Platform concern. + +If a project has a genuine reason to do any of these itself (e.g. a +compliance-driven exception), the Architect documents it as a platform- +standard deviation with rationale. + +## 11. Deployment + +- Each app ships a `Dockerfile` and a `docker-compose.yaml` in the same + shape as the template. +- Every service has a `/health` endpoint returning 200 when ready. +- Startup uses the existing `init` pattern — see + `druppie/templates/project/docker-compose.yaml`. +- Prod deployment is via the Druppie deploy pipeline; the TD does not + describe Kubernetes manifests or cloud infra. + +## 12. Git + +- Conventional commits (`feat:`, `fix:`, `refactor:`, `docs:`, `test:`, + `chore:`). +- Feature branches off `main`; PRs back to `main`. +- One logical change per commit. +- Every PR description states the why, not just the what. + +## 13. Referencing this file in the TD + +Every `technical-design.md` starts with a **Platform standards** line +linking back here with the revision the TD was written against: + +> Platform standards: conforms to [docs/platform-technical-standards.md](./platform-technical-standards.md) rev 2026-06-01. +> Only deviations are documented below. diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..a33665a --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui" + } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..9aefcae --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + App + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..6d8ae03 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,32 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.0", + "@radix-ui/react-slot": "^1.1.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "tailwind-merge": "^3.0.0", + "lucide-react": "^0.469.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "~5.7.0", + "vite": "^6.0.0", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..860cef0 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,144 @@ +import { useEffect, useState } from "react" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { ChatPanel } from "@/components/chat" + +interface SearchResult { + title: string + url: string + snippet: string +} + +function App() { + const [appName, setAppName] = useState("My App") + const [tab, setTab] = useState<"home" | "search" | "chat">("home") + + useEffect(() => { + fetch("/api/info") + .then((res) => res.json()) + .then((data) => setAppName(data.app_name)) + .catch(() => {}) + }, []) + + return ( +
+
+
+

{appName}

+
+ + + +
+
+
+ + {tab === "home" && ( +
+ + + {appName} + + +

+ Welkom! Klik op Assistent om te chatten met de AI-assistent, + of pas deze pagina aan voor je eigen applicatie. +

+
+
+
+ )} + + {tab === "search" && } + + {tab === "chat" && ( +
+ +
+ )} +
+ ) +} + +function SearchPage() { + const [query, setQuery] = useState("") + const [results, setResults] = useState([]) + const [loading, setLoading] = useState(false) + const [searched, setSearched] = useState(false) + + const search = async () => { + if (!query.trim()) return + setLoading(true) + setSearched(true) + try { + const r = await fetch("/api/ai/search", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query: query.trim() }), + }) + const data = await r.json() + setResults(data.results || []) + } catch { + setResults([]) + } finally { + setLoading(false) + } + } + + return ( +
+ + + Web Search + + +
+ setQuery(e.target.value)} + onKeyDown={e => e.key === "Enter" && search()} + placeholder="Zoek op het web..." + disabled={loading} + /> + +
+
+
+ + {searched && ( + + + {loading ? ( +

Zoeken...

+ ) : results.length === 0 ? ( +

Geen resultaten gevonden.

+ ) : ( +
+ {results.map((r, i) => ( +
+ + {r.title} + +

{r.url}

+

{r.snippet}

+
+ ))} +
+ )} +
+
+ )} +
+ ) +} + +export default App diff --git a/frontend/src/components/chat/ChatPanel.tsx b/frontend/src/components/chat/ChatPanel.tsx new file mode 100644 index 0000000..168f582 --- /dev/null +++ b/frontend/src/components/chat/ChatPanel.tsx @@ -0,0 +1,284 @@ +/** + * ChatPanel — Full chat UI with sidebar session list and message area. + * + * Drop this into any page to add a conversational AI assistant. + * Customize the agent backend in app/agent.py. + */ + +import { useState, useEffect, useRef, useCallback } from "react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { MessageSquarePlus, Trash2, ChevronDown, ChevronRight, Send, Loader2, PanelLeftClose, PanelLeft } from "lucide-react" +import type { ChatSession, ChatMessage, AgentStep } from "./types" +import * as api from "./api" + +// --------------------------------------------------------------------------- +// Agent step rendering (transparency) +// --------------------------------------------------------------------------- + +function StepBadge({ step }: { step: AgentStep }) { + const [open, setOpen] = useState(false) + const colors: Record = { + search: "bg-blue-50 text-blue-700 border-blue-200", + query: "bg-amber-50 text-amber-700 border-amber-200", + tool: "bg-purple-50 text-purple-700 border-purple-200", + reasoning: "bg-gray-50 text-gray-600 border-gray-200", + } + const cls = colors[step.type] || colors.reasoning + + return ( +
+ + {open && step.detail && ( +
+          {typeof step.detail === "string"
+            ? step.detail
+            : Array.isArray(step.detail)
+              ? step.detail.join("\n")
+              : JSON.stringify(step.detail, null, 2)}
+        
+ )} +
+ ) +} + +// --------------------------------------------------------------------------- +// Sidebar +// --------------------------------------------------------------------------- + +function Sidebar({ + sessions, activeId, onSelect, onNew, onDelete, collapsed, onToggle, +}: { + sessions: ChatSession[] + activeId: number | null + onSelect: (id: number) => void + onNew: () => void + onDelete: (id: number) => void + collapsed: boolean + onToggle: () => void +}) { + if (collapsed) { + return ( +
+ + +
+ ) + } + + return ( +
+
+ + +
+
+ {sessions.length === 0 && ( +

Geen gesprekken

+ )} + {sessions.map(s => ( +
onSelect(s.id)} + > + {s.title} + +
+ ))} +
+
+ ) +} + +// --------------------------------------------------------------------------- +// Main ChatPanel +// --------------------------------------------------------------------------- + +export default function ChatPanel() { + const [sessions, setSessions] = useState([]) + const [activeId, setActiveId] = useState(null) + const [messages, setMessages] = useState([]) + const [input, setInput] = useState("") + const [sidebarCollapsed, setSidebarCollapsed] = useState(false) + + // Sending state derived from messages — session-specific, not global + const sending = messages.some(m => m.status === "thinking") + const endRef = useRef(null) + const inputRef = useRef(null) + + // Load sessions on mount + const loadSessions = useCallback(async () => { + const list = await api.listSessions() + setSessions(list) + }, []) + + useEffect(() => { loadSessions() }, [loadSessions]) + useEffect(() => { endRef.current?.scrollIntoView({ behavior: "smooth" }) }, [messages]) + + // Load messages when active session changes + const loadMessages = useCallback(async (sid: number) => { + const data = await api.getSession(sid) + setMessages(data.messages || []) + return data.messages || [] + }, []) + + useEffect(() => { + if (!activeId) { setMessages([]); return } + loadMessages(activeId) + }, [activeId, loadMessages]) + + // Poll for "thinking" messages — agent processes in background + useEffect(() => { + const hasThinking = messages.some(m => m.status === "thinking") + if (!hasThinking || !activeId) return + + const interval = setInterval(async () => { + const msgs = await loadMessages(activeId) + const stillThinking = msgs.some((m: ChatMessage) => m.status === "thinking") + if (!stillThinking) { + loadSessions() // title may have updated + } + }, 2000) + + return () => clearInterval(interval) + }, [messages, activeId, loadMessages, loadSessions]) + + const handleNew = async () => { + const session = await api.createSession() + setSessions(prev => [session, ...prev]) + setActiveId(session.id) + setMessages([]) + inputRef.current?.focus() + } + + const handleDelete = async (id: number) => { + await api.deleteSession(id) + setSessions(prev => prev.filter(s => s.id !== id)) + if (activeId === id) { + setActiveId(null) + setMessages([]) + } + } + + const handleSend = async () => { + if (!input.trim() || sending) return + let sid = activeId + if (!sid) { + const session = await api.createSession() + setSessions(prev => [session, ...prev]) + setActiveId(session.id) + sid = session.id + } + const text = input.trim() + setInput("") + try { + await api.sendMessage(sid, text) + await loadMessages(sid) + loadSessions() + } catch { + setMessages(prev => [ + ...prev, + { id: Date.now(), role: "assistant", content: "Verbindingsfout — probeer opnieuw.", + status: "error", steps: null, created_at: new Date().toISOString() }, + ]) + } + } + + return ( +
+ setSidebarCollapsed(!sidebarCollapsed)} + /> + + {/* Chat area */} +
+ {/* Messages */} +
+ {!activeId && messages.length === 0 && ( +
+
+ +

Start een nieuw gesprek of selecteer een bestaand gesprek.

+ +
+
+ )} + + {messages.map(m => ( +
+
+ {/* Agent steps (transparency) */} + {m.role === "assistant" && m.steps && m.steps.length > 0 && ( +
+ {m.steps.map((step, i) => )} +
+ )} +
+ {m.role === "assistant" && m.status === "thinking" ? ( + + Denken... + + ) : m.content} +
+
+
+ ))} + + {/* No separate "thinking" bubble needed — the message itself shows status */} +
+
+ + {/* Input */} +
+
+ setInput(e.target.value)} + onKeyDown={e => e.key === "Enter" && !e.shiftKey && handleSend()} + placeholder={activeId ? "Typ een bericht..." : "Start een nieuw gesprek..."} + disabled={sending} + className="flex-1" + /> + +
+
+
+
+ ) +} diff --git a/frontend/src/components/chat/api.ts b/frontend/src/components/chat/api.ts new file mode 100644 index 0000000..2ff4b29 --- /dev/null +++ b/frontend/src/components/chat/api.ts @@ -0,0 +1,40 @@ +import type { ChatSession, ChatMessage } from "./types" + +const BASE = "/api/chat" + +export async function createSession(): Promise { + const r = await fetch(`${BASE}/sessions`, { method: "POST" }) + return r.json() +} + +export async function listSessions(): Promise { + const r = await fetch(`${BASE}/sessions`) + return r.json() +} + +export async function getSession(id: number): Promise { + const r = await fetch(`${BASE}/sessions/${id}`) + return r.json() +} + +export async function deleteSession(id: number): Promise { + await fetch(`${BASE}/sessions/${id}`, { method: "DELETE" }) +} + +export async function renameSession(id: number, title: string): Promise { + const r = await fetch(`${BASE}/sessions/${id}/rename`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title }), + }) + return r.json() +} + +export async function sendMessage(sessionId: number, message: string): Promise { + const r = await fetch(`${BASE}/sessions/${sessionId}/messages`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message }), + }) + return r.json() +} diff --git a/frontend/src/components/chat/index.ts b/frontend/src/components/chat/index.ts new file mode 100644 index 0000000..b794906 --- /dev/null +++ b/frontend/src/components/chat/index.ts @@ -0,0 +1,2 @@ +export { default as ChatPanel } from "./ChatPanel" +export type { ChatSession, ChatMessage, AgentStep } from "./types" diff --git a/frontend/src/components/chat/types.ts b/frontend/src/components/chat/types.ts new file mode 100644 index 0000000..af4c70b --- /dev/null +++ b/frontend/src/components/chat/types.ts @@ -0,0 +1,22 @@ +export interface ChatSession { + id: number + title: string + created_at: string + updated_at: string + message_count: number +} + +export interface AgentStep { + type: string // "search", "query", "tool", "reasoning" + label: string + detail: string | string[] | Record | null +} + +export interface ChatMessage { + id: number + role: "user" | "assistant" + content: string + status: "thinking" | "done" | "error" + steps: AgentStep[] | null + created_at: string +} diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx new file mode 100644 index 0000000..44a2f5e --- /dev/null +++ b/frontend/src/components/ui/button.tsx @@ -0,0 +1,57 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + }, +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx new file mode 100644 index 0000000..e676b75 --- /dev/null +++ b/frontend/src/components/ui/card.tsx @@ -0,0 +1,83 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, +} diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx new file mode 100644 index 0000000..8353802 --- /dev/null +++ b/frontend/src/components/ui/input.tsx @@ -0,0 +1,23 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Input = React.forwardRef< + HTMLInputElement, + React.InputHTMLAttributes +>(({ className, type, ...props }, ref) => { + return ( + + ) +}) +Input.displayName = "Input" + +export { Input } diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..e484da0 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,55 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + --radius: 0.5rem; + } + + .dark { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..d084cca --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..89b2710 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from "react" +import { createRoot } from "react-dom/client" +import { BrowserRouter } from "react-router-dom" +import "./index.css" +import App from "./App" + +createRoot(document.getElementById("root")!).render( + + + + + , +) diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..755dd4c --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,46 @@ +/** @type {import('tailwindcss').Config} */ +export default { + darkMode: ["class"], + content: ["./index.html", "./src/**/*.{ts,tsx}"], + theme: { + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + }, + }, + plugins: [], +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..5e1feb4 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..8e5b203 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..793fb27 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,17 @@ +import { fileURLToPath } from "url" +import react from "@vitejs/plugin-react" +import { defineConfig } from "vite" + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)), + }, + }, + server: { + proxy: { + "/api": "http://localhost:8000", + }, + }, +}) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..19706e2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +flask==3.1.0 +gunicorn==23.0.0 +sqlalchemy==2.0.36 +psycopg2-binary==2.9.10 +pgvector==0.3.6 +numpy>=1.24.0 +python-dotenv==1.0.1