feat(sprint2): диалог с памятью треда — POST /chat + CRUD тредов
Второй кусок Спринта 2: агент теперь помнит контекст. RAG-retrieval
делается по последней реплике пациента, в LLM уходит системный промпт +
последние 20 сообщений треда + новая реплика + найденные фрагменты.
Backend:
- services/chat_service: send_message — создаёт тред при необходимости
(auto-имя из первой реплики + UTC-дата), сохраняет user-реплику до
вызова LLM (чтобы не потерять при сбое), делает retrieval, грузит
историю треда (desc/limit 20 → reversed для хронологии), зовёт
llm.chat, сохраняет ответ ассистента вместе с sources_json и
assembled_prompt, обновляет thread.updated_at. Плюс list_threads с
JOIN-выборкой превью первой реплики и счётчика сообщений,
get_thread_detail через selectinload, rename_thread, delete_thread
(CASCADE на FK делает уборку сообщений автоматически, но
explicit delete оставлен для подсчёта удалённых).
- services/llm_client.chat: принимает history=[{role, content}, ...],
собирает messages = [system, ...history, user-с-RAG]; assembled_prompt
дампит всю цепочку в виде [SYSTEM]/[USER]/[ASSISTANT]-блоков для
отображения в Debug UI.
- routers/chat: POST /chat, обрабатывает LookupError → 404.
- routers/threads: GET /threads, GET /threads/{id}, PATCH /threads/{id}
(переименовать), DELETE /threads/{id}.
- models: ChatRequest, ThreadRenameRequest; ChatResponse, ThreadInfo,
ThreadListResponse, ThreadDetailResponse, MessageInfo,
ThreadDeleteResponse.
Запуск:
- В lifespan main.py: автоматический alembic upgrade head через
asyncio.to_thread (сам alembic делает asyncio.run внутри, его нельзя
звать из уже работающего event loop). LLMClient инициализируется
один раз при старте — вместо создания на каждый запрос.
E2E проверено: новый тред → агент отвечает и просит представиться;
вторая реплика в том же треде — агент помнит контекст; PATCH
переименовывает; DELETE удаляет тред с каскадом на сообщения.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,15 @@ DEFAULT_USER_TEMPLATE = """Вопрос пациента:
|
||||
Ответь пациенту в чате по правилам из системного сообщения."""
|
||||
|
||||
|
||||
CHAT_USER_TEMPLATE = """Новая реплика пациента:
|
||||
{question}
|
||||
|
||||
Выдержки из базы знаний операторов (по последней реплике):
|
||||
{sources}
|
||||
|
||||
Ответь пациенту с учётом истории диалога выше и правил из системного сообщения."""
|
||||
|
||||
|
||||
class LLMClient:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -102,3 +111,64 @@ class LLMClient:
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
logger.info("LLM response: %d chars, model=%s, temp=%.2f", len(content), self.model, effective_temp)
|
||||
return {"text": content.strip(), "assembled_prompt": assembled_prompt}
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
question: str,
|
||||
sources: list[dict],
|
||||
history: list[dict],
|
||||
system_prompt: str | None = None,
|
||||
temperature: float | None = None,
|
||||
max_tokens: int | None = None,
|
||||
) -> dict:
|
||||
"""Generate a patient-facing answer using RAG + conversation history.
|
||||
|
||||
`history` — список предыдущих сообщений треда в формате
|
||||
[{"role": "user"|"assistant", "content": str}, ...] (без текущей реплики).
|
||||
|
||||
Returns dict with 'text' and 'assembled_prompt'.
|
||||
"""
|
||||
effective_system = system_prompt or DEFAULT_SYSTEM_PROMPT
|
||||
effective_temp = temperature if temperature is not None else 0.2
|
||||
effective_max_tokens = max_tokens or 1200
|
||||
|
||||
formatted_sources = self._format_sources(sources)
|
||||
user_message = CHAT_USER_TEMPLATE.format(
|
||||
question=question,
|
||||
sources=formatted_sources,
|
||||
)
|
||||
|
||||
messages: list[dict] = [{"role": "system", "content": effective_system}]
|
||||
messages.extend(history)
|
||||
messages.append({"role": "user", "content": user_message})
|
||||
|
||||
assembled_prompt_parts = [f"[SYSTEM]\n{effective_system}"]
|
||||
for m in history:
|
||||
tag = "USER" if m["role"] == "user" else "ASSISTANT"
|
||||
assembled_prompt_parts.append(f"[{tag}]\n{m['content']}")
|
||||
assembled_prompt_parts.append(f"[USER]\n{user_message}")
|
||||
assembled_prompt = "\n\n".join(assembled_prompt_parts)
|
||||
|
||||
url = f"{self.base_url}/chat/completions"
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"temperature": effective_temp,
|
||||
"max_tokens": effective_max_tokens,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
logger.info("LLM chat response: %d chars, history=%d, model=%s", len(content), len(history), self.model)
|
||||
return {"text": content.strip(), "assembled_prompt": assembled_prompt}
|
||||
|
||||
Reference in New Issue
Block a user