# План: md-to-html — Streamlit UI + публичный API в Docker ## Context Сейчас в проекте есть CLI-скрипт `md_to_html.py`, который через GitHub Markdown API конвертирует `.md` файл в самодостаточный HTML (с CSS-шаблоном `md/template.html`). Нужно превратить его в сервис с двумя интерфейсами: 1. **Streamlit UI** — загрузить `.md`, нажать кнопку, увидеть превью HTML и скачать результат. 2. **Публичный REST API** — принимает markdown-текст, отдаёт готовый HTML. Для интеграции с другими приложениями. Всё это должно упаковываться в Docker-образ. Решения, зафиксированные по ходу обсуждения: - FastAPI и Streamlit живут в **одном контейнере** (два процесса, запуск через стартовый скрипт). - Возвращается **полная HTML-страница** с применённым `template.html` — и в API, и в превью Streamlit. - Читаем `GITHUB_TOKEN` из env (опционально, для обхода лимита 60/час). - API **без аутентификации и rate-limiting** — минимальный стартовый вариант. ## Архитектура ``` md-to-html/ ├── md_to_html.py # оставить как есть (работающий CLI) ├── md/template.html # без изменений, используется как раньше ├── app/ │ ├── __init__.py │ ├── converter.py # общая логика (вынесена из md_to_html.py) │ ├── api.py # FastAPI приложение │ └── streamlit_app.py # Streamlit UI ├── requirements.txt ├── Dockerfile ├── start.py # Python-супервизор: запускает uvicorn + streamlit ├── .dockerignore └── README.md # короткая инструкция запуска ``` Порты в контейнере: FastAPI — 8000, Streamlit — 8501. Оба пробрасываются наружу. ## Что делать ### 1. `app/converter.py` — общий модуль Вынести из `md_to_html.py` переиспользуемые функции (без изменения логики): - `render_markdown(markdown_text: str) -> str` — вызов GitHub API. Добавить чтение `GITHUB_TOKEN` из env: если задан, слать `Authorization: Bearer `. - `FirstHeadingParser` + `extract_title(html_text, fallback) -> str`. - `apply_template(template_text, html_text, title) -> str`. - Хелпер `convert(markdown_text: str, fallback_title: str = "Document") -> str` — объединяет три шага и читает `md/template.html` один раз (кэшировать через `functools.lru_cache`). Путь к шаблону: `Path(__file__).resolve().parent.parent / "md" / "template.html"`. `md_to_html.py` переписать так, чтобы он импортировал `convert` из `app.converter` — убрать дублирование. CLI-поведение сохранить (входной путь → записать рядом `.html`). ### 2. `app/api.py` — FastAPI Endpoints: - `POST /convert` - Тело: `{"markdown": "", "title": ""}` — Pydantic-модель с `field_validator` на `markdown`, проверяющим `len(value.encode("utf-8")) <= MAX_MARKDOWN_BYTES` (именно **байты UTF-8**, не `constr(max_length=...)` — тот считает символы). При превышении — `raise HTTPException(status_code=413, detail=...)`, чтобы обойти дефолтный `422` FastAPI-валидатора. - **Приоритет title (зафиксировано явно):** 1. Первый `` из отрендеренного HTML. 2. Если heading отсутствует — переданный `title` из запроса. 3. Если и его нет — `"Document"`. - Ответ: `text/html` с полной страницей (`Response(content=..., media_type="text/html; charset=utf-8")`). - Ошибки: пустой markdown → `400` (через отдельную проверку в роуте, до валидации); превышение размера → `413` (через ручной `HTTPException` в валидаторе, см. выше); `RuntimeError` от GitHub API → `502 Bad Gateway` с текстом исключения. - Дополнительно: `exception_handler(RequestValidationError)` перехватывает Pydantic-422 и возвращает структурированный `400` — чтобы публичный API не отдавал разные коды на разные виды плохого ввода. - `GET /health` → `{"status": "ok"}` для проверки. - `GET /ready` → проверяет, что шаблон загружен и при желании пингует `https://api.github.com` (опционально). **Лимит размера запроса (два уровня, defence-in-depth):** 1. **Hard guard — ASGI middleware до парсинга тела.** Читает `Content-Length` и, если превышает `MAX_REQUEST_BYTES = 1_200_000` (немного больше лимита на поле, чтобы учитывать JSON-обёртку), возвращает `413` без чтения тела. Если `Content-Length` отсутствует (chunked) — аккуратно считать байты из `receive()` и обрывать при превышении. Это настоящий request-size guard, не post-parse. 2. **Soft guard — Pydantic `field_validator` на поле `markdown`,** проверяющий `len(value.encode("utf-8")) <= MAX_MARKDOWN_BYTES` (`1_048_576`, 1 МБ) и поднимающий `HTTPException(413)`. Это вторая линия — на случай, если middleware обойдут (прокси, переписанные заголовки). GitHub Markdown API сам ограничивает вход ~400 КБ, поэтому 1 МБ на стороне сервиса — безопасный запас с отсечкой абьюза. Значения вынести в env `MAX_MARKDOWN_BYTES` и `MAX_REQUEST_BYTES`. CORS открыть для всех origin (`allow_origins=["*"]`, `allow_methods=["POST","GET"]`, `allow_headers=["content-type"]`). ### 3. `app/streamlit_app.py` — UI Минимальный интерфейс: 1. `st.title("Markdown → HTML")` 2. `st.file_uploader("Загрузите .md файл", type=["md", "markdown"])` 3. Кнопка `st.button("Конвертировать")` — активна только когда файл загружен. 4. После клика: вызвать `convert()` из `app.converter` напрямую (не через HTTP — это тот же Python-импорт, один процесс). 5. Результат (три способа посмотреть, без deprecated API и без iframe-споров): - **Inline-превью (approximate):** извлечь содержимое `` из результата (простой regex/BeautifulSoup — фрагмент HTML без `/`, без CSS из template) и отрендерить через `st.markdown(body_html, unsafe_allow_html=True)`. Это приблизительный рендер без стилей template — нужен для быстрой проверки разметки. Явно подписать: *«Inline-превью без стилей. Для точного вида — «Открыть превью в новой вкладке» или скачайте файл.»* - **Превью в новой вкладке:** `st.link_button("Открыть превью", url=f"data:text/html;charset=utf-8;base64,{b64(html_result)}")` — data-URL с полной страницей. Визуально идентично скачанному файлу, без component-API. **Fallback на большие документы:** если `len(html_result.encode()) > 1_500_000`, кнопка не рендерится, вместо неё показать `st.info("Документ слишком большой для превью в браузере. Скачайте файл.")` — браузеры ограничивают длину data-URL (~2 МБ в Chrome), а base64-кодирование раздувает payload в 1.33×. - **Скачивание:** `st.download_button("Скачать HTML", data=html_result, file_name=f"{stem}.html", mime="text/html")`. - **Сырой HTML:** `st.expander("Показать исходный HTML")` с `st.code(html_result, language="html")`. Обоснование отказа от `st.components.v1.html` / `st.components.v2.*`: project skill `developing-with-streamlit` помечает v1 как deprecated, а v2 — это API для custom components с Python↔JS (`st.components.v2.component()`), не для простого показа HTML. Для нашего случая native-решения (`st.markdown` + `st.link_button` с data-URL) достаточно. 6. Хранить результат в `st.session_state["html_result"]`, чтобы повторный rerun (клик по expander, download) не терял его и не гонял GitHub API заново. Обработка ошибок: `try/except RuntimeError` → `st.error(str(e))`. ### 4. Зависимости и окружение (локальная разработка) Локально использовать **`uv`** + виртуальное окружение, не системный `pip`: ```bash uv venv .venv # создать venv в .venv/ source .venv/bin/activate # (или `uv run ` без активации) uv pip install -r requirements.txt ``` Либо эквивалент через `uv pip sync requirements.txt`, либо (если решим сразу в pep-621-формате) — `pyproject.toml` + `uv sync`. Для данного плана достаточно плоского `requirements.txt` ради совместимости с Docker-образом, где `uv` не обязателен. `.gitignore`/`.dockerignore` — исключить `.venv/`. **`requirements.txt`:** ``` streamlit>=1.42 fastapi>=0.115 uvicorn[standard]>=0.32 pydantic>=2.9 ``` Streamlit зафиксирован `>=1.42` (актуальная стабильная ветка на момент планирования). Никаких HTTP-клиентов: `render_markdown` использует стандартную `urllib`. Для извлечения `` в inline-превью достаточно stdlib (`html.parser`) — ту же `HTMLParser`-базу, что уже применяется в `FirstHeadingParser`. Отдельная зависимость на BeautifulSoup не нужна. **В Docker-образе** `uv` не используется — остаёмся на `pip install --no-cache-dir -r requirements.txt`, чтобы не тащить лишний бинарь в runtime-образ. `uv` — только для локального dev-цикла. ### 5. `Dockerfile` - Базовый образ: `python:3.12-slim`. - Установить `tini` через apt (`apt-get update && apt-get install -y --no-install-recommends tini && rm -rf /var/lib/apt/lists/*`). - `WORKDIR /app`, скопировать `requirements.txt`, `pip install --no-cache-dir -r requirements.txt`. - Скопировать остальной код. - `EXPOSE 8000 8501`. - `ENTRYPOINT ["/usr/bin/tini", "--"]`, `CMD ["python", "start.py"]`. - `HEALTHCHECK` — см. секцию 6b. ### 6. Запуск двух процессов — `start.py` (супервизор) В одном контейнере два процесса — это риск (см. F-01 из ревью: упавший uvicorn, неубитые zombies, игнор сигналов PID 1). Вместо хрупкого shell-скрипта использовать маленький Python-супервизор, который: - стартует `uvicorn` и `streamlit` через `subprocess.Popen`; - пробрасывает `SIGTERM`/`SIGINT` обоим дочерним через `signal.signal`; - ждёт через `os.wait()` — **если любой из детей падает, супервизор убивает второго и выходит с его exit code** (контейнер корректно умирает и Docker перезапускает его по restart policy, а не живёт-зомби на одном сервисе); - после `SIGTERM` делает graceful shutdown с таймаутом (например 10 сек), затем `SIGKILL`. Скрипт ~40 строк, без дополнительных зависимостей. Альтернатива `tini` как init (`ENTRYPOINT ["/usr/bin/tini", "--"]`) — для reaping, но решение о fail-fast всё равно за супервизором. Ориентир (псевдокод, финализировать при реализации): ```python # start.py import os, signal, subprocess, sys procs = [ subprocess.Popen(["uvicorn", "app.api:app", "--host", "0.0.0.0", "--port", "8000"]), subprocess.Popen(["streamlit", "run", "app/streamlit_app.py", "--server.port", "8501", "--server.address", "0.0.0.0", "--server.headless", "true", "--browser.gatherUsageStats", "false"]), ] def shutdown(signum, _frame): for p in procs: p.terminate() signal.signal(signal.SIGTERM, shutdown) signal.signal(signal.SIGINT, shutdown) pid, status = os.wait() for p in procs: if p.pid != pid: p.terminate() sys.exit(os.waitstatus_to_exitcode(status)) ``` `Dockerfile` использует `CMD ["python", "start.py"]` плюс `ENTRYPOINT ["tini", "--"]` (ставится через `apt-get install -y --no-install-recommends tini`) для надёжного reaping. ### 6b. Healthcheck `HEALTHCHECK` в Dockerfile проверяет **оба** сервиса: ```dockerfile HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ CMD python -c "import urllib.request as u; \ u.urlopen('http://127.0.0.1:8000/health', timeout=3); \ u.urlopen('http://127.0.0.1:8501/_stcore/health', timeout=3)" || exit 1 ``` Если упадёт только Streamlit — healthcheck покраснеет, контейнер перезапустится (в паре с restart policy). ### 7. `.dockerignore` Исключить `.git`, `.DS_Store`, `.claude/`, `.agents/`, `.review-sandboxes/`, `md/*.html` (сгенерированное), `__pycache__/`, `*.pyc`, `venv/`, `.venv/`. ### 8. `README.md` Короткий блок: как собрать образ (`docker build -t md-to-html .`), как запустить (`docker run -p 8000:8000 -p 8501:8501 -e GITHUB_TOKEN=... md-to-html`), примеры `curl` для API. ## Критические файлы - **Создать:** `app/converter.py`, `app/api.py`, `app/streamlit_app.py`, `app/__init__.py`, `requirements.txt`, `Dockerfile`, `start.py`, `.dockerignore`, `README.md`. - **Изменить:** `md_to_html.py` (переписать на использование `app.converter.convert`). - **Без изменений:** `md/template.html`, `md/01-01-pretask.md`. ## Проверка (verification) 1. **Локально без Docker (через uv + venv):** - `uv venv .venv && source .venv/bin/activate && uv pip install -r requirements.txt`. - `uvicorn app.api:app --reload` → `curl -X POST http://localhost:8000/convert -H 'Content-Type: application/json' -d '{"markdown":"# Hello"}'` — должна вернуться полная HTML-страница с `Hello`. - `streamlit run app/streamlit_app.py` → загрузить `md/01-01-pretask.md`, нажать кнопку, убедиться что превью отрисовывается и скачивание работает. - `GET /health` → `{"status":"ok"}`. - CLI не сломался: `python md_to_html.py md/01-01-pretask.md` создаёт рядом `.html`, идентичный прежнему. 2. **Error paths API:** - `curl -X POST .../convert -d '{"markdown":""}'` → `400`. - `curl` с телом >1 МБ (поле `markdown` превышает `MAX_MARKDOWN_BYTES`) → `413` (validator). - `curl --data-binary @big.json` >1.2 МБ общего размера → `413` (middleware). - `curl -H "Transfer-Encoding: chunked" --data-binary @big.json` без `Content-Length` → `413` (middleware-ветка подсчёта байт из `receive()`). - Невалидный JSON (отсутствует поле `markdown`) → `400` (через `RequestValidationError` handler). - Имитация недоступности GitHub (временно подменить `API_URL` или поднять firewall rule) → `502`, контейнер не падает. 3. **Preview fallback в Streamlit:** загрузить синтетический markdown, дающий HTML >1.5 МБ — убедиться, что `link_button` скрывается и появляется `st.info` про скачивание; `download_button` при этом работает. 4. **В Docker:** - `docker build -t md-to-html .` — собирается без ошибок. - `docker run --rm -p 8000:8000 -p 8501:8501 md-to-html` — оба порта отвечают. - Открыть `http://localhost:8501`, прогнать сценарий из пункта 1. - `curl` на `http://localhost:8000/convert` работает снаружи контейнера. 5. **Supervision (F-01):** - Внутри работающего контейнера убить uvicorn (`docker exec ... pkill -f uvicorn`) → контейнер **должен завершиться** (не остаться с одним Streamlit). `docker ps` покажет рестарт. - `docker stop ` — оба процесса уходят в ≤10 сек, exit code корректный. - `docker inspect ... | grep Health` после 30 сек — `healthy`; после kill любого сервиса — `unhealthy`. 6. **С токеном:** `docker run -e GITHUB_TOKEN=ghp_... ...` — запросы проходят, в логах нет 403/429 при нагрузке.