Deploy SmartPaper production v1.1 R1Fix

#2
.streamlit/desktop.ini ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [.ShellClassInfo]
2
+ IconResource=C:\Program Files\Google\Drive File Stream\129.0.1.0\GoogleDriveFS.exe,27
HF_COMMIT_SHA.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ af60fe1bdcbab60f47f06586cca03210f7f61026
app.py CHANGED
@@ -1,260 +1,52 @@
1
  # -*- coding: utf-8 -*-
2
- """Smart Draft - Dual Track (AMJ Empirical vs ASQ Qualitative)"""
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(
9
- page_title="Article Agent",
10
- page_icon="📊",
11
- layout="wide",
12
- initial_sidebar_state="collapsed",
13
- )
14
-
15
- import warnings
16
- warnings.filterwarnings("ignore", message="coroutine 'expire_cache' was never awaited")
17
-
18
  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,
40
  render_logged_in_account,
 
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"
 
 
 
 
49
 
50
  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,
83
- current_node=str(st.session_state.get("sp_current_node") or "")[:240],
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()
114
- if now - last_beat > 5:
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')
122
- for i in range(len(lines)-1, max(-1, len(lines)-100), -1):
123
- if any(re.match(rf'^##?\s*{kw}', lines[i], re.IGNORECASE) for kw in keywords):
124
- return '\n'.join(lines[:i]).strip()
125
- return text.strip()
126
-
127
- def clean_markdown_headings(text):
128
- return re.sub(r'(?m)^#+\s+(.*)$', r'**\1**', text)
129
-
130
- def _read_runtime_secret(name):
131
- # Read a Hugging Face/Streamlit secret and remove copied whitespace.
132
- value = os.environ.get(name, "")
133
- if not value:
134
- try:
135
- value = st.secrets.get(name, "")
136
- except Exception:
137
- value = ""
138
- return str(value or "").strip()
139
-
140
-
141
- global_ds_key = _read_runtime_secret("DEEPSEEK_API_KEY")
142
- global_s2_key = _read_runtime_secret("S2_API_KEY")
143
 
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."""
@@ -462,1031 +254,504 @@ def inject_wechat_browser_notice() -> None:
462
  components.html(script, height=0, width=0)
463
 
464
 
465
- if "is_running" not in st.session_state: st.session_state.is_running = False
466
- if "start_pipeline" not in st.session_state: st.session_state.start_pipeline = False
467
- if "resume_pipeline" not in st.session_state: st.session_state.resume_pipeline = False
468
- if "zip_buffer" not in st.session_state: st.session_state.zip_buffer = None
469
- if "run_mode" not in st.session_state: st.session_state.run_mode = "amj"
470
- if "sp_generation_task" not in st.session_state: st.session_state.sp_generation_task = None
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):
477
  cleaned = re.sub(r"[^A-Za-z0-9_.-]", "_", str(value or "").strip())
478
- return cleaned[:80] or "anonymous"
479
 
480
 
481
- def _load_recoverable_checkpoint():
482
- """Return a validated checkpoint for the current authenticated user only."""
483
- if not os.path.isfile(CHECKPOINT_FILE):
484
- return None
485
- try:
486
- with open(CHECKPOINT_FILE, "r", encoding="utf-8") as handle:
487
- state = json.load(handle)
488
- except Exception:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
489
  return None
490
- required = {
491
- "topic", "keywords", "target_lang", "words",
492
- "agent_state", "adaptive_structure", "completed_sections",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
493
  }
494
- if not isinstance(state, dict) or not required.issubset(state):
495
- return None
496
- if not isinstance(state.get("adaptive_structure"), list):
497
- return None
498
- if not isinstance(state.get("completed_sections"), list):
499
- return None
500
- return state
501
 
502
 
503
- def _new_generation_idempotency_key() -> str:
504
- return f"gen_{int(time.time() * 1000)}_{secrets.token_urlsafe(18)}"
 
 
 
 
 
 
505
 
506
 
507
- def _prepare_new_generation_request() -> None:
508
- st.session_state["sp_generation_idempotency_key"] = _new_generation_idempotency_key()
509
- st.session_state["sp_generation_task"] = None
510
- st.session_state["sp_last_task_heartbeat"] = 0.0
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):
517
- checkpoint_key = ""
518
- if is_resume and isinstance(loaded_state, dict):
519
- checkpoint_key = str(loaded_state.get("managed_task_idempotency_key") or "").strip()
520
 
521
- idempotency_key = checkpoint_key or str(
522
- st.session_state.get("sp_generation_idempotency_key") or ""
523
- ).strip()
524
- if not idempotency_key:
525
- idempotency_key = _new_generation_idempotency_key()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
526
 
527
- response = start_generation_task(
528
- idempotency_key=idempotency_key,
529
- mode=mode,
530
- interface_language=language,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
531
  )
532
- task = dict(response.get("task") or {})
533
- if not task.get("task_id"):
534
- raise RuntimeError("task_start_missing_task_id")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
535
 
536
- st.session_state["sp_generation_idempotency_key"] = idempotency_key
537
- st.session_state["sp_generation_task"] = task
538
- st.session_state["sp_last_task_heartbeat"] = time.time()
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
 
594
  DEFAULT_TOPIC = "Generative Artificial Intelligence in Ubiquitous Learning: Evaluating a Chatbot-based Recommendation Engine for Personalized and Context-aware Education"
595
  DEFAULT_KEYWORDS = "Ubiquitous computing; Context awareness; Deep learning; Personalized educational; Educational technologies; HCI; Recommendation system; LLMs; Generative AI; ChatGPT; Prompting engineering; Few-shot prompting"
596
 
597
- _fragment = getattr(st, "fragment", None) or getattr(st, "experimental_fragment", None)
598
- if _fragment is None:
599
- def _fragment(function):
600
- return function
601
-
602
-
603
  def _current_language_code() -> str:
604
  choice = st.session_state.get("interface_language_choice")
605
  return LANGUAGE_OPTIONS.get(choice, "zh")
606
 
607
 
608
- # Public access uses Alipay only. Password, legacy, and pilot login paths are not exposed.
609
  authenticated_user = require_alipay_login(_current_language_code())
610
-
611
- # Checkpoints are user-scoped. The former global snapshot.json could cause one
612
- # user or an old run to trigger a false recovery notice for another session.
613
- _user_workspace = os.path.join(
614
- WORKSPACE_DIR,
615
- "users",
616
- _safe_user_workspace_key(authenticated_user.get("id")),
617
- )
618
  os.makedirs(_user_workspace, exist_ok=True)
619
- CHECKPOINT_FILE = os.path.join(_user_workspace, "snapshot.json")
620
 
621
 
622
- @_fragment
623
  def render_workspace_controls(user):
624
- """Render controls in an isolated rerun scope for fast language switching."""
625
  top_toolbar = st.container()
626
-
627
  with top_toolbar:
628
  language_col, account_col = st.columns([5.4, 4.6])
629
-
630
  with language_col:
631
  lang_choice = st.radio(
632
- "Language",
633
- list(LANGUAGE_OPTIONS.keys()),
634
- index=0,
635
- horizontal=True,
636
- label_visibility="collapsed",
637
- key="interface_language_choice",
638
  )
639
-
640
  lang_code = LANGUAGE_OPTIONS[lang_choice]
641
  st.session_state["sp_lang_code"] = lang_code
642
  st.session_state["sp_target_lang"] = get_target_language(lang_code)
643
  st.session_state["sp_output_suffix"] = get_output_suffix(lang_code)
644
  st.session_state["sp_target_flag"] = get_target_flag(lang_code)
645
-
646
  with account_col:
647
  render_logged_in_account(lang_code, user)
648
 
649
- # Cross-device global busy check remains unchanged in function, but all visible text is localized.
650
- is_my_session = st.session_state.get("is_running", False) or \
651
- st.session_state.get("start_pipeline", False) or \
652
- st.session_state.get("resume_pipeline", False)
653
- ui_blocked = (not is_my_session and is_system_busy())
654
- st.session_state["sp_ui_blocked"] = ui_blocked
655
- if ui_blocked:
656
- st.markdown(
657
- f"<h3 style='text-align:center;color:#e6a23c;'>{tr(lang_code, 'system_busy_title')}</h3>",
658
- unsafe_allow_html=True,
659
- )
660
- st.warning(tr(lang_code, "system_busy_warning"))
661
- st.info(tr(lang_code, "system_busy_info"))
662
- return
663
 
664
  st.markdown(f"<div class='main-title'><h2>{tr(lang_code, 'title')}</h2></div>", unsafe_allow_html=True)
665
 
666
- if _load_recoverable_checkpoint() is not None and not (st.session_state.is_running or st.session_state.resume_pipeline or st.session_state.start_pipeline):
667
  st.warning(tr(lang_code, "checkpoint_warning"))
668
  colA, colB = st.columns([1, 1])
669
  if colA.button(tr(lang_code, "resume"), key="resume_task"):
670
- if is_system_busy():
671
- st.warning(tr(lang_code, "busy_short"))
672
- else:
673
- checkpoint_state = _load_recoverable_checkpoint() or {}
674
- checkpoint_key = str(checkpoint_state.get("managed_task_idempotency_key") or "").strip()
675
- st.session_state["sp_generation_idempotency_key"] = checkpoint_key or _new_generation_idempotency_key()
676
- st.session_state["sp_generation_task"] = None
677
- st.session_state["sp_last_task_heartbeat"] = 0.0
678
- update_heartbeat()
679
- st.session_state.resume_pipeline = True
680
- st.session_state.start_pipeline = False
681
- st.rerun()
682
-
683
  if colB.button(tr(lang_code, "clear"), key="clear_task"):
684
- if is_system_busy():
685
- st.warning(tr(lang_code, "busy_short"))
686
- else:
687
- checkpoint_state = _load_recoverable_checkpoint() or {}
688
- checkpoint_task_id = str(checkpoint_state.get("managed_task_id") or "").strip()
689
- if checkpoint_task_id:
690
- st.session_state["sp_generation_task"] = {"task_id": checkpoint_task_id}
691
- try:
692
- _finish_managed_generation_task(
693
- "cancelled",
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("---")
708
 
709
- st.text_input(
710
- tr(lang_code, "topic_label"),
711
- value=DEFAULT_TOPIC,
712
- disabled=st.session_state.is_running,
713
- key="sp_topic",
714
- )
715
- st.text_input(
716
- tr(lang_code, "keywords_label"),
717
- value=DEFAULT_KEYWORDS,
718
- disabled=st.session_state.is_running,
719
- key="sp_keywords",
720
- )
721
  st.number_input(
722
  tr(lang_code, "words_label"),
723
- min_value=12000,
724
- max_value=25000,
725
- value=20000,
726
  step=1000,
727
- disabled=st.session_state.is_running,
728
  key="sp_words",
729
  )
730
 
731
  tab_amj, tab_asq = st.tabs([tr(lang_code, "tab_amj"), tr(lang_code, "tab_asq")])
732
-
733
  with tab_amj:
734
  st.markdown(tr(lang_code, "amj_config"))
735
  st.info(tr(lang_code, "amj_info"))
736
- st.file_uploader(
737
- tr(lang_code, "upload_reference"),
738
- type=["pdf", "doc", "docx"],
739
- accept_multiple_files=True,
740
- key="up_amj_ref",
741
- disabled=st.session_state.is_running,
742
- )
743
- st.file_uploader(
744
- tr(lang_code, "upload_stats"),
745
- type=["csv", "xlsx", "xls"],
746
- key="up_amj_data",
747
- disabled=st.session_state.is_running,
748
- )
749
-
750
  with st.expander(tr(lang_code, "amj_expander")):
751
  st.markdown(tr(lang_code, "amj_hint"))
752
- amj_csv_template = get_amj_csv_template(lang_code)
753
- st.code(amj_csv_template, language="csv")
754
- st.download_button(
755
- label=tr(lang_code, "download_amj_template"),
756
- data=amj_csv_template,
757
- file_name="AMJ_Data_Template.csv",
758
- mime="text/csv",
759
- use_container_width=True,
760
- )
761
-
762
- if st.button(tr(lang_code, "start_amj"), disabled=st.session_state.is_running, key="btn_amj"):
763
- if is_system_busy():
764
- st.warning(tr(lang_code, "busy_short"))
765
- else:
766
- _prepare_new_generation_request()
767
- update_heartbeat()
768
- st.session_state.run_mode = "amj"
769
- st.session_state.start_pipeline = True
770
- st.rerun()
771
 
772
  with tab_asq:
773
  st.markdown(tr(lang_code, "asq_config"))
774
  st.info(tr(lang_code, "asq_info"))
775
- st.file_uploader(
776
- tr(lang_code, "upload_reference"),
777
- type=["pdf", "doc", "docx"],
778
- accept_multiple_files=True,
779
- key="up_asq_ref",
780
- disabled=st.session_state.is_running,
781
- )
782
- st.file_uploader(
783
- tr(lang_code, "upload_interviews"),
784
- type=["txt", "pdf", "doc", "docx"],
785
- key="up_asq_data",
786
- disabled=st.session_state.is_running,
787
- )
788
-
789
  with st.expander(tr(lang_code, "asq_expander")):
790
  st.markdown(tr(lang_code, "asq_hint"))
791
- asq_txt_template = get_asq_txt_template(lang_code)
792
- st.code(asq_txt_template, language="text")
793
- st.download_button(
794
- label=tr(lang_code, "download_asq_template"),
795
- data=asq_txt_template,
796
- file_name="ASQ_Interview_Template.txt",
797
- mime="text/plain",
798
- use_container_width=True,
799
- )
800
 
801
- if st.button(tr(lang_code, "start_asq"), disabled=st.session_state.is_running, key="btn_asq"):
802
- if is_system_busy():
803
- st.warning(tr(lang_code, "busy_short"))
804
- else:
805
- _prepare_new_generation_request()
806
- update_heartbeat()
807
- st.session_state.run_mode = "asq"
808
- st.session_state.start_pipeline = True
809
- st.rerun()
810
-
811
- # Localize Streamlit built-in uploader text after the uploaders are rendered.
812
  inject_file_uploader_i18n(lang_code)
813
 
814
 
815
  render_workspace_controls(authenticated_user)
816
-
817
  lang_code = str(st.session_state.get("sp_lang_code") or _current_language_code())
818
- target_lang = str(st.session_state.get("sp_target_lang") or get_target_language(lang_code))
819
- output_suffix = str(st.session_state.get("sp_output_suffix") or get_output_suffix(lang_code))
820
- target_flag = str(st.session_state.get("sp_target_flag") or get_target_flag(lang_code))
821
-
822
- topic = str(st.session_state.get("sp_topic") or DEFAULT_TOPIC)
823
- keywords = str(st.session_state.get("sp_keywords") or DEFAULT_KEYWORDS)
824
- words = int(st.session_state.get("sp_words") or 20000)
825
- uploaded_papers_amj = st.session_state.get("up_amj_ref") or []
826
- uploaded_data_amj = st.session_state.get("up_amj_data")
827
- uploaded_papers_asq = st.session_state.get("up_asq_ref") or []
828
- uploaded_interviews = st.session_state.get("up_asq_data")
829
-
830
- if st.session_state.get("sp_ui_blocked"):
831
- st.stop()
832
-
833
- progress_bar = st.empty()
834
- monitor_ui = st.empty()
835
-
836
- def render_monitor(agent, phase, current_sec="", details=""):
837
- st.session_state["sp_current_node"] = str(current_sec or phase or "")[:240]
838
- monitor_ui.markdown(f"""
839
  <div class='log-box'>
840
- <div><b>{tr(lang_code, 'monitor_pipeline')}:</b> {phase}</div><div><b>{tr(lang_code, 'monitor_node')}:</b> {current_sec}</div><div><b>{tr(lang_code, 'monitor_log')}:</b> {details}</div><hr>
841
  <div><b>{tr(lang_code, 'monitor_compute')}:</b></div>
842
  <div style='display: flex; gap: 8px;'>
843
- <span style='background: #fef3c7; padding: 4px 8px; border-radius: 4px; color: black;'>🧠 DeepSeek: <b>{agent.ds_calls}</b></span>
844
- <span style='background: #dbeafe; padding: 4px 8px; border-radius: 4px; color: black;'>📚 S2: <b>{agent.s2_calls}</b></span>
845
- <span style='background: #d1fae5; padding: 4px 8px; border-radius: 4px; color: black;'>🌐 OpenAlex: <b>{agent.oa_calls}</b></span>
846
  </div>
847
  </div>
848
  """, unsafe_allow_html=True)
849
 
850
- FLAG_MAP = {"Simplified Chinese (简体中文)": "🇨🇳", "Russian (Русский)": "🇷🇺", "Kazakh (Қазақша)": "🇰🇿"}
851
-
852
- if st.session_state.start_pipeline or st.session_state.resume_pipeline:
853
- if not global_ds_key:
854
- st.error(tr(lang_code, "api_key_missing"))
855
- st.stop()
856
-
857
- if st.session_state.get("is_running", False):
858
- recoverable_state = _load_recoverable_checkpoint()
859
- st.session_state.is_running = False
860
- st.session_state.start_pipeline = False
861
- st.session_state.resume_pipeline = bool(recoverable_state)
862
- if recoverable_state:
863
- st.toast(tr(lang_code, "resume_toast"), icon="⚡")
864
- update_heartbeat()
865
- st.rerun()
866
- # A normal page rerun without a valid checkpoint is not a recovery event.
867
- # Keep the already-rendered writing interface and show no recovery toast.
868
- clear_heartbeat()
869
- st.stop()
870
 
871
- is_resume = st.session_state.resume_pipeline
872
- loaded_state = _load_recoverable_checkpoint() if is_resume else None
873
- if is_resume and loaded_state is None:
874
- is_resume = False
875
-
876
- try:
877
- managed_task, managed_task_key = _begin_managed_generation_task(
878
- is_resume=is_resume,
879
- loaded_state=loaded_state,
880
- mode=st.session_state.run_mode,
881
- language=lang_code,
882
- )
883
- except Exception as exc:
884
- clear_heartbeat()
885
- st.session_state.is_running = False
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:
898
- update_heartbeat()
899
-
900
- if is_resume and loaded_state:
901
- pb = progress_bar.progress(50, text=tr(lang_code, "resume_loading"))
902
- state = loaded_state
903
- state["managed_task_idempotency_key"] = managed_task_key
904
- state["managed_task_id"] = managed_task.get("task_id", "")
905
- topic = state["topic"]
906
- keywords = state["keywords"]
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"])
913
- adaptive_structure = state["adaptive_structure"]
914
- completed_sections = state["completed_sections"]
915
- full_draft_en = state["full_draft_en"]
916
- full_draft_translated = state["full_draft_translated"]
917
- synthesis_result = state["synthesis_result"]
918
- refs_text = state["refs_text"]
919
- context_tail = state["context_tail"]
920
-
921
- st.session_state.run_mode = state.get("run_mode", "amj")
922
- if st.session_state.run_mode == "amj":
923
- APP_CONFIG.update(AMJ_PROMPTS)
924
- else:
925
- APP_CONFIG.update(ASQ_PROMPTS)
926
-
927
- st.success(tr(lang_code, "resume_success", done=len(completed_sections), total=len(adaptive_structure)))
928
- render_monitor(agent, tr(lang_code, "resume_phase"), tr(lang_code, "state_check"), tr(lang_code, "history_restored"))
929
-
930
  else:
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:
937
- user_lit_context = extract_text_from_files(uploaded_papers_amj[:20])
938
- with st.status(tr(lang_code, "literature_extract"), expanded=True) as status:
939
- ph = st.empty()
940
- all_paper_texts = [extract_text_from_files([f]) for f in uploaded_papers_amj[:20]]
941
-
942
- future = ThreadPoolExecutor(max_workers=1).submit(extract_golden_metadata, agent, all_paper_texts)
943
- dot_count = 0
944
- while not future.done():
945
- update_heartbeat()
946
- ph.markdown(tr(lang_code, "literature_deep") + " ⏳" * (dot_count % 4))
947
- time.sleep(1)
948
- dot_count += 1
949
-
950
- agent.global_memory["core_golden_metadata"] = future.result()
951
- st.session_state.rag_engine = initialize_rag(all_paper_texts)
952
- status.update(label=tr(lang_code, "literature_done"), state="complete", expanded=False)
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":
960
- if uploaded_papers_asq:
961
- user_lit_context = extract_text_from_files(uploaded_papers_asq[:20])
962
- with st.status(tr(lang_code, "literature_extract"), expanded=True) as status:
963
- ph = st.empty()
964
- all_paper_texts = [extract_text_from_files([f]) for f in uploaded_papers_asq[:20]]
965
-
966
- future = ThreadPoolExecutor(max_workers=1).submit(extract_golden_metadata, agent, all_paper_texts)
967
- dot_count = 0
968
- while not future.done():
969
- update_heartbeat()
970
- ph.markdown(tr(lang_code, "literature_deep") + " ⏳" * (dot_count % 4))
971
- time.sleep(1)
972
- dot_count += 1
973
-
974
- agent.global_memory["core_golden_metadata"] = future.result()
975
- st.session_state.rag_engine = initialize_rag(all_paper_texts)
976
- status.update(label=tr(lang_code, "literature_done"), state="complete", expanded=False)
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,
989
- "managed_task_id": managed_task.get("task_id", ""),
990
- }
991
-
992
- pb.progress(5, text=tr(lang_code, "config_route"))
993
-
994
- if st.session_state.run_mode == "amj":
995
- APP_CONFIG.update(AMJ_PROMPTS)
996
- state["prompt_config"] = AMJ_PROMPTS
997
- else:
998
- APP_CONFIG.update(ASQ_PROMPTS)
999
- state["prompt_config"] = ASQ_PROMPTS
1000
-
1001
- pb.progress(10, text=tr(lang_code, "literature_search"))
1002
- def sync_monitor_ui():
1003
- update_heartbeat()
1004
- render_monitor(agent, tr(lang_code, "search_phase"), "S2 + OpenAlex", tr(lang_code, "search_detail"))
1005
-
1006
- target_refs_count = 50
1007
- refs_text = agent.fetch_references_parallel(topic, keywords, min_required=target_refs_count, progress_callback=sync_monitor_ui)
1008
-
1009
- if len(agent.citation_map) < target_refs_count:
1010
- raise RuntimeError(
1011
- tr(lang_code, "refs_insufficient", count=len(agent.citation_map), required=target_refs_count)
1012
- )
1013
- else:
1014
- st.success(tr(lang_code, "refs_success", count=len(agent.citation_map)))
1015
-
1016
- with st.spinner(tr(lang_code, "rag_sync")):
1017
- update_heartbeat()
1018
- if hasattr(st.session_state, "rag_engine") and st.session_state.rag_engine is not None:
1019
- st.session_state.rag_engine._initialize_chunks([refs_text])
1020
- else:
1021
- st.session_state.rag_engine = initialize_rag([refs_text])
1022
- st.success(tr(lang_code, "rag_done"))
1023
-
1024
- if user_lit_context:
1025
- pb.progress(11, text=tr(lang_code, "core_literature_parsing"))
1026
- update_heartbeat()
1027
- core_refs = agent.parse_user_core_literature(user_lit_context)
1028
- if core_refs:
1029
- refs_text = "【USER PROVIDED CORE LITERATURE】\n" + core_refs + "\n\n【RETRIEVED LITERATURE】\n" + refs_text
1030
- st.success(tr(lang_code, "core_refs_done"))
1031
-
1032
- state["refs_text"] = refs_text
1033
-
1034
- pb.progress(12, text=tr(lang_code, "synthesis_progress"))
1035
- with st.status(tr(lang_code, "synthesis_status"), expanded=True) as status:
1036
- ph = st.empty()
1037
- synthesis_result = ph.write_stream(stream_with_heartbeat(agent.stream_synthesize_literature(topic, refs_text, user_core_literature=user_lit_context)))
1038
- synthesis_result = re.sub(r'<think>.*?</think>', '', synthesis_result, flags=re.DOTALL).strip()
1039
- status.update(label=tr(lang_code, "synthesis_done"), state="complete", expanded=False)
1040
- state["synthesis_result"] = synthesis_result
1041
-
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
- # ==========================================================
1049
- # [核心修复] 必须先剔除 <think> 标签!防止正则匹配到模型在思考时引用提示词的空占位符
1050
- # ==========================================================
1051
- anchor_response_clean = re.sub(r'<think>.*?</think>', '', anchor_response, flags=re.DOTALL).strip()
1052
-
1053
- abstract_match = re.search(r'-+\s*ABSTRACT\s*-+\s*(.*?)(?=\s*-+\s*CONCLUSION\s*-+|$)', anchor_response_clean, re.DOTALL | re.IGNORECASE)
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")
1070
-
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)
1092
- if jit_texts:
1093
- if hasattr(st.session_state, "rag_engine") and st.session_state.rag_engine is not None:
1094
- st.session_state.rag_engine._initialize_chunks([jit_texts])
1095
- else:
1096
- st.session_state.rag_engine = initialize_rag([jit_texts])
1097
- st.success(tr(lang_code, "jit_done"))
1098
-
1099
- state["adaptive_structure"] = adaptive_structure
1100
- state["agent_state"] = agent.export_state()
1101
- state["full_draft_en"] = full_draft_en
1102
- state["full_draft_translated"] = full_draft_translated
1103
- completed_sections = []
1104
- context_tail = ""
1105
-
1106
- os.makedirs(WORKSPACE_DIR, exist_ok=True)
1107
- with open(CHECKPOINT_FILE, "w", encoding="utf-8") as f:
1108
- json.dump(state, f, ensure_ascii=False)
1109
-
1110
- total_steps = len(adaptive_structure)
1111
- current_step = len(completed_sections)
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}")
1119
- col_en, col_trans = st.columns(2)
1120
- with col_en:
1121
- st.markdown(tr(lang_code, "english_draft"))
1122
- en_ph = st.empty()
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
1483
- st.error(tr(lang_code, "startup_error", error=e))
1484
- finally:
1485
- clear_heartbeat()
1486
- st.session_state.is_running = False
1487
- st.session_state.start_pipeline = False
1488
- st.session_state.resume_pipeline = False
1489
-
1490
- if st.session_state.zip_buffer:
1491
- st.success(tr(lang_code, "success"))
1492
- st.download_button(label=tr(lang_code, "download"), data=st.session_state.zip_buffer, file_name=st.session_state.zip_filename, mime="application/zip")
 
1
  # -*- coding: utf-8 -*-
2
+ """SmartPaper R3-Free UI: Streamlit is an observer, never the paper executor."""
3
+ import hashlib
4
+ import json
5
+ import logging
 
 
 
 
 
 
 
 
 
 
 
 
6
  import os
 
 
7
  import re
 
 
 
 
8
  import secrets
9
+ import shutil
10
+ import signal
11
+ import subprocess
12
+ import sys
13
+ import time
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+
17
+ import streamlit as st
18
+ import streamlit.components.v1 as components
19
+
20
+ from config import APP_CONFIG
21
  from managed_auth import (
22
+ cancel_generation_task,
23
+ generation_task_status,
24
+ recover_generation_task,
25
  require_alipay_login,
26
  render_logged_in_account,
27
+ render_payment_purchase,
28
  start_generation_task,
 
 
29
  )
30
+ from i18n import (
31
+ LANGUAGE_OPTIONS, get_amj_csv_template, get_asq_txt_template,
32
+ get_output_suffix, get_target_flag, get_target_language, tr,
33
+ )
34
 
35
+ st.set_page_config(
36
+ page_title="Article Agent",
37
+ page_icon="📊",
38
+ layout="wide",
39
+ initial_sidebar_state="collapsed",
40
+ )
41
 
42
  WORKSPACE_DIR = APP_CONFIG.get("workspace_dir", ".workspace")
43
  os.makedirs(WORKSPACE_DIR, exist_ok=True)
44
+ os.environ["STREAMLIT_SERVER_ENABLE_CORS"] = "false"
45
+ os.environ["STREAMLIT_SERVER_ENABLE_XSRF_PROTECTION"] = "false"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
 
47
  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>"""
48
  components.html(WECHAT_BLOCKER_JS, height=0, width=0)
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
 
51
  def inject_file_uploader_i18n(language: str) -> None:
52
  """Localize Streamlit uploaders without modifying their React structure."""
 
254
  components.html(script, height=0, width=0)
255
 
256
 
257
+
258
+ for key, default in {
259
+ "is_running": False,
260
+ "zip_buffer": None,
261
+ "zip_filename": "",
262
+ "run_mode": "amj",
263
+ "sp_generation_task": None,
264
+ "sp_pending_launch": None,
265
+ "sp_pending_resume_task_id": "",
266
+ "sp_pending_cancel_task_id": "",
267
+ "sp_payment_required": False,
268
+ "sp_terminal_notice": None,
269
+ "sp_task_recovery_initialized": False,
270
+ "sp_last_ui_task_status": "",
271
+ }.items():
272
+ if key not in st.session_state:
273
+ st.session_state[key] = default
274
 
275
 
276
+ def _safe_key(value: str) -> str:
277
  cleaned = re.sub(r"[^A-Za-z0-9_.-]", "_", str(value or "").strip())
278
+ return cleaned[:100] or "anonymous"
279
 
280
 
281
+ def _new_generation_idempotency_key() -> str:
282
+ return f"gen_{int(time.time() * 1000)}_{secrets.token_urlsafe(18)}"
283
+
284
+
285
+ def _atomic_json(path: Path, value: dict) -> None:
286
+ path.parent.mkdir(parents=True, exist_ok=True)
287
+ temporary = path.with_suffix(path.suffix + ".tmp")
288
+ temporary.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8")
289
+ os.replace(temporary, path)
290
+
291
+
292
+ def _canonical_request_hash(value: dict) -> str:
293
+ material = dict(value or {})
294
+ material.pop("request_hash", None)
295
+ encoded = json.dumps(material, sort_keys=True, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
296
+ return hashlib.sha256(encoded).hexdigest()
297
+
298
+
299
+ def _task_dir(task_id: str) -> Path:
300
+ return Path(_user_workspace) / "tasks" / _safe_key(task_id)
301
+
302
+
303
+ def _task_output_path(task: dict) -> Path | None:
304
+ task_id = str(task.get("task_id") or "").strip()
305
+ filename = os.path.basename(str(task.get("output_filename") or "").strip())
306
+ if not task_id or not filename:
307
  return None
308
+ return _task_dir(task_id) / "outputs" / filename
309
+
310
+
311
+ def _save_uploaded(items, inputs_dir: Path, prefix: str) -> list[str]:
312
+ saved = []
313
+ for index, item in enumerate(items or []):
314
+ if item is None:
315
+ continue
316
+ original = os.path.basename(str(getattr(item, "name", f"input_{index}")))
317
+ safe_name = re.sub(r"[^A-Za-z0-9_.-]", "_", original)[:140] or f"input_{index}"
318
+ filename = f"{prefix}_{index:02d}_{safe_name}"
319
+ path = inputs_dir / filename
320
+ data = item.getvalue() if hasattr(item, "getvalue") else item.read()
321
+ path.write_bytes(bytes(data or b""))
322
+ saved.append(str(Path("inputs") / filename))
323
+ try:
324
+ item.seek(0)
325
+ except Exception:
326
+ pass
327
+ return saved
328
+
329
+
330
+ def _persist_task_request(task: dict, mode: str, lang_code: str) -> dict:
331
+ task_id = str(task.get("task_id") or "").strip()
332
+ numeric_id = (st.session_state.get("sp_user") or {}).get("numeric_id")
333
+ if not task_id or not isinstance(numeric_id, int) or numeric_id <= 0:
334
+ raise RuntimeError("task_identity_missing")
335
+ task_dir = _task_dir(task_id)
336
+ inputs_dir = task_dir / "inputs"
337
+ inputs_dir.mkdir(parents=True, exist_ok=True)
338
+
339
+ if mode == "amj":
340
+ refs = _save_uploaded(st.session_state.get("up_amj_ref") or [], inputs_dir, "ref")
341
+ data = _save_uploaded([st.session_state.get("up_amj_data")], inputs_dir, "data")
342
+ interviews = []
343
+ else:
344
+ refs = _save_uploaded(st.session_state.get("up_asq_ref") or [], inputs_dir, "ref")
345
+ data = []
346
+ interviews = _save_uploaded([st.session_state.get("up_asq_data")], inputs_dir, "interview")
347
+
348
+ request_data = {
349
+ "task_id": task_id,
350
+ "user_id": numeric_id,
351
+ "mode": mode,
352
+ "interface_language": lang_code,
353
+ "target_lang": get_target_language(lang_code),
354
+ "output_suffix": get_output_suffix(lang_code),
355
+ "topic": str(st.session_state.get("sp_topic") or DEFAULT_TOPIC),
356
+ "keywords": str(st.session_state.get("sp_keywords") or DEFAULT_KEYWORDS),
357
+ "target_words": int(st.session_state.get("sp_words") or APP_CONFIG["target_words_default"]),
358
+ "reference_files": refs,
359
+ "data_files": data,
360
+ "interview_files": interviews,
361
+ "created_at": datetime.now(timezone.utc).isoformat(),
362
  }
363
+ request_data["request_hash"] = _canonical_request_hash(request_data)
364
+ _atomic_json(task_dir / "request.json", request_data)
365
+ return request_data
 
 
 
 
366
 
367
 
368
+ def _pid_alive(pid: int) -> bool:
369
+ if not isinstance(pid, int) or pid <= 0:
370
+ return False
371
+ try:
372
+ os.kill(pid, 0)
373
+ return True
374
+ except OSError:
375
+ return False
376
 
377
 
378
+ def _pid_is_task_executor(pid: int, task_id: str, task_dir: Path) -> bool:
379
+ """Best-effort Linux identity check before trusting/killing a launch PID.
380
+
381
+ Hugging Face Spaces run on Linux. A stale PID can be reused, so a bare
382
+ ``os.kill(pid, 0)`` is never enough to identify our executor.
383
+ """
384
+ if not _pid_alive(pid):
385
+ return False
386
+ try:
387
+ raw = (Path("/proc") / str(pid) / "cmdline").read_bytes()
388
+ command = raw.replace(b"\x00", b" ").decode("utf-8", errors="ignore")
389
+ except Exception:
390
+ return False
391
+ return (
392
+ "task_executor.py" in command
393
+ and f"--task-id {task_id}" in command
394
+ and str(task_dir.resolve()) in command
395
+ )
396
 
397
 
398
+ def _launch_executor(task_id: str, *, force: bool = False) -> int:
399
+ """Launch only after a PID-bound claim handshake is observed.
 
 
400
 
401
+ ``subprocess.Popen`` proves only that the OS accepted a child process. R3.1
402
+ waits until that exact child has successfully claimed the D1 task and
403
+ written ``executor_claim.json``. A child that exits/import-fails before
404
+ claim is therefore never reported as a running executor.
405
+ """
406
+ task_dir = _task_dir(task_id)
407
+ request_path = task_dir / "request.json"
408
+ if not request_path.is_file():
409
+ raise RuntimeError("task_request_manifest_missing")
410
+ request_data = json.loads(request_path.read_text(encoding="utf-8"))
411
+ numeric_id = int(request_data.get("user_id") or 0)
412
+ request_hash = str(request_data.get("request_hash") or "").strip()
413
+ if numeric_id <= 0 or not request_hash:
414
+ raise RuntimeError("task_request_identity_invalid")
415
+
416
+ launch_marker_path = task_dir / "executor_launch.json"
417
+ claim_marker_path = task_dir / "executor_claim.json"
418
+ if launch_marker_path.is_file():
419
+ try:
420
+ marker = json.loads(launch_marker_path.read_text(encoding="utf-8"))
421
+ pid = int(marker.get("pid") or 0)
422
+ verified_executor = _pid_is_task_executor(pid, task_id, task_dir)
423
+ if verified_executor and not force:
424
+ claim_marker = {}
425
+ if claim_marker_path.is_file():
426
+ try:
427
+ claim_marker = json.loads(claim_marker_path.read_text(encoding="utf-8"))
428
+ except Exception:
429
+ claim_marker = {}
430
+ if int(claim_marker.get("pid") or 0) == pid and int(claim_marker.get("execution_epoch") or 0) > 0:
431
+ return pid
432
+ if verified_executor and force:
433
+ try:
434
+ os.kill(pid, signal.SIGTERM)
435
+ except OSError:
436
+ pass
437
+ except Exception:
438
+ pass
439
 
440
+ # A stale claim file must never satisfy a new launch/resume handshake.
441
+ try:
442
+ claim_marker_path.unlink(missing_ok=True)
443
+ except TypeError:
444
+ if claim_marker_path.exists():
445
+ claim_marker_path.unlink()
446
+
447
+ log_path = task_dir / "executor.log"
448
+ task_dir.mkdir(parents=True, exist_ok=True)
449
+ command = [
450
+ sys.executable,
451
+ str(Path(__file__).resolve().parent / "task_executor.py"),
452
+ "--task-dir", str(task_dir.resolve()),
453
+ "--task-id", str(task_id),
454
+ "--user-id", str(numeric_id),
455
+ ]
456
+ executor_env = os.environ.copy()
457
+ executor_env["SMARTPAPER_EXECUTOR_LOG_PATH"] = str(log_path.resolve())
458
+ process = subprocess.Popen(
459
+ command,
460
+ cwd=str(Path(__file__).resolve().parent),
461
+ stdin=subprocess.DEVNULL,
462
+ # Inherit stdout/stderr so executor bootstrap/errors are visible in HF
463
+ # Container Logs; task_executor also writes the same log to executor.log.
464
+ close_fds=True,
465
+ start_new_session=True,
466
+ env=executor_env,
467
  )
468
+ _atomic_json(launch_marker_path, {"pid": process.pid, "launched_at": datetime.now(timezone.utc).isoformat()})
469
+ logging.info("[SP_UI] executor_spawned task_id=%s pid=%s", task_id, process.pid)
470
+
471
+ timeout_seconds = float(APP_CONFIG.get("executor_bootstrap_timeout_seconds", 20))
472
+ poll_seconds = max(0.05, float(APP_CONFIG.get("executor_bootstrap_poll_ms", 100)) / 1000.0)
473
+ deadline = time.monotonic() + timeout_seconds
474
+ while time.monotonic() < deadline:
475
+ if claim_marker_path.is_file():
476
+ try:
477
+ claim_marker = json.loads(claim_marker_path.read_text(encoding="utf-8"))
478
+ except Exception:
479
+ claim_marker = {}
480
+ if (
481
+ int(claim_marker.get("pid") or 0) == int(process.pid)
482
+ and str(claim_marker.get("task_id") or "") == str(task_id)
483
+ and str(claim_marker.get("request_hash") or "") == request_hash
484
+ and int(claim_marker.get("execution_epoch") or 0) > 0
485
+ and str(claim_marker.get("executor_instance_id") or "").strip()
486
+ ):
487
+ logging.info(
488
+ "[SP_UI] executor_claim_confirmed task_id=%s pid=%s epoch=%s",
489
+ task_id, process.pid, claim_marker.get("execution_epoch"),
490
+ )
491
+ return int(process.pid)
492
+ return_code = process.poll()
493
+ if return_code is not None:
494
+ logging.error("[SP_UI] executor_bootstrap_exit task_id=%s pid=%s rc=%s", task_id, process.pid, return_code)
495
+ raise RuntimeError(f"executor_bootstrap_failed_exit_{return_code}")
496
+ time.sleep(poll_seconds)
497
 
498
+ try:
499
+ process.terminate()
500
+ except Exception:
501
+ pass
502
+ logging.error("[SP_UI] executor_claim_timeout task_id=%s pid=%s", task_id, process.pid)
503
+ raise RuntimeError("executor_claim_timeout")
504
 
505
 
506
+ def _process_pending_cancel() -> None:
507
+ task_id = str(st.session_state.get("sp_pending_cancel_task_id") or "").strip()
508
+ if not task_id:
509
+ return
510
+ result = cancel_generation_task(task_id)
511
+ if result.get("cancelled"):
512
+ shutil.rmtree(_task_dir(task_id), ignore_errors=True)
513
+ st.session_state["sp_pending_cancel_task_id"] = ""
514
+ st.session_state["sp_generation_task"] = dict(result.get("task") or {}) or None
515
+ st.rerun()
516
+
517
+
518
+ def _process_pending_resume() -> None:
519
+ task_id = str(st.session_state.get("sp_pending_resume_task_id") or "").strip()
520
+ if not task_id:
521
+ return
522
+ if not (_task_dir(task_id) / "request.json").is_file():
523
+ st.session_state["sp_pending_resume_task_id"] = ""
524
+ st.session_state["sp_pending_cancel_task_id"] = task_id
525
+ st.session_state["sp_terminal_notice"] = {
526
+ "kind": "error",
527
+ "message": "Local checkpoint/request files are unavailable after runtime replacement; the reserved quota will be released.",
528
+ }
529
+ st.rerun()
530
+ _launch_executor(task_id, force=True)
531
+ st.session_state["sp_pending_resume_task_id"] = ""
532
+ st.rerun()
533
+
534
+
535
+ def _process_pending_launch(lang_code: str) -> None:
536
+ pending = st.session_state.get("sp_pending_launch")
537
+ if not isinstance(pending, dict):
538
+ return
539
+ mode = str(pending.get("mode") or "").strip().lower()
540
+ idempotency_key = str(pending.get("idempotency_key") or "").strip()
541
+ try:
542
+ response = start_generation_task(
543
+ idempotency_key=idempotency_key,
544
+ mode=mode,
545
+ interface_language=lang_code,
546
+ target_words=int(st.session_state.get("sp_words") or APP_CONFIG["target_words_default"]),
547
+ )
548
+ except Exception as exc:
549
+ st.session_state["sp_pending_launch"] = None
550
+ raw = str(exc or "")
551
+ if "payment_required" in raw:
552
+ st.session_state["sp_payment_required"] = True
553
+ st.error(tr(lang_code, "quota_exhausted"))
554
+ render_payment_purchase(lang_code)
555
+ else:
556
+ st.error(raw)
557
+ return
558
+ task = dict(response.get("task") or {})
559
  task_id = str(task.get("task_id") or "").strip()
560
  if not task_id:
561
+ st.session_state["sp_pending_launch"] = None
562
+ st.error("task_start_missing_task_id")
563
+ return
564
+ st.session_state["sp_generation_task"] = task
565
+ st.session_state["sp_last_ui_task_status"] = str(task.get("status") or "")
566
+ try:
567
+ request_path = _task_dir(task_id) / "request.json"
568
+ if not request_path.is_file():
569
+ _persist_task_request(task, mode, lang_code)
570
+ _launch_executor(task_id)
571
+ except Exception as exc:
572
+ st.session_state["sp_pending_launch"] = None
573
+ st.session_state["sp_pending_cancel_task_id"] = task_id
574
+ st.session_state["sp_terminal_notice"] = {"kind": "error", "message": str(exc)[:800]}
575
+ st.rerun()
576
+ st.session_state["sp_pending_launch"] = None
577
+ st.session_state["sp_payment_required"] = False
578
+ st.rerun()
579
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
580
 
581
  inject_wechat_browser_notice()
582
 
583
  DEFAULT_TOPIC = "Generative Artificial Intelligence in Ubiquitous Learning: Evaluating a Chatbot-based Recommendation Engine for Personalized and Context-aware Education"
584
  DEFAULT_KEYWORDS = "Ubiquitous computing; Context awareness; Deep learning; Personalized educational; Educational technologies; HCI; Recommendation system; LLMs; Generative AI; ChatGPT; Prompting engineering; Few-shot prompting"
585
 
 
 
 
 
 
 
586
  def _current_language_code() -> str:
587
  choice = st.session_state.get("interface_language_choice")
588
  return LANGUAGE_OPTIONS.get(choice, "zh")
589
 
590
 
 
591
  authenticated_user = require_alipay_login(_current_language_code())
592
+ if not bool(st.session_state.get("sp_task_recovery_initialized")):
593
+ recover_generation_task()
594
+ st.session_state["sp_task_recovery_initialized"] = True
595
+ _user_workspace = os.path.join(WORKSPACE_DIR, "users", _safe_key(authenticated_user.get("id")))
 
 
 
 
596
  os.makedirs(_user_workspace, exist_ok=True)
 
597
 
598
 
 
599
  def render_workspace_controls(user):
 
600
  top_toolbar = st.container()
 
601
  with top_toolbar:
602
  language_col, account_col = st.columns([5.4, 4.6])
 
603
  with language_col:
604
  lang_choice = st.radio(
605
+ "Language", list(LANGUAGE_OPTIONS.keys()), index=0, horizontal=True,
606
+ label_visibility="collapsed", key="interface_language_choice",
 
 
 
 
607
  )
 
608
  lang_code = LANGUAGE_OPTIONS[lang_choice]
609
  st.session_state["sp_lang_code"] = lang_code
610
  st.session_state["sp_target_lang"] = get_target_language(lang_code)
611
  st.session_state["sp_output_suffix"] = get_output_suffix(lang_code)
612
  st.session_state["sp_target_flag"] = get_target_flag(lang_code)
 
613
  with account_col:
614
  render_logged_in_account(lang_code, user)
615
 
616
+ task = st.session_state.get("sp_generation_task") or {}
617
+ task_status = str(task.get("status") or "")
618
+ active = task_status in {"created", "running", "resumable", "cancelling"}
619
+ st.session_state["is_running"] = active
 
 
 
 
 
 
 
 
 
 
620
 
621
  st.markdown(f"<div class='main-title'><h2>{tr(lang_code, 'title')}</h2></div>", unsafe_allow_html=True)
622
 
623
+ if task_status == "resumable":
624
  st.warning(tr(lang_code, "checkpoint_warning"))
625
  colA, colB = st.columns([1, 1])
626
  if colA.button(tr(lang_code, "resume"), key="resume_task"):
627
+ st.session_state["sp_pending_resume_task_id"] = str(task.get("task_id") or "")
628
+ st.rerun()
 
 
 
 
 
 
 
 
 
 
 
629
  if colB.button(tr(lang_code, "clear"), key="clear_task"):
630
+ st.session_state["sp_pending_cancel_task_id"] = str(task.get("task_id") or "")
631
+ st.rerun()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
632
  st.markdown("---")
633
 
634
+ st.text_input(tr(lang_code, "topic_label"), value=DEFAULT_TOPIC, disabled=active, key="sp_topic")
635
+ st.text_input(tr(lang_code, "keywords_label"), value=DEFAULT_KEYWORDS, disabled=active, key="sp_keywords")
 
 
 
 
 
 
 
 
 
 
636
  st.number_input(
637
  tr(lang_code, "words_label"),
638
+ min_value=int(APP_CONFIG["target_words_min"]),
639
+ max_value=int(APP_CONFIG["target_words_max"]),
640
+ value=int(APP_CONFIG["target_words_default"]),
641
  step=1000,
642
+ disabled=active,
643
  key="sp_words",
644
  )
645
 
646
  tab_amj, tab_asq = st.tabs([tr(lang_code, "tab_amj"), tr(lang_code, "tab_asq")])
 
647
  with tab_amj:
648
  st.markdown(tr(lang_code, "amj_config"))
649
  st.info(tr(lang_code, "amj_info"))
650
+ st.file_uploader(tr(lang_code, "upload_reference"), type=["pdf", "doc", "docx"], accept_multiple_files=True, key="up_amj_ref", disabled=active)
651
+ st.file_uploader(tr(lang_code, "upload_stats"), type=["csv", "xlsx", "xls"], key="up_amj_data", disabled=active)
 
 
 
 
 
 
 
 
 
 
 
 
652
  with st.expander(tr(lang_code, "amj_expander")):
653
  st.markdown(tr(lang_code, "amj_hint"))
654
+ template = get_amj_csv_template(lang_code)
655
+ st.code(template, language="csv")
656
+ st.download_button(label=tr(lang_code, "download_amj_template"), data=template, file_name="AMJ_Data_Template.csv", mime="text/csv", use_container_width=True)
657
+ if st.button(tr(lang_code, "start_amj"), disabled=active, key="btn_amj"):
658
+ st.session_state["run_mode"] = "amj"
659
+ st.session_state["sp_pending_launch"] = {"mode": "amj", "idempotency_key": _new_generation_idempotency_key()}
660
+ st.rerun()
 
 
 
 
 
 
 
 
 
 
 
 
661
 
662
  with tab_asq:
663
  st.markdown(tr(lang_code, "asq_config"))
664
  st.info(tr(lang_code, "asq_info"))
665
+ st.file_uploader(tr(lang_code, "upload_reference"), type=["pdf", "doc", "docx"], accept_multiple_files=True, key="up_asq_ref", disabled=active)
666
+ st.file_uploader(tr(lang_code, "upload_interviews"), type=["txt", "pdf", "doc", "docx"], key="up_asq_data", disabled=active)
 
 
 
 
 
 
 
 
 
 
 
 
667
  with st.expander(tr(lang_code, "asq_expander")):
668
  st.markdown(tr(lang_code, "asq_hint"))
669
+ template = get_asq_txt_template(lang_code)
670
+ st.code(template, language="text")
671
+ st.download_button(label=tr(lang_code, "download_asq_template"), data=template, file_name="ASQ_Interview_Template.txt", mime="text/plain", use_container_width=True)
672
+ if st.button(tr(lang_code, "start_asq"), disabled=active, key="btn_asq"):
673
+ st.session_state["run_mode"] = "asq"
674
+ st.session_state["sp_pending_launch"] = {"mode": "asq", "idempotency_key": _new_generation_idempotency_key()}
675
+ st.rerun()
 
 
676
 
 
 
 
 
 
 
 
 
 
 
 
677
  inject_file_uploader_i18n(lang_code)
678
 
679
 
680
  render_workspace_controls(authenticated_user)
 
681
  lang_code = str(st.session_state.get("sp_lang_code") or _current_language_code())
682
+
683
+ _notice = st.session_state.pop("sp_terminal_notice", None)
684
+ if isinstance(_notice, dict) and _notice.get("message"):
685
+ (st.error if _notice.get("kind") == "error" else st.warning)(str(_notice.get("message")))
686
+
687
+ _process_pending_cancel()
688
+ _process_pending_resume()
689
+ _process_pending_launch(lang_code)
690
+
691
+ if st.session_state.get("sp_payment_required"):
692
+ render_payment_purchase(lang_code)
693
+
694
+
695
+ def _render_monitor_box(task: dict) -> None:
696
+ status = str(task.get("status") or "")
697
+ node = str(task.get("current_node") or status or "")
698
+ progress = max(0, min(100, int(task.get("progress_pct") or 0)))
699
+ if status in {"created", "running", "resumable", "cancelling"}:
700
+ st.progress(progress, text=f"{progress}% — {node}")
701
+ st.markdown(f"""
 
702
  <div class='log-box'>
703
+ <div><b>{tr(lang_code, 'monitor_pipeline')}:</b> {status}</div><div><b>{tr(lang_code, 'monitor_node')}:</b> {node}</div><div><b>{tr(lang_code, 'monitor_log')}:</b> checkpoint {int(task.get('checkpoint_seq') or 0)} · {str(task.get('checkpoint_stage') or '')}</div><hr>
704
  <div><b>{tr(lang_code, 'monitor_compute')}:</b></div>
705
  <div style='display: flex; gap: 8px;'>
706
+ <span style='background: #fef3c7; padding: 4px 8px; border-radius: 4px; color: black;'>🧠 DeepSeek: <b>{int(task.get('ds_calls') or 0)}</b></span>
707
+ <span style='background: #dbeafe; padding: 4px 8px; border-radius: 4px; color: black;'>📚 S2: <b>{int(task.get('s2_calls') or 0)}</b></span>
708
+ <span style='background: #d1fae5; padding: 4px 8px; border-radius: 4px; color: black;'>🌐 OpenAlex: <b>{int(task.get('oa_calls') or 0)}</b></span>
709
  </div>
710
  </div>
711
  """, unsafe_allow_html=True)
712
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
713
 
714
+ def _render_task_status_once() -> None:
715
+ task = st.session_state.get("sp_generation_task") or {}
716
+ task_id = str(task.get("task_id") or "").strip()
717
+ if not task_id:
718
+ return
719
+ previous_status = str(st.session_state.get("sp_last_ui_task_status") or str(task.get("status") or ""))
720
+ status = str(task.get("status") or "")
721
+ if status in {"created", "running", "resumable", "cancelling"}:
722
+ try:
723
+ result = generation_task_status(task_id)
724
+ task = dict(result.get("task") or task)
725
+ st.session_state["sp_generation_task"] = task
726
+ status = str(task.get("status") or "")
727
+ except Exception as exc:
728
+ logging.warning("[SP_UI] status_poll_error task_id=%s error=%s", task_id, type(exc).__name__)
729
+ st.session_state["sp_last_ui_task_status"] = status
730
+ _render_monitor_box(task)
731
+ if status == "completed":
732
+ output_path = _task_output_path(task)
733
+ if output_path and output_path.is_file():
734
+ st.success(tr(lang_code, "success"))
735
+ st.download_button(label=tr(lang_code, "download"), data=output_path.read_bytes(), file_name=output_path.name, mime="application/zip")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
736
  else:
737
+ st.warning("The task completed, but the local output file is unavailable after runtime replacement.")
738
+ elif status == "failed":
739
+ st.error(str(task.get("safe_error_detail") or task.get("safe_error_code") or tr(lang_code, "node_error")))
740
+ elif status == "cancelled":
741
+ detail = str(task.get("safe_error_detail") or "")
742
+ if detail:
743
+ st.warning(detail)
744
+
745
+ # Only boundary transitions need one full app rerun so static controls pick
746
+ # up resume/terminal state. Running progress never reruns the whole page.
747
+ if previous_status and status != previous_status and status in {"resumable", "completed", "failed", "cancelled"}:
748
+ st.rerun()
749
+
750
+
751
+ if getattr(st, "fragment", None) is not None:
752
+ @st.fragment(run_every=5)
753
+ def _task_monitor_fragment():
754
+ _render_task_status_once()
755
+ _task_monitor_fragment()
756
+ else:
757
+ _render_task_status_once()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
config.py CHANGED
@@ -4,22 +4,38 @@ 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")),
16
  "rag_top_k": int(os.environ.get("RAG_TOP_K", "3")),
17
  "max_extract_chars": int(os.environ.get("MAX_EXTRACT_CHARS", "150000")),
 
 
 
18
  "api_timeouts": {
19
- "standard": (15, 180),
20
- "stream": (15, 300),
21
  "search": 15
22
  },
 
 
 
 
23
 
24
  # --- API 节点配置 ---
25
  "deepseek_api_url": os.environ.get(
@@ -82,8 +98,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 +116,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,
@@ -110,18 +127,42 @@ def load_config():
110
  int(os.environ.get("CITATION_SEMANTIC_JSON_REPAIR_RETRIES", "1")),
111
  ),
112
  },
113
- # Task/lease settings are consumed by the HF client. The Worker remains
114
- # the authoritative source for task ownership and lock expiry.
115
- "task_heartbeat_interval_seconds": max(
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
 
122
  # --- UI 多语言标签 ---
123
  "ui_labels": {
124
- "中文": {"title": "📊 工商管理论文智能体", "btn_generate": "🚀 生成初稿", "btn_download": "📥 下载文件", "topic_label": "📝 论文名称", "keywords_label": "🔍 关键字", "words_label": "📏 目标总单词数 (12000-25000单词)", "success": "🎉 流水线推演完毕!双语文档已就绪。"},
125
  "English": {"title": "📊 Business Administration Article Agent", "btn_generate": "🚀 Generate Draft", "btn_download": "📥 Download", "topic_label": "📝 Article Title", "keywords_label": "🔍 Keywords", "words_label": "📏 Target Word Count", "success": "🎉 Task complete!"},
126
  "Русский": {"title": "📊 Агент статей по бизнес-администрированию", "btn_generate": "🚀 Черновик", "btn_download": "📥 Скачать", "topic_label": "📝 Тема", "keywords_label": "🔍 Ключевые слова", "words_label": "📏 Количество слов", "success": "🎉 Задача выполнена!"},
127
  "Қазақша": {"title": "📊 Бизнес-басқару жөніндегі ғылыми мақала агенті", "btn_generate": "🚀 Жоба дайындау", "btn_download": "📥 Жүктеу", "topic_label": "📝 Мақала атауы", "keywords_label": "🔍 Түйін сөздер", "words_label": "📏 Мақсатты сөз саны", "success": "🎉 Тапсырма аяқталды!"}
@@ -331,7 +372,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
+ "target_words_min": 5000,
12
+ "target_words_default": 5000,
13
+ "target_words_max": 20000,
14
+ "search_max_retries": max(1, int(os.environ.get("SEARCH_MAX_RETRIES", "4"))),
15
+ "translation_batch_tokens": max(800, int(os.environ.get("TRANSLATION_BATCH_TOKENS", "2500"))),
16
+ "translation_glossary_term_limit": max(10, int(os.environ.get("TRANSLATION_GLOSSARY_TERM_LIMIT", "40"))),
17
+ "translation_min_coverage_ratio": float(os.environ.get("TRANSLATION_MIN_COVERAGE_RATIO", "0.25")),
18
+ "translation_min_block_ratio_zh": float(os.environ.get("TRANSLATION_MIN_BLOCK_RATIO_ZH", "0.28")),
19
+ "translation_min_block_ratio_cyrillic": float(os.environ.get("TRANSLATION_MIN_BLOCK_RATIO_CYRILLIC", "0.65")),
20
+ "translation_max_block_ratio": float(os.environ.get("TRANSLATION_MAX_BLOCK_RATIO", "2.20")),
21
+ "openalex_email": os.environ.get("OPENALEX_EMAIL", "").strip(),
22
 
23
  # --- RAG 与解析配置 ---
24
  "rag_chunk_size": int(os.environ.get("RAG_CHUNK_SIZE", "3000")),
25
  "rag_top_k": int(os.environ.get("RAG_TOP_K", "3")),
26
  "max_extract_chars": int(os.environ.get("MAX_EXTRACT_CHARS", "150000")),
27
+ # A DeepSeek request must fail before the 90-second Worker generation
28
+ # lease can expire without real execution progress. Retries renew the
29
+ # lease through the execution heartbeat hook.
30
  "api_timeouts": {
31
+ "standard": (15, 70),
32
+ "stream": (15, 70),
33
  "search": 15
34
  },
35
+ "deepseek_stream_idle_seconds": max(
36
+ 20,
37
+ min(65, int(os.environ.get("DEEPSEEK_STREAM_IDLE_SECONDS", "60"))),
38
+ ),
39
 
40
  # --- API 节点配置 ---
41
  "deepseek_api_url": os.environ.get(
 
98
  "DEEPSEEK_SHOW_REASONING",
99
  "false",
100
  ).strip().lower() in {"1", "true", "yes", "on"},
101
+ # Citation-semantic audit repairs only affected sentences. Technical
102
+ # failures fall back to deterministic single-source recovery and are
103
+ # recorded in the audit log instead of terminating the whole paper.
104
  "citation_semantic_audit": {
105
  "enabled": os.environ.get(
106
  "CITATION_SEMANTIC_AUDIT_ENABLED",
 
116
  ),
117
  "fail_closed": os.environ.get(
118
  "CITATION_SEMANTIC_FAIL_CLOSED",
119
+ "false",
120
  ).strip().lower() in {"1", "true", "yes", "on"},
121
  "technical_retries": max(
122
  1,
 
127
  int(os.environ.get("CITATION_SEMANTIC_JSON_REPAIR_RETRIES", "1")),
128
  ),
129
  },
130
+ # R3-Free task execution runs outside Streamlit. The executor renews
131
+ # the Worker lease only while real Python/model progress is recent.
132
+ "executor_heartbeat_interval_seconds": max(
133
+ 10,
134
+ int(os.environ.get("SMARTPAPER_EXECUTOR_HEARTBEAT_SECONDS", "15")),
135
+ ),
136
+ "executor_activity_grace_seconds": max(
137
+ 30,
138
+ int(os.environ.get("SMARTPAPER_EXECUTOR_ACTIVITY_GRACE_SECONDS", "45")),
139
  ),
140
+ "executor_bootstrap_timeout_seconds": max(
141
+ 5,
142
+ int(os.environ.get("SMARTPAPER_EXECUTOR_BOOTSTRAP_TIMEOUT_SECONDS", "20")),
143
+ ),
144
+ "executor_bootstrap_poll_ms": max(
145
+ 50,
146
+ int(os.environ.get("SMARTPAPER_EXECUTOR_BOOTSTRAP_POLL_MS", "100")),
147
+ ),
148
+ "manuscript_quality": {
149
+ "abstract_max_words": int(os.environ.get("ABSTRACT_MAX_WORDS", "200")),
150
+ "abstract_max_numeric_mentions": int(os.environ.get("ABSTRACT_MAX_NUMERIC_MENTIONS", "6")),
151
+ "forbidden_phrases": [
152
+ "delve into", "intricate", "paramount", "overarching",
153
+ "pivotal", "foster", "seamlessly", "multifaceted",
154
+ "beacon", "landscape", "underscore", "robust",
155
+ "firstly", "secondly", "moreover", "furthermore",
156
+ "in conclusion", "to summarize", "as an ai",
157
+ "ai-generated", "this response",
158
+ ],
159
+ },
160
  "openalex_api_url": os.environ.get("OPENALEX_API_URL", "https://api.openalex.org/works"),
161
  "s2_api_url": os.environ.get("S2_API_URL", "https://api.semanticscholar.org/graph/v1"),
162
 
163
  # --- UI 多语言标签 ---
164
  "ui_labels": {
165
+ "中文": {"title": "📊 工商管理论文智能体", "btn_generate": "🚀 生成初稿", "btn_download": "📥 下载文件", "topic_label": "📝 论文名称", "keywords_label": "🔍 关键字", "words_label": "📏 目标总单词数 (5000-20000单词)", "success": "🎉 流水线推演完毕!双语文档已就绪。"},
166
  "English": {"title": "📊 Business Administration Article Agent", "btn_generate": "🚀 Generate Draft", "btn_download": "📥 Download", "topic_label": "📝 Article Title", "keywords_label": "🔍 Keywords", "words_label": "📏 Target Word Count", "success": "🎉 Task complete!"},
167
  "Русский": {"title": "📊 Агент статей по бизнес-администрированию", "btn_generate": "🚀 Черновик", "btn_download": "📥 Скачать", "topic_label": "📝 Тема", "keywords_label": "🔍 Ключевые слова", "words_label": "📏 Количество слов", "success": "🎉 Задача выполнена!"},
168
  "Қазақша": {"title": "📊 Бизнес-басқару жөніндегі ғылыми мақала агенті", "btn_generate": "🚀 Жоба дайындау", "btn_download": "📥 Жүктеу", "topic_label": "📝 Мақала атауы", "keywords_label": "🔍 Түйін сөздер", "words_label": "📏 Мақсатты сөз саны", "success": "🎉 Тапсырма аяқталды!"}
 
372
  [ABSOLUTE ANTI-AI-TONE MANDATES]:
373
  1. CRITICAL: NEVER use these cliché AI words: delve into, intricate, paramount, overarching, pivotal, foster, seamlessly, multifaceted, beacon, landscape, underscore, robust.
374
  2. CRITICAL: NEVER use robotic transition phrases (e.g., Firstly, Moreover, Furthermore, In conclusion, To summarize). Use conceptual bridges instead.
375
+ 3. NATURAL SYNTAX: Vary sentence structure only where the argument requires it. Avoid artificial short-long sentence alternation and repetitive templates.
376
  4. ACADEMIC HEDGING: Use strategic hedging (e.g., 'This suggests a tendency toward X, bounded by Y').
377
  5. NO SWEEPING GENERALIZATIONS: Every sentence must carry specific theoretical weight or empirical fact.
378
  6. FOCUS ON CAUSALITY: You MUST articulate the exact theoretical mechanism driving the relationship.
core/checkpoint_store.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Lightweight task-scoped checkpoint persistence for SmartPaper executors.
3
+
4
+ This module intentionally depends only on the Python standard library. It can
5
+ be used immediately after a Worker claim without importing DeepSeek, RAG,
6
+ statistics, document, or Streamlit modules.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import re
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+
18
+ def _atomic_json(path: Path, value: dict[str, Any]) -> None:
19
+ path.parent.mkdir(parents=True, exist_ok=True)
20
+ temporary = path.with_suffix(path.suffix + ".tmp")
21
+ temporary.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8")
22
+ os.replace(temporary, path)
23
+
24
+
25
+ class TaskCheckpointStore:
26
+ def __init__(self, task_dir: Path, request_hash: str, execution_epoch: int):
27
+ self.task_dir = Path(task_dir)
28
+ self.request_hash = str(request_hash)
29
+ self.execution_epoch = int(execution_epoch)
30
+ self.directory = self.task_dir / "checkpoints"
31
+ self.directory.mkdir(parents=True, exist_ok=True)
32
+ self.sequence = 0
33
+
34
+ def load_latest(self) -> dict[str, Any] | None:
35
+ candidates: list[tuple[int, Path, dict[str, Any]]] = []
36
+ for path in self.directory.glob("checkpoint_*.json"):
37
+ try:
38
+ payload = json.loads(path.read_text(encoding="utf-8"))
39
+ if not isinstance(payload, dict):
40
+ continue
41
+ if payload.get("request_hash") != self.request_hash:
42
+ continue
43
+ seq = int(payload.get("checkpoint_seq") or 0)
44
+ if seq <= 0:
45
+ continue
46
+ candidates.append((seq, path, payload))
47
+ except Exception:
48
+ continue
49
+ if not candidates:
50
+ return None
51
+ seq, _, payload = max(candidates, key=lambda item: item[0])
52
+ self.sequence = seq
53
+ return payload
54
+
55
+ def save(self, stage: str, state: dict[str, Any]) -> tuple[int, str]:
56
+ self.sequence += 1
57
+ stage_name = re.sub(r"[^A-Za-z0-9_.-]", "_", str(stage or "checkpoint"))[:80]
58
+ payload = dict(state or {})
59
+ payload.update(
60
+ {
61
+ "request_hash": self.request_hash,
62
+ "checkpoint_seq": self.sequence,
63
+ "checkpoint_stage": stage_name,
64
+ "checkpoint_execution_epoch": self.execution_epoch,
65
+ "checkpoint_saved_at": datetime.now(timezone.utc).isoformat(),
66
+ }
67
+ )
68
+ path = self.directory / f"checkpoint_{self.sequence:05d}_{stage_name}.json"
69
+ _atomic_json(path, payload)
70
+ _atomic_json(self.directory / "latest.json", payload)
71
+ return self.sequence, stage_name
core/desktop.ini ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [.ShellClassInfo]
2
+ IconResource=C:\Program Files\Google\Drive File Stream\129.0.1.0\GoogleDriveFS.exe,27
core/generation_runner.py ADDED
@@ -0,0 +1,536 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """R3-Free headless SmartPaper generation runner.
3
+
4
+ The runner contains no Streamlit calls. It is safe to execute in a subprocess
5
+ whose lifetime is independent from Streamlit ScriptRunner reruns and browser
6
+ connections. Checkpoints are task-scoped, immutable-by-sequence JSON snapshots.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import logging
13
+ import math
14
+ import os
15
+ import re
16
+ from datetime import datetime, timezone, timedelta
17
+ from pathlib import Path
18
+ from typing import Callable, Any
19
+
20
+ from config import APP_CONFIG, AMJ_PROMPTS, ASQ_PROMPTS
21
+ from core.checkpoint_store import TaskCheckpointStore
22
+ from core.meta_extractor import extract_golden_metadata
23
+ from core.pipeline_agent import SSciPipelineAgent
24
+ from core.production_guard import build_validated_package, replace_abstract
25
+ from core.qualitative_engine import get_qualitative_engine
26
+ from core.quantitative_engine import get_quantitative_engine
27
+ from core.rag_engine import initialize_rag
28
+ from utils.file_parser import extract_text_from_files
29
+
30
+ ProgressCallback = Callable[..., None]
31
+
32
+
33
+ def _clean_trailing_references(text, keywords=("reference", "references", "reference list", "参考文献")):
34
+ lines = str(text or "").split("\n")
35
+ for i in range(len(lines) - 1, max(-1, len(lines) - 100), -1):
36
+ if any(re.match(rf"^##?\s*{kw}", lines[i], re.IGNORECASE) for kw in keywords):
37
+ return "\n".join(lines[:i]).strip()
38
+ return str(text or "").strip()
39
+
40
+
41
+ def _clean_markdown_headings(text):
42
+ return re.sub(r"(?m)^#+\s+(.*)$", r"**\1**", str(text or ""))
43
+
44
+
45
+ def _canonical_request_hash(request_data: dict[str, Any]) -> str:
46
+ material = dict(request_data or {})
47
+ material.pop("request_hash", None)
48
+ encoded = json.dumps(material, sort_keys=True, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
49
+ return hashlib.sha256(encoded).hexdigest()
50
+
51
+
52
+ class GenerationRunner:
53
+ def __init__(
54
+ self,
55
+ *,
56
+ task_dir: str | os.PathLike,
57
+ request_data: dict[str, Any],
58
+ execution_epoch: int,
59
+ progress_callback: ProgressCallback | None = None,
60
+ cancel_checker: Callable[[], bool] | None = None,
61
+ ):
62
+ self.task_dir = Path(task_dir)
63
+ self.request = dict(request_data)
64
+ self.execution_epoch = int(execution_epoch)
65
+ self.progress_callback = progress_callback or (lambda **_: None)
66
+ self.cancel_checker = cancel_checker or (lambda: False)
67
+ expected_hash = str(self.request.get("request_hash") or "")
68
+ actual_hash = _canonical_request_hash(self.request)
69
+ if not expected_hash or expected_hash != actual_hash:
70
+ raise RuntimeError("request_hash_mismatch")
71
+ self.request_hash = expected_hash
72
+ self.checkpoints = TaskCheckpointStore(self.task_dir, self.request_hash, self.execution_epoch)
73
+ self.output_dir = self.task_dir / "outputs"
74
+ self.output_dir.mkdir(parents=True, exist_ok=True)
75
+ self.agent = None
76
+
77
+ def _progress(self, pct: int, node: str, *, checkpoint_seq=None, checkpoint_stage=None) -> None:
78
+ agent = self.agent
79
+ self.progress_callback(
80
+ progress_pct=max(0, min(100, int(pct))),
81
+ current_node=str(node or "")[:240],
82
+ checkpoint_seq=checkpoint_seq,
83
+ checkpoint_stage=checkpoint_stage,
84
+ ds_calls=int(getattr(agent, "ds_calls", 0) or 0),
85
+ s2_calls=int(getattr(agent, "s2_calls", 0) or 0),
86
+ oa_calls=int(getattr(agent, "oa_calls", 0) or 0),
87
+ )
88
+
89
+ def _checkpoint(self, stage: str, state: dict[str, Any], pct: int) -> None:
90
+ seq, normalized_stage = self.checkpoints.save(stage, state)
91
+ self._progress(pct, normalized_stage, checkpoint_seq=seq, checkpoint_stage=normalized_stage)
92
+
93
+ @staticmethod
94
+ def _stream_text(generator) -> str:
95
+ return "".join(str(chunk or "") for chunk in generator)
96
+
97
+ @staticmethod
98
+ def _bounded_target(base_target: int, accumulated_deviation: float, remaining_including_current: int, safe_chunk: int) -> int:
99
+ base = max(80, int(base_target))
100
+ correction = -float(accumulated_deviation) / max(1, int(remaining_including_current))
101
+ lower = max(80, int(round(base * 0.75)))
102
+ upper = min(int(safe_chunk * 1.5), max(lower, int(round(base * 1.25))))
103
+ return max(lower, min(upper, int(round(base + correction))))
104
+
105
+ @staticmethod
106
+ def _english_count_for_target(manuscript: str) -> int:
107
+ body = re.split(r"(?m)^#\s+References\s*$", str(manuscript or ""), maxsplit=1)[0]
108
+ # Markdown punctuation is ignored; contractions/hyphenated terms stay one token.
109
+ return len(re.findall(r"\b[A-Za-z0-9]+(?:[-'][A-Za-z0-9]+)*\b", body))
110
+
111
+ def _validate_target_length(self, manuscript: str, target_words: int) -> None:
112
+ quality = APP_CONFIG.get("manuscript_validation", {})
113
+ minimum = int(target_words * float(quality.get("minimum_total_ratio", 0.85)))
114
+ maximum = int(target_words * float(quality.get("maximum_total_ratio", 1.15)))
115
+ actual = self._english_count_for_target(manuscript)
116
+ if actual < minimum or actual > maximum:
117
+ raise RuntimeError(
118
+ f"English manuscript length outside validated range: actual={actual}, "
119
+ f"required={minimum}-{maximum}, target={target_words}."
120
+ )
121
+
122
+ def _resolve_paths(self, items) -> list[str]:
123
+ output = []
124
+ for item in items or []:
125
+ path = (self.task_dir / str(item)).resolve()
126
+ if self.task_dir.resolve() not in path.parents:
127
+ raise RuntimeError("input_path_escape")
128
+ if path.is_file():
129
+ output.append(str(path))
130
+ return output
131
+
132
+ def run(self) -> dict[str, Any]:
133
+ req = self.request
134
+ mode = str(req.get("mode") or "").lower()
135
+ if mode not in {"amj", "asq"}:
136
+ raise RuntimeError("invalid_generation_mode")
137
+ target_words = int(req.get("target_words") or 0)
138
+ if not APP_CONFIG["target_words_min"] <= target_words <= APP_CONFIG["target_words_max"]:
139
+ raise RuntimeError("target_words_out_of_range")
140
+
141
+ ds_key = str(os.environ.get("DEEPSEEK_API_KEY") or "").strip()
142
+ s2_key = str(os.environ.get("S2_API_KEY") or "").strip()
143
+ if not ds_key:
144
+ raise RuntimeError("DEEPSEEK_API_KEY_missing")
145
+
146
+ if mode == "amj":
147
+ APP_CONFIG.update(AMJ_PROMPTS)
148
+ else:
149
+ APP_CONFIG.update(ASQ_PROMPTS)
150
+
151
+ agent = SSciPipelineAgent(ds_key, s2_key)
152
+ self.agent = agent
153
+ agent.set_cancel_checker(self.cancel_checker)
154
+ # DeepSeek/search activity marks the executor as genuinely alive; the
155
+ # heartbeat thread decides separately when to renew D1's lease.
156
+ agent.set_execution_progress_hook(lambda: self._progress_activity())
157
+ safe_chunk_words = int(agent.chunk_words_default)
158
+ quant_engine = get_quantitative_engine()
159
+ qual_engine = get_qualitative_engine(agent)
160
+
161
+ state = self.checkpoints.load_latest() or {}
162
+ if state:
163
+ logging.info(
164
+ "[SP_EXECUTOR] resume checkpoint seq=%s stage=%s",
165
+ state.get("checkpoint_seq"), state.get("checkpoint_stage"),
166
+ )
167
+
168
+ topic = str(req.get("topic") or "").strip()
169
+ keywords = str(req.get("keywords") or "").strip()
170
+ target_lang = str(req.get("target_lang") or "").strip()
171
+ output_suffix = str(req.get("output_suffix") or "Target").strip()
172
+ interface_language = str(req.get("interface_language") or "zh").strip()
173
+ reference_paths = self._resolve_paths(req.get("reference_files") or [])
174
+ data_paths = self._resolve_paths(req.get("data_files") or [])
175
+ interview_paths = self._resolve_paths(req.get("interview_files") or [])
176
+
177
+ # Restore agent state before continuing any dependent step.
178
+ if isinstance(state.get("agent_state"), dict):
179
+ agent.load_state(state["agent_state"])
180
+
181
+ user_lit_context = str(state.get("user_lit_context") or "")
182
+ user_data_md = str(state.get("user_data_md") or "")
183
+ data_metadata = dict(state.get("data_metadata") or {})
184
+
185
+ if not state.get("evidence_ready"):
186
+ self._progress(3, "inputs_and_evidence")
187
+ if reference_paths:
188
+ user_lit_context = extract_text_from_files(reference_paths[:20])
189
+ all_paper_texts = [extract_text_from_files([path]) for path in reference_paths[:20]]
190
+ agent.global_memory["core_golden_metadata"] = extract_golden_metadata(agent, all_paper_texts)
191
+ if mode == "amj":
192
+ data_file = data_paths[0] if data_paths else None
193
+ _, user_data_md, _, data_metadata = quant_engine.resolve_empirical_data(data_file, interface_language)
194
+ else:
195
+ raw_interviews = extract_text_from_files(interview_paths[:1]) if interview_paths else ""
196
+ user_data_md, _, data_metadata = qual_engine.resolve_qualitative_data(topic, keywords, raw_interviews)
197
+ state.update(
198
+ {
199
+ "topic": topic,
200
+ "keywords": keywords,
201
+ "target_lang": target_lang,
202
+ "target_words": target_words,
203
+ "mode": mode,
204
+ "interface_language": interface_language,
205
+ "user_lit_context": user_lit_context,
206
+ "user_data_md": user_data_md,
207
+ "data_metadata": data_metadata,
208
+ "agent_state": agent.export_state(),
209
+ "evidence_ready": True,
210
+ }
211
+ )
212
+ self._checkpoint("evidence_ready", state, 6)
213
+
214
+ refs_text = str(state.get("refs_text") or "")
215
+ if not refs_text:
216
+ self._progress(8, "S2 + OpenAlex")
217
+ refs_text = agent.fetch_references_parallel(
218
+ topic,
219
+ keywords,
220
+ min_required=50,
221
+ progress_callback=lambda: self._progress(9, "S2 + OpenAlex"),
222
+ )
223
+ if len(agent.citation_map) < 50:
224
+ raise RuntimeError(f"Insufficient eligible references: {len(agent.citation_map)}/50")
225
+ if user_lit_context:
226
+ core_refs = agent.parse_user_core_literature(user_lit_context)
227
+ if core_refs:
228
+ refs_text = (
229
+ "【USER PROVIDED CORE LITERATURE】\n" + core_refs
230
+ + "\n\n【RETRIEVED LITERATURE】\n" + refs_text
231
+ )
232
+ state.update({"refs_text": refs_text, "agent_state": agent.export_state()})
233
+ self._checkpoint("literature_ready", state, 11)
234
+
235
+ rag_engine = initialize_rag([value for value in (user_lit_context, refs_text) if value])
236
+
237
+ synthesis_result = str(state.get("synthesis_result") or "")
238
+ if not synthesis_result:
239
+ self._progress(12, "literature_synthesis")
240
+ synthesis_result = self._stream_text(
241
+ agent.stream_synthesize_literature(topic, refs_text, user_core_literature=user_lit_context)
242
+ )
243
+ synthesis_result = re.sub(r"<think>.*?</think>", "", synthesis_result, flags=re.DOTALL).strip()
244
+ state.update({"synthesis_result": synthesis_result, "agent_state": agent.export_state()})
245
+ self._checkpoint("synthesis_ready", state, 13)
246
+
247
+ adaptive_structure = state.get("adaptive_structure")
248
+ if not isinstance(adaptive_structure, list) or not adaptive_structure:
249
+ self._progress(14, "anchors_and_outline")
250
+ anchor_response = self._stream_text(
251
+ agent.stream_generate_anchors(
252
+ topic, keywords, synthesis_result, refs_text,
253
+ data_evidence=user_data_md, mode=mode,
254
+ )
255
+ )
256
+ anchor_clean = re.sub(r"<think>.*?</think>", "", anchor_response, flags=re.DOTALL).strip()
257
+ abstract_match = re.search(
258
+ r"-+\s*ABSTRACT\s*-+\s*(.*?)(?=\s*-+\s*CONCLUSION\s*-+|$)",
259
+ anchor_clean, re.DOTALL | re.IGNORECASE,
260
+ )
261
+ conclusion_match = re.search(
262
+ r"-+\s*CONCLUSION\s*-+\s*(.*?)$", anchor_clean, re.DOTALL | re.IGNORECASE,
263
+ )
264
+ abstract_anchor = abstract_match.group(1).strip() if abstract_match else anchor_clean
265
+ conclusion_anchor = conclusion_match.group(1).strip() if conclusion_match else ""
266
+ agent.global_memory["abstract_anchor"] = abstract_anchor
267
+ agent.global_memory["conclusion_anchor"] = conclusion_anchor
268
+
269
+ plan = agent.generate_outline(topic, keywords, mode=mode, data_metadata=data_metadata)
270
+ en_topic = str(plan.get("en_topic", topic))
271
+ structure = plan.get("structure", [])
272
+ en_abstract_clean = _clean_markdown_headings(
273
+ re.sub(r"(?i)^abstract[:\s]*", "", str(abstract_anchor)).strip()
274
+ )
275
+ full_draft_en = f"# {en_topic}\n\n## Abstract\n{en_abstract_clean}\n\n## Keywords\n{keywords}\n\n"
276
+ adaptive_structure = agent.build_generation_tasks(structure, target_words, safe_chunk_words)
277
+ for task in adaptive_structure:
278
+ task["is_first"] = bool(task.get("section_first"))
279
+ jit_texts = agent.prefetch_jit_literature(topic, keywords, adaptive_structure)
280
+ if jit_texts:
281
+ rag_engine._initialize_chunks([jit_texts])
282
+ state.update(
283
+ {
284
+ "adaptive_structure": adaptive_structure,
285
+ "full_draft_en": full_draft_en,
286
+ "full_draft_translated": "",
287
+ "completed_sections": [],
288
+ "context_tail": "",
289
+ "accumulated_deviation": 0.0,
290
+ "agent_state": agent.export_state(),
291
+ "outline_ready": True,
292
+ }
293
+ )
294
+ self._checkpoint("outline_ready", state, 15)
295
+ else:
296
+ full_draft_en = str(state.get("full_draft_en") or "")
297
+ # JIT text itself is already represented in agent citation state;
298
+ # refs + user literature rebuild a safe RAG baseline on resume.
299
+
300
+ completed_sections = list(state.get("completed_sections") or [])
301
+ context_tail = str(state.get("context_tail") or "")
302
+ accumulated_deviation = float(state.get("accumulated_deviation") or 0.0)
303
+ all_cite_keys = list(agent.citation_map.keys())
304
+ valid_chunks = [
305
+ item for item in adaptive_structure
306
+ if not any(word in item["section"].lower() for word in ["abstract", "keyword", "appendix"])
307
+ ]
308
+ allocation_map: dict[str, list[str]] = {}
309
+ current_cite_idx = 0
310
+ for idx, item in enumerate(valid_chunks):
311
+ count = max(1, math.ceil(len(all_cite_keys) / max(1, len(valid_chunks))))
312
+ allocation_map[item["display_name"]] = (
313
+ all_cite_keys[current_cite_idx:]
314
+ if idx == len(valid_chunks) - 1
315
+ else all_cite_keys[current_cite_idx:current_cite_idx + count]
316
+ )
317
+ current_cite_idx += count
318
+
319
+ total_steps = len(adaptive_structure)
320
+ for sec in adaptive_structure:
321
+ display_name = sec["display_name"]
322
+ if display_name in completed_sections:
323
+ continue
324
+ if self.cancel_checker():
325
+ raise RuntimeError("task_cancel_requested")
326
+ remaining = sum(1 for item in adaptive_structure if item["display_name"] not in completed_sections)
327
+ adjusted_target = self._bounded_target(
328
+ int(sec["target_words"]), accumulated_deviation, remaining, safe_chunk_words
329
+ )
330
+ sec_title = sec["section"]
331
+ subsection = sec.get("subsection")
332
+ is_quarantined = any(
333
+ word in sec_title.lower()
334
+ for word in ["abstract", "keyword", "conclusion", "references", "appendix"]
335
+ )
336
+ assigned_tags = ", ".join(allocation_map.get(display_name, [])) or "the single most relevant source"
337
+ citation_rule = (
338
+ "NO IN-TEXT CITATIONS ALLOWED IN THIS SECTION."
339
+ if is_quarantined
340
+ else "Use claim-specific citation. Each sentence may contain at most one source tag. "
341
+ f"Candidate tags: {assigned_tags}. <AVAILABLE_CITATIONS>{refs_text}</AVAILABLE_CITATIONS>"
342
+ )
343
+ memory_context = agent._build_memory_context(sec_title, 2500)
344
+ memory_block = f"\n<GLOBAL_MEMORY>\n{memory_context}\n</GLOBAL_MEMORY>\n" if memory_context else ""
345
+ snippets = rag_engine.get_relevant_context(display_name) if rag_engine is not None else ""
346
+ if snippets:
347
+ memory_block += f"\n<TARGETED_RAG>\n{snippets}\n</TARGETED_RAG>\n"
348
+ heading_directive = "Do not output the section heading; it will be inserted by the system."
349
+ if subsection:
350
+ heading_directive += (
351
+ f" Do not output the subsection heading '{subsection}'; it will also be inserted by the system. "
352
+ "Use level-3 headings only when analytically necessary."
353
+ )
354
+ elif sec_title.lower() in {"introduction", "conclusion"}:
355
+ heading_directive += " Do not use additional headings."
356
+ else:
357
+ heading_directive += (
358
+ " Use level-2 or level-3 headings only when they clarify the argument; "
359
+ "never use level-4 headings or numbered headings."
360
+ )
361
+ table_rule = (
362
+ "Present statistical evidence in standard Markdown tables with plain-text captions."
363
+ if sec["is_method_or_result"] and mode == "amj" else ""
364
+ )
365
+ concepts = ", ".join(sec.get("concepts") or [])
366
+ qualitative_rule = (
367
+ f"Assigned qualitative concepts: {concepts}. Use only their assigned representative quotations "
368
+ "and do not repeat a quotation already used elsewhere."
369
+ if concepts else ""
370
+ )
371
+ data_injection = (
372
+ f"\n<RESEARCH_EVIDENCE>\n{user_data_md}\n</RESEARCH_EVIDENCE>\n"
373
+ if user_data_md and sec["is_method_or_result"] else ""
374
+ )
375
+ completed_label = ", ".join(completed_sections)
376
+ anti_repeat = (
377
+ f"Already completed: {completed_label}. Do not restate their background, gap, or contribution."
378
+ if completed_label else ""
379
+ )
380
+ sys_prompt = APP_CONFIG.get("sys_academic_writer", "") + "\n" + APP_CONFIG.get("deep_refinement_directive", "")
381
+ user_prompt = f"""Write the assigned English manuscript unit.
382
+ Section: {sec_title}
383
+ Subsection: {subsection or 'None'}
384
+ Target length: {int(adjusted_target * 0.9)}-{int(adjusted_target * 1.1)} words.
385
+ {heading_directive}
386
+ {citation_rule}
387
+ {table_rule}
388
+ {str(sec.get('required_content') or '')}
389
+ {qualitative_rule}
390
+ {anti_repeat}
391
+ {memory_block}
392
+ Literature synthesis: {synthesis_result}
393
+ {data_injection}
394
+ Previous tail: {context_tail[-2500:]}
395
+ Output only final academic prose. Do not report the word count."""
396
+ if agent._count_tokens(sys_prompt) + agent._count_tokens(user_prompt) > 60000:
397
+ user_prompt = agent._truncate_context(user_prompt, 60000 - agent._count_tokens(sys_prompt))
398
+
399
+ current_index = len(completed_sections) + 1
400
+ pct = min(88, 15 + int((current_index / max(1, total_steps)) * 72))
401
+ self._progress(pct, display_name)
402
+ en_raw = self._stream_text(
403
+ agent.stream_deepseek(
404
+ sys_prompt, user_prompt, max_tokens=7000,
405
+ model_role="writing", thinking=False, allow_fallback=False,
406
+ )
407
+ )
408
+ en_raw = re.sub(r"<think>.*?</think>", "", en_raw, flags=re.S).strip()
409
+ en_raw = re.sub(rf"(?im)^#\s*{re.escape(sec_title)}\s*\n+", "", en_raw).strip()
410
+ if subsection:
411
+ en_raw = re.sub(rf"(?im)^##\s*{re.escape(subsection)}\s*\n+", "", en_raw).strip()
412
+ en_raw = re.sub(r"(?m)^(#{2,3})\s+[\d\.\s一二三四五六七八九十、]+\s*(.*)", r"\1 \2", en_raw)
413
+ en_raw = re.sub(r"(?m)^#{4,}\s+(.*)", r"**\1**", en_raw)
414
+ en_raw = re.sub(r"(?m)^#\s+(.+)$", r"## \1", en_raw)
415
+ if mode == "asq" and user_data_md and "appendix" not in sec_title.lower():
416
+ quote_dict = {tag: quote for quote, tag in re.findall(r'"([^"]+)"\s*\[(QUOTE-\d+)\]', user_data_md)}
417
+ for tag, quote in quote_dict.items():
418
+ en_raw = en_raw.replace(tag, f'"{quote}"')
419
+ en_raw = agent.polish_academic_section(en_raw, display_name, abstract=False)
420
+ prefix = ""
421
+ if sec.get("section_first"):
422
+ prefix += f"# {sec_title}\n\n"
423
+ if sec.get("subsection_first") and subsection:
424
+ prefix += f"## {subsection}\n\n"
425
+ en_resolved = agent.audit_and_resolve_citations(
426
+ prefix + en_raw, section_name=display_name, quarantine=is_quarantined
427
+ )
428
+ full_draft_en += en_resolved + "\n\n"
429
+ agent._extract_memory_from_text(en_raw, sec_title)
430
+ context_tail = " ".join(en_raw.split()[-200:])
431
+ actual_words = len(en_raw.split())
432
+ accumulated_deviation += actual_words - adjusted_target
433
+ completed_sections.append(display_name)
434
+ state.update(
435
+ {
436
+ "completed_sections": completed_sections,
437
+ "full_draft_en": full_draft_en,
438
+ "full_draft_translated": "",
439
+ "context_tail": context_tail,
440
+ "accumulated_deviation": accumulated_deviation,
441
+ "agent_state": agent.export_state(),
442
+ "data_metadata": data_metadata,
443
+ }
444
+ )
445
+ self._checkpoint(f"section_{len(completed_sections):03d}", state, pct)
446
+
447
+ if not state.get("english_finalized"):
448
+ self._progress(91, "english_validation")
449
+ full_draft_en = _clean_trailing_references(full_draft_en, ["Reference", "References", "Reference List"])
450
+ final_abstract = agent.generate_final_abstract(
451
+ topic, keywords, full_draft_en, data_evidence=user_data_md, mode=mode
452
+ )
453
+ full_draft_en = replace_abstract(full_draft_en, final_abstract)
454
+ agent.validate_manuscript_structure(full_draft_en, adaptive_structure, translated=False)
455
+ if mode == "asq":
456
+ repeated_quotes = agent.find_repeated_qualitative_quotes(full_draft_en, data_metadata)
457
+ if repeated_quotes:
458
+ raise RuntimeError(f"Repeated qualitative quotations: {repeated_quotes[:5]}")
459
+ references = []
460
+ seen_refs = set()
461
+ for tag in sorted(agent.used_citations, key=agent._citation_tag_number):
462
+ ref = str((agent.citation_map.get(tag) or {}).get("ref") or "").strip()
463
+ if ref and ref not in seen_refs:
464
+ references.append(f"- {ref}")
465
+ seen_refs.add(ref)
466
+ if not references:
467
+ raise RuntimeError("No cited references remained after citation validation.")
468
+ full_draft_en = full_draft_en.rstrip() + "\n\n# References\n" + "\n\n".join(references)
469
+ self._validate_target_length(full_draft_en, target_words)
470
+ state.update(
471
+ {
472
+ "full_draft_en": full_draft_en,
473
+ "english_finalized": True,
474
+ "agent_state": agent.export_state(),
475
+ }
476
+ )
477
+ self._checkpoint("english_complete", state, 93)
478
+ else:
479
+ full_draft_en = str(state.get("full_draft_en") or "")
480
+
481
+ full_draft_translated = str(state.get("full_draft_translated") or "")
482
+ if not state.get("translation_complete") or not full_draft_translated:
483
+ self._progress(95, "translation")
484
+ full_draft_translated = agent.translate_locked_manuscript(full_draft_en, target_lang)
485
+ agent.validate_translation_alignment(full_draft_en, full_draft_translated)
486
+ state.update(
487
+ {
488
+ "full_draft_translated": full_draft_translated,
489
+ "translation_complete": True,
490
+ "agent_state": agent.export_state(),
491
+ }
492
+ )
493
+ self._checkpoint("translation_complete", state, 96)
494
+
495
+ existing_output = str(state.get("output_filename") or "").strip()
496
+ if state.get("package_validated") and existing_output:
497
+ existing_path = self.output_dir / os.path.basename(existing_output)
498
+ if existing_path.is_file() and existing_path.stat().st_size > 0:
499
+ self._progress(99, "package_validated")
500
+ return {"output_filename": existing_path.name, "output_path": str(existing_path)}
501
+
502
+ self._progress(97, "packaging")
503
+ bj_time = datetime.now(timezone(timedelta(hours=8))).strftime("%Y%m%d_%H%M")
504
+ package = build_validated_package(
505
+ english_markdown=full_draft_en,
506
+ translated_markdown=full_draft_translated,
507
+ english_filename=f"Research_EN_{bj_time}.docx",
508
+ translated_filename=f"Research_{output_suffix}_{bj_time}.docx",
509
+ data_metadata=data_metadata,
510
+ mode=mode,
511
+ citation_audit=agent.citation_audit_log,
512
+ literature_metrics=agent.get_source_metrics(),
513
+ )
514
+ output_filename = f"SmartPaper_{bj_time}.zip"
515
+ pending = self.output_dir / (".pending_" + output_filename)
516
+ pending.write_bytes(package.zip_bytes)
517
+ final = self.output_dir / output_filename
518
+ os.replace(pending, final)
519
+ state.update(
520
+ {
521
+ "output_filename": output_filename,
522
+ "package_validated": True,
523
+ "agent_state": agent.export_state(),
524
+ }
525
+ )
526
+ self._checkpoint("package_validated", state, 99)
527
+ return {"output_filename": output_filename, "output_path": str(final)}
528
+
529
+ def _progress_activity(self) -> None:
530
+ # This call intentionally does not update D1 directly. The executor's
531
+ # heartbeat thread uses it only to prove that real backend work is still
532
+ # occurring, which avoids browser-driven or timer-only zombie liveness.
533
+ try:
534
+ self.progress_callback(activity_only=True)
535
+ except TypeError:
536
+ pass
core/pipeline_agent.py CHANGED
@@ -1,6 +1,5 @@
1
  # -*- coding: utf-8 -*-
2
  import os
3
- import streamlit as st
4
  import time
5
  import re
6
  import json
@@ -13,7 +12,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)])
@@ -62,15 +62,12 @@ class SSciPipelineAgent:
62
  "DEEPSEEK_API_KEY is empty. Configure it in Hugging Face "
63
  "Space Settings > Variables and secrets."
64
  )
65
- try:
66
- self.s2_key = st.secrets.get("S2_API_KEY", s2_key)
67
- except:
68
- self.s2_key = os.environ.get("S2_API_KEY", s2_key)
69
-
70
- try:
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,27 +79,30 @@ 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
  }
93
  self._cancel_checker = None
 
94
  self.s2_fused = False
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 {}
@@ -143,6 +143,10 @@ class SSciPipelineAgent:
143
  self.openalex_api_url = APP_CONFIG["openalex_api_url"]
144
  self.s2_api_url = APP_CONFIG["s2_api_url"]
145
  self.timeouts = APP_CONFIG["api_timeouts"]
 
 
 
 
146
 
147
  self.global_memory = {
148
  "hypotheses": "", "variables": "", "model_summary": "",
@@ -160,6 +164,23 @@ class SSciPipelineAgent:
160
  def set_cancel_checker(self, checker):
161
  self._cancel_checker = checker
162
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  def _check_cancelled(self):
164
  if self._cancel_checker is not None:
165
  result = self._cancel_checker()
@@ -228,6 +249,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 +266,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:
@@ -654,6 +725,13 @@ class SSciPipelineAgent:
654
 
655
  try:
656
  self._check_cancelled()
 
 
 
 
 
 
 
657
  response = requests.post(
658
  self.deepseek_api_url,
659
  headers=self._deepseek_headers(self.ds_key),
@@ -662,6 +740,7 @@ class SSciPipelineAgent:
662
  )
663
 
664
  self._check_cancelled()
 
665
  if response.status_code != 200:
666
  self._raise_for_deepseek_status(response)
667
 
@@ -715,6 +794,7 @@ class SSciPipelineAgent:
715
  self.deepseek_max_retries,
716
  exc,
717
  )
 
718
  if is_last:
719
  break
720
  time.sleep(self._retry_delay(attempt))
@@ -790,10 +870,22 @@ class SSciPipelineAgent:
790
  emitted_content = False
791
  reasoning_open = False
792
  reasoning_chunks = 0
 
 
 
793
  start_time = time.time()
 
794
 
795
  try:
796
  self._check_cancelled()
 
 
 
 
 
 
 
 
797
  with requests.post(
798
  self.deepseek_api_url,
799
  headers=self._deepseek_headers(self.ds_key),
@@ -809,9 +901,22 @@ class SSciPipelineAgent:
809
  ):
810
  self._check_cancelled()
811
  event = self._parse_sse_line(raw_line)
 
 
 
 
 
 
812
  if not event:
813
  continue
814
  if event.get("done"):
 
 
 
 
 
 
 
815
  break
816
 
817
  choices = event.get("choices") or []
@@ -825,7 +930,9 @@ class SSciPipelineAgent:
825
  content = delta.get("content") or ""
826
 
827
  if reasoning_content:
 
828
  reasoning_chunks += 1
 
829
  if not reasoning_open:
830
  reasoning_open = True
831
  logging.info(
@@ -848,6 +955,16 @@ class SSciPipelineAgent:
848
  )
849
 
850
  if content:
 
 
 
 
 
 
 
 
 
 
851
  if (
852
  reasoning_open
853
  and self.deepseek_show_reasoning
@@ -865,9 +982,13 @@ class SSciPipelineAgent:
865
  "DeepSeek stream ended without final content."
866
  )
867
 
 
868
  logging.info(
869
- "DeepSeek stream complete | seconds=%.1f",
870
  time.time() - start_time,
 
 
 
871
  )
872
  return
873
 
@@ -899,6 +1020,7 @@ class SSciPipelineAgent:
899
  "content had already been emitted."
900
  ) from exc
901
 
 
902
  is_last = attempt == self.deepseek_max_retries - 1
903
  if is_last:
904
  break
@@ -1347,15 +1469,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 +1521,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 +1662,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 +1760,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 +2061,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 +2188,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 +2254,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 +2309,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 +2324,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 +2339,598 @@ 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)
 
1
  # -*- coding: utf-8 -*-
2
  import os
 
3
  import time
4
  import re
5
  import json
 
12
  import logging
13
  import concurrent.futures
14
  from collections import Counter
15
+ from datetime import datetime, timezone
16
+ from email.utils import parsedate_to_datetime
17
 
18
  # 基础日志配置:自带时间戳和日志级别
19
  logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", handlers=[logging.StreamHandler(sys.stdout)])
 
62
  "DEEPSEEK_API_KEY is empty. Configure it in Hugging Face "
63
  "Space Settings > Variables and secrets."
64
  )
65
+ # R3-Free: the generation engine is headless. Hugging Face Secrets are
66
+ # exposed to the process as environment variables; Streamlit is not a
67
+ # dependency of the executor or pipeline.
68
+ self.s2_key = str(os.environ.get("S2_API_KEY", s2_key) or "").strip()
69
+ self.openalex_key = str(os.environ.get("OA_API_KEY", "") or "").strip()
70
+ self.openalex_email = str(APP_CONFIG.get("openalex_email") or "").strip()
 
 
 
71
 
72
  self.ds_calls = 0
73
  # Backward-compatible counters now mirror unique additions, while the
 
79
  "request_count": 0,
80
  "success_count": 0,
81
  "raw_result_count": 0,
82
+ "eligible_result_count": 0,
83
+ "merged_count": 0,
84
  "unique_added_count": 0,
85
  "duplicate_count": 0,
86
  "error_count": 0,
87
  "last_status_code": None,
88
  "last_safe_error": "",
89
+ "status": "not_started",
90
  }
91
  for source in ("s2", "oa")
92
  }
93
  self._cancel_checker = None
94
+ self._execution_progress_hook = None
95
  self.s2_fused = False
96
  self.oa_fused = False
97
  self.citation_map = {}
98
  self.used_citations = set()
99
+ self.citation_audit_log = []
100
  self.lock = threading.RLock()
101
 
102
  self.lit_target_papers = APP_CONFIG["lit_target_papers"]
103
  self.lit_per_page = APP_CONFIG["lit_per_page"]
104
  self.lit_max_workers = APP_CONFIG["lit_max_workers"]
 
105
  self.chunk_words_default = APP_CONFIG["chunk_words_default"]
 
106
 
107
  self.deepseek_api_url = APP_CONFIG["deepseek_api_url"]
108
  configured_models = APP_CONFIG.get("deepseek_models") or {}
 
143
  self.openalex_api_url = APP_CONFIG["openalex_api_url"]
144
  self.s2_api_url = APP_CONFIG["s2_api_url"]
145
  self.timeouts = APP_CONFIG["api_timeouts"]
146
+ self.deepseek_stream_idle_seconds = max(
147
+ 20,
148
+ int(APP_CONFIG.get("deepseek_stream_idle_seconds", 60)),
149
+ )
150
 
151
  self.global_memory = {
152
  "hypotheses": "", "variables": "", "model_summary": "",
 
164
  def set_cancel_checker(self, checker):
165
  self._cancel_checker = checker
166
 
167
+ def set_execution_progress_hook(self, hook):
168
+ """Register a best-effort callback tied to real backend execution."""
169
+ self._execution_progress_hook = hook
170
+
171
+ def _touch_execution_progress(self, stage=""):
172
+ hook = self._execution_progress_hook
173
+ if hook is None:
174
+ return
175
+ try:
176
+ hook()
177
+ except Exception as exc:
178
+ logging.warning(
179
+ "Execution heartbeat hook failed | stage=%s | error=%s",
180
+ str(stage or "unknown")[:80],
181
+ type(exc).__name__,
182
+ )
183
+
184
  def _check_cancelled(self):
185
  if self._cancel_checker is not None:
186
  result = self._cancel_checker()
 
249
  "source_metrics": self.get_source_metrics(),
250
  "citation_map": self.citation_map,
251
  "used_citations": list(self.used_citations),
252
+ "citation_audit_log": list(self.citation_audit_log),
253
  "global_memory": self.global_memory
254
  }
255
 
 
266
  self.source_metrics[source].update(loaded_metrics[source])
267
  self.citation_map = state_dict.get("citation_map", {})
268
  self.used_citations = set(state_dict.get("used_citations", []))
269
+ self.citation_audit_log = list(state_dict.get("citation_audit_log", []))
270
  self.global_memory = state_dict.get("global_memory", self.global_memory)
271
  logging.info(f"🔄 状态恢复成功: DS API={self.ds_calls}, 现有文献={len(self.citation_map)}")
272
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
  def _requests_get_with_retry(self, url, headers=None, params=None, timeout=None):
274
+ """GET with bounded, status-aware retry semantics.
275
+
276
+ Authentication and query errors are returned immediately. Rate limits
277
+ honour ``Retry-After`` when present. Only timeouts, connection failures,
278
+ 429, and 5xx responses are retried.
279
+ """
280
+ if timeout is None:
281
  timeout = self.timeouts["search"]
282
+ attempts = max(1, int(APP_CONFIG.get("search_max_retries", 4)))
283
+ last_error = None
284
+ for attempt in range(attempts):
285
+ self._check_cancelled()
286
+ try:
287
+ response = requests.get(
288
+ url,
289
+ headers=headers,
290
+ params=params,
291
+ timeout=timeout,
292
+ )
293
+ except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as error:
294
+ last_error = error
295
+ if attempt + 1 >= attempts:
296
+ raise
297
+ delay = min(12.0, 1.5 * (2 ** attempt)) + random.uniform(0.0, 0.5)
298
+ logging.warning(
299
+ "Search transport retry %s/%s after %s: %s",
300
+ attempt + 1,
301
+ attempts,
302
+ type(error).__name__,
303
+ url,
304
+ )
305
+ time.sleep(delay)
306
+ continue
307
+
308
+ self._check_cancelled()
309
+ status = int(response.status_code)
310
+ if status in (400, 401, 403):
311
+ return response
312
+ if status == 429:
313
+ if attempt + 1 >= attempts:
314
+ return response
315
+ retry_after = str(response.headers.get("Retry-After") or "").strip()
316
+ delay = None
317
+ if retry_after:
318
+ try:
319
+ delay = float(retry_after)
320
+ except ValueError:
321
+ try:
322
+ retry_at = parsedate_to_datetime(retry_after)
323
+ if retry_at.tzinfo is None:
324
+ retry_at = retry_at.replace(tzinfo=timezone.utc)
325
+ delay = (retry_at - datetime.now(timezone.utc)).total_seconds()
326
+ except (TypeError, ValueError, OverflowError):
327
+ delay = None
328
+ if delay is None:
329
+ delay = 2.0 * (2 ** attempt) + random.uniform(0.0, 0.5)
330
+ delay = max(1.0, min(30.0, delay))
331
+ logging.warning(
332
+ "Search rate-limit retry %s/%s in %.1fs: %s",
333
+ attempt + 1,
334
+ attempts,
335
+ delay,
336
+ url,
337
+ )
338
+ time.sleep(delay)
339
+ continue
340
+ if 500 <= status <= 599:
341
+ if attempt + 1 >= attempts:
342
+ response.raise_for_status()
343
+ delay = min(12.0, 1.5 * (2 ** attempt)) + random.uniform(0.0, 0.5)
344
+ logging.warning(
345
+ "Search server retry %s/%s after HTTP %s: %s",
346
+ attempt + 1,
347
+ attempts,
348
+ status,
349
+ url,
350
+ )
351
+ time.sleep(delay)
352
+ continue
353
+ response.raise_for_status()
354
+ return response
355
+
356
+ if last_error is not None:
357
+ raise last_error
358
+ raise requests.exceptions.RequestException("search_request_failed")
359
 
360
  def _is_english_dominant(self, text):
361
  if not text:
 
725
 
726
  try:
727
  self._check_cancelled()
728
+ self._touch_execution_progress("deepseek_static_request")
729
+ logging.info(
730
+ "DeepSeek static attempt start | attempt=%s/%s | model=%s",
731
+ attempt + 1,
732
+ self.deepseek_max_retries,
733
+ current_model,
734
+ )
735
  response = requests.post(
736
  self.deepseek_api_url,
737
  headers=self._deepseek_headers(self.ds_key),
 
740
  )
741
 
742
  self._check_cancelled()
743
+ self._touch_execution_progress("deepseek_static_response")
744
  if response.status_code != 200:
745
  self._raise_for_deepseek_status(response)
746
 
 
794
  self.deepseek_max_retries,
795
  exc,
796
  )
797
+ self._touch_execution_progress("deepseek_static_retry")
798
  if is_last:
799
  break
800
  time.sleep(self._retry_delay(attempt))
 
870
  emitted_content = False
871
  reasoning_open = False
872
  reasoning_chunks = 0
873
+ content_chunks = 0
874
+ content_chars = 0
875
+ first_content_at = None
876
  start_time = time.time()
877
+ last_model_activity = start_time
878
 
879
  try:
880
  self._check_cancelled()
881
+ self._touch_execution_progress("deepseek_stream_request")
882
+ logging.info(
883
+ "DeepSeek stream attempt start | attempt=%s/%s | model=%s | idle_limit=%ss",
884
+ attempt + 1,
885
+ self.deepseek_max_retries,
886
+ current_model,
887
+ self.deepseek_stream_idle_seconds,
888
+ )
889
  with requests.post(
890
  self.deepseek_api_url,
891
  headers=self._deepseek_headers(self.ds_key),
 
901
  ):
902
  self._check_cancelled()
903
  event = self._parse_sse_line(raw_line)
904
+ now = time.time()
905
+ if now - last_model_activity > self.deepseek_stream_idle_seconds:
906
+ raise DeepSeekAPIError(
907
+ "DeepSeek stream produced no model progress for "
908
+ f"{self.deepseek_stream_idle_seconds} seconds."
909
+ )
910
  if not event:
911
  continue
912
  if event.get("done"):
913
+ logging.info(
914
+ "DeepSeek stream done marker | seconds=%.1f | reasoning_chunks=%s | content_chunks=%s | content_chars=%s",
915
+ now - start_time,
916
+ reasoning_chunks,
917
+ content_chunks,
918
+ content_chars,
919
+ )
920
  break
921
 
922
  choices = event.get("choices") or []
 
930
  content = delta.get("content") or ""
931
 
932
  if reasoning_content:
933
+ last_model_activity = time.time()
934
  reasoning_chunks += 1
935
+ self._touch_execution_progress("deepseek_reasoning")
936
  if not reasoning_open:
937
  reasoning_open = True
938
  logging.info(
 
955
  )
956
 
957
  if content:
958
+ last_model_activity = time.time()
959
+ self._touch_execution_progress("deepseek_content")
960
+ if first_content_at is None:
961
+ first_content_at = last_model_activity
962
+ logging.info(
963
+ "DeepSeek first content | seconds=%.1f",
964
+ first_content_at - start_time,
965
+ )
966
+ content_chunks += 1
967
+ content_chars += len(str(content))
968
  if (
969
  reasoning_open
970
  and self.deepseek_show_reasoning
 
982
  "DeepSeek stream ended without final content."
983
  )
984
 
985
+ self._touch_execution_progress("deepseek_stream_complete")
986
  logging.info(
987
+ "DeepSeek stream complete | seconds=%.1f | reasoning_chunks=%s | content_chunks=%s | content_chars=%s",
988
  time.time() - start_time,
989
+ reasoning_chunks,
990
+ content_chunks,
991
+ content_chars,
992
  )
993
  return
994
 
 
1020
  "content had already been emitted."
1021
  ) from exc
1022
 
1023
+ self._touch_execution_progress("deepseek_stream_retry")
1024
  is_last = attempt == self.deepseek_max_retries - 1
1025
  if is_last:
1026
  break
 
1469
  results = future.result()
1470
  with self.lock:
1471
  for res in results:
1472
+ result_keys = self._reference_keys(res)
1473
+ existing_keys = {
1474
+ key
1475
+ for metadata in self.citation_map.values()
1476
+ for key in self._reference_keys(metadata)
1477
+ }
1478
+ if result_keys and any(key in existing_keys for key in result_keys):
1479
+ continue
1480
+ cite_tag = f"[CITE-{len(self.citation_map)+1:02d}]"
1481
+ self.citation_map[cite_tag] = {
1482
+ "in_text": res["in_text"],
1483
+ "ref": res["ref"],
1484
+ "id": result_keys[0] if result_keys else ('title_year', res.get('title'), res.get('year')),
1485
+ "doi": res.get("doi", ""),
1486
+ "external_id": res.get("external_id", ""),
1487
+ "title": res.get("title", ""),
1488
+ "year": res.get("year", ""),
1489
+ "evidence": res.get("rag_text", ""),
1490
+ "sources": [res.get("source", "jit")],
1491
+ }
1492
+ jit_rag_texts.append(f"{cite_tag} {res.get('rag_text', '')}")
1493
 
1494
  logging.info(f"✅ [Pre-fetch JIT] 成功抓取并注入了 {len(jit_rag_texts)} 篇底层支撑文献。")
1495
  return "\n".join(jit_rag_texts)
 
1521
  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())]
1522
  return clean_chunks[:3] if clean_chunks else []
1523
 
1524
+ @staticmethod
1525
+ def _normalize_search_query(value):
1526
+ query = re.sub(r'["“”‘’()\[\]{}]', ' ', str(value or ''))
1527
+ query = re.sub(r'(?i)\b(?:AND|OR|NOT)\b', ' ', query)
1528
+ query = query.replace('-', ' ')
1529
+ query = re.sub(r'\s+', ' ', query).strip()
1530
+ terms = re.findall(r"[A-Za-z0-9][A-Za-z0-9']*", query)
1531
+ return ' '.join(terms[:8])
1532
+
1533
+ @staticmethod
1534
+ def _normalize_doi(value):
1535
+ doi = str(value or '').strip().lower()
1536
+ doi = re.sub(r'^https?://(?:dx\.)?doi\.org/', '', doi)
1537
+ return doi.strip()
1538
+
1539
+ @classmethod
1540
+ def _reference_keys(cls, result):
1541
+ keys = []
1542
+ doi = cls._normalize_doi(result.get('doi'))
1543
+ if doi:
1544
+ keys.append(('doi', doi))
1545
+ external_id = str(result.get('external_id') or '').strip().lower()
1546
+ if external_id:
1547
+ keys.append(('external', external_id))
1548
+ title = re.sub(r'[^a-z0-9]+', ' ', str(result.get('title') or '').lower()).strip()
1549
+ year = str(result.get('year') or '').strip()
1550
+ if title:
1551
+ keys.append(('title_year', title, year))
1552
+ return keys
1553
+
1554
+ def _record_source_error(self, source, code, status_code=None, detail=''):
1555
+ self._source_metric_add(source, 'error_count')
1556
+ self._source_metric_set(source, 'status', 'error')
1557
+ self._source_metric_set(source, 'last_status_code', status_code)
1558
+ safe = code if not detail else f"{code}: {self._safe_source_error(detail)}"
1559
+ self._source_metric_set(source, 'last_safe_error', safe[:300])
1560
+
1561
+ def _merge_reference_results(self, source_results, limit):
1562
+ """Merge deterministically while preserving each source's contribution.
1563
+
1564
+ ``merged_count`` means that a source is represented in the final merged
1565
+ evidence set, including overlap confirmed by both services.
1566
+ ``unique_added_count`` records which source first introduced a record.
1567
+ """
1568
+ merged = []
1569
+ key_to_index = {}
1570
+ source_lists = {source: list(source_results.get(source, [])) for source in ('s2', 'oa')}
1571
+ max_length = max((len(items) for items in source_lists.values()), default=0)
1572
+ for position in range(max_length):
1573
+ for source in ('s2', 'oa'):
1574
+ items = source_lists[source]
1575
+ if position >= len(items):
1576
+ continue
1577
+ result = items[position]
1578
+ keys = self._reference_keys(result)
1579
+ duplicate_index = next((key_to_index[k] for k in keys if k in key_to_index), None)
1580
+ if duplicate_index is not None:
1581
+ self._source_metric_add(source, 'duplicate_count')
1582
+ sources = merged[duplicate_index].setdefault('sources', [])
1583
+ if source not in sources:
1584
+ sources.append(source)
1585
+ self._source_metric_add(source, 'merged_count')
1586
+ continue
1587
+ if len(merged) >= limit:
1588
+ continue
1589
+ item = dict(result)
1590
+ item['sources'] = [source]
1591
+ merged.append(item)
1592
+ index = len(merged) - 1
1593
+ for key in keys:
1594
+ key_to_index[key] = index
1595
+ self._source_metric_add(source, 'merged_count')
1596
+ self._source_metric_add(source, 'unique_added_count')
1597
+ # The compact, approved UI displays successful eligible returns, not
1598
+ # source ownership after cross-source deduplication. Detailed overlap and
1599
+ # merge counts remain available in LITERATURE_METRICS.json.
1600
+ self.s2_calls = int(self.source_metrics['s2']['eligible_result_count'])
1601
+ self.oa_calls = int(self.source_metrics['oa']['eligible_result_count'])
1602
+ return merged
1603
+
1604
  def _s2_worker(self, term, offset):
1605
  self._check_cancelled()
1606
+ query = self._normalize_search_query(term)
1607
+ if not query:
1608
+ self._record_source_error('s2', 'S2_BAD_QUERY')
1609
+ return []
1610
+ headers = {'x-api-key': str(self.s2_key).strip()} if self.s2_key else {}
1611
+ self._source_metric_add('s2', 'request_count')
1612
+ self._source_metric_set('s2', 'status', 'running')
1613
+ logging.info('S2 search: term=%r normalized=%r offset=%s', term, query, offset)
1614
  try:
1615
+ response = self._requests_get_with_retry(
1616
  f"{self.s2_api_url}/paper/search",
1617
  headers=headers,
1618
  params={
1619
+ 'query': query,
1620
+ 'offset': offset,
1621
+ 'limit': 50,
1622
+ 'fields': 'paperId,title,authors,year,venue,abstract,externalIds',
1623
  },
1624
  )
1625
+ status = response.status_code
1626
+ self._source_metric_set('s2', 'last_status_code', status)
1627
+ if status in (401, 403):
1628
+ self._record_source_error('s2', 'S2_AUTH_ERROR', status)
1629
+ return []
1630
+ if status == 429:
1631
+ self._record_source_error('s2', 'S2_RATE_LIMIT', status)
1632
+ return []
1633
+ if status == 400:
1634
+ self._record_source_error('s2', 'S2_BAD_QUERY', status)
1635
+ return []
1636
+ response.raise_for_status()
1637
+ self._source_metric_add('s2', 'success_count')
1638
+ papers = response.json().get('data', [])
1639
+ self._source_metric_add('s2', 'raw_result_count', len(papers))
1640
+ results = []
1641
  for paper in papers:
1642
  self._check_cancelled()
1643
+ title = str(paper.get('title') or '').strip()
1644
+ year = paper.get('year')
1645
+ if not title or not year or not self._is_english_dominant(title):
1646
  continue
1647
+ authors = paper.get('authors') or []
1648
  last_names, formatted_authors = [], []
1649
  for author in authors:
1650
+ name = str(author.get('name') or '').strip()
1651
  if not name:
1652
  continue
1653
  parts = name.split()
1654
+ last = parts[-1]
1655
+ last_names.append(last)
1656
+ formatted_authors.append(f"{last}, {parts[0][0]}." if len(parts) > 1 else last)
 
 
 
1657
  if not last_names:
1658
  continue
1659
  if len(last_names) == 1:
 
1662
  in_text = f"({last_names[0]} & {last_names[1]}, {year})"
1663
  else:
1664
  in_text = f"({last_names[0]} et al., {year})"
1665
+ 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])
1666
+ doi = self._normalize_doi((paper.get('externalIds') or {}).get('DOI'))
1667
+ ref = f"{author_str} ({year}). {title}. {paper.get('venue') or 'Journal'}."
 
 
 
 
 
1668
  if doi:
1669
+ ref += f" https://doi.org/{doi}"
1670
+ abstract = ' '.join(str(paper.get('abstract') or '').split()[:180])
1671
  results.append({
1672
+ 'title': title,
1673
+ 'year': str(year),
1674
+ 'doi': doi,
1675
+ 'external_id': str(paper.get('paperId') or ''),
1676
+ 'in_text': in_text,
1677
+ 'ref': ref,
1678
+ 'rag_text': f"Title: {title}. Abstract evidence: {abstract}",
1679
+ 'source': 's2',
1680
  })
1681
+ self._source_metric_add('s2', 'eligible_result_count', len(results))
1682
+ self._source_metric_set('s2', 'status', 'ok' if results else 'no_results')
1683
+ if not results:
1684
+ self._source_metric_set('s2', 'last_safe_error', 'S2_NO_RESULTS')
1685
+ return results
1686
  except PipelineCancelledError:
1687
  raise
1688
+ except requests.Timeout as error:
1689
+ self._record_source_error('s2', 'S2_TIMEOUT', detail=error)
1690
  except Exception as error:
1691
+ status = getattr(getattr(error, 'response', None), 'status_code', None)
1692
+ code = 'S2_RATE_LIMIT' if status == 429 else ('S2_AUTH_ERROR' if status in (401, 403) else 'S2_REQUEST_ERROR')
1693
+ self._record_source_error('s2', code, status, error)
1694
+ return []
 
 
 
 
1695
 
1696
  def _oa_worker(self, term, page, filter_str):
1697
  self._check_cancelled()
1698
+ query = self._normalize_search_query(term)
1699
+ if not query:
1700
+ self._record_source_error('oa', 'OPENALEX_BAD_QUERY')
1701
+ return []
1702
+ user_agent = 'SmartPaper/production-v1.1'
1703
+ if self.openalex_email:
1704
+ user_agent += f' (mailto:{self.openalex_email})'
1705
+ headers = {'User-Agent': user_agent}
1706
  if self.openalex_key:
1707
+ headers['Authorization'] = f"Bearer {self.openalex_key}"
1708
+ self._source_metric_add('oa', 'request_count')
1709
+ self._source_metric_set('oa', 'status', 'running')
1710
+ logging.info('OpenAlex search: term=%r normalized=%r page=%s', term, query, page)
1711
  try:
1712
+ response = self._requests_get_with_retry(
 
 
 
 
 
 
 
 
1713
  self.openalex_api_url,
1714
  headers=headers,
1715
+ params={
1716
+ **{
1717
+ 'search': query,
1718
+ 'per-page': self.lit_per_page,
1719
+ 'page': page,
1720
+ 'filter': filter_str,
1721
+ },
1722
+ **({'mailto': self.openalex_email} if self.openalex_email else {}),
1723
+ },
1724
  )
1725
+ status = response.status_code
1726
+ self._source_metric_set('oa', 'last_status_code', status)
1727
+ if status in (401, 403):
1728
+ self._record_source_error('oa', 'OPENALEX_AUTH_ERROR', status)
1729
+ return []
1730
+ if status == 429:
1731
+ self._record_source_error('oa', 'OPENALEX_RATE_LIMIT', status)
1732
+ return []
1733
+ if status == 400:
1734
+ self._record_source_error('oa', 'OPENALEX_BAD_QUERY', status)
1735
+ return []
1736
+ response.raise_for_status()
1737
+ self._source_metric_add('oa', 'success_count')
1738
+ raw_items = response.json().get('results', [])
1739
+ self._source_metric_add('oa', 'raw_result_count', len(raw_items))
1740
+ results = []
1741
  for item in raw_items:
1742
  self._check_cancelled()
1743
+ title = str(item.get('title') or '').strip()
1744
  if not title or not self._is_english_dominant(title):
1745
  continue
1746
+ year = item.get('publication_year') or 'n.d.'
1747
  formatted_authors, last_names = [], []
1748
+ for authorship in item.get('authorships') or []:
1749
+ name = str((authorship.get('author') or {}).get('display_name') or '').strip()
1750
+ if not name:
1751
  continue
1752
+ parts = name.split(); last = parts[-1]
1753
+ last_names.append(last)
1754
+ formatted_authors.append(f"{last}, {parts[0][0]}." if len(parts) > 1 else last)
 
 
 
 
1755
  if not formatted_authors:
1756
  continue
1757
  if len(last_names) == 1:
 
1760
  in_text = f"({last_names[0]} & {last_names[1]}, {year})"
1761
  else:
1762
  in_text = f"({last_names[0]} et al., {year})"
1763
+ 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])
1764
+ doi = self._normalize_doi(item.get('doi'))
1765
+ venue = (((item.get('primary_location') or {}).get('source') or {}).get('display_name') or 'Journal')
1766
+ ref = f"{author_str} ({year}). {title}. {venue}."
1767
+ if doi:
1768
+ ref += f" https://doi.org/{doi}"
1769
+ inverted = item.get('abstract_inverted_index') or {}
1770
+ words = [word for position, word in sorted((position, word) for word, positions in inverted.items() for position in positions)]
1771
+ abstract = ' '.join(words[:180])
 
 
 
 
 
 
 
 
 
 
 
1772
  results.append({
1773
+ 'title': title,
1774
+ 'year': str(year),
1775
+ 'doi': doi,
1776
+ 'external_id': str(item.get('id') or ''),
1777
+ 'in_text': in_text,
1778
+ 'ref': ref,
1779
+ 'rag_text': f"Title: {title}. Abstract evidence: {abstract}",
1780
+ 'source': 'oa',
1781
  })
1782
+ self._source_metric_add('oa', 'eligible_result_count', len(results))
1783
+ self._source_metric_set('oa', 'status', 'ok' if results else 'no_results')
1784
+ if not results:
1785
+ self._source_metric_set('oa', 'last_safe_error', 'OPENALEX_NO_RESULTS')
1786
+ return results
1787
  except PipelineCancelledError:
1788
  raise
1789
+ except requests.Timeout as error:
1790
+ self._record_source_error('oa', 'OPENALEX_TIMEOUT', detail=error)
1791
  except Exception as error:
1792
+ status = getattr(getattr(error, 'response', None), 'status_code', None)
1793
+ if status == 429:
1794
+ code = 'OPENALEX_RATE_LIMIT'
1795
+ elif status in (401, 403):
1796
+ code = 'OPENALEX_AUTH_ERROR'
1797
+ elif status == 400:
1798
+ code = 'OPENALEX_BAD_QUERY'
1799
+ else:
1800
+ code = 'OPENALEX_REQUEST_ERROR'
1801
+ self._record_source_error('oa', code, status, error)
1802
+ return []
1803
 
1804
  def fetch_references_parallel(self, topic, keywords, min_required=50, progress_callback=None):
1805
+ """Retrieve adaptively, then merge deterministically by DOI/ID/title-year.
1806
+
1807
+ Each phase first requests one page per source and query. A second page is
1808
+ requested only when the minimum evidence set is still not met. This keeps
1809
+ the interface responsive and avoids redundant OpenAlex filter pairs.
1810
+ """
1811
  with self.lock:
1812
  self.citation_map.clear()
1813
  self.used_citations.clear()
1814
+ self.citation_audit_log.clear()
1815
+ for source in ('s2', 'oa'):
1816
  for field in (
1817
+ 'request_count','success_count','raw_result_count',
1818
+ 'eligible_result_count','merged_count','unique_added_count',
1819
+ 'duplicate_count','error_count',
1820
  ):
1821
  self.source_metrics[source][field] = 0
1822
+ self.source_metrics[source]['last_status_code'] = None
1823
+ self.source_metrics[source]['last_safe_error'] = ''
1824
+ self.source_metrics[source]['status'] = 'not_started'
1825
+ self.s2_calls = self.oa_calls = 0
1826
 
 
 
1827
  all_used_terms = set()
1828
+ collected = {'s2': [], 'oa': []}
1829
+ basic_filter = 'language:en,type:article,is_paratext:false'
1830
+
1831
+ def unique_preview_count():
1832
+ keys_seen = set()
1833
+ count = 0
1834
+ for source in ('s2', 'oa'):
1835
+ for item in collected[source]:
1836
+ keys = self._reference_keys(item)
1837
+ if keys and any(key in keys_seen for key in keys):
1838
+ continue
1839
+ keys_seen.update(keys)
1840
+ count += 1
1841
+ return count
1842
 
1843
+ def run_wave(terms, *, second_page=False):
1844
+ future_sources = {}
1845
+ with concurrent.futures.ThreadPoolExecutor(max_workers=self.lit_max_workers) as executor:
1846
+ if not self.s2_fused:
1847
+ offset = 50 if second_page else 0
1848
+ for term in terms:
1849
+ future_sources[executor.submit(self._s2_worker, term, offset)] = 's2'
1850
+ if not self.oa_fused:
1851
+ page = 2 if second_page else 1
1852
+ for term in terms:
1853
+ future_sources[executor.submit(self._oa_worker, term, page, basic_filter)] = 'oa'
1854
+ for future in concurrent.futures.as_completed(future_sources):
1855
+ self._check_cancelled()
1856
+ source = future_sources[future]
1857
+ collected[source].extend(future.result())
1858
+ if progress_callback:
1859
+ progress_callback()
1860
+
1861
+ for phase in range(3):
1862
  self._check_cancelled()
1863
+ if phase == 0:
1864
+ terms = self.generate_search_queries(topic, keywords)
 
 
1865
  else:
 
 
 
 
 
1866
  response = self.call_deepseek(
1867
+ 'You are an academic librarian. Return only a JSON array with three new concise English search queries, each under five terms.',
1868
+ f"Topic: {topic}\nKeywords: {keywords}\nUsed: {sorted(all_used_terms)}",
1869
+ temp=0.4,
1870
+ model_role='fast',
1871
  )
1872
+ parsed = self._extract_json_from_text(response)
1873
+ terms = parsed if isinstance(parsed, list) else []
1874
+
1875
+ normalized_terms = []
1876
+ for value in terms:
1877
+ term = self._normalize_search_query(value)
1878
+ if term and self._is_english_dominant(term) and term not in all_used_terms:
1879
+ normalized_terms.append(term)
1880
+ all_used_terms.add(term)
1881
+ if not normalized_terms:
 
 
 
1882
  continue
 
1883
 
1884
+ run_wave(normalized_terms, second_page=False)
1885
+ if unique_preview_count() >= min_required:
1886
+ break
1887
+ run_wave(normalized_terms, second_page=True)
1888
+ if unique_preview_count() >= min_required:
1889
+ break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1890
 
1891
+ merge_limit = max(min_required, min(self.lit_target_papers, min_required + 30))
1892
+ merged = self._merge_reference_results(collected, merge_limit)
1893
+ with self.lock:
1894
+ for index, result in enumerate(merged, start=1):
1895
+ tag = f"[CITE-{index:02d}]"
1896
+ result_keys = self._reference_keys(result)
1897
+ self.citation_map[tag] = {
1898
+ 'in_text': result['in_text'],
1899
+ 'ref': result['ref'],
1900
+ 'id': result_keys[0] if result_keys else ('title_year', result.get('title'), result.get('year')),
1901
+ 'doi': result.get('doi',''),
1902
+ 'external_id': result.get('external_id',''),
1903
+ 'title': result.get('title',''),
1904
+ 'year': result.get('year',''),
1905
+ 'evidence': result.get('rag_text',''),
1906
+ 'sources': list(result.get('sources') or [result.get('source')]),
1907
+ }
1908
+ self.s2_calls = int(self.source_metrics['s2']['eligible_result_count'])
1909
+ self.oa_calls = int(self.source_metrics['oa']['eligible_result_count'])
1910
+ for source, no_result_code in (('s2', 'S2_NO_RESULTS'), ('oa', 'OPENALEX_NO_RESULTS')):
1911
+ metric = self.source_metrics[source]
1912
+ eligible = int(metric.get('eligible_result_count', 0))
1913
+ errors = int(metric.get('error_count', 0))
1914
+ if eligible > 0 and errors > 0:
1915
+ metric['status'] = 'partial'
1916
+ elif eligible > 0:
1917
+ metric['status'] = 'ok'
1918
+ metric['last_safe_error'] = ''
1919
+ elif errors > 0:
1920
+ metric['status'] = 'error'
1921
+ else:
1922
+ metric['status'] = 'no_results'
1923
+ metric['last_safe_error'] = no_result_code
1924
  logging.info(
1925
+ 'Reference search completed: unique=%s metrics=%s',
1926
  len(self.citation_map),
1927
  self.get_source_metrics(),
1928
  )
1929
+ return '\n'.join(
1930
+ f"{tag} {meta['evidence']}" for tag, meta in self.citation_map.items()
1931
+ )
1932
 
1933
  @staticmethod
1934
  def _citation_tag_number(tag):
 
2061
  text,
2062
  section_name="",
2063
  ):
2064
+ """Audit cited sentences against retrieved evidence at sentence scope.
2065
 
2066
  The audit may split a sentence when different sources support different
2067
  claims, or keep one source when several sources support the same claim.
2068
  It may not invent facts, numbers, quotations, or citation tags. A cited
2069
+ claim that cannot be supported is removed from the manuscript and logged;
2070
+ one unsupported sentence must not discard an otherwise valid paper.
2071
  """
2072
  if not text:
2073
  return text
 
2188
  self._safe_source_error(error),
2189
  )
2190
  if last_error is not None:
 
 
2191
  raise CitationAuditError(
2192
  "E_CITATION_AUDIT_TIMEOUT",
2193
  self._safe_source_error(last_error),
 
2254
  f"Invalid citation audit status for {record['id']}: {status!r}"
2255
  )
2256
  if status == "unsupported":
2257
+ reason = str(item.get("reason", ""))[:500]
2258
  unsupported.append(
2259
  {
2260
  "id": record["id"],
2261
  "sentence": record["original"][:500],
2262
+ "reason": reason,
2263
  }
2264
  )
2265
+ # An unsupported externally verifiable claim cannot remain in
2266
+ # the manuscript with an arbitrary citation. Remove only this
2267
+ # sentence and retain an auditable record of the decision.
2268
+ replacements[record["id"]] = ""
2269
+ self._append_citation_audit(
2270
+ section_name,
2271
+ "remove_unsupported_claim",
2272
+ record["original"],
2273
+ "",
2274
+ reason or "No candidate evidence directly supported the claim.",
2275
+ )
2276
  continue
2277
  if not revised:
2278
  raise DeepSeekRequestError(
 
2309
  replacements[record["id"]] = revised
2310
 
2311
  if unsupported:
2312
+ logging.warning(
2313
+ "Citation audit removed unsupported claims: section=%s count=%s examples=%s",
2314
+ section_name,
2315
+ len(unsupported),
2316
+ json.dumps(unsupported[:3], ensure_ascii=False),
 
2317
  )
2318
 
2319
  rebuilt = []
 
2324
  cursor = record["end"]
2325
  rebuilt.append(str(text)[cursor:])
2326
  audited = "".join(rebuilt)
2327
+ audited = re.sub(r"[ \t]+\n", "\n", audited)
2328
+ audited = re.sub(r"\n{3,}", "\n\n", audited).strip()
2329
  violations = self.find_multi_citation_violations(audited)
2330
  if violations:
2331
  raise DeepSeekRequestError(
 
2339
  )
2340
  return audited
2341
 
2342
+ def _append_citation_audit(self, section_name, action, original, revised='', detail=''):
2343
+ self.citation_audit_log.append({
2344
+ 'section': str(section_name),
2345
+ 'action': str(action),
2346
+ 'original': str(original)[:800],
2347
+ 'revised': str(revised)[:800],
2348
+ 'detail': str(detail)[:500],
2349
+ })
2350
+
2351
+ def _citation_support_score(self, sentence, tag):
2352
+ metadata = self.citation_map.get(tag) or {}
2353
+ claim_text = re.sub(r"\[?CITE-\d+\]?", " ", str(sentence), flags=re.I)
2354
+ claim_terms = self._citation_terms(claim_text)
2355
+ evidence_text = " ".join(
2356
+ [str(metadata.get("ref", "")), str(metadata.get("evidence", ""))]
2357
+ )
2358
+ evidence_terms = self._citation_terms(evidence_text)
2359
+ overlap = len(claim_terms & evidence_terms)
2360
+ coverage = overlap / max(1, len(claim_terms))
2361
+ return coverage, overlap
2362
+
2363
+ def _local_citation_recovery(self, text, section_name=''):
2364
+ """Conservative deterministic recovery for technical audit failures.
2365
+
2366
+ This routine never keeps the first citation merely because it appeared
2367
+ first. A source is retained only when lexical evidence is sufficiently
2368
+ strong; otherwise the externally verifiable sentence is removed and the
2369
+ decision is written to the citation audit log.
2370
+ """
2371
+ rebuilt = []
2372
+ cursor = 0
2373
+ for start, end, sentence in self._sentence_spans(text):
2374
+ rebuilt.append(str(text)[cursor:start])
2375
+ original = sentence
2376
+ tags = [f"[{tag.upper()}]" for tag in re.findall(r'CITE-\d+', sentence, re.I)]
2377
+ unique_tags = list(dict.fromkeys(tags))
2378
+ valid = [tag for tag in unique_tags if tag in self.citation_map]
2379
+ revised = sentence
2380
+
2381
+ if unique_tags and not valid:
2382
+ revised = re.sub(r'\s*[\[(]?CITE-\d+[\])]?\s*', ' ', sentence, flags=re.I)
2383
+ revised = re.sub(r'\s+', ' ', revised).strip()
2384
+ self._append_citation_audit(
2385
+ section_name,
2386
+ 'remove_unknown_tag',
2387
+ original,
2388
+ revised,
2389
+ 'No valid citation tag was available.',
2390
+ )
2391
+ elif len(valid) == 1:
2392
+ selected_tag = valid[0]
2393
+ coverage, overlap = self._citation_support_score(sentence, selected_tag)
2394
+ if overlap >= 2 and coverage >= 0.12:
2395
+ self._append_citation_audit(
2396
+ section_name,
2397
+ 'retain_lexically_supported_source',
2398
+ original,
2399
+ sentence,
2400
+ f'Lexical support overlap={overlap}, coverage={coverage:.3f}.',
2401
+ )
2402
+ else:
2403
+ revised = ''
2404
+ self._append_citation_audit(
2405
+ section_name,
2406
+ 'remove_unverified_claim',
2407
+ original,
2408
+ '',
2409
+ f'The single candidate did not meet the conservative support threshold; overlap={overlap}, coverage={coverage:.3f}.',
2410
+ )
2411
+ elif len(valid) > 1:
2412
+ scored = sorted(
2413
+ (
2414
+ (*self._citation_support_score(sentence, tag), -self._citation_tag_number(tag), tag)
2415
+ for tag in valid
2416
+ ),
2417
+ reverse=True,
2418
+ )
2419
+ coverage, overlap, _, selected_tag = scored[0]
2420
+ if overlap >= 2 and coverage >= 0.12:
2421
+ revised = re.sub(
2422
+ r'\s*[\[(]?CITE-\d+(?:\s*[,;]\s*CITE-\d+)*[\])]?\s*',
2423
+ ' ',
2424
+ sentence,
2425
+ flags=re.I,
2426
+ )
2427
+ revised = re.sub(r'\s+', ' ', revised).strip()
2428
+ punctuation = revised[-1] if revised and revised[-1] in '.!?。!?' else ''
2429
+ if punctuation:
2430
+ revised = revised[:-1].rstrip()
2431
+ revised = f"{revised} {selected_tag}{punctuation}"
2432
+ self._append_citation_audit(
2433
+ section_name,
2434
+ 'select_evidence_supported_source',
2435
+ original,
2436
+ revised,
2437
+ f'Lexical support overlap={overlap}, coverage={coverage:.3f}.',
2438
+ )
2439
+ else:
2440
+ revised = ''
2441
+ self._append_citation_audit(
2442
+ section_name,
2443
+ 'remove_unverified_claim',
2444
+ original,
2445
+ '',
2446
+ f'No candidate met the conservative support threshold; best overlap={overlap}, coverage={coverage:.3f}.',
2447
+ )
2448
+ rebuilt.append(revised)
2449
+ cursor = end
2450
+ rebuilt.append(str(text)[cursor:])
2451
+ recovered = ''.join(rebuilt)
2452
+ recovered = re.sub(r'[ \t]+\n', '\n', recovered)
2453
+ return re.sub(r'\n{3,}', '\n\n', recovered).strip()
2454
+
2455
+ def audit_and_resolve_citations(self, text, section_name='', quarantine=False):
2456
+ value = str(text or '')
2457
+ if quarantine:
2458
+ return self.resolve_citations(value, quarantine=True)
2459
+ unknown = sorted({f"[{tag.upper()}]" for tag in re.findall(r'CITE-\d+', value, re.I)} - set(self.citation_map))
2460
+ if unknown:
2461
+ for tag in unknown:
2462
+ value = re.sub(rf'\s*\[?{re.escape(tag.strip("[]"))}\]?\s*', ' ', value, flags=re.I)
2463
+ self._append_citation_audit(section_name, 'remove_unknown_tags', text, value, ','.join(unknown))
2464
+ has_citations = bool(re.search(r'CITE-\d+', value, re.I))
2465
+ violations = self.find_multi_citation_violations(value)
2466
+ if has_citations and self.citation_semantic_audit.get('enabled', True):
2467
+ try:
2468
+ value = self.semantic_audit_claim_specific_citations(value, section_name=section_name)
2469
+ except PipelineCancelledError:
2470
+ raise
2471
+ except CitationAuditError as error:
2472
+ logging.warning(
2473
+ 'Citation semantic audit technical fallback: section=%s code=%s detail=%s',
2474
+ section_name,
2475
+ error.code,
2476
+ error.safe_detail,
2477
+ )
2478
+ value = self._local_citation_recovery(value, section_name)
2479
+ except Exception as error:
2480
+ logging.warning(
2481
+ 'Citation semantic audit fallback: section=%s error=%s',
2482
+ section_name,
2483
+ self._safe_source_error(error),
2484
+ )
2485
+ value = self._local_citation_recovery(value, section_name)
2486
+ elif violations:
2487
+ value = self._local_citation_recovery(value, section_name)
2488
+ if self.find_multi_citation_violations(value):
2489
+ value = self._local_citation_recovery(value, section_name)
2490
+ if self.find_multi_citation_violations(value):
2491
+ raise DeepSeekRequestError(
2492
+ f'Citation recovery could not resolve all violations in {section_name}.'
2493
+ )
2494
+ return self.resolve_citations(value, quarantine=False)
2495
+
2496
+ @staticmethod
2497
+ def _remove_ai_meta_traces(text):
2498
+ patterns = [
2499
+ r'(?im)^.*\bas an ai(?: language model)?\b.*(?:\n|$)',
2500
+ r'(?im)^.*\bai[- ]generated\b.*(?:\n|$)',
2501
+ r'(?im)^.*\bthis response\b.*(?:\n|$)',
2502
+ r'(?im)^.*\bi cannot (?:browse|access|verify)\b.*(?:\n|$)',
2503
+ ]
2504
+ value = str(text or '')
2505
+ for pattern in patterns:
2506
+ value = re.sub(pattern, '', value)
2507
+ return re.sub(r'\n{3,}', '\n\n', value).strip()
2508
+
2509
+ def polish_academic_section(self, text, section_name='', abstract=False):
2510
+ value = self._remove_ai_meta_traces(text)
2511
+ quality = APP_CONFIG.get('manuscript_quality', {})
2512
+ forbidden = quality.get('forbidden_phrases', [])
2513
+ detected = [phrase for phrase in forbidden if re.search(rf'\b{re.escape(phrase)}\b', value, re.I)]
2514
+ transitions = re.findall(r'(?im)^(Firstly|Secondly|Moreover|Furthermore|In conclusion|To summarize)\b', value)
2515
+ numeric_count = len(re.findall(r'(?<![A-Za-z])[-+]?\d+(?:\.\d+)?%?', value)) if abstract else 0
2516
+ max_numbers = int(quality.get('abstract_max_numeric_mentions', 6))
2517
+ needs_revision = bool(detected or transitions or (abstract and numeric_count > max_numbers))
2518
+ if not needs_revision:
2519
+ return value
2520
+ numeric_rule = (
2521
+ "For the abstract, remove nonessential numerical details. You may omit numbers, "
2522
+ "but do not alter any number that remains and do not add a new number."
2523
+ if abstract
2524
+ else "Preserve every number, percentage, coefficient, p-value, and confidence interval exactly."
2525
+ )
2526
+ 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}"""
2527
+ revised = self.call_deepseek(
2528
+ 'You are a meticulous academic language editor. Output only the revised section.',
2529
+ prompt,
2530
+ max_tokens=max(1200, min(8000, self._count_tokens(value) + 1200)),
2531
+ temp=0.1,
2532
+ model_role='writing',
2533
+ thinking=False,
2534
+ allow_fallback=False,
2535
+ )
2536
+ revised = self._remove_ai_meta_traces(revised)
2537
+ original_numbers = self._numeric_tokens(value)
2538
+ revised_numbers = self._numeric_tokens(revised)
2539
+ if abstract:
2540
+ added_or_changed = any(
2541
+ count > original_numbers.get(token, 0)
2542
+ for token, count in revised_numbers.items()
2543
+ )
2544
+ if added_or_changed:
2545
+ logging.warning('Abstract quality revision rejected because a number was added or changed: %s', section_name)
2546
+ return value
2547
+ if sum(revised_numbers.values()) > max_numbers:
2548
+ logging.warning('Abstract quality revision rejected because numerical density remains excessive: %s', section_name)
2549
+ return value
2550
+ elif original_numbers != revised_numbers:
2551
+ logging.warning('Quality revision rejected because numbers changed: %s', section_name)
2552
+ return value
2553
+ original_tags = Counter(re.findall(r'CITE-\d+', value, re.I))
2554
+ revised_tags = Counter(re.findall(r'CITE-\d+', revised, re.I))
2555
+ if original_tags != revised_tags:
2556
+ logging.warning('Quality revision rejected because citation tags changed: %s', section_name)
2557
+ return value
2558
+ return revised.strip()
2559
+
2560
+ @staticmethod
2561
+ def _trim_at_sentence_boundary(value, max_words):
2562
+ text = str(value or '').strip()
2563
+ words = text.split()
2564
+ if len(words) <= max_words:
2565
+ return text
2566
+ sentences = re.split(r'(?<=[.!?])\s+', text)
2567
+ selected = []
2568
+ count = 0
2569
+ for sentence in sentences:
2570
+ sentence_words = sentence.split()
2571
+ if not sentence_words:
2572
+ continue
2573
+ if count + len(sentence_words) > max_words:
2574
+ break
2575
+ selected.append(sentence.strip())
2576
+ count += len(sentence_words)
2577
+ if selected:
2578
+ return ' '.join(selected).strip()
2579
+ return ' '.join(words[:max_words]).rstrip(' ,;:') + '.'
2580
+
2581
+ def _abstract_manuscript_context(self, manuscript, max_tokens=18000):
2582
+ value = str(manuscript or '').strip()
2583
+ if self._count_tokens(value) <= max_tokens:
2584
+ return value
2585
+ matches = list(re.finditer(r'(?m)^#\s+(.+?)\s*$', value))
2586
+ if not matches:
2587
+ head = self._truncate_context(value, max_tokens // 3)
2588
+ tail_tokens = self.tokenizer.encode(value)[-(max_tokens - self._count_tokens(head)):]
2589
+ return head + '\n\n' + self.tokenizer.decode(tail_tokens)
2590
+
2591
+ sections = []
2592
+ preamble = value[:matches[0].start()].strip()
2593
+ if preamble:
2594
+ sections.append(('preamble', preamble))
2595
+ for index, match in enumerate(matches):
2596
+ end = matches[index + 1].start() if index + 1 < len(matches) else len(value)
2597
+ sections.append((match.group(1).strip().lower(), value[match.start():end].strip()))
2598
+
2599
+ categories = (
2600
+ (('method', 'research design', 'data', 'sample'), 3000),
2601
+ (('result', 'finding', 'analysis'), 6500),
2602
+ (('discussion', 'implication'), 4000),
2603
+ (('conclusion',), 2500),
2604
+ (('introduction', 'background'), 1800),
2605
+ )
2606
+ selected = []
2607
+ used = set()
2608
+ for keywords, budget in categories:
2609
+ candidates = [
2610
+ (index, text) for index, (title, text) in enumerate(sections)
2611
+ if index not in used and any(keyword in title for keyword in keywords)
2612
+ ]
2613
+ for index, text in candidates:
2614
+ selected.append(self._truncate_context(text, budget))
2615
+ used.add(index)
2616
+ break
2617
+ if not selected:
2618
+ return self._truncate_context(value, max_tokens)
2619
+ combined = '\n\n'.join(selected)
2620
+ return self._truncate_context(combined, max_tokens)
2621
+
2622
+ def generate_final_abstract(self, topic, keywords, manuscript, data_evidence='', mode='amj'):
2623
+ evidence = self._truncate_context(str(data_evidence or ''), 5000)
2624
+ body = self._abstract_manuscript_context(manuscript, 18000)
2625
+ 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}"""
2626
+ abstract = self.call_deepseek(
2627
+ 'You are a senior journal editor.', prompt, max_tokens=900, temp=0.1,
2628
+ model_role='writing', thinking=False, allow_fallback=False,
2629
+ )
2630
+ abstract = self._remove_ai_meta_traces(abstract)
2631
+ abstract = re.sub(r'(?im)^#+\s*abstract\s*', '', abstract).strip()
2632
+ abstract = self.polish_academic_section(abstract, 'Abstract', abstract=True)
2633
+ max_words = max(80, int(APP_CONFIG.get('manuscript_quality', {}).get('abstract_max_words', 200)))
2634
+ return self._trim_at_sentence_boundary(abstract, max_words)
2635
+
2636
+ @staticmethod
2637
+ def _translation_language_rule(target_lang):
2638
+ if 'Russian' in target_lang:
2639
+ 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.'
2640
+ if 'Kazakh' in target_lang:
2641
+ 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.'
2642
+ 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.'
2643
+
2644
+ @staticmethod
2645
+ def _translation_prompt(target_lang):
2646
+ 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."""
2647
+ return common + ' ' + SSciPipelineAgent._translation_language_rule(target_lang)
2648
+
2649
+ @staticmethod
2650
+ def _localized_reference_heading(target_lang):
2651
+ if 'Russian' in target_lang:
2652
+ return 'Литература'
2653
+ if 'Kazakh' in target_lang:
2654
+ return 'Пайдаланылған әдебиеттер'
2655
+ return '参考文献'
2656
+
2657
+ @staticmethod
2658
+ def _markdown_heading_tree(value):
2659
+ return [
2660
+ (len(match.group(1)), match.group(2).strip())
2661
+ for match in re.finditer(r'(?m)^(#{1,3})\s+(.+?)\s*$', str(value or ''))
2662
+ ]
2663
+
2664
+ @staticmethod
2665
+ def _translation_hypothesis_tokens(value):
2666
+ return Counter(re.findall(r'\bH\d+[A-Za-z]?\b', str(value or ''), flags=re.I))
2667
+
2668
+ @staticmethod
2669
+ def _translation_acronym_tokens(value):
2670
+ without_doi = re.sub(r'https?://\S+|doi:\s*\S+', ' ', str(value or ''), flags=re.I)
2671
+ return Counter(re.findall(r'\b[A-Z][A-Z0-9-]{1,11}\b', without_doi))
2672
+
2673
+ @staticmethod
2674
+ def _translation_citation_tokens(value):
2675
+ return Counter(
2676
+ re.findall(
2677
+ r'\([^()\n]*?(?:19|20)\d{2}[a-z]?[^()\n]*?\)',
2678
+ str(value or ''),
2679
+ flags=re.I,
2680
+ )
2681
+ )
2682
+
2683
+ @staticmethod
2684
+ def _translation_body_units(body):
2685
+ units = []
2686
+ buffer = []
2687
+
2688
+ def flush_buffer():
2689
+ if not buffer:
2690
+ return
2691
+ segment = '\n'.join(buffer).strip()
2692
+ buffer.clear()
2693
+ if not segment:
2694
+ return
2695
+ for part in re.split(r'\n{2,}', segment):
2696
+ part = part.strip()
2697
+ if part:
2698
+ units.append({'kind': 'body', 'text': part})
2699
+
2700
+ for line in str(body or '').splitlines():
2701
+ heading = re.match(r'^(#{1,3})\s+(.+?)\s*$', line)
2702
+ if heading:
2703
+ flush_buffer()
2704
+ units.append({
2705
+ 'kind': 'heading',
2706
+ 'level': len(heading.group(1)),
2707
+ 'text': heading.group(2).strip(),
2708
+ })
2709
+ else:
2710
+ buffer.append(line)
2711
+ flush_buffer()
2712
+ return units
2713
+
2714
+ def _build_translation_glossary(self, body, target_lang):
2715
+ headings = [title for _, title in self._markdown_heading_tree(body)]
2716
+ acronyms = sorted(self._translation_acronym_tokens(body))
2717
+ phrases = re.findall(
2718
+ r'\b(?:[A-Z][a-z]+(?:[- ][A-Z][a-z]+){1,3})\b',
2719
+ str(body or ''),
2720
+ )
2721
+ phrase_counts = Counter(phrases)
2722
+ term_limit = max(10, int(APP_CONFIG.get('translation_glossary_term_limit', 40)))
2723
+ recurring = [term for term, count in phrase_counts.most_common() if count >= 2]
2724
+ unique_headings = list(dict.fromkeys(headings))
2725
+ extras = [term for term in list(dict.fromkeys(recurring + acronyms)) if term not in unique_headings]
2726
+ sources = unique_headings + extras[:max(0, term_limit - len(unique_headings))]
2727
+ if not sources:
2728
+ return {}
2729
+
2730
+ language_rule = self._translation_language_rule(target_lang)
2731
+ payload = json.dumps({'terms': sources}, ensure_ascii=False)
2732
+ system_prompt = f"""Create one consistent academic terminology glossary for translation into {target_lang}. {language_rule}
2733
+ Return valid JSON only in this exact form: {{"terms":[{{"source":"...","target":"..."}}]}}.
2734
+ Translate headings and terminology naturally. Preserve standard variable abbreviations and acronyms when they should remain unchanged. Return every supplied source exactly once."""
2735
+ response = self.call_deepseek(
2736
+ system_prompt,
2737
+ payload,
2738
+ max_tokens=max(1600, min(6000, len(sources) * 80)),
2739
+ temp=0.0,
2740
+ model_role='translation',
2741
+ thinking=False,
2742
+ allow_fallback=False,
2743
+ )
2744
+ parsed = self._extract_json_from_text(response)
2745
+ items = parsed.get('terms', []) if isinstance(parsed, dict) else []
2746
+ mapping = {
2747
+ str(item.get('source') or '').strip(): str(item.get('target') or '').strip()
2748
+ for item in items
2749
+ if isinstance(item, dict)
2750
+ }
2751
+ missing_headings = [heading for heading in headings if not mapping.get(heading)]
2752
+ if missing_headings:
2753
+ raise DeepSeekRequestError(
2754
+ 'Translation glossary omitted heading(s): ' + ', '.join(missing_headings[:5])
2755
+ )
2756
+ for acronym in acronyms:
2757
+ mapping.setdefault(acronym, acronym)
2758
+ return mapping
2759
+
2760
+ @staticmethod
2761
+ def _parse_marked_translation(value, expected_ids):
2762
+ pattern = re.compile(r'(?m)^\[\[SP-P(\d{6})\]\]\s*$')
2763
+ matches = list(pattern.finditer(str(value or '')))
2764
+ found_ids = [match.group(1) for match in matches]
2765
+ if found_ids != list(expected_ids):
2766
+ raise DeepSeekRequestError(
2767
+ f'Translation marker mismatch: expected={list(expected_ids)}, got={found_ids}.'
2768
+ )
2769
+ parsed = {}
2770
+ for index, match in enumerate(matches):
2771
+ end = matches[index + 1].start() if index + 1 < len(matches) else len(str(value or ''))
2772
+ translated = str(value or '')[match.end():end].strip()
2773
+ if not translated:
2774
+ raise DeepSeekRequestError(f'Translation block SP-P{match.group(1)} is empty.')
2775
+ parsed[match.group(1)] = translated
2776
+ return parsed
2777
+
2778
+ def _validate_translation_block(self, source, translated, block_id, target_lang):
2779
+ if self._numeric_tokens(source) != self._numeric_tokens(translated):
2780
+ raise DeepSeekRequestError(f'Translation changed numerical content in {block_id}.')
2781
+ if self._translation_citation_tokens(source) != self._translation_citation_tokens(translated):
2782
+ raise DeepSeekRequestError(f'Translation changed citations in {block_id}.')
2783
+ if self._translation_hypothesis_tokens(source) != self._translation_hypothesis_tokens(translated):
2784
+ raise DeepSeekRequestError(f'Translation changed hypothesis labels in {block_id}.')
2785
+ if self._translation_acronym_tokens(source) != self._translation_acronym_tokens(translated):
2786
+ raise DeepSeekRequestError(f'Translation changed variable abbreviations in {block_id}.')
2787
+
2788
+ source_compact = re.sub(r'\s+', '', str(source or ''))
2789
+ target_compact = re.sub(r'\s+', '', str(translated or ''))
2790
+ if len(source_compact) >= 120:
2791
+ is_chinese = 'Chinese' in target_lang
2792
+ minimum = float(APP_CONFIG.get(
2793
+ 'translation_min_block_ratio_zh' if is_chinese else 'translation_min_block_ratio_cyrillic',
2794
+ 0.28 if is_chinese else 0.65,
2795
+ ))
2796
+ maximum = float(APP_CONFIG.get('translation_max_block_ratio', 2.20))
2797
+ ratio = len(target_compact) / max(1, len(source_compact))
2798
+ if ratio < minimum or ratio > maximum:
2799
+ raise DeepSeekRequestError(
2800
+ f'Translation coverage ratio is implausible in {block_id}: {ratio:.3f}.'
2801
+ )
2802
+ if source_compact.casefold() == target_compact.casefold():
2803
+ raise DeepSeekRequestError(f'Translation block {block_id} was returned unchanged.')
2804
+ if is_chinese and not re.search(r'[\u4e00-\u9fff]', translated):
2805
+ raise DeepSeekRequestError(f'Translation block {block_id} contains no Chinese text.')
2806
+ if not is_chinese and not re.search(r'[А-Яа-яЁёӘәҒғҚқҢңӨөҰұҮүҺһІі]', translated):
2807
+ raise DeepSeekRequestError(f'Translation block {block_id} contains no Cyrillic target-language text.')
2808
+
2809
+ def translate_locked_manuscript(self, manuscript, target_lang):
2810
+ value = str(manuscript or '').strip()
2811
+ if not value:
2812
+ raise DeepSeekRequestError('English manuscript is empty.')
2813
+ reference_split = re.split(r'(?m)^#\s+References\s*$', value, maxsplit=1)
2814
+ body = reference_split[0].strip()
2815
+ references = reference_split[1].strip() if len(reference_split) == 2 else ''
2816
+ units = self._translation_body_units(body)
2817
+ if not units:
2818
+ raise DeepSeekRequestError('English manuscript contains no translatable content.')
2819
+
2820
+ glossary = self._build_translation_glossary(body, target_lang)
2821
+ body_units = [unit for unit in units if unit['kind'] == 'body']
2822
+ translations = {}
2823
+ token_budget = max(800, int(APP_CONFIG.get('translation_batch_tokens', 2500)))
2824
+ batches = []
2825
+ current = []
2826
+ current_tokens = 0
2827
+ for index, unit in enumerate(body_units, start=1):
2828
+ block_id = f'{index:06d}'
2829
+ tokens = max(1, self._count_tokens(unit['text']))
2830
+ if current and current_tokens + tokens > token_budget:
2831
+ batches.append(current)
2832
+ current = []
2833
+ current_tokens = 0
2834
+ current.append((block_id, unit['text']))
2835
+ current_tokens += tokens
2836
+ if current:
2837
+ batches.append(current)
2838
+
2839
+ system_prompt = self._translation_prompt(target_lang) + '\nUse this terminology glossary consistently:\n' + json.dumps(glossary, ensure_ascii=False)
2840
+ for batch in batches:
2841
+ self._check_cancelled()
2842
+ marked_source = '\n\n'.join(
2843
+ f'[[SP-P{block_id}]]\n{source}' for block_id, source in batch
2844
+ )
2845
+ result = self.call_deepseek(
2846
+ system_prompt,
2847
+ marked_source,
2848
+ max_tokens=max(1800, min(9000, self._count_tokens(marked_source) * 2 + 1200)),
2849
+ temp=0.0,
2850
+ model_role='translation',
2851
+ thinking=False,
2852
+ allow_fallback=False,
2853
+ )
2854
+ result = self._remove_ai_meta_traces(result)
2855
+ parsed = self._parse_marked_translation(result, [block_id for block_id, _ in batch])
2856
+ for block_id, source in batch:
2857
+ translated = parsed[block_id]
2858
+ self._validate_translation_block(source, translated, f'SP-P{block_id}', target_lang)
2859
+ translations[block_id] = translated
2860
+
2861
+ output_units = []
2862
+ body_index = 0
2863
+ for unit in units:
2864
+ if unit['kind'] == 'heading':
2865
+ translated_heading = str(glossary.get(unit['text']) or '').strip()
2866
+ if not translated_heading:
2867
+ raise DeepSeekRequestError(f'Translation glossary missing heading: {unit["text"]}')
2868
+ output_units.append('#' * int(unit['level']) + ' ' + translated_heading)
2869
+ else:
2870
+ body_index += 1
2871
+ output_units.append(translations[f'{body_index:06d}'])
2872
+
2873
+ output = '\n\n'.join(output_units).strip()
2874
+ if references:
2875
+ output += f"\n\n# {self._localized_reference_heading(target_lang)}\n{references}"
2876
+ self.validate_translation_alignment(value, output)
2877
+ return output.strip()
2878
+
2879
+ @staticmethod
2880
+ def validate_translation_alignment(english_text, translated_text):
2881
+ source = str(english_text or '').strip()
2882
+ target = str(translated_text or '').strip()
2883
+ if not target:
2884
+ raise DeepSeekRequestError('Target-language manuscript is empty.')
2885
+ en_tree = [level for level, _ in SSciPipelineAgent._markdown_heading_tree(source)]
2886
+ tr_tree = [level for level, _ in SSciPipelineAgent._markdown_heading_tree(target)]
2887
+ if en_tree != tr_tree:
2888
+ raise DeepSeekRequestError(
2889
+ f'Translation heading-tree mismatch: English={en_tree}, target={tr_tree}.'
2890
+ )
2891
+ if SSciPipelineAgent._numeric_tokens(source) != SSciPipelineAgent._numeric_tokens(target):
2892
+ raise DeepSeekRequestError('Translation numerical alignment failed.')
2893
+ if SSciPipelineAgent._translation_citation_tokens(source) != SSciPipelineAgent._translation_citation_tokens(target):
2894
+ raise DeepSeekRequestError('Translation citation alignment failed.')
2895
+ if SSciPipelineAgent._translation_hypothesis_tokens(source) != SSciPipelineAgent._translation_hypothesis_tokens(target):
2896
+ raise DeepSeekRequestError('Translation hypothesis-label alignment failed.')
2897
+ if SSciPipelineAgent._translation_acronym_tokens(source) != SSciPipelineAgent._translation_acronym_tokens(target):
2898
+ raise DeepSeekRequestError('Translation abbreviation alignment failed.')
2899
+ source_body = re.split(r'(?m)^#\s+References\s*$', source, maxsplit=1)[0]
2900
+ target_body = re.split(r'(?m)^#\s+(?:参考文献|Литература|Пайдаланылған әдебиеттер)\s*$', target, maxsplit=1)[0]
2901
+ source_chars = len(re.sub(r'\s+', '', source_body))
2902
+ target_chars = len(re.sub(r'\s+', '', target_body))
2903
+ if re.search(r'(?m)^#\s+参考文献\s*$', target):
2904
+ minimum_ratio = float(APP_CONFIG.get('translation_min_block_ratio_zh', 0.28))
2905
+ elif re.search(r'(?m)^#\s+(?:Литература|Пайдаланылған әдебиеттер)\s*$', target):
2906
+ minimum_ratio = float(APP_CONFIG.get('translation_min_block_ratio_cyrillic', 0.65))
2907
+ else:
2908
+ minimum_ratio = float(APP_CONFIG.get('translation_min_coverage_ratio', 0.25))
2909
+ ratio = target_chars / source_chars if source_chars else 0.0
2910
+ if source_chars and ratio < minimum_ratio:
2911
+ raise DeepSeekRequestError(
2912
+ f'Translation content coverage is too low: {ratio:.3f}.'
2913
+ )
2914
+
2915
+ # Russian and Kazakh both use Cyrillic, so a generic Cyrillic check is
2916
+ # insufficient. Long Kazakh manuscripts must contain a meaningful
2917
+ # number of Kazakh-specific letters; Russian manuscripts must not be
2918
+ # dominated by those letters. This is deterministic and adds no API cost.
2919
+ kazakh_specific = re.findall(r'[ӘәҒғҚқҢңӨөҰұҮүҺһІі]', target_body)
2920
+ cyrillic = re.findall(r'[А-Яа-яЁёӘәҒғҚқҢңӨөҰұҮүҺһІі]', target_body)
2921
+ if re.search(r'(?m)^#\s+Пайдаланылған әдебиеттер\s*$', target):
2922
+ minimum_kazakh = max(8, int(len(cyrillic) * 0.003))
2923
+ if len(kazakh_specific) < minimum_kazakh:
2924
+ raise DeepSeekRequestError(
2925
+ 'Kazakh language validation failed: insufficient Kazakh-specific letters.'
2926
+ )
2927
+ elif re.search(r'(?m)^#\s+Литература\s*$', target):
2928
+ if cyrillic and len(kazakh_specific) / len(cyrillic) > 0.02:
2929
+ raise DeepSeekRequestError(
2930
+ 'Russian language validation failed: excessive Kazakh-specific letters.'
2931
+ )
2932
+ return True
2933
+
2934
  def enforce_claim_specific_citations(self, text):
2935
  """Backward-compatible strict validator; no silent citation deletion."""
2936
  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
@@ -6,7 +6,6 @@ from __future__ import annotations
6
  import logging
7
  import re
8
 
9
- import streamlit as st
10
 
11
  from config import APP_CONFIG
12
 
@@ -69,35 +68,18 @@ Rules:
69
  def resolve_qualitative_data(self, topic, keywords, raw_interviews=""):
70
  has_user_data = bool(raw_interviews and len(raw_interviews) > 100)
71
  prompt = self._build_request(topic, keywords, raw_interviews)
72
- model_label = self.agent.model_for("reasoning")
73
-
74
- if has_user_data:
75
- status_label = (
76
- f"🧠 正在使用 {model_label} 分析访谈材料并冻结研究事实…"
77
- )
78
- complete_label = "✅ 访谈材料、研究事实与编码结构已完成"
79
- else:
80
- status_label = (
81
- f"🧠 正在使用 {model_label} 构建一致的定性研究证据包…"
82
- )
83
- complete_label = "✅ 定性研究事实与编码结构已完成"
84
-
85
- with st.status(status_label, expanded=True) as status:
86
- placeholder = st.empty()
87
- response = placeholder.write_stream(
88
- self.agent.stream_deepseek(
89
- "You are a rigorous qualitative research analyst.",
90
- prompt,
91
- model_role="reasoning",
92
- thinking=True,
93
- allow_fallback=False,
94
- )
95
- )
96
- status.update(
97
- label=complete_label,
98
- state="complete",
99
- expanded=False,
100
  )
 
101
 
102
  response = re.sub(
103
  r"<think>.*?</think>",
@@ -222,6 +204,21 @@ Rules:
222
  "analysis_method": method,
223
  },
224
  "evidence_count": len(clean_evidence),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  "evidence": clean_evidence,
226
  "themes": themes,
227
  }
 
6
  import logging
7
  import re
8
 
 
9
 
10
  from config import APP_CONFIG
11
 
 
68
  def resolve_qualitative_data(self, topic, keywords, raw_interviews=""):
69
  has_user_data = bool(raw_interviews and len(raw_interviews) > 100)
70
  prompt = self._build_request(topic, keywords, raw_interviews)
71
+ # R3-Free engine code is headless. Streamlit rendering belongs to app.py;
72
+ # this method only produces deterministic evidence + metadata.
73
+ response = "".join(
74
+ str(chunk or "")
75
+ for chunk in self.agent.stream_deepseek(
76
+ "You are a rigorous qualitative research analyst.",
77
+ prompt,
78
+ model_role="reasoning",
79
+ thinking=True,
80
+ allow_fallback=False,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  )
82
+ )
83
 
84
  response = re.sub(
85
  r"<think>.*?</think>",
 
204
  "analysis_method": method,
205
  },
206
  "evidence_count": len(clean_evidence),
207
+ "generation_parameters": {
208
+ "coding_approach": "Gioia-style inductive coding",
209
+ "requested_quote_count_when_generated": 12,
210
+ "maximum_retained_quotes": 15,
211
+ "minimum_themes": minimum_themes,
212
+ "minimum_quotes": minimum_quotes,
213
+ "consistency_constraints": [
214
+ "one fixed research setting",
215
+ "one fixed participant count",
216
+ "one fixed interview count",
217
+ "one fixed data-source set",
218
+ "one fixed analysis method",
219
+ "quotation tags must remain unique",
220
+ ],
221
+ },
222
  "evidence": clean_evidence,
223
  "themes": themes,
224
  }
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",
desktop.ini ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [.ShellClassInfo]
2
+ IconResource=C:\Program Files\Google\Drive File Stream\129.0.1.0\GoogleDriveFS.exe,27
i18n.py CHANGED
@@ -208,7 +208,7 @@ TEXTS: dict[str, dict[str, str]] = {
208
  "title": "📊 工商管理论文智能体",
209
  "topic_label": "📝 论文名称",
210
  "keywords_label": "🔍 关键字",
211
- "words_label": "📏 目标总单词数(12000–25000单词)",
212
  "success": "🎉 流水线推演完毕!双语文档已就绪。",
213
  "download": "📥 下载文件",
214
  "system_busy_title": "⏳ 系统忙碌",
@@ -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": "将文件拖放到此处",
@@ -352,7 +361,7 @@ TEXTS: dict[str, dict[str, str]] = {
352
  "title": "📊 Business Administration Article Agent",
353
  "topic_label": "📝 Article Title",
354
  "keywords_label": "🔍 Keywords",
355
- "words_label": "📏 Target Word Count (12,000–25,000 words)",
356
  "success": "🎉 The pipeline is complete. The bilingual documents are ready.",
357
  "download": "📥 Download Files",
358
  "system_busy_title": "⏳ System Busy",
@@ -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",
@@ -496,7 +514,7 @@ TEXTS: dict[str, dict[str, str]] = {
496
  "title": "📊 Интеллектуальный агент для научных статей по управлению бизнесом",
497
  "topic_label": "📝 Название статьи",
498
  "keywords_label": "🔍 Ключевые слова",
499
- "words_label": "📏 Целевой объём (12 000–25 000 слов)",
500
  "success": "🎉 Работа конвейера завершена. Двуязычные документы готовы.",
501
  "download": "📥 Скачать файлы",
502
  "system_busy_title": "⏳ Система занята",
@@ -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": "Перетащите файлы сюда",
@@ -640,7 +667,7 @@ TEXTS: dict[str, dict[str, str]] = {
640
  "title": "📊 Бизнес-басқару жөніндегі ғылыми мақала интеллектуалды агенті",
641
  "topic_label": "📝 Мақала атауы",
642
  "keywords_label": "🔍 Түйін сөздер",
643
- "words_label": "📏 Мақсатты көлем (12 000–25 000 сөз)",
644
  "success": "🎉 Жұмыс үдерісі аяқталды. Екі тілдегі құжаттар дайын.",
645
  "download": "📥 Файлдарды жүктеу",
646
  "system_busy_title": "⏳ Жүйе бос емес",
@@ -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": "Файлдарды осы жерге сүйреп әкеліңіз",
 
208
  "title": "📊 工商管理论文智能体",
209
  "topic_label": "📝 论文名称",
210
  "keywords_label": "🔍 关键字",
211
+ "words_label": "📏 目标总单词数(5000–20000单词)",
212
  "success": "🎉 流水线推演完毕!双语文档已就绪。",
213
  "download": "📥 下载文件",
214
  "system_busy_title": "⏳ 系统忙碌",
 
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": "将文件拖放到此处",
 
361
  "title": "📊 Business Administration Article Agent",
362
  "topic_label": "📝 Article Title",
363
  "keywords_label": "🔍 Keywords",
364
+ "words_label": "📏 Target Word Count (5,000–20,000 words)",
365
  "success": "🎉 The pipeline is complete. The bilingual documents are ready.",
366
  "download": "📥 Download Files",
367
  "system_busy_title": "⏳ System Busy",
 
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",
 
514
  "title": "📊 Интеллектуальный агент для научных статей по управлению бизнесом",
515
  "topic_label": "📝 Название статьи",
516
  "keywords_label": "🔍 Ключевые слова",
517
+ "words_label": "📏 Целевой объём (5 000–20 000 слов)",
518
  "success": "🎉 Работа конвейера завершена. Двуязычные документы готовы.",
519
  "download": "📥 Скачать файлы",
520
  "system_busy_title": "⏳ Система занята",
 
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": "Перетащите файлы сюда",
 
667
  "title": "📊 Бизнес-басқару жөніндегі ғылыми мақала интеллектуалды агенті",
668
  "topic_label": "📝 Мақала атауы",
669
  "keywords_label": "🔍 Түйін сөздер",
670
+ "words_label": "📏 Мақсатты көлем (5 000–20 000 сөз)",
671
  "success": "🎉 Жұмыс үдерісі аяқталды. Екі тілдегі құжаттар дайын.",
672
  "download": "📥 Файлдарды жүктеу",
673
  "system_busy_title": "⏳ Жүйе бос емес",
 
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,45 +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:
54
  value = os.environ.get(name, "")
55
  if not value:
@@ -61,15 +40,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 +64,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 +80,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 +103,46 @@ 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 +163,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 +170,175 @@ 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 +350,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 +580,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
  def _runtime_value(name: str) -> str:
33
  value = os.environ.get(name, "")
34
  if not value:
 
40
 
41
 
42
  def _api_base() -> str:
 
 
 
43
  value = str(st.session_state.get("sp_auth_api_origin") or "").strip().rstrip("/")
44
  if not value:
45
  value = _runtime_value("SMARTPAPER_AUTH_API").rstrip("/")
46
+ parsed = urlsplit(value)
47
+ if (
48
+ parsed.scheme != "https"
49
+ or not parsed.netloc
50
+ or parsed.username is not None
51
+ or parsed.password is not None
52
+ or parsed.path not in ("", "/")
53
+ or parsed.query
54
+ or parsed.fragment
55
+ ):
56
  raise RuntimeError("SMARTPAPER_AUTH_API must be an HTTPS origin.")
57
+ return f"https://{parsed.netloc}"
58
 
59
 
60
  def _service_key() -> str:
 
64
  return value
65
 
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  def _context_cookie(name: str) -> str:
68
  try:
69
  cookies = st.context.cookies
 
80
 
81
 
82
  def _decode_b64url(value: str) -> bytes:
83
+ return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
 
84
 
85
 
86
  def _verify_edge_assertion(token: str) -> dict[str, Any]:
 
103
  if payload.get("ver") != 1 or payload.get("aud") != EDGE_ASSERTION_AUDIENCE:
104
  raise RuntimeError("invalid_edge_assertion_claims")
105
  api_origin = str(payload.get("api_origin") or "").strip().rstrip("/")
106
+ parsed = urlsplit(api_origin)
107
  if (
108
+ parsed.scheme != "https"
109
+ or not parsed.netloc
110
+ or parsed.username is not None
111
+ or parsed.password is not None
112
+ or parsed.path not in ("", "/")
113
+ or parsed.query
114
+ or parsed.fragment
115
  ):
116
  raise RuntimeError("invalid_edge_assertion_origin")
 
117
  issued_at = int(payload.get("iat") or 0)
118
  expires_at = int(payload.get("exp") or 0)
119
  if issued_at > now + ASSERTION_CLOCK_SKEW_SECONDS or expires_at <= now - ASSERTION_CLOCK_SKEW_SECONDS:
120
  raise RuntimeError("expired_edge_assertion")
121
  if expires_at - issued_at > 9 * 60 * 60:
122
  raise RuntimeError("invalid_edge_assertion_lifetime")
 
123
  user = payload.get("user") or {}
124
+ if not isinstance(user, dict) or not user.get("id") or user.get("status") != "active":
125
  raise RuntimeError("invalid_edge_assertion_identity")
126
+ payload["api_origin"] = f"https://{parsed.netloc}"
127
  return payload
128
 
129
 
130
  def _clear_generation_session_state() -> None:
131
  for key in (
132
+ "sp_generation_task", "sp_tracked_task_id", "sp_current_node",
133
+ "sp_task_recovery_initialized", "sp_last_ui_task_status",
134
+ "sp_generation_idempotency_key", "zip_buffer", "zip_filename",
135
+ "is_running", "start_pipeline", "resume_pipeline", "run_mode",
136
+ "rag_engine", "up_amj_ref", "up_amj_data", "up_asq_ref",
137
+ "up_asq_data", "sp_payment_required", "sp_payment_order",
138
+ "sp_payment_request_id",
139
  ):
140
  st.session_state.pop(key, None)
141
 
142
 
143
  def _clear_session() -> None:
144
  for key in (
145
+ "sp_session_expires_at", "sp_user",
146
  "sp_last_verified_at", "sp_edge_assertion", "sp_auth_api_origin",
147
  ):
148
  st.session_state.pop(key, None)
 
163
  st.stop()
164
  user = dict(payload["user"])
165
  st.session_state["sp_edge_assertion"] = token
 
166
  st.session_state["sp_session_expires_at"] = payload["exp"]
167
  st.session_state["sp_user"] = user
168
  st.session_state["sp_auth_api_origin"] = str(payload["api_origin"]).rstrip("/")
 
170
  return user
171
 
172
 
173
+ def _bridge_key(prefix: str, value: str) -> str:
174
+ digest = hashlib.sha256(str(value).encode("utf-8")).hexdigest()[:20]
175
+ return f"{prefix}_{digest}"
176
+
177
+
178
+ def _edge_call(
179
+ action: str,
180
+ command_id: str,
181
+ payload: dict[str, Any] | None = None,
182
+ *,
183
+ key_prefix: str = "sp_edge",
184
+ ) -> dict[str, Any] | None:
185
+ return edge_bridge(
186
+ action=action,
187
+ command_id=command_id,
188
+ api_origin=_api_base(),
189
+ payload=payload or {},
190
+ key=_bridge_key(key_prefix, command_id),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  )
192
 
193
 
194
+ def _raise_edge_error(result: dict[str, Any], fallback: str) -> None:
195
+ message = (
196
+ result.get("message")
197
+ or result.get("reason")
198
+ or result.get("error")
199
+ or fallback
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  )
201
+ raise RuntimeError(str(message))
202
 
203
+
204
+ def _merge_quota_into_session(result: dict[str, Any]) -> None:
205
+ """Refresh the approved account card immediately after a quota mutation."""
206
+ quota = result.get("quota")
207
+ if not isinstance(quota, dict):
208
+ return
209
+ user = dict(st.session_state.get("sp_user") or {})
210
+ for key in (
211
+ "quota_date",
212
+ "daily_free_limit",
213
+ "daily_free_reserved",
214
+ "daily_free_used",
215
+ "daily_free_remaining",
216
+ "bonus_papers",
217
+ ):
218
+ if key in quota:
219
+ user[key] = quota[key]
220
+ if user:
221
+ st.session_state["sp_user"] = user
222
+ st.session_state["sp_account_revision"] = int(
223
+ st.session_state.get("sp_account_revision") or 0
224
+ ) + 1
225
+
226
+
227
+ def get_account_snapshot(*, block: bool = False) -> dict[str, Any] | None:
228
+ bucket = int(time.time() // 30)
229
+ revision = int(st.session_state.get("sp_account_revision") or 0)
230
+ command_id = f"account:{revision}:{bucket}"
231
+ result = _edge_call("account", command_id, {}, key_prefix="sp_account")
232
+ if result is None:
233
+ if block:
234
+ st.stop()
235
+ return None
236
+ if not result.get("ok"):
237
+ if block:
238
+ _raise_edge_error(result, "account_query_failed")
239
+ return None
240
+ user = result.get("user")
241
+ if isinstance(user, dict):
242
+ st.session_state["sp_user"] = dict(user)
243
+ # R3.1 single-writer rule: account refresh owns identity/quota only.
244
+ # Task UI state is initialized by recover_generation_task() and thereafter
245
+ # updated only by generation_task_status()/explicit task actions.
246
+ return result
247
+
248
+
249
+ def start_generation_task(*, idempotency_key: str, mode: str, interface_language: str, target_words: int) -> 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
+ "target_words": int(target_words),
261
+ },
262
+ key_prefix="sp_task_start",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
263
  )
264
+ if result is None:
265
+ st.stop()
266
+ if not result.get("ok"):
267
+ _raise_edge_error(result, "task_start_failed")
268
+ _merge_quota_into_session(result)
269
+ started_task = result.get("task")
270
+ if isinstance(started_task, dict) and started_task.get("task_id"):
271
+ st.session_state["sp_tracked_task_id"] = str(started_task["task_id"])
272
+ return dict(result)
273
 
274
 
275
  def recover_generation_task() -> dict[str, Any]:
276
+ """Initialize current task state once from the authoritative Worker.
277
 
278
+ Account polling is intentionally forbidden from writing task state. This
279
+ recovery call is the only fresh-session initializer; later changes come
280
+ from generation_task_status() or explicit start/cancel operations.
281
+ """
282
+ command_id = f"recover:{int(time.time() // 10)}"
283
+ result = _edge_call("task_recover", command_id, {}, key_prefix="sp_task_recover")
284
+ if result is None:
285
+ st.stop()
286
+ if not result.get("ok"):
287
+ _raise_edge_error(result, "task_recover_failed")
288
+ _merge_quota_into_session(result)
289
+ task = result.get("task")
290
+ if isinstance(task, dict) and task.get("task_id"):
291
+ st.session_state["sp_generation_task"] = dict(task)
292
+ st.session_state["sp_tracked_task_id"] = str(task["task_id"])
293
+ st.session_state["sp_last_ui_task_status"] = str(task.get("status") or "")
294
+ else:
295
+ st.session_state["sp_generation_task"] = None
296
+ st.session_state["sp_last_ui_task_status"] = ""
297
+ st.session_state.pop("sp_tracked_task_id", None)
298
+ return dict(result)
299
 
300
 
301
  def generation_task_status(task_id: str) -> dict[str, Any]:
302
+ normalized = str(task_id or "").strip()
303
+ command_id = f"status:{normalized}:{int(time.time() // 5)}"
304
+ result = _edge_call(
305
+ "task_status", command_id, {"task_id": normalized}, key_prefix="sp_task_status"
306
+ )
307
+ if result is None:
308
+ st.stop()
309
+ if not result.get("ok"):
310
+ _raise_edge_error(result, "task_status_failed")
311
+ _merge_quota_into_session(result)
312
+ task = result.get("task")
313
+ if isinstance(task, dict):
314
+ st.session_state["sp_generation_task"] = dict(task)
315
+ if task.get("task_id"):
316
+ st.session_state["sp_tracked_task_id"] = str(task["task_id"])
317
+ return dict(result)
318
 
319
 
320
  def cancel_generation_task(task_id: str | None = None) -> dict[str, Any]:
321
+ """Cancel synchronously so quota release is confirmed before local cleanup."""
322
+ normalized = str(task_id or "").strip()
323
+ command_id = f"cancel:{normalized or 'latest'}"
324
+ result = _edge_call(
325
+ "task_cancel",
326
+ command_id,
327
+ {"task_id": normalized} if normalized else {},
328
+ key_prefix="sp_task_cancel",
329
+ )
330
+ if result is None:
331
+ st.stop()
332
+ if not result.get("ok"):
333
+ _raise_edge_error(result, "task_cancel_failed")
334
+ reservation = result.get("reservation")
335
+ if result.get("cancelled") and reservation and str(reservation.get("status") or "").lower() != "released":
336
+ raise RuntimeError("task_cancel_quota_not_released")
337
+ _merge_quota_into_session(result)
338
+ return dict(result)
339
 
340
 
341
  def logout_alipay_session(on_logout: Callable[[str, str], None] | None = None) -> None:
 
 
 
 
 
 
342
  user_id = str((st.session_state.get("sp_user") or {}).get("id") or "")
343
  task = st.session_state.get("sp_generation_task") or {}
344
  task_id = str(task.get("task_id") or "")
 
350
  _clear_session()
351
  _clear_generation_session_state()
352
 
353
+
354
+ def create_payment_order(idempotency_key: str) -> dict[str, Any]:
355
+ normalized = str(idempotency_key or "").strip()
356
+ result = _edge_call(
357
+ "pay_create",
358
+ normalized,
359
+ {"idempotency_key": normalized},
360
+ key_prefix="sp_pay_create",
361
+ )
362
+ if result is None:
363
+ st.stop()
364
+ if not result.get("ok"):
365
+ _raise_edge_error(result, "payment_create_failed")
366
+ return dict(result)
367
+
368
+
369
+ def payment_order_status(merchant_order_no: str) -> dict[str, Any]:
370
+ order_no = str(merchant_order_no or "").strip()
371
+ command_id = f"pay-status:{order_no}:{int(time.time() // 3)}"
372
+ result = _edge_call(
373
+ "pay_status",
374
+ command_id,
375
+ {"merchant_order_no": order_no},
376
+ key_prefix="sp_pay_status",
377
+ )
378
+ if result is None:
379
+ st.stop()
380
+ if not result.get("ok"):
381
+ _raise_edge_error(result, "payment_status_failed")
382
+ return dict(result)
383
+
384
+
385
+ def _qr_png_bytes(value: str) -> bytes:
386
+ image = qrcode.make(str(value))
387
+ buffer = io.BytesIO()
388
+ image.save(buffer, format="PNG")
389
+ return buffer.getvalue()
390
+
391
+
392
+ def _render_payment_purchase_body(language: str) -> None:
393
+ st.warning(tr(language, "payment_notice"))
394
+
395
+ if st.button(tr(language, "payment_create"), key="sp_create_payment_order"):
396
+ st.session_state["sp_payment_request_id"] = (
397
+ f"pay_{int(time.time() * 1000)}_{secrets.token_urlsafe(12)}"
398
+ )
399
+
400
+ request_id = str(st.session_state.get("sp_payment_request_id") or "").strip()
401
+ order = st.session_state.get("sp_payment_order")
402
+ if request_id and not order:
403
+ st.info(tr(language, "payment_creating"))
404
+ order_result = create_payment_order(request_id)
405
+ order = dict(order_result.get("order") or {})
406
+ if not order.get("merchant_order_no") or not order.get("qr_code"):
407
+ raise RuntimeError("payment_order_missing_qr_code")
408
+ st.session_state["sp_payment_order"] = order
409
+
410
+ order = st.session_state.get("sp_payment_order") or {}
411
+ qr_code = str(order.get("qr_code") or "").strip()
412
+ if qr_code:
413
+ st.image(_qr_png_bytes(qr_code), width=280)
414
+ st.caption(tr(language, "payment_scan"))
415
+ if st.button(tr(language, "payment_check"), key="sp_check_payment_status"):
416
+ status_result = payment_order_status(order.get("merchant_order_no", ""))
417
+ updated = dict(status_result.get("order") or {})
418
+ st.session_state["sp_payment_order"] = updated
419
+ if updated.get("status") == "paid" and updated.get("credit_applied"):
420
+ user = status_result.get("user")
421
+ if isinstance(user, dict):
422
+ st.session_state["sp_user"] = dict(user)
423
+ st.session_state["sp_account_revision"] = int(
424
+ st.session_state.get("sp_account_revision") or 0
425
+ ) + 1
426
+ st.session_state["sp_payment_required"] = False
427
+ st.session_state["sp_payment_order"] = None
428
+ st.session_state["sp_payment_request_id"] = ""
429
+ st.success(tr(language, "payment_paid"))
430
+ elif updated.get("status") in {"closed", "failed"}:
431
+ st.session_state["sp_payment_order"] = None
432
+ st.session_state["sp_payment_request_id"] = ""
433
+ st.warning(tr(language, "payment_expired"))
434
+
435
+
436
+ def render_payment_purchase(language: str) -> None:
437
+ """Show payment in a modal so the approved normal-page layout is unchanged."""
438
+ dialog_factory = getattr(st, "dialog", None)
439
+ if callable(dialog_factory):
440
+ @dialog_factory(tr(language, "payment_dialog_title"))
441
+ def _payment_dialog():
442
+ _render_payment_purchase_body(language)
443
+ _payment_dialog()
444
+ return
445
+ # Compatibility fallback for older Streamlit builds.
446
+ _render_payment_purchase_body(language)
447
+
448
+
449
  def render_logged_in_account(
450
  language: str,
451
  user: dict[str, Any] | None = None,
452
  on_logout: Callable[[str, str], None] | None = None,
453
  ) -> None:
454
+ """Render the approved responsive account summary with live quota values."""
455
+ live = get_account_snapshot(block=False)
456
+ if live and isinstance(live.get("user"), dict):
457
+ user = dict(live["user"])
458
+ else:
459
+ user = user or st.session_state.get("sp_user", {})
460
  user_id = user.get("id", "—")
461
+ free_remaining = user.get("daily_free_remaining", user.get("daily_free_limit", 3))
462
  bonus_papers = user.get("bonus_papers", 0)
463
 
464
  st.markdown(
 
580
  )
581
 
582
  info_col, logout_col = st.columns([5.9, 1.65])
 
583
  with info_col:
584
  st.markdown(
585
  f"""
586
  <div class="sp-account-card">
587
+ <span>{html.escape(tr(language, 'account_chip_user', value=user_id))}</span>
588
  <span class="sp-account-separator">•</span>
589
+ <span>{html.escape(tr(language, 'account_chip_free', value=free_remaining))}</span>
590
  <span class="sp-account-separator">•</span>
591
+ <span>{html.escape(tr(language, 'account_chip_bonus', value=bonus_papers))}</span>
592
  </div>
593
  """,
594
  unsafe_allow_html=True,
595
  )
 
596
  with logout_col:
597
  logout_label = html.escape(tr(language, "logout_short"))
598
  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,42 @@ 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
+ height=0,
45
+ default=None,
46
+ key=key,
47
+ )
48
+ return value if isinstance(value, dict) else None
49
+
50
+
51
+ def edge_bridge(
52
+ *,
53
+ action: str,
54
+ command_id: str,
55
+ api_origin: str,
56
+ payload: dict[str, Any] | None = None,
57
+ key: str = "sp_edge_bridge",
58
+ ) -> dict[str, Any] | None:
59
+ """Execute one authenticated same-origin Worker command in the browser.
60
+
61
+ R3-Free keeps the browser bridge synchronous and limited to user-facing
62
+ operations. Execution liveness and terminal success/failure are owned by
63
+ the independent Python executor, never by this component.
64
+ """
65
+ value = _COMPONENT(
66
+ action=str(action or ""),
67
+ command_id=str(command_id or ""),
68
+ token="",
69
+ expires_at="",
70
+ api_origin=str(api_origin or ""),
71
+ payload=dict(payload or {}),
72
  height=0,
73
  default=None,
74
  key=key,
smartpaper_restore_bridge/desktop.ini ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [.ShellClassInfo]
2
+ IconResource=C:\Program Files\Google\Drive File Stream\129.0.1.0\GoogleDriveFS.exe,27
smartpaper_restore_bridge/frontend/desktop.ini ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [.ShellClassInfo]
2
+ IconResource=C:\Program Files\Google\Drive File Stream\129.0.1.0\GoogleDriveFS.exe,27
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,56 @@
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 +70,147 @@
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 RESULT_PREFIX = "smartpaper.edge.result.v3:";
19
  const MAX_TOKEN_LENGTH = 4096;
20
+ const MAX_RESULT_AGE_MS = 10 * 60 * 1000;
21
+ const REQUEST_TIMEOUT_MS = 20000;
22
  let lastResponseSignature = "";
23
 
24
+ const ACTION_PATHS = Object.freeze({
25
+ task_start: "/edge/task/start",
26
+ task_cancel: "/edge/task/cancel",
27
+ task_status: "/edge/task/status",
28
+ task_recover: "/edge/task/recover",
29
+ account: "/edge/account",
30
+ pay_create: "/edge/pay/create",
31
+ pay_status: "/edge/pay/status"
32
+ });
33
+
34
  function sendMessage(type, data) {
35
+ window.parent.postMessage(Object.assign({ isStreamlitMessage:true, type }, data || {}), "*");
 
 
 
 
36
  }
37
 
38
+ function setFrameHeight() { sendMessage("streamlit:setFrameHeight", { height:0 }); }
 
 
39
 
40
  function sendValue(value) {
41
  const signature = JSON.stringify(value);
42
+ if (signature === lastResponseSignature) return;
 
 
43
  lastResponseSignature = signature;
44
+ sendMessage("streamlit:setComponentValue", { value });
45
+ }
46
+
47
+ function readJson(key, fallback=null) {
48
+ try {
49
+ const raw = window.localStorage.getItem(key);
50
+ return raw ? JSON.parse(raw) : fallback;
51
+ } catch (_) { return fallback; }
52
+ }
53
+
54
+ function writeJson(key, value) {
55
+ try { window.localStorage.setItem(key, JSON.stringify(value)); return true; }
56
+ catch (_) { return false; }
57
  }
58
 
59
  function normalizedToken(value) {
60
  const token = typeof value === "string" ? value.trim() : "";
61
+ return token && token.length <= MAX_TOKEN_LENGTH ? token : "";
 
 
 
62
  }
63
 
64
  function parseExpiry(value) {
65
+ if (typeof value !== "string" || !value.trim()) return null;
 
 
66
  const timestamp = Date.parse(value);
67
  return Number.isFinite(timestamp) ? timestamp : null;
68
  }
 
70
  function readLegacyCookie() {
71
  try {
72
  const prefix = `${LEGACY_COOKIE_NAME}=`;
73
+ const entry = (document.cookie ? document.cookie.split("; ") : []).find(item => item.startsWith(prefix));
74
+ return entry ? normalizedToken(decodeURIComponent(entry.slice(prefix.length))) : "";
75
+ } catch (_) { return ""; }
 
 
 
 
 
 
76
  }
77
 
78
  function deleteLegacyCookie() {
79
+ try { document.cookie = `${LEGACY_COOKIE_NAME}=; Max-Age=0; Path=/; Secure; SameSite=Strict`; }
80
+ catch (_) {}
 
 
 
81
  }
82
 
83
  function readStoredRecord() {
84
+ const record = readJson(RESTORE_KEY, null);
 
 
 
 
 
 
 
 
 
 
 
 
85
  const token = normalizedToken(record && record.token);
86
+ if (!token) { window.localStorage.removeItem(RESTORE_KEY); return null; }
87
+ const expiresAt = record && typeof record.expires_at === "string" ? record.expires_at : "";
88
+ const expiry = parseExpiry(expiresAt);
89
+ if ((expiresAt.trim() && expiry === null) || (expiry !== null && expiry <= Date.now())) {
90
+ window.localStorage.removeItem(RESTORE_KEY); return null;
 
 
 
 
 
 
 
 
 
 
 
 
91
  }
92
+ return { token, expires_at:expiresAt };
 
93
  }
94
 
95
  function writeStoredRecord(token, expiresAt) {
96
  const normalized = normalizedToken(token);
97
+ if (!normalized) throw new Error("invalid_token");
98
+ const record = { token:normalized, expires_at:typeof expiresAt === "string" ? expiresAt : "", stored_at:new Date().toISOString() };
99
+ if (!writeJson(RESTORE_KEY, record)) throw new Error("storage_unavailable");
 
 
 
 
 
 
 
 
100
  const verified = readStoredRecord();
101
+ if (!verified || verified.token !== normalized) throw new Error("write_verification_failed");
 
 
102
  deleteLegacyCookie();
103
  return verified;
104
  }
105
 
106
+ function handleRestore(action, commandId, args) {
107
+ if (action === "write") {
108
+ const record = writeStoredRecord(args.token || "", args.expires_at || "");
109
+ return { action, command_id:commandId, status:"written", expires_at:record.expires_at || "" };
110
+ }
111
+ if (action === "delete") {
112
+ window.localStorage.removeItem(RESTORE_KEY); deleteLegacyCookie();
113
+ const deleted = window.localStorage.getItem(RESTORE_KEY) === null;
114
+ return { action, command_id:commandId, status:deleted ? "deleted" : "error", error:deleted ? "" : "delete_verification_failed" };
115
+ }
116
+ let record = readStoredRecord(); let source = "local_storage";
117
  if (!record) {
118
+ const legacy = readLegacyCookie();
119
+ if (legacy) { record = writeStoredRecord(legacy, ""); source = "legacy_cookie"; }
 
 
 
120
  }
121
+ return record
122
+ ? { action:"read", command_id:commandId, status:"found", token:record.token, expires_at:record.expires_at || "", source }
123
+ : { action:"read", command_id:commandId, status:"missing" };
124
+ }
125
 
126
+ function normalizeOrigin(value) {
127
+ try {
128
+ const parsed = new URL(String(value || ""));
129
+ if (parsed.protocol !== "https:") return "";
130
+ const parentOrigin = window.parent.location.origin;
131
+ return parsed.origin === parentOrigin ? parsed.origin : "";
132
+ } catch (_) { return ""; }
133
+ }
134
+
135
+ async function postJson(apiOrigin, path, payload) {
136
+ const origin = normalizeOrigin(apiOrigin);
137
+ if (!origin) return { ok:false, error:"invalid_api_origin", http_status:0 };
138
+ const controller = new AbortController();
139
+ const timer = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
140
+ try {
141
+ const response = await fetch(new URL(path, origin).toString(), {
142
+ method:"POST",
143
+ credentials:"include",
144
+ cache:"no-store",
145
+ redirect:"error",
146
+ headers:{ "Accept":"application/json", "Content-Type":"application/json", "X-SmartPaper-Client":"browser-bridge-v1" },
147
+ body:JSON.stringify(payload || {}),
148
+ signal:controller.signal
149
+ });
150
+ const text = await response.text();
151
+ let data = {};
152
+ try { data = text ? JSON.parse(text) : {}; }
153
+ catch (_) { data = { ok:false, error:"non_json_response", body_prefix:text.slice(0,200) }; }
154
+ return Object.assign({}, data, { ok:response.ok && data.ok !== false, http_status:response.status });
155
+ } catch (error) {
156
  return {
157
+ ok:false,
158
+ error:error && error.name === "AbortError" ? "browser_edge_timeout" : "browser_edge_request_failed",
159
+ detail:String(error || "").slice(0,300),
160
+ http_status:0
161
  };
162
+ } finally { window.clearTimeout(timer); }
163
+ }
164
 
165
+ function cachedResult(commandId) {
166
+ const cached = readJson(RESULT_PREFIX + commandId, null);
167
+ if (!cached || Date.now() - Number(cached.saved_at || 0) > MAX_RESULT_AGE_MS) return null;
168
+ return cached.value || null;
 
 
 
 
169
  }
170
 
171
+ function saveResult(commandId, value) {
172
+ writeJson(RESULT_PREFIX + commandId, { saved_at:Date.now(), value });
 
 
 
 
 
 
173
  }
174
 
175
+ async function handleEdgeAction(action, commandId, args) {
176
+ const path = ACTION_PATHS[action];
177
+ if (!path) return { ok:false, error:"unsupported_edge_action", action, command_id:commandId };
178
+ const apiOrigin = normalizeOrigin(args.api_origin || "");
179
+ if (!apiOrigin) return { ok:false, error:"invalid_api_origin", action, command_id:commandId };
180
+ const payload = args.payload && typeof args.payload === "object" ? args.payload : {};
181
+
182
+ const cached = cachedResult(commandId);
183
+ if (cached) return cached;
184
+ const result = Object.assign(await postJson(apiOrigin, path, payload), { action, command_id:commandId });
185
+ // Cache only confirmed successes. Transient network and HTTP failures must
186
+ // be retryable on the next Streamlit rerun with the same idempotency key.
187
+ if (result.ok) saveResult(commandId, result);
188
+
189
+ return result;
190
+ }
 
 
 
 
191
 
192
+ async function onRender(event) {
193
+ if (!event || event.source !== window.parent) return;
194
+ const message = event.data || {};
195
+ if (message.type !== "streamlit:render") return;
196
  const args = message.args || {};
197
  const action = typeof args.action === "string" ? args.action : "read";
198
+ const commandId = typeof args.command_id === "string" ? args.command_id : "";
 
 
199
  let response;
200
  try {
201
+ response = ["read","write","delete"].includes(action)
202
+ ? handleRestore(action, commandId, args)
203
+ : await handleEdgeAction(action, commandId, args);
 
 
 
 
 
 
 
 
204
  } catch (_) {
205
+ response = { ok:false, action, command_id:commandId, status:"error", error:"browser_bridge_failure" };
 
 
 
 
 
206
  }
207
+ setFrameHeight();
 
208
  sendValue(response);
209
  }
210
 
211
  window.addEventListener("message", onRender);
212
+ sendMessage("streamlit:componentReady", { apiVersion:1 });
213
+ setFrameHeight();
214
  })();
215
  </script>
216
  </body>
task_executor.py ADDED
@@ -0,0 +1,447 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """SmartPaper R3.1 independent task executor.
3
+
4
+ Bootstrap is intentionally lightweight: the child process validates the local
5
+ request, claims the D1 task, writes a local claim handshake, and only then
6
+ imports the heavy paper-generation modules. A Streamlit rerun/browser event
7
+ therefore cannot be mistaken for successful executor startup, and import or
8
+ pipeline failures after claim can be finalized through the authoritative
9
+ Worker so quota is never left reserved by an unowned task.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import logging
16
+ import os
17
+ import re
18
+ import sys
19
+ import threading
20
+ import time
21
+ from datetime import datetime, timezone
22
+ from pathlib import Path
23
+ from urllib.parse import urlsplit
24
+
25
+ import requests
26
+
27
+ from config import APP_CONFIG
28
+
29
+ _log_handlers = [logging.StreamHandler(sys.stdout)]
30
+ _executor_log_path = str(os.environ.get("SMARTPAPER_EXECUTOR_LOG_PATH") or "").strip()
31
+ if _executor_log_path:
32
+ try:
33
+ Path(_executor_log_path).parent.mkdir(parents=True, exist_ok=True)
34
+ _log_handlers.append(logging.FileHandler(_executor_log_path, encoding="utf-8"))
35
+ except Exception:
36
+ pass
37
+ logging.basicConfig(
38
+ level=logging.INFO,
39
+ format="%(asctime)s [%(levelname)s] %(message)s",
40
+ handlers=_log_handlers,
41
+ )
42
+
43
+
44
+ CLAIM_HANDSHAKE_FILENAME = "executor_claim.json"
45
+
46
+
47
+ def _safe(value, limit=500):
48
+ text = str(value or "")
49
+ text = re.sub(r"(?i)(api[_ -]?key|authorization|bearer|token)\s*[:=]\s*[^\s,;]+", r"\1=[redacted]", text)
50
+ return text[:limit]
51
+
52
+
53
+ def _api_origin() -> str:
54
+ value = str(os.environ.get("SMARTPAPER_AUTH_API") or "").strip().rstrip("/")
55
+ parsed = urlsplit(value)
56
+ if parsed.scheme != "https" or not parsed.netloc or parsed.path not in ("", "/") or parsed.query or parsed.fragment:
57
+ raise RuntimeError("SMARTPAPER_AUTH_API_invalid")
58
+ return f"https://{parsed.netloc}"
59
+
60
+
61
+ def _service_key() -> str:
62
+ value = str(os.environ.get("SMARTPAPER_SERVICE_KEY") or "").strip()
63
+ if not value:
64
+ raise RuntimeError("SMARTPAPER_SERVICE_KEY_missing")
65
+ return value
66
+
67
+
68
+ def _atomic_json(path: Path, value: dict) -> None:
69
+ path.parent.mkdir(parents=True, exist_ok=True)
70
+ temporary = path.with_suffix(path.suffix + ".tmp")
71
+ temporary.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8")
72
+ os.replace(temporary, path)
73
+
74
+
75
+ def _claim_marker_path(task_dir: Path) -> Path:
76
+ return task_dir / CLAIM_HANDSHAKE_FILENAME
77
+
78
+
79
+ class ExecutorApiError(RuntimeError):
80
+ def __init__(self, error: str, status_code: int = 0, payload: dict | None = None):
81
+ super().__init__(str(error or "executor_api_error"))
82
+ self.error = str(error or "executor_api_error")
83
+ self.status_code = int(status_code or 0)
84
+ self.payload = dict(payload or {})
85
+
86
+
87
+ def _is_recoverable_generation_failure(exc: Exception) -> bool:
88
+ """Classify only post-claim execution failures.
89
+
90
+ This deliberately avoids importing the generation pipeline at module import
91
+ time. DeepSeek exception class names are stable public names inside this
92
+ project and are inspected only after claim/bootstrap has succeeded.
93
+ """
94
+ if isinstance(exc, ExecutorApiError):
95
+ return exc.status_code == 429 or exc.status_code >= 500
96
+ class_name = exc.__class__.__name__
97
+ if class_name in {"DeepSeekAuthenticationError", "DeepSeekBalanceError", "DeepSeekRequestError"}:
98
+ return False
99
+ if isinstance(exc, (requests.exceptions.Timeout, requests.exceptions.ConnectionError)):
100
+ return True
101
+ if class_name == "DeepSeekAPIError":
102
+ return True
103
+ text = str(exc or "").lower()
104
+ transient_markers = (
105
+ "connection", "timed out", "timeout", "temporarily unavailable",
106
+ "streaming failed after", "api failed after", "stream was interrupted",
107
+ "insufficient eligible references",
108
+ )
109
+ return any(marker in text for marker in transient_markers)
110
+
111
+
112
+ class ExecutorApi:
113
+ def __init__(self):
114
+ self.origin = _api_origin()
115
+ self.headers = {
116
+ "Authorization": f"Bearer {_service_key()}",
117
+ "Accept": "application/json",
118
+ "Content-Type": "application/json",
119
+ "User-Agent": "SmartPaper-R3.1-Executor/1.0",
120
+ }
121
+
122
+ def post(self, path: str, payload: dict, *, timeout=20) -> dict:
123
+ response = requests.post(
124
+ self.origin + path,
125
+ headers=self.headers,
126
+ json=payload,
127
+ timeout=timeout,
128
+ )
129
+ try:
130
+ data = response.json()
131
+ except Exception as exc:
132
+ raise RuntimeError(f"executor_worker_non_json_http_{response.status_code}") from exc
133
+ if not response.ok or data.get("ok") is False:
134
+ error = str(data.get("error") or data.get("reason") or f"HTTP_{response.status_code}")
135
+ raise ExecutorApiError(error, response.status_code, data)
136
+ return data
137
+
138
+ def claim(self, *, task_id: str, user_id: int, request_hash: str) -> dict:
139
+ return self.post(
140
+ "/executor/task/claim",
141
+ {"task_id": task_id, "user_id": user_id, "request_hash": request_hash},
142
+ )
143
+
144
+ def heartbeat(self, payload: dict) -> dict:
145
+ return self.post("/executor/task/heartbeat", payload)
146
+
147
+ def finish(self, payload: dict) -> dict:
148
+ return self.post("/executor/task/finish", payload, timeout=30)
149
+
150
+
151
+ class ExecutionState:
152
+ def __init__(self, *, task_id: str, user_id: int, epoch: int, instance_id: str, api: ExecutorApi):
153
+ self.task_id = task_id
154
+ self.user_id = int(user_id)
155
+ self.epoch = int(epoch)
156
+ self.instance_id = str(instance_id)
157
+ self.api = api
158
+ self._lock = threading.Lock()
159
+ self._last_activity = time.monotonic()
160
+ self._progress_pct = 0
161
+ self._current_node = "executor_claimed"
162
+ self._checkpoint_seq = 0
163
+ self._checkpoint_stage = ""
164
+ self._ds_calls = 0
165
+ self._s2_calls = 0
166
+ self._oa_calls = 0
167
+ self._cancel = False
168
+ self._stop = threading.Event()
169
+ self._thread = None
170
+
171
+ def touch(self, *, progress_pct=None, current_node=None, checkpoint_seq=None, checkpoint_stage=None, ds_calls=None, s2_calls=None, oa_calls=None, activity_only=False):
172
+ with self._lock:
173
+ self._last_activity = time.monotonic()
174
+ if not activity_only:
175
+ if progress_pct is not None:
176
+ self._progress_pct = max(self._progress_pct, max(0, min(100, int(progress_pct))))
177
+ if current_node:
178
+ self._current_node = str(current_node)[:240]
179
+ if checkpoint_seq is not None and int(checkpoint_seq) >= self._checkpoint_seq:
180
+ self._checkpoint_seq = int(checkpoint_seq)
181
+ self._checkpoint_stage = str(checkpoint_stage or self._checkpoint_stage)[:120]
182
+ if ds_calls is not None:
183
+ self._ds_calls = max(self._ds_calls, int(ds_calls))
184
+ if s2_calls is not None:
185
+ self._s2_calls = max(self._s2_calls, int(s2_calls))
186
+ if oa_calls is not None:
187
+ self._oa_calls = max(self._oa_calls, int(oa_calls))
188
+
189
+ def cancelled(self) -> bool:
190
+ with self._lock:
191
+ return bool(self._cancel)
192
+
193
+ def start_heartbeat(self):
194
+ self._thread = threading.Thread(target=self._heartbeat_loop, name="smartpaper-executor-heartbeat", daemon=True)
195
+ self._thread.start()
196
+
197
+ def stop_heartbeat(self):
198
+ self._stop.set()
199
+ if self._thread and self._thread.is_alive():
200
+ self._thread.join(timeout=2)
201
+
202
+ def _payload(self):
203
+ with self._lock:
204
+ return {
205
+ "task_id": self.task_id,
206
+ "user_id": self.user_id,
207
+ "execution_epoch": self.epoch,
208
+ "executor_instance_id": self.instance_id,
209
+ "current_node": self._current_node,
210
+ "progress_pct": self._progress_pct,
211
+ "checkpoint_seq": self._checkpoint_seq,
212
+ "checkpoint_stage": self._checkpoint_stage,
213
+ "ds_calls": self._ds_calls,
214
+ "s2_calls": self._s2_calls,
215
+ "oa_calls": self._oa_calls,
216
+ }, time.monotonic() - self._last_activity
217
+
218
+ def heartbeat_once(self):
219
+ payload, _ = self._payload()
220
+ result = self.api.heartbeat(payload)
221
+ if result.get("cancel_requested"):
222
+ with self._lock:
223
+ self._cancel = True
224
+ return result
225
+
226
+ def _heartbeat_loop(self):
227
+ interval = int(APP_CONFIG.get("executor_heartbeat_interval_seconds", 15))
228
+ grace = int(APP_CONFIG.get("executor_activity_grace_seconds", 45))
229
+ while not self._stop.wait(interval):
230
+ payload, idle_seconds = self._payload()
231
+ if idle_seconds > grace:
232
+ logging.warning(
233
+ "[SP_EXECUTOR] heartbeat_paused task_id=%s idle_s=%.1f node=%s",
234
+ self.task_id, idle_seconds, payload.get("current_node"),
235
+ )
236
+ continue
237
+ try:
238
+ result = self.api.heartbeat(payload)
239
+ if result.get("cancel_requested"):
240
+ with self._lock:
241
+ self._cancel = True
242
+ return
243
+ except ExecutorApiError as exc:
244
+ if exc.error in {
245
+ "executor_superseded", "generation_lock_lost",
246
+ "task_already_finalized", "task_cancel_requested",
247
+ } or exc.status_code in {404, 409, 410}:
248
+ with self._lock:
249
+ self._cancel = True
250
+ logging.warning(
251
+ "[SP_EXECUTOR] executor_no_longer_authoritative task_id=%s error=%s",
252
+ self.task_id, _safe(exc.error, 180),
253
+ )
254
+ return
255
+ logging.warning("[SP_EXECUTOR] heartbeat_error task_id=%s error=%s", self.task_id, _safe(exc, 180))
256
+ except Exception as exc:
257
+ logging.warning("[SP_EXECUTOR] heartbeat_error task_id=%s error=%s", self.task_id, _safe(exc, 180))
258
+
259
+
260
+ def _load_request(task_dir: Path) -> dict:
261
+ path = task_dir / "request.json"
262
+ if not path.is_file():
263
+ raise RuntimeError("request_manifest_missing")
264
+ value = json.loads(path.read_text(encoding="utf-8"))
265
+ if not isinstance(value, dict):
266
+ raise RuntimeError("request_manifest_invalid")
267
+ return value
268
+
269
+
270
+ def _bootstrap_claim(*, task_dir: Path, task_id: str, user_id: int, request_data: dict, api: ExecutorApi) -> tuple[dict, int, str]:
271
+ """Claim first and publish a PID-bound local handshake before heavy imports."""
272
+ request_hash = str(request_data.get("request_hash") or "").strip()
273
+ if not request_hash:
274
+ raise RuntimeError("request_hash_missing")
275
+ claim = api.claim(task_id=task_id, user_id=user_id, request_hash=request_hash)
276
+ task = dict(claim.get("task") or {})
277
+ execution = dict(claim.get("execution") or {})
278
+ epoch = int(execution.get("execution_epoch") or task.get("execution_epoch") or 0)
279
+ instance_id = str(execution.get("executor_instance_id") or "")
280
+ if epoch <= 0 or not instance_id:
281
+ raise RuntimeError("executor_claim_identity_missing")
282
+ if int(task.get("target_words") or 0) != int(request_data.get("target_words") or 0):
283
+ raise RuntimeError("target_words_claim_mismatch")
284
+ try:
285
+ _atomic_json(
286
+ _claim_marker_path(task_dir),
287
+ {
288
+ "task_id": task_id,
289
+ "user_id": int(user_id),
290
+ "request_hash": request_hash,
291
+ "pid": os.getpid(),
292
+ "execution_epoch": epoch,
293
+ "executor_instance_id": instance_id,
294
+ "claimed_at": datetime.now(timezone.utc).isoformat(),
295
+ },
296
+ )
297
+ except Exception as exc:
298
+ try:
299
+ api.finish(
300
+ {
301
+ "task_id": task_id,
302
+ "user_id": int(user_id),
303
+ "execution_epoch": epoch,
304
+ "executor_instance_id": instance_id,
305
+ "status": "failed",
306
+ "safe_error_code": "E_EXECUTOR_HANDSHAKE_FAILED",
307
+ "safe_error_detail": _safe(exc, 800),
308
+ "current_node": "executor_handshake_failed",
309
+ }
310
+ )
311
+ finally:
312
+ raise
313
+ return task, epoch, instance_id
314
+
315
+
316
+ def _finish_bootstrap_failure(api: ExecutorApi, *, task_id: str, user_id: int, epoch: int, instance_id: str, exc: Exception) -> None:
317
+ try:
318
+ api.finish(
319
+ {
320
+ "task_id": task_id,
321
+ "user_id": user_id,
322
+ "execution_epoch": epoch,
323
+ "executor_instance_id": instance_id,
324
+ "status": "failed",
325
+ "safe_error_code": "E_EXECUTOR_BOOTSTRAP_FAILED",
326
+ "safe_error_detail": _safe(exc, 800),
327
+ "current_node": "executor_bootstrap_failed",
328
+ }
329
+ )
330
+ except Exception as finish_exc:
331
+ logging.warning("[SP_EXECUTOR] bootstrap_finalize_rejected task_id=%s error=%s", task_id, _safe(finish_exc, 220))
332
+
333
+
334
+ def main() -> int:
335
+ parser = argparse.ArgumentParser()
336
+ parser.add_argument("--task-dir", required=True)
337
+ parser.add_argument("--task-id", required=True)
338
+ parser.add_argument("--user-id", required=True, type=int)
339
+ args = parser.parse_args()
340
+
341
+ task_dir = Path(args.task_dir).resolve()
342
+ request_data = _load_request(task_dir)
343
+ if str(request_data.get("task_id") or "") != args.task_id:
344
+ raise RuntimeError("request_task_id_mismatch")
345
+ if int(request_data.get("user_id") or 0) != int(args.user_id):
346
+ raise RuntimeError("request_user_id_mismatch")
347
+
348
+ api = ExecutorApi()
349
+ task, epoch, instance_id = _bootstrap_claim(
350
+ task_dir=task_dir,
351
+ task_id=args.task_id,
352
+ user_id=args.user_id,
353
+ request_data=request_data,
354
+ api=api,
355
+ )
356
+ state = ExecutionState(task_id=args.task_id, user_id=args.user_id, epoch=epoch, instance_id=instance_id, api=api)
357
+ state.start_heartbeat()
358
+ logging.info("[SP_EXECUTOR] claimed task_id=%s epoch=%s instance=%s", args.task_id, epoch, instance_id[:12])
359
+
360
+ remote_checkpoint_seq = int(task.get("checkpoint_seq") or 0)
361
+ local_checkpoints = list((task_dir / "checkpoints").glob("checkpoint_*.json")) if (task_dir / "checkpoints").is_dir() else []
362
+ if remote_checkpoint_seq > 0 and not local_checkpoints:
363
+ try:
364
+ api.finish(
365
+ {
366
+ "task_id": args.task_id,
367
+ "user_id": args.user_id,
368
+ "execution_epoch": epoch,
369
+ "executor_instance_id": instance_id,
370
+ "status": "failed",
371
+ "safe_error_code": "E_CHECKPOINT_MISSING",
372
+ "safe_error_detail": "Checkpoint metadata exists but local checkpoint files are unavailable after runtime replacement; quota released.",
373
+ "current_node": "checkpoint_missing",
374
+ }
375
+ )
376
+ finally:
377
+ state.stop_heartbeat()
378
+ return 2
379
+
380
+ try:
381
+ # Heavy paper modules are deliberately imported only after D1 claim and
382
+ # the local claim handshake. Any import/init failure is now observable
383
+ # and can atomically release the reserved quota.
384
+ from core.generation_runner import GenerationRunner
385
+
386
+ runner = GenerationRunner(
387
+ task_dir=task_dir,
388
+ request_data=request_data,
389
+ execution_epoch=epoch,
390
+ progress_callback=state.touch,
391
+ cancel_checker=state.cancelled,
392
+ )
393
+ result = runner.run()
394
+ state.touch(progress_pct=100, current_node="package_validated")
395
+ try:
396
+ state.heartbeat_once()
397
+ except Exception as exc:
398
+ logging.warning("[SP_EXECUTOR] final_heartbeat_error task_id=%s error=%s", args.task_id, _safe(exc, 180))
399
+ if state.cancelled():
400
+ raise RuntimeError("task_cancel_requested")
401
+ api.finish(
402
+ {
403
+ "task_id": args.task_id,
404
+ "user_id": args.user_id,
405
+ "execution_epoch": epoch,
406
+ "executor_instance_id": instance_id,
407
+ "status": "completed",
408
+ "current_node": "package_validated",
409
+ "output_filename": str(result.get("output_filename") or "")[:240],
410
+ }
411
+ )
412
+ logging.info("[SP_EXECUTOR] completed task_id=%s epoch=%s", args.task_id, epoch)
413
+ return 0
414
+ except Exception as exc:
415
+ if state.cancelled():
416
+ logging.warning("[SP_EXECUTOR] stopped_non_authoritative task_id=%s epoch=%s", args.task_id, epoch)
417
+ return 3
418
+ if isinstance(exc, (ImportError, ModuleNotFoundError)):
419
+ logging.exception("[SP_EXECUTOR] bootstrap_import_failed task_id=%s epoch=%s error=%s", args.task_id, epoch, _safe(exc, 240))
420
+ _finish_bootstrap_failure(api, task_id=args.task_id, user_id=args.user_id, epoch=epoch, instance_id=instance_id, exc=exc)
421
+ return 4
422
+ if _is_recoverable_generation_failure(exc):
423
+ logging.exception("[SP_EXECUTOR] recoverable_interruption task_id=%s epoch=%s error=%s", args.task_id, epoch, _safe(exc, 240))
424
+ return 75
425
+ logging.exception("[SP_EXECUTOR] failed task_id=%s epoch=%s error=%s", args.task_id, epoch, _safe(exc, 240))
426
+ try:
427
+ api.finish(
428
+ {
429
+ "task_id": args.task_id,
430
+ "user_id": args.user_id,
431
+ "execution_epoch": epoch,
432
+ "executor_instance_id": instance_id,
433
+ "status": "failed",
434
+ "safe_error_code": "E_GENERATION_FAILED",
435
+ "safe_error_detail": _safe(exc, 800),
436
+ "current_node": "generation_failed",
437
+ }
438
+ )
439
+ except Exception as finish_exc:
440
+ logging.warning("[SP_EXECUTOR] failure_finalize_rejected task_id=%s error=%s", args.task_id, _safe(finish_exc, 220))
441
+ return 1
442
+ finally:
443
+ state.stop_heartbeat()
444
+
445
+
446
+ if __name__ == "__main__":
447
+ raise SystemExit(main())
utils/desktop.ini ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [.ShellClassInfo]
2
+ IconResource=C:\Program Files\Google\Drive File Stream\129.0.1.0\GoogleDriveFS.exe,27
utils/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
utils/file_parser.py CHANGED
@@ -1,5 +1,13 @@
1
  # -*- coding: utf-8 -*-
2
- import streamlit as st
 
 
 
 
 
 
 
 
3
  from docx import Document
4
  from config import APP_CONFIG
5
 
@@ -10,65 +18,114 @@ except ImportError:
10
  PANDAS_AVAILABLE = False
11
 
12
  try:
13
- import fitz # PyMuPDF 的导入名称是 fitz
14
  PYMUPDF_AVAILABLE = True
15
  except ImportError:
16
  PYMUPDF_AVAILABLE = False
17
 
18
- def extract_text_from_files(uploaded_files, max_chars=None):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  if max_chars is None:
20
  max_chars = APP_CONFIG.get("max_extract_chars", 150000)
21
-
22
  extracted_text = ""
23
- for file in uploaded_files:
 
 
24
  try:
25
- file.seek(0)
26
- if file.name.lower().endswith('.pdf') and PYMUPDF_AVAILABLE:
27
- # 从内存中读取文件字节流
28
- pdf_bytes = file.read()
29
- # 使用上下文管理器确保内存被及时释放
30
- with fitz.open(stream=pdf_bytes, filetype="pdf") as pdf_doc:
31
  for page in pdf_doc:
32
  text = page.get_text()
33
- if text:
34
  extracted_text += text + "\n"
35
- if len(extracted_text) > max_chars:
36
  break
37
- elif file.name.lower().endswith(('.docx', '.doc')):
38
- doc = Document(file)
 
39
  for para in doc.paragraphs:
40
  extracted_text += para.text + "\n"
41
- if len(extracted_text) > max_chars:
42
  break
43
  else:
44
- content = file.getvalue().decode('utf-8', errors='ignore')
45
- extracted_text += content + "\n"
46
-
47
  if len(extracted_text) > max_chars:
48
- st.warning(f"⚠️ 文件 {file.name} 已安全截断至 {max_chars} 字符。")
49
  break
50
- except Exception as e:
51
- st.warning(f"⚠️ 解析失败: {e}")
52
  finally:
53
- file.seek(0)
 
 
 
 
54
  return extracted_text[:max_chars]
55
 
 
56
  def extract_data_from_files(uploaded_file, max_chars=20000):
57
- if not uploaded_file:
58
  return ""
59
-
 
 
60
  try:
61
- uploaded_file.seek(0)
62
  if PANDAS_AVAILABLE:
63
- df = pd.read_csv(uploaded_file) if uploaded_file.name.lower().endswith('.csv') else pd.read_excel(uploaded_file)
 
 
 
 
 
 
64
  if len(df) > 100:
65
- st.warning(f"⚠️ 数据表超长,安全截取前100行。")
66
  df = df.head(100)
67
  return df.to_markdown(index=False)
68
- else:
69
- return uploaded_file.getvalue().decode('utf-8', errors='ignore')[:max_chars]
70
- except Exception as e:
71
- st.warning(f"⚠️ 解析失败: {e}")
72
  return ""
73
  finally:
74
- uploaded_file.seek(0)
 
 
 
 
 
1
  # -*- coding: utf-8 -*-
2
+ """Headless file parsing helpers used by both Streamlit and R3-Free executor."""
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import logging
7
+ import os
8
+ from pathlib import Path
9
+ from typing import BinaryIO, Iterable
10
+
11
  from docx import Document
12
  from config import APP_CONFIG
13
 
 
18
  PANDAS_AVAILABLE = False
19
 
20
  try:
21
+ import pymupdf
22
  PYMUPDF_AVAILABLE = True
23
  except ImportError:
24
  PYMUPDF_AVAILABLE = False
25
 
26
+
27
+ def _name(file) -> str:
28
+ if isinstance(file, (str, os.PathLike, Path)):
29
+ return os.fspath(file)
30
+ return str(getattr(file, "name", "uploaded_file"))
31
+
32
+
33
+ def _read_bytes(file) -> bytes:
34
+ if isinstance(file, (str, os.PathLike, Path)):
35
+ return Path(file).read_bytes()
36
+ try:
37
+ file.seek(0)
38
+ except Exception:
39
+ pass
40
+ if hasattr(file, "getvalue"):
41
+ data = file.getvalue()
42
+ else:
43
+ data = file.read()
44
+ try:
45
+ file.seek(0)
46
+ except Exception:
47
+ pass
48
+ return bytes(data or b"")
49
+
50
+
51
+ def _binary_stream(file) -> io.BytesIO | BinaryIO:
52
+ if isinstance(file, (str, os.PathLike, Path)):
53
+ return io.BytesIO(Path(file).read_bytes())
54
+ try:
55
+ file.seek(0)
56
+ except Exception:
57
+ pass
58
+ return file
59
+
60
+
61
+ def extract_text_from_files(uploaded_files: Iterable, max_chars=None):
62
  if max_chars is None:
63
  max_chars = APP_CONFIG.get("max_extract_chars", 150000)
64
+
65
  extracted_text = ""
66
+ for file in uploaded_files or []:
67
+ filename = _name(file)
68
+ lower_name = filename.lower()
69
  try:
70
+ if lower_name.endswith(".pdf") and PYMUPDF_AVAILABLE:
71
+ pdf_bytes = _read_bytes(file)
72
+ with pymupdf.open(stream=pdf_bytes, filetype="pdf") as pdf_doc:
 
 
 
73
  for page in pdf_doc:
74
  text = page.get_text()
75
+ if text:
76
  extracted_text += text + "\n"
77
+ if len(extracted_text) > max_chars:
78
  break
79
+ elif lower_name.endswith((".docx", ".doc")):
80
+ stream = _binary_stream(file)
81
+ doc = Document(stream)
82
  for para in doc.paragraphs:
83
  extracted_text += para.text + "\n"
84
+ if len(extracted_text) > max_chars:
85
  break
86
  else:
87
+ extracted_text += _read_bytes(file).decode("utf-8", errors="ignore") + "\n"
88
+
 
89
  if len(extracted_text) > max_chars:
90
+ logging.warning("File %s was truncated safely to %s characters.", filename, max_chars)
91
  break
92
+ except Exception as exc:
93
+ logging.warning("File parse failed for %s: %s", filename, exc)
94
  finally:
95
+ if not isinstance(file, (str, os.PathLike, Path)):
96
+ try:
97
+ file.seek(0)
98
+ except Exception:
99
+ pass
100
  return extracted_text[:max_chars]
101
 
102
+
103
  def extract_data_from_files(uploaded_file, max_chars=20000):
104
+ if not uploaded_file:
105
  return ""
106
+
107
+ filename = _name(uploaded_file)
108
+ lower_name = filename.lower()
109
  try:
 
110
  if PANDAS_AVAILABLE:
111
+ source = os.fspath(uploaded_file) if isinstance(uploaded_file, (str, os.PathLike, Path)) else uploaded_file
112
+ if not isinstance(uploaded_file, (str, os.PathLike, Path)):
113
+ try:
114
+ uploaded_file.seek(0)
115
+ except Exception:
116
+ pass
117
+ df = pd.read_csv(source) if lower_name.endswith(".csv") else pd.read_excel(source)
118
  if len(df) > 100:
119
+ logging.warning("Data file %s truncated safely to first 100 rows.", filename)
120
  df = df.head(100)
121
  return df.to_markdown(index=False)
122
+ return _read_bytes(uploaded_file).decode("utf-8", errors="ignore")[:max_chars]
123
+ except Exception as exc:
124
+ logging.warning("Data parse failed for %s: %s", filename, exc)
 
125
  return ""
126
  finally:
127
+ if not isinstance(uploaded_file, (str, os.PathLike, Path)):
128
+ try:
129
+ uploaded_file.seek(0)
130
+ except Exception:
131
+ pass