update: 更新伏笔管理删除和同步逻辑,避免重复引入

This commit is contained in:
xiamuceer
2026-04-09 09:15:11 +08:00
parent 5968a3d29e
commit 425aab9eec
6 changed files with 357 additions and 19 deletions
+1 -1
View File
@@ -114,7 +114,7 @@ logo.ico
dist_embed/ dist_embed/
embed_build.py embed_build.py
.roo/
data/ data/
docs/ docs/
data_old/ data_old/
+74 -1
View File
@@ -146,6 +146,25 @@ async def analyze_chapter(
chapter.chapter_number 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 saved_count = 0
for mem_data in memories_data: for mem_data in memories_data:
@@ -178,6 +197,59 @@ async def analyze_chapter(
await db.commit() 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} foreshadow_stats = {"planted_count": 0, "resolved_count": 0, "created_count": 0}
analysis_foreshadows = analysis_result.get('foreshadows', []) analysis_foreshadows = analysis_result.get('foreshadows', [])
@@ -202,7 +274,8 @@ async def analyze_chapter(
"message": f"分析完成,提取了{saved_count}条记忆", "message": f"分析完成,提取了{saved_count}条记忆",
"analysis": plot_analysis.to_dict(), "analysis": plot_analysis.to_dict(),
"memories_count": saved_count, "memories_count": saved_count,
"foreshadow_stats": foreshadow_stats "foreshadow_stats": foreshadow_stats,
"entity_changes": entity_changes
} }
except HTTPException: except HTTPException:
+131 -5
View File
@@ -9,6 +9,8 @@ import hashlib
from app.models.foreshadow import Foreshadow from app.models.foreshadow import Foreshadow
from app.models.chapter import Chapter from app.models.chapter import Chapter
from app.models.memory import PlotAnalysis, StoryMemory 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 ( from app.schemas.foreshadow import (
ForeshadowCreate, ForeshadowUpdate, ForeshadowCreate, ForeshadowUpdate,
PlantForeshadowRequest, ResolveForeshadowRequest, PlantForeshadowRequest, ResolveForeshadowRequest,
@@ -232,16 +234,134 @@ class ForeshadowService:
db: AsyncSession, db: AsyncSession,
foreshadow_id: str foreshadow_id: str
) -> bool: ) -> bool:
"""删除伏笔""" """删除伏笔,并同步清理关联记忆数据和历史分析引用"""
try: try:
foreshadow = await self.get_foreshadow(db, foreshadow_id) foreshadow = await self.get_foreshadow(db, foreshadow_id)
if not foreshadow: if not foreshadow:
return False 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.delete(foreshadow)
await db.commit() 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 return True
except Exception as e: except Exception as e:
@@ -1173,16 +1293,22 @@ class ForeshadowService:
matched_by_content = False matched_by_content = False
# 策略1: 优先使用 reference_id 精确匹配 # 策略1: 优先使用 reference_id 精确匹配
# 重要:如果分析结果里已经给出了 reference_foreshadow_id
# 但该伏笔已被用户删除或已不属于当前项目,则直接跳过,
# 不再退回内容匹配,避免把“已删除伏笔”的旧分析结果错误同步到其他同名/近似伏笔上。
if reference_id: if reference_id:
existing = await self.get_foreshadow(db, reference_id) existing = await self.get_foreshadow(db, reference_id)
if existing and existing.project_id == project_id: if existing and existing.project_id == project_id:
logger.info(f"🎯 通过ID精确匹配伏笔: {existing.title}") logger.info(f"🎯 通过ID精确匹配伏笔: {existing.title}")
else: else:
existing = None 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匹配失败时 # 策略2: 内容匹配备用机制(仅在 analysis 未提供 reference_id 时启用
if not existing and planted_foreshadows: if not reference_id and not existing and planted_foreshadows:
matched = self._match_foreshadow_by_content( matched = self._match_foreshadow_by_content(
fs_data, planted_foreshadows fs_data, planted_foreshadows
) )
+53
View File
@@ -723,6 +723,59 @@ class MemoryService:
return "\n".join(lines) + "\n" 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( async def delete_chapter_memories(
self, self,
user_id: str, user_id: str,
+73 -1
View File
@@ -333,7 +333,14 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
const renderAnalysisResult = () => { const renderAnalysisResult = () => {
if (!analysis) return null; 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 ( return (
<Tabs <Tabs
@@ -411,6 +418,71 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
</Card> </Card>
)} )}
{hasEntityChanges && entity_changes && (
<Card title="实体联动更新" style={{ marginBottom: 16 }} size={isMobile ? 'small' : 'default'}>
<Row gutter={isMobile ? 8 : 16} style={{ marginBottom: 16 }}>
<Col span={isMobile ? 24 : 8}>
<Statistic
title="职业更新"
value={entity_changes.careers?.updated_count || 0}
/>
</Col>
<Col span={isMobile ? 24 : 8}>
<Statistic
title="角色状态/关系更新"
value={
(entity_changes.character_states?.state_updated_count || 0) +
(entity_changes.character_states?.relationship_created_count || 0) +
(entity_changes.character_states?.relationship_updated_count || 0) +
(entity_changes.character_states?.org_updated_count || 0)
}
/>
</Col>
<Col span={isMobile ? 24 : 8}>
<Statistic
title="组织状态更新"
value={entity_changes.organization_states?.updated_count || 0}
/>
</Col>
</Row>
{entity_changes.careers?.changes?.length ? (
<div style={{ marginBottom: 12 }}>
<strong></strong>
<div style={{ marginTop: 8 }}>
{entity_changes.careers.changes.map((change, index) => (
<Tag key={`career-${index}`} color="blue" style={{ marginBottom: 8 }}>
{change}
</Tag>
))}
</div>
</div>
) : null}
{entity_changes.character_states?.changes?.length ? (
<div style={{ marginBottom: 12 }}>
<strong>/</strong>
<List
size="small"
dataSource={entity_changes.character_states.changes}
renderItem={(item) => <List.Item>{item}</List.Item>}
/>
</div>
) : null}
{entity_changes.organization_states?.changes?.length ? (
<div>
<strong></strong>
<List
size="small"
dataSource={entity_changes.organization_states.changes}
renderItem={(item) => <List.Item>{item}</List.Item>}
/>
</div>
) : null}
</Card>
)}
{analysis_data.suggestions && analysis_data.suggestions.length > 0 && ( {analysis_data.suggestions && analysis_data.suggestions.length > 0 && (
<Card title={<><BulbOutlined /> </>} size={isMobile ? 'small' : 'default'}> <Card title={<><BulbOutlined /> </>} size={isMobile ? 'small' : 'default'}>
<List <List
+14
View File
@@ -744,12 +744,26 @@ export interface StoryMemory {
is_foreshadow: 0 | 1 | 2; // 0=普通, 1=已埋下, 2=已回收 is_foreshadow: 0 | 1 | 2; // 0=普通, 1=已埋下, 2=已回收
} }
export interface EntityChangesSummaryItem {
updated_count?: number;
state_updated_count?: number;
relationship_created_count?: number;
relationship_updated_count?: number;
org_updated_count?: number;
changes: string[];
}
// 章节分析结果响应 - 匹配后端API返回 // 章节分析结果响应 - 匹配后端API返回
export interface ChapterAnalysisResponse { export interface ChapterAnalysisResponse {
chapter_id: string; chapter_id: string;
analysis: AnalysisData; // 注意:后端返回的是analysis而不是analysis_data analysis: AnalysisData; // 注意:后端返回的是analysis而不是analysis_data
memories: StoryMemory[]; memories: StoryMemory[];
created_at: string; created_at: string;
entity_changes?: {
careers: EntityChangesSummaryItem;
character_states: EntityChangesSummaryItem;
organization_states: EntityChangesSummaryItem;
};
} }
// 手动触发分析响应 // 手动触发分析响应