vergunningzoeker-cfb8aac7/app/chat.py

225 lines
7.7 KiB
Python

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