Align Coolify webapp with WinSCP share and remote output paths.

Resolve section text from OUTPUT_DIR by basename, add /api/search and richer /healthz, default mount to /mnt/.../Output, support --remote-root in processor.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-25 19:47:09 +03:00
parent 9613420d1d
commit a81d5794d4
9 changed files with 342 additions and 145 deletions

View File

@@ -4,3 +4,9 @@ REM HELP_DB_CONN е libpq формат за PostgreSQL.
ANTHROPIC_API_KEY=sk-ant-api03-XXXXXXXXXXXXXXXXXXXXXXXXX ANTHROPIC_API_KEY=sk-ant-api03-XXXXXXXXXXXXXXXXXXXXXXXXX
HELP_DB_CONN=host=192.168.88.18 port=5432 dbname=rip_help_system user=postgres password=YOUR_PASSWORD HELP_DB_CONN=host=192.168.88.18 port=5432 dbname=rip_help_system user=postgres password=YOUR_PASSWORD
HELP_REMOTE_ROOT=/mnt/mssql/share/RIP/RIP_Help_Source/Output
REM Coolify Environment Variables (същите ключове):
REM HELP_DB_CONN=...
REM OUTPUT_DIR=/mnt/mssql/share/RIP/RIP_Help_Source/Output
REM HOME_IMAGE=/mnt/mssql/share/RIP/RIP_Help_Source/Output/Bairaci.png

4
.gitignore vendored
View File

@@ -19,6 +19,10 @@ keywords_changes*.json
Output/ Output/
output/ output/
# Local WIP / obsolete
_local_wip/
api/
# Archives # Archives
*.zip *.zip
*.tar.gz *.tar.gz

38
COOLIFY.md Normal file
View File

@@ -0,0 +1,38 @@
# Coolify — RIP Help System
## Build (Configuration → General)
| Поле | Стойност |
|------|----------|
| Build Pack | Dockerfile |
| Base Directory | `/` |
| Dockerfile Location | `/Dockerfile` |
Dockerfile се редактира **само в git** (няма Edit Dockerfile след Add Resource).
## Environment Variables
```
HELP_DB_CONN=host=... port=5432 dbname=rip_help_system user=... password=...
OUTPUT_DIR=/mnt/mssql/share/RIP/RIP_Help_Source/Output
HOME_IMAGE=/mnt/mssql/share/RIP/RIP_Help_Source/Output/Bairaci.png
```
## Persistent Storage
| Host path | Container path | Mode |
|-----------|----------------|------|
| `/mnt/mssql/share/RIP/RIP_Help_Source/Output` | `/mnt/mssql/share/RIP/RIP_Help_Source/Output` | read-only OK |
(Файловете се качват с WinSCP в този host path.)
## Проверки след Redeploy
1. `/healthz` → `status/db ok`, `output_dir_ok: true`, `txt_count` > 0
2. `/api/sections?prefix=RIP` → `text` не е празен
3. `/api/search?q=поръчки&prefix=RIP`
4. `/?prefix=RIP` — dashboard/viewer
## Порт
`8000`

View File

@@ -8,11 +8,13 @@ WORKDIR /app
COPY requirements-webapp.txt /app/ COPY requirements-webapp.txt /app/
RUN pip install --no-cache-dir -r requirements-webapp.txt RUN pip install --no-cache-dir -r requirements-webapp.txt
# Кода # Кода + home image в образа (fallback ако липсва в share)
COPY webapp /app/webapp COPY webapp /app/webapp
COPY Bairaci.png /app/Bairaci.png
# Изходна директория за картинките — Docker volume mount # Секции/картинки — Coolify Persistent Storage mount
ENV OUTPUT_DIR=/data/help_output ENV OUTPUT_DIR=/mnt/mssql/share/RIP/RIP_Help_Source/Output
ENV HOME_IMAGE=/app/Bairaci.png
EXPOSE 8000 EXPOSE 8000

View File

@@ -1,9 +1,10 @@
:@echo off @echo off
chcp 65001 > nul chcp 65001 > nul
call "%~dp0_load_env.bat" || exit /b 1 call "%~dp0_load_env.bat" || exit /b 1
set PYTHONIOENCODING=utf-8 set PYTHONIOENCODING=utf-8
if not defined HELP_REMOTE_ROOT set HELP_REMOTE_ROOT=/mnt/mssql/share/RIP/RIP_Help_Source/Output
echo === INCREMENTAL prefix=RIP === echo === INCREMENTAL prefix=RIP remote=%HELP_REMOTE_ROOT% ===
echo. echo.
python help_processor.py --prefix=RIP "q:\RIP_Help_Source" "q:\RIP_Help_Source\Output" python help_processor.py --prefix=RIP --remote-root="%HELP_REMOTE_ROOT%" "q:\RIP_Help_Source" "q:\RIP_Help_Source\Output"
pause pause

View File

@@ -1,9 +1,10 @@
:@echo off @echo off
chcp 65001 > nul chcp 65001 > nul
call "%~dp0_load_env.bat" || exit /b 1 call "%~dp0_load_env.bat" || exit /b 1
set PYTHONIOENCODING=utf-8 set PYTHONIOENCODING=utf-8
if not defined HELP_REMOTE_ROOT set HELP_REMOTE_ROOT=/mnt/mssql/share/RIP/RIP_Help_Source/Output
echo === FORCE + PURGE prefix=RIP === echo === FORCE + PURGE prefix=RIP remote=%HELP_REMOTE_ROOT% ===
echo. echo.
python help_processor.py --prefix=RIP --force --purge-missing "q:\RIP_Help_Source" "q:\RIP_Help_Source\Output" python help_processor.py --prefix=RIP --force --purge-missing --remote-root="%HELP_REMOTE_ROOT%" "q:\RIP_Help_Source" "q:\RIP_Help_Source\Output"
pause pause

View File

@@ -1,21 +1,16 @@
# Local dev / Coolify production # Local / Coolify
# За local: docker compose up --build # Coolify: git deploy + env vars + persistent storage (не задължително compose)
# За Coolify: качваш през git, Coolify сам пуска docker build + run
version: '3.8'
services: services:
webapp: webapp:
build: . build: .
container_name: rip-help-webapp container_name: rip-help-webapp
environment: environment:
# libpq формат (Postgres). Коригирай към твоя Postgres host. HELP_DB_CONN: "${HELP_DB_CONN}"
HELP_DB_CONN: "host=192.168.88.18 port=5432 dbname=rip_help_system user=sa password=Parola~12345!!!" OUTPUT_DIR: "/mnt/mssql/share/RIP/RIP_Help_Source/Output"
OUTPUT_DIR: "/data/help_output" HOME_IMAGE: "/mnt/mssql/share/RIP/RIP_Help_Source/Output/Bairaci.png"
HOME_IMAGE: "/data/help_output/Bairaci.png"
volumes: volumes:
# Картинките се очакват в OUTPUT_DIR/images/ # Host share (качен с WinSCP) → същият път в контейнера
# На сървъра ще качваш с WinSCP/rsync (напр. /home/sabo/share/help_output) - /mnt/mssql/share/RIP/RIP_Help_Source/Output:/mnt/mssql/share/RIP/RIP_Help_Source/Output:ro
- ./data:/data/help_output:ro
ports: ports:
- "8000:8000" - "8000:8000"
restart: unless-stopped restart: unless-stopped

View File

@@ -842,6 +842,14 @@ def make_code(prefix: str, file_index: int, sec_index: int) -> str:
# Основна обработка # Основна обработка
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
def _db_output_path(local_path: Path, code: str, remote_root: Optional[str]) -> str:
"""Локален staging файл; в БД — сървърен път ако е зададен remote_root."""
if remote_root:
root = remote_root.replace("\\", "/").rstrip("/")
return f"{root}/{code}.txt"
return str(local_path)
def process_file( def process_file(
path: Path, path: Path,
file_index: int, file_index: int,
@@ -850,6 +858,7 @@ def process_file(
output_dir: Path, output_dir: Path,
prefix: str = "HLP", prefix: str = "HLP",
force: bool = False, force: bool = False,
remote_root: Optional[str] = None,
) -> int: ) -> int:
"""Обработва един файл. Връща броя записани секции (0 = пропуснат).""" """Обработва един файл. Връща броя записани секции (0 = пропуснат)."""
rel = str(path) rel = str(path)
@@ -937,7 +946,7 @@ def process_file(
html_text=html_text, html_text=html_text,
) )
# Записваме текста в изходна директория # Локален staging; в БД — remote път (ако е зададен) за Coolify/API
out_path = output_dir / f"{code}.txt" out_path = output_dir / f"{code}.txt"
out_path.write_text( out_path.write_text(
f"КОД: {code}\nФАЙЛ: {rel}\nЗАГЛАВИЕ: {title}\nКЛЮЧОВИ ДУМИ: {keywords}\n" f"КОД: {code}\nФАЙЛ: {rel}\nЗАГЛАВИЕ: {title}\nКЛЮЧОВИ ДУМИ: {keywords}\n"
@@ -946,7 +955,7 @@ def process_file(
encoding="utf-8" encoding="utf-8"
) )
db.insert_section(prefix, ps, str(out_path)) db.insert_section(prefix, ps, _db_output_path(out_path, code, remote_root))
saved += 1 saved += 1
log.debug(f" {code}: {title[:60]} ({len(image_rel_paths)} img)") log.debug(f" {code}: {title[:60]} ({len(image_rel_paths)} img)")
@@ -966,6 +975,7 @@ def process_directory(
prefix: str = "HLP", prefix: str = "HLP",
force: bool = False, force: bool = False,
purge_missing: bool = False, purge_missing: bool = False,
remote_root: Optional[str] = None,
): ):
if not _PREFIX_RE.match(prefix): if not _PREFIX_RE.match(prefix):
raise ValueError( raise ValueError(
@@ -991,13 +1001,17 @@ def process_directory(
if p.is_file() and p.suffix.lower() in extensions and not _under_output(p) if p.is_file() and p.suffix.lower() in extensions and not _under_output(p)
] ]
log.info(f"Prefix={prefix} Намерени {len(files)} файла в {input_dir}") log.info(f"Prefix={prefix} Намерени {len(files)} файла в {input_dir}")
if remote_root:
log.info(f"DB output_path root: {remote_root.replace(chr(92), '/').rstrip('/')}")
current_paths = {str(p) for p in files} current_paths = {str(p) for p in files}
total_sections = 0 total_sections = 0
try: try:
for idx, path in enumerate(sorted(files), 1): for idx, path in enumerate(sorted(files), 1):
n = process_file(path, idx, db, client, output_dir, n = process_file(
prefix=prefix, force=force) path, idx, db, client, output_dir,
prefix=prefix, force=force, remote_root=remote_root,
)
total_sections += n total_sections += n
if purge_missing: if purge_missing:
@@ -1013,11 +1027,19 @@ def process_directory(
removed_files = 0 removed_files = 0
for op in disk_paths: for op in disk_paths:
try: try:
code = Path(str(op).replace("\\", "/")).stem
local_txt = output_dir / f"{code}.txt"
if local_txt.exists():
local_txt.unlink()
removed_files += 1
opath = Path(op) opath = Path(op)
if opath.exists(): if opath.exists():
opath.unlink() try:
removed_files += 1 if opath.resolve() != local_txt.resolve():
code = opath.stem opath.unlink()
removed_files += 1
except Exception:
pass
for img in (output_dir / "images").glob(f"{code}_*"): for img in (output_dir / "images").glob(f"{code}_*"):
try: try:
img.unlink() img.unlink()
@@ -1040,14 +1062,14 @@ def process_directory(
def main(): def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Help-файл декомпозитор с SQL Server + Anthropic" description="Help-файл декомпозитор с PostgreSQL + Anthropic"
) )
parser.add_argument("input_dir", help="Входна директория с help-файлове") parser.add_argument("input_dir", help="Входна директория с help-файлове")
parser.add_argument("output_dir", help="Изходна директория за текстови секции") parser.add_argument("output_dir", help="Изходна директория за текстови секции")
parser.add_argument( parser.add_argument(
"--conn", "--conn",
default=os.getenv("HELP_DB_CONN"), default=os.getenv("HELP_DB_CONN"),
help="SQL Server connection string (или HELP_DB_CONN env var)" help="Postgres libpq connection string (или HELP_DB_CONN env var)"
) )
parser.add_argument( parser.add_argument(
"--api-key", "--api-key",
@@ -1071,12 +1093,20 @@ def main():
help="След обработката изтрива от БД и диска секциите за източници, " help="След обработката изтрива от БД и диска секциите за източници, "
"които вече не съществуват във входната директория (само в дадения prefix)" "които вече не съществуват във входната директория (само в дадения prefix)"
) )
parser.add_argument(
"--remote-root",
default=os.getenv("HELP_REMOTE_ROOT"),
help="Сървърен корен за output_path в БД "
"(напр. /mnt/mssql/share/RIP/RIP_Help_Source/Output). "
"Файловете се пишат локално; в БД се записва remote път. "
"Или env HELP_REMOTE_ROOT."
)
args = parser.parse_args() args = parser.parse_args()
if not args.api_key: if not args.api_key:
sys.exit("Грешка: липсва Anthropic API ключ (--api-key или ANTHROPIC_API_KEY).") sys.exit("Грешка: липсва Anthropic API ключ (--api-key или ANTHROPIC_API_KEY).")
if not args.conn: if not args.conn:
sys.exit("Грешка: липсва SQL Server connection string (--conn или HELP_DB_CONN).") sys.exit("Грешка: липсва Postgres connection string (--conn или HELP_DB_CONN).")
process_directory( process_directory(
input_dir=Path(args.input_dir), input_dir=Path(args.input_dir),
@@ -1086,6 +1116,7 @@ def main():
prefix=args.prefix, prefix=args.prefix,
force=args.force, force=args.force,
purge_missing=args.purge_missing, purge_missing=args.purge_missing,
remote_root=args.remote_root,
) )

View File

@@ -2,63 +2,58 @@
FastAPI webapp за RIP Help System. FastAPI webapp за RIP Help System.
Endpoint-и: Endpoint-и:
GET / — viewer HTML (с опционален ?prefix=&home=) GET / — viewer HTML (?prefix=&home=)
GET /images/{path} — статичен сервинг на картинки от OUTPUT_DIR GET /images/{filename} — картинки от OUTPUT_DIR/images
GET /home-image — home image ако HOME_IMAGE е зададен GET /home-image — Bairaci / HOME_IMAGE
GET /api/sections — JSON списък със секции (?prefix=) GET /api/sections — JSON секции (?prefix=)
POST /api/keywords/{code} — обновяване на keywords в БД GET /api/search — търсене (?q=&prefix=)
GET /healthz — health check GET /api/section/{code} — една секция с пълен текст
POST /api/keywords/{code} — обновяване на keywords
GET /healthz — health + OUTPUT_DIR статус
Конфигурация (env vars): Env:
HELP_DB_CONN — libpq формат за Postgres HELP_DB_CONN — libpq Postgres
OUTPUT_DIR — директорията със секциите и картинките (default: ./data) OUTPUT_DIR — локален/mounted път към секциите (default: share)
HOME_IMAGE — път към home картинка (опционален) HOME_IMAGE — път към home картинка
""" """
import os, sys, json, re from __future__ import annotations
from pathlib import Path
import json
import os
import re
from datetime import datetime from datetime import datetime
from pathlib import Path
from typing import Optional from typing import Optional
import psycopg2 import psycopg2
from fastapi import FastAPI, HTTPException, Request, Query from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from pydantic import BaseModel from pydantic import BaseModel
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
# Configuration # Configuration
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
CONN_STR = os.getenv( DEFAULT_SHARE = "/mnt/mssql/share/RIP/RIP_Help_Source/Output"
"HELP_DB_CONN",
"host=192.168.88.18 port=5432 dbname=rip_help_system user=sa password=Parola~12345!!!"
)
OUTPUT_DIR = Path(os.getenv("OUTPUT_DIR", Path(__file__).parent.parent / "data"))
HOME_IMAGE = os.getenv("HOME_IMAGE") # абсолютен път или None
CONN_STR = os.getenv(
"HELP_DB_CONN",
"host=192.168.88.18 port=5432 dbname=rip_help_system user=sa password=Parola~12345!!!",
)
OUTPUT_DIR = Path(os.getenv("OUTPUT_DIR", DEFAULT_SHARE))
HOME_IMAGE = os.getenv("HOME_IMAGE") # absolute path or None
_IMG_PLACEHOLDER_RE = re.compile(r"\[IMG:\s*([^\]]+?)\s*\]") _IMG_PLACEHOLDER_RE = re.compile(r"\[IMG:\s*([^\]]+?)\s*\]")
# ──────────────────────────────────────────────
# App
# ──────────────────────────────────────────────
BASE_DIR = Path(__file__).parent BASE_DIR = Path(__file__).parent
app = FastAPI(title="RIP Help System") app = FastAPI(title="RIP Help System", version="0.2.0")
templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
# Static mount за картинките — само ако директорията съществува
_images_dir = OUTPUT_DIR / "images"
if _images_dir.exists():
app.mount("/images", StaticFiles(directory=str(_images_dir)), name="images")
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
# DB # Helpers
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
def db_conn(): def db_conn():
@@ -66,19 +61,60 @@ def db_conn():
def _esc(s: str) -> str: def _esc(s: str) -> str:
return (str(s or "") return (
.replace("&", "&amp;").replace("<", "&lt;") str(s or "")
.replace(">", "&gt;").replace('"', "&quot;")) .replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)
def _basename(path_str: str) -> str:
return Path(str(path_str).replace("\\", "/")).name
def _resolve_section_txt(code: str, output_path: Optional[str]) -> Optional[Path]:
"""Намира .txt в OUTPUT_DIR по code / basename (игнорира q:\\ от БД)."""
candidates = []
if code:
candidates.append(OUTPUT_DIR / f"{code}.txt")
if output_path:
candidates.append(OUTPUT_DIR / _basename(output_path))
# ако output_path вече е абсолютен linux път и съществува
p = Path(output_path)
if p.is_file():
candidates.insert(0, p)
for c in candidates:
try:
if c.is_file():
return c
except OSError:
continue
return None
def _read_section_body(code: str, output_path: Optional[str]) -> str:
path = _resolve_section_txt(code, output_path)
if not path:
return ""
try:
raw = path.read_text(encoding="utf-8")
except Exception:
try:
raw = path.read_text(encoding="cp1251")
except Exception:
return ""
parts = raw.split("─" * 60, 1)
return parts[1].strip() if len(parts) > 1 else raw
def _text_to_html(text: str) -> str: def _text_to_html(text: str) -> str:
"""Конвертира [IMG: images/foo.png] към <img src="/images/foo.png">."""
parts = [] parts = []
last = 0 last = 0
for m in _IMG_PLACEHOLDER_RE.finditer(text): for m in _IMG_PLACEHOLDER_RE.finditer(text):
parts.append(_esc(text[last:m.start()])) parts.append(_esc(text[last:m.start()]))
rel = m.group(1).strip().replace("\\", "/") rel = m.group(1).strip().replace("\\", "/")
# Очакваме path като "images/<filename>" — превръщаме в URL /images/<filename>
fname = rel.split("/", 1)[1] if rel.startswith("images/") else rel fname = rel.split("/", 1)[1] if rel.startswith("images/") else rel
parts.append( parts.append(
f'<img src="/images/{_esc(fname)}" alt="" ' f'<img src="/images/{_esc(fname)}" alt="" '
@@ -91,70 +127,77 @@ def _text_to_html(text: str) -> str:
def _rich_html_with_images(html: str) -> str: def _rich_html_with_images(html: str) -> str:
"""Подменя [IMG: ...] placeholder-и с <img src="/images/...">; не escape-ва HTML."""
def sub(m): def sub(m):
rel = m.group(1).strip().replace("\\", "/") rel = m.group(1).strip().replace("\\", "/")
fname = rel.split("/", 1)[1] if rel.startswith("images/") else rel fname = rel.split("/", 1)[1] if rel.startswith("images/") else rel
return (f'<img src="/images/{_esc(fname)}" alt="" ' return (
f'style="max-width:100%;max-height:240px;display:block;margin:8px 0;' f'<img src="/images/{_esc(fname)}" alt="" '
f'border:1px solid #d8dce3;border-radius:6px">') f'style="max-width:100%;max-height:240px;display:block;margin:8px 0;'
f'border:1px solid #d8dce3;border-radius:6px">'
)
return _IMG_PLACEHOLDER_RE.sub(sub, html) return _IMG_PLACEHOLDER_RE.sub(sub, html)
def fetch_sections(prefix: Optional[str] = None) -> list[dict]: def _row_to_dict(cols, r, *, full_text: bool = False) -> dict:
conn = db_conn() d = dict(zip(cols, r))
cur = conn.cursor() d["updated_at"] = str(d["updated_at"])[:16] if d["updated_at"] else ""
if prefix: try:
cur.execute(""" d["images"] = json.loads(d["images"]) if d.get("images") else []
SELECT s.prefix, s.code, s.title, s.keywords, s.char_count, except Exception:
s.source_file, s.output_path, s.updated_at, d["images"] = []
s.images, s.html_text, f.section_count
FROM rip_help_sections s body = _read_section_body(d.get("code") or "", d.get("output_path"))
LEFT JOIN rip_help_files f # нормализиран път за клиента / дебъг
ON f.file_path = s.source_file AND f.prefix = s.prefix resolved = _resolve_section_txt(d.get("code") or "", d.get("output_path"))
WHERE s.prefix = %s d["resolved_path"] = str(resolved) if resolved else None
ORDER BY s.code
""", (prefix,)) if d.get("html_text"):
d["text_html"] = _rich_html_with_images(d["html_text"])
d["text"] = body if full_text else (body[:800] if body else "")
else: else:
cur.execute(""" if full_text:
SELECT s.prefix, s.code, s.title, s.keywords, s.char_count, d["text"] = body
s.source_file, s.output_path, s.updated_at, d["text_html"] = _text_to_html(body) if body else ""
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 ""
try:
d["images"] = json.loads(d["images"]) if d.get("images") else []
except Exception:
d["images"] = []
# Render rich HTML или fallback към plain text + image placeholders
if d.get("html_text"):
d["text_html"] = _rich_html_with_images(d["html_text"])
else: else:
# Plain text fallback — четем от output_path ако може d["text"] = body[:800] if body else ""
body = ""
op = d.get("output_path")
if op:
# Опитваме се да намерим файла в OUTPUT_DIR (по basename)
txt_name = Path(op).name
txt_path = OUTPUT_DIR / txt_name
if txt_path.exists():
try:
raw = txt_path.read_text(encoding="utf-8")
parts = raw.split("─" * 60, 1)
body = parts[1].strip() if len(parts) > 1 else raw
except Exception:
pass
d["text"] = body[:800]
d["text_html"] = _text_to_html(body[:1200]) if body else "" d["text_html"] = _text_to_html(body[:1200]) if body else ""
rows.append(d) return d
def fetch_sections(
prefix: Optional[str] = None,
q: Optional[str] = None,
*,
full_text: bool = False,
) -> list[dict]:
conn = db_conn()
cur = conn.cursor()
sql = """
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 1=1
"""
params: list = []
if prefix:
sql += " AND s.prefix = %s"
params.append(prefix)
if q:
like = f"%{q}%"
sql += """ AND (
s.code ILIKE %s OR s.title ILIKE %s OR COALESCE(s.keywords,'') ILIKE %s
OR COALESCE(s.html_text,'') ILIKE %s
)"""
params.extend([like, like, like, like])
sql += " ORDER BY s.prefix, s.code"
cur.execute(sql, params)
cols = [c[0] for c in cur.description]
rows = [_row_to_dict(cols, r, full_text=full_text) for r in cur.fetchall()]
conn.close() conn.close()
return rows return rows
@@ -164,38 +207,91 @@ def fetch_sections(prefix: Optional[str] = None) -> list[dict]:
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
def viewer(request: Request, def viewer(
prefix: Optional[str] = Query(None), request: Request,
home: Optional[str] = Query(None)): prefix: Optional[str] = Query(None),
home: Optional[str] = Query(None),
):
sections = fetch_sections(prefix) sections = fetch_sections(prefix)
home_url = "/home-image" if (home or HOME_IMAGE) else None home_url = "/home-image" if (home or HOME_IMAGE or (BASE_DIR.parent / "Bairaci.png").exists()) else None
return templates.TemplateResponse(request, "viewer.html", { return templates.TemplateResponse(
"sections_json": json.dumps(sections, ensure_ascii=False, default=str), request,
"section_count": len(sections), "viewer.html",
"prefix": prefix or "", {
"home_url": home_url, "sections_json": json.dumps(sections, ensure_ascii=False, default=str),
"generated": datetime.now().strftime("%d.%m.%Y %H:%M"), "section_count": len(sections),
}) "prefix": prefix or "",
"home_url": home_url,
"generated": datetime.now().strftime("%d.%m.%Y %H:%M"),
},
)
@app.get("/home-image") @app.get("/home-image")
def home_image(): def home_image():
img_path = HOME_IMAGE img_path = HOME_IMAGE
# Ако HOME_IMAGE не е зададен, пробваме Bairaci.png в проекта
if not img_path: if not img_path:
candidate = BASE_DIR.parent / "Bairaci.png" for candidate in (
if candidate.exists(): OUTPUT_DIR / "Bairaci.png",
img_path = str(candidate) OUTPUT_DIR.parent / "Bairaci.png",
BASE_DIR.parent / "Bairaci.png",
):
if candidate.is_file():
img_path = str(candidate)
break
if not img_path or not Path(img_path).is_file(): if not img_path or not Path(img_path).is_file():
raise HTTPException(404, "home image not configured") raise HTTPException(404, "home image not configured")
return FileResponse(img_path) return FileResponse(img_path)
@app.get("/images/{filename:path}")
def serve_image(filename: str):
# само basename — без path traversal
safe = Path(filename).name
path = OUTPUT_DIR / "images" / safe
if not path.is_file():
raise HTTPException(404, f"image not found: {safe}")
return FileResponse(path)
@app.get("/api/sections") @app.get("/api/sections")
def api_sections(prefix: Optional[str] = Query(None)): def api_sections(prefix: Optional[str] = Query(None)):
return JSONResponse(fetch_sections(prefix)) return JSONResponse(fetch_sections(prefix))
@app.get("/api/search")
def api_search(
q: str = Query(..., min_length=1),
prefix: Optional[str] = Query(None),
):
rows = fetch_sections(prefix, q=q.strip())
return JSONResponse({"q": q, "prefix": prefix, "count": len(rows), "results": rows})
@app.get("/api/section/{code}")
def api_section(code: str):
conn = db_conn()
cur = conn.cursor()
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.code = %s
""",
(code,),
)
row = cur.fetchone()
cols = [c[0] for c in cur.description] if cur.description else []
conn.close()
if not row:
raise HTTPException(404, f"section {code} not found")
return JSONResponse(_row_to_dict(cols, row, full_text=True))
class KeywordsUpdate(BaseModel): class KeywordsUpdate(BaseModel):
keywords: str keywords: str
@@ -203,10 +299,10 @@ class KeywordsUpdate(BaseModel):
@app.post("/api/keywords/{code}") @app.post("/api/keywords/{code}")
def update_keywords(code: str, body: KeywordsUpdate): def update_keywords(code: str, body: KeywordsUpdate):
conn = db_conn() conn = db_conn()
cur = conn.cursor() cur = conn.cursor()
cur.execute( cur.execute(
"UPDATE rip_help_sections SET keywords=%s, updated_at=NOW() WHERE code=%s", "UPDATE rip_help_sections SET keywords=%s, updated_at=NOW() WHERE code=%s",
(body.keywords, code) (body.keywords, code),
) )
if cur.rowcount == 0: if cur.rowcount == 0:
conn.close() conn.close()
@@ -218,12 +314,35 @@ def update_keywords(code: str, body: KeywordsUpdate):
@app.get("/healthz") @app.get("/healthz")
def healthz(): def healthz():
db_status = "ok"
try: try:
conn = db_conn() conn = db_conn()
cur = conn.cursor() cur = conn.cursor()
cur.execute("SELECT 1") cur.execute("SELECT 1")
cur.fetchone() cur.fetchone()
conn.close() conn.close()
return {"status": "ok", "db": "ok"}
except Exception as e: except Exception as e:
return JSONResponse({"status": "error", "db": str(e)}, status_code=503) return JSONResponse(
{"status": "error", "db": str(e), "output_dir": str(OUTPUT_DIR)},
status_code=503,
)
out_ok = OUTPUT_DIR.is_dir()
sample = None
txt_count = 0
if out_ok:
try:
txts = list(OUTPUT_DIR.glob("*.txt"))
txt_count = len(txts)
sample = txts[0].name if txts else None
except Exception:
pass
return {
"status": "ok",
"db": db_status,
"output_dir": str(OUTPUT_DIR),
"output_dir_ok": out_ok,
"txt_count": txt_count,
"sample": sample,
}