diff --git a/README.md b/README.md index fffd020..916980b 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ **岁月时书 (Life Echo)** 是一个创新的回忆录生成平台,通过 AI 智能对话引导用户回顾人生历程,并将口语对话自动整理为结构化的回忆录章节,最终生成精美的 PDF 电子书。 +后端侧:会话轮次以 DB `conversation_messages` 为真源、Redis 为缓存;实时对话编排统一走 `ChatOrchestrator`;图像任务为 `generate_story_image`(正文)与 `generate_chapter_cover`(章节封面)。详见 [api/README.md](api/README.md)。 + ### 核心功能 - 🎙️ **实时语音对话** - 基于 WebSocket 的实时双向语音交互 @@ -252,4 +254,3 @@ MIT License --- **岁月时书** - 让每一段人生故事都被温柔记录 ✨ - diff --git a/api/.env.example b/api/.env.example index acdbd2b..8dacf38 100644 --- a/api/.env.example +++ b/api/.env.example @@ -4,6 +4,11 @@ # 不要把真实密钥提交到仓库 # ============================================================================= +# ============================================================================= +# Logging(loguru sink 最低级别:TRACE / DEBUG / INFO / WARNING / ERROR / CRITICAL) +# ============================================================================= +LOG_LEVEL=INFO + # ============================================================================= # LLM / DeepSeek # ============================================================================= diff --git a/api/.env.production b/api/.env.production index 2ab878a..d8ff6e8 100644 --- a/api/.env.production +++ b/api/.env.production @@ -3,6 +3,11 @@ DEEPSEEK_API_KEY=sk-09f17fb61c5a4299a3afc2a01de7af75 DEEPSEEK_BASE_URL=https://api.deepseek.com DEEPSEEK_MODEL=deepseek-chat +# ============================================================================= +# Logging(loguru sink 最低级别:TRACE / DEBUG / INFO / WARNING / ERROR / CRITICAL) +# ============================================================================= +LOG_LEVEL=INFO + # ============================================================================= # 数据库配置(必需) # ============================================================================= diff --git a/api/README.md b/api/README.md index 8d0d79d..1cca388 100644 --- a/api/README.md +++ b/api/README.md @@ -6,6 +6,12 @@ Life Echo 后端服务,基于 FastAPI 构建的实时语音对话回忆录生 Life Echo API 是一个智能对话系统,通过 WebSocket 实时连接,使用 LangChain Agent 引导用户进行回忆录访谈对话,并将口语内容自动整理为结构化的书面章节,最终生成回忆录 PDF。 +### 架构要点(多 Agent 收敛) + +- **会话真源**:`conversation_messages`(DB)+ Redis 缓存;**实时编排入口**:`ChatOrchestrator`。 +- **图像管线**:正文主图 `generate_story_image`;章节封面 `try_enqueue_generate_chapter_cover` → `generate_chapter_cover`。 +- **回忆录批次**:`MemoirOrchestrator.prepare_batches` 显式分桶后,`process_memoir_segments` 按类别加锁并调用 `run_story_pipeline_for_category_batch`(含 `StoryRouteAgent.plan_batch` 多 unit 写入)。 + ## 技术栈 - **Web 框架**: FastAPI 0.115.0 diff --git a/api/alembic/versions/0001_initial_schema.py b/api/alembic/versions/0001_initial_schema.py index 82fc6dd..c3a032d 100644 --- a/api/alembic/versions/0001_initial_schema.py +++ b/api/alembic/versions/0001_initial_schema.py @@ -7,6 +7,7 @@ chapters 含 story 物化字段:markdown_compose_dirty、markdown_composed_at 已并入原 0002(stories-first:无 chapter_sections / memoir_images.section_id)与原 0003(segments.tts_audio_urls) 的语义:新库仅由当前 ORM 建表即可,无需后续 ALTER。 +conversation_messages(会话轮次 durable log)由 app.features.conversation.models.ConversationMessage 一并 create_all。 segments.audio_duration_seconds(语音条时长秒数,历史 API / Redis 回填)由 ORM 一并 create_all,无独立迁移。 story_image_intents 无 source_span(主图回填在正文末尾,意图仅存 caption / prompt_brief 等)。 diff --git a/api/app/agents/__init__.py b/api/app/agents/__init__.py index 37da606..cebd788 100644 --- a/api/app/agents/__init__.py +++ b/api/app/agents/__init__.py @@ -4,20 +4,15 @@ Agent 模块(按功能拆分:chat / memoir / image_prompt) from app.agents.chat import ( ChatOrchestrator, - ConversationAgent, InterviewAgent, ProfileAgent, ) from app.agents.image_prompt import ImagePromptOrchestrator, PromptGenerationAgent -from app.agents.memoir import BackgroundTaskRunner, MemoryAgent __all__ = [ - "ConversationAgent", - "MemoryAgent", "ChatOrchestrator", "ProfileAgent", "InterviewAgent", - "BackgroundTaskRunner", "ImagePromptOrchestrator", "PromptGenerationAgent", ] diff --git a/api/app/agents/chat/__init__.py b/api/app/agents/chat/__init__.py index 4510af6..a739ba4 100644 --- a/api/app/agents/chat/__init__.py +++ b/api/app/agents/chat/__init__.py @@ -1,12 +1,10 @@ """聊天模块:AI 回复用户(ProfileAgent + InterviewAgent + ChatOrchestrator)""" -from app.agents.chat.conversation_agent import ConversationAgent +from app.agents.chat.interview_agent import InterviewAgent from app.agents.chat.orchestrator import ChatOrchestrator from app.agents.chat.profile_agent import ProfileAgent -from app.agents.chat.interview_agent import InterviewAgent __all__ = [ - "ConversationAgent", "ChatOrchestrator", "ProfileAgent", "InterviewAgent", diff --git a/api/app/agents/chat/conversation_agent.py b/api/app/agents/chat/conversation_agent.py deleted file mode 100644 index 18a30eb..0000000 --- a/api/app/agents/chat/conversation_agent.py +++ /dev/null @@ -1,147 +0,0 @@ -""" -对话 Agent:Facade,内部委托 ChatOrchestrator + ProfileAgent + InterviewAgent -保留原有对外 API,供 router 等调用方兼容使用 -""" - -from datetime import datetime -from typing import Any, Dict, List, Optional - -from app.agents.chat.agent_turn import AgentChatTurn -from app.agents.chat.orchestrator import ChatOrchestrator -from app.agents.chat.prompts_conversation import ConversationStage -from app.agents.state_schema import MemoirStateSchema -from app.core.redis import redis_service - - -class ConversationAgent: - """对话 Agent Facade,委托 ChatOrchestrator 实现多 Agent 协同""" - - def __init__(self): - self._orchestrator = ChatOrchestrator() - - async def extract_profile_from_message( - self, - user_message: str, - missing_fields: List[str], - conversation_id: Optional[str] = None, - ) -> Dict[str, Any]: - """委托 ChatOrchestrator/ProfileAgent 提取资料""" - return await self._orchestrator.extract_profile_from_message( - user_message, missing_fields, conversation_id=conversation_id - ) - - async def generate_profile_followup( - self, - conversation_id: str, - user_message: str, - missing_fields: List[str], - filled_fields: Dict[str, str], - nickname: str = "", - is_from_voice: bool = False, - voice_session_id: str | None = None, - user_message_timestamp: datetime | None = None, - audio_duration_seconds: int | None = None, - ) -> List[str]: - """委托 ChatOrchestrator/ProfileAgent 生成资料追问""" - return await self._orchestrator.generate_profile_followup( - conversation_id=conversation_id, - user_message=user_message, - missing_fields=missing_fields, - filled_fields=filled_fields, - nickname=nickname, - is_from_voice=is_from_voice, - voice_session_id=voice_session_id, - user_message_timestamp=user_message_timestamp, - audio_duration_seconds=audio_duration_seconds, - ) - - async def generate_profile_greeting( - self, - conversation_id: str, - missing_fields: List[str], - nickname: str = "", - ) -> List[str]: - """委托 ChatOrchestrator/ProfileAgent 生成资料收集开场白""" - return await self._orchestrator.generate_profile_greeting( - conversation_id=conversation_id, - missing_fields=missing_fields, - nickname=nickname, - ) - - async def generate_response_with_state( - self, - conversation_id: str, - user_message: str, - memoir_state: MemoirStateSchema, - user_profile_context: str = "", - is_from_voice: bool = False, - voice_session_id: str | None = None, - user_message_timestamp: datetime | None = None, - audio_duration_seconds: int | None = None, - ) -> AgentChatTurn: - """委托 ChatOrchestrator/InterviewAgent 生成访谈回复""" - return await self._orchestrator.generate_response_with_state( - conversation_id=conversation_id, - user_message=user_message, - memoir_state=memoir_state, - user_profile_context=user_profile_context, - is_from_voice=is_from_voice, - voice_session_id=voice_session_id, - user_message_timestamp=user_message_timestamp, - audio_duration_seconds=audio_duration_seconds, - ) - - async def generate_opening_message( - self, - conversation_id: str, - memoir_state: MemoirStateSchema, - user_profile_context: str = "", - ) -> List[str]: - """委托 ChatOrchestrator/InterviewAgent 生成开场白""" - return await self._orchestrator.generate_opening_message( - conversation_id=conversation_id, - memoir_state=memoir_state, - user_profile_context=user_profile_context, - ) - - async def generate_response( - self, - conversation_id: str, - user_message: str, - current_stage: Optional[ConversationStage] = None, - covered_topics: Optional[List[str]] = None, - ) -> str: - """兼容旧 API:生成简单回复(无状态感知),委托 InterviewAgent 的等价逻辑""" - from app.agents.state_schema import default_state - - state = default_state() - state.current_stage = (current_stage or ConversationStage.CHILDHOOD).value - state.covered_stages = covered_topics or [] - turn = await self._orchestrator.generate_response_with_state( - conversation_id=conversation_id, - user_message=user_message, - memoir_state=state, - user_profile_context="", - ) - return turn.messages[0] if turn.messages else "" - - def detect_stage( - self, conversation_id: str, user_message: str - ) -> ConversationStage: - """根据关键词检测用户阶段(兼容 API)""" - detected = self._orchestrator.detect_user_stage(user_message) - if detected == "childhood": - return ConversationStage.CHILDHOOD - if detected == "education": - return ConversationStage.EDUCATION - if detected == "career": - return ConversationStage.CAREER - if detected == "family": - return ConversationStage.FAMILY - if detected == "belief": - return ConversationStage.BELIEFS - return ConversationStage.CHILDHOOD - - async def clear_memory(self, conversation_id: str) -> None: - """清除 Redis 中的对话历史""" - await redis_service.clear_conversation_history(conversation_id) diff --git a/api/app/agents/chat/orchestrator.py b/api/app/agents/chat/orchestrator.py index 9d20320..cbc3292 100644 --- a/api/app/agents/chat/orchestrator.py +++ b/api/app/agents/chat/orchestrator.py @@ -1,6 +1,6 @@ """ ChatOrchestrator:AI 回复用户模块的编排层 -负责路由(Profile vs Interview)、调用 Specialist Agent、统一 Redis 持久化与错误处理 +负责路由(Profile vs Interview)、调用 Specialist Agent;持久化由 feature 层 ConversationHistoryStore 完成。 """ from datetime import datetime @@ -9,7 +9,6 @@ from typing import TYPE_CHECKING, List, Optional from sqlalchemy.ext.asyncio import AsyncSession from app.agents.chat.agent_turn import AgentChatTurn -from app.agents.chat.helpers import save_message from app.agents.chat.interview_agent import InterviewAgent from app.agents.chat.profile_agent import ProfileAgent from app.agents.state_schema import MemoirStateSchema @@ -28,8 +27,8 @@ _UNAUTH_TURN = AgentChatTurn( class ChatOrchestrator: """ - 聊天编排器:根据用户资料完成度路由到 ProfileAgent 或 InterviewAgent, - 统一管理 Redis 写入。 + 聊天编排器:根据用户资料完成度路由到 ProfileAgent 或 InterviewAgent。 + 不直接写入 Redis/DB;由 WS pipeline / ConversationHistoryStore 落库并同步缓存。 """ def __init__(self): @@ -53,8 +52,7 @@ class ChatOrchestrator: ) -> AgentChatTurn: """ 处理用户消息,返回 AI 回复(分段 + 是否跳过 TTS)。 - 根据 missing_fields 路由到 ProfileAgent 或 InterviewAgent, - 统一写入 Redis。 + 根据 missing_fields 路由到 ProfileAgent 或 InterviewAgent。 """ # --- 资料收集模式 --- @@ -77,15 +75,6 @@ class ChatOrchestrator: filled_fields=filled, nickname=user.nickname or "", ) - await self._save_messages( - conversation_id=conversation_id, - user_message=user_message, - response_text="\n\n".join(responses), - is_from_voice=is_from_voice, - voice_session_id=voice_session_id, - user_message_timestamp=user_message_timestamp, - audio_duration_seconds=audio_duration_seconds, - ) return AgentChatTurn(messages=responses, skip_tts=False) except Exception as e: logger.error(f"资料收集处理失败: {e}", exc_info=True) @@ -111,52 +100,12 @@ class ChatOrchestrator: occupation=user.occupation, ) - turn = await self.interview_agent.generate_response_with_state( + return await self.interview_agent.generate_response_with_state( conversation_id=conversation_id, user_message=user_message, memoir_state=state, user_profile_context=user_profile_context, ) - await self._save_messages( - conversation_id=conversation_id, - user_message=user_message, - response_text="\n\n".join(turn.messages), - is_from_voice=is_from_voice, - voice_session_id=voice_session_id, - user_message_timestamp=user_message_timestamp, - audio_duration_seconds=audio_duration_seconds, - ) - return turn - - async def _save_messages( - self, - conversation_id: str, - user_message: str, - response_text: str, - is_from_voice: bool = False, - voice_session_id: Optional[str] = None, - user_message_timestamp: Optional[datetime] = None, - audio_duration_seconds: Optional[int] = None, - ) -> None: - """统一写入 Human + AI 消息到 Redis""" - human_msg_type = "audio" if is_from_voice else "text" - human_duration = ( - audio_duration_seconds - if is_from_voice - and audio_duration_seconds is not None - and audio_duration_seconds > 0 - else None - ) - await save_message( - conversation_id, - "human", - user_message, - message_type=human_msg_type, - voice_session_id=voice_session_id, - timestamp=user_message_timestamp, - audio_duration_seconds=human_duration, - ) - await save_message(conversation_id, "ai", response_text) async def extract_profile_from_message( self, @@ -181,25 +130,14 @@ class ChatOrchestrator: user_message_timestamp: datetime | None = None, audio_duration_seconds: int | None = None, ) -> List[str]: - """委托 ProfileAgent 生成资料追问,并写入 Redis""" - responses = await self.profile_agent.generate_profile_followup( + """委托 ProfileAgent 生成资料追问(持久化由调用方负责)。""" + return await self.profile_agent.generate_profile_followup( conversation_id=conversation_id, user_message=user_message, missing_fields=missing_fields, filled_fields=filled_fields, nickname=nickname, ) - response_text = "\n\n".join(responses) - await self._save_messages( - conversation_id=conversation_id, - user_message=user_message, - response_text=response_text, - is_from_voice=is_from_voice, - voice_session_id=voice_session_id, - user_message_timestamp=user_message_timestamp, - audio_duration_seconds=audio_duration_seconds, - ) - return responses async def generate_profile_greeting( self, @@ -207,15 +145,12 @@ class ChatOrchestrator: missing_fields: List[str], nickname: str = "", ) -> List[str]: - """委托 ProfileAgent 生成资料收集开场白,并写入 Redis""" - responses = await self.profile_agent.generate_profile_greeting( + """委托 ProfileAgent 生成资料收集开场白(持久化由调用方负责)。""" + return await self.profile_agent.generate_profile_greeting( conversation_id=conversation_id, missing_fields=missing_fields, nickname=nickname, ) - response_text = "\n\n".join(responses) - await save_message(conversation_id, "ai", response_text) - return responses async def generate_response_with_state( self, @@ -228,24 +163,13 @@ class ChatOrchestrator: user_message_timestamp: datetime | None = None, audio_duration_seconds: int | None = None, ) -> AgentChatTurn: - """委托 InterviewAgent 生成访谈回复,并写入 Redis""" - turn = await self.interview_agent.generate_response_with_state( + """委托 InterviewAgent 生成访谈回复(持久化由调用方负责)。""" + return await self.interview_agent.generate_response_with_state( conversation_id=conversation_id, user_message=user_message, memoir_state=memoir_state, user_profile_context=user_profile_context, ) - response_text = "\n\n".join(turn.messages) - await self._save_messages( - conversation_id=conversation_id, - user_message=user_message, - response_text=response_text, - is_from_voice=is_from_voice, - voice_session_id=voice_session_id, - user_message_timestamp=user_message_timestamp, - audio_duration_seconds=audio_duration_seconds, - ) - return turn def detect_user_stage(self, user_message: str) -> str: """委托 InterviewAgent 检测用户阶段""" @@ -258,16 +182,10 @@ class ChatOrchestrator: user_profile_context: str = "", ) -> List[str]: """ - 委托 InterviewAgent 生成访谈开场白,并写入 Redis。 - - 调用方(如 WS)须在「空会话」分支前通过 ConversationService 从 DB 回填 Redis, - 避免与多 Agent 契约混淆:本编排器不读取 segments,只假定 Redis 已反映是否已有轮次。 + 委托 InterviewAgent 生成访谈开场白(持久化由调用方 ConversationHistoryStore 负责)。 """ - responses = await self.interview_agent.generate_opening_message( + return await self.interview_agent.generate_opening_message( conversation_id=conversation_id, memoir_state=memoir_state, user_profile_context=user_profile_context, ) - response_text = "\n\n".join(responses) - await save_message(conversation_id, "ai", response_text) - return responses diff --git a/api/app/agents/chat/prompts.py b/api/app/agents/chat/prompts.py index 55d2144..b77ea17 100644 --- a/api/app/agents/chat/prompts.py +++ b/api/app/agents/chat/prompts.py @@ -17,7 +17,6 @@ from app.agents.chat.prompts_conversation import ( ConversationStage, INTERVIEW_QUESTIONS, SLOT_NAME_MAP, - get_conversation_prompt, get_guided_conversation_prompt, get_opening_prompt, get_questions_for_stage, @@ -34,7 +33,6 @@ __all__ = [ "ConversationStage", "INTERVIEW_QUESTIONS", "SLOT_NAME_MAP", - "get_conversation_prompt", "get_guided_conversation_prompt", "get_opening_prompt", "get_questions_for_stage", diff --git a/api/app/agents/chat/prompts_conversation.py b/api/app/agents/chat/prompts_conversation.py index 1e89ded..dd9ecdf 100644 --- a/api/app/agents/chat/prompts_conversation.py +++ b/api/app/agents/chat/prompts_conversation.py @@ -465,12 +465,3 @@ def get_guided_conversation_prompt( 直接输出你要说的话(多条消息用 [SPLIT] 分隔):""" return prompt - - -def get_conversation_prompt( - current_stage: ConversationStage, - covered_topics: List[str], - user_latest_response: str, -) -> str: - """向后兼容的函数""" - return get_system_prompt(current_stage, covered_topics, user_latest_response) diff --git a/api/app/agents/image_prompt/prompt_agent.py b/api/app/agents/image_prompt/prompt_agent.py index 0c1d509..bbf0734 100644 --- a/api/app/agents/image_prompt/prompt_agent.py +++ b/api/app/agents/image_prompt/prompt_agent.py @@ -2,7 +2,7 @@ PromptGenerationAgent:生成回忆录配图的 image-generation prompt。 接收 chapter_title、chapter_category、description、context_excerpt, 调用 LLM 或 fallback 生成 {prompt, style, size}。 -底层委托 MemoirImagePromptService,保持对外接口兼容。 +底层委托 MemoirImagePromptService。 """ from __future__ import annotations diff --git a/api/app/agents/memoir/__init__.py b/api/app/agents/memoir/__init__.py index 6423297..5c4546b 100644 --- a/api/app/agents/memoir/__init__.py +++ b/api/app/agents/memoir/__init__.py @@ -1,19 +1,25 @@ -"""回忆录模块:MemoryAgent、BackgroundTaskRunner、MemoirOrchestrator、各 Specialist Agent""" +"""回忆录模块:MemoirOrchestrator、各 Specialist Agent。""" -from app.agents.memoir.memory_agent import MemoryAgent -from app.agents.memoir.processor import BackgroundTaskRunner -from app.agents.memoir.orchestrator import MemoirOrchestrator -from app.agents.memoir.extraction_agent import ExtractionAgent, ExtractionResult from app.agents.memoir.classification_agent import ClassificationAgent +from app.agents.memoir.extraction_agent import ExtractionAgent, ExtractionResult from app.agents.memoir.narrative_agent import NarrativeAgent -from app.agents.memoir.story_route_agent import StoryRouteAgent, StoryRouteDecision +from app.agents.memoir.orchestrator import MemoirOrchestrator, PreparedMemoirBatches +from app.agents.memoir.story_route_agent import ( + StoryBatchPlan, + StoryBatchPlanUnit, + StoryRouteAgent, + StoryRouteDecision, + validate_story_batch_plan, +) __all__ = [ - "MemoryAgent", - "BackgroundTaskRunner", "MemoirOrchestrator", + "PreparedMemoirBatches", "StoryRouteAgent", "StoryRouteDecision", + "StoryBatchPlan", + "StoryBatchPlanUnit", + "validate_story_batch_plan", "ExtractionAgent", "ExtractionResult", "ClassificationAgent", diff --git a/api/app/agents/memoir/classification_agent.py b/api/app/agents/memoir/classification_agent.py index 6b17297..baf7d94 100644 --- a/api/app/agents/memoir/classification_agent.py +++ b/api/app/agents/memoir/classification_agent.py @@ -7,12 +7,11 @@ from __future__ import annotations from typing import Any, Optional -from app.core.logging import get_logger - from app.agents.memoir.prompts import ( CHAPTER_CATEGORIES, get_chapter_classification_prompt, ) +from app.core.logging import get_logger logger = get_logger(__name__) @@ -64,8 +63,10 @@ class ClassificationAgent: response = llm.invoke(prompt) category = (response.content or "").strip().lower() if category == "none": - logger.info( - "LLM 判定内容无回忆录价值,跳过: %s...", (text or "")[:80] + logger.debug( + "LLM 判定内容无回忆录价值,跳过: text_len=%s text=%s", + len(text or ""), + text or "", ) return None if category in CHAPTER_CATEGORIES: diff --git a/api/app/agents/memoir/memory_agent.py b/api/app/agents/memoir/memory_agent.py deleted file mode 100644 index c79960d..0000000 --- a/api/app/agents/memoir/memory_agent.py +++ /dev/null @@ -1,126 +0,0 @@ -""" -回忆录整理 Agent:基于传记结构,将口语改写为书面语,归类到章节 -支持异步调用 -""" - -import json -from typing import Dict, List, Optional - -from app.agents.memoir.prompts import ( - CHAPTER_CATEGORIES, - STAGE_TO_ORDER, - get_chapter_classification_prompt, - get_text_rewrite_prompt, -) -from app.core.dependencies import get_llm_provider -from app.core.langchain_llm import bind_json_object_mode -from app.core.logging import get_logger -from app.features.memoir.memoir_images.json_payload import extract_json_payload - -logger = get_logger(__name__) - - -def _get_langchain_llm(): - try: - provider = get_llm_provider() - return getattr(provider, "langchain_llm", None) - except Exception: - return None - - -class MemoryAgent: - """回忆录整理 Agent(支持异步)""" - - def __init__(self): - self.llm = _get_langchain_llm() - - async def classify_chapter(self, segments_text: str) -> str: - if not self.llm: - return "childhood" - try: - prompt = get_chapter_classification_prompt(segments_text) - response = await self.llm.ainvoke(prompt) - content = ( - response.content if hasattr(response, "content") else str(response) - ) - category = content.strip().lower() - if category in CHAPTER_CATEGORIES: - return category - except Exception as e: - logger.error("分类章节失败: %s", e) - return "childhood" - - async def rewrite_to_literary( - self, - segments_text: str, - chapter_category: str, - existing_content: Optional[str] = None, - ) -> Dict: - if not self.llm: - return { - "title": CHAPTER_CATEGORIES.get(chapter_category, "章节"), - "content": segments_text, - "summary": "", - "image_suggestions": [], - } - try: - prompt = get_text_rewrite_prompt( - segments_text, chapter_category, existing_content or "" - ) - json_llm = bind_json_object_mode(self.llm, max_tokens=4096) - response = await json_llm.ainvoke(prompt) - content = ( - response.content if hasattr(response, "content") else str(response) - ) - content = content.strip() - result = json.loads(extract_json_payload(content)) - return result - except json.JSONDecodeError: - raw = response.content if hasattr(response, "content") else str(response) - return { - "title": CHAPTER_CATEGORIES.get(chapter_category, "章节"), - "content": raw, - "summary": "", - "image_suggestions": [], - } - except Exception as e: - logger.error("改写文本失败: %s", e) - return { - "title": CHAPTER_CATEGORIES.get(chapter_category, "章节"), - "content": segments_text, - "summary": "", - "image_suggestions": [], - } - - async def process_segments( - self, - segments: List[Dict], - existing_chapters: Optional[Dict[str, Dict]] = None, - ) -> Dict[str, Dict]: - if existing_chapters is None: - existing_chapters = {} - segments_by_category: Dict[str, List[str]] = {} - for segment in segments: - text = segment.get("transcript_text", "") - if not text: - continue - category = await self.classify_chapter(text) - if category not in segments_by_category: - segments_by_category[category] = [] - segments_by_category[category].append(text) - updated_chapters = existing_chapters.copy() - for category, texts in segments_by_category.items(): - combined_text = "\n\n".join(texts) - existing_content = existing_chapters.get(category, {}).get("content", "") - result = await self.rewrite_to_literary( - combined_text, category, existing_content - ) - updated_chapters[category] = { - "title": result.get("title", CHAPTER_CATEGORIES.get(category, "章节")), - "content": result.get("content", ""), - "summary": result.get("summary", ""), - "image_suggestions": result.get("image_suggestions", []), - "category": category, - "order_index": STAGE_TO_ORDER.get(category, 999), - } - return updated_chapters diff --git a/api/app/agents/memoir/orchestrator.py b/api/app/agents/memoir/orchestrator.py index 728942a..76b9e30 100644 --- a/api/app/agents/memoir/orchestrator.py +++ b/api/app/agents/memoir/orchestrator.py @@ -6,21 +6,31 @@ MemoirOrchestrator:按 segment 编排流水线,调用各 Specialist Agent。 from __future__ import annotations +from dataclasses import dataclass from typing import Any, Callable, Dict, List, Set, Tuple -from app.core.logging import get_logger -from app.features.conversation.models import Segment -from app.agents.state_schema import MemoirStateSchema - -from app.agents.memoir.extraction_agent import ExtractionAgent, ExtractionResult from app.agents.memoir.classification_agent import ( ClassificationAgent, +) +from app.agents.memoir.classification_agent import ( _detect_stage as detect_stage_from_keywords, ) +from app.agents.memoir.extraction_agent import ExtractionAgent, ExtractionResult +from app.agents.state_schema import MemoirStateSchema +from app.core.logging import get_logger +from app.features.conversation.models import Segment logger = get_logger(__name__) +@dataclass +class PreparedMemoirBatches: + """Explicit batching result: updated state + segments grouped by chapter category.""" + + state: MemoirStateSchema + category_to_segments: Dict[str, List[Segment]] + + class MemoirOrchestrator: """ 回忆录生成编排器。 @@ -32,6 +42,57 @@ class MemoirOrchestrator: self.extraction_agent = ExtractionAgent() self.classification_agent = ClassificationAgent() + def prepare_batches( + self, + *, + segments: List[Segment], + llm: Any, + get_or_create_state: Callable[[], MemoirStateSchema], + update_slot: Callable[[str, str, str, List[str]], MemoirStateSchema], + ) -> PreparedMemoirBatches: + """ + 遍历 segments:Extraction → slot 更新 → Classification → 按 category 分桶。 + 不含锁与写章节/故事(由调用方显式执行)。 + """ + state = get_or_create_state() + category_to_segments: Dict[str, List[Segment]] = {} + + for segment in segments: + text = segment.transcript_text or "" + initial_stage = detect_stage_from_keywords( + text, state.current_stage or "childhood" + ) + stage_slots_raw = state.slots.get(initial_stage, {}) or {} + + result: ExtractionResult = self.extraction_agent.extract( + user_message=text, + current_stage=state.current_stage or "childhood", + stage_slots=stage_slots_raw, + llm=llm, + ) + detected_stage = result.detected_stage + for slot_name, snippet in result.slots.items(): + state = update_slot(detected_stage, slot_name, snippet, [segment.id]) + + chapter_category = self.classification_agent.classify( + text=text, + fallback_stage=detected_stage, + llm=llm, + ) + if chapter_category is None: + logger.debug( + "段落无回忆录价值,跳过: segment_id=%s transcript=%s", + segment.id, + getattr(segment, "transcript_text", None) or "", + ) + continue + category_to_segments.setdefault(chapter_category, []).append(segment) + + return PreparedMemoirBatches( + state=state, + category_to_segments=category_to_segments, + ) + def run( self, *, @@ -63,41 +124,17 @@ class MemoirOrchestrator: 返回 (chapters_to_enqueue, processed_count)。 raise_retry 用于锁竞争时抛出 Celery retry。 """ - state = get_or_create_state() + prepared = self.prepare_batches( + segments=segments, + llm=llm, + get_or_create_state=get_or_create_state, + update_slot=update_slot, + ) + state = prepared.state chapters_to_enqueue: Set[str] = set() - category_to_segments: Dict[str, List[Segment]] = {} + category_to_segments = prepared.category_to_segments - # 1) 遍历 segments:ExtractionAgent → 更新 slots;ClassificationAgent → 聚合 - for segment in segments: - text = segment.transcript_text or "" - # 关键词预检测阶段,用于 slot 查找(与原有逻辑一致) - initial_stage = detect_stage_from_keywords( - text, state.current_stage or "childhood" - ) - stage_slots_raw = state.slots.get(initial_stage, {}) or {} - - result: ExtractionResult = self.extraction_agent.extract( - user_message=text, - current_stage=state.current_stage or "childhood", - stage_slots=stage_slots_raw, - llm=llm, - ) - detected_stage = result.detected_stage - for slot_name, snippet in result.slots.items(): - state = update_slot(detected_stage, slot_name, snippet, [segment.id]) - - # ClassificationAgent - chapter_category = self.classification_agent.classify( - text=text, - fallback_stage=detected_stage, - llm=llm, - ) - if chapter_category is None: - logger.info("段落无回忆录价值,跳过: segment_id=%s", segment.id) - continue - category_to_segments.setdefault(chapter_category, []).append(segment) - - # 2) 按 category 调用 process_category:叙事生成、持久化、封面入队标记 + # 按 category 调用 process_category:叙事生成、持久化、封面入队标记 for chapter_category, category_segments in category_to_segments.items(): if not acquire_lock(chapter_category): logger.warning( diff --git a/api/app/agents/memoir/prompts.py b/api/app/agents/memoir/prompts.py index ee05e92..fb8e07b 100644 --- a/api/app/agents/memoir/prompts.py +++ b/api/app/agents/memoir/prompts.py @@ -386,10 +386,11 @@ def get_narrative_json_prompt( 1. 从对话中提炼与人生经历相关的核心内容,过滤语气词、寒暄、与AI的交互 2. 使用第一人称,改写为流畅的书面叙述,不要直接引用对话原话 3. 只输出新内容的改写,不要重复已有内容 -4. 每 200-300 字左右一个段落 -5. 如有衔接上下文,确保新内容与之自然衔接 -6. **不要使用 Markdown 表格**(不要用 `|` 管道表格) -7. **不要用 `#`、`##` 写故事或章节标题**;标题由系统管理 +4. **本批输入对应一个独立叙事单元**:只围绕同一主题/事件链展开,不要写入与上述对话无关的其他话题或回忆 +5. 每 200-300 字左右一个段落 +6. 如有衔接上下文,确保新内容与之自然衔接 +7. **不要使用 Markdown 表格**(不要用 `|` 管道表格) +8. **不要用 `#`、`##` 写故事或章节标题**;标题由系统管理 ## 输出格式(严格 JSON) {{ @@ -417,6 +418,8 @@ def get_story_route_prompt( - append_story:内容明显延续、补充某一已有故事的主题与时间线,且能对应到具体 candidate id - new_story:新话题、新人生阶段片段,或与所有候选故事都不够贴合 +「故事」在此指:**可独立讲述的一段人生经历**——单一主题或同一事件链;不要假设本批里包含多个互不相关的故事(多段由系统其它步骤处理)。 + 当前章节(写作容器): - category: {chapter_category} - title: {chapter_title} @@ -441,6 +444,54 @@ def get_story_route_prompt( """ +def get_story_batch_plan_prompt( + *, + chapter_category: str, + chapter_title: str, + segments_json: str, + candidate_stories_json: str, +) -> str: + """同一章节类别下多 segment:划分为若干写入单元(每单元 new 或 append)。输出严格 JSON。""" + return f"""你是回忆录编辑助手。下面同一章节类别下有一批**按时间顺序**的用户口述片段(每段有 id 与文本)。 + +## 「故事」定义(必须遵守) +一段「故事」= **可独立讲述的一段人生经历**:单一主题或同一事件链,能单独成篇。若话题切换、时间线跳到另一件事、人物/主线明显变化,应作为**新的故事**(new_story),而不是塞进同一段 append。 + +## 任务 +将本批 segment **划分为连续若干块**(每块包含至少一个 segment,顺序不能打乱;每个 segment 必须恰好属于一块)。对每一块决定: +- **append_story**:内容明显延续、补充**某一已有候选故事**的主题与时间线,且能对应到具体 candidate id +- **new_story**:新话题、与所有候选故事都不够贴合、或应独立成篇的片段 + +当前章节(写作容器): +- category: {chapter_category} +- title: {chapter_title} + +【本批口述片段】(JSON 数组,顺序即口述顺序) +{segments_json} + +【候选故事】(仅允许在 append 时选择其中的 id;id 必须原样复制) +{candidate_stories_json} + +## 输出 JSON(仅此一个对象,不要 markdown) +{{ + "units": [ + {{ + "segment_ids": ["<按顺序列出本块包含的 segment id>"], + "decision": "new_story" | "append_story", + "target_story_id": "", + "new_story_title": "<短标题,6-20 字;new_story 时必填,append 时可 null>", + "reason": "<一句中文理由,可选>" + }} + ] +}} + +规则: +- `units` 中所有 `segment_ids` 拼接后,必须**不重不漏**地覆盖本批全部 id,且顺序与【本批口述片段】数组一致 +- 若无法自信匹配某一候选,对该块选 new_story +- new_story_title 应概括该块内容,不要与候选标题重复 +""" + + def format_evidence_chunks_for_prompt(evidence: dict) -> str: """将 retrieve_evidence 结果格式化为简短文本,供叙事 prompt 使用。""" chunks = evidence.get("relevant_chunks") or [] diff --git a/api/app/agents/memoir/story_route_agent.py b/api/app/agents/memoir/story_route_agent.py index 174da01..7c59d72 100644 --- a/api/app/agents/memoir/story_route_agent.py +++ b/api/app/agents/memoir/story_route_agent.py @@ -9,7 +9,10 @@ from typing import Any, Literal from pydantic import BaseModel, field_validator -from app.agents.memoir.prompts import get_story_route_prompt +from app.agents.memoir.prompts import ( + get_story_batch_plan_prompt, + get_story_route_prompt, +) from app.core.langchain_llm import bind_json_object_mode from app.core.logging import get_logger from app.features.story.models import Story @@ -17,6 +20,33 @@ from app.features.story.models import Story logger = get_logger(__name__) +# 超过此数量跳过批量规划(单次路由),避免 prompt 过大 +PLAN_BATCH_MAX_SEGMENTS = 48 + + +class StoryBatchPlanUnit(BaseModel): + """批量写入中的一个单元(连续 segment 块)。""" + + segment_ids: list[str] + decision: Literal["new_story", "append_story"] + target_story_id: str | None = None + new_story_title: str | None = None + reason: str | None = None + + @field_validator("target_story_id", mode="before") + @classmethod + def empty_str_to_none_tid(cls, v: Any) -> str | None: + if v is None or v == "": + return None + if isinstance(v, str): + return v.strip() or None + return str(v) + + +class StoryBatchPlan(BaseModel): + units: list[StoryBatchPlanUnit] + + class StoryRouteDecision(BaseModel): decision: Literal["new_story", "append_story"] target_story_id: str | None = None @@ -57,6 +87,51 @@ def _build_candidate_json(stories: list[Story], *, preview_chars: int = 220) -> return json.dumps(rows, ensure_ascii=False, indent=2) +def _build_segments_json_for_plan( + segments: list[tuple[str, str]], *, text_preview_chars: int = 4000 +) -> str: + """segments: (id, transcript_text) 按口述顺序。""" + rows: list[dict[str, str]] = [] + for sid, text in segments: + t = (text or "").strip() + if len(t) > text_preview_chars: + t = t[:text_preview_chars] + "…" + rows.append({"id": sid, "text": t}) + return json.dumps(rows, ensure_ascii=False, indent=2) + + +def validate_story_batch_plan( + ordered_segment_ids: list[str], + plan: StoryBatchPlan, + valid_story_ids: set[str], +) -> tuple[bool, str | None]: + """ + 校验:segment 全覆盖、顺序一致、append 目标合法、new_story 有标题。 + 返回 (ok, error_code)。 + """ + if not plan.units: + return False, "empty_units" + flat: list[str] = [] + for u in plan.units: + if not u.segment_ids: + return False, "empty_unit_segment_ids" + flat.extend(u.segment_ids) + if len(flat) != len(set(flat)): + return False, "duplicate_segment" + if flat != ordered_segment_ids: + return False, "segment_mismatch" + for u in plan.units: + if u.decision == "append_story": + tid = u.target_story_id + if not tid or tid not in valid_story_ids: + return False, "invalid_append_target" + else: + title = (u.new_story_title or "").strip() + if not title: + return False, "missing_new_title" + return True, None + + class StoryRouteAgent: def decide( self, @@ -112,3 +187,43 @@ class StoryRouteAgent: ): decision.new_story_title = None return decision + + def plan_batch( + self, + *, + chapter_category: str, + chapter_title: str, + segments: list[tuple[str, str]], + candidate_stories: list[Story], + llm: Any, + valid_story_ids: set[str], + ) -> StoryBatchPlan | None: + """ + 将本批 segment 划分为多个写入单元。解析失败返回 None,由调用方回退 decide()。 + """ + if not llm or len(segments) < 2: + return None + payload = _build_candidate_json(candidate_stories) + segments_json = _build_segments_json_for_plan(segments) + prompt = get_story_batch_plan_prompt( + chapter_category=chapter_category, + chapter_title=chapter_title, + segments_json=segments_json, + candidate_stories_json=payload, + ) + try: + json_llm = bind_json_object_mode(llm, max_tokens=4096) + response = json_llm.invoke(prompt) + raw = (response.content or "").strip() + data = json.loads(raw) + plan = StoryBatchPlan.model_validate(data) + except Exception as e: + logger.warning("StoryRouteAgent.plan_batch 解析失败: %s", e) + return None + + ordered = [s[0] for s in segments] + ok, err = validate_story_batch_plan(ordered, plan, valid_story_ids) + if not ok: + logger.warning("StoryRouteAgent.plan_batch 校验失败: %s", err) + return None + return plan diff --git a/api/app/core/config.py b/api/app/core/config.py index 7457097..d85aa6c 100644 --- a/api/app/core/config.py +++ b/api/app/core/config.py @@ -86,6 +86,10 @@ class Settings(BaseSettings): alipay_sign_type: str = "RSA2" alipay_under_development: str = "true" # "1"/"true"/"yes" 视为开发中不可用 + # ── Logging ────────────────────────────────────────────── + # 环境变量 LOG_LEVEL;控制 loguru sink 最低级别(TRACE/DEBUG/INFO/…) + log_level: str = "INFO" + # ── Misc ───────────────────────────────────────────────── enable_test_subscription: int = 0 enable_test_plan: str = "" # "1" / "true" / "yes" 为 True diff --git a/api/app/core/logging.py b/api/app/core/logging.py index b14a0b3..8fc3464 100644 --- a/api/app/core/logging.py +++ b/api/app/core/logging.py @@ -1,5 +1,13 @@ """ loguru 统一日志配置 + InterceptHandler 拦截标准库 logging。 + +日志约定: +- INFO:面向运维的稳定摘要,避免敏感字段与高频噪音。 +- DEBUG:可记录完整上下文、用户内容、连接串、URL 等敏感信息;仅用于受控环境排查, + 生产环境勿长期开启 DEBUG。 + +由 ``Settings.log_level`` 控制(环境变量 ``LOG_LEVEL``,默认 ``INFO``); +设为 ``DEBUG`` 时上述详细日志才会输出。 """ import logging @@ -7,6 +15,15 @@ import sys from loguru import logger +from app.core.config import settings + + +def _sink_min_level() -> str: + raw = (settings.log_level or "INFO").strip().upper() + if raw in ("TRACE", "DEBUG", "INFO", "SUCCESS", "WARNING", "ERROR", "CRITICAL"): + return raw + return "INFO" + class InterceptHandler(logging.Handler): """Route standard-library logging messages into loguru.""" @@ -37,7 +54,7 @@ def setup_logging() -> None: logger.add( sys.stderr, - level="INFO", + level=_sink_min_level(), format=( "{time:YYYY-MM-DD HH:mm:ss.SSS} | " "{level.name: <8} | " diff --git a/api/app/core/redis.py b/api/app/core/redis.py index 6a653ca..bbbe2ca 100644 --- a/api/app/core/redis.py +++ b/api/app/core/redis.py @@ -33,7 +33,8 @@ class RedisService: decode_responses=True, ) await self._client.ping() - logger.info("Redis 连接成功: %s", self.redis_url) + logger.info("Redis 连接成功") + logger.debug("Redis 连接 URL: %s", self.redis_url) except Exception as e: logger.error("Redis 连接失败: %s", e) raise diff --git a/api/app/core/task_tracker.py b/api/app/core/task_tracker.py index 73c8262..45f9d4f 100644 --- a/api/app/core/task_tracker.py +++ b/api/app/core/task_tracker.py @@ -32,7 +32,7 @@ class TaskTracker: } await client.hset(key, task_id, json.dumps(task_info)) await client.expire(key, self.TASK_TTL) - logger.info("任务已记录: user_id=%s, task_id=%s", user_id, task_id) + logger.debug("任务已记录: user_id=%s, task_id=%s", user_id, task_id) return True except Exception as e: logger.error("记录任务失败: %s", e) diff --git a/api/app/features/auth/router.py b/api/app/features/auth/router.py index a1dd43a..fdc81f6 100644 --- a/api/app/features/auth/router.py +++ b/api/app/features/auth/router.py @@ -1,6 +1,4 @@ import io -from app.core.logging import get_logger -import traceback from pathlib import Path from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status @@ -8,6 +6,7 @@ from fastapi.responses import FileResponse from PIL import Image from app.core.dependencies import get_current_user +from app.core.logging import get_logger from app.features.auth.deps import get_auth_service from app.features.auth.schemas import ( ChangePasswordRequest, @@ -225,10 +224,6 @@ async def upload_avatar( service: AuthService = Depends(get_auth_service), ): allowed_types = ["image/jpeg", "image/png", "image/webp"] - logger.info( - f"上传头像 - 文件名: {file.filename}, Content-Type: {file.content_type}, " - f"Size: {file.size if hasattr(file, 'size') else 'unknown'}" - ) if file.content_type not in allowed_types: raise HTTPException( @@ -237,7 +232,6 @@ async def upload_avatar( ) file_content = await file.read() - logger.info(f"读取文件内容 - 大小: {len(file_content)} bytes") if not file_content or len(file_content) == 0: raise HTTPException( @@ -251,6 +245,14 @@ async def upload_avatar( detail="文件大小超过5MB限制", ) + logger.debug( + "上传头像: user_id=%s filename=%s content_type=%s size=%s", + current_user.id, + file.filename, + file.content_type, + len(file_content), + ) + try: AVATAR_DIR.mkdir(parents=True, exist_ok=True) @@ -263,15 +265,13 @@ async def upload_avatar( is_valid_image = False if header.startswith(b"\xff\xd8\xff"): is_valid_image = True - logger.info("检测到JPEG格式") elif header.startswith(b"\x89PNG\r\n\x1a\n"): is_valid_image = True - logger.info("检测到PNG格式") elif header.startswith(b"RIFF") and b"WEBP" in header[:12]: is_valid_image = True - logger.info("检测到WebP格式") else: - logger.warning(f"无法识别的文件头: {header[:12].hex()}") + logger.warning("无法识别的图片文件头") + logger.debug("无法识别的文件头 hex=%s", header[:12].hex()) if not is_valid_image: raise HTTPException( @@ -280,8 +280,11 @@ async def upload_avatar( ) image = Image.open(image_bytes) - logger.info( - f"成功打开图片 - 格式: {image.format}, 模式: {image.mode}, 尺寸: {image.size}" + logger.debug( + "头像解码: format=%s mode=%s size=%s", + image.format, + image.mode, + image.size, ) if image.mode != "RGB": @@ -311,11 +314,11 @@ async def upload_avatar( raise except Exception as e: error_msg = f"处理图片失败: {str(e)}" - logger.error(f"头像上传失败: {error_msg}\n{traceback.format_exc()}") + logger.exception("头像上传失败: {}", error_msg) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=error_msg, - ) + ) from e @router.get( diff --git a/api/app/features/conversation/history_store.py b/api/app/features/conversation/history_store.py new file mode 100644 index 0000000..df4c211 --- /dev/null +++ b/api/app/features/conversation/history_store.py @@ -0,0 +1,144 @@ +"""Durable conversation turn persistence + Redis cache sync (feature layer).""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timedelta, timezone +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core import redis as redis_core +from app.core.logging import get_logger +from app.features.conversation import repo +from app.features.conversation.models import ConversationMessage +from app.features.conversation.session_history import ( + conversation_messages_to_redis_history, +) + +logger = get_logger(__name__) + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + +class ConversationHistoryStore: + def __init__(self, db: AsyncSession): + self._db = db + + async def load_canonical_history( + self, conversation_id: str + ) -> list[dict[str, Any]]: + rows = await repo.get_conversation_messages(conversation_id, self._db) + return conversation_messages_to_redis_history(rows) + + async def _touch_conversation( + self, conversation_id: str, *, occurred_at: datetime + ) -> None: + conversation = await repo.get_conversation(conversation_id, self._db) + if conversation is None: + return + current = getattr(conversation, "last_message_at", None) + if current is None or current < occurred_at: + conversation.last_message_at = occurred_at + + async def _sync_redis_from_db(self, conversation_id: str) -> None: + hist = await self.load_canonical_history(conversation_id) + await redis_core.redis_service.set_conversation_history(conversation_id, hist) + + async def _sync_redis_best_effort(self, conversation_id: str) -> None: + try: + await self._sync_redis_from_db(conversation_id) + except Exception as exc: + logger.warning("conversation history cache sync skipped: %s", exc) + + async def record_ai_only_turn( + self, conversation_id: str, responses: list[str] + ) -> None: + if not responses: + return + combined = "\n\n".join(responses) + created_at = _utc_now() + msg = ConversationMessage( + id=str(uuid.uuid4()), + conversation_id=conversation_id, + role="ai", + content=combined, + message_type="text", + created_at=created_at, + ) + repo.add_conversation_message(msg, self._db) + await self._touch_conversation(conversation_id, occurred_at=created_at) + await self._db.commit() + await self._sync_redis_best_effort(conversation_id) + + async def record_human_ai_turn( + self, + conversation_id: str, + user_message: str, + responses: list[str], + *, + user_message_timestamp: datetime | None, + is_from_voice: bool, + voice_session_id: str | None, + audio_duration_seconds: int | None, + tts_audio_urls: list[str] | None, + segment_id: str | None, + ) -> None: + if not responses: + return + human_ts = user_message_timestamp or _utc_now() + if human_ts.tzinfo is None: + human_ts = human_ts.replace(tzinfo=timezone.utc) + ai_ts = human_ts + timedelta(microseconds=1) + human_type = "audio" if is_from_voice else "text" + human = ConversationMessage( + id=str(uuid.uuid4()), + conversation_id=conversation_id, + role="human", + content=user_message, + message_type=human_type, + voice_session_id=voice_session_id, + duration_seconds=audio_duration_seconds + if audio_duration_seconds is not None and audio_duration_seconds > 0 + else None, + segment_id=segment_id, + created_at=human_ts, + ) + combined = "\n\n".join(responses) + ai = ConversationMessage( + id=str(uuid.uuid4()), + conversation_id=conversation_id, + role="ai", + content=combined, + message_type="text", + tts_audio_urls=tts_audio_urls if tts_audio_urls else None, + segment_id=segment_id, + created_at=ai_ts, + ) + repo.add_conversation_message(human, self._db) + repo.add_conversation_message(ai, self._db) + await self._touch_conversation(conversation_id, occurred_at=ai_ts) + await self._db.commit() + await self._sync_redis_best_effort(conversation_id) + + async def attach_ai_tts_audio_urls( + self, + conversation_id: str, + *, + tts_audio_urls: list[str], + segment_id: str | None = None, + ) -> None: + if not tts_audio_urls: + return + row = await repo.set_latest_ai_message_tts_audio_urls( + conversation_id, + self._db, + tts_audio_urls=tts_audio_urls, + segment_id=segment_id, + ) + if row is None: + return + await self._db.commit() + await self._sync_redis_best_effort(conversation_id) diff --git a/api/app/features/conversation/models.py b/api/app/features/conversation/models.py index e961356..737f9f0 100644 --- a/api/app/features/conversation/models.py +++ b/api/app/features/conversation/models.py @@ -32,6 +32,11 @@ class Conversation(Base): segments = relationship( "Segment", back_populates="conversation", cascade="all, delete-orphan" ) + messages = relationship( + "ConversationMessage", + back_populates="conversation", + cascade="all, delete-orphan", + ) class Segment(Base): @@ -49,3 +54,23 @@ class Segment(Base): tts_audio_urls = Column(JSON, nullable=True) conversation = relationship("Conversation", back_populates="segments") + + +class ConversationMessage(Base): + """durable turn log aligned with Redis history shape (canonical chat source of truth).""" + + __tablename__ = "conversation_messages" + + id = Column(String, primary_key=True) + conversation_id = Column(String, ForeignKey("conversations.id"), nullable=False) + role = Column(String, nullable=False) # human / ai + content = Column(Text, nullable=False) + message_type = Column(String, nullable=False, default="text") + voice_session_id = Column(String, nullable=True) + duration_seconds = Column(Integer, nullable=True) + tts_audio_urls = Column(JSON, nullable=True) + segment_id = Column(String, ForeignKey("segments.id"), nullable=True) + created_at = Column(DateTime(timezone=True), default=utc_now) + + conversation = relationship("Conversation", back_populates="messages") + segment = relationship("Segment", foreign_keys=[segment_id]) diff --git a/api/app/features/conversation/repo.py b/api/app/features/conversation/repo.py index 550622c..447a56b 100644 --- a/api/app/features/conversation/repo.py +++ b/api/app/features/conversation/repo.py @@ -1,9 +1,9 @@ -"""Conversation repository — Conversation, Segment data access.""" +"""Conversation repository — Conversation, turn log, and Segment data access.""" from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession -from app.features.conversation.models import Conversation, Segment +from app.features.conversation.models import Conversation, ConversationMessage, Segment async def get_conversation( @@ -31,6 +31,44 @@ def add_conversation(conv: Conversation, db: AsyncSession) -> None: db.add(conv) +def add_conversation_message(msg: ConversationMessage, db: AsyncSession) -> None: + db.add(msg) + + +async def get_conversation_messages( + conversation_id: str, db: AsyncSession +) -> list[ConversationMessage]: + stmt = ( + select(ConversationMessage) + .where(ConversationMessage.conversation_id == conversation_id) + .order_by(ConversationMessage.created_at) + ) + result = await db.execute(stmt) + return list(result.scalars().all()) + + +async def set_latest_ai_message_tts_audio_urls( + conversation_id: str, + db: AsyncSession, + *, + tts_audio_urls: list[str], + segment_id: str | None = None, +) -> ConversationMessage | None: + stmt = select(ConversationMessage).where( + ConversationMessage.conversation_id == conversation_id, + ConversationMessage.role == "ai", + ) + if segment_id is not None: + stmt = stmt.where(ConversationMessage.segment_id == segment_id) + stmt = stmt.order_by(ConversationMessage.created_at.desc()) + result = await db.execute(stmt) + row = result.scalars().first() + if row is None: + return None + row.tts_audio_urls = list(tts_audio_urls) + return row + + async def get_segments_for_conversation( conversation_id: str, db: AsyncSession ) -> list[Segment]: diff --git a/api/app/features/conversation/service.py b/api/app/features/conversation/service.py index c08af9f..cdbcd75 100644 --- a/api/app/features/conversation/service.py +++ b/api/app/features/conversation/service.py @@ -16,7 +16,9 @@ from app.core.redis import redis_service from app.core.storage_purge import delete_object_storage_keys_best_effort from app.features.conversation import repo from app.features.conversation.models import Conversation -from app.features.conversation.session_history import segments_to_redis_history +from app.features.conversation.session_history import ( + conversation_messages_to_redis_history, +) from app.features.memory import repo as memory_repo from app.features.quota.service import QuotaService from app.ports.storage import ObjectStorage @@ -104,32 +106,34 @@ class ConversationService: self._quota = quota_service self._object_storage = object_storage - async def _get_history(self, conversation_id: str) -> list[dict]: - return await redis_service.get_conversation_history(conversation_id) - async def _clear_history(self, conversation_id: str) -> None: try: await redis_service.clear_conversation_history(conversation_id) except Exception: pass - async def ensure_redis_history_from_segments( - self, conversation_id: str - ) -> list[dict]: + async def ensure_redis_history_from_db(self, conversation_id: str) -> list[dict]: """ - 供 WS 与 get_messages 使用:优先 Redis;若为空则用 DB segments 重建并写回。 - 会话层编排,非 Agent 职责(ChatOrchestrator 只读写已存在的 Redis 流)。 + 供 WS 与 get_messages 使用:优先 Redis;若为空则用 DB conversation_messages 重建并写回。 """ - history = await redis_service.get_conversation_history(conversation_id) + try: + history = await redis_service.get_conversation_history(conversation_id) + except Exception as exc: + logger.warning("conversation history cache read skipped: %s", exc) + history = [] if history: return history - segments = await repo.get_segments_for_conversation(conversation_id, self._db) - if not segments: - return [] - rebuilt = segments_to_redis_history(segments) - if rebuilt: - await redis_service.set_conversation_history(conversation_id, rebuilt) - return rebuilt + + rows = await repo.get_conversation_messages(conversation_id, self._db) + if rows: + rebuilt = conversation_messages_to_redis_history(rows) + try: + await redis_service.set_conversation_history(conversation_id, rebuilt) + except Exception as exc: + logger.warning("conversation history cache write skipped: %s", exc) + return rebuilt + + return [] async def list_for_user(self, user_id: str) -> list[dict]: conversations = await repo.get_user_conversations(user_id, self._db) @@ -137,7 +141,7 @@ class ConversationService: for conv in conversations: history: list[dict] = [] try: - history = await self._get_history(conv.id) + history = await self.ensure_redis_history_from_db(conv.id) except Exception: pass latest_message = history[-1].get("content", "")[:50] if history else None @@ -243,7 +247,7 @@ class ConversationService: async def get_messages(self, conversation_id: str, user_id: str) -> list[dict]: conv = await self.get_or_404(conversation_id, user_id) try: - history = await self.ensure_redis_history_from_segments(conversation_id) + history = await self.ensure_redis_history_from_db(conversation_id) return _build_messages_from_history( conversation_id=conversation_id, history=history, diff --git a/api/app/features/conversation/session_history.py b/api/app/features/conversation/session_history.py index f0e533c..8d7926f 100644 --- a/api/app/features/conversation/session_history.py +++ b/api/app/features/conversation/session_history.py @@ -1,68 +1,29 @@ -""" -会话 transcript 与 Redis 历史条目的纯映射(无 I/O)。 - -仅由 ConversationService 使用:对齐 ChatOrchestrator 经 save_message 写入 Redis 的字段形状, -不属于 Agent 层 —— 多 Agent 模块只消费已就绪的 history,不负责从 DB 重建。 -""" +"""ConversationMessage -> Redis conversation history 的纯映射(无 I/O)。""" from __future__ import annotations -from datetime import timezone from typing import Any, Dict, List -from app.features.conversation.models import Segment +from app.features.conversation.models import ConversationMessage -def _voice_session_id_from_audio_url(audio_url: str | None) -> str | None: - if not audio_url: - return None - prefix = "audio-segment:" - if not audio_url.startswith(prefix): - return None - payload = audio_url[len(prefix) :] - voice_session_id_raw, sep, _ = payload.rpartition(":") - if sep and voice_session_id_raw: - return voice_session_id_raw - return None - - -def _segment_timestamp_iso(seg: Segment) -> str | None: - if not seg.created_at: - return None - dt = seg.created_at - if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) - return dt.isoformat() - - -def segments_to_redis_history(segments: List[Segment]) -> List[Dict[str, Any]]: - """Segment 行 → Redis conversation history 项(与 ChatOrchestrator 写入格式一致)。""" +def conversation_messages_to_redis_history( + rows: List[ConversationMessage], +) -> List[Dict[str, Any]]: + """ConversationMessage 行 -> Redis conversation history 项。""" history: List[Dict[str, Any]] = [] - for seg in segments: - ts = _segment_timestamp_iso(seg) - is_voice = bool(seg.audio_url) - human: Dict[str, Any] = { - "role": "human", - "content": seg.transcript_text or "", - "messageType": "audio" if is_voice else "text", - "timestamp": ts, + for row in rows: + item: Dict[str, Any] = { + "role": row.role, + "content": row.content, + "messageType": row.message_type, + "timestamp": row.created_at.isoformat() if row.created_at else None, } - vsid = _voice_session_id_from_audio_url(seg.audio_url) - if vsid: - human["voiceSessionId"] = vsid - ads = getattr(seg, "audio_duration_seconds", None) - if ads is not None and ads > 0: - human["durationSeconds"] = int(ads) - history.append(human) - if seg.agent_response and seg.agent_response.strip(): - ai_item: Dict[str, Any] = { - "role": "ai", - "content": seg.agent_response.strip(), - "messageType": "text", - "timestamp": ts, - } - tts = getattr(seg, "tts_audio_urls", None) - if isinstance(tts, list) and tts: - ai_item["ttsAudioUrls"] = [u for u in tts if isinstance(u, str)] - history.append(ai_item) + if row.voice_session_id: + item["voiceSessionId"] = row.voice_session_id + if row.duration_seconds: + item["durationSeconds"] = row.duration_seconds + if row.tts_audio_urls: + item["ttsAudioUrls"] = row.tts_audio_urls + history.append(item) return history diff --git a/api/app/features/conversation/ws/message_types.py b/api/app/features/conversation/ws/message_types.py index 8422cec..f4ce34d 100644 --- a/api/app/features/conversation/ws/message_types.py +++ b/api/app/features/conversation/ws/message_types.py @@ -2,8 +2,6 @@ from enum import Enum -LEGACY_VOICE_SESSION_ID = "legacy" - class MessageType(str, Enum): """WebSocket 消息类型""" diff --git a/api/app/features/conversation/ws/pipeline.py b/api/app/features/conversation/ws/pipeline.py index 32d9049..df40a28 100644 --- a/api/app/features/conversation/ws/pipeline.py +++ b/api/app/features/conversation/ws/pipeline.py @@ -15,24 +15,20 @@ if TYPE_CHECKING: from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession -from app.agents import ConversationAgent, MemoryAgent from app.agents.chat import ChatOrchestrator -from app.agents.memoir import BackgroundTaskRunner from app.core.config import settings from app.core.db import AsyncSessionLocal from app.core.dependencies import get_asr_provider, get_object_storage, get_tts_provider -from app.core.redis import redis_service +from app.features.conversation.history_store import ConversationHistoryStore from app.features.conversation.models import Conversation, Segment from app.features.conversation.ws.connection_manager import manager -from app.features.conversation.ws.message_types import ( - LEGACY_VOICE_SESSION_ID, - MessageType, -) +from app.features.conversation.ws.message_types import MessageType from app.features.conversation.ws.profile_collector import ( apply_extracted_profile, get_filled_profile_fields, get_missing_profile_fields, ) +from app.features.memoir.background_runner import BackgroundTaskRunner from app.features.user.models import User logger = get_logger(__name__) @@ -77,9 +73,6 @@ async def _send_tts_audio( storage = get_object_storage() key = f"conversations/{conversation_id}/tts/{uuid.uuid4().hex}.{ext}" public_url = storage.upload(key, audio_bytes, content_type) - await redis_service.append_tts_audio_url_to_last_ai_message( - conversation_id, public_url - ) await manager.send_message( conversation_id, { @@ -109,9 +102,7 @@ async def _send_tts_audio( # ── Agent 实例(从 ConnectionManager 移出) ───────────────────── -conversation_agent = ConversationAgent() chat_orchestrator = ChatOrchestrator() -memory_agent = MemoryAgent() background_runner = BackgroundTaskRunner() @@ -197,12 +188,6 @@ def _mark_conversation_active( return activity_time -def _normalize_voice_session_id(voice_session_id: Optional[str]) -> str: - if voice_session_id: - return str(voice_session_id) - return LEGACY_VOICE_SESSION_ID - - def _voice_session_id_from_client_segment_id( client_segment_id: Optional[str], ) -> Optional[str]: @@ -220,19 +205,19 @@ def _build_segment_audio_url(voice_session_id: str, segment_index: int) -> str: def _extract_segment_scope(audio_url: Optional[str]) -> Optional[Tuple[str, int]]: - """从 audio_url 中解析 voice_session_id 与 segment_index。兼容旧格式 audio-segment:{index}。""" + """从 audio_url 解析 voice_session_id 与 segment_index(audio-segment:{session_id}:{index})。""" prefix = "audio-segment:" if not audio_url or not audio_url.startswith(prefix): return None payload = audio_url[len(prefix) :] voice_session_id_raw, separator, segment_index_raw = payload.rpartition(":") + if not separator: + return None try: - if separator: - return ( - _normalize_voice_session_id(voice_session_id_raw), - int(segment_index_raw), - ) - return (LEGACY_VOICE_SESSION_ID, int(payload)) + sid = str(voice_session_id_raw).strip() + if not sid: + return None + return (sid, int(segment_index_raw)) except ValueError: return None @@ -452,9 +437,14 @@ async def process_audio_segment( if existing_segment: async with state.lock: state.processed_indices.add(segment_index) - logger.info( - "分段已存在,按幂等处理跳过: " - f"conversation_id={conversation_id}, voice_session_id={voice_session_id}, segment_index={segment_index}" + logger.debug( + "分段已存在,按幂等跳过: conversation_id=%s voice_session_id=%s " + "segment_index=%s segment_id=%s transcript=%s", + conversation_id, + voice_session_id, + segment_index, + existing_segment.id, + existing_segment.transcript_text or "", ) return else: @@ -535,6 +525,8 @@ async def process_user_message( user_message_timestamp: Optional[datetime] = None, ) -> None: """处理用户消息,生成 Agent 回应。由 ChatOrchestrator 路由到 ProfileAgent 或 InterviewAgent。""" + store = ConversationHistoryStore(db) + tts_urls: list[str] = [] try: is_from_voice = bool(segment.audio_url) voice_session_id = _voice_session_id_from_audio_url(segment.audio_url) @@ -558,9 +550,18 @@ async def process_user_message( segment.agent_response = "\n\n".join(responses) _mark_conversation_active(conversation) - await db.commit() + await store.record_human_ai_turn( + conversation_id=conversation_id, + user_message=user_message, + responses=responses, + user_message_timestamp=user_message_timestamp, + is_from_voice=is_from_voice, + voice_session_id=voice_session_id, + audio_duration_seconds=audio_dur, + tts_audio_urls=None, + segment_id=segment.id, + ) - tts_urls: list[str] = [] n = len(responses) for i, response_text in enumerate(responses): await manager.send_message( @@ -589,14 +590,35 @@ async def process_user_message( if i < n - 1: await asyncio.sleep(0.5) - await db.execute( - update(Segment) - .where(Segment.id == segment.id) - .values(tts_audio_urls=tts_urls if tts_urls else None) - ) - await db.commit() + if tts_urls: + await store.attach_ai_tts_audio_urls( + conversation_id, + tts_audio_urls=tts_urls, + segment_id=segment.id, + ) + await db.execute( + update(Segment) + .where(Segment.id == segment.id) + .values(tts_audio_urls=tts_urls) + ) + await db.commit() except Exception as e: + if tts_urls: + try: + await store.attach_ai_tts_audio_urls( + conversation_id, + tts_audio_urls=tts_urls, + segment_id=segment.id, + ) + await db.execute( + update(Segment) + .where(Segment.id == segment.id) + .values(tts_audio_urls=tts_urls) + ) + await db.commit() + except Exception as persist_error: + logger.warning("补写 TTS 元数据失败: %s", persist_error) logger.error(f"处理用户消息失败: {e}", exc_info=True) if conversation_id in manager.active_connections: try: diff --git a/api/app/features/conversation/ws/router.py b/api/app/features/conversation/ws/router.py index 75b04f5..43f9bc5 100644 --- a/api/app/features/conversation/ws/router.py +++ b/api/app/features/conversation/ws/router.py @@ -16,19 +16,18 @@ from app.core.db import AsyncSessionLocal from app.core.dependencies import get_asr_provider from app.core.logging import get_logger from app.core.security import verify_token +from app.features.conversation.history_store import ConversationHistoryStore from app.features.conversation.models import Conversation, Segment from app.features.conversation.service import ConversationService from app.features.conversation.ws.connection_manager import manager from app.features.conversation.ws.message_types import MessageType from app.features.conversation.ws.pipeline import ( - SegmentStreamState, # noqa: F401 — re-export for test backward compat _delayed_listening_feedback, _mark_conversation_active, - _normalize_voice_session_id, _voice_session_id_from_client_segment_id, background_runner, + chat_orchestrator, cleanup_segment_states, - conversation_agent, get_or_create_segment_state, process_audio_segment, process_conversation_segments, @@ -144,18 +143,21 @@ async def websocket_endpoint( ) return - history = await conversation_service.ensure_redis_history_from_segments( + history = await conversation_service.ensure_redis_history_from_db( conversation_id ) if not history: missing_profile = get_missing_profile_fields(user) if missing_profile: try: - greetings = await conversation_agent.generate_profile_greeting( + greetings = await chat_orchestrator.generate_profile_greeting( conversation_id=conversation_id, missing_fields=missing_profile, nickname=user.nickname or "", ) + await ConversationHistoryStore(db).record_ai_only_turn( + conversation_id, greetings + ) for i, text in enumerate(greetings): await manager.send_message( conversation_id, @@ -184,12 +186,15 @@ async def websocket_endpoint( occupation=user.occupation, ) opening_messages = ( - await conversation_agent.generate_opening_message( + await chat_orchestrator.generate_opening_message( conversation_id=conversation_id, memoir_state=state, user_profile_context=user_profile_context, ) ) + await ConversationHistoryStore(db).record_ai_only_turn( + conversation_id, opening_messages + ) for i, text in enumerate(opening_messages): await manager.send_message( conversation_id, @@ -212,8 +217,9 @@ async def websocket_endpoint( while True: try: if websocket.application_state != WebSocketState.CONNECTED: - logger.info( - f"WebSocket 已非连接状态,退出循环: conversation_id={conversation_id}" + logger.debug( + "WebSocket 已非连接状态,退出循环: conversation_id=%s", + conversation_id, ) break message = await websocket.receive_json() @@ -271,9 +277,18 @@ async def websocket_endpoint( elif msg_type == MessageType.RECORDING_STARTED: data = message.get("data", {}) - voice_session_id = _normalize_voice_session_id( - data.get("voice_session_id") - ) + raw_vs = data.get("voice_session_id") + if not raw_vs or not str(raw_vs).strip(): + await manager.send_message( + conversation_id, + { + "type": MessageType.ERROR, + "data": {"message": "缺少 voice_session_id"}, + "timestamp": datetime.now(timezone.utc).isoformat(), + }, + ) + continue + voice_session_id = str(raw_vs).strip() segment_state = get_or_create_segment_state( conversation_id, voice_session_id, @@ -298,12 +313,24 @@ async def websocket_endpoint( data = message.get("data", {}) audio_base64 = data.get("audio_base64", "") segment_index_raw = data.get("segment_index") - voice_session_id = _normalize_voice_session_id( - data.get("voice_session_id") - or _voice_session_id_from_client_segment_id( + resolved_vs = data.get("voice_session_id") or ( + _voice_session_id_from_client_segment_id( data.get("client_segment_id") ) ) + if not resolved_vs or not str(resolved_vs).strip(): + await manager.send_message( + conversation_id, + { + "type": MessageType.ERROR, + "data": { + "message": "缺少 voice_session_id 或有效的 client_segment_id" + }, + "timestamp": datetime.now(timezone.utc).isoformat(), + }, + ) + continue + voice_session_id = str(resolved_vs).strip() is_last = bool(data.get("is_last", False)) audio_duration = int(data.get("duration", 0) or 0) @@ -386,9 +413,14 @@ async def websocket_endpoint( should_process = True if not should_process: - logger.info( - "收到重复分段,跳过处理: " - f"conversation_id={conversation_id}, voice_session_id={voice_session_id}, segment_index={segment_index}" + logger.debug( + "收到重复分段,跳过: conversation_id=%s voice_session_id=%s " + "segment_index=%s audio_b64_len=%s duration=%s", + conversation_id, + voice_session_id, + segment_index, + len(audio_base64 or ""), + audio_duration, ) continue @@ -437,7 +469,11 @@ async def websocket_endpoint( ) continue - logger.info(f"收到音频消息,时长: {audio_duration}s") + logger.debug( + "收到音频消息: conversation_id=%s duration_s=%s", + conversation_id, + audio_duration, + ) try: asr = get_asr_provider() @@ -445,7 +481,16 @@ async def websocket_endpoint( transcript_text = await asr.transcribe( audio_bytes, "m4a" ) - logger.info("ASR 转写结果: %s", transcript_text) + logger.debug( + "ASR 转写完成: conversation_id=%s chars=%s", + conversation_id, + len(transcript_text or ""), + ) + logger.debug( + "ASR 转写全文: conversation_id=%s text=%s", + conversation_id, + transcript_text, + ) await manager.send_message( conversation_id, @@ -591,8 +636,10 @@ async def websocket_endpoint( or "accept" in error_msg.lower() and "not connected" in error_msg.lower() ): - logger.info( - f"WebSocket 连接已断开或未就绪: conversation_id={conversation_id}, error={error_msg}" + logger.debug( + "WebSocket 连接已断开或未就绪: conversation_id=%s error=%s", + conversation_id, + error_msg, ) break else: @@ -613,8 +660,8 @@ async def websocket_endpoint( logger.warning(f"发送错误消息失败: {send_error}") break except WebSocketDisconnect: - logger.info( - f"WebSocket 断开连接: conversation_id={conversation_id}" + logger.debug( + "WebSocket 断开连接: conversation_id=%s", conversation_id ) break except Exception as e: @@ -634,7 +681,7 @@ async def websocket_endpoint( break except WebSocketDisconnect: - logger.info(f"WebSocket 断开连接: conversation_id={conversation_id}") + logger.debug("WebSocket 断开连接: conversation_id=%s", conversation_id) await manager.disconnect(conversation_id) cleanup_segment_states(conversation_id) except Exception as e: diff --git a/api/app/features/memoir/asset_resolver.py b/api/app/features/memoir/asset_resolver.py index 3005082..698dd49 100644 --- a/api/app/features/memoir/asset_resolver.py +++ b/api/app/features/memoir/asset_resolver.py @@ -1,7 +1,7 @@ """ -asset:// 与旧占位符清理。 +asset:// 与正文占位符清理。 -迁移与渲染共用:从正文移除 {{IMAGE:...}} / {{{{IMAGE:...}}}}。 +从正文移除 {{IMAGE:...}} / {{{{IMAGE:...}}}}(历史正文可能仍含此类标记)。 """ import re @@ -16,8 +16,8 @@ _ASSET_REF_RE = re.compile(r"!\[([^\]]*)\]\(asset://([a-zA-Z0-9_-]+)\)") _BLANK_RUN_RE = re.compile(r"\n{3,}") -def strip_legacy_image_placeholders(text: str | None) -> str: - """移除正文中的旧 IMAGE 占位符,保留其余 markdown。""" +def strip_image_placeholders(text: str | None) -> str: + """移除正文中的 IMAGE 占位符,保留其余 markdown。""" if not text: return "" return _PLACEHOLDER_RE.sub("", text).strip() diff --git a/api/app/agents/memoir/processor.py b/api/app/features/memoir/background_runner.py similarity index 92% rename from api/app/agents/memoir/processor.py rename to api/app/features/memoir/background_runner.py index 601878f..9a4c68d 100644 --- a/api/app/agents/memoir/processor.py +++ b/api/app/features/memoir/background_runner.py @@ -1,7 +1,4 @@ -""" -回忆录后台处理器:debounce 聚合后派发 Celery 任务 -实际回忆录生成由 memoir_tasks.process_memoir_segments 调用 MemoirOrchestrator 完成 -""" +"""回忆录后台任务聚合:debounce 后派发 process_memoir_segments(feature 层)。""" from __future__ import annotations diff --git a/api/app/features/memoir/chapter_cover.py b/api/app/features/memoir/chapter_cover.py index 833da04..d9de85b 100644 --- a/api/app/features/memoir/chapter_cover.py +++ b/api/app/features/memoir/chapter_cover.py @@ -38,23 +38,4 @@ def aggregate_cover_prompt_from_stories( parts.append(stage) if summary: parts.append((summary or "")[:100]) - return ",".join(p for p in parts if p) - - -def aggregate_cover_prompt_from_chapter( - chapter_title: str = "", - chapter_category: str = "", - markdown_excerpt: str = "", -) -> str: - """ - 从章节标题、分类、正文摘要聚合封面 prompt。 - 用于无 story_links 的章节(兼容旧 memoir 流程)。 - """ - parts = [] - if chapter_title: - parts.append(chapter_title) - if chapter_category: - parts.append(chapter_category) - if markdown_excerpt: - parts.append(markdown_excerpt[:200].strip()) return ",".join(p for p in parts if p) or "人生回忆录章节" diff --git a/api/app/features/memoir/cover_eligibility.py b/api/app/features/memoir/cover_eligibility.py index 7a08d71..fffd6f8 100644 --- a/api/app/features/memoir/cover_eligibility.py +++ b/api/app/features/memoir/cover_eligibility.py @@ -14,6 +14,13 @@ from app.features.memoir.memoir_images.schema import ( MIN_INLINE_BODY_IMAGES_FOR_CHAPTER_COVER = 3 +def chapter_has_story_links(chapter: Any) -> bool: + return any( + getattr(link, "story", None) + for link in getattr(chapter, "story_links", None) or [] + ) + + def count_chapter_inline_body_images(chapter: Any) -> int: """统计章节 canonical_markdown 中正文插图(asset:// 图片引用)次数。""" md = getattr(chapter, "canonical_markdown", None) or "" @@ -41,6 +48,8 @@ def chapter_needs_cover_enqueue(chapter) -> bool: """尚无 cover_asset、有正文、且正文内 asset 插图多于阈值时,可派发 generate_chapter_cover。""" if not chapter: return False + if not chapter_has_story_links(chapter): + return False if getattr(chapter, "cover_asset_id", None): return False md = (getattr(chapter, "canonical_markdown", None) or "").strip() @@ -52,7 +61,7 @@ def chapter_needs_cover_enqueue(chapter) -> bool: def chapter_has_cover_to_generate(chapter) -> bool: """章节是否有待生成的封面图(任一条 chapter 级 MemoirImage 为 pending/failed)。""" for m in getattr(chapter, "images", None) or []: - status = (getattr(m, "status") or "").strip() + status = (m.status or "").strip() if status in (IMAGE_STATUS_PENDING, IMAGE_STATUS_FAILED): return True return False @@ -65,7 +74,7 @@ def cover_memoir_image_pending_or_failed(chapter: Any) -> Any | None: key=lambda m: getattr(m, "order_index", 0), ) for m in images: - st = (getattr(m, "status") or "").strip() + st = (m.status or "").strip() if st in (IMAGE_STATUS_PENDING, IMAGE_STATUS_FAILED): return m return None diff --git a/api/app/features/memoir/helpers.py b/api/app/features/memoir/helpers.py index 3b09483..d5e49a5 100644 --- a/api/app/features/memoir/helpers.py +++ b/api/app/features/memoir/helpers.py @@ -1,14 +1,11 @@ -""" -回忆录序列化与图片归一化辅助(供 MemoirService 使用)。 -""" - -from typing import Any +"""回忆录序列化与图片归一化辅助(供 MemoirService 使用)。""" from app.core.config import settings from app.core.logging import get_logger from app.features.memoir.asset_resolver import resolve_asset_refs_in_markdown -from app.features.memoir.reading_segment_materialize import ( - resolve_reading_segments_for_chapter_detail, +from app.features.memoir.cover_eligibility import ( + chapter_eligible_for_cover_by_inline_body_image_count, + primary_chapter_memoir_image, ) from app.features.memoir.memoir_images.schema import ( IMAGE_STATUS_COMPLETED, @@ -25,11 +22,10 @@ from app.features.memoir.memoir_images.storage import ( normalize_cos_url, resolve_image_storage_key, ) -from app.features.memoir.cover_eligibility import ( - chapter_eligible_for_cover_by_inline_body_image_count, - primary_chapter_memoir_image, -) from app.features.memoir.models import Chapter +from app.features.memoir.reading_segment_materialize import ( + resolve_reading_segments_for_chapter_detail, +) logger = get_logger(__name__) @@ -93,59 +89,6 @@ def is_image_permanently_unavailable(rec) -> bool: return False -def story_primary_cover_image_dict( - story: Any, asset_url_map: dict[str, str] | None = None -) -> dict | None: - """ - Story 主插图(StoryImageIntent intent_role=primary)。 - asset_url_map: asset_id -> 签名 URL;无 URL 时仍返回 pending/processing 等状态供客户端占位。 - """ - asset_url_map = asset_url_map or {} - intents = getattr(story, "image_intents", None) or [] - primary = None - for it in intents: - if getattr(it, "intent_role", None) == "primary": - primary = it - break - if not primary: - return None - aid = getattr(primary, "asset_id", None) - url = asset_url_map.get(str(aid)) if aid else None - status = getattr(primary, "status", None) or "pending" - return { - "placeholder": "", - "description": getattr(primary, "caption", None) or "故事配图", - "index": 0, - "status": status, - "prompt": getattr(primary, "prompt_brief", None), - "url": url, - "storage_key": None, - "provider": None, - "style": getattr(primary, "style_profile", None), - "size": None, - "error": getattr(primary, "error", None), - "retryable": None, - "created_at": primary.created_at.isoformat() if primary.created_at else None, - "updated_at": primary.updated_at.isoformat() if primary.updated_at else None, - } - - -def build_reading_segments( - ch: Chapter, - asset_url_map: dict[str, str] | None = None, -) -> list[dict]: - """与 chapter_story_links 顺序一致;每段 body 已清洗。 - - 配图策略:StoryImageIntent(intent_role=primary)的 asset_id → 签名 URL; - 无 intent 或未完成时 cover_image 为 null,客户端展示占位块;补图仍由 story_image_tasks 驱动。 - """ - from app.features.memoir.reading_segment_materialize import ( - materialize_chapter_reading_segments, - ) - - return materialize_chapter_reading_segments(ch, asset_url_map) - - def chapter_cover_to_dict( ch: Chapter, asset_url_map: dict[str, str] | None = None ) -> dict | None: @@ -174,19 +117,9 @@ def chapter_cover_to_dict( "created_at": None, "updated_at": None, } - if getattr(ch, "cover_image", None) and isinstance(ch.cover_image, dict): - return ch.cover_image return None -def sections_to_content_and_images(ch: Chapter) -> tuple[str, list[dict]]: - """ - stories-first:正文以 canonical_markdown 为准;正文插图在 story 层,章节 API 不返回 section 级配图列表。 - """ - md = _chapter_markdown(ch) - return md, [] - - def _chapter_markdown(ch: Chapter) -> str: """正文真源:canonical_markdown。""" md = getattr(ch, "canonical_markdown", None) @@ -198,10 +131,7 @@ def _chapter_markdown(ch: Chapter) -> str: def chapter_to_list_dict( ch: Chapter, asset_url_map: dict[str, str] | None = None ) -> dict: - """ - 列表视图:与详情字段对齐的最小子集 + 客户端兼容字段。 - 含 status、canonical_markdown、content、cover_image(与 cover_asset 同构)、images、sections、word_count。 - """ + """列表视图:与详情字段对齐的最小子集。""" cover = chapter_cover_to_dict(ch, asset_url_map=asset_url_map) cover_normalized = first_normalized_image_for_api(cover) canonical_raw = _chapter_markdown(ch) @@ -214,11 +144,8 @@ def chapter_to_list_dict( "status": getattr(ch, "status", None) or "draft", "summary": getattr(ch, "summary", None) or "", "canonical_markdown": canonical_raw, - "content": canonical_raw, "cover_asset": cover_normalized, - "cover_image": cover_normalized, "images": [], - "sections": [], "word_count": wcount, "updated_at": ch.updated_at.isoformat() if ch.updated_at else None, "is_new": getattr(ch, "is_new", False), @@ -227,16 +154,12 @@ def chapter_to_list_dict( def chapter_to_dict(ch: Chapter, asset_url_map: dict[str, str] | None = None) -> dict: - """详情视图:含 canonical_markdown、rendered_assets。asset_url_map 用于解析 asset:// 与 cover_asset_id。""" + """详情视图:stories-first 契约。asset_url_map 用于解析 asset:// 与 cover_asset_id。""" asset_url_map = asset_url_map or {} resolve = lambda aid: asset_url_map.get(aid) # noqa: E731 - content, images_list = sections_to_content_and_images(ch) - content = resolve_asset_refs_in_markdown(content, resolve) - normalized_images = normalize_image_assets_for_api(images_list) cover = chapter_cover_to_dict(ch, asset_url_map=asset_url_map) cover_normalized = first_normalized_image_for_api(cover) - sections_data: list[dict] = [] # 正文真源:优先 canonical_markdown canonical_md = _chapter_markdown(ch) canonical_md = resolve_asset_refs_in_markdown(canonical_md, resolve) @@ -246,15 +169,12 @@ def chapter_to_dict(ch: Chapter, asset_url_map: dict[str, str] | None = None) -> return { "id": ch.id, "title": ch.title, - "content": content, "canonical_markdown": canonical_md, "order_index": ch.order_index, "status": ch.status, "category": ch.category, - "images": normalized_images, - "cover_image": cover_normalized, - "rendered_assets": normalized_images, - "sections": sections_data, + "images": [], + "cover_asset": cover_normalized, "reading_segments": reading_segments, "updated_at": ch.updated_at.isoformat() if ch.updated_at else None, "is_new": ch.is_new, diff --git a/api/app/features/memoir/memoir_images/parser.py b/api/app/features/memoir/memoir_images/parser.py index a3565a5..8a762a1 100644 --- a/api/app/features/memoir/memoir_images/parser.py +++ b/api/app/features/memoir/memoir_images/parser.py @@ -2,7 +2,7 @@ import json import re from typing import Any -from app.features.memoir.asset_resolver import strip_legacy_image_placeholders +from app.features.memoir.asset_resolver import strip_image_placeholders from .json_payload import extract_json_payload from .schema import IMAGE_STATUS_PENDING @@ -87,7 +87,7 @@ def parse_narrative_json(raw: str) -> list[dict[str, Any]]: def split_plain_narrative_into_sections(narrative: str) -> list[dict[str, Any]]: """非 JSON 叙事:去掉遗留占位符后按空行拆段,不产生段落配图。""" - text = strip_legacy_image_placeholders(narrative or "") + text = strip_image_placeholders(narrative or "") if not text.strip(): return [] parts = [p.strip() for p in text.split("\n\n") if p.strip()] diff --git a/api/app/features/memoir/memoir_images/provider.py b/api/app/features/memoir/memoir_images/provider.py deleted file mode 100644 index 745a2f3..0000000 --- a/api/app/features/memoir/memoir_images/provider.py +++ /dev/null @@ -1,9 +0,0 @@ -""" -LiblibImageProvider 已迁至 app.adapters.image_gen.liblib_provider; -此处仅 re-export,以便 memoir 与 tasks 的既有引用不中断。 -Feature 应通过 port ImageGenerator + get_image_generator() 使用图生能力。 -""" - -from app.adapters.image_gen.liblib_provider import LiblibImageProvider # noqa: F401 - -__all__ = ["LiblibImageProvider"] diff --git a/api/app/features/memoir/memoir_images/schema.py b/api/app/features/memoir/memoir_images/schema.py index fe6df39..90b3fed 100644 --- a/api/app/features/memoir/memoir_images/schema.py +++ b/api/app/features/memoir/memoir_images/schema.py @@ -21,8 +21,8 @@ _PLACEHOLDER_DESCRIPTION_RE = re.compile( def normalize_image_asset(asset: dict[str, Any] | None) -> dict[str, Any] | None: """归一化单条图片 dict。 - - 兼容旧正文中的 {{{{IMAGE:…}}}} / {{IMAGE:…}} 占位符(需能解析出 description)。 - - 新模型:插图不嵌入 markdown,可无占位符;已完成且带 url/storage_key 即可通过, + - 可解析 {{{{IMAGE:…}}}} / {{IMAGE:…}} 占位符中的 description。 + - 插图不嵌入 markdown 时可无占位符;已完成且带 url/storage_key 即可通过, description 缺省时用「插图」;pending/processing 至少要有 description、占位符或 prompt。 """ if not isinstance(asset, dict): diff --git a/api/app/features/memoir/memoir_images/storage.py b/api/app/features/memoir/memoir_images/storage.py index e5aeb40..67604a9 100644 --- a/api/app/features/memoir/memoir_images/storage.py +++ b/api/app/features/memoir/memoir_images/storage.py @@ -1,9 +1,10 @@ -from app.core.logging import get_logger from urllib.parse import urlparse, urlunparse from qcloud_cos import CosConfig, CosS3Client from qcloud_cos.cos_exception import CosClientError, CosServiceError +from app.core.logging import get_logger + logger = get_logger(__name__) @@ -148,8 +149,14 @@ class TencentCosStorageService: ) etag = response.get("ETag", "") request_id = response.get("x-cos-request-id", "") - logger.info( - "COS upload ok: key=%s, ETag=%s, request_id=%s", key, etag, request_id + public_url = f"{self.base_url}/{key}" + logger.debug( + "COS upload ok: key=%s url=%s ETag=%s request_id=%s bytes=%s", + key, + public_url, + etag, + request_id, + len(image_bytes), ) except (CosClientError, CosServiceError) as exc: retryable = _is_retryable_cos_error(exc) @@ -161,7 +168,7 @@ class TencentCosStorageService: retryable=retryable, request_id=request_id, ) from exc - return f"{self.base_url}/{key}" + return public_url def get_download_url(self, key: str, expires: int = 3600) -> str: try: diff --git a/api/app/features/memoir/models.py b/api/app/features/memoir/models.py index 4c9f1fd..4066088 100644 --- a/api/app/features/memoir/models.py +++ b/api/app/features/memoir/models.py @@ -27,12 +27,10 @@ class Chapter(Base): summary = Column(Text, nullable=True) canonical_markdown = Column(Text, nullable=True) # 当前生效正文(markdown-first) status = Column(String, default="draft") # active / draft / archived - cover_image = Column(JSON, nullable=True) # 兼容旧数据,逐步迁移到 cover_asset_id cover_asset_id = Column(String, nullable=True) current_version_id = Column(String, nullable=True) # FK 在 migration 中分步添加 created_at = Column(DateTime(timezone=True), default=utc_now) updated_at = Column(DateTime(timezone=True), default=utc_now, onupdate=utc_now) - # 兼容旧运行时,迁移后废弃 is_new = Column(Boolean, default=True) is_active = Column(Boolean, default=True) source_segments = Column(JSON, nullable=True) diff --git a/api/app/features/memoir/pdf_service.py b/api/app/features/memoir/pdf_service.py index 4ef7996..3e20321 100644 --- a/api/app/features/memoir/pdf_service.py +++ b/api/app/features/memoir/pdf_service.py @@ -2,110 +2,48 @@ PDF 生成服务(从 services 迁入 memoir feature) """ -from app.core.logging import get_logger from io import BytesIO from typing import List, Optional import httpx from PIL import Image from reportlab.lib.pagesizes import A4 -from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet from reportlab.lib.units import inch +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.cidfonts import UnicodeCIDFont from reportlab.platypus import ( Image as ReportLabImage, +) +from reportlab.platypus import ( PageBreak, Paragraph, SimpleDocTemplate, Spacer, ) -from reportlab.pdfbase import pdfmetrics -from reportlab.pdfbase.cidfonts import UnicodeCIDFont +from app.core.logging import get_logger from app.features.memoir.asset_resolver import ( collect_asset_ids_from_markdown, split_markdown_by_asset_refs, - strip_legacy_image_placeholders, + strip_image_placeholders, ) from app.features.memoir.chapter_markdown_compose import ( materialize_chapter_pdf_markdown_from_loaded_chapter, ) -from app.features.memoir.helpers import ( - _chapter_markdown, - sections_to_content_and_images, -) +from app.features.memoir.helpers import _chapter_markdown + +logger = get_logger(__name__) def _chapter_markdown_for_pdf(chapter) -> str: """有 story 编排时 PDF 使用「## 故事名 + 正文」物化;否则沿用章节 canonical。""" links = getattr(chapter, "story_links", None) or [] - if links and any(getattr(l, "story", None) for l in links): + if links and any(getattr(link, "story", None) for link in links): return materialize_chapter_pdf_markdown_from_loaded_chapter(chapter) return _chapter_markdown(chapter) -from app.features.memoir.memoir_images.parser import PLACEHOLDER_RE -from app.features.memoir.memoir_images.schema import ( - IMAGE_STATUS_COMPLETED, - normalize_image_assets, -) -from app.features.memoir.memoir_images.storage import ( - CosDownloadUrlError, - TencentCosStorageService, - mark_image_delivery_unavailable, - resolve_image_storage_key, -) - -logger = get_logger(__name__) - - -def strip_image_placeholders(text: str) -> str: - return PLACEHOLDER_RE.sub("", text or "").strip() - - -def split_content_blocks(content: str, images: list[dict]) -> list[dict]: - blocks: list[dict] = [] - remaining = content - for image in sorted(images or [], key=lambda item: item.get("index", 0)): - placeholder = image.get("placeholder") - if not placeholder or placeholder not in remaining: - continue - before, remaining = remaining.split(placeholder, 1) - cleaned_before = strip_image_placeholders(before) - if cleaned_before: - blocks.append({"type": "text", "value": cleaned_before}) - if image.get("status") == IMAGE_STATUS_COMPLETED and image.get("url"): - blocks.append({"type": "image", "url": image["url"]}) - cleaned_remaining = strip_image_placeholders(remaining) - if cleaned_remaining: - blocks.append({"type": "text", "value": cleaned_remaining}) - return blocks - - -def _prepare_pdf_image_assets(images: list[dict]) -> list[dict]: - storage = TencentCosStorageService.from_env() - prepared_assets: list[dict] = [] - for item in normalize_image_assets(images): - asset = dict(item) - storage_key = resolve_image_storage_key(asset) - if asset.get("status") == IMAGE_STATUS_COMPLETED and storage_key: - try: - asset["url"] = storage.get_download_url(storage_key) - except CosDownloadUrlError as exc: - logger.warning( - "PDF 图片签名失败: key=%s, retryable=%s, request_id=%s, error=%s", - storage_key, - exc.retryable, - exc.request_id, - exc, - ) - asset = mark_image_delivery_unavailable(asset) - except Exception as exc: - logger.warning("PDF 图片签名失败: key=%s, error=%s", storage_key, exc) - asset = mark_image_delivery_unavailable(asset) - prepared_assets.append(asset) - return prepared_assets - - def _fit_image_size( image_bytes: bytes, max_width: float, max_height: float ) -> tuple[float, float]: @@ -180,12 +118,6 @@ class PDFService: story.append(Spacer(1, 0.2 * inch)) # 有 story_links 时按章节内故事注入 ## 标题(与物化章节正文不含故事标题区分) markdown = _chapter_markdown_for_pdf(chapter) - _, images_list = sections_to_content_and_images(chapter) - if not markdown: - markdown = getattr(chapter, "content", "") or "" - if not images_list: - images_list = list(getattr(chapter, "images", None) or []) - prepared_images = _prepare_pdf_image_assets(images_list) blocks: list[dict] if asset_url_map and collect_asset_ids_from_markdown(markdown): blocks = split_markdown_by_asset_refs( @@ -194,11 +126,14 @@ class PDFService: ) for b in blocks: if b.get("type") == "text": - b["value"] = strip_legacy_image_placeholders( - b.get("value") or "" - ) + b["value"] = strip_image_placeholders(b.get("value") or "") else: - blocks = split_content_blocks(markdown, prepared_images) + cleaned_markdown = strip_image_placeholders(markdown or "") + blocks = ( + [{"type": "text", "value": cleaned_markdown}] + if cleaned_markdown + else [] + ) for block in blocks: if block["type"] == "text": paragraphs = block["value"].split("\n\n") diff --git a/api/app/features/memoir/processor.py b/api/app/features/memoir/processor.py deleted file mode 100644 index 0d19bf0..0000000 --- a/api/app/features/memoir/processor.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Memoir processor — 从 agents/memoir_processor.py 迁入的占位。 -实际逻辑仍由 agents/memoir_processor.py 提供,后续迁入。""" - -from app.agents.memoir import BackgroundTaskRunner - -__all__ = ["BackgroundTaskRunner"] diff --git a/api/app/features/memoir/reading_segment_materialize.py b/api/app/features/memoir/reading_segment_materialize.py index 1d65e11..0cf9521 100644 --- a/api/app/features/memoir/reading_segment_materialize.py +++ b/api/app/features/memoir/reading_segment_materialize.py @@ -1,7 +1,4 @@ -""" -章节阅读片段物化:与 canonical 同一生成时机写入 reading_segments_json(无签名 URL); -API 读时 hydrate 或(dirty / 无快照)回退为运行时物化。 -""" +"""章节阅读片段物化:与 canonical 同一生成时机写入 reading_segments_json(无签名 URL)。""" from __future__ import annotations @@ -100,52 +97,12 @@ def build_reading_segments_snapshot(ch: Chapter) -> list[dict[str, Any]]: { "story_id": st.id, "body_markdown": body, - "cover_image": cover, + "cover_asset": cover, } ) return out -def materialize_chapter_reading_segments( - ch: Chapter, - asset_url_map: dict[str, str] | None = None, -) -> list[dict[str, Any]]: - """运行时物化(解析签名 URL),与旧 build_reading_segments 行为一致。""" - from app.features.memoir import helpers as h - - asset_url_map = asset_url_map or {} - resolve = lambda aid: asset_url_map.get(aid) # noqa: E731 - links = sorted( - list(getattr(ch, "story_links", None) or []), - key=lambda x: getattr(x, "order_index", 0), - ) - segments: list[dict[str, Any]] = [] - for link in links: - st = getattr(link, "story", None) - if st is None: - continue - title = (getattr(st, "title", None) or "").strip() - raw = (getattr(st, "canonical_markdown", None) or "").strip() - body = sanitize_story_for_chapter_compose(raw, title) - if not body: - continue - body_md = resolve_asset_refs_in_markdown(body, resolve) - img_raw = h.story_primary_cover_image_dict(st, asset_url_map=asset_url_map) - primary_aid = _primary_story_intent_asset_id(st) - inline_ids = set(collect_asset_ids_from_markdown(body)) - if img_raw and primary_aid and primary_aid in inline_ids: - img_raw = None - img_norm = h.first_normalized_image_for_api(img_raw) if img_raw else None - segments.append( - { - "story_id": st.id, - "body_markdown": body_md, - "cover_image": img_norm, - } - ) - return segments - - def hydrate_reading_segments_from_snapshot( ch: Chapter, asset_url_map: dict[str, str] | None = None, @@ -159,7 +116,7 @@ def hydrate_reading_segments_from_snapshot( out: list[dict[str, Any]] = [] for row in rows: body = resolve_asset_refs_in_markdown(row["body_markdown"], resolve) - ci = row.get("cover_image") + ci = row.get("cover_asset") if ci: img_raw = _cover_dict_from_snapshot_row(ci, asset_url_map) img_norm = h.first_normalized_image_for_api(img_raw) @@ -169,7 +126,7 @@ def hydrate_reading_segments_from_snapshot( { "story_id": row["story_id"], "body_markdown": body, - "cover_image": img_norm, + "cover_asset": img_norm, } ) return out @@ -179,10 +136,8 @@ def resolve_reading_segments_for_chapter_detail( ch: Chapter, asset_url_map: dict[str, str] | None = None, ) -> list[dict[str, Any]]: - """章节详情:dirty 或无快照列时运行时物化;否则 hydrate。""" + """章节详情:仅读取已物化快照。""" asset_url_map = asset_url_map or {} - dirty = getattr(ch, "markdown_compose_dirty", True) - has_snapshot = getattr(ch, "reading_segments_json", None) is not None - if has_snapshot and not dirty: - return hydrate_reading_segments_from_snapshot(ch, asset_url_map=asset_url_map) - return materialize_chapter_reading_segments(ch, asset_url_map=asset_url_map) + if getattr(ch, "reading_segments_json", None) is None: + return [] + return hydrate_reading_segments_from_snapshot(ch, asset_url_map=asset_url_map) diff --git a/api/app/features/memoir/repo.py b/api/app/features/memoir/repo.py index d9f53b2..e36598e 100644 --- a/api/app/features/memoir/repo.py +++ b/api/app/features/memoir/repo.py @@ -12,9 +12,6 @@ from app.features.memoir.asset_resolver import collect_asset_ids_for_chapter from app.features.memoir.chapter_markdown_compose import ( materialize_chapter_markdown_from_loaded_chapter, ) -from app.features.memoir.reading_segment_materialize import ( - build_reading_segments_snapshot, -) from app.features.memoir.models import ( Book, Chapter, @@ -23,6 +20,9 @@ from app.features.memoir.models import ( ChapterVersion, MemoirState, ) +from app.features.memoir.reading_segment_materialize import ( + build_reading_segments_snapshot, +) from app.features.story.models import Story @@ -64,19 +64,6 @@ async def get_chapters_for_memoir_list( return list(result.unique().scalars().all()) -async def get_chapters_with_sections( - user_id: str, - db: AsyncSession, - *, - active_only: bool = True, - is_new_only: bool | None = None, -) -> list[Chapter]: - """兼容旧名:与 get_chapters_for_memoir_list 相同。""" - return await get_chapters_for_memoir_list( - user_id, db, active_only=active_only, is_new_only=is_new_only - ) - - async def get_chapter_by_id(chapter_id: str, db: AsyncSession) -> Chapter | None: stmt = ( select(Chapter) @@ -98,138 +85,6 @@ async def get_memoir_state(user_id: str, db: AsyncSession) -> MemoirState | None return result.scalar_one_or_none() -def get_archived_chapter_summaries_sync( - session: Session, user_id: str, category: str -) -> list[tuple[str, str]]: - """获取已删除(is_active=False)的同类别章节的标题与内容摘要,供 AI 参考。""" - stmt = ( - select(Chapter) - .where( - Chapter.user_id == user_id, - Chapter.category == category, - Chapter.is_active == False, # noqa: E712 - ) - .order_by(Chapter.updated_at.desc()) - ) - result = session.execute(stmt) - chapters = list(result.unique().scalars().all()) - summaries: list[tuple[str, str]] = [] - for ch in chapters: - combined = (getattr(ch, "canonical_markdown", None) or "").strip() - preview = (combined[:200] + "...") if len(combined) > 200 else combined - if preview.strip(): - summaries.append((ch.title or "", preview)) - return summaries - - -def ensure_chapter_markdown_and_version_sync( - session: Session, - chapter: Chapter, - markdown: str, -) -> None: - """ - 为已有 chapter 设置 canonical_markdown 并创建 chapter_version。 - 供非 story 物化路径(如 save_chapter_markdown_sync)使用。 - """ - from sqlalchemy import func - - count_stmt = select(func.count(ChapterVersion.id)).where( - ChapterVersion.chapter_id == chapter.id - ) - version_no = (session.execute(count_stmt).scalar() or 0) + 1 - - version = ChapterVersion( - id=str(uuid.uuid4()), - chapter_id=chapter.id, - version_no=version_no, - markdown_snapshot=markdown, - actor_type="ai", - source_type="generate", - ) - session.add(version) - session.flush() - chapter.canonical_markdown = markdown - chapter.current_version_id = version.id - - -def save_chapter_markdown_sync( - session: Session, - *, - user_id: str, - chapter_id: str | None, - title: str, - category: str, - order_index: int, - markdown: str, - source_segments: list[str] | None = None, -) -> Chapter: - """ - 将 markdown 写入 chapter.canonical_markdown 和 chapter_versions。 - Agent 不直接调用,由 service/task 调用。 - 若 chapter_id 为 None 则新建章节。 - """ - if chapter_id: - chapter = session.get(Chapter, chapter_id) - if not chapter or chapter.user_id != user_id: - raise ValueError(f"Chapter {chapter_id} not found or access denied") - else: - chapter = Chapter( - id=str(uuid.uuid4()), - user_id=user_id, - title=title, - category=category, - order_index=order_index, - status="completed", - is_new=True, - is_active=True, - source_segments=source_segments or [], - ) - session.add(chapter) - session.flush() - - # 创建 chapter_version - from sqlalchemy import func - - count_stmt = select(func.count(ChapterVersion.id)).where( - ChapterVersion.chapter_id == chapter.id - ) - version_no = (session.execute(count_stmt).scalar() or 0) + 1 - - version = ChapterVersion( - id=str(uuid.uuid4()), - chapter_id=chapter.id, - version_no=version_no, - markdown_snapshot=markdown, - actor_type="ai", - source_type="generate", - ) - session.add(version) - session.flush() - - chapter.canonical_markdown = markdown - chapter.current_version_id = version.id - chapter.title = title - chapter.is_new = True - if source_segments: - chapter.source_segments = list( - set((chapter.source_segments or []) + source_segments) - ) - - session.flush() - session.refresh(chapter) - return chapter - - -async def count_chapter_story_links(db: AsyncSession, chapter_id: str) -> int: - stmt = ( - select(func.count()) - .select_from(ChapterStoryLink) - .where(ChapterStoryLink.chapter_id == chapter_id) - ) - n = await db.scalar(stmt) - return int(n or 0) - - async def get_chapter_ids_linked_to_story(db: AsyncSession, story_id: str) -> list[str]: stmt = select(ChapterStoryLink.chapter_id).where( ChapterStoryLink.story_id == story_id diff --git a/api/app/features/memoir/router.py b/api/app/features/memoir/router.py index dbab237..3d92c85 100644 --- a/api/app/features/memoir/router.py +++ b/api/app/features/memoir/router.py @@ -85,8 +85,7 @@ async def get_chapters( service: MemoirService = Depends(get_memoir_service), ): """ - 获取用户所有章节(需要认证,仅返回 active 章节)。 - 始终返回全部 8 个预定义类别,没有内容的类别用占位符返回。 + 获取用户所有有效章节(需要认证,仅返回 active 章节)。 """ return await service.get_chapters(current_user.id, is_new=is_new) @@ -107,7 +106,7 @@ async def check_cover_generation( service: MemoirService = Depends(get_memoir_service), ): """ - 检查可生成封面的章节(section 配图 > 3 且无已完成封面), + 检查可生成封面的章节(story-linked 且正文 asset 插图 > 3), 若有则触发生成任务。已有封面的章节不再检查。 """ return await service.check_and_trigger_cover_generation(current_user.id) @@ -123,16 +122,6 @@ async def disable_chapter( return await service.disable_chapter(chapter_id, current_user.id) -@router.post("/chapters/{chapter_id}/regenerate") -async def regenerate_chapter( - chapter_id: str, - current_user: User = Depends(get_current_user), - service: MemoirService = Depends(get_memoir_service), -): - """重新整理章节(需要认证,只能操作自己的章节)""" - return await service.regenerate_chapter(chapter_id, current_user.id) - - @router.put("/chapters/{chapter_id}/story-order") async def set_chapter_story_order( chapter_id: str, diff --git a/api/app/features/memoir/service.py b/api/app/features/memoir/service.py index 67c9042..b7242d4 100644 --- a/api/app/features/memoir/service.py +++ b/api/app/features/memoir/service.py @@ -8,18 +8,13 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import joinedload -from app.agents.memoir.prompts import ( - CHAPTER_CATEGORIES, - CHAPTER_ORDER, - STAGE_TO_ORDER, -) from app.core.logging import get_logger from app.core.storage_purge import delete_object_storage_keys_best_effort from app.features.memoir import repo from app.features.memoir.asset_resolver import ( collect_asset_ids_for_chapter, collect_asset_ids_for_chapters, - strip_legacy_image_placeholders, + strip_image_placeholders, ) from app.features.memoir.asset_urls import signed_urls_for_asset_ids from app.features.memoir.chapter_markdown_compose import ( @@ -78,6 +73,18 @@ class MemoirService: await self._db.commit() await self._db.refresh(ch) + async def _ensure_chapter_materialized(self, chapter: Chapter) -> Chapter: + has_story_links = bool(getattr(chapter, "story_links", None)) + has_snapshot = chapter.reading_segments_json is not None + if not has_story_links or (has_snapshot and not chapter.markdown_compose_dirty): + return chapter + + markdown = materialize_chapter_markdown_from_loaded_chapter(chapter) + await repo.append_chapter_compose_version_async(self._db, chapter, markdown) + await self._db.commit() + refreshed = await repo.get_chapter_by_id(chapter.id, self._db) + return refreshed or chapter + async def get_current_book(self, user_id: str) -> dict: book = await repo.get_current_book(user_id, self._db) if not book: @@ -151,47 +158,18 @@ class MemoirService: async def get_chapters( self, user_id: str, is_new: bool | None = None ) -> List[dict]: - chapters = await repo.get_chapters_with_sections( + chapters = await repo.get_chapters_for_memoir_list( user_id, self._db, is_new_only=is_new ) + if not chapters: + return [] + asset_ids: set[str] = set() for ch in chapters: asset_ids |= collect_asset_ids_for_chapter(ch) asset_map = await signed_urls_for_asset_ids(self._db, asset_ids) - chapter_by_category: dict[str, Chapter] = {} - for ch in chapters: - if ch.category and ch.category not in chapter_by_category: - chapter_by_category[ch.category] = ch all_chapters: List[dict] = [] - for category in CHAPTER_ORDER: - ch = chapter_by_category.pop(category, None) - if ch: - await self._cleanup_unavailable_images(ch) - all_chapters.append(chapter_to_list_dict(ch, asset_url_map=asset_map)) - else: - if is_new is True: - continue - all_chapters.append( - { - "id": f"placeholder_{category}", - "title": CHAPTER_CATEGORIES[category], - "category": category, - "order_index": STAGE_TO_ORDER.get(category, 999), - "status": "empty", - "summary": "", - "canonical_markdown": "", - "content": "", - "cover_asset": None, - "cover_image": None, - "images": [], - "sections": [], - "word_count": 0, - "updated_at": None, - "is_new": False, - "source_segments": [], - } - ) - for ch in chapter_by_category.values(): + for ch in chapters: await self._cleanup_unavailable_images(ch) all_chapters.append(chapter_to_list_dict(ch, asset_url_map=asset_map)) return all_chapters @@ -204,6 +182,7 @@ class MemoirService: raise HTTPException(status_code=403, detail="无权访问此章节") if not chapter.is_active: raise HTTPException(status_code=404, detail="Chapter not found") + chapter = await self._ensure_chapter_materialized(chapter) await self._cleanup_unavailable_images(chapter) asset_map = await signed_urls_for_asset_ids( self._db, collect_asset_ids_for_chapter(chapter) @@ -226,21 +205,6 @@ class MemoirService: ) return {"status": "ok", "message": "章节已清除"} - async def regenerate_chapter(self, chapter_id: str, user_id: str) -> dict: - chapter = await self._db.get(Chapter, chapter_id) - if not chapter: - raise HTTPException(status_code=404, detail="Chapter not found") - if chapter.user_id != user_id: - raise HTTPException(status_code=403, detail="无权操作此章节") - n = await repo.count_chapter_story_links(self._db, chapter_id) - if n > 0: - raise HTTPException( - status_code=400, - detail="该章节由故事编排驱动,请更新故事正文或调整故事顺序,不支持在此处整章再生。", - ) - # TODO: 非 story-backed 章节的 LLM 重新整理 - return {"status": "ok", "message": "Chapter regeneration triggered"} - async def set_chapter_story_order( self, chapter_id: str, user_id: str, story_ids: list[str] ) -> dict: @@ -290,7 +254,7 @@ class MemoirService: async def check_and_trigger_cover_generation(self, user_id: str) -> dict: """ - 有正文、尚无 cover_asset、且 legacy 封面 MemoirImage 未 completed 时, + 有正文、尚无 cover_asset、且封面 MemoirImage 未 completed 时, 派发 generate_chapter_cover(由 intent/asset 闭环完成)。 """ from app.tasks.chapter_cover_enqueue import try_enqueue_generate_chapter_cover @@ -299,17 +263,17 @@ class MemoirService: if not img_settings.enabled: return {"triggered": []} - chapters = await repo.get_chapters_with_sections( + chapters = await repo.get_chapters_for_memoir_list( user_id, self._db, active_only=True, is_new_only=None ) triggered: List[str] = [] for ch in chapters: - if not ch.category or ch.status == "empty": + if not ch.category or not getattr(ch, "story_links", None): continue if getattr(ch, "cover_asset_id", None): continue md = (ch.canonical_markdown or "").strip() - body = strip_legacy_image_placeholders(md).strip() if md else "" + body = strip_image_placeholders(md).strip() if md else "" if not body: continue cover_rec = primary_chapter_memoir_image(ch) diff --git a/api/app/features/memoir/story_pipeline_sync.py b/api/app/features/memoir/story_pipeline_sync.py index 3d49001..de78f55 100644 --- a/api/app/features/memoir/story_pipeline_sync.py +++ b/api/app/features/memoir/story_pipeline_sync.py @@ -12,7 +12,11 @@ from sqlalchemy.orm import Session, joinedload from app.agents.memoir.narrative_agent import NarrativeAgent from app.agents.memoir.prompts import STAGE_TO_ORDER, format_evidence_chunks_for_prompt -from app.agents.memoir.story_route_agent import StoryRouteAgent +from app.agents.memoir.story_route_agent import ( + PLAN_BATCH_MAX_SEGMENTS, + StoryBatchPlan, + StoryRouteAgent, +) from app.agents.state_schema import MemoirStateSchema from app.core.logging import get_logger from app.features.memoir.cover_eligibility import chapter_needs_cover_enqueue @@ -21,6 +25,7 @@ from app.features.memoir.memoir_images.settings import MemoirImageSettings from app.features.memoir.models import Chapter from app.features.memoir.narrative_to_markdown import narrative_to_markdown from app.features.memoir.repo import compose_chapter_from_story_links_sync +from app.features.memory.repo import retrieve_evidence_sync from app.features.story.models import Story from app.features.story.sync_write import ( append_story_version_sync, @@ -28,7 +33,6 @@ from app.features.story.sync_write import ( ensure_chapter_story_link_sync, list_active_stories_for_user_sync, ) -from app.features.memory.repo import retrieve_evidence_sync logger = get_logger(__name__) @@ -40,6 +44,172 @@ def _is_json_narrative(text: str) -> bool: return s.startswith("{") and "paragraphs" in s +def _ordered_text_for_segment_ids( + category_segments: list, segment_ids: list[str] +) -> str: + id_to_text = {seg.id: (seg.transcript_text or "") for seg in category_segments} + return "\n\n".join(id_to_text.get(sid, "") for sid in segment_ids) + + +def _apply_narrative_fallbacks( + narrative_raw: str, + combined_unit_text: str, + existing_for_narrative: str, + existing_chapter_md: str, + *, + chapter_category: str, +) -> str: + if ( + existing_for_narrative + and not _is_json_narrative(narrative_raw) + and len(narrative_raw) < len(existing_for_narrative) * 0.8 + ): + logger.warning("叙事长度异常: 回退为原文追加") + return f"{existing_for_narrative}\n\n{combined_unit_text}" + + if ( + not existing_for_narrative + and existing_chapter_md + and not _is_json_narrative(narrative_raw) + and len(narrative_raw) < len(existing_chapter_md) * 0.8 + ): + logger.warning( + "章节级长度异常: 回退为 transcript 追加, category=%s", + chapter_category, + ) + return f"{existing_chapter_md}\n\n{combined_unit_text}" + return narrative_raw + + +def _ensure_chapter_record( + session: Session, + *, + user_id: str, + chapter_category: str, + title: str, + source_ids: list[str], + calculated_order_index: int, +) -> Chapter: + stmt_chapter = ( + select(Chapter) + .where( + Chapter.user_id == user_id, + Chapter.category == chapter_category, + Chapter.is_active == True, # noqa: E712 + ) + .options( + joinedload(Chapter.images), + joinedload(Chapter.story_links), + ) + ) + chapter = session.execute(stmt_chapter).unique().scalar_one_or_none() + if not chapter: + chapter = Chapter( + id=str(uuid.uuid4()), + user_id=user_id, + title=title, + order_index=calculated_order_index, + status="completed", + category=chapter_category, + is_new=True, + source_segments=source_ids, + ) + session.add(chapter) + session.flush() + else: + chapter.source_segments = list( + set((chapter.source_segments or []) + source_ids) + ) + chapter.is_new = True + session.flush() + return chapter + + +def _run_batch_plan_writes( + session: Session, + *, + plan: StoryBatchPlan, + category_segments: list, + chapter: Chapter, + chapter_category: str, + evidence_text: str, + existing_chapter_md: str, + slot_snippets: dict[str, str], + user_id: str, + user_profile: str, + user_birth_year: int | None, + llm: Any, + narrative_agent: NarrativeAgent, +) -> set[str]: + dispatch_ids: set[str] = set() + for unit in plan.units: + unit_text = _ordered_text_for_segment_ids(category_segments, unit.segment_ids) + new_content_input = ( + f"{unit_text}\n\n【相关记忆摘录】\n{evidence_text}" + if evidence_text.strip() + else unit_text + ) + + target_story_id: str | None = None + existing_for_narrative = "" + if unit.decision == "append_story" and unit.target_story_id: + st = session.get(Story, unit.target_story_id) + if st and st.user_id == user_id: + target_story_id = st.id + existing_for_narrative = (st.canonical_markdown or "").strip() + + narrative_raw = narrative_agent.generate_narrative( + stage=chapter_category, + slots=slot_snippets, + new_content=new_content_input, + existing_content=existing_for_narrative, + user_profile=user_profile, + birth_year=user_birth_year, + llm=llm, + ) + narrative_raw = _apply_narrative_fallbacks( + narrative_raw, + unit_text, + existing_for_narrative, + existing_chapter_md, + chapter_category=chapter_category, + ) + + md = narrative_to_markdown(narrative_raw) + if not md.strip(): + md = unit_text.strip() + + if target_story_id: + append_story_version_sync(session, target_story_id, md) + dispatch_ids.add(target_story_id) + ensure_chapter_story_link_sync( + session, chapter_id=chapter.id, story_id=target_story_id + ) + else: + story_title = (unit.new_story_title or "").strip() + if not story_title: + story_title = narrative_agent.generate_title( + stage=chapter_category, + emotion="neutral", + slots=slot_snippets, + user_profile=user_profile, + birth_year=user_birth_year, + llm=llm, + ) + st = create_story_with_version_sync( + session, + user_id=user_id, + title=story_title, + canonical_markdown=md, + stage=chapter_category, + ) + dispatch_ids.add(st.id) + ensure_chapter_story_link_sync( + session, chapter_id=chapter.id, story_id=st.id + ) + return dispatch_ids + + def run_story_pipeline_for_category_batch( session: Session, *, @@ -125,107 +295,121 @@ def run_story_pipeline_for_category_batch( if evidence_text.strip() else combined_text ) - route = route_agent.decide( - chapter_category=chapter_category, - chapter_title=title, - batch_transcript=batch_for_route, - candidate_stories=candidates, - llm=llm, - valid_story_ids=valid_ids, - ) - - target_story_id: str | None = None - existing_for_narrative = "" - if route.decision == "append_story" and route.target_story_id: - st = session.get(Story, route.target_story_id) - if st and st.user_id == user_id: - target_story_id = st.id - existing_for_narrative = (st.canonical_markdown or "").strip() - - narrative_raw = narrative_agent.generate_narrative( - stage=chapter_category, - slots=slot_snippets, - new_content=new_content_input, - existing_content=existing_for_narrative, - user_profile=user_profile, - birth_year=user_birth_year, - llm=llm, - ) - - if ( - existing_for_narrative - and not _is_json_narrative(narrative_raw) - and len(narrative_raw) < len(existing_for_narrative) * 0.8 - ): - logger.warning("叙事长度异常: 回退为原文追加") - narrative_raw = f"{existing_for_narrative}\n\n{combined_text}" - - if ( - not existing_for_narrative - and existing_chapter_md - and not _is_json_narrative(narrative_raw) - and len(narrative_raw) < len(existing_chapter_md) * 0.8 - ): - logger.warning( - "章节级长度异常: 回退为 transcript 追加, category=%s", - chapter_category, - ) - narrative_raw = f"{existing_chapter_md}\n\n{combined_text}" - - md = narrative_to_markdown(narrative_raw) - if not md.strip(): - md = combined_text.strip() calculated_order_index = STAGE_TO_ORDER.get(chapter_category, 999) - if not chapter: - chapter = Chapter( - id=str(uuid.uuid4()), - user_id=user_id, - title=title, - order_index=calculated_order_index, - status="completed", - category=chapter_category, - cover_image=None, - is_new=True, - source_segments=source_ids, + use_batch_plan = ( + llm + and len(category_segments) >= 2 + and len(category_segments) <= PLAN_BATCH_MAX_SEGMENTS + ) + plan: StoryBatchPlan | None = None + if use_batch_plan: + segs = [(seg.id, seg.transcript_text or "") for seg in category_segments] + plan = route_agent.plan_batch( + chapter_category=chapter_category, + chapter_title=title, + segments=segs, + candidate_stories=candidates, + llm=llm, + valid_story_ids=valid_ids, ) - session.add(chapter) - session.flush() - else: - chapter.source_segments = list( - set((chapter.source_segments or []) + source_ids) - ) - chapter.is_new = True - do_append = target_story_id is not None + chapter = _ensure_chapter_record( + session, + user_id=user_id, + chapter_category=chapter_category, + title=title, + source_ids=source_ids, + calculated_order_index=calculated_order_index, + ) - if do_append: - append_story_version_sync(session, target_story_id, md) - dispatch_ids.add(target_story_id) - ensure_chapter_story_link_sync( - session, chapter_id=chapter.id, story_id=target_story_id - ) - else: - story_title = (route.new_story_title or "").strip() - if not story_title: - story_title = narrative_agent.generate_title( - stage=chapter_category, - emotion="neutral", - slots=slot_snippets, - user_profile=user_profile, - birth_year=user_birth_year, - llm=llm, - ) - st = create_story_with_version_sync( + if plan is not None: + dispatch_ids = _run_batch_plan_writes( session, + plan=plan, + category_segments=category_segments, + chapter=chapter, + chapter_category=chapter_category, + evidence_text=evidence_text, + existing_chapter_md=existing_chapter_md, + slot_snippets=slot_snippets, user_id=user_id, - title=story_title, - canonical_markdown=md, - stage=chapter_category, + user_profile=user_profile, + user_birth_year=user_birth_year, + llm=llm, + narrative_agent=narrative_agent, ) - dispatch_ids.add(st.id) - ensure_chapter_story_link_sync(session, chapter_id=chapter.id, story_id=st.id) + else: + route = route_agent.decide( + chapter_category=chapter_category, + chapter_title=title, + batch_transcript=batch_for_route, + candidate_stories=candidates, + llm=llm, + valid_story_ids=valid_ids, + ) + + target_story_id: str | None = None + existing_for_narrative = "" + if route.decision == "append_story" and route.target_story_id: + st = session.get(Story, route.target_story_id) + if st and st.user_id == user_id: + target_story_id = st.id + existing_for_narrative = (st.canonical_markdown or "").strip() + + narrative_raw = narrative_agent.generate_narrative( + stage=chapter_category, + slots=slot_snippets, + new_content=new_content_input, + existing_content=existing_for_narrative, + user_profile=user_profile, + birth_year=user_birth_year, + llm=llm, + ) + + narrative_raw = _apply_narrative_fallbacks( + narrative_raw, + combined_text, + existing_for_narrative, + existing_chapter_md, + chapter_category=chapter_category, + ) + + md = narrative_to_markdown(narrative_raw) + if not md.strip(): + md = combined_text.strip() + + do_append = target_story_id is not None + + if do_append: + append_story_version_sync(session, target_story_id, md) + dispatch_ids.add(target_story_id) + ensure_chapter_story_link_sync( + session, chapter_id=chapter.id, story_id=target_story_id + ) + else: + story_title = (route.new_story_title or "").strip() + if not story_title: + story_title = narrative_agent.generate_title( + stage=chapter_category, + emotion="neutral", + slots=slot_snippets, + user_profile=user_profile, + birth_year=user_birth_year, + llm=llm, + ) + st = create_story_with_version_sync( + session, + user_id=user_id, + title=story_title, + canonical_markdown=md, + stage=chapter_category, + ) + dispatch_ids.add(st.id) + ensure_chapter_story_link_sync( + session, chapter_id=chapter.id, story_id=st.id + ) compose_chapter_from_story_links_sync(session, chapter.id) session.flush() diff --git a/api/app/features/payment/order_service.py b/api/app/features/payment/order_service.py index ecbeac8..cac64ef 100644 --- a/api/app/features/payment/order_service.py +++ b/api/app/features/payment/order_service.py @@ -37,7 +37,7 @@ def _generate_order_no() -> str: return f"LE{timestamp}{short_uuid}" -def _get_legacy_payment_service(): +def _get_payment_service_client(): from app.features.payment.deps import get_payment_service return get_payment_service() @@ -71,7 +71,7 @@ class PaymentOrderService: status_code=400, detail="不支持的支付方式,仅支持 wechat / alipay" ) - client = _get_legacy_payment_service() + client = _get_payment_service_client() if not client.is_method_available(payment_method): if payment_method == "alipay": raise HTTPException( @@ -202,7 +202,7 @@ class PaymentOrderService: ) async def handle_wechat_notify(self, headers: dict, body: str) -> dict: - client = _get_legacy_payment_service() + client = _get_payment_service_client() notify_result = client.handle_wechat_notify(headers=headers, body=body) if notify_result.success and notify_result.trade_status == "SUCCESS": await self.handle_payment_success( @@ -212,7 +212,7 @@ class PaymentOrderService: return {"code": "SUCCESS", "message": "成功"} async def handle_alipay_notify(self, params: dict) -> str: - client = _get_legacy_payment_service() + client = _get_payment_service_client() notify_result = client.handle_alipay_notify(params=params) if notify_result.success and notify_result.trade_status in ( "TRADE_SUCCESS", diff --git a/api/app/features/payment/router.py b/api/app/features/payment/router.py index 7296f87..9324066 100644 --- a/api/app/features/payment/router.py +++ b/api/app/features/payment/router.py @@ -99,23 +99,3 @@ async def list_orders( service: PaymentOrderService = Depends(get_payment_order_service), ): return await service.list_orders(current_user.id) - - -# 订单列表路由(兼容旧版 /api/orders) -orders_router = APIRouter( - prefix="/api/orders", - tags=["orders"], - responses={ - 401: {"description": "认证失败"}, - 404: {"description": "订单不存在"}, - }, -) - - -@orders_router.get("", response_model=List[OrderListResponse]) -async def get_orders( - current_user: User = Depends(get_current_user), - service: PaymentOrderService = Depends(get_payment_order_service), -): - """获取当前用户的订单列表""" - return await service.list_orders(current_user.id) diff --git a/api/app/features/quota/service.py b/api/app/features/quota/service.py index 9374a3b..8cc9b56 100644 --- a/api/app/features/quota/service.py +++ b/api/app/features/quota/service.py @@ -83,14 +83,6 @@ def check_can_send_message( return True, "" -def check_can_create_conversation( - subscription_type: str, - conversation_count: int, -) -> tuple[bool, str]: - """兼容旧调用。""" - return check_can_send_message(subscription_type, conversation_count) - - def check_can_submit_organize( subscription_type: str, chapter_count: int, diff --git a/api/app/features/user/router.py b/api/app/features/user/router.py index d077b2b..b45b984 100644 --- a/api/app/features/user/router.py +++ b/api/app/features/user/router.py @@ -117,11 +117,18 @@ async def submit_feedback( """提交用户反馈。用户可通过此接口提交反馈意见或联系客服。""" feedback_id = str(uuid.uuid4()) logger.info( - "用户反馈 - ID: %s, 用户ID: %s, 内容: %s..., 联系方式: %s", + "用户反馈已提交 feedback_id=%s user_id=%s content_len=%s has_contact=%s", feedback_id, current_user.id, - request.content[:100] if len(request.content) > 100 else request.content, - request.contact or "未提供", + len(request.content or ""), + bool(request.contact and str(request.contact).strip()), + ) + logger.debug( + "用户反馈详情: feedback_id=%s user_id=%s content=%s contact=%s", + feedback_id, + current_user.id, + request.content, + request.contact, ) return FeedbackResponse( id=feedback_id, diff --git a/api/app/main.py b/api/app/main.py index 21e12a9..86c5c3e 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -22,7 +22,6 @@ from app.features.content.router import router as content_router from app.features.conversation.router import router as conversation_router from app.features.conversation.ws.router import websocket_endpoint from app.features.memoir.router import router as memoir_router -from app.features.payment.router import orders_router as payment_orders_router from app.features.payment.router import router as payment_router from app.features.plan.router import router as plan_router from app.features.quota.router import router as quota_router @@ -160,7 +159,6 @@ app.include_router(user_router) app.include_router(user_feedback_router) app.include_router(plan_router) app.include_router(payment_router) -app.include_router(payment_orders_router) app.include_router(quota_router) app.include_router(tasks_router) app.include_router(content_router) diff --git a/api/app/tasks/__init__.py b/api/app/tasks/__init__.py index d20cd56..c4c928f 100644 --- a/api/app/tasks/__init__.py +++ b/api/app/tasks/__init__.py @@ -4,13 +4,12 @@ Celery 任务模块 from .celery_app import celery_app from .chapter_cover_tasks import generate_chapter_cover -from .memoir_tasks import process_memoir_segments, generate_chapter_images +from .memoir_tasks import process_memoir_segments from .story_image_tasks import generate_story_image __all__ = [ "celery_app", "process_memoir_segments", - "generate_chapter_images", "generate_chapter_cover", "generate_story_image", ] diff --git a/api/app/tasks/chapter_cover_enqueue.py b/api/app/tasks/chapter_cover_enqueue.py index a0fe678..5722bf5 100644 --- a/api/app/tasks/chapter_cover_enqueue.py +++ b/api/app/tasks/chapter_cover_enqueue.py @@ -13,13 +13,14 @@ from sqlalchemy.orm import joinedload from app.core.config import settings from app.core.db import get_sync_db from app.core.logging import get_logger -from app.features.memoir.asset_resolver import strip_legacy_image_placeholders +from app.features.memoir.asset_resolver import strip_image_placeholders from app.features.memoir.cover_eligibility import ( chapter_eligible_for_cover_by_inline_body_image_count, + chapter_has_story_links, chapter_needs_cover_enqueue, primary_chapter_memoir_image, ) -from app.features.memoir.models import Chapter +from app.features.memoir.models import Chapter, ChapterStoryLink logger = get_logger(__name__) @@ -35,7 +36,9 @@ def _chapter_eligible_for_http_enqueue(chapter: Chapter | None) -> bool: """与 MemoirService.check_and_trigger_cover_generation 循环条件一致。""" if not chapter: return False - if not chapter.category or chapter.status == "empty": + if not chapter.category: + return False + if not chapter_has_story_links(chapter): return False if getattr(chapter, "cover_asset_id", None): return False @@ -43,7 +46,7 @@ def _chapter_eligible_for_http_enqueue(chapter: Chapter | None) -> bool: body = md or "" if not body.strip(): return False - body = strip_legacy_image_placeholders(body).strip() + body = strip_image_placeholders(body).strip() if not body: return False if not chapter_eligible_for_cover_by_inline_body_image_count(chapter): @@ -66,6 +69,7 @@ def _load_chapter_for_enqueue_sync(chapter_id: str) -> Chapter | None: .where(Chapter.id == chapter_id) .options( joinedload(Chapter.images), + joinedload(Chapter.story_links).joinedload(ChapterStoryLink.story), ) ) return db.execute(stmt).unique().scalar_one_or_none() diff --git a/api/app/tasks/chapter_cover_tasks.py b/api/app/tasks/chapter_cover_tasks.py index 78efcbe..7649908 100644 --- a/api/app/tasks/chapter_cover_tasks.py +++ b/api/app/tasks/chapter_cover_tasks.py @@ -16,21 +16,20 @@ from sqlalchemy.orm import joinedload from app.core.db import get_sync_db from app.core.dependencies import get_image_generator +from app.core.logging import get_logger from app.core.redis_lock import acquire_redis_lock, release_redis_lock from app.features.asset.models import Asset from app.features.memoir.chapter_cover import ( - aggregate_cover_prompt_from_chapter, aggregate_cover_prompt_from_stories, ) from app.features.memoir.cover_eligibility import ( chapter_eligible_for_cover_by_inline_body_image_count, + chapter_has_story_links, ) from app.features.memoir.memoir_images.storage import TencentCosStorageService from app.features.memoir.models import Chapter, ChapterCoverIntent, ChapterStoryLink from app.ports.image_gen import TaskStatus -from app.core.logging import get_logger - logger = get_logger(__name__) CHAPTER_COVER_LOCK_TTL_SECONDS = 1800 @@ -82,28 +81,17 @@ def _chapter_cover_claimable_clause(now: datetime): def _build_chapter_cover_brief(chapter: Chapter) -> str: - prompt_brief = "" stories = [] - if getattr(chapter, "story_links", None): - for link in sorted( - chapter.story_links, key=lambda l: getattr(l, "order_index", 0) - ): - story = getattr(link, "story", None) - if story: - stories.append(story) - prompt_brief = aggregate_cover_prompt_from_stories( - stories, - chapter_title=chapter.title or "", - chapter_category=chapter.category or "", - ) - if prompt_brief: - return prompt_brief - md = (chapter.canonical_markdown or "").strip() - excerpt = md[:200] if md else "" - return aggregate_cover_prompt_from_chapter( + for link in sorted( + chapter.story_links, key=lambda link_row: link_row.order_index or 0 + ): + story = getattr(link, "story", None) + if story: + stories.append(story) + return aggregate_cover_prompt_from_stories( + stories, chapter_title=chapter.title or "", chapter_category=chapter.category or "", - markdown_excerpt=excerpt, ) @@ -179,7 +167,7 @@ def generate_chapter_cover(self, chapter_id: str): lock_key, ttl_seconds=CHAPTER_COVER_LOCK_TTL_SECONDS ) if lock_handle is None: - logger.info("generate_chapter_cover: chapter=%s, reason=locked", chapter_id) + logger.debug("generate_chapter_cover: chapter=%s, reason=locked", chapter_id) return {"status": "locked"} claim_token = uuid.uuid4().hex @@ -195,20 +183,26 @@ def generate_chapter_cover(self, chapter_id: str): ) chapter = db.execute(stmt).unique().scalar_one_or_none() if not chapter: - logger.info( + logger.debug( "generate_chapter_cover: chapter=%s, reason=not_found", chapter_id ) return {"status": "no_chapter"} if not chapter_eligible_for_cover_by_inline_body_image_count(chapter): - logger.info( + logger.debug( "generate_chapter_cover: chapter=%s, reason=insufficient_inline_body_images", chapter_id, ) return {"status": "insufficient_inline_body_images"} + if not chapter_has_story_links(chapter): + logger.debug( + "generate_chapter_cover: chapter=%s, reason=no_story_links", + chapter_id, + ) + return {"status": "no_story_links"} if getattr(chapter, "cover_asset_id", None): - logger.info( + logger.debug( "generate_chapter_cover: chapter=%s, reason=has_cover_asset", chapter_id, ) @@ -216,7 +210,7 @@ def generate_chapter_cover(self, chapter_id: str): intent = _claim_chapter_cover_intent_sync(db, chapter, claim_token) if not intent: - logger.info( + logger.debug( "generate_chapter_cover: chapter=%s, reason=no_claimable_intent", chapter_id, ) @@ -252,7 +246,7 @@ def generate_chapter_cover(self, chapter_id: str): or (intent_db.status or "").strip() != "processing" or (intent_db.claim_token or "").strip() != claim_token ): - logger.info( + logger.debug( "generate_chapter_cover: skip persist intent=%s status=%s claim=%s", intent.id, getattr(intent_db, "status", None), @@ -286,10 +280,17 @@ def generate_chapter_cover(self, chapter_id: str): db.commit() logger.info( - "generate_chapter_cover: chapter=%s, asset=%s, url=%s", + "generate_chapter_cover: chapter=%s, asset=%s", + chapter_id, + asset_id, + ) + logger.debug( + "generate_chapter_cover: chapter=%s asset=%s url=%s cos_key=%s prompt_final=%s", chapter_id, asset_id, url, + cos_key, + prompt_final, ) return {"status": "success", "asset_id": asset_id} except Exception as exc: @@ -309,6 +310,6 @@ def generate_chapter_cover(self, chapter_id: str): logger.warning( "generate_chapter_cover failed: chapter=%s, error=%s", chapter_id, exc ) - raise self.retry(exc=exc) + raise self.retry(exc=exc) from exc finally: release_redis_lock(lock_handle) diff --git a/api/app/tasks/memoir_tasks.py b/api/app/tasks/memoir_tasks.py index bea3b1e..d5f0958 100644 --- a/api/app/tasks/memoir_tasks.py +++ b/api/app/tasks/memoir_tasks.py @@ -3,64 +3,47 @@ """ import json - -from app.core.logging import get_logger import uuid -from io import BytesIO -from typing import Dict, List, Set from datetime import datetime, timezone +from typing import Dict, List, Set import redis from celery import shared_task -from PIL import Image -from sqlalchemy import delete, select -from sqlalchemy.orm import Session, joinedload +from sqlalchemy import select +from sqlalchemy.orm import Session -from app.core.db import get_sync_db -from app.features.conversation.models import Segment -from app.features.memoir.models import ( - Book, - Chapter, - MemoirImage, - MemoirState, -) -from app.features.user.models import User -from app.core.dependencies import get_llm_provider -from app.agents.state_schema import MemoirStateSchema, SlotData, default_state -from app.agents.memoir import MemoirOrchestrator from app.agents.chat.prompts_profile import format_user_profile_context +from app.agents.memoir import MemoirOrchestrator +from app.agents.state_schema import MemoirStateSchema, SlotData, default_state +from app.core.db import get_sync_db +from app.core.dependencies import get_llm_provider +from app.core.logging import get_logger +from app.features.conversation.models import Segment +from app.features.memoir.cover_eligibility import ( + chapter_needs_cover_enqueue, +) from app.features.memoir.memoir_images.parser import ( build_initial_image_assets, - parse_image_placeholders, ) -import hashlib -from app.core.dependencies import get_image_generator -from app.agents.image_prompt import ImagePromptOrchestrator from app.features.memoir.memoir_images.schema import ( - completed_image_assets, IMAGE_STATUS_COMPLETED, IMAGE_STATUS_FAILED, IMAGE_STATUS_PENDING, - IMAGE_STATUS_PROCESSING, normalize_image_assets, ) from app.features.memoir.memoir_images.serializers import ( image_dict_to_row_kwargs, - memoir_image_to_dict, ) from app.features.memoir.memoir_images.settings import MemoirImageSettings -from app.ports.image_gen import TaskStatus -from app.features.memoir.memoir_images.storage import ( - TencentCosStorageService, - CosUploadError, -) -from app.features.memoir.cover_eligibility import ( - chapter_needs_cover_enqueue, - cover_memoir_image_pending_or_failed, +from app.features.memoir.models import ( + Book, + MemoirImage, + MemoirState, ) from app.features.memoir.story_pipeline_sync import ( run_story_pipeline_for_category_batch, ) +from app.features.user.models import User logger = get_logger(__name__) _REDIS_CLIENTS: dict[bool, redis.Redis] = {} @@ -101,20 +84,6 @@ def _release_chapter_lock(user_id: str, stage: str): r.delete(lock_key) -def _acquire_chapter_image_lock(chapter_id: str, timeout: int = 600) -> bool: - """获取章节补图分布式锁,避免同一章节重复补图。""" - r = _get_redis_client() - lock_key = f"lock:chapter-images:{chapter_id}" - return r.set(lock_key, "1", nx=True, ex=timeout) - - -def _release_chapter_image_lock(chapter_id: str): - """释放章节补图分布式锁。""" - r = _get_redis_client() - lock_key = f"lock:chapter-images:{chapter_id}" - r.delete(lock_key) - - def _update_task_status_sync( user_id: str, task_id: str, status: str, result: Dict = None ): @@ -139,7 +108,7 @@ def _update_task_status_sync( r.hset(key, task_id, json.dumps(task_info)) r.expire(key, 3600) # 1小时过期 - logger.info(f"任务状态已更新: task_id={task_id}, status={status}") + logger.debug("任务状态已更新: task_id=%s status=%s", task_id, status) except Exception as e: logger.error(f"更新任务状态失败: {e}") @@ -240,25 +209,6 @@ def _select_placeholders_for_effective_max( return [{**item, "index": index} for index, item in enumerate(selected)] -def initialize_chapter_images(_chapter): - """兼容旧调用:封面由 generate_chapter_cover 处理。""" - logger.info("initialize_chapter_images: 封面由 generate_chapter_cover 处理,跳过") - return [] - - -def _normalize_image_bytes_for_storage(image_bytes: bytes) -> bytes: - with Image.open(BytesIO(image_bytes)) as image: - output = BytesIO() - if image.mode in {"RGBA", "LA"}: - normalized = image - elif image.mode == "P": - normalized = image.convert("RGBA") - else: - normalized = image.convert("RGB") - normalized.save(output, format="PNG") - return output.getvalue() - - def _coerce_state(model: MemoirState) -> MemoirStateSchema: """将数据库模型转换为 Schema""" return MemoirStateSchema.model_validate( @@ -372,8 +322,6 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]): except Exception as e: logger.warning("Memory ingest 跳过: %s", e) - # 获取用户状态和资料 - state = _get_or_create_state_sync(user_id, db) llm = _get_llm() image_settings = MemoirImageSettings.from_env() @@ -391,75 +339,71 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]): story_dispatch_ids: Set[str] = set() - def _process_category( - chapter_category: str, - category_segments: List, - state: MemoirStateSchema, - profile: str, - birth_year, - llm, - ): - """stories-first:路由 + 写 story,物化 chapter。""" - nonlocal story_dispatch_ids - chapter, needs_cover, disp = run_story_pipeline_for_category_batch( - db, - user_id=user_id, - chapter_category=chapter_category, - category_segments=category_segments, - state=state, - user_profile=profile, - user_birth_year=birth_year, - llm=llm, - ) - story_dispatch_ids |= disp - db.flush() - db.refresh(chapter) - - needs_cover_enqueue = ( - image_settings.enabled and chapter_needs_cover_enqueue(chapter) - ) - - stmt_book = ( - select(Book) - .where(Book.user_id == user_id) - .order_by(Book.updated_at.desc()) - ) - result_book = db.execute(stmt_book) - book = result_book.scalar_one_or_none() - if not book: - book = Book( - id=str(uuid.uuid4()), - user_id=user_id, - title="我的回忆录", - total_pages=0, - total_words=0, - cover_image_url=None, - ) - db.add(book) - book.has_update = True - book.last_update_chapter_id = chapter.id - - return chapter, needs_cover_enqueue - - def _raise_retry(): - raise self.retry(countdown=10) - memoir_orchestrator = MemoirOrchestrator() - chapters_to_enqueue, _ = memoir_orchestrator.run( - segments=segments, + prepared = memoir_orchestrator.prepare_batches( + segments=list(segments), llm=llm, - user_profile=user_profile, - user_birth_year=user_birth_year, get_or_create_state=lambda: _get_or_create_state_sync(user_id, db), update_slot=lambda stage, slot_name, snippet, seg_ids: ( _update_slot_sync(user_id, stage, slot_name, snippet, seg_ids, db) ), - acquire_lock=lambda stage: _acquire_chapter_lock(user_id, stage), - release_lock=lambda stage: _release_chapter_lock(user_id, stage), - process_category=_process_category, - raise_retry=_raise_retry, ) + chapters_to_enqueue: Set[str] = set() + for ( + chapter_category, + category_segments, + ) in prepared.category_to_segments.items(): + if not _acquire_chapter_lock(user_id, chapter_category): + logger.warning( + "章节锁竞争: category=%s, 延迟重试", + chapter_category, + ) + raise self.retry(countdown=10) + try: + chapter, needs_cover, disp = run_story_pipeline_for_category_batch( + db, + user_id=user_id, + chapter_category=chapter_category, + category_segments=category_segments, + state=prepared.state, + user_profile=user_profile, + user_birth_year=user_birth_year, + llm=llm, + ) + story_dispatch_ids |= disp + db.flush() + db.refresh(chapter) + + needs_cover_enqueue = ( + image_settings.enabled and chapter_needs_cover_enqueue(chapter) + ) + + stmt_book = ( + select(Book) + .where(Book.user_id == user_id) + .order_by(Book.updated_at.desc()) + ) + result_book = db.execute(stmt_book) + book = result_book.scalar_one_or_none() + if not book: + book = Book( + id=str(uuid.uuid4()), + user_id=user_id, + title="我的回忆录", + total_pages=0, + total_words=0, + cover_image_url=None, + ) + db.add(book) + book.has_update = True + book.last_update_chapter_id = chapter.id + + if chapter and needs_cover_enqueue: + chapters_to_enqueue.add(chapter.id) + finally: + _release_chapter_lock(user_id, chapter_category) + # 标记段落为已处理 for seg in segments: seg.processed = True @@ -503,7 +447,7 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]): _update_task_status_sync(user_id, task_id, "failure", {"error": str(e)}) # 重试 - raise self.retry(exc=e) + raise self.retry(exc=e) from e @shared_task(bind=True, max_retries=3, default_retry_delay=30) @@ -580,161 +524,4 @@ def generate_chapter_content(self, user_id: str, stage: str, new_content: str): except Exception as e: logger.error(f"章节生成失败: {e}") - raise self.retry(exc=e) - - -def build_cos_key(user_id: str, chapter_id: str, index: int | str, prompt: str) -> str: - short_hash = hashlib.sha1(prompt.encode("utf-8")).hexdigest()[:10] - index_part = "cover" if index in (-1, "cover") else str(index) - return f"memoirs/{user_id}/{chapter_id}/{index_part}-{short_hash}.png" - - -@shared_task(bind=True, max_retries=3, default_retry_delay=30) -def generate_chapter_images(self, chapter_id: str): - """异步补图:仅处理章节级 MemoirImage(pending/failed)。正文配图走 story_image_tasks。""" - lock_acquired = False - provider = None - with get_sync_db() as db: - try: - stmt = ( - select(Chapter) - .where(Chapter.id == chapter_id) - .options(joinedload(Chapter.images)) - ) - chapter = db.execute(stmt).unique().scalar_one_or_none() - if not chapter: - logger.info("章节补图跳过: chapter=%s, reason=not_found", chapter_id) - return {"status": "no_chapter"} - cover_to_generate = cover_memoir_image_pending_or_failed(chapter) - if not cover_to_generate: - logger.info( - "章节补图跳过: chapter=%s, reason=no_pending_cover", chapter_id - ) - return {"status": "no_images"} - - settings = MemoirImageSettings.from_env() - if not settings.enabled: - logger.info("章节补图跳过: chapter=%s, reason=disabled", chapter_id) - return {"status": "disabled"} - - lock_acquired = _acquire_chapter_image_lock(chapter_id) - if not lock_acquired: - logger.info("章节补图跳过: chapter=%s, reason=locked", chapter_id) - return {"status": "locked"} - - prompt_orchestrator = ImagePromptOrchestrator(_get_llm(), settings) - image_generator = get_image_generator() - storage = TencentCosStorageService.from_env() - logger.info( - "章节封面补图开始: chapter=%s, cover=%s", - chapter_id, - bool(cover_to_generate), - ) - retryable_failures: list[str] = [] - permanent_failures: list[str] = [] - - def _apply_item_to_memoir_image(rec: MemoirImage, d: dict): - rec.placeholder = d.get("placeholder") - rec.description = d.get("description") - rec.status = (d.get("status") or "pending").strip() or "pending" - rec.prompt = d.get("prompt") - rec.url = d.get("url") - rec.storage_key = d.get("storage_key") - rec.provider = d.get("provider") - rec.style = d.get("style") - rec.size = d.get("size") - rec.error = d.get("error") - rec.retryable = d.get("retryable") - rec.updated_at = datetime.now(timezone.utc) - - # 封面图(正文来自 canonical_markdown) - if cover_to_generate: - current_item = memoir_image_to_dict(cover_to_generate) or {} - current_item.setdefault("placeholder", "") - current_item.setdefault("description", "") - current_item["status"] = IMAGE_STATUS_PROCESSING - current_item["updated_at"] = datetime.now(timezone.utc).isoformat() - _apply_item_to_memoir_image(cover_to_generate, current_item) - db.commit() - try: - raw_md = ( - getattr(chapter, "canonical_markdown", None) or "" - ).strip() - context_excerpt = " ".join(raw_md.split("\n")[:5])[:200] - prompt_data = prompt_orchestrator.build_cover_prompt( - chapter_title=chapter.title, - chapter_category=chapter.category or "", - context_excerpt=context_excerpt, - ) - result = image_generator.generate( - prompt_data["prompt"], - prompt_data["size"], - prompt_data["style"], - ) - if result.status != TaskStatus.COMPLETED or not result.image_url: - raise RuntimeError(result.error or "Image generation failed") - image_bytes = _normalize_image_bytes_for_storage( - image_generator.download_image(result.image_url) - ) - key = build_cos_key( - chapter.user_id, chapter.id, "cover", prompt_data["prompt"] - ) - current_item["storage_key"] = key - current_item["url"] = storage.upload_bytes( - image_bytes, key, "image/png" - ) - current_item["prompt"] = prompt_data["prompt"] - current_item["style"] = prompt_data["style"] - current_item["size"] = prompt_data["size"] - current_item["status"] = IMAGE_STATUS_COMPLETED - current_item["error"] = None - current_item["retryable"] = None - current_item["updated_at"] = datetime.now(timezone.utc).isoformat() - _apply_item_to_memoir_image(cover_to_generate, current_item) - db.commit() - logger.info( - "章节封面图生成成功: chapter=%s, url=%s", - chapter_id, - current_item["url"], - ) - except Exception as exc: - failure_msg = f"cover, error={exc}" - if isinstance(exc, CosUploadError) and not exc.retryable: - permanent_failures.append(failure_msg) - logger.error( - "封面图上传不可重试,清理: chapter=%s, %s", - chapter_id, - failure_msg, - ) - db.delete(cover_to_generate) - db.commit() - else: - current_item = memoir_image_to_dict(cover_to_generate) or {} - current_item["status"] = IMAGE_STATUS_FAILED - current_item["error"] = str(exc) - current_item["retryable"] = True - current_item["updated_at"] = datetime.now( - timezone.utc - ).isoformat() - retryable_failures.append(failure_msg) - logger.warning( - "封面图生成失败(可重试): chapter=%s, %s", - chapter_id, - failure_msg, - ) - _apply_item_to_memoir_image(cover_to_generate, current_item) - db.commit() - - if retryable_failures: - raise RuntimeError( - f"章节补图存在可重试失败项: chapter={chapter_id}, failures={'; '.join(retryable_failures)}" - ) - return {"status": "success"} - except Exception as exc: - logger.error("章节补图任务失败: chapter=%s, error=%s", chapter_id, exc) - raise self.retry(exc=exc) - finally: - if provider: - provider.close() - if lock_acquired: - _release_chapter_image_lock(chapter_id) + raise self.retry(exc=e) from e diff --git a/api/app/tasks/story_image_tasks.py b/api/app/tasks/story_image_tasks.py index de327c7..38f13c8 100644 --- a/api/app/tasks/story_image_tasks.py +++ b/api/app/tasks/story_image_tasks.py @@ -160,7 +160,7 @@ def generate_story_image(self, story_id: str): lock_key = f"lock:story-image:{story_id}" lock_handle = acquire_redis_lock(lock_key, ttl_seconds=STORY_IMAGE_LOCK_TTL_SECONDS) if lock_handle is None: - logger.info("generate_story_image: story=%s, reason=locked", story_id) + logger.debug("generate_story_image: story=%s, reason=locked", story_id) return {"status": "locked"} claim_token = uuid.uuid4().hex @@ -170,7 +170,7 @@ def generate_story_image(self, story_id: str): with get_sync_db() as db: row = _claim_story_image_intent_sync(db, story_id, claim_token) if not row: - logger.info( + logger.debug( "generate_story_image: story=%s, reason=no_claimable_intent", story_id, ) @@ -213,7 +213,7 @@ def generate_story_image(self, story_id: str): or (intent_db.status or "").strip() != "processing" or (intent_db.claim_token or "").strip() != claim_token ): - logger.info( + logger.debug( "generate_story_image: skip persist intent=%s status=%s claim=%s", intent.id, getattr(intent_db, "status", None), @@ -249,12 +249,14 @@ def generate_story_image(self, story_id: str): # 仅当 intent 仍指向当前版本时回填正文,避免慢任务/重试把图插到新版本上 if not target_vid or target_vid != current_vid: db.commit() - logger.info( + logger.debug( "generate_story_image: stale intent skip backfill story=%s " - "intent_ver=%s current=%s", + "intent_ver=%s current=%s url=%s asset=%s", story_id, target_vid, current_vid, + url, + asset_id, ) return {"status": "success_stale", "asset_id": asset_id} @@ -297,10 +299,17 @@ def generate_story_image(self, story_id: str): _enqueue_chapter_recompose_for_story(story_id) logger.info( - "generate_story_image: story=%s, asset=%s, url=%s", + "generate_story_image: story=%s, asset=%s", + story_id, + asset_id, + ) + logger.debug( + "generate_story_image: story=%s asset=%s url=%s cos_key=%s prompt_final=%s", story_id, asset_id, url, + cos_key, + prompt_final, ) return {"status": "success", "asset_id": asset_id} except Exception as exc: diff --git a/api/development.sh b/api/development.sh index 624b788..a963e57 100755 --- a/api/development.sh +++ b/api/development.sh @@ -212,7 +212,11 @@ start_services() { fi fi - "${UVICORN_BIN}" main:app --reload --host "${API_HOST}" --port "${API_PORT}" & + # 迁移由 main.py 在启动时执行;排除 alembic 目录与 alembic.ini,避免编辑迁移时触发整进程重载 + "${UVICORN_BIN}" main:app --reload \ + --reload-exclude 'alembic/**' \ + --reload-exclude 'alembic.ini' \ + --host "${API_HOST}" --port "${API_PORT}" & API_PID=$! print_ok "FastAPI 已启动 (PID: ${API_PID})" diff --git a/api/pyproject.toml b/api/pyproject.toml index a1a10f3..20cf217 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -54,6 +54,7 @@ ignore = ["E501", "B008", "E712"] "__init__.py" = ["F401"] "main.py" = ["E402", "I001"] "app/tasks/celery_app.py" = ["E402"] +"tests/conftest.py" = ["E402", "I001"] [tool.ruff.format] quote-style = "double" diff --git a/api/tests/conftest.py b/api/tests/conftest.py deleted file mode 100644 index b573716..0000000 --- a/api/tests/conftest.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Pytest 配置:确保 api 目录在 path 中,并预先加载所有 model 以解析 relationship 字符串引用。""" - -import sys -from pathlib import Path - -_api_dir = Path(__file__).resolve().parent.parent -if str(_api_dir) not in sys.path: - sys.path.insert(0, str(_api_dir)) - -# 聚合导入所有 feature models,使 SQLAlchemy 能解析 User -> Order 等字符串 relationship -from app.features.asset import models as _asset_models # noqa: F401 -from app.features.auth import models as _auth_models # noqa: F401 -from app.features.conversation import models as _conv_models # noqa: F401 -from app.features.memory import models as _memory_models # noqa: F401 -from app.features.memoir import models as _memoir_models # noqa: F401 -from app.features.story import models as _story_models # noqa: F401 -from app.features.payment import models as _payment_models # noqa: F401 -from app.features.user import models as _user_models # noqa: F401 diff --git a/api/tests/test_asset_resolver.py b/api/tests/test_asset_resolver.py deleted file mode 100644 index ab44656..0000000 --- a/api/tests/test_asset_resolver.py +++ /dev/null @@ -1,91 +0,0 @@ -"""asset_resolver:旧占位符清理与 asset:// 解析。""" - -import unittest -from types import SimpleNamespace - -from app.features.memoir.asset_resolver import ( - collect_asset_ids_for_chapter, - collect_asset_ids_from_markdown, - resolve_asset_refs_in_markdown, - split_markdown_by_asset_refs, - strip_asset_image_refs_from_markdown, - strip_legacy_image_placeholders, -) -from app.features.memoir.models import Chapter - - -class AssetResolverTest(unittest.TestCase): - def test_strip_legacy_image_placeholders_double_brace(self): - md = "正文\n\n{{IMAGE:院子里的树}}\n\n结尾" - out = strip_legacy_image_placeholders(md) - self.assertNotIn("IMAGE", out) - self.assertIn("正文", out) - self.assertIn("结尾", out) - - def test_strip_legacy_image_placeholders_quad_brace(self): - md = "a\n\n{{{{IMAGE:描述}}}}\n\nb" - out = strip_legacy_image_placeholders(md) - self.assertNotIn("IMAGE", out) - - def test_collect_and_split_asset_refs(self): - md = "前\n\n![图注](asset://abc-123)\n\n后" - self.assertEqual(collect_asset_ids_from_markdown(md), ["abc-123"]) - blocks = split_markdown_by_asset_refs(md, lambda aid: f"https://x/{aid}") - self.assertEqual(len(blocks), 3) - self.assertEqual(blocks[0]["type"], "text") - self.assertEqual(blocks[1]["type"], "image") - self.assertIn("https://x/abc-123", blocks[1]["url"]) - - def test_resolve_asset_refs_in_markdown(self): - md = "![c](asset://id1)" - out = resolve_asset_refs_in_markdown(md, lambda aid: "https://cdn/u") - self.assertIn("https://cdn/u", out) - self.assertNotIn("asset://", out) - - def test_collect_asset_ids_for_chapter(self): - ch = Chapter( - id="c1", - user_id="u1", - title="t", - order_index=0, - canonical_markdown="![x](asset://a1)", - cover_asset_id="cov1", - ) - ids = collect_asset_ids_for_chapter(ch) - self.assertEqual(ids, {"a1", "cov1"}) - - def test_strip_asset_image_refs_removes_all_and_collapses_blank_lines(self): - md = ( - "第一段\n\n![a](asset://old-id-1)\n\n第二段\n\n\n" - "![b](asset://old-id-2)\n\n第三段" - ) - out = strip_asset_image_refs_from_markdown(md) - self.assertNotIn("asset://", out) - self.assertIn("第一段", out) - self.assertIn("第二段", out) - self.assertIn("第三段", out) - self.assertNotIn("\n\n\n", out) - - def test_strip_asset_image_refs_empty(self): - self.assertEqual(strip_asset_image_refs_from_markdown(""), "") - self.assertEqual(strip_asset_image_refs_from_markdown(" "), "") - - def test_collect_asset_ids_includes_linked_story_markdown(self): - ch = SimpleNamespace( - canonical_markdown="", - sections=[], - cover_asset_id=None, - story_links=[ - SimpleNamespace( - story=SimpleNamespace( - canonical_markdown="![主图](asset://from-story-1)" - ) - ) - ], - ) - ids = collect_asset_ids_for_chapter(ch) - self.assertEqual(ids, {"from-story-1"}) - - -if __name__ == "__main__": - unittest.main() diff --git a/api/tests/test_chapter_cover_enqueue.py b/api/tests/test_chapter_cover_enqueue.py deleted file mode 100644 index 82a798f..0000000 --- a/api/tests/test_chapter_cover_enqueue.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Tests for chapter cover Celery enqueue deduplication gate.""" - -from unittest.mock import MagicMock, patch - -from app.tasks.chapter_cover_enqueue import ( - _enqueue_dedup_key, - try_enqueue_generate_chapter_cover, -) - - -def _md_with_n_inline_images(n: int) -> str: - lines = [f"![c](asset://a{i})" for i in range(n)] - return "\n\n".join(lines) + "\n\n正文" - - -def _eligible_pipeline_chapter(): - ch = MagicMock() - ch.cover_asset_id = None - # 章节封面:正文内 asset 插图需 >3 才入队 - ch.canonical_markdown = _md_with_n_inline_images(4) - ch.images = [] - return ch - - -@patch("app.tasks.chapter_cover_tasks.generate_chapter_cover") -@patch("app.tasks.chapter_cover_enqueue.redis.from_url") -@patch("app.tasks.chapter_cover_enqueue._load_chapter_for_enqueue_sync") -def test_try_enqueue_false_when_chapter_missing(mock_load, mock_redis, mock_gen_task): - mock_load.return_value = None - assert try_enqueue_generate_chapter_cover("missing-id", "pipeline") is False - mock_gen_task.delay.assert_not_called() - mock_redis.assert_not_called() - - -@patch("app.tasks.chapter_cover_tasks.generate_chapter_cover") -@patch("app.tasks.chapter_cover_enqueue.redis.from_url") -@patch("app.tasks.chapter_cover_enqueue._load_chapter_for_enqueue_sync") -def test_try_enqueue_false_when_cover_asset_exists( - mock_load, mock_redis, mock_gen_task -): - ch = _eligible_pipeline_chapter() - ch.cover_asset_id = "asset-1" - mock_load.return_value = ch - assert try_enqueue_generate_chapter_cover("ch-1", "pipeline") is False - mock_gen_task.delay.assert_not_called() - mock_redis.assert_not_called() - - -@patch("app.tasks.chapter_cover_tasks.generate_chapter_cover") -@patch("app.tasks.chapter_cover_enqueue.redis.from_url") -@patch("app.tasks.chapter_cover_enqueue._load_chapter_for_enqueue_sync") -def test_try_enqueue_false_when_redis_nx_not_acquired( - mock_load, mock_redis, mock_gen_task -): - mock_load.return_value = _eligible_pipeline_chapter() - r = MagicMock() - r.set.return_value = False - mock_redis.return_value = r - assert try_enqueue_generate_chapter_cover("ch-1", "pipeline") is False - mock_gen_task.delay.assert_not_called() - r.set.assert_called_once() - - -@patch("app.tasks.chapter_cover_tasks.generate_chapter_cover") -@patch("app.tasks.chapter_cover_enqueue.redis.from_url") -@patch("app.tasks.chapter_cover_enqueue._load_chapter_for_enqueue_sync") -def test_try_enqueue_true_when_eligible_and_nx_ok(mock_load, mock_redis, mock_gen_task): - mock_load.return_value = _eligible_pipeline_chapter() - r = MagicMock() - r.set.return_value = True - mock_redis.return_value = r - mock_gen_task.delay = MagicMock() - assert try_enqueue_generate_chapter_cover("ch-1", "pipeline") is True - mock_gen_task.delay.assert_called_once_with("ch-1") - r.set.assert_called_once() - - -@patch("app.tasks.chapter_cover_tasks.generate_chapter_cover") -@patch("app.tasks.chapter_cover_enqueue.redis.from_url") -@patch("app.tasks.chapter_cover_enqueue._load_chapter_for_enqueue_sync") -def test_try_enqueue_false_when_inline_images_not_enough( - mock_load, mock_redis, mock_gen_task -): - ch = _eligible_pipeline_chapter() - ch.canonical_markdown = _md_with_n_inline_images(3) - mock_load.return_value = ch - assert try_enqueue_generate_chapter_cover("ch-1", "pipeline") is False - mock_gen_task.delay.assert_not_called() - mock_redis.assert_not_called() - - -@patch("app.tasks.chapter_cover_tasks.generate_chapter_cover") -@patch("app.tasks.chapter_cover_enqueue.redis.from_url") -@patch("app.tasks.chapter_cover_enqueue._load_chapter_for_enqueue_sync") -def test_try_enqueue_deletes_dedup_key_when_delay_fails( - mock_load, mock_redis, mock_gen_task -): - mock_load.return_value = _eligible_pipeline_chapter() - r = MagicMock() - r.set.return_value = True - mock_redis.return_value = r - mock_gen_task.delay = MagicMock(side_effect=RuntimeError("broker down")) - assert try_enqueue_generate_chapter_cover("ch-1", "pipeline") is False - mock_gen_task.delay.assert_called_once_with("ch-1") - r.delete.assert_called_once_with(_enqueue_dedup_key("ch-1")) - - -@patch("app.tasks.chapter_cover_tasks.generate_chapter_cover") -@patch("app.tasks.chapter_cover_enqueue.redis.from_url") -@patch("app.tasks.chapter_cover_enqueue._load_chapter_for_enqueue_sync") -def test_try_enqueue_http_skips_empty_category(mock_load, mock_redis, mock_gen_task): - ch = _eligible_pipeline_chapter() - ch.category = None - ch.status = "completed" - mock_load.return_value = ch - assert try_enqueue_generate_chapter_cover("ch-1", "http") is False - mock_gen_task.delay.assert_not_called() - mock_redis.assert_not_called() diff --git a/api/tests/test_chapter_markdown_compose.py b/api/tests/test_chapter_markdown_compose.py deleted file mode 100644 index 7da34e0..0000000 --- a/api/tests/test_chapter_markdown_compose.py +++ /dev/null @@ -1,66 +0,0 @@ -import unittest - -from app.features.memoir.chapter_markdown_compose import ( - compose_ordered_stories_to_markdown, - compose_ordered_stories_to_pdf_markdown, - materialize_chapter_markdown_from_loaded_chapter, -) - - -class ChapterMarkdownComposeTest(unittest.TestCase): - def test_joins_bodies_with_hr_only_no_story_headings(self): - md = compose_ordered_stories_to_markdown( - [("第一章", "正文A"), ("第二章", "正文B")] - ) - self.assertNotIn("##", md) - self.assertIn("正文A", md) - self.assertIn("正文B", md) - self.assertIn("\n\n---\n\n", md) - self.assertTrue(md.index("正文A") < md.index("---")) - self.assertTrue(md.index("---") < md.index("正文B")) - - def test_preserves_asset_refs(self): - body = "![x](asset://abc-123)" - md = compose_ordered_stories_to_markdown([("S", body)]) - self.assertIn("asset://abc-123", md) - - def test_empty_title_still_composes_body(self): - md = compose_ordered_stories_to_markdown([("", "仅正文")]) - self.assertEqual(md, "仅正文") - self.assertNotIn("##", md) - - def test_empty_body_skipped(self): - md = compose_ordered_stories_to_markdown([("仅标题", "")]) - self.assertEqual(md, "") - - def test_pdf_markdown_includes_story_headings(self): - md = compose_ordered_stories_to_pdf_markdown( - [("第一章", "正文A"), ("第二章", "正文B")] - ) - self.assertIn("## 第一章", md) - self.assertIn("## 第二章", md) - - def test_materialize_respects_order_index(self): - class _S: - def __init__(self, title: str, body: str): - self.title = title - self.canonical_markdown = body - - class _L: - def __init__(self, o: int, story: _S): - self.order_index = o - self.story = story - - ch = type( - "Ch", - (), - { - "story_links": [ - _L(1, _S("B", "后")), - _L(0, _S("A", "先")), - ] - }, - )() - md = materialize_chapter_markdown_from_loaded_chapter(ch) - self.assertLess(md.index("先"), md.index("后")) - self.assertNotIn("##", md) diff --git a/api/tests/test_chapter_story_compose_flow.py b/api/tests/test_chapter_story_compose_flow.py deleted file mode 100644 index 631948f..0000000 --- a/api/tests/test_chapter_story_compose_flow.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Story 变更触发章节物化:调用链与 Celery 派发的行为验证(无真实 DB)。""" - -import asyncio -import unittest -from unittest.mock import AsyncMock, MagicMock, patch - -from app.features.story.service import StoryService - - -class ChapterStoryComposeFlowTest(unittest.TestCase): - def test_append_version_marks_dirty_and_delays_recompose(self): - async def run(): - db = MagicMock() - db.flush = AsyncMock() - db.commit = AsyncMock() - db.add = MagicMock() - story = MagicMock() - story.id = "story-1" - story.current_version_id = None - story.title = "T" - - version = MagicMock() - version.id = "ver-new" - - with ( - patch( - "app.features.story.service.get_story_by_id", - new_callable=AsyncMock, - return_value=story, - ), - patch( - "app.features.story.service.count_story_versions", - new_callable=AsyncMock, - return_value=0, - ), - patch( - "app.features.story.service.create_story_version", - new_callable=AsyncMock, - return_value=version, - ), - patch( - "app.features.story.service._extract_and_store_image_intent", - new_callable=AsyncMock, - ), - patch( - "app.features.memoir.repo.mark_chapters_dirty_for_story", - new_callable=AsyncMock, - ) as m_mark, - patch( - "app.tasks.chapter_compose_tasks.recompose_chapters_for_story" - ) as m_task, - ): - m_task.delay = MagicMock() - svc = StoryService(db=db) - await svc.append_version("story-1", "# 新正文") - m_mark.assert_awaited_once_with(db, "story-1") - m_task.delay.assert_called_once_with("story-1") - - asyncio.run(run()) diff --git a/api/tests/test_chapters_router_images.py b/api/tests/test_chapters_router_images.py deleted file mode 100644 index be6282d..0000000 --- a/api/tests/test_chapters_router_images.py +++ /dev/null @@ -1,203 +0,0 @@ -import os -import unittest -from unittest.mock import Mock, patch - -from app.features.memoir.helpers import chapter_to_dict as _chapter_to_dict -from app.features.memoir.memoir_images.storage import CosDownloadUrlError - - -def _image_stub(**kwargs): - """构造具有 .placeholder 等属性的 image 对象,供 memoir_image_to_dict 使用。""" - defaults = { - "placeholder": "", - "description": "", - "order_index": 0, - "status": "pending", - "prompt": None, - "url": None, - "storage_key": None, - "provider": None, - "style": None, - "size": None, - "error": None, - "retryable": None, - "created_at": None, - "updated_at": None, - } - defaults.update(kwargs) - return type("ImageStub", (), defaults)() - - -def _chapter_stub(*, images=None, canonical_markdown=None): - """stories-first:章节配图均为 chapter 级 MemoirImage(按 order_index 取封面)。""" - images = images or [] - if canonical_markdown is None: - # 正文内 asset:// 插图数 >3 时章节封面才展示(cover_eligibility) - canonical_markdown = "正文\n" + "\n".join( - [f"![](asset://c{i})" for i in range(4)] - ) - return type( - "ChapterStub", - (), - { - "id": "chapter-1", - "title": "童年的夏天", - "content": "", - "order_index": 0, - "status": "completed", - "category": "childhood", - "canonical_markdown": canonical_markdown, - "images": images, - "cover_image": None, - "cover_asset_id": None, - "updated_at": None, - "is_new": False, - "source_segments": [], - }, - )() - - -class ChaptersRouterImagesTest(unittest.TestCase): - @patch("app.features.memoir.helpers.TencentCosStorageService") - @patch.dict( - os.environ, - { - "TENCENT_COS_BUCKET": "life-echo-dev-1319381411", - "TENCENT_COS_REGION": "ap-shanghai", - "TENCENT_COS_BASE_URL": "https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com", - }, - clear=False, - ) - def test_chapter_to_dict_returns_signed_image_urls_for_response(self, storage_cls): - storage = Mock() - storage.get_download_url.return_value = ( - "https://signed.example.com/memoirs/u1/c1/0-demo.png?sig=123" - ) - storage_cls.from_settings.return_value = storage - - img0 = _image_stub( - placeholder="{{IMAGE:南方小镇的青石板路}}", - description="南方小镇的青石板路", - status="completed", - prompt="A serene southern China town", - url="https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0-demo.png", - storage_key="memoirs/u1/c1/0-demo.png", - order_index=0, - ) - chapter = _chapter_stub(images=[img0]) - - payload = _chapter_to_dict(chapter) - - self.assertEqual(payload["images"], []) - self.assertEqual( - payload["cover_image"]["url"], - "https://signed.example.com/memoirs/u1/c1/0-demo.png?sig=123", - ) - self.assertEqual( - payload["cover_image"]["prompt"], "A serene southern China town" - ) - self.assertNotIn("storage_key", payload["cover_image"]) - - @patch("app.features.memoir.helpers.TencentCosStorageService") - @patch.dict( - os.environ, - { - "TENCENT_COS_BUCKET": "life-echo-dev-1319381411", - "TENCENT_COS_REGION": "ap-shanghai", - "TENCENT_COS_BASE_URL": "https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com", - }, - clear=False, - ) - def test_chapter_to_dict_preserves_completed_asset_when_signing_fails( - self, storage_cls - ): - storage = Mock() - storage.get_download_url.side_effect = CosDownloadUrlError( - "cos unavailable", retryable=True, request_id="req-err" - ) - storage_cls.from_settings.return_value = storage - - img0 = _image_stub( - placeholder="{{IMAGE:南方小镇的青石板路}}", - description="南方小镇的青石板路", - status="completed", - prompt="A serene southern China town", - url="https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0-demo.png", - storage_key="memoirs/u1/c1/0-demo.png", - order_index=0, - ) - chapter = _chapter_stub(images=[img0]) - - payload = _chapter_to_dict(chapter) - - self.assertEqual(payload["images"], []) - self.assertEqual(payload["cover_image"]["status"], "completed") - self.assertIsNone(payload["cover_image"]["url"]) - self.assertEqual( - payload["cover_image"]["prompt"], "A serene southern China town" - ) - self.assertEqual(payload["cover_image"]["error"], "image delivery unavailable") - self.assertNotIn("storage_key", payload["cover_image"]) - - @patch("app.features.memoir.helpers.TencentCosStorageService") - def test_chapter_to_dict_inline_images_list_empty(self, storage_cls): - storage_cls.from_settings.return_value = Mock() - img = _image_stub( - status="completed", placeholder="", description="", order_index=0 - ) - chapter = _chapter_stub(images=[img]) - - payload = _chapter_to_dict(chapter) - - self.assertEqual(payload["images"], []) - - @patch("app.features.memoir.helpers.MemoirImageSettings") - @patch("app.features.memoir.helpers.TencentCosStorageService") - def test_chapter_to_dict_hides_non_completed_assets_when_feature_disabled( - self, storage_cls, memoir_img_settings_cls - ): - storage = Mock() - storage.get_download_url.return_value = ( - "https://signed.example.com/0.png?sig=123" - ) - storage_cls.from_settings.return_value = storage - memoir_img_settings_cls.from_settings.return_value = Mock(enabled=False) - - img_completed = _image_stub( - placeholder="{{IMAGE:奶奶坐在院子里的藤椅上}}", - description="奶奶坐在院子里的藤椅上", - status="completed", - url="https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/1-demo.png", - storage_key="memoirs/u1/c1/1-demo.png", - order_index=0, - ) - chapter = _chapter_stub(images=[img_completed]) - - payload = _chapter_to_dict(chapter) - - self.assertEqual(payload["images"], []) - self.assertEqual(payload["cover_image"]["status"], "completed") - - @patch("app.features.memoir.helpers.TencentCosStorageService") - @patch.dict(os.environ, {"MEMOIR_IMAGE_ENABLED": "true"}, clear=False) - def test_chapter_to_dict_preserves_retryable_flag_for_failed_assets( - self, storage_cls - ): - storage_cls.from_settings.return_value = Mock() - - img = _image_stub( - placeholder="{{IMAGE:南方小镇的青石板路}}", - description="南方小镇的青石板路", - status="failed", - url=None, - error="upload denied", - retryable=False, - order_index=0, - ) - chapter = _chapter_stub(images=[img]) - - payload = _chapter_to_dict(chapter) - - self.assertEqual(payload["images"], []) - self.assertEqual(payload["cover_image"]["status"], "failed") - self.assertFalse(payload["cover_image"]["retryable"]) diff --git a/api/tests/test_conversation.py b/api/tests/test_conversation.py deleted file mode 100644 index 14c748a..0000000 --- a/api/tests/test_conversation.py +++ /dev/null @@ -1,383 +0,0 @@ -#!/usr/bin/env python3 -""" -多轮对话测试脚本 -测试对话引导 Agent 和回忆录整理功能 -""" - -import asyncio -import json -import uuid -import httpx -import websockets -from datetime import datetime - -# 配置 -BASE_URL = "http://localhost:8000" -WS_URL = "ws://localhost:8000" - -# 测试用户信息 -TEST_PHONE = f"138{uuid.uuid4().hex[:8]}" # 随机手机号避免冲突 -TEST_PASSWORD = "test123456" -TEST_NICKNAME = "测试用户" - -# 模拟用户的多轮对话内容(关于童年和教育阶段) -CONVERSATION_MESSAGES = [ - # 童年阶段 - "我出生在南方一个小镇,小时候跟奶奶住在一起。", - "奶奶家有个小院子,夏天的时候我们经常坐在院子里乘凉,她给我讲故事。", - "那段时光真的很美好,我记得奶奶总是给我做红烧肉,那是我最爱吃的菜。", - "小时候最开心的事就是过年,可以放鞭炮,还能收到压岁钱。", - # 教育阶段 - "后来我去城里上学了,那是我第一次离开家,心里特别害怕。", - "初中的时候遇到了一个很好的语文老师,她鼓励我多读书,对我影响很大。", - "高考那年压力特别大,但最后还是考上了理想的大学。", -] - - -class ConversationTester: - """对话测试器""" - - def __init__(self): - self.token = None - self.user_id = None - self.conversation_id = str(uuid.uuid4()) - - async def register_or_login(self): - """注册或登录用户""" - async with httpx.AsyncClient(timeout=30.0) as client: - # 先尝试注册 - print(f"\n📝 注册用户: {TEST_PHONE}") - resp = await client.post( - f"{BASE_URL}/api/auth/register", - json={ - "phone": TEST_PHONE, - "password": TEST_PASSWORD, - "nickname": TEST_NICKNAME, - }, - ) - - if resp.status_code == 201: - data = resp.json() - self.token = data["access_token"] - print(f"✅ 注册成功!") - elif resp.status_code == 400 and "已被注册" in resp.text: - # 已注册,尝试登录 - print(f"ℹ️ 用户已存在,尝试登录...") - resp = await client.post( - f"{BASE_URL}/api/auth/login", - json={"phone": TEST_PHONE, "password": TEST_PASSWORD}, - ) - if resp.status_code == 200: - data = resp.json() - self.token = data["access_token"] - print(f"✅ 登录成功!") - else: - raise Exception(f"登录失败: {resp.text}") - else: - raise Exception(f"注册失败: {resp.text}") - - print(f"🔑 Token: {self.token[:30]}...") - - async def get_memoir_state(self): - """获取回忆录状态""" - async with httpx.AsyncClient(timeout=60.0) as client: - resp = await client.get( - f"{BASE_URL}/api/memoir-state", - headers={"Authorization": f"Bearer {self.token}"}, - ) - if resp.status_code != 200: - print(f" ⚠️ 状态API返回 {resp.status_code}: {resp.text[:200]}") - return {"current_stage": "unknown", "covered_stages": [], "slots": {}} - return resp.json() - - async def get_chapters(self): - """获取章节列表""" - async with httpx.AsyncClient(timeout=60.0) as client: - resp = await client.get( - f"{BASE_URL}/api/chapters", - headers={"Authorization": f"Bearer {self.token}"}, - ) - if resp.status_code != 200: - print(f" ⚠️ 章节API返回 {resp.status_code}: {resp.text[:200]}") - return [] - return resp.json() - - async def get_book(self): - """获取回忆录信息""" - async with httpx.AsyncClient(timeout=60.0) as client: - resp = await client.get( - f"{BASE_URL}/api/books/current", - headers={"Authorization": f"Bearer {self.token}"}, - ) - if resp.status_code != 200: - print(f" ⚠️ 回忆录API返回 {resp.status_code}: {resp.text[:200]}") - return {"message": "获取失败"} - return resp.json() - - async def get_tasks_status(self): - """获取任务状态""" - async with httpx.AsyncClient(timeout=60.0) as client: - resp = await client.get( - f"{BASE_URL}/api/tasks/status", - headers={"Authorization": f"Bearer {self.token}"}, - ) - if resp.status_code != 200: - return {"total": 0, "all_completed": True, "tasks": []} - return resp.json() - - async def clear_tasks(self): - """清除任务记录""" - async with httpx.AsyncClient(timeout=60.0) as client: - await client.delete( - f"{BASE_URL}/api/tasks/clear", - headers={"Authorization": f"Bearer {self.token}"}, - ) - - async def run_conversation(self): - """运行多轮对话""" - print(f"\n🔗 连接 WebSocket: {self.conversation_id}") - - ws_url = f"{WS_URL}/ws/conversation/{self.conversation_id}?token={self.token}" - - async with websockets.connect(ws_url) as ws: - # 接收连接确认 - msg = await ws.recv() - data = json.loads(msg) - print(f"✅ 连接成功: {data['type']}") - - # 多轮对话 - for i, user_message in enumerate(CONVERSATION_MESSAGES, 1): - print(f"\n{'=' * 60}") - print(f"📤 第 {i} 轮对话") - print(f"{'=' * 60}") - print(f"👤 用户: {user_message}") - - # 发送消息 - await ws.send( - json.dumps({"type": "text", "data": {"text": user_message}}) - ) - - # 接收 Agent 回复(可能是多条消息) - try: - while True: - msg = await asyncio.wait_for(ws.recv(), timeout=30) - data = json.loads(msg) - if data["type"] == "agent_response": - msg_data = data["data"] - total = msg_data.get("total", 1) - index = msg_data.get("index", 0) - print(f"🤖 Agent: {msg_data['text']}") - # 如果是最后一条消息,退出循环 - if index >= total - 1: - break - elif data["type"] == "error": - print(f"❌ 错误: {data['data']['message']}") - break - else: - break - except asyncio.TimeoutError: - print("⏰ 等待响应超时") - - # 短暂等待,模拟真实对话节奏 - await asyncio.sleep(1) - - # 结束对话 - print(f"\n{'=' * 60}") - print("📭 结束对话") - print(f"{'=' * 60}") - - await ws.send( - json.dumps( - { - "type": "end_conversation", - "conversation_id": self.conversation_id, - } - ) - ) - - try: - # 结束时会触发 process_conversation_segments,可能需要更长时间 - msg = await asyncio.wait_for(ws.recv(), timeout=60) - data = json.loads(msg) - if data["type"] == "error": - print(f"❌ 结束对话错误: {data['data'].get('message', 'unknown')}") - else: - print(f"✅ 对话结束: {data['type']}") - except asyncio.TimeoutError: - print("⏰ 等待结束确认超时(但后台处理可能仍在进行)") - - async def wait_for_processing( - self, max_wait_seconds: int = 300, check_interval: int = 3 - ): - """ - 等待后台处理完成 - 通过查询 Celery 任务状态来判断处理是否完成 - - Args: - max_wait_seconds: 最大等待时间(秒),默认 5 分钟 - check_interval: 检查间隔(秒) - - Returns: - 是否在超时前完成 - """ - print(f"\n⏳ 等待后台任务完成(最多 {max_wait_seconds} 秒)...") - print(" 提示: 通过 Celery 任务状态 API 追踪任务进度") - - start_time = asyncio.get_event_loop().time() - - while True: - elapsed = asyncio.get_event_loop().time() - start_time - - if elapsed >= max_wait_seconds: - print(f"\n⚠️ 已等待 {max_wait_seconds} 秒,超时退出") - return False - - # 检查任务状态 - tasks_status = await self.get_tasks_status() - total = tasks_status.get("total", 0) - pending = tasks_status.get("pending", 0) - running = tasks_status.get("running", 0) - success = tasks_status.get("success", 0) - failure = tasks_status.get("failure", 0) - all_completed = tasks_status.get("all_completed", False) - - # 同时检查章节内容 - chapters = await self.get_chapters() - chapter_count = len(chapters) - total_content_length = sum(len(ch.get("content", "")) for ch in chapters) - - status_str = f"📊 总:{total} 等待:{pending} 运行:{running} 成功:{success} 失败:{failure}" - content_str = f"📚 章节:{chapter_count} 内容:{total_content_length}字符" - print(f" [{int(elapsed):3d}s] {status_str} | {content_str}") - - # 判断是否完成: - # 1. 有任务且全部完成 - # 2. 或者没有任务但有章节内容(兼容旧逻辑) - if total > 0 and all_completed: - print(f"\n✅ 所有任务已完成!共 {total} 个任务,等待 {int(elapsed)} 秒") - return True - - # 如果没有任务记录,等待一会儿任务提交 - if total == 0 and elapsed < 15: - await asyncio.sleep(check_interval) - continue - - # 如果长时间没有任务但有内容,也认为完成 - if total == 0 and chapter_count > 0 and elapsed > 30: - print( - f"\n✅ 无待处理任务,已有 {chapter_count} 个章节。等待 {int(elapsed)} 秒" - ) - return True - - await asyncio.sleep(check_interval) - - async def check_results(self): - """检查回忆录生成结果""" - print(f"\n{'=' * 60}") - print("📊 检查结果") - print(f"{'=' * 60}") - - # 等待后台处理完成(使用智能轮询) - await self.wait_for_processing(max_wait_seconds=180, check_interval=5) - - # 获取回忆录状态 - print("\n📋 回忆录状态:") - state = await self.get_memoir_state() - print(f" 当前阶段: {state.get('current_stage', 'N/A')}") - print(f" 已完成阶段: {state.get('covered_stages', [])}") - - # 显示已填充的 slots - slots = state.get("slots", {}) - for stage, stage_slots in slots.items(): - filled = [k for k, v in stage_slots.items() if v.get("snippet")] - if filled: - print(f" {stage} 已填充: {filled}") - for slot_name in filled: - snippet = stage_slots[slot_name].get("snippet", "") - if snippet: - print(f" - {slot_name}: {snippet[:50]}...") - - # 获取章节 - print("\n📚 生成的章节:") - chapters = await self.get_chapters() - if chapters: - for ch in chapters: - is_new = "🆕" if ch.get("is_new") else "" - content_len = len(ch.get("content", "")) - print( - f" {is_new} [{ch.get('category', 'N/A')}] {ch.get('title', 'N/A')} ({content_len} 字符)" - ) - else: - print(" (暂无章节)") - - # 获取回忆录 - print("\n📖 回忆录信息:") - book = await self.get_book() - if "message" not in book: - print(f" 标题: {book.get('title', 'N/A')}") - print(f" 总字数: {book.get('total_words', 0)}") - print(f" 有更新: {'是' if book.get('has_update') else '否'}") - else: - print(f" {book.get('message', 'N/A')}") - - # 显示回忆录完整内容 - if chapters: - print(f"\n{'=' * 60}") - print("📜 回忆录完整内容") - print(f"{'=' * 60}") - for ch in chapters: - category = ch.get("category", "N/A") - title = ch.get("title", "未命名章节") - content = ch.get("content", "") - - print(f"\n{'─' * 60}") - print(f"【{title}】({category})") - print(f"{'─' * 60}") - if content: - print(content) - else: - print("(暂无内容)") - print(f"\n{'=' * 60}") - - -async def main(): - """主函数""" - print("=" * 60) - print("🎭 Life Echo 多轮对话测试") - print(f"⏰ 开始时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") - print("=" * 60) - - tester = ConversationTester() - - try: - # 1. 注册/登录 - await tester.register_or_login() - - # 2. 清除旧的任务记录 - await tester.clear_tasks() - print("\n🧹 已清除旧的任务记录") - - # 3. 查看初始状态 - print("\n📋 初始回忆录状态:") - state = await tester.get_memoir_state() - print(f" 当前阶段: {state.get('current_stage', 'N/A')}") - - # 4. 运行多轮对话 - await tester.run_conversation() - - # 5. 检查结果 - await tester.check_results() - - except Exception as e: - print(f"\n❌ 测试失败: {e}") - import traceback - - traceback.print_exc() - - print("\n" + "=" * 60) - print(f"⏰ 结束时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") - print("=" * 60) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/api/tests/test_conversation_messages_history.py b/api/tests/test_conversation_messages_history.py deleted file mode 100644 index 24d0739..0000000 --- a/api/tests/test_conversation_messages_history.py +++ /dev/null @@ -1,146 +0,0 @@ -import unittest -from datetime import datetime, timezone - -from app.features.conversation.models import Conversation - -from app.features.conversation.service import ( - _build_messages_from_history, - _latest_message_time_ms, - _message_timestamp_ms, -) - - -class ConversationMessagesHistoryTest(unittest.TestCase): - def test_build_messages_collapses_audio_segments_from_same_voice_session(self): - history = [ - { - "role": "human", - "content": "第一段", - "messageType": "audio", - "voiceSessionId": "voice-1", - "timestamp": "2026-03-14T12:00:01+00:00", - }, - { - "role": "ai", - "content": "继续说", - "messageType": "text", - "timestamp": "2026-03-14T12:00:02+00:00", - }, - { - "role": "human", - "content": "第二段", - "messageType": "audio", - "voiceSessionId": "voice-1", - "timestamp": "2026-03-14T12:00:03+00:00", - }, - { - "role": "ai", - "content": "我记住了", - "messageType": "text", - "timestamp": "2026-03-14T12:00:04+00:00", - }, - ] - - messages = _build_messages_from_history( - conversation_id="conv-1", - history=history, - fallback_timestamp=datetime(2026, 3, 14, 12, 0, 0, tzinfo=timezone.utc), - ) - - self.assertEqual( - [ - (msg["senderType"], msg["messageType"], msg["content"]) - for msg in messages - ], - [ - ("user", "audio", "第一段"), - ("assistant", "text", "继续说"), - ("assistant", "text", "我记住了"), - ], - ) - self.assertEqual(messages[0]["timestamp"], 1773489601000) - self.assertEqual(messages[0]["voiceSessionId"], "voice-1") - self.assertEqual(messages[1]["timestamp"], 1773489602000) - self.assertEqual(messages[2]["timestamp"], 1773489604000) - - def test_build_messages_keeps_distinct_voice_sessions_separate(self): - history = [ - { - "role": "human", - "content": "第一次录音", - "messageType": "audio", - "voiceSessionId": "voice-1", - "timestamp": "2026-03-14T12:00:01+00:00", - }, - { - "role": "human", - "content": "第二次录音", - "messageType": "audio", - "voiceSessionId": "voice-2", - "timestamp": "2026-03-14T12:00:02+00:00", - }, - ] - - messages = _build_messages_from_history( - conversation_id="conv-1", - history=history, - fallback_timestamp=datetime(2026, 3, 14, 12, 0, 0, tzinfo=timezone.utc), - ) - - self.assertEqual(len(messages), 2) - self.assertEqual(messages[0]["messageType"], "audio") - self.assertEqual(messages[0]["content"], "第一次录音") - self.assertEqual(messages[0]["voiceSessionId"], "voice-1") - self.assertEqual(messages[1]["messageType"], "audio") - self.assertEqual(messages[1]["content"], "第二次录音") - self.assertEqual(messages[1]["voiceSessionId"], "voice-2") - - def test_build_messages_includes_duration_seconds_from_history(self): - history = [ - { - "role": "human", - "content": "你好", - "messageType": "audio", - "voiceSessionId": "vs-a", - "durationSeconds": 8, - "timestamp": "2026-03-14T12:00:01+00:00", - }, - ] - messages = _build_messages_from_history( - conversation_id="conv-1", - history=history, - fallback_timestamp=datetime(2026, 3, 14, 12, 0, 0, tzinfo=timezone.utc), - ) - self.assertEqual(len(messages), 1) - self.assertEqual(messages[0]["durationSeconds"], 8) - - def test_latest_message_time_prefers_conversation_last_message_at(self): - conversation = Conversation( - id="conv-1", - user_id="user-1", - started_at=datetime(2026, 3, 9, 12, 0, 0, tzinfo=timezone.utc), - last_message_at=datetime(2026, 3, 14, 12, 0, 5, tzinfo=timezone.utc), - ) - history = [ - { - "role": "human", - "content": "旧消息", - "messageType": "text", - "timestamp": "2026-03-10T12:00:00+00:00", - } - ] - - latest_message_time = _latest_message_time_ms(conversation, history) - - self.assertEqual(latest_message_time, 1773489605000) - - def test_message_timestamp_falls_back_to_started_at_for_legacy_history(self): - conversation = Conversation( - id="conv-1", - user_id="user-1", - started_at=datetime(2026, 3, 14, 12, 0, 0, tzinfo=timezone.utc), - ) - - timestamp = _message_timestamp_ms({}, conversation.started_at) - - self.assertEqual(timestamp, 1773489600000) diff --git a/api/tests/test_cos_url_keys.py b/api/tests/test_cos_url_keys.py deleted file mode 100644 index 7e480d7..0000000 --- a/api/tests/test_cos_url_keys.py +++ /dev/null @@ -1,32 +0,0 @@ -"""cos_url_keys:仅当 host 匹配配置时才解析 key。""" - -import unittest -from unittest.mock import patch - -from app.core.cos_url_keys import extract_cos_object_key_if_owned - - -class TestExtractCosObjectKeyIfOwned(unittest.TestCase): - def test_non_http_returns_none(self): - self.assertIsNone(extract_cos_object_key_if_owned("audio-segment:x:0")) - self.assertIsNone(extract_cos_object_key_if_owned(None)) - - @patch("app.core.cos_url_keys.settings") - def test_matching_host_returns_key(self, mock_settings): - mock_settings.tencent_cos_bucket = "mybucket" - mock_settings.tencent_cos_region = "ap-shanghai" - mock_settings.tencent_cos_base_url = "" - url = "https://mybucket.cos.ap-shanghai.myqcloud.com/chapters/u1/c1/a.png" - self.assertEqual(extract_cos_object_key_if_owned(url), "chapters/u1/c1/a.png") - - @patch("app.core.cos_url_keys.settings") - def test_foreign_host_returns_none(self, mock_settings): - mock_settings.tencent_cos_bucket = "mybucket" - mock_settings.tencent_cos_region = "ap-shanghai" - mock_settings.tencent_cos_base_url = "" - url = "https://evil.com/chapters/u1/c1/a.png" - self.assertIsNone(extract_cos_object_key_if_owned(url)) - - -if __name__ == "__main__": - unittest.main() diff --git a/api/tests/test_cover_eligibility.py b/api/tests/test_cover_eligibility.py deleted file mode 100644 index f832032..0000000 --- a/api/tests/test_cover_eligibility.py +++ /dev/null @@ -1,49 +0,0 @@ -"""章节封面与正文插图数量闸门。""" - -import unittest - -from app.features.memoir.cover_eligibility import ( - MIN_INLINE_BODY_IMAGES_FOR_CHAPTER_COVER, - chapter_eligible_for_cover_by_inline_body_image_count, - chapter_needs_cover_enqueue, - count_chapter_inline_body_images, -) - - -def _md_with_n_inline_images(n: int) -> str: - lines = [f"![c](asset://a{i})" for i in range(n)] - return "\n\n".join(lines) + "\n\n正文" - - -class CoverEligibilityTest(unittest.TestCase): - def test_count_inline_images(self): - class Ch: - canonical_markdown = _md_with_n_inline_images(4) - - self.assertEqual(count_chapter_inline_body_images(Ch()), 4) - - def test_eligible_only_when_more_than_threshold(self): - class Ch: - pass - - ch = Ch() - ch.canonical_markdown = _md_with_n_inline_images( - MIN_INLINE_BODY_IMAGES_FOR_CHAPTER_COVER - ) - self.assertFalse(chapter_eligible_for_cover_by_inline_body_image_count(ch)) - - ch.canonical_markdown = _md_with_n_inline_images( - MIN_INLINE_BODY_IMAGES_FOR_CHAPTER_COVER + 1 - ) - self.assertTrue(chapter_eligible_for_cover_by_inline_body_image_count(ch)) - - def test_chapter_needs_cover_enqueue_requires_count(self): - class Ch: - cover_asset_id = None - - ch = Ch() - ch.canonical_markdown = _md_with_n_inline_images(2) - self.assertFalse(chapter_needs_cover_enqueue(ch)) - - ch.canonical_markdown = _md_with_n_inline_images(4) - self.assertTrue(chapter_needs_cover_enqueue(ch)) diff --git a/api/tests/test_generate_chapter_images_persistence.py b/api/tests/test_generate_chapter_images_persistence.py deleted file mode 100644 index f46a3cc..0000000 --- a/api/tests/test_generate_chapter_images_persistence.py +++ /dev/null @@ -1,113 +0,0 @@ -import base64 -import unittest -from types import SimpleNamespace -from unittest.mock import Mock, patch - -from app.ports.image_gen import ImageResult, TaskStatus -from app.tasks.memoir_tasks import build_cos_key, generate_chapter_images - - -_ONE_BY_ONE_PNG = base64.b64decode( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aF9sAAAAASUVORK5CYII=" -) - - -def _cover_image_record(img_dict): - d = dict(img_dict or {}) - return SimpleNamespace( - id="cover-img-1", - order_index=d.get("index", 0), - placeholder=d.get("placeholder"), - description=d.get("description"), - status=d.get("status"), - prompt=d.get("prompt"), - url=d.get("url"), - storage_key=d.get("storage_key"), - provider=d.get("provider"), - style=d.get("style"), - size=d.get("size"), - error=d.get("error"), - retryable=d.get("retryable"), - created_at=d.get("created_at"), - updated_at=d.get("updated_at"), - ) - - -def _chapter_stub(): - rec = _cover_image_record( - { - "index": 0, - "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", - "description": "南方小镇的青石板路", - "status": "pending", - "url": None, - } - ) - return SimpleNamespace( - id="chapter-1", - user_id="user-1", - title="童年的夏天", - category="childhood", - canonical_markdown="# 标题\n\n那条路我一直记得。", - sections=[], - images=[rec], - cover_image=None, - ) - - -class GenerateChapterImagesPersistenceTest(unittest.TestCase): - @patch("app.tasks.memoir_tasks.get_sync_db") - @patch("app.tasks.memoir_tasks.TencentCosStorageService") - @patch("app.tasks.memoir_tasks.get_image_generator") - @patch("app.tasks.memoir_tasks.ImagePromptOrchestrator") - @patch("app.tasks.memoir_tasks._release_chapter_image_lock") - @patch("app.tasks.memoir_tasks._acquire_chapter_image_lock", return_value=True) - def test_successful_generation_persists_completed_status( - self, - _acquire_lock_mock, - _release_lock_mock, - prompt_service_cls, - get_image_generator_mock, - storage_cls, - get_sync_db_mock, - ): - chapter = _chapter_stub() - cover = chapter.images[0] - db = Mock() - db.execute.return_value.unique.return_value.scalar_one_or_none.return_value = ( - chapter - ) - get_sync_db_mock.return_value.__enter__.return_value = db - get_sync_db_mock.return_value.__exit__.return_value = False - - prompt_data = { - "prompt": "A serene southern China town", - "style": "watercolor", - "size": "1024x1024", - "prompt_context": "childhood: 童年的夏天", - } - prompt_service_cls.return_value.build_cover_prompt.return_value = prompt_data - mock_gen = Mock() - mock_gen.generate.return_value = ImageResult( - status=TaskStatus.COMPLETED, - task_id="", - image_url="https://provider.example.com/1.png", - ) - mock_gen.download_image.return_value = _ONE_BY_ONE_PNG - get_image_generator_mock.return_value = mock_gen - storage_cls.from_env.return_value.upload_bytes.return_value = ( - "https://cos.example.com/memoirs/user-1/chapter-1/cover.png" - ) - - generate_chapter_images.run("chapter-1") - - self.assertEqual(cover.status, "completed") - self.assertEqual( - cover.url, - "https://cos.example.com/memoirs/user-1/chapter-1/cover.png", - ) - self.assertEqual(cover.prompt, "A serene southern China town") - self.assertEqual( - cover.storage_key, - build_cos_key("user-1", "chapter-1", "cover", prompt_data["prompt"]), - ) diff --git a/api/tests/test_generate_chapter_images_task.py b/api/tests/test_generate_chapter_images_task.py deleted file mode 100644 index bf1432d..0000000 --- a/api/tests/test_generate_chapter_images_task.py +++ /dev/null @@ -1,351 +0,0 @@ -import unittest -from io import BytesIO -from types import SimpleNamespace -from unittest.mock import Mock, patch - -from PIL import Image - -from app.ports.image_gen import ImageResult, TaskStatus -from app.tasks import memoir_tasks -from app.tasks.memoir_tasks import build_cos_key, generate_chapter_images - - -def _mock_image_generator( - *, - image_url: str = "https://provider.example.com/1.png", - image_bytes: bytes | None = None, -): - """构造满足 port ImageGenerator 的 mock:generate 返回 ImageResult,download_image 返回 bytes。""" - if image_bytes is None: - buf = BytesIO() - Image.new("RGB", (1, 1), color="white").save(buf, format="PNG") - image_bytes = buf.getvalue() - gen = Mock() - gen.generate.return_value = ImageResult( - status=TaskStatus.COMPLETED, - task_id="", - image_url=image_url, - ) - gen.download_image.return_value = image_bytes - return gen - - -def _chapter_with_cover_memoir_image( - *, - cover_status: str = "pending", - cover_url: str | None = None, - canonical_markdown: str = "# 童年\n\n那条路我一直记得。", -): - """stories-first:章节级 MemoirImage(order_index 最小为封面槽位)。""" - cover_rec = SimpleNamespace( - id="cover-img-1", - order_index=0, - placeholder="", - description="", - status=cover_status, - url=cover_url, - storage_key=None, - prompt=None, - provider=None, - style=None, - size=None, - error=None, - retryable=None, - created_at=None, - updated_at=None, - ) - return SimpleNamespace( - id="chapter-1", - user_id="user-1", - title="童年的夏天", - category="childhood", - canonical_markdown=canonical_markdown, - cover_image=None, - images=[cover_rec], - ) - - -def _bind_db_execute_to_chapter(db_mock, chapter): - """让 db.execute(select(...)).unique().scalar_one_or_none() 返回 chapter。""" - db_mock.execute.return_value.unique.return_value.scalar_one_or_none.return_value = ( - chapter - ) - - -class GenerateChapterImagesTaskTest(unittest.TestCase): - def setUp(self): - memoir_tasks._REDIS_CLIENTS.clear() - - @patch("app.tasks.memoir_tasks.redis.from_url") - @patch("app.tasks.memoir_tasks.get_sync_db") - @patch("app.tasks.memoir_tasks.TencentCosStorageService") - @patch("app.tasks.memoir_tasks.get_image_generator") - @patch("app.tasks.memoir_tasks.ImagePromptOrchestrator") - def test_generate_chapter_images_skips_when_lock_is_already_held( - self, - prompt_service_cls, - get_image_generator_mock, - storage_cls, - get_sync_db_mock, - redis_from_url, - ): - chapter = _chapter_with_cover_memoir_image() - db = Mock() - _bind_db_execute_to_chapter(db, chapter) - get_sync_db_mock.return_value.__enter__.return_value = db - get_sync_db_mock.return_value.__exit__.return_value = False - redis_from_url.return_value.set.return_value = False - - result = generate_chapter_images.run("chapter-1") - - self.assertEqual(result, {"status": "locked"}) - get_image_generator_mock.return_value.generate.assert_not_called() - storage_cls.from_env.return_value.upload_bytes.assert_not_called() - db.commit.assert_not_called() - - @patch("app.tasks.memoir_tasks.get_sync_db") - @patch("app.tasks.memoir_tasks.TencentCosStorageService") - @patch("app.tasks.memoir_tasks.get_image_generator") - @patch("app.tasks.memoir_tasks.ImagePromptOrchestrator") - @patch("app.tasks.memoir_tasks._release_chapter_image_lock") - @patch("app.tasks.memoir_tasks._acquire_chapter_image_lock", return_value=True) - def test_generate_chapter_images_retries_when_cover_generation_fails( - self, - _acquire_lock_mock, - _release_lock_mock, - prompt_service_cls, - get_image_generator_mock, - storage_cls, - get_sync_db_mock, - ): - chapter = _chapter_with_cover_memoir_image() - cover = chapter.images[0] - db = Mock() - _bind_db_execute_to_chapter(db, chapter) - get_sync_db_mock.return_value.__enter__.return_value = db - get_sync_db_mock.return_value.__exit__.return_value = False - prompt_service_cls.return_value.build_cover_prompt.return_value = { - "prompt": "A serene southern China town", - "style": "watercolor", - "size": "1024x1024", - "prompt_context": "childhood: 童年的夏天", - } - get_image_generator_mock.return_value.generate.side_effect = RuntimeError( - "transient provider error" - ) - - retry_error = RuntimeError("retry requested") - task_self = SimpleNamespace( - request=SimpleNamespace(id="task-1"), retry=Mock(side_effect=retry_error) - ) - - with self.assertRaises(RuntimeError) as ctx: - generate_chapter_images.run.__func__(task_self, "chapter-1") - - self.assertIs(ctx.exception, retry_error) - self.assertEqual(cover.status, "failed") - self.assertEqual(cover.error, "transient provider error") - task_self.retry.assert_called_once() - storage_cls.from_env.return_value.upload_bytes.assert_not_called() - - @patch("app.tasks.memoir_tasks.get_sync_db") - @patch("app.tasks.memoir_tasks.TencentCosStorageService") - @patch("app.tasks.memoir_tasks.get_image_generator") - @patch("app.tasks.memoir_tasks.ImagePromptOrchestrator") - @patch("app.tasks.memoir_tasks._release_chapter_image_lock") - @patch("app.tasks.memoir_tasks._acquire_chapter_image_lock", return_value=True) - def test_generate_chapter_images_marks_successful_cover_completed( - self, - _acquire_lock_mock, - _release_lock_mock, - prompt_service_cls, - get_image_generator_mock, - storage_cls, - get_sync_db_mock, - ): - chapter = _chapter_with_cover_memoir_image() - cover = chapter.images[0] - db = Mock() - _bind_db_execute_to_chapter(db, chapter) - get_sync_db_mock.return_value.__enter__.return_value = db - get_sync_db_mock.return_value.__exit__.return_value = False - prompt_data = { - "prompt": "A serene southern China town", - "style": "watercolor", - "size": "1024x1024", - "prompt_context": "childhood: 童年的夏天", - } - prompt_service_cls.return_value.build_cover_prompt.return_value = prompt_data - get_image_generator_mock.return_value = _mock_image_generator() - storage_inst = storage_cls.from_env.return_value - storage_inst.upload_bytes.return_value = ( - "https://cos.example.com/memoirs/u1/c1/cover.png" - ) - - generate_chapter_images.run("chapter-1") - - self.assertEqual(cover.status, "completed") - expected_key = build_cos_key( - "user-1", "chapter-1", "cover", prompt_data["prompt"] - ) - self.assertEqual(cover.storage_key, expected_key) - self.assertEqual( - cover.url, - "https://cos.example.com/memoirs/u1/c1/cover.png", - ) - self.assertEqual(cover.prompt, "A serene southern China town") - get_image_generator_mock.return_value.generate.assert_called_once() - prompt_service_cls.return_value.build_cover_prompt.assert_called_once() - db.commit.assert_called() - - @patch("app.tasks.memoir_tasks.get_sync_db") - @patch("app.tasks.memoir_tasks.TencentCosStorageService") - @patch("app.tasks.memoir_tasks.get_image_generator") - @patch("app.tasks.memoir_tasks.ImagePromptOrchestrator") - @patch("app.tasks.memoir_tasks.MemoirImageSettings.from_env") - def test_generate_chapter_images_returns_disabled_when_feature_flag_is_off( - self, - settings_from_env, - prompt_service_cls, - get_image_generator_mock, - storage_cls, - get_sync_db_mock, - ): - chapter = _chapter_with_cover_memoir_image() - settings_from_env.return_value = SimpleNamespace( - enabled=False, - max_per_chapter=2, - provider="liblib", - default_style="watercolor", - default_size="1024x1024", - poll_interval_seconds=3, - max_attempts=20, - liblib_template_uuid="tpl-uuid", - ) - db = Mock() - _bind_db_execute_to_chapter(db, chapter) - get_sync_db_mock.return_value.__enter__.return_value = db - get_sync_db_mock.return_value.__exit__.return_value = False - - result = generate_chapter_images.run("chapter-1") - - self.assertEqual(result, {"status": "disabled"}) - prompt_service_cls.assert_not_called() - get_image_generator_mock.assert_not_called() - storage_cls.from_env.return_value.upload_bytes.assert_not_called() - db.commit.assert_not_called() - - @patch("app.tasks.memoir_tasks.get_sync_db") - @patch("app.tasks.memoir_tasks.TencentCosStorageService") - @patch("app.tasks.memoir_tasks.get_image_generator") - @patch("app.tasks.memoir_tasks.ImagePromptOrchestrator") - @patch("app.tasks.memoir_tasks._release_chapter_image_lock") - @patch("app.tasks.memoir_tasks._acquire_chapter_image_lock", return_value=True) - def test_generate_chapter_images_converts_non_png_payload_before_upload( - self, - _acquire_lock_mock, - _release_lock_mock, - prompt_service_cls, - get_image_generator_mock, - storage_cls, - get_sync_db_mock, - ): - chapter = _chapter_with_cover_memoir_image() - db = Mock() - _bind_db_execute_to_chapter(db, chapter) - get_sync_db_mock.return_value.__enter__.return_value = db - get_sync_db_mock.return_value.__exit__.return_value = False - prompt_service_cls.return_value.build_cover_prompt.return_value = { - "prompt": "A serene southern China town", - "style": "watercolor", - "size": "1024x1024", - "prompt_context": "childhood: 童年的夏天", - } - image_buffer = BytesIO() - Image.new("RGB", (2, 1), color="white").save(image_buffer, format="JPEG") - jpeg_bytes = image_buffer.getvalue() - - get_image_generator_mock.return_value = _mock_image_generator( - image_url="https://provider.example.com/1.jpg", - image_bytes=jpeg_bytes, - ) - storage_inst = storage_cls.from_env.return_value - storage_inst.upload_bytes.return_value = ( - "https://cos.example.com/memoirs/u1/c1/cover.png" - ) - - generate_chapter_images.run("chapter-1") - - upload_args = storage_inst.upload_bytes.call_args.args - self.assertTrue(upload_args[0].startswith(b"\x89PNG\r\n\x1a\n")) - self.assertEqual(upload_args[2], "image/png") - - @patch("app.tasks.memoir_tasks.get_sync_db") - @patch("app.tasks.memoir_tasks.TencentCosStorageService") - @patch("app.tasks.memoir_tasks.get_image_generator") - @patch("app.tasks.memoir_tasks.ImagePromptOrchestrator") - @patch("app.tasks.memoir_tasks._release_chapter_image_lock") - @patch("app.tasks.memoir_tasks._acquire_chapter_image_lock", return_value=True) - def test_generate_chapter_images_fails_without_retry_on_permanent_cos_error( - self, - _acquire_lock_mock, - _release_lock_mock, - prompt_service_cls, - get_image_generator_mock, - storage_cls, - get_sync_db_mock, - ): - chapter = _chapter_with_cover_memoir_image() - cover = chapter.images[0] - db = Mock() - _bind_db_execute_to_chapter(db, chapter) - get_sync_db_mock.return_value.__enter__.return_value = db - get_sync_db_mock.return_value.__exit__.return_value = False - prompt_service_cls.return_value.build_cover_prompt.return_value = { - "prompt": "A serene southern China town", - "style": "watercolor", - "size": "1024x1024", - "prompt_context": "childhood: 童年的夏天", - } - get_image_generator_mock.return_value = _mock_image_generator() - storage_inst = storage_cls.from_env.return_value - storage_inst.upload_bytes.side_effect = memoir_tasks.CosUploadError( - "AccessDenied", retryable=False, request_id="req-403" - ) - task_self = SimpleNamespace(request=SimpleNamespace(id="task-1"), retry=Mock()) - - result = generate_chapter_images.run.__func__(task_self, "chapter-1") - - self.assertEqual(result, {"status": "success"}) - db.delete.assert_called_with(cover) - task_self.retry.assert_not_called() - - @patch("app.tasks.memoir_tasks.get_sync_db") - @patch("app.tasks.memoir_tasks.TencentCosStorageService") - @patch("app.tasks.memoir_tasks.get_image_generator") - @patch("app.tasks.memoir_tasks.ImagePromptOrchestrator") - @patch("app.tasks.memoir_tasks._release_chapter_image_lock") - @patch("app.tasks.memoir_tasks._acquire_chapter_image_lock", return_value=True) - def test_generate_chapter_images_skips_completed_cover_for_idempotency( - self, - _acquire_lock_mock, - _release_lock_mock, - prompt_service_cls, - get_image_generator_mock, - storage_cls, - get_sync_db_mock, - ): - chapter = _chapter_with_cover_memoir_image( - cover_status="completed", - cover_url="https://cos.example.com/already-there.png", - ) - db = Mock() - _bind_db_execute_to_chapter(db, chapter) - get_sync_db_mock.return_value.__enter__.return_value = db - get_sync_db_mock.return_value.__exit__.return_value = False - - result = generate_chapter_images.run("chapter-1") - - self.assertEqual(result, {"status": "no_images"}) - get_image_generator_mock.return_value.generate.assert_not_called() - storage_cls.from_env.return_value.upload_bytes.assert_not_called() diff --git a/api/tests/test_markdown_sanitize.py b/api/tests/test_markdown_sanitize.py deleted file mode 100644 index 696bc7e..0000000 --- a/api/tests/test_markdown_sanitize.py +++ /dev/null @@ -1,27 +0,0 @@ -import unittest - -from app.features.memoir.markdown_sanitize import ( - sanitize_story_for_chapter_compose, - strip_leading_heading_if_matches_title, - strip_markdown_tables, -) - - -class MarkdownSanitizeTest(unittest.TestCase): - def test_strip_table_block(self): - md = "第一段\n\n| a | b |\n| - | - |\n| 1 | 2 |\n\n第二段" - out = strip_markdown_tables(md) - self.assertNotIn("| a |", out) - self.assertIn("第一段", out) - self.assertIn("第二段", out) - - def test_strip_heading_when_matches_title(self): - body = "## 童年\n\n正文" - out = strip_leading_heading_if_matches_title(body, "童年") - self.assertEqual(out, "正文") - - def test_sanitize_compose(self): - raw = "| x | y |\n|---|---|\n|1|2|\n\n你好" - out = sanitize_story_for_chapter_compose(raw, "T") - self.assertIn("你好", out) - self.assertNotIn("| x |", out) diff --git a/api/tests/test_memoir_image_bootstrap.py b/api/tests/test_memoir_image_bootstrap.py deleted file mode 100644 index 3d61b76..0000000 --- a/api/tests/test_memoir_image_bootstrap.py +++ /dev/null @@ -1,141 +0,0 @@ -import os -import unittest -import unittest.mock - -from app.tasks.memoir_tasks import initialize_chapter_images - - -class MemoirImageBootstrapTest(unittest.TestCase): - def test_initialize_chapter_images_keeps_only_completed_assets_when_disabled(self): - """图片初始化已迁移到 _save_narrative_to_sections;此处为兼容 no-op,直接返回 []""" - chapter = type( - "ChapterStub", - (), - { - "id": "chapter-1", - "title": "童年的夏天", - "category": "childhood", - }, - )() - - with unittest.mock.patch.dict( - os.environ, {"MEMOIR_IMAGE_ENABLED": "false"}, clear=False - ): - assets = initialize_chapter_images(chapter) - - self.assertEqual(assets, []) - - def test_initialize_chapter_images_sets_pending_assets_when_enabled(self): - """图片初始化已迁移到 _save_narrative_to_sections;此处为兼容 no-op""" - chapter = type( - "ChapterStub", - (), - { - "id": "chapter-1", - "title": "童年的夏天", - "category": "childhood", - }, - )() - - with unittest.mock.patch.dict( - os.environ, {"MEMOIR_IMAGE_ENABLED": "true"}, clear=False - ): - assets = initialize_chapter_images(chapter) - - self.assertEqual(assets, []) - - def test_initialize_chapter_images_preserves_completed_assets_and_adds_only_new_placeholders( - self, - ): - """图片初始化已迁移到 _save_narrative_to_sections;此处为兼容 no-op""" - chapter = type( - "ChapterStub", - (), - {"id": "chapter-1", "title": "童年的夏天", "category": "childhood"}, - )() - - with unittest.mock.patch.dict( - os.environ, {"MEMOIR_IMAGE_ENABLED": "true"}, clear=False - ): - assets = initialize_chapter_images(chapter) - - self.assertEqual(assets, []) - - def test_initialize_chapter_images_accepts_double_brace_placeholders(self): - """图片初始化已迁移到 _save_narrative_to_sections;此处为兼容 no-op""" - chapter = type( - "ChapterStub", - (), - {"id": "chapter-1", "title": "童年的夏天", "category": "childhood"}, - )() - - with unittest.mock.patch.dict( - os.environ, {"MEMOIR_IMAGE_ENABLED": "true"}, clear=False - ): - assets = initialize_chapter_images(chapter) - - self.assertEqual(assets, []) - - def test_initialize_chapter_images_normalizes_invalid_existing_asset_status(self): - """图片初始化已迁移到 _save_narrative_to_sections;此处为兼容 no-op""" - chapter = type( - "ChapterStub", - (), - {"id": "chapter-1", "title": "童年的夏天", "category": "childhood"}, - )() - - with unittest.mock.patch.dict( - os.environ, {"MEMOIR_IMAGE_ENABLED": "true"}, clear=False - ): - assets = initialize_chapter_images(chapter) - - self.assertEqual(assets, []) - - def test_initialize_chapter_images_preserves_existing_completed_assets_beyond_effective_max( - self, - ): - """图片初始化已迁移到 _save_narrative_to_sections;此处为兼容 no-op""" - chapter = type( - "ChapterStub", - (), - {"id": "chapter-1", "title": "童年的夏天", "category": "childhood"}, - )() - - with unittest.mock.patch.dict( - os.environ, - {"MEMOIR_IMAGE_ENABLED": "true", "MEMOIR_IMAGE_MAX_PER_CHAPTER": "2"}, - clear=False, - ): - assets = initialize_chapter_images(chapter) - - self.assertEqual(assets, []) - - def test_initialize_chapter_images_increases_limit_for_long_content(self): - """图片初始化已迁移到 _save_narrative_to_sections;此处为兼容 no-op""" - chapter = type( - "ChapterStub", - (), - {"id": "chapter-1", "title": "童年的夏天", "category": "childhood"}, - )() - - with unittest.mock.patch.dict( - os.environ, {"MEMOIR_IMAGE_ENABLED": "true"}, clear=False - ): - assets = initialize_chapter_images(chapter) - - self.assertEqual(assets, []) - - def test_initialize_chapter_images_caps_dynamic_limit_at_max_images_cap(self): - """图片初始化已迁移到 _save_narrative_to_sections;此处为兼容 no-op""" - chapter = type( - "ChapterStub", - (), - {"id": "chapter-1", "title": "童年的夏天", "category": "childhood"}, - )() - - with unittest.mock.patch.dict( - os.environ, {"MEMOIR_IMAGE_ENABLED": "true"}, clear=False - ): - assets = initialize_chapter_images(chapter) - - self.assertEqual(assets, []) diff --git a/api/tests/test_memoir_image_parser.py b/api/tests/test_memoir_image_parser.py deleted file mode 100644 index 3b8bd58..0000000 --- a/api/tests/test_memoir_image_parser.py +++ /dev/null @@ -1,86 +0,0 @@ -import unittest - -from app.features.memoir.memoir_images.parser import ( - build_initial_image_assets, - parse_image_placeholders, - parse_narrative_json, - parse_narrative_to_sections, -) - - -class MemoirImageParserTest(unittest.TestCase): - def test_parse_image_placeholders_preserves_order_and_offsets(self): - content = ( - "那条路我一直记得。\n\n" - "{{{{IMAGE:南方小镇的青石板路}}}}\n\n" - "奶奶总坐在门口。\n\n" - "{{{{IMAGE:奶奶坐在院子里的藤椅上}}}}" - ) - - items = parse_image_placeholders(content, max_images=3) - - self.assertEqual([item["index"] for item in items], [0, 1]) - self.assertEqual(items[0]["description"], "南方小镇的青石板路") - self.assertEqual( - items[1]["placeholder"], "{{{{IMAGE:奶奶坐在院子里的藤椅上}}}}" - ) - self.assertLess(items[0]["start_offset"], items[1]["start_offset"]) - - def test_build_initial_image_assets_marks_every_item_pending(self): - placeholders = [ - { - "index": 0, - "description": "南方小镇的青石板路", - "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", - "start_offset": 10, - } - ] - - assets = build_initial_image_assets( - placeholders=placeholders, - provider="liblib", - style="watercolor", - size="1024x1024", - now_iso="2026-03-10T10:00:00Z", - ) - - self.assertEqual(assets[0]["status"], "pending") - self.assertEqual(assets[0]["provider"], "liblib") - self.assertEqual(assets[0]["url"], None) - - def test_parse_image_placeholders_accepts_double_brace_variant(self): - content = "开头。\n\n{{IMAGE:1938年初的上海弄堂口,冬日萧瑟}}\n\n结尾。" - - items = parse_image_placeholders(content, max_images=2) - - self.assertEqual(len(items), 1) - self.assertEqual( - items[0]["placeholder"], "{{IMAGE:1938年初的上海弄堂口,冬日萧瑟}}" - ) - self.assertEqual(items[0]["description"], "1938年初的上海弄堂口,冬日萧瑟") - - def test_parse_narrative_json_ignores_image_description(self): - raw = '{"paragraphs": [{"content": "那年春天。", "image_description": "南方小镇的青石板路"}, {"content": "奶奶坐在藤椅上。", "image_description": "奶奶的藤椅"}]}' - segments = parse_narrative_json(raw) - self.assertEqual(len(segments), 2) - self.assertEqual(segments[0]["content"], "那年春天。") - self.assertIsNone(segments[0]["placeholder_info"]) - self.assertEqual(segments[1]["content"], "奶奶坐在藤椅上。") - self.assertIsNone(segments[1]["placeholder_info"]) - - def test_parse_narrative_to_sections_json_then_plain_strips_placeholders(self): - json_raw = ( - '{"paragraphs": [{"content": "段落一", "image_description": "图一"}]}' - ) - segments = parse_narrative_to_sections(json_raw) - self.assertEqual(len(segments), 1) - self.assertEqual(segments[0]["content"], "段落一") - self.assertIsNone(segments[0]["placeholder_info"]) - - placeholder_raw = "正文。\n\n{{{{IMAGE:描述}}}}\n\n结尾。" - segments2 = parse_narrative_to_sections(placeholder_raw) - self.assertEqual(len(segments2), 2) - self.assertEqual(segments2[0]["content"], "正文。") - self.assertEqual(segments2[1]["content"], "结尾。") - self.assertIsNone(segments2[0]["placeholder_info"]) - self.assertIsNone(segments2[1]["placeholder_info"]) diff --git a/api/tests/test_memoir_image_prompting.py b/api/tests/test_memoir_image_prompting.py deleted file mode 100644 index e2774e1..0000000 --- a/api/tests/test_memoir_image_prompting.py +++ /dev/null @@ -1,141 +0,0 @@ -import unittest -from unittest.mock import Mock, patch - -from app.features.memoir.memoir_images.prompting import MemoirImagePromptService -from app.features.memoir.memoir_images.settings import MemoirImageSettings - - -class MemoirImagePromptingTest(unittest.TestCase): - def test_prompt_service_uses_english_fallback_without_llm(self): - settings = MemoirImageSettings( - enabled=True, - max_per_chapter=2, - provider="liblib", - default_style="watercolor", - default_size="1024x1024", - poll_interval_seconds=3, - max_attempts=20, - liblib_template_uuid="tpl-uuid", - ) - service = MemoirImagePromptService(llm=None, settings=settings) - - result = service.build_prompt( - chapter_title="童年的夏天", - chapter_category="childhood", - description="奶奶坐在院子里的藤椅上", - context_excerpt="梧桐树下很安静,夏天总有蝉鸣。", - ) - - self.assertEqual(result["style"], "watercolor") - self.assertEqual(result["size"], "1024x1024") - self.assertIn("childhood memory", result["prompt"]) - self.assertIn("watercolor", result["prompt"]) - self.assertNotIn("奶奶坐在院子里的藤椅上", result["prompt"]) - self.assertIn("childhood", result["prompt_context"]) - - def test_prompt_service_parses_structured_llm_response(self): - settings = MemoirImageSettings( - enabled=True, - max_per_chapter=2, - provider="liblib", - default_style="watercolor", - default_size="1024x1024", - poll_interval_seconds=3, - max_attempts=20, - liblib_template_uuid="tpl-uuid", - ) - llm = Mock() - bound_llm = Mock() - bound_llm.invoke.return_value.content = ( - '{"prompt":"A grandmother in a quiet courtyard, summer cicadas, soft watercolor",' - '"style":"watercolor","size":"1024x1024"}' - ) - llm.bind.return_value = bound_llm - service = MemoirImagePromptService(llm=llm, settings=settings) - - result = service.build_prompt( - chapter_title="童年的夏天", - chapter_category="childhood", - description="奶奶坐在院子里的藤椅上", - context_excerpt="梧桐树下很安静,夏天总有蝉鸣。", - ) - - self.assertEqual( - result["prompt"], - "A grandmother in a quiet courtyard, summer cicadas, soft watercolor", - ) - self.assertEqual(result["style"], "watercolor") - self.assertEqual(result["size"], "1024x1024") - - def test_prompt_service_parses_markdown_wrapped_json_response(self): - settings = MemoirImageSettings( - enabled=True, - max_per_chapter=2, - provider="liblib", - default_style="watercolor", - default_size="1024x1024", - poll_interval_seconds=3, - max_attempts=20, - liblib_template_uuid="tpl-uuid", - ) - llm = Mock() - bound_llm = Mock() - bound_llm.invoke.return_value.content = """```json -{ - "prompt": "A middle-aged teacher stands on the empty stage, realistic, cinematic lighting", - "style": "realistic", - "size": "1280x720" -} -```""" - llm.bind.return_value = bound_llm - service = MemoirImagePromptService(llm=llm, settings=settings) - - result = service.build_prompt( - chapter_title="二十出头 · 在小镇讲台上种下第一粒种子", - chapter_category="career_early", - description="空荡荡的教室讲台前,一个年轻老师站着", - context_excerpt="第一次站上讲台,心里紧张又兴奋。", - ) - - self.assertEqual( - result["prompt"], - "A middle-aged teacher stands on the empty stage, realistic, cinematic lighting", - ) - self.assertEqual(result["style"], "realistic") - self.assertEqual(result["size"], "1280x720") - - @patch("app.features.memoir.memoir_images.prompting.logger") - def test_prompt_service_logs_warning_and_falls_back_when_llm_response_is_invalid( - self, logger_mock - ): - settings = MemoirImageSettings( - enabled=True, - max_per_chapter=2, - provider="liblib", - default_style="watercolor", - default_size="1024x1024", - poll_interval_seconds=3, - max_attempts=20, - liblib_template_uuid="tpl-uuid", - ) - llm = Mock() - bound_llm = Mock() - bound_llm.invoke.return_value.content = "not-json" - llm.bind.return_value = bound_llm - service = MemoirImagePromptService(llm=llm, settings=settings) - - result = service.build_prompt( - chapter_title="童年的夏天", - chapter_category="childhood", - description="奶奶坐在院子里的藤椅上", - context_excerpt="梧桐树下很安静,夏天总有蝉鸣。", - ) - - self.assertIn("childhood memory", result["prompt"]) - self.assertNotIn("奶奶坐在院子里的藤椅上", result["prompt"]) - logger_mock.warning.assert_called_once_with( - "图片 prompt 生成回退到默认模板: chapter_category=%s, title=%s, error=%s", - "childhood", - "童年的夏天", - unittest.mock.ANY, - ) diff --git a/api/tests/test_memoir_image_provider.py b/api/tests/test_memoir_image_provider.py deleted file mode 100644 index 72664fb..0000000 --- a/api/tests/test_memoir_image_provider.py +++ /dev/null @@ -1,279 +0,0 @@ -import os -import unittest -from unittest.mock import Mock, patch - -from app.features.memoir.memoir_images.provider import LiblibImageProvider -from app.features.memoir.memoir_images.settings import DEFAULT_LIBLIB_TEMPLATE_UUID - - -def _make_provider(http_client=None): - return LiblibImageProvider( - http_client=http_client or Mock(), - access_key="test-ak", - secret_key="test-sk", - base_url="https://openapi.liblibai.cloud", - template_uuid="tpl-uuid", - ) - - -class LiblibSignatureTest(unittest.TestCase): - def test_sign_returns_auth_params(self): - provider = _make_provider() - params = provider._sign("/api/generate/webui/text2img/ultra") - self.assertEqual(params["AccessKey"], "test-ak") - self.assertIn("Signature", params) - self.assertIn("Timestamp", params) - self.assertIn("SignatureNonce", params) - - -class SubmitGenerationTest(unittest.TestCase): - def test_submit_keeps_auth_params_out_of_url_string(self): - http_client = Mock() - resp = Mock() - resp.json.return_value = { - "code": 0, - "data": {"generateUuid": "uuid-abc"}, - } - resp.raise_for_status = Mock() - http_client.post.return_value = resp - - provider = _make_provider(http_client) - provider.submit_generation(prompt="a cat", size="1024x1024", style="watercolor") - - call_kwargs = http_client.post.call_args - url = call_kwargs.args[0] if call_kwargs.args else call_kwargs.kwargs["url"] - params = call_kwargs.kwargs.get("params") - - self.assertEqual( - url, "https://openapi.liblibai.cloud/api/generate/webui/text2img/ultra" - ) - self.assertNotIn("AccessKey=", url) - self.assertEqual(params["AccessKey"], "test-ak") - self.assertIn("Signature", params) - self.assertIn("Timestamp", params) - self.assertIn("SignatureNonce", params) - - def test_submit_returns_processing_with_job_id(self): - http_client = Mock() - resp = Mock() - resp.json.return_value = { - "code": 0, - "data": {"generateUuid": "uuid-abc"}, - } - resp.raise_for_status = Mock() - http_client.post.return_value = resp - - provider = _make_provider(http_client) - job = provider.submit_generation( - prompt="a cat", size="1024x1024", style="watercolor" - ) - - self.assertEqual(job["status"], "processing") - self.assertEqual(job["job_id"], "uuid-abc") - self.assertIsNone(job["image_url"]) - - call_kwargs = http_client.post.call_args - body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") - self.assertEqual(body["templateUuid"], "tpl-uuid") - self.assertIn("a cat", body["generateParams"]["prompt"]) - self.assertIn("watercolor", body["generateParams"]["prompt"].lower()) - self.assertEqual(body["generateParams"]["aspectRatio"], "square") - - def test_submit_applies_style_when_prompt_does_not_include_it(self): - http_client = Mock() - resp = Mock() - resp.json.return_value = { - "code": 0, - "data": {"generateUuid": "uuid-abc"}, - } - resp.raise_for_status = Mock() - http_client.post.return_value = resp - - provider = _make_provider(http_client) - provider.submit_generation( - prompt="a cat under the rain", size="1024x1024", style="watercolor" - ) - - call_kwargs = http_client.post.call_args - body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") - - self.assertIn("a cat under the rain", body["generateParams"]["prompt"]) - self.assertIn("watercolor", body["generateParams"]["prompt"].lower()) - - def test_submit_raises_on_error_code(self): - http_client = Mock() - resp = Mock() - resp.json.return_value = {"code": 100000, "msg": "param error"} - resp.raise_for_status = Mock() - http_client.post.return_value = resp - - provider = _make_provider(http_client) - with self.assertRaises(RuntimeError): - provider.submit_generation( - prompt="a cat", size="1024x1024", style="watercolor" - ) - - -class PollUntilCompleteTest(unittest.TestCase): - def test_returns_completed_on_status_5(self): - http_client = Mock() - pending_resp = Mock() - pending_resp.json.return_value = { - "code": 0, - "data": {"generateStatus": 2, "images": []}, - } - pending_resp.raise_for_status = Mock() - - success_resp = Mock() - success_resp.json.return_value = { - "code": 0, - "data": { - "generateStatus": 5, - "images": [ - {"imageUrl": "https://cdn.example.com/1.png", "auditStatus": 3} - ], - }, - } - success_resp.raise_for_status = Mock() - http_client.post.side_effect = [pending_resp, success_resp] - - provider = _make_provider(http_client) - job = provider.poll_until_complete( - {"status": "processing", "job_id": "uuid-abc"}, - poll_interval_seconds=0, - max_attempts=3, - ) - - self.assertEqual(job["status"], "completed") - self.assertEqual(job["image_url"], "https://cdn.example.com/1.png") - self.assertEqual(job["job_id"], "uuid-abc") - - def test_raises_on_status_6_failure(self): - http_client = Mock() - resp = Mock() - resp.json.return_value = { - "code": 0, - "data": {"generateStatus": 6, "generateMsg": "content violation"}, - } - resp.raise_for_status = Mock() - http_client.post.return_value = resp - - provider = _make_provider(http_client) - with self.assertRaises(RuntimeError, msg="content violation"): - provider.poll_until_complete( - {"status": "processing", "job_id": "uuid-abc"}, - poll_interval_seconds=0, - max_attempts=2, - ) - - def test_raises_timeout_after_max_attempts(self): - http_client = Mock() - resp = Mock() - resp.json.return_value = { - "code": 0, - "data": {"generateStatus": 2, "images": []}, - } - resp.raise_for_status = Mock() - http_client.post.return_value = resp - - provider = _make_provider(http_client) - with self.assertRaises(TimeoutError): - provider.poll_until_complete( - {"status": "processing", "job_id": "uuid-abc"}, - poll_interval_seconds=0, - max_attempts=2, - ) - - -class DownloadImageTest(unittest.TestCase): - def test_download_allows_liblib_tmp_image_host_by_default(self): - http_client = Mock() - resp = Mock() - resp.content = b"png-bytes" - resp.raise_for_status = Mock() - http_client.get.return_value = resp - - provider = _make_provider(http_client) - payload = provider.download_image( - {"image_url": "https://liblibai-tmp-image.liblib.cloud/img/demo.png"} - ) - - self.assertEqual(payload, b"png-bytes") - http_client.get.assert_called_once_with( - "https://liblibai-tmp-image.liblib.cloud/img/demo.png" - ) - - def test_download_fetches_binary_payload(self): - http_client = Mock() - resp = Mock() - resp.content = b"png-bytes" - resp.raise_for_status = Mock() - http_client.get.return_value = resp - - provider = LiblibImageProvider( - http_client=http_client, - access_key="test-ak", - secret_key="test-sk", - base_url="https://openapi.liblibai.cloud", - template_uuid="tpl-uuid", - allowed_download_hosts=("cdn.example.com",), - ) - payload = provider.download_image( - {"image_url": "https://cdn.example.com/1.png"} - ) - - self.assertEqual(payload, b"png-bytes") - - def test_download_rejects_unapproved_host(self): - http_client = Mock() - provider = _make_provider(http_client) - - with self.assertRaises(ValueError): - provider.download_image({"image_url": "https://evil.example.com/1.png"}) - - http_client.get.assert_not_called() - - -class ProviderResourceManagementTest(unittest.TestCase): - @patch("app.adapters.image_gen.liblib_provider.httpx.Client") - def test_provider_closes_owned_http_client(self, httpx_client_cls): - http_client = Mock() - httpx_client_cls.return_value = http_client - - provider = LiblibImageProvider( - access_key="test-ak", - secret_key="test-sk", - base_url="https://openapi.liblibai.cloud", - template_uuid="tpl-uuid", - ) - - provider.close() - - http_client.close.assert_called_once() - - def test_provider_does_not_close_injected_http_client(self): - http_client = Mock() - provider = LiblibImageProvider( - http_client=http_client, - access_key="test-ak", - secret_key="test-sk", - base_url="https://openapi.liblibai.cloud", - template_uuid="tpl-uuid", - ) - - provider.close() - - http_client.close.assert_not_called() - - -class ProviderDefaultsTest(unittest.TestCase): - @patch.dict(os.environ, {"LIBLIB_TEMPLATE_UUID": ""}, clear=False) - def test_provider_uses_shared_template_uuid_default(self): - provider = LiblibImageProvider( - http_client=Mock(), - access_key="test-ak", - secret_key="test-sk", - base_url="https://openapi.liblibai.cloud", - ) - - self.assertEqual(provider.template_uuid, DEFAULT_LIBLIB_TEMPLATE_UUID) diff --git a/api/tests/test_memoir_image_schema.py b/api/tests/test_memoir_image_schema.py deleted file mode 100644 index 69e4f9b..0000000 --- a/api/tests/test_memoir_image_schema.py +++ /dev/null @@ -1,87 +0,0 @@ -import unittest - -from app.features.memoir.memoir_images.schema import ( - IMAGE_STATUS_FAILED, - IMAGE_STATUS_PENDING, - normalize_image_asset, -) - - -class MemoirImageSchemaTest(unittest.TestCase): - def test_normalize_image_asset_coerces_invalid_status_to_failed(self): - asset = normalize_image_asset( - { - "index": 0, - "placeholder": "{{IMAGE:南方小镇的青石板路}}", - "description": "南方小镇的青石板路", - "status": "mystery", - "url": "https://cos.example.com/0.png", - } - ) - - self.assertEqual(asset["status"], IMAGE_STATUS_FAILED) - self.assertEqual(asset["error"], "invalid image status: mystery") - - def test_normalize_image_asset_requires_placeholder_and_description(self): - asset = normalize_image_asset( - { - "index": 0, - "status": IMAGE_STATUS_PENDING, - } - ) - - self.assertIsNone(asset) - - def test_normalize_image_asset_completed_without_inline_placeholder(self): - asset = normalize_image_asset( - { - "index": 0, - "placeholder": "", - "description": "章节封面", - "status": "completed", - "url": "https://cos.example.com/cover.png", - } - ) - self.assertIsNotNone(asset) - self.assertEqual(asset["placeholder"], "") - self.assertEqual(asset["description"], "章节封面") - - def test_normalize_image_asset_completed_defaults_description(self): - asset = normalize_image_asset( - { - "status": "completed", - "url": "https://cos.example.com/x.png", - } - ) - self.assertIsNotNone(asset) - self.assertEqual(asset["placeholder"], "") - self.assertEqual(asset["description"], "插图") - - def test_normalize_image_asset_preserves_retryable_for_failed_assets(self): - asset = normalize_image_asset( - { - "index": 0, - "placeholder": "{{IMAGE:南方小镇的青石板路}}", - "description": "南方小镇的青石板路", - "status": IMAGE_STATUS_FAILED, - "error": "upload denied", - "retryable": False, - } - ) - - self.assertEqual(asset["status"], IMAGE_STATUS_FAILED) - self.assertFalse(asset["retryable"]) - - def test_normalize_image_asset_clears_retryable_for_non_failed_assets(self): - asset = normalize_image_asset( - { - "index": 0, - "placeholder": "{{IMAGE:南方小镇的青石板路}}", - "description": "南方小镇的青石板路", - "status": IMAGE_STATUS_PENDING, - "retryable": True, - } - ) - - self.assertEqual(asset["status"], IMAGE_STATUS_PENDING) - self.assertIsNone(asset["retryable"]) diff --git a/api/tests/test_memoir_image_settings.py b/api/tests/test_memoir_image_settings.py deleted file mode 100644 index abc62f5..0000000 --- a/api/tests/test_memoir_image_settings.py +++ /dev/null @@ -1,44 +0,0 @@ -import os -import unittest -from unittest.mock import patch - -from app.features.memoir.memoir_images.settings import ( - DEFAULT_LIBLIB_TEMPLATE_UUID, - MemoirImageSettings, -) - - -class MemoirImageSettingsTest(unittest.TestCase): - @patch.dict( - os.environ, - { - "MEMOIR_IMAGE_MAX_PER_CHAPTER": "not-an-int", - "MEMOIR_IMAGE_CHARS_PER_EXTRA": "bad-extra", - "MEMOIR_IMAGE_MAX_CAP": "bad-cap", - "MEMOIR_IMAGE_POLL_INTERVAL": "bad", - "MEMOIR_IMAGE_MAX_ATTEMPTS": "oops", - }, - clear=False, - ) - def test_from_env_falls_back_to_defaults_for_invalid_integers(self): - settings = MemoirImageSettings.from_env() - - self.assertEqual(settings.max_per_chapter, 2) - self.assertEqual(settings.chars_per_extra_image, 1500) - self.assertEqual(settings.max_images_cap, 8) - self.assertEqual(settings.poll_interval_seconds, 3) - self.assertEqual(settings.max_attempts, 60) - - @patch.dict(os.environ, {}, clear=False) - def test_from_env_uses_shared_template_uuid_default(self): - with patch.dict(os.environ, {"LIBLIB_TEMPLATE_UUID": ""}, clear=False): - settings = MemoirImageSettings.from_env() - - self.assertEqual(settings.liblib_template_uuid, DEFAULT_LIBLIB_TEMPLATE_UUID) - - def test_effective_max_images_never_drops_below_base_max_per_chapter(self): - settings = MemoirImageSettings( - enabled=True, max_per_chapter=2, max_images_cap=1 - ) - - self.assertEqual(settings.effective_max_images(0), 2) diff --git a/api/tests/test_memoir_image_storage.py b/api/tests/test_memoir_image_storage.py deleted file mode 100644 index 2e27288..0000000 --- a/api/tests/test_memoir_image_storage.py +++ /dev/null @@ -1,239 +0,0 @@ -import os -import unittest -from unittest.mock import Mock, patch - -from qcloud_cos.cos_exception import CosClientError, CosServiceError - -from app.features.memoir.memoir_images.storage import ( - CosDownloadUrlError, - CosUploadError, - TencentCosStorageService, - _is_retryable_cos_error, - normalize_cos_url, - resolve_image_storage_key, -) - - -class MemoirImageStorageTest(unittest.TestCase): - @patch.dict( - os.environ, - { - "TENCENT_COS_SECRET_ID": "id", - "TENCENT_COS_SECRET_KEY": "key", - "TENCENT_COS_REGION": "ap-shanghai", - "TENCENT_COS_BUCKET": "memoir-1250000000", - "TENCENT_COS_BASE_URL": "https://memoir-1250000000.cos.ap-shanghai.myqcloud.com", - }, - clear=False, - ) - @patch("app.features.memoir.memoir_images.storage.CosS3Client") - def test_from_env_reuses_singleton_for_same_config(self, client_cls): - TencentCosStorageService._instance = None - TencentCosStorageService._instance_config = None - client_cls.return_value = Mock() - - first = TencentCosStorageService.from_env() - second = TencentCosStorageService.from_env() - - self.assertIs(first, second) - client_cls.assert_called_once() - - @patch("app.features.memoir.memoir_images.storage.CosS3Client") - def test_upload_bytes_returns_persistent_cos_url(self, client_cls): - client = Mock() - client.put_object.return_value = { - "ETag": '"abc123"', - "x-cos-request-id": "req-001", - } - client_cls.return_value = client - storage = TencentCosStorageService( - secret_id="id", - secret_key="key", - region="ap-shanghai", - bucket="memoir-1250000000", - base_url="https://memoir-1250000000.cos.ap-shanghai.myqcloud.com", - ) - - url = storage.upload_bytes( - image_bytes=b"png-bytes", - key="memoirs/u1/c1/0-demo.png", - content_type="image/png", - ) - - self.assertEqual( - url, - "https://memoir-1250000000.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0-demo.png", - ) - client.put_object.assert_called_once() - - @patch("app.features.memoir.memoir_images.storage.CosS3Client") - def test_upload_bytes_normalizes_duplicate_appid_suffix_in_base_url( - self, client_cls - ): - client = Mock() - client_cls.return_value = client - storage = TencentCosStorageService( - secret_id="id", - secret_key="key", - region="ap-shanghai", - bucket="life-echo-dev-1319381411", - base_url="https://life-echo-dev-1319381411-appid.cos.ap-shanghai.myqcloud.com", - ) - - url = storage.upload_bytes( - image_bytes=b"png-bytes", - key="memoirs/u1/c1/0-demo.png", - content_type="image/png", - ) - - self.assertEqual( - url, - "https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0-demo.png", - ) - client.put_object.assert_called_once() - - def test_normalize_cos_url_repairs_existing_duplicate_appid_host(self): - normalized = normalize_cos_url( - "https://life-echo-dev-1319381411-appid.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0-demo.png", - bucket="life-echo-dev-1319381411", - region="ap-shanghai", - ) - - self.assertEqual( - normalized, - "https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0-demo.png", - ) - - @patch("app.features.memoir.memoir_images.storage.CosS3Client") - def test_get_download_url_returns_presigned_download_url(self, client_cls): - client = Mock() - client.get_presigned_download_url.return_value = ( - "https://cos.example.com/0.png?q-sign-algorithm=sha1" - ) - client_cls.return_value = client - storage = TencentCosStorageService( - secret_id="id", - secret_key="key", - region="ap-shanghai", - bucket="life-echo-dev-1319381411", - base_url="https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com", - ) - - url = storage.get_download_url("memoirs/u1/c1/0-demo.png", expires=1800) - - self.assertEqual(url, "https://cos.example.com/0.png?q-sign-algorithm=sha1") - client.get_presigned_download_url.assert_called_once_with( - Bucket="life-echo-dev-1319381411", - Key="memoirs/u1/c1/0-demo.png", - Expired=1800, - ) - - def test_resolve_image_storage_key_prefers_explicit_storage_key(self): - key = resolve_image_storage_key( - { - "storage_key": "memoirs/u1/c1/0-demo.png", - "url": "https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/other.png", - } - ) - - self.assertEqual(key, "memoirs/u1/c1/0-demo.png") - - def test_resolve_image_storage_key_derives_key_from_existing_url(self): - key = resolve_image_storage_key( - { - "url": "https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0-demo.png?q-sign-algorithm=sha1" - } - ) - - self.assertEqual(key, "memoirs/u1/c1/0-demo.png") - - @patch("app.features.memoir.memoir_images.storage.CosS3Client") - def test_upload_bytes_raises_cos_upload_error_on_client_error(self, client_cls): - client = Mock() - client.put_object.side_effect = CosClientError("network timeout") - client_cls.return_value = client - storage = TencentCosStorageService( - secret_id="id", - secret_key="key", - region="ap-shanghai", - bucket="memoir-1250000000", - base_url="https://memoir-1250000000.cos.ap-shanghai.myqcloud.com", - ) - - with self.assertRaises(CosUploadError) as ctx: - storage.upload_bytes(b"data", "key.png", "image/png") - - self.assertTrue(ctx.exception.retryable) - self.assertIsInstance(ctx.exception.__cause__, CosClientError) - - @patch("app.features.memoir.memoir_images.storage.CosS3Client") - def test_upload_bytes_raises_non_retryable_on_403(self, client_cls): - client = Mock() - svc_error = CosServiceError("PUT", "AccessDenied", 403) - client.put_object.side_effect = svc_error - client_cls.return_value = client - storage = TencentCosStorageService( - secret_id="id", - secret_key="key", - region="ap-shanghai", - bucket="memoir-1250000000", - base_url="https://memoir-1250000000.cos.ap-shanghai.myqcloud.com", - ) - - with self.assertRaises(CosUploadError) as ctx: - storage.upload_bytes(b"data", "key.png", "image/png") - - self.assertFalse(ctx.exception.retryable) - - @patch("app.features.memoir.memoir_images.storage.CosS3Client") - def test_get_download_url_raises_cos_download_url_error(self, client_cls): - client = Mock() - client.get_presigned_download_url.side_effect = CosClientError("dns failure") - client_cls.return_value = client - storage = TencentCosStorageService( - secret_id="id", - secret_key="key", - region="ap-shanghai", - bucket="memoir-1250000000", - base_url="https://memoir-1250000000.cos.ap-shanghai.myqcloud.com", - ) - - with self.assertRaises(CosDownloadUrlError) as ctx: - storage.get_download_url("key.png") - - self.assertTrue(ctx.exception.retryable) - - def test_is_retryable_returns_true_for_client_error(self): - self.assertTrue(_is_retryable_cos_error(CosClientError("timeout"))) - - def test_is_retryable_returns_false_for_4xx_service_error(self): - self.assertFalse( - _is_retryable_cos_error(CosServiceError("GET", "Forbidden", 403)) - ) - - def test_is_retryable_returns_true_for_5xx_service_error(self): - self.assertTrue( - _is_retryable_cos_error(CosServiceError("GET", "Internal", 500)) - ) - - @patch("app.features.memoir.memoir_images.storage.CosS3Client") - def test_cos_config_includes_scheme_and_token(self, client_cls): - client_cls.return_value = Mock() - - with patch("app.features.memoir.memoir_images.storage.CosConfig") as config_cls: - TencentCosStorageService( - secret_id="id", - secret_key="key", - region="ap-shanghai", - bucket="memoir-1250000000", - base_url="https://memoir-1250000000.cos.ap-shanghai.myqcloud.com", - token="tmp-token", - ) - - config_cls.assert_called_once_with( - Region="ap-shanghai", - SecretId="id", - SecretKey="key", - Token="tmp-token", - Scheme="https", - ) diff --git a/api/tests/test_memoir_tasks_redis.py b/api/tests/test_memoir_tasks_redis.py deleted file mode 100644 index dcd29de..0000000 --- a/api/tests/test_memoir_tasks_redis.py +++ /dev/null @@ -1,39 +0,0 @@ -import unittest -from unittest.mock import Mock, patch - -from app.tasks import memoir_tasks -from app.tasks.memoir_tasks import ( - _acquire_chapter_lock, - _release_chapter_lock, - _update_task_status_sync, -) - - -class MemoirTasksRedisReuseTest(unittest.TestCase): - def setUp(self): - memoir_tasks._REDIS_CLIENTS.clear() - - @patch("app.tasks.memoir_tasks.redis.from_url") - def test_chapter_lock_helpers_reuse_same_redis_client(self, from_url_mock): - client = Mock() - client.set.return_value = True - from_url_mock.return_value = client - - self.assertTrue(_acquire_chapter_lock("user-1", "childhood")) - _release_chapter_lock("user-1", "childhood") - - self.assertEqual(from_url_mock.call_count, 1) - client.set.assert_called_once() - client.delete.assert_called_once() - - @patch("app.tasks.memoir_tasks.redis.from_url") - def test_task_status_updates_reuse_decode_response_client(self, from_url_mock): - client = Mock() - client.hget.return_value = None - from_url_mock.return_value = client - - _update_task_status_sync("user-1", "task-1", "running") - _update_task_status_sync("user-1", "task-1", "success", {"processed": 1}) - - self.assertEqual(from_url_mock.call_count, 1) - client.hset.assert_called() diff --git a/api/tests/test_memory_prompts_inject.py b/api/tests/test_memory_prompts_inject.py deleted file mode 100644 index 2f9b439..0000000 --- a/api/tests/test_memory_prompts_inject.py +++ /dev/null @@ -1,52 +0,0 @@ -"""测试 memory_prompts.inject_image_placeholder_template:占位符花括号统一为四层,避免多余花括号残留""" - -import unittest - -from app.agents.memoir.prompts import ( - IMAGE_PLACEHOLDER_TEMPLATE, - inject_image_placeholder_template, -) - - -class InjectImagePlaceholderTemplateTest(unittest.TestCase): - def test_normalizes_double_brace_to_four(self): - content = "段落。\n\n{{IMAGE:南方小镇的青石板路}}\n\n结尾。" - out = inject_image_placeholder_template(content) - self.assertIn("{{{{IMAGE:", out) - self.assertIn("}}}}", out) - # 应为四层占位符,且「结尾。」前不应有多余的 }} - self.assertIn("\n\n结尾。", out) - self.assertEqual(out.count("}}}}"), 1) - - def test_normalizes_quad_brace_unchanged(self): - content = "段落。\n\n{{{{IMAGE:南方小镇的青石板路}}}}\n\n结尾。" - out = inject_image_placeholder_template(content) - self.assertEqual(out.count("{{{{"), out.count("}}}}")) - self.assertIn("{{{{IMAGE:", out) - self.assertNotRegex(out, r"\}\}\}\}\}\}") # 不应出现五层及以上闭合括号 - - def test_normalizes_six_braces_to_four_so_no_residue(self): - # LLM 有时会多打花括号,导致客户端按四层占位符 split 后残留 "{{" "}}" 被显示 - content = "段落。\n\n{{{{{{IMAGE:南方小镇的青石板路}}}}}}\n\n结尾。" - out = inject_image_placeholder_template(content) - # 整段应被替换为四层,不应留下多余的 "{{" 或 "}}" - self.assertIn("{{{{IMAGE:" + IMAGE_PLACEHOLDER_TEMPLATE, out) - self.assertIn("}}}}", out) - self.assertNotIn("{{{{{{", out) - self.assertNotIn("}}}}}}", out) - # 正文前后不应出现裸花括号 - parts = out.split("}}}}") - for i, p in enumerate(parts): - if "南方小镇" in p or i == 0: - continue - self.assertNotRegex(p, r"^\s*\{\{", msg=f"残留开括号 in part: {p!r}") - before, sep, after = out.partition("{{{{IMAGE:") - self.assertNotRegex(before, r"\{\{\s*$", msg="开头段不应以 {{ 结尾") - self.assertNotRegex(after, r"^\s*\}\}", msg="占位符后不应以 }} 开头") - - def test_normalizes_eight_braces_to_four(self): - content = "前\n\n{{{{{{{{IMAGE:奶奶的藤椅}}}}}}}}\n\n后" - out = inject_image_placeholder_template(content) - self.assertIn("{{{{IMAGE:", out) - self.assertNotIn("{{{{{{{{", out) - self.assertNotIn("}}}}}}}}", out) diff --git a/api/tests/test_pdf_service_images.py b/api/tests/test_pdf_service_images.py deleted file mode 100644 index 47e840b..0000000 --- a/api/tests/test_pdf_service_images.py +++ /dev/null @@ -1,211 +0,0 @@ -from io import BytesIO -import unittest -from unittest.mock import AsyncMock, patch, MagicMock - -from PIL import Image - -from app.features.memoir.pdf_service import PDFService -import app.features.memoir.pdf_service as pdf_service_module -from app.features.memoir.memoir_images.storage import CosDownloadUrlError - - -class PDFServiceImagesTest(unittest.IsolatedAsyncioTestCase): - @patch("app.features.memoir.pdf_service.ReportLabImage") - @patch("app.features.memoir.pdf_service.httpx.AsyncClient") - @patch("app.features.memoir.pdf_service.TencentCosStorageService") - async def test_generate_pdf_preserves_image_aspect_ratio( - self, - storage_cls, - async_client_cls, - reportlab_image_cls, - ): - image_buffer = BytesIO() - Image.new("RGB", (2, 1), color="white").save(image_buffer, format="PNG") - png_bytes = image_buffer.getvalue() - mock_response = MagicMock() - mock_response.content = png_bytes - mock_response.raise_for_status = MagicMock() - - mock_client = AsyncMock() - mock_client.get.return_value = mock_response - async_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) - async_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) - storage = MagicMock() - storage.get_download_url.return_value = ( - "https://signed.example.com/0.png?sig=123" - ) - storage_cls.from_env.return_value = storage - reportlab_image_cls.return_value = MagicMock() - - service = PDFService() - book = type("BookStub", (), {"title": "我的回忆录"})() - chapter = type( - "ChapterStub", - (), - { - "title": "童年的夏天", - "content": "{{{{IMAGE:南方小镇的青石板路}}}}", - "images": [ - { - "index": 0, - "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", - "url": "https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0.png", - "storage_key": "memoirs/u1/c1/0.png", - "status": "completed", - } - ], - }, - )() - - await service.generate_pdf(book, [chapter]) - - _, kwargs = reportlab_image_cls.call_args - self.assertAlmostEqual(kwargs["width"], 5 * 72) - self.assertAlmostEqual(kwargs["height"], 2.5 * 72) - - @patch("app.features.memoir.pdf_service.httpx.AsyncClient") - @patch("app.features.memoir.pdf_service.TencentCosStorageService") - async def test_generate_pdf_embeds_completed_images_and_removes_placeholders( - self, - storage_cls, - async_client_cls, - ): - png_bytes = ( - b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01" - b"\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc```\x00\x00" - b"\x00\x04\x00\x01\xf6\x178U\x00\x00\x00\x00IEND\xaeB`\x82" - ) - mock_response = MagicMock() - mock_response.content = png_bytes - mock_response.raise_for_status = MagicMock() - - mock_client = AsyncMock() - mock_client.get.return_value = mock_response - async_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) - async_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) - storage = MagicMock() - storage.get_download_url.return_value = ( - "https://signed.example.com/0.png?sig=123" - ) - storage_cls.from_env.return_value = storage - - service = PDFService() - book = type("BookStub", (), {"title": "我的回忆录"})() - chapter = type( - "ChapterStub", - (), - { - "title": "童年的夏天", - "content": "那条路我一直记得。\n\n{{{{IMAGE:南方小镇的青石板路}}}}\n\n奶奶常坐在那里。", - "images": [ - { - "index": 0, - "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", - "url": "https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0.png", - "storage_key": "memoirs/u1/c1/0.png", - "status": "completed", - } - ], - }, - )() - - pdf_bytes = await service.generate_pdf(book, [chapter]) - - self.assertGreater(len(pdf_bytes), 100) - self.assertNotIn(b"IMAGE:", pdf_bytes) - mock_client.get.assert_called_once_with( - "https://signed.example.com/0.png?sig=123" - ) - - @patch("app.features.memoir.pdf_service.httpx.AsyncClient") - @patch("app.features.memoir.pdf_service.TencentCosStorageService") - async def test_generate_pdf_skips_private_cos_url_when_signing_fails( - self, - storage_cls, - async_client_cls, - ): - mock_client = AsyncMock() - async_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) - async_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) - storage = MagicMock() - storage.get_download_url.side_effect = CosDownloadUrlError( - "cos unavailable", retryable=True, request_id="req-err" - ) - storage_cls.from_env.return_value = storage - - service = PDFService() - book = type("BookStub", (), {"title": "我的回忆录"})() - chapter = type( - "ChapterStub", - (), - { - "title": "童年的夏天", - "content": "那条路我一直记得。\n\n{{{{IMAGE:南方小镇的青石板路}}}}\n\n奶奶常坐在那里。", - "images": [ - { - "index": 0, - "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", - "url": "https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0.png", - "storage_key": "memoirs/u1/c1/0.png", - "status": "completed", - } - ], - }, - )() - - pdf_bytes = await service.generate_pdf(book, [chapter]) - - self.assertGreater(len(pdf_bytes), 100) - mock_client.get.assert_not_called() - - @patch("app.features.memoir.pdf_service.httpx.AsyncClient") - @patch("app.features.memoir.pdf_service.TencentCosStorageService") - async def test_generate_pdf_uses_canonical_markdown_when_present( - self, - storage_cls, - async_client_cls, - ): - """PDF 正文真源为 canonical_markdown,与 API / 前端一致。""" - png_bytes = ( - b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01" - b"\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc```\x00\x00" - b"\x00\x04\x00\x01\xf6\x178U\x00\x00\x00\x00IEND\xaeB`\x82" - ) - mock_response = MagicMock() - mock_response.content = png_bytes - mock_response.raise_for_status = MagicMock() - mock_client = AsyncMock() - mock_client.get.return_value = mock_response - async_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) - async_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) - storage = MagicMock() - storage.get_download_url.return_value = "https://signed.example.com/img.png" - storage_cls.from_env.return_value = storage - - service = PDFService() - book = type("BookStub", (), {"title": "我的回忆录"})() - chapter = type( - "ChapterStub", - (), - { - "title": "童年", - "canonical_markdown": "开头。\n\n{{{{IMAGE:南方小镇}}}}\n\n结尾。", - "sections": [], - "content": "", - "images": [ - { - "index": 0, - "placeholder": "{{{{IMAGE:南方小镇}}}}", - "url": None, - "storage_key": "memoirs/u1/c1/0.png", - "status": "completed", - } - ], - }, - )() - - pdf_bytes = await service.generate_pdf(book, [chapter]) - - self.assertGreater(len(pdf_bytes), 100) - self.assertNotIn(b"IMAGE:", pdf_bytes) - mock_client.get.assert_called_once() diff --git a/api/tests/test_process_memoir_segments_image_enqueue.py b/api/tests/test_process_memoir_segments_image_enqueue.py deleted file mode 100644 index bd056bf..0000000 --- a/api/tests/test_process_memoir_segments_image_enqueue.py +++ /dev/null @@ -1,239 +0,0 @@ -import unittest -from contextlib import contextmanager -from types import SimpleNamespace -from unittest.mock import Mock, patch - -from app.tasks.memoir_tasks import MemoirImageSettings, process_memoir_segments - - -def _mock_get_sync_db(db): - @contextmanager - def _cm(): - yield db - - return _cm() # 返回 context manager 实例,供 with 使用 - - -def _fake_chapter_for_pipeline(): - return SimpleNamespace( - id="chapter-1", - canonical_markdown="# 标题\n\n正文若干字。", - cover_asset_id=None, - images=[], - ) - - -class ProcessMemoirSegmentsImageEnqueueTest(unittest.TestCase): - @patch("app.tasks.chapter_compose_tasks.recompose_chapters_for_story.delay") - @patch("app.tasks.story_image_tasks.generate_story_image.delay") - @patch("app.tasks.memoir_tasks.run_story_pipeline_for_category_batch") - @patch( - "app.features.memory.repo.retrieve_evidence_sync", - return_value={ - "relevant_chunks": [], - "relevant_summaries": [], - "relevant_facts": [], - "timeline_hints": [], - "relevant_stories": [], - }, - ) - @patch("app.features.memory.service.ingest_transcript_sync") - @patch("app.tasks.memoir_tasks._update_task_status_sync") - @patch("app.tasks.memoir_tasks._release_chapter_lock") - @patch("app.tasks.memoir_tasks._acquire_chapter_lock", return_value=True) - @patch("app.tasks.memoir_tasks._update_slot_sync") - @patch( - "app.agents.memoir.orchestrator.ClassificationAgent.classify", - return_value="childhood", - ) - @patch("app.tasks.memoir_tasks._get_or_create_state_sync") - @patch("app.tasks.memoir_tasks._get_llm") - @patch("app.tasks.chapter_cover_enqueue.try_enqueue_generate_chapter_cover") - @patch("app.tasks.memoir_tasks.get_sync_db") - @patch("app.tasks.memoir_tasks.MemoirImageSettings.from_env") - def test_process_memoir_segments_parses_markdown_wrapped_state_extraction_json( - self, - settings_from_env, - get_sync_db_mock, - try_enqueue_mock, - get_llm_mock, - get_state_mock, - _classify_mock, - update_slot_mock, - _acquire_lock_mock, - _release_lock_mock, - _update_status_mock, - ingest_mock, - retrieve_mock, - mock_pipeline, - _delay_story_image, - _delay_recompose, - ): - settings_from_env.return_value = MemoirImageSettings( - enabled=True, - max_per_chapter=2, - provider="liblib", - default_style="watercolor", - default_size="1024x1024", - poll_interval_seconds=3, - max_attempts=20, - liblib_template_uuid="tpl-uuid", - ) - get_state_mock.return_value = SimpleNamespace( - current_stage="childhood", slots={} - ) - update_slot_mock.return_value = SimpleNamespace( - current_stage="childhood", slots={} - ) - mock_pipeline.return_value = (_fake_chapter_for_pipeline(), True, {"story-1"}) - llm = Mock() - bound_llm = Mock() - bound_llm.invoke.side_effect = [ - SimpleNamespace( - content="""```json -{ - "detected_stage": "childhood", - "slots": { - "family_memory": "外婆总在门口等我" - } -} -```""" - ), - ] - llm.bind.return_value = bound_llm - llm.invoke.side_effect = [ - SimpleNamespace(content="childhood"), - ] - get_llm_mock.return_value = llm - - segment = SimpleNamespace( - id="segment-1", - transcript_text="那条路我一直记得。\n\n{{{{IMAGE:南方小镇的青石板路}}}}", - processed=False, - ) - - segments_result = Mock() - segments_result.scalars.return_value.all.return_value = [segment] - - book_result = Mock() - book_result.scalar_one_or_none.return_value = None - - db = Mock() - db.execute.side_effect = [ - segments_result, - book_result, - ] - db.get.return_value = None - get_sync_db_mock.return_value = _mock_get_sync_db(db) - - events: list[str] = [] - db.commit.side_effect = lambda: events.append("commit") - try_enqueue_mock.side_effect = lambda chapter_id, source="pipeline": ( - events.append(f"enqueue:{chapter_id}") - ) - - task_self = SimpleNamespace( - request=SimpleNamespace(id="task-1"), - retry=Mock(side_effect=AssertionError("retry should not be called")), - ) - process_memoir_segments.run.__func__(task_self, "user-1", ["segment-1"]) - - update_slot_mock.assert_called_once_with( - "user-1", - "childhood", - "family_memory", - "外婆总在门口等我", - ["segment-1"], - db, - ) - mock_pipeline.assert_called_once() - self.assertIn("commit", events) - enqueue_events = [event for event in events if event.startswith("enqueue:")] - self.assertEqual(len(enqueue_events), 1) - self.assertGreater(events.index(enqueue_events[0]), events.index("commit")) - - @patch("app.tasks.chapter_compose_tasks.recompose_chapters_for_story.delay") - @patch("app.tasks.story_image_tasks.generate_story_image.delay") - @patch("app.tasks.memoir_tasks.run_story_pipeline_for_category_batch") - @patch( - "app.features.memory.repo.retrieve_evidence_sync", - return_value={ - "relevant_chunks": [], - "relevant_summaries": [], - "relevant_facts": [], - "timeline_hints": [], - "relevant_stories": [], - }, - ) - @patch("app.features.memory.service.ingest_transcript_sync") - @patch("app.tasks.memoir_tasks._update_task_status_sync") - @patch("app.tasks.memoir_tasks._release_chapter_lock") - @patch("app.tasks.memoir_tasks._acquire_chapter_lock", return_value=True) - @patch( - "app.agents.memoir.orchestrator.ClassificationAgent.classify", - return_value="childhood", - ) - @patch("app.tasks.memoir_tasks._get_or_create_state_sync") - @patch("app.tasks.memoir_tasks._get_llm", return_value=None) - @patch("app.tasks.chapter_cover_enqueue.try_enqueue_generate_chapter_cover") - @patch("app.tasks.memoir_tasks.get_sync_db") - @patch("app.tasks.memoir_tasks.MemoirImageSettings.from_env") - def test_process_memoir_segments_does_not_enqueue_image_jobs_when_feature_disabled( - self, - settings_from_env, - get_sync_db_mock, - try_enqueue_mock, - _get_llm, - get_state_mock, - _classify_mock, - _acquire_lock_mock, - _release_lock_mock, - _update_status_mock, - ingest_mock, - retrieve_mock, - mock_pipeline, - _delay_story_image, - _delay_recompose, - ): - settings_from_env.return_value = MemoirImageSettings( - enabled=False, - max_per_chapter=2, - provider="liblib", - default_style="watercolor", - default_size="1024x1024", - poll_interval_seconds=3, - max_attempts=20, - liblib_template_uuid="tpl-uuid", - ) - get_state_mock.return_value = SimpleNamespace( - current_stage="childhood", slots={} - ) - mock_pipeline.return_value = (_fake_chapter_for_pipeline(), True, set()) - - segment = SimpleNamespace( - id="segment-1", - transcript_text="那条路我一直记得。\n\n{{{{IMAGE:南方小镇的青石板路}}}}", - processed=False, - ) - - segments_result = Mock() - segments_result.scalars.return_value.all.return_value = [segment] - - book_result = Mock() - book_result.scalar_one_or_none.return_value = None - - db = Mock() - db.execute.side_effect = [ - segments_result, - book_result, - ] - db.get.return_value = None - get_sync_db_mock.return_value = _mock_get_sync_db(db) - - task_self = SimpleNamespace( - request=SimpleNamespace(id="task-1"), - retry=Mock(side_effect=AssertionError("retry should not be called")), - ) - process_memoir_segments.run.__func__(task_self, "user-1", ["segment-1"]) - - try_enqueue_mock.assert_not_called() diff --git a/api/tests/test_reading_segments_dedupe.py b/api/tests/test_reading_segments_dedupe.py deleted file mode 100644 index 9ec628e..0000000 --- a/api/tests/test_reading_segments_dedupe.py +++ /dev/null @@ -1,89 +0,0 @@ -"""reading_segments:正文已含与主插图相同 asset 时不重复 cover_image。""" - -import unittest - -from app.features.memoir.helpers import build_reading_segments -from app.features.memoir.reading_segment_materialize import ( - build_reading_segments_snapshot, - resolve_reading_segments_for_chapter_detail, -) - - -class _Intent: - def __init__(self, asset_id: str): - self.intent_role = "primary" - self.asset_id = asset_id - self.caption = "cap" - self.prompt_brief = None - self.style_profile = None - self.error = None - self.status = "completed" - self.created_at = None - self.updated_at = None - - -class _Story: - def __init__(self, sid: str, body: str, asset_id: str): - self.id = sid - self.title = "T" - self.canonical_markdown = body - self.image_intents = [_Intent(asset_id)] - - -class _Link: - def __init__(self, order: int, story: _Story): - self.order_index = order - self.story = story - - -class _Ch: - def __init__(self, story: _Story): - self.story_links = [_Link(0, story)] - - -class ReadingSegmentsDedupeTest(unittest.TestCase): - def test_omits_cover_when_primary_asset_in_body(self): - aid = "c67d11c5-23b7-4ab9-91ed-c05b5a27a12b" - body = f"正文\n\n![cap](asset://{aid})\n\n更多" - st = _Story("s1", body, aid) - ch = _Ch(st) - asset_url_map = {aid: "https://cos.example/img.png"} - segs = build_reading_segments(ch, asset_url_map=asset_url_map) - self.assertEqual(len(segs), 1) - self.assertIn("https://cos.example", segs[0]["body_markdown"]) - self.assertIsNone(segs[0]["cover_image"]) - - def test_keeps_cover_when_primary_not_in_body(self): - aid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" - body = "仅文字无图" - st = _Story("s1", body, aid) - ch = _Ch(st) - asset_url_map = {aid: "https://cos.example/cover.png"} - segs = build_reading_segments(ch, asset_url_map=asset_url_map) - self.assertEqual(len(segs), 1) - self.assertIsNotNone(segs[0]["cover_image"]) - self.assertTrue(segs[0]["cover_image"].get("url")) - - def test_persisted_snapshot_hydrate_matches_live_materialize(self): - aid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" - body = "仅文字无图" - st = _Story("s1", body, aid) - ch = _Ch(st) - ch.reading_segments_json = build_reading_segments_snapshot(ch) - ch.markdown_compose_dirty = False - asset_url_map = {aid: "https://cos.example/cover.png"} - live = build_reading_segments(ch, asset_url_map=asset_url_map) - via_snapshot = resolve_reading_segments_for_chapter_detail( - ch, asset_url_map=asset_url_map - ) - self.assertEqual(len(live), len(via_snapshot)) - self.assertEqual(live[0]["story_id"], via_snapshot[0]["story_id"]) - self.assertEqual(live[0]["body_markdown"], via_snapshot[0]["body_markdown"]) - self.assertEqual( - (live[0]["cover_image"] or {}).get("url"), - (via_snapshot[0]["cover_image"] or {}).get("url"), - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/api/tests/test_session_history.py b/api/tests/test_session_history.py deleted file mode 100644 index 3d5dc51..0000000 --- a/api/tests/test_session_history.py +++ /dev/null @@ -1,57 +0,0 @@ -"""session_history 纯映射测试(ConversationService 的会话层,非 Agent)。""" - -import unittest -from datetime import datetime, timezone - -from app.features.conversation.models import Segment -from app.features.conversation.session_history import segments_to_redis_history - - -class SegmentsToRedisHistoryTest(unittest.TestCase): - def test_text_turn_maps_to_human_and_ai(self): - seg = Segment( - id="s1", - conversation_id="c1", - transcript_text="我在杭州长大", - audio_url=None, - created_at=datetime(2024, 1, 2, 3, 4, 5, tzinfo=timezone.utc), - agent_response="听起来很温润的城市。", - ) - h = segments_to_redis_history([seg]) - self.assertEqual(len(h), 2) - self.assertEqual(h[0]["role"], "human") - self.assertEqual(h[0]["messageType"], "text") - self.assertEqual(h[0]["content"], "我在杭州长大") - self.assertEqual(h[1]["role"], "ai") - self.assertEqual(h[1]["content"], "听起来很温润的城市。") - - def test_voice_segment_sets_voice_session_id(self): - seg = Segment( - id="s1", - conversation_id="c1", - transcript_text="嗯", - audio_url="audio-segment:vs-9:0", - created_at=datetime(2024, 1, 2, tzinfo=timezone.utc), - agent_response=None, - ) - h = segments_to_redis_history([seg]) - self.assertEqual(len(h), 1) - self.assertEqual(h[0]["messageType"], "audio") - self.assertEqual(h[0]["voiceSessionId"], "vs-9") - - def test_voice_segment_includes_duration_seconds_when_set(self): - seg = Segment( - id="s1", - conversation_id="c1", - transcript_text="嗯", - audio_url="audio-segment:vs-9:0", - audio_duration_seconds=12, - created_at=datetime(2024, 1, 2, tzinfo=timezone.utc), - agent_response=None, - ) - h = segments_to_redis_history([seg]) - self.assertEqual(h[0]["durationSeconds"], 12) - - -if __name__ == "__main__": - unittest.main() diff --git a/api/tests/test_sms_verification.py b/api/tests/test_sms_verification.py deleted file mode 100755 index e8e4299..0000000 --- a/api/tests/test_sms_verification.py +++ /dev/null @@ -1,405 +0,0 @@ -#!/usr/bin/env python3 -""" -短信验证码功能测试脚本 - -测试用例: -1. 发送验证码(成功/频率限制) -2. 验证码验证(成功/过期/错误) -3. 验证码注册流程 -4. 验证码登录流程 -5. 密码重置流程 -6. 修改密码(已登录) -7. 修改手机号 -8. 登出所有设备 -""" - -import requests -import time -import json -from typing import Optional, Dict, Any - -# 配置 -BASE_URL = "http://localhost:8000" -API_PREFIX = "/api" - -# 测试用户数据 -TEST_PHONE = "13800138000" -TEST_PASSWORD = "test123456" -TEST_NICKNAME = "测试用户" -TEST_EMAIL = "test@example.com" -NEW_PHONE = "13900139000" -NEW_PASSWORD = "newpass123456" - -# 全局变量 -access_token: Optional[str] = None -refresh_token: Optional[str] = None - - -class Colors: - """终端颜色""" - - HEADER = "\033[95m" - OKBLUE = "\033[94m" - OKCYAN = "\033[96m" - OKGREEN = "\033[92m" - WARNING = "\033[93m" - FAIL = "\033[91m" - ENDC = "\033[0m" - BOLD = "\033[1m" - UNDERLINE = "\033[4m" - - -def print_header(text: str): - """打印测试标题""" - print(f"\n{Colors.HEADER}{Colors.BOLD}{'=' * 60}{Colors.ENDC}") - print(f"{Colors.HEADER}{Colors.BOLD}{text}{Colors.ENDC}") - print(f"{Colors.HEADER}{Colors.BOLD}{'=' * 60}{Colors.ENDC}\n") - - -def print_success(text: str): - """打印成功信息""" - print(f"{Colors.OKGREEN}✓ {text}{Colors.ENDC}") - - -def print_error(text: str): - """打印错误信息""" - print(f"{Colors.FAIL}✗ {text}{Colors.ENDC}") - - -def print_info(text: str): - """打印信息""" - print(f"{Colors.OKCYAN}ℹ {text}{Colors.ENDC}") - - -def print_warning(text: str): - """打印警告""" - print(f"{Colors.WARNING}⚠ {text}{Colors.ENDC}") - - -def make_request( - method: str, - endpoint: str, - data: Optional[Dict[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - expected_status: int = 200, -) -> Optional[Dict[str, Any]]: - """发送HTTP请求""" - url = f"{BASE_URL}{API_PREFIX}{endpoint}" - - try: - if method.upper() == "GET": - response = requests.get(url, headers=headers) - elif method.upper() == "POST": - response = requests.post(url, json=data, headers=headers) - elif method.upper() == "PUT": - response = requests.put(url, json=data, headers=headers) - elif method.upper() == "DELETE": - response = requests.delete(url, headers=headers) - else: - print_error(f"不支持的HTTP方法: {method}") - return None - - print_info(f"{method.upper()} {endpoint} - Status: {response.status_code}") - - if response.status_code == expected_status: - print_success(f"请求成功 (状态码: {response.status_code})") - try: - return response.json() - except: - return {"status": "success"} - else: - print_error( - f"请求失败 (期望: {expected_status}, 实际: {response.status_code})" - ) - try: - error_data = response.json() - print_error( - f"错误信息: {json.dumps(error_data, ensure_ascii=False, indent=2)}" - ) - except: - print_error(f"响应内容: {response.text}") - return None - - except requests.exceptions.ConnectionError: - print_error(f"连接失败: 无法连接到 {BASE_URL}") - print_warning("请确保后端服务正在运行") - return None - except Exception as e: - print_error(f"请求异常: {str(e)}") - return None - - -def test_send_verification_code(phone: str, purpose: str) -> bool: - """测试发送验证码""" - print_header(f"测试发送验证码 - {purpose}") - - data = {"phone": phone, "purpose": purpose} - - result = make_request("POST", "/auth/sms/send", data=data) - - if result: - print_success(f"验证码已发送: {result.get('message', '')}") - print_info(f"有效期: {result.get('expires_in', 0)} 秒") - return True - - return False - - -def test_rate_limit(phone: str) -> bool: - """测试频率限制""" - print_header("测试频率限制") - - # 第一次发送应该成功 - if not test_send_verification_code(phone, "register"): - return False - - print_info("等待1秒后再次发送...") - time.sleep(1) - - # 第二次发送应该被限制 - data = {"phone": phone, "purpose": "register"} - - result = make_request("POST", "/auth/sms/send", data=data, expected_status=429) - - if result is None: - print_success("频率限制生效") - return True - else: - print_error("频率限制未生效") - return False - - -def test_register_with_sms(phone: str, code: str) -> bool: - """测试验证码注册""" - print_header("测试验证码注册") - - data = { - "phone": phone, - "code": code, - "password": TEST_PASSWORD, - "nickname": TEST_NICKNAME, - "email": TEST_EMAIL, - } - - result = make_request("POST", "/auth/register/sms", data=data, expected_status=201) - - if result: - global access_token, refresh_token - access_token = result.get("access_token") - refresh_token = result.get("refresh_token") - - print_success("注册成功") - print_info(f"Access Token: {access_token[:20]}...") - print_info(f"Refresh Token: {refresh_token[:20]}...") - return True - - return False - - -def test_login_with_sms(phone: str, code: str) -> bool: - """测试验证码登录""" - print_header("测试验证码登录") - - data = {"phone": phone, "code": code} - - result = make_request("POST", "/auth/login/sms", data=data) - - if result: - global access_token, refresh_token - access_token = result.get("access_token") - refresh_token = result.get("refresh_token") - - print_success("登录成功") - print_info(f"Access Token: {access_token[:20]}...") - return True - - return False - - -def test_reset_password(phone: str, code: str, new_password: str) -> bool: - """测试重置密码""" - print_header("测试重置密码") - - data = {"phone": phone, "code": code, "new_password": new_password} - - result = make_request("POST", "/auth/password/reset", data=data) - - if result: - print_success(f"密码重置成功: {result.get('message', '')}") - return True - - return False - - -def test_change_password(old_password: str, new_password: str) -> bool: - """测试修改密码(已登录)""" - print_header("测试修改密码") - - if not access_token: - print_error("未登录,无法测试") - return False - - data = {"old_password": old_password, "new_password": new_password} - - headers = {"Authorization": f"Bearer {access_token}"} - - result = make_request("POST", "/auth/password/change", data=data, headers=headers) - - if result: - print_success(f"密码修改成功: {result.get('message', '')}") - return True - - return False - - -def test_change_phone(new_phone: str, code: str) -> bool: - """测试修改手机号""" - print_header("测试修改手机号") - - if not access_token: - print_error("未登录,无法测试") - return False - - data = {"new_phone": new_phone, "code": code} - - headers = {"Authorization": f"Bearer {access_token}"} - - result = make_request("POST", "/auth/phone/change", data=data, headers=headers) - - if result: - print_success(f"手机号修改成功") - print_info(f"新手机号: {result.get('phone', '')}") - return True - - return False - - -def test_logout_all() -> bool: - """测试登出所有设备""" - print_header("测试登出所有设备") - - if not access_token: - print_error("未登录,无法测试") - return False - - headers = {"Authorization": f"Bearer {access_token}"} - - result = make_request("POST", "/auth/logout/all", headers=headers) - - if result: - print_success(f"登出成功: {result.get('message', '')}") - return True - - return False - - -def test_get_current_user() -> bool: - """测试获取当前用户信息""" - print_header("测试获取当前用户信息") - - if not access_token: - print_error("未登录,无法测试") - return False - - headers = {"Authorization": f"Bearer {access_token}"} - - result = make_request("GET", "/auth/me", headers=headers) - - if result: - print_success("获取用户信息成功") - print_info(f"用户信息: {json.dumps(result, ensure_ascii=False, indent=2)}") - return True - - return False - - -def interactive_test(): - """交互式测试""" - print_header("短信验证码功能交互式测试") - print_info("此模式需要您手动输入收到的验证码") - print_warning("请确保已配置腾讯云短信服务") - - phone = input(f"\n请输入测试手机号 (默认: {TEST_PHONE}): ").strip() or TEST_PHONE - - # 1. 测试发送验证码 - if not test_send_verification_code(phone, "register"): - print_error("发送验证码失败,测试终止") - return - - code = input("\n请输入收到的验证码: ").strip() - - if not code or len(code) != 6: - print_error("验证码格式错误") - return - - # 2. 测试注册 - if test_register_with_sms(phone, code): - print_success("注册测试通过") - - # 3. 测试获取用户信息 - test_get_current_user() - - # 4. 测试修改密码 - if input("\n是否测试修改密码? (y/n): ").lower() == "y": - test_change_password(TEST_PASSWORD, NEW_PASSWORD) - - # 5. 测试修改手机号 - if input("\n是否测试修改手机号? (y/n): ").lower() == "y": - new_phone = ( - input(f"请输入新手机号 (默认: {NEW_PHONE}): ").strip() or NEW_PHONE - ) - - if test_send_verification_code(new_phone, "change_phone"): - code = input("请输入收到的验证码: ").strip() - test_change_phone(new_phone, code) - - # 6. 测试登出所有设备 - if input("\n是否测试登出所有设备? (y/n): ").lower() == "y": - test_logout_all() - - -def automated_test(): - """自动化测试(需要mock验证码)""" - print_header("短信验证码功能自动化测试") - print_warning("此模式需要后端支持测试验证码(如:123456)") - - # 测试发送验证码 - test_send_verification_code(TEST_PHONE, "register") - - # 等待一段时间 - print_info("等待60秒以测试频率限制...") - time.sleep(60) - - # 测试频率限制 - test_rate_limit(TEST_PHONE) - - -if __name__ == "__main__": - print(f"{Colors.BOLD}{Colors.OKBLUE}") - print("=" * 60) - print("短信验证码功能测试脚本") - print("=" * 60) - print(f"{Colors.ENDC}") - - print("\n请选择测试模式:") - print("1. 交互式测试(需要真实短信验证码)") - print("2. 自动化测试(需要测试验证码支持)") - print("3. 仅测试API连接") - - choice = input("\n请输入选项 (1/2/3): ").strip() - - if choice == "1": - interactive_test() - elif choice == "2": - automated_test() - elif choice == "3": - print_header("测试API连接") - result = make_request("GET", "/health", expected_status=200) - if result: - print_success("API连接正常") - else: - print_error("API连接失败") - else: - print_error("无效的选项") - - print(f"\n{Colors.BOLD}{Colors.OKBLUE}测试完成{Colors.ENDC}\n") diff --git a/api/tests/test_story_backfill.py b/api/tests/test_story_backfill.py deleted file mode 100644 index 12242c5..0000000 --- a/api/tests/test_story_backfill.py +++ /dev/null @@ -1,20 +0,0 @@ -import unittest - -from app.features.story.backfill import backfill_image_into_markdown - - -class StoryBackfillTest(unittest.TestCase): - def test_appends_image_at_end_with_prompt_brief_alt(self): - md = "第一段\n\n第二段" - out = backfill_image_into_markdown(md, "aid-1", "院子里的藤椅") - self.assertTrue(out.endswith("](asset://aid-1)\n")) - self.assertIn("第二段", out) - self.assertLess(out.index("第二段"), out.index("![")) - - def test_empty_markdown_is_only_image_ref(self): - out = backfill_image_into_markdown("", "x", "提示") - self.assertEqual(out, "![提示](asset://x)") - - def test_escapes_bracket_in_alt(self): - out = backfill_image_into_markdown("正文", "a", "说明]尾") - self.assertIn(r"说明\]", out) diff --git a/api/tests/test_story_image_tasks.py b/api/tests/test_story_image_tasks.py deleted file mode 100644 index 6b5269f..0000000 --- a/api/tests/test_story_image_tasks.py +++ /dev/null @@ -1,248 +0,0 @@ -import unittest -from contextlib import contextmanager -from io import BytesIO -from types import SimpleNamespace -from unittest.mock import Mock, patch - -from PIL import Image - -from app.ports.image_gen import ImageResult, TaskStatus -from app.tasks.story_image_tasks import generate_story_image - - -def _mock_db_cm(db): - @contextmanager - def _cm(): - yield db - - return _cm() - - -def _png_bytes() -> bytes: - buf = BytesIO() - Image.new("RGB", (1, 1), color="white").save(buf, format="PNG") - return buf.getvalue() - - -class _FakeUUID: - def __init__(self, value: str): - self.hex = value - self._value = value - - def __str__(self) -> str: - return self._value - - -class GenerateStoryImageTaskTest(unittest.TestCase): - @patch("app.tasks.story_image_tasks.release_redis_lock") - @patch( - "app.tasks.story_image_tasks.acquire_redis_lock", - return_value=SimpleNamespace(key="lock:story-image:story-1"), - ) - @patch("app.tasks.story_image_tasks._claim_story_image_intent_sync") - @patch("app.tasks.story_image_tasks.get_sync_db") - @patch("app.tasks.story_image_tasks.TencentCosStorageService") - @patch("app.tasks.story_image_tasks.get_image_generator") - @patch("app.features.memoir.memoir_images.settings.MemoirImageSettings.from_env") - @patch("app.tasks.story_image_tasks.uuid.uuid4") - def test_generate_story_image_resumes_processing_intent_and_backfills_markdown( - self, - uuid4_mock, - settings_from_env, - get_image_generator_mock, - storage_cls, - get_sync_db_mock, - claim_intent_mock, - acquire_lock_mock, - release_lock_mock, - ): - uuid4_mock.side_effect = [ - _FakeUUID("claim-token"), - _FakeUUID("asset-uuid"), - _FakeUUID("version-uuid"), - ] - settings_from_env.return_value = SimpleNamespace( - provider="liblib", - default_style="watercolor", - default_size="1024x1024", - ) - - intent = SimpleNamespace( - id="intent-1", - prompt_brief="院子里的藤椅", - style_profile="watercolor", - story_version_id="ver-1", - caption="主插图", - status="processing", - ) - story = SimpleNamespace( - id="story-1", - user_id="user-1", - title="童年的院子", - stage="childhood", - ) - db_claim = Mock() - claim_intent_mock.return_value = (intent, story) - - intent_db = SimpleNamespace( - id="intent-1", - story_version_id="ver-1", - caption="主插图", - prompt_brief="院子里的藤椅", - status="processing", - style_profile="watercolor", - claim_token="claim-token", - asset_id=None, - error=None, - updated_at=None, - ) - story_db = SimpleNamespace( - id="story-1", - current_version_id="ver-1", - canonical_markdown="第一段\n\n第二段", - ) - version_db = SimpleNamespace(id="ver-1", markdown_snapshot="第一段\n\n第二段") - version_max_result = Mock() - version_max_result.scalar.return_value = 1 - db_persist = Mock() - db_persist.get.side_effect = [intent_db, story_db, version_db] - db_persist.execute.return_value = version_max_result - - get_sync_db_mock.side_effect = [_mock_db_cm(db_claim), _mock_db_cm(db_persist)] - - generator = get_image_generator_mock.return_value - generator.generate.return_value = ImageResult( - status=TaskStatus.COMPLETED, - task_id="task-1", - image_url="https://provider.example.com/story.png", - ) - generator.download_image.return_value = _png_bytes() - storage_cls.from_env.return_value.upload_bytes.return_value = ( - "https://cos.example.com/stories/u1/s1.png" - ) - - result = generate_story_image.run("story-1") - - self.assertEqual(result["status"], "success") - self.assertEqual(intent_db.status, "completed") - self.assertIsNotNone(intent_db.asset_id) - self.assertNotEqual(story_db.current_version_id, "ver-1") - self.assertIn("asset://", story_db.canonical_markdown) - generator.generate.assert_called_once() - storage_cls.from_env.return_value.upload_bytes.assert_called_once() - claim_intent_mock.assert_called_once() - acquire_lock_mock.assert_called_once() - release_lock_mock.assert_called_once() - - @patch("app.tasks.story_image_tasks.release_redis_lock") - @patch( - "app.tasks.story_image_tasks.acquire_redis_lock", - return_value=SimpleNamespace(key="lock:story-image:story-1"), - ) - @patch("app.tasks.story_image_tasks._claim_story_image_intent_sync") - @patch("app.tasks.story_image_tasks.get_sync_db") - @patch("app.tasks.story_image_tasks.TencentCosStorageService") - @patch("app.tasks.story_image_tasks.get_image_generator") - @patch("app.features.memoir.memoir_images.settings.MemoirImageSettings.from_env") - @patch("app.tasks.story_image_tasks.uuid.uuid4") - def test_generate_story_image_strips_existing_asset_refs_before_backfill( - self, - uuid4_mock, - settings_from_env, - get_image_generator_mock, - storage_cls, - get_sync_db_mock, - claim_intent_mock, - acquire_lock_mock, - release_lock_mock, - ): - uuid4_mock.side_effect = [ - _FakeUUID("claim-token"), - _FakeUUID("new-asset-uuid"), - _FakeUUID("version-uuid"), - ] - settings_from_env.return_value = SimpleNamespace( - provider="liblib", - default_style="watercolor", - default_size="1024x1024", - ) - - intent = SimpleNamespace( - id="intent-1", - prompt_brief="院子里的藤椅", - style_profile="watercolor", - story_version_id="ver-1", - caption="主插图", - status="processing", - ) - story = SimpleNamespace( - id="story-1", - user_id="user-1", - title="童年的院子", - stage="childhood", - ) - db_claim = Mock() - claim_intent_mock.return_value = (intent, story) - - intent_db = SimpleNamespace( - id="intent-1", - story_version_id="ver-1", - caption="主插图", - prompt_brief="院子里的藤椅", - status="processing", - style_profile="watercolor", - claim_token="claim-token", - asset_id=None, - error=None, - updated_at=None, - ) - story_db = SimpleNamespace( - id="story-1", - current_version_id="ver-1", - canonical_markdown="第一段\n\n第二段", - ) - version_db = SimpleNamespace( - id="ver-1", - markdown_snapshot=("第一段\n\n![旧图](asset://old-stale-id)\n\n第二段"), - ) - version_max_result = Mock() - version_max_result.scalar.return_value = 1 - db_persist = Mock() - db_persist.get.side_effect = [intent_db, story_db, version_db] - db_persist.execute.return_value = version_max_result - - get_sync_db_mock.side_effect = [_mock_db_cm(db_claim), _mock_db_cm(db_persist)] - - generator = get_image_generator_mock.return_value - generator.generate.return_value = ImageResult( - status=TaskStatus.COMPLETED, - task_id="task-1", - image_url="https://provider.example.com/story.png", - ) - generator.download_image.return_value = _png_bytes() - storage_cls.from_env.return_value.upload_bytes.return_value = ( - "https://cos.example.com/stories/u1/s1.png" - ) - - result = generate_story_image.run("story-1") - - self.assertEqual(result["status"], "success") - self.assertEqual(story_db.canonical_markdown.count("asset://"), 1) - self.assertIn("asset://new-asset-uuid", story_db.canonical_markdown) - self.assertNotIn("old-stale-id", story_db.canonical_markdown) - - @patch("app.tasks.story_image_tasks.acquire_redis_lock", return_value=None) - @patch("app.tasks.story_image_tasks.get_sync_db") - @patch("app.tasks.story_image_tasks.get_image_generator") - def test_generate_story_image_skips_when_lock_is_already_held( - self, - get_image_generator_mock, - get_sync_db_mock, - acquire_lock_mock, - ): - result = generate_story_image.run("story-1") - - self.assertEqual(result, {"status": "locked"}) - get_sync_db_mock.assert_not_called() - get_image_generator_mock.assert_not_called() - acquire_lock_mock.assert_called_once() diff --git a/api/tests/test_story_route_agent.py b/api/tests/test_story_route_agent.py deleted file mode 100644 index 4007085..0000000 --- a/api/tests/test_story_route_agent.py +++ /dev/null @@ -1,73 +0,0 @@ -"""StoryRouteAgent:LLM JSON 决策与非法 target 回退。""" - -import unittest -from types import SimpleNamespace -from unittest.mock import Mock - -from app.agents.memoir.story_route_agent import StoryRouteAgent - - -def _story_stub(sid: str, title: str = "T"): - return SimpleNamespace( - id=sid, - title=title, - canonical_markdown="预览正文", - chapter_links=[], - ) - - -class StoryRouteAgentTest(unittest.TestCase): - def test_no_llm_returns_new_story(self): - agent = StoryRouteAgent() - out = agent.decide( - chapter_category="childhood", - chapter_title="童年", - batch_transcript="hello", - candidate_stories=[_story_stub("s1")], - llm=None, - valid_story_ids={"s1"}, - ) - self.assertEqual(out.decision, "new_story") - self.assertIsNone(out.new_story_title) - - def test_append_invalid_id_falls_back_to_new_story(self): - agent = StoryRouteAgent() - llm = Mock() - bound = Mock() - llm.bind.return_value = bound - bound.invoke.return_value = SimpleNamespace( - content='{"decision":"append_story","target_story_id":"unknown"}' - ) - out = agent.decide( - chapter_category="childhood", - chapter_title="童年", - batch_transcript="hello", - candidate_stories=[_story_stub("s1")], - llm=llm, - valid_story_ids={"s1"}, - ) - self.assertEqual(out.decision, "new_story") - self.assertEqual(out.reason, "invalid_target") - - def test_append_valid_target(self): - agent = StoryRouteAgent() - llm = Mock() - bound = Mock() - llm.bind.return_value = bound - bound.invoke.return_value = SimpleNamespace( - content='{"decision":"append_story","target_story_id":"s1"}' - ) - out = agent.decide( - chapter_category="childhood", - chapter_title="童年", - batch_transcript="more text", - candidate_stories=[_story_stub("s1")], - llm=llm, - valid_story_ids={"s1"}, - ) - self.assertEqual(out.decision, "append_story") - self.assertEqual(out.target_story_id, "s1") - - -if __name__ == "__main__": - unittest.main() diff --git a/api/tests/test_tts_cos_keys.py b/api/tests/test_tts_cos_keys.py deleted file mode 100644 index 256d6b3..0000000 --- a/api/tests/test_tts_cos_keys.py +++ /dev/null @@ -1,39 +0,0 @@ -"""COS key collection for conversation TTS URLs.""" - -from app.core.cos_url_keys import ( - collect_cos_keys_from_conversation_history, - collect_cos_keys_from_tts_url_list, -) - - -def test_collect_from_history_empty(): - assert collect_cos_keys_from_conversation_history([]) == set() - - -def test_collect_from_history_ai_tts_urls(monkeypatch): - monkeypatch.setattr( - "app.core.cos_url_keys.settings.tencent_cos_bucket", - "bucket", - raising=False, - ) - monkeypatch.setattr( - "app.core.cos_url_keys.settings.tencent_cos_region", - "ap-guangzhou", - raising=False, - ) - url = "https://bucket.cos.ap-guangzhou.myqcloud.com/conversations/c1/tts/a.mp3" - hist = [ - {"role": "human", "content": "hi", "messageType": "text"}, - { - "role": "ai", - "content": "hello", - "messageType": "text", - "ttsAudioUrls": [url], - }, - ] - keys = collect_cos_keys_from_conversation_history(hist) - assert keys == {"conversations/c1/tts/a.mp3"} - - -def test_collect_from_tts_url_list_none(): - assert collect_cos_keys_from_tts_url_list(None) == set() diff --git a/api/tests/test_websocket_baseline.py b/api/tests/test_websocket_baseline.py deleted file mode 100644 index 58f2df4..0000000 --- a/api/tests/test_websocket_baseline.py +++ /dev/null @@ -1,1499 +0,0 @@ -import asyncio -import unittest -from contextlib import ExitStack -from dataclasses import dataclass -from types import SimpleNamespace -from unittest.mock import AsyncMock, patch - -from starlette.websockets import WebSocketDisconnect, WebSocketState - -from app.features.conversation import service as conversation_feature_service -from app.features.conversation.models import Conversation, Segment -from app.features.conversation.ws import router as ws_router - - -class _FakeWebSocket: - def __init__(self, messages, token="valid-token"): - self.query_params = {} - if token is not None: - self.query_params["token"] = token - self._messages = list(messages) - self.application_state = WebSocketState.CONNECTED - self.close_calls = [] - - async def receive_json(self): - if not self._messages: - raise WebSocketDisconnect() - next_item = self._messages.pop(0) - if isinstance(next_item, BaseException): - raise next_item - return next_item - - async def close(self, code=None, reason=None): - self.close_calls.append({"code": code, "reason": reason}) - self.application_state = WebSocketState.DISCONNECTED - - -@dataclass -class _ScalarsResult: - _items: list - - def all(self): - return list(self._items) - - -@dataclass -class _ExecuteResult: - _items: list - - def scalars(self): - return _ScalarsResult(self._items) - - def scalar_one_or_none(self): - return self._items[0] if len(self._items) == 1 else None - - -class _FakeAsyncDB: - def __init__(self, user, conversation=None, segments=None): - self.user = user - self.conversation = conversation - self.segments = list(segments or []) - self.added = [] - self.commit_calls = 0 - self.refresh_calls = 0 - self.state_result = None # 若为 None,get_or_create_state 的查询返回空 - - async def get(self, model, key): - model_name = getattr(model, "__name__", "") - if model_name == "User": - return self.user - if model_name == "Conversation": - if self.conversation and self.conversation.id == key: - return self.conversation - return None - return None - - def add(self, obj): - self.added.append(obj) - if isinstance(obj, Conversation): - self.conversation = obj - if isinstance(obj, Segment): - self.segments.append(obj) - - async def commit(self): - self.commit_calls += 1 - - async def refresh(self, obj): - _ = obj - self.refresh_calls += 1 - - async def execute(self, stmt): - stmt_str = str(stmt) - if "MemoirState" in stmt_str or "memoir_state" in stmt_str: - return _ExecuteResult( - [self.state_result] if self.state_result is not None else [] - ) - return _ExecuteResult(self.segments) - - -class _FakeManager: - def __init__(self): - self.active_connections = {} - self.segment_states = {} - self.sent_messages = [] - self.disconnect_calls = [] - self.background_runner = SimpleNamespace( - queue_message=AsyncMock(), - flush_pending=AsyncMock(), - ) - self.conversation_agent = SimpleNamespace( - generate_profile_greeting=AsyncMock(return_value=[]), - generate_opening_message=AsyncMock(return_value=[]), - ) - - async def connect(self, websocket, conversation_id): - self.active_connections[conversation_id] = websocket - - async def disconnect(self, conversation_id): - self.disconnect_calls.append(conversation_id) - self.active_connections.pop(conversation_id, None) - - async def send_message(self, conversation_id, message): - self.sent_messages.append( - {"conversation_id": conversation_id, "message": message} - ) - - def get_or_create_segment_state(self, conversation_id, voice_session_id): - state_key = (conversation_id, voice_session_id) - if state_key not in self.segment_states: - self.segment_states[state_key] = ws_router.SegmentStreamState() - return self.segment_states[state_key] - - def register_segment_task(self, conversation_id, voice_session_id, task): - state = self.get_or_create_segment_state(conversation_id, voice_session_id) - state.active_tasks.add(task) - - def _cleanup(done_task): - state.active_tasks.discard(done_task) - - task.add_done_callback(_cleanup) - - -def _make_user(): - # Provide all profile fields to skip greeting/profile-collection branch. - return SimpleNamespace( - id="user-1", - nickname="tester", - subscription_type="premium", - birth_year=1990, - birth_place="A", - grew_up_place="B", - occupation="dev", - ) - - -def _db_provider(db): - """返回可被 patch 到 get_async_db 的异步生成器(旧用法)。""" - - async def _provider(): - yield db - - return _provider - - -class _FakeSessionCM: - """模拟 async with AsyncSessionLocal() as db 的上下文管理器。""" - - def __init__(self, db): - self._db = db - - async def __aenter__(self): - return self._db - - async def __aexit__(self, *args): - pass - - -def _session_local_factory(fake_db): - """返回可 patch 到 AsyncSessionLocal 的工厂,使 async with AsyncSessionLocal() as db 得到 fake_db。""" - - def _factory(): - return _FakeSessionCM(fake_db) - - return _factory - - -def _redis_empty_history_patch(): - """Patch redis to return empty history so websocket sends opening (or skips if mocked).""" - return patch.object( - conversation_feature_service.redis_service, - "get_conversation_history", - new=AsyncMock(return_value=[]), - ) - - -class WebSocketBaselineTest(unittest.IsolatedAsyncioTestCase): - async def test_invalid_token_closes_connection(self): - websocket = _FakeWebSocket(messages=[], token="invalid") - - with patch.object(ws_router, "verify_token", return_value=None): - await ws_router.websocket_endpoint(websocket, "conv-1") - - self.assertEqual(len(websocket.close_calls), 1) - self.assertEqual(websocket.close_calls[0]["reason"], "无效的认证令牌") - - async def test_text_message_creates_segment_and_dispatches_agent(self): - user = _make_user() - conversation = Conversation(id="conv-1", user_id=user.id, status="active") - fake_db = _FakeAsyncDB(user=user, conversation=conversation) - fake_manager = _FakeManager() - fake_websocket = _FakeWebSocket( - messages=[ - {"type": "text", "data": {"text": "你好"}}, - WebSocketDisconnect(), - ] - ) - - process_user_message_mock = AsyncMock() - - with ExitStack() as stack: - stack.enter_context( - patch.object( - ws_router, - "verify_token", - return_value={"type": "access", "sub": user.id}, - ) - ) - stack.enter_context( - patch.object( - ws_router, "AsyncSessionLocal", _session_local_factory(fake_db) - ) - ) - stack.enter_context( - patch( - "app.features.conversation.ws.pipeline.AsyncSessionLocal", - _session_local_factory(fake_db), - ) - ) - stack.enter_context(patch.object(ws_router, "manager", fake_manager)) - stack.enter_context(_redis_empty_history_patch()) - stack.enter_context( - patch("app.features.conversation.ws.pipeline.manager", fake_manager) - ) - stack.enter_context( - patch.object( - ws_router, "background_runner", fake_manager.background_runner - ) - ) - stack.enter_context(_redis_empty_history_patch()) - stack.enter_context( - patch( - "app.features.conversation.ws.router.check_ws_quota", - new=AsyncMock(return_value=(True, "")), - ) - ) - stack.enter_context( - patch.object( - ws_router, "process_user_message", process_user_message_mock - ) - ) - - await ws_router.websocket_endpoint(fake_websocket, "conv-1") - - segments = [obj for obj in fake_db.added if isinstance(obj, Segment)] - self.assertEqual(len(segments), 1) - self.assertEqual(segments[0].transcript_text, "你好") - self.assertIsNone(segments[0].audio_url) - self.assertIsNotNone(conversation.last_message_at) - fake_manager.background_runner.queue_message.assert_awaited_once() - process_user_message_mock.assert_awaited_once() - - message_types = [item["message"]["type"] for item in fake_manager.sent_messages] - self.assertIn(ws_router.MessageType.CONNECT, message_types) - - async def test_audio_message_transcribes_then_calls_agent(self): - user = _make_user() - conversation = Conversation(id="conv-1", user_id=user.id, status="active") - fake_db = _FakeAsyncDB(user=user, conversation=conversation) - fake_manager = _FakeManager() - fake_websocket = _FakeWebSocket( - messages=[ - { - "type": "audio_message", - "data": {"audio_base64": "ZmFrZS1hdWRpby1iNjQ=", "duration": 12}, - }, - WebSocketDisconnect(), - ] - ) - - process_user_message_mock = AsyncMock() - transcribe_mock = AsyncMock(return_value="这是转写结果") - - with ExitStack() as stack: - stack.enter_context( - patch.object( - ws_router, - "verify_token", - return_value={"type": "access", "sub": user.id}, - ) - ) - stack.enter_context( - patch.object( - ws_router, "AsyncSessionLocal", _session_local_factory(fake_db) - ) - ) - stack.enter_context( - patch( - "app.features.conversation.ws.pipeline.AsyncSessionLocal", - _session_local_factory(fake_db), - ) - ) - stack.enter_context(patch.object(ws_router, "manager", fake_manager)) - stack.enter_context(_redis_empty_history_patch()) - stack.enter_context( - patch("app.features.conversation.ws.pipeline.manager", fake_manager) - ) - stack.enter_context( - patch.object( - ws_router, "background_runner", fake_manager.background_runner - ) - ) - stack.enter_context(_redis_empty_history_patch()) - stack.enter_context( - patch( - "app.features.conversation.ws.router.check_ws_quota", - new=AsyncMock(return_value=(True, "")), - ) - ) - stack.enter_context( - patch.object( - ws_router, "process_user_message", process_user_message_mock - ) - ) - stack.enter_context( - patch( - "app.features.conversation.ws.pipeline.process_user_message", - process_user_message_mock, - ) - ) - stack.enter_context( - patch.object( - ws_router, - "get_asr_provider", - lambda: SimpleNamespace(transcribe=transcribe_mock), - ) - ) - stack.enter_context( - patch( - "app.features.conversation.ws.pipeline.get_asr_provider", - lambda: SimpleNamespace(transcribe=transcribe_mock), - ) - ) - - await ws_router.websocket_endpoint(fake_websocket, "conv-1") - - transcribe_mock.assert_awaited_once_with(b"fake-audio-b64", "m4a") - process_user_message_mock.assert_awaited_once() - - segments = [obj for obj in fake_db.added if isinstance(obj, Segment)] - self.assertEqual(len(segments), 1) - self.assertEqual(segments[0].transcript_text, "这是转写结果") - self.assertEqual(segments[0].audio_url, "audio:12s") - self.assertIsNotNone(conversation.last_message_at) - - transcript_msgs = [ - item["message"] - for item in fake_manager.sent_messages - if item["message"]["type"] == ws_router.MessageType.TRANSCRIPT - ] - self.assertEqual(len(transcript_msgs), 1) - self.assertEqual(transcript_msgs[0]["data"]["text"], "这是转写结果") - - async def test_transcribe_only_returns_transcript_without_segment_or_agent(self): - user = _make_user() - conversation = Conversation(id="conv-1", user_id=user.id, status="active") - fake_db = _FakeAsyncDB(user=user, conversation=conversation) - fake_manager = _FakeManager() - fake_websocket = _FakeWebSocket( - messages=[ - { - "type": "transcribe_only", - "data": {"audio_base64": "ZmFrZS1hdWRpby1iNjQ="}, - }, - WebSocketDisconnect(), - ] - ) - - process_user_message_mock = AsyncMock() - transcribe_mock = AsyncMock(return_value="仅转写文本") - - with ExitStack() as stack: - stack.enter_context( - patch.object( - ws_router, - "verify_token", - return_value={"type": "access", "sub": user.id}, - ) - ) - stack.enter_context( - patch.object( - ws_router, "AsyncSessionLocal", _session_local_factory(fake_db) - ) - ) - stack.enter_context( - patch( - "app.features.conversation.ws.pipeline.AsyncSessionLocal", - _session_local_factory(fake_db), - ) - ) - stack.enter_context(patch.object(ws_router, "manager", fake_manager)) - stack.enter_context(_redis_empty_history_patch()) - stack.enter_context( - patch("app.features.conversation.ws.pipeline.manager", fake_manager) - ) - stack.enter_context( - patch.object( - ws_router, "background_runner", fake_manager.background_runner - ) - ) - stack.enter_context(_redis_empty_history_patch()) - stack.enter_context( - patch.object( - ws_router, "process_user_message", process_user_message_mock - ) - ) - stack.enter_context( - patch( - "app.features.conversation.ws.pipeline.process_user_message", - process_user_message_mock, - ) - ) - stack.enter_context( - patch.object( - ws_router, - "get_asr_provider", - lambda: SimpleNamespace(transcribe=transcribe_mock), - ) - ) - stack.enter_context( - patch( - "app.features.conversation.ws.pipeline.get_asr_provider", - lambda: SimpleNamespace(transcribe=transcribe_mock), - ) - ) - - await ws_router.websocket_endpoint(fake_websocket, "conv-1") - - transcribe_mock.assert_awaited_once_with(b"fake-audio-b64", "m4a") - process_user_message_mock.assert_not_awaited() - self.assertEqual( - len([obj for obj in fake_db.added if isinstance(obj, Segment)]), 0 - ) - - transcript_msgs = [ - item["message"] - for item in fake_manager.sent_messages - if item["message"]["type"] == ws_router.MessageType.TRANSCRIPT - ] - self.assertEqual(len(transcript_msgs), 1) - self.assertEqual(transcript_msgs[0]["data"]["text"], "仅转写文本") - - async def test_end_conversation_updates_status_and_triggers_processing(self): - user = _make_user() - conversation = Conversation(id="conv-1", user_id=user.id, status="active") - fake_db = _FakeAsyncDB(user=user, conversation=conversation) - fake_manager = _FakeManager() - fake_websocket = _FakeWebSocket( - messages=[{"type": "end_conversation", "conversation_id": "conv-1"}] - ) - process_conversation_segments_mock = AsyncMock() - - with ExitStack() as stack: - stack.enter_context( - patch.object( - ws_router, - "verify_token", - return_value={"type": "access", "sub": user.id}, - ) - ) - stack.enter_context( - patch.object( - ws_router, "AsyncSessionLocal", _session_local_factory(fake_db) - ) - ) - stack.enter_context( - patch( - "app.features.conversation.ws.pipeline.AsyncSessionLocal", - _session_local_factory(fake_db), - ) - ) - stack.enter_context(patch.object(ws_router, "manager", fake_manager)) - stack.enter_context(_redis_empty_history_patch()) - stack.enter_context( - patch("app.features.conversation.ws.pipeline.manager", fake_manager) - ) - stack.enter_context( - patch.object( - ws_router, "background_runner", fake_manager.background_runner - ) - ) - stack.enter_context(_redis_empty_history_patch()) - stack.enter_context( - patch.object( - ws_router, - "process_conversation_segments", - process_conversation_segments_mock, - ) - ) - - await ws_router.websocket_endpoint(fake_websocket, "conv-1") - - self.assertEqual(conversation.status, "ended") - process_conversation_segments_mock.assert_awaited_once() - end_msgs = [ - item["message"] - for item in fake_manager.sent_messages - if item["message"]["type"] == ws_router.MessageType.END_CONVERSATION - ] - self.assertEqual(len(end_msgs), 1) - - async def test_transcribe_only_missing_audio_returns_error(self): - user = _make_user() - conversation = Conversation(id="conv-1", user_id=user.id, status="active") - fake_db = _FakeAsyncDB(user=user, conversation=conversation) - fake_manager = _FakeManager() - fake_websocket = _FakeWebSocket( - messages=[ - {"type": "transcribe_only", "data": {}}, - WebSocketDisconnect(), - ] - ) - transcribe_mock = AsyncMock(return_value="should-not-be-called") - - with ExitStack() as stack: - stack.enter_context( - patch.object( - ws_router, - "verify_token", - return_value={"type": "access", "sub": user.id}, - ) - ) - stack.enter_context( - patch.object( - ws_router, "AsyncSessionLocal", _session_local_factory(fake_db) - ) - ) - stack.enter_context( - patch( - "app.features.conversation.ws.pipeline.AsyncSessionLocal", - _session_local_factory(fake_db), - ) - ) - stack.enter_context(patch.object(ws_router, "manager", fake_manager)) - stack.enter_context(_redis_empty_history_patch()) - stack.enter_context( - patch("app.features.conversation.ws.pipeline.manager", fake_manager) - ) - stack.enter_context( - patch.object( - ws_router, "background_runner", fake_manager.background_runner - ) - ) - stack.enter_context(_redis_empty_history_patch()) - stack.enter_context( - patch.object( - ws_router, - "get_asr_provider", - lambda: SimpleNamespace(transcribe=transcribe_mock), - ) - ) - stack.enter_context( - patch( - "app.features.conversation.ws.pipeline.get_asr_provider", - lambda: SimpleNamespace(transcribe=transcribe_mock), - ) - ) - - await ws_router.websocket_endpoint(fake_websocket, "conv-1") - - transcribe_mock.assert_not_awaited() - error_msgs = [ - item["message"] - for item in fake_manager.sent_messages - if item["message"]["type"] == ws_router.MessageType.ERROR - ] - self.assertEqual(len(error_msgs), 1) - self.assertEqual(error_msgs[0]["data"]["message"], "缺少 audio_base64") - - async def test_audio_message_transcribe_failure_sends_error_and_skips_agent(self): - user = _make_user() - conversation = Conversation(id="conv-1", user_id=user.id, status="active") - fake_db = _FakeAsyncDB(user=user, conversation=conversation) - fake_manager = _FakeManager() - fake_websocket = _FakeWebSocket( - messages=[ - { - "type": "audio_message", - "data": {"audio_base64": "ZmFrZS1hdWRpby1iNjQ=", "duration": 8}, - }, - WebSocketDisconnect(), - ] - ) - - process_user_message_mock = AsyncMock() - transcribe_mock = AsyncMock(return_value="转写失败: mock error") - - with ExitStack() as stack: - stack.enter_context( - patch.object( - ws_router, - "verify_token", - return_value={"type": "access", "sub": user.id}, - ) - ) - stack.enter_context( - patch.object( - ws_router, "AsyncSessionLocal", _session_local_factory(fake_db) - ) - ) - stack.enter_context( - patch( - "app.features.conversation.ws.pipeline.AsyncSessionLocal", - _session_local_factory(fake_db), - ) - ) - stack.enter_context(patch.object(ws_router, "manager", fake_manager)) - stack.enter_context(_redis_empty_history_patch()) - stack.enter_context( - patch("app.features.conversation.ws.pipeline.manager", fake_manager) - ) - stack.enter_context( - patch.object( - ws_router, "background_runner", fake_manager.background_runner - ) - ) - stack.enter_context(_redis_empty_history_patch()) - stack.enter_context( - patch( - "app.features.conversation.ws.router.check_ws_quota", - new=AsyncMock(return_value=(True, "")), - ) - ) - stack.enter_context( - patch.object( - ws_router, "process_user_message", process_user_message_mock - ) - ) - stack.enter_context( - patch( - "app.features.conversation.ws.pipeline.process_user_message", - process_user_message_mock, - ) - ) - stack.enter_context( - patch.object( - ws_router, - "get_asr_provider", - lambda: SimpleNamespace(transcribe=transcribe_mock), - ) - ) - stack.enter_context( - patch( - "app.features.conversation.ws.pipeline.get_asr_provider", - lambda: SimpleNamespace(transcribe=transcribe_mock), - ) - ) - - await ws_router.websocket_endpoint(fake_websocket, "conv-1") - - process_user_message_mock.assert_not_awaited() - error_msgs = [ - item["message"] - for item in fake_manager.sent_messages - if item["message"]["type"] == ws_router.MessageType.ERROR - ] - self.assertEqual(len(error_msgs), 1) - self.assertEqual( - error_msgs[0]["data"]["message"], "语音转写失败,请重试或使用文字输入" - ) - - async def test_audio_segment_out_of_order_is_aggregated_by_segment_index(self): - user = _make_user() - conversation = Conversation(id="conv-1", user_id=user.id, status="active") - fake_db = _FakeAsyncDB(user=user, conversation=conversation) - fake_manager = _FakeManager() - fake_websocket = _FakeWebSocket( - messages=[ - { - "type": "audio_segment", - "data": { - "audio_base64": "seg-1", - "voice_session_id": "voice-session-1", - "segment_index": 1, - "duration": 12, - "is_last": False, - }, - }, - { - "type": "audio_segment", - "data": { - "audio_base64": "seg-0", - "voice_session_id": "voice-session-1", - "segment_index": 0, - "duration": 10, - "is_last": False, - }, - }, - WebSocketDisconnect(), - ] - ) - - process_user_message_mock = AsyncMock() - transcribe_mock = AsyncMock(side_effect=["这是第 1 段", "这是第 0 段"]) - - with ExitStack() as stack: - stack.enter_context( - patch.object( - ws_router, - "verify_token", - return_value={"type": "access", "sub": user.id}, - ) - ) - stack.enter_context( - patch.object( - ws_router, "AsyncSessionLocal", _session_local_factory(fake_db) - ) - ) - stack.enter_context( - patch( - "app.features.conversation.ws.pipeline.AsyncSessionLocal", - _session_local_factory(fake_db), - ) - ) - stack.enter_context(patch.object(ws_router, "manager", fake_manager)) - stack.enter_context(_redis_empty_history_patch()) - stack.enter_context( - patch("app.features.conversation.ws.pipeline.manager", fake_manager) - ) - stack.enter_context( - patch.object( - ws_router, "background_runner", fake_manager.background_runner - ) - ) - stack.enter_context(_redis_empty_history_patch()) - stack.enter_context( - patch( - "app.features.conversation.ws.router.check_ws_quota", - new=AsyncMock(return_value=(True, "")), - ) - ) - stack.enter_context( - patch.object( - ws_router, "process_user_message", process_user_message_mock - ) - ) - stack.enter_context( - patch( - "app.features.conversation.ws.pipeline.process_user_message", - process_user_message_mock, - ) - ) - stack.enter_context( - patch.object( - ws_router, - "get_asr_provider", - lambda: SimpleNamespace(transcribe=transcribe_mock), - ) - ) - stack.enter_context( - patch( - "app.features.conversation.ws.pipeline.get_asr_provider", - lambda: SimpleNamespace(transcribe=transcribe_mock), - ) - ) - - await ws_router.websocket_endpoint(fake_websocket, "conv-1") - await asyncio.sleep(0.05) - - self.assertEqual(transcribe_mock.await_count, 2) - ordered_messages = [ - call.kwargs["user_message"] - for call in process_user_message_mock.await_args_list - ] - self.assertEqual(ordered_messages, ["这是第 0 段", "这是第 1 段"]) - self.assertEqual( - len([obj for obj in fake_db.added if isinstance(obj, Segment)]), 2 - ) - transcript_msgs = [ - item["message"] - for item in fake_manager.sent_messages - if item["message"]["type"] == ws_router.MessageType.TRANSCRIPT - ] - self.assertEqual( - [msg["data"]["voice_session_id"] for msg in transcript_msgs], - ["voice-session-1", "voice-session-1"], - ) - - async def test_audio_segment_duplicate_index_is_idempotent(self): - user = _make_user() - conversation = Conversation(id="conv-1", user_id=user.id, status="active") - fake_db = _FakeAsyncDB(user=user, conversation=conversation) - fake_manager = _FakeManager() - fake_websocket = _FakeWebSocket( - messages=[ - { - "type": "audio_segment", - "data": { - "audio_base64": "dup-seg-0-a", - "segment_index": 0, - "duration": 10, - "is_last": False, - }, - }, - { - "type": "audio_segment", - "data": { - "audio_base64": "dup-seg-0-b", - "segment_index": 0, - "duration": 10, - "is_last": True, - }, - }, - WebSocketDisconnect(), - ] - ) - - process_user_message_mock = AsyncMock() - transcribe_mock = AsyncMock(return_value="重复分段去重测试") - - with ExitStack() as stack: - stack.enter_context( - patch.object( - ws_router, - "verify_token", - return_value={"type": "access", "sub": user.id}, - ) - ) - stack.enter_context( - patch.object( - ws_router, "AsyncSessionLocal", _session_local_factory(fake_db) - ) - ) - stack.enter_context( - patch( - "app.features.conversation.ws.pipeline.AsyncSessionLocal", - _session_local_factory(fake_db), - ) - ) - stack.enter_context(patch.object(ws_router, "manager", fake_manager)) - stack.enter_context(_redis_empty_history_patch()) - stack.enter_context( - patch("app.features.conversation.ws.pipeline.manager", fake_manager) - ) - stack.enter_context( - patch.object( - ws_router, "background_runner", fake_manager.background_runner - ) - ) - stack.enter_context(_redis_empty_history_patch()) - stack.enter_context( - patch( - "app.features.conversation.ws.router.check_ws_quota", - new=AsyncMock(return_value=(True, "")), - ) - ) - stack.enter_context( - patch.object( - ws_router, "process_user_message", process_user_message_mock - ) - ) - stack.enter_context( - patch( - "app.features.conversation.ws.pipeline.process_user_message", - process_user_message_mock, - ) - ) - stack.enter_context( - patch.object( - ws_router, - "get_asr_provider", - lambda: SimpleNamespace(transcribe=transcribe_mock), - ) - ) - stack.enter_context( - patch( - "app.features.conversation.ws.pipeline.get_asr_provider", - lambda: SimpleNamespace(transcribe=transcribe_mock), - ) - ) - - await ws_router.websocket_endpoint(fake_websocket, "conv-1") - await asyncio.sleep(0.05) - - self.assertEqual(transcribe_mock.await_count, 1) - process_user_message_mock.assert_awaited_once() - self.assertEqual( - len([obj for obj in fake_db.added if isinstance(obj, Segment)]), 1 - ) - - async def test_audio_segment_same_index_is_allowed_for_different_voice_sessions( - self, - ): - user = _make_user() - conversation = Conversation(id="conv-1", user_id=user.id, status="active") - fake_db = _FakeAsyncDB(user=user, conversation=conversation) - fake_manager = _FakeManager() - fake_websocket = _FakeWebSocket( - messages=[ - { - "type": "audio_segment", - "data": { - "audio_base64": "session-a-seg-0", - "voice_session_id": "voice-session-a", - "client_segment_id": "voice-session-a-0", - "segment_index": 0, - "duration": 10, - "is_last": True, - }, - }, - { - "type": "audio_segment", - "data": { - "audio_base64": "session-b-seg-0", - "voice_session_id": "voice-session-b", - "client_segment_id": "voice-session-b-0", - "segment_index": 0, - "duration": 8, - "is_last": True, - }, - }, - WebSocketDisconnect(), - ] - ) - - process_user_message_mock = AsyncMock() - transcribe_mock = AsyncMock(side_effect=["第一轮第 0 段", "第二轮第 0 段"]) - - with ExitStack() as stack: - stack.enter_context( - patch.object( - ws_router, - "verify_token", - return_value={"type": "access", "sub": user.id}, - ) - ) - stack.enter_context( - patch.object( - ws_router, "AsyncSessionLocal", _session_local_factory(fake_db) - ) - ) - stack.enter_context( - patch( - "app.features.conversation.ws.pipeline.AsyncSessionLocal", - _session_local_factory(fake_db), - ) - ) - stack.enter_context(patch.object(ws_router, "manager", fake_manager)) - stack.enter_context(_redis_empty_history_patch()) - stack.enter_context( - patch("app.features.conversation.ws.pipeline.manager", fake_manager) - ) - stack.enter_context( - patch.object( - ws_router, "background_runner", fake_manager.background_runner - ) - ) - stack.enter_context(_redis_empty_history_patch()) - stack.enter_context( - patch( - "app.features.conversation.ws.router.check_ws_quota", - new=AsyncMock(return_value=(True, "")), - ) - ) - stack.enter_context( - patch.object( - ws_router, "process_user_message", process_user_message_mock - ) - ) - stack.enter_context( - patch( - "app.features.conversation.ws.pipeline.process_user_message", - process_user_message_mock, - ) - ) - stack.enter_context( - patch.object( - ws_router, - "get_asr_provider", - lambda: SimpleNamespace(transcribe=transcribe_mock), - ) - ) - stack.enter_context( - patch( - "app.features.conversation.ws.pipeline.get_asr_provider", - lambda: SimpleNamespace(transcribe=transcribe_mock), - ) - ) - - await ws_router.websocket_endpoint(fake_websocket, "conv-1") - await asyncio.sleep(0.05) - - ordered_messages = [ - call.kwargs["user_message"] - for call in process_user_message_mock.await_args_list - ] - self.assertEqual(ordered_messages, ["第一轮第 0 段", "第二轮第 0 段"]) - self.assertEqual(transcribe_mock.await_count, 2) - self.assertEqual( - len([obj for obj in fake_db.added if isinstance(obj, Segment)]), 2 - ) - - async def test_audio_segment_sends_transition_feedback_while_processing(self): - user = _make_user() - conversation = Conversation(id="conv-1", user_id=user.id, status="active") - fake_db = _FakeAsyncDB(user=user, conversation=conversation) - fake_manager = _FakeManager() - fake_websocket = _FakeWebSocket( - messages=[ - { - "type": "audio_segment", - "data": { - "audio_base64": "slow-seg-0", - "segment_index": 0, - "duration": 20, - "is_last": True, - }, - }, - WebSocketDisconnect(), - ] - ) - - async def _slow_transcribe(_: str = None, **kwargs) -> str: - await asyncio.sleep(0.2) - return "慢速转写" - - process_user_message_mock = AsyncMock() - transcribe_mock = AsyncMock(side_effect=_slow_transcribe) - - with ExitStack() as stack: - stack.enter_context( - patch.object( - ws_router, - "verify_token", - return_value={"type": "access", "sub": user.id}, - ) - ) - stack.enter_context( - patch.object( - ws_router, "AsyncSessionLocal", _session_local_factory(fake_db) - ) - ) - stack.enter_context( - patch( - "app.features.conversation.ws.pipeline.AsyncSessionLocal", - _session_local_factory(fake_db), - ) - ) - stack.enter_context(patch.object(ws_router, "manager", fake_manager)) - stack.enter_context(_redis_empty_history_patch()) - stack.enter_context( - patch("app.features.conversation.ws.pipeline.manager", fake_manager) - ) - stack.enter_context( - patch.object( - ws_router, "background_runner", fake_manager.background_runner - ) - ) - stack.enter_context(_redis_empty_history_patch()) - stack.enter_context( - patch( - "app.features.conversation.ws.router.check_ws_quota", - new=AsyncMock(return_value=(True, "")), - ) - ) - stack.enter_context( - patch.object( - ws_router, "process_user_message", process_user_message_mock - ) - ) - stack.enter_context( - patch( - "app.features.conversation.ws.pipeline.process_user_message", - process_user_message_mock, - ) - ) - stack.enter_context( - patch.object( - ws_router, - "get_asr_provider", - lambda: SimpleNamespace(transcribe=transcribe_mock), - ) - ) - stack.enter_context( - patch( - "app.features.conversation.ws.pipeline.get_asr_provider", - lambda: SimpleNamespace(transcribe=transcribe_mock), - ) - ) - - await ws_router.websocket_endpoint(fake_websocket, "conv-1") - await asyncio.sleep(0.05) - - # 当前逻辑:仅首个分段且 5s 后发一次「我在认真听」;若本段 is_last 则取消,故此处应为 0 - transition_msgs = [ - item["message"] - for item in fake_manager.sent_messages - if item["message"]["type"] == ws_router.MessageType.AGENT_RESPONSE - and item["message"].get("data", {}).get("transition") is True - ] - self.assertEqual(len(transition_msgs), 0) - - async def test_recording_started_sends_listening_feedback_after_delay(self): - """客户端发送 recording_started 后,延迟 5s 发一次「我在认真听」。""" - user = _make_user() - conversation = Conversation(id="conv-1", user_id=user.id, status="active") - fake_db = _FakeAsyncDB(user=user, conversation=conversation) - fake_manager = _FakeManager() - fake_websocket = _FakeWebSocket( - messages=[ - { - "type": "recording_started", - "data": {"voice_session_id": "session-1"}, - }, - WebSocketDisconnect(), - ] - ) - - with ExitStack() as stack: - stack.enter_context( - patch.object( - ws_router, - "verify_token", - return_value={"type": "access", "sub": user.id}, - ) - ) - stack.enter_context( - patch.object( - ws_router, "AsyncSessionLocal", _session_local_factory(fake_db) - ) - ) - stack.enter_context( - patch( - "app.features.conversation.ws.pipeline.AsyncSessionLocal", - _session_local_factory(fake_db), - ) - ) - stack.enter_context(patch.object(ws_router, "manager", fake_manager)) - stack.enter_context( - patch("app.features.conversation.ws.pipeline.manager", fake_manager) - ) - stack.enter_context( - patch.object( - ws_router, "background_runner", fake_manager.background_runner - ) - ) - stack.enter_context(_redis_empty_history_patch()) - stack.enter_context( - patch( - "app.features.conversation.ws.pipeline.LISTENING_FEEDBACK_DELAY_SEC", - 0.05, - ) - ) - - await ws_router.websocket_endpoint(fake_websocket, "conv-1") - await asyncio.sleep(0.12) - - transition_msgs = [ - item["message"] - for item in fake_manager.sent_messages - if item["message"]["type"] == ws_router.MessageType.AGENT_RESPONSE - and item["message"].get("data", {}).get("transition") is True - ] - self.assertEqual(len(transition_msgs), 1) - self.assertIn("我在认真听", transition_msgs[0]["data"].get("text", "")) - - async def test_audio_segment_last_segment_does_not_emit_terminal_transition(self): - user = _make_user() - conversation = Conversation(id="conv-1", user_id=user.id, status="active") - fake_db = _FakeAsyncDB(user=user, conversation=conversation) - fake_manager = _FakeManager() - fake_websocket = _FakeWebSocket( - messages=[ - { - "type": "audio_segment", - "data": { - "audio_base64": "last-seg-0", - "voice_session_id": "voice-session-last", - "client_segment_id": "voice-session-last-0", - "segment_index": 0, - "duration": 15, - "is_last": True, - }, - }, - WebSocketDisconnect(), - ] - ) - - process_user_message_mock = AsyncMock() - transcribe_mock = AsyncMock(return_value="最后一段转写") - - with ExitStack() as stack: - stack.enter_context( - patch.object( - ws_router, - "verify_token", - return_value={"type": "access", "sub": user.id}, - ) - ) - stack.enter_context( - patch.object( - ws_router, "AsyncSessionLocal", _session_local_factory(fake_db) - ) - ) - stack.enter_context( - patch( - "app.features.conversation.ws.pipeline.AsyncSessionLocal", - _session_local_factory(fake_db), - ) - ) - stack.enter_context(patch.object(ws_router, "manager", fake_manager)) - stack.enter_context(_redis_empty_history_patch()) - stack.enter_context( - patch("app.features.conversation.ws.pipeline.manager", fake_manager) - ) - stack.enter_context( - patch.object( - ws_router, "background_runner", fake_manager.background_runner - ) - ) - stack.enter_context(_redis_empty_history_patch()) - stack.enter_context( - patch( - "app.features.conversation.ws.router.check_ws_quota", - new=AsyncMock(return_value=(True, "")), - ) - ) - stack.enter_context( - patch.object( - ws_router, "process_user_message", process_user_message_mock - ) - ) - stack.enter_context( - patch( - "app.features.conversation.ws.pipeline.process_user_message", - process_user_message_mock, - ) - ) - stack.enter_context( - patch.object( - ws_router, - "get_asr_provider", - lambda: SimpleNamespace(transcribe=transcribe_mock), - ) - ) - stack.enter_context( - patch( - "app.features.conversation.ws.pipeline.get_asr_provider", - lambda: SimpleNamespace(transcribe=transcribe_mock), - ) - ) - - await ws_router.websocket_endpoint(fake_websocket, "conv-1") - await asyncio.sleep(0.05) - - # 仅一段且 is_last:延迟任务被取消,不应发出 transition - transition_msgs = [ - item["message"] - for item in fake_manager.sent_messages - if item["message"]["type"] == ws_router.MessageType.AGENT_RESPONSE - and item["message"].get("data", {}).get("transition") is True - ] - self.assertEqual(len(transition_msgs), 0) - - async def test_audio_segment_continues_after_reconnect_with_existing_previous_segment( - self, - ): - user = _make_user() - conversation = Conversation(id="conv-1", user_id=user.id, status="active") - existing_segment = Segment( - id="seg-existing-0", - conversation_id="conv-1", - transcript_text="已存在的上一段", - audio_url="audio-segment:voice-session-1:0", - processed=False, - ) - fake_db = _FakeAsyncDB( - user=user, - conversation=conversation, - segments=[existing_segment], - ) - fake_manager = _FakeManager() - fake_websocket = _FakeWebSocket( - messages=[ - { - "type": "audio_segment", - "data": { - "audio_base64": "seg-1-after-reconnect", - "voice_session_id": "voice-session-1", - "client_segment_id": "voice-session-1-1", - "segment_index": 1, - "duration": 18, - "is_last": True, - }, - }, - WebSocketDisconnect(), - ] - ) - - process_user_message_mock = AsyncMock() - transcribe_mock = AsyncMock(return_value="这是重连后的第 1 段") - - with ExitStack() as stack: - stack.enter_context( - patch.object( - ws_router, - "verify_token", - return_value={"type": "access", "sub": user.id}, - ) - ) - stack.enter_context( - patch.object( - ws_router, "AsyncSessionLocal", _session_local_factory(fake_db) - ) - ) - stack.enter_context( - patch( - "app.features.conversation.ws.pipeline.AsyncSessionLocal", - _session_local_factory(fake_db), - ) - ) - stack.enter_context(patch.object(ws_router, "manager", fake_manager)) - stack.enter_context(_redis_empty_history_patch()) - stack.enter_context( - patch("app.features.conversation.ws.pipeline.manager", fake_manager) - ) - stack.enter_context( - patch.object( - ws_router, "background_runner", fake_manager.background_runner - ) - ) - stack.enter_context(_redis_empty_history_patch()) - stack.enter_context( - patch( - "app.features.conversation.ws.router.check_ws_quota", - new=AsyncMock(return_value=(True, "")), - ) - ) - stack.enter_context( - patch.object( - ws_router, "process_user_message", process_user_message_mock - ) - ) - stack.enter_context( - patch( - "app.features.conversation.ws.pipeline.process_user_message", - process_user_message_mock, - ) - ) - stack.enter_context( - patch.object( - ws_router, - "get_asr_provider", - lambda: SimpleNamespace(transcribe=transcribe_mock), - ) - ) - stack.enter_context( - patch( - "app.features.conversation.ws.pipeline.get_asr_provider", - lambda: SimpleNamespace(transcribe=transcribe_mock), - ) - ) - - await ws_router.websocket_endpoint(fake_websocket, "conv-1") - await asyncio.sleep(0.05) - - process_user_message_mock.assert_awaited_once() - self.assertEqual( - process_user_message_mock.await_args.kwargs["user_message"], - "这是重连后的第 1 段", - ) - - async def test_audio_segment_reconnect_uses_contiguous_prefix_not_max_index(self): - user = _make_user() - conversation = Conversation(id="conv-1", user_id=user.id, status="active") - existing_segments = [ - Segment( - id="seg-existing-0", - conversation_id="conv-1", - transcript_text="已存在的第 0 段", - audio_url="audio-segment:voice-session-gap:0", - processed=False, - ), - Segment( - id="seg-existing-2", - conversation_id="conv-1", - transcript_text="已存在的第 2 段", - audio_url="audio-segment:voice-session-gap:2", - processed=False, - ), - ] - fake_db = _FakeAsyncDB( - user=user, - conversation=conversation, - segments=existing_segments, - ) - fake_manager = _FakeManager() - fake_websocket = _FakeWebSocket( - messages=[ - { - "type": "audio_segment", - "data": { - "audio_base64": "seg-1-gap-retry", - "voice_session_id": "voice-session-gap", - "client_segment_id": "voice-session-gap-1", - "segment_index": 1, - "duration": 18, - "is_last": False, - }, - }, - WebSocketDisconnect(), - ] - ) - - process_user_message_mock = AsyncMock() - transcribe_mock = AsyncMock(return_value="补传后的第 1 段") - - with ExitStack() as stack: - stack.enter_context( - patch.object( - ws_router, - "verify_token", - return_value={"type": "access", "sub": user.id}, - ) - ) - stack.enter_context( - patch.object( - ws_router, "AsyncSessionLocal", _session_local_factory(fake_db) - ) - ) - stack.enter_context( - patch( - "app.features.conversation.ws.pipeline.AsyncSessionLocal", - _session_local_factory(fake_db), - ) - ) - stack.enter_context(patch.object(ws_router, "manager", fake_manager)) - stack.enter_context(_redis_empty_history_patch()) - stack.enter_context( - patch("app.features.conversation.ws.pipeline.manager", fake_manager) - ) - stack.enter_context( - patch.object( - ws_router, "background_runner", fake_manager.background_runner - ) - ) - stack.enter_context(_redis_empty_history_patch()) - stack.enter_context( - patch( - "app.features.conversation.ws.router.check_ws_quota", - new=AsyncMock(return_value=(True, "")), - ) - ) - stack.enter_context( - patch.object( - ws_router, "process_user_message", process_user_message_mock - ) - ) - stack.enter_context( - patch( - "app.features.conversation.ws.pipeline.process_user_message", - process_user_message_mock, - ) - ) - stack.enter_context( - patch.object( - ws_router, - "get_asr_provider", - lambda: SimpleNamespace(transcribe=transcribe_mock), - ) - ) - stack.enter_context( - patch( - "app.features.conversation.ws.pipeline.get_asr_provider", - lambda: SimpleNamespace(transcribe=transcribe_mock), - ) - ) - - await ws_router.websocket_endpoint(fake_websocket, "conv-1") - await asyncio.sleep(0.05) - - process_user_message_mock.assert_awaited_once() - self.assertEqual( - process_user_message_mock.await_args.kwargs["user_message"], - "补传后的第 1 段", - ) - self.assertEqual(transcribe_mock.await_count, 1) - - -if __name__ == "__main__": - unittest.main() diff --git a/app-expo/src/app/(auth)/login.tsx b/app-expo/src/app/(auth)/login.tsx index 9026d0c..42af415 100644 --- a/app-expo/src/app/(auth)/login.tsx +++ b/app-expo/src/app/(auth)/login.tsx @@ -7,6 +7,7 @@ import { View, } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Link } from 'expo-router'; import { useTranslation } from 'react-i18next'; import { AuthError, NetworkError } from '@/core/api/types'; @@ -240,37 +241,36 @@ export default function LoginScreen() { ) : null} - {/* Terms */} + {/* Terms — min-w-0 so EN text wraps; Link for /legal */} setTermsAccepted(v === true)} - className="mt-0.5 h-8 w-8 rounded-md border-2" + className="mt-0.5 h-8 w-8 shrink-0 rounded-md border-2" indicatorClassName="rounded-md" /> - - {t('login.termsIntro')}{' '} + {}} + className="text-muted-foreground" + style={{ + fontSize: compact ? 15 : 17, + lineHeight: compact ? 22 : 26, + }} > - {t('login.userAgreement')} - {' '} - {t('login.termsAnd')}{' '} - {}} - > - {t('login.privacyPolicy')} + {t('login.termsIntro')}{' '} + + + {t('login.userAgreement')} + + {' '} + {t('login.termsAnd')}{' '} + + + {t('login.privacyPolicy')} + + - + {/* Login button */} diff --git a/app-expo/src/app/(main)/chapter/[id].tsx b/app-expo/src/app/(main)/chapter/[id].tsx index 780352e..7142f65 100644 --- a/app-expo/src/app/(main)/chapter/[id].tsx +++ b/app-expo/src/app/(main)/chapter/[id].tsx @@ -90,7 +90,7 @@ function StorySegmentCover({ asset, contentWidth, }: { - asset: NonNullable; + asset: NonNullable; contentWidth: number; }) { const url = asset?.url; @@ -401,9 +401,9 @@ export default function ChapterScreen() { ); } - const coverImageUrl = chapter.cover_image?.url ?? null; + const coverImageUrl = chapter.cover_asset?.url ?? null; const canonicalMarkdown = (chapter.canonical_markdown ?? '').trim(); - const renderedAssets = chapter.rendered_assets ?? chapter.images ?? []; + const renderedAssets = chapter.images ?? []; const readingSegments = chapter.reading_segments; const useReadingSegments = Array.isArray(readingSegments) && readingSegments.length > 0; @@ -528,9 +528,9 @@ export default function ChapterScreen() { enableDropCap={i === 0} showBottomDivider={false} /> - {seg.cover_image ? ( + {seg.cover_asset ? ( ) : null} diff --git a/app-expo/src/app/(main)/legal/[type].tsx b/app-expo/src/app/(main)/legal/[type].tsx deleted file mode 100644 index a6e02dd..0000000 --- a/app-expo/src/app/(main)/legal/[type].tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { useLocalSearchParams } from 'expo-router'; -import React from 'react'; -import { ActivityIndicator, View } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import WebView from 'react-native-webview'; - -import { ScreenHeader } from '@/components/screen-header'; -import { useLegalDoc } from '@/features/profile/hooks'; -import type { LegalDocType } from '@/features/profile/types'; - -const TITLES: Record = { - terms: '用户协议', - privacy: '隐私政策', -}; - -export default function LegalScreen() { - const { type } = useLocalSearchParams<{ type: string }>(); - const docType = ( - type === 'terms' || type === 'privacy' ? type : 'terms' - ) as LegalDocType; - // TODO: 连接不上后端时可能一直 loading,需加超时或展示错误态 - const { data: html, isLoading } = useLegalDoc(docType); - - return ( - - - - - {isLoading && ( - - - - )} - - {html && ( - - )} - - - ); -} diff --git a/app-expo/src/app/(tabs)/memoir.tsx b/app-expo/src/app/(tabs)/memoir.tsx index c936124..a80dbb8 100644 --- a/app-expo/src/app/(tabs)/memoir.tsx +++ b/app-expo/src/app/(tabs)/memoir.tsx @@ -1,6 +1,12 @@ import { Image } from 'expo-image'; import { router } from 'expo-router'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { Platform, Pressable, @@ -11,17 +17,18 @@ import { } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useTranslation } from 'react-i18next'; -import { BookOpen, FileText } from 'lucide-react-native'; +import { FileText } from 'lucide-react-native'; import { Icon } from '@/components/ui/icon'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; import { ScreenGutter } from '@/constants/layout'; import { useCreateConversation } from '@/features/conversation/hooks'; +import { buildFrameworkChapterPlaceholders } from '@/features/memoir/framework-chapter-keys'; import { useChapters, useCheckCoverGeneration } from '@/features/memoir/hooks'; import type { ChapterViewModel } from '@/features/memoir/types'; -type ChapterVariant = 'completed' | 'drafting' | 'locked-left' | 'locked-large'; +type ChapterVariant = 'completed' | 'drafting'; function getChapterVariant(vm: ChapterViewModel): ChapterVariant { if (!vm.isEmpty) return 'completed'; @@ -29,11 +36,7 @@ function getChapterVariant(vm: ChapterViewModel): ChapterVariant { } function getWordCount(vm: ChapterViewModel): number { - if (vm.wordCount > 0) return vm.wordCount; - return (vm.sections ?? []).reduce( - (sum, s) => sum + (s.content?.length ?? 0), - 0, - ); + return vm.wordCount; } function formatWordCount(count: number): string { @@ -44,33 +47,6 @@ function formatWordCount(count: number): string { return count.toLocaleString(); } -function MemoirImagePlaceholder({ - size, - muted = false, -}: { - size: number; - muted?: boolean; -}) { - return ( - - 70 ? 32 : 24} - /> - - ); -} - function ChapterCardSkeleton() { return ( @@ -125,7 +101,6 @@ function ChapterCard({ if (variant === 'completed') { const hasCoverImage = !!item.coverImageUrl; - const imageAreaHeight = hasCoverImage ? 192 : 96; return ( - - {hasCoverImage ? ( + {hasCoverImage ? ( + - ) : ( - - )} - + + ) : null} - - - - - - {chapterLabel} - - - {item.title} - - - - - {t('statusDrafting')} - - + + + + {chapterLabel} + + + {item.title} + + + + + {t('statusDrafting')} + ); } - - if (variant === 'locked-left') { - return ( - - - - - {chapterLabel} - - - {item.title} - - - - {t('statusLocked')} - - - - - - - - {t('startChapter')} - - - - ); - } - - // locked-large - return ( - - - - - - - - {chapterLabel} - - - {item.title} - - - - - {t('statusPending')} - - - - - {t('startChapter')} - - - - - ); + return null; } -function EmptyState({ - t, - onStart, - disabled, -}: { - t: (key: string) => string; - onStart: () => void; - disabled?: boolean; -}) { +function MemoirLoadError({ onRetry }: { onRetry: () => void }) { + const { t } = useTranslation('memoir'); return ( - - - - - {t('emptyTitle')} + + + {t('loadErrorMessage')} + + + + {t('loadErrorRetry')} - - {t('emptySubtitle')} - - - + + ); } export default function MemoirScreen() { const { t } = useTranslation('memoir'); - const { viewModels: chapters, isLoading, refetch } = useChapters(); + const { viewModels: chapters, isLoading, isError, refetch } = useChapters(); const createConversation = useCreateConversation(); const checkCover = useCheckCoverGeneration(); const [refreshing, setRefreshing] = useState(false); const didRunInitialCoverCheckRef = useRef(false); + const frameworkPlaceholders = useMemo( + () => buildFrameworkChapterPlaceholders(t as (key: string) => string), + [t], + ); + useEffect(() => { if (didRunInitialCoverCheckRef.current) return; didRunInitialCoverCheckRef.current = true; @@ -417,17 +279,17 @@ export default function MemoirScreen() { } }, [checkCover, refetch]); - const handleStartChapter = () => { + const handleStartChapter = useCallback(() => { createConversation.mutate(undefined, { onSuccess: (result) => { router.push(`/(main)/conversation/${result.id}`); }, }); - }; + }, [createConversation]); - const handleReadChapter = (chapterId: string) => { + const handleReadChapter = useCallback((chapterId: string) => { router.push(`/(main)/chapter/${chapterId}`); - }; + }, []); return ( @@ -443,7 +305,7 @@ export default function MemoirScreen() { paddingTop: 24, paddingBottom: 96, gap: 24, - ...(chapters.length === 0 && !isLoading + ...(!isLoading && isError ? { flexGrow: 1, justifyContent: 'center' } : {}), }} @@ -454,12 +316,19 @@ export default function MemoirScreen() { + ) : isError ? ( + void refetch()} /> ) : chapters.length === 0 ? ( - string} - onStart={handleStartChapter} - disabled={createConversation.isPending} - /> + frameworkPlaceholders.map((item) => ( + string} + onReadPress={() => handleReadChapter(item.id)} + onContinuePress={handleStartChapter} + /> + )) ) : ( chapters.map((item) => ( + diff --git a/app-expo/src/app/legal/[type].tsx b/app-expo/src/app/legal/[type].tsx new file mode 100644 index 0000000..87840b5 --- /dev/null +++ b/app-expo/src/app/legal/[type].tsx @@ -0,0 +1,79 @@ +import { useLocalSearchParams, router } from 'expo-router'; +import React, { useEffect, useMemo } from 'react'; +import { ActivityIndicator, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import WebView from 'react-native-webview'; +import { useTranslation } from 'react-i18next'; + +import { ScreenHeader } from '@/components/screen-header'; +import { useLegalDoc } from '@/features/profile/hooks'; +import type { LegalDocType } from '@/features/profile/types'; + +function normalizeType(raw: string | string[] | undefined): string { + if (Array.isArray(raw)) return raw[0] ?? ''; + return raw ?? ''; +} + +export default function LegalScreen() { + const { t } = useTranslation('legal'); + const { type } = useLocalSearchParams<{ type: string }>(); + const rawType = useMemo(() => normalizeType(type), [type]); + const ready = rawType !== ''; + const isValid = rawType === 'terms' || rawType === 'privacy'; + + useEffect(() => { + if (ready && !isValid) { + router.replace('/legal/terms'); + } + }, [ready, isValid]); + + const docType: LegalDocType = isValid ? rawType : 'terms'; + + const { data: html, isLoading } = useLegalDoc(docType, { + enabled: ready && isValid, + }); + + const title = docType === 'privacy' ? t('titlePrivacy') : t('titleTerms'); + + if (ready && !isValid) { + return ( + + + + + + ); + } + + if (!ready) { + return ( + + + + + + ); + } + + return ( + + + + + {isLoading && ( + + + + )} + + {html && ( + + )} + + + ); +} diff --git a/app-expo/src/app/legal/_layout.tsx b/app-expo/src/app/legal/_layout.tsx new file mode 100644 index 0000000..a912dc5 --- /dev/null +++ b/app-expo/src/app/legal/_layout.tsx @@ -0,0 +1,6 @@ +import { Stack } from 'expo-router'; +import React from 'react'; + +export default function LegalLayout() { + return ; +} diff --git a/app-expo/src/components/ui/alert-dialog.tsx b/app-expo/src/components/ui/alert-dialog.tsx index 9bb18f5..98de67b 100644 --- a/app-expo/src/components/ui/alert-dialog.tsx +++ b/app-expo/src/components/ui/alert-dialog.tsx @@ -28,14 +28,17 @@ function AlertDialogOverlay({ <>{children} @@ -58,9 +61,10 @@ function AlertDialogContent({ - + ); } @@ -94,7 +101,7 @@ function AlertDialogTitle({ }: AlertDialogPrimitive.TitleProps & React.RefAttributes) { return ( ); @@ -102,12 +109,21 @@ function AlertDialogTitle({ function AlertDialogDescription({ className, + style, ...props }: AlertDialogPrimitive.DescriptionProps & React.RefAttributes) { return ( ); diff --git a/app-expo/src/features/memoir/api.ts b/app-expo/src/features/memoir/api.ts index 08755a4..82266e9 100644 --- a/app-expo/src/features/memoir/api.ts +++ b/app-expo/src/features/memoir/api.ts @@ -54,12 +54,6 @@ export const memoirApi = { ); }, - regenerateChapter(chapterId: string) { - return api.post<{ status: string; message: string }>( - `/api/chapters/${chapterId}/regenerate`, - ); - }, - fetchMemoirState() { return api.get('/api/memoir-state'); }, diff --git a/app-expo/src/features/memoir/framework-chapter-keys.ts b/app-expo/src/features/memoir/framework-chapter-keys.ts new file mode 100644 index 0000000..32ef7f5 --- /dev/null +++ b/app-expo/src/features/memoir/framework-chapter-keys.ts @@ -0,0 +1,37 @@ +import type { ChapterViewModel } from './types'; + +/** + * 与后端 `CHAPTER_ORDER` / `CHAPTER_CATEGORIES` 顺序一致;对应 i18n `memoir.frameworkChapters.*`。 + */ +export const FRAMEWORK_CHAPTER_KEYS = [ + 'chapter1', + 'chapter2', + 'chapter3', + 'chapter4', + 'chapter5', + 'chapter6', + 'chapter7', + 'chapter8', +] as const; + +export type FrameworkChapterKey = (typeof FRAMEWORK_CHAPTER_KEYS)[number]; + +export function buildFrameworkChapterPlaceholders( + tr: (key: string) => string, +): ChapterViewModel[] { + return FRAMEWORK_CHAPTER_KEYS.map((key, orderIndex) => ({ + id: `framework:${key}`, + title: tr(`frameworkChapters.${key}`), + category: '', + orderIndex, + isEmpty: true, + isNew: false, + hasImages: false, + allImagesReady: false, + pendingImageCount: 0, + failedImageCount: 0, + coverImageUrl: null, + updatedAt: null, + wordCount: 0, + })); +} diff --git a/app-expo/src/features/memoir/mappers.ts b/app-expo/src/features/memoir/mappers.ts index 38b483a..c4b9348 100644 --- a/app-expo/src/features/memoir/mappers.ts +++ b/app-expo/src/features/memoir/mappers.ts @@ -6,28 +6,24 @@ function countByStatus(images: ImageAsset[], status: string): number { export function toChapterViewModel(chapter: Chapter): ChapterViewModel { const images = chapter.images ?? []; - const cover = chapter.cover_image ?? chapter.cover_asset ?? null; + const cover = chapter.cover_asset ?? null; const imagesForStatus = cover ? [cover, ...images] : images; const completedCount = countByStatus(imagesForStatus, 'completed'); const hasContent = !!(chapter.canonical_markdown ?? '').trim() || - !!(chapter.content ?? '').trim() || !!(chapter.summary ?? '').trim(); - const wordCountFromSections = (chapter.sections ?? []).reduce( - (sum, s) => sum + (s.content?.length ?? 0), - 0, - ); + const wordCountFromMarkdown = (chapter.canonical_markdown ?? '').length; const wordCount = typeof chapter.word_count === 'number' && chapter.word_count >= 0 ? chapter.word_count - : wordCountFromSections; + : wordCountFromMarkdown; return { id: chapter.id, title: chapter.title, category: chapter.category, orderIndex: chapter.order_index, - isEmpty: chapter.status === 'empty' || !hasContent, + isEmpty: !hasContent, isNew: chapter.is_new, hasImages: imagesForStatus.length > 0, allImagesReady: @@ -36,7 +32,6 @@ export function toChapterViewModel(chapter: Chapter): ChapterViewModel { countByStatus(imagesForStatus, 'pending') + countByStatus(imagesForStatus, 'processing'), failedImageCount: countByStatus(imagesForStatus, 'failed'), - sections: chapter.sections ?? [], coverImageUrl: cover?.url ?? null, updatedAt: chapter.updated_at, wordCount, diff --git a/app-expo/src/features/memoir/markdown-renderer.tsx b/app-expo/src/features/memoir/markdown-renderer.tsx index c5cdee9..94517e8 100644 --- a/app-expo/src/features/memoir/markdown-renderer.tsx +++ b/app-expo/src/features/memoir/markdown-renderer.tsx @@ -1,6 +1,6 @@ /** * Markdown 渲染器:使用 react-native-markdown-display 渲染 canonical_markdown。 - * 线上正文以 asset:// 或已解析的 https 为准;遗留 {{IMAGE:...}} 仅从展示层剥离,不作为协议。 + * 线上正文以 asset:// 或已解析的 https 为准;{{IMAGE:...}} 仅从展示层剥离,不作为协议。 */ import { Image } from 'expo-image'; @@ -25,13 +25,13 @@ function buildPlaceholderToAssetMap( return map; } -/** 移除遗留 IMAGE 占位符(不参与正文协议)。 */ -export function stripLegacyImagePlaceholders(markdown: string): string { +/** 移除 IMAGE 占位符(不参与正文协议)。 */ +export function stripImagePlaceholders(markdown: string): string { return markdown.replace(PLACEHOLDER_RE, '').replace(/\n{3,}/g, '\n\n'); } /** - * 预处理正文:先用 assets 替换可匹配的遗留占位符,再剥离剩余占位符。 + * 预处理正文:先用 assets 替换可匹配的占位符,再剥离剩余占位符。 */ export function replaceImagePlaceholders( markdown: string, @@ -47,7 +47,7 @@ export function replaceImagePlaceholders( return `![${caption}](${asset.url})`; }); } - return stripLegacyImagePlaceholders(out); + return stripImagePlaceholders(out); } /** 顶层正文段落(body 直属,非列表/引用内)用于首行缩进 */ diff --git a/app-expo/src/features/memoir/types.ts b/app-expo/src/features/memoir/types.ts index a66719b..1f22dc5 100644 --- a/app-expo/src/features/memoir/types.ts +++ b/app-expo/src/features/memoir/types.ts @@ -44,37 +44,26 @@ export interface ImageAsset { updated_at: string | null; } -export interface ChapterSection { - content: string; - image: ImageAsset | null; -} - /** 章节详情:与 chapter_story_links 顺序一致,每段故事正文 + 主配图 */ export interface ChapterReadingSegment { story_id: string; body_markdown: string; - cover_image: ImageAsset | null; + cover_asset: ImageAsset | null; } export interface Chapter { id: string; title: string; - content: string; order_index: number; status: string; category: string; images: ImageAsset[]; - cover_image: ImageAsset | null; - /** 列表接口与 cover_image 同构(资产化封面) */ - cover_asset?: ImageAsset | null; - sections: ChapterSection[]; + cover_asset: ImageAsset | null; summary?: string; /** 列表接口:与 canonical 一致的字符规模(后端 word_count) */ word_count?: number; /** 正文真源,优先用于渲染 */ canonical_markdown?: string | null; - /** 图片等资源映射,与 canonical_markdown 配合使用 */ - rendered_assets?: ImageAsset[]; /** 有 story 编排时的分段阅读(正文不含故事标题,配图按故事) */ reading_segments?: ChapterReadingSegment[]; updated_at: string | null; @@ -138,9 +127,8 @@ export interface ChapterViewModel { allImagesReady: boolean; pendingImageCount: number; failedImageCount: number; - sections: ChapterSection[]; coverImageUrl: string | null; updatedAt: string | null; - /** 优先使用列表接口的 word_count,否则由 sections 推算 */ + /** 优先使用列表接口的 word_count,否则由 canonical_markdown 推算 */ wordCount: number; } diff --git a/app-expo/src/features/profile/hooks.ts b/app-expo/src/features/profile/hooks.ts index dbb52eb..d41fbd9 100644 --- a/app-expo/src/features/profile/hooks.ts +++ b/app-expo/src/features/profile/hooks.ts @@ -107,10 +107,14 @@ export function usePurgeUserData() { // ─── Legal ─── -export function useLegalDoc(type: LegalDocType) { +export function useLegalDoc( + type: LegalDocType, + options?: { enabled?: boolean }, +) { return useQuery({ queryKey: profileKeys.legal(type), queryFn: () => profileApi.fetchLegalDoc(type), staleTime: Infinity, + enabled: options?.enabled ?? true, }); } diff --git a/app-expo/src/features/voice/player.ts b/app-expo/src/features/voice/player.ts deleted file mode 100644 index 0d2c08e..0000000 --- a/app-expo/src/features/voice/player.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Player is now fully implemented in hooks/use-player.ts as a self-contained - * React hook. expo-audio's hook-centric API (useAudioPlayer + useAudioPlayerStatus) - * makes a class-level player impractical — completion detection, source replacement, - * and lifecycle management all need React context. - * - * This file re-exports the hook for backward compatibility with any imports. - */ -export { usePlayer } from './hooks/use-player'; diff --git a/app-expo/src/features/voice/voice-segment-store.ts b/app-expo/src/features/voice/voice-segment-store.ts index 676d3f0..9d62907 100644 --- a/app-expo/src/features/voice/voice-segment-store.ts +++ b/app-expo/src/features/voice/voice-segment-store.ts @@ -18,21 +18,6 @@ const CREATE_TABLE_SQL = ` let initialized = false; -async function migrateLegacyVoiceMessageLocal(): Promise { - const rows = await querySql<{ name: string }>( - `SELECT name FROM sqlite_master WHERE type='table' AND name='voice_message_local'`, - ); - if (rows.length === 0) return; - const now = Date.now(); - await executeSql( - `INSERT OR IGNORE INTO segment_outbox (conversation_id, voice_session_id, segment_index, file_uri, duration_ms, status, retry_count, created_at) - SELECT conversation_id, voice_session_id, 0, file_uri, duration_ms, 'sent', 0, ? - FROM voice_message_local`, - [now], - ); - await executeSql(`DROP TABLE IF EXISTS voice_message_local`); -} - async function ensureTable(): Promise { if (initialized) return; await executeSql(CREATE_TABLE_SQL); @@ -40,7 +25,6 @@ async function ensureTable(): Promise { `CREATE UNIQUE INDEX IF NOT EXISTS uq_segment_outbox_voice_session_segment ON segment_outbox(voice_session_id, segment_index)`, ); - await migrateLegacyVoiceMessageLocal(); initialized = true; } @@ -192,6 +176,3 @@ export const voiceSegmentStore = { initialized = false; }, } as const; - -/** @deprecated 使用 voiceSegmentStore */ -export const segmentOutbox = voiceSegmentStore; diff --git a/app-expo/src/i18n/generated/resources.ts b/app-expo/src/i18n/generated/resources.ts index 70290db..85df3fe 100644 --- a/app-expo/src/i18n/generated/resources.ts +++ b/app-expo/src/i18n/generated/resources.ts @@ -99,6 +99,10 @@ interface Resources { }; explore: {}; home: {}; + legal: { + titlePrivacy: 'Privacy Policy'; + titleTerms: 'User Agreement'; + }; memoir: { chapterLabel: 'Chapter {{index}}'; chapterReading: { @@ -125,6 +129,18 @@ interface Resources { continueWriting: 'Continue Writing'; emptySubtitle: 'Chat with Echo to record your stories'; emptyTitle: 'No memoir yet'; + frameworkChapters: { + chapter1: 'Childhood and upbringing'; + chapter2: 'Education and young adulthood'; + chapter3: 'Early career'; + chapter4: 'Major achievements and peak moments'; + chapter5: 'Setbacks, challenges, and turning points'; + chapter6: 'Family and relationships'; + chapter7: 'Beliefs and values'; + chapter8: 'Life summary'; + }; + loadErrorMessage: 'Could not load chapters'; + loadErrorRetry: 'Retry'; pageTitle: 'Memoir'; readMemory: 'Read Memory'; startChapter: 'Start Writing'; diff --git a/app-expo/src/i18n/locales/en/legal.json b/app-expo/src/i18n/locales/en/legal.json new file mode 100644 index 0000000..ed2e2ba --- /dev/null +++ b/app-expo/src/i18n/locales/en/legal.json @@ -0,0 +1,4 @@ +{ + "titleTerms": "User Agreement", + "titlePrivacy": "Privacy Policy" +} diff --git a/app-expo/src/i18n/locales/en/memoir.json b/app-expo/src/i18n/locales/en/memoir.json index d5aadfd..197f309 100644 --- a/app-expo/src/i18n/locales/en/memoir.json +++ b/app-expo/src/i18n/locales/en/memoir.json @@ -1,4 +1,16 @@ { + "frameworkChapters": { + "chapter1": "Childhood and upbringing", + "chapter2": "Education and young adulthood", + "chapter3": "Early career", + "chapter4": "Major achievements and peak moments", + "chapter5": "Setbacks, challenges, and turning points", + "chapter6": "Family and relationships", + "chapter7": "Beliefs and values", + "chapter8": "Life summary" + }, + "loadErrorMessage": "Could not load chapters", + "loadErrorRetry": "Retry", "chapterLabel": "Chapter {{index}}", "chapterReading": { "back": "Back", diff --git a/app-expo/src/i18n/locales/zh/legal.json b/app-expo/src/i18n/locales/zh/legal.json new file mode 100644 index 0000000..a299192 --- /dev/null +++ b/app-expo/src/i18n/locales/zh/legal.json @@ -0,0 +1,4 @@ +{ + "titleTerms": "用户协议", + "titlePrivacy": "隐私政策" +} diff --git a/app-expo/src/i18n/locales/zh/memoir.json b/app-expo/src/i18n/locales/zh/memoir.json index 2451b51..327a229 100644 --- a/app-expo/src/i18n/locales/zh/memoir.json +++ b/app-expo/src/i18n/locales/zh/memoir.json @@ -1,4 +1,16 @@ { + "frameworkChapters": { + "chapter1": "童年与成长背景", + "chapter2": "教育经历与青年时期", + "chapter3": "崭露头角", + "chapter4": "主要成就与巅峰时刻", + "chapter5": "挫折、挑战与重大转折", + "chapter6": "家庭与情感", + "chapter7": "信念与价值观", + "chapter8": "人生总结" + }, + "loadErrorMessage": "无法加载章节列表", + "loadErrorRetry": "重试", "chapterLabel": "第 {{index}} 章", "chapterReading": { "back": "返回", diff --git a/app-expo/src/i18n/resources/index.ts b/app-expo/src/i18n/resources/index.ts index 374558f..29cd61c 100644 --- a/app-expo/src/i18n/resources/index.ts +++ b/app-expo/src/i18n/resources/index.ts @@ -4,6 +4,7 @@ import commonEn from '../locales/en/common.json'; import conversationEn from '../locales/en/conversation.json'; import exploreEn from '../locales/en/explore.json'; import homeEn from '../locales/en/home.json'; +import legalEn from '../locales/en/legal.json'; import memoirEn from '../locales/en/memoir.json'; import profileEn from '../locales/en/profile.json'; import appZh from '../locales/zh/app.json'; @@ -12,6 +13,7 @@ import commonZh from '../locales/zh/common.json'; import conversationZh from '../locales/zh/conversation.json'; import exploreZh from '../locales/zh/explore.json'; import homeZh from '../locales/zh/home.json'; +import legalZh from '../locales/zh/legal.json'; import memoirZh from '../locales/zh/memoir.json'; import profileZh from '../locales/zh/profile.json'; @@ -28,6 +30,7 @@ export const namespaces = [ 'conversation', 'home', 'explore', + 'legal', 'memoir', 'profile', ] as const; @@ -42,6 +45,7 @@ export const resources = { conversation: conversationZh, home: homeZh, explore: exploreZh, + legal: legalZh, memoir: memoirZh, profile: profileZh, }, @@ -52,6 +56,7 @@ export const resources = { conversation: conversationEn, home: homeEn, explore: exploreEn, + legal: legalEn, memoir: memoirEn, profile: profileEn, }, diff --git a/app-expo/tests/features/memoir/mappers.test.ts b/app-expo/tests/features/memoir/mappers.test.ts index fb1d770..99210ef 100644 --- a/app-expo/tests/features/memoir/mappers.test.ts +++ b/app-expo/tests/features/memoir/mappers.test.ts @@ -24,13 +24,12 @@ function makeChapter(overrides: Partial = {}): Chapter { return { id: 'ch-1', title: '童年', - content: '一些内容', order_index: 0, status: 'ready', category: 'childhood', images: [], - cover_image: null, - sections: [{ content: '段落1', image: null }], + cover_asset: null, + canonical_markdown: '段落1', updated_at: '2026-01-01T00:00:00Z', is_new: false, source_segments: [], @@ -48,14 +47,12 @@ describe('toChapterViewModel', () => { expect(vm.orderIndex).toBe(0); expect(vm.isEmpty).toBe(false); expect(vm.isNew).toBe(false); - expect(vm.sections).toHaveLength(1); expect(vm.wordCount).toBe('段落1'.length); }); - test('uses word_count from API when sections empty', () => { + test('uses word_count from API when canonical markdown exists', () => { const vm = toChapterViewModel( makeChapter({ - sections: [], word_count: 1200, canonical_markdown: 'x'.repeat(1200), }), @@ -63,19 +60,17 @@ describe('toChapterViewModel', () => { expect(vm.wordCount).toBe(1200); }); - test('cover_asset mirrors cover_image for list payload', () => { + test('uses cover_asset from list payload', () => { const cover = makeImage({ url: 'https://example.com/from-asset.jpg' }); const vm = toChapterViewModel( - makeChapter({ cover_image: null, cover_asset: cover, images: [] }), + makeChapter({ cover_asset: cover, images: [] }), ); expect(vm.coverImageUrl).toBe('https://example.com/from-asset.jpg'); expect(vm.hasImages).toBe(true); }); test('detects empty chapters', () => { - const vm = toChapterViewModel( - makeChapter({ status: 'empty', content: '' }), - ); + const vm = toChapterViewModel(makeChapter({ canonical_markdown: '' })); expect(vm.isEmpty).toBe(true); }); @@ -83,8 +78,6 @@ describe('toChapterViewModel', () => { const vm = toChapterViewModel( makeChapter({ status: 'ready', - content: '', - sections: [], canonical_markdown: '# 童年\n\n一段回忆。', }), ); @@ -122,13 +115,13 @@ describe('toChapterViewModel', () => { test('extracts cover image URL', () => { const cover = makeImage({ url: 'https://example.com/cover.jpg' }); - const vm = toChapterViewModel(makeChapter({ cover_image: cover })); + const vm = toChapterViewModel(makeChapter({ cover_asset: cover })); expect(vm.coverImageUrl).toBe('https://example.com/cover.jpg'); }); test('cover image URL is null when no cover', () => { - const vm = toChapterViewModel(makeChapter({ cover_image: null })); + const vm = toChapterViewModel(makeChapter({ cover_asset: null })); expect(vm.coverImageUrl).toBeNull(); }); }); diff --git a/docs/plans/2026-03-19-story-first-markdown-first-design.md b/docs/plans/2026-03-19-story-first-markdown-first-design.md index 5063ebd..21e7404 100644 --- a/docs/plans/2026-03-19-story-first-markdown-first-design.md +++ b/docs/plans/2026-03-19-story-first-markdown-first-design.md @@ -128,11 +128,11 @@ flowchart LR 异步任务 `process_memoir_segments` 不再写入 `chapter_sections`。每个「章节类别 + 本批 segments」在 Celery 内走统一流水线(见 `story_pipeline_sync.run_story_pipeline_for_category_batch`): -1. **检索**:`retrieve_evidence_sync` 提供 RAG evidence,与 transcript 合并为叙事输入。 -2. **路由**:`StoryRouteAgent` 读取用户全部 `active` stories(标题 + 正文摘要 + 已关联章节提示),结构化输出 `new_story` 或 `append_story`(`target_story_id` 必须在候选列表中,否则回退 `new_story`)。 -3. **叙事**:`NarrativeAgent` 产出 JSON/文本,经 `narrative_to_markdown` 规范为 markdown。 -4. **落库**:`create_story_with_version_sync` 或 `append_story_version_sync`,并 `ensure_chapter_story_link_sync` 将当前类别下的 active 章节与该 story 关联(若尚未关联则追加 link);再 `compose_chapter_from_story_links_sync` 物化 `chapters.canonical_markdown`。 -5. **后续**:commit 后派发 `generate_story_image`、`recompose_chapters_for_story`;章节补图任务 `generate_chapter_images` 仅处理**章节级** MemoirImage(封面槽位,按 `order_index`),正文插图由 `story_image_tasks` 消费 story 主图 intent。 +1. **检索**:`retrieve_evidence_sync` 提供 RAG evidence,与 transcript 合并为叙事输入(整批检索一次;可按 unit 复用同一份 evidence 文本)。 +2. **路由(批量规划)**:当同一章节类别下 **≥2 条 segment** 且未超过上限时,`StoryRouteAgent.plan_batch` 将本批 segment(带 id、按口述顺序)划分为若干 **连续块**,每块输出 `new_story` 或 `append_story`(`target_story_id` 必须来自候选列表),使「故事」与「可独立讲述的一段人生经历」对齐。解析或校验失败时回退为单次 `StoryRouteAgent.decide`(与旧行为一致)。**单条 segment** 直接走单次 `decide`,不额外调用批量规划。 +3. **叙事**:对每个写入单元,`NarrativeAgent` 仅接收该单元合并后的 transcript(+ evidence),产出 JSON/文本,经 `narrative_to_markdown` 规范为 markdown。 +4. **落库**:对每个单元依次 `create_story_with_version_sync` 或 `append_story_version_sync`,并 `ensure_chapter_story_link_sync` 将当前类别下的 active 章节与各 story 关联;最后 **`compose_chapter_from_story_links_sync` 调用一次** 物化 `chapters.canonical_markdown`。 +5. **后续**:commit 后派发 `generate_story_image`、`recompose_chapters_for_story`;章节封面由 `try_enqueue_generate_chapter_cover` → **`generate_chapter_cover`**(`chapter_cover_intents` + asset),正文插图由 `story_image_tasks` 消费 story 主图 intent。 对话侧多 Agent 仍负责访谈节奏与槽位;与 Celery 的衔接点是 segment 落库与异步任务,不共享同一套「写 story」代码路径。 diff --git a/docs/plans/2026-03-21-multi-agent-convergence.md b/docs/plans/2026-03-21-multi-agent-convergence.md new file mode 100644 index 0000000..a2bac2d --- /dev/null +++ b/docs/plans/2026-03-21-multi-agent-convergence.md @@ -0,0 +1,16 @@ +# 2026-03-21 Multi-Agent Convergence(已完成) + +> 该计划已完成。为减少后续搜索和维护噪音,这里只保留结果摘要,不再保留迁移过程中的逐项旧路径说明。 + +## 收敛结果 + +- Chat 只保留 `ChatOrchestrator` 作为实时编排入口。 +- 会话历史只保留 `conversation_messages` 为 DB 真源,Redis 为缓存。 +- Memoir 只保留 `MemoirOrchestrator.prepare_batches` + `run_story_pipeline_for_category_batch` 主链路。 +- 图片只保留 `generate_story_image` 与 `generate_chapter_cover` 正式任务。 + +## 清理结果 + +- 旧 facade、旧 agent-layer runner、旧章节补图任务名均已移除。 +- 旧的双轨历史重建逻辑已删除。 +- 设计/计划文档已改成归档摘要,避免继续传播过时工作流。 diff --git a/docs/plans/2026-03-21-remove-memoir-compatibility-layers.md b/docs/plans/2026-03-21-remove-memoir-compatibility-layers.md new file mode 100644 index 0000000..6f239a8 --- /dev/null +++ b/docs/plans/2026-03-21-remove-memoir-compatibility-layers.md @@ -0,0 +1,106 @@ +# Memoir Compatibility Cleanup Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Remove remaining memoir stories-first compatibility layers so runtime only serves the current `story -> chapter` contract. + +**Architecture:** Tighten the backend around story-linked chapters only, remove transitional serializer fields and dead endpoints, then update Expo consumers to rely only on canonical markdown, reading segments, and cover assets. Keep fallback logic only where it protects current-state correctness rather than old schema compatibility. + +**Tech Stack:** FastAPI, SQLAlchemy, Alembic, Expo Router, React Query, TypeScript, Jest + +--- + +### Task 1: Remove obsolete backend runtime paths + +**Files:** +- Modify: `api/app/features/memoir/service.py` +- Modify: `api/app/features/memoir/router.py` +- Modify: `api/app/features/memoir/repo.py` +- Modify: `api/app/features/memoir/chapter_cover.py` +- Modify: `api/app/tasks/chapter_cover_tasks.py` +- Modify: `api/app/tasks/chapter_cover_enqueue.py` +- Modify: `api/app/features/memoir/cover_eligibility.py` + +**Step 1: Remove dead whole-chapter regeneration path** + +Delete `MemoirService.regenerate_chapter` and the `/api/chapters/{chapter_id}/regenerate` route because stories-first no longer supports a separate chapter rewrite pipeline. + +**Step 2: Stop returning placeholder chapters** + +Update `MemoirService.get_chapters` to return real active chapters only. Remove `placeholder_*` records and related `empty` compatibility status generation. + +**Step 3: Require story-linked chapters for chapter-cover generation** + +Remove chapter-content fallback prompt generation and refuse enqueue/generation when a chapter has no `story_links`. + +**Step 4: Remove unused non-story chapter write helpers** + +Delete old repo helpers kept for the removed chapter-generation flow if they no longer have call sites. + +### Task 2: Tighten chapter API serialization + +**Files:** +- Modify: `api/app/features/memoir/helpers.py` +- Modify: `api/app/features/memoir/models.py` +- Modify: `api/app/features/memoir/pdf_service.py` +- Modify: `api/alembic/versions/0001_initial_schema.py` + +**Step 1: Remove legacy response fields** + +Stop serializing `sections`, `content`, and `rendered_assets` from chapter payloads when those fields are no longer part of the stories-first contract. + +**Step 2: Remove `cover_image` JSON fallback usage** + +Drop runtime dependence on legacy `cover_image` snapshots and keep `cover_asset_id` as the only supported chapter-cover source. + +**Step 3: Simplify PDF generation inputs** + +Consume canonical markdown directly instead of section-based compatibility helpers. + +### Task 3: Update Expo memoir consumers + +**Files:** +- Modify: `app-expo/src/features/memoir/types.ts` +- Modify: `app-expo/src/features/memoir/mappers.ts` +- Modify: `app-expo/src/features/memoir/api.ts` +- Modify: `app-expo/src/app/(tabs)/memoir.tsx` +- Modify: `app-expo/src/app/(main)/chapter/[id].tsx` + +**Step 1: Remove old chapter fields from types** + +Delete `ChapterSection`, remove `sections` and `content` from chapter contracts, and simplify `ChapterViewModel`. + +**Step 2: Remove sections-based view model fallbacks** + +Compute emptiness and word count from canonical markdown and summary only. + +**Step 3: Remove dead UI branches** + +Delete unreachable locked variants and unused API methods tied to removed backend endpoints. + +### Task 4: Refresh tests and verify + +**Files:** +- Modify: `app-expo/tests/features/memoir/mappers.test.ts` + +**Step 1: Update mapper tests** + +Rewrite fixtures and assertions to match the new stories-first payload shape. + +**Step 2: Run focused verification** + +Run: + +```bash +cd /Users/timcook/Codes/hgtk/life-echo/app-expo && npm run test:ci -- --runTestsByPath tests/features/memoir/mappers.test.ts +``` + +Run: + +```bash +cd /Users/timcook/Codes/hgtk/life-echo/app-expo && npm run lint -- src/features/memoir src/app/\(tabs\)/memoir.tsx src/app/\(main\)/chapter/\[id\].tsx +``` + +Expected: +- Mapper tests pass +- Targeted Expo lint passes, or any remaining failures are unrelated and documented diff --git a/docs/plans/2026-03-22-clean-slate-schema-refactor.md b/docs/plans/2026-03-22-clean-slate-schema-refactor.md new file mode 100644 index 0000000..5973ae0 --- /dev/null +++ b/docs/plans/2026-03-22-clean-slate-schema-refactor.md @@ -0,0 +1,1073 @@ +# Clean Slate Schema Refactor Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Remove all backward-compatibility layers, collapse duplicate persistence paths, rename inconsistent schema/API vocabulary, and rebuild the backend around a single clean contract backed by one hand-written Alembic `0001_initial.py`. + +**Architecture:** Treat the product as five bounded contexts: identity/billing, conversation, memory, story, and memoir. Collapse duplicate conversation storage into a single `conversation_turns` truth source, collapse old memoir image flow into canonical `assets + story_image_intents + memoir_chapter_cover_intents`, and rename every remaining schema/API surface to one vocabulary. All schema creation must live in explicit Alembic DDL; runtime code may consume migrations but must never generate schema with `create_all`. + +**Tech Stack:** FastAPI, SQLAlchemy 2.x, Alembic, PostgreSQL 17, pgvector, Celery, Redis, Expo Router, React Query, TypeScript, Jest + +--- + +## Non-Negotiables + +- 执行时必须在独立 worktree 中进行,避免和当前脏工作区互相污染。 +- 允许删库重建,因此**禁止**写任何数据迁移、双写、回填兼容、deprecated alias。 +- 目标是单一路径,不保留旧表名、旧字段名、旧路由名、旧环境变量名。 +- `@architecture-patterns`:命名只反映 bounded context,不再混用 `book` / `memoir` / `image` / `asset` / `state`。 +- `@supabase-postgres-best-practices`:所有 FK、唯一约束、索引、部分索引都显式写在 Alembic 中,不依赖 ORM 隐式副作用。 +- `@verification-before-completion`:不允许“看起来差不多”;迁移测试、后端测试、前端测试、lint 必须实际跑过。 +- 完成后仓库中不得再出现: + - `Base.metadata.create_all(...)` + - 运行时自动 `alembic upgrade head` + - `memoir_images` 表 + - `api/app/features/memoir/memoir_images/` 包 + - `segments` 表 + - `conversation_messages` 表 + - `books` 表 + - `memoir_states` 表 + - `timeline_events` 表 + - `{{IMAGE:...}}` 或 `{{{{IMAGE:...}}}}` 占位符兼容渲染 + - `premium` 套餐 alias + +## Target Vocabulary + +### 数据库词典 + +| Current | Target | Action | Notes | +| --- | --- | --- | --- | +| `segments` | removed | delete | 合并进 `conversation_turns`,不再维护双表 | +| `conversation_messages` | `conversation_turns` | rename + widen | 单一对话真源,承载 human/ai turn 与音频元数据 | +| `timeline_events` | `memory_timeline_events` | rename | memory 域统一前缀 | +| `books` | `memoirs` | rename | 内容聚合根统一用 memoir | +| `chapters` | `memoir_chapters` | rename | 消除 generic `chapters` 歧义 | +| `chapter_versions` | `memoir_chapter_versions` | rename | 与上面保持一致 | +| `chapter_story_links` | `memoir_chapter_story_links` | rename | 与上面保持一致 | +| `chapter_cover_intents` | `memoir_chapter_cover_intents` | rename | 与上面保持一致 | +| `memoir_states` | `memoir_workflow_states` | rename | 明确这是流程态,不是内容实体 | +| `memoir_images` | removed | delete | 章节内联图片兼容层彻底移除;只保留 `assets` + intent | + +### 字段词典 + +| Current | Target | Action | +| --- | --- | --- | +| `users.subscription_type` | `users.plan_code` | rename | +| `users.subscription_expires_at` | `users.plan_expires_at` | rename | +| `orders.plan_id` | `orders.plan_code` | rename | +| `memoirs.cover_image_url` | `memoirs.cover_asset_id` | rename + FK | +| `memoirs.has_update` | `memoirs.has_unread_update` | rename | +| `memoirs.last_update_chapter_id` | `memoirs.last_changed_chapter_id` | rename | +| `memoir_chapters.book_id` | `memoir_chapters.memoir_id` | rename | +| `memoir_chapters.is_new` | `memoir_chapters.is_unread` | rename | +| `memoir_chapters.is_active` | removed | delete, use `status` only | +| `memoir_chapters.source_segments` | `memoir_chapters.source_turn_ids` | rename | +| `memoir_chapters.reading_segments_json` | `memoir_chapters.reading_segments_snapshot` | rename | + +### API 词典 + +| Current | Target | +| --- | --- | +| `GET /api/books/current` | `GET /api/memoirs/current` | +| `PUT /api/books/{book_id}` | `PUT /api/memoirs/{memoir_id}` | +| `POST /api/books/clear-update` | `POST /api/memoirs/clear-unread` | +| `POST /api/books/export-pdf` | `POST /api/memoirs/export-pdf` | +| `GET /api/chapters` | `GET /api/memoir-chapters` | +| `GET /api/chapters/{chapter_id}` | `GET /api/memoir-chapters/{chapter_id}` | +| `DELETE /api/chapters/{chapter_id}` | `DELETE /api/memoir-chapters/{chapter_id}` | +| `POST /api/chapters/check-cover-generation` | `POST /api/memoir-chapters/check-cover-generation` | +| `GET /api/memoir-state` | `GET /api/memoir-workflow` | +| `GET /api/memoir-state/next-question` | `GET /api/memoir-workflow/next-question` | +| `POST /api/memoir-state/mark-read` | `POST /api/memoir-workflow/clear-unread` | + +## Clean 0001 Target Tables + +最终 `alembic upgrade head` 后,public schema 只允许这 23 张表: + +```text +assets +conversation_turns +conversations +memory_chunks +memory_curation_actions +memory_facts +memory_sources +memory_summaries +memory_timeline_events +memoir_chapter_cover_intents +memoir_chapter_story_links +memoir_chapter_versions +memoir_chapters +memoir_workflow_states +memoirs +orders +refresh_tokens +sms_verification_codes +stories +story_evidence_links +story_image_intents +story_versions +users +``` + +必须不存在的旧表: + +```text +books +chapter_cover_intents +chapter_story_links +chapter_versions +chapters +conversation_messages +memoir_images +memoir_states +segments +timeline_events +``` + +## Alembic `0001_initial.py` Rules + +- 文件路径固定为 `api/alembic/versions/0001_initial.py` +- 删除 `api/alembic/versions/0001_initial_schema.py` +- `upgrade()` / `downgrade()` 都必须显式写 DDL +- 禁止 import models 后调用 `Base.metadata.create_all()` / `drop_all()` +- 先 `CREATE EXTENSION IF NOT EXISTS vector` +- `stories.current_version_id` 与 `memoir_chapters.current_version_id` 采用“两步建表 + 后置 FK”模式 +- 所有 FK 明确声明 `ondelete` +- 所有唯一约束、部分唯一索引、GIN 索引都在 migration 中显式写出 + +建议的建表顺序: + +1. `users` +2. `assets` +3. `refresh_tokens` +4. `sms_verification_codes` +5. `orders` +6. `conversations` +7. `conversation_turns` +8. `memory_sources` +9. `memory_chunks` +10. `memory_summaries` +11. `memory_facts` +12. `memory_timeline_events` +13. `memory_curation_actions` +14. `stories` +15. `story_versions` +16. `story_evidence_links` +17. `story_image_intents` +18. `memoirs` +19. `memoir_chapters` +20. `memoir_chapter_versions` +21. `memoir_chapter_story_links` +22. `memoir_chapter_cover_intents` +23. `memoir_workflow_states` +24. 追加 FK / unique / partial index / GIN index + +代表性 DDL 片段: + +```python +from alembic import op +import sqlalchemy as sa +from pgvector.sqlalchemy import Vector +from sqlalchemy.dialects import postgresql + +op.create_table( + "conversation_turns", + sa.Column("id", sa.String(), primary_key=True), + sa.Column( + "conversation_id", + sa.String(), + sa.ForeignKey("conversations.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("role", sa.String(), nullable=False), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("message_type", sa.String(), nullable=False, server_default="text"), + sa.Column("audio_url", sa.String(), nullable=True), + sa.Column("audio_duration_seconds", sa.Integer(), nullable=True), + sa.Column("tts_audio_urls", sa.JSON(), nullable=True), + sa.Column("voice_session_id", sa.String(), nullable=True), + sa.Column("topic_category", sa.String(), nullable=True), + sa.Column("processed_for_memoir", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), +) + +op.create_table( + "memory_chunks", + sa.Column("id", sa.String(), primary_key=True), + sa.Column("source_id", sa.String(), sa.ForeignKey("memory_sources.id", ondelete="CASCADE"), nullable=False), + sa.Column("user_id", sa.String(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("embedding", Vector(1536), nullable=True), + sa.Column( + "content_tsv", + postgresql.TSVECTOR(), + sa.Computed("to_tsvector('simple', coalesce(content, ''))", persisted=True), + nullable=True, + ), + sa.Column("chunk_index", sa.Integer(), nullable=False), + sa.Column("metadata_json", sa.JSON(), nullable=True), + sa.Column("is_excluded", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), +) +op.create_index( + "ix_memory_chunks_content_tsv", + "memory_chunks", + ["content_tsv"], + postgresql_using="gin", +) + +op.execute( + ''' + CREATE UNIQUE INDEX uq_story_image_intents_primary_per_story + ON story_image_intents (story_id) + WHERE intent_role = 'primary' + ''' +) +``` + +--- + +### Task 1: Create a Dedicated Worktree and Backend Test Harness + +**Files:** +- Create: `api/tests/conftest.py` +- Create: `api/tests/helpers/postgres.py` +- Create: `api/tests/test_test_harness.py` +- Modify: `api/pyproject.toml` + +**Step 1: Create the clean-slate worktree** + +Run: + +```bash +cd /Users/timcook/Codes/hgtk/life-echo +git worktree add ../life-echo-clean-slate -b codex/clean-slate-schema +``` + +Expected: +- New worktree created at `/Users/timcook/Codes/hgtk/life-echo-clean-slate` + +**Step 2: Write the failing harness smoke test** + +```python +def test_test_database_url_is_configured(test_database_url: str) -> None: + assert test_database_url.startswith("postgresql://") +``` + +**Step 3: Run the test to verify it fails** + +Run: + +```bash +cd /Users/timcook/Codes/hgtk/life-echo-clean-slate/api +TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/life_echo_test \ +uv run pytest tests/test_test_harness.py -v +``` + +Expected: +- FAIL because `tests/conftest.py` and fixture do not exist yet + +**Step 4: Write the minimal harness** + +```python +import os +import pytest + +@pytest.fixture +def test_database_url() -> str: + url = os.environ["TEST_DATABASE_URL"] + assert url + return url +``` + +**Step 5: Run the test to verify it passes** + +Run: + +```bash +cd /Users/timcook/Codes/hgtk/life-echo-clean-slate/api +TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/life_echo_test \ +uv run pytest tests/test_test_harness.py -v +``` + +Expected: +- PASS + +**Step 6: Commit** + +```bash +git add api/tests/conftest.py api/tests/helpers/postgres.py api/tests/test_test_harness.py api/pyproject.toml +git commit -m "test: add backend integration test harness" +``` + +### Task 2: Collapse Duplicate Conversation Persistence into `conversation_turns` + +**Files:** +- Modify: `api/app/features/conversation/models.py` +- Modify: `api/app/features/conversation/repo.py` +- Modify: `api/app/features/conversation/service.py` +- Modify: `api/app/features/conversation/router.py` +- Modify: `api/app/features/conversation/history_store.py` +- Modify: `api/app/features/conversation/session_history.py` +- Modify: `api/app/features/conversation/ws/router.py` +- Modify: `api/app/features/conversation/ws/pipeline.py` +- Modify: `api/app/features/quota/service.py` +- Modify: `api/app/features/memoir/models.py` +- Modify: `api/app/features/memoir/repo.py` +- Modify: `api/app/features/memoir/service.py` +- Modify: `api/app/features/memoir/story_pipeline_sync.py` +- Modify: `api/app/tasks/memoir_tasks.py` +- Modify: `api/app/agents/memoir/orchestrator.py` +- Modify: `api/app/agents/memoir/story_route_agent.py` +- Test: `api/tests/features/conversation/test_turn_repository.py` +- Test: `api/tests/features/quota/test_quota_service.py` + +**Step 1: Write the failing tests** + +```python +def test_conversation_turns_are_the_only_chat_truth_source() -> None: + from app.features.conversation.models import ConversationTurn + + assert ConversationTurn.__tablename__ == "conversation_turns" + +def test_quota_counts_human_turns_only() -> None: + assert _countable_roles() == {"human"} +``` + +**Step 2: Run the tests to verify they fail** + +Run: + +```bash +cd /Users/timcook/Codes/hgtk/life-echo-clean-slate/api +uv run pytest tests/features/conversation/test_turn_repository.py tests/features/quota/test_quota_service.py -v +``` + +Expected: +- FAIL because `ConversationTurn` does not exist and quota still imports `Segment` + +**Step 3: Replace `Segment` + `ConversationMessage` with one model** + +```python +class ConversationTurn(Base): + __tablename__ = "conversation_turns" + + id = Column(String, primary_key=True) + conversation_id = Column(String, ForeignKey("conversations.id", ondelete="CASCADE"), nullable=False, index=True) + role = Column(String, nullable=False) # human / ai / system + content = Column(Text, nullable=False) + message_type = Column(String, nullable=False, default="text") + audio_url = Column(String, nullable=True) + audio_duration_seconds = Column(Integer, nullable=True) + tts_audio_urls = Column(JSON, nullable=True) + voice_session_id = Column(String, nullable=True) + topic_category = Column(String, nullable=True) + processed_for_memoir = Column(Boolean, default=False, nullable=False) + created_at = Column(DateTime(timezone=True), default=utc_now) +``` + +**Step 4: Update all conversation, quota, memoir, and task code paths** + +- Redis 历史重建只读 `conversation_turns` +- 配额统计只统计 `role == "human"` 的 turn +- `process_memoir_segments` 改名为 `process_memoir_turns` +- 章节来源字段统一改成 `source_turn_ids` +- 删除所有 `segment_id` / `segments_count` 语义,改为 `turn_id` / `turn_count` + +**Step 5: Run the tests to verify they pass** + +Run: + +```bash +cd /Users/timcook/Codes/hgtk/life-echo-clean-slate/api +uv run pytest tests/features/conversation/test_turn_repository.py tests/features/quota/test_quota_service.py -v +``` + +Expected: +- PASS + +**Step 6: Commit** + +```bash +git add api/app/features/conversation api/app/features/quota/service.py api/app/features/memoir api/app/tasks/memoir_tasks.py api/app/agents/memoir tests/features/conversation tests/features/quota +git commit -m "refactor: collapse conversation persistence into turns" +``` + +### Task 3: Normalize Billing Vocabulary Around `plan_code` + +**Files:** +- Modify: `api/app/features/user/models.py` +- Modify: `api/app/features/user/schemas.py` +- Modify: `api/app/features/user/service.py` +- Modify: `api/app/features/user/router.py` +- Modify: `api/app/features/auth/schemas.py` +- Modify: `api/app/features/auth/service.py` +- Modify: `api/app/features/auth/router.py` +- Modify: `api/app/features/plan/schemas.py` +- Modify: `api/app/features/plan/service.py` +- Modify: `api/app/features/quota/service.py` +- Modify: `api/app/features/payment/models.py` +- Modify: `api/app/features/payment/schemas.py` +- Modify: `api/app/features/payment/order_service.py` +- Modify: `api/app/features/payment/router.py` +- Modify: `app-expo/src/features/auth/types.ts` +- Modify: `app-expo/src/features/profile/types.ts` +- Modify: `app-expo/src/app/(tabs)/profile.tsx` +- Test: `api/tests/features/plan/test_plan_service.py` + +**Step 1: Write the failing tests** + +```python +def test_current_plan_response_uses_plan_code() -> None: + payload = build_current_plan_payload(plan_code="pro") + assert payload["plan_code"] == "pro" + assert "subscription_type" not in payload +``` + +**Step 2: Run the tests to verify they fail** + +Run: + +```bash +cd /Users/timcook/Codes/hgtk/life-echo-clean-slate/api +uv run pytest tests/features/plan/test_plan_service.py -v +``` + +Expected: +- FAIL because code still emits `subscription_type` + +**Step 3: Rename schema and service vocabulary** + +```python +class User(Base): + plan_code = Column(String, default="free") + plan_expires_at = Column(DateTime(timezone=True), nullable=True) + +class Order(Base): + plan_code = Column(String, nullable=False) +``` + +- 删除 `premium -> pro` alias +- 对外 API 响应统一用 `plan_code` +- 前端 profile/auth 类型同步改为 `plan_code` + +**Step 4: Run the tests to verify they pass** + +Run: + +```bash +cd /Users/timcook/Codes/hgtk/life-echo-clean-slate/api +uv run pytest tests/features/plan/test_plan_service.py -v +``` + +Run: + +```bash +cd /Users/timcook/Codes/hgtk/life-echo-clean-slate/app-expo +npm run lint -- src/features/auth src/features/profile src/app/\(tabs\)/profile.tsx +``` + +Expected: +- PASS + +**Step 5: Commit** + +```bash +git add api/app/features/user api/app/features/auth api/app/features/plan api/app/features/quota api/app/features/payment app-expo/src/features/auth app-expo/src/features/profile app-expo/src/app/'(tabs)'/profile.tsx +git commit -m "refactor: normalize billing vocabulary around plan code" +``` + +### Task 4: Normalize Memory and Story Schema Names + +**Files:** +- Modify: `api/app/features/memory/models.py` +- Modify: `api/app/features/memory/repo.py` +- Modify: `api/app/features/memory/retriever.py` +- Modify: `api/app/features/memory/timeline.py` +- Modify: `api/app/features/story/models.py` +- Modify: `api/app/features/story/repo.py` +- Modify: `api/app/features/story/service.py` +- Test: `api/tests/features/memory/test_schema_metadata.py` +- Test: `api/tests/features/story/test_story_models.py` + +**Step 1: Write the failing tests** + +```python +def test_memory_timeline_table_is_prefixed() -> None: + from app.features.memory.models import MemoryTimelineEvent + + assert MemoryTimelineEvent.__tablename__ == "memory_timeline_events" + +def test_story_image_intent_asset_fk_is_explicit() -> None: + assert story_image_intent_asset_fk_target() == "assets.id" +``` + +**Step 2: Run the tests to verify they fail** + +Run: + +```bash +cd /Users/timcook/Codes/hgtk/life-echo-clean-slate/api +uv run pytest tests/features/memory/test_schema_metadata.py tests/features/story/test_story_models.py -v +``` + +Expected: +- FAIL because `timeline_events` 仍是旧表名,且 FK 约束依赖迁移补丁而非模型语义 + +**Step 3: Rename the memory timeline model and tighten story relationships** + +```python +class MemoryTimelineEvent(Base): + __tablename__ = "memory_timeline_events" + +class StoryImageIntent(Base): + asset_id = Column(String, ForeignKey("assets.id", ondelete="SET NULL"), nullable=True) +``` + +- 同步更新 repo/retriever 查询 +- 保持 `story_*` 前缀完整一致 + +**Step 4: Run the tests to verify they pass** + +Run: + +```bash +cd /Users/timcook/Codes/hgtk/life-echo-clean-slate/api +uv run pytest tests/features/memory/test_schema_metadata.py tests/features/story/test_story_models.py -v +``` + +Expected: +- PASS + +**Step 5: Commit** + +```bash +git add api/app/features/memory api/app/features/story tests/features/memory tests/features/story +git commit -m "refactor: normalize memory and story schema names" +``` + +### Task 5: Refactor Memoir Domain to `memoirs` / `memoir_chapters` and Delete `memoir_images` + +**Files:** +- Modify: `api/app/features/memoir/models.py` +- Modify: `api/app/features/memoir/repo.py` +- Modify: `api/app/features/memoir/service.py` +- Modify: `api/app/features/memoir/router.py` +- Modify: `api/app/features/memoir/schemas.py` +- Modify: `api/app/features/memoir/helpers.py` +- Modify: `api/app/features/memoir/state_service.py` +- Modify: `api/app/features/memoir/chapter_cover.py` +- Modify: `api/app/features/memoir/cover_eligibility.py` +- Modify: `api/app/features/memoir/reading_segment_materialize.py` +- Modify: `api/app/features/memoir/pdf_service.py` +- Modify: `api/app/features/memoir/story_pipeline_sync.py` +- Modify: `api/app/features/user/models.py` +- Modify: `api/app/features/user/repo.py` +- Modify: `api/app/features/user/router.py` +- Test: `api/tests/features/memoir/test_serializers.py` +- Test: `api/tests/features/memoir/test_cover_eligibility.py` + +**Step 1: Write the failing tests** + +```python +def test_memoir_chapter_payload_has_no_legacy_image_list() -> None: + payload = serialize_memoir_chapter(sample_chapter()) + assert "images" not in payload + assert "is_new" not in payload + assert payload["is_unread"] is True + +def test_cover_eligibility_does_not_read_memoir_images() -> None: + assert cover_eligibility_source() == "story_links_and_cover_asset_only" +``` + +**Step 2: Run the tests to verify they fail** + +Run: + +```bash +cd /Users/timcook/Codes/hgtk/life-echo-clean-slate/api +uv run pytest tests/features/memoir/test_serializers.py tests/features/memoir/test_cover_eligibility.py -v +``` + +Expected: +- FAIL because serializers still emit `images` / `is_new`, and cover logic still imports `MemoirImage` + +**Step 3: Rename the memoir aggregate and chapter tables** + +```python +class Memoir(Base): + __tablename__ = "memoirs" + user_id = Column(String, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True, index=True) + title = Column(String, nullable=False) + total_pages = Column(Integer, default=0, nullable=False) + total_words = Column(Integer, default=0, nullable=False) + cover_asset_id = Column(String, ForeignKey("assets.id", ondelete="SET NULL"), nullable=True) + has_unread_update = Column(Boolean, default=False, nullable=False) + last_changed_chapter_id = Column(String, nullable=True) + +class MemoirChapter(Base): + __tablename__ = "memoir_chapters" + memoir_id = Column(String, ForeignKey("memoirs.id", ondelete="SET NULL"), nullable=True) + is_unread = Column(Boolean, default=True, nullable=False) + source_turn_ids = Column(JSON, nullable=True) + reading_segments_snapshot = Column(JSON, nullable=True) +``` + +**Step 4: Remove redundant memoir state fields and boolean drift** + +- 删除 `is_active` +- `DELETE /api/memoir-chapters/{id}` 改为只更新 `status="archived"` 或真正删除,二选一,只保留一种语义 +- `mark_read` 只更新 `is_unread` / `has_unread_update` + +**Step 5: Delete `MemoirImage` usage completely** + +- serializer 不再返回 `images` +- PDF 不再 strip placeholder 再插图 +- cover 生成只看 `story_links`、`cover_asset_id`、`memoir_chapter_cover_intents` +- 用户清理逻辑只删除 `assets.storage_key` + +**Step 6: Run the tests to verify they pass** + +Run: + +```bash +cd /Users/timcook/Codes/hgtk/life-echo-clean-slate/api +uv run pytest tests/features/memoir/test_serializers.py tests/features/memoir/test_cover_eligibility.py -v +``` + +Expected: +- PASS + +**Step 7: Commit** + +```bash +git add api/app/features/memoir api/app/features/user tests/features/memoir +git commit -m "refactor: normalize memoir aggregate and remove memoir images" +``` + +### Task 6: Extract Shared Asset and Image-Generation Infrastructure + +**Files:** +- Create: `api/app/features/asset/storage.py` +- Create: `api/app/features/asset/settings.py` +- Modify: `api/app/core/config.py` +- Modify: `api/app/core/dependencies.py` +- Modify: `api/app/tasks/story_image_tasks.py` +- Modify: `api/app/tasks/chapter_cover_tasks.py` +- Modify: `api/app/tasks/chapter_cover_enqueue.py` +- Modify: `api/app/features/memoir/helpers.py` +- Modify: `api/app/features/memoir/asset_urls.py` +- Modify: `api/app/features/memoir/asset_resolver.py` +- Delete: `api/app/features/memoir/memoir_images/__init__.py` +- Delete: `api/app/features/memoir/memoir_images/json_payload.py` +- Delete: `api/app/features/memoir/memoir_images/parser.py` +- Delete: `api/app/features/memoir/memoir_images/prompting.py` +- Delete: `api/app/features/memoir/memoir_images/schema.py` +- Delete: `api/app/features/memoir/memoir_images/serializers.py` +- Delete: `api/app/features/memoir/memoir_images/settings.py` +- Delete: `api/app/features/memoir/memoir_images/storage.py` +- Test: `api/tests/features/asset/test_storage.py` + +**Step 1: Write the failing tests** + +```python +def test_asset_storage_service_is_shared_not_memoir_specific() -> None: + from app.features.asset.storage import TencentCosStorageService + + assert TencentCosStorageService is not None +``` + +**Step 2: Run the tests to verify they fail** + +Run: + +```bash +cd /Users/timcook/Codes/hgtk/life-echo-clean-slate/api +uv run pytest tests/features/asset/test_storage.py -v +``` + +Expected: +- FAIL because `app.features.asset.storage` does not exist yet + +**Step 3: Move generic code into the asset feature** + +```python +@dataclass(frozen=True) +class ImageGenerationSettings: + enabled: bool = False + provider: str = "liblib" + default_style: str = "watercolor" + default_size: str = "1280x720" + poll_interval_seconds: int = 3 + max_attempts: int = 20 +``` + +- 环境变量统一改成 `image_generation_*` +- 删除 `memoir_image_max_per_chapter` / `memoir_image_chars_per_extra` / `memoir_image_max_cap` +- story image 与 chapter cover 都从 `asset.settings` 取配置 + +**Step 4: Run the tests to verify they pass** + +Run: + +```bash +cd /Users/timcook/Codes/hgtk/life-echo-clean-slate/api +uv run pytest tests/features/asset/test_storage.py -v +``` + +Expected: +- PASS + +**Step 5: Commit** + +```bash +git add api/app/features/asset api/app/core/config.py api/app/core/dependencies.py api/app/tasks/story_image_tasks.py api/app/tasks/chapter_cover_tasks.py api/app/tasks/chapter_cover_enqueue.py api/app/features/memoir +git commit -m "refactor: extract shared asset and image infrastructure" +``` + +### Task 7: Rewrite API Contracts and Expo Clients to the Clean Nouns + +**Files:** +- Modify: `api/app/features/memoir/router.py` +- Modify: `api/app/features/memoir/schemas.py` +- Modify: `api/app/features/plan/schemas.py` +- Modify: `api/app/features/auth/schemas.py` +- Modify: `api/app/features/user/schemas.py` +- Modify: `app-expo/src/features/memoir/api.ts` +- Modify: `app-expo/src/features/memoir/hooks.ts` +- Modify: `app-expo/src/features/memoir/types.ts` +- Modify: `app-expo/src/features/memoir/mappers.ts` +- Modify: `app-expo/src/features/memoir/markdown-renderer.tsx` +- Modify: `app-expo/src/app/(tabs)/memoir.tsx` +- Modify: `app-expo/src/app/(main)/chapter/[id].tsx` +- Modify: `app-expo/src/features/profile/api.ts` +- Modify: `app-expo/src/features/profile/types.ts` +- Modify: `app-expo/tests/features/memoir/mappers.test.ts` + +**Step 1: Write the failing frontend tests** + +```ts +test('memoir chapter contract has no renderedAssets fallback', () => { + const chapter = makeChapter(); + expect('images' in chapter).toBe(false); +}); +``` + +**Step 2: Run the tests to verify they fail** + +Run: + +```bash +cd /Users/timcook/Codes/hgtk/life-echo-clean-slate/app-expo +npm run test:ci -- --runTestsByPath tests/features/memoir/mappers.test.ts +``` + +Expected: +- FAIL because current types still require `images` + +**Step 3: Remove placeholder/image compatibility from the reader** + +```ts +export interface MemoirChapter { + id: string; + title: string; + order_index: number; + status: string; + category: string; + cover_asset: ImageAsset | null; + canonical_markdown?: string | null; + reading_segments?: ChapterReadingSegment[]; + is_unread: boolean; + source_turn_ids: string[]; + word_count?: number; + updated_at: string | null; +} +``` + +- 删除 `images` +- 删除 `placeholder` +- `MarkdownRenderer` 只渲染服务端已解析好的 markdown / image URL +- 前端 API 路径切到 `/api/memoirs/*`、`/api/memoir-chapters/*`、`/api/memoir-workflow/*` + +**Step 4: Run the tests to verify they pass** + +Run: + +```bash +cd /Users/timcook/Codes/hgtk/life-echo-clean-slate/app-expo +npm run test:ci -- --runTestsByPath tests/features/memoir/mappers.test.ts +``` + +Run: + +```bash +cd /Users/timcook/Codes/hgtk/life-echo-clean-slate/app-expo +npm run lint -- src/features/memoir src/features/profile src/app/\(tabs\)/memoir.tsx src/app/\(main\)/chapter/\[id\].tsx src/app/\(tabs\)/profile.tsx +``` + +Expected: +- PASS + +**Step 5: Commit** + +```bash +git add api/app/features/memoir api/app/features/plan api/app/features/auth api/app/features/user app-expo/src/features/memoir app-expo/src/features/profile app-expo/src/app/'(tabs)'/memoir.tsx app-expo/src/app/'(main)'/chapter/'[id]'.tsx app-expo/src/app/'(tabs)'/profile.tsx app-expo/tests/features/memoir/mappers.test.ts +git commit -m "refactor: align api and expo contracts with clean schema" +``` + +### Task 8: Replace the Alembic Baseline with an Explicit Clean `0001_initial.py` + +**Files:** +- Delete: `api/alembic/versions/0001_initial_schema.py` +- Create: `api/alembic/versions/0001_initial.py` +- Create: `api/tests/alembic/test_upgrade_head.py` +- Create: `api/tests/alembic/test_downgrade_base.py` + +**Step 1: Write the failing migration contract tests** + +```python +EXPECTED_TABLES = { + "assets", + "conversation_turns", + "conversations", + "memory_chunks", + "memory_curation_actions", + "memory_facts", + "memory_sources", + "memory_summaries", + "memory_timeline_events", + "memoir_chapter_cover_intents", + "memoir_chapter_story_links", + "memoir_chapter_versions", + "memoir_chapters", + "memoir_workflow_states", + "memoirs", + "orders", + "refresh_tokens", + "sms_verification_codes", + "stories", + "story_evidence_links", + "story_image_intents", + "story_versions", + "users", +} + +FORBIDDEN_TABLES = { + "books", + "chapters", + "chapter_versions", + "chapter_story_links", + "chapter_cover_intents", + "memoir_images", + "memoir_states", + "segments", + "conversation_messages", + "timeline_events", +} +``` + +**Step 2: Run the tests to verify they fail** + +Run: + +```bash +cd /Users/timcook/Codes/hgtk/life-echo-clean-slate/api +TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/life_echo_test \ +uv run pytest tests/alembic/test_upgrade_head.py tests/alembic/test_downgrade_base.py -v -m integration +``` + +Expected: +- FAIL because current migration still creates old tables and uses `create_all` + +**Step 3: Write the explicit baseline** + +- `upgrade()` 中逐个 `op.create_table(...)` +- 显式写: + - `uq_memoirs_user_id` + - `uq_memoir_workflow_states_user_id` + - `uq_memoir_chapters_memoir_id_order_index` + - `uq_memoir_chapter_story_links_chapter_story` + - `uq_story_image_intents_primary_per_story`(partial unique index) + - `uq_orders_trade_no_not_null`(partial unique index) + - `ix_memory_chunks_content_tsv`(GIN) +- `downgrade()` 反向 drop,最后 `DROP EXTENSION IF EXISTS vector` + +**Step 4: Run the tests to verify they pass** + +Run: + +```bash +cd /Users/timcook/Codes/hgtk/life-echo-clean-slate/api +TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/life_echo_test \ +uv run pytest tests/alembic/test_upgrade_head.py tests/alembic/test_downgrade_base.py -v -m integration +``` + +Expected: +- PASS +- Inspector 只看到目标 23 张表 +- Forbidden tables 全部不存在 + +**Step 5: Commit** + +```bash +git add api/alembic/versions/0001_initial.py api/tests/alembic +git rm api/alembic/versions/0001_initial_schema.py +git commit -m "refactor: replace schema baseline with explicit alembic 0001" +``` + +### Task 9: Remove Runtime Schema Bootstrap Debt and Centralize Model Registry + +**Files:** +- Create: `api/app/models.py` +- Modify: `api/alembic/env.py` +- Modify: `api/app/core/db.py` +- Modify: `api/app/main.py` +- Modify: `api/dev-up.sh` +- Modify: `api/README.md` +- Test: `api/tests/test_model_registry.py` + +**Step 1: Write the failing test** + +```python +def test_model_registry_imports_metadata_without_main_side_effects() -> None: + import app.models # noqa: F401 +``` + +**Step 2: Run the test to verify it fails** + +Run: + +```bash +cd /Users/timcook/Codes/hgtk/life-echo-clean-slate/api +uv run pytest tests/test_model_registry.py -v +``` + +Expected: +- FAIL because `app.models` does not exist + +**Step 3: Centralize metadata and remove runtime schema authorship** + +```python +# api/app/models.py +from app.features.asset import models as _asset_models +from app.features.auth import models as _auth_models +from app.features.conversation import models as _conversation_models +from app.features.memory import models as _memory_models +from app.features.memoir import models as _memoir_models +from app.features.payment import models as _payment_models +from app.features.story import models as _story_models +from app.features.user import models as _user_models +``` + +- 删除 `init_db_schema()` +- 删除 `startup_event()` 里的 `_run_alembic_upgrade()` +- `api/dev-up.sh` / 文档中明确要求先执行 `uv run alembic upgrade head` + +**Step 4: Run the test to verify it passes** + +Run: + +```bash +cd /Users/timcook/Codes/hgtk/life-echo-clean-slate/api +uv run pytest tests/test_model_registry.py -v +``` + +Expected: +- PASS + +**Step 5: Commit** + +```bash +git add api/app/models.py api/alembic/env.py api/app/core/db.py api/app/main.py api/dev-up.sh api/README.md api/tests/test_model_registry.py +git commit -m "refactor: remove runtime schema bootstrap debt" +``` + +### Task 10: Delete Dead Files, Refresh Docs, and Run Full Verification + +**Files:** +- Modify: `README.md` +- Modify: `api/README.md` +- Modify: `api/docs/README.md` +- Modify: `docs/数据库设计.md` +- Delete: any remaining dead imports/files revealed by `rg` + +**Step 1: Remove stale documentation and dead references** + +- README 中不再出现旧目录结构 `api/routers`, `api/services`, `app-android` 旧叙述 +- 文档中不再出现 `books`, `memoir-state`, `segments`, `conversation_messages`, `memoir_images` +- 补一节 “schema bootstrap policy: Alembic only” + +**Step 2: Run backend verification** + +Run: + +```bash +cd /Users/timcook/Codes/hgtk/life-echo-clean-slate/api +uv run ruff check app tests +``` + +Run: + +```bash +cd /Users/timcook/Codes/hgtk/life-echo-clean-slate/api +uv run pyright +``` + +Run: + +```bash +cd /Users/timcook/Codes/hgtk/life-echo-clean-slate/api +TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/life_echo_test \ +uv run pytest -v +``` + +Expected: +- PASS + +**Step 3: Run frontend verification** + +Run: + +```bash +cd /Users/timcook/Codes/hgtk/life-echo-clean-slate/app-expo +npm run lint +``` + +Run: + +```bash +cd /Users/timcook/Codes/hgtk/life-echo-clean-slate/app-expo +npm run test:ci +``` + +Expected: +- PASS + +**Step 4: Commit** + +```bash +git add README.md api/README.md api/docs/README.md docs/数据库设计.md +git commit -m "docs: refresh architecture and schema documentation" +``` + +## Final Definition of Done + +- DB 只由 `api/alembic/versions/0001_initial.py` 建出目标 23 张表 +- 仓库中不存在旧 schema / API / env / markdown placeholder 兼容路径 +- Conversation 只有一个持久化真源:`conversation_turns` +- Memoir 图像链路只剩: + - `assets` + - `story_image_intents` + - `memoir_chapter_cover_intents` +- Billing 只有一个套餐词汇:`plan_code` +- FastAPI 启动不再偷偷迁移数据库 +- Expo 不再消费 `images`、`placeholder`、`subscription_type`、`/api/books/*` + +## Risks to Watch Explicitly + +- Conversation turn 合并是本次最大改动面,必须先补测试再动 WS / Celery +- 删除 `memoir_images` 后,任何仍依赖 placeholder 的渲染都会直接坏;前后端必须同批落地 +- 去掉启动时自动迁移后,开发脚本和部署脚本如果不补齐,会导致“代码已改、库没升级”的新故障 +- `plan_code` 重命名会波及 auth/profile/payment/quota,多端接口要一起改,不要留 alias + diff --git a/docs/plans/backend-test-system.md b/docs/plans/backend-test-system.md index 5eb95b1..520d473 100644 --- a/docs/plans/backend-test-system.md +++ b/docs/plans/backend-test-system.md @@ -21,7 +21,7 @@ | `test_memoir_image_storage.py` | 单例、上传、下载 URL、错误 | `unit/adapters/test_storage_tencent_cos.py` | | `test_memoir_image_prompting.py` | 无 LLM 回退、JSON 解析 | `unit/features/test_memoir_service.py` | | `test_memoir_image_bootstrap.py` | 启用/禁用、动态上限 | `unit/features/test_memoir_service.py` | -| `test_generate_chapter_images_task.py` | 锁、重试、幂等、JPEG->PNG | `unit/tasks/test_memoir_tasks.py` | +| (已删)旧章节补图任务测试 | 已由 `generate_chapter_cover` + `test_chapter_cover_tasks.py` 覆盖章节封面路径 | `tests/test_chapter_cover_tasks.py` | | `test_process_memoir_segments_image_enqueue.py` | markdown JSON、入队、禁用 | `unit/tasks/test_memoir_tasks.py` | | `test_memoir_tasks_redis.py` | Redis 锁复用 | `unit/tasks/test_memoir_tasks.py` | | `test_pdf_service_images.py` | 图片宽高比、签名失败跳过 | `unit/features/test_memoir_service.py` | diff --git a/docs/plans/multi-agent-refactor-plan.md b/docs/plans/multi-agent-refactor-plan.md index fb231bc..8e23371 100644 --- a/docs/plans/multi-agent-refactor-plan.md +++ b/docs/plans/multi-agent-refactor-plan.md @@ -1,354 +1,22 @@ -# 三模块多 Agent 改造方案 +# 三模块多 Agent 改造方案(归档) -> 本文档描述将「AI 回复用户」「生成回忆录」「生成图片提示词」三个模块改造为多 Agent 模式的修改方案,仅作设计参考,不执行代码修改。 +> 本文档已归档。早期大段“迁移中”设计会持续引入错误心智模型,因此这里仅保留结果摘要;旧细节请直接查 Git 历史。 ---- +## 当前正式架构 -## 一、概述 +- Chat:唯一实时编排入口是 `ChatOrchestrator`;`ProfileAgent` / `InterviewAgent` 为 specialist。 +- Conversation history:DB `conversation_messages` 为真源,Redis 仅作缓存。 +- Memoir:`MemoirOrchestrator.prepare_batches` 负责分桶;`run_story_pipeline_for_category_batch` 负责 story 写入与 chapter 物化。 +- Images:正文主图走 `generate_story_image`;章节封面走 `try_enqueue_generate_chapter_cover` -> `generate_chapter_cover`。 -### 1.1 目标 +## 已删除路径 -将当前单体式 Agent 逻辑拆分为职责清晰、可独立演进的多 Agent 协同架构,实现: +- 旧 chat facade +- 旧 memoir facade +- agent-layer runner +- 旧章节补图任务名 -- **职责分离**:每个 Agent 专注单一任务,便于维护和测试 -- **可编排性**:通过 Orchestrator 统一调度,支持灵活编排与扩展 -- **可观测性**:Agent 边界清晰,便于链路追踪和问题定位 -- **渐进迁移**:在不破坏现有行为的前提下分阶段落地 +## 约束 -### 1.2 适用范围 - -| 模块 | 当前实现位置 | 改造后预期 | -|------|--------------|------------| -| AI 回复用户 | `ConversationAgent` + pipeline | `ChatOrchestrator` + `ProfileAgent` + `InterviewAgent` | -| 生成回忆录 | `memoir_tasks` + `ContentAnalyzer`/`MemoirGenerator` | `MemoirOrchestrator` + 多个 Specialist Agent | -| 生成图片提示词 | `MemoirImagePromptService` | `ImagePromptOrchestrator` + `PromptGenerationAgent` | - ---- - -## 二、现状分析 - -### 2.1 当前架构简图 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ WebSocket pipeline / Celery tasks │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ [process_user_message] [process_memoir_segments] │ -│ │ │ │ -│ ▼ ▼ │ -│ ┌──────────────────┐ ┌─────────────────────────┐ │ -│ │ ConversationAgent │ │ memoir_tasks (inline) │ │ -│ │ - extract_profile│ │ - state extraction │ │ -│ │ - generate_* │ │ - chapter classification│ │ -│ │ - generate_ │ │ - narrative generation │ │ -│ │ response_ │ │ - inject placeholder │ │ -│ │ with_state │ └───────────┬─────────────┘ │ -│ └──────────────────┘ │ │ -│ ▼ │ -│ ┌─────────────────────────┐ │ -│ │ generate_chapter_images │ │ -│ │ └─ MemoirImagePrompt │ │ -│ │ Service.build_* │ │ -│ └─────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 2.2 主要问题 - -1. **职责混杂**:`ConversationAgent` 同时做资料提取、资料追问、正式访谈,逻辑耦合 -2. **流程内聚**:回忆录处理全部写在 `process_memoir_segments` 单任务内,难以单测和替换子步骤 -3. **Service 非 Agent**:`MemoirImagePromptService` 仅为工具类,无编排与决策能力 -4. **缺乏统一抽象**:三个模块调用方式各异,无统一的 Agent 协议 - ---- - -## 三、多 Agent 架构设计 - -### 3.1 总体模式 - -采用 **Orchestrator + Specialist Agents** 模式: - -- **Orchestrator**:负责路由、决策、编排,不直接调用 LLM 完成具体生成 -- **Specialist Agent**:接收 Orchestrator 下发的任务,完成单一 LLM 任务并返回结构化结果 - -### 3.2 统一 Agent 协议(建议) - -为便于 DI、测试和可观测性,建议定义统一的 Agent 协议: - -```python -# 概念性协议,非实际代码 -class AgentProtocol(Protocol): - """Agent 基础协议""" - async def run(self, context: AgentContext) -> AgentResult: ... -``` - -- `AgentContext`:输入上下文(用户消息、状态、历史等) -- `AgentResult`:结构化输出(含 success/failure、data、trace_id) - ---- - -## 四、模块一:AI 回复用户(Chat) - -### 4.1 目标架构 - -``` - ┌─────────────────────────────┐ - │ ChatOrchestrator │ - │ - 判断 profile vs interview │ - │ - 路由到对应 Specialist │ - │ - 聚合结果并写入 Redis │ - └──────────────┬──────────────┘ - │ - ┌────────────────────┼────────────────────┐ - ▼ ▼ ▼ -┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐ -│ ProfileAgent │ │ InterviewAgent │ │ StageDetectorAgent │ -│ - 提取资料 │ │ - 状态感知回复 │ │ - 检测用户阶段 │ -│ - 资料追问 │ │ - 引导与追问 │ │ - 供 Orchestrator │ -│ - 开场白/问候 │ │ - 时代背景融入 │ │ 决策使用 │ -└─────────────────────┘ └─────────────────────┘ └─────────────────────┘ -``` - -### 4.2 Agent 职责 - -| Agent | 职责 | 对应现有逻辑 | -|-------|------|--------------| -| **ChatOrchestrator** | 根据 `missing_fields`、`memoir_state` 决定调用 ProfileAgent 或 InterviewAgent;统一管理 Redis 写入、错误处理 | `process_user_message` 中的 if/else 分支 | -| **ProfileAgent** | `extract_profile_from_message`、`generate_profile_followup`、`generate_profile_greeting` | `ConversationAgent` 中 profile 相关方法 | -| **InterviewAgent** | `generate_response_with_state`、`generate_opening_message` | `ConversationAgent` 中 interview 相关方法 | -| **StageDetectorAgent**(可选) | `_detect_user_stage`,可升级为 LLM/embedding 检测 | `ConversationAgent._detect_user_stage` | - -### 4.3 协作流程 - -1. Pipeline 调用 `ChatOrchestrator.run(conversation_id, user_message, user, segment, ...)` -2. Orchestrator 查询 `get_missing_profile_fields(user)`,若有缺失则调用 `ProfileAgent` -3. 否则获取 `MemoirState`,调用 `InterviewAgent`(可选:先调 `StageDetectorAgent` 获取 `detected_stage`) -4. 将 Agent 返回的 `responses` 写 Redis,返回给 pipeline 用于 WebSocket 下发 - -### 4.4 修改方案 - -| 步骤 | 操作 | 文件 | -|------|------|------| -| 1 | 新增 `ChatOrchestrator` 类 | `app/agents/chat/orchestrator.py` | -| 2 | 抽取 `ProfileAgent` | `app/agents/chat/profile_agent.py` | -| 3 | 抽取 `InterviewAgent` | `app/agents/chat/interview_agent.py` | -| 4 | 可选:抽取 `StageDetectorAgent` | `app/agents/chat/stage_detector.py` | -| 5 | 定义 `app/agents/chat/__init__.py` 统一导出 | - | -| 6 | 修改 `pipeline.process_user_message` 调用 `ChatOrchestrator` 而非 `ConversationAgent` | `app/features/conversation/ws/pipeline.py` | -| 7 | 保留 `ConversationAgent` 为 facade,内部委托给 Orchestrator(兼容期) | `app/agents/conversation_agent.py` | - ---- - -## 五、模块二:生成回忆录(Memoir) - -### 5.1 目标架构 - -``` - ┌─────────────────────────────────────────┐ - │ MemoirOrchestrator │ - │ - 按 segment 编排流水线 │ - │ - 管理章节锁、状态更新 │ - │ - 派发 generate_chapter_images │ - └──────────────────┬────────────────────────┘ - │ - ┌─────────────────────────────┼─────────────────────────────┐ - ▼ ▼ ▼ -┌──────────────────┐ ┌──────────────────────┐ ┌──────────────────┐ -│ ExtractionAgent │ │ ClassificationAgent │ │ NarrativeAgent │ -│ - state/slot │ │ - 8-category 分类 │ │ - 标题生成 │ -│ 提取 │ │ - 无价值跳过决策 │ │ - 叙事改写 │ -└──────────────────┘ └──────────────────────┘ └──────────────────┘ - │ │ │ - └─────────────────────────────┼─────────────────────────────┘ - │ - ▼ - ┌──────────────────────────┐ - │ PlaceholderInjectAgent │ - │ - inject_image_ │ - │ placeholder_template │ - └──────────────────────────┘ -``` - -### 5.2 Agent 职责 - -| Agent | 职责 | 对应现有逻辑 | -|-------|------|--------------| -| **MemoirOrchestrator** | 遍历 segments、按 category 聚合、调用各 Specialist、更新 state、写 DB、派发补图任务 | `process_memoir_segments` 主循环 | -| **ExtractionAgent** | 调用 `get_state_extraction_prompt`,解析 JSON 返回 `detected_stage`、`slots` | `get_state_extraction_prompt` + 解析 | -| **ClassificationAgent** | 调用 `get_chapter_classification_prompt` 或等价逻辑,返回 category 或 None(跳过) | `_classify_chapter_category` | -| **NarrativeAgent** | `get_creative_title_prompt`、`get_narrative_prompt`,生成标题和正文 | `get_creative_title_prompt`、`get_narrative_prompt` | -| **PlaceholderInjectAgent** | 纯函数式,对 narrative 做 `inject_image_placeholder_template` | `inject_image_placeholder_template` | - -### 5.3 协作流程 - -1. Celery 任务 `process_memoir_segments` 入口仅负责:取 segments、获取 db、调用 `MemoirOrchestrator.run(user_id, segment_ids, db)` -2. Orchestrator 对每个 segment:调 `ExtractionAgent` → 调 `ClassificationAgent`,若 None 则跳过 -3. 按 category 聚合后,对每个 category:调 `NarrativeAgent` 生成 title + narrative → 调 `PlaceholderInjectAgent` → 写入 sections -4. 若有待补图章节,派发 `generate_chapter_images` - -### 5.4 修改方案 - -| 步骤 | 操作 | 文件 | -|------|------|------| -| 1 | 新增 `MemoirOrchestrator` | `app/agents/memoir/orchestrator.py` | -| 2 | 抽取 `ExtractionAgent` | `app/agents/memoir/extraction_agent.py` | -| 3 | 抽取 `ClassificationAgent` | `app/agents/memoir/classification_agent.py` | -| 4 | 抽取 `NarrativeAgent` | `app/agents/memoir/narrative_agent.py` | -| 5 | 抽取 `PlaceholderInjectAgent`(或保留为 util) | `app/agents/memoir/placeholder_agent.py` | -| 6 | 定义 `app/agents/memoir/__init__.py` | - | -| 7 | 修改 `process_memoir_segments`:将主循环逻辑委托给 `MemoirOrchestrator` | `app/tasks/memoir_tasks.py` | -| 8 | 保留 `ContentAnalyzer`、`MemoirGenerator` 为内部实现或弃用 | `app/agents/memoir_processor.py` | - ---- - -## 六、模块三:生成图片提示词(Image Prompt) - -### 6.1 目标架构 - -``` - ┌─────────────────────────────────┐ - │ ImagePromptOrchestrator │ - │ - 区分封面 vs 正文配图 │ - │ - 调用 PromptGenerationAgent │ - │ - 回退逻辑与缓存(可选) │ - └────────────────┬────────────────┘ - │ - ▼ - ┌─────────────────────────────────┐ - │ PromptGenerationAgent │ - │ - build_prompt (正文) │ - │ - build_cover_prompt (封面) │ - │ - 风格映射、fallback │ - └─────────────────────────────────┘ -``` - -### 6.2 Agent 职责 - -| Agent | 职责 | 对应现有逻辑 | -|-------|------|--------------| -| **ImagePromptOrchestrator** | 根据调用方(封面/正文)选择 `build_prompt` 或 `build_cover_prompt`;统一异常处理和回退;可选:缓存相同输入的 prompt | `generate_chapter_images` 中调用 `prompt_service` 的代码 | -| **PromptGenerationAgent** | 接收 `chapter_title`、`chapter_category`、`description`、`context_excerpt`,调用 LLM 或 fallback 生成 `{prompt, style, size}` | `MemoirImagePromptService` 全部逻辑 | - -### 6.3 协作流程 - -1. `generate_chapter_images` 任务内,对封面和每个 section: - - 构造 `(chapter_title, category, description, context_excerpt)` 等输入 - - 调用 `ImagePromptOrchestrator.build_prompt` 或 `build_cover_prompt` -2. Orchestrator 内部委托 `PromptGenerationAgent`,失败时执行 fallback - -### 6.4 修改方案 - -| 步骤 | 操作 | 文件 | -|------|------|------| -| 1 | 新增 `ImagePromptOrchestrator` | `app/agents/image_prompt/orchestrator.py` | -| 2 | 将 `MemoirImagePromptService` 重命名/重构为 `PromptGenerationAgent` | `app/agents/image_prompt/prompt_agent.py` 或保留 `app/features/memoir/memoir_images/prompting.py` 作为底层 | -| 3 | 定义 `app/agents/image_prompt/__init__.py` | - | -| 4 | 修改 `generate_chapter_images`:通过 `ImagePromptOrchestrator` 获取 prompt | `app/tasks/memoir_tasks.py` | -| 5 | 保持 `MemoirImagePromptService` 对外接口兼容(Orchestrator 内部调用) | - | - ---- - -## 七、跨模块协作(可选扩展) - -### 7.1 统一编排层(远期) - -若需在「对话结束 → 回忆录生成 → 图片生成」整条链路上做统一编排,可引入高层 Orchestrator: - -``` -┌────────────────────────────────────────────────────────────────┐ -│ LifeEchoOrchestrator(可选) │ -│ - 对话结束事件 → 触发回忆录编排 │ -│ - 回忆录完成 → 触发图片编排 │ -│ - 统一 trace_id、重试、配额校验 │ -└────────────────────────────────────────────────────────────────┘ -``` - -当前三个模块已在 pipeline / Celery 中串联,无需立即引入;待多 Agent 稳定后可评估是否抽象。 - -### 7.2 共享基础设施 - -- **LLM 调用**:各 Agent 通过 `get_llm_provider()` 获取,可统一包装为 `TracingLLM` 做 span 记录 -- **配置**:`MemoirImageSettings` 等可注入 Agent 构造函数,便于测试 mock -- **日志**:建议在每个 Agent 入口/出口打 `logger.info`,含 `agent_name`、`trace_id` - ---- - -## 八、目录结构建议 - -``` -api/app/agents/ -├── __init__.py # 导出 ChatOrchestrator, MemoirOrchestrator, ImagePromptOrchestrator 等 -├── base.py # AgentProtocol, AgentContext, AgentResult(可选) -├── conversation_agent.py # 保留为 facade,内部委托 ChatOrchestrator -├── memory_agent.py # 保留或标记 deprecated,由 MemoirOrchestrator 替代 -├── memoir_processor.py # BackgroundTaskRunner 保留,ContentAnalyzer/MemoirGenerator 可迁移到 memoir/ -├── chat/ -│ ├── __init__.py -│ ├── orchestrator.py # ChatOrchestrator -│ ├── profile_agent.py # ProfileAgent -│ ├── interview_agent.py # InterviewAgent -│ └── stage_detector.py # StageDetectorAgent(可选) -├── memoir/ -│ ├── __init__.py -│ ├── orchestrator.py # MemoirOrchestrator -│ ├── extraction_agent.py -│ ├── classification_agent.py -│ ├── narrative_agent.py -│ └── placeholder_agent.py -└── image_prompt/ - ├── __init__.py - ├── orchestrator.py # ImagePromptOrchestrator - └── prompt_agent.py # PromptGenerationAgent(或沿用 MemoirImagePromptService) -``` - ---- - -## 九、迁移路径 - -### 9.1 阶段划分 - -| 阶段 | 内容 | 风险 | -|------|------|------| -| **Phase 1** | 模块一 Chat 多 Agent 改造 | 低,可保留 ConversationAgent facade | -| **Phase 2** | 模块二 Memoir 多 Agent 改造 | 中,Celery 任务改动需充分测试 | -| **Phase 3** | 模块三 Image Prompt 多 Agent 改造 | 低,调用点少 | -| **Phase 4** | 清理旧实现、统一协议、补充测试与文档 | 低 | - -### 9.2 兼容策略 - -1. **Facade 保留**:`ConversationAgent` 在 Phase 1 后作为 thin wrapper,内部调用 `ChatOrchestrator`,pipeline 可不改或仅改 import -2. **Feature Flag**:可配置 `USE_MULTI_AGENT_CHAT=true` 等,便于灰度与回滚 -3. **测试**:每个 Phase 完成后,跑现有 HTTP/WebSocket/Celery 测试,确保行为一致 - -### 9.3 验收标准 - -- [ ] 三个模块均通过 Orchestrator + Specialist 模式工作 -- [ ] 现有功能行为与改造前一致(对话回复、回忆录生成、图片生成) -- [ ] 单元测试覆盖各 Specialist Agent -- [ ] 文档更新:AGENT.md 或新增 multi-agent.md 描述新架构 - ---- - -## 十、风险与注意事项 - -| 风险 | 缓解措施 | -|------|----------| -| Celery 任务中同步/异步混用 | Memoir/Image 相关 Agent 在 Celery 中需用同步 LLM 调用(`invoke`),保持与现有一致 | -| 状态一致性 | Orchestrator 负责事务边界,Memoir 模块的章节锁、state 更新逻辑保持不变 | -| 性能回归 | 多一层调用理论上增加极少量开销,可通过 benchmark 验证;避免不必要的 Agent 间序列化 | -| 过度抽象 | 若某模块 Specialist 仅一个,可简化为例常函数,不必强求「每个能力一个 Agent」 | - ---- - -## 十一、附录:现有调用关系速查 - -| 调用链 | 入口 | 核心逻辑位置 | -|--------|------|--------------| -| 用户发消息 → AI 回复 | `pipeline.process_user_message` | `ConversationAgent.generate_response_with_state` / `generate_profile_followup` | -| 对话结束 → 回忆录 | `pipeline.process_conversation_segments` → `process_memoir_segments.delay` | `memoir_tasks.process_memoir_segments` | -| 回忆录完成 → 补图 | `process_memoir_segments` 末尾 → `generate_chapter_images.delay` | `memoir_tasks.generate_chapter_images` → `MemoirImagePromptService.build_prompt` / `build_cover_prompt` | - ---- - -*文档版本:1.0 | 创建日期:2025-03-19* +- 不再为旧入口保留 facade、alias 或兼容任务名。 +- 新设计、测试、代码 review 一律以上述正式路径为准。 diff --git a/docs/实施总结.md b/docs/实施总结.md index e635fa7..5c1e488 100644 --- a/docs/实施总结.md +++ b/docs/实施总结.md @@ -45,12 +45,12 @@ - ✅ 章节分类规则 - ✅ 文本改写规则 -#### Agent 实现 -- ✅ ConversationAgent(对话引导) +#### Agent / 编排 +- ✅ `ChatOrchestrator` + Specialist Agents(对话引导) - 对话阶段检测 - 动态问题选择 - - 对话记忆管理 -- ✅ MemoryAgent(回忆录整理) + - 会话历史以 DB 为真源 +- ✅ `MemoirOrchestrator` + Specialist Agents(回忆录整理) - 章节分类 - 口语到书面语改写 - 章节生成和合并