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
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/
# Local WIP / obsolete
_local_wip/
api/
# Archives
*.zip
*.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/
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

View File

@@ -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

View File

@@ -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

View File

@@ -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

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(
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():
try:
if opath.resolve() != local_txt.resolve():
opath.unlink()
removed_files += 1
code = opath.stem
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,
)

View File

@@ -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
# ──────────────────────────────────────────────
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!!!"
"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
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("&", "&amp;").replace("<", "&lt;")
.replace(">", "&gt;").replace('"', "&quot;"))
return (
str(s or "")
.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:
"""Конвертира [IMG: images/foo.png] към <img src="/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/<filename>" — превръщаме в URL /images/<filename>
fname = rel.split("/", 1)[1] if rel.startswith("images/") else rel
parts.append(
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:
"""Подменя [IMG: ...] placeholder-и с <img src="/images/...">; не escape-ва HTML."""
def sub(m):
rel = m.group(1).strip().replace("\\", "/")
fname = rel.split("/", 1)[1] if rel.startswith("images/") else rel
return (f'<img src="/images/{_esc(fname)}" alt="" '
return (
f'<img src="/images/{_esc(fname)}" alt="" '
f'style="max-width:100%;max-height:240px;display:block;margin:8px 0;'
f'border:1px solid #d8dce3;border-radius:6px">')
f'border:1px solid #d8dce3;border-radius:6px">'
)
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,))
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():
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"] = []
# Render rich HTML или fallback към plain text + image placeholders
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:
# 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]
if full_text:
d["text"] = body
d["text_html"] = _text_to_html(body) if body else ""
else:
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,
def viewer(
request: Request,
prefix: Optional[str] = Query(None),
home: 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", {
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():
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
@@ -206,7 +302,7 @@ def update_keywords(code: str, body: KeywordsUpdate):
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.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,
}