---
title: "ADR-001: product decisions"
description: "Every open decision in the PRD, resolved: training path, scoring, rewards, accounts, paywall, languages."
source: "docs/decisions/ADR-001-product-decisions.md"
---

> Part of [Decisions](/decisions/llms.txt). Every page: [llms.txt](/llms.txt).

# ADR-001 — Product decisions for v1 (binding)

Status: Accepted 2026-10-17. Owner: TPM. Supersedes the "待决策" list in PRD v1.0 §11.
Anything here can be changed by a PR that updates this file **and** the affected contract/design.

## D1. Training path = 3 steps

阅读 (read) → 复述 (first retell) → 压缩重讲 (compressed retell) → 完成 (settlement). "观点迁移" (transfer) is not in v1 and is not shown anywhere in the UI. Progress indicators are 1/3, 2/3, 3/3.

## D2. Recording limits and validity

| Rule | Round 1 | Round 2 |
|---|---|---|
| Max duration | 180 s, auto-stop then confirm sheet | 120 s, auto-stop then confirm sheet |
| Client floor | 20 s of unpaused recording and level activity above noise floor | same |
| Server validity | transcript length ≥ the per-language floor (zh-Hans: 40 characters; en: 30 words) **and** speech ratio ≥ 0.35 **and** detected language = the article's language | same |
| Outcome if invalid | `attempt.status = "unreliable"` with `reason` (`too_short`, `no_speech`, `language_mismatch`, `low_audio_quality`, `transcript_incomplete`). No score, no XP. User may re-record for free; the session stays in the same stage. | same |

Pause is allowed; paused time does not count. Backgrounding, incoming call, or route change pauses and persists the partial recording to disk; on return the user chooses 继续 / 重录. Leaving the screen stops capture and the timer.

## D3. Scoring

Four dimensions, each 0–100: `key_info` 关键信息, `structure` 逻辑结构, `efficiency` 表达效率, `fluency` 流畅度.

- `overall = round(0.35*key_info + 0.30*structure + 0.20*efficiency + 0.15*fluency)`
- Bands: `< 50` 需要再练, `50–69` 基础不错, `70–84` 讲清楚了, `≥ 85` 出色.
- Every assessment carries `rubric_version` (start `rubric-v1`), `model_ref` (provider/model), `confidence` (`high|medium|low`). `low` confidence is surfaced as "这次评估不太确定" with the reason, and the one-action advice is still given if available; `unreliable` (D2) is a different, non-scored outcome.
- Evidence: each dimension note and the one-action must reference ≥ 1 evidence item `{start_ms, end_ms, transcript_excerpt}` that falls inside the attempt duration (server validates bounds). Dimension notes with no evidence are rendered as "依据不足" and do not lower the score.
- Rubric prompts are language-aware: the assessor is instructed in the article's language and judges idiom, structure and fillers against that language's norms (e.g. 嗯/那个 vs um/like). Scores are comparable across languages only within the same rubric version.
- Fluency is judged from audio timing (pause map from ASR word timestamps), never from transcript punctuation alone. Accent, stutter, and disability-related speech patterns are explicitly out of scope of fluency; the rubric penalises only pauses/breaks that affect comprehension.
- The assessor is given the article's `key_points` (hidden from the client before assessment), and scores coverage by meaning, not by string match.

**Long-term ability score (能力分)**: per dimension and overall, `ability = EMA(alpha=0.3)` over the per-session best attempt score, computed server-side at settlement with `algo_version = "ability-v1"`. Shown only when `sample_count >= 3`; before that the UI shows "建立基线中 n/3". Session delta (`+18` in the prototype) is **not** the ability delta; the completion screen shows the round-to-round delta and, separately, the ability change if any.

## D4. Rewards, streaks, levels

| Reward | XP | Idempotency key |
|---|---|---|
| Round 1 assessed (not unreliable) | 15 | `{session_id}:round1` |
| Round 2 assessed | 20 | `{session_id}:round2` |
| Session completed | 10 | `{session_id}:complete` |
| Improvement bonus (round 2 overall > round 1 overall, or same overall with shorter duration and `key_info` not lower) | 10 | `{session_id}:improve` |

- Weekly quests award XP on completion: 复述 5 次 30, 压缩 3 次 30, 做到建议 3 次 40. Weekly-quest XP does **not** count toward the daily cap (it is a weekly reward); it is ledgered with type `weekly_quest` and idempotency key `{user}:{week_start}:{quest_id}`.
- Daily XP cap 165 (user's time zone) applies to session XP (round1, round2, complete, improve). Repeating an article completed in the last 7 days earns 50 % XP, rounded down. Diagnostic session earns XP once.
- Streak: +1 on the first **completed** session of a local day. Multiple sessions per day do not add days. One streak freeze is granted at each 7-day milestone (max 2 banked); a missed day consumes a freeze automatically. Time-zone changes cannot create a second "first session" on the same UTC day: streak day keys are stored as the local date at completion; when the user's time zone differs from the one at the previous increment, a new increment is refused if less than 20 h have passed (same-zone consecutive local days always count, so an honest 23:00 → 08:00 pair is fine).
- Levels (cumulative XP): L1 0, L2 100, L3 250, L4 450, L5 700, L6 1000, L7 1350, L8 1750, L9 2200, L10 2700, then +600 per level. Titles: L1–2 开口者, L3–4 讲述者, L5–6 清晰表达者, L7–8 简洁表达者, L9–10 结构大师, L11+ 影响者.
- Settlement (`POST /v1/sessions/{id}/complete`) is a single transaction, idempotent on `session_id`; repeated calls return the original settlement.

## D5. Accounts and sync

First launch creates an anonymous server account (`POST /v1/auth/anonymous` with a Keychain-persisted install id). Sign in with Apple upgrades the same account (merges on Apple `sub`). Server is authoritative for all stats; iOS keeps a read cache for offline display and a local queue for pending uploads/events. No separate "device sync" feature in v1.

## D6. Audio, privacy, AI processing

- Audio uploaded as `audio/mp4` (AAC, 48 kbps mono, 16 kHz) to R2 via presigned PUT. Default retention 30 days (user setting: 7 / 30 / 365). Transcripts and assessments are kept until the user deletes the session or the account.
- Third-party AI providers are used for ASR, assessment and TTS. This is disclosed in onboarding and in Settings. Audio is not used to train models. Minimum age 13 (App Store 4+ rating is not sought; the app is 12+ with no UGC exposure).
- `DELETE /v1/sessions/{id}` and `DELETE /v1/me/training-data` cascade to audio, transcript, assessment, evidence, ledger (ledger rows are tombstoned, stats are recomputed). Backup purge within 30 days, stated in the privacy copy.
- Recording only starts on an explicit tap; a persistent recording indicator stays visible; the first tap shows a purpose sheet before the system microphone prompt.

## D7. Release batches

- **v1.0**: onboarding, diagnostic, paywall, 3-step loop with real ASR + assessment, exemplar TTS, library with topic filter, growth, profile, weekly quests, settings and deletion, reminders.
- **v1.1**: article import (text paste / URL), weekly report push, streak freeze UI.
- **v2**: 观点迁移, personalised recommendations, Android.

## D8. Business model — free diagnostic, hard paywall

Flow: splash → onboarding (goal, scenario, self-assessed level, weekly commitment, reminder time, notification permission) → **diagnostic session** (one short article, round 1 only, 90 s cap; this is the real pipeline, not a demo) → result reveal (four dimensions + one action, with evidence) → **plan** (14-day plan targeted at the weakest dimension) → **paywall** → trial.

The paywall is a *hard* paywall: nothing past it is usable without an active entitlement (trial or paid), including the library and growth tabs. It must be **interactive and honest**:

- Shows the user's own diagnostic `overall` and weakest dimension.
- A weekly-days control (3 / 5 / 7) that updates a projection: "起点 N 分的用户，每周练 K 天，14 天后平均达到 M 分（样本 S 人）". Numbers come from `GET /v1/paywall` → `benchmarks[]`, computed by a nightly cohort job; until real cohorts exist, the server returns pilot values with `source: "pilot_benchmark"` and the UI labels them "试点数据".
- A 14-day calendar showing the plan (day 1 = 先说结论, …), the streak milestones and when the trial converts.
- Products: `clarisay.pro.annual` (default, trial 7 days), `clarisay.pro.monthly`. Prices are set in App Store Connect; the UI reads them from the store (via RevenueCat), never hard-codes them. Restore purchases, Terms, Privacy links.
- Purchases go through **RevenueCat** (amended 2026-10-17): project "Clarisay", app `com.weizhi.shuke`, entitlement `pro` (both products), offering `default` (`$rc_annual`, `$rc_monthly`). The RevenueCat app user id is the server `user_id`.
- Entitlement is still decided server-side: after a purchase/restore the app calls `POST /v1/subscriptions/revenuecat/sync`, and the RevenueCat webhook (`POST /v1/webhooks/revenuecat`) keeps it current; both re-read RevenueCat's subscriber record. The client trusts `GET /v1/me.entitlement` with a 24 h grace cache for offline. `POST /v1/subscriptions/apple/transactions` and `/v1/webhooks/apple` stay only for pre-RevenueCat builds (deprecated).

## D9. Progressive improvement model

- **Micro-skills** (fixed catalogue v1, `micro_skill_id`): `conclusion_first` 先说结论, `three_points` 三点结构, `cut_fillers` 删掉口头禅, `keep_constraints` 保留关键限制, `one_example` 一个例子, `name_the_claim` 先点明观点, `drop_repeats` 不重复. Every one-action in an assessment maps to exactly one micro-skill.
- **Focus skill**: the user's current focus = micro-skill most recently suggested and not yet achieved twice in a row. Home shows "上次建议：先说结论". Each new assessment returns `achieved_previous_action: true|false|null` so the feedback screen can say "做到了 ✓ 上次的建议" before anything else.
- **Mastery** per micro-skill = achieved / suggested over the last 10 sessions; shown on growth as a skill grid.
- **Difficulty tiers**: articles have `difficulty` 1–3 (length 300–450 / 450–650 / 650–900 字, argument complexity). Tier 2 unlocks at ability ≥ 70 or 10 completed sessions; tier 3 at ability ≥ 80. Daily quest picks the highest unlocked tier in the user's preferred topics, unseen first.
- **14-day plan**: generated at onboarding from the diagnostic; each day has a theme micro-skill. The plan is advisory (the loop is the same), it changes the daily quest's one-action emphasis and the copy.

## D10. "A bit addictive", within limits

Mechanics in v1: daily quest card with XP badge, streak with flame and freeze, XP bar that fills with animation at settlement, level-up moment, achievements (首战告捷, 先说结论 ×3, 连续 3 天, 连续 7 天, 10 次训练, 效率 +10, 压缩成功 ×5), weekly quests (复述 5 次 / 压缩 3 次 / 做到建议 3 次), the before/after block on every completion, a "明天解锁下一个趋势点" hook on growth, reminder at the user's chosen time with streak-at-risk copy at 20:00 local if no session yet.
Not allowed: guilt copy, fake scarcity, countdowns on the paywall, decoy pricing, notification more than once per day.

## D10a. Accent colour contrast

Coral `#FF6030` on white is 3.0:1, which passes WCAG only for large/bold text. Rule: coral backgrounds carry text ≥ 17 pt bold (buttons, badges), never body text; coral text on light surfaces uses the darker `#C4421A` (`accent-primary-strong`; amended 2026-10-18 from `#E14E21`, which measures 3.75:1 on the canvas and fails body text, to the nearest coral at 4.5:1 or more); when the system Increase Contrast setting is on, the primary button background switches to `#C2410C` (white label 5.2:1, as design/tokens.css specifies). Tokens: `accent-primary` (#FF6030), `accent-primary-strong` (#C4421A), high-contrast `accent-primary` (#C2410C).

## D11. Dynamic AI providers

Backend exposes three capabilities — `asr`, `llm`, `tts` — each with a provider chain configured at runtime (KV `ai:providers`, editable via `/v1/admin/ai-providers`, with a code default). Providers in v1: ASR = Workers AI Whisper, OpenAI `whisper-1`/`gpt-4o-transcribe`, Deepgram; LLM = Anthropic (default `claude-sonnet-4-5` or newer), OpenAI, Google Gemini, Workers AI; TTS = OpenAI TTS, ElevenLabs, Workers AI. Each adapter implements the same interface and returns a normalised result plus `model_ref`. Fallback on provider error; never fallback on content-policy or validation failure.

## D12. Analytics

Event names from the PRD §10 are canonical: `training_start`, `reading_ready`, `recording_start`, `recording_pause`, `attempt_submit`, `assessment_success`, `assessment_fail`, `feedback_view`, `retry_start`, `training_complete`, `reward_granted`, `report_view`, plus `onboarding_step`, `diagnostic_complete`, `paywall_view`, `paywall_interact`, `trial_start`, `purchase`. Common properties: anonymous user id, session_id, article_id, round, screen, ts, error_code, app_version. Never audio, article text, transcript or evidence text.

## D13. Languages — the product is multilingual

- **UI locales** in v1: `en`, `zh-Hans`. Follows the device locale; user can override in Settings. Every string exists in both; the UI never assumes a script (no CJK-only layout, no hard-coded 字 units).
- **Practice language** (the language the user reads and speaks in) is chosen in onboarding, default = UI locale, changeable any time. v1 practice languages: `en`, `zh-Hans`. The daily quest, quick picks and library are filtered by the user's practice language(s); the diagnostic uses the practice language.
- **Content** is per-article: `language` (BCP-47), `char_count` **or** `word_count` as appropriate, `est_read_seconds` computed with a per-language reading rate (zh-Hans 400 chars/min, en 230 words/min). Seeds ship ≥ 8 articles per language per launch.
- **AI pipeline**: ASR receives the article language as a hint and the detected language must match it (else `unreliable: language_mismatch`); assessment prompt, one-action copy, exemplar text/TTS voice are all in the article's language; micro-skill titles and instructions are localized by the API using `Accept-Language`.
- **API strings**: any user-facing string the API returns (band labels, verdict labels, headlines, quest titles, error messages) is localized from the request's `Accept-Language` (fallback `en`). Content strings (article body, transcript, assessment notes) are in the article's language regardless of UI locale.
- **Stats** (XP, streaks, ability) are per user, not per language; ability dimensions carry the language of the sessions that produced them, and growth can filter by language.
- **Landing**: English default with a zh-Hans version; legal pages exist in both.
- Adding a language later = adding seeds, a reading rate, a validity floor, a TTS voice and the string catalog entries; no code paths may branch on `language === "zh-Hans"` except those tables.
