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:
+230
@@ -0,0 +1,230 @@
|
||||
"""SQLite-история генераций.
|
||||
|
||||
Один пользователь, без авторизации, одна таблица. WAL для устойчивости при
|
||||
одновременной записи из UI и, например, фонового процесса.
|
||||
|
||||
Файл БД по умолчанию: `~/.omnisvg_lite/history.sqlite`. Можно переопределить
|
||||
через `OMNISVG_DB_PATH` (env) или явно в конструкторе.
|
||||
|
||||
Схема расширена по сравнению с design.md: добавлены `n_requested`, `n_returned`,
|
||||
`previews` (JSON list[str]), `error_reason`, `best_index`, `temperature` —
|
||||
чтобы UI мог показать полную картину без дополнительных запросов.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Дефолтный путь к БД — в домашнем каталоге пользователя. Это не абсолютный
|
||||
# путь к проекту: пользователь может переопределить через env.
|
||||
DEFAULT_DB_DIR = Path.home() / ".omnisvg_lite"
|
||||
DEFAULT_DB_PATH = DEFAULT_DB_DIR / "history.sqlite"
|
||||
|
||||
|
||||
def _resolve_db_path(db_path: str | Path | None) -> Path:
|
||||
if db_path is not None:
|
||||
return Path(db_path)
|
||||
env = os.environ.get("OMNISVG_DB_PATH")
|
||||
if env:
|
||||
return Path(env)
|
||||
return DEFAULT_DB_PATH
|
||||
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS generations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at REAL NOT NULL,
|
||||
prompt TEXT NOT NULL,
|
||||
mode TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
n_requested INTEGER NOT NULL,
|
||||
n_returned INTEGER NOT NULL,
|
||||
temperature REAL NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
error_reason TEXT,
|
||||
raw_outputs TEXT NOT NULL,
|
||||
validated_outputs TEXT NOT NULL,
|
||||
previews TEXT NOT NULL,
|
||||
best_index INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_generations_created_at
|
||||
ON generations(created_at DESC);
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Record:
|
||||
"""Запись о генерации.
|
||||
|
||||
Attributes:
|
||||
prompt: пользовательский промпт.
|
||||
mode: "icon" | "illustration".
|
||||
model: имя модели.
|
||||
n_requested: сколько кандидатов запросили.
|
||||
n_returned: сколько LM Studio реально вернула.
|
||||
temperature: использованная температура.
|
||||
status: "ok" | "partial" | "failed".
|
||||
error_reason: строка с ошибкой или None.
|
||||
raw_outputs: list[str] — N сырых ответов модели.
|
||||
validated_outputs: list[str] — прошедшие валидатор SVG.
|
||||
previews: list[str] — пути к PNG-превью (rel или abs).
|
||||
best_index: индекс лучшего кандидата в validated_outputs (или None).
|
||||
created_at: time.time() (если None — заполняется в `add`).
|
||||
"""
|
||||
|
||||
prompt: str
|
||||
mode: str
|
||||
model: str
|
||||
n_requested: int
|
||||
n_returned: int
|
||||
temperature: float
|
||||
status: str
|
||||
error_reason: str | None
|
||||
raw_outputs: list[str] = field(default_factory=list)
|
||||
validated_outputs: list[str] = field(default_factory=list)
|
||||
previews: list[str] = field(default_factory=list)
|
||||
best_index: int | None = None
|
||||
created_at: float | None = None
|
||||
|
||||
|
||||
class History:
|
||||
"""Контекст-менеджер для работы с историей генераций.
|
||||
|
||||
Используется так:
|
||||
with History() as h:
|
||||
row_id = h.add(Record(...))
|
||||
for r in h.list_recent(20):
|
||||
...
|
||||
|
||||
На каждый enter/exit открывается/закрывается соединение. Для долгоживущих
|
||||
демонов Gradio это нормально: событий мало.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str | Path | None = None):
|
||||
self.db_path = _resolve_db_path(db_path)
|
||||
self._conn: sqlite3.Connection | None = None
|
||||
|
||||
# -- context manager ------------------------------------------------
|
||||
|
||||
def __enter__(self) -> "History":
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._conn = sqlite3.connect(str(self.db_path))
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
# WAL: пишущие транзакции не блокируют читателей, читатели — писателей.
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("PRAGMA synchronous=NORMAL")
|
||||
self._conn.executescript(_SCHEMA)
|
||||
self._conn.commit()
|
||||
log.info("history: БД открыта по %s", self.db_path)
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
if self._conn is not None:
|
||||
try:
|
||||
if exc_type is None:
|
||||
self._conn.commit()
|
||||
else:
|
||||
self._conn.rollback()
|
||||
finally:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
@property
|
||||
def conn(self) -> sqlite3.Connection:
|
||||
if self._conn is None:
|
||||
raise RuntimeError("History используется вне контекст-менеджера")
|
||||
return self._conn
|
||||
|
||||
# -- CRUD -----------------------------------------------------------
|
||||
|
||||
def add(self, record: Record) -> int:
|
||||
"""Добавляет запись и возвращает её id."""
|
||||
created_at = record.created_at if record.created_at is not None else time.time()
|
||||
cur = self.conn.execute(
|
||||
"""
|
||||
INSERT INTO generations
|
||||
(created_at, prompt, mode, model, n_requested, n_returned,
|
||||
temperature, status, error_reason,
|
||||
raw_outputs, validated_outputs, previews, best_index)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
created_at,
|
||||
record.prompt,
|
||||
record.mode,
|
||||
record.model,
|
||||
record.n_requested,
|
||||
record.n_returned,
|
||||
record.temperature,
|
||||
record.status,
|
||||
record.error_reason,
|
||||
json.dumps(record.raw_outputs, ensure_ascii=False),
|
||||
json.dumps(record.validated_outputs, ensure_ascii=False),
|
||||
json.dumps(record.previews, ensure_ascii=False),
|
||||
record.best_index,
|
||||
),
|
||||
)
|
||||
self.conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
|
||||
def list_recent(self, limit: int = 50) -> list[dict[str, Any]]:
|
||||
"""Возвращает последние `limit` записей, новые сверху."""
|
||||
cur = self.conn.execute(
|
||||
"""
|
||||
SELECT id, created_at, prompt, mode, model, n_requested, n_returned,
|
||||
temperature, status, error_reason,
|
||||
raw_outputs, validated_outputs, previews, best_index
|
||||
FROM generations
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
return [_row_to_dict(row) for row in cur.fetchall()]
|
||||
|
||||
def get(self, record_id: int) -> dict[str, Any] | None:
|
||||
"""Возвращает одну запись по id или None."""
|
||||
cur = self.conn.execute(
|
||||
"""
|
||||
SELECT id, created_at, prompt, mode, model, n_requested, n_returned,
|
||||
temperature, status, error_reason,
|
||||
raw_outputs, validated_outputs, previews, best_index
|
||||
FROM generations
|
||||
WHERE id = ?
|
||||
""",
|
||||
(record_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return _row_to_dict(row)
|
||||
|
||||
def count(self) -> int:
|
||||
cur = self.conn.execute("SELECT COUNT(*) AS c FROM generations")
|
||||
return int(cur.fetchone()["c"])
|
||||
|
||||
|
||||
def _row_to_dict(row: sqlite3.Row) -> dict[str, Any]:
|
||||
d = dict(row)
|
||||
# JSON-поля превращаем в Python-структуры.
|
||||
for key in ("raw_outputs", "validated_outputs", "previews"):
|
||||
try:
|
||||
d[key] = json.loads(d[key]) if d.get(key) else []
|
||||
except (TypeError, ValueError):
|
||||
log.warning("history: не удалось разобрать JSON в поле %s", key)
|
||||
d[key] = []
|
||||
return d
|
||||
|
||||
|
||||
__all__ = ["DEFAULT_DB_PATH", "History", "Record"]
|
||||
Reference in New Issue
Block a user