در طراحی تلفن گویا، مسیر انتخاب درست تنها بخشی از مسئله است. اگر کاربر گزینه‌ای خارج از منو وارد کند یا رویداد نبود ورودی گزارش شود، برنامه باید رفتار مشخصی داشته باشد. در این درس، دو منوی فرضی را با ماشین حالت پایتون مدل می‌کنیم و سیاست تلاش مجدد را می‌آزماییم. همهٔ ورودی‌ها از قبل ساخته شده‌اند؛ تماس، صوت، تشخیص گفتار، زمان‌سنج یا مرکز تلفن واقعی اجرا نمی‌شود و خروجی، گزارش کارکرد محصول نیست.

۱. رویداد ورودی را از خطای برنامه جدا کنیم

تفکیک رویداد رقم، نبود ورودی و پایان تماس در مدل
تصویر مفهومی: تفکیک رویداد رقم، نبود ورودی و پایان تماس در مدل؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

سه نوع رویداد داریم: DIGIT برای یک رقم، NOINPUT برای نبود ورودی و HANGUP برای اعلام پایان از سمت تماس‌گیرندهٔ فرضی. رقم باید دقیقاً یک نویسهٔ ASCII از صفر تا نه باشد. رشتهٔ چندرقمی، رقم فارسی، ستاره و مربع در قرارداد این تمرین پذیرفته نیستند. این محدودیت، تعریف همهٔ کلیدهای فیزیکی تلفن نیست؛ ما فقط ورودیِ از قبل تفسیرشدهٔ همین مدل را بررسی می‌کنیم.

رقم نه می‌تواند از نظر شکل رویداد معتبر باشد، ولی گزینهٔ هیچ‌کدام از منوهای ما نباشد؛ آن را عدم تطبیق با منو حساب می‌کنیم. در مقابل، فرستادن رشتهٔ «۱۲» به سازندهٔ رویداد، خطای فراخواننده است و استثنا می‌دهد. این دو وضعیت را یکی نمی‌گیریم تا خطای اتصال کد، بی‌صدا به حساب انتخاب اشتباه کاربر گذاشته نشود. هیچ تبدیل خودکاری میان رقم فارسی و ASCII انجام نمی‌شود.

VoiceXML 2.0 میان رویدادهای noinput و nomatch تفاوت می‌گذارد. در برنامهٔ حاضر، NOINPUT را خودِ جدول آزمایش می‌سازد؛ سکوت صوتی یا پایان زمان انتظار تشخیص داده نمی‌شود. بنابراین نبود این رویداد در فهرست ورودی، هیچ تصمیم خودکاری ایجاد نمی‌کند. پیش از وصل کردن مدل به تلفن، تولید رویداد، زمان‌بندی و سیاست تشخیص آن باید جداگانه تعریف و آزمایش شوند.

۲. دو منو و سهمیهٔ روشنِ تلاش ناموفق

سهمیهٔ محدود خطا و خروج از تکرار منو
تصویر مفهومی: سهمیهٔ محدود خطا و خروج از تکرار منو؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

در منوی MAIN، رقم یک به منوی فنی TECH و رقم دو به درخواست مسیر حسابداری می‌رسد. در منوی فنی، یک درخواست سخت‌افزار و دو درخواست نرم‌افزار را انتخاب می‌کنند. رقم صفر در هر دو منو درخواست کارشناس است. نام مقصدها فقط برچسب مثال‌اند؛ داخلی واقعی، مشتری یا سازمانی پشت آن‌ها وجود ندارد. بازگشت به منوی قبل و ورود چندرقمی در این نسخه پیاده نشده‌اند.

برای هر منو، سهمیهٔ سه ورودی ناموفق انتخاب کرده‌ایم. نبود ورودی و رقمِ بدون گزینه، در همین سیاست تألیفی از یک شمارندهٔ مشترک مصرف می‌کنند. پس از شکست اول و دوم، برچسب بازپرسش متناسب برمی‌گردد؛ شکست سوم درخواست کارشناس می‌دهد. این یعنی دو بار دعوت به تلاش دوباره، نه سه تکرار اضافی پس از نخستین خطا. عدد سه را استاندارد اجباری یا بهترین انتخاب همهٔ سامانه‌ها معرفی نمی‌کنیم.

با ورود موفق از MAIN به TECH، شمارنده صفر می‌شود و سهمیهٔ منوی تازه آغاز می‌شود. این قرارداد را نباید همان شمارنده‌های VoiceXML دانست؛ آن استاندارد برای نوع رویداد و دامنهٔ اجرا قواعد خودش را دارد. مقالهٔ طراحی مسیر تماس در تلفن گویا تصویر کلی تعامل را توضیح می‌دهد. تمرکز فعلی، تبدیل یک سیاست محدود و مشخص به رفتار قابل آزمون است.

۳. کد کامل ماشین حالت و هجده بررسی

آزمون آفلاین ماشین حالت تلفن گویا در پایتون
تصویر مفهومی: آزمون آفلاین ماشین حالت تلفن گویا در پایتون؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

کد را با نام ivr_invalid_input.py ذخیره و دستور python ivr_invalid_input.py را اجرا کنید. مثال با پایتون ۳٫۱۴٫۵ و کتابخانهٔ استاندارد اجرا شده است. تابع step یک وضعیت و یک رویداد می‌گیرد و وضعیت بعدی همراه برچسب اقدام را برمی‌گرداند. آن برچسب فقط داده است؛ بازپرسش پخش نمی‌کند، شماره‌ای نمی‌گیرد و هیچ انتقال واقعی را آغاز نمی‌کند.

"""Pure authored IVR decision model; no telephone, clock, audio or PBX action."""
from dataclasses import dataclass, FrozenInstanceError
from enum import Enum
import json
import platform

MAX_FAILURES = 3


class Stage(Enum):
    MAIN = 'main'
    TECH = 'tech'
    ROUTED = 'route_requested'
    ENDED = 'ended'


class Kind(Enum):
    DIGIT = 'digit'
    NOINPUT = 'noinput'
    HANGUP = 'hangup'


@dataclass(frozen=True)
class Event:
    kind: Kind
    digit: str | None = None

    def __post_init__(self):
        if type(self.kind) is not Kind:
            raise ValueError('An explicit Kind is required')
        if self.kind is Kind.DIGIT:
            if type(self.digit) is not str or len(self.digit) != 1 or self.digit not in '0123456789':
                raise ValueError('Exactly one ASCII digit required')
        elif self.digit is not None:
            raise ValueError('Non-digit events must not contain a digit')


@dataclass(frozen=True)
class State:
    stage: Stage = Stage.MAIN
    failures: int = 0
    route: str | None = None
    reason: str | None = None

    def __post_init__(self):
        if type(self.stage) is not Stage or type(self.failures) is not int or not 0 <= self.failures <= MAX_FAILURES:
            raise ValueError('Invalid stage or failure counter')
        if self.stage in (Stage.MAIN, Stage.TECH):
            valid = self.failures < MAX_FAILURES and self.route is None and self.reason is None
        elif self.stage is Stage.ROUTED:
            valid = ((self.reason == 'retry_budget' and self.route == 'operator' and self.failures == MAX_FAILURES)
                     or (self.reason == 'operator_request' and self.route == 'operator' and self.failures == 0)
                     or (self.reason == 'valid_selection' and self.route in ('billing', 'hardware', 'software') and self.failures == 0))
        else:
            valid = self.failures == 0 and self.route is None and self.reason == 'caller_hangup'
        if not valid:
            raise ValueError('Fields do not describe a reachable authored state')


def step(state, event):
    if type(state) is not State or type(event) is not Event:
        raise TypeError('State and Event instances required')
    if state.stage in (Stage.ROUTED, Stage.ENDED):
        return state, 'ignore_terminal'
    if event.kind is Kind.HANGUP:
        return State(Stage.ENDED, reason='caller_hangup'), 'record_end'
    if event.kind is Kind.DIGIT:
        if event.digit == '0':
            return State(Stage.ROUTED, route='operator', reason='operator_request'), 'request_operator'
        if state.stage is Stage.MAIN and event.digit == '1':
            return State(Stage.TECH), 'enter_tech'
        routes = {'2': 'billing'} if state.stage is Stage.MAIN else {'1': 'hardware', '2': 'software'}
        if event.digit in routes:
            route = routes[event.digit]
            return State(Stage.ROUTED, route=route, reason='valid_selection'), 'request_' + route
    failures = state.failures + 1
    if failures == MAX_FAILURES:
        return State(Stage.ROUTED, failures, 'operator', 'retry_budget'), 'request_operator_retry_budget'
    action = 'reprompt_noinput' if event.kind is Kind.NOINPUT else 'reprompt_nomatch'
    return State(state.stage, failures), action


def as_record(state):
    return {'stage': state.stage.value, 'failures': state.failures, 'route': state.route, 'reason': state.reason}


def scenario(events):
    state = State()
    trace = []
    for event in events:
        state, action = step(state, event)
        trace.append({'event': event.kind.value, 'digit': event.digit, 'action': action, 'state': as_record(state)})
    return state, trace


def rejects(call, exception=ValueError):
    try:
        call()
    except exception:
        return True
    return False


def run_example():
    digit = lambda value: Event(Kind.DIGIT, value)
    noinput = Event(Kind.NOINPUT)
    hangup = Event(Kind.HANGUP)
    main, tech = State(), State(Stage.TECH)
    fallback, fallback_trace = scenario((digit('9'), noinput, digit('8')))
    final, trace = scenario((digit('9'), noinput, digit('1'), digit('8'), noinput, digit('9'), digit('2')))
    reset = step(State(Stage.MAIN, 2), digit('1'))[0]
    ended = step(main, hangup)[0]
    checks = {
        'main_one_enters_tech': step(main, digit('1')) == (tech, 'enter_tech'),
        'main_two_requests_billing': step(main, digit('2'))[0].route == 'billing',
        'tech_one_requests_hardware': step(tech, digit('1'))[0].route == 'hardware',
        'tech_two_requests_software': step(tech, digit('2'))[0].route == 'software',
        'zero_requests_operator_from_both_menus': all(step(s, digit('0'))[0].route == 'operator' for s in (main, tech)),
        'nomatch_and_noinput_have_distinct_reprompt_actions': step(main, digit('9'))[1] == 'reprompt_nomatch' and step(main, noinput)[1] == 'reprompt_noinput',
        'mixed_third_failure_requests_fallback': as_record(fallback) == {'stage': 'route_requested', 'failures': 3, 'route': 'operator', 'reason': 'retry_budget'},
        'exactly_two_reprompts_before_third_failure': [r['action'] for r in fallback_trace] == ['reprompt_nomatch', 'reprompt_noinput', 'request_operator_retry_budget'],
        'three_noinputs_use_same_authored_budget': scenario((noinput,) * 3)[0] == fallback,
        'entering_new_menu_resets_failure_counter': reset.stage is Stage.TECH and reset.failures == 0,
        'two_menu_trace_has_expected_actions_and_counters': [r['action'] for r in trace] == ['reprompt_nomatch', 'reprompt_noinput', 'enter_tech', 'reprompt_nomatch', 'reprompt_noinput', 'request_operator_retry_budget', 'ignore_terminal']
                                                         and [r['state']['failures'] for r in trace] == [1, 2, 0, 1, 2, 3, 3],
        'terminal_route_is_not_reissued': step(final, digit('2')) == (final, 'ignore_terminal'),
        'hangup_ends_both_active_menus_without_route': all(step(s, hangup)[0] == ended and ended.route is None for s in (main, tech)),
        'events_after_end_are_ignored': step(ended, noinput) == (ended, 'ignore_terminal'),
        'malformed_digit_events_rejected_before_transition': all(rejects(lambda value=value: digit(value)) for value in ('', '12', '۱', '*', '#', 1)),
        'event_kind_and_payload_contract_rejected': rejects(lambda: Event('digit', '1')) and rejects(lambda: Event(Kind.NOINPUT, '1')),
        'unreachable_state_and_wrong_step_types_rejected': rejects(lambda: State(Stage.MAIN, 3)) and rejects(lambda: State(Stage.MAIN, True))
                                                          and rejects(lambda: step(main, '9'), TypeError),
        'transition_does_not_mutate_input_state': main == State() and rejects(lambda: setattr(main, 'failures', 2), FrozenInstanceError),
    }
    assert all(checks.values()), checks
    return {'python': platform.python_version(), 'model': 'authored_offline_ivr_failure_policy',
            'max_failures_per_menu': MAX_FAILURES, 'event_types': [kind.value for kind in Kind],
            'fallback_trace': fallback_trace, 'two_menu_trace': trace,
            'final_state': as_record(final), 'actual_call_audio_timer_or_pbx_used': False,
            'checks': checks}


if __name__ == '__main__':
    print(json.dumps(run_example(), indent=2))

برای نام حالت‌ها و رویدادها از Enum پایتون استفاده کرده‌ایم تا قرارداد خوانا باشد. بررسی شکل رویداد و ترکیب فیلدهای وضعیت را صریح نوشته‌ایم؛ نام‌گذاری به تنهایی قواعد تجاری را تولید نمی‌کند. روش post-init در dataclasses محل اجرای همین شرط‌های سازنده است. نوشتن راهنمای نوع کنار فیلد، جای این بررسی‌ها را نمی‌گیرد.

وضعیت ورودی در step تغییر داده نمی‌شود و برای گذارهای فعال، نمونهٔ بعدی ساخته می‌شود. گزینهٔ frozen نیز انتساب عادی به فیلد را رد می‌کند، اما مرز امنیتی یا تضمین تغییرناپذیری عمیق نیست. هجده بررسی، انتخاب مقصدها، شمارندهٔ مشترک، بازنشانی منو، مسیر پایانی، شکل نامعتبر رویداد و حفظ ورودی را پوشش می‌دهند. موفقیت این موارد، ارزیابی کامل تمام سامانه‌های IVR یا اثبات درستی همهٔ ورودی‌های ممکن نیست.

۴. خواندن رد گذار و کنترل حالت پایانی

شروع شمارندهٔ تازه هنگام ورود به منوی جدید
تصویر مفهومی: شروع شمارندهٔ تازه هنگام ورود به منوی جدید؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

در سناریوی نخست، رقم نه، نبود ورودی و سپس رقم هشت، شمارنده را به ترتیب به یک، دو و سه می‌رسانند. دو برچسب نخست متفاوت‌اند، ولی هر دو سهمیه را مصرف می‌کنند. در رویداد سوم، وضعیت به route_requested با مقصد operator می‌رسد. این نام عمداً درخواست مسیر را نشان می‌دهد؛ پاسخ دادن کارشناس، ورود تماس به صف یا موفق شدن انتقال از آن نتیجه گرفته نمی‌شود.

{
  "python": "3.14.5",
  "model": "authored_offline_ivr_failure_policy",
  "max_failures_per_menu": 3,
  "event_types": [
    "digit",
    "noinput",
    "hangup"
  ],
  "fallback_trace": [
    {
      "event": "digit",
      "digit": "9",
      "action": "reprompt_nomatch",
      "state": {
        "stage": "main",
        "failures": 1,
        "route": null,
        "reason": null
      }
    },
    {
      "event": "noinput",
      "digit": null,
      "action": "reprompt_noinput",
      "state": {
        "stage": "main",
        "failures": 2,
        "route": null,
        "reason": null
      }
    },
    {
      "event": "digit",
      "digit": "8",
      "action": "request_operator_retry_budget",
      "state": {
        "stage": "route_requested",
        "failures": 3,
        "route": "operator",
        "reason": "retry_budget"
      }
    }
  ],
  "two_menu_trace": [
    {
      "event": "digit",
      "digit": "9",
      "action": "reprompt_nomatch",
      "state": {
        "stage": "main",
        "failures": 1,
        "route": null,
        "reason": null
      }
    },
    {
      "event": "noinput",
      "digit": null,
      "action": "reprompt_noinput",
      "state": {
        "stage": "main",
        "failures": 2,
        "route": null,
        "reason": null
      }
    },
    {
      "event": "digit",
      "digit": "1",
      "action": "enter_tech",
      "state": {
        "stage": "tech",
        "failures": 0,
        "route": null,
        "reason": null
      }
    },
    {
      "event": "digit",
      "digit": "8",
      "action": "reprompt_nomatch",
      "state": {
        "stage": "tech",
        "failures": 1,
        "route": null,
        "reason": null
      }
    },
    {
      "event": "noinput",
      "digit": null,
      "action": "reprompt_noinput",
      "state": {
        "stage": "tech",
        "failures": 2,
        "route": null,
        "reason": null
      }
    },
    {
      "event": "digit",
      "digit": "9",
      "action": "request_operator_retry_budget",
      "state": {
        "stage": "route_requested",
        "failures": 3,
        "route": "operator",
        "reason": "retry_budget"
      }
    },
    {
      "event": "digit",
      "digit": "2",
      "action": "ignore_terminal",
      "state": {
        "stage": "route_requested",
        "failures": 3,
        "route": "operator",
        "reason": "retry_budget"
      }
    }
  ],
  "final_state": {
    "stage": "route_requested",
    "failures": 3,
    "route": "operator",
    "reason": "retry_budget"
  },
  "actual_call_audio_timer_or_pbx_used": false,
  "checks": {
    "main_one_enters_tech": true,
    "main_two_requests_billing": true,
    "tech_one_requests_hardware": true,
    "tech_two_requests_software": true,
    "zero_requests_operator_from_both_menus": true,
    "nomatch_and_noinput_have_distinct_reprompt_actions": true,
    "mixed_third_failure_requests_fallback": true,
    "exactly_two_reprompts_before_third_failure": true,
    "three_noinputs_use_same_authored_budget": true,
    "entering_new_menu_resets_failure_counter": true,
    "two_menu_trace_has_expected_actions_and_counters": true,
    "terminal_route_is_not_reissued": true,
    "hangup_ends_both_active_menus_without_route": true,
    "events_after_end_are_ignored": true,
    "malformed_digit_events_rejected_before_transition": true,
    "event_kind_and_payload_contract_rejected": true,
    "unreachable_state_and_wrong_step_types_rejected": true,
    "transition_does_not_mutate_input_state": true
  }
}

سناریوی دوم هفت رویداد دارد. پس از دو خطا در MAIN، رقم یک به TECH می‌رود و شمارنده صفر می‌شود؛ سه خطای منوی فنی سپس مسیر جایگزین را درخواست می‌کنند. دنبالهٔ شمارنده‌ها یک، دو، صفر، یک، دو، سه و سه است. آخرین رقم دو، یک رویداد معتبرِ دیرتر است که پس از رسیدن مدل به حالت پایانی، نادیده گرفته می‌شود و درخواست دیگری صادر نمی‌کند.

HANGUP در منوی فعال، وضعیت ended را بدون مقصد می‌سازد. بعد از ended یا route_requested، رویدادهای معتبرِ بعدی برچسب ignore_terminal دارند و وضعیت ثابت می‌ماند. این رفتار تنها همین تابع تصمیم‌گیر را محدود می‌کند؛ تکرار نشدن اقدام در سامانهٔ خارجی به طراحی لایهٔ اجرای اقدام هم وابسته است. اگر فراخواننده همان درخواست خروجی را چند بار به مرکز تلفن بدهد، این مدل به تنهایی جلوی آن را نمی‌گیرد.

۵. تمرین‌های پاسخ‌دار و مرز اتصال به صوت

جداسازی درخواست مسیر و پایان مدل از انتقال تماس واقعی
تصویر مفهومی: جداسازی درخواست مسیر و پایان مدل از انتقال تماس واقعی؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

تمرین اول: چرا رقم نه خطای شکل ورودی نیست؟ پاسخ: یک رقم ASCII معتبر است، ولی در منوی فعلی گزینه ندارد؛ بنابراین عدم تطبیق، شمارنده را زیاد می‌کند. تمرین دوم: اگر پس از دو خطا وارد TECH شویم، چند شکست دیگر تا مسیر جایگزین فرصت داریم؟ پاسخ: شمارندهٔ منوی تازه صفر است؛ شکست سوم آن منو مسیر جایگزین می‌دهد و دو شکست پیشین از MAIN منتقل نمی‌شوند.

تمرین سوم: اگر هیچ رویدادی به مدل ندهیم، آیا پس از چند ثانیه NOINPUT تولید می‌شود؟ پاسخ: خیر؛ ساعت و زمان‌سنج نداریم و این رویداد باید از لایهٔ دیگری بیاید. تمرین چهارم: آیا route_requested یعنی کارشناس پاسخ داده است؟ پاسخ: خیر؛ فقط تصمیم مسیر ثبت شده و نتیجهٔ انتقال واقعی باید در آزمون دیگری بررسی شود.

تشخیص رقم از صوت نیز مسئولیتی جداست؛ آزمایش DTMF با Goertzel یک نمونهٔ پردازش سیگنال را بررسی می‌کند. عبور آن آزمایش و این ماشین حالت، بدون آزمون اتصال، تضمین یک تماس کامل نمی‌سازد. منابع در ۲۴ مهر ۱۴۰۵ خوانده شده‌اند؛ تاریخ توصیه‌نامهٔ VoiceXML، نسخهٔ مستندات و نسخهٔ اجرای کد جدا ثبت شده‌اند. این آموزش تألیفی، خبر پژوهشی تازه یا گزارش عملکرد محصول نیست.

منابع و مطالعهٔ بیشتر

بررسی منابع: ۲۴ مهر ۱۴۰۵. تحلیل‌ها و پیشنهادهای اجرایی، جمع‌بندی نویسنده‌اند.