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:
+39
-10
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user