Files
omnisvg-lite/docs/design.md
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

396 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
```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: <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
```python
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. Рендер
```python
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 схема
```sql
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 (контракт)
```python
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. Команды
```bash
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/джоба.