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
320 lines
11 KiB
Python
320 lines
11 KiB
Python
"""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())
|