From 5125ee15649f8bac42a3dc0c065606dfcd23e7fb Mon Sep 17 00:00:00 2001 From: penghanyuan Date: Sun, 1 Mar 2026 10:50:58 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BF=AE=E6=AD=A3=E7=AB=A0=E8=8A=82?= =?UTF-8?q?=E6=8E=92=E5=BA=8F=E5=92=8C=E5=88=86=E7=B1=BB=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 SQL 脚本以修正章节排序索引,确保与 8 个分类体系对齐。 - 更新 API 章节获取逻辑,始终返回所有 8 个预定义类别,未填充内容的类别使用占位符。 - 引入章节分类功能,支持从 5-stage 关键词映射到 8 个章节类别,提升内容分类准确性。 - 更新 Android 客户端以适应新的章节定义和占位逻辑,确保用户界面一致性。 --- api/migrations/fix_chapter_order_index_v2.sql | 19 ++++++ api/routers/chapters.py | 68 ++++++++++++++----- api/tasks/memoir_tasks.py | 59 ++++++++++++---- .../life_echo/ui/screens/MyMemoirScreen.kt | 25 ++++--- 4 files changed, 131 insertions(+), 40 deletions(-) create mode 100644 api/migrations/fix_chapter_order_index_v2.sql diff --git a/api/migrations/fix_chapter_order_index_v2.sql b/api/migrations/fix_chapter_order_index_v2.sql new file mode 100644 index 0000000..59ca025 --- /dev/null +++ b/api/migrations/fix_chapter_order_index_v2.sql @@ -0,0 +1,19 @@ +-- 修正章节排序索引,与 8 分类体系对齐 +-- childhood=0, education=1, career_early=2, career_achievement=3, +-- career_challenge=4, family=5, beliefs=6, summary=7 +UPDATE chapters SET order_index = 0 WHERE category = 'childhood' AND order_index != 0; +UPDATE chapters SET order_index = 1 WHERE category = 'education' AND order_index != 1; +UPDATE chapters SET order_index = 2 WHERE category = 'career_early' AND order_index != 2; +UPDATE chapters SET order_index = 3 WHERE category = 'career_achievement' AND order_index != 3; +UPDATE chapters SET order_index = 4 WHERE category = 'career_challenge' AND order_index != 4; +UPDATE chapters SET order_index = 5 WHERE category = 'family' AND order_index != 5; +UPDATE chapters SET order_index = 6 WHERE category IN ('belief', 'beliefs') AND order_index != 6; +UPDATE chapters SET order_index = 7 WHERE category = 'summary' AND order_index != 7; + +-- 旧的 5-stage "career" 章节归入 career_early +UPDATE chapters SET category = 'career_early', order_index = 2 + WHERE category = 'career'; + +-- 旧的 "belief" 统一为 "beliefs" +UPDATE chapters SET category = 'beliefs' + WHERE category = 'belief'; diff --git a/api/routers/chapters.py b/api/routers/chapters.py index 3768d06..42355a0 100644 --- a/api/routers/chapters.py +++ b/api/routers/chapters.py @@ -11,17 +11,36 @@ from database import get_async_db from database.models import Chapter as ChapterModel from database.models import User as UserModel from middleware.auth import get_current_user +from agents.prompts.memory_prompts import CHAPTER_CATEGORIES, CHAPTER_ORDER, STAGE_TO_ORDER router = APIRouter(prefix="/api/chapters", tags=["chapters"]) +def _chapter_to_dict(ch: ChapterModel) -> dict: + return { + "id": ch.id, + "title": ch.title, + "content": ch.content, + "order_index": ch.order_index, + "status": ch.status, + "category": ch.category, + "images": ch.images or [], + "updated_at": ch.updated_at.isoformat() if ch.updated_at else None, + "is_new": ch.is_new, + "source_segments": ch.source_segments or [], + } + + @router.get("", response_model=List[dict]) async def get_chapters( current_user: UserModel = Depends(get_current_user), is_new: Optional[bool] = Query(None, description="仅返回未读章节"), db: AsyncSession = Depends(get_async_db) ): - """获取用户所有章节(需要认证,仅返回 active 章节)""" + """ + 获取用户所有章节(需要认证,仅返回 active 章节)。 + 始终返回全部 8 个预定义类别,没有内容的类别用占位符返回。 + """ stmt = select(ChapterModel).where( ChapterModel.user_id == current_user.id, ChapterModel.is_active == True @@ -31,22 +50,37 @@ async def get_chapters( stmt = stmt.order_by(ChapterModel.order_index) result = await db.execute(stmt) chapters = result.scalars().all() - - return [ - { - "id": ch.id, - "title": ch.title, - "content": ch.content, - "order_index": ch.order_index, - "status": ch.status, - "category": ch.category, - "images": ch.images or [], - "updated_at": ch.updated_at.isoformat() if ch.updated_at else None, - "is_new": ch.is_new, - "source_segments": ch.source_segments or [], - } - for ch in chapters - ] + + chapter_by_category: dict[str, ChapterModel] = {} + 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: + all_chapters.append(_chapter_to_dict(ch)) + else: + if is_new is True: + continue + all_chapters.append({ + "id": f"placeholder_{category}", + "title": CHAPTER_CATEGORIES[category], + "content": "", + "order_index": STAGE_TO_ORDER.get(category, 999), + "status": "empty", + "category": category, + "images": [], + "updated_at": None, + "is_new": False, + "source_segments": [], + }) + + for ch in chapter_by_category.values(): + all_chapters.append(_chapter_to_dict(ch)) + + return all_chapters @router.get("/{chapter_id}", response_model=dict) diff --git a/api/tasks/memoir_tasks.py b/api/tasks/memoir_tasks.py index 59a5237..c2de954 100644 --- a/api/tasks/memoir_tasks.py +++ b/api/tasks/memoir_tasks.py @@ -21,7 +21,9 @@ from agents.prompts.memory_prompts import ( get_creative_title_prompt, get_narrative_prompt, get_state_extraction_prompt, + get_chapter_classification_prompt, STAGE_TO_ORDER, + CHAPTER_CATEGORIES, ) from agents.prompts.profile_prompts import format_user_profile_context @@ -77,9 +79,18 @@ STAGE_KEYWORDS = { "belief": ["信念", "价值观", "座右铭", "坚持", "原则"], } +# 5-stage → 默认 8-category 映射(LLM 分类失败时的兜底) +_STAGE_TO_DEFAULT_CATEGORY = { + "childhood": "childhood", + "education": "education", + "career": "career_early", + "family": "family", + "belief": "beliefs", +} + def _detect_stage(user_message: str, fallback_stage: str) -> str: - """检测消息所属阶段""" + """检测消息所属的 5-stage 阶段(用于状态跟踪)""" message = user_message.lower() for stage, keywords in STAGE_KEYWORDS.items(): if any(word in message for word in keywords): @@ -87,6 +98,25 @@ def _detect_stage(user_message: str, fallback_stage: str) -> str: return fallback_stage +def _classify_chapter_category(text: str, fallback_stage: str, llm=None) -> str: + """ + 将内容分类到 8 个章节类别之一。 + 优先使用 LLM,失败则按 5-stage 关键词映射到默认类别。 + """ + if llm: + try: + prompt = get_chapter_classification_prompt(text) + response = llm.invoke(prompt) + category = response.content.strip().lower() + if category in CHAPTER_CATEGORIES: + return category + except Exception as e: + logger.warning(f"LLM 章节分类失败: {e}") + + stage = _detect_stage(text, fallback_stage) + return _STAGE_TO_DEFAULT_CATEGORY.get(stage, _STAGE_TO_DEFAULT_CATEGORY.get(fallback_stage, "childhood")) + + def _coerce_state(model: MemoirState) -> MemoirStateSchema: """将数据库模型转换为 Schema""" return MemoirStateSchema.model_validate( @@ -196,14 +226,16 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]): occupation=user_obj.occupation, ) - # 按阶段分组处理 - stage_to_segments: Dict[str, List[Segment]] = {} - + # 分两步处理: + # 1) 5-stage 状态跟踪(slots) + # 2) 8-category 章节分类(chapter creation) + category_to_segments: Dict[str, List[Segment]] = {} + for segment in segments: text = segment.transcript_text detected_stage = _detect_stage(text, state.current_stage) - - # 尝试使用 LLM 提取信息 + + # 提取 slots(5-stage 状态跟踪) extracted_slots = {} if llm: try: @@ -219,8 +251,7 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]): extracted_slots = parsed.get("slots", {}) or {} except (json.JSONDecodeError, Exception) as e: logger.warning(f"LLM 解析失败: {e}") - - # 更新 slots + for slot_name, snippet in extracted_slots.items(): state = _update_slot_sync( user_id=user_id, @@ -230,11 +261,13 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]): segment_ids=[segment.id], db=db, ) - - stage_to_segments.setdefault(detected_stage, []).append(segment) - - # 生成章节内容 - for stage, stage_segments in stage_to_segments.items(): + + # 8-category 章节分类 + chapter_category = _classify_chapter_category(text, detected_stage, llm) + category_to_segments.setdefault(chapter_category, []).append(segment) + + # 按 8 分类生成章节内容 + for stage, stage_segments in category_to_segments.items(): if not _acquire_chapter_lock(user_id, stage): logger.warning(f"章节锁竞争: user={user_id}, stage={stage}, 延迟重试") raise self.retry(countdown=10) diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/MyMemoirScreen.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/MyMemoirScreen.kt index 2e4133f..32ed9ac 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/MyMemoirScreen.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/MyMemoirScreen.kt @@ -37,8 +37,8 @@ import com.huaga.life_echo.ui.viewmodel.ViewModelFactory import kotlinx.coroutines.launch /** - * 默认章节定义(5个人生阶段) - * 即使没有从API获取到内容,也会显示在页面上 + * 默认章节定义(8 个章节类别) + * 后端 API 已返回全部章节(含占位),此处作为离线兜底 */ private data class DefaultChapterInfo( val category: String, @@ -47,18 +47,25 @@ private data class DefaultChapterInfo( ) private val DEFAULT_CHAPTERS = listOf( - DefaultChapterInfo("childhood", "童年时光", 0), - DefaultChapterInfo("education", "求学经历", 1), - DefaultChapterInfo("career", "职业生涯", 2), - DefaultChapterInfo("family", "家庭生活", 5), - DefaultChapterInfo("belief", "人生信念", 6), + DefaultChapterInfo("childhood", "童年与成长背景", 0), + DefaultChapterInfo("education", "教育经历与青年时期", 1), + DefaultChapterInfo("career_early", "崭露头角", 2), + DefaultChapterInfo("career_achievement", "主要成就与巅峰时刻", 3), + DefaultChapterInfo("career_challenge", "挑战与重大转折", 4), + DefaultChapterInfo("family", "家庭与情感", 5), + DefaultChapterInfo("beliefs", "信念与价值观", 6), + DefaultChapterInfo("summary", "人生总结", 7), ) /** * 合并 API 章节和默认章节占位 - * 确保所有5个阶段都有显示,有内容的用API数据,没内容的用占位 + * 后端 API 已返回含占位的完整列表,此处做兜底确保所有类别都显示 */ private fun mergeChaptersWithDefaults(apiChapters: List): List { + if (apiChapters.size >= DEFAULT_CHAPTERS.size) { + return apiChapters.sortedBy { it.order_index } + } + val apiCategoryMap = apiChapters.associateBy { it.category } val result = mutableListOf() @@ -67,7 +74,6 @@ private fun mergeChaptersWithDefaults(apiChapters: List): List): List