- docker-compose.yml: профили docs (Nextcloud), office (ONLYOFFICE Document Server), ai (Ollama + скачивание модели) - автонастройка: адреса Nextcloud, ONLYOFFICE, портала и модели подставляются из .env или по именам сервисов во внутренней сети Docker; значения из интерфейса важнее - локальная модель в своей сети (имя сервиса, частный IP) подключается без ключа API - .env.example, docs/DEPLOY.md (раздел 7а), README
161 lines
7.0 KiB
Python
161 lines
7.0 KiB
Python
"""Индекс документации для опции 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]]
|