docs: handbook, diagrams and guides brought up to 2026-10-02

- technical-handbook: question packs instead of subscriptions, real
  hearts with the new wait screen (screenshot), math-only tutor and what
  is charged (new 9-7), admin cancel/refund, Bazaar planId, account
  deletion, 59 endpoints, 779 tests, 14 apps, status and open decisions.
- Diagrams redrawn: use-case (buy a pack, refunds), data model
  (QuestionCredit, HeartsPass, Profile.birth_date), pack payment sequence.
- The PDF is now built from the Markdown by docs/build_handbook.py
  (pandoc + Chrome, font in docs/assets); the hand-made HTML it used to
  come from was not in the repo and had been lost.
- architecture, operations, security-before-production (item 13 closed),
  ai-cost-and-guardrails and README follow the same changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 23:10:50 +03:30
parent 8a4753bd15
commit 7683920b0b
13 changed files with 196 additions and 55 deletions

95
docs/build_handbook.py Normal file
View File

@@ -0,0 +1,95 @@
"""
مستندِ فنی را از Markdown به PDF می‌سازد: docs/technical-handbook.md → docs/technical-handbook.pdf
python docs/build_handbook.py
چرا (۱۰ مهر ۱۴۰۵): PDF پیش‌تر از یک HTMLِ دست‌سازِ جدا ساخته می‌شد که در مخزن نبود. هر به‌روزرسانی
یعنی ویرایشِ دو نسخه، و بعد از ۹ مهر آن HTML گم شد. حالا Markdown تنها منبع است و PDF از روی آن
ساخته می‌شود؛ نمودارها PNGهای `docs/images/technical-handbook/` هستند.
نیاز دارد: `pandoc` (brew install pandoc) و Google Chrome. فونت (وزیرمتن، مجوزِ OFL) کنارِ همین فایل در
`docs/assets/` است، پس PDF روی هر ماشینی یکسان درمی‌آید و به اینترنت نیاز ندارد.
"""
import base64
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
DOCS = Path(__file__).resolve().parent
SRC = DOCS / 'technical-handbook.md'
OUT = DOCS / 'technical-handbook.pdf'
CHROME_CANDIDATES = [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'google-chrome', 'chromium', 'chromium-browser',
]
CSS = '''
@font-face{font-family:'Vazirmatn';font-weight:100 900;src:url(data:font/woff2;base64,%(font)s) format('woff2')}
@page{size:A4;margin:16mm 15mm 16mm 15mm}
:root{--ink:#1d2330;--muted:#5b6472;--line:#e4e0d6;--accent:#2f55c8;--accent-soft:#eaf0ff;--code-bg:#f6f4ee}
*{box-sizing:border-box}
html,body{background:#fff}
body{margin:0;font-family:Vazirmatn,Tahoma,sans-serif;color:var(--ink);font-size:10.6pt;line-height:1.85;
-webkit-print-color-adjust:exact;print-color-adjust:exact}
h1{font-size:25pt;font-weight:900;margin:0 0 6px;color:var(--ink)}
h1+table{margin-top:14px}
h2{font-size:17pt;font-weight:800;margin:0 0 8px;padding-top:6px;border-top:3px solid var(--accent);break-before:page;break-after:avoid}
h3{font-size:12.5pt;font-weight:700;margin:20px 0 6px;color:#26305a;break-after:avoid}
h4{font-size:11pt;font-weight:700;margin:12px 0 4px;break-after:avoid}
p{margin:6px 0}
a{color:var(--accent);text-decoration:none}
code{font-family:Menlo,Consolas,monospace;font-size:.84em;direction:ltr;unicode-bidi:isolate;background:var(--code-bg);
padding:1px 4px;border-radius:4px;border:1px solid #ece8dd}
pre{direction:ltr;text-align:left;background:#1f2430;color:#e8e6df;border-radius:8px;padding:10px 12px;
font-size:8.6pt;line-height:1.55;white-space:pre-wrap;word-break:break-word;break-inside:avoid}
pre code{background:none;border:0;padding:0;color:inherit;font-size:1em}
table{width:100%%;border-collapse:collapse;margin:8px 0 12px;font-size:9.4pt;break-inside:auto}
th,td{border:1px solid var(--line);padding:5px 8px;text-align:right;vertical-align:top}
th{background:#f4f1e8;font-weight:700}
tr{break-inside:avoid}
blockquote{margin:10px 0;padding:8px 14px;border:1px solid #d9e2fb;border-right:4px solid var(--accent);
background:var(--accent-soft);border-radius:8px;break-inside:avoid}
blockquote h4{margin-top:2px;color:var(--accent)}
img{max-width:100%%;display:block;margin:10px auto 4px;border-radius:6px}
img[src*="screenshot-"]{max-width:62mm;border:1px solid var(--line);box-shadow:0 2px 8px rgba(30,40,80,.08)}
p:has(> img){break-inside:avoid;margin:0}
p > em:only-child{display:block;font-size:9pt;color:var(--muted);text-align:center;margin:2px 0 12px}
ul,ol{padding-right:20px;margin:6px 0}
li{margin:2px 0}
hr{border:0;border-top:1px solid var(--line);margin:14px 0}
'''
def chrome() -> str:
for c in CHROME_CANDIDATES:
if Path(c).exists() or shutil.which(c):
return c
sys.exit('Chrome پیدا نشد؛ مسیرش را در CHROME_CANDIDATES بگذارید.')
def main():
if not shutil.which('pandoc'):
sys.exit('pandoc نصب نیست: brew install pandoc')
font = base64.b64encode((DOCS / 'assets' / 'Vazirmatn.woff2').read_bytes()).decode()
body = subprocess.run(['pandoc', str(SRC), '-f', 'gfm', '-t', 'html5'],
capture_output=True, text=True, check=True).stdout
html = (f'<!doctype html><html lang="fa" dir="rtl"><head><meta charset="utf-8">'
f'<title>مستند فنی تدریسینو</title><style>{CSS % {"font": font}}</style></head>'
f'<body>{body}</body></html>')
# فایلِ موقت کنارِ docs تا مسیرِ نسبیِ عکس‌ها (images/…) درست باز شود.
with tempfile.NamedTemporaryFile('w', suffix='.html', dir=DOCS, delete=False, encoding='utf-8') as f:
f.write(html)
tmp = Path(f.name)
try:
subprocess.run([chrome(), '--headless=new', '--disable-gpu', '--no-pdf-header-footer',
'--virtual-time-budget=10000', f'--print-to-pdf={OUT}', tmp.as_uri()],
check=True, capture_output=True)
finally:
tmp.unlink(missing_ok=True)
print(f'{OUT} ({OUT.stat().st_size // 1024} KB)')
if __name__ == '__main__':
main()