diff --git a/.gitignore b/.gitignore index f0f97da..b6302eb 100644 --- a/.gitignore +++ b/.gitignore @@ -114,7 +114,7 @@ logo.ico dist_embed/ embed_build.py - +.roo/ data/ docs/ data_old/ diff --git a/backend/app/api/memories.py b/backend/app/api/memories.py index a7cd221..02c3560 100644 --- a/backend/app/api/memories.py +++ b/backend/app/api/memories.py @@ -145,12 +145,31 @@ async def analyze_chapter( chapter_id, chapter.chapter_number ) - + + # 重新分析前,先清理该章节旧记忆(关系库 + 向量库) + old_memories_result = await db.execute( + select(StoryMemory).where(StoryMemory.chapter_id == chapter_id) + ) + old_memories = old_memories_result.scalars().all() + for old_mem in old_memories: + await db.delete(old_mem) + await db.flush() + + if user_id: + try: + await memory_service.delete_chapter_memories( + user_id=user_id, + project_id=project_id, + chapter_id=chapter_id + ) + except Exception as vector_delete_error: + logger.warning(f"⚠️ 清理章节向量记忆失败(继续分析): {str(vector_delete_error)}") + # 保存记忆到数据库和向量库 saved_count = 0 for mem_data in memories_data: memory_id = str(uuid.uuid4()) - + # 保存到关系数据库 memory = StoryMemory( id=memory_id, @@ -164,7 +183,7 @@ async def analyze_chapter( **mem_data['metadata'] ) db.add(memory) - + # 保存到向量库 await memory_service.add_memory( user_id=user_id, @@ -175,13 +194,66 @@ async def analyze_chapter( metadata=mem_data['metadata'] ) saved_count += 1 - + await db.commit() - + + entity_changes = { + "careers": {"updated_count": 0, "changes": []}, + "character_states": { + "state_updated_count": 0, + "relationship_created_count": 0, + "relationship_updated_count": 0, + "org_updated_count": 0, + "changes": [] + }, + "organization_states": {"updated_count": 0, "changes": []} + } + + # 更新角色职业 / 角色状态关系 / 组织状态 + if analysis_result.get('character_states'): + try: + from app.services.career_update_service import CareerUpdateService + career_update_result = await CareerUpdateService.update_careers_from_analysis( + db=db, + project_id=project_id, + character_states=analysis_result.get('character_states', []), + chapter_id=chapter_id, + chapter_number=chapter.chapter_number + ) + entity_changes["careers"] = career_update_result + except Exception as career_error: + logger.error(f"⚠️ 更新角色职业失败(不影响分析结果): {str(career_error)}", exc_info=True) + + try: + from app.services.character_state_update_service import CharacterStateUpdateService + state_update_result = await CharacterStateUpdateService.update_from_analysis( + db=db, + project_id=project_id, + character_states=analysis_result.get('character_states', []), + chapter_id=chapter_id, + chapter_number=chapter.chapter_number + ) + entity_changes["character_states"] = state_update_result + except Exception as state_error: + logger.error(f"⚠️ 更新角色状态、关系和组织成员失败(不影响分析结果): {str(state_error)}", exc_info=True) + + if analysis_result.get('organization_states'): + try: + from app.services.character_state_update_service import CharacterStateUpdateService + org_state_result = await CharacterStateUpdateService.update_organization_states( + db=db, + project_id=project_id, + organization_states=analysis_result.get('organization_states', []), + chapter_number=chapter.chapter_number + ) + entity_changes["organization_states"] = org_state_result + except Exception as org_state_error: + logger.error(f"⚠️ 更新组织自身状态失败(不影响分析结果): {str(org_state_error)}", exc_info=True) + # 【新增】自动更新伏笔状态 foreshadow_stats = {"planted_count": 0, "resolved_count": 0, "created_count": 0} analysis_foreshadows = analysis_result.get('foreshadows', []) - + if analysis_foreshadows: try: foreshadow_stats = await foreshadow_service.auto_update_from_analysis( @@ -194,15 +266,16 @@ async def analyze_chapter( logger.info(f"📊 伏笔自动更新: 埋入{foreshadow_stats['planted_count']}个, 回收{foreshadow_stats['resolved_count']}个") except Exception as fs_error: logger.error(f"⚠️ 伏笔自动更新失败(不影响分析结果): {str(fs_error)}") - + logger.info(f"✅ 章节分析完成: 保存{saved_count}条记忆") - + return { "success": True, "message": f"分析完成,提取了{saved_count}条记忆", "analysis": plot_analysis.to_dict(), "memories_count": saved_count, - "foreshadow_stats": foreshadow_stats + "foreshadow_stats": foreshadow_stats, + "entity_changes": entity_changes } except HTTPException: diff --git a/backend/app/services/foreshadow_service.py b/backend/app/services/foreshadow_service.py index 90bc539..2c4fbb1 100644 --- a/backend/app/services/foreshadow_service.py +++ b/backend/app/services/foreshadow_service.py @@ -9,6 +9,8 @@ import hashlib from app.models.foreshadow import Foreshadow from app.models.chapter import Chapter from app.models.memory import PlotAnalysis, StoryMemory +from app.models.project import Project +from app.services.memory_service import memory_service from app.schemas.foreshadow import ( ForeshadowCreate, ForeshadowUpdate, PlantForeshadowRequest, ResolveForeshadowRequest, @@ -232,18 +234,136 @@ class ForeshadowService: db: AsyncSession, foreshadow_id: str ) -> bool: - """删除伏笔""" + """删除伏笔,并同步清理关联记忆数据和历史分析引用""" try: foreshadow = await self.get_foreshadow(db, foreshadow_id) if not foreshadow: return False - + + project_result = await db.execute( + select(Project).where(Project.id == foreshadow.project_id) + ) + project = project_result.scalar_one_or_none() + + deleted_memory_rows = 0 + deleted_vector_memories = 0 + cleaned_analysis_refs = 0 + cleaned_analysis_rows = 0 + foreshadow_keywords = [] + content_snippet = (foreshadow.content or "")[:50].strip() + + if foreshadow.title and foreshadow.title.strip(): + foreshadow_keywords.append(foreshadow.title.strip()) + + if content_snippet: + foreshadow_keywords.append(content_snippet) + + memory_conditions = [ + StoryMemory.project_id == foreshadow.project_id, + StoryMemory.memory_type == "foreshadow" + ] + keyword_conditions = [] + for keyword in foreshadow_keywords: + keyword_conditions.append(StoryMemory.content.contains(keyword)) + keyword_conditions.append(StoryMemory.title.contains(keyword)) + + if keyword_conditions: + delete_memory_query = delete(StoryMemory).where( + and_(*memory_conditions, or_(*keyword_conditions)) + ) + delete_memory_result = await db.execute(delete_memory_query) + deleted_memory_rows = delete_memory_result.rowcount or 0 + + if project and project.user_id and foreshadow_keywords: + deleted_vector_memories = await memory_service.delete_foreshadow_memories( + user_id=project.user_id, + project_id=foreshadow.project_id, + foreshadow_keywords=foreshadow_keywords + ) + + analysis_result = await db.execute( + select(PlotAnalysis).where(PlotAnalysis.project_id == foreshadow.project_id) + ) + project_analyses = analysis_result.scalars().all() + + for analysis in project_analyses: + analysis_foreshadows = analysis.foreshadows or [] + if not analysis_foreshadows: + continue + + original_count = len(analysis_foreshadows) + filtered_foreshadows = [] + removed_count = 0 + + for item in analysis_foreshadows: + if not isinstance(item, dict): + filtered_foreshadows.append(item) + continue + + should_remove = False + + # 1. 清理历史回收引用 + if item.get("reference_foreshadow_id") == foreshadow_id: + should_remove = True + + # 2. 清理历史埋入记录,避免“手动同步分析伏笔”再次从 PlotAnalysis 重建已删除伏笔 + if not should_remove and foreshadow.source_type == "analysis": + item_type = item.get("type") + item_content = (item.get("content") or "").strip() + item_title = (item.get("title") or "").strip() + item_source_memory_id = None + + if item_type == "planted" and item_content and analysis.chapter_id == foreshadow.plant_chapter_id: + item_source_memory_id = generate_stable_foreshadow_id( + analysis.chapter_id, + item_content, + item_type + ) + + if foreshadow.source_memory_id and item_source_memory_id == foreshadow.source_memory_id: + should_remove = True + elif ( + item_title + and foreshadow.title + and item_title == foreshadow.title.strip() + and content_snippet + and content_snippet in item_content + ): + should_remove = True + + if should_remove: + removed_count += 1 + continue + + filtered_foreshadows.append(item) + + if removed_count > 0: + analysis.foreshadows = filtered_foreshadows + analysis.foreshadows_planted = sum( + 1 for f in filtered_foreshadows + if isinstance(f, dict) and f.get('type') == 'planted' + ) + analysis.foreshadows_resolved = sum( + 1 for f in filtered_foreshadows + if isinstance(f, dict) and f.get('type') == 'resolved' + ) + cleaned_analysis_refs += removed_count + cleaned_analysis_rows += 1 + logger.info( + f"🧹 已清理章节分析 {analysis.chapter_id[:8]} 中 {removed_count} 条已删除伏笔历史记录 " + f"(原数量: {original_count}, 现数量: {len(filtered_foreshadows)})" + ) + await db.delete(foreshadow) await db.commit() - - logger.info(f"✅ 删除伏笔成功: {foreshadow.title}") + + logger.info( + f"✅ 删除伏笔成功: {foreshadow.title} " + f"(关系记忆清理: {deleted_memory_rows}条, 向量记忆清理: {deleted_vector_memories}条, " + f"历史分析引用清理: {cleaned_analysis_refs}条/{cleaned_analysis_rows}个分析)" + ) return True - + except Exception as e: await db.rollback() logger.error(f"❌ 删除伏笔失败: {str(e)}") @@ -1173,16 +1293,22 @@ class ForeshadowService: matched_by_content = False # 策略1: 优先使用 reference_id 精确匹配 + # 重要:如果分析结果里已经给出了 reference_foreshadow_id, + # 但该伏笔已被用户删除或已不属于当前项目,则直接跳过, + # 不再退回内容匹配,避免把“已删除伏笔”的旧分析结果错误同步到其他同名/近似伏笔上。 if reference_id: existing = await self.get_foreshadow(db, reference_id) if existing and existing.project_id == project_id: logger.info(f"🎯 通过ID精确匹配伏笔: {existing.title}") else: existing = None - logger.warning(f"⚠️ 伏笔ID不存在或不属于该项目: {reference_id}") + logger.warning(f"⚠️ 伏笔ID不存在或不属于该项目,跳过本次回收同步: {reference_id}") + stats["skipped_resolve_count"] = stats.get("skipped_resolve_count", 0) + 1 + stats["errors"].append(f"reference_foreshadow_id 无效或已删除: {reference_id}") + continue - # 策略2: 内容匹配备用机制(当没有reference_id或ID匹配失败时) - if not existing and planted_foreshadows: + # 策略2: 内容匹配备用机制(仅在 analysis 未提供 reference_id 时启用) + if not reference_id and not existing and planted_foreshadows: matched = self._match_foreshadow_by_content( fs_data, planted_foreshadows ) diff --git a/backend/app/services/memory_service.py b/backend/app/services/memory_service.py index 189a5fe..791a496 100644 --- a/backend/app/services/memory_service.py +++ b/backend/app/services/memory_service.py @@ -723,6 +723,59 @@ class MemoryService: return "\n".join(lines) + "\n" + async def delete_foreshadow_memories( + self, + user_id: str, + project_id: str, + foreshadow_keywords: List[str] + ) -> int: + """ + 根据伏笔关键词删除向量库中的相关伏笔记忆 + + 说明:当前记忆系统未持久化 [reference_foreshadow_id](backend/app/services/prompt_service.py:1109) / + [foreshadow_id](backend/app/services/foreshadow_service.py:230) 映射,因此这里采用内容关键词匹配作为清理策略, + 仅删除 [memory_type='foreshadow'](backend/app/models/memory.py:23) 的向量记忆。 + + Args: + user_id: 用户ID + project_id: 项目ID + foreshadow_keywords: 伏笔关键词列表 + + Returns: + 实际删除数量 + """ + try: + keywords = [kw.strip() for kw in foreshadow_keywords if kw and kw.strip()] + if not keywords: + return 0 + + collection = self.get_collection(user_id, project_id) + results = collection.get(where={"memory_type": "foreshadow"}) + + ids_to_delete = [] + documents = results.get('documents') or [] + metadatas = results.get('metadatas') or [] + result_ids = results.get('ids') or [] + + for index, memory_id in enumerate(result_ids): + document = documents[index] if index < len(documents) else "" + metadata = metadatas[index] if index < len(metadatas) else {} + title = str((metadata or {}).get('title', '')) + haystack = f"{title}\n{document}".lower() + + if any(keyword.lower() in haystack for keyword in keywords): + ids_to_delete.append(memory_id) + + if ids_to_delete: + collection.delete(ids=ids_to_delete) + logger.info(f"🗑️ 已删除项目{project_id[:8]}的{len(ids_to_delete)}条伏笔相关向量记忆") + + return len(ids_to_delete) + + except Exception as e: + logger.error(f"❌ 删除伏笔相关向量记忆失败: {str(e)}") + return 0 + async def delete_chapter_memories( self, user_id: str, diff --git a/frontend/src/components/ChapterAnalysis.tsx b/frontend/src/components/ChapterAnalysis.tsx index 914a9fb..29de37e 100644 --- a/frontend/src/components/ChapterAnalysis.tsx +++ b/frontend/src/components/ChapterAnalysis.tsx @@ -333,7 +333,14 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter const renderAnalysisResult = () => { if (!analysis) return null; - const { analysis: analysis_data, memories } = analysis; + const { analysis: analysis_data, memories, entity_changes } = analysis; + const hasEntityChanges = Boolean( + entity_changes && ( + (entity_changes.careers?.changes?.length || 0) > 0 || + (entity_changes.character_states?.changes?.length || 0) > 0 || + (entity_changes.organization_states?.changes?.length || 0) > 0 + ) + ); return ( )} + {hasEntityChanges && entity_changes && ( + + + + + + + + + + + + + + {entity_changes.careers?.changes?.length ? ( +
+ 职业变化: +
+ {entity_changes.careers.changes.map((change, index) => ( + + {change} + + ))} +
+
+ ) : null} + + {entity_changes.character_states?.changes?.length ? ( +
+ 角色/关系变化: + {item}} + /> +
+ ) : null} + + {entity_changes.organization_states?.changes?.length ? ( +
+ 组织状态变化: + {item}} + /> +
+ ) : null} +
+ )} + {analysis_data.suggestions && analysis_data.suggestions.length > 0 && ( 改进建议} size={isMobile ? 'small' : 'default'}>