===== src/siberia_dialog/core/__init__.py =====
     1	from .dialog import DialogEngine, DialogSession, Reply
     2	from .scenario import Scenario, State, load_scenario, load_scenario_file
     3	
     4	__all__ = [
     5	    "DialogEngine",
     6	    "DialogSession",
     7	    "Reply",
     8	    "Scenario",
     9	    "State",
    10	    "load_scenario",
    11	    "load_scenario_file",
    12	]
===== src/siberia_dialog/core/address.py =====
     1	"""Нормализация и разбор российского адреса из свободного текста абонента.
     2	
     3	«ул. Ленина д 5 кв 12», «Ленина 5-12», «проспект Мира, дом 10, корпус 2, квартира 7» →
     4	{street: "ленина", house: "5", block: "2", flat: "12"}. Сопоставление с базой обслуживаемых
     5	адресов — по нормализованной улице + дому (+ корпус), квартира — отдельно.
     6	
     7	Полный ГАР (ФИАС) подключается позже как справочник улиц для исправления опечаток.
     8	"""
     9	from __future__ import annotations
    10	
    11	import re
    12	from dataclasses import dataclass, field
    13	
    14	STREET_TYPES = {
    15	    "ул": "ул", "улица": "ул", "пр": "пр", "просп": "пр", "проспект": "пр", "пр-т": "пр", "пр-кт": "пр",
    16	    "пер": "пер", "переулок": "пер", "б-р": "б-р", "бульвар": "б-р", "бул": "б-р", "ш": "ш", "шоссе": "ш",
    17	    "пл": "пл", "площадь": "пл", "наб": "наб", "набережная": "наб", "проезд": "проезд", "пр-д": "проезд",
    18	    "мкр": "мкр", "микрорайон": "мкр", "тупик": "туп", "туп": "туп", "аллея": "аллея", "тракт": "тракт",
    19	}
    20	HOUSE_RE = re.compile(
    21	    r"(?:\b(?:д|дом)\.?\s*)?(?P<house>\d+(?:\s?[а-яa-z](?![а-яa-z0-9]))?(?:/\d+)?)"
    22	    r"(?:\s*(?:,\s*)?(?:к|корп|корпус|стр|строение)\.?\s*(?P<block>\d+[а-я]?))?"
    23	    r"(?:\s*(?:,\s*)?(?:кв|квартира|оф|офис|пом|помещение)\.?\s*(?P<flat>\d+[а-я]?)|\s*-\s*(?P<flat2>\d+))?",
    24	    re.IGNORECASE,
    25	)
    26	
    27	
    28	@dataclass
    29	class ParsedAddress:
    30	    city: str | None = None
    31	    street: str | None = None
    32	    street_type: str | None = None
    33	    house: str | None = None
    34	    block: str | None = None
    35	    flat: str | None = None
    36	    raw: str = ""
    37	    tokens: list[str] = field(default_factory=list)
    38	
    39	    @property
    40	    def key(self) -> str:
    41	        """Ключ для сопоставления с базой: улица|дом|корпус."""
    42	        return f"{self.street or ''}|{self.house or ''}|{self.block or ''}"
    43	
    44	    @property
    45	    def complete(self) -> bool:
    46	        return bool(self.street and self.house)
    47	
    48	
    49	def _clean(text: str) -> str:
    50	    t = text.lower().replace("ё", "е")
    51	    t = re.sub(r"[«»\"']", " ", t)
    52	    t = re.sub(r"\s+", " ", t).strip(" ,.")
    53	    return t
    54	
    55	
    56	def parse_address(text: str, city_hints: list[str] | None = None) -> ParsedAddress:
    57	    t = _clean(text)
    58	    res = ParsedAddress(raw=text)
    59	    for c in city_hints or []:
    60	        c_l = c.lower()
    61	        if re.search(rf"(?:^|\b)(?:г\.?\s*)?{re.escape(c_l)}\b", t):
    62	            res.city = c
    63	            t = re.sub(rf"(?:^|\b)(?:г\.?\s*|город\s+)?{re.escape(c_l)}\b,?", " ", t).strip()
    64	            break
    65	    m = HOUSE_RE.search(t)
    66	    street_part = t
    67	    if m and m.group("house"):
    68	        res.house = m.group("house").replace(" ", "")
    69	        res.block = m.group("block")
    70	        res.flat = m.group("flat") or m.group("flat2")
    71	        street_part = t[: m.start()].strip(" ,.-")
    72	    words = [w for w in re.split(r"[\s,]+", street_part) if w]
    73	    stype = None
    74	    name_words = []
    75	    for w in words:
    76	        w2 = w.rstrip(".")
    77	        if w2 in STREET_TYPES and stype is None:
    78	            stype = STREET_TYPES[w2]
    79	        elif w2 not in ("г", "город", "дом", "д"):
    80	            name_words.append(w2)
    81	    res.street_type = stype
    82	    res.street = " ".join(name_words) or None
    83	    res.tokens = name_words
    84	    return res
    85	
    86	
    87	def street_key(street: str) -> str:
    88	    """Нормализованное имя улицы для сравнения: без типа, без окончаний склонения."""
    89	    p = parse_address(street + " 1")
    90	    s = p.street or _clean(street)
    91	    s = re.sub(r"\b(имени|им)\b", "", s).strip()
    92	    # грубое стемминг-сравнение: «ленина»/«ленин», «советская»/«советской»
    93	    words = [re.sub(r"(ого|ому|ая|ой|ую|ый|ий|ое|ее|ая|а|я|у|ю|ы|и|е|ь)$", "", w) if len(w) > 4 else w for w in s.split()]
    94	    return " ".join(words)
    95	
    96	
    97	def match_address(parsed: ParsedAddress, base: dict[str, list[str]]) -> list[str]:
    98	    """base: {street_key|house|block -> [account, ...]}. Возвращает найденные лицевые счета."""
    99	    if parsed.street is None or parsed.house is None:  # то же, что parsed.complete, но с сужением типов
   100	        return []
   101	    prefix = f"{street_key(parsed.street)}|{parsed.house}|"
   102	    if parsed.block:
   103	        return list(base.get(prefix + parsed.block, []))
   104	    # корпус не указан — все корпуса дома (уточнение по квартире/корпусу делает вызывающий код)
   105	    return [a for k, accs in base.items() if k.startswith(prefix) for a in accs]
   106	
   107	
   108	def build_index(rows: list[tuple[str, str, str | None, str]]) -> dict[str, list[str]]:
   109	    """rows: (street, house, block, account) → индекс для match_address."""
   110	    idx: dict[str, list[str]] = {}
   111	    for street, house, block, account in rows:
   112	        k = f"{street_key(street)}|{str(house).lower().replace(' ', '')}|{block or ''}"
   113	        idx.setdefault(k, []).append(account)
   114	    return idx
===== src/siberia_dialog/core/dialog.py =====
     1	"""Диалоговый движок: исполняет YAML-FSM одинаково для голоса и чата.
     2	
     3	Вход — `Event` (текст / DTMF / тишина / таймаут), выход — `Reply` (что сказать/написать,
     4	кнопки, завершён ли диалог и с каким исходом). Состояние сессии сериализуемо (Redis/БД).
     5	"""
     6	from __future__ import annotations
     7	
     8	import datetime as dt
     9	from collections.abc import Awaitable, Callable
    10	from dataclasses import dataclass, field
    11	from typing import Any
    12	
    13	from .nlu import LLMClassifier, NLUResult, extract_slot, match_intent
    14	from .scenario import AskKnowledge, Scenario, State
    15	
    16	ActionFn = Callable[["DialogSession", dict[str, Any]], Awaitable[dict[str, Any] | None]]
    17	# Ответ по базе знаний: вопрос и параметры шага на входе, слоты ответа на выходе.
    18	# None означает «в базе такого нет» — тогда диалог идёт по ветке not_found.
    19	KnowledgeFn = Callable[[str, AskKnowledge], Awaitable[dict[str, Any] | None]]
    20	
    21	
    22	@dataclass
    23	class Event:
    24	    kind: str  # text | dtmf | silence | timeout | start
    25	    text: str = ""
    26	
    27	
    28	@dataclass
    29	class Reply:
    30	    say: str | None
    31	    buttons: list[str] = field(default_factory=list)
    32	    done: bool = False
    33	    outcome: str | None = None
    34	    state: str = ""
    35	    expects_input: bool = False
    36	
    37	
    38	@dataclass
    39	class DialogSession:
    40	    scenario_id: str
    41	    slots: dict[str, Any] = field(default_factory=dict)
    42	    state: str = ""
    43	    repeats: int = 0
    44	    turns: list[dict[str, Any]] = field(default_factory=list)
    45	    outcome: str | None = None
    46	    done: bool = False
    47	    started_at: str = field(default_factory=lambda: dt.datetime.now().isoformat(timespec="seconds"))
    48	
    49	    def to_dict(self) -> dict[str, Any]:
    50	        return self.__dict__.copy()
    51	
    52	    @classmethod
    53	    def from_dict(cls, d: dict[str, Any]) -> DialogSession:
    54	        return cls(**d)
    55	
    56	
    57	class DialogEngine:
    58	    def __init__(
    59	        self,
    60	        scenario: Scenario,
    61	        *,
    62	        classifier: LLMClassifier | None = None,
    63	        actions: dict[str, ActionFn] | None = None,
    64	        knowledge: KnowledgeFn | None = None,
    65	        llm_threshold: float = 0.6,
    66	        today: dt.date | None = None,
    67	    ) -> None:
    68	        self.sc = scenario
    69	        self.classifier = classifier
    70	        self.actions = actions or {}
    71	        self.knowledge = knowledge
    72	        self.llm_threshold = llm_threshold
    73	        self.today = today
    74	
    75	    def new_session(self, slots: dict[str, Any] | None = None) -> DialogSession:
    76	        s = DialogSession(scenario_id=self.sc.id, slots={**self.sc.defaults, **(slots or {})})
    77	        s.state = self.sc.start
    78	        return s
    79	
    80	    # ---- публичный API ----
    81	    async def start(self, session: DialogSession) -> Reply:
    82	        return await self._enter(session, self.sc.start)
    83	
    84	    async def handle(self, session: DialogSession, event: Event) -> Reply:
    85	        if session.done:
    86	            return Reply(None, done=True, outcome=session.outcome, state=session.state)
    87	        st = self.sc.states[session.state]
    88	        session.turns.append({"role": "user", "kind": event.kind, "text": event.text})
    89	
    90	        if event.kind in ("silence", "timeout"):
    91	            return await self._transition(session, st, f"_{event.kind}", fallback="_silence")
    92	
    93	        if event.kind == "dtmf" and st.expect and event.text in st.expect.dtmf:
    94	            return await self._transition(session, st, st.expect.dtmf[event.text])
    95	
    96	        if event.kind == "photo":
    97	            if st.expect and st.expect.slot and st.expect.slot_type == "photo":
    98	                session.slots[st.expect.slot] = event.text
    99	                return await self._transition(session, st, "_filled", fallback="_any")
   100	            return await self._transition(session, st, "_photo", fallback="_unknown")
   101	
   102	        nlu = await self._understand(session, st, event.text)
   103	        session.turns[-1]["nlu"] = {"intent": nlu.intent, "conf": nlu.confidence, "src": nlu.source}
   104	        session.slots.update(nlu.slots)
   105	
   106	        if st.expect and st.expect.slot and st.expect.slot in nlu.slots:
   107	            return await self._transition(session, st, "_filled", fallback="_any")
   108	        if nlu.intent:
   109	            return await self._transition(session, st, nlu.intent)
   110	        return await self._transition(session, st, "_unknown", fallback="_silence")
   111	
   112	    # ---- внутреннее ----
   113	    async def _understand(self, session: DialogSession, st: State, text: str) -> NLUResult:
   114	        allowed = list(st.expect.intents) if st.expect else []
   115	        # глобальные интенты доступны везде, если есть переход
   116	        for g in ("stop", "operator", "repeat"):
   117	            if g in st.on and g not in allowed:
   118	                allowed.append(g)
   119	        # нажатие кнопки в чате → интент по позиции кнопки
   120	        if st.buttons and st.expect and st.expect.intents and text.strip() in st.buttons:
   121	            i = st.buttons.index(text.strip())
   122	            if i < len(st.expect.intents):
   123	                return NLUResult(st.expect.intents[i], 1.0, source="button")
   124	        res = match_intent(text, allowed, self.sc.intents)
   125	        if st.expect and st.expect.slot:
   126	            val = extract_slot(st.expect.slot_type, text, self.today)
   127	            if val is not None and res.intent in (None, "yes"):
   128	                res.slots[st.expect.slot] = val
   129	                res.confidence = max(res.confidence, 0.8)
   130	        if res.confidence >= self.llm_threshold or not self.classifier or not allowed:
   131	            return res
   132	        ctx = st.say or ""
   133	        llm = await self.classifier.classify(
   134	            text, allowed, st.expect.slot if st.expect else None, ctx
   135	        )
   136	        if llm.confidence >= self.llm_threshold:
   137	            if st.expect and st.expect.slot and st.expect.slot in llm.slots:
   138	                v = extract_slot(st.expect.slot_type, str(llm.slots[st.expect.slot]), self.today)
   139	                llm.slots[st.expect.slot] = v if v is not None else llm.slots[st.expect.slot]
   140	            return llm
   141	        return res
   142	
   143	    async def _transition(
   144	        self, session: DialogSession, st: State, key: str, fallback: str | None = None
   145	    ) -> Reply:
   146	        target = st.on.get(key) or (st.on.get(fallback) if fallback else None) or st.on.get("_any")
   147	        if target is None:
   148	            # нет перехода — повторяем состояние (с лимитом), затем вежливо завершаем
   149	            session.repeats += 1
   150	            if session.repeats > st.max_repeats:
   151	                return self._finish(session, "no_understanding")
   152	            return self._reply(session, st, repeat=True)
   153	        if target == session.state:
   154	            session.repeats += 1
   155	            if session.repeats > st.max_repeats:
   156	                return self._finish(session, "no_understanding")
   157	            return self._reply(session, self.sc.states[target], repeat=True)
   158	        return await self._enter(session, target)
   159	
   160	    async def _enter(self, session: DialogSession, name: str) -> Reply:
   161	        session.state = name
   162	        session.repeats = 0
   163	        st = self.sc.states[name]
   164	        for k, v in st.set.items():
   165	            session.slots[k] = self._render(str(v), session) if isinstance(v, str) else v
   166	        if st.call:
   167	            fn = self.actions.get(st.call)
   168	            if fn is not None:
   169	                result = await fn(session, dict(session.slots))
   170	                if result:
   171	                    session.slots.update(result)
   172	            else:
   173	                session.turns.append({"role": "system", "warn": f"no action {st.call}"})
   174	        if st.ask_knowledge:
   175	            target = await self._ask_knowledge(session, st)
   176	            if target is not None:
   177	                return await self._enter(session, target)
   178	        if st.end:
   179	            return self._finish(session, st.end, say=self._render(st.say, session) if st.say else None)
   180	        if st.switch:
   181	            val = session.slots.get(st.switch)
   182	            target = st.on.get(str(val)) if val is not None else None
   183	            target = target or st.on.get("_default")
   184	            if target is None:
   185	                return self._finish(session, "scenario_error")
   186	            pre = self._reply(session, st) if st.say else None
   187	            follow = await self._enter(session, target)
   188	            if pre and pre.say:
   189	                follow.say = " ".join(x for x in (pre.say, follow.say) if x)
   190	            return follow
   191	        reply = self._reply(session, st)
   192	        # состояние без ввода (только say/call/set) — авто-переход по _next/_any
   193	        if not st.expect and st.on:
   194	            nxt = st.on.get("_next") or st.on.get("_any")
   195	            if nxt:
   196	                follow = await self._enter(session, nxt)
   197	                follow.say = " ".join(x for x in (reply.say, follow.say) if x) or None
   198	                return follow
   199	        return reply
   200	
   201	    async def _ask_knowledge(self, session: DialogSession, st: State) -> str | None:
   202	        """Кладёт ответ базы знаний в слоты. Возвращает имя состояния, если ответа нет.
   203	
   204	        База знаний может быть не подключена — например, на установке без модуля. Тогда
   205	        шаг ведёт себя так же, как при отсутствии ответа: диалог продолжается по ветке
   206	        not_found, а в журнал попадает предупреждение. Так же поступает и шаг с
   207	        несуществующим действием: обрывать разговор из-за настройки нельзя.
   208	        """
   209	        ask = st.ask_knowledge
   210	        assert ask is not None
   211	        if self.knowledge is None:
   212	            session.turns.append({"role": "system", "warn": "knowledge is not configured"})
   213	            return ask.not_found
   214	        question = self._render(ask.question, session).strip()
   215	        if not question:
   216	            return ask.not_found
   217	        try:
   218	            result = await self.knowledge(question, ask)
   219	        except Exception as e:  # сбой поиска не должен ронять разговор
   220	            session.turns.append({"role": "system", "warn": f"knowledge failed: {type(e).__name__}"})
   221	            return ask.not_found
   222	        if not result:
   223	            return ask.not_found
   224	        session.slots.update(result)
   225	        return None
   226	
   227	    def _reply(self, session: DialogSession, st: State, repeat: bool = False) -> Reply:
   228	        say = self._render(st.say, session) if st.say else None
   229	        if repeat and say:
   230	            say = "Извините, я не расслышал. " + say
   231	        buttons = st.buttons if st.buttons is not None else (
   232	            [self._label(i) for i in st.expect.intents] if st.expect else []
   233	        )
   234	        if say:
   235	            session.turns.append({"role": "bot", "text": say})
   236	        return Reply(say, buttons, state=session.state, expects_input=st.expect is not None)
   237	
   238	    def _finish(self, session: DialogSession, outcome: str, say: str | None = None) -> Reply:
   239	        session.done = True
   240	        session.outcome = outcome
   241	        if say:
   242	            session.turns.append({"role": "bot", "text": say})
   243	        return Reply(say, done=True, outcome=outcome, state=session.state)
   244	
   245	    def _render(self, template: str, session: DialogSession) -> str:
   246	        class _Safe(dict):
   247	            def __missing__(self, k: str) -> str:
   248	                return "{" + k + "}"
   249	
   250	        return template.format_map(_Safe({k: humanize(v) for k, v in session.slots.items()}))
   251	
   252	    @staticmethod
   253	    def _label(intent: str) -> str:
   254	        return {
   255	            "yes": "Да", "no": "Нет", "wrong_number": "Ошиблись номером", "later": "Позже",
   256	            "paid": "Уже оплатил", "operator": "Оператор", "reschedule": "Перенести",
   257	            "cancel": "Отменить", "repeat": "Повторить", "stop": "Не звонить",
   258	            "dispute": "Не согласен с суммой", "readings": "Передать показания",
   259	            "debt": "Задолженность", "request": "Подать заявку", "emergency": "Аварии",
   260	        }.get(intent, intent)
   261	
   262	
   263	MONTHS_GEN = ["января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа",
   264	              "сентября", "октября", "ноября", "декабря"]
   265	
   266	
   267	def humanize(v: Any) -> Any:
   268	    """ISO-дата → «28 августа» (для речи и чата); остальное как есть."""
   269	    if isinstance(v, str) and len(v) == 10 and v[4] == "-" and v[7] == "-":
   270	        try:
   271	            d = dt.date.fromisoformat(v)
   272	            return f"{d.day} {MONTHS_GEN[d.month - 1]}"
   273	        except ValueError:
   274	            return v
   275	    return v
===== src/siberia_dialog/core/nlu.py =====
     1	"""NLU: сначала дешёвые правила (ключевые слова, DTMF, даты, суммы), потом LLM через Gateway.
     2	
     3	Правила закрывают ~80 % реплик в сценарных диалогах (да/нет/не туда/оператор/повторите) —
     4	LLM вызывается только при неуверенности, что держит себестоимость минуты низкой.
     5	"""
     6	from __future__ import annotations
     7	
     8	import datetime as dt
     9	import json
    10	import re
    11	from dataclasses import dataclass, field
    12	from typing import Any
    13	
    14	from ..providers.base import LLMMessage, LLMProvider
    15	
    16	BUILTIN_INTENTS: dict[str, list[str]] = {
    17	    "yes": ["да", "ага", "угу", "верно", "конечно", "слушаю", "это я", "говорите", "подтверждаю"],
    18	    "no": ["нет", "неа", "не я", "не могу", "не буду", "отказываюсь", "не подтверждаю"],
    19	    "wrong_number": ["ошиблись", "не туда", "такого нет", "не знаю такого", "неправильно набрали"],
    20	    "repeat": ["повторите", "не расслышал", "не поняла", "не понял", "ещё раз", "что?"],
    21	    "operator": ["оператор", "человек", "сотрудник", "живой", "специалист", "соедините"],
    22	    "stop": ["не звоните", "удалите", "больше не", "прекратите", "отстаньте", "запрещаю"],
    23	    "paid": ["оплатил", "заплатил", "оплачено", "погасил", "перевел", "внес оплату", "уже платил"],
    24	    "later": ["позже", "перезвоните", "не сейчас", "занят", "неудобно"],
    25	    "reschedule": ["перенести", "другое время", "другой день", "перенесите"],
    26	    "cancel": ["отмените", "отменить", "не приду", "не смогу прийти"],
    27	}
    28	
    29	WEEKDAYS = ["понедельник", "вторник", "сред", "четверг", "пятниц", "суббот", "воскресень"]
    30	MONTHS = [
    31	    ("янв",), ("фев",), ("мар",), ("апр",), ("мая", "май"), ("июн",), ("июл",), ("авг",),
    32	    ("сен",), ("окт",), ("ноя",), ("дек",),
    33	]
    34	NUM_WORDS = {
    35	    "один": 1, "одного": 1, "два": 2, "две": 2, "три": 3, "четыре": 4, "пять": 5, "шесть": 6,
    36	    "семь": 7, "восемь": 8, "девять": 9, "десять": 10, "неделю": 7, "недели": 7, "две недели": 14,
    37	}
    38	
    39	
    40	@dataclass
    41	class NLUResult:
    42	    intent: str | None
    43	    confidence: float
    44	    slots: dict[str, Any] = field(default_factory=dict)
    45	    source: str = "rules"
    46	
    47	
    48	def norm(text: str) -> str:
    49	    return re.sub(r"[^\w\s]", " ", text.lower().replace("ё", "е")).strip()
    50	
    51	
    52	_norm = norm  # исторические вызовы внутри модуля
    53	
    54	
    55	def phrase_pattern(key: str) -> re.Pattern[str]:
    56	    """Короткие ключи («да», «нет») — только целым словом; длинные — как основа слова."""
    57	    tail = r"\w*" if len(key) >= 5 else r"(?=\s|$)"
    58	    return re.compile(rf"(^|\s){re.escape(key)}{tail}")
    59	
    60	
    61	def match_intent(text: str, allowed: list[str], extra: dict[str, list[str]] | None = None) -> NLUResult:
    62	    t = _norm(text)
    63	    if not t:
    64	        return NLUResult(None, 0.0)
    65	    vocab = {**BUILTIN_INTENTS, **(extra or {})}
    66	    # «нет» внутри «нет, это я» — приоритет более специфичным интентам, затем по порядку allowed.
    67	    for intent in ["stop", "operator", "wrong_number", "repeat", "paid", "reschedule", "cancel", "later"]:
    68	        if intent in allowed and any(k in t for k in vocab.get(intent, [])):
    69	            return NLUResult(intent, 0.9)
    70	    for intent in allowed:
    71	        for k in vocab.get(intent, []):
    72	            if phrase_pattern(k).search(t):
    73	                return NLUResult(intent, 0.8)
    74	    return NLUResult(None, 0.0)
    75	
    76	
    77	def extract_date(text: str, today: dt.date | None = None) -> dt.date | None:
    78	    """«завтра», «послезавтра», «через 3 дня», «в пятницу», «15 числа», «20 сентября»."""
    79	    today = today or dt.date.today()
    80	    t = _norm(text)
    81	    if "сегодня" in t:
    82	        return today
    83	    if "послезавтра" in t:
    84	        return today + dt.timedelta(days=2)
    85	    if "завтра" in t:
    86	        return today + dt.timedelta(days=1)
    87	    if re.search(r"через\s+недел", t):
    88	        return today + dt.timedelta(days=7)
    89	    if m := re.search(r"через\s+(\d+|\w+)\s+(дн|недел)", t):
    90	        raw = m.group(1)
    91	        n = int(raw) if raw.isdigit() else NUM_WORDS.get(raw, 0)
    92	        if m.group(2).startswith("недел"):
    93	            n = n * 7 if n else 7
    94	        if n:
    95	            return today + dt.timedelta(days=n)
    96	    if m := re.search(r"(\d{1,2})\s*(\w+)", t):
    97	        day = int(m.group(1))
    98	        word = m.group(2)
    99	        for i, stems in enumerate(MONTHS, start=1):
   100	            if any(word.startswith(s) for s in stems):
   101	                year = today.year if (i, day) >= (today.month, today.day) else today.year + 1
   102	                try:
   103	                    return dt.date(year, i, day)
   104	                except ValueError:
   105	                    return None
   106	        if "числ" in word and 1 <= day <= 31:
   107	            month, year = today.month, today.year
   108	            if day < today.day:
   109	                month += 1
   110	                if month > 12:
   111	                    month, year = 1, year + 1
   112	            try:
   113	                return dt.date(year, month, day)
   114	            except ValueError:
   115	                return None
   116	    for i, wd in enumerate(WEEKDAYS):
   117	        if wd in t:
   118	            delta = (i - today.weekday()) % 7 or 7
   119	            return today + dt.timedelta(days=delta)
   120	    return None
   121	
   122	
   123	def extract_amount(text: str) -> float | None:
   124	    t = text.lower().replace("ё", "е")
   125	    t = re.sub(r"(?<=\d)\s+(?=\d)", "", t)  # «12 340» → «12340», запятая-дробь сохраняется
   126	    t = re.sub(r"\s+", "", t)
   127	    if m := re.search(r"(\d+(?:[.,]\d+)?)(тыс|т(?![а-я]))?", t):
   128	        val = float(m.group(1).replace(",", "."))
   129	        if m.group(2):
   130	            val *= 1000
   131	        return val
   132	    return None
   133	
   134	
   135	def extract_slot(slot_type: str, text: str, today: dt.date | None = None) -> Any:
   136	    if slot_type == "date":
   137	        d = extract_date(text, today)
   138	        return d.isoformat() if d else None
   139	    if slot_type == "amount":
   140	        return extract_amount(text)
   141	    if slot_type == "phone":
   142	        digits = re.sub(r"\D", "", text)
   143	        return digits if 10 <= len(digits) <= 11 else None
   144	    if slot_type == "yes_no":
   145	        r = match_intent(text, ["yes", "no"])
   146	        return r.intent
   147	    return text.strip() or None
   148	
   149	
   150	class LLMClassifier:
   151	    """Fallback-классификатор интентов + извлечение слота через Gateway (structured output)."""
   152	
   153	    def __init__(self, llm: LLMProvider) -> None:
   154	        self._llm = llm
   155	
   156	    async def classify(
   157	        self, text: str, intents: list[str], slot: str | None, context: str = ""
   158	    ) -> NLUResult:
   159	        schema = {
   160	            "type": "object",
   161	            "properties": {
   162	                "intent": {"type": ["string", "null"], "enum": [*intents, None]},
   163	                "slot_value": {"type": ["string", "null"]},
   164	                "confidence": {"type": "number"},
   165	            },
   166	            "required": ["intent", "slot_value", "confidence"],
   167	            "additionalProperties": False,
   168	        }
   169	        sys = (
   170	            "Ты классификатор реплик абонента в сценарном диалоге (ЖКХ/клиника/энергосбыт). "
   171	            f"Допустимые интенты: {intents}. "
   172	            + (f"Извлеки значение слота '{slot}' если оно есть. " if slot else "")
   173	            + "Отвечай только JSON по схеме. Если не уверен — intent=null."
   174	        )
   175	        msgs = [LLMMessage("system", sys)]
   176	        if context:
   177	            msgs.append(LLMMessage("system", f"Контекст: {context}"))
   178	        msgs.append(LLMMessage("user", text))
   179	        try:
   180	            out = await self._llm.chat(msgs, max_tokens=120, json_schema=schema)
   181	            data = json.loads(out)
   182	        except Exception:  # noqa: BLE001 — LLM недоступен: остаёмся в правилах
   183	            return NLUResult(None, 0.0, source="llm_error")
   184	        slots = {slot: data.get("slot_value")} if slot and data.get("slot_value") else {}
   185	        return NLUResult(data.get("intent"), float(data.get("confidence", 0)), slots, "llm")
===== src/siberia_dialog/core/scenario.py =====
     1	"""YAML DSL сценариев — единый для голоса и чата.
     2	
     3	```yaml
     4	id: zhkh_debt
     5	legal_basis: pp354_notice          # pp354_notice | contract | consent | fz_duty
     6	vertical: zhkh
     7	start: intro
     8	slots: [promise_date]
     9	states:
    10	  intro:
    11	    say: "Здравствуйте! Это {org_name}. Разговор записывается. Я говорю с {full_name}?"
    12	    expect:
    13	      intents: [yes, no, wrong_number]
    14	      dtmf: {"1": yes, "2": no}
    15	    on: {yes: debt_info, no: callback, wrong_number: end_wrong, _silence: repeat, _unknown: clarify}
    16	  end_ok:
    17	    end: promised                  # исход (outcome) звонка/диалога
    18	```
    19	
    20	Состояние: `say` (шаблон с {slot}), `expect` (интенты / слот / DTMF), `on` (переходы),
    21	`set` (присвоить слоты), `call` (действие), `switch: slot` (переход по значению слота,
    22	`_default`), `end` (финальный исход). Служебные переходы: `_silence`, `_unknown`, `_any`, `_timeout`,
    23	`_next`. Тип слота `photo` принимает вложение (Event kind="photo"). Лимит повторов одного состояния — `max_repeats` (по умолчанию 2).
    24	"""
    25	from __future__ import annotations
    26	
    27	import re
    28	from pathlib import Path
    29	from typing import Any
    30	
    31	import yaml
    32	from pydantic import BaseModel, Field, model_validator
    33	
    34	LEGAL_BASES = {"pp354_notice", "contract", "consent", "fz_duty", "service"}
    35	
    36	# Слова, превращающие информирование в рекламу (38-ФЗ ст. 18) — lint при загрузке.
    37	AD_WORDS = re.compile(
    38	    r"\b(акци[яи]|скидк|рассрочк|спецпредложени|бонус|подарок|выгодн|купит[ье]|закажите)\w*",
    39	    re.IGNORECASE,
    40	)
    41	
    42	
    43	class Expect(BaseModel):
    44	    intents: list[str] = Field(default_factory=list)
    45	    slot: str | None = None  # имя слота для извлечения (date, amount, phone, free_text, yes_no)
    46	    slot_type: str = "free_text"
    47	    dtmf: dict[str, str] = Field(default_factory=dict)
    48	    timeout_sec: float = 6.0
    49	
    50	
    51	class AskKnowledge(BaseModel):
    52	    """Шаг «ответить по базе знаний».
    53	
    54	    Вопрос берётся из слота, куда его положил предыдущий шаг с `expect`. Если ответа в
    55	    базе нет, диалог уходит в состояние `not_found` — придумывать ответ нельзя, а тупик
    56	    в сценарии недопустим, поэтому переход обязателен и проверяется при загрузке.
    57	    """
    58	
    59	    collection: str  # имя коллекции; берётся из настроек заказчика
    60	    question: str = "{question}"  # шаблон по слотам диалога
    61	    top_k: int = Field(default=4, ge=1, le=20)
    62	    not_found: str  # состояние, куда идём, если ответа в базе нет
    63	
    64	
    65	class State(BaseModel):
    66	    say: str | None = None
    67	    expect: Expect | None = None
    68	    on: dict[str, str] = Field(default_factory=dict)
    69	    set: dict[str, Any] = Field(default_factory=dict)
    70	    call: str | None = None  # имя коннектора/действия: sms.send, onec.promise, ...
    71	    end: str | None = None
    72	    max_repeats: int = 2
    73	    buttons: list[str] | None = None  # подписи кнопок для чата (по умолчанию — из intents)
    74	    switch: str | None = None  # переход по значению слота: on: {value: state, _default: state}
    75	    ask_knowledge: AskKnowledge | None = None  # ответ по базе знаний заказчика
    76	
    77	    @model_validator(mode="after")
    78	    def _check(self) -> State:
    79	        if (
    80	            self.end is None
    81	            and self.say is None
    82	            and self.call is None
    83	            and not self.set
    84	            and not self.switch
    85	            and self.ask_knowledge is None
    86	        ):
    87	            raise ValueError("state must have say/call/set/switch/ask_knowledge or end")
    88	        if self.switch and self.expect:
    89	            raise ValueError("switch and expect are mutually exclusive")
    90	        return self
    91	
    92	
    93	class Scenario(BaseModel):
    94	    id: str
    95	    vertical: str = "generic"
    96	    legal_basis: str = "service"
    97	    start: str
    98	    slots: list[str] = Field(default_factory=list)
    99	    defaults: dict[str, Any] = Field(default_factory=dict)
   100	    states: dict[str, State]
   101	    intents: dict[str, list[str]] = Field(default_factory=dict)  # intent -> ключевые фразы
   102	
   103	    @model_validator(mode="after")
   104	    def _validate(self) -> Scenario:
   105	        if self.legal_basis not in LEGAL_BASES:
   106	            raise ValueError(f"legal_basis must be one of {sorted(LEGAL_BASES)}")
   107	        if self.start not in self.states:
   108	            raise ValueError(f"start state '{self.start}' not found")
   109	        for name, st in self.states.items():
   110	            for target in st.on.values():
   111	                if target not in self.states:
   112	                    raise ValueError(f"state '{name}' -> unknown target '{target}'")
   113	            if st.say and (m := AD_WORDS.search(st.say)):
   114	                raise ValueError(
   115	                    f"state '{name}': рекламная лексика «{m.group(0)}» запрещена (38-ФЗ ст. 18)"
   116	                )
   117	            if st.ask_knowledge and st.ask_knowledge.not_found not in self.states:
   118	                raise ValueError(
   119	                    f"state '{name}': ask_knowledge.not_found -> unknown target "
   120	                    f"'{st.ask_knowledge.not_found}'"
   121	                )
   122	            if st.expect and st.expect.intents and not st.end:
   123	                missing = [i for i in st.expect.intents if i not in st.on and "_any" not in st.on]
   124	                if missing:
   125	                    raise ValueError(f"state '{name}': no transition for intents {missing}")
   126	        return self
   127	
   128	    def fixed_phrases(self) -> list[str]:
   129	        """Реплики без переменных — для предсинтеза TTS."""
   130	        return [s.say for s in self.states.values() if s.say and "{" not in s.say]
   131	
   132	
   133	class _ScenarioLoader(yaml.SafeLoader):
   134	    """YAML 1.1 читает `on`, `yes`, `no`, `off` как булевы — в DSL это имена интентов и ключей.
   135	    Оставляем булевыми только true/false."""
   136	
   137	
   138	_ScenarioLoader.yaml_implicit_resolvers = {
   139	    k: [(tag, rx) for tag, rx in v if tag != "tag:yaml.org,2002:bool"]
   140	    for k, v in yaml.SafeLoader.yaml_implicit_resolvers.items()
   141	}
   142	_ScenarioLoader.add_implicit_resolver(
   143	    "tag:yaml.org,2002:bool", re.compile(r"^(?:true|True|TRUE|false|False|FALSE)$"), list("tTfF")
   144	)
   145	
   146	YamlLoader = _ScenarioLoader  # тот же загрузчик нужен правилам речевой аналитики
   147	
   148	
   149	
   150	def load_scenario(data: dict[str, Any]) -> Scenario:
   151	    return Scenario.model_validate(data)
   152	
   153	
   154	def load_scenario_file(path: str | Path) -> Scenario:
   155	    with open(path, encoding="utf-8") as f:
   156	        return load_scenario(yaml.load(f, Loader=_ScenarioLoader))
===== src/siberia_dialog/compliance/__init__.py =====
     1	from .limits import ContactHistory, FrequencyLimits, check_frequency
     2	from .policy import CompliancePolicy, Decision, LegalBasis
     3	from .windows import CallWindows, in_window, next_window_start
     4	
     5	__all__ = [
     6	    "CallWindows",
     7	    "CompliancePolicy",
     8	    "ContactHistory",
     9	    "Decision",
    10	    "FrequencyLimits",
    11	    "LegalBasis",
    12	    "check_frequency",
    13	    "in_window",
    14	    "next_window_start",
    15	]
===== src/siberia_dialog/compliance/limits.py =====
     1	"""Частотные лимиты на контакт. Дефолт — 230-ФЗ ст. 7 ч. 3 (1/сутки, 2/неделю, 8/месяц)
     2	как добровольный стандарт для ЖКХ/энергетики (к ним закон не применяется, ст. 1 ч. 4),
     3	обязательный — для банков/МФО/ПКО.
     4	
     5	Считаются только **состоявшиеся** взаимодействия (абонент ответил, не автоответчик):
     6	230-ФЗ считает именно переговоры; недозвоны не учитываются.
     7	"""
     8	from __future__ import annotations
     9	
    10	import datetime as dt
    11	from dataclasses import dataclass, field
    12	
    13	
    14	@dataclass(frozen=True)
    15	class FrequencyLimits:
    16	    per_day: int = 1
    17	    per_week: int = 2
    18	    per_month: int = 8
    19	
    20	    @classmethod
    21	    def fz230(cls) -> FrequencyLimits:
    22	        return cls(1, 2, 8)
    23	
    24	    @classmethod
    25	    def messages_fz230(cls) -> FrequencyLimits:
    26	        """Сообщения (SMS/мессенджеры): 2/4/16 — 230-ФЗ ст. 7 ч. 5."""
    27	        return cls(2, 4, 16)
    28	
    29	
    30	@dataclass
    31	class ContactHistory:
    32	    """Моменты состоявшихся взаимодействий (UTC-aware или naive в локальном времени контакта)."""
    33	
    34	    contacts: list[dt.datetime] = field(default_factory=list)
    35	
    36	    def count_since(self, since: dt.datetime) -> int:
    37	        return sum(1 for t in self.contacts if t >= since)
    38	
    39	
    40	def check_frequency(
    41	    history: ContactHistory, now: dt.datetime, limits: FrequencyLimits | None = None
    42	) -> tuple[bool, str | None]:
    43	    """Возвращает (allowed, reason). Сутки/неделя/месяц — календарные, в TZ контакта."""
    44	    limits = limits or FrequencyLimits()
    45	    day_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
    46	    week_start = day_start - dt.timedelta(days=day_start.weekday())
    47	    month_start = day_start.replace(day=1)
    48	    if history.count_since(day_start) >= limits.per_day:
    49	        return False, f"limit_per_day={limits.per_day}"
    50	    if history.count_since(week_start) >= limits.per_week:
    51	        return False, f"limit_per_week={limits.per_week}"
    52	    if history.count_since(month_start) >= limits.per_month:
    53	        return False, f"limit_per_month={limits.per_month}"
    54	    return True, None
===== src/siberia_dialog/compliance/policy.py =====
     1	"""Единая точка решения «можно ли сейчас звонить/писать этому контакту».
     2	
     3	Проверки по порядку (первая неудача — отказ):
     4	1. DNC — отказ абонента (230-ФЗ, 126-ФЗ ст. 44.1-1 право на отказ) — безусловный.
     5	2. Правовое основание кампании и согласие (до 01.03.2027 для массовых вызовов нужно
     6	   согласие, если заказчик не в перечне исключений; с 01.03.2027 — 210-ФЗ, «информирование
     7	   по обязанности из ФЗ»).
     8	3. Окно времени по TZ контакта.
     9	4. Частотные лимиты.
    10	"""
    11	from __future__ import annotations
    12	
    13	import datetime as dt
    14	import enum
    15	from dataclasses import dataclass, field
    16	from zoneinfo import ZoneInfo
    17	
    18	from .limits import ContactHistory, FrequencyLimits, check_frequency
    19	from .windows import CallWindows, in_window, next_window_start
    20	
    21	FZ210_EFFECTIVE = dt.date(2027, 3, 1)
    22	
    23	
    24	class LegalBasis(enum.StrEnum):
    25	    PP354_NOTICE = "pp354_notice"  # предупреждение об ограничении услуги, ПП 354 п. 119
    26	    CONTRACT = "contract"  # информирование в рамках договора управления/ресурсоснабжения
    27	    CONSENT = "consent"  # явное согласие абонента (ЛК, SMS, договор)
    28	    FZ_DUTY = "fz_duty"  # обязанность информировать по ФЗ (210-ФЗ, с 01.03.2027)
    29	    SERVICE = "service"  # сервисные уведомления (запись к врачу и т.п.)
    30	
    31	
    32	@dataclass
    33	class ContactState:
    34	    tz: str = "Europe/Moscow"
    35	    dnc: bool = False
    36	    has_consent: bool = False
    37	    history: ContactHistory = field(default_factory=ContactHistory)
    38	
    39	
    40	@dataclass
    41	class Decision:
    42	    allowed: bool
    43	    reason: str | None = None
    44	    retry_at: dt.datetime | None = None
    45	
    46	
    47	@dataclass
    48	class CompliancePolicy:
    49	    legal_basis: LegalBasis
    50	    customer_in_gov_list: bool = False  # заказчик в перечне исключений ст. 44.1-1 / госорган
    51	    limits: FrequencyLimits = field(default_factory=FrequencyLimits)
    52	    windows: CallWindows = field(default_factory=CallWindows)
    53	    channel: str = "voice"  # voice | message
    54	
    55	    def decide(self, contact: ContactState, now_utc: dt.datetime) -> Decision:
    56	        if contact.dnc:
    57	            return Decision(False, "dnc")
    58	        if not self._basis_ok(contact, now_utc.date()):
    59	            return Decision(False, "no_consent_for_mass_calls")
    60	        if self.channel == "voice" and not in_window(now_utc, contact.tz, self.windows):
    61	            return Decision(False, "outside_window", next_window_start(now_utc, contact.tz, self.windows))
    62	        local_now = now_utc.astimezone(ZoneInfo(contact.tz))
    63	        ok, why = check_frequency(contact.history, local_now, self.limits)
    64	        if not ok:
    65	            return Decision(False, why, self._next_day(now_utc, contact.tz))
    66	        return Decision(True)
    67	
    68	    def _basis_ok(self, contact: ContactState, today: dt.date) -> bool:
    69	        if contact.has_consent or self.customer_in_gov_list:
    70	            return True
    71	        if self.legal_basis == LegalBasis.CONSENT:
    72	            return False
    73	        if self.legal_basis == LegalBasis.FZ_DUTY:
    74	            # «информирование по обязанности из ФЗ» — прямое основание только с 01.03.2027 (210-ФЗ)
    75	            return today >= FZ210_EFFECTIVE
    76	        # pp354_notice / contract / service: информирование в рамках договора управления,
    77	        # ресурсоснабжения или оказания услуг — согласие зафиксировано договором (152-ФЗ п. 5 ч. 1 ст. 6).
    78	        return True
    79	
    80	    def _next_day(self, now_utc: dt.datetime, tz: str) -> dt.datetime:
    81	        tomorrow = (now_utc + dt.timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
    82	        return next_window_start(tomorrow, tz, self.windows)
===== src/siberia_dialog/compliance/proof.py =====
     1	"""Доказательство доставки уведомления (ПП 354 п. 119 пп. «а», письмо Минстроя 23.12.2025
     2	№ 79938-ДН/04): звонок с записью разговора — юридически значимый канал, если зафиксированы
     3	факт и дата доставки. Формируем акт: реквизиты, номер, время, длительность, исход, SHA-256
     4	записи, ключевые реплики транскрипта.
     5	"""
     6	from __future__ import annotations
     7	
     8	import datetime as dt
     9	import hashlib
    10	import json
    11	from dataclasses import asdict, dataclass
    12	from pathlib import Path
    13	from typing import Any
    14	
    15	
    16	@dataclass
    17	class DeliveryProof:
    18	    campaign_id: str
    19	    contact_id: str
    20	    org_name: str
    21	    phone: str
    22	    account: str | None
    23	    started_at: str
    24	    duration_sec: int
    25	    outcome: str
    26	    legal_basis: str
    27	    recording_path: str | None
    28	    recording_sha256: str | None
    29	    transcript: list[dict[str, Any]]
    30	    identity_confirmed: bool
    31	
    32	    @property
    33	    def delivered(self) -> bool:
    34	        """Доставка считается состоявшейся, если абонент подтвердил личность и услышал суть."""
    35	        return self.identity_confirmed and self.outcome not in (
    36	            "amd_machine", "no_answer", "busy", "failed", "wrong_number", "no_understanding"
    37	        )
    38	
    39	    def to_json(self) -> str:
    40	        return json.dumps(asdict(self) | {"delivered": self.delivered}, ensure_ascii=False, indent=2)
    41	
    42	    def to_text(self) -> str:
    43	        lines = [
    44	            "АКТ О НАПРАВЛЕНИИ УВЕДОМЛЕНИЯ ГОЛОСОВЫМ ЗВОНКОМ С ЗАПИСЬЮ РАЗГОВОРА",
    45	            "(ПП РФ № 354 п. 119 пп. «а»; письмо Минстроя России от 23.12.2025 № 79938-ДН/04)",
    46	            "",
    47	            f"Исполнитель (кредитор): {self.org_name}",
    48	            f"Лицевой счёт: {self.account or '—'}",
    49	            f"Телефон абонента: {self.phone}",
    50	            f"Дата и время звонка: {self.started_at}",
    51	            f"Длительность: {self.duration_sec} с",
    52	            f"Личность подтверждена абонентом: {'да' if self.identity_confirmed else 'нет'}",
    53	            f"Исход: {self.outcome}",
    54	            f"Уведомление доставлено: {'ДА' if self.delivered else 'НЕТ'}",
    55	            f"Правовое основание: {self.legal_basis}",
    56	            f"Файл записи: {self.recording_path or '—'}",
    57	            f"SHA-256 записи: {self.recording_sha256 or '—'}",
    58	            "",
    59	            "Ключевые реплики:",
    60	        ]
    61	        for t in self.transcript:
    62	            if t.get("text"):
    63	                lines.append(f"  [{t.get('role')}] {t['text']}")
    64	        lines += ["", f"Сформировано Siberia.Dialog {dt.datetime.now().isoformat(timespec='seconds')}"]
    65	        return "\n".join(lines)
    66	
    67	
    68	def sha256_file(path: Path) -> str:
    69	    h = hashlib.sha256()
    70	    with open(path, "rb") as f:
    71	        for chunk in iter(lambda: f.read(1 << 20), b""):
    72	            h.update(chunk)
    73	    return h.hexdigest()
    74	
    75	
    76	def build_proof(
    77	    *,
    78	    campaign_id: str,
    79	    contact_id: str,
    80	    org_name: str,
    81	    phone: str,
    82	    account: str | None,
    83	    started_at: dt.datetime,
    84	    duration_sec: int,
    85	    outcome: str,
    86	    legal_basis: str,
    87	    recording: Path | None,
    88	    transcript: list[dict[str, Any]],
    89	    identity_confirmed: bool,
    90	) -> DeliveryProof:
    91	    return DeliveryProof(
    92	        campaign_id=campaign_id,
    93	        contact_id=contact_id,
    94	        org_name=org_name,
    95	        phone=phone,
    96	        account=account,
    97	        started_at=started_at.isoformat(timespec="seconds"),
    98	        duration_sec=duration_sec,
    99	        outcome=outcome,
   100	        legal_basis=legal_basis,
   101	        recording_path=str(recording) if recording else None,
   102	        recording_sha256=sha256_file(recording) if recording and recording.exists() else None,
   103	        transcript=transcript,
   104	        identity_confirmed=identity_confirmed,
   105	    )
===== src/siberia_dialog/compliance/retention.py =====
     1	"""Ретенция ПДн: транскрипты, слоты, записи разговоров, реестры показаний и вопросы к базе
     2	знаний старше RETENTION_DAYS обезличиваются/удаляются. Запускается cron-задачей воркера
     3	ежедневно.
     4	
     5	Расшифровки речевой аналитики уходят по тому же сроку: текст реплики содержит ФИО,
     6	адреса и лицевые счета. Числовые метрики при этом остаются — персональных данных в
     7	них нет, и отчёт о качестве за прошлый год не должен исчезать вместе с ними.
     8	"""
     9	from __future__ import annotations
    10	
    11	import datetime as dt
    12	import json
    13	import logging
    14	from pathlib import Path
    15	from typing import Any, cast
    16	
    17	from sqlalchemy import CursorResult, delete, select
    18	
    19	from ..db import AnalyticsCall, AnalyticsSegment, Attempt, AuditLog, KbQuery, Reading, session
    20	from ..settings import settings
    21	
    22	log = logging.getLogger("retention")
    23	
    24	
    25	async def purge(days: int | None = None, now: dt.datetime | None = None) -> dict[str, int]:
    26	    days = days or settings.retention_days
    27	    now = now or dt.datetime.now(dt.UTC)
    28	    cutoff = now - dt.timedelta(days=days)
    29	    stats = {"attempts_anonymized": 0, "recordings_deleted": 0, "readings_deleted": 0, "audit_deleted": 0, "jsonl_trimmed": 0,
    30	             "kb_queries_deleted": 0,
    31	             "analytics_calls_purged": 0, "analytics_segments_deleted": 0, "analytics_audio_deleted": 0}
    32	    async with session() as db:
    33	        old = (await db.scalars(select(Attempt).where(Attempt.started_at < cutoff, Attempt.transcript != []))).all()
    34	        for a in old:
    35	            if a.recording_path:
    36	                p = Path(a.recording_path)
    37	                if p.exists():
    38	                    p.unlink()
    39	                    stats["recordings_deleted"] += 1
    40	            a.transcript = []
    41	            a.slots = {k: v for k, v in (a.slots or {}).items() if k in ("promise_date", "debt_amount")}
    42	            a.recording_path = None
    43	            stats["attempts_anonymized"] += 1
    44	        r = cast(CursorResult[Any], await db.execute(delete(Reading).where(Reading.ts < cutoff)))
    45	        stats["readings_deleted"] = r.rowcount
    46	        # Вопрос к базе знаний задаёт человек — это персональные данные, и хранятся они
    47	        # по тому же сроку, что транскрипты. Сами документы заказчика не трогаем: это его
    48	        # база, а не журнал обращений.
    49	        r = cast(CursorResult[Any], await db.execute(delete(KbQuery).where(KbQuery.ts < cutoff)))
    50	        stats["kb_queries_deleted"] = r.rowcount
    51	        r = cast(CursorResult[Any], await db.execute(delete(AuditLog).where(AuditLog.ts < cutoff - dt.timedelta(days=365))))  # аудит храним вдвое дольше
    52	        stats["audit_deleted"] = r.rowcount
    53	        stats.update(await _purge_analytics(db, cutoff, now))
    54	        await db.commit()
    55	    stats["jsonl_trimmed"] = _trim_jsonl(cutoff)
    56	    stats["media_deleted"] = _purge_media(cutoff)
    57	    log.info("retention purge: %s", stats)
    58	    return stats
    59	
    60	
    61	async def _purge_analytics(db: Any, cutoff: dt.datetime, now: dt.datetime) -> dict[str, int]:
    62	    """Расшифровки и рабочие копии записей. Таблица метрик не трогается намеренно."""
    63	    out = {"analytics_calls_purged": 0, "analytics_segments_deleted": 0, "analytics_audio_deleted": 0}
    64	    calls = (await db.scalars(select(AnalyticsCall).where(
    65	        AnalyticsCall.created_at < cutoff, AnalyticsCall.purged_at.is_(None)))).all()
    66	    if not calls:
    67	        return out
    68	    ids = [c.id for c in calls]
    69	    r = cast(CursorResult[Any], await db.execute(
    70	        delete(AnalyticsSegment).where(AnalyticsSegment.call_id.in_(ids))))
    71	    out["analytics_segments_deleted"] = r.rowcount
    72	    for c in calls:
    73	        if c.source_path and not c.attempt_id:
    74	            # Записи собственных кампаний удаляются вместе с попыткой, выше.
    75	            p = Path(c.source_path)
    76	            if p.exists():
    77	                p.unlink()
    78	                out["analytics_audio_deleted"] += 1
    79	        c.source_path = None
    80	        c.source_name = None
    81	        c.phone = None
    82	        c.text = None
    83	        c.speech = []
    84	        c.status = "purged"
    85	        c.purged_at = now
    86	        out["analytics_calls_purged"] += 1
    87	    return out
    88	
    89	
    90	def _purge_media(cutoff: dt.datetime) -> int:
    91	    """Фото абонентов — персональные данные и удаляются по тому же сроку.
    92	
    93	    Файл именован хешем содержимого и метки времени в себе не несёт, поэтому
    94	    ориентируемся на время последнего изменения.
    95	    """
    96	    root = Path(settings.data_dir) / "customers"
    97	    n = 0
    98	    if not root.exists():
    99	        return n
   100	    for p in root.glob("*/media/*"):
   101	        try:
   102	            if dt.datetime.fromtimestamp(p.stat().st_mtime, dt.UTC) < cutoff:
   103	                p.unlink()
   104	                n += 1
   105	        except OSError as e:
   106	            log.warning("фото %s не удалено: %s", p.name, e)
   107	    return n
   108	
   109	
   110	def _trim_jsonl(cutoff: dt.datetime) -> int:
   111	    root = settings.data_dir / "customers"
   112	    n = 0
   113	    if not root.exists():
   114	        return 0
   115	    for p in root.glob("*/readings.jsonl"):
   116	        keep = []
   117	        for line in p.read_text(encoding="utf-8").splitlines():
   118	            try:
   119	                ts = dt.datetime.fromisoformat(json.loads(line)["ts"])
   120	                if ts.tzinfo is None:
   121	                    ts = ts.replace(tzinfo=dt.UTC)
   122	            except (ValueError, KeyError, json.JSONDecodeError):
   123	                keep.append(line)
   124	                continue
   125	            if ts >= cutoff:
   126	                keep.append(line)
   127	            else:
   128	                n += 1
   129	        p.write_text("\n".join(keep) + ("\n" if keep else ""), encoding="utf-8")
   130	    return n
===== src/siberia_dialog/compliance/windows.py =====
     1	"""Окна времени для звонков по **местному времени контакта** (230-ФЗ ст. 7 ч. 3):
     2	будни 08:00–22:00, выходные и праздники 09:00–20:00.
     3	
     4	Праздники — производственный календарь РФ; передаются списком дат (обновлять ежегодно,
     5	источник: ПП РФ о переносе выходных / consultant.ru «Производственный календарь»).
     6	"""
     7	from __future__ import annotations
     8	
     9	import datetime as dt
    10	from dataclasses import dataclass, field
    11	from zoneinfo import ZoneInfo
    12	
    13	# Нерабочие праздничные дни 2026 (ст. 112 ТК РФ + переносы по ПП РФ). Обновить на 2027.
    14	HOLIDAYS_2026: frozenset[dt.date] = frozenset(
    15	    [
    16	        *[dt.date(2026, 1, d) for d in range(1, 9)],
    17	        dt.date(2026, 2, 23),
    18	        dt.date(2026, 3, 9),  # перенос 8 марта (воскресенье)
    19	        dt.date(2026, 5, 1),
    20	        dt.date(2026, 5, 11),  # перенос 9 мая (суббота)
    21	        dt.date(2026, 6, 12),
    22	        dt.date(2026, 11, 4),
    23	        dt.date(2026, 12, 31),
    24	    ]
    25	)
    26	
    27	
    28	def _parse(window: str) -> tuple[dt.time, dt.time]:
    29	    a, b = window.split("-")
    30	    return dt.time.fromisoformat(a), dt.time.fromisoformat(b)
    31	
    32	
    33	@dataclass(frozen=True)
    34	class CallWindows:
    35	    weekday: tuple[dt.time, dt.time] = (dt.time(8, 0), dt.time(22, 0))
    36	    weekend: tuple[dt.time, dt.time] = (dt.time(9, 0), dt.time(20, 0))
    37	    holidays: frozenset[dt.date] = field(default_factory=lambda: HOLIDAYS_2026)
    38	
    39	    @classmethod
    40	    def from_strings(cls, weekday: str, weekend: str) -> CallWindows:
    41	        return cls(_parse(weekday), _parse(weekend))
    42	
    43	    def window_for(self, day: dt.date) -> tuple[dt.time, dt.time]:
    44	        if day.weekday() >= 5 or day in self.holidays:
    45	            return self.weekend
    46	        return self.weekday
    47	
    48	
    49	def in_window(now_utc: dt.datetime, tz: str, windows: CallWindows | None = None) -> bool:
    50	    windows = windows or CallWindows()
    51	    local = now_utc.astimezone(ZoneInfo(tz))
    52	    start, end = windows.window_for(local.date())
    53	    return start <= local.time() < end
    54	
    55	
    56	def next_window_start(now_utc: dt.datetime, tz: str, windows: CallWindows | None = None) -> dt.datetime:
    57	    """Ближайший момент (UTC), когда звонить можно. Если уже можно — now."""
    58	    windows = windows or CallWindows()
    59	    zone = ZoneInfo(tz)
    60	    local = now_utc.astimezone(zone)
    61	    for offset in range(8):
    62	        day = local.date() + dt.timedelta(days=offset)
    63	        start, end = windows.window_for(day)
    64	        candidate = dt.datetime.combine(day, start, tzinfo=zone)
    65	        if offset == 0 and start <= local.time() < end:
    66	            return now_utc
    67	        if candidate > local:
    68	            return candidate.astimezone(dt.UTC)
    69	    return now_utc  # недостижимо при корректных окнах
===== src/siberia_dialog/licensing.py =====
     1	"""Лицензирование: подписанный файл лицензии (Ed25519) и сведения «О программе».
     2	
     3	Файл `license.json`: {"licensee": "...", "inn": "...", "valid_until": "2027-12-31",
     4	"limits": {"voice_lines": 50, "chat_channels": 4}, "signature": "<base64>"} — подпись по
     5	каноническому JSON без поля signature закрытым ключом правообладателя (хранится в РФ).
     6	Отсутствие файла = режим демонстрации (ограничения: 2 линии, 1 канал) — ПО работает,
     7	но в `/about` статус «демо». Это система управления лицензионными ключами по п. 5 ПП 1236.
     8	"""
     9	from __future__ import annotations
    10	
    11	import base64
    12	import datetime as dt
    13	import json
    14	from dataclasses import asdict, dataclass
    15	from pathlib import Path
    16	from typing import Any
    17	
    18	from cryptography.exceptions import InvalidSignature
    19	from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
    20	
    21	from . import __version__
    22	from .settings import settings
    23	
    24	# Открытый ключ правообладателя (ООО «Финансовый искусственный интеллект»). Заменить на боевой перед релизом.
    25	PUBLIC_KEY_B64 = "E0yCKc2atbZpIBYLO2I6oOJDUABwxu5dXrIlzPa7c4c="
    26	
    27	RIGHTSHOLDER = {
    28	    "name": "ООО «Финансовый искусственный интеллект» (Siberia AI)",
    29	    "inn": "5402076457",
    30	    "kpp": "547301001",
    31	    "ogrn": "1235400001870",
    32	    "address": "630090, г. Новосибирск, ул. Демакова, д. 23/5",
    33	    "site": "https://siberia-ai.ru",
    34	    "support": "support@siberia-ai.ru, +7 (383) 288-82-82",
    35	}
    36	DEMO_LIMITS = {"voice_lines": 2, "chat_channels": 1}
    37	
    38	
    39	@dataclass
    40	class License:
    41	    licensee: str
    42	    inn: str
    43	    valid_until: str
    44	    limits: dict[str, int]
    45	    status: str  # valid | expired | invalid | demo
    46	
    47	    @property
    48	    def active(self) -> bool:
    49	        return self.status == "valid"
    50	
    51	
    52	def _canonical(d: dict[str, Any]) -> bytes:
    53	    return json.dumps({k: v for k, v in d.items() if k != "signature"}, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
    54	
    55	
    56	def sign_license(data: dict[str, Any], private_key_b64: str) -> dict[str, Any]:
    57	    key = Ed25519PrivateKey.from_private_bytes(base64.b64decode(private_key_b64))
    58	    sig = key.sign(_canonical(data))
    59	    return {**data, "signature": base64.b64encode(sig).decode()}
    60	
    61	
    62	def verify_license(data: dict[str, Any], public_key_b64: str = PUBLIC_KEY_B64, today: dt.date | None = None) -> License:
    63	    today = today or dt.date.today()
    64	    try:
    65	        pub = Ed25519PublicKey.from_public_bytes(base64.b64decode(public_key_b64))
    66	        pub.verify(base64.b64decode(data.get("signature", "")), _canonical(data))
    67	    except (InvalidSignature, ValueError, KeyError):
    68	        return License(data.get("licensee", "?"), data.get("inn", ""), data.get("valid_until", ""), DEMO_LIMITS, "invalid")
    69	    until = dt.date.fromisoformat(data["valid_until"])
    70	    status = "valid" if until >= today else "expired"
    71	    return License(data["licensee"], data.get("inn", ""), data["valid_until"], data.get("limits", {}), status)
    72	
    73	
    74	def load_license(path: Path | None = None) -> License:
    75	    path = path or settings.license_path
    76	    if not path.exists():
    77	        return License("демонстрационный режим", "", "", DEMO_LIMITS, "demo")
    78	    try:
    79	        return verify_license(json.loads(path.read_text(encoding="utf-8")))
    80	    except (ValueError, OSError):
    81	        return License("?", "", "", DEMO_LIMITS, "invalid")
    82	
    83	
    84	def generate_keypair() -> tuple[str, str]:
    85	    """Возвращает (private_b64, public_b64) — выполнить один раз, закрытый ключ хранить офлайн."""
    86	    from cryptography.hazmat.primitives import serialization
    87	
    88	    priv = Ed25519PrivateKey.generate()
    89	    pb = priv.private_bytes(serialization.Encoding.Raw, serialization.PrivateFormat.Raw, serialization.NoEncryption())
    90	    pub = priv.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
    91	    return base64.b64encode(pb).decode(), base64.b64encode(pub).decode()
    92	
    93	
    94	def about_info() -> dict[str, Any]:
    95	    lic = load_license()
    96	    return {
    97	        "product": "Siberia.Dialog",
    98	        "version": __version__,
    99	        "rightsholder": RIGHTSHOLDER,
   100	        "license": asdict(lic),
   101	        "third_party": "см. LICENSE-THIRD-PARTY.md в составе дистрибутива",
   102	        "registry_class": "05.09 Средства управления диалоговыми роботами",
   103	    }
