# OmniSVG-Lite — Design > Архитектура MVP. Backend-dev может взять этот документ + `prompts/` и писать код без уточнений. --- ## 1. Стек и обоснование | Компонент | Выбор | Зачем | |---|---|---| | Язык | Python 3.11 | Зафиксировано пользователем. | | UI | Gradio 5 | `Blocks` + встроенный Gallery. Альтернативы (Streamlit, NiceGUI) хуже по лайауту. | | HTTP | httpx (sync) | Проще openai SDK, JSON+timeouts из коробки. | | Парсинг/валидация | lxml | Строгий, `XMLSyntaxError` с координатами, `iter()` для обхода. | | PNG-рендер | cairosvg | Декларативный, синхронный. Pillow+svglib — хуже на градиентах. | | БД | sqlite3 (stdlib) | WAL, 1 таблица. SQLAlchemy — overkill. | | Логирование | logging (stdlib) | Достаточно. | | Concurrency | threading (stdlib) | UI не фризит во время LM-вызова. | | Тесты | pytest | validator/renderer/prompts покрыты. | **Не берём:** `openai` SDK (httpx достаточно), `pydantic` (dataclass хватит), `tenacity` (одна попытка в MVP). --- ## 2. Структура модулей ``` OmniSVG-Lite/ ├── app.py # Gradio UI, callbacks, main() ├── lm_client.py # httpx-обёртка над LM Studio ├── prompts.py # Сборка messages[], загрузка шаблонов ├── validator.py # lxml-парсинг + проверка правил SVG ├── renderer.py # cairosvg → PNG bytes + сохранение ├── history.py # SQLite-схема, CRUD ├── data/{omnisvg.db, previews/_.png} ├── prompts/{system_icon.txt, system_illustration.txt, few_shot_examples.txt} ├── docs/{spec.md, design.md} └── tests/{test_validator.py, test_renderer.py, test_prompts.py} ``` ### 2.1. `lm_client.py` — httpx → LM Studio Импорт: `app.py`. Зависимости: httpx, base64, json, logging. ```python class LMTurnResult: raw_texts: list[str] # N строк, по одной на кандидат elapsed_s: float model: str usage: dict | None def chat(*, base_url, api_key, model, messages, n, temperature, max_tokens=4096, timeout_s=60.0) -> LMTurnResult: ... ``` ### 2.2. `prompts.py` — сборка messages Импорт: `app.py`. Зависимости: pathlib, prompts/. ```python def load_system_prompt(mode: Literal["icon","illustration"]) -> str: ... def load_few_shot() -> list[dict]: ... # [{role,content}, ...] def build_messages(*, mode, prompt, image_b64: str|None, palette: str|None, n: int, temperature: float) -> list[dict]: ... ``` ### 2.3. `validator.py` — lxml + правила Импорт: `app.py`. Зависимости: lxml.etree, re. ```python class ValidatorError(Exception): # code: malformed_xml|not_svg|missing_viewbox|bad_viewbox|disallowed_tag|unknown_tag|external_ref|too_large code: str; detail: str ALLOWED_TAGS = {"svg","g","defs","symbol","use","path","rect","circle","ellipse", "line","polygon","polyline","linearGradient","radialGradient","stop", "filter","feGaussianBlur","feOffset","feBlend","feMerge","feMergeNode", "feFlood","feComposite","feColorMatrix","clipPath","mask","pattern", "text","tspan","textPath","title","desc"} DISALLOWED_TAGS = {"script","foreignObject","image"} MAX_BYTES = {"icon": 32*1024, "illustration": 256*1024} def extract_svg(text: str) -> str: ... # regex def validate(svg_text: str, *, mode) -> etree._Element: ... # raises ValidatorError ``` ### 2.4. `renderer.py` — cairosvg Импорт: `app.py`. Зависимости: cairosvg, pathlib, io. ```python def svg_to_png_bytes(svg_text: str, *, output_width: int) -> bytes: ... # добавляет xmlns если нет, output_width=256 (icon) / 512 (illustration) def save_preview(png_bytes, *, job_id: int, candidate_index: int) -> Path: ... def to_pil_image(png_bytes: bytes) -> "PIL.Image.Image": ... ``` ### 2.5. `history.py` — SQLite CRUD Импорт: `app.py`. Зависимости: sqlite3, json, time, pathlib. ```python DB_PATH = Path("data/omnisvg.db") def init_db() -> None: ... # WAL mode def insert_job(*, prompt, mode, model, n_candidates, status, raw_outputs: list[str], validated_outputs: list[str], preview_paths: list[str], error: str|None = None) -> int: ... def list_jobs(limit: int = 50) -> list[dict]: ... # для History table def get_job(job_id: int) -> dict | None: ... ``` ### 2.6. `app.py` — UI + wiring Импорт: всё перечисленное + gradio. ```python def on_generate(prompt, mode, n, temperature, image, palette, model) -> \ tuple[list[tuple[str,str]], list[dict], str]: ... def on_history_select(evt: gr.SelectData): ... # row click def main() -> None: ... ``` --- ## 3. LM Studio client — детали - **POST** `http://127.0.0.1:1234/v1/chat/completions`, заголовки: `Content-Type: application/json`, `Authorization: Bearer lm-studio` (значение игнорируется, но требуется OpenAI-совместимым клиентом). - **`stream=false`** в payload: n>1 плохо сочетается со streaming, плюс инкрементальный парсинг сложнее. - **Payload (text-to-SVG):** `{"model", "messages":[{"role":"system","content":...}], "n", "temperature", "max_tokens":4096, "stream":false}`. - **Payload (image-to-SVG):** `content` это `[{type:"text", text:...}, {type:"image_url", image_url:{url:"data:image/png;base64,..."}}]` — текст **первым**, картинка **второй**. - **Парсинг ответа:** `choices[i].message.content` — N строк. `tool_calls` не передаём; если модель их всё же вернула, игнорируем (если `content` пустой — кандидат invalid). - **Кодирование картинки:** convert в RGB, проверка `max(size) <= 4096`, PIL save PNG optimize → base64 в data-URL. JPEG **не используем** (LM Studio VLM обучены на PNG, артефактов на границах не будет). --- ## 4. Промпт-стратегия **Структура messages[]:** ``` system: user: assistant: user: assistant: ... (3-5 пар) user: ``` **Требования к SVG в ответе модели:** - Один корневой `...`, **ничего больше** (никаких ``` ``` ```, никаких пояснений). - `viewBox` обязателен и совпадает с mode: `0 0 64 64` (icon) или `0 0 512 512` (illustration). - Без `