From b56fc859cc3b027b2dc846c310b4f433c0049d4a Mon Sep 17 00:00:00 2001 From: yangshilin <2157598560@qq.com> Date: Thu, 19 Mar 2026 10:28:40 +0800 Subject: [PATCH 1/8] =?UTF-8?q?docs:=20=E5=A4=9Aagent=E6=9E=B6=E6=9E=84pla?= =?UTF-8?q?n=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/multi-agent-refactor-plan.md | 354 ++++++++++++++++++++++++ 1 file changed, 354 insertions(+) create mode 100644 docs/plans/multi-agent-refactor-plan.md diff --git a/docs/plans/multi-agent-refactor-plan.md b/docs/plans/multi-agent-refactor-plan.md new file mode 100644 index 0000000..fb231bc --- /dev/null +++ b/docs/plans/multi-agent-refactor-plan.md @@ -0,0 +1,354 @@ +# 三模块多 Agent 改造方案 + +> 本文档描述将「AI 回复用户」「生成回忆录」「生成图片提示词」三个模块改造为多 Agent 模式的修改方案,仅作设计参考,不执行代码修改。 + +--- + +## 一、概述 + +### 1.1 目标 + +将当前单体式 Agent 逻辑拆分为职责清晰、可独立演进的多 Agent 协同架构,实现: + +- **职责分离**:每个 Agent 专注单一任务,便于维护和测试 +- **可编排性**:通过 Orchestrator 统一调度,支持灵活编排与扩展 +- **可观测性**:Agent 边界清晰,便于链路追踪和问题定位 +- **渐进迁移**:在不破坏现有行为的前提下分阶段落地 + +### 1.2 适用范围 + +| 模块 | 当前实现位置 | 改造后预期 | +|------|--------------|------------| +| AI 回复用户 | `ConversationAgent` + pipeline | `ChatOrchestrator` + `ProfileAgent` + `InterviewAgent` | +| 生成回忆录 | `memoir_tasks` + `ContentAnalyzer`/`MemoirGenerator` | `MemoirOrchestrator` + 多个 Specialist Agent | +| 生成图片提示词 | `MemoirImagePromptService` | `ImagePromptOrchestrator` + `PromptGenerationAgent` | + +--- + +## 二、现状分析 + +### 2.1 当前架构简图 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ WebSocket pipeline / Celery tasks │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ [process_user_message] [process_memoir_segments] │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌──────────────────┐ ┌─────────────────────────┐ │ +│ │ ConversationAgent │ │ memoir_tasks (inline) │ │ +│ │ - extract_profile│ │ - state extraction │ │ +│ │ - generate_* │ │ - chapter classification│ │ +│ │ - generate_ │ │ - narrative generation │ │ +│ │ response_ │ │ - inject placeholder │ │ +│ │ with_state │ └───────────┬─────────────┘ │ +│ └──────────────────┘ │ │ +│ ▼ │ +│ ┌─────────────────────────┐ │ +│ │ generate_chapter_images │ │ +│ │ └─ MemoirImagePrompt │ │ +│ │ Service.build_* │ │ +│ └─────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 2.2 主要问题 + +1. **职责混杂**:`ConversationAgent` 同时做资料提取、资料追问、正式访谈,逻辑耦合 +2. **流程内聚**:回忆录处理全部写在 `process_memoir_segments` 单任务内,难以单测和替换子步骤 +3. **Service 非 Agent**:`MemoirImagePromptService` 仅为工具类,无编排与决策能力 +4. **缺乏统一抽象**:三个模块调用方式各异,无统一的 Agent 协议 + +--- + +## 三、多 Agent 架构设计 + +### 3.1 总体模式 + +采用 **Orchestrator + Specialist Agents** 模式: + +- **Orchestrator**:负责路由、决策、编排,不直接调用 LLM 完成具体生成 +- **Specialist Agent**:接收 Orchestrator 下发的任务,完成单一 LLM 任务并返回结构化结果 + +### 3.2 统一 Agent 协议(建议) + +为便于 DI、测试和可观测性,建议定义统一的 Agent 协议: + +```python +# 概念性协议,非实际代码 +class AgentProtocol(Protocol): + """Agent 基础协议""" + async def run(self, context: AgentContext) -> AgentResult: ... +``` + +- `AgentContext`:输入上下文(用户消息、状态、历史等) +- `AgentResult`:结构化输出(含 success/failure、data、trace_id) + +--- + +## 四、模块一:AI 回复用户(Chat) + +### 4.1 目标架构 + +``` + ┌─────────────────────────────┐ + │ ChatOrchestrator │ + │ - 判断 profile vs interview │ + │ - 路由到对应 Specialist │ + │ - 聚合结果并写入 Redis │ + └──────────────┬──────────────┘ + │ + ┌────────────────────┼────────────────────┐ + ▼ ▼ ▼ +┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐ +│ ProfileAgent │ │ InterviewAgent │ │ StageDetectorAgent │ +│ - 提取资料 │ │ - 状态感知回复 │ │ - 检测用户阶段 │ +│ - 资料追问 │ │ - 引导与追问 │ │ - 供 Orchestrator │ +│ - 开场白/问候 │ │ - 时代背景融入 │ │ 决策使用 │ +└─────────────────────┘ └─────────────────────┘ └─────────────────────┘ +``` + +### 4.2 Agent 职责 + +| Agent | 职责 | 对应现有逻辑 | +|-------|------|--------------| +| **ChatOrchestrator** | 根据 `missing_fields`、`memoir_state` 决定调用 ProfileAgent 或 InterviewAgent;统一管理 Redis 写入、错误处理 | `process_user_message` 中的 if/else 分支 | +| **ProfileAgent** | `extract_profile_from_message`、`generate_profile_followup`、`generate_profile_greeting` | `ConversationAgent` 中 profile 相关方法 | +| **InterviewAgent** | `generate_response_with_state`、`generate_opening_message` | `ConversationAgent` 中 interview 相关方法 | +| **StageDetectorAgent**(可选) | `_detect_user_stage`,可升级为 LLM/embedding 检测 | `ConversationAgent._detect_user_stage` | + +### 4.3 协作流程 + +1. Pipeline 调用 `ChatOrchestrator.run(conversation_id, user_message, user, segment, ...)` +2. Orchestrator 查询 `get_missing_profile_fields(user)`,若有缺失则调用 `ProfileAgent` +3. 否则获取 `MemoirState`,调用 `InterviewAgent`(可选:先调 `StageDetectorAgent` 获取 `detected_stage`) +4. 将 Agent 返回的 `responses` 写 Redis,返回给 pipeline 用于 WebSocket 下发 + +### 4.4 修改方案 + +| 步骤 | 操作 | 文件 | +|------|------|------| +| 1 | 新增 `ChatOrchestrator` 类 | `app/agents/chat/orchestrator.py` | +| 2 | 抽取 `ProfileAgent` | `app/agents/chat/profile_agent.py` | +| 3 | 抽取 `InterviewAgent` | `app/agents/chat/interview_agent.py` | +| 4 | 可选:抽取 `StageDetectorAgent` | `app/agents/chat/stage_detector.py` | +| 5 | 定义 `app/agents/chat/__init__.py` 统一导出 | - | +| 6 | 修改 `pipeline.process_user_message` 调用 `ChatOrchestrator` 而非 `ConversationAgent` | `app/features/conversation/ws/pipeline.py` | +| 7 | 保留 `ConversationAgent` 为 facade,内部委托给 Orchestrator(兼容期) | `app/agents/conversation_agent.py` | + +--- + +## 五、模块二:生成回忆录(Memoir) + +### 5.1 目标架构 + +``` + ┌─────────────────────────────────────────┐ + │ MemoirOrchestrator │ + │ - 按 segment 编排流水线 │ + │ - 管理章节锁、状态更新 │ + │ - 派发 generate_chapter_images │ + └──────────────────┬────────────────────────┘ + │ + ┌─────────────────────────────┼─────────────────────────────┐ + ▼ ▼ ▼ +┌──────────────────┐ ┌──────────────────────┐ ┌──────────────────┐ +│ ExtractionAgent │ │ ClassificationAgent │ │ NarrativeAgent │ +│ - state/slot │ │ - 8-category 分类 │ │ - 标题生成 │ +│ 提取 │ │ - 无价值跳过决策 │ │ - 叙事改写 │ +└──────────────────┘ └──────────────────────┘ └──────────────────┘ + │ │ │ + └─────────────────────────────┼─────────────────────────────┘ + │ + ▼ + ┌──────────────────────────┐ + │ PlaceholderInjectAgent │ + │ - inject_image_ │ + │ placeholder_template │ + └──────────────────────────┘ +``` + +### 5.2 Agent 职责 + +| Agent | 职责 | 对应现有逻辑 | +|-------|------|--------------| +| **MemoirOrchestrator** | 遍历 segments、按 category 聚合、调用各 Specialist、更新 state、写 DB、派发补图任务 | `process_memoir_segments` 主循环 | +| **ExtractionAgent** | 调用 `get_state_extraction_prompt`,解析 JSON 返回 `detected_stage`、`slots` | `get_state_extraction_prompt` + 解析 | +| **ClassificationAgent** | 调用 `get_chapter_classification_prompt` 或等价逻辑,返回 category 或 None(跳过) | `_classify_chapter_category` | +| **NarrativeAgent** | `get_creative_title_prompt`、`get_narrative_prompt`,生成标题和正文 | `get_creative_title_prompt`、`get_narrative_prompt` | +| **PlaceholderInjectAgent** | 纯函数式,对 narrative 做 `inject_image_placeholder_template` | `inject_image_placeholder_template` | + +### 5.3 协作流程 + +1. Celery 任务 `process_memoir_segments` 入口仅负责:取 segments、获取 db、调用 `MemoirOrchestrator.run(user_id, segment_ids, db)` +2. Orchestrator 对每个 segment:调 `ExtractionAgent` → 调 `ClassificationAgent`,若 None 则跳过 +3. 按 category 聚合后,对每个 category:调 `NarrativeAgent` 生成 title + narrative → 调 `PlaceholderInjectAgent` → 写入 sections +4. 若有待补图章节,派发 `generate_chapter_images` + +### 5.4 修改方案 + +| 步骤 | 操作 | 文件 | +|------|------|------| +| 1 | 新增 `MemoirOrchestrator` | `app/agents/memoir/orchestrator.py` | +| 2 | 抽取 `ExtractionAgent` | `app/agents/memoir/extraction_agent.py` | +| 3 | 抽取 `ClassificationAgent` | `app/agents/memoir/classification_agent.py` | +| 4 | 抽取 `NarrativeAgent` | `app/agents/memoir/narrative_agent.py` | +| 5 | 抽取 `PlaceholderInjectAgent`(或保留为 util) | `app/agents/memoir/placeholder_agent.py` | +| 6 | 定义 `app/agents/memoir/__init__.py` | - | +| 7 | 修改 `process_memoir_segments`:将主循环逻辑委托给 `MemoirOrchestrator` | `app/tasks/memoir_tasks.py` | +| 8 | 保留 `ContentAnalyzer`、`MemoirGenerator` 为内部实现或弃用 | `app/agents/memoir_processor.py` | + +--- + +## 六、模块三:生成图片提示词(Image Prompt) + +### 6.1 目标架构 + +``` + ┌─────────────────────────────────┐ + │ ImagePromptOrchestrator │ + │ - 区分封面 vs 正文配图 │ + │ - 调用 PromptGenerationAgent │ + │ - 回退逻辑与缓存(可选) │ + └────────────────┬────────────────┘ + │ + ▼ + ┌─────────────────────────────────┐ + │ PromptGenerationAgent │ + │ - build_prompt (正文) │ + │ - build_cover_prompt (封面) │ + │ - 风格映射、fallback │ + └─────────────────────────────────┘ +``` + +### 6.2 Agent 职责 + +| Agent | 职责 | 对应现有逻辑 | +|-------|------|--------------| +| **ImagePromptOrchestrator** | 根据调用方(封面/正文)选择 `build_prompt` 或 `build_cover_prompt`;统一异常处理和回退;可选:缓存相同输入的 prompt | `generate_chapter_images` 中调用 `prompt_service` 的代码 | +| **PromptGenerationAgent** | 接收 `chapter_title`、`chapter_category`、`description`、`context_excerpt`,调用 LLM 或 fallback 生成 `{prompt, style, size}` | `MemoirImagePromptService` 全部逻辑 | + +### 6.3 协作流程 + +1. `generate_chapter_images` 任务内,对封面和每个 section: + - 构造 `(chapter_title, category, description, context_excerpt)` 等输入 + - 调用 `ImagePromptOrchestrator.build_prompt` 或 `build_cover_prompt` +2. Orchestrator 内部委托 `PromptGenerationAgent`,失败时执行 fallback + +### 6.4 修改方案 + +| 步骤 | 操作 | 文件 | +|------|------|------| +| 1 | 新增 `ImagePromptOrchestrator` | `app/agents/image_prompt/orchestrator.py` | +| 2 | 将 `MemoirImagePromptService` 重命名/重构为 `PromptGenerationAgent` | `app/agents/image_prompt/prompt_agent.py` 或保留 `app/features/memoir/memoir_images/prompting.py` 作为底层 | +| 3 | 定义 `app/agents/image_prompt/__init__.py` | - | +| 4 | 修改 `generate_chapter_images`:通过 `ImagePromptOrchestrator` 获取 prompt | `app/tasks/memoir_tasks.py` | +| 5 | 保持 `MemoirImagePromptService` 对外接口兼容(Orchestrator 内部调用) | - | + +--- + +## 七、跨模块协作(可选扩展) + +### 7.1 统一编排层(远期) + +若需在「对话结束 → 回忆录生成 → 图片生成」整条链路上做统一编排,可引入高层 Orchestrator: + +``` +┌────────────────────────────────────────────────────────────────┐ +│ LifeEchoOrchestrator(可选) │ +│ - 对话结束事件 → 触发回忆录编排 │ +│ - 回忆录完成 → 触发图片编排 │ +│ - 统一 trace_id、重试、配额校验 │ +└────────────────────────────────────────────────────────────────┘ +``` + +当前三个模块已在 pipeline / Celery 中串联,无需立即引入;待多 Agent 稳定后可评估是否抽象。 + +### 7.2 共享基础设施 + +- **LLM 调用**:各 Agent 通过 `get_llm_provider()` 获取,可统一包装为 `TracingLLM` 做 span 记录 +- **配置**:`MemoirImageSettings` 等可注入 Agent 构造函数,便于测试 mock +- **日志**:建议在每个 Agent 入口/出口打 `logger.info`,含 `agent_name`、`trace_id` + +--- + +## 八、目录结构建议 + +``` +api/app/agents/ +├── __init__.py # 导出 ChatOrchestrator, MemoirOrchestrator, ImagePromptOrchestrator 等 +├── base.py # AgentProtocol, AgentContext, AgentResult(可选) +├── conversation_agent.py # 保留为 facade,内部委托 ChatOrchestrator +├── memory_agent.py # 保留或标记 deprecated,由 MemoirOrchestrator 替代 +├── memoir_processor.py # BackgroundTaskRunner 保留,ContentAnalyzer/MemoirGenerator 可迁移到 memoir/ +├── chat/ +│ ├── __init__.py +│ ├── orchestrator.py # ChatOrchestrator +│ ├── profile_agent.py # ProfileAgent +│ ├── interview_agent.py # InterviewAgent +│ └── stage_detector.py # StageDetectorAgent(可选) +├── memoir/ +│ ├── __init__.py +│ ├── orchestrator.py # MemoirOrchestrator +│ ├── extraction_agent.py +│ ├── classification_agent.py +│ ├── narrative_agent.py +│ └── placeholder_agent.py +└── image_prompt/ + ├── __init__.py + ├── orchestrator.py # ImagePromptOrchestrator + └── prompt_agent.py # PromptGenerationAgent(或沿用 MemoirImagePromptService) +``` + +--- + +## 九、迁移路径 + +### 9.1 阶段划分 + +| 阶段 | 内容 | 风险 | +|------|------|------| +| **Phase 1** | 模块一 Chat 多 Agent 改造 | 低,可保留 ConversationAgent facade | +| **Phase 2** | 模块二 Memoir 多 Agent 改造 | 中,Celery 任务改动需充分测试 | +| **Phase 3** | 模块三 Image Prompt 多 Agent 改造 | 低,调用点少 | +| **Phase 4** | 清理旧实现、统一协议、补充测试与文档 | 低 | + +### 9.2 兼容策略 + +1. **Facade 保留**:`ConversationAgent` 在 Phase 1 后作为 thin wrapper,内部调用 `ChatOrchestrator`,pipeline 可不改或仅改 import +2. **Feature Flag**:可配置 `USE_MULTI_AGENT_CHAT=true` 等,便于灰度与回滚 +3. **测试**:每个 Phase 完成后,跑现有 HTTP/WebSocket/Celery 测试,确保行为一致 + +### 9.3 验收标准 + +- [ ] 三个模块均通过 Orchestrator + Specialist 模式工作 +- [ ] 现有功能行为与改造前一致(对话回复、回忆录生成、图片生成) +- [ ] 单元测试覆盖各 Specialist Agent +- [ ] 文档更新:AGENT.md 或新增 multi-agent.md 描述新架构 + +--- + +## 十、风险与注意事项 + +| 风险 | 缓解措施 | +|------|----------| +| Celery 任务中同步/异步混用 | Memoir/Image 相关 Agent 在 Celery 中需用同步 LLM 调用(`invoke`),保持与现有一致 | +| 状态一致性 | Orchestrator 负责事务边界,Memoir 模块的章节锁、state 更新逻辑保持不变 | +| 性能回归 | 多一层调用理论上增加极少量开销,可通过 benchmark 验证;避免不必要的 Agent 间序列化 | +| 过度抽象 | 若某模块 Specialist 仅一个,可简化为例常函数,不必强求「每个能力一个 Agent」 | + +--- + +## 十一、附录:现有调用关系速查 + +| 调用链 | 入口 | 核心逻辑位置 | +|--------|------|--------------| +| 用户发消息 → AI 回复 | `pipeline.process_user_message` | `ConversationAgent.generate_response_with_state` / `generate_profile_followup` | +| 对话结束 → 回忆录 | `pipeline.process_conversation_segments` → `process_memoir_segments.delay` | `memoir_tasks.process_memoir_segments` | +| 回忆录完成 → 补图 | `process_memoir_segments` 末尾 → `generate_chapter_images.delay` | `memoir_tasks.generate_chapter_images` → `MemoirImagePromptService.build_prompt` / `build_cover_prompt` | + +--- + +*文档版本:1.0 | 创建日期:2025-03-19* From b16bb2b96c43d4be4cc4c15f66fe676b96cb6da3 Mon Sep 17 00:00:00 2001 From: yangshilin <2157598560@qq.com> Date: Thu, 19 Mar 2026 10:36:55 +0800 Subject: [PATCH 2/8] =?UTF-8?q?feat=20&=20refactor:=20=E9=87=8D=E6=9E=84ag?= =?UTF-8?q?ents=E7=9B=AE=E5=BD=95=E7=BB=93=E6=9E=84=EF=BC=9BAI=E5=9B=9E?= =?UTF-8?q?=E5=A4=8D=E6=A8=A1=E5=9D=97agent=E7=BB=93=E6=9E=84=E5=B0=81?= =?UTF-8?q?=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/agents/__init__.py | 15 +- api/app/agents/chat/__init__.py | 12 + api/app/agents/chat/conversation_agent.py | 139 ++++++++ api/app/agents/chat/helpers.py | 49 +++ api/app/agents/chat/interview_agent.py | 143 ++++++++ api/app/agents/chat/orchestrator.py | 246 +++++++++++++ api/app/agents/chat/profile_agent.py | 132 +++++++ api/app/agents/conversation_agent.py | 355 ------------------- api/app/agents/memoir_processor.py | 211 ----------- api/app/agents/memory_agent.py | 130 ------- api/app/features/conversation/ws/pipeline.py | 87 +---- api/app/features/memoir/processor.py | 2 +- api/routers/chapters.py | 2 +- api/routers/websocket.py | 12 +- 14 files changed, 754 insertions(+), 781 deletions(-) create mode 100644 api/app/agents/chat/__init__.py create mode 100644 api/app/agents/chat/conversation_agent.py create mode 100644 api/app/agents/chat/helpers.py create mode 100644 api/app/agents/chat/interview_agent.py create mode 100644 api/app/agents/chat/orchestrator.py create mode 100644 api/app/agents/chat/profile_agent.py delete mode 100644 api/app/agents/conversation_agent.py delete mode 100644 api/app/agents/memoir_processor.py delete mode 100644 api/app/agents/memory_agent.py diff --git a/api/app/agents/__init__.py b/api/app/agents/__init__.py index 988a061..b1b613c 100644 --- a/api/app/agents/__init__.py +++ b/api/app/agents/__init__.py @@ -1,10 +1,19 @@ """ -Agent 模块(app 内,符合架构计划) +Agent 模块(按功能拆分:chat / memoir) """ -from app.agents.conversation_agent import ConversationAgent -from app.agents.memory_agent import MemoryAgent +from app.agents.chat import ( + ChatOrchestrator, + ConversationAgent, + InterviewAgent, + ProfileAgent, +) +from app.agents.memoir import BackgroundTaskRunner, MemoryAgent __all__ = [ "ConversationAgent", "MemoryAgent", + "ChatOrchestrator", + "ProfileAgent", + "InterviewAgent", + "BackgroundTaskRunner", ] diff --git a/api/app/agents/chat/__init__.py b/api/app/agents/chat/__init__.py new file mode 100644 index 0000000..32305dd --- /dev/null +++ b/api/app/agents/chat/__init__.py @@ -0,0 +1,12 @@ +"""聊天模块:AI 回复用户(ProfileAgent + InterviewAgent + ChatOrchestrator)""" +from app.agents.chat.conversation_agent import ConversationAgent +from app.agents.chat.orchestrator import ChatOrchestrator +from app.agents.chat.profile_agent import ProfileAgent +from app.agents.chat.interview_agent import InterviewAgent + +__all__ = [ + "ConversationAgent", + "ChatOrchestrator", + "ProfileAgent", + "InterviewAgent", +] diff --git a/api/app/agents/chat/conversation_agent.py b/api/app/agents/chat/conversation_agent.py new file mode 100644 index 0000000..27d8680 --- /dev/null +++ b/api/app/agents/chat/conversation_agent.py @@ -0,0 +1,139 @@ +""" +对话 Agent:Facade,内部委托 ChatOrchestrator + ProfileAgent + InterviewAgent +保留原有对外 API,供 router 等调用方兼容使用 +""" +from datetime import datetime +from typing import Any, Dict, List, Optional + +from app.agents.chat.orchestrator import ChatOrchestrator +from app.agents.prompts import ConversationStage +from app.agents.state_schema import MemoirStateSchema +from app.core.redis import redis_service + + +class ConversationAgent: + """对话 Agent Facade,委托 ChatOrchestrator 实现多 Agent 协同""" + + def __init__(self): + self._orchestrator = ChatOrchestrator() + + async def extract_profile_from_message( + self, + user_message: str, + missing_fields: List[str], + conversation_id: Optional[str] = None, + ) -> Dict[str, Any]: + """委托 ChatOrchestrator/ProfileAgent 提取资料""" + return await self._orchestrator.extract_profile_from_message( + user_message, missing_fields, conversation_id=conversation_id + ) + + async def generate_profile_followup( + self, + conversation_id: str, + user_message: str, + missing_fields: List[str], + filled_fields: Dict[str, str], + nickname: str = "", + is_from_voice: bool = False, + voice_session_id: str | None = None, + user_message_timestamp: datetime | None = None, + ) -> List[str]: + """委托 ChatOrchestrator/ProfileAgent 生成资料追问""" + return await self._orchestrator.generate_profile_followup( + conversation_id=conversation_id, + user_message=user_message, + missing_fields=missing_fields, + filled_fields=filled_fields, + nickname=nickname, + is_from_voice=is_from_voice, + voice_session_id=voice_session_id, + user_message_timestamp=user_message_timestamp, + ) + + async def generate_profile_greeting( + self, + conversation_id: str, + missing_fields: List[str], + nickname: str = "", + ) -> List[str]: + """委托 ChatOrchestrator/ProfileAgent 生成资料收集开场白""" + return await self._orchestrator.generate_profile_greeting( + conversation_id=conversation_id, + missing_fields=missing_fields, + nickname=nickname, + ) + + async def generate_response_with_state( + self, + conversation_id: str, + user_message: str, + memoir_state: MemoirStateSchema, + user_profile_context: str = "", + is_from_voice: bool = False, + voice_session_id: str | None = None, + user_message_timestamp: datetime | None = None, + ) -> List[str]: + """委托 ChatOrchestrator/InterviewAgent 生成访谈回复""" + return await self._orchestrator.generate_response_with_state( + conversation_id=conversation_id, + user_message=user_message, + memoir_state=memoir_state, + user_profile_context=user_profile_context, + is_from_voice=is_from_voice, + voice_session_id=voice_session_id, + user_message_timestamp=user_message_timestamp, + ) + + async def generate_opening_message( + self, + conversation_id: str, + memoir_state: MemoirStateSchema, + user_profile_context: str = "", + ) -> List[str]: + """委托 ChatOrchestrator/InterviewAgent 生成开场白""" + return await self._orchestrator.generate_opening_message( + conversation_id=conversation_id, + memoir_state=memoir_state, + user_profile_context=user_profile_context, + ) + + async def generate_response( + self, + conversation_id: str, + user_message: str, + current_stage: Optional[ConversationStage] = None, + covered_topics: Optional[List[str]] = None, + ) -> str: + """兼容旧 API:生成简单回复(无状态感知),委托 InterviewAgent 的等价逻辑""" + from app.agents.state_schema import default_state + + state = default_state() + state.current_stage = (current_stage or ConversationStage.CHILDHOOD).value + state.covered_stages = covered_topics or [] + responses = await self._orchestrator.generate_response_with_state( + conversation_id=conversation_id, + user_message=user_message, + memoir_state=state, + user_profile_context="", + ) + return responses[0] if responses else "" + + def detect_stage(self, conversation_id: str, user_message: str) -> ConversationStage: + """根据关键词检测用户阶段(兼容 API)""" + detected = self._orchestrator.detect_user_stage(user_message) + if detected == "childhood": + return ConversationStage.CHILDHOOD + if detected == "education": + return ConversationStage.EDUCATION + if detected == "career": + return ConversationStage.CAREER + if detected == "family": + return ConversationStage.FAMILY + if detected == "belief": + return ConversationStage.BELIEFS + return ConversationStage.CHILDHOOD + + async def clear_memory(self, conversation_id: str) -> None: + """清除 Redis 中的对话历史""" + await redis_service.clear_conversation_history(conversation_id) diff --git a/api/app/agents/chat/helpers.py b/api/app/agents/chat/helpers.py new file mode 100644 index 0000000..db2c666 --- /dev/null +++ b/api/app/agents/chat/helpers.py @@ -0,0 +1,49 @@ +"""聊天 Agent 共享工具:历史获取、格式化、存储""" +from datetime import datetime +from typing import Any, List + +from langchain_core.messages import AIMessage, HumanMessage + +from app.core.redis import redis_service + + +async def get_history_messages(conversation_id: str) -> List[Any]: + """从 Redis 获取对话历史""" + history = await redis_service.get_conversation_history(conversation_id) + messages = [] + for msg in history: + if msg["role"] == "human": + messages.append(HumanMessage(content=msg["content"])) + elif msg["role"] == "ai": + messages.append(AIMessage(content=msg["content"])) + return messages + + +def format_history_string(messages: List[Any]) -> str: + """将消息列表格式化为 Human/Assistant 字符串""" + history_parts = [] + for msg in messages: + if isinstance(msg, HumanMessage): + history_parts.append(f"Human: {msg.content}") + elif isinstance(msg, AIMessage): + history_parts.append(f"Assistant: {msg.content}") + return "\n\n".join(history_parts) + + +async def save_message( + conversation_id: str, + role: str, + content: str, + message_type: str = "text", + voice_session_id: str | None = None, + timestamp: datetime | str | int | None = None, +) -> None: + """保存消息到 Redis""" + await redis_service.add_message( + conversation_id, + role, + content, + message_type=message_type, + voice_session_id=voice_session_id, + timestamp=timestamp.isoformat() if isinstance(timestamp, datetime) else timestamp, + ) diff --git a/api/app/agents/chat/interview_agent.py b/api/app/agents/chat/interview_agent.py new file mode 100644 index 0000000..4bd045d --- /dev/null +++ b/api/app/agents/chat/interview_agent.py @@ -0,0 +1,143 @@ +""" +InterviewAgent:正式访谈 Specialist +负责状态感知回复、开场白,不负责 Redis 持久化(由 Orchestrator 统一处理) +""" +from typing import Any, List + +from app.core.dependencies import get_llm_provider +from app.core.logging import get_logger + +from app.agents.chat.helpers import format_history_string, get_history_messages +from app.agents.prompts import get_guided_conversation_prompt, get_opening_prompt +from app.agents.prompts.conversation_prompts import SLOT_NAME_MAP +from app.agents.state_schema import MemoirStateSchema + +logger = get_logger(__name__) + + +def _get_langchain_llm(): + try: + provider = get_llm_provider() + return getattr(provider, "langchain_llm", None) + except Exception: + return None + + +class InterviewAgent: + """正式访谈 Specialist Agent""" + + def __init__(self): + self.llm = _get_langchain_llm() + + def _detect_user_stage(self, user_message: str) -> str: + """根据关键词检测用户正在谈论的人生阶段""" + message = user_message.lower() + stage_keywords = { + "childhood": ["童年", "小时候", "出生", "家乡", "小镇", "爸妈", "父亲", "母亲", "爷爷", "奶奶", "外公", "外婆", "幼儿园"], + "education": ["上学", "学校", "老师", "同学", "教育", "大学", "高中", "初中", "小学", "考试", "毕业", "读书", "高考", "课堂"], + "career": ["工作", "职业", "事业", "公司", "同事", "创业", "升职", "跳槽", "老板", "行业", "项目", "加班", "薪水", "面试"], + "family": ["伴侣", "孩子", "家庭", "家人", "结婚", "爱人", "老婆", "老公", "丈夫", "妻子", "儿子", "女儿", "婚礼", "恋爱"], + "belief": ["信念", "价值观", "座右铭", "坚持", "原则", "信仰", "意义", "感悟", "遗憾", "骄傲"], + } + for stage, keywords in stage_keywords.items(): + if any(word in message for word in keywords): + return stage + return "" + + def _estimate_same_topic_turns( + self, history_messages: List[Any], current_filled_slots: dict + ) -> int: + """估算同一话题的连续轮数""" + if len(history_messages) < 4: + return len(history_messages) // 2 + recent_messages = history_messages[-6:] + keywords_per_turn = [] + for i in range(0, len(recent_messages), 2): + if i + 1 < len(recent_messages): + human_msg = ( + recent_messages[i].content + if hasattr(recent_messages[i], "content") + else str(recent_messages[i]) + ) + ai_msg = ( + recent_messages[i + 1].content + if hasattr(recent_messages[i + 1], "content") + else str(recent_messages[i + 1]) + ) + keywords_per_turn.append((human_msg + ai_msg)[:100]) + if len(keywords_per_turn) >= 3: + return 3 + return len(keywords_per_turn) + + async def generate_response_with_state( + self, + conversation_id: str, + user_message: str, + memoir_state: MemoirStateSchema, + user_profile_context: str = "", + ) -> List[str]: + """生成状态感知的访谈回复,不持久化(由 Orchestrator 负责)""" + if not self.llm: + return ["抱歉,LLM 服务未配置。请设置 DEEPSEEK_API_KEY 或 LLM_API_KEY 环境变量。"] + try: + empty_slots = memoir_state.empty_slots_for_current_stage() + filled_slots = { + key: value.snippet + for key, value in memoir_state.slots.get(memoir_state.current_stage, {}).items() + if value.snippet + } + detected_user_stage = self._detect_user_stage(user_message) + history_messages = await get_history_messages(conversation_id) + conversation_turn = len(history_messages) // 2 + same_topic_turns = self._estimate_same_topic_turns( + history_messages, filled_slots + ) + all_stages_coverage = memoir_state.all_stages_coverage() + system_prompt = get_guided_conversation_prompt( + current_stage=memoir_state.current_stage, + empty_slots=empty_slots, + filled_slots=filled_slots, + user_message=user_message, + conversation_turn=conversation_turn, + same_topic_turns=same_topic_turns, + all_stages_coverage=all_stages_coverage, + detected_user_stage=detected_user_stage, + user_profile_context=user_profile_context, + ) + history_string = format_history_string(history_messages) + full_prompt = f"{system_prompt}\n\n{history_string}\n\nHuman: {user_message}\n\nAssistant:" + response = await self.llm.ainvoke(full_prompt) + response_text = response.content if hasattr(response, "content") else str(response) + messages = [msg.strip() for msg in response_text.split("[SPLIT]") if msg.strip()] + return messages[:3] if messages else [response_text] + except Exception as e: + logger.error("生成回应失败: %s", e) + return [f"抱歉,生成回应时出现错误: {str(e)}"] + + async def generate_opening_message( + self, + conversation_id: str, + memoir_state: MemoirStateSchema, + user_profile_context: str = "", + ) -> List[str]: + """生成空对话开场白,不持久化(由 Orchestrator 负责)""" + if not self.llm: + return ["你好呀~ 有空聊聊你的人生故事吗?你小时候是在哪儿长大的?"] + try: + empty_slots = memoir_state.empty_slots_for_current_stage() + empty_slots_readable = [SLOT_NAME_MAP.get(s, s) for s in empty_slots] + if not empty_slots_readable: + empty_slots_readable = ["成长的地方", "难忘的事", "重要的人"] + prompt = get_opening_prompt( + current_stage=memoir_state.current_stage, + empty_slots_readable=empty_slots_readable, + user_profile_context=user_profile_context, + ) + full_prompt = f"{prompt}\n\nAssistant:" + response = await self.llm.ainvoke(full_prompt) + response_text = response.content if hasattr(response, "content") else str(response) + messages = [msg.strip() for msg in response_text.split("[SPLIT]") if msg.strip()] + return messages[:2] if messages else [response_text] + except Exception as e: + logger.error("生成开场白失败: %s", e, exc_info=True) + return ["你好呀~ 有空聊聊你的人生故事吗?你童年里印象最深的一件事是什么?"] diff --git a/api/app/agents/chat/orchestrator.py b/api/app/agents/chat/orchestrator.py new file mode 100644 index 0000000..1a5e66d --- /dev/null +++ b/api/app/agents/chat/orchestrator.py @@ -0,0 +1,246 @@ +""" +ChatOrchestrator:AI 回复用户模块的编排层 +负责路由(Profile vs Interview)、调用 Specialist Agent、统一 Redis 持久化与错误处理 +""" +from datetime import datetime +from typing import TYPE_CHECKING, List, Optional + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.agents.chat.helpers import save_message +from app.agents.chat.interview_agent import InterviewAgent +from app.agents.chat.profile_agent import ProfileAgent +from app.agents.state_schema import MemoirStateSchema +from app.core.logging import get_logger +from app.features.memoir.state_service import get_or_create_state + +if TYPE_CHECKING: + from app.features.user.models import User + +logger = get_logger(__name__) + + +class ChatOrchestrator: + """ + 聊天编排器:根据用户资料完成度路由到 ProfileAgent 或 InterviewAgent, + 统一管理 Redis 写入。 + """ + + def __init__(self): + self.profile_agent = ProfileAgent() + self.interview_agent = InterviewAgent() + + async def process_user_message( + self, + conversation_id: str, + user_message: str, + user: Optional["User"], + conversation, # 用于更新 conversation_stage + is_from_voice: bool, + voice_session_id: Optional[str], + db: AsyncSession, + apply_extracted_profile_fn, + get_missing_profile_fields_fn, + get_filled_profile_fields_fn, + user_message_timestamp: Optional[datetime] = None, + ) -> List[str]: + """ + 处理用户消息,返回 AI 回复列表。 + 根据 missing_fields 路由到 ProfileAgent 或 InterviewAgent, + 统一写入 Redis。 + """ + + # --- 资料收集模式 --- + if user: + missing = get_missing_profile_fields_fn(user) + if missing: + try: + extracted = await self.profile_agent.extract_profile_from_message( + user_message, missing, conversation_id=conversation_id + ) + if extracted: + await apply_extracted_profile_fn(user, extracted, db) + + remaining = get_missing_profile_fields_fn(user) + filled = get_filled_profile_fields_fn(user) + responses = await self.profile_agent.generate_profile_followup( + conversation_id=conversation_id, + user_message=user_message, + missing_fields=remaining, + filled_fields=filled, + nickname=user.nickname or "", + ) + await self._save_messages( + conversation_id=conversation_id, + user_message=user_message, + response_text="\n\n".join(responses), + is_from_voice=is_from_voice, + voice_session_id=voice_session_id, + user_message_timestamp=user_message_timestamp, + ) + return responses + except Exception as e: + logger.error(f"资料收集处理失败: {e}", exc_info=True) + + # --- 正式访谈模式 --- + user_id = user.id if user else None + if not user_id: + return ["抱歉,无法识别用户。"] + + state = await get_or_create_state(user_id, db) + if conversation and conversation.conversation_stage != state.current_stage: + conversation.conversation_stage = state.current_stage + await db.commit() + + from app.agents.prompts.profile_prompts import format_user_profile_context + + user_profile_context = "" + if user: + user_profile_context = format_user_profile_context( + birth_year=user.birth_year, + birth_place=user.birth_place, + grew_up_place=user.grew_up_place, + occupation=user.occupation, + ) + + responses = await self.interview_agent.generate_response_with_state( + conversation_id=conversation_id, + user_message=user_message, + memoir_state=state, + user_profile_context=user_profile_context, + ) + await self._save_messages( + conversation_id=conversation_id, + user_message=user_message, + response_text="\n\n".join(responses), + is_from_voice=is_from_voice, + voice_session_id=voice_session_id, + user_message_timestamp=user_message_timestamp, + ) + return responses + + async def _save_messages( + self, + conversation_id: str, + user_message: str, + response_text: str, + is_from_voice: bool = False, + voice_session_id: Optional[str] = None, + user_message_timestamp: Optional[datetime] = None, + ) -> None: + """统一写入 Human + AI 消息到 Redis""" + human_msg_type = "audio" if is_from_voice else "text" + await save_message( + conversation_id, + "human", + user_message, + message_type=human_msg_type, + voice_session_id=voice_session_id, + timestamp=user_message_timestamp, + ) + await save_message(conversation_id, "ai", response_text) + + async def extract_profile_from_message( + self, + user_message: str, + missing_fields: List[str], + conversation_id: Optional[str] = None, + ): + """委托 ProfileAgent 提取资料""" + return await self.profile_agent.extract_profile_from_message( + user_message, missing_fields, conversation_id=conversation_id + ) + + async def generate_profile_followup( + self, + conversation_id: str, + user_message: str, + missing_fields: List[str], + filled_fields: dict, + nickname: str = "", + is_from_voice: bool = False, + voice_session_id: str | None = None, + user_message_timestamp: datetime | None = None, + ) -> List[str]: + """委托 ProfileAgent 生成资料追问,并写入 Redis""" + responses = await self.profile_agent.generate_profile_followup( + conversation_id=conversation_id, + user_message=user_message, + missing_fields=missing_fields, + filled_fields=filled_fields, + nickname=nickname, + ) + response_text = "\n\n".join(responses) + await self._save_messages( + conversation_id=conversation_id, + user_message=user_message, + response_text=response_text, + is_from_voice=is_from_voice, + voice_session_id=voice_session_id, + user_message_timestamp=user_message_timestamp, + ) + return responses + + async def generate_profile_greeting( + self, + conversation_id: str, + missing_fields: List[str], + nickname: str = "", + ) -> List[str]: + """委托 ProfileAgent 生成资料收集开场白,并写入 Redis""" + responses = await self.profile_agent.generate_profile_greeting( + conversation_id=conversation_id, + missing_fields=missing_fields, + nickname=nickname, + ) + response_text = "\n\n".join(responses) + await save_message(conversation_id, "ai", response_text) + return responses + + async def generate_response_with_state( + self, + conversation_id: str, + user_message: str, + memoir_state: MemoirStateSchema, + user_profile_context: str = "", + is_from_voice: bool = False, + voice_session_id: str | None = None, + user_message_timestamp: datetime | None = None, + ) -> List[str]: + """委托 InterviewAgent 生成访谈回复,并写入 Redis""" + responses = await self.interview_agent.generate_response_with_state( + conversation_id=conversation_id, + user_message=user_message, + memoir_state=memoir_state, + user_profile_context=user_profile_context, + ) + response_text = "\n\n".join(responses) + await self._save_messages( + conversation_id=conversation_id, + user_message=user_message, + response_text=response_text, + is_from_voice=is_from_voice, + voice_session_id=voice_session_id, + user_message_timestamp=user_message_timestamp, + ) + return responses + + def detect_user_stage(self, user_message: str) -> str: + """委托 InterviewAgent 检测用户阶段""" + return self.interview_agent._detect_user_stage(user_message) + + async def generate_opening_message( + self, + conversation_id: str, + memoir_state: MemoirStateSchema, + user_profile_context: str = "", + ) -> List[str]: + """委托 InterviewAgent 生成开场白,并写入 Redis""" + responses = await self.interview_agent.generate_opening_message( + conversation_id=conversation_id, + memoir_state=memoir_state, + user_profile_context=user_profile_context, + ) + response_text = "\n\n".join(responses) + await save_message(conversation_id, "ai", response_text) + return responses diff --git a/api/app/agents/chat/profile_agent.py b/api/app/agents/chat/profile_agent.py new file mode 100644 index 0000000..ae7688b --- /dev/null +++ b/api/app/agents/chat/profile_agent.py @@ -0,0 +1,132 @@ +""" +ProfileAgent:用户资料收集 Specialist +负责提取资料、资料追问、资料收集开场白,不负责 Redis 持久化(由 Orchestrator 统一处理) +""" +import json +from typing import Any, Dict, List, Optional + +from langchain_core.messages import AIMessage, HumanMessage + +from app.core.dependencies import get_llm_provider +from app.core.logging import get_logger + +from app.agents.chat.helpers import format_history_string, get_history_messages +from app.agents.prompts.profile_prompts import ( + get_profile_extraction_prompt, + get_profile_followup_prompt, + get_profile_greeting_prompt, +) + +logger = get_logger(__name__) + + +def _get_langchain_llm(): + try: + provider = get_llm_provider() + return getattr(provider, "langchain_llm", None) + except Exception: + return None + + +class ProfileAgent: + """用户资料收集 Specialist Agent""" + + def __init__(self): + self.llm = _get_langchain_llm() + + async def extract_profile_from_message( + self, + user_message: str, + missing_fields: List[str], + conversation_id: Optional[str] = None, + ) -> Dict[str, Any]: + """从用户消息中提取资料字段,不持久化""" + if not self.llm or not missing_fields: + return {} + recent_dialogue = "" + if conversation_id: + history_messages = await get_history_messages(conversation_id) + recent = history_messages[-4:] if len(history_messages) > 4 else history_messages + parts = [] + for msg in recent: + if isinstance(msg, HumanMessage): + parts.append(f"用户: {msg.content}") + elif isinstance(msg, AIMessage): + parts.append(f"助手: {msg.content}") + recent_dialogue = "\n".join(parts) if parts else "" + try: + prompt = get_profile_extraction_prompt( + user_message, missing_fields, recent_dialogue=recent_dialogue or None + ) + response = await self.llm.ainvoke(prompt) + content = response.content.strip() + parsed = json.loads(content) + result = {} + if "birth_year" in parsed and parsed["birth_year"] is not None: + raw = parsed["birth_year"] + if isinstance(raw, int) and 1900 <= raw <= 2100: + result["birth_year"] = raw + elif isinstance(raw, str) and raw.isdigit(): + y = int(raw) + if y < 100: + y = 1900 + y if y >= 50 else 2000 + y + if 1900 <= y <= 2100: + result["birth_year"] = y + if "birth_place" in parsed and parsed["birth_place"]: + result["birth_place"] = str(parsed["birth_place"]) + if "grew_up_place" in parsed and parsed["grew_up_place"]: + result["grew_up_place"] = str(parsed["grew_up_place"]) + if "occupation" in parsed and parsed["occupation"]: + result["occupation"] = str(parsed["occupation"]) + return result + except (json.JSONDecodeError, Exception) as e: + logger.error("提取资料信息失败: %s", e) + return {} + + async def generate_profile_followup( + self, + conversation_id: str, + user_message: str, + missing_fields: List[str], + filled_fields: Dict[str, str], + nickname: str = "", + ) -> List[str]: + """生成资料追问回复,不持久化(由 Orchestrator 负责)""" + if not self.llm: + return ["谢谢!还能告诉我更多吗?"] + try: + prompt = get_profile_followup_prompt( + missing_fields, filled_fields, user_message, nickname + ) + history_messages = await get_history_messages(conversation_id) + history_string = format_history_string(history_messages) + full_prompt = f"{prompt}\n\n{history_string}\n\nHuman: {user_message}\n\nAssistant:" + response = await self.llm.ainvoke(full_prompt) + response_text = response.content if hasattr(response, "content") else str(response) + messages = [msg.strip() for msg in response_text.split("[SPLIT]") if msg.strip()] + return messages[:3] if messages else [response_text] + except Exception as e: + logger.error("生成资料跟进回复失败: %s", e) + return ["谢谢分享!能再告诉我一些吗?"] + + async def generate_profile_greeting( + self, + conversation_id: str, + missing_fields: List[str], + nickname: str = "", + ) -> List[str]: + """生成资料收集开场白,不持久化(由 Orchestrator 负责)""" + if not self.llm: + return ["你好!在开始之前,能告诉我你是哪一年出生的吗?"] + try: + prompt = get_profile_greeting_prompt(missing_fields, nickname) + history_messages = await get_history_messages(conversation_id) + history_string = format_history_string(history_messages) + full_prompt = f"{prompt}\n\n{history_string}" if history_string else prompt + response = await self.llm.ainvoke(full_prompt) + response_text = response.content if hasattr(response, "content") else str(response) + messages = [msg.strip() for msg in response_text.split("[SPLIT]") if msg.strip()] + return messages[:2] if messages else [response_text] + except Exception as e: + logger.error("生成资料收集开场白失败: %s", e) + return ["你好!在我们开始聊人生故事之前,能先简单介绍一下你自己吗?比如你是哪一年出生的?"] diff --git a/api/app/agents/conversation_agent.py b/api/app/agents/conversation_agent.py deleted file mode 100644 index 3430f51..0000000 --- a/api/app/agents/conversation_agent.py +++ /dev/null @@ -1,355 +0,0 @@ -""" -对话 Agent:基于访谈问题清单,动态选择问题,实时生成回应 -支持异步调用和 Redis 会话存储,支持用户基础资料收集和时代背景融入 -""" -import json -from app.core.logging import get_logger -from datetime import datetime -from typing import Any, Dict, List, Optional - -from langchain_core.messages import AIMessage, HumanMessage - -from app.core.redis import redis_service -from app.core.dependencies import get_llm_provider - -from app.agents.prompts import ( - ConversationStage, - get_conversation_prompt, - get_guided_conversation_prompt, - get_opening_prompt, -) -from app.agents.prompts.profile_prompts import ( - get_profile_greeting_prompt, - get_profile_extraction_prompt, - get_profile_followup_prompt, - format_user_profile_context, - get_missing_profile_fields, -) -from app.agents.prompts.conversation_prompts import SLOT_NAME_MAP -from app.agents.state_schema import MemoirStateSchema - -logger = get_logger(__name__) - - -def _get_langchain_llm(): - """从 port 获取 LangChain LLM 实例(供 Agent 使用)""" - try: - provider = get_llm_provider() - return getattr(provider, "langchain_llm", None) - except Exception: - return None - - -class ConversationAgent: - """对话 Agent(支持异步和 Redis 存储)""" - - def __init__(self): - self.llm = _get_langchain_llm() - - async def _get_history_messages(self, conversation_id: str) -> List[Any]: - history = await redis_service.get_conversation_history(conversation_id) - messages = [] - for msg in history: - if msg["role"] == "human": - messages.append(HumanMessage(content=msg["content"])) - elif msg["role"] == "ai": - messages.append(AIMessage(content=msg["content"])) - return messages - - async def _save_message( - self, - conversation_id: str, - role: str, - content: str, - message_type: str = "text", - voice_session_id: str | None = None, - timestamp: datetime | str | int | None = None, - ): - await redis_service.add_message( - conversation_id, - role, - content, - message_type=message_type, - voice_session_id=voice_session_id, - timestamp=timestamp.isoformat() if isinstance(timestamp, datetime) else timestamp, - ) - - def _format_history_string(self, messages: List[Any]) -> str: - history_parts = [] - for msg in messages: - if isinstance(msg, HumanMessage): - history_parts.append(f"Human: {msg.content}") - elif isinstance(msg, AIMessage): - history_parts.append(f"Assistant: {msg.content}") - return "\n\n".join(history_parts) - - async def generate_response( - self, - conversation_id: str, - user_message: str, - current_stage: Optional[ConversationStage] = None, - covered_topics: Optional[List[str]] = None, - ) -> str: - if current_stage is None: - current_stage = ConversationStage.CHILDHOOD - if covered_topics is None: - covered_topics = [] - if not self.llm: - return "抱歉,LLM 服务未配置。请设置 DEEPSEEK_API_KEY 或 LLM_API_KEY 环境变量。" - try: - system_prompt = get_conversation_prompt(current_stage, covered_topics, user_message) - history_messages = await self._get_history_messages(conversation_id) - history_string = self._format_history_string(history_messages) - full_prompt = f"{system_prompt}\n\n{history_string}\n\nHuman: {user_message}\n\nAssistant:" - response = await self.llm.ainvoke(full_prompt) - response_text = response.content if hasattr(response, "content") else str(response) - await self._save_message(conversation_id, "human", user_message) - await self._save_message(conversation_id, "ai", response_text) - return response_text - except Exception as e: - logger.error("生成回应失败: %s", e) - return f"抱歉,生成回应时出现错误: {str(e)}" - - async def generate_profile_greeting( - self, - conversation_id: str, - missing_fields: List[str], - nickname: str = "", - ) -> List[str]: - if not self.llm: - return ["你好!在开始之前,能告诉我你是哪一年出生的吗?"] - try: - prompt = get_profile_greeting_prompt(missing_fields, nickname) - history_messages = await self._get_history_messages(conversation_id) - history_string = self._format_history_string(history_messages) - full_prompt = f"{prompt}\n\n{history_string}" if history_string else prompt - response = await self.llm.ainvoke(full_prompt) - response_text = response.content if hasattr(response, "content") else str(response) - await self._save_message(conversation_id, "ai", response_text) - messages = [msg.strip() for msg in response_text.split("[SPLIT]") if msg.strip()] - return messages[:2] if messages else [response_text] - except Exception as e: - logger.error("生成资料收集开场白失败: %s", e) - return ["你好!在我们开始聊人生故事之前,能先简单介绍一下你自己吗?比如你是哪一年出生的?"] - - async def generate_opening_message( - self, - conversation_id: str, - memoir_state: MemoirStateSchema, - user_profile_context: str = "", - ) -> List[str]: - if not self.llm: - return ["你好呀~ 有空聊聊你的人生故事吗?你小时候是在哪儿长大的?"] - try: - empty_slots = memoir_state.empty_slots_for_current_stage() - empty_slots_readable = [SLOT_NAME_MAP.get(s, s) for s in empty_slots] - if not empty_slots_readable: - empty_slots_readable = ["成长的地方", "难忘的事", "重要的人"] - prompt = get_opening_prompt( - current_stage=memoir_state.current_stage, - empty_slots_readable=empty_slots_readable, - user_profile_context=user_profile_context, - ) - full_prompt = f"{prompt}\n\nAssistant:" - response = await self.llm.ainvoke(full_prompt) - response_text = response.content if hasattr(response, "content") else str(response) - await self._save_message(conversation_id, "ai", response_text) - messages = [msg.strip() for msg in response_text.split("[SPLIT]") if msg.strip()] - return messages[:2] if messages else [response_text] - except Exception as e: - logger.error("生成开场白失败: %s", e, exc_info=True) - return ["你好呀~ 有空聊聊你的人生故事吗?你童年里印象最深的一件事是什么?"] - - async def extract_profile_from_message( - self, - user_message: str, - missing_fields: List[str], - conversation_id: Optional[str] = None, - ) -> Dict[str, Any]: - if not self.llm or not missing_fields: - return {} - recent_dialogue = "" - if conversation_id: - history_messages = await self._get_history_messages(conversation_id) - recent = history_messages[-4:] if len(history_messages) > 4 else history_messages - parts = [] - for msg in recent: - if isinstance(msg, HumanMessage): - parts.append(f"用户: {msg.content}") - elif isinstance(msg, AIMessage): - parts.append(f"助手: {msg.content}") - recent_dialogue = "\n".join(parts) if parts else "" - try: - prompt = get_profile_extraction_prompt( - user_message, missing_fields, recent_dialogue=recent_dialogue or None - ) - response = await self.llm.ainvoke(prompt) - content = response.content.strip() - parsed = json.loads(content) - result = {} - if "birth_year" in parsed and parsed["birth_year"] is not None: - raw = parsed["birth_year"] - if isinstance(raw, int) and 1900 <= raw <= 2100: - result["birth_year"] = raw - elif isinstance(raw, str) and raw.isdigit(): - y = int(raw) - if y < 100: - y = 1900 + y if y >= 50 else 2000 + y - if 1900 <= y <= 2100: - result["birth_year"] = y - if "birth_place" in parsed and parsed["birth_place"]: - result["birth_place"] = str(parsed["birth_place"]) - if "grew_up_place" in parsed and parsed["grew_up_place"]: - result["grew_up_place"] = str(parsed["grew_up_place"]) - if "occupation" in parsed and parsed["occupation"]: - result["occupation"] = str(parsed["occupation"]) - return result - except (json.JSONDecodeError, Exception) as e: - logger.error("提取资料信息失败: %s", e) - return {} - - async def generate_profile_followup( - self, - conversation_id: str, - user_message: str, - missing_fields: List[str], - filled_fields: Dict[str, str], - nickname: str = "", - is_from_voice: bool = False, - voice_session_id: str | None = None, - user_message_timestamp: datetime | None = None, - ) -> List[str]: - if not self.llm: - return ["谢谢!还能告诉我更多吗?"] - try: - prompt = get_profile_followup_prompt(missing_fields, filled_fields, user_message, nickname) - history_messages = await self._get_history_messages(conversation_id) - history_string = self._format_history_string(history_messages) - full_prompt = f"{prompt}\n\n{history_string}\n\nHuman: {user_message}\n\nAssistant:" - response = await self.llm.ainvoke(full_prompt) - response_text = response.content if hasattr(response, "content") else str(response) - human_msg_type = "audio" if is_from_voice else "text" - await self._save_message( - conversation_id, - "human", - user_message, - message_type=human_msg_type, - voice_session_id=voice_session_id, - timestamp=user_message_timestamp, - ) - await self._save_message(conversation_id, "ai", response_text) - messages = [msg.strip() for msg in response_text.split("[SPLIT]") if msg.strip()] - return messages[:3] if messages else [response_text] - except Exception as e: - logger.error("生成资料跟进回复失败: %s", e) - return ["谢谢分享!能再告诉我一些吗?"] - - def _detect_user_stage(self, user_message: str) -> str: - message = user_message.lower() - stage_keywords = { - "childhood": ["童年", "小时候", "出生", "家乡", "小镇", "爸妈", "父亲", "母亲", "爷爷", "奶奶", "外公", "外婆", "幼儿园"], - "education": ["上学", "学校", "老师", "同学", "教育", "大学", "高中", "初中", "小学", "考试", "毕业", "读书", "高考", "课堂"], - "career": ["工作", "职业", "事业", "公司", "同事", "创业", "升职", "跳槽", "老板", "行业", "项目", "加班", "薪水", "面试"], - "family": ["伴侣", "孩子", "家庭", "家人", "结婚", "爱人", "老婆", "老公", "丈夫", "妻子", "儿子", "女儿", "婚礼", "恋爱"], - "belief": ["信念", "价值观", "座右铭", "坚持", "原则", "信仰", "意义", "感悟", "遗憾", "骄傲"], - } - for stage, keywords in stage_keywords.items(): - if any(word in message for word in keywords): - return stage - return "" - - async def generate_response_with_state( - self, - conversation_id: str, - user_message: str, - memoir_state: MemoirStateSchema, - user_profile_context: str = "", - is_from_voice: bool = False, - voice_session_id: str | None = None, - user_message_timestamp: datetime | None = None, - ) -> List[str]: - if not self.llm: - return ["抱歉,LLM 服务未配置。请设置 DEEPSEEK_API_KEY 或 LLM_API_KEY 环境变量。"] - try: - empty_slots = memoir_state.empty_slots_for_current_stage() - filled_slots = { - key: value.snippet - for key, value in memoir_state.slots.get(memoir_state.current_stage, {}).items() - if value.snippet - } - detected_user_stage = self._detect_user_stage(user_message) - history_messages = await self._get_history_messages(conversation_id) - conversation_turn = len(history_messages) // 2 - same_topic_turns = self._estimate_same_topic_turns(history_messages, filled_slots) - all_stages_coverage = memoir_state.all_stages_coverage() - system_prompt = get_guided_conversation_prompt( - current_stage=memoir_state.current_stage, - empty_slots=empty_slots, - filled_slots=filled_slots, - user_message=user_message, - conversation_turn=conversation_turn, - same_topic_turns=same_topic_turns, - all_stages_coverage=all_stages_coverage, - detected_user_stage=detected_user_stage, - user_profile_context=user_profile_context, - ) - history_string = self._format_history_string(history_messages) - full_prompt = f"{system_prompt}\n\n{history_string}\n\nHuman: {user_message}\n\nAssistant:" - response = await self.llm.ainvoke(full_prompt) - response_text = response.content if hasattr(response, "content") else str(response) - human_msg_type = "audio" if is_from_voice else "text" - await self._save_message( - conversation_id, - "human", - user_message, - message_type=human_msg_type, - voice_session_id=voice_session_id, - timestamp=user_message_timestamp, - ) - await self._save_message(conversation_id, "ai", response_text) - messages = [msg.strip() for msg in response_text.split("[SPLIT]") if msg.strip()] - return messages[:3] if messages else [response_text] - except Exception as e: - logger.error("生成回应失败: %s", e) - return [f"抱歉,生成回应时出现错误: {str(e)}"] - - def _estimate_same_topic_turns(self, history_messages: List[Any], current_filled_slots: dict) -> int: - if len(history_messages) < 4: - return len(history_messages) // 2 - recent_messages = history_messages[-6:] - keywords_per_turn = [] - for i in range(0, len(recent_messages), 2): - if i + 1 < len(recent_messages): - human_msg = ( - recent_messages[i].content - if hasattr(recent_messages[i], "content") - else str(recent_messages[i]) - ) - ai_msg = ( - recent_messages[i + 1].content - if hasattr(recent_messages[i + 1], "content") - else str(recent_messages[i + 1]) - ) - keywords_per_turn.append((human_msg + ai_msg)[:100]) - if len(keywords_per_turn) >= 3: - return 3 - return len(keywords_per_turn) - - def detect_stage(self, conversation_id: str, user_message: str) -> ConversationStage: - message_lower = user_message.lower() - if any(word in message_lower for word in ["童年", "小时候", "出生", "家庭背景"]): - return ConversationStage.CHILDHOOD - if any(word in message_lower for word in ["上学", "学校", "老师", "同学", "教育"]): - return ConversationStage.EDUCATION - if any(word in message_lower for word in ["工作", "职业", "事业", "公司", "同事"]): - return ConversationStage.CAREER - if any(word in message_lower for word in ["伴侣", "孩子", "家庭", "家人", "结婚"]): - return ConversationStage.FAMILY - if any(word in message_lower for word in ["信念", "价值观", "座右铭", "坚持", "原则"]): - return ConversationStage.BELIEFS - if any(word in message_lower for word in ["总结", "回顾", "感激", "希望", "未来"]): - return ConversationStage.SUMMARY - return ConversationStage.CHILDHOOD - - async def clear_memory(self, conversation_id: str): - await redis_service.clear_conversation_history(conversation_id) diff --git a/api/app/agents/memoir_processor.py b/api/app/agents/memoir_processor.py deleted file mode 100644 index 18eece3..0000000 --- a/api/app/agents/memoir_processor.py +++ /dev/null @@ -1,211 +0,0 @@ -""" -回忆录后台处理器:分析对话、更新状态、生成章节、创意标题 -使用 Celery 进行后台任务处理 -""" -from __future__ import annotations - -import json -from app.core.logging import get_logger -from dataclasses import dataclass -from typing import Dict, List - -from app.core.dependencies import get_llm_provider -from app.core.task_tracker import task_tracker -from app.agents.state_schema import MemoirStateSchema -from app.agents.prompts.memory_prompts import ( - get_creative_title_prompt, - get_narrative_prompt, - get_state_extraction_prompt, -) - -logger = get_logger(__name__) - -STAGE_KEYWORDS = { - "childhood": ["童年", "小时候", "出生", "家乡", "小镇"], - "education": ["上学", "学校", "老师", "同学", "教育", "大学"], - "career": ["工作", "职业", "事业", "公司", "同事", "创业"], - "family": ["伴侣", "孩子", "家庭", "家人", "结婚", "父母"], - "belief": ["信念", "价值观", "座右铭", "坚持", "原则"], -} - - -def _get_langchain_llm(): - try: - provider = get_llm_provider() - return getattr(provider, "langchain_llm", None) - except Exception: - return None - - -@dataclass -class AnalysisResult: - detected_stage: str - extracted_slots: Dict[str, str] - emotion: str - is_new_chapter: bool - - -class ContentAnalyzer: - def __init__(self) -> None: - self.llm = _get_langchain_llm() - - def _detect_stage(self, user_message: str, fallback_stage: str) -> str: - message = user_message.lower() - for stage, keywords in STAGE_KEYWORDS.items(): - if any(word in message for word in keywords): - return stage - return fallback_stage - - def _fallback_slots( - self, state: MemoirStateSchema, stage: str, user_message: str - ) -> Dict[str, str]: - stage_slots = state.slots.get(stage, {}) - for key, value in stage_slots.items(): - if not value.snippet: - return {key: user_message.strip()[:200]} - return {} - - async def analyze_message( - self, user_message: str, current_state: MemoirStateSchema - ) -> AnalysisResult: - detected_stage = self._detect_stage( - user_message, current_state.current_stage - ) - extracted_slots: Dict[str, str] = {} - emotion = "neutral" - is_new_chapter = False - if self.llm: - try: - prompt = get_state_extraction_prompt( - user_message=user_message, - current_stage=current_state.current_stage, - stage_slots=current_state.slots.get(detected_stage, {}), - ) - response = await self.llm.ainvoke(prompt) - content = response.content.strip() - parsed = json.loads(content) - detected_stage = parsed.get("detected_stage", detected_stage) - extracted_slots = parsed.get("slots", {}) or {} - emotion = parsed.get("emotion", emotion) - is_new_chapter = bool(parsed.get("is_new_chapter", is_new_chapter)) - except json.JSONDecodeError: - extracted_slots = self._fallback_slots( - current_state, detected_stage, user_message - ) - except Exception as e: - logger.error("分析消息失败: %s", e) - extracted_slots = self._fallback_slots( - current_state, detected_stage, user_message - ) - else: - extracted_slots = self._fallback_slots( - current_state, detected_stage, user_message - ) - return AnalysisResult( - detected_stage=detected_stage, - extracted_slots=extracted_slots, - emotion=emotion, - is_new_chapter=is_new_chapter, - ) - - -class MemoirGenerator: - def __init__(self) -> None: - self.llm = _get_langchain_llm() - - async def generate_chapter_title( - self, stage: str, slots: Dict[str, str], emotion: str - ) -> str: - if not self.llm: - return f"{stage} 回忆" - try: - prompt = get_creative_title_prompt( - stage=stage, emotion=emotion, slots=slots - ) - response = await self.llm.ainvoke(prompt) - return response.content.strip().strip('"') - except Exception as e: - logger.error("生成标题失败: %s", e) - return f"{stage} 回忆" - - async def generate_narrative( - self, - stage: str, - slots: Dict[str, str], - new_content: str, - existing_content: str, - ) -> str: - if not self.llm: - if existing_content: - return f"{existing_content}\n\n{new_content}" - return new_content - try: - prompt = get_narrative_prompt( - stage=stage, - slots=slots, - new_content=new_content, - existing_content=existing_content, - ) - response = await self.llm.ainvoke(prompt) - return response.content.strip() - except Exception as e: - logger.error("生成叙事失败: %s", e) - if existing_content: - return f"{existing_content}\n\n{new_content}" - return new_content - - -class BackgroundTaskRunner: - def __init__(self, debounce_seconds: int = 5) -> None: - self.debounce_seconds = debounce_seconds - self._pending: Dict[str, List[str]] = {} - self._timers: Dict[str, object] = {} - self.analyzer = ContentAnalyzer() - self.generator = MemoirGenerator() - - async def _submit_task(self, user_id: str, segment_ids: List[str]) -> str | None: - try: - from app.tasks.memoir_tasks import process_memoir_segments - - result = process_memoir_segments.delay(user_id, segment_ids) - task_id = result.id - await task_tracker.add_task(user_id, task_id, "memoir") - logger.info( - "已提交 Celery 任务: user_id=%s, task_id=%s, segments=%s", - user_id, - task_id, - len(segment_ids), - ) - return task_id - except Exception as e: - logger.error("提交 Celery 任务失败: %s", e) - return None - - async def queue_message(self, user_id: str, segment_id: str) -> None: - import asyncio - - self._pending.setdefault(user_id, []).append(segment_id) - if user_id in self._timers: - self._timers[user_id].cancel() - - async def delayed_submit(): - try: - await asyncio.sleep(self.debounce_seconds) - segment_ids = self._pending.pop(user_id, []) - if segment_ids: - await self._submit_task(user_id, segment_ids) - except asyncio.CancelledError: - pass - except Exception as e: - logger.error("延迟提交任务失败: %s", e) - - self._timers[user_id] = asyncio.create_task(delayed_submit()) - - async def flush_pending(self, user_id: str) -> str | None: - if user_id in self._timers: - self._timers[user_id].cancel() - del self._timers[user_id] - segment_ids = self._pending.pop(user_id, []) - if segment_ids: - return await self._submit_task(user_id, segment_ids) - return None diff --git a/api/app/agents/memory_agent.py b/api/app/agents/memory_agent.py deleted file mode 100644 index 2970a92..0000000 --- a/api/app/agents/memory_agent.py +++ /dev/null @@ -1,130 +0,0 @@ -""" -回忆录整理 Agent:基于传记结构,将口语改写为书面语,归类到章节 -支持异步调用 -""" -import json -from app.core.logging import get_logger -from typing import Dict, List, Optional - -from app.core.dependencies import get_llm_provider - -from app.agents.prompts import ( - get_chapter_classification_prompt, - get_text_rewrite_prompt, - inject_image_placeholder_template, - CHAPTER_CATEGORIES, - STAGE_TO_ORDER, -) - -logger = get_logger(__name__) - - -def _get_langchain_llm(): - try: - provider = get_llm_provider() - return getattr(provider, "langchain_llm", None) - except Exception: - return None - - -class MemoryAgent: - """回忆录整理 Agent(支持异步)""" - - def __init__(self): - self.llm = _get_langchain_llm() - - async def classify_chapter(self, segments_text: str) -> str: - if not self.llm: - return "childhood" - try: - prompt = get_chapter_classification_prompt(segments_text) - response = await self.llm.ainvoke(prompt) - content = response.content if hasattr(response, "content") else str(response) - category = content.strip().lower() - if category in CHAPTER_CATEGORIES: - return category - except Exception as e: - logger.error("分类章节失败: %s", e) - return "childhood" - - async def rewrite_to_literary( - self, - segments_text: str, - chapter_category: str, - existing_content: Optional[str] = None, - ) -> Dict: - if not self.llm: - return { - "title": CHAPTER_CATEGORIES.get(chapter_category, "章节"), - "content": segments_text, - "summary": "", - "image_suggestions": [], - } - try: - prompt = get_text_rewrite_prompt( - segments_text, chapter_category, existing_content or "" - ) - response = await self.llm.ainvoke(prompt) - content = response.content if hasattr(response, "content") else str(response) - content = content.strip() - if content.startswith("```json"): - content = content[7:] - if content.startswith("```"): - content = content[3:] - if content.endswith("```"): - content = content[:-3] - content = content.strip() - result = json.loads(content) - result["content"] = inject_image_placeholder_template( - result.get("content") or "" - ) - return result - except json.JSONDecodeError: - raw = response.content if hasattr(response, "content") else str(response) - return { - "title": CHAPTER_CATEGORIES.get(chapter_category, "章节"), - "content": inject_image_placeholder_template(raw), - "summary": "", - "image_suggestions": [], - } - except Exception as e: - logger.error("改写文本失败: %s", e) - return { - "title": CHAPTER_CATEGORIES.get(chapter_category, "章节"), - "content": segments_text, - "summary": "", - "image_suggestions": [], - } - - async def process_segments( - self, - segments: List[Dict], - existing_chapters: Optional[Dict[str, Dict]] = None, - ) -> Dict[str, Dict]: - if existing_chapters is None: - existing_chapters = {} - segments_by_category: Dict[str, List[str]] = {} - for segment in segments: - text = segment.get("transcript_text", "") - if not text: - continue - category = await self.classify_chapter(text) - if category not in segments_by_category: - segments_by_category[category] = [] - segments_by_category[category].append(text) - updated_chapters = existing_chapters.copy() - for category, texts in segments_by_category.items(): - combined_text = "\n\n".join(texts) - existing_content = existing_chapters.get(category, {}).get("content", "") - result = await self.rewrite_to_literary( - combined_text, category, existing_content - ) - updated_chapters[category] = { - "title": result.get("title", CHAPTER_CATEGORIES.get(category, "章节")), - "content": result.get("content", ""), - "summary": result.get("summary", ""), - "image_suggestions": result.get("image_suggestions", []), - "category": category, - "order_index": STAGE_TO_ORDER.get(category, 999), - } - return updated_chapters diff --git a/api/app/features/conversation/ws/pipeline.py b/api/app/features/conversation/ws/pipeline.py index b3e0c04..b04555f 100644 --- a/api/app/features/conversation/ws/pipeline.py +++ b/api/app/features/conversation/ws/pipeline.py @@ -14,8 +14,8 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.agents import ConversationAgent, MemoryAgent -from app.agents.memoir_processor import BackgroundTaskRunner -from app.agents.prompts.profile_prompts import format_user_profile_context +from app.agents.chat import ChatOrchestrator +from app.agents.memoir import BackgroundTaskRunner from app.core.db import AsyncSessionLocal from app.features.conversation.models import Conversation, Segment from app.features.conversation.ws.connection_manager import manager @@ -28,7 +28,6 @@ from app.features.conversation.ws.profile_collector import ( from app.features.user.models import User from app.core.config import settings from app.core.dependencies import get_asr_provider, get_tts_provider -from app.features.memoir.state_service import get_or_create_state logger = get_logger(__name__) @@ -53,6 +52,7 @@ async def _send_tts_audio(conversation_id: str, text: str) -> None: # ── Agent 实例(从 ConnectionManager 移出) ───────────────────── conversation_agent = ConversationAgent() +chat_orchestrator = ChatOrchestrator() memory_agent = MemoryAgent() background_runner = BackgroundTaskRunner() @@ -429,82 +429,21 @@ async def process_user_message( user: User = None, user_message_timestamp: Optional[datetime] = None, ) -> None: - """处理用户消息,生成 Agent 回应。支持资料收集模式和正式访谈模式。""" - agent = conversation_agent - - if user: - missing = get_missing_profile_fields(user) - if missing: - try: - extracted = await agent.extract_profile_from_message( - user_message, missing, conversation_id=conversation_id - ) - if extracted: - await apply_extracted_profile(user, extracted, db) - - remaining = get_missing_profile_fields(user) - filled = get_filled_profile_fields(user) - is_from_voice = bool(segment.audio_url) - responses = await agent.generate_profile_followup( - conversation_id=conversation_id, - user_message=user_message, - missing_fields=remaining, - filled_fields=filled, - nickname=user.nickname or "", - is_from_voice=is_from_voice, - voice_session_id=_voice_session_id_from_audio_url(segment.audio_url), - user_message_timestamp=user_message_timestamp, - ) - - segment.agent_response = "\n\n".join(responses) - _mark_conversation_active(conversation) - await db.commit() - - for i, response_text in enumerate(responses): - await manager.send_message(conversation_id, { - "type": MessageType.AGENT_RESPONSE, - "conversation_id": conversation_id, - "data": {"text": response_text, "index": i, "total": len(responses)}, - "timestamp": datetime.now(timezone.utc).isoformat(), - }) - await _send_tts_audio(conversation_id, response_text) - if i < len(responses) - 1: - await asyncio.sleep(0.5) - return - except Exception as e: - logger.error(f"资料收集处理失败: {e}", exc_info=True) - - state = await get_or_create_state(conversation.user_id, db) - - if conversation.conversation_stage != state.current_stage: - conversation.conversation_stage = state.current_stage - await db.commit() - - stmt_segments = select(Segment).where( - Segment.conversation_id == conversation_id - ).order_by(Segment.created_at) - result_segments = await db.execute(stmt_segments) - previous_segments = result_segments.scalars().all() - covered_topics = [seg.topic_category for seg in previous_segments if seg.topic_category] - - user_profile_context = "" - if user: - user_profile_context = format_user_profile_context( - birth_year=user.birth_year, - birth_place=user.birth_place, - grew_up_place=user.grew_up_place, - occupation=user.occupation, - ) - + """处理用户消息,生成 Agent 回应。由 ChatOrchestrator 路由到 ProfileAgent 或 InterviewAgent。""" try: is_from_voice = bool(segment.audio_url) - responses = await agent.generate_response_with_state( + voice_session_id = _voice_session_id_from_audio_url(segment.audio_url) + responses = await chat_orchestrator.process_user_message( conversation_id=conversation_id, user_message=user_message, - memoir_state=state, - user_profile_context=user_profile_context, + user=user, + conversation=conversation, is_from_voice=is_from_voice, - voice_session_id=_voice_session_id_from_audio_url(segment.audio_url), + voice_session_id=voice_session_id, + db=db, + apply_extracted_profile_fn=apply_extracted_profile, + get_missing_profile_fields_fn=get_missing_profile_fields, + get_filled_profile_fields_fn=get_filled_profile_fields, user_message_timestamp=user_message_timestamp, ) diff --git a/api/app/features/memoir/processor.py b/api/app/features/memoir/processor.py index ad367a2..0d19bf0 100644 --- a/api/app/features/memoir/processor.py +++ b/api/app/features/memoir/processor.py @@ -1,6 +1,6 @@ """Memoir processor — 从 agents/memoir_processor.py 迁入的占位。 实际逻辑仍由 agents/memoir_processor.py 提供,后续迁入。""" -from app.agents.memoir_processor import BackgroundTaskRunner +from app.agents.memoir import BackgroundTaskRunner __all__ = ["BackgroundTaskRunner"] diff --git a/api/routers/chapters.py b/api/routers/chapters.py index 7f2bb58..a357f06 100644 --- a/api/routers/chapters.py +++ b/api/routers/chapters.py @@ -14,7 +14,7 @@ from database import get_async_db from database.models import Chapter as ChapterModel, ChapterSection 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 +from app.agents.prompts.memory_prompts import CHAPTER_CATEGORIES, CHAPTER_ORDER, STAGE_TO_ORDER from services.memoir_images.schema import ( completed_image_assets, IMAGE_STATUS_COMPLETED, diff --git a/api/routers/websocket.py b/api/routers/websocket.py index f6c9afa..4559842 100644 --- a/api/routers/websocket.py +++ b/api/routers/websocket.py @@ -15,15 +15,15 @@ from starlette.websockets import WebSocketState from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from agents import ConversationAgent, MemoryAgent -from agents.memoir_processor import BackgroundTaskRunner +from app.agents import ConversationAgent, MemoryAgent +from app.agents.memoir import BackgroundTaskRunner from database import get_async_db from database.models import Conversation, Segment from database.models import User as UserModel from services.auth_service import verify_token from services.memoir_state_service import get_or_create_state from services import asr_service, redis_service -from agents.prompts.profile_prompts import format_user_profile_context +from app.agents.prompts.profile_prompts import format_user_profile_context logger = logging.getLogger(__name__) LEGACY_VOICE_SESSION_ID = "legacy" @@ -924,7 +924,7 @@ async def websocket_endpoint( def _get_missing_profile_fields(user: UserModel) -> list: """检查用户缺失的资料字段""" - from agents.prompts.profile_prompts import get_missing_profile_fields + from app.agents.prompts.profile_prompts import get_missing_profile_fields return get_missing_profile_fields( birth_year=user.birth_year, birth_place=user.birth_place, @@ -935,7 +935,7 @@ def _get_missing_profile_fields(user: UserModel) -> list: def _get_filled_profile_fields(user: UserModel) -> dict: """获取用户已有的资料字段(中文展示)""" - from agents.prompts.profile_prompts import PROFILE_FIELD_NAMES + from app.agents.prompts.profile_prompts import PROFILE_FIELD_NAMES filled = {} if user.birth_year: filled["birth_year"] = str(user.birth_year) @@ -1045,7 +1045,7 @@ async def process_user_message( # 构建用户资料上下文 user_profile_context = "" if user: - from agents.prompts.profile_prompts import format_user_profile_context + from app.agents.prompts.profile_prompts import format_user_profile_context user_profile_context = format_user_profile_context( birth_year=user.birth_year, birth_place=user.birth_place, From 4a1d6f0dccf66490a6da8a7136327c1ccc4fb41b Mon Sep 17 00:00:00 2001 From: yangshilin <2157598560@qq.com> Date: Thu, 19 Mar 2026 10:38:11 +0800 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=E7=94=9F=E6=88=90=E5=9B=9E?= =?UTF-8?q?=E5=BF=86=E5=BD=95agent=E7=BB=93=E6=9E=84=E5=B0=81=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/agents/memoir/__init__.py | 25 ++ api/app/agents/memoir/classification_agent.py | 77 ++++ api/app/agents/memoir/extraction_agent.py | 66 ++++ api/app/agents/memoir/memory_agent.py | 130 +++++++ api/app/agents/memoir/narrative_agent.py | 78 ++++ api/app/agents/memoir/orchestrator.py | 124 ++++++ api/app/agents/memoir/placeholder_agent.py | 14 + api/app/agents/memoir/processor.py | 212 +++++++++++ api/app/tasks/memoir_tasks.py | 352 +++++++----------- ...t_process_memoir_segments_image_enqueue.py | 30 +- 10 files changed, 881 insertions(+), 227 deletions(-) create mode 100644 api/app/agents/memoir/__init__.py create mode 100644 api/app/agents/memoir/classification_agent.py create mode 100644 api/app/agents/memoir/extraction_agent.py create mode 100644 api/app/agents/memoir/memory_agent.py create mode 100644 api/app/agents/memoir/narrative_agent.py create mode 100644 api/app/agents/memoir/orchestrator.py create mode 100644 api/app/agents/memoir/placeholder_agent.py create mode 100644 api/app/agents/memoir/processor.py diff --git a/api/app/agents/memoir/__init__.py b/api/app/agents/memoir/__init__.py new file mode 100644 index 0000000..1de5720 --- /dev/null +++ b/api/app/agents/memoir/__init__.py @@ -0,0 +1,25 @@ +"""回忆录模块:MemoryAgent、BackgroundTaskRunner、MemoirOrchestrator、各 Specialist Agent""" +from app.agents.memoir.memory_agent import MemoryAgent +from app.agents.memoir.processor import ( + BackgroundTaskRunner, + ContentAnalyzer, + MemoirGenerator, +) +from app.agents.memoir.orchestrator import MemoirOrchestrator +from app.agents.memoir.extraction_agent import ExtractionAgent, ExtractionResult +from app.agents.memoir.classification_agent import ClassificationAgent +from app.agents.memoir.narrative_agent import NarrativeAgent +from app.agents.memoir.placeholder_agent import inject_placeholders + +__all__ = [ + "MemoryAgent", + "BackgroundTaskRunner", + "ContentAnalyzer", + "MemoirGenerator", + "MemoirOrchestrator", + "ExtractionAgent", + "ExtractionResult", + "ClassificationAgent", + "NarrativeAgent", + "inject_placeholders", +] diff --git a/api/app/agents/memoir/classification_agent.py b/api/app/agents/memoir/classification_agent.py new file mode 100644 index 0000000..094e0f3 --- /dev/null +++ b/api/app/agents/memoir/classification_agent.py @@ -0,0 +1,77 @@ +""" +ClassificationAgent:将内容分类到 8 个章节类别,或判定无价值返回 None。 +对应现有逻辑:_classify_chapter_category +""" +from __future__ import annotations + +from typing import Any, Optional + +from app.core.logging import get_logger + +from app.agents.prompts.memory_prompts import ( + CHAPTER_CATEGORIES, + get_chapter_classification_prompt, +) + +logger = get_logger(__name__) + +# 5-stage 关键词(用于 LLM 失败时的兜底) +STAGE_KEYWORDS = { + "childhood": ["童年", "小时候", "出生", "家乡", "小镇"], + "education": ["上学", "学校", "老师", "同学", "教育", "大学"], + "career": ["工作", "职业", "事业", "公司", "同事", "创业"], + "family": ["伴侣", "孩子", "家庭", "家人", "结婚", "父母"], + "belief": ["信念", "价值观", "座右铭", "坚持", "原则"], +} + +# 5-stage → 默认 8-category 映射(LLM 分类失败时的兜底) +_STAGE_TO_DEFAULT_CATEGORY = { + "childhood": "childhood", + "education": "education", + "career": "career_early", + "family": "family", + "belief": "beliefs", +} + + +def _detect_stage(text: str, fallback_stage: str) -> str: + """根据关键词检测消息所属的 5-stage 阶段""" + message = (text or "").lower() + for stage, keywords in STAGE_KEYWORDS.items(): + if any(word in message for word in keywords): + return stage + return fallback_stage + + +class ClassificationAgent: + """将内容分类到 8 个章节类别之一,或判定无价值返回 None""" + + def classify( + self, + text: str, + fallback_stage: str, + llm: Any, + ) -> Optional[str]: + """ + 分类到 8 个章节类别之一。 + 若 LLM 判定内容无实质回忆录价值,返回 None。 + llm 需支持 .invoke(prompt) 同步调用。 + """ + if llm: + try: + prompt = get_chapter_classification_prompt(text) + response = llm.invoke(prompt) + category = (response.content or "").strip().lower() + if category == "none": + logger.info("LLM 判定内容无回忆录价值,跳过: %s...", (text or "")[:80]) + return None + if category in CHAPTER_CATEGORIES: + return category + except Exception as e: + logger.warning("ClassificationAgent LLM 章节分类失败: %s", e) + + stage = _detect_stage(text, fallback_stage) + return _STAGE_TO_DEFAULT_CATEGORY.get( + stage, + _STAGE_TO_DEFAULT_CATEGORY.get(fallback_stage, "childhood"), + ) diff --git a/api/app/agents/memoir/extraction_agent.py b/api/app/agents/memoir/extraction_agent.py new file mode 100644 index 0000000..10f776f --- /dev/null +++ b/api/app/agents/memoir/extraction_agent.py @@ -0,0 +1,66 @@ +""" +ExtractionAgent:从用户消息中提取 5-stage 状态与 slots。 +对应现有逻辑:get_state_extraction_prompt + JSON 解析 +""" +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any, Dict + +from app.core.logging import get_logger +from app.features.memoir.memoir_images.json_payload import extract_json_payload + +from app.agents.prompts.memory_prompts import get_state_extraction_prompt + +logger = get_logger(__name__) + + +@dataclass +class ExtractionResult: + """状态提取结果""" + detected_stage: str + slots: Dict[str, str] + + +class ExtractionAgent: + """从用户消息中提取 detected_stage 和 slots""" + + def extract( + self, + user_message: str, + current_stage: str, + stage_slots: Dict[str, Any], + llm: Any, + ) -> ExtractionResult: + """ + 提取结构化信息并判断阶段。 + llm 需支持 .invoke(prompt) 同步调用(Celery 任务内使用)。 + """ + detected_stage = current_stage + extracted_slots: Dict[str, str] = {} + + if not llm: + return ExtractionResult(detected_stage=detected_stage, slots=extracted_slots) + + try: + prompt = get_state_extraction_prompt( + user_message=user_message, + current_stage=current_stage, + stage_slots={ + k: v.model_dump() if hasattr(v, "model_dump") else v + for k, v in (stage_slots or {}).items() + }, + ) + response = llm.invoke(prompt) + parsed = json.loads(extract_json_payload(response.content)) + detected_stage = parsed.get("detected_stage", detected_stage) + raw_slots = parsed.get("slots", {}) or {} + extracted_slots = { + k: v if isinstance(v, str) else str(v) + for k, v in raw_slots.items() + } + except (json.JSONDecodeError, Exception) as e: + logger.warning("ExtractionAgent LLM 解析失败: %s", e) + + return ExtractionResult(detected_stage=detected_stage, slots=extracted_slots) diff --git a/api/app/agents/memoir/memory_agent.py b/api/app/agents/memoir/memory_agent.py new file mode 100644 index 0000000..d764778 --- /dev/null +++ b/api/app/agents/memoir/memory_agent.py @@ -0,0 +1,130 @@ +""" +回忆录整理 Agent:基于传记结构,将口语改写为书面语,归类到章节 +支持异步调用 +""" +import json +from typing import Dict, List, Optional + +from app.core.dependencies import get_llm_provider +from app.core.logging import get_logger + +from app.agents.prompts import ( + get_chapter_classification_prompt, + get_text_rewrite_prompt, + inject_image_placeholder_template, + CHAPTER_CATEGORIES, + STAGE_TO_ORDER, +) + +logger = get_logger(__name__) + + +def _get_langchain_llm(): + try: + provider = get_llm_provider() + return getattr(provider, "langchain_llm", None) + except Exception: + return None + + +class MemoryAgent: + """回忆录整理 Agent(支持异步)""" + + def __init__(self): + self.llm = _get_langchain_llm() + + async def classify_chapter(self, segments_text: str) -> str: + if not self.llm: + return "childhood" + try: + prompt = get_chapter_classification_prompt(segments_text) + response = await self.llm.ainvoke(prompt) + content = response.content if hasattr(response, "content") else str(response) + category = content.strip().lower() + if category in CHAPTER_CATEGORIES: + return category + except Exception as e: + logger.error("分类章节失败: %s", e) + return "childhood" + + async def rewrite_to_literary( + self, + segments_text: str, + chapter_category: str, + existing_content: Optional[str] = None, + ) -> Dict: + if not self.llm: + return { + "title": CHAPTER_CATEGORIES.get(chapter_category, "章节"), + "content": segments_text, + "summary": "", + "image_suggestions": [], + } + try: + prompt = get_text_rewrite_prompt( + segments_text, chapter_category, existing_content or "" + ) + response = await self.llm.ainvoke(prompt) + content = response.content if hasattr(response, "content") else str(response) + content = content.strip() + if content.startswith("```json"): + content = content[7:] + if content.startswith("```"): + content = content[3:] + if content.endswith("```"): + content = content[:-3] + content = content.strip() + result = json.loads(content) + result["content"] = inject_image_placeholder_template( + result.get("content") or "" + ) + return result + except json.JSONDecodeError: + raw = response.content if hasattr(response, "content") else str(response) + return { + "title": CHAPTER_CATEGORIES.get(chapter_category, "章节"), + "content": inject_image_placeholder_template(raw), + "summary": "", + "image_suggestions": [], + } + except Exception as e: + logger.error("改写文本失败: %s", e) + return { + "title": CHAPTER_CATEGORIES.get(chapter_category, "章节"), + "content": segments_text, + "summary": "", + "image_suggestions": [], + } + + async def process_segments( + self, + segments: List[Dict], + existing_chapters: Optional[Dict[str, Dict]] = None, + ) -> Dict[str, Dict]: + if existing_chapters is None: + existing_chapters = {} + segments_by_category: Dict[str, List[str]] = {} + for segment in segments: + text = segment.get("transcript_text", "") + if not text: + continue + category = await self.classify_chapter(text) + if category not in segments_by_category: + segments_by_category[category] = [] + segments_by_category[category].append(text) + updated_chapters = existing_chapters.copy() + for category, texts in segments_by_category.items(): + combined_text = "\n\n".join(texts) + existing_content = existing_chapters.get(category, {}).get("content", "") + result = await self.rewrite_to_literary( + combined_text, category, existing_content + ) + updated_chapters[category] = { + "title": result.get("title", CHAPTER_CATEGORIES.get(category, "章节")), + "content": result.get("content", ""), + "summary": result.get("summary", ""), + "image_suggestions": result.get("image_suggestions", []), + "category": category, + "order_index": STAGE_TO_ORDER.get(category, 999), + } + return updated_chapters diff --git a/api/app/agents/memoir/narrative_agent.py b/api/app/agents/memoir/narrative_agent.py new file mode 100644 index 0000000..866f5d0 --- /dev/null +++ b/api/app/agents/memoir/narrative_agent.py @@ -0,0 +1,78 @@ +""" +NarrativeAgent:生成创意标题和叙事改写。 +对应现有逻辑:get_creative_title_prompt、get_narrative_prompt +""" +from __future__ import annotations + +from typing import Any, Dict, Optional + +from app.core.logging import get_logger + +from app.agents.prompts.memory_prompts import ( + get_creative_title_prompt, + get_narrative_prompt, +) + +logger = get_logger(__name__) + + +class NarrativeAgent: + """生成章节标题和叙事正文""" + + def generate_title( + self, + stage: str, + emotion: str, + slots: Dict[str, str], + user_profile: str = "", + birth_year: Optional[int] = None, + llm: Any = None, + ) -> str: + """生成创意标题。若无 LLM 则返回默认标题""" + if not llm: + return f"{stage} 回忆" + try: + prompt = get_creative_title_prompt( + stage=stage, + emotion=emotion, + slots=slots, + user_profile=user_profile, + birth_year=birth_year, + ) + response = llm.invoke(prompt) + return (response.content or "").strip().strip('"') + except Exception as e: + logger.warning("NarrativeAgent 生成标题失败: %s", e) + return f"{stage} 回忆" + + def generate_narrative( + self, + stage: str, + slots: Dict[str, str], + new_content: str, + existing_content: str = "", + user_profile: str = "", + birth_year: Optional[int] = None, + llm: Any = None, + ) -> str: + """将新对话改写为叙述。若无 LLM 则直接拼接""" + if not llm: + if existing_content: + return f"{existing_content}\n\n{new_content}" + return new_content + try: + prompt = get_narrative_prompt( + stage=stage, + slots=slots, + new_content=new_content, + existing_content=existing_content, + user_profile=user_profile, + birth_year=birth_year, + ) + response = llm.invoke(prompt) + return (response.content or "").strip() + except Exception as e: + logger.warning("NarrativeAgent 生成叙事失败: %s", e) + if existing_content: + return f"{existing_content}\n\n{new_content}" + return new_content diff --git a/api/app/agents/memoir/orchestrator.py b/api/app/agents/memoir/orchestrator.py new file mode 100644 index 0000000..1870714 --- /dev/null +++ b/api/app/agents/memoir/orchestrator.py @@ -0,0 +1,124 @@ +""" +MemoirOrchestrator:按 segment 编排流水线,调用各 Specialist Agent。 +负责:遍历 segments、按 category 聚合、调用 Specialist、更新 state; +持久化与章节生成由 process_category 回调完成。 +""" +from __future__ import annotations + +from typing import Any, Callable, Dict, List, Set, Tuple + +from app.core.logging import get_logger +from app.features.conversation.models import Segment +from app.agents.state_schema import MemoirStateSchema + +from app.agents.memoir.extraction_agent import ExtractionAgent, ExtractionResult +from app.agents.memoir.classification_agent import ( + ClassificationAgent, + _detect_stage as detect_stage_from_keywords, +) + +logger = get_logger(__name__) + + +class MemoirOrchestrator: + """ + 回忆录生成编排器。 + 遍历 segments → ExtractionAgent → ClassificationAgent → 按 category 聚合 → + 调用 process_category 生成叙事并持久化。 + """ + + def __init__(self) -> None: + self.extraction_agent = ExtractionAgent() + self.classification_agent = ClassificationAgent() + + def run( + self, + *, + segments: List[Segment], + llm: Any, + user_profile: str = "", + user_birth_year: Any = None, + get_or_create_state: Callable[[], MemoirStateSchema], + update_slot: Callable[ + [str, str, str, List[str]], MemoirStateSchema + ], + acquire_lock: Callable[[str], bool], + release_lock: Callable[[str], None], + process_category: Callable[ + [ + str, + List[Segment], + MemoirStateSchema, + str, + Any, + Any, + ], + Tuple[Any, bool], + ], + raise_retry: Callable[[], None], + ) -> Tuple[Set[str], int]: + """ + 执行回忆录流水线。 + process_category(category, segments, state, user_profile, user_birth_year, llm) + 返回 (chapter, has_images_to_generate)。 + 返回 (chapters_to_enqueue, processed_count)。 + raise_retry 用于锁竞争时抛出 Celery retry。 + """ + state = get_or_create_state() + chapters_to_enqueue: Set[str] = set() + category_to_segments: Dict[str, List[Segment]] = {} + + # 1) 遍历 segments:ExtractionAgent → 更新 slots;ClassificationAgent → 聚合 + for segment in segments: + text = segment.transcript_text or "" + # 关键词预检测阶段,用于 slot 查找(与原有逻辑一致) + initial_stage = detect_stage_from_keywords( + text, state.current_stage or "childhood" + ) + stage_slots_raw = state.slots.get(initial_stage, {}) or {} + + result: ExtractionResult = self.extraction_agent.extract( + user_message=text, + current_stage=state.current_stage or "childhood", + stage_slots=stage_slots_raw, + llm=llm, + ) + detected_stage = result.detected_stage + for slot_name, snippet in result.slots.items(): + state = update_slot(detected_stage, slot_name, snippet, [segment.id]) + + # ClassificationAgent + chapter_category = self.classification_agent.classify( + text=text, + fallback_stage=detected_stage, + llm=llm, + ) + if chapter_category is None: + logger.info("段落无回忆录价值,跳过: segment_id=%s", segment.id) + continue + category_to_segments.setdefault(chapter_category, []).append(segment) + + # 2) 按 category 调用 process_category:内含 NarrativeAgent、PlaceholderInject、持久化 + for chapter_category, category_segments in category_to_segments.items(): + if not acquire_lock(chapter_category): + logger.warning( + "章节锁竞争: category=%s, 延迟重试", + chapter_category, + ) + raise_retry() + + try: + chapter, has_images = process_category( + chapter_category, + category_segments, + state, + user_profile, + user_birth_year, + llm, + ) + if chapter and has_images: + chapters_to_enqueue.add(chapter.id) + finally: + release_lock(chapter_category) + + return chapters_to_enqueue, len(segments) diff --git a/api/app/agents/memoir/placeholder_agent.py b/api/app/agents/memoir/placeholder_agent.py new file mode 100644 index 0000000..4559b9e --- /dev/null +++ b/api/app/agents/memoir/placeholder_agent.py @@ -0,0 +1,14 @@ +""" +PlaceholderInjectAgent:对 narrative 做占位符模板注入。 +对应现有逻辑:inject_image_placeholder_template +纯函数式,无 LLM 调用。 +""" +from app.agents.prompts.memory_prompts import inject_image_placeholder_template + + +def inject_placeholders(content: str) -> str: + """ + 对章节正文做占位符处理:匹配所有图片占位符,拼上固定模板。 + 与 inject_image_placeholder_template 行为一致。 + """ + return inject_image_placeholder_template(content) diff --git a/api/app/agents/memoir/processor.py b/api/app/agents/memoir/processor.py new file mode 100644 index 0000000..5d09bb3 --- /dev/null +++ b/api/app/agents/memoir/processor.py @@ -0,0 +1,212 @@ +""" +回忆录后台处理器:分析对话、更新状态、生成章节、创意标题 +使用 Celery 进行后台任务处理 +""" +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Dict, List + +from app.core.dependencies import get_llm_provider +from app.core.logging import get_logger +from app.core.task_tracker import task_tracker + +from app.agents.state_schema import MemoirStateSchema +from app.agents.prompts.memory_prompts import ( + get_creative_title_prompt, + get_narrative_prompt, + get_state_extraction_prompt, +) + +logger = get_logger(__name__) + +STAGE_KEYWORDS = { + "childhood": ["童年", "小时候", "出生", "家乡", "小镇"], + "education": ["上学", "学校", "老师", "同学", "教育", "大学"], + "career": ["工作", "职业", "事业", "公司", "同事", "创业"], + "family": ["伴侣", "孩子", "家庭", "家人", "结婚", "父母"], + "belief": ["信念", "价值观", "座右铭", "坚持", "原则"], +} + + +def _get_langchain_llm(): + try: + provider = get_llm_provider() + return getattr(provider, "langchain_llm", None) + except Exception: + return None + + +@dataclass +class AnalysisResult: + detected_stage: str + extracted_slots: Dict[str, str] + emotion: str + is_new_chapter: bool + + +class ContentAnalyzer: + def __init__(self) -> None: + self.llm = _get_langchain_llm() + + def _detect_stage(self, user_message: str, fallback_stage: str) -> str: + message = user_message.lower() + for stage, keywords in STAGE_KEYWORDS.items(): + if any(word in message for word in keywords): + return stage + return fallback_stage + + def _fallback_slots( + self, state: MemoirStateSchema, stage: str, user_message: str + ) -> Dict[str, str]: + stage_slots = state.slots.get(stage, {}) + for key, value in stage_slots.items(): + if not value.snippet: + return {key: user_message.strip()[:200]} + return {} + + async def analyze_message( + self, user_message: str, current_state: MemoirStateSchema + ) -> AnalysisResult: + detected_stage = self._detect_stage( + user_message, current_state.current_stage + ) + extracted_slots: Dict[str, str] = {} + emotion = "neutral" + is_new_chapter = False + if self.llm: + try: + prompt = get_state_extraction_prompt( + user_message=user_message, + current_stage=current_state.current_stage, + stage_slots=current_state.slots.get(detected_stage, {}), + ) + response = await self.llm.ainvoke(prompt) + content = response.content.strip() + parsed = json.loads(content) + detected_stage = parsed.get("detected_stage", detected_stage) + extracted_slots = parsed.get("slots", {}) or {} + emotion = parsed.get("emotion", emotion) + is_new_chapter = bool(parsed.get("is_new_chapter", is_new_chapter)) + except json.JSONDecodeError: + extracted_slots = self._fallback_slots( + current_state, detected_stage, user_message + ) + except Exception as e: + logger.error("分析消息失败: %s", e) + extracted_slots = self._fallback_slots( + current_state, detected_stage, user_message + ) + else: + extracted_slots = self._fallback_slots( + current_state, detected_stage, user_message + ) + return AnalysisResult( + detected_stage=detected_stage, + extracted_slots=extracted_slots, + emotion=emotion, + is_new_chapter=is_new_chapter, + ) + + +class MemoirGenerator: + def __init__(self) -> None: + self.llm = _get_langchain_llm() + + async def generate_chapter_title( + self, stage: str, slots: Dict[str, str], emotion: str + ) -> str: + if not self.llm: + return f"{stage} 回忆" + try: + prompt = get_creative_title_prompt( + stage=stage, emotion=emotion, slots=slots + ) + response = await self.llm.ainvoke(prompt) + return response.content.strip().strip('"') + except Exception as e: + logger.error("生成标题失败: %s", e) + return f"{stage} 回忆" + + async def generate_narrative( + self, + stage: str, + slots: Dict[str, str], + new_content: str, + existing_content: str, + ) -> str: + if not self.llm: + if existing_content: + return f"{existing_content}\n\n{new_content}" + return new_content + try: + prompt = get_narrative_prompt( + stage=stage, + slots=slots, + new_content=new_content, + existing_content=existing_content, + ) + response = await self.llm.ainvoke(prompt) + return response.content.strip() + except Exception as e: + logger.error("生成叙事失败: %s", e) + if existing_content: + return f"{existing_content}\n\n{new_content}" + return new_content + + +class BackgroundTaskRunner: + def __init__(self, debounce_seconds: int = 5) -> None: + self.debounce_seconds = debounce_seconds + self._pending: Dict[str, List[str]] = {} + self._timers: Dict[str, object] = {} + self.analyzer = ContentAnalyzer() + self.generator = MemoirGenerator() + + async def _submit_task(self, user_id: str, segment_ids: List[str]) -> str | None: + try: + from app.tasks.memoir_tasks import process_memoir_segments + + result = process_memoir_segments.delay(user_id, segment_ids) + task_id = result.id + await task_tracker.add_task(user_id, task_id, "memoir") + logger.info( + "已提交 Celery 任务: user_id=%s, task_id=%s, segments=%s", + user_id, + task_id, + len(segment_ids), + ) + return task_id + except Exception as e: + logger.error("提交 Celery 任务失败: %s", e) + return None + + async def queue_message(self, user_id: str, segment_id: str) -> None: + import asyncio + + self._pending.setdefault(user_id, []).append(segment_id) + if user_id in self._timers: + self._timers[user_id].cancel() + + async def delayed_submit(): + try: + await asyncio.sleep(self.debounce_seconds) + segment_ids = self._pending.pop(user_id, []) + if segment_ids: + await self._submit_task(user_id, segment_ids) + except asyncio.CancelledError: + pass + except Exception as e: + logger.error("延迟提交任务失败: %s", e) + + self._timers[user_id] = asyncio.create_task(delayed_submit()) + + async def flush_pending(self, user_id: str) -> str | None: + if user_id in self._timers: + self._timers[user_id].cancel() + del self._timers[user_id] + segment_ids = self._pending.pop(user_id, []) + if segment_ids: + return await self._submit_task(user_id, segment_ids) + return None diff --git a/api/app/tasks/memoir_tasks.py b/api/app/tasks/memoir_tasks.py index 4bc24e6..97a5ffc 100644 --- a/api/app/tasks/memoir_tasks.py +++ b/api/app/tasks/memoir_tasks.py @@ -27,23 +27,20 @@ from app.features.user.models import User from app.core.dependencies import get_llm_provider from app.agents.state_schema import MemoirStateSchema, SlotData, default_state from app.agents.prompts.memory_prompts import ( - get_creative_title_prompt, - get_narrative_prompt, - get_state_extraction_prompt, - get_chapter_classification_prompt, - inject_image_placeholder_template, STAGE_TO_ORDER, - CHAPTER_CATEGORIES, + get_narrative_prompt, + inject_image_placeholder_template, ) +from app.agents.memoir import MemoirOrchestrator +from app.agents.memoir.narrative_agent import NarrativeAgent +from app.agents.memoir.placeholder_agent import inject_placeholders from app.agents.prompts.profile_prompts import format_user_profile_context -import hashlib - from app.features.memoir.memoir_images.parser import ( build_initial_image_assets, parse_image_placeholders, split_narrative_to_sections, ) -from app.features.memoir.memoir_images.json_payload import extract_json_payload +import hashlib from app.core.dependencies import get_image_generator from app.features.memoir.memoir_images.prompting import MemoirImagePromptService from app.features.memoir.memoir_images.schema import ( @@ -469,56 +466,6 @@ def _normalize_image_bytes_for_storage(image_bytes: bytes) -> bytes: return output.getvalue() -STAGE_KEYWORDS = { - "childhood": ["童年", "小时候", "出生", "家乡", "小镇"], - "education": ["上学", "学校", "老师", "同学", "教育", "大学"], - "career": ["工作", "职业", "事业", "公司", "同事", "创业"], - "family": ["伴侣", "孩子", "家庭", "家人", "结婚", "父母"], - "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): - return stage - return fallback_stage - - -def _classify_chapter_category(text: str, fallback_stage: str, llm=None) -> str | None: - """ - 将内容分类到 8 个章节类别之一。 - 优先使用 LLM,失败则按 5-stage 关键词映射到默认类别。 - 如果 LLM 判定内容无实质回忆录价值,返回 None。 - """ - if llm: - try: - prompt = get_chapter_classification_prompt(text) - response = llm.invoke(prompt) - category = response.content.strip().lower() - if category == "none": - logger.info(f"LLM 判定内容无回忆录价值,跳过: {text[:80]}...") - return None - 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( @@ -628,174 +575,141 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]): grew_up_place=user_obj.grew_up_place, occupation=user_obj.occupation, ) - - # 分两步处理: - # 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) + narrative_agent = NarrativeAgent() - # 提取 slots(5-stage 状态跟踪) - extracted_slots = {} - if llm: - try: - prompt = get_state_extraction_prompt( - user_message=text, - current_stage=state.current_stage, - stage_slots=state.slots.get(detected_stage, {}), - ) - response = llm.invoke(prompt) - parsed = json.loads(extract_json_payload(response.content)) - detected_stage = parsed.get("detected_stage", detected_stage) - extracted_slots = parsed.get("slots", {}) or {} - except (json.JSONDecodeError, Exception) as e: - logger.warning(f"LLM 解析失败: {e}") + def _process_category( + chapter_category: str, + category_segments: List, + state: MemoirStateSchema, + profile: str, + birth_year, + llm, + ): + """单章节处理:NarrativeAgent 生成标题+叙事,PlaceholderInjectAgent 注入,持久化""" + segment_texts = [seg.transcript_text or "" for seg in category_segments] + combined_text = "\n\n".join(segment_texts) + source_ids = [seg.id for seg in category_segments] - for slot_name, snippet in extracted_slots.items(): - state = _update_slot_sync( + stmt_chapter = ( + select(Chapter) + .where( + Chapter.user_id == user_id, + Chapter.category == chapter_category, + Chapter.is_active == True, + ) + .options( + joinedload(Chapter.sections).joinedload(ChapterSection.image_record), + joinedload(Chapter.images), + ) + ) + result_chapter = db.execute(stmt_chapter) + chapter = result_chapter.unique().scalar_one_or_none() + + slot_snippets = {} + stage_slots = state.slots.get(chapter_category, {}) or {} + for key, value in stage_slots.items(): + snip = getattr(value, "snippet", None) or (value.get("snippet") if isinstance(value, dict) else None) + if snip: + slot_snippets[key] = snip + + title = chapter.title if chapter else f"{chapter_category} 回忆" + existing_content = "" + if chapter and getattr(chapter, "sections", None): + existing_content = "\n\n".join( + s.content for s in sorted(chapter.sections, key=lambda x: x.order_index) if (s.content or "").strip() + ) + narrative = combined_text + + if not chapter: + title = narrative_agent.generate_title( + stage=chapter_category, + emotion="neutral", + slots=slot_snippets, + user_profile=profile, + birth_year=birth_year, + llm=llm, + ) + new_narrative = narrative_agent.generate_narrative( + stage=chapter_category, + slots=slot_snippets, + new_content=combined_text, + existing_content=existing_content, + user_profile=profile, + birth_year=birth_year, + llm=llm, + ) + if existing_content: + narrative = f"{existing_content}\n\n{new_narrative}" + else: + narrative = new_narrative + + if existing_content and len(narrative) < len(existing_content) * 0.8: + logger.warning( + "内容长度异常: existing=%d, new=%d, category=%s. 回退为追加模式", + len(existing_content), + len(narrative), + chapter_category, + ) + narrative = f"{existing_content}\n\n{combined_text}" + + narrative = inject_placeholders(narrative) + calculated_order_index = STAGE_TO_ORDER.get(chapter_category, 999) + + chapter = _save_narrative_to_sections( + db, + chapter, + narrative, + title=title, + category=chapter_category, + order_index=calculated_order_index, + source_segments=source_ids, + user_id=user_id, + ) + db.flush() + db.refresh(chapter) + + has_images = image_settings.enabled and ( + _chapter_has_any_section_images_to_generate(chapter) + or _chapter_has_cover_to_generate(chapter) + ) + + stmt_book = select(Book).where(Book.user_id == user_id).order_by(Book.updated_at.desc()) + result_book = db.execute(stmt_book) + book = result_book.scalar_one_or_none() + if not book: + book = Book( + id=str(uuid.uuid4()), user_id=user_id, - stage=detected_stage, - slot_name=slot_name, - snippet=snippet, - segment_ids=[segment.id], - db=db, + title="我的回忆录", + total_pages=0, + total_words=0, + cover_image_url=None, ) + db.add(book) + book.has_update = True + book.last_update_chapter_id = chapter.id - # 8-category 章节分类 - chapter_category = _classify_chapter_category(text, detected_stage, llm) - if chapter_category is None: - logger.info(f"段落无回忆录价值,跳过: segment_id={segment.id}") - continue - category_to_segments.setdefault(chapter_category, []).append(segment) + return chapter, has_images - # 按 8 分类生成章节内容 - for chapter_category, category_segments in category_to_segments.items(): - if not _acquire_chapter_lock(user_id, chapter_category): - logger.warning(f"章节锁竞争: user={user_id}, category={chapter_category}, 延迟重试") - raise self.retry(countdown=10) - try: - segment_texts = [seg.transcript_text for seg in category_segments] - combined_text = "\n\n".join(segment_texts) - source_ids = [seg.id for seg in category_segments] + def _raise_retry(): + raise self.retry(countdown=10) - # 查找 active 章节(被清除的章节不继续更新,而是创建新的),并预加载 sections、images - stmt_chapter = ( - select(Chapter) - .where( - Chapter.user_id == user_id, - Chapter.category == chapter_category, - Chapter.is_active == True, - ) - .options( - joinedload(Chapter.sections).joinedload(ChapterSection.image_record), - joinedload(Chapter.images), - ) - ) - result_chapter = db.execute(stmt_chapter) - chapter = result_chapter.unique().scalar_one_or_none() - - # 获取 slot snippets - slot_snippets = { - key: value.snippet - for key, value in (state.slots.get(chapter_category, {}) or {}).items() - if value.snippet - } - - # 生成标题和内容;已有章节的正文从 sections 拼接 - title = chapter.title if chapter else f"{chapter_category} 回忆" - existing_content = "" - if chapter and getattr(chapter, "sections", None): - existing_content = "\n\n".join( - s.content for s in sorted(chapter.sections, key=lambda x: x.order_index) if (s.content or "").strip() - ) - narrative = combined_text - - if llm: - try: - if not chapter: - title_prompt = get_creative_title_prompt( - stage=chapter_category, - emotion="neutral", - slots=slot_snippets, - user_profile=user_profile, - birth_year=user_birth_year, - ) - title_response = llm.invoke(title_prompt) - title = title_response.content.strip().strip('"') - - narrative_prompt = get_narrative_prompt( - stage=chapter_category, - slots=slot_snippets, - new_content=combined_text, - existing_content=existing_content, - user_profile=user_profile, - birth_year=user_birth_year, - ) - narrative_response = llm.invoke(narrative_prompt) - new_narrative = narrative_response.content.strip() - - # 追加而非替换 - if existing_content: - narrative = f"{existing_content}\n\n{new_narrative}" - else: - narrative = new_narrative - except Exception as e: - logger.warning(f"LLM 生成失败: {e}") - if existing_content: - narrative = f"{existing_content}\n\n{combined_text}" - - # 安全检查:新内容不应比旧内容短 - if existing_content and len(narrative) < len(existing_content) * 0.8: - logger.warning( - f"内容长度异常: existing={len(existing_content)}, " - f"new={len(narrative)}, category={chapter_category}. 回退为追加模式" - ) - narrative = f"{existing_content}\n\n{combined_text}" - - # 入库前:占位符位置用正则匹配后拼上固定模板 - narrative = inject_image_placeholder_template(narrative) - calculated_order_index = STAGE_TO_ORDER.get(chapter_category, 999) - - # 写入 sections(拆段 + 每段配图占位),新建或覆盖该章下所有 sections - chapter = _save_narrative_to_sections( - db, - chapter, - narrative, - title=title, - category=chapter_category, - order_index=calculated_order_index, - source_segments=source_ids, - user_id=user_id, - ) - db.flush() - db.refresh(chapter) - if image_settings.enabled and ( - _chapter_has_any_section_images_to_generate(chapter) - or _chapter_has_cover_to_generate(chapter) - ): - chapters_to_enqueue.add(chapter.id) - - # 更新 Book - stmt_book = select(Book).where(Book.user_id == user_id).order_by(Book.updated_at.desc()) - result_book = db.execute(stmt_book) - book = result_book.scalar_one_or_none() - if not book: - book = Book( - id=str(uuid.uuid4()), - user_id=user_id, - title="我的回忆录", - total_pages=0, - total_words=0, - cover_image_url=None, - ) - db.add(book) - book.has_update = True - book.last_update_chapter_id = chapter.id - finally: - _release_chapter_lock(user_id, chapter_category) + memoir_orchestrator = MemoirOrchestrator() + chapters_to_enqueue, _ = memoir_orchestrator.run( + segments=segments, + llm=llm, + user_profile=user_profile, + user_birth_year=user_birth_year, + get_or_create_state=lambda: _get_or_create_state_sync(user_id, db), + update_slot=lambda stage, slot_name, snippet, seg_ids: _update_slot_sync( + user_id, stage, slot_name, snippet, seg_ids, db + ), + acquire_lock=lambda stage: _acquire_chapter_lock(user_id, stage), + release_lock=lambda stage: _release_chapter_lock(user_id, stage), + process_category=_process_category, + raise_retry=_raise_retry, + ) # 标记段落为已处理 for seg in segments: diff --git a/api/tests/test_process_memoir_segments_image_enqueue.py b/api/tests/test_process_memoir_segments_image_enqueue.py index 55dbc21..f07136a 100644 --- a/api/tests/test_process_memoir_segments_image_enqueue.py +++ b/api/tests/test_process_memoir_segments_image_enqueue.py @@ -15,11 +15,16 @@ def _mock_get_sync_db(db): class ProcessMemoirSegmentsImageEnqueueTest(unittest.TestCase): + @patch("app.tasks.memoir_tasks._chapter_has_cover_to_generate", return_value=True) + @patch("app.tasks.memoir_tasks._chapter_has_any_section_images_to_generate", return_value=True) @patch("app.tasks.memoir_tasks._update_task_status_sync") @patch("app.tasks.memoir_tasks._release_chapter_lock") @patch("app.tasks.memoir_tasks._acquire_chapter_lock", return_value=True) @patch("app.tasks.memoir_tasks._update_slot_sync") - @patch("app.tasks.memoir_tasks._classify_chapter_category", return_value="childhood") + @patch( + "app.agents.memoir.orchestrator.ClassificationAgent.classify", + return_value="childhood", + ) @patch("app.tasks.memoir_tasks._get_or_create_state_sync") @patch("app.tasks.memoir_tasks._get_llm") @patch("app.tasks.memoir_tasks.generate_chapter_images.delay") @@ -37,6 +42,8 @@ class ProcessMemoirSegmentsImageEnqueueTest(unittest.TestCase): _acquire_lock_mock, _release_lock_mock, _update_status_mock, + _has_section_images_mock, + _has_cover_mock, ): settings_from_env.return_value = MemoirImageSettings( enabled=True, @@ -87,11 +94,15 @@ class ProcessMemoirSegmentsImageEnqueueTest(unittest.TestCase): empty_sections_result = Mock() empty_sections_result.scalars.return_value.all.return_value = [] + cover_check_result = Mock() + cover_check_result.scalar_one_or_none.return_value = None + db = Mock() db.execute.side_effect = [ segments_result, chapter_result, empty_sections_result, # _save_narrative_to_sections 内查询 ChapterSection + cover_check_result, # 封面 MemoirImage 检查 book_result, ] db.get.return_value = None @@ -108,12 +119,12 @@ class ProcessMemoirSegmentsImageEnqueueTest(unittest.TestCase): process_memoir_segments.run.__func__(task_self, "user-1", ["segment-1"]) update_slot_mock.assert_called_once_with( - user_id="user-1", - stage="childhood", - slot_name="family_memory", - snippet="外婆总在门口等我", - segment_ids=["segment-1"], - db=db, + "user-1", + "childhood", + "family_memory", + "外婆总在门口等我", + ["segment-1"], + db, ) self.assertIn("commit", events) delay_events = [event for event in events if event.startswith("delay:")] @@ -123,7 +134,10 @@ class ProcessMemoirSegmentsImageEnqueueTest(unittest.TestCase): @patch("app.tasks.memoir_tasks._update_task_status_sync") @patch("app.tasks.memoir_tasks._release_chapter_lock") @patch("app.tasks.memoir_tasks._acquire_chapter_lock", return_value=True) - @patch("app.tasks.memoir_tasks._classify_chapter_category", return_value="childhood") + @patch( + "app.agents.memoir.orchestrator.ClassificationAgent.classify", + return_value="childhood", + ) @patch("app.tasks.memoir_tasks._get_or_create_state_sync") @patch("app.tasks.memoir_tasks._get_llm", return_value=None) @patch("app.tasks.memoir_tasks.generate_chapter_images.delay") From c21cda3e788af95ef8cdd047e5f60dffe21c68c6 Mon Sep 17 00:00:00 2001 From: yangshilin <2157598560@qq.com> Date: Thu, 19 Mar 2026 10:43:34 +0800 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20=E7=94=9F=E6=88=90=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E6=8F=90=E7=A4=BA=E8=AF=8Dagent=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E5=B0=81=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/agents/__init__.py | 5 +- api/app/agents/image_prompt/__init__.py | 8 +++ api/app/agents/image_prompt/orchestrator.py | 57 +++++++++++++++++++ api/app/agents/image_prompt/prompt_agent.py | 50 ++++++++++++++++ api/app/tasks/memoir_tasks.py | 7 ++- ...est_generate_chapter_images_persistence.py | 2 +- .../test_generate_chapter_images_task.py | 14 ++--- 7 files changed, 131 insertions(+), 12 deletions(-) create mode 100644 api/app/agents/image_prompt/__init__.py create mode 100644 api/app/agents/image_prompt/orchestrator.py create mode 100644 api/app/agents/image_prompt/prompt_agent.py diff --git a/api/app/agents/__init__.py b/api/app/agents/__init__.py index b1b613c..839f422 100644 --- a/api/app/agents/__init__.py +++ b/api/app/agents/__init__.py @@ -1,5 +1,5 @@ """ -Agent 模块(按功能拆分:chat / memoir) +Agent 模块(按功能拆分:chat / memoir / image_prompt) """ from app.agents.chat import ( ChatOrchestrator, @@ -7,6 +7,7 @@ from app.agents.chat import ( InterviewAgent, ProfileAgent, ) +from app.agents.image_prompt import ImagePromptOrchestrator, PromptGenerationAgent from app.agents.memoir import BackgroundTaskRunner, MemoryAgent __all__ = [ @@ -16,4 +17,6 @@ __all__ = [ "ProfileAgent", "InterviewAgent", "BackgroundTaskRunner", + "ImagePromptOrchestrator", + "PromptGenerationAgent", ] diff --git a/api/app/agents/image_prompt/__init__.py b/api/app/agents/image_prompt/__init__.py new file mode 100644 index 0000000..3d26b1b --- /dev/null +++ b/api/app/agents/image_prompt/__init__.py @@ -0,0 +1,8 @@ +"""图片提示词模块:ImagePromptOrchestrator + PromptGenerationAgent""" +from app.agents.image_prompt.orchestrator import ImagePromptOrchestrator +from app.agents.image_prompt.prompt_agent import PromptGenerationAgent + +__all__ = [ + "ImagePromptOrchestrator", + "PromptGenerationAgent", +] diff --git a/api/app/agents/image_prompt/orchestrator.py b/api/app/agents/image_prompt/orchestrator.py new file mode 100644 index 0000000..6a879d3 --- /dev/null +++ b/api/app/agents/image_prompt/orchestrator.py @@ -0,0 +1,57 @@ +""" +ImagePromptOrchestrator:图片提示词生成编排器。 +根据调用方(封面/正文)选择 build_prompt 或 build_cover_prompt; +统一异常处理和回退;内部委托 PromptGenerationAgent。 +""" +from __future__ import annotations + +from typing import Any, Optional + +from app.features.memoir.memoir_images.settings import MemoirImageSettings + +from app.agents.image_prompt.prompt_agent import PromptGenerationAgent + + +class ImagePromptOrchestrator: + """ + 图片提示词编排器。 + 区分封面 vs 正文配图,统一调用 PromptGenerationAgent; + 异常与回退由 PromptGenerationAgent(底层 MemoirImagePromptService)处理。 + """ + + def __init__(self, llm: Optional[Any], settings: MemoirImageSettings) -> None: + self._agent = PromptGenerationAgent(llm=llm, settings=settings) + + def build_prompt( + self, + chapter_title: str, + chapter_category: str, + description: str, + context_excerpt: str, + ) -> dict[str, str]: + """ + 生成正文配图的 prompt。 + 委托 PromptGenerationAgent,已含 LLM 调用失败时的 fallback 逻辑。 + """ + return self._agent.build_prompt( + chapter_title=chapter_title, + chapter_category=chapter_category, + description=description, + context_excerpt=context_excerpt, + ) + + def build_cover_prompt( + self, + chapter_title: str, + chapter_category: str, + context_excerpt: str, + ) -> dict[str, str]: + """ + 生成章节封面的 prompt。 + 委托 PromptGenerationAgent,已含 LLM 调用失败时的 fallback 逻辑。 + """ + return self._agent.build_cover_prompt( + chapter_title=chapter_title, + chapter_category=chapter_category, + context_excerpt=context_excerpt, + ) diff --git a/api/app/agents/image_prompt/prompt_agent.py b/api/app/agents/image_prompt/prompt_agent.py new file mode 100644 index 0000000..5983fad --- /dev/null +++ b/api/app/agents/image_prompt/prompt_agent.py @@ -0,0 +1,50 @@ +""" +PromptGenerationAgent:生成回忆录配图的 image-generation prompt。 +接收 chapter_title、chapter_category、description、context_excerpt, +调用 LLM 或 fallback 生成 {prompt, style, size}。 +底层委托 MemoirImagePromptService,保持对外接口兼容。 +""" +from __future__ import annotations + +from typing import Any, Optional + +from app.features.memoir.memoir_images.prompting import MemoirImagePromptService +from app.features.memoir.memoir_images.settings import MemoirImageSettings + + +class PromptGenerationAgent: + """ + 图片提示词生成 Specialist Agent。 + 封装 MemoirImagePromptService,提供 build_prompt / build_cover_prompt 接口。 + """ + + def __init__(self, llm: Optional[Any], settings: MemoirImageSettings) -> None: + self._service = MemoirImagePromptService(llm=llm, settings=settings) + + def build_prompt( + self, + chapter_title: str, + chapter_category: str, + description: str, + context_excerpt: str, + ) -> dict[str, str]: + """生成正文配图的 image-generation prompt。""" + return self._service.build_prompt( + chapter_title=chapter_title, + chapter_category=chapter_category, + description=description, + context_excerpt=context_excerpt, + ) + + def build_cover_prompt( + self, + chapter_title: str, + chapter_category: str, + context_excerpt: str, + ) -> dict[str, str]: + """生成章节封面图的 image-generation prompt。""" + return self._service.build_cover_prompt( + chapter_title=chapter_title, + chapter_category=chapter_category, + context_excerpt=context_excerpt, + ) diff --git a/api/app/tasks/memoir_tasks.py b/api/app/tasks/memoir_tasks.py index 97a5ffc..550e2b1 100644 --- a/api/app/tasks/memoir_tasks.py +++ b/api/app/tasks/memoir_tasks.py @@ -42,6 +42,7 @@ from app.features.memoir.memoir_images.parser import ( ) import hashlib from app.core.dependencies import get_image_generator +from app.agents.image_prompt import ImagePromptOrchestrator from app.features.memoir.memoir_images.prompting import MemoirImagePromptService from app.features.memoir.memoir_images.schema import ( completed_image_assets, @@ -883,7 +884,7 @@ def generate_chapter_images(self, chapter_id: str): logger.info("章节补图跳过: chapter=%s, reason=locked", chapter_id) return {"status": "locked"} - prompt_service = MemoirImagePromptService(_get_llm(), settings) + prompt_orchestrator = ImagePromptOrchestrator(_get_llm(), settings) image_generator = get_image_generator() storage = TencentCosStorageService.from_env() logger.info( @@ -922,7 +923,7 @@ def generate_chapter_images(self, chapter_id: str): sections_ordered = sorted(sections, key=lambda s: getattr(s, "order_index", 0)) first_content = (sections_ordered[0].content or "").strip() if sections_ordered else "" context_excerpt = " ".join(first_content.split("\n")[:5])[:200] - prompt_data = prompt_service.build_cover_prompt( + prompt_data = prompt_orchestrator.build_cover_prompt( chapter_title=chapter.title, chapter_category=chapter.category or "", context_excerpt=context_excerpt, @@ -985,7 +986,7 @@ def generate_chapter_images(self, chapter_id: str): try: context_lines = (section.content or "").strip().split("\n")[:5] context_excerpt = " ".join(context_lines)[:200] - prompt_data = prompt_service.build_prompt( + prompt_data = prompt_orchestrator.build_prompt( chapter_title=chapter.title, chapter_category=chapter.category or "", description=current_item.get("description", ""), diff --git a/api/tests/test_generate_chapter_images_persistence.py b/api/tests/test_generate_chapter_images_persistence.py index 283e544..6af5cd8 100644 --- a/api/tests/test_generate_chapter_images_persistence.py +++ b/api/tests/test_generate_chapter_images_persistence.py @@ -63,7 +63,7 @@ class GenerateChapterImagesPersistenceTest(unittest.TestCase): @patch("app.tasks.memoir_tasks.get_sync_db") @patch("app.tasks.memoir_tasks.TencentCosStorageService") @patch("app.tasks.memoir_tasks.get_image_generator") - @patch("app.tasks.memoir_tasks.MemoirImagePromptService") + @patch("app.tasks.memoir_tasks.ImagePromptOrchestrator") @patch("app.tasks.memoir_tasks._release_chapter_image_lock") @patch("app.tasks.memoir_tasks._acquire_chapter_image_lock", return_value=True) def test_successful_generation_persists_completed_status( diff --git a/api/tests/test_generate_chapter_images_task.py b/api/tests/test_generate_chapter_images_task.py index 5d77c2c..e104abd 100644 --- a/api/tests/test_generate_chapter_images_task.py +++ b/api/tests/test_generate_chapter_images_task.py @@ -92,7 +92,7 @@ class GenerateChapterImagesTaskTest(unittest.TestCase): @patch("app.tasks.memoir_tasks.get_sync_db") @patch("app.tasks.memoir_tasks.TencentCosStorageService") @patch("app.tasks.memoir_tasks.get_image_generator") - @patch("app.tasks.memoir_tasks.MemoirImagePromptService") + @patch("app.tasks.memoir_tasks.ImagePromptOrchestrator") def test_generate_chapter_images_skips_when_lock_is_already_held( self, prompt_service_cls, @@ -120,7 +120,7 @@ class GenerateChapterImagesTaskTest(unittest.TestCase): @patch("app.tasks.memoir_tasks.get_sync_db") @patch("app.tasks.memoir_tasks.TencentCosStorageService") @patch("app.tasks.memoir_tasks.get_image_generator") - @patch("app.tasks.memoir_tasks.MemoirImagePromptService") + @patch("app.tasks.memoir_tasks.ImagePromptOrchestrator") @patch("app.tasks.memoir_tasks._release_chapter_image_lock") @patch("app.tasks.memoir_tasks._acquire_chapter_image_lock", return_value=True) def test_generate_chapter_images_retries_when_any_item_generation_fails( @@ -162,7 +162,7 @@ class GenerateChapterImagesTaskTest(unittest.TestCase): @patch("app.tasks.memoir_tasks.get_sync_db") @patch("app.tasks.memoir_tasks.TencentCosStorageService") @patch("app.tasks.memoir_tasks.get_image_generator") - @patch("app.tasks.memoir_tasks.MemoirImagePromptService") + @patch("app.tasks.memoir_tasks.ImagePromptOrchestrator") @patch("app.tasks.memoir_tasks._release_chapter_image_lock") @patch("app.tasks.memoir_tasks._acquire_chapter_image_lock", return_value=True) def test_generate_chapter_images_marks_successful_item_completed( @@ -203,7 +203,7 @@ class GenerateChapterImagesTaskTest(unittest.TestCase): @patch("app.tasks.memoir_tasks.get_sync_db") @patch("app.tasks.memoir_tasks.TencentCosStorageService") @patch("app.tasks.memoir_tasks.get_image_generator") - @patch("app.tasks.memoir_tasks.MemoirImagePromptService") + @patch("app.tasks.memoir_tasks.ImagePromptOrchestrator") @patch("app.tasks.memoir_tasks.MemoirImageSettings.from_env") def test_generate_chapter_images_returns_disabled_when_feature_flag_is_off( self, @@ -242,7 +242,7 @@ class GenerateChapterImagesTaskTest(unittest.TestCase): @patch("app.tasks.memoir_tasks.get_sync_db") @patch("app.tasks.memoir_tasks.TencentCosStorageService") @patch("app.tasks.memoir_tasks.get_image_generator") - @patch("app.tasks.memoir_tasks.MemoirImagePromptService") + @patch("app.tasks.memoir_tasks.ImagePromptOrchestrator") @patch("app.tasks.memoir_tasks._release_chapter_image_lock") @patch("app.tasks.memoir_tasks._acquire_chapter_image_lock", return_value=True) def test_generate_chapter_images_converts_non_png_payload_before_upload( @@ -287,7 +287,7 @@ class GenerateChapterImagesTaskTest(unittest.TestCase): @patch("app.tasks.memoir_tasks.get_sync_db") @patch("app.tasks.memoir_tasks.TencentCosStorageService") @patch("app.tasks.memoir_tasks.get_image_generator") - @patch("app.tasks.memoir_tasks.MemoirImagePromptService") + @patch("app.tasks.memoir_tasks.ImagePromptOrchestrator") @patch("app.tasks.memoir_tasks._release_chapter_image_lock") @patch("app.tasks.memoir_tasks._acquire_chapter_image_lock", return_value=True) def test_generate_chapter_images_fails_without_retry_on_permanent_cos_error( @@ -330,7 +330,7 @@ class GenerateChapterImagesTaskTest(unittest.TestCase): @patch("app.tasks.memoir_tasks.get_sync_db") @patch("app.tasks.memoir_tasks.TencentCosStorageService") @patch("app.tasks.memoir_tasks.get_image_generator") - @patch("app.tasks.memoir_tasks.MemoirImagePromptService") + @patch("app.tasks.memoir_tasks.ImagePromptOrchestrator") @patch("app.tasks.memoir_tasks._release_chapter_image_lock") @patch("app.tasks.memoir_tasks._acquire_chapter_image_lock", return_value=True) def test_generate_chapter_images_skips_completed_items_for_idempotency( From 67fb5d2cb68f230889fdc00a671a58749868a67a Mon Sep 17 00:00:00 2001 From: yangshilin <2157598560@qq.com> Date: Thu, 19 Mar 2026 10:54:48 +0800 Subject: [PATCH 5/8] =?UTF-8?q?refactor:=20=E8=BF=9B=E4=B8=80=E6=AD=A5?= =?UTF-8?q?=E9=87=8D=E6=9E=84agents=E7=9B=AE=E5=BD=95=E7=BB=93=E6=9E=84=20?= =?UTF-8?q?=E7=AC=A6=E5=90=88=E5=A4=9Aagent=E6=9E=B6=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/agents/chat/conversation_agent.py | 2 +- api/app/agents/chat/interview_agent.py | 7 +- api/app/agents/chat/orchestrator.py | 2 +- api/app/agents/chat/profile_agent.py | 2 +- api/app/agents/chat/prompts.py | 41 ++++++++++ .../prompts_conversation.py} | 79 +++++-------------- .../prompts_profile.py} | 0 api/app/agents/memoir/classification_agent.py | 2 +- api/app/agents/memoir/extraction_agent.py | 2 +- api/app/agents/memoir/memory_agent.py | 6 +- api/app/agents/memoir/narrative_agent.py | 2 +- api/app/agents/memoir/placeholder_agent.py | 2 +- api/app/agents/memoir/processor.py | 2 +- .../memory_prompts.py => memoir/prompts.py} | 35 +++----- api/app/agents/prompts/__init__.py | 57 ------------- .../conversation/ws/profile_collector.py | 2 +- api/app/features/conversation/ws/router.py | 2 +- api/app/features/memoir/service.py | 2 +- api/app/tasks/memoir_tasks.py | 4 +- api/routers/chapters.py | 2 +- api/routers/websocket.py | 8 +- api/scripts/reprocess_user_memoir.py | 2 +- api/tests/test_memory_prompts_inject.py | 2 +- 23 files changed, 98 insertions(+), 167 deletions(-) create mode 100644 api/app/agents/chat/prompts.py rename api/app/agents/{prompts/conversation_prompts.py => chat/prompts_conversation.py} (88%) rename api/app/agents/{prompts/profile_prompts.py => chat/prompts_profile.py} (100%) rename api/app/agents/{prompts/memory_prompts.py => memoir/prompts.py} (90%) delete mode 100644 api/app/agents/prompts/__init__.py diff --git a/api/app/agents/chat/conversation_agent.py b/api/app/agents/chat/conversation_agent.py index 27d8680..0d0d121 100644 --- a/api/app/agents/chat/conversation_agent.py +++ b/api/app/agents/chat/conversation_agent.py @@ -6,7 +6,7 @@ from datetime import datetime from typing import Any, Dict, List, Optional from app.agents.chat.orchestrator import ChatOrchestrator -from app.agents.prompts import ConversationStage +from app.agents.chat.prompts_conversation import ConversationStage from app.agents.state_schema import MemoirStateSchema from app.core.redis import redis_service diff --git a/api/app/agents/chat/interview_agent.py b/api/app/agents/chat/interview_agent.py index 4bd045d..8d51fee 100644 --- a/api/app/agents/chat/interview_agent.py +++ b/api/app/agents/chat/interview_agent.py @@ -8,8 +8,11 @@ from app.core.dependencies import get_llm_provider from app.core.logging import get_logger from app.agents.chat.helpers import format_history_string, get_history_messages -from app.agents.prompts import get_guided_conversation_prompt, get_opening_prompt -from app.agents.prompts.conversation_prompts import SLOT_NAME_MAP +from app.agents.chat.prompts_conversation import ( + SLOT_NAME_MAP, + get_guided_conversation_prompt, + get_opening_prompt, +) from app.agents.state_schema import MemoirStateSchema logger = get_logger(__name__) diff --git a/api/app/agents/chat/orchestrator.py b/api/app/agents/chat/orchestrator.py index 1a5e66d..20be068 100644 --- a/api/app/agents/chat/orchestrator.py +++ b/api/app/agents/chat/orchestrator.py @@ -92,7 +92,7 @@ class ChatOrchestrator: conversation.conversation_stage = state.current_stage await db.commit() - from app.agents.prompts.profile_prompts import format_user_profile_context + from app.agents.chat.prompts_profile import format_user_profile_context user_profile_context = "" if user: diff --git a/api/app/agents/chat/profile_agent.py b/api/app/agents/chat/profile_agent.py index ae7688b..91337a9 100644 --- a/api/app/agents/chat/profile_agent.py +++ b/api/app/agents/chat/profile_agent.py @@ -11,7 +11,7 @@ from app.core.dependencies import get_llm_provider from app.core.logging import get_logger from app.agents.chat.helpers import format_history_string, get_history_messages -from app.agents.prompts.profile_prompts import ( +from app.agents.chat.prompts_profile import ( get_profile_extraction_prompt, get_profile_followup_prompt, get_profile_greeting_prompt, diff --git a/api/app/agents/chat/prompts.py b/api/app/agents/chat/prompts.py new file mode 100644 index 0000000..1dc9467 --- /dev/null +++ b/api/app/agents/chat/prompts.py @@ -0,0 +1,41 @@ +""" +Chat 模块提示词:用户资料收集 + 对话访谈 +""" +# Profile prompts(用户资料收集) +from app.agents.chat.prompts_profile import ( + PROFILE_FIELD_NAMES, + format_user_profile_context, + get_missing_profile_fields, + get_profile_extraction_prompt, + get_profile_followup_prompt, + get_profile_greeting_prompt, +) + +# Conversation prompts(对话访谈) +from app.agents.chat.prompts_conversation import ( + ConversationStage, + INTERVIEW_QUESTIONS, + SLOT_NAME_MAP, + get_conversation_prompt, + get_guided_conversation_prompt, + get_opening_prompt, + get_questions_for_stage, + get_system_prompt, +) + +__all__ = [ + "PROFILE_FIELD_NAMES", + "format_user_profile_context", + "get_missing_profile_fields", + "get_profile_extraction_prompt", + "get_profile_followup_prompt", + "get_profile_greeting_prompt", + "ConversationStage", + "INTERVIEW_QUESTIONS", + "SLOT_NAME_MAP", + "get_conversation_prompt", + "get_guided_conversation_prompt", + "get_opening_prompt", + "get_questions_for_stage", + "get_system_prompt", +] diff --git a/api/app/agents/prompts/conversation_prompts.py b/api/app/agents/chat/prompts_conversation.py similarity index 88% rename from api/app/agents/prompts/conversation_prompts.py rename to api/app/agents/chat/prompts_conversation.py index 6e1d41b..7e6ff59 100644 --- a/api/app/agents/prompts/conversation_prompts.py +++ b/api/app/agents/chat/prompts_conversation.py @@ -65,12 +65,12 @@ INTERVIEW_QUESTIONS: Dict[ConversationStage, List[str]] = { def get_system_prompt(current_stage: ConversationStage, covered_topics: List[str], user_latest_response: str) -> str: """ 生成对话 Agent 的系统提示词 - + Args: current_stage: 当前对话阶段 covered_topics: 已聊过的话题列表 user_latest_response: 用户最新回答 - + Returns: 系统提示词字符串 """ @@ -82,9 +82,9 @@ def get_system_prompt(current_stage: ConversationStage, covered_topics: List[str ConversationStage.BELIEFS: "信念", ConversationStage.SUMMARY: "人生总结", } - + covered_topics_str = "、".join(covered_topics) if covered_topics else "暂无" - + prompt = f"""你是「岁月知己」,一位资深的人生故事访谈者,专注于帮助用户回忆和讲述人生经历。 ## 角色定位 @@ -106,7 +106,7 @@ def get_system_prompt(current_stage: ConversationStage, covered_topics: List[str 已聊话题:{covered_topics_str} 请直接回应用户,不要有任何元描述。""" - + return prompt @@ -116,46 +116,39 @@ def get_questions_for_stage(stage: ConversationStage) -> List[str]: SLOT_NAME_MAP = { - # 童年 "place": "成长的地方", "people": "重要的人", "daily_life": "日常生活", "emotion": "童年感受", "turning_event": "难忘的事", - # 教育 "school": "学校经历", "city": "求学的城市", "motivation": "学习动力", "challenge": "遇到的挑战", "change": "成长变化", - # 事业 "job": "工作内容", "environment": "工作环境", "decision": "重要决定", "pressure": "压力与困难", "growth": "职业成长", - # 家庭 "relationship": "家人关系", "conflict": "矛盾与化解", "support": "相互支持", "responsibility": "家庭责任", - # 信念 "value": "核心价值观", "regret": "遗憾与释怀", "pride": "骄傲的事", "lesson": "人生经验", } -# 阶段关联话题(用于自然过渡) STAGE_RELATED_TOPICS = { - "childhood": ["family", "education"], # 童年可以自然聊到家庭、教育 - "education": ["childhood", "career"], # 教育可以聊到童年、事业 - "career": ["education", "family", "belief"], # 事业可以聊到教育、家庭、信念 - "family": ["childhood", "career", "belief"], # 家庭可以聊到童年、事业、信念 - "belief": ["career", "family"], # 信念可以聊到事业、家庭 + "childhood": ["family", "education"], + "education": ["childhood", "career"], + "career": ["education", "family", "belief"], + "family": ["childhood", "career", "belief"], + "belief": ["career", "family"], } -# 轻松话题(用于调节气氛) LIGHT_TOPICS = [ "有什么爱好或者特别喜欢的消遣方式吗?", "最近有什么让你开心的事吗?", @@ -163,13 +156,12 @@ LIGHT_TOPICS = [ "平时喜欢看什么书或者电影吗?", ] -# 回应风格模板(增加多样性) RESPONSE_STYLES = [ - "empathy", # 共情式回应 - "curious", # 好奇追问 - "reflection", # 感慨反思 - "lighthearted", # 轻松调侃 - "connection", # 关联自身(虚构) + "empathy", + "curious", + "reflection", + "lighthearted", + "connection", ] @@ -178,10 +170,7 @@ def get_opening_prompt( empty_slots_readable: List[str], user_profile_context: str = "", ) -> str: - """ - 空对话时 AI 先开口的提示词(用户通过「打个招呼」进入,尚未发送任何消息)。 - 要求 AI 先发一条问候 + 一个具体问题,引导用户开始分享。 - """ + """空对话时 AI 先开口的提示词""" stage_name_map = { "childhood": "童年时光", "education": "求学经历", @@ -216,10 +205,7 @@ def get_opening_prompt( def _build_era_context(current_stage: str, user_profile_context: str) -> str: - """ - 根据用户的人生阶段和出生年份,生成对应时代的历史/政治/文化背景提示。 - 让 agent 在对话中自然融入时代感。 - """ + """根据用户的人生阶段和出生年份,生成对应时代的历史/政治/文化背景提示""" if not user_profile_context: return "" @@ -250,7 +236,6 @@ def _build_era_context(current_stage: str, user_profile_context: str) -> str: era_end = birth_year + age_range[1] era_events = [] - decade_events = { 1950: "新中国成立初期、土地改革、抗美援朝", 1960: "大跃进、三年自然灾害、中苏关系变化", @@ -270,7 +255,6 @@ def _build_era_context(current_stage: str, user_profile_context: str) -> str: return "" place_hint = f"(用户来自{birth_place})" if birth_place else "" - return f""" ## 时代背景参考{place_hint} 用户在这个人生阶段大约经历了 {era_start}-{era_end} 年({age_range[0]}-{age_range[1]} 岁): @@ -293,20 +277,7 @@ def get_guided_conversation_prompt( detected_user_stage: str = "", user_profile_context: str = "", ) -> str: - """ - 生成状态感知的对话提示词 - - Args: - current_stage: 系统当前跟踪的阶段 - empty_slots: 当前阶段未填充的槽位 - filled_slots: 当前阶段已填充的槽位 - user_message: 用户消息 - conversation_turn: 总对话轮数 - same_topic_turns: 同一话题的轮数 - all_stages_coverage: 所有阶段的覆盖情况 {stage: {total, filled, empty, ratio}} - detected_user_stage: 检测到用户正在谈论的阶段(可能和 current_stage 不同) - user_profile_context: 用户基础资料上下文 - """ + """生成状态感知的对话提示词""" stage_name_map = { "childhood": "童年时光", "education": "求学经历", @@ -317,12 +288,8 @@ def get_guided_conversation_prompt( current_stage_name = stage_name_map.get(current_stage, current_stage) user_stage_name = stage_name_map.get(detected_user_stage, "") if detected_user_stage else "" - - # 判断用户是否在聊一个不同于系统当前阶段的话题 user_jumped = detected_user_stage and detected_user_stage != current_stage - # --- 构建当前聊天上下文 --- - # 转换 slot 名称为中文 empty_slots_readable = [SLOT_NAME_MAP.get(s, s) for s in empty_slots] empty_slots_str = "、".join(empty_slots_readable) if empty_slots_readable else "已聊得很充分" @@ -332,7 +299,6 @@ def get_guided_conversation_prompt( filled_info.append(f"{readable_key}: {value[:50]}..." if len(value) > 50 else f"{readable_key}: {value}") filled_slots_str = "\n".join(filled_info) if filled_info else "刚开始聊" - # --- 构建全局进度概览 --- progress_lines = [] uncovered_stages = [] if all_stages_coverage: @@ -350,17 +316,14 @@ def get_guided_conversation_prompt( progress_lines.append(f" {sname}:已聊得很充分 ✓") progress_str = "\n".join(progress_lines) if progress_lines else "" - # --- 动态策略 --- filled_count = len(filled_slots) should_switch_topic = same_topic_turns >= 3 or (filled_count >= 2 and same_topic_turns >= 2) should_lighten_mood = conversation_turn > 0 and conversation_turn % 5 == 0 should_try_new_stage = filled_count >= 3 and len(empty_slots) <= 2 - # 获取相关阶段 related_stages = STAGE_RELATED_TOPICS.get(current_stage, []) related_stages_str = "、".join([stage_name_map.get(s, s) for s in related_stages]) - # 选择回应风格 style = random.choice(RESPONSE_STYLES) style_guidance = { "empathy": "这次回应要特别体现共情,表达你能理解用户的感受", @@ -370,7 +333,6 @@ def get_guided_conversation_prompt( "connection": "这次回应可以分享一个类似的经历或感受(可以虚构)", }.get(style, "") - # --- 构建动态指导 --- dynamic_guidance = "" if user_jumped: dynamic_guidance += f""" @@ -385,19 +347,15 @@ def get_guided_conversation_prompt( if should_try_new_stage and related_stages: dynamic_guidance += f"\n- 如果自然的话,可以尝试聊聊相关的话题,比如{related_stages_str}" - # --- 缺失章节补充提示(仅在用户没有跳转、且当前话题聊得差不多时) --- uncovered_hint = "" if not user_jumped and uncovered_stages and should_try_new_stage: uncovered_hint = f"\n- 还没聊到的人生阶段有:{'、'.join(uncovered_stages)},如果聊天中有自然的契机,可以轻轻带一句,但不要刻意" - # --- 组合 prompt --- - # 根据是否跳转,调整主题描述 if user_jumped: topic_desc = f"你们原本在聊「{current_stage_name}」,但用户自然地聊到了「{user_stage_name}」的内容" else: topic_desc = f"你们聊到了「{current_stage_name}」这个话题" - # --- 用户资料和时代背景 --- profile_section = "" if user_profile_context: profile_section = f"\n## 用户基本信息\n{user_profile_context}\n" @@ -460,7 +418,6 @@ def get_guided_conversation_prompt( return prompt -# 保留向后兼容的函数名 def get_conversation_prompt(current_stage: ConversationStage, covered_topics: List[str], user_latest_response: str) -> str: """向后兼容的函数""" return get_system_prompt(current_stage, covered_topics, user_latest_response) diff --git a/api/app/agents/prompts/profile_prompts.py b/api/app/agents/chat/prompts_profile.py similarity index 100% rename from api/app/agents/prompts/profile_prompts.py rename to api/app/agents/chat/prompts_profile.py diff --git a/api/app/agents/memoir/classification_agent.py b/api/app/agents/memoir/classification_agent.py index 094e0f3..2ea203b 100644 --- a/api/app/agents/memoir/classification_agent.py +++ b/api/app/agents/memoir/classification_agent.py @@ -8,7 +8,7 @@ from typing import Any, Optional from app.core.logging import get_logger -from app.agents.prompts.memory_prompts import ( +from app.agents.memoir.prompts import ( CHAPTER_CATEGORIES, get_chapter_classification_prompt, ) diff --git a/api/app/agents/memoir/extraction_agent.py b/api/app/agents/memoir/extraction_agent.py index 10f776f..f6e9136 100644 --- a/api/app/agents/memoir/extraction_agent.py +++ b/api/app/agents/memoir/extraction_agent.py @@ -11,7 +11,7 @@ from typing import Any, Dict from app.core.logging import get_logger from app.features.memoir.memoir_images.json_payload import extract_json_payload -from app.agents.prompts.memory_prompts import get_state_extraction_prompt +from app.agents.memoir.prompts import get_state_extraction_prompt logger = get_logger(__name__) diff --git a/api/app/agents/memoir/memory_agent.py b/api/app/agents/memoir/memory_agent.py index d764778..242f3a7 100644 --- a/api/app/agents/memoir/memory_agent.py +++ b/api/app/agents/memoir/memory_agent.py @@ -8,12 +8,12 @@ from typing import Dict, List, Optional from app.core.dependencies import get_llm_provider from app.core.logging import get_logger -from app.agents.prompts import ( +from app.agents.memoir.prompts import ( + CHAPTER_CATEGORIES, + STAGE_TO_ORDER, get_chapter_classification_prompt, get_text_rewrite_prompt, inject_image_placeholder_template, - CHAPTER_CATEGORIES, - STAGE_TO_ORDER, ) logger = get_logger(__name__) diff --git a/api/app/agents/memoir/narrative_agent.py b/api/app/agents/memoir/narrative_agent.py index 866f5d0..0c90c8c 100644 --- a/api/app/agents/memoir/narrative_agent.py +++ b/api/app/agents/memoir/narrative_agent.py @@ -8,7 +8,7 @@ from typing import Any, Dict, Optional from app.core.logging import get_logger -from app.agents.prompts.memory_prompts import ( +from app.agents.memoir.prompts import ( get_creative_title_prompt, get_narrative_prompt, ) diff --git a/api/app/agents/memoir/placeholder_agent.py b/api/app/agents/memoir/placeholder_agent.py index 4559b9e..59e0b2c 100644 --- a/api/app/agents/memoir/placeholder_agent.py +++ b/api/app/agents/memoir/placeholder_agent.py @@ -3,7 +3,7 @@ PlaceholderInjectAgent:对 narrative 做占位符模板注入。 对应现有逻辑:inject_image_placeholder_template 纯函数式,无 LLM 调用。 """ -from app.agents.prompts.memory_prompts import inject_image_placeholder_template +from app.agents.memoir.prompts import inject_image_placeholder_template def inject_placeholders(content: str) -> str: diff --git a/api/app/agents/memoir/processor.py b/api/app/agents/memoir/processor.py index 5d09bb3..e9f0e84 100644 --- a/api/app/agents/memoir/processor.py +++ b/api/app/agents/memoir/processor.py @@ -13,7 +13,7 @@ from app.core.logging import get_logger from app.core.task_tracker import task_tracker from app.agents.state_schema import MemoirStateSchema -from app.agents.prompts.memory_prompts import ( +from app.agents.memoir.prompts import ( get_creative_title_prompt, get_narrative_prompt, get_state_extraction_prompt, diff --git a/api/app/agents/prompts/memory_prompts.py b/api/app/agents/memoir/prompts.py similarity index 90% rename from api/app/agents/prompts/memory_prompts.py rename to api/app/agents/memoir/prompts.py index 3e78d68..a451e2e 100644 --- a/api/app/agents/prompts/memory_prompts.py +++ b/api/app/agents/memoir/prompts.py @@ -5,7 +5,6 @@ import json import re from typing import Optional -# 章节分类映射 CHAPTER_CATEGORIES = { "childhood": "童年与成长背景", "education": "教育经历与青年时期", @@ -17,7 +16,6 @@ CHAPTER_CATEGORIES = { "summary": "人生总结", } -# 章节顺序 CHAPTER_ORDER = [ "childhood", "education", @@ -29,22 +27,19 @@ CHAPTER_ORDER = [ "summary", ] -# 统一的阶段名 → 排序索引映射 -# 兼容 5 阶段简化名(conversation/state 模型)和 8 分类详细名(chapter 模型) STAGE_TO_ORDER = { "childhood": 0, "education": 1, - "career": 2, # 5-stage 简化名 - "career_early": 2, # 8-category 详细名 + "career": 2, + "career_early": 2, "career_achievement": 3, "career_challenge": 4, "family": 5, - "belief": 6, # 5-stage 简化名(单数) - "beliefs": 6, # 8-category 详细名(复数) + "belief": 6, + "beliefs": 6, "summary": 7, } -# 图片占位符入库前拼接的固定提示词模板(与原先 prompt 中要求一致,改为代码侧统一拼接) IMAGE_PLACEHOLDER_TEMPLATE = ( "温暖怀旧风格,年代感复古色调,柔和光影,朴素温馨氛围,安静治愈,低饱和度," "质感柔和细腻,简约构图,充满岁月沉淀感与故事感,高清唯美插画封面,不要包含文字," @@ -53,8 +48,6 @@ IMAGE_PLACEHOLDER_TEMPLATE = ( "有朦胧怀旧的年代感。" ) - -# 匹配任意层数的图片占位符(2/4/6/8...层花括号),整段替换为规范四层,避免多余花括号残留导致客户端显示异常 _IMAGE_PLACEHOLDER_ANY_BRACES_RE = re.compile( r"(\{\{)+IMAGE:\s*([^}]+)(\}\})+", re.DOTALL, @@ -64,9 +57,7 @@ _IMAGE_PLACEHOLDER_ANY_BRACES_RE = re.compile( def inject_image_placeholder_template(content: str) -> str: """ 入库前对章节正文做占位符处理:用正则匹配所有图片占位符位置,拼上固定模板。 - 支持任意层数花括号(如 {{、{{{{、{{{{{{ 等),输出统一为四层大括号 + 固定模板 + 描述, - 避免 LLM 输出花括号过多时只替换内层导致多余花括号残留在正文中、在手机端被原样显示。 - 若占位符内已包含固定模板前缀则不再重复拼接。 + 支持任意层数花括号,输出统一为四层大括号 + 固定模板 + 描述。 """ if not content or not content.strip(): return content @@ -82,6 +73,8 @@ def inject_image_placeholder_template(content: str) -> str: content = _IMAGE_PLACEHOLDER_ANY_BRACES_RE.sub(replace_one, content) return content + + def get_system_prompt() -> str: """获取整理 Agent 的系统提示词""" return """你是一位专业的传记作家和文字编辑,擅长将口语化的对话内容整理成优雅的书面语回忆录章节。 @@ -156,9 +149,7 @@ def get_chapter_classification_prompt(segments_text: str) -> str: def get_text_rewrite_prompt(segments_text: str, chapter_category: str, existing_content: str = "") -> str: """获取文本改写的提示词""" chapter_name = CHAPTER_CATEGORIES.get(chapter_category, chapter_category) - existing_section = f"\n\n已有章节内容:\n{existing_content}" if existing_content else "" - return f"""{get_system_prompt()} 请将以下口语化的对话内容改写为书面语,归类到"{chapter_name}"章节。 @@ -193,8 +184,6 @@ def get_text_rewrite_prompt(segments_text: str, chapter_category: str, existing_ def get_state_extraction_prompt(user_message: str, current_stage: str, stage_slots: dict) -> str: """抽取结构化信息并判断阶段""" slot_keys = list(stage_slots.keys()) - - # 提供所有阶段的 slot 参考,帮助 LLM 将内容归类到正确的阶段 all_stage_slots = { "childhood": ["place", "people", "daily_life", "emotion", "turning_event"], "education": ["school", "city", "motivation", "challenge", "change"], @@ -227,10 +216,10 @@ def get_state_extraction_prompt(user_message: str, current_stage: str, stage_slo }} 要求: -1. **应的 slot 列表 -4. slots 只填写确实提到的、与人生经历相关的实先忽略话语中的语气词、填充词、寒暄、与AI的交互指令等无关内容**,只关注涉及人生经历的实质信息 +1. **先忽略话语中的语气词、填充词、寒暄、与AI的交互指令等无关内容**,只关注涉及人生经历的实质信息 2. **detected_stage 必须根据用户话语的实际内容判断**,不要默认沿用系统当前阶段。用户可能在聊不同阶段的事情 -3. slots 的 key 必须属于 detected_stage 对质内容 +3. slots 的 key 必须属于 detected_stage 对应的 slot 列表 +4. slots 只填写确实提到的、与人生经历相关的实质内容 5. **snippet 应是提炼后的核心信息**,去除语气词和冗余表达,50 字以内 6. 如果用户话语中没有任何与人生经历相关的实质内容(如纯粹的寒暄、指令、语气词),slots 为空对象 """ @@ -275,7 +264,7 @@ def get_creative_title_prompt( return f"""{get_system_prompt()} 请根据阶段和情绪生成 1 个有创意的章节标题。 -阶段:{stage} +阶段:{stage} 情绪:{emotion} 可用信息:{slots}{profile_section}{time_section} @@ -307,9 +296,7 @@ def get_narrative_prompt( context_tail = "" if existing_content: context_tail = existing_content[-300:] if len(existing_content) > 300 else existing_content - context_section = f"\n\n【衔接上下文(已有内容的末尾,仅供参考衔接,不要重复)】:\n{context_tail}" if context_tail else "" - profile_section = f"\n\n用户基本信息:\n{user_profile}" if user_profile else "" age_hint = _build_age_hint(stage, birth_year) time_section = f"\n时间参考:{age_hint}" if age_hint else "" diff --git a/api/app/agents/prompts/__init__.py b/api/app/agents/prompts/__init__.py deleted file mode 100644 index c5a0d95..0000000 --- a/api/app/agents/prompts/__init__.py +++ /dev/null @@ -1,57 +0,0 @@ -""" -提示词模块 -""" -from .conversation_prompts import ( - ConversationStage, - get_system_prompt as get_conversation_prompt, - get_questions_for_stage, - get_guided_conversation_prompt, - get_opening_prompt, - INTERVIEW_QUESTIONS, -) -from .memory_prompts import ( - get_system_prompt as get_memory_prompt, - get_chapter_classification_prompt, - get_text_rewrite_prompt, - get_state_extraction_prompt, - get_creative_title_prompt, - get_narrative_prompt, - inject_image_placeholder_template, - CHAPTER_CATEGORIES, - CHAPTER_ORDER, - STAGE_TO_ORDER, -) -from .profile_prompts import ( - get_profile_greeting_prompt, - get_profile_extraction_prompt, - get_profile_followup_prompt, - format_user_profile_context, - get_missing_profile_fields, - PROFILE_FIELD_NAMES, -) - -__all__ = [ - "ConversationStage", - "get_conversation_prompt", - "get_questions_for_stage", - "get_guided_conversation_prompt", - "get_opening_prompt", - "INTERVIEW_QUESTIONS", - "get_memory_prompt", - "get_chapter_classification_prompt", - "get_text_rewrite_prompt", - "get_state_extraction_prompt", - "get_creative_title_prompt", - "get_narrative_prompt", - "inject_image_placeholder_template", - "CHAPTER_CATEGORIES", - "CHAPTER_ORDER", - "STAGE_TO_ORDER", - "get_profile_greeting_prompt", - "get_profile_extraction_prompt", - "get_profile_followup_prompt", - "format_user_profile_context", - "get_missing_profile_fields", - "PROFILE_FIELD_NAMES", -] - diff --git a/api/app/features/conversation/ws/profile_collector.py b/api/app/features/conversation/ws/profile_collector.py index 9d44bd6..6b178e9 100644 --- a/api/app/features/conversation/ws/profile_collector.py +++ b/api/app/features/conversation/ws/profile_collector.py @@ -6,7 +6,7 @@ from app.features.user.models import User def get_missing_profile_fields(user: User) -> list: """检查用户缺失的资料字段""" - from app.agents.prompts.profile_prompts import get_missing_profile_fields as _get_missing + from app.agents.chat.prompts_profile import get_missing_profile_fields as _get_missing return _get_missing( birth_year=user.birth_year, birth_place=user.birth_place, diff --git a/api/app/features/conversation/ws/router.py b/api/app/features/conversation/ws/router.py index 9314e72..3621184 100644 --- a/api/app/features/conversation/ws/router.py +++ b/api/app/features/conversation/ws/router.py @@ -10,7 +10,7 @@ from datetime import datetime, timezone from fastapi import WebSocket, WebSocketDisconnect, status from starlette.websockets import WebSocketState -from app.agents.prompts.profile_prompts import format_user_profile_context +from app.agents.chat.prompts_profile import format_user_profile_context from app.core.db import AsyncSessionLocal from app.core.security import verify_token from app.features.conversation.models import Conversation, Segment diff --git a/api/app/features/memoir/service.py b/api/app/features/memoir/service.py index 1f82e15..2e4ff6c 100644 --- a/api/app/features/memoir/service.py +++ b/api/app/features/memoir/service.py @@ -8,7 +8,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import joinedload -from app.agents.prompts.memory_prompts import ( +from app.agents.memoir.prompts import ( CHAPTER_CATEGORIES, CHAPTER_ORDER, STAGE_TO_ORDER, diff --git a/api/app/tasks/memoir_tasks.py b/api/app/tasks/memoir_tasks.py index 550e2b1..6f02936 100644 --- a/api/app/tasks/memoir_tasks.py +++ b/api/app/tasks/memoir_tasks.py @@ -26,7 +26,7 @@ from app.features.memoir.models import ( from app.features.user.models import User from app.core.dependencies import get_llm_provider from app.agents.state_schema import MemoirStateSchema, SlotData, default_state -from app.agents.prompts.memory_prompts import ( +from app.agents.memoir.prompts import ( STAGE_TO_ORDER, get_narrative_prompt, inject_image_placeholder_template, @@ -34,7 +34,7 @@ from app.agents.prompts.memory_prompts import ( from app.agents.memoir import MemoirOrchestrator from app.agents.memoir.narrative_agent import NarrativeAgent from app.agents.memoir.placeholder_agent import inject_placeholders -from app.agents.prompts.profile_prompts import format_user_profile_context +from app.agents.chat.prompts_profile import format_user_profile_context from app.features.memoir.memoir_images.parser import ( build_initial_image_assets, parse_image_placeholders, diff --git a/api/routers/chapters.py b/api/routers/chapters.py index a357f06..c8663d6 100644 --- a/api/routers/chapters.py +++ b/api/routers/chapters.py @@ -14,7 +14,7 @@ from database import get_async_db from database.models import Chapter as ChapterModel, ChapterSection from database.models import User as UserModel from middleware.auth import get_current_user -from app.agents.prompts.memory_prompts import CHAPTER_CATEGORIES, CHAPTER_ORDER, STAGE_TO_ORDER +from app.agents.memoir.prompts import CHAPTER_CATEGORIES, CHAPTER_ORDER, STAGE_TO_ORDER from services.memoir_images.schema import ( completed_image_assets, IMAGE_STATUS_COMPLETED, diff --git a/api/routers/websocket.py b/api/routers/websocket.py index 4559842..7b49a75 100644 --- a/api/routers/websocket.py +++ b/api/routers/websocket.py @@ -23,7 +23,7 @@ from database.models import User as UserModel from services.auth_service import verify_token from services.memoir_state_service import get_or_create_state from services import asr_service, redis_service -from app.agents.prompts.profile_prompts import format_user_profile_context +from app.agents.chat.prompts_profile import format_user_profile_context logger = logging.getLogger(__name__) LEGACY_VOICE_SESSION_ID = "legacy" @@ -924,7 +924,7 @@ async def websocket_endpoint( def _get_missing_profile_fields(user: UserModel) -> list: """检查用户缺失的资料字段""" - from app.agents.prompts.profile_prompts import get_missing_profile_fields + from app.agents.chat.prompts_profile import get_missing_profile_fields return get_missing_profile_fields( birth_year=user.birth_year, birth_place=user.birth_place, @@ -935,7 +935,7 @@ def _get_missing_profile_fields(user: UserModel) -> list: def _get_filled_profile_fields(user: UserModel) -> dict: """获取用户已有的资料字段(中文展示)""" - from app.agents.prompts.profile_prompts import PROFILE_FIELD_NAMES + from app.agents.chat.prompts_profile import PROFILE_FIELD_NAMES filled = {} if user.birth_year: filled["birth_year"] = str(user.birth_year) @@ -1045,7 +1045,7 @@ async def process_user_message( # 构建用户资料上下文 user_profile_context = "" if user: - from app.agents.prompts.profile_prompts import format_user_profile_context + from app.agents.chat.prompts_profile import format_user_profile_context user_profile_context = format_user_profile_context( birth_year=user.birth_year, birth_place=user.birth_place, diff --git a/api/scripts/reprocess_user_memoir.py b/api/scripts/reprocess_user_memoir.py index 71d1572..2b1ce90 100644 --- a/api/scripts/reprocess_user_memoir.py +++ b/api/scripts/reprocess_user_memoir.py @@ -50,7 +50,7 @@ from app.features.memoir.models import Book, Chapter, ChapterSection, MemoirStat from app.features.user.models import User from app.core.dependencies import get_llm_provider from app.agents.state_schema import MemoirStateSchema, SlotData, default_state -from app.agents.prompts.memory_prompts import ( +from app.agents.memoir.prompts import ( get_creative_title_prompt, get_narrative_prompt, get_state_extraction_prompt, diff --git a/api/tests/test_memory_prompts_inject.py b/api/tests/test_memory_prompts_inject.py index 76165c0..1d2e4f9 100644 --- a/api/tests/test_memory_prompts_inject.py +++ b/api/tests/test_memory_prompts_inject.py @@ -1,7 +1,7 @@ """测试 memory_prompts.inject_image_placeholder_template:占位符花括号统一为四层,避免多余花括号残留""" import unittest -from app.agents.prompts.memory_prompts import ( +from app.agents.memoir.prompts import ( IMAGE_PLACEHOLDER_TEMPLATE, inject_image_placeholder_template, ) From f3629efec3cc6946165300fb3943a5962d871ded Mon Sep 17 00:00:00 2001 From: yangshilin <2157598560@qq.com> Date: Thu, 19 Mar 2026 11:18:58 +0800 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20=E5=8E=BB=E9=99=A4LLM=E7=9B=B4?= =?UTF-8?q?=E6=8E=A5=E7=94=9F=E6=88=90=E5=9B=BE=E7=89=87=E5=8D=A0=E4=BD=8D?= =?UTF-8?q?=E7=AC=A6=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/agents/memoir/narrative_agent.py | 4 +- api/app/agents/memoir/processor.py | 4 +- api/app/agents/memoir/prompts.py | 50 ++++++++++++++++++ .../features/memoir/memoir_images/parser.py | 51 +++++++++++++++++++ api/app/tasks/memoir_tasks.py | 46 +++++++++++------ api/tests/test_memoir_image_parser.py | 24 +++++++++ 6 files changed, 160 insertions(+), 19 deletions(-) diff --git a/api/app/agents/memoir/narrative_agent.py b/api/app/agents/memoir/narrative_agent.py index 0c90c8c..0f68cf0 100644 --- a/api/app/agents/memoir/narrative_agent.py +++ b/api/app/agents/memoir/narrative_agent.py @@ -10,7 +10,7 @@ from app.core.logging import get_logger from app.agents.memoir.prompts import ( get_creative_title_prompt, - get_narrative_prompt, + get_narrative_json_prompt, ) logger = get_logger(__name__) @@ -61,7 +61,7 @@ class NarrativeAgent: return f"{existing_content}\n\n{new_content}" return new_content try: - prompt = get_narrative_prompt( + prompt = get_narrative_json_prompt( stage=stage, slots=slots, new_content=new_content, diff --git a/api/app/agents/memoir/processor.py b/api/app/agents/memoir/processor.py index e9f0e84..f81e751 100644 --- a/api/app/agents/memoir/processor.py +++ b/api/app/agents/memoir/processor.py @@ -15,7 +15,7 @@ from app.core.task_tracker import task_tracker from app.agents.state_schema import MemoirStateSchema from app.agents.memoir.prompts import ( get_creative_title_prompt, - get_narrative_prompt, + get_narrative_json_prompt, get_state_extraction_prompt, ) @@ -141,7 +141,7 @@ class MemoirGenerator: return f"{existing_content}\n\n{new_content}" return new_content try: - prompt = get_narrative_prompt( + prompt = get_narrative_json_prompt( stage=stage, slots=slots, new_content=new_content, diff --git a/api/app/agents/memoir/prompts.py b/api/app/agents/memoir/prompts.py index a451e2e..6eff10e 100644 --- a/api/app/agents/memoir/prompts.py +++ b/api/app/agents/memoir/prompts.py @@ -349,3 +349,53 @@ def get_narrative_prompt( 只输出新对话内容的改写结果(包含图片占位符)。如果对话中没有值得记录的人生经历内容,输出空字符串。 """ + + +def get_narrative_json_prompt( + stage: str, + slots: dict, + new_content: str, + existing_content: str = "", + user_profile: str = "", + birth_year: Optional[int] = None, +) -> str: + """将新对话改写为叙述,输出 JSON 格式(paragraphs: [{content, image_description}])""" + context_tail = "" + if existing_content: + context_tail = existing_content[-300:] if len(existing_content) > 300 else existing_content + context_section = f"\n\n【衔接上下文(已有内容的末尾,仅供参考衔接,不要重复)】:\n{context_tail}" if context_tail else "" + profile_section = f"\n\n用户基本信息:\n{user_profile}" if user_profile else "" + age_hint = _build_age_hint(stage, birth_year) + time_section = f"\n时间参考:{age_hint}" if age_hint else "" + + return f"""{get_system_prompt()} + +请将以下新的对话内容改写为第一人称文学叙述,并输出 **纯 JSON**,不要包含任何其他文字或 markdown 代码块。 + +阶段:{stage} +可用信息:{slots}{profile_section}{time_section} + +新的对话内容: +{new_content} +{context_section} + +## 要求 +1. 从对话中提炼与人生经历相关的核心内容,过滤语气词、寒暄、与AI的交互 +2. 使用第一人称,改写为流畅的书面叙述,不要直接引用对话原话 +3. 只输出新内容的改写,不要重复已有内容 +4. 每 200-300 字左右一个段落,每个段落配一张图 +5. 如有衔接上下文,确保新内容与之自然衔接 + +## 输出格式(严格 JSON) +{{ + "paragraphs": [ + {{"content": "段落正文", "image_description": "该段配图的场景描述,具体有画面感"}}, + ... + ] +}} + +- content: 本段纯正文,不含占位符 +- image_description: 该段配图的场景描述,具体、有画面感,便于生成图片。示例:南方小镇的青石板路,两旁是白墙黑瓦的老房子 + +如果对话中没有值得记录的人生经历内容,输出:{{"paragraphs": []}} +""" diff --git a/api/app/features/memoir/memoir_images/parser.py b/api/app/features/memoir/memoir_images/parser.py index e6db4e5..1fa316a 100644 --- a/api/app/features/memoir/memoir_images/parser.py +++ b/api/app/features/memoir/memoir_images/parser.py @@ -1,6 +1,8 @@ +import json import re from typing import Any +from .json_payload import extract_json_payload from .schema import IMAGE_STATUS_PENDING PLACEHOLDER_RE = re.compile( @@ -82,3 +84,52 @@ def split_narrative_to_sections(narrative: str) -> list[dict[str, Any]]: content = content.strip() sections.append({"content": content or "", "placeholder_info": placeholder_info}) return sections + + +def parse_narrative_json(raw: str) -> list[dict[str, Any]]: + """ + 解析 LLM 输出的 JSON 格式叙事。 + 返回与 split_narrative_to_sections 相同结构:list[dict],每项含 content、placeholder_info。 + """ + if not (raw or raw.strip()): + return [] + try: + payload = extract_json_payload(raw) + data = json.loads(payload) + paragraphs = data.get("paragraphs") or [] + if not isinstance(paragraphs, list): + return [] + except (json.JSONDecodeError, TypeError, AttributeError): + return [] + + result: list[dict[str, Any]] = [] + for i, p in enumerate(paragraphs): + if not isinstance(p, dict): + continue + content = (p.get("content") or "").strip() + desc = (p.get("image_description") or "").strip() + placeholder_info = None + if desc: + placeholder_info = { + "placeholder": f"{{{{IMAGE:{desc}}}}}", + "description": desc, + "index": i, + "start_offset": 0, + } + result.append({"content": content, "placeholder_info": placeholder_info}) + return result + + +def parse_narrative_to_sections(narrative: str) -> list[dict[str, Any]]: + """ + 将 narrative 解析为 sections。优先尝试 JSON 格式,失败则回退到占位符解析。 + 返回与 split_narrative_to_sections 相同结构。 + """ + if not (narrative or narrative.strip()): + return [] + stripped = narrative.strip() + if stripped.startswith("{") and "paragraphs" in stripped: + segments = parse_narrative_json(narrative) + if segments: + return segments + return split_narrative_to_sections(narrative) diff --git a/api/app/tasks/memoir_tasks.py b/api/app/tasks/memoir_tasks.py index 6f02936..ef3b0bb 100644 --- a/api/app/tasks/memoir_tasks.py +++ b/api/app/tasks/memoir_tasks.py @@ -28,7 +28,7 @@ from app.core.dependencies import get_llm_provider from app.agents.state_schema import MemoirStateSchema, SlotData, default_state from app.agents.memoir.prompts import ( STAGE_TO_ORDER, - get_narrative_prompt, + get_narrative_json_prompt, inject_image_placeholder_template, ) from app.agents.memoir import MemoirOrchestrator @@ -38,6 +38,7 @@ from app.agents.chat.prompts_profile import format_user_profile_context from app.features.memoir.memoir_images.parser import ( build_initial_image_assets, parse_image_placeholders, + parse_narrative_to_sections, split_narrative_to_sections, ) import hashlib @@ -67,6 +68,14 @@ logger = get_logger(__name__) _REDIS_CLIENTS: dict[bool, redis.Redis] = {} +def _is_json_narrative(text: str) -> bool: + """检测 narrative 是否为 JSON 格式(paragraphs 结构)""" + if not text or not text.strip(): + return False + s = text.strip() + return s.startswith("{") and "paragraphs" in s + + def _get_llm(): """Celery 任务内获取 LangChain LLM(通过 port)""" try: @@ -328,7 +337,7 @@ def _save_narrative_to_sections(db: Session, chapter, narrative: str, title: str img_settings = MemoirImageSettings.from_env() prompt_service = MemoirImagePromptService(llm=None, settings=img_settings) if img_settings.enabled else None - segments = split_narrative_to_sections(narrative_to_parse) + segments = parse_narrative_to_sections(narrative_to_parse) if not segments: sec = ChapterSection( id=str(uuid.uuid4()), @@ -368,8 +377,11 @@ def _save_narrative_to_sections(db: Session, chapter, narrative: str, title: str chapter.source_segments = list(set((chapter.source_segments or []) + (source_segments or []))) return chapter - # 每 3 个 section 对应 1 张图片,其他 section 的 image_id 为空 - def _should_have_image(order_idx: int) -> bool: + def _should_have_image(seg: dict, order_idx: int) -> bool: + """有 placeholder_info 的段落配图;无则兼容旧格式(每 3 段 1 图)""" + ph = seg.get("placeholder_info") + if ph and ph.get("description"): + return True return (order_idx % 3) == 2 def _placeholder_for_segment(seg: dict, order_idx: int) -> dict | None: @@ -385,7 +397,7 @@ def _save_narrative_to_sections(db: Session, chapter, narrative: str, title: str order_idx = order_base + i content = (seg.get("content") or "").strip() image_asset = None - if img_settings.enabled and _should_have_image(order_idx): + if img_settings.enabled and _should_have_image(seg, order_idx): ph = _placeholder_for_segment(seg, order_idx) style = prompt_service.CATEGORY_STYLE_MAP.get(category, img_settings.default_style) if prompt_service else img_settings.default_style image_asset = build_initial_image_assets( @@ -640,12 +652,14 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]): birth_year=birth_year, llm=llm, ) - if existing_content: + if _is_json_narrative(new_narrative): + narrative = new_narrative + elif existing_content: narrative = f"{existing_content}\n\n{new_narrative}" else: narrative = new_narrative - if existing_content and len(narrative) < len(existing_content) * 0.8: + if existing_content and not _is_json_narrative(narrative) and len(narrative) < len(existing_content) * 0.8: logger.warning( "内容长度异常: existing=%d, new=%d, category=%s. 回退为追加模式", len(existing_content), @@ -654,7 +668,8 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]): ) narrative = f"{existing_content}\n\n{combined_text}" - narrative = inject_placeholders(narrative) + if not _is_json_narrative(narrative): + narrative = inject_placeholders(narrative) calculated_order_index = STAGE_TO_ORDER.get(chapter_category, 999) chapter = _save_narrative_to_sections( @@ -777,7 +792,7 @@ def generate_chapter_content(self, user_id: str, stage: str, new_content: str): ) if llm: - prompt = get_narrative_prompt( + prompt = get_narrative_json_prompt( stage=stage, slots={}, new_content=new_content, @@ -785,24 +800,25 @@ def generate_chapter_content(self, user_id: str, stage: str, new_content: str): ) response = llm.invoke(prompt) new_narrative = response.content.strip() - # 追加而非替换 - if existing_content: + if _is_json_narrative(new_narrative): + narrative = new_narrative + elif existing_content: narrative = f"{existing_content}\n\n{new_narrative}" else: narrative = new_narrative else: narrative = f"{existing_content}\n\n{new_content}" if existing_content else new_content - # 安全检查:新内容不应比旧内容短 - if existing_content and len(narrative) < len(existing_content) * 0.8: + # 安全检查:新内容不应比旧内容短(仅非 JSON 格式) + if existing_content and not _is_json_narrative(narrative) and len(narrative) < len(existing_content) * 0.8: logger.warning( f"内容长度异常: existing={len(existing_content)}, " f"new={len(narrative)}, stage={stage}. 回退为追加模式" ) narrative = f"{existing_content}\n\n{new_content}" - # 入库前:占位符位置用正则匹配后拼上固定模板 - narrative = inject_image_placeholder_template(narrative) + if not _is_json_narrative(narrative): + narrative = inject_image_placeholder_template(narrative) calculated_order_index = STAGE_TO_ORDER.get(stage, 999) title = chapter.title if chapter else f"{stage} 回忆" chapter = _save_narrative_to_sections( diff --git a/api/tests/test_memoir_image_parser.py b/api/tests/test_memoir_image_parser.py index b42e3df..38ae532 100644 --- a/api/tests/test_memoir_image_parser.py +++ b/api/tests/test_memoir_image_parser.py @@ -3,6 +3,8 @@ import unittest from app.features.memoir.memoir_images.parser import ( build_initial_image_assets, parse_image_placeholders, + parse_narrative_json, + parse_narrative_to_sections, ) @@ -52,3 +54,25 @@ class MemoirImageParserTest(unittest.TestCase): self.assertEqual(len(items), 1) self.assertEqual(items[0]["placeholder"], "{{IMAGE:1938年初的上海弄堂口,冬日萧瑟}}") self.assertEqual(items[0]["description"], "1938年初的上海弄堂口,冬日萧瑟") + + def test_parse_narrative_json_returns_sections_with_content_and_placeholder_info(self): + raw = '{"paragraphs": [{"content": "那年春天。", "image_description": "南方小镇的青石板路"}, {"content": "奶奶坐在藤椅上。", "image_description": "奶奶的藤椅"}]}' + segments = parse_narrative_json(raw) + self.assertEqual(len(segments), 2) + self.assertEqual(segments[0]["content"], "那年春天。") + self.assertEqual(segments[0]["placeholder_info"]["description"], "南方小镇的青石板路") + self.assertEqual(segments[1]["content"], "奶奶坐在藤椅上。") + self.assertEqual(segments[1]["placeholder_info"]["description"], "奶奶的藤椅") + + def test_parse_narrative_to_sections_prefers_json_then_fallback_to_placeholder(self): + json_raw = '{"paragraphs": [{"content": "段落一", "image_description": "图一"}]}' + segments = parse_narrative_to_sections(json_raw) + self.assertEqual(len(segments), 1) + self.assertEqual(segments[0]["content"], "段落一") + self.assertEqual(segments[0]["placeholder_info"]["description"], "图一") + + placeholder_raw = "正文。\n\n{{{{IMAGE:描述}}}}\n\n结尾。" + segments2 = parse_narrative_to_sections(placeholder_raw) + self.assertEqual(len(segments2), 2) + self.assertIn("正文", segments2[0]["content"]) + self.assertEqual(segments2[0]["placeholder_info"]["description"], "描述") From 4c75c6f4f442e9bd0f9894488a70ffc6d0c75410 Mon Sep 17 00:00:00 2001 From: yangshilin <2157598560@qq.com> Date: Thu, 19 Mar 2026 11:27:43 +0800 Subject: [PATCH 7/8] =?UTF-8?q?feat:=20=E5=BC=95=E5=85=A5deepseek=E7=BB=93?= =?UTF-8?q?=E6=9E=84=E5=8C=96=E8=BE=93=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/agents/chat/profile_agent.py | 9 +++++++-- api/app/agents/memoir/extraction_agent.py | 6 +++++- api/app/agents/memoir/memory_agent.py | 16 +++++++--------- api/app/agents/memoir/narrative_agent.py | 6 +++++- api/app/agents/memoir/processor.py | 15 ++++++++++++--- .../features/memoir/memoir_images/prompting.py | 12 ++++++++++-- api/app/tasks/memoir_tasks.py | 6 +++++- api/scripts/reprocess_user_memoir.py | 9 +++++++-- api/tests/test_memoir_image_prompting.py | 12 +++++++++--- ...test_process_memoir_segments_image_enqueue.py | 9 +++++++-- 10 files changed, 74 insertions(+), 26 deletions(-) diff --git a/api/app/agents/chat/profile_agent.py b/api/app/agents/chat/profile_agent.py index 91337a9..ca85575 100644 --- a/api/app/agents/chat/profile_agent.py +++ b/api/app/agents/chat/profile_agent.py @@ -16,6 +16,7 @@ from app.agents.chat.prompts_profile import ( get_profile_followup_prompt, get_profile_greeting_prompt, ) +from app.features.memoir.memoir_images.json_payload import extract_json_payload logger = get_logger(__name__) @@ -58,9 +59,13 @@ class ProfileAgent: prompt = get_profile_extraction_prompt( user_message, missing_fields, recent_dialogue=recent_dialogue or None ) - response = await self.llm.ainvoke(prompt) + json_llm = self.llm.bind( + model_kwargs={"response_format": {"type": "json_object"}}, + max_tokens=512, + ) + response = await json_llm.ainvoke(prompt) content = response.content.strip() - parsed = json.loads(content) + parsed = json.loads(extract_json_payload(content)) result = {} if "birth_year" in parsed and parsed["birth_year"] is not None: raw = parsed["birth_year"] diff --git a/api/app/agents/memoir/extraction_agent.py b/api/app/agents/memoir/extraction_agent.py index f6e9136..027b9e8 100644 --- a/api/app/agents/memoir/extraction_agent.py +++ b/api/app/agents/memoir/extraction_agent.py @@ -52,7 +52,11 @@ class ExtractionAgent: for k, v in (stage_slots or {}).items() }, ) - response = llm.invoke(prompt) + json_llm = llm.bind( + model_kwargs={"response_format": {"type": "json_object"}}, + max_tokens=1024, + ) + response = json_llm.invoke(prompt) parsed = json.loads(extract_json_payload(response.content)) detected_stage = parsed.get("detected_stage", detected_stage) raw_slots = parsed.get("slots", {}) or {} diff --git a/api/app/agents/memoir/memory_agent.py b/api/app/agents/memoir/memory_agent.py index 242f3a7..fc82924 100644 --- a/api/app/agents/memoir/memory_agent.py +++ b/api/app/agents/memoir/memory_agent.py @@ -15,6 +15,7 @@ from app.agents.memoir.prompts import ( get_text_rewrite_prompt, inject_image_placeholder_template, ) +from app.features.memoir.memoir_images.json_payload import extract_json_payload logger = get_logger(__name__) @@ -64,17 +65,14 @@ class MemoryAgent: prompt = get_text_rewrite_prompt( segments_text, chapter_category, existing_content or "" ) - response = await self.llm.ainvoke(prompt) + json_llm = self.llm.bind( + model_kwargs={"response_format": {"type": "json_object"}}, + max_tokens=4096, + ) + response = await json_llm.ainvoke(prompt) content = response.content if hasattr(response, "content") else str(response) content = content.strip() - if content.startswith("```json"): - content = content[7:] - if content.startswith("```"): - content = content[3:] - if content.endswith("```"): - content = content[:-3] - content = content.strip() - result = json.loads(content) + result = json.loads(extract_json_payload(content)) result["content"] = inject_image_placeholder_template( result.get("content") or "" ) diff --git a/api/app/agents/memoir/narrative_agent.py b/api/app/agents/memoir/narrative_agent.py index 0f68cf0..fd82ff1 100644 --- a/api/app/agents/memoir/narrative_agent.py +++ b/api/app/agents/memoir/narrative_agent.py @@ -69,7 +69,11 @@ class NarrativeAgent: user_profile=user_profile, birth_year=birth_year, ) - response = llm.invoke(prompt) + json_llm = llm.bind( + model_kwargs={"response_format": {"type": "json_object"}}, + max_tokens=4096, + ) + response = json_llm.invoke(prompt) return (response.content or "").strip() except Exception as e: logger.warning("NarrativeAgent 生成叙事失败: %s", e) diff --git a/api/app/agents/memoir/processor.py b/api/app/agents/memoir/processor.py index f81e751..56b9155 100644 --- a/api/app/agents/memoir/processor.py +++ b/api/app/agents/memoir/processor.py @@ -13,6 +13,7 @@ from app.core.logging import get_logger from app.core.task_tracker import task_tracker from app.agents.state_schema import MemoirStateSchema +from app.features.memoir.memoir_images.json_payload import extract_json_payload from app.agents.memoir.prompts import ( get_creative_title_prompt, get_narrative_json_prompt, @@ -82,9 +83,13 @@ class ContentAnalyzer: current_stage=current_state.current_stage, stage_slots=current_state.slots.get(detected_stage, {}), ) - response = await self.llm.ainvoke(prompt) + json_llm = self.llm.bind( + model_kwargs={"response_format": {"type": "json_object"}}, + max_tokens=1024, + ) + response = await json_llm.ainvoke(prompt) content = response.content.strip() - parsed = json.loads(content) + parsed = json.loads(extract_json_payload(content)) detected_stage = parsed.get("detected_stage", detected_stage) extracted_slots = parsed.get("slots", {}) or {} emotion = parsed.get("emotion", emotion) @@ -147,7 +152,11 @@ class MemoirGenerator: new_content=new_content, existing_content=existing_content, ) - response = await self.llm.ainvoke(prompt) + json_llm = self.llm.bind( + model_kwargs={"response_format": {"type": "json_object"}}, + max_tokens=4096, + ) + response = await json_llm.ainvoke(prompt) return response.content.strip() except Exception as e: logger.error("生成叙事失败: %s", e) diff --git a/api/app/features/memoir/memoir_images/prompting.py b/api/app/features/memoir/memoir_images/prompting.py index ee2b539..892fc06 100644 --- a/api/app/features/memoir/memoir_images/prompting.py +++ b/api/app/features/memoir/memoir_images/prompting.py @@ -57,7 +57,11 @@ class MemoirImagePromptService: if self.llm: raw_response = None try: - response = self.llm.invoke( + json_llm = self.llm.bind( + model_kwargs={"response_format": {"type": "json_object"}}, + max_tokens=512, + ) + response = json_llm.invoke( "Return JSON only with keys prompt, style, size. " "Convert the memoir scene into an image-generation prompt.\n" + json.dumps(llm_input, ensure_ascii=False) @@ -113,7 +117,11 @@ class MemoirImagePromptService: if self.llm: try: - response = self.llm.invoke( + json_llm = self.llm.bind( + model_kwargs={"response_format": {"type": "json_object"}}, + max_tokens=512, + ) + response = json_llm.invoke( "Return JSON only with keys prompt, style, size. " "Create an image-generation prompt for a memoir chapter COVER. " "Emphasize: hero composition, evocative scene, chapter cover aesthetic.\n" diff --git a/api/app/tasks/memoir_tasks.py b/api/app/tasks/memoir_tasks.py index ef3b0bb..c70e895 100644 --- a/api/app/tasks/memoir_tasks.py +++ b/api/app/tasks/memoir_tasks.py @@ -798,7 +798,11 @@ def generate_chapter_content(self, user_id: str, stage: str, new_content: str): new_content=new_content, existing_content=existing_content, ) - response = llm.invoke(prompt) + json_llm = llm.bind( + model_kwargs={"response_format": {"type": "json_object"}}, + max_tokens=4096, + ) + response = json_llm.invoke(prompt) new_narrative = response.content.strip() if _is_json_narrative(new_narrative): narrative = new_narrative diff --git a/api/scripts/reprocess_user_memoir.py b/api/scripts/reprocess_user_memoir.py index 2b1ce90..6a459be 100644 --- a/api/scripts/reprocess_user_memoir.py +++ b/api/scripts/reprocess_user_memoir.py @@ -57,6 +57,7 @@ from app.agents.memoir.prompts import ( inject_image_placeholder_template, STAGE_TO_ORDER, ) +from app.features.memoir.memoir_images.json_payload import extract_json_payload from app.features.memoir.memoir_images.parser import split_narrative_to_sections from app.core.logging import get_logger, setup_logging @@ -200,8 +201,12 @@ def extract_slots_with_llm(llm, text: str, current_stage: str, stage_slots: dict current_stage=current_stage, stage_slots=stage_slots, ) - response = llm.invoke(prompt) - parsed = json.loads(response.content.strip()) + json_llm = llm.bind( + model_kwargs={"response_format": {"type": "json_object"}}, + max_tokens=1024, + ) + response = json_llm.invoke(prompt) + parsed = json.loads(extract_json_payload(response.content.strip())) return parsed.get("detected_stage", current_stage), parsed.get("slots", {}) or {} except Exception as e: logger.warning(f"LLM slot 提取失败: {e}") diff --git a/api/tests/test_memoir_image_prompting.py b/api/tests/test_memoir_image_prompting.py index 7c20e82..b2e7141 100644 --- a/api/tests/test_memoir_image_prompting.py +++ b/api/tests/test_memoir_image_prompting.py @@ -45,10 +45,12 @@ class MemoirImagePromptingTest(unittest.TestCase): liblib_template_uuid="tpl-uuid", ) llm = Mock() - llm.invoke.return_value.content = ( + bound_llm = Mock() + bound_llm.invoke.return_value.content = ( '{"prompt":"A grandmother in a quiet courtyard, summer cicadas, soft watercolor",' '"style":"watercolor","size":"1024x1024"}' ) + llm.bind.return_value = bound_llm service = MemoirImagePromptService(llm=llm, settings=settings) result = service.build_prompt( @@ -74,13 +76,15 @@ class MemoirImagePromptingTest(unittest.TestCase): liblib_template_uuid="tpl-uuid", ) llm = Mock() - llm.invoke.return_value.content = """```json + bound_llm = Mock() + bound_llm.invoke.return_value.content = """```json { "prompt": "A middle-aged teacher stands on the empty stage, realistic, cinematic lighting", "style": "realistic", "size": "1280x720" } ```""" + llm.bind.return_value = bound_llm service = MemoirImagePromptService(llm=llm, settings=settings) result = service.build_prompt( @@ -112,7 +116,9 @@ class MemoirImagePromptingTest(unittest.TestCase): liblib_template_uuid="tpl-uuid", ) llm = Mock() - llm.invoke.return_value.content = "not-json" + bound_llm = Mock() + bound_llm.invoke.return_value.content = "not-json" + llm.bind.return_value = bound_llm service = MemoirImagePromptService(llm=llm, settings=settings) result = service.build_prompt( diff --git a/api/tests/test_process_memoir_segments_image_enqueue.py b/api/tests/test_process_memoir_segments_image_enqueue.py index f07136a..8d101b0 100644 --- a/api/tests/test_process_memoir_segments_image_enqueue.py +++ b/api/tests/test_process_memoir_segments_image_enqueue.py @@ -58,7 +58,8 @@ class ProcessMemoirSegmentsImageEnqueueTest(unittest.TestCase): get_state_mock.return_value = SimpleNamespace(current_stage="childhood", slots={}) update_slot_mock.return_value = SimpleNamespace(current_stage="childhood", slots={}) llm = Mock() - llm.invoke.side_effect = [ + bound_llm = Mock() + bound_llm.invoke.side_effect = [ SimpleNamespace( content="""```json { @@ -69,8 +70,12 @@ class ProcessMemoirSegmentsImageEnqueueTest(unittest.TestCase): } ```""" ), + SimpleNamespace(content='{"paragraphs":[{"content":"新的章节正文","image_description":"南方小镇的青石板路"}]}'), + ] + llm.bind.return_value = bound_llm + llm.invoke.side_effect = [ + SimpleNamespace(content="childhood"), SimpleNamespace(content="童年的门前"), - SimpleNamespace(content="新的章节正文\n\n{{IMAGE:南方小镇的青石板路}}"), ] get_llm_mock.return_value = llm From 8dc47d200bb0958270873ea3fb9ac1b2146ee3ea Mon Sep 17 00:00:00 2001 From: yangshilin <2157598560@qq.com> Date: Thu, 19 Mar 2026 14:02:21 +0800 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=9B=9E=E5=BF=86?= =?UTF-8?q?=E5=BD=95celery=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/tasks/memoir_tasks.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/api/app/tasks/memoir_tasks.py b/api/app/tasks/memoir_tasks.py index c70e895..f2b2a73 100644 --- a/api/app/tasks/memoir_tasks.py +++ b/api/app/tasks/memoir_tasks.py @@ -387,10 +387,13 @@ def _save_narrative_to_sections(db: Session, chapter, narrative: str, title: str def _placeholder_for_segment(seg: dict, order_idx: int) -> dict | None: ph = seg.get("placeholder_info") if ph and ph.get("placeholder") and ph.get("description"): + # 确保有 index,build_initial_image_assets 依赖此字段 + if "index" not in ph: + ph = {**ph, "index": order_idx} return ph content = (seg.get("content") or "").strip() desc = (content[:50] + "…") if len(content) > 50 else (content or "章节配图") - return {"placeholder": f"{{{{{{{{IMAGE:{desc}}}}}}}}}", "description": desc} + return {"placeholder": f"{{{{{{{{IMAGE:{desc}}}}}}}}}", "description": desc, "index": order_idx} # 按顺序创建 section,每 3 个 section 对应 1 张配图 for i, seg in enumerate(segments):