From a81d5794d4376af1ed87f38fa8d73f22daba12bd Mon Sep 17 00:00:00 2001 From: sabo Date: Tue, 25 Aug 2026 19:47:09 +0300 Subject: [PATCH] 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 --- .env.example | 6 + .gitignore | 4 + COOLIFY.md | 38 +++++ Dockerfile | 8 +- RIP_load.bat | 7 +- RIP_load_force.bat | 7 +- docker-compose.yml | 19 +-- help_processor.py | 51 +++++-- webapp/main.py | 347 ++++++++++++++++++++++++++++++--------------- 9 files changed, 342 insertions(+), 145 deletions(-) create mode 100644 COOLIFY.md diff --git a/.env.example b/.env.example index 02dfe91..cf7bc90 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,9 @@ REM HELP_DB_CONN е libpq формат за PostgreSQL. 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_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 diff --git a/.gitignore b/.gitignore index d8e5472..ec0f2d7 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,10 @@ keywords_changes*.json Output/ output/ +# Local WIP / obsolete +_local_wip/ +api/ + # Archives *.zip *.tar.gz diff --git a/COOLIFY.md b/COOLIFY.md new file mode 100644 index 0000000..f50bd6e --- /dev/null +++ b/COOLIFY.md @@ -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` diff --git a/Dockerfile b/Dockerfile index d2d1d48..72ccb4c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,11 +8,13 @@ WORKDIR /app COPY requirements-webapp.txt /app/ RUN pip install --no-cache-dir -r requirements-webapp.txt -# Кода +# Кода + home image в образа (fallback ако липсва в share) COPY webapp /app/webapp +COPY Bairaci.png /app/Bairaci.png -# Изходна директория за картинките — Docker volume mount -ENV OUTPUT_DIR=/data/help_output +# Секции/картинки — Coolify Persistent Storage mount +ENV OUTPUT_DIR=/mnt/mssql/share/RIP/RIP_Help_Source/Output +ENV HOME_IMAGE=/app/Bairaci.png EXPOSE 8000 diff --git a/RIP_load.bat b/RIP_load.bat index b825420..7dc4933 100644 --- a/RIP_load.bat +++ b/RIP_load.bat @@ -1,9 +1,10 @@ -:@echo off +@echo off chcp 65001 > nul call "%~dp0_load_env.bat" || exit /b 1 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. -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 diff --git a/RIP_load_force.bat b/RIP_load_force.bat index 8400e94..1c2ad9f 100644 --- a/RIP_load_force.bat +++ b/RIP_load_force.bat @@ -1,9 +1,10 @@ -:@echo off +@echo off chcp 65001 > nul call "%~dp0_load_env.bat" || exit /b 1 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. -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 diff --git a/docker-compose.yml b/docker-compose.yml index 76fc715..6371f14 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,21 +1,16 @@ -# Local dev / Coolify production -# За local: docker compose up --build -# За Coolify: качваш през git, Coolify сам пуска docker build + run -version: '3.8' - +# Local / Coolify +# Coolify: git deploy + env vars + persistent storage (не задължително compose) services: webapp: build: . container_name: rip-help-webapp environment: - # libpq формат (Postgres). Коригирай към твоя Postgres host. - HELP_DB_CONN: "host=192.168.88.18 port=5432 dbname=rip_help_system user=sa password=Parola~12345!!!" - OUTPUT_DIR: "/data/help_output" - HOME_IMAGE: "/data/help_output/Bairaci.png" + HELP_DB_CONN: "${HELP_DB_CONN}" + OUTPUT_DIR: "/mnt/mssql/share/RIP/RIP_Help_Source/Output" + HOME_IMAGE: "/mnt/mssql/share/RIP/RIP_Help_Source/Output/Bairaci.png" volumes: - # Картинките се очакват в OUTPUT_DIR/images/ - # На сървъра ще качваш с WinSCP/rsync (напр. /home/sabo/share/help_output) - - ./data:/data/help_output:ro + # Host share (качен с WinSCP) → същият път в контейнера + - /mnt/mssql/share/RIP/RIP_Help_Source/Output:/mnt/mssql/share/RIP/RIP_Help_Source/Output:ro ports: - "8000:8000" restart: unless-stopped diff --git a/help_processor.py b/help_processor.py index 8bf81c7..4013205 100644 --- a/help_processor.py +++ b/help_processor.py @@ -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( path: Path, file_index: int, @@ -850,6 +858,7 @@ def process_file( output_dir: Path, prefix: str = "HLP", force: bool = False, + remote_root: Optional[str] = None, ) -> int: """Обработва един файл. Връща броя записани секции (0 = пропуснат).""" rel = str(path) @@ -937,7 +946,7 @@ def process_file( html_text=html_text, ) - # Записваме текста в изходна директория + # Локален staging; в БД — remote път (ако е зададен) за Coolify/API out_path = output_dir / f"{code}.txt" out_path.write_text( f"КОД: {code}\nФАЙЛ: {rel}\nЗАГЛАВИЕ: {title}\nКЛЮЧОВИ ДУМИ: {keywords}\n" @@ -946,7 +955,7 @@ def process_file( 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 log.debug(f" {code}: {title[:60]} ({len(image_rel_paths)} img)") @@ -966,6 +975,7 @@ def process_directory( prefix: str = "HLP", force: bool = False, purge_missing: bool = False, + remote_root: Optional[str] = None, ): if not _PREFIX_RE.match(prefix): raise ValueError( @@ -991,13 +1001,17 @@ def process_directory( if p.is_file() and p.suffix.lower() in extensions and not _under_output(p) ] 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} 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) + n = process_file( + path, idx, db, client, output_dir, + prefix=prefix, force=force, remote_root=remote_root, + ) total_sections += n if purge_missing: @@ -1013,11 +1027,19 @@ def process_directory( removed_files = 0 for op in disk_paths: 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) if opath.exists(): - opath.unlink() - removed_files += 1 - code = opath.stem + try: + if opath.resolve() != local_txt.resolve(): + opath.unlink() + removed_files += 1 + except Exception: + pass for img in (output_dir / "images").glob(f"{code}_*"): try: img.unlink() @@ -1040,14 +1062,14 @@ def process_directory( def main(): parser = argparse.ArgumentParser( - description="Help-файл декомпозитор с SQL Server + Anthropic" + description="Help-файл декомпозитор с PostgreSQL + 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)" + help="Postgres libpq connection string (или HELP_DB_CONN env var)" ) parser.add_argument( "--api-key", @@ -1071,12 +1093,20 @@ def main(): help="След обработката изтрива от БД и диска секциите за източници, " "които вече не съществуват във входната директория (само в дадения 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() 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).") + sys.exit("Грешка: липсва Postgres connection string (--conn или HELP_DB_CONN).") process_directory( input_dir=Path(args.input_dir), @@ -1086,6 +1116,7 @@ def main(): prefix=args.prefix, force=args.force, purge_missing=args.purge_missing, + remote_root=args.remote_root, ) diff --git a/webapp/main.py b/webapp/main.py index 9400464..a7ae1e1 100644 --- a/webapp/main.py +++ b/webapp/main.py @@ -2,63 +2,58 @@ FastAPI webapp за RIP Help System. Endpoint-и: - GET / — viewer HTML (с опционален ?prefix=&home=) - GET /images/{path} — статичен сервинг на картинки от OUTPUT_DIR - GET /home-image — home image ако HOME_IMAGE е зададен - GET /api/sections — JSON списък със секции (?prefix=) - POST /api/keywords/{code} — обновяване на keywords в БД - GET /healthz — health check + GET / — viewer HTML (?prefix=&home=) + GET /images/{filename} — картинки от OUTPUT_DIR/images + GET /home-image — Bairaci / HOME_IMAGE + GET /api/sections — JSON секции (?prefix=) + GET /api/search — търсене (?q=&prefix=) + GET /api/section/{code} — една секция с пълен текст + POST /api/keywords/{code} — обновяване на keywords + GET /healthz — health + OUTPUT_DIR статус -Конфигурация (env vars): - HELP_DB_CONN — libpq формат за Postgres - OUTPUT_DIR — директорията със секциите и картинките (default: ./data) - HOME_IMAGE — път към home картинка (опционален) +Env: + HELP_DB_CONN — libpq Postgres + OUTPUT_DIR — локален/mounted път към секциите (default: share) + HOME_IMAGE — път към home картинка """ -import os, sys, json, re -from pathlib import Path +from __future__ import annotations + +import json +import os +import re from datetime import datetime +from pathlib import Path from typing import Optional import psycopg2 -from fastapi import FastAPI, HTTPException, Request, Query -from fastapi.responses import HTMLResponse, FileResponse, JSONResponse -from fastapi.staticfiles import StaticFiles +from fastapi import FastAPI, HTTPException, Query, Request +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from fastapi.templating import Jinja2Templates from pydantic import BaseModel - # ────────────────────────────────────────────── # Configuration # ────────────────────────────────────────────── -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", Path(__file__).parent.parent / "data")) -HOME_IMAGE = os.getenv("HOME_IMAGE") # абсолютен път или None +DEFAULT_SHARE = "/mnt/mssql/share/RIP/RIP_Help_Source/Output" +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*\]") - -# ────────────────────────────────────────────── -# App -# ────────────────────────────────────────────── - 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")) -# 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(): @@ -66,19 +61,60 @@ def db_conn(): def _esc(s: str) -> str: - return (str(s or "") - .replace("&", "&").replace("<", "<") - .replace(">", ">").replace('"', """)) + return ( + str(s or "") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + ) + + +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: - """Конвертира [IMG: images/foo.png] към .""" parts = [] last = 0 for m in _IMG_PLACEHOLDER_RE.finditer(text): parts.append(_esc(text[last:m.start()])) rel = m.group(1).strip().replace("\\", "/") - # Очакваме path като "images/" — превръщаме в URL /images/ fname = rel.split("/", 1)[1] if rel.startswith("images/") else rel parts.append( f' str: def _rich_html_with_images(html: str) -> str: - """Подменя [IMG: ...] placeholder-и с ; не escape-ва HTML.""" def sub(m): rel = m.group(1).strip().replace("\\", "/") fname = rel.split("/", 1)[1] if rel.startswith("images/") else rel - return (f'') + return ( + f'' + ) + return _IMG_PLACEHOLDER_RE.sub(sub, html) -def fetch_sections(prefix: Optional[str] = None) -> list[dict]: - conn = db_conn() - 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 = %s - ORDER BY s.code - """, (prefix,)) +def _row_to_dict(cols, r, *, full_text: bool = False) -> dict: + 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"] = [] + + body = _read_section_body(d.get("code") or "", d.get("output_path")) + # нормализиран път за клиента / дебъг + resolved = _resolve_section_txt(d.get("code") or "", d.get("output_path")) + d["resolved_path"] = str(resolved) if resolved else None + + 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: - 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 "" - 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"]) + if full_text: + d["text"] = body + d["text_html"] = _text_to_html(body) if body else "" else: - # Plain text fallback — четем от output_path ако може - 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"] = body[:800] 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() return rows @@ -164,38 +207,91 @@ def fetch_sections(prefix: Optional[str] = None) -> list[dict]: # ────────────────────────────────────────────── @app.get("/", response_class=HTMLResponse) -def viewer(request: Request, - prefix: Optional[str] = Query(None), - home: Optional[str] = Query(None)): +def viewer( + request: Request, + prefix: Optional[str] = Query(None), + home: Optional[str] = Query(None), +): sections = fetch_sections(prefix) - home_url = "/home-image" if (home or HOME_IMAGE) else None - return templates.TemplateResponse(request, "viewer.html", { - "sections_json": json.dumps(sections, ensure_ascii=False, default=str), - "section_count": len(sections), - "prefix": prefix or "", - "home_url": home_url, - "generated": datetime.now().strftime("%d.%m.%Y %H:%M"), - }) + home_url = "/home-image" if (home or HOME_IMAGE or (BASE_DIR.parent / "Bairaci.png").exists()) else None + return templates.TemplateResponse( + request, + "viewer.html", + { + "sections_json": json.dumps(sections, ensure_ascii=False, default=str), + "section_count": len(sections), + "prefix": prefix or "", + "home_url": home_url, + "generated": datetime.now().strftime("%d.%m.%Y %H:%M"), + }, + ) @app.get("/home-image") def home_image(): img_path = HOME_IMAGE - # Ако HOME_IMAGE не е зададен, пробваме Bairaci.png в проекта if not img_path: - candidate = BASE_DIR.parent / "Bairaci.png" - if candidate.exists(): - img_path = str(candidate) + for candidate in ( + OUTPUT_DIR / "Bairaci.png", + 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(): raise HTTPException(404, "home image not configured") 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") def api_sections(prefix: Optional[str] = Query(None)): 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): keywords: str @@ -203,10 +299,10 @@ class KeywordsUpdate(BaseModel): @app.post("/api/keywords/{code}") def update_keywords(code: str, body: KeywordsUpdate): conn = db_conn() - cur = conn.cursor() + cur = conn.cursor() cur.execute( "UPDATE rip_help_sections SET keywords=%s, updated_at=NOW() WHERE code=%s", - (body.keywords, code) + (body.keywords, code), ) if cur.rowcount == 0: conn.close() @@ -218,12 +314,35 @@ def update_keywords(code: str, body: KeywordsUpdate): @app.get("/healthz") def healthz(): + db_status = "ok" try: conn = db_conn() - cur = conn.cursor() + cur = conn.cursor() cur.execute("SELECT 1") cur.fetchone() conn.close() - return {"status": "ok", "db": "ok"} 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, + }