2394eff1c0
- 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
174 lines
6.9 KiB
Python
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
|