"""Индекс документации для опции 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"", "\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]]