feat: документация из Nextcloud, график у сотрудника, автоочистка фото, мониторинг, итог мастера, фильтр по местам; доп. сервисы в docker-compose

- docker-compose.yml: профили docs (Nextcloud), office (ONLYOFFICE Document Server), ai (Ollama + скачивание модели)
- автонастройка: адреса Nextcloud, ONLYOFFICE, портала и модели подставляются из .env или по именам сервисов
  во внутренней сети Docker; значения из интерфейса важнее
- локальная модель в своей сети (имя сервиса, частный IP) подключается без ключа API
- .env.example, docs/DEPLOY.md (раздел 7а), README
This commit is contained in:
darkorion
2026-10-13 13:27:20 +03:00
parent 3a6e1f444c
commit 0f239d5015
44 changed files with 2131 additions and 94 deletions
+39 -10
View File
@@ -16,10 +16,12 @@ import re
import time
from pathlib import Path
from urllib.parse import quote
import httpx
from sqlalchemy.orm import Session
from . import config, settings_store as ss
from . import autoconf, config, settings_store as ss
from .models import User
log = logging.getLogger("assistant")
@@ -59,7 +61,7 @@ def public_cfg(db: Session) -> dict:
def ready(db: Session) -> bool:
c = cfg(db)
local = c["provider"] == "openai" and any(h in c["base_url"] for h in ("localhost", "127.0.0.1"))
local = c["provider"] == "openai" and autoconf.is_local(c["base_url"]) # своя модель в сети (Ollama и т. п.) — без ключа
return c["enabled"] and bool(c["api_key"] or local) and bool(c["model"])
@@ -78,11 +80,18 @@ TOOLS = {
"equipment_history": ("История машины. args: {\"id\": id машины}", "GET", "/equipment/{id}/history?limit=30", "id"),
"find_people": ("Сотрудники: ФИО, должность, на смене ли", "GET", "/users", None),
"warehouses": ("Склады и сколько на них позиций", "GET", "/warehouses", None),
# опция «Работа с документацией» (включается в настройках сервера)
"search_docs": ("Поиск по документации машин: файлы и фрагменты текста. args: {\"q\": \"2-5 ключевых слов\"}", "GET", "/docs/search?q={q}", "q"),
"equipment_docs": ("Папки и файлы документации машины. args: {\"id\": id машины, \"sub\": \"вложенная папка или пусто\"}",
"GET", "/equipment/{id}/docs?sub={sub}", "id"),
}
DOC_TOOLS = {"search_docs", "equipment_docs"}
TEXT_ARGS = ("q", "sub")
KEEP = {"id", "title", "name", "full_name", "status", "state", "day", "due_at", "is_overdue", "equipment_name", "equipment_id", "unit_name",
"assignee_name", "executor_name", "item_name", "qty", "unit", "what", "answer", "occurred_at", "inventory_no", "location_id",
"remaining_hours", "remaining_days", "percent", "on_shift", "position", "type_ru", "at", "text", "open_incidents", "due", "soon",
"positions", "can_execute", "can_review", "last_comment", "description", "total", "is_crane", "labor_hours", "kind", "open_faults"}
"positions", "can_execute", "can_review", "last_comment", "description", "total", "is_crane", "labor_hours", "kind", "open_faults",
"path", "snippet", "items", "folder", "sub", "is_dir", "note"}
def _slim(x, depth=0):
@@ -101,11 +110,18 @@ def _slim(x, depth=0):
return x
async def run_tool(app, token: str, name: str, args: dict) -> str:
if name not in TOOLS:
async def run_tool(app, token: str, name: str, args: dict, docs: bool = False) -> str:
if name not in TOOLS or (name in DOC_TOOLS and not docs):
return json.dumps({"error": f"нет инструмента {name}"}, ensure_ascii=False)
_, method, path, arg = TOOLS[name]
if arg:
for t in TEXT_ARGS: # текстовые параметры — в строку запроса, экранированными
if "{" + t + "}" in path:
v = args.get(t)
v = str(v).strip()[:200] if isinstance(v, (str, int, float)) else ""
if t == arg and len(v) < 2:
return json.dumps({"error": f"нужен параметр {t}"}, ensure_ascii=False)
path = path.replace("{" + t + "}", quote(v, safe=""))
if arg and arg not in TEXT_ARGS:
v = args.get(arg)
if not isinstance(v, int) and not (isinstance(v, str) and v.isdigit()):
return json.dumps({"error": f"нужен числовой параметр {arg}"}, ensure_ascii=False)
@@ -129,9 +145,21 @@ def who(u: User) -> str:
f"роли: {', '.join(roles) or 'нет особых ролей'}.")
def system_prompt(u: User, scope: str) -> str:
tools = "\n".join(f"- {k}: {v[0]}" for k, v in TOOLS.items())
DOCS_RULES = """
ДОКУМЕНТАЦИЯ (это тоже тема портала, не OFFTOPIC). К машинам привязаны папки с документацией (паспорта, руководства,
схемы). Если спрашивают, где найти
сведения по машине (схема, параметры, порядок обслуживания, коды ошибок), найди файл через search_docs или equipment_docs
и подскажи, какой файл открыть и что в нём искать. Давай ссылку вида [имя файла](#/docs/ID?p=путь) — ID машины и путь
файла из результата, пробелы в пути замени на %20. Фрагменты текста — только подсказка, где искать: не выдавай их за полный ответ и не
придумывай содержимое, которого нет во фрагменте. Если ничего не нашлось — так и скажи и предложи открыть папку
документации машины [Документация](#/docs/ID).
"""
def system_prompt(u: User, scope: str, docs: bool = False) -> str:
tools = "\n".join(f"- {k}: {v[0]}" for k, v in TOOLS.items() if docs or k not in DOC_TOOLS)
extra = f"\nДополнительно от администратора: {scope.strip()}\n" if scope and scope.strip() else ""
extra += DOCS_RULES if docs else ""
return f"""Ты — помощник внутри «Портала технической службы» (ремонтная служба предприятия). Отвечай по-русски, кратко, по шагам.
ПРАВИЛА:
@@ -226,7 +254,8 @@ async def chat(app, db: Session, u: User, token: str, history: list[dict], cfg_o
while msgs and msgs[0]["role"] != "user": # некоторые провайдеры требуют начинать с пользователя
msgs.pop(0)
rate_check(u.id)
system = system_prompt(u, c["scope"])
docs = ss.get(db, "docs_ai") == "1"
system = system_prompt(u, c["scope"], docs)
used = []
for _ in range(MAX_STEPS + 1):
text = (await complete(c, system, msgs)).strip()
@@ -240,7 +269,7 @@ async def chat(app, db: Session, u: User, token: str, history: list[dict], cfg_o
args = json.loads(raw)
except ValueError:
args = {}
result = await run_tool(app, token, name, args if isinstance(args, dict) else {})
result = await run_tool(app, token, name, args if isinstance(args, dict) else {}, docs)
used.append(name)
msgs += [{"role": "assistant", "content": text}, {"role": "user", "content": f"РЕЗУЛЬТАТ {name}: {result}\nОтветь пользователю."}]
return {"reply": "Не получилось собрать ответ. Попробуйте переформулировать вопрос.", "offtopic": False, "tools": used}