GuXiaopeng commited on
Commit
4a7148f
·
verified ·
1 Parent(s): b54ace2

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +577 -219
app.py CHANGED
@@ -1,7 +1,9 @@
1
  # -*- coding: utf-8 -*-
2
- """Smart Draft - Agentic Two-Pass Pipeline with JIT RAG"""
3
 
4
  import streamlit as st
 
 
5
  st.set_page_config(page_title="SSCI Pipeline Agent", page_icon="📊", layout="wide")
6
 
7
  import os
@@ -16,13 +18,14 @@ from datetime import datetime, timezone, timedelta
16
  import pandas as pd
17
  import numpy as np
18
 
 
19
  from app_config import APP_CONFIG, AMJ_PROMPTS, GENERIC_PROMPTS
20
  from utils.file_parser import extract_text_from_files, extract_data_from_files
21
  from utils.docx_builder import text_to_docx
22
  from core.pipeline_agent import SSciPipelineAgent
23
 
24
  # ==========================================
25
- # 0. 环境与状态机配置
26
  # ==========================================
27
  os.environ["STREAMLIT_SERVER_ENABLE_CORS"] = "false"
28
  os.environ["STREAMLIT_SERVER_ENABLE_XSRF_PROTECTION"] = "false"
@@ -31,72 +34,159 @@ global_ds_key = os.environ.get("DEEPSEEK_API_KEY", "")
31
  if not global_ds_key:
32
  try: global_ds_key = st.secrets.get("DEEPSEEK_API_KEY", "")
33
  except: pass
 
34
  global_s2_key = os.environ.get("S2_API_KEY", "")
 
 
 
35
 
 
36
  WORKSPACE_DIR = ".workspace"
37
  CHECKPOINT_FILE = os.path.join(WORKSPACE_DIR, "snapshot.json")
38
- if not os.path.exists(WORKSPACE_DIR): os.makedirs(WORKSPACE_DIR, exist_ok=True)
39
-
40
- WECHAT_BLOCKER = "<script>try { if (navigator.userAgent.toLowerCase().indexOf('micromessenger') !== -1) { var d = window.parent.document; if (!d.getElementById('wx-block')) { var o = d.createElement('div'); o.id = 'wx-block'; o.style.cssText = 'position:fixed;top:0;left:0;width:100vw;height:100vh;background:#1a365d;z-index:9999999;color:white;display:flex;align-items:center;justify-content:center;text-align:center;padding:30px;'; o.innerHTML = '<h2>请在外部浏览器打开以支持文件下载</h2>'; d.body.appendChild(o); } } } catch(e) {}</script>"
41
- components.html(WECHAT_BLOCKER, height=0, width=0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
  # ==========================================
44
- # 1. 蒙特卡洛防幻觉数据引擎 (保持不变)
45
  # ==========================================
46
  def generate_psd_corr_matrix(n_constructs, max_corr=0.5):
 
47
  if n_constructs == 1: return np.array([[1.0]])
 
48
  base_corr = np.random.uniform(0.2, max_corr - 0.1)
49
  A = np.random.uniform(base_corr - 0.1, base_corr + 0.1, (n_constructs, n_constructs))
50
  A = (A + A.T) / 2
51
  np.fill_diagonal(A, 1.0)
 
52
  min_eig = np.min(np.linalg.eigvals(A))
53
- if min_eig < 0: A -= (min_eig - 0.01) * np.eye(n_constructs)
 
 
54
  d = np.sqrt(np.diag(A))
55
  return A / np.outer(d, d)
56
 
57
- def generate_empirical_data_robust(construct_names=None, n=300):
58
- if not construct_names or len(construct_names) < 2: construct_names = ["IV", "Mediator", "DV"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  n_constructs = len(construct_names)
60
- latent_scores = np.random.multivariate_normal(mean=[0]*n_constructs, cov=generate_psd_corr_matrix(n_constructs), size=n)
61
- data, true_loadings, info = {}, {}, []
 
 
 
 
 
62
  for idx, c_name in enumerate(construct_names):
63
  true_score = latent_scores[:, idx]
64
- loadings = np.random.uniform(0.72, 0.88, np.random.randint(3, 6))
 
65
  true_loadings[c_name] = loadings
 
66
  item_names = []
67
  for i, load in enumerate(loadings):
68
- item_score = load * true_score + np.random.normal(0, np.sqrt(1 - load**2), n)
 
 
 
69
  col_name = f"{c_name}_{i+1}"
70
- data[col_name] = np.clip(np.round(item_score * 1.4 + 4), 1, 7).astype(int)
71
  item_names.append(col_name)
72
- info.append({"name": c_name, "items": item_names})
73
-
 
74
  df = pd.DataFrame(data)
75
- metrics = []
76
- for c in info:
77
- sub = df[c['items']]
78
- var_t, sum_v, k = sub.sum(axis=1).var(ddof=1), sub.var(ddof=1).sum(), len(c['items'])
79
- alpha = 0.0 if var_t <= 0 else (k / (k - 1)) * (1 - sum_v / var_t)
80
- load = np.array(true_loadings[c['name']])
81
- cr = np.sum(load)**2 / (np.sum(load)**2 + np.sum(1 - load**2))
82
- ave = np.sum(load**2) / k
83
- metrics.append({"Construct": c['name'], "Items": k, "Cronbach's α": round(alpha, 3), "CR": round(cr, 3), "AVE": round(ave, 3)})
84
- val_df = pd.DataFrame(metrics)
85
 
86
- mean_scores = pd.DataFrame({c['name']: df[c['items']].mean(axis=1) for c in info})
87
  fl_matrix = mean_scores.corr()
88
- for i, row in val_df.iterrows(): fl_matrix.loc[row['Construct'], row['Construct']] = f"({np.sqrt(row['AVE']):.3f})"
 
 
 
 
 
89
  for i in range(len(fl_matrix.columns)):
90
- for j in range(i + 1, len(fl_matrix.columns)): fl_matrix.iloc[i, j] = ""
91
- return df, val_df, mean_scores.mean().to_frame('Mean').join(mean_scores.std().to_frame('SD')).join(fl_matrix)
 
 
 
92
 
93
  def resolve_empirical_data(extracted_vars=None):
94
- df, val_df, fl_matrix = generate_empirical_data_robust(extracted_vars)
95
- prompt = f"### Table 1: Validity\n{val_df.to_markdown(index=False)}\n\n### Table 2: Discriminant\n{fl_matrix.to_markdown()}\n[STRICT INSTRUCTION]: ZERO HALLUCINATION. Report exact metrics. State AVE>0.5 establishes Convergent Validity."
96
- return df, prompt, "✅ 已启用蒙特卡洛防幻觉引擎"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
 
98
  # ==========================================
99
- # 2. UI 布局与拦截器
100
  # ==========================================
101
  if "is_running" not in st.session_state: st.session_state.is_running = False
102
  if "start_pipeline" not in st.session_state: st.session_state.start_pipeline = False
@@ -104,262 +194,530 @@ if "resume_pipeline" not in st.session_state: st.session_state.resume_pipeline =
104
  if "zip_buffer" not in st.session_state: st.session_state.zip_buffer = None
105
  if "run_mode" not in st.session_state: st.session_state.run_mode = "wizard"
106
 
107
- lang_choice = st.radio("Lang", list(APP_CONFIG["ui_labels"].keys()), horizontal=True, label_visibility="collapsed")
108
  ui, target_lang = APP_CONFIG["ui_labels"][lang_choice], APP_CONFIG["target_lang_map"][lang_choice]
109
  st.markdown(f"<div class='main-title'><h2>{ui['title']}</h2></div>", unsafe_allow_html=True)
110
 
111
- # 断点恢复拦截器
112
- if os.path.exists(CHECKPOINT_FILE) and not st.session_state.is_running:
113
- st.warning("⚠️ 系统检测到上次因网络或意外中断的未完成任务。")
114
- c1, c2 = st.columns([1, 1])
115
- if c1.button("🔄 恢复断点续传 (Resume Task)"):
116
- st.session_state.resume_pipeline, st.session_state.start_pipeline = True, False
 
117
  st.rerun()
118
- if c2.button("🗑️ 清除临时数据 (Clear Data)"):
119
  os.remove(CHECKPOINT_FILE)
120
  st.rerun()
121
  st.markdown("---")
122
 
123
  topic = st.text_input(ui["topic_label"], value="虚拟旅游体验对实地旅游意愿的影响研究", disabled=st.session_state.is_running)
124
- keywords = st.text_input(ui["keywords_label"], value="临场感;国家公园;虚拟旅游;消费者行为", disabled=st.session_state.is_running)
125
  words = st.number_input(ui["words_label"], min_value=500, max_value=50000, value=10000, step=500, disabled=st.session_state.is_running)
126
 
127
- tab_wiz, tab_clone = st.tabs(["✨ 智能向导模式", "🧬 靶向克隆模式"])
128
 
129
- with tab_wiz:
130
- prompt_style_ui = st.radio("1. 写作规范:", ["工商管理实证研究 (AMJ Style)", "通用学术规范 (Generic Style)"], index=0, disabled=st.session_state.is_running)
131
- up_papers = st.file_uploader("2. 上传参考论文 (可选)", type=["pdf", "docx"], accept_multiple_files=True, disabled=st.session_state.is_running)
132
- up_data = st.file_uploader("3. 上传科研数据 (可选)", type=["csv", "xlsx"], disabled=st.session_state.is_running)
133
- if st.button("🚀 启动生成", disabled=st.session_state.is_running, key="b1"):
134
- st.session_state.run_mode, st.session_state.start_pipeline = "wizard", True
 
 
 
 
 
135
  st.rerun()
136
 
137
  with tab_clone:
138
- ctx_diff = st.text_area("1. 研究背景与核心差异(必填)", placeholder="简述新研究与范例论文的区别...", max_chars=2000, disabled=st.session_state.is_running)
139
- if "extracted_sections" not in st.session_state: st.session_state.extracted_sections = []
140
- up_ref_clone = st.file_uploader("2. 上传范例论文 (必填)", type=["pdf", "docx"], key="cr", disabled=st.session_state.is_running)
 
 
 
 
141
 
142
- if up_ref_clone and global_ds_key and not st.session_state.extracted_sections:
143
- with st.spinner("🔍 提取范例目录中..."):
144
- tmp_ag = SSciPipelineAgent(global_ds_key)
145
- ref_txt = extract_text_from_files([up_ref_clone])
146
- st.session_state.extracted_sections = tmp_ag.extract_document_structure(ref_txt)
147
- st.success(f"已提取克隆结构:{', '.join(st.session_state.extracted_sections)}")
148
-
149
- up_data_clone = st.file_uploader("3. 上传科研数据 (可选)", type=["csv", "xlsx"], key="cd", disabled=st.session_state.is_running)
150
- if st.button("🚀 启动克隆", disabled=st.session_state.is_running, key="b2"):
151
- if not ctx_diff.strip() or not up_ref_clone: st.error("必须填写差异并上传范例!")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  else:
153
- st.session_state.run_mode, st.session_state.start_pipeline = "clone", True
 
154
  st.rerun()
155
 
156
- progress_bar, monitor_ui = st.empty(), st.empty()
157
- def render_monitor(ag, phase, sec="", dets=""):
158
- monitor_ui.markdown(f"<div style='border:1px solid #ddd;padding:10px;border-radius:5px;'><b>🔄 {phase}</b> | 📍 {sec} <br>📝 {dets}<br>📡 Calls - DeepSeek: {ag.ds_calls} | OpenAlex: {ag.oa_calls} | S2: {ag.s2_calls}</div>", unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
 
160
  # ==========================================
161
- # 3. 后台流水线执行 (Agentic Two-Pass + JIT)
162
  # ==========================================
163
  if st.session_state.start_pipeline or st.session_state.resume_pipeline:
164
- if not global_ds_key: st.stop()
 
 
 
165
  st.session_state.is_running = True
 
166
  agent = SSciPipelineAgent(global_ds_key, global_s2_key)
167
 
168
  try:
169
- # ---- 状态机加载/初始化 ----
170
- if st.session_state.resume_pipeline:
171
- pb = progress_bar.progress(50, text="🔄 从快照恢复状态...")
172
- with open(CHECKPOINT_FILE, "r", encoding="utf-8") as f: state = json.load(f)
173
- topic, keywords, target_lang, words = state["topic"], state["keywords"], state["target_lang"], state["words"]
174
- user_data_md, user_lit_context = state["user_data_md"], state["user_lit_context"]
 
 
 
 
 
 
 
 
175
  agent.load_state(state["agent_state"])
176
- adaptive_structure, completed_sections = state["adaptive_structure"], state["completed_sections"]
177
- full_draft_en, full_draft_translated = state["full_draft_en"], state["full_draft_translated"]
178
- synthesis_result, clone_metadata = state["synthesis_result"], state["clone_metadata"]
 
 
 
 
179
  context_tail = state["context_tail"]
 
180
  APP_CONFIG.update(state.get("prompt_config", GENERIC_PROMPTS))
 
 
 
181
  else:
182
  pb = progress_bar.progress(1, text="⏳ 流水线启动...")
183
- user_lit_context, user_data_md = "", ""
184
- if st.session_state.run_mode == "wizard":
185
- if up_papers: user_lit_context = extract_text_from_files(up_papers)
186
- if up_data: user_data_md = extract_data_from_files(up_data)
187
- else:
188
- reference_paper_text = extract_text_from_files([up_ref_clone])
189
- if up_data_clone: user_data_md = extract_data_from_files(up_data_clone)
190
-
191
- if not user_data_md: _, user_data_md, m = resolve_empirical_data(); st.info(m)
192
 
193
- # 选择风格引擎
194
- cfg = AMJ_PROMPTS if ("AMJ" in prompt_style_ui or agent._is_empirical_business_research(topic, keywords)) else GENERIC_PROMPTS
195
- APP_CONFIG.update(cfg)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
 
 
 
197
  clone_metadata = {}
198
  if st.session_state.run_mode == "clone":
199
- pb.progress(5, text="🧬 逆向解构范例...")
200
  for sec_tgt in st.session_state.extracted_sections:
201
  clone_metadata[sec_tgt] = agent.reverse_engineer_section(reference_paper_text, sec_tgt)
202
-
203
- pb.progress(10, text="📚 种子文献检索 (Seed RAG)...")
204
- render_monitor(agent, "全局种子检索", "API", "提取大纲所需支撑...")
205
- seed_refs = agent.fetch_seed_references(topic, keywords)
 
 
 
 
 
 
 
 
 
 
 
 
206
 
207
- pb.progress(15, text="🧠 文献综合与锚点生成...")
208
- synthesis_result = agent.synthesize_literature(topic, seed_refs, user_lit_context)
209
- abs_anc, conc_anc = agent.generate_anchors(topic, keywords, synthesis_result, seed_refs)
210
- agent.global_memory["abstract_anchor"], agent.global_memory["conclusion_anchor"] = abs_anc, conc_anc
211
 
 
212
  plan = agent.generate_outline(topic, keywords, synthesis_result)
213
- en_topic, en_keywords = plan["en_topic"], plan["en_keywords"]
214
 
215
- t_abs = re.sub(r'(?i)^(摘要|abstract|аннотация|түйіндеме)[:\s]*', '', agent.call_deepseek(f"Translate into {target_lang}. ONLY text.", abs_anc, max_tokens=1500)).strip()
216
- la = {"Simplified Chinese (简体中文)": "摘要", "Russian (Русский)": "Аннотация", "Kazakh (Қазақша)": "Түйіндеме"}.get(target_lang, "Abstract")
217
- lk = {"Simplified Chinese (简体中文)": "关键词", "Russian (Русский)": "Ключевые слова", "Kazakh (Қазақша)": "Кілт сөздер"}.get(target_lang, "Keywords")
 
 
 
 
218
 
219
- full_draft_en = f"# {en_topic}\n\n## Abstract\n{re.sub(r'(?i)^abstract[:\s]*', '', abs_anc).strip()}\n\n## Keywords\n{re.sub(r'(?i)^keywords[:\s]*', '', en_keywords).strip()}\n\n"
220
- full_draft_translated = f"# {topic}\n\n## {la}\n{t_abs}\n\n## {lk}\n{keywords}\n\n"
221
 
 
222
  adaptive_structure = []
223
- for sec in plan["structure"]:
224
- s_name = sec.get("section", "Section")
225
- t_words = int(words * sec.get("weight", 0.1))
226
- if t_words > agent.chunk_words_default:
227
- num_c = max(2, math.ceil(t_words / agent.chunk_words_default))
228
- for i in range(num_c): adaptive_structure.append({"section": s_name, "display_name": f"{s_name} (Part {i+1}/{num_c})", "target_words": int(t_words/num_c), "is_first": i==0, "is_mor": sec.get("is_method_or_result", False)})
229
- else: adaptive_structure.append({"section": s_name, "display_name": s_name, "target_words": t_words, "is_first": True, "is_mor": sec.get("is_method_or_result", False)})
 
 
 
230
 
231
- state = {"topic": topic, "keywords": keywords, "target_lang": target_lang, "words": words, "user_data_md": user_data_md, "user_lit_context": user_lit_context, "completed_sections": [], "full_draft_en": full_draft_en, "full_draft_translated": full_draft_translated, "context_tail": "", "clone_metadata": clone_metadata, "prompt_config": cfg, "adaptive_structure": adaptive_structure, "synthesis_result": synthesis_result}
232
  state["agent_state"] = agent.export_state()
233
- with open(CHECKPOINT_FILE, "w", encoding="utf-8") as f: json.dump(state, f, ensure_ascii=False)
234
- completed_sections, context_tail = [], ""
 
 
 
 
 
235
 
236
- # ---- 微观循环生成 (Two-Pass WorkFlow) ----
 
 
237
  st.markdown("---")
238
- total_steps = len(adaptive_structure)
239
- generation_aborted = False
240
-
241
- for current_idx, sec in enumerate(adaptive_structure):
242
- if generation_aborted: break
243
  display_name = sec["display_name"]
244
  st.markdown(f"### ⚙️ {display_name}")
245
- colA, colB = st.columns(2)
246
- with colA: ph_en = st.empty()
247
- with colB: ph_tr = st.empty()
 
 
 
 
 
 
 
 
 
 
 
 
248
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
  if display_name in completed_sections:
250
- ph_en.success("✅ [已从快照恢复]"); ph_tr.success("✅ [已从快照恢复]")
251
- continue
252
 
253
- pb.progress(20 + int((current_idx/total_steps)*70), text=f"正在生成 {display_name}...")
 
 
254
 
255
- # JIT RAG: 实时补充弹药
256
- is_quarantine = any(k in sec["section"].lower() for k in ["abstract", "keyword", "conclusion", "reference"])
257
- if not is_quarantine:
258
- render_monitor(agent, "JIT RAG", display_name, "实时检索支撑文献...")
259
- agent.fetch_jit_references(sec["section"], topic, context_tail)
260
 
261
- local_context_str, local_cites = agent.get_pruned_context(sec["section"])
262
- q_rule = "【NO IN-TEXT CITATIONS ALLOWED HERE】" if is_quarantine else f"【CITE STRICTLY】: Integrate these citations contextually: {', '.join(local_cites)}. <LIT>{local_context_str}</LIT>"
263
- head_inst = f"Output the section heading # {sec['section']}." if sec["is_first"] else "DO NOT output heading. Seamlessly continue."
 
 
 
264
 
265
- # --- Pass 1: Drafting Agent (草拟) ---
266
- render_monitor(agent, "Pass 1: Drafting", display_name, "主攻逻辑、数据流与引文...")
267
- ph_en.info("✍️ [Pass 1] Drafting Content...")
 
 
 
 
268
 
269
- base_p1 = f"""{APP_CONFIG['pass1_directive']}
270
- Task: Write '{display_name}' for topic '{topic}'.
271
- {q_rule}
272
- {head_inst}
273
- <GLOBAL_MEMORY>\n{agent._build_memory_context(1000)}\n</GLOBAL_MEMORY>
274
- Previous Tail: ...{context_tail}..."""
275
-
276
- is_clone = False
 
 
 
 
 
 
 
 
 
 
 
277
  if st.session_state.run_mode == "clone":
278
- target_key = next((k for k in clone_metadata.keys() if set(re.findall(r'[a-z]{3,}', k.lower())) & set(re.findall(r'[a-z]{3,}', sec["section"].lower()))), None)
279
- if target_key:
280
- is_clone = True
281
- meta = clone_metadata[target_key]
282
- base_p1 += f"\n[CLONE MODE: STRUCTURE] Follow this exact reporting sequence: {meta.get('reporting_structure')}. Integrate Core Diff: {ctx_diff}"
283
-
284
- if sec["is_mor"]: base_p1 += f"\n<USER_DATA>\n{user_data_md}\n</USER_DATA>"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
285
 
286
- try:
287
- draft_stream = ph_en.write_stream(agent.stream_deepseek(APP_CONFIG['sys_academic_writer'], base_p1, 5000))
288
- draft_clean = re.sub(r'<think>.*?</think>', '', draft_stream, flags=re.DOTALL).strip()
289
- except Exception as e:
290
- st.error(f"Pass 1 断网熔断: {e}"); generation_aborted = True; break
291
-
292
- # --- Pass 2: Editorial Agent (审校与风格洗稿) ---
293
- render_monitor(agent, "Pass 2: Editing", display_name, "主攻反 AI 味、突现度与排版...")
294
- ph_en.info("👔 [Pass 2] Editorial Polishing...")
295
 
296
- t_words = sec['target_words']
297
- base_p2 = f"""{APP_CONFIG['pass2_directive']}
298
- [LENGTH CONSTRAINT] Force text to ~{t_words} words ({agent._calc_paragraph_count(t_words)} paras).
299
- [DRAFT TO EDIT]:
300
- {draft_clean}"""
301
-
302
- if is_clone:
303
- base_p2 += f"\n[CLONE MODE: STYLE] Wash the draft to perfectly mimic this tone: {meta.get('tone_and_style')}. Snippet to mimic: {meta.get('few_shot_snippet')}"
304
-
305
  try:
306
- final_stream = ph_en.write_stream(agent.stream_deepseek(APP_CONFIG['sys_academic_writer'], base_p2, 5000))
307
- final_clean = re.sub(r'<think>.*?</think>', '', final_stream, flags=re.DOTALL).strip()
308
- final_resolved = agent.resolve_citations(final_clean, is_quarantine)
309
- ph_en.markdown(final_resolved)
310
- full_draft_en += final_resolved + "\n\n"
 
 
 
 
 
 
 
311
 
312
- agent._extract_memory_from_text(final_clean, sec["section"])
313
- context_tail = " ".join(final_clean.split()[-200:])
314
  except Exception as e:
315
- st.error(f"Pass 2 断网熔断: {e}"); generation_aborted = True; break
 
 
 
316
 
317
- # --- Translation ---
318
- render_monitor(agent, "Translating", display_name, "多语种同步中...")
319
  try:
320
- t_raw = ph_tr.write_stream(agent.stream_deepseek(f"Translate into {target_lang}. Preserve formatting and tags.", f"TEXT:\n{final_clean}", 5000))
321
- t_clean = agent.resolve_citations(re.sub(r'<think>.*?</think>', '', t_raw, flags=re.DOTALL).strip(), is_quarantine)
322
- ph_tr.markdown(t_clean)
323
- full_draft_translated += t_clean + "\n\n"
 
 
 
324
  except Exception as e:
325
- st.error(f"翻译断网熔断: {e}"); generation_aborted = True; break
326
-
327
- # --- 状态快照持久化 ---
328
- completed_sections.append(display_name)
329
- state.update({"completed_sections": completed_sections, "full_draft_en": full_draft_en, "full_draft_translated": full_draft_translated, "context_tail": context_tail, "agent_state": agent.export_state()})
330
- with open(CHECKPOINT_FILE, "w", encoding="utf-8") as f: json.dump(state, f, ensure_ascii=False)
 
 
 
 
 
 
 
331
 
332
- # ---- 全局打包与清理 (Finalization & GC) ----
333
  if not generation_aborted and full_draft_en.strip():
334
- pb.progress(95, text="📦 打包并生成参考文献列表...")
335
- full_draft_en = re.sub(r'(?im)^##?\s*(References?).*?(\n##?|\Z)', '', full_draft_en, flags=re.DOTALL).strip()
336
- full_draft_translated = re.sub(r'(?im)^##?\s*(参考文献|References?).*?(\n##?|\Z)', '', full_draft_translated, flags=re.DOTALL).strip()
337
 
338
- # 引文聚合 (仅使用真实引用的文献)
339
- used = sorted(list(agent.used_citations))
340
- refs = sorted(list(set([agent.citation_map[tag]['ref'] for tag in used if tag in agent.citation_map])))
341
- apa = "\n\n".join([f"- {r}" for r in refs])
342
- full_draft_en += "\n\n# References\n" + apa
343
- full_draft_translated += "\n\n# 参考文献\n" + apa
344
-
345
- # 导出 ZIP
346
- bj_t = datetime.now(timezone(timedelta(hours=8))).strftime("%Y%m%d_%H%M")
347
- pref = {"Simplified Chinese (简体中文)": "ZH", "Russian (Русский)": "RU", "Kazakh (Қазақша)": "KK"}.get(target_lang, "ZH")
348
- zb = io.BytesIO()
349
- with zipfile.ZipFile(zb, "w", zipfile.ZIP_DEFLATED) as zf:
350
- zf.writestr(f"Draft_EN_{bj_t}.docx", text_to_docx(full_draft_en).getvalue())
351
- zf.writestr(f"Draft_{pref}_{bj_t}.docx", text_to_docx(full_draft_translated).getvalue())
352
- st.session_state.zip_buffer, st.session_state.zip_filename = zb.getvalue(), f"SSCI_Agent_{bj_t}.zip"
353
 
354
- # 无痕 GC 清理
355
- if os.path.exists(CHECKPOINT_FILE): os.remove(CHECKPOINT_FILE)
356
- pb.progress(100, text="✅ 任务圆满完成")
357
- render_monitor(agent, "完成", "✅", f"已生成 {len(refs)} 篇精确参考文献,临时数据已销毁")
358
-
359
- except Exception as e: st.error(f"🚨 底层系统异常: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
360
  finally:
361
- st.session_state.is_running, st.session_state.start_pipeline, st.session_state.resume_pipeline = False, False, False
 
 
362
 
363
  if st.session_state.zip_buffer:
364
  st.success(ui["success"])
365
- st.download_button(ui["btn_download"], st.session_state.zip_buffer, st.session_state.zip_filename, "application/zip")
 
1
  # -*- coding: utf-8 -*-
2
+ """Smart Draft - Empirical State Machine (HITL & Hybrid Clone Architecture)"""
3
 
4
  import streamlit as st
5
+
6
+ # 🚨 核心修复:必须在引入任何可能包含 Streamlit 动作的本地模块前,先设置 Page Config!
7
  st.set_page_config(page_title="SSCI Pipeline Agent", page_icon="📊", layout="wide")
8
 
9
  import os
 
18
  import pandas as pd
19
  import numpy as np
20
 
21
+ # 然后再引入本地模块
22
  from app_config import APP_CONFIG, AMJ_PROMPTS, GENERIC_PROMPTS
23
  from utils.file_parser import extract_text_from_files, extract_data_from_files
24
  from utils.docx_builder import text_to_docx
25
  from core.pipeline_agent import SSciPipelineAgent
26
 
27
  # ==========================================
28
+ # 0. 核心环境、全局变量与状态机准备
29
  # ==========================================
30
  os.environ["STREAMLIT_SERVER_ENABLE_CORS"] = "false"
31
  os.environ["STREAMLIT_SERVER_ENABLE_XSRF_PROTECTION"] = "false"
 
34
  if not global_ds_key:
35
  try: global_ds_key = st.secrets.get("DEEPSEEK_API_KEY", "")
36
  except: pass
37
+
38
  global_s2_key = os.environ.get("S2_API_KEY", "")
39
+ if not global_s2_key:
40
+ try: global_s2_key = st.secrets.get("S2_API_KEY", "")
41
+ except: pass
42
 
43
+ # 状态机:断点续传工作区配置
44
  WORKSPACE_DIR = ".workspace"
45
  CHECKPOINT_FILE = os.path.join(WORKSPACE_DIR, "snapshot.json")
46
+ if not os.path.exists(WORKSPACE_DIR):
47
+ os.makedirs(WORKSPACE_DIR, exist_ok=True)
48
+
49
+ WECHAT_BLOCKER_JS = """
50
+ <script>
51
+ try {
52
+ var ua = navigator.userAgent.toLowerCase();
53
+ if (ua.indexOf('micromessenger') !== -1) {
54
+ var parentDoc = window.parent.document;
55
+ if (!parentDoc.getElementById('wx-blocker')) {
56
+ var overlay = parentDoc.createElement('div');
57
+ overlay.id = 'wx-blocker';
58
+ overlay.style.cssText = 'position:fixed;top:0;left:0;width:100vw;height:100vh;background:#1a365d;z-index:9999999;color:white;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;font-family:sans-serif;padding:30px;box-sizing:border-box;';
59
+ overlay.innerHTML = '<h2 style="font-size:26px;margin-bottom:15px;font-weight:bold;">环境受限 / Restricted</h2><p style="font-size:18px;line-height:1.6;color:#e2e8f0;max-width:100%;">为了保障最终双语 ZIP 压缩包正常下载,<b>本系统禁止在微信内直接运行</b>。<br><br>👉 <b>请点击右上角 [···] 菜单,选择“在浏览器打开” (Open in Browser)</b></p>';
60
+ parentDoc.body.appendChild(overlay);
61
+ }
62
+ }
63
+ } catch (e) {}
64
+ </script>
65
+ """
66
+ components.html(WECHAT_BLOCKER_JS, height=0, width=0)
67
 
68
  # ==========================================
69
+ # 1. 鲁棒的蒙特卡洛实证模拟引擎 (带严密信效度约束)
70
  # ==========================================
71
  def generate_psd_corr_matrix(n_constructs, max_corr=0.5):
72
+ """动态生成绝对正定矩阵,强制限制最大相关系数以保证区分效度"""
73
  if n_constructs == 1: return np.array([[1.0]])
74
+
75
  base_corr = np.random.uniform(0.2, max_corr - 0.1)
76
  A = np.random.uniform(base_corr - 0.1, base_corr + 0.1, (n_constructs, n_constructs))
77
  A = (A + A.T) / 2
78
  np.fill_diagonal(A, 1.0)
79
+
80
  min_eig = np.min(np.linalg.eigvals(A))
81
+ if min_eig < 0:
82
+ A -= (min_eig - 0.01) * np.eye(n_constructs)
83
+
84
  d = np.sqrt(np.diag(A))
85
  return A / np.outer(d, d)
86
 
87
+ def calculate_validity_metrics(df, constructs_info, true_loadings):
88
+ """动态计算信度与效度 (Cronbach's alpha, CR, AVE)"""
89
+ metrics = []
90
+ for const in constructs_info:
91
+ c_name = const['name']
92
+ items = const['items']
93
+
94
+ # 经验 Cronbach's Alpha
95
+ sub_df = df[items]
96
+ var_total = sub_df.sum(axis=1).var(ddof=1)
97
+ sum_var = sub_df.var(ddof=1).sum()
98
+ n_items = len(items)
99
+ alpha = 0.0 if var_total <= 0 else (n_items / (n_items - 1)) * (1 - sum_var / var_total)
100
+
101
+ # 理论 CR & AVE
102
+ c_loadings = np.array(true_loadings[c_name])
103
+ sum_loadings_sq = np.sum(c_loadings)**2
104
+ sum_sq_loadings = np.sum(c_loadings**2)
105
+ sum_error = np.sum(1 - c_loadings**2)
106
+
107
+ cr = sum_loadings_sq / (sum_loadings_sq + sum_error)
108
+ ave = sum_sq_loadings / len(c_loadings)
109
+
110
+ metrics.append({
111
+ "Construct": c_name,
112
+ "Items": n_items,
113
+ "Cronbach's α": round(alpha, 3),
114
+ "CR": round(cr, 3),
115
+ "AVE": round(ave, 3)
116
+ })
117
+ return pd.DataFrame(metrics)
118
+
119
+ def generate_empirical_data_robust(construct_names=None, n=300, scale_points=7):
120
+ """全自适应的实证数据生成器"""
121
+ if not construct_names or len(construct_names) < 2:
122
+ construct_names = ["Independent_Var", "Mediator_Var", "Dependent_Var"]
123
+
124
  n_constructs = len(construct_names)
125
+ corr = generate_psd_corr_matrix(n_constructs, max_corr=0.55)
126
+ latent_scores = np.random.multivariate_normal(mean=[0]*n_constructs, cov=corr, size=n)
127
+
128
+ data, true_loadings, constructs_info = {}, {}, []
129
+ target_mean = scale_points / 2.0 + 0.5
130
+ target_std = scale_points / 5.0
131
+
132
  for idx, c_name in enumerate(construct_names):
133
  true_score = latent_scores[:, idx]
134
+ n_items = np.random.randint(3, 6)
135
+ loadings = np.random.uniform(0.72, 0.88, n_items) # 数学约束:确保 AVE > 0.5
136
  true_loadings[c_name] = loadings
137
+
138
  item_names = []
139
  for i, load in enumerate(loadings):
140
+ error = np.random.normal(0, np.sqrt(1 - load**2), n)
141
+ item_score = load * true_score + error
142
+ raw_scaled = item_score * target_std + target_mean
143
+ item_int = np.clip(np.round(raw_scaled), 1, scale_points).astype(int)
144
  col_name = f"{c_name}_{i+1}"
145
+ data[col_name] = item_int
146
  item_names.append(col_name)
147
+
148
+ constructs_info.append({"name": c_name, "items": item_names})
149
+
150
  df = pd.DataFrame(data)
151
+ validity_df = calculate_validity_metrics(df, constructs_info, true_loadings)
 
 
 
 
 
 
 
 
 
152
 
153
+ mean_scores = pd.DataFrame({c['name']: df[c['items']].mean(axis=1) for c in constructs_info})
154
  fl_matrix = mean_scores.corr()
155
+
156
+ for i, row in validity_df.iterrows():
157
+ c_name = row['Construct']
158
+ sqrt_ave = np.sqrt(row['AVE'])
159
+ fl_matrix.loc[c_name, c_name] = f"({sqrt_ave:.3f})"
160
+
161
  for i in range(len(fl_matrix.columns)):
162
+ for j in range(i + 1, len(fl_matrix.columns)):
163
+ fl_matrix.iloc[i, j] = ""
164
+
165
+ desc_stats = mean_scores.mean().to_frame('Mean').join(mean_scores.std().to_frame('SD'))
166
+ return df, validity_df, desc_stats.join(fl_matrix)
167
 
168
  def resolve_empirical_data(extracted_vars=None):
169
+ """路由层:生成数据并返回 LLM 专用的 Markdown 提示词"""
170
+ df, validity_df, fl_matrix_df = generate_empirical_data_robust(extracted_vars)
171
+
172
+ simulated_md_prompt = f"""
173
+ ### Table 1: Reliability and Convergent Validity
174
+ {validity_df.to_markdown(index=False)}
175
+
176
+ ### Table 2: Descriptive Statistics and Discriminant Validity (Fornell-Larcker Criterion)
177
+ *Note: Diagonal elements in parentheses are the square roots of AVE.*
178
+ {fl_matrix_df.to_markdown()}
179
+
180
+ [STRICT EMPIRICAL INSTRUCTION]
181
+ 1. ZERO HALLUCINATION: You MUST report the exact metrics (Cronbach's α, CR, AVE, correlations) provided in the tables above.
182
+ 2. VALIDITY DEFENSE: Explicitly state that Convergent Validity is established because all AVEs > 0.5 and CRs > 0.7.
183
+ 3. DISCRIMINANT DEFENSE: Explicitly state that Discriminant Validity is satisfied per the Fornell-Larcker criterion, as the square root of AVE (diagonal values) exceeds inter-construct correlations.
184
+ """
185
+ status_msg = "✅ 已成功启用稳健信效度生成引擎,AVE与Fornell-Larcker准则均验证达标。"
186
+ return df, simulated_md_prompt, status_msg
187
 
188
  # ==========================================
189
+ # 2. UI 界面布局与初始化
190
  # ==========================================
191
  if "is_running" not in st.session_state: st.session_state.is_running = False
192
  if "start_pipeline" not in st.session_state: st.session_state.start_pipeline = False
 
194
  if "zip_buffer" not in st.session_state: st.session_state.zip_buffer = None
195
  if "run_mode" not in st.session_state: st.session_state.run_mode = "wizard"
196
 
197
+ lang_choice = st.radio("Language", list(APP_CONFIG["ui_labels"].keys()), horizontal=True, label_visibility="collapsed")
198
  ui, target_lang = APP_CONFIG["ui_labels"][lang_choice], APP_CONFIG["target_lang_map"][lang_choice]
199
  st.markdown(f"<div class='main-title'><h2>{ui['title']}</h2></div>", unsafe_allow_html=True)
200
 
201
+ # 状态机:如果存在未完成快照,拦截并提示用户恢复
202
+ if os.path.exists(CHECKPOINT_FILE) and not (st.session_state.is_running):
203
+ st.warning("⚠️ 系统检测到上次因网络或意外中断的未完成论文任务。")
204
+ colA, colB = st.columns([1, 1])
205
+ if colA.button("🔄 恢复断点续传 (Resume Progress)", key="resume_task"):
206
+ st.session_state.resume_pipeline = True
207
+ st.session_state.start_pipeline = False
208
  st.rerun()
209
+ if colB.button("🗑️ 清除临时数据 (Clear Garbage)", key="clear_task"):
210
  os.remove(CHECKPOINT_FILE)
211
  st.rerun()
212
  st.markdown("---")
213
 
214
  topic = st.text_input(ui["topic_label"], value="虚拟旅游体验对实地旅游意愿的影响研究", disabled=st.session_state.is_running)
215
+ keywords = st.text_input(ui["keywords_label"], value="临场感;国家公园;全景漫游体验;虚拟旅游体验;消费者行为", disabled=st.session_state.is_running)
216
  words = st.number_input(ui["words_label"], min_value=500, max_value=50000, value=10000, step=500, disabled=st.session_state.is_running)
217
 
218
+ tab_wizard, tab_clone = st.tabs(["✨ 智能向导模式 (Smart Wizard)", "🧬 靶向克隆模式 (Clone Mode)"])
219
 
220
+ with tab_wizard:
221
+ st.markdown("### 🛠️ 人在环路:研究材料配置")
222
+ prompt_style_ui = st.radio("1. 请选择期望的写作规范与提示词引擎:",
223
+ options=["工商管理实证研究 (AMJ Style)", "通用学术规范 (Generic Style)", "🤖 自动嗅探 (Auto-detect)"],
224
+ index=0, disabled=st.session_state.is_running)
225
+ uploaded_papers_wiz = st.file_uploader("2. 上传核心参考论文 (可选,支持 PDF/DOCX,强制优先引用)", type=["pdf", "doc", "docx"], accept_multiple_files=True, disabled=st.session_state.is_running)
226
+ uploaded_data_wiz = st.file_uploader("3. 上传统计分析结果 (可选,支持 CSV/Excel,用于 Results 章��数据支撑)", type=["csv", "xlsx", "xls"], disabled=st.session_state.is_running)
227
+
228
+ if st.button("🚀 启动智能向导生成", disabled=st.session_state.is_running, key="btn_wiz"):
229
+ st.session_state.run_mode = "wizard"
230
+ st.session_state.start_pipeline = True
231
  st.rerun()
232
 
233
  with tab_clone:
234
+ st.markdown("### 🧬 高级克隆:骨架与风格复刻")
235
+ context_differences = st.text_area(
236
+ "1. 研究背景与核心差异(Context & Differences)【必填,建议 1-2000 字】",
237
+ placeholder="简述您的新研究与参考论文在数据、案例或背景上的核心区别...",
238
+ max_chars=2000,
239
+ disabled=st.session_state.is_running
240
+ )
241
 
242
+ if "extracted_sections" not in st.session_state:
243
+ st.session_state.extracted_sections = ["Introduction", "Literature Review", "Methodology", "Results", "Discussion"]
244
+ if "last_uploaded_clone_ref" not in st.session_state:
245
+ st.session_state.last_uploaded_clone_ref = None
246
+
247
+ uploaded_reference_clone = st.file_uploader("2. 上传范例参考论文 (必填,支持 PDF/DOCX,系统将自动静默提取全文档目录)", type=["pdf", "doc", "docx"], key="clone_ref", disabled=st.session_state.is_running)
248
+
249
+ if uploaded_reference_clone and global_ds_key:
250
+ if st.session_state.last_uploaded_clone_ref != uploaded_reference_clone.name:
251
+ with st.spinner("🔍 正在智能提取该范例论文的真实目录结构,请稍候..."):
252
+ temp_agent = SSciPipelineAgent(global_ds_key, global_s2_key)
253
+ ref_text_raw = extract_text_from_files([uploaded_reference_clone])
254
+ dynamic_sections = temp_agent.extract_document_structure(ref_text_raw)
255
+
256
+ st.session_state.extracted_sections = dynamic_sections
257
+ st.session_state.last_uploaded_clone_ref = uploaded_reference_clone.name
258
+ st.success(f"✅ 目录提取成功,将自动全量克隆以下结构:{', '.join(dynamic_sections)}")
259
+
260
+ uploaded_data_clone = st.file_uploader("3. 上传统计分析结果 (可选,用于实证章节防幻觉支撑)", type=["csv", "xlsx", "xls"], key="clone_data", disabled=st.session_state.is_running)
261
+
262
+ if st.button("🚀 启动克隆级生成", disabled=st.session_state.is_running, key="btn_clone"):
263
+ if not context_differences.strip():
264
+ st.error("🚨 启动克隆模式必须填写研究背景与核心差异!")
265
+ elif not uploaded_reference_clone:
266
+ st.error("🚨 启动克隆模式必须上传范例论文!")
267
  else:
268
+ st.session_state.run_mode = "clone"
269
+ st.session_state.start_pipeline = True
270
  st.rerun()
271
 
272
+ progress_bar = st.empty()
273
+ monitor_ui = st.empty()
274
+
275
+ def render_monitor(agent, phase, current_sec="", details=""):
276
+ monitor_ui.markdown(f"""
277
+ <div class='log-box'>
278
+ <div><b>🔄 流水线进度:</b> {phase}</div>
279
+ <div><b>📍 当前节点:</b> {current_sec}</div>
280
+ <div><b>📝 动作日志:</b> {details}</div>
281
+ <hr>
282
+ <div><b>📡 三维算力消耗监控:</b></div>
283
+ <div style='display: flex; gap: 8px;'>
284
+ <span style='background: #fef3c7; padding: 4px 8px; border-radius: 4px;'>🧠 DeepSeek: <b>{agent.ds_calls}</b></span>
285
+ <span style='background: #d1fae5; padding: 4px 8px; border-radius: 4px;'>🌐 OpenAlex: <b>{agent.oa_calls}</b></span>
286
+ <span style='background: #dbeafe; padding: 4px 8px; border-radius: 4px;'>📚 S2: <b>{agent.s2_calls}</b></span>
287
+ </div>
288
+ </div>
289
+ """, unsafe_allow_html=True)
290
+
291
+ FLAG_MAP = {"Simplified Chinese (简体中文)": "🇨🇳", "Russian (Русский)": "🇷🇺", "Kazakh (Қазақша)": "🇰🇿"}
292
 
293
  # ==========================================
294
+ # 3. 后台流水线执行 (融合断点续传逻辑)
295
  # ==========================================
296
  if st.session_state.start_pipeline or st.session_state.resume_pipeline:
297
+ if not global_ds_key:
298
+ st.error("❌ DEEPSEEK_API_KEY missing. 请配置API KEY。")
299
+ st.stop()
300
+
301
  st.session_state.is_running = True
302
+ is_resume = st.session_state.resume_pipeline
303
  agent = SSciPipelineAgent(global_ds_key, global_s2_key)
304
 
305
  try:
306
+ if is_resume:
307
+ pb = progress_bar.progress(50, text="🔄 正在从本地安全快照恢复状态...")
308
+ with open(CHECKPOINT_FILE, "r", encoding="utf-8") as f:
309
+ state = json.load(f)
310
+
311
+ # 从状态机恢复核心配置,规避 Streamlit 组件刷新丢失的问题
312
+ topic = state["topic"]
313
+ keywords = state["keywords"]
314
+ target_lang = state["target_lang"]
315
+ words = state["words"]
316
+ user_data_md = state["user_data_md"]
317
+ user_lit_context = state["user_lit_context"]
318
+
319
+ # 恢复 Agent 及核心变量
320
  agent.load_state(state["agent_state"])
321
+ adaptive_structure = state["adaptive_structure"]
322
+ completed_sections = state["completed_sections"]
323
+ full_draft_en = state["full_draft_en"]
324
+ full_draft_translated = state["full_draft_translated"]
325
+ synthesis_result = state["synthesis_result"]
326
+ refs_text = state["refs_text"]
327
+ clone_metadata = state["clone_metadata"]
328
  context_tail = state["context_tail"]
329
+ st.session_state.run_mode = state.get("run_mode", "wizard")
330
  APP_CONFIG.update(state.get("prompt_config", GENERIC_PROMPTS))
331
+
332
+ st.success(f"✅ 断点加载成功!将从节点 {len(completed_sections)}/{len(adaptive_structure)} 安全恢复。")
333
+
334
  else:
335
  pb = progress_bar.progress(1, text="⏳ 流水线启动...")
 
 
 
 
 
 
 
 
 
336
 
337
+ user_lit_context = ""
338
+ user_data_md = ""
339
+ if st.session_state.run_mode == "wizard":
340
+ if uploaded_papers_wiz: user_lit_context = extract_text_from_files(uploaded_papers_wiz)
341
+ if uploaded_data_wiz: user_data_md = extract_data_from_files(uploaded_data_wiz)
342
+ elif st.session_state.run_mode == "clone":
343
+ if uploaded_reference_clone: reference_paper_text = extract_text_from_files([uploaded_reference_clone])
344
+ if uploaded_data_clone: user_data_md = extract_data_from_files(uploaded_data_clone)
345
+
346
+ if not user_data_md:
347
+ with st.spinner("⚙️ 检测到未上传数据,正在通过蒙特卡洛引擎生成合格的底层防幻觉数据..."):
348
+ sim_df, sim_md_prompt, sim_msg = resolve_empirical_data()
349
+ user_data_md = sim_md_prompt
350
+ st.info(sim_msg)
351
+
352
+ # ��始化底层状态机参数
353
+ state = {
354
+ "topic": topic, "keywords": keywords, "target_lang": target_lang, "words": words,
355
+ "user_data_md": user_data_md, "user_lit_context": user_lit_context,
356
+ "completed_sections": [], "full_draft_en": "", "full_draft_translated": "",
357
+ "context_tail": "", "clone_metadata": {}, "run_mode": st.session_state.run_mode
358
+ }
359
+
360
+ pb.progress(5, text="🕵️ 引擎配置与范式路由...")
361
+ is_emp = False
362
+ if st.session_state.run_mode == "wizard":
363
+ if "AMJ" in prompt_style_ui:
364
+ APP_CONFIG.update(AMJ_PROMPTS)
365
+ st.success("🎯 用户显式指定:强制启用 AMJ 顶级期刊写作引擎。")
366
+ elif "Generic" in prompt_style_ui:
367
+ APP_CONFIG.update(GENERIC_PROMPTS)
368
+ st.info("ℹ️ 用户显式指定:启用通用学术规范引擎。")
369
+ else:
370
+ is_emp = agent._is_empirical_business_research(topic, keywords)
371
+ if is_emp: APP_CONFIG.update(AMJ_PROMPTS)
372
+ else: APP_CONFIG.update(GENERIC_PROMPTS)
373
 
374
+ state["prompt_config"] = AMJ_PROMPTS if is_emp or "AMJ" in prompt_style_ui else GENERIC_PROMPTS
375
+
376
  clone_metadata = {}
377
  if st.session_state.run_mode == "clone":
378
+ pb.progress(8, text="🧬 正在对参考论文进行逆向解构...")
379
  for sec_tgt in st.session_state.extracted_sections:
380
  clone_metadata[sec_tgt] = agent.reverse_engineer_section(reference_paper_text, sec_tgt)
381
+ st.success(f"🧬 逆向解构完成!已提取 {len(clone_metadata)} 个目标章节的骨架与风格。")
382
+ state["clone_metadata"] = clone_metadata
383
+
384
+ pb.progress(10, text="📚 文献检索...")
385
+ render_monitor(agent, "文献检索", "S2 + OpenAlex", "智能检索中...")
386
+ refs_text = agent.fetch_references_parallel(topic, keywords)
387
+ if not refs_text and len(agent.citation_map) == 0:
388
+ st.session_state.is_running = False
389
+ st.stop()
390
+ if len(agent.citation_map) < 80:
391
+ st.warning(f"文献数量仅 {len(agent.citation_map)} 篇,可能影响引用数量,继续生成...")
392
+ state["refs_text"] = refs_text
393
+
394
+ pb.progress(12, text="🧠 文献综合...")
395
+ synthesis_result = agent.synthesize_literature(topic, refs_text, user_core_literature=user_lit_context)
396
+ state["synthesis_result"] = synthesis_result
397
 
398
+ pb.progress(14, text="⚓ 生成锚点...")
399
+ abstract_anchor, conclusion_anchor = agent.generate_anchors(topic, keywords, synthesis_result, refs_text)
400
+ agent.global_memory["abstract_anchor"] = abstract_anchor
401
+ agent.global_memory["conclusion_anchor"] = conclusion_anchor
402
 
403
+ pb.progress(15, text="📝 大纲设计...")
404
  plan = agent.generate_outline(topic, keywords, synthesis_result)
405
+ en_topic, en_keywords, structure = plan["en_topic"], plan["en_keywords"], plan["structure"]
406
 
407
+ en_abstract_clean = re.sub(r'(?i)^abstract[:\s]*', '', abstract_anchor).strip()
408
+ en_keywords_clean = re.sub(r'(?i)^keywords[:\s]*', '', en_keywords).strip()
409
+ trans_abstract = agent.call_deepseek(f"Translate the following abstract into {target_lang}. Output ONLY translated text without prefix.", abstract_anchor, max_tokens=1500)
410
+ trans_abstract_clean = re.sub(r'(?i)^(摘要|abstract|аннотация|түйіндеме)[:\s]*', '', trans_abstract).strip()
411
+
412
+ loc_abs = {"Simplified Chinese (简体中文)": "摘要", "Russian (Русский)": "Аннотация", "Kazakh (Қазақша)": "Түйіндеме"}.get(target_lang, "Abstract")
413
+ loc_kw = {"Simplified Chinese (简体中文)": "关键词", "Russian (Русский)": "Ключевые слова", "Kazakh (Қазақша)": "Кілт сөздер"}.get(target_lang, "Keywords")
414
 
415
+ full_draft_en = f"# {en_topic}\n\n## Abstract\n{en_abstract_clean}\n\n## Keywords\n{en_keywords_clean}\n\n"
416
+ full_draft_translated = f"# {topic}\n\n## {loc_abs}\n{trans_abstract_clean}\n\n## {loc_kw}\n{keywords}\n\n"
417
 
418
+ SAFE_CHUNK_WORDS = agent.chunk_words_cyrillic if target_lang in ["Russian (Русский)", "Kazakh (Қазақша)"] else agent.chunk_words_default
419
  adaptive_structure = []
420
+ for sec in structure:
421
+ sec_name = sec.get("section", "Section")
422
+ is_method_or_result = sec.get("is_method_or_result", False)
423
+ target_sec_words = int(words * sec.get("weight", 0.1))
424
+ if target_sec_words > SAFE_CHUNK_WORDS:
425
+ num_chunks = max(2, math.ceil(target_sec_words / SAFE_CHUNK_WORDS))
426
+ for i in range(num_chunks):
427
+ adaptive_structure.append({"section": sec_name, "display_name": f"{sec_name} (Part {i+1}/{num_chunks})", "target_words": int(target_sec_words/num_chunks), "is_first": i==0, "is_method_or_result": is_method_or_result})
428
+ else:
429
+ adaptive_structure.append({"section": sec_name, "display_name": sec_name, "target_words": target_sec_words, "is_first": True, "is_method_or_result": is_method_or_result})
430
 
431
+ state["adaptive_structure"] = adaptive_structure
432
  state["agent_state"] = agent.export_state()
433
+ state["full_draft_en"] = full_draft_en
434
+ state["full_draft_translated"] = full_draft_translated
435
+ completed_sections = []
436
+ context_tail = ""
437
+
438
+ with open(CHECKPOINT_FILE, "w", encoding="utf-8") as f:
439
+ json.dump(state, f, ensure_ascii=False)
440
 
441
+ # ====== 核心循环块:完全兼容断点读取 ======
442
+ total_steps = len(adaptive_structure) * 2
443
+ current_step = len(completed_sections) * 2
444
  st.markdown("---")
445
+ ui_blocks = []
446
+ target_flag = FLAG_MAP.get(target_lang, "🌐")
447
+
448
+ # 先渲染 UI 占位符,识别哪些已经恢复
449
+ for sec in adaptive_structure:
450
  display_name = sec["display_name"]
451
  st.markdown(f"### ⚙️ {display_name}")
452
+ col_en, col_trans = st.columns(2)
453
+ with col_en:
454
+ st.markdown("#### 🇬🇧 英文初稿")
455
+ en_ph = st.empty()
456
+ with col_trans:
457
+ st.markdown(f"#### {target_flag} 翻译初稿")
458
+ trans_ph = st.empty()
459
+
460
+ if display_name in completed_sections:
461
+ en_ph.success("✅ 已从快照安全恢复并拼接")
462
+ trans_ph.success("✅ 已从快照安全恢复并拼接")
463
+ else:
464
+ en_ph.info("⏳ 等待执行...")
465
+ trans_ph.info("⏳ 等待...")
466
+ ui_blocks.append({"sec": sec, "en_ph": en_ph, "trans_ph": trans_ph})
467
 
468
+ generation_aborted = False
469
+ accumulated_deviation = 0
470
+ remaining_sections = len(adaptive_structure)
471
+
472
+ all_cite_keys = list(agent.citation_map.keys())
473
+ valid_chunks = [s for s in adaptive_structure if not any(kw in s["section"].lower() for kw in ["abstract", "keyword", "摘要", "关键字"])]
474
+ allocation_map = {}
475
+ current_cite_idx = 0
476
+ for idx, vc in enumerate(valid_chunks):
477
+ if idx == len(valid_chunks) - 1: allocation_map[vc["display_name"]] = all_cite_keys[current_cite_idx:]
478
+ else:
479
+ num_cites = max(1, math.ceil(len(all_cite_keys) / len(valid_chunks)))
480
+ allocation_map[vc["display_name"]] = all_cite_keys[current_cite_idx : current_cite_idx + num_cites]
481
+ current_cite_idx += num_cites
482
+
483
+ for i, block in enumerate(ui_blocks):
484
+ if generation_aborted: break
485
+ sec = block["sec"]; en_ph = block["en_ph"]; trans_ph = block["trans_ph"]
486
+ sec_title = sec["section"]; display_name = sec["display_name"]
487
+
488
  if display_name in completed_sections:
489
+ remaining_sections -= 1
490
+ continue # 直接跳过已生成的步骤
491
 
492
+ remaining_sections -= 1
493
+ adjustment = -accumulated_deviation / remaining_sections if remaining_sections > 0 else 0
494
+ adjusted_target = max(50, int(sec['target_words'] + adjustment))
495
 
496
+ quarantine_keywords = ["abstract", "keyword", "摘要", "关键字", "关键词", "conclusion", "结论", "заключение", "қорытынды", "references"]
497
+ is_quarantined = any(kw in sec_title.lower() for kw in quarantine_keywords)
 
 
 
498
 
499
+ if is_quarantined: quarantine_rule = "【CRITICAL】: NO IN-TEXT CITATIONS ALLOWED IN THIS SECTION."
500
+ else:
501
+ assigned_keys = allocation_map.get(display_name, [])
502
+ assigned_tags_str = ", ".join(assigned_keys) if assigned_keys else "any relevant citations"
503
+ quarantine_rule = f"""【CITATION GUIDANCE】: You are strongly encouraged to integrate citations: {assigned_tags_str}. <AVAILABLE_CITATIONS>{refs_text}</AVAILABLE_CITATIONS>"""
504
+ heading_instruction = f"Output the section heading # {sec_title}." if sec["is_first"] else "DO NOT output the main heading. Seamlessly continue."
505
 
506
+ memory_context = agent._build_memory_context(max_tokens=1500)
507
+ memory_block = f"\n【GLOBAL PAPER MEMORY】\n{memory_context}\n" if memory_context else ""
508
+
509
+ lower_bound = int(adjusted_target * 0.9)
510
+ upper_bound = int(adjusted_target * 1.1)
511
+ lang_hint = "ru" if "Русский" in target_lang else ("kk" if "Қазақша" in target_lang else "en")
512
+ num_paragraphs = agent._calc_paragraph_count(adjusted_target, lang_hint)
513
 
514
+ adaptive_heading_rule = ""
515
+ if adjusted_target > 800:
516
+ adaptive_heading_rule = "The section is long. You MUST use Level 2 (##) and Level 3 (###) subheadings to logically structure your arguments. Do not write massive blocks of un-sectioned text."
517
+ elif adjusted_target > 400:
518
+ adaptive_heading_rule = "Please use Level 2 (##) subheadings to organize your content clearly."
519
+
520
+ table_rule = ""
521
+ if sec["is_method_or_result"]:
522
+ table_rule = "[CRITICAL DATA FORMATTING] You MUST present ALL statistical data, descriptive statistics, validity metrics, and correlation matrices using STANDARD MARKDOWN TABLES. NEVER present dense statistical data in plain text paragraphs."
523
+
524
+ length_directive = f"""
525
+ [LENGTH MANDATE - STRICT] Write between {lower_bound} and {upper_bound} words (approx {num_paragraphs} paragraphs). No fluff.
526
+ {adaptive_heading_rule}
527
+ {table_rule}
528
+ [FINAL CHECK] Do not output a word count statement.
529
+ """
530
+
531
+ is_clone_target = False
532
+ active_clone_key = None
533
  if st.session_state.run_mode == "clone":
534
+ for target_key in clone_metadata.keys():
535
+ target_words = set(re.findall(r'[a-zA-Z]{3,}', target_key.lower()))
536
+ sec_words = set(re.findall(r'[a-zA-Z]{3,}', sec_title.lower()))
537
+
538
+ if "result" in target_words: target_words.add("analysis")
539
+ if "result" in sec_words: sec_words.add("analysis")
540
+
541
+ if target_words & sec_words:
542
+ is_clone_target = True
543
+ active_clone_key = target_key
544
+ break
545
+
546
+ if is_clone_target:
547
+ meta = clone_metadata[active_clone_key]
548
+ sys_prompt_m3 = "You are a top-tier academic ghostwriter executing a structural and stylistic clone."
549
+
550
+ base_user = f"""
551
+ [CLONE MODE ACTIVATED - TARGET: {sec_title}]
552
+ Mimic the academic tone, structural flow, and sentence patterns of this reference template:
553
+ <TONE_PROFILE>{meta.get("tone_and_style", "Objective")}</TONE_PROFILE>
554
+ <REFERENCE_TEMPLATE_SNIPPET>\n{meta.get("few_shot_snippet", "")}\n</REFERENCE_TEMPLATE_SNIPPET>
555
+
556
+ [NEW CONTEXT]
557
+ Write '{display_name}' for the topic: {topic}. Core differences to integrate: {context_differences}
558
+ {length_directive} {quarantine_rule} {heading_instruction}
559
+
560
+ 【CONTEXT SMOOTHING MANDATE】
561
+ Your previous section ended with: "...{context_tail}..."
562
+ Write a natural, smooth transitional opening sentence to bridge this.
563
+ """
564
+
565
+ if "method" in sec_title.lower() or "result" in sec_title.lower() or "analysis" in sec_title.lower():
566
+ base_user += f"""\n[CRITICAL STOP - ZERO DATA HALLUCINATION RULE]
567
+ You are writing an empirical section. YOU ARE ABSOLUTELY FORBIDDEN from using any exact numbers (R-square, p-values, sample sizes, beta coefficients) found in the <REFERENCE_TEMPLATE_SNIPPET>.
568
+ You MUST ONLY rely on the user's actual statistical data provided below.
569
+ If a required metric is missing from the user data, insert "[INSERT VALUE]" as a placeholder. DO NOT FABRICATE NUMBERS.
570
+ <USER_ACTUAL_DATA>\n{user_data_md}\n</USER_ACTUAL_DATA>"""
571
+ else:
572
+ extra_directives = []
573
+ lower_sec = sec_title.lower()
574
+ if sec["is_method_or_result"]:
575
+ extra_directives.append(APP_CONFIG["methodological_defense_directive"])
576
+ extra_directives.append(APP_CONFIG["empirical_data_logic"])
577
+ if any(kw in lower_sec for kw in ["theory", "theoretical", "hypothesis", "introduction"]):
578
+ extra_directives.append(APP_CONFIG["deep_synthesis_directive"])
579
+ extra_directives.append(APP_CONFIG["hypothesis_development_directive"])
580
+ if "discussion" in lower_sec: extra_directives.append(APP_CONFIG["discussion_depth_directive"])
581
+ if "conclusion" in lower_sec: extra_directives.append(APP_CONFIG["conclusion_anchor_directive"])
582
+
583
+ theory_injection = "\n\n".join(extra_directives)
584
+ sys_prompt_m3 = APP_CONFIG["sys_academic_writer"] + "\n" + APP_CONFIG["deep_refinement_directive"]
585
+
586
+ wiz_data_injection = f"\n<USER_ACTUAL_DATA>\n{user_data_md}\n</USER_ACTUAL_DATA>\n" if user_data_md and sec["is_method_or_result"] else ""
587
+
588
+ base_user = f"""Generate a polished section '{display_name}'.
589
+ {length_directive}\n{quarantine_rule}\n{heading_instruction}\n{theory_injection}\n{memory_block}
590
+ Global Research Synthesis: {synthesis_result}\n{wiz_data_injection}
591
+ Previous Context Tail: ...{context_tail}...\nIMPORTANT: Output directly the final academic text."""
592
+
593
+ total_tokens = agent._count_tokens(sys_prompt_m3) + agent._count_tokens(base_user)
594
+ if total_tokens > 60000:
595
+ base_user = agent._truncate_context(base_user, 60000 - agent._count_tokens(sys_prompt_m3))
596
 
597
+ engine_label = "🧬 克隆引擎" if is_clone_target else "📚 标准引擎"
598
+ render_monitor(agent, f"English Drafting ({current_step+1}/{total_steps})", display_name, f"目标字数: {adjusted_target} ({engine_label})")
599
+ en_ph.info(f"🔄 正在生成高质量章节 (目标: {adjusted_target} 词 | {engine_label})...")
 
 
 
 
 
 
600
 
601
+ # --- API 生成并附加异常熔断逻辑 ---
 
 
 
 
 
 
 
 
602
  try:
603
+ en_draft_stream = en_ph.write_stream(agent.stream_deepseek(sys_prompt_m3, base_user, max_tokens=6000))
604
+ en_draft_clean = re.sub(r'<think>.*?</think>', '', en_draft_stream, flags=re.DOTALL).strip()
605
+ en_resolved = agent.resolve_citations(en_draft_clean, quarantine=is_quarantined)
606
+ en_ph.markdown(en_resolved)
607
+ full_draft_en += en_resolved + "\n\n"
608
+ agent._extract_memory_from_text(en_draft_clean, sec_title)
609
+ words_tail = en_draft_clean.split()
610
+ context_tail = " ".join(words_tail[-200:]) if len(words_tail) > 200 else en_draft_clean
611
+
612
+ actual_words = len(en_draft_clean.split())
613
+ accumulated_deviation += (actual_words - adjusted_target)
614
+ st.caption(f"📊 本章节实际字数: {actual_words} (目标: {adjusted_target}), 累计偏差: {accumulated_deviation:+d}")
615
 
616
+ current_step += 1
617
+ pb.progress(min(15 + int((current_step / total_steps) * 80), 99), text=f"🇬🇧 {display_name} 英文完成...")
618
  except Exception as e:
619
+ en_ph.error("🚨 节点网络异常或达到并发限制,生成已自动熔断。")
620
+ st.error(f"异常信息: {e} | 💥 系统已自动保存之前所有的生成快照,您随时可以刷新页面点击【恢复断点续传】继续任务。")
621
+ generation_aborted = True
622
+ break
623
 
624
+ render_monitor(agent, f"Translating ({current_step+1}/{total_steps})", display_name, "翻译中...")
625
+ trans_ph.empty()
626
  try:
627
+ trans_chunk_raw = trans_ph.write_stream(agent.stream_deepseek(f"Translate into {target_lang}. Preserve formatting and [CITE-XX] tags.", f"TEXT:\n{en_draft_clean}", max_tokens=6000))
628
+ trans_chunk_clean = re.sub(r'<think>.*?</think>', '', trans_chunk_raw, flags=re.DOTALL).strip()
629
+ trans_resolved = agent.resolve_citations(trans_chunk_clean, quarantine=is_quarantined)
630
+ trans_ph.markdown(trans_resolved)
631
+ full_draft_translated += trans_resolved + "\n\n"
632
+ current_step += 1
633
+ pb.progress(min(15 + int((current_step / total_steps) * 80), 99), text=f"{target_flag} {display_name} 翻译完成...")
634
  except Exception as e:
635
+ trans_ph.error("🚨 节点网络异常,翻译已熔断。")
636
+ st.error(f"异常信息: {e} | 💥 系统已保存断点,可随时恢复任务。")
637
+ generation_aborted = True
638
+ break
639
+
640
+ # 每成功完成一个 Section 的双语生成,立即持久化状态快照
641
+ state["completed_sections"].append(display_name)
642
+ state["full_draft_en"] = full_draft_en
643
+ state["full_draft_translated"] = full_draft_translated
644
+ state["context_tail"] = context_tail
645
+ state["agent_state"] = agent.export_state()
646
+ with open(CHECKPOINT_FILE, "w", encoding="utf-8") as f:
647
+ json.dump(state, f, ensure_ascii=False)
648
 
649
+ # 仅在正常完整生成所有结构后,进入打包环节并触发无痕垃圾清理
650
  if not generation_aborted and full_draft_en.strip():
651
+ pb.progress(96, text="📦 打包DOCX...")
652
+ full_draft_en = re.sub(r'(?im)^##?\s*(References?|Reference List).*?(\n##?|\Z)', '', full_draft_en, flags=re.DOTALL).strip()
653
+ full_draft_translated = re.sub(r'(?im)^##?\s*(参考文献|References?|Reference List).*?(\n##?|\Z)', '', full_draft_translated, flags=re.DOTALL).strip()
654
 
655
+ total_words_en = len(full_draft_en.split())
656
+ target_words = int(words)
657
+ lower_limit = int(target_words * 0.95); upper_limit = int(target_words * 1.05)
658
+ st.info(f"📊 全文总字数: {total_words_en} (目标区间: {lower_limit} - {upper_limit})")
 
 
 
 
 
 
 
 
 
 
 
659
 
660
+ if total_words_en > upper_limit:
661
+ st.warning(f"⚠️ 全文超出上限 {total_words_en - upper_limit} 词,正在智能精简 Conclusion...")
662
+ conclusion_match = re.search(r'(## Conclusion.*?)(?=\n## |\Z)', full_draft_en, re.DOTALL | re.IGNORECASE)
663
+ if conclusion_match:
664
+ conclusion_text = conclusion_match.group(1)
665
+ compressed_en = agent.call_deepseek("You are an academic editor. Condense the following conclusion by 15-20% while preserving key findings and citations.", conclusion_text, max_tokens=1500)
666
+ if compressed_en:
667
+ full_draft_en = full_draft_en.replace(conclusion_text, compressed_en.strip())
668
+ compressed_trans = agent._translate_text(compressed_en, target_lang, max_tokens=2000)
669
+ if compressed_trans:
670
+ trans_conc_title = {"Simplified Chinese (简体中文)": "结论", "Russian (Русский)": "Заключение", "Kazakh (Қазақша)": "Қорытынды"}.get(target_lang, "Conclusion")
671
+ trans_match = re.search(rf'(## {trans_conc_title}.*?)(?=\n## |\Z)', full_draft_translated, re.DOTALL | re.IGNORECASE)
672
+ if trans_match: full_draft_translated = full_draft_translated.replace(trans_match.group(1), compressed_trans.strip())
673
+ else: full_draft_translated += "\n\n" + compressed_trans.strip()
674
+ total_words_en = len(full_draft_en.split())
675
+ st.success(f"✅ 精简完成,现总字数: {total_words_en}")
676
+ elif total_words_en < lower_limit:
677
+ st.warning(f"⚠️ 全文低于下限 {lower_limit - total_words_en} 词,正在扩展 Conclusion...")
678
+ expanded_en = agent.call_deepseek("Continue the conclusion by adding 100-200 words of academically substantive content (future research/limitations).", full_draft_en[-min(2000, len(full_draft_en)):], max_tokens=800)
679
+ if expanded_en:
680
+ full_draft_en += "\n\n" + expanded_en.strip()
681
+ expanded_trans = agent._translate_text(expanded_en, target_lang, max_tokens=1000)
682
+ if expanded_trans: full_draft_translated += "\n\n" + expanded_trans.strip()
683
+ total_words_en = len(full_draft_en.split())
684
+ st.success(f"✅ 扩展完成,现总字数: {total_words_en}")
685
+
686
+ if len(agent.used_citations) < 80:
687
+ unused = [tag for tag in agent.citation_map if tag not in agent.used_citations]
688
+ for tag in unused[:80 - len(agent.used_citations)]: agent.used_citations.add(tag)
689
+
690
+ used_tags = sorted(list(agent.used_citations))
691
+ raw_refs = [agent.citation_map[tag]['ref'] for tag in used_tags if tag in agent.citation_map]
692
+ unique_refs = sorted(list(set(raw_refs)))
693
+ apa_refs = "\n\n".join([f"- {ref}" for ref in unique_refs])
694
+ full_draft_en += "\n\n# References\n" + apa_refs
695
+ full_draft_translated += "\n\n# 参考文献\n" + apa_refs
696
+
697
+ bj_time = datetime.now(timezone(timedelta(hours=8))).strftime("%Y%m%d_%H%M")
698
+ lang_prefix = {"Simplified Chinese (简体中文)": "ZH", "Russian (Русский)": "RU", "Kazakh (Қазақша)": "KK"}.get(target_lang, "ZH")
699
+ zip_buffer = io.BytesIO()
700
+ with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
701
+ zf.writestr(f"Empirical_Research_EN_{bj_time}.docx", text_to_docx(full_draft_en).getvalue())
702
+ zf.writestr(f"Empirical_Research_Trans_{lang_prefix}_{bj_time}.docx", text_to_docx(full_draft_translated).getvalue())
703
+ zip_buffer.seek(0)
704
+ st.session_state.zip_buffer = zip_buffer.getvalue()
705
+ st.session_state.zip_filename = f"SSCI_Pipeline_Set_{bj_time}.zip"
706
+
707
+ # 任务最终完成!执行自动无痕清理机制 (Garbage Collection)
708
+ if os.path.exists(CHECKPOINT_FILE):
709
+ os.remove(CHECKPOINT_FILE)
710
+
711
+ pb.progress(100, text="✅ 完成")
712
+ render_monitor(agent, "完成", "✅", "文档已生成并已清空本地临时快照空间")
713
+
714
+ except Exception as e:
715
+ st.error(f"🚨 启动/恢复异常: {e}")
716
  finally:
717
+ st.session_state.is_running = False
718
+ st.session_state.start_pipeline = False
719
+ st.session_state.resume_pipeline = False
720
 
721
  if st.session_state.zip_buffer:
722
  st.success(ui["success"])
723
+ st.download_button(label=ui["btn_download"], data=st.session_state.zip_buffer, file_name=st.session_state.zip_filename, mime="application/zip")