diff --git a/CLAUDE.md b/CLAUDE.md index 6193d10..9f12010 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,6 +60,16 @@ - Активируется только при `provider=deepseek` (tool calling). Сборка — в composition root (`cli/main.py`). - Демо (воспроизводимо, без ключа — `JARVIS_DEMO_SCRIPTED=1`): `examples/fs_agent_demo.py` — агент по цели сам ищет использования API и генерирует ADR. +## Ассистент поддержки (/support) + +Мини-сервис поддержки пользователей: отвечает на вопрос о продукте по FAQ (RAG) с учётом контекста тикета/пользователя (MCP). `/support <вопрос> [#T-1024]`. Это брат-близнец `/help`: FAQ вместо доков, тикеты вместо git-ветки. + +- **RAG по FAQ**: `infra/faq_retrieval.py::MarkdownFaqRetrievalEngine` — реализует порт `RetrievalEngine`, лексический поиск по `docs/support-faq/*.md` (чанки по `##`-разделам), ноль внешних зависимостей. Взаимозаменяем с FAISS-движком через тот же порт (`JARVIS_FAQ_DIR` переопределяет каталог). +- **Тикеты через MCP**: `infra/ticket_store_client.py::TicketStoreClient` — in-process `McpClient` над JSON `~/.jarvis/support/tickets.json` (users + tickets), по образцу `LocalFilesystemClient`. Встаёт в `McpRegistry.register()`; тулы `support__get_ticket · get_user · search_tickets` агент вызывает сам в tool-loop. Файл сидируется примером при первом запуске (`_seed_support_tickets`); `JARVIS_SUPPORT_TICKETS` переопределяет путь. Заменить на реальный CRM = поднять внешний MCP-сервер в конфиге, use case не изменится. +- **Use case**: `app/support_assistant.py::answer_support_question` — оркеструет `RetrievalEngine` + `SupportChat` (порт tool-loop). `SupportChat` реализует `ToolRouter` (полный tool-loop) или `PlainChatAdapter` (деградация без tool calling — ответ только по FAQ). +- Полноценный доступ к тикетам — только при `provider=deepseek` (tool calling). Сборка — в composition root (`cli/main.py`). +- Демо (без ключа — `JARVIS_DEMO_SCRIPTED=1`): `examples/support_agent_demo.py` — «Почему не работает авторизация? #T-1024» → агент поднимает тикет (Free + SSO ⇒ 403), читает FAQ, отвечает адресно. + ## Конвенции - Все пользовательские данные — в `~/.jarvis/`, не в репо diff --git a/app/support_assistant.py b/app/support_assistant.py new file mode 100644 index 0000000..1fd4656 --- /dev/null +++ b/app/support_assistant.py @@ -0,0 +1,162 @@ +"""Use case: AI-ассистент поддержки пользователей. + +`/support <вопрос> [#T-1024]` — ассистент отвечает на вопрос о продукте, +опираясь на: + + • RAG по FAQ/документации (порт `RetrievalEngine`) — фактическая база + ответов, чтобы не выдумывать; + • контекст пользователя/тикета через MCP (тулы `support__*`, которые + агент вызывает сам в tool-loop) — чтобы ответ учитывал тариф, способ + входа, историю обращения. + +Оркеструет два порта и ничего не знает об их реализациях (markdown-FAQ или +FAISS; JSON-стор тикетов или реальный CRM). Это прямой аналог +`app/project_help.py`, но FAQ вместо доков и тикеты вместо git. + +Тикеты берутся именно через MCP-механизм (`SupportChat` = `ToolRouter`), +поэтому «агент сам выбирает инструмент». Если tool-loop недоступен +(провайдер без tool calling), `PlainChatAdapter` даёт деградацию: ответ +только по FAQ, без данных тикета. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Optional, Protocol + +from app.ports import LLMClient, RetrievalEngine +from domain.retrieval import RetrievedChunk + +SYSTEM_PROMPT = ( + "Ты — ассистент службы поддержки продукта. Твоя задача — помочь " + "пользователю.\n" + "Правила:\n" + "1. Отвечай по существу вопроса, опираясь на приведённый КОНТЕКСТ FAQ. " + "Если в FAQ нет ответа — честно скажи об этом, не выдумывай факты о продукте.\n" + "2. Если известен номер тикета или пользователя — ОБЯЗАТЕЛЬНО вызови тулы " + "support__get_ticket / support__get_user, чтобы учесть контекст: тариф, " + "способ входа, платформу, историю переписки. Ответ должен быть адресным, " + "а не общим.\n" + "3. При необходимости посмотри похожие обращения через support__search_tickets.\n" + "4. Пиши кратко, по-человечески, на языке вопроса. Укажи, из какого раздела " + "FAQ взят ответ. Если проблему нельзя решить по FAQ — предложи следующий шаг " + "(эскалация, нужные данные)." +) + + +class SupportChat(Protocol): + """Канал общения с моделью, умеющий tool-loop по MCP-тулам. + + Реализуется `app.tool_router.ToolRouter` (полноценный tool-loop) и + `PlainChatAdapter` (деградация без тулов). Use case знает только про этот + протокол, поэтому в тестах подменяется фейком. + """ + + def chat(self, messages: list, params: dict, + system_prompt: Optional[str] = None, + on_event=None): ... + + +@dataclass +class _PlainResult: + reply: str + trace: list = field(default_factory=list) + truncated: bool = False + + +class PlainChatAdapter: + """Оборачивает обычный LLMClient в интерфейс SupportChat (без тулов). + + Нужен, когда провайдер не умеет tool calling: ассистент всё равно + отвечает по FAQ, просто не может подтянуть данные тикета. + """ + + def __init__(self, client: LLMClient): + self._client = client + + def chat(self, messages: list, params: dict, + system_prompt: Optional[str] = None, on_event=None) -> _PlainResult: + reply = self._client.chat(messages, params, system_prompt) + return _PlainResult(reply=reply) + + +@dataclass +class ToolUse: + """Свёрнутый след одного обращения к тулу поддержки — для показа в UI.""" + server_id: str + tool_name: str + arguments: dict + is_error: bool + + +@dataclass +class SupportAnswer: + """Результат ответа ассистента поддержки.""" + reply: str + sources: list[RetrievedChunk] = field(default_factory=list) + tools_used: list[ToolUse] = field(default_factory=list) + ticket_id: Optional[str] = None + used_faq: bool = True + truncated: bool = False + + +def _build_context(chunks: list[RetrievedChunk], ticket_hint: Optional[str]) -> str: + parts: list[str] = [] + if ticket_hint: + parts.append( + f"[тикет] В вопросе упомянут тикет {ticket_hint}. " + f"Сначала вызови support__get_ticket с ticket_id={ticket_hint}." + ) + if chunks: + parts.append("[FAQ] Релевантные разделы базы знаний:") + for i, c in enumerate(chunks, 1): + loc = c.section or c.title or c.source + header = f"[{i}] {c.source}" + (f" — {loc}" if loc and loc != c.source else "") + parts.append(f"{header}\n{c.text.strip()}") + else: + parts.append("[FAQ] По этому вопросу в базе ничего не нашлось.") + return "\n\n".join(parts) + + +def answer_support_question( + question: str, + ticket_hint: Optional[str], + engine: Optional[RetrievalEngine], + support_chat: SupportChat, + params: dict, + top_k: int = 5, + on_event=None, +) -> SupportAnswer: + """Найти релевантный FAQ, дать агенту тулы тикетов, вернуть адресный ответ.""" + question = question.strip() + + chunks: list[RetrievedChunk] = [] + if engine is not None and engine.is_ready(): + chunks = engine.retrieve(question, top_k=top_k) + + context = _build_context(chunks, ticket_hint) + user_msg = f"{context}\n\n---\nВопрос пользователя: {question}" + messages = [{"role": "user", "content": user_msg}] + + aux = dict(params) + aux.setdefault("temperature", 0.2) # фактологичный ответ, минимум фантазии + + result = support_chat.chat(messages, aux, SYSTEM_PROMPT, on_event) + + tools_used = [ + ToolUse( + server_id=inv.server_id, + tool_name=inv.tool_name, + arguments=inv.arguments, + is_error=inv.is_error, + ) + for inv in getattr(result, "trace", []) + ] + + return SupportAnswer( + reply=result.reply, + sources=chunks, + tools_used=tools_used, + ticket_id=ticket_hint, + used_faq=bool(chunks), + truncated=getattr(result, "truncated", False), + ) diff --git a/cli/config.py b/cli/config.py index 8705a06..ec762b9 100644 --- a/cli/config.py +++ b/cli/config.py @@ -115,6 +115,8 @@ def resolve_provider(env_value: str) -> str: INVARIANTS_DIR = os.path.expanduser("~/.jarvis/invariants") MCP_DIR = os.path.expanduser("~/.jarvis/mcp") MCP_CONFIG_FILE = os.path.join(MCP_DIR, "servers.json") +SUPPORT_DIR = os.path.expanduser("~/.jarvis/support") +SUPPORT_TICKETS_FILE = os.path.join(SUPPORT_DIR, "tickets.json") ACTIVE_TASK_FILE = os.path.join(TASKS_DIR, "active") MAX_SESSIONS = 20 diff --git a/cli/main.py b/cli/main.py index c1a894d..39f56c4 100644 --- a/cli/main.py +++ b/cli/main.py @@ -7,6 +7,7 @@ """ from __future__ import annotations +import json import os import sys from typing import Optional @@ -42,6 +43,7 @@ OLLAMA, OLLAMA_BASE_URL, PROFILES_DIR, + SUPPORT_TICKETS_FILE, TASKS_DIR, WORKING_DIR, DEFAULT_EMBED_MODEL, @@ -101,7 +103,11 @@ from infra.mcp_git import McpGitContextProvider from infra.mcp_registry import StdioMcpRegistry from infra.local_fs_client import LocalFilesystemClient +from infra.ticket_store_client import TicketStoreClient, TicketStoreError +from infra.faq_retrieval import MarkdownFaqRetrievalEngine from cli.fs_confirm import make_interactive_confirm +from cli.support_commands import handle_support +from app.support_assistant import PlainChatAdapter from infra.pr_diff import GhDiffProvider from infra.profile_repository import FileProfileRepository from infra.query_rewriter import LLMQueryRewriter @@ -115,6 +121,48 @@ _YES = {"y", "yes", "да", "д"} +# Пример стора тикетов: создаётся при первом запуске, если файла ещё нет. +# Данные согласованы с FAQ (docs/support-faq), чтобы /support давал связный +# ответ «из коробки». Пользователь потом правит этот JSON под свой продукт. +_SAMPLE_TICKETS = { + "users": [ + {"id": "U-100", "name": "Иван Петров", "email": "ivan@example.com", + "plan": "Free", "auth_method": "SSO (Google)", "platform": "web"}, + {"id": "U-200", "name": "Мария Client", "email": "maria@corp.example", + "plan": "Business", "auth_method": "email+пароль", "platform": "iOS"}, + ], + "tickets": [ + {"id": "T-1024", "user_id": "U-100", "status": "open", "priority": "high", + "product_area": "auth", "error_code": "403", + "subject": "Не могу войти через Google", + "created_at": "2026-07-18", + "messages": [ + {"author": "user", "text": "При входе через Google выдаёт ошибку 403."}, + {"author": "support", "text": "Уточните, включён ли SSO в вашем тарифе?"}, + ]}, + {"id": "T-1042", "user_id": "U-200", "status": "pending", "priority": "normal", + "product_area": "billing", + "subject": "Как перейти на годовую оплату", + "created_at": "2026-07-17", + "messages": [ + {"author": "user", "text": "Хочу перейти с месячной на годовую подписку."}, + ]}, + ], +} + + +def _seed_support_tickets(path: str) -> None: + """Создать пример стора тикетов, если файла ещё нет. Ошибки — не критичны.""" + try: + if os.path.exists(path): + return + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(_SAMPLE_TICKETS, f, ensure_ascii=False, indent=2) + except OSError: + pass # не смогли записать — /support просто скажет, что стор недоступен + + def _build_client(provider: str) -> LLMClient: """Собрать LLM-клиент для выбранного провайдера. @@ -258,6 +306,29 @@ def main(): except Exception as e: print(f"{YELLOW}Файловые тулы не поднялись: {e}{RESET}") + # Ассистент поддержки: JSON-стор тикетов как MCP-CRM (in-process клиент) + # плюс RAG по FAQ. Файл тикетов сидируется примером при первом запуске. + # Переопределяется JARVIS_SUPPORT_TICKETS / JARVIS_FAQ_DIR. + tickets_file = os.path.expanduser( + os.environ.get("JARVIS_SUPPORT_TICKETS", "").strip() or SUPPORT_TICKETS_FILE + ) + _seed_support_tickets(tickets_file) + try: + support_client = TicketStoreClient(path=tickets_file) + mcp_registry.register(support_client) + print(f"{DIM}Стор тикетов (support) активен: {tickets_file}.{RESET}") + except TicketStoreError as e: + print(f"{YELLOW}Стор тикетов не поднялся: {e}{RESET}") + + faq_dir = os.path.expanduser( + os.environ.get("JARVIS_FAQ_DIR", "").strip() + or os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "docs", "support-faq") + ) + faq_engine = MarkdownFaqRetrievalEngine(faq_dir) + if faq_engine.is_ready(): + print(f"{DIM}FAQ поддержки: {faq_dir}.{RESET}") + tool_router = ToolRouter(client, mcp_registry) \ if provider == DEEPSEEK and mcp_registry.all_tools() else None if tool_router is not None: @@ -266,6 +337,10 @@ def main(): print(f"{YELLOW}MCP-серверы настроены, но tool calling доступен только " f"для DeepSeek. Переключи /provider deepseek.{RESET}\n") + # /support использует tool-loop для доступа к тикетам через MCP; без него + # (провайдер без tool calling) — деградация до ответа только по FAQ. + support_chat = tool_router if tool_router is not None else PlainChatAdapter(client) + # Инициализация долговременной памяти current_profile: Optional[Profile] = profile_repo.ensure_default() @@ -368,6 +443,8 @@ def main(): client = _build_client(provider) tool_router = ToolRouter(client, mcp_registry) \ if provider == DEEPSEEK and mcp_registry.all_tools() else None + support_chat = tool_router if tool_router is not None \ + else PlainChatAdapter(client) print(f"{GREEN}Провайдер переключён: {provider}{RESET}") print(f"{DIM}модель сброшена на дефолт: {params['model']}{RESET}") if tool_router is not None: @@ -383,6 +460,7 @@ def main(): provider = OLLAMA client = _build_client(provider) tool_router = None + support_chat = PlainChatAdapter(client) installed = client.list_models() if hasattr(client, "list_models") else [] if installed: params["model"] = installed[0] @@ -444,6 +522,9 @@ def main(): elif cmd == "/review" or cmd.startswith("/review "): handle_review(user_input, review_engine, diff_provider, client, params, top_k=rag_config.top_k) + elif cmd == "/support" or cmd.startswith("/support "): + handle_support(user_input, faq_engine, support_chat, params, + top_k=rag_config.top_k) else: print(f"{YELLOW}Неизвестная команда. Введите /help.{RESET}") continue diff --git a/cli/support_commands.py b/cli/support_commands.py new file mode 100644 index 0000000..036632a --- /dev/null +++ b/cli/support_commands.py @@ -0,0 +1,93 @@ +"""CLI-обработчик /support — AI-ассистент поддержки. + + /support <вопрос> — ответ по FAQ (RAG). + /support <вопрос> #T-1024 — ответ с учётом данных тикета (через MCP). + +Тонкий слой: парсит ввод, вытаскивает номер тикета/пользователя, зовёт use +case `answer_support_question`, печатает ответ, задействованные тулы и +источники FAQ. Вся логика — в `app/support_assistant.py`. +""" +from __future__ import annotations + +import json +import re +from typing import Optional + +from app.support_assistant import SupportChat, answer_support_question +from app.ports import RetrievalEngine +from cli.ansi import BOLD, CYAN, DIM, GREEN, MAGENTA, RESET, YELLOW +from cli.spinner import Spinner + +# Идентификатор тикета (T-…) или пользователя (U-…), с необязательной решёткой. +_TICKET_RE = re.compile(r"#?\b([TU]-\w+)\b", re.IGNORECASE) + + +def _extract_ticket(payload: str) -> tuple[Optional[str], str]: + """Вытащить первый #T-1024 / U-100 из текста; вернуть (id, вопрос-без-тега).""" + m = _TICKET_RE.search(payload) + if not m: + return None, payload.strip() + ticket = m.group(1).upper() + # Убираем сам тег (вместе с решёткой) из вопроса, схлопываем пробелы. + question = (payload[:m.start()] + payload[m.end():]).strip() + question = re.sub(r"\s{2,}", " ", question) + return ticket, question + + +def handle_support(cmd_str: str, + engine: Optional[RetrievalEngine], + support_chat: Optional[SupportChat], + params: dict, + top_k: int = 5) -> None: + payload = cmd_str[len("/support"):].strip() + + if not payload: + print(f"{DIM} Использование: /support <вопрос> [#T-1024]{RESET}") + print(f"{DIM} Примеры:{RESET}") + print(f"{DIM} /support Почему не работает авторизация? #T-1024{RESET}") + print(f"{DIM} /support Как поменять тариф?{RESET}") + return + + if support_chat is None: + print(f"{YELLOW} Ассистент поддержки недоступен: нужен провайдер с " + f"tool calling (deepseek) для доступа к тикетам через MCP.{RESET}") + print(f"{DIM} Переключись: /provider deepseek{RESET}") + return + + ticket_hint, question = _extract_ticket(payload) + if not question: + question = payload # был только тег без текста — оставим как есть + + with Spinner("Ищу в FAQ и поднимаю контекст тикета..."): + result = answer_support_question( + question, ticket_hint, engine, support_chat, params, top_k=top_k) + + print(f"\n{BOLD}{GREEN}Поддержка:{RESET} {result.reply}\n") + + if result.ticket_id: + print(f"{DIM} Контекст тикета: {CYAN}{result.ticket_id}{RESET}") + + if result.tools_used: + seen: list[str] = [] + for tu in result.tools_used: + args = json.dumps(tu.arguments, ensure_ascii=False) + mark = "✗" if tu.is_error else "✓" + label = f"{mark} {tu.server_id}.{tu.tool_name} {args}" + if label not in seen: + seen.append(label) + print(f"{DIM} MCP-тулы: {CYAN}{' · '.join(seen)}{RESET}") + + if result.sources: + locs: list[str] = [] + for c in result.sources: + loc = c.section or c.title or c.source + label = loc if loc == c.source else f"{c.source} — {loc}" + if label not in locs: + locs.append(label) + print(f"{DIM} Источники FAQ: {MAGENTA}{' · '.join(locs)}{RESET}") + elif not result.used_faq: + print(f"{DIM} (в FAQ по запросу ничего не нашлось){RESET}") + + if result.truncated: + print(f"{YELLOW} (tool-loop прерван по лимиту итераций){RESET}") + print() diff --git a/cli/views.py b/cli/views.py index 2946809..6c46f97 100644 --- a/cli/views.py +++ b/cli/views.py @@ -362,6 +362,7 @@ def print_help() -> None: {CYAN}/help{RESET} — эта справка {CYAN}/help <вопрос>{RESET} — ответ о проекте по документации (RAG) + git-ветка через MCP {CYAN}/review {RESET} — AI-ревью пул-реквеста: diff через GitHub + RAG (доки+код) + {CYAN}/support <вопрос> [#T-1024]{RESET} — поддержка: ответ по FAQ (RAG) + контекст тикета (MCP) {DIM}Что куда сохраняется: краткосрочная → текущий диалог (messages), авто diff --git a/docs/support-faq/account.md b/docs/support-faq/account.md new file mode 100644 index 0000000..790d889 --- /dev/null +++ b/docs/support-faq/account.md @@ -0,0 +1,24 @@ +# Аккаунт и данные + +## Изменить email + +«Профиль → Учётная запись → Email». На новый адрес придёт письмо для +подтверждения; до подтверждения вход остаётся по старому email. + +## Удалить аккаунт + +«Профиль → Учётная запись → Удалить аккаунт». Аккаунт и данные удаляются +безвозвратно через 30 дней; в течение этого срока удаление можно отменить, +просто снова войдя в систему. + +## Экспорт данных + +Выгрузить свои данные можно в «Профиль → Приватность → Экспорт». Архив +формируется до 24 часов, ссылка на скачивание приходит на email. + +## Приложение тормозит или не открывается + +- обновите приложение до последней версии; +- очистите кэш в настройках приложения; +- на iOS/Android переустановите приложение, если проблема сохраняется; +- если не помогает — напишите в поддержку, указав платформу и версию. diff --git a/docs/support-faq/auth.md b/docs/support-faq/auth.md new file mode 100644 index 0000000..c9a3360 --- /dev/null +++ b/docs/support-faq/auth.md @@ -0,0 +1,34 @@ +# Авторизация и вход + +## Вход через Google (SSO) + +Вход через Google (SSO) доступен на тарифах **Business** и **Enterprise**. +На тарифе **Free** единый вход (SSO) отключён — при попытке войти через Google +пользователь получает ошибку **403 Forbidden**. + +Что делать: +- на тарифе Free войдите по email и паролю; +- чтобы включить вход через Google, перейдите на тариф Business в разделе + «Тарифы и оплата»; +- если SSO уже входит в ваш тариф, но ошибка 403 сохраняется — попросите + администратора рабочего пространства добавить ваш домен в список + разрешённых для SSO. + +## Ошибка 403 при входе + +Код **403** при авторизации означает, что способ входа запрещён для текущего +аккаунта или тарифа. Частые причины: +- SSO недоступен на тарифе Free (см. раздел про вход через Google); +- домен email не добавлен в разрешённые для единого входа; +- аккаунт заблокирован после нескольких неудачных попыток — подождите 15 минут. + +## Забыли пароль + +Нажмите «Забыли пароль?» на экране входа и введите email. Письмо для сброса +приходит в течение 5 минут; проверьте папку «Спам». Ссылка действует 1 час. + +## Двухфакторная аутентификация (2FA) + +2FA настраивается в «Профиль → Безопасность». Если вы потеряли доступ к +приложению-аутентификатору, используйте резервные коды, сохранённые при +включении 2FA. diff --git a/docs/support-faq/billing.md b/docs/support-faq/billing.md new file mode 100644 index 0000000..8e355a3 --- /dev/null +++ b/docs/support-faq/billing.md @@ -0,0 +1,28 @@ +# Тарифы и оплата + +## Тарифы продукта + +- **Free** — базовые функции, вход только по email и паролю, без SSO. +- **Business** — командная работа, единый вход (SSO, включая Google), + приоритетная поддержка. +- **Enterprise** — всё из Business плюс SLA, управление доступом на уровне + организации и выделенный менеджер. + +## Как перейти на другой тариф + +Откройте «Тарифы и оплата → Сменить тариф», выберите нужный тариф и подтвердите +оплату. Повышение тарифа вступает в силу сразу; при понижении новый тариф +начинает действовать с конца оплаченного периода. + +## Месячная и годовая подписка + +Годовая подписка дешевле месячной примерно на 20%. Чтобы перейти с месячной +оплаты на годовую, в разделе «Тарифы и оплата» выберите период оплаты «Год». +Разница за оставшийся оплаченный период пересчитывается автоматически +(пропорционально). + +## Возврат средств + +Вернуть деньги можно в течение 14 дней с момента оплаты, если тарифом почти не +пользовались. Напишите в поддержку с номером счёта — возврат приходит на исходный +способ оплаты за 3–5 рабочих дней. diff --git a/examples/support_agent_demo.py b/examples/support_agent_demo.py new file mode 100644 index 0000000..62ff42f --- /dev/null +++ b/examples/support_agent_demo.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Демо AI-ассистента поддержки: вопрос + номер тикета → адресный ответ. + +Собирает мини-сервис поддержки из трёх частей проекта: + • RAG по FAQ — MarkdownFaqRetrievalEngine над docs/support-faq/*.md; + • стор тикетов (MCP) — TicketStoreClient (in-process, JSON users/tickets), + поднятый как обычный MCP-сервер в реестре; + • tool-loop — ToolRouter: агент сам вызывает support__get_ticket / + support__get_user, чтобы учесть контекст обращения. + +Сценарий из задания: «Почему не работает авторизация? #T-1024». Тикет T-1024 +завёл пользователь на тарифе Free, который входит через Google → это и есть +причина ошибки 403 (SSO недоступен на Free). Агент поднимает тикет, читает +FAQ и отвечает адресно. + +Запуск: + python3 examples/support_agent_demo.py # реальный DeepSeek, если есть ключ + JARVIS_DEMO_SCRIPTED=1 python3 examples/support_agent_demo.py # без ключа, детерминированно + +Без DEEPSEEK_API_KEY автоматически включается скриптованный режим. +""" +from __future__ import annotations + +import json +import os +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, ROOT) + +from app.support_assistant import answer_support_question # noqa: E402 +from app.tool_router import ToolRouter # noqa: E402 +from cli.config import DEEPSEEK_CHAT_URL, load_env # noqa: E402 +from infra.deepseek_client import DeepSeekClient # noqa: E402 +from infra.faq_retrieval import MarkdownFaqRetrievalEngine # noqa: E402 +from infra.ticket_store_client import TicketStoreClient # noqa: E402 +from infra.mcp_registry import StdioMcpRegistry # noqa: E402 + + +QUESTION = "Почему не работает авторизация?" +TICKET = "T-1024" + +SAMPLE = { + "users": [ + {"id": "U-100", "name": "Иван Петров", "email": "ivan@example.com", + "plan": "Free", "auth_method": "SSO (Google)", "platform": "web"}, + ], + "tickets": [ + {"id": "T-1024", "user_id": "U-100", "status": "open", "priority": "high", + "product_area": "auth", "error_code": "403", + "subject": "Не могу войти через Google", + "messages": [ + {"author": "user", "text": "При входе через Google выдаёт ошибку 403."}, + ]}, + ], +} + + +class _EmptyRepo: + def list_all(self): + return [] + + +class _ScriptedLLM: + """Детерминированная замена LLM: те же tool_calls, что сделал бы агент.""" + + def __init__(self): + self._script = [ + {"content": None, "tool_calls": [self._call( + "s1", "support__get_ticket", {"ticket_id": "T-1024"})]}, + {"content": None, "tool_calls": [self._call( + "s2", "support__get_user", {"user_id": "U-100"})]}, + {"content": ( + "Иван, вход через Google (SSO) на вашем тарифе Free недоступен — " + "поэтому и возникает ошибка 403. Варианты: войдите по email и " + "паролю, либо перейдите на тариф Business, где SSO включён " + "(раздел FAQ «Вход через Google (SSO)»).")}, + ] + + @staticmethod + def _call(cid, name, args): + return {"id": cid, "type": "function", + "function": {"name": name, "arguments": json.dumps(args, ensure_ascii=False)}} + + def chat(self, messages, params, system_prompt=None): + return self._script.pop(0).get("content") or "" + + def chat_with_tools(self, messages, params, tools, system_prompt=None): + return self._script.pop(0) + + +def _make_llm(): + api_key = os.environ.get("DEEPSEEK_API_KEY") + scripted = os.environ.get("JARVIS_DEMO_SCRIPTED") or not api_key + if scripted: + print("[режим] скриптованный LLM (детерминированно, без сети)\n") + return _ScriptedLLM(), {"model": "scripted"} + print("[режим] реальный DeepSeek\n") + return (DeepSeekClient(api_key=api_key, chat_url=DEEPSEEK_CHAT_URL), + {"model": "deepseek-chat", "temperature": 0.2}) + + +def main() -> int: + load_env(os.path.join(ROOT, ".env")) + + faq = MarkdownFaqRetrievalEngine(os.path.join(ROOT, "docs", "support-faq")) + registry = StdioMcpRegistry(_EmptyRepo()) + registry.start_all() + registry.register(TicketStoreClient(data=SAMPLE)) + + try: + llm, params = _make_llm() + support_chat = ToolRouter(llm, registry, max_iterations=8) + + print("Тулы поддержки:", + ", ".join(t.qualified_name for t in registry.all_tools())) + print("FAQ готов:", faq.is_ready()) + print(f"\nВОПРОС: {QUESTION} (тикет {TICKET})\n" + "─" * 60) + + ans = answer_support_question(QUESTION, TICKET, faq, support_chat, params) + + print("MCP-тулы, которые вызвал агент:") + for tu in ans.tools_used: + mark = "✗" if tu.is_error else "✓" + print(f" {mark} {tu.server_id}.{tu.tool_name} " + f"{json.dumps(tu.arguments, ensure_ascii=False)}") + print("\nИсточники FAQ:") + for c in ans.sources: + print(f" • {c.source} — {c.section}") + print("─" * 60) + print("ОТВЕТ:\n" + ans.reply) + return 0 + finally: + registry.shutdown() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/infra/faq_retrieval.py b/infra/faq_retrieval.py new file mode 100644 index 0000000..a4608af --- /dev/null +++ b/infra/faq_retrieval.py @@ -0,0 +1,149 @@ +"""RAG-поиск по FAQ/документации поддержки без внешних зависимостей. + +Реализует порт `app.ports.RetrievalEngine` (те же `retrieve`/`is_ready`), +поэтому подставляется в use case и в `RetrievalPipeline` вместо +FAISS+ollama движка. Здесь — лёгкий лексический поиск по markdown-файлам: +каталог `docs/support-faq/*.md` разбивается на чанки по заголовкам `##`, +а близость к запросу считается как перекрытие терминов (с bias на +совпадения в заголовке раздела). Ноль внешних зависимостей — feature +работает «из коробки» и детерминированно тестируется. + +Для «настоящего» векторного RAG движок взаимозаменяем: тот же порт +реализует `infra/rag_retrieval.py::FaissOllamaRetrievalEngine` — достаточно +собрать FAISS-индекс по этим же .md и подсунуть его в composition root. +""" +from __future__ import annotations + +import re +from pathlib import Path +from typing import Optional + +from domain.retrieval import RetrievedChunk + +# Слова, которые не несут смысла для лексического матчинга (рус/англ стоп-лист). +_STOP = { + "и", "в", "во", "не", "на", "по", "с", "со", "а", "но", "же", "как", "что", + "это", "у", "к", "о", "об", "за", "из", "для", "то", "так", "или", "бы", + "почему", "мой", "моя", "мне", "меня", "я", "ты", "вы", "он", "она", + "the", "a", "an", "is", "are", "to", "of", "in", "on", "for", "and", "or", + "why", "how", "what", "my", "i", "it", "do", "does", +} +_WORD_RE = re.compile(r"[a-zA-Zа-яА-ЯёЁ0-9_]+") + + +def _tokens(text: str) -> list[str]: + return [w.lower() for w in _WORD_RE.findall(text) if len(w) > 1] + + +def _terms(text: str) -> set[str]: + return {w for w in _tokens(text) if w not in _STOP} + + +class _FaqChunk: + """Разобранный раздел FAQ + предпосчитанное множество терминов.""" + + __slots__ = ("text", "source", "title", "section", "terms", "title_terms") + + def __init__(self, text: str, source: str, title: str, section: str): + self.text = text + self.source = source + self.title = title + self.section = section + self.terms = _terms(text) + self.title_terms = _terms(f"{title} {section}") + + +class MarkdownFaqRetrievalEngine: + """Лексический RAG по каталогу markdown-файлов FAQ. Реализует RetrievalEngine.""" + + def __init__(self, faq_dir: str, min_overlap: int = 1): + self._dir = Path(faq_dir).expanduser() + self._min_overlap = min_overlap + self._chunks: Optional[list[_FaqChunk]] = None + + # ── RetrievalEngine ─────────────────────────────────────────────────────── + + def is_ready(self) -> bool: + try: + return self._dir.is_dir() and bool(self._load()) + except OSError: + return False + + def retrieve(self, query: str, top_k: int = 5) -> list[RetrievedChunk]: + chunks = self._load() + if not chunks: + return [] + q_terms = _terms(query) + if not q_terms: + return [] + scored: list[tuple[float, _FaqChunk]] = [] + for ch in chunks: + overlap = len(q_terms & ch.terms) + # Совпадение в заголовке раздела ценнее совпадения в теле — и само + # по себе достаточно, чтобы раздел прошёл порог (термин из вопроса + # может стоять только в заголовке документа/раздела). + title_hits = len(q_terms & ch.title_terms) + if overlap + title_hits < self._min_overlap: + continue + score = overlap + 2.0 * title_hits + # Нормируем на длину запроса — грубый аналог косинуса в [0..~1+]. + score = score / (len(q_terms) + 1e-9) + scored.append((score, ch)) + scored.sort(key=lambda p: p[0], reverse=True) + out: list[RetrievedChunk] = [] + for score, ch in scored[:top_k]: + out.append(RetrievedChunk( + text=ch.text, + source=ch.source, + title=ch.title, + section=ch.section, + score=round(float(score), 4), + )) + return out + + # ── загрузка/парсинг ────────────────────────────────────────────────────── + + def _load(self) -> list[_FaqChunk]: + if self._chunks is not None: + return self._chunks + chunks: list[_FaqChunk] = [] + if self._dir.is_dir(): + for md in sorted(self._dir.rglob("*.md")): + try: + raw = md.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + rel = md.name + chunks.extend(self._split(raw, rel)) + self._chunks = chunks + return chunks + + @staticmethod + def _split(raw: str, source: str) -> list[_FaqChunk]: + """Разбить markdown на разделы: заголовок `## ...` начинает новый чанк.""" + lines = raw.splitlines() + title = "" + section = "" + buf: list[str] = [] + out: list[_FaqChunk] = [] + + def flush(): + body = "\n".join(buf).strip() + if body: + out.append(_FaqChunk(text=body, source=source, title=title, section=section)) + + for line in lines: + if line.startswith("# ") and not line.startswith("## "): + # H1 — заголовок документа; тело до первого H2 тоже сохраняем. + flush() + buf = [] + title = line[2:].strip() + section = "" + elif line.startswith("## "): + flush() + buf = [] + section = line[3:].strip() + else: + buf.append(line) + flush() + return out diff --git a/infra/ticket_store_client.py b/infra/ticket_store_client.py new file mode 100644 index 0000000..f5ffa85 --- /dev/null +++ b/infra/ticket_store_client.py @@ -0,0 +1,259 @@ +"""In-process источник тулов поддержки над JSON с пользователями и тикетами. + +Реализует тот же протокол `McpClient` (`app.ports.McpClient`), что и +stdio/http-клиенты, но вместо JSON-RPC к внешнему серверу читает данные из +локального JSON-файла. За счёт этого он бесшовно встаёт в `McpRegistry` +(`register()`), а `ToolRouter` отдаёт его тулы модели и маршрутизирует +вызовы `support__` сюда — как к обычной MCP-CRM. Никаких правок в +tool-loop не требуется: «агент сам выбирает инструмент» уже работает. + +Это и есть «подключить CRM или JSON с пользователями/тикетами через MCP» +из задания — только вместо реального CRM за портом стоит файл, что удобно +для демо и тестов. Заменить на реальный CRM = поднять внешний MCP-сервер и +включить его в конфиге, use case (`app/support_assistant.py`) не изменится. + +Формат файла (`~/.jarvis/support/tickets.json` по умолчанию): + + { + "users": [{"id": "U-100", "name": ..., "plan": ..., "auth_method": ...}], + "tickets": [{"id": "T-1024", "user_id": "U-100", "status": "open", + "product_area": "auth", "subject": ..., + "messages": [{"author": "user", "text": ...}]}] + } + +Тулы (агент вызывает их сам в tool-loop): + • get_ticket — тикет по id + профиль автора + переписка + • get_user — профиль пользователя + список его тикетов + • search_tickets — похожие обращения по тексту / статусу / области продукта +""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Optional + +from domain.mcp import McpTool, ToolResult + + +class TicketStoreError(Exception): + """Проблема с файлом-хранилищем тикетов (нет файла, битый JSON).""" + + +class TicketStoreClient: + """Тулы поддержки над JSON-файлом `path`. + + Данные читаются один раз в `start()` и держатся в памяти. Для демо/тестов + можно передать `data=` напрямую — тогда файл не читается. + """ + + def __init__(self, + path: Optional[str] = None, + server_id: str = "support", + data: Optional[dict] = None, + max_results: int = 20): + self.server_id = server_id + self._path = Path(path).expanduser() if path else None + self._max = max_results + self._users: dict[str, dict] = {} + self._tickets: dict[str, dict] = {} + if data is not None: + self._ingest(data) + + # ── жизненный цикл (McpClient) ──────────────────────────────────────────── + + def start(self) -> None: + # data= уже загружены в конструкторе — файл не обязателен. + if self._users or self._tickets: + return + if self._path is None: + raise TicketStoreError("не задан ни путь к файлу тикетов, ни data=") + if not self._path.is_file(): + raise TicketStoreError(f"нет файла хранилища тикетов: {self._path}") + try: + raw = json.loads(self._path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as e: + raise TicketStoreError(f"не читается {self._path}: {e}") from e + self._ingest(raw) + + def close(self) -> None: + pass + + def _ingest(self, raw: dict) -> None: + if not isinstance(raw, dict): + raise TicketStoreError("корень JSON должен быть объектом с users/tickets") + self._users = {u["id"]: u for u in raw.get("users", []) if u.get("id")} + self._tickets = {t["id"]: t for t in raw.get("tickets", []) if t.get("id")} + + # ── описание тулов ──────────────────────────────────────────────────────── + + def list_tools(self) -> list[McpTool]: + return [ + self._tool("get_ticket", + "Получить тикет поддержки по идентификатору: тему, статус, " + "приоритет, область продукта, код ошибки, всю переписку и " + "профиль автора (тариф, способ входа). Вызывай первым, если " + "в вопросе упомянут номер тикета вида T-1024.", + {"ticket_id": {"type": "string", + "description": "Идентификатор тикета, напр. 'T-1024'."}}, + required=["ticket_id"]), + self._tool("get_user", + "Получить профиль пользователя по идентификатору: имя, email, " + "тариф, способ авторизации, платформу — и список его тикетов. " + "Используй, чтобы учесть контекст пользователя в ответе.", + {"user_id": {"type": "string", + "description": "Идентификатор пользователя, напр. 'U-100'."}}, + required=["user_id"]), + self._tool("search_tickets", + "Найти похожие обращения по подстроке в теме/тексте, а также " + "отфильтровать по статусу (open/pending/closed) и области " + "продукта (auth/billing/...). Полезно, чтобы сослаться на " + "уже решённые похожие тикеты.", + {"query": {"type": "string", + "description": "Подстрока для поиска в теме и сообщениях (необязательно).", + "default": ""}, + "status": {"type": "string", + "description": "Фильтр по статусу: open/pending/closed.", + "default": ""}, + "product_area": {"type": "string", + "description": "Фильтр по области продукта, напр. 'auth'.", + "default": ""}}), + ] + + def _tool(self, name: str, description: str, properties: dict, + required: Optional[list] = None) -> McpTool: + return McpTool( + server_id = self.server_id, + name = name, + description = description, + input_schema = { + "type": "object", + "properties": properties, + "required": required or [], + }, + ) + + # ── вызов тула (McpClient) ──────────────────────────────────────────────── + + def call_tool(self, name: str, arguments: dict) -> ToolResult: + args = arguments or {} + try: + if name == "get_ticket": + return self._get_ticket(str(args["ticket_id"]).strip()) + if name == "get_user": + return self._get_user(str(args["user_id"]).strip()) + if name == "search_tickets": + return self._search_tickets( + query = str(args.get("query", "") or "").strip(), + status = str(args.get("status", "") or "").strip().lower(), + product_area = str(args.get("product_area", "") or "").strip().lower(), + ) + except KeyError as e: + return ToolResult(text=f"Отсутствует обязательный аргумент: {e}", is_error=True) + return ToolResult(text=f"Неизвестный тул: {name}", is_error=True) + + # ── реализация тулов ────────────────────────────────────────────────────── + + def _get_ticket(self, ticket_id: str) -> ToolResult: + ticket = self._tickets.get(ticket_id) or self._find_ci(self._tickets, ticket_id) + if ticket is None: + return ToolResult( + text=f"Тикет {ticket_id!r} не найден. Доступные: " + f"{', '.join(sorted(self._tickets)) or '(нет)'}", + is_error=True) + user = self._users.get(ticket.get("user_id", "")) + return ToolResult(text=self._format_ticket(ticket, user)) + + def _get_user(self, user_id: str) -> ToolResult: + user = self._users.get(user_id) or self._find_ci(self._users, user_id) + if user is None: + return ToolResult( + text=f"Пользователь {user_id!r} не найден. Доступные: " + f"{', '.join(sorted(self._users)) or '(нет)'}", + is_error=True) + their = [t for t in self._tickets.values() if t.get("user_id") == user["id"]] + return ToolResult(text=self._format_user(user, their)) + + def _search_tickets(self, query: str, status: str, product_area: str) -> ToolResult: + q = query.lower() + hits: list[dict] = [] + for t in self._tickets.values(): + if status and str(t.get("status", "")).lower() != status: + continue + if product_area and str(t.get("product_area", "")).lower() != product_area: + continue + if q and q not in self._ticket_haystack(t): + continue + hits.append(t) + if len(hits) >= self._max: + break + if not hits: + return ToolResult(text="Похожих тикетов не найдено под заданные фильтры.") + lines = [f"Найдено тикетов: {len(hits)}"] + for t in hits: + lines.append( + f" {t.get('id')} [{t.get('status', '?')}/{t.get('product_area', '-')}] " + f"{t.get('subject', '(без темы)')}") + return ToolResult(text="\n".join(lines)) + + # ── форматирование (чистое) ─────────────────────────────────────────────── + + @staticmethod + def _ticket_haystack(t: dict) -> str: + parts = [str(t.get("subject", "")), str(t.get("error_code", ""))] + for m in t.get("messages", []) or []: + parts.append(str(m.get("text", ""))) + return " ".join(parts).lower() + + @staticmethod + def _format_ticket(t: dict, user: Optional[dict]) -> str: + lines = [ + f"Тикет {t.get('id')}", + f" Тема: {t.get('subject', '(без темы)')}", + f" Статус: {t.get('status', '?')} Приоритет: {t.get('priority', '-')}", + f" Область: {t.get('product_area', '-')}", + ] + if t.get("error_code"): + lines.append(f" Код ошибки: {t.get('error_code')}") + if t.get("created_at"): + lines.append(f" Создан: {t.get('created_at')}") + if user: + lines.append( + f" Автор: {user.get('name', user.get('id'))} " + f"(тариф {user.get('plan', '-')}, вход через {user.get('auth_method', '-')}, " + f"платформа {user.get('platform', '-')})") + else: + lines.append(f" Автор: {t.get('user_id', '-')} (профиль не найден)") + msgs = t.get("messages") or [] + if msgs: + lines.append(" Переписка:") + for m in msgs: + lines.append(f" [{m.get('author', '?')}] {m.get('text', '')}") + return "\n".join(lines) + + @staticmethod + def _format_user(u: dict, tickets: list[dict]) -> str: + lines = [ + f"Пользователь {u.get('id')}", + f" Имя: {u.get('name', '-')}", + f" Email: {u.get('email', '-')}", + f" Тариф: {u.get('plan', '-')}", + f" Вход: {u.get('auth_method', '-')}", + f" Платформа: {u.get('platform', '-')}", + ] + if tickets: + lines.append(" Тикеты:") + for t in tickets: + lines.append( + f" {t.get('id')} [{t.get('status', '?')}] {t.get('subject', '')}") + else: + lines.append(" Тикеты: (нет)") + return "\n".join(lines) + + @staticmethod + def _find_ci(index: dict, key: str) -> Optional[Any]: + """Регистронезависимый поиск по ключу (LLM может прислать 't-1024').""" + low = key.lower() + for k, v in index.items(): + if k.lower() == low: + return v + return None diff --git a/tests/app/test_support_assistant.py b/tests/app/test_support_assistant.py new file mode 100644 index 0000000..eda04b6 --- /dev/null +++ b/tests/app/test_support_assistant.py @@ -0,0 +1,158 @@ +"""Юнит-тесты use case /support — ответ по FAQ + контекст тикета через MCP. + +Порты (RetrievalEngine, SupportChat) подменены фейками: без FAISS, MCP и сети. +Проверяем сборку контекста, проброс FAQ и тикета в модель и агрегацию следа +tool-loop в результат. +""" +from __future__ import annotations + +from dataclasses import dataclass, field + +from app.support_assistant import ( + PlainChatAdapter, + SupportAnswer, + answer_support_question, +) +from domain.retrieval import RetrievedChunk + + +@dataclass +class _FakeEngine: + chunks: list = field(default_factory=list) + ready: bool = True + last_query: str = "" + + def is_ready(self): + return self.ready + + def retrieve(self, query, top_k=5): + self.last_query = query + return list(self.chunks) + + +@dataclass +class _Inv: + server_id: str + tool_name: str + arguments: dict + is_error: bool = False + + +@dataclass +class _FakeResult: + reply: str + trace: list = field(default_factory=list) + truncated: bool = False + + +@dataclass +class _FakeChat: + """Фейковый SupportChat: возвращает заданный результат, записывает вход.""" + result: _FakeResult + last_messages: list = field(default_factory=list) + last_system: str = "" + + def chat(self, messages, params, system_prompt=None, on_event=None): + self.last_messages = messages + self.last_system = system_prompt or "" + return self.result + + +def _chunks(): + return [ + RetrievedChunk(text="Вход через Google доступен на Business. На Free — 403.", + source="auth.md", section="Вход через Google"), + ] + + +def test_faq_context_and_question_reach_the_model(): + engine = _FakeEngine(chunks=_chunks()) + chat = _FakeChat(_FakeResult(reply="На тарифе Free SSO недоступен.")) + answer_support_question("Почему не работает вход?", None, engine, chat, {}) + user_msg = chat.last_messages[0]["content"] + assert "Вход через Google" in user_msg # FAQ подмешан + assert "Почему не работает вход?" in user_msg # вопрос на месте + assert engine.last_query == "Почему не работает вход?" + + +def test_ticket_hint_instructs_tool_call(): + engine = _FakeEngine(chunks=_chunks()) + chat = _FakeChat(_FakeResult(reply="ok")) + answer_support_question("Не могу войти", "T-1024", engine, chat, {}) + user_msg = chat.last_messages[0]["content"] + assert "T-1024" in user_msg + assert "support__get_ticket" in user_msg # явная подсказка агенту + + +def test_returns_sources_and_ticket_id(): + engine = _FakeEngine(chunks=_chunks()) + chat = _FakeChat(_FakeResult(reply="ответ")) + ans = answer_support_question("вопрос", "T-1024", engine, chat, {}) + assert isinstance(ans, SupportAnswer) + assert ans.reply == "ответ" + assert ans.ticket_id == "T-1024" + assert ans.used_faq is True + assert ans.sources and ans.sources[0].source == "auth.md" + + +def test_tool_trace_is_aggregated(): + engine = _FakeEngine(chunks=[]) + result = _FakeResult( + reply="готово", + trace=[ + _Inv("support", "get_ticket", {"ticket_id": "T-1024"}), + _Inv("support", "get_user", {"user_id": "U-100"}, is_error=True), + ], + ) + ans = answer_support_question("q", "T-1024", engine, _FakeChat(result), {}) + assert [t.tool_name for t in ans.tools_used] == ["get_ticket", "get_user"] + assert ans.tools_used[1].is_error is True + + +def test_no_faq_hits_marks_used_faq_false(): + chat = _FakeChat(_FakeResult(reply="не нашёл в FAQ")) + ans = answer_support_question("экзотика", None, _FakeEngine(chunks=[]), chat, {}) + assert ans.used_faq is False + assert "ничего не нашлось" in chat.last_messages[0]["content"] + + +def test_engine_not_ready_is_tolerated(): + engine = _FakeEngine(chunks=_chunks(), ready=False) + chat = _FakeChat(_FakeResult(reply="ok")) + ans = answer_support_question("вопрос", None, engine, chat, {}) + assert ans.sources == [] # поиск не запускался + assert ans.reply == "ok" + + +def test_truncated_flag_propagates(): + chat = _FakeChat(_FakeResult(reply="...", truncated=True)) + ans = answer_support_question("q", None, _FakeEngine(), chat, {}) + assert ans.truncated is True + + +def test_system_prompt_mentions_faq_and_tools(): + chat = _FakeChat(_FakeResult(reply="ok")) + answer_support_question("q", None, _FakeEngine(), chat, {}) + assert "FAQ" in chat.last_system + assert "support__get_ticket" in chat.last_system + + +# ── PlainChatAdapter (деградация без tool-loop) ────────────────────────────── + +@dataclass +class _FakeLLM: + reply: str = "ответ по FAQ" + last_system: str = "" + + def chat(self, messages, params, system_prompt=None): + self.last_system = system_prompt or "" + return self.reply + + +def test_plain_adapter_answers_from_faq_without_tools(): + llm = _FakeLLM() + engine = _FakeEngine(chunks=_chunks()) + ans = answer_support_question("вопрос", None, engine, PlainChatAdapter(llm), {}) + assert ans.reply == "ответ по FAQ" + assert ans.tools_used == [] # тулов нет — деградация + assert ans.sources # но FAQ подмешан diff --git a/tests/infra/test_faq_retrieval.py b/tests/infra/test_faq_retrieval.py new file mode 100644 index 0000000..295ea8b --- /dev/null +++ b/tests/infra/test_faq_retrieval.py @@ -0,0 +1,70 @@ +"""Тесты лексического RAG по FAQ (MarkdownFaqRetrievalEngine). + +Проверяем разбор markdown на разделы, ранжирование по перекрытию терминов и +приоритет совпадений в заголовке — на временном каталоге. +""" +from __future__ import annotations + +from infra.faq_retrieval import MarkdownFaqRetrievalEngine + + +def _faq(tmp_path): + (tmp_path / "auth.md").write_text( + "# Авторизация\n\n" + "## Вход через Google\n" + "Вход через Google доступен на тарифе Business. На Free выдаёт 403.\n\n" + "## Забыли пароль\n" + "Нажмите «Забыли пароль» и введите email.\n", + encoding="utf-8") + (tmp_path / "billing.md").write_text( + "# Оплата\n\n" + "## Годовая подписка\n" + "Годовая подписка дешевле месячной на 20%.\n", + encoding="utf-8") + return MarkdownFaqRetrievalEngine(str(tmp_path)) + + +def test_is_ready_true_when_dir_has_md(tmp_path): + assert _faq(tmp_path).is_ready() + + +def test_is_ready_false_for_empty_dir(tmp_path): + assert not MarkdownFaqRetrievalEngine(str(tmp_path)).is_ready() + + +def test_splits_into_sections(tmp_path): + eng = _faq(tmp_path) + hits = eng.retrieve("вход через Google 403", top_k=10) + sections = {h.section for h in hits} + assert "Вход через Google" in sections + + +def test_ranks_relevant_section_first(tmp_path): + eng = _faq(tmp_path) + hits = eng.retrieve("почему ошибка 403 при входе через Google", top_k=3) + assert hits + assert hits[0].section == "Вход через Google" + assert hits[0].source == "auth.md" + + +def test_title_match_outranks_body(tmp_path): + eng = _faq(tmp_path) + hits = eng.retrieve("годовая подписка", top_k=1) + assert hits[0].section == "Годовая подписка" + + +def test_no_terms_returns_empty(tmp_path): + eng = _faq(tmp_path) + assert eng.retrieve("и в на", top_k=5) == [] + + +def test_irrelevant_query_returns_nothing(tmp_path): + eng = _faq(tmp_path) + assert eng.retrieve("квантовая криптография сатурн", top_k=5) == [] + + +def test_top_k_limits_results(tmp_path): + eng = _faq(tmp_path) + # запрос, задевающий несколько разделов + hits = eng.retrieve("вход пароль подписка", top_k=1) + assert len(hits) <= 1 diff --git a/tests/infra/test_ticket_store_client.py b/tests/infra/test_ticket_store_client.py new file mode 100644 index 0000000..ab51d5b --- /dev/null +++ b/tests/infra/test_ticket_store_client.py @@ -0,0 +1,126 @@ +"""Тесты in-process MCP-стора тикетов (TicketStoreClient). + +Проверяем загрузку из JSON, три тула и обработку ошибок — на временном файле +и на прямых data=, без сети и подпроцессов. +""" +from __future__ import annotations + +import json + +import pytest + +from infra.ticket_store_client import TicketStoreClient, TicketStoreError + + +DATA = { + "users": [ + {"id": "U-100", "name": "Иван", "email": "ivan@ex.com", + "plan": "Free", "auth_method": "SSO (Google)", "platform": "web"}, + {"id": "U-200", "name": "Мария", "plan": "Business"}, + ], + "tickets": [ + {"id": "T-1024", "user_id": "U-100", "status": "open", "priority": "high", + "product_area": "auth", "error_code": "403", + "subject": "Не могу войти через Google", + "messages": [{"author": "user", "text": "ошибка 403 при входе"}]}, + {"id": "T-1042", "user_id": "U-200", "status": "pending", + "product_area": "billing", "subject": "Годовая оплата", + "messages": [{"author": "user", "text": "хочу годовую подписку"}]}, + ], +} + + +def _client(data=DATA): + c = TicketStoreClient(data=data) + c.start() + return c + + +def test_lists_three_tools(): + names = {t.name for t in _client().list_tools()} + assert names == {"get_ticket", "get_user", "search_tickets"} + + +def test_tools_are_namespaced_to_server(): + tools = _client().list_tools() + assert all(t.server_id == "support" for t in tools) + assert all(t.qualified_name.startswith("support__") for t in tools) + + +def test_get_ticket_includes_author_profile_and_messages(): + res = _client().call_tool("get_ticket", {"ticket_id": "T-1024"}) + assert not res.is_error + # Ключевой контекст для ответа: тариф Free + SSO объясняют 403. + assert "T-1024" in res.text + assert "Free" in res.text and "SSO" in res.text + assert "403" in res.text + assert "ошибка 403 при входе" in res.text + + +def test_get_ticket_is_case_insensitive(): + res = _client().call_tool("get_ticket", {"ticket_id": "t-1024"}) + assert not res.is_error + assert "T-1024" in res.text + + +def test_get_ticket_unknown_is_error_and_lists_available(): + res = _client().call_tool("get_ticket", {"ticket_id": "T-9999"}) + assert res.is_error + assert "T-1024" in res.text # подсказка со списком доступных + + +def test_get_user_lists_their_tickets(): + res = _client().call_tool("get_user", {"user_id": "U-100"}) + assert not res.is_error + assert "Иван" in res.text + assert "T-1024" in res.text + + +def test_search_by_product_area(): + res = _client().call_tool("search_tickets", {"product_area": "auth"}) + assert "T-1024" in res.text + assert "T-1042" not in res.text + + +def test_search_by_status_and_query(): + res = _client().call_tool("search_tickets", {"status": "pending", "query": "годовую"}) + assert "T-1042" in res.text + assert "T-1024" not in res.text + + +def test_search_no_match_is_not_error(): + res = _client().call_tool("search_tickets", {"query": "нет-такого"}) + assert not res.is_error + assert "не найдено" in res.text.lower() + + +def test_missing_required_arg_is_error(): + res = _client().call_tool("get_ticket", {}) + assert res.is_error + + +def test_unknown_tool_is_error(): + res = _client().call_tool("nope", {}) + assert res.is_error + + +def test_start_reads_from_file(tmp_path): + p = tmp_path / "tickets.json" + p.write_text(json.dumps(DATA, ensure_ascii=False), encoding="utf-8") + c = TicketStoreClient(path=str(p)) + c.start() + assert not c.call_tool("get_ticket", {"ticket_id": "T-1024"}).is_error + + +def test_start_missing_file_raises(tmp_path): + c = TicketStoreClient(path=str(tmp_path / "nope.json")) + with pytest.raises(TicketStoreError): + c.start() + + +def test_start_bad_json_raises(tmp_path): + p = tmp_path / "bad.json" + p.write_text("{ not json", encoding="utf-8") + c = TicketStoreClient(path=str(p)) + with pytest.raises(TicketStoreError): + c.start()