Files
darkorion 0f239d5015 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
2026-10-13 13:27:20 +03:00

161 lines
7.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Индекс документации для опции 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]]