refactor: прогресс звонящего вынесен в модели SlotProgress и PersonaProgress

This commit is contained in:
gglamer 2026-09-26 22:13:35 +00:00
commit 64313a62fb
4 changed files with 163 additions and 8 deletions

View file

@ -7,6 +7,8 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pydantic import BaseModel, Field
from app.domain.events import Mood from app.domain.events import Mood
from app.scenarios.schema import Persona from app.scenarios.schema import Persona
@ -37,13 +39,47 @@ DIRECTIVE_MOOD: dict[str, Mood] = {
} }
@dataclass class PersonaProgress(BaseModel):
class PersonaState: """Где звонящий по дуге и что накопил; профиль берётся из сценария."""
persona: Persona
stage: str = "registration" stage: str = "registration"
repeats: int = 0 repeats: int = 0
directive: str | None = None directive: str | None = None
history: list[Mood] = field(default_factory=list) history: list[Mood] = Field(default_factory=list)
@dataclass
class PersonaState:
persona: Persona
progress: PersonaProgress = field(default_factory=PersonaProgress)
@property
def stage(self) -> str:
return self.progress.stage
@stage.setter
def stage(self, value: str) -> None:
self.progress.stage = value
@property
def repeats(self) -> int:
return self.progress.repeats
@repeats.setter
def repeats(self, value: int) -> None:
self.progress.repeats = value
@property
def directive(self) -> str | None:
return self.progress.directive
@directive.setter
def directive(self, value: str | None) -> None:
self.progress.directive = value
@property
def history(self) -> list[Mood]:
return self.progress.history
@property @property
def base(self) -> str: def base(self) -> str:

View file

@ -12,6 +12,7 @@ import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
import numpy as np import numpy as np
from pydantic import BaseModel, Field
from app.dialog.embeddings import Embedder from app.dialog.embeddings import Embedder
from app.scenarios.schema import ChecklistItem, Fact, Scenario from app.scenarios.schema import ChecklistItem, Fact, Scenario
@ -57,6 +58,17 @@ class TurnResult:
return bool(self.matched) return bool(self.matched)
class SlotProgress(BaseModel):
"""Что уже произошло в разговоре — единственное, что стоит сохранять.
Якоря, словари раскрытия и уточнения собираются из сценария заново.
"""
asked: list[str] = Field(default_factory=list)
revealed: list[str] = Field(default_factory=list)
refined: list[str] = Field(default_factory=list)
class SlotMachine: class SlotMachine:
def __init__( def __init__(
self, self,
@ -64,6 +76,7 @@ class SlotMachine:
embedder: Embedder, embedder: Embedder,
floor: float = MATCH_FLOOR, floor: float = MATCH_FLOOR,
margin: float = MATCH_MARGIN, margin: float = MATCH_MARGIN,
progress: SlotProgress | None = None,
) -> None: ) -> None:
self.scenario = scenario self.scenario = scenario
self.floor = floor self.floor = floor
@ -103,9 +116,19 @@ class SlotMachine:
if fact.refine_on: if fact.refine_on:
self._refines.setdefault(fact.refine_on, []).append(fact.id) self._refines.setdefault(fact.refine_on, []).append(fact.id)
self.asked: list[str] = [] self.progress = progress if progress is not None else SlotProgress()
self.revealed: list[str] = []
self.refined: list[str] = [] @property
def asked(self) -> list[str]:
return self.progress.asked
@property
def revealed(self) -> list[str]:
return self.progress.revealed
@property
def refined(self) -> list[str]:
return self.progress.refined
# ── реплика оператора ── # ── реплика оператора ──

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,97 @@
"""Прогресс звонящего — чистые данные, из которых собирается живой звонящий.
В снимок занятия прогресс пока не пишется; модель заложена, чтобы включение
сохранения было одним полем сохраняемой части и передачей в пересборку.
"""
from uuid import uuid4
from app.dialog.persona import PersonaProgress, PersonaState
from app.dialog.slots import SlotMachine, SlotProgress
from app.domain.events import Exercise, Mood, SessionMode
from app.session import checkpoint
from app.session.checkpoint import CallerProgress, dump_state, rebuild_live
from app.session.state import SessionState
from tests.test_refinement import SCENARIO
from tests.test_slots import StemEmbedder
def slot_progress() -> SlotProgress:
return SlotProgress(asked=["q_address", "q_city"], revealed=["f_address"],
refined=["f_address"])
def persona_progress() -> PersonaProgress:
return PersonaProgress(stage="dispatch", repeats=2, directive="panic_rises",
history=[Mood.CALM, Mood.PANIC])
def test_slot_machine_resumes_given_progress():
progress = slot_progress()
slots = SlotMachine(SCENARIO, StemEmbedder(), progress=progress)
assert slots.progress == progress
assert [fact.id for fact in slots.revealed_facts()] == ["f_address"]
assert slots.refined == ["f_address"]
assert slots.value_of("f_address") == "Королёв, улица Станционная, дом 28"
assert [item.id for item in slots.unasked()] == []
def test_slot_machine_without_progress_starts_clean():
slots = SlotMachine(SCENARIO, StemEmbedder())
assert slots.progress == SlotProgress()
slots.hear("Назовите адрес")
assert slots.progress.revealed == ["f_address"]
def test_persona_resumes_given_progress():
progress = persona_progress()
persona = PersonaState(SCENARIO.persona, progress=progress)
assert persona.progress == progress
assert persona.mood is Mood.PANIC
assert persona.repeats == 2
def call_state() -> SessionState:
return SessionState(
session_id=uuid4(), scenario_id=SCENARIO.id, scenario_title=SCENARIO.title,
level=SCENARIO.level.value, mode=SessionMode.TRAINING,
exercise=Exercise.CALL, scenario=SCENARIO,
)
def test_rebuild_live_uses_caller_progress(monkeypatch):
monkeypatch.setattr(checkpoint, "get_embedder", StemEmbedder)
state = call_state()
progress = CallerProgress(slots=slot_progress(), persona=persona_progress())
rebuild_live(state, progress)
assert state.slots.progress == progress.slots
assert state.persona.progress == progress.persona
assert state.caller is not None
def test_rebuild_live_without_progress_starts_clean(monkeypatch):
monkeypatch.setattr(checkpoint, "get_embedder", StemEmbedder)
state = call_state()
rebuild_live(state)
assert state.slots.progress == SlotProgress()
assert state.persona.progress == PersonaProgress()
def test_snapshot_has_no_caller_progress(monkeypatch):
monkeypatch.setattr(checkpoint, "get_embedder", StemEmbedder)
state = call_state()
rebuild_live(state, CallerProgress(slots=slot_progress(), persona=persona_progress()))
payload = dump_state(state)
assert not {"slots", "persona", "caller", "caller_progress"} & set(payload)
assert "panic_rises" not in str(payload)