U1-R2A Unified Auto-Method UI + Four-Language Copy

#126
Files changed (4) hide show
  1. app.py +321 -94
  2. core/advanced_method_registry.py +340 -0
  3. i18n.py +191 -19
  4. task_executor.py +30 -4
app.py CHANGED
@@ -30,8 +30,12 @@ from managed_auth import (
30
  start_generation_task,
31
  )
32
  from i18n import (
33
- LANGUAGE_OPTIONS, get_amj_csv_template, get_asq_txt_template,
34
- get_output_suffix, get_target_flag, get_target_language, tr,
 
 
 
 
35
  )
36
  from smartpaper_restore_bridge import edge_bridge
37
 
@@ -73,6 +77,12 @@ def inject_file_uploader_i18n(language: str) -> None:
73
  payload = {
74
  "browse": tr(language, "browse_files"),
75
  "uploaders": {
 
 
 
 
 
 
76
  tr(language, "upload_reference"): {
77
  "title": tr(language, "drop_docs"),
78
  "limit": tr(language, "limit_docs"),
@@ -294,6 +304,9 @@ for key, default in {
294
  "sp_last_ui_task_status": "",
295
  "sp_scroll_to_generation_action": False,
296
  "sp_completed_download_scroll_task_id": "",
 
 
 
297
  }.items():
298
  if key not in st.session_state:
299
  st.session_state[key] = default
@@ -435,6 +448,23 @@ def _task_mode(task: dict) -> str:
435
  return "amj" if mode == "amj" else "asq"
436
 
437
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
438
  def _task_output_path(task: dict) -> Path | None:
439
  task_id = str(task.get("task_id") or "").strip()
440
  filename = os.path.basename(str(task.get("output_filename") or "").strip())
@@ -462,7 +492,100 @@ def _save_uploaded(items, inputs_dir: Path, prefix: str) -> list[str]:
462
  return saved
463
 
464
 
465
- def _persist_task_request(task: dict, mode: str, lang_code: str) -> dict:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
466
  task_id = str(task.get("task_id") or "").strip()
467
  numeric_id = (st.session_state.get("sp_user") or {}).get("numeric_id")
468
  if not task_id or not isinstance(numeric_id, int) or numeric_id <= 0:
@@ -471,19 +594,23 @@ def _persist_task_request(task: dict, mode: str, lang_code: str) -> dict:
471
  inputs_dir = task_dir / "inputs"
472
  inputs_dir.mkdir(parents=True, exist_ok=True)
473
 
474
- if mode == "amj":
475
- refs = _save_uploaded(st.session_state.get("up_amj_ref") or [], inputs_dir, "ref")
476
- data = _save_uploaded([st.session_state.get("up_amj_data")], inputs_dir, "data")
477
- interviews = []
478
- else:
479
- refs = _save_uploaded(st.session_state.get("up_asq_ref") or [], inputs_dir, "ref")
480
- data = []
481
- interviews = _save_uploaded([st.session_state.get("up_asq_data")], inputs_dir, "interview")
482
 
483
  request_data = {
484
  "task_id": task_id,
485
  "user_id": numeric_id,
486
  "mode": mode,
 
 
 
 
487
  "interface_language": lang_code,
488
  "target_lang": get_target_language(lang_code),
489
  "output_suffix": get_output_suffix(lang_code),
@@ -502,7 +629,6 @@ def _persist_task_request(task: dict, mode: str, lang_code: str) -> dict:
502
  _atomic_json(task_dir / "request.json", request_data)
503
  return request_data
504
 
505
-
506
  def _pid_alive(pid: int) -> bool:
507
  if not isinstance(pid, int) or pid <= 0:
508
  return False
@@ -644,8 +770,36 @@ def _process_pending_launch(lang_code: str) -> None:
644
  pending = st.session_state.get("sp_pending_launch")
645
  if not isinstance(pending, dict):
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()
@@ -655,7 +809,7 @@ def _process_pending_launch(lang_code: str) -> None:
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,
@@ -684,10 +838,20 @@ def _process_pending_launch(lang_code: str) -> None:
684
  return
685
  st.session_state["sp_generation_task"] = task
686
  st.session_state["sp_last_ui_task_status"] = str(task.get("status") or "")
 
687
  try:
688
  request_path = _task_dir(task_id) / "request.json"
689
  if not request_path.is_file():
690
- _persist_task_request(task, mode, lang_code)
 
 
 
 
 
 
 
 
 
691
  _launch_executor(task_id)
692
  except Exception as exc:
693
  st.session_state["sp_pending_launch"] = None
@@ -1191,9 +1355,9 @@ def _localized_terminal_task_error(task: dict, lang_code: str) -> tuple[str, str
1191
  return detail or code or tr(lang_code, "node_error"), ""
1192
 
1193
 
1194
- def _inject_generation_action_scroll(mode: str) -> None:
1195
- """Scroll immediately to the active generation action without a server wait."""
1196
- target_id = "sp-generation-action-asq" if str(mode or "").lower() == "asq" else "sp-generation-action-amj"
1197
  components.html(
1198
  f"""
1199
  <script>
@@ -1222,8 +1386,7 @@ def _scroll_to_generation_action_once() -> None:
1222
  if not bool(st.session_state.get("sp_scroll_to_generation_action")):
1223
  return
1224
  st.session_state["sp_scroll_to_generation_action"] = False
1225
- mode = str(st.session_state.get("run_mode") or "asq")
1226
- fallback_id = "sp-generation-action-asq" if mode.lower() == "asq" else "sp-generation-action-amj"
1227
  components.html(
1228
  f"""
1229
  <script>
@@ -1253,35 +1416,6 @@ def _scroll_to_generation_action_once() -> None:
1253
  )
1254
 
1255
 
1256
- def _select_active_task_tab(task: dict, lang_code: str) -> None:
1257
- """Keep the existing visual tab aligned with the immutable task mode after rerun/refresh."""
1258
- status = str((task or {}).get("status") or "")
1259
- mode = str((task or {}).get("mode") or "").strip().lower()
1260
- if mode not in {"amj", "asq"}:
1261
- return
1262
- if status not in (ACTIVE_TASK_STATUSES | {"resumable", "completed", "failed", "cancelled"}):
1263
- return
1264
- label = tr(lang_code, "tab_asq" if mode == "asq" else "tab_amj")
1265
- components.html(
1266
- f"""
1267
- <script>
1268
- (() => {{
1269
- const parentDocument = window.parent.document;
1270
- const expected = {json.dumps(label)};
1271
- const select = () => {{
1272
- const tabs = Array.from(parentDocument.querySelectorAll('[role="tab"]'));
1273
- const target = tabs.find((node) => (node.textContent || '').trim() === expected.trim());
1274
- if (target && target.getAttribute('aria-selected') !== 'true') target.click();
1275
- }};
1276
- [0, 80, 220, 500, 900].forEach((delay) => window.parent.setTimeout(select, delay));
1277
- }})();
1278
- </script>
1279
- """,
1280
- height=0,
1281
- width=0,
1282
- )
1283
-
1284
-
1285
  def render_workspace_controls(user):
1286
  top_toolbar = st.container()
1287
  with top_toolbar:
@@ -1332,6 +1466,103 @@ def render_workspace_controls(user):
1332
  st.text_input(tr(lang_code, "topic_label"), value=DEFAULT_TOPIC, disabled=active, key="sp_topic")
1333
  st.text_input(tr(lang_code, "keywords_label"), value=DEFAULT_KEYWORDS, disabled=active, key="sp_keywords")
1334
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1335
  product_ui = get_product_ui_projection()
1336
  if product_ui.get("mode") == "a5_product":
1337
  products = product_ui.get("products") or {}
@@ -1386,51 +1617,47 @@ def render_workspace_controls(user):
1386
  key="sp_words",
1387
  )
1388
 
1389
- tab_asq, tab_amj = st.tabs([tr(lang_code, "tab_asq"), tr(lang_code, "tab_amj")])
1390
- with tab_asq:
1391
- st.markdown(tr(lang_code, "asq_config"))
1392
- st.info(tr(lang_code, "asq_info"))
1393
- st.file_uploader(tr(lang_code, "upload_reference"), type=["pdf", "doc", "docx"], accept_multiple_files=True, key="up_asq_ref", disabled=active)
1394
- st.file_uploader(tr(lang_code, "upload_interviews"), type=["txt", "pdf", "doc", "docx"], key="up_asq_data", disabled=active)
1395
- with st.expander(tr(lang_code, "asq_expander")):
1396
- st.markdown(tr(lang_code, "asq_hint"))
1397
- template = get_asq_txt_template(lang_code)
1398
- st.code(template, language="text")
1399
- st.download_button(label=tr(lang_code, "download_asq_template"), data=template, file_name="ASQ_Interview_Template.txt", mime="text/plain", use_container_width=True)
1400
- st.markdown("<div id='sp-generation-action-asq'></div>", unsafe_allow_html=True)
1401
- if active and _task_mode(task) == "asq":
1402
- if st.button(tr(lang_code, "cancel_generation"), key="cancel_asq"):
1403
- st.session_state["sp_pending_cancel_task_id"] = str(task.get("task_id") or "")
1404
- st.rerun()
1405
- elif st.button(tr(lang_code, "start_asq"), disabled=active, key="btn_asq"):
1406
- st.session_state["run_mode"] = "asq"
1407
- st.session_state["sp_pending_launch"] = {"mode": "asq", "idempotency_key": _new_generation_idempotency_key()}
1408
- st.session_state["sp_scroll_to_generation_action"] = True
1409
- st.info(tr(lang_code, "pipeline_start"))
1410
- _inject_generation_action_scroll("asq")
1411
-
1412
- with tab_amj:
1413
- st.markdown(tr(lang_code, "amj_config"))
1414
- st.info(tr(lang_code, "amj_info"))
1415
- st.file_uploader(tr(lang_code, "upload_reference"), type=["pdf", "doc", "docx"], accept_multiple_files=True, key="up_amj_ref", disabled=active)
1416
- st.file_uploader(tr(lang_code, "upload_stats"), type=["csv", "xlsx", "xls"], key="up_amj_data", disabled=active)
1417
- with st.expander(tr(lang_code, "amj_expander")):
1418
- st.markdown(tr(lang_code, "amj_hint"))
1419
- template = get_amj_csv_template(lang_code)
1420
- st.code(template, language="csv")
1421
- st.download_button(label=tr(lang_code, "download_amj_template"), data=template, file_name="AMJ_Data_Template.csv", mime="text/csv", use_container_width=True)
1422
- st.markdown("<div id='sp-generation-action-amj'></div>", unsafe_allow_html=True)
1423
- if active and _task_mode(task) == "amj":
1424
- if st.button(tr(lang_code, "cancel_generation"), key="cancel_amj"):
1425
- st.session_state["sp_pending_cancel_task_id"] = str(task.get("task_id") or "")
1426
- st.rerun()
1427
- elif st.button(tr(lang_code, "start_amj"), disabled=active, key="btn_amj"):
1428
- st.session_state["run_mode"] = "amj"
1429
- st.session_state["sp_pending_launch"] = {"mode": "amj", "idempotency_key": _new_generation_idempotency_key()}
1430
- st.session_state["sp_scroll_to_generation_action"] = True
1431
- st.info(tr(lang_code, "pipeline_start"))
1432
- _inject_generation_action_scroll("amj")
1433
- _select_active_task_tab(task, lang_code)
1434
  inject_file_uploader_i18n(lang_code)
1435
 
1436
 
 
30
  start_generation_task,
31
  )
32
  from i18n import (
33
+ LANGUAGE_OPTIONS, get_output_suffix, get_research_material_template,
34
+ get_target_flag, get_target_language, tr,
35
+ )
36
+ from core.advanced_method_registry import (
37
+ METHOD_FAMILIES, METHOD_RESOLVER_VERSION, method_capability,
38
+ resolve_generation_preflight,
39
  )
40
  from smartpaper_restore_bridge import edge_bridge
41
 
 
77
  payload = {
78
  "browse": tr(language, "browse_files"),
79
  "uploaders": {
80
+ tr(language, "research_materials_label"): {
81
+ "title": tr(language, "drop_research_materials"),
82
+ "limit": tr(language, "limit_research_materials"),
83
+ },
84
+ # Legacy labels remain recognized for recovered historical UI state,
85
+ # but the unified workspace renders only research_materials_label.
86
  tr(language, "upload_reference"): {
87
  "title": tr(language, "drop_docs"),
88
  "limit": tr(language, "limit_docs"),
 
304
  "sp_last_ui_task_status": "",
305
  "sp_scroll_to_generation_action": False,
306
  "sp_completed_download_scroll_task_id": "",
307
+ "sp_method_confirmation": "",
308
+ "sp_method_confirmation_signature": "",
309
+ "sp_material_document_use": "reference",
310
  }.items():
311
  if key not in st.session_state:
312
  st.session_state[key] = default
 
448
  return "amj" if mode == "amj" else "asq"
449
 
450
 
451
+ def _task_method_family(task: dict) -> str:
452
+ """Return frozen new-task method_family, with legacy resume fallback."""
453
+ task_id = str((task or {}).get("task_id") or "").strip()
454
+ if task_id:
455
+ request_path = _task_dir(task_id) / "request.json"
456
+ if request_path.is_file():
457
+ try:
458
+ payload = json.loads(request_path.read_text(encoding="utf-8"))
459
+ family = str(payload.get("method_family") or "").strip().casefold()
460
+ if family in METHOD_FAMILIES:
461
+ return family
462
+ except Exception:
463
+ pass
464
+ mode = _task_mode(task)
465
+ return "qualitative" if mode == "asq" else "survey_path"
466
+
467
+
468
  def _task_output_path(task: dict) -> Path | None:
469
  task_id = str(task.get("task_id") or "").strip()
470
  filename = os.path.basename(str(task.get("output_filename") or "").strip())
 
492
  return saved
493
 
494
 
495
+ def _uploaded_material_descriptors() -> list[dict[str, str]]:
496
+ out: list[dict[str, str]] = []
497
+ for item in st.session_state.get("up_research_materials") or []:
498
+ name = os.path.basename(str(getattr(item, "name", "") or ""))
499
+ mime_type = str(getattr(item, "type", "") or "")
500
+ if name or mime_type:
501
+ out.append({"name": name, "mime_type": mime_type})
502
+ return out
503
+
504
+
505
+ def _method_intent_signature() -> str:
506
+ payload = {
507
+ "topic": str(st.session_state.get("sp_topic") or DEFAULT_TOPIC),
508
+ "keywords": str(st.session_state.get("sp_keywords") or DEFAULT_KEYWORDS),
509
+ "materials": _uploaded_material_descriptors(),
510
+ }
511
+ return hashlib.sha256(
512
+ json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
513
+ ).hexdigest()
514
+
515
+
516
+ def _current_method_preflight(*, confirmed_method_family: str | None = None) -> tuple[dict, str]:
517
+ signature = _method_intent_signature()
518
+ stored_confirmation = str(st.session_state.get("sp_method_confirmation") or "").strip()
519
+ stored_signature = str(st.session_state.get("sp_method_confirmation_signature") or "").strip()
520
+ if confirmed_method_family is None:
521
+ confirmed = stored_confirmation if stored_signature == signature else ""
522
+ else:
523
+ confirmed = str(confirmed_method_family or "").strip()
524
+ preflight = resolve_generation_preflight(
525
+ str(st.session_state.get("sp_topic") or DEFAULT_TOPIC),
526
+ str(st.session_state.get("sp_keywords") or DEFAULT_KEYWORDS),
527
+ materials=_uploaded_material_descriptors(),
528
+ confirmed_method_family=confirmed,
529
+ )
530
+ return preflight, signature
531
+
532
+
533
+ def _classify_and_save_research_materials(
534
+ method_family: str, inputs_dir: Path
535
+ ) -> tuple[list[str], list[str], list[str], str]:
536
+ """Map one visible uploader into legacy request buckets without changing engines."""
537
+ uploads = list(st.session_state.get("up_research_materials") or [])
538
+ if not uploads:
539
+ return [], [], [], "none"
540
+
541
+ structured = []
542
+ text_materials = []
543
+ documents = []
544
+ for item in uploads:
545
+ name = os.path.basename(str(getattr(item, "name", "") or ""))
546
+ ext = Path(name).suffix.lower()
547
+ if ext in {".csv", ".xls", ".xlsx"}:
548
+ structured.append(item)
549
+ elif ext == ".txt":
550
+ text_materials.append(item)
551
+ else:
552
+ documents.append(item)
553
+
554
+ decision = str(st.session_state.get("sp_material_document_use") or "reference").strip().lower()
555
+ if decision not in {"reference", "research"}:
556
+ decision = "reference"
557
+
558
+ reference_items = []
559
+ data_items = list(structured)
560
+ interview_items = []
561
+
562
+ if method_family == "qualitative":
563
+ interview_items.extend(text_materials)
564
+ if decision == "research":
565
+ interview_items.extend(documents)
566
+ else:
567
+ reference_items.extend(documents)
568
+ else:
569
+ # Structured spreadsheets are the only legacy-safe empirical-data path
570
+ # for current survey production. Text/doc materials remain reference
571
+ # context unless a dedicated future method adapter owns them.
572
+ reference_items.extend(text_materials)
573
+ reference_items.extend(documents)
574
+
575
+ refs = _save_uploaded(reference_items, inputs_dir, "ref")
576
+ data = _save_uploaded(data_items[:1], inputs_dir, "data")
577
+ interviews = _save_uploaded(interview_items[:1], inputs_dir, "interview")
578
+ return refs, data, interviews, decision
579
+
580
+
581
+ def _persist_task_request(
582
+ task: dict,
583
+ mode: str,
584
+ lang_code: str,
585
+ *,
586
+ method_family: str,
587
+ method_resolver_version: str,
588
+ ) -> dict:
589
  task_id = str(task.get("task_id") or "").strip()
590
  numeric_id = (st.session_state.get("sp_user") or {}).get("numeric_id")
591
  if not task_id or not isinstance(numeric_id, int) or numeric_id <= 0:
 
594
  inputs_dir = task_dir / "inputs"
595
  inputs_dir.mkdir(parents=True, exist_ok=True)
596
 
597
+ capability = method_capability(method_family)
598
+ expected_lane = str(capability.get("legacy_policy_lane") or "").strip().lower()
599
+ if expected_lane not in {"amj", "asq"} or expected_lane != str(mode or "").strip().lower():
600
+ raise RuntimeError("method_family_policy_lane_mismatch")
601
+
602
+ refs, data, interviews, material_use_decision = _classify_and_save_research_materials(
603
+ method_family, inputs_dir
604
+ )
605
 
606
  request_data = {
607
  "task_id": task_id,
608
  "user_id": numeric_id,
609
  "mode": mode,
610
+ "method_family": method_family,
611
+ "method_resolver_version": str(method_resolver_version or METHOD_RESOLVER_VERSION),
612
+ "derived_policy_lane": expected_lane,
613
+ "material_use_decision": material_use_decision,
614
  "interface_language": lang_code,
615
  "target_lang": get_target_language(lang_code),
616
  "output_suffix": get_output_suffix(lang_code),
 
629
  _atomic_json(task_dir / "request.json", request_data)
630
  return request_data
631
 
 
632
  def _pid_alive(pid: int) -> bool:
633
  if not isinstance(pid, int) or pid <= 0:
634
  return False
 
770
  pending = st.session_state.get("sp_pending_launch")
771
  if not isinstance(pending, dict):
772
  return
773
+
774
+ pending_family = str(pending.get("method_family") or "").strip().casefold()
775
+ pending_mode = str(pending.get("mode") or "").strip().lower()
776
  idempotency_key = str(pending.get("idempotency_key") or "").strip()
777
+ pending_signature = str(pending.get("method_intent_signature") or "").strip()
778
+
779
+ # U1-R2 repeats the capability preflight immediately before any task/quota
780
+ # creation so a stale rerun or modified session state cannot bypass it.
781
+ preflight, current_signature = _current_method_preflight(
782
+ confirmed_method_family=pending_family or None
783
+ )
784
+ resolved_family = str(preflight.get("method_family") or "").strip().casefold()
785
+ resolved_mode = str(preflight.get("legacy_policy_lane") or "").strip().lower()
786
+ if (
787
+ not bool(preflight.get("can_start_generation"))
788
+ or not pending_family
789
+ or pending_family != resolved_family
790
+ or pending_mode != resolved_mode
791
+ or not pending_signature
792
+ or pending_signature != current_signature
793
+ ):
794
+ st.session_state["sp_pending_launch"] = None
795
+ reason = str(preflight.get("block_reason") or "method_preflight_changed")
796
+ if reason == "method_capability_blocked":
797
+ method_label = tr(lang_code, f"method_{resolved_family}")
798
+ st.warning(tr(lang_code, "method_not_supported", method=method_label))
799
+ else:
800
+ st.warning(tr(lang_code, "method_confirmation_required"))
801
+ return
802
+
803
  projection = get_product_ui_projection()
804
  selected_product_tier = (
805
  str(st.session_state.get("sp_product_tier") or "").strip().upper()
 
809
  try:
810
  response = start_generation_task(
811
  idempotency_key=idempotency_key,
812
+ mode=resolved_mode,
813
  interface_language=lang_code,
814
  target_words=int(st.session_state.get("sp_words") or APP_CONFIG["target_words_default"]),
815
  product_tier=selected_product_tier,
 
838
  return
839
  st.session_state["sp_generation_task"] = task
840
  st.session_state["sp_last_ui_task_status"] = str(task.get("status") or "")
841
+ st.session_state["run_mode"] = resolved_mode
842
  try:
843
  request_path = _task_dir(task_id) / "request.json"
844
  if not request_path.is_file():
845
+ _persist_task_request(
846
+ task,
847
+ resolved_mode,
848
+ lang_code,
849
+ method_family=resolved_family,
850
+ method_resolver_version=str(
851
+ (preflight.get("resolution") or {}).get("resolver_version")
852
+ or METHOD_RESOLVER_VERSION
853
+ ),
854
+ )
855
  _launch_executor(task_id)
856
  except Exception as exc:
857
  st.session_state["sp_pending_launch"] = None
 
1355
  return detail or code or tr(lang_code, "node_error"), ""
1356
 
1357
 
1358
+ def _inject_generation_action_scroll(mode: str | None = None) -> None:
1359
+ """Scroll immediately to the unified generation action without a server wait."""
1360
+ target_id = "sp-generation-action"
1361
  components.html(
1362
  f"""
1363
  <script>
 
1386
  if not bool(st.session_state.get("sp_scroll_to_generation_action")):
1387
  return
1388
  st.session_state["sp_scroll_to_generation_action"] = False
1389
+ fallback_id = "sp-generation-action"
 
1390
  components.html(
1391
  f"""
1392
  <script>
 
1416
  )
1417
 
1418
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1419
  def render_workspace_controls(user):
1420
  top_toolbar = st.container()
1421
  with top_toolbar:
 
1466
  st.text_input(tr(lang_code, "topic_label"), value=DEFAULT_TOPIC, disabled=active, key="sp_topic")
1467
  st.text_input(tr(lang_code, "keywords_label"), value=DEFAULT_KEYWORDS, disabled=active, key="sp_keywords")
1468
 
1469
+ # U1-R2 unified research workspace: academic intent/materials first, then
1470
+ # product/length controls. Legacy AMJ/ASQ remains internal policy routing.
1471
+ st.file_uploader(
1472
+ tr(lang_code, "research_materials_label"),
1473
+ type=["pdf", "txt", "doc", "docx", "csv", "xls", "xlsx"],
1474
+ accept_multiple_files=True,
1475
+ key="up_research_materials",
1476
+ disabled=active,
1477
+ help=tr(lang_code, "research_materials_help"),
1478
+ )
1479
+
1480
+ uploaded_materials = list(st.session_state.get("up_research_materials") or [])
1481
+ has_document_material = any(
1482
+ Path(str(getattr(item, "name", "") or "")).suffix.lower() in {".pdf", ".doc", ".docx"}
1483
+ for item in uploaded_materials
1484
+ )
1485
+ if has_document_material and not active:
1486
+ st.radio(
1487
+ tr(lang_code, "material_document_use_label"),
1488
+ ["reference", "research"],
1489
+ format_func=lambda value: tr(lang_code, f"material_use_{value}"),
1490
+ horizontal=True,
1491
+ key="sp_material_document_use",
1492
+ )
1493
+
1494
+ if active:
1495
+ active_family = _task_method_family(task)
1496
+ active_capability = method_capability(active_family)
1497
+ preflight = {
1498
+ "can_start_generation": True,
1499
+ "block_reason": "",
1500
+ "method_family": active_family,
1501
+ "legacy_policy_lane": str(active_capability.get("legacy_policy_lane") or _task_mode(task)),
1502
+ "capability": active_capability,
1503
+ "resolution": {"confidence": "TASK_FROZEN", "resolver_version": METHOD_RESOLVER_VERSION},
1504
+ }
1505
+ method_signature = ""
1506
+ else:
1507
+ preflight, method_signature = _current_method_preflight()
1508
+ if str(preflight.get("block_reason") or "") == "method_confirmation_required":
1509
+ stored = str(st.session_state.get("sp_method_confirmation") or "").strip().casefold()
1510
+ stored_sig = str(st.session_state.get("sp_method_confirmation_signature") or "").strip()
1511
+ selected_default = stored if stored_sig == method_signature and stored in METHOD_FAMILIES else ""
1512
+ options = [""] + list(METHOD_FAMILIES)
1513
+ selected = st.selectbox(
1514
+ tr(lang_code, "method_confirm_label"),
1515
+ options,
1516
+ index=options.index(selected_default) if selected_default in options else 0,
1517
+ format_func=lambda family: (
1518
+ tr(lang_code, "method_confirm_placeholder")
1519
+ if not family else tr(lang_code, f"method_{family}")
1520
+ ),
1521
+ )
1522
+ if selected:
1523
+ st.session_state["sp_method_confirmation"] = selected
1524
+ st.session_state["sp_method_confirmation_signature"] = method_signature
1525
+ preflight, method_signature = _current_method_preflight(confirmed_method_family=selected)
1526
+ else:
1527
+ # Explicit Title/Keyword cues are authoritative; stale confirmations
1528
+ # from a previous ambiguous intent cannot override them.
1529
+ st.session_state["sp_method_confirmation"] = ""
1530
+ st.session_state["sp_method_confirmation_signature"] = ""
1531
+
1532
+ method_family = str(preflight.get("method_family") or "undetermined").strip().casefold()
1533
+ method_label = (
1534
+ tr(lang_code, f"method_{method_family}")
1535
+ if method_family in METHOD_FAMILIES
1536
+ else tr(lang_code, "method_undetermined")
1537
+ )
1538
+ st.markdown(f"**{tr(lang_code, 'detected_method_label')}:** {method_label}")
1539
+ st.caption(tr(lang_code, "method_detection_explanation"))
1540
+
1541
+ block_reason = str(preflight.get("block_reason") or "")
1542
+ if block_reason == "method_capability_blocked":
1543
+ st.warning(tr(lang_code, "method_not_supported", method=method_label))
1544
+ elif block_reason == "method_confirmation_required":
1545
+ st.warning(tr(lang_code, "method_confirmation_required"))
1546
+ elif not active:
1547
+ st.info(tr(lang_code, "method_supported", method=method_label))
1548
+
1549
+ with st.expander(tr(lang_code, "adaptive_template_expander")):
1550
+ if method_family in METHOD_FAMILIES:
1551
+ st.markdown(tr(lang_code, f"template_help_{method_family}"))
1552
+ template_info = get_research_material_template(lang_code, method_family)
1553
+ template_data = str(template_info.get("content") or "")
1554
+ if template_data:
1555
+ st.code(template_data, language=str(template_info.get("code_language") or "text"))
1556
+ st.download_button(
1557
+ label=tr(lang_code, "download_research_template"),
1558
+ data=template_data,
1559
+ file_name=str(template_info.get("filename") or "SmartPaper_Research_Materials_Template.txt"),
1560
+ mime=str(template_info.get("mime_type") or "text/plain"),
1561
+ use_container_width=True,
1562
+ )
1563
+ else:
1564
+ st.markdown(tr(lang_code, "template_help_undetermined"))
1565
+
1566
  product_ui = get_product_ui_projection()
1567
  if product_ui.get("mode") == "a5_product":
1568
  products = product_ui.get("products") or {}
 
1617
  key="sp_words",
1618
  )
1619
 
1620
+ st.markdown("<div id='sp-generation-action'></div>", unsafe_allow_html=True)
1621
+ if active:
1622
+ if st.button(tr(lang_code, "cancel_generation"), key="cancel_generation_unified"):
1623
+ st.session_state["sp_pending_cancel_task_id"] = str(task.get("task_id") or "")
1624
+ st.rerun()
1625
+ else:
1626
+ can_start = bool(preflight.get("can_start_generation"))
1627
+ if st.button(
1628
+ tr(lang_code, "start_generation_unified"),
1629
+ disabled=not can_start,
1630
+ key="btn_generation_unified",
1631
+ ):
1632
+ # Re-resolve at click time; _process_pending_launch repeats this gate
1633
+ # again immediately before authoritative task/quota creation.
1634
+ click_preflight, click_signature = _current_method_preflight(
1635
+ confirmed_method_family=method_family if method_family in METHOD_FAMILIES else None
1636
+ )
1637
+ if not bool(click_preflight.get("can_start_generation")):
1638
+ reason = str(click_preflight.get("block_reason") or "")
1639
+ if reason == "method_capability_blocked":
1640
+ st.warning(tr(lang_code, "method_not_supported", method=method_label))
1641
+ else:
1642
+ st.warning(tr(lang_code, "method_confirmation_required"))
1643
+ else:
1644
+ resolved_family = str(click_preflight.get("method_family") or "").strip().casefold()
1645
+ resolved_mode = str(click_preflight.get("legacy_policy_lane") or "").strip().lower()
1646
+ st.session_state["run_mode"] = resolved_mode
1647
+ st.session_state["sp_pending_launch"] = {
1648
+ "mode": resolved_mode,
1649
+ "method_family": resolved_family,
1650
+ "method_resolver_version": str(
1651
+ (click_preflight.get("resolution") or {}).get("resolver_version")
1652
+ or METHOD_RESOLVER_VERSION
1653
+ ),
1654
+ "method_intent_signature": click_signature,
1655
+ "idempotency_key": _new_generation_idempotency_key(),
1656
+ }
1657
+ st.session_state["sp_scroll_to_generation_action"] = True
1658
+ st.info(tr(lang_code, "pipeline_start"))
1659
+ _inject_generation_action_scroll()
1660
+
 
 
 
 
1661
  inject_file_uploader_i18n(lang_code)
1662
 
1663
 
core/advanced_method_registry.py ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Deterministic method-family resolver and capability preflight for SmartPaper.
3
+
4
+ U1-R2 derives an academic ``method_family`` from frozen user intent
5
+ (Title/Keywords plus light material metadata), maps that family to the legacy
6
+ AMJ/ASQ policy lane, and blocks unsupported method families before task/quota
7
+ creation. It never selects providers/models and never rewrites Title/Keywords.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import re
13
+ from typing import Any, Iterable
14
+
15
+ METHOD_RESOLVER_VERSION = "smartpaper-auto-method-resolver-v2"
16
+ METHOD_CAPABILITY_REGISTRY_VERSION = "smartpaper-method-capability-registry-v2"
17
+
18
+ METHOD_FAMILIES = (
19
+ "survey_path",
20
+ "qualitative",
21
+ "fsqca",
22
+ "meta_analysis",
23
+ "panel_econometrics",
24
+ "bibliometric_integrative_review",
25
+ )
26
+
27
+ # Capability status is executable U1-R2 preflight authority. Advanced methods
28
+ # remain BLOCKED until their dedicated engines are implemented.
29
+ _METHOD_CAPABILITIES: dict[str, dict[str, Any]] = {
30
+ "survey_path": {
31
+ "legacy_policy_lane": "amj",
32
+ "capability_status": "production_supported",
33
+ "evidence_timing": "pre_literature",
34
+ "engine_family": "quantitative_engine",
35
+ },
36
+ "qualitative": {
37
+ "legacy_policy_lane": "asq",
38
+ "capability_status": "production_supported",
39
+ "evidence_timing": "pre_literature",
40
+ "engine_family": "qualitative_engine",
41
+ },
42
+ "fsqca": {
43
+ "legacy_policy_lane": "amj",
44
+ "capability_status": "blocked_pending_engine",
45
+ "evidence_timing": "pre_literature",
46
+ "engine_family": "fsqca_engine_pending",
47
+ },
48
+ "meta_analysis": {
49
+ "legacy_policy_lane": "amj",
50
+ "capability_status": "blocked_pending_engine",
51
+ "evidence_timing": "post_literature",
52
+ "engine_family": "meta_analysis_engine_pending",
53
+ },
54
+ "panel_econometrics": {
55
+ "legacy_policy_lane": "amj",
56
+ "capability_status": "blocked_pending_engine",
57
+ "evidence_timing": "pre_literature",
58
+ "engine_family": "panel_econometrics_engine_pending",
59
+ },
60
+ "bibliometric_integrative_review": {
61
+ "legacy_policy_lane": "amj",
62
+ "capability_status": "blocked_pending_engine",
63
+ "evidence_timing": "post_literature",
64
+ "engine_family": "review_engine_pending",
65
+ },
66
+ }
67
+
68
+ # Priority matters. Method-defining paradigms outrank generic survey cues.
69
+ _METHOD_PATTERNS: tuple[tuple[str, tuple[str, ...]], ...] = (
70
+ (
71
+ "fsqca",
72
+ (
73
+ r"\bfsqca\b", r"\bfuzzy[- ]set\s+qca\b", r"\bfuzzy[- ]set\s+qualitative\s+comparative\s+analysis\b",
74
+ r"\bconfigurational\s+(?:analysis|paths?|approach)\b", r"\btruth\s+table\b",
75
+ r"模糊集定性比较分析", r"定性比较分析", r"组态(?:分析|路径)",
76
+ r"нечетк\w*.{0,18}сравнительн\w*.{0,18}анализ", r"конфигурационн\w*.{0,12}анализ",
77
+ r"бұлыңғыр.{0,16}сапалық.{0,16}салыстырмалы.{0,16}талдау", r"конфигурациялық.{0,12}талдау",
78
+ ),
79
+ ),
80
+ (
81
+ "meta_analysis",
82
+ (
83
+ r"\bmeta[- ]analysis\b", r"\bmeta[- ]analytic\b", r"\bmeta analysis\b",
84
+ r"元分析", r"荟萃分析",
85
+ r"мета[- ]?анализ", r"метааналитическ",
86
+ r"мета[- ]?талдау", r"метааналитикалық",
87
+ ),
88
+ ),
89
+ (
90
+ "bibliometric_integrative_review",
91
+ (
92
+ r"\bbibliometric\b", r"\bscience\s+mapping\b", r"\bco[- ]citation\b", r"\bco[- ]word\b",
93
+ r"\bintegrative\s+(?:theory\s+)?review\b", r"\btheory\s+review\b", r"\bsystematic\s+literature\s+review\b",
94
+ r"文献计量", r"科学知识图谱", r"共被引", r"共词", r"整合性(?:理论)?综述", r"系统性文献综述",
95
+ r"библиометр", r"научн\w*.{0,8}картирован", r"коцитирован", r"интегративн\w*.{0,8}обзор",
96
+ r"библиометр", r"ғылыми.{0,8}карталау", r"бірлескен.{0,8}дәйексөз", r"интегративті.{0,8}шолу",
97
+ ),
98
+ ),
99
+ (
100
+ "panel_econometrics",
101
+ (
102
+ r"\bpanel\s+data\b", r"\bfirm[- ]year\b", r"\bentity\s+fixed\s+effects?\b", r"\bfixed\s+effects?\b",
103
+ r"\brandom\s+effects?\b", r"\bpanel\s+econometrics?\b", r"\btwo[- ]way\s+fixed\s+effects?\b",
104
+ r"面板数据", r"固定效应", r"双向固定效应", r"公司[-—– ]?年度",
105
+ r"панельн\w*.{0,8}данн", r"фиксированн\w*.{0,8}эффект",
106
+ r"панельдік.{0,8}дерек", r"тұрақты.{0,8}әсер",
107
+ ),
108
+ ),
109
+ (
110
+ "qualitative",
111
+ (
112
+ r"\bqualitative\s+(?:study|research|analysis)\b", r"\bgioia\b", r"\bgrounded\s+theory\b",
113
+ r"\bcase\s+study\b", r"\bcomparative\s+case\b", r"\bprocess\s+study\b", r"\bethnograph",
114
+ r"\bsemi[- ]structured\s+interviews?\b", r"\binterviews?\b",
115
+ r"定性研究", r"质性研究", r"扎根理论", r"案例研究", r"个案研究", r"访谈", r"民族志",
116
+ r"качественн\w*.{0,8}(?:исследован|анализ)", r"обоснованн\w*.{0,8}теори", r"интервью", r"кейс[- ]?исследован",
117
+ r"сапалық.{0,8}(?:зерттеу|талдау)", r"негізделген.{0,8}теори", r"сұхбат", r"кейс.{0,8}зерттеу",
118
+ ),
119
+ ),
120
+ (
121
+ "survey_path",
122
+ (
123
+ r"\bsurvey\b", r"\bquestionnaire\b", r"\bstructural\s+equation\s+model", r"\bsem\b", r"\bpls[- ]sem\b",
124
+ r"\bregression\b", r"\bmediating\s+role\b", r"\bmoderating\s+role\b", r"\bmediation\b", r"\bmoderation\b",
125
+ r"问卷", r"结构方程", r"回归", r"中介(?:作用|效应|变量)?", r"调节(?:作用|效应|变量)?",
126
+ r"опрос", r"анкет", r"структурн\w*.{0,10}уравнен", r"регресс", r"медиирующ", r"модерирующ",
127
+ r"сауалнама", r"құрылымдық.{0,10}теңдеу", r"регресс", r"медиаторлық", r"модераторлық",
128
+ ),
129
+ ),
130
+ )
131
+
132
+ _MATERIAL_NAME_PATTERNS: tuple[tuple[str, tuple[str, ...]], ...] = (
133
+ ("fsqca", (r"fsqca", r"truth[-_ ]?table", r"calibrat", r"condition[-_ ]?outcome")),
134
+ ("meta_analysis", (r"meta[-_ ]?analysis", r"effect[-_ ]?size", r"forest[-_ ]?plot", r"study[-_ ]?character")),
135
+ ("bibliometric_integrative_review", (r"bibliometric", r"corpus", r"co[-_ ]?citation", r"co[-_ ]?word", r"openalex")),
136
+ ("panel_econometrics", (r"panel", r"firm[-_ ]?year", r"entity[-_ ]?year", r"fixed[-_ ]?effect")),
137
+ ("qualitative", (r"interview", r"transcript", r"field[-_ ]?note", r"focus[-_ ]?group", r"访谈", r"сұхбат", r"интервью")),
138
+ )
139
+
140
+
141
+ def _normalized_text(value: Any) -> str:
142
+ return re.sub(r"\s+", " ", str(value or "")).strip()
143
+
144
+
145
+ def _keyword_items(value: Any) -> list[str]:
146
+ if isinstance(value, (list, tuple)):
147
+ raw = list(value)
148
+ else:
149
+ raw = re.split(r"[;\n|]+", str(value or ""))
150
+ if len(raw) == 1:
151
+ raw = re.split(r"[,]", str(value or ""))
152
+ return [_normalized_text(item) for item in raw if _normalized_text(item)]
153
+
154
+
155
+ def _material_descriptors(materials: Iterable[Any] | None) -> list[dict[str, str]]:
156
+ out: list[dict[str, str]] = []
157
+ for item in materials or []:
158
+ if isinstance(item, dict):
159
+ name = _normalized_text(item.get("name") or item.get("filename"))
160
+ mime = _normalized_text(item.get("mime_type") or item.get("type"))
161
+ else:
162
+ name = _normalized_text(getattr(item, "name", ""))
163
+ mime = _normalized_text(getattr(item, "type", ""))
164
+ if not name and not mime:
165
+ continue
166
+ out.append({
167
+ "name": name,
168
+ "mime_type": mime,
169
+ "extension": os.path.splitext(name)[1].lower().lstrip("."),
170
+ })
171
+ return out
172
+
173
+
174
+ def method_capability(method_family: str) -> dict[str, Any]:
175
+ """Return a copy of versioned capability metadata for one method family."""
176
+ family = str(method_family or "").strip().casefold()
177
+ row = dict(_METHOD_CAPABILITIES.get(family) or {})
178
+ if not row:
179
+ return {
180
+ "registry_version": METHOD_CAPABILITY_REGISTRY_VERSION,
181
+ "method_family": "undetermined",
182
+ "legacy_policy_lane": None,
183
+ "capability_status": "ambiguous",
184
+ "evidence_timing": "undetermined",
185
+ "engine_family": "undetermined",
186
+ }
187
+ row.update({
188
+ "registry_version": METHOD_CAPABILITY_REGISTRY_VERSION,
189
+ "method_family": family,
190
+ })
191
+ return row
192
+
193
+
194
+ def resolve_method_family(
195
+ title: Any,
196
+ keywords: Any,
197
+ *,
198
+ materials: Iterable[Any] | None = None,
199
+ ) -> dict[str, Any]:
200
+ """Resolve an academic method family from frozen user intent.
201
+
202
+ Explicit Title/Keyword cues are authoritative. Material *metadata* can add
203
+ support, but never overrides an explicit method cue. File contents are
204
+ deliberately not read here.
205
+ """
206
+ source_title = _normalized_text(title)
207
+ source_keywords = _keyword_items(keywords)
208
+ text = " ; ".join([source_title] + source_keywords)
209
+
210
+ hits: list[dict[str, Any]] = []
211
+ chosen = ""
212
+ for family, patterns in _METHOD_PATTERNS:
213
+ matched = [pattern for pattern in patterns if re.search(pattern, text, flags=re.I | re.UNICODE)]
214
+ if matched:
215
+ hits.append({"method_family": family, "source": "title_keywords", "match_count": len(matched)})
216
+ if not chosen:
217
+ chosen = family
218
+
219
+ descriptors = _material_descriptors(materials)
220
+ material_hits: list[str] = []
221
+ for descriptor in descriptors:
222
+ name = descriptor.get("name") or ""
223
+ for family, patterns in _MATERIAL_NAME_PATTERNS:
224
+ if any(re.search(pattern, name, flags=re.I | re.UNICODE) for pattern in patterns):
225
+ if family not in material_hits:
226
+ material_hits.append(family)
227
+
228
+ # Explicit academic wording outranks material filename hints. When there is
229
+ # no explicit cue, one unique material hint is useful but remains ambiguous
230
+ # until future U1 benchmarks justify auto-acceptance.
231
+ confidence = "HIGH_CONFIDENCE" if chosen else "AMBIGUOUS"
232
+ reason_codes: list[str] = []
233
+ if chosen:
234
+ reason_codes.append("explicit_title_keyword_method_cue")
235
+ conflicting = [row["method_family"] for row in hits if row["method_family"] != chosen]
236
+ if conflicting:
237
+ reason_codes.append("lower_priority_method_cues_present")
238
+ elif len(material_hits) == 1:
239
+ chosen = material_hits[0]
240
+ reason_codes.append("material_filename_method_hint_only")
241
+ elif len(material_hits) > 1:
242
+ reason_codes.append("conflicting_material_method_hints")
243
+ else:
244
+ reason_codes.append("no_explicit_method_cue")
245
+
246
+ capability = method_capability(chosen)
247
+ return {
248
+ "resolver_version": METHOD_RESOLVER_VERSION,
249
+ "shadow_only": False,
250
+ "method_family": chosen or "undetermined",
251
+ "confidence": confidence,
252
+ "reason_codes": reason_codes,
253
+ "title_keyword_hits": hits,
254
+ "material_hint_families": material_hits,
255
+ "material_count": len(descriptors),
256
+ "derived_policy_lane": capability.get("legacy_policy_lane"),
257
+ "capability": capability,
258
+ }
259
+
260
+
261
+ def compare_shadow_to_legacy_lane(resolution: dict[str, Any], selected_lane: Any) -> dict[str, Any]:
262
+ """Compare shadow inference with the legacy user-selected AMJ/ASQ lane."""
263
+ selected = str(selected_lane or "").strip().casefold()
264
+ derived = str((resolution or {}).get("derived_policy_lane") or "").strip().casefold()
265
+ return {
266
+ "selected_legacy_lane": selected or None,
267
+ "derived_policy_lane": derived or None,
268
+ "lane_match": bool(selected and derived and selected == derived),
269
+ "comparable": bool(selected and derived),
270
+ }
271
+
272
+
273
+ def resolve_generation_preflight(
274
+ title: Any,
275
+ keywords: Any,
276
+ *,
277
+ materials: Iterable[Any] | None = None,
278
+ confirmed_method_family: Any = None,
279
+ ) -> dict[str, Any]:
280
+ """Resolve method + capability before any task/quota creation.
281
+
282
+ A user confirmation is accepted only when deterministic resolution is
283
+ genuinely ambiguous. Explicit Title/Keyword cues always remain authoritative.
284
+ """
285
+ resolution = resolve_method_family(title, keywords, materials=materials)
286
+ family = str(resolution.get("method_family") or "").strip().casefold()
287
+ confidence = str(resolution.get("confidence") or "").strip().upper()
288
+
289
+ confirmed = str(confirmed_method_family or "").strip().casefold()
290
+ if confidence == "AMBIGUOUS":
291
+ if confirmed in METHOD_FAMILIES:
292
+ family = confirmed
293
+ capability = method_capability(family)
294
+ resolution = dict(resolution)
295
+ resolution.update({
296
+ "method_family": family,
297
+ "confidence": "USER_CONFIRMED",
298
+ "derived_policy_lane": capability.get("legacy_policy_lane"),
299
+ "capability": capability,
300
+ "reason_codes": list(resolution.get("reason_codes") or []) + ["user_confirmed_method_family"],
301
+ })
302
+ else:
303
+ return {
304
+ "can_start_generation": False,
305
+ "block_reason": "method_confirmation_required",
306
+ "resolution": resolution,
307
+ "method_family": family or "undetermined",
308
+ "legacy_policy_lane": None,
309
+ "capability": method_capability(family),
310
+ }
311
+
312
+ capability = method_capability(family)
313
+ lane = str(capability.get("legacy_policy_lane") or "").strip().casefold() or None
314
+ status = str(capability.get("capability_status") or "").strip().casefold()
315
+ if family not in METHOD_FAMILIES or not lane:
316
+ return {
317
+ "can_start_generation": False,
318
+ "block_reason": "method_confirmation_required",
319
+ "resolution": resolution,
320
+ "method_family": family or "undetermined",
321
+ "legacy_policy_lane": None,
322
+ "capability": capability,
323
+ }
324
+ if status != "production_supported":
325
+ return {
326
+ "can_start_generation": False,
327
+ "block_reason": "method_capability_blocked",
328
+ "resolution": resolution,
329
+ "method_family": family,
330
+ "legacy_policy_lane": lane,
331
+ "capability": capability,
332
+ }
333
+ return {
334
+ "can_start_generation": True,
335
+ "block_reason": "",
336
+ "resolution": resolution,
337
+ "method_family": family,
338
+ "legacy_policy_lane": lane,
339
+ "capability": capability,
340
+ }
i18n.py CHANGED
@@ -206,10 +206,10 @@ REFERENCE_HEADINGS = {
206
  TEXTS: dict[str, dict[str, str]] = {
207
  "zh": {
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": "旗舰版",
@@ -275,6 +275,37 @@ TEXTS: dict[str, dict[str, str]] = {
275
  "asq_hint": "**⚠️ 核心提示**:为了让AI准确提取语录和编码树,请务必标注**受访者角色(Role)**,并采用清晰的**对话格式**。",
276
  "download_asq_template": "📥 一键下载 TXT 访谈模板",
277
  "start_asq": "🚀 启动 ASQ 模式生成",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
278
  "api_key_missing": "❌ 缺少 DEEPSEEK_API_KEY,请配置API密钥。",
279
  "resume_toast": "🔄 检测到页面刷新或网络抖动,系统正在自动恢复进度……",
280
  "pipeline_start": "⏳ 流水线启动……",
@@ -416,10 +447,10 @@ TEXTS: dict[str, dict[str, str]] = {
416
  },
417
  "en": {
418
  "title": "📊 Business Administration Article Agent",
419
- "topic_label": "📝 Article Title",
420
- "keywords_label": "🔍 Keywords",
421
- "words_label": "📏 Target Word Count ({min_words}–{max_words} words)",
422
- "words_label_product": "📏 Target Body Word Count ({min_words}–{max_words} words)",
423
  "product_tier_label": "Version",
424
  "product_tier_standard": "Standard",
425
  "product_tier_flagship": "Flagship",
@@ -433,7 +464,7 @@ TEXTS: dict[str, dict[str, str]] = {
433
  "resume_unavailable_runtime_replaced": "⚠️ A paused unfinished paper task was detected, but this runtime no longer has the local request or checkpoint files required to continue it. Resume has been disabled to prevent an unsafe recovery. To release this task and start again, explicitly choose ‘Delete and Start Again’.",
434
  "resume": "🔄 Continue Previous Task",
435
  "clear": "🗑️ Delete and Start Again",
436
- "cancel_generation": "⛔ Cancel Generation",
437
  "draft_preparing": "Preparing the English draft…",
438
  "draft_stage_inputs": "Organizing research inputs and evidence…",
439
  "draft_stage_search": "Searching the academic literature…",
@@ -485,6 +516,37 @@ TEXTS: dict[str, dict[str, str]] = {
485
  "asq_hint": "**⚠️ Important**: Clearly label each **informant role** and use a consistent **dialogue format** so the system can accurately extract quotations and build the coding structure.",
486
  "download_asq_template": "📥 Download TXT Interview Template",
487
  "start_asq": "🚀 Start ASQ Generation",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
488
  "api_key_missing": "❌ DEEPSEEK_API_KEY is missing. Configure the API key.",
489
  "resume_toast": "🔄 A page refresh or network interruption was detected. Progress is being restored automatically…",
490
  "pipeline_start": "⏳ Starting the pipeline…",
@@ -626,10 +688,10 @@ TEXTS: dict[str, dict[str, str]] = {
626
  },
627
  "ru": {
628
  "title": "📊 Интеллектуальный агент для научных статей по управлению бизнесом",
629
- "topic_label": "📝 Название статьи",
630
- "keywords_label": "🔍 Ключевые слова",
631
- "words_label": "📏 Целевой объём ({min_words}–{max_words} слов)",
632
- "words_label_product": "📏 Целевой объём основного текста ({min_words}–{max_words} слов)",
633
  "product_tier_label": "Версия",
634
  "product_tier_standard": "Стандарт",
635
  "product_tier_flagship": "Флагман",
@@ -643,7 +705,7 @@ TEXTS: dict[str, dict[str, str]] = {
643
  "resume_unavailable_runtime_replaced": "⚠️ Обнаружена приостановленная незавершённая задача, но в текущей среде выполнения отсутствуют локальный файл запроса или контрольная точка, необходимые для продолжения. Чтобы избежать некорректного восстановления, кнопка продолжения отключена. Для освобождения задачи и нового запуска явно выберите «Удалить и начать заново».",
644
  "resume": "🔄 Продолжить предыдущую задачу",
645
  "clear": "🗑️ Удалить и начать заново",
646
- "cancel_generation": "⛔ Отменить генерацию",
647
  "draft_preparing": "Подготовка черновика на английском языке…",
648
  "draft_stage_inputs": "Систематизация исследовательских данных и доказательств…",
649
  "draft_stage_search": "Поиск научной литературы…",
@@ -695,6 +757,37 @@ TEXTS: dict[str, dict[str, str]] = {
695
  "asq_hint": "**⚠️ Важно**: укажите **роль информанта** и соблюдайте единый **формат диалога**, чтобы система точно извлекала цитаты и строила структуру кодирования.",
696
  "download_asq_template": "📥 Скачать TXT-шаблон интервью",
697
  "start_asq": "🚀 Запустить формирование в режиме ASQ",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
698
  "api_key_missing": "❌ Не задан DEEPSEEK_API_KEY. Настройте ключ API.",
699
  "resume_toast": "🔄 Обнаружено обновление страницы или нарушение соединения. Выполняется автоматическое восстановление…",
700
  "pipeline_start": "⏳ Запуск конвейера…",
@@ -836,10 +929,10 @@ TEXTS: dict[str, dict[str, str]] = {
836
  },
837
  "kk": {
838
  "title": "📊 Бизнес-басқару жөніндегі ғылыми мақала интеллектуалды агенті",
839
- "topic_label": "📝 Мақала атауы",
840
- "keywords_label": "🔍 Түйін сөздер",
841
- "words_label": "📏 Мақсатты көлем ({min_words}–{max_words} сөз)",
842
- "words_label_product": "📏 Негізгі мәтіннің мақсатты көлемі ({min_words}–{max_words} сөз)",
843
  "product_tier_label": "Нұсқа",
844
  "product_tier_standard": "Стандарт",
845
  "product_tier_flagship": "Флагман",
@@ -853,7 +946,7 @@ TEXTS: dict[str, dict[str, str]] = {
853
  "resume_unavailable_runtime_replaced": "⚠️ Уақытша тоқтатылған аяқталмаған тапсырма анықталды, бірақ ағымдағы іске қосу ортасында оны жалғастыруға қажет жергілікті сұрау немесе бақылау нүктесі файлдары жоқ. Қате қалпына келтіруге жол бермеу үшін жалғастыру түймесі өшірілді. Тапс��рманы босатып, қайта бастау үшін «Өшіріп, қайта бастау» әрекетін нақты таңдаңыз.",
854
  "resume": "🔄 Алдыңғы тапсырманы жалғастыру",
855
  "clear": "🗑️ Өшіріп, қайта бастау",
856
- "cancel_generation": "⛔ Генерацияны тоқтату",
857
  "draft_preparing": "Ағылшын тіліндегі бастапқы нұсқа дайындалуда…",
858
  "draft_stage_inputs": "Зерттеу кірістері мен дәлелдер реттелуде…",
859
  "draft_stage_search": "Ғылыми әдебиеттер ізделуде…",
@@ -905,6 +998,37 @@ TEXTS: dict[str, dict[str, str]] = {
905
  "asq_hint": "**⚠️ Маңызды**: жүйе дәйексөздерді дәл шығарып, кодтау құрылымын құруы үшін **ақпарат берушінің рөлін** көрсетіп, бірізді **диалог пішімін** қолданыңыз.",
906
  "download_asq_template": "📥 TXT сұхбат үлгісін жүктеу",
907
  "start_asq": "🚀 ASQ режимінде дайындауды бастау",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
908
  "api_key_missing": "❌ DEEPSEEK_API_KEY көрсетілмеген. API кілтін баптаңыз.",
909
  "resume_toast": "🔄 Бет жаңартылғаны немесе желі үзілісі анықталды. Үдеріс автоматты түрде қалпына келтірілуде…",
910
  "pipeline_start": "⏳ Жұмыс үдерісі басталуда…",
@@ -1080,5 +1204,53 @@ def get_asq_txt_template(language: str) -> str:
1080
  return ASQ_TXT_TEMPLATES[normalize_language(language)]
1081
 
1082
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1083
  def get_reference_heading(language: str) -> str:
1084
  return REFERENCE_HEADINGS[normalize_language(language)]
 
206
  TEXTS: dict[str, dict[str, str]] = {
207
  "zh": {
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": "旗舰版",
 
275
  "asq_hint": "**⚠️ 核心提示**:为了让AI准确提取语录和编码树,请务必标注**受访者角色(Role)**,并采用清晰的**对话格式**。",
276
  "download_asq_template": "📥 一键下载 TXT 访谈模板",
277
  "start_asq": "🚀 启动 ASQ 模式生成",
278
+ "research_materials_label": "研究资料(可选)",
279
+ "research_materials_help": "支持 PDF、TXT、DOC、DOCX、CSV、XLS、XLSX。SmartPaper 会结合论文题目、关键词与文件特征判断研究设计和资料用途。",
280
+ "drop_research_materials": "将研究资料拖到这里",
281
+ "limit_research_materials": "每个文件最大 1 GB · PDF, TXT, DOC, DOCX, CSV, XLS, XLSX",
282
+ "material_document_use_label": "如何使用上传的 PDF/DOC/DOCX 文件?",
283
+ "material_use_reference": "作为参考文献资料",
284
+ "material_use_research": "作为研究资料",
285
+ "detected_method_label": "研究设计(自动识别)",
286
+ "method_detection_explanation": "SmartPaper 根据论文题目、关键词和可选研究资料自动识别研究方法;不会修改你的题目或关键词。",
287
+ "method_confirm_label": "请确认研究设计",
288
+ "method_confirm_placeholder": "请选择研究方法",
289
+ "method_confirmation_required": "当前信息不足以可靠识别研究设计,请先确认研究方法。",
290
+ "method_not_supported": "已识别为“{method}”。该研究方法的专用生成引擎尚未开放;本次不会创建任务,也不会占用额度。",
291
+ "method_supported": "已识别为“{method}”。系统将按对应研究流程生成论文。",
292
+ "method_undetermined": "尚未确定",
293
+ "method_survey_path": "问卷与路径关系研究",
294
+ "method_qualitative": "定性研究",
295
+ "method_fsqca": "fsQCA 组态研究",
296
+ "method_meta_analysis": "元分析",
297
+ "method_panel_econometrics": "面板计量研究",
298
+ "method_bibliometric_integrative_review": "文献计量与整合性综述",
299
+ "adaptive_template_expander": "💡 推荐模板(可选)",
300
+ "download_research_template": "📥 下载研究资料模板",
301
+ "template_help_survey_path": "推荐使用变量或统计结果表。若未上传数据,系统会按项目规则生成用于初稿的辅助证据。",
302
+ "template_help_qualitative": "推荐使用访谈、田野记录或案例材料。若未上传资料,系统会构建匿名的初稿证据包。",
303
+ "template_help_fsqca": "推荐使用条件变量、结果变量及校准信息表。专用 fsQCA 引擎开放后将按此结构处理。",
304
+ "template_help_meta_analysis": "推荐使用研究层面的效应量提取表。真实论文身份与系统辅助效应量必须严格分离。",
305
+ "template_help_panel_econometrics": "推荐使用实体—时间面板数据表,例如企业—年度结构。",
306
+ "template_help_bibliometric_integrative_review": "推荐使用真实文献语料的题名、作者、年份、DOI、期刊和关键词等元数据。",
307
+ "template_help_undetermined": "确认研究设计后,系统将显示对应的研究资料模板。",
308
+ "start_generation_unified": "🚀 开始生成",
309
  "api_key_missing": "❌ 缺少 DEEPSEEK_API_KEY,请配置API密钥。",
310
  "resume_toast": "🔄 检测到页面刷新或网络抖动,系统正在自动恢复进度……",
311
  "pipeline_start": "⏳ 流水线启动……",
 
447
  },
448
  "en": {
449
  "title": "📊 Business Administration Article Agent",
450
+ "topic_label": "📝 Title (Required)",
451
+ "keywords_label": "🔍 Keywords (Required)",
452
+ "words_label": "📏 Target Word Count (Required, {min_words}–{max_words} words)",
453
+ "words_label_product": "📏 Target Word Count (Required, {min_words}–{max_words} words)",
454
  "product_tier_label": "Version",
455
  "product_tier_standard": "Standard",
456
  "product_tier_flagship": "Flagship",
 
464
  "resume_unavailable_runtime_replaced": "⚠️ A paused unfinished paper task was detected, but this runtime no longer has the local request or checkpoint files required to continue it. Resume has been disabled to prevent an unsafe recovery. To release this task and start again, explicitly choose ‘Delete and Start Again’.",
465
  "resume": "🔄 Continue Previous Task",
466
  "clear": "🗑️ Delete and Start Again",
467
+ "cancel_generation": "⛔ Cancel",
468
  "draft_preparing": "Preparing the English draft…",
469
  "draft_stage_inputs": "Organizing research inputs and evidence…",
470
  "draft_stage_search": "Searching the academic literature…",
 
516
  "asq_hint": "**⚠️ Important**: Clearly label each **informant role** and use a consistent **dialogue format** so the system can accurately extract quotations and build the coding structure.",
517
  "download_asq_template": "📥 Download TXT Interview Template",
518
  "start_asq": "🚀 Start ASQ Generation",
519
+ "research_materials_label": "Research Materials (Optional)",
520
+ "research_materials_help": "Supports PDF, TXT, DOC, DOCX, CSV, XLS, and XLSX. SmartPaper uses the Title, Keywords, and file characteristics to infer the research design and material role.",
521
+ "drop_research_materials": "Drag research materials here",
522
+ "limit_research_materials": "Up to 1 GB per file · PDF, TXT, DOC, DOCX, CSV, XLS, XLSX",
523
+ "material_document_use_label": "How should SmartPaper use the uploaded PDF/DOC/DOCX files?",
524
+ "material_use_reference": "Use as reference literature",
525
+ "material_use_research": "Use as research materials",
526
+ "detected_method_label": "Research Design (Auto-detected)",
527
+ "method_detection_explanation": "SmartPaper infers the research method from the Title, Keywords, and optional materials without rewriting your Title or Keywords.",
528
+ "method_confirm_label": "Confirm Research Design",
529
+ "method_confirm_placeholder": "Select a research method",
530
+ "method_confirmation_required": "The current information is insufficient for reliable method detection. Please confirm the research design first.",
531
+ "method_not_supported": "Detected method: {method}. Its dedicated generation engine is not yet enabled, so no task will be created and no quota will be used.",
532
+ "method_supported": "Detected method: {method}. SmartPaper will use the corresponding research workflow.",
533
+ "method_undetermined": "Not yet determined",
534
+ "method_survey_path": "Survey and Path-Relationship Study",
535
+ "method_qualitative": "Qualitative Study",
536
+ "method_fsqca": "fsQCA Configurational Study",
537
+ "method_meta_analysis": "Meta-Analysis",
538
+ "method_panel_econometrics": "Panel Econometric Study",
539
+ "method_bibliometric_integrative_review": "Bibliometric and Integrative Review",
540
+ "adaptive_template_expander": "💡 Recommended Template (Optional)",
541
+ "download_research_template": "📥 Download Research Materials Template",
542
+ "template_help_survey_path": "A variable-level or statistical-results table is recommended. If no data are uploaded, SmartPaper may construct draft evidence under the project provenance rules.",
543
+ "template_help_qualitative": "Interview, field-note, or case materials are recommended. If none are uploaded, SmartPaper may construct an anonymous draft evidence packet.",
544
+ "template_help_fsqca": "A table of conditions, outcomes, and calibration information is recommended. The dedicated fsQCA engine will use this structure once enabled.",
545
+ "template_help_meta_analysis": "A study-level effect-size extraction table is recommended. Real paper identities must remain separate from any system-assisted effect-size evidence.",
546
+ "template_help_panel_econometrics": "An entity-time panel table, such as a firm-year structure, is recommended.",
547
+ "template_help_bibliometric_integrative_review": "Use real-corpus metadata such as title, authors, year, DOI, venue, and keywords.",
548
+ "template_help_undetermined": "Confirm the research design to view the corresponding research-material template.",
549
+ "start_generation_unified": "🚀 Start Generation",
550
  "api_key_missing": "❌ DEEPSEEK_API_KEY is missing. Configure the API key.",
551
  "resume_toast": "🔄 A page refresh or network interruption was detected. Progress is being restored automatically…",
552
  "pipeline_start": "⏳ Starting the pipeline…",
 
688
  },
689
  "ru": {
690
  "title": "📊 Интеллектуальный агент для научных статей по управлению бизнесом",
691
+ "topic_label": "📝 Название статьи (обязательно)",
692
+ "keywords_label": "🔍 Ключевые слова (обязательно)",
693
+ "words_label": "📏 Целевой объём текста (обязательно, {min_words}–{max_words} слов)",
694
+ "words_label_product": "📏 Целевой объём текста (обязательно, {min_words}–{max_words} слов)",
695
  "product_tier_label": "Версия",
696
  "product_tier_standard": "Стандарт",
697
  "product_tier_flagship": "Флагман",
 
705
  "resume_unavailable_runtime_replaced": "⚠️ Обнаружена приостановленная незавершённая задача, но в текущей среде выполнения отсутствуют локальный файл запроса или контрольная точка, необходимые для продолжения. Чтобы избежать некорректного восстановления, кнопка продолжения отключена. Для освобождения задачи и нового запуска явно выберите «Удалить и начать заново».",
706
  "resume": "🔄 Продолжить предыдущую задачу",
707
  "clear": "🗑️ Удалить и начать заново",
708
+ "cancel_generation": "⛔ Отменить",
709
  "draft_preparing": "Подготовка черновика на английском языке…",
710
  "draft_stage_inputs": "Систематизация исследовательских данных и доказательств…",
711
  "draft_stage_search": "Поиск научной литературы…",
 
757
  "asq_hint": "**⚠️ Важно**: укажите **роль информанта** и соблюдайте единый **формат диалога**, чтобы система точно извлекала цитаты и строила структуру кодирования.",
758
  "download_asq_template": "📥 Скачать TXT-шаблон интервью",
759
  "start_asq": "🚀 Запустить формирование в режиме ASQ",
760
+ "research_materials_label": "Материалы исследования (необязательно)",
761
+ "research_materials_help": "Поддерживаются PDF, TXT, DOC, DOCX, CSV, XLS и XLSX. SmartPaper определяет дизайн исследования и назначение материалов по названию статьи, ключевым словам и характеристикам файлов.",
762
+ "drop_research_materials": "Перетащите исследовательские материалы сюда",
763
+ "limit_research_materials": "До 1 ГБ на файл · PDF, TXT, DOC, DOCX, CSV, XLS, XLSX",
764
+ "material_document_use_label": "Как использовать загруженные файлы PDF/DOC/DOCX?",
765
+ "material_use_reference": "Как научные источники",
766
+ "material_use_research": "Как исследовательские материалы",
767
+ "detected_method_label": "Дизайн исследования (определяется автоматически)",
768
+ "method_detection_explanation": "SmartPaper определяет метод по названию статьи, ключевым словам и необязательным материалам, не изменяя название и ключевые слова.",
769
+ "method_confirm_label": "Подтвердите дизайн исследования",
770
+ "method_confirm_placeholder": "Выберите метод исследования",
771
+ "method_confirmation_required": "Имеющейся информации недостаточно для надёжного определения метода. Сначала подтвердите дизайн исследования.",
772
+ "method_not_supported": "Определён метод «{method}». Специализированный движок этого метода пока не включён, поэтому задача не будет создана и квота не будет списана.",
773
+ "method_supported": "Определён метод «{method}». SmartPaper применит соответствующий исследовательский процесс.",
774
+ "method_undetermined": "Пока не определён",
775
+ "method_survey_path": "Опросное исследование и анализ связей",
776
+ "method_qualitative": "Качественное исследование",
777
+ "method_fsqca": "Конфигурационное исследование fsQCA",
778
+ "method_meta_analysis": "Мета-анализ",
779
+ "method_panel_econometrics": "Панельное эконометрическое исследование",
780
+ "method_bibliometric_integrative_review": "Библиометрический и интегративный обзор",
781
+ "adaptive_template_expander": "💡 Рекомендуемый шаблон (необязательно)",
782
+ "download_research_template": "📥 Скачать шаблон исследовательских материалов",
783
+ "template_help_survey_path": "Рекомендуется таблица переменных или статистических результатов. При отсутствии данных SmartPaper может сформировать черновые доказательные материалы в рамках правил происхождения данных.",
784
+ "template_help_qualitative": "Рекомендуются интервью, полевые заметки или материалы кейсов. При их отсутствии SmartPaper может сформировать анонимный черновой пакет доказательств.",
785
+ "template_help_fsqca": "Рекомендуется таблица условий, результата и параметров калибровки. После включения специализированный движок fsQCA будет использовать эту структуру.",
786
+ "template_help_meta_analysis": "Рекомендуется таблица извлечения размеров эффектов на уровне исследований. Реальные публикации нельзя связывать с системно сформированными размерами эффектов.",
787
+ "template_help_panel_econometrics": "Рекомендуется панельная таблица «объект–время», например структура «компания–год».",
788
+ "template_help_bibliometric_integrative_review": "Используйте реальные метаданные корпуса: название, авторов, год, DOI, журнал и ключевые слова.",
789
+ "template_help_undetermined": "После подтверждения дизайна исследования будет показан соответствующий шаблон материалов.",
790
+ "start_generation_unified": "🚀 Начать",
791
  "api_key_missing": "❌ Не задан DEEPSEEK_API_KEY. Настройте ключ API.",
792
  "resume_toast": "🔄 Обнаружено обновление страницы или нарушение соединения. Выполняется автоматическое восстановление…",
793
  "pipeline_start": "⏳ Запуск конвейера…",
 
929
  },
930
  "kk": {
931
  "title": "📊 Бизнес-басқару жөніндегі ғылыми мақала интеллектуалды агенті",
932
+ "topic_label": "📝 Мақала тақырыбы (міндетті)",
933
+ "keywords_label": "🔍 Кілт сөздер (міндетті)",
934
+ "words_label": "📏 Мақсатты сөз саны (міндетті, {min_words}–{max_words} сөз)",
935
+ "words_label_product": "📏 Мақсатты сөз саны (міндетті, {min_words}–{max_words} сөз)",
936
  "product_tier_label": "Нұсқа",
937
  "product_tier_standard": "Стандарт",
938
  "product_tier_flagship": "Флагман",
 
946
  "resume_unavailable_runtime_replaced": "⚠️ Уақытша тоқтатылған аяқталмаған тапсырма анықталды, бірақ ағымдағы іске қосу ортасында оны жалғастыруға қажет жергілікті сұрау немесе бақылау нүктесі файлдары жоқ. Қате қалпына келтіруге жол бермеу үшін жалғастыру түймесі өшірілді. Тапс��рманы босатып, қайта бастау үшін «Өшіріп, қайта бастау» әрекетін нақты таңдаңыз.",
947
  "resume": "🔄 Алдыңғы тапсырманы жалғастыру",
948
  "clear": "🗑️ Өшіріп, қайта бастау",
949
+ "cancel_generation": "⛔ Болдырмау",
950
  "draft_preparing": "Ағылшын тіліндегі бастапқы нұсқа дайындалуда…",
951
  "draft_stage_inputs": "Зерттеу кірістері мен дәлелдер реттелуде…",
952
  "draft_stage_search": "Ғылыми әдебиеттер ізделуде…",
 
998
  "asq_hint": "**⚠️ Маңызды**: жүйе дәйексөздерді дәл шығарып, кодтау құрылымын құруы үшін **ақпарат берушінің рөлін** көрсетіп, бірізді **диалог пішімін** қолданыңыз.",
999
  "download_asq_template": "📥 TXT сұхбат үлгісін жүктеу",
1000
  "start_asq": "🚀 ASQ режимінде дайындауды бастау",
1001
+ "research_materials_label": "Зерттеу материалдары (міндетті емес)",
1002
+ "research_materials_help": "PDF, TXT, DOC, DOCX, CSV, XLS және XLSX форматтары қолдау табады. SmartPaper мақала атауы, түйін сөздер және файл сипаттары негізінде зерттеу дизайны мен материалдардың қызметін анықтайды.",
1003
+ "drop_research_materials": "Зерттеу материалдарын осы жерге сүйреп әкеліңіз",
1004
+ "limit_research_materials": "Әр файлға 1 ГБ дейін · PDF, TXT, DOC, DOCX, CSV, XLS, XLSX",
1005
+ "material_document_use_label": "Жүктелген PDF/DOC/DOCX файлдарын қалай пайдалану керек?",
1006
+ "material_use_reference": "Ғылыми дереккөз ретінде",
1007
+ "material_use_research": "Зерттеу материалы ретінде",
1008
+ "detected_method_label": "Зерттеу дизайны (автоматты түрде анықталады)",
1009
+ "method_detection_explanation": "SmartPaper зерттеу әдісін мақала атауы, түйін сөздер және қосымша материалдар негізінде анықтайды; мақала атауы мен түйін сөздерді өзгертпейді.",
1010
+ "method_confirm_label": "Зерттеу дизайнын растаңыз",
1011
+ "method_confirm_placeholder": "Зерттеу әдісін таңдаңыз",
1012
+ "method_confirmation_required": "Қазіргі ақпарат зерттеу әдісін сенімді анықтауға жеткіліксіз. Алдымен зерттеу дизайнын растаңыз.",
1013
+ "method_not_supported": "«{method}» әдісі анықталды. Бұл әдістің арнайы генерация қозғалтқышы әлі қосылмаған, сондықтан тапсырма құрылмайды және квота пайдаланылмайды.",
1014
+ "method_supported": "«{method}» әдісі анықталды. SmartPaper тиісті зерттеу үдерісін қолданады.",
1015
+ "method_undetermined": "Әзірге анықталмаған",
1016
+ "method_survey_path": "Сауалнама және байланыстарды талдау зерттеуі",
1017
+ "method_qualitative": "Сапалық зерттеу",
1018
+ "method_fsqca": "fsQCA конфигурациялық зерттеуі",
1019
+ "method_meta_analysis": "Мета-талдау",
1020
+ "method_panel_econometrics": "Панельдік эконометрикалық зерттеу",
1021
+ "method_bibliometric_integrative_review": "Библиометриялық және интегративті шолу",
1022
+ "adaptive_template_expander": "💡 Ұсынылатын үлгі (міндетті емес)",
1023
+ "download_research_template": "📥 Зерттеу материалдары үлгісін жүктеу",
1024
+ "template_help_survey_path": "Айнымалылар немесе статистикалық нәтижелер кестесі ұсынылады. Дерек жүктелмесе, SmartPaper жоба ережелеріне сәйкес бастапқы нұсқаға арналған көмекші дәлелдерді құра алады.",
1025
+ "template_help_qualitative": "Сұхбат, далалық жазба немесе кейс материалдары ұсынылады. Олар болмаса, SmartPaper анонимді бастапқы дәлелдер пакетін құра алады.",
1026
+ "template_help_fsqca": "Шарттар, нәтиже және калибрлеу ақпараты бар кесте ұсынылады. Арнайы fsQCA қозғалтқышы қосылғаннан кейін осы құрылым қолданылады.",
1027
+ "template_help_meta_analysis": "Зерттеу деңгейіндегі әсер мөлшерлерін шығару кестесі ұсынылады. Нақты мақалалардың сәйкестендіргіштері жүйе құрастырған әсер мөлшерлерімен байланыстырылмауы тиіс.",
1028
+ "template_help_panel_econometrics": "Нысан–уақыт панельдік кестесі, мысалы компания–жыл құрылымы ұсынылады.",
1029
+ "template_help_bibliometric_integrative_review": "Нақты әдеби корпус метадеректерін пайдаланыңыз: атауы, авторлары, жылы, DOI, басылымы және түйін сөздері.",
1030
+ "template_help_undetermined": "Зерттеу дизайнын растағаннан кейін тиісті материал үлгісі көрсетіледі.",
1031
+ "start_generation_unified": "🚀 Бастау",
1032
  "api_key_missing": "❌ DEEPSEEK_API_KEY көрсетілмеген. API кілтін баптаңыз.",
1033
  "resume_toast": "🔄 Бет жаңартылғаны немесе желі үзілісі анықталды. Үдеріс автоматты түрде қалпына келтірілуде…",
1034
  "pipeline_start": "⏳ Жұмыс үдерісі басталуда…",
 
1204
  return ASQ_TXT_TEMPLATES[normalize_language(language)]
1205
 
1206
 
1207
+ def get_research_material_template(language: str, method_family: str) -> dict[str, str]:
1208
+ """Return the method-adaptive user template without changing engine policy."""
1209
+ family = str(method_family or "").strip().casefold()
1210
+ if family == "survey_path":
1211
+ return {
1212
+ "content": get_amj_csv_template(language),
1213
+ "code_language": "csv",
1214
+ "filename": "Survey_Path_Data_Template.csv",
1215
+ "mime_type": "text/csv",
1216
+ }
1217
+ if family == "qualitative":
1218
+ return {
1219
+ "content": get_asq_txt_template(language),
1220
+ "code_language": "text",
1221
+ "filename": "Qualitative_Research_Materials_Template.txt",
1222
+ "mime_type": "text/plain",
1223
+ }
1224
+ advanced = {
1225
+ "fsqca": (
1226
+ "case_id,condition_1,condition_2,condition_3,outcome\n"
1227
+ "CASE001,0.85,0.20,0.70,0.90\n",
1228
+ "fsQCA_Conditions_Outcome_Template.csv",
1229
+ ),
1230
+ "meta_analysis": (
1231
+ "study_id,effect_size,standard_error,sample_size,moderator\n"
1232
+ "STUDY001,0.25,0.08,240,context_A\n",
1233
+ "Meta_Analysis_Effect_Size_Template.csv",
1234
+ ),
1235
+ "panel_econometrics": (
1236
+ "entity_id,year,outcome,x1,x2,control_1\n"
1237
+ "E001,2025,0.62,0.40,0.71,0.15\n",
1238
+ "Panel_Entity_Time_Template.csv",
1239
+ ),
1240
+ "bibliometric_integrative_review": (
1241
+ "title,authors,year,doi,venue,keywords\n"
1242
+ "Real publication title,Author A; Author B,2025,10.xxxx/xxxxx,Journal Name,keyword1; keyword2\n",
1243
+ "Bibliometric_Corpus_Metadata_Template.csv",
1244
+ ),
1245
+ }
1246
+ content, filename = advanced.get(family, ("", "SmartPaper_Research_Materials_Template.txt"))
1247
+ return {
1248
+ "content": content,
1249
+ "code_language": "csv" if content else "text",
1250
+ "filename": filename,
1251
+ "mime_type": "text/csv" if content else "text/plain",
1252
+ }
1253
+
1254
+
1255
  def get_reference_heading(language: str) -> str:
1256
  return REFERENCE_HEADINGS[normalize_language(language)]
task_executor.py CHANGED
@@ -29,6 +29,7 @@ import requests
29
 
30
  from config import APP_CONFIG
31
  from core.checkpoint_store import TaskCheckpointStore, checkpoint_authority_lock
 
32
 
33
  _log_handlers = [logging.StreamHandler(sys.stdout)]
34
  _executor_log_path = str(os.environ.get("SMARTPAPER_EXECUTOR_LOG_PATH") or "").strip()
@@ -415,6 +416,23 @@ def _snapshot_nullable_version(value):
415
  return text or None
416
 
417
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
418
  def _trusted_task_policy_snapshot_envelope(snapshot: dict, resolved: dict) -> dict:
419
  return {
420
  "task_id": str(snapshot.get("task_id") or ""),
@@ -455,11 +473,18 @@ def _validate_trusted_task_policy_snapshot(claim: dict, task: dict, request_data
455
  entitlement_class = str(snapshot.get("entitlement_class") or "").strip().upper()
456
  if tier not in {"STANDARD", "FLAGSHIP"}:
457
  raise RuntimeError("task_policy_snapshot_product_tier_invalid")
458
- if entitlement_class not in {"STANDARD_TRIAL", "STANDARD_PAID", "FLAGSHIP_PAID"}:
 
 
 
 
 
 
 
 
 
459
  raise RuntimeError("task_policy_snapshot_entitlement_invalid")
460
- if tier == "STANDARD" and entitlement_class == "FLAGSHIP_PAID":
461
- raise RuntimeError("task_policy_snapshot_entitlement_mismatch")
462
- if tier == "FLAGSHIP" and entitlement_class != "FLAGSHIP_PAID":
463
  raise RuntimeError("task_policy_snapshot_entitlement_mismatch")
464
 
465
  if str(resolved.get("snapshot_schema_version") or "") != str(snapshot.get("snapshot_schema_version") or ""):
@@ -479,6 +504,7 @@ def _validate_trusted_task_policy_snapshot(claim: dict, task: dict, request_data
479
  if _snapshot_nullable_version(resolved.get(field)) != _snapshot_nullable_version(snapshot.get(field)):
480
  raise RuntimeError("task_policy_snapshot_policy_version_mismatch")
481
 
 
482
  if str(resolved.get("manuscript_mode") or "").strip().lower() != str(task.get("mode") or "").strip().lower():
483
  raise RuntimeError("task_policy_snapshot_mode_mismatch")
484
  target_words = int(task.get("target_words") or 0)
 
29
 
30
  from config import APP_CONFIG
31
  from core.checkpoint_store import TaskCheckpointStore, checkpoint_authority_lock
32
+ from core.advanced_method_registry import METHOD_FAMILIES, method_capability
33
 
34
  _log_handlers = [logging.StreamHandler(sys.stdout)]
35
  _executor_log_path = str(os.environ.get("SMARTPAPER_EXECUTOR_LOG_PATH") or "").strip()
 
416
  return text or None
417
 
418
 
419
+ def _validate_request_method_family(request_data: dict, task: dict) -> None:
420
+ """Validate new U1-R2 method identity while preserving legacy requests."""
421
+ raw_family = str((request_data or {}).get("method_family") or "").strip().casefold()
422
+ if not raw_family:
423
+ return
424
+ if raw_family not in METHOD_FAMILIES:
425
+ raise RuntimeError("request_method_family_invalid")
426
+ capability = method_capability(raw_family)
427
+ expected_lane = str(capability.get("legacy_policy_lane") or "").strip().casefold()
428
+ request_lane = str((request_data or {}).get("derived_policy_lane") or "").strip().casefold()
429
+ task_lane = str((task or {}).get("mode") or "").strip().casefold()
430
+ if expected_lane not in {"amj", "asq"} or request_lane != expected_lane or task_lane != expected_lane:
431
+ raise RuntimeError("request_method_family_lane_mismatch")
432
+ if str(capability.get("capability_status") or "").strip().casefold() != "production_supported":
433
+ raise RuntimeError("request_method_family_capability_blocked")
434
+
435
+
436
  def _trusted_task_policy_snapshot_envelope(snapshot: dict, resolved: dict) -> dict:
437
  return {
438
  "task_id": str(snapshot.get("task_id") or ""),
 
473
  entitlement_class = str(snapshot.get("entitlement_class") or "").strip().upper()
474
  if tier not in {"STANDARD", "FLAGSHIP"}:
475
  raise RuntimeError("task_policy_snapshot_product_tier_invalid")
476
+ trial = resolved.get("trial") if isinstance(resolved.get("trial"), dict) else None
477
+ billing = resolved.get("billing") if isinstance(resolved.get("billing"), dict) else None
478
+ allowed_entitlements = []
479
+ if trial and str(trial.get("entitlement_class") or "").strip():
480
+ allowed_entitlements.append(str(trial.get("entitlement_class") or "").strip().upper())
481
+ if billing and str(billing.get("entitlement_class") or "").strip():
482
+ paid_class = str(billing.get("entitlement_class") or "").strip().upper()
483
+ if paid_class not in allowed_entitlements:
484
+ allowed_entitlements.append(paid_class)
485
+ if not entitlement_class or entitlement_class not in allowed_entitlements:
486
  raise RuntimeError("task_policy_snapshot_entitlement_invalid")
487
+ if any(not value.startswith(f"{tier}_") for value in allowed_entitlements):
 
 
488
  raise RuntimeError("task_policy_snapshot_entitlement_mismatch")
489
 
490
  if str(resolved.get("snapshot_schema_version") or "") != str(snapshot.get("snapshot_schema_version") or ""):
 
504
  if _snapshot_nullable_version(resolved.get(field)) != _snapshot_nullable_version(snapshot.get(field)):
505
  raise RuntimeError("task_policy_snapshot_policy_version_mismatch")
506
 
507
+ _validate_request_method_family(request_data, task)
508
  if str(resolved.get("manuscript_mode") or "").strip().lower() != str(task.get("mode") or "").strip().lower():
509
  raise RuntimeError("task_policy_snapshot_mode_mismatch")
510
  target_words = int(task.get("target_words") or 0)