فهرست مقاله
اگر منوی تلفنی هم کد ۱ و هم کدهای ۱۲ و ۱۲۳ داشته باشد، فشردن کلید نخست بهتنهایی انتخاب نهایی را مشخص نمیکند. مشکل در این مثال، ابهام پیشوند در لایهٔ برنامه است. با پایتون، یک جمعآورندهٔ آفلاین میسازیم که انتخاب را با پایاندهنده یا مهلت مشخص ثبت میکند. ورودی، رویداد تفسیرشدهٔ کلید است؛ صوت، بستهٔ RTP و تماس واقعی اجرا نمیشوند. تمام زمانها ساختگیاند و نتیجه، قرارداد رفتاری قابلآزمون است.
رویداد کلید را از حامل شبکه و تشخیص صوت جدا کنیم

RFC 4733 برای یک رویداد تلفنی، گزارشهای بهروزرسانی و پایان را تعریف میکند؛ بنابراین هر بسته الزاماً یک فشردن کلید تازه نیست. برای نمونه، تکرار گزارش پایان رویداد را نباید چند انتخاب مستقل حساب کرد. برنامهٔ حاضر دریافت، بازسازی یا حذف تکرار آن بستهها را پیاده نمیکند؛ این وظیفه باید پیش از تحویل رویداد به جمعآورنده انجام شده باشد.
قرارداد ورودی ما فقط دوازده کلید رایج، یعنی ارقام ASCII و * و # را میپذیرد. این انتخاب به معنی نبودن DTMFهای A تا D نیست؛ ضمیمهٔ DTMF در مشخصات SRGS آنها را هم معرفی میکند. رقم فارسی «۱» نیز خودکار به ASCII تبدیل نمیشود. یک رویداد ازپیشتفسیرشده با زمان صحیح میلیثانیه میدهیم و شبکه را از آزمون انتخاب مسیر جدا نگه میداریم.
اگر مسئله تشخیص خود کلید از موج صوتی است، مثال کنترلشدهٔ Goertzel برای DTMF مربوط به آن لایه است. قبولشدن آزمون این پارسر، دقت تشخیص تون یا قابلیت همکاری با یک PBX را ثابت نمیکند.
پیشوند معتبر با کد کامل و تصمیم نهایی فرق دارد

منوی ساختگی پنج کد دارد: 1 برای sales، 12 برای support، 123 برای technical، 45 برای billing و 9 برای operator. این نامها فقط خروجی نمونه هستند. پس از ورود 1، هم یک کد کامل داریم و هم امکان ادامهٔ آن؛ پس از 12 نیز همین وضعیت برقرار است. کد 4 فقط پیشوند 45 است و هنوز انتخاب کامل نیست.
سیاست آموزشی را عمداً ساده و صریح میگذاریم: همهٔ کدها، حتی برگهایی مانند 9 و 123، تا # یا پایان مهلت بین ارقام منتظر میمانند. هیچ انتقال زودهنگام در لحظهٔ تکمیل برگ انجام نمیدهیم. کلید # در کد ذخیره نمیشود. اگر بافر کد کامل باشد، آن را ثبت میکند؛ در غیر این صورت، تصمیم nomatch است. # در بافر خالی نیز در این سیاست «پایاندهی نامعتبر» است، نه نبود ورودی.
این تفاوت را با وضعیت collecting و خروجی نهایی matched نمایش میدهیم. داشتن کد کامل در بافر، بهتنهایی مجوز گزارش مسیر نهایی نیست. سیاست را باید در پیام راهنمای منو نیز منعکس کرد؛ در کاربرد واقعی، انتظار برای # و رفتار مهلت نباید برای تماسگیرنده مبهم بماند.
مهلت نخستین کلید و مهلت بین ارقام را جدا اجرا کنیم

ساعت آزمایش از صفر شروع میشود. مهلت نخستین کلید ۵۰۰۰ میلیثانیه و مهلت بین ارقام ۱۵۰۰ میلیثانیه است. اگر 1 در زمان ۱۰۰ برسد، deadline برابر ۱۶۰۰ میشود؛ اگر 2 در زمان ۲۰۰ برسد، بافر 12 و deadline برابر ۱۷۰۰ است. # در زمان ۳۰۰، support را همان زمان ثبت میکند. این اعداد انتخاب مؤلف برای آزموناند، نه تنظیم پیشنهادی شبکه.
برای سیاست بدون #، پایان مهلت یک کد کامل را ثبت میکند، پیشوند ناقص را nomatch میکند و نبود هرگونه رقم تا پایان مهلت اولیه را noinput میکند. در VoiceXML 2.0، timeout، interdigittimeout، termtimeout و termchar مفاهیم جدا دارند. کد ما termtimeout جداگانه ندارد و پیادهسازی آن استاندارد نیست؛ برای برگ کامل هم همان مهلت بین ارقام را به کار میبرد.
یک تفاوت دیگر نیز آگاهانه است: پیشوند ناممکن مانند 8 یا کلید * در منوی انتخابشده، فوراً nomatch میشود. این رفتار را به VoiceXML نسبت نمیدهیم؛ ضمیمهٔ زمانی آن دربارهٔ جمعآوری ورودی نامعتبر تا پایاندهنده یا مهلت نیز توضیح دارد. ثبت روشن این اختلاف، از ادعای سازگاری نادرست جلوگیری میکند.
"""Deterministic offline multi-digit menu policy, not VoiceXML or RFC4733.
Inputs are already interpreted keypress events, never RTP packets or audio.
Integer milliseconds are a synthetic monotonic event clock; no sleeping occurs.
"""
from itertools import product
import json
import platform
MENU = (('1', 'sales'), ('12', 'support'), ('123', 'technical'),
('45', 'billing'), ('9', 'operator'))
class MenuParser:
def __init__(self, entries=MENU, initial_ms=5000, interdigit_ms=1500):
if type(entries) not in (tuple, list):
raise TypeError('entries must be an exact tuple or list')
if not 1 <= len(entries) <= 32:
raise ValueError('require 1..32 menu entries')
routes = {}
for row in entries:
if type(row) not in (tuple, list) or len(row) != 2:
raise TypeError('each entry must be a code-route pair')
code, route = row
if type(code) is not str or type(route) is not str:
raise TypeError('code and route must be exact str')
if not 1 <= len(code) <= 6 or any(c not in '0123456789' for c in code):
raise ValueError('codes contain 1..6 ASCII digits')
if not route or code in routes:
raise ValueError('routes must be nonempty; duplicate codes forbidden')
routes[code] = route
for value in (initial_ms, interdigit_ms):
if type(value) is not int:
raise TypeError('timeouts must be exact int, not bool')
if not 1 <= value <= 60000:
raise ValueError('teaching timeouts are 1..60000 milliseconds')
self.routes = routes
self.prefixes = {code[:j] for code in routes for j in range(1, len(code) + 1)}
self.interdigit_ms = interdigit_ms
self.clock_ms = 0
self.buffer = ''
self.status = 'collecting'
self.reason = None
self.route = None
self.decision_ms = None
self.deadline_ms = initial_ms
def snapshot(self):
return {'status': self.status, 'buffer': self.buffer, 'route': self.route,
'reason': self.reason, 'decision_ms': self.decision_ms,
'deadline_ms': self.deadline_ms}
def _finish(self, status, reason, at_ms):
self.status = status
self.reason = reason
self.decision_ms = at_ms
self.route = self.routes[self.buffer] if status == 'matched' else None
self.deadline_ms = None
def advance(self, at_ms):
if type(at_ms) is not int:
raise TypeError('clock must be exact int milliseconds, not bool')
if at_ms < self.clock_ms:
raise ValueError('clock cannot move backward')
self.clock_ms = at_ms
if self.status == 'collecting' and at_ms >= self.deadline_ms:
deadline = self.deadline_ms
if not self.buffer:
self._finish('noinput', 'initial_timeout', deadline)
elif self.buffer in self.routes:
self._finish('matched', 'interdigit_timeout', deadline)
else:
self._finish('nomatch', 'incomplete_timeout', deadline)
return self.snapshot()
def feed(self, key, at_ms):
if type(key) is not str:
raise TypeError('keypress must be an exact str')
if len(key) != 1 or key not in '0123456789*#':
raise ValueError('this adapter contract supports only 12 common keys')
# Timeout wins a timestamp tie. Keypresses at/after it cannot extend input.
self.advance(at_ms)
if self.status != 'collecting':
return self.snapshot()
if key == '#':
self._finish('matched' if self.buffer in self.routes else 'nomatch',
'terminator' if self.buffer in self.routes else 'invalid_termination', at_ms)
elif key == '*':
self._finish('nomatch', 'unsupported_menu_key', at_ms)
else:
self.buffer += key
if self.buffer not in self.prefixes:
self._finish('nomatch', 'invalid_prefix', at_ms)
else:
# Even a complete leaf code waits for # or this timeout.
self.deadline_ms = at_ms + self.interdigit_ms
return self.snapshot()
def fails(error, function, *args):
try:
function(*args)
except error:
return True
return False
def authored_sequence(sequence, terminate):
parser = MenuParser()
for j, key in enumerate(sequence, 1):
parser.feed(key, j * 100)
return parser.feed('#', (len(sequence) + 1) * 100) if terminate else parser.advance(10000)
def finite_sequence_oracle(sequence, terminate):
"""Independent whole-sequence search, restricted to this fixed 100ms fixture."""
routes = dict(MENU)
for j in range(1, len(sequence) + 1):
prefix = sequence[:j]
if not any(code.startswith(prefix) for code in routes):
return {'status': 'nomatch', 'buffer': prefix, 'route': None,
'reason': 'invalid_prefix', 'decision_ms': j * 100, 'deadline_ms': None}
status = 'matched' if sequence in routes else ('nomatch' if sequence or terminate else 'noinput')
if terminate:
reason = 'terminator' if status == 'matched' else 'invalid_termination'
at = (len(sequence) + 1) * 100
else:
reason = 'interdigit_timeout' if status == 'matched' else ('incomplete_timeout' if sequence else 'initial_timeout')
at = len(sequence) * 100 + 1500 if sequence else 5000
return {'status': status, 'buffer': sequence, 'route': routes.get(sequence) if status == 'matched' else None,
'reason': reason, 'decision_ms': at, 'deadline_ms': None}
def run_example():
p = MenuParser()
after1 = p.feed('1', 100)
after12 = p.feed('2', 200)
result12 = p.feed('#', 300)
leaf = MenuParser()
for j, key in enumerate('123', 1):
leaf.feed(key, j * 100)
leaf_pending = leaf.snapshot()
leaf_done = leaf.feed('#', 400)
nine = MenuParser(); nine_pending = nine.feed('9', 100); nine_done = nine.feed('#', 200)
lone = MenuParser(); lone.feed('1', 100); lone_timeout = lone.advance(1600)
partial = MenuParser(); partial.feed('4', 100); partial_timeout = partial.advance(1600)
empty = MenuParser(); before_initial = empty.advance(4999); initial_expiry = empty.advance(5000)
empty_term = MenuParser().feed('#', 100)
partial_term_parser = MenuParser(); partial_term_parser.feed('4', 100); partial_term = partial_term_parser.feed('#', 200)
before = MenuParser(); before.feed('1', 100); before.feed('2', 1599); before_done = before.feed('#', 1700)
tie = MenuParser(); tie.feed('1', 100); tie_done = tie.feed('2', 1600)
late = MenuParser(); late.feed('1', 100); late_done = late.advance(10000)
stable_before = p.snapshot(); p.feed('9', 1000); p.advance(20000)
bad_clock = MenuParser(); bad_clock.advance(100)
oracle_cases = 0
oracle_agrees = True
for length in range(4):
for digits in product('1234590', repeat=length):
seq = ''.join(digits)
for term in (False, True):
oracle_agrees &= authored_sequence(seq, term) == finite_sequence_oracle(seq, term)
oracle_cases += 1
checks = {
'exact1_remains_collecting_because_longer_codes_are_possible':
after1 == {'status': 'collecting', 'buffer': '1', 'route': None, 'reason': None, 'decision_ms': None, 'deadline_ms': 1600},
'exact12_remains_collecting_and_resets_interdigit_deadline':
after12 == {'status': 'collecting', 'buffer': '12', 'route': None, 'reason': None, 'decision_ms': None, 'deadline_ms': 1700},
'terminator_commits12_support_at300_without_storing_hash_key':
result12 == {'status': 'matched', 'buffer': '12', 'route': 'support', 'reason': 'terminator', 'decision_ms': 300, 'deadline_ms': None},
'leaf123_also_waits_under_this_explicit_custom_policy':
leaf_pending['status'] == 'collecting' and leaf_pending['buffer'] == '123' and leaf_done['route'] == 'technical',
'single_leaf9_waits_then_explicit_terminator_commits_operator':
nine_pending['status'] == 'collecting' and nine_done['route'] == 'operator' and nine_done['decision_ms'] == 200,
'complete_prefix1_commits_on_1600ms_interdigit_timeout':
lone_timeout == {'status': 'matched', 'buffer': '1', 'route': 'sales', 'reason': 'interdigit_timeout', 'decision_ms': 1600, 'deadline_ms': None},
'incomplete_prefix4_times_out_as_nomatch_not_noinput':
partial_timeout['status'] == 'nomatch' and partial_timeout['reason'] == 'incomplete_timeout' and partial_timeout['decision_ms'] == 1600,
'initial_wait_stays_pending_at4999_and_noinput_at5000':
before_initial['status'] == 'collecting' and initial_expiry['status'] == 'noinput' and initial_expiry['decision_ms'] == 5000,
'empty_terminator_is_explicit_invalid_termination_not_noinput':
empty_term['status'] == 'nomatch' and empty_term['reason'] == 'invalid_termination' and empty_term['decision_ms'] == 100,
'terminator_rejects_incomplete4_without_selecting45':
partial_term['status'] == 'nomatch' and partial_term['buffer'] == '4' and partial_term['decision_ms'] == 200,
'unsupported_star_and_nonprefix8_fail_immediately_by_policy':
MenuParser().feed('*', 100)['reason'] == 'unsupported_menu_key' and MenuParser().feed('8', 100)['reason'] == 'invalid_prefix',
'digit_before_deadline_can_extend1_to12':
before_done['route'] == 'support' and before_done['buffer'] == '12' and before_done['decision_ms'] == 1700,
'timeout_wins_exact_tie_so_digit_at1600_cannot_extend1':
tie_done == lone_timeout,
'late_poll_records_logical_deadline_not_poll_time': late_done == lone_timeout,
'terminal_result_is_stable_under_later_keys_and_clock_polls': p.snapshot() == stable_before,
'invalid_keys_and_clock_types_negative_backward_time_rejected':
all(fails(ValueError, MenuParser().feed, key, 0) for key in ('', '12', '۱', 'A', 'x')) and
all(fails(TypeError, MenuParser().feed, key, 0) for key in (1, None, b'1')) and
all(fails(TypeError, MenuParser().advance, t) for t in (True, 1.0, '1', None)) and
fails(ValueError, MenuParser().advance, -1) and fails(ValueError, bad_clock.advance, 99),
'invalid_menu_duplicates_codes_routes_and_timeout_contracts_rejected':
fails(ValueError, MenuParser, ()) and
fails(ValueError, MenuParser, (('1', 'a'), ('1', 'b'))) and
all(fails(ValueError, MenuParser, ((code, 'a'),)) for code in ('', '1#', '۱', '1234567')) and
fails(ValueError, MenuParser, (('1', ''),)) and
fails(TypeError, MenuParser, {'1': 'a'}) and
fails(TypeError, MenuParser, (('1', 1),)) and
all(fails(TypeError, MenuParser, MENU, t, 1500) for t in (True, 1.0)) and
all(fails(ValueError, MenuParser, MENU, 5000, t) for t in (0, -1, 60001)),
'800_fixed_gap_sequences_match_independent_batch_prefix_oracle': bool(oracle_agrees) and oracle_cases == 800,
}
if len(checks) != 18 or not all(type(v) is bool and v for v in checks.values()):
raise AssertionError(checks)
return {'python': platform.python_version(),
'scope': 'Authored offline application menu policy; interpreted keypresses only; no audio, RTP, PBX, VoiceXML or real network clock executed.',
'menu': dict(MENU), 'initial_timeout_ms': 5000, 'interdigit_timeout_ms': 1500,
'after1': after1, 'after12': after12, 'result12': result12,
'lone1_timeout': lone_timeout, 'incomplete4_timeout': partial_timeout,
'exact_deadline_digit2': tie_done, 'oracle_fixture_cases': oracle_cases,
'checks': checks}
if __name__ == '__main__':
print(json.dumps(run_example(), ensure_ascii=False, indent=2))
مرز مهلت و زمان تصمیم را با مثال دستی بیازماییم

قاعدهٔ تساوی این است که timeout پیش از کلیدی با همان زمان اجرا میشود. پس بعد از 1 در زمان ۱۰۰، کلید 2 در زمان ۱۵۹۹ میتواند بافر را به 12 گسترش دهد؛ همان کلید در ۱۶۰۰ دیگر پذیرفته نمیشود و مسیر sales برای بافر 1 ثبت شده است. این ترتیب، انتخاب صریح مدل است و اندازهگیری زمانبندی سیستمعامل نیست.
اگر تنها در زمان ۱۰۰۰۰ وضعیت را بررسی کنیم، زمان منطقی تصمیم برای همان تکرقم همچنان ۱۶۰۰ است. برنامه برای رسیدن به این زمان sleep نمیکند. در سامانهٔ واقعی، فاصلهٔ زمان منطقی deadline تا اجرای callback ممکن است وجود داشته باشد و باید جدا اندازهگیری شود. همچنین زمانهای مساویِ کلیدهای پیش از deadline طبق ترتیب فراخوانی پردازش میشوند؛ ورودی با ساعت عقبرونده خطاست.
خروجی نهایی پایدار میماند؛ کلیدهای بعدی همان نمونهٔ پارسر را دوباره باز نمیکنند. برای دور بعدی منو، نمونهٔ تازه میسازیم. مرجع مستقل، رشتهٔ کامل رویدادهای با فاصلهٔ ثابت را با جستوجوی مستقیم پیشوند در کدهای منو تحلیل میکند. در ۸۰۰ سناریوی محدود، نتیجه با ماشین حالت برابر است. این پیمایش فقط یکی از ۱۸ بررسی نامدار است؛ موارد مرزی زمانی نیز جدا بررسی شدهاند.
{
"python": "3.14.5",
"scope": "Authored offline application menu policy; interpreted keypresses only; no audio, RTP, PBX, VoiceXML or real network clock executed.",
"menu": {
"1": "sales",
"12": "support",
"123": "technical",
"45": "billing",
"9": "operator"
},
"initial_timeout_ms": 5000,
"interdigit_timeout_ms": 1500,
"after1": {
"status": "collecting",
"buffer": "1",
"route": null,
"reason": null,
"decision_ms": null,
"deadline_ms": 1600
},
"after12": {
"status": "collecting",
"buffer": "12",
"route": null,
"reason": null,
"decision_ms": null,
"deadline_ms": 1700
},
"result12": {
"status": "matched",
"buffer": "12",
"route": "support",
"reason": "terminator",
"decision_ms": 300,
"deadline_ms": null
},
"lone1_timeout": {
"status": "matched",
"buffer": "1",
"route": "sales",
"reason": "interdigit_timeout",
"decision_ms": 1600,
"deadline_ms": null
},
"incomplete4_timeout": {
"status": "nomatch",
"buffer": "4",
"route": null,
"reason": "incomplete_timeout",
"decision_ms": 1600,
"deadline_ms": null
},
"exact_deadline_digit2": {
"status": "matched",
"buffer": "1",
"route": "sales",
"reason": "interdigit_timeout",
"decision_ms": 1600,
"deadline_ms": null
},
"oracle_fixture_cases": 800,
"checks": {
"exact1_remains_collecting_because_longer_codes_are_possible": true,
"exact12_remains_collecting_and_resets_interdigit_deadline": true,
"terminator_commits12_support_at300_without_storing_hash_key": true,
"leaf123_also_waits_under_this_explicit_custom_policy": true,
"single_leaf9_waits_then_explicit_terminator_commits_operator": true,
"complete_prefix1_commits_on_1600ms_interdigit_timeout": true,
"incomplete_prefix4_times_out_as_nomatch_not_noinput": true,
"initial_wait_stays_pending_at4999_and_noinput_at5000": true,
"empty_terminator_is_explicit_invalid_termination_not_noinput": true,
"terminator_rejects_incomplete4_without_selecting45": true,
"unsupported_star_and_nonprefix8_fail_immediately_by_policy": true,
"digit_before_deadline_can_extend1_to12": true,
"timeout_wins_exact_tie_so_digit_at1600_cannot_extend1": true,
"late_poll_records_logical_deadline_not_poll_time": true,
"terminal_result_is_stable_under_later_keys_and_clock_polls": true,
"invalid_keys_and_clock_types_negative_backward_time_rejected": true,
"invalid_menu_duplicates_codes_routes_and_timeout_contracts_rejected": true,
"800_fixed_gap_sequences_match_independent_batch_prefix_oracle": true
}
}
قرارداد ورودی و چهار تمرین پاسخدار

منو یک تا ۳۲ مدخل با کدهای یک تا شش رقم ASCII دارد؛ کد تکراری پیش از ساخت نگاشت رد میشود. مسیر خالی، مهلت نامثبت، bool بهجای زمان و کلید چندکاراکتری پذیرفته نیستند. این سقفها قرارداد مثالاند. افزودن تلاش مجدد، انتقال به اپراتور یا ثبت رخداد باید جدا طراحی شود؛ ماشین حالت ورودی نامعتبر IVR مثال مکمل آن بخش است.
تمرین یک: چرا با دریافت 1 فوراً sales انتخاب نمیشود؟ پاسخ: چون 12 و 123 نیز معتبرند. در این سیاست، بافر تا # یا timeout در وضعیت collecting میماند؛ کد کامل با انتخاب نهایی یکی نیست.
تمرین دو: ورود 4 در زمان ۱۰۰ و سپس سکوت چه نتیجهای دارد؟ پاسخ: در ۱۶۰۰، nomatch با علت incomplete_timeout ثبت میشود. رقم وارد شده، پس noinput درست نیست؛ کد 45 نیز کامل نشده است.
تمرین سه: اگر 2 دقیقاً در deadline بافر 1 برسد چه میشود؟ پاسخ: ابتدا timeout اجرا میشود و sales برای 1 نهایی میشود. برای توسعه به 12، رویداد دوم باید پیش از deadline برسد.
تمرین چهار: آیا سه گزارش پایان یک رویداد RTP باید سه بار به feed داده شود؟ پاسخ: خیر؛ feed رویداد منطقی کلید میخواهد، نه گزارشهای حامل. بازسازی رویداد و تشخیص تکرار باید در آداپتور انتقال انجام شود؛ این مثال آن آداپتور را آزمایش نمیکند.
منابع در ۲۴ مهر ۱۴۰۵ خوانده شدهاند. VoiceXML 2.0 و SRGS توصیهنامههای ۲۰۰۴ و RFC 4733 سند دسامبر ۲۰۰۶ است؛ این مطلب آموزش پایه است، نه خبر پژوهشی تازه. اجرای ثبتشده با Python 3.14.5 و کتابخانهٔ استاندارد انجام شده است.
منابع و مطالعهٔ بیشتر
- RFC4733؛ رویداد DTMF، گزارشهای بهروزرسانی و تکرار پایان
- W3C VoiceXML2.0؛ مهلتها و پایاندهندهٔ DTMF
- W3C SRGS1.0 AppendixE؛ توالی زمانی کلیدهای DTMF
بررسی منابع: ۲۴ مهر ۱۴۰۵. تحلیلها و پیشنهادهای اجرایی، جمعبندی نویسندهاند.

