feat: scaffold project template

This commit is contained in:
Druppie 2026-06-11 11:35:12 +00:00
parent 89dba9a914
commit ca2e8aa60a
36 changed files with 2265 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
__pycache__/
*.pyc
.env
*.egg-info/
.venv/
node_modules/
dist/
.DS_Store

20
Dockerfile Normal file
View File

@ -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()"]

32
app/__init__.py Normal file
View File

@ -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("/<path:path>")
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

85
app/agent.py Normal file
View File

@ -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}

78
app/ai.py Normal file
View File

@ -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

224
app/chat.py Normal file
View File

@ -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/<int:session_id>")
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/<int:session_id>", 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/<int:session_id>/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/<int:session_id>/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,
}

10
app/config.py Normal file
View File

@ -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()

48
app/database.py Normal file
View File

@ -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)

30
app/models.py Normal file
View File

@ -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

342
app/rag.py Normal file
View File

@ -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

131
app/routes.py Normal file
View File

@ -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)

37
docker-compose.yaml Normal file
View File

@ -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:

View File

@ -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.

View File

@ -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('<corpus-language>', ...)`) 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.

17
frontend/components.json Normal file
View File

@ -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"
}
}

12
frontend/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

32
frontend/package.json Normal file
View File

@ -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"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

144
frontend/src/App.tsx Normal file
View File

@ -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 (
<div className="min-h-screen bg-background flex flex-col">
<header className="border-b bg-card shrink-0">
<div className="container mx-auto flex h-14 items-center justify-between px-4">
<h1 className="text-lg font-bold">{appName}</h1>
<div className="flex gap-1">
<Button variant={tab === "home" ? "default" : "ghost"} size="sm" onClick={() => setTab("home")}>
Home
</Button>
<Button variant={tab === "search" ? "default" : "ghost"} size="sm" onClick={() => setTab("search")}>
Web Search
</Button>
<Button variant={tab === "chat" ? "default" : "ghost"} size="sm" onClick={() => setTab("chat")}>
Assistent
</Button>
</div>
</div>
</header>
{tab === "home" && (
<main className="container mx-auto px-4 py-8 max-w-2xl">
<Card>
<CardHeader>
<CardTitle>{appName}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
Welkom! Klik op <strong>Assistent</strong> om te chatten met de AI-assistent,
of pas deze pagina aan voor je eigen applicatie.
</p>
</CardContent>
</Card>
</main>
)}
{tab === "search" && <SearchPage />}
{tab === "chat" && (
<div className="flex-1 overflow-hidden">
<ChatPanel />
</div>
)}
</div>
)
}
function SearchPage() {
const [query, setQuery] = useState("")
const [results, setResults] = useState<SearchResult[]>([])
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 (
<main className="container mx-auto px-4 py-8 max-w-2xl space-y-4">
<Card>
<CardHeader>
<CardTitle>Web Search</CardTitle>
</CardHeader>
<CardContent>
<div className="flex gap-2">
<Input
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => e.key === "Enter" && search()}
placeholder="Zoek op het web..."
disabled={loading}
/>
<Button onClick={search} disabled={loading || !query.trim()}>
{loading ? "Zoeken..." : "Zoek"}
</Button>
</div>
</CardContent>
</Card>
{searched && (
<Card>
<CardContent className="pt-5">
{loading ? (
<p className="text-center text-muted-foreground py-4">Zoeken...</p>
) : results.length === 0 ? (
<p className="text-center text-muted-foreground py-4">Geen resultaten gevonden.</p>
) : (
<div className="divide-y">
{results.map((r, i) => (
<div key={i} className="py-3">
<a href={r.url} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline font-medium">
{r.title}
</a>
<p className="text-xs text-muted-foreground mt-0.5">{r.url}</p>
<p className="text-sm mt-1">{r.snippet}</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
)}
</main>
)
}
export default App

View File

@ -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<string, string> = {
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 (
<div className="text-xs">
<button
onClick={() => step.detail && setOpen(!open)}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded border ${cls} ${step.detail ? "cursor-pointer hover:opacity-80" : ""}`}
>
{step.detail ? (open ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />) : null}
<span>{step.label}</span>
</button>
{open && step.detail && (
<pre className="mt-1 p-2 bg-muted rounded text-[11px] whitespace-pre-wrap max-h-40 overflow-y-auto">
{typeof step.detail === "string"
? step.detail
: Array.isArray(step.detail)
? step.detail.join("\n")
: JSON.stringify(step.detail, null, 2)}
</pre>
)}
</div>
)
}
// ---------------------------------------------------------------------------
// 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 (
<div className="w-12 border-r bg-card flex flex-col items-center py-3 gap-2 shrink-0">
<button onClick={onToggle} className="p-2 rounded hover:bg-muted" title="Open sidebar">
<PanelLeft className="w-4 h-4" />
</button>
<button onClick={onNew} className="p-2 rounded hover:bg-muted" title="Nieuw gesprek">
<MessageSquarePlus className="w-4 h-4" />
</button>
</div>
)
}
return (
<div className="w-64 border-r bg-card flex flex-col shrink-0">
<div className="p-3 border-b flex items-center justify-between">
<Button variant="outline" size="sm" className="flex-1 mr-2" onClick={onNew}>
<MessageSquarePlus className="w-4 h-4 mr-1" /> Nieuw gesprek
</Button>
<button onClick={onToggle} className="p-1.5 rounded hover:bg-muted" title="Sluit sidebar">
<PanelLeftClose className="w-4 h-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto">
{sessions.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-6">Geen gesprekken</p>
)}
{sessions.map(s => (
<div
key={s.id}
className={`group flex items-center px-3 py-2 cursor-pointer text-sm transition-colors ${
s.id === activeId ? "bg-muted font-medium" : "hover:bg-muted/50"
}`}
onClick={() => onSelect(s.id)}
>
<span className="flex-1 truncate">{s.title}</span>
<button
onClick={e => { e.stopPropagation(); onDelete(s.id) }}
className="opacity-0 group-hover:opacity-100 p-1 rounded hover:bg-destructive/10 hover:text-destructive transition-opacity"
title="Verwijderen"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
))}
</div>
</div>
)
}
// ---------------------------------------------------------------------------
// Main ChatPanel
// ---------------------------------------------------------------------------
export default function ChatPanel() {
const [sessions, setSessions] = useState<ChatSession[]>([])
const [activeId, setActiveId] = useState<number | null>(null)
const [messages, setMessages] = useState<ChatMessage[]>([])
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<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(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 (
<div className="flex h-full">
<Sidebar
sessions={sessions}
activeId={activeId}
onSelect={setActiveId}
onNew={handleNew}
onDelete={handleDelete}
collapsed={sidebarCollapsed}
onToggle={() => setSidebarCollapsed(!sidebarCollapsed)}
/>
{/* Chat area */}
<div className="flex-1 flex flex-col min-w-0">
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{!activeId && messages.length === 0 && (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-3">
<MessageSquarePlus className="w-12 h-12 mx-auto text-muted-foreground/30" />
<p className="text-muted-foreground">Start een nieuw gesprek of selecteer een bestaand gesprek.</p>
<Button onClick={handleNew}>Nieuw gesprek</Button>
</div>
</div>
)}
{messages.map(m => (
<div key={m.id} className={`flex ${m.role === "user" ? "justify-end" : "justify-start"}`}>
<div className={`max-w-[85%] space-y-2 ${
m.role === "user"
? "bg-primary text-primary-foreground rounded-2xl rounded-tr-sm px-4 py-2.5"
: "space-y-2"
}`}>
{/* Agent steps (transparency) */}
{m.role === "assistant" && m.steps && m.steps.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{m.steps.map((step, i) => <StepBadge key={i} step={step} />)}
</div>
)}
<div className={`text-sm whitespace-pre-wrap ${m.role === "assistant" ? "bg-muted rounded-2xl rounded-tl-sm px-4 py-2.5" : ""}`}>
{m.role === "assistant" && m.status === "thinking" ? (
<span className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" /> Denken...
</span>
) : m.content}
</div>
</div>
</div>
))}
{/* No separate "thinking" bubble needed — the message itself shows status */}
<div ref={endRef} />
</div>
{/* Input */}
<div className="border-t p-3">
<div className="flex gap-2 max-w-3xl mx-auto">
<Input
ref={inputRef}
value={input}
onChange={e => 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"
/>
<Button onClick={handleSend} disabled={sending || !input.trim()} size="icon">
<Send className="w-4 h-4" />
</Button>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,40 @@
import type { ChatSession, ChatMessage } from "./types"
const BASE = "/api/chat"
export async function createSession(): Promise<ChatSession> {
const r = await fetch(`${BASE}/sessions`, { method: "POST" })
return r.json()
}
export async function listSessions(): Promise<ChatSession[]> {
const r = await fetch(`${BASE}/sessions`)
return r.json()
}
export async function getSession(id: number): Promise<ChatSession & { messages: ChatMessage[] }> {
const r = await fetch(`${BASE}/sessions/${id}`)
return r.json()
}
export async function deleteSession(id: number): Promise<void> {
await fetch(`${BASE}/sessions/${id}`, { method: "DELETE" })
}
export async function renameSession(id: number, title: string): Promise<ChatSession> {
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<ChatMessage> {
const r = await fetch(`${BASE}/sessions/${sessionId}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }),
})
return r.json()
}

View File

@ -0,0 +1,2 @@
export { default as ChatPanel } from "./ChatPanel"
export type { ChatSession, ChatMessage, AgentStep } from "./types"

View File

@ -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<string, unknown> | null
}
export interface ChatMessage {
id: number
role: "user" | "assistant"
content: string
status: "thinking" | "done" | "error"
steps: AgentStep[] | null
created_at: string
}

View File

@ -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<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
},
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@ -0,0 +1,83 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className,
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
}

View File

@ -0,0 +1,23 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<
HTMLInputElement,
React.InputHTMLAttributes<HTMLInputElement>
>(({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
)
})
Input.displayName = "Input"
export { Input }

55
frontend/src/index.css Normal file
View File

@ -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;
}
}

View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

13
frontend/src/main.tsx Normal file
View File

@ -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(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
)

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -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: [],
}

View File

@ -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"]
}

7
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -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"]
}

17
frontend/vite.config.ts Normal file
View File

@ -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",
},
},
})

7
requirements.txt Normal file
View File

@ -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