275 lines
13 KiB
Python
275 lines
13 KiB
Python
"""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 += ["<details><summary>Tool-Aufrufe</summary>", ""]
|
||
lines += [f"- `{c.get('name')}` {c.get('arguments', '')}" for c in calls]
|
||
lines += ["", "</details>", ""]
|
||
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
|