update: 更新伏笔管理删除和同步逻辑,避免重复引入
This commit is contained in:
+1
-1
@@ -114,7 +114,7 @@ logo.ico
|
||||
dist_embed/
|
||||
embed_build.py
|
||||
|
||||
|
||||
.roo/
|
||||
data/
|
||||
docs/
|
||||
data_old/
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<Tabs
|
||||
@@ -411,6 +418,71 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
|
||||
</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 && (
|
||||
<Card title={<><BulbOutlined /> 改进建议</>} size={isMobile ? 'small' : 'default'}>
|
||||
<List
|
||||
|
||||
@@ -744,12 +744,26 @@ export interface StoryMemory {
|
||||
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返回
|
||||
export interface ChapterAnalysisResponse {
|
||||
chapter_id: string;
|
||||
analysis: AnalysisData; // 注意:后端返回的是analysis而不是analysis_data
|
||||
memories: StoryMemory[];
|
||||
created_at: string;
|
||||
entity_changes?: {
|
||||
careers: EntityChangesSummaryItem;
|
||||
character_states: EntityChangesSummaryItem;
|
||||
organization_states: EntityChangesSummaryItem;
|
||||
};
|
||||
}
|
||||
|
||||
// 手动触发分析响应
|
||||
|
||||
Reference in New Issue
Block a user