R14-TQ2-PQ1-P0-R1 Study Integrity Terminal UI Mode Decoupling

#69
app.py CHANGED
@@ -264,7 +264,7 @@ for key, default in {
264
  "is_running": False,
265
  "zip_buffer": None,
266
  "zip_filename": "",
267
- "run_mode": "amj",
268
  "sp_generation_task": None,
269
  "sp_pending_launch": None,
270
  "sp_pending_resume_task_id": "",
@@ -412,8 +412,8 @@ def _cleanup_task_artifacts(task_id: str) -> None:
412
 
413
 
414
  def _task_mode(task: dict) -> str:
415
- mode = str((task or {}).get("mode") or st.session_state.get("run_mode") or "amj").strip().lower()
416
- return "asq" if mode == "asq" else "amj"
417
 
418
 
419
  def _task_output_path(task: dict) -> Path | None:
@@ -740,6 +740,19 @@ def _localized_monitor_node(lang_code: str, node: str) -> str:
740
  return tr(lang_code, "draft_stage_writing")
741
  return tr(lang_code, "draft_stage_processing")
742
 
 
 
 
 
 
 
 
 
 
 
 
 
 
743
  def _render_monitor_box(task: dict, lang_code: str) -> None:
744
  """Render the approved monitor location with DOM targets for live updates."""
745
  status = str(task.get("status") or "")
@@ -764,7 +777,7 @@ def _render_monitor_box(task: dict, lang_code: str) -> None:
764
  </div>
765
  """).strip()
766
 
767
- st.markdown(textwrap.dedent(f"""
768
  <div class='log-box' id='sp-live-monitor'>
769
  {progress_markup}
770
  <div><b>{tr(lang_code, 'monitor_pipeline')}:</b> <span id='sp-live-status'>{html.escape(status_label)}</span></div>
@@ -781,7 +794,7 @@ def _render_monitor_box(task: dict, lang_code: str) -> None:
781
  <span style='background: #d1fae5;padding:4px 8px;border-radius:4px;color:black;'>🌐 OpenAlex: <b id='sp-live-oa'>{oa_calls}</b></span>
782
  </div>
783
  </div>
784
- """).strip(), unsafe_allow_html=True)
785
 
786
 
787
  def _watch_task_status_live(task: dict) -> dict | None:
@@ -996,50 +1009,74 @@ def _render_checkpoint_preview(task: dict, lang_code: str) -> None:
996
  with left:
997
  st.markdown(tr(lang_code, "english_draft"))
998
  st.markdown(
999
- textwrap.dedent(f"""
1000
  <div id='sp-live-manuscript-shell' style='border:1px solid #e5e7eb;border-radius:8px;padding:14px 16px;min-height:220px;max-height:68vh;overflow:auto;background:#ffffff;color:#111827;'>
1001
  <div id='sp-live-manuscript-status' style='white-space:pre-wrap;font-size:0.88em;color:#374151;opacity:1;margin:0 0 8px 0;display:{english_status_display};'>{html.escape(english_status_initial)}</div>
1002
  <div id='sp-live-manuscript-preview' data-sp-placeholder='{english_placeholder}' style='white-space:pre-wrap;overflow-wrap:anywhere;margin:0;font-family:inherit;line-height:1.72;color:#111827;'>{html.escape(english_initial)}</div>
1003
  <span id='sp-live-manuscript-cursor' aria-hidden='true' style='display:inline-block;width:7px;height:1.05em;background:#111827;margin-left:2px;vertical-align:-0.12em;animation:spBlink 1s steps(1,end) infinite;'></span>
1004
  </div>
1005
- """).strip(),
1006
  unsafe_allow_html=True,
1007
  )
1008
  with right:
1009
  st.markdown(tr(lang_code, "target_draft", flag=get_target_flag(lang_code)))
1010
  st.markdown(
1011
- textwrap.dedent(f"""
1012
  <div id='sp-live-translation-shell' style='border:1px solid #e5e7eb;border-radius:8px;padding:14px 16px;min-height:220px;max-height:68vh;overflow:auto;background:#ffffff;color:#111827;'>
1013
  <div id='sp-live-translation-preview' data-sp-placeholder='{target_placeholder}' style='white-space:pre-wrap;overflow-wrap:anywhere;margin:0;font-family:inherit;line-height:1.72;color:#111827;'>{html.escape(target_initial)}</div>
1014
  <span id='sp-live-translation-cursor' aria-hidden='true' style='display:inline-block;width:7px;height:1.05em;background:#111827;margin-left:2px;vertical-align:-0.12em;animation:spBlink 1s steps(1,end) infinite;'></span>
1015
  </div>
1016
- """).strip(),
1017
  unsafe_allow_html=True,
1018
  )
1019
  st.markdown("<style>@keyframes spBlink { 0%,49% {opacity:1} 50%,100% {opacity:0} }</style>", unsafe_allow_html=True)
1020
 
1021
 
1022
  def _scroll_to_completed_download_once(task_id: str) -> None:
1023
- """Move the page to the existing download area once per completed task."""
 
 
 
 
 
1024
  task_id = str(task_id or "").strip()
1025
- if not task_id or st.session_state.get("sp_completed_download_scroll_task_id") == task_id:
1026
  return
1027
- st.session_state["sp_completed_download_scroll_task_id"] = task_id
1028
  components.html(
1029
  f"""
1030
  <script>
1031
  (() => {{
1032
- const parentDocument = window.parent.document;
 
1033
  const expectedTaskId = {json.dumps(task_id)};
 
 
 
 
 
 
1034
  let done = false;
 
1035
  const locate = () => {{
1036
- if (done) return;
1037
  const target = parentDocument.getElementById('sp-completed-download-anchor');
1038
- if (!target || target.dataset.spTaskId !== expectedTaskId) return;
1039
  done = true;
1040
  target.scrollIntoView({{behavior:'smooth', block:'center'}});
 
 
 
 
1041
  }};
1042
- [0, 60, 140, 300, 650, 1000].forEach(delay => window.setTimeout(locate, delay));
 
 
 
 
 
 
 
 
1043
  }})();
1044
  </script>
1045
  """,
@@ -1153,7 +1190,7 @@ def _scroll_to_generation_action_once() -> None:
1153
  if not bool(st.session_state.get("sp_scroll_to_generation_action")):
1154
  return
1155
  st.session_state["sp_scroll_to_generation_action"] = False
1156
- mode = str(st.session_state.get("run_mode") or "amj")
1157
  fallback_id = "sp-generation-action-asq" if mode.lower() == "asq" else "sp-generation-action-amj"
1158
  components.html(
1159
  f"""
@@ -1188,10 +1225,10 @@ def _select_active_task_tab(task: dict, lang_code: str) -> None:
1188
  """Keep the existing visual tab aligned with the immutable task mode after rerun/refresh."""
1189
  status = str((task or {}).get("status") or "")
1190
  mode = str((task or {}).get("mode") or "").strip().lower()
1191
- if status not in ACTIVE_TASK_STATUSES and status != "resumable":
1192
- return
1193
  if mode not in {"amj", "asq"}:
1194
  return
 
 
1195
  label = tr(lang_code, "tab_asq" if mode == "asq" else "tab_amj")
1196
  components.html(
1197
  f"""
@@ -1272,29 +1309,7 @@ def render_workspace_controls(user):
1272
  key="sp_words",
1273
  )
1274
 
1275
- tab_amj, tab_asq = st.tabs([tr(lang_code, "tab_amj"), tr(lang_code, "tab_asq")])
1276
- with tab_amj:
1277
- st.markdown(tr(lang_code, "amj_config"))
1278
- st.info(tr(lang_code, "amj_info"))
1279
- st.file_uploader(tr(lang_code, "upload_reference"), type=["pdf", "doc", "docx"], accept_multiple_files=True, key="up_amj_ref", disabled=active)
1280
- st.file_uploader(tr(lang_code, "upload_stats"), type=["csv", "xlsx", "xls"], key="up_amj_data", disabled=active)
1281
- with st.expander(tr(lang_code, "amj_expander")):
1282
- st.markdown(tr(lang_code, "amj_hint"))
1283
- template = get_amj_csv_template(lang_code)
1284
- st.code(template, language="csv")
1285
- 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)
1286
- st.markdown("<div id='sp-generation-action-amj'></div>", unsafe_allow_html=True)
1287
- if active and _task_mode(task) == "amj":
1288
- if st.button(tr(lang_code, "cancel_generation"), key="cancel_amj"):
1289
- st.session_state["sp_pending_cancel_task_id"] = str(task.get("task_id") or "")
1290
- st.rerun()
1291
- elif st.button(tr(lang_code, "start_amj"), disabled=active, key="btn_amj"):
1292
- st.session_state["run_mode"] = "amj"
1293
- st.session_state["sp_pending_launch"] = {"mode": "amj", "idempotency_key": _new_generation_idempotency_key()}
1294
- st.session_state["sp_scroll_to_generation_action"] = True
1295
- st.info(tr(lang_code, "pipeline_start"))
1296
- _inject_generation_action_scroll("amj")
1297
-
1298
  with tab_asq:
1299
  st.markdown(tr(lang_code, "asq_config"))
1300
  st.info(tr(lang_code, "asq_info"))
@@ -1317,6 +1332,27 @@ def render_workspace_controls(user):
1317
  st.info(tr(lang_code, "pipeline_start"))
1318
  _inject_generation_action_scroll("asq")
1319
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1320
  _select_active_task_tab(task, lang_code)
1321
  inject_file_uploader_i18n(lang_code)
1322
 
 
264
  "is_running": False,
265
  "zip_buffer": None,
266
  "zip_filename": "",
267
+ "run_mode": "asq",
268
  "sp_generation_task": None,
269
  "sp_pending_launch": None,
270
  "sp_pending_resume_task_id": "",
 
412
 
413
 
414
  def _task_mode(task: dict) -> str:
415
+ mode = str((task or {}).get("mode") or st.session_state.get("run_mode") or "asq").strip().lower()
416
+ return "amj" if mode == "amj" else "asq"
417
 
418
 
419
  def _task_output_path(task: dict) -> Path | None:
 
740
  return tr(lang_code, "draft_stage_writing")
741
  return tr(lang_code, "draft_stage_processing")
742
 
743
+ def _markdown_safe_html(markup: str) -> str:
744
+ """Prevent Streamlit Markdown from reclassifying indented HTML as code.
745
+
746
+ Only indentation before actual HTML tags is removed. Text rendered inside
747
+ preview containers keeps its own leading whitespace.
748
+ """
749
+ lines = []
750
+ for line in textwrap.dedent(str(markup or "")).strip().splitlines():
751
+ stripped = line.lstrip()
752
+ lines.append(stripped if stripped.startswith("<") else line)
753
+ return "\n".join(lines)
754
+
755
+
756
  def _render_monitor_box(task: dict, lang_code: str) -> None:
757
  """Render the approved monitor location with DOM targets for live updates."""
758
  status = str(task.get("status") or "")
 
777
  </div>
778
  """).strip()
779
 
780
+ st.markdown(_markdown_safe_html(f"""
781
  <div class='log-box' id='sp-live-monitor'>
782
  {progress_markup}
783
  <div><b>{tr(lang_code, 'monitor_pipeline')}:</b> <span id='sp-live-status'>{html.escape(status_label)}</span></div>
 
794
  <span style='background: #d1fae5;padding:4px 8px;border-radius:4px;color:black;'>🌐 OpenAlex: <b id='sp-live-oa'>{oa_calls}</b></span>
795
  </div>
796
  </div>
797
+ """), unsafe_allow_html=True)
798
 
799
 
800
  def _watch_task_status_live(task: dict) -> dict | None:
 
1009
  with left:
1010
  st.markdown(tr(lang_code, "english_draft"))
1011
  st.markdown(
1012
+ _markdown_safe_html(f"""
1013
  <div id='sp-live-manuscript-shell' style='border:1px solid #e5e7eb;border-radius:8px;padding:14px 16px;min-height:220px;max-height:68vh;overflow:auto;background:#ffffff;color:#111827;'>
1014
  <div id='sp-live-manuscript-status' style='white-space:pre-wrap;font-size:0.88em;color:#374151;opacity:1;margin:0 0 8px 0;display:{english_status_display};'>{html.escape(english_status_initial)}</div>
1015
  <div id='sp-live-manuscript-preview' data-sp-placeholder='{english_placeholder}' style='white-space:pre-wrap;overflow-wrap:anywhere;margin:0;font-family:inherit;line-height:1.72;color:#111827;'>{html.escape(english_initial)}</div>
1016
  <span id='sp-live-manuscript-cursor' aria-hidden='true' style='display:inline-block;width:7px;height:1.05em;background:#111827;margin-left:2px;vertical-align:-0.12em;animation:spBlink 1s steps(1,end) infinite;'></span>
1017
  </div>
1018
+ """),
1019
  unsafe_allow_html=True,
1020
  )
1021
  with right:
1022
  st.markdown(tr(lang_code, "target_draft", flag=get_target_flag(lang_code)))
1023
  st.markdown(
1024
+ _markdown_safe_html(f"""
1025
  <div id='sp-live-translation-shell' style='border:1px solid #e5e7eb;border-radius:8px;padding:14px 16px;min-height:220px;max-height:68vh;overflow:auto;background:#ffffff;color:#111827;'>
1026
  <div id='sp-live-translation-preview' data-sp-placeholder='{target_placeholder}' style='white-space:pre-wrap;overflow-wrap:anywhere;margin:0;font-family:inherit;line-height:1.72;color:#111827;'>{html.escape(target_initial)}</div>
1027
  <span id='sp-live-translation-cursor' aria-hidden='true' style='display:inline-block;width:7px;height:1.05em;background:#111827;margin-left:2px;vertical-align:-0.12em;animation:spBlink 1s steps(1,end) infinite;'></span>
1028
  </div>
1029
+ """),
1030
  unsafe_allow_html=True,
1031
  )
1032
  st.markdown("<style>@keyframes spBlink { 0%,49% {opacity:1} 50%,100% {opacity:0} }</style>", unsafe_allow_html=True)
1033
 
1034
 
1035
  def _scroll_to_completed_download_once(task_id: str) -> None:
1036
+ """Scroll only after the task-scoped download anchor actually exists.
1037
+
1038
+ Success identity is browser-session scoped and is written only after
1039
+ scrollIntoView executes. A missed early attempt therefore remains retryable
1040
+ on later Streamlit reruns.
1041
+ """
1042
  task_id = str(task_id or "").strip()
1043
+ if not task_id:
1044
  return
 
1045
  components.html(
1046
  f"""
1047
  <script>
1048
  (() => {{
1049
+ const parentWindow = window.parent;
1050
+ const parentDocument = parentWindow.document;
1051
  const expectedTaskId = {json.dumps(task_id)};
1052
+ const storageKey = `sp-completed-download-scrolled:${{expectedTaskId}}`;
1053
+ const root = parentDocument.documentElement;
1054
+ if (root && root.dataset.spCompletedDownloadScrolled === expectedTaskId) return;
1055
+ try {{
1056
+ if (parentWindow.sessionStorage.getItem(storageKey) === '1') return;
1057
+ }} catch (_) {{}}
1058
  let done = false;
1059
+ let observer = null;
1060
  const locate = () => {{
1061
+ if (done) return true;
1062
  const target = parentDocument.getElementById('sp-completed-download-anchor');
1063
+ if (!target || target.dataset.spTaskId !== expectedTaskId) return false;
1064
  done = true;
1065
  target.scrollIntoView({{behavior:'smooth', block:'center'}});
1066
+ if (root) root.dataset.spCompletedDownloadScrolled = expectedTaskId;
1067
+ try {{ parentWindow.sessionStorage.setItem(storageKey, '1'); }} catch (_) {{}}
1068
+ if (observer) observer.disconnect();
1069
+ return true;
1070
  }};
1071
+ if (locate()) return;
1072
+ if (parentDocument.body && parentWindow.MutationObserver) {{
1073
+ observer = new parentWindow.MutationObserver(locate);
1074
+ observer.observe(parentDocument.body, {{childList:true, subtree:true}});
1075
+ }}
1076
+ [80, 180, 360, 700, 1200, 2000, 3200, 4800].forEach((delay) =>
1077
+ parentWindow.setTimeout(locate, delay)
1078
+ );
1079
+ parentWindow.setTimeout(() => {{ if (observer) observer.disconnect(); }}, 5200);
1080
  }})();
1081
  </script>
1082
  """,
 
1190
  if not bool(st.session_state.get("sp_scroll_to_generation_action")):
1191
  return
1192
  st.session_state["sp_scroll_to_generation_action"] = False
1193
+ mode = str(st.session_state.get("run_mode") or "asq")
1194
  fallback_id = "sp-generation-action-asq" if mode.lower() == "asq" else "sp-generation-action-amj"
1195
  components.html(
1196
  f"""
 
1225
  """Keep the existing visual tab aligned with the immutable task mode after rerun/refresh."""
1226
  status = str((task or {}).get("status") or "")
1227
  mode = str((task or {}).get("mode") or "").strip().lower()
 
 
1228
  if mode not in {"amj", "asq"}:
1229
  return
1230
+ if status not in (ACTIVE_TASK_STATUSES | {"resumable", "completed", "failed", "cancelled"}):
1231
+ return
1232
  label = tr(lang_code, "tab_asq" if mode == "asq" else "tab_amj")
1233
  components.html(
1234
  f"""
 
1309
  key="sp_words",
1310
  )
1311
 
1312
+ tab_asq, tab_amj = st.tabs([tr(lang_code, "tab_asq"), tr(lang_code, "tab_amj")])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1313
  with tab_asq:
1314
  st.markdown(tr(lang_code, "asq_config"))
1315
  st.info(tr(lang_code, "asq_info"))
 
1332
  st.info(tr(lang_code, "pipeline_start"))
1333
  _inject_generation_action_scroll("asq")
1334
 
1335
+ with tab_amj:
1336
+ st.markdown(tr(lang_code, "amj_config"))
1337
+ st.info(tr(lang_code, "amj_info"))
1338
+ st.file_uploader(tr(lang_code, "upload_reference"), type=["pdf", "doc", "docx"], accept_multiple_files=True, key="up_amj_ref", disabled=active)
1339
+ st.file_uploader(tr(lang_code, "upload_stats"), type=["csv", "xlsx", "xls"], key="up_amj_data", disabled=active)
1340
+ with st.expander(tr(lang_code, "amj_expander")):
1341
+ st.markdown(tr(lang_code, "amj_hint"))
1342
+ template = get_amj_csv_template(lang_code)
1343
+ st.code(template, language="csv")
1344
+ 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)
1345
+ st.markdown("<div id='sp-generation-action-amj'></div>", unsafe_allow_html=True)
1346
+ if active and _task_mode(task) == "amj":
1347
+ if st.button(tr(lang_code, "cancel_generation"), key="cancel_amj"):
1348
+ st.session_state["sp_pending_cancel_task_id"] = str(task.get("task_id") or "")
1349
+ st.rerun()
1350
+ elif st.button(tr(lang_code, "start_amj"), disabled=active, key="btn_amj"):
1351
+ st.session_state["run_mode"] = "amj"
1352
+ st.session_state["sp_pending_launch"] = {"mode": "amj", "idempotency_key": _new_generation_idempotency_key()}
1353
+ st.session_state["sp_scroll_to_generation_action"] = True
1354
+ st.info(tr(lang_code, "pipeline_start"))
1355
+ _inject_generation_action_scroll("amj")
1356
  _select_active_task_tab(task, lang_code)
1357
  inject_file_uploader_i18n(lang_code)
1358
 
config.py CHANGED
@@ -378,7 +378,7 @@ def load_config():
378
  "asq": {
379
  "introduction": "Open with the organizational puzzle, establish why existing explanations are insufficient, state the research question, and preview the emergent theoretical payoff without pre-solving the puzzle.",
380
  "theoretical_framing": "Use prior theory as sensitizing concepts and a source of tension. Define what remains unexplained without forcing hypotheses or predicting the findings.",
381
- "methods": "Describe the frozen setting, data, analytic design, trustworthiness, ethics, and reflexivity. The analytic method must match the selected qualitative design rather than default to Gioia.",
382
  "findings": "Move from concrete episode, quotation, or observation to recurring pattern, temporal/process relationship, and emergent mechanism. Keep informant or field evidence central and include deviant evidence when available.",
383
  "discussion": "Explain how the emergent mechanism changes, qualifies, or extends prior theory. Distinguish what is grounded in this setting from what may transfer beyond it.",
384
  "conclusion": "Resolve the organizational puzzle and state the bounded theoretical contribution without introducing new data, quotations, or claims.",
@@ -480,7 +480,7 @@ Literature Metadata:
480
  "deep_refinement_directive": "Ensure logical flow and focus on the emergent theoretical model. Write in continuous, engaging paragraphs. Apply claim-specific citation: use at most one [CITE-XX] tag per sentence, never combine sources in one parenthesis, and cite only the single most direct source when several sources support the same claim.",
481
  "deep_synthesis_directive": "Structure this synthesis to highlight a critical empirical puzzle or theoretical paradox that qualitative investigation can unravel.",
482
  "hypothesis_development_directive": "Develop the theoretical framing. DO NOT use formal hypotheses. Detail the conceptual gaps and frame the overarching research questions or theoretical propositions.",
483
- "methodological_defense_directive": "Describe only the frozen research setting and data sources. Explain the analytic design actually selected for this study (for example process analysis, comparative case logic, grounded-theory/Gioia-style coding when appropriate) and its trustworthiness procedures without forcing one method onto every qualitative study.",
484
  "discussion_depth_directive": "Connect the emergent mechanism or process to prior theory. Show precisely what the evidence changes, qualifies, or newly explains, including negative or deviant evidence when present. Do not force a process-model contrast when the selected qualitative design does not support it.",
485
  "conclusion_anchor_directive": "Derive the conclusion only after validated Findings and Discussion. Resolve the organizational puzzle and state the bounded theoretical change without introducing new evidence or forcing sensemaking language.",
486
  "anchor_generation_directive": "Generate a PROVISIONAL RESEARCH LOGIC CONTRACT, not an expected finding or conclusion. Identify the organizational puzzle, sensitizing theoretical tension, evidence boundaries, and research question while leaving the empirical resolution open. Output only the contract.",
 
378
  "asq": {
379
  "introduction": "Open with the organizational puzzle, establish why existing explanations are insufficient, state the research question, and preview the emergent theoretical payoff without pre-solving the puzzle.",
380
  "theoretical_framing": "Use prior theory as sensitizing concepts and a source of tension. Define what remains unexplained without forcing hypotheses or predicting the findings.",
381
+ "methods": "Describe the frozen setting, data, and analytic design. Describe trustworthiness, ethics, or reflexivity procedures only when those procedures are explicitly present in the frozen Study Evidence Ledger; never infer or invent member checking, peer debriefing, audit trails, field journals, consent/approval procedures, positionality, or other study-owned procedures. The analytic method must match the selected qualitative design rather than default to Gioia.",
382
  "findings": "Move from concrete episode, quotation, or observation to recurring pattern, temporal/process relationship, and emergent mechanism. Keep informant or field evidence central and include deviant evidence when available.",
383
  "discussion": "Explain how the emergent mechanism changes, qualifies, or extends prior theory. Distinguish what is grounded in this setting from what may transfer beyond it.",
384
  "conclusion": "Resolve the organizational puzzle and state the bounded theoretical contribution without introducing new data, quotations, or claims.",
 
480
  "deep_refinement_directive": "Ensure logical flow and focus on the emergent theoretical model. Write in continuous, engaging paragraphs. Apply claim-specific citation: use at most one [CITE-XX] tag per sentence, never combine sources in one parenthesis, and cite only the single most direct source when several sources support the same claim.",
481
  "deep_synthesis_directive": "Structure this synthesis to highlight a critical empirical puzzle or theoretical paradox that qualitative investigation can unravel.",
482
  "hypothesis_development_directive": "Develop the theoretical framing. DO NOT use formal hypotheses. Detail the conceptual gaps and frame the overarching research questions or theoretical propositions.",
483
+ "methodological_defense_directive": "Describe only the frozen research setting, actor/data-source relations, data, analytic design, and explicitly frozen study procedures. Explain the analytic design actually selected for this study (for example process analysis, comparative case logic, grounded-theory/Gioia-style coding when appropriate). Discuss trustworthiness, ethics, or reflexivity as procedures actually performed only when the frozen Study Evidence Ledger explicitly records them; otherwise do not manufacture such procedures or convert general methodological best practice into a study-owned fact.",
484
  "discussion_depth_directive": "Connect the emergent mechanism or process to prior theory. Show precisely what the evidence changes, qualifies, or newly explains, including negative or deviant evidence when present. Do not force a process-model contrast when the selected qualitative design does not support it.",
485
  "conclusion_anchor_directive": "Derive the conclusion only after validated Findings and Discussion. Resolve the organizational puzzle and state the bounded theoretical change without introducing new evidence or forcing sensemaking language.",
486
  "anchor_generation_directive": "Generate a PROVISIONAL RESEARCH LOGIC CONTRACT, not an expected finding or conclusion. Identify the organizational puzzle, sensitizing theoretical tension, evidence boundaries, and research question while leaving the empirical resolution open. Output only the contract.",
core/generation_runner.py CHANGED
@@ -2667,10 +2667,55 @@ Output only final academic prose. Do not report the word count."""
2667
  data_metadata=data_metadata,
2668
  quote_invocation_plan=quote_invocation_plan,
2669
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2670
  en_resolved, duplicate_rows = self._remove_severe_cross_unit_duplicates(en_resolved, full_draft_en)
2671
  if duplicate_rows:
2672
  logging.warning("Severe cross-unit redundancy removed | unit=%s count=%s max_similarity=%s", display_name, len(duplicate_rows), max(row["similarity"] for row in duplicate_rows))
2673
  quality_diagnostics.append({"scope":"manuscript_redundancy","name":display_name,"status":"bounded_duplicate_removed","count":len(duplicate_rows),"examples":duplicate_rows[:3]})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2674
  full_draft_en += en_resolved + "\n\n"
2675
  if assigned_tag_list:
2676
  logging.info(
@@ -2732,6 +2777,19 @@ Output only final academic prose. Do not report the word count."""
2732
  if not state.get("english_finalized"):
2733
  self._progress(91, "english_validation")
2734
  full_draft_en = _clean_trailing_references(full_draft_en, ["Reference", "References", "Reference List"])
 
 
 
 
 
 
 
 
 
 
 
 
 
2735
  # Validate the completed body while the Working Abstract is still in
2736
  # place. Final front matter must not be generated until all body
2737
  # sections, citation cleanup, and any Discussion rebalance are frozen.
 
2667
  data_metadata=data_metadata,
2668
  quote_invocation_plan=quote_invocation_plan,
2669
  )
2670
+ if mode == "asq":
2671
+ en_resolved, study_fact_rows = agent.repair_qualitative_study_fact_contract(
2672
+ en_resolved, data_metadata, section_name=display_name
2673
+ )
2674
+ if study_fact_rows:
2675
+ logging.warning(
2676
+ "Qualitative Study Evidence Ledger repair | unit=%s count=%s types=%s",
2677
+ display_name, len(study_fact_rows),
2678
+ sorted({str(row.get("type") or "") for row in study_fact_rows}),
2679
+ )
2680
+ quality_diagnostics.append({
2681
+ "scope":"qualitative_study_fact_integrity", "name":display_name,
2682
+ "status":"deterministic_repair", "count":len(study_fact_rows),
2683
+ "examples":study_fact_rows[:3],
2684
+ })
2685
+ agent.validate_qualitative_study_fact_contract(
2686
+ en_resolved, data_metadata, section_name=display_name
2687
+ )
2688
+ en_resolved, enumeration_rows = agent.repair_and_validate_enumeration_contract(
2689
+ en_resolved, section_name=display_name
2690
+ )
2691
+ if enumeration_rows:
2692
+ logging.warning(
2693
+ "Structural enumeration repaired | unit=%s count=%s details=%s",
2694
+ display_name, len(enumeration_rows), enumeration_rows[:3],
2695
+ )
2696
+ quality_diagnostics.append({
2697
+ "scope":"structural_enumeration", "name":display_name,
2698
+ "status":"deterministic_repair", "count":len(enumeration_rows),
2699
+ "examples":enumeration_rows[:3],
2700
+ })
2701
  en_resolved, duplicate_rows = self._remove_severe_cross_unit_duplicates(en_resolved, full_draft_en)
2702
  if duplicate_rows:
2703
  logging.warning("Severe cross-unit redundancy removed | unit=%s count=%s max_similarity=%s", display_name, len(duplicate_rows), max(row["similarity"] for row in duplicate_rows))
2704
  quality_diagnostics.append({"scope":"manuscript_redundancy","name":display_name,"status":"bounded_duplicate_removed","count":len(duplicate_rows),"examples":duplicate_rows[:3]})
2705
+ en_resolved, sentence_echo_rows = agent.remove_severe_cross_unit_sentence_echoes(
2706
+ en_resolved, full_draft_en
2707
+ )
2708
+ if sentence_echo_rows:
2709
+ logging.warning(
2710
+ "Severe cross-unit sentence echo removed | unit=%s count=%s max_similarity=%s",
2711
+ display_name, len(sentence_echo_rows),
2712
+ max(row["similarity"] for row in sentence_echo_rows),
2713
+ )
2714
+ quality_diagnostics.append({
2715
+ "scope":"manuscript_sentence_echo", "name":display_name,
2716
+ "status":"bounded_duplicate_removed", "count":len(sentence_echo_rows),
2717
+ "examples":sentence_echo_rows[:3],
2718
+ })
2719
  full_draft_en += en_resolved + "\n\n"
2720
  if assigned_tag_list:
2721
  logging.info(
 
2777
  if not state.get("english_finalized"):
2778
  self._progress(91, "english_validation")
2779
  full_draft_en = _clean_trailing_references(full_draft_en, ["Reference", "References", "Reference List"])
2780
+ if mode == "asq":
2781
+ fact_checked, fact_rows = agent.repair_qualitative_study_fact_contract(
2782
+ full_draft_en, data_metadata, section_name="Final English manuscript"
2783
+ )
2784
+ if fact_checked.strip() != full_draft_en.strip() or fact_rows:
2785
+ raise RuntimeError(
2786
+ "final_qualitative_study_fact_contract_requires_section_regeneration"
2787
+ )
2788
+ enum_checked, enum_rows = agent.repair_and_validate_enumeration_contract(
2789
+ full_draft_en, section_name="Final English manuscript"
2790
+ )
2791
+ if enum_checked.strip() != full_draft_en.strip() or enum_rows:
2792
+ raise RuntimeError("final_enumeration_contract_requires_section_regeneration")
2793
  # Validate the completed body while the Working Abstract is still in
2794
  # place. Final front matter must not be generated until all body
2795
  # sections, citation cleanup, and any Discussion rebalance are frozen.
core/pipeline_agent.py CHANGED
@@ -3397,6 +3397,283 @@ INPUT:
3397
  )
3398
  return repaired
3399
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3400
  def semantic_audit_claim_specific_citations(
3401
  self,
3402
  text,
 
3397
  )
3398
  return repaired
3399
 
3400
+ @staticmethod
3401
+ def _qual_source_family(value):
3402
+ text = re.sub(r"\s+", " ", str(value or "")).strip().casefold()
3403
+ if re.search(r"\b(interview|interviews|interviewed|semi[- ]structured interview)\b", text):
3404
+ return "interview"
3405
+ if re.search(r"\bfocus\s+groups?\b", text):
3406
+ return "focus_group"
3407
+ if re.search(r"\b(observation|observations|observed|field observation)\b", text):
3408
+ return "observation"
3409
+ if re.search(r"\b(usage|interaction|system)\s+logs?\b|\blog\s+data\b", text):
3410
+ return "log"
3411
+ if re.search(r"\b(document|documents|records?|archive|archival)\b", text):
3412
+ return "document"
3413
+ return ""
3414
+
3415
+ @classmethod
3416
+ def _qual_actor_variants(cls, role):
3417
+ text = re.sub(r"\s+", " ", str(role or "")).strip().casefold()
3418
+ if not text:
3419
+ return []
3420
+ variants = {text}
3421
+ words = re.findall(r"[a-z][a-z-]{2,}", text)
3422
+ generic = {"participants", "participant", "respondents", "respondent", "actors", "actor", "users", "user"}
3423
+ informative = [w for w in words if w not in generic]
3424
+ if informative:
3425
+ variants.add(" ".join(informative))
3426
+ variants.add(informative[-1])
3427
+ if informative[-1].endswith("s") and len(informative[-1]) > 4:
3428
+ variants.add(informative[-1][:-1])
3429
+ return sorted((v for v in variants if len(v) >= 4), key=len, reverse=True)
3430
+
3431
+ @classmethod
3432
+ def _nearest_qual_source_family(cls, sentence, actor_start):
3433
+ text = str(sentence or "")
3434
+ markers = []
3435
+ patterns = {
3436
+ "interview": r"\b(?:semi[- ]structured\s+)?interviews?\b|\binterviewed\b|\binterview\s+participants?\b",
3437
+ "focus_group": r"\bfocus\s+groups?\b",
3438
+ "observation": r"\b(?:field\s+)?observations?\b|\bobserved\b",
3439
+ "log": r"\b(?:usage|interaction|system)\s+logs?\b|\blog\s+data\b",
3440
+ "document": r"\bdocuments?\b|\barchival\s+(?:records?|materials?)\b|\brecords?\b",
3441
+ }
3442
+ for family, pattern in patterns.items():
3443
+ for match in re.finditer(pattern, text, flags=re.I):
3444
+ markers.append((abs(match.start() - int(actor_start)), family))
3445
+ if not markers:
3446
+ return ""
3447
+ markers.sort(key=lambda row: row[0])
3448
+ return markers[0][1]
3449
+
3450
+ @classmethod
3451
+ def repair_qualitative_study_fact_contract(cls, text, metadata, section_name=""):
3452
+ """Remove/repair only high-confidence study-owned fact violations.
3453
+
3454
+ The frozen Study Evidence Ledger owns actor/data-source relations and
3455
+ whether trustworthiness/ethics/reflexivity procedures were actually
3456
+ performed. External methodological best practice never authorizes a
3457
+ study-owned procedure claim.
3458
+ """
3459
+ value = str(text or "")
3460
+ facts = dict((metadata or {}).get("study_facts") or {})
3461
+ relations = [row for row in (facts.get("data_source_roles") or []) if isinstance(row, dict)]
3462
+ procedure_lists = {
3463
+ "trustworthiness": [str(x or "").strip() for x in (facts.get("trustworthiness_procedures") or []) if str(x or "").strip()],
3464
+ "ethics": [str(x or "").strip() for x in (facts.get("ethics_procedures") or []) if str(x or "").strip()],
3465
+ "reflexivity": [str(x or "").strip() for x in (facts.get("reflexivity_procedures") or []) if str(x or "").strip()],
3466
+ }
3467
+ allowed_by_actor = {}
3468
+ canonical_relations = []
3469
+ for row in relations:
3470
+ role = re.sub(r"\s+", " ", str(row.get("actor_role") or "")).strip()
3471
+ source = re.sub(r"\s+", " ", str(row.get("data_source") or "")).strip()
3472
+ family = cls._qual_source_family(source)
3473
+ if not role or not source or not family:
3474
+ continue
3475
+ canonical_relations.append((role, source, row.get("count")))
3476
+ for variant in cls._qual_actor_variants(role):
3477
+ allowed_by_actor.setdefault(variant, set()).add(family)
3478
+
3479
+ study_owned = re.compile(
3480
+ r"\b(?:we|our\s+study|this\s+study|the\s+study|research\s+team|participants?|respondents?)\b|"
3481
+ r"\b(?:was|were)\s+(?:conducted|performed|used|maintained|obtained|recorded|anonymized|anonymised)\b",
3482
+ flags=re.I,
3483
+ )
3484
+ procedure_patterns = {
3485
+ "trustworthiness": r"\bmember\s+check(?:ing|s)?\b|\bpeer\s+debrief(?:ing|ed)?\b|\baudit\s+trail\b|\bexternal\s+audit\b",
3486
+ "ethics": r"\binformed\s+consent\b|\bethics?\s+approval\b|\binstitutional\s+review\s+board\b|\bIRB\b|\bapproval\s+(?:number|code)\b",
3487
+ "reflexivity": r"\bfield\s+journal\b|\breflexive\s+journal\b|\bpositionality\s+(?:statement|memo)\b|\breflexive\s+memo(?:s)?\b",
3488
+ }
3489
+ spans = cls._sentence_spans(value)
3490
+ replacements = []
3491
+ diagnostics = []
3492
+ for start, end, sentence in spans:
3493
+ core = str(sentence or "")
3494
+ lower = core.casefold()
3495
+ if not core.strip():
3496
+ continue
3497
+ removed = False
3498
+ if study_owned.search(core):
3499
+ for category, pattern in procedure_patterns.items():
3500
+ procedure_match = re.search(pattern, core, flags=re.I)
3501
+ if not procedure_match:
3502
+ continue
3503
+ observed = procedure_match.group(0).casefold()
3504
+ if "member check" in observed:
3505
+ key = "member check"
3506
+ elif "peer debrief" in observed:
3507
+ key = "peer debrief"
3508
+ elif "audit trail" in observed:
3509
+ key = "audit trail"
3510
+ elif "external audit" in observed:
3511
+ key = "external audit"
3512
+ elif "informed consent" in observed:
3513
+ key = "informed consent"
3514
+ elif "ethic" in observed and "approval" in observed:
3515
+ key = "ethics approval"
3516
+ elif "review board" in observed or observed == "irb":
3517
+ key = "review board"
3518
+ elif "approval" in observed:
3519
+ key = "approval"
3520
+ elif "field journal" in observed:
3521
+ key = "field journal"
3522
+ elif "reflexive journal" in observed:
3523
+ key = "reflexive journal"
3524
+ elif "positionality" in observed:
3525
+ key = "positionality"
3526
+ else:
3527
+ key = "reflexive memo"
3528
+ authorized = any(key in item.casefold() for item in procedure_lists[category])
3529
+ if not authorized:
3530
+ replacements.append((start, end, ""))
3531
+ diagnostics.append({"type":"unsupported_study_procedure", "category":category, "procedure":key, "preview":core.strip()[:180]})
3532
+ removed = True
3533
+ break
3534
+ if removed or not canonical_relations:
3535
+ continue
3536
+ for actor_variant, allowed_families in allowed_by_actor.items():
3537
+ for actor_match in re.finditer(rf"(?<![A-Za-z]){re.escape(actor_variant)}(?![A-Za-z])", lower, flags=re.I):
3538
+ nearest = cls._nearest_qual_source_family(core, actor_match.start())
3539
+ if nearest and nearest not in allowed_families:
3540
+ relation_text = " and ".join(
3541
+ f"{source} with {role}" + (f" (n={count})" if isinstance(count, int) else "")
3542
+ for role, source, count in canonical_relations
3543
+ )
3544
+ replacement = f"Data collection comprised {relation_text}."
3545
+ leading = core[: len(core) - len(core.lstrip())]
3546
+ trailing = core[len(core.rstrip()):]
3547
+ replacements.append((start, end, f"{leading}{replacement}{trailing}"))
3548
+ diagnostics.append({"type":"actor_data_source_relation_repaired", "actor":actor_variant, "observed":nearest, "allowed":sorted(allowed_families)})
3549
+ removed = True
3550
+ break
3551
+ if removed:
3552
+ break
3553
+ if replacements:
3554
+ for start, end, replacement in sorted(replacements, reverse=True):
3555
+ value = value[:start] + replacement + value[end:]
3556
+ value = re.sub(r"[ \t]+\n", "\n", value)
3557
+ value = re.sub(r"\n{3,}", "\n\n", value)
3558
+ value = re.sub(r" {2,}", " ", value)
3559
+ return value.strip(), diagnostics
3560
+
3561
+ @classmethod
3562
+ def validate_qualitative_study_fact_contract(cls, text, metadata, section_name=""):
3563
+ repaired, diagnostics = cls.repair_qualitative_study_fact_contract(text, metadata, section_name=section_name)
3564
+ if repaired.strip() != str(text or "").strip():
3565
+ raise RuntimeError(f"qualitative_study_fact_contract_unrepaired:{section_name}:{diagnostics[:3]}")
3566
+ return True
3567
+
3568
+ @classmethod
3569
+ def repair_and_validate_enumeration_contract(cls, text, section_name=""):
3570
+ """Repair only high-confidence ordinal shifts tied to an explicit count."""
3571
+ value = str(text or "")
3572
+ number_map = {"two":2, "three":3, "four":4, "five":5, "six":6, "2":2, "3":3, "4":4, "5":5, "6":6}
3573
+ ordinal_map = {"first":1, "second":2, "third":3, "fourth":4, "fifth":5, "sixth":6}
3574
+ reverse = {1:"first",2:"second",3:"third",4:"fourth",5:"fifth",6:"sixth"}
3575
+ decl = re.compile(
3576
+ r"\b(two|three|four|five|six|[2-6])\s+(?:provisional\s+)?"
3577
+ r"(domains?|mechanisms?|dimensions?|stages?|patterns?|components?|themes?|processes?|conditions?)\b",
3578
+ flags=re.I,
3579
+ )
3580
+ offset = 0
3581
+ diagnostics = []
3582
+ for match in list(decl.finditer(value)):
3583
+ count = number_map[match.group(1).lower()]
3584
+ start = match.end() + offset
3585
+ next_heading = re.search(r"\n#{1,3}\s+", value[start:])
3586
+ scope_end = start + min(3500, next_heading.start() if next_heading else 3500)
3587
+ scope = value[start:scope_end]
3588
+ ordinals = list(re.finditer(r"\bThe\s+(first|second|third|fourth|fifth|sixth)\b", scope, flags=re.I))
3589
+ if len(ordinals) < count:
3590
+ continue
3591
+ selected = ordinals[:count]
3592
+ nums = [ordinal_map[m.group(1).lower()] for m in selected]
3593
+ expected = list(range(1, count + 1))
3594
+ if nums == expected:
3595
+ continue
3596
+ consecutive = nums == list(range(nums[0], nums[0] + count))
3597
+ if not consecutive:
3598
+ raise RuntimeError(f"enumeration_contract_mismatch:{section_name}:declared={count}:ordinals={nums}")
3599
+ scope_chars = list(scope)
3600
+ for index, omatch in reversed(list(enumerate(selected, start=1))):
3601
+ old = omatch.group(1)
3602
+ new = reverse[index]
3603
+ if old[:1].isupper():
3604
+ new = new.capitalize()
3605
+ a, b = omatch.start(1), omatch.end(1)
3606
+ scope_chars[a:b] = list(new)
3607
+ repaired_scope = "".join(scope_chars)
3608
+ value = value[:start] + repaired_scope + value[scope_end:]
3609
+ delta = len(repaired_scope) - len(scope)
3610
+ offset += delta
3611
+ diagnostics.append({"declared":count, "observed":nums, "repaired":expected})
3612
+ # Validate the same high-confidence patterns after repair.
3613
+ for match in decl.finditer(value):
3614
+ count = number_map[match.group(1).lower()]
3615
+ start = match.end()
3616
+ next_heading = re.search(r"\n#{1,3}\s+", value[start:])
3617
+ scope_end = start + min(3500, next_heading.start() if next_heading else 3500)
3618
+ nums = [ordinal_map[m.group(1).lower()] for m in list(re.finditer(r"\bThe\s+(first|second|third|fourth|fifth|sixth)\b", value[start:scope_end], flags=re.I))[:count]]
3619
+ if len(nums) >= count and nums != list(range(1, count + 1)):
3620
+ raise RuntimeError(f"enumeration_contract_unresolved:{section_name}:declared={count}:ordinals={nums}")
3621
+ return value, diagnostics
3622
+
3623
+ @classmethod
3624
+ def remove_severe_cross_unit_sentence_echoes(cls, candidate, prior_text):
3625
+ """Remove only near-verbatim long sentence echoes from earlier units.
3626
+
3627
+ Quotations, headings, and sentences with different citation/numeric
3628
+ ownership are never removed. This is intentionally stricter than a
3629
+ general semantic similarity rewriter.
3630
+ """
3631
+ previous = []
3632
+ for _start, _end, sentence in cls._sentence_spans(prior_text):
3633
+ core = re.sub(r"\s+", " ", str(sentence or "")).strip()
3634
+ if len(core.split()) < 20 or core.startswith("#") or '"' in core or "QUOTE-" in core:
3635
+ continue
3636
+ tokens = set(re.findall(r"[A-Za-z][A-Za-z0-9_-]{2,}", re.sub(r"\[?CITE-\d+\]?", " ", core, flags=re.I).lower()))
3637
+ if tokens:
3638
+ reader_citations = set(re.findall(r"\([^()\n]{0,120}(?:19|20)\d{2}[a-z]?[^()\n]{0,40}\)", core))
3639
+ raw_citations = set(re.findall(r"\[?CITE-\d+\]?", core, flags=re.I))
3640
+ previous.append((core, tokens, raw_citations | reader_citations, set(re.findall(r"(?<![A-Za-z])[-+]?\d+(?:\.\d+)?%?(?![A-Za-z])", core))))
3641
+ value = str(candidate or "")
3642
+ replacements = []
3643
+ diagnostics = []
3644
+ for start, end, sentence in cls._sentence_spans(value):
3645
+ core = re.sub(r"\s+", " ", str(sentence or "")).strip()
3646
+ if len(core.split()) < 20 or core.startswith("#") or '"' in core or "QUOTE-" in core:
3647
+ continue
3648
+ tokens = set(re.findall(r"[A-Za-z][A-Za-z0-9_-]{2,}", re.sub(r"\[?CITE-\d+\]?", " ", core, flags=re.I).lower()))
3649
+ if not tokens:
3650
+ continue
3651
+ citations = set(re.findall(r"\[?CITE-\d+\]?", core, flags=re.I))
3652
+ citations |= set(re.findall(r"\([^()\n]{0,120}(?:19|20)\d{2}[a-z]?[^()\n]{0,40}\)", core))
3653
+ numerics = set(re.findall(r"(?<![A-Za-z])[-+]?\d+(?:\.\d+)?%?(?![A-Za-z])", core))
3654
+ duplicate = None
3655
+ for old, old_tokens, old_citations, old_numerics in previous:
3656
+ if citations != old_citations or numerics != old_numerics:
3657
+ continue
3658
+ union = tokens | old_tokens
3659
+ similarity = len(tokens & old_tokens) / max(1, len(union))
3660
+ normalized_same = re.sub(r"\W+", " ", core).strip().casefold() == re.sub(r"\W+", " ", old).strip().casefold()
3661
+ if normalized_same or similarity >= 0.96:
3662
+ duplicate = (1.0 if normalized_same else similarity, old[:180])
3663
+ break
3664
+ if duplicate:
3665
+ replacements.append((start, end, ""))
3666
+ diagnostics.append({"similarity":round(duplicate[0],4), "removed_preview":core[:180]})
3667
+ else:
3668
+ previous.append((core, tokens, citations, numerics))
3669
+ if replacements:
3670
+ for start, end, replacement in sorted(replacements, reverse=True):
3671
+ value = value[:start] + replacement + value[end:]
3672
+ value = re.sub(r"[ \t]+\n", "\n", value)
3673
+ value = re.sub(r"\n{3,}", "\n\n", value)
3674
+ value = re.sub(r" {2,}", " ", value)
3675
+ return value.strip(), diagnostics
3676
+
3677
  def semantic_audit_claim_specific_citations(
3678
  self,
3679
  text,
core/qualitative_engine.py CHANGED
@@ -36,6 +36,37 @@ class QualitativeEngine:
36
  lookup = {item.lower(): item for item in cls.ALLOWED_ANALYTIC_DESIGNS}
37
  return lookup.get(cleaned.lower(), "Iterative thematic/process analysis")
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  @classmethod
40
  def build_scoped_evidence_md(
41
  cls,
@@ -74,6 +105,10 @@ class QualitativeEngine:
74
  temporal_structure = cls._clean_cell(facts.get("temporal_structure"))
75
  case_structure = cls._clean_cell(facts.get("case_structure"))
76
  setting = cls._clean_cell(facts.get("research_setting"))
 
 
 
 
77
 
78
  lines = [
79
  "### Frozen Qualitative Study Facts",
@@ -87,12 +122,29 @@ class QualitativeEngine:
87
  f"| Analytic logic | {analytic_logic} |",
88
  f"| Temporal structure | {temporal_structure} |",
89
  f"| Case structure | {case_structure} |",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  "",
91
  "[QUALITATIVE EVIDENCE CHANNELS]",
92
  "1. STUDY FACTS and THEME SUMMARIES below are this study's internal empirical evidence. Never attach an external [CITE-XX] tag to a sentence merely because it reports or interprets these frozen facts.",
93
  "2. External literature may support only prior-theory, prior-evidence, or methodological-rationale claims. Keep those claims in distinct sentences from this study's setting, sample, data, events, quotations, and findings.",
94
  "3. ASSIGNED QUOTATIONS, when present, are analysis-only verbatim evidence owned by the current stable theme. Do not reproduce the verbatim wording or any internal quotation identifier in generated prose; the Executor renders frozen quotations deterministically after narrative QA.",
95
- ]
 
96
 
97
  allowed_theme_titles = None
98
  if theme_titles:
@@ -216,8 +268,12 @@ class QualitativeEngine:
216
  "study_facts": {
217
  "research_setting": "string or Not specified",
218
  "data_sources": ["string"],
 
219
  "participant_count": integer or null,
220
  "interview_count": integer or null,
 
 
 
221
  "analytic_design": "one allowed design",
222
  "analytic_logic": "one concise sentence",
223
  "temporal_structure": "string or Not specified",
@@ -242,12 +298,12 @@ Keywords: {keywords}
242
  Analyze the supplied interview or field material for an ASQ-oriented qualitative manuscript.
243
 
244
  Rules:
245
- 1. Treat the supplied material as the only source of study facts. Use null or "Not specified" for any setting, sample, count, date, case, or data source not supported by the material.
246
  2. Extract 8-15 representative quotations VERBATIM. Never repair, paraphrase, translate, combine, or invent a quotation.
247
  3. Choose the analytic design that best fits the actual material. Do NOT default to Gioia. Use Gioia-style coding only if the evidence structure genuinely warrants it.
248
  4. Organize evidence around a central organizational puzzle and, when the material supports it, preserve temporal stages, actor/case contrasts, process transitions, and at least one deviant or boundary observation.
249
  5. Do not manufacture a longitudinal sequence when the material is cross-sectional, and do not manufacture multiple cases when only one is present.
250
- 6. The study_facts object and quotations will become the frozen single source of truth for the entire manuscript.
251
 
252
  {schema}
253
  <QUALITATIVE_MATERIAL>
@@ -261,13 +317,14 @@ Construct one internally coherent qualitative evidence packet for a complete ASQ
261
 
262
  Rules:
263
  1. Choose one generic organizational research setting directly relevant to the topic and keep it fixed. Do not name a real organization, institution, or identifiable person.
264
- 2. Freeze one participant count, interview count, data-source set, analytic design, temporal/case structure, and analytic logic. Keep them internally consistent.
265
  3. Produce exactly 12 natural representative quotations distributed across at least three higher-order themes. Quotations must sound like distinct participants rather than repeated template variants.
266
  4. Choose the analytic design that best fits the constructed evidence; do NOT default to Gioia. Use process, comparative-case, grounded-theory, longitudinal, or Gioia-style logic only when the packet supports it.
267
  5. Include at least one clearly labelled deviant/boundary observation so the eventual theory is not built only from confirming examples.
268
  6. The evidence should support a process/mechanism explanation, not merely descriptive topic categories.
269
- 7. The study_facts object and evidence packet will become the frozen single source of truth for the entire manuscript.
270
- 8. Avoid decorative language, generic AI phrasing, and repetitive quotation patterns.
 
271
 
272
  {schema}
273
  """
@@ -292,6 +349,38 @@ Rules:
292
  value = facts.get(key)
293
  if value is not None and (not isinstance(value, int) or value <= 0):
294
  return f"invalid_{key}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
 
296
  seen_quotes = set()
297
  theme_keys = set()
@@ -408,6 +497,10 @@ Rules:
408
  setting = self._clean_cell(facts.get("research_setting"))
409
  clean_sources = [self._clean_cell(item) for item in data_sources]
410
  sources = ", ".join(clean_sources)
 
 
 
 
411
  participants = facts.get("participant_count")
412
  interviews = facts.get("interview_count")
413
  analytic_design = self._normalize_analytic_design(
@@ -435,16 +528,29 @@ Rules:
435
  f"| Analytic design | {analytic_design} |\n"
436
  f"| Analytic logic | {analytic_logic} |\n"
437
  f"| Temporal structure | {temporal_structure} |\n"
438
- f"| Case structure | {case_structure} |\n\n"
 
 
 
439
  "[QUALITATIVE EVIDENCE CONTRACT]\n"
440
  "Use the frozen design summary and tagged evidence below as the only source of this study's empirical facts. "
441
- "Do not change the setting, sample, interview count, data sources, analytic design, temporal structure, cases, or quotations. "
442
- "Do not infer an event sequence, actor comparison, negative case, or quotation that is absent from this packet.\n\n"
443
  "### Table: Qualitative Evidence Structure\n"
444
  "| Higher-order Theme | Empirical Concept | Process Stage | Case/Actor | Evidence Role | Representative Quote |\n"
445
  "| :--- | :--- | :--- | :--- | :--- | :--- |\n"
446
  )
447
 
 
 
 
 
 
 
 
 
 
 
448
  clean_evidence = []
449
  seen_quotes = set()
450
  for item in evidence[:15]:
@@ -537,8 +643,12 @@ Rules:
537
  "study_facts": {
538
  "research_setting": setting,
539
  "data_sources": clean_sources,
 
540
  "participant_count": participants,
541
  "interview_count": interviews,
 
 
 
542
  "analytic_design": analytic_design,
543
  "analysis_method": analytic_design,
544
  "analytic_logic": analytic_logic,
 
36
  lookup = {item.lower(): item for item in cls.ALLOWED_ANALYTIC_DESIGNS}
37
  return lookup.get(cleaned.lower(), "Iterative thematic/process analysis")
38
 
39
+ @classmethod
40
+ def _normalize_string_list(cls, values):
41
+ if values is None:
42
+ return []
43
+ if not isinstance(values, list):
44
+ values = [values]
45
+ out = []
46
+ for value in values:
47
+ cleaned = cls._clean_cell(value, "")
48
+ if cleaned and cleaned.casefold() != "not specified" and cleaned not in out:
49
+ out.append(cleaned)
50
+ return out
51
+
52
+ @classmethod
53
+ def _normalize_data_source_roles(cls, values):
54
+ rows = []
55
+ for item in values or []:
56
+ if not isinstance(item, dict):
57
+ continue
58
+ actor_role = cls._clean_cell(item.get("actor_role"), "")
59
+ data_source = cls._clean_cell(item.get("data_source"), "")
60
+ count = item.get("count")
61
+ if not actor_role or not data_source:
62
+ continue
63
+ if count is not None and (not isinstance(count, int) or count <= 0):
64
+ continue
65
+ row = {"actor_role": actor_role, "data_source": data_source, "count": count}
66
+ if row not in rows:
67
+ rows.append(row)
68
+ return rows
69
+
70
  @classmethod
71
  def build_scoped_evidence_md(
72
  cls,
 
105
  temporal_structure = cls._clean_cell(facts.get("temporal_structure"))
106
  case_structure = cls._clean_cell(facts.get("case_structure"))
107
  setting = cls._clean_cell(facts.get("research_setting"))
108
+ data_source_roles = cls._normalize_data_source_roles(facts.get("data_source_roles") or [])
109
+ trustworthiness = cls._normalize_string_list(facts.get("trustworthiness_procedures"))
110
+ ethics = cls._normalize_string_list(facts.get("ethics_procedures"))
111
+ reflexivity = cls._normalize_string_list(facts.get("reflexivity_procedures"))
112
 
113
  lines = [
114
  "### Frozen Qualitative Study Facts",
 
122
  f"| Analytic logic | {analytic_logic} |",
123
  f"| Temporal structure | {temporal_structure} |",
124
  f"| Case structure | {case_structure} |",
125
+ f"| Trustworthiness procedures | {'; '.join(trustworthiness) if trustworthiness else 'Not specified'} |",
126
+ f"| Ethics procedures | {'; '.join(ethics) if ethics else 'Not specified'} |",
127
+ f"| Reflexivity procedures | {'; '.join(reflexivity) if reflexivity else 'Not specified'} |",
128
+ ]
129
+ if data_source_roles:
130
+ lines.extend([
131
+ "",
132
+ "### Frozen Actor / Data-Source Relations",
133
+ "| Actor role | Data source | Count |",
134
+ "| :--- | :--- | :--- |",
135
+ ])
136
+ lines.extend(
137
+ f"| {row['actor_role']} | {row['data_source']} | {row['count'] if row['count'] is not None else 'Not specified'} |"
138
+ for row in data_source_roles
139
+ )
140
+ lines.extend([
141
  "",
142
  "[QUALITATIVE EVIDENCE CHANNELS]",
143
  "1. STUDY FACTS and THEME SUMMARIES below are this study's internal empirical evidence. Never attach an external [CITE-XX] tag to a sentence merely because it reports or interprets these frozen facts.",
144
  "2. External literature may support only prior-theory, prior-evidence, or methodological-rationale claims. Keep those claims in distinct sentences from this study's setting, sample, data, events, quotations, and findings.",
145
  "3. ASSIGNED QUOTATIONS, when present, are analysis-only verbatim evidence owned by the current stable theme. Do not reproduce the verbatim wording or any internal quotation identifier in generated prose; the Executor renders frozen quotations deterministically after narrative QA.",
146
+ "4. Study-owned trustworthiness, ethics, reflexivity, recruitment, consent, observation, or audit procedures may be stated as performed only when explicitly frozen above. Never convert general methodological best practice into a fact about this study.",
147
+ ])
148
 
149
  allowed_theme_titles = None
150
  if theme_titles:
 
268
  "study_facts": {
269
  "research_setting": "string or Not specified",
270
  "data_sources": ["string"],
271
+ "data_source_roles": [{"actor_role":"string", "data_source":"string", "count":integer or null}],
272
  "participant_count": integer or null,
273
  "interview_count": integer or null,
274
+ "trustworthiness_procedures": ["explicitly supported/frozen procedure"],
275
+ "ethics_procedures": ["explicitly supported/frozen procedure"],
276
+ "reflexivity_procedures": ["explicitly supported/frozen procedure"],
277
  "analytic_design": "one allowed design",
278
  "analytic_logic": "one concise sentence",
279
  "temporal_structure": "string or Not specified",
 
298
  Analyze the supplied interview or field material for an ASQ-oriented qualitative manuscript.
299
 
300
  Rules:
301
+ 1. Treat the supplied material as the only source of study facts. Use null, empty arrays, or "Not specified" for any setting, sample, count, date, case, actor/data-source relation, trustworthiness procedure, ethics procedure, reflexivity procedure, or data source not explicitly supported by the material.
302
  2. Extract 8-15 representative quotations VERBATIM. Never repair, paraphrase, translate, combine, or invent a quotation.
303
  3. Choose the analytic design that best fits the actual material. Do NOT default to Gioia. Use Gioia-style coding only if the evidence structure genuinely warrants it.
304
  4. Organize evidence around a central organizational puzzle and, when the material supports it, preserve temporal stages, actor/case contrasts, process transitions, and at least one deviant or boundary observation.
305
  5. Do not manufacture a longitudinal sequence when the material is cross-sectional, and do not manufacture multiple cases when only one is present.
306
+ 6. Freeze actor-to-data-source relations explicitly in data_source_roles whenever the material supports them (for example extension officers -> interviews; farmers -> focus groups). Never infer that an actor participated in a data source not supported by the material. The study_facts object and quotations will become the frozen single source of truth for the entire manuscript.
307
 
308
  {schema}
309
  <QUALITATIVE_MATERIAL>
 
317
 
318
  Rules:
319
  1. Choose one generic organizational research setting directly relevant to the topic and keep it fixed. Do not name a real organization, institution, or identifiable person.
320
+ 2. Freeze one participant count, interview count, data-source set, actor-to-data-source relation map, analytic design, temporal/case structure, and analytic logic. Keep them internally consistent. Every actor/data-source relationship later stated in the paper must be represented in data_source_roles.
321
  3. Produce exactly 12 natural representative quotations distributed across at least three higher-order themes. Quotations must sound like distinct participants rather than repeated template variants.
322
  4. Choose the analytic design that best fits the constructed evidence; do NOT default to Gioia. Use process, comparative-case, grounded-theory, longitudinal, or Gioia-style logic only when the packet supports it.
323
  5. Include at least one clearly labelled deviant/boundary observation so the eventual theory is not built only from confirming examples.
324
  6. The evidence should support a process/mechanism explanation, not merely descriptive topic categories.
325
+ 7. Freeze concise, internally coherent trustworthiness, ethics, and reflexivity procedures in their typed arrays so later Methods prose has an explicit study-owned source. Do not name a real ethics board, institution, vendor, approval number, or identifiable organization.
326
+ 8. The study_facts object and evidence packet will become the frozen single source of truth for the entire manuscript.
327
+ 9. Avoid decorative language, generic AI phrasing, and repetitive quotation patterns.
328
 
329
  {schema}
330
  """
 
349
  value = facts.get(key)
350
  if value is not None and (not isinstance(value, int) or value <= 0):
351
  return f"invalid_{key}"
352
+ raw_relations = facts.get("data_source_roles") or []
353
+ if raw_relations and not isinstance(raw_relations, list):
354
+ return "invalid_data_source_roles"
355
+ declared_sources = [
356
+ re.sub(r"\s+", " ", str(item or "")).strip().casefold()
357
+ for item in (facts.get("data_sources") or [])
358
+ if str(item or "").strip()
359
+ ]
360
+ for item in raw_relations:
361
+ if not isinstance(item, dict) or not str(item.get("actor_role") or "").strip() or not str(item.get("data_source") or "").strip():
362
+ return "invalid_data_source_role_item"
363
+ count = item.get("count")
364
+ if count is not None and (not isinstance(count, int) or count <= 0):
365
+ return "invalid_data_source_role_count"
366
+ relation_source = re.sub(r"\s+", " ", str(item.get("data_source") or "")).strip().casefold()
367
+ if declared_sources and not any(
368
+ relation_source == source or relation_source in source or source in relation_source
369
+ for source in declared_sources
370
+ ):
371
+ return "data_source_role_not_in_declared_sources"
372
+ for key in ("trustworthiness_procedures", "ethics_procedures", "reflexivity_procedures"):
373
+ value = facts.get(key) or []
374
+ if not isinstance(value, list):
375
+ return f"invalid_{key}"
376
+ if any(not str(item or "").strip() for item in value):
377
+ return f"invalid_{key}"
378
+ if not has_user_data:
379
+ if not raw_relations:
380
+ return "generated_packet_missing_data_source_roles"
381
+ for key in ("trustworthiness_procedures", "ethics_procedures", "reflexivity_procedures"):
382
+ if not (facts.get(key) or []):
383
+ return f"generated_packet_missing_{key}"
384
 
385
  seen_quotes = set()
386
  theme_keys = set()
 
497
  setting = self._clean_cell(facts.get("research_setting"))
498
  clean_sources = [self._clean_cell(item) for item in data_sources]
499
  sources = ", ".join(clean_sources)
500
+ data_source_roles = self._normalize_data_source_roles(facts.get("data_source_roles") or [])
501
+ trustworthiness = self._normalize_string_list(facts.get("trustworthiness_procedures"))
502
+ ethics = self._normalize_string_list(facts.get("ethics_procedures"))
503
+ reflexivity = self._normalize_string_list(facts.get("reflexivity_procedures"))
504
  participants = facts.get("participant_count")
505
  interviews = facts.get("interview_count")
506
  analytic_design = self._normalize_analytic_design(
 
528
  f"| Analytic design | {analytic_design} |\n"
529
  f"| Analytic logic | {analytic_logic} |\n"
530
  f"| Temporal structure | {temporal_structure} |\n"
531
+ f"| Case structure | {case_structure} |\n"
532
+ f"| Trustworthiness procedures | {'; '.join(trustworthiness) if trustworthiness else 'Not specified'} |\n"
533
+ f"| Ethics procedures | {'; '.join(ethics) if ethics else 'Not specified'} |\n"
534
+ f"| Reflexivity procedures | {'; '.join(reflexivity) if reflexivity else 'Not specified'} |\n\n"
535
  "[QUALITATIVE EVIDENCE CONTRACT]\n"
536
  "Use the frozen design summary and tagged evidence below as the only source of this study's empirical facts. "
537
+ "Do not change the setting, sample, interview count, data sources, actor/data-source relations, analytic design, temporal structure, cases, procedures, or quotations. "
538
+ "Do not infer an event sequence, actor comparison, trustworthiness/ethics/reflexivity procedure, negative case, or quotation that is absent from this packet.\n\n"
539
  "### Table: Qualitative Evidence Structure\n"
540
  "| Higher-order Theme | Empirical Concept | Process Stage | Case/Actor | Evidence Role | Representative Quote |\n"
541
  "| :--- | :--- | :--- | :--- | :--- | :--- |\n"
542
  )
543
 
544
+ if data_source_roles:
545
+ md += (
546
+ "### Frozen Actor / Data-Source Relations\n"
547
+ "| Actor role | Data source | Count |\n"
548
+ "| :--- | :--- | :--- |\n"
549
+ )
550
+ for row in data_source_roles:
551
+ md += f"| {row['actor_role']} | {row['data_source']} | {row['count'] if row['count'] is not None else 'Not specified'} |\n"
552
+ md += "\n"
553
+
554
  clean_evidence = []
555
  seen_quotes = set()
556
  for item in evidence[:15]:
 
643
  "study_facts": {
644
  "research_setting": setting,
645
  "data_sources": clean_sources,
646
+ "data_source_roles": data_source_roles,
647
  "participant_count": participants,
648
  "interview_count": interviews,
649
+ "trustworthiness_procedures": trustworthiness,
650
+ "ethics_procedures": ethics,
651
+ "reflexivity_procedures": reflexivity,
652
  "analytic_design": analytic_design,
653
  "analysis_method": analytic_design,
654
  "analytic_logic": analytic_logic,