Deploy SmartPaper production v1.1 R1Fix clean

#3
HF_COMMIT_SHA.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ af60fe1bdcbab60f47f06586cca03210f7f61026
app.py CHANGED
@@ -3,6 +3,8 @@
3
  from core.meta_extractor import extract_golden_metadata
4
  from core.rag_engine import initialize_rag
5
  from core.qualitative_engine import get_qualitative_engine
 
 
6
  import streamlit as st
7
 
8
  st.set_page_config(
@@ -19,21 +21,14 @@ import os
19
  import streamlit.components.v1 as components
20
  import time
21
  import re
22
- import io
23
- import zipfile
24
  import math
25
  import json
26
  import secrets
27
  from datetime import datetime, timezone, timedelta
28
- import pandas as pd
29
- import numpy as np
30
- import threading
31
  from concurrent.futures import ThreadPoolExecutor
32
- from streamlit.runtime.scriptrunner import add_script_run_ctx, get_script_run_ctx
33
 
34
- from config import APP_CONFIG, AMJ_PROMPTS, ASQ_PROMPTS, GENERIC_PROMPTS
35
- from utils.file_parser import extract_text_from_files, extract_data_from_files
36
- from utils.docx_builder import text_to_docx
37
  from core.pipeline_agent import SSciPipelineAgent
38
  from managed_auth import (
39
  require_alipay_login,
@@ -41,8 +36,9 @@ from managed_auth import (
41
  start_generation_task,
42
  heartbeat_generation_task,
43
  finish_generation_task,
 
44
  )
45
- from i18n import LANGUAGE_OPTIONS, get_amj_csv_template, get_asq_txt_template, get_output_suffix, get_reference_heading, get_target_flag, get_target_language, tr
46
 
47
  os.environ["STREAMLIT_SERVER_ENABLE_CORS"] = "false"
48
  os.environ["STREAMLIT_SERVER_ENABLE_XSRF_PROTECTION"] = "false"
@@ -51,32 +47,19 @@ WORKSPACE_DIR = APP_CONFIG.get("workspace_dir", ".workspace")
51
  os.makedirs(WORKSPACE_DIR, exist_ok=True)
52
  LEGACY_CHECKPOINT_FILE = os.path.join(WORKSPACE_DIR, "snapshot.json")
53
  CHECKPOINT_FILE = LEGACY_CHECKPOINT_FILE
54
- OUTPUT_DIR = "outputs"
55
- os.makedirs(OUTPUT_DIR, exist_ok=True)
56
 
57
  META_SECTIONS_BLACKLIST = set(APP_CONFIG["blacklists"]["meta_sections"])
58
 
59
- # =====================================================================
60
- # [架构升级] 强硬的文件心跳锁机制,彻底解决跨设备/跨进程并发漏洞
61
- # =====================================================================
62
- HEARTBEAT_FILE = os.path.join(WORKSPACE_DIR, "heartbeat.lock")
63
- HEARTBEAT_TIMEOUT = 120 # 2分钟心跳超时
64
 
65
  def update_heartbeat():
66
- """Refresh both the local file lock and the Cloudflare task lease."""
67
  now = time.time()
68
- try:
69
- with open(HEARTBEAT_FILE, "w") as f:
70
- f.write(str(now))
71
- except Exception:
72
- pass
73
-
74
  task = st.session_state.get("sp_generation_task") or {}
75
  task_id = str(task.get("task_id") or "").strip()
 
76
  last_remote = float(st.session_state.get("sp_last_task_heartbeat") or 0.0)
77
- if not task_id or now - last_remote < 25:
78
  return
79
-
80
  try:
81
  heartbeat_generation_task(
82
  task_id,
@@ -84,30 +67,23 @@ def update_heartbeat():
84
  )
85
  st.session_state["sp_last_task_heartbeat"] = now
86
  except Exception:
87
- # A transient heartbeat failure must not interrupt paper generation.
 
88
  pass
89
 
 
90
  def is_system_busy():
91
- if not os.path.exists(HEARTBEAT_FILE):
92
- return False
93
- try:
94
- with open(HEARTBEAT_FILE, "r") as f:
95
- last_active = float(f.read().strip())
96
- if time.time() - last_active < HEARTBEAT_TIMEOUT:
97
- return True
98
- except:
99
- pass
100
  return False
101
 
 
102
  def clear_heartbeat():
103
- try:
104
- if os.path.exists(HEARTBEAT_FILE):
105
- os.remove(HEARTBEAT_FILE)
106
- except:
107
- pass
108
 
109
  def stream_with_heartbeat(generator):
110
- """包裹器:确保大模型长时间流式输出时,能够持续发送心跳,防止锁被释放"""
111
  last_beat = time.time()
112
  for chunk in generator:
113
  now = time.time()
@@ -115,7 +91,7 @@ def stream_with_heartbeat(generator):
115
  update_heartbeat()
116
  last_beat = now
117
  yield chunk
118
- # =====================================================================
119
 
120
  def clean_trailing_references(text, keywords=("reference", "references", "reference list", "参考文献")):
121
  lines = text.split('\n')
@@ -144,117 +120,6 @@ global_s2_key = _read_runtime_secret("S2_API_KEY")
144
  WECHAT_BLOCKER_JS = """<script>try{var ua=navigator.userAgent.toLowerCase();if(ua.indexOf('micromessenger')!==-1){var parentDoc=window.parent.document;if(!parentDoc.getElementById('wx-blocker')){var overlay=parentDoc.createElement('div');overlay.id='wx-blocker';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;';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>';parentDoc.body.appendChild(overlay);}}}catch(e){}</script>"""
145
  components.html(WECHAT_BLOCKER_JS, height=0, width=0)
146
 
147
- @st.cache_resource
148
- def get_global_lock():
149
- return threading.Lock()
150
-
151
- global_lock = get_global_lock()
152
-
153
- def generate_psd_corr_matrix(n_constructs, max_corr=0.5):
154
- if n_constructs == 1: return np.array([[1.0]])
155
- base_corr = np.random.uniform(0.2, max_corr - 0.1)
156
- A = np.random.uniform(base_corr - 0.1, base_corr + 0.1, (n_constructs, n_constructs))
157
- A = (A + A.T) / 2
158
- np.fill_diagonal(A, 1.0)
159
- min_eig = np.min(np.linalg.eigvals(A))
160
- if min_eig < 0: A -= (min_eig - 0.01) * np.eye(n_constructs)
161
- d = np.sqrt(np.diag(A))
162
- return A / np.outer(d, d)
163
-
164
- def calculate_validity_metrics(df, constructs_info, true_loadings):
165
- metrics = []
166
- for const in constructs_info:
167
- c_name = const['name']
168
- items = const['items']
169
- sub_df = df[items]
170
- var_total = sub_df.sum(axis=1).var(ddof=1)
171
- sum_var = sub_df.var(ddof=1).sum()
172
- n_items = len(items)
173
- alpha = 0.0 if var_total <= 0 else (n_items / (n_items - 1)) * (1 - sum_var / var_total)
174
- c_loadings = np.array(true_loadings[c_name])
175
- sum_loadings_sq = np.sum(c_loadings)**2
176
- sum_sq_loadings = np.sum(c_loadings**2)
177
- sum_error = np.sum(1 - c_loadings**2)
178
- cr = sum_loadings_sq / (sum_loadings_sq + sum_error)
179
- ave = sum_sq_loadings / len(c_loadings)
180
- metrics.append({"Construct": c_name, "Items": n_items, "Cronbach's α": round(alpha, 3), "CR": round(cr, 3), "AVE": round(ave, 3)})
181
- return pd.DataFrame(metrics)
182
-
183
- def generate_empirical_data_robust(construct_names=None, n=300, scale_points=7):
184
- if not construct_names or len(construct_names) < 2:
185
- construct_names = ["Independent_Var", "Mediator_Var", "Dependent_Var"]
186
-
187
- n_constructs = len(construct_names)
188
- corr = generate_psd_corr_matrix(n_constructs, max_corr=0.55)
189
- latent_scores = np.random.multivariate_normal(mean=[0]*n_constructs, cov=corr, size=n)
190
- data, true_loadings, constructs_info = {}, {}, []
191
- target_mean = scale_points / 2.0 + 0.5
192
- target_std = scale_points / 5.0
193
-
194
- for idx, c_name in enumerate(construct_names):
195
- true_score = latent_scores[:, idx]
196
- n_items = np.random.randint(3, 6)
197
- loadings = np.random.uniform(0.72, 0.88, n_items)
198
- true_loadings[c_name] = loadings
199
- item_names = []
200
- for i, load in enumerate(loadings):
201
- error = np.random.normal(0, np.sqrt(1 - load**2), n)
202
- item_score = load * true_score + error
203
- raw_scaled = item_score * target_std + target_mean
204
- item_int = np.clip(np.round(raw_scaled), 1, scale_points).astype(int)
205
- col_name = f"{c_name}_{i+1}"
206
- data[col_name] = item_int
207
- item_names.append(col_name)
208
- constructs_info.append({"name": c_name, "items": item_names})
209
-
210
- df = pd.DataFrame(data)
211
- validity_df = calculate_validity_metrics(df, constructs_info, true_loadings)
212
- mean_scores = pd.DataFrame({c['name']: df[c['items']].mean(axis=1) for c in constructs_info})
213
- fl_matrix = mean_scores.corr().astype(object)
214
-
215
- for i, row in validity_df.iterrows():
216
- c_name = row['Construct']
217
- sqrt_ave = np.sqrt(row['AVE'])
218
- fl_matrix.loc[c_name, c_name] = f"({sqrt_ave:.3f})"
219
-
220
- for i in range(len(fl_matrix.columns)):
221
- for j in range(i + 1, len(fl_matrix.columns)):
222
- fl_matrix.iloc[i, j] = ""
223
-
224
- desc_stats = mean_scores.mean().to_frame('Mean').join(mean_scores.std().to_frame('SD'))
225
- return df, validity_df, desc_stats.join(fl_matrix)
226
-
227
- def resolve_empirical_data(uploaded_files=None, language="zh"):
228
- if uploaded_files:
229
- user_md = extract_data_from_files(uploaded_files)
230
- if user_md:
231
- return None, user_md, tr(language, "actual_stats_parsed")
232
-
233
- df, validity_df, fl_matrix_df = generate_empirical_data_robust()
234
- simulated_md_prompt = f"""### Table 1: Reliability and Convergent Validity\n{validity_df.to_markdown(index=False)}\n### Table 2: Descriptive Statistics and Discriminant Validity (Fornell-Larcker Criterion)\n*Note: Diagonal elements in parentheses are the square roots of AVE.*\n{fl_matrix_df.to_markdown()}\n[STRICT EMPIRICAL INSTRUCTION]\n1. ZERO HALLUCINATION: You MUST report the exact metrics (Cronbach's α, CR, AVE, correlations) provided in the tables above.\n2. VALIDITY DEFENSE: Explicitly state that Convergent Validity is established because all AVEs > 0.5 and CRs > 0.7.\n3. DISCRIMINANT DEFENSE: Explicitly state that Discriminant Validity is satisfied per the Fornell-Larcker criterion, as the square root of AVE (diagonal values) exceed inter-construct correlations."""
235
- status_msg = tr(language, "synthetic_stats_generated")
236
- return df, simulated_md_prompt, status_msg
237
-
238
- def _translation_system_prompt(target_lang):
239
- common = """Translate the supplied academic manuscript faithfully. Preserve every citation tag, number, percentage, coefficient, p-value, confidence interval, DOI, table structure, heading level, hypothesis label, variable abbreviation, and conclusion direction. Do not add, remove, strengthen, or weaken empirical claims. Use concise, accurate, and rigorous academic language. Do not use metaphors, analogies, rhetorical questions, promotional language, formulaic transitions, or mechanical repetition. Output only the translated manuscript."""
240
- if target_lang == "Russian (Русский)":
241
- return common + " Ensure idiomatic academic Russian, correct case government, gender-number agreement, verb aspect, punctuation, and terminology. Reorganize English syntax where necessary and avoid literal English calques. Keep journal titles, DOIs, and reference entries in their original form."
242
- if target_lang == "Kazakh (Қазақша)":
243
- return common + " Ensure idiomatic academic Kazakh, correct case and possessive suffixes, person and plural endings, vowel harmony, consonant assimilation, and natural Kazakh word order. Avoid Russian syntactic calques and unnecessary Russian loan forms. Use Kazakh-specific letters correctly: ә, ғ, қ, ң, ө, ұ, ү, һ, і. Keep journal titles, DOIs, and reference entries in their original form."
244
- return common + " Use formal academic Chinese with clear concepts, explicit logic, and natural sentence structure."
245
-
246
-
247
- def async_translate_task(agent, en_text, placeholder, target_lang, quarantine_flag, interface_lang):
248
- try:
249
- sys_p = _translation_system_prompt(target_lang)
250
- trans_text = agent.call_deepseek(sys_p, f"TEXT:\n{en_text}", max_tokens=6000, model_name="deepseek-chat")
251
- trans_resolved = agent.resolve_citations(trans_text, quarantine=quarantine_flag)
252
- placeholder.markdown(trans_resolved)
253
- return trans_resolved
254
- except Exception:
255
- placeholder.error(tr(interface_lang, "translation_error"))
256
- return ""
257
-
258
 
259
  def inject_file_uploader_i18n(language: str) -> None:
260
  """Localize Streamlit uploaders without modifying their React structure."""
@@ -471,6 +336,9 @@ if "sp_generation_task" not in st.session_state: st.session_state.sp_generation_
471
  if "sp_generation_idempotency_key" not in st.session_state: st.session_state.sp_generation_idempotency_key = ""
472
  if "sp_last_task_heartbeat" not in st.session_state: st.session_state.sp_last_task_heartbeat = 0.0
473
  if "sp_current_node" not in st.session_state: st.session_state.sp_current_node = ""
 
 
 
474
 
475
 
476
  def _safe_user_workspace_key(value):
@@ -511,6 +379,9 @@ def _prepare_new_generation_request() -> None:
511
  st.session_state["sp_current_node"] = "task_start"
512
  st.session_state["zip_buffer"] = None
513
  st.session_state["zip_filename"] = ""
 
 
 
514
 
515
 
516
  def _begin_managed_generation_task(*, is_resume: bool, loaded_state: dict | None, mode: str, language: str):
@@ -539,55 +410,200 @@ def _begin_managed_generation_task(*, is_resume: bool, loaded_state: dict | None
539
  return task, idempotency_key
540
 
541
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
542
  def _finish_managed_generation_task(
543
  status: str,
544
  *,
545
  safe_error_code: str = "",
546
  safe_error_detail: str = "",
547
  current_node: str = "",
 
 
548
  ):
549
  task = st.session_state.get("sp_generation_task") or {}
550
  task_id = str(task.get("task_id") or "").strip()
551
  if not task_id:
552
  return None
553
-
554
- last_error = None
555
- for attempt in range(3):
556
- try:
557
- response = finish_generation_task(
558
- task_id,
559
- status=status,
560
- safe_error_code=safe_error_code[:80],
561
- safe_error_detail=safe_error_detail[:800],
562
- current_node=current_node[:240],
563
- )
564
- updated_task = dict(response.get("task") or task)
565
- if status == "resumable":
566
- st.session_state["sp_generation_task"] = updated_task
567
- else:
568
- st.session_state["sp_generation_task"] = None
569
- st.session_state["sp_generation_idempotency_key"] = ""
570
- st.session_state["sp_last_task_heartbeat"] = 0.0
571
- return response
572
- except Exception as exc:
573
- last_error = exc
574
- if attempt < 2:
575
- time.sleep(0.6 * (attempt + 1))
576
-
577
- raise RuntimeError(f"task_finish_failed: {last_error}")
578
 
579
 
580
  def _quota_start_error_message(language: str, error: Exception) -> str:
581
  raw = str(error or "")
582
- if "payment_required" not in raw:
583
- return raw
584
- messages = {
585
- "zh": "今日免费额度和长期额度均已用完。支付功能将在下一阶段接入。",
586
- "en": "Your daily free quota and lifetime quota are exhausted. Payment will be connected in the next stage.",
587
- "ru": "Дневная бесплатная и долгосрочная квоты исчерпаны. Оплата будет подключена на следующем этапе.",
588
- "kk": "Күнделікті тегін және ұзақ мерзімді квота таусылды. Төлем келесі кезеңде қосылады.",
589
- }
590
- return messages.get(language, messages["zh"])
591
 
592
  inject_wechat_browser_notice()
593
 
@@ -617,6 +633,8 @@ _user_workspace = os.path.join(
617
  )
618
  os.makedirs(_user_workspace, exist_ok=True)
619
  CHECKPOINT_FILE = os.path.join(_user_workspace, "snapshot.json")
 
 
620
 
621
 
622
  @_fragment
@@ -694,14 +712,16 @@ def render_workspace_controls(user):
694
  safe_error_code="E_USER_CLEARED_CHECKPOINT",
695
  safe_error_detail="User cleared the recoverable checkpoint.",
696
  current_node="checkpoint_clear",
 
697
  )
698
  except Exception as exc:
699
  st.error(str(exc))
700
  return
701
- try:
702
- os.remove(CHECKPOINT_FILE)
703
- except OSError:
704
- pass
 
705
  clear_heartbeat()
706
  st.rerun()
707
  st.markdown("---")
@@ -830,6 +850,15 @@ uploaded_interviews = st.session_state.get("up_asq_data")
830
  if st.session_state.get("sp_ui_blocked"):
831
  st.stop()
832
 
 
 
 
 
 
 
 
 
 
833
  progress_bar = st.empty()
834
  monitor_ui = st.empty()
835
 
@@ -886,12 +915,16 @@ if st.session_state.start_pipeline or st.session_state.resume_pipeline:
886
  st.session_state.start_pipeline = False
887
  st.session_state.resume_pipeline = False
888
  st.error(_quota_start_error_message(lang_code, exc))
 
 
 
889
  st.stop()
890
 
891
  st.session_state.is_running = True
892
  agent = SSciPipelineAgent(global_ds_key, global_s2_key)
893
- SAFE_CHUNK_WORDS = agent.chunk_words_cyrillic if target_lang in ["Russian (Русский)", "Kazakh (Қазақша)"] else agent.chunk_words_default
894
  qual_engine = get_qualitative_engine(agent)
 
895
  completion_finalization_attempted = False
896
 
897
  try:
@@ -907,6 +940,7 @@ if st.session_state.start_pipeline or st.session_state.resume_pipeline:
907
  target_lang = state["target_lang"]
908
  words = state["words"]
909
  user_data_md = state["user_data_md"]
 
910
  user_lit_context = state["user_lit_context"]
911
 
912
  agent.load_state(state["agent_state"])
@@ -931,6 +965,7 @@ if st.session_state.start_pipeline or st.session_state.resume_pipeline:
931
  pb = progress_bar.progress(1, text=tr(lang_code, "pipeline_start"))
932
  user_lit_context = ""
933
  user_data_md = ""
 
934
 
935
  if st.session_state.run_mode == "amj":
936
  if uploaded_papers_amj:
@@ -953,7 +988,7 @@ if st.session_state.start_pipeline or st.session_state.resume_pipeline:
953
 
954
  with st.spinner(tr(lang_code, "extract_quant")):
955
  update_heartbeat()
956
- _, user_data_md, msg = resolve_empirical_data(uploaded_data_amj, lang_code)
957
  st.info(msg)
958
 
959
  elif st.session_state.run_mode == "asq":
@@ -977,12 +1012,12 @@ if st.session_state.start_pipeline or st.session_state.resume_pipeline:
977
 
978
  update_heartbeat()
979
  raw_interviews = extract_text_from_files([uploaded_interviews]) if uploaded_interviews else ""
980
- user_data_md, msg = qual_engine.resolve_qualitative_data(topic, keywords, raw_interviews)
981
- st.info(tr(lang_code, "qualitative_ready"))
982
 
983
  state = {
984
  "topic": topic, "keywords": keywords, "target_lang": target_lang, "words": words,
985
- "user_data_md": user_data_md, "user_lit_context": user_lit_context,
986
  "completed_sections": [], "full_draft_en": "", "full_draft_translated": "",
987
  "context_tail": "", "run_mode": st.session_state.run_mode, "interface_language": lang_code,
988
  "managed_task_idempotency_key": managed_task_key,
@@ -1042,7 +1077,7 @@ if st.session_state.start_pipeline or st.session_state.resume_pipeline:
1042
  pb.progress(14, text=tr(lang_code, "anchor_progress"))
1043
  with st.status(tr(lang_code, "anchor_status"), expanded=True) as status:
1044
  ph = st.empty()
1045
- anchor_response = ph.write_stream(stream_with_heartbeat(agent.stream_generate_anchors(topic, keywords, synthesis_result, refs_text)))
1046
  status.update(label=tr(lang_code, "anchor_done"), state="complete", expanded=False)
1047
 
1048
  # ==========================================================
@@ -1054,16 +1089,18 @@ if st.session_state.start_pipeline or st.session_state.resume_pipeline:
1054
  conclusion_match = re.search(r'-+\s*CONCLUSION\s*-+\s*(.*?)$', anchor_response_clean, re.DOTALL | re.IGNORECASE)
1055
  abstract_anchor = abstract_match.group(1).strip() if abstract_match else anchor_response_clean.strip()
1056
  conclusion_anchor = conclusion_match.group(1).strip() if conclusion_match else ""
 
 
1057
  # ==========================================================
1058
 
1059
  pb.progress(15, text=tr(lang_code, "outline_progress"))
1060
  update_heartbeat()
1061
- plan = agent.generate_outline(topic, keywords, mode=st.session_state.run_mode)
1062
  en_topic = str(plan.get("en_topic", topic))
1063
  structure = plan.get("structure", [])
1064
 
1065
  en_abstract_clean = clean_markdown_headings(re.sub(r'(?i)^abstract[:\s]*', '', str(abstract_anchor)).strip())
1066
- trans_abstract_clean = clean_markdown_headings(re.sub(r'(?i)^(摘要|abstract|аннотация|түйіндеме)[:\s]*', '', agent.call_deepseek(_translation_system_prompt(target_lang), abstract_anchor, max_tokens=3000)).strip())
1067
 
1068
  loc_abs = {"Simplified Chinese (简体中文)": "摘要", "Russian (Русский)": "Аннотация", "Kazakh (Қазақша)": "Түйіндеме"}.get(target_lang, "Abstract")
1069
  loc_kw = {"Simplified Chinese (简体中文)": "关键词", "Russian (Русский)": "Ключевые слова", "Kazakh (Қазақша)": "Кілт сөздер"}.get(target_lang, "Keywords")
@@ -1071,21 +1108,10 @@ if st.session_state.start_pipeline or st.session_state.resume_pipeline:
1071
  full_draft_en = f"# {en_topic}\n\n## Abstract\n{en_abstract_clean}\n\n## Keywords\n{keywords}\n\n"
1072
  full_draft_translated = f"# {topic}\n\n## {loc_abs}\n{trans_abstract_clean}\n\n## {loc_kw}\n{keywords}\n\n"
1073
 
1074
- adaptive_structure = []
1075
- for sec in structure:
1076
- sec_name = sec.get("section", "Section")
1077
- sec_name = re.sub(r'^[\d\.\s一二三四五六七八九十、]+', '', sec_name).strip()
1078
-
1079
- is_method_or_result = sec.get("is_method_or_result", False)
1080
- target_sec_words = int(words * sec.get("weight", 0.1))
1081
-
1082
- if target_sec_words > SAFE_CHUNK_WORDS:
1083
- num_chunks = max(2, math.ceil(target_sec_words / SAFE_CHUNK_WORDS))
1084
- for i in range(num_chunks):
1085
- 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})
1086
- else:
1087
- 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})
1088
-
1089
  with st.spinner(tr(lang_code, "jit_status")):
1090
  update_heartbeat()
1091
  jit_texts = agent.prefetch_jit_literature(topic, keywords, adaptive_structure)
@@ -1112,7 +1138,7 @@ if st.session_state.start_pipeline or st.session_state.resume_pipeline:
1112
  st.markdown("---")
1113
  ui_blocks = []
1114
  target_flag = FLAG_MAP.get(target_lang, "🌐")
1115
-
1116
  for sec in adaptive_structure:
1117
  display_name = sec["display_name"]
1118
  st.markdown(f"### ⚙️ {display_name}")
@@ -1123,360 +1149,220 @@ if st.session_state.start_pipeline or st.session_state.resume_pipeline:
1123
  with col_trans:
1124
  st.markdown(tr(lang_code, "target_draft", flag=target_flag))
1125
  trans_ph = st.empty()
1126
-
1127
- if display_name in completed_sections:
1128
  en_ph.success(tr(lang_code, "restored"))
1129
- trans_ph.success(tr(lang_code, "restored"))
1130
- else:
1131
  en_ph.info(tr(lang_code, "waiting"))
1132
  trans_ph.info(tr(lang_code, "translation_waiting"))
1133
-
1134
  ui_blocks.append({"sec": sec, "en_ph": en_ph, "trans_ph": trans_ph})
1135
 
1136
  generation_aborted = False
1137
  generation_failure_code = ""
1138
  generation_failure_detail = ""
1139
  accumulated_deviation = 0
1140
- remaining_sections = len(adaptive_structure)
1141
  all_cite_keys = list(agent.citation_map.keys())
1142
- valid_chunks = [s for s in adaptive_structure if not any(kw in s["section"].lower() for kw in ["abstract", "keyword", "摘要", "关键字", "appendix", "附录"])]
1143
  allocation_map = {}
1144
  current_cite_idx = 0
1145
-
1146
- for idx, vc in enumerate(valid_chunks):
1147
- if idx == len(valid_chunks) - 1:
1148
- allocation_map[vc["display_name"]] = all_cite_keys[current_cite_idx:]
1149
- else:
1150
- num_cites = max(1, math.ceil(len(all_cite_keys) / len(valid_chunks)))
1151
- allocation_map[vc["display_name"]] = all_cite_keys[current_cite_idx : current_cite_idx + num_cites]
1152
- current_cite_idx += num_cites
1153
 
1154
- translation_executor = ThreadPoolExecutor(max_workers=3)
1155
- previous_task = None
1156
-
1157
- for i, block in enumerate(ui_blocks):
1158
- if generation_aborted:
1159
  break
1160
-
1161
  sec = block["sec"]
1162
  en_ph = block["en_ph"]
1163
  trans_ph = block["trans_ph"]
1164
  sec_title = sec["section"]
1165
  display_name = sec["display_name"]
1166
-
1167
- if display_name in completed_sections:
1168
- remaining_sections -= 1
1169
- continue
1170
-
1171
- if previous_task:
1172
- with st.spinner(tr(lang_code, "sync_previous", name=previous_task["name"])):
1173
- try:
1174
- while not previous_task["future"].done():
1175
- update_heartbeat()
1176
- time.sleep(1)
1177
- trans_text = previous_task["future"].result()
1178
- if trans_text:
1179
- full_draft_translated += trans_text + "\n\n"
1180
- except Exception as e:
1181
- pass
1182
-
1183
- full_draft_en += previous_task["en_text"] + "\n\n"
1184
- state["completed_sections"].append(previous_task["name"])
1185
- state["full_draft_translated"] = full_draft_translated
1186
- state["full_draft_en"] = full_draft_en
1187
- state["context_tail"] = previous_task["context_tail"]
1188
- state["agent_state"] = agent.export_state()
1189
-
1190
- os.makedirs(WORKSPACE_DIR, exist_ok=True)
1191
- with open(CHECKPOINT_FILE, "w", encoding="utf-8") as f:
1192
- json.dump(state, f, ensure_ascii=False)
1193
- previous_task = None
1194
-
1195
- remaining_sections -= 1
1196
- adjustment = -accumulated_deviation / remaining_sections if remaining_sections > 0 else 0
1197
- adjusted_target = min(int(SAFE_CHUNK_WORDS * 1.5), max(50, int(sec['target_words'] + adjustment)))
1198
-
1199
- is_quarantined = any(kw in sec_title.lower() for kw in ["abstract", "keyword", "摘要", "关键字", "关键词", "conclusion", "结论", "заключение", "қорытынды", "references", "appendix", "附录"])
1200
-
1201
- if is_quarantined:
1202
- quarantine_rule = "【CRITICAL】: NO IN-TEXT CITATIONS ALLOWED IN THIS SECTION."
1203
  else:
1204
- assigned_tags_str = ", ".join(allocation_map.get(display_name, [])) or "any relevant citations"
1205
- quarantine_rule = f"【CITATION GUIDANCE】: You are strongly encouraged to integrate citations: {assigned_tags_str}. <AVAILABLE_CITATIONS>{refs_text}</AVAILABLE_CITATIONS>"
1206
-
1207
- heading_instruction = f"Write the content for the section '{sec_title}'. DO NOT output the main heading '# {sec_title}' itself."
1208
- memory_block = f"\n【GLOBAL PAPER MEMORY】\n{agent._build_memory_context(sec_title, 2500)}\n" if agent._build_memory_context(sec_title, 2500) else ""
1209
-
1210
- if hasattr(st.session_state, "rag_engine") and st.session_state.rag_engine is not None:
1211
- context_snippets = st.session_state.rag_engine.get_relevant_context(sec_title)
1212
- if context_snippets:
1213
- memory_block += f"\n【TARGETED RAG RESULTS (Includes JIT)】\n{context_snippets}\n"
1214
-
1215
- lang_hint = "ru" if "Русский" in target_lang else ("kk" if "Қазақша" in target_lang else "en")
1216
- num_paragraphs = agent._calc_paragraph_count(adjusted_target, lang_hint)
1217
-
1218
- if "introduction" in sec_title.lower() or "conclusion" in sec_title.lower():
1219
- adaptive_heading_rule = "[HEADING MANDATE] DO NOT use any subheadings (no ## or ###) in this section. Write in continuous, well-structured paragraphs."
1220
- else:
1221
- adaptive_heading_rule = f"[HEADING MANDATE] Use Level 2 (##) and Level 3 (###) subheadings to structure the text." if adjusted_target > 800 else (f"[HEADING MANDATE] Use Level 2 (##) subheadings." if adjusted_target > 400 else "")
1222
-
1223
- adaptive_heading_rule += " STRICT GLOBAL RULE: NEVER use Level 4 (####) headings anywhere in the paper. DO NOT prepend numbers (e.g., 1., 2.1, 一、) to your subheadings."
1224
-
1225
- table_rule = "[CRITICAL DATA FORMATTING] You MUST present ALL statistical data using STANDARD MARKDOWN TABLES. IMPORTANT: Table titles/captions MUST be normal plain text (e.g., 'Table 1: Data Summary'), DO NOT use heading syntax (like ## or ###) for table titles." if sec["is_method_or_result"] and st.session_state.run_mode == "amj" else ""
1226
-
1227
- length_directive = f"[LANGUAGE MANDATE] Output strictly in ENGLISH.\n[LENGTH MANDATE - STRICT] Write between {int(adjusted_target * 0.9)} and {int(adjusted_target * 1.1)} words (approx {num_paragraphs} paragraphs).\n{adaptive_heading_rule}\n{table_rule}\n[FINAL CHECK] Do not output a word count statement."
1228
-
1229
- extra_directives = []
1230
- lower_sec = sec_title.lower()
1231
- golden_meta = agent.global_memory.get("core_golden_metadata", [])
1232
-
1233
- if golden_meta:
1234
- if any(kw in lower_sec for kw in ["introduction", "background"]):
1235
- extra_directives.append("【TARGETED DIRECTIVE】 Locate research gaps from [TARGETED INJECTION].")
1236
- elif any(kw in lower_sec for kw in ["literature", "theor", "hypothesis", "review", "framing"]):
1237
- extra_directives.append("【TARGETED DIRECTIVE】 Base conceptual framing on theories from [TARGETED INJECTION].")
1238
- elif any(kw in lower_sec for kw in ["method", "measure", "data", "sample", "setting"]):
1239
- extra_directives.append("【TARGETED DIRECTIVE】 Adapt analytical design from [TARGETED INJECTION].")
1240
- elif any(kw in lower_sec for kw in ["result", "analysis", "finding"]):
1241
- extra_directives.append("【ZERO HALLUCINATION】 Translate <USER_ACTUAL_DATA> strictly.")
1242
- elif any(kw in lower_sec for kw in ["discussion", "conclusion", "implication"]):
1243
- extra_directives.append("【TARGETED DIRECTIVE】 Compare findings with [TARGETED INJECTION].")
1244
- else:
1245
- extra_directives.append(APP_CONFIG.get("deep_refinement_directive", ""))
1246
- else:
1247
- if sec["is_method_or_result"]:
1248
- extra_directives.append(APP_CONFIG.get("methodological_defense_directive", ""))
1249
- extra_directives.append(APP_CONFIG.get("empirical_data_logic", ""))
1250
- if any(kw in lower_sec for kw in ["theory", "theoretical", "hypothesis", "introduction", "framing"]):
1251
- extra_directives.append(APP_CONFIG.get("deep_synthesis_directive", ""))
1252
- extra_directives.append(APP_CONFIG.get("hypothesis_development_directive", ""))
1253
- if "discussion" in lower_sec:
1254
- extra_directives.append(APP_CONFIG.get("discussion_depth_directive", ""))
1255
- if "conclusion" in lower_sec:
1256
- extra_directives.append(APP_CONFIG.get("conclusion_anchor_directive", ""))
1257
-
1258
- theory_injection = "\n\n".join(extra_directives)
1259
- sys_prompt_m3 = APP_CONFIG.get("sys_academic_writer", "") + "\n" + APP_CONFIG.get("deep_refinement_directive", "")
1260
- 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 ""
1261
-
1262
- action_verb = f"Generate the main section '{sec_title}'" if sec["is_first"] else f"Continue generating the subsequent paragraphs for the section '{sec_title}' (DO NOT restate the main section title)"
1263
-
1264
- done_list = ", ".join(completed_sections)
1265
- anti_repeat = f"\n[ANTI-REPETITION MANDATE] You have already generated: [{done_list}]. Do NOT restate core arguments, research gaps, or background introductions belonging to those completed sections." if done_list else ""
1266
-
1267
- if "appendix" in sec_title.lower() and user_data_md:
1268
- base_user = f"Present the following qualitative data structure as a formal Appendix for the paper '{topic}'.\n\nDATA TABLE:\n{user_data_md}\n\nInstruction: Introduce the table with one short paragraph explaining the coding process, then output the exact table. DO NOT write a long analytical section.\n{heading_instruction}\n[FINAL CHECK] Output strictly in ENGLISH."
1269
- else:
1270
- base_user = f"{action_verb}.\n{length_directive}\n{quarantine_rule}\n{heading_instruction}\n{theory_injection}\n{anti_repeat}\n{memory_block}\nSynthesis: {synthesis_result}\n{wiz_data_injection}\nPrevious Tail: ...{context_tail}...\nIMPORTANT: Output final text."
1271
-
1272
- if agent._count_tokens(sys_prompt_m3) + agent._count_tokens(base_user) > 60000:
1273
- base_user = agent._truncate_context(base_user, 60000 - agent._count_tokens(sys_prompt_m3))
1274
-
1275
- engine_label = tr(lang_code, "engine_amj") if st.session_state.run_mode == "amj" else tr(lang_code, "engine_asq")
1276
- render_monitor(agent, tr(lang_code, "drafting_phase", current=current_step+1, total=total_steps), display_name, tr(lang_code, "target_words", words=adjusted_target, engine=engine_label))
1277
- en_ph.empty()
1278
-
1279
  try:
1280
  with st.status(tr(lang_code, "draft_status", name=display_name), expanded=True) as status:
1281
  update_heartbeat()
1282
- st.write(tr(lang_code, "api_requested"))
1283
-
1284
- target_model = "deepseek-v4-flash"
1285
- st.write(tr(lang_code, "model_writing", model=target_model))
1286
-
1287
- en_draft_clean = re.sub(r'<think>.*?</think>', '', en_ph.write_stream(
1288
- stream_with_heartbeat(agent.stream_deepseek(sys_prompt_m3, base_user, max_tokens=6000, model_name=target_model, thinking=False))
1289
- ), flags=re.DOTALL).strip()
1290
-
1291
  status.update(label=tr(lang_code, "draft_done", name=display_name), state="complete", expanded=False)
1292
-
1293
- update_heartbeat()
1294
- # 1. 移除多余的章节总标题和分块标记
1295
- en_draft_clean = re.sub(rf'(?im)^#\s*{re.escape(sec_title)}\s*\n+', '', en_draft_clean).strip()
1296
- en_draft_clean = re.sub(r'(?im)^#+\s*.*\(Part\s*\d+/\d+\).*?\n+', '', en_draft_clean)
1297
-
1298
- # 2. 【保留三级标题】保留 ## 和 ###,仅仅去除它们自带的数字编号 (如 "### 3.1.1 样本" 变成 "### 样本")
1299
- en_draft_clean = re.sub(r'(?m)^(#{2,3})\s+[\d\.\s一二三四五六七八九十、]+\s*(.*)', r'\1 \2', en_draft_clean)
1300
-
1301
- # ==========================================================
1302
- # 3. 【彻底拦截四级标题】精准匹配 4 个或更多的 #,将其强行转为加粗纯文本
1303
- # ==========================================================
1304
- en_draft_clean = re.sub(r'(?m)^#{4,}\s+(.*)', r'\n**\1**\n', en_draft_clean)
1305
- # ==========================================================
1306
-
1307
- if "introduction" in sec_title.lower():
1308
- en_draft_clean = re.sub(r'(?m)^###?\s+(.*)', r'**\1**', en_draft_clean)
1309
-
1310
- en_draft_clean = re.sub(r'(?m)^###?\s+(\**Table\s+\d+.*)', r'\1', en_draft_clean, flags=re.IGNORECASE)
1311
-
1312
- if sec["is_first"]:
1313
- en_draft_clean = f"# {sec_title}\n\n{en_draft_clean}"
1314
-
1315
  if st.session_state.run_mode == "asq" and user_data_md and "appendix" not in sec_title.lower():
1316
  quote_dict = {tag: quote for quote, tag in re.findall(r'"([^"]+)"\s*\[(QUOTE-\d+)\]', user_data_md)}
1317
  for tag, quote in quote_dict.items():
1318
- en_draft_clean = en_draft_clean.replace(tag, f'"{quote}"')
1319
-
1320
- en_resolved = agent.resolve_citations(en_draft_clean, quarantine=is_quarantined)
 
 
 
 
 
 
1321
  en_ph.markdown(en_resolved)
1322
-
1323
- agent._extract_memory_from_text(en_draft_clean, sec_title)
1324
- words_tail = en_draft_clean.split()
1325
- context_tail = " ".join(words_tail[-200:]) if len(words_tail) > 200 else en_draft_clean
1326
-
1327
- actual_words = len(en_draft_clean.split())
1328
- accumulated_deviation += (actual_words - adjusted_target)
1329
- st.caption(tr(lang_code, "word_count", actual=actual_words, target=adjusted_target, deviation=accumulated_deviation))
1330
-
1331
  current_step += 1
1332
- pb.progress(min(15 + int((current_step / total_steps) * 80), 95), text=tr(lang_code, "section_complete_progress", name=display_name))
1333
-
1334
- trans_ph.info(tr(lang_code, "translation_running"))
1335
-
1336
- ctx = get_script_run_ctx()
1337
- def thread_wrapper(agent_obj, text, ph, lang, q_flag):
1338
- add_script_run_ctx(threading.current_thread(), ctx)
1339
- return async_translate_task(agent_obj, text, ph, lang, q_flag, lang_code)
1340
-
1341
- future = translation_executor.submit(thread_wrapper, agent, en_draft_clean, trans_ph, target_lang, is_quarantined)
1342
-
1343
- previous_task = {
1344
- "name": display_name,
1345
- "future": future,
1346
- "en_text": en_resolved,
1347
- "context_tail": context_tail
1348
- }
1349
-
1350
- except Exception as e:
1351
  en_ph.error(tr(lang_code, "node_error"))
1352
- st.error(tr(lang_code, "node_error_detail", error=e))
1353
  generation_aborted = True
1354
  generation_failure_code = "E_GENERATION_NODE_FAILED"
1355
- generation_failure_detail = str(e)[:800]
1356
- break
1357
 
1358
  if generation_aborted:
1359
  _finish_managed_generation_task(
1360
- "resumable",
1361
  safe_error_code=generation_failure_code or "E_GENERATION_INTERRUPTED",
1362
- safe_error_detail=generation_failure_detail or "Generation stopped with a recoverable checkpoint.",
1363
  current_node=str(st.session_state.get("sp_current_node") or "generation"),
 
1364
  )
1365
 
1366
- if not generation_aborted and previous_task:
1367
- with st.spinner(tr(lang_code, "sync_last", name=previous_task["name"])):
1368
- try:
1369
- while not previous_task["future"].done():
1370
- update_heartbeat()
1371
- time.sleep(1)
1372
- trans_text = previous_task["future"].result()
1373
- if trans_text:
1374
- full_draft_translated += trans_text + "\n\n"
1375
- except Exception as e:
1376
- pass
1377
-
1378
- full_draft_en += previous_task["en_text"] + "\n\n"
1379
- state["completed_sections"].append(previous_task["name"])
1380
- state["full_draft_translated"] = full_draft_translated
1381
- state["full_draft_en"] = full_draft_en
1382
- state["context_tail"] = previous_task["context_tail"]
1383
- state["agent_state"] = agent.export_state()
1384
-
1385
- with open(CHECKPOINT_FILE, "w", encoding="utf-8") as f:
1386
- json.dump(state, f, ensure_ascii=False)
1387
-
1388
  if not generation_aborted and full_draft_en.strip():
1389
- pb.progress(96, text=tr(lang_code, "pack_docx"))
1390
  update_heartbeat()
1391
- full_draft_en = clean_trailing_references(full_draft_en, ["Reference", "References", "Reference List"])
1392
- full_draft_translated = clean_trailing_references(full_draft_translated, ["参考文献", "Reference", "References", "Reference List"])
1393
-
1394
- total_words_en = len(full_draft_en.split())
1395
- target_words = int(words)
1396
- lower_limit = int(target_words * 0.95)
1397
- upper_limit = int(target_words * 1.05)
1398
- st.info(tr(lang_code, "total_words", actual=total_words_en, lower=lower_limit, upper=upper_limit))
1399
-
1400
- if total_words_en > upper_limit:
1401
- conclusion_match = re.search(r'(#+\s*Conclusion.*?)(?=\n#+ |\Z)', full_draft_en, re.DOTALL | re.IGNORECASE)
1402
- if conclusion_match:
1403
- compressed_en = agent.call_deepseek("Condense the following conclusion by 15-20% preserving citations.", conclusion_match.group(1), max_tokens=1500, model_name="deepseek-chat")
1404
- if compressed_en:
1405
- compressed_en = agent.resolve_citations(compressed_en, quarantine=True)
1406
- full_draft_en = full_draft_en.replace(conclusion_match.group(1), compressed_en.strip())
1407
- compressed_trans = agent._translate_text(compressed_en, target_lang, max_tokens=2000)
1408
- if compressed_trans:
1409
- compressed_trans = agent.resolve_citations(compressed_trans, quarantine=True)
1410
- trans_match = re.search(r'(#+\s*(?:结论|结论和启示|讨论与结论|Заключение|Қорытынды|Conclusion).*?)(?=\n#+ |\Z)', full_draft_translated, re.DOTALL | re.IGNORECASE)
1411
- if trans_match:
1412
- full_draft_translated = full_draft_translated.replace(trans_match.group(1), compressed_trans.strip())
1413
- else:
1414
- full_draft_translated += "\n\n" + compressed_trans.strip()
1415
- elif total_words_en < lower_limit:
1416
- expanded_en = agent.call_deepseek("Continue conclusion by adding 150 words (future research).", full_draft_en[-2000:], max_tokens=800, model_name="deepseek-chat")
1417
- if expanded_en:
1418
- expanded_en = agent.resolve_citations(expanded_en, quarantine=True)
1419
- full_draft_en += "\n\n" + expanded_en.strip()
1420
- expanded_trans = agent._translate_text(expanded_en, target_lang, max_tokens=1000)
1421
- if expanded_trans:
1422
- expanded_trans = agent.resolve_citations(expanded_trans, quarantine=True)
1423
- full_draft_translated += "\n\n" + expanded_trans.strip()
1424
-
1425
- if len(agent.used_citations) < 50:
1426
- for tag in [t for t in agent.citation_map if t not in agent.used_citations][:50 - len(agent.used_citations)]:
1427
- agent.used_citations.add(tag)
1428
-
1429
- apa_refs = "\n\n".join([f"- {ref}" for ref in sorted(list(set([agent.citation_map[t]['ref'] for t in sorted(list(agent.used_citations)) if t in agent.citation_map])))])
1430
- full_draft_en += "\n\n# References\n" + apa_refs
1431
- full_draft_translated += f"\n\n# {get_reference_heading(lang_code)}\n" + apa_refs
1432
 
1433
  bj_time = datetime.now(timezone(timedelta(hours=8))).strftime("%Y%m%d_%H%M")
1434
- zip_buffer = io.BytesIO()
1435
- with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
1436
- zf.writestr(f"Research_EN_{bj_time}.docx", text_to_docx(full_draft_en).getvalue())
1437
- zf.writestr(f"Research_{output_suffix}_{bj_time}.docx", text_to_docx(full_draft_translated).getvalue())
1438
- zip_buffer.seek(0)
1439
- generated_zip_bytes = zip_buffer.getvalue()
1440
- generated_zip_filename = f"Draft_{bj_time}.zip"
1441
-
1442
- # Quota is committed only after both DOCX files and the ZIP are complete.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1443
  completion_finalization_attempted = True
1444
  _finish_managed_generation_task(
1445
  "completed",
1446
- current_node="package_complete",
 
1447
  )
1448
-
1449
- st.session_state.zip_buffer = generated_zip_bytes
1450
- st.session_state.zip_filename = generated_zip_filename
1451
-
1452
- try:
1453
- disk_save_path = os.path.join(OUTPUT_DIR, generated_zip_filename)
1454
- with open(disk_save_path, "wb") as f_out:
1455
- f_out.write(generated_zip_bytes)
1456
-
1457
- saved_files = sorted([os.path.join(OUTPUT_DIR, f) for f in os.listdir(OUTPUT_DIR) if f.endswith('.zip')], key=os.path.getmtime)
1458
- while len(saved_files) > 5:
1459
- os.remove(saved_files.pop(0))
1460
- except Exception:
1461
- pass
1462
-
1463
- try:
1464
- os.remove(CHECKPOINT_FILE)
1465
- except OSError:
1466
- pass
1467
-
1468
  pb.progress(100, text=tr(lang_code, "complete"))
1469
  render_monitor(agent, tr(lang_code, "complete"), "✅", tr(lang_code, "document_generated"))
1470
 
1471
  except Exception as e:
1472
  if not completion_finalization_attempted:
1473
- checkpoint_available = _load_recoverable_checkpoint() is not None
1474
  try:
1475
  _finish_managed_generation_task(
1476
- "resumable" if checkpoint_available else "failed",
1477
- safe_error_code="E_GENERATION_INTERRUPTED" if checkpoint_available else "E_GENERATION_FAILED",
1478
  safe_error_detail=str(e)[:800],
1479
  current_node=str(st.session_state.get("sp_current_node") or "generation"),
 
1480
  )
1481
  except Exception:
1482
  pass
 
3
  from core.meta_extractor import extract_golden_metadata
4
  from core.rag_engine import initialize_rag
5
  from core.qualitative_engine import get_qualitative_engine
6
+ from core.quantitative_engine import get_quantitative_engine
7
+ from core.production_guard import build_validated_package, replace_abstract
8
  import streamlit as st
9
 
10
  st.set_page_config(
 
21
  import streamlit.components.v1 as components
22
  import time
23
  import re
 
 
24
  import math
25
  import json
26
  import secrets
27
  from datetime import datetime, timezone, timedelta
 
 
 
28
  from concurrent.futures import ThreadPoolExecutor
 
29
 
30
+ from config import APP_CONFIG, AMJ_PROMPTS, ASQ_PROMPTS
31
+ from utils.file_parser import extract_text_from_files
 
32
  from core.pipeline_agent import SSciPipelineAgent
33
  from managed_auth import (
34
  require_alipay_login,
 
36
  start_generation_task,
37
  heartbeat_generation_task,
38
  finish_generation_task,
39
+ render_payment_purchase,
40
  )
41
+ from i18n import LANGUAGE_OPTIONS, get_amj_csv_template, get_asq_txt_template, get_output_suffix, get_target_flag, get_target_language, tr
42
 
43
  os.environ["STREAMLIT_SERVER_ENABLE_CORS"] = "false"
44
  os.environ["STREAMLIT_SERVER_ENABLE_XSRF_PROTECTION"] = "false"
 
47
  os.makedirs(WORKSPACE_DIR, exist_ok=True)
48
  LEGACY_CHECKPOINT_FILE = os.path.join(WORKSPACE_DIR, "snapshot.json")
49
  CHECKPOINT_FILE = LEGACY_CHECKPOINT_FILE
 
 
50
 
51
  META_SECTIONS_BLACKLIST = set(APP_CONFIG["blacklists"]["meta_sections"])
52
 
 
 
 
 
 
53
 
54
  def update_heartbeat():
55
+ """Refresh the authoritative Cloudflare/D1 task lease only."""
56
  now = time.time()
 
 
 
 
 
 
57
  task = st.session_state.get("sp_generation_task") or {}
58
  task_id = str(task.get("task_id") or "").strip()
59
+ interval = max(3, int(APP_CONFIG.get("task_heartbeat_interval_seconds", 5)))
60
  last_remote = float(st.session_state.get("sp_last_task_heartbeat") or 0.0)
61
+ if not task_id or now - last_remote < interval:
62
  return
 
63
  try:
64
  heartbeat_generation_task(
65
  task_id,
 
67
  )
68
  st.session_state["sp_last_task_heartbeat"] = now
69
  except Exception:
70
+ # The hidden browser bridge continues heartbeats independently. A single
71
+ # transient UI heartbeat must not interrupt model generation.
72
  pass
73
 
74
+
75
  def is_system_busy():
76
+ """D1 is the sole concurrency authority; no duplicate Hugging Face file lock."""
 
 
 
 
 
 
 
 
77
  return False
78
 
79
+
80
  def clear_heartbeat():
81
+ """Backward-compatible no-op after removal of the duplicate local lock."""
82
+ return None
83
+
 
 
84
 
85
  def stream_with_heartbeat(generator):
86
+ """Refresh the D1 lease while streaming model output."""
87
  last_beat = time.time()
88
  for chunk in generator:
89
  now = time.time()
 
91
  update_heartbeat()
92
  last_beat = now
93
  yield chunk
94
+
95
 
96
  def clean_trailing_references(text, keywords=("reference", "references", "reference list", "参考文献")):
97
  lines = text.split('\n')
 
120
  WECHAT_BLOCKER_JS = """<script>try{var ua=navigator.userAgent.toLowerCase();if(ua.indexOf('micromessenger')!==-1){var parentDoc=window.parent.document;if(!parentDoc.getElementById('wx-blocker')){var overlay=parentDoc.createElement('div');overlay.id='wx-blocker';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;';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>';parentDoc.body.appendChild(overlay);}}}catch(e){}</script>"""
121
  components.html(WECHAT_BLOCKER_JS, height=0, width=0)
122
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
 
124
  def inject_file_uploader_i18n(language: str) -> None:
125
  """Localize Streamlit uploaders without modifying their React structure."""
 
336
  if "sp_generation_idempotency_key" not in st.session_state: st.session_state.sp_generation_idempotency_key = ""
337
  if "sp_last_task_heartbeat" not in st.session_state: st.session_state.sp_last_task_heartbeat = 0.0
338
  if "sp_current_node" not in st.session_state: st.session_state.sp_current_node = ""
339
+ if "sp_pending_task_finalization" not in st.session_state: st.session_state.sp_pending_task_finalization = None
340
+ if "sp_pending_package" not in st.session_state: st.session_state.sp_pending_package = None
341
+ if "sp_terminal_notice" not in st.session_state: st.session_state.sp_terminal_notice = None
342
 
343
 
344
  def _safe_user_workspace_key(value):
 
379
  st.session_state["sp_current_node"] = "task_start"
380
  st.session_state["zip_buffer"] = None
381
  st.session_state["zip_filename"] = ""
382
+ st.session_state["sp_pending_task_finalization"] = None
383
+ st.session_state["sp_pending_package"] = None
384
+ st.session_state["sp_terminal_notice"] = None
385
 
386
 
387
  def _begin_managed_generation_task(*, is_resume: bool, loaded_state: dict | None, mode: str, language: str):
 
410
  return task, idempotency_key
411
 
412
 
413
+ def _task_output_dir(task_id: str) -> str:
414
+ root = globals().get("_user_workspace") or os.path.join(WORKSPACE_DIR, "users", "anonymous")
415
+ path = os.path.join(root, "tasks", _safe_user_workspace_key(task_id), "outputs")
416
+ os.makedirs(path, exist_ok=True)
417
+ return path
418
+
419
+
420
+ def _checkpoint_state_for_update() -> dict:
421
+ if not os.path.isfile(CHECKPOINT_FILE):
422
+ return {}
423
+ try:
424
+ with open(CHECKPOINT_FILE, "r", encoding="utf-8") as handle:
425
+ value = json.load(handle)
426
+ return value if isinstance(value, dict) else {}
427
+ except Exception:
428
+ return {}
429
+
430
+
431
+ def _write_checkpoint_state(value: dict) -> None:
432
+ if not isinstance(value, dict) or not value:
433
+ return
434
+ os.makedirs(os.path.dirname(CHECKPOINT_FILE), exist_ok=True)
435
+ temporary = CHECKPOINT_FILE + ".tmp"
436
+ with open(temporary, "w", encoding="utf-8") as handle:
437
+ json.dump(value, handle, ensure_ascii=False)
438
+ os.replace(temporary, CHECKPOINT_FILE)
439
+
440
+
441
+ def _persist_pending_finalization() -> None:
442
+ pending = st.session_state.get("sp_pending_task_finalization")
443
+ if not isinstance(pending, dict):
444
+ return
445
+ state = _checkpoint_state_for_update()
446
+ if not state:
447
+ return
448
+ state["pending_task_finalization"] = pending
449
+ package = st.session_state.get("sp_pending_package")
450
+ if isinstance(package, dict):
451
+ state["pending_package"] = package
452
+ _write_checkpoint_state(state)
453
+
454
+
455
+ def _restore_pending_finalization() -> None:
456
+ if isinstance(st.session_state.get("sp_pending_task_finalization"), dict):
457
+ return
458
+ state = _checkpoint_state_for_update()
459
+ pending = state.get("pending_task_finalization") if isinstance(state, dict) else None
460
+ if not isinstance(pending, dict):
461
+ return
462
+ st.session_state["sp_pending_task_finalization"] = pending
463
+ package = state.get("pending_package")
464
+ if isinstance(package, dict):
465
+ st.session_state["sp_pending_package"] = package
466
+ task_id = str(pending.get("task_id") or "").strip()
467
+ if task_id and not st.session_state.get("sp_generation_task"):
468
+ st.session_state["sp_generation_task"] = {"task_id": task_id}
469
+
470
+
471
+ def _remove_pending_checkpoint_metadata(*, delete_checkpoint=False, detach_task=False) -> None:
472
+ if delete_checkpoint:
473
+ try:
474
+ os.remove(CHECKPOINT_FILE)
475
+ except OSError:
476
+ pass
477
+ return
478
+ state = _checkpoint_state_for_update()
479
+ if not state:
480
+ return
481
+ state.pop("pending_task_finalization", None)
482
+ state.pop("pending_package", None)
483
+ if detach_task:
484
+ state["managed_task_idempotency_key"] = ""
485
+ state["managed_task_id"] = ""
486
+ _write_checkpoint_state(state)
487
+
488
+
489
+ def _prune_user_output_packages(max_files: int = 5) -> None:
490
+ root = globals().get("_user_workspace")
491
+ if not root or not os.path.isdir(root):
492
+ return
493
+ packages = []
494
+ for current_root, _, names in os.walk(os.path.join(root, "tasks")):
495
+ for name in names:
496
+ if name.endswith(".zip") and not name.startswith(".pending_"):
497
+ path = os.path.join(current_root, name)
498
+ try:
499
+ packages.append((os.path.getmtime(path), path))
500
+ except OSError:
501
+ pass
502
+ packages.sort()
503
+ while len(packages) > max_files:
504
+ _, old_path = packages.pop(0)
505
+ try:
506
+ os.remove(old_path)
507
+ except OSError:
508
+ pass
509
+
510
+
511
+ def _process_pending_task_finalization():
512
+ pending = st.session_state.get("sp_pending_task_finalization")
513
+ if not isinstance(pending, dict):
514
+ return None
515
+ task_id = str(pending.get("task_id") or "").strip()
516
+ status = str(pending.get("status") or "").strip().lower()
517
+ if not task_id or status not in {"completed", "failed", "cancelled", "resumable"}:
518
+ raise RuntimeError("invalid_pending_task_finalization")
519
+
520
+ package_bytes = None
521
+ package = st.session_state.get("sp_pending_package")
522
+ if status == "completed":
523
+ if not isinstance(package, dict):
524
+ raise RuntimeError("validated_package_metadata_missing")
525
+ pending_path = str(package.get("pending_path") or "").strip()
526
+ if not pending_path or not os.path.isfile(pending_path):
527
+ raise RuntimeError("validated_package_file_missing")
528
+ with open(pending_path, "rb") as handle:
529
+ package_bytes = handle.read()
530
+ if not package_bytes:
531
+ raise RuntimeError("validated_package_file_empty")
532
+
533
+ response = finish_generation_task(
534
+ task_id,
535
+ status=status,
536
+ safe_error_code=str(pending.get("safe_error_code") or "")[:80],
537
+ safe_error_detail=str(pending.get("safe_error_detail") or "")[:800],
538
+ current_node=str(pending.get("current_node") or "")[:240],
539
+ )
540
+
541
+ if status == "completed":
542
+ filename = str(package.get("filename") or "SmartPaper.zip")
543
+ pending_path = str(package.get("pending_path") or "")
544
+ final_path = os.path.join(_task_output_dir(task_id), os.path.basename(filename))
545
+ try:
546
+ os.replace(pending_path, final_path)
547
+ except OSError:
548
+ # The bytes were read and validated before quota commit, so the user
549
+ # can still download even if the local archival rename fails.
550
+ final_path = pending_path
551
+ st.session_state["zip_buffer"] = package_bytes
552
+ st.session_state["zip_filename"] = os.path.basename(filename)
553
+ st.session_state["sp_terminal_notice"] = {"kind": "success"}
554
+ _prune_user_output_packages()
555
+ elif status in {"failed", "cancelled"}:
556
+ st.session_state["sp_terminal_notice"] = {
557
+ "kind": "error" if status == "failed" else "warning",
558
+ "message": str(pending.get("safe_error_detail") or "")[:800],
559
+ }
560
+
561
+ _remove_pending_checkpoint_metadata(
562
+ delete_checkpoint=bool(pending.get("delete_checkpoint")),
563
+ detach_task=bool(pending.get("detach_checkpoint")),
564
+ )
565
+ if status == "resumable":
566
+ st.session_state["sp_generation_task"] = dict(response.get("task") or {"task_id": task_id})
567
+ else:
568
+ st.session_state["sp_generation_task"] = None
569
+ st.session_state["sp_generation_idempotency_key"] = ""
570
+ st.session_state["sp_last_task_heartbeat"] = 0.0
571
+ st.session_state["sp_pending_task_finalization"] = None
572
+ st.session_state["sp_pending_package"] = None
573
+ return response
574
+
575
+
576
  def _finish_managed_generation_task(
577
  status: str,
578
  *,
579
  safe_error_code: str = "",
580
  safe_error_detail: str = "",
581
  current_node: str = "",
582
+ delete_checkpoint: bool = False,
583
+ detach_checkpoint: bool = False,
584
  ):
585
  task = st.session_state.get("sp_generation_task") or {}
586
  task_id = str(task.get("task_id") or "").strip()
587
  if not task_id:
588
  return None
589
+ pending = {
590
+ "task_id": task_id,
591
+ "status": str(status or "").strip().lower(),
592
+ "safe_error_code": str(safe_error_code or "")[:80],
593
+ "safe_error_detail": str(safe_error_detail or "")[:800],
594
+ "current_node": str(current_node or "")[:240],
595
+ "delete_checkpoint": bool(delete_checkpoint),
596
+ "detach_checkpoint": bool(detach_checkpoint),
597
+ }
598
+ st.session_state["sp_pending_task_finalization"] = pending
599
+ _persist_pending_finalization()
600
+ return _process_pending_task_finalization()
 
 
 
 
 
 
 
 
 
 
 
 
 
601
 
602
 
603
  def _quota_start_error_message(language: str, error: Exception) -> str:
604
  raw = str(error or "")
605
+ return tr(language, "quota_exhausted") if "payment_required" in raw else raw
606
+
 
 
 
 
 
 
 
607
 
608
  inject_wechat_browser_notice()
609
 
 
633
  )
634
  os.makedirs(_user_workspace, exist_ok=True)
635
  CHECKPOINT_FILE = os.path.join(_user_workspace, "snapshot.json")
636
+ _restore_pending_finalization()
637
+ _process_pending_task_finalization()
638
 
639
 
640
  @_fragment
 
712
  safe_error_code="E_USER_CLEARED_CHECKPOINT",
713
  safe_error_detail="User cleared the recoverable checkpoint.",
714
  current_node="checkpoint_clear",
715
+ delete_checkpoint=True,
716
  )
717
  except Exception as exc:
718
  st.error(str(exc))
719
  return
720
+ else:
721
+ try:
722
+ os.remove(CHECKPOINT_FILE)
723
+ except OSError:
724
+ pass
725
  clear_heartbeat()
726
  st.rerun()
727
  st.markdown("---")
 
850
  if st.session_state.get("sp_ui_blocked"):
851
  st.stop()
852
 
853
+ if st.session_state.get("sp_payment_required") and not (st.session_state.start_pipeline or st.session_state.resume_pipeline):
854
+ render_payment_purchase(lang_code)
855
+
856
+ _terminal_notice = st.session_state.pop("sp_terminal_notice", None)
857
+ if isinstance(_terminal_notice, dict) and _terminal_notice.get("kind") in {"error", "warning"}:
858
+ _message = str(_terminal_notice.get("message") or "").strip()
859
+ if _message:
860
+ (st.error if _terminal_notice.get("kind") == "error" else st.warning)(_message)
861
+
862
  progress_bar = st.empty()
863
  monitor_ui = st.empty()
864
 
 
915
  st.session_state.start_pipeline = False
916
  st.session_state.resume_pipeline = False
917
  st.error(_quota_start_error_message(lang_code, exc))
918
+ if "payment_required" in str(exc):
919
+ st.session_state["sp_payment_required"] = True
920
+ render_payment_purchase(lang_code)
921
  st.stop()
922
 
923
  st.session_state.is_running = True
924
  agent = SSciPipelineAgent(global_ds_key, global_s2_key)
925
+ SAFE_CHUNK_WORDS = agent.chunk_words_default
926
  qual_engine = get_qualitative_engine(agent)
927
+ quant_engine = get_quantitative_engine()
928
  completion_finalization_attempted = False
929
 
930
  try:
 
940
  target_lang = state["target_lang"]
941
  words = state["words"]
942
  user_data_md = state["user_data_md"]
943
+ data_metadata = state.get("data_metadata", {})
944
  user_lit_context = state["user_lit_context"]
945
 
946
  agent.load_state(state["agent_state"])
 
965
  pb = progress_bar.progress(1, text=tr(lang_code, "pipeline_start"))
966
  user_lit_context = ""
967
  user_data_md = ""
968
+ data_metadata = {}
969
 
970
  if st.session_state.run_mode == "amj":
971
  if uploaded_papers_amj:
 
988
 
989
  with st.spinner(tr(lang_code, "extract_quant")):
990
  update_heartbeat()
991
+ _, user_data_md, msg, data_metadata = quant_engine.resolve_empirical_data(uploaded_data_amj, lang_code)
992
  st.info(msg)
993
 
994
  elif st.session_state.run_mode == "asq":
 
1012
 
1013
  update_heartbeat()
1014
  raw_interviews = extract_text_from_files([uploaded_interviews]) if uploaded_interviews else ""
1015
+ user_data_md, msg, data_metadata = qual_engine.resolve_qualitative_data(topic, keywords, raw_interviews)
1016
+ st.info(msg or tr(lang_code, "qualitative_ready"))
1017
 
1018
  state = {
1019
  "topic": topic, "keywords": keywords, "target_lang": target_lang, "words": words,
1020
+ "user_data_md": user_data_md, "data_metadata": data_metadata, "user_lit_context": user_lit_context,
1021
  "completed_sections": [], "full_draft_en": "", "full_draft_translated": "",
1022
  "context_tail": "", "run_mode": st.session_state.run_mode, "interface_language": lang_code,
1023
  "managed_task_idempotency_key": managed_task_key,
 
1077
  pb.progress(14, text=tr(lang_code, "anchor_progress"))
1078
  with st.status(tr(lang_code, "anchor_status"), expanded=True) as status:
1079
  ph = st.empty()
1080
+ anchor_response = ph.write_stream(stream_with_heartbeat(agent.stream_generate_anchors(topic, keywords, synthesis_result, refs_text, data_evidence=user_data_md, mode=st.session_state.run_mode)))
1081
  status.update(label=tr(lang_code, "anchor_done"), state="complete", expanded=False)
1082
 
1083
  # ==========================================================
 
1089
  conclusion_match = re.search(r'-+\s*CONCLUSION\s*-+\s*(.*?)$', anchor_response_clean, re.DOTALL | re.IGNORECASE)
1090
  abstract_anchor = abstract_match.group(1).strip() if abstract_match else anchor_response_clean.strip()
1091
  conclusion_anchor = conclusion_match.group(1).strip() if conclusion_match else ""
1092
+ agent.global_memory["abstract_anchor"] = abstract_anchor
1093
+ agent.global_memory["conclusion_anchor"] = conclusion_anchor
1094
  # ==========================================================
1095
 
1096
  pb.progress(15, text=tr(lang_code, "outline_progress"))
1097
  update_heartbeat()
1098
+ plan = agent.generate_outline(topic, keywords, mode=st.session_state.run_mode, data_metadata=data_metadata)
1099
  en_topic = str(plan.get("en_topic", topic))
1100
  structure = plan.get("structure", [])
1101
 
1102
  en_abstract_clean = clean_markdown_headings(re.sub(r'(?i)^abstract[:\s]*', '', str(abstract_anchor)).strip())
1103
+ trans_abstract_clean = ""
1104
 
1105
  loc_abs = {"Simplified Chinese (简体中文)": "摘要", "Russian (Русский)": "Аннотация", "Kazakh (Қазақша)": "Түйіндеме"}.get(target_lang, "Abstract")
1106
  loc_kw = {"Simplified Chinese (简体中文)": "关键词", "Russian (Русский)": "Ключевые слова", "Kazakh (Қазақша)": "Кілт сөздер"}.get(target_lang, "Keywords")
 
1108
  full_draft_en = f"# {en_topic}\n\n## Abstract\n{en_abstract_clean}\n\n## Keywords\n{keywords}\n\n"
1109
  full_draft_translated = f"# {topic}\n\n## {loc_abs}\n{trans_abstract_clean}\n\n## {loc_kw}\n{keywords}\n\n"
1110
 
1111
+ adaptive_structure = agent.build_generation_tasks(structure, int(words), SAFE_CHUNK_WORDS)
1112
+ for task in adaptive_structure:
1113
+ task["is_first"] = bool(task.get("section_first"))
1114
+
 
 
 
 
 
 
 
 
 
 
 
1115
  with st.spinner(tr(lang_code, "jit_status")):
1116
  update_heartbeat()
1117
  jit_texts = agent.prefetch_jit_literature(topic, keywords, adaptive_structure)
 
1138
  st.markdown("---")
1139
  ui_blocks = []
1140
  target_flag = FLAG_MAP.get(target_lang, "🌐")
1141
+
1142
  for sec in adaptive_structure:
1143
  display_name = sec["display_name"]
1144
  st.markdown(f"### ⚙️ {display_name}")
 
1149
  with col_trans:
1150
  st.markdown(tr(lang_code, "target_draft", flag=target_flag))
1151
  trans_ph = st.empty()
1152
+ if display_name in completed_sections:
 
1153
  en_ph.success(tr(lang_code, "restored"))
1154
+ trans_ph.info(tr(lang_code, "translation_waiting"))
1155
+ else:
1156
  en_ph.info(tr(lang_code, "waiting"))
1157
  trans_ph.info(tr(lang_code, "translation_waiting"))
 
1158
  ui_blocks.append({"sec": sec, "en_ph": en_ph, "trans_ph": trans_ph})
1159
 
1160
  generation_aborted = False
1161
  generation_failure_code = ""
1162
  generation_failure_detail = ""
1163
  accumulated_deviation = 0
1164
+ remaining_sections = sum(1 for item in adaptive_structure if item["display_name"] not in completed_sections)
1165
  all_cite_keys = list(agent.citation_map.keys())
1166
+ valid_chunks = [item for item in adaptive_structure if not any(word in item["section"].lower() for word in ["abstract","keyword","appendix"])]
1167
  allocation_map = {}
1168
  current_cite_idx = 0
1169
+ for idx, item in enumerate(valid_chunks):
1170
+ count = max(1, math.ceil(len(all_cite_keys) / max(1, len(valid_chunks))))
1171
+ allocation_map[item["display_name"]] = all_cite_keys[current_cite_idx:] if idx == len(valid_chunks)-1 else all_cite_keys[current_cite_idx:current_cite_idx+count]
1172
+ current_cite_idx += count
 
 
 
 
1173
 
1174
+ for block in ui_blocks:
1175
+ if generation_aborted:
 
 
 
1176
  break
 
1177
  sec = block["sec"]
1178
  en_ph = block["en_ph"]
1179
  trans_ph = block["trans_ph"]
1180
  sec_title = sec["section"]
1181
  display_name = sec["display_name"]
1182
+ if display_name in completed_sections:
1183
+ continue
1184
+ remaining_sections = max(0, remaining_sections - 1)
1185
+ adjustment = -accumulated_deviation / remaining_sections if remaining_sections else 0
1186
+ adjusted_target = min(int(SAFE_CHUNK_WORDS * 1.5), max(80, int(sec["target_words"] + adjustment)))
1187
+ is_quarantined = any(word in sec_title.lower() for word in ["abstract","keyword","conclusion","references","appendix"])
1188
+ assigned_tags = ", ".join(allocation_map.get(display_name, [])) or "the single most relevant source"
1189
+ citation_rule = "NO IN-TEXT CITATIONS ALLOWED IN THIS SECTION." if is_quarantined else f"Use claim-specific citation. Each sentence may contain at most one source tag. Candidate tags: {assigned_tags}. <AVAILABLE_CITATIONS>{refs_text}</AVAILABLE_CITATIONS>"
1190
+ memory_context = agent._build_memory_context(sec_title, 2500)
1191
+ memory_block = f"\n<GLOBAL_MEMORY>\n{memory_context}\n</GLOBAL_MEMORY>\n" if memory_context else ""
1192
+ if getattr(st.session_state, "rag_engine", None) is not None:
1193
+ snippets = st.session_state.rag_engine.get_relevant_context(display_name)
1194
+ if snippets:
1195
+ memory_block += f"\n<TARGETED_RAG>\n{snippets}\n</TARGETED_RAG>\n"
1196
+ subsection = sec.get("subsection")
1197
+ heading_directive = "Do not output the section heading; it will be inserted by the system."
1198
+ if subsection:
1199
+ heading_directive += f" Do not output the subsection heading '{subsection}'; it will also be inserted by the system. Use level-3 headings only when analytically necessary."
1200
+ elif sec_title.lower() in {"introduction", "conclusion"}:
1201
+ heading_directive += " Do not use additional headings."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1202
  else:
1203
+ heading_directive += " Use level-2 or level-3 headings only when they clarify the argument; never use level-4 headings or numbered headings."
1204
+ table_rule = "Present statistical evidence in standard Markdown tables with plain-text captions." if sec["is_method_or_result"] and st.session_state.run_mode == "amj" else ""
1205
+ required_content = str(sec.get("required_content") or "")
1206
+ concepts = ", ".join(sec.get("concepts") or [])
1207
+ qualitative_rule = f"Assigned qualitative concepts: {concepts}. Use only their assigned representative quotations and do not repeat a quotation already used elsewhere." if concepts else ""
1208
+ data_injection = f"\n<RESEARCH_EVIDENCE>\n{user_data_md}\n</RESEARCH_EVIDENCE>\n" if user_data_md and sec["is_method_or_result"] else ""
1209
+ completed_label = ", ".join(completed_sections)
1210
+ anti_repeat = f"Already completed: {completed_label}. Do not restate their background, gap, or contribution." if completed_label else ""
1211
+ sys_prompt = APP_CONFIG.get("sys_academic_writer", "") + "\n" + APP_CONFIG.get("deep_refinement_directive", "")
1212
+ user_prompt = f"""Write the assigned English manuscript unit.
1213
+ Section: {sec_title}
1214
+ Subsection: {subsection or 'None'}
1215
+ Target length: {int(adjusted_target*0.9)}-{int(adjusted_target*1.1)} words.
1216
+ {heading_directive}
1217
+ {citation_rule}
1218
+ {table_rule}
1219
+ {required_content}
1220
+ {qualitative_rule}
1221
+ {anti_repeat}
1222
+ {memory_block}
1223
+ Literature synthesis: {synthesis_result}
1224
+ {data_injection}
1225
+ Previous tail: {context_tail[-2500:]}
1226
+ Output only final academic prose. Do not report the word count."""
1227
+ if agent._count_tokens(sys_prompt) + agent._count_tokens(user_prompt) > 60000:
1228
+ user_prompt = agent._truncate_context(user_prompt, 60000 - agent._count_tokens(sys_prompt))
1229
+ render_monitor(agent, tr(lang_code, "drafting_phase", current=current_step+1, total=total_steps), display_name, tr(lang_code, "target_words", words=adjusted_target, engine=tr(lang_code, "engine_amj") if st.session_state.run_mode=="amj" else tr(lang_code, "engine_asq")))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1230
  try:
1231
  with st.status(tr(lang_code, "draft_status", name=display_name), expanded=True) as status:
1232
  update_heartbeat()
1233
+ en_raw = en_ph.write_stream(stream_with_heartbeat(agent.stream_deepseek(sys_prompt, user_prompt, max_tokens=7000, model_role="writing", thinking=False, allow_fallback=False)))
 
 
 
 
 
 
 
 
1234
  status.update(label=tr(lang_code, "draft_done", name=display_name), state="complete", expanded=False)
1235
+ en_raw = re.sub(r'<think>.*?</think>', '', str(en_raw), flags=re.S).strip()
1236
+ en_raw = re.sub(rf'(?im)^#\s*{re.escape(sec_title)}\s*\n+', '', en_raw).strip()
1237
+ if subsection:
1238
+ en_raw = re.sub(rf'(?im)^##\s*{re.escape(subsection)}\s*\n+', '', en_raw).strip()
1239
+ en_raw = re.sub(r'(?m)^(#{2,3})\s+[\d\.\s一二三四五六七八九十、]+\s*(.*)', r'\1 \2', en_raw)
1240
+ en_raw = re.sub(r'(?m)^#{4,}\s+(.*)', r'**\1**', en_raw)
1241
+ en_raw = re.sub(r'(?m)^#\s+(.+)$', r'## \1', en_raw)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1242
  if st.session_state.run_mode == "asq" and user_data_md and "appendix" not in sec_title.lower():
1243
  quote_dict = {tag: quote for quote, tag in re.findall(r'"([^"]+)"\s*\[(QUOTE-\d+)\]', user_data_md)}
1244
  for tag, quote in quote_dict.items():
1245
+ en_raw = en_raw.replace(tag, f'"{quote}"')
1246
+ en_raw = agent.polish_academic_section(en_raw, display_name, abstract=False)
1247
+ prefix = ""
1248
+ if sec.get("section_first"):
1249
+ prefix += f"# {sec_title}\n\n"
1250
+ if sec.get("subsection_first") and subsection:
1251
+ prefix += f"## {subsection}\n\n"
1252
+ en_with_headings = prefix + en_raw
1253
+ en_resolved = agent.audit_and_resolve_citations(en_with_headings, section_name=display_name, quarantine=is_quarantined)
1254
  en_ph.markdown(en_resolved)
1255
+ trans_ph.info(tr(lang_code, "translation_waiting"))
1256
+ full_draft_en += en_resolved + "\n\n"
1257
+ agent._extract_memory_from_text(en_raw, sec_title)
1258
+ words_tail = en_raw.split()
1259
+ context_tail = " ".join(words_tail[-200:])
1260
+ actual_words = len(en_raw.split())
1261
+ accumulated_deviation += actual_words - adjusted_target
 
 
1262
  current_step += 1
1263
+ completed_sections.append(display_name)
1264
+ state.update({
1265
+ "completed_sections": completed_sections,
1266
+ "full_draft_en": full_draft_en,
1267
+ "full_draft_translated": "",
1268
+ "context_tail": context_tail,
1269
+ "agent_state": agent.export_state(),
1270
+ "data_metadata": data_metadata,
1271
+ })
1272
+ with open(CHECKPOINT_FILE, "w", encoding="utf-8") as checkpoint:
1273
+ json.dump(state, checkpoint, ensure_ascii=False)
1274
+ st.caption(tr(lang_code, "word_count", actual=actual_words, target=adjusted_target, deviation=accumulated_deviation))
1275
+ pb.progress(min(15 + int((current_step/max(1,total_steps))*75), 90), text=tr(lang_code, "section_complete_progress", name=display_name))
1276
+ except Exception as error:
 
 
 
 
 
1277
  en_ph.error(tr(lang_code, "node_error"))
1278
+ st.error(tr(lang_code, "node_error_detail", error=error))
1279
  generation_aborted = True
1280
  generation_failure_code = "E_GENERATION_NODE_FAILED"
1281
+ generation_failure_detail = str(error)[:800]
 
1282
 
1283
  if generation_aborted:
1284
  _finish_managed_generation_task(
1285
+ "failed",
1286
  safe_error_code=generation_failure_code or "E_GENERATION_INTERRUPTED",
1287
+ safe_error_detail=generation_failure_detail or "Generation stopped; quota was released and the checkpoint was retained for a fresh retry.",
1288
  current_node=str(st.session_state.get("sp_current_node") or "generation"),
1289
+ detach_checkpoint=True,
1290
  )
1291
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1292
  if not generation_aborted and full_draft_en.strip():
1293
+ pb.progress(91, text=tr(lang_code, "pack_docx"))
1294
  update_heartbeat()
1295
+ full_draft_en = clean_trailing_references(full_draft_en, ["Reference","References","Reference List"])
1296
+ # The final abstract is derived from the completed English manuscript and frozen evidence.
1297
+ final_abstract = agent.generate_final_abstract(topic, keywords, full_draft_en, data_evidence=user_data_md, mode=st.session_state.run_mode)
1298
+ full_draft_en = replace_abstract(full_draft_en, final_abstract)
1299
+ agent.validate_manuscript_structure(full_draft_en, adaptive_structure, translated=False)
1300
+ if st.session_state.run_mode == "asq":
1301
+ repeated_quotes = agent.find_repeated_qualitative_quotes(full_draft_en, data_metadata)
1302
+ if repeated_quotes:
1303
+ raise RuntimeError(f"Repeated qualitative quotations: {repeated_quotes[:5]}")
1304
+ # Only sources actually cited in the manuscript enter the reference list.
1305
+ references = []
1306
+ seen_refs = set()
1307
+ for tag in sorted(agent.used_citations, key=agent._citation_tag_number):
1308
+ ref = str((agent.citation_map.get(tag) or {}).get("ref") or "").strip()
1309
+ if ref and ref not in seen_refs:
1310
+ references.append(f"- {ref}")
1311
+ seen_refs.add(ref)
1312
+ if not references:
1313
+ raise RuntimeError("No cited references remained after citation validation.")
1314
+ full_draft_en = full_draft_en.rstrip() + "\n\n# References\n" + "\n\n".join(references)
1315
+
1316
+ pb.progress(94, text=tr(lang_code, "translation_running"))
1317
+ update_heartbeat()
1318
+ full_draft_translated = agent.translate_locked_manuscript(full_draft_en, target_lang)
1319
+ agent.validate_translation_alignment(full_draft_en, full_draft_translated)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1320
 
1321
  bj_time = datetime.now(timezone(timedelta(hours=8))).strftime("%Y%m%d_%H%M")
1322
+ package = build_validated_package(
1323
+ english_markdown=full_draft_en,
1324
+ translated_markdown=full_draft_translated,
1325
+ english_filename=f"Research_EN_{bj_time}.docx",
1326
+ translated_filename=f"Research_{output_suffix}_{bj_time}.docx",
1327
+ data_metadata=data_metadata,
1328
+ mode=st.session_state.run_mode,
1329
+ citation_audit=agent.citation_audit_log,
1330
+ literature_metrics=agent.get_source_metrics(),
1331
+ )
1332
+ generated_zip_bytes = package.zip_bytes
1333
+ generated_zip_filename = f"SmartPaper_{bj_time}.zip"
1334
+
1335
+ # Persist the already validated package in the current user's task
1336
+ # directory before asking Worker/D1 to commit quota. The download is
1337
+ # exposed only after the browser receives completed+committed.
1338
+ task_id = str((st.session_state.get("sp_generation_task") or {}).get("task_id") or "").strip()
1339
+ if not task_id:
1340
+ raise RuntimeError("package_task_id_missing")
1341
+ pending_path = os.path.join(_task_output_dir(task_id), ".pending_" + generated_zip_filename)
1342
+ with open(pending_path, "wb") as output_file:
1343
+ output_file.write(generated_zip_bytes)
1344
+ st.session_state["sp_pending_package"] = {
1345
+ "pending_path": pending_path,
1346
+ "filename": generated_zip_filename,
1347
+ }
1348
  completion_finalization_attempted = True
1349
  _finish_managed_generation_task(
1350
  "completed",
1351
+ current_node="package_validated",
1352
+ delete_checkpoint=True,
1353
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1354
  pb.progress(100, text=tr(lang_code, "complete"))
1355
  render_monitor(agent, tr(lang_code, "complete"), "✅", tr(lang_code, "document_generated"))
1356
 
1357
  except Exception as e:
1358
  if not completion_finalization_attempted:
 
1359
  try:
1360
  _finish_managed_generation_task(
1361
+ "failed",
1362
+ safe_error_code="E_GENERATION_FAILED",
1363
  safe_error_detail=str(e)[:800],
1364
  current_node=str(st.session_state.get("sp_current_node") or "generation"),
1365
+ detach_checkpoint=True,
1366
  )
1367
  except Exception:
1368
  pass
config.py CHANGED
@@ -4,12 +4,18 @@ import os
4
  def load_config():
5
  base_config = {
6
  # --- 基础运行参数 ---
7
- "lit_target_papers": int(os.environ.get("LIT_TARGET_PAPERS", "250")),
8
  "lit_per_page": int(os.environ.get("LIT_PER_PAGE", "100")),
9
  "lit_max_workers": int(os.environ.get("LIT_MAX_WORKERS", "5")),
10
- "lit_max_page_batches": int(os.environ.get("LIT_MAX_PAGE_BATCHES", "12")),
11
  "chunk_words_default": int(os.environ.get("CHUNK_WORDS_DEFAULT", "1500")),
12
- "chunk_words_cyrillic": int(os.environ.get("CHUNK_WORDS_CYRILLIC", "1000")),
 
 
 
 
 
 
 
13
 
14
  # --- RAG 与解析配置 ---
15
  "rag_chunk_size": int(os.environ.get("RAG_CHUNK_SIZE", "3000")),
@@ -82,8 +88,9 @@ def load_config():
82
  "DEEPSEEK_SHOW_REASONING",
83
  "false",
84
  ).strip().lower() in {"1", "true", "yes", "on"},
85
- # Citation-semantic audit is fail-closed. It reviews only sentences
86
- # containing source tags and never silently deletes extra sources.
 
87
  "citation_semantic_audit": {
88
  "enabled": os.environ.get(
89
  "CITATION_SEMANTIC_AUDIT_ENABLED",
@@ -99,7 +106,7 @@ def load_config():
99
  ),
100
  "fail_closed": os.environ.get(
101
  "CITATION_SEMANTIC_FAIL_CLOSED",
102
- "true",
103
  ).strip().lower() in {"1", "true", "yes", "on"},
104
  "technical_retries": max(
105
  1,
@@ -116,6 +123,18 @@ def load_config():
116
  3,
117
  int(os.environ.get("SMARTPAPER_TASK_HEARTBEAT_SECONDS", "5")),
118
  ),
 
 
 
 
 
 
 
 
 
 
 
 
119
  "openalex_api_url": os.environ.get("OPENALEX_API_URL", "https://api.openalex.org/works"),
120
  "s2_api_url": os.environ.get("S2_API_URL", "https://api.semanticscholar.org/graph/v1"),
121
 
@@ -331,7 +350,7 @@ Literature Metadata:
331
  [ABSOLUTE ANTI-AI-TONE MANDATES]:
332
  1. CRITICAL: NEVER use these cliché AI words: delve into, intricate, paramount, overarching, pivotal, foster, seamlessly, multifaceted, beacon, landscape, underscore, robust.
333
  2. CRITICAL: NEVER use robotic transition phrases (e.g., Firstly, Moreover, Furthermore, In conclusion, To summarize). Use conceptual bridges instead.
334
- 3. SYNTAX BURSTINESS: You MUST vary sentence length dramatically. Mix short, punchy, definitive claims with longer, nuanced analytical sentences.
335
  4. ACADEMIC HEDGING: Use strategic hedging (e.g., 'This suggests a tendency toward X, bounded by Y').
336
  5. NO SWEEPING GENERALIZATIONS: Every sentence must carry specific theoretical weight or empirical fact.
337
  6. FOCUS ON CAUSALITY: You MUST articulate the exact theoretical mechanism driving the relationship.
 
4
  def load_config():
5
  base_config = {
6
  # --- 基础运行参数 ---
7
+ "lit_target_papers": int(os.environ.get("LIT_TARGET_PAPERS", "80")),
8
  "lit_per_page": int(os.environ.get("LIT_PER_PAGE", "100")),
9
  "lit_max_workers": int(os.environ.get("LIT_MAX_WORKERS", "5")),
 
10
  "chunk_words_default": int(os.environ.get("CHUNK_WORDS_DEFAULT", "1500")),
11
+ "search_max_retries": max(1, int(os.environ.get("SEARCH_MAX_RETRIES", "4"))),
12
+ "translation_batch_tokens": max(800, int(os.environ.get("TRANSLATION_BATCH_TOKENS", "2500"))),
13
+ "translation_glossary_term_limit": max(10, int(os.environ.get("TRANSLATION_GLOSSARY_TERM_LIMIT", "40"))),
14
+ "translation_min_coverage_ratio": float(os.environ.get("TRANSLATION_MIN_COVERAGE_RATIO", "0.25")),
15
+ "translation_min_block_ratio_zh": float(os.environ.get("TRANSLATION_MIN_BLOCK_RATIO_ZH", "0.28")),
16
+ "translation_min_block_ratio_cyrillic": float(os.environ.get("TRANSLATION_MIN_BLOCK_RATIO_CYRILLIC", "0.65")),
17
+ "translation_max_block_ratio": float(os.environ.get("TRANSLATION_MAX_BLOCK_RATIO", "2.20")),
18
+ "openalex_email": os.environ.get("OPENALEX_EMAIL", "").strip(),
19
 
20
  # --- RAG 与解析配置 ---
21
  "rag_chunk_size": int(os.environ.get("RAG_CHUNK_SIZE", "3000")),
 
88
  "DEEPSEEK_SHOW_REASONING",
89
  "false",
90
  ).strip().lower() in {"1", "true", "yes", "on"},
91
+ # Citation-semantic audit repairs only affected sentences. Technical
92
+ # failures fall back to deterministic single-source recovery and are
93
+ # recorded in the audit log instead of terminating the whole paper.
94
  "citation_semantic_audit": {
95
  "enabled": os.environ.get(
96
  "CITATION_SEMANTIC_AUDIT_ENABLED",
 
106
  ),
107
  "fail_closed": os.environ.get(
108
  "CITATION_SEMANTIC_FAIL_CLOSED",
109
+ "false",
110
  ).strip().lower() in {"1", "true", "yes", "on"},
111
  "technical_retries": max(
112
  1,
 
123
  3,
124
  int(os.environ.get("SMARTPAPER_TASK_HEARTBEAT_SECONDS", "5")),
125
  ),
126
+ "manuscript_quality": {
127
+ "abstract_max_words": int(os.environ.get("ABSTRACT_MAX_WORDS", "200")),
128
+ "abstract_max_numeric_mentions": int(os.environ.get("ABSTRACT_MAX_NUMERIC_MENTIONS", "6")),
129
+ "forbidden_phrases": [
130
+ "delve into", "intricate", "paramount", "overarching",
131
+ "pivotal", "foster", "seamlessly", "multifaceted",
132
+ "beacon", "landscape", "underscore", "robust",
133
+ "firstly", "secondly", "moreover", "furthermore",
134
+ "in conclusion", "to summarize", "as an ai",
135
+ "ai-generated", "this response",
136
+ ],
137
+ },
138
  "openalex_api_url": os.environ.get("OPENALEX_API_URL", "https://api.openalex.org/works"),
139
  "s2_api_url": os.environ.get("S2_API_URL", "https://api.semanticscholar.org/graph/v1"),
140
 
 
350
  [ABSOLUTE ANTI-AI-TONE MANDATES]:
351
  1. CRITICAL: NEVER use these cliché AI words: delve into, intricate, paramount, overarching, pivotal, foster, seamlessly, multifaceted, beacon, landscape, underscore, robust.
352
  2. CRITICAL: NEVER use robotic transition phrases (e.g., Firstly, Moreover, Furthermore, In conclusion, To summarize). Use conceptual bridges instead.
353
+ 3. NATURAL SYNTAX: Vary sentence structure only where the argument requires it. Avoid artificial short-long sentence alternation and repetitive templates.
354
  4. ACADEMIC HEDGING: Use strategic hedging (e.g., 'This suggests a tendency toward X, bounded by Y').
355
  5. NO SWEEPING GENERALIZATIONS: Every sentence must carry specific theoretical weight or empirical fact.
356
  6. FOCUS ON CAUSALITY: You MUST articulate the exact theoretical mechanism driving the relationship.
core/pipeline_agent.py CHANGED
@@ -13,7 +13,8 @@ import sys
13
  import logging
14
  import concurrent.futures
15
  from collections import Counter
16
- from tenacity import retry, stop_after_attempt, wait_random_exponential, retry_if_exception_type
 
17
 
18
  # 基础日志配置:自带时间戳和日志级别
19
  logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", handlers=[logging.StreamHandler(sys.stdout)])
@@ -71,6 +72,7 @@ class SSciPipelineAgent:
71
  self.openalex_key = st.secrets.get("OA_API_KEY", "")
72
  except:
73
  self.openalex_key = os.environ.get("OA_API_KEY", "")
 
74
 
75
  self.ds_calls = 0
76
  # Backward-compatible counters now mirror unique additions, while the
@@ -82,11 +84,14 @@ class SSciPipelineAgent:
82
  "request_count": 0,
83
  "success_count": 0,
84
  "raw_result_count": 0,
 
 
85
  "unique_added_count": 0,
86
  "duplicate_count": 0,
87
  "error_count": 0,
88
  "last_status_code": None,
89
  "last_safe_error": "",
 
90
  }
91
  for source in ("s2", "oa")
92
  }
@@ -95,14 +100,13 @@ class SSciPipelineAgent:
95
  self.oa_fused = False
96
  self.citation_map = {}
97
  self.used_citations = set()
 
98
  self.lock = threading.RLock()
99
 
100
  self.lit_target_papers = APP_CONFIG["lit_target_papers"]
101
  self.lit_per_page = APP_CONFIG["lit_per_page"]
102
  self.lit_max_workers = APP_CONFIG["lit_max_workers"]
103
- self.lit_max_page_batches = APP_CONFIG["lit_max_page_batches"]
104
  self.chunk_words_default = APP_CONFIG["chunk_words_default"]
105
- self.chunk_words_cyrillic = APP_CONFIG["chunk_words_cyrillic"]
106
 
107
  self.deepseek_api_url = APP_CONFIG["deepseek_api_url"]
108
  configured_models = APP_CONFIG.get("deepseek_models") or {}
@@ -228,6 +232,7 @@ class SSciPipelineAgent:
228
  "source_metrics": self.get_source_metrics(),
229
  "citation_map": self.citation_map,
230
  "used_citations": list(self.used_citations),
 
231
  "global_memory": self.global_memory
232
  }
233
 
@@ -244,47 +249,96 @@ class SSciPipelineAgent:
244
  self.source_metrics[source].update(loaded_metrics[source])
245
  self.citation_map = state_dict.get("citation_map", {})
246
  self.used_citations = set(state_dict.get("used_citations", []))
 
247
  self.global_memory = state_dict.get("global_memory", self.global_memory)
248
  logging.info(f"🔄 状态恢复成功: DS API={self.ds_calls}, 现有文献={len(self.citation_map)}")
249
 
250
- def _calc_paragraph_count(self, target_words, lang_hint="en"):
251
- words_per_para = 100 if lang_hint in ["ru", "kk"] else 130
252
- return max(2, int(target_words / words_per_para))
253
-
254
- def _translate_text(self, text, target_lang, max_tokens=2000):
255
- if not text.strip():
256
- return ""
257
- sys_prompt = f"Translate the following text into {target_lang}. Preserve formatting and [CITE-XX] tags exactly. Output ONLY the translation."
258
- return self.call_deepseek(
259
- sys_prompt,
260
- text,
261
- max_tokens=max_tokens,
262
- temp=0.2,
263
- model_role="translation",
264
- thinking=False,
265
- allow_fallback=False,
266
- )
267
-
268
- @retry(stop=stop_after_attempt(4), wait=wait_random_exponential(multiplier=1.5, max=15), retry=retry_if_exception_type((requests.exceptions.Timeout, requests.exceptions.ConnectionError, requests.exceptions.HTTPError)))
269
  def _requests_get_with_retry(self, url, headers=None, params=None, timeout=None):
270
- self._check_cancelled()
271
- if timeout is None:
 
 
 
 
 
272
  timeout = self.timeouts["search"]
273
- resp = requests.get(url, headers=headers, params=params, timeout=timeout)
274
- self._check_cancelled()
275
-
276
- if resp.status_code == 429:
277
- logging.warning(f"⚠️ [API 限流] 触发 429 (Too Many Requests)。启动指数退避。URL: {url}")
278
- time.sleep(random.uniform(2.0, 5.0))
279
- resp.raise_for_status()
280
-
281
- if resp.status_code == 400 and "Requested data for this limit" in resp.text:
282
- return resp
283
-
284
- if resp.status_code != 200:
285
- logging.error(f"❌ [API 错误] 状态码: {resp.status_code} | URL: {url}")
286
- resp.raise_for_status()
287
- return resp
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
 
289
  def _is_english_dominant(self, text):
290
  if not text:
@@ -1347,15 +1401,27 @@ You MUST output your response EXACTLY using the following two delimiters. Do not
1347
  results = future.result()
1348
  with self.lock:
1349
  for res in results:
1350
- if not any(res["id"] == self.citation_map[k].get("id", "") for k in self.citation_map):
1351
- cite_tag = f"[CITE-{len(self.citation_map)+1:02d}]"
1352
- self.citation_map[cite_tag] = {
1353
- "in_text": res["in_text"],
1354
- "ref": res["ref"],
1355
- "id": res["id"],
1356
- "evidence": res.get("rag_text", ""),
1357
- }
1358
- jit_rag_texts.append(f"{cite_tag} {res['rag_text']}")
 
 
 
 
 
 
 
 
 
 
 
 
1359
 
1360
  logging.info(f"✅ [Pre-fetch JIT] 成功抓取并注入了 {len(jit_rag_texts)} 篇底层支撑文献。")
1361
  return "\n".join(jit_rag_texts)
@@ -1387,51 +1453,139 @@ Return ONLY a valid JSON array of strings."""
1387
  clean_chunks = [c.strip() for c in re.findall(r'[a-zA-Z0-9\s\-]{4,}', f"{topic} {keywords}") if self._is_english_dominant(c.strip())]
1388
  return clean_chunks[:3] if clean_chunks else []
1389
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1390
  def _s2_worker(self, term, offset):
1391
  self._check_cancelled()
1392
- time.sleep(random.uniform(0.5, 1.5))
1393
- headers = {"x-api-key": str(self.s2_key).strip()} if self.s2_key else {}
1394
- results = []
1395
- self._source_metric_add("s2", "request_count")
1396
- logging.info("S2 search: term=%r offset=%s", term, offset)
 
 
 
1397
  try:
1398
- resp = self._requests_get_with_retry(
1399
  f"{self.s2_api_url}/paper/search",
1400
  headers=headers,
1401
  params={
1402
- "query": term,
1403
- "offset": offset,
1404
- "limit": 50,
1405
- "fields": "title,authors,year,venue,abstract,externalIds",
1406
  },
1407
  )
1408
- self._source_metric_set("s2", "last_status_code", resp.status_code)
1409
- if resp.status_code == 400:
1410
- self._source_metric_add("s2", "error_count")
1411
- self._source_metric_set("s2", "last_safe_error", "HTTP 400")
1412
- return results
1413
- self._source_metric_add("s2", "success_count")
1414
- papers = resp.json().get("data", [])
1415
- self._source_metric_add("s2", "raw_result_count", len(papers))
 
 
 
 
 
 
 
 
1416
  for paper in papers:
1417
  self._check_cancelled()
1418
- title = paper.get("title")
1419
- year = paper.get("year")
1420
- if not title or not year:
1421
  continue
1422
- authors = paper.get("authors", [])
1423
  last_names, formatted_authors = [], []
1424
  for author in authors:
1425
- name = author.get("name", "")
1426
  if not name:
1427
  continue
1428
  parts = name.split()
1429
- if len(parts) > 1:
1430
- last_names.append(parts[-1])
1431
- formatted_authors.append(f"{parts[-1]}, {parts[0][0]}.")
1432
- else:
1433
- last_names.append(name)
1434
- formatted_authors.append(name)
1435
  if not last_names:
1436
  continue
1437
  if len(last_names) == 1:
@@ -1440,81 +1594,96 @@ Return ONLY a valid JSON array of strings."""
1440
  in_text = f"({last_names[0]} & {last_names[1]}, {year})"
1441
  else:
1442
  in_text = f"({last_names[0]} et al., {year})"
1443
- if len(formatted_authors) > 2:
1444
- author_str = ", ".join(formatted_authors[:-1]) + ", & " + formatted_authors[-1]
1445
- elif len(formatted_authors) == 2:
1446
- author_str = " & ".join(formatted_authors)
1447
- else:
1448
- author_str = formatted_authors[0]
1449
- apa_full = f"{author_str} ({year}). {title}. {paper.get('venue') or 'Journal'}."
1450
- doi = paper.get("externalIds", {}).get("DOI", "")
1451
  if doi:
1452
- apa_full += f" https://doi.org/{doi}"
1453
- abstract_str = " ".join((paper.get("abstract") or "").split()[:60])
1454
  results.append({
1455
- "id": (title.lower().strip(), str(year)),
1456
- "in_text": in_text,
1457
- "ref": apa_full,
1458
- "rag_text": f"Title: {title}. Focus: {abstract_str}...",
1459
- "source": "s2",
 
 
 
1460
  })
 
 
 
 
 
1461
  except PipelineCancelledError:
1462
  raise
 
 
1463
  except Exception as error:
1464
- self._source_metric_add("s2", "error_count")
1465
- status = getattr(getattr(error, "response", None), "status_code", None)
1466
- if status is not None:
1467
- self._source_metric_set("s2", "last_status_code", status)
1468
- safe_error = self._safe_source_error(error)
1469
- self._source_metric_set("s2", "last_safe_error", safe_error)
1470
- logging.warning("S2 search error: %s", safe_error)
1471
- return results
1472
 
1473
  def _oa_worker(self, term, page, filter_str):
1474
  self._check_cancelled()
1475
- time.sleep(random.uniform(1.0, 2.5))
1476
- headers = {"User-Agent": f"mailto:ssci_researcher_{random.randint(10000, 99999)}@gmail.com"}
 
 
 
 
 
 
1477
  if self.openalex_key:
1478
- headers["Authorization"] = f"Bearer {self.openalex_key}"
1479
- results = []
1480
- self._source_metric_add("oa", "request_count")
1481
- logging.info("OpenAlex search: term=%r page=%s", term, page)
1482
  try:
1483
- # Query and authentication parameters intentionally remain unchanged.
1484
- params = {
1485
- "search": term,
1486
- "per-page": self.lit_per_page,
1487
- "page": page,
1488
- "filter": filter_str,
1489
- "mailto": "ssci_researcher_agent@gmail.com",
1490
- }
1491
- resp = self._requests_get_with_retry(
1492
  self.openalex_api_url,
1493
  headers=headers,
1494
- params=params,
 
 
 
 
 
 
 
 
1495
  )
1496
- self._source_metric_set("oa", "last_status_code", resp.status_code)
1497
- self._source_metric_add("oa", "success_count")
1498
- raw_items = resp.json().get("results", [])
1499
- self._source_metric_add("oa", "raw_result_count", len(raw_items))
 
 
 
 
 
 
 
 
 
 
 
 
1500
  for item in raw_items:
1501
  self._check_cancelled()
1502
- title = item.get("title")
1503
  if not title or not self._is_english_dominant(title):
1504
  continue
1505
- year = item.get("publication_year", "n.d.")
1506
  formatted_authors, last_names = [], []
1507
- for authorship in item.get("authorships", []):
1508
- name_raw = authorship.get("author", {}).get("display_name", "")
1509
- if not name_raw or not name_raw.strip():
1510
  continue
1511
- parts = name_raw.strip().split()
1512
- if len(parts) > 1:
1513
- last_names.append(parts[-1])
1514
- formatted_authors.append(f"{parts[-1]}, {parts[0][0]}.")
1515
- else:
1516
- last_names.append(name_raw)
1517
- formatted_authors.append(name_raw)
1518
  if not formatted_authors:
1519
  continue
1520
  if len(last_names) == 1:
@@ -1523,170 +1692,175 @@ Return ONLY a valid JSON array of strings."""
1523
  in_text = f"({last_names[0]} & {last_names[1]}, {year})"
1524
  else:
1525
  in_text = f"({last_names[0]} et al., {year})"
1526
- if len(formatted_authors) > 2:
1527
- author_str = ", ".join(formatted_authors[:-1]) + ", & " + formatted_authors[-1]
1528
- elif len(formatted_authors) == 2:
1529
- author_str = " & ".join(formatted_authors)
1530
- else:
1531
- author_str = formatted_authors[0]
1532
- doi_clean = (item.get("doi") or "").replace("https://doi.org/", "")
1533
- venue_display = ((item.get("primary_location") or {}).get("source") or {}).get("display_name", "Journal")
1534
- apa_full = f"{author_str} ({year}). {title}. {venue_display}."
1535
- if doi_clean:
1536
- apa_full += f" https://doi.org/{doi_clean}"
1537
- raw_abstract = " ".join([
1538
- word
1539
- for position, word in sorted([
1540
- (position, word)
1541
- for word, positions in (item.get("abstract_inverted_index") or {}).items()
1542
- for position in positions
1543
- ])
1544
- ])
1545
- abstract_str = " ".join(str(raw_abstract).split()[:60])
1546
  results.append({
1547
- "id": (title.lower().strip(), str(year)),
1548
- "in_text": in_text,
1549
- "ref": apa_full,
1550
- "rag_text": f"Title: {title}. Focus: {abstract_str}...",
1551
- "source": "oa",
 
 
 
1552
  })
 
 
 
 
 
1553
  except PipelineCancelledError:
1554
  raise
 
 
1555
  except Exception as error:
1556
- self._source_metric_add("oa", "error_count")
1557
- status = getattr(getattr(error, "response", None), "status_code", None)
1558
- if status is not None:
1559
- self._source_metric_set("oa", "last_status_code", status)
1560
- safe_error = self._safe_source_error(error)
1561
- self._source_metric_set("oa", "last_safe_error", safe_error)
1562
- logging.warning("OpenAlex search error: %s", safe_error)
1563
- return results
 
 
 
1564
 
1565
  def fetch_references_parallel(self, topic, keywords, min_required=50, progress_callback=None):
 
 
 
 
 
 
1566
  with self.lock:
1567
  self.citation_map.clear()
1568
  self.used_citations.clear()
1569
- for source in ("s2", "oa"):
 
1570
  for field in (
1571
- "request_count", "success_count", "raw_result_count",
1572
- "unique_added_count", "duplicate_count", "error_count",
 
1573
  ):
1574
  self.source_metrics[source][field] = 0
1575
- self.source_metrics[source]["last_status_code"] = None
1576
- self.source_metrics[source]["last_safe_error"] = ""
1577
- self.s2_calls = 0
1578
- self.oa_calls = 0
1579
 
1580
- llm_rag_context = []
1581
- seen_identifiers = {}
1582
  all_used_terms = set()
1583
- filters_to_try = [
1584
- "language:en,type:article,has_doi:true,is_paratext:false",
1585
- "language:en,type:article,is_paratext:false",
1586
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1587
 
1588
- for expansion_phase in range(3):
1589
  self._check_cancelled()
1590
- if len(self.citation_map) >= min_required:
1591
- break
1592
- if expansion_phase == 0:
1593
- search_terms = self.generate_search_queries(topic, keywords)
1594
  else:
1595
- system_prompt = """You are an expert academic librarian. Broaden the search scope. Return only a JSON array containing three new English search queries, each under five terms. Do not use nested Boolean logic."""
1596
- user_prompt = (
1597
- f"Topic: {topic}\nKeywords: {keywords}\n"
1598
- f"Used Queries: {list(all_used_terms)}"
1599
- )
1600
  response = self.call_deepseek(
1601
- system_prompt,
1602
- user_prompt,
1603
- temp=0.5,
1604
- model_role="fast",
1605
  )
1606
- queries = self._extract_json_from_text(response)
1607
- if isinstance(queries, list):
1608
- search_terms = [
1609
- str(query).strip()
1610
- for query in queries
1611
- if self._is_english_dominant(str(query).strip())
1612
- ][:3]
1613
- else:
1614
- search_terms = []
1615
- current_batch_terms = [
1616
- term for term in search_terms if term not in all_used_terms
1617
- ]
1618
- if not current_batch_terms:
1619
  continue
1620
- all_used_terms.update(current_batch_terms)
1621
-
1622
- executor = concurrent.futures.ThreadPoolExecutor(
1623
- max_workers=self.lit_max_workers
1624
- )
1625
- futures = []
1626
- try:
1627
- if not self.s2_fused:
1628
- for term in current_batch_terms:
1629
- futures.append(executor.submit(self._s2_worker, term, 0))
1630
- futures.append(executor.submit(self._s2_worker, term, 50))
1631
- if not self.oa_fused:
1632
- for term in current_batch_terms:
1633
- for filter_str in filters_to_try:
1634
- futures.append(executor.submit(self._oa_worker, term, 1, filter_str))
1635
- futures.append(executor.submit(self._oa_worker, term, 2, filter_str))
1636
 
1637
- for future in concurrent.futures.as_completed(futures):
1638
- self._check_cancelled()
1639
- results = future.result()
1640
- with self.lock:
1641
- for result in results:
1642
- source = result["source"]
1643
- identifier = result["id"]
1644
- if identifier in seen_identifiers:
1645
- self.source_metrics[source]["duplicate_count"] += 1
1646
- existing_tag = seen_identifiers[identifier]
1647
- sources = self.citation_map[existing_tag].setdefault("sources", [])
1648
- if source not in sources:
1649
- sources.append(source)
1650
- continue
1651
- if len(self.citation_map) >= self.lit_target_papers:
1652
- break
1653
- seen_identifiers[identifier] = f"[CITE-{len(self.citation_map)+1:02d}]"
1654
- cite_tag = seen_identifiers[identifier]
1655
- self.citation_map[cite_tag] = {
1656
- "in_text": result["in_text"],
1657
- "ref": result["ref"],
1658
- "id": identifier,
1659
- "evidence": result.get("rag_text", ""),
1660
- "sources": [source],
1661
- }
1662
- self.source_metrics[source]["unique_added_count"] += 1
1663
- llm_rag_context.append(
1664
- f"{cite_tag} {result['rag_text']}"
1665
- )
1666
- self.s2_calls = self.source_metrics["s2"]["unique_added_count"]
1667
- self.oa_calls = self.source_metrics["oa"]["unique_added_count"]
1668
- if progress_callback:
1669
- progress_callback()
1670
- if len(self.citation_map) >= self.lit_target_papers:
1671
- break
1672
- except PipelineCancelledError:
1673
- for future in futures:
1674
- future.cancel()
1675
- # Do not wait for unrelated network workers after the user has
1676
- # explicitly cancelled or logged out.
1677
- executor.shutdown(wait=False, cancel_futures=True)
1678
- executor = None
1679
- raise
1680
- finally:
1681
- if executor is not None:
1682
- executor.shutdown(wait=True, cancel_futures=False)
1683
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1684
  logging.info(
1685
- "Reference search completed: unique=%s metrics=%s",
1686
  len(self.citation_map),
1687
  self.get_source_metrics(),
1688
  )
1689
- return "\n".join(llm_rag_context)
 
 
1690
 
1691
  @staticmethod
1692
  def _citation_tag_number(tag):
@@ -1819,12 +1993,13 @@ Return ONLY a valid JSON array of strings."""
1819
  text,
1820
  section_name="",
1821
  ):
1822
- """Audit cited sentences against retrieved evidence and fail closed.
1823
 
1824
  The audit may split a sentence when different sources support different
1825
  claims, or keep one source when several sources support the same claim.
1826
  It may not invent facts, numbers, quotations, or citation tags. A cited
1827
- claim that cannot be supported by the available evidence stops the task.
 
1828
  """
1829
  if not text:
1830
  return text
@@ -1945,8 +2120,6 @@ Return exactly:
1945
  self._safe_source_error(error),
1946
  )
1947
  if last_error is not None:
1948
- if not fail_closed:
1949
- continue
1950
  raise CitationAuditError(
1951
  "E_CITATION_AUDIT_TIMEOUT",
1952
  self._safe_source_error(last_error),
@@ -2013,13 +2186,25 @@ Return exactly:
2013
  f"Invalid citation audit status for {record['id']}: {status!r}"
2014
  )
2015
  if status == "unsupported":
 
2016
  unsupported.append(
2017
  {
2018
  "id": record["id"],
2019
  "sentence": record["original"][:500],
2020
- "reason": str(item.get("reason", ""))[:500],
2021
  }
2022
  )
 
 
 
 
 
 
 
 
 
 
 
2023
  continue
2024
  if not revised:
2025
  raise DeepSeekRequestError(
@@ -2056,12 +2241,11 @@ Return exactly:
2056
  replacements[record["id"]] = revised
2057
 
2058
  if unsupported:
2059
- raise CitationAuditError(
2060
- "E_CITATION_UNSUPPORTED",
2061
- "Unsupported cited claims: " + json.dumps(
2062
- unsupported[:3],
2063
- ensure_ascii=False,
2064
- ),
2065
  )
2066
 
2067
  rebuilt = []
@@ -2072,6 +2256,8 @@ Return exactly:
2072
  cursor = record["end"]
2073
  rebuilt.append(str(text)[cursor:])
2074
  audited = "".join(rebuilt)
 
 
2075
  violations = self.find_multi_citation_violations(audited)
2076
  if violations:
2077
  raise DeepSeekRequestError(
@@ -2085,6 +2271,580 @@ Return exactly:
2085
  )
2086
  return audited
2087
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2088
  def enforce_claim_specific_citations(self, text):
2089
  """Backward-compatible strict validator; no silent citation deletion."""
2090
  violations = self.find_multi_citation_violations(text)
 
13
  import logging
14
  import concurrent.futures
15
  from collections import Counter
16
+ from datetime import datetime, timezone
17
+ from email.utils import parsedate_to_datetime
18
 
19
  # 基础日志配置:自带时间戳和日志级别
20
  logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", handlers=[logging.StreamHandler(sys.stdout)])
 
72
  self.openalex_key = st.secrets.get("OA_API_KEY", "")
73
  except:
74
  self.openalex_key = os.environ.get("OA_API_KEY", "")
75
+ self.openalex_email = str(APP_CONFIG.get("openalex_email") or "").strip()
76
 
77
  self.ds_calls = 0
78
  # Backward-compatible counters now mirror unique additions, while the
 
84
  "request_count": 0,
85
  "success_count": 0,
86
  "raw_result_count": 0,
87
+ "eligible_result_count": 0,
88
+ "merged_count": 0,
89
  "unique_added_count": 0,
90
  "duplicate_count": 0,
91
  "error_count": 0,
92
  "last_status_code": None,
93
  "last_safe_error": "",
94
+ "status": "not_started",
95
  }
96
  for source in ("s2", "oa")
97
  }
 
100
  self.oa_fused = False
101
  self.citation_map = {}
102
  self.used_citations = set()
103
+ self.citation_audit_log = []
104
  self.lock = threading.RLock()
105
 
106
  self.lit_target_papers = APP_CONFIG["lit_target_papers"]
107
  self.lit_per_page = APP_CONFIG["lit_per_page"]
108
  self.lit_max_workers = APP_CONFIG["lit_max_workers"]
 
109
  self.chunk_words_default = APP_CONFIG["chunk_words_default"]
 
110
 
111
  self.deepseek_api_url = APP_CONFIG["deepseek_api_url"]
112
  configured_models = APP_CONFIG.get("deepseek_models") or {}
 
232
  "source_metrics": self.get_source_metrics(),
233
  "citation_map": self.citation_map,
234
  "used_citations": list(self.used_citations),
235
+ "citation_audit_log": list(self.citation_audit_log),
236
  "global_memory": self.global_memory
237
  }
238
 
 
249
  self.source_metrics[source].update(loaded_metrics[source])
250
  self.citation_map = state_dict.get("citation_map", {})
251
  self.used_citations = set(state_dict.get("used_citations", []))
252
+ self.citation_audit_log = list(state_dict.get("citation_audit_log", []))
253
  self.global_memory = state_dict.get("global_memory", self.global_memory)
254
  logging.info(f"🔄 状态恢复成功: DS API={self.ds_calls}, 现有文献={len(self.citation_map)}")
255
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  def _requests_get_with_retry(self, url, headers=None, params=None, timeout=None):
257
+ """GET with bounded, status-aware retry semantics.
258
+
259
+ Authentication and query errors are returned immediately. Rate limits
260
+ honour ``Retry-After`` when present. Only timeouts, connection failures,
261
+ 429, and 5xx responses are retried.
262
+ """
263
+ if timeout is None:
264
  timeout = self.timeouts["search"]
265
+ attempts = max(1, int(APP_CONFIG.get("search_max_retries", 4)))
266
+ last_error = None
267
+ for attempt in range(attempts):
268
+ self._check_cancelled()
269
+ try:
270
+ response = requests.get(
271
+ url,
272
+ headers=headers,
273
+ params=params,
274
+ timeout=timeout,
275
+ )
276
+ except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as error:
277
+ last_error = error
278
+ if attempt + 1 >= attempts:
279
+ raise
280
+ delay = min(12.0, 1.5 * (2 ** attempt)) + random.uniform(0.0, 0.5)
281
+ logging.warning(
282
+ "Search transport retry %s/%s after %s: %s",
283
+ attempt + 1,
284
+ attempts,
285
+ type(error).__name__,
286
+ url,
287
+ )
288
+ time.sleep(delay)
289
+ continue
290
+
291
+ self._check_cancelled()
292
+ status = int(response.status_code)
293
+ if status in (400, 401, 403):
294
+ return response
295
+ if status == 429:
296
+ if attempt + 1 >= attempts:
297
+ return response
298
+ retry_after = str(response.headers.get("Retry-After") or "").strip()
299
+ delay = None
300
+ if retry_after:
301
+ try:
302
+ delay = float(retry_after)
303
+ except ValueError:
304
+ try:
305
+ retry_at = parsedate_to_datetime(retry_after)
306
+ if retry_at.tzinfo is None:
307
+ retry_at = retry_at.replace(tzinfo=timezone.utc)
308
+ delay = (retry_at - datetime.now(timezone.utc)).total_seconds()
309
+ except (TypeError, ValueError, OverflowError):
310
+ delay = None
311
+ if delay is None:
312
+ delay = 2.0 * (2 ** attempt) + random.uniform(0.0, 0.5)
313
+ delay = max(1.0, min(30.0, delay))
314
+ logging.warning(
315
+ "Search rate-limit retry %s/%s in %.1fs: %s",
316
+ attempt + 1,
317
+ attempts,
318
+ delay,
319
+ url,
320
+ )
321
+ time.sleep(delay)
322
+ continue
323
+ if 500 <= status <= 599:
324
+ if attempt + 1 >= attempts:
325
+ response.raise_for_status()
326
+ delay = min(12.0, 1.5 * (2 ** attempt)) + random.uniform(0.0, 0.5)
327
+ logging.warning(
328
+ "Search server retry %s/%s after HTTP %s: %s",
329
+ attempt + 1,
330
+ attempts,
331
+ status,
332
+ url,
333
+ )
334
+ time.sleep(delay)
335
+ continue
336
+ response.raise_for_status()
337
+ return response
338
+
339
+ if last_error is not None:
340
+ raise last_error
341
+ raise requests.exceptions.RequestException("search_request_failed")
342
 
343
  def _is_english_dominant(self, text):
344
  if not text:
 
1401
  results = future.result()
1402
  with self.lock:
1403
  for res in results:
1404
+ result_keys = self._reference_keys(res)
1405
+ existing_keys = {
1406
+ key
1407
+ for metadata in self.citation_map.values()
1408
+ for key in self._reference_keys(metadata)
1409
+ }
1410
+ if result_keys and any(key in existing_keys for key in result_keys):
1411
+ continue
1412
+ cite_tag = f"[CITE-{len(self.citation_map)+1:02d}]"
1413
+ self.citation_map[cite_tag] = {
1414
+ "in_text": res["in_text"],
1415
+ "ref": res["ref"],
1416
+ "id": result_keys[0] if result_keys else ('title_year', res.get('title'), res.get('year')),
1417
+ "doi": res.get("doi", ""),
1418
+ "external_id": res.get("external_id", ""),
1419
+ "title": res.get("title", ""),
1420
+ "year": res.get("year", ""),
1421
+ "evidence": res.get("rag_text", ""),
1422
+ "sources": [res.get("source", "jit")],
1423
+ }
1424
+ jit_rag_texts.append(f"{cite_tag} {res.get('rag_text', '')}")
1425
 
1426
  logging.info(f"✅ [Pre-fetch JIT] 成功抓取并注入了 {len(jit_rag_texts)} 篇底层支撑文献。")
1427
  return "\n".join(jit_rag_texts)
 
1453
  clean_chunks = [c.strip() for c in re.findall(r'[a-zA-Z0-9\s\-]{4,}', f"{topic} {keywords}") if self._is_english_dominant(c.strip())]
1454
  return clean_chunks[:3] if clean_chunks else []
1455
 
1456
+ @staticmethod
1457
+ def _normalize_search_query(value):
1458
+ query = re.sub(r'["“”‘’()\[\]{}]', ' ', str(value or ''))
1459
+ query = re.sub(r'(?i)\b(?:AND|OR|NOT)\b', ' ', query)
1460
+ query = query.replace('-', ' ')
1461
+ query = re.sub(r'\s+', ' ', query).strip()
1462
+ terms = re.findall(r"[A-Za-z0-9][A-Za-z0-9']*", query)
1463
+ return ' '.join(terms[:8])
1464
+
1465
+ @staticmethod
1466
+ def _normalize_doi(value):
1467
+ doi = str(value or '').strip().lower()
1468
+ doi = re.sub(r'^https?://(?:dx\.)?doi\.org/', '', doi)
1469
+ return doi.strip()
1470
+
1471
+ @classmethod
1472
+ def _reference_keys(cls, result):
1473
+ keys = []
1474
+ doi = cls._normalize_doi(result.get('doi'))
1475
+ if doi:
1476
+ keys.append(('doi', doi))
1477
+ external_id = str(result.get('external_id') or '').strip().lower()
1478
+ if external_id:
1479
+ keys.append(('external', external_id))
1480
+ title = re.sub(r'[^a-z0-9]+', ' ', str(result.get('title') or '').lower()).strip()
1481
+ year = str(result.get('year') or '').strip()
1482
+ if title:
1483
+ keys.append(('title_year', title, year))
1484
+ return keys
1485
+
1486
+ def _record_source_error(self, source, code, status_code=None, detail=''):
1487
+ self._source_metric_add(source, 'error_count')
1488
+ self._source_metric_set(source, 'status', 'error')
1489
+ self._source_metric_set(source, 'last_status_code', status_code)
1490
+ safe = code if not detail else f"{code}: {self._safe_source_error(detail)}"
1491
+ self._source_metric_set(source, 'last_safe_error', safe[:300])
1492
+
1493
+ def _merge_reference_results(self, source_results, limit):
1494
+ """Merge deterministically while preserving each source's contribution.
1495
+
1496
+ ``merged_count`` means that a source is represented in the final merged
1497
+ evidence set, including overlap confirmed by both services.
1498
+ ``unique_added_count`` records which source first introduced a record.
1499
+ """
1500
+ merged = []
1501
+ key_to_index = {}
1502
+ source_lists = {source: list(source_results.get(source, [])) for source in ('s2', 'oa')}
1503
+ max_length = max((len(items) for items in source_lists.values()), default=0)
1504
+ for position in range(max_length):
1505
+ for source in ('s2', 'oa'):
1506
+ items = source_lists[source]
1507
+ if position >= len(items):
1508
+ continue
1509
+ result = items[position]
1510
+ keys = self._reference_keys(result)
1511
+ duplicate_index = next((key_to_index[k] for k in keys if k in key_to_index), None)
1512
+ if duplicate_index is not None:
1513
+ self._source_metric_add(source, 'duplicate_count')
1514
+ sources = merged[duplicate_index].setdefault('sources', [])
1515
+ if source not in sources:
1516
+ sources.append(source)
1517
+ self._source_metric_add(source, 'merged_count')
1518
+ continue
1519
+ if len(merged) >= limit:
1520
+ continue
1521
+ item = dict(result)
1522
+ item['sources'] = [source]
1523
+ merged.append(item)
1524
+ index = len(merged) - 1
1525
+ for key in keys:
1526
+ key_to_index[key] = index
1527
+ self._source_metric_add(source, 'merged_count')
1528
+ self._source_metric_add(source, 'unique_added_count')
1529
+ # The compact, approved UI displays successful eligible returns, not
1530
+ # source ownership after cross-source deduplication. Detailed overlap and
1531
+ # merge counts remain available in LITERATURE_METRICS.json.
1532
+ self.s2_calls = int(self.source_metrics['s2']['eligible_result_count'])
1533
+ self.oa_calls = int(self.source_metrics['oa']['eligible_result_count'])
1534
+ return merged
1535
+
1536
  def _s2_worker(self, term, offset):
1537
  self._check_cancelled()
1538
+ query = self._normalize_search_query(term)
1539
+ if not query:
1540
+ self._record_source_error('s2', 'S2_BAD_QUERY')
1541
+ return []
1542
+ headers = {'x-api-key': str(self.s2_key).strip()} if self.s2_key else {}
1543
+ self._source_metric_add('s2', 'request_count')
1544
+ self._source_metric_set('s2', 'status', 'running')
1545
+ logging.info('S2 search: term=%r normalized=%r offset=%s', term, query, offset)
1546
  try:
1547
+ response = self._requests_get_with_retry(
1548
  f"{self.s2_api_url}/paper/search",
1549
  headers=headers,
1550
  params={
1551
+ 'query': query,
1552
+ 'offset': offset,
1553
+ 'limit': 50,
1554
+ 'fields': 'paperId,title,authors,year,venue,abstract,externalIds',
1555
  },
1556
  )
1557
+ status = response.status_code
1558
+ self._source_metric_set('s2', 'last_status_code', status)
1559
+ if status in (401, 403):
1560
+ self._record_source_error('s2', 'S2_AUTH_ERROR', status)
1561
+ return []
1562
+ if status == 429:
1563
+ self._record_source_error('s2', 'S2_RATE_LIMIT', status)
1564
+ return []
1565
+ if status == 400:
1566
+ self._record_source_error('s2', 'S2_BAD_QUERY', status)
1567
+ return []
1568
+ response.raise_for_status()
1569
+ self._source_metric_add('s2', 'success_count')
1570
+ papers = response.json().get('data', [])
1571
+ self._source_metric_add('s2', 'raw_result_count', len(papers))
1572
+ results = []
1573
  for paper in papers:
1574
  self._check_cancelled()
1575
+ title = str(paper.get('title') or '').strip()
1576
+ year = paper.get('year')
1577
+ if not title or not year or not self._is_english_dominant(title):
1578
  continue
1579
+ authors = paper.get('authors') or []
1580
  last_names, formatted_authors = [], []
1581
  for author in authors:
1582
+ name = str(author.get('name') or '').strip()
1583
  if not name:
1584
  continue
1585
  parts = name.split()
1586
+ last = parts[-1]
1587
+ last_names.append(last)
1588
+ formatted_authors.append(f"{last}, {parts[0][0]}." if len(parts) > 1 else last)
 
 
 
1589
  if not last_names:
1590
  continue
1591
  if len(last_names) == 1:
 
1594
  in_text = f"({last_names[0]} & {last_names[1]}, {year})"
1595
  else:
1596
  in_text = f"({last_names[0]} et al., {year})"
1597
+ author_str = (', '.join(formatted_authors[:-1]) + ', & ' + formatted_authors[-1]) if len(formatted_authors) > 2 else (' & '.join(formatted_authors) if len(formatted_authors) == 2 else formatted_authors[0])
1598
+ doi = self._normalize_doi((paper.get('externalIds') or {}).get('DOI'))
1599
+ ref = f"{author_str} ({year}). {title}. {paper.get('venue') or 'Journal'}."
 
 
 
 
 
1600
  if doi:
1601
+ ref += f" https://doi.org/{doi}"
1602
+ abstract = ' '.join(str(paper.get('abstract') or '').split()[:180])
1603
  results.append({
1604
+ 'title': title,
1605
+ 'year': str(year),
1606
+ 'doi': doi,
1607
+ 'external_id': str(paper.get('paperId') or ''),
1608
+ 'in_text': in_text,
1609
+ 'ref': ref,
1610
+ 'rag_text': f"Title: {title}. Abstract evidence: {abstract}",
1611
+ 'source': 's2',
1612
  })
1613
+ self._source_metric_add('s2', 'eligible_result_count', len(results))
1614
+ self._source_metric_set('s2', 'status', 'ok' if results else 'no_results')
1615
+ if not results:
1616
+ self._source_metric_set('s2', 'last_safe_error', 'S2_NO_RESULTS')
1617
+ return results
1618
  except PipelineCancelledError:
1619
  raise
1620
+ except requests.Timeout as error:
1621
+ self._record_source_error('s2', 'S2_TIMEOUT', detail=error)
1622
  except Exception as error:
1623
+ status = getattr(getattr(error, 'response', None), 'status_code', None)
1624
+ code = 'S2_RATE_LIMIT' if status == 429 else ('S2_AUTH_ERROR' if status in (401, 403) else 'S2_REQUEST_ERROR')
1625
+ self._record_source_error('s2', code, status, error)
1626
+ return []
 
 
 
 
1627
 
1628
  def _oa_worker(self, term, page, filter_str):
1629
  self._check_cancelled()
1630
+ query = self._normalize_search_query(term)
1631
+ if not query:
1632
+ self._record_source_error('oa', 'OPENALEX_BAD_QUERY')
1633
+ return []
1634
+ user_agent = 'SmartPaper/production-v1.1'
1635
+ if self.openalex_email:
1636
+ user_agent += f' (mailto:{self.openalex_email})'
1637
+ headers = {'User-Agent': user_agent}
1638
  if self.openalex_key:
1639
+ headers['Authorization'] = f"Bearer {self.openalex_key}"
1640
+ self._source_metric_add('oa', 'request_count')
1641
+ self._source_metric_set('oa', 'status', 'running')
1642
+ logging.info('OpenAlex search: term=%r normalized=%r page=%s', term, query, page)
1643
  try:
1644
+ response = self._requests_get_with_retry(
 
 
 
 
 
 
 
 
1645
  self.openalex_api_url,
1646
  headers=headers,
1647
+ params={
1648
+ **{
1649
+ 'search': query,
1650
+ 'per-page': self.lit_per_page,
1651
+ 'page': page,
1652
+ 'filter': filter_str,
1653
+ },
1654
+ **({'mailto': self.openalex_email} if self.openalex_email else {}),
1655
+ },
1656
  )
1657
+ status = response.status_code
1658
+ self._source_metric_set('oa', 'last_status_code', status)
1659
+ if status in (401, 403):
1660
+ self._record_source_error('oa', 'OPENALEX_AUTH_ERROR', status)
1661
+ return []
1662
+ if status == 429:
1663
+ self._record_source_error('oa', 'OPENALEX_RATE_LIMIT', status)
1664
+ return []
1665
+ if status == 400:
1666
+ self._record_source_error('oa', 'OPENALEX_BAD_QUERY', status)
1667
+ return []
1668
+ response.raise_for_status()
1669
+ self._source_metric_add('oa', 'success_count')
1670
+ raw_items = response.json().get('results', [])
1671
+ self._source_metric_add('oa', 'raw_result_count', len(raw_items))
1672
+ results = []
1673
  for item in raw_items:
1674
  self._check_cancelled()
1675
+ title = str(item.get('title') or '').strip()
1676
  if not title or not self._is_english_dominant(title):
1677
  continue
1678
+ year = item.get('publication_year') or 'n.d.'
1679
  formatted_authors, last_names = [], []
1680
+ for authorship in item.get('authorships') or []:
1681
+ name = str((authorship.get('author') or {}).get('display_name') or '').strip()
1682
+ if not name:
1683
  continue
1684
+ parts = name.split(); last = parts[-1]
1685
+ last_names.append(last)
1686
+ formatted_authors.append(f"{last}, {parts[0][0]}." if len(parts) > 1 else last)
 
 
 
 
1687
  if not formatted_authors:
1688
  continue
1689
  if len(last_names) == 1:
 
1692
  in_text = f"({last_names[0]} & {last_names[1]}, {year})"
1693
  else:
1694
  in_text = f"({last_names[0]} et al., {year})"
1695
+ author_str = (', '.join(formatted_authors[:-1]) + ', & ' + formatted_authors[-1]) if len(formatted_authors) > 2 else (' & '.join(formatted_authors) if len(formatted_authors) == 2 else formatted_authors[0])
1696
+ doi = self._normalize_doi(item.get('doi'))
1697
+ venue = (((item.get('primary_location') or {}).get('source') or {}).get('display_name') or 'Journal')
1698
+ ref = f"{author_str} ({year}). {title}. {venue}."
1699
+ if doi:
1700
+ ref += f" https://doi.org/{doi}"
1701
+ inverted = item.get('abstract_inverted_index') or {}
1702
+ words = [word for position, word in sorted((position, word) for word, positions in inverted.items() for position in positions)]
1703
+ abstract = ' '.join(words[:180])
 
 
 
 
 
 
 
 
 
 
 
1704
  results.append({
1705
+ 'title': title,
1706
+ 'year': str(year),
1707
+ 'doi': doi,
1708
+ 'external_id': str(item.get('id') or ''),
1709
+ 'in_text': in_text,
1710
+ 'ref': ref,
1711
+ 'rag_text': f"Title: {title}. Abstract evidence: {abstract}",
1712
+ 'source': 'oa',
1713
  })
1714
+ self._source_metric_add('oa', 'eligible_result_count', len(results))
1715
+ self._source_metric_set('oa', 'status', 'ok' if results else 'no_results')
1716
+ if not results:
1717
+ self._source_metric_set('oa', 'last_safe_error', 'OPENALEX_NO_RESULTS')
1718
+ return results
1719
  except PipelineCancelledError:
1720
  raise
1721
+ except requests.Timeout as error:
1722
+ self._record_source_error('oa', 'OPENALEX_TIMEOUT', detail=error)
1723
  except Exception as error:
1724
+ status = getattr(getattr(error, 'response', None), 'status_code', None)
1725
+ if status == 429:
1726
+ code = 'OPENALEX_RATE_LIMIT'
1727
+ elif status in (401, 403):
1728
+ code = 'OPENALEX_AUTH_ERROR'
1729
+ elif status == 400:
1730
+ code = 'OPENALEX_BAD_QUERY'
1731
+ else:
1732
+ code = 'OPENALEX_REQUEST_ERROR'
1733
+ self._record_source_error('oa', code, status, error)
1734
+ return []
1735
 
1736
  def fetch_references_parallel(self, topic, keywords, min_required=50, progress_callback=None):
1737
+ """Retrieve adaptively, then merge deterministically by DOI/ID/title-year.
1738
+
1739
+ Each phase first requests one page per source and query. A second page is
1740
+ requested only when the minimum evidence set is still not met. This keeps
1741
+ the interface responsive and avoids redundant OpenAlex filter pairs.
1742
+ """
1743
  with self.lock:
1744
  self.citation_map.clear()
1745
  self.used_citations.clear()
1746
+ self.citation_audit_log.clear()
1747
+ for source in ('s2', 'oa'):
1748
  for field in (
1749
+ 'request_count','success_count','raw_result_count',
1750
+ 'eligible_result_count','merged_count','unique_added_count',
1751
+ 'duplicate_count','error_count',
1752
  ):
1753
  self.source_metrics[source][field] = 0
1754
+ self.source_metrics[source]['last_status_code'] = None
1755
+ self.source_metrics[source]['last_safe_error'] = ''
1756
+ self.source_metrics[source]['status'] = 'not_started'
1757
+ self.s2_calls = self.oa_calls = 0
1758
 
 
 
1759
  all_used_terms = set()
1760
+ collected = {'s2': [], 'oa': []}
1761
+ basic_filter = 'language:en,type:article,is_paratext:false'
1762
+
1763
+ def unique_preview_count():
1764
+ keys_seen = set()
1765
+ count = 0
1766
+ for source in ('s2', 'oa'):
1767
+ for item in collected[source]:
1768
+ keys = self._reference_keys(item)
1769
+ if keys and any(key in keys_seen for key in keys):
1770
+ continue
1771
+ keys_seen.update(keys)
1772
+ count += 1
1773
+ return count
1774
+
1775
+ def run_wave(terms, *, second_page=False):
1776
+ future_sources = {}
1777
+ with concurrent.futures.ThreadPoolExecutor(max_workers=self.lit_max_workers) as executor:
1778
+ if not self.s2_fused:
1779
+ offset = 50 if second_page else 0
1780
+ for term in terms:
1781
+ future_sources[executor.submit(self._s2_worker, term, offset)] = 's2'
1782
+ if not self.oa_fused:
1783
+ page = 2 if second_page else 1
1784
+ for term in terms:
1785
+ future_sources[executor.submit(self._oa_worker, term, page, basic_filter)] = 'oa'
1786
+ for future in concurrent.futures.as_completed(future_sources):
1787
+ self._check_cancelled()
1788
+ source = future_sources[future]
1789
+ collected[source].extend(future.result())
1790
+ if progress_callback:
1791
+ progress_callback()
1792
 
1793
+ for phase in range(3):
1794
  self._check_cancelled()
1795
+ if phase == 0:
1796
+ terms = self.generate_search_queries(topic, keywords)
 
 
1797
  else:
 
 
 
 
 
1798
  response = self.call_deepseek(
1799
+ 'You are an academic librarian. Return only a JSON array with three new concise English search queries, each under five terms.',
1800
+ f"Topic: {topic}\nKeywords: {keywords}\nUsed: {sorted(all_used_terms)}",
1801
+ temp=0.4,
1802
+ model_role='fast',
1803
  )
1804
+ parsed = self._extract_json_from_text(response)
1805
+ terms = parsed if isinstance(parsed, list) else []
1806
+
1807
+ normalized_terms = []
1808
+ for value in terms:
1809
+ term = self._normalize_search_query(value)
1810
+ if term and self._is_english_dominant(term) and term not in all_used_terms:
1811
+ normalized_terms.append(term)
1812
+ all_used_terms.add(term)
1813
+ if not normalized_terms:
 
 
 
1814
  continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1815
 
1816
+ run_wave(normalized_terms, second_page=False)
1817
+ if unique_preview_count() >= min_required:
1818
+ break
1819
+ run_wave(normalized_terms, second_page=True)
1820
+ if unique_preview_count() >= min_required:
1821
+ break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1822
 
1823
+ merge_limit = max(min_required, min(self.lit_target_papers, min_required + 30))
1824
+ merged = self._merge_reference_results(collected, merge_limit)
1825
+ with self.lock:
1826
+ for index, result in enumerate(merged, start=1):
1827
+ tag = f"[CITE-{index:02d}]"
1828
+ result_keys = self._reference_keys(result)
1829
+ self.citation_map[tag] = {
1830
+ 'in_text': result['in_text'],
1831
+ 'ref': result['ref'],
1832
+ 'id': result_keys[0] if result_keys else ('title_year', result.get('title'), result.get('year')),
1833
+ 'doi': result.get('doi',''),
1834
+ 'external_id': result.get('external_id',''),
1835
+ 'title': result.get('title',''),
1836
+ 'year': result.get('year',''),
1837
+ 'evidence': result.get('rag_text',''),
1838
+ 'sources': list(result.get('sources') or [result.get('source')]),
1839
+ }
1840
+ self.s2_calls = int(self.source_metrics['s2']['eligible_result_count'])
1841
+ self.oa_calls = int(self.source_metrics['oa']['eligible_result_count'])
1842
+ for source, no_result_code in (('s2', 'S2_NO_RESULTS'), ('oa', 'OPENALEX_NO_RESULTS')):
1843
+ metric = self.source_metrics[source]
1844
+ eligible = int(metric.get('eligible_result_count', 0))
1845
+ errors = int(metric.get('error_count', 0))
1846
+ if eligible > 0 and errors > 0:
1847
+ metric['status'] = 'partial'
1848
+ elif eligible > 0:
1849
+ metric['status'] = 'ok'
1850
+ metric['last_safe_error'] = ''
1851
+ elif errors > 0:
1852
+ metric['status'] = 'error'
1853
+ else:
1854
+ metric['status'] = 'no_results'
1855
+ metric['last_safe_error'] = no_result_code
1856
  logging.info(
1857
+ 'Reference search completed: unique=%s metrics=%s',
1858
  len(self.citation_map),
1859
  self.get_source_metrics(),
1860
  )
1861
+ return '\n'.join(
1862
+ f"{tag} {meta['evidence']}" for tag, meta in self.citation_map.items()
1863
+ )
1864
 
1865
  @staticmethod
1866
  def _citation_tag_number(tag):
 
1993
  text,
1994
  section_name="",
1995
  ):
1996
+ """Audit cited sentences against retrieved evidence at sentence scope.
1997
 
1998
  The audit may split a sentence when different sources support different
1999
  claims, or keep one source when several sources support the same claim.
2000
  It may not invent facts, numbers, quotations, or citation tags. A cited
2001
+ claim that cannot be supported is removed from the manuscript and logged;
2002
+ one unsupported sentence must not discard an otherwise valid paper.
2003
  """
2004
  if not text:
2005
  return text
 
2120
  self._safe_source_error(error),
2121
  )
2122
  if last_error is not None:
 
 
2123
  raise CitationAuditError(
2124
  "E_CITATION_AUDIT_TIMEOUT",
2125
  self._safe_source_error(last_error),
 
2186
  f"Invalid citation audit status for {record['id']}: {status!r}"
2187
  )
2188
  if status == "unsupported":
2189
+ reason = str(item.get("reason", ""))[:500]
2190
  unsupported.append(
2191
  {
2192
  "id": record["id"],
2193
  "sentence": record["original"][:500],
2194
+ "reason": reason,
2195
  }
2196
  )
2197
+ # An unsupported externally verifiable claim cannot remain in
2198
+ # the manuscript with an arbitrary citation. Remove only this
2199
+ # sentence and retain an auditable record of the decision.
2200
+ replacements[record["id"]] = ""
2201
+ self._append_citation_audit(
2202
+ section_name,
2203
+ "remove_unsupported_claim",
2204
+ record["original"],
2205
+ "",
2206
+ reason or "No candidate evidence directly supported the claim.",
2207
+ )
2208
  continue
2209
  if not revised:
2210
  raise DeepSeekRequestError(
 
2241
  replacements[record["id"]] = revised
2242
 
2243
  if unsupported:
2244
+ logging.warning(
2245
+ "Citation audit removed unsupported claims: section=%s count=%s examples=%s",
2246
+ section_name,
2247
+ len(unsupported),
2248
+ json.dumps(unsupported[:3], ensure_ascii=False),
 
2249
  )
2250
 
2251
  rebuilt = []
 
2256
  cursor = record["end"]
2257
  rebuilt.append(str(text)[cursor:])
2258
  audited = "".join(rebuilt)
2259
+ audited = re.sub(r"[ \t]+\n", "\n", audited)
2260
+ audited = re.sub(r"\n{3,}", "\n\n", audited).strip()
2261
  violations = self.find_multi_citation_violations(audited)
2262
  if violations:
2263
  raise DeepSeekRequestError(
 
2271
  )
2272
  return audited
2273
 
2274
+ def _append_citation_audit(self, section_name, action, original, revised='', detail=''):
2275
+ self.citation_audit_log.append({
2276
+ 'section': str(section_name),
2277
+ 'action': str(action),
2278
+ 'original': str(original)[:800],
2279
+ 'revised': str(revised)[:800],
2280
+ 'detail': str(detail)[:500],
2281
+ })
2282
+
2283
+ def _citation_support_score(self, sentence, tag):
2284
+ metadata = self.citation_map.get(tag) or {}
2285
+ claim_text = re.sub(r"\[?CITE-\d+\]?", " ", str(sentence), flags=re.I)
2286
+ claim_terms = self._citation_terms(claim_text)
2287
+ evidence_text = " ".join(
2288
+ [str(metadata.get("ref", "")), str(metadata.get("evidence", ""))]
2289
+ )
2290
+ evidence_terms = self._citation_terms(evidence_text)
2291
+ overlap = len(claim_terms & evidence_terms)
2292
+ coverage = overlap / max(1, len(claim_terms))
2293
+ return coverage, overlap
2294
+
2295
+ def _local_citation_recovery(self, text, section_name=''):
2296
+ """Conservative deterministic recovery for technical audit failures.
2297
+
2298
+ This routine never keeps the first citation merely because it appeared
2299
+ first. A source is retained only when lexical evidence is sufficiently
2300
+ strong; otherwise the externally verifiable sentence is removed and the
2301
+ decision is written to the citation audit log.
2302
+ """
2303
+ rebuilt = []
2304
+ cursor = 0
2305
+ for start, end, sentence in self._sentence_spans(text):
2306
+ rebuilt.append(str(text)[cursor:start])
2307
+ original = sentence
2308
+ tags = [f"[{tag.upper()}]" for tag in re.findall(r'CITE-\d+', sentence, re.I)]
2309
+ unique_tags = list(dict.fromkeys(tags))
2310
+ valid = [tag for tag in unique_tags if tag in self.citation_map]
2311
+ revised = sentence
2312
+
2313
+ if unique_tags and not valid:
2314
+ revised = re.sub(r'\s*[\[(]?CITE-\d+[\])]?\s*', ' ', sentence, flags=re.I)
2315
+ revised = re.sub(r'\s+', ' ', revised).strip()
2316
+ self._append_citation_audit(
2317
+ section_name,
2318
+ 'remove_unknown_tag',
2319
+ original,
2320
+ revised,
2321
+ 'No valid citation tag was available.',
2322
+ )
2323
+ elif len(valid) == 1:
2324
+ selected_tag = valid[0]
2325
+ coverage, overlap = self._citation_support_score(sentence, selected_tag)
2326
+ if overlap >= 2 and coverage >= 0.12:
2327
+ self._append_citation_audit(
2328
+ section_name,
2329
+ 'retain_lexically_supported_source',
2330
+ original,
2331
+ sentence,
2332
+ f'Lexical support overlap={overlap}, coverage={coverage:.3f}.',
2333
+ )
2334
+ else:
2335
+ revised = ''
2336
+ self._append_citation_audit(
2337
+ section_name,
2338
+ 'remove_unverified_claim',
2339
+ original,
2340
+ '',
2341
+ f'The single candidate did not meet the conservative support threshold; overlap={overlap}, coverage={coverage:.3f}.',
2342
+ )
2343
+ elif len(valid) > 1:
2344
+ scored = sorted(
2345
+ (
2346
+ (*self._citation_support_score(sentence, tag), -self._citation_tag_number(tag), tag)
2347
+ for tag in valid
2348
+ ),
2349
+ reverse=True,
2350
+ )
2351
+ coverage, overlap, _, selected_tag = scored[0]
2352
+ if overlap >= 2 and coverage >= 0.12:
2353
+ revised = re.sub(
2354
+ r'\s*[\[(]?CITE-\d+(?:\s*[,;]\s*CITE-\d+)*[\])]?\s*',
2355
+ ' ',
2356
+ sentence,
2357
+ flags=re.I,
2358
+ )
2359
+ revised = re.sub(r'\s+', ' ', revised).strip()
2360
+ punctuation = revised[-1] if revised and revised[-1] in '.!?。!?' else ''
2361
+ if punctuation:
2362
+ revised = revised[:-1].rstrip()
2363
+ revised = f"{revised} {selected_tag}{punctuation}"
2364
+ self._append_citation_audit(
2365
+ section_name,
2366
+ 'select_evidence_supported_source',
2367
+ original,
2368
+ revised,
2369
+ f'Lexical support overlap={overlap}, coverage={coverage:.3f}.',
2370
+ )
2371
+ else:
2372
+ revised = ''
2373
+ self._append_citation_audit(
2374
+ section_name,
2375
+ 'remove_unverified_claim',
2376
+ original,
2377
+ '',
2378
+ f'No candidate met the conservative support threshold; best overlap={overlap}, coverage={coverage:.3f}.',
2379
+ )
2380
+ rebuilt.append(revised)
2381
+ cursor = end
2382
+ rebuilt.append(str(text)[cursor:])
2383
+ recovered = ''.join(rebuilt)
2384
+ recovered = re.sub(r'[ \t]+\n', '\n', recovered)
2385
+ return re.sub(r'\n{3,}', '\n\n', recovered).strip()
2386
+
2387
+ def audit_and_resolve_citations(self, text, section_name='', quarantine=False):
2388
+ value = str(text or '')
2389
+ if quarantine:
2390
+ return self.resolve_citations(value, quarantine=True)
2391
+ unknown = sorted({f"[{tag.upper()}]" for tag in re.findall(r'CITE-\d+', value, re.I)} - set(self.citation_map))
2392
+ if unknown:
2393
+ for tag in unknown:
2394
+ value = re.sub(rf'\s*\[?{re.escape(tag.strip("[]"))}\]?\s*', ' ', value, flags=re.I)
2395
+ self._append_citation_audit(section_name, 'remove_unknown_tags', text, value, ','.join(unknown))
2396
+ has_citations = bool(re.search(r'CITE-\d+', value, re.I))
2397
+ violations = self.find_multi_citation_violations(value)
2398
+ if has_citations and self.citation_semantic_audit.get('enabled', True):
2399
+ try:
2400
+ value = self.semantic_audit_claim_specific_citations(value, section_name=section_name)
2401
+ except PipelineCancelledError:
2402
+ raise
2403
+ except CitationAuditError as error:
2404
+ logging.warning(
2405
+ 'Citation semantic audit technical fallback: section=%s code=%s detail=%s',
2406
+ section_name,
2407
+ error.code,
2408
+ error.safe_detail,
2409
+ )
2410
+ value = self._local_citation_recovery(value, section_name)
2411
+ except Exception as error:
2412
+ logging.warning(
2413
+ 'Citation semantic audit fallback: section=%s error=%s',
2414
+ section_name,
2415
+ self._safe_source_error(error),
2416
+ )
2417
+ value = self._local_citation_recovery(value, section_name)
2418
+ elif violations:
2419
+ value = self._local_citation_recovery(value, section_name)
2420
+ if self.find_multi_citation_violations(value):
2421
+ value = self._local_citation_recovery(value, section_name)
2422
+ if self.find_multi_citation_violations(value):
2423
+ raise DeepSeekRequestError(
2424
+ f'Citation recovery could not resolve all violations in {section_name}.'
2425
+ )
2426
+ return self.resolve_citations(value, quarantine=False)
2427
+
2428
+ @staticmethod
2429
+ def _remove_ai_meta_traces(text):
2430
+ patterns = [
2431
+ r'(?im)^.*\bas an ai(?: language model)?\b.*(?:\n|$)',
2432
+ r'(?im)^.*\bai[- ]generated\b.*(?:\n|$)',
2433
+ r'(?im)^.*\bthis response\b.*(?:\n|$)',
2434
+ r'(?im)^.*\bi cannot (?:browse|access|verify)\b.*(?:\n|$)',
2435
+ ]
2436
+ value = str(text or '')
2437
+ for pattern in patterns:
2438
+ value = re.sub(pattern, '', value)
2439
+ return re.sub(r'\n{3,}', '\n\n', value).strip()
2440
+
2441
+ def polish_academic_section(self, text, section_name='', abstract=False):
2442
+ value = self._remove_ai_meta_traces(text)
2443
+ quality = APP_CONFIG.get('manuscript_quality', {})
2444
+ forbidden = quality.get('forbidden_phrases', [])
2445
+ detected = [phrase for phrase in forbidden if re.search(rf'\b{re.escape(phrase)}\b', value, re.I)]
2446
+ transitions = re.findall(r'(?im)^(Firstly|Secondly|Moreover|Furthermore|In conclusion|To summarize)\b', value)
2447
+ numeric_count = len(re.findall(r'(?<![A-Za-z])[-+]?\d+(?:\.\d+)?%?', value)) if abstract else 0
2448
+ max_numbers = int(quality.get('abstract_max_numeric_mentions', 6))
2449
+ needs_revision = bool(detected or transitions or (abstract and numeric_count > max_numbers))
2450
+ if not needs_revision:
2451
+ return value
2452
+ numeric_rule = (
2453
+ "For the abstract, remove nonessential numerical details. You may omit numbers, "
2454
+ "but do not alter any number that remains and do not add a new number."
2455
+ if abstract
2456
+ else "Preserve every number, percentage, coefficient, p-value, and confidence interval exactly."
2457
+ )
2458
+ 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}"""
2459
+ revised = self.call_deepseek(
2460
+ 'You are a meticulous academic language editor. Output only the revised section.',
2461
+ prompt,
2462
+ max_tokens=max(1200, min(8000, self._count_tokens(value) + 1200)),
2463
+ temp=0.1,
2464
+ model_role='writing',
2465
+ thinking=False,
2466
+ allow_fallback=False,
2467
+ )
2468
+ revised = self._remove_ai_meta_traces(revised)
2469
+ original_numbers = self._numeric_tokens(value)
2470
+ revised_numbers = self._numeric_tokens(revised)
2471
+ if abstract:
2472
+ added_or_changed = any(
2473
+ count > original_numbers.get(token, 0)
2474
+ for token, count in revised_numbers.items()
2475
+ )
2476
+ if added_or_changed:
2477
+ logging.warning('Abstract quality revision rejected because a number was added or changed: %s', section_name)
2478
+ return value
2479
+ if sum(revised_numbers.values()) > max_numbers:
2480
+ logging.warning('Abstract quality revision rejected because numerical density remains excessive: %s', section_name)
2481
+ return value
2482
+ elif original_numbers != revised_numbers:
2483
+ logging.warning('Quality revision rejected because numbers changed: %s', section_name)
2484
+ return value
2485
+ original_tags = Counter(re.findall(r'CITE-\d+', value, re.I))
2486
+ revised_tags = Counter(re.findall(r'CITE-\d+', revised, re.I))
2487
+ if original_tags != revised_tags:
2488
+ logging.warning('Quality revision rejected because citation tags changed: %s', section_name)
2489
+ return value
2490
+ return revised.strip()
2491
+
2492
+ @staticmethod
2493
+ def _trim_at_sentence_boundary(value, max_words):
2494
+ text = str(value or '').strip()
2495
+ words = text.split()
2496
+ if len(words) <= max_words:
2497
+ return text
2498
+ sentences = re.split(r'(?<=[.!?])\s+', text)
2499
+ selected = []
2500
+ count = 0
2501
+ for sentence in sentences:
2502
+ sentence_words = sentence.split()
2503
+ if not sentence_words:
2504
+ continue
2505
+ if count + len(sentence_words) > max_words:
2506
+ break
2507
+ selected.append(sentence.strip())
2508
+ count += len(sentence_words)
2509
+ if selected:
2510
+ return ' '.join(selected).strip()
2511
+ return ' '.join(words[:max_words]).rstrip(' ,;:') + '.'
2512
+
2513
+ def _abstract_manuscript_context(self, manuscript, max_tokens=18000):
2514
+ value = str(manuscript or '').strip()
2515
+ if self._count_tokens(value) <= max_tokens:
2516
+ return value
2517
+ matches = list(re.finditer(r'(?m)^#\s+(.+?)\s*$', value))
2518
+ if not matches:
2519
+ head = self._truncate_context(value, max_tokens // 3)
2520
+ tail_tokens = self.tokenizer.encode(value)[-(max_tokens - self._count_tokens(head)):]
2521
+ return head + '\n\n' + self.tokenizer.decode(tail_tokens)
2522
+
2523
+ sections = []
2524
+ preamble = value[:matches[0].start()].strip()
2525
+ if preamble:
2526
+ sections.append(('preamble', preamble))
2527
+ for index, match in enumerate(matches):
2528
+ end = matches[index + 1].start() if index + 1 < len(matches) else len(value)
2529
+ sections.append((match.group(1).strip().lower(), value[match.start():end].strip()))
2530
+
2531
+ categories = (
2532
+ (('method', 'research design', 'data', 'sample'), 3000),
2533
+ (('result', 'finding', 'analysis'), 6500),
2534
+ (('discussion', 'implication'), 4000),
2535
+ (('conclusion',), 2500),
2536
+ (('introduction', 'background'), 1800),
2537
+ )
2538
+ selected = []
2539
+ used = set()
2540
+ for keywords, budget in categories:
2541
+ candidates = [
2542
+ (index, text) for index, (title, text) in enumerate(sections)
2543
+ if index not in used and any(keyword in title for keyword in keywords)
2544
+ ]
2545
+ for index, text in candidates:
2546
+ selected.append(self._truncate_context(text, budget))
2547
+ used.add(index)
2548
+ break
2549
+ if not selected:
2550
+ return self._truncate_context(value, max_tokens)
2551
+ combined = '\n\n'.join(selected)
2552
+ return self._truncate_context(combined, max_tokens)
2553
+
2554
+ def generate_final_abstract(self, topic, keywords, manuscript, data_evidence='', mode='amj'):
2555
+ evidence = self._truncate_context(str(data_evidence or ''), 5000)
2556
+ body = self._abstract_manuscript_context(manuscript, 18000)
2557
+ prompt = f"""Write the final English abstract for this completed {mode.upper()} manuscript. Maximum 200 words. State the research problem, design, main finding, and contribution. Use no citations. Include only the minimum numerical evidence required; do not list coefficients, p-values, and fit statistics unless one value is indispensable. Use concise, accurate, rigorous academic language. Do not use metaphors, analogies, rhetorical questions, promotional language, formulaic transitions, or AI-related meta-commentary. Output only the abstract.\nTOPIC: {topic}\nKEYWORDS: {keywords}\nFROZEN EVIDENCE:\n{evidence}\nCOMPLETED MANUSCRIPT:\n{body}"""
2558
+ abstract = self.call_deepseek(
2559
+ 'You are a senior journal editor.', prompt, max_tokens=900, temp=0.1,
2560
+ model_role='writing', thinking=False, allow_fallback=False,
2561
+ )
2562
+ abstract = self._remove_ai_meta_traces(abstract)
2563
+ abstract = re.sub(r'(?im)^#+\s*abstract\s*', '', abstract).strip()
2564
+ abstract = self.polish_academic_section(abstract, 'Abstract', abstract=True)
2565
+ max_words = max(80, int(APP_CONFIG.get('manuscript_quality', {}).get('abstract_max_words', 200)))
2566
+ return self._trim_at_sentence_boundary(abstract, max_words)
2567
+
2568
+ @staticmethod
2569
+ def _translation_language_rule(target_lang):
2570
+ if 'Russian' in target_lang:
2571
+ return 'Use idiomatic academic Russian with correct case government, gender-number agreement, aspect, punctuation, and terminology. Reorganize English syntax where necessary and avoid literal English calques.'
2572
+ if 'Kazakh' in target_lang:
2573
+ return 'Use idiomatic academic Kazakh with correct case and possessive suffixes, person/plural endings, vowel harmony, consonant assimilation, natural word order, and correct Kazakh-specific letters. Avoid Russian syntactic calques.'
2574
+ return 'Use formal academic Simplified Chinese with explicit concepts, natural Chinese syntax, clear hierarchy, and logically connected claims. Avoid carrying English nominalization and clause order into Chinese mechanically.'
2575
+
2576
+ @staticmethod
2577
+ def _translation_prompt(target_lang):
2578
+ common = """Translate the supplied blocks from the locked English manuscript faithfully. Preserve every marker, citation, number, percentage, coefficient, p-value, confidence interval, DOI, hypothesis label, variable abbreviation, quotation meaning, and conclusion direction. Do not add or remove claims. Use concise, accurate, rigorous academic language. Avoid metaphors, analogies, rhetorical questions, promotional language, formulaic transitions, and mechanical repetition. Return every marker exactly once and in the same order. Output only the marked translations."""
2579
+ return common + ' ' + SSciPipelineAgent._translation_language_rule(target_lang)
2580
+
2581
+ @staticmethod
2582
+ def _localized_reference_heading(target_lang):
2583
+ if 'Russian' in target_lang:
2584
+ return 'Литература'
2585
+ if 'Kazakh' in target_lang:
2586
+ return 'Пайдаланылған әдебиеттер'
2587
+ return '参考文献'
2588
+
2589
+ @staticmethod
2590
+ def _markdown_heading_tree(value):
2591
+ return [
2592
+ (len(match.group(1)), match.group(2).strip())
2593
+ for match in re.finditer(r'(?m)^(#{1,3})\s+(.+?)\s*$', str(value or ''))
2594
+ ]
2595
+
2596
+ @staticmethod
2597
+ def _translation_hypothesis_tokens(value):
2598
+ return Counter(re.findall(r'\bH\d+[A-Za-z]?\b', str(value or ''), flags=re.I))
2599
+
2600
+ @staticmethod
2601
+ def _translation_acronym_tokens(value):
2602
+ without_doi = re.sub(r'https?://\S+|doi:\s*\S+', ' ', str(value or ''), flags=re.I)
2603
+ return Counter(re.findall(r'\b[A-Z][A-Z0-9-]{1,11}\b', without_doi))
2604
+
2605
+ @staticmethod
2606
+ def _translation_citation_tokens(value):
2607
+ return Counter(
2608
+ re.findall(
2609
+ r'\([^()\n]*?(?:19|20)\d{2}[a-z]?[^()\n]*?\)',
2610
+ str(value or ''),
2611
+ flags=re.I,
2612
+ )
2613
+ )
2614
+
2615
+ @staticmethod
2616
+ def _translation_body_units(body):
2617
+ units = []
2618
+ buffer = []
2619
+
2620
+ def flush_buffer():
2621
+ if not buffer:
2622
+ return
2623
+ segment = '\n'.join(buffer).strip()
2624
+ buffer.clear()
2625
+ if not segment:
2626
+ return
2627
+ for part in re.split(r'\n{2,}', segment):
2628
+ part = part.strip()
2629
+ if part:
2630
+ units.append({'kind': 'body', 'text': part})
2631
+
2632
+ for line in str(body or '').splitlines():
2633
+ heading = re.match(r'^(#{1,3})\s+(.+?)\s*$', line)
2634
+ if heading:
2635
+ flush_buffer()
2636
+ units.append({
2637
+ 'kind': 'heading',
2638
+ 'level': len(heading.group(1)),
2639
+ 'text': heading.group(2).strip(),
2640
+ })
2641
+ else:
2642
+ buffer.append(line)
2643
+ flush_buffer()
2644
+ return units
2645
+
2646
+ def _build_translation_glossary(self, body, target_lang):
2647
+ headings = [title for _, title in self._markdown_heading_tree(body)]
2648
+ acronyms = sorted(self._translation_acronym_tokens(body))
2649
+ phrases = re.findall(
2650
+ r'\b(?:[A-Z][a-z]+(?:[- ][A-Z][a-z]+){1,3})\b',
2651
+ str(body or ''),
2652
+ )
2653
+ phrase_counts = Counter(phrases)
2654
+ term_limit = max(10, int(APP_CONFIG.get('translation_glossary_term_limit', 40)))
2655
+ recurring = [term for term, count in phrase_counts.most_common() if count >= 2]
2656
+ unique_headings = list(dict.fromkeys(headings))
2657
+ extras = [term for term in list(dict.fromkeys(recurring + acronyms)) if term not in unique_headings]
2658
+ sources = unique_headings + extras[:max(0, term_limit - len(unique_headings))]
2659
+ if not sources:
2660
+ return {}
2661
+
2662
+ language_rule = self._translation_language_rule(target_lang)
2663
+ payload = json.dumps({'terms': sources}, ensure_ascii=False)
2664
+ system_prompt = f"""Create one consistent academic terminology glossary for translation into {target_lang}. {language_rule}
2665
+ Return valid JSON only in this exact form: {{"terms":[{{"source":"...","target":"..."}}]}}.
2666
+ Translate headings and terminology naturally. Preserve standard variable abbreviations and acronyms when they should remain unchanged. Return every supplied source exactly once."""
2667
+ response = self.call_deepseek(
2668
+ system_prompt,
2669
+ payload,
2670
+ max_tokens=max(1600, min(6000, len(sources) * 80)),
2671
+ temp=0.0,
2672
+ model_role='translation',
2673
+ thinking=False,
2674
+ allow_fallback=False,
2675
+ )
2676
+ parsed = self._extract_json_from_text(response)
2677
+ items = parsed.get('terms', []) if isinstance(parsed, dict) else []
2678
+ mapping = {
2679
+ str(item.get('source') or '').strip(): str(item.get('target') or '').strip()
2680
+ for item in items
2681
+ if isinstance(item, dict)
2682
+ }
2683
+ missing_headings = [heading for heading in headings if not mapping.get(heading)]
2684
+ if missing_headings:
2685
+ raise DeepSeekRequestError(
2686
+ 'Translation glossary omitted heading(s): ' + ', '.join(missing_headings[:5])
2687
+ )
2688
+ for acronym in acronyms:
2689
+ mapping.setdefault(acronym, acronym)
2690
+ return mapping
2691
+
2692
+ @staticmethod
2693
+ def _parse_marked_translation(value, expected_ids):
2694
+ pattern = re.compile(r'(?m)^\[\[SP-P(\d{6})\]\]\s*$')
2695
+ matches = list(pattern.finditer(str(value or '')))
2696
+ found_ids = [match.group(1) for match in matches]
2697
+ if found_ids != list(expected_ids):
2698
+ raise DeepSeekRequestError(
2699
+ f'Translation marker mismatch: expected={list(expected_ids)}, got={found_ids}.'
2700
+ )
2701
+ parsed = {}
2702
+ for index, match in enumerate(matches):
2703
+ end = matches[index + 1].start() if index + 1 < len(matches) else len(str(value or ''))
2704
+ translated = str(value or '')[match.end():end].strip()
2705
+ if not translated:
2706
+ raise DeepSeekRequestError(f'Translation block SP-P{match.group(1)} is empty.')
2707
+ parsed[match.group(1)] = translated
2708
+ return parsed
2709
+
2710
+ def _validate_translation_block(self, source, translated, block_id, target_lang):
2711
+ if self._numeric_tokens(source) != self._numeric_tokens(translated):
2712
+ raise DeepSeekRequestError(f'Translation changed numerical content in {block_id}.')
2713
+ if self._translation_citation_tokens(source) != self._translation_citation_tokens(translated):
2714
+ raise DeepSeekRequestError(f'Translation changed citations in {block_id}.')
2715
+ if self._translation_hypothesis_tokens(source) != self._translation_hypothesis_tokens(translated):
2716
+ raise DeepSeekRequestError(f'Translation changed hypothesis labels in {block_id}.')
2717
+ if self._translation_acronym_tokens(source) != self._translation_acronym_tokens(translated):
2718
+ raise DeepSeekRequestError(f'Translation changed variable abbreviations in {block_id}.')
2719
+
2720
+ source_compact = re.sub(r'\s+', '', str(source or ''))
2721
+ target_compact = re.sub(r'\s+', '', str(translated or ''))
2722
+ if len(source_compact) >= 120:
2723
+ is_chinese = 'Chinese' in target_lang
2724
+ minimum = float(APP_CONFIG.get(
2725
+ 'translation_min_block_ratio_zh' if is_chinese else 'translation_min_block_ratio_cyrillic',
2726
+ 0.28 if is_chinese else 0.65,
2727
+ ))
2728
+ maximum = float(APP_CONFIG.get('translation_max_block_ratio', 2.20))
2729
+ ratio = len(target_compact) / max(1, len(source_compact))
2730
+ if ratio < minimum or ratio > maximum:
2731
+ raise DeepSeekRequestError(
2732
+ f'Translation coverage ratio is implausible in {block_id}: {ratio:.3f}.'
2733
+ )
2734
+ if source_compact.casefold() == target_compact.casefold():
2735
+ raise DeepSeekRequestError(f'Translation block {block_id} was returned unchanged.')
2736
+ if is_chinese and not re.search(r'[\u4e00-\u9fff]', translated):
2737
+ raise DeepSeekRequestError(f'Translation block {block_id} contains no Chinese text.')
2738
+ if not is_chinese and not re.search(r'[А-Яа-яЁёӘәҒғҚқҢңӨөҰұҮүҺһІі]', translated):
2739
+ raise DeepSeekRequestError(f'Translation block {block_id} contains no Cyrillic target-language text.')
2740
+
2741
+ def translate_locked_manuscript(self, manuscript, target_lang):
2742
+ value = str(manuscript or '').strip()
2743
+ if not value:
2744
+ raise DeepSeekRequestError('English manuscript is empty.')
2745
+ reference_split = re.split(r'(?m)^#\s+References\s*$', value, maxsplit=1)
2746
+ body = reference_split[0].strip()
2747
+ references = reference_split[1].strip() if len(reference_split) == 2 else ''
2748
+ units = self._translation_body_units(body)
2749
+ if not units:
2750
+ raise DeepSeekRequestError('English manuscript contains no translatable content.')
2751
+
2752
+ glossary = self._build_translation_glossary(body, target_lang)
2753
+ body_units = [unit for unit in units if unit['kind'] == 'body']
2754
+ translations = {}
2755
+ token_budget = max(800, int(APP_CONFIG.get('translation_batch_tokens', 2500)))
2756
+ batches = []
2757
+ current = []
2758
+ current_tokens = 0
2759
+ for index, unit in enumerate(body_units, start=1):
2760
+ block_id = f'{index:06d}'
2761
+ tokens = max(1, self._count_tokens(unit['text']))
2762
+ if current and current_tokens + tokens > token_budget:
2763
+ batches.append(current)
2764
+ current = []
2765
+ current_tokens = 0
2766
+ current.append((block_id, unit['text']))
2767
+ current_tokens += tokens
2768
+ if current:
2769
+ batches.append(current)
2770
+
2771
+ system_prompt = self._translation_prompt(target_lang) + '\nUse this terminology glossary consistently:\n' + json.dumps(glossary, ensure_ascii=False)
2772
+ for batch in batches:
2773
+ self._check_cancelled()
2774
+ marked_source = '\n\n'.join(
2775
+ f'[[SP-P{block_id}]]\n{source}' for block_id, source in batch
2776
+ )
2777
+ result = self.call_deepseek(
2778
+ system_prompt,
2779
+ marked_source,
2780
+ max_tokens=max(1800, min(9000, self._count_tokens(marked_source) * 2 + 1200)),
2781
+ temp=0.0,
2782
+ model_role='translation',
2783
+ thinking=False,
2784
+ allow_fallback=False,
2785
+ )
2786
+ result = self._remove_ai_meta_traces(result)
2787
+ parsed = self._parse_marked_translation(result, [block_id for block_id, _ in batch])
2788
+ for block_id, source in batch:
2789
+ translated = parsed[block_id]
2790
+ self._validate_translation_block(source, translated, f'SP-P{block_id}', target_lang)
2791
+ translations[block_id] = translated
2792
+
2793
+ output_units = []
2794
+ body_index = 0
2795
+ for unit in units:
2796
+ if unit['kind'] == 'heading':
2797
+ translated_heading = str(glossary.get(unit['text']) or '').strip()
2798
+ if not translated_heading:
2799
+ raise DeepSeekRequestError(f'Translation glossary missing heading: {unit["text"]}')
2800
+ output_units.append('#' * int(unit['level']) + ' ' + translated_heading)
2801
+ else:
2802
+ body_index += 1
2803
+ output_units.append(translations[f'{body_index:06d}'])
2804
+
2805
+ output = '\n\n'.join(output_units).strip()
2806
+ if references:
2807
+ output += f"\n\n# {self._localized_reference_heading(target_lang)}\n{references}"
2808
+ self.validate_translation_alignment(value, output)
2809
+ return output.strip()
2810
+
2811
+ @staticmethod
2812
+ def validate_translation_alignment(english_text, translated_text):
2813
+ source = str(english_text or '').strip()
2814
+ target = str(translated_text or '').strip()
2815
+ if not target:
2816
+ raise DeepSeekRequestError('Target-language manuscript is empty.')
2817
+ en_tree = [level for level, _ in SSciPipelineAgent._markdown_heading_tree(source)]
2818
+ tr_tree = [level for level, _ in SSciPipelineAgent._markdown_heading_tree(target)]
2819
+ if en_tree != tr_tree:
2820
+ raise DeepSeekRequestError(
2821
+ f'Translation heading-tree mismatch: English={en_tree}, target={tr_tree}.'
2822
+ )
2823
+ if SSciPipelineAgent._numeric_tokens(source) != SSciPipelineAgent._numeric_tokens(target):
2824
+ raise DeepSeekRequestError('Translation numerical alignment failed.')
2825
+ if SSciPipelineAgent._translation_citation_tokens(source) != SSciPipelineAgent._translation_citation_tokens(target):
2826
+ raise DeepSeekRequestError('Translation citation alignment failed.')
2827
+ if SSciPipelineAgent._translation_hypothesis_tokens(source) != SSciPipelineAgent._translation_hypothesis_tokens(target):
2828
+ raise DeepSeekRequestError('Translation hypothesis-label alignment failed.')
2829
+ if SSciPipelineAgent._translation_acronym_tokens(source) != SSciPipelineAgent._translation_acronym_tokens(target):
2830
+ raise DeepSeekRequestError('Translation abbreviation alignment failed.')
2831
+ source_body = re.split(r'(?m)^#\s+References\s*$', source, maxsplit=1)[0]
2832
+ target_body = re.split(r'(?m)^#\s+(?:参考文献|Литература|Пайдаланылған әдебиеттер)\s*$', target, maxsplit=1)[0]
2833
+ source_chars = len(re.sub(r'\s+', '', source_body))
2834
+ target_chars = len(re.sub(r'\s+', '', target_body))
2835
+ if re.search(r'(?m)^#\s+参考文献\s*$', target):
2836
+ minimum_ratio = float(APP_CONFIG.get('translation_min_block_ratio_zh', 0.28))
2837
+ elif re.search(r'(?m)^#\s+(?:Литература|Пайдаланылған әдебиеттер)\s*$', target):
2838
+ minimum_ratio = float(APP_CONFIG.get('translation_min_block_ratio_cyrillic', 0.65))
2839
+ else:
2840
+ minimum_ratio = float(APP_CONFIG.get('translation_min_coverage_ratio', 0.25))
2841
+ ratio = target_chars / source_chars if source_chars else 0.0
2842
+ if source_chars and ratio < minimum_ratio:
2843
+ raise DeepSeekRequestError(
2844
+ f'Translation content coverage is too low: {ratio:.3f}.'
2845
+ )
2846
+ return True
2847
+
2848
  def enforce_claim_specific_citations(self, text):
2849
  """Backward-compatible strict validator; no silent citation deletion."""
2850
  violations = self.find_multi_citation_violations(text)
core/production_guard.py ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Final-output validation and packaging for SmartPaper."""
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import io
7
+ import json
8
+ import re
9
+ import zipfile
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+ from docx import Document
14
+
15
+ from utils.docx_builder import DocxBuildError, text_to_docx
16
+
17
+
18
+ class OutputValidationError(RuntimeError):
19
+ pass
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class PackageResult:
24
+ zip_bytes: bytes
25
+ file_names: tuple[str, ...]
26
+ manifest: str
27
+
28
+
29
+ def replace_abstract(manuscript: str, abstract: str) -> str:
30
+ value = str(manuscript or "")
31
+ replacement = f"## Abstract\n{str(abstract or '').strip()}\n\n"
32
+ updated, count = re.subn(
33
+ r"(?ms)^##\s+Abstract\s*$\n.*?(?=^##\s+Keywords\s*$)",
34
+ replacement,
35
+ value,
36
+ count=1,
37
+ )
38
+ if count != 1:
39
+ raise OutputValidationError("English abstract block was not found exactly once.")
40
+ return updated
41
+
42
+
43
+ def validate_docx_bytes(data: bytes, *, minimum_paragraphs: int = 8) -> None:
44
+ if not data or len(data) < 1500:
45
+ raise OutputValidationError("DOCX output is empty or unexpectedly small.")
46
+ try:
47
+ document = Document(io.BytesIO(data))
48
+ except Exception as exc:
49
+ raise OutputValidationError(f"DOCX cannot be reopened: {exc}") from exc
50
+ nonempty = [paragraph.text.strip() for paragraph in document.paragraphs if paragraph.text.strip()]
51
+ if len(nonempty) < minimum_paragraphs:
52
+ raise OutputValidationError(
53
+ f"DOCX contains only {len(nonempty)} nonempty paragraphs; expected at least {minimum_paragraphs}."
54
+ )
55
+
56
+
57
+ def validate_markdown_manuscript(text: str, *, language_name: str) -> None:
58
+ value = str(text or "").strip()
59
+ if len(value) < 1000:
60
+ raise OutputValidationError(f"{language_name} manuscript is incomplete.")
61
+ if re.search(r"\[?CITE-\d+\]?", value, flags=re.IGNORECASE):
62
+ raise OutputValidationError(f"{language_name} manuscript still contains unresolved citation tags.")
63
+ if re.search(r"(?i)\bas an ai(?: language model)?\b|\bai[- ]generated\b", value):
64
+ raise OutputValidationError(f"{language_name} manuscript contains AI-writing meta-commentary.")
65
+ if not re.search(r"(?m)^#\s+", value):
66
+ raise OutputValidationError(f"{language_name} manuscript has no title/section headings.")
67
+
68
+
69
+ def build_generation_notes(data_metadata: dict[str, Any], mode: str) -> str:
70
+ metadata = dict(data_metadata or {})
71
+ source_type = metadata.get("source_type", "unknown")
72
+ lines = [
73
+ "SmartPaper Generation Notes",
74
+ "",
75
+ f"Research mode: {mode}",
76
+ f"Evidence source type: {source_type}",
77
+ "",
78
+ "This file is separate from the manuscript. It documents how research evidence was supplied or generated, the applicable boundaries, and the validation steps used before packaging.",
79
+ ]
80
+ if metadata.get("disclosure_required"):
81
+ lines.extend(
82
+ [
83
+ "",
84
+ "Evidence-generation notice:",
85
+ "The statistical results or qualitative materials used by this run were generated by the system because verified user-provided evidence was unavailable. They were created for drafting and methodological demonstration. They must not be represented as independently collected empirical observations.",
86
+ "",
87
+ f"Generation method: {metadata.get('method') or 'Structured evidence generation under the selected quantitative or qualitative engine.'}",
88
+ ]
89
+ )
90
+ study_facts = metadata.get("study_facts")
91
+ if isinstance(study_facts, dict):
92
+ lines.extend(["", "Frozen qualitative study facts:", json.dumps(study_facts, ensure_ascii=False, indent=2)])
93
+ if metadata.get("sample_size") is not None:
94
+ lines.append(f"Generated sample size: {metadata.get('sample_size')}")
95
+ if metadata.get("evidence_count") is not None:
96
+ lines.append(f"Generated/retained qualitative evidence items: {metadata.get('evidence_count')}")
97
+ themes = metadata.get("themes")
98
+ if isinstance(themes, list):
99
+ lines.append(f"Generated/retained qualitative themes: {len(themes)}")
100
+ parameters = metadata.get("generation_parameters")
101
+ if isinstance(parameters, dict) and parameters:
102
+ lines.extend(
103
+ [
104
+ "",
105
+ "Generation parameters and process:",
106
+ json.dumps(parameters, ensure_ascii=False, indent=2),
107
+ ]
108
+ )
109
+ lines.extend(
110
+ [
111
+ "",
112
+ "Validation boundary:",
113
+ "The package confirms internal consistency, file integrity, citation resolution, and translation completeness. It does not independently verify external data collection, factual truth beyond the supplied sources, journal acceptance, or the result of any third-party AI-writing detector.",
114
+ ]
115
+ )
116
+ return "\n".join(lines).strip() + "\n"
117
+
118
+
119
+ def _manifest(entries: dict[str, bytes]) -> str:
120
+ rows = []
121
+ for name in sorted(entries):
122
+ rows.append(f"{hashlib.sha256(entries[name]).hexdigest()} {name}")
123
+ return "\n".join(rows) + "\n"
124
+
125
+
126
+ def build_validated_package(
127
+ *,
128
+ english_markdown: str,
129
+ translated_markdown: str,
130
+ english_filename: str,
131
+ translated_filename: str,
132
+ data_metadata: dict[str, Any],
133
+ mode: str,
134
+ citation_audit: list[dict[str, Any]],
135
+ literature_metrics: dict[str, Any],
136
+ ) -> PackageResult:
137
+ validate_markdown_manuscript(english_markdown, language_name="English")
138
+ validate_markdown_manuscript(translated_markdown, language_name="Target-language")
139
+
140
+ try:
141
+ english_docx = text_to_docx(english_markdown).getvalue()
142
+ translated_docx = text_to_docx(translated_markdown).getvalue()
143
+ except DocxBuildError as exc:
144
+ raise OutputValidationError(str(exc)) from exc
145
+ validate_docx_bytes(english_docx)
146
+ validate_docx_bytes(translated_docx)
147
+
148
+ entries: dict[str, bytes] = {
149
+ english_filename: english_docx,
150
+ translated_filename: translated_docx,
151
+ "GENERATION_NOTES.txt": build_generation_notes(data_metadata, mode).encode("utf-8"),
152
+ "CITATION_AUDIT.json": json.dumps(citation_audit or [], ensure_ascii=False, indent=2).encode("utf-8"),
153
+ "LITERATURE_METRICS.json": json.dumps(literature_metrics or {}, ensure_ascii=False, indent=2).encode("utf-8"),
154
+ }
155
+ manifest = _manifest(entries)
156
+ entries["FILE_MANIFEST_SHA256.txt"] = manifest.encode("utf-8")
157
+
158
+ buffer = io.BytesIO()
159
+ with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
160
+ for name, content in entries.items():
161
+ archive.writestr(name, content)
162
+ zip_bytes = buffer.getvalue()
163
+
164
+ try:
165
+ with zipfile.ZipFile(io.BytesIO(zip_bytes), "r") as archive:
166
+ bad = archive.testzip()
167
+ if bad:
168
+ raise OutputValidationError(f"ZIP integrity check failed at {bad}.")
169
+ names = set(archive.namelist())
170
+ required = set(entries)
171
+ if names != required:
172
+ raise OutputValidationError(
173
+ f"ZIP file list mismatch: actual={sorted(names)}, expected={sorted(required)}"
174
+ )
175
+ for name in (english_filename, translated_filename):
176
+ validate_docx_bytes(archive.read(name))
177
+ except OutputValidationError:
178
+ raise
179
+ except Exception as exc:
180
+ raise OutputValidationError(f"ZIP cannot be reopened: {exc}") from exc
181
+
182
+ return PackageResult(zip_bytes=zip_bytes, file_names=tuple(sorted(entries)), manifest=manifest)
core/qualitative_engine.py CHANGED
@@ -222,6 +222,21 @@ Rules:
222
  "analysis_method": method,
223
  },
224
  "evidence_count": len(clean_evidence),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  "evidence": clean_evidence,
226
  "themes": themes,
227
  }
 
222
  "analysis_method": method,
223
  },
224
  "evidence_count": len(clean_evidence),
225
+ "generation_parameters": {
226
+ "coding_approach": "Gioia-style inductive coding",
227
+ "requested_quote_count_when_generated": 12,
228
+ "maximum_retained_quotes": 15,
229
+ "minimum_themes": minimum_themes,
230
+ "minimum_quotes": minimum_quotes,
231
+ "consistency_constraints": [
232
+ "one fixed research setting",
233
+ "one fixed participant count",
234
+ "one fixed interview count",
235
+ "one fixed data-source set",
236
+ "one fixed analysis method",
237
+ "quotation tags must remain unique",
238
+ ],
239
+ },
240
  "evidence": clean_evidence,
241
  "themes": themes,
242
  }
core/quantitative_engine.py CHANGED
@@ -192,6 +192,27 @@ class QuantitativeEngine:
192
  "indicator loadings, discretization, and derived "
193
  "reliability/validity summaries."
194
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  "evidence_sections": [
196
  "Reliability and convergent validity",
197
  "Descriptive statistics and discriminant validity",
 
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",
204
+ "indicator_loading_range": [0.72, 0.88],
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",
i18n.py CHANGED
@@ -339,6 +339,15 @@ TEXTS: dict[str, dict[str, str]] = {
339
  "account_chip_free": "免费 {value}篇",
340
  "account_chip_bonus": "长期 {value}篇",
341
  "logout_short": "退出",
 
 
 
 
 
 
 
 
 
342
  "browse_files": "选择文件",
343
  "drop_docs": "将文件拖放到此处",
344
  "drop_data": "将文件拖放到此处",
@@ -483,6 +492,15 @@ TEXTS: dict[str, dict[str, str]] = {
483
  "account_chip_free": "Free {value}",
484
  "account_chip_bonus": "Bonus {value}",
485
  "logout_short": "Sign out",
 
 
 
 
 
 
 
 
 
486
  "browse_files": "Choose files",
487
  "drop_docs": "Drag and drop files here",
488
  "drop_data": "Drag and drop files here",
@@ -627,6 +645,15 @@ TEXTS: dict[str, dict[str, str]] = {
627
  "account_chip_free": "Бесплатно {value}",
628
  "account_chip_bonus": "Запас {value}",
629
  "logout_short": "Выйти",
 
 
 
 
 
 
 
 
 
630
  "browse_files": "Выбрать файлы",
631
  "drop_docs": "Перетащите файлы сюда",
632
  "drop_data": "Перетащите файлы сюда",
@@ -771,6 +798,15 @@ TEXTS: dict[str, dict[str, str]] = {
771
  "account_chip_free": "Тегін {value}",
772
  "account_chip_bonus": "Қор {value}",
773
  "logout_short": "Шығу",
 
 
 
 
 
 
 
 
 
774
  "browse_files": "Файлдарды таңдау",
775
  "drop_docs": "Файлдарды осы жерге сүйреп әкеліңіз",
776
  "drop_data": "Файлдарды осы жерге сүйреп әкеліңіз",
 
339
  "account_chip_free": "免费 {value}篇",
340
  "account_chip_bonus": "长期 {value}篇",
341
  "logout_short": "退出",
342
+ "payment_dialog_title": "支付宝付款",
343
+ "payment_notice": "今日免费额度和长期额度均已用完。支付9.9元可增加3篇长期有效额度。",
344
+ "quota_exhausted": "今日免费额度和长期额度均已用完。请使用支付宝购买长期额度。",
345
+ "payment_create": "生成支付宝付款二维码",
346
+ "payment_creating": "正在创建支付宝订单……",
347
+ "payment_scan": "请使用支付宝 App 扫码支付。支付成功后点击“查询支付结果”。",
348
+ "payment_check": "查询支付结果",
349
+ "payment_paid": "支付成功,已增加3篇长期有效额度。请重新点击生成论文。",
350
+ "payment_expired": "付款二维码已过期,请重新生成。",
351
  "browse_files": "选择文件",
352
  "drop_docs": "将文件拖放到此处",
353
  "drop_data": "将文件拖放到此处",
 
492
  "account_chip_free": "Free {value}",
493
  "account_chip_bonus": "Bonus {value}",
494
  "logout_short": "Sign out",
495
+ "payment_dialog_title": "Alipay Payment",
496
+ "payment_notice": "Your daily free quota and long-term quota are exhausted. Pay CNY 9.9 to add three long-term papers.",
497
+ "quota_exhausted": "Your daily free quota and long-term quota are exhausted. Purchase long-term quota with Alipay.",
498
+ "payment_create": "Create Alipay QR code",
499
+ "payment_creating": "Creating the Alipay order…",
500
+ "payment_scan": "Scan the code with the Alipay app. After payment, select “Check payment result.”",
501
+ "payment_check": "Check payment result",
502
+ "payment_paid": "Payment succeeded. Three long-term papers were added. Start the paper again.",
503
+ "payment_expired": "The payment QR code has expired. Create a new one.",
504
  "browse_files": "Choose files",
505
  "drop_docs": "Drag and drop files here",
506
  "drop_data": "Drag and drop files here",
 
645
  "account_chip_free": "Бесплатно {value}",
646
  "account_chip_bonus": "Запас {value}",
647
  "logout_short": "Выйти",
648
+ "payment_dialog_title": "Оплата через Alipay",
649
+ "payment_notice": "Ежедневная бесплатная и долгосрочная квоты исчерпаны. Оплата 9,9 юаня добавляет квоту на три статьи без срока действия.",
650
+ "quota_exhausted": "Ежедневная бесплатная и долгосрочная квоты исчерпаны. Приобретите долгосрочную квоту через Alipay.",
651
+ "payment_create": "Создать QR-код для оплаты",
652
+ "payment_creating": "Создаётся заказ Alipay…",
653
+ "payment_scan": "Отсканируйте QR-код в приложении Alipay. После оплаты нажмите «Проверить результат оплаты».",
654
+ "payment_check": "Проверить результат оплаты",
655
+ "payment_paid": "Оплата прошла успешно. Добавлена долгосрочная квота на три статьи. Запустите создание статьи повторно.",
656
+ "payment_expired": "Срок действия QR-кода истёк. Создайте новый код.",
657
  "browse_files": "Выбрать файлы",
658
  "drop_docs": "Перетащите файлы сюда",
659
  "drop_data": "Перетащите файлы сюда",
 
798
  "account_chip_free": "Тегін {value}",
799
  "account_chip_bonus": "Қор {value}",
800
  "logout_short": "Шығу",
801
+ "payment_dialog_title": "Alipay арқылы төлеу",
802
+ "payment_notice": "Күнделікті тегін және ұзақ мерзімді квота таусылды. 9,9 юань төлегенде мерзімі шектелмеген үш мақала квотасы қосылады.",
803
+ "quota_exhausted": "Күнделікті тегін және ұзақ мерзімді квота таусылды. Alipay арқылы ұзақ мерзімді квота сатып алыңыз.",
804
+ "payment_create": "Төлемге арналған QR-код жасау",
805
+ "payment_creating": "Alipay тапсырысы жасалуда…",
806
+ "payment_scan": "QR-кодты Alipay қолданбасымен сканерлеңіз. Төлемнен кейін «Төлем нәтижесін тексеру» түймесін басыңыз.",
807
+ "payment_check": "Төлем нәтижесін тексеру",
808
+ "payment_paid": "Төлем сәтті өтті. Мерзімі шектелмеген үш мақала квотасы қосылды. Мақала жасауды қайта бастаңыз.",
809
+ "payment_expired": "QR-кодтың қолданылу мерзімі аяқталды. Жаңа код жасаңыз.",
810
  "browse_files": "Файлдарды таңдау",
811
  "drop_docs": "Файлдарды осы жерге сүйреп әкеліңіз",
812
  "drop_data": "Файлдарды осы жерге сүйреп әкеліңіз",
managed_auth.py CHANGED
@@ -1,9 +1,10 @@
1
  # -*- coding: utf-8 -*-
2
- """SmartPaper edge-first authentication for Streamlit.
3
 
4
- Cloudflare owns the login page and durable browser session. Streamlit receives
5
- one short-lived, signed assertion cookie on the proxied HTTP/WebSocket request.
6
- No authentication network call is made while rendering or changing language.
 
7
  """
8
  from __future__ import annotations
9
 
@@ -11,43 +12,23 @@ import base64
11
  import hashlib
12
  import hmac
13
  import html
 
14
  import json
15
  import os
16
- import socket
17
  import time
18
  from typing import Any, Callable
19
  from urllib.parse import unquote, urlsplit
20
 
21
- import requests
22
  import streamlit as st
23
 
24
  from i18n import tr
 
25
 
26
  EDGE_ASSERTION_COOKIE_NAME = "smartpaper_edge_assertion"
27
  EDGE_ASSERTION_AUDIENCE = "smartpaper-streamlit"
28
  ASSERTION_CLOCK_SKEW_SECONDS = 30
29
- REQUEST_TIMEOUT = (5.0, 15.0)
30
- TASK_START_TIMEOUT = (8.0, 20.0)
31
- TASK_START_RETRY_DELAY_SECONDS = 0.5
32
- WORKER_DIAGNOSTIC_TIMEOUT = (5.0, 8.0)
33
- WORKER_DIAGNOSTIC_PREFIX = "[SP_NETWORK_DIAG]"
34
-
35
- _HTTP_SESSION = requests.Session()
36
- _HTTP_SESSION.headers.update({
37
- "Accept": "application/json",
38
- "Content-Type": "application/json",
39
- "User-Agent": "SmartPaper-HuggingFace/edge-first-v1",
40
- })
41
-
42
- # /task/start uses a direct connection first, then one environment-aware
43
- # retry. Both attempts reuse the exact same idempotency_key and request body.
44
- _TASK_DIRECT_SESSION = requests.Session()
45
- _TASK_DIRECT_SESSION.trust_env = False
46
- _TASK_DIRECT_SESSION.headers.update({
47
- "Accept": "application/json",
48
- "Content-Type": "application/json",
49
- "User-Agent": "SmartPaper-HuggingFace/edge-first-v1",
50
- })
51
 
52
 
53
  def _runtime_value(name: str) -> str:
@@ -61,15 +42,21 @@ def _runtime_value(name: str) -> str:
61
 
62
 
63
  def _api_base() -> str:
64
- # Prefer the public edge origin carried inside the verified assertion.
65
- # This keeps API calls correct after a workers.dev subdomain rename and
66
- # removes any dependency on a stale hard-coded public URL.
67
  value = str(st.session_state.get("sp_auth_api_origin") or "").strip().rstrip("/")
68
  if not value:
69
  value = _runtime_value("SMARTPAPER_AUTH_API").rstrip("/")
70
- if not value.startswith("https://"):
 
 
 
 
 
 
 
 
 
71
  raise RuntimeError("SMARTPAPER_AUTH_API must be an HTTPS origin.")
72
- return value
73
 
74
 
75
  def _service_key() -> str:
@@ -79,26 +66,6 @@ def _service_key() -> str:
79
  return value
80
 
81
 
82
- def _post(path: str, payload: dict[str, Any]) -> dict[str, Any]:
83
- response = _HTTP_SESSION.post(
84
- f"{_api_base()}{path}",
85
- headers={"Authorization": f"Bearer {_service_key()}"},
86
- json=payload,
87
- timeout=REQUEST_TIMEOUT,
88
- allow_redirects=False,
89
- )
90
- try:
91
- data = response.json()
92
- except ValueError as exc:
93
- raise RuntimeError(
94
- f"Authentication service returned non-JSON content (HTTP {response.status_code})."
95
- ) from exc
96
- if response.status_code >= 400:
97
- message = data.get("message") or data.get("reason") or data.get("error") or f"HTTP {response.status_code}"
98
- raise RuntimeError(str(message))
99
- return data
100
-
101
-
102
  def _context_cookie(name: str) -> str:
103
  try:
104
  cookies = st.context.cookies
@@ -115,8 +82,7 @@ def _context_cookie(name: str) -> str:
115
 
116
 
117
  def _decode_b64url(value: str) -> bytes:
118
- padding = "=" * (-len(value) % 4)
119
- return base64.urlsafe_b64decode(value + padding)
120
 
121
 
122
  def _verify_edge_assertion(token: str) -> dict[str, Any]:
@@ -139,45 +105,45 @@ def _verify_edge_assertion(token: str) -> dict[str, Any]:
139
  if payload.get("ver") != 1 or payload.get("aud") != EDGE_ASSERTION_AUDIENCE:
140
  raise RuntimeError("invalid_edge_assertion_claims")
141
  api_origin = str(payload.get("api_origin") or "").strip().rstrip("/")
142
- parsed_origin = urlsplit(api_origin)
143
  if (
144
- parsed_origin.scheme != "https"
145
- or not parsed_origin.netloc
146
- or parsed_origin.username is not None
147
- or parsed_origin.password is not None
148
- or parsed_origin.path not in ("", "/")
149
- or parsed_origin.query
150
- or parsed_origin.fragment
151
  ):
152
  raise RuntimeError("invalid_edge_assertion_origin")
153
- api_origin = f"https://{parsed_origin.netloc}"
154
  issued_at = int(payload.get("iat") or 0)
155
  expires_at = int(payload.get("exp") or 0)
156
  if issued_at > now + ASSERTION_CLOCK_SKEW_SECONDS or expires_at <= now - ASSERTION_CLOCK_SKEW_SECONDS:
157
  raise RuntimeError("expired_edge_assertion")
158
  if expires_at - issued_at > 9 * 60 * 60:
159
  raise RuntimeError("invalid_edge_assertion_lifetime")
160
- browser_token = str(payload.get("browser_token") or "").strip()
161
  user = payload.get("user") or {}
162
- if not browser_token or not isinstance(user, dict) or not user.get("id") or user.get("status") != "active":
163
  raise RuntimeError("invalid_edge_assertion_identity")
164
- payload["api_origin"] = api_origin
165
  return payload
166
 
167
 
168
  def _clear_generation_session_state() -> None:
169
  for key in (
170
  "sp_generation_task", "sp_current_node", "sp_last_task_heartbeat",
171
- "zip_buffer", "zip_filename", "is_running", "start_pipeline",
172
- "resume_pipeline", "run_mode", "rag_engine", "up_amj_ref",
173
- "up_amj_data", "up_asq_ref", "up_asq_data",
 
 
174
  ):
175
  st.session_state.pop(key, None)
176
 
177
 
178
  def _clear_session() -> None:
179
  for key in (
180
- "sp_session_token", "sp_session_expires_at", "sp_user",
181
  "sp_last_verified_at", "sp_edge_assertion", "sp_auth_api_origin",
182
  ):
183
  st.session_state.pop(key, None)
@@ -198,7 +164,6 @@ def require_alipay_login(language: str = "zh") -> dict[str, Any]:
198
  st.stop()
199
  user = dict(payload["user"])
200
  st.session_state["sp_edge_assertion"] = token
201
- st.session_state["sp_session_token"] = payload["browser_token"]
202
  st.session_state["sp_session_expires_at"] = payload["exp"]
203
  st.session_state["sp_user"] = user
204
  st.session_state["sp_auth_api_origin"] = str(payload["api_origin"]).rstrip("/")
@@ -206,237 +171,215 @@ def require_alipay_login(language: str = "zh") -> dict[str, Any]:
206
  return user
207
 
208
 
209
- def _authorized_post(path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
210
- session_token = str(st.session_state.get("sp_session_token") or "").strip()
211
- if not session_token:
212
- raise RuntimeError("session_token_required")
213
- body = dict(payload or {})
214
- body["session_token"] = session_token
215
- return _post(path, body)
216
-
217
-
218
- def _post_task_start(payload: dict[str, Any]) -> dict[str, Any]:
219
- """Start one task with one bounded retry and the same idempotency key."""
220
- url = f"{_api_base()}/task/start"
221
- headers = {"Authorization": f"Bearer {_service_key()}"}
222
- transports = (_TASK_DIRECT_SESSION, _HTTP_SESSION)
223
- last_error: Exception | None = None
224
-
225
- for attempt, session in enumerate(transports):
226
- try:
227
- response = session.post(
228
- url,
229
- headers=headers,
230
- json=payload,
231
- timeout=TASK_START_TIMEOUT,
232
- allow_redirects=False,
233
- )
234
- except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as exc:
235
- last_error = exc
236
- if attempt == 0:
237
- time.sleep(TASK_START_RETRY_DELAY_SECONDS)
238
- continue
239
- raise
240
-
241
- try:
242
- data = response.json()
243
- except ValueError as exc:
244
- raise RuntimeError(
245
- f"Authentication service returned non-JSON content (HTTP {response.status_code})."
246
- ) from exc
247
-
248
- if response.status_code >= 400:
249
- message = (
250
- data.get("message")
251
- or data.get("reason")
252
- or data.get("error")
253
- or f"HTTP {response.status_code}"
254
- )
255
- raise RuntimeError(str(message))
256
-
257
- return data
258
-
259
- if last_error is not None:
260
- raise last_error
261
- raise RuntimeError("task_start_request_failed")
262
-
263
-
264
- def _diagnostic_log(event: str, **fields: Any) -> None:
265
- record = {
266
- "event": event,
267
- "time_unix": round(time.time(), 3),
268
- **fields,
269
- }
270
- print(
271
- f"{WORKER_DIAGNOSTIC_PREFIX} "
272
- + json.dumps(record, ensure_ascii=False, sort_keys=True, default=str),
273
- flush=True,
274
  )
275
 
276
 
277
- def _run_worker_health_diagnostic() -> dict[str, Any]:
278
- """Test Hugging Face -> Worker connectivity without creating a task."""
279
- base_url = _api_base()
280
- parsed = urlsplit(base_url)
281
- host = str(parsed.hostname or "").strip()
282
- port = parsed.port or 443
283
- health_url = f"{base_url}/health"
284
-
285
- summary: dict[str, Any] = {
286
- "base_url": base_url,
287
- "dns_ok": False,
288
- "direct_ok": False,
289
- "environment_ok": False,
290
- }
291
-
292
- dns_started = time.perf_counter()
293
- try:
294
- answers = socket.getaddrinfo(
295
- host,
296
- port,
297
- family=socket.AF_UNSPEC,
298
- type=socket.SOCK_STREAM,
299
- )
300
- addresses = sorted({
301
- str(item[4][0])
302
- for item in answers
303
- if item and len(item) >= 5 and item[4]
304
- })
305
- elapsed_ms = round((time.perf_counter() - dns_started) * 1000, 1)
306
- summary["dns_ok"] = bool(addresses)
307
- summary["dns_addresses"] = addresses[:8]
308
- _diagnostic_log(
309
- "dns_result",
310
- host=host,
311
- port=port,
312
- ok=bool(addresses),
313
- elapsed_ms=elapsed_ms,
314
- addresses=addresses[:8],
315
- )
316
- except Exception as exc:
317
- elapsed_ms = round((time.perf_counter() - dns_started) * 1000, 1)
318
- summary["dns_error"] = type(exc).__name__
319
- _diagnostic_log(
320
- "dns_error",
321
- host=host,
322
- port=port,
323
- elapsed_ms=elapsed_ms,
324
- exception_type=type(exc).__name__,
325
- message=str(exc)[:500],
326
- )
327
-
328
- transports = (
329
- ("direct", _TASK_DIRECT_SESSION),
330
- ("environment", _HTTP_SESSION),
331
  )
 
332
 
333
- for transport_name, session in transports:
334
- started = time.perf_counter()
335
- _diagnostic_log(
336
- "health_request_start",
337
- transport=transport_name,
338
- url=health_url,
339
- timeout_connect=WORKER_DIAGNOSTIC_TIMEOUT[0],
340
- timeout_read=WORKER_DIAGNOSTIC_TIMEOUT[1],
341
- )
342
- try:
343
- response = session.get(
344
- health_url,
345
- timeout=WORKER_DIAGNOSTIC_TIMEOUT,
346
- allow_redirects=False,
347
- headers={
348
- "Accept": "application/json",
349
- "User-Agent": "SmartPaper-HuggingFace/network-diagnostic-r2",
350
- },
351
- )
352
- elapsed_ms = round((time.perf_counter() - started) * 1000, 1)
353
- ok = 200 <= response.status_code < 300
354
- summary[f"{transport_name}_ok"] = ok
355
- summary[f"{transport_name}_status"] = response.status_code
356
- _diagnostic_log(
357
- "health_response",
358
- transport=transport_name,
359
- ok=ok,
360
- status_code=response.status_code,
361
- elapsed_ms=elapsed_ms,
362
- content_type=str(response.headers.get("content-type") or ""),
363
- cf_ray=str(response.headers.get("cf-ray") or ""),
364
- server=str(response.headers.get("server") or ""),
365
- body_prefix=str(response.text or "")[:300],
366
- )
367
- except requests.exceptions.RequestException as exc:
368
- elapsed_ms = round((time.perf_counter() - started) * 1000, 1)
369
- summary[f"{transport_name}_error"] = type(exc).__name__
370
- _diagnostic_log(
371
- "health_error",
372
- transport=transport_name,
373
- elapsed_ms=elapsed_ms,
374
- exception_type=type(exc).__name__,
375
- message=str(exc)[:500],
376
- )
377
- except Exception as exc:
378
- elapsed_ms = round((time.perf_counter() - started) * 1000, 1)
379
- summary[f"{transport_name}_error"] = type(exc).__name__
380
- _diagnostic_log(
381
- "health_unexpected_error",
382
- transport=transport_name,
383
- elapsed_ms=elapsed_ms,
384
- exception_type=type(exc).__name__,
385
- message=str(exc)[:500],
386
- )
387
-
388
- _diagnostic_log("diagnostic_summary", **summary)
389
- return summary
390
 
391
 
392
  def start_generation_task(*, idempotency_key: str, mode: str, interface_language: str) -> dict[str, Any]:
393
- """Temporary R2 diagnostic mode: test /health only; never call /task/start."""
394
- session_token = str(st.session_state.get("sp_session_token") or "").strip()
395
- if not session_token:
396
- raise RuntimeError("session_token_required")
397
-
398
- summary = _run_worker_health_diagnostic()
399
- if summary.get("direct_ok") or summary.get("environment_ok"):
400
- raise RuntimeError(
401
- "R2网络诊断完成:Worker /health 可访问。请查看 Hugging Face Runtime Logs 中的 "
402
- "[SP_NETWORK_DIAG] 记录。当前未创建论文任务,也未预留额度。"
403
- )
404
- raise RuntimeError(
405
- "R2网络诊断完成:Worker /health 不可访问。请查看 Hugging Face Runtime Logs 中的 "
406
- "[SP_NETWORK_DIAG] 记录。当前未创建论文任务,也未预留额度。"
407
  )
 
 
 
 
 
 
408
 
409
 
410
  def recover_generation_task() -> dict[str, Any]:
411
- return _authorized_post("/task/recover", {})
 
 
 
 
 
 
412
 
413
 
414
  def heartbeat_generation_task(task_id: str, *, current_node: str = "") -> dict[str, Any]:
415
- return _authorized_post("/task/heartbeat", {"task_id": task_id, "current_node": current_node})
 
 
 
 
 
 
 
 
 
 
 
416
 
417
 
418
  def generation_task_status(task_id: str) -> dict[str, Any]:
419
- return _authorized_post("/task/status", {"task_id": task_id})
420
-
421
-
422
- def finish_generation_task(task_id: str, *, status: str, safe_error_code: str = "", safe_error_detail: str = "", current_node: str = "") -> dict[str, Any]:
423
- return _authorized_post("/task/finish", {"task_id": task_id, "status": status, "safe_error_code": safe_error_code, "safe_error_detail": safe_error_detail, "current_node": current_node})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
424
 
425
 
426
  def cancel_generation_task(task_id: str | None = None) -> dict[str, Any]:
427
- payload: dict[str, Any] = {}
428
- if task_id:
429
- payload["task_id"] = task_id
430
- return _authorized_post("/task/cancel", payload)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
431
 
432
 
433
  def logout_alipay_session(on_logout: Callable[[str, str], None] | None = None) -> None:
434
- """Clear only the current Streamlit state.
435
-
436
- Durable logout is performed by the browser navigating directly to the
437
- Cloudflare edge endpoint ``/edge/logout``. This helper remains for API
438
- compatibility and must not be used as the visible logout control.
439
- """
440
  user_id = str((st.session_state.get("sp_user") or {}).get("id") or "")
441
  task = st.session_state.get("sp_generation_task") or {}
442
  task_id = str(task.get("task_id") or "")
@@ -448,15 +391,115 @@ def logout_alipay_session(on_logout: Callable[[str, str], None] | None = None) -
448
  _clear_session()
449
  _clear_generation_session_state()
450
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
451
  def render_logged_in_account(
452
  language: str,
453
  user: dict[str, Any] | None = None,
454
  on_logout: Callable[[str, str], None] | None = None,
455
  ) -> None:
456
- """Render a responsive account summary in the top toolbar."""
457
- user = user or st.session_state.get("sp_user", {})
 
 
 
 
458
  user_id = user.get("id", "—")
459
- free_limit = user.get("daily_free_limit", 3)
460
  bonus_papers = user.get("bonus_papers", 0)
461
 
462
  st.markdown(
@@ -578,21 +621,19 @@ def render_logged_in_account(
578
  )
579
 
580
  info_col, logout_col = st.columns([5.9, 1.65])
581
-
582
  with info_col:
583
  st.markdown(
584
  f"""
585
  <div class="sp-account-card">
586
- <span>{html.escape(tr(language, "account_chip_user", value=user_id))}</span>
587
  <span class="sp-account-separator">•</span>
588
- <span>{html.escape(tr(language, "account_chip_free", value=free_limit))}</span>
589
  <span class="sp-account-separator">•</span>
590
- <span>{html.escape(tr(language, "account_chip_bonus", value=bonus_papers))}</span>
591
  </div>
592
  """,
593
  unsafe_allow_html=True,
594
  )
595
-
596
  with logout_col:
597
  logout_label = html.escape(tr(language, "logout_short"))
598
  logout_help = html.escape(tr(language, "logout"), quote=True)
 
1
  # -*- coding: utf-8 -*-
2
+ """SmartPaper edge-first authentication and browser task/payment bridge.
3
 
4
+ Cloudflare owns the durable browser session, quota, tasks, and payments. The
5
+ Hugging Face process verifies the signed edge assertion but does not call the
6
+ Worker over the server-to-server network. Authenticated business operations are
7
+ executed by the user's browser through the same-origin invisible component.
8
  """
9
  from __future__ import annotations
10
 
 
12
  import hashlib
13
  import hmac
14
  import html
15
+ import io
16
  import json
17
  import os
18
+ import secrets
19
  import time
20
  from typing import Any, Callable
21
  from urllib.parse import unquote, urlsplit
22
 
23
+ import qrcode
24
  import streamlit as st
25
 
26
  from i18n import tr
27
+ from smartpaper_restore_bridge import edge_bridge
28
 
29
  EDGE_ASSERTION_COOKIE_NAME = "smartpaper_edge_assertion"
30
  EDGE_ASSERTION_AUDIENCE = "smartpaper-streamlit"
31
  ASSERTION_CLOCK_SKEW_SECONDS = 30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
 
34
  def _runtime_value(name: str) -> str:
 
42
 
43
 
44
  def _api_base() -> str:
 
 
 
45
  value = str(st.session_state.get("sp_auth_api_origin") or "").strip().rstrip("/")
46
  if not value:
47
  value = _runtime_value("SMARTPAPER_AUTH_API").rstrip("/")
48
+ parsed = urlsplit(value)
49
+ if (
50
+ parsed.scheme != "https"
51
+ or not parsed.netloc
52
+ or parsed.username is not None
53
+ or parsed.password is not None
54
+ or parsed.path not in ("", "/")
55
+ or parsed.query
56
+ or parsed.fragment
57
+ ):
58
  raise RuntimeError("SMARTPAPER_AUTH_API must be an HTTPS origin.")
59
+ return f"https://{parsed.netloc}"
60
 
61
 
62
  def _service_key() -> str:
 
66
  return value
67
 
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  def _context_cookie(name: str) -> str:
70
  try:
71
  cookies = st.context.cookies
 
82
 
83
 
84
  def _decode_b64url(value: str) -> bytes:
85
+ return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
 
86
 
87
 
88
  def _verify_edge_assertion(token: str) -> dict[str, Any]:
 
105
  if payload.get("ver") != 1 or payload.get("aud") != EDGE_ASSERTION_AUDIENCE:
106
  raise RuntimeError("invalid_edge_assertion_claims")
107
  api_origin = str(payload.get("api_origin") or "").strip().rstrip("/")
108
+ parsed = urlsplit(api_origin)
109
  if (
110
+ parsed.scheme != "https"
111
+ or not parsed.netloc
112
+ or parsed.username is not None
113
+ or parsed.password is not None
114
+ or parsed.path not in ("", "/")
115
+ or parsed.query
116
+ or parsed.fragment
117
  ):
118
  raise RuntimeError("invalid_edge_assertion_origin")
 
119
  issued_at = int(payload.get("iat") or 0)
120
  expires_at = int(payload.get("exp") or 0)
121
  if issued_at > now + ASSERTION_CLOCK_SKEW_SECONDS or expires_at <= now - ASSERTION_CLOCK_SKEW_SECONDS:
122
  raise RuntimeError("expired_edge_assertion")
123
  if expires_at - issued_at > 9 * 60 * 60:
124
  raise RuntimeError("invalid_edge_assertion_lifetime")
 
125
  user = payload.get("user") or {}
126
+ if not isinstance(user, dict) or not user.get("id") or user.get("status") != "active":
127
  raise RuntimeError("invalid_edge_assertion_identity")
128
+ payload["api_origin"] = f"https://{parsed.netloc}"
129
  return payload
130
 
131
 
132
  def _clear_generation_session_state() -> None:
133
  for key in (
134
  "sp_generation_task", "sp_current_node", "sp_last_task_heartbeat",
135
+ "sp_generation_idempotency_key", "zip_buffer", "zip_filename",
136
+ "is_running", "start_pipeline", "resume_pipeline", "run_mode",
137
+ "rag_engine", "up_amj_ref", "up_amj_data", "up_asq_ref",
138
+ "up_asq_data", "sp_payment_required", "sp_payment_order",
139
+ "sp_payment_request_id",
140
  ):
141
  st.session_state.pop(key, None)
142
 
143
 
144
  def _clear_session() -> None:
145
  for key in (
146
+ "sp_session_expires_at", "sp_user",
147
  "sp_last_verified_at", "sp_edge_assertion", "sp_auth_api_origin",
148
  ):
149
  st.session_state.pop(key, None)
 
164
  st.stop()
165
  user = dict(payload["user"])
166
  st.session_state["sp_edge_assertion"] = token
 
167
  st.session_state["sp_session_expires_at"] = payload["exp"]
168
  st.session_state["sp_user"] = user
169
  st.session_state["sp_auth_api_origin"] = str(payload["api_origin"]).rstrip("/")
 
171
  return user
172
 
173
 
174
+ def _bridge_key(prefix: str, value: str) -> str:
175
+ digest = hashlib.sha256(str(value).encode("utf-8")).hexdigest()[:20]
176
+ return f"{prefix}_{digest}"
177
+
178
+
179
+ def _edge_call(
180
+ action: str,
181
+ command_id: str,
182
+ payload: dict[str, Any] | None = None,
183
+ *,
184
+ fire_and_forget: bool = False,
185
+ key_prefix: str = "sp_edge",
186
+ ) -> dict[str, Any] | None:
187
+ return edge_bridge(
188
+ action=action,
189
+ command_id=command_id,
190
+ api_origin=_api_base(),
191
+ payload=payload or {},
192
+ fire_and_forget=fire_and_forget,
193
+ key=_bridge_key(key_prefix, command_id),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  )
195
 
196
 
197
+ def _raise_edge_error(result: dict[str, Any], fallback: str) -> None:
198
+ message = (
199
+ result.get("message")
200
+ or result.get("reason")
201
+ or result.get("error")
202
+ or fallback
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  )
204
+ raise RuntimeError(str(message))
205
 
206
+
207
+ def _merge_quota_into_session(result: dict[str, Any]) -> None:
208
+ """Refresh the approved account card immediately after a quota mutation."""
209
+ quota = result.get("quota")
210
+ if not isinstance(quota, dict):
211
+ return
212
+ user = dict(st.session_state.get("sp_user") or {})
213
+ for key in (
214
+ "quota_date",
215
+ "daily_free_limit",
216
+ "daily_free_reserved",
217
+ "daily_free_used",
218
+ "daily_free_remaining",
219
+ "bonus_papers",
220
+ ):
221
+ if key in quota:
222
+ user[key] = quota[key]
223
+ if user:
224
+ st.session_state["sp_user"] = user
225
+ st.session_state["sp_account_revision"] = int(
226
+ st.session_state.get("sp_account_revision") or 0
227
+ ) + 1
228
+
229
+
230
+ def get_account_snapshot(*, block: bool = False) -> dict[str, Any] | None:
231
+ bucket = int(time.time() // 30)
232
+ revision = int(st.session_state.get("sp_account_revision") or 0)
233
+ command_id = f"account:{revision}:{bucket}"
234
+ result = _edge_call("account", command_id, {}, key_prefix="sp_account")
235
+ if result is None:
236
+ if block:
237
+ st.stop()
238
+ return None
239
+ if not result.get("ok"):
240
+ if block:
241
+ _raise_edge_error(result, "account_query_failed")
242
+ return None
243
+ user = result.get("user")
244
+ if isinstance(user, dict):
245
+ st.session_state["sp_user"] = dict(user)
246
+ return result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
 
248
 
249
  def start_generation_task(*, idempotency_key: str, mode: str, interface_language: str) -> dict[str, Any]:
250
+ normalized = str(idempotency_key or "").strip()
251
+ if not normalized:
252
+ raise RuntimeError("idempotency_key_required")
253
+ result = _edge_call(
254
+ "task_start",
255
+ normalized,
256
+ {
257
+ "idempotency_key": normalized,
258
+ "mode": str(mode or "").strip().lower(),
259
+ "interface_language": str(interface_language or "zh").strip(),
260
+ },
261
+ key_prefix="sp_task_start",
 
 
262
  )
263
+ if result is None:
264
+ st.stop()
265
+ if not result.get("ok"):
266
+ _raise_edge_error(result, "task_start_failed")
267
+ _merge_quota_into_session(result)
268
+ return dict(result)
269
 
270
 
271
  def recover_generation_task() -> dict[str, Any]:
272
+ command_id = f"recover:{int(time.time() // 10)}"
273
+ result = _edge_call("task_recover", command_id, {}, key_prefix="sp_task_recover")
274
+ if result is None:
275
+ st.stop()
276
+ if not result.get("ok"):
277
+ _raise_edge_error(result, "task_recover_failed")
278
+ return dict(result)
279
 
280
 
281
  def heartbeat_generation_task(task_id: str, *, current_node: str = "") -> dict[str, Any]:
282
+ normalized = str(task_id or "").strip()
283
+ if not normalized:
284
+ raise RuntimeError("task_id_required")
285
+ bucket = int(time.time() // 20)
286
+ _edge_call(
287
+ "task_heartbeat",
288
+ f"heartbeat:{normalized}:{bucket}",
289
+ {"task_id": normalized, "current_node": str(current_node or "")[:240]},
290
+ fire_and_forget=True,
291
+ key_prefix="sp_task_heartbeat",
292
+ )
293
+ return {"ok": True, "queued": True, "cancel_requested": False}
294
 
295
 
296
  def generation_task_status(task_id: str) -> dict[str, Any]:
297
+ normalized = str(task_id or "").strip()
298
+ command_id = f"status:{normalized}:{int(time.time() // 5)}"
299
+ result = _edge_call(
300
+ "task_status", command_id, {"task_id": normalized}, key_prefix="sp_task_status"
301
+ )
302
+ if result is None:
303
+ st.stop()
304
+ if not result.get("ok"):
305
+ _raise_edge_error(result, "task_status_failed")
306
+ return dict(result)
307
+
308
+
309
+ def finish_generation_task(
310
+ task_id: str,
311
+ *,
312
+ status: str,
313
+ safe_error_code: str = "",
314
+ safe_error_detail: str = "",
315
+ current_node: str = "",
316
+ ) -> dict[str, Any]:
317
+ """Finalize a task only after the browser receives Worker/D1 confirmation."""
318
+ normalized_task = str(task_id or "").strip()
319
+ normalized_status = str(status or "").strip().lower()
320
+ if not normalized_task:
321
+ raise RuntimeError("task_id_required")
322
+ if normalized_status not in {"completed", "failed", "cancelled", "resumable"}:
323
+ raise RuntimeError("invalid_finish_status")
324
+ command_id = f"finish:{normalized_task}:{normalized_status}"
325
+ result = _edge_call(
326
+ "task_finish",
327
+ command_id,
328
+ {
329
+ "task_id": normalized_task,
330
+ "status": normalized_status,
331
+ "safe_error_code": str(safe_error_code or "")[:80],
332
+ "safe_error_detail": str(safe_error_detail or "")[:800],
333
+ "current_node": str(current_node or "")[:240],
334
+ },
335
+ fire_and_forget=False,
336
+ key_prefix="sp_task_finish",
337
+ )
338
+ if result is None:
339
+ st.stop()
340
+ if not result.get("ok"):
341
+ _raise_edge_error(result, "task_finish_failed")
342
+ task = dict(result.get("task") or {})
343
+ reservation = dict(result.get("reservation") or {})
344
+ if str(task.get("task_id") or "") != normalized_task:
345
+ raise RuntimeError("task_finish_confirmation_mismatch")
346
+ if str(task.get("status") or "").lower() != normalized_status:
347
+ raise RuntimeError("task_finish_status_not_confirmed")
348
+ expected_reservation = {
349
+ "completed": "committed",
350
+ "failed": "released",
351
+ "cancelled": "released",
352
+ "resumable": "reserved",
353
+ }[normalized_status]
354
+ if str(reservation.get("status") or "").lower() != expected_reservation:
355
+ raise RuntimeError("task_quota_finalization_not_confirmed")
356
+ _merge_quota_into_session(result)
357
+ return dict(result)
358
 
359
 
360
  def cancel_generation_task(task_id: str | None = None) -> dict[str, Any]:
361
+ """Cancel synchronously so quota release is confirmed before local cleanup."""
362
+ normalized = str(task_id or "").strip()
363
+ command_id = f"cancel:{normalized or 'latest'}"
364
+ result = _edge_call(
365
+ "task_cancel",
366
+ command_id,
367
+ {"task_id": normalized} if normalized else {},
368
+ fire_and_forget=False,
369
+ key_prefix="sp_task_cancel",
370
+ )
371
+ if result is None:
372
+ st.stop()
373
+ if not result.get("ok"):
374
+ _raise_edge_error(result, "task_cancel_failed")
375
+ reservation = result.get("reservation")
376
+ if result.get("cancelled") and reservation and str(reservation.get("status") or "").lower() != "released":
377
+ raise RuntimeError("task_cancel_quota_not_released")
378
+ _merge_quota_into_session(result)
379
+ return dict(result)
380
 
381
 
382
  def logout_alipay_session(on_logout: Callable[[str, str], None] | None = None) -> None:
 
 
 
 
 
 
383
  user_id = str((st.session_state.get("sp_user") or {}).get("id") or "")
384
  task = st.session_state.get("sp_generation_task") or {}
385
  task_id = str(task.get("task_id") or "")
 
391
  _clear_session()
392
  _clear_generation_session_state()
393
 
394
+
395
+ def create_payment_order(idempotency_key: str) -> dict[str, Any]:
396
+ normalized = str(idempotency_key or "").strip()
397
+ result = _edge_call(
398
+ "pay_create",
399
+ normalized,
400
+ {"idempotency_key": normalized},
401
+ key_prefix="sp_pay_create",
402
+ )
403
+ if result is None:
404
+ st.stop()
405
+ if not result.get("ok"):
406
+ _raise_edge_error(result, "payment_create_failed")
407
+ return dict(result)
408
+
409
+
410
+ def payment_order_status(merchant_order_no: str) -> dict[str, Any]:
411
+ order_no = str(merchant_order_no or "").strip()
412
+ command_id = f"pay-status:{order_no}:{int(time.time() // 3)}"
413
+ result = _edge_call(
414
+ "pay_status",
415
+ command_id,
416
+ {"merchant_order_no": order_no},
417
+ key_prefix="sp_pay_status",
418
+ )
419
+ if result is None:
420
+ st.stop()
421
+ if not result.get("ok"):
422
+ _raise_edge_error(result, "payment_status_failed")
423
+ return dict(result)
424
+
425
+
426
+ def _qr_png_bytes(value: str) -> bytes:
427
+ image = qrcode.make(str(value))
428
+ buffer = io.BytesIO()
429
+ image.save(buffer, format="PNG")
430
+ return buffer.getvalue()
431
+
432
+
433
+ def _render_payment_purchase_body(language: str) -> None:
434
+ st.warning(tr(language, "payment_notice"))
435
+
436
+ if st.button(tr(language, "payment_create"), key="sp_create_payment_order"):
437
+ st.session_state["sp_payment_request_id"] = (
438
+ f"pay_{int(time.time() * 1000)}_{secrets.token_urlsafe(12)}"
439
+ )
440
+
441
+ request_id = str(st.session_state.get("sp_payment_request_id") or "").strip()
442
+ order = st.session_state.get("sp_payment_order")
443
+ if request_id and not order:
444
+ st.info(tr(language, "payment_creating"))
445
+ order_result = create_payment_order(request_id)
446
+ order = dict(order_result.get("order") or {})
447
+ if not order.get("merchant_order_no") or not order.get("qr_code"):
448
+ raise RuntimeError("payment_order_missing_qr_code")
449
+ st.session_state["sp_payment_order"] = order
450
+
451
+ order = st.session_state.get("sp_payment_order") or {}
452
+ qr_code = str(order.get("qr_code") or "").strip()
453
+ if qr_code:
454
+ st.image(_qr_png_bytes(qr_code), width=280)
455
+ st.caption(tr(language, "payment_scan"))
456
+ if st.button(tr(language, "payment_check"), key="sp_check_payment_status"):
457
+ status_result = payment_order_status(order.get("merchant_order_no", ""))
458
+ updated = dict(status_result.get("order") or {})
459
+ st.session_state["sp_payment_order"] = updated
460
+ if updated.get("status") == "paid" and updated.get("credit_applied"):
461
+ user = status_result.get("user")
462
+ if isinstance(user, dict):
463
+ st.session_state["sp_user"] = dict(user)
464
+ st.session_state["sp_account_revision"] = int(
465
+ st.session_state.get("sp_account_revision") or 0
466
+ ) + 1
467
+ st.session_state["sp_payment_required"] = False
468
+ st.session_state["sp_payment_order"] = None
469
+ st.session_state["sp_payment_request_id"] = ""
470
+ st.success(tr(language, "payment_paid"))
471
+ elif updated.get("status") in {"closed", "failed"}:
472
+ st.session_state["sp_payment_order"] = None
473
+ st.session_state["sp_payment_request_id"] = ""
474
+ st.warning(tr(language, "payment_expired"))
475
+
476
+
477
+ def render_payment_purchase(language: str) -> None:
478
+ """Show payment in a modal so the approved normal-page layout is unchanged."""
479
+ dialog_factory = getattr(st, "dialog", None)
480
+ if callable(dialog_factory):
481
+ @dialog_factory(tr(language, "payment_dialog_title"))
482
+ def _payment_dialog():
483
+ _render_payment_purchase_body(language)
484
+ _payment_dialog()
485
+ return
486
+ # Compatibility fallback for older Streamlit builds.
487
+ _render_payment_purchase_body(language)
488
+
489
+
490
  def render_logged_in_account(
491
  language: str,
492
  user: dict[str, Any] | None = None,
493
  on_logout: Callable[[str, str], None] | None = None,
494
  ) -> None:
495
+ """Render the approved responsive account summary with live quota values."""
496
+ live = get_account_snapshot(block=False)
497
+ if live and isinstance(live.get("user"), dict):
498
+ user = dict(live["user"])
499
+ else:
500
+ user = user or st.session_state.get("sp_user", {})
501
  user_id = user.get("id", "—")
502
+ free_remaining = user.get("daily_free_remaining", user.get("daily_free_limit", 3))
503
  bonus_papers = user.get("bonus_papers", 0)
504
 
505
  st.markdown(
 
621
  )
622
 
623
  info_col, logout_col = st.columns([5.9, 1.65])
 
624
  with info_col:
625
  st.markdown(
626
  f"""
627
  <div class="sp-account-card">
628
+ <span>{html.escape(tr(language, 'account_chip_user', value=user_id))}</span>
629
  <span class="sp-account-separator">•</span>
630
+ <span>{html.escape(tr(language, 'account_chip_free', value=free_remaining))}</span>
631
  <span class="sp-account-separator">•</span>
632
+ <span>{html.escape(tr(language, 'account_chip_bonus', value=bonus_papers))}</span>
633
  </div>
634
  """,
635
  unsafe_allow_html=True,
636
  )
 
637
  with logout_col:
638
  logout_label = html.escape(tr(language, "logout_short"))
639
  logout_help = html.escape(tr(language, "logout"), quote=True)
requirements.txt CHANGED
@@ -1,17 +1,14 @@
1
  streamlit==1.38.0
2
- requests
3
- python-docx
4
- pandas
5
- numpy
6
- statsmodels
7
- tiktoken
8
- tenacity
9
- langdetect
10
- mistune>=3.0.0
11
- beautifulsoup4
12
- PyMuPDF
13
- openpyxl
14
- tabulate
15
- pycryptodome
16
- qrcode[pil]>=8.0,<9
17
- streamlit-autorefresh==1.0.1
 
1
  streamlit==1.38.0
2
+ requests>=2.31,<3
3
+ python-docx>=1.1,<2
4
+ pandas>=2.1,<3
5
+ numpy>=1.26,<3
6
+ scikit-learn>=1.4,<2
7
+ tiktoken>=0.7,<1
8
+ langdetect==1.0.9
9
+ mistune>=3,<4
10
+ beautifulsoup4>=4.12,<5
11
+ PyMuPDF>=1.24,<2
12
+ openpyxl>=3.1,<4
13
+ tabulate>=0.9,<1
14
+ qrcode[pil]>=8,<9
 
 
 
smartpaper_restore_bridge/__init__.py CHANGED
@@ -1,8 +1,10 @@
1
  # -*- coding: utf-8 -*-
2
- """Invisible browser storage bridge used by SmartPaper authentication.
3
 
4
- The component is intentionally dependency-free. Its frontend is served by
5
- Streamlit itself and returns only JSON-serializable status data to Python.
 
 
6
  """
7
 
8
  from __future__ import annotations
@@ -31,18 +33,47 @@ def restore_bridge(
31
  expires_at: str = "",
32
  key: str = "sp_restore_bridge",
33
  ) -> dict[str, Any] | None:
34
- """Read, write, or delete SmartPaper's browser recovery token.
35
-
36
- The return value is asynchronous: the first call normally returns ``None``;
37
- after the frontend completes the requested storage operation, Streamlit
38
- reruns the script and this function returns a result dictionary.
39
- """
40
-
41
  value = _COMPONENT(
42
  action=str(action or "read"),
43
  command_id=str(command_id or ""),
44
  token=str(token or ""),
45
  expires_at=str(expires_at or ""),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  height=0,
47
  default=None,
48
  key=key,
 
1
  # -*- coding: utf-8 -*-
2
+ """Invisible browser bridge for SmartPaper recovery and edge API calls.
3
 
4
+ The component is served by Streamlit through the public Cloudflare origin. It
5
+ therefore allows the authenticated browser to call same-origin Worker endpoints
6
+ with the existing HttpOnly browser cookie. No service key or cookie value is
7
+ passed to Python or JavaScript arguments.
8
  """
9
 
10
  from __future__ import annotations
 
33
  expires_at: str = "",
34
  key: str = "sp_restore_bridge",
35
  ) -> dict[str, Any] | None:
36
+ """Backward-compatible recovery-token storage operation."""
 
 
 
 
 
 
37
  value = _COMPONENT(
38
  action=str(action or "read"),
39
  command_id=str(command_id or ""),
40
  token=str(token or ""),
41
  expires_at=str(expires_at or ""),
42
+ api_origin="",
43
+ payload={},
44
+ fire_and_forget=False,
45
+ height=0,
46
+ default=None,
47
+ key=key,
48
+ )
49
+ return value if isinstance(value, dict) else None
50
+
51
+
52
+ def edge_bridge(
53
+ *,
54
+ action: str,
55
+ command_id: str,
56
+ api_origin: str,
57
+ payload: dict[str, Any] | None = None,
58
+ fire_and_forget: bool = False,
59
+ key: str = "sp_edge_bridge",
60
+ ) -> dict[str, Any] | None:
61
+ """Execute one authenticated same-origin Worker command in the browser.
62
+
63
+ The first synchronous invocation usually returns ``None``. Streamlit reruns
64
+ after the browser completes the request and then returns the result. Only
65
+ non-terminal heartbeat calls may use ``fire_and_forget=True``. Task
66
+ completion, failure, cancellation, payment, and account mutations remain
67
+ synchronous so Python receives the Worker/D1 postcondition.
68
+ """
69
+ value = _COMPONENT(
70
+ action=str(action or ""),
71
+ command_id=str(command_id or ""),
72
+ token="",
73
+ expires_at="",
74
+ api_origin=str(api_origin or ""),
75
+ payload=dict(payload or {}),
76
+ fire_and_forget=bool(fire_and_forget),
77
  height=0,
78
  default=None,
79
  key=key,
smartpaper_restore_bridge/frontend/index.html CHANGED
@@ -3,18 +3,9 @@
3
  <head>
4
  <meta charset="utf-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>SmartPaper Restore Bridge</title>
7
  <style>
8
- html, body {
9
- width: 0;
10
- height: 0;
11
- min-width: 0;
12
- min-height: 0;
13
- margin: 0;
14
- padding: 0;
15
- overflow: hidden;
16
- background: transparent;
17
- }
18
  </style>
19
  </head>
20
  <body>
@@ -22,44 +13,62 @@
22
  (() => {
23
  "use strict";
24
 
25
- const STORAGE_KEY = "smartpaper.restore_token.v1";
26
  const LEGACY_COOKIE_NAME = "__Host-smartpaper_restore";
 
 
 
27
  const MAX_TOKEN_LENGTH = 4096;
 
 
 
28
  let lastResponseSignature = "";
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
  function sendMessage(type, data) {
31
- const message = Object.assign(
32
- { isStreamlitMessage: true, type: type },
33
- data || {}
34
- );
35
- window.parent.postMessage(message, "*");
36
  }
37
 
38
- function setFrameHeight(height) {
39
- sendMessage("streamlit:setFrameHeight", { height: height });
40
- }
41
 
42
  function sendValue(value) {
43
  const signature = JSON.stringify(value);
44
- if (signature === lastResponseSignature) {
45
- return;
46
- }
47
  lastResponseSignature = signature;
48
- sendMessage("streamlit:setComponentValue", { value: value });
 
 
 
 
 
 
 
 
 
 
 
 
49
  }
50
 
51
  function normalizedToken(value) {
52
  const token = typeof value === "string" ? value.trim() : "";
53
- if (!token || token.length > MAX_TOKEN_LENGTH) {
54
- return "";
55
- }
56
- return token;
57
  }
58
 
59
  function parseExpiry(value) {
60
- if (typeof value !== "string" || !value.trim()) {
61
- return null;
62
- }
63
  const timestamp = Date.parse(value);
64
  return Number.isFinite(timestamp) ? timestamp : null;
65
  }
@@ -67,178 +76,214 @@
67
  function readLegacyCookie() {
68
  try {
69
  const prefix = `${LEGACY_COOKIE_NAME}=`;
70
- const entries = document.cookie ? document.cookie.split("; ") : [];
71
- const entry = entries.find((item) => item.startsWith(prefix));
72
- if (!entry) {
73
- return "";
74
- }
75
- return normalizedToken(decodeURIComponent(entry.slice(prefix.length)));
76
- } catch (_) {
77
- return "";
78
- }
79
  }
80
 
81
  function deleteLegacyCookie() {
82
- try {
83
- document.cookie = `${LEGACY_COOKIE_NAME}=; Max-Age=0; Path=/; Secure; SameSite=Strict`;
84
- } catch (_) {
85
- // The legacy cookie is optional. Storage operations continue normally.
86
- }
87
  }
88
 
89
  function readStoredRecord() {
90
- const raw = window.localStorage.getItem(STORAGE_KEY);
91
- if (!raw) {
92
- return null;
 
 
 
 
93
  }
 
 
94
 
95
- let record;
96
- try {
97
- record = JSON.parse(raw);
98
- } catch (_) {
99
- window.localStorage.removeItem(STORAGE_KEY);
100
- return null;
101
- }
 
 
 
102
 
103
- const token = normalizedToken(record && record.token);
104
- if (!token) {
105
- window.localStorage.removeItem(STORAGE_KEY);
106
- return null;
107
  }
108
-
109
- const expiresAt =
110
- record && typeof record.expires_at === "string"
111
- ? record.expires_at
112
- : "";
113
- const expiryTimestamp = parseExpiry(expiresAt);
114
- if (expiresAt.trim() && expiryTimestamp === null) {
115
- window.localStorage.removeItem(STORAGE_KEY);
116
- return null;
117
  }
118
- if (expiryTimestamp !== null && expiryTimestamp <= Date.now()) {
119
- window.localStorage.removeItem(STORAGE_KEY);
120
- return null;
 
121
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
122
 
123
- return { token: token, expires_at: expiresAt };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  }
125
 
126
- function writeStoredRecord(token, expiresAt) {
127
- const normalized = normalizedToken(token);
128
- if (!normalized) {
129
- throw new Error("invalid_token");
130
- }
131
 
132
- const record = {
133
- token: normalized,
134
- expires_at: typeof expiresAt === "string" ? expiresAt : "",
135
- stored_at: new Date().toISOString(),
136
- };
137
- window.localStorage.setItem(STORAGE_KEY, JSON.stringify(record));
138
 
139
- const verified = readStoredRecord();
140
- if (!verified || verified.token !== normalized) {
141
- throw new Error("write_verification_failed");
 
142
  }
143
- deleteLegacyCookie();
144
- return verified;
145
  }
146
 
147
- function handleRead(commandId) {
148
- let record = readStoredRecord();
149
- let source = "local_storage";
150
 
151
- if (!record) {
152
- const legacyToken = readLegacyCookie();
153
- if (legacyToken) {
154
- record = writeStoredRecord(legacyToken, "");
155
- source = "legacy_cookie";
 
 
 
 
156
  }
157
  }
 
158
 
159
- if (!record) {
160
- return {
161
- action: "read",
162
- command_id: commandId,
163
- status: "missing",
164
- };
 
 
 
165
  }
 
166
 
167
- return {
168
- action: "read",
169
- command_id: commandId,
170
- status: "found",
171
- token: record.token,
172
- expires_at: record.expires_at || "",
173
- source: source,
174
- };
175
  }
176
 
177
- function handleWrite(commandId, token, expiresAt) {
178
- const record = writeStoredRecord(token, expiresAt);
179
- return {
180
- action: "write",
181
- command_id: commandId,
182
- status: "written",
183
- expires_at: record.expires_at || "",
184
- };
185
  }
186
 
187
- function handleDelete(commandId) {
188
- window.localStorage.removeItem(STORAGE_KEY);
189
- deleteLegacyCookie();
190
- const deleted = window.localStorage.getItem(STORAGE_KEY) === null;
191
- return {
192
- action: "delete",
193
- command_id: commandId,
194
- status: deleted ? "deleted" : "error",
195
- error: deleted ? "" : "delete_verification_failed",
196
- };
197
- }
198
-
199
- function onRender(event) {
200
- if (!event || event.source !== window.parent) {
201
- return;
 
202
  }
203
- const message = event.data || {};
204
- if (message.type !== "streamlit:render") {
205
- return;
 
 
 
 
 
 
 
 
 
 
206
  }
 
 
207
 
 
 
 
 
208
  const args = message.args || {};
209
  const action = typeof args.action === "string" ? args.action : "read";
210
- const commandId =
211
- typeof args.command_id === "string" ? args.command_id : "";
212
-
213
  let response;
214
  try {
215
- if (action === "write") {
216
- response = handleWrite(
217
- commandId,
218
- typeof args.token === "string" ? args.token : "",
219
- typeof args.expires_at === "string" ? args.expires_at : ""
220
- );
221
- } else if (action === "delete") {
222
- response = handleDelete(commandId);
223
- } else {
224
- response = handleRead(commandId);
225
- }
226
  } catch (_) {
227
- response = {
228
- action: action,
229
- command_id: commandId,
230
- status: "error",
231
- error: "storage_unavailable",
232
- };
233
  }
234
-
235
- setFrameHeight(0);
236
  sendValue(response);
237
  }
238
 
239
  window.addEventListener("message", onRender);
240
- sendMessage("streamlit:componentReady", { apiVersion: 1 });
241
- setFrameHeight(0);
 
242
  })();
243
  </script>
244
  </body>
 
3
  <head>
4
  <meta charset="utf-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>SmartPaper Browser Bridge</title>
7
  <style>
8
+ html, body { width:0; height:0; min-width:0; min-height:0; margin:0; padding:0; overflow:hidden; background:transparent; }
 
 
 
 
 
 
 
 
 
9
  </style>
10
  </head>
11
  <body>
 
13
  (() => {
14
  "use strict";
15
 
16
+ const RESTORE_KEY = "smartpaper.restore_token.v1";
17
  const LEGACY_COOKIE_NAME = "__Host-smartpaper_restore";
18
+ const ACTIVE_TASK_KEY = "smartpaper.edge.active_task.v3";
19
+ const PENDING_OPS_KEY = "smartpaper.edge.pending_ops.v3";
20
+ const RESULT_PREFIX = "smartpaper.edge.result.v3:";
21
  const MAX_TOKEN_LENGTH = 4096;
22
+ const MAX_RESULT_AGE_MS = 10 * 60 * 1000;
23
+ const HEARTBEAT_MS = 25000;
24
+ const REQUEST_TIMEOUT_MS = 20000;
25
  let lastResponseSignature = "";
26
+ let heartbeatTimer = null;
27
+
28
+ const ACTION_PATHS = Object.freeze({
29
+ task_start: "/edge/task/start",
30
+ task_heartbeat: "/edge/task/heartbeat",
31
+ task_finish: "/edge/task/finish",
32
+ task_cancel: "/edge/task/cancel",
33
+ task_status: "/edge/task/status",
34
+ task_recover: "/edge/task/recover",
35
+ account: "/edge/account",
36
+ pay_create: "/edge/pay/create",
37
+ pay_status: "/edge/pay/status"
38
+ });
39
 
40
  function sendMessage(type, data) {
41
+ window.parent.postMessage(Object.assign({ isStreamlitMessage:true, type }, data || {}), "*");
 
 
 
 
42
  }
43
 
44
+ function setFrameHeight() { sendMessage("streamlit:setFrameHeight", { height:0 }); }
 
 
45
 
46
  function sendValue(value) {
47
  const signature = JSON.stringify(value);
48
+ if (signature === lastResponseSignature) return;
 
 
49
  lastResponseSignature = signature;
50
+ sendMessage("streamlit:setComponentValue", { value });
51
+ }
52
+
53
+ function readJson(key, fallback=null) {
54
+ try {
55
+ const raw = window.localStorage.getItem(key);
56
+ return raw ? JSON.parse(raw) : fallback;
57
+ } catch (_) { return fallback; }
58
+ }
59
+
60
+ function writeJson(key, value) {
61
+ try { window.localStorage.setItem(key, JSON.stringify(value)); return true; }
62
+ catch (_) { return false; }
63
  }
64
 
65
  function normalizedToken(value) {
66
  const token = typeof value === "string" ? value.trim() : "";
67
+ return token && token.length <= MAX_TOKEN_LENGTH ? token : "";
 
 
 
68
  }
69
 
70
  function parseExpiry(value) {
71
+ if (typeof value !== "string" || !value.trim()) return null;
 
 
72
  const timestamp = Date.parse(value);
73
  return Number.isFinite(timestamp) ? timestamp : null;
74
  }
 
76
  function readLegacyCookie() {
77
  try {
78
  const prefix = `${LEGACY_COOKIE_NAME}=`;
79
+ const entry = (document.cookie ? document.cookie.split("; ") : []).find(item => item.startsWith(prefix));
80
+ return entry ? normalizedToken(decodeURIComponent(entry.slice(prefix.length))) : "";
81
+ } catch (_) { return ""; }
 
 
 
 
 
 
82
  }
83
 
84
  function deleteLegacyCookie() {
85
+ try { document.cookie = `${LEGACY_COOKIE_NAME}=; Max-Age=0; Path=/; Secure; SameSite=Strict`; }
86
+ catch (_) {}
 
 
 
87
  }
88
 
89
  function readStoredRecord() {
90
+ const record = readJson(RESTORE_KEY, null);
91
+ const token = normalizedToken(record && record.token);
92
+ if (!token) { window.localStorage.removeItem(RESTORE_KEY); return null; }
93
+ const expiresAt = record && typeof record.expires_at === "string" ? record.expires_at : "";
94
+ const expiry = parseExpiry(expiresAt);
95
+ if ((expiresAt.trim() && expiry === null) || (expiry !== null && expiry <= Date.now())) {
96
+ window.localStorage.removeItem(RESTORE_KEY); return null;
97
  }
98
+ return { token, expires_at:expiresAt };
99
+ }
100
 
101
+ function writeStoredRecord(token, expiresAt) {
102
+ const normalized = normalizedToken(token);
103
+ if (!normalized) throw new Error("invalid_token");
104
+ const record = { token:normalized, expires_at:typeof expiresAt === "string" ? expiresAt : "", stored_at:new Date().toISOString() };
105
+ if (!writeJson(RESTORE_KEY, record)) throw new Error("storage_unavailable");
106
+ const verified = readStoredRecord();
107
+ if (!verified || verified.token !== normalized) throw new Error("write_verification_failed");
108
+ deleteLegacyCookie();
109
+ return verified;
110
+ }
111
 
112
+ function handleRestore(action, commandId, args) {
113
+ if (action === "write") {
114
+ const record = writeStoredRecord(args.token || "", args.expires_at || "");
115
+ return { action, command_id:commandId, status:"written", expires_at:record.expires_at || "" };
116
  }
117
+ if (action === "delete") {
118
+ window.localStorage.removeItem(RESTORE_KEY); deleteLegacyCookie();
119
+ const deleted = window.localStorage.getItem(RESTORE_KEY) === null;
120
+ return { action, command_id:commandId, status:deleted ? "deleted" : "error", error:deleted ? "" : "delete_verification_failed" };
 
 
 
 
 
121
  }
122
+ let record = readStoredRecord(); let source = "local_storage";
123
+ if (!record) {
124
+ const legacy = readLegacyCookie();
125
+ if (legacy) { record = writeStoredRecord(legacy, ""); source = "legacy_cookie"; }
126
  }
127
+ return record
128
+ ? { action:"read", command_id:commandId, status:"found", token:record.token, expires_at:record.expires_at || "", source }
129
+ : { action:"read", command_id:commandId, status:"missing" };
130
+ }
131
+
132
+ function normalizeOrigin(value) {
133
+ try {
134
+ const parsed = new URL(String(value || ""));
135
+ if (parsed.protocol !== "https:") return "";
136
+ const parentOrigin = window.parent.location.origin;
137
+ return parsed.origin === parentOrigin ? parsed.origin : "";
138
+ } catch (_) { return ""; }
139
+ }
140
 
141
+ async function postJson(apiOrigin, path, payload) {
142
+ const origin = normalizeOrigin(apiOrigin);
143
+ if (!origin) return { ok:false, error:"invalid_api_origin", http_status:0 };
144
+ const controller = new AbortController();
145
+ const timer = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
146
+ try {
147
+ const response = await fetch(new URL(path, origin).toString(), {
148
+ method:"POST",
149
+ credentials:"include",
150
+ cache:"no-store",
151
+ redirect:"error",
152
+ headers:{ "Accept":"application/json", "Content-Type":"application/json", "X-SmartPaper-Client":"browser-bridge-v1" },
153
+ body:JSON.stringify(payload || {}),
154
+ signal:controller.signal
155
+ });
156
+ const text = await response.text();
157
+ let data = {};
158
+ try { data = text ? JSON.parse(text) : {}; }
159
+ catch (_) { data = { ok:false, error:"non_json_response", body_prefix:text.slice(0,200) }; }
160
+ return Object.assign({}, data, { ok:response.ok && data.ok !== false, http_status:response.status });
161
+ } catch (error) {
162
+ return {
163
+ ok:false,
164
+ error:error && error.name === "AbortError" ? "browser_edge_timeout" : "browser_edge_request_failed",
165
+ detail:String(error || "").slice(0,300),
166
+ http_status:0
167
+ };
168
+ } finally { window.clearTimeout(timer); }
169
  }
170
 
171
+ function pendingOperations() {
172
+ const value = readJson(PENDING_OPS_KEY, []);
173
+ return Array.isArray(value) ? value : [];
174
+ }
 
175
 
176
+ function storePending(items) { writeJson(PENDING_OPS_KEY, items.slice(-30)); }
 
 
 
 
 
177
 
178
+ function queuePending(operation) {
179
+ const items = pendingOperations();
180
+ if (!items.some(item => item.command_id === operation.command_id)) {
181
+ items.push(operation); storePending(items);
182
  }
 
 
183
  }
184
 
185
+ function removePending(commandId) {
186
+ storePending(pendingOperations().filter(item => item.command_id !== commandId));
187
+ }
188
 
189
+ async function drainPending() {
190
+ for (const operation of pendingOperations()) {
191
+ const result = await postJson(operation.api_origin, operation.path, operation.payload);
192
+ if (result.ok) {
193
+ removePending(operation.command_id);
194
+ } else if ([401,403].includes(Number(result.http_status || 0))) {
195
+ // Authentication may be restored by a new Alipay session. Keep the
196
+ // transient heartbeat operation out of the terminal task state.
197
+ removePending(operation.command_id);
198
  }
199
  }
200
+ }
201
 
202
+ async function heartbeatActiveTask() {
203
+ const active = readJson(ACTIVE_TASK_KEY, null);
204
+ if (!active || !active.api_origin || !active.task_id) return;
205
+ const result = await postJson(active.api_origin, ACTION_PATHS.task_heartbeat, {
206
+ task_id:active.task_id,
207
+ current_node:active.current_node || "generation"
208
+ });
209
+ if (result.cancel_requested || [401,403,404,409].includes(Number(result.http_status || 0))) {
210
+ window.localStorage.removeItem(ACTIVE_TASK_KEY);
211
  }
212
+ }
213
 
214
+ function startBackgroundWork() {
215
+ if (heartbeatTimer) return;
216
+ heartbeatTimer = window.setInterval(() => {
217
+ heartbeatActiveTask();
218
+ drainPending();
219
+ }, HEARTBEAT_MS);
220
+ heartbeatActiveTask();
221
+ drainPending();
222
  }
223
 
224
+ function cachedResult(commandId) {
225
+ const cached = readJson(RESULT_PREFIX + commandId, null);
226
+ if (!cached || Date.now() - Number(cached.saved_at || 0) > MAX_RESULT_AGE_MS) return null;
227
+ return cached.value || null;
 
 
 
 
228
  }
229
 
230
+ function saveResult(commandId, value) {
231
+ writeJson(RESULT_PREFIX + commandId, { saved_at:Date.now(), value });
232
+ }
233
+
234
+ async function handleEdgeAction(action, commandId, args) {
235
+ const path = ACTION_PATHS[action];
236
+ if (!path) return { ok:false, error:"unsupported_edge_action", action, command_id:commandId };
237
+ const apiOrigin = normalizeOrigin(args.api_origin || "");
238
+ if (!apiOrigin) return { ok:false, error:"invalid_api_origin", action, command_id:commandId };
239
+ const payload = args.payload && typeof args.payload === "object" ? args.payload : {};
240
+ const fireAndForget = Boolean(args.fire_and_forget);
241
+
242
+ if (fireAndForget) {
243
+ queuePending({ command_id:commandId, api_origin:apiOrigin, path, payload });
244
+ drainPending();
245
+ return { ok:true, queued:true, action, command_id:commandId };
246
  }
247
+
248
+ const cached = cachedResult(commandId);
249
+ if (cached) return cached;
250
+ const result = Object.assign(await postJson(apiOrigin, path, payload), { action, command_id:commandId });
251
+ // Cache only confirmed successes. Transient network and HTTP failures must
252
+ // be retryable on the next Streamlit rerun with the same idempotency key.
253
+ if (result.ok) saveResult(commandId, result);
254
+
255
+ if (action === "task_start" && result.ok && result.task && result.task.task_id) {
256
+ writeJson(ACTIVE_TASK_KEY, { api_origin:apiOrigin, task_id:result.task.task_id, current_node:"generation" });
257
+ }
258
+ if (["task_finish", "task_cancel"].includes(action) && result.ok) {
259
+ window.localStorage.removeItem(ACTIVE_TASK_KEY);
260
  }
261
+ return result;
262
+ }
263
 
264
+ async function onRender(event) {
265
+ if (!event || event.source !== window.parent) return;
266
+ const message = event.data || {};
267
+ if (message.type !== "streamlit:render") return;
268
  const args = message.args || {};
269
  const action = typeof args.action === "string" ? args.action : "read";
270
+ const commandId = typeof args.command_id === "string" ? args.command_id : "";
 
 
271
  let response;
272
  try {
273
+ response = ["read","write","delete"].includes(action)
274
+ ? handleRestore(action, commandId, args)
275
+ : await handleEdgeAction(action, commandId, args);
 
 
 
 
 
 
 
 
276
  } catch (_) {
277
+ response = { ok:false, action, command_id:commandId, status:"error", error:"browser_bridge_failure" };
 
 
 
 
 
278
  }
279
+ setFrameHeight();
 
280
  sendValue(response);
281
  }
282
 
283
  window.addEventListener("message", onRender);
284
+ sendMessage("streamlit:componentReady", { apiVersion:1 });
285
+ setFrameHeight();
286
+ startBackgroundWork();
287
  })();
288
  </script>
289
  </body>
utils/docx_builder.py CHANGED
@@ -3,9 +3,14 @@ import io
3
  import mistune
4
  from bs4 import BeautifulSoup
5
  from docx import Document
6
- from docx.shared import Inches, Pt
7
  from config import APP_CONFIG
8
 
 
 
 
 
 
9
  class DocxRenderer(mistune.HTMLRenderer):
10
  def __init__(self):
11
  super().__init__()
@@ -130,17 +135,18 @@ class DocxRenderer(mistune.HTMLRenderer):
130
  return ''
131
 
132
  def text_to_docx(markdown_content):
 
133
  try:
134
  renderer = DocxRenderer()
135
- mistune.create_markdown(renderer=renderer, plugins=['table'])(markdown_content)
136
  doc = renderer.doc
137
- except Exception:
138
- doc = Document()
139
- for line in markdown_content.split('\n'):
140
- if line.strip():
141
- doc.add_paragraph(line)
142
-
143
- stream = io.BytesIO()
144
- doc.save(stream)
145
- stream.seek(0)
146
- return stream
 
3
  import mistune
4
  from bs4 import BeautifulSoup
5
  from docx import Document
6
+ from docx.shared import Pt
7
  from config import APP_CONFIG
8
 
9
+
10
+ class DocxBuildError(RuntimeError):
11
+ """Raised when markdown cannot be rendered into a valid DOCX document."""
12
+
13
+
14
  class DocxRenderer(mistune.HTMLRenderer):
15
  def __init__(self):
16
  super().__init__()
 
135
  return ''
136
 
137
  def text_to_docx(markdown_content):
138
+ """Render markdown strictly; never disguise renderer failures as success."""
139
  try:
140
  renderer = DocxRenderer()
141
+ mistune.create_markdown(renderer=renderer, plugins=['table'])(str(markdown_content or ''))
142
  doc = renderer.doc
143
+ if not doc.paragraphs and not doc.tables:
144
+ raise DocxBuildError("DOCX renderer produced no document content.")
145
+ stream = io.BytesIO()
146
+ doc.save(stream)
147
+ stream.seek(0)
148
+ return stream
149
+ except DocxBuildError:
150
+ raise
151
+ except Exception as exc:
152
+ raise DocxBuildError(f"DOCX rendering failed: {exc}") from exc