Commit Graph

173 Commits

Author SHA1 Message Date
b3f624a512 gunicorn: warm the assistant's Qdrant/OpenAI clients when a worker starts
The first assistant round after a restart waited ~6 s before reaching the model on the
live server (later rounds ~1 s); locally a cold 10-ask burst took 7.5 s vs 6.1 s with
this hook (simulated model, 5.2 s). Workers are recycled by max_requests, so the delay
recurred. A failed warm-up is logged and does not stop the worker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:41:48 +03:30
2cf2d14dd4 Security re-check and load test 2026-10-04: 32 threads, data file permissions, patched deps
- gunicorn gthread 8 -> 32 threads per worker: each assistant stream holds a thread for
  the whole answer; with 3x8, about 20 concurrent asks already queued ordinary page loads
  for seconds (up to 23 s at 50 asks locally, 7 s at 20 asks on the live server). With 3x32,
  75 concurrent asks kept page loads under 110 ms (laptop, simulated model and Qdrant)
- deploy.sh: new step closes the project dir to other accounts and sets the database and
  backups to 0600/0700 on every release; user `nobody` on the shared server could read
  db.sqlite3 and every backup
- requirements: python-dotenv 1.2.2, urllib3>=2.8.0 (known CVEs in the installed versions),
  gunicorn pinned to the server's version (was only installed by hand)
- security-before-production.md: items 19-23 (file permissions, DEBUG=1 still in the systemd
  unit, dependencies, frontend headers, guest AI cap per shared IP) and the reproduced
  X-Forwarded-For spoof through the origin IP
- model-prices.md: 0.183 cents per answer from 50 answers in the live load test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:39:11 +03:30
f441cfa96b CLAUDE.md: grades 4/5 and Bazaar purchase path are deployed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 03:08:29 +03:30
8cb326ba94 Grades 4 and 5, Bazaar purchase from the app, pack SKUs
- rag: MATH_4 / MATH_5 textbooks (TOC and page offsets read from the PDFs)
- content: grades 4 and 5 in the seed files: 7 units / 28 nodes each,
  204 + 194 questions (every node >= 4), all re-solved blind once
- subscription: Plan.bazaar_sku (admin); POST /api/subscription/purchase
  accepts paymentMethod=bazaar for packs, rejects unknown methods (was ignored)
- payment: idempotency key cannot switch provider; discount codes refused
  on Bazaar orders (Bazaar charges the fixed SKU price)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:01:44 +03:30
ab5e53730a Audit fixes 2026-10-03: seed keeps admin edits, plan cache, refresh limit, payment and tutor guards
- seed_content no longer undoes admin work: a SeedRecord ledger (content/seed_sync.py)
  stops deleted/renamed/re-worded content from being recreated and keeps admin-edited
  lesson text; new rows in the seed files still arrive (migration content.0010)
- plan list cache is invalidated on Plan save (receiver used a stale 'v1' key)
- token refresh per-IP cap 30/hour -> 600/hour (TOKEN_REFRESH_IP_RATE)
- a failed gateway result only changes a pending order (forged NOK no longer
  overwrites refunded/canceled)
- tutor gets the correct answer only after the student has answered
- support reply notification moved to a TicketMessage post_save signal
- global daily cap on paid AI questions (AI_GLOBAL_DAILY_MAX, default 3000)
- Bazaar verify uses only the order's SKU; bazaar orders require a SKU
- /api/health warns on the committed signing key; deploy.sh saves local commits;
  400 for bad ?unit= / ?period=; account delete keeps the referrer's Conversion
- earlier today: no stub answers outside DEBUG, ?grade= removed, logged fallbacks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 16:05:27 +03:30
7e027c167c CLAUDE.md: the no-fallbacks fixes are deployed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 12:03:16 +03:30
7307bfcadb Remove silent fallbacks found in the 2026-10-03 audit
- /api/ai/ask: the stub answer is reachable only under DEBUG or
  RAG_FORCE_MOCK. Without RAG keys on the server it used to return 200 with
  "this is a stub, set OPENAI_API_KEY" and spend the student's quota; now 503.
- /api/home: the `grade` alias is gone and sending it is a 400 instead of a
  silent fallback to the user's own grade. No client sent it.
- activate_subscription logs ERROR when the paid order's plan no longer exists
  (it silently became 30 days).
- resolve_grade_title logs instead of `except: pass`; curriculum_io no longer
  reads an `intro_text` key no file contains.
- CLAUDE.md: "No silent fallbacks" rules for future agents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:38:42 +03:30
7e4b8b54a6 CLAUDE.md: the 2026-10-02 fixes are deployed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:20:27 +03:30
7683920b0b 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>
2026-10-02 23:10:50 +03:30
8a4753bd15 CLAUDE.md: what the 2026-10-02 review fixed and what is not deployed yet
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:43:50 +03:30
d7d168e82f admin: cancel a pending order, refund a paid one
There was no way to take an order back: staff could only edit the
status by hand, and turning 'paid' into 'failed' left the credits,
heart days or subscription days the order had given. Two OrderAdmin
actions now do it:
- cancel a pending order (a payment that does arrive later still counts);
- refund a paid order, after a confirmation page: takes back the pack's
  unspent credits (never below zero), its heart days, or the plan's
  subscription days, and undoes the referrer's commission.
The money itself is returned in the Zarinpal / Bazaar panel; the action
says so. Status is read-only in the order form. Steps in
docs/operations.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:37:30 +03:30
4700ccbf91 hearts: zero hearts really stops play
- The zero-hearts check moved inside the answer transaction: two wrong
  answers sent at once with one heart left were both accepted (reproduced
  with two threads).
- Finishing a lesson or an island without hearts and without having
  answered its questions is now 403 no_hearts; before, quiz/complete let
  a student move on island after island with zero hearts. Losing the last
  heart on the last question still finishes the island.
- A heart that came back while waiting is now saved before answering, so
  the reply no longer says 0 hearts left (the app then showed the
  no-hearts page to someone who had a heart).
- The 403 also carries fullRefillAt for the waiting screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:31:44 +03:30
0f0b779a4e docs: plan with 700 toman per question; pack profit margins
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:19:06 +03:30
3bafd3d9c4 docs: measured model price ledger (docs/model-prices.md)
What GapGPT actually billed per model, with dates and evidence, so a
later price rise can be compared with what it was. gpt-4.1-mini and
gpt-4o-mini were re-checked from the 852 recorded calls in
docs/benchmarks/2026-09-26-tutor-model: every call equals the official
OpenAI per-token price or less (input cache), none more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:12:49 +03:30
1803c76a8b schema: name the two 'kind' enums so the OpenAPI build is warning-free again
Plan.kind (pack/subscription) collided with the lesson kind
(quiz/video); drf-spectacular named one KindEd8Enum and warned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:11:22 +03:30
67cbe05609 tests: two questions at once with one credit, with real threads
The claim that the user-row lock plus SQLite IMMEDIATE stops one credit
being spent twice was untested. Two threads on separate connections now
ask at the same time: one answer, one credit spent; the same
Idempotency-Key arriving twice at once also costs one credit. Removing
transaction_mode IMMEDIATE turns both red (500, database is locked).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:07:08 +03:30
fe658c697a bazaar: a pack bought through Bazaar now gives its credits; verify reports the real order status
Bazaar orders are only created through POST /api/payment/orders, which
took no planId, so a pack order had plan_id=None: Bazaar verified the
purchase, the order became paid, and grant_pack never ran. The endpoint
now takes planId (rejected when unknown) and snapshots the plan title.
The verify response also returned the pre-payment status ('pending')
because apply_order_payment_result updates its own copy of the order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:04:41 +03:30
5fb9f4aaa0 guardrails: self-harm caught however it is typed; ZWNJ no longer slips an injection past the screen
The ZWNJ normalisation in screen() replaced ZWNJ with itself, so
'نادیده‌بگیر' passed, and self-harm sentences typed with a space or
without ZWNJ ('می خوام بمیرم', 'دیگه نمیخوام زنده باشم', 'خود کشی')
reached the model, which answered with the plain math-only refusal
(one live call, 0.018 cents). Self-harm is now matched on the text with
all spaces removed; the imperative must end the word, so 'نادیده بگیریم'
in a math sentence is no longer an injection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:02:07 +03:30
4c7ccf10a0 docs: question packs, credit rules, real hearts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 12:14:45 +03:30
58c8e6d014 question packs instead of monthly/yearly plans; hearts are real
- packs (20 / 60 / 150 questions for 45,000 / 119,000 / 259,000 toman) never expire and
  give 7 / 30 / 60 days of unlimited hearts; old subscription plans stay for existing
  subscribers but are no longer on sale
- credit ledger (subscription.QuestionCredit): +N per paid pack (once per order), -1 per
  tutor question that reaches the API; the free daily questions are spent first; questions
  stopped by the server filter, cached answers and failed answers cost nothing
- unlimited hearts from packs live in HeartsPass, so buying a pack never rewrites a
  running subscription
- Message.charge records how each question was paid; GET /api/ai/quota and the ask
  responses report free-left-today and credits
- HEARTS_ENFORCED now defaults to on: zero hearts stops answering until refill or a pack

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:41:39 +03:30
2edc56fc08 paid plans: 10 tutor questions a day at 179,000 / 1,790,000 toman
Five a day was too close to the free three. Pricing keeps the tutor under ~40% of net revenue
at typical use (a third of the cap); full numbers in the cost report and docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:11:31 +03:30
b3940e87f7 docs: age tone, public IDs, prices, account deletion and the test-account cleanup
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 06:50:33 +03:30
191aba0e25 tutor quota message in Persian digits
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 06:36:03 +03:30
2f1e257533 delete_users: only project models; Django admin history (LogEntry.user_id is staff) was being counted
Account deletion also keeps the status log of the payment records it keeps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 06:20:05 +03:30
63cd1af6f7 public user IDs, plans priced by tutor cost, age-based tutor tone, self-service account deletion
- profile publicId ("User482917", accounts/public_id.py); admin shows and searches it
- plans API reports aiDailyQuota per plan and freeAiDailyQuota; migration 0007 sets the
  temporary prices (monthly 99,000 / yearly 990,000 toman, 5 tutor questions a day) unless
  an admin already edited the yearly plan
- tutor tone follows the learner's age (18+ gets an adult tone at any grade); notation
  still follows the grade's book; answer cache keyed by tone (prompt tutor-v12-age-tone)
- POST /api/user/delete: phone users confirm with a fresh SMS code (same checks as login,
  now shared in verify_phone_otp), guests type "حذف"; payment records are kept, demo
  accounts refused
- delete_users --skip-paid

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 06:10:17 +03:30
e9d676ce68 delete_users --real-phones: every phone user except the demo logins
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 02:41:16 +03:30
39247f6d53 senior's post-demo changes: parent report in the student's own account, Jalali birth date, invoices, math-only tutor, priced quotas
- remove the family app from the root (separate parent accounts, invite codes, parental
  settings); progress.0006 drops its tables, permissions and migration history, and
  Profile.parent_phone, the two family notification types and ?userId= on
  /api/progress/nodes go with it
- GET /api/user/weekly-report: last 7 days next to the 7 before (estimated minutes,
  questions, accuracy, islands, tutor questions) for the "parent report" page
- profile birthDate in Jalali (accounts/jalali.py), ages 5-99, birthYear derived from it
- GET /api/payment/invoices; orders now snapshot list price and plan title
- tutor answers only math: server pre-filter, scope rule in the prompt (tutor-v11),
  refusal marker replaced with fixed text; self-harm gets a help line instead
- streamed answers now record token usage (it was always 0)
- daily tutor quota: AI_DAILY_QUOTA_FREE (default 3) vs Plan.ai_daily_quota
- delete_users management command for wiping test accounts across every app
- docs/ai-cost-and-guardrails.md: measured cost per question and guardrail results

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 02:38:43 +03:30
d5dbd1b23d keep the Referer on the redirect to Zarinpal so StartPay accepts our domain
A test payment on 2026-10-01 stopped at StartPay with "access from this
domain is not allowed". The app opens /api/payment/orders/{id}/pay, which
302s to Zarinpal, and that response carried Django's default
Referrer-Policy: same-origin, so the browser dropped the Referer on the
cross-site hop and Zarinpal could not see tadrisino2.ir. Only this view now
sends strict-origin-when-cross-origin (origin only, no path or query);
every other response keeps same-origin.

Not yet confirmed by a second real attempt in a browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 08:56:13 +03:30
5e1613e431 docs: technical handbook without a version label, dated only
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:11:39 +03:30
a325def504 docs: technical handbook (Markdown + PDF) with use-case and relational models, scenario diagrams
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:38:21 +03:30
e3abd675db let Bazaar verification find out whether a SKU is a one-time product or a subscription
Nobody has told us how the plans are defined in the Bazaar panel. The
verify view always asked the in-app endpoint, so a subscription SKU would
get "Product is not found" and a 503. verify_purchase now defaults to
is_subscription=None: in-app first, and on that exact answer the same SKU
is asked as a subscription. A wrong package name still raises at once.

Seen against the live Bazaar API with a fake SKU and token only; no real
purchase has gone through it yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:31:51 +03:30
5f4d7b1ed4 record the Bazaar connection: company account authorized, package com.tadrisino.app set
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:24:00 +03:30
957c9d0f1e record the 2026-09-29 final pre-presentation pass: what shipped, measured tutor latency, test writes to demo data
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:23:07 +03:30
93b451d422 list a user's own support tickets, Persian digits in completion notifications, rewrite .env.example to match the code
GET /api/support/tickets returns the caller's last 20 tickets with their
support-reply count, on the read throttle so listing never spends the
30/hour create quota. Before it the app kept ticket ids only in one
browser's localStorage, so a ticket and its answer vanished with the device.

Completion notifications now use Persian digits and drop the
'with 0 stars ... 0 points' sentence when nothing was earned.

.env.example: NUM_PROXIES=2 (ArvanCloud + nginx), KAVENEGAR_OTP_TEMPLATE,
BAZAAR_PISHKHAN_API_SECRET, sandbox off by default; removed variables the
code no longer reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:18:12 +03:30
8321dc22c3 record the 2026-09-29 deploy: diagrams live, backup name, test guests to remove
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 08:18:29 +03:30
2c759a7dd7 docs: how tutor diagrams work, measured accuracy and cost
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 06:58:01 +03:30
50df7bd371 diagram kit: triangles and quadrilaterals from problem data, shape patterns, translate points, arithmetic/quote/duplicate-key repairs, clock guard (tutor-v10-shape-anchor)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 06:11:43 +03:30
a8cd681d65 an explicit 'draw it' request without topic words gets the whole diagram catalog (tutor-v8-asked-figure)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 03:34:12 +03:30
731dea9bb1 tutor-v7-diagram-kit: anchor short follow-ups to the previous question, new diagram pieces in the prompt
A real chat answered "what if 6 were 4?" with a semicircle problem from the
retrieved book excerpt instead of the parabola asked a moment before. A short
follow-up now carries the previous user question next to it. The catalog
teaches line equations as written, regularPolygon, exteriorAngle, graphs only
through function items and tangents through tangent; fraction words (یک‌پنجم،
نُه‌دهم) pull the fraction section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 02:37:50 +03:30
14a4d9cbf1 redraw the figure on follow-ups that only change the numbers; add a local fake OpenAI server
"What if 6 were 4?" has no shape word, so the diagram hint never went next to
it and the tutor would answer without a new figure. When the previous user
question in the conversation had one, a soft hint (redraw only if this
continues that problem) and the same catalog sections now go along.

tools/fake_openai.py stands in for GapGPT locally so the whole tutor path can be
exercised without credit: 12 scripted answers across grades 6-12 went through
streaming, block splitting, repair, storage, history and rendering in the real
chat screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 00:43:04 +03:30
0917ab487f diagrams for every grade, sectioned prompt, repair of real model slips; fill lesson texts and thin islands
Diagrams: 14 kinds drawn by the app (plane with constructed points, graphs with
computed roots/extrema/tangents, number lines with intervals, solids, charts,
tables, Venn, mapping, trees, fractions, grids, groups, clock). The catalog is
split by topic and only the matching sections go next to a question (200-1073
tokens instead of 1854). parse() repairs slips seen on 96 real book problems:
Persian digits in JSON numbers, 2*pi, // comments, bare items, Venn with
Persian set names. Measured: 45/48 valid on the second round, 0/8 on plain
arithmetic; visual review of 83: 64 right, 3 weak, 16 wrong.

Content: 82 new questions so no island has fewer than four; fill_lesson_texts
writes Unit.blurb (58) and Lesson.example/steps (590) from verified book
problems, never the lesson's own quiz problem. seed_content only fills empty
fields, so admin edits survive a deploy. Tedi unlocks at 500 xp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:22:13 +03:30
33d8f43d74 draw a diagram in tutor answers: validated JSON from the model, rendered by the app; add the fourth companion tedi
The model ends a shape question with <diagram>{json}</diagram>; rag/services/diagram.py
splits it off before mathtext, validates it against a whitelist and stores it on
Message.diagram (ai_tutor.0006). It reaches the app in the SSE done event and in
conversation history. Prompt tutor-v6-diagrams; a hint next to the question (shape
words only) raised gpt-4.1-mini from 3/12 to 12/12 diagrams, 0/8 on arithmetic.

progress.0005 adds tedi (600 xp) to the companion catalog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:44:44 +03:30
74c3ee5e86 record the Zarinpal fix: shared tadrisino2.ir merchant installed, PaymentRequest now 100
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:25:13 +03:30
4393087fa7 wait up to 10 s to reach Kavenegar and 30 s for its reply
Iranian network paths can be slow; the owner asked for 30 s. The frontend's
requestOtp timeout goes to 45 s in the same change so it outlasts both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:14:23 +03:30
ca46e66024 keep the login code when Kavenegar answers late instead of failing the request
Seen live on 2026-09-27: Kavenegar took 5.4 s, our 5 s timeout fired, the
API answered 503 and deleted the code, but the SMS arrived, so the site
said "service unavailable" while the phone held a code that no longer
worked. A read timeout now means "delivery unknown": the code is kept and
the request succeeds (the cooldown still blocks a second SMS). A failed
connection is still an explicit 503. Read timeout raised to 10 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:06:34 +03:30
9a1ee36c0a record the new Bazaar client and the real cause of Zarinpal -14 (merchant registered for tadrisino.org)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:45:58 +03:30
301a6942da record that the login SMS now arrives: template SMSVerify on the server, status 10
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:45:31 +03:30
c1c2a270c2 record the new Bazaar authorize link for mathapi.tadrisino2.ir
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 02:11:28 +03:30
93334635f2 record the domain move and the live frontend: mathapi live, Zarinpal still -14, demo data intact
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 01:39:56 +03:30
1f38465d88 move the API's own address to mathapi.tadrisino2.ir
The API moved to mathapi.tadrisino2.ir on 2026-09-26 (nginx server_name
changed at 23:22) and api.math.tadrisino2.ir no longer resolves. The code
default for PUBLIC_BASE_URL and the docs' curl examples still named the dead
host, so every paymentUrl, the Zarinpal callback and the Bazaar redirect the
backend builds pointed nowhere. Historical status notes in CLAUDE.md keep the
old name on purpose. config/tests.py now reads the API host from
PUBLIC_BASE_URL instead of a literal. 624 tests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 01:06:48 +03:30