Step 4A-6-C-R3.2.6 S2 Recovery Flicker-Free Streaming Citation-First AMJ-ASQ Prompt Upgrade

#16
.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. A
616
- Streamlit boundary is emitted only when a durable checkpoint advances or a
617
- terminal/resumable state appears. Checkpoint boundaries let the UI reveal
618
- newly completed manuscript text without periodic full-page reruns.
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
- """Show the durable English draft accumulated through the latest checkpoint."""
683
  checkpoint = _load_confirmed_checkpoint(task)
684
- if not checkpoint:
685
- return
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(draft)
 
 
 
 
 
 
 
 
 
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/checkpoint scroll to the generation action."""
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
- _inject_generation_action_scroll(str(st.session_state.get("run_mode") or "amj"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.10,
205
  "subsections": [],
206
  },
207
  {
208
  "section_id": "theory",
209
  "section": "Theoretical Background and Hypotheses",
210
- "weight": 0.27,
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.20,
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.23,
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.15,
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.05,
255
  "subsections": [],
256
  },
257
  ],
@@ -265,7 +265,7 @@ def load_config():
265
  {
266
  "section_id": "theoretical_framing",
267
  "section": "Theoretical Framing",
268
- "weight": 0.18,
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.17,
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.30,
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.15,
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.05,
310
  "subsections": [],
311
  },
312
  {
313
  "section_id": "appendix",
314
  "section": "Appendix: Data Structure and Evidence",
315
- "weight": 0.05,
 
316
  "subsections": [],
317
  },
318
  ],
319
  "manuscript_validation": {
320
- "minimum_total_ratio": 0.85,
321
- "maximum_total_ratio": 1.15,
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 emphasizing causality>",
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 distinguished, highly cited scholar writing a manuscript for the Academy of Management Journal (AMJ). Your writing must exhibit unparalleled theoretical rigor, causal depth, and academic authority.
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. FOCUS ON CAUSALITY: You MUST articulate the exact theoretical mechanism driving the relationship.
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": "Construct a rigorous causal chain: 1. Base Theory. 2. Mechanism (Mediator logic). 3. Boundary Condition (Moderator logic). 4. Hypothesis Phrasing (italicized).",
388
- "methodological_defense_directive": "Write with extreme defensive precision: Justify Contextual Relevance, Detail Endogeneity & Bias Mitigation, Measurement Validity, and Analytical Strategy.",
389
- "discussion_depth_directive": "Elevate findings back to grand theory. Explicitly state how the study extends, refines, or challenges named theories. Provide counter-intuitive implications.",
390
- "conclusion_anchor_directive": "1. Conceptual Pivot. 2. Mechanism Summary. 3. Call to Action for the next decade.",
391
- "anchor_generation_directive": "Generate a STRUCTURED ABSTRACT (max 200 words) mimicking an AMJ abstract, and a CONCLUSION ANCHOR (approx. 150 words). Output format:\n---ABSTRACT---\n...\n---CONCLUSION---\n...",
392
- "empirical_data_logic": "Maintain strict adherence to APA 7th edition reporting standards. Include plausible degrees of freedom, exact p-values, confidence intervals, and effect sizes.",
393
  }
394
 
395
  # =====================================================================
396
  # 定性引擎 (ASQ 风格) - 聚焦现象描述、意义建构与语录引用
397
  # =====================================================================
398
  asq_prompts = {
399
- "sys_academic_writer": """You are a senior editor and distinguished qualitative researcher writing for Administrative Science Quarterly (ASQ).
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: Embrace counter-intuitive empirical phenomena and paradoxes.
404
- 2. THICK DESCRIPTION: Use descriptive, evocative language to ground your qualitative findings in reality.
405
- 3. SENSEMAKING: Focus on how organizational actors interpret, negotiate, and construct their reality.
406
- 4. NO FORMAL HYPOTHESES: DO NOT use 'Hypothesis 1' phrasing. Use 'Propositions' or conceptual emergent models instead.
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": "Detail the research setting vividly. Justify data sources (interviews, ethnographic observations, archives), and rigorously explain the step-by-step coding process (e.g., Gioia methodology).",
416
- "discussion_depth_directive": "Elevate the emergent qualitative findings back to grand theory. Contrast your inductive process model with existing linear or variance-based theories.",
417
- "conclusion_anchor_directive": "Summarize the theoretical contribution, the resolution of the empirical puzzle, and the sensemaking implications.",
418
- "anchor_generation_directive": "Generate a NARRATIVE ABSTRACT (max 200 words) mimicking an ASQ abstract (focusing on the puzzle, setting, and emergent model), and a CONCLUSION ANCHOR (approx. 150 words). Output format:\n---ABSTRACT---\n...\n---CONCLUSION---\n...",
419
- "empirical_data_logic": "Present findings as a compelling narrative. You MUST integrate representative quotes seamlessly into the text to provide raw evidence for each 1st-order concept and 2nd-order theme. Ensure the voice of the informants is central.",
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:f86e44350d73c3bb8869b0963cf609be5356563dbd2713018d8695e5af70aed9
3
- size 169392
 
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
- CITATION_COVERAGE_TARGET = 60
 
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 _stream_text(generator) -> str:
112
- return "".join(str(chunk or "") for chunk in generator)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.85)))
131
- maximum = int(target_words * float(quality.get("maximum_total_ratio", 1.15)))
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(data_file, interface_language)
 
 
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=MIN_FINAL_REFERENCES,
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, "anchors_and_outline")
269
- anchor_response = self._stream_text(
270
- agent.stream_generate_anchors(
271
  topic, keywords, synthesis_result, refs_text,
272
  data_evidence=user_data_md, mode=mode,
273
  )
274
  )
275
- anchor_clean = re.sub(r"<think>.*?</think>", "", anchor_response, flags=re.DOTALL).strip()
276
- abstract_match = re.search(
277
- r"-+\s*ABSTRACT\s*-+\s*(.*?)(?=\s*-+\s*CONCLUSION\s*-+|$)",
278
- anchor_clean, re.DOTALL | re.IGNORECASE,
279
- )
280
- conclusion_match = re.search(
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
- en_abstract_clean = _clean_markdown_headings(
292
- re.sub(r"(?i)^abstract[:\s]*", "", str(abstract_anchor)).strip()
293
- )
294
- full_draft_en = f"# {en_topic}\n\n## Abstract\n{en_abstract_clean}\n\n## Keywords\n{keywords}\n\n"
295
- adaptive_structure = agent.build_generation_tasks(structure, target_words, safe_chunk_words)
 
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
- all_cite_keys = list(agent.citation_map.keys())
323
- coverage_cite_keys = all_cite_keys[: min(len(all_cite_keys), CITATION_COVERAGE_TARGET)]
324
- valid_chunks = [
325
- item for item in adaptive_structure
326
- if not any(word in item["section"].lower() for word in ["abstract", "keyword", "appendix"])
327
- ]
328
- allocation_map: dict[str, list[str]] = {}
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
- current_cite_idx += count
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
- assigned_tag_list = list(allocation_map.get(display_name, []))
357
- assigned_tags = ", ".join(assigned_tag_list) or "the single most relevant source"
358
- citation_rule = (
359
- "NO IN-TEXT CITATIONS ALLOWED IN THIS SECTION."
360
- if is_quarantined
361
- else "Use claim-specific citation. Each sentence may contain at most one source tag. "
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}. Use only their assigned representative quotations "
391
- "and do not repeat a quotation already used elsewhere."
392
- if concepts else ""
 
 
393
  )
394
- data_injection = (
395
- f"\n<RESEARCH_EVIDENCE>\n{user_data_md}\n</RESEARCH_EVIDENCE>\n"
396
- if user_data_md and sec["is_method_or_result"] else ""
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
- sys_prompt = APP_CONFIG.get("sys_academic_writer", "") + "\n" + APP_CONFIG.get("deep_refinement_directive", "")
 
 
 
 
 
 
 
 
404
  user_prompt = f"""Write the assigned English manuscript unit.
405
  Section: {sec_title}
406
  Subsection: {subsection or 'None'}
407
- Target length: {int(adjusted_target * 0.9)}-{int(adjusted_target * 1.1)} words.
 
 
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
- {data_injection}
 
 
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
- prefix = ""
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 = len(en_raw.split())
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": "", "abstract_anchor": "", "conclusion_anchor": "",
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 self.global_memory["abstract_anchor"]:
412
- memory_parts.append(f"【PAPER ANCHOR - ABSTRACT】\n{self.global_memory['abstract_anchor']}")
413
-
414
- is_intro_lit = any(kw in lower_sec for kw in ["introduction", "background", "literature", "theor", "review", "framing"])
 
 
 
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 p in golden_meta:
439
- if isinstance(p, dict):
440
- if p.get('future_gaps'): injected_data.append(f"[{p.get('title', 'Ref')}] Future Gaps: {p['future_gaps']}")
441
- if p.get('theoretical_lens'): injected_data.append(f"[{p.get('title', 'Ref')}] Theory: {p['theoretical_lens']}")
442
- if injected_data:
443
- memory_parts.append("【TARGETED INJECTION: CORE GAPS & THEORIES】\n" + "\n".join(injected_data))
444
-
 
 
 
445
  elif is_method:
446
- for p in golden_meta:
447
- if isinstance(p, dict) and p.get('variables_and_measurements'):
448
- injected_data.append(f"[{p.get('title', 'Ref')}] Measurements: {p['variables_and_measurements']}")
449
- if injected_data:
450
- memory_parts.append("【TARGETED INJECTION: CORE MEASUREMENTS】\n" + "\n".join(injected_data))
451
-
 
 
 
452
  elif is_discussion:
453
- for p in golden_meta:
454
- if isinstance(p, dict) and p.get('causal_pathways'):
455
- injected_data.append(f"[{p.get('title', 'Ref')}] Prior Pathways: {p['causal_pathways']}")
456
- if injected_data:
457
- memory_parts.append("【TARGETED INJECTION: CORE LITERATURE FINDINGS】\n" + "\n".join(injected_data))
 
 
 
 
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
- sys_prompt = (
1124
- "You are a senior scholar conceptualizing a high-impact "
1125
- "empirical paper."
1126
- )
1127
- format_mandate = """\n\n[CRITICAL FORMAT MANDATE]
1128
- You MUST output your response EXACTLY using the following two delimiters. Do not add Markdown headings before them:
1129
- ---ABSTRACT---
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
- return final_abstract, final_conclusion
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 stream_generate_anchors(
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
- sys_prompt = (
1212
- "You are a senior scholar conceptualizing a high-impact "
1213
- "empirical paper."
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
- f"\nResearch Mode: {mode}\n"
1223
- "The following evidence packet is the single source of truth "
1224
- "for setting, sample, data sources, method, findings, and all "
1225
- "numerical details. Do not invent or alter them.\n"
1226
- f"<RESEARCH_EVIDENCE>\n{data_evidence[:12000]}\n"
1227
- "</RESEARCH_EVIDENCE>\n"
1228
- if data_evidence
1229
- else ""
1230
  )
1231
- base_directive = APP_CONFIG.get(
1232
- "anchor_generation_directive",
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
- user_prompt,
1244
- max_tokens=8000,
1245
- model_role="reasoning",
1246
- thinking=True,
1247
- allow_fallback=False,
 
 
 
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
- "representative quotations. Do not repeat another theme."
 
 
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
- section_target = max(120, int(total_words * float(section.get("weight", 0.1))))
 
 
 
 
 
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
- headers = {'x-api-key': str(self.s2_key).strip()} if self.s2_key else {}
 
 
 
 
 
 
 
 
 
 
 
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 status in (401, 403):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- 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.\nSECTION: {section_name}\nTEXT:\n{value}"""
 
 
 
 
 
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
- "analysis_method": "string"
 
 
 
31
  },
32
  "evidence": [
33
- {"theme": "2nd-order theme", "concept": "1st-order concept", "quote": "verbatim quote"}
 
 
 
 
 
 
 
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. Use only setting, sample, source, and count information supported by the supplied material. Use null or "Not specified" when absent.
44
- 2. Extract up to 15 representative quotations verbatim; do not rewrite quotations.
45
- 3. Build a coherent Gioia-style coding structure.
46
- 4. The study_facts object will become the single source of truth for the entire manuscript.
 
 
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 study design and evidence packet for a complete ASQ-style manuscript when no interview material is supplied.
57
 
58
  Rules:
59
- 1. Choose one research setting and keep it fixed.
60
- 2. Choose one participant count, interview count, data-source set, and analysis method; do not create conflicting counts.
61
- 3. Produce exactly 12 natural representative quotations organized into a Gioia-style coding structure.
62
- 4. The study_facts object will become the single source of truth for the entire manuscript.
63
- 5. Avoid decorative language, generic AI phrasing, and repetitive quotation patterns.
 
 
 
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 deterministic evidence + metadata.
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
- logging.error("Qualitative JSON parsing failed; using safe fallback.")
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
- sources = ", ".join(self._clean_cell(item) for item in data_sources)
 
125
  participants = facts.get("participant_count")
126
  interviews = facts.get("interview_count")
127
- method = self._clean_cell(
128
- facts.get("analysis_method"),
129
- "Gioia-style inductive coding",
 
 
 
130
  )
 
 
 
 
 
 
131
 
132
  md = (
133
- "### Qualitative Study Design Summary\n"
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"| Analysis method | {method} |\n\n"
141
- "[CONSISTENCY MANDATE]\n"
142
- "Use the study-design summary above as the single source of truth. "
143
- "Do not change the setting, sample, interview count, data sources, "
144
- "or analysis method in any section.\n\n"
145
- "### Table: Qualitative Coding Structure (Data Evidence)\n"
146
- "| 2nd-order Theme | 1st-order Concept | Representative Quote |\n"
147
- "| :--- | :--- | :--- |\n"
 
 
 
148
  )
149
 
150
  clean_evidence = []
151
- for index, item in enumerate(evidence[:15]):
 
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"), "Analysis")
156
- theme = self._clean_cell(item.get("theme"), "Observation")
157
- if not quote:
 
 
 
 
 
 
158
  continue
159
- tag = f"QUOTE-{index + 1:02d}"
160
- md += f'| {theme} | {concept} | "{quote}" [{tag}] |\n'
 
 
 
 
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
- {"title": item["theme"], "concepts": [], "evidence": []},
 
 
 
 
 
 
175
  )
176
  if item["concept"] not in theme_entry["concepts"]:
177
  theme_entry["concepts"].append(item["concept"])
 
 
 
 
178
  theme_entry["evidence"].append(
179
- {"tag": item["tag"], "quote": item["quote"]}
 
 
 
 
 
 
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": data_sources,
202
  "participant_count": participants,
203
  "interview_count": interviews,
204
- "analysis_method": method,
 
 
 
 
205
  },
206
  "evidence_count": len(clean_evidence),
207
  "generation_parameters": {
208
- "coding_approach": "Gioia-style inductive coding",
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 fixed research setting",
215
- "one fixed participant count",
216
- "one fixed interview count",
217
- "one fixed data-source set",
218
- "one fixed analysis method",
 
219
  "quotation tags must remain unique",
220
  ],
221
  },
222
  "evidence": clean_evidence,
223
  "themes": themes,
224
  }
225
- status_message = "✅ 定性研究事实、摘要锚点输入和Gioia编码证据已准备完成。"
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 data preparation for SmartPaper's AMJ track."""
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
- """Keep quantitative preparation isolated from the Streamlit UI."""
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
- "Independent_Var",
78
- "Mediator_Var",
79
- "Dependent_Var",
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"{construct_name}_{item_index + 1}"
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
- fl_matrix = mean_scores.corr().astype(object)
 
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
- def resolve_empirical_data(self, uploaded_files=None, language="zh"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- df, validity_df, fl_matrix_df = self.generate_empirical_data_robust()
173
- generated_prompt = f"""### Table 1: Reliability and Convergent Validity
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: Report the exact metrics provided above.
180
- 2. VALIDITY DEFENSE: State convergent validity only when the displayed AVE and CR values support it.
181
- 3. DISCRIMINANT DEFENSE: State discriminant validity only when the displayed diagonal and correlation values support it.
182
- 4. CONSISTENCY: Use these tables as the single source of numerical truth throughout the paper.
 
 
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
- "indicator loadings, discretization, and derived "
193
- "reliability/validity summaries."
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