feat: 修正章节排序和分类逻辑
- 新增 SQL 脚本以修正章节排序索引,确保与 8 个分类体系对齐。 - 更新 API 章节获取逻辑,始终返回所有 8 个预定义类别,未填充内容的类别使用占位符。 - 引入章节分类功能,支持从 5-stage 关键词映射到 8 个章节类别,提升内容分类准确性。 - 更新 Android 客户端以适应新的章节定义和占位逻辑,确保用户界面一致性。
This commit is contained in:
19
api/migrations/fix_chapter_order_index_v2.sql
Normal file
19
api/migrations/fix_chapter_order_index_v2.sql
Normal 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';
|
||||||
@@ -11,17 +11,36 @@ from database import get_async_db
|
|||||||
from database.models import Chapter as ChapterModel
|
from database.models import Chapter as ChapterModel
|
||||||
from database.models import User as UserModel
|
from database.models import User as UserModel
|
||||||
from middleware.auth import get_current_user
|
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"])
|
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])
|
@router.get("", response_model=List[dict])
|
||||||
async def get_chapters(
|
async def get_chapters(
|
||||||
current_user: UserModel = Depends(get_current_user),
|
current_user: UserModel = Depends(get_current_user),
|
||||||
is_new: Optional[bool] = Query(None, description="仅返回未读章节"),
|
is_new: Optional[bool] = Query(None, description="仅返回未读章节"),
|
||||||
db: AsyncSession = Depends(get_async_db)
|
db: AsyncSession = Depends(get_async_db)
|
||||||
):
|
):
|
||||||
"""获取用户所有章节(需要认证,仅返回 active 章节)"""
|
"""
|
||||||
|
获取用户所有章节(需要认证,仅返回 active 章节)。
|
||||||
|
始终返回全部 8 个预定义类别,没有内容的类别用占位符返回。
|
||||||
|
"""
|
||||||
stmt = select(ChapterModel).where(
|
stmt = select(ChapterModel).where(
|
||||||
ChapterModel.user_id == current_user.id,
|
ChapterModel.user_id == current_user.id,
|
||||||
ChapterModel.is_active == True
|
ChapterModel.is_active == True
|
||||||
@@ -31,22 +50,37 @@ async def get_chapters(
|
|||||||
stmt = stmt.order_by(ChapterModel.order_index)
|
stmt = stmt.order_by(ChapterModel.order_index)
|
||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
chapters = result.scalars().all()
|
chapters = result.scalars().all()
|
||||||
|
|
||||||
return [
|
chapter_by_category: dict[str, ChapterModel] = {}
|
||||||
{
|
for ch in chapters:
|
||||||
"id": ch.id,
|
if ch.category and ch.category not in chapter_by_category:
|
||||||
"title": ch.title,
|
chapter_by_category[ch.category] = ch
|
||||||
"content": ch.content,
|
|
||||||
"order_index": ch.order_index,
|
all_chapters: List[dict] = []
|
||||||
"status": ch.status,
|
for category in CHAPTER_ORDER:
|
||||||
"category": ch.category,
|
ch = chapter_by_category.pop(category, None)
|
||||||
"images": ch.images or [],
|
if ch:
|
||||||
"updated_at": ch.updated_at.isoformat() if ch.updated_at else None,
|
all_chapters.append(_chapter_to_dict(ch))
|
||||||
"is_new": ch.is_new,
|
else:
|
||||||
"source_segments": ch.source_segments or [],
|
if is_new is True:
|
||||||
}
|
continue
|
||||||
for ch in chapters
|
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)
|
@router.get("/{chapter_id}", response_model=dict)
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ from agents.prompts.memory_prompts import (
|
|||||||
get_creative_title_prompt,
|
get_creative_title_prompt,
|
||||||
get_narrative_prompt,
|
get_narrative_prompt,
|
||||||
get_state_extraction_prompt,
|
get_state_extraction_prompt,
|
||||||
|
get_chapter_classification_prompt,
|
||||||
STAGE_TO_ORDER,
|
STAGE_TO_ORDER,
|
||||||
|
CHAPTER_CATEGORIES,
|
||||||
)
|
)
|
||||||
from agents.prompts.profile_prompts import format_user_profile_context
|
from agents.prompts.profile_prompts import format_user_profile_context
|
||||||
|
|
||||||
@@ -77,9 +79,18 @@ STAGE_KEYWORDS = {
|
|||||||
"belief": ["信念", "价值观", "座右铭", "坚持", "原则"],
|
"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:
|
def _detect_stage(user_message: str, fallback_stage: str) -> str:
|
||||||
"""检测消息所属阶段"""
|
"""检测消息所属的 5-stage 阶段(用于状态跟踪)"""
|
||||||
message = user_message.lower()
|
message = user_message.lower()
|
||||||
for stage, keywords in STAGE_KEYWORDS.items():
|
for stage, keywords in STAGE_KEYWORDS.items():
|
||||||
if any(word in message for word in keywords):
|
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
|
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:
|
def _coerce_state(model: MemoirState) -> MemoirStateSchema:
|
||||||
"""将数据库模型转换为 Schema"""
|
"""将数据库模型转换为 Schema"""
|
||||||
return MemoirStateSchema.model_validate(
|
return MemoirStateSchema.model_validate(
|
||||||
@@ -196,14 +226,16 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]):
|
|||||||
occupation=user_obj.occupation,
|
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:
|
for segment in segments:
|
||||||
text = segment.transcript_text
|
text = segment.transcript_text
|
||||||
detected_stage = _detect_stage(text, state.current_stage)
|
detected_stage = _detect_stage(text, state.current_stage)
|
||||||
|
|
||||||
# 尝试使用 LLM 提取信息
|
# 提取 slots(5-stage 状态跟踪)
|
||||||
extracted_slots = {}
|
extracted_slots = {}
|
||||||
if llm:
|
if llm:
|
||||||
try:
|
try:
|
||||||
@@ -219,8 +251,7 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]):
|
|||||||
extracted_slots = parsed.get("slots", {}) or {}
|
extracted_slots = parsed.get("slots", {}) or {}
|
||||||
except (json.JSONDecodeError, Exception) as e:
|
except (json.JSONDecodeError, Exception) as e:
|
||||||
logger.warning(f"LLM 解析失败: {e}")
|
logger.warning(f"LLM 解析失败: {e}")
|
||||||
|
|
||||||
# 更新 slots
|
|
||||||
for slot_name, snippet in extracted_slots.items():
|
for slot_name, snippet in extracted_slots.items():
|
||||||
state = _update_slot_sync(
|
state = _update_slot_sync(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
@@ -230,11 +261,13 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]):
|
|||||||
segment_ids=[segment.id],
|
segment_ids=[segment.id],
|
||||||
db=db,
|
db=db,
|
||||||
)
|
)
|
||||||
|
|
||||||
stage_to_segments.setdefault(detected_stage, []).append(segment)
|
# 8-category 章节分类
|
||||||
|
chapter_category = _classify_chapter_category(text, detected_stage, llm)
|
||||||
# 生成章节内容
|
category_to_segments.setdefault(chapter_category, []).append(segment)
|
||||||
for stage, stage_segments in stage_to_segments.items():
|
|
||||||
|
# 按 8 分类生成章节内容
|
||||||
|
for stage, stage_segments in category_to_segments.items():
|
||||||
if not _acquire_chapter_lock(user_id, stage):
|
if not _acquire_chapter_lock(user_id, stage):
|
||||||
logger.warning(f"章节锁竞争: user={user_id}, stage={stage}, 延迟重试")
|
logger.warning(f"章节锁竞争: user={user_id}, stage={stage}, 延迟重试")
|
||||||
raise self.retry(countdown=10)
|
raise self.retry(countdown=10)
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ import com.huaga.life_echo.ui.viewmodel.ViewModelFactory
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 默认章节定义(5个人生阶段)
|
* 默认章节定义(8 个章节类别)
|
||||||
* 即使没有从API获取到内容,也会显示在页面上
|
* 后端 API 已返回全部章节(含占位),此处作为离线兜底
|
||||||
*/
|
*/
|
||||||
private data class DefaultChapterInfo(
|
private data class DefaultChapterInfo(
|
||||||
val category: String,
|
val category: String,
|
||||||
@@ -47,18 +47,25 @@ private data class DefaultChapterInfo(
|
|||||||
)
|
)
|
||||||
|
|
||||||
private val DEFAULT_CHAPTERS = listOf(
|
private val DEFAULT_CHAPTERS = listOf(
|
||||||
DefaultChapterInfo("childhood", "童年时光", 0),
|
DefaultChapterInfo("childhood", "童年与成长背景", 0),
|
||||||
DefaultChapterInfo("education", "求学经历", 1),
|
DefaultChapterInfo("education", "教育经历与青年时期", 1),
|
||||||
DefaultChapterInfo("career", "职业生涯", 2),
|
DefaultChapterInfo("career_early", "崭露头角", 2),
|
||||||
DefaultChapterInfo("family", "家庭生活", 5),
|
DefaultChapterInfo("career_achievement", "主要成就与巅峰时刻", 3),
|
||||||
DefaultChapterInfo("belief", "人生信念", 6),
|
DefaultChapterInfo("career_challenge", "挑战与重大转折", 4),
|
||||||
|
DefaultChapterInfo("family", "家庭与情感", 5),
|
||||||
|
DefaultChapterInfo("beliefs", "信念与价值观", 6),
|
||||||
|
DefaultChapterInfo("summary", "人生总结", 7),
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 合并 API 章节和默认章节占位
|
* 合并 API 章节和默认章节占位
|
||||||
* 确保所有5个阶段都有显示,有内容的用API数据,没内容的用占位
|
* 后端 API 已返回含占位的完整列表,此处做兜底确保所有类别都显示
|
||||||
*/
|
*/
|
||||||
private fun mergeChaptersWithDefaults(apiChapters: List<ChapterDto>): List<ChapterDto> {
|
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 apiCategoryMap = apiChapters.associateBy { it.category }
|
||||||
val result = mutableListOf<ChapterDto>()
|
val result = mutableListOf<ChapterDto>()
|
||||||
|
|
||||||
@@ -67,7 +74,6 @@ private fun mergeChaptersWithDefaults(apiChapters: List<ChapterDto>): List<Chapt
|
|||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
result.add(existing)
|
result.add(existing)
|
||||||
} else {
|
} else {
|
||||||
// 创建占位章节(空内容)
|
|
||||||
result.add(
|
result.add(
|
||||||
ChapterDto(
|
ChapterDto(
|
||||||
id = "placeholder_${default.category}",
|
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) {
|
for (apiChapter in apiChapters) {
|
||||||
if (!DEFAULT_CHAPTERS.any { it.category == apiChapter.category }) {
|
if (!DEFAULT_CHAPTERS.any { it.category == apiChapter.category }) {
|
||||||
result.add(apiChapter)
|
result.add(apiChapter)
|
||||||
|
|||||||
Reference in New Issue
Block a user