feat: scaffold project template
This commit is contained in:
parent
bb6e064ab9
commit
8b6e5ab87c
|
|
@ -0,0 +1,8 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.DS_Store
|
||||||
|
|
@ -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()"]
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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:
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { default as ChatPanel } from "./ChatPanel"
|
||||||
|
export type { ChatSession, ChatMessage, AgentStep } from "./types"
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { type ClassValue, clsx } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
|
|
@ -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>,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
@ -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: [],
|
||||||
|
}
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./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"]
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue