Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`, не в репо
Expand Down
162 changes: 162 additions & 0 deletions app/support_assistant.py
Original file line number Diff line number Diff line change
@@ -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),
)
2 changes: 2 additions & 0 deletions cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
81 changes: 81 additions & 0 deletions cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"""
from __future__ import annotations

import json
import os
import sys
from typing import Optional
Expand Down Expand Up @@ -42,6 +43,7 @@
OLLAMA,
OLLAMA_BASE_URL,
PROFILES_DIR,
SUPPORT_TICKETS_FILE,
TASKS_DIR,
WORKING_DIR,
DEFAULT_EMBED_MODEL,
Expand Down Expand Up @@ -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
Expand All @@ -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-клиент для выбранного провайдера.

Expand Down Expand Up @@ -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:
Expand All @@ -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()

Expand Down Expand Up @@ -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:
Expand All @@ -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]
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading