Spaces:
Running
Running
R14-TQ2-PQ1-P0-R1 Study Integrity Terminal UI Mode Decoupling
#69
by GuXiaopeng - opened
- app.py +78 -42
- config.py +2 -2
- core/generation_runner.py +58 -0
- core/pipeline_agent.py +277 -0
- core/qualitative_engine.py +119 -9
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": "
|
| 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 "
|
| 416 |
-
return "
|
| 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(
|
| 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 |
-
""")
|
| 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 |
-
|
| 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 |
-
""")
|
| 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 |
-
|
| 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 |
-
""")
|
| 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 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1024 |
task_id = str(task_id or "").strip()
|
| 1025 |
-
if not 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
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 "
|
| 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 |
-
|
| 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
|
| 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
|
| 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.
|
| 270 |
-
8.
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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,
|