A5-R3-R1 dormant product UI projection

#109
Files changed (3) hide show
  1. app.py +59 -4
  2. i18n.py +32 -4
  3. managed_auth.py +124 -6
app.py CHANGED
@@ -22,6 +22,7 @@ from config import APP_CONFIG
22
  from managed_auth import (
23
  cancel_generation_task,
24
  generation_task_status,
 
25
  recover_generation_task,
26
  require_alipay_login,
27
  render_logged_in_account,
@@ -645,12 +646,19 @@ def _process_pending_launch(lang_code: str) -> None:
645
  return
646
  mode = str(pending.get("mode") or "").strip().lower()
647
  idempotency_key = str(pending.get("idempotency_key") or "").strip()
 
 
 
 
 
 
648
  try:
649
  response = start_generation_task(
650
  idempotency_key=idempotency_key,
651
  mode=mode,
652
  interface_language=lang_code,
653
  target_words=int(st.session_state.get("sp_words") or APP_CONFIG["target_words_default"]),
 
654
  )
655
  except Exception as exc:
656
  st.session_state["sp_pending_launch"] = None
@@ -659,6 +667,8 @@ def _process_pending_launch(lang_code: str) -> None:
659
  st.session_state["sp_payment_required"] = True
660
  st.error(tr(lang_code, "quota_exhausted"))
661
  render_payment_purchase(lang_code)
 
 
662
  else:
663
  st.error(raw)
664
  return
@@ -1317,11 +1327,56 @@ def render_workspace_controls(user):
1317
 
1318
  st.text_input(tr(lang_code, "topic_label"), value=DEFAULT_TOPIC, disabled=active, key="sp_topic")
1319
  st.text_input(tr(lang_code, "keywords_label"), value=DEFAULT_KEYWORDS, disabled=active, key="sp_keywords")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1320
  st.number_input(
1321
- tr(lang_code, "words_label"),
1322
- min_value=int(APP_CONFIG["target_words_min"]),
1323
- max_value=int(APP_CONFIG["target_words_max"]),
1324
- value=int(APP_CONFIG["target_words_default"]),
1325
  step=1000,
1326
  disabled=active,
1327
  key="sp_words",
 
22
  from managed_auth import (
23
  cancel_generation_task,
24
  generation_task_status,
25
+ get_product_ui_projection,
26
  recover_generation_task,
27
  require_alipay_login,
28
  render_logged_in_account,
 
646
  return
647
  mode = str(pending.get("mode") or "").strip().lower()
648
  idempotency_key = str(pending.get("idempotency_key") or "").strip()
649
+ projection = get_product_ui_projection()
650
+ selected_product_tier = (
651
+ str(st.session_state.get("sp_product_tier") or "").strip().upper()
652
+ if projection.get("mode") == "a5_product"
653
+ else None
654
+ )
655
  try:
656
  response = start_generation_task(
657
  idempotency_key=idempotency_key,
658
  mode=mode,
659
  interface_language=lang_code,
660
  target_words=int(st.session_state.get("sp_words") or APP_CONFIG["target_words_default"]),
661
+ product_tier=selected_product_tier,
662
  )
663
  except Exception as exc:
664
  st.session_state["sp_pending_launch"] = None
 
667
  st.session_state["sp_payment_required"] = True
668
  st.error(tr(lang_code, "quota_exhausted"))
669
  render_payment_purchase(lang_code)
670
+ elif "a5_product_task_path_not_authorized" in raw:
671
+ st.error(tr(lang_code, "product_generation_not_active"))
672
  else:
673
  st.error(raw)
674
  return
 
1327
 
1328
  st.text_input(tr(lang_code, "topic_label"), value=DEFAULT_TOPIC, disabled=active, key="sp_topic")
1329
  st.text_input(tr(lang_code, "keywords_label"), value=DEFAULT_KEYWORDS, disabled=active, key="sp_keywords")
1330
+
1331
+ product_ui = get_product_ui_projection()
1332
+ if product_ui.get("mode") == "a5_product":
1333
+ products = product_ui.get("products") or {}
1334
+ tier_options = [tier for tier in ("STANDARD", "FLAGSHIP") if tier in products]
1335
+ if len(tier_options) != 2:
1336
+ st.error(tr(lang_code, "product_generation_not_active"))
1337
+ st.stop()
1338
+ selected_tier = str(st.session_state.get("sp_product_tier") or "STANDARD").strip().upper()
1339
+ if selected_tier not in tier_options:
1340
+ selected_tier = "STANDARD"
1341
+ st.session_state["sp_product_tier"] = selected_tier
1342
+ selected_tier = st.radio(
1343
+ tr(lang_code, "product_tier_label"),
1344
+ tier_options,
1345
+ index=tier_options.index(selected_tier),
1346
+ format_func=lambda tier: tr(lang_code, f"product_tier_{tier.lower()}"),
1347
+ horizontal=True,
1348
+ disabled=active,
1349
+ key="sp_product_tier",
1350
+ )
1351
+ selected_product = products[selected_tier]
1352
+ word_min = int(selected_product["min_body_words"])
1353
+ word_max = int(selected_product["max_body_words"])
1354
+ word_label = tr(
1355
+ lang_code,
1356
+ "words_label_product",
1357
+ min_words=word_min,
1358
+ max_words=word_max,
1359
+ )
1360
+ else:
1361
+ st.session_state.pop("sp_product_tier", None)
1362
+ word_min = int(APP_CONFIG["target_words_min"])
1363
+ word_max = int(APP_CONFIG["target_words_max"])
1364
+ word_label = tr(
1365
+ lang_code,
1366
+ "words_label",
1367
+ min_words=word_min,
1368
+ max_words=word_max,
1369
+ )
1370
+
1371
+ current_words = int(st.session_state.get("sp_words") or APP_CONFIG["target_words_default"])
1372
+ bounded_words = min(word_max, max(word_min, current_words))
1373
+ if st.session_state.get("sp_words") != bounded_words:
1374
+ st.session_state["sp_words"] = bounded_words
1375
  st.number_input(
1376
+ word_label,
1377
+ min_value=word_min,
1378
+ max_value=word_max,
1379
+ value=bounded_words,
1380
  step=1000,
1381
  disabled=active,
1382
  key="sp_words",
i18n.py CHANGED
@@ -208,7 +208,12 @@ TEXTS: dict[str, dict[str, str]] = {
208
  "title": "📊 工商管理论文智能体",
209
  "topic_label": "📝 论文名称",
210
  "keywords_label": "🔍 关键字",
211
- "words_label": "📏 目标总单词数(5000–20000单词)",
 
 
 
 
 
212
  "success": "🎉 流水线推演完毕!双语文档已就绪。",
213
  "download": "📥 下载文件",
214
  "system_busy_title": "⏳ 系统忙碌",
@@ -385,6 +390,8 @@ TEXTS: dict[str, dict[str, str]] = {
385
  "account_chip_user": "用户 {value}",
386
  "account_chip_free": "免费 {value}篇",
387
  "account_chip_bonus": "长期 {value}篇",
 
 
388
  "logout_short": "退出",
389
  "payment_dialog_title": "支付宝付款",
390
  "payment_notice": "今日免费额度和长期额度均已用完。支付9.9元可增加3篇长期有效额度。",
@@ -408,7 +415,12 @@ TEXTS: dict[str, dict[str, str]] = {
408
  "title": "📊 Business Administration Article Agent",
409
  "topic_label": "📝 Article Title",
410
  "keywords_label": "🔍 Keywords",
411
- "words_label": "📏 Target Word Count (5,000–20,000 words)",
 
 
 
 
 
412
  "success": "🎉 The pipeline is complete. The bilingual documents are ready.",
413
  "download": "📥 Download Files",
414
  "system_busy_title": "⏳ System Busy",
@@ -585,6 +597,8 @@ TEXTS: dict[str, dict[str, str]] = {
585
  "account_chip_user": "User {value}",
586
  "account_chip_free": "Free {value}",
587
  "account_chip_bonus": "Bonus {value}",
 
 
588
  "logout_short": "Sign out",
589
  "payment_dialog_title": "Alipay Payment",
590
  "payment_notice": "Your daily free quota and long-term quota are exhausted. Pay CNY 9.9 to add three long-term papers.",
@@ -608,7 +622,12 @@ TEXTS: dict[str, dict[str, str]] = {
608
  "title": "📊 Интеллектуальный агент для научных статей по управлению бизнесом",
609
  "topic_label": "📝 Название статьи",
610
  "keywords_label": "🔍 Ключевые слова",
611
- "words_label": "📏 Целевой объём (5 000–20 000 слов)",
 
 
 
 
 
612
  "success": "🎉 Работа конвейера завершена. Двуязычные документы готовы.",
613
  "download": "📥 Скачать файлы",
614
  "system_busy_title": "⏳ Система занята",
@@ -785,6 +804,8 @@ TEXTS: dict[str, dict[str, str]] = {
785
  "account_chip_user": "Пользователь {value}",
786
  "account_chip_free": "Бесплатно {value}",
787
  "account_chip_bonus": "Запас {value}",
 
 
788
  "logout_short": "Выйти",
789
  "payment_dialog_title": "Оплата через Alipay",
790
  "payment_notice": "Ежедневная бесплатная и долгосрочная квоты исчерпаны. Оплата 9,9 юаня добавляет квоту на три статьи без срока действия.",
@@ -808,7 +829,12 @@ TEXTS: dict[str, dict[str, str]] = {
808
  "title": "📊 Бизнес-басқару жөніндегі ғылыми мақала интеллектуалды агенті",
809
  "topic_label": "📝 Мақала атау��",
810
  "keywords_label": "🔍 Түйін сөздер",
811
- "words_label": "📏 Мақсатты көлем (5 000–20 000 сөз)",
 
 
 
 
 
812
  "success": "🎉 Жұмыс үдерісі аяқталды. Екі тілдегі құжаттар дайын.",
813
  "download": "📥 Файлдарды жүктеу",
814
  "system_busy_title": "⏳ Жүйе бос емес",
@@ -985,6 +1011,8 @@ TEXTS: dict[str, dict[str, str]] = {
985
  "account_chip_user": "Пайдаланушы {value}",
986
  "account_chip_free": "Тегін {value}",
987
  "account_chip_bonus": "Қор {value}",
 
 
988
  "logout_short": "Шығу",
989
  "payment_dialog_title": "Alipay арқылы төлеу",
990
  "payment_notice": "Күнделікті тегін және ұзақ мерзімді квота таусылды. 9,9 юань төлегенде мерзімі шектелмеген үш мақала квотасы қосылады.",
 
208
  "title": "📊 工商管理论文智能体",
209
  "topic_label": "📝 论文名称",
210
  "keywords_label": "🔍 关键字",
211
+ "words_label": "📏 目标总单词数({min_words}–{max_words}单词)",
212
+ "words_label_product": "📏 目标正文单词数({min_words}–{max_words}单词)",
213
+ "product_tier_label": "版本",
214
+ "product_tier_standard": "标准版",
215
+ "product_tier_flagship": "旗舰版",
216
+ "product_generation_not_active": "产品版任务生命周期尚未启用,请勿继续生成。",
217
  "success": "🎉 流水线推演完毕!双语文档已就绪。",
218
  "download": "📥 下载文件",
219
  "system_busy_title": "⏳ 系统忙碌",
 
390
  "account_chip_user": "用户 {value}",
391
  "account_chip_free": "免费 {value}篇",
392
  "account_chip_bonus": "长期 {value}篇",
393
+ "account_chip_standard": "标准版 {value}篇",
394
+ "account_chip_flagship": "旗舰版 {value}篇",
395
  "logout_short": "退出",
396
  "payment_dialog_title": "支付宝付款",
397
  "payment_notice": "今日免费额度和长期额度均已用完。支付9.9元可增加3篇长期有效额度。",
 
415
  "title": "📊 Business Administration Article Agent",
416
  "topic_label": "📝 Article Title",
417
  "keywords_label": "🔍 Keywords",
418
+ "words_label": "📏 Target Word Count ({min_words}–{max_words} words)",
419
+ "words_label_product": "📏 Target Body Word Count ({min_words}–{max_words} words)",
420
+ "product_tier_label": "Version",
421
+ "product_tier_standard": "Standard",
422
+ "product_tier_flagship": "Flagship",
423
+ "product_generation_not_active": "The product-tier task lifecycle is not active yet. Generation has been blocked to protect your balance.",
424
  "success": "🎉 The pipeline is complete. The bilingual documents are ready.",
425
  "download": "📥 Download Files",
426
  "system_busy_title": "⏳ System Busy",
 
597
  "account_chip_user": "User {value}",
598
  "account_chip_free": "Free {value}",
599
  "account_chip_bonus": "Bonus {value}",
600
+ "account_chip_standard": "Standard {value}",
601
+ "account_chip_flagship": "Flagship {value}",
602
  "logout_short": "Sign out",
603
  "payment_dialog_title": "Alipay Payment",
604
  "payment_notice": "Your daily free quota and long-term quota are exhausted. Pay CNY 9.9 to add three long-term papers.",
 
622
  "title": "📊 Интеллектуальный агент для научных статей по управлению бизнесом",
623
  "topic_label": "📝 Название статьи",
624
  "keywords_label": "🔍 Ключевые слова",
625
+ "words_label": "📏 Целевой объём ({min_words}–{max_words} слов)",
626
+ "words_label_product": "📏 Целевой объём основного текста ({min_words}–{max_words} слов)",
627
+ "product_tier_label": "Версия",
628
+ "product_tier_standard": "Стандарт",
629
+ "product_tier_flagship": "Флагман",
630
+ "product_generation_not_active": "Жизненный цикл задач продуктовых версий ещё не активирован. Генерация заблокирована для защиты баланса.",
631
  "success": "🎉 Работа конвейера завершена. Двуязычные документы готовы.",
632
  "download": "📥 Скачать файлы",
633
  "system_busy_title": "⏳ Система занята",
 
804
  "account_chip_user": "Пользователь {value}",
805
  "account_chip_free": "Бесплатно {value}",
806
  "account_chip_bonus": "Запас {value}",
807
+ "account_chip_standard": "Стандарт {value}",
808
+ "account_chip_flagship": "Флагман {value}",
809
  "logout_short": "Выйти",
810
  "payment_dialog_title": "Оплата через Alipay",
811
  "payment_notice": "Ежедневная бесплатная и долгосрочная квоты исчерпаны. Оплата 9,9 юаня добавляет квоту на три статьи без срока действия.",
 
829
  "title": "📊 Бизнес-басқару жөніндегі ғылыми мақала интеллектуалды агенті",
830
  "topic_label": "📝 Мақала атау��",
831
  "keywords_label": "🔍 Түйін сөздер",
832
+ "words_label": "📏 Мақсатты көлем ({min_words}–{max_words} сөз)",
833
+ "words_label_product": "📏 Негізгі мәтіннің мақсатты көлемі ({min_words}–{max_words} сөз)",
834
+ "product_tier_label": "Нұсқа",
835
+ "product_tier_standard": "Стандарт",
836
+ "product_tier_flagship": "Флагман",
837
+ "product_generation_not_active": "Өнімдік нұсқалардың тапсырма өмірлік циклі әлі іске қосылған жоқ. Балансты қорғау үшін генерация бұғатталды.",
838
  "success": "🎉 Жұмыс үдерісі аяқталды. Екі тілдегі құжаттар дайын.",
839
  "download": "📥 Файлдарды жүктеу",
840
  "system_busy_title": "⏳ Жүйе бос емес",
 
1011
  "account_chip_user": "Пайдаланушы {value}",
1012
  "account_chip_free": "Тегін {value}",
1013
  "account_chip_bonus": "Қор {value}",
1014
+ "account_chip_standard": "Стандарт {value}",
1015
+ "account_chip_flagship": "Флагман {value}",
1016
  "logout_short": "Шығу",
1017
  "payment_dialog_title": "Alipay арқылы төлеу",
1018
  "payment_notice": "Күнделікті тегін және ұзақ мерзімді квота таусылды. 9,9 юань төлегенде мерзімі шектелмеген үш мақала квотасы қосылады.",
managed_auth.py CHANGED
@@ -144,6 +144,7 @@ def _clear_session() -> None:
144
  for key in (
145
  "sp_session_expires_at", "sp_user",
146
  "sp_last_verified_at", "sp_edge_assertion", "sp_auth_api_origin",
 
147
  ):
148
  st.session_state.pop(key, None)
149
 
@@ -224,6 +225,84 @@ def _merge_quota_into_session(result: dict[str, Any]) -> None:
224
  ) + 1
225
 
226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  def get_account_snapshot(*, block: bool = False) -> dict[str, Any] | None:
228
  bucket = int(time.time() // 30)
229
  revision = int(st.session_state.get("sp_account_revision") or 0)
@@ -240,16 +319,38 @@ def get_account_snapshot(*, block: bool = False) -> dict[str, Any] | None:
240
  user = result.get("user")
241
  if isinstance(user, dict):
242
  st.session_state["sp_user"] = dict(user)
 
 
 
 
243
  # R3.1 single-writer rule: account refresh owns identity/quota only.
244
  # Task UI state is initialized by recover_generation_task() and thereafter
245
  # updated only by generation_task_status()/explicit task actions.
246
  return result
247
 
248
 
249
- def start_generation_task(*, idempotency_key: str, mode: str, interface_language: str, target_words: int) -> dict[str, Any]:
 
 
 
 
 
 
 
250
  normalized = str(idempotency_key or "").strip()
251
  if not normalized:
252
  raise RuntimeError("idempotency_key_required")
 
 
 
 
 
 
 
 
 
 
 
253
  result = _edge_call(
254
  "task_start",
255
  normalized,
@@ -458,8 +559,15 @@ def render_logged_in_account(
458
  else:
459
  user = user or st.session_state.get("sp_user", {})
460
  user_id = user.get("id", "—")
461
- free_remaining = user.get("daily_free_remaining", user.get("daily_free_limit", 3))
462
- bonus_papers = user.get("bonus_papers", 0)
 
 
 
 
 
 
 
463
 
464
  st.markdown(
465
  """
@@ -581,14 +689,24 @@ def render_logged_in_account(
581
 
582
  info_col, logout_col = st.columns([5.9, 1.65])
583
  with info_col:
 
 
 
 
 
 
 
 
 
 
 
 
584
  st.markdown(
585
  f"""
586
  <div class="sp-account-card">
587
  <span>{html.escape(tr(language, 'account_chip_user', value=user_id))}</span>
588
  <span class="sp-account-separator">•</span>
589
- <span>{html.escape(tr(language, 'account_chip_free', value=free_remaining))}</span>
590
- <span class="sp-account-separator">•</span>
591
- <span>{html.escape(tr(language, 'account_chip_bonus', value=bonus_papers))}</span>
592
  </div>
593
  """,
594
  unsafe_allow_html=True,
 
144
  for key in (
145
  "sp_session_expires_at", "sp_user",
146
  "sp_last_verified_at", "sp_edge_assertion", "sp_auth_api_origin",
147
+ "sp_product_ui_projection", "sp_product_tier",
148
  ):
149
  st.session_state.pop(key, None)
150
 
 
225
  ) + 1
226
 
227
 
228
+ def _legacy_product_ui_projection() -> dict[str, Any]:
229
+ return {
230
+ "schema_version": "a5-product-ui-v1",
231
+ "mode": "legacy",
232
+ "product_tier_selection_required": False,
233
+ "products": {},
234
+ }
235
+
236
+
237
+ def _normalize_product_ui_projection(value: Any) -> dict[str, Any]:
238
+ """Accept only the Worker-owned A5 UI projection; never reinterpret legacy quota fields."""
239
+ if not isinstance(value, dict):
240
+ return _legacy_product_ui_projection()
241
+ mode = str(value.get("mode") or "legacy").strip().lower()
242
+ if mode == "legacy":
243
+ return _legacy_product_ui_projection()
244
+ if mode != "a5_product":
245
+ raise RuntimeError("product_ui_projection_invalid_mode")
246
+
247
+ raw_products = value.get("products")
248
+ raw_entitlement = value.get("entitlement")
249
+ if not isinstance(raw_products, dict) or not isinstance(raw_entitlement, dict):
250
+ raise RuntimeError("product_ui_projection_incomplete")
251
+
252
+ products: dict[str, dict[str, Any]] = {}
253
+ for tier in ("STANDARD", "FLAGSHIP"):
254
+ raw = raw_products.get(tier)
255
+ if not isinstance(raw, dict):
256
+ raise RuntimeError("product_ui_projection_missing_tier")
257
+ try:
258
+ minimum = int(raw.get("min_body_words"))
259
+ maximum = int(raw.get("max_body_words"))
260
+ except (TypeError, ValueError) as exc:
261
+ raise RuntimeError("product_ui_projection_invalid_word_policy") from exc
262
+ if minimum <= 0 or maximum < minimum:
263
+ raise RuntimeError("product_ui_projection_invalid_word_policy")
264
+ products[tier] = {
265
+ "product_tier": tier,
266
+ "product_policy_version": str(raw.get("product_policy_version") or ""),
267
+ "word_count_policy_version": str(raw.get("word_count_policy_version") or ""),
268
+ "body_metric": str(raw.get("body_metric") or ""),
269
+ "min_body_words": minimum,
270
+ "max_body_words": maximum,
271
+ "counting_contract_version": str(raw.get("counting_contract_version") or ""),
272
+ }
273
+ if not all((
274
+ products[tier]["product_policy_version"],
275
+ products[tier]["word_count_policy_version"],
276
+ products[tier]["body_metric"],
277
+ products[tier]["counting_contract_version"],
278
+ )):
279
+ raise RuntimeError("product_ui_projection_incomplete")
280
+
281
+ try:
282
+ standard_remaining = int(raw_entitlement.get("standard_remaining"))
283
+ flagship_remaining = int(raw_entitlement.get("flagship_remaining"))
284
+ except (TypeError, ValueError) as exc:
285
+ raise RuntimeError("product_ui_projection_invalid_entitlement") from exc
286
+ if standard_remaining < 0 or flagship_remaining < 0:
287
+ raise RuntimeError("product_ui_projection_invalid_entitlement")
288
+
289
+ return {
290
+ "schema_version": str(value.get("schema_version") or "a5-product-ui-v1"),
291
+ "mode": "a5_product",
292
+ "product_tier_selection_required": True,
293
+ "products": products,
294
+ "entitlement": {
295
+ "standard_remaining": standard_remaining,
296
+ "flagship_remaining": flagship_remaining,
297
+ },
298
+ }
299
+
300
+
301
+ def get_product_ui_projection() -> dict[str, Any]:
302
+ value = st.session_state.get("sp_product_ui_projection")
303
+ return _normalize_product_ui_projection(value)
304
+
305
+
306
  def get_account_snapshot(*, block: bool = False) -> dict[str, Any] | None:
307
  bucket = int(time.time() // 30)
308
  revision = int(st.session_state.get("sp_account_revision") or 0)
 
319
  user = result.get("user")
320
  if isinstance(user, dict):
321
  st.session_state["sp_user"] = dict(user)
322
+ projection = _normalize_product_ui_projection(result.get("product_ui"))
323
+ st.session_state["sp_product_ui_projection"] = projection
324
+ if projection["mode"] != "a5_product":
325
+ st.session_state.pop("sp_product_tier", None)
326
  # R3.1 single-writer rule: account refresh owns identity/quota only.
327
  # Task UI state is initialized by recover_generation_task() and thereafter
328
  # updated only by generation_task_status()/explicit task actions.
329
  return result
330
 
331
 
332
+ def start_generation_task(
333
+ *,
334
+ idempotency_key: str,
335
+ mode: str,
336
+ interface_language: str,
337
+ target_words: int,
338
+ product_tier: str | None = None,
339
+ ) -> dict[str, Any]:
340
  normalized = str(idempotency_key or "").strip()
341
  if not normalized:
342
  raise RuntimeError("idempotency_key_required")
343
+ projection = get_product_ui_projection()
344
+ normalized_tier = str(product_tier or "").strip().upper()
345
+ if projection.get("mode") == "a5_product":
346
+ if normalized_tier not in projection.get("products", {}):
347
+ raise RuntimeError("product_tier_required")
348
+ # A5-R3-R1 is projection-only. Until entitlement reservation/task policy
349
+ # snapshot ownership is separately authorized, never fall through to the
350
+ # legacy quota task path.
351
+ raise RuntimeError("a5_product_task_path_not_authorized")
352
+ if normalized_tier:
353
+ raise RuntimeError("product_tier_not_available_in_legacy_mode")
354
  result = _edge_call(
355
  "task_start",
356
  normalized,
 
559
  else:
560
  user = user or st.session_state.get("sp_user", {})
561
  user_id = user.get("id", "—")
562
+ projection = get_product_ui_projection()
563
+ product_mode = projection.get("mode") == "a5_product"
564
+ if product_mode:
565
+ entitlement = projection["entitlement"]
566
+ standard_remaining = entitlement["standard_remaining"]
567
+ flagship_remaining = entitlement["flagship_remaining"]
568
+ else:
569
+ free_remaining = user.get("daily_free_remaining", user.get("daily_free_limit", 3))
570
+ bonus_papers = user.get("bonus_papers", 0)
571
 
572
  st.markdown(
573
  """
 
689
 
690
  info_col, logout_col = st.columns([5.9, 1.65])
691
  with info_col:
692
+ if product_mode:
693
+ balance_markup = (
694
+ f"<span>{html.escape(tr(language, 'account_chip_standard', value=standard_remaining))}</span>"
695
+ f'<span class="sp-account-separator">•</span>'
696
+ f"<span>{html.escape(tr(language, 'account_chip_flagship', value=flagship_remaining))}</span>"
697
+ )
698
+ else:
699
+ balance_markup = (
700
+ f"<span>{html.escape(tr(language, 'account_chip_free', value=free_remaining))}</span>"
701
+ f'<span class="sp-account-separator">•</span>'
702
+ f"<span>{html.escape(tr(language, 'account_chip_bonus', value=bonus_papers))}</span>"
703
+ )
704
  st.markdown(
705
  f"""
706
  <div class="sp-account-card">
707
  <span>{html.escape(tr(language, 'account_chip_user', value=user_id))}</span>
708
  <span class="sp-account-separator">•</span>
709
+ {balance_markup}
 
 
710
  </div>
711
  """,
712
  unsafe_allow_html=True,