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

174 lines
6.9 KiB
Python

"""Загрузка промпт-шаблонов и сборка 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