پرش به محتویات

طراحی گفتگو

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

این صفحه آن بخشی است که تثبیت شده: واژگان ناوبری، اینکه یک صفحه‌کلید چطور چیده می‌شود، اینکه جریان ثبت چه چیزی را فرض می‌گیرد و چرا، و آن ثابت‌هایی که جلوی ثبت دوبارهٔ پول با یک دکمه را می‌گیرند. اینکه کدام صفحه چه می‌گوید در کد است، زیر bot/screens/. آنچه می‌آید دلیلش است، که با عوض شدن یک صفحه عوض نمی‌شود.

یک نشانه برای هر کار، نه برای هر بخش

سه ردیف مشترک در bot/screens/common.py کل واژگان ناوبری‌اند، و bot/screens/__init__.py مثل هر نام صفحهٔ دیگری دوباره export می‌کندشان:

ردیف معنایش
back_row(data) ⬅️ بازگشت یک سطح بالا، به کال‌بک خود والد
cancel_row(data="nav:cancel") ↩️ انصراف رها کردن پیش‌نویس؛ چیزی ثبت نشد
rows_of(buttons, per_row=2) — چیدن دکمه‌ها، حداکثر سه تا در یک ردیف

این‌ها هستند چون نشانه از معنای «کار» افتاده بود و معنای «ماژول» گرفته بود. برنامه‌ریزی و دفترها «↩️ انصراف» می‌نوشتند، جایی که حقوق و حساب کاربری و کیف‌پول «⬅️ انصراف» می‌نوشتند، و جریان تراکنش هر دو را در یک سفر داشت. آدمی که برای یک قصد در یک بخش ⬅️ می‌زند و در بخش بعدی ↩️، مجبور است هر صفحه‌کلید را از نو بخواند — و واژگان یکدست برای نپرداختن همین هزینه است.

نشانهٔ رسمی انصراف ↩️ است، و دلیلش را بهتر است صریح گفت تا سرش بحث کرد: tests/test_conversation.py صفحه‌کلید یک پرسش را دقیقاً به ["↩️ انصراف"] میخ کرده است. جایی که دو املا هر دو قابل دفاع‌اند، آن یکی می‌ماند که تستی از قبل ادعایش را کرده — تست تصمیمی است که کسی قبلاً گرفته.

دو نکته که در امضای همین توابع است:

  • back_row کال‌بک والد را می‌گیرد، نه یک مقدار ثابت. nav:home فقط وقتی درست است که خانه واقعاً والد باشد، و این تنها برای فرزندان خود منوی اصلی درست است. هر جای دیگر، «بازگشت»ی که به خانه می‌رسد انصرافی است با نشانهٔ غلط.
  • پیش‌فرض cancel_row همان nav:cancel است، که می‌گویدش. انصراف cancelled() را می‌کشد — یک خط که تأیید می‌کند چیزی نگه داشته نشد — نه اینکه بی‌صدا صفحهٔ خانه را دوباره بکشد؛ چون آدمی که انصراف را از خرابی تشخیص نمی‌دهد همان تراکنش را دوباره می‌نویسد.

دستور زبان صفحه‌کلید

برگرفته از Seamless_bot همسایه، که آنجا با استفاده جا افتاده بود:

  1. کار اصلی صفحه ردیف تمام‌عرض خودش را دارد.
  2. دکمه‌های هم‌وزن و هم‌جنس دوتایی می‌شوند.
  3. کار مخرب ردیف خودش را می‌گیرد — انگشتی که دکمهٔ بغلش را نشانه گرفته نباید بتواند به آن برسد. confirm_delete() همین‌طور ساخته شده است.
  4. ردیف بازگشت یا انصراف آخر است، و تنها.
  5. هرگز بیش از سه دکمه در یک ردیف.

این سقف زیبایی‌شناسی نیست. برچسب فارسی چهارم روی گوشی می‌شکند یا بریده می‌شود، و دکمه‌ای که متنش بریده است دکمه‌ای است که معنایش حدس زده می‌شود. rows_of() خودش این را نگه می‌دارد و به فراخوان اعتماد نمی‌کند: per_row محدود می‌شود، پس چهار دادن بی‌صدا سه می‌دهد.

rows_of قبلاً _pairs بود، خصوصیِ bot/screens/transactions.py، جایی که فقط صفحه‌های همان ماژول به آن می‌رسیدند — بستهٔ صفحه‌ها ستاره‌ای است دور common و صفحه‌های یک بخش نمی‌توانند صفحه‌های بخش دیگر را import کنند (tests/test_screens_architecture.py همین شکل را نگه می‌دارد). قاعدهٔ چیدمانی که داخل یک بخش زندگی می‌کند قاعده‌ای است که بخش‌های دیگر باید از نو اختراعش کنند، و ناهمگونی بالا همین‌طور پیش آمد. حالا کنار بقیهٔ واژگان مشترک در common است؛ _pairs نامی است برای همان، و نگه داشته شد چون صفحه‌هایی که صدایش می‌زنند با نام کوتاه‌تر خوانا‌ترند.

ثبت: چه چیزی از جریان بیرون آمد و چه چیزی نیامد

اندازه‌گیری با راندن Conversation.handle با IncomingEvent — همان الگوی فیکسچر tests/test_conversation.py — برای کسی که یک دفتر با یک ارز دارد. هفت برخورد پنج شد:

قبل حالا
۱ tx:new — منویی که همان را دوباره می‌پرسید که منوی اصلی پرسیده بود زدن «📌 ثبت تراکنش»، که خودش ثبت را شروع می‌کند
۲ tx:book زدن نوع
۳ tx:on:today — پرسش تاریخ، که جوابش تقریباً همیشه امروز است دسته
۴ tx:type:we مبلغ
۵ دسته توضیح، یا دکمه‌ای که ردش می‌کند
۶ مبلغ
۷ tx:skip

سه قدم رفت: منوی میانی، انتخاب دفتر وقتی فقط یک دفتر هست، و پرسش تاریخ. قدم چهارمی هم برداشته شد و برگشت، که آموزنده‌ترینِ این چهار است.

صفحهٔ خانه خودش منو است

main_menu() در bot/screens/common.py پنج دکمهٔ تمام‌عرض روی هم بود، و اولی‌شان منویی باز می‌کرد که فقط همان را دوباره می‌پرسید که تازه زده شده بود. حالا چهار ردیف است با هم‌ترازها دوتایی: یک فعل در ردیف خودش، بعد لیست روزانه کنار گزارش‌هایی که به آن تعلق دارد، دفترها کنار یادآورها، و حساب کاربری آخر. لیست روزانه قبلاً دو زدن آن‌طرف‌تر، پشت همان منوی میانی بود — صفحه‌ای که مغازه‌دار از همه بیشتر بازش می‌کند سخت‌ترین صفحه برای رسیدن بود.

دکمه‌اش dl:v است، عمداً بدون تاریخ. هندلر به امروزِ خود آدم برمی‌گردد، پس دکمه تاریخ نمی‌خواهد، از آن ۶۴ بایتِ payload کال‌بک چیزی خرج نمی‌کند، و روی صفحه‌ای که یک شب باز مانده کهنه نمی‌شود.

تاریخ فرض گرفته می‌شود، پرسیده نمی‌شود

جریان با pick_date شروع می‌شد که دو دکمه داشت: «امروز» و «تاریخ دیگر». سؤالی که یک جواب معمول دارد مالیاتی است روی هر ثبت، برای خدمت به آن ثبت کم‌پیش‌آمد.

روز حالا در _start_single (bot/features/transactions.py) از امروزِ خود آدم گرفته می‌شود و جریان روی pick_type باز می‌شود. این فرض هیچ‌وقت خاموش نیست: pick_type روز را در تیتر خودش می‌گوید، «✍️ تاریخ دیگر» را برای استثنا پیش می‌گذارد، و کارتی که بعد از ثبت برمی‌گردد هنوز می‌تواند تاریخ را ویرایش کند. فرضی که گفته می‌شود، پیش از وقوع قابل اصلاح است و پس از آن هم، حدس نیست.

قدم توضیح برداشته شد، و برگشت

این یکی را دو بار بخوان، چون استدلالِ برداشتنش خوب بود. توضیح اختیاری است، جواب معمولش دکمهٔ رد کردن بود، و کارتِ ثبت‌شده از قبل «ویرایش توضیحات» و «افزودن رسید» را پیش می‌گذارد — پس آن پرسش شبیه صفحه‌ای مسدودکننده بود که جلوی کاری ایستاده بود که آدم بعداً، یا هیچ‌وقت، می‌توانست بکند. commit کردن روی مبلغ جریان را چهار برخورد می‌کند، و مدتی همین بود.

پنج است، چون آن قدم فقط پرسش یک یادداشت نیست. همان‌جا هم هست که عکسی با توضیح، ثبت را در یک پیام کامل می‌کند، و دو راه رسیدن به رسید یکسان دروازه نشده‌اند: LedgerService.record() رسید را زیر RECORD_INCOME/RECORD_EXPENSE می‌گیرد، در حالی که attach_receipt() بعد از آن EDIT_TRANSACTION می‌خواهد. عضوی که اجازهٔ ثبت دارد و اجازهٔ ویرایش ندارد فقط همان موقعِ ساختن ثبت می‌تواند رسید بچسباند. ثبت کردن روی مبلغ رسید را دقیقاً از همان عضو می‌گرفت، بی‌صدا، در عوض یک زدن.

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

هر قدمی که commit می‌کند همان قدمی است که ادعا می‌کند

_save_tx پیش از ثبت، پیش‌نویس را با یک pop اتمیک از انبار وضعیت بیرون می‌کشد. از دو نسخهٔ آن قدم که در راه‌اند — دو زدن روی دکمهٔ رد کردن، در حالت وب‌هوک که هر به‌روزرسانی session خودش را دارد و هم‌زمان اجرا می‌شوند — یکی پیش‌نویس پیدا می‌کند و به آن یکی گفته می‌شود پیامش قدیمی است. اگر record() بعدش نپذیرد، پیش‌نویس برمی‌گردد: ادعا مصرف نیست، و یک غلط تایپی نباید به قیمت کل ثبت تمام شود.

آنچه مهم است خود پیوند است، نه آن pop. آن ادعا در _save_tx است چون commit در _save_tx است؛ تا وقتی commit روی پیام مبلغ نشسته بود ادعا هم باید همان‌جا می‌نشست، و برگشتن commit آن را هم برگرداند. ادعایی که یک قدم عقب‌تر از commit باشد از چیزی محافظت نمی‌کند، و هیچ‌چیز در سیستم نوعی این را نخواهد گفت:

هر قدمی که commit می‌کند همان قدمی است که باید ادعا کند.

این را tests/test_bot_stale_and_cancel.py::test_two_simultaneous_description_skips_record_once و ::test_two_simultaneous_quick_confirms_record_once میخ کرده‌اند، که دو به‌روزرسانی یکسان را از دو session مسابقه می‌دهند و یک تراکنش و یک جواب «پیامت قدیمی است» را ادعا می‌کنند.

خط سریع: یک فاصله نباید معنای خط را عوض کند

bot/quick.py یک تراکنش کامل را از یک خط نوشته می‌خواند — «فروش 250ک» — و تنها مسیری است که بدون قدم تأیید ثبت می‌کند. همین خطاهایش را از جنس خطای یک صفحه جدا می‌کند: کسی نیست که متوجه شود. هر چیزی را که نمی‌پذیرد با پرسیدن نمی‌پذیرد (unclear_reason() در bot/screens/common.py می‌گوید کدام خوانش روشن نبود و یکی را نشان می‌دهد که کار می‌کند). چهار ثابت، که هر کدام را یک بار شکسته بود:

هر چیزی که چسبیده به رقم‌ها پذیرفته می‌شود، کنارشان هم پذیرفته می‌شود. «فروش 250 ک» به جای ۲۵۰٬۰۰۰ مبلغ ۲۵۰ تومان ثبت می‌کرد، بی‌صدا، با «ک» به‌عنوان توضیح — کم‌شماریِ هزاربرابر. فهرست مقیاس‌هایی که می‌توانند به‌صورت کلمهٔ جدا بعد از عدد بیایند فقط حرفی‌ها را داشت («هزار»، «میلیون»)، در حالی که parse_amount در shared/parsing.py چسبیده به رقم‌ها ک/م/ب/k/m/b را هم می‌پذیرد. یک فاصله ضریب را می‌انداخت. آن فهرست حالا از همان یک جدولی مشتق می‌شود که parse_amount می‌خواند، پس دیگر نمی‌توانند از هم جدا بیفتند، و tests/test_quick_and_reports.py::test_a_space_before_the_unit_does_not_divide_the_amount_by_a_thousand هر واحد آن جدول را به هر دو شکل بررسی می‌کند.

کوتاه‌ترین خطی که آدم می‌تواند بنویسد هنوز خوانده می‌شود. «1404/12/28 250000» — یک تاریخ و یک مبلغ — تاریخ را به‌عنوان نام دسته ثبت می‌کرد و همان دسته را در دفتر می‌ساخت، چون جدا کردن تاریخ ابتدایی به بیش از دو توکن شرط شده بود. دو تا کافی است.

خطی که مبلغ دارد هیچ‌وقت بی‌مبلغ نیست. «250000» تنها جواب می‌گرفت «مبلغی در این پیام ندیدم»، که تنها چیزی بود که آن خط قطعاً داشت. چیزی که نبود دسته بود، و حالا همان را می‌پرسد. هر چیزی که مبلغ نیست ناخوانا می‌ماند، پس به گپ معمولی با غر جواب داده نمی‌شود.

رقم مبهم پرسیده می‌شود، خوانده نمی‌شود. «1.500.000» معمول‌ترین شکل فارسیِ نوشتن یک و نیم میلیون است؛ parse_amount دو نقطه را نمی‌پذیرد، پس خط به‌کل ناخوانا برمی‌گشت — همان یک ردی که چیزی یاد نمی‌دهد. حالا پرسیده می‌شود، همان‌طور که ریال پرسیده می‌شود، چون انتخاب خاموشِ یک خوانش حدس زدن دربارهٔ پول است. خود پرسش هم دربارهٔ همان رقمی است که نوشته شده: قبلاً هرچه به آن می‌دادند ۲۵۰ را نام می‌برد، پس «1.500.000» با دو خوانش از عددی جواب می‌گرفت که کسی ننوشته بود.

متنی که آدم می‌خواند کجا نوشته می‌شود

خطاهای دامنه به هر زبانی که سرویس نوشته به ربات می‌رسند. user_message() در bot/screens/common.py همان مرز است: پیام انگلیسی به یک جملهٔ فارسی با راه پیش رو نقشه می‌شود، و پیامی که از قبل فارسی دارد دست‌نخورده رد می‌شود، با این فرض معقول که سرویسی که فارسی نوشته برای آدم نوشته.

همین رد شدن تله است. modules/ledger/categories.py در هر ردی که بالا می‌اندازد «دسته‌بندی» نوشته بود — دقیقاً همان کلمه‌ای که tests/test_bot_ux_fixes.py::test_the_vocabulary_is_one_word_per_concept ممنوع می‌کند، چون هر مفهوم یک کلمه دارد — و یک جمله موفق شده بود «دسته‌بندی» و «دستهٔ» را با هم به کار ببرد. دو تایشان رقم فارسی هم نوشته بودند، که test_user_facing_digits_are_written_one_way به همان دلیل ممنوع می‌کند. هر یک از آن جمله‌ها بی‌تغییر به دست آدم‌ها می‌رسید و هیچ نگهبانی متوجه نشد، چون نگهبان‌های متن فقط فایل‌های زیر bot/ را می‌خوانند.

آن دسته‌ای که از خود نمونه مهم‌تر است:

پیام خطای لایهٔ سرویس هم متنی است که آدم می‌خواند.

در سرویس نوشته می‌شود، همان‌جا که قواعد پروژه می‌گویند قاعده جایش است، و روی گوشی خوانده می‌شود، همان‌جا که قواعد متنِ پروژه برقرارند. اگر فارسی است، متن است.

پس به‌جای اینکه فقط آن کلمه درست شود، دامنهٔ نگهبان‌ها گسترده شد. tests/test_copy_rules.py همان قواعد واژگان و نیم‌فاصله و رقم را روی modules/ و shared/ نگه می‌دارد. سطح را پیدا می‌کند نه حدس: یک پیمایش AST اولین آرگومان هر فراخوانی به کلاسی را جمع می‌کند که shared/errors.py تعریف کرده، و این دقیقاً همان است که به user_message() می‌رسد. عبارت منظم روی متن فایل‌ها توضیح‌ها و docstringهایی را هم جمع می‌کرد که کسی نمی‌خواندشان، و پیامی را که در یک ثابت ماژول نگه داشته شده از دست می‌داد. یک کف هم ادعا می‌کند که آن پیمایش چقدر متن پیدا کرده، پس پیمایشی که بی‌صدا دیگر لایهٔ سرویس را نبیند — چون سرویسی از راه کمکی‌ای شروع کرده که آن پیمایش دنبالش نمی‌رود — بلند شکست می‌خورد، نه اینکه روی یک فهرست خالی هر قاعده را پاس کند.

بیشتر کجا بخوانیم

  • شکل bot/features/ و bot/screens/ و اینکه صفحه چه چیزی را می‌تواند import کند: معماری.
  • فهرست کارهای افزودن یک صفحه و تست‌های نگهبان: توسعه.
  • اینکه این بازطراحی در برابر چه چیزی اندازه‌گیری شد، سفر به سفر، با رونوشت‌هایی که از آن‌ها اندازه گرفته شد: docs/ux/current-flow-audit.md و docs/ux/redesign-plan.md. هر دو حسابرسی‌های تاریخ‌دارند — شاهدِ درختی که بودند، نه توصیف درختی که داری.