Spaces:
Running
Running
Step 4A-6-C-R3.2.6 S2 Recovery Flicker-Free Streaming Citation-First AMJ-ASQ Prompt Upgrade
#16
by GuXiaopeng - opened
- .streamlit/config.toml +1 -0
- __pycache__/app.cpython-313.pyc +0 -0
- __pycache__/config.cpython-313.pyc +0 -0
- __pycache__/i18n.cpython-313.pyc +0 -0
- __pycache__/managed_auth.cpython-313.pyc +0 -0
- __pycache__/task_executor.cpython-313.pyc +0 -0
- app.py +66 -15
- config.py +76 -34
- core/__pycache__/checkpoint_store.cpython-313.pyc +0 -0
- core/__pycache__/generation_runner.cpython-313.pyc +0 -0
- core/__pycache__/meta_extractor.cpython-313.pyc +0 -0
- core/__pycache__/pipeline_agent.cpython-313.pyc +2 -2
- core/__pycache__/production_guard.cpython-313.pyc +0 -0
- core/__pycache__/qualitative_engine.cpython-313.pyc +0 -0
- core/__pycache__/quantitative_engine.cpython-313.pyc +0 -0
- core/__pycache__/rag_engine.cpython-313.pyc +0 -0
- core/generation_runner.py +233 -69
- core/pipeline_agent.py +524 -146
- core/qualitative_engine.py +134 -70
- core/quantitative_engine.py +238 -23
- smartpaper_restore_bridge/__pycache__/__init__.cpython-313.pyc +0 -0
- smartpaper_restore_bridge/frontend/index.html +116 -11
- static/desktop.ini +2 -0
- static/previews/.gitkeep +1 -0
- static/previews/desktop.ini +2 -0
- utils/__pycache__/docx_builder.cpython-313.pyc +0 -0
- utils/__pycache__/file_parser.cpython-313.pyc +0 -0
.streamlit/config.toml
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
[server]
|
|
|
|
| 2 |
enableCORS = false
|
| 3 |
enableXsrfProtection = false
|
| 4 |
enableWebsocketCompression = false
|
|
|
|
| 1 |
[server]
|
| 2 |
+
enableStaticServing = true
|
| 3 |
enableCORS = false
|
| 4 |
enableXsrfProtection = false
|
| 5 |
enableWebsocketCompression = false
|
__pycache__/app.cpython-313.pyc
CHANGED
|
Binary files a/__pycache__/app.cpython-313.pyc and b/__pycache__/app.cpython-313.pyc differ
|
|
|
__pycache__/config.cpython-313.pyc
CHANGED
|
Binary files a/__pycache__/config.cpython-313.pyc and b/__pycache__/config.cpython-313.pyc differ
|
|
|
__pycache__/i18n.cpython-313.pyc
CHANGED
|
Binary files a/__pycache__/i18n.cpython-313.pyc and b/__pycache__/i18n.cpython-313.pyc differ
|
|
|
__pycache__/managed_auth.cpython-313.pyc
CHANGED
|
Binary files a/__pycache__/managed_auth.cpython-313.pyc and b/__pycache__/managed_auth.cpython-313.pyc differ
|
|
|
__pycache__/task_executor.cpython-313.pyc
CHANGED
|
Binary files a/__pycache__/task_executor.cpython-313.pyc and b/__pycache__/task_executor.cpython-313.pyc differ
|
|
|
app.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
# -*- coding: utf-8 -*-
|
| 2 |
"""SmartPaper R3-Free UI: Streamlit is an observer, never the paper executor."""
|
| 3 |
import hashlib
|
|
|
|
| 4 |
import json
|
| 5 |
import logging
|
| 6 |
import os
|
|
@@ -41,6 +42,9 @@ st.set_page_config(
|
|
| 41 |
|
| 42 |
WORKSPACE_DIR = APP_CONFIG.get("workspace_dir", ".workspace")
|
| 43 |
os.makedirs(WORKSPACE_DIR, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
| 44 |
os.environ["STREAMLIT_SERVER_ENABLE_CORS"] = "false"
|
| 45 |
os.environ["STREAMLIT_SERVER_ENABLE_XSRF_PROTECTION"] = "false"
|
| 46 |
|
|
@@ -301,6 +305,21 @@ def _task_dir(task_id: str) -> Path:
|
|
| 301 |
return Path(_user_workspace) / "tasks" / _safe_key(task_id)
|
| 302 |
|
| 303 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
def _task_output_path(task: dict) -> Path | None:
|
| 305 |
task_id = str(task.get("task_id") or "").strip()
|
| 306 |
filename = os.path.basename(str(task.get("output_filename") or "").strip())
|
|
@@ -356,6 +375,9 @@ def _persist_task_request(task: dict, mode: str, lang_code: str) -> dict:
|
|
| 356 |
"topic": str(st.session_state.get("sp_topic") or DEFAULT_TOPIC),
|
| 357 |
"keywords": str(st.session_state.get("sp_keywords") or DEFAULT_KEYWORDS),
|
| 358 |
"target_words": int(st.session_state.get("sp_words") or APP_CONFIG["target_words_default"]),
|
|
|
|
|
|
|
|
|
|
| 359 |
"reference_files": refs,
|
| 360 |
"data_files": data,
|
| 361 |
"interview_files": interviews,
|
|
@@ -612,10 +634,10 @@ def _render_monitor_box(task: dict, lang_code: str) -> None:
|
|
| 612 |
def _watch_task_status_live(task: dict) -> dict | None:
|
| 613 |
"""Start browser-only read polling for live UI state.
|
| 614 |
|
| 615 |
-
DOM updates remain read-only and never participate in task liveness.
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
"""
|
| 620 |
task_id = str(task.get("task_id") or "").strip()
|
| 621 |
status = str(task.get("status") or "")
|
|
@@ -633,6 +655,9 @@ def _watch_task_status_live(task: dict) -> dict | None:
|
|
| 633 |
payload={
|
| 634 |
"task_id": task_id,
|
| 635 |
"poll_interval_ms": 2000,
|
|
|
|
|
|
|
|
|
|
| 636 |
"initial_checkpoint_seq": int(task.get("checkpoint_seq") or 0),
|
| 637 |
},
|
| 638 |
key=f"sp_task_watch_{_safe_key(task_id)}",
|
|
@@ -641,8 +666,6 @@ def _watch_task_status_live(task: dict) -> dict | None:
|
|
| 641 |
if isinstance(boundary_task, dict) and str(boundary_task.get("task_id") or "") == task_id:
|
| 642 |
st.session_state["sp_generation_task"] = dict(boundary_task)
|
| 643 |
st.session_state["sp_last_ui_task_status"] = str(boundary_task.get("status") or "")
|
| 644 |
-
if str(watch_result.get("boundary_type") or "") == "checkpoint":
|
| 645 |
-
st.session_state["sp_scroll_to_generation_action"] = True
|
| 646 |
return dict(boundary_task)
|
| 647 |
except Exception as exc:
|
| 648 |
logging.warning("[SP_UI] live_watch_error task_id=%s error=%s", task_id, type(exc).__name__)
|
|
@@ -679,15 +702,21 @@ def _load_confirmed_checkpoint(task: dict) -> dict | None:
|
|
| 679 |
|
| 680 |
|
| 681 |
def _render_checkpoint_preview(task: dict, lang_code: str) -> None:
|
| 682 |
-
"""
|
| 683 |
checkpoint = _load_confirmed_checkpoint(task)
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
draft = str(checkpoint.get("full_draft_en") or "").strip()
|
| 687 |
-
if not draft:
|
| 688 |
-
return
|
| 689 |
st.markdown(tr(lang_code, "english_draft"))
|
| 690 |
-
st.markdown(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 691 |
|
| 692 |
|
| 693 |
def _render_task_status_once(lang_code: str) -> None:
|
|
@@ -759,11 +788,33 @@ def _inject_generation_action_scroll(mode: str) -> None:
|
|
| 759 |
|
| 760 |
|
| 761 |
def _scroll_to_generation_action_once() -> None:
|
| 762 |
-
"""One-time post-launch/resume
|
| 763 |
if not bool(st.session_state.get("sp_scroll_to_generation_action")):
|
| 764 |
return
|
| 765 |
st.session_state["sp_scroll_to_generation_action"] = False
|
| 766 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 767 |
|
| 768 |
|
| 769 |
def render_workspace_controls(user):
|
|
|
|
| 1 |
# -*- coding: utf-8 -*-
|
| 2 |
"""SmartPaper R3-Free UI: Streamlit is an observer, never the paper executor."""
|
| 3 |
import hashlib
|
| 4 |
+
import html
|
| 5 |
import json
|
| 6 |
import logging
|
| 7 |
import os
|
|
|
|
| 42 |
|
| 43 |
WORKSPACE_DIR = APP_CONFIG.get("workspace_dir", ".workspace")
|
| 44 |
os.makedirs(WORKSPACE_DIR, exist_ok=True)
|
| 45 |
+
# The display-only preview cache must exist from the first Streamlit script run.
|
| 46 |
+
# Runtime preview files remain ephemeral and carry no task/execution authority.
|
| 47 |
+
(Path(__file__).resolve().parent / "static" / "previews").mkdir(parents=True, exist_ok=True)
|
| 48 |
os.environ["STREAMLIT_SERVER_ENABLE_CORS"] = "false"
|
| 49 |
os.environ["STREAMLIT_SERVER_ENABLE_XSRF_PROTECTION"] = "false"
|
| 50 |
|
|
|
|
| 305 |
return Path(_user_workspace) / "tasks" / _safe_key(task_id)
|
| 306 |
|
| 307 |
|
| 308 |
+
def _task_preview_path(task_id: str) -> str:
|
| 309 |
+
"""Return the unguessable local static preview URL for this task, if any."""
|
| 310 |
+
request_path = _task_dir(task_id) / "request.json"
|
| 311 |
+
if not request_path.is_file():
|
| 312 |
+
return ""
|
| 313 |
+
try:
|
| 314 |
+
payload = json.loads(request_path.read_text(encoding="utf-8"))
|
| 315 |
+
token = str(payload.get("preview_token") or "").strip()
|
| 316 |
+
if not re.fullmatch(r"[A-Za-z0-9_-]{32,128}", token):
|
| 317 |
+
return ""
|
| 318 |
+
return f"/app/static/previews/{token}.json"
|
| 319 |
+
except Exception:
|
| 320 |
+
return ""
|
| 321 |
+
|
| 322 |
+
|
| 323 |
def _task_output_path(task: dict) -> Path | None:
|
| 324 |
task_id = str(task.get("task_id") or "").strip()
|
| 325 |
filename = os.path.basename(str(task.get("output_filename") or "").strip())
|
|
|
|
| 375 |
"topic": str(st.session_state.get("sp_topic") or DEFAULT_TOPIC),
|
| 376 |
"keywords": str(st.session_state.get("sp_keywords") or DEFAULT_KEYWORDS),
|
| 377 |
"target_words": int(st.session_state.get("sp_words") or APP_CONFIG["target_words_default"]),
|
| 378 |
+
# High-entropy, display-only token. It is part of the immutable task
|
| 379 |
+
# request/hash, but it grants no Worker/D1 authority.
|
| 380 |
+
"preview_token": secrets.token_urlsafe(32),
|
| 381 |
"reference_files": refs,
|
| 382 |
"data_files": data,
|
| 383 |
"interview_files": interviews,
|
|
|
|
| 634 |
def _watch_task_status_live(task: dict) -> dict | None:
|
| 635 |
"""Start browser-only read polling for live UI state.
|
| 636 |
|
| 637 |
+
DOM updates remain read-only and never participate in task liveness.
|
| 638 |
+
During execution, progress and manuscript preview updates stay entirely in
|
| 639 |
+
the browser; Streamlit is notified only once at terminal/resumable state.
|
| 640 |
+
This prevents checkpoint-driven full-page reruns and visible flicker.
|
| 641 |
"""
|
| 642 |
task_id = str(task.get("task_id") or "").strip()
|
| 643 |
status = str(task.get("status") or "")
|
|
|
|
| 655 |
payload={
|
| 656 |
"task_id": task_id,
|
| 657 |
"poll_interval_ms": 2000,
|
| 658 |
+
"preview_interval_ms": 700,
|
| 659 |
+
"preview_path": _task_preview_path(task_id),
|
| 660 |
+
"expected_execution_epoch": int(task.get("execution_epoch") or 0),
|
| 661 |
"initial_checkpoint_seq": int(task.get("checkpoint_seq") or 0),
|
| 662 |
},
|
| 663 |
key=f"sp_task_watch_{_safe_key(task_id)}",
|
|
|
|
| 666 |
if isinstance(boundary_task, dict) and str(boundary_task.get("task_id") or "") == task_id:
|
| 667 |
st.session_state["sp_generation_task"] = dict(boundary_task)
|
| 668 |
st.session_state["sp_last_ui_task_status"] = str(boundary_task.get("status") or "")
|
|
|
|
|
|
|
| 669 |
return dict(boundary_task)
|
| 670 |
except Exception as exc:
|
| 671 |
logging.warning("[SP_UI] live_watch_error task_id=%s error=%s", task_id, type(exc).__name__)
|
|
|
|
| 702 |
|
| 703 |
|
| 704 |
def _render_checkpoint_preview(task: dict, lang_code: str) -> None:
|
| 705 |
+
"""Render one stable DOM target; browser preview updates mutate it in place."""
|
| 706 |
checkpoint = _load_confirmed_checkpoint(task)
|
| 707 |
+
draft = str((checkpoint or {}).get("full_draft_en") or "").strip()
|
| 708 |
+
escaped = html.escape(draft)
|
|
|
|
|
|
|
|
|
|
| 709 |
st.markdown(tr(lang_code, "english_draft"))
|
| 710 |
+
st.markdown(
|
| 711 |
+
f"""
|
| 712 |
+
<div id='sp-live-manuscript-shell' style='border:1px solid #e5e7eb;border-radius:8px;padding:14px 16px;min-height:180px;max-height:68vh;overflow:auto;background:#ffffff;'>
|
| 713 |
+
<div id='sp-live-manuscript-preview' style='white-space:pre-wrap;overflow-wrap:anywhere;margin:0;font-family:inherit;line-height:1.72;color:#111827;'>{escaped}</div>
|
| 714 |
+
<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>
|
| 715 |
+
</div>
|
| 716 |
+
<style>@keyframes spBlink {{ 0%,49% {{opacity:1}} 50%,100% {{opacity:0}} }}</style>
|
| 717 |
+
""",
|
| 718 |
+
unsafe_allow_html=True,
|
| 719 |
+
)
|
| 720 |
|
| 721 |
|
| 722 |
def _render_task_status_once(lang_code: str) -> None:
|
|
|
|
| 788 |
|
| 789 |
|
| 790 |
def _scroll_to_generation_action_once() -> None:
|
| 791 |
+
"""One-time post-launch/resume scroll to the live manuscript, never per checkpoint."""
|
| 792 |
if not bool(st.session_state.get("sp_scroll_to_generation_action")):
|
| 793 |
return
|
| 794 |
st.session_state["sp_scroll_to_generation_action"] = False
|
| 795 |
+
mode = str(st.session_state.get("run_mode") or "amj")
|
| 796 |
+
fallback_id = "sp-generation-action-asq" if mode.lower() == "asq" else "sp-generation-action-amj"
|
| 797 |
+
components.html(
|
| 798 |
+
f"""
|
| 799 |
+
<script>
|
| 800 |
+
(() => {{
|
| 801 |
+
const parentDocument = window.parent.document;
|
| 802 |
+
const fallbackId = {json.dumps(fallback_id)};
|
| 803 |
+
let done = false;
|
| 804 |
+
const locate = () => {{
|
| 805 |
+
if (done) return;
|
| 806 |
+
const target = parentDocument.getElementById('sp-live-manuscript-shell') || parentDocument.getElementById(fallbackId);
|
| 807 |
+
if (!target) return;
|
| 808 |
+
done = true;
|
| 809 |
+
target.scrollIntoView({{behavior:'auto', block:'center'}});
|
| 810 |
+
}};
|
| 811 |
+
[0, 40, 100, 220, 450, 800].forEach((delay) => window.parent.setTimeout(locate, delay));
|
| 812 |
+
}})();
|
| 813 |
+
</script>
|
| 814 |
+
""",
|
| 815 |
+
height=0,
|
| 816 |
+
width=0,
|
| 817 |
+
)
|
| 818 |
|
| 819 |
|
| 820 |
def render_workspace_controls(user):
|
config.py
CHANGED
|
@@ -201,13 +201,13 @@ def load_config():
|
|
| 201 |
{
|
| 202 |
"section_id": "introduction",
|
| 203 |
"section": "Introduction",
|
| 204 |
-
"weight": 0.
|
| 205 |
"subsections": [],
|
| 206 |
},
|
| 207 |
{
|
| 208 |
"section_id": "theory",
|
| 209 |
"section": "Theoretical Background and Hypotheses",
|
| 210 |
-
"weight": 0.
|
| 211 |
"subsections": [
|
| 212 |
{"subsection_id": "conceptual_background", "title": "Conceptual Background and Research Gap", "share": 0.23},
|
| 213 |
{"subsection_id": "theoretical_mechanisms", "title": "Theoretical Mechanisms", "share": 0.27},
|
|
@@ -218,7 +218,7 @@ def load_config():
|
|
| 218 |
{
|
| 219 |
"section_id": "methodology",
|
| 220 |
"section": "Methodology",
|
| 221 |
-
"weight": 0.
|
| 222 |
"subsections": [
|
| 223 |
{"subsection_id": "research_design", "title": "Research Design and Context", "share": 0.18},
|
| 224 |
{"subsection_id": "sample_data", "title": "Sample and Data Collection", "share": 0.20},
|
|
@@ -230,7 +230,7 @@ def load_config():
|
|
| 230 |
{
|
| 231 |
"section_id": "results",
|
| 232 |
"section": "Results",
|
| 233 |
-
"weight": 0.
|
| 234 |
"subsections": [
|
| 235 |
{"subsection_id": "measurement_results", "title": "Descriptive Statistics and Measurement Assessment", "share": 0.34},
|
| 236 |
{"subsection_id": "hypothesis_results", "title": "Hypothesis and Association Tests", "share": 0.46},
|
|
@@ -240,7 +240,7 @@ def load_config():
|
|
| 240 |
{
|
| 241 |
"section_id": "discussion",
|
| 242 |
"section": "Discussion",
|
| 243 |
-
"weight": 0.
|
| 244 |
"subsections": [
|
| 245 |
{"subsection_id": "interpretation", "title": "Interpretation of the Findings", "share": 0.30},
|
| 246 |
{"subsection_id": "theoretical_contributions", "title": "Theoretical Contributions", "share": 0.30},
|
|
@@ -251,7 +251,7 @@ def load_config():
|
|
| 251 |
{
|
| 252 |
"section_id": "conclusion",
|
| 253 |
"section": "Conclusion",
|
| 254 |
-
"weight": 0.
|
| 255 |
"subsections": [],
|
| 256 |
},
|
| 257 |
],
|
|
@@ -265,7 +265,7 @@ def load_config():
|
|
| 265 |
{
|
| 266 |
"section_id": "theoretical_framing",
|
| 267 |
"section": "Theoretical Framing",
|
| 268 |
-
"weight": 0.
|
| 269 |
"subsections": [
|
| 270 |
{"subsection_id": "phenomenon_puzzle", "title": "Phenomenon and Theoretical Puzzle", "share": 0.35},
|
| 271 |
{"subsection_id": "conceptual_tension", "title": "Relevant Literature and Conceptual Tension", "share": 0.40},
|
|
@@ -275,7 +275,7 @@ def load_config():
|
|
| 275 |
{
|
| 276 |
"section_id": "methods",
|
| 277 |
"section": "Research Setting and Methods",
|
| 278 |
-
"weight": 0.
|
| 279 |
"subsections": [
|
| 280 |
{"subsection_id": "research_setting", "title": "Research Setting", "share": 0.24},
|
| 281 |
{"subsection_id": "data_collection", "title": "Data Sources and Collection", "share": 0.28},
|
|
@@ -286,7 +286,7 @@ def load_config():
|
|
| 286 |
{
|
| 287 |
"section_id": "findings",
|
| 288 |
"section": "Findings",
|
| 289 |
-
"weight": 0.
|
| 290 |
"dynamic_subsections": "qualitative_themes",
|
| 291 |
"subsections": [
|
| 292 |
{"subsection_id": "emergent_model", "title": "Emergent Process Model", "share": 0.18},
|
|
@@ -295,7 +295,7 @@ def load_config():
|
|
| 295 |
{
|
| 296 |
"section_id": "discussion",
|
| 297 |
"section": "Discussion",
|
| 298 |
-
"weight": 0.
|
| 299 |
"subsections": [
|
| 300 |
{"subsection_id": "interpretation", "title": "Interpretation of the Emergent Findings", "share": 0.30},
|
| 301 |
{"subsection_id": "theoretical_contributions", "title": "Theoretical Contributions", "share": 0.30},
|
|
@@ -306,24 +306,67 @@ def load_config():
|
|
| 306 |
{
|
| 307 |
"section_id": "conclusion",
|
| 308 |
"section": "Conclusion",
|
| 309 |
-
"weight": 0.
|
| 310 |
"subsections": [],
|
| 311 |
},
|
| 312 |
{
|
| 313 |
"section_id": "appendix",
|
| 314 |
"section": "Appendix: Data Structure and Evidence",
|
| 315 |
-
"weight": 0.
|
|
|
|
| 316 |
"subsections": [],
|
| 317 |
},
|
| 318 |
],
|
| 319 |
"manuscript_validation": {
|
| 320 |
-
"minimum_total_ratio": 0.
|
| 321 |
-
"maximum_total_ratio": 1.
|
| 322 |
"minimum_section_words": 120,
|
| 323 |
"minimum_conclusion_words": 120,
|
| 324 |
"minimum_qualitative_themes": 3,
|
| 325 |
"minimum_qualitative_quotes": 8,
|
| 326 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
|
| 328 |
"prompt_templates": {
|
| 329 |
"meta_extract": """You are an expert academic data extractor. Read the provided text and extract the core metadata.
|
|
@@ -343,7 +386,7 @@ CRITICAL: You MUST output ONLY a valid JSON object. Do not include markdown form
|
|
| 343 |
"context": "<String: Max 10 words, e.g., 'US Tech Industry'>",
|
| 344 |
"analysis_tool": "<String: e.g., 'SEM, PLS, OLS'>"
|
| 345 |
},
|
| 346 |
-
"key_findings": "<String: Strict ONE-SENTENCE summary
|
| 347 |
"future_gaps": "<String: Explicit limitations or future directions>"
|
| 348 |
}""",
|
| 349 |
"synthesis_system": "You are a top-tier academic researcher in Business Administration.",
|
|
@@ -367,7 +410,7 @@ Literature Metadata:
|
|
| 367 |
# 定量引擎 (AMJ 风格) - 保持原样,极致严谨与假设推演
|
| 368 |
# =====================================================================
|
| 369 |
amj_prompts = {
|
| 370 |
-
"sys_academic_writer": """You are a
|
| 371 |
[CRITICAL LANGUAGE MANDATE]: You MUST generate all responses strictly in ACADEMIC ENGLISH. Even if the user provides topic, data, or context in Chinese or another language, your final output MUST be in English.
|
| 372 |
[ABSOLUTE ANTI-AI-TONE MANDATES]:
|
| 373 |
1. CRITICAL: NEVER use these cliché AI words: delve into, intricate, paramount, overarching, pivotal, foster, seamlessly, multifaceted, beacon, landscape, underscore, robust.
|
|
@@ -375,7 +418,7 @@ Literature Metadata:
|
|
| 375 |
3. NATURAL SYNTAX: Vary sentence structure only where the argument requires it. Avoid artificial short-long sentence alternation and repetitive templates.
|
| 376 |
4. ACADEMIC HEDGING: Use strategic hedging (e.g., 'This suggests a tendency toward X, bounded by Y').
|
| 377 |
5. NO SWEEPING GENERALIZATIONS: Every sentence must carry specific theoretical weight or empirical fact.
|
| 378 |
-
6.
|
| 379 |
7. ACADEMIC REGISTER: Use concise, accurate, and rigorous language. Concepts must be explicit, hierarchy clear, and reasoning logically traceable.
|
| 380 |
8. PROHIBITED RHETORIC: Never use metaphors, analogies, rhetorical questions, literary embellishment, or promotional claims.
|
| 381 |
9. ABSTRACT DISCIPLINE: In abstracts, report only the minimum numerical evidence needed to convey the main finding; do not crowd the abstract with coefficients or test statistics.
|
|
@@ -384,26 +427,25 @@ Literature Metadata:
|
|
| 384 |
""",
|
| 385 |
"deep_refinement_directive": "Produce a final, polished section ready for top-tier submission. Ensure flawless logical flow without meta-commentary. 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 multiple sources support the same claim.\n[SELF-REFLECTION]: Before drafting, critically analyze common AI clichés related to this topic and actively suppress them in your output.",
|
| 386 |
"deep_synthesis_directive": "Structure this synthesis to expose a critical theoretical tension: Consensus vs Paradox, Competing Perspectives, Theoretical Blind Spot.",
|
| 387 |
-
"hypothesis_development_directive": "
|
| 388 |
-
"methodological_defense_directive": "
|
| 389 |
-
"discussion_depth_directive": "
|
| 390 |
-
"conclusion_anchor_directive": "
|
| 391 |
-
"anchor_generation_directive": "Generate a
|
| 392 |
-
"empirical_data_logic": "
|
| 393 |
}
|
| 394 |
|
| 395 |
# =====================================================================
|
| 396 |
# 定性引擎 (ASQ 风格) - 聚焦现象描述、意义建构与语录引用
|
| 397 |
# =====================================================================
|
| 398 |
asq_prompts = {
|
| 399 |
-
"sys_academic_writer": """You are a senior
|
| 400 |
-
Your writing style is phenomenon-driven, deeply narrative, and theoretically rich.
|
| 401 |
[CRITICAL LANGUAGE MANDATE]: You MUST generate all responses strictly in ACADEMIC ENGLISH.
|
| 402 |
[ABSOLUTE ASQ MANDATES]:
|
| 403 |
-
1. FOCUS ON THE PUZZLE:
|
| 404 |
-
2.
|
| 405 |
-
3.
|
| 406 |
-
4. NO FORMAL HYPOTHESES:
|
| 407 |
5. NO AI CLICHES: Strictly avoid 'delve', 'tapestry', 'multifaceted', 'beacon', 'landscape'.
|
| 408 |
6. ACADEMIC REGISTER: Use concise, accurate, and rigorous language with explicit concepts and logically ordered claims.
|
| 409 |
7. PROHIBITED RHETORIC: Never use metaphors, analogies, rhetorical questions, literary embellishment, or promotional claims. Use concrete evidence-based description rather than decorative prose.
|
|
@@ -412,11 +454,11 @@ Your writing style is phenomenon-driven, deeply narrative, and theoretically ric
|
|
| 412 |
"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.",
|
| 413 |
"deep_synthesis_directive": "Structure this synthesis to highlight a critical empirical puzzle or theoretical paradox that qualitative investigation can unravel.",
|
| 414 |
"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.",
|
| 415 |
-
"methodological_defense_directive": "
|
| 416 |
-
"discussion_depth_directive": "
|
| 417 |
-
"conclusion_anchor_directive": "
|
| 418 |
-
"anchor_generation_directive": "Generate a
|
| 419 |
-
"empirical_data_logic": "Present findings
|
| 420 |
}
|
| 421 |
|
| 422 |
generic_prompts = amj_prompts.copy()
|
|
|
|
| 201 |
{
|
| 202 |
"section_id": "introduction",
|
| 203 |
"section": "Introduction",
|
| 204 |
+
"weight": 0.11,
|
| 205 |
"subsections": [],
|
| 206 |
},
|
| 207 |
{
|
| 208 |
"section_id": "theory",
|
| 209 |
"section": "Theoretical Background and Hypotheses",
|
| 210 |
+
"weight": 0.25,
|
| 211 |
"subsections": [
|
| 212 |
{"subsection_id": "conceptual_background", "title": "Conceptual Background and Research Gap", "share": 0.23},
|
| 213 |
{"subsection_id": "theoretical_mechanisms", "title": "Theoretical Mechanisms", "share": 0.27},
|
|
|
|
| 218 |
{
|
| 219 |
"section_id": "methodology",
|
| 220 |
"section": "Methodology",
|
| 221 |
+
"weight": 0.19,
|
| 222 |
"subsections": [
|
| 223 |
{"subsection_id": "research_design", "title": "Research Design and Context", "share": 0.18},
|
| 224 |
{"subsection_id": "sample_data", "title": "Sample and Data Collection", "share": 0.20},
|
|
|
|
| 230 |
{
|
| 231 |
"section_id": "results",
|
| 232 |
"section": "Results",
|
| 233 |
+
"weight": 0.17,
|
| 234 |
"subsections": [
|
| 235 |
{"subsection_id": "measurement_results", "title": "Descriptive Statistics and Measurement Assessment", "share": 0.34},
|
| 236 |
{"subsection_id": "hypothesis_results", "title": "Hypothesis and Association Tests", "share": 0.46},
|
|
|
|
| 240 |
{
|
| 241 |
"section_id": "discussion",
|
| 242 |
"section": "Discussion",
|
| 243 |
+
"weight": 0.24,
|
| 244 |
"subsections": [
|
| 245 |
{"subsection_id": "interpretation", "title": "Interpretation of the Findings", "share": 0.30},
|
| 246 |
{"subsection_id": "theoretical_contributions", "title": "Theoretical Contributions", "share": 0.30},
|
|
|
|
| 251 |
{
|
| 252 |
"section_id": "conclusion",
|
| 253 |
"section": "Conclusion",
|
| 254 |
+
"weight": 0.04,
|
| 255 |
"subsections": [],
|
| 256 |
},
|
| 257 |
],
|
|
|
|
| 265 |
{
|
| 266 |
"section_id": "theoretical_framing",
|
| 267 |
"section": "Theoretical Framing",
|
| 268 |
+
"weight": 0.15,
|
| 269 |
"subsections": [
|
| 270 |
{"subsection_id": "phenomenon_puzzle", "title": "Phenomenon and Theoretical Puzzle", "share": 0.35},
|
| 271 |
{"subsection_id": "conceptual_tension", "title": "Relevant Literature and Conceptual Tension", "share": 0.40},
|
|
|
|
| 275 |
{
|
| 276 |
"section_id": "methods",
|
| 277 |
"section": "Research Setting and Methods",
|
| 278 |
+
"weight": 0.16,
|
| 279 |
"subsections": [
|
| 280 |
{"subsection_id": "research_setting", "title": "Research Setting", "share": 0.24},
|
| 281 |
{"subsection_id": "data_collection", "title": "Data Sources and Collection", "share": 0.28},
|
|
|
|
| 286 |
{
|
| 287 |
"section_id": "findings",
|
| 288 |
"section": "Findings",
|
| 289 |
+
"weight": 0.34,
|
| 290 |
"dynamic_subsections": "qualitative_themes",
|
| 291 |
"subsections": [
|
| 292 |
{"subsection_id": "emergent_model", "title": "Emergent Process Model", "share": 0.18},
|
|
|
|
| 295 |
{
|
| 296 |
"section_id": "discussion",
|
| 297 |
"section": "Discussion",
|
| 298 |
+
"weight": 0.22,
|
| 299 |
"subsections": [
|
| 300 |
{"subsection_id": "interpretation", "title": "Interpretation of the Emergent Findings", "share": 0.30},
|
| 301 |
{"subsection_id": "theoretical_contributions", "title": "Theoretical Contributions", "share": 0.30},
|
|
|
|
| 306 |
{
|
| 307 |
"section_id": "conclusion",
|
| 308 |
"section": "Conclusion",
|
| 309 |
+
"weight": 0.03,
|
| 310 |
"subsections": [],
|
| 311 |
},
|
| 312 |
{
|
| 313 |
"section_id": "appendix",
|
| 314 |
"section": "Appendix: Data Structure and Evidence",
|
| 315 |
+
"weight": 0.0,
|
| 316 |
+
"exclude_from_word_budget": True,
|
| 317 |
"subsections": [],
|
| 318 |
},
|
| 319 |
],
|
| 320 |
"manuscript_validation": {
|
| 321 |
+
"minimum_total_ratio": 0.97,
|
| 322 |
+
"maximum_total_ratio": 1.03,
|
| 323 |
"minimum_section_words": 120,
|
| 324 |
"minimum_conclusion_words": 120,
|
| 325 |
"minimum_qualitative_themes": 3,
|
| 326 |
"minimum_qualitative_quotes": 8,
|
| 327 |
},
|
| 328 |
+
"word_budget": {
|
| 329 |
+
"front_matter_words": 220,
|
| 330 |
+
"target_min_ratio": 0.97,
|
| 331 |
+
"target_max_ratio": 1.03,
|
| 332 |
+
"section_min_ratio": 0.92,
|
| 333 |
+
"section_max_ratio": 1.08,
|
| 334 |
+
},
|
| 335 |
+
"evidence_integrity_contract": """[EVIDENCE & INTEGRITY CONTRACT]
|
| 336 |
+
1. Treat the frozen research evidence packet as the only source of truth for the study setting, sample, measures, qualitative material, analysis, numerical results, and empirical findings.
|
| 337 |
+
2. Retrieved literature may support external theoretical or methodological claims, but it must never be used as evidence for this study's own sample, coefficients, events, quotations, or findings.
|
| 338 |
+
3. Do not invent a country, organization, university, sample characteristic, instrument, software package, coefficient, p-value, confidence interval, R-squared, fit index, robustness test, interview count, quotation, event, or date that is absent from the frozen evidence.
|
| 339 |
+
4. Theoretical inference is allowed only when it is explicitly framed as interpretation rather than as a new empirical fact.
|
| 340 |
+
5. Causal language must match the design. Association, mediation estimates, temporal process evidence, and experimental identification are not interchangeable.
|
| 341 |
+
6. If evidence is absent, narrow the claim instead of filling the gap with a plausible detail.
|
| 342 |
+
""",
|
| 343 |
+
"section_reasoning_profiles": {
|
| 344 |
+
"amj": {
|
| 345 |
+
"introduction": "Move from an important management problem to a precise theoretical tension, show why the prevailing explanation is incomplete, state the research move, and preview the bounded contribution. Do not front-load results or generic importance claims.",
|
| 346 |
+
"theory": "Define constructs precisely, develop the mechanism step by step, identify boundaries only when supported, and derive hypotheses from the mechanism. Each paragraph must advance one theoretical job.",
|
| 347 |
+
"methodology": "Report frozen design facts, justify actual analytical choices, identify validity threats, and state only mitigations or robustness procedures that are present in the evidence packet.",
|
| 348 |
+
"results": "Report this study's empirical facts without literature decoration. State uncertainty and hypothesis implications. Do not interpret an association as causal identification.",
|
| 349 |
+
"discussion": "Start from a validated finding, compare it with the prior theoretical expectation, explain the mechanism or boundary that accounts for the pattern, and state the specific theoretical revision. Separate theoretical and practical implications.",
|
| 350 |
+
"conclusion": "Answer the research question and summarize the bounded theoretical change using only conclusions already established in Results and Discussion. Introduce no new evidence.",
|
| 351 |
+
},
|
| 352 |
+
"asq": {
|
| 353 |
+
"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.",
|
| 354 |
+
"theoretical_framing": "Use prior theory as sensitizing concepts and a source of tension. Define what remains unexplained without forcing hypotheses or predicting the findings.",
|
| 355 |
+
"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.",
|
| 356 |
+
"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.",
|
| 357 |
+
"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.",
|
| 358 |
+
"conclusion": "Resolve the organizational puzzle and state the bounded theoretical contribution without introducing new data, quotations, or claims.",
|
| 359 |
+
"appendix": "Present the frozen data structure and evidence compactly. Do not use the appendix to add new claims to the manuscript argument.",
|
| 360 |
+
},
|
| 361 |
+
},
|
| 362 |
+
"journal_voice_profiles": {
|
| 363 |
+
"amj": "Use direct, theoretically dense, broadly intelligible management scholarship. Prefer precise verbs and explicit mechanisms over evaluative adjectives. Avoid contribution-counting templates, generic topic sentences, and repeated summaries.",
|
| 364 |
+
"asq": "Use coherent phenomenon-driven prose with concrete evidence, temporal continuity, and theoretical depth. Preserve one central argument; avoid formulaic contribution lists, decorative narration, and generic sensemaking language unsupported by the data.",
|
| 365 |
+
},
|
| 366 |
+
"top_journal_critic_profiles": {
|
| 367 |
+
"amj": ["management_problem", "theoretical_tension", "mechanism", "hypothesis_logic", "empirical_fidelity", "theoretical_contribution", "evidence_strength_wording", "citation_support", "concision"],
|
| 368 |
+
"asq": ["organizational_puzzle", "evidence_richness", "process_logic", "negative_or_deviant_evidence", "mechanism", "theory_data_fit", "theoretical_revision", "coherence"],
|
| 369 |
+
},
|
| 370 |
|
| 371 |
"prompt_templates": {
|
| 372 |
"meta_extract": """You are an expert academic data extractor. Read the provided text and extract the core metadata.
|
|
|
|
| 386 |
"context": "<String: Max 10 words, e.g., 'US Tech Industry'>",
|
| 387 |
"analysis_tool": "<String: e.g., 'SEM, PLS, OLS'>"
|
| 388 |
},
|
| 389 |
+
"key_findings": "<String: Strict ONE-SENTENCE summary using causal language only when the design identifies causality>",
|
| 390 |
"future_gaps": "<String: Explicit limitations or future directions>"
|
| 391 |
}""",
|
| 392 |
"synthesis_system": "You are a top-tier academic researcher in Business Administration.",
|
|
|
|
| 410 |
# 定量引擎 (AMJ 风格) - 保持原样,极致严谨与假设推演
|
| 411 |
# =====================================================================
|
| 412 |
amj_prompts = {
|
| 413 |
+
"sys_academic_writer": """You are a senior management scholar preparing an AMJ-oriented quantitative manuscript. Prioritize theoretical importance, mechanism precision, empirical fidelity, and a clear bounded contribution over status language or stylistic display.
|
| 414 |
[CRITICAL LANGUAGE MANDATE]: You MUST generate all responses strictly in ACADEMIC ENGLISH. Even if the user provides topic, data, or context in Chinese or another language, your final output MUST be in English.
|
| 415 |
[ABSOLUTE ANTI-AI-TONE MANDATES]:
|
| 416 |
1. CRITICAL: NEVER use these cliché AI words: delve into, intricate, paramount, overarching, pivotal, foster, seamlessly, multifaceted, beacon, landscape, underscore, robust.
|
|
|
|
| 418 |
3. NATURAL SYNTAX: Vary sentence structure only where the argument requires it. Avoid artificial short-long sentence alternation and repetitive templates.
|
| 419 |
4. ACADEMIC HEDGING: Use strategic hedging (e.g., 'This suggests a tendency toward X, bounded by Y').
|
| 420 |
5. NO SWEEPING GENERALIZATIONS: Every sentence must carry specific theoretical weight or empirical fact.
|
| 421 |
+
6. MECHANISM WITHOUT OVERCLAIMING: Articulate the theoretical mechanism precisely, but use causal wording only when the research design and frozen evidence justify causal identification.
|
| 422 |
7. ACADEMIC REGISTER: Use concise, accurate, and rigorous language. Concepts must be explicit, hierarchy clear, and reasoning logically traceable.
|
| 423 |
8. PROHIBITED RHETORIC: Never use metaphors, analogies, rhetorical questions, literary embellishment, or promotional claims.
|
| 424 |
9. ABSTRACT DISCIPLINE: In abstracts, report only the minimum numerical evidence needed to convey the main finding; do not crowd the abstract with coefficients or test statistics.
|
|
|
|
| 427 |
""",
|
| 428 |
"deep_refinement_directive": "Produce a final, polished section ready for top-tier submission. Ensure flawless logical flow without meta-commentary. 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 multiple sources support the same claim.\n[SELF-REFLECTION]: Before drafting, critically analyze common AI clichés related to this topic and actively suppress them in your output.",
|
| 429 |
"deep_synthesis_directive": "Structure this synthesis to expose a critical theoretical tension: Consensus vs Paradox, Competing Perspectives, Theoretical Blind Spot.",
|
| 430 |
+
"hypothesis_development_directive": "Develop only hypotheses justified by the frozen research design: define the focal constructs, establish the theoretical mechanism, state relevant boundary conditions only when supported, and phrase directional hypotheses without claiming causal identification beyond the design.",
|
| 431 |
+
"methodological_defense_directive": "Use defensive methodological precision. Report only frozen design facts; justify the analytical choices actually present; distinguish validity threats from mitigations; do not invent endogeneity tests, robustness checks, software, sample details, or statistics.",
|
| 432 |
+
"discussion_depth_directive": "Interpret the validated findings against prior theoretical expectations. Explain why the pattern matters, what mechanism or boundary it clarifies, and exactly which part of prior theory is extended, refined, bounded, or challenged. Do not manufacture counter-intuitive implications.",
|
| 433 |
+
"conclusion_anchor_directive": "Derive the conclusion only after the validated Results and Discussion. Restate the research answer, the bounded theoretical change, and the practical implication without introducing new evidence.",
|
| 434 |
+
"anchor_generation_directive": "Generate a PROVISIONAL RESEARCH LOGIC CONTRACT, not an expected result or conclusion. Identify the management problem, theoretical tension, research move, evidence boundaries, and questions the study may answer. Do not predict findings. Output only the contract.",
|
| 435 |
+
"empirical_data_logic": "Report only empirical values that appear in the frozen evidence packet. Preserve every coefficient, p-value, confidence interval, degree of freedom, effect size, sample count, and model statistic exactly. Never create a value because it would be statistically plausible.",
|
| 436 |
}
|
| 437 |
|
| 438 |
# =====================================================================
|
| 439 |
# 定性引擎 (ASQ 风格) - 聚焦现象描述、意义建构与语录引用
|
| 440 |
# =====================================================================
|
| 441 |
asq_prompts = {
|
| 442 |
+
"sys_academic_writer": """You are a senior qualitative management scholar preparing an ASQ-oriented manuscript. The manuscript must be phenomenon-driven, evidence-rich, theoretically consequential, and organized around one central organizational puzzle rather than a generic topic.
|
|
|
|
| 443 |
[CRITICAL LANGUAGE MANDATE]: You MUST generate all responses strictly in ACADEMIC ENGLISH.
|
| 444 |
[ABSOLUTE ASQ MANDATES]:
|
| 445 |
+
1. FOCUS ON THE PUZZLE: Identify the organizational puzzle or theoretical tension that is actually supported by the frozen evidence. Do not manufacture a paradox merely to make the paper sound surprising.
|
| 446 |
+
2. EVIDENCE-RICH DESCRIPTION: Ground claims in concrete setting, event, actor, quotation, comparison, and temporal detail only when those elements exist in the frozen evidence. Do not add decorative or invented description.
|
| 447 |
+
3. ACTOR/PROCESS LOGIC: Follow interpretation, action, coordination, sequence, comparison, or change as the selected analytic design and evidence warrant. Do not force a sensemaking lens onto every qualitative study.
|
| 448 |
+
4. NO FORMAL HYPOTHESES: Do not use formal hypothesis testing. Use a research question, process/model explanation, or propositions only when the selected qualitative design and evidence warrant them.
|
| 449 |
5. NO AI CLICHES: Strictly avoid 'delve', 'tapestry', 'multifaceted', 'beacon', 'landscape'.
|
| 450 |
6. ACADEMIC REGISTER: Use concise, accurate, and rigorous language with explicit concepts and logically ordered claims.
|
| 451 |
7. PROHIBITED RHETORIC: Never use metaphors, analogies, rhetorical questions, literary embellishment, or promotional claims. Use concrete evidence-based description rather than decorative prose.
|
|
|
|
| 454 |
"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.",
|
| 455 |
"deep_synthesis_directive": "Structure this synthesis to highlight a critical empirical puzzle or theoretical paradox that qualitative investigation can unravel.",
|
| 456 |
"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.",
|
| 457 |
+
"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.",
|
| 458 |
+
"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.",
|
| 459 |
+
"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.",
|
| 460 |
+
"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.",
|
| 461 |
+
"empirical_data_logic": "Present findings through the selected qualitative analytic design. Integrate frozen representative quotations and other evidence where they directly substantiate a pattern, process, comparison, or mechanism. Do not impose Gioia-style first-order/second-order terminology unless that analytic design was explicitly selected.",
|
| 462 |
}
|
| 463 |
|
| 464 |
generic_prompts = amj_prompts.copy()
|
core/__pycache__/checkpoint_store.cpython-313.pyc
CHANGED
|
Binary files a/core/__pycache__/checkpoint_store.cpython-313.pyc and b/core/__pycache__/checkpoint_store.cpython-313.pyc differ
|
|
|
core/__pycache__/generation_runner.cpython-313.pyc
CHANGED
|
Binary files a/core/__pycache__/generation_runner.cpython-313.pyc and b/core/__pycache__/generation_runner.cpython-313.pyc differ
|
|
|
core/__pycache__/meta_extractor.cpython-313.pyc
CHANGED
|
Binary files a/core/__pycache__/meta_extractor.cpython-313.pyc and b/core/__pycache__/meta_extractor.cpython-313.pyc differ
|
|
|
core/__pycache__/pipeline_agent.cpython-313.pyc
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:bb26d239e31ae5bca0996c1f27abaa2459fd38da852e452f089e784f3db5d269
|
| 3 |
+
size 169473
|
core/__pycache__/production_guard.cpython-313.pyc
CHANGED
|
Binary files a/core/__pycache__/production_guard.cpython-313.pyc and b/core/__pycache__/production_guard.cpython-313.pyc differ
|
|
|
core/__pycache__/qualitative_engine.cpython-313.pyc
CHANGED
|
Binary files a/core/__pycache__/qualitative_engine.cpython-313.pyc and b/core/__pycache__/qualitative_engine.cpython-313.pyc differ
|
|
|
core/__pycache__/quantitative_engine.cpython-313.pyc
CHANGED
|
Binary files a/core/__pycache__/quantitative_engine.cpython-313.pyc and b/core/__pycache__/quantitative_engine.cpython-313.pyc differ
|
|
|
core/__pycache__/rag_engine.cpython-313.pyc
CHANGED
|
Binary files a/core/__pycache__/rag_engine.cpython-313.pyc and b/core/__pycache__/rag_engine.cpython-313.pyc differ
|
|
|
core/generation_runner.py
CHANGED
|
@@ -13,6 +13,7 @@ import logging
|
|
| 13 |
import math
|
| 14 |
import os
|
| 15 |
import re
|
|
|
|
| 16 |
from datetime import datetime, timezone, timedelta
|
| 17 |
from pathlib import Path
|
| 18 |
from typing import Callable, Any
|
|
@@ -30,7 +31,8 @@ from utils.file_parser import extract_text_from_files
|
|
| 30 |
ProgressCallback = Callable[..., None]
|
| 31 |
|
| 32 |
MIN_FINAL_REFERENCES = 50
|
| 33 |
-
|
|
|
|
| 34 |
|
| 35 |
|
| 36 |
def _clean_trailing_references(text, keywords=("reference", "references", "reference list", "参考文献")):
|
|
@@ -76,6 +78,23 @@ class GenerationRunner:
|
|
| 76 |
self.output_dir = self.task_dir / "outputs"
|
| 77 |
self.output_dir.mkdir(parents=True, exist_ok=True)
|
| 78 |
self.agent = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
def _compute_request_counts(self) -> tuple[int, int, int]:
|
| 81 |
"""Return UI metrics: DeepSeek requests and eligible literature results."""
|
|
@@ -108,8 +127,45 @@ class GenerationRunner:
|
|
| 108 |
self._progress(pct, normalized_stage, checkpoint_seq=seq, checkpoint_stage=normalized_stage)
|
| 109 |
|
| 110 |
@staticmethod
|
| 111 |
-
def
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
|
| 114 |
@staticmethod
|
| 115 |
def _bounded_target(base_target: int, accumulated_deviation: float, remaining_including_current: int, safe_chunk: int) -> int:
|
|
@@ -122,13 +178,14 @@ class GenerationRunner:
|
|
| 122 |
@staticmethod
|
| 123 |
def _english_count_for_target(manuscript: str) -> int:
|
| 124 |
body = re.split(r"(?m)^#\s+References\s*$", str(manuscript or ""), maxsplit=1)[0]
|
|
|
|
| 125 |
# Markdown punctuation is ignored; contractions/hyphenated terms stay one token.
|
| 126 |
return len(re.findall(r"\b[A-Za-z0-9]+(?:[-'][A-Za-z0-9]+)*\b", body))
|
| 127 |
|
| 128 |
def _validate_target_length(self, manuscript: str, target_words: int) -> None:
|
| 129 |
quality = APP_CONFIG.get("manuscript_validation", {})
|
| 130 |
-
minimum = int(target_words * float(quality.get("minimum_total_ratio", 0.
|
| 131 |
-
maximum = int(target_words * float(quality.get("maximum_total_ratio", 1.
|
| 132 |
actual = self._english_count_for_target(manuscript)
|
| 133 |
if actual < minimum or actual > maximum:
|
| 134 |
raise RuntimeError(
|
|
@@ -191,6 +248,8 @@ class GenerationRunner:
|
|
| 191 |
data_paths = self._resolve_paths(req.get("data_files") or [])
|
| 192 |
interview_paths = self._resolve_paths(req.get("interview_files") or [])
|
| 193 |
|
|
|
|
|
|
|
| 194 |
# Restore agent state before continuing any dependent step.
|
| 195 |
if isinstance(state.get("agent_state"), dict):
|
| 196 |
agent.load_state(state["agent_state"])
|
|
@@ -207,7 +266,9 @@ class GenerationRunner:
|
|
| 207 |
agent.global_memory["core_golden_metadata"] = extract_golden_metadata(agent, all_paper_texts)
|
| 208 |
if mode == "amj":
|
| 209 |
data_file = data_paths[0] if data_paths else None
|
| 210 |
-
_, user_data_md, _, data_metadata = quant_engine.resolve_empirical_data(
|
|
|
|
|
|
|
| 211 |
else:
|
| 212 |
raw_interviews = extract_text_from_files(interview_paths[:1]) if interview_paths else ""
|
| 213 |
user_data_md, _, data_metadata = qual_engine.resolve_qualitative_data(topic, keywords, raw_interviews)
|
|
@@ -234,7 +295,7 @@ class GenerationRunner:
|
|
| 234 |
refs_text = agent.fetch_references_parallel(
|
| 235 |
topic,
|
| 236 |
keywords,
|
| 237 |
-
min_required=
|
| 238 |
progress_callback=lambda: self._progress(9, "S2 + OpenAlex"),
|
| 239 |
)
|
| 240 |
if len(agent.citation_map) < MIN_FINAL_REFERENCES:
|
|
@@ -265,42 +326,54 @@ class GenerationRunner:
|
|
| 265 |
|
| 266 |
adaptive_structure = state.get("adaptive_structure")
|
| 267 |
if not isinstance(adaptive_structure, list) or not adaptive_structure:
|
| 268 |
-
self._progress(14, "
|
| 269 |
-
|
| 270 |
-
agent.
|
| 271 |
topic, keywords, synthesis_result, refs_text,
|
| 272 |
data_evidence=user_data_md, mode=mode,
|
| 273 |
)
|
| 274 |
)
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
r"-+\s*CONCLUSION\s*-+\s*(.*?)$", anchor_clean, re.DOTALL | re.IGNORECASE,
|
| 282 |
-
)
|
| 283 |
-
abstract_anchor = abstract_match.group(1).strip() if abstract_match else anchor_clean
|
| 284 |
-
conclusion_anchor = conclusion_match.group(1).strip() if conclusion_match else ""
|
| 285 |
-
agent.global_memory["abstract_anchor"] = abstract_anchor
|
| 286 |
-
agent.global_memory["conclusion_anchor"] = conclusion_anchor
|
| 287 |
|
| 288 |
plan = agent.generate_outline(topic, keywords, mode=mode, data_metadata=data_metadata)
|
| 289 |
en_topic = str(plan.get("en_topic", topic))
|
| 290 |
structure = plan.get("structure", [])
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
|
|
|
| 296 |
for task in adaptive_structure:
|
| 297 |
task["is_first"] = bool(task.get("section_first"))
|
| 298 |
jit_texts = agent.prefetch_jit_literature(topic, keywords, adaptive_structure)
|
| 299 |
if jit_texts:
|
| 300 |
rag_engine._initialize_chunks([jit_texts])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
state.update(
|
| 302 |
{
|
| 303 |
"adaptive_structure": adaptive_structure,
|
|
|
|
|
|
|
|
|
|
| 304 |
"full_draft_en": full_draft_en,
|
| 305 |
"full_draft_translated": "",
|
| 306 |
"completed_sections": [],
|
|
@@ -310,6 +383,7 @@ class GenerationRunner:
|
|
| 310 |
"outline_ready": True,
|
| 311 |
}
|
| 312 |
)
|
|
|
|
| 313 |
self._checkpoint("outline_ready", state, 15)
|
| 314 |
else:
|
| 315 |
full_draft_en = str(state.get("full_draft_en") or "")
|
|
@@ -319,22 +393,15 @@ class GenerationRunner:
|
|
| 319 |
completed_sections = list(state.get("completed_sections") or [])
|
| 320 |
context_tail = str(state.get("context_tail") or "")
|
| 321 |
accumulated_deviation = float(state.get("accumulated_deviation") or 0.0)
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
current_cite_idx = 0
|
| 330 |
-
for idx, item in enumerate(valid_chunks):
|
| 331 |
-
count = max(1, math.ceil(len(coverage_cite_keys) / max(1, len(valid_chunks))))
|
| 332 |
-
allocation_map[item["display_name"]] = (
|
| 333 |
-
coverage_cite_keys[current_cite_idx:]
|
| 334 |
-
if idx == len(valid_chunks) - 1
|
| 335 |
-
else coverage_cite_keys[current_cite_idx:current_cite_idx + count]
|
| 336 |
)
|
| 337 |
-
|
| 338 |
|
| 339 |
total_steps = len(adaptive_structure)
|
| 340 |
for sec in adaptive_structure:
|
|
@@ -353,16 +420,35 @@ class GenerationRunner:
|
|
| 353 |
word in sec_title.lower()
|
| 354 |
for word in ["abstract", "keyword", "conclusion", "references", "appendix"]
|
| 355 |
)
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
"Use the assigned candidate tags across distinct supported claims whenever their evidence directly applies; "
|
| 363 |
-
"never stack sources or cite a source that does not support the sentence. "
|
| 364 |
-
f"Assigned candidate tags: {assigned_tags}. <AVAILABLE_CITATIONS>{refs_text}</AVAILABLE_CITATIONS>"
|
| 365 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 366 |
memory_context = agent._build_memory_context(sec_title, 2500)
|
| 367 |
memory_block = f"\n<GLOBAL_MEMORY>\n{memory_context}\n</GLOBAL_MEMORY>\n" if memory_context else ""
|
| 368 |
snippets = rag_engine.get_relevant_context(display_name) if rag_engine is not None else ""
|
|
@@ -382,38 +468,64 @@ class GenerationRunner:
|
|
| 382 |
"never use level-4 headings or numbered headings."
|
| 383 |
)
|
| 384 |
table_rule = (
|
| 385 |
-
"Present statistical evidence in standard Markdown tables with plain-text captions."
|
| 386 |
if sec["is_method_or_result"] and mode == "amj" else ""
|
| 387 |
)
|
| 388 |
concepts = ", ".join(sec.get("concepts") or [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
qualitative_rule = (
|
| 390 |
-
f"Assigned qualitative concepts: {concepts}.
|
| 391 |
-
"and do not repeat a
|
| 392 |
-
|
|
|
|
|
|
|
| 393 |
)
|
| 394 |
-
|
| 395 |
-
f"\n<
|
| 396 |
-
if user_data_md
|
| 397 |
)
|
| 398 |
completed_label = ", ".join(completed_sections)
|
| 399 |
anti_repeat = (
|
| 400 |
f"Already completed: {completed_label}. Do not restate their background, gap, or contribution."
|
| 401 |
if completed_label else ""
|
| 402 |
)
|
| 403 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
user_prompt = f"""Write the assigned English manuscript unit.
|
| 405 |
Section: {sec_title}
|
| 406 |
Subsection: {subsection or 'None'}
|
| 407 |
-
|
|
|
|
|
|
|
| 408 |
{heading_directive}
|
| 409 |
{citation_rule}
|
| 410 |
{table_rule}
|
| 411 |
{str(sec.get('required_content') or '')}
|
| 412 |
{qualitative_rule}
|
| 413 |
{anti_repeat}
|
|
|
|
|
|
|
|
|
|
| 414 |
{memory_block}
|
| 415 |
Literature synthesis: {synthesis_result}
|
| 416 |
-
|
|
|
|
|
|
|
| 417 |
Previous tail: {context_tail[-2500:]}
|
| 418 |
Output only final academic prose. Do not report the word count."""
|
| 419 |
if agent._count_tokens(sys_prompt) + agent._count_tokens(user_prompt) > 60000:
|
|
@@ -422,11 +534,18 @@ Output only final academic prose. Do not report the word count."""
|
|
| 422 |
current_index = len(completed_sections) + 1
|
| 423 |
pct = min(88, 15 + int((current_index / max(1, total_steps)) * 72))
|
| 424 |
self._progress(pct, display_name)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 425 |
en_raw = self._stream_text(
|
| 426 |
agent.stream_deepseek(
|
| 427 |
sys_prompt, user_prompt, max_tokens=7000,
|
| 428 |
model_role="writing", thinking=False, allow_fallback=False,
|
| 429 |
-
)
|
|
|
|
|
|
|
| 430 |
)
|
| 431 |
en_raw = re.sub(r"<think>.*?</think>", "", en_raw, flags=re.S).strip()
|
| 432 |
en_raw = re.sub(rf"(?im)^#\s*{re.escape(sec_title)}\s*\n+", "", en_raw).strip()
|
|
@@ -435,27 +554,62 @@ Output only final academic prose. Do not report the word count."""
|
|
| 435 |
en_raw = re.sub(r"(?m)^(#{2,3})\s+[\d\.\s一二三四五六七八九十、]+\s*(.*)", r"\1 \2", en_raw)
|
| 436 |
en_raw = re.sub(r"(?m)^#{4,}\s+(.*)", r"**\1**", en_raw)
|
| 437 |
en_raw = re.sub(r"(?m)^#\s+(.+)$", r"## \1", en_raw)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 438 |
if mode == "asq" and user_data_md and "appendix" not in sec_title.lower():
|
| 439 |
quote_dict = {tag: quote for quote, tag in re.findall(r'"([^"]+)"\s*\[(QUOTE-\d+)\]', user_data_md)}
|
| 440 |
for tag, quote in quote_dict.items():
|
| 441 |
en_raw = en_raw.replace(tag, f'"{quote}"')
|
| 442 |
-
en_raw = agent.polish_academic_section(en_raw, display_name, abstract=False)
|
| 443 |
if not is_quarantined and assigned_tag_list:
|
| 444 |
en_raw = agent.reinforce_claim_specific_citation_coverage(
|
| 445 |
en_raw, assigned_tag_list, section_name=display_name
|
| 446 |
)
|
| 447 |
-
|
| 448 |
-
if sec.get("section_first"):
|
| 449 |
-
prefix += f"# {sec_title}\n\n"
|
| 450 |
-
if sec.get("subsection_first") and subsection:
|
| 451 |
-
prefix += f"## {subsection}\n\n"
|
| 452 |
en_resolved = agent.audit_and_resolve_citations(
|
| 453 |
prefix + en_raw, section_name=display_name, quarantine=is_quarantined
|
| 454 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 455 |
full_draft_en += en_resolved + "\n\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 456 |
agent._extract_memory_from_text(en_raw, sec_title)
|
| 457 |
context_tail = " ".join(en_raw.split()[-200:])
|
| 458 |
-
actual_words =
|
| 459 |
accumulated_deviation += actual_words - adjusted_target
|
| 460 |
completed_sections.append(display_name)
|
| 461 |
state.update(
|
|
@@ -496,7 +650,17 @@ Output only final academic prose. Do not report the word count."""
|
|
| 496 |
f"{len(references)}/{MIN_FINAL_REFERENCES}."
|
| 497 |
)
|
| 498 |
full_draft_en = full_draft_en.rstrip() + "\n\n# References\n" + "\n\n".join(references)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 499 |
self._validate_target_length(full_draft_en, target_words)
|
|
|
|
| 500 |
state.update(
|
| 501 |
{
|
| 502 |
"full_draft_en": full_draft_en,
|
|
|
|
| 13 |
import math
|
| 14 |
import os
|
| 15 |
import re
|
| 16 |
+
import time
|
| 17 |
from datetime import datetime, timezone, timedelta
|
| 18 |
from pathlib import Path
|
| 19 |
from typing import Callable, Any
|
|
|
|
| 31 |
ProgressCallback = Callable[..., None]
|
| 32 |
|
| 33 |
MIN_FINAL_REFERENCES = 50
|
| 34 |
+
LITERATURE_EVIDENCE_TARGET = 75
|
| 35 |
+
CITATION_PLAN_TARGET = 70
|
| 36 |
|
| 37 |
|
| 38 |
def _clean_trailing_references(text, keywords=("reference", "references", "reference list", "参考文献")):
|
|
|
|
| 78 |
self.output_dir = self.task_dir / "outputs"
|
| 79 |
self.output_dir.mkdir(parents=True, exist_ok=True)
|
| 80 |
self.agent = None
|
| 81 |
+
preview_token = str(self.request.get("preview_token") or "").strip()
|
| 82 |
+
self.preview_token = preview_token if re.fullmatch(r"[A-Za-z0-9_-]{32,128}", preview_token) else ""
|
| 83 |
+
self.preview_path = None
|
| 84 |
+
self._last_preview_write = 0.0
|
| 85 |
+
if self.preview_token:
|
| 86 |
+
preview_dir = Path(__file__).resolve().parents[1] / "static" / "previews"
|
| 87 |
+
preview_dir.mkdir(parents=True, exist_ok=True)
|
| 88 |
+
self.preview_path = preview_dir / f"{self.preview_token}.json"
|
| 89 |
+
# Best-effort cleanup of stale display-only previews; task/checkpoint
|
| 90 |
+
# authority is unaffected by this cache.
|
| 91 |
+
cutoff = time.time() - 24 * 3600
|
| 92 |
+
for old_preview in preview_dir.glob("*.json"):
|
| 93 |
+
try:
|
| 94 |
+
if old_preview.stat().st_mtime < cutoff:
|
| 95 |
+
old_preview.unlink()
|
| 96 |
+
except OSError:
|
| 97 |
+
pass
|
| 98 |
|
| 99 |
def _compute_request_counts(self) -> tuple[int, int, int]:
|
| 100 |
"""Return UI metrics: DeepSeek requests and eligible literature results."""
|
|
|
|
| 127 |
self._progress(pct, normalized_stage, checkpoint_seq=seq, checkpoint_stage=normalized_stage)
|
| 128 |
|
| 129 |
@staticmethod
|
| 130 |
+
def _preview_visible_text(value: str) -> str:
|
| 131 |
+
text = str(value or "")
|
| 132 |
+
# Never expose hidden reasoning; only final manuscript prose is previewed.
|
| 133 |
+
text = re.sub(r"(?is)<think>.*?(?:</think>|$)", "", text)
|
| 134 |
+
return text.strip()
|
| 135 |
+
|
| 136 |
+
def _publish_preview(self, text: str, stage: str, *, force: bool = False, complete: bool = False) -> None:
|
| 137 |
+
if self.preview_path is None:
|
| 138 |
+
return
|
| 139 |
+
now = time.monotonic()
|
| 140 |
+
if not force and now - self._last_preview_write < 0.45:
|
| 141 |
+
return
|
| 142 |
+
payload = {
|
| 143 |
+
"task_id": str(self.request.get("task_id") or ""),
|
| 144 |
+
"request_hash": self.request_hash,
|
| 145 |
+
"execution_epoch": self.execution_epoch,
|
| 146 |
+
"stage": str(stage or "")[:240],
|
| 147 |
+
"text": self._preview_visible_text(text),
|
| 148 |
+
"complete": bool(complete),
|
| 149 |
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
| 150 |
+
}
|
| 151 |
+
temporary = self.preview_path.with_suffix(".json.tmp")
|
| 152 |
+
temporary.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
| 153 |
+
os.replace(temporary, self.preview_path)
|
| 154 |
+
self._last_preview_write = now
|
| 155 |
+
|
| 156 |
+
def _stream_text(self, generator, *, preview_prefix: str | None = None, preview_stage: str = "") -> str:
|
| 157 |
+
chunks = []
|
| 158 |
+
for chunk in generator:
|
| 159 |
+
value = str(chunk or "")
|
| 160 |
+
if not value:
|
| 161 |
+
continue
|
| 162 |
+
chunks.append(value)
|
| 163 |
+
if preview_prefix is not None:
|
| 164 |
+
self._publish_preview(preview_prefix + "".join(chunks), preview_stage, force=False)
|
| 165 |
+
text = "".join(chunks)
|
| 166 |
+
if preview_prefix is not None:
|
| 167 |
+
self._publish_preview(preview_prefix + text, preview_stage, force=True)
|
| 168 |
+
return text
|
| 169 |
|
| 170 |
@staticmethod
|
| 171 |
def _bounded_target(base_target: int, accumulated_deviation: float, remaining_including_current: int, safe_chunk: int) -> int:
|
|
|
|
| 178 |
@staticmethod
|
| 179 |
def _english_count_for_target(manuscript: str) -> int:
|
| 180 |
body = re.split(r"(?m)^#\s+References\s*$", str(manuscript or ""), maxsplit=1)[0]
|
| 181 |
+
body = re.split(r"(?m)^#\s+Appendix: Data Structure and Evidence\s*$", body, maxsplit=1)[0]
|
| 182 |
# Markdown punctuation is ignored; contractions/hyphenated terms stay one token.
|
| 183 |
return len(re.findall(r"\b[A-Za-z0-9]+(?:[-'][A-Za-z0-9]+)*\b", body))
|
| 184 |
|
| 185 |
def _validate_target_length(self, manuscript: str, target_words: int) -> None:
|
| 186 |
quality = APP_CONFIG.get("manuscript_validation", {})
|
| 187 |
+
minimum = int(target_words * float(quality.get("minimum_total_ratio", 0.97)))
|
| 188 |
+
maximum = int(target_words * float(quality.get("maximum_total_ratio", 1.03)))
|
| 189 |
actual = self._english_count_for_target(manuscript)
|
| 190 |
if actual < minimum or actual > maximum:
|
| 191 |
raise RuntimeError(
|
|
|
|
| 248 |
data_paths = self._resolve_paths(req.get("data_files") or [])
|
| 249 |
interview_paths = self._resolve_paths(req.get("interview_files") or [])
|
| 250 |
|
| 251 |
+
base_evidence_contract = str(APP_CONFIG.get("evidence_integrity_contract") or "")
|
| 252 |
+
|
| 253 |
# Restore agent state before continuing any dependent step.
|
| 254 |
if isinstance(state.get("agent_state"), dict):
|
| 255 |
agent.load_state(state["agent_state"])
|
|
|
|
| 266 |
agent.global_memory["core_golden_metadata"] = extract_golden_metadata(agent, all_paper_texts)
|
| 267 |
if mode == "amj":
|
| 268 |
data_file = data_paths[0] if data_paths else None
|
| 269 |
+
_, user_data_md, _, data_metadata = quant_engine.resolve_empirical_data(
|
| 270 |
+
data_file, interface_language, topic=topic, keywords=keywords, agent=agent
|
| 271 |
+
)
|
| 272 |
else:
|
| 273 |
raw_interviews = extract_text_from_files(interview_paths[:1]) if interview_paths else ""
|
| 274 |
user_data_md, _, data_metadata = qual_engine.resolve_qualitative_data(topic, keywords, raw_interviews)
|
|
|
|
| 295 |
refs_text = agent.fetch_references_parallel(
|
| 296 |
topic,
|
| 297 |
keywords,
|
| 298 |
+
min_required=LITERATURE_EVIDENCE_TARGET,
|
| 299 |
progress_callback=lambda: self._progress(9, "S2 + OpenAlex"),
|
| 300 |
)
|
| 301 |
if len(agent.citation_map) < MIN_FINAL_REFERENCES:
|
|
|
|
| 326 |
|
| 327 |
adaptive_structure = state.get("adaptive_structure")
|
| 328 |
if not isinstance(adaptive_structure, list) or not adaptive_structure:
|
| 329 |
+
self._progress(14, "research_logic_and_outline")
|
| 330 |
+
research_logic_response = self._stream_text(
|
| 331 |
+
agent.stream_generate_research_logic(
|
| 332 |
topic, keywords, synthesis_result, refs_text,
|
| 333 |
data_evidence=user_data_md, mode=mode,
|
| 334 |
)
|
| 335 |
)
|
| 336 |
+
research_logic_contract = re.sub(
|
| 337 |
+
r"<think>.*?</think>", "", research_logic_response, flags=re.DOTALL
|
| 338 |
+
).strip()
|
| 339 |
+
if not research_logic_contract:
|
| 340 |
+
raise RuntimeError("research_logic_contract_missing")
|
| 341 |
+
agent.global_memory["research_logic_contract"] = research_logic_contract
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
|
| 343 |
plan = agent.generate_outline(topic, keywords, mode=mode, data_metadata=data_metadata)
|
| 344 |
en_topic = str(plan.get("en_topic", topic))
|
| 345 |
structure = plan.get("structure", [])
|
| 346 |
+
# The final abstract is derived after Results/Findings validation;
|
| 347 |
+
# do not pre-commit the paper to an expected conclusion.
|
| 348 |
+
full_draft_en = f"# {en_topic}\n\n## Abstract\n\n## Keywords\n{keywords}\n\n"
|
| 349 |
+
front_budget = max(180, int(APP_CONFIG.get("word_budget", {}).get("front_matter_words", 220)))
|
| 350 |
+
body_target_words = max(1000, target_words - front_budget)
|
| 351 |
+
adaptive_structure = agent.build_generation_tasks(structure, body_target_words, safe_chunk_words)
|
| 352 |
for task in adaptive_structure:
|
| 353 |
task["is_first"] = bool(task.get("section_first"))
|
| 354 |
jit_texts = agent.prefetch_jit_literature(topic, keywords, adaptive_structure)
|
| 355 |
if jit_texts:
|
| 356 |
rag_engine._initialize_chunks([jit_texts])
|
| 357 |
+
citation_plan = agent.build_claim_evidence_plan(
|
| 358 |
+
adaptive_structure, topic, synthesis_result, mode=mode, target_unique=CITATION_PLAN_TARGET
|
| 359 |
+
)
|
| 360 |
+
planned_unique = {
|
| 361 |
+
item.get("tag")
|
| 362 |
+
for items in citation_plan.values()
|
| 363 |
+
for item in items
|
| 364 |
+
if item.get("tag")
|
| 365 |
+
}
|
| 366 |
+
if len(planned_unique) < MIN_FINAL_REFERENCES:
|
| 367 |
+
raise RuntimeError(
|
| 368 |
+
"Insufficient claim-evidence plan before drafting: "
|
| 369 |
+
f"{len(planned_unique)}/{MIN_FINAL_REFERENCES}."
|
| 370 |
+
)
|
| 371 |
state.update(
|
| 372 |
{
|
| 373 |
"adaptive_structure": adaptive_structure,
|
| 374 |
+
"citation_plan": citation_plan,
|
| 375 |
+
"research_logic_contract": research_logic_contract,
|
| 376 |
+
"body_target_words": body_target_words,
|
| 377 |
"full_draft_en": full_draft_en,
|
| 378 |
"full_draft_translated": "",
|
| 379 |
"completed_sections": [],
|
|
|
|
| 383 |
"outline_ready": True,
|
| 384 |
}
|
| 385 |
)
|
| 386 |
+
self._publish_preview(full_draft_en, "outline_ready", force=True)
|
| 387 |
self._checkpoint("outline_ready", state, 15)
|
| 388 |
else:
|
| 389 |
full_draft_en = str(state.get("full_draft_en") or "")
|
|
|
|
| 393 |
completed_sections = list(state.get("completed_sections") or [])
|
| 394 |
context_tail = str(state.get("context_tail") or "")
|
| 395 |
accumulated_deviation = float(state.get("accumulated_deviation") or 0.0)
|
| 396 |
+
research_logic_contract = str(
|
| 397 |
+
state.get("research_logic_contract") or agent.global_memory.get("research_logic_contract") or ""
|
| 398 |
+
)
|
| 399 |
+
citation_plan = state.get("citation_plan") if isinstance(state.get("citation_plan"), dict) else {}
|
| 400 |
+
if not citation_plan:
|
| 401 |
+
citation_plan = agent.build_claim_evidence_plan(
|
| 402 |
+
adaptive_structure, topic, synthesis_result, mode=mode, target_unique=CITATION_PLAN_TARGET
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
)
|
| 404 |
+
state["citation_plan"] = citation_plan
|
| 405 |
|
| 406 |
total_steps = len(adaptive_structure)
|
| 407 |
for sec in adaptive_structure:
|
|
|
|
| 420 |
word in sec_title.lower()
|
| 421 |
for word in ["abstract", "keyword", "conclusion", "references", "appendix"]
|
| 422 |
)
|
| 423 |
+
planned_items = list(citation_plan.get(display_name, []))
|
| 424 |
+
assigned_tag_list = [str(item.get("tag") or "") for item in planned_items if item.get("tag")]
|
| 425 |
+
claim_plan_text = agent.format_claim_evidence_plan(planned_items)
|
| 426 |
+
section_id = str(sec.get("section_id") or "")
|
| 427 |
+
section_role = str(
|
| 428 |
+
APP_CONFIG.get("section_reasoning_profiles", {}).get(mode, {}).get(section_id) or ""
|
|
|
|
|
|
|
|
|
|
| 429 |
)
|
| 430 |
+
mode_directive = ""
|
| 431 |
+
if section_id in {"theory", "theoretical_framing"}:
|
| 432 |
+
mode_directive = str(APP_CONFIG.get("hypothesis_development_directive") or "")
|
| 433 |
+
elif section_id in {"methodology", "methods"}:
|
| 434 |
+
mode_directive = str(APP_CONFIG.get("methodological_defense_directive") or "")
|
| 435 |
+
elif section_id == "discussion":
|
| 436 |
+
mode_directive = str(APP_CONFIG.get("discussion_depth_directive") or "")
|
| 437 |
+
elif section_id == "conclusion":
|
| 438 |
+
mode_directive = str(APP_CONFIG.get("conclusion_anchor_directive") or "")
|
| 439 |
+
|
| 440 |
+
if is_quarantined or section_id in {"results", "findings"}:
|
| 441 |
+
citation_rule = (
|
| 442 |
+
"NO EXTERNAL LITERATURE CITATIONS ALLOWED IN THIS UNIT. "
|
| 443 |
+
"Report only frozen empirical/qualitative evidence and the interpretation permitted by the section role."
|
| 444 |
+
)
|
| 445 |
+
else:
|
| 446 |
+
citation_rule = (
|
| 447 |
+
"Use the citation-first claim-evidence plan below. Every listed item has already been assigned to this unit on evidence relevance: use each assigned item exactly once unless it would contradict the frozen research evidence. "
|
| 448 |
+
"For each item, write one distinct claim no broader than the supplied evidence and attach exactly its assigned [CITE-XX] tag. "
|
| 449 |
+
"Never stack sources, substitute tags, or use literature as evidence for this study's own facts.\n"
|
| 450 |
+
f"<CLAIM_EVIDENCE_PLAN>\n{claim_plan_text}\n</CLAIM_EVIDENCE_PLAN>"
|
| 451 |
+
)
|
| 452 |
memory_context = agent._build_memory_context(sec_title, 2500)
|
| 453 |
memory_block = f"\n<GLOBAL_MEMORY>\n{memory_context}\n</GLOBAL_MEMORY>\n" if memory_context else ""
|
| 454 |
snippets = rag_engine.get_relevant_context(display_name) if rag_engine is not None else ""
|
|
|
|
| 468 |
"never use level-4 headings or numbered headings."
|
| 469 |
)
|
| 470 |
table_rule = (
|
| 471 |
+
"Present only frozen statistical evidence in standard Markdown tables with plain-text captions."
|
| 472 |
if sec["is_method_or_result"] and mode == "amj" else ""
|
| 473 |
)
|
| 474 |
concepts = ", ".join(sec.get("concepts") or [])
|
| 475 |
+
theme_evidence = [item for item in (sec.get("theme_evidence") or []) if isinstance(item, dict)]
|
| 476 |
+
evidence_labels = []
|
| 477 |
+
for item in theme_evidence:
|
| 478 |
+
tag = str(item.get("tag") or "").strip()
|
| 479 |
+
stage = str(item.get("stage") or "Not specified").strip()
|
| 480 |
+
role = str(item.get("evidence_role") or "typical").strip()
|
| 481 |
+
case_label = str(item.get("case_label") or "Not specified").strip()
|
| 482 |
+
if tag:
|
| 483 |
+
evidence_labels.append(f"{tag} [stage={stage}; case={case_label}; role={role}]")
|
| 484 |
qualitative_rule = (
|
| 485 |
+
f"Assigned qualitative concepts: {concepts}. Assigned quotation tags: {', '.join(evidence_labels)}. "
|
| 486 |
+
"Use only these assigned quotation tags in this Findings unit and do not repeat a tag used elsewhere. "
|
| 487 |
+
"When invoking a quotation, output only its [QUOTE-XX] tag at the exact position; do not reproduce or paraphrase the quotation because the system inserts the frozen verbatim text. "
|
| 488 |
+
"Preserve any frozen stage/case/deviant role and use deviant evidence to qualify rather than erase the pattern."
|
| 489 |
+
if concepts or evidence_labels else ""
|
| 490 |
)
|
| 491 |
+
evidence_packet = (
|
| 492 |
+
f"{base_evidence_contract}\n<FROZEN_RESEARCH_EVIDENCE>\n{user_data_md}\n</FROZEN_RESEARCH_EVIDENCE>"
|
| 493 |
+
if user_data_md else base_evidence_contract
|
| 494 |
)
|
| 495 |
completed_label = ", ".join(completed_sections)
|
| 496 |
anti_repeat = (
|
| 497 |
f"Already completed: {completed_label}. Do not restate their background, gap, or contribution."
|
| 498 |
if completed_label else ""
|
| 499 |
)
|
| 500 |
+
voice = str(APP_CONFIG.get("journal_voice_profiles", {}).get(mode) or "")
|
| 501 |
+
sys_prompt = (
|
| 502 |
+
APP_CONFIG.get("sys_academic_writer", "") + "\n"
|
| 503 |
+
+ evidence_packet + "\n" + voice + "\n"
|
| 504 |
+
+ APP_CONFIG.get("deep_refinement_directive", "")
|
| 505 |
+
)
|
| 506 |
+
word_budget = APP_CONFIG.get("word_budget", {})
|
| 507 |
+
min_ratio = float(word_budget.get("section_min_ratio", 0.92))
|
| 508 |
+
max_ratio = float(word_budget.get("section_max_ratio", 1.08))
|
| 509 |
user_prompt = f"""Write the assigned English manuscript unit.
|
| 510 |
Section: {sec_title}
|
| 511 |
Subsection: {subsection or 'None'}
|
| 512 |
+
Section reasoning role: {section_role}
|
| 513 |
+
Additional mode directive: {mode_directive}
|
| 514 |
+
Target length: {int(adjusted_target * min_ratio)}-{int(adjusted_target * max_ratio)} words.
|
| 515 |
{heading_directive}
|
| 516 |
{citation_rule}
|
| 517 |
{table_rule}
|
| 518 |
{str(sec.get('required_content') or '')}
|
| 519 |
{qualitative_rule}
|
| 520 |
{anti_repeat}
|
| 521 |
+
<PROVISIONAL_RESEARCH_LOGIC>
|
| 522 |
+
{research_logic_contract}
|
| 523 |
+
</PROVISIONAL_RESEARCH_LOGIC>
|
| 524 |
{memory_block}
|
| 525 |
Literature synthesis: {synthesis_result}
|
| 526 |
+
<FROZEN_RESEARCH_EVIDENCE>
|
| 527 |
+
{user_data_md}
|
| 528 |
+
</FROZEN_RESEARCH_EVIDENCE>
|
| 529 |
Previous tail: {context_tail[-2500:]}
|
| 530 |
Output only final academic prose. Do not report the word count."""
|
| 531 |
if agent._count_tokens(sys_prompt) + agent._count_tokens(user_prompt) > 60000:
|
|
|
|
| 534 |
current_index = len(completed_sections) + 1
|
| 535 |
pct = min(88, 15 + int((current_index / max(1, total_steps)) * 72))
|
| 536 |
self._progress(pct, display_name)
|
| 537 |
+
prefix = ""
|
| 538 |
+
if sec.get("section_first"):
|
| 539 |
+
prefix += f"# {sec_title}\n\n"
|
| 540 |
+
if sec.get("subsection_first") and subsection:
|
| 541 |
+
prefix += f"## {subsection}\n\n"
|
| 542 |
en_raw = self._stream_text(
|
| 543 |
agent.stream_deepseek(
|
| 544 |
sys_prompt, user_prompt, max_tokens=7000,
|
| 545 |
model_role="writing", thinking=False, allow_fallback=False,
|
| 546 |
+
),
|
| 547 |
+
preview_prefix=full_draft_en + prefix,
|
| 548 |
+
preview_stage=display_name,
|
| 549 |
)
|
| 550 |
en_raw = re.sub(r"<think>.*?</think>", "", en_raw, flags=re.S).strip()
|
| 551 |
en_raw = re.sub(rf"(?im)^#\s*{re.escape(sec_title)}\s*\n+", "", en_raw).strip()
|
|
|
|
| 554 |
en_raw = re.sub(r"(?m)^(#{2,3})\s+[\d\.\s一二三四五六七八九十、]+\s*(.*)", r"\1 \2", en_raw)
|
| 555 |
en_raw = re.sub(r"(?m)^#{4,}\s+(.*)", r"**\1**", en_raw)
|
| 556 |
en_raw = re.sub(r"(?m)^#\s+(.+)$", r"## \1", en_raw)
|
| 557 |
+
en_raw = agent.critique_and_repair_section(
|
| 558 |
+
en_raw, section_id=section_id, section_name=display_name, mode=mode,
|
| 559 |
+
evidence_contract=evidence_packet,
|
| 560 |
+
)
|
| 561 |
+
en_raw = agent.polish_academic_section(en_raw, display_name, abstract=False, mode=mode)
|
| 562 |
+
# Keep QUOTE tags immutable through critic/style editing, then bind
|
| 563 |
+
# each tag to its frozen verbatim quotation exactly once. This
|
| 564 |
+
# prevents editorial passes from silently paraphrasing evidence.
|
| 565 |
if mode == "asq" and user_data_md and "appendix" not in sec_title.lower():
|
| 566 |
quote_dict = {tag: quote for quote, tag in re.findall(r'"([^"]+)"\s*\[(QUOTE-\d+)\]', user_data_md)}
|
| 567 |
for tag, quote in quote_dict.items():
|
| 568 |
en_raw = en_raw.replace(tag, f'"{quote}"')
|
|
|
|
| 569 |
if not is_quarantined and assigned_tag_list:
|
| 570 |
en_raw = agent.reinforce_claim_specific_citation_coverage(
|
| 571 |
en_raw, assigned_tag_list, section_name=display_name
|
| 572 |
)
|
| 573 |
+
used_before = set(agent.used_citations)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 574 |
en_resolved = agent.audit_and_resolve_citations(
|
| 575 |
prefix + en_raw, section_name=display_name, quarantine=is_quarantined
|
| 576 |
)
|
| 577 |
+
used_after = set(agent.used_citations)
|
| 578 |
+
survived = used_after - used_before
|
| 579 |
+
if assigned_tag_list and not is_quarantined:
|
| 580 |
+
required_survival = min(
|
| 581 |
+
len(assigned_tag_list),
|
| 582 |
+
max(1, int(math.ceil(len(assigned_tag_list) * 0.78))),
|
| 583 |
+
)
|
| 584 |
+
if len(survived) < required_survival:
|
| 585 |
+
missing_items = [
|
| 586 |
+
item for item in planned_items
|
| 587 |
+
if str(item.get("tag") or "") not in used_after
|
| 588 |
+
]
|
| 589 |
+
needed = max(0, required_survival - len(survived))
|
| 590 |
+
supplemental_raw = agent.build_citation_recovery_paragraph(
|
| 591 |
+
missing_items[:needed], section_name=display_name
|
| 592 |
+
)
|
| 593 |
+
if supplemental_raw:
|
| 594 |
+
supplemental_resolved = agent.audit_and_resolve_citations(
|
| 595 |
+
supplemental_raw,
|
| 596 |
+
section_name=f"{display_name} — citation recovery",
|
| 597 |
+
quarantine=False,
|
| 598 |
+
)
|
| 599 |
+
if supplemental_resolved.strip():
|
| 600 |
+
en_resolved = en_resolved.rstrip() + "\n\n" + supplemental_resolved.strip()
|
| 601 |
+
used_after = set(agent.used_citations)
|
| 602 |
+
survived = used_after - used_before
|
| 603 |
full_draft_en += en_resolved + "\n\n"
|
| 604 |
+
if assigned_tag_list:
|
| 605 |
+
logging.info(
|
| 606 |
+
"Citation-first survival: section=%s planned=%s validated_new=%s",
|
| 607 |
+
display_name, len(assigned_tag_list), len(survived),
|
| 608 |
+
)
|
| 609 |
+
self._publish_preview(full_draft_en, display_name, force=True)
|
| 610 |
agent._extract_memory_from_text(en_raw, sec_title)
|
| 611 |
context_tail = " ".join(en_raw.split()[-200:])
|
| 612 |
+
actual_words = self._english_count_for_target(en_resolved)
|
| 613 |
accumulated_deviation += actual_words - adjusted_target
|
| 614 |
completed_sections.append(display_name)
|
| 615 |
state.update(
|
|
|
|
| 650 |
f"{len(references)}/{MIN_FINAL_REFERENCES}."
|
| 651 |
)
|
| 652 |
full_draft_en = full_draft_en.rstrip() + "\n\n# References\n" + "\n\n".join(references)
|
| 653 |
+
actual_words = self._english_count_for_target(full_draft_en)
|
| 654 |
+
quality = APP_CONFIG.get("manuscript_validation", {})
|
| 655 |
+
minimum_words = int(target_words * float(quality.get("minimum_total_ratio", 0.97)))
|
| 656 |
+
maximum_words = int(target_words * float(quality.get("maximum_total_ratio", 1.03)))
|
| 657 |
+
if actual_words < minimum_words or actual_words > maximum_words:
|
| 658 |
+
full_draft_en = agent.rebalance_discussion_length(
|
| 659 |
+
full_draft_en, target_words=target_words, mode=mode, actual_words=actual_words
|
| 660 |
+
)
|
| 661 |
+
agent.validate_manuscript_structure(full_draft_en, adaptive_structure, translated=False)
|
| 662 |
self._validate_target_length(full_draft_en, target_words)
|
| 663 |
+
self._publish_preview(full_draft_en, "english_complete", force=True, complete=True)
|
| 664 |
state.update(
|
| 665 |
{
|
| 666 |
"full_draft_en": full_draft_en,
|
core/pipeline_agent.py
CHANGED
|
@@ -87,11 +87,22 @@ class SSciPipelineAgent:
|
|
| 87 |
"last_status_code": None,
|
| 88 |
"last_safe_error": "",
|
| 89 |
"status": "not_started",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
}
|
| 91 |
for source in ("s2", "oa")
|
| 92 |
}
|
| 93 |
self._cancel_checker = None
|
| 94 |
self._execution_progress_hook = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
self.s2_fused = False
|
| 96 |
self.oa_fused = False
|
| 97 |
self.citation_map = {}
|
|
@@ -150,7 +161,7 @@ class SSciPipelineAgent:
|
|
| 150 |
|
| 151 |
self.global_memory = {
|
| 152 |
"hypotheses": "", "variables": "", "model_summary": "",
|
| 153 |
-
"method_summary": "", "
|
| 154 |
"core_golden_metadata": []
|
| 155 |
}
|
| 156 |
|
|
@@ -250,7 +261,9 @@ class SSciPipelineAgent:
|
|
| 250 |
"citation_map": self.citation_map,
|
| 251 |
"used_citations": list(self.used_citations),
|
| 252 |
"citation_audit_log": list(self.citation_audit_log),
|
| 253 |
-
"global_memory": self.global_memory
|
|
|
|
|
|
|
| 254 |
}
|
| 255 |
|
| 256 |
def load_state(self, state_dict):
|
|
@@ -268,6 +281,10 @@ class SSciPipelineAgent:
|
|
| 268 |
self.used_citations = set(state_dict.get("used_citations", []))
|
| 269 |
self.citation_audit_log = list(state_dict.get("citation_audit_log", []))
|
| 270 |
self.global_memory = state_dict.get("global_memory", self.global_memory)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
logging.info(f"🔄 状态恢复成功: DS API={self.ds_calls}, 现有文献={len(self.citation_map)}")
|
| 272 |
|
| 273 |
def _requests_get_with_retry(self, url, headers=None, params=None, timeout=None):
|
|
@@ -407,57 +424,66 @@ class SSciPipelineAgent:
|
|
| 407 |
memory_parts = []
|
| 408 |
lower_sec = section_name.lower() if section_name else ""
|
| 409 |
golden_meta = self.global_memory.get("core_golden_metadata", [])
|
| 410 |
-
|
| 411 |
-
if
|
| 412 |
-
memory_parts.append(f"【
|
| 413 |
-
|
| 414 |
-
is_intro_lit = any(
|
|
|
|
|
|
|
|
|
|
| 415 |
is_method = any(kw in lower_sec for kw in ["method", "measure", "data", "sample", "setting"])
|
| 416 |
is_result = any(kw in lower_sec for kw in ["result", "analysis", "finding"])
|
| 417 |
is_discussion = any(kw in lower_sec for kw in ["discussion", "conclusion", "implication"])
|
| 418 |
|
| 419 |
-
if is_intro_lit or is_discussion:
|
| 420 |
-
if self.global_memory["conclusion_anchor"]:
|
| 421 |
-
memory_parts.append(f"【PAPER ANCHOR - EXPECTED CONCLUSION】\n{self.global_memory['conclusion_anchor']}")
|
| 422 |
-
|
| 423 |
if is_method or is_result or is_discussion:
|
| 424 |
-
if self.global_memory["hypotheses"]:
|
| 425 |
memory_parts.append(f"【ESTABLISHED HYPOTHESES】\n{self.global_memory['hypotheses']}")
|
| 426 |
-
if self.global_memory["variables"]:
|
| 427 |
memory_parts.append(f"【CORE CONSTRUCT DEFINITIONS】\n{self.global_memory['variables']}")
|
| 428 |
-
|
| 429 |
if is_method:
|
| 430 |
-
if self.global_memory["model_summary"]:
|
| 431 |
memory_parts.append(f"【RESEARCH MODEL】\n{self.global_memory['model_summary']}")
|
| 432 |
-
if self.global_memory["method_summary"]:
|
| 433 |
memory_parts.append(f"【METHODOLOGICAL SUMMARY】\n{self.global_memory['method_summary']}")
|
| 434 |
-
|
| 435 |
if isinstance(golden_meta, list) and section_name:
|
| 436 |
injected_data = []
|
| 437 |
if is_intro_lit:
|
| 438 |
-
for
|
| 439 |
-
if isinstance(
|
| 440 |
-
if
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
|
|
|
|
|
|
|
|
|
| 445 |
elif is_method:
|
| 446 |
-
for
|
| 447 |
-
if isinstance(
|
| 448 |
-
injected_data.append(
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
|
|
|
|
|
|
|
|
|
| 452 |
elif is_discussion:
|
| 453 |
-
for
|
| 454 |
-
if isinstance(
|
| 455 |
-
injected_data.append(
|
| 456 |
-
|
| 457 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 458 |
|
| 459 |
full = "\n\n".join(memory_parts)
|
| 460 |
-
if self._count_tokens(full) > max_tokens:
|
| 461 |
return self._truncate_context(full, max_tokens)
|
| 462 |
return full
|
| 463 |
|
|
@@ -1120,68 +1146,16 @@ class SSciPipelineAgent:
|
|
| 1120 |
data_evidence="",
|
| 1121 |
mode="amj",
|
| 1122 |
):
|
| 1123 |
-
|
| 1124 |
-
|
| 1125 |
-
|
| 1126 |
-
|
| 1127 |
-
|
| 1128 |
-
|
| 1129 |
-
|
| 1130 |
-
(Write the comprehensive abstract here)
|
| 1131 |
-
---CONCLUSION---
|
| 1132 |
-
(Write the expected conclusion here)"""
|
| 1133 |
-
evidence_block = (
|
| 1134 |
-
f"\nResearch Mode: {mode}\n"
|
| 1135 |
-
"The following evidence packet is the single source of truth "
|
| 1136 |
-
"for setting, sample, data sources, method, findings, and all "
|
| 1137 |
-
"numerical details. Do not invent or alter them.\n"
|
| 1138 |
-
f"<RESEARCH_EVIDENCE>\n{data_evidence[:12000]}\n"
|
| 1139 |
-
"</RESEARCH_EVIDENCE>\n"
|
| 1140 |
-
if data_evidence
|
| 1141 |
-
else ""
|
| 1142 |
-
)
|
| 1143 |
-
base_directive = APP_CONFIG.get(
|
| 1144 |
-
"anchor_generation_directive",
|
| 1145 |
-
"",
|
| 1146 |
-
)
|
| 1147 |
-
user_prompt = (
|
| 1148 |
-
f"Topic: {topic}\nKeywords: {keywords}\n"
|
| 1149 |
-
f"Literature Synthesis:\n{synthesis_result}\n"
|
| 1150 |
-
f"Citation Context:\n{refs_text[:3000]}\n"
|
| 1151 |
-
f"{evidence_block}{base_directive}{format_mandate}"
|
| 1152 |
-
)
|
| 1153 |
-
response = self.call_deepseek(
|
| 1154 |
-
sys_prompt,
|
| 1155 |
-
user_prompt,
|
| 1156 |
-
max_tokens=8000,
|
| 1157 |
-
temp=0.3,
|
| 1158 |
-
model_role="reasoning",
|
| 1159 |
-
thinking=True,
|
| 1160 |
-
allow_fallback=False,
|
| 1161 |
-
)
|
| 1162 |
-
abstract_match = re.search(
|
| 1163 |
-
r"-+\s*ABSTRACT\s*-+\s*(.*?)(?=\s*-+\s*CONCLUSION\s*-+|$)",
|
| 1164 |
-
response,
|
| 1165 |
-
re.DOTALL | re.IGNORECASE,
|
| 1166 |
-
)
|
| 1167 |
-
conclusion_match = re.search(
|
| 1168 |
-
r"-+\s*CONCLUSION\s*-+\s*(.*?)$",
|
| 1169 |
-
response,
|
| 1170 |
-
re.DOTALL | re.IGNORECASE,
|
| 1171 |
-
)
|
| 1172 |
-
final_abstract = (
|
| 1173 |
-
abstract_match.group(1).strip()
|
| 1174 |
-
if abstract_match
|
| 1175 |
-
else response.strip()
|
| 1176 |
-
)
|
| 1177 |
-
final_conclusion = (
|
| 1178 |
-
conclusion_match.group(1).strip()
|
| 1179 |
-
if conclusion_match
|
| 1180 |
-
else ""
|
| 1181 |
)
|
| 1182 |
-
|
| 1183 |
-
|
| 1184 |
-
# ... 保留上面的 synthesize_literature 和 generate_anchors 函数不变 ...
|
| 1185 |
|
| 1186 |
# [新增] 综合文献的流式输出方法
|
| 1187 |
def stream_synthesize_literature(self, topic, refs_text, user_core_literature=""):
|
|
@@ -1199,7 +1173,7 @@ You MUST output your response EXACTLY using the following two delimiters. Do not
|
|
| 1199 |
)
|
| 1200 |
|
| 1201 |
# Generate abstract and conclusion anchors from literature plus evidence.
|
| 1202 |
-
def
|
| 1203 |
self,
|
| 1204 |
topic,
|
| 1205 |
keywords,
|
|
@@ -1208,43 +1182,38 @@ You MUST output your response EXACTLY using the following two delimiters. Do not
|
|
| 1208 |
data_evidence="",
|
| 1209 |
mode="amj",
|
| 1210 |
):
|
| 1211 |
-
|
| 1212 |
-
|
| 1213 |
-
|
| 1214 |
-
)
|
| 1215 |
-
format_mandate = """\n\n[CRITICAL FORMAT MANDATE]
|
| 1216 |
-
You MUST output your response EXACTLY using the following two delimiters. Do not add Markdown headings before them:
|
| 1217 |
-
---ABSTRACT---
|
| 1218 |
-
(Write the comprehensive abstract here)
|
| 1219 |
-
---CONCLUSION---
|
| 1220 |
-
(Write the expected conclusion here)"""
|
| 1221 |
evidence_block = (
|
| 1222 |
-
|
| 1223 |
-
"
|
| 1224 |
-
"
|
| 1225 |
-
|
| 1226 |
-
f"<RESEARCH_EVIDENCE>\n{data_evidence[:12000]}\n"
|
| 1227 |
-
"</RESEARCH_EVIDENCE>\n"
|
| 1228 |
-
if data_evidence
|
| 1229 |
-
else ""
|
| 1230 |
)
|
| 1231 |
-
|
| 1232 |
-
"
|
| 1233 |
-
|
| 1234 |
-
|
| 1235 |
-
user_prompt = (
|
| 1236 |
-
f"Topic: {topic}\nKeywords: {keywords}\n"
|
| 1237 |
-
f"Literature Synthesis:\n{synthesis_result}\n"
|
| 1238 |
-
f"Citation Context:\n{refs_text[:3000]}\n"
|
| 1239 |
-
f"{evidence_block}{base_directive}{format_mandate}"
|
| 1240 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1241 |
return self.stream_deepseek(
|
| 1242 |
-
sys_prompt,
|
| 1243 |
-
|
| 1244 |
-
|
| 1245 |
-
|
| 1246 |
-
|
| 1247 |
-
|
|
|
|
|
|
|
|
|
|
| 1248 |
)
|
| 1249 |
|
| 1250 |
# =====================================================================
|
|
@@ -1286,6 +1255,9 @@ You MUST output your response EXACTLY using the following two delimiters. Do not
|
|
| 1286 |
suffix += 1
|
| 1287 |
seen_ids.add(subsection_id)
|
| 1288 |
concepts = [str(item).strip() for item in theme.get("concepts", []) if str(item).strip()]
|
|
|
|
|
|
|
|
|
|
| 1289 |
dynamic_subsections.append(
|
| 1290 |
{
|
| 1291 |
"subsection_id": subsection_id,
|
|
@@ -1293,9 +1265,12 @@ You MUST output your response EXACTLY using the following two delimiters. Do not
|
|
| 1293 |
"share": per_theme_share,
|
| 1294 |
"theme_title": title,
|
| 1295 |
"concepts": concepts,
|
|
|
|
| 1296 |
"required_content": (
|
| 1297 |
-
"Explain this theme using only its assigned concepts and "
|
| 1298 |
-
"
|
|
|
|
|
|
|
| 1299 |
),
|
| 1300 |
}
|
| 1301 |
)
|
|
@@ -1311,7 +1286,12 @@ You MUST output your response EXACTLY using the following two delimiters. Do not
|
|
| 1311 |
def build_generation_tasks(self, structure, total_words, safe_chunk_words):
|
| 1312 |
tasks = []
|
| 1313 |
for section in structure:
|
| 1314 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1315 |
subsections = section.get("subsections") or []
|
| 1316 |
units = subsections or [None]
|
| 1317 |
for unit_index, subsection in enumerate(units):
|
|
@@ -1322,6 +1302,7 @@ You MUST output your response EXACTLY using the following two delimiters. Do not
|
|
| 1322 |
required_content = subsection.get("required_content", "")
|
| 1323 |
theme_title = subsection.get("theme_title")
|
| 1324 |
concepts = subsection.get("concepts", [])
|
|
|
|
| 1325 |
else:
|
| 1326 |
unit_target = section_target
|
| 1327 |
subsection_id = None
|
|
@@ -1329,10 +1310,16 @@ You MUST output your response EXACTLY using the following two delimiters. Do not
|
|
| 1329 |
required_content = ""
|
| 1330 |
theme_title = None
|
| 1331 |
concepts = []
|
|
|
|
| 1332 |
|
| 1333 |
part_count = max(1, math.ceil(unit_target / max(1, int(safe_chunk_words))))
|
| 1334 |
for part_index in range(part_count):
|
| 1335 |
part_target = max(80, int(unit_target / part_count))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1336 |
label = section["section"]
|
| 1337 |
if subsection_title:
|
| 1338 |
label += f" > {subsection_title}"
|
|
@@ -1352,7 +1339,9 @@ You MUST output your response EXACTLY using the following two delimiters. Do not
|
|
| 1352 |
"required_content": required_content,
|
| 1353 |
"theme_title": theme_title,
|
| 1354 |
"concepts": concepts,
|
|
|
|
| 1355 |
"is_method_or_result": bool(section.get("is_method_or_result")),
|
|
|
|
| 1356 |
}
|
| 1357 |
)
|
| 1358 |
return tasks
|
|
@@ -1607,24 +1596,79 @@ Return ONLY a valid JSON array of strings."""
|
|
| 1607 |
if not query:
|
| 1608 |
self._record_source_error('s2', 'S2_BAD_QUERY')
|
| 1609 |
return []
|
| 1610 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1611 |
self._source_metric_add('s2', 'request_count')
|
| 1612 |
self._source_metric_set('s2', 'status', 'running')
|
| 1613 |
logging.info('S2 search: term=%r normalized=%r offset=%s', term, query, offset)
|
| 1614 |
try:
|
|
|
|
|
|
|
| 1615 |
response = self._requests_get_with_retry(
|
| 1616 |
f"{self.s2_api_url}/paper/search",
|
| 1617 |
headers=headers,
|
| 1618 |
-
params=
|
| 1619 |
-
'query': query,
|
| 1620 |
-
'offset': offset,
|
| 1621 |
-
'limit': 50,
|
| 1622 |
-
'fields': 'paperId,title,authors,year,venue,abstract,externalIds',
|
| 1623 |
-
},
|
| 1624 |
)
|
| 1625 |
-
status = response.status_code
|
| 1626 |
self._source_metric_set('s2', 'last_status_code', status)
|
| 1627 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1628 |
self._record_source_error('s2', 'S2_AUTH_ERROR', status)
|
| 1629 |
return []
|
| 1630 |
if status == 429:
|
|
@@ -1822,6 +1866,12 @@ Return ONLY a valid JSON array of strings."""
|
|
| 1822 |
self.source_metrics[source]['last_status_code'] = None
|
| 1823 |
self.source_metrics[source]['last_safe_error'] = ''
|
| 1824 |
self.source_metrics[source]['status'] = 'not_started'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1825 |
self.s2_calls = self.oa_calls = 0
|
| 1826 |
|
| 1827 |
all_used_terms = set()
|
|
@@ -1949,6 +1999,238 @@ Return ONLY a valid JSON array of strings."""
|
|
| 1949 |
words = re.findall(r"[A-Za-z][A-Za-z0-9_-]{2,}", str(value).lower())
|
| 1950 |
return {word for word in words if word not in stopwords}
|
| 1951 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1952 |
def _candidate_citation_tags(self, sentence, required_tags, limit):
|
| 1953 |
valid_required = [
|
| 1954 |
tag for tag in required_tags
|
|
@@ -2589,7 +2871,7 @@ Return exactly:
|
|
| 2589 |
value = re.sub(pattern, '', value)
|
| 2590 |
return re.sub(r'\n{3,}', '\n\n', value).strip()
|
| 2591 |
|
| 2592 |
-
def polish_academic_section(self, text, section_name='', abstract=False):
|
| 2593 |
value = self._remove_ai_meta_traces(text)
|
| 2594 |
quality = APP_CONFIG.get('manuscript_quality', {})
|
| 2595 |
forbidden = quality.get('forbidden_phrases', [])
|
|
@@ -2606,7 +2888,12 @@ Return exactly:
|
|
| 2606 |
if abstract
|
| 2607 |
else "Preserve every number, percentage, coefficient, p-value, and confidence interval exactly."
|
| 2608 |
)
|
| 2609 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2610 |
revised = self.call_deepseek(
|
| 2611 |
'You are a meticulous academic language editor. Output only the revised section.',
|
| 2612 |
prompt,
|
|
@@ -2638,8 +2925,99 @@ Return exactly:
|
|
| 2638 |
if original_tags != revised_tags:
|
| 2639 |
logging.warning('Quality revision rejected because citation tags changed: %s', section_name)
|
| 2640 |
return value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2641 |
return revised.strip()
|
| 2642 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2643 |
@staticmethod
|
| 2644 |
def _trim_at_sentence_boundary(value, max_words):
|
| 2645 |
text = str(value or '').strip()
|
|
@@ -2712,7 +3090,7 @@ Return exactly:
|
|
| 2712 |
)
|
| 2713 |
abstract = self._remove_ai_meta_traces(abstract)
|
| 2714 |
abstract = re.sub(r'(?im)^#+\s*abstract\s*', '', abstract).strip()
|
| 2715 |
-
abstract = self.polish_academic_section(abstract, 'Abstract', abstract=True)
|
| 2716 |
max_words = max(80, int(APP_CONFIG.get('manuscript_quality', {}).get('abstract_max_words', 200)))
|
| 2717 |
return self._trim_at_sentence_boundary(abstract, max_words)
|
| 2718 |
|
|
|
|
| 87 |
"last_status_code": None,
|
| 88 |
"last_safe_error": "",
|
| 89 |
"status": "not_started",
|
| 90 |
+
"key_present": bool(self.s2_key) if source == "s2" else bool(self.openalex_key),
|
| 91 |
+
"authenticated_status": None,
|
| 92 |
+
"anonymous_status": None,
|
| 93 |
+
"access_mode": "key" if source == "s2" and self.s2_key else "anonymous" if source == "s2" else "api",
|
| 94 |
}
|
| 95 |
for source in ("s2", "oa")
|
| 96 |
}
|
| 97 |
self._cancel_checker = None
|
| 98 |
self._execution_progress_hook = None
|
| 99 |
+
# S2 authentication recovery is task-local. Never log or persist the key.
|
| 100 |
+
# "key" means use the configured key, "anonymous" means a 401/403 key
|
| 101 |
+
# rejection was recovered by an anonymous probe, and "blocked" means
|
| 102 |
+
# both authenticated and anonymous access were rejected.
|
| 103 |
+
self._s2_access_mode = "key" if self.s2_key else "anonymous"
|
| 104 |
+
self._s2_probe_lock = threading.Lock()
|
| 105 |
+
self._s2_probe_complete = not bool(self.s2_key)
|
| 106 |
self.s2_fused = False
|
| 107 |
self.oa_fused = False
|
| 108 |
self.citation_map = {}
|
|
|
|
| 161 |
|
| 162 |
self.global_memory = {
|
| 163 |
"hypotheses": "", "variables": "", "model_summary": "",
|
| 164 |
+
"method_summary": "", "research_logic_contract": "",
|
| 165 |
"core_golden_metadata": []
|
| 166 |
}
|
| 167 |
|
|
|
|
| 261 |
"citation_map": self.citation_map,
|
| 262 |
"used_citations": list(self.used_citations),
|
| 263 |
"citation_audit_log": list(self.citation_audit_log),
|
| 264 |
+
"global_memory": self.global_memory,
|
| 265 |
+
"s2_access_mode": self._s2_access_mode,
|
| 266 |
+
"s2_probe_complete": bool(self._s2_probe_complete),
|
| 267 |
}
|
| 268 |
|
| 269 |
def load_state(self, state_dict):
|
|
|
|
| 281 |
self.used_citations = set(state_dict.get("used_citations", []))
|
| 282 |
self.citation_audit_log = list(state_dict.get("citation_audit_log", []))
|
| 283 |
self.global_memory = state_dict.get("global_memory", self.global_memory)
|
| 284 |
+
loaded_mode = str(state_dict.get("s2_access_mode") or "").strip().lower()
|
| 285 |
+
if loaded_mode in {"key", "anonymous", "blocked"}:
|
| 286 |
+
self._s2_access_mode = loaded_mode
|
| 287 |
+
self._s2_probe_complete = bool(state_dict.get("s2_probe_complete", self._s2_probe_complete))
|
| 288 |
logging.info(f"🔄 状态恢复成功: DS API={self.ds_calls}, 现有文献={len(self.citation_map)}")
|
| 289 |
|
| 290 |
def _requests_get_with_retry(self, url, headers=None, params=None, timeout=None):
|
|
|
|
| 424 |
memory_parts = []
|
| 425 |
lower_sec = section_name.lower() if section_name else ""
|
| 426 |
golden_meta = self.global_memory.get("core_golden_metadata", [])
|
| 427 |
+
research_logic = str(self.global_memory.get("research_logic_contract") or "").strip()
|
| 428 |
+
if research_logic:
|
| 429 |
+
memory_parts.append(f"【PROVISIONAL RESEARCH LOGIC】\n{research_logic}")
|
| 430 |
+
|
| 431 |
+
is_intro_lit = any(
|
| 432 |
+
kw in lower_sec
|
| 433 |
+
for kw in ["introduction", "background", "literature", "theor", "review", "framing"]
|
| 434 |
+
)
|
| 435 |
is_method = any(kw in lower_sec for kw in ["method", "measure", "data", "sample", "setting"])
|
| 436 |
is_result = any(kw in lower_sec for kw in ["result", "analysis", "finding"])
|
| 437 |
is_discussion = any(kw in lower_sec for kw in ["discussion", "conclusion", "implication"])
|
| 438 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 439 |
if is_method or is_result or is_discussion:
|
| 440 |
+
if self.global_memory["hypotheses"]:
|
| 441 |
memory_parts.append(f"【ESTABLISHED HYPOTHESES】\n{self.global_memory['hypotheses']}")
|
| 442 |
+
if self.global_memory["variables"]:
|
| 443 |
memory_parts.append(f"【CORE CONSTRUCT DEFINITIONS】\n{self.global_memory['variables']}")
|
| 444 |
+
|
| 445 |
if is_method:
|
| 446 |
+
if self.global_memory["model_summary"]:
|
| 447 |
memory_parts.append(f"【RESEARCH MODEL】\n{self.global_memory['model_summary']}")
|
| 448 |
+
if self.global_memory["method_summary"]:
|
| 449 |
memory_parts.append(f"【METHODOLOGICAL SUMMARY】\n{self.global_memory['method_summary']}")
|
| 450 |
+
|
| 451 |
if isinstance(golden_meta, list) and section_name:
|
| 452 |
injected_data = []
|
| 453 |
if is_intro_lit:
|
| 454 |
+
for paper in golden_meta:
|
| 455 |
+
if isinstance(paper, dict):
|
| 456 |
+
if paper.get("future_gaps"):
|
| 457 |
+
injected_data.append(f"[{paper.get('title', 'Ref')}] Future Gaps: {paper['future_gaps']}")
|
| 458 |
+
if paper.get("theoretical_lens"):
|
| 459 |
+
injected_data.append(f"[{paper.get('title', 'Ref')}] Theory: {paper['theoretical_lens']}")
|
| 460 |
+
if injected_data:
|
| 461 |
+
memory_parts.append(
|
| 462 |
+
"【TARGETED INJECTION: CORE GAPS & THEORIES】\n" + "\n".join(injected_data)
|
| 463 |
+
)
|
| 464 |
elif is_method:
|
| 465 |
+
for paper in golden_meta:
|
| 466 |
+
if isinstance(paper, dict) and paper.get("variables_and_measurements"):
|
| 467 |
+
injected_data.append(
|
| 468 |
+
f"[{paper.get('title', 'Ref')}] Measurements: {paper['variables_and_measurements']}"
|
| 469 |
+
)
|
| 470 |
+
if injected_data:
|
| 471 |
+
memory_parts.append(
|
| 472 |
+
"【TARGETED INJECTION: CORE MEASUREMENTS】\n" + "\n".join(injected_data)
|
| 473 |
+
)
|
| 474 |
elif is_discussion:
|
| 475 |
+
for paper in golden_meta:
|
| 476 |
+
if isinstance(paper, dict) and paper.get("causal_pathways"):
|
| 477 |
+
injected_data.append(
|
| 478 |
+
f"[{paper.get('title', 'Ref')}] Prior mechanisms/pathways: {paper['causal_pathways']}"
|
| 479 |
+
)
|
| 480 |
+
if injected_data:
|
| 481 |
+
memory_parts.append(
|
| 482 |
+
"【TARGETED INJECTION: CORE LITERATURE FINDINGS】\n" + "\n".join(injected_data)
|
| 483 |
+
)
|
| 484 |
|
| 485 |
full = "\n\n".join(memory_parts)
|
| 486 |
+
if self._count_tokens(full) > max_tokens:
|
| 487 |
return self._truncate_context(full, max_tokens)
|
| 488 |
return full
|
| 489 |
|
|
|
|
| 1146 |
data_evidence="",
|
| 1147 |
mode="amj",
|
| 1148 |
):
|
| 1149 |
+
"""Deprecated compatibility shim; never prewrites a conclusion."""
|
| 1150 |
+
response = "".join(
|
| 1151 |
+
str(chunk or "")
|
| 1152 |
+
for chunk in self.stream_generate_research_logic(
|
| 1153 |
+
topic, keywords, synthesis_result, refs_text,
|
| 1154 |
+
data_evidence=data_evidence, mode=mode,
|
| 1155 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1156 |
)
|
| 1157 |
+
response = re.sub(r"<think>.*?</think>", "", response, flags=re.DOTALL).strip()
|
| 1158 |
+
return response, ""
|
|
|
|
| 1159 |
|
| 1160 |
# [新增] 综合文献的流式输出方法
|
| 1161 |
def stream_synthesize_literature(self, topic, refs_text, user_core_literature=""):
|
|
|
|
| 1173 |
)
|
| 1174 |
|
| 1175 |
# Generate abstract and conclusion anchors from literature plus evidence.
|
| 1176 |
+
def stream_generate_research_logic(
|
| 1177 |
self,
|
| 1178 |
topic,
|
| 1179 |
keywords,
|
|
|
|
| 1182 |
data_evidence="",
|
| 1183 |
mode="amj",
|
| 1184 |
):
|
| 1185 |
+
"""Generate a provisional research-logic contract without prewriting results."""
|
| 1186 |
+
mode = 'asq' if str(mode).lower() == 'asq' else 'amj'
|
| 1187 |
+
sys_prompt = "You are a senior management scholar planning a high-impact empirical paper before manuscript drafting."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1188 |
evidence_block = (
|
| 1189 |
+
"The following evidence packet is the single source of truth for setting, sample, data sources, method, findings, and numerical details. "
|
| 1190 |
+
"Do not invent or alter them.\n"
|
| 1191 |
+
f"<RESEARCH_EVIDENCE>\n{data_evidence[:14000]}\n</RESEARCH_EVIDENCE>\n"
|
| 1192 |
+
if data_evidence else ""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1193 |
)
|
| 1194 |
+
mode_directive = (
|
| 1195 |
+
"For the quantitative AMJ-oriented track, identify the management problem, theoretical tension, research move, focal mechanism, evidence-strength boundary, and questions/hypotheses the evidence can address. Do not predict coefficients or findings."
|
| 1196 |
+
if mode == 'amj'
|
| 1197 |
+
else "For the qualitative ASQ-oriented track, identify the organizational puzzle, sensitizing theoretical tension, research question, likely process/meaning domains to examine, and evidence boundaries. Do not predict the emergent resolution."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1198 |
)
|
| 1199 |
+
user_prompt = f"""Topic: {topic}
|
| 1200 |
+
Keywords: {keywords}
|
| 1201 |
+
Literature Synthesis:
|
| 1202 |
+
{synthesis_result}
|
| 1203 |
+
|
| 1204 |
+
{evidence_block}
|
| 1205 |
+
{mode_directive}
|
| 1206 |
+
Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It must guide later sections without fixing an expected result or conclusion. Do not use citations in the contract."""
|
| 1207 |
return self.stream_deepseek(
|
| 1208 |
+
sys_prompt, user_prompt, max_tokens=2200,
|
| 1209 |
+
model_role="reasoning", thinking=True, allow_fallback=False,
|
| 1210 |
+
)
|
| 1211 |
+
|
| 1212 |
+
# Backward-compatible name retained for old checkpoints/tests.
|
| 1213 |
+
def stream_generate_anchors(self, topic, keywords, synthesis_result, refs_text, data_evidence="", mode="amj"):
|
| 1214 |
+
return self.stream_generate_research_logic(
|
| 1215 |
+
topic, keywords, synthesis_result, refs_text,
|
| 1216 |
+
data_evidence=data_evidence, mode=mode,
|
| 1217 |
)
|
| 1218 |
|
| 1219 |
# =====================================================================
|
|
|
|
| 1255 |
suffix += 1
|
| 1256 |
seen_ids.add(subsection_id)
|
| 1257 |
concepts = [str(item).strip() for item in theme.get("concepts", []) if str(item).strip()]
|
| 1258 |
+
theme_evidence = [dict(item) for item in (theme.get("evidence") or []) if isinstance(item, dict)]
|
| 1259 |
+
stages = [str(item).strip() for item in (theme.get("stages") or []) if str(item).strip()]
|
| 1260 |
+
has_deviant = bool(theme.get("has_deviant_evidence"))
|
| 1261 |
dynamic_subsections.append(
|
| 1262 |
{
|
| 1263 |
"subsection_id": subsection_id,
|
|
|
|
| 1265 |
"share": per_theme_share,
|
| 1266 |
"theme_title": title,
|
| 1267 |
"concepts": concepts,
|
| 1268 |
+
"theme_evidence": theme_evidence,
|
| 1269 |
"required_content": (
|
| 1270 |
+
"Explain this theme using only its assigned concepts and tagged representative quotations. "
|
| 1271 |
+
+ (f"Preserve the frozen process order across these stages: {', '.join(stages)}. " if stages else "")
|
| 1272 |
+
+ ("Use the assigned deviant/boundary evidence to qualify the pattern rather than hiding it. " if has_deviant else "")
|
| 1273 |
+
+ "Do not reuse evidence assigned to another theme or invent a temporal sequence."
|
| 1274 |
),
|
| 1275 |
}
|
| 1276 |
)
|
|
|
|
| 1286 |
def build_generation_tasks(self, structure, total_words, safe_chunk_words):
|
| 1287 |
tasks = []
|
| 1288 |
for section in structure:
|
| 1289 |
+
excluded_budget = bool(section.get("exclude_from_word_budget"))
|
| 1290 |
+
section_target = (
|
| 1291 |
+
max(320, int(APP_CONFIG.get("qualitative_appendix_words", 420)))
|
| 1292 |
+
if excluded_budget
|
| 1293 |
+
else max(120, int(total_words * float(section.get("weight", 0.1))))
|
| 1294 |
+
)
|
| 1295 |
subsections = section.get("subsections") or []
|
| 1296 |
units = subsections or [None]
|
| 1297 |
for unit_index, subsection in enumerate(units):
|
|
|
|
| 1302 |
required_content = subsection.get("required_content", "")
|
| 1303 |
theme_title = subsection.get("theme_title")
|
| 1304 |
concepts = subsection.get("concepts", [])
|
| 1305 |
+
theme_evidence = subsection.get("theme_evidence", [])
|
| 1306 |
else:
|
| 1307 |
unit_target = section_target
|
| 1308 |
subsection_id = None
|
|
|
|
| 1310 |
required_content = ""
|
| 1311 |
theme_title = None
|
| 1312 |
concepts = []
|
| 1313 |
+
theme_evidence = []
|
| 1314 |
|
| 1315 |
part_count = max(1, math.ceil(unit_target / max(1, int(safe_chunk_words))))
|
| 1316 |
for part_index in range(part_count):
|
| 1317 |
part_target = max(80, int(unit_target / part_count))
|
| 1318 |
+
part_theme_evidence = (
|
| 1319 |
+
theme_evidence[part_index::part_count]
|
| 1320 |
+
if theme_evidence and part_count > 1
|
| 1321 |
+
else theme_evidence
|
| 1322 |
+
)
|
| 1323 |
label = section["section"]
|
| 1324 |
if subsection_title:
|
| 1325 |
label += f" > {subsection_title}"
|
|
|
|
| 1339 |
"required_content": required_content,
|
| 1340 |
"theme_title": theme_title,
|
| 1341 |
"concepts": concepts,
|
| 1342 |
+
"theme_evidence": part_theme_evidence,
|
| 1343 |
"is_method_or_result": bool(section.get("is_method_or_result")),
|
| 1344 |
+
"exclude_from_word_budget": excluded_budget,
|
| 1345 |
}
|
| 1346 |
)
|
| 1347 |
return tasks
|
|
|
|
| 1596 |
if not query:
|
| 1597 |
self._record_source_error('s2', 'S2_BAD_QUERY')
|
| 1598 |
return []
|
| 1599 |
+
params = {
|
| 1600 |
+
'query': query,
|
| 1601 |
+
'offset': offset,
|
| 1602 |
+
'limit': 50,
|
| 1603 |
+
'fields': 'paperId,title,authors,year,venue,abstract,externalIds',
|
| 1604 |
+
}
|
| 1605 |
+
if self._s2_access_mode == 'blocked':
|
| 1606 |
+
# The task already proved that both keyed and anonymous access are
|
| 1607 |
+
# refused from this runtime. Avoid repeating doomed network calls;
|
| 1608 |
+
# OpenAlex remains the safe literature fallback.
|
| 1609 |
+
self._record_source_error('s2', 'S2_ACCESS_BLOCKED', self.source_metrics['s2'].get('last_status_code'))
|
| 1610 |
+
return []
|
| 1611 |
self._source_metric_add('s2', 'request_count')
|
| 1612 |
self._source_metric_set('s2', 'status', 'running')
|
| 1613 |
logging.info('S2 search: term=%r normalized=%r offset=%s', term, query, offset)
|
| 1614 |
try:
|
| 1615 |
+
access_mode = self._s2_access_mode
|
| 1616 |
+
headers = {'x-api-key': self.s2_key} if access_mode == 'key' and self.s2_key else {}
|
| 1617 |
response = self._requests_get_with_retry(
|
| 1618 |
f"{self.s2_api_url}/paper/search",
|
| 1619 |
headers=headers,
|
| 1620 |
+
params=params,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1621 |
)
|
| 1622 |
+
status = int(response.status_code)
|
| 1623 |
self._source_metric_set('s2', 'last_status_code', status)
|
| 1624 |
+
if access_mode == 'key':
|
| 1625 |
+
self._source_metric_set('s2', 'authenticated_status', status)
|
| 1626 |
+
else:
|
| 1627 |
+
self._source_metric_set('s2', 'anonymous_status', status)
|
| 1628 |
+
if status in (401, 403) and access_mode == 'key' and self.s2_key:
|
| 1629 |
+
# A configured key can be stale/revoked while the public Graph
|
| 1630 |
+
# endpoint remains usable. Probe anonymously exactly once per
|
| 1631 |
+
# task; this reveals only status codes, never the secret.
|
| 1632 |
+
with self._s2_probe_lock:
|
| 1633 |
+
if not self._s2_probe_complete:
|
| 1634 |
+
self._s2_probe_complete = True
|
| 1635 |
+
self._source_metric_add('s2', 'request_count')
|
| 1636 |
+
anonymous = self._requests_get_with_retry(
|
| 1637 |
+
f"{self.s2_api_url}/paper/search",
|
| 1638 |
+
headers={},
|
| 1639 |
+
params=params,
|
| 1640 |
+
)
|
| 1641 |
+
anonymous_status = int(anonymous.status_code)
|
| 1642 |
+
self._source_metric_set('s2', 'anonymous_status', anonymous_status)
|
| 1643 |
+
logging.warning(
|
| 1644 |
+
'S2 access diagnostic: key_present=true authenticated_status=%s anonymous_status=%s',
|
| 1645 |
+
status, anonymous_status,
|
| 1646 |
+
)
|
| 1647 |
+
if anonymous_status not in (401, 403):
|
| 1648 |
+
self._s2_access_mode = 'anonymous'
|
| 1649 |
+
self._source_metric_set('s2', 'access_mode', 'anonymous')
|
| 1650 |
+
response = anonymous
|
| 1651 |
+
status = anonymous_status
|
| 1652 |
+
self._source_metric_set('s2', 'last_status_code', status)
|
| 1653 |
+
else:
|
| 1654 |
+
self._s2_access_mode = 'blocked'
|
| 1655 |
+
self._source_metric_set('s2', 'access_mode', 'blocked')
|
| 1656 |
+
elif self._s2_access_mode == 'anonymous':
|
| 1657 |
+
self._source_metric_add('s2', 'request_count')
|
| 1658 |
+
response = self._requests_get_with_retry(
|
| 1659 |
+
f"{self.s2_api_url}/paper/search", headers={}, params=params
|
| 1660 |
+
)
|
| 1661 |
+
status = int(response.status_code)
|
| 1662 |
+
self._source_metric_set('s2', 'anonymous_status', status)
|
| 1663 |
+
self._source_metric_set('s2', 'last_status_code', status)
|
| 1664 |
+
if self._s2_access_mode == 'blocked' or status in (401, 403):
|
| 1665 |
+
self._record_source_error('s2', 'S2_AUTH_ERROR', status)
|
| 1666 |
+
return []
|
| 1667 |
+
elif status in (401, 403):
|
| 1668 |
+
# Anonymous access itself is refused from this runtime.
|
| 1669 |
+
if access_mode == 'anonymous':
|
| 1670 |
+
self._s2_access_mode = 'blocked'
|
| 1671 |
+
self._source_metric_set('s2', 'access_mode', 'blocked')
|
| 1672 |
self._record_source_error('s2', 'S2_AUTH_ERROR', status)
|
| 1673 |
return []
|
| 1674 |
if status == 429:
|
|
|
|
| 1866 |
self.source_metrics[source]['last_status_code'] = None
|
| 1867 |
self.source_metrics[source]['last_safe_error'] = ''
|
| 1868 |
self.source_metrics[source]['status'] = 'not_started'
|
| 1869 |
+
self.source_metrics[source]['authenticated_status'] = None
|
| 1870 |
+
self.source_metrics[source]['anonymous_status'] = None
|
| 1871 |
+
self._s2_access_mode = 'key' if self.s2_key else 'anonymous'
|
| 1872 |
+
self._s2_probe_complete = not bool(self.s2_key)
|
| 1873 |
+
self.source_metrics['s2']['key_present'] = bool(self.s2_key)
|
| 1874 |
+
self.source_metrics['s2']['access_mode'] = self._s2_access_mode
|
| 1875 |
self.s2_calls = self.oa_calls = 0
|
| 1876 |
|
| 1877 |
all_used_terms = set()
|
|
|
|
| 1999 |
words = re.findall(r"[A-Za-z][A-Za-z0-9_-]{2,}", str(value).lower())
|
| 2000 |
return {word for word in words if word not in stopwords}
|
| 2001 |
|
| 2002 |
+
def build_claim_evidence_plan(self, tasks, topic, synthesis_result, mode='amj', target_unique=60):
|
| 2003 |
+
"""Plan citations by claim/evidence relevance before prose is written.
|
| 2004 |
+
|
| 2005 |
+
This replaces position-based tag allocation. A source is assigned to at
|
| 2006 |
+
most one manuscript unit and only together with a claim that its title/
|
| 2007 |
+
abstract evidence can directly support. Results/Findings, conclusion,
|
| 2008 |
+
abstract-like and appendix units are excluded from literature quotas.
|
| 2009 |
+
"""
|
| 2010 |
+
mode = 'asq' if str(mode).lower() == 'asq' else 'amj'
|
| 2011 |
+
allowed_ids = (
|
| 2012 |
+
{'introduction', 'theoretical_framing', 'methods', 'discussion'}
|
| 2013 |
+
if mode == 'asq'
|
| 2014 |
+
else {'introduction', 'theory', 'methodology', 'discussion'}
|
| 2015 |
+
)
|
| 2016 |
+
section_profiles = APP_CONFIG.get('section_reasoning_profiles', {}).get(mode, {})
|
| 2017 |
+
sections = []
|
| 2018 |
+
seen = set()
|
| 2019 |
+
for task in tasks or []:
|
| 2020 |
+
section_id = str(task.get('section_id') or '')
|
| 2021 |
+
name = str(task.get('display_name') or '').strip()
|
| 2022 |
+
if section_id not in allowed_ids or not name or name in seen:
|
| 2023 |
+
continue
|
| 2024 |
+
seen.add(name)
|
| 2025 |
+
sections.append({
|
| 2026 |
+
'name': name,
|
| 2027 |
+
'section_id': section_id,
|
| 2028 |
+
'role': str(section_profiles.get(section_id) or ''),
|
| 2029 |
+
'target_words': max(1, int(task.get('target_words') or 1)),
|
| 2030 |
+
})
|
| 2031 |
+
if not sections:
|
| 2032 |
+
return {}
|
| 2033 |
+
|
| 2034 |
+
candidates = []
|
| 2035 |
+
for tag in sorted(self.citation_map, key=self._citation_tag_number)[:80]:
|
| 2036 |
+
meta = self.citation_map.get(tag) or {}
|
| 2037 |
+
evidence = re.sub(r'\s+', ' ', str(meta.get('evidence') or '')).strip()
|
| 2038 |
+
if not evidence:
|
| 2039 |
+
continue
|
| 2040 |
+
candidates.append({
|
| 2041 |
+
'tag': tag,
|
| 2042 |
+
'title': str(meta.get('title') or '')[:240],
|
| 2043 |
+
'year': str(meta.get('year') or ''),
|
| 2044 |
+
'evidence': evidence[:900],
|
| 2045 |
+
})
|
| 2046 |
+
target = min(max(1, int(target_unique)), len(candidates))
|
| 2047 |
+
if not candidates:
|
| 2048 |
+
return {}
|
| 2049 |
+
|
| 2050 |
+
total_section_words = sum(item['target_words'] for item in sections) or len(sections)
|
| 2051 |
+
capacities = {}
|
| 2052 |
+
for item in sections:
|
| 2053 |
+
proportional = target * (item['target_words'] / total_section_words)
|
| 2054 |
+
capacities[item['name']] = max(3, int(math.ceil(proportional * 1.18)))
|
| 2055 |
+
while sum(capacities.values()) < target:
|
| 2056 |
+
for item in sorted(sections, key=lambda row: row['target_words'], reverse=True):
|
| 2057 |
+
capacities[item['name']] += 1
|
| 2058 |
+
if sum(capacities.values()) >= target:
|
| 2059 |
+
break
|
| 2060 |
+
|
| 2061 |
+
payload = {
|
| 2062 |
+
'topic': str(topic or '')[:700],
|
| 2063 |
+
'mode': mode,
|
| 2064 |
+
'target_unique_sources': target,
|
| 2065 |
+
'sections': [dict(item, citation_capacity=capacities[item['name']]) for item in sections],
|
| 2066 |
+
'sources': candidates,
|
| 2067 |
+
}
|
| 2068 |
+
system_prompt = """You are a rigorous claim-evidence planner for a top management journal manuscript. Build the citation architecture BEFORE prose is written. Use only the supplied title/abstract evidence. Never use a source to support this study's own sample, coefficients, events, quotations, or findings. Each source may be assigned at most once. Each planned claim must be narrow enough that the supplied evidence directly supports it. Do not manufacture a claim merely to hit the target. Return only valid JSON."""
|
| 2069 |
+
user_prompt = f"""Create a claim-evidence plan.
|
| 2070 |
+
JSON schema:
|
| 2071 |
+
{{"assignments":[{{"tag":"[CITE-01]","section":"exact display_name from sections","claim":"one directly supported external/theoretical/methodological claim"}}]}}
|
| 2072 |
+
Aim for {target} distinct sources when the evidence permits. Distribute sources across appropriate non-results sections; do not assign literature to Results/Findings or Conclusion.
|
| 2073 |
+
INPUT:
|
| 2074 |
+
{json.dumps(payload, ensure_ascii=False)}"""
|
| 2075 |
+
|
| 2076 |
+
best = []
|
| 2077 |
+
for attempt in range(2):
|
| 2078 |
+
raw = self.call_deepseek(
|
| 2079 |
+
system_prompt,
|
| 2080 |
+
user_prompt + ("\nPrevious attempt was incomplete. Return a fuller plan while keeping every claim directly evidence-supported." if attempt else ""),
|
| 2081 |
+
max_tokens=8000,
|
| 2082 |
+
temp=0.1 + attempt * 0.1,
|
| 2083 |
+
model_role='reasoning',
|
| 2084 |
+
thinking=True,
|
| 2085 |
+
allow_fallback=False,
|
| 2086 |
+
)
|
| 2087 |
+
parsed = self._extract_json_from_text(raw)
|
| 2088 |
+
rows = parsed.get('assignments') if isinstance(parsed, dict) else None
|
| 2089 |
+
if not isinstance(rows, list):
|
| 2090 |
+
continue
|
| 2091 |
+
allowed_sections = {item['name'] for item in sections}
|
| 2092 |
+
valid_tags = set(self.citation_map)
|
| 2093 |
+
unique_tags = set()
|
| 2094 |
+
cleaned = []
|
| 2095 |
+
section_loads = {item['name']: 0 for item in sections}
|
| 2096 |
+
for row in rows:
|
| 2097 |
+
if not isinstance(row, dict):
|
| 2098 |
+
continue
|
| 2099 |
+
tag = str(row.get('tag') or '').upper().strip()
|
| 2100 |
+
if not tag.startswith('['):
|
| 2101 |
+
tag = f'[{tag}]'
|
| 2102 |
+
section = str(row.get('section') or '').strip()
|
| 2103 |
+
claim = re.sub(r'\s+', ' ', str(row.get('claim') or '')).strip()
|
| 2104 |
+
if tag not in valid_tags or tag in unique_tags or section not in allowed_sections or len(claim.split()) < 5:
|
| 2105 |
+
continue
|
| 2106 |
+
if section_loads.get(section, 0) >= capacities.get(section, target):
|
| 2107 |
+
continue
|
| 2108 |
+
# Lightweight lexical sanity check. The semantic audit remains
|
| 2109 |
+
# authoritative later, but a zero-overlap plan is rejected now.
|
| 2110 |
+
evidence = str((self.citation_map.get(tag) or {}).get('evidence') or '')
|
| 2111 |
+
overlap = self._citation_terms(claim) & self._citation_terms(evidence)
|
| 2112 |
+
if len(overlap) < 2:
|
| 2113 |
+
continue
|
| 2114 |
+
unique_tags.add(tag)
|
| 2115 |
+
cleaned.append({'tag': tag, 'section': section, 'claim': claim})
|
| 2116 |
+
section_loads[section] = section_loads.get(section, 0) + 1
|
| 2117 |
+
if len(cleaned) > len(best):
|
| 2118 |
+
best = cleaned
|
| 2119 |
+
if len(best) >= target:
|
| 2120 |
+
break
|
| 2121 |
+
|
| 2122 |
+
# If a model response is truncated or structurally incomplete, recover
|
| 2123 |
+
# only with claims copied from the supplied abstract evidence itself.
|
| 2124 |
+
# This is not the old position-based citation allocation: the fallback
|
| 2125 |
+
# still requires nonempty abstract evidence and chooses the manuscript
|
| 2126 |
+
# unit by lexical relevance, while each source remains unique.
|
| 2127 |
+
if len(best) < target:
|
| 2128 |
+
used_tags = {row['tag'] for row in best}
|
| 2129 |
+
loads = {item['name']: sum(1 for row in best if row['section'] == item['name']) for item in sections}
|
| 2130 |
+
for candidate in candidates:
|
| 2131 |
+
if candidate['tag'] in used_tags:
|
| 2132 |
+
continue
|
| 2133 |
+
evidence = str(candidate.get('evidence') or '')
|
| 2134 |
+
abstract = evidence.split('Abstract evidence:', 1)[-1].strip() if 'Abstract evidence:' in evidence else ''
|
| 2135 |
+
if len(abstract.split()) < 8:
|
| 2136 |
+
continue
|
| 2137 |
+
sentence_parts = re.split(r'(?<=[.!?])\s+', abstract)
|
| 2138 |
+
claim = next((part.strip() for part in sentence_parts if 8 <= len(part.split()) <= 55), '')
|
| 2139 |
+
if not claim:
|
| 2140 |
+
words = abstract.split()
|
| 2141 |
+
if len(words) < 8:
|
| 2142 |
+
continue
|
| 2143 |
+
claim = ' '.join(words[:55]).rstrip(' ,;:') + '.'
|
| 2144 |
+
evidence_terms = self._citation_terms(evidence)
|
| 2145 |
+
ranked = []
|
| 2146 |
+
for section in sections:
|
| 2147 |
+
name = section['name']
|
| 2148 |
+
if loads[name] >= capacities.get(name, target):
|
| 2149 |
+
continue
|
| 2150 |
+
section_terms = self._citation_terms(
|
| 2151 |
+
name + ' ' + section['role']
|
| 2152 |
+
)
|
| 2153 |
+
overlap = len(evidence_terms & section_terms)
|
| 2154 |
+
if overlap <= 0:
|
| 2155 |
+
continue
|
| 2156 |
+
# Relevance is primary; normalized load prevents one unit
|
| 2157 |
+
# from absorbing the full citation plan when several units
|
| 2158 |
+
# are genuinely relevant.
|
| 2159 |
+
load_ratio = loads[name] / max(1, capacities.get(name, 1))
|
| 2160 |
+
ranked.append((overlap, -load_ratio, section['target_words'], name))
|
| 2161 |
+
if not ranked:
|
| 2162 |
+
continue
|
| 2163 |
+
ranked.sort(reverse=True)
|
| 2164 |
+
chosen = ranked[0][3]
|
| 2165 |
+
best.append({'tag': candidate['tag'], 'section': chosen, 'claim': claim})
|
| 2166 |
+
used_tags.add(candidate['tag'])
|
| 2167 |
+
loads[chosen] += 1
|
| 2168 |
+
if len(best) >= target:
|
| 2169 |
+
break
|
| 2170 |
+
|
| 2171 |
+
plan = {item['name']: [] for item in sections}
|
| 2172 |
+
for row in best:
|
| 2173 |
+
meta = self.citation_map.get(row['tag']) or {}
|
| 2174 |
+
plan[row['section']].append({
|
| 2175 |
+
'tag': row['tag'],
|
| 2176 |
+
'claim': row['claim'],
|
| 2177 |
+
'evidence': re.sub(r'\s+', ' ', str(meta.get('evidence') or '')).strip()[:900],
|
| 2178 |
+
})
|
| 2179 |
+
logging.info(
|
| 2180 |
+
'Citation-first plan built: target=%s planned=%s sections=%s',
|
| 2181 |
+
target, len(best), sum(1 for items in plan.values() if items),
|
| 2182 |
+
)
|
| 2183 |
+
return plan
|
| 2184 |
+
|
| 2185 |
+
@staticmethod
|
| 2186 |
+
def format_claim_evidence_plan(items):
|
| 2187 |
+
lines = []
|
| 2188 |
+
for item in items or []:
|
| 2189 |
+
tag = str(item.get('tag') or '').strip()
|
| 2190 |
+
claim = str(item.get('claim') or '').strip()
|
| 2191 |
+
evidence = str(item.get('evidence') or '').strip()
|
| 2192 |
+
if tag and claim and evidence:
|
| 2193 |
+
lines.append(f"- {tag} SUPPORTED CLAIM: {claim}\n EVIDENCE: {evidence}")
|
| 2194 |
+
return '\n'.join(lines)
|
| 2195 |
+
|
| 2196 |
+
def build_citation_recovery_paragraph(self, items, section_name=''):
|
| 2197 |
+
"""Create one bounded literature paragraph from already planned evidence.
|
| 2198 |
+
|
| 2199 |
+
This is invoked only when semantic validation removed too many planned
|
| 2200 |
+
citations from a unit. It never invents new sources or study facts.
|
| 2201 |
+
"""
|
| 2202 |
+
selected = [item for item in (items or []) if item.get('tag') and item.get('claim') and item.get('evidence')][:5]
|
| 2203 |
+
if not selected:
|
| 2204 |
+
return ''
|
| 2205 |
+
plan_text = self.format_claim_evidence_plan(selected)
|
| 2206 |
+
expected = [str(item['tag']) for item in selected]
|
| 2207 |
+
prompt = f"""Write ONE cohesive top-journal literature paragraph for the named manuscript unit using ONLY the planned claim-evidence items below.
|
| 2208 |
+
Rules:
|
| 2209 |
+
1. Use every assigned [CITE-XX] tag exactly once, attached only to its own directly supported claim.
|
| 2210 |
+
2. Do not broaden a claim beyond its evidence and do not add any study-specific fact, statistic, setting, result, quotation, hypothesis, or new source.
|
| 2211 |
+
3. Do not stack citations. Keep each source's claim in a distinct sentence or clause.
|
| 2212 |
+
4. Avoid generic literature-listing prose and contribution-counting templates; connect the claims through one clear theoretical point relevant to the unit.
|
| 2213 |
+
5. Output only the paragraph, no heading or commentary.
|
| 2214 |
+
SECTION: {section_name}
|
| 2215 |
+
<CLAIM_EVIDENCE_PLAN>
|
| 2216 |
+
{plan_text}
|
| 2217 |
+
</CLAIM_EVIDENCE_PLAN>"""
|
| 2218 |
+
raw = self.call_deepseek(
|
| 2219 |
+
'You are a precise management-journal evidence integrator.',
|
| 2220 |
+
prompt,
|
| 2221 |
+
max_tokens=1400,
|
| 2222 |
+
temp=0.1,
|
| 2223 |
+
model_role='writing',
|
| 2224 |
+
thinking=False,
|
| 2225 |
+
allow_fallback=False,
|
| 2226 |
+
).strip()
|
| 2227 |
+
actual = Counter(re.findall(r'\[CITE-\d+\]', raw, re.I))
|
| 2228 |
+
expected_counter = Counter(expected)
|
| 2229 |
+
if actual != expected_counter:
|
| 2230 |
+
logging.warning('Citation recovery paragraph model output rejected; using evidence-plan fallback: section=%s', section_name)
|
| 2231 |
+
raw = ' '.join(f"{str(item['claim']).rstrip('.')} {item['tag']}." for item in selected)
|
| 2232 |
+
return raw.strip()
|
| 2233 |
+
|
| 2234 |
def _candidate_citation_tags(self, sentence, required_tags, limit):
|
| 2235 |
valid_required = [
|
| 2236 |
tag for tag in required_tags
|
|
|
|
| 2871 |
value = re.sub(pattern, '', value)
|
| 2872 |
return re.sub(r'\n{3,}', '\n\n', value).strip()
|
| 2873 |
|
| 2874 |
+
def polish_academic_section(self, text, section_name='', abstract=False, mode='amj'):
|
| 2875 |
value = self._remove_ai_meta_traces(text)
|
| 2876 |
quality = APP_CONFIG.get('manuscript_quality', {})
|
| 2877 |
forbidden = quality.get('forbidden_phrases', [])
|
|
|
|
| 2888 |
if abstract
|
| 2889 |
else "Preserve every number, percentage, coefficient, p-value, and confidence interval exactly."
|
| 2890 |
)
|
| 2891 |
+
voice = str(APP_CONFIG.get('journal_voice_profiles', {}).get('asq' if str(mode).lower() == 'asq' else 'amj') or '')
|
| 2892 |
+
prompt = f"""Revise only the supplied academic section. Preserve every heading, citation tag, quotation, hypothesis label, variable abbreviation, and conclusion direction exactly. {numeric_rule} Use concise, accurate, rigorous academic English. Remove formulaic transitions, repetitive summaries, metaphors, analogies, promotional language, and any AI-writing meta-commentary. Do not add new evidence or citations.
|
| 2893 |
+
JOURNAL VOICE: {voice}
|
| 2894 |
+
SECTION: {section_name}
|
| 2895 |
+
TEXT:
|
| 2896 |
+
{value}"""
|
| 2897 |
revised = self.call_deepseek(
|
| 2898 |
'You are a meticulous academic language editor. Output only the revised section.',
|
| 2899 |
prompt,
|
|
|
|
| 2925 |
if original_tags != revised_tags:
|
| 2926 |
logging.warning('Quality revision rejected because citation tags changed: %s', section_name)
|
| 2927 |
return value
|
| 2928 |
+
original_quote_tags = Counter(re.findall(r'QUOTE-\d+', value, re.I))
|
| 2929 |
+
revised_quote_tags = Counter(re.findall(r'QUOTE-\d+', revised, re.I))
|
| 2930 |
+
if original_quote_tags != revised_quote_tags:
|
| 2931 |
+
logging.warning('Quality revision rejected because quotation tags changed: %s', section_name)
|
| 2932 |
+
return value
|
| 2933 |
return revised.strip()
|
| 2934 |
|
| 2935 |
+
def critique_and_repair_section(self, text, *, section_id, section_name, mode, evidence_contract=''):
|
| 2936 |
+
"""Independent top-journal critic with bounded local repair."""
|
| 2937 |
+
value = str(text or '').strip()
|
| 2938 |
+
mode = 'asq' if str(mode).lower() == 'asq' else 'amj'
|
| 2939 |
+
critical_sections = ({'introduction', 'theory', 'discussion'} if mode == 'amj' else {'introduction', 'theoretical_framing', 'findings', 'discussion'})
|
| 2940 |
+
if str(section_id or '') not in critical_sections or len(value.split()) < 120:
|
| 2941 |
+
return value
|
| 2942 |
+
dimensions = APP_CONFIG.get('top_journal_critic_profiles', {}).get(mode, [])
|
| 2943 |
+
profile = APP_CONFIG.get('section_reasoning_profiles', {}).get(mode, {}).get(str(section_id or ''), '')
|
| 2944 |
+
prompt = f"""Assess ONLY this manuscript unit as an independent top-journal critic. Return valid JSON only:
|
| 2945 |
+
{{"pass":true,"critical_issue":"","repair_instruction":""}}
|
| 2946 |
+
Set pass=false only for a material argument/evidence defect, not for minor stylistic preference. Evaluate: {', '.join(dimensions)}.
|
| 2947 |
+
SECTION ROLE: {profile}
|
| 2948 |
+
EVIDENCE CONTRACT: {evidence_contract[:4500]}
|
| 2949 |
+
TEXT:
|
| 2950 |
+
{value}"""
|
| 2951 |
+
raw = self.call_deepseek(
|
| 2952 |
+
'You are an independent senior management-journal reviewer. Be exact and conservative.',
|
| 2953 |
+
prompt, max_tokens=800, temp=0.0, model_role='fast', thinking=False, allow_fallback=True,
|
| 2954 |
+
)
|
| 2955 |
+
parsed = self._extract_json_from_text(raw)
|
| 2956 |
+
if not isinstance(parsed, dict) or parsed.get('pass') is not False:
|
| 2957 |
+
return value
|
| 2958 |
+
instruction = re.sub(r'\s+', ' ', str(parsed.get('repair_instruction') or parsed.get('critical_issue') or '')).strip()
|
| 2959 |
+
if not instruction:
|
| 2960 |
+
return value
|
| 2961 |
+
repair_prompt = f"""Repair only the material issue identified by an independent reviewer.
|
| 2962 |
+
Do not change or remove any number, citation tag, quotation/QUOTE tag, hypothesis label, variable abbreviation, heading, or empirical conclusion direction. Do not add a new statistic, source, quotation, setting fact, sample fact, or method. Preserve the approximate length.
|
| 2963 |
+
MODE: {mode}
|
| 2964 |
+
SECTION: {section_name}
|
| 2965 |
+
REVIEWER INSTRUCTION: {instruction}
|
| 2966 |
+
EVIDENCE CONTRACT: {evidence_contract[:4500]}
|
| 2967 |
+
TEXT:
|
| 2968 |
+
{value}
|
| 2969 |
+
Output only the repaired section."""
|
| 2970 |
+
revised = self.call_deepseek(
|
| 2971 |
+
'You are a meticulous top-journal academic editor.', repair_prompt,
|
| 2972 |
+
max_tokens=max(1600, min(8000, self._count_tokens(value) + 1000)), temp=0.1,
|
| 2973 |
+
model_role='writing', thinking=False, allow_fallback=False,
|
| 2974 |
+
).strip()
|
| 2975 |
+
invariants = [
|
| 2976 |
+
(self._numeric_tokens(value), self._numeric_tokens(revised)),
|
| 2977 |
+
(Counter(re.findall(r'CITE-\d+', value, re.I)), Counter(re.findall(r'CITE-\d+', revised, re.I))),
|
| 2978 |
+
(Counter(re.findall(r'QUOTE-\d+', value, re.I)), Counter(re.findall(r'QUOTE-\d+', revised, re.I))),
|
| 2979 |
+
(Counter(re.findall(r'\bH\d+[A-Za-z]?\b', value)), Counter(re.findall(r'\bH\d+[A-Za-z]?\b', revised))),
|
| 2980 |
+
]
|
| 2981 |
+
if any(left != right for left, right in invariants):
|
| 2982 |
+
logging.warning('Top-journal local repair rejected because protected content changed: %s', section_name)
|
| 2983 |
+
return value
|
| 2984 |
+
logging.info('Top-journal local repair accepted: section=%s issue=%s', section_name, instruction[:180])
|
| 2985 |
+
return self._remove_ai_meta_traces(revised)
|
| 2986 |
+
|
| 2987 |
+
def rebalance_discussion_length(self, manuscript, *, target_words, mode, actual_words):
|
| 2988 |
+
"""Use Discussion as the bounded final word-budget correction surface."""
|
| 2989 |
+
value = str(manuscript or '')
|
| 2990 |
+
delta = int(target_words) - int(actual_words)
|
| 2991 |
+
if abs(delta) < 40:
|
| 2992 |
+
return value
|
| 2993 |
+
match = re.search(r'(?ms)^#\s+Discussion\s*$\n(.*?)(?=^#\s+[^#].*?$|\Z)', value)
|
| 2994 |
+
if not match:
|
| 2995 |
+
return value
|
| 2996 |
+
original = match.group(1).strip()
|
| 2997 |
+
if len(original.split()) < 200:
|
| 2998 |
+
return value
|
| 2999 |
+
action = 'expand' if delta > 0 else 'compress'
|
| 3000 |
+
requested = min(abs(delta), max(120, int(len(original.split()) * 0.28)))
|
| 3001 |
+
prompt = f"""{action.capitalize()} this Discussion section by approximately {requested} words so the completed manuscript approaches its requested total length.
|
| 3002 |
+
Preserve every heading, number, resolved author-year citation, quotation, hypothesis label, variable abbreviation, and conclusion direction. Do not add new empirical facts, sources, statistics, quotations, or methods. If expanding, deepen mechanism, theoretical boundary, and interpretation already supported by the section. If compressing, remove repetition and low-information phrasing without deleting a substantive finding or citation.
|
| 3003 |
+
MODE: {mode}
|
| 3004 |
+
TEXT:
|
| 3005 |
+
{original}
|
| 3006 |
+
Output only the revised Discussion body, without the '# Discussion' heading."""
|
| 3007 |
+
revised = self.call_deepseek(
|
| 3008 |
+
'You are a precise top-journal length editor.', prompt,
|
| 3009 |
+
max_tokens=max(1800, min(8000, self._count_tokens(original) + max(800, requested * 2))),
|
| 3010 |
+
temp=0.1, model_role='writing', thinking=False, allow_fallback=False,
|
| 3011 |
+
).strip()
|
| 3012 |
+
if self._numeric_tokens(original) != self._numeric_tokens(revised):
|
| 3013 |
+
logging.warning('Word-budget Discussion repair rejected because numbers changed.')
|
| 3014 |
+
return value
|
| 3015 |
+
cite_pattern = r'\([^()\n]*?(?:19|20)\d{2}[a-z]?[^()\n]*?\)'
|
| 3016 |
+
if Counter(re.findall(cite_pattern, original, re.I)) != Counter(re.findall(cite_pattern, revised, re.I)):
|
| 3017 |
+
logging.warning('Word-budget Discussion repair rejected because citations changed.')
|
| 3018 |
+
return value
|
| 3019 |
+
return value[:match.start(1)] + revised + '\n\n' + value[match.end(1):]
|
| 3020 |
+
|
| 3021 |
@staticmethod
|
| 3022 |
def _trim_at_sentence_boundary(value, max_words):
|
| 3023 |
text = str(value or '').strip()
|
|
|
|
| 3090 |
)
|
| 3091 |
abstract = self._remove_ai_meta_traces(abstract)
|
| 3092 |
abstract = re.sub(r'(?im)^#+\s*abstract\s*', '', abstract).strip()
|
| 3093 |
+
abstract = self.polish_academic_section(abstract, 'Abstract', abstract=True, mode=mode)
|
| 3094 |
max_words = max(80, int(APP_CONFIG.get('manuscript_quality', {}).get('abstract_max_words', 200)))
|
| 3095 |
return self._trim_at_sentence_boundary(abstract, max_words)
|
| 3096 |
|
core/qualitative_engine.py
CHANGED
|
@@ -1,16 +1,24 @@
|
|
| 1 |
# -*- coding: utf-8 -*-
|
| 2 |
-
"""Qualitative evidence preparation for SmartPaper's ASQ track."""
|
| 3 |
|
| 4 |
from __future__ import annotations
|
| 5 |
|
| 6 |
import logging
|
| 7 |
import re
|
| 8 |
|
| 9 |
-
|
| 10 |
from config import APP_CONFIG
|
| 11 |
|
| 12 |
|
| 13 |
class QualitativeEngine:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
def __init__(self, agent):
|
| 15 |
self.agent = agent
|
| 16 |
|
|
@@ -19,31 +27,52 @@ class QualitativeEngine:
|
|
| 19 |
cleaned = str(value or default).replace("\n", " ").strip()
|
| 20 |
return cleaned or default
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
def _build_request(self, topic, keywords, raw_interviews):
|
| 23 |
schema = """Return ONLY one valid JSON object with this schema:
|
| 24 |
{
|
| 25 |
"study_facts": {
|
| 26 |
-
"research_setting": "string",
|
| 27 |
"data_sources": ["string"],
|
| 28 |
"participant_count": integer or null,
|
| 29 |
"interview_count": integer or null,
|
| 30 |
-
"
|
|
|
|
|
|
|
|
|
|
| 31 |
},
|
| 32 |
"evidence": [
|
| 33 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
]
|
| 35 |
}
|
|
|
|
| 36 |
"""
|
| 37 |
if raw_interviews and len(raw_interviews) > 100:
|
| 38 |
return f"""Topic: {topic}
|
| 39 |
Keywords: {keywords}
|
| 40 |
-
Analyze the supplied interview or field material.
|
| 41 |
|
| 42 |
Rules:
|
| 43 |
-
1.
|
| 44 |
-
2. Extract
|
| 45 |
-
3.
|
| 46 |
-
4.
|
|
|
|
|
|
|
| 47 |
|
| 48 |
{schema}
|
| 49 |
<QUALITATIVE_MATERIAL>
|
|
@@ -53,14 +82,17 @@ Rules:
|
|
| 53 |
|
| 54 |
return f"""Topic: {topic}
|
| 55 |
Keywords: {keywords}
|
| 56 |
-
Construct one internally coherent qualitative
|
| 57 |
|
| 58 |
Rules:
|
| 59 |
-
1. Choose one research setting and keep it fixed.
|
| 60 |
-
2.
|
| 61 |
-
3. Produce exactly 12 natural representative quotations
|
| 62 |
-
4.
|
| 63 |
-
5.
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
{schema}
|
| 66 |
"""
|
|
@@ -69,11 +101,11 @@ Rules:
|
|
| 69 |
has_user_data = bool(raw_interviews and len(raw_interviews) > 100)
|
| 70 |
prompt = self._build_request(topic, keywords, raw_interviews)
|
| 71 |
# R3-Free engine code is headless. Streamlit rendering belongs to app.py;
|
| 72 |
-
# this method only produces
|
| 73 |
response = "".join(
|
| 74 |
str(chunk or "")
|
| 75 |
for chunk in self.agent.stream_deepseek(
|
| 76 |
-
"You are a rigorous qualitative research analyst.",
|
| 77 |
prompt,
|
| 78 |
model_role="reasoning",
|
| 79 |
thinking=True,
|
|
@@ -90,80 +122,88 @@ Rules:
|
|
| 90 |
data = self.agent._extract_json_from_text(response)
|
| 91 |
|
| 92 |
if not isinstance(data, dict):
|
| 93 |
-
|
| 94 |
-
data = {
|
| 95 |
-
"study_facts": {
|
| 96 |
-
"research_setting": "Not specified",
|
| 97 |
-
"data_sources": ["Interview or field material"],
|
| 98 |
-
"participant_count": None,
|
| 99 |
-
"interview_count": None,
|
| 100 |
-
"analysis_method": "Gioia-style inductive coding",
|
| 101 |
-
},
|
| 102 |
-
"evidence": [
|
| 103 |
-
{
|
| 104 |
-
"theme": "Sensemaking",
|
| 105 |
-
"concept": "Initial uncertainty",
|
| 106 |
-
"quote": (
|
| 107 |
-
"We did not yet have a shared understanding "
|
| 108 |
-
"of how to respond."
|
| 109 |
-
),
|
| 110 |
-
}
|
| 111 |
-
],
|
| 112 |
-
}
|
| 113 |
|
| 114 |
facts = data.get("study_facts") or {}
|
| 115 |
evidence = data.get("evidence") or data.get("quotes") or []
|
| 116 |
-
if not isinstance(evidence, list):
|
| 117 |
-
evidence
|
| 118 |
|
| 119 |
data_sources = facts.get("data_sources") or ["Not specified"]
|
| 120 |
if not isinstance(data_sources, list):
|
| 121 |
data_sources = [data_sources]
|
| 122 |
|
| 123 |
setting = self._clean_cell(facts.get("research_setting"))
|
| 124 |
-
|
|
|
|
| 125 |
participants = facts.get("participant_count")
|
| 126 |
interviews = facts.get("interview_count")
|
| 127 |
-
|
| 128 |
-
facts.get("analysis_method")
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
| 130 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
|
| 132 |
md = (
|
| 133 |
-
"### Qualitative Study Design
|
| 134 |
"| Field | Frozen value |\n"
|
| 135 |
"| :--- | :--- |\n"
|
| 136 |
f"| Research setting | {setting} |\n"
|
| 137 |
f"| Data sources | {sources} |\n"
|
| 138 |
f"| Participants | {participants if participants is not None else 'Not specified'} |\n"
|
| 139 |
f"| Interviews | {interviews if interviews is not None else 'Not specified'} |\n"
|
| 140 |
-
f"|
|
| 141 |
-
"
|
| 142 |
-
"
|
| 143 |
-
"
|
| 144 |
-
"
|
| 145 |
-
"
|
| 146 |
-
"
|
| 147 |
-
"
|
|
|
|
|
|
|
|
|
|
| 148 |
)
|
| 149 |
|
| 150 |
clean_evidence = []
|
| 151 |
-
|
|
|
|
| 152 |
if not isinstance(item, dict):
|
| 153 |
continue
|
| 154 |
quote = self._clean_cell(item.get("quote"), "").replace('"', "'")
|
| 155 |
-
concept = self._clean_cell(item.get("concept"), "
|
| 156 |
-
theme = self._clean_cell(item.get("theme"), "
|
| 157 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
continue
|
| 159 |
-
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
clean_evidence.append(
|
| 162 |
{
|
| 163 |
"theme": theme,
|
| 164 |
"concept": concept,
|
| 165 |
"quote": quote,
|
| 166 |
"tag": tag,
|
|
|
|
|
|
|
|
|
|
| 167 |
}
|
| 168 |
)
|
| 169 |
|
|
@@ -171,12 +211,28 @@ Rules:
|
|
| 171 |
for item in clean_evidence:
|
| 172 |
theme_entry = theme_map.setdefault(
|
| 173 |
item["theme"],
|
| 174 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
)
|
| 176 |
if item["concept"] not in theme_entry["concepts"]:
|
| 177 |
theme_entry["concepts"].append(item["concept"])
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
theme_entry["evidence"].append(
|
| 179 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
)
|
| 181 |
|
| 182 |
themes = list(theme_map.values())
|
|
@@ -189,6 +245,8 @@ Rules:
|
|
| 189 |
f"themes={len(themes)}, quotes={len(clean_evidence)}; "
|
| 190 |
f"required themes>={minimum_themes}, quotes>={minimum_quotes}."
|
| 191 |
)
|
|
|
|
|
|
|
| 192 |
|
| 193 |
metadata = {
|
| 194 |
"mode": "qualitative",
|
|
@@ -198,31 +256,37 @@ Rules:
|
|
| 198 |
"disclosure_required": not has_user_data,
|
| 199 |
"study_facts": {
|
| 200 |
"research_setting": setting,
|
| 201 |
-
"data_sources":
|
| 202 |
"participant_count": participants,
|
| 203 |
"interview_count": interviews,
|
| 204 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
},
|
| 206 |
"evidence_count": len(clean_evidence),
|
| 207 |
"generation_parameters": {
|
| 208 |
-
"
|
| 209 |
"requested_quote_count_when_generated": 12,
|
| 210 |
"maximum_retained_quotes": 15,
|
| 211 |
"minimum_themes": minimum_themes,
|
| 212 |
"minimum_quotes": minimum_quotes,
|
|
|
|
| 213 |
"consistency_constraints": [
|
| 214 |
-
"one
|
| 215 |
-
"one
|
| 216 |
-
"one
|
| 217 |
-
"one
|
| 218 |
-
"one
|
|
|
|
| 219 |
"quotation tags must remain unique",
|
| 220 |
],
|
| 221 |
},
|
| 222 |
"evidence": clean_evidence,
|
| 223 |
"themes": themes,
|
| 224 |
}
|
| 225 |
-
status_message = "✅ 定性研究事实、
|
| 226 |
return md, status_message, metadata
|
| 227 |
|
| 228 |
|
|
|
|
| 1 |
# -*- coding: utf-8 -*-
|
| 2 |
+
"""Qualitative evidence preparation for SmartPaper's ASQ-oriented track."""
|
| 3 |
|
| 4 |
from __future__ import annotations
|
| 5 |
|
| 6 |
import logging
|
| 7 |
import re
|
| 8 |
|
|
|
|
| 9 |
from config import APP_CONFIG
|
| 10 |
|
| 11 |
|
| 12 |
class QualitativeEngine:
|
| 13 |
+
ALLOWED_ANALYTIC_DESIGNS = {
|
| 14 |
+
"Process analysis",
|
| 15 |
+
"Comparative case analysis",
|
| 16 |
+
"Grounded theory",
|
| 17 |
+
"Gioia-style inductive coding",
|
| 18 |
+
"Longitudinal qualitative analysis",
|
| 19 |
+
"Iterative thematic/process analysis",
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
def __init__(self, agent):
|
| 23 |
self.agent = agent
|
| 24 |
|
|
|
|
| 27 |
cleaned = str(value or default).replace("\n", " ").strip()
|
| 28 |
return cleaned or default
|
| 29 |
|
| 30 |
+
@classmethod
|
| 31 |
+
def _normalize_analytic_design(cls, value):
|
| 32 |
+
cleaned = re.sub(r"\s+", " ", str(value or "")).strip()
|
| 33 |
+
if not cleaned:
|
| 34 |
+
return "Iterative thematic/process analysis"
|
| 35 |
+
lookup = {item.lower(): item for item in cls.ALLOWED_ANALYTIC_DESIGNS}
|
| 36 |
+
return lookup.get(cleaned.lower(), "Iterative thematic/process analysis")
|
| 37 |
+
|
| 38 |
def _build_request(self, topic, keywords, raw_interviews):
|
| 39 |
schema = """Return ONLY one valid JSON object with this schema:
|
| 40 |
{
|
| 41 |
"study_facts": {
|
| 42 |
+
"research_setting": "string or Not specified",
|
| 43 |
"data_sources": ["string"],
|
| 44 |
"participant_count": integer or null,
|
| 45 |
"interview_count": integer or null,
|
| 46 |
+
"analytic_design": "one allowed design",
|
| 47 |
+
"analytic_logic": "one concise sentence",
|
| 48 |
+
"temporal_structure": "string or Not specified",
|
| 49 |
+
"case_structure": "string or Not specified"
|
| 50 |
},
|
| 51 |
"evidence": [
|
| 52 |
+
{
|
| 53 |
+
"theme": "higher-order pattern/theme",
|
| 54 |
+
"concept": "empirical concept",
|
| 55 |
+
"quote": "verbatim quotation",
|
| 56 |
+
"stage": "temporal/process stage or Not specified",
|
| 57 |
+
"case_label": "generic case/actor label or Not specified",
|
| 58 |
+
"evidence_role": "typical or deviant"
|
| 59 |
+
}
|
| 60 |
]
|
| 61 |
}
|
| 62 |
+
Allowed analytic designs: Process analysis; Comparative case analysis; Grounded theory; Gioia-style inductive coding; Longitudinal qualitative analysis; Iterative thematic/process analysis.
|
| 63 |
"""
|
| 64 |
if raw_interviews and len(raw_interviews) > 100:
|
| 65 |
return f"""Topic: {topic}
|
| 66 |
Keywords: {keywords}
|
| 67 |
+
Analyze the supplied interview or field material for an ASQ-oriented qualitative manuscript.
|
| 68 |
|
| 69 |
Rules:
|
| 70 |
+
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.
|
| 71 |
+
2. Extract 8-15 representative quotations VERBATIM. Never repair, paraphrase, translate, combine, or invent a quotation.
|
| 72 |
+
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.
|
| 73 |
+
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.
|
| 74 |
+
5. Do not manufacture a longitudinal sequence when the material is cross-sectional, and do not manufacture multiple cases when only one is present.
|
| 75 |
+
6. The study_facts object and quotations will become the frozen single source of truth for the entire manuscript.
|
| 76 |
|
| 77 |
{schema}
|
| 78 |
<QUALITATIVE_MATERIAL>
|
|
|
|
| 82 |
|
| 83 |
return f"""Topic: {topic}
|
| 84 |
Keywords: {keywords}
|
| 85 |
+
Construct one internally coherent qualitative evidence packet for a complete ASQ-oriented manuscript because no interview material was supplied.
|
| 86 |
|
| 87 |
Rules:
|
| 88 |
+
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.
|
| 89 |
+
2. Freeze one participant count, interview count, data-source set, analytic design, temporal/case structure, and analytic logic. Keep them internally consistent.
|
| 90 |
+
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.
|
| 91 |
+
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.
|
| 92 |
+
5. Include at least one clearly labelled deviant/boundary observation so the eventual theory is not built only from confirming examples.
|
| 93 |
+
6. The evidence should support a process/mechanism explanation, not merely descriptive topic categories.
|
| 94 |
+
7. The study_facts object and evidence packet will become the frozen single source of truth for the entire manuscript.
|
| 95 |
+
8. Avoid decorative language, generic AI phrasing, and repetitive quotation patterns.
|
| 96 |
|
| 97 |
{schema}
|
| 98 |
"""
|
|
|
|
| 101 |
has_user_data = bool(raw_interviews and len(raw_interviews) > 100)
|
| 102 |
prompt = self._build_request(topic, keywords, raw_interviews)
|
| 103 |
# R3-Free engine code is headless. Streamlit rendering belongs to app.py;
|
| 104 |
+
# this method only produces frozen evidence + metadata.
|
| 105 |
response = "".join(
|
| 106 |
str(chunk or "")
|
| 107 |
for chunk in self.agent.stream_deepseek(
|
| 108 |
+
"You are a rigorous qualitative research analyst. Evidence fidelity has priority over narrative completeness.",
|
| 109 |
prompt,
|
| 110 |
model_role="reasoning",
|
| 111 |
thinking=True,
|
|
|
|
| 122 |
data = self.agent._extract_json_from_text(response)
|
| 123 |
|
| 124 |
if not isinstance(data, dict):
|
| 125 |
+
raise RuntimeError("Qualitative evidence packet could not be parsed safely.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
facts = data.get("study_facts") or {}
|
| 128 |
evidence = data.get("evidence") or data.get("quotes") or []
|
| 129 |
+
if not isinstance(facts, dict) or not isinstance(evidence, list):
|
| 130 |
+
raise RuntimeError("Qualitative evidence packet has an invalid schema.")
|
| 131 |
|
| 132 |
data_sources = facts.get("data_sources") or ["Not specified"]
|
| 133 |
if not isinstance(data_sources, list):
|
| 134 |
data_sources = [data_sources]
|
| 135 |
|
| 136 |
setting = self._clean_cell(facts.get("research_setting"))
|
| 137 |
+
clean_sources = [self._clean_cell(item) for item in data_sources]
|
| 138 |
+
sources = ", ".join(clean_sources)
|
| 139 |
participants = facts.get("participant_count")
|
| 140 |
interviews = facts.get("interview_count")
|
| 141 |
+
analytic_design = self._normalize_analytic_design(
|
| 142 |
+
facts.get("analytic_design") or facts.get("analysis_method")
|
| 143 |
+
)
|
| 144 |
+
analytic_logic = self._clean_cell(
|
| 145 |
+
facts.get("analytic_logic"),
|
| 146 |
+
"Iteratively compare empirical observations to refine a process/mechanism explanation.",
|
| 147 |
)
|
| 148 |
+
temporal_structure = self._clean_cell(facts.get("temporal_structure"))
|
| 149 |
+
case_structure = self._clean_cell(facts.get("case_structure"))
|
| 150 |
+
|
| 151 |
+
for value, label in ((participants, "participant_count"), (interviews, "interview_count")):
|
| 152 |
+
if value is not None and (not isinstance(value, int) or value <= 0):
|
| 153 |
+
raise RuntimeError(f"Qualitative evidence packet has invalid {label}.")
|
| 154 |
|
| 155 |
md = (
|
| 156 |
+
"### Frozen Qualitative Study Design\n"
|
| 157 |
"| Field | Frozen value |\n"
|
| 158 |
"| :--- | :--- |\n"
|
| 159 |
f"| Research setting | {setting} |\n"
|
| 160 |
f"| Data sources | {sources} |\n"
|
| 161 |
f"| Participants | {participants if participants is not None else 'Not specified'} |\n"
|
| 162 |
f"| Interviews | {interviews if interviews is not None else 'Not specified'} |\n"
|
| 163 |
+
f"| Analytic design | {analytic_design} |\n"
|
| 164 |
+
f"| Analytic logic | {analytic_logic} |\n"
|
| 165 |
+
f"| Temporal structure | {temporal_structure} |\n"
|
| 166 |
+
f"| Case structure | {case_structure} |\n\n"
|
| 167 |
+
"[QUALITATIVE EVIDENCE CONTRACT]\n"
|
| 168 |
+
"Use the frozen design summary and tagged evidence below as the only source of this study's empirical facts. "
|
| 169 |
+
"Do not change the setting, sample, interview count, data sources, analytic design, temporal structure, cases, or quotations. "
|
| 170 |
+
"Do not infer an event sequence, actor comparison, negative case, or quotation that is absent from this packet.\n\n"
|
| 171 |
+
"### Table: Qualitative Evidence Structure\n"
|
| 172 |
+
"| Higher-order Theme | Empirical Concept | Process Stage | Case/Actor | Evidence Role | Representative Quote |\n"
|
| 173 |
+
"| :--- | :--- | :--- | :--- | :--- | :--- |\n"
|
| 174 |
)
|
| 175 |
|
| 176 |
clean_evidence = []
|
| 177 |
+
seen_quotes = set()
|
| 178 |
+
for item in evidence[:15]:
|
| 179 |
if not isinstance(item, dict):
|
| 180 |
continue
|
| 181 |
quote = self._clean_cell(item.get("quote"), "").replace('"', "'")
|
| 182 |
+
concept = self._clean_cell(item.get("concept"), "Empirical observation")
|
| 183 |
+
theme = self._clean_cell(item.get("theme"), "Emergent pattern")
|
| 184 |
+
stage = self._clean_cell(item.get("stage"))
|
| 185 |
+
case_label = self._clean_cell(item.get("case_label"))
|
| 186 |
+
evidence_role = str(item.get("evidence_role") or "typical").strip().lower()
|
| 187 |
+
if evidence_role not in {"typical", "deviant"}:
|
| 188 |
+
evidence_role = "typical"
|
| 189 |
+
normalized_quote = re.sub(r"\s+", " ", quote).strip().lower()
|
| 190 |
+
if not quote or len(quote.split()) < 4 or normalized_quote in seen_quotes:
|
| 191 |
continue
|
| 192 |
+
seen_quotes.add(normalized_quote)
|
| 193 |
+
tag = f"QUOTE-{len(clean_evidence) + 1:02d}"
|
| 194 |
+
md += (
|
| 195 |
+
f'| {theme} | {concept} | {stage} | {case_label} | {evidence_role} | '
|
| 196 |
+
f'"{quote}" [{tag}] |\n'
|
| 197 |
+
)
|
| 198 |
clean_evidence.append(
|
| 199 |
{
|
| 200 |
"theme": theme,
|
| 201 |
"concept": concept,
|
| 202 |
"quote": quote,
|
| 203 |
"tag": tag,
|
| 204 |
+
"stage": stage,
|
| 205 |
+
"case_label": case_label,
|
| 206 |
+
"evidence_role": evidence_role,
|
| 207 |
}
|
| 208 |
)
|
| 209 |
|
|
|
|
| 211 |
for item in clean_evidence:
|
| 212 |
theme_entry = theme_map.setdefault(
|
| 213 |
item["theme"],
|
| 214 |
+
{
|
| 215 |
+
"title": item["theme"],
|
| 216 |
+
"concepts": [],
|
| 217 |
+
"evidence": [],
|
| 218 |
+
"stages": [],
|
| 219 |
+
"has_deviant_evidence": False,
|
| 220 |
+
},
|
| 221 |
)
|
| 222 |
if item["concept"] not in theme_entry["concepts"]:
|
| 223 |
theme_entry["concepts"].append(item["concept"])
|
| 224 |
+
if item["stage"] != "Not specified" and item["stage"] not in theme_entry["stages"]:
|
| 225 |
+
theme_entry["stages"].append(item["stage"])
|
| 226 |
+
if item["evidence_role"] == "deviant":
|
| 227 |
+
theme_entry["has_deviant_evidence"] = True
|
| 228 |
theme_entry["evidence"].append(
|
| 229 |
+
{
|
| 230 |
+
"tag": item["tag"],
|
| 231 |
+
"quote": item["quote"],
|
| 232 |
+
"stage": item["stage"],
|
| 233 |
+
"case_label": item["case_label"],
|
| 234 |
+
"evidence_role": item["evidence_role"],
|
| 235 |
+
}
|
| 236 |
)
|
| 237 |
|
| 238 |
themes = list(theme_map.values())
|
|
|
|
| 245 |
f"themes={len(themes)}, quotes={len(clean_evidence)}; "
|
| 246 |
f"required themes>={minimum_themes}, quotes>={minimum_quotes}."
|
| 247 |
)
|
| 248 |
+
if not has_user_data and not any(item["evidence_role"] == "deviant" for item in clean_evidence):
|
| 249 |
+
raise RuntimeError("Generated qualitative evidence packet lacks a deviant/boundary observation.")
|
| 250 |
|
| 251 |
metadata = {
|
| 252 |
"mode": "qualitative",
|
|
|
|
| 256 |
"disclosure_required": not has_user_data,
|
| 257 |
"study_facts": {
|
| 258 |
"research_setting": setting,
|
| 259 |
+
"data_sources": clean_sources,
|
| 260 |
"participant_count": participants,
|
| 261 |
"interview_count": interviews,
|
| 262 |
+
"analytic_design": analytic_design,
|
| 263 |
+
"analysis_method": analytic_design,
|
| 264 |
+
"analytic_logic": analytic_logic,
|
| 265 |
+
"temporal_structure": temporal_structure,
|
| 266 |
+
"case_structure": case_structure,
|
| 267 |
},
|
| 268 |
"evidence_count": len(clean_evidence),
|
| 269 |
"generation_parameters": {
|
| 270 |
+
"analytic_design": analytic_design,
|
| 271 |
"requested_quote_count_when_generated": 12,
|
| 272 |
"maximum_retained_quotes": 15,
|
| 273 |
"minimum_themes": minimum_themes,
|
| 274 |
"minimum_quotes": minimum_quotes,
|
| 275 |
+
"requires_generated_deviant_evidence": not has_user_data,
|
| 276 |
"consistency_constraints": [
|
| 277 |
+
"one frozen research setting",
|
| 278 |
+
"one frozen participant count",
|
| 279 |
+
"one frozen interview count",
|
| 280 |
+
"one frozen data-source set",
|
| 281 |
+
"one frozen analytic design",
|
| 282 |
+
"no unsupported temporal or case structure",
|
| 283 |
"quotation tags must remain unique",
|
| 284 |
],
|
| 285 |
},
|
| 286 |
"evidence": clean_evidence,
|
| 287 |
"themes": themes,
|
| 288 |
}
|
| 289 |
+
status_message = "✅ 定性研究事实、分析设计与证据结构已冻结。"
|
| 290 |
return md, status_message, metadata
|
| 291 |
|
| 292 |
|
core/quantitative_engine.py
CHANGED
|
@@ -1,8 +1,10 @@
|
|
| 1 |
# -*- coding: utf-8 -*-
|
| 2 |
-
"""Quantitative
|
| 3 |
|
| 4 |
from __future__ import annotations
|
| 5 |
|
|
|
|
|
|
|
| 6 |
import re
|
| 7 |
|
| 8 |
import numpy as np
|
|
@@ -13,7 +15,7 @@ from utils.file_parser import extract_data_from_files
|
|
| 13 |
|
| 14 |
|
| 15 |
class QuantitativeEngine:
|
| 16 |
-
"""
|
| 17 |
|
| 18 |
@staticmethod
|
| 19 |
def generate_psd_corr_matrix(n_constructs, max_corr=0.5):
|
|
@@ -66,6 +68,68 @@ class QuantitativeEngine:
|
|
| 66 |
)
|
| 67 |
return pd.DataFrame(metrics)
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
def generate_empirical_data_robust(
|
| 70 |
self,
|
| 71 |
construct_names=None,
|
|
@@ -74,9 +138,9 @@ class QuantitativeEngine:
|
|
| 74 |
):
|
| 75 |
if not construct_names or len(construct_names) < 2:
|
| 76 |
construct_names = [
|
| 77 |
-
"
|
| 78 |
-
"
|
| 79 |
-
"
|
| 80 |
]
|
| 81 |
|
| 82 |
n_constructs = len(construct_names)
|
|
@@ -98,6 +162,7 @@ class QuantitativeEngine:
|
|
| 98 |
loadings = np.random.uniform(0.72, 0.88, n_items)
|
| 99 |
true_loadings[construct_name] = loadings
|
| 100 |
item_names = []
|
|
|
|
| 101 |
for item_index, loading in enumerate(loadings):
|
| 102 |
error = np.random.normal(
|
| 103 |
0,
|
|
@@ -111,7 +176,7 @@ class QuantitativeEngine:
|
|
| 111 |
1,
|
| 112 |
scale_points,
|
| 113 |
).astype(int)
|
| 114 |
-
column_name = f"{
|
| 115 |
data[column_name] = item_int
|
| 116 |
item_names.append(column_name)
|
| 117 |
constructs_info.append(
|
|
@@ -130,7 +195,8 @@ class QuantitativeEngine:
|
|
| 130 |
for construct in constructs_info
|
| 131 |
}
|
| 132 |
)
|
| 133 |
-
|
|
|
|
| 134 |
|
| 135 |
for _, row in validity_df.iterrows():
|
| 136 |
construct_name = row["Construct"]
|
|
@@ -142,13 +208,109 @@ class QuantitativeEngine:
|
|
| 142 |
for row_index in range(len(fl_matrix.columns)):
|
| 143 |
for column_index in range(row_index + 1, len(fl_matrix.columns)):
|
| 144 |
fl_matrix.iloc[row_index, column_index] = ""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
|
| 146 |
desc_stats = mean_scores.mean().to_frame("Mean").join(
|
| 147 |
mean_scores.std().to_frame("SD")
|
| 148 |
)
|
| 149 |
-
return df, validity_df, desc_stats.join(fl_matrix)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
if uploaded_files:
|
| 153 |
user_md = extract_data_from_files(uploaded_files)
|
| 154 |
if user_md:
|
|
@@ -166,20 +328,58 @@ class QuantitativeEngine:
|
|
| 166 |
"disclosure_required": False,
|
| 167 |
"evidence_sections": evidence_sections[:20]
|
| 168 |
or ["User-provided quantitative evidence"],
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
}
|
| 170 |
return None, user_md, tr(language, "actual_stats_parsed"), metadata
|
| 171 |
|
| 172 |
-
|
| 173 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
{validity_df.to_markdown(index=False)}
|
|
|
|
| 175 |
### Table 2: Descriptive Statistics and Discriminant Validity (Fornell-Larcker Criterion)
|
| 176 |
*Note: Diagonal elements in parentheses are the square roots of AVE.*
|
| 177 |
{fl_matrix_df.to_markdown()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
[STRICT EMPIRICAL INSTRUCTION]
|
| 179 |
-
1. ZERO HALLUCINATION:
|
| 180 |
-
2.
|
| 181 |
-
3.
|
| 182 |
-
4.
|
|
|
|
|
|
|
| 183 |
"""
|
| 184 |
metadata = {
|
| 185 |
"mode": "quantitative",
|
|
@@ -187,17 +387,21 @@ class QuantitativeEngine:
|
|
| 187 |
"disclosure_required": True,
|
| 188 |
"sample_size": int(len(df)),
|
| 189 |
"scale_points": 7,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
"method": (
|
| 191 |
-
"Positive-semidefinite latent correlation generation, "
|
| 192 |
-
"
|
| 193 |
-
"
|
| 194 |
),
|
| 195 |
"generation_parameters": {
|
| 196 |
-
"constructs":
|
| 197 |
-
"Independent_Var",
|
| 198 |
-
"Mediator_Var",
|
| 199 |
-
"Dependent_Var",
|
| 200 |
-
],
|
| 201 |
"sample_size": int(len(df)),
|
| 202 |
"likert_scale_points": 7,
|
| 203 |
"items_per_construct": "random integer from 3 to 5",
|
|
@@ -205,17 +409,28 @@ class QuantitativeEngine:
|
|
| 205 |
"latent_correlation_control": "positive-semidefinite matrix with maximum target correlation 0.55",
|
| 206 |
"item_generation": "loading × latent score + normally distributed measurement error",
|
| 207 |
"discretization": "round to nearest integer and clamp to 1–7",
|
|
|
|
|
|
|
| 208 |
"reported_summaries": [
|
| 209 |
"Cronbach alpha",
|
| 210 |
"composite reliability",
|
| 211 |
"average variance extracted",
|
| 212 |
"descriptive statistics",
|
| 213 |
"Fornell-Larcker matrix",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
],
|
| 215 |
},
|
| 216 |
"evidence_sections": [
|
|
|
|
| 217 |
"Reliability and convergent validity",
|
| 218 |
"Descriptive statistics and discriminant validity",
|
|
|
|
|
|
|
|
|
|
| 219 |
],
|
| 220 |
}
|
| 221 |
return (
|
|
|
|
| 1 |
# -*- coding: utf-8 -*-
|
| 2 |
+
"""Quantitative evidence preparation for SmartPaper's AMJ-oriented track."""
|
| 3 |
|
| 4 |
from __future__ import annotations
|
| 5 |
|
| 6 |
+
import json
|
| 7 |
+
import logging
|
| 8 |
import re
|
| 9 |
|
| 10 |
import numpy as np
|
|
|
|
| 15 |
|
| 16 |
|
| 17 |
class QuantitativeEngine:
|
| 18 |
+
"""Prepare one frozen quantitative evidence packet before manuscript writing."""
|
| 19 |
|
| 20 |
@staticmethod
|
| 21 |
def generate_psd_corr_matrix(n_constructs, max_corr=0.5):
|
|
|
|
| 68 |
)
|
| 69 |
return pd.DataFrame(metrics)
|
| 70 |
|
| 71 |
+
@staticmethod
|
| 72 |
+
def _safe_constructs(values):
|
| 73 |
+
cleaned = []
|
| 74 |
+
for value in values or []:
|
| 75 |
+
name = re.sub(r"\s+", " ", str(value or "")).strip(" .,:;-_\n\t")
|
| 76 |
+
if not name or len(name) > 80 or name.lower() in {item.lower() for item in cleaned}:
|
| 77 |
+
continue
|
| 78 |
+
cleaned.append(name)
|
| 79 |
+
return cleaned[:3]
|
| 80 |
+
|
| 81 |
+
def _derive_system_design(self, agent, topic="", keywords=""):
|
| 82 |
+
"""Freeze a parsimonious topic-aligned design without inventing named sites."""
|
| 83 |
+
fallback = {
|
| 84 |
+
"constructs": [
|
| 85 |
+
"Focal Predictor",
|
| 86 |
+
"Focal Mediator",
|
| 87 |
+
"Focal Outcome",
|
| 88 |
+
],
|
| 89 |
+
"research_context": "respondents with experience relevant to the focal management phenomenon",
|
| 90 |
+
}
|
| 91 |
+
if agent is None:
|
| 92 |
+
return fallback
|
| 93 |
+
prompt = f"""Design the minimum coherent quantitative management-study skeleton for the topic below.
|
| 94 |
+
Return ONLY valid JSON:
|
| 95 |
+
{{"constructs":["predictor construct","mediating construct","outcome construct"],"research_context":"one generic respondent context"}}
|
| 96 |
+
Rules:
|
| 97 |
+
1. Exactly three distinct constructs in predictor -> mediator -> outcome order.
|
| 98 |
+
2. Use concise construct labels that can appear throughout a journal article.
|
| 99 |
+
3. The context must be generic and directly relevant to the topic. Do NOT name or invent a country, university, firm, industry sample frame, software package, time period, or institution.
|
| 100 |
+
4. This is a cross-sectional single-wave survey design, so do not imply experimental or causal identification.
|
| 101 |
+
5. Do not create any number; sample size and scale are frozen elsewhere.
|
| 102 |
+
Topic: {topic}
|
| 103 |
+
Keywords: {keywords}"""
|
| 104 |
+
try:
|
| 105 |
+
raw = agent.call_deepseek(
|
| 106 |
+
"You are a rigorous management research-design editor.",
|
| 107 |
+
prompt,
|
| 108 |
+
max_tokens=500,
|
| 109 |
+
temp=0.0,
|
| 110 |
+
model_role="fast",
|
| 111 |
+
thinking=False,
|
| 112 |
+
allow_fallback=True,
|
| 113 |
+
)
|
| 114 |
+
parsed = agent._extract_json_from_text(raw)
|
| 115 |
+
if not isinstance(parsed, dict):
|
| 116 |
+
return fallback
|
| 117 |
+
constructs = self._safe_constructs(parsed.get("constructs"))
|
| 118 |
+
context = re.sub(r"\s+", " ", str(parsed.get("research_context") or "")).strip()
|
| 119 |
+
if len(constructs) != 3:
|
| 120 |
+
return fallback
|
| 121 |
+
if not context or len(context) > 240:
|
| 122 |
+
context = fallback["research_context"]
|
| 123 |
+
# Named-location/site details are deliberately not accepted from this
|
| 124 |
+
# design step. The manuscript may remain generic when no user evidence
|
| 125 |
+
# supplies a real setting.
|
| 126 |
+
if re.search(r"\b(university|company|corporation|inc\.?|ltd\.?|country|china|usa|united states|europe|asia)\b", context, re.I):
|
| 127 |
+
context = fallback["research_context"]
|
| 128 |
+
return {"constructs": constructs, "research_context": context}
|
| 129 |
+
except Exception as exc:
|
| 130 |
+
logging.warning("Quantitative study-design freeze fell back safely: %s", exc)
|
| 131 |
+
return fallback
|
| 132 |
+
|
| 133 |
def generate_empirical_data_robust(
|
| 134 |
self,
|
| 135 |
construct_names=None,
|
|
|
|
| 138 |
):
|
| 139 |
if not construct_names or len(construct_names) < 2:
|
| 140 |
construct_names = [
|
| 141 |
+
"Focal Predictor",
|
| 142 |
+
"Focal Mediator",
|
| 143 |
+
"Focal Outcome",
|
| 144 |
]
|
| 145 |
|
| 146 |
n_constructs = len(construct_names)
|
|
|
|
| 162 |
loadings = np.random.uniform(0.72, 0.88, n_items)
|
| 163 |
true_loadings[construct_name] = loadings
|
| 164 |
item_names = []
|
| 165 |
+
safe_prefix = re.sub(r"[^A-Za-z0-9]+", "_", construct_name).strip("_") or f"Construct_{index + 1}"
|
| 166 |
for item_index, loading in enumerate(loadings):
|
| 167 |
error = np.random.normal(
|
| 168 |
0,
|
|
|
|
| 176 |
1,
|
| 177 |
scale_points,
|
| 178 |
).astype(int)
|
| 179 |
+
column_name = f"{safe_prefix}_{item_index + 1}"
|
| 180 |
data[column_name] = item_int
|
| 181 |
item_names.append(column_name)
|
| 182 |
constructs_info.append(
|
|
|
|
| 195 |
for construct in constructs_info
|
| 196 |
}
|
| 197 |
)
|
| 198 |
+
corr_matrix = mean_scores.corr()
|
| 199 |
+
fl_matrix = corr_matrix.astype(object)
|
| 200 |
|
| 201 |
for _, row in validity_df.iterrows():
|
| 202 |
construct_name = row["Construct"]
|
|
|
|
| 208 |
for row_index in range(len(fl_matrix.columns)):
|
| 209 |
for column_index in range(row_index + 1, len(fl_matrix.columns)):
|
| 210 |
fl_matrix.iloc[row_index, column_index] = ""
|
| 211 |
+
for column_index in range(row_index):
|
| 212 |
+
value = fl_matrix.iloc[row_index, column_index]
|
| 213 |
+
if isinstance(value, (int, float, np.integer, np.floating)):
|
| 214 |
+
fl_matrix.iloc[row_index, column_index] = f"{float(value):.3f}"
|
| 215 |
|
| 216 |
desc_stats = mean_scores.mean().to_frame("Mean").join(
|
| 217 |
mean_scores.std().to_frame("SD")
|
| 218 |
)
|
| 219 |
+
return df, validity_df, desc_stats.join(fl_matrix), mean_scores, corr_matrix
|
| 220 |
+
|
| 221 |
+
@staticmethod
|
| 222 |
+
def _standardized_ols(y, predictors):
|
| 223 |
+
y = np.asarray(y, dtype=float)
|
| 224 |
+
x = np.column_stack([np.asarray(value, dtype=float) for value in predictors])
|
| 225 |
+
y_sd = y.std(ddof=1)
|
| 226 |
+
x_sd = x.std(axis=0, ddof=1)
|
| 227 |
+
if y_sd <= 0 or np.any(x_sd <= 0):
|
| 228 |
+
raise RuntimeError("quantitative_zero_variance")
|
| 229 |
+
yz = (y - y.mean()) / y_sd
|
| 230 |
+
xz = (x - x.mean(axis=0)) / x_sd
|
| 231 |
+
design = np.column_stack([np.ones(len(yz)), xz])
|
| 232 |
+
coef = np.linalg.lstsq(design, yz, rcond=None)[0]
|
| 233 |
+
fitted = design @ coef
|
| 234 |
+
ss_res = float(np.sum((yz - fitted) ** 2))
|
| 235 |
+
ss_tot = float(np.sum((yz - yz.mean()) ** 2))
|
| 236 |
+
r2 = 0.0 if ss_tot <= 0 else 1.0 - ss_res / ss_tot
|
| 237 |
+
return coef[1:], max(0.0, min(1.0, r2))
|
| 238 |
+
|
| 239 |
+
def _path_evidence(self, mean_scores, construct_names, bootstrap_iterations=1500):
|
| 240 |
+
if len(construct_names) < 3:
|
| 241 |
+
return {}, pd.DataFrame(), pd.DataFrame()
|
| 242 |
+
x_name, m_name, y_name = construct_names[0], construct_names[1], construct_names[2]
|
| 243 |
+
x = mean_scores[x_name].to_numpy(dtype=float)
|
| 244 |
+
m = mean_scores[m_name].to_numpy(dtype=float)
|
| 245 |
+
y = mean_scores[y_name].to_numpy(dtype=float)
|
| 246 |
+
a_coef, r2_m = self._standardized_ols(m, [x])
|
| 247 |
+
y_coef, r2_y = self._standardized_ols(y, [x, m])
|
| 248 |
+
a = float(a_coef[0])
|
| 249 |
+
direct = float(y_coef[0])
|
| 250 |
+
b = float(y_coef[1])
|
| 251 |
+
indirect = a * b
|
| 252 |
|
| 253 |
+
rng = np.random.default_rng(20260809)
|
| 254 |
+
indirect_boot = []
|
| 255 |
+
direct_boot = []
|
| 256 |
+
n = len(mean_scores)
|
| 257 |
+
for _ in range(int(bootstrap_iterations)):
|
| 258 |
+
idx = rng.integers(0, n, n)
|
| 259 |
+
try:
|
| 260 |
+
a_b, _ = self._standardized_ols(m[idx], [x[idx]])
|
| 261 |
+
y_b, _ = self._standardized_ols(y[idx], [x[idx], m[idx]])
|
| 262 |
+
indirect_boot.append(float(a_b[0] * y_b[1]))
|
| 263 |
+
direct_boot.append(float(y_b[0]))
|
| 264 |
+
except Exception:
|
| 265 |
+
continue
|
| 266 |
+
if len(indirect_boot) < 100:
|
| 267 |
+
raise RuntimeError("quantitative_bootstrap_insufficient")
|
| 268 |
+
indirect_ci = np.percentile(indirect_boot, [2.5, 97.5])
|
| 269 |
+
direct_ci = np.percentile(direct_boot, [2.5, 97.5])
|
| 270 |
+
|
| 271 |
+
path_table = pd.DataFrame(
|
| 272 |
+
[
|
| 273 |
+
{"Association": f"{x_name} → {m_name}", "Standardized estimate": round(a, 3), "R² of outcome": round(r2_m, 3)},
|
| 274 |
+
{"Association": f"{m_name} → {y_name} (controlling {x_name})", "Standardized estimate": round(b, 3), "R² of outcome": round(r2_y, 3)},
|
| 275 |
+
{"Association": f"{x_name} → {y_name} (controlling {m_name})", "Standardized estimate": round(direct, 3), "R² of outcome": round(r2_y, 3)},
|
| 276 |
+
{"Association": f"Indirect association: {x_name} → {m_name} → {y_name}", "Standardized estimate": round(indirect, 3), "R² of outcome": "—"},
|
| 277 |
+
]
|
| 278 |
+
)
|
| 279 |
+
bootstrap_table = pd.DataFrame(
|
| 280 |
+
[
|
| 281 |
+
{"Estimate": "Indirect association", "Point estimate": round(indirect, 3), "95% bootstrap CI lower": round(float(indirect_ci[0]), 3), "95% bootstrap CI upper": round(float(indirect_ci[1]), 3)},
|
| 282 |
+
{"Estimate": "Residual direct association", "Point estimate": round(direct, 3), "95% bootstrap CI lower": round(float(direct_ci[0]), 3), "95% bootstrap CI upper": round(float(direct_ci[1]), 3)},
|
| 283 |
+
]
|
| 284 |
+
)
|
| 285 |
+
x_m_corr = float(np.corrcoef(x, m)[0, 1])
|
| 286 |
+
vif = 1.0 / max(1e-9, 1.0 - x_m_corr**2)
|
| 287 |
+
spearman = mean_scores.corr(method="spearman")
|
| 288 |
+
summary = {
|
| 289 |
+
"predictor": x_name,
|
| 290 |
+
"mediator": m_name,
|
| 291 |
+
"outcome": y_name,
|
| 292 |
+
"a": round(a, 3),
|
| 293 |
+
"b": round(b, 3),
|
| 294 |
+
"direct": round(direct, 3),
|
| 295 |
+
"indirect": round(indirect, 3),
|
| 296 |
+
"r2_mediator": round(r2_m, 3),
|
| 297 |
+
"r2_outcome": round(r2_y, 3),
|
| 298 |
+
"indirect_ci": [round(float(indirect_ci[0]), 3), round(float(indirect_ci[1]), 3)],
|
| 299 |
+
"direct_ci": [round(float(direct_ci[0]), 3), round(float(direct_ci[1]), 3)],
|
| 300 |
+
"predictor_mediator_vif": round(float(vif), 3),
|
| 301 |
+
"bootstrap_iterations": int(bootstrap_iterations),
|
| 302 |
+
}
|
| 303 |
+
return summary, path_table, spearman
|
| 304 |
+
|
| 305 |
+
def resolve_empirical_data(
|
| 306 |
+
self,
|
| 307 |
+
uploaded_files=None,
|
| 308 |
+
language="zh",
|
| 309 |
+
*,
|
| 310 |
+
topic="",
|
| 311 |
+
keywords="",
|
| 312 |
+
agent=None,
|
| 313 |
+
):
|
| 314 |
if uploaded_files:
|
| 315 |
user_md = extract_data_from_files(uploaded_files)
|
| 316 |
if user_md:
|
|
|
|
| 328 |
"disclosure_required": False,
|
| 329 |
"evidence_sections": evidence_sections[:20]
|
| 330 |
or ["User-provided quantitative evidence"],
|
| 331 |
+
"evidence_boundary": (
|
| 332 |
+
"Only values explicitly present in the uploaded evidence are frozen. "
|
| 333 |
+
"Do not infer absent setting, sample, measures, software, or results."
|
| 334 |
+
),
|
| 335 |
}
|
| 336 |
return None, user_md, tr(language, "actual_stats_parsed"), metadata
|
| 337 |
|
| 338 |
+
design = self._derive_system_design(agent, topic, keywords)
|
| 339 |
+
construct_names = design["constructs"]
|
| 340 |
+
df, validity_df, fl_matrix_df, mean_scores, corr_matrix = self.generate_empirical_data_robust(
|
| 341 |
+
construct_names=construct_names
|
| 342 |
+
)
|
| 343 |
+
path_summary, path_table, spearman = self._path_evidence(mean_scores, construct_names)
|
| 344 |
+
context = design["research_context"]
|
| 345 |
+
generated_prompt = f"""### Frozen Quantitative Study Design
|
| 346 |
+
| Field | Frozen value |
|
| 347 |
+
| :--- | :--- |
|
| 348 |
+
| Design | Cross-sectional, single-wave questionnaire study |
|
| 349 |
+
| Respondent context | {context} |
|
| 350 |
+
| Sample size | {len(df)} |
|
| 351 |
+
| Response scale | 7-point Likert-type scale |
|
| 352 |
+
| Predictor | {construct_names[0]} |
|
| 353 |
+
| Mediator | {construct_names[1]} |
|
| 354 |
+
| Outcome | {construct_names[2]} |
|
| 355 |
+
| Causal identification | None; report associations and estimated indirect association, not causal effects |
|
| 356 |
+
|
| 357 |
+
### Table 1: Reliability and Convergent Validity
|
| 358 |
{validity_df.to_markdown(index=False)}
|
| 359 |
+
|
| 360 |
### Table 2: Descriptive Statistics and Discriminant Validity (Fornell-Larcker Criterion)
|
| 361 |
*Note: Diagonal elements in parentheses are the square roots of AVE.*
|
| 362 |
{fl_matrix_df.to_markdown()}
|
| 363 |
+
|
| 364 |
+
### Table 3: Frozen Association Model Results
|
| 365 |
+
{path_table.to_markdown(index=False)}
|
| 366 |
+
|
| 367 |
+
### Table 4: Rank-Order Robustness Correlations (Spearman)
|
| 368 |
+
{spearman.round(3).to_markdown()}
|
| 369 |
+
|
| 370 |
+
### Frozen Bootstrap and Collinearity Checks
|
| 371 |
+
- Bootstrap resamples: {path_summary['bootstrap_iterations']}.
|
| 372 |
+
- Indirect association ({construct_names[0]} → {construct_names[1]} → {construct_names[2]}): {path_summary['indirect']}; 95% bootstrap CI [{path_summary['indirect_ci'][0]}, {path_summary['indirect_ci'][1]}].
|
| 373 |
+
- Residual direct association ({construct_names[0]} → {construct_names[2]} controlling {construct_names[1]}): {path_summary['direct']}; 95% bootstrap CI [{path_summary['direct_ci'][0]}, {path_summary['direct_ci'][1]}].
|
| 374 |
+
- Predictor/mediator VIF in the outcome model: {path_summary['predictor_mediator_vif']}.
|
| 375 |
+
|
| 376 |
[STRICT EMPIRICAL INSTRUCTION]
|
| 377 |
+
1. ZERO HALLUCINATION: Every study-specific fact and number must come from this frozen packet. Never invent a country, named organization, sampling frame, response rate, demographic characteristic, measure source, software package, coefficient, p-value, fit index, robustness test, or date.
|
| 378 |
+
2. DESIGN DISCIPLINE: This is a cross-sectional observational survey. Use association language. The indirect estimate is not causal mediation identification.
|
| 379 |
+
3. MEASUREMENT DEFENSE: State reliability/convergent/discriminant validity only when the displayed values support it.
|
| 380 |
+
4. RESULTS DISCIPLINE: Results must report this study's frozen numbers without attaching external citations to the coefficients or sample facts.
|
| 381 |
+
5. ROBUSTNESS: The Spearman matrix, bootstrap intervals, and VIF above are the only frozen robustness evidence. Do not add another robustness procedure.
|
| 382 |
+
6. CONSISTENCY: The predictor, mediator, outcome, sample size, context, scale, and all numerical results above are the single source of quantitative truth throughout the manuscript.
|
| 383 |
"""
|
| 384 |
metadata = {
|
| 385 |
"mode": "quantitative",
|
|
|
|
| 387 |
"disclosure_required": True,
|
| 388 |
"sample_size": int(len(df)),
|
| 389 |
"scale_points": 7,
|
| 390 |
+
"study_design": "cross-sectional single-wave questionnaire",
|
| 391 |
+
"research_context": context,
|
| 392 |
+
"constructs": {
|
| 393 |
+
"predictor": construct_names[0],
|
| 394 |
+
"mediator": construct_names[1],
|
| 395 |
+
"outcome": construct_names[2],
|
| 396 |
+
},
|
| 397 |
+
"association_results": path_summary,
|
| 398 |
"method": (
|
| 399 |
+
"Positive-semidefinite latent correlation generation, indicator loadings, "
|
| 400 |
+
"Likert discretization, reliability/validity summaries, standardized association "
|
| 401 |
+
"models, fixed-seed bootstrap intervals, and Spearman robustness correlations."
|
| 402 |
),
|
| 403 |
"generation_parameters": {
|
| 404 |
+
"constructs": construct_names,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 405 |
"sample_size": int(len(df)),
|
| 406 |
"likert_scale_points": 7,
|
| 407 |
"items_per_construct": "random integer from 3 to 5",
|
|
|
|
| 409 |
"latent_correlation_control": "positive-semidefinite matrix with maximum target correlation 0.55",
|
| 410 |
"item_generation": "loading × latent score + normally distributed measurement error",
|
| 411 |
"discretization": "round to nearest integer and clamp to 1–7",
|
| 412 |
+
"bootstrap_iterations": path_summary["bootstrap_iterations"],
|
| 413 |
+
"bootstrap_seed": 20260809,
|
| 414 |
"reported_summaries": [
|
| 415 |
"Cronbach alpha",
|
| 416 |
"composite reliability",
|
| 417 |
"average variance extracted",
|
| 418 |
"descriptive statistics",
|
| 419 |
"Fornell-Larcker matrix",
|
| 420 |
+
"standardized association estimates",
|
| 421 |
+
"R-squared",
|
| 422 |
+
"bootstrap confidence intervals",
|
| 423 |
+
"Spearman correlations",
|
| 424 |
+
"VIF",
|
| 425 |
],
|
| 426 |
},
|
| 427 |
"evidence_sections": [
|
| 428 |
+
"Frozen quantitative study design",
|
| 429 |
"Reliability and convergent validity",
|
| 430 |
"Descriptive statistics and discriminant validity",
|
| 431 |
+
"Frozen association model results",
|
| 432 |
+
"Rank-order robustness correlations",
|
| 433 |
+
"Frozen bootstrap and collinearity checks",
|
| 434 |
],
|
| 435 |
}
|
| 436 |
return (
|
smartpaper_restore_bridge/__pycache__/__init__.cpython-313.pyc
CHANGED
|
Binary files a/smartpaper_restore_bridge/__pycache__/__init__.cpython-313.pyc and b/smartpaper_restore_bridge/__pycache__/__init__.cpython-313.pyc differ
|
|
|
smartpaper_restore_bridge/frontend/index.html
CHANGED
|
@@ -24,6 +24,8 @@
|
|
| 24 |
let activeTaskWatch = null;
|
| 25 |
let lastMonitorNode = "";
|
| 26 |
let lastMonitorCheckpointSeq = 0;
|
|
|
|
|
|
|
| 27 |
|
| 28 |
const ACTION_PATHS = Object.freeze({
|
| 29 |
task_start: "/edge/task/start",
|
|
@@ -197,6 +199,102 @@
|
|
| 197 |
feed.scrollTop = feed.scrollHeight;
|
| 198 |
}
|
| 199 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
function updateTaskMonitor(task) {
|
| 201 |
let parentDocument;
|
| 202 |
try { parentDocument = window.parent.document; }
|
|
@@ -231,6 +329,7 @@
|
|
| 231 |
const watch = activeTaskWatch;
|
| 232 |
activeTaskWatch = null;
|
| 233 |
if (watch && watch.timer) window.clearTimeout(watch.timer);
|
|
|
|
| 234 |
if (watch && watch.controller) {
|
| 235 |
try { watch.controller.abort(); } catch (_) {}
|
| 236 |
}
|
|
@@ -243,16 +342,30 @@
|
|
| 243 |
const taskId = typeof payload.task_id === "string" ? payload.task_id.trim() : "";
|
| 244 |
const requestedInterval = Number(payload.poll_interval_ms || 2000);
|
| 245 |
const intervalMs = Math.max(1500, Math.min(10000, Number.isFinite(requestedInterval) ? requestedInterval : 2000));
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
const initialCheckpointSeq = asCount(payload.initial_checkpoint_seq);
|
| 247 |
if (!apiOrigin || !taskId) {
|
| 248 |
sendValue({ ok:false, action:"task_watch", command_id:commandId, error:"invalid_task_watch" });
|
| 249 |
return;
|
| 250 |
}
|
| 251 |
|
| 252 |
-
const watch = { commandId, taskId, apiOrigin, intervalMs, timer:null, controller:null, stopped:false, lastCheckpointSeq:initialCheckpointSeq };
|
| 253 |
activeTaskWatch = watch;
|
|
|
|
|
|
|
| 254 |
lastMonitorCheckpointSeq = Math.max(lastMonitorCheckpointSeq, initialCheckpointSeq);
|
| 255 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
const schedule = () => {
|
| 257 |
if (activeTaskWatch !== watch || watch.stopped) return;
|
| 258 |
watch.timer = window.setTimeout(poll, watch.intervalMs);
|
|
@@ -281,21 +394,13 @@
|
|
| 281 |
}
|
| 282 |
if (checkpointSeq > watch.lastCheckpointSeq) {
|
| 283 |
watch.lastCheckpointSeq = checkpointSeq;
|
| 284 |
-
watch.stopped = true;
|
| 285 |
-
activeTaskWatch = null;
|
| 286 |
-
sendValue(Object.assign({}, result, {
|
| 287 |
-
action:"task_watch",
|
| 288 |
-
command_id:commandId,
|
| 289 |
-
terminal:false,
|
| 290 |
-
boundary:true,
|
| 291 |
-
boundary_type:"checkpoint"
|
| 292 |
-
}));
|
| 293 |
-
return;
|
| 294 |
}
|
| 295 |
}
|
| 296 |
schedule();
|
| 297 |
};
|
| 298 |
|
|
|
|
|
|
|
| 299 |
poll();
|
| 300 |
}
|
| 301 |
|
|
|
|
| 24 |
let activeTaskWatch = null;
|
| 25 |
let lastMonitorNode = "";
|
| 26 |
let lastMonitorCheckpointSeq = 0;
|
| 27 |
+
let lastPreviewText = "";
|
| 28 |
+
let lastPreviewDisplayText = "";
|
| 29 |
|
| 30 |
const ACTION_PATHS = Object.freeze({
|
| 31 |
task_start: "/edge/task/start",
|
|
|
|
| 199 |
feed.scrollTop = feed.scrollHeight;
|
| 200 |
}
|
| 201 |
|
| 202 |
+
function previewUrl(path) {
|
| 203 |
+
const value = String(path || "").trim();
|
| 204 |
+
if (!value || !value.startsWith("/app/static/previews/")) return "";
|
| 205 |
+
try { return new URL(value, window.parent.location.origin).toString(); }
|
| 206 |
+
catch (_) { return ""; }
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
function appendSafeInline(parentDocument, parent, value) {
|
| 210 |
+
const parts = String(value || "").split(/(\*\*[^*]+\*\*)/g);
|
| 211 |
+
for (const part of parts) {
|
| 212 |
+
if (!part) continue;
|
| 213 |
+
if (part.startsWith("**") && part.endsWith("**") && part.length > 4) {
|
| 214 |
+
const strong = parentDocument.createElement("strong");
|
| 215 |
+
strong.textContent = part.slice(2, -2);
|
| 216 |
+
parent.appendChild(strong);
|
| 217 |
+
} else {
|
| 218 |
+
parent.appendChild(parentDocument.createTextNode(part));
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
function renderPreviewContent(parentDocument, target, text) {
|
| 224 |
+
// Keep one stable text node and append only the newly generated suffix.
|
| 225 |
+
// Rebuilding the whole manuscript DOM on every preview poll caused visible
|
| 226 |
+
// repaint/flicker and did not feel like ChatGPT-style streaming.
|
| 227 |
+
const displayText = String(text || "")
|
| 228 |
+
.replace(/\r/g, "")
|
| 229 |
+
.replace(/^#{1,3}\s+/gm, "");
|
| 230 |
+
let stream = target.querySelector("[data-sp-live-stream='1']");
|
| 231 |
+
if (!stream) {
|
| 232 |
+
stream = parentDocument.createElement("div");
|
| 233 |
+
stream.dataset.spLiveStream = "1";
|
| 234 |
+
stream.style.whiteSpace = "pre-wrap";
|
| 235 |
+
stream.style.overflowWrap = "anywhere";
|
| 236 |
+
stream.style.lineHeight = "1.72";
|
| 237 |
+
target.replaceChildren(stream);
|
| 238 |
+
lastPreviewDisplayText = "";
|
| 239 |
+
}
|
| 240 |
+
if (displayText.startsWith(lastPreviewDisplayText)) {
|
| 241 |
+
const delta = displayText.slice(lastPreviewDisplayText.length);
|
| 242 |
+
if (delta) stream.appendChild(parentDocument.createTextNode(delta));
|
| 243 |
+
} else {
|
| 244 |
+
// Resume/recovery or a bounded server-side revision can replace prior
|
| 245 |
+
// prose. Update the existing node in place rather than replacing the DOM.
|
| 246 |
+
stream.textContent = displayText;
|
| 247 |
+
}
|
| 248 |
+
lastPreviewDisplayText = displayText;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
function previewShouldFollow(parentWindow, shell) {
|
| 252 |
+
if (!shell) return false;
|
| 253 |
+
if (!lastPreviewText) return true;
|
| 254 |
+
try {
|
| 255 |
+
const rect = shell.getBoundingClientRect();
|
| 256 |
+
const viewport = parentWindow.innerHeight || 0;
|
| 257 |
+
// Follow only while the preview is already on-screen or immediately
|
| 258 |
+
// below it. A user who scrolls upward is never pulled back down.
|
| 259 |
+
return rect.top < viewport * 0.92 && rect.bottom > -80;
|
| 260 |
+
} catch (_) { return false; }
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
function updateManuscriptPreview(payload) {
|
| 264 |
+
let parentDocument, parentWindow;
|
| 265 |
+
try { parentDocument = window.parent.document; parentWindow = window.parent; }
|
| 266 |
+
catch (_) { return; }
|
| 267 |
+
const target = parentDocument.getElementById("sp-live-manuscript-preview");
|
| 268 |
+
const shell = parentDocument.getElementById("sp-live-manuscript-shell");
|
| 269 |
+
if (!target || !shell) return;
|
| 270 |
+
const text = String(payload && payload.text || "");
|
| 271 |
+
if (!text || text === lastPreviewText) return;
|
| 272 |
+
const shouldFollow = previewShouldFollow(parentWindow, shell);
|
| 273 |
+
renderPreviewContent(parentDocument, target, text);
|
| 274 |
+
lastPreviewText = text;
|
| 275 |
+
if (shouldFollow) {
|
| 276 |
+
// Keep the manuscript viewport pinned to the newest prose without moving
|
| 277 |
+
// the whole browser page. Whole-page scrolling is reserved for the single
|
| 278 |
+
// post-launch positioning action in app.py.
|
| 279 |
+
shell.scrollTop = shell.scrollHeight;
|
| 280 |
+
}
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
async function fetchPreview(watch) {
|
| 284 |
+
if (!watch || !watch.previewUrl || activeTaskWatch !== watch || watch.stopped) return;
|
| 285 |
+
try {
|
| 286 |
+
const response = await fetch(watch.previewUrl + `?t=${Date.now()}`, {
|
| 287 |
+
method:"GET", cache:"no-store", credentials:"same-origin", headers:{"Accept":"application/json"}
|
| 288 |
+
});
|
| 289 |
+
if (!response.ok) return;
|
| 290 |
+
const payload = await response.json();
|
| 291 |
+
if (!payload || String(payload.task_id || "") !== watch.taskId) return;
|
| 292 |
+
const epoch = asCount(payload.execution_epoch);
|
| 293 |
+
if (watch.expectedEpoch > 0 && epoch !== watch.expectedEpoch) return;
|
| 294 |
+
updateManuscriptPreview(payload);
|
| 295 |
+
} catch (_) {}
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
function updateTaskMonitor(task) {
|
| 299 |
let parentDocument;
|
| 300 |
try { parentDocument = window.parent.document; }
|
|
|
|
| 329 |
const watch = activeTaskWatch;
|
| 330 |
activeTaskWatch = null;
|
| 331 |
if (watch && watch.timer) window.clearTimeout(watch.timer);
|
| 332 |
+
if (watch && watch.previewTimer) window.clearTimeout(watch.previewTimer);
|
| 333 |
if (watch && watch.controller) {
|
| 334 |
try { watch.controller.abort(); } catch (_) {}
|
| 335 |
}
|
|
|
|
| 342 |
const taskId = typeof payload.task_id === "string" ? payload.task_id.trim() : "";
|
| 343 |
const requestedInterval = Number(payload.poll_interval_ms || 2000);
|
| 344 |
const intervalMs = Math.max(1500, Math.min(10000, Number.isFinite(requestedInterval) ? requestedInterval : 2000));
|
| 345 |
+
const requestedPreviewInterval = Number(payload.preview_interval_ms || 700);
|
| 346 |
+
const previewIntervalMs = Math.max(400, Math.min(2500, Number.isFinite(requestedPreviewInterval) ? requestedPreviewInterval : 700));
|
| 347 |
+
const resolvedPreviewUrl = previewUrl(payload.preview_path || "");
|
| 348 |
+
const expectedEpoch = asCount(payload.expected_execution_epoch);
|
| 349 |
const initialCheckpointSeq = asCount(payload.initial_checkpoint_seq);
|
| 350 |
if (!apiOrigin || !taskId) {
|
| 351 |
sendValue({ ok:false, action:"task_watch", command_id:commandId, error:"invalid_task_watch" });
|
| 352 |
return;
|
| 353 |
}
|
| 354 |
|
| 355 |
+
const watch = { commandId, taskId, apiOrigin, intervalMs, previewIntervalMs, previewUrl:resolvedPreviewUrl, expectedEpoch, timer:null, previewTimer:null, controller:null, stopped:false, lastCheckpointSeq:initialCheckpointSeq };
|
| 356 |
activeTaskWatch = watch;
|
| 357 |
+
lastPreviewText = "";
|
| 358 |
+
lastPreviewDisplayText = "";
|
| 359 |
lastMonitorCheckpointSeq = Math.max(lastMonitorCheckpointSeq, initialCheckpointSeq);
|
| 360 |
|
| 361 |
+
const schedulePreview = () => {
|
| 362 |
+
if (activeTaskWatch !== watch || watch.stopped || !watch.previewUrl) return;
|
| 363 |
+
watch.previewTimer = window.setTimeout(async () => {
|
| 364 |
+
await fetchPreview(watch);
|
| 365 |
+
schedulePreview();
|
| 366 |
+
}, watch.previewIntervalMs);
|
| 367 |
+
};
|
| 368 |
+
|
| 369 |
const schedule = () => {
|
| 370 |
if (activeTaskWatch !== watch || watch.stopped) return;
|
| 371 |
watch.timer = window.setTimeout(poll, watch.intervalMs);
|
|
|
|
| 394 |
}
|
| 395 |
if (checkpointSeq > watch.lastCheckpointSeq) {
|
| 396 |
watch.lastCheckpointSeq = checkpointSeq;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
}
|
| 398 |
}
|
| 399 |
schedule();
|
| 400 |
};
|
| 401 |
|
| 402 |
+
fetchPreview(watch);
|
| 403 |
+
schedulePreview();
|
| 404 |
poll();
|
| 405 |
}
|
| 406 |
|
static/desktop.ini
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[.ShellClassInfo]
|
| 2 |
+
IconResource=C:\Program Files\Google\Drive File Stream\129.0.1.0\GoogleDriveFS.exe,27
|
static/previews/.gitkeep
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# SmartPaper runtime live-preview directory. Preview JSON is ephemeral and non-authoritative.
|
static/previews/desktop.ini
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[.ShellClassInfo]
|
| 2 |
+
IconResource=C:\Program Files\Google\Drive File Stream\129.0.1.0\GoogleDriveFS.exe,27
|
utils/__pycache__/docx_builder.cpython-313.pyc
CHANGED
|
Binary files a/utils/__pycache__/docx_builder.cpython-313.pyc and b/utils/__pycache__/docx_builder.cpython-313.pyc differ
|
|
|
utils/__pycache__/file_parser.cpython-313.pyc
CHANGED
|
Binary files a/utils/__pycache__/file_parser.cpython-313.pyc and b/utils/__pycache__/file_parser.cpython-313.pyc differ
|
|
|