Initial commit: VKUS vitrina MVP (frontend + backend).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-29 23:13:06 +07:00
commit 862e6b7b25
113 changed files with 50949 additions and 0 deletions
View File
+46
View File
@@ -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()
+489
View File
@@ -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)
+184
View File
@@ -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()
View File
+396
View File
@@ -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]
+179
View File
@@ -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]
View File
+174
View File
@@ -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
+312
View File
@@ -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()
+138
View File
@@ -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
+44
View File
@@ -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()
+149
View File
@@ -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,
)
View File
+214
View File
@@ -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)
+206
View File
@@ -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
+46
View File
@@ -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()
+6
View File
@@ -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