init
This commit is contained in:
516
assistant_bot/agent.py
Normal file
516
assistant_bot/agent.py
Normal file
@@ -0,0 +1,516 @@
|
||||
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.constants import ChatAction
|
||||
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 (
|
||||
edit_or_reply,
|
||||
get_ai_client,
|
||||
get_storage,
|
||||
get_tz,
|
||||
parse_positive_int,
|
||||
require_user_id,
|
||||
)
|
||||
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"
|
||||
"Доступные 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 update.effective_message.reply_text("Не могу определить пользователя.")
|
||||
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},
|
||||
]
|
||||
|
||||
await context.bot.send_chat_action(
|
||||
chat_id=chat_id,
|
||||
action=ChatAction.TYPING,
|
||||
)
|
||||
placeholder = await update.effective_message.reply_text(
|
||||
f"Думаю через {ai_client.display_name} / {model}..."
|
||||
)
|
||||
|
||||
try:
|
||||
last_tool_results: list[dict[str, Any]] = []
|
||||
reset_context = False
|
||||
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:
|
||||
await edit_or_reply(placeholder, decision.final)
|
||||
save_conversation_exchange(
|
||||
storage,
|
||||
user_id,
|
||||
chat_id,
|
||||
prompt,
|
||||
decision.final,
|
||||
reset_context,
|
||||
)
|
||||
return
|
||||
|
||||
messages.append({"role": "assistant", "content": raw_answer})
|
||||
|
||||
final_answer = await ai_client.chat(
|
||||
model,
|
||||
[
|
||||
*messages,
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"Лимит tool-шагов исчерпан. Больше не вызывай tools. "
|
||||
f"Последние результаты tools: {json_dumps(last_tool_results)}. "
|
||||
"Сформулируй короткий финальный ответ пользователю обычным текстом."
|
||||
),
|
||||
},
|
||||
],
|
||||
)
|
||||
await edit_or_reply(placeholder, 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 placeholder.edit_text(
|
||||
f"Не получилось вызвать {ai_client.display_name}.\n{exc}\n\n{hint}"
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
logger.exception("Failed to run AI prompt")
|
||||
await placeholder.edit_text("Произошла внутренняя ошибка при запросе к модели.")
|
||||
|
||||
|
||||
async def run_ai_prompt(
|
||||
update: Update,
|
||||
context: ContextTypes.DEFAULT_TYPE,
|
||||
prompt: str,
|
||||
) -> None:
|
||||
await run_agent_prompt(update, context, prompt)
|
||||
Reference in New Issue
Block a user