feat: 修正章节排序和分类逻辑

- 新增 SQL 脚本以修正章节排序索引,确保与 8 个分类体系对齐。
- 更新 API 章节获取逻辑,始终返回所有 8 个预定义类别,未填充内容的类别使用占位符。
- 引入章节分类功能,支持从 5-stage 关键词映射到 8 个章节类别,提升内容分类准确性。
- 更新 Android 客户端以适应新的章节定义和占位逻辑,确保用户界面一致性。
This commit is contained in:
penghanyuan
2026-03-01 10:50:58 +01:00
parent c1e2fb31a0
commit 5125ee1564
4 changed files with 131 additions and 40 deletions

View File

@@ -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';

View File

@@ -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)

View File

@@ -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 提取信息
# 提取 slots5-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)

View File

@@ -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<ChapterDto>): List<ChapterDto> {
if (apiChapters.size >= DEFAULT_CHAPTERS.size) {
return apiChapters.sortedBy { it.order_index }
}
val apiCategoryMap = apiChapters.associateBy { it.category }
val result = mutableListOf<ChapterDto>()
@@ -67,7 +74,6 @@ private fun mergeChaptersWithDefaults(apiChapters: List<ChapterDto>): List<Chapt
if (existing != null) {
result.add(existing)
} else {
// 创建占位章节(空内容)
result.add(
ChapterDto(
id = "placeholder_${default.category}",
@@ -85,7 +91,6 @@ private fun mergeChaptersWithDefaults(apiChapters: List<ChapterDto>): List<Chapt
}
}
// 添加不在默认列表中的 API 章节(如 career_early, career_achievement 等)
for (apiChapter in apiChapters) {
if (!DEFAULT_CHAPTERS.any { it.category == apiChapter.category }) {
result.add(apiChapter)