Spaces:
Sleeping
Sleeping
P0-R5-R3-D single authority registry projection
#142
by GuXiaopeng - opened
- app.py +150 -87
- core/advanced_method_registry.py +119 -15
- 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,
|
| 38 |
-
|
|
|
|
| 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 |
-
"
|
| 308 |
-
"
|
| 309 |
-
"
|
| 310 |
-
"
|
| 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
|
| 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
|
| 508 |
-
"""
|
|
|
|
| 509 |
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 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": "
|
| 518 |
-
"topic":
|
| 519 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 527 |
-
"""
|
| 528 |
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
|
|
|
| 532 |
"""
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
st.session_state
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 539 |
|
| 540 |
|
| 541 |
def _current_method_preflight(*, confirmed_method_family: str | None = None) -> tuple[dict, str]:
|
|
|
|
| 542 |
signature = _method_intent_signature()
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 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 |
-
|
| 584 |
-
|
| 585 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 586 |
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 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
|
| 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 |
-
#
|
| 805 |
-
#
|
| 806 |
-
|
|
|
|
|
|
|
| 807 |
preflight, current_signature = _current_method_preflight(
|
| 808 |
-
confirmed_method_family=pending_family
|
| 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 |
-
#
|
| 1551 |
-
#
|
| 1552 |
-
|
| 1553 |
-
|
| 1554 |
-
|
| 1555 |
-
|
| 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="
|
| 1568 |
disabled=active,
|
| 1569 |
)
|
| 1570 |
-
|
| 1571 |
-
|
| 1572 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
#
|
| 1694 |
-
#
|
|
|
|
|
|
|
|
|
|
| 1695 |
click_preflight, click_signature = _current_method_preflight(
|
| 1696 |
-
confirmed_method_family=
|
| 1697 |
)
|
| 1698 |
-
if
|
| 1699 |
-
|
|
|
|
|
|
|
|
|
|
| 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":
|
| 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
|
| 8 |
-
|
| 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-
|
| 19 |
-
METHOD_CAPABILITY_REGISTRY_VERSION = "smartpaper-method-capability-registry-
|
|
|
|
| 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 |
-
|
|
|
|
| 187 |
return {
|
| 188 |
"resolver_version": METHOD_RESOLVER_VERSION,
|
| 189 |
"shadow_only": False,
|
| 190 |
-
"method_family":
|
| 191 |
-
"confidence": "
|
| 192 |
-
"reason_codes": ["manual_design_control", "
|
| 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
|
| 225 |
-
the
|
| 226 |
-
Title, Keywords,
|
| 227 |
"""
|
| 228 |
resolution = resolve_method_family(title, keywords, materials=materials)
|
| 229 |
-
family =
|
| 230 |
|
| 231 |
-
confirmed =
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
return {
|
| 249 |
"can_start_generation": False,
|
| 250 |
"block_reason": "method_resolution_invalid",
|
| 251 |
"resolution": resolution,
|
| 252 |
-
"method_family": family or
|
| 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,
|
| 1232 |
-
"""Return a template for
|
| 1233 |
-
|
| 1234 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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":
|
| 1243 |
"code_language": "text",
|
| 1244 |
-
"filename": "
|
| 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 |
|