Files
vkus-ai-v4/backend/app/rag/service.py
T

207 lines
6.7 KiB
Python

"""Оркестрация временного локального RAG и сборки ответа."""
from __future__ import annotations
import re
from typing import TYPE_CHECKING
from app.config import settings
from app.data.knowledge_base import get_colleague_cases_for_system
from app.models import ColleagueCase, ResponsePackage, Source
from app.package_builder import (
build_warning,
build_why_suggested,
ensure_escalation_draft,
enrich_ticket_draft,
)
from app.rag.indexer import LocalRagIndex, SearchHit, detect_system_from_text
if TYPE_CHECKING:
from app.llm.client import LlmClient
_index: LocalRagIndex | None = None
def get_index() -> LocalRagIndex:
global _index
if _index is None:
_index = LocalRagIndex()
return _index
def init_local_rag() -> dict[str, object]:
"""Построить индекс при старте backend."""
index = get_index()
if not settings.rag_local_enabled or settings.rag_mode != "local":
return {"enabled": False, "chunks": 0, "mode": settings.rag_mode}
count = index.build(settings.rag_documents_path, chunk_size=settings.rag_chunk_size)
return {
"enabled": True,
"mode": "local",
"chunks": count,
"documents_path": str(settings.rag_documents_path),
}
def search_documents(query: str) -> list[SearchHit]:
if not settings.rag_local_enabled or settings.rag_mode != "local":
return []
index = get_index()
if not index.is_ready:
init_local_rag()
return index.search(query, top_k=settings.rag_top_k, min_score=settings.rag_min_score)
def score_to_confidence(score: float) -> int:
"""Нормализация cosine similarity в проценты уверенности."""
return min(95, max(35, int(score * 120)))
def build_package_from_hits(
query: str,
hits: list[SearchHit],
*,
system: str | None = None,
conversation: list[dict[str, str]] | None = None,
) -> ResponsePackage | None:
if not hits:
return None
top = hits[0]
confidence = score_to_confidence(top.score)
resolved_system = system or top.chunk.system or detect_system_from_text(query) or "Общее"
recommendation = _extract_recommendation(top.chunk.text)
steps = _extract_steps(top.chunk.text, hits)
sources = _unique_sources(hits)
colleague_cases = [
ColleagueCase.model_validate(case) for case in get_colleague_cases_for_system(resolved_system)
]
priority = "Средний" if confidence >= settings.confidence_threshold else "Высокий"
warning = build_warning(recommendation, colleague_cases) if colleague_cases else None
why_suggested = build_why_suggested(
system=resolved_system,
sources=sources,
confidence=confidence,
source_title=top.chunk.source_title,
)
package = ResponsePackage(
recommendation=recommendation,
steps=steps,
sources=sources,
colleague_cases=colleague_cases or None,
warning=warning,
why_suggested=why_suggested,
confidence=confidence,
system=resolved_system,
ticket_draft=enrich_ticket_draft(
query,
resolved_system,
priority,
conversation=conversation,
sources=sources,
colleague_cases=colleague_cases,
recommendation=recommendation,
),
)
return ensure_escalation_draft(package, query, conversation=conversation)
def build_package_with_llm(
query: str,
hits: list[SearchHit],
llm_client: "LlmClient",
greeting: str,
*,
system: str | None = None,
conversation: list[dict[str, str]] | None = None,
) -> tuple[str, ResponsePackage] | None:
context = "\n\n---\n\n".join(
f"[{h.chunk.source_title}]\n{h.chunk.text}" for h in hits[: settings.rag_top_k]
)
llm_text = llm_client.generate_with_context(query, context)
if not llm_text:
return None
package = build_package_from_hits(query, hits, system=system, conversation=conversation)
if package is None:
return None
package.recommendation = llm_text
if package.colleague_cases:
package.warning = build_warning(llm_text, package.colleague_cases)
if package.ticket_draft:
package.ticket_draft = enrich_ticket_draft(
query,
package.system or system or "Общее",
package.ticket_draft.priority,
conversation=conversation,
sources=package.sources,
colleague_cases=package.colleague_cases,
recommendation=llm_text,
)
package = ensure_escalation_draft(package, query, conversation=conversation)
threshold = settings.confidence_threshold
intro = (
f"{greeting}нашёл материалы в инструкциях и подготовил ответ:"
if package.confidence >= threshold
else (
f"{greeting}уверенность ниже {threshold}% — ответ может быть неполным. "
"Подготовил черновик заявки, его можно сразу отправить в поддержку:"
)
)
return intro, package
def _extract_recommendation(text: str) -> str:
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
if not lines:
return text[:400]
# Первая содержательная строка или абзац
paragraph = lines[0]
if len(paragraph) < 80 and len(lines) > 1:
paragraph = f"{paragraph} {lines[1]}"
return paragraph[:500]
def _extract_steps(text: str, hits: list[SearchHit]) -> list[str]:
steps: list[str] = []
combined = "\n".join(h.chunk.text for h in hits[:3])
for line in combined.splitlines():
line = line.strip()
if not line:
continue
if re.match(r"^(\d+[\.\)]|[-•*])\s+", line):
steps.append(re.sub(r"^(\d+[\.\)]|[-•*])\s+", "", line))
elif "http" in line.lower() or line.lower().startswith("откройте"):
steps.append(line)
if len(steps) >= 6:
break
if not steps:
for line in combined.splitlines():
line = line.strip()
if len(line) > 20:
steps.append(line)
if len(steps) >= 4:
break
return steps[:6]
def _unique_sources(hits: list[SearchHit]) -> list[Source]:
seen: set[str] = set()
sources: list[Source] = []
for hit in hits:
key = hit.chunk.source_path
if key in seen:
continue
seen.add(key)
sources.append(Source(title=hit.chunk.source_title, url=None))
if len(sources) >= 4:
break
return sources