refactor: прогресс звонящего вынесен в модели SlotProgress и PersonaProgress
This commit is contained in:
parent
fca353fdd0
commit
64313a62fb
4 changed files with 163 additions and 8 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
97
backend/tests/test_caller_progress.py
Normal file
97
backend/tests/test_caller_progress.py
Normal 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)
|
||||||
Loading…
Reference in a new issue