import json import logging import re from datetime import datetime, timezone from typing import Any from zoneinfo import ZoneInfo from telegram import Update from telegram.ext import ContextTypes from .ai import AIClientError from .config import CONVERSATION_HISTORY_LIMIT, MAX_AGENT_STEPS from .models import AgentDecision, ReminderParseResult from .prompts import ASSISTANT_SYSTEM_PROMPT from .reminders import parse_reminder from .storage import AssistantStorage from .telegram_utils import ( escape_markdown_text, get_ai_client, get_storage, get_tz, parse_positive_int, reply_markdown, require_user_id, typing_action, ) from .time_utils import format_local_dt, to_utc_iso, utc_now logger = logging.getLogger(__name__) def build_assistant_context( storage: AssistantStorage, user_id: int, tz: ZoneInfo ) -> str: memories = storage.list_memories(user_id, limit=20) notes = storage.list_notes(user_id, limit=10) reminders = storage.list_reminders(user_id, limit=10) tracked_items = storage.list_tracked_items(user_id, limit=20) sections: list[str] = [] if memories: sections.append( "Долговременная память:\n" + "\n".join(f"- #{row['id']}: {row['text']}" for row in memories) ) if notes: sections.append( "Последние заметки:\n" + "\n".join(f"- #{row['id']}: {row['text']}" for row in notes) ) if reminders: sections.append( "Активные напоминания:\n" + "\n".join( f"- #{row['id']} {format_local_dt(row['remind_at'], tz)}: {row['text']}" for row in reminders ) ) if tracked_items: sections.append( "Отслеживаемые статусы:\n" + "\n".join( f"- #{row['id']} {row['title']}: {row['status']}" for row in tracked_items ) ) if not sections: return "Долговременный контекст пока пуст." return "\n\n".join(sections) def json_dumps(data: Any) -> str: return json.dumps(data, ensure_ascii=False, indent=2) def build_agent_tool_prompt(tz: ZoneInfo) -> str: now_local = datetime.now(tz).strftime("%Y-%m-%d %H:%M") return ( "Ты работаешь как агент с внутренними tools. Пользователь пишет свободным текстом, " "а Telegram-команды ему не нужны.\n" f"Текущее локальное время: {now_local}. Таймзона: {tz.key}.\n\n" "Отвечай СТРОГО одним JSON-объектом без Markdown-блока и без текста вокруг.\n" "Если нужно выполнить действие, верни tool_calls. Если действие уже выполнено " "или tool не нужен, верни final.\n\n" "Форматы ответа:\n" '{"tool_calls":[{"name":"create_note","arguments":{"text":"..."}}],"reset_context":false}\n' '{"final":"Короткий ответ пользователю","reset_context":false}\n\n' "Значение final оформляй обычным Markdown, не MarkdownV2. Умеренно используй " "жирный и курсивный текст, списки, ссылки и блоки кода, когда они улучшают " "читаемость. Для короткого простого ответа разметка не обязательна. " "Не добавляй декоративные эмодзи чаще одного раза на сообщение.\n\n" "Доступные tools:\n" "- get_current_datetime {} - узнать текущее локальное и UTC-время.\n" '- remember {"text": string} - сохранить важный долгосрочный факт о пользователе.\n' '- list_memory {"limit": number} - показать сохраненную память.\n' '- delete_memory {"id": number} - удалить запись памяти.\n' '- create_note {"text": string} - сохранить заметку.\n' '- list_notes {"limit": number} - показать заметки.\n' '- delete_note {"id": number} - удалить заметку.\n' '- create_reminder {"when": string, "text": string} - поставить напоминание. ' "when можно указывать как '30m', 'через 2 часа', '18:30', '2026-07-16 18:30'. " "Если пользователь говорит 'завтра/послезавтра/через неделю', сам рассчитай " "дату от текущего локального времени и передай 'YYYY-MM-DD HH:MM'.\n" '- list_reminders {"limit": number} - показать активные напоминания.\n' '- cancel_reminder {"id": number} - отменить напоминание.\n' '- create_status {"title": string, "status": string} - начать отслеживать статус.\n' '- list_statuses {"limit": number} - показать отслеживаемые статусы.\n' '- update_status {"id": number, "status": string} - обновить статус.\n' '- delete_status {"id": number} - удалить отслеживаемый объект.\n\n' '- search_conversation {"query": string, "limit": number} - найти старое обсуждение ' "во всей сохраненной переписке по содержательным ключевым словам.\n\n" "Правила:\n" "- Для просьб 'запомни', 'сохрани как факт', 'учти на будущее' используй remember.\n" "- Для заметок используй create_note, для напоминаний create_reminder, " "для контроля дел/заявок/ожиданий create_status или update_status.\n" "- Если для действия не хватает данных, не вызывай tool, а задай уточняющий вопрос через final.\n" "- Учитывай историю диалога для коротких ответов на уточняющие вопросы. " "Если новый запрос явно начинает другую, не связанную с историей тему, не опирайся на старую тему " "и верни reset_context=true. Для продолжения темы и сомнительных случаев верни false.\n" "- Если пользователь просит найти, вспомнить или продолжить старое обсуждение, вызови " "search_conversation. Передавай в query только ключевые слова темы, без общих слов. " "Результаты поиска содержат соседние реплики; более свежие совпадения при прочих равных важнее.\n" "- После результата tool верни final с кратким подтверждением или следующим tool_calls." ) def extract_json_object(raw_text: str) -> dict[str, Any] | None: candidates = [raw_text.strip()] code_block = re.search( r"```(?:json)?\s*(.*?)```", raw_text, re.IGNORECASE | re.DOTALL ) if code_block: candidates.insert(0, code_block.group(1).strip()) first_brace = raw_text.find("{") last_brace = raw_text.rfind("}") if 0 <= first_brace < last_brace: candidates.append(raw_text[first_brace : last_brace + 1]) for candidate in candidates: if not candidate: continue try: parsed = json.loads(candidate) except json.JSONDecodeError: continue if isinstance(parsed, dict): return parsed return None def parse_agent_decision(raw_text: str) -> AgentDecision: parsed = extract_json_object(raw_text) if parsed is None: return AgentDecision(final=raw_text.strip(), tool_calls=[], reset_context=False) raw_calls = parsed.get("tool_calls") if raw_calls is None and parsed.get("tool"): raw_calls = [ { "name": parsed.get("tool"), "arguments": parsed.get("arguments", {}), } ] tool_calls: list[dict[str, Any]] = [] if isinstance(raw_calls, list): for raw_call in raw_calls: if not isinstance(raw_call, dict): continue name = raw_call.get("name") or raw_call.get("tool") arguments = raw_call.get("arguments", {}) if isinstance(name, str): tool_calls.append( { "name": name.strip(), "arguments": arguments if isinstance(arguments, dict) else {}, } ) final = parsed.get("final") or parsed.get("answer") reset_context = parsed.get("reset_context") is True if isinstance(final, str) and final.strip(): return AgentDecision( final=final.strip(), tool_calls=tool_calls, reset_context=reset_context, ) return AgentDecision(final=None, tool_calls=tool_calls, reset_context=reset_context) def save_conversation_exchange( storage: AssistantStorage, user_id: int, chat_id: int, prompt: str, answer: str, reset_context: bool, ) -> None: if reset_context: storage.start_new_conversation(user_id, chat_id) storage.add_conversation_exchange(user_id, chat_id, prompt, answer) def coerce_positive_int(value: Any, default: int, maximum: int = 50) -> int: try: parsed = int(value) except (TypeError, ValueError): return default if parsed <= 0: return default return min(parsed, maximum) def coerce_required_text(arguments: dict[str, Any], key: str) -> str: value = arguments.get(key, "") return str(value).strip() def parse_agent_reminder( when: str, text: str, tz: ZoneInfo ) -> ReminderParseResult | None: parsed = parse_reminder(f"{when} {text}".strip(), tz) if parsed: return parsed normalized_when = when.strip().replace("T", " ") try: parsed_dt = datetime.fromisoformat(normalized_when) except ValueError: return None if parsed_dt.tzinfo is None: parsed_dt = parsed_dt.replace(tzinfo=tz) return ReminderParseResult(parsed_dt.astimezone(timezone.utc), text) def execute_agent_tool( name: str, arguments: dict[str, Any], storage: AssistantStorage, user_id: int, chat_id: int, tz: ZoneInfo, ) -> dict[str, Any]: tool_name = name.strip().lower() if tool_name == "get_current_datetime": now = utc_now() return { "ok": True, "local": now.astimezone(tz).strftime("%Y-%m-%d %H:%M:%S"), "utc": to_utc_iso(now), "timezone": tz.key, } if tool_name == "remember": text = coerce_required_text(arguments, "text") if not text: return {"ok": False, "error": "text is required"} memory_id = storage.add_memory(user_id, text) return {"ok": True, "id": memory_id, "text": text} if tool_name == "list_memory": rows = storage.list_memories( user_id, coerce_positive_int(arguments.get("limit"), 20) ) return {"ok": True, "items": rows} if tool_name == "delete_memory": memory_id = parse_positive_int(str(arguments.get("id", ""))) if memory_id is None: return {"ok": False, "error": "valid id is required"} return {"ok": storage.delete_memory(user_id, memory_id), "id": memory_id} if tool_name == "create_note": text = coerce_required_text(arguments, "text") if not text: return {"ok": False, "error": "text is required"} note_id = storage.add_note(user_id, text) return {"ok": True, "id": note_id, "text": text} if tool_name == "list_notes": rows = storage.list_notes( user_id, coerce_positive_int(arguments.get("limit"), 20) ) return {"ok": True, "items": rows} if tool_name == "delete_note": note_id = parse_positive_int(str(arguments.get("id", ""))) if note_id is None: return {"ok": False, "error": "valid id is required"} return {"ok": storage.delete_note(user_id, note_id), "id": note_id} if tool_name == "create_reminder": when = coerce_required_text(arguments, "when") text = coerce_required_text(arguments, "text") if not when or not text: return {"ok": False, "error": "when and text are required"} parsed = parse_agent_reminder(when, text, tz) if not parsed: return {"ok": False, "error": "could not parse reminder time"} if parsed.remind_at_utc <= utc_now(): return {"ok": False, "error": "reminder time must be in the future"} reminder_id = storage.add_reminder( user_id, chat_id, parsed.text, parsed.remind_at_utc ) return { "ok": True, "id": reminder_id, "text": parsed.text, "remind_at": format_local_dt(parsed.remind_at_utc, tz), } if tool_name == "list_reminders": rows = storage.list_reminders( user_id, coerce_positive_int(arguments.get("limit"), 20) ) for row in rows: row["remind_at_local"] = format_local_dt(row["remind_at"], tz) return {"ok": True, "items": rows} if tool_name == "cancel_reminder": reminder_id = parse_positive_int(str(arguments.get("id", ""))) if reminder_id is None: return {"ok": False, "error": "valid id is required"} return {"ok": storage.cancel_reminder(user_id, reminder_id), "id": reminder_id} if tool_name == "create_status": title = coerce_required_text(arguments, "title") status = coerce_required_text(arguments, "status") or "open" if not title: return {"ok": False, "error": "title is required"} item_id = storage.add_tracked_item(user_id, title, status) return {"ok": True, "id": item_id, "title": title, "status": status} if tool_name == "list_statuses": rows = storage.list_tracked_items( user_id, coerce_positive_int(arguments.get("limit"), 30) ) return {"ok": True, "items": rows} if tool_name == "update_status": item_id = parse_positive_int(str(arguments.get("id", ""))) status = coerce_required_text(arguments, "status") if item_id is None or not status: return {"ok": False, "error": "valid id and status are required"} return { "ok": storage.set_tracked_status(user_id, item_id, status), "id": item_id, "status": status, } if tool_name == "delete_status": item_id = parse_positive_int(str(arguments.get("id", ""))) if item_id is None: return {"ok": False, "error": "valid id is required"} return {"ok": storage.delete_tracked_item(user_id, item_id), "id": item_id} if tool_name == "search_conversation": query = coerce_required_text(arguments, "query") if not query: return {"ok": False, "error": "query is required"} limit = coerce_positive_int(arguments.get("limit"), 5, maximum=10) hits = storage.search_conversation_messages(user_id, chat_id, query, limit) discussions = [ { "matched_message_id": hit["id"], "score": hit["score"], "messages": storage.conversation_message_window( user_id, chat_id, int(hit["id"]), ), } for hit in hits ] return {"ok": True, "query": query, "discussions": discussions} return {"ok": False, "error": f"unknown tool: {name}"} async def run_agent_prompt( update: Update, context: ContextTypes.DEFAULT_TYPE, prompt: str, ) -> None: if not update.effective_message: return user_id = require_user_id(update) if user_id is None: await reply_markdown( update.effective_message, "⚠️ **Не могу определить пользователя.**", ) return storage = get_storage(context) ai_client = get_ai_client(context) tz = get_tz(context) model = ai_client.normalize_model( storage.get_user_model(user_id, ai_client.provider) ) chat_id = int(update.effective_message.chat_id) assistant_context = build_assistant_context(storage, user_id, tz) conversation_history = storage.list_conversation_messages( user_id, chat_id, limit=CONVERSATION_HISTORY_LIMIT, ) messages = [ {"role": "system", "content": ASSISTANT_SYSTEM_PROMPT}, {"role": "system", "content": build_agent_tool_prompt(tz)}, {"role": "system", "content": assistant_context}, { "role": "system", "content": ( "Далее идет недавняя история текущей темы от старых реплик к новым. " "Чем старше реплика, тем меньше ее приоритет; при противоречии опирайся " "на более новые сообщения. Архив других тем доступен через search_conversation." ), }, *( {"role": str(row["role"]), "content": str(row["content"])} for row in conversation_history ), {"role": "user", "content": prompt}, ] try: last_tool_results: list[dict[str, Any]] = [] reset_context = False final_answer: str | None = None async with typing_action(context.bot, chat_id): for _step in range(MAX_AGENT_STEPS): raw_answer = await ai_client.chat(model, messages, json_mode=True) decision = parse_agent_decision(raw_answer) reset_context = reset_context or decision.reset_context if decision.tool_calls: tool_results = [] for call in decision.tool_calls: result = execute_agent_tool( name=str(call.get("name", "")), arguments=call.get("arguments", {}), storage=storage, user_id=user_id, chat_id=chat_id, tz=tz, ) tool_results.append( { "name": call.get("name"), "arguments": call.get("arguments", {}), "result": result, } ) last_tool_results = tool_results messages.append( { "role": "assistant", "content": json_dumps( {"tool_calls": decision.tool_calls} ), } ) messages.append( { "role": "user", "content": ( "Результаты tools:\n" f"{json_dumps({'tool_results': tool_results})}\n" "Продолжи. Верни либо следующий tool_calls, либо final. " "Ответ снова строго JSON." ), } ) continue if decision.final: final_answer = decision.final break messages.append({"role": "assistant", "content": raw_answer}) if final_answer is None: final_answer = await ai_client.chat( model, [ *messages, { "role": "user", "content": ( "Лимит tool-шагов исчерпан. Больше не вызывай tools. " f"Последние результаты tools: {json_dumps(last_tool_results)}. " "Сформулируй короткий финальный ответ пользователю обычным Markdown." ), }, ], ) await reply_markdown(update.effective_message, final_answer) save_conversation_exchange( storage, user_id, chat_id, prompt, final_answer, reset_context, ) except AIClientError as exc: if ai_client.provider == "local": hint = f"Проверь, что Ollama запущена и модель установлена: ollama pull {model}" else: hint = ( "Проверь YANDEX_CLOUD_FOLDER, YC_API_KEY и доступ к выбранной модели." ) await reply_markdown( update.effective_message, f"⚠️ **Не получилось вызвать " f"{escape_markdown_text(ai_client.display_name)}.**\n\n" f"{escape_markdown_text(exc)}\n\n" f"{escape_markdown_text(hint)}", ) return except Exception: logger.exception("Failed to run AI prompt") await reply_markdown( update.effective_message, "⚠️ **Произошла внутренняя ошибка** при запросе к модели.", )