"""Оркестрация временного локального 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