Files
RAG_helper/services/llm_client.py
T
AR 15 M4 4e45b8b181 feat(sprint2.5): логи, вынесение системного промпта, markdown-рендер
Три подряд доработки по плану Спринта 2.5.

1) Логи. Проблема: uvicorn ставит handlers на root-logger до того, как
отработает наш lifespan, поэтому logging.basicConfig там был no-op, и
logger.exception ничего не писал. Переносим basicConfig на уровень
импорта main.py с force=True — наш StreamHandler перебивает
uvicorn-овский root, остальные логгеры (uvicorn.access, uvicorn.error,
alembic, chromadb) остаются со своими форматами. В lifespan
basicConfig больше не зовётся.

2) Системный промпт вынесен из services/llm_client.py в
prompts/system_prompt.md. LLMClient читает файл при импорте модуля
через _load_system_prompt(); если файла нет — пустая строка + warning.
Это задел под Спринт 3, где промпт будет редактируемым и
версионируемым — физически положить его как файл дешевле, чем
держать в исходниках.

3) Markdown в ответах ассистента. Подключены marked и DOMPurify с
CDN в sandbox.html. Рендер через renderMd(text): marked.parse +
DOMPurify.sanitize — защищает от <script> на случай, если LLM вернёт
сырой HTML. Реплики пациента остаются plain text (esc). Добавлены
стили для p/ul/ol/code/pre/a/h1-h3/blockquote внутри .msg.assistant,
чтобы всё выглядело уместно в пузыре. Обёртка msg-body введена,
чтобы разделить контент и msg-meta.

План в SPRINTS.md уточнён по переиндексации — будет отдельный
endpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:53:01 +05:00

177 lines
6.2 KiB
Python

import logging
from pathlib import Path
import httpx
from config import settings
logger = logging.getLogger(__name__)
SYSTEM_PROMPT_PATH = Path(__file__).resolve().parent.parent / "prompts" / "system_prompt.md"
def _load_system_prompt() -> str:
try:
return SYSTEM_PROMPT_PATH.read_text(encoding="utf-8").strip()
except FileNotFoundError:
logger.warning("System prompt file not found at %s — using empty prompt", SYSTEM_PROMPT_PATH)
return ""
DEFAULT_SYSTEM_PROMPT = _load_system_prompt()
DEFAULT_USER_TEMPLATE = """Вопрос пациента:
{question}
Выдержки из базы знаний операторов:
{sources}
Ответь пациенту в чате по правилам из системного сообщения."""
CHAT_USER_TEMPLATE = """Новая реплика пациента:
{question}
Выдержки из базы знаний операторов (по последней реплике):
{sources}
Ответь пациенту с учётом истории диалога выше и правил из системного сообщения."""
class LLMClient:
def __init__(
self,
api_key: str | None = None,
model: str | None = None,
base_url: str | None = None,
):
self.api_key = api_key or settings.deepseek_api_key
self.model = model or settings.deepseek_model
self.base_url = (base_url or settings.deepseek_base_url).rstrip("/")
def _format_sources(self, sources: list[dict]) -> str:
if not sources:
return "(источники не найдены)"
lines = []
for i, src in enumerate(sources, 1):
meta = src.get("metadata", {})
doc_name = meta.get("document_name", "Документ")
section = meta.get("section", "")
lines.append(
f"[{i}] {src['text']}\n"
f" (Источник: {doc_name}, раздел: {section})"
)
return "\n".join(lines)
async def answer(
self,
question: str,
sources: list[dict],
system_prompt: str | None = None,
temperature: float | None = None,
max_tokens: int | None = None,
) -> dict:
"""Generate a patient-facing answer using RAG context.
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 = DEFAULT_USER_TEMPLATE.format(
question=question,
sources=formatted_sources,
)
assembled_prompt = f"[SYSTEM]\n{effective_system}\n\n[USER]\n{user_message}"
url = f"{self.base_url}/chat/completions"
payload = {
"model": self.model,
"messages": [
{"role": "system", "content": effective_system},
{"role": "user", "content": user_message},
],
"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 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}