bifroest/app/chat/service.py
2026-10-27 08:34:17 +01:00

275 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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