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 6b8fca4f5a
commit e67c175f80
44 changed files with 2131 additions and 94 deletions
+160
View File
@@ -0,0 +1,160 @@
"""Индекс документации для опции AI-помощника «Работа с документацией».
Раз в сутки (и по кнопке) обходим папки, привязанные к машинам, и для новых или изменившихся файлов (по etag)
сохраняем имя и начало текста: PDF — через pypdf, docx/xlsx/pptx/odt — из XML внутри архива, текстовые файлы как есть.
Поиск — по словам в имени и тексте, только по машинам, которые видит пользователь. Помощник получает короткие
фрагменты и путь к файлу, а не документы целиком."""
import io
import logging
import re
import threading
import zipfile
from datetime import timedelta
from sqlalchemy import delete, or_, select
from sqlalchemy.orm import Session
from . import config, nextcloud as nc, scope, settings_store as ss
from .models import DocIndex, Equipment, User
log = logging.getLogger("docs_index")
MAX_TEXT = 20_000 # символов текста на файл
MAX_INDEX_MB = 60 # файлы крупнее — только по имени
MAX_PDF_PAGES = 40
EVERY = timedelta(hours=24)
_lock = threading.Lock()
state = {"running": False, "done": 0, "total": 0, "error": ""}
def enabled(db: Session) -> bool:
return ss.get(db, "docs_ai") == "1"
def norm(s: str) -> str:
return re.sub(r"\s+", " ", s.lower().replace("ё", "е")).strip()
def _xml_text(z: zipfile.ZipFile, names: list[str]) -> str:
parts = []
for n in names:
raw = z.read(n).decode("utf-8", "ignore")
parts.append(re.sub(r"<[^>]+>", " ", re.sub(r"</(w:p|a:p|text:p|si|row)>", "\n", raw)))
return " ".join(parts)
def extract(name: str, data: bytes) -> str:
"""Текст документа (может быть пустым — например, скан без текстового слоя)."""
ext = nc.ext_of(name)
if ext in nc.TEXT:
return data[: MAX_TEXT * 4].decode("utf-8", "ignore")
if ext == "pdf":
try:
from pypdf import PdfReader
except ImportError:
return ""
reader = PdfReader(io.BytesIO(data))
out = []
for page in reader.pages[:MAX_PDF_PAGES]:
out.append(page.extract_text() or "")
if sum(map(len, out)) > MAX_TEXT:
break
return "\n".join(out)
if ext in ("docx", "docm", "dotx", "xlsx", "xlsm", "pptx", "odt", "ods", "odp"):
with zipfile.ZipFile(io.BytesIO(data)) as z:
files = z.namelist()
pick = ([n for n in files if n == "word/document.xml"] or [n for n in files if n == "xl/sharedStrings.xml"]
or sorted(n for n in files if re.fullmatch(r"ppt/slides/slide\d+\.xml", n)) or [n for n in files if n == "content.xml"])
return _xml_text(z, pick)
return ""
def index_equipment(db: Session, c: nc.Conf, eq: Equipment) -> int:
"""Обновить индекс одной машины; вернуть число прочитанных заново файлов."""
files = nc.walk(c, eq.docs_path)
have = {r.path: r for r in db.scalars(select(DocIndex).where(DocIndex.equipment_id == eq.id))}
seen, fresh = set(), 0
base = len(nc.clean(eq.docs_path))
for f in files:
rel = f["path"][base:].strip("/")
seen.add(rel)
row = have.get(rel)
if row and row.etag == f["etag"]:
continue
text, note = "", ""
if f["size"] > MAX_INDEX_MB * 1024 * 1024:
note = "большой файл — только имя"
elif nc.ext_of(f["name"]) in nc.TEXT | {"pdf"} | nc.OFFICE:
try:
text = extract(f["name"], nc.fetch(c, f).read_bytes())
note = "" if text.strip() else "текст не найден (скан?)"
except Exception as e: # повреждённый или защищённый файл не должен останавливать индекс
note = f"не прочитан: {type(e).__name__}"[:200]
row = row or DocIndex(equipment_id=eq.id, path=rel)
row.name, row.etag, row.size = f["name"][:300], f["etag"][:200], f["size"]
row.text, row.note, row.indexed_at = norm(rel + " " + text)[:MAX_TEXT], note, config.now() # путь тоже ищется
db.add(row)
fresh += 1
state["done"] += 1
gone = [p for p in have if p not in seen]
if gone:
db.execute(delete(DocIndex).where(DocIndex.equipment_id == eq.id, DocIndex.path.in_(gone)))
db.commit()
return fresh
def rebuild(db: Session) -> dict:
"""Обойти все привязанные папки. Ошибка одной машины не мешает остальным."""
if not _lock.acquire(blocking=False):
return {"running": True}
try:
state.update(running=True, done=0, total=0, error="")
c = nc.conf(db)
eqs = db.scalars(select(Equipment).where(Equipment.docs_path != "")).all()
db.execute(delete(DocIndex).where(DocIndex.equipment_id.not_in([e.id for e in eqs] or [0])))
errors = []
for eq in eqs:
try:
index_equipment(db, c, eq)
except nc.DocsError as e:
db.rollback()
errors.append(f"{eq.name}: {e}")
ss.set_(db, "docs_index_at", config.now().isoformat())
db.commit()
state["error"] = "; ".join(errors)[:500]
return {"files": db.query(DocIndex).count(), "errors": errors}
finally:
state["running"] = False
_lock.release()
def due(db: Session) -> bool:
if not enabled(db) or not nc.conf(db).ready:
return False
at = ss.get(db, "docs_index_at")
try:
from datetime import datetime
return not at or config.now() - datetime.fromisoformat(at) >= EVERY
except ValueError:
return True
def search(db: Session, u: User, q: str, limit: int = 10) -> list[dict]:
words = [w for w in re.findall(r"\w+", norm(q)) if len(w) >= 3][:8]
if not words:
return []
cond = or_(*[DocIndex.text.contains(w) for w in words])
stmt = select(DocIndex, Equipment).join(Equipment, Equipment.id == DocIndex.equipment_id).where(cond)
vis = scope.equipment_filter(db, u)
if vis is not None:
stmt = stmt.where(vis)
scored = []
for row, eq in db.execute(stmt.limit(500)):
name = norm(row.path)
score = sum(5 * (w in name) + min(row.text.count(w), 20) for w in words)
body = row.text[len(name) + 1:]
hit = min((i for i in (body.find(w) for w in words) if i >= 0), default=-1)
snippet = body[max(0, hit - 120): hit + 220] if hit >= 0 else ""
scored.append((score, {"equipment_id": eq.id, "equipment_name": eq.name, "path": row.path, "name": row.name,
"snippet": snippet, "note": row.note}))
scored.sort(key=lambda x: -x[0])
return [x for _, x in scored[:limit]]