added RAG, Multiuser, TG bot
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from app.chat.service import ChatService
|
||||
from app.db.base import SessionLocal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GenerationBusyError(Exception):
|
||||
"""Сессия уже генерирует ответ."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class GenerationHandle:
|
||||
session_id: int
|
||||
user_id: int
|
||||
user_text: str
|
||||
task: asyncio.Task | None = None
|
||||
subscribers: list[asyncio.Queue[str | None]] = field(default_factory=list)
|
||||
_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
|
||||
async def broadcast(self, chunk: str | None) -> None:
|
||||
async with self._lock:
|
||||
targets = list(self.subscribers)
|
||||
for queue in targets:
|
||||
try:
|
||||
queue.put_nowait(chunk)
|
||||
except asyncio.QueueFull:
|
||||
logger.debug("generation queue full for session=%s, dropping subscriber", self.session_id)
|
||||
|
||||
def add_subscriber(self) -> asyncio.Queue[str | None]:
|
||||
queue: asyncio.Queue[str | None] = asyncio.Queue(maxsize=512)
|
||||
self.subscribers.append(queue)
|
||||
return queue
|
||||
|
||||
def remove_subscriber(self, queue: asyncio.Queue[str | None]) -> None:
|
||||
if queue in self.subscribers:
|
||||
self.subscribers.remove(queue)
|
||||
|
||||
|
||||
_registry: dict[int, GenerationHandle] = {}
|
||||
_registry_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def is_generation_active(session_id: int) -> bool:
|
||||
return session_id in _registry
|
||||
|
||||
|
||||
def get_active_handle(session_id: int) -> GenerationHandle | None:
|
||||
return _registry.get(session_id)
|
||||
|
||||
|
||||
async def _run_generation(handle: GenerationHandle) -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
service = ChatService(db, handle.user_id)
|
||||
async for chunk in service.stream_response(
|
||||
handle.session_id,
|
||||
handle.user_text,
|
||||
user_message_saved=True,
|
||||
):
|
||||
await handle.broadcast(chunk)
|
||||
except Exception as exc:
|
||||
logger.exception("Background generation failed session=%s", handle.session_id)
|
||||
await handle.broadcast(ChatService._sse("error", {"message": str(exc)}))
|
||||
finally:
|
||||
await handle.broadcast(None)
|
||||
db.close()
|
||||
async with _registry_lock:
|
||||
if _registry.get(handle.session_id) is handle:
|
||||
_registry.pop(handle.session_id, None)
|
||||
|
||||
|
||||
async def start_generation(session_id: int, user_id: int, user_text: str) -> GenerationHandle:
|
||||
async with _registry_lock:
|
||||
if session_id in _registry:
|
||||
raise GenerationBusyError()
|
||||
handle = GenerationHandle(session_id=session_id, user_id=user_id, user_text=user_text)
|
||||
_registry[session_id] = handle
|
||||
handle.task = asyncio.create_task(_run_generation(handle))
|
||||
return handle
|
||||
|
||||
|
||||
async def subscribe_generation(handle: GenerationHandle):
|
||||
queue = handle.add_subscriber()
|
||||
try:
|
||||
while True:
|
||||
chunk = await queue.get()
|
||||
if chunk is None:
|
||||
break
|
||||
yield chunk
|
||||
finally:
|
||||
handle.remove_subscriber(queue)
|
||||
@@ -1,44 +1,47 @@
|
||||
"""Инжект системных оповещений в чат без role=assistant (не ломает LLM-историю)."""
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.base import SessionLocal
|
||||
from app.db.models import ChatSession, Message
|
||||
|
||||
DISPLAY_ONLY_ROLES = frozenset({"notice", "character"})
|
||||
|
||||
|
||||
def _latest_chat_session(db) -> ChatSession:
|
||||
session = db.scalar(
|
||||
select(ChatSession).order_by(ChatSession.updated_at.desc()).limit(1)
|
||||
)
|
||||
if not session:
|
||||
session = ChatSession(title="Уведомления")
|
||||
db.add(session)
|
||||
db.commit()
|
||||
db.refresh(session)
|
||||
return session
|
||||
|
||||
|
||||
def post_notice_to_latest_chat(content: str) -> int | None:
|
||||
"""Сохраняет notice в последний активный чат. Возвращает session_id."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
session = _latest_chat_session(db)
|
||||
db.add(Message(session_id=session.id, role="notice", content=content))
|
||||
db.commit()
|
||||
return session.id
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def post_character_comment_to_latest_chat(content: str) -> int | None:
|
||||
"""Реплика персонажа в UI; не попадает в контекст LLM (в отличие от assistant)."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
session = _latest_chat_session(db)
|
||||
db.add(Message(session_id=session.id, role="character", content=content))
|
||||
db.commit()
|
||||
return session.id
|
||||
finally:
|
||||
db.close()
|
||||
"""Инжект системных оповещений в чат без role=assistant (не ломает LLM-историю)."""
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.base import SessionLocal
|
||||
from app.db.models import ChatSession, Message
|
||||
|
||||
DISPLAY_ONLY_ROLES = frozenset({"notice", "character"})
|
||||
|
||||
|
||||
def _latest_chat_session(db, user_id: int) -> ChatSession:
|
||||
session = db.scalar(
|
||||
select(ChatSession)
|
||||
.where(ChatSession.user_id == user_id)
|
||||
.order_by(ChatSession.updated_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if not session:
|
||||
session = ChatSession(user_id=user_id, title="Уведомления")
|
||||
db.add(session)
|
||||
db.commit()
|
||||
db.refresh(session)
|
||||
return session
|
||||
|
||||
|
||||
def post_notice_to_latest_chat(content: str, user_id: int) -> int | None:
|
||||
"""Сохраняет notice в последний активный чат пользователя. Возвращает session_id."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
session = _latest_chat_session(db, user_id)
|
||||
db.add(Message(session_id=session.id, role="notice", content=content))
|
||||
db.commit()
|
||||
return session.id
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def post_character_comment_to_latest_chat(content: str, user_id: int) -> int | None:
|
||||
"""Реплика персонажа в UI; не попадает в контекст LLM (в отличие от assistant)."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
session = _latest_chat_session(db, user_id)
|
||||
db.add(Message(session_id=session.id, role="character", content=content))
|
||||
db.commit()
|
||||
return session.id
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
+432
-397
@@ -1,397 +1,432 @@
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from app.db.models import PomodoroSession
|
||||
from app.pomodoro.cycle import PHASE_LONG_BREAK, PHASE_SHORT_BREAK, PHASE_WORK
|
||||
|
||||
PHASE_LABELS = {
|
||||
PHASE_WORK: "Работа",
|
||||
PHASE_SHORT_BREAK: "Короткий перерыв",
|
||||
PHASE_LONG_BREAK: "Длинный перерыв",
|
||||
}
|
||||
|
||||
|
||||
def _format_time(seconds: int) -> str:
|
||||
minutes, secs = divmod(max(0, seconds), 60)
|
||||
return f"{minutes:02d}:{secs:02d}"
|
||||
|
||||
|
||||
def format_phase_completed_notice(
|
||||
session: PomodoroSession,
|
||||
next_phase: str | None,
|
||||
) -> str:
|
||||
phase_label = PHASE_LABELS.get(session.phase, session.phase)
|
||||
task = session.task_note or "без описания"
|
||||
lines = [f"⏱ **{phase_label} завершена** · {session.duration_min} мин · _{task}_"]
|
||||
|
||||
if next_phase == PHASE_SHORT_BREAK:
|
||||
lines.append("Дальше: короткий перерыв ☕")
|
||||
elif next_phase == PHASE_LONG_BREAK:
|
||||
lines.append("Дальше: длинный перерыв 🌴 · цикл почти завершён")
|
||||
elif next_phase == PHASE_WORK:
|
||||
lines.append("Дальше: снова работа 💪")
|
||||
else:
|
||||
lines.append("Цикл сброшен. Можно отдохнуть и начать заново.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
POMODORO_TOOL_NAMES = frozenset({
|
||||
"get_pomodoro_status",
|
||||
"start_pomodoro",
|
||||
"start_short_break",
|
||||
"start_long_break",
|
||||
"stop_pomodoro",
|
||||
"skip_pomodoro_phase",
|
||||
"reset_pomodoro_cycle",
|
||||
"get_pomodoro_history",
|
||||
})
|
||||
|
||||
MEMORY_TOOL_NAMES = frozenset({
|
||||
"remember_fact",
|
||||
"recall_memories",
|
||||
"forget_memory",
|
||||
"update_profile",
|
||||
"update_session_summary",
|
||||
})
|
||||
|
||||
FITNESS_TOOL_NAMES = frozenset({
|
||||
"get_fitness_summary",
|
||||
"get_fitness_history",
|
||||
"set_fitness_profile",
|
||||
"calc_fitness_targets",
|
||||
"log_meal",
|
||||
"log_water",
|
||||
"log_weight",
|
||||
"log_workout",
|
||||
"lookup_food",
|
||||
"lookup_exercise",
|
||||
"set_fitness_reminder",
|
||||
})
|
||||
|
||||
# Не засорять чат служебными ответами
|
||||
REMINDER_TOOL_NAMES = frozenset({
|
||||
"list_reminders",
|
||||
"create_reminder",
|
||||
"update_reminder",
|
||||
"delete_reminder",
|
||||
"complete_reminder",
|
||||
})
|
||||
|
||||
SHOPPING_TOOL_NAMES = frozenset({
|
||||
"list_shopping_lists",
|
||||
"create_shopping_list",
|
||||
"add_shopping_items",
|
||||
"check_shopping_item",
|
||||
"remove_shopping_item",
|
||||
"delete_shopping_list",
|
||||
})
|
||||
|
||||
TOOLS_SKIP_CHAT_NOTICE = frozenset({
|
||||
"get_pomodoro_status",
|
||||
"recall_memories",
|
||||
"get_fitness_summary",
|
||||
"get_fitness_history",
|
||||
"lookup_food",
|
||||
"lookup_exercise",
|
||||
"calc_fitness_targets",
|
||||
"get_weather",
|
||||
"get_morning_briefing",
|
||||
"list_shopping_lists",
|
||||
"list_reminders",
|
||||
})
|
||||
|
||||
|
||||
def format_tool_notice(tool_name: str, raw_result: str) -> str | None:
|
||||
if tool_name in TOOLS_SKIP_CHAT_NOTICE:
|
||||
return None
|
||||
|
||||
try:
|
||||
data = json.loads(raw_result)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
if isinstance(data, dict) and "error" in data:
|
||||
if tool_name in POMODORO_TOOL_NAMES:
|
||||
prefix = "⏱"
|
||||
elif tool_name in MEMORY_TOOL_NAMES:
|
||||
prefix = "🧠"
|
||||
elif tool_name in FITNESS_TOOL_NAMES:
|
||||
prefix = "💪"
|
||||
elif tool_name in SHOPPING_TOOL_NAMES:
|
||||
prefix = "🛒"
|
||||
elif tool_name in REMINDER_TOOL_NAMES:
|
||||
prefix = "📅"
|
||||
else:
|
||||
prefix = "📋"
|
||||
return f"{prefix} {data['error']}"
|
||||
|
||||
if tool_name == "reset_pomodoro_cycle":
|
||||
cycle = data.get("cycle", data)
|
||||
return (
|
||||
"⏱ **Цикл помидоро сброшен** · "
|
||||
f"прогресс: {cycle.get('completed_work_sessions', 0)}/"
|
||||
f"{cycle.get('sessions_until_long_break', 4)}"
|
||||
)
|
||||
|
||||
if tool_name in (
|
||||
"get_pomodoro_status",
|
||||
"start_pomodoro",
|
||||
"start_work",
|
||||
"start_short_break",
|
||||
"start_long_break",
|
||||
"stop_pomodoro",
|
||||
"skip_pomodoro_phase",
|
||||
):
|
||||
return _format_status_notice(data)
|
||||
|
||||
if tool_name == "get_pomodoro_history":
|
||||
return _format_history_notice(data)
|
||||
|
||||
if tool_name == "create_work_item":
|
||||
return _format_work_item_notice(data)
|
||||
|
||||
if tool_name == "list_work_items":
|
||||
return _format_work_items_list_notice(data)
|
||||
|
||||
if tool_name == "list_taiga_tasks":
|
||||
return _format_taiga_tasks_notice(data)
|
||||
|
||||
if tool_name == "sync_taiga_projects":
|
||||
return f"📋 Синхронизировано проектов Taiga: **{len(data)}**"
|
||||
|
||||
if tool_name == "list_taiga_projects":
|
||||
if not isinstance(data, list) or not data:
|
||||
return "📋 Проекты Taiga не найдены. Вызовите sync_taiga_projects."
|
||||
lines = ["📋 **Проекты:**"]
|
||||
for p in data:
|
||||
gitea = f"{p.get('gitea_owner')}/{p.get('gitea_repo')}" if p.get("gitea_configured") else "—"
|
||||
lines.append(f"- `{p.get('slug')}`: {p.get('name')} · Gitea: {gitea}")
|
||||
return "\n".join(lines)
|
||||
|
||||
if tool_name == "remember_fact" and data.get("ok"):
|
||||
action = "обновлено" if data.get("action") == "updated" else "сохранено"
|
||||
return f"🧠 **Память {action}** · #{data.get('memory_id')}: {data.get('content')}"
|
||||
|
||||
if tool_name == "forget_memory" and data.get("ok"):
|
||||
return f"🧠 **Забыто** · #{data.get('memory_id')}: {data.get('forgotten')}"
|
||||
|
||||
if tool_name == "update_profile" and data.get("ok"):
|
||||
profile = data.get("profile") or {}
|
||||
parts = [f"{k}={v}" for k, v in profile.items() if v]
|
||||
return f"🧠 **Профиль обновлён** · {', '.join(parts) or 'пусто'}"
|
||||
|
||||
if tool_name == "update_session_summary" and data.get("ok"):
|
||||
return "🧠 **Сводка чата сохранена**"
|
||||
|
||||
if tool_name == "log_meal" and data.get("ok"):
|
||||
meal = data.get("meal", {})
|
||||
est = "≈" if meal.get("estimated") else ""
|
||||
return (
|
||||
f"💪 **Приём пищи** · {meal.get('description')} · "
|
||||
f"{est}{meal.get('calories', 0):.0f} ккал "
|
||||
f"(Б{meal.get('protein_g', 0):.0f}/Ж{meal.get('fat_g', 0):.0f}/У{meal.get('carbs_g', 0):.0f})"
|
||||
)
|
||||
|
||||
if tool_name == "log_water" and data.get("ok"):
|
||||
w = data.get("water", {})
|
||||
return f"💪 **Вода** +{w.get('amount_ml')} мл"
|
||||
|
||||
if tool_name == "log_weight" and data.get("ok"):
|
||||
m = data.get("metric", {})
|
||||
return f"💪 **Вес** {m.get('weight_kg')} кг"
|
||||
|
||||
if tool_name == "log_workout" and data.get("ok"):
|
||||
wo = data.get("workout", {})
|
||||
return f"💪 **Тренировка** · {wo.get('title')}"
|
||||
|
||||
if tool_name == "set_fitness_profile" and data.get("ok"):
|
||||
p = data.get("profile", {})
|
||||
return (
|
||||
f"💪 **Профиль** · {p.get('calorie_target')} ккал, "
|
||||
f"вода {p.get('water_l')} л"
|
||||
)
|
||||
|
||||
if tool_name == "set_fitness_reminder" and data.get("ok"):
|
||||
r = data.get("reminder", {})
|
||||
state = "вкл" if r.get("enabled") else "выкл"
|
||||
return f"💪 **Напоминание {r.get('kind')}** · {state}"
|
||||
|
||||
if tool_name == "generate_image" and data.get("ok"):
|
||||
url = data.get("url", "")
|
||||
return f"🎨 **Картинка готова**\n\n"
|
||||
|
||||
if tool_name == "create_shopping_list" and data.get("ok"):
|
||||
lst = data.get("list") or {}
|
||||
action = "создан" if data.get("created") else "уже был"
|
||||
return f"🛒 **Список {action}** · «{lst.get('name')}» (#{lst.get('id')})"
|
||||
|
||||
if tool_name == "add_shopping_items" and data.get("ok"):
|
||||
added = data.get("added") or []
|
||||
names = ", ".join(i.get("text", "") for i in added[:5])
|
||||
extra = f" +{len(added) - 5}" if len(added) > 5 else ""
|
||||
return f"🛒 **Добавлено в «{data.get('list_name')}»** · {names}{extra}"
|
||||
|
||||
if tool_name == "check_shopping_item" and data.get("ok"):
|
||||
item = data.get("item") or {}
|
||||
state = "куплено" if item.get("checked") else "снята отметка"
|
||||
return f"🛒 **{state}** · #{item.get('id')} {item.get('text')}"
|
||||
|
||||
if tool_name == "remove_shopping_item" and data.get("ok"):
|
||||
removed = data.get("removed") or {}
|
||||
return f"🛒 **Удалено** · {removed.get('text')}"
|
||||
|
||||
if tool_name == "delete_shopping_list" and data.get("ok"):
|
||||
return f"🛒 **Список удалён** · «{data.get('name')}»"
|
||||
|
||||
if tool_name == "create_reminder" and data.get("ok"):
|
||||
r = data.get("reminder") or {}
|
||||
rec = r.get("recurrence", "none")
|
||||
rec_label = f" · повтор {rec}" if rec and rec != "none" else ""
|
||||
return f"📅 **Напоминание создано** · {r.get('title')} · {r.get('due_at_local')}{rec_label}"
|
||||
|
||||
if tool_name == "update_reminder" and data.get("ok"):
|
||||
r = data.get("reminder") or {}
|
||||
return f"📅 **Напоминание обновлено** · #{r.get('id')} {r.get('title')}"
|
||||
|
||||
if tool_name == "delete_reminder" and data.get("ok"):
|
||||
return f"📅 **Напоминание удалено** · «{data.get('title')}»"
|
||||
|
||||
if tool_name == "complete_reminder" and data.get("ok"):
|
||||
r = data.get("reminder") or {}
|
||||
return f"📅 **Готово** · {r.get('title')}"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _format_work_item_notice(data: dict[str, Any]) -> str | None:
|
||||
if data.get("error"):
|
||||
return f"📋 {data['error']}"
|
||||
if not data.get("ok"):
|
||||
return None
|
||||
taiga = data.get("taiga", {})
|
||||
gitea = data.get("gitea", {})
|
||||
lines = [
|
||||
"📋 **Создано:**",
|
||||
f"- Taiga: #{taiga.get('ref')} — {taiga.get('subject')}",
|
||||
f"- URL: {taiga.get('url')}",
|
||||
]
|
||||
if gitea.get("url"):
|
||||
lines.append(f"- Gitea: {gitea.get('url')}")
|
||||
if data.get("branch"):
|
||||
lines.append(f"- Ветка: `{data['branch']}`")
|
||||
subtasks = data.get("subtasks") or []
|
||||
if subtasks:
|
||||
lines.append("**Подзадачи:**")
|
||||
for t in subtasks:
|
||||
lines.append(f"- #{t.get('ref')} {t.get('subject')}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_work_items_list_notice(data: Any) -> str | None:
|
||||
if not isinstance(data, list) or not data:
|
||||
return "📋 Локальных work items (созданных ассистентом) нет."
|
||||
lines = ["📋 **Work items ассистента:**"]
|
||||
for item in data[:15]:
|
||||
lines.append(
|
||||
f"- [{item.get('status')}] #{item.get('taiga_ref')} {item.get('title')} "
|
||||
f"({item.get('taiga_slug')})"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_taiga_tasks_notice(data: Any) -> str | None:
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
if data.get("error"):
|
||||
return f"📋 {data['error']}"
|
||||
|
||||
blocks = data.get("projects") or []
|
||||
total_stories = data.get("total_stories", 0)
|
||||
total_tasks = data.get("total_tasks", 0)
|
||||
|
||||
if not blocks or (total_stories == 0 and total_tasks == 0):
|
||||
slug = blocks[0].get("slug") if len(blocks) == 1 else None
|
||||
if slug:
|
||||
return f"📋 В `{slug}` нет открытых user stories и tasks в Taiga."
|
||||
return "📋 Открытых задач в Taiga не найдено."
|
||||
|
||||
lines = [f"📋 **Открытые задачи Taiga** (stories: {total_stories}, tasks: {total_tasks}):"]
|
||||
for block in blocks:
|
||||
stories = block.get("stories") or []
|
||||
tasks = block.get("tasks") or []
|
||||
if not stories and not tasks:
|
||||
continue
|
||||
lines.append(f"**{block.get('name')}** (`{block.get('slug')}`):")
|
||||
for s in stories:
|
||||
lines.append(f"- story #{s.get('ref')} {s.get('subject')}")
|
||||
for t in tasks:
|
||||
lines.append(f"- task #{t.get('ref')} {t.get('subject')}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_status_notice(data: dict[str, Any]) -> str:
|
||||
status = data.get("status", "idle")
|
||||
phase = data.get("phase", PHASE_WORK)
|
||||
phase_label = PHASE_LABELS.get(phase, phase)
|
||||
task = data.get("task_note") or "без описания"
|
||||
remaining = data.get("remaining_seconds", 0)
|
||||
duration = data.get("duration_min", 25)
|
||||
cycle = data.get("cycle", {})
|
||||
cycle_info = ""
|
||||
if cycle:
|
||||
cycle_info = (
|
||||
f" · цикл {cycle.get('completed_work_sessions', 0)}/"
|
||||
f"{cycle.get('sessions_until_long_break', 4)}"
|
||||
)
|
||||
|
||||
if status == "idle":
|
||||
return f"⏱ **Помидоро:** таймер не запущен{cycle_info}."
|
||||
|
||||
if status == "running":
|
||||
return (
|
||||
f"⏱ **{phase_label}** · осталось **{_format_time(remaining)}** "
|
||||
f"из {duration} мин · _{task}_{cycle_info}"
|
||||
)
|
||||
|
||||
if status == "paused":
|
||||
elapsed = data.get("elapsed_seconds", 0)
|
||||
return (
|
||||
f"⏱ **{phase_label} на паузе** · прошло {_format_time(elapsed)} "
|
||||
f"из {duration} мин · _{task}_{cycle_info}"
|
||||
)
|
||||
|
||||
if status == "completed":
|
||||
return f"⏱ **{phase_label} завершена** · {duration} мин · _{task}_"
|
||||
|
||||
if status == "cancelled":
|
||||
return f"⏱ **{phase_label} отменена** · _{task}_"
|
||||
|
||||
return f"⏱ Помидоро: {status}"
|
||||
|
||||
|
||||
def _format_history_notice(data: Any) -> str:
|
||||
if not isinstance(data, list) or not data:
|
||||
return "⏱ **История помидоро** пуста."
|
||||
|
||||
lines = ["⏱ **История помидоро:**"]
|
||||
for item in data[:10]:
|
||||
task = item.get("task_note") or "без описания"
|
||||
phase = PHASE_LABELS.get(item.get("phase", ""), item.get("phase", "?"))
|
||||
duration = item.get("duration_min", "?")
|
||||
lines.append(f"- {phase}: {task} ({duration} мин)")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_pomodoro_context(status: dict[str, Any]) -> str:
|
||||
notice = _format_status_notice(status)
|
||||
cycle = status.get("cycle", {})
|
||||
extra = ""
|
||||
if cycle:
|
||||
extra = (
|
||||
f"\nНастройки цикла: работа {cycle.get('work_duration_min')} мин, "
|
||||
f"перерыв {cycle.get('short_break_min')} мин, "
|
||||
f"длинный {cycle.get('long_break_min')} мин."
|
||||
)
|
||||
return f"[Актуальный статус помидоро]\n{notice}{extra}"
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from app.db.models import PomodoroSession
|
||||
from app.pomodoro.cycle import PHASE_LONG_BREAK, PHASE_SHORT_BREAK, PHASE_WORK
|
||||
|
||||
PHASE_LABELS = {
|
||||
PHASE_WORK: "Работа",
|
||||
PHASE_SHORT_BREAK: "Короткий перерыв",
|
||||
PHASE_LONG_BREAK: "Длинный перерыв",
|
||||
}
|
||||
|
||||
|
||||
def _format_time(seconds: int) -> str:
|
||||
minutes, secs = divmod(max(0, seconds), 60)
|
||||
return f"{minutes:02d}:{secs:02d}"
|
||||
|
||||
|
||||
def format_phase_completed_notice(
|
||||
session: PomodoroSession,
|
||||
next_phase: str | None,
|
||||
) -> str:
|
||||
phase_label = PHASE_LABELS.get(session.phase, session.phase)
|
||||
task = session.task_note or "без описания"
|
||||
lines = [f"⏱ **{phase_label} завершена** · {session.duration_min} мин · _{task}_"]
|
||||
|
||||
if next_phase == PHASE_SHORT_BREAK:
|
||||
lines.append("Дальше: короткий перерыв ☕")
|
||||
elif next_phase == PHASE_LONG_BREAK:
|
||||
lines.append("Дальше: длинный перерыв 🌴 · цикл почти завершён")
|
||||
elif next_phase == PHASE_WORK:
|
||||
lines.append("Дальше: снова работа 💪")
|
||||
else:
|
||||
lines.append("Цикл сброшен. Можно отдохнуть и начать заново.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
POMODORO_TOOL_NAMES = frozenset({
|
||||
"get_pomodoro_status",
|
||||
"start_pomodoro",
|
||||
"start_short_break",
|
||||
"start_long_break",
|
||||
"stop_pomodoro",
|
||||
"skip_pomodoro_phase",
|
||||
"reset_pomodoro_cycle",
|
||||
"get_pomodoro_history",
|
||||
})
|
||||
|
||||
MEMORY_TOOL_NAMES = frozenset({
|
||||
"remember_fact",
|
||||
"recall_memories",
|
||||
"forget_memory",
|
||||
"update_profile",
|
||||
"update_session_summary",
|
||||
})
|
||||
|
||||
FITNESS_TOOL_NAMES = frozenset({
|
||||
"get_fitness_summary",
|
||||
"get_fitness_history",
|
||||
"set_fitness_profile",
|
||||
"calc_fitness_targets",
|
||||
"calc_body_composition",
|
||||
"log_meal",
|
||||
"log_water",
|
||||
"log_weight",
|
||||
"log_workout",
|
||||
"lookup_food",
|
||||
"lookup_exercise",
|
||||
"set_fitness_reminder",
|
||||
})
|
||||
|
||||
# Не засорять чат служебными ответами
|
||||
REMINDER_TOOL_NAMES = frozenset({
|
||||
"list_reminders",
|
||||
"create_reminder",
|
||||
"update_reminder",
|
||||
"delete_reminder",
|
||||
"complete_reminder",
|
||||
})
|
||||
|
||||
SHOPPING_TOOL_NAMES = frozenset({
|
||||
"list_shopping_lists",
|
||||
"create_shopping_list",
|
||||
"add_shopping_items",
|
||||
"check_shopping_item",
|
||||
"remove_shopping_item",
|
||||
"delete_shopping_list",
|
||||
})
|
||||
|
||||
TOOLS_SKIP_CHAT_NOTICE = frozenset({
|
||||
"get_pomodoro_status",
|
||||
"recall_memories",
|
||||
"get_fitness_summary",
|
||||
"get_fitness_history",
|
||||
"lookup_food",
|
||||
"lookup_exercise",
|
||||
"calc_fitness_targets",
|
||||
"calc_body_composition",
|
||||
"get_weather",
|
||||
"get_morning_briefing",
|
||||
"list_shopping_lists",
|
||||
"list_reminders",
|
||||
})
|
||||
|
||||
|
||||
|
||||
def _format_body_composition_notice(computed: dict[str, Any], *, headline: str) -> str:
|
||||
parts: list[str] = []
|
||||
bf = computed.get("body_fat_pct")
|
||||
if bf is not None:
|
||||
method = computed.get("body_fat_method")
|
||||
if method == "navy":
|
||||
parts.append(f"жир ≈{bf}% (Navy)")
|
||||
elif method == "manual":
|
||||
parts.append(f"жир {bf}%")
|
||||
else:
|
||||
parts.append(f"жир ≈{bf}%")
|
||||
if computed.get("whr") is not None:
|
||||
parts.append(f"WHR {computed.get('whr')}")
|
||||
if computed.get("ffmi") is not None:
|
||||
parts.append(f"FFMI {computed.get('ffmi')}")
|
||||
if parts:
|
||||
return f"{headline} — {', '.join(parts)}"
|
||||
return headline
|
||||
|
||||
def format_tool_notice(tool_name: str, raw_result: str) -> str | None:
|
||||
if tool_name in TOOLS_SKIP_CHAT_NOTICE:
|
||||
return None
|
||||
|
||||
try:
|
||||
data = json.loads(raw_result)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
if isinstance(data, dict) and "error" in data:
|
||||
if tool_name in POMODORO_TOOL_NAMES:
|
||||
prefix = "⏱"
|
||||
elif tool_name in MEMORY_TOOL_NAMES:
|
||||
prefix = "🧠"
|
||||
elif tool_name in FITNESS_TOOL_NAMES:
|
||||
prefix = "💪"
|
||||
elif tool_name in SHOPPING_TOOL_NAMES:
|
||||
prefix = "🛒"
|
||||
elif tool_name in REMINDER_TOOL_NAMES:
|
||||
prefix = "📅"
|
||||
else:
|
||||
prefix = "📋"
|
||||
return f"{prefix} {data['error']}"
|
||||
|
||||
if tool_name == "reset_pomodoro_cycle":
|
||||
cycle = data.get("cycle", data)
|
||||
return (
|
||||
"⏱ **Цикл помидоро сброшен** · "
|
||||
f"прогресс: {cycle.get('completed_work_sessions', 0)}/"
|
||||
f"{cycle.get('sessions_until_long_break', 4)}"
|
||||
)
|
||||
|
||||
if tool_name in (
|
||||
"get_pomodoro_status",
|
||||
"start_pomodoro",
|
||||
"start_work",
|
||||
"start_short_break",
|
||||
"start_long_break",
|
||||
"stop_pomodoro",
|
||||
"skip_pomodoro_phase",
|
||||
):
|
||||
return _format_status_notice(data)
|
||||
|
||||
if tool_name == "get_pomodoro_history":
|
||||
return _format_history_notice(data)
|
||||
|
||||
if tool_name == "create_work_item":
|
||||
return _format_work_item_notice(data)
|
||||
|
||||
if tool_name == "list_work_items":
|
||||
return _format_work_items_list_notice(data)
|
||||
|
||||
if tool_name == "list_taiga_tasks":
|
||||
return _format_taiga_tasks_notice(data)
|
||||
|
||||
if tool_name == "sync_taiga_projects":
|
||||
return f"📋 Синхронизировано проектов Taiga: **{len(data)}**"
|
||||
|
||||
if tool_name == "list_taiga_projects":
|
||||
if not isinstance(data, list) or not data:
|
||||
return "📋 Проекты Taiga не найдены. Вызовите sync_taiga_projects."
|
||||
lines = ["📋 **Проекты:**"]
|
||||
for p in data:
|
||||
gitea = f"{p.get('gitea_owner')}/{p.get('gitea_repo')}" if p.get("gitea_configured") else "—"
|
||||
lines.append(f"- `{p.get('slug')}`: {p.get('name')} · Gitea: {gitea}")
|
||||
return "\n".join(lines)
|
||||
|
||||
if tool_name == "remember_fact" and data.get("ok"):
|
||||
action = "обновлено" if data.get("action") == "updated" else "сохранено"
|
||||
return f"🧠 **Память {action}** · #{data.get('memory_id')}: {data.get('content')}"
|
||||
|
||||
if tool_name == "forget_memory" and data.get("ok"):
|
||||
return f"🧠 **Забыто** · #{data.get('memory_id')}: {data.get('forgotten')}"
|
||||
|
||||
if tool_name == "update_profile" and data.get("ok"):
|
||||
profile = data.get("profile") or {}
|
||||
parts = [f"{k}={v}" for k, v in profile.items() if v]
|
||||
return f"🧠 **Профиль обновлён** · {', '.join(parts) or 'пусто'}"
|
||||
|
||||
if tool_name == "update_session_summary" and data.get("ok"):
|
||||
return "🧠 **Сводка чата сохранена**"
|
||||
|
||||
if tool_name == "log_meal" and data.get("ok"):
|
||||
meal = data.get("meal", {})
|
||||
est = "≈" if meal.get("estimated") else ""
|
||||
return (
|
||||
f"💪 **Приём пищи** · {meal.get('description')} · "
|
||||
f"{est}{meal.get('calories', 0):.0f} ккал "
|
||||
f"(Б{meal.get('protein_g', 0):.0f}/Ж{meal.get('fat_g', 0):.0f}/У{meal.get('carbs_g', 0):.0f})"
|
||||
)
|
||||
|
||||
if tool_name == "log_water" and data.get("ok"):
|
||||
w = data.get("water", {})
|
||||
return f"💪 **Вода** +{w.get('amount_ml')} мл"
|
||||
|
||||
if tool_name == "log_weight" and data.get("ok"):
|
||||
m = data.get("metric", {})
|
||||
computed = data.get("computed") or {}
|
||||
headline = f"💪 **Вес** {m.get('weight_kg')} кг"
|
||||
return _format_body_composition_notice(computed, headline=headline)
|
||||
|
||||
if tool_name == "calc_body_composition" and isinstance(data, dict) and "error" not in data:
|
||||
w = data.get("weight_kg")
|
||||
headline = "💪 **Состав тела** (расчёт)"
|
||||
if w is not None:
|
||||
headline += f" · {w} кг"
|
||||
msg = _format_body_composition_notice(data, headline=headline)
|
||||
warnings = data.get("warnings") or []
|
||||
if warnings:
|
||||
msg += f" · {'; '.join(warnings[:2])}"
|
||||
return msg
|
||||
|
||||
if tool_name == "log_workout" and data.get("ok"):
|
||||
wo = data.get("workout", {})
|
||||
return f"💪 **Тренировка** · {wo.get('title')}"
|
||||
|
||||
if tool_name == "set_fitness_profile" and data.get("ok"):
|
||||
p = data.get("profile", {})
|
||||
return (
|
||||
f"💪 **Профиль** · {p.get('calorie_target')} ккал, "
|
||||
f"вода {p.get('water_l')} л"
|
||||
)
|
||||
|
||||
if tool_name == "set_fitness_reminder" and data.get("ok"):
|
||||
r = data.get("reminder", {})
|
||||
state = "вкл" if r.get("enabled") else "выкл"
|
||||
return f"💪 **Напоминание {r.get('kind')}** · {state}"
|
||||
|
||||
if tool_name == "generate_image" and data.get("ok"):
|
||||
url = data.get("url", "")
|
||||
return f"🎨 **Картинка готова**\n\n"
|
||||
|
||||
if tool_name == "create_shopping_list" and data.get("ok"):
|
||||
lst = data.get("list") or {}
|
||||
action = "создан" if data.get("created") else "уже был"
|
||||
return f"🛒 **Список {action}** · «{lst.get('name')}» (#{lst.get('id')})"
|
||||
|
||||
if tool_name == "add_shopping_items" and data.get("ok"):
|
||||
added = data.get("added") or []
|
||||
names = ", ".join(i.get("text", "") for i in added[:5])
|
||||
extra = f" +{len(added) - 5}" if len(added) > 5 else ""
|
||||
return f"🛒 **Добавлено в «{data.get('list_name')}»** · {names}{extra}"
|
||||
|
||||
if tool_name == "check_shopping_item" and data.get("ok"):
|
||||
item = data.get("item") or {}
|
||||
state = "куплено" if item.get("checked") else "снята отметка"
|
||||
return f"🛒 **{state}** · #{item.get('id')} {item.get('text')}"
|
||||
|
||||
if tool_name == "remove_shopping_item" and data.get("ok"):
|
||||
removed = data.get("removed") or {}
|
||||
return f"🛒 **Удалено** · {removed.get('text')}"
|
||||
|
||||
if tool_name == "delete_shopping_list" and data.get("ok"):
|
||||
return f"🛒 **Список удалён** · «{data.get('name')}»"
|
||||
|
||||
if tool_name == "create_reminder" and data.get("ok"):
|
||||
r = data.get("reminder") or {}
|
||||
rec = r.get("recurrence", "none")
|
||||
rec_label = f" · повтор {rec}" if rec and rec != "none" else ""
|
||||
return f"📅 **Напоминание создано** · {r.get('title')} · {r.get('due_at_local')}{rec_label}"
|
||||
|
||||
if tool_name == "update_reminder" and data.get("ok"):
|
||||
r = data.get("reminder") or {}
|
||||
return f"📅 **Напоминание обновлено** · #{r.get('id')} {r.get('title')}"
|
||||
|
||||
if tool_name == "delete_reminder" and data.get("ok"):
|
||||
return f"📅 **Напоминание удалено** · «{data.get('title')}»"
|
||||
|
||||
if tool_name == "complete_reminder" and data.get("ok"):
|
||||
r = data.get("reminder") or {}
|
||||
return f"📅 **Готово** · {r.get('title')}"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _format_work_item_notice(data: dict[str, Any]) -> str | None:
|
||||
if data.get("error"):
|
||||
return f"📋 {data['error']}"
|
||||
if not data.get("ok"):
|
||||
return None
|
||||
taiga = data.get("taiga", {})
|
||||
gitea = data.get("gitea", {})
|
||||
lines = [
|
||||
"📋 **Создано:**",
|
||||
f"- Taiga: #{taiga.get('ref')} — {taiga.get('subject')}",
|
||||
f"- URL: {taiga.get('url')}",
|
||||
]
|
||||
if gitea.get("url"):
|
||||
lines.append(f"- Gitea: {gitea.get('url')}")
|
||||
if data.get("branch"):
|
||||
lines.append(f"- Ветка: `{data['branch']}`")
|
||||
subtasks = data.get("subtasks") or []
|
||||
if subtasks:
|
||||
lines.append("**Подзадачи:**")
|
||||
for t in subtasks:
|
||||
lines.append(f"- #{t.get('ref')} {t.get('subject')}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_work_items_list_notice(data: Any) -> str | None:
|
||||
if not isinstance(data, list) or not data:
|
||||
return "📋 Локальных work items (созданных ассистентом) нет."
|
||||
lines = ["📋 **Work items ассистента:**"]
|
||||
for item in data[:15]:
|
||||
lines.append(
|
||||
f"- [{item.get('status')}] #{item.get('taiga_ref')} {item.get('title')} "
|
||||
f"({item.get('taiga_slug')})"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_taiga_tasks_notice(data: Any) -> str | None:
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
if data.get("error"):
|
||||
return f"📋 {data['error']}"
|
||||
|
||||
blocks = data.get("projects") or []
|
||||
total_stories = data.get("total_stories", 0)
|
||||
total_tasks = data.get("total_tasks", 0)
|
||||
|
||||
if not blocks or (total_stories == 0 and total_tasks == 0):
|
||||
slug = blocks[0].get("slug") if len(blocks) == 1 else None
|
||||
if slug:
|
||||
return f"📋 В `{slug}` нет открытых user stories и tasks в Taiga."
|
||||
return "📋 Открытых задач в Taiga не найдено."
|
||||
|
||||
lines = [f"📋 **Открытые задачи Taiga** (stories: {total_stories}, tasks: {total_tasks}):"]
|
||||
for block in blocks:
|
||||
stories = block.get("stories") or []
|
||||
tasks = block.get("tasks") or []
|
||||
if not stories and not tasks:
|
||||
continue
|
||||
lines.append(f"**{block.get('name')}** (`{block.get('slug')}`):")
|
||||
for s in stories:
|
||||
lines.append(f"- story #{s.get('ref')} {s.get('subject')}")
|
||||
for t in tasks:
|
||||
lines.append(f"- task #{t.get('ref')} {t.get('subject')}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_status_notice(data: dict[str, Any]) -> str:
|
||||
status = data.get("status", "idle")
|
||||
phase = data.get("phase", PHASE_WORK)
|
||||
phase_label = PHASE_LABELS.get(phase, phase)
|
||||
task = data.get("task_note") or "без описания"
|
||||
remaining = data.get("remaining_seconds", 0)
|
||||
duration = data.get("duration_min", 25)
|
||||
cycle = data.get("cycle", {})
|
||||
cycle_info = ""
|
||||
if cycle:
|
||||
cycle_info = (
|
||||
f" · цикл {cycle.get('completed_work_sessions', 0)}/"
|
||||
f"{cycle.get('sessions_until_long_break', 4)}"
|
||||
)
|
||||
|
||||
if status == "idle":
|
||||
return f"⏱ **Помидоро:** таймер не запущен{cycle_info}."
|
||||
|
||||
if status == "running":
|
||||
return (
|
||||
f"⏱ **{phase_label}** · осталось **{_format_time(remaining)}** "
|
||||
f"из {duration} мин · _{task}_{cycle_info}"
|
||||
)
|
||||
|
||||
if status == "paused":
|
||||
elapsed = data.get("elapsed_seconds", 0)
|
||||
return (
|
||||
f"⏱ **{phase_label} на паузе** · прошло {_format_time(elapsed)} "
|
||||
f"из {duration} мин · _{task}_{cycle_info}"
|
||||
)
|
||||
|
||||
if status == "completed":
|
||||
return f"⏱ **{phase_label} завершена** · {duration} мин · _{task}_"
|
||||
|
||||
if status == "cancelled":
|
||||
return f"⏱ **{phase_label} отменена** · _{task}_"
|
||||
|
||||
return f"⏱ Помидоро: {status}"
|
||||
|
||||
|
||||
def _format_history_notice(data: Any) -> str:
|
||||
if not isinstance(data, list) or not data:
|
||||
return "⏱ **История помидоро** пуста."
|
||||
|
||||
lines = ["⏱ **История помидоро:**"]
|
||||
for item in data[:10]:
|
||||
task = item.get("task_note") or "без описания"
|
||||
phase = PHASE_LABELS.get(item.get("phase", ""), item.get("phase", "?"))
|
||||
duration = item.get("duration_min", "?")
|
||||
lines.append(f"- {phase}: {task} ({duration} мин)")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_pomodoro_context(status: dict[str, Any]) -> str:
|
||||
notice = _format_status_notice(status)
|
||||
cycle = status.get("cycle", {})
|
||||
extra = ""
|
||||
if cycle:
|
||||
extra = (
|
||||
f"\nНастройки цикла: работа {cycle.get('work_duration_min')} мин, "
|
||||
f"перерыв {cycle.get('short_break_min')} мин, "
|
||||
f"длинный {cycle.get('long_break_min')} мин."
|
||||
)
|
||||
return f"[Актуальный статус помидоро]\n{notice}{extra}"
|
||||
|
||||
+564
-468
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,468 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db.base import SessionLocal
|
||||
from app.character.service import CharacterService
|
||||
from app.chat.history import sanitize_openai_messages, strip_historical_reasoning
|
||||
from app.chat.notice_inbox import DISPLAY_ONLY_ROLES
|
||||
from app.chat.notices import (
|
||||
POMODORO_TOOL_NAMES,
|
||||
format_pomodoro_context,
|
||||
format_tool_notice,
|
||||
)
|
||||
from app.fitness.context import format_fitness_context, get_fitness_snapshot
|
||||
from app.homelab.context import format_datetime_context
|
||||
from app.homelab.openmeteo import format_weather_snapshot
|
||||
from app.memory.context import (
|
||||
format_identity_hint,
|
||||
format_memory_context,
|
||||
get_memory_snapshot,
|
||||
)
|
||||
from app.memory.extract import extract_after_turn
|
||||
from app.projects.context import format_projects_context, get_projects_snapshot
|
||||
from app.reminders.context import format_reminders_context, get_reminders_snapshot
|
||||
from app.shopping.context import format_shopping_context, get_shopping_snapshot
|
||||
from app.db.models import ChatSession, Message
|
||||
from app.llm.client import LLMClient
|
||||
from app.pomodoro.service import PomodoroService
|
||||
from app.tools.registry import TOOL_DEFINITIONS, execute_tool
|
||||
|
||||
MAX_TOOL_ROUNDS = 5
|
||||
MAX_HISTORY_MESSAGES = 40
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_messages_for_session(session_id: int) -> list[dict[str, Any]]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
service = ChatService(db)
|
||||
session = service.get_session(session_id)
|
||||
if not session:
|
||||
return []
|
||||
return service._build_messages(session)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def _extract_memory_background(
|
||||
session_id: int,
|
||||
user_text: str,
|
||||
assistant_text: str,
|
||||
) -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
await extract_after_turn(db, session_id, user_text, assistant_text)
|
||||
except Exception as exc:
|
||||
logger.warning("Background memory extraction failed: %s", exc)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
class ChatService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.llm = LLMClient()
|
||||
self.character = CharacterService()
|
||||
|
||||
def list_sessions(self) -> list[ChatSession]:
|
||||
stmt = select(ChatSession).order_by(ChatSession.updated_at.desc())
|
||||
return list(self.db.scalars(stmt).all())
|
||||
|
||||
def get_session(self, session_id: int) -> ChatSession | None:
|
||||
return self.db.get(ChatSession, session_id)
|
||||
|
||||
def create_session(self, title: str = "Новый чат") -> ChatSession:
|
||||
session = ChatSession(title=title)
|
||||
self.db.add(session)
|
||||
self.db.commit()
|
||||
self.db.refresh(session)
|
||||
return session
|
||||
|
||||
def delete_session(self, session_id: int) -> bool:
|
||||
session = self.get_session(session_id)
|
||||
if not session:
|
||||
return False
|
||||
self.db.delete(session)
|
||||
self.db.commit()
|
||||
return True
|
||||
|
||||
def _build_system_prompt(self, session_id: int | None = None) -> str:
|
||||
status = PomodoroService(self.db).get_status()
|
||||
memory_snapshot = get_memory_snapshot(self.db, session_id)
|
||||
fitness_snapshot = get_fitness_snapshot(self.db)
|
||||
shopping_snapshot = get_shopping_snapshot(self.db)
|
||||
reminders_snapshot = get_reminders_snapshot(self.db)
|
||||
projects_snapshot = get_projects_snapshot(self.db)
|
||||
return (
|
||||
f"{self.character.get_system_prompt()}\n\n"
|
||||
f"{format_datetime_context(self.db)}\n\n"
|
||||
f"{format_memory_context(memory_snapshot)}\n\n"
|
||||
f"{format_fitness_context(fitness_snapshot)}\n\n"
|
||||
f"{format_shopping_context(shopping_snapshot)}\n\n"
|
||||
f"{format_reminders_context(reminders_snapshot)}\n\n"
|
||||
f"{format_weather_snapshot()}\n\n"
|
||||
f"{format_pomodoro_context(status)}\n\n"
|
||||
f"{format_projects_context(projects_snapshot)}"
|
||||
)
|
||||
|
||||
def _build_messages(self, session: ChatSession) -> list[dict[str, Any]]:
|
||||
system_prompt = self._build_system_prompt(session.id)
|
||||
all_chat = [m for m in session.messages if m.role not in DISPLAY_ONLY_ROLES]
|
||||
last_user = next((m.content for m in reversed(all_chat) if m.role == "user"), "")
|
||||
if last_user:
|
||||
memory_snapshot = get_memory_snapshot(self.db, session.id)
|
||||
identity_hint = format_identity_hint(memory_snapshot, last_user)
|
||||
if identity_hint:
|
||||
system_prompt += f"\n\n{identity_hint}"
|
||||
if len(all_chat) > MAX_HISTORY_MESSAGES:
|
||||
system_prompt += (
|
||||
f"\n\n[История чата: в контексте последние {MAX_HISTORY_MESSAGES} "
|
||||
f"из {len(all_chat)} сообщений. Раннее — в сводке сессии, если сохранена.]"
|
||||
)
|
||||
messages: list[dict[str, Any]] = [
|
||||
{"role": "system", "content": system_prompt}
|
||||
]
|
||||
chat_messages = all_chat[-MAX_HISTORY_MESSAGES:] if len(all_chat) > MAX_HISTORY_MESSAGES else all_chat
|
||||
|
||||
for msg in chat_messages:
|
||||
content = msg.content or None
|
||||
entry: dict[str, Any] = {"role": msg.role, "content": content}
|
||||
if msg.tool_calls_json:
|
||||
entry["tool_calls"] = json.loads(msg.tool_calls_json)
|
||||
if not content:
|
||||
entry["content"] = None
|
||||
reasoning_data = LLMClient.deserialize_reasoning(msg.reasoning_json)
|
||||
if reasoning_data:
|
||||
LLMClient.attach_reasoning_to_message(
|
||||
entry,
|
||||
reasoning=reasoning_data.get("reasoning", ""),
|
||||
reasoning_details=reasoning_data.get("reasoning_details"),
|
||||
)
|
||||
if msg.role == "tool" and msg.tool_call_id:
|
||||
entry["tool_call_id"] = msg.tool_call_id
|
||||
messages.append(entry)
|
||||
messages = sanitize_openai_messages(messages)
|
||||
messages = strip_historical_reasoning(messages)
|
||||
return messages
|
||||
|
||||
def _save_message(
|
||||
self,
|
||||
session_id: int,
|
||||
role: str,
|
||||
content: str = "",
|
||||
tool_calls: list[dict[str, Any]] | None = None,
|
||||
tool_call_id: str | None = None,
|
||||
reasoning_json: str | None = None,
|
||||
) -> Message:
|
||||
message = Message(
|
||||
session_id=session_id,
|
||||
role=role,
|
||||
content=content,
|
||||
tool_calls_json=json.dumps(tool_calls, ensure_ascii=False) if tool_calls else None,
|
||||
reasoning_json=reasoning_json,
|
||||
tool_call_id=tool_call_id,
|
||||
)
|
||||
self.db.add(message)
|
||||
session = self.get_session(session_id)
|
||||
if session and role == "user" and session.title == "Новый чат" and content:
|
||||
session.title = content[:60] + ("..." if len(content) > 60 else "")
|
||||
self.db.commit()
|
||||
self.db.refresh(message)
|
||||
return message
|
||||
|
||||
def save_user_message(self, session_id: int, user_text: str) -> None:
|
||||
self._save_message(session_id, "user", user_text)
|
||||
|
||||
async def _fallback_complete(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
session_id: int,
|
||||
) -> tuple[str, list[str], list[dict[str, Any]]]:
|
||||
"""Нестриминговый запасной путь, если stream вернул пустоту."""
|
||||
logger.info("chat session=%s fallback complete", session_id)
|
||||
result: dict[str, Any] = {"content": "", "tool_calls": []}
|
||||
for with_tools in (True, False):
|
||||
result = await self.llm.complete(
|
||||
messages,
|
||||
tools=TOOL_DEFINITIONS if with_tools else None,
|
||||
temperature=0.5,
|
||||
visible_reply=True,
|
||||
)
|
||||
if (result.get("content") or "").strip() or result.get("tool_calls"):
|
||||
break
|
||||
|
||||
tool_calls = result.get("tool_calls") or []
|
||||
content = (result.get("content") or "").strip()
|
||||
notices: list[str] = []
|
||||
pomodoro_events: list[dict[str, Any]] = []
|
||||
|
||||
if tool_calls:
|
||||
assistant_msg: dict[str, Any] = {
|
||||
"role": "assistant",
|
||||
"content": content or None,
|
||||
"tool_calls": tool_calls,
|
||||
}
|
||||
messages.append(assistant_msg)
|
||||
self._save_message(
|
||||
session_id,
|
||||
"assistant",
|
||||
content,
|
||||
tool_calls=tool_calls,
|
||||
)
|
||||
for tool_call in tool_calls:
|
||||
fn = tool_call["function"]
|
||||
args = LLMClient.parse_tool_arguments(fn.get("arguments", ""))
|
||||
tool_result = await execute_tool(
|
||||
self.db, fn["name"], args, session_id=session_id
|
||||
)
|
||||
messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call["id"],
|
||||
"content": tool_result,
|
||||
}
|
||||
)
|
||||
self._save_message(
|
||||
session_id,
|
||||
"tool",
|
||||
tool_result,
|
||||
tool_call_id=tool_call["id"],
|
||||
)
|
||||
notice = format_tool_notice(fn["name"], tool_result)
|
||||
if notice:
|
||||
self._save_message(session_id, "notice", notice)
|
||||
notices.append(notice)
|
||||
if fn["name"] in POMODORO_TOOL_NAMES:
|
||||
pomodoro_events.append(
|
||||
{"name": fn["name"], "result": json.loads(tool_result)}
|
||||
)
|
||||
|
||||
followup = await self.llm.complete(
|
||||
messages,
|
||||
tools=None,
|
||||
temperature=0.4,
|
||||
visible_reply=True,
|
||||
)
|
||||
return (followup.get("content") or "").strip(), notices, pomodoro_events
|
||||
|
||||
return content, notices, pomodoro_events
|
||||
|
||||
async def stream_response(
|
||||
self,
|
||||
session_id: int,
|
||||
user_text: str,
|
||||
*,
|
||||
user_message_saved: bool = False,
|
||||
) -> AsyncIterator[str]:
|
||||
session = self.get_session(session_id)
|
||||
if not session:
|
||||
yield self._sse("error", {"message": "Session not found"})
|
||||
return
|
||||
|
||||
if not user_message_saved:
|
||||
self._save_message(session_id, "user", user_text)
|
||||
yield self._sse("status", {"phase": "preparing"})
|
||||
t0 = time.monotonic()
|
||||
messages = await asyncio.to_thread(_build_messages_for_session, session_id)
|
||||
prepare_sec = time.monotonic() - t0
|
||||
if not messages:
|
||||
yield self._sse("error", {"message": "Session not found"})
|
||||
return
|
||||
yield self._sse("status", {"phase": "generating"})
|
||||
streamed_reply_parts: list[str] = []
|
||||
all_tool_notices: list[str] = []
|
||||
tools_executed = 0
|
||||
tool_round = 0
|
||||
|
||||
for _ in range(MAX_TOOL_ROUNDS):
|
||||
tool_round += 1
|
||||
t_round = time.monotonic()
|
||||
content_parts: list[str] = []
|
||||
tool_calls: list[dict[str, Any]] = []
|
||||
reasoning = ""
|
||||
reasoning_details: list[Any] | None = None
|
||||
finish_reason = ""
|
||||
|
||||
# После tool-раунда стримим вживую; до tools — буфер (иначе текст «переписывает» notice).
|
||||
stream_live = tools_executed > 0
|
||||
|
||||
async for event in self.llm.stream_chat(messages, tools=TOOL_DEFINITIONS):
|
||||
if event["type"] == "content":
|
||||
content_parts.append(event["content"])
|
||||
if stream_live:
|
||||
yield self._sse("token", {"content": event["content"]})
|
||||
elif event["type"] == "reasoning":
|
||||
reasoning = event.get("reasoning", "") or reasoning
|
||||
if event.get("reasoning_details"):
|
||||
reasoning_details = event["reasoning_details"]
|
||||
elif event["type"] == "error":
|
||||
logger.warning(
|
||||
"chat session=%s llm_error round=%d prepare=%.2fs: %s",
|
||||
session_id,
|
||||
tool_round,
|
||||
prepare_sec,
|
||||
event.get("content"),
|
||||
)
|
||||
yield self._sse("error", {"message": event.get("content", "LLM error")})
|
||||
return
|
||||
elif event["type"] == "tool_calls":
|
||||
tool_calls = event["tool_calls"]
|
||||
elif event["type"] == "done":
|
||||
finish_reason = event.get("finish_reason", "")
|
||||
|
||||
logger.info(
|
||||
"chat session=%s round=%d prepare=%.2fs llm=%.2fs "
|
||||
"content_len=%d tool_calls=%d finish_reason=%s reasoning_len=%d",
|
||||
session_id,
|
||||
tool_round,
|
||||
prepare_sec,
|
||||
time.monotonic() - t_round,
|
||||
len("".join(content_parts)),
|
||||
len(tool_calls),
|
||||
finish_reason,
|
||||
len(reasoning),
|
||||
)
|
||||
|
||||
if tool_calls:
|
||||
round_text = "".join(content_parts)
|
||||
if round_text.strip():
|
||||
streamed_reply_parts.append(round_text)
|
||||
|
||||
assistant_msg: dict[str, Any] = {
|
||||
"role": "assistant",
|
||||
"content": round_text or None,
|
||||
"tool_calls": tool_calls,
|
||||
}
|
||||
LLMClient.attach_reasoning_to_message(
|
||||
assistant_msg,
|
||||
reasoning=reasoning,
|
||||
reasoning_details=reasoning_details,
|
||||
)
|
||||
reasoning_json = LLMClient.serialize_reasoning(
|
||||
reasoning=reasoning,
|
||||
reasoning_details=reasoning_details,
|
||||
)
|
||||
messages.append(assistant_msg)
|
||||
self._save_message(
|
||||
session_id,
|
||||
"assistant",
|
||||
round_text,
|
||||
tool_calls=tool_calls,
|
||||
reasoning_json=reasoning_json,
|
||||
)
|
||||
|
||||
round_notices: list[str] = []
|
||||
for tool_call in tool_calls:
|
||||
fn = tool_call["function"]
|
||||
args = LLMClient.parse_tool_arguments(fn.get("arguments", ""))
|
||||
result = await execute_tool(
|
||||
self.db, fn["name"], args, session_id=session_id
|
||||
)
|
||||
tools_executed += 1
|
||||
tool_message = {
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call["id"],
|
||||
"content": result,
|
||||
}
|
||||
messages.append(tool_message)
|
||||
self._save_message(session_id, "tool", result, tool_call_id=tool_call["id"])
|
||||
|
||||
notice = format_tool_notice(fn["name"], result)
|
||||
if notice:
|
||||
self._save_message(session_id, "notice", notice)
|
||||
round_notices.append(notice)
|
||||
all_tool_notices.append(notice)
|
||||
|
||||
if fn["name"] in POMODORO_TOOL_NAMES:
|
||||
yield self._sse(
|
||||
"pomodoro",
|
||||
{"name": fn["name"], "result": json.loads(result)},
|
||||
)
|
||||
|
||||
yield self._sse("status", {"phase": "tools"})
|
||||
for notice in round_notices:
|
||||
yield self._sse("notice", {"content": notice})
|
||||
|
||||
continue
|
||||
|
||||
if content_parts and not stream_live:
|
||||
for part in content_parts:
|
||||
yield self._sse("token", {"content": part})
|
||||
|
||||
final_content = "".join(content_parts).strip()
|
||||
if not final_content and streamed_reply_parts and tools_executed == 0:
|
||||
final_content = "".join(streamed_reply_parts).strip()
|
||||
if not final_content and reasoning:
|
||||
final_content = reasoning.strip()
|
||||
if not final_content and tools_executed:
|
||||
retry = await self.llm.complete(
|
||||
messages,
|
||||
tools=None,
|
||||
temperature=0.4,
|
||||
visible_reply=True,
|
||||
)
|
||||
final_content = (retry.get("content") or "").strip()
|
||||
if final_content:
|
||||
yield self._sse("token", {"content": final_content})
|
||||
# Notices уже в чате как role=notice — не дублируем в assistant.
|
||||
if not final_content:
|
||||
final_content, fb_notices, fb_pomodoro = await self._fallback_complete(
|
||||
messages, session_id
|
||||
)
|
||||
if final_content:
|
||||
yield self._sse("token", {"content": final_content})
|
||||
for notice in fb_notices:
|
||||
yield self._sse("notice", {"content": notice})
|
||||
for event in fb_pomodoro:
|
||||
yield self._sse("pomodoro", event)
|
||||
|
||||
if not final_content:
|
||||
logger.warning(
|
||||
"chat session=%s empty_reply tools=%d rounds=%d finish_reason=%s",
|
||||
session_id,
|
||||
tools_executed,
|
||||
tool_round,
|
||||
finish_reason,
|
||||
)
|
||||
yield self._sse(
|
||||
"error",
|
||||
{
|
||||
"message": (
|
||||
"Модель не вернула ответ (finish_reason="
|
||||
f"{finish_reason or 'unknown'}). "
|
||||
"Попробуй новый чат или проверь OPENROUTER_MODEL."
|
||||
),
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
self._save_message(session_id, "assistant", final_content)
|
||||
|
||||
logger.info(
|
||||
"chat session=%s done tools=%d reply_len=%d total=%.2fs",
|
||||
session_id,
|
||||
tools_executed,
|
||||
len(final_content),
|
||||
time.monotonic() - t0,
|
||||
)
|
||||
yield self._sse("done", {})
|
||||
if get_settings().memory_auto_extract:
|
||||
asyncio.create_task(
|
||||
_extract_memory_background(session_id, user_text, final_content)
|
||||
)
|
||||
return
|
||||
|
||||
yield self._sse("error", {"message": "Too many tool call rounds"})
|
||||
|
||||
@staticmethod
|
||||
def _sse(event: str, data: dict[str, Any]) -> str:
|
||||
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||
Reference in New Issue
Block a user