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
+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"]
)