Контекст между сообщениями
Build and push Docker image / tests (push) Successful in 15s Details
Build and push Docker image / docker (push) Successful in 29s Details

Сохраняю базовый и текущий промпт пользователя и передаю предыдущий контекст в enhancer. Добавил /reset, снимок контекста в requests/pending_payments и обновление только после успешной отправки результата.
This commit is contained in:
MpakobecEx 2026-10-19 19:08:16 +05:00
parent f0064191e1
commit d593e35921
10 changed files with 378 additions and 16 deletions

View File

@ -15,6 +15,7 @@ Telegram-бот для генерации и редактирования изо
- **Баланс и рефералы**: 3 стартовые генерации, +1 ежедневно в 00:00 МСК, +3 за первую оплату друга. - **Баланс и рефералы**: 3 стартовые генерации, +1 ежедневно в 00:00 МСК, +3 за первую оплату друга.
- **Управление пользователями**: в веб-интерфейсе можно выдавать админку, менять баланс и блокировать. - **Управление пользователями**: в веб-интерфейсе можно выдавать админку, менять баланс и блокировать.
- **Подтверждение 18+**: без подтверждения возраста бот не принимает запросы. - **Подтверждение 18+**: без подтверждения возраста бот не принимает запросы.
- **Контекст**: следующий текстовый запрос продолжает предыдущую сцену; `/reset` начинает новую.
- Глобальная очередь с одной активной задачей на пользователя. - Глобальная очередь с одной активной задачей на пользователя.
- Позиция в очереди и прогресс генерации через WebSocket ComfyUI. - Позиция в очереди и прогресс генерации через WebSocket ComfyUI.
- PostgreSQL: запросы, статусы, seed, пути к файлам. - PostgreSQL: запросы, статусы, seed, пути к файлам.
@ -201,10 +202,22 @@ TELEGRAM_HEALTH_INTERVAL=60
- Премиум-очередь всегда оплачивается 15 звёздами; баланс при этом не расходуется. - Премиум-очередь всегда оплачивается 15 звёздами; баланс при этом не расходуется.
- `/balance` — текущий баланс. - `/balance` — текущий баланс.
- `/invite` — реферальная ссылка. - `/invite` — реферальная ссылка.
- `/reset` — сбросить контекст и начать новую сцену.
- За первую оплату приглашённого друга начисляется +3 генерации, реферер получает уведомление. - За первую оплату приглашённого друга начисляется +3 генерации, реферер получает уведомление.
Баланс и рефералы хранятся в таблице `users`. Пропущенные дни начисляются при следующем запуске/полуночи. Баланс и рефералы хранятся в таблице `users`. Пропущенные дни начисляются при следующем запуске/полуночи.
## Контекст
- Первый текстовый запрос создаёт контекст: базовую сцену и текущий улучшенный промпт.
- Следующий текст обрабатывается вместе с предыдущим промптом: бот сохраняет сцену и применяет только запрошенное изменение.
- `/reset` очищает контекст; следующий запрос начнёт новую сцену.
- Новое фото с подписью автоматически начинает новую цепочку.
- Для точного редактирования конкретной картинки ответь на сообщение бота с изображением.
- `/reset` не отменяет уже запущенную генерацию: она завершится со своим снимком контекста.
Контекст хранится в таблице `user_contexts` и обновляется только после успешной отправки результата.
## Оплата ## Оплата
Генерация стоит 5 Telegram Stars в обычной очереди и 15 звёзд в премиум-очереди: Генерация стоит 5 Telegram Stars в обычной очереди и 15 звёзд в премиум-очереди:

60
bot.py
View File

@ -63,7 +63,8 @@ START_TEXT = (
"Или ответь текстом на готовую картинку — возьму её как исходник.\n\n" "Или ответь текстом на готовую картинку — возьму её как исходник.\n\n"
"/balance — баланс генераций.\n" "/balance — баланс генераций.\n"
"/invite — пригласить друга.\n" "/invite — пригласить друга.\n"
"/status — состояние текущей задачи." "/status — состояние текущей задачи.\n"
"/reset — начать новую сцену."
) )
BLOCKED_TEXT = "Запрос нарушает правила. Звёзды не списаны." BLOCKED_TEXT = "Запрос нарушает правила. Звёзды не списаны."
AGE_TEXT = ( AGE_TEXT = (
@ -124,6 +125,7 @@ class MpakoBot:
application.add_handler(CommandHandler("status", self._status)) application.add_handler(CommandHandler("status", self._status))
application.add_handler(CommandHandler("balance", self._balance)) application.add_handler(CommandHandler("balance", self._balance))
application.add_handler(CommandHandler("invite", self._invite)) application.add_handler(CommandHandler("invite", self._invite))
application.add_handler(CommandHandler("reset", self._reset))
application.add_handler( application.add_handler(
PreCheckoutQueryHandler(self._pre_checkout) PreCheckoutQueryHandler(self._pre_checkout)
) )
@ -491,6 +493,23 @@ class MpakoBot:
reply_markup=keyboard, reply_markup=keyboard,
) )
async def _reset(
self, update: Update, context: ContextTypes.DEFAULT_TYPE
) -> None:
if not await self._check_access(update):
return
message = update.effective_message
user = update.effective_user
if message is None or user is None:
return
cleared = await self._db.clear_user_context(user.id)
text = (
"Контекст сброшен. Следующий запрос начнёт новую сцену."
if cleared
else "Контекст уже пуст."
)
await self._retry(message.reply_text, text)
async def _status( async def _status(
self, update: Update, context: ContextTypes.DEFAULT_TYPE self, update: Update, context: ContextTypes.DEFAULT_TYPE
) -> None: ) -> None:
@ -632,6 +651,9 @@ class MpakoBot:
input_path=pending.input_file_path, input_path=pending.input_file_path,
skip_payment=True, skip_payment=True,
is_premium=False, is_premium=False,
use_context=bool(pending.context_prompt),
context_base_prompt=pending.context_base_prompt,
context_prompt=pending.context_prompt,
) )
if not accepted: if not accepted:
await self._db.add_credits(query.from_user.id, 1) await self._db.add_credits(query.from_user.id, 1)
@ -740,6 +762,9 @@ class MpakoBot:
payment_charge_id=charge_id, payment_charge_id=charge_id,
payment_payload=payload, payment_payload=payload,
is_premium=pending.is_premium, is_premium=pending.is_premium,
use_context=bool(pending.context_prompt),
context_base_prompt=pending.context_base_prompt,
context_prompt=pending.context_prompt,
) )
if accepted: if accepted:
await self._reward_referrer(user) await self._reward_referrer(user)
@ -830,6 +855,7 @@ class MpakoBot:
mode="i2i", mode="i2i",
prompt=caption, prompt=caption,
input_path=str(input_path), input_path=str(input_path),
use_context=False,
) )
if not accepted: if not accepted:
input_path.unlink(missing_ok=True) input_path.unlink(missing_ok=True)
@ -895,6 +921,9 @@ class MpakoBot:
payment_charge_id: str | None = None, payment_charge_id: str | None = None,
payment_payload: str | None = None, payment_payload: str | None = None,
is_premium: bool = False, is_premium: bool = False,
use_context: bool = True,
context_base_prompt: str | None = None,
context_prompt: str | None = None,
) -> bool: ) -> bool:
message = update.effective_message message = update.effective_message
user = update.effective_user user = update.effective_user
@ -914,6 +943,14 @@ class MpakoBot:
if user_row.is_blocked: if user_row.is_blocked:
await message.reply_text("Доступ заблокирован.") await message.reply_text("Доступ заблокирован.")
return False return False
if not use_context:
context_base_prompt = None
context_prompt = None
elif context_prompt is None:
user_context = await self._db.get_user_context(user.id)
if user_context is not None:
context_base_prompt = user_context.base_prompt
context_prompt = user_context.current_prompt
credits = user_row.credits credits = user_row.credits
has_person = False has_person = False
if ( if (
@ -966,6 +1003,8 @@ class MpakoBot:
username=user.username, username=user.username,
input_file_path=input_path, input_file_path=input_path,
amount=0, amount=0,
context_base_prompt=context_base_prompt,
context_prompt=context_prompt,
) )
keyboard = InlineKeyboardMarkup( keyboard = InlineKeyboardMarkup(
[ [
@ -1023,6 +1062,8 @@ class MpakoBot:
is_admin=is_admin, is_admin=is_admin,
payment_charge_id=payment_charge_id, payment_charge_id=payment_charge_id,
is_premium=is_premium, is_premium=is_premium,
context_base_prompt=context_base_prompt,
context_prompt=context_prompt,
) )
except PromptBlocked: except PromptBlocked:
note = await self._refund_if_paid( note = await self._refund_if_paid(
@ -1278,6 +1319,23 @@ class MpakoBot:
return return
await self._clear_status(request.id, delete=True) await self._clear_status(request.id, delete=True)
await self._save_context(request)
async def _save_context(self, request: Request) -> None:
current = request.enhanced_prompt or request.prompt_text
base = request.context_base_prompt or current
try:
await self._db.save_user_context(
user_id=request.user_id,
base_prompt=base,
current_prompt=current,
last_request_id=request.id,
last_mode=request.mode,
)
except Exception:
logger.exception(
"Не удалось сохранить контекст запроса %s", request.id
)
async def _finish_failed(self, request: Request, error: str) -> None: async def _finish_failed(self, request: Request, error: str) -> None:
target = self._status_messages.get(request.id) target = self._status_messages.get(request.id)

View File

@ -8,7 +8,7 @@
- Реализованы блоки 2–7, базовое хранилище (9), файлы Docker (10), тесты (11), README (12), веб-история (13), улучшение промптов (14), модерация (15), оплата Stars (16), баланс/рефералы (17), управление пользователями (18), возрастной gate (19) и надёжность Telegram (20). - Реализованы блоки 2–7, базовое хранилище (9), файлы Docker (10), тесты (11), README (12), веб-история (13), улучшение промптов (14), модерация (15), оплата Stars (16), баланс/рефералы (17), управление пользователями (18), возрастной gate (19) и надёжность Telegram (20).
- Вживую проверены: T2I (Z-Image), I2I (Krea2), WebSocket-прогресс, улучшение промптов, Docker, CI и веб-история. - Вживую проверены: T2I (Z-Image), I2I (Krea2), WebSocket-прогресс, улучшение промптов, Docker, CI и веб-история.
- 59 тестов: 37 проходят локально без БД, 22 теста БД выполняются в CI на PostgreSQL через `TEST_DATABASE_URL`. Покрыты workflows, очередь, веб-история, enhancer, модерация, платежи, премиум-очередь, баланс/рефералы, управление пользователями, retry, миграция, watchdog. - 62 теста: 38 проходят локально без БД, 24 теста БД выполняются в CI на PostgreSQL через `TEST_DATABASE_URL`. Покрыты workflows, очередь, веб-история, enhancer, модерация, платежи, премиум-очередь, баланс/рефералы, управление пользователями, retry, миграция, watchdog, контекст.
- Не проверено: реальная оплата Stars и реферальная выплата в Telegram, очередь из 3+ пользователей, падение ComfyUI, рестарт с `running`-задачей, детекция лица на реальном фото. PostgreSQL-миграция и тесты БД ещё не запускались вживую — это сделает CI и первый деплой. - Не проверено: реальная оплата Stars и реферальная выплата в Telegram, очередь из 3+ пользователей, падение ComfyUI, рестарт с `running`-задачей, детекция лица на реальном фото. PostgreSQL-миграция и тесты БД ещё не запускались вживую — это сделает CI и первый деплой.
## 0. Допущения и решения по умолчанию ## 0. Допущения и решения по умолчанию
@ -381,6 +381,17 @@ ON requests(user_id) WHERE status IN ('queued', 'running');
**DoD:** при таймаутах прокси операции повторяются, health-check фиксирует восстановление связи. **DoD:** при таймаутах прокси операции повторяются, health-check фиксирует восстановление связи.
## 21. Контекст между сообщениями
- [x] Таблица `user_contexts`: `base_prompt`, `current_prompt`, `last_request_id`, `last_mode`, `updated_at`.
- [x] Снимок контекста сохраняется в `requests` и `pending_payments`.
- [x] Следующий текстовый запрос обрабатывается вместе с предыдущим промптом.
- [x] Контекст обновляется только после успешной отправки результата.
- [x] `/reset` очищает контекст; новое фото начинает новую цепочку.
- [x] Тесты БД, enhancer и очереди.
**DoD:** продолжение сцены работает по тексту, `/reset` возвращает к новой сцене.
## Порядок работ ## Порядок работ
1. Блок 1 (подготовка) — без него нельзя валидировать клиент и workflow. 1. Блок 1 (подготовка) — без него нельзя валидировать клиент и workflow.

113
db.py
View File

@ -61,6 +61,8 @@ class Request:
timestamp: str timestamp: str
prompt_text: str prompt_text: str
enhanced_prompt: str | None enhanced_prompt: str | None
context_base_prompt: str | None
context_prompt: str | None
input_file_path: str | None input_file_path: str | None
output_file_path: str | None output_file_path: str | None
status: str status: str
@ -86,6 +88,8 @@ class Request:
timestamp=_as_iso(row["timestamp"]) or "", timestamp=_as_iso(row["timestamp"]) or "",
prompt_text=row["prompt_text"], prompt_text=row["prompt_text"],
enhanced_prompt=row["enhanced_prompt"], enhanced_prompt=row["enhanced_prompt"],
context_base_prompt=row["context_base_prompt"],
context_prompt=row["context_prompt"],
input_file_path=row["input_file_path"], input_file_path=row["input_file_path"],
output_file_path=row["output_file_path"], output_file_path=row["output_file_path"],
status=row["status"], status=row["status"],
@ -177,6 +181,8 @@ class PendingPayment:
timestamp: str timestamp: str
mode: str mode: str
prompt: str prompt: str
context_base_prompt: str | None
context_prompt: str | None
username: str | None username: str | None
input_file_path: str | None input_file_path: str | None
amount: int amount: int
@ -193,6 +199,8 @@ class PendingPayment:
timestamp=_as_iso(row["timestamp"]) or "", timestamp=_as_iso(row["timestamp"]) or "",
mode=row["mode"], mode=row["mode"],
prompt=row["prompt"], prompt=row["prompt"],
context_base_prompt=row["context_base_prompt"],
context_prompt=row["context_prompt"],
username=row["username"], username=row["username"],
input_file_path=row["input_file_path"], input_file_path=row["input_file_path"],
amount=int(row["amount"]), amount=int(row["amount"]),
@ -203,6 +211,27 @@ class PendingPayment:
) )
@dataclass(frozen=True)
class UserContext:
user_id: int
base_prompt: str
current_prompt: str
last_request_id: int | None
last_mode: str | None
updated_at: str
@classmethod
def from_row(cls, row: Mapping[str, Any]) -> UserContext:
return cls(
user_id=int(row["user_id"]),
base_prompt=row["base_prompt"],
current_prompt=row["current_prompt"],
last_request_id=row["last_request_id"],
last_mode=row["last_mode"],
updated_at=_as_iso(row["updated_at"]) or "",
)
SCHEMA = """ SCHEMA = """
CREATE TABLE IF NOT EXISTS requests ( CREATE TABLE IF NOT EXISTS requests (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
@ -211,6 +240,8 @@ CREATE TABLE IF NOT EXISTS requests (
timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), timestamp TIMESTAMPTZ NOT NULL DEFAULT now(),
prompt_text TEXT NOT NULL, prompt_text TEXT NOT NULL,
enhanced_prompt TEXT, enhanced_prompt TEXT,
context_base_prompt TEXT,
context_prompt TEXT,
input_file_path TEXT, input_file_path TEXT,
output_file_path TEXT, output_file_path TEXT,
status TEXT NOT NULL DEFAULT 'queued' status TEXT NOT NULL DEFAULT 'queued'
@ -259,6 +290,8 @@ CREATE TABLE IF NOT EXISTS pending_payments (
status TEXT NOT NULL DEFAULT 'pending' status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'paid', 'cancelled', 'refunded')), CHECK (status IN ('pending', 'paid', 'cancelled', 'refunded')),
is_premium BOOLEAN NOT NULL DEFAULT FALSE, is_premium BOOLEAN NOT NULL DEFAULT FALSE,
context_base_prompt TEXT,
context_prompt TEXT,
telegram_payment_charge_id TEXT, telegram_payment_charge_id TEXT,
request_id BIGINT request_id BIGINT
); );
@ -283,6 +316,15 @@ CREATE TABLE IF NOT EXISTS users (
CREATE INDEX IF NOT EXISTS idx_users_referrer CREATE INDEX IF NOT EXISTS idx_users_referrer
ON users(referrer_id); ON users(referrer_id);
CREATE TABLE IF NOT EXISTS user_contexts (
user_id BIGINT PRIMARY KEY,
base_prompt TEXT NOT NULL,
current_prompt TEXT NOT NULL,
last_request_id BIGINT,
last_mode TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS app_settings ( CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT value TEXT
@ -294,6 +336,8 @@ SCHEMA_STATEMENTS = [part.strip() for part in SCHEMA.split(";") if part.strip()]
MIGRATIONS = ( MIGRATIONS = (
"ALTER TABLE requests ADD COLUMN IF NOT EXISTS username TEXT", "ALTER TABLE requests ADD COLUMN IF NOT EXISTS username TEXT",
"ALTER TABLE requests ADD COLUMN IF NOT EXISTS enhanced_prompt TEXT", "ALTER TABLE requests ADD COLUMN IF NOT EXISTS enhanced_prompt TEXT",
"ALTER TABLE requests ADD COLUMN IF NOT EXISTS context_base_prompt TEXT",
"ALTER TABLE requests ADD COLUMN IF NOT EXISTS context_prompt TEXT",
"ALTER TABLE requests ADD COLUMN IF NOT EXISTS payment_charge_id TEXT", "ALTER TABLE requests ADD COLUMN IF NOT EXISTS payment_charge_id TEXT",
( (
"ALTER TABLE requests ADD COLUMN IF NOT EXISTS payment_refunded BOOLEAN" "ALTER TABLE requests ADD COLUMN IF NOT EXISTS payment_refunded BOOLEAN"
@ -307,6 +351,8 @@ MIGRATIONS = (
"ALTER TABLE pending_payments ADD COLUMN IF NOT EXISTS is_premium BOOLEAN" "ALTER TABLE pending_payments ADD COLUMN IF NOT EXISTS is_premium BOOLEAN"
" NOT NULL DEFAULT FALSE" " NOT NULL DEFAULT FALSE"
), ),
"ALTER TABLE pending_payments ADD COLUMN IF NOT EXISTS context_base_prompt TEXT",
"ALTER TABLE pending_payments ADD COLUMN IF NOT EXISTS context_prompt TEXT",
( (
"ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN" "ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN"
" NOT NULL DEFAULT FALSE" " NOT NULL DEFAULT FALSE"
@ -402,6 +448,8 @@ class Database:
seed: int, seed: int,
payment_charge_id: str | None = None, payment_charge_id: str | None = None,
is_premium: bool = False, is_premium: bool = False,
context_base_prompt: str | None = None,
context_prompt: str | None = None,
) -> Request: ) -> Request:
try: try:
async with self._connection_pool().connection() as conn: async with self._connection_pool().connection() as conn:
@ -409,9 +457,9 @@ class Database:
""" """
INSERT INTO requests INSERT INTO requests
(user_id, username, timestamp, prompt_text, (user_id, username, timestamp, prompt_text,
input_file_path, status, mode, seed, payment_charge_id, context_base_prompt, context_prompt, input_file_path,
is_premium) status, mode, seed, payment_charge_id, is_premium)
VALUES (%s, %s, %s, %s, %s, 'queued', %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, 'queued', %s, %s, %s, %s)
RETURNING * RETURNING *
""", """,
( (
@ -419,6 +467,8 @@ class Database:
username, username,
utc_now(), utc_now(),
prompt_text, prompt_text,
context_base_prompt,
context_prompt,
input_file_path, input_file_path,
mode, mode,
seed, seed,
@ -462,6 +512,54 @@ class Database:
row = await cursor.fetchone() row = await cursor.fetchone()
return User.from_row(row) if row is not None else None return User.from_row(row) if row is not None else None
async def get_user_context(self, user_id: int) -> UserContext | None:
async with self._connection_pool().connection() as conn:
cursor = await conn.execute(
"SELECT * FROM user_contexts WHERE user_id = %s", (user_id,)
)
row = await cursor.fetchone()
return UserContext.from_row(row) if row is not None else None
async def save_user_context(
self,
*,
user_id: int,
base_prompt: str,
current_prompt: str,
last_request_id: int | None,
last_mode: str | None,
) -> None:
async with self._connection_pool().connection() as conn:
await conn.execute(
"""
INSERT INTO user_contexts
(user_id, base_prompt, current_prompt, last_request_id,
last_mode, updated_at)
VALUES (%s, %s, %s, %s, %s, now())
ON CONFLICT (user_id) DO UPDATE
SET base_prompt = EXCLUDED.base_prompt,
current_prompt = EXCLUDED.current_prompt,
last_request_id = EXCLUDED.last_request_id,
last_mode = EXCLUDED.last_mode,
updated_at = now()
""",
(
user_id,
base_prompt,
current_prompt,
last_request_id,
last_mode,
),
)
async def clear_user_context(self, user_id: int) -> bool:
async with self._connection_pool().connection() as conn:
cursor = await conn.execute(
"DELETE FROM user_contexts WHERE user_id = %s RETURNING user_id",
(user_id,),
)
return await cursor.fetchone() is not None
async def ensure_user( async def ensure_user(
self, user_id: int, username: str | None = None self, user_id: int, username: str | None = None
) -> User: ) -> User:
@ -862,14 +960,17 @@ class Database:
input_file_path: str | None, input_file_path: str | None,
amount: int, amount: int,
is_premium: bool = False, is_premium: bool = False,
context_base_prompt: str | None = None,
context_prompt: str | None = None,
) -> PendingPayment: ) -> PendingPayment:
async with self._connection_pool().connection() as conn: async with self._connection_pool().connection() as conn:
cursor = await conn.execute( cursor = await conn.execute(
""" """
INSERT INTO pending_payments INSERT INTO pending_payments
(payload, user_id, timestamp, mode, prompt, username, (payload, user_id, timestamp, mode, prompt, username,
input_file_path, amount, status, is_premium) input_file_path, amount, status, is_premium,
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'pending', %s) context_base_prompt, context_prompt)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'pending', %s, %s, %s)
RETURNING * RETURNING *
""", """,
( (
@ -882,6 +983,8 @@ class Database:
input_file_path, input_file_path,
amount, amount,
is_premium, is_premium,
context_base_prompt,
context_prompt,
), ),
) )
row = await cursor.fetchone() row = await cursor.fetchone()

View File

@ -31,6 +31,23 @@ SYSTEM_PROMPTS = {
), ),
} }
REFINE_SYSTEM_PROMPTS = {
"t2i": (
"You are editing an existing image prompt. The previous prompt describes "
"the current image. Apply only the user's requested changes. Keep all "
"subjects, style, colors, composition and constraints from the previous "
"prompt unless the user explicitly asks to change them. Return only the "
"final English prompt, without quotes or explanations."
),
"i2i": (
"You are updating an image-editing instruction. The previous prompt "
"describes the current image. Apply only the user's requested changes. "
"Keep the existing composition, subjects, style, lighting and constraints "
"unless the user explicitly asks to change them. Return only the final "
"English instruction, without quotes or explanations."
),
}
class PromptEnhanceError(RuntimeError): class PromptEnhanceError(RuntimeError):
pass pass
@ -80,20 +97,35 @@ class PromptEnhancer:
) )
return str(node_ids[0]) return str(node_ids[0])
async def enhance(self, prompt: str, mode: str) -> str: async def enhance(
self,
prompt: str,
mode: str,
*,
previous_prompt: str | None = None,
base_prompt: str | None = None,
) -> str:
if not self.enabled: if not self.enabled:
return prompt return prompt
user_prompt = prompt.strip() user_prompt = self._sanitize(prompt)
for token in ("<|im_start|>", "<|im_end|>"):
user_prompt = user_prompt.replace(token, "")
if not user_prompt: if not user_prompt:
return prompt return prompt
previous = self._sanitize(previous_prompt or "")
base = self._sanitize(base_prompt or "")
if previous:
chat_prompt = self._refine_chat_prompt(
user_prompt,
mode,
previous_prompt=previous,
base_prompt=base or previous,
)
else:
chat_prompt = self._chat_prompt(user_prompt, mode)
workflow = copy.deepcopy(self._template) workflow = copy.deepcopy(self._template)
workflow[self._text_node_id]["inputs"]["prompt"] = self._chat_prompt( workflow[self._text_node_id]["inputs"]["prompt"] = chat_prompt
user_prompt, mode
)
prompt_id = await self._comfy.submit(workflow, uuid.uuid4().hex) prompt_id = await self._comfy.submit(workflow, uuid.uuid4().hex)
entry = await self._comfy.wait_for_history( entry = await self._comfy.wait_for_history(
prompt_id, prompt_id,
@ -108,6 +140,13 @@ class PromptEnhancer:
raise PromptEnhanceError("Не удалось очистить сгенерированный промпт") raise PromptEnhanceError("Не удалось очистить сгенерированный промпт")
return cleaned[: self._settings.max_prompt_length] return cleaned[: self._settings.max_prompt_length]
@staticmethod
def _sanitize(text: str) -> str:
value = text.strip()
for token in ("<|im_start|>", "<|im_end|>"):
value = value.replace(token, "")
return value
@staticmethod @staticmethod
def _chat_prompt(prompt: str, mode: str) -> str: def _chat_prompt(prompt: str, mode: str) -> str:
system = SYSTEM_PROMPTS.get(mode, SYSTEM_PROMPTS["t2i"]) system = SYSTEM_PROMPTS.get(mode, SYSTEM_PROMPTS["t2i"])
@ -117,6 +156,27 @@ class PromptEnhancer:
"<|im_start|>assistant\n" "<|im_start|>assistant\n"
) )
@staticmethod
def _refine_chat_prompt(
prompt: str,
mode: str,
*,
previous_prompt: str,
base_prompt: str,
) -> str:
system = REFINE_SYSTEM_PROMPTS.get(
mode, REFINE_SYSTEM_PROMPTS["t2i"]
)
return (
f"<|im_start|>system\n{system}<|im_end|>\n"
"<|im_start|>user\n"
f"Base scene: {base_prompt}\n"
f"Current prompt: {previous_prompt}\n"
f"Requested change: {prompt}\n"
"<|im_end|>\n"
"<|im_start|>assistant\n"
)
@staticmethod @staticmethod
def _clean(text: str) -> str: def _clean(text: str) -> str:
text = " ".join(text.split()).strip() text = " ".join(text.split()).strip()

View File

@ -112,6 +112,8 @@ class QueueManager:
is_admin: bool = False, is_admin: bool = False,
payment_charge_id: str | None = None, payment_charge_id: str | None = None,
is_premium: bool = False, is_premium: bool = False,
context_base_prompt: str | None = None,
context_prompt: str | None = None,
) -> tuple[Request, int]: ) -> tuple[Request, int]:
async with self._lock: async with self._lock:
if self._stopping: if self._stopping:
@ -148,6 +150,8 @@ class QueueManager:
seed=seed, seed=seed,
payment_charge_id=payment_charge_id, payment_charge_id=payment_charge_id,
is_premium=is_premium, is_premium=is_premium,
context_base_prompt=context_base_prompt,
context_prompt=context_prompt,
) )
target = self._premium if is_premium else self._normal target = self._premium if is_premium else self._normal
target.append(request.id) target.append(request.id)
@ -250,16 +254,31 @@ class QueueManager:
) )
try: try:
enhanced = await self._enhancer.enhance( enhanced = await self._enhancer.enhance(
request.prompt_text, request.mode request.prompt_text,
request.mode,
previous_prompt=request.context_prompt,
base_prompt=request.context_base_prompt,
) )
except Exception: except Exception:
logger.exception( logger.exception(
"Не удалось улучшить промпт запроса %s", request_id "Не удалось улучшить промпт запроса %s", request_id
) )
if request.context_prompt:
fallback = (
f"{request.context_prompt}. {request.prompt_text}"
)
await self._db.set_enhanced_prompt(
request_id, fallback
)
request = await self._refresh(request_id)
else: else:
if enhanced and enhanced != request.prompt_text: if enhanced and enhanced != request.prompt_text:
await self._db.set_enhanced_prompt(request_id, enhanced) await self._db.set_enhanced_prompt(request_id, enhanced)
request = await self._refresh(request_id) request = await self._refresh(request_id)
elif request.context_prompt:
combined = f"{request.context_prompt}. {request.prompt_text}"
await self._db.set_enhanced_prompt(request_id, combined)
request = await self._refresh(request_id)
workflow = await self._build_workflow(request) workflow = await self._build_workflow(request)
progress = await self._comfy.open_progress(client_id) progress = await self._comfy.open_progress(client_id)

View File

@ -18,6 +18,7 @@ TABLES = (
"blocked_requests", "blocked_requests",
"pending_payments", "pending_payments",
"users", "users",
"user_contexts",
"app_settings", "app_settings",
) )

View File

@ -273,6 +273,41 @@ async def test_user_credits_referral_and_daily_accrual(db: Database) -> None:
assert await db.accrue_daily_credits(future) == 0 assert await db.accrue_daily_credits(future) == 0
async def test_user_context_lifecycle(db: Database) -> None:
assert await db.get_user_context(1) is None
await db.save_user_context(
user_id=1,
base_prompt="A red apple",
current_prompt="A red apple on a table",
last_request_id=10,
last_mode="t2i",
)
context = await db.get_user_context(1)
assert context is not None
assert context.base_prompt == "A red apple"
assert context.current_prompt == "A red apple on a table"
assert context.last_request_id == 10
assert context.last_mode == "t2i"
await db.save_user_context(
user_id=1,
base_prompt="A red apple",
current_prompt="A red apple on a blue table",
last_request_id=11,
last_mode="t2i",
)
context = await db.get_user_context(1)
assert context is not None
assert context.base_prompt == "A red apple"
assert context.current_prompt == "A red apple on a blue table"
assert context.last_request_id == 11
assert await db.clear_user_context(1) is True
assert await db.get_user_context(1) is None
assert await db.clear_user_context(1) is False
async def test_ensure_user_updates_username_without_resetting_credits( async def test_ensure_user_updates_username_without_resetting_credits(
db: Database, db: Database,
) -> None: ) -> None:

View File

@ -62,6 +62,25 @@ async def test_enhance_i2i_prompt() -> None:
assert "image-editing prompt translator" in prompt assert "image-editing prompt translator" in prompt
async def test_enhance_with_context_uses_refine_prompt() -> None:
comfy = FakeEnhanceComfy("A blue apple on a wooden table, soft light")
enhancer = PromptEnhancer(comfy, make_settings())
result = await enhancer.enhance(
"Сделай яблоко синим",
"t2i",
previous_prompt="A red apple on a wooden table, soft light",
base_prompt="A red apple on a wooden table",
)
assert result == "A blue apple on a wooden table, soft light"
assert comfy.workflow is not None
prompt = comfy.workflow["2"]["inputs"]["prompt"]
assert "editing an existing image prompt" in prompt
assert "A red apple on a wooden table, soft light" in prompt
assert "Requested change: Сделай яблоко синим" in prompt
async def test_disabled_returns_original_without_comfy_call() -> None: async def test_disabled_returns_original_without_comfy_call() -> None:
comfy = FakeEnhanceComfy() comfy = FakeEnhanceComfy()
enhancer = PromptEnhancer(comfy, make_settings(enabled=False)) enhancer = PromptEnhancer(comfy, make_settings(enabled=False))

View File

@ -50,9 +50,18 @@ class FakeEnhancer:
def __init__(self) -> None: def __init__(self) -> None:
self.calls: list[tuple[str, str]] = [] self.calls: list[tuple[str, str]] = []
self.contexts: list[tuple[str | None, str | None]] = []
async def enhance(self, prompt: str, mode: str) -> str: async def enhance(
self,
prompt: str,
mode: str,
*,
previous_prompt: str | None = None,
base_prompt: str | None = None,
) -> str:
self.calls.append((prompt, mode)) self.calls.append((prompt, mode))
self.contexts.append((previous_prompt, base_prompt))
return f"enhanced: {prompt}" return f"enhanced: {prompt}"
@ -213,6 +222,40 @@ async def test_prompt_enhancer_is_used(db: Database, tmp_path: Path) -> None:
await queue.stop() await queue.stop()
async def test_context_is_passed_to_enhancer(
db: Database, tmp_path: Path
) -> None:
events: list[QueueEvent] = []
enhancer = FakeEnhancer()
queue = make_queue(
db, make_settings(tmp_path), events, enhancer=enhancer
)
await queue.start()
try:
request, _ = await queue.enqueue(
user_id=1,
mode="t2i",
prompt="make it blue",
context_base_prompt="A red apple on a table",
context_prompt="A red apple on a table, soft light",
)
await wait_for_event(events, "done")
assert enhancer.contexts == [
(
"A red apple on a table, soft light",
"A red apple on a table",
)
]
stored = await db.get_request(request.id)
assert stored is not None
assert stored.context_prompt == "A red apple on a table, soft light"
assert stored.context_base_prompt == "A red apple on a table"
finally:
await queue.stop()
async def test_blocked_prompt_is_logged(db: Database, tmp_path: Path) -> None: async def test_blocked_prompt_is_logged(db: Database, tmp_path: Path) -> None:
events: list[QueueEvent] = [] events: list[QueueEvent] = []
queue = make_queue(db, make_settings(tmp_path), events) queue = make_queue(db, make_settings(tmp_path), events)