Files
omnisvg-lite/docs/design.md
T
Mavis 2394eff1c0 Initial commit: OmniSVG-Lite MVP before live-streaming work
- LM Studio client (httpx-based, OpenAI-compatible)
- SVG validator (lxml, whitelist tags, no <script>/<foreignObject>/http refs)
- PNG renderer (resvg-py primary, cairosvg fallback - no native cairo dep)
- History (SQLite, tracks raw/validated/preview paths)
- Gradio UI on 127.0.0.1:8788 with:
  * mode radio (icon/illustration)
  * n_candidates slider (default 1)
  * image upload for image-to-SVG
  * LM Studio URL/token inputs
  * model dropdown + refresh button
- prompts/ with system_icon.txt, system_illustration.txt, few_shot_examples.txt
- docs/spec.md, docs/design.md
- 122 unit/integration tests passing
2026-06-13 15:32:54 +03:00

19 KiB
Raw Blame History

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/<id>_<n>.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.

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/.

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.

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.

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.

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.

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: <system_icon.txt | system_illustration.txt>
user:    <few-shot user 1>
assistant: <few-shot svg 1>
user:    <few-shot user 2>
assistant: <few-shot svg 2>
... (3-5 пар)
user:    <actual user prompt + (опц.) image>

Требования к SVG в ответе модели:

  • Один корневой <svg>...</svg>, ничего больше (никаких ```, никаких пояснений).
  • viewBox обязателен и совпадает с mode: 0 0 64 64 (icon) или 0 0 512 512 (illustration).
  • Без <script>, <foreignObject>, <image>, http://, https://, data: в href.
  • Размер файла ≤ лимита mode (32/256 KB).
  • xmlns="http://www.w3.org/2000/svg" желателен (валидатор примет и без, но cairosvg ругается — добавляем автоматически в renderer).

Few-shot (готов в prompts/few_shot_examples.txt): 2 иконки (filled magnifying glass, outline battery), 2 иллюстрации (flat fox, isometric server room), 1 пример refusal (NSFW-отказ без SVG).


5. Валидация — что проверяет lxml

def validate(svg_text, *, mode):
    svg_text = extract_svg(svg_text)                            # вытащить <svg>...</svg>
    try:
        root = etree.fromstring(svg_text.encode("utf-8"))
    except etree.XMLSyntaxError as e:
        raise ValidatorError("malformed_xml", str(e))
    local_root = root.tag.split("}")[-1]
    if local_root != "svg": raise ValidatorError("not_svg", root.tag)
    if not root.get("viewBox"): raise ValidatorError("missing_viewbox", "")
    try:
        vb = [float(x) for x in root.get("viewBox").split()]
        if len(vb) != 4: raise ValueError
    except ValueError:
        raise ValidatorError("bad_viewbox", root.get("viewBox"))
    for el in root.iter():
        local = el.tag.split("}")[-1]
        if local in DISALLOWED_TAGS: raise ValidatorError("disallowed_tag", local)
        if local not in ALLOWED_TAGS:  raise ValidatorError("unknown_tag", local)
        for attr, val in el.attrib.items():
            if val and ("http://" in val or "https://" in val):
                if attr.endswith("href") or "url(" in val:
                    raise ValidatorError("external_ref", f"{attr}={val[:50]}")
    if len(svg_text.encode("utf-8")) > MAX_BYTES[mode]:
        raise ValidatorError("too_large", f"{len(svg_text)} bytes")
    return root

SVG_NS = "http://www.w3.org/2000/svg". lxml возвращает теги в Clark-notation {ns}local.


6. Рендер

def svg_to_png_bytes(svg_text, *, output_width):
    if "xmlns=" not in svg_text.split(">", 1)[0]:
        svg_text = svg_text.replace("<svg", '<svg xmlns="http://www.w3.org/2000/svg"', 1)
    return cairosvg.svg2png(
        bytestring=svg_text.encode("utf-8"),
        output_width=output_width,           # 256 icon, 512 illustration
        background_color="white",
    )

PNG bytes → save_preview (файл) + to_pil_image (для Gallery). Лучший получает caption "★ best — #1".

Windows + Cairo: если pycairo поставлен через conda — работает. Через pip — может потребоваться GTK runtime. В README отметить, в MVP не чинить.


7. История — SQLite схема

CREATE TABLE IF NOT EXISTS jobs (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    created_at      REAL    NOT NULL,                -- time.time()
    prompt          TEXT    NOT NULL,
    mode            TEXT    NOT NULL CHECK (mode IN ('icon','illustration')),
    model           TEXT    NOT NULL,
    n_candidates    INTEGER NOT NULL,
    temperature     REAL    NOT NULL,
    status          TEXT    NOT NULL,                -- ok | all_invalid | error
    error           TEXT,
    raw_outputs     TEXT    NOT NULL,                -- JSON list[str]
    validated_outputs TEXT  NOT NULL,                -- JSON list[str]
    preview_paths   TEXT    NOT NULL,                -- JSON list[str] (rel paths)
    best_index      INTEGER                          -- индекс лучшего или NULL
);
CREATE INDEX IF NOT EXISTS idx_jobs_created_at ON jobs(created_at DESC);

WAL mode в init_db(): PRAGMA journal_mode=WAL. JSON через json.dumps/loads. 1 запись = 1 запуск, N кандидатов внутри.


8. Gradio UI

8.1. Layout (ASCII)

┌─────────────────────────────────────────────────────────────────┐
│ OmniSVG-Lite                                       [Model: ▼]  │
├──────────────────┬──────────────────────────────────────────────┤
│ Prompt           │ Gallery (PNG превью)                          │
│ [textbox]        │ [★best] [#2] [#3]                            │
│ Reference image  │                                              │
│ [image upload]   │ Selected SVG:                                │
│ Palette (opt)    │ [code block]                                 │
│ [textbox]        │                                              │
│ Mode             │ History                                      │
│ (•) icon ( ) ill │ [gr.Dataframe]                               │
│ Candidates 1..8  │                                              │
│ [slider]         │                                              │
│ Temperature      │                                              │
│ [slider]         │                                              │
│ [Generate]       │                                              │
│ Status: [md]     │                                              │
└──────────────────┴──────────────────────────────────────────────┘

8.2. Компоненты

gr.X Имя Параметры
Textbox prompt_tb lines=4, max_lines=8
Image image_in type="pil", sources=["upload","clipboard"]
Textbox palette_tb lines=1
Radio mode_radio ["icon","illustration"], value="icon"
Slider n_slider 1..8, step=1, value=4 (icon)/2 (illustration)
Slider temp_slider 0..1.5, step=0.05, value=0.4
Button gen_btn "Generate", variant="primary"
Gallery gallery columns=3, height=320, object_fit="contain"
Code svg_viewer language="xml"
Dataframe history_df id/created_at/mode/model/n/status
Markdown status_md для статус-строки
Dropdown model_dd из DEFAULT_MODEL env

8.3. Callbacks (контракт)

def on_mode_change(mode): return gr.update(value=4 if mode=="icon" else 2)

def on_generate(prompt, mode, n, temp, image, palette, model):
    if not (1 <= len(prompt.strip()) <= 1000):
        raise gr.Warning("Prompt must be 11000 characters")
    if image is not None: _check_image(image)               # size/format
    messages = build_messages(mode=mode, prompt=prompt,
        image_b64=encode_image(image) if image else None,
        palette=palette or None, n=n, temperature=temp)
    result = chat(base_url=..., model=model, messages=messages, n=n, temperature=temp)
    previews, validated = [], []
    for i, raw in enumerate(result.raw_texts):
        try:
            svg = extract_svg(raw); validate(svg, mode=mode)
            png = svg_to_png_bytes(svg, output_width=256 if mode=="icon" else 512)
            path = save_preview(png, job_id=0, candidate_index=i)
            validated.append(svg); previews.append((str(path), f"#{i+1}"))
        except ValidatorError as e:
            log.warning("invalid candidate %d: %s", i, e.code)
    best_idx = None
    if validated:
        sizes = [len(s.encode("utf-8")) for s in validated]
        best_idx = min(range(len(sizes)), key=lambda i: sizes[i])
        previews[best_idx] = (previews[best_idx][0], f"★ best — #{best_idx+1}")
    job_id = insert_job(prompt=prompt, mode=mode, model=model, n_candidates=n,
        temperature=temp, status="ok" if validated else "all_invalid",
        raw_outputs=result.raw_texts, validated_outputs=validated,
        preview_paths=[p[0] for p in previews],
        best_index=best_idx)
    return previews, validated[best_idx] if validated else "", _refresh_history(), \
        f"Generated {len(validated)}/{n}"

def on_history_select(evt: gr.SelectData):
    job = get_job(evt.value[0])
    return [(p, "") for p in job["preview_paths"]], \
        job["validated_outputs"][job["best_index"] or 0]

Gradio сам сериализует вызовы одного callback; gen_btn.interactive = False через .then() достаточно.


9. Обработка ошибок

Слой Что ловим UI Лог history.status
UI pre-check пустой/длинный prompt, плохая картинка gr.Warning INFO — (запись не создаётся)
LM Studio timeout, 5xx, network gr.Error ERROR+tb error
Парсер модель вернула текст без <svg> WARNING all_invalid / частично ok
Валидатор malformed, viewBox, tag, ref, size WARNING (с code) как выше
Renderer cairosvg бросил invalid WARNING как выше
History SQLite locked / disk full gr.Error("DB error") ERROR

Принцип: всё, что ниже UI pre-check, не блокирует показ уже валидных кандидатов. Из 4 валиден 1 → показываем 1, статус ok, в raw_outputs все 4 (включая невалидные).


10. Запуск

10.1. Команды

python -m venv .venv
.venv\Scripts\activate                # Windows
pip install -r requirements.txt
python app.py

UI поднимется на http://127.0.0.1:7860 (Gradio default).

10.2. requirements.txt (стартовая точка)

gradio>=5.0,<6
httpx>=0.27
lxml>=5.0
cairosvg>=2.7
Pillow>=10.0
pytest>=8.0           # dev only

10.3. Env-переменные (опционально, с дефолтами)

Переменная Дефолт Назначение
LM_STUDIO_BASE_URL http://127.0.0.1:1234/v1 endpoint
LM_STUDIO_API_KEY lm-studio Authorization header
DEFAULT_MODEL qwen/qwen3.5-35b-a3b Dropdown default
REQUEST_TIMEOUT_S 60 httpx timeout
OMNISVG_DB_PATH data/omnisvg.db SQLite
OMNISVG_PREVIEW_DIR data/previews PNG-превью
LOG_LEVEL INFO DEBUG/INFO/WARNING

10.4. Логи при старте

INFO  omnisvg  starting model=qwen/qwen3.5-35b-a3b base_url=http://127.0.0.1:1234/v1
INFO  omnisvg  db initialized at data/omnisvg.db (WAL)
INFO  omnisvg  ui on http://127.0.0.1:7860

11. Open Questions

  • Streaming — не идём, non-stream. Если понадобится: второй endpoint в lm_client.py (stream=true + SSE-парсер) + callback с gr.update(...) для прогресс-бара.
  • Per-mode n default — решено: 4 для icon, 2 для illustration (§8.2).
  • PNG-превью лучшего крупнее — нет, все одинаковые. Акцент через caption "★ best".
  • Regenerate — нет в MVP. Если нужен: ~20 строк, кнопка копирует параметры в поля и дёргает on_generate.
  • cairosvg на Windows — README предупреждает про GTK runtime. Не блокер MVP.
  • Cleanup старых превью — нет, диск заполняется. В прод — TTL/джоба.