Files
life-echo/api/app/agents/state_schema.py

272 lines
8.7 KiB
Python
Raw Permalink Normal View History

2026-01-21 22:31:03 +01:00
"""
共享状态 Schema对话 Agent 与后台 Agent 共用
Option B schema 分层
- `NarrativeCoverageState`叙述覆盖视图 阶段推进叙述槽每阶段的完成情况**不掺控制元信息**
- `InterviewControlState`访谈控制视图 已确认事实人物主线最近已问用于访谈控场**不参与成稿槽位**
- `MemoirStateSchema`持久化 DTO只承载字段消费方显式投影到两个视图
2026-01-21 22:31:03 +01:00
"""
2026-03-19 14:36:14 +08:00
2026-01-21 22:31:03 +01:00
from __future__ import annotations
from typing import Dict, List, Optional
from pydantic import BaseModel, Field
refactor(agents): 抽取阶段常量与对话上下文;快档 LLM;图片 prompt 可禁止回退 访谈与阶段 - 新增 app/agents/stage_constants.py:集中 CHAT_STAGES、章节分类/顺序、阶段到默认 memoir 类别等,与 MemoirState 默认槽位顺序对齐;减少散落在 prompts 内的重复常量。 - 新增 app/agents/chat/prompt_context.py:以 ChatPromptContext 汇总 guided 系统提示所需字段(阶段、槽位、轮次、人设、记忆证据、回复长度模式、背景声线、职业等),统一走 get_guided_conversation_prompt。 - 大幅收敛 app/agents/chat/prompts_conversation.py;调整 prompts.py、stage_prompts.py、stage_detection.py;同步 interview_agent、profile_agent、helpers 与 state_schema,使对话侧构造提示的方式一致、可测。 回忆录流水线 - memoir/prompts.py 删除已迁至 stage_constants / 独立模板的大段常量与图片占位相关逻辑;classification / extraction / fidelity / narrative agents 与 orchest(全量历史仍可用于计数,注入模型时按轮次与字符上限截断)、image_prompt_fallback_disabled。 - dependencies 增加 get_llm_provider_fast(LRU 缓存,可与默认共用密钥与 base_url)。 任务与编排 - memoir_tasks:prepare_batches 注入 llm_fast;开启独立快档模型时打结构化日志。 - chapter_cover_tasks、story_image_tasks:与图片 prompt / JSON 工具路径或策略变更对齐(import 与行为一致)。 - story_pipeline_sync 等小处同步。 其它核心 - langchain_llm、text_normalize 随上述调用链微调。 开发者体验 - .cursor/settings.json:启用 redis-development、postman 插件。 测试 - 新增 test_image_prompt_policy:覆盖「禁止回退」等图片 prompt 策略。 - 更新 test_interview_prompts、test_interview_reply_length、test_experience_regressions、test_json_and_memory_utils,匹配新常量位置、json_utils 与对话/长度行为。
2026-04-02 12:00:00 +08:00
from app.agents.stage_constants import CHAT_STAGES
2026-01-21 22:31:03 +01:00
class SlotData(BaseModel):
"""Slot 数据结构"""
2026-03-19 14:36:14 +08:00
2026-01-21 22:31:03 +01:00
snippet: Optional[str] = None
segment_ids: List[str] = Field(default_factory=list)
class KnownFact(BaseModel):
"""会话级已知事实:供 prompt 明确声明“不要再问这些”。"""
label: str
value: str
source: str = ""
stage: str = ""
slot_name: str | None = None
def prompt_line(self) -> str:
prefix = f"{self.label}".strip("")
if prefix:
return f"{prefix} {self.value}".strip()
return self.value.strip()
class PersonaThread(BaseModel):
"""跨轮人物主线:用于持续呼应用户的稳定特质与动机。"""
trait: str
evidence: str = ""
source: str = ""
stage: str = ""
def prompt_line(self) -> str:
if self.evidence:
return f"{self.trait}(依据:{self.evidence}"
return self.trait
# =============================================================================
# Narrative 视图:叙述覆盖、阶段推进、叙述槽
# =============================================================================
class NarrativeCoverageState(BaseModel):
"""叙述覆盖视图。
只承载人生叙事覆盖相关信息阶段顺序当前阶段覆盖过的阶段每阶段的叙述槽
**禁止**在此视图承载访谈控场数据已确认事实人物主线最近已问那些数据属于
`InterviewControlState`
"""
2026-03-19 14:36:14 +08:00
2026-01-21 22:31:03 +01:00
stage_order: List[str]
current_stage: str
covered_stages: List[str]
slots: Dict[str, Dict[str, SlotData]]
def empty_slots_for_current_stage(self) -> List[str]:
stage_slots = self.slots.get(self.current_stage, {})
return [key for key, value in stage_slots.items() if not value.snippet]
def empty_slots_for_stage(self, stage: str) -> List[str]:
stage_slots = self.slots.get(stage, {})
return [key for key, value in stage_slots.items() if not value.snippet]
def filled_slots_for_stage(self, stage: str) -> Dict[str, str]:
stage_slots = self.slots.get(stage, {})
return {
2026-03-19 14:36:14 +08:00
key: value.snippet for key, value in stage_slots.items() if value.snippet
}
def all_stages_coverage(self) -> Dict[str, Dict]:
coverage: Dict[str, Dict] = {}
for stage in self.stage_order:
stage_slots = self.slots.get(stage, {})
total = len(stage_slots)
filled = sum(1 for v in stage_slots.values() if v.snippet)
coverage[stage] = {
"total": total,
"filled": filled,
"empty": total - filled,
"ratio": filled / total if total > 0 else 0,
}
return coverage
# =============================================================================
# Interview Control 视图:访谈控场(已知事实 / 人物主线 / 最近已问)
# =============================================================================
class InterviewControlState(BaseModel):
"""访谈控制视图。
承载仅与控场 / 去重问 / 人物呼应相关的信息这些字段**不应**出现在叙述覆盖计算里
**不应**写入 `slots`
"""
known_facts: List[KnownFact] = Field(default_factory=list)
persona_threads: List[PersonaThread] = Field(default_factory=list)
recent_questions: List[str] = Field(default_factory=list)
def prompt_known_fact_lines(self, *, limit: int = 10) -> List[str]:
xs: List[str] = []
for fact in self.known_facts[-limit:]:
line = fact.prompt_line().strip()
if line:
xs.append(line)
return xs
def prompt_persona_thread_lines(self, *, limit: int = 6) -> List[str]:
xs: List[str] = []
for item in self.persona_threads[-limit:]:
line = item.prompt_line().strip()
if line:
xs.append(line)
return xs
def prompt_recent_question_lines(self, *, limit: int = 4) -> List[str]:
out: List[str] = []
seen: set[str] = set()
for item in self.recent_questions[-limit:]:
s = str(item).strip()
if not s or s in seen:
continue
seen.add(s)
out.append(s)
return out
def blocked_slot_names_for_stage(self, stage: str) -> set[str]:
"""已被 known_facts 覆盖的槽位名:追问时应避开。"""
return {
fact.slot_name
for fact in self.known_facts
if fact.slot_name and (not fact.stage or fact.stage == stage)
}
def prompt_empty_slots_for_stage(
self, narrative: NarrativeCoverageState, stage: str
) -> List[str]:
"""生成 prompt 时可追问的槽位,排除已被 known_facts 覆盖的方向。"""
blocked = self.blocked_slot_names_for_stage(stage)
return [
key
for key in narrative.empty_slots_for_stage(stage)
if key not in blocked
]
# =============================================================================
# 持久化 DTO不承载视图委托逻辑
# =============================================================================
class MemoirStateSchema(BaseModel):
"""回忆录状态持久化 DTO。视图逻辑由显式 state projector 提供。"""
stage_order: List[str]
current_stage: str
covered_stages: List[str]
slots: Dict[str, Dict[str, SlotData]]
known_facts: List[KnownFact] = Field(default_factory=list)
persona_threads: List[PersonaThread] = Field(default_factory=list)
recent_questions: List[str] = Field(default_factory=list)
DEFAULT_STAGE_ORDER: list[str] = list(CHAT_STAGES)
def narrative_coverage_state(state: MemoirStateSchema) -> NarrativeCoverageState:
return NarrativeCoverageState(
stage_order=state.stage_order,
current_stage=state.current_stage,
covered_stages=state.covered_stages,
slots=state.slots,
)
def interview_control_state(state: MemoirStateSchema) -> InterviewControlState:
return InterviewControlState(
known_facts=state.known_facts,
persona_threads=state.persona_threads,
recent_questions=state.recent_questions,
)
2026-01-21 22:31:03 +01:00
def prompt_empty_slots_for_current_stage(state: MemoirStateSchema) -> List[str]:
narrative = narrative_coverage_state(state)
control = interview_control_state(state)
return control.prompt_empty_slots_for_stage(narrative, state.current_stage)
2026-01-21 22:31:03 +01:00
def default_slots() -> Dict[str, Dict[str, SlotData]]:
return {
"childhood": {
"place": SlotData(),
"people": SlotData(),
"daily_life": SlotData(),
"emotion": SlotData(),
"turning_event": SlotData(),
},
"education": {
"school": SlotData(),
"city": SlotData(),
"motivation": SlotData(),
"challenge": SlotData(),
"change": SlotData(),
},
"career": {
"job": SlotData(),
"environment": SlotData(),
"decision": SlotData(),
"pressure": SlotData(),
"growth": SlotData(),
},
"family": {
"relationship": SlotData(),
"conflict": SlotData(),
"support": SlotData(),
"responsibility": SlotData(),
"change": SlotData(),
},
"belief": {
"value": SlotData(),
"regret": SlotData(),
"pride": SlotData(),
"lesson": SlotData(),
},
}
def default_state() -> MemoirStateSchema:
return MemoirStateSchema(
stage_order=DEFAULT_STAGE_ORDER,
current_stage=DEFAULT_STAGE_ORDER[0],
covered_stages=[],
slots=default_slots(),
)
__all__ = [
"DEFAULT_STAGE_ORDER",
"InterviewControlState",
"KnownFact",
"MemoirStateSchema",
"NarrativeCoverageState",
"PersonaThread",
"SlotData",
"default_slots",
"default_state",
"interview_control_state",
"narrative_coverage_state",
"prompt_empty_slots_for_current_stage",
]