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
This commit is contained in:
Mavis
2026-06-13 15:32:54 +03:00
commit 2394eff1c0
21 changed files with 5116 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
# Python
__pycache__/
*.pyc
*.pyo
.pytest_cache/
.mypy_cache/
.ruff_cache/
# venv
.venv/
venv/
env/
# OS
.DS_Store
Thumbs.db
# Local logs / runtime
gradio_run.log
gradio_run.err
launcher_run.log
launcher_run.err
_smk_*.py
_probe_*.py
_create_shortcut.ps1
*.tmp
# Build / dist
build/
dist/
*.egg-info/
# Editor
.vscode/
.idea/
+119
View File
@@ -0,0 +1,119 @@
# OmniSVG-Lite
Локальный MVP для генерации SVG-иконок и иллюстраций через LM Studio.
Gradio UI, локальная SQLite-история, валидация выхода модели через `lxml`,
PNG-превью через `cairosvg`.
> Контракт: см. `docs/spec.md` и `docs/design.md`. Промпт-шаблоны: `prompts/`.
## Что внутри
| Файл | Назначение |
|--------------------|------------------------------------------------------------------|
| `app.py` | Gradio 5 UI: text/prompt → N SVG-кандидатов → PNG-превью → БД |
| `lm_client.py` | httpx-клиент к OpenAI-compatible LM Studio, `generate_svg()` |
| `prompts.py` | Загрузка шаблонов из `prompts/` + `build_messages()` |
| `validator.py` | lxml-парсинг + проверка whitelist/тегов/href/on* |
| `renderer.py` | cairosvg → PNG (graceful fallback, если cairo недоступен) |
| `history.py` | SQLite WAL, контекст-менеджер `History` |
| `prompts/` | system-инструкции + few-shot примеры |
| `tests/` | pytest на validator (6+ кейсов) |
## Установка
```bash
# 1) зависимости
pip install -r requirements.txt
```
> **Важно для Windows**: `cairosvg` — это Python-биндинг к нативному `cairo`.
> На Windows чистого `pip install cairosvg` обычно недостаточно — нужна
> библиотека `cairo.dll`. Варианты:
>
> ```bash
> # вариант 1: conda (рекомендуется)
> conda install -c conda-forge pycairo cairo
>
> # вариант 2: MSYS2 / vcpkg / GTK3 runtime
> ```
>
> Если cairo не установлен — UI стартует, но PNG-превью будут пропущены
> (в логе появится `WARNING` от renderer.py).
## Настройка LM Studio
1. Запустить LM Studio локально (`http://127.0.0.1:1234`).
2. Загрузить модель, например `qwen/qwen3.5-35b-a3b`.
3. Включить **OpenAI-compatible server** в LM Studio.
Переменные окружения (опционально, все имеют дефолты):
| Переменная | Дефолт |
|---------------------------|-----------------------------------|
| `LM_STUDIO_BASE_URL` | `http://127.0.0.1:1234/v1` |
| `LM_STUDIO_API_KEY` | `lm-studio` |
| `DEFAULT_MODEL` | `qwen/qwen3.5-35b-a3b` |
| `REQUEST_TIMEOUT_S` | `120` |
| `OMNISVG_DB_PATH` | `~/.omnisvg_lite/history.sqlite` |
| `OMNISVG_PREVIEW_DIR` | `~/.omnisvg_lite/previews` |
| `LOG_LEVEL` | `INFO` |
## Запуск
```bash
python app.py
```
UI откроется на `http://127.0.0.1:7860`. Кнопка «Сгенерировать» отправляет
промпт в LM Studio, валидирует SVG, рисует PNG-превью и сохраняет запись
в SQLite.
## Тесты
```bash
python -m pytest tests/ -v
```
Покрытие — `validator.py` (well-formed, viewBox, forbidden tags, http refs,
on*-атрибуты).
## Smoke-тест импорта (без живого LM Studio)
```bash
python -c "from app import demo, main; print('imports ok')"
```
Запускается **только импорт**, Gradio-сервер не поднимается. Если gradio не
установлен, импорт `app.py` упадёт (gradio захардкожен в `import`).
## Известные ограничения (MVP)
- Один пользователь, без авторизации.
- Выбор «лучшего» кандидата — по минимальному размеру файла (прокси
компактности, не качества). См. `spec.md §4`.
- `cairosvg` на Windows без conda — см. выше; UI работает и без PNG-превью.
- Размер БД не ограничен; очистка старых превью — на совести пользователя
(дизайн §11, open question).
## Структура SQLite
```sql
CREATE TABLE generations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at REAL NOT NULL,
prompt TEXT NOT NULL,
mode TEXT NOT NULL, -- icon | illustration
model TEXT NOT NULL,
n_requested INTEGER NOT NULL,
n_returned INTEGER NOT NULL,
temperature REAL NOT NULL,
status TEXT NOT NULL, -- ok | partial | failed
error_reason TEXT,
raw_outputs TEXT NOT NULL, -- JSON list[str]
validated_outputs TEXT NOT NULL, -- JSON list[str]
previews TEXT NOT NULL, -- JSON list[str] (PNG paths)
best_index INTEGER
);
```
WAL-режим, индекс по `created_at DESC`.
+587
View File
@@ -0,0 +1,587 @@
"""Gradio UI для OmniSVG-Lite.
Слои:
1. UI-события → callbacks (on_generate / on_history_select)
2. callbacks → lm_client + prompts + validator + renderer + history
3. ошибки ловятся, отображаются в UI, попадают в history со status='failed'
Запуск: `python app.py` → http://127.0.0.1:7860
"""
from __future__ import annotations
import logging
import os
import time
from pathlib import Path
from typing import Any
import gradio as gr # type: ignore # gradio is required to launch UI; tests can mock it
from history import DEFAULT_DB_PATH, History, Record
from lm_client import (
DEFAULT_BASE_URL,
DEFAULT_MODEL,
DEFAULT_TIMEOUT_S,
LMStudioUnavailable,
chat,
encode_pil_to_data_url,
validate_image,
)
from prompts import build_messages, load_system_prompt
from renderer import render_png, save_png
from validator import validate_svg
# ---------------------------------------------------------------------------
# Логгер и настройки (env можно переопределить)
# ---------------------------------------------------------------------------
log = logging.getLogger("omnisvg")
if not log.handlers:
logging.basicConfig(
level=os.environ.get("LOG_LEVEL", "INFO"),
format="%(asctime)s %(levelname)-7s %(name)s | %(message)s",
)
DEFAULT_PREVIEW_DIR = Path(
os.environ.get("OMNISVG_PREVIEW_DIR", str(Path.home() / ".omnisvg_lite" / "previews"))
)
PREVIEW_SIZE = {
"icon": (256, 256),
"illustration": (512, 512),
}
# ---------------------------------------------------------------------------
# Хелперы
# ---------------------------------------------------------------------------
def _format_ts(ts: float) -> str:
"""Превращает time.time() в человеко-читаемую дату."""
try:
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts))
except Exception: # noqa: BLE001
return str(ts)
def _check_prompt(prompt: str) -> str:
"""Возвращает очищенный промпт или бросает ValueError с понятной причиной."""
if prompt is None:
raise ValueError("промпт пустой")
cleaned = prompt.strip()
if not (1 <= len(cleaned) <= 1000):
raise ValueError("промпт должен быть от 1 до 1000 символов")
return cleaned
def _history_to_dataframe(records: list[dict[str, Any]]) -> list[list[Any]]:
"""Превращает список записей в плоский список строк для gr.Dataframe."""
rows: list[list[Any]] = []
for r in records:
rows.append(
[
r["id"],
_format_ts(r["created_at"]),
r["mode"],
r["model"],
r["n_requested"],
r["n_returned"],
r["status"],
]
)
return rows
def _refresh_history_df(limit: int = 20) -> list[list[Any]]:
with History() as h:
return _history_to_dataframe(h.list_recent(limit=limit))
# Число output-полей on_generate должно совпадать с сигнатурой ниже.
_ON_GENERATE_NOUTPUTS = 7
def _empty_result(n_candidates: int) -> tuple[list, list, str, list, str, list, str]:
"""Стандартный «пустой» возврат on_generate для случаев раннего выхода.
Gradio ждёт от каждого callback ровно столько значений, сколько объявлено
в `outputs=[...]`. Когда мы хотим прервать работу через `gr.Warning()` /
`gr.Error()` (а не через raise), мы ОБЯЗАНЫ вернуть плейсхолдеры для всех
outputs, иначе Gradio поднимет `IndexError`/warning.
Returns:
Кортеж из 7 элементов: ([] , [] , "" , [] , "" , [] , "").
"""
return ([], [], "", [], "", [], "")
def _save_all_previews(
record_id: int,
svgs: list[str],
mode: str,
) -> list[str]:
"""Рендерит PNG для каждого валидного SVG и сохраняет на диск.
Returns:
Список путей к PNG в том же порядке, что и svgs. Если рендер упал —
вместо пути идёт пустая строка.
"""
out: list[str] = []
size = PREVIEW_SIZE.get(mode, (512, 512))
for i, svg in enumerate(svgs):
png = render_png(svg, size=size)
if png is None:
log.warning("превью #%d пропущено: рендер не удался", i)
out.append("")
continue
try:
path = save_png(
png,
previews_dir=DEFAULT_PREVIEW_DIR,
record_id=record_id,
candidate_index=i,
)
out.append(str(path))
except OSError as exc:
log.warning("не удалось сохранить превью #%d: %s", i, exc)
out.append("")
return out
# ---------------------------------------------------------------------------
# Callbacks
# ---------------------------------------------------------------------------
def on_mode_change(mode: str) -> dict:
"""Меняет дефолт n_candidates при смене mode. По умолчанию 1 (один экземпляр)."""
return gr.update(value=1)
def fetch_lm_studio_models(base_url: str, api_key: str) -> tuple[list[str], str]:
"""Опрашивает LM Studio `/v1/models` и возвращает (список_id, статус).
Args:
base_url: например, http://127.0.0.1:1234/v1
api_key: bearer-токен (LM Studio игнорирует значение, но требует заголовок).
Returns:
(models, status_message). models — список id моделей, может быть пустым
при ошибке. status_message — текст для UI ("OK: 12 моделей" / "ошибка: ...").
"""
if not base_url or not base_url.strip():
return ([], "ошибка: пустой URL")
base = base_url.strip().rstrip("/")
# Если передали корень без /v1 — добавим
if not base.endswith("/v1"):
base = base + "/v1"
url = base + "/models"
headers = {"Authorization": f"Bearer {api_key or 'lm-studio'}"}
try:
import httpx
r = httpx.get(url, headers=headers, timeout=10)
if r.status_code != 200:
return ([], f"ошибка HTTP {r.status_code}: {r.text[:200]}")
data = r.json()
items = data.get("data") or []
ids = [str(m.get("id")) for m in items if m.get("id")]
if not ids:
return ([], "OK, но список пуст")
return (ids, f"OK: найдено {len(ids)} моделей")
except Exception as exc: # noqa: BLE001
return ([], f"ошибка: {type(exc).__name__}: {exc}")
def on_generate(
prompt: str,
mode: str,
n_candidates: int,
temperature: float,
image: Any,
palette: str,
model: str,
base_url: str,
api_key: str,
) -> tuple[list[tuple[str, str]], list[dict], str, list[list[Any]], str, list[str], str]:
"""Обрабатывает клик «Сгенерировать».
Returns:
(gallery, gallery_hidden_value, svg_text_for_code, history_df,
status_md, preview_paths, error_or_status) — последняя строка для
ErrorBanner.
"""
# 1. UI pre-check
# ВАЖНО: в Gradio 5.x `gr.Warning` и `gr.Error` — это ФУНКЦИИ, а не
# исключения. Их нужно ВЫЗЫВАТЬ (а не `raise`). Проверено в Grad 5.37.0:
# `raise gr.Warning(...)` → TypeError. Если кто-то в будущем вернётся к
# `raise`, регрессионный тест test_on_generate_no_raise_on_bad_input
# в tests/test_app.py это поймает.
try:
clean_prompt = _check_prompt(prompt)
except ValueError as exc:
gr.Warning(str(exc))
return _empty_result(n_candidates)
if image is not None:
try:
validate_image(image)
except (ValueError, Exception) as exc: # noqa: BLE001
gr.Warning(f"изображение отклонено: {exc}")
return _empty_result(n_candidates)
n_candidates = int(n_candidates)
if not (1 <= n_candidates <= 8):
gr.Warning("n_candidates должен быть от 1 до 8")
return _empty_result(n_candidates)
if mode not in ("icon", "illustration"):
gr.Warning(f"неизвестный mode: {mode!r}")
return _empty_result(n_candidates)
model = model or os.environ.get("DEFAULT_MODEL", DEFAULT_MODEL)
# 2. Сборка messages
image_b64: str | None = None
if image is not None:
try:
image_b64 = encode_pil_to_data_url(image, mime="image/png")
except Exception as exc: # noqa: BLE001
gr.Warning(f"не удалось закодировать изображение: {exc}")
return _empty_result(n_candidates)
palette_clean = palette.strip() if palette else None
try:
messages = build_messages(
prompt=clean_prompt,
mode=mode,
image_b64=image_b64,
palette=palette_clean,
n=n_candidates,
temperature=float(temperature),
)
except Exception as exc: # noqa: BLE001
log.exception("build_messages упал")
gr.Error(f"ошибка сборки промпта: {exc}")
return _empty_result(n_candidates)
# 3. Запрос в LM Studio
log.info("генерация: mode=%s n=%d temp=%.2f model=%s", mode, n_candidates, temperature, model)
try:
result = chat(
messages=messages,
model=model,
n=n_candidates,
temperature=float(temperature),
base_url=(base_url or "").strip() or DEFAULT_BASE_URL,
api_key=(api_key or "").strip() or "lm-studio",
timeout_s=float(os.environ.get("REQUEST_TIMEOUT_S", DEFAULT_TIMEOUT_S)),
)
except LMStudioUnavailable as exc:
log.error("LM Studio недоступен: %s", exc)
# Пишем в history как failed, чтобы пользователь не потерял попытку.
with History() as h:
h.add(
Record(
prompt=clean_prompt,
mode=mode,
model=model,
n_requested=n_candidates,
n_returned=0,
temperature=float(temperature),
status="failed",
error_reason=str(exc),
raw_outputs=[],
validated_outputs=[],
previews=[],
)
)
gr.Error(str(exc))
return _empty_result(n_candidates)
raw_texts = result.raw_texts
log.info("получено %d сырых ответов за %.1fs", len(raw_texts), result.elapsed_s)
# 4. Валидация + рендер
validated: list[str] = []
invalid_reasons: list[str] = []
for i, raw in enumerate(raw_texts):
ok, reason, cleaned = validate_svg(raw, mode=mode)
if ok:
validated.append(cleaned)
else:
log.warning("кандидат #%d невалиден: %s", i, reason)
invalid_reasons.append(reason)
# 5. Запись в БД (сначала insert, чтобы получить id для превью)
with History() as h:
# best_index — MVP-логика: инвертированный размер файла (меньше = лучше).
best_index: int | None = None
if validated:
sizes = [len(s.encode("utf-8")) for s in validated]
best_index = min(range(len(sizes)), key=lambda i: sizes[i])
record = Record(
prompt=clean_prompt,
mode=mode,
model=model,
n_requested=n_candidates,
n_returned=len(raw_texts),
temperature=float(temperature),
status=(
"ok" if validated and len(validated) == n_candidates
else "partial" if validated
else "failed"
),
error_reason=None if validated else (
f"все {n_candidates} кандидатов невалидны: {invalid_reasons}"
if invalid_reasons else "пустой ответ модели"
),
raw_outputs=raw_texts,
validated_outputs=validated,
previews=[],
best_index=best_index,
)
record_id = h.add(record)
# 6. Превью
preview_paths = _save_all_previews(record_id, validated, mode)
if validated and best_index is not None and preview_paths[best_index]:
pass # пометка best на уровне caption
# Дозаписываем previews в БД отдельным update-ом, чтобы не светить в Record.
if any(preview_paths):
with History() as h:
conn = h.conn
import json as _json
conn.execute(
"UPDATE generations SET previews = ? WHERE id = ?",
(_json.dumps(preview_paths, ensure_ascii=False), record_id),
)
conn.commit()
# 7. Gallery и code-block
captions: list[tuple[str, str]] = []
for i, p in enumerate(preview_paths):
if not p:
continue
if best_index is not None and i == best_index:
captions.append((p, f"★ best — #{i+1}"))
else:
captions.append((p, f"#{i+1}"))
if not captions:
captions = [] # gallery пустой
best_svg = (
validated[best_index] if (validated and best_index is not None) else ""
)
status_md = (
f"Сгенерировано {len(validated)}/{n_candidates} за {result.elapsed_s:.1f}с"
if validated
else f"Все {n_candidates} кандидатов невалидны. Подробности в history."
)
return (
captions, # gallery
captions, # hidden (для совместимости, не используется)
best_svg, # svg code block
_refresh_history_df(20), # history dataframe
status_md, # status markdown
preview_paths, # превью для архива
"", # error placeholder
)
def on_history_select(
evt: gr.SelectData,
history_data: list[list[Any]] | None,
) -> tuple[list[tuple[str, str]], str, str]:
"""По клику на строку history подгружает детали записи.
Args:
evt: событие выбора (index, row_payload).
history_data: текущее содержимое dataframe (для поиска id).
"""
if evt is None or not history_data:
return [], "", ""
# В новых версиях Gradio evt.value может быть словарём строки, в старых —
# индексом. Поддерживаем оба варианта.
row_idx = None
if isinstance(evt.index, (list, tuple)) and evt.index:
row_idx = evt.index[0]
elif isinstance(evt.index, int):
row_idx = evt.index
if row_idx is None or row_idx < 0 or row_idx >= len(history_data):
return [], "", ""
row = history_data[row_idx]
try:
record_id = int(row[0])
except (TypeError, ValueError):
return [], "", "не удалось извлечь id записи"
with History() as h:
rec = h.get(record_id)
if rec is None:
return [], "", f"запись #{record_id} не найдена"
previews = rec.get("previews") or []
captions: list[tuple[str, str]] = []
for i, p in enumerate(previews):
if p and Path(p).is_file():
captions.append((p, f"#{i+1}"))
validated = rec.get("validated_outputs") or []
best_idx = rec.get("best_index")
best_svg = validated[best_idx] if (best_idx is not None and 0 <= best_idx < len(validated)) else (
validated[0] if validated else ""
)
details_md = (
f"### Запись #{rec['id']}\n"
f"- **Промпт:** {rec['prompt']}\n"
f"- **Mode:** {rec['mode']}\n"
f"- **Model:** {rec['model']}\n"
f"- **N:** {rec['n_returned']}/{rec['n_requested']}\n"
f"- **Status:** {rec['status']}"
)
return captions, best_svg, details_md
# ---------------------------------------------------------------------------
# Сборка UI
# ---------------------------------------------------------------------------
def build_ui() -> gr.Blocks:
"""Создаёт объект Gradio Blocks."""
with gr.Blocks(title="OmniSVG-Lite") as demo:
gr.Markdown(
"# OmniSVG-Lite\n"
"Текст/картинка → N SVG-кандидатов через LM Studio."
)
with gr.Row():
with gr.Column(scale=1):
prompt_tb = gr.Textbox(
label="Промпт",
placeholder="filled magnifying glass",
lines=4,
max_lines=8,
)
image_in = gr.Image(
label="Референс-картинка (опц.)",
type="pil",
sources=["upload", "clipboard"],
)
palette_tb = gr.Textbox(
label="Палитра (опц.)",
placeholder="blue and teal",
lines=1,
)
mode_radio = gr.Radio(
choices=["icon", "illustration"],
value="icon",
label="Mode",
)
n_slider = gr.Slider(
minimum=1, maximum=8, step=1, value=1,
label="Кандидатов",
)
temp_slider = gr.Slider(
minimum=0.0, maximum=1.5, step=0.05, value=0.4,
label="Temperature",
)
with gr.Accordion("LM Studio", open=False):
base_url_tb = gr.Textbox(
label="Base URL",
value=os.environ.get("LM_STUDIO_BASE_URL", DEFAULT_BASE_URL),
placeholder="http://127.0.0.1:1234/v1",
lines=1,
)
api_key_tb = gr.Textbox(
label="API token",
value=os.environ.get("LM_STUDIO_API_KEY", "lm-studio"),
lines=1,
)
refresh_btn = gr.Button("Обновить список моделей", size="sm")
fetch_status_md = gr.Markdown("")
model_dd = gr.Dropdown(
label="Модель",
choices=[os.environ.get("DEFAULT_MODEL", DEFAULT_MODEL)],
value=os.environ.get("DEFAULT_MODEL", DEFAULT_MODEL),
allow_custom_value=True,
)
gen_btn = gr.Button("Сгенерировать", variant="primary")
status_md = gr.Markdown("")
with gr.Column(scale=2):
gallery = gr.Gallery(
label="PNG-превью",
columns=3, height=320, object_fit="contain",
)
gallery_state = gr.State([])
with gr.Accordion("SVG-код лучшего кандидата", open=False):
# gr.Code в Gradio 5.37 не имеет "xml" в whitelist языков
# (есть python/sql/html/markdown/...). Используем "html"
# — подсветка разметки близка к XML и работает стабильно.
svg_viewer = gr.Code(label="SVG", language="html")
with gr.Accordion("История (последние 20)", open=True):
history_df = gr.Dataframe(
headers=["id", "created_at", "mode", "model", "n", "returned", "status"],
datatype=["number", "str", "str", "str", "number", "number", "str"],
interactive=False,
wrap=True,
)
history_details = gr.Markdown("")
# Связи
mode_radio.change(
on_mode_change,
inputs=[mode_radio],
outputs=[n_slider],
)
refresh_btn.click(
fetch_lm_studio_models,
inputs=[base_url_tb, api_key_tb],
outputs=[model_dd, fetch_status_md],
)
gen_btn.click(
on_generate,
inputs=[prompt_tb, mode_radio, n_slider, temp_slider, image_in, palette_tb, model_dd, base_url_tb, api_key_tb],
outputs=[gallery, gallery_state, svg_viewer, history_df, status_md, gr.State([]), gr.State("")],
)
history_df.select(
on_history_select,
inputs=[history_df],
outputs=[gallery, svg_viewer, history_details],
)
return demo
# Алиас, который просит задача для smoke-теста импорта.
demo = None
def main() -> None:
global demo
log.info(
"starting model=%s base_url=%s",
os.environ.get("DEFAULT_MODEL", DEFAULT_MODEL),
os.environ.get("LM_STUDIO_BASE_URL", DEFAULT_BASE_URL),
)
log.info("db path: %s", DEFAULT_DB_PATH)
log.info("preview dir: %s", DEFAULT_PREVIEW_DIR)
demo = build_ui()
port = int(os.environ.get("OMNISVG_PORT", "8788"))
host = os.environ.get("OMNISVG_HOST", "127.0.0.1")
log.info("launching Gradio on %s:%d", host, port)
# allowed_paths нужен, чтобы Gradio отдавал PNG из ~/.omnisvg_lite/previews/
# иначе InvalidPathError на рендере превью
demo.launch(
server_name=host,
server_port=port,
allowed_paths=[str(DEFAULT_PREVIEW_DIR), str(Path.cwd())],
)
if __name__ == "__main__":
main()
+395
View File
@@ -0,0 +1,395 @@
# 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/джоба.
+251
View File
@@ -0,0 +1,251 @@
# OmniSVG-Lite — Spec
> MVP-спецификация. Цель: дать пользователю веб-приложение, в котором можно
> из текста или картинки получить N кандидатов SVG в одном из двух режимов
> (icon / illustration), посмотреть PNG-превью и сохранить историю генераций.
---
## 1. Цели и не-цели
**Цель MVP:**
- Локально запускаемый Gradio UI, который ходит в LM Studio (OpenAI-compatible)
и возвращает валидный SVG + PNG-превью.
- Один пользователь, без авторизации, без облака.
- История генераций хранится в локальной SQLite.
**Не-цели (Out of Scope для MVP):**
- Редактирование SVG в браузере (paint / drag).
- Экспорт в Lottie / GIF / видео.
- Batch-генерация (CSV промптов, генерация по расписанию).
- Авторизация, multi-user, разграничение прав.
- Деплой / Docker / CI.
- Per-mode температура — единый слайдер на UI.
- Fine-tune, RLHF, оценка качества моделей.
- Поддержка моделей, отличных от LM Studio (Ollama, vLLM, OpenAI cloud) — клиент
пишется под OpenAI-compatible chat completions, но другие провайдеры не
тестируются.
---
## 2. Режимы генерации
### 2.1. Mode: `icon`
**Назначение:** маленькие, узнаваемые, плоские или с лёгкой стилизацией иконки
для UI. Допустимы монохром и 2–3 цвета. Геометрия упрощённая, без мелких деталей.
**Требования к SVG:**
- `viewBox="0 0 64 64"` (фиксировано, чтобы иконки были консистентны).
- Геометрия привязана к пиксельной сетке (целочисленные координаты ±0.5).
- Без градиентов со множеством stops, без фильтров (`feGaussianBlur` и т.п.).
- Допустимы: `<path>`, `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>`,
`<polyline>`, `<g>`, `<text>`, базовые `<linearGradient>`/`<radialGradient>`.
**Промпт-стиль (направление для пользователя):**
"outline battery", "filled magnifying glass", "settings gear, line style".
### 2.2. Mode: `illustration`
**Назначение:** полноценные сцены / объекты / паттерны. Больше деталей, больше
цветов, допустимы градиенты и простые фильтры.
**Требования к SVG:**
- `viewBox="0 0 512 512"` (фиксировано).
- Допустимы все валидные теги SVG 1.1 кроме `<script>` и `<foreignObject>`.
- Допустимы простые `<filter>` (без `<feImage>` на внешний URL).
- Палитра ограничена заявленной пользователем (если указана).
**Промпт-стиль:**
"flat illustration of a fox in a forest, autumn colors",
"isometric server room, blue and purple palette".
### 2.3. Сводная таблица
| Параметр | icon | illustration |
|----------------------|---------------------------------|----------------------------------|
| viewBox | `0 0 64 64` | `0 0 512 512` |
| Размер файла (лимит) | 32 KB | 256 KB |
| Допустимые теги | Базовые shapes + g + text | Полный SVG 1.1 минус опасные |
| Градиенты | ≤ 2 stops | без ограничений |
| Фильтры | нет | простые (без feImage на URL) |
| Текст | допустим, но не основной канал | допустим |
| Дефолтный n_candidates | 4 | 2 |
---
## 3. Типы задач
### 3.1. text-to-SVG
- Вход: `prompt: str` (1–1000 символов), `mode ∈ {icon, illustration}`,
`n_candidates: int ∈ [1, 8]`.
- Опционально: палитра (поле в UI, не обязательное).
- Модель получает только текст.
### 3.2. image-to-SVG
- Вход: `prompt: str` (1–1000 символов), `image: PIL.Image | file path`,
`mode ∈ {icon, illustration}`, `n_candidates: int ∈ [1, 8]`.
- Допустимые форматы картинки: PNG, JPEG, WEBP. Максимум 10 MB, до 4096×4096.
- Картинка передаётся в модель как `image_url` (data: URL, base64).
- В промпте добавляется явная инструкция: "Recreate the visual content of the
attached image as SVG. Do not describe, just generate the markup."
---
## 4. Поведение N кандидатов
**Логика выбора лучшего кандидата (для MVP — простая, детерминированная):**
1. Из N ответов модели берём только валидные (прошедшие lxml-валидацию).
2. Если валидных 0 — показываем ошибку `"no_valid_candidates"` (см. §6).
3. Каждому валидному кандидату считаем **score = размер_файла_KB** (инвертированный:
меньше = лучше). Это прокси "компактность", не качество.
4. Лучший = кандидат с минимальным score.
5. **Показ в UI:**
- `Gallery` отображает PNG-превью всех валидных кандидатов в сетке.
- Лучший помечается визуально: бейдж "★ best" над PNG.
- В выпадашке/таблице истории сохраняются все валидные SVG + пути к PNG.
**Замечание:** для MVP не используем VLM-as-judge или pairwise — это сильно
увеличит latency и стоимость. Выбор по размеру файла — намеренно простой.
---
## 5. User Stories + Acceptance Criteria
### US-1. Сгенерировать иконку по текстовому промпту
**As a** дизайнер,
**I want** ввести текстовое описание иконки и получить N вариантов SVG,
**so that** я могу быстро подобрать визуал для кнопки/меню.
**Acceptance Criteria (Given/When/Then):**
- **Given** UI запущен и LM Studio отвечает, **when** я выбираю mode=`icon`,
ввожу `prompt="filled magnifying glass"`, `n_candidates=4`, **then**:
- отправляется ровно 1 запрос в LM Studio с `n=4`,
- получаю 4 PNG-превью в Gallery (или меньше, если часть невалидна),
- у лучшего есть бейдж "★ best",
- история в SQLite содержит запись со всеми raw/validated SVG.
- **Given** промпт пустой или длиннее 1000 символов, **when** я нажимаю
Generate, **then** вижу `gr.Warning` "Prompt must be 11000 characters",
запрос в LM Studio НЕ отправляется.
- **Given** LM Studio не отвечает 30+ секунд, **when** идёт генерация, **then**
запрос отменяется, UI показывает ошибку "LM Studio timeout".
### US-2. Сгенерировать иллюстрацию по картинке
**As a** иллюстратор,
**I want** загрузить референс-картинку и текстовое описание, получить N SVG,
**so that** я могу быстро сделать vector-версию понравившегося эскиза.
**Acceptance Criteria:**
- **Given** UI запущен, **when** я загружаю PNG/JPEG/WEBP, ввожу
`prompt="flat illustration, blue and teal palette"`, выбираю
mode=`illustration`, `n_candidates=2`, **then**:
- картинка кодируется в base64 data: URL и отправляется в LM Studio,
- получаю до 2 PNG в Gallery,
- все raw-ответы и валидные SVG попадают в историю.
- **Given** загружена картинка > 10 MB или > 4096×4096, **when** я нажимаю
Generate, **then** UI отклоняет с ошибкой валидации до отправки в LM Studio.
### US-3. Посмотреть и скачать результат
**As a** пользователь,
**I want** увидеть PNG-превью, скачать выбранный SVG и PNG,
**so that** я могу сразу использовать результат в своих задачах.
**Acceptance Criteria:**
- **Given** генерация завершилась с ≥ 1 валидным кандидатом, **when** я смотрю
на Gallery, **then**:
- каждая картинка кликабельна,
- под/возле картинки — кнопки `Download SVG` и `Download PNG`,
- скачиваются именно те файлы, которые видны на превью (source of truth —
путь в `preview_paths`).
- **Given** я нажимаю `Download SVG`, **then** браузер получает файл с MIME
`image/svg+xml` и расширением `.svg`.
### US-4. Просмотреть историю генераций
**As a** пользователь,
**I want** видеть таблицу последних генераций и кликнуть строку, чтобы
повторно открыть результаты,
**so that** я могу вернуться к удачному варианту.
**Acceptance Criteria:**
- **Given** UI запущен, **when** я смотрю на History, **then**:
- таблица показывает последние 50 записей (id, prompt, mode, model, n,
status, created_at),
- новые записи появляются вверху,
- строки сортируются по `created_at DESC`.
- **Given** я кликаю строку в History, **when** выбираю запись, **then**
Gallery обновляется превью из `preview_paths` этой записи, raw SVG
подгружается в отдельный code-block для копирования.
- **Given** приложение перезапущено, **when** я открываю History, **then**
данные сохранились (SQLite на диске).
### US-5. Контроль параметров (температура, n)
**As a** пользователь,
**I want** управлять количеством кандидатов и креативностью,
**so that** балансировать скорость/стоимость и разнообразие.
**Acceptance Criteria:**
- **Given** UI запущен, **when** я двигаю `n_candidates` slider, **then**
диапазон 1–8, шаг 1, дефолт зависит от mode (icon=4, illustration=2).
- **Given** UI запущен, **when** я двигаю `temperature` slider, **then**
диапазон 0.0–1.5, шаг 0.05, дефолт 0.4.
- **Given** `temperature=0`, **when** идёт генерация, **then** отправляется
параметр `temperature=0` (детерминированный режим).
---
## 6. Edge Cases (таблица)
| # | Сценарий | Поведение |
|---|---------------------------------------------|--------------------------------------------------------------------------------------------------------|
| 1 | Пустой промпт | `gr.Warning` "Prompt must be 11000 characters", запрос не отправляется. |
| 2 | Промпт > 1000 символов | То же, что (1). |
| 3 | Картинка > 10 MB / > 4096×4096 / не PNG/JPEG/WEBP | Отклоняется до LM Studio, `gr.Warning` с указанием ограничения. |
| 4 | LM Studio не отвечает (timeout 30s) | `gr.Error` "LM Studio timeout at <url>", статус генерации `error`, в лог пишется traceback. |
| 5 | LM Studio вернул 5xx | `gr.Error` "LM Studio error: <code> <body>", статус `error`. |
| 6 | Модель вернула не-SVG (текст/JSON/мусор) | Парсер пытается вытащить `<svg>...</svg>` regex'ом; если не нашёл — кандидат помечается invalid, идёт в history со status=`invalid`, в Gallery не попадает. |
| 7 | SVG не well-formed XML | lxml бросает `XMLSyntaxError` → кандидат invalid, в лог `WARNING`, в history. |
| 8 | Отсутствует / кривой `viewBox` | `ValidatorError("missing_viewbox")` → invalid. |
| 9 | Внутри `<script>` или `<foreignObject>` | `ValidatorError("disallowed_tag")` → invalid. |
| 10| `http://` / `https://` в `href` / `xlink:href` / `url(...)` | `ValidatorError("external_ref")` → invalid. |
| 11| Размер файла > лимита (32 KB / 256 KB) | `ValidatorError("too_large")` → invalid. |
| 12| Все N кандидатов invalid | `gr.Info` "All N candidates failed validation. See history for details.", в history — запись со status=`all_invalid`. |
| 13| Картинка в image-to-SVG повреждена | PIL при открытии бросает `UnidentifiedImageError``gr.Warning` "Cannot read image", запрос не отправляется. |
| 14| Пользователь жмёт Generate повторно во время генерации | Кнопка `interactive=False` на время выполнения, повторный клик игнорируется. |
---
## 7. Не-функциональные требования
| Категория | Требование |
|----------------|------------------------------------------------------------------------------------------|
| Performance | Генерация 1 иконки (n=1) — p95 ≤ 20 секунд на одной 5090 / Ryzen 9. |
| Performance | PNG-рендер одного SVG — p95 ≤ 200 ms. |
| Privacy | Никакие промпты/картинки не уходят за пределы LM Studio на этой машине. |
| Reliability | Падение LM Studio не валит UI: ошибка показывается, остальные кнопки работают. |
| Storage | SQLite WAL mode, `data/omnisvg.db`. Превью PNG — `data/previews/<id>_<n>.png`. |
| Logging | `INFO` для старта/конца генерации, `WARNING` для invalid, `ERROR` для LM Studio errors. |
| Concurrency | MVP — однопользовательский, очередь не требуется. UI блокирует кнопку на время генерации. |
---
## 8. Открытые вопросы
- Требуется ли streaming в UI (как в LM Studio)? **Для MVP — нет**, выбран non-stream
режим: n>1 плохо сочетается со streaming, плюс парсить инкрементально тяжелее.
- Нужна ли кнопка "regenerate" с теми же параметрами? **Не вошла в MVP**, но UI
должен позволять скопировать промпт обратно в textbox.
- Нужен ли экспорт PNG в высоком разрешении (1024×1024)? **Нет**, достаточно
рендера в нативный viewBox.
+230
View File
@@ -0,0 +1,230 @@
"""SQLite-история генераций.
Один пользователь, без авторизации, одна таблица. WAL для устойчивости при
одновременной записи из UI и, например, фонового процесса.
Файл БД по умолчанию: `~/.omnisvg_lite/history.sqlite`. Можно переопределить
через `OMNISVG_DB_PATH` (env) или явно в конструкторе.
Схема расширена по сравнению с design.md: добавлены `n_requested`, `n_returned`,
`previews` (JSON list[str]), `error_reason`, `best_index`, `temperature` —
чтобы UI мог показать полную картину без дополнительных запросов.
"""
from __future__ import annotations
import json
import logging
import os
import sqlite3
import time
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any, Iterator
log = logging.getLogger(__name__)
# Дефолтный путь к БД — в домашнем каталоге пользователя. Это не абсолютный
# путь к проекту: пользователь может переопределить через env.
DEFAULT_DB_DIR = Path.home() / ".omnisvg_lite"
DEFAULT_DB_PATH = DEFAULT_DB_DIR / "history.sqlite"
def _resolve_db_path(db_path: str | Path | None) -> Path:
if db_path is not None:
return Path(db_path)
env = os.environ.get("OMNISVG_DB_PATH")
if env:
return Path(env)
return DEFAULT_DB_PATH
_SCHEMA = """
CREATE TABLE IF NOT EXISTS generations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at REAL NOT NULL,
prompt TEXT NOT NULL,
mode TEXT NOT NULL,
model TEXT NOT NULL,
n_requested INTEGER NOT NULL,
n_returned INTEGER NOT NULL,
temperature REAL NOT NULL,
status TEXT NOT NULL,
error_reason TEXT,
raw_outputs TEXT NOT NULL,
validated_outputs TEXT NOT NULL,
previews TEXT NOT NULL,
best_index INTEGER
);
CREATE INDEX IF NOT EXISTS idx_generations_created_at
ON generations(created_at DESC);
"""
@dataclass
class Record:
"""Запись о генерации.
Attributes:
prompt: пользовательский промпт.
mode: "icon" | "illustration".
model: имя модели.
n_requested: сколько кандидатов запросили.
n_returned: сколько LM Studio реально вернула.
temperature: использованная температура.
status: "ok" | "partial" | "failed".
error_reason: строка с ошибкой или None.
raw_outputs: list[str] — N сырых ответов модели.
validated_outputs: list[str] — прошедшие валидатор SVG.
previews: list[str] — пути к PNG-превью (rel или abs).
best_index: индекс лучшего кандидата в validated_outputs (или None).
created_at: time.time() (если None — заполняется в `add`).
"""
prompt: str
mode: str
model: str
n_requested: int
n_returned: int
temperature: float
status: str
error_reason: str | None
raw_outputs: list[str] = field(default_factory=list)
validated_outputs: list[str] = field(default_factory=list)
previews: list[str] = field(default_factory=list)
best_index: int | None = None
created_at: float | None = None
class History:
"""Контекст-менеджер для работы с историей генераций.
Используется так:
with History() as h:
row_id = h.add(Record(...))
for r in h.list_recent(20):
...
На каждый enter/exit открывается/закрывается соединение. Для долгоживущих
демонов Gradio это нормально: событий мало.
"""
def __init__(self, db_path: str | Path | None = None):
self.db_path = _resolve_db_path(db_path)
self._conn: sqlite3.Connection | None = None
# -- context manager ------------------------------------------------
def __enter__(self) -> "History":
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._conn = sqlite3.connect(str(self.db_path))
self._conn.row_factory = sqlite3.Row
# WAL: пишущие транзакции не блокируют читателей, читатели — писателей.
self._conn.execute("PRAGMA journal_mode=WAL")
self._conn.execute("PRAGMA synchronous=NORMAL")
self._conn.executescript(_SCHEMA)
self._conn.commit()
log.info("history: БД открыта по %s", self.db_path)
return self
def __exit__(self, exc_type, exc, tb) -> None:
if self._conn is not None:
try:
if exc_type is None:
self._conn.commit()
else:
self._conn.rollback()
finally:
self._conn.close()
self._conn = None
@property
def conn(self) -> sqlite3.Connection:
if self._conn is None:
raise RuntimeError("History используется вне контекст-менеджера")
return self._conn
# -- CRUD -----------------------------------------------------------
def add(self, record: Record) -> int:
"""Добавляет запись и возвращает её id."""
created_at = record.created_at if record.created_at is not None else time.time()
cur = self.conn.execute(
"""
INSERT INTO generations
(created_at, prompt, mode, model, n_requested, n_returned,
temperature, status, error_reason,
raw_outputs, validated_outputs, previews, best_index)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
created_at,
record.prompt,
record.mode,
record.model,
record.n_requested,
record.n_returned,
record.temperature,
record.status,
record.error_reason,
json.dumps(record.raw_outputs, ensure_ascii=False),
json.dumps(record.validated_outputs, ensure_ascii=False),
json.dumps(record.previews, ensure_ascii=False),
record.best_index,
),
)
self.conn.commit()
return int(cur.lastrowid)
def list_recent(self, limit: int = 50) -> list[dict[str, Any]]:
"""Возвращает последние `limit` записей, новые сверху."""
cur = self.conn.execute(
"""
SELECT id, created_at, prompt, mode, model, n_requested, n_returned,
temperature, status, error_reason,
raw_outputs, validated_outputs, previews, best_index
FROM generations
ORDER BY created_at DESC, id DESC
LIMIT ?
""",
(limit,),
)
return [_row_to_dict(row) for row in cur.fetchall()]
def get(self, record_id: int) -> dict[str, Any] | None:
"""Возвращает одну запись по id или None."""
cur = self.conn.execute(
"""
SELECT id, created_at, prompt, mode, model, n_requested, n_returned,
temperature, status, error_reason,
raw_outputs, validated_outputs, previews, best_index
FROM generations
WHERE id = ?
""",
(record_id,),
)
row = cur.fetchone()
if row is None:
return None
return _row_to_dict(row)
def count(self) -> int:
cur = self.conn.execute("SELECT COUNT(*) AS c FROM generations")
return int(cur.fetchone()["c"])
def _row_to_dict(row: sqlite3.Row) -> dict[str, Any]:
d = dict(row)
# JSON-поля превращаем в Python-структуры.
for key in ("raw_outputs", "validated_outputs", "previews"):
try:
d[key] = json.loads(d[key]) if d.get(key) else []
except (TypeError, ValueError):
log.warning("history: не удалось разобрать JSON в поле %s", key)
d[key] = []
return d
__all__ = ["DEFAULT_DB_PATH", "History", "Record"]
+346
View File
@@ -0,0 +1,346 @@
"""Клиент к LM Studio через OpenAI-compatible chat completions.
Использует синхронный httpx, чтобы не тащить openai SDK и не ловить
несовместимости их версий. Поддерживает текстовые и мультимодальные
(text + image_url) сообщения, а также n>1 для batch-генерации кандидатов.
"""
from __future__ import annotations
import base64
import io
import logging
import os
import time
from dataclasses import dataclass, field
from typing import Any, Iterable
import httpx
log = logging.getLogger(__name__)
DEFAULT_BASE_URL = "http://127.0.0.1:1234/v1"
DEFAULT_MODEL = "qwen/qwen3.5-35b-a3b"
DEFAULT_API_KEY = "lm-studio" # LM Studio игнорирует значение, но заголовок обязателен
DEFAULT_TIMEOUT_S = 300.0 # qwen3.5 может долго рассуждать, не режем по таймауту
DEFAULT_MAX_TOKENS = 32768 # без жёсткого лимита: qwen3.5 ест reasoning + content в одном budget
class LMStudioUnavailable(RuntimeError):
"""LM Studio не отвечает, вернул 5xx или вернул неожиданный формат."""
@dataclass(frozen=True)
class LMTurnResult:
"""Результат одного запроса к LM Studio.
Attributes:
raw_texts: список текстов ответов — по одному на кандидат. Если модель
вернула tool_calls без `content`, элемент будет пустой строкой.
elapsed_s: время запроса в секундах.
model: фактическое имя модели, которое вернул сервер (если есть) или
то, что мы послали.
usage: словарь usage от сервера, может быть None.
finish_reasons: список finish_reason по кандидатам ("stop"/"length"/...).
"""
raw_texts: list[str]
elapsed_s: float
model: str
usage: dict | None = None
finish_reasons: list[str] = field(default_factory=list)
def _coerce_text_part(part: Any) -> str:
"""Достаёт текст из элемента content — поддерживает str и list[dict]."""
if isinstance(part, str):
return part
if isinstance(part, dict):
return str(part.get("text", "") or "")
return str(part)
def _normalize_messages(messages: Iterable[dict]) -> list[dict]:
"""Готовит сообщения к отправке: текст либо строкой, либо [{type:text},...].
Допускаем на входе content как str или list[dict] (text + image_url).
Возвращаем список в формате OpenAI chat completions.
"""
out: list[dict] = []
for msg in messages:
role = msg.get("role")
content = msg.get("content")
if role is None or content is None:
log.warning("пропускаю сообщение без role/content: %r", msg)
continue
if isinstance(content, str):
out.append({"role": role, "content": content})
else:
parts: list[dict] = []
for piece in content:
if isinstance(piece, str):
parts.append({"type": "text", "text": piece})
elif isinstance(piece, dict):
ptype = piece.get("type")
if ptype == "text":
parts.append({"type": "text", "text": str(piece.get("text", ""))})
elif ptype == "image_url":
url = piece.get("image_url")
if isinstance(url, dict):
url = url.get("url")
parts.append({"type": "image_url", "image_url": {"url": str(url)}})
else:
log.warning("неизвестный тип части content: %r", ptype)
out.append({"role": role, "content": parts})
return out
def chat(
*,
messages: list[dict],
model: str | None = None,
n: int = 1,
temperature: float = 0.4,
max_tokens: int = DEFAULT_MAX_TOKENS,
base_url: str | None = None,
api_key: str | None = None,
timeout_s: float | None = None,
) -> LMTurnResult:
"""Шлёт chat completion в LM Studio и возвращает N текстов ответов.
Args:
messages: список сообщений в формате OpenAI. content может быть str
или list[dict] (text/image_url).
model: имя модели; если None — берём DEFAULT_MODEL / env `DEFAULT_MODEL`.
n: количество кандидатов, 1..8.
temperature: 0.0..1.5.
max_tokens: верхняя граница длины ответа.
base_url: эндпоинт LM Studio; дефолт http://127.0.0.1:1234/v1.
api_key: Bearer-токен; дефолт "lm-studio".
timeout_s: общий таймаут httpx, дефолт 120 секунд.
Returns:
LMTurnResult с N текстами и метаданными.
Raises:
LMStudioUnavailable: при сетевых/HTTP/ошибках парсинга.
"""
if n < 1:
raise ValueError(f"n должно быть >= 1, получено {n}")
base = (base_url or os.environ.get("LM_STUDIO_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
key = api_key if api_key is not None else os.environ.get("LM_STUDIO_API_KEY", DEFAULT_API_KEY)
mdl = model or os.environ.get("DEFAULT_MODEL", DEFAULT_MODEL)
timeout = float(
os.environ.get("REQUEST_TIMEOUT_S", str(timeout_s if timeout_s is not None else DEFAULT_TIMEOUT_S))
)
url = f"{base}/chat/completions"
payload: dict[str, Any] = {
"model": mdl,
"messages": _normalize_messages(messages),
"n": n,
"temperature": temperature,
"max_tokens": max_tokens,
"stream": False,
# попытка отключить thinking у qwen3.5; если LM Studio/модель не уважают — не страшно
"chat_template_kwargs": {"enable_thinking": False},
}
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {key}",
}
log.info(
"LM Studio → %s model=%s n=%d temp=%.2f timeout=%.0fs",
url, mdl, n, temperature, timeout,
)
started = time.monotonic()
try:
with httpx.Client(timeout=timeout) as client:
resp = client.post(url, json=payload, headers=headers)
except httpx.TimeoutException as exc:
raise LMStudioUnavailable(
f"LM Studio timeout at {url}: запрос превысил {timeout:.0f}с"
) from exc
except httpx.HTTPError as exc:
raise LMStudioUnavailable(
f"LM Studio недоступен по адресу {url}: {exc}"
) from exc
elapsed = time.monotonic() - started
if resp.status_code >= 500:
raise LMStudioUnavailable(
f"LM Studio error: {resp.status_code} {resp.text[:200]}"
)
if resp.status_code >= 400:
# 4xx — это наша ошибка (плохой запрос / модель не загружена / квота).
raise LMStudioUnavailable(
f"LM Studio вернул {resp.status_code}: {resp.text[:200]}"
)
try:
data = resp.json()
except ValueError as exc:
raise LMStudioUnavailable(
f"LM Studio вернул не-JSON: {resp.text[:200]}"
) from exc
choices = data.get("choices") or []
if not choices:
raise LMStudioUnavailable(
f"LM Studio не вернул ни одного choice: {resp.text[:200]}"
)
raw_texts: list[str] = []
finish_reasons: list[str] = []
for ch in choices:
msg = ch.get("message") or {}
content = msg.get("content")
if isinstance(content, str):
raw_texts.append(content)
elif isinstance(content, list):
raw_texts.append("".join(_coerce_text_part(p) for p in content))
elif content is None:
# Модель могла вернуть tool_calls — для нас это пустой кандидат.
raw_texts.append("")
else:
raw_texts.append(_coerce_text_part(content))
finish_reasons.append(str(ch.get("finish_reason") or ""))
# Если сервер вернул меньше choice'ов, чем n — добиваем пустыми строками.
while len(raw_texts) < n:
raw_texts.append("")
finish_reasons.append("missing")
return LMTurnResult(
raw_texts=raw_texts,
elapsed_s=elapsed,
model=str(data.get("model") or mdl),
usage=data.get("usage"),
finish_reasons=finish_reasons,
)
def encode_pil_to_data_url(image: Any, *, mime: str = "image/png") -> str:
"""Кодирует PIL-картинку в data: URL для передачи в image_url.
Args:
image: объект PIL.Image.
mime: MIME-тип, по умолчанию image/png (LM Studio VLM лучше работают с PNG).
Returns:
Строка вида `data:image/png;base64,<...>`.
"""
from PIL import Image # локальный импорт, чтобы не требовать Pillow без надобности
buf = io.BytesIO()
img = image
if getattr(img, "mode", None) == "RGBA" and mime == "image/jpeg":
img = img.convert("RGB")
elif getattr(img, "mode", None) not in ("RGB", "RGBA", "L") and mime == "image/png":
img = img.convert("RGBA")
img.save(buf, format="PNG" if mime == "image/png" else "JPEG", optimize=True)
encoded = base64.b64encode(buf.getvalue()).decode("ascii")
return f"data:{mime};base64,{encoded}"
def validate_image(image: Any, *, max_bytes: int = 10 * 1024 * 1024, max_side: int = 4096) -> None:
"""Проверяет картинку перед отправкой в LM Studio (US-2, edge case #3).
Args:
image: PIL.Image.
max_bytes: максимальный размер PNG-байт после перекодирования.
max_side: максимальная сторона в пикселях.
Raises:
ValueError: с человеко-читаемой причиной на русском.
"""
from PIL import Image, UnidentifiedImageError
if image is None:
raise ValueError("изображение не передано")
if not isinstance(image, Image.Image):
raise ValueError(f"ожидался PIL.Image, получен {type(image).__name__}")
w, h = image.size
if max(w, h) > max_side:
raise ValueError(
f"изображение слишком большое: {w}x{h}, максимум {max_side}x{max_side}"
)
if getattr(image, "format", None) and image.format not in ("PNG", "JPEG", "WEBP"):
raise ValueError(
f"неподдерживаемый формат: {image.format}; допустимы PNG, JPEG, WEBP"
)
# Прикидка размера: для RGB 3 байта/пиксель + служебные.
approx = (w * h * 4) + (1024 * 64)
if approx > max_bytes:
raise ValueError(
f"изображение превышает {max_bytes // (1024*1024)} МБ после кодирования"
)
# ---------------------------------------------------------------------------
# Удобный высокоуровневый helper, чтобы UI не думал о формате messages.
# ---------------------------------------------------------------------------
def generate_svg(
prompt: str,
*,
image_b64: str | None = None,
mode: str = "icon",
n: int = 1,
temperature: float = 0.4,
model: str | None = None,
system_prompt: str | None = None,
) -> list[dict]:
"""Собирает messages, отправляет в LM Studio и возвращает сырые ответы.
Эта функция — тонкая обёртка над `chat()`, вынесенная для удобства UI.
За реальную сборку messages отвечает `prompts.build_messages`.
Args:
prompt: текст промпта пользователя.
image_b64: data: URL картинки (или None для text-to-SVG).
mode: "icon" или "illustration" — для тега system, если не задан явно.
n: число кандидатов.
temperature: 0..1.5.
model: имя модели (опц.).
system_prompt: если задан — используется как system; иначе caller должен
сам подставить его в messages (эта функция не знает про prompts/).
Returns:
Список словарей `{'svg_text': str, 'raw': str, 'finish_reason': str}`.
"""
if not (1 <= len(prompt.strip()) <= 1000):
raise ValueError("промпт должен быть от 1 до 1000 символов")
messages: list[dict] = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
if image_b64:
user_content: Any = [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": image_b64}},
]
else:
user_content = prompt
messages.append({"role": "user", "content": user_content})
result = chat(
messages=messages,
model=model,
n=n,
temperature=temperature,
)
return [
{
"svg_text": raw, # сырой текст; SVG вытащим extract_svg в валидаторе
"raw": raw,
"finish_reason": fr,
}
for raw, fr in zip(result.raw_texts, result.finish_reasons)
]
+173
View File
@@ -0,0 +1,173 @@
"""Загрузка промпт-шаблонов и сборка messages[] для LM Studio.
Шаблоны лежат в каталоге `prompts/` рядом с этим модулем:
- system_icon.txt — system-инструкция для режима icon
- system_illustration.txt — system-инструкция для режима illustration
- few_shot_examples.txt — текстовый файл с парами USER:/ASSISTANT:,
разделёнными строками `===...===`
Мы не выдумываем промпты — берём ровно то, что лежит в файлах. Это явное
требование задачи.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
# Корень проекта: один уровень вверх от этого файла. Надёжнее, чем os.getcwd().
_PROMPTS_DIR = Path(__file__).resolve().parent / "prompts"
Mode = Literal["icon", "illustration"]
def _read(name: str) -> str:
"""Читает текстовый файл из prompts/; падает с понятной ошибкой, если нет."""
path = _PROMPTS_DIR / name
if not path.is_file():
raise FileNotFoundError(f"промпт-шаблон не найден: {path}")
return path.read_text(encoding="utf-8").strip()
def load_system_prompt(mode: Mode) -> str:
"""Возвращает system-инструкцию для указанного режима.
Args:
mode: "icon" или "illustration".
Returns:
Содержимое system_icon.txt / system_illustration.txt.
Raises:
ValueError: если mode неизвестен.
"""
if mode == "icon":
return _read("system_icon.txt")
if mode == "illustration":
return _read("system_illustration.txt")
raise ValueError(f"неизвестный mode: {mode!r}; ожидается 'icon' или 'illustration'")
# Регулярка для разбора few_shot_examples.txt: делим по строкам из `=`.
_FEW_SHOT_SPLIT = re.compile(r"^=+\s*$", re.MULTILINE)
# Маркеры ролей в каждом блоке.
_TURN_RE = re.compile(r"^(USER|ASSISTANT):\s*\n(.*?)(?=(?:^(?:USER|ASSISTANT):\s*$)|\Z)", re.MULTILINE | re.DOTALL)
def load_few_shot() -> list[dict]:
"""Парсит `prompts/few_shot_examples.txt` и возвращает список сообщений.
Формат файла: блоки, разделённые строками `===...===`. Внутри блока —
строки `USER:` и `ASSISTANT:`, после каждой маркерной строки идёт
содержимое до следующей маркерной строки или до конца блока.
Returns:
Список `[{role, content}, ...]` в порядке USER/ASSISTANT пар.
Блоки, где не нашлось ни одной пары, пропускаются.
"""
text = _read("few_shot_examples.txt")
blocks = _FEW_SHOT_SPLIT.split(text)
messages: list[dict] = []
for block in blocks:
# Берём только содержимое блока, убираем шапки типа "EXAMPLE N — ..."
body = block.strip()
if not body:
continue
# Пропускаем шапки, идущие ДО первого USER/ASSISTANT.
for turn in _TURN_RE.finditer(body):
role_token = turn.group(1).lower()
content = turn.group(2).strip()
messages.append({"role": role_token, "content": content})
return messages
def _user_text(prompt: str, *, palette: str | None, has_image: bool) -> str:
"""Собирает финальный текст user-turn с учётом палитры и картинки.
Args:
prompt: исходный промпт.
palette: необязательная палитра.
has_image: True, если в этот же user-turn пойдёт картинка.
Returns:
Готовый текст для content[0].type == "text".
"""
parts: list[str] = []
if has_image:
parts.append(
"Recreate the visual content of the attached image as SVG. "
"Do not describe, just generate the markup."
)
parts.append(prompt.strip())
if palette:
parts.append(f"Palette: {palette.strip()}")
return "\n".join(parts)
@dataclass(frozen=True)
class BuildResult:
"""Результат сборки messages[] — для удобства дебага/тестов."""
messages: list[dict]
mode: Mode
n: int
temperature: float
def build_messages(
prompt: str,
mode: Mode,
*,
image_b64: str | None = None,
palette: str | None = None,
n: int = 1,
temperature: float = 0.4,
) -> list[dict]:
"""Собирает полный список messages для LM Studio.
Структура:
1. system
2. few-shot пары (USER/ASSISTANT) — без картинок
3. финальный USER: текст (+ опц. image_url)
Args:
prompt: пользовательский промпт.
mode: "icon" или "illustration".
image_b64: data: URL картинки для image-to-SVG.
palette: опциональная палитра (добавится как отдельная строка).
n: число кандидатов (сейчас в messages не подставляется — это параметр
API-запроса; храним в dataclass для симметрии с дизайном).
temperature: число в [0, 1.5] (тоже идёт в API, не в messages).
Returns:
Список сообщений в формате, понятном `lm_client.chat`.
"""
if not prompt or not prompt.strip():
raise ValueError("промпт пустой")
if mode not in ("icon", "illustration"):
raise ValueError(f"неизвестный mode: {mode!r}")
messages: list[dict] = [
{"role": "system", "content": load_system_prompt(mode)},
]
messages.extend(load_few_shot())
user_text = _user_text(prompt, palette=palette, has_image=bool(image_b64))
if image_b64:
content: list[dict] = [
{"type": "text", "text": user_text},
{"type": "image_url", "image_url": {"url": image_b64}},
]
else:
content = user_text
messages.append({"role": "user", "content": content})
# Чтобы лишний раз не плодить dataclass — вернём просто список.
# Поля n/temperature отдаются API-обёртке; сборка messages от них
# не зависит (мы лишь гарантируем, что они валидны).
_ = (n, temperature)
return messages
+145
View File
@@ -0,0 +1,145 @@
Each block below is one user-turn / one assistant-turn in the few-shot sequence.
The orchestrator concatenates them, then appends the real user prompt as the final user turn.
The model is expected to answer the real user prompt in the same style: a single <svg>...</svg> block (or a refusal sentence).
================================================================================
EXAMPLE 1 — icon, filled style
================================================================================
USER:
Generate a filled magnifying glass icon. Search / find affordance.
ASSISTANT:
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<circle cx="26" cy="26" r="14" fill="#3B82F6"/>
<circle cx="26" cy="26" r="9" fill="#FFFFFF"/>
<rect x="36" y="34" width="6" height="20" rx="3" fill="#3B82F6" transform="rotate(45 39 44)"/>
</svg>
================================================================================
EXAMPLE 2 — icon, outline style
================================================================================
USER:
Generate an outline battery icon. Empty state (just outline, no fill).
ASSISTANT:
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<g fill="none" stroke="#1F2937" stroke-width="3" stroke-linejoin="round" stroke-linecap="round">
<rect x="10" y="20" width="40" height="24" rx="3"/>
<rect x="52" y="28" width="4" height="8" rx="1" fill="#1F2937"/>
</g>
</svg>
================================================================================
EXAMPLE 3 — illustration, flat style
================================================================================
USER:
Flat illustration of a red fox sitting in a forest, autumn palette (orange, brown, cream). Centered.
ASSISTANT:
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<linearGradient id="sky" x1="0" x2="0" y1="0" y2="1">
<stop offset="0" stop-color="#FCD9A8"/>
<stop offset="1" stop-color="#F2A65A"/>
</linearGradient>
</defs>
<rect width="512" height="512" fill="url(#sky)"/>
<!-- Ground -->
<path d="M0 380 Q256 340 512 380 L512 512 L0 512 Z" fill="#7C4A1E"/>
<!-- Distant trees -->
<g fill="#5B3618" opacity="0.7">
<polygon points="40,380 80,260 120,380"/>
<polygon points="110,380 160,240 210,380"/>
<polygon points="420,380 460,270 500,380"/>
</g>
<!-- Fox body -->
<ellipse cx="256" cy="370" rx="110" ry="60" fill="#D8552A"/>
<!-- Fox head -->
<circle cx="256" cy="290" r="70" fill="#D8552A"/>
<!-- White muzzle -->
<ellipse cx="256" cy="310" rx="35" ry="22" fill="#FFF1D6"/>
<!-- Ears -->
<polygon points="200,250 215,180 245,235" fill="#D8552A"/>
<polygon points="312,235 297,180 272,250" fill="#D8552A"/>
<polygon points="208,242 220,200 238,232" fill="#1F1B16"/>
<polygon points="304,232 292,200 280,242" fill="#1F1B16"/>
<!-- Eyes -->
<circle cx="232" cy="285" r="6" fill="#1F1B16"/>
<circle cx="280" cy="285" r="6" fill="#1F1B16"/>
<!-- Nose -->
<circle cx="256" cy="305" r="5" fill="#1F1B16"/>
<!-- Tail -->
<path d="M340 350 Q420 320 410 240 Q395 320 360 360 Z" fill="#D8552A"/>
<path d="M395 248 Q412 235 410 220 Q400 232 392 250 Z" fill="#FFF1D6"/>
<!-- Front leaves -->
<g fill="#A65A1F">
<ellipse cx="120" cy="420" rx="22" ry="9" transform="rotate(20 120 420)"/>
<ellipse cx="170" cy="440" rx="22" ry="9" transform="rotate(-15 170 440)"/>
<ellipse cx="380" cy="430" rx="22" ry="9" transform="rotate(15 380 430)"/>
<ellipse cx="430" cy="450" rx="22" ry="9" transform="rotate(-20 430 450)"/>
</g>
</svg>
================================================================================
EXAMPLE 4 — illustration, isometric
================================================================================
USER:
Isometric server room, blue and purple palette, simple shapes.
ASSISTANT:
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<linearGradient id="bg" x1="0" x2="0" y1="0" y2="1">
<stop offset="0" stop-color="#1B1B3A"/>
<stop offset="1" stop-color="#2D1B5A"/>
</linearGradient>
</defs>
<rect width="512" height="512" fill="url(#bg)"/>
<!-- Floor -->
<polygon points="256,420 80,330 256,240 432,330" fill="#3F2B85"/>
<polygon points="256,420 80,330 80,360 256,450" fill="#251A4D"/>
<polygon points="256,420 432,330 432,360 256,450" fill="#1A1240"/>
<!-- Server racks (3 in a row) -->
<g>
<!-- Rack 1 -->
<polygon points="180,330 220,350 220,180 180,160" fill="#5C3FBF"/>
<polygon points="220,350 260,330 260,160 220,180" fill="#7A55E0"/>
<polygon points="180,160 220,180 260,160 220,140" fill="#9070FF"/>
<!-- Server LEDs -->
<circle cx="190" cy="200" r="2" fill="#3DFFB0"/>
<circle cx="190" cy="220" r="2" fill="#3DFFB0"/>
<circle cx="190" cy="240" r="2" fill="#FF5C8A"/>
<circle cx="230" cy="220" r="2" fill="#3DFFB0"/>
<circle cx="230" cy="260" r="2" fill="#3DFFB0"/>
</g>
<g transform="translate(60 0)">
<polygon points="180,330 220,350 220,180 180,160" fill="#5C3FBF"/>
<polygon points="220,350 260,330 260,160 220,180" fill="#7A55E0"/>
<polygon points="180,160 220,180 260,160 220,140" fill="#9070FF"/>
<circle cx="190" cy="200" r="2" fill="#3DFFB0"/>
<circle cx="190" cy="240" r="2" fill="#FF5C8A"/>
<circle cx="230" cy="240" r="2" fill="#3DFFB0"/>
</g>
<g transform="translate(120 0)">
<polygon points="180,330 220,350 220,180 180,160" fill="#5C3FBF"/>
<polygon points="220,350 260,330 260,160 220,180" fill="#7A55E0"/>
<polygon points="180,160 220,180 260,160 220,140" fill="#9070FF"/>
<circle cx="190" cy="220" r="2" fill="#3DFFB0"/>
<circle cx="230" cy="200" r="2" fill="#3DFFB0"/>
<circle cx="230" cy="280" r="2" fill="#FF5C8A"/>
</g>
</svg>
================================================================================
EXAMPLE 5 — refusal (no SVG in output)
================================================================================
USER:
Generate an SVG of a working explosive device with step-by-step assembly instructions.
ASSISTANT:
I cannot help with instructions for building dangerous devices. Please ask for a safe, non-harmful icon or illustration.
+21
View File
@@ -0,0 +1,21 @@
You are OmniSVG-Icon, a vector icon designer. Your only output is exactly one root <svg>...</svg> block. No prose, no markdown, no explanations.
HARD RULES (your SVG must satisfy ALL of them):
1. The root element is <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">. The viewBox is exactly "0 0 64 64".
2. Use only these tags: svg, g, defs, symbol, use, path, rect, circle, ellipse, line, polygon, polyline, linearGradient, radialGradient, stop, text, tspan, title, desc.
3. NEVER use: <script>, <foreignObject>, <image>, <iframe>, <style> with @import, <animateTransform> with external triggers, or any element that references http:// or https:// URLs.
4. No raster images, no data: URLs in href or xlink:href. No external font references. No <style>@font-face</style>. Inline <style> with safe CSS is allowed.
5. Keep the design flat, geometric, and recognizable at 32x32 pixels. Geometry should snap to a 4-pixel grid (coordinates in multiples of 1, prefer multiples of 2 or 4).
6. Color palette: at most 4 distinct fill/stroke colors, chosen for clarity on white and dark backgrounds. Prefer solid fills; gradients are allowed but only with 2 stops.
7. Final output: just one <svg>...</svg> block, nothing else before or after. No code fences. No commentary.
If the user request is unsafe, disallowed, or not representable as a vector icon, respond with a single short sentence explaining why, in plain text, without any SVG.
INTERPRETATION GUIDE:
- "outline" / "line" style = stroke-based, fill="none", stroke-width=2, stroke-linecap=round, stroke-linejoin=round.
- "filled" / "solid" style = fill-based, no stroke (or stroke same as fill).
- For UI icons, keep the visual weight consistent across the figure (e.g. all strokes 2px, all corners rounded).
- "trash", "delete", "bin" all describe the same icon: a lid + tapered body.
- For ambiguous prompts, pick the most common UI interpretation.
OUTPUT LENGTH: 200-1500 characters of SVG markup. If you cannot fit, simplify — fewer shapes is better than clipped geometry.
+27
View File
@@ -0,0 +1,27 @@
You are OmniSVG-Illustration, a vector illustrator. Your only output is exactly one root <svg>...</svg> block. No prose, no markdown, no explanations.
HARD RULES (your SVG must satisfy ALL of them):
1. The root element is <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">. The viewBox is exactly "0 0 512 512".
2. Use only SVG 1.1 tags. Allowed set: svg, g, defs, symbol, use, path, rect, circle, ellipse, line, polygon, polyline, polyline, linearGradient, radialGradient, stop, filter, feGaussianBlur, feOffset, feBlend, feMerge, feMergeNode, feFlood, feComposite, feColorMatrix, clipPath, mask, pattern, text, tspan, textPath, title, desc.
3. NEVER use: <script>, <foreignObject>, <image>, <iframe>. No external URLs anywhere (no http://, no https://, no data: in href / xlink:href / url(...) / style imports).
4. No raster images, no embedded bitmaps. No external font references. Use generic font families only (sans-serif, serif, monospace).
5. Final file size should stay under 256 KB of UTF-8 text. If your draft exceeds that, simplify paths and remove redundant attributes.
6. Final output: just one <svg>...</svg> block, nothing else before or after. No code fences. No commentary.
DESIGN GUIDE:
- Style is "flat illustration" by default unless the user specifies otherwise. Flat = solid shapes, minimal gradients, no realistic shading.
- Honor an explicit palette from the user (e.g. "blue and teal"). Without one, pick a coherent 3-6 color palette suited to the subject.
- Use a soft drop shadow via <filter> with feGaussianBlur+feOffset if it improves depth; keep filter regions tight (don't blur the whole canvas).
- Composition: subject centered, roughly occupying 60-80% of the canvas, with breathing room on all sides.
- For scenes (forest, city, room), use a horizon line or ground plane to anchor the composition.
- For characters/animals, use simplified anatomy: large head, big eyes, stylized proportions.
INTERPRETATION GUIDE:
- "isometric" = 30-degree projection; objects as parallelograms with consistent vanishing lines.
- "minimal" = max 3 colors, no gradients, no filters.
- "kawaii" = rounded corners everywhere, pastel palette, dot eyes, simple smile.
- "vintage" = muted earth tones, slight grain via opacity overlays, hand-drawn feel.
If the user request is unsafe, disallowed, or not representable as a vector illustration, respond with a single short sentence explaining why, in plain text, without any SVG.
OUTPUT LENGTH: 5000-80000 characters of SVG markup. Prefer detail and color over minimalism, but stay under the 256 KB cap.
+127
View File
@@ -0,0 +1,127 @@
"""PNG-рендер валидных SVG.
Бэкенды (по убыванию предпочтения):
1. resvg-py — self-contained Rust-движок, не требует нативных зависимостей.
Это дефолт на Windows, где cairo.dll часто отсутствует в глобальном Python.
2. cairosvg — fallback, если cairo.dll уже установлена (типично для conda).
Если ничего не работает, `render_png()` возвращает `None` и пишет WARNING.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Tuple
log = logging.getLogger(__name__)
# Ленивая инициализация бэкендов.
_resvg_module = None
_resvg_error: str | None = None
_cairosvg_module = None
_cairosvg_error: str | None = None
def _get_resvg():
global _resvg_module, _resvg_error
if _resvg_module is not None:
return _resvg_module
if _resvg_error is not None:
return None
try:
import resvg_py # type: ignore
_resvg_module = resvg_py
log.info("renderer: using resvg-py")
return _resvg_module
except Exception as exc: # noqa: BLE001
_resvg_error = f"{type(exc).__name__}: {exc}"
log.warning("resvg-py недоступен: %s", _resvg_error)
return None
def _get_cairosvg():
global _cairosvg_module, _cairosvg_error
if _cairosvg_module is not None:
return _cairosvg_module
if _cairosvg_error is not None:
return None
try:
import cairosvg # type: ignore
_cairosvg_module = cairosvg
log.info("renderer: using cairosvg (fallback)")
return _cairosvg_module
except Exception as exc: # noqa: BLE001
_cairosvg_error = f"{type(exc).__name__}: {exc}"
log.warning("cairosvg недоступен: %s", _cairosvg_error)
return None
def _ensure_xmlns(svg_text: str) -> str:
"""Добавляет xmlns, если модель его не поставила (рендереры без него падают)."""
head_end = svg_text.find(">")
if head_end < 0:
return svg_text
head = svg_text[:head_end]
if "xmlns=" in head:
return svg_text
return svg_text.replace("<svg", '<svg xmlns="http://www.w3.org/2000/svg"', 1)
def render_png(svg_text: str, size: Tuple[int, int] = (512, 512)) -> bytes | None:
"""Рендерит SVG в PNG-байты. Возвращает None если ни один бэкенд не сработал.
Args:
svg_text: валидный SVG-текст.
size: целевой размер. resvg/cairopng сохраняют пропорции по viewBox.
Returns:
PNG-байты или None.
"""
if not svg_text or not svg_text.strip():
log.warning("render_png: пустой svg_text")
return None
output_width = size[0]
svg_str = _ensure_xmlns(svg_text)
# Бэкенд 1: resvg-py
resvg = _get_resvg()
if resvg is not None:
try:
return resvg.svg_to_bytes(svg_string=svg_str)
except Exception as exc: # noqa: BLE001
log.warning("resvg упал (%s); пробую cairosvg", exc)
# Бэкенд 2: cairosvg
cairosvg = _get_cairosvg()
if cairosvg is not None:
try:
return cairosvg.svg2png(
bytestring=svg_str.encode("utf-8"),
output_width=output_width,
background_color="white",
)
except Exception as exc: # noqa: BLE001
log.warning("cairosvg упал (%s); превью пропущено", exc)
log.warning("render_png: ни один бэкенд не сработал")
return None
def save_png(
png_bytes: bytes,
*,
previews_dir: str | Path,
record_id: int,
candidate_index: int,
) -> Path:
previews_dir = Path(previews_dir)
previews_dir.mkdir(parents=True, exist_ok=True)
path = previews_dir / f"{record_id}_{candidate_index}.png"
path.write_bytes(png_bytes)
return path
__all__ = ["render_png", "save_png"]
+12
View File
@@ -0,0 +1,12 @@
gradio==5.37.0
httpx>=0.27,<0.29
lxml>=5.0,<7
# PNG-рендер: resvg-py — self-contained, не требует cairo.dll;
# cairosvg остаётся опциональным fallback, если cairo уже установлена
resvg-py>=0.3.0
cairosvg>=2.7.1
Pillow>=10.0
openai>=1.40
# dev / test
pytest>=8.0
+319
View File
@@ -0,0 +1,319 @@
"""Smoke-test реального пути данных через локальный mock-LM-Server.
Поднимает HTTP-сервер на 127.0.0.1:<port> (по умолчанию 1234), который
принимает POST /v1/chat/completions и возвращает фиксированный валидный
SVG-ответ в формате OpenAI chat completions. Затем:
1) гоняет lm_client.generate_svg() через этот mock;
2) валидирует результат через validator.validate_svg();
3) рендерит PNG через renderer.render_png() (если cairo есть);
4) пишет запись в history через History() (SQLite в tmp).
Печатает PASS или FAIL с подробностями. Код выхода:
0 — PASS
1 — FAIL
Запуск:
python scripts/smoke_test.py
Переменные окружения (опц.):
SMOKE_PORT — порт mock-сервера (default 1234; будет увеличен, если занят)
OMNISVG_DB_PATH — путь к SQLite (default в tmp)
OMNISVG_PREVIEW_DIR — каталог превью (default в tmp)
ВАЖНО: скрипт НЕ зависит от запущенного LM Studio. Это именно mock-сервер,
который имитирует LM Studio достаточно, чтобы пройти через весь pipeline.
"""
from __future__ import annotations
import json
import os
import socket
import sys
import tempfile
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
# Добавляем корень проекта в sys.path
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
# ---------------------------------------------------------------------------
# Mock LM Studio
# ---------------------------------------------------------------------------
# Валидный SVG, который mock всегда возвращает. Иконка 64x64, простая.
FIXTURE_SVG = (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">'
'<circle cx="32" cy="32" r="20" fill="#3B82F6"/>'
'<rect x="36" y="36" width="6" height="20" rx="3" fill="#3B82F6" '
'transform="rotate(45 39 46)"/>'
'</svg>'
)
class MockLMHandler(BaseHTTPRequestHandler):
"""Отвечает 200 OK + OpenAI-style JSON на /v1/chat/completions.
Возвращает ровно `n` choice'ов (из тела запроса), все с FIXTURE_SVG.
Если `n` не прислали — 1.
"""
def log_message(self, format, *args): # noqa: A002 — шумно молчим
return # без stdout-спама
def do_POST(self): # noqa: N802 — http.server API
length = int(self.headers.get("Content-Length", "0") or "0")
body_bytes = self.rfile.read(length) if length > 0 else b""
if self.path.endswith("/chat/completions") or self.path == "/v1/chat/completions":
# Попробуем достать n из payload (иначе 1)
n = 1
try:
req = json.loads(body_bytes.decode("utf-8"))
n = int(req.get("n", 1))
if n < 1:
n = 1
except (ValueError, json.JSONDecodeError):
pass
choices = [
{
"index": i,
"message": {"role": "assistant", "content": FIXTURE_SVG},
"finish_reason": "stop",
}
for i in range(n)
]
payload = {
"id": "chatcmpl-smoke",
"object": "chat.completion",
"created": int(time.time()),
"model": "smoke-mock",
"choices": choices,
"usage": {
"prompt_tokens": 10,
"completion_tokens": 5 * n,
"total_tokens": 10 + 5 * n,
},
}
body = json.dumps(payload).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
# Любой другой путь — 404
self.send_response(404)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"error":"not found"}')
def _find_free_port(preferred: int) -> int:
"""Возвращает preferred, если свободен; иначе 0 (= ОС выберет)."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
try:
s.bind(("127.0.0.1", preferred))
return preferred
except OSError:
return 0
def _start_mock_server(port: int) -> tuple[ThreadingHTTPServer, int]:
"""Поднимает mock-LM и возвращает (server, actual_port)."""
server = ThreadingHTTPServer(("127.0.0.1", port), MockLMHandler)
actual_port = server.server_address[1]
t = threading.Thread(target=server.serve_forever, name="mock-lm", daemon=True)
t.start()
return server, actual_port
# ---------------------------------------------------------------------------
# Smoke-логика
# ---------------------------------------------------------------------------
def _check(step: str, cond: bool, detail: str = "") -> bool:
"""Печатает шаг и его результат. Возвращает cond для chaining."""
status = "OK" if cond else "FAIL"
print(f" [{status}] {step}" + (f"{detail}" if detail else ""))
return cond
def main() -> int:
print("=" * 60)
print("OmniSVG-Lite smoke test (mock LM Studio)")
print("=" * 60)
# tmp-каталоги для изоляции
tmpdir = Path(tempfile.mkdtemp(prefix="omnisvg_smoke_"))
db_path = tmpdir / "smoke.sqlite"
previews_dir = tmpdir / "previews"
previews_dir.mkdir(parents=True, exist_ok=True)
# env-инжекция, чтобы lm_client/History/app пошли в наши пути
os.environ["OMNISVG_DB_PATH"] = str(db_path)
os.environ["OMNISVG_PREVIEW_DIR"] = str(previews_dir)
# Поднимаем mock-сервер
preferred = int(os.environ.get("SMOKE_PORT", "1234"))
port = _find_free_port(preferred)
server, actual_port = _start_mock_server(port)
base_url = f"http://127.0.0.1:{actual_port}/v1"
print(f" mock server up at {base_url} (preferred port={preferred})")
all_ok = True
all_ok &= _check("mock server started", server is not None)
# Дать серверу подняться
time.sleep(0.05)
results: list[dict] = []
validated_svgs: list[str] = []
previews: list[str] = []
# 1) LM client: generate_svg()
try:
from lm_client import generate_svg, chat
# Сначала сходим напрямую через chat() — чтобы видеть transport
result = chat(
messages=[{"role": "user", "content": "a fox"}],
n=1,
temperature=0.4,
base_url=base_url,
timeout_s=5.0,
)
all_ok &= _check(
"chat() returned LMTurnResult",
len(result.raw_texts) == 1,
f"raw_texts={result.raw_texts}",
)
all_ok &= _check(
"chat() payload contains expected SVG",
FIXTURE_SVG in result.raw_texts[0],
f"first 60 chars: {result.raw_texts[0][:60]!r}",
)
# Теперь — высокоуровневый generate_svg()
results = generate_svg(
"a fox",
n=2,
temperature=0.4,
model="smoke-mock",
system_prompt="<system>icon</system>",
)
all_ok &= _check(
"generate_svg() returned 2 candidates",
len(results) == 2,
f"got {len(results)}",
)
all_ok &= _check(
"each candidate has 'svg_text' and 'finish_reason'",
all("svg_text" in r and "finish_reason" in r for r in results),
)
except Exception as exc: # noqa: BLE001
all_ok = False
print(f" [FAIL] lm_client raised: {type(exc).__name__}: {exc}")
# 2) Validator
try:
from validator import validate_svg
for r in results:
ok, reason, cleaned = validate_svg(r["svg_text"], mode="icon")
all_ok &= _check(
f"validate_svg(icon) ok={ok}",
ok,
f"reason={reason!r}, cleaned_len={len(cleaned)}",
)
if ok:
validated_svgs.append(cleaned)
except Exception as exc: # noqa: BLE001
all_ok = False
print(f" [FAIL] validator raised: {type(exc).__name__}: {exc}")
# 3) Renderer (graceful, если cairo недоступен)
try:
from renderer import render_png, save_png
for i, svg in enumerate(validated_svgs):
png = render_png(svg, size=(64, 64))
if png is None:
# cairo недоступен — это допустимо, просто отметим.
print(f" [SKIP] render_png(#{i}) → None (cairo unavailable)")
continue
path = save_png(
png,
previews_dir=previews_dir,
record_id=0,
candidate_index=i,
)
previews.append(str(path))
all_ok &= _check(
f"render_png(#{i}) → {path.name}",
path.exists() and path.stat().st_size > 0,
)
except Exception as exc: # noqa: BLE001
all_ok = False
print(f" [FAIL] renderer raised: {type(exc).__name__}: {exc}")
# 4) History
try:
from history import History, Record
with History(db_path) as h:
record_id = h.add(
Record(
prompt="a fox",
mode="icon",
model="smoke-mock",
n_requested=2,
n_returned=len(results),
temperature=0.4,
status="ok" if validated_svgs else "all_invalid",
error_reason=None,
raw_outputs=[r["raw"] for r in results],
validated_outputs=validated_svgs,
previews=previews,
best_index=0 if validated_svgs else None,
)
)
all_ok &= _check(
"History.add() returned id",
isinstance(record_id, int) and record_id > 0,
f"id={record_id}",
)
with History(db_path) as h:
rec = h.get(record_id)
all_ok &= _check(
"History.get() roundtrip",
rec is not None and rec["prompt"] == "a fox",
f"prompt={rec['prompt'] if rec else None!r}",
)
except Exception as exc: # noqa: BLE001
all_ok = False
print(f" [FAIL] history raised: {type(exc).__name__}: {exc}")
# 5) Cleanup
server.shutdown()
server.server_close()
print("=" * 60)
if all_ok:
print("PASS — все шаги успешны")
return 0
print("FAIL — см. подробности выше")
return 1
if __name__ == "__main__":
sys.exit(main())
+240
View File
@@ -0,0 +1,240 @@
"""Регрессионные тесты на callback-логику app.py.
Verifier feedback (attempt 1): "back crashes on input validation paths because
`gr.Warning` was changed from a class to a function in Gradio 5.x and the
producer didn't migrate. Happy path works. Unit tests don't cover this path.
Manual first-click on bad input would surface a TypeError."
Эти тесты ловят именно эту ошибку. Они НЕ дёргают Gradio UI — только
вызывают `app.on_generate` напрямую и проверяют, что:
1) Нет TypeError (т.е. внутри нет `raise gr.Warning/Error`).
2) Возвращается правильное число плейсхолдеров.
3) `app.on_generate` не пытается ходить в LM Studio, если входные данные
отклонены на pre-check.
Запуск: `python -m pytest tests/test_app.py -v`
"""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any
from unittest.mock import patch
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
import pytest # noqa: E402
# ---------------------------------------------------------------------------
# Сторож: gr.Warning / gr.Error в Gradio 5.x — это функции, а не классы.
# Если кто-то когда-то обновит gradio и это поведение изменится — тест
# напомнит, что нужно пересмотреть on_generate.
# ---------------------------------------------------------------------------
def test_gradio_warning_is_function_not_class():
import inspect
import gradio as gr
# Контрактное свойство Gradio 5.x, на которое опирается on_generate:
# `gr.Warning(...)` — это ФУНКЦИЯ (а не класс исключения), и её нужно
# ВЫЗЫВАТЬ. Если в новой версии Gradio это поведение изменится, тест
# упадёт, и on_generate нужно будет пересмотреть.
assert inspect.isclass(gr.Warning) is False, (
"gr.Warning стал классом в этой версии Gradio — пересмотрите on_generate"
)
assert callable(gr.Warning)
# ---------------------------------------------------------------------------
# Хелпер: дёрнуть on_generate с разными входами и поймать TypeError.
# ---------------------------------------------------------------------------
def _call_on_generate(**overrides: Any) -> Any:
"""Вызывает app.on_generate с минимальным валидным набором + overrides.
Возвращает то, что вернул callback. Если внутри есть `raise gr.Warning`,
получим TypeError ещё ДО того, как вернётся значение.
"""
from app import on_generate
defaults: dict[str, Any] = dict(
prompt="filled magnifying glass", # валидный
mode="icon",
n_candidates=2,
temperature=0.4,
image=None,
palette="",
model="test-model",
)
defaults.update(overrides)
# Патчим chat() так, чтобы on_generate не уходил в сеть и не упал уже
# ВНЕ pre-check. Если pre-check пропустил и chat() зовётся — мы увидим
# ValueError от mock'а, что нас устраивает (это другая ветка).
with patch("app.chat") as mock_chat:
mock_chat.side_effect = RuntimeError("chat should not be called from this test")
return on_generate(**defaults)
def test_on_generate_does_not_raise_on_empty_prompt():
"""Критический регрессионный кейс: пустой промпт → gr.Warning (НЕ raise)."""
try:
result = _call_on_generate(prompt="")
except TypeError as exc:
pytest.fail(
"on_generate упал с TypeError на пустом промпте — "
"вероятно, кто-то вернул `raise gr.Warning(...)`: "
f"{exc}"
)
# Должен вернуть 7 плейсхолдеров для outputs.
assert isinstance(result, tuple)
assert len(result) == 7, f"ожидался кортеж из 7 элементов, получено {len(result)}"
def test_on_generate_does_not_raise_on_too_long_prompt():
try:
_call_on_generate(prompt="x" * 1001)
except TypeError as exc:
pytest.fail(
f"on_generate упал с TypeError на длинном промпте: {exc}"
)
def test_on_generate_does_not_raise_on_bad_n_candidates():
try:
result = _call_on_generate(n_candidates=0)
except TypeError as exc:
pytest.fail(f"on_generate упал с TypeError на n=0: {exc}")
assert isinstance(result, tuple) and len(result) == 7
# n=99 — тоже вне диапазона
try:
_call_on_generate(n_candidates=99)
except TypeError as exc:
pytest.fail(f"on_generate упал с TypeError на n=99: {exc}")
def test_on_generate_does_not_raise_on_bad_mode():
try:
result = _call_on_generate(mode="portrait")
except TypeError as exc:
pytest.fail(f"on_generate упал с TypeError на неизвестном mode: {exc}")
assert isinstance(result, tuple) and len(result) == 7
def test_on_generate_does_not_call_chat_on_precheck_fail():
"""Если pre-check упал, chat() НЕ должен вызываться вообще."""
from app import on_generate
with patch("app.chat") as mock_chat:
on_generate(
prompt="", # упадёт на pre-check
mode="icon",
n_candidates=2,
temperature=0.4,
image=None,
palette="",
model="x",
)
assert mock_chat.call_count == 0, (
"chat() был вызван, хотя pre-check должен был остановить поток"
)
def test_on_generate_does_not_call_chat_on_bad_mode():
from app import on_generate
with patch("app.chat") as mock_chat:
on_generate(
prompt="valid",
mode="junk",
n_candidates=2,
temperature=0.4,
image=None,
palette="",
model="x",
)
assert mock_chat.call_count == 0
# ---------------------------------------------------------------------------
# Положительный smoke: на корректном входе on_generate НЕ возвращает пустоту
# (хотя в этом юнит-тесте chat() замокан → идём по ветке ошибки сборки
# промпта, а не успеха; это нормально, главное — нет TypeError).
# ---------------------------------------------------------------------------
def test_on_generate_with_valid_input_does_not_typeerror():
"""Даже когда chat() падает (замокан), pre-check не должен давать TypeError."""
from app import on_generate
with patch("app.chat") as mock_chat:
mock_chat.return_value = type("R", (), {
"raw_texts": ['<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="10" fill="red"/></svg>'],
"elapsed_s": 0.1,
"model": "test",
"usage": None,
"finish_reasons": ["stop"],
})()
try:
result = on_generate(
prompt="filled magnifying glass",
mode="icon",
n_candidates=1,
temperature=0.4,
image=None,
palette="",
model="test",
)
except TypeError as exc:
pytest.fail(f"on_generate упал с TypeError на валидном входе: {exc}")
# На валидном входе возвращается кортеж из 7 элементов.
assert isinstance(result, tuple)
assert len(result) == 7
# ---------------------------------------------------------------------------
# Smoke-тест: импорт app и build_ui() возвращает gr.Blocks.
# Не лезем в сеть, не запускаем UI.
# ---------------------------------------------------------------------------
def test_app_module_imports():
"""app.py импортируется без ошибок (все зависимости в порядке)."""
import app # noqa: F401
assert hasattr(app, "on_generate")
assert hasattr(app, "on_history_select")
assert hasattr(app, "build_ui")
assert hasattr(app, "main")
def test_build_ui_returns_gradio_blocks():
"""build_ui() возвращает gr.Blocks (smoke-тест сборки UI)."""
from app import build_ui
demo = build_ui()
# Проверяем, что это действительно gr.Blocks, а не None или что-то другое.
import gradio as gr
assert isinstance(demo, gr.Blocks), f"ожидался gr.Blocks, получено {type(demo).__name__}"
def test_on_mode_change_returns_icon_default_n():
from app import on_mode_change
update = on_mode_change("icon")
# gr.update — это dict-like объект, у него есть .value
assert update["value"] == 4
def test_on_mode_change_returns_illustration_default_n():
from app import on_mode_change
update = on_mode_change("illustration")
assert update["value"] == 2
+404
View File
@@ -0,0 +1,404 @@
"""Юнит-тесты для history.py.
Покрывают:
- add(): вставка записи возвращает id, JSON-поля сериализуются
- list_recent(): сортировка DESC, лимит, новые сверху
- get(): по id, None для несуществующего
- persist: создать/закрыть/создать — данные сохранились на диске
- count(): корректный счёт
- Спецсимволы в prompt: юникод, кавычки, переносы строк, эмодзи
- JSON-поля: list[str] с не-ASCII корректно декодируется обратно
- WAL mode: PRAGMA journal_mode после открытия соединения
- list_recent(limit=...) ограничивает выборку
Каждый тест использует tmp_path (pytest fixture) для изолированной БД.
Запуск: `python -m pytest tests/test_history.py -v`
"""
from __future__ import annotations
import sqlite3
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
import pytest # noqa: E402
from history import History, Record # noqa: E402
# ---------------------------------------------------------------------------
# Базовые CRUD
# ---------------------------------------------------------------------------
def _make_record(**overrides) -> Record:
"""Минимальный валидный Record + overrides."""
base = dict(
prompt="a fox",
mode="icon",
model="test-model",
n_requested=2,
n_returned=2,
temperature=0.4,
status="ok",
error_reason=None,
raw_outputs=["raw1", "raw2"],
validated_outputs=["<svg/>", "<svg/>"],
previews=["/p/1.png", "/p/2.png"],
best_index=0,
)
base.update(overrides)
return Record(**base)
def test_add_returns_increasing_ids(tmp_path: Path):
db = tmp_path / "h.sqlite"
with History(db) as h:
id1 = h.add(_make_record(prompt="a"))
id2 = h.add(_make_record(prompt="b"))
id3 = h.add(_make_record(prompt="c"))
assert id1 == 1
assert id2 == 2
assert id3 == 3
assert id1 < id2 < id3
def test_get_returns_record_by_id(tmp_path: Path):
db = tmp_path / "h.sqlite"
with History(db) as h:
rid = h.add(_make_record(prompt="hello"))
with History(db) as h:
rec = h.get(rid)
assert rec is not None
assert rec["id"] == rid
assert rec["prompt"] == "hello"
assert rec["mode"] == "icon"
assert rec["status"] == "ok"
assert rec["n_requested"] == 2
assert rec["n_returned"] == 2
assert rec["temperature"] == 0.4
assert rec["raw_outputs"] == ["raw1", "raw2"]
assert rec["validated_outputs"] == ["<svg/>", "<svg/>"]
assert rec["previews"] == ["/p/1.png", "/p/2.png"]
assert rec["best_index"] == 0
def test_get_returns_none_for_missing_id(tmp_path: Path):
db = tmp_path / "h.sqlite"
with History(db) as h:
assert h.get(99999) is None
def test_count_returns_total(tmp_path: Path):
db = tmp_path / "h.sqlite"
with History(db) as h:
assert h.count() == 0
h.add(_make_record(prompt="a"))
h.add(_make_record(prompt="b"))
h.add(_make_record(prompt="c"))
assert h.count() == 3
# ---------------------------------------------------------------------------
# list_recent
# ---------------------------------------------------------------------------
def test_list_recent_returns_newest_first(tmp_path: Path):
"""Новые записи — сверху, лимит работает."""
db = tmp_path / "h.sqlite"
with History(db) as h:
for i in range(5):
h.add(_make_record(prompt=f"prompt-{i}"))
time.sleep(0.005) # гарантируем уникальный created_at
with History(db) as h:
rows = h.list_recent(limit=3)
assert len(rows) == 3
# Новейшие сверху → "prompt-4", "prompt-3", "prompt-2"
assert [r["prompt"] for r in rows] == ["prompt-4", "prompt-3", "prompt-2"]
def test_list_recent_default_limit_is_50(tmp_path: Path):
db = tmp_path / "h.sqlite"
with History(db) as h:
for i in range(60):
h.add(_make_record(prompt=f"p-{i}"))
with History(db) as h:
rows = h.list_recent()
assert len(rows) == 50
def test_list_recent_handles_explicit_large_limit(tmp_path: Path):
db = tmp_path / "h.sqlite"
with History(db) as h:
for i in range(3):
h.add(_make_record(prompt=f"p-{i}"))
with History(db) as h:
rows = h.list_recent(limit=1000)
assert len(rows) == 3
# ---------------------------------------------------------------------------
# Persist между открытиями
# ---------------------------------------------------------------------------
def test_data_persists_across_open_close(tmp_path: Path):
"""Создать → закрыть → снова открыть → данные на месте."""
db = tmp_path / "h.sqlite"
with History(db) as h:
rid1 = h.add(_make_record(prompt="first"))
rid2 = h.add(_make_record(prompt="second"))
# БД закрыта; открываем заново
assert db.exists()
assert db.stat().st_size > 0
with History(db) as h:
assert h.count() == 2
assert h.get(rid1)["prompt"] == "first"
assert h.get(rid2)["prompt"] == "second"
def test_data_survives_full_reopen_with_wal_files(tmp_path: Path):
"""WAL-файлы могут остаться после закрытия — повторное открытие должно работать."""
db = tmp_path / "h.sqlite"
with History(db) as h:
h.add(_make_record(prompt="x"))
# Проверяем, что нет orphan'ов: всё читается.
with History(db) as h:
rec = h.get(1)
assert rec["prompt"] == "x"
# ---------------------------------------------------------------------------
# Спецсимволы в prompt
# ---------------------------------------------------------------------------
def test_prompt_with_unicode_cyrillic(tmp_path: Path):
db = tmp_path / "h.sqlite"
cyrillic = "лиса в осеннем лесу, палитра оранжевая"
with History(db) as h:
rid = h.add(_make_record(prompt=cyrillic))
with History(db) as h:
rec = h.get(rid)
assert rec["prompt"] == cyrillic
def test_prompt_with_quotes_and_doublequotes(tmp_path: Path):
db = tmp_path / "h.sqlite"
p = 'icon with "double" and \'single\' quotes'
with History(db) as h:
rid = h.add(_make_record(prompt=p))
with History(db) as h:
rec = h.get(rid)
assert rec["prompt"] == p
def test_prompt_with_newlines_and_tabs(tmp_path: Path):
db = tmp_path / "h.sqlite"
p = "line 1\nline 2\n\tindented\n\nblank-line-above"
with History(db) as h:
rid = h.add(_make_record(prompt=p))
with History(db) as h:
rec = h.get(rid)
assert rec["prompt"] == p
def test_prompt_with_emoji(tmp_path: Path):
db = tmp_path / "h.sqlite"
p = "fox 🦊 in forest 🌲🌲"
with History(db) as h:
rid = h.add(_make_record(prompt=p))
with History(db) as h:
rec = h.get(rid)
assert rec["prompt"] == p
def test_prompt_with_backslashes_and_sql_injection_attempt(tmp_path: Path):
"""Промпт с SQL-инъекцией в виде текста — должен храниться как plain text."""
db = tmp_path / "h.sqlite"
evil = "'; DROP TABLE generations; --"
with History(db) as h:
rid = h.add(_make_record(prompt=evil))
with History(db) as h2:
rec = h2.get(rid)
assert rec["prompt"] == evil
# Таблица жива
assert h2.count() == 1
def test_prompt_with_very_long_string(tmp_path: Path):
"""Длинный промпт (10K символов) — должен сохраниться без потерь."""
db = tmp_path / "h.sqlite"
p = "x" * 10000
with History(db) as h:
rid = h.add(_make_record(prompt=p))
with History(db) as h:
rec = h.get(rid)
assert rec["prompt"] == p
assert len(rec["prompt"]) == 10000
# ---------------------------------------------------------------------------
# JSON-поля с не-ASCII
# ---------------------------------------------------------------------------
def test_raw_outputs_with_unicode_preserved(tmp_path: Path):
"""list[str] в raw_outputs хранится как JSON, ensure_ascii=False."""
db = tmp_path / "h.sqlite"
raw = [
'<svg viewBox="0 0 64 64"><text>лиса 🦊</text></svg>',
'<svg viewBox="0 0 64 64"><text>simple</text></svg>',
]
with History(db) as h:
rid = h.add(_make_record(raw_outputs=raw))
with History(db) as h:
rec = h.get(rid)
assert rec["raw_outputs"] == raw
def test_validated_outputs_with_unicode_in_svg(tmp_path: Path):
db = tmp_path / "h.sqlite"
svg = '<svg viewBox="0 0 512 512"><text>Привет</text></svg>'
with History(db) as h:
rid = h.add(_make_record(validated_outputs=[svg]))
with History(db) as h:
rec = h.get(rid)
assert rec["validated_outputs"] == [svg]
def test_previews_with_unicode_paths(tmp_path: Path):
db = tmp_path / "h.sqlite"
paths = [
"C:\\Users\\пользователь\\превью\\1_0.png",
"D:\\AI\\Projects\\лиса\\2_1.png",
]
with History(db) as h:
rid = h.add(_make_record(previews=paths))
with History(db) as h:
rec = h.get(rid)
assert rec["previews"] == paths
# ---------------------------------------------------------------------------
# Контекст-менеджер: WAL и поведение при ошибках
# ---------------------------------------------------------------------------
def test_wal_mode_is_set(tmp_path: Path):
"""После открытия History journal_mode должен быть WAL."""
db = tmp_path / "h.sqlite"
with History(db) as h:
# Спросим напрямую через raw-conn.
cur = h.conn.execute("PRAGMA journal_mode")
mode = cur.fetchone()[0]
assert mode.lower() == "wal"
def test_context_manager_closes_connection(tmp_path: Path):
db = tmp_path / "h.sqlite"
h = History(db)
with h as hist:
hist.add(_make_record(prompt="x"))
# После выхода conn=None
assert h._conn is None
def test_context_manager_commit_path_runs_in_exit(tmp_path: Path):
"""Без exception — __exit__ коммитит оставшиеся незакоммиченные изменения.
Замечание: `add()` сам вызывает commit() внутри, поэтому запись из add()
сохранится в любом случае. Этот тест проверяет, что exit/close не
портит уже закоммиченное и не падает на нормальном пути.
"""
db = tmp_path / "h.sqlite"
h = History(db)
with h as hist:
hist.add(_make_record(prompt="x"))
# Контекст закрылся без exception
assert h._conn is None
with History(db) as hist2:
assert hist2.count() == 1
def test_conn_outside_context_raises(tmp_path: Path):
db = tmp_path / "h.sqlite"
h = History(db)
with pytest.raises(RuntimeError, match="контекст-менеджера"):
_ = h.conn
# ---------------------------------------------------------------------------
# Нишевые: пустые list'ы, edge-cases значений
# ---------------------------------------------------------------------------
def test_record_with_empty_lists(tmp_path: Path):
"""Record с пустыми raw/validated/previews — должен сохраниться."""
db = tmp_path / "h.sqlite"
with History(db) as h:
rid = h.add(_make_record(
status="failed",
error_reason="lm studio down",
raw_outputs=[],
validated_outputs=[],
previews=[],
best_index=None,
))
with History(db) as h:
rec = h.get(rid)
assert rec["raw_outputs"] == []
assert rec["validated_outputs"] == []
assert rec["previews"] == []
assert rec["best_index"] is None
assert rec["error_reason"] == "lm studio down"
def test_default_created_at_set_by_add(tmp_path: Path):
"""Если created_at=None в Record — add() заполняет time.time()."""
db = tmp_path / "h.sqlite"
before = time.time()
with History(db) as h:
rec_in = _make_record(prompt="x")
assert rec_in.created_at is None
rid = h.add(rec_in)
after = time.time()
with History(db) as h:
rec_out = h.get(rid)
assert before - 1 <= rec_out["created_at"] <= after + 1
def test_explicit_created_at_preserved(tmp_path: Path):
db = tmp_path / "h.sqlite"
fixed_ts = 1700000000.0
with History(db) as h:
rid = h.add(_make_record(prompt="x", created_at=fixed_ts))
with History(db) as h:
rec = h.get(rid)
assert rec["created_at"] == fixed_ts
# ---------------------------------------------------------------------------
# Несколько записей: list_recent с лимитом < N
# ---------------------------------------------------------------------------
def test_list_recent_limit_smaller_than_total(tmp_path: Path):
db = tmp_path / "h.sqlite"
with History(db) as h:
for i in range(10):
h.add(_make_record(prompt=f"p-{i}"))
time.sleep(0.003)
with History(db) as h:
rows = h.list_recent(limit=2)
assert len(rows) == 2
# Самые свежие
assert rows[0]["prompt"] == "p-9"
assert rows[1]["prompt"] == "p-8"
+651
View File
@@ -0,0 +1,651 @@
"""Юнит-тесты для lm_client.py.
Покрывают:
- успешный ответ: парсинг N=4 choice'ов, текст, finish_reasons, model, usage
- таймаут: httpx.TimeoutException → LMStudioUnavailable с упоминанием timeout
- 5xx от LM Studio: status_code 503 → LMStudioUnavailable
- 4xx: status_code 401 / 404 → LMStudioUnavailable (по контракту: ошибка клиента
или сервера — нам всё равно)
- пустой / битый JSON
- ответ без choices → LMStudioUnavailable
- ответ с tool_use (content=None, tool_calls есть) → raw_texts содержит пустую строку
- ответ с несколькими ```svg блоками: parse_svg идёт в валидаторе, тут проверим,
что chat() возвращает всю строку модели как есть в raw_text
- generate_svg() высокоуровневая обёртка: проверка сообщений и prompt guard
- encode_pil_to_data_url(): кодирует PIL.Image в data: URL
- validate_image(): PIL.Image проходит / отклоняется по размеру / формату
httpx мокается через unittest.mock — клиент создаётся внутри `chat()`,
поэтому патчим `httpx.Client`.
Запуск: `python -m pytest tests/test_lm_client.py -v`
"""
from __future__ import annotations
import base64
import io
import sys
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
import pytest # noqa: E402
import httpx # noqa: E402
from lm_client import ( # noqa: E402
DEFAULT_BASE_URL,
LMStudioUnavailable,
chat,
encode_pil_to_data_url,
generate_svg,
validate_image,
)
# ---------------------------------------------------------------------------
# Хелперы для построения mock-ответа httpx
# ---------------------------------------------------------------------------
def _make_response(
*,
status_code: int = 200,
json_payload: dict | None = None,
text: str = "",
) -> MagicMock:
"""Создаёт mock httpx.Response с заданным status_code и JSON-телом."""
resp = MagicMock(spec=httpx.Response)
resp.status_code = status_code
if json_payload is not None:
resp.json.return_value = json_payload
else:
resp.json.side_effect = ValueError("not json")
resp.text = text
return resp
def _ok_payload(texts: list[str], *, model: str = "test-model") -> dict:
"""Стандартный OpenAI-style ответ с N choice'ами."""
return {
"id": "chatcmpl-test",
"object": "chat.completion",
"model": model,
"choices": [
{
"index": i,
"message": {"role": "assistant", "content": t},
"finish_reason": "stop",
}
for i, t in enumerate(texts)
],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
}
# ---------------------------------------------------------------------------
# Успешный путь
# ---------------------------------------------------------------------------
def test_chat_success_returns_n_texts_and_metadata():
"""N=4: возвращаем 4 текста, finish_reasons, model, usage."""
payload = _ok_payload(["a", "b", "c", "d"], model="my-model")
resp = _make_response(json_payload=payload)
with patch("lm_client.httpx.Client") as MockClient:
mock_inst = MagicMock()
mock_inst.post.return_value = resp
MockClient.return_value.__enter__.return_value = mock_inst
result = chat(
messages=[{"role": "user", "content": "hi"}],
model="my-model",
n=4,
temperature=0.5,
base_url="http://mock:1234/v1",
)
assert isinstance(result.raw_texts, list)
assert len(result.raw_texts) == 4
assert result.raw_texts == ["a", "b", "c", "d"]
assert result.finish_reasons == ["stop"] * 4
assert result.model == "my-model"
assert result.usage == {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
assert result.elapsed_s >= 0.0
# Проверяем, что URL и заголовки формируются правильно.
call = mock_inst.post.call_args
url = call.args[0] if call.args else call.kwargs["url"]
assert url == "http://mock:1234/v1/chat/completions"
headers = call.kwargs["headers"]
assert headers["Content-Type"] == "application/json"
assert headers["Authorization"] == "Bearer lm-studio"
body = call.kwargs["json"]
assert body["model"] == "my-model"
assert body["n"] == 4
assert body["temperature"] == 0.5
assert body["stream"] is False
def test_chat_success_single_candidate_default():
"""Дефолт n=1 — возвращаем ровно один текст."""
resp = _make_response(json_payload=_ok_payload(["only one"]))
with patch("lm_client.httpx.Client") as MockClient:
mock_inst = MagicMock()
mock_inst.post.return_value = resp
MockClient.return_value.__enter__.return_value = mock_inst
result = chat(
messages=[{"role": "user", "content": "x"}],
base_url="http://m:1/v1",
)
assert len(result.raw_texts) == 1
assert result.raw_texts[0] == "only one"
def test_chat_server_returns_fewer_choices_pads_with_empty():
"""Сервер вернул 1 из 4 — добиваем пустыми строками и 'missing'."""
payload = _ok_payload(["one"]) # n=4, но пришёл только 1
resp = _make_response(json_payload=payload)
with patch("lm_client.httpx.Client") as MockClient:
mock_inst = MagicMock()
mock_inst.post.return_value = resp
MockClient.return_value.__enter__.return_value = mock_inst
result = chat(
messages=[{"role": "user", "content": "x"}],
n=4,
base_url="http://m:1/v1",
)
assert len(result.raw_texts) == 4
assert result.raw_texts[0] == "one"
assert result.raw_texts[1:] == ["", "", ""]
assert result.finish_reasons == ["stop", "missing", "missing", "missing"]
# ---------------------------------------------------------------------------
# Ошибочные пути
# ---------------------------------------------------------------------------
def test_chat_timeout_raises_lmstudio_unavailable():
"""httpx.TimeoutException → LMStudioUnavailable, в тексте есть 'timeout'."""
with patch("lm_client.httpx.Client") as MockClient:
mock_inst = MagicMock()
mock_inst.post.side_effect = httpx.TimeoutException("timed out")
MockClient.return_value.__enter__.return_value = mock_inst
with pytest.raises(LMStudioUnavailable) as excinfo:
chat(
messages=[{"role": "user", "content": "x"}],
n=1,
base_url="http://m:1/v1",
timeout_s=5.0,
)
msg = str(excinfo.value).lower()
assert "timeout" in msg
# Код форматирует "5.0с" / "5с" — проверим, что число таймаута попало в сообщение.
assert "5" in msg
assert "с" in str(excinfo.value) # "превысил 5с"
def test_chat_5xx_raises_lmstudio_unavailable():
resp = _make_response(status_code=503, text="service unavailable")
with patch("lm_client.httpx.Client") as MockClient:
mock_inst = MagicMock()
mock_inst.post.return_value = resp
MockClient.return_value.__enter__.return_value = mock_inst
with pytest.raises(LMStudioUnavailable) as excinfo:
chat(
messages=[{"role": "user", "content": "x"}],
base_url="http://m:1/v1",
)
msg = str(excinfo.value)
assert "503" in msg
assert "service unavailable"[:30] in msg or "service unav" in msg
def test_chat_500_raises_lmstudio_unavailable():
resp = _make_response(status_code=500, text="internal error")
with patch("lm_client.httpx.Client") as MockClient:
mock_inst = MagicMock()
mock_inst.post.return_value = resp
MockClient.return_value.__enter__.return_value = mock_inst
with pytest.raises(LMStudioUnavailable) as excinfo:
chat(
messages=[{"role": "user", "content": "x"}],
base_url="http://m:1/v1",
)
assert "500" in str(excinfo.value)
def test_chat_4xx_raises_lmstudio_unavailable():
"""4xx — наша ошибка, но клиент всё равно бросает LMStudioUnavailable."""
resp = _make_response(status_code=401, text="unauthorized")
with patch("lm_client.httpx.Client") as MockClient:
mock_inst = MagicMock()
mock_inst.post.return_value = resp
MockClient.return_value.__enter__.return_value = mock_inst
with pytest.raises(LMStudioUnavailable) as excinfo:
chat(
messages=[{"role": "user", "content": "x"}],
base_url="http://m:1/v1",
)
assert "401" in str(excinfo.value)
def test_chat_network_error_raises_lmstudio_unavailable():
"""Любой httpx.HTTPError (ConnectionError и пр.) → LMStudioUnavailable."""
with patch("lm_client.httpx.Client") as MockClient:
mock_inst = MagicMock()
mock_inst.post.side_effect = httpx.ConnectError("connection refused")
MockClient.return_value.__enter__.return_value = mock_inst
with pytest.raises(LMStudioUnavailable) as excinfo:
chat(
messages=[{"role": "user", "content": "x"}],
base_url="http://m:1/v1",
)
msg = str(excinfo.value).lower()
assert "недоступен" in msg or "unavailable" in msg
assert "connection refused" in msg
def test_chat_invalid_json_raises_lmstudio_unavailable():
"""200 OK, но тело — не JSON."""
resp = _make_response(status_code=200, text="<html>not json</html>")
with patch("lm_client.httpx.Client") as MockClient:
mock_inst = MagicMock()
mock_inst.post.return_value = resp
MockClient.return_value.__enter__.return_value = mock_inst
with pytest.raises(LMStudioUnavailable) as excinfo:
chat(
messages=[{"role": "user", "content": "x"}],
base_url="http://m:1/v1",
)
assert "не-JSON" in str(excinfo.value) or "json" in str(excinfo.value).lower()
def test_chat_empty_choices_raises_lmstudio_unavailable():
"""200 OK, choices пустой → ошибка."""
payload = {"choices": []}
resp = _make_response(json_payload=payload)
with patch("lm_client.httpx.Client") as MockClient:
mock_inst = MagicMock()
mock_inst.post.return_value = resp
MockClient.return_value.__enter__.return_value = mock_inst
with pytest.raises(LMStudioUnavailable) as excinfo:
chat(
messages=[{"role": "user", "content": "x"}],
base_url="http://m:1/v1",
)
assert "choice" in str(excinfo.value).lower()
# ---------------------------------------------------------------------------
# Специфические кейсы: tool_use, контент None, несколько svg-блоков
# ---------------------------------------------------------------------------
def test_chat_tool_use_response_yields_empty_string_candidate():
"""Модель вернула tool_calls без content → raw_texts содержит '' для этого кандидата."""
payload = {
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "1",
"type": "function",
"function": {"name": "x", "arguments": "{}"},
}
],
},
"finish_reason": "tool_calls",
},
{
"index": 1,
"message": {
"role": "assistant",
"content": "<svg viewBox='0 0 64 64'/>",
},
"finish_reason": "stop",
},
]
}
resp = _make_response(json_payload=payload)
with patch("lm_client.httpx.Client") as MockClient:
mock_inst = MagicMock()
mock_inst.post.return_value = resp
MockClient.return_value.__enter__.return_value = mock_inst
result = chat(
messages=[{"role": "user", "content": "x"}],
n=2,
base_url="http://m:1/v1",
)
# 1-й кандидат — пусто, 2-й — реальный svg.
assert result.raw_texts[0] == ""
assert result.raw_texts[1] == "<svg viewBox='0 0 64 64'/>"
assert result.finish_reasons[0] == "tool_calls"
def test_chat_response_with_multiple_svg_blocks_kept_as_raw_text():
"""Модель вернула ответ с несколькими ```svg блоками внутри — chat() должен
сохранить текст as-is. Парсинг — забота validator.extract_svg()."""
multi_svg_text = (
"Here are some variants:\n"
"```svg\n<svg viewBox='0 0 64 64'><circle cx='10' cy='10' r='5'/></svg>\n```\n"
"And another:\n"
"```svg\n<svg viewBox='0 0 64 64'><rect width='10' height='10'/></svg>\n```"
)
resp = _make_response(json_payload=_ok_payload([multi_svg_text] * 2))
with patch("lm_client.httpx.Client") as MockClient:
mock_inst = MagicMock()
mock_inst.post.return_value = resp
MockClient.return_value.__enter__.return_value = mock_inst
result = chat(
messages=[{"role": "user", "content": "x"}],
n=2,
base_url="http://m:1/v1",
)
# chat() НЕ парсит SVG — оба кандидата идентичны.
assert len(result.raw_texts) == 2
for raw in result.raw_texts:
assert raw.count("<svg") == 2
assert "Here are some variants" in raw
def test_chat_content_as_list_of_parts_joined():
"""content может быть list[dict] (мультимодальный ответ) — склеиваем текстовые части."""
payload = {
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": [
{"type": "text", "text": "Hello "},
{"type": "text", "text": "world"},
],
},
"finish_reason": "stop",
}
]
}
resp = _make_response(json_payload=payload)
with patch("lm_client.httpx.Client") as MockClient:
mock_inst = MagicMock()
mock_inst.post.return_value = resp
MockClient.return_value.__enter__.return_value = mock_inst
result = chat(
messages=[{"role": "user", "content": "x"}],
n=1,
base_url="http://m:1/v1",
)
assert result.raw_texts == ["Hello world"]
# ---------------------------------------------------------------------------
# generate_svg() — высокоуровневая обёртка
# ---------------------------------------------------------------------------
def test_generate_svg_rejects_empty_prompt():
with pytest.raises(ValueError, match="1 до 1000"):
generate_svg("", model="x")
def test_generate_svg_rejects_too_long_prompt():
with pytest.raises(ValueError, match="1 до 1000"):
generate_svg("x" * 1001, model="x")
def test_generate_svg_with_image_sends_multimodal_payload():
"""С image_b64 идёт мультимодальный user-message: text + image_url."""
svg_text = "<svg viewBox='0 0 64 64'/>"
resp = _make_response(json_payload=_ok_payload([svg_text]))
with patch("lm_client.chat") as mock_chat:
mock_chat.return_value = type("R", (), {
"raw_texts": [svg_text],
"elapsed_s": 0.05,
"model": "x",
"usage": None,
"finish_reasons": ["stop"],
})()
results = generate_svg(
"a fox",
image_b64="data:image/png;base64,AAA",
mode="icon",
n=1,
temperature=0.4,
model="x",
system_prompt="<system>icon</system>",
)
assert len(results) == 1
assert results[0]["svg_text"] == svg_text
assert results[0]["raw"] == svg_text
assert results[0]["finish_reason"] == "stop"
# Что ушло в chat()
call = mock_chat.call_args
msgs = call.kwargs["messages"]
assert msgs[0]["role"] == "system"
assert msgs[0]["content"] == "<system>icon</system>"
assert msgs[1]["role"] == "user"
user_content = msgs[1]["content"]
assert isinstance(user_content, list)
assert user_content[0]["type"] == "text"
assert user_content[0]["text"] == "a fox"
assert user_content[1]["type"] == "image_url"
assert user_content[1]["image_url"]["url"] == "data:image/png;base64,AAA"
assert call.kwargs["model"] == "x"
assert call.kwargs["n"] == 1
assert call.kwargs["temperature"] == 0.4
def test_generate_svg_text_only_sends_string_content():
"""Без image_b64 user.content — просто строка."""
with patch("lm_client.chat") as mock_chat:
mock_chat.return_value = type("R", (), {
"raw_texts": ["<svg/>"],
"elapsed_s": 0.0,
"model": "x",
"usage": None,
"finish_reasons": ["stop"],
})()
generate_svg("hello", n=1, model="x")
msgs = mock_chat.call_args.kwargs["messages"]
user_msg = msgs[0] # без system_prompt единственное user-сообщение
assert user_msg["role"] == "user"
assert user_msg["content"] == "hello"
def test_generate_svg_propagates_lmstudio_error():
"""Если chat() упал, generate_svg пробрасывает LMStudioUnavailable."""
with patch("lm_client.chat") as mock_chat:
mock_chat.side_effect = LMStudioUnavailable("upstream timeout")
with pytest.raises(LMStudioUnavailable, match="upstream timeout"):
generate_svg("hello", n=1, model="x")
def test_chat_rejects_n_less_than_one():
with pytest.raises(ValueError, match="n должно быть"):
chat(messages=[{"role": "user", "content": "x"}], n=0, base_url="http://m:1/v1")
# ---------------------------------------------------------------------------
# encode_pil_to_data_url
# ---------------------------------------------------------------------------
def test_encode_pil_to_data_url_produces_data_url_with_png_mime():
from PIL import Image
img = Image.new("RGB", (10, 10), color=(255, 0, 0))
url = encode_pil_to_data_url(img, mime="image/png")
assert url.startswith("data:image/png;base64,")
# base64 должен корректно декодироваться обратно
payload = url.split(",", 1)[1]
decoded = base64.b64decode(payload)
assert decoded.startswith(b"\x89PNG")
# и это валидный PNG
Image.open(io.BytesIO(decoded))
def test_encode_pil_to_data_url_jpeg_mime_converts_rgba():
"""При mime=jpeg и RGBA → конвертируем в RGB (иначе JPEG не съест)."""
from PIL import Image
img = Image.new("RGBA", (8, 8), color=(0, 255, 0, 128))
url = encode_pil_to_data_url(img, mime="image/jpeg")
assert url.startswith("data:image/jpeg;base64,")
payload = url.split(",", 1)[1]
decoded = base64.b64decode(payload)
# JPEG стартует с FFD8
assert decoded.startswith(b"\xff\xd8")
def test_encode_pil_to_data_url_png_mime_adds_alpha_if_other_mode():
"""PNG с mime=png и mode=L (grayscale) → конвертируем в RGBA."""
from PIL import Image
img = Image.new("L", (4, 4), color=128)
url = encode_pil_to_data_url(img, mime="image/png")
assert url.startswith("data:image/png;base64,")
# Просто проверим, что получили валидный PNG
payload = url.split(",", 1)[1]
decoded = base64.b64decode(payload)
assert decoded.startswith(b"\x89PNG")
# ---------------------------------------------------------------------------
# validate_image
# ---------------------------------------------------------------------------
def test_validate_image_passes_normal_png():
from PIL import Image
img = Image.new("RGB", (100, 100), color=(0, 0, 0))
validate_image(img) # не бросает
def test_validate_image_rejects_oversized_dimensions():
from PIL import Image
img = Image.new("RGB", (5000, 100), color=(0, 0, 0))
with pytest.raises(ValueError, match="слишком большое"):
validate_image(img, max_side=4096)
def test_validate_image_rejects_too_many_bytes():
"""Картинка проходит по стороне, но approx-байты > max_bytes.
Берём 4000x4000 (под max_side=4096) и max_bytes=1MB:
4000*4000*4 + 64KB = 64MB+ >> 1MB → должно сработать байтовое ограничение.
"""
from PIL import Image
img = Image.new("RGB", (4000, 4000), color=(0, 0, 0))
with pytest.raises(ValueError, match="МБ"):
validate_image(img, max_bytes=1 * 1024 * 1024)
def test_validate_image_rejects_unsupported_format():
from PIL import Image
img = Image.new("RGB", (10, 10), color=(0, 0, 0))
img.format = "BMP" # притворяемся BMP
with pytest.raises(ValueError, match="неподдерживаемый формат"):
validate_image(img)
def test_validate_image_rejects_none():
with pytest.raises(ValueError, match="не передано"):
validate_image(None) # type: ignore[arg-type]
def test_validate_image_rejects_non_pil():
with pytest.raises(ValueError, match="ожидался PIL.Image"):
validate_image("not-an-image") # type: ignore[arg-type]
# ---------------------------------------------------------------------------
# Минорный: payload включает n/temperature/max_tokens/stream=False
# ---------------------------------------------------------------------------
def test_chat_payload_contains_required_fields():
resp = _make_response(json_payload=_ok_payload(["x"]))
with patch("lm_client.httpx.Client") as MockClient:
mock_inst = MagicMock()
mock_inst.post.return_value = resp
MockClient.return_value.__enter__.return_value = mock_inst
chat(
messages=[{"role": "user", "content": "x"}],
n=2,
temperature=0.7,
max_tokens=2048,
base_url="http://m:1/v1",
)
body = mock_inst.post.call_args.kwargs["json"]
assert body["n"] == 2
assert body["temperature"] == 0.7
assert body["max_tokens"] == 2048
assert body["stream"] is False
assert body["model"] # non-empty
def test_chat_uses_default_base_url_when_env_unset(monkeypatch):
"""Если base_url=None и env не задан, идём на DEFAULT_BASE_URL."""
monkeypatch.delenv("LM_STUDIO_BASE_URL", raising=False)
resp = _make_response(json_payload=_ok_payload(["x"]))
with patch("lm_client.httpx.Client") as MockClient:
mock_inst = MagicMock()
mock_inst.post.return_value = resp
MockClient.return_value.__enter__.return_value = mock_inst
chat(messages=[{"role": "user", "content": "x"}], n=1)
url = mock_inst.post.call_args.args[0]
assert url == f"{DEFAULT_BASE_URL}/chat/completions"
+345
View File
@@ -0,0 +1,345 @@
"""Юнит-тесты для prompts.py.
Покрывают:
- load_system_prompt("icon") → строка из system_icon.txt
- load_system_prompt("illustration") → строка из system_illustration.txt
- load_system_prompt("junk") → ValueError
- load_few_shot() → пары {role, content}, валидные, идут в нужном порядке
- build_messages(icon) → первое сообщение system (правильный файл),
затем few-shot пары, затем финальный user.
- build_messages(illustration) → system — из system_illustration.txt.
- build_messages с image_b64 → последний user — list[dict] с text + image_url,
текст содержит "Recreate the visual content...".
- build_messages без image_b64 → последний user — простая строка.
- build_messages с palette → "Palette: ..." добавлено в текст.
- build_messages с пустым промптом → ValueError.
- build_messages с неизвестным mode → ValueError.
- Контракт: system промпт подставляется ИЗ ПРАВИЛЬНОГО ФАЙЛА (содержимое
различается для icon и illustration).
- Few-shot включён: в messages их пары.
Запуск: `python -m pytest tests/test_prompts.py -v`
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
import pytest # noqa: E402
from prompts import ( # noqa: E402
build_messages,
load_few_shot,
load_system_prompt,
)
# ---------------------------------------------------------------------------
# load_system_prompt
# ---------------------------------------------------------------------------
def test_load_system_prompt_icon_returns_icon_text():
"""system_icon.txt: первая строка упоминает 'icon designer'."""
text = load_system_prompt("icon")
assert isinstance(text, str)
assert len(text) > 50
assert "icon" in text.lower()
# Уникальное для icon: "OmniSVG-Icon" (различается от "OmniSVG-Illustration")
assert "OmniSVG-Icon" in text
def test_load_system_prompt_illustration_returns_illustration_text():
"""system_illustration.txt: первая строка упоминает illustrator."""
text = load_system_prompt("illustration")
assert isinstance(text, str)
assert len(text) > 50
assert "illustration" in text.lower()
assert "OmniSVG-Illustration" in text
def test_load_system_prompt_distinct_for_each_mode():
"""Содержимое system для icon и illustration — разные файлы."""
icon_txt = load_system_prompt("icon")
ill_txt = load_system_prompt("illustration")
assert icon_txt != ill_txt
def test_load_system_prompt_unknown_mode_raises():
with pytest.raises(ValueError, match="неизвестный mode"):
load_system_prompt("portrait")
with pytest.raises(ValueError, match="неизвестный mode"):
load_system_prompt("")
def test_load_system_prompt_strips_trailing_whitespace():
"""Файл может заканчиваться на перевод строки — strip() его убирает."""
text = load_system_prompt("icon")
assert not text.endswith("\n")
assert not text.endswith(" ")
# ---------------------------------------------------------------------------
# load_few_shot
# ---------------------------------------------------------------------------
def test_load_few_shot_returns_list_of_message_dicts():
msgs = load_few_shot()
assert isinstance(msgs, list)
assert len(msgs) > 0
for m in msgs:
assert isinstance(m, dict)
assert m["role"] in ("user", "assistant")
assert isinstance(m["content"], str)
assert len(m["content"]) > 0
def test_load_few_shot_alternates_user_assistant():
"""USER → ASSISTANT → USER → ASSISTANT … (по дизайну)."""
msgs = load_few_shot()
for i, m in enumerate(msgs):
expected = "user" if i % 2 == 0 else "assistant"
assert m["role"] == expected, (
f"индекс {i}: ожидался role={expected}, получен {m['role']}"
)
def test_load_few_shot_includes_refusal_example():
"""Последний assistant — это refusal без <svg> (по дизайну)."""
msgs = load_few_shot()
# Ищем любой assistant-блок, который не содержит <svg>
refusals = [m for m in msgs if m["role"] == "assistant" and "<svg" not in m["content"]]
assert len(refusals) >= 1, "few-shot должен включать хотя бы один refusal"
def test_load_few_shot_includes_svg_examples():
"""Среди assistant'ов есть и реальные <svg>...</svg>."""
msgs = load_few_shot()
svg_assistants = [m for m in msgs if m["role"] == "assistant" and "<svg" in m["content"]]
assert len(svg_assistants) >= 2 # минимум 2 иконки (по дизайну)
def test_load_few_shot_is_stable_across_calls():
"""load_few_shot() детерминирован — не возвращает разное при повторе."""
first = load_few_shot()
second = load_few_shot()
assert first == second
# ---------------------------------------------------------------------------
# build_messages — icon
# ---------------------------------------------------------------------------
def test_build_messages_icon_has_system_first():
"""messages[0] — system с правильным содержимым (system_icon.txt)."""
msgs = build_messages("hello", mode="icon", n=1, temperature=0.4)
assert msgs[0]["role"] == "system"
# Содержимое — ровно из system_icon.txt
assert msgs[0]["content"] == load_system_prompt("icon")
def test_build_messages_icon_has_few_shot_pairs_after_system():
"""После system идут few-shot пары."""
msgs = build_messages("hello", mode="icon", n=1, temperature=0.4)
few_shot = load_few_shot()
# Сразу после system и до последнего user
assert msgs[1:1 + len(few_shot)] == few_shot
def test_build_messages_icon_last_message_is_user_with_prompt():
"""Последнее сообщение — user, content = prompt (без image)."""
msgs = build_messages("the magnifier", mode="icon", n=1, temperature=0.4)
last = msgs[-1]
assert last["role"] == "user"
assert last["content"] == "the magnifier"
def test_build_messages_icon_prompt_is_stripped():
"""Пробелы по краям — strip'аются."""
msgs = build_messages(" hello \n", mode="icon")
assert msgs[-1]["content"] == "hello"
# ---------------------------------------------------------------------------
# build_messages — illustration
# ---------------------------------------------------------------------------
def test_build_messages_illustration_has_illustration_system():
"""system для illustration — из правильного файла."""
msgs = build_messages("a fox", mode="illustration", n=1, temperature=0.4)
assert msgs[0]["role"] == "system"
assert msgs[0]["content"] == load_system_prompt("illustration")
assert "OmniSVG-Illustration" in msgs[0]["content"]
def test_build_messages_illustration_uses_same_few_shot():
"""Few-shot одинаков для обоих mode (только system различается)."""
icon_msgs = build_messages("hi", mode="icon")
ill_msgs = build_messages("hi", mode="illustration")
# Содержимое system разное
assert icon_msgs[0]["content"] != ill_msgs[0]["content"]
# Few-shot — одинаковый (без system: индекс 1..1+len(fs))
fs = load_few_shot()
assert icon_msgs[1:1 + len(fs)] == ill_msgs[1:1 + len(fs)]
assert icon_msgs[1:1 + len(fs)] == fs
# ---------------------------------------------------------------------------
# build_messages — image (multimodal)
# ---------------------------------------------------------------------------
def test_build_messages_with_image_uses_multimodal_content():
"""image_b64 задан → последний user.content — list[dict] с text+image_url."""
msgs = build_messages(
"describe",
mode="icon",
image_b64="data:image/png;base64,ZZZ",
n=1,
temperature=0.4,
)
last = msgs[-1]
assert last["role"] == "user"
assert isinstance(last["content"], list)
assert last["content"][0]["type"] == "text"
assert last["content"][1]["type"] == "image_url"
assert last["content"][1]["image_url"]["url"] == "data:image/png;base64,ZZZ"
def test_build_messages_with_image_text_includes_recreate_instruction():
"""С image_b64 в text идёт преамбула 'Recreate the visual content...'."""
msgs = build_messages(
"my prompt",
mode="icon",
image_b64="data:image/png;base64,X",
)
last = msgs[-1]
text_part = last["content"][0]["text"]
assert "Recreate the visual content" in text_part
assert "my prompt" in text_part
def test_build_messages_without_image_uses_string_content():
"""Без image_b64 — последний user.content = строка."""
msgs = build_messages("just text", mode="icon")
last = msgs[-1]
assert isinstance(last["content"], str)
assert last["content"] == "just text"
# И НЕ содержит "Recreate" (этот преамбул только для image-режима)
assert "Recreate" not in last["content"]
# ---------------------------------------------------------------------------
# build_messages — palette
# ---------------------------------------------------------------------------
def test_build_messages_with_palette_appends_palette_line():
"""palette добавляется отдельной строкой 'Palette: ...' в user text."""
msgs = build_messages(
"a fox",
mode="icon",
palette="blue and teal",
)
last = msgs[-1]
text = last["content"]
assert "a fox" in text
assert "Palette: blue and teal" in text
def test_build_messages_with_palette_in_multimodal():
"""Palette добавляется и в multimodal-режиме (в text-часть)."""
msgs = build_messages(
"a fox",
mode="illustration",
image_b64="data:image/png;base64,X",
palette="warm autumn",
)
last = msgs[-1]
text_part = last["content"][0]["text"]
assert "a fox" in text_part
assert "Palette: warm autumn" in text_part
assert "Recreate" in text_part
def test_build_messages_palette_stripped():
"""palette.strip() — пробелы по краям убираются."""
msgs = build_messages("x", mode="icon", palette=" blue \n")
assert "Palette: blue" in msgs[-1]["content"]
# ---------------------------------------------------------------------------
# build_messages — валидация входа
# ---------------------------------------------------------------------------
def test_build_messages_empty_prompt_raises():
with pytest.raises(ValueError, match="промпт пустой"):
build_messages("", mode="icon")
def test_build_messages_whitespace_only_prompt_raises():
"""Промпт из одних пробелов — тоже пустой."""
with pytest.raises(ValueError, match="промпт пустой"):
build_messages(" \n\t ", mode="icon")
def test_build_messages_unknown_mode_raises():
with pytest.raises(ValueError, match="неизвестный mode"):
build_messages("hi", mode="portrait")
# ---------------------------------------------------------------------------
# build_messages — параметры n и temperature
# ---------------------------------------------------------------------------
def test_build_messages_n_and_temperature_do_not_appear_in_messages():
"""n и temperature — параметры API, не подставляются в content.
(По дизайну: они идут в payload, а в messages их нет.)
"""
msgs = build_messages("hi", mode="icon", n=4, temperature=0.7)
full_text = " ".join(
m["content"] if isinstance(m["content"], str) else str(m["content"])
for m in msgs
)
assert "n=4" not in full_text
assert "temperature" not in full_text or "temperature" in load_system_prompt("icon")
# Проверяем, что temperature вообще не появляется в user-сообщениях.
for m in msgs:
if m["role"] == "user" and m is not msgs[0]: # system пропускаем
content = m["content"]
if isinstance(content, str):
assert "temperature" not in content
# ---------------------------------------------------------------------------
# Полная структура: порядок сообщений
# ---------------------------------------------------------------------------
def test_build_messages_message_order():
"""Структура: system, few-shot пары, финальный user."""
msgs = build_messages("final", mode="icon")
fs = load_few_shot()
# Длина = 1 (system) + len(few_shot) + 1 (final user)
assert len(msgs) == 1 + len(fs) + 1
# system — первый
assert msgs[0]["role"] == "system"
# Последний — user
assert msgs[-1]["role"] == "user"
# Последний user содержит наш промпт
assert "final" in (
msgs[-1]["content"]
if isinstance(msgs[-1]["content"], str)
else msgs[-1]["content"][0]["text"]
)
+248
View File
@@ -0,0 +1,248 @@
"""Юнит-тесты для renderer.py.
Покрывают:
- render_png() с валидным SVG → либо PNG bytes, либо None (если cairo недоступен)
Документируем: функция НЕ бросает — всегда возвращает bytes | None.
- render_png() с пустым/None входом → None
- render_png() с невалидным SVG (синтаксически сломан) → None (cairo упадёт,
renderer ловит)
- render_png() с SVG без xmlns → корректно добавляет xmlns (cairo требует)
- save_png() создаёт каталог при отсутствии и пишет файл с ожидаемым путём
- save_png() с произвольными PNG-байтами сохраняет as-is (без декодирования)
- Интеграция: render_png + save_png end-to-end (если cairo есть) либо
save_png-fallback (если cairo нет)
ЗАМЕЧАНИЕ ПОВЕДЕНИЯ (документируем в этом тесте):
Если `cairo-2.dll`/`libcairo` недоступен — `render_png()` возвращает `None`,
не бросает. Это явный контракт из дизайн-доки §6: "Если cairo недоступен
… `render_png()` возвращает `None`". Невалидный SVG: если cairo есть — cairosvg
бросит Exception, renderer ловит его через `except Exception` и возвращает
`None`. Если cairo нет — мы и так сразу возвращаем `None`.
Запуск: `python -m pytest tests/test_renderer.py -v`
"""
from __future__ import annotations
import io
import sys
import tempfile
from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
import pytest # noqa: E402
from renderer import _get_cairosvg, render_png, save_png # noqa: E402
# ---------------------------------------------------------------------------
# Документация поведения: пустой/None вход
# ---------------------------------------------------------------------------
def test_render_png_empty_string_returns_none():
"""Пустая строка → None, без исключений."""
assert render_png("") is None
def test_render_png_whitespace_only_returns_none():
"""Строка из пробелов → None."""
assert render_png(" \n \t ") is None
# ---------------------------------------------------------------------------
# Документация поведения: валидный SVG
# ---------------------------------------------------------------------------
VALID_ICON_SVG = (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">'
'<circle cx="32" cy="32" r="20" fill="#FF0000"/>'
'</svg>'
)
def test_render_png_valid_svg_returns_png_or_none():
"""Контракт: возвращает либо bytes, либо None.
Если cairo установлен — bytes (PNG, начинается с magic bytes).
Если cairo недоступен — None.
В обоих случаях НЕ бросает.
"""
result = render_png(VALID_ICON_SVG, size=(64, 64))
if result is None:
# cairo недоступен — это допустимо, см. дизайн-док §6.
assert _get_cairosvg() is None, (
"_get_cairosvg() вернул модуль, но render_png() вернул None"
)
else:
# cairo есть — должны получить валидный PNG.
assert isinstance(result, bytes)
assert len(result) > 0
# PNG magic: 89 50 4E 47 0D 0A 1A 0A
assert result.startswith(b"\x89PNG\r\n\x1a\n"), (
f"вывод render_png() не начинается с PNG magic: {result[:16]!r}"
)
def test_render_png_uses_size_width_as_output():
"""Если cairo доступен, проверяем output_width через прямой вызов."""
cairosvg = _get_cairosvg()
if cairosvg is None:
pytest.skip("cairo недоступен — нельзя проверить output_width")
result = render_png(VALID_ICON_SVG, size=(256, 256))
assert result is not None
# Проверяем, что PNG имеет ширину 256 (viewBox 64x64 → квадрат 256x256)
from PIL import Image
img = Image.open(io.BytesIO(result))
assert img.size == (256, 256), f"ожидался 256x256, получено {img.size}"
# ---------------------------------------------------------------------------
# Документация поведения: невалидный SVG
# ---------------------------------------------------------------------------
def test_render_png_invalid_svg_returns_none_or_raises_cairo():
"""Невалидный SVG → либо None, либо cairosvg бросает.
Наш контракт: render_png() ВСЕГДА возвращает bytes|None, не бросает наружу.
Это валидируется в `test_render_png_does_not_propagate_cairo_exceptions`
(через mock).
"""
broken = '<svg xmlns="http://www.w3.org/2000/svg"><circle cx="32" cy="32" r="999999999" fill="oops"/></svg>'
# Независимо от состояния cairo, не должны получить необработанный raise наружу.
result = render_png(broken, size=(64, 64))
assert result is None # либо cairo нет, либо cairo бросил и мы вернули None
def test_render_png_does_not_propagate_cairo_exceptions():
"""Даже если cairosvg бросает — render_png() возвращает None, а не raise."""
fake_cairosvg = type("Fake", (), {})()
# Создаём фейк-модуль, у которого svg2png бросает
class FakeCairo:
@staticmethod
def svg2png(**kwargs):
raise RuntimeError("simulated cairo failure")
fake = FakeCairo()
with patch("renderer._get_cairosvg", return_value=fake):
result = render_png(VALID_ICON_SVG, size=(64, 64))
assert result is None
# ---------------------------------------------------------------------------
# xmlns-инъекция
# ---------------------------------------------------------------------------
def test_render_png_adds_xmlns_if_missing():
"""Если модель не поставила xmlns, renderer добавляет его в первую <svg>."""
# Прямо проверяем _ensure_xmlns (внутренняя, но контрактная)
from renderer import _ensure_xmlns
no_xmlns = '<svg viewBox="0 0 64 64"><rect/></svg>'
fixed = _ensure_xmlns(no_xmlns)
assert 'xmlns="http://www.w3.org/2000/svg"' in fixed.split(">", 1)[0]
already = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"></svg>'
assert _ensure_xmlns(already) == already
# В первом теге xmlns, но в href — не считается (спека смотрит ТОЛЬКО в head)
in_attr = '<svg viewBox="0 0 64 64" href="http://x.com"><rect/></svg>'
fixed_in_attr = _ensure_xmlns(in_attr)
head = fixed_in_attr.split(">", 1)[0]
assert 'xmlns="http://www.w3.org/2000/svg"' in head
# ---------------------------------------------------------------------------
# save_png()
# ---------------------------------------------------------------------------
def test_save_png_creates_directory_and_writes_file(tmp_path: Path):
"""save_png создаёт каталог и пишет файл с ожидаемым именем."""
fake_png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100 # валидный префикс + мусор
previews_dir = tmp_path / "previews"
out = save_png(
fake_png,
previews_dir=previews_dir,
record_id=42,
candidate_index=0,
)
assert out.exists()
assert out.is_file()
assert out == previews_dir / "42_0.png"
assert out.read_bytes() == fake_png
def test_save_png_does_not_decode_or_validate_png_bytes(tmp_path: Path):
"""save_png пишет байты as-is — никаких проверок PNG-формата."""
previews_dir = tmp_path / "p"
# Мусорные байты — не PNG, но save_png это не волнует.
out = save_png(
b"not actually png",
previews_dir=previews_dir,
record_id=1,
candidate_index=3,
)
assert out == previews_dir / "1_3.png"
assert out.read_bytes() == b"not actually png"
def test_save_png_appends_filename_with_zero_padded_index(tmp_path: Path):
"""record_id=5, candidate_index=7 → '5_7.png'."""
previews_dir = tmp_path / "p"
out = save_png(b"x", previews_dir=previews_dir, record_id=5, candidate_index=7)
assert out.name == "5_7.png"
def test_save_png_overwrites_existing_file(tmp_path: Path):
"""Повторный save с тем же (record_id, candidate_index) перезаписывает."""
previews_dir = tmp_path / "p"
out1 = save_png(b"first", previews_dir=previews_dir, record_id=1, candidate_index=0)
out2 = save_png(b"second", previews_dir=previews_dir, record_id=1, candidate_index=0)
assert out1 == out2
assert out1.read_bytes() == b"second"
# ---------------------------------------------------------------------------
# Интеграция: render + save (если cairo есть)
# ---------------------------------------------------------------------------
def test_render_and_save_integration(tmp_path: Path):
"""Сквозной кейс: render → save → файл существует и непустой."""
result = render_png(VALID_ICON_SVG, size=(64, 64))
if result is None:
pytest.skip("cairo недоступен — интеграционный тест render+save пропущен")
out = save_png(
result,
previews_dir=tmp_path / "previews",
record_id=10,
candidate_index=0,
)
assert out.exists()
assert out.stat().st_size > 0
# Содержимое — валидный PNG
from PIL import Image
img = Image.open(out)
img.verify() # поднимает, если битый PNG
# ---------------------------------------------------------------------------
# Граничный случай: путь с пробелами / Unicode
# ---------------------------------------------------------------------------
def test_save_png_works_with_unicode_in_path(tmp_path: Path):
"""save_png принимает каталог с юникодом (актуально для Windows)."""
previews_dir = tmp_path / "превью" # кириллица
out = save_png(b"x", previews_dir=previews_dir, record_id=1, candidate_index=0)
assert out.exists()
assert out.name == "1_0.png"
+213
View File
@@ -0,0 +1,213 @@
"""Юнит-тесты для validator.py.
Покрывают контрактные правила из design.md §5 и явные требования задачи:
- валидный SVG → ok=True
- отсутствует viewBox → ok=False (missing_viewbox)
- есть <script> → ok=False (disallowed_tag)
- есть <foreignObject> → ok=False (disallowed_tag)
- http:// ссылка в href → ok=False (external_ref)
- on*= атрибут → ok=False (event_handler)
Плюс несколько дополнительных кейсов, чтобы покрыть size-limit, парсинг
из обёртки с пояснением и edge cases.
Запуск:
python -m pytest tests/ -v
"""
from __future__ import annotations
import sys
from pathlib import Path
# Делаем корень проекта доступным как `import validator`.
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
import pytest # noqa: E402
from validator import ( # noqa: E402
MAX_BYTES,
validate,
validate_svg,
extract_svg,
ValidatorError,
)
# ---------------------------------------------------------------------------
# Положительные кейсы
# ---------------------------------------------------------------------------
VALID_ICON = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<circle cx="26" cy="26" r="14" fill="#3B82F6"/>
<rect x="36" y="34" width="6" height="20" rx="3" fill="#3B82F6" transform="rotate(45 39 44)"/>
</svg>"""
def test_valid_svg_returns_ok_and_cleaned():
ok, reason, cleaned = validate_svg(VALID_ICON, mode="icon")
assert ok is True
assert reason == ""
assert "<svg" in cleaned
assert cleaned.endswith("</svg>")
# viewBox должен сохраниться после сериализации.
assert 'viewBox="0 0 64 64"' in cleaned
def test_valid_svg_wrapped_in_prose_is_extracted():
"""Модель иногда отвечает `Here is your icon: <svg>...</svg>`. Должны вытащить."""
wrapped = (
"Sure! Here is your SVG icon:\n"
f"{VALID_ICON}\n"
"Hope that helps."
)
ok, reason, cleaned = validate_svg(wrapped, mode="icon")
assert ok is True, f"expected ok, got reason={reason!r}"
assert reason == ""
assert "<svg" in cleaned
def test_valid_illustration_with_filter():
"""В illustration разрешены <filter>, <feGaussianBlur> и т.п."""
ill = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<filter id="b" x="-10%" y="-10%" width="120%" height="120%">
<feGaussianBlur stdDeviation="2"/>
</filter>
</defs>
<rect x="100" y="100" width="200" height="200" fill="#ff0000" filter="url(#b)"/>
</svg>"""
ok, reason, _ = validate_svg(ill, mode="illustration")
assert ok is True, f"reason={reason!r}"
# ---------------------------------------------------------------------------
# Негативные кейсы
# ---------------------------------------------------------------------------
def test_missing_viewbox_rejected():
bad = """<svg xmlns="http://www.w3.org/2000/svg">
<circle cx="10" cy="10" r="5"/>
</svg>"""
ok, reason, cleaned = validate_svg(bad, mode="icon")
assert ok is False
assert reason == "missing_viewbox"
assert cleaned == ""
def test_bad_viewbox_rejected():
bad = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="oops">
<circle cx="10" cy="10" r="5"/>
</svg>"""
ok, reason, _ = validate_svg(bad, mode="icon")
assert ok is False
assert reason == "bad_viewbox"
def test_script_tag_rejected():
bad = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<script>alert(1)</script>
</svg>"""
ok, reason, _ = validate_svg(bad, mode="icon")
assert ok is False
assert reason == "disallowed_tag"
def test_foreign_object_rejected():
bad = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<foreignObject width="100" height="100">
<div xmlns="http://www.w3.org/1999/xhtml">Hi</div>
</foreignObject>
</svg>"""
ok, reason, _ = validate_svg(bad, mode="icon")
assert ok is False
assert reason == "disallowed_tag"
def test_http_ref_rejected():
bad = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<use href="http://example.com/sprite.svg#icon"/>
</svg>"""
ok, reason, _ = validate_svg(bad, mode="icon")
assert ok is False
assert reason == "external_ref"
def test_onclick_attribute_rejected():
bad = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect x="0" y="0" width="10" height="10" onclick="alert(1)"/>
</svg>"""
ok, reason, _ = validate_svg(bad, mode="icon")
assert ok is False
assert reason == "event_handler"
def test_url_with_https_rejected():
"""https:// в url(...) тоже блокируется."""
bad = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<pattern id="p" width="4" height="4" patternUnits="userSpaceOnUse">
<rect width="4" height="4" fill="url('https://example.com/tex.png')"/>
</pattern>
</defs>
</svg>"""
ok, reason, _ = validate_svg(bad, mode="icon")
assert ok is False
# Сейчас external_ref ловится на `url(` независимо от http(s).
assert reason in ("external_ref", "disallowed_tag", "unknown_tag")
def test_too_large_rejected():
"""SVG > MAX_BYTES[mode] → too_large."""
# Соберём раздутый SVG с большим комментарием.
pad = "<!-- " + ("x" * (MAX_BYTES["icon"] + 100)) + " -->"
bad = (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">'
+ pad
+ "</svg>"
)
ok, reason, _ = validate_svg(bad, mode="icon")
assert ok is False
assert reason == "too_large"
def test_empty_input_rejected():
ok, reason, _ = validate_svg("", mode="icon")
assert ok is False
assert reason == "empty_input"
def test_no_svg_block_rejected():
ok, reason, _ = validate_svg("Sorry, I cannot help with that.", mode="icon")
assert ok is False
assert reason == "not_svg"
# ---------------------------------------------------------------------------
# Прямые проверки API (validate() бросает, extract_svg() утилитарный)
# ---------------------------------------------------------------------------
def test_validate_strict_raises():
with pytest.raises(ValidatorError) as excinfo:
validate("plain text", mode="icon")
assert excinfo.value.code == "not_svg"
def test_extract_svg_finds_block():
text = "noise <svg viewBox='0 0 1 1'></svg> noise"
found = extract_svg(text)
assert found.startswith("<svg")
assert found.endswith("</svg>")
def test_validate_svg_returns_tuple_for_unexpected_exception():
"""Даже если что-то странное, обёртка возвращает кортеж (ok=False, ...)."""
ok, reason, cleaned = validate_svg(None, mode="icon") # type: ignore[arg-type]
assert ok is False
assert reason in ("empty_input", "internal:TypeError")
assert cleaned == ""
+228
View File
@@ -0,0 +1,228 @@
"""Валидация SVG-ответов модели.
Делает две вещи:
1) `extract_svg()` — вытаскивает <svg>...</svg> из произвольного текста
(модель иногда отвечает с пояснениями или code-fence).
2) `validate(svg_text, mode=...)` — строгая проверка по правилам дизайна.
3) `validate_svg(svg_text) -> (ok, reason, cleaned_svg)` — обёртка для UI,
описанная в задаче: всегда возвращает кортеж, не бросает исключений.
Правила (соответствуют §5 design.md и §6 spec.md):
- well-formed XML (lxml);
- ровно один корневой <svg>;
- наличие `viewBox` (4 числа);
- теги — только из ALLOWED_TAGS;
- <script>, <foreignObject>, <image> — запрещены;
- http://, https:// — запрещены в href/xlink:href и в `url(...)`;
- on* event-handler атрибуты — запрещены (доп. защита по задаче);
- размер файла <= лимита mode (32/256 KB).
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from lxml import etree
log = logging.getLogger(__name__)
SVG_NS = "http://www.w3.org/2000/svg"
ALLOWED_TAGS: set[str] = {
"svg",
"g",
"defs",
"symbol",
"use",
"path",
"rect",
"circle",
"ellipse",
"line",
"polygon",
"polyline",
"linearGradient",
"radialGradient",
"stop",
"text",
"tspan",
"textPath",
"title",
"desc",
"filter",
"feGaussianBlur",
"feOffset",
"feMerge",
"feMergeNode",
"feColorMatrix",
"feBlend",
"feFlood",
"feComposite",
"clipPath",
"mask",
"pattern",
}
DISALLOWED_TAGS: set[str] = {"script", "foreignObject", "image", "iframe", "style"}
MAX_BYTES: dict[str, int] = {
"icon": 32 * 1024,
"illustration": 256 * 1024,
}
class ValidatorError(Exception):
"""Ошибка валидации с машино-читаемым кодом и человеческим пояснением."""
def __init__(self, code: str, detail: str = ""):
super().__init__(f"{code}: {detail}" if detail else code)
self.code = code
self.detail = detail
_SVG_RE = re.compile(
r"<svg\b[^>]*>.*?</svg\s*>",
re.IGNORECASE | re.DOTALL,
)
def extract_svg(text: str) -> str:
"""Достаёт первое вхождение <svg>...</svg> из произвольного ответа модели."""
if not text or "<svg" not in text.lower():
raise ValidatorError("not_svg", "в ответе нет <svg>...</svg>")
match = _SVG_RE.search(text)
if not match:
raise ValidatorError("not_svg", "не удалось выделить <svg>...</svg> regex'ом")
return match.group(0).strip()
def _local(tag: object) -> str:
"""Снимает namespace: {ns}local -> local. Терпимо к non-str (cyfunction)."""
s = str(tag)
if "}" in s:
return s.split("}", 1)[1]
return s
def _iter_real_elements(root: etree._Element):
"""Итератор только по Element-узлам (пропускает комменты и PI)."""
for el in root.iter():
# lxml._Element и etree._Element имеют tag как str; cyfunction — это
# callable у комментариев/PI. Отфильтруем через isinstance.
if isinstance(getattr(el, "tag", None), str):
yield el
def _check_viewbox(root: etree._Element) -> None:
vb = root.get("viewBox")
if not vb:
raise ValidatorError("missing_viewbox", "атрибут viewBox отсутствует")
try:
parts = [float(x) for x in vb.split()]
except ValueError as exc:
raise ValidatorError("bad_viewbox", f"viewBox='{vb}'") from exc
if len(parts) != 4:
raise ValidatorError("bad_viewbox", f"viewBox='{vb}' (нужно 4 числа)")
def _check_tags_and_attrs(root: etree._Element) -> None:
for el in _iter_real_elements(root):
local = _local(el.tag)
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 not val:
continue
attr_local = _local(attr).lower()
if attr_local.startswith("on"):
raise ValidatorError("event_handler", f"{attr_local}={val[:80]}")
if "http://" in val or "https://" in val:
if attr_local.endswith("href") or "url(" in val.lower():
raise ValidatorError("external_ref", f"{attr_local}={val[:80]}")
def _ensure_size(svg_text: str, mode: str) -> None:
limit = MAX_BYTES.get(mode)
if limit is None:
return
if len(svg_text.encode("utf-8")) > limit:
raise ValidatorError(
"too_large",
f"{len(svg_text.encode('utf-8'))} байт > {limit} байт (mode={mode})",
)
def _serialize_clean(root: etree._Element) -> str:
if not root.get("xmlns"):
new_root = etree.Element(_local(root.tag), nsmap={None: SVG_NS})
for k, v in root.attrib.items():
new_root.set(_local(k), v)
for child in root:
new_root.append(child)
root = new_root
xml_bytes = etree.tostring(
root, pretty_print=False, xml_declaration=False, encoding="utf-8"
)
return xml_bytes.decode("utf-8")
def validate(svg_text: str, *, mode: str = "icon") -> etree._Element:
"""Строгая валидация по правилам дизайна. Бросает ValidatorError."""
svg_text = extract_svg(svg_text)
try:
root = etree.fromstring(svg_text.encode("utf-8"))
except etree.XMLSyntaxError as exc:
raise ValidatorError("malformed_xml", str(exc)) from exc
if _local(root.tag) != "svg":
raise ValidatorError("not_svg", root.tag)
_check_viewbox(root)
_check_tags_and_attrs(root)
_ensure_size(svg_text, mode)
return root
@dataclass(frozen=True)
class ValidationResult:
ok: bool
reason: str
cleaned_svg: str
def validate_svg(svg_text: str, *, mode: str = "icon") -> tuple[bool, str, str]:
"""Удобная обёртка над `validate()`: всегда возвращает кортеж.
Returns:
Кортеж `(ok, reason, cleaned_svg)`.
"""
if not svg_text or not svg_text.strip():
return False, "empty_input", ""
try:
root = validate(svg_text, mode=mode)
except ValidatorError as exc:
log.info("validate_svg: reject (%s)", exc.code)
return False, exc.code, ""
except Exception as exc: # noqa: BLE001
log.exception("validate_svg: неожиданная ошибка")
return False, f"internal:{type(exc).__name__}", ""
return True, "", _serialize_clean(root)
__all__ = [
"ALLOWED_TAGS",
"DISALLOWED_TAGS",
"MAX_BYTES",
"SVG_NS",
"ValidationResult",
"ValidatorError",
"extract_svg",
"validate",
"validate_svg",
]