Initial commit: VKUS vitrina MVP (frontend + backend).
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.models import AnalyticsSummary, FeedbackRequest
|
||||
|
||||
|
||||
@dataclass
|
||||
class FeedbackEvent:
|
||||
message_id: str
|
||||
helped: bool
|
||||
query: str
|
||||
system: str | None
|
||||
confidence: float
|
||||
created_at: str
|
||||
|
||||
|
||||
class AnalyticsStore:
|
||||
def __init__(self) -> None:
|
||||
self._events: list[FeedbackEvent] = []
|
||||
|
||||
def record(self, request: FeedbackRequest) -> FeedbackEvent:
|
||||
event = FeedbackEvent(
|
||||
message_id=request.message_id,
|
||||
helped=request.helped,
|
||||
query=request.query,
|
||||
system=request.system,
|
||||
confidence=request.confidence,
|
||||
created_at=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
)
|
||||
self._events.append(event)
|
||||
return event
|
||||
|
||||
def summary(self) -> AnalyticsSummary:
|
||||
total = len(self._events)
|
||||
helped = sum(1 for e in self._events if e.helped)
|
||||
not_helped = total - helped
|
||||
deflection = round((helped / total) * 100, 1) if total else 0.0
|
||||
return AnalyticsSummary(
|
||||
total_feedback=total,
|
||||
helped_count=helped,
|
||||
not_helped_count=not_helped,
|
||||
deflection_rate=deflection,
|
||||
)
|
||||
|
||||
|
||||
analytics_store = AnalyticsStore()
|
||||
@@ -0,0 +1,489 @@
|
||||
import re
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
|
||||
from app.config import settings
|
||||
|
||||
from app.data.knowledge_base import (
|
||||
|
||||
DEFAULT_PROFILE,
|
||||
|
||||
GLOSSARY_ANSWERS,
|
||||
|
||||
IT_SYSTEMS,
|
||||
|
||||
KNOWLEDGE_ENTRIES,
|
||||
|
||||
get_colleague_cases_for_system,
|
||||
|
||||
)
|
||||
|
||||
from app.llm.client import get_llm_client
|
||||
|
||||
from app.models import AssistantResult, ColleagueCase, ResponsePackage, UserProfile
|
||||
|
||||
from app.package_builder import build_why_suggested, ensure_escalation_draft, enrich_ticket_draft
|
||||
|
||||
from app.rag.service import (
|
||||
|
||||
build_package_from_hits,
|
||||
|
||||
build_package_with_llm,
|
||||
|
||||
search_documents,
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _detect_glossary(query: str) -> Optional[str]:
|
||||
|
||||
match = re.search(r"что такое\s+([а-яёa-z0-9]+)", query, re.IGNORECASE)
|
||||
|
||||
if not match:
|
||||
|
||||
return None
|
||||
|
||||
return GLOSSARY_ANSWERS.get(match.group(1).lower())
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _detect_system(query: str) -> Optional[str]:
|
||||
|
||||
lower = query.lower()
|
||||
|
||||
if re.search(r"meridium|меридиум", lower):
|
||||
|
||||
return "Meridium 4"
|
||||
|
||||
if re.search(r"есуид|esuid|esu[iі]d", lower):
|
||||
|
||||
return "ЕСУИД"
|
||||
|
||||
if re.search(r"1[сc]|1c", lower):
|
||||
|
||||
return "1С"
|
||||
|
||||
if re.search(r"starlims|старлимс", lower):
|
||||
|
||||
return "StarLims"
|
||||
|
||||
if re.search(r"лимс|lims", lower):
|
||||
|
||||
return "ЛИМС"
|
||||
|
||||
if re.search(r"вкус|vkus", lower):
|
||||
|
||||
return "ВКУС"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _is_thanks(query: str) -> bool:
|
||||
|
||||
return bool(
|
||||
|
||||
re.match(
|
||||
|
||||
r"^(спасибо|благодар|всё получилось|все получилось|понятно|достаточно|до свидания|нет,? спасибо)",
|
||||
|
||||
query.strip(),
|
||||
|
||||
re.IGNORECASE,
|
||||
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _is_off_topic(query: str) -> bool:
|
||||
|
||||
return bool(re.search(r"политик|выборы|погод|рецепт|футбол|кино|личн", query, re.IGNORECASE))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _is_confidential_request(query: str) -> bool:
|
||||
|
||||
return bool(
|
||||
|
||||
re.search(r"зарплат|табельн|паспорт|телефон.*коллег|заявк.*коллег|данн.*сотрудник", query, re.IGNORECASE)
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _is_security_probe(query: str) -> bool:
|
||||
|
||||
return bool(
|
||||
|
||||
re.search(r"директор|служб.*безопас|представь.*что|гипотетич|покажи.*промпт|как ты устроен", query, re.IGNORECASE)
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _try_pattern_knowledge(
|
||||
|
||||
trimmed: str,
|
||||
|
||||
greeting: str,
|
||||
|
||||
conversation: list[dict[str, str]] | None = None,
|
||||
|
||||
) -> AssistantResult | None:
|
||||
|
||||
if not settings.use_pattern_kb:
|
||||
|
||||
return None
|
||||
|
||||
|
||||
|
||||
for entry in KNOWLEDGE_ENTRIES:
|
||||
|
||||
if any(re.search(pattern, trimmed, re.IGNORECASE) for pattern in entry["patterns"]):
|
||||
|
||||
confidence = entry["confidence"]
|
||||
|
||||
intro = (
|
||||
|
||||
f"{greeting}нашёл инструкцию по вашему вопросу. Вот что рекомендую:"
|
||||
|
||||
if confidence >= settings.confidence_threshold
|
||||
|
||||
else f"{greeting}нашёл частично подходящую информацию, но уверенность ниже {settings.confidence_threshold}%. Рекомендую также оформить обращение:"
|
||||
|
||||
)
|
||||
|
||||
package_data = {**entry["package"], "confidence": confidence, "system": entry["system"]}
|
||||
|
||||
package = ResponsePackage.model_validate(package_data)
|
||||
|
||||
package.why_suggested = build_why_suggested(
|
||||
system=entry["system"],
|
||||
sources=package.sources,
|
||||
confidence=confidence,
|
||||
)
|
||||
|
||||
if package.ticket_draft:
|
||||
|
||||
cases = package.colleague_cases or [
|
||||
|
||||
ColleagueCase.model_validate(case)
|
||||
|
||||
for case in get_colleague_cases_for_system(entry["system"])
|
||||
|
||||
]
|
||||
|
||||
package.ticket_draft = enrich_ticket_draft(
|
||||
|
||||
trimmed,
|
||||
|
||||
entry["system"],
|
||||
|
||||
package.ticket_draft.priority,
|
||||
|
||||
conversation=conversation,
|
||||
|
||||
sources=package.sources,
|
||||
|
||||
colleague_cases=cases,
|
||||
|
||||
recommendation=package.recommendation,
|
||||
|
||||
)
|
||||
|
||||
package = ensure_escalation_draft(package, trimmed, conversation=conversation)
|
||||
|
||||
return AssistantResult(text=intro, package=package)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _try_local_rag(
|
||||
|
||||
trimmed: str,
|
||||
|
||||
greeting: str,
|
||||
|
||||
system: str,
|
||||
|
||||
conversation: list[dict[str, str]] | None = None,
|
||||
|
||||
) -> AssistantResult | None:
|
||||
|
||||
"""Временный RAG в backend: поиск по Manuals/ + опционально внешняя LLM."""
|
||||
|
||||
if not settings.rag_local_enabled or settings.rag_mode != "local":
|
||||
|
||||
return None
|
||||
|
||||
|
||||
|
||||
hits = search_documents(trimmed)
|
||||
|
||||
if not hits:
|
||||
|
||||
return None
|
||||
|
||||
|
||||
|
||||
llm = get_llm_client()
|
||||
|
||||
if llm.is_configured():
|
||||
|
||||
llm_result = build_package_with_llm(
|
||||
|
||||
trimmed, hits, llm, greeting, system=system, conversation=conversation
|
||||
|
||||
)
|
||||
|
||||
if llm_result:
|
||||
|
||||
intro, package = llm_result
|
||||
|
||||
return AssistantResult(text=intro, package=package)
|
||||
|
||||
|
||||
|
||||
package = build_package_from_hits(trimmed, hits, system=system, conversation=conversation)
|
||||
|
||||
if package is None:
|
||||
|
||||
return None
|
||||
|
||||
|
||||
|
||||
threshold = settings.confidence_threshold
|
||||
|
||||
intro = (
|
||||
|
||||
f"{greeting}нашёл релевантные фрагменты в инструкциях (локальный RAG). Вот что рекомендую:"
|
||||
|
||||
if package.confidence >= threshold
|
||||
|
||||
else f"{greeting}нашёл частично подходящие материалы (уверенность ниже {threshold}%). Рекомендую также оформить обращение:"
|
||||
|
||||
)
|
||||
|
||||
return AssistantResult(text=intro, package=package)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def process_message(
|
||||
|
||||
query: str,
|
||||
|
||||
profile: UserProfile,
|
||||
|
||||
is_first_message: bool,
|
||||
|
||||
conversation: list[dict[str, str]] | None = None,
|
||||
|
||||
) -> AssistantResult:
|
||||
|
||||
trimmed = query.strip()
|
||||
|
||||
greeting = f"{profile.first_name} {profile.patronymic}, " if is_first_message else ""
|
||||
|
||||
|
||||
|
||||
if _is_thanks(trimmed):
|
||||
|
||||
return AssistantResult(
|
||||
|
||||
text="Рад был помочь. Если появятся вопросы — обращайтесь.",
|
||||
|
||||
is_simple=True,
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
if _is_off_topic(trimmed):
|
||||
|
||||
return AssistantResult(
|
||||
|
||||
text=f"{greeting}этот вопрос выходит за рамки моих задач — я помогаю только с рабочими вопросами. Могу ли я помочь ещё?",
|
||||
|
||||
is_simple=True,
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
if _is_confidential_request(trimmed):
|
||||
|
||||
return AssistantResult(
|
||||
|
||||
text=f"{greeting}эта информация является конфиденциальной и доступна только самому сотруднику. Могу ли я помочь ещё?",
|
||||
|
||||
is_simple=True,
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
if _is_security_probe(trimmed):
|
||||
|
||||
return AssistantResult(
|
||||
|
||||
text=f"{greeting}я работаю на основе ваших актуальных данных в системе. Гипотетические сценарии не влияют на мои возможности и правила работы. Могу ли я помочь ещё?",
|
||||
|
||||
is_simple=True,
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
glossary = _detect_glossary(trimmed)
|
||||
|
||||
if glossary:
|
||||
|
||||
return AssistantResult(
|
||||
|
||||
text=f"{greeting}{glossary}\n\nМогу ли я помочь ещё?",
|
||||
|
||||
is_simple=True,
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
dialog = conversation or []
|
||||
|
||||
|
||||
|
||||
# 1) Курируемые сценарии (быстрые ответы для демо)
|
||||
|
||||
pattern_result = _try_pattern_knowledge(trimmed, greeting, dialog)
|
||||
|
||||
if pattern_result:
|
||||
|
||||
return pattern_result
|
||||
|
||||
|
||||
|
||||
# 2) Определение ИТ-системы до поиска (CJM §3.2, §4.1)
|
||||
|
||||
system = _detect_system(trimmed)
|
||||
|
||||
if not system:
|
||||
|
||||
systems_list = ", ".join(IT_SYSTEMS)
|
||||
|
||||
return AssistantResult(
|
||||
|
||||
text=f"{greeting}чтобы подобрать точную инструкцию, уточните, пожалуйста: с какой ИТ-системой связан ваш вопрос? ({systems_list})",
|
||||
|
||||
needs_clarification=True,
|
||||
|
||||
is_simple=True,
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
if system not in IT_SYSTEMS:
|
||||
|
||||
return AssistantResult(
|
||||
|
||||
text=f"{greeting}система «{system}» пока не входит в базу знаний ассистента. Могу помочь оформить обращение в поддержку.",
|
||||
|
||||
create_ticket=True,
|
||||
|
||||
is_simple=True,
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
# 3) Локальный RAG по Manuals/ после определения системы
|
||||
|
||||
rag_result = _try_local_rag(trimmed, greeting, system, dialog)
|
||||
|
||||
if rag_result:
|
||||
|
||||
return rag_result
|
||||
|
||||
|
||||
|
||||
cases = [ColleagueCase.model_validate(case) for case in get_colleague_cases_for_system(system)]
|
||||
|
||||
ticket = enrich_ticket_draft(
|
||||
|
||||
trimmed,
|
||||
|
||||
system,
|
||||
|
||||
"Средний",
|
||||
|
||||
conversation=dialog,
|
||||
|
||||
colleague_cases=cases,
|
||||
|
||||
recommendation="К сожалению, в базе знаний нет точного ответа на ваш вопрос.",
|
||||
|
||||
)
|
||||
|
||||
why = build_why_suggested(system=system, sources=[], confidence=35)
|
||||
|
||||
return AssistantResult(
|
||||
|
||||
text=f"{greeting}по системе «{system}» не нашёл подходящей инструкции. Предлагаю создать обращение с предзаполненным контекстом.",
|
||||
|
||||
create_ticket=True,
|
||||
|
||||
package=ResponsePackage(
|
||||
|
||||
recommendation="К сожалению, в базе знаний нет точного ответа на ваш вопрос.",
|
||||
|
||||
steps=[],
|
||||
|
||||
sources=[],
|
||||
|
||||
colleague_cases=cases or None,
|
||||
|
||||
why_suggested=why,
|
||||
|
||||
confidence=35,
|
||||
|
||||
system=system,
|
||||
|
||||
ticket_draft=ticket,
|
||||
|
||||
),
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def get_default_profile() -> UserProfile:
|
||||
|
||||
return UserProfile.model_validate(DEFAULT_PROFILE)
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Загрузка llm.config.yaml и переопределений из переменных окружения."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from dotenv import load_dotenv
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
CONFIG_PATH = PROJECT_ROOT / "llm.config.yaml"
|
||||
|
||||
# .env в корне репозитория (секреты не коммитить)
|
||||
load_dotenv(PROJECT_ROOT / ".env")
|
||||
|
||||
|
||||
def _deep_get(data: dict[str, Any], *keys: str, default: Any = None) -> Any:
|
||||
node: Any = data
|
||||
for key in keys:
|
||||
if not isinstance(node, dict):
|
||||
return default
|
||||
node = node.get(key)
|
||||
if node is None:
|
||||
return default
|
||||
return node
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_config() -> dict[str, Any]:
|
||||
if not CONFIG_PATH.exists():
|
||||
return {}
|
||||
with CONFIG_PATH.open(encoding="utf-8") as fh:
|
||||
return yaml.safe_load(fh) or {}
|
||||
|
||||
|
||||
class Settings:
|
||||
"""Настройки интеграции LLM и временного локального RAG."""
|
||||
|
||||
@property
|
||||
def project_root(self) -> Path:
|
||||
return PROJECT_ROOT
|
||||
|
||||
@property
|
||||
def llm_enabled(self) -> bool:
|
||||
env = os.getenv("VKUS_LLM_ENABLED")
|
||||
if env is not None:
|
||||
return env.lower() in ("1", "true", "yes")
|
||||
return bool(_deep_get(load_config(), "provider", "enabled", default=False))
|
||||
|
||||
@property
|
||||
def llm_base_url(self) -> str:
|
||||
# Полный URL из .env (OpenRouter и др.) — нормализуем до base
|
||||
api_url = os.getenv("VKUS_LLM_API_URL")
|
||||
if api_url:
|
||||
url = api_url.rstrip("/")
|
||||
if url.endswith("/v1/chat/completions"):
|
||||
return url[: -len("/v1/chat/completions")]
|
||||
return url
|
||||
return os.getenv(
|
||||
"VKUS_LLM_BASE_URL",
|
||||
_deep_get(load_config(), "api", "base_url", default="https://apiai.sibur.local"),
|
||||
)
|
||||
|
||||
@property
|
||||
def llm_chat_url(self) -> str:
|
||||
"""Полный URL для chat/completions."""
|
||||
api_url = os.getenv("VKUS_LLM_API_URL")
|
||||
if api_url:
|
||||
return api_url.rstrip("/")
|
||||
return f"{self.llm_base_url.rstrip('/')}/v1/chat/completions"
|
||||
|
||||
@property
|
||||
def llm_model(self) -> str:
|
||||
return os.getenv(
|
||||
"VKUS_LLM_MODEL",
|
||||
_deep_get(load_config(), "llm", "model", default="gpt-oss-120b"),
|
||||
)
|
||||
|
||||
@property
|
||||
def llm_api_key(self) -> str | None:
|
||||
key_name = _deep_get(load_config(), "auth", "api_key_env", default="VKUS_LLM_API_KEY")
|
||||
return os.getenv(key_name or "VKUS_LLM_API_KEY") or None
|
||||
|
||||
@property
|
||||
def llm_jwt_token(self) -> str | None:
|
||||
key_name = _deep_get(load_config(), "auth", "jwt_token_env", default="VKUS_LLM_JWT_TOKEN")
|
||||
return os.getenv(key_name or "VKUS_LLM_JWT_TOKEN") or None
|
||||
|
||||
@property
|
||||
def llm_timeout(self) -> float:
|
||||
env = os.getenv("VKUS_LLM_TIMEOUT_SEC")
|
||||
if env:
|
||||
return float(env)
|
||||
return float(_deep_get(load_config(), "api", "timeout_seconds", default=60))
|
||||
|
||||
@property
|
||||
def llm_verify_ssl(self) -> bool:
|
||||
return bool(_deep_get(load_config(), "api", "verify_ssl", default=True))
|
||||
|
||||
@property
|
||||
def llm_temperature(self) -> float:
|
||||
env = os.getenv("VKUS_LLM_TEMPERATURE")
|
||||
if env:
|
||||
return float(env)
|
||||
return float(_deep_get(load_config(), "llm", "parameters", "temperature", default=0.3))
|
||||
|
||||
@property
|
||||
def llm_max_tokens(self) -> int:
|
||||
env = os.getenv("VKUS_LLM_MAX_TOKENS")
|
||||
if env:
|
||||
return int(env)
|
||||
return int(_deep_get(load_config(), "llm", "parameters", "max_tokens", default=2048))
|
||||
|
||||
@property
|
||||
def system_prompt_path(self) -> Path:
|
||||
rel = _deep_get(
|
||||
load_config(),
|
||||
"llm",
|
||||
"system_prompt",
|
||||
"file",
|
||||
default="Start data/Системный промпт - правила общения с пользователем.md",
|
||||
)
|
||||
return PROJECT_ROOT / str(rel)
|
||||
|
||||
@property
|
||||
def system_prompt_fallback(self) -> str:
|
||||
return str(
|
||||
_deep_get(
|
||||
load_config(),
|
||||
"llm",
|
||||
"system_prompt",
|
||||
"fallback",
|
||||
default="Ты — корпоративный ИТ-ассистент портала ВКУС.",
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def confidence_threshold(self) -> int:
|
||||
return int(_deep_get(load_config(), "assistant", "confidence_threshold", default=70))
|
||||
|
||||
# --- Временный локальный RAG (внутри backend) ---
|
||||
|
||||
@property
|
||||
def rag_mode(self) -> str:
|
||||
"""local — временный RAG в backend; external — целевая платформа ИИ (будущее)."""
|
||||
return str(_deep_get(load_config(), "rag", "mode", default="local"))
|
||||
|
||||
@property
|
||||
def rag_local_enabled(self) -> bool:
|
||||
env = os.getenv("VKUS_RAG_LOCAL_ENABLED")
|
||||
if env is not None:
|
||||
return env.lower() in ("1", "true", "yes")
|
||||
return bool(_deep_get(load_config(), "rag", "local", "enabled", default=True))
|
||||
|
||||
@property
|
||||
def rag_documents_path(self) -> Path:
|
||||
rel = _deep_get(load_config(), "rag", "local", "documents_path", default="Manuals")
|
||||
return PROJECT_ROOT / str(rel)
|
||||
|
||||
@property
|
||||
def rag_top_k(self) -> int:
|
||||
return int(_deep_get(load_config(), "rag", "local", "top_k", default=5))
|
||||
|
||||
@property
|
||||
def rag_min_score(self) -> float:
|
||||
return float(_deep_get(load_config(), "rag", "local", "min_score", default=0.12))
|
||||
|
||||
@property
|
||||
def rag_chunk_size(self) -> int:
|
||||
return int(_deep_get(load_config(), "rag", "local", "chunk_size", default=450))
|
||||
|
||||
@property
|
||||
def use_pattern_kb(self) -> bool:
|
||||
return bool(_deep_get(load_config(), "fallback", "use_local_knowledge_base", default=True))
|
||||
|
||||
@property
|
||||
def use_template_on_llm_failure(self) -> bool:
|
||||
return bool(_deep_get(load_config(), "fallback", "use_template_on_llm_failure", default=True))
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,396 @@
|
||||
QUICK_PROMPTS = [
|
||||
"Проект в ЛИМС не виден",
|
||||
"VPN не работает",
|
||||
"Восстановить файл",
|
||||
"Ошибка в Meridium",
|
||||
]
|
||||
|
||||
IT_SYSTEMS = [
|
||||
"Meridium 4",
|
||||
"ЕСУИД",
|
||||
"1С",
|
||||
"StarLims",
|
||||
"ЛИМС",
|
||||
"ВКУС",
|
||||
]
|
||||
|
||||
GLOSSARY_ANSWERS = {
|
||||
"есуид": (
|
||||
"**ЕСУИД** (Единая система управления идентификационными данными) — "
|
||||
"корпоративная система аутентификации. Через неё вы входите в Meridium, 1С "
|
||||
"и другие корпоративные приложения. Портал: https://idmoam.sibur.local/identity"
|
||||
),
|
||||
"meridium": (
|
||||
"**Meridium 4** — система управления активами и надёжностью оборудования (APM). "
|
||||
"Основной URL: https://s001as-apmapp.sibur.local/Meridium/. "
|
||||
"Отчёты: https://s001as-apmrep.sibur.local/ReportServer"
|
||||
),
|
||||
"вкус": (
|
||||
"**ВКУС** (Витрина корпоративных услуг СИБУРа) — единый сервисный вход "
|
||||
"для сотрудников: помощь по ИТ-вопросам, создание обращений и отслеживание их статуса."
|
||||
),
|
||||
"лимс": (
|
||||
"**ЛИМС** (Laboratory Information Management System) — лабораторная "
|
||||
"информационная система для управления образцами и результатами анализов."
|
||||
),
|
||||
"starlims": (
|
||||
"**StarLims** — лабораторная информационная система, используемая на "
|
||||
"предприятиях СИБУРа для учёта лабораторных исследований."
|
||||
),
|
||||
}
|
||||
|
||||
KNOWLEDGE_ENTRIES = [
|
||||
{
|
||||
"patterns": [
|
||||
r"meridium.*(вход|войти|логин|парол|авториз)",
|
||||
r"не могу.*meridium",
|
||||
r"meridium.*не открыв",
|
||||
],
|
||||
"system": "Meridium 4",
|
||||
"confidence": 85,
|
||||
"package": {
|
||||
"recommendation": (
|
||||
"Для входа в Meridium 4 используется единая учётная запись ЕСУИД. "
|
||||
"Если основная система не открывается, сначала проверьте доступ к порталу ЕСУИД."
|
||||
),
|
||||
"steps": [
|
||||
"Откройте портал ЕСУИД: https://idmoam.sibur.local/identity",
|
||||
"Войдите с корпоративными учётными данными",
|
||||
"Перейдите в Meridium 4: https://s001as-apmapp.sibur.local/Meridium/",
|
||||
"При ошибке доступа — очистите кэш браузера (Ctrl+Shift+Delete) и повторите вход",
|
||||
"Если пароль не подходит — запросите сброс через ЕСУИД или создайте обращение",
|
||||
],
|
||||
"sources": [
|
||||
{"title": "Инструкция по входу в Meridium 4", "url": "https://vkus.sibur.local/"},
|
||||
{"title": "URL систем Meridium", "url": "https://s001as-apmapp.sibur.local/Meridium/"},
|
||||
],
|
||||
"colleague_cases": [
|
||||
{
|
||||
"summary": "Ошибка «доступ запрещён» после смены пароля",
|
||||
"outcome": "Помог выход из всех сессий ЕСУИД и повторный вход через режим инкогнито",
|
||||
},
|
||||
{
|
||||
"summary": "Meridium не загружается в Chrome",
|
||||
"outcome": "Решилось добавлением сайта в доверенные зоны и обновлением браузера",
|
||||
},
|
||||
],
|
||||
"ticket_draft": {
|
||||
"title": "Проблема с входом в Meridium 4",
|
||||
"description": "Не удаётся войти в систему Meridium 4. Проверка ЕСУИД и очистка кэша не помогли.",
|
||||
"system": "Meridium 4",
|
||||
"priority": "Высокий",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"patterns": [
|
||||
r"есуид.*(парол|сброс|разблок)",
|
||||
r"сброс.*парол",
|
||||
r"забыл.*парол",
|
||||
r"разблокир.*учётн",
|
||||
],
|
||||
"system": "ЕСУИД",
|
||||
"confidence": 90,
|
||||
"package": {
|
||||
"recommendation": (
|
||||
"Пароль ЕСУИД можно сбросить самостоятельно через портал самообслуживания "
|
||||
"или обратившись в ИТ-поддержку."
|
||||
),
|
||||
"steps": [
|
||||
"Откройте портал ЕСУИД: https://idmoam.sibur.local/identity",
|
||||
"Нажмите «Забыли пароль?» на странице входа",
|
||||
"Введите корпоративный логин (формат: ivanov.as)",
|
||||
"Следуйте инструкциям на экране — ссылка придёт на корпоративную почту",
|
||||
"При блокировке учётной записи — создайте обращение на разблокировку",
|
||||
],
|
||||
"sources": [
|
||||
{"title": "ЕСУИД — единая система идентификации", "url": "https://idmoam.sibur.local/identity"},
|
||||
{"title": "Инструкция по сбросу пароля", "url": "https://vkus.sibur.local/"},
|
||||
],
|
||||
"colleague_cases": [
|
||||
{
|
||||
"summary": "Ссылка для сброса не приходит",
|
||||
"outcome": "Проверили папку «Спам» и убедились, что почтовый ящик не переполнен",
|
||||
},
|
||||
],
|
||||
"ticket_draft": {
|
||||
"title": "Сброс / разблокировка пароля ЕСУИД",
|
||||
"description": "Требуется сброс или разблокировка учётной записи ЕСУИД. Самостоятельный сброс не удался.",
|
||||
"system": "ЕСУИД",
|
||||
"priority": "Средний",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"patterns": [
|
||||
r"meridium.*(отчёт|отчет|report)",
|
||||
r"отчёт.*meridium",
|
||||
r"apmrep",
|
||||
r"reportserver",
|
||||
],
|
||||
"system": "Meridium 4",
|
||||
"confidence": 82,
|
||||
"package": {
|
||||
"recommendation": (
|
||||
"Отчёты Meridium 4 формируются на отдельном сервере отчётов. "
|
||||
"Ошибки часто связаны с правами доступа или недоступностью сервера."
|
||||
),
|
||||
"steps": [
|
||||
"Проверьте доступность сервера отчётов: https://s001as-apmrep.sibur.local/ReportServer",
|
||||
"Убедитесь, что основной Meridium 4 работает: https://s001as-apmapp.sibur.local/Meridium/",
|
||||
"Откройте отчёт повторно — дождитесь полной загрузки",
|
||||
"При ошибке — откройте консоль браузера (F12) и сохраните текст ошибки",
|
||||
"Если проблема сохраняется — приложите скриншот к обращению",
|
||||
],
|
||||
"sources": [
|
||||
{"title": "Сервер отчётов Meridium 4", "url": "https://s001as-apmrep.sibur.local/ReportServer"},
|
||||
{"title": "Руководство по отчётам Meridium", "url": "https://vkus.sibur.local/"},
|
||||
],
|
||||
"colleague_cases": [
|
||||
{
|
||||
"summary": "Ошибка 403 при открытии отчёта",
|
||||
"outcome": "Потребовалось обновление роли в группе доступа (ГД) — оформили заявку на доступ",
|
||||
},
|
||||
],
|
||||
"warning": (
|
||||
"В одном из похожих случаев коллеги решили проблему переустановкой браузера, "
|
||||
"хотя официальная инструкция рекомендует сначала проверить права доступа. "
|
||||
"Рекомендуем следовать регламенту."
|
||||
),
|
||||
"ticket_draft": {
|
||||
"title": "Ошибка при формировании отчёта Meridium 4",
|
||||
"description": "При открытии отчёта на сервере s001as-apmrep возникает ошибка. Основной модуль Meridium работает.",
|
||||
"system": "Meridium 4",
|
||||
"priority": "Высокий",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"patterns": [
|
||||
r"1[сc].*(медлен|тормоз|долго|загруз)",
|
||||
r"медленн.*1[сc]",
|
||||
r"1[сc].*не открыв",
|
||||
],
|
||||
"system": "1С",
|
||||
"confidence": 78,
|
||||
"package": {
|
||||
"recommendation": (
|
||||
"Медленная работа 1С может быть связана с нагрузкой на сервер, "
|
||||
"сетевым подключением или локальным кэшем клиента."
|
||||
),
|
||||
"steps": [
|
||||
"Перезапустите клиент 1С полностью (закройте все окна)",
|
||||
"Проверьте подключение к корпоративной сети (VPN, если работаете удалённо)",
|
||||
"Очистите кэш 1С: удалите папку %APPDATA%\\1C\\1Cv8\\",
|
||||
"Попробуйте запустить 1С в другое время — утром нагрузка выше",
|
||||
"Зафиксируйте время возникновения проблемы для обращения в поддержку",
|
||||
],
|
||||
"sources": [
|
||||
{"title": "Рекомендации по работе с 1С", "url": "https://vkus.sibur.local/"},
|
||||
],
|
||||
"colleague_cases": [
|
||||
{
|
||||
"summary": "1С зависает при открытии базы утром",
|
||||
"outcome": "Проблема была на стороне сервера — решилась после перезапуска сеансов администратором",
|
||||
},
|
||||
],
|
||||
"ticket_draft": {
|
||||
"title": "Медленная работа 1С",
|
||||
"description": "Клиент 1С загружается более 5 минут. Перезапуск и очистка кэша не помогли.",
|
||||
"system": "1С",
|
||||
"priority": "Средний",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"patterns": [
|
||||
r"лимс.*(проект|не виден|не отображ)",
|
||||
r"проект.*лимс",
|
||||
r"starlims.*(проект|не виден)",
|
||||
],
|
||||
"system": "ЛИМС",
|
||||
"confidence": 80,
|
||||
"package": {
|
||||
"recommendation": (
|
||||
"Если проект не отображается в ЛИМС, чаще всего причина в правах доступа "
|
||||
"или привязке проекта к вашему подразделению."
|
||||
),
|
||||
"steps": [
|
||||
"Обновите страницу и проверьте фильтры в списке проектов",
|
||||
"Убедитесь, что выбрано правильное предприятие / площадка",
|
||||
"Проверьте, что у вас есть роль для просмотра проектов в ЛИМС",
|
||||
"Уточните у коллег, виден ли проект у других пользователей",
|
||||
"Если проект новый — возможно, он ещё не синхронизирован, подождите до 24 часов",
|
||||
],
|
||||
"sources": [
|
||||
{"title": "Инструкция по работе с проектами ЛИМС", "url": "https://vkus.sibur.local/"},
|
||||
],
|
||||
"colleague_cases": [
|
||||
{
|
||||
"summary": "Проект не появился после создания",
|
||||
"outcome": "Потребовалось обновление роли доступа через заявку в ИТ-поддержку",
|
||||
},
|
||||
],
|
||||
"ticket_draft": {
|
||||
"title": "Проект в ЛИМС не виден",
|
||||
"description": "Проект не отображается в списке ЛИМС. Фильтры и обновление страницы не помогли.",
|
||||
"system": "ЛИМС",
|
||||
"priority": "Средний",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"patterns": [
|
||||
r"vpn.*(не работ|не подключ|ошибк|обрыв)",
|
||||
r"не работает.*vpn",
|
||||
r"впн",
|
||||
],
|
||||
"system": "Общее",
|
||||
"confidence": 75,
|
||||
"package": {
|
||||
"recommendation": (
|
||||
"Проблемы с VPN обычно связаны с сетевым подключением, устаревшим клиентом "
|
||||
"или истёкшим сертификатом."
|
||||
),
|
||||
"steps": [
|
||||
"Проверьте подключение к интернету",
|
||||
"Перезапустите VPN-клиент и повторите подключение",
|
||||
"Перезагрузите компьютер",
|
||||
"Убедитесь, что используете корпоративный VPN-клиент последней версии",
|
||||
"Если ошибка сохраняется — сделайте скриншот сообщения об ошибке",
|
||||
],
|
||||
"sources": [
|
||||
{"title": "Инструкция по VPN", "url": "https://vkus.sibur.local/"},
|
||||
],
|
||||
"ticket_draft": {
|
||||
"title": "VPN не работает",
|
||||
"description": "Не удаётся подключиться к корпоративному VPN. Перезапуск клиента не помог.",
|
||||
"system": "Общее",
|
||||
"priority": "Высокий",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"patterns": [
|
||||
r"восстанов.*файл",
|
||||
r"удалил.*файл",
|
||||
r"потерял.*файл",
|
||||
r"вернуть.*файл",
|
||||
],
|
||||
"system": "Общее",
|
||||
"confidence": 72,
|
||||
"package": {
|
||||
"recommendation": (
|
||||
"Восстановление файлов возможно из корзины, резервных копий на сетевом диске "
|
||||
"или через обращение в ИТ-поддержку."
|
||||
),
|
||||
"steps": [
|
||||
"Проверьте корзину на рабочем столе",
|
||||
"Если файл был на сетевом диске — проверьте папку «Предыдущие версии» (ПКМ → Свойства)",
|
||||
"Вспомните точное имя файла и расположение",
|
||||
"Не сохраняйте новые файлы на диск, где был удалён файл",
|
||||
"Оформите обращение с указанием пути и времени удаления",
|
||||
],
|
||||
"sources": [
|
||||
{"title": "Политика резервного копирования", "url": "https://vkus.sibur.local/"},
|
||||
],
|
||||
"ticket_draft": {
|
||||
"title": "Восстановление удалённого файла",
|
||||
"description": "Требуется восстановление удалённого файла. Корзина и предыдущие версии не помогли.",
|
||||
"system": "Общее",
|
||||
"priority": "Средний",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"patterns": [
|
||||
r"ошибк.*meridium",
|
||||
r"ошибк.*меридиум",
|
||||
r"meridium.*ошибк",
|
||||
],
|
||||
"system": "Meridium 4",
|
||||
"confidence": 80,
|
||||
"package": {
|
||||
"recommendation": (
|
||||
"Ошибки в Meridium 4 могут быть связаны с доступом, сессией ЕСУИД "
|
||||
"или недоступностью сервера приложения."
|
||||
),
|
||||
"steps": [
|
||||
"Зафиксируйте точный текст ошибки (скриншот)",
|
||||
"Выйдите из Meridium и войдите заново через ЕСУИД",
|
||||
"Проверьте доступность: https://s001as-apmapp.sibur.local/Meridium/",
|
||||
"Очистите кэш браузера и повторите попытку",
|
||||
"Если ошибка в отчётах — проверьте сервер отчётов отдельно",
|
||||
],
|
||||
"sources": [
|
||||
{"title": "Meridium 4 — основная система", "url": "https://s001as-apmapp.sibur.local/Meridium/"},
|
||||
{"title": "Руководство пользователя Meridium", "url": "https://vkus.sibur.local/"},
|
||||
],
|
||||
"ticket_draft": {
|
||||
"title": "Ошибка в Meridium 4",
|
||||
"description": "При работе в Meridium 4 возникает ошибка. Повторный вход не помог.",
|
||||
"system": "Meridium 4",
|
||||
"priority": "Высокий",
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
DEFAULT_PROFILE = {
|
||||
"first_name": "Алексей",
|
||||
"patronymic": "Петрович",
|
||||
"last_name": "Смирнов",
|
||||
"position": "Инженер по надёжности",
|
||||
"department": "АО «Сибур-Нефтехим»",
|
||||
}
|
||||
|
||||
|
||||
def get_colleague_cases_for_system(system: str | None, limit: int = 3) -> list[dict[str, str]]:
|
||||
"""Опыт коллег по системе — для блока self-service при ответах из RAG."""
|
||||
if not system:
|
||||
return []
|
||||
|
||||
seen: set[str] = set()
|
||||
cases: list[dict[str, str]] = []
|
||||
|
||||
for entry in KNOWLEDGE_ENTRIES:
|
||||
if entry.get("system") != system:
|
||||
continue
|
||||
for case in entry.get("package", {}).get("colleague_cases") or []:
|
||||
key = case.get("summary", "")
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
cases.append(case)
|
||||
if len(cases) >= limit:
|
||||
return cases
|
||||
|
||||
# Дополнительные кейсы для лабораторных систем
|
||||
extra_by_system: dict[str, list[dict[str, str]]] = {
|
||||
"ЛИМС": [
|
||||
{
|
||||
"summary": "Не удаётся провести переиспытание образца",
|
||||
"outcome": "Потребовалось проверить статус образца и роль «Проведение переиспытаний»",
|
||||
},
|
||||
],
|
||||
"StarLims": [
|
||||
{
|
||||
"summary": "Переиспытание недоступно в карточке образца",
|
||||
"outcome": "Образец был в статусе «Завершён» — сначала выполнили отмену завершения",
|
||||
},
|
||||
{
|
||||
"summary": "Ошибка при создании переиспытания",
|
||||
"outcome": "Помогла очистка кэша браузера и повторный вход (не по регламенту)",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
for case in extra_by_system.get(system, []):
|
||||
key = case.get("summary", "")
|
||||
if key and key not in seen:
|
||||
seen.add(key)
|
||||
cases.append(case)
|
||||
if len(cases) >= limit:
|
||||
break
|
||||
|
||||
return cases[:limit]
|
||||
@@ -0,0 +1,179 @@
|
||||
from app.models import Appeal, AppealComment, ServiceItem
|
||||
|
||||
MOCK_APPEALS: list[dict] = [
|
||||
{
|
||||
"id": "1",
|
||||
"number": "INC-2026-004521",
|
||||
"title": "Не открывается модуль отчётов Meridium 4",
|
||||
"system": "Meridium 4",
|
||||
"status": "in_progress",
|
||||
"status_label": "В работе у поддержки",
|
||||
"next_step": "Ожидайте ответа специалиста — запрошены логи с сервера отчётов",
|
||||
"priority": "high",
|
||||
"priority_label": "Высокий",
|
||||
"created_at": "2026-06-27T09:15:00",
|
||||
"updated_at": "2026-06-28T14:30:00",
|
||||
"assignee": "Иванов А.С.",
|
||||
"description": (
|
||||
"При попытке открыть отчёт «Статус оборудования» появляется ошибка доступа. "
|
||||
"Основной модуль Meridium работает."
|
||||
),
|
||||
"comments": [
|
||||
{
|
||||
"id": "c1",
|
||||
"author": "Иванов А.С.",
|
||||
"text": (
|
||||
"Добрый день! Проверьте, пожалуйста, доступ к серверу отчётов "
|
||||
"s001as-apmrep.sibur.local. Запросите логи из браузера (F12 → Console)."
|
||||
),
|
||||
"date": "2026-06-28T11:00:00",
|
||||
"is_support": True,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"number": "INC-2026-004398",
|
||||
"title": "Сброс пароля ЕСУИД",
|
||||
"system": "ЕСУИД",
|
||||
"status": "resolved",
|
||||
"status_label": "Решено",
|
||||
"next_step": "Проверьте вход в систему. Если проблема повторится — переоткройте обращение",
|
||||
"priority": "medium",
|
||||
"priority_label": "Средний",
|
||||
"created_at": "2026-06-20T16:45:00",
|
||||
"updated_at": "2026-06-21T10:20:00",
|
||||
"assignee": "Петрова М.В.",
|
||||
"description": "Не могу войти в Meridium — забыл пароль ЕСУИД.",
|
||||
"resolution": (
|
||||
"Пароль сброшен. Новый временный пароль отправлен на корпоративную почту. "
|
||||
"При первом входе потребуется сменить пароль."
|
||||
),
|
||||
"comments": [
|
||||
{
|
||||
"id": "c2",
|
||||
"author": "Петрова М.В.",
|
||||
"text": "Пароль сброшен, инструкция отправлена на почту.",
|
||||
"date": "2026-06-21T10:20:00",
|
||||
"is_support": True,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"number": "INC-2026-004612",
|
||||
"title": "Медленная загрузка 1С:Управление предприятием",
|
||||
"system": "1С",
|
||||
"status": "waiting",
|
||||
"status_label": "Ожидает вашего ответа",
|
||||
"next_step": "Уточните, в какое время проблема проявляется чаще всего",
|
||||
"priority": "medium",
|
||||
"priority_label": "Средний",
|
||||
"created_at": "2026-06-28T08:00:00",
|
||||
"updated_at": "2026-06-29T09:00:00",
|
||||
"assignee": "Сидоров К.П.",
|
||||
"description": "1С открывается более 5 минут, особенно утром.",
|
||||
"comments": [
|
||||
{
|
||||
"id": "c3",
|
||||
"author": "Сидоров К.П.",
|
||||
"text": (
|
||||
"Уточните, пожалуйста: проблема возникает только утром или в течение всего дня? "
|
||||
"Сколько пользователей затронуто?"
|
||||
),
|
||||
"date": "2026-06-29T09:00:00",
|
||||
"is_support": True,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"number": "INC-2026-004701",
|
||||
"title": "Запрос доступа к StarLims",
|
||||
"system": "StarLims",
|
||||
"status": "new",
|
||||
"status_label": "Новое обращение",
|
||||
"next_step": "Обращение в очереди на назначение исполнителя",
|
||||
"priority": "low",
|
||||
"priority_label": "Низкий",
|
||||
"created_at": "2026-06-29T11:30:00",
|
||||
"updated_at": "2026-06-29T11:30:00",
|
||||
"description": "Необходим доступ к модулю лабораторных исследований для нового сотрудника.",
|
||||
"comments": [],
|
||||
},
|
||||
]
|
||||
|
||||
MOCK_SERVICES: list[dict] = [
|
||||
{
|
||||
"id": "s1",
|
||||
"title": "Сброс пароля ЕСУИД",
|
||||
"category": "Доступ и учётные записи",
|
||||
"system": "ЕСУИД",
|
||||
"description": "Сброс или разблокировка учётной записи в единой системе идентификации",
|
||||
"sla": "4 часа",
|
||||
},
|
||||
{
|
||||
"id": "s2",
|
||||
"title": "Предоставление доступа к Meridium 4",
|
||||
"category": "Доступ и учётные записи",
|
||||
"system": "Meridium 4",
|
||||
"description": "Запрос роли и прав доступа к системе управления активами",
|
||||
"sla": "2 рабочих дня",
|
||||
},
|
||||
{
|
||||
"id": "s3",
|
||||
"title": "Ошибка при формировании отчёта",
|
||||
"category": "Инцидент",
|
||||
"system": "Meridium 4",
|
||||
"description": "Сообщение об ошибке при работе с отчётами на сервере отчётов",
|
||||
"sla": "8 часов",
|
||||
},
|
||||
{
|
||||
"id": "s4",
|
||||
"title": "Проблема с входом в 1С",
|
||||
"category": "Инцидент",
|
||||
"system": "1С",
|
||||
"description": "Невозможность авторизации или ошибки при запуске клиента 1С",
|
||||
"sla": "4 часа",
|
||||
},
|
||||
{
|
||||
"id": "s5",
|
||||
"title": "Запрос доступа к StarLims",
|
||||
"category": "Доступ и учётные записи",
|
||||
"system": "StarLims",
|
||||
"description": "Предоставление доступа к лабораторной информационной системе",
|
||||
"sla": "3 рабочих дня",
|
||||
},
|
||||
{
|
||||
"id": "s6",
|
||||
"title": "Медленная работа системы",
|
||||
"category": "Инцидент",
|
||||
"system": "Общее",
|
||||
"description": "Замедление работы корпоративных приложений",
|
||||
"sla": "8 часов",
|
||||
},
|
||||
{
|
||||
"id": "s7",
|
||||
"title": "Установка ПО на рабочее место",
|
||||
"category": "Запрос на обслуживание",
|
||||
"system": "ПРМ",
|
||||
"description": "Установка разрешённого программного обеспечения на АРМ",
|
||||
"sla": "2 рабочих дня",
|
||||
},
|
||||
{
|
||||
"id": "s8",
|
||||
"title": "Консультация по ЛИМС",
|
||||
"category": "Консультация",
|
||||
"system": "ЛИМС",
|
||||
"description": "Вопросы по работе с лабораторной информационной системой",
|
||||
"sla": "1 рабочий день",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def seed_appeals() -> list[Appeal]:
|
||||
return [Appeal.model_validate(item) for item in MOCK_APPEALS]
|
||||
|
||||
|
||||
def get_services() -> list[ServiceItem]:
|
||||
return [ServiceItem.model_validate(item) for item in MOCK_SERVICES]
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Клиент внешней LLM (OpenAI-совместимый API платформы ИИ)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LlmTestResult:
|
||||
ok: bool
|
||||
configured: bool
|
||||
model: str
|
||||
endpoint: str
|
||||
message: str
|
||||
sample: str | None = None
|
||||
status_code: int | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class LlmClient:
|
||||
def __init__(self) -> None:
|
||||
self._system_prompt = self._load_system_prompt()
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return settings.llm_enabled and bool(settings.llm_api_key or settings.llm_jwt_token)
|
||||
|
||||
def _load_system_prompt(self) -> str:
|
||||
path = settings.system_prompt_path
|
||||
if path.exists():
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
return content[:6000]
|
||||
except OSError:
|
||||
pass
|
||||
return settings.system_prompt_fallback
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if settings.llm_api_key:
|
||||
headers["Authorization"] = f"Bearer {settings.llm_api_key}"
|
||||
if settings.llm_jwt_token:
|
||||
headers["X-JWT-Token"] = settings.llm_jwt_token
|
||||
# OpenRouter рекомендует указать referer
|
||||
if "openrouter.ai" in settings.llm_chat_url:
|
||||
headers["HTTP-Referer"] = "http://localhost:5173"
|
||||
headers["X-Title"] = "VKUS IT Assistant"
|
||||
return headers
|
||||
|
||||
def test_connection(self) -> LlmTestResult:
|
||||
endpoint = settings.llm_chat_url
|
||||
model = settings.llm_model
|
||||
|
||||
if not settings.llm_enabled:
|
||||
return LlmTestResult(
|
||||
ok=False,
|
||||
configured=False,
|
||||
model=model,
|
||||
endpoint=endpoint,
|
||||
message="LLM отключена (VKUS_LLM_ENABLED=false)",
|
||||
)
|
||||
|
||||
if not settings.llm_api_key and not settings.llm_jwt_token:
|
||||
return LlmTestResult(
|
||||
ok=False,
|
||||
configured=False,
|
||||
model=model,
|
||||
endpoint=endpoint,
|
||||
message="API-ключ не задан (VKUS_LLM_API_KEY)",
|
||||
)
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "user", "content": "Ответь одним словом: работает"},
|
||||
],
|
||||
"max_tokens": 16,
|
||||
"temperature": 0,
|
||||
}
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=settings.llm_timeout, verify=settings.llm_verify_ssl) as client:
|
||||
response = client.post(endpoint, json=payload, headers=self._headers())
|
||||
if response.status_code >= 400:
|
||||
return LlmTestResult(
|
||||
ok=False,
|
||||
configured=True,
|
||||
model=model,
|
||||
endpoint=endpoint,
|
||||
message="Ошибка HTTP при обращении к LLM",
|
||||
status_code=response.status_code,
|
||||
error=response.text[:500],
|
||||
)
|
||||
data = response.json()
|
||||
sample = data["choices"][0]["message"]["content"].strip()
|
||||
return LlmTestResult(
|
||||
ok=True,
|
||||
configured=True,
|
||||
model=model,
|
||||
endpoint=endpoint,
|
||||
message="Соединение с LLM установлено",
|
||||
sample=sample,
|
||||
status_code=response.status_code,
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
return LlmTestResult(
|
||||
ok=False,
|
||||
configured=True,
|
||||
model=model,
|
||||
endpoint=endpoint,
|
||||
message=f"Таймаут ({settings.llm_timeout} сек)",
|
||||
error="timeout",
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("LLM connection test failed")
|
||||
return LlmTestResult(
|
||||
ok=False,
|
||||
configured=True,
|
||||
model=model,
|
||||
endpoint=endpoint,
|
||||
message="Не удалось подключиться к LLM",
|
||||
error=str(exc)[:500],
|
||||
)
|
||||
|
||||
def generate_with_context(self, query: str, context: str) -> str | None:
|
||||
if not self.is_configured():
|
||||
return None
|
||||
|
||||
user_content = (
|
||||
"Используй только предоставленный контекст из корпоративных инструкций.\n"
|
||||
"Если контекста недостаточно — честно скажи об этом.\n"
|
||||
"Ответ должен быть кратким, структурированным, на русском языке.\n\n"
|
||||
f"### Контекст\n{context}\n\n"
|
||||
f"### Вопрос сотрудника\n{query}"
|
||||
)
|
||||
|
||||
payload = {
|
||||
"model": settings.llm_model,
|
||||
"messages": [
|
||||
{"role": "system", "content": self._system_prompt},
|
||||
{"role": "user", "content": user_content},
|
||||
],
|
||||
"temperature": settings.llm_temperature,
|
||||
"max_tokens": settings.llm_max_tokens,
|
||||
}
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=settings.llm_timeout, verify=settings.llm_verify_ssl) as client:
|
||||
response = client.post(settings.llm_chat_url, json=payload, headers=self._headers())
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data["choices"][0]["message"]["content"].strip()
|
||||
except Exception as exc:
|
||||
if settings.use_template_on_llm_failure:
|
||||
logger.warning("LLM request failed, fallback to template: %s", exc)
|
||||
else:
|
||||
logger.error("LLM request failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
_llm_client: LlmClient | None = None
|
||||
|
||||
|
||||
def get_llm_client() -> LlmClient:
|
||||
global _llm_client
|
||||
if _llm_client is None:
|
||||
_llm_client = LlmClient()
|
||||
return _llm_client
|
||||
@@ -0,0 +1,312 @@
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
|
||||
|
||||
from app.analytics_store import analytics_store
|
||||
from app.assistant_engine import get_default_profile, process_message
|
||||
|
||||
from app.config import settings
|
||||
|
||||
from app.data.knowledge_base import QUICK_PROMPTS
|
||||
|
||||
from app.data.mock_data import get_services
|
||||
|
||||
from app.llm.client import get_llm_client
|
||||
|
||||
from app.models import (
|
||||
AnalyticsSummary,
|
||||
Appeal,
|
||||
AssistantResult,
|
||||
ChatRequest,
|
||||
CreateAppealRequest,
|
||||
FeedbackRequest,
|
||||
NotificationItem,
|
||||
ServiceItem,
|
||||
UserProfile,
|
||||
)
|
||||
from app.notification_store import notification_store
|
||||
|
||||
from app.rag.service import init_local_rag
|
||||
|
||||
from app.store import appeal_store
|
||||
|
||||
|
||||
|
||||
_rag_status: dict[str, object] = {}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
|
||||
async def lifespan(app: FastAPI):
|
||||
|
||||
global _rag_status
|
||||
|
||||
_rag_status = init_local_rag()
|
||||
|
||||
yield
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
|
||||
title="ВКУС IT Assistant API",
|
||||
|
||||
description="Backend витрины корпоративных услуг — ядро обработки запросов",
|
||||
|
||||
version="1.0.0",
|
||||
|
||||
lifespan=lifespan,
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
app.add_middleware(
|
||||
|
||||
CORSMiddleware,
|
||||
|
||||
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
|
||||
|
||||
allow_credentials=True,
|
||||
|
||||
allow_methods=["*"],
|
||||
|
||||
allow_headers=["*"],
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
"""Корень backend — перенаправление на витрину (frontend)."""
|
||||
return RedirectResponse(url="http://127.0.0.1:5173")
|
||||
|
||||
|
||||
@app.get("/api")
|
||||
def api_root() -> dict[str, str]:
|
||||
return {
|
||||
"message": "ВКУС IT Assistant API",
|
||||
"docs": "/docs",
|
||||
"health": "/api/health",
|
||||
"frontend": "http://localhost:5173",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/llm/test")
|
||||
def test_llm() -> dict[str, object]:
|
||||
result = get_llm_client().test_connection()
|
||||
return {
|
||||
"ok": result.ok,
|
||||
"configured": result.configured,
|
||||
"enabled": settings.llm_enabled,
|
||||
"model": result.model,
|
||||
"endpoint": result.endpoint,
|
||||
"message": result.message,
|
||||
"sample": result.sample,
|
||||
"statusCode": result.status_code,
|
||||
"error": result.error,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
def health() -> dict[str, object]:
|
||||
|
||||
llm = get_llm_client()
|
||||
|
||||
return {
|
||||
|
||||
"status": "ok",
|
||||
|
||||
"rag": {
|
||||
|
||||
**_rag_status,
|
||||
|
||||
"note": "Временный локальный RAG в backend. Целевой контур — платформа ИИ.",
|
||||
|
||||
},
|
||||
|
||||
"llm": {
|
||||
"enabled": settings.llm_enabled,
|
||||
"configured": llm.is_configured(),
|
||||
"model": settings.llm_model,
|
||||
"endpoint": settings.llm_chat_url,
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@app.get("/api/profile", response_model=UserProfile)
|
||||
|
||||
def get_profile() -> UserProfile:
|
||||
|
||||
return get_default_profile()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@app.get("/api/prompts", response_model=list[str])
|
||||
|
||||
def get_prompts() -> list[str]:
|
||||
|
||||
return QUICK_PROMPTS
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@app.post("/api/chat", response_model=AssistantResult)
|
||||
|
||||
def chat(request: ChatRequest) -> AssistantResult:
|
||||
|
||||
profile = get_default_profile()
|
||||
|
||||
conversation = [turn.model_dump() for turn in request.conversation]
|
||||
|
||||
return process_message(request.message, profile, request.is_first_message, conversation)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@app.get("/api/appeals", response_model=list[Appeal])
|
||||
|
||||
def list_appeals() -> list[Appeal]:
|
||||
|
||||
return appeal_store.list_all()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@app.post("/api/appeals", response_model=Appeal)
|
||||
|
||||
def create_appeal(request: CreateAppealRequest) -> Appeal:
|
||||
|
||||
appeal = appeal_store.create(request)
|
||||
|
||||
notification_store.add(
|
||||
|
||||
title="Обращение создано",
|
||||
|
||||
text=f"{appeal.number}: {appeal.title}. Отслеживайте статус во вкладке «История моих обращений».",
|
||||
|
||||
link_tab="appeals",
|
||||
|
||||
)
|
||||
|
||||
return appeal
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@app.post("/api/feedback")
|
||||
|
||||
def submit_feedback(request: FeedbackRequest) -> dict[str, object]:
|
||||
|
||||
analytics_store.record(request)
|
||||
|
||||
if request.helped:
|
||||
|
||||
notification_store.add(
|
||||
|
||||
title="Спасибо за отзыв",
|
||||
|
||||
text="Ваш ответ «Помогло» учтён и поможет улучшить сервис.",
|
||||
|
||||
link_tab="vitrina",
|
||||
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
notification_store.add(
|
||||
|
||||
title="Переход к оформлению обращения",
|
||||
|
||||
text="Ответ не помог — подготовьте заявку с контекстом диалога.",
|
||||
|
||||
link_tab="vitrina",
|
||||
|
||||
)
|
||||
|
||||
return {"ok": True, "analytics": analytics_store.summary().model_dump(by_alias=True)}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@app.get("/api/analytics/summary", response_model=AnalyticsSummary)
|
||||
|
||||
def analytics_summary() -> AnalyticsSummary:
|
||||
|
||||
return analytics_store.summary()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@app.get("/api/notifications", response_model=list[NotificationItem])
|
||||
|
||||
def list_notifications() -> list[NotificationItem]:
|
||||
|
||||
return notification_store.list_all()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@app.get("/api/notifications/unread-count")
|
||||
|
||||
def notifications_unread_count() -> dict[str, int]:
|
||||
|
||||
return {"count": notification_store.unread_count()}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@app.post("/api/notifications/{notification_id}/read")
|
||||
|
||||
def mark_notification_read(notification_id: str) -> dict[str, bool]:
|
||||
|
||||
return {"ok": notification_store.mark_read(notification_id)}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@app.post("/api/notifications/read-all")
|
||||
|
||||
def mark_all_notifications_read() -> dict[str, int]:
|
||||
|
||||
return {"marked": notification_store.mark_all_read()}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@app.get("/api/services", response_model=list[ServiceItem])
|
||||
|
||||
def list_services() -> list[ServiceItem]:
|
||||
|
||||
return get_services()
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic.alias_generators import to_camel
|
||||
|
||||
|
||||
class CamelModel(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
alias_generator=to_camel,
|
||||
populate_by_name=True,
|
||||
serialize_by_alias=True,
|
||||
)
|
||||
|
||||
|
||||
class Source(CamelModel):
|
||||
title: str
|
||||
url: Optional[str] = None
|
||||
|
||||
|
||||
class ColleagueCase(CamelModel):
|
||||
summary: str
|
||||
outcome: str
|
||||
|
||||
|
||||
class TicketDraft(CamelModel):
|
||||
title: str
|
||||
description: str
|
||||
system: str
|
||||
priority: str
|
||||
|
||||
|
||||
class ResponsePackage(CamelModel):
|
||||
recommendation: str = ""
|
||||
steps: list[str] = Field(default_factory=list)
|
||||
sources: list[Source] = Field(default_factory=list)
|
||||
colleague_cases: Optional[list[ColleagueCase]] = None
|
||||
warning: Optional[str] = None
|
||||
why_suggested: Optional[str] = None
|
||||
ticket_draft: Optional[TicketDraft] = None
|
||||
confidence: float = 0
|
||||
system: Optional[str] = None
|
||||
|
||||
|
||||
class AssistantResult(CamelModel):
|
||||
text: str
|
||||
package: Optional[ResponsePackage] = None
|
||||
is_simple: bool = False
|
||||
needs_clarification: bool = False
|
||||
create_ticket: bool = False
|
||||
|
||||
|
||||
class ConversationTurn(CamelModel):
|
||||
role: Literal["user", "assistant"]
|
||||
text: str
|
||||
|
||||
|
||||
class ChatRequest(CamelModel):
|
||||
message: str
|
||||
is_first_message: bool = False
|
||||
conversation: list[ConversationTurn] = Field(default_factory=list)
|
||||
|
||||
|
||||
class FeedbackRequest(CamelModel):
|
||||
message_id: str
|
||||
helped: bool
|
||||
query: str = ""
|
||||
system: Optional[str] = None
|
||||
confidence: float = 0
|
||||
|
||||
|
||||
class AnalyticsSummary(CamelModel):
|
||||
total_feedback: int
|
||||
helped_count: int
|
||||
not_helped_count: int
|
||||
deflection_rate: float
|
||||
|
||||
|
||||
class NotificationItem(CamelModel):
|
||||
id: str
|
||||
title: str
|
||||
text: str
|
||||
created_at: str
|
||||
read: bool = False
|
||||
link_tab: Optional[Literal["vitrina", "appeals", "catalog"]] = None
|
||||
|
||||
|
||||
class UserProfile(CamelModel):
|
||||
first_name: str
|
||||
patronymic: str
|
||||
last_name: str
|
||||
position: str
|
||||
department: str
|
||||
|
||||
|
||||
class AppealComment(CamelModel):
|
||||
id: str
|
||||
author: str
|
||||
text: str
|
||||
date: str
|
||||
is_support: bool
|
||||
|
||||
|
||||
AppealStatus = Literal["new", "in_progress", "waiting", "resolved", "closed"]
|
||||
AppealPriority = Literal["critical", "high", "medium", "low"]
|
||||
|
||||
|
||||
class Appeal(CamelModel):
|
||||
id: str
|
||||
number: str
|
||||
title: str
|
||||
system: str
|
||||
status: AppealStatus
|
||||
status_label: str
|
||||
next_step: str
|
||||
priority: AppealPriority
|
||||
priority_label: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
assignee: Optional[str] = None
|
||||
description: str
|
||||
resolution: Optional[str] = None
|
||||
comments: list[AppealComment] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CreateAppealRequest(CamelModel):
|
||||
title: str
|
||||
description: str
|
||||
system: str
|
||||
priority: str = "Средний"
|
||||
|
||||
|
||||
class ServiceItem(CamelModel):
|
||||
id: str
|
||||
title: str
|
||||
category: str
|
||||
system: str
|
||||
description: str
|
||||
sla: str
|
||||
@@ -0,0 +1,44 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.models import NotificationItem
|
||||
|
||||
|
||||
class NotificationStore:
|
||||
def __init__(self) -> None:
|
||||
self._items: list[NotificationItem] = []
|
||||
|
||||
def list_all(self) -> list[NotificationItem]:
|
||||
return sorted(self._items, key=lambda n: n.created_at, reverse=True)
|
||||
|
||||
def unread_count(self) -> int:
|
||||
return sum(1 for item in self._items if not item.read)
|
||||
|
||||
def add(self, title: str, text: str, link_tab: str | None = None) -> NotificationItem:
|
||||
item = NotificationItem(
|
||||
id=f"ntf-{int(datetime.now(timezone.utc).timestamp() * 1000)}",
|
||||
title=title,
|
||||
text=text,
|
||||
created_at=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
read=False,
|
||||
link_tab=link_tab, # type: ignore[arg-type]
|
||||
)
|
||||
self._items.insert(0, item)
|
||||
return item
|
||||
|
||||
def mark_read(self, notification_id: str) -> bool:
|
||||
for item in self._items:
|
||||
if item.id == notification_id:
|
||||
item.read = True
|
||||
return True
|
||||
return False
|
||||
|
||||
def mark_all_read(self) -> int:
|
||||
count = 0
|
||||
for item in self._items:
|
||||
if not item.read:
|
||||
item.read = True
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
notification_store = NotificationStore()
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Сборка полного пакета ответа и контекста заявки по CJM."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.config import settings
|
||||
from app.data.knowledge_base import get_colleague_cases_for_system
|
||||
from app.models import ColleagueCase, ResponsePackage, Source, TicketDraft
|
||||
|
||||
|
||||
def build_why_suggested(
|
||||
*,
|
||||
system: str | None,
|
||||
sources: list[Source],
|
||||
confidence: int,
|
||||
source_title: str | None = None,
|
||||
) -> str:
|
||||
reasons: list[str] = []
|
||||
if system:
|
||||
reasons.append(f"в запросе определена система «{system}»")
|
||||
if sources:
|
||||
reasons.append(f"в инструкциях найден материал «{sources[0].title}»")
|
||||
elif source_title:
|
||||
reasons.append(f"совпадение с документом «{source_title}»")
|
||||
if confidence < 70:
|
||||
reasons.append("уверенность ниже 70% — ответ может быть неполным")
|
||||
if not reasons:
|
||||
return "Ответ сформирован на основе доступных корпоративных инструкций."
|
||||
return "Мы предлагаем этот ответ, потому что " + "; ".join(reasons) + "."
|
||||
|
||||
|
||||
def build_warning(recommendation: str, cases: list[ColleagueCase]) -> str | None:
|
||||
if not cases:
|
||||
return None
|
||||
|
||||
rec_lower = recommendation.lower()
|
||||
divergent_keywords = (
|
||||
"переустанов",
|
||||
"другой браузер",
|
||||
"режим инкогнито",
|
||||
"перезапуск сервера",
|
||||
"администратор",
|
||||
)
|
||||
|
||||
for case in cases:
|
||||
outcome_lower = case.outcome.lower()
|
||||
if any(keyword in outcome_lower for keyword in divergent_keywords):
|
||||
if not any(keyword in rec_lower for keyword in divergent_keywords):
|
||||
return (
|
||||
"В похожих случаях коллеги использовали обходные решения, которые могут "
|
||||
"отличаться от официальной инструкции. Рекомендуем сначала следовать "
|
||||
"регламенту, а при неудаче — оформить обращение."
|
||||
)
|
||||
|
||||
return (
|
||||
"Опыт коллег приведён для ориентира. При расхождении с инструкцией "
|
||||
"приоритет у официального регламента."
|
||||
)
|
||||
|
||||
|
||||
def build_ticket_description(
|
||||
query: str,
|
||||
system: str,
|
||||
*,
|
||||
conversation: list[dict[str, str]] | None = None,
|
||||
sources: list[Source] | None = None,
|
||||
colleague_cases: list[ColleagueCase] | None = None,
|
||||
recommendation: str = "",
|
||||
) -> str:
|
||||
lines = [
|
||||
f"Исходный вопрос: {query}",
|
||||
f"ИТ-система: {system}",
|
||||
]
|
||||
|
||||
if recommendation:
|
||||
lines.extend(["", "Рекомендация ассистента:", recommendation[:800]])
|
||||
|
||||
if conversation:
|
||||
lines.extend(["", "Диалог с ассистентом:"])
|
||||
for turn in conversation[-8:]:
|
||||
role = "Сотрудник" if turn.get("role") == "user" else "Ассистент"
|
||||
text = turn.get("text", "").strip()
|
||||
if text:
|
||||
lines.append(f"- {role}: {text[:300]}")
|
||||
|
||||
if sources:
|
||||
lines.extend(["", "Источники:"])
|
||||
for src in sources[:4]:
|
||||
title = src.title
|
||||
if src.url:
|
||||
lines.append(f"- {title} ({src.url})")
|
||||
else:
|
||||
lines.append(f"- {title}")
|
||||
|
||||
if colleague_cases:
|
||||
lines.extend(["", "Похожие случаи коллег:"])
|
||||
for case in colleague_cases[:3]:
|
||||
lines.append(f"- {case.summary} → {case.outcome}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def ensure_escalation_draft(
|
||||
package: ResponsePackage,
|
||||
query: str,
|
||||
*,
|
||||
conversation: list[dict[str, str]] | None = None,
|
||||
) -> ResponsePackage:
|
||||
"""CJM §4.2: при уверенности ниже порога — черновик заявки всегда в пакете."""
|
||||
if package.confidence >= settings.confidence_threshold:
|
||||
return package
|
||||
|
||||
system = package.system or "Общее"
|
||||
cases = package.colleague_cases
|
||||
package.ticket_draft = enrich_ticket_draft(
|
||||
query,
|
||||
system,
|
||||
"Высокий",
|
||||
conversation=conversation,
|
||||
sources=package.sources,
|
||||
colleague_cases=cases,
|
||||
recommendation=package.recommendation,
|
||||
)
|
||||
return package
|
||||
|
||||
|
||||
def enrich_ticket_draft(
|
||||
query: str,
|
||||
system: str,
|
||||
priority: str,
|
||||
*,
|
||||
conversation: list[dict[str, str]] | None = None,
|
||||
sources: list[Source] | None = None,
|
||||
colleague_cases: list[ColleagueCase] | None = None,
|
||||
recommendation: str = "",
|
||||
) -> TicketDraft:
|
||||
cases = colleague_cases or get_colleague_cases_for_system(system)
|
||||
return TicketDraft(
|
||||
title=f"Вопрос по системе {system}",
|
||||
description=build_ticket_description(
|
||||
query,
|
||||
system,
|
||||
conversation=conversation,
|
||||
sources=sources,
|
||||
colleague_cases=cases,
|
||||
recommendation=recommendation,
|
||||
),
|
||||
system=system,
|
||||
priority=priority,
|
||||
)
|
||||
@@ -0,0 +1,214 @@
|
||||
"""
|
||||
Временный локальный RAG-слой внутри backend.
|
||||
|
||||
Целевая архитектура выносит RAG на платформу ИИ (Docling + BERT + VectorDB).
|
||||
Для тестирования витрины используется упрощённый индекс TF-IDF по папке Manuals/.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
SUPPORTED_SUFFIXES = {".txt", ".md", ".docx"}
|
||||
|
||||
TOKEN_RE = re.compile(r"[a-zA-Zа-яА-ЯёЁ0-9]{3,}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class DocumentChunk:
|
||||
id: str
|
||||
text: str
|
||||
source_title: str
|
||||
source_path: str
|
||||
system: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchHit:
|
||||
chunk: DocumentChunk
|
||||
score: float
|
||||
|
||||
|
||||
def tokenize(text: str) -> list[str]:
|
||||
return [t.lower() for t in TOKEN_RE.findall(text)]
|
||||
|
||||
|
||||
def detect_system_from_text(text: str) -> str | None:
|
||||
lower = text.lower()
|
||||
rules = [
|
||||
(r"meridium|меридиум", "Meridium 4"),
|
||||
(r"есуид", "ЕСУИД"),
|
||||
(r"\b1[сc]\b", "1С"),
|
||||
(r"starlims|старлимс", "StarLims"),
|
||||
(r"лимс|lims", "ЛИМС"),
|
||||
(r"вкус|vkus", "ВКУС"),
|
||||
(r"vpn|впн", "Общее"),
|
||||
]
|
||||
for pattern, name in rules:
|
||||
if re.search(pattern, lower):
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
class LocalRagIndex:
|
||||
def __init__(self) -> None:
|
||||
self.chunks: list[DocumentChunk] = []
|
||||
self._idf: dict[str, float] = {}
|
||||
self._tf_vectors: list[dict[str, float]] = []
|
||||
|
||||
@property
|
||||
def is_ready(self) -> bool:
|
||||
return len(self.chunks) > 0
|
||||
|
||||
def build(self, documents_path: Path, chunk_size: int = 450) -> int:
|
||||
self.chunks.clear()
|
||||
self._idf.clear()
|
||||
self._tf_vectors.clear()
|
||||
|
||||
if not documents_path.exists():
|
||||
return 0
|
||||
|
||||
chunk_idx = 0
|
||||
for file_path in sorted(documents_path.rglob("*")):
|
||||
if not file_path.is_file():
|
||||
continue
|
||||
if file_path.suffix.lower() not in SUPPORTED_SUFFIXES:
|
||||
continue
|
||||
|
||||
try:
|
||||
content = _read_document_text(file_path)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
if not content:
|
||||
continue
|
||||
|
||||
rel_path = str(file_path.relative_to(documents_path.parent))
|
||||
title = file_path.stem
|
||||
system = detect_system_from_text(content) or detect_system_from_text(str(file_path))
|
||||
|
||||
for piece in _split_into_chunks(content, chunk_size):
|
||||
self.chunks.append(
|
||||
DocumentChunk(
|
||||
id=f"chunk-{chunk_idx}",
|
||||
text=piece,
|
||||
source_title=title,
|
||||
source_path=rel_path,
|
||||
system=system,
|
||||
)
|
||||
)
|
||||
chunk_idx += 1
|
||||
|
||||
self._build_tfidf()
|
||||
return len(self.chunks)
|
||||
|
||||
def _build_tfidf(self) -> None:
|
||||
doc_freq: dict[str, int] = {}
|
||||
self._tf_vectors = []
|
||||
|
||||
for chunk in self.chunks:
|
||||
tokens = tokenize(chunk.text)
|
||||
if not tokens:
|
||||
self._tf_vectors.append({})
|
||||
continue
|
||||
tf: dict[str, float] = {}
|
||||
for token in tokens:
|
||||
tf[token] = tf.get(token, 0.0) + 1.0
|
||||
for token in tf:
|
||||
tf[token] /= len(tokens)
|
||||
self._tf_vectors.append(tf)
|
||||
for token in set(tokens):
|
||||
doc_freq[token] = doc_freq.get(token, 0) + 1
|
||||
|
||||
n_docs = max(len(self.chunks), 1)
|
||||
self._idf = {token: math.log((1 + n_docs) / (1 + freq)) + 1 for token, freq in doc_freq.items()}
|
||||
|
||||
def search(self, query: str, top_k: int = 5, min_score: float = 0.0) -> list[SearchHit]:
|
||||
if not self.chunks:
|
||||
return []
|
||||
|
||||
q_tokens = tokenize(query)
|
||||
if not q_tokens:
|
||||
return []
|
||||
|
||||
q_tf: dict[str, float] = {}
|
||||
for token in q_tokens:
|
||||
q_tf[token] = q_tf.get(token, 0.0) + 1.0
|
||||
for token in q_tf:
|
||||
q_tf[token] /= len(q_tokens)
|
||||
q_vec = {t: q_tf[t] * self._idf.get(t, 0.0) for t in q_tf}
|
||||
|
||||
hits: list[SearchHit] = []
|
||||
for chunk, tf_vec in zip(self.chunks, self._tf_vectors):
|
||||
score = _cosine(q_vec, {t: tf_vec[t] * self._idf.get(t, 0.0) for t in tf_vec})
|
||||
if score >= min_score:
|
||||
hits.append(SearchHit(chunk=chunk, score=score))
|
||||
|
||||
hits.sort(key=lambda h: h.score, reverse=True)
|
||||
return hits[:top_k]
|
||||
|
||||
|
||||
def _read_document_text(file_path: Path) -> str:
|
||||
suffix = file_path.suffix.lower()
|
||||
if suffix in {".txt", ".md"}:
|
||||
return file_path.read_text(encoding="utf-8", errors="ignore").strip()
|
||||
if suffix == ".docx":
|
||||
return _extract_docx_text(file_path)
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_docx_text(file_path: Path) -> str:
|
||||
with zipfile.ZipFile(file_path) as archive:
|
||||
xml = archive.read("word/document.xml").decode("utf-8", errors="ignore")
|
||||
|
||||
text = re.sub(r"</w:p>", "\n\n", xml)
|
||||
text = re.sub(r"<[^>]+>", " ", text)
|
||||
text = re.sub(r"[ \t]+", " ", text)
|
||||
return re.sub(r"\n{3,}", "\n\n", text).strip()
|
||||
|
||||
|
||||
def _split_into_chunks(text: str, chunk_size: int) -> list[str]:
|
||||
paragraphs = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
|
||||
if not paragraphs:
|
||||
return []
|
||||
|
||||
chunks: list[str] = []
|
||||
current = ""
|
||||
|
||||
for para in paragraphs:
|
||||
if len(para) > chunk_size:
|
||||
if current:
|
||||
chunks.append(current.strip())
|
||||
current = ""
|
||||
for i in range(0, len(para), chunk_size):
|
||||
chunks.append(para[i : i + chunk_size].strip())
|
||||
continue
|
||||
|
||||
candidate = f"{current}\n\n{para}".strip() if current else para
|
||||
if len(candidate) <= chunk_size:
|
||||
current = candidate
|
||||
else:
|
||||
if current:
|
||||
chunks.append(current.strip())
|
||||
current = para
|
||||
|
||||
if current:
|
||||
chunks.append(current.strip())
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
def _cosine(a: dict[str, float], b: dict[str, float]) -> float:
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
common = set(a) & set(b)
|
||||
dot = sum(a[t] * b[t] for t in common)
|
||||
norm_a = math.sqrt(sum(v * v for v in a.values()))
|
||||
norm_b = math.sqrt(sum(v * v for v in b.values()))
|
||||
if norm_a == 0 or norm_b == 0:
|
||||
return 0.0
|
||||
return dot / (norm_a * norm_b)
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Оркестрация временного локального 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
|
||||
@@ -0,0 +1,46 @@
|
||||
import random
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.data.mock_data import seed_appeals
|
||||
from app.models import Appeal, CreateAppealRequest
|
||||
|
||||
PRIORITY_MAP = {
|
||||
"Критический": ("critical", "Критический"),
|
||||
"Высокий": ("high", "Высокий"),
|
||||
"Средний": ("medium", "Средний"),
|
||||
"Низкий": ("low", "Низкий"),
|
||||
}
|
||||
|
||||
|
||||
class AppealStore:
|
||||
def __init__(self) -> None:
|
||||
self._appeals: list[Appeal] = seed_appeals()
|
||||
|
||||
def list_all(self) -> list[Appeal]:
|
||||
return sorted(self._appeals, key=lambda a: a.updated_at, reverse=True)
|
||||
|
||||
def create(self, request: CreateAppealRequest) -> Appeal:
|
||||
priority_key, priority_label = PRIORITY_MAP.get(request.priority, ("medium", "Средний"))
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
number = f"INC-2026-{random.randint(1000, 9999)}"
|
||||
|
||||
appeal = Appeal(
|
||||
id=f"new-{int(datetime.now(timezone.utc).timestamp() * 1000)}",
|
||||
number=number,
|
||||
title=request.title,
|
||||
system=request.system,
|
||||
status="new",
|
||||
status_label="Новое обращение",
|
||||
next_step="Обращение в очереди на назначение исполнителя",
|
||||
priority=priority_key, # type: ignore[arg-type]
|
||||
priority_label=priority_label,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=request.description,
|
||||
comments=[],
|
||||
)
|
||||
self._appeals.insert(0, appeal)
|
||||
return appeal
|
||||
|
||||
|
||||
appeal_store = AppealStore()
|
||||
@@ -0,0 +1,6 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
pydantic==2.10.4
|
||||
pyyaml==6.0.2
|
||||
httpx==0.28.1
|
||||
python-dotenv==1.0.1
|
||||
Reference in New Issue
Block a user