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:
+173
@@ -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
|
||||
Reference in New Issue
Block a user