diff --git a/video-gen-api/app/api/v1/recent_generation.py b/video-gen-api/app/api/v1/recent_generation.py new file mode 100644 index 00000000..f7b7550d --- /dev/null +++ b/video-gen-api/app/api/v1/recent_generation.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.dependencies import get_current_user, get_db +from app.enums.recent_generation import RecentGenerationModuleEnum +from app.models.user import User +from app.schemas.recent_generation import RecentGenerationGroupOut +from app.services.recent_generation_service import ( + DEFAULT_RECENT_GENERATION_LIMIT, + MAX_RECENT_GENERATION_LIMIT, + list_recent_generations, +) + +router = APIRouter( + prefix="/recent-generations", + tags=["recent-generations"], +) + + +@router.get( + "", + response_model=RecentGenerationGroupOut, + summary="获取当前用户各模块最近生成记录", + description=( + "获取当前登录用户在多个生成模块下最近生成成功的图片/视频记录。" + "返回结构固定为 project、chat_ai、hot_opening_replicate、shot_replicate 四个数组。" + "modules 不传时查询全部模块;modules 可重复传参指定一个或多个模块,例如 " + "?modules=project&modules=chat_ai。" + "limit 表示每个模块最多返回多少条,默认 5 条,最大 100 条。" + "接口只查询和返回展示所需轻量字段,不返回 prompt、engine_snapshot、provider_response_json 等大字段。" + ), + responses={ + 200: { + "description": "查询成功,固定返回四个模块数组;没有数据的模块返回空数组。", + "content": { + "application/json": { + "example": { + "project": [], + "chat_ai": [], + "hot_opening_replicate": [], + "shot_replicate": [ + { + "generated_time": "2026-06-26T14:30:00", + "result_url": "https://example.com/generate/video/demo.mp4?exp=1780000000&sign=xxxx", + "cover_url": "https://example.com/generate/cover/demo.jpg?exp=1780000000&sign=xxxx", + "module": "shot_replicate", + "shot_task_set_id": "0019ef0000000000001", + "shot_segment_id": "0019ef0000000000002", + "module_project_id": "0019ef0000000000003", + "module_step_id": "0019ef0000000000004", + "generation_id": "0019ef0000000000005", + "resource_type": "video", + } + ], + } + } + }, + }, + 401: {"description": "未登录或 Token 无效"}, + 403: {"description": "账号需要先设置登录密码或无权限"}, + 422: {"description": "参数校验失败,例如 limit 超出范围或 modules 枚举值非法"}, + }, +) +async def get_recent_generations( + limit: Annotated[ + int, + Query( + ge=1, + le=MAX_RECENT_GENERATION_LIMIT, + description=( + "每个模块返回的最近生成记录数量,默认 5,最大 100。" + "例如 limit=10 表示 project/chat_ai/hot_opening_replicate/shot_replicate 每个模块最多返回 10 条。" + ), + examples=[DEFAULT_RECENT_GENERATION_LIMIT], + ), + ] = DEFAULT_RECENT_GENERATION_LIMIT, + modules: Annotated[ + list[RecentGenerationModuleEnum] | None, + Query( + description=( + "模块枚举,可不传或重复传参。" + "不传表示查询全部模块。" + "可选值:" + "project=项目生成 GenerationRecord;" + "chat_ai=AI创作 ChatGenerationTask.generation_mode=chatapi_async;" + "hot_opening_replicate=爆款开头复刻 ChatGenerationTask.generation_mode=hot_opening_replicate;" + "shot_replicate=拆镜复刻 ChatGenerationTask.generation_mode=shot_replicate。" + ), + examples=[["project", "chat_ai"]], + ), + ] = None, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> RecentGenerationGroupOut: + return await list_recent_generations( + db, + user_id=current_user.id, + modules=modules, + limit=limit, + ) \ No newline at end of file diff --git a/video-gen-api/app/enums/recent_generation.py b/video-gen-api/app/enums/recent_generation.py new file mode 100644 index 00000000..c568e332 --- /dev/null +++ b/video-gen-api/app/enums/recent_generation.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from enum import StrEnum + +from app.enums.generation_task import ChatGenerationTaskStatus, GenerationMode + + +class RecentGenerationModuleEnum(StrEnum): + """最近生成记录接口支持的模块分组枚举。""" + + PROJECT = "project" + CHAT_AI = "chat_ai" + HOT_OPENING_REPLICATE = "hot_opening_replicate" + SHOT_REPLICATE = "shot_replicate" + + +class RecentGenerationResourceTypeEnum(StrEnum): + """最近生成记录接口返回的资源类型枚举。""" + + IMAGE = "image" + VIDEO = "video" + + +RECENT_GENERATION_ALL_MODULES: tuple[RecentGenerationModuleEnum, ...] = ( + RecentGenerationModuleEnum.PROJECT, + RecentGenerationModuleEnum.CHAT_AI, + RecentGenerationModuleEnum.HOT_OPENING_REPLICATE, + RecentGenerationModuleEnum.SHOT_REPLICATE, +) +"""最近生成记录接口默认查询的全部模块。""" + + +RECENT_GENERATION_CHAT_TASK_MODULES: tuple[RecentGenerationModuleEnum, ...] = ( + RecentGenerationModuleEnum.CHAT_AI, + RecentGenerationModuleEnum.HOT_OPENING_REPLICATE, + RecentGenerationModuleEnum.SHOT_REPLICATE, +) +"""来自 chat_generation_tasks 表的模块集合。""" + + +RECENT_GENERATION_MODULE_TO_TASK_MODE: dict[RecentGenerationModuleEnum, GenerationMode] = { + RecentGenerationModuleEnum.CHAT_AI: GenerationMode.CHATAPI_ASYNC, + RecentGenerationModuleEnum.HOT_OPENING_REPLICATE: GenerationMode.HOT_OPENING_REPLICATE, + RecentGenerationModuleEnum.SHOT_REPLICATE: GenerationMode.SHOT_REPLICATE, +} +"""最近生成记录模块枚举到 ChatGenerationTask.generation_mode 的映射。""" + + +RECENT_GENERATION_TASK_MODE_VALUE_TO_MODULE: dict[str, RecentGenerationModuleEnum] = { + task_mode.value: module + for module, task_mode in RECENT_GENERATION_MODULE_TO_TASK_MODE.items() +} +"""ChatGenerationTask.generation_mode 字符串值到最近生成记录模块枚举的映射。""" + + +RECENT_GENERATION_COMPLETED_STATUS = ChatGenerationTaskStatus.COMPLETED.value +"""最近生成记录只展示生成成功的数据,状态值与 ChatGenerationTaskStatus.COMPLETED 保持一致。""" \ No newline at end of file diff --git a/video-gen-api/app/schemas/recent_generation.py b/video-gen-api/app/schemas/recent_generation.py new file mode 100644 index 00000000..b33c7069 --- /dev/null +++ b/video-gen-api/app/schemas/recent_generation.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +from app.enums.recent_generation import RecentGenerationModuleEnum, RecentGenerationResourceTypeEnum +from app.schemas.common import NaiveDatetimeOptional + + +class RecentGenerationItemOut(BaseModel): + """最近生成记录响应项。""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "generated_time": "2026-06-26T14:30:00", + "result_url": "https://example.com/generate/video/demo.mp4?exp=1780000000&sign=xxxx", + "cover_url": "https://example.com/generate/cover/demo.jpg?exp=1780000000&sign=xxxx", + "module": "shot_replicate", + "shot_task_set_id": "0019ef0000000000001", + "shot_segment_id": "0019ef0000000000002", + "module_project_id": "0019ef0000000000003", + "module_step_id": "0019ef0000000000004", + "generation_id": "0019ef0000000000005", + "resource_type": "video", + } + } + ) + + generated_time: NaiveDatetimeOptional = Field( + None, + description="生成完成时间。优先使用 generated_at;历史数据 generated_at 为空时回退 updated_at,再回退 created_at。", + ) + result_url: str | None = Field( + None, + description="生成结果链接。resource_type=image 时为图片链接;resource_type=video 时为视频链接。返回前会按项目资源签名规则追加 exp/sign。", + ) + cover_url: str | None = Field( + None, + description="视频封面链接。仅视频资源通常有值;图片资源或历史无封面数据时返回 null。返回前会按项目资源签名规则追加 exp/sign。", + ) + module: RecentGenerationModuleEnum = Field( + ..., + description=( + "数据模块枚举:" + "project=项目生成 GenerationRecord;" + "chat_ai=AI创作 ChatGenerationTask.generation_mode=chatapi_async;" + "hot_opening_replicate=爆款开头复刻 ChatGenerationTask.generation_mode=hot_opening_replicate;" + "shot_replicate=拆镜复刻 ChatGenerationTask.generation_mode=shot_replicate。" + ), + ) + shot_task_set_id: str | None = Field( + None, + description="关联拆镜总任务ID。仅 shot_replicate 模块可能有值,来源 shot_replicate_segments.task_set_id;其他模块返回 null。", + ) + shot_segment_id: str | None = Field( + None, + description="关联拆镜片段ID。仅 shot_replicate 模块可能有值,来源 shot_replicate_segments.id;其他模块返回 null。", + ) + module_project_id: str | None = Field( + None, + description="通用模块项目ID。hot_opening_replicate/shot_replicate 模块可能有值,来源 module_generation_steps.project_id;project/chat_ai 返回 null。", + ) + module_step_id: str | None = Field( + None, + description="通用模块步骤ID。hot_opening_replicate/shot_replicate 模块可能有值,来源 module_generation_steps.id;project/chat_ai 返回 null。", + ) + generation_id: str = Field( + ..., + description="生成ID。project 模块为 generation_records.id;chat_ai/hot_opening_replicate/shot_replicate 模块为 chat_generation_tasks.id。", + ) + resource_type: RecentGenerationResourceTypeEnum = Field( + ..., + description="资源类型枚举:image=图片资源;video=视频资源。前端可据此决定预览组件。", + ) + + +class RecentGenerationGroupOut(BaseModel): + """最近生成记录固定分组响应。""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "project": [], + "chat_ai": [ + { + "generated_time": "2026-06-26T14:30:00", + "result_url": "https://example.com/generate/image/demo.png?exp=1780000000&sign=xxxx", + "cover_url": None, + "module": "chat_ai", + "shot_task_set_id": None, + "shot_segment_id": None, + "module_project_id": None, + "module_step_id": None, + "generation_id": "0019ef0000000000010", + "resource_type": "image", + } + ], + "hot_opening_replicate": [], + "shot_replicate": [], + } + } + ) + + project: list[RecentGenerationItemOut] = Field( + default_factory=list, + description="项目生成最近记录数组,来源 generation_records。未查询该模块或无数据时返回空数组。", + ) + chat_ai: list[RecentGenerationItemOut] = Field( + default_factory=list, + description="AI创作最近记录数组,来源 chat_generation_tasks,条件 generation_mode=chatapi_async。未查询该模块或无数据时返回空数组。", + ) + hot_opening_replicate: list[RecentGenerationItemOut] = Field( + default_factory=list, + description="爆款开头复刻最近记录数组,来源 chat_generation_tasks,条件 generation_mode=hot_opening_replicate。未查询该模块或无数据时返回空数组。", + ) + shot_replicate: list[RecentGenerationItemOut] = Field( + default_factory=list, + description="拆镜复刻最近记录数组,来源 chat_generation_tasks,条件 generation_mode=shot_replicate。未查询该模块或无数据时返回空数组。", + ) \ No newline at end of file diff --git a/video-gen-api/app/services/recent_generation_service.py b/video-gen-api/app/services/recent_generation_service.py new file mode 100644 index 00000000..ee27fdca --- /dev/null +++ b/video-gen-api/app/services/recent_generation_service.py @@ -0,0 +1,407 @@ +from __future__ import annotations + +from collections.abc import Iterable +from datetime import datetime +from typing import Any, TypedDict + +from sqlalchemy import and_, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.enums.generation_task import GenerationType +from app.enums.recent_generation import ( + RECENT_GENERATION_ALL_MODULES, + RECENT_GENERATION_CHAT_TASK_MODULES, + RECENT_GENERATION_COMPLETED_STATUS, + RECENT_GENERATION_MODULE_TO_TASK_MODE, + RECENT_GENERATION_TASK_MODE_VALUE_TO_MODULE, + RecentGenerationModuleEnum, + RecentGenerationResourceTypeEnum, +) +from app.models.chat_generation_task import ChatGenerationTask +from app.models.generation_record import GenerationRecord +from app.models.module_generation_step import ModuleGenerationStep +from app.models.shot_replicate_segment import ShotReplicateSegment +from app.schemas.recent_generation import RecentGenerationGroupOut, RecentGenerationItemOut +from app.services.resource_signed_url_service import build_resource_signed_url + +DEFAULT_RECENT_GENERATION_LIMIT = 5 +MAX_RECENT_GENERATION_LIMIT = 100 + + +class _StepLinkInfo(TypedDict): + module_project_id: str | None + module_step_id: str | None + module: str | None + + +class _ShotLinkInfo(TypedDict): + shot_task_set_id: str | None + shot_segment_id: str | None + + +def _normalize_limit(limit: int | None) -> int: + if limit is None: + return DEFAULT_RECENT_GENERATION_LIMIT + return min(max(int(limit), 1), MAX_RECENT_GENERATION_LIMIT) + + +def _normalize_modules( + modules: Iterable[RecentGenerationModuleEnum] | None, +) -> list[RecentGenerationModuleEnum]: + if not modules: + return list(RECENT_GENERATION_ALL_MODULES) + + normalized: list[RecentGenerationModuleEnum] = [] + seen: set[RecentGenerationModuleEnum] = set() + for module in modules: + module_enum = RecentGenerationModuleEnum(module) + if module_enum not in seen: + normalized.append(module_enum) + seen.add(module_enum) + return normalized + + +def _has_url(column) -> Any: + return and_(column.is_not(None), column != "") + + +def _detect_resource_type( + gen_type: str | None, + image_url: str | None, + video_url: str | None, +) -> RecentGenerationResourceTypeEnum: + gen_type_value = (gen_type or "").strip().lower() + + if gen_type_value == GenerationType.IMAGE.value and image_url: + return RecentGenerationResourceTypeEnum.IMAGE + if gen_type_value == GenerationType.VIDEO.value and video_url: + return RecentGenerationResourceTypeEnum.VIDEO + if video_url: + return RecentGenerationResourceTypeEnum.VIDEO + return RecentGenerationResourceTypeEnum.IMAGE + + +def _sign_url(url: str | None) -> str | None: + if not url: + return None + return build_resource_signed_url(url) + + +def _build_item( + *, + generation_id: str, + module: RecentGenerationModuleEnum, + gen_type: str | None, + image_url: str | None, + video_url: str | None, + video_cover_url: str | None, + generated_time: datetime | None, + step_info: _StepLinkInfo | None = None, + shot_info: _ShotLinkInfo | None = None, +) -> RecentGenerationItemOut: + resource_type = _detect_resource_type( + gen_type=gen_type, + image_url=image_url, + video_url=video_url, + ) + + raw_result_url = video_url if resource_type == RecentGenerationResourceTypeEnum.VIDEO else image_url + raw_cover_url = video_cover_url if resource_type == RecentGenerationResourceTypeEnum.VIDEO else None + + return RecentGenerationItemOut( + generated_time=generated_time, + result_url=_sign_url(raw_result_url), + cover_url=_sign_url(raw_cover_url), + module=module, + shot_task_set_id=shot_info["shot_task_set_id"] if shot_info else None, + shot_segment_id=shot_info["shot_segment_id"] if shot_info else None, + module_project_id=step_info["module_project_id"] if step_info else None, + module_step_id=step_info["module_step_id"] if step_info else None, + generation_id=generation_id, + resource_type=resource_type, + ) + + +async def _list_project_recent_items( + db: AsyncSession, + *, + user_id: str, + limit: int, +) -> list[RecentGenerationItemOut]: + generated_time_expr = func.coalesce( + GenerationRecord.generated_at, + GenerationRecord.updated_at, + GenerationRecord.created_at, + ).label("generated_time") + + stmt = ( + select( + GenerationRecord.id.label("generation_id"), + GenerationRecord.gen_type.label("gen_type"), + GenerationRecord.image_url.label("image_url"), + GenerationRecord.video_url.label("video_url"), + GenerationRecord.video_cover_url.label("video_cover_url"), + generated_time_expr, + ) + .where( + GenerationRecord.user_id == user_id, + GenerationRecord.deleted_at.is_(None), + GenerationRecord.status == RECENT_GENERATION_COMPLETED_STATUS, + or_(_has_url(GenerationRecord.image_url), _has_url(GenerationRecord.video_url)), + ) + .order_by(generated_time_expr.desc(), GenerationRecord.created_at.desc()) + .limit(limit) + ) + + rows = (await db.execute(stmt)).mappings().all() + return [ + _build_item( + generation_id=row["generation_id"], + module=RecentGenerationModuleEnum.PROJECT, + gen_type=row["gen_type"], + image_url=row["image_url"], + video_url=row["video_url"], + video_cover_url=row["video_cover_url"], + generated_time=row["generated_time"], + ) + for row in rows + ] + + +async def _list_chat_task_recent_rows( + db: AsyncSession, + *, + user_id: str, + modules: list[RecentGenerationModuleEnum], + limit: int, +) -> list[dict[str, Any]]: + task_mode_values = [ + RECENT_GENERATION_MODULE_TO_TASK_MODE[module].value + for module in modules + if module in RECENT_GENERATION_CHAT_TASK_MODULES + ] + if not task_mode_values: + return [] + + generated_time_expr = func.coalesce( + ChatGenerationTask.generated_at, + ChatGenerationTask.updated_at, + ChatGenerationTask.created_at, + ) + + ranked_subquery = ( + select( + ChatGenerationTask.id.label("generation_id"), + ChatGenerationTask.generation_mode.label("generation_mode"), + ChatGenerationTask.gen_type.label("gen_type"), + ChatGenerationTask.image_url.label("image_url"), + ChatGenerationTask.video_url.label("video_url"), + ChatGenerationTask.video_cover_url.label("video_cover_url"), + generated_time_expr.label("generated_time"), + func.row_number() + .over( + partition_by=ChatGenerationTask.generation_mode, + order_by=(generated_time_expr.desc(), ChatGenerationTask.created_at.desc()), + ) + .label("row_num"), + ) + .where( + ChatGenerationTask.user_id == user_id, + ChatGenerationTask.deleted_at.is_(None), + ChatGenerationTask.status == RECENT_GENERATION_COMPLETED_STATUS, + ChatGenerationTask.generation_mode.in_(task_mode_values), + or_(_has_url(ChatGenerationTask.image_url), _has_url(ChatGenerationTask.video_url)), + ) + .subquery() + ) + + stmt = ( + select(ranked_subquery) + .where(ranked_subquery.c.row_num <= limit) + .order_by(ranked_subquery.c.generation_mode.asc(), ranked_subquery.c.generated_time.desc()) + ) + + return [dict(row) for row in (await db.execute(stmt)).mappings().all()] + + +async def _load_step_link_map( + db: AsyncSession, + *, + chat_task_ids: list[str], +) -> dict[str, _StepLinkInfo]: + if not chat_task_ids: + return {} + + stmt = ( + select( + ModuleGenerationStep.chat_task_id.label("chat_task_id"), + ModuleGenerationStep.id.label("module_step_id"), + ModuleGenerationStep.project_id.label("module_project_id"), + ModuleGenerationStep.module.label("module"), + ModuleGenerationStep.is_current.label("is_current"), + ModuleGenerationStep.updated_at.label("updated_at"), + ) + .where( + ModuleGenerationStep.deleted_at.is_(None), + ModuleGenerationStep.chat_task_id.in_(chat_task_ids), + ModuleGenerationStep.module.in_( + [ + RecentGenerationModuleEnum.HOT_OPENING_REPLICATE.value, + RecentGenerationModuleEnum.SHOT_REPLICATE.value, + ] + ), + ) + .order_by( + ModuleGenerationStep.chat_task_id.asc(), + ModuleGenerationStep.is_current.desc(), + ModuleGenerationStep.updated_at.desc(), + ) + ) + + link_map: dict[str, _StepLinkInfo] = {} + rows = (await db.execute(stmt)).mappings().all() + for row in rows: + chat_task_id = row["chat_task_id"] + if not chat_task_id or chat_task_id in link_map: + continue + link_map[chat_task_id] = { + "module_project_id": row["module_project_id"], + "module_step_id": row["module_step_id"], + "module": row["module"], + } + return link_map + + +async def _load_shot_link_map( + db: AsyncSession, + *, + module_project_ids: list[str], +) -> dict[str, _ShotLinkInfo]: + if not module_project_ids: + return {} + + stmt = ( + select( + ShotReplicateSegment.module_project_id.label("module_project_id"), + ShotReplicateSegment.id.label("shot_segment_id"), + ShotReplicateSegment.task_set_id.label("shot_task_set_id"), + ) + .where( + ShotReplicateSegment.deleted_at.is_(None), + ShotReplicateSegment.module_project_id.in_(module_project_ids), + ) + .order_by(ShotReplicateSegment.updated_at.desc()) + ) + + link_map: dict[str, _ShotLinkInfo] = {} + rows = (await db.execute(stmt)).mappings().all() + for row in rows: + module_project_id = row["module_project_id"] + if not module_project_id or module_project_id in link_map: + continue + link_map[module_project_id] = { + "shot_task_set_id": row["shot_task_set_id"], + "shot_segment_id": row["shot_segment_id"], + } + return link_map + + +async def _build_chat_task_group_items( + db: AsyncSession, + *, + rows: list[dict[str, Any]], +) -> dict[RecentGenerationModuleEnum, list[RecentGenerationItemOut]]: + grouped: dict[RecentGenerationModuleEnum, list[RecentGenerationItemOut]] = { + RecentGenerationModuleEnum.CHAT_AI: [], + RecentGenerationModuleEnum.HOT_OPENING_REPLICATE: [], + RecentGenerationModuleEnum.SHOT_REPLICATE: [], + } + if not rows: + return grouped + + module_task_rows: list[dict[str, Any]] = [] + module_chat_task_ids: list[str] = [] + for row in rows: + module = RECENT_GENERATION_TASK_MODE_VALUE_TO_MODULE.get(row["generation_mode"]) + if module in ( + RecentGenerationModuleEnum.HOT_OPENING_REPLICATE, + RecentGenerationModuleEnum.SHOT_REPLICATE, + ): + module_task_rows.append(row) + module_chat_task_ids.append(row["generation_id"]) + + step_link_map = await _load_step_link_map(db, chat_task_ids=module_chat_task_ids) + + shot_project_ids = [ + step_info["module_project_id"] + for row in module_task_rows + if (step_info := step_link_map.get(row["generation_id"])) + and step_info.get("module") == RecentGenerationModuleEnum.SHOT_REPLICATE.value + and step_info.get("module_project_id") + ] + shot_link_map = await _load_shot_link_map(db, module_project_ids=shot_project_ids) + + for row in rows: + module = RECENT_GENERATION_TASK_MODE_VALUE_TO_MODULE.get(row["generation_mode"]) + if not module: + continue + + step_info = step_link_map.get(row["generation_id"]) + shot_info = None + if module == RecentGenerationModuleEnum.SHOT_REPLICATE and step_info: + module_project_id = step_info.get("module_project_id") + if module_project_id: + shot_info = shot_link_map.get(module_project_id) + + grouped[module].append( + _build_item( + generation_id=row["generation_id"], + module=module, + gen_type=row["gen_type"], + image_url=row["image_url"], + video_url=row["video_url"], + video_cover_url=row["video_cover_url"], + generated_time=row["generated_time"], + step_info=step_info, + shot_info=shot_info, + ) + ) + + return grouped + + +async def list_recent_generations( + db: AsyncSession, + *, + user_id: str, + modules: Iterable[RecentGenerationModuleEnum] | None = None, + limit: int | None = None, +) -> RecentGenerationGroupOut: + """获取当前用户各模块最近生成成功的图片/视频记录。""" + + normalized_limit = _normalize_limit(limit) + normalized_modules = _normalize_modules(modules) + + response = RecentGenerationGroupOut() + + if RecentGenerationModuleEnum.PROJECT in normalized_modules: + response.project = await _list_project_recent_items( + db, + user_id=user_id, + limit=normalized_limit, + ) + + chat_modules = [module for module in normalized_modules if module in RECENT_GENERATION_CHAT_TASK_MODULES] + if chat_modules: + chat_rows = await _list_chat_task_recent_rows( + db, + user_id=user_id, + modules=chat_modules, + limit=normalized_limit, + ) + chat_grouped = await _build_chat_task_group_items(db, rows=chat_rows) + response.chat_ai = chat_grouped[RecentGenerationModuleEnum.CHAT_AI] + response.hot_opening_replicate = chat_grouped[RecentGenerationModuleEnum.HOT_OPENING_REPLICATE] + response.shot_replicate = chat_grouped[RecentGenerationModuleEnum.SHOT_REPLICATE] + + return response \ No newline at end of file