P0-R5-R3-D single authority registry projection

#142
Files changed (3) hide show
  1. app.py +150 -87
  2. core/advanced_method_registry.py +119 -15
  3. i18n.py +17 -6
app.py CHANGED
@@ -34,8 +34,9 @@ from i18n import (
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
 
@@ -304,10 +305,10 @@ for key, default in {
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_method_selector": "qualitative",
310
- "sp_method_selector_signature": "",
311
  "sp_material_document_use": "reference",
312
  }.items():
313
  if key not in st.session_state:
@@ -464,7 +465,7 @@ def _task_method_family(task: dict) -> str:
464
  except Exception:
465
  pass
466
  mode = _task_mode(task)
467
- return "qualitative" if mode == "asq" else "survey_path"
468
 
469
 
470
  def _task_output_path(task: dict) -> Path | None:
@@ -504,48 +505,89 @@ def _uploaded_material_descriptors() -> list[dict[str, str]]:
504
  return out
505
 
506
 
507
- def _method_intent_signature() -> str:
508
- """Identity of the current paper intent for launch-time stale-state checks.
 
509
 
510
- Title/Keywords remain part of the launch signature so a pending generation
511
- cannot start against a different paper intent. Research Design reset itself
512
- is event-driven by the Title/Keywords widgets rather than by every Streamlit
513
- rerun; unrelated product-tier, word-count, and button reruns therefore never
514
- change the selected design. Uploaded materials do not reset the selection.
515
- """
516
  payload = {
517
- "research_design_control": "manual-default-qualitative-v3-event-reset",
518
- "topic": str(st.session_state.get("sp_topic") or DEFAULT_TOPIC).strip(),
519
- "keywords": str(st.session_state.get("sp_keywords") or DEFAULT_KEYWORDS).strip(),
 
 
 
 
520
  }
521
  return hashlib.sha256(
522
  json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
523
  ).hexdigest()
524
 
525
 
526
- def _reset_research_design_after_intent_edit() -> None:
527
- """Reset a not-yet-started paper to the qualitative default after intent edit.
528
 
529
- Streamlit invokes this callback only when the Title or Keywords widget
530
- actually changes. It is intentionally not attached to product tier, target
531
- words, research materials, or the Generate button.
 
532
  """
533
- st.session_state["sp_method_selector"] = "qualitative"
534
- st.session_state["sp_method_confirmation"] = ""
535
- st.session_state["sp_method_confirmation_signature"] = ""
536
- st.session_state["sp_method_selector_signature"] = ""
537
- st.session_state["sp_pending_launch"] = None
538
- st.session_state["run_mode"] = "asq"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
539
 
540
 
541
  def _current_method_preflight(*, confirmed_method_family: str | None = None) -> tuple[dict, str]:
 
542
  signature = _method_intent_signature()
543
- stored_confirmation = str(st.session_state.get("sp_method_confirmation") or "").strip()
544
- stored_signature = str(st.session_state.get("sp_method_confirmation_signature") or "").strip()
545
- if confirmed_method_family is None:
546
- confirmed = stored_confirmation if stored_signature == signature else ""
547
- else:
548
- confirmed = str(confirmed_method_family or "").strip()
549
  preflight = resolve_generation_preflight(
550
  str(st.session_state.get("sp_topic") or DEFAULT_TOPIC),
551
  str(st.session_state.get("sp_keywords") or DEFAULT_KEYWORDS),
@@ -554,7 +596,6 @@ def _current_method_preflight(*, confirmed_method_family: str | None = None) ->
554
  )
555
  return preflight, signature
556
 
557
-
558
  def _classify_and_save_research_materials(
559
  method_family: str, inputs_dir: Path
560
  ) -> tuple[list[str], list[str], list[str], str]:
@@ -580,22 +621,26 @@ def _classify_and_save_research_materials(
580
  if decision not in {"reference", "research"}:
581
  decision = "reference"
582
 
583
- reference_items = []
584
- data_items = list(structured)
585
- interview_items = []
 
 
 
 
 
 
 
 
 
 
 
 
 
586
 
587
- if method_family == "qualitative":
588
- interview_items.extend(text_materials)
589
- if decision == "research":
590
- interview_items.extend(documents)
591
- else:
592
- reference_items.extend(documents)
593
- else:
594
- # Structured spreadsheets are the only legacy-safe empirical-data path
595
- # for current survey production. Text/doc materials remain reference
596
- # context unless a dedicated future method adapter owns them.
597
- reference_items.extend(text_materials)
598
- reference_items.extend(documents)
599
 
600
  refs = _save_uploaded(reference_items, inputs_dir, "ref")
601
  data = _save_uploaded(data_items[:1], inputs_dir, "data")
@@ -621,7 +666,7 @@ def _persist_task_request(
621
 
622
  capability = method_capability(method_family)
623
  expected_lane = str(capability.get("legacy_policy_lane") or "").strip().lower()
624
- if expected_lane not in {"amj", "asq"} or expected_lane != str(mode or "").strip().lower():
625
  raise RuntimeError("method_family_policy_lane_mismatch")
626
 
627
  refs, data, interviews, material_use_decision = _classify_and_save_research_materials(
@@ -801,19 +846,34 @@ def _process_pending_launch(lang_code: str) -> None:
801
  idempotency_key = str(pending.get("idempotency_key") or "").strip()
802
  pending_signature = str(pending.get("method_intent_signature") or "").strip()
803
 
804
- # Repeat the capability preflight immediately before any task/quota
805
- # creation so a stale rerun or modified session state cannot bypass the
806
- # manually selected/default research design.
 
 
807
  preflight, current_signature = _current_method_preflight(
808
- confirmed_method_family=pending_family or None
809
  )
810
  resolved_family = str(preflight.get("method_family") or "").strip().casefold()
811
  resolved_mode = str(preflight.get("legacy_policy_lane") or "").strip().lower()
 
 
 
 
 
 
 
 
 
 
 
812
  if (
813
  not bool(preflight.get("can_start_generation"))
814
  or not pending_family
 
815
  or pending_family != resolved_family
816
  or pending_mode != resolved_mode
 
817
  or not pending_signature
818
  or pending_signature != current_signature
819
  ):
@@ -1499,14 +1559,12 @@ def render_workspace_controls(user):
1499
  value=DEFAULT_TOPIC,
1500
  disabled=active,
1501
  key="sp_topic",
1502
- on_change=_reset_research_design_after_intent_edit,
1503
  )
1504
  st.text_input(
1505
  tr(lang_code, "keywords_label"),
1506
  value=DEFAULT_KEYWORDS,
1507
  disabled=active,
1508
  key="sp_keywords",
1509
- on_change=_reset_research_design_after_intent_edit,
1510
  )
1511
 
1512
  # U1-R2 unified research workspace: academic intent/materials first, then
@@ -1547,32 +1605,21 @@ def render_workspace_controls(user):
1547
  }
1548
  method_signature = ""
1549
  else:
1550
- # SmartPaper intentionally supports only two reader-visible research
1551
- # designs. New tasks default to qualitative research. Title/Keywords
1552
- # changes reset the selector through their explicit on_change callback;
1553
- # unrelated Streamlit reruns must preserve the user's manual selection.
1554
- method_signature = _method_intent_signature()
1555
- selected_default = str(
1556
- st.session_state.get("sp_method_selector") or "qualitative"
1557
- ).strip().casefold()
1558
- if selected_default not in METHOD_FAMILIES:
1559
- selected_default = "qualitative"
1560
- st.session_state["sp_method_selector"] = selected_default
1561
-
1562
- selected = st.selectbox(
1563
  tr(lang_code, "detected_method_label"),
1564
  list(METHOD_FAMILIES),
1565
- index=list(METHOD_FAMILIES).index(selected_default),
1566
  format_func=lambda family: tr(lang_code, f"method_{family}"),
1567
- key="sp_method_selector",
1568
  disabled=active,
1569
  )
1570
- selected = str(selected or "qualitative").strip().casefold()
1571
- if selected not in METHOD_FAMILIES:
1572
- selected = "qualitative"
1573
- st.session_state["sp_method_confirmation"] = selected
1574
- st.session_state["sp_method_confirmation_signature"] = method_signature
1575
- preflight, method_signature = _current_method_preflight(confirmed_method_family=selected)
1576
 
1577
  method_family = str(preflight.get("method_family") or "undetermined").strip().casefold()
1578
  method_label = (
@@ -1593,7 +1640,9 @@ def render_workspace_controls(user):
1593
  with st.expander(tr(lang_code, "adaptive_template_expander")):
1594
  if method_family in METHOD_FAMILIES:
1595
  st.markdown(tr(lang_code, f"template_help_{method_family}"))
1596
- template_info = get_research_material_template(lang_code, method_family)
 
 
1597
  template_data = str(template_info.get("content") or "")
1598
  if template_data:
1599
  st.code(template_data, language=str(template_info.get("code_language") or "text"))
@@ -1604,6 +1653,8 @@ def render_workspace_controls(user):
1604
  mime=str(template_info.get("mime_type") or "text/plain"),
1605
  use_container_width=True,
1606
  )
 
 
1607
  else:
1608
  st.markdown(tr(lang_code, "template_help_undetermined"))
1609
 
@@ -1690,25 +1741,37 @@ def render_workspace_controls(user):
1690
  disabled=not can_start,
1691
  key="btn_generation_unified",
1692
  ):
1693
- # Re-resolve at click time; _process_pending_launch repeats this gate
1694
- # again immediately before authoritative task/quota creation.
 
 
 
1695
  click_preflight, click_signature = _current_method_preflight(
1696
- confirmed_method_family=method_family if method_family in METHOD_FAMILIES else None
1697
  )
1698
- if not bool(click_preflight.get("can_start_generation")):
1699
- reason = str(click_preflight.get("block_reason") or "")
 
 
 
1700
  st.warning(tr(lang_code, "method_resolution_invalid"))
1701
  else:
1702
- resolved_family = str(click_preflight.get("method_family") or "").strip().casefold()
1703
  resolved_mode = str(click_preflight.get("legacy_policy_lane") or "").strip().lower()
 
1704
  st.session_state["run_mode"] = resolved_mode
1705
  st.session_state["sp_pending_launch"] = {
1706
  "mode": resolved_mode,
1707
- "method_family": resolved_family,
1708
  "method_resolver_version": str(
1709
  (click_preflight.get("resolution") or {}).get("resolver_version")
1710
  or METHOD_RESOLVER_VERSION
1711
  ),
 
 
 
 
 
 
1712
  "method_intent_signature": click_signature,
1713
  "idempotency_key": _new_generation_idempotency_key(),
1714
  }
 
34
  get_target_flag, get_target_language, tr,
35
  )
36
  from core.advanced_method_registry import (
37
+ METHOD_CAPABILITY_REGISTRY_VERSION, METHOD_FAMILIES, METHOD_RESOLVER_VERSION, default_method_family,
38
+ method_capability, method_family_for_legacy_lane, normalize_method_family,
39
+ reconcile_method_authority, resolve_generation_preflight,
40
  )
41
  from smartpaper_restore_bridge import edge_bridge
42
 
 
305
  "sp_last_ui_task_status": "",
306
  "sp_scroll_to_generation_action": False,
307
  "sp_completed_download_scroll_task_id": "",
308
+ "sp_research_design_authority": "",
309
+ "sp_method_selector_ui": "",
310
+ "sp_research_design_ui_snapshot": "",
311
+ "sp_research_intent_snapshot": "",
312
  "sp_material_document_use": "reference",
313
  }.items():
314
  if key not in st.session_state:
 
465
  except Exception:
466
  pass
467
  mode = _task_mode(task)
468
+ return method_family_for_legacy_lane(mode) or default_method_family()
469
 
470
 
471
  def _task_output_path(task: dict) -> Path | None:
 
505
  return out
506
 
507
 
508
+ def _normalized_research_intent_text(value: object) -> str:
509
+ """Normalize intent text so whitespace-only edits do not reset design."""
510
+ return re.sub(r"\s+", " ", str(value or "")).strip()
511
 
512
+
513
+ def _method_intent_signature() -> str:
514
+ """Stable Title/Keywords identity used by design reconciliation and launch."""
 
 
 
515
  payload = {
516
+ "research_design_control": "single-authority-registry-projection-v1",
517
+ "topic": _normalized_research_intent_text(
518
+ st.session_state.get("sp_topic") or DEFAULT_TOPIC
519
+ ),
520
+ "keywords": _normalized_research_intent_text(
521
+ st.session_state.get("sp_keywords") or DEFAULT_KEYWORDS
522
+ ),
523
  }
524
  return hashlib.sha256(
525
  json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
526
  ).hexdigest()
527
 
528
 
529
+ def _reconcile_research_design_authority() -> tuple[str, str, dict]:
530
+ """Maintain one mutable new-task Research Design authority.
531
 
532
+ The selector is only a UI projection. Explicit selector changes win over a
533
+ simultaneous Title/Keywords edit; otherwise a genuine intent edit resets to
534
+ the registry-configured default. Product tier, word count, language, upload,
535
+ payment, and Generate reruns do not participate in this reconciliation.
536
  """
537
+ default_family = default_method_family()
538
+
539
+ authority = normalize_method_family(st.session_state.get("sp_research_design_authority"))
540
+ if not authority:
541
+ # One-time migration for live sessions created by the previous UI key.
542
+ authority = (
543
+ normalize_method_family(st.session_state.get("sp_method_selector"))
544
+ or default_family
545
+ )
546
+
547
+ current_ui = (
548
+ normalize_method_family(st.session_state.get("sp_method_selector_ui"))
549
+ or authority
550
+ )
551
+ previous_ui = (
552
+ normalize_method_family(st.session_state.get("sp_research_design_ui_snapshot"))
553
+ or authority
554
+ )
555
+ current_signature = _method_intent_signature()
556
+ previous_signature = str(st.session_state.get("sp_research_intent_snapshot") or "").strip()
557
+ if not previous_signature:
558
+ previous_signature = current_signature
559
+
560
+ reconciliation = reconcile_method_authority(
561
+ current_authority=authority,
562
+ current_ui_family=current_ui,
563
+ previous_ui_family=previous_ui,
564
+ current_intent_signature=current_signature,
565
+ previous_intent_signature=previous_signature,
566
+ )
567
+ resolved_family = normalize_method_family(
568
+ reconciliation.get("method_family"), fallback_to_default=True
569
+ )
570
+
571
+ if reconciliation.get("ui_changed") or reconciliation.get("intent_changed"):
572
+ # Any explicit design/intent change invalidates a not-yet-launched snapshot.
573
+ st.session_state["sp_pending_launch"] = None
574
+
575
+ st.session_state["sp_research_design_authority"] = resolved_family
576
+ st.session_state["sp_method_selector_ui"] = resolved_family
577
+ st.session_state["sp_research_design_ui_snapshot"] = resolved_family
578
+ st.session_state["sp_research_intent_snapshot"] = current_signature
579
+ return resolved_family, current_signature, reconciliation
580
 
581
 
582
  def _current_method_preflight(*, confirmed_method_family: str | None = None) -> tuple[dict, str]:
583
+ """Validate the current/frozen authority without re-selecting a design."""
584
  signature = _method_intent_signature()
585
+ confirmed = normalize_method_family(confirmed_method_family)
586
+ if not confirmed:
587
+ confirmed = normalize_method_family(
588
+ st.session_state.get("sp_research_design_authority"),
589
+ fallback_to_default=True,
590
+ )
591
  preflight = resolve_generation_preflight(
592
  str(st.session_state.get("sp_topic") or DEFAULT_TOPIC),
593
  str(st.session_state.get("sp_keywords") or DEFAULT_KEYWORDS),
 
596
  )
597
  return preflight, signature
598
 
 
599
  def _classify_and_save_research_materials(
600
  method_family: str, inputs_dir: Path
601
  ) -> tuple[list[str], list[str], list[str], str]:
 
621
  if decision not in {"reference", "research"}:
622
  decision = "reference"
623
 
624
+ buckets = {"reference": [], "data": [], "interview": []}
625
+ capability = method_capability(method_family)
626
+ material_routing = dict(capability.get("material_routing") or {})
627
+
628
+ def _route(items: list, route_key: str) -> None:
629
+ bucket = str(material_routing.get(route_key) or "").strip().casefold()
630
+ if bucket not in buckets:
631
+ raise RuntimeError("method_material_routing_unsupported")
632
+ buckets[bucket].extend(items)
633
+
634
+ _route(structured, "structured")
635
+ _route(text_materials, "text")
636
+ _route(
637
+ documents,
638
+ "document_research" if decision == "research" else "document_reference",
639
+ )
640
 
641
+ reference_items = buckets["reference"]
642
+ data_items = buckets["data"]
643
+ interview_items = buckets["interview"]
 
 
 
 
 
 
 
 
 
644
 
645
  refs = _save_uploaded(reference_items, inputs_dir, "ref")
646
  data = _save_uploaded(data_items[:1], inputs_dir, "data")
 
666
 
667
  capability = method_capability(method_family)
668
  expected_lane = str(capability.get("legacy_policy_lane") or "").strip().lower()
669
+ if not expected_lane or expected_lane != str(mode or "").strip().lower():
670
  raise RuntimeError("method_family_policy_lane_mismatch")
671
 
672
  refs, data, interviews, material_use_decision = _classify_and_save_research_materials(
 
846
  idempotency_key = str(pending.get("idempotency_key") or "").strip()
847
  pending_signature = str(pending.get("method_intent_signature") or "").strip()
848
 
849
+ # Validate the frozen launch snapshot. This gate may reject stale state, but
850
+ # it must never choose or rewrite Research Design after the user clicks Generate.
851
+ current_authority = normalize_method_family(
852
+ st.session_state.get("sp_research_design_authority")
853
+ )
854
  preflight, current_signature = _current_method_preflight(
855
+ confirmed_method_family=pending_family
856
  )
857
  resolved_family = str(preflight.get("method_family") or "").strip().casefold()
858
  resolved_mode = str(preflight.get("legacy_policy_lane") or "").strip().lower()
859
+ capability = dict(preflight.get("capability") or {})
860
+ projection_matches = all([
861
+ str(pending.get("capability_registry_version") or "").strip()
862
+ == str(capability.get("registry_version") or "").strip(),
863
+ str(pending.get("engine_family") or "").strip()
864
+ == str(capability.get("engine_family") or "").strip(),
865
+ str(pending.get("template_profile") or "").strip()
866
+ == str(capability.get("template_profile") or "").strip(),
867
+ str(pending.get("material_profile") or "").strip()
868
+ == str(capability.get("material_profile") or "").strip(),
869
+ ])
870
  if (
871
  not bool(preflight.get("can_start_generation"))
872
  or not pending_family
873
+ or pending_family != current_authority
874
  or pending_family != resolved_family
875
  or pending_mode != resolved_mode
876
+ or not projection_matches
877
  or not pending_signature
878
  or pending_signature != current_signature
879
  ):
 
1559
  value=DEFAULT_TOPIC,
1560
  disabled=active,
1561
  key="sp_topic",
 
1562
  )
1563
  st.text_input(
1564
  tr(lang_code, "keywords_label"),
1565
  value=DEFAULT_KEYWORDS,
1566
  disabled=active,
1567
  key="sp_keywords",
 
1568
  )
1569
 
1570
  # U1-R2 unified research workspace: academic intent/materials first, then
 
1605
  }
1606
  method_signature = ""
1607
  else:
1608
+ # One business authority owns the new-task research design. The widget is
1609
+ # only a UI projection; workflow/engine/template come from the registry.
1610
+ authority_family, method_signature, _reconciliation = (
1611
+ _reconcile_research_design_authority()
1612
+ )
1613
+ st.selectbox(
 
 
 
 
 
 
 
1614
  tr(lang_code, "detected_method_label"),
1615
  list(METHOD_FAMILIES),
 
1616
  format_func=lambda family: tr(lang_code, f"method_{family}"),
1617
+ key="sp_method_selector_ui",
1618
  disabled=active,
1619
  )
1620
+ preflight, method_signature = _current_method_preflight(
1621
+ confirmed_method_family=authority_family
1622
+ )
 
 
 
1623
 
1624
  method_family = str(preflight.get("method_family") or "undetermined").strip().casefold()
1625
  method_label = (
 
1640
  with st.expander(tr(lang_code, "adaptive_template_expander")):
1641
  if method_family in METHOD_FAMILIES:
1642
  st.markdown(tr(lang_code, f"template_help_{method_family}"))
1643
+ capability = dict(preflight.get("capability") or method_capability(method_family))
1644
+ template_profile = str(capability.get("template_profile") or "").strip()
1645
+ template_info = get_research_material_template(lang_code, template_profile)
1646
  template_data = str(template_info.get("content") or "")
1647
  if template_data:
1648
  st.code(template_data, language=str(template_info.get("code_language") or "text"))
 
1653
  mime=str(template_info.get("mime_type") or "text/plain"),
1654
  use_container_width=True,
1655
  )
1656
+ else:
1657
+ st.warning(tr(lang_code, "method_resolution_invalid"))
1658
  else:
1659
  st.markdown(tr(lang_code, "template_help_undetermined"))
1660
 
 
1741
  disabled=not can_start,
1742
  key="btn_generation_unified",
1743
  ):
1744
+ # Freeze the already-reconciled authority. This is validation only;
1745
+ # Generate must never infer or reset Research Design.
1746
+ launch_family = normalize_method_family(
1747
+ st.session_state.get("sp_research_design_authority")
1748
+ )
1749
  click_preflight, click_signature = _current_method_preflight(
1750
+ confirmed_method_family=launch_family
1751
  )
1752
+ if (
1753
+ not launch_family
1754
+ or not bool(click_preflight.get("can_start_generation"))
1755
+ or str(click_preflight.get("method_family") or "").strip().casefold() != launch_family
1756
+ ):
1757
  st.warning(tr(lang_code, "method_resolution_invalid"))
1758
  else:
 
1759
  resolved_mode = str(click_preflight.get("legacy_policy_lane") or "").strip().lower()
1760
+ capability = dict(click_preflight.get("capability") or {})
1761
  st.session_state["run_mode"] = resolved_mode
1762
  st.session_state["sp_pending_launch"] = {
1763
  "mode": resolved_mode,
1764
+ "method_family": launch_family,
1765
  "method_resolver_version": str(
1766
  (click_preflight.get("resolution") or {}).get("resolver_version")
1767
  or METHOD_RESOLVER_VERSION
1768
  ),
1769
+ "capability_registry_version": str(
1770
+ capability.get("registry_version") or METHOD_CAPABILITY_REGISTRY_VERSION
1771
+ ),
1772
+ "engine_family": str(capability.get("engine_family") or ""),
1773
+ "template_profile": str(capability.get("template_profile") or ""),
1774
+ "material_profile": str(capability.get("material_profile") or ""),
1775
  "method_intent_signature": click_signature,
1776
  "idempotency_key": _new_generation_idempotency_key(),
1777
  }
core/advanced_method_registry.py CHANGED
@@ -4,8 +4,8 @@
4
  Production research design is intentionally limited to two supported paths:
5
  qualitative research and survey-based quantitative research. New tasks default to
6
  qualitative research, and the user may explicitly adjust the design before task
7
- creation. Title, Keywords, and uploaded Research Materials never auto-select or
8
- change the research design.
9
 
10
  This module never selects providers/models and never rewrites Title/Keywords.
11
  """
@@ -15,8 +15,9 @@ import os
15
  import re
16
  from typing import Any, Iterable
17
 
18
- METHOD_RESOLVER_VERSION = "smartpaper-manual-method-selector-v1"
19
- METHOD_CAPABILITY_REGISTRY_VERSION = "smartpaper-method-capability-registry-v3"
 
20
 
21
  # Reader-visible / task-owning method families. Order is intentional:
22
  # qualitative is the product default and appears first in the selector.
@@ -31,12 +32,28 @@ _METHOD_CAPABILITIES: dict[str, dict[str, Any]] = {
31
  "capability_status": "production_supported",
32
  "evidence_timing": "pre_literature",
33
  "engine_family": "qualitative_engine",
 
 
 
 
 
 
 
 
34
  },
35
  "survey_path": {
36
  "legacy_policy_lane": "amj",
37
  "capability_status": "production_supported",
38
  "evidence_timing": "pre_literature",
39
  "engine_family": "quantitative_engine",
 
 
 
 
 
 
 
 
40
  },
41
  }
42
 
@@ -139,6 +156,74 @@ def _material_descriptors(materials: Iterable[Any] | None) -> list[dict[str, str
139
  return out
140
 
141
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  def method_capability(method_family: str) -> dict[str, Any]:
143
  """Return a copy of versioned capability metadata for one supported family."""
144
  family = str(method_family or "").strip().casefold()
@@ -151,6 +236,9 @@ def method_capability(method_family: str) -> dict[str, Any]:
151
  "capability_status": "ambiguous",
152
  "evidence_timing": "undetermined",
153
  "engine_family": "undetermined",
 
 
 
154
  }
155
  row.update({
156
  "registry_version": METHOD_CAPABILITY_REGISTRY_VERSION,
@@ -183,13 +271,14 @@ def resolve_method_family(
183
  """
184
  _ = title, keywords
185
  descriptors = _material_descriptors(materials)
186
- capability = method_capability("qualitative")
 
187
  return {
188
  "resolver_version": METHOD_RESOLVER_VERSION,
189
  "shadow_only": False,
190
- "method_family": "qualitative",
191
- "confidence": "DEFAULT_QUALITATIVE",
192
- "reason_codes": ["manual_design_control", "default_qualitative"],
193
  "title_keyword_hits": [],
194
  "specialized_method_detected": False,
195
  "specialized_method_families": [],
@@ -221,14 +310,14 @@ def resolve_generation_preflight(
221
  ) -> dict[str, Any]:
222
  """Resolve the two supported designs before any task/quota creation.
223
 
224
- The default is qualitative research. If the user explicitly selects one of
225
- the two supported production designs, that selection is authoritative.
226
- Title, Keywords, and Research Materials never alter the chosen design.
227
  """
228
  resolution = resolve_method_family(title, keywords, materials=materials)
229
- family = "qualitative"
230
 
231
- confirmed = str(confirmed_method_family or "").strip().casefold()
232
  if confirmed in METHOD_FAMILIES:
233
  family = confirmed
234
  capability = method_capability(family)
@@ -244,12 +333,27 @@ def resolve_generation_preflight(
244
  capability = method_capability(family)
245
  lane = str(capability.get("legacy_policy_lane") or "").strip().casefold() or None
246
  status = str(capability.get("capability_status") or "").strip().casefold()
247
- if family not in METHOD_FAMILIES or not lane or status != "production_supported":
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
  return {
249
  "can_start_generation": False,
250
  "block_reason": "method_resolution_invalid",
251
  "resolution": resolution,
252
- "method_family": family or "qualitative",
253
  "legacy_policy_lane": None,
254
  "capability": capability,
255
  }
 
4
  Production research design is intentionally limited to two supported paths:
5
  qualitative research and survey-based quantitative research. New tasks default to
6
  qualitative research, and the user may explicitly adjust the design before task
7
+ creation. Title/Keywords edits may reset an unlaunched design to the configured
8
+ default through deterministic reconciliation; materials never select a design.
9
 
10
  This module never selects providers/models and never rewrites Title/Keywords.
11
  """
 
15
  import re
16
  from typing import Any, Iterable
17
 
18
+ METHOD_RESOLVER_VERSION = "smartpaper-manual-method-selector-v2-single-authority"
19
+ METHOD_CAPABILITY_REGISTRY_VERSION = "smartpaper-method-capability-registry-v4"
20
+ DEFAULT_METHOD_FAMILY = "qualitative"
21
 
22
  # Reader-visible / task-owning method families. Order is intentional:
23
  # qualitative is the product default and appears first in the selector.
 
32
  "capability_status": "production_supported",
33
  "evidence_timing": "pre_literature",
34
  "engine_family": "qualitative_engine",
35
+ "template_profile": "qualitative_materials",
36
+ "material_profile": "qualitative_materials",
37
+ "material_routing": {
38
+ "structured": "data",
39
+ "text": "interview",
40
+ "document_reference": "reference",
41
+ "document_research": "interview",
42
+ },
43
  },
44
  "survey_path": {
45
  "legacy_policy_lane": "amj",
46
  "capability_status": "production_supported",
47
  "evidence_timing": "pre_literature",
48
  "engine_family": "quantitative_engine",
49
+ "template_profile": "survey_quantitative_data",
50
+ "material_profile": "survey_quantitative_data",
51
+ "material_routing": {
52
+ "structured": "data",
53
+ "text": "reference",
54
+ "document_reference": "reference",
55
+ "document_research": "reference",
56
+ },
57
  },
58
  }
59
 
 
156
  return out
157
 
158
 
159
+ def default_method_family() -> str:
160
+ """Return the single configured default research-design family."""
161
+ if DEFAULT_METHOD_FAMILY not in METHOD_FAMILIES:
162
+ raise RuntimeError("default_method_family_not_registered")
163
+ return DEFAULT_METHOD_FAMILY
164
+
165
+
166
+ def normalize_method_family(value: Any, *, fallback_to_default: bool = False) -> str:
167
+ """Normalize a reader-visible method family without inventing new values."""
168
+ family = str(value or "").strip().casefold()
169
+ if family in METHOD_FAMILIES:
170
+ return family
171
+ return default_method_family() if fallback_to_default else ""
172
+
173
+
174
+ def method_family_for_legacy_lane(selected_lane: Any) -> str:
175
+ """Project a legacy AMJ/ASQ lane back to its registered method family."""
176
+ lane = str(selected_lane or "").strip().casefold()
177
+ matches = [
178
+ family for family in METHOD_FAMILIES
179
+ if str((_METHOD_CAPABILITIES.get(family) or {}).get("legacy_policy_lane") or "").strip().casefold() == lane
180
+ ]
181
+ return matches[0] if len(matches) == 1 else ""
182
+
183
+
184
+ def reconcile_method_authority(
185
+ *,
186
+ current_authority: Any,
187
+ current_ui_family: Any,
188
+ previous_ui_family: Any,
189
+ current_intent_signature: Any,
190
+ previous_intent_signature: Any,
191
+ ) -> dict[str, Any]:
192
+ """Deterministically reconcile the one mutable new-task design authority.
193
+
194
+ Explicit Research Design changes win over a simultaneous Title/Keywords
195
+ change. Otherwise, a genuine intent change resets to the configured default.
196
+ Unrelated reruns preserve the existing authority.
197
+ """
198
+ default_family = default_method_family()
199
+ authority = normalize_method_family(current_authority) or default_family
200
+ current_ui = normalize_method_family(current_ui_family) or authority
201
+ previous_ui = normalize_method_family(previous_ui_family) or current_ui
202
+ current_sig = str(current_intent_signature or "").strip()
203
+ previous_sig = str(previous_intent_signature or "").strip()
204
+
205
+ ui_changed = current_ui != previous_ui
206
+ intent_changed = bool(previous_sig) and current_sig != previous_sig
207
+
208
+ if ui_changed:
209
+ chosen = current_ui
210
+ reason = "user_selected_method_family"
211
+ elif intent_changed:
212
+ chosen = default_family
213
+ reason = "intent_changed_reset_to_default"
214
+ else:
215
+ chosen = authority
216
+ reason = "preserve_existing_authority"
217
+
218
+ return {
219
+ "method_family": chosen,
220
+ "reason": reason,
221
+ "ui_changed": ui_changed,
222
+ "intent_changed": intent_changed,
223
+ "default_method_family": default_family,
224
+ }
225
+
226
+
227
  def method_capability(method_family: str) -> dict[str, Any]:
228
  """Return a copy of versioned capability metadata for one supported family."""
229
  family = str(method_family or "").strip().casefold()
 
236
  "capability_status": "ambiguous",
237
  "evidence_timing": "undetermined",
238
  "engine_family": "undetermined",
239
+ "template_profile": None,
240
+ "material_profile": None,
241
+ "material_routing": {},
242
  }
243
  row.update({
244
  "registry_version": METHOD_CAPABILITY_REGISTRY_VERSION,
 
271
  """
272
  _ = title, keywords
273
  descriptors = _material_descriptors(materials)
274
+ family = default_method_family()
275
+ capability = method_capability(family)
276
  return {
277
  "resolver_version": METHOD_RESOLVER_VERSION,
278
  "shadow_only": False,
279
+ "method_family": family,
280
+ "confidence": "DEFAULT_CONFIGURED",
281
+ "reason_codes": ["manual_design_control", "default_method_family"],
282
  "title_keyword_hits": [],
283
  "specialized_method_detected": False,
284
  "specialized_method_families": [],
 
310
  ) -> dict[str, Any]:
311
  """Resolve the two supported designs before any task/quota creation.
312
 
313
+ The configured default is used unless a supported method family is supplied
314
+ by the single UI authority. This function validates/projections capability;
315
+ it does not infer a design from Title, Keywords, or Research Materials.
316
  """
317
  resolution = resolve_method_family(title, keywords, materials=materials)
318
+ family = default_method_family()
319
 
320
+ confirmed = normalize_method_family(confirmed_method_family)
321
  if confirmed in METHOD_FAMILIES:
322
  family = confirmed
323
  capability = method_capability(family)
 
333
  capability = method_capability(family)
334
  lane = str(capability.get("legacy_policy_lane") or "").strip().casefold() or None
335
  status = str(capability.get("capability_status") or "").strip().casefold()
336
+ engine_family = str(capability.get("engine_family") or "").strip()
337
+ template_profile = str(capability.get("template_profile") or "").strip()
338
+ material_profile = str(capability.get("material_profile") or "").strip()
339
+ material_routing = dict(capability.get("material_routing") or {})
340
+ routing_keys = {"structured", "text", "document_reference", "document_research"}
341
+ routing_values = {str(value or "").strip() for value in material_routing.values()}
342
+ if (
343
+ family not in METHOD_FAMILIES
344
+ or not lane
345
+ or status != "production_supported"
346
+ or not engine_family
347
+ or not template_profile
348
+ or not material_profile
349
+ or set(material_routing) != routing_keys
350
+ or not routing_values.issubset({"reference", "data", "interview"})
351
+ ):
352
  return {
353
  "can_start_generation": False,
354
  "block_reason": "method_resolution_invalid",
355
  "resolution": resolution,
356
+ "method_family": family or default_method_family(),
357
  "legacy_policy_lane": None,
358
  "capability": capability,
359
  }
i18n.py CHANGED
@@ -1228,20 +1228,31 @@ def get_asq_txt_template(language: str) -> str:
1228
  return ASQ_TXT_TEMPLATES[normalize_language(language)]
1229
 
1230
 
1231
- def get_research_material_template(language: str, method_family: str) -> dict[str, str]:
1232
- """Return a template for one of the two supported production designs."""
1233
- family = str(method_family or "").strip().casefold()
1234
- if family == "survey_path":
 
 
 
 
1235
  return {
1236
  "content": get_amj_csv_template(language),
1237
  "code_language": "csv",
1238
  "filename": "Survey_Path_Data_Template.csv",
1239
  "mime_type": "text/csv",
1240
  }
 
 
 
 
 
 
 
1241
  return {
1242
- "content": get_asq_txt_template(language),
1243
  "code_language": "text",
1244
- "filename": "Qualitative_Research_Materials_Template.txt",
1245
  "mime_type": "text/plain",
1246
  }
1247
 
 
1228
  return ASQ_TXT_TEMPLATES[normalize_language(language)]
1229
 
1230
 
1231
+ def get_research_material_template(language: str, template_profile: str) -> dict[str, str]:
1232
+ """Return a strict template projection for a registered capability profile.
1233
+
1234
+ Unknown profiles return an empty projection rather than silently falling back
1235
+ to qualitative research. The method registry owns method→profile mapping.
1236
+ """
1237
+ profile = str(template_profile or "").strip().casefold()
1238
+ if profile == "survey_quantitative_data":
1239
  return {
1240
  "content": get_amj_csv_template(language),
1241
  "code_language": "csv",
1242
  "filename": "Survey_Path_Data_Template.csv",
1243
  "mime_type": "text/csv",
1244
  }
1245
+ if profile == "qualitative_materials":
1246
+ return {
1247
+ "content": get_asq_txt_template(language),
1248
+ "code_language": "text",
1249
+ "filename": "Qualitative_Research_Materials_Template.txt",
1250
+ "mime_type": "text/plain",
1251
+ }
1252
  return {
1253
+ "content": "",
1254
  "code_language": "text",
1255
+ "filename": "",
1256
  "mime_type": "text/plain",
1257
  }
1258