From 711053b8bd18fec1aa3650dc80798bc9ed095fc1 Mon Sep 17 00:00:00 2001 From: Sabo Sabev Date: Wed, 20 May 2026 11:52:11 +0300 Subject: [PATCH] Initial commit: working RIP/INEX_TM help processing pipeline - help_processor.py: parses .docx/.html/.pdf/.doc/.txt, extracts images, classifies sections via Claude API, writes to SQL Server - generate_html.py: builds interactive HTML viewer (Home/Editor/Search/Generator) - save_keywords.py: applies keyword edits back to DB - Prefix-scoped DB schema (RIP_help_files, RIP_help_sections) so multiple projects share the same database without collision - BAT launchers per project (RIP_load.bat, INEX_TM_load.bat, ...) load credentials from gitignored .env via _load_env.bat - Rich HTML preservation for .html sources (html_text column) - Image extraction for all formats with MS Word / LibreOffice fallback for .doc Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 5 + .gitignore | 30 ++ Bairaci.png | Bin 0 -> 71872 bytes INEX_TM_load.bat | 9 + INEX_TM_load_force.bat | 9 + INEX_TM_view.bat | 6 + README.md | 124 +++++ RIP_load.bat | 9 + RIP_load_force.bat | 9 + RIP_view.bat | 6 + _load_env.bat | 9 + generate_html.py | 938 ++++++++++++++++++++++++++++++++ help_processor.py | 1162 ++++++++++++++++++++++++++++++++++++++++ requirements.txt | 7 + save_keywords.py | 77 +++ view.bat | 21 + 16 files changed, 2421 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Bairaci.png create mode 100644 INEX_TM_load.bat create mode 100644 INEX_TM_load_force.bat create mode 100644 INEX_TM_view.bat create mode 100644 README.md create mode 100644 RIP_load.bat create mode 100644 RIP_load_force.bat create mode 100644 RIP_view.bat create mode 100644 _load_env.bat create mode 100644 generate_html.py create mode 100644 help_processor.py create mode 100644 requirements.txt create mode 100644 save_keywords.py create mode 100644 view.bat diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f401d7c --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +REM Copy to .env and fill in real values. .env is gitignored. +REM Loaded by .bat файловете чрез: for /f "delims=" %%a in (.env) do set "%%a" + +ANTHROPIC_API_KEY=sk-ant-api03-XXXXXXXXXXXXXXXXXXXXXXXXX +HELP_DB_CONN=DRIVER={ODBC Driver 18 for SQL Server};TrustServerCertificate=yes;SERVER=host,port;DATABASE=db;UID=user;PWD=password diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8e5472 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Credentials +.env +.env.local +*.local + +# Python +__pycache__/ +*.pyc +*.pyo + +# Logs +*.log + +# Generated outputs +help_viewer.html +keywords_changes*.json + +# Output processing folders (на отделен диск, не за git) +Output/ +output/ + +# Archives +*.zip +*.tar.gz + +# IDE / tools +.vscode/ +.idea/ +.claude/ +*.swp diff --git a/Bairaci.png b/Bairaci.png new file mode 100644 index 0000000000000000000000000000000000000000..28d1ce6039bd3abbafcdb36d36a88da28267f804 GIT binary patch literal 71872 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≪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 a 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%0A{oD1GHFN#-{Lc|R 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≶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 X7#?IXns;*{0}yz+`njxgN@xNAA<{pO literal 0 HcmV?d00001 diff --git a/INEX_TM_load.bat b/INEX_TM_load.bat new file mode 100644 index 0000000..bf979c3 --- /dev/null +++ b/INEX_TM_load.bat @@ -0,0 +1,9 @@ +:@echo off +chcp 65001 > nul +call "%~dp0_load_env.bat" || exit /b 1 +set PYTHONIOENCODING=utf-8 + +echo === INCREMENTAL prefix=INEX_TM === +echo. +python help_processor.py --prefix=INEX_TM "q:\___Proekti\2022 INEX Технологична модернизация" "q:\___Proekti\2022 INEX Технологична модернизация\Output" +pause diff --git a/INEX_TM_load_force.bat b/INEX_TM_load_force.bat new file mode 100644 index 0000000..883c272 --- /dev/null +++ b/INEX_TM_load_force.bat @@ -0,0 +1,9 @@ +:@echo off +chcp 65001 > nul +call "%~dp0_load_env.bat" || exit /b 1 +set PYTHONIOENCODING=utf-8 + +echo === FORCE + PURGE prefix=INEX_TM === +echo. +python help_processor.py --prefix=INEX_TM --force --purge-missing "q:\___Proekti\2022 INEX Технологична модернизация" "q:\___Proekti\2022 INEX Технологична модернизация\Output" +pause diff --git a/INEX_TM_view.bat b/INEX_TM_view.bat new file mode 100644 index 0000000..874032d --- /dev/null +++ b/INEX_TM_view.bat @@ -0,0 +1,6 @@ +:@echo off +chcp 65001 > nul +call "%~dp0_load_env.bat" || exit /b 1 +set PYTHONIOENCODING=utf-8 + +python generate_html.py --prefix=INEX_TM diff --git a/README.md b/README.md new file mode 100644 index 0000000..63a0f95 --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +# RIP Help System — Help-файл декомпозитор и viewer + +Обработва help-файлове (`.html`, `.htm`, `.docx`, `.doc`, `.pdf`, `.txt`), декомпозира ги на секции, извлича картинки, класифицира секциите с Claude API (заглавие + ключови думи), и записва всичко в SQL Server. После генерира интерактивен HTML viewer. + +## Архитектура + +``` +Входни файлове → help_processor.py → SQL Server → generate_html.py → help_viewer.html +(.docx, .html, (RIP_help_*) (Home / Редактор / + .pdf, .doc) Търсене / Генератор) + ↑ + │ + save_keywords.py ← keywords_changes.json + (от Редактора на viewer-а) +``` + +## Инсталация + +``` +pip install -r requirements.txt +``` + +За стар `.doc` формат — едно от: +- **LibreOffice** в PATH (кросплатформено) +- **MS Word** (Windows, чрез pywin32 COM — автоматичен fallback) + +## Конфигурация + +Копирай `.env.example` като `.env` и попълни: + +``` +ANTHROPIC_API_KEY=sk-ant-... +HELP_DB_CONN=DRIVER={ODBC Driver 18 for SQL Server};TrustServerCertificate=yes;SERVER=host,port;DATABASE=db;UID=user;PWD=password +``` + +`.env` е gitignore-нат. Bat файловете го зареждат автоматично през `_load_env.bat`. + +## Употреба (Windows) + +### Обработка на нов проект + +Първо създай `_load.bat` и `_view.bat` (вж. `RIP_load.bat`, `RIP_view.bat` като образец). + +| BAT | Какво прави | +|---|---| +| `RIP_load.bat` | Incremental — обработва само нови/променени файлове по SHA-256 hash | +| `RIP_load_force.bat` | `--force --purge-missing` — преобработва всичко, изтрива orphans | +| `RIP_view.bat` | Генерира `help_viewer.html` за prefix=RIP и го отваря в браузъра | + +### Директно от CLI + +``` +python help_processor.py --prefix= +python help_processor.py --prefix= --force --purge-missing + +python generate_html.py --prefix= # без Home таб +python generate_html.py --prefix= --home img.png # с Home таб +``` + +## Prefix scoping + +Всеки проект има свой `--prefix` (напр. `RIP`, `INEX_TM`). Прави следните неща изолирани между проектите: + +- Кодовете на секциите: `RIP_0001_SEC_0001` vs `INEX_TM_0001_SEC_0001` +- skip-by-hash (incremental) — само в рамките на prefix-а +- `--purge-missing` — изтрива orphans само в текущия prefix +- `generate_html.py --prefix=X` — viewer-а филтрира по prefix + +## Структура на базата + +### `RIP_help_files` +| Поле | Тип | Описание | +|---|---|---| +| id | INT IDENTITY | PK | +| prefix | NVARCHAR(50) | Project scope | +| file_path | NVARCHAR(1000) | Пълен път до файла | +| file_hash | CHAR(64) | SHA-256 за incremental | +| processed_at | DATETIME2 | Последна обработка | +| section_count | INT | Брой секции | + +UNIQUE constraint: `(prefix, file_path)` + +### `RIP_help_sections` +| Поле | Тип | Описание | +|---|---|---| +| id | INT IDENTITY | PK | +| prefix | NVARCHAR(50) | Project scope | +| code | NVARCHAR(80) | `_NNNN_SEC_NNNN` (UNIQUE) | +| source_file | NVARCHAR(1000) | Източник | +| title | NVARCHAR(500) | AI-генерирано заглавие | +| keywords | NVARCHAR(300) | До 5 ключови думи | +| char_count | INT | Размер на чистия текст | +| output_path | NVARCHAR(1000) | Път до `.txt` файла | +| images | NVARCHAR(MAX) | JSON масив с относителни пътища | +| html_text | NVARCHAR(MAX) | Rich HTML с форматиране (само за `.html` източници) | +| created_at, updated_at | DATETIME2 | | + +## HTML Viewer — 3 / 4 таба + +- **Home** (опционален, ако `--home ` е подаден) — началов екран с изображение +- **Редактор** — таблица със секции; inline редактиране на ключови думи; ✓ Save → JSON download → `save_keywords.py` → UPDATE в БД +- **Търсене** — карти със секции; multi-keyword (intervals = AND, "phrase" = literal); preview с картинки +- **Генератор** — drag & drop ordering → export като HTML (self-contained, всички картинки base64-embed-нати) + +## Картинки + +Извличат се по време на парсване: +- `.docx` — `` в paragraph drawings → bytes от related_parts +- `.html` — локални файлове и `data:` URLs; HTTP пропуска +- `.pdf` — `pdfplumber.page.crop(bbox).to_image()` като PNG +- `.doc` — след LibreOffice/MS Word конверсия до `.docx` + +Филтър ≥ 50×50 px (PIL детектва), за да отрязва иконки/булети. + +Записват се в `/images/_img_NN.`. В текста placeholder `[IMG: images/...]`. В DB `images` колоната съдържа JSON масив с пътищата. + +## Constants (в `help_processor.py`) + +| Константа | Default | Описание | +|---|---|---| +| `MIN_SECTION_TOKENS` | 60 | Под този праг секцията се слива с предишната | +| `MAX_AI_CHARS` | 4000 | Символи, пращани към Claude | +| `AI_MODEL` | claude-sonnet-4-6 | Модел за класификация | +| `MIN_IMAGE_PX` | 50 | Картинки под NxN px се пропускат | diff --git a/RIP_load.bat b/RIP_load.bat new file mode 100644 index 0000000..b825420 --- /dev/null +++ b/RIP_load.bat @@ -0,0 +1,9 @@ +:@echo off +chcp 65001 > nul +call "%~dp0_load_env.bat" || exit /b 1 +set PYTHONIOENCODING=utf-8 + +echo === INCREMENTAL prefix=RIP === +echo. +python help_processor.py --prefix=RIP "q:\RIP_Help_Source" "q:\RIP_Help_Source\Output" +pause diff --git a/RIP_load_force.bat b/RIP_load_force.bat new file mode 100644 index 0000000..8400e94 --- /dev/null +++ b/RIP_load_force.bat @@ -0,0 +1,9 @@ +:@echo off +chcp 65001 > nul +call "%~dp0_load_env.bat" || exit /b 1 +set PYTHONIOENCODING=utf-8 + +echo === FORCE + PURGE prefix=RIP === +echo. +python help_processor.py --prefix=RIP --force --purge-missing "q:\RIP_Help_Source" "q:\RIP_Help_Source\Output" +pause diff --git a/RIP_view.bat b/RIP_view.bat new file mode 100644 index 0000000..6391d55 --- /dev/null +++ b/RIP_view.bat @@ -0,0 +1,6 @@ +:@echo off +chcp 65001 > nul +call "%~dp0_load_env.bat" || exit /b 1 +set PYTHONIOENCODING=utf-8 + +python generate_html.py --prefix=RIP --home Bairaci.png diff --git a/_load_env.bat b/_load_env.bat new file mode 100644 index 0000000..69ccaa7 --- /dev/null +++ b/_load_env.bat @@ -0,0 +1,9 @@ +@echo off +REM Зарежда ANTHROPIC_API_KEY и HELP_DB_CONN от .env в текущата cmd среда. +REM Извиква се с: call _load_env.bat +if not exist .env ( + echo [ERROR] Липсва .env файл. Копирай .env.example като .env и попълни. + exit /b 1 +) +for /f "usebackq tokens=1,* delims== eol=#" %%A in (".env") do set "%%A=%%B" +exit /b 0 diff --git a/generate_html.py b/generate_html.py new file mode 100644 index 0000000..151c54e --- /dev/null +++ b/generate_html.py @@ -0,0 +1,938 @@ +""" +generate_html.py +================ +Чете секциите от SQL Server и генерира help_viewer.html. +Стартирай с: python generate_html.py +""" + +import os, sys, json, re, base64, mimetypes, argparse +from pathlib import Path +from datetime import datetime +from typing import Optional + +try: + import pyodbc +except ImportError: + sys.exit("Инсталирай pyodbc: pip install pyodbc") + +CONN_STR = os.getenv( + "HELP_DB_CONN", + "DRIVER={ODBC Driver 18 for SQL Server};" + "TrustServerCertificate=yes;" + "SERVER=94.26.63.238,13151;DATABASE=blondina;" + "UID=blondina_login;PWD=blondina_parola_123" +) +OUT_HTML = Path(__file__).parent / "help_viewer.html" + + +_IMG_PLACEHOLDER_RE = re.compile(r"\[IMG:\s*([^\]]+?)\s*\]") + + +def _esc(s: str) -> str: + return (s.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """)) + + +def _img_src(rel: str, output_dir: Path, embed: bool) -> str: + """file:// URI или base64 data URI за картинка.""" + abs_path = (output_dir / rel).resolve() + if not abs_path.exists(): + return _esc(rel) + if embed: + try: + mime = mimetypes.guess_type(str(abs_path))[0] or "image/png" + b64 = base64.b64encode(abs_path.read_bytes()).decode("ascii") + return f"data:{mime};base64,{b64}" + except Exception: + return abs_path.as_uri() + return abs_path.as_uri() + + +def _text_to_html(text: str, output_dir: Path, embed: bool = False) -> str: + """Конвертира [IMG: images/foo.png] към ; escape-ва останалия текст.""" + parts = [] + last = 0 + for m in _IMG_PLACEHOLDER_RE.finditer(text): + parts.append(_esc(text[last:m.start()])) + rel = m.group(1).strip().replace("\\", "/") + src = _img_src(rel, output_dir, embed) + parts.append( + f'' + ) + last = m.end() + parts.append(_esc(text[last:])) + return "".join(parts).replace("\n", "
") + + +def _rich_html_with_images(html: str, output_dir: Path, embed: bool = False) -> str: + """Същото като _text_to_html, но входът е вече HTML — НЕ escape-ва.""" + def sub(m): + rel = m.group(1).strip().replace("\\", "/") + src = _img_src(rel, output_dir, embed) + return (f'') + return _IMG_PLACEHOLDER_RE.sub(sub, html) + + +def fetch_sections(prefix: Optional[str] = None): + conn = pyodbc.connect(CONN_STR, autocommit=True) + cur = conn.cursor() + if prefix: + cur.execute(""" + SELECT s.prefix, s.code, s.title, s.keywords, s.char_count, + s.source_file, s.output_path, s.updated_at, + s.images, s.html_text, f.section_count + FROM RIP_help_sections s + LEFT JOIN RIP_help_files f + ON f.file_path = s.source_file AND f.prefix = s.prefix + WHERE s.prefix = ? + ORDER BY s.code + """, prefix) + else: + cur.execute(""" + SELECT s.prefix, s.code, s.title, s.keywords, s.char_count, + s.source_file, s.output_path, s.updated_at, + s.images, s.html_text, f.section_count + FROM RIP_help_sections s + LEFT JOIN RIP_help_files f + ON f.file_path = s.source_file AND f.prefix = s.prefix + ORDER BY s.prefix, s.code + """) + cols = [c[0] for c in cur.description] + rows = [] + for r in cur.fetchall(): + d = dict(zip(cols, r)) + d["updated_at"] = str(d["updated_at"])[:16] if d["updated_at"] else "" + # парсваме images JSON + try: + d["images"] = json.loads(d["images"]) if d.get("images") else [] + except Exception: + d["images"] = [] + # прочитаме текста от .txt файла ако съществува + d["text"] = "" + d["text_html"] = "" # file:// — за viewer-а + d["text_html_embed"] = "" # base64 data: — за export (self-contained) + out_dir = Path(d["output_path"]).parent if d.get("output_path") else None + if d.get("output_path") and Path(d["output_path"]).exists(): + try: + txt_path = Path(d["output_path"]) + raw = txt_path.read_text(encoding="utf-8") + parts = raw.split("─" * 60, 1) + body = parts[1].strip() if len(parts) > 1 else raw + d["text"] = body[:800] + except Exception: + pass + # rich HTML от БД има приоритет; иначе fallback към plain text + if d.get("html_text") and out_dir: + d["text_html"] = _rich_html_with_images(d["html_text"], out_dir, embed=False) + d["text_html_embed"] = _rich_html_with_images(d["html_text"], out_dir, embed=True) + elif out_dir and d["text"]: + d["text_html"] = _text_to_html(d["text"][:1200], out_dir, embed=False) + d["text_html_embed"] = _text_to_html(d["text"], out_dir, embed=True) + rows.append(d) + conn.close() + return rows + + +def _home_image_data_uri(home_path: Optional[str]) -> Optional[str]: + """Връща data: URI ако файлът съществува, иначе None.""" + if not home_path: + return None + p = Path(home_path).expanduser() + if not p.is_absolute(): + p = (Path(__file__).parent / p).resolve() + if not p.is_file(): + print(f" [home] файлът не е намерен: {p}", file=sys.stderr) + return None + mime = mimetypes.guess_type(str(p))[0] or "image/png" + b64 = base64.b64encode(p.read_bytes()).decode("ascii") + return f"data:{mime};base64,{b64}" + + +def build_html(sections, home_image: Optional[str] = None): + data_json = json.dumps(sections, ensure_ascii=False) + generated = datetime.now().strftime("%d.%m.%Y %H:%M") + home_uri = _home_image_data_uri(home_image) + + if home_uri: + home_tab_html = '
00 / Home
' + editor_tab_cls = "tab" + editor_panel_cls = "panel" + home_panel_html = ( + '
' + f'
Home
' + '
' + ) + tab_index_list = "['home','editor','search','generator']" + initial_tab = "home" + else: + home_tab_html = "" + editor_tab_cls = "tab active" + editor_panel_cls = "panel active" + home_panel_html = "" + tab_index_list = "['editor','search','generator']" + initial_tab = "editor" + + return f""" + + + + +Help Viewer + + + + +
+

BG16RFPR001-1.001-0068

+ | + + генериран: {generated} +
+ +
+ {home_tab_html} +
01 / Редактор
+
02 / Търсене
+
03 / Генератор
+
+ +{home_panel_html} + + +
+
+ + +
+
+ + + + + + + + + + + +
КодЗаглавиеКлючови думиSource файлОбновен
+
+
+ + + + + +
+
+
+
+ Няма избрани секции + +
+
+
+
+
Избери секции от таб Търсене
+
+
+
+
+

ГЕНЕРИРАЙ ДОКУМЕНТ

+
+ + + +
+
+ Подреди секциите с drag & drop преди генериране. +

+ За Word и PDF е нужен Python backend — засега се генерира HTML. +
+
+
+
+ + +
+ 0 промени + + +
+ +
+ + + +""" + + +if __name__ == "__main__": + ap = argparse.ArgumentParser(description="Генерира help_viewer.html от БД") + ap.add_argument( + "--prefix", + default=os.getenv("HELP_PREFIX"), + help="Филтрира viewer-а по prefix (например 'HLP', 'PROJ_X'). " + "Ако липсва, показва всички префикси." + ) + ap.add_argument( + "--out", + default=str(OUT_HTML), + help=f"Изходен HTML път (default: {OUT_HTML.name})." + ) + ap.add_argument( + "--home", + default=None, + help="Път към изображение, което да се покаже като Home таб (пръв). " + "Ако липсва — няма Home таб (трите стандартни таба остават)." + ) + args = ap.parse_args() + + print("Четем от базата данни...") + if args.prefix: + print(f" Филтър по prefix: {args.prefix}") + if args.home: + print(f" Home image: {args.home}") + try: + sections = fetch_sections(prefix=args.prefix) + except Exception as e: + sys.exit(f"Грешка при свързване с БД: {e}") + + print(f"Намерени {len(sections)} секции.") + html = build_html(sections, home_image=args.home) + out_path = Path(args.out) + out_path.write_text(html, encoding="utf-8") + print(f"Генериран: {out_path}") + + import webbrowser + webbrowser.open(out_path.as_uri()) + print("Отворен в браузъра.") diff --git a/help_processor.py b/help_processor.py new file mode 100644 index 0000000..eff3f1d --- /dev/null +++ b/help_processor.py @@ -0,0 +1,1162 @@ +""" +help_processor.py +================= +Обработва help-файлове (.doc, .docx, .html, .htm, .txt, .pdf), +декомпозира ги на смислови секции, извлича ключови думи чрез Anthropic API +и записва резултатите в SQL Server + изходна директория. + +Поддържа инкрементална обработка: файлове, чийто hash не се е променил, +се прескачат при повторно пускане. + +Изисквания (pip install): + pip install anthropic pyodbc python-docx beautifulsoup4 lxml + pip install pdfplumber striprtf chardet + pip install pywin32 # за MS Word fallback на Windows + +За .doc (стар формат) е необходим един от: + - LibreOffice (soffice в PATH) — кросплатформено + - MS Word — Windows, чрез pywin32 COM (автоматичен fallback) + - antiword — Linux (apt install antiword) +""" + +import os +import re +import sys +import json +import hashlib +import logging +import argparse +import subprocess +import tempfile +from pathlib import Path +from datetime import datetime +from dataclasses import dataclass, field +from typing import Optional + +import pyodbc +import anthropic +from docx import Document +from bs4 import BeautifulSoup + +try: + import pdfplumber + HAS_PDF = True +except ImportError: + HAS_PDF = False + +try: + from PIL import Image + HAS_PIL = True +except ImportError: + HAS_PIL = False + +# ────────────────────────────────────────────── +# Конфигурация +# ────────────────────────────────────────────── + +# На Windows конзолата често е cp1251 → пренастройваме stdout на utf-8 +try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") +except AttributeError: + pass + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)-8s %(message)s", + handlers=[ + logging.StreamHandler(sys.stdout), + logging.FileHandler("help_processor.log", encoding="utf-8"), + ], +) +log = logging.getLogger(__name__) + +MIN_SECTION_TOKENS = 60 # секции под тази граница се сливат с предишната +MAX_AI_CHARS = 4000 # максимален текст, изпращан към Claude за класификация +AI_MODEL = "claude-sonnet-4-6" +MIN_IMAGE_PX = 50 # картинки под NxN px се пропускат (иконки/булети) + + +# ────────────────────────────────────────────── +# Изображения — помощни +# ────────────────────────────────────────────── + +@dataclass +class ImageRef: + placeholder: str # вътрешен ID в текста, напр. "img_01" + data: bytes + ext: str # "png", "jpg", "gif"... + + +def _img_dimensions(data: bytes) -> Optional[tuple[int, int]]: + if not HAS_PIL: + return None + try: + from io import BytesIO + with Image.open(BytesIO(data)) as im: + return im.size + except Exception: + return None + + +def _should_keep_image(data: bytes) -> bool: + """Връща False за дребни иконки/булети под MIN_IMAGE_PX × MIN_IMAGE_PX.""" + if not data: + return False + dims = _img_dimensions(data) + if dims is None: + # Не можем да преценим — пазим по подразбиране + return True + w, h = dims + return w >= MIN_IMAGE_PX and h >= MIN_IMAGE_PX + + +def _ext_from_content_type(ct: str) -> str: + ct = (ct or "").lower() + if "png" in ct: return "png" + if "jpeg" in ct or "jpg" in ct: return "jpg" + if "gif" in ct: return "gif" + if "bmp" in ct: return "bmp" + if "svg" in ct: return "svg" + if "webp" in ct: return "webp" + return "png" + + +_IMG_PLACEHOLDER_RE = re.compile(r"\[IMG:\s*([A-Za-z0-9_./\\-]+)\s*\]") + + +# ────────────────────────────────────────────── +# Структури +# ────────────────────────────────────────────── + +@dataclass +class Section: + title: str + text: str + level: int = 1 # 1=H1, 2=H2, 3=H3, 0=без заглавие + images: list = field(default_factory=list) # list[ImageRef] + html_text: Optional[str] = None # rich HTML с [IMG: ...] placeholders + + +@dataclass +class ProcessedSection: + code: str # DOC_003_SEC_012 + source_file: str + title: str + keywords: str # "кл1, кл2, кл3" + text: str + images_json: str = "[]" # JSON масив с относителни пътища + html_text: str = "" # rich HTML (само за HTML-source файлове) + char_count: int = 0 + + def __post_init__(self): + self.char_count = len(self.text) + + +# ────────────────────────────────────────────── +# База данни +# ────────────────────────────────────────────── + +def _ensure_trust_server_certificate(conn_str: str) -> str: + """Добавя TrustServerCertificate=yes към connection string ако липсва.""" + if not conn_str: + return conn_str + if re.search(r"TrustServerCertificate\s*=", conn_str, re.IGNORECASE): + return conn_str + sep = "" if conn_str.rstrip().endswith(";") else ";" + return f"{conn_str}{sep}TrustServerCertificate=yes;" + + +class Database: + def __init__(self, conn_str: str): + self.conn_str = _ensure_trust_server_certificate(conn_str) + self.conn = pyodbc.connect(self.conn_str, autocommit=False) + self._ensure_schema() + + def _ensure_schema(self): + """Създава таблиците ако не съществуват.""" + cur = self.conn.cursor() + cur.execute(""" + IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name='RIP_help_files') + CREATE TABLE RIP_help_files ( + id INT IDENTITY PRIMARY KEY, + prefix NVARCHAR(50) NOT NULL DEFAULT 'HLP', + file_path NVARCHAR(1000) NOT NULL, + file_hash CHAR(64) NOT NULL, + processed_at DATETIME2 NOT NULL DEFAULT GETDATE(), + section_count INT NOT NULL DEFAULT 0, + CONSTRAINT UQ_RIP_help_files_prefix_path UNIQUE (prefix, file_path) + )""") + # Migrate: добавяме колонка prefix ако таблицата е по-стара версия + cur.execute(""" + IF NOT EXISTS ( + SELECT 1 FROM sys.columns + WHERE object_id=OBJECT_ID('RIP_help_files') AND name='prefix' + ) + BEGIN + ALTER TABLE RIP_help_files ADD prefix NVARCHAR(50) NOT NULL + CONSTRAINT DF_RIP_help_files_prefix DEFAULT 'HLP' WITH VALUES; + END + """) + # Migrate: ако има стара UNIQUE на file_path сама (без prefix), сваляме я + cur.execute(""" + DECLARE @c NVARCHAR(200); + SELECT @c = i.name FROM sys.indexes i + WHERE i.object_id=OBJECT_ID('RIP_help_files') + AND i.is_unique=1 + AND i.name <> 'UQ_RIP_help_files_prefix_path' + AND i.name NOT LIKE 'PK_%' + AND (SELECT COUNT(*) FROM sys.index_columns ic + WHERE ic.object_id=i.object_id AND ic.index_id=i.index_id) = 1; + IF @c IS NOT NULL EXEC('ALTER TABLE RIP_help_files DROP CONSTRAINT [' + @c + ']'); + """) + # Migrate: създаваме новата composite UNIQUE ако липсва + cur.execute(""" + IF NOT EXISTS ( + SELECT 1 FROM sys.indexes + WHERE name='UQ_RIP_help_files_prefix_path' + AND object_id=OBJECT_ID('RIP_help_files') + ) + ALTER TABLE RIP_help_files + ADD CONSTRAINT UQ_RIP_help_files_prefix_path UNIQUE (prefix, file_path) + """) + cur.execute(""" + IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name='RIP_help_sections') + CREATE TABLE RIP_help_sections ( + id INT IDENTITY PRIMARY KEY, + prefix NVARCHAR(50) NOT NULL DEFAULT 'HLP', + code NVARCHAR(80) NOT NULL UNIQUE, + source_file NVARCHAR(1000) NOT NULL, + title NVARCHAR(500), + keywords NVARCHAR(300), + char_count INT, + output_path NVARCHAR(1000), + images NVARCHAR(MAX), + created_at DATETIME2 NOT NULL DEFAULT GETDATE(), + updated_at DATETIME2 NOT NULL DEFAULT GETDATE() + )""") + # Migrate: добавяме колонка prefix ако таблицата е по-стара версия + cur.execute(""" + IF NOT EXISTS ( + SELECT 1 FROM sys.columns + WHERE object_id=OBJECT_ID('RIP_help_sections') AND name='prefix' + ) + ALTER TABLE RIP_help_sections ADD prefix NVARCHAR(50) NOT NULL + CONSTRAINT DF_RIP_help_sections_prefix DEFAULT 'HLP' WITH VALUES + """) + # Migrate: добавяме колонка 'images' ако таблицата е създадена по-стара версия + cur.execute(""" + IF NOT EXISTS ( + SELECT 1 FROM sys.columns + WHERE object_id=OBJECT_ID('RIP_help_sections') AND name='images' + ) + ALTER TABLE RIP_help_sections ADD images NVARCHAR(MAX) NULL + """) + # Migrate: добавяме колонка 'html_text' (rich HTML с форматиране) + cur.execute(""" + IF NOT EXISTS ( + SELECT 1 FROM sys.columns + WHERE object_id=OBJECT_ID('RIP_help_sections') AND name='html_text' + ) + ALTER TABLE RIP_help_sections ADD html_text NVARCHAR(MAX) NULL + """) + # Индекси за търсене по ключови думи и заглавие + cur.execute(""" + IF NOT EXISTS ( + SELECT 1 FROM sys.indexes + WHERE name='IX_RIP_help_sections_keywords' AND object_id=OBJECT_ID('RIP_help_sections') + ) + CREATE INDEX IX_RIP_help_sections_keywords ON RIP_help_sections(keywords) + """) + self.conn.commit() + log.info("Схемата е проверена / създадена.") + + def get_file_hash(self, prefix: str, file_path: str) -> Optional[str]: + cur = self.conn.cursor() + cur.execute( + "SELECT file_hash FROM RIP_help_files WHERE prefix=? AND file_path=?", + prefix, file_path + ) + row = cur.fetchone() + return row[0] if row else None + + def upsert_file(self, prefix: str, file_path: str, file_hash: str, section_count: int): + cur = self.conn.cursor() + cur.execute(""" + MERGE RIP_help_files AS t + USING (SELECT ? AS prefix, ? AS file_path, ? AS file_hash, ? AS section_count) AS s + ON t.prefix = s.prefix AND t.file_path = s.file_path + WHEN MATCHED THEN + UPDATE SET file_hash=s.file_hash, section_count=s.section_count, + processed_at=GETDATE() + WHEN NOT MATCHED THEN + INSERT (prefix, file_path, file_hash, section_count) + VALUES (s.prefix, s.file_path, s.file_hash, s.section_count); + """, prefix, file_path, file_hash, section_count) + self.conn.commit() + + def delete_sections_for_file(self, prefix: str, file_path: str): + cur = self.conn.cursor() + cur.execute( + "DELETE FROM RIP_help_sections WHERE prefix=? AND source_file=?", + prefix, file_path + ) + self.conn.commit() + + def all_source_files(self, prefix: str) -> list[str]: + """Връща всички source_file пътища за даден префикс.""" + cur = self.conn.cursor() + cur.execute(""" + SELECT file_path FROM RIP_help_files WHERE prefix=? + UNION + SELECT source_file FROM RIP_help_sections WHERE prefix=? + """, prefix, prefix) + return [r[0] for r in cur.fetchall()] + + def section_output_paths_for(self, prefix: str, source_files: list[str]) -> list[str]: + if not source_files: + return [] + cur = self.conn.cursor() + placeholders = ",".join("?" for _ in source_files) + cur.execute( + f"SELECT output_path FROM RIP_help_sections " + f"WHERE prefix=? AND source_file IN ({placeholders})", + prefix, *source_files + ) + return [r[0] for r in cur.fetchall() if r[0]] + + def purge_sources(self, prefix: str, source_files: list[str]) -> int: + if not source_files: + return 0 + cur = self.conn.cursor() + placeholders = ",".join("?" for _ in source_files) + cur.execute( + f"DELETE FROM RIP_help_sections " + f"WHERE prefix=? AND source_file IN ({placeholders})", + prefix, *source_files + ) + sec_deleted = cur.rowcount + cur.execute( + f"DELETE FROM RIP_help_files " + f"WHERE prefix=? AND file_path IN ({placeholders})", + prefix, *source_files + ) + self.conn.commit() + return sec_deleted + + def insert_section(self, prefix: str, ps: ProcessedSection, output_path: str): + cur = self.conn.cursor() + cur.execute(""" + MERGE RIP_help_sections AS t + USING (SELECT ? AS code) AS s ON t.code = s.code + WHEN MATCHED THEN + UPDATE SET prefix=?, source_file=?, title=?, keywords=?, + char_count=?, output_path=?, images=?, html_text=?, + updated_at=GETDATE() + WHEN NOT MATCHED THEN + INSERT (prefix, code, source_file, title, keywords, char_count, output_path, + images, html_text) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); + """, + ps.code, # USING + prefix, ps.source_file, ps.title, ps.keywords, # UPDATE SET + ps.char_count, output_path, ps.images_json, ps.html_text, + prefix, ps.code, ps.source_file, ps.title, ps.keywords, # INSERT + ps.char_count, output_path, ps.images_json, ps.html_text) + self.conn.commit() + + def close(self): + self.conn.close() + + +# ────────────────────────────────────────────── +# Парсъри +# ────────────────────────────────────────────── + +def file_hash(path: Path) -> str: + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(65536), b""): + h.update(chunk) + return h.hexdigest() + + +def _load_html_image(src: str, base_dir: Path) -> Optional[tuple[bytes, str]]: + """Връща (data, ext) или None. Пропуска HTTP/HTTPS.""" + if not src: + return None + s = src.strip() + if s.startswith("data:"): + # data:image/png;base64,XXXX + m = re.match(r"data:([^;]+);base64,(.+)$", s, re.DOTALL) + if not m: + return None + import base64 + try: + data = base64.b64decode(m.group(2)) + except Exception: + return None + return data, _ext_from_content_type(m.group(1)) + if s.startswith(("http://", "https://")): + return None # по правило пропускаме мрежови картинки + # локален път, относителен или абсолютен + p = (base_dir / s).resolve() if not Path(s).is_absolute() else Path(s) + try: + if p.is_file(): + data = p.read_bytes() + ext = p.suffix.lstrip(".").lower() or "png" + return data, ext + except Exception: + return None + return None + + +def _detect_html_encoding(raw: bytes) -> str: + """Връща име на encoding: BOM → chardet → fallback (utf-8 ако ASCII, иначе windows-1251).""" + # BOM-и + if raw.startswith(b"\xef\xbb\xbf"): + return "utf-8" + if raw.startswith((b"\xff\xfe", b"\xfe\xff")): + return "utf-16" + # chardet + try: + import chardet + det = chardet.detect(raw[:65536]) or {} + enc = (det.get("encoding") or "").lower() + conf = det.get("confidence", 0) or 0 + if enc and conf >= 0.6: + # нормализиране на често срещани имена + if enc in ("cp1251", "ms-cyrl", "windows-1251"): + return "windows-1251" + if enc.startswith("utf"): + return enc + return enc + except Exception: + pass + # fallback: ако байтовете изглеждат "над 127" (т.е. има не-ASCII), приемаме CP1251 + if any(b > 127 for b in raw[:8192]): + return "windows-1251" + return "utf-8" + + +_HTML_BLOCK_TAGS = ["h1", "h2", "h3", "h4", "h5", "h6", + "p", "ul", "ol", "table", "dl", "pre", + "blockquote", "figure", "hr"] +_HTML_DROP_ATTRS = ("class", "style", "id", "lang", "dir", "align", + "valign", "width", "height", "bgcolor", "border") + + +def _strip_attrs(el): + """Премахва decorative атрибути (class, style, on*, data-*).""" + for t in el.find_all(True): + for a in list(t.attrs): + if a in _HTML_DROP_ATTRS or a.startswith("on") or a.startswith("data-"): + del t[a] + + +def _swap_imgs_in_block(el, base_dir: Path, sec_images: list, img_counter: list) -> None: + """Намира всички в подадения елемент, извлича данните и подменя с + NavigableString placeholder ([IMG: img_NN]).""" + from bs4 import NavigableString + for img in el.find_all("img"): + src = img.get("src") or img.get("data-src") or "" + loaded = _load_html_image(src, base_dir) + if not loaded: + img.decompose() + continue + data, ext = loaded + if not _should_keep_image(data): + img.decompose() + continue + img_counter[0] += 1 + ref = ImageRef(placeholder=f"img_{img_counter[0]:02d}", data=data, ext=ext) + sec_images.append(ref) + img.replace_with(NavigableString(f"[IMG: {ref.placeholder}]")) + + +def parse_html(path: Path) -> list[Section]: + raw = path.read_bytes() + enc = _detect_html_encoding(raw) + log.debug(f" {path.name} encoding: {enc}") + try: + soup = BeautifulSoup(raw, "lxml", from_encoding=enc) + except Exception: + soup = BeautifulSoup(raw, "lxml") + + # Премахваме скриптове и стилове + for tag in soup(["script", "style", "nav", "footer", "header", "noscript"]): + tag.decompose() + + base_dir = path.parent + body = soup.body or soup + + heading_map = {"h1": 1, "h2": 2, "h3": 3, "h4": 3, "h5": 3, "h6": 3} + + # Събираме top-level блокови елементи (без да включваме вложените в тях) + consumed = set() + blocks = [] + for el in body.find_all(_HTML_BLOCK_TAGS + ["img"]): + if any(id(par) in consumed for par in el.parents): + continue + consumed.add(id(el)) + blocks.append(el) + + sections: list[Section] = [] + current_title = "" + current_level = 1 + sec_text: list[str] = [] + sec_html: list[str] = [] + sec_images: list[ImageRef] = [] + img_counter = [0] + + def flush(): + if sec_text or sec_html or sec_images: + sec = Section(current_title, "\n".join(sec_text), current_level) + sec.images = list(sec_images) + sec.html_text = "\n".join(sec_html) if sec_html else None + sections.append(sec) + + for el in blocks: + if el.name in heading_map: + txt = el.get_text(" ", strip=True) + if not txt: + continue + flush() + current_title = txt + current_level = heading_map[el.name] + sec_text, sec_html, sec_images = [], [], [] + continue + + if el.name == "img": + # самостоятелен (не вътре в блок) + _swap_imgs_in_block(el.parent if el.parent and el.parent.name else el, + base_dir, sec_images, img_counter) + # ако е заменен с placeholder, добавяме като текст + txt = el.get_text(" ", strip=True) if el.name else "" + if txt: + sec_text.append(txt) + sec_html.append(f"

{txt}

") + continue + + _swap_imgs_in_block(el, base_dir, sec_images, img_counter) + _strip_attrs(el) + txt = el.get_text(" ", strip=True) + if txt: + sec_text.append(txt) + try: + sec_html.append(str(el)) + except Exception: + pass + + flush() + + if not sections: + plain = body.get_text(" ", strip=True) + return [Section("", plain, 0)] + return sections + + +def _extract_docx_paragraph_images(para, doc) -> list[ImageRef]: + """Намира drawing-и в параграф; връща ImageRef-и за филтрираните по размер.""" + from docx.oxml.ns import qn + imgs: list[ImageRef] = [] + try: + blips = para._element.findall(".//" + qn("a:blip")) + except Exception: + return imgs + + embed_attr = qn("r:embed") + for blip in blips: + rId = blip.get(embed_attr) + if not rId: + continue + try: + part = doc.part.related_parts[rId] + data = part.blob + ct = getattr(part, "content_type", "") or "" + except Exception: + continue + if not _should_keep_image(data): + continue + ext = _ext_from_content_type(ct) + imgs.append(ImageRef(placeholder=f"__IMG_{len(imgs)+1}__", data=data, ext=ext)) + return imgs + + +def parse_docx(path: Path) -> list[Section]: + doc = Document(path) + sections: list[Section] = [] + current_title, current_level = "", 1 + buf: list[str] = [] + sec_images: list[ImageRef] = [] + img_counter = [0] # списък за nonlocal-стил мутация + + HEADING_STYLES = {"heading 1": 1, "heading 2": 2, "heading 3": 3, + "title": 1, "subtitle": 2} + + def flush(): + if buf or sec_images: + sec = Section(current_title, "\n".join(buf), current_level) + sec.images = list(sec_images) + sections.append(sec) + + for para in doc.paragraphs: + style_name = para.style.name.lower() if para.style else "" + text = para.text.strip() + para_imgs = _extract_docx_paragraph_images(para, doc) + + if not text and not para_imgs: + continue + + level = HEADING_STYLES.get(style_name) + is_bold_heading = bool(text and len(text) < 120 and not style_name.startswith("list") + and para.runs + and all(run.bold for run in para.runs if run.text.strip())) + + if level or (is_bold_heading and not para_imgs): + flush() + buf, sec_images = [], [] + current_title = text + current_level = level or 2 + continue + + if text: + buf.append(text) + for im in para_imgs: + img_counter[0] += 1 + im.placeholder = f"img_{img_counter[0]:02d}" + sec_images.append(im) + buf.append(f"[IMG: {im.placeholder}]") + + flush() + + if not sections: + fallback_text = "\n".join(p.text for p in doc.paragraphs if p.text.strip()) + return [Section("", fallback_text, 0)] + return sections + + +def _convert_doc_with_libreoffice(path: Path, out_dir: Path) -> Optional[Path]: + try: + subprocess.run( + ["soffice", "--headless", "--convert-to", "docx", + "--outdir", str(out_dir), str(path)], + check=True, capture_output=True, timeout=60 + ) + except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired) as e: + log.debug(f"LibreOffice конверсия неуспешна: {e}") + return None + out = list(out_dir.glob("*.docx")) + return out[0] if out else None + + +def _convert_doc_with_word(path: Path, out_dir: Path) -> Optional[Path]: + """Fallback: ползва MS Word през COM на Windows.""" + try: + import win32com.client # noqa: F401 + import pythoncom + except ImportError: + log.debug("pywin32 не е инсталиран — MS Word fallback недостъпен.") + return None + + import win32com.client as wcc + pythoncom.CoInitialize() + word = None + doc = None + try: + word = wcc.DispatchEx("Word.Application") + word.Visible = False + word.DisplayAlerts = False + doc = word.Documents.Open(str(path.resolve()), ReadOnly=True) + out_path = out_dir / (path.stem + ".docx") + # FileFormat=16 → wdFormatXMLDocument (.docx) + doc.SaveAs2(str(out_path.resolve()), FileFormat=16) + return out_path if out_path.exists() else None + except Exception as e: + log.debug(f"MS Word конверсия неуспешна: {e}") + return None + finally: + try: + if doc is not None: + doc.Close(SaveChanges=False) + except Exception: + pass + try: + if word is not None: + word.Quit() + except Exception: + pass + pythoncom.CoUninitialize() + + +def parse_doc_old(path: Path) -> list[Section]: + """Конвертира стар .doc до .docx чрез LibreOffice или MS Word, после парси.""" + with tempfile.TemporaryDirectory() as tmp: + tmp_dir = Path(tmp) + + converted = _convert_doc_with_libreoffice(path, tmp_dir) + engine = "LibreOffice" + + if not converted: + converted = _convert_doc_with_word(path, tmp_dir) + engine = "MS Word" + + if not converted: + log.warning( + f"Нито LibreOffice, нито MS Word успяха да конвертират {path.name}. " + f"Пробваме като текст." + ) + return parse_txt(path) + + log.info(f" {path.name} конвертиран чрез {engine}") + return parse_docx(converted) + + +def _render_pdf_image(page, img_info, resolution: int = 150) -> Optional[bytes]: + """Кропва картинката от PDF страницата и я записва като PNG bytes.""" + try: + x0 = float(img_info.get("x0", 0)) + x1 = float(img_info.get("x1", 0)) + top = float(img_info.get("top", img_info.get("y0", 0))) + bot = float(img_info.get("bottom", img_info.get("y1", 0))) + if x1 <= x0 or bot <= top: + return None + # ограничаваме до страницата (pdfplumber иначе хвърля) + x0 = max(0, x0); top = max(0, top) + x1 = min(page.width, x1); bot = min(page.height, bot) + if x1 - x0 < 1 or bot - top < 1: + return None + cropped = page.crop((x0, top, x1, bot)) + pil = cropped.to_image(resolution=resolution).original + from io import BytesIO + buf = BytesIO() + pil.save(buf, format="PNG") + return buf.getvalue() + except Exception as e: + log.debug(f"PDF image render failed: {e}") + return None + + +def parse_pdf(path: Path) -> list[Section]: + if not HAS_PDF: + log.warning("pdfplumber не е инсталиран. PDF се прескача.") + return [] + + sections: list[Section] = [] + current_title = "" + buf: list[str] = [] + sec_images: list[ImageRef] = [] + img_counter = [0] + prev_size = None + + def flush(): + if buf or sec_images: + sec = Section(current_title, "\n".join(buf), 2) + sec.images = list(sec_images) + sections.append(sec) + + with pdfplumber.open(path) as pdf: + for page in pdf.pages: + # Картинките за страницата (сортирани по y отгоре надолу) + page_images = sorted( + page.images or [], + key=lambda im: float(im.get("top", im.get("y0", 0))) + ) + img_queue = [] + for im in page_images: + data = _render_pdf_image(page, im) + if not data or not _should_keep_image(data): + continue + img_queue.append((float(im.get("top", 0)), data)) + + words = page.extract_words(extra_attrs=["size"]) + line_buf, line_size = [], None + + def emit_images_before(y: float): + while img_queue and img_queue[0][0] <= y: + _, data = img_queue.pop(0) + img_counter[0] += 1 + ref = ImageRef(placeholder=f"img_{img_counter[0]:02d}", + data=data, ext="png") + sec_images.append(ref) + buf.append(f"[IMG: {ref.placeholder}]") + + for w in words: + sz = round(float(w.get("size", 10)), 1) + y = float(w.get("top", 0)) + if line_size is None: + line_size = sz + if abs(sz - line_size) > 1: + line_text = " ".join(line_buf).strip() + if line_text: + if line_size > (prev_size or 10) + 1 and len(line_text) < 150: + flush() + buf, sec_images = [], [] + current_title = line_text + else: + emit_images_before(y) + buf.append(line_text) + prev_size = line_size + line_buf, line_size = [w["text"]], sz + else: + line_buf.append(w["text"]) + + if line_buf: + emit_images_before(page.height) + buf.append(" ".join(line_buf)) + + # картинките след всичкия текст на страницата + emit_images_before(page.height + 1) + + flush() + + return sections or [Section("", "", 0)] + + +def parse_txt(path: Path) -> list[Section]: + import chardet + raw = path.read_bytes() + enc = chardet.detect(raw)["encoding"] or "utf-8" + text = raw.decode(enc, errors="replace") + return [Section("", text, 0)] + + +PARSERS = { + ".html": parse_html, + ".htm": parse_html, + ".docx": parse_docx, + ".doc": parse_doc_old, + ".txt": parse_txt, + ".pdf": parse_pdf, +} + + +# ────────────────────────────────────────────── +# Сегментиране и почистване +# ────────────────────────────────────────────── + +def merge_short_sections(sections: list[Section]) -> list[Section]: + """Слива секции, по-кратки от MIN_SECTION_TOKENS думи, с предишната.""" + result: list[Section] = [] + for sec in sections: + words = len(sec.text.split()) + if result and words < MIN_SECTION_TOKENS: + prev = result[-1] + merged = Section( + prev.title, + prev.text + "\n" + sec.text, + prev.level, + ) + merged.images = (prev.images or []) + (sec.images or []) + html_parts = [h for h in (prev.html_text, sec.html_text) if h] + merged.html_text = "\n".join(html_parts) if html_parts else None + result[-1] = merged + else: + result.append(sec) + return result + + +def clean_text(text: str) -> str: + text = re.sub(r"\s+", " ", text) + text = re.sub(r" {2,}", " ", text) + return text.strip() + + +# ────────────────────────────────────────────── +# AI класификация +# ────────────────────────────────────────────── + +def classify_section(client: anthropic.Anthropic, title: str, text: str) -> tuple[str, str]: + """Връща (наименование, 'кл1, кл2, кл3') чрез Claude.""" + snippet = text[:MAX_AI_CHARS] + prompt = f"""Анализирай следната секция от help-документация и върни JSON обект с два ключа: +- "title": кратко наименование на секцията (до 8 думи, на езика на текста) +- "keywords": списък от до 5 ключови думи/фрази, разделени със запетая (на езика на текста) + +Съществуващо заглавие (може да е празно): {title!r} + +Текст: +{snippet} + +Върни САМО валиден JSON без markdown, без коментари.""" + + msg = client.messages.create( + model=AI_MODEL, + max_tokens=200, + messages=[{"role": "user", "content": prompt}] + ) + raw = msg.content[0].text.strip() + raw = re.sub(r"^```[a-z]*\n?", "", raw) + raw = re.sub(r"\n?```$", "", raw) + + try: + data = json.loads(raw) + t = str(data.get("title", title or "Секция"))[:200] + k = str(data.get("keywords", ""))[:300] + return t, k + except json.JSONDecodeError: + log.warning(f"AI върна невалиден JSON: {raw[:120]}") + return title or "Секция", "" + + +# ────────────────────────────────────────────── +# Генериране на кодове +# ────────────────────────────────────────────── + +def make_code(prefix: str, file_index: int, sec_index: int) -> str: + return f"{prefix}_{file_index:04d}_SEC_{sec_index:04d}" + + +# ────────────────────────────────────────────── +# Основна обработка +# ────────────────────────────────────────────── + +def process_file( + path: Path, + file_index: int, + db: Database, + client: anthropic.Anthropic, + output_dir: Path, + prefix: str = "HLP", + force: bool = False, +) -> int: + """Обработва един файл. Връща броя записани секции (0 = пропуснат).""" + rel = str(path) + fh = file_hash(path) + + if not force: + stored = db.get_file_hash(prefix, rel) + if stored == fh: + log.info(f" [SKIP] {path.name} (непроменен)") + return 0 + + log.info(f" [PROC] {path.name}") + ext = path.suffix.lower() + parser = PARSERS.get(ext) + if not parser: + log.warning(f" Неподдържан формат: {ext}") + return 0 + + try: + sections = parser(path) + except Exception as e: + log.error(f" Грешка при парсване: {e}") + return 0 + + sections = merge_short_sections(sections) + + # Изтриваме старите секции за файла при повторна обработка + db.delete_sections_for_file(prefix, rel) + + images_dir = output_dir / "images" + images_dir.mkdir(parents=True, exist_ok=True) + + saved = 0 + for i, sec in enumerate(sections, 1): + text = clean_text(sec.text) + html_text = sec.html_text or "" + if not text and not sec.images and not html_text: + continue + + code = make_code(prefix, file_index, i) + + # Записваме картинките на диск и заменяме placeholder-ите в текста + HTML + image_rel_paths: list[str] = [] + for ref in sec.images or []: + fname = f"{code}_{ref.placeholder}.{ref.ext}" + disk_path = images_dir / fname + try: + disk_path.write_bytes(ref.data) + except Exception as e: + log.warning(f" Грешка при запис на картинка {fname}: {e}") + continue + rel_path = f"images/{fname}" + image_rel_paths.append(rel_path) + old_ph = f"[IMG: {ref.placeholder}]" + new_ph = f"[IMG: {rel_path}]" + text = text.replace(old_ph, new_ph) + html_text = html_text.replace(old_ph, new_ph) + + # Премахваме placeholder-и, останали без файл + text = _IMG_PLACEHOLDER_RE.sub( + lambda m: m.group(0) if "/" in m.group(1) or "\\" in m.group(1) else "", + text + ).strip() + html_text = _IMG_PLACEHOLDER_RE.sub( + lambda m: m.group(0) if "/" in m.group(1) or "\\" in m.group(1) else "", + html_text + ).strip() + if not text and not image_rel_paths and not html_text: + continue + + try: + title, keywords = classify_section(client, sec.title, text) + except Exception as e: + log.warning(f" AI грешка за {code}: {e}") + title, keywords = sec.title or f"Секция {i}", "" + + images_json = json.dumps(image_rel_paths, ensure_ascii=False) + ps = ProcessedSection( + code=code, + source_file=rel, + title=title, + keywords=keywords, + text=text, + images_json=images_json, + html_text=html_text, + ) + + # Записваме текста в изходна директория + out_path = output_dir / f"{code}.txt" + out_path.write_text( + f"КОД: {code}\nФАЙЛ: {rel}\nЗАГЛАВИЕ: {title}\nКЛЮЧОВИ ДУМИ: {keywords}\n" + f"КАРТИНКИ: {len(image_rel_paths)}\n" + f"{'─'*60}\n{text}", + encoding="utf-8" + ) + + db.insert_section(prefix, ps, str(out_path)) + saved += 1 + log.debug(f" {code}: {title[:60]} ({len(image_rel_paths)} img)") + + db.upsert_file(prefix, rel, fh, saved) + log.info(f" → {saved} секции записани") + return saved + + +_PREFIX_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]{0,49}$") + + +def process_directory( + input_dir: Path, + output_dir: Path, + conn_str: str, + api_key: str, + prefix: str = "HLP", + force: bool = False, + purge_missing: bool = False, +): + if not _PREFIX_RE.match(prefix): + raise ValueError( + f"Невалиден prefix {prefix!r}. Допустими: буква + букви/цифри/подчертавки, до 50 символа." + ) + + output_dir.mkdir(parents=True, exist_ok=True) + db = Database(conn_str) + client = anthropic.Anthropic(api_key=api_key) + + extensions = set(PARSERS.keys()) + output_resolved = output_dir.resolve() + + def _under_output(p: Path) -> bool: + try: + p.resolve().relative_to(output_resolved) + return True + except ValueError: + return False + + files = [ + p for p in input_dir.rglob("*") + if p.is_file() and p.suffix.lower() in extensions and not _under_output(p) + ] + log.info(f"Prefix={prefix} Намерени {len(files)} файла в {input_dir}") + + current_paths = {str(p) for p in files} + total_sections = 0 + try: + for idx, path in enumerate(sorted(files), 1): + n = process_file(path, idx, db, client, output_dir, + prefix=prefix, force=force) + total_sections += n + + if purge_missing: + existing = set(db.all_source_files(prefix)) + orphans = sorted(existing - current_paths) + if not orphans: + log.info(f"Purge: няма orphan записи в БД за prefix={prefix}.") + else: + log.info(f"Purge ({prefix}): намерени {len(orphans)} orphan източника:") + for o in orphans: + log.info(f" - {o}") + disk_paths = db.section_output_paths_for(prefix, orphans) + removed_files = 0 + for op in disk_paths: + try: + opath = Path(op) + if opath.exists(): + opath.unlink() + removed_files += 1 + code = opath.stem + for img in (output_dir / "images").glob(f"{code}_*"): + try: + img.unlink() + removed_files += 1 + except Exception: + pass + except Exception as e: + log.debug(f" не успях да изтрия {op}: {e}") + deleted = db.purge_sources(prefix, orphans) + log.info(f"Purge: изтрити {deleted} секции от БД, {removed_files} файла от диска.") + finally: + db.close() + + log.info(f"Готово. Prefix={prefix}. Общо нови/обновени секции: {total_sections}") + + +# ────────────────────────────────────────────── +# CLI +# ────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser( + description="Help-файл декомпозитор с SQL Server + Anthropic" + ) + parser.add_argument("input_dir", help="Входна директория с help-файлове") + parser.add_argument("output_dir", help="Изходна директория за текстови секции") + parser.add_argument( + "--conn", + default=os.getenv("HELP_DB_CONN"), + help="SQL Server connection string (или HELP_DB_CONN env var)" + ) + parser.add_argument( + "--api-key", + default=os.getenv("ANTHROPIC_API_KEY"), + help="Anthropic API ключ (или ANTHROPIC_API_KEY env var)" + ) + parser.add_argument( + "--prefix", + default=os.getenv("HELP_PREFIX", "HLP"), + help="Префикс за кодовете/scope в БД (буква + букви/цифри/_, до 50 знака). " + "Default: 'HLP' (или env HELP_PREFIX)." + ) + parser.add_argument( + "--force", + action="store_true", + help="Преобработва всички файлове, независимо от hash" + ) + parser.add_argument( + "--purge-missing", + action="store_true", + help="След обработката изтрива от БД и диска секциите за източници, " + "които вече не съществуват във входната директория (само в дадения prefix)" + ) + args = parser.parse_args() + + if not args.api_key: + sys.exit("Грешка: липсва Anthropic API ключ (--api-key или ANTHROPIC_API_KEY).") + if not args.conn: + sys.exit("Грешка: липсва SQL Server connection string (--conn или HELP_DB_CONN).") + + process_directory( + input_dir=Path(args.input_dir), + output_dir=Path(args.output_dir), + conn_str=args.conn, + api_key=args.api_key, + prefix=args.prefix, + force=args.force, + purge_missing=args.purge_missing, + ) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..157aa1e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +anthropic>=0.25.0 +pyodbc>=5.0.0 +python-docx>=1.1.0 +beautifulsoup4>=4.12.0 +lxml>=5.0.0 +pdfplumber>=0.11.0 +chardet>=5.0.0 diff --git a/save_keywords.py b/save_keywords.py new file mode 100644 index 0000000..58a714b --- /dev/null +++ b/save_keywords.py @@ -0,0 +1,77 @@ +""" +save_keywords.py +================ +Чете keywords_changes.json (генериран от браузъра) +и записва промените в SQL Server. + +Стартирай с: python save_keywords.py +""" + +import os, sys, json +from pathlib import Path +from datetime import datetime + +try: + import pyodbc +except ImportError: + sys.exit("Инсталирай pyodbc: pip install pyodbc") + +CONN_STR = os.getenv( + "HELP_DB_CONN", + "DRIVER={ODBC Driver 18 for SQL Server};" + "TrustServerCertificate=yes;" + "SERVER=94.26.63.238,13151;DATABASE=blondina;" + "UID=blondina_login;PWD=blondina_parola_123" +) +CHANGES_FILE = Path(__file__).parent / "keywords_changes.json" + + +def main(): + if not CHANGES_FILE.exists(): + print("Файлът keywords_changes.json не е намерен.") + print("Запази промените от браузъра първо.") + return + + changes = json.loads(CHANGES_FILE.read_text(encoding="utf-8")) + if not changes: + print("Няма промени за запис.") + return + + print(f"Записвам {len(changes)} промени в БД...") + conn = pyodbc.connect(CONN_STR, autocommit=False) + cur = conn.cursor() + ok, err = 0, 0 + + for item in changes: + code = item.get("code", "").strip() + keywords = item.get("keywords", "").strip() + if not code: + continue + try: + cur.execute( + "UPDATE RIP_help_sections SET keywords=?, updated_at=GETDATE() WHERE code=?", + keywords, code + ) + if cur.rowcount > 0: + ok += 1 + print(f" ✓ {code}") + else: + print(f" ? {code} — не е намерен в БД") + except Exception as e: + print(f" ✗ {code} — {e}") + err += 1 + + conn.commit() + conn.close() + + print(f"\nГотово: {ok} записани, {err} грешки.") + + # Архивираме файла + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + archive = CHANGES_FILE.parent / f"keywords_changes_{ts}.json" + CHANGES_FILE.rename(archive) + print(f"Файлът е архивиран като: {archive.name}") + + +if __name__ == "__main__": + main() diff --git a/view.bat b/view.bat new file mode 100644 index 0000000..2666db2 --- /dev/null +++ b/view.bat @@ -0,0 +1,21 @@ +:@echo off +chcp 65001 > nul +call "%~dp0_load_env.bat" || exit /b 1 +set PYTHONIOENCODING=utf-8 + +rem Optional: %1 = prefix filter (e.g. RIP, INEX_TM). Empty = show all. +if "%~1"=="" ( + echo Generate help_viewer.html from DB ^(all prefixes^) + python generate_html.py +) else ( + echo Generate help_viewer.html from DB ^(prefix=%~1^) + python generate_html.py --prefix=%~1 +) + +echo. +echo ok. browser should be open +echo. +echo to write changes in key words back into DB +echo python save_keywords.py +echo. +pause