feat: 添加新的模型,删除后端数据库
This commit is contained in:
+51
-109
@@ -11,16 +11,13 @@ import base64
|
||||
import logging
|
||||
import mimetypes
|
||||
import re
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
|
||||
from config import get_settings
|
||||
from db import get_session_factory
|
||||
from models import Product, ProductAsset, Suite, SuiteImage, SUITE_RUNNING, SUITE_DONE, SUITE_PARTIAL, SUITE_FAILED, STATUS_OK, STATUS_FAILED
|
||||
from services import storage
|
||||
from services.prompt import build_prompt, build_context, type_name, wrap_prompt_for_gpt_edits
|
||||
from services.tasks import Task, TaskImage, TASK_FAILED, TASK_RUNNING, TASK_DONE, TASK_PARTIAL, IMG_OK
|
||||
|
||||
log = logging.getLogger("suite.generator")
|
||||
|
||||
@@ -242,22 +239,25 @@ async def generate_tongyi(prompt: str, ref_images: list[str], size: str = "2048*
|
||||
# 可重试的状态码:中转限流/网关抖动(该中转限流时返回 Cloudflare 502 而非 429)
|
||||
RETRYABLE_STATUS = {429, 500, 502, 503, 504}
|
||||
|
||||
# 中转对 input_fidelity 参数的支持探测:None=未探测,True=支持,False=不支持(已降级)
|
||||
_rightapi_fidelity_supported: bool | None = None
|
||||
# 中转对 input_fidelity 参数的支持探测:按模型记忆不支持该参数的模型(gpt-image 系列支持,
|
||||
# nano-banana 系列可能不认;降级只影响触发过的模型,不牵连其他模型)
|
||||
_rightapi_fidelity_unsupported: set[str] = set()
|
||||
|
||||
|
||||
async def _rightapi_request(s, prompt: str, ref_images: list[str], size: str, model: str) -> bytes:
|
||||
"""gpt-image 系列:有参考图走 /v1/images/edits(multipart),无参考图走 /v1/images/generations。
|
||||
"""RightAPI 各模型:有参考图走 /v1/images/edits(multipart),无参考图走 /v1/images/generations。
|
||||
|
||||
OpenAI 兼容协议:响应固定 b64_json(不支持 response_format 参数,传了报 400);
|
||||
同步调用无任务轮询,高质量档单张 1-5 分钟,超时按文档建议兜底 600s。
|
||||
input_fidelity=high 强制高保真保留输入图细节(商品一致性关键参数,仅 edits 端点);
|
||||
中转若不认该参数(400),自动去掉重试并记住,后续请求不再带。
|
||||
input_fidelity=high 是 gpt-image-1 的 edits 保真参数(gpt-image-2 官方已移除、默认高保真,
|
||||
官逆通道更是不识别);带上是为了兼容按 gpt-image-1 语义实现的中转,中转不认(400)则按模型
|
||||
自动去掉重试并记住,该模型后续请求不再带。
|
||||
"""
|
||||
global _rightapi_fidelity_supported
|
||||
base = s.rightapi_base_url.rstrip("/")
|
||||
headers = {"Authorization": f"Bearer {s.rightapi_api_key}"}
|
||||
use_fidelity = bool(ref_images) and s.rightapi_input_fidelity and _rightapi_fidelity_supported is not False
|
||||
use_fidelity = (
|
||||
bool(ref_images) and s.rightapi_input_fidelity and model not in _rightapi_fidelity_unsupported
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=max(s.request_timeout, 600), verify=False) as client:
|
||||
common = {
|
||||
@@ -276,14 +276,12 @@ async def _rightapi_request(s, prompt: str, ref_images: list[str], size: str, mo
|
||||
data, mime = await _resolve_ref_bytes(u)
|
||||
files.append(("image[]", (f"ref-{i + 1}.{mime.split('/')[-1]}", data, mime)))
|
||||
resp = await client.post(f"{base}/v1/images/edits", headers=headers, files=files, data=common)
|
||||
# 中转不认 input_fidelity:去掉参数重试一次(仅一次探测)
|
||||
# 中转不认 input_fidelity:去掉参数重试一次(仅一次探测),降级只记到当前模型
|
||||
if resp.status_code == 400 and use_fidelity and "input_fidelity" in resp.text:
|
||||
_rightapi_fidelity_supported = False
|
||||
log.warning("RightAPI 不支持 input_fidelity 参数,已自动去掉并降级(后续请求不再带)")
|
||||
_rightapi_fidelity_unsupported.add(model)
|
||||
log.warning("RightAPI 模型 %s 不支持 input_fidelity 参数,已自动去掉并降级(该模型后续请求不再带)", model)
|
||||
common.pop("input_fidelity", None)
|
||||
resp = await client.post(f"{base}/v1/images/edits", headers=headers, files=files, data=common)
|
||||
elif resp.is_success and use_fidelity:
|
||||
_rightapi_fidelity_supported = True
|
||||
else:
|
||||
resp = await client.post(
|
||||
f"{base}/v1/images/generations",
|
||||
@@ -365,123 +363,67 @@ def _refs_for_job(images: list[dict], job: dict) -> list[str]:
|
||||
return _order_refs(pool, job.get("kind", ""))
|
||||
|
||||
|
||||
async def _select_ref_images(db, product_id: UUID, type_id: str) -> list[str]:
|
||||
"""商品路径:主图组前几张。转存完成的用本地文件,未完成的直接用源站 URL。"""
|
||||
assets = (await db.scalars(
|
||||
select(ProductAsset).where(
|
||||
ProductAsset.product_id == product_id,
|
||||
ProductAsset.group_key == "main",
|
||||
ProductAsset.type == "img",
|
||||
).order_by(ProductAsset.sort_order)
|
||||
)).all()
|
||||
refs = [a.stored_url or a.source_url for a in assets if (a.stored_url or a.source_url)]
|
||||
if not refs:
|
||||
raise RuntimeError("商品没有可用参考图(未采集主图)")
|
||||
return _order_refs(refs, type_id)
|
||||
# 串行生成队列:所有用户共享同一批 API key,并发生成会触发中转限流
|
||||
# (rightapi 同 key 分钟级冷却);同一时间只跑一个任务,其余保持 pending 排队。
|
||||
_GEN_LOCK = asyncio.Lock()
|
||||
|
||||
|
||||
async def run_suite(suite_id: str) -> None:
|
||||
"""后台执行套图任务:逐张生成 → 落盘 → 记录;单张失败不中断。
|
||||
|
||||
两条路径:
|
||||
- 无状态(product_id 为空):上下文与参考图来自请求自带的 context / ref_images
|
||||
- 商品路径(兼容旧流程):从 product + product_assets 取
|
||||
"""
|
||||
async def run_suite(task: Task) -> None:
|
||||
"""后台执行套图任务:排队 → 逐张生成 → 落盘 → 更新内存状态;单张失败不中断。"""
|
||||
settings = get_settings()
|
||||
async with get_session_factory()() as db:
|
||||
suite = await db.get(Suite, UUID(suite_id))
|
||||
if suite is None:
|
||||
return
|
||||
provider_name = task.provider or settings.image_provider
|
||||
generator = GENERATORS.get(provider_name)
|
||||
if generator is None:
|
||||
task.status = TASK_FAILED
|
||||
task.error = f"未知 provider: {provider_name}"
|
||||
return
|
||||
|
||||
product = None
|
||||
if suite.product_id:
|
||||
product = await db.get(Product, suite.product_id)
|
||||
if product is None:
|
||||
suite.status = SUITE_FAILED
|
||||
suite.error = "商品不存在"
|
||||
await db.commit()
|
||||
return
|
||||
|
||||
suite.status = SUITE_RUNNING
|
||||
await db.commit()
|
||||
|
||||
provider_name = suite.provider or settings.image_provider
|
||||
generator = GENERATORS.get(provider_name)
|
||||
if generator is None:
|
||||
suite.status = SUITE_FAILED
|
||||
suite.error = f"未知 provider: {provider_name}"
|
||||
await db.commit()
|
||||
return
|
||||
|
||||
raw = suite.context if not product else (product.raw or {})
|
||||
ctx = build_context(raw or {}, fallback_name=product.name if product else "")
|
||||
model = suite.model or {
|
||||
"tongyi": settings.dashscope_model,
|
||||
"rightapi": settings.rightapi_image_model,
|
||||
}.get(provider_name, settings.ark_image_model)
|
||||
is_wan = provider_name == "tongyi" and _is_wan_model(model)
|
||||
size = _image_size(provider_name, suite.ratio, is_wan=is_wan, model=model)
|
||||
|
||||
# 任务列表:方案(逐张)优先,旧路径按 types
|
||||
if suite.plan:
|
||||
jobs = [dict(j) for j in suite.plan]
|
||||
else:
|
||||
jobs = [
|
||||
{"kind": t, "title": type_name(t), "detail": "", "prompt_hint": "", "variant_name": None}
|
||||
for t in (suite.types or [])
|
||||
]
|
||||
ctx = build_context(task.context or {}, fallback_name="")
|
||||
model = task.model or {
|
||||
"tongyi": settings.dashscope_model,
|
||||
"rightapi": settings.rightapi_image_model,
|
||||
}.get(provider_name, settings.ark_image_model)
|
||||
is_wan = provider_name == "tongyi" and _is_wan_model(model)
|
||||
size = _image_size(provider_name, task.ratio, is_wan=is_wan, model=model)
|
||||
jobs = [dict(j) for j in task.plan]
|
||||
|
||||
async with _GEN_LOCK:
|
||||
task.status = TASK_RUNNING
|
||||
ok, failed = 0, 0
|
||||
failures: list[str] = []
|
||||
for job in jobs:
|
||||
type_id = job["kind"]
|
||||
image_row = SuiteImage(
|
||||
suite_id=suite.id,
|
||||
type_id=type_id,
|
||||
name=job.get("title") or type_name(type_id),
|
||||
status=STATUS_FAILED,
|
||||
)
|
||||
db.add(image_row)
|
||||
await db.flush()
|
||||
image = TaskImage(type_id=type_id, name=job.get("title") or type_name(type_id))
|
||||
task.images.append(image)
|
||||
try:
|
||||
prompt = build_prompt(
|
||||
type_id, ctx, suite.style_set, suite.lang,
|
||||
extra=job, style_prompt=suite.style_prompt, requirements=suite.requirements,
|
||||
type_id, ctx, task.style_set, task.lang,
|
||||
extra=job, style_prompt=task.style_prompt, requirements=task.requirements,
|
||||
)
|
||||
# gpt-image edits 语义:商品冻结契约前置,防止风格词改商品
|
||||
# gpt-image edits 语义:商品冻结契约前置(含商品文字锚定),防止风格词改商品
|
||||
if provider_name == "rightapi":
|
||||
prompt = wrap_prompt_for_gpt_edits(prompt)
|
||||
if product:
|
||||
refs = await _select_ref_images(db, product.id, type_id)
|
||||
else:
|
||||
refs = _refs_for_job(list(suite.ref_images or []), job)
|
||||
prompt = wrap_prompt_for_gpt_edits(prompt, ctx)
|
||||
refs = _refs_for_job(list(task.ref_images or []), job)
|
||||
data = await generator(prompt, refs, size=size, model=model)
|
||||
# 部分中转不遵守 output_format(要 jpeg 回 PNG),按魔数定扩展名
|
||||
ext = ".png" if data[:8] == b"\x89PNG\r\n\x1a\n" else ".jpg"
|
||||
key = storage.write_bytes(data, key_prefix=f"suites/{suite.id}", ext=ext)
|
||||
image_row.stored_url = storage.public_url(key)
|
||||
image_row.status = STATUS_OK
|
||||
key = storage.write_bytes(data, key_prefix=f"suites/{task.id}", ext=ext)
|
||||
image.url = storage.public_url(key)
|
||||
image.status = IMG_OK
|
||||
ok += 1
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.exception("套图 %s 类型 %s 生成失败", suite_id, type_id)
|
||||
err = str(exc)[:500]
|
||||
image_row.error = err
|
||||
failures.append(f"{job.get('title') or type_name(type_id)}:{err[:200]}")
|
||||
log.exception("套图 %s 类型 %s 生成失败", task.id, type_id)
|
||||
image.error = str(exc)[:500]
|
||||
failures.append(f"{job.get('title') or type_name(type_id)}:{str(exc)[:200]}")
|
||||
failed += 1
|
||||
await db.commit()
|
||||
|
||||
suite.status = SUITE_DONE if failed == 0 else (SUITE_PARTIAL if ok > 0 else SUITE_FAILED)
|
||||
task.status = TASK_DONE if failed == 0 else (TASK_PARTIAL if ok > 0 else TASK_FAILED)
|
||||
if failed:
|
||||
uniq = list(dict.fromkeys(failures)) # 去重保序
|
||||
detail = ";".join(uniq[:6])
|
||||
if len(uniq) > 6:
|
||||
detail += f";…等共 {failed} 张失败"
|
||||
if ok == 0:
|
||||
suite.error = f"全部生成失败。{detail}"
|
||||
task.error = f"全部生成失败。{detail}"
|
||||
else:
|
||||
suite.error = f"部分生成失败({failed} 张)。{detail}"
|
||||
from datetime import datetime, timezone
|
||||
suite.finished_at = datetime.now(timezone.utc)
|
||||
if product:
|
||||
product.stage = "generated" # 商品路径才有的阶段升级
|
||||
await db.commit()
|
||||
task.error = f"部分生成失败({failed} 张)。{detail}"
|
||||
|
||||
+47
-50
@@ -17,47 +17,28 @@ import re
|
||||
|
||||
STYLE_SETS: dict[int, dict] = {
|
||||
1: {
|
||||
"name": "高级质感大片",
|
||||
"tone": "高端电商大片质感,柔和的方向性棚拍光,背景带细腻的浅渐变,材质纹理清晰可见,"
|
||||
"色彩层次高级克制,商业画册级品质,构图干净、留白充足",
|
||||
"name": "北欧极简",
|
||||
"tone": "北欧极简风:浅灰或米白背景,柔和漫射光,低饱和色调,画面留白充足,构图克制干净",
|
||||
"bg": "",
|
||||
},
|
||||
2: {
|
||||
"name": "清新生活场景",
|
||||
"tone": "明亮通透的生活场景摄影,自然窗光,柔和的低饱和居家环境,浅景深虚化,"
|
||||
"真实自然的氛围感,绿植与暖色织物点缀,温馨有人气",
|
||||
"name": "清新明亮",
|
||||
"tone": "清新明亮风:明亮的白色到浅蓝渐变背景,高调光线,色彩明快通透,整体轻盈干净",
|
||||
"bg": "",
|
||||
},
|
||||
3: {
|
||||
"name": "极简白底规范",
|
||||
"tone": "极简棚拍风格,纯净无缝的浅色背景,柔和均匀的无影布光,以商品为中心的严谨构图,"
|
||||
"安静的高级感,画面只保留轻微的自然接触投影",
|
||||
"name": "高级感深色",
|
||||
"tone": "高级质感风:深灰或炭黑背景,戏剧性侧光打光,突出商品材质与光泽,沉稳高级",
|
||||
"bg": "",
|
||||
},
|
||||
4: {
|
||||
"name": "炫彩促销风",
|
||||
"tone": "高能量促销风格,高饱和度色块背景搭配动感几何图形,强对比,节日大促海报氛围,"
|
||||
"构图抢眼、视觉冲击力强",
|
||||
"name": "暖调生活",
|
||||
"tone": "温暖生活风:暖米色背景,暖色灯光氛围,温馨的家居质感,亲和力强",
|
||||
"bg": "",
|
||||
},
|
||||
5: {
|
||||
"name": "暗调轻奢",
|
||||
"tone": "暗调轻奢质感,深炭灰色背景,轮廓光勾勒商品边缘,材质细节丰富,带轻微雾感,"
|
||||
"如美术馆展陈般的呈现",
|
||||
"bg": "",
|
||||
},
|
||||
6: {
|
||||
"name": "俄式风情",
|
||||
"tone": "俄式风情电商大片,浓郁温暖的色调,红与金的传统配色点缀,冬日节庆氛围,"
|
||||
"深色木质与毛毡织物背景,如暖炉烛光般的柔和光晕,厚重扎实的质感,"
|
||||
"带一丝巴洛克式的华丽细节,适合俄语区市场",
|
||||
"bg": "",
|
||||
},
|
||||
7: {
|
||||
"name": "北欧极简",
|
||||
"tone": "北欧极简风格,白色与浅灰的原木空间,大量自然漫射光,干净利落的线条,"
|
||||
"浅色木质背景点缀少量绿植,克制的中性配色,画面通透轻盈,"
|
||||
"舒适宁静的氛围",
|
||||
"name": "纯净棚拍",
|
||||
"tone": "标准电商棚拍:纯色浅背景,均匀的正面柔光,无杂物干扰,商品居中突出",
|
||||
"bg": "",
|
||||
},
|
||||
}
|
||||
@@ -121,33 +102,49 @@ DEFAULT_NEGATIVE_INTENT = (
|
||||
# "主体参考"),风格词会被字面执行到商品上。按 OpenAI 官方提示词指南的编辑模式:
|
||||
# 按序号说明输入图、PRESERVE/MAY CHANGE 分列、首尾重申不变量、文案逐字渲染。
|
||||
|
||||
GPT_EDITS_CONTRACT = (
|
||||
"INPUT IMAGES: Image 1 (and Image 2 if present) are reference photos of ONE product "
|
||||
"from different angles. Use them ONLY as the source of the product's true appearance.\n"
|
||||
"PRESERVE (frozen, never change): the product itself — silhouette, proportions, colors, "
|
||||
"print/pattern (keep stripes / logos / labels exactly), materials, texture, stitching, "
|
||||
"hardware and every design detail. The product in the output must be the same physical "
|
||||
"item as in the input images, merely photographed in a new setting.\n"
|
||||
"MAY CHANGE: background, scene, props, camera angle, lighting, composition "
|
||||
"and in-image marketing typography.\n"
|
||||
"STYLE SCOPE: all style, mood, color-palette and decoration instructions below describe "
|
||||
"the SCENE AND BACKGROUND ONLY — never apply them to the product itself. "
|
||||
"Do not restyle, recolor, re-pattern or redecorate the product. "
|
||||
"You may relight the product so it sits naturally in the new scene "
|
||||
"(matched shadows and color temperature), but never change its design, colors or pattern."
|
||||
)
|
||||
def gpt_edits_contract(ctx: dict) -> str:
|
||||
"""gpt-image edits 语义契约(放开头,指令权重最高处)。
|
||||
|
||||
除通用锁定条款外,注入商品文字锚定(标题 + 关键参数 + 描述):
|
||||
官逆通道(gpt-image-2-vip 等)会把参考图当对话附件弱化处理,
|
||||
input_fidelity 类 API 参数不生效,此时商品文字描述是保真的唯一兜底。
|
||||
"""
|
||||
anchor = f" The product is: \"{ctx['title']}\""
|
||||
if ctx.get("params_line"):
|
||||
anchor += f" (key specs: {ctx['params_line']})"
|
||||
if ctx.get("desc"):
|
||||
anchor += f". {ctx['desc']}"
|
||||
return (
|
||||
"TASK: Edit the attached product photos — re-photograph THE SAME physical product "
|
||||
"in a new setting. This is an edit of the input images, NOT a new product design.\n"
|
||||
"INPUT IMAGES: Image 1 = product front view (PRIMARY source of truth for the product's "
|
||||
f"true appearance); Image 2 (if present) = product back / detail view.{anchor}\n"
|
||||
"PRODUCT LOCK (highest priority, overrides everything else in this prompt): exactly "
|
||||
"preserve the product's shape, silhouette, proportions, colors, label text, logos, "
|
||||
"print/pattern, materials and texture. Do not redesign, restyle, recolor, re-pattern "
|
||||
"or substitute the product with a similar one. The output must show the very product "
|
||||
"from the input images AND match the product description above; if the generated "
|
||||
"product differs from either in any design detail, the image is rejected.\n"
|
||||
"MAY CHANGE: background, scene, props, camera angle, lighting, composition "
|
||||
"and in-image marketing typography only. You may relight the product so it sits "
|
||||
"naturally in the new scene (matched shadows and color temperature).\n"
|
||||
"STYLE SCOPE: all style, mood, color-palette and decoration instructions below describe "
|
||||
"the SCENE AND BACKGROUND ONLY — never apply them to the product. "
|
||||
"When any style instruction conflicts with product fidelity, product fidelity always wins."
|
||||
)
|
||||
|
||||
|
||||
GPT_EDITS_FINAL_CHECK = (
|
||||
"FINAL CHECK before output: if the product in your result differs from the input product in any "
|
||||
"design detail (shape, color, pattern, material, logo), the image is rejected. "
|
||||
"Render any listed marketing copy / headlines exactly as written (verbatim, no extra characters, "
|
||||
"no paraphrasing)."
|
||||
"FINAL CHECK before output: if the product in your result differs from the input product "
|
||||
"or the product description above in any design detail (shape, color, pattern, material, "
|
||||
"logo), the image is rejected. Render any listed marketing copy / headlines exactly as "
|
||||
"written (verbatim, no extra characters, no paraphrasing)."
|
||||
)
|
||||
|
||||
|
||||
def wrap_prompt_for_gpt_edits(prompt: str) -> str:
|
||||
def wrap_prompt_for_gpt_edits(prompt: str, ctx: dict) -> str:
|
||||
"""gpt-image edits 语义适配:契约放开头(指令权重最高处),终检放结尾。"""
|
||||
return f"{GPT_EDITS_CONTRACT}\n\n{prompt}\n\n{GPT_EDITS_FINAL_CHECK}"
|
||||
return f"{gpt_edits_contract(ctx)}\n\n{prompt}\n\n{GPT_EDITS_FINAL_CHECK}"
|
||||
|
||||
# ── 商品上下文提炼 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""内存任务注册表:套图生成任务的生命周期与进程一致(重启即新会话)。
|
||||
|
||||
轮询/导出只服务「当前会话正在跟踪的任务」——前端没有历史记录功能,
|
||||
任务状态无需跨进程持久化;重启后轮询自然 404,前端提示任务已中断。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
# 任务状态
|
||||
TASK_PENDING = "pending"
|
||||
TASK_RUNNING = "running"
|
||||
TASK_DONE = "done"
|
||||
TASK_PARTIAL = "partial"
|
||||
TASK_FAILED = "failed"
|
||||
|
||||
# 任务内单张图状态
|
||||
IMG_OK = "ok"
|
||||
IMG_FAILED = "failed"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskImage:
|
||||
"""任务里单张生成图:完成一张追加一条(前端进度 x/y 依赖此语义)。"""
|
||||
|
||||
type_id: str
|
||||
name: str
|
||||
status: str = IMG_FAILED # 循环里先建后跑,成功后改为 ok
|
||||
url: str = ""
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Task:
|
||||
"""一次套图生成任务:轮询可见字段 + 仅供 run_suite 消费的执行参数。"""
|
||||
|
||||
id: str
|
||||
status: str = TASK_PENDING
|
||||
platform: str = "cn"
|
||||
lang: str = "zh"
|
||||
ratio: str = "1:1"
|
||||
style_set: int = 1
|
||||
style_prompt: str | None = None
|
||||
requirements: str | None = None
|
||||
provider: str = ""
|
||||
model: str | None = None
|
||||
total: int = 0 # 计划总张数(进度分母)
|
||||
images: list[TaskImage] = field(default_factory=list)
|
||||
error: str | None = None
|
||||
# ── 执行参数(不进轮询响应)──
|
||||
context: dict = field(default_factory=dict) # 采集文本素材(build_context 的输入)
|
||||
plan: list[dict] = field(default_factory=list) # 展开后的逐张任务
|
||||
ref_images: list[dict] = field(default_factory=list) # 参考图池(main 优先)
|
||||
|
||||
|
||||
# 进程内任务表:asyncio 单事件循环读写,无并发问题;不做淘汰(单会话量级很小)
|
||||
_TASKS: dict[str, Task] = {}
|
||||
|
||||
|
||||
def create_task(**kwargs) -> Task:
|
||||
task = Task(id=uuid.uuid4().hex, **kwargs)
|
||||
_TASKS[task.id] = task
|
||||
return task
|
||||
|
||||
|
||||
def get_task(task_id: str) -> Task | None:
|
||||
return _TASKS.get(task_id)
|
||||
Reference in New Issue
Block a user