"""Chat-Sessions und Nachrichten. Jede Nachricht wird als Run im Worker beantwortet (→ Konzept 3).""" from dataclasses import dataclass from datetime import datetime from decimal import Decimal from sqlalchemy import false, func, or_, select from sqlalchemy.orm import Session from app import audit from app.chat.models import ChatSession, Message from app.core.errors import Forbidden, Invalid, NotFound from app.data_sources import service as sources from app.hugin import service as hugin from app.identity import Principal from app.identity.models import User from app.tasks import engine, states from app.tasks.models import TaskRun from app.tools.models import ToolCall from app.workspaces import service as workspaces MAX_LENGTH = 20_000 DEFAULT_TITLE = "Neuer Chat" ACTIVE = (states.QUEUED, states.RUNNING, states.WAITING_APPROVAL, states.WAITING_INPUT) def list_for(session: Session, principal: Principal, workspace_id: int) -> list[ChatSession]: workspaces.get(session, principal, workspace_id) return list(session.scalars(select(ChatSession).where(ChatSession.workspace_id == workspace_id) .order_by(ChatSession.created.desc()))) @dataclass(frozen=True) class ChatRow: chat: ChatSession author: str messages: int last: datetime snippet: str # Fundstelle bei einer Suche def like(text: str) -> str: return "%" + text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%" def overview(session: Session, principal: Principal, workspace_id: int, q: str = "", limit: int = 100) -> list[ChatRow]: """Chats des Workspace mit letzter Aktivität, neueste zuerst; mit q nur Treffer in Titel oder Nachrichten.""" workspaces.get(session, principal, workspace_id) stats = (select(Message.chat_session_id, func.count().label("n"), func.max(Message.created).label("last")) .group_by(Message.chat_session_id).subquery()) statement = (select(ChatSession, User.display_name, stats.c.n, stats.c.last) .join(User, User.id == ChatSession.created_by) .outerjoin(stats, stats.c.chat_session_id == ChatSession.id) .where(ChatSession.workspace_id == workspace_id) .order_by(func.coalesce(stats.c.last, ChatSession.created).desc()).limit(limit)) q = " ".join(q.split())[:200] hits: dict[int, str] = {} if q: pattern = like(q) found = session.execute(select(Message.chat_session_id, Message.content) .join(ChatSession, ChatSession.id == Message.chat_session_id) .where(ChatSession.workspace_id == workspace_id, Message.content.ilike(pattern, escape="\\")) .order_by(Message.id.desc()).limit(500)).all() for chat_id, content in found: hits.setdefault(chat_id, snippet(content, q)) statement = statement.where(or_(ChatSession.title.ilike(pattern, escape="\\"), ChatSession.id.in_(list(hits)) if hits else false())) return [ChatRow(chat, author, n or 0, last or chat.created, hits.get(chat.id, "")) for chat, author, n, last in session.execute(statement)] def snippet(content: str, q: str, around: int = 70) -> str: text = " ".join(content.split()) at = text.lower().find(q.lower()) if at < 0: return text[:2 * around] start = max(0, at - around) return ("…" if start else "") + text[start:at + len(q) + around] + ("…" if at + len(q) + around < len(text) else "") def create(session: Session, principal: Principal, workspace_id: int, title: str = "") -> ChatSession: workspaces.get(session, principal, workspace_id) if not sources.chat_ready(session, workspace_id): raise Invalid("Erst eine Datenquelle verbinden und erfolgreich testen.") chat = ChatSession(workspace_id=workspace_id, title=title.strip()[:200] or DEFAULT_TITLE, created_by=principal.user_id) session.add(chat) session.flush() return chat def get(session: Session, principal: Principal, chat_id: int) -> ChatSession: chat = session.get(ChatSession, chat_id) if chat is None: raise NotFound workspaces.get(session, principal, chat.workspace_id) return chat def messages(session: Session, principal: Principal, chat_id: int) -> list[Message]: get(session, principal, chat_id) return list(session.scalars(select(Message).where(Message.chat_session_id == chat_id).order_by(Message.id))) def active_run(session: Session, chat_id: int) -> TaskRun | None: return session.scalar(select(TaskRun).where(TaskRun.chat_session_id == chat_id, TaskRun.status.in_(ACTIVE)) .order_by(TaskRun.created.desc()).limit(1)) def send(session: Session, principal: Principal, chat_id: int, content: str) -> tuple[Message, TaskRun]: chat = get(session, principal, chat_id) content = content.strip() if not content: raise Invalid("Die Nachricht ist leer.") if len(content) > MAX_LENGTH: raise Invalid(f"Die Nachricht ist länger als {MAX_LENGTH} Zeichen.") if not sources.chat_ready(session, chat.workspace_id): raise Invalid("Keine erfolgreich getestete Datenquelle im Workspace.") if active_run(session, chat_id) is not None: raise Invalid("Bitte warten, bis die laufende Antwort fertig ist.") workspace = workspaces.get(session, principal, chat.workspace_id).workspace if chat.title == DEFAULT_TITLE: # Erste Frage als Titel, bis jemand umbenennt chat.title = " ".join(content.split())[:60] + ("…" if len(content) > 60 else "") message = Message(chat_session_id=chat.id, role="user", content=content, author_id=principal.user_id) # Slash-Befehl aus Hugin: angezeigt wird die Eingabe, an das Modell geht der Text des Befehls expanded = hugin.expand(session, chat.workspace_id, content) if expanded is not None: text, command = expanded message.items = [{"role": "user", "content": text}] message.usage = {"command": {"id": command.id, "version": command.version}} session.add(message) session.flush() run = engine.create(session, workspace, principal.user_id, "chat", {"message_id": message.id}, chat_session_id=chat.id) message.run_id = run.id return message, run def set_plan_mode(session: Session, principal: Principal, chat_id: int, on: bool) -> ChatSession: """Planungsmodus: Die KI untersucht und plant nur; schreibende Tools gibt es erst nach „Plan umsetzen“.""" chat = get(session, principal, chat_id) if chat.plan_mode != on: chat.plan_mode = on audit.record(session, "chat.plan_mode_set", user_id=principal.user_id, workspace_id=chat.workspace_id, target_kind="chat", target_id=chat.id, on=on) return chat def execute_plan(session: Session, principal: Principal, chat_id: int) -> tuple[Message, TaskRun]: """Planungsmodus beenden und die Umsetzung als neue Nachricht starten.""" from app.chat import plan chat = get(session, principal, chat_id) if not chat.plan: raise Invalid("Es gibt noch keinen Plan.") set_plan_mode(session, principal, chat_id, False) return send(session, principal, chat_id, plan.EXECUTE_MESSAGE) def session_usage(session: Session, chat_id: int) -> tuple[int, Decimal | None]: """Tokens und Kosten aller Runs dieses Chats (auch Arbeitsnotizen); Kosten None ohne Preise.""" from app.usage.models import UsageRecord row = session.execute( select(func.coalesce(func.sum(UsageRecord.input_tokens + UsageRecord.output_tokens), 0), func.sum(UsageRecord.cost)) .where(UsageRecord.run_id.in_(select(TaskRun.id).where(TaskRun.chat_session_id == chat_id)))).one() return int(row[0]), row[1] def results(session: Session, run_ids: list) -> dict: """Ergebnistabellen der Tool-Aufrufe je Run, für die Anzeige unter der Antwort.""" ids = [r for r in run_ids if r is not None] if not ids: return {} found: dict = {} for call in session.scalars(select(ToolCall).where(ToolCall.run_id.in_(ids), ToolCall.result.is_not(None)) .order_by(ToolCall.id)): found.setdefault(call.run_id, []).append(call) return found def tool_call(session: Session, principal: Principal, call_id: int) -> ToolCall: call = session.get(ToolCall, call_id) if call is None or call.result is None: raise NotFound workspaces.get(session, principal, call.workspace_id) return call def regenerate(session: Session, principal: Principal, chat_id: int) -> TaskRun: """Letzte Frage erneut beantworten: eine vorhandene letzte Antwort wird ersetzt. Ohne Antwort (Run fehlgeschlagen oder abgebrochen) ist es ein neuer Versuch.""" chat = get(session, principal, chat_id) if active_run(session, chat_id) is not None: raise Invalid("Bitte warten, bis die laufende Antwort fertig ist.") if not sources.chat_ready(session, chat.workspace_id): raise Invalid("Keine erfolgreich getestete Datenquelle im Workspace.") last = session.scalars(select(Message).where(Message.chat_session_id == chat_id) .order_by(Message.id.desc()).limit(2)).all() if not last: raise Invalid("Im Chat gibt es noch keine Frage.") if last[0].role == "assistant": if len(last) < 2 or last[1].role != "user": raise Invalid("Zu dieser Antwort gibt es keine Frage.") question = last[1] session.delete(last[0]) else: question = last[0] workspace = workspaces.get(session, principal, chat.workspace_id).workspace run = engine.create(session, workspace, principal.user_id, "chat", {"message_id": question.id}, chat_session_id=chat.id) question.run_id = run.id audit.record(session, "chat.regenerated", user_id=principal.user_id, workspace_id=workspace.id, target_kind="chat", target_id=chat.id, run_id=str(run.id)) return run def export(session: Session, principal: Principal, chat_id: int) -> tuple[ChatSession, str]: """Chat als Markdown: Fragen, Antworten und die aufgerufenen Tools.""" chat = get(session, principal, chat_id) workspace = workspaces.get(session, principal, chat.workspace_id).workspace names = dict(session.execute(select(User.id, User.display_name).join( Message, Message.author_id == User.id).where(Message.chat_session_id == chat_id)).all()) lines = [f"# {chat.title}", "", f"Workspace: {workspace.name} ", f"Exportiert: {datetime.now().astimezone().strftime('%d.%m.%Y %H:%M')}", ""] for message in messages(session, principal, chat_id): when = message.created.astimezone().strftime("%d.%m.%Y %H:%M") if message.role == "user": lines += [f"## Frage – {names.get(message.author_id, 'unbekannt')}, {when}", "", message.content, ""] continue lines += [f"## Antwort – {message.model or 'Modell'}, {when}", ""] calls = [i for i in message.items or [] if i.get("type") == "function_call"] if calls: lines += ["
Tool-Aufrufe", ""] lines += [f"- `{c.get('name')}` {c.get('arguments', '')}" for c in calls] lines += ["", "
", ""] lines += [message.content, ""] return chat, "\n".join(lines) def answer(session: Session, principal: Principal, run_id) -> Message | None: """Antwort eines Runs, für das Fragment nach Ende des Streams.""" run = engine.get(session, principal, run_id) return session.scalar(select(Message).where(Message.run_id == run.id, Message.role == "assistant")) def require_owner(session: Session, principal: Principal, chat_id: int) -> ChatSession: """Umbenennen und Löschen: wer den Chat angelegt hat oder Workspace-Admin ist.""" chat = get(session, principal, chat_id) if chat.created_by != principal.user_id and not workspaces.get(session, principal, chat.workspace_id).is_admin: raise Forbidden return chat def rename(session: Session, principal: Principal, chat_id: int, title: str) -> ChatSession: chat = require_owner(session, principal, chat_id) title = " ".join(title.split())[:200] if not title: raise Invalid("Der Titel ist leer.") chat.title = title return chat def delete(session: Session, principal: Principal, chat_id: int) -> int: """Chat mit Nachrichten, Runs, Tool-Aufrufen und Anträgen löschen; das Audit-Log bleibt.""" chat = require_owner(session, principal, chat_id) if active_run(session, chat_id) is not None: raise Invalid("Im Chat läuft noch eine Antwort oder sie wartet auf eine Freigabe. Bitte erst abbrechen.") workspace_id = chat.workspace_id audit.record(session, "chat.deleted", user_id=principal.user_id, workspace_id=workspace_id, target_kind="chat", target_id=chat.id, title=chat.title) session.delete(chat) return workspace_id