2026-01-21 22:31:03 +01:00
|
|
|
|
"""
|
2026-04-22 16:56:28 +08:00
|
|
|
|
共享状态 Schema(对话 Agent 与后台 Agent 共用)。
|
|
|
|
|
|
|
|
|
|
|
|
Option B 的 schema 分层:
|
|
|
|
|
|
|
|
|
|
|
|
- `NarrativeCoverageState`:叙述覆盖视图 —— 阶段推进、叙述槽、每阶段的完成情况;**不掺控制元信息**。
|
|
|
|
|
|
- `InterviewControlState`:访谈控制视图 —— 已确认事实、人物主线、最近已问;用于访谈控场,**不参与成稿槽位**。
|
2026-04-30 14:11:46 +08:00
|
|
|
|
- `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
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-08 21:36:12 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-22 16:56:28 +08:00
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 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, {})
|
2026-04-22 16:56:28 +08:00
|
|
|
|
return [key for key, value in stage_slots.items() if not value.snippet]
|
2026-04-08 21:36:12 +08:00
|
|
|
|
|
2026-02-13 21:45:56 +01:00
|
|
|
|
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
|
2026-02-13 21:45:56 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
2026-04-22 16:56:28 +08:00
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 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)
|
|
|
|
|
|
|
2026-04-08 21:36:12 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-04-22 16:56:28 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-30 14:11:46 +08:00
|
|
|
|
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
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-04-22 16:56:28 +08:00
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
2026-04-30 14:11:46 +08:00
|
|
|
|
# 持久化 DTO:不承载视图委托逻辑
|
2026-04-22 16:56:28 +08:00
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MemoirStateSchema(BaseModel):
|
2026-04-30 14:11:46 +08:00
|
|
|
|
"""回忆录状态持久化 DTO。视图逻辑由显式 state projector 提供。"""
|
2026-04-22 16:56:28 +08:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-30 14:11:46 +08:00
|
|
|
|
DEFAULT_STAGE_ORDER: list[str] = list(CHAT_STAGES)
|
2026-04-22 16:56:28 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-04-30 14:11:46 +08:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
2026-04-22 16:56:28 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-04-30 14:11:46 +08:00
|
|
|
|
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-04-22 16:56:28 +08:00
|
|
|
|
|
2026-01-21 22:31:03 +01:00
|
|
|
|
|
2026-04-30 14:11:46 +08: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(),
|
|
|
|
|
|
)
|
2026-04-22 16:56:28 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
__all__ = [
|
|
|
|
|
|
"DEFAULT_STAGE_ORDER",
|
|
|
|
|
|
"InterviewControlState",
|
|
|
|
|
|
"KnownFact",
|
|
|
|
|
|
"MemoirStateSchema",
|
|
|
|
|
|
"NarrativeCoverageState",
|
|
|
|
|
|
"PersonaThread",
|
|
|
|
|
|
"SlotData",
|
|
|
|
|
|
"default_slots",
|
|
|
|
|
|
"default_state",
|
2026-04-30 14:11:46 +08:00
|
|
|
|
"interview_control_state",
|
|
|
|
|
|
"narrative_coverage_state",
|
|
|
|
|
|
"prompt_empty_slots_for_current_stage",
|
2026-04-22 16:56:28 +08:00
|
|
|
|
]
|