Spaces:
Running
Running
Deploy SmartPaper production v1.1 R1Fix
#2
by GuXiaopeng - opened
- .streamlit/desktop.ini +2 -0
- HF_COMMIT_SHA.txt +1 -0
- app.py +439 -1174
- config.py +56 -15
- core/checkpoint_store.py +71 -0
- core/desktop.ini +2 -0
- core/generation_runner.py +536 -0
- core/pipeline_agent.py +1137 -291
- core/production_guard.py +182 -0
- core/qualitative_engine.py +26 -29
- core/quantitative_engine.py +21 -0
- desktop.ini +2 -0
- i18n.py +40 -4
- managed_auth.py +295 -295
- requirements.txt +13 -16
- smartpaper_restore_bridge/__init__.py +36 -10
- smartpaper_restore_bridge/desktop.ini +2 -0
- smartpaper_restore_bridge/frontend/desktop.ini +2 -0
- smartpaper_restore_bridge/frontend/index.html +137 -165
- task_executor.py +447 -0
- utils/desktop.ini +2 -0
- utils/docx_builder.py +18 -12
- utils/file_parser.py +90 -33
.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 |
-
"""
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 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 |
-
|
| 28 |
-
import
|
| 29 |
-
import
|
| 30 |
-
import
|
| 31 |
-
|
| 32 |
-
from
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
-
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
WORKSPACE_DIR = APP_CONFIG.get("workspace_dir", ".workspace")
|
| 51 |
os.makedirs(WORKSPACE_DIR, exist_ok=True)
|
| 52 |
-
|
| 53 |
-
|
| 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 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 474 |
|
| 475 |
|
| 476 |
-
def
|
| 477 |
cleaned = re.sub(r"[^A-Za-z0-9_.-]", "_", str(value or "").strip())
|
| 478 |
-
return cleaned[:
|
| 479 |
|
| 480 |
|
| 481 |
-
def
|
| 482 |
-
"
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 489 |
return None
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 493 |
}
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
return None
|
| 498 |
-
if not isinstance(state.get("completed_sections"), list):
|
| 499 |
-
return None
|
| 500 |
-
return state
|
| 501 |
|
| 502 |
|
| 503 |
-
def
|
| 504 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 505 |
|
| 506 |
|
| 507 |
-
def
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 514 |
|
| 515 |
|
| 516 |
-
def
|
| 517 |
-
|
| 518 |
-
if is_resume and isinstance(loaded_state, dict):
|
| 519 |
-
checkpoint_key = str(loaded_state.get("managed_task_idempotency_key") or "").strip()
|
| 520 |
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 526 |
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 531 |
)
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 535 |
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
|
|
|
|
|
|
| 540 |
|
| 541 |
|
| 542 |
-
def
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
)
|
| 549 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 550 |
task_id = str(task.get("task_id") or "").strip()
|
| 551 |
if not task_id:
|
| 552 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 612 |
-
|
| 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 |
-
|
| 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 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 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
|
| 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 |
-
|
| 671 |
-
|
| 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 |
-
|
| 685 |
-
|
| 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 |
-
|
| 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=
|
| 724 |
-
max_value=
|
| 725 |
-
value=
|
| 726 |
step=1000,
|
| 727 |
-
disabled=
|
| 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 |
-
|
| 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 |
-
|
| 753 |
-
st.code(
|
| 754 |
-
st.download_button(
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 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 |
-
|
| 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 |
-
|
| 792 |
-
st.code(
|
| 793 |
-
st.download_button(
|
| 794 |
-
|
| 795 |
-
|
| 796 |
-
|
| 797 |
-
|
| 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 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
|
| 834 |
-
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
st.
|
| 838 |
-
monitor_ui.markdown(f"""
|
| 839 |
<div class='log-box'>
|
| 840 |
-
<div><b>{tr(lang_code, 'monitor_pipeline')}:</b> {
|
| 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>{
|
| 844 |
-
<span style='background: #dbeafe; padding: 4px 8px; border-radius: 4px; color: black;'>📚 S2: <b>{
|
| 845 |
-
<span style='background: #d1fae5; padding: 4px 8px; border-radius: 4px; color: black;'>🌐 OpenAlex: <b>{
|
| 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 |
-
|
| 872 |
-
|
| 873 |
-
|
| 874 |
-
|
| 875 |
-
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
|
| 882 |
-
|
| 883 |
-
|
| 884 |
-
|
| 885 |
-
|
| 886 |
-
|
| 887 |
-
|
| 888 |
-
|
| 889 |
-
|
| 890 |
-
|
| 891 |
-
|
| 892 |
-
|
| 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 |
-
|
| 932 |
-
|
| 933 |
-
|
| 934 |
-
|
| 935 |
-
|
| 936 |
-
|
| 937 |
-
|
| 938 |
-
|
| 939 |
-
|
| 940 |
-
|
| 941 |
-
|
| 942 |
-
|
| 943 |
-
|
| 944 |
-
|
| 945 |
-
|
| 946 |
-
|
| 947 |
-
|
| 948 |
-
|
| 949 |
-
|
| 950 |
-
|
| 951 |
-
|
| 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", "
|
| 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 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 20 |
-
"stream": (15,
|
| 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
|
| 86 |
-
#
|
|
|
|
| 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 |
-
"
|
| 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 |
-
#
|
| 114 |
-
# the
|
| 115 |
-
"
|
| 116 |
-
|
| 117 |
-
int(os.environ.get("
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": "📏 目标总单词数 (
|
| 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.
|
| 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
|
|
|
|
| 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 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 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 |
-
|
| 271 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
timeout = self.timeouts["search"]
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1351 |
-
|
| 1352 |
-
|
| 1353 |
-
|
| 1354 |
-
|
| 1355 |
-
|
| 1356 |
-
|
| 1357 |
-
|
| 1358 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1393 |
-
|
| 1394 |
-
|
| 1395 |
-
|
| 1396 |
-
|
|
|
|
|
|
|
|
|
|
| 1397 |
try:
|
| 1398 |
-
|
| 1399 |
f"{self.s2_api_url}/paper/search",
|
| 1400 |
headers=headers,
|
| 1401 |
params={
|
| 1402 |
-
|
| 1403 |
-
|
| 1404 |
-
|
| 1405 |
-
|
| 1406 |
},
|
| 1407 |
)
|
| 1408 |
-
|
| 1409 |
-
|
| 1410 |
-
|
| 1411 |
-
self.
|
| 1412 |
-
return
|
| 1413 |
-
|
| 1414 |
-
|
| 1415 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1416 |
for paper in papers:
|
| 1417 |
self._check_cancelled()
|
| 1418 |
-
title = paper.get(
|
| 1419 |
-
year = paper.get(
|
| 1420 |
-
if not title or not year:
|
| 1421 |
continue
|
| 1422 |
-
authors = paper.get(
|
| 1423 |
last_names, formatted_authors = [], []
|
| 1424 |
for author in authors:
|
| 1425 |
-
name = author.get(
|
| 1426 |
if not name:
|
| 1427 |
continue
|
| 1428 |
parts = name.split()
|
| 1429 |
-
|
| 1430 |
-
|
| 1431 |
-
|
| 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 |
-
|
| 1445 |
-
|
| 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 |
-
|
| 1453 |
-
|
| 1454 |
results.append({
|
| 1455 |
-
|
| 1456 |
-
|
| 1457 |
-
|
| 1458 |
-
|
| 1459 |
-
|
|
|
|
|
|
|
|
|
|
| 1460 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1461 |
except PipelineCancelledError:
|
| 1462 |
raise
|
|
|
|
|
|
|
| 1463 |
except Exception as error:
|
| 1464 |
-
|
| 1465 |
-
status =
|
| 1466 |
-
|
| 1467 |
-
|
| 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 |
-
|
| 1476 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1477 |
if self.openalex_key:
|
| 1478 |
-
headers[
|
| 1479 |
-
|
| 1480 |
-
self.
|
| 1481 |
-
logging.info(
|
| 1482 |
try:
|
| 1483 |
-
|
| 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=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1495 |
)
|
| 1496 |
-
|
| 1497 |
-
self.
|
| 1498 |
-
|
| 1499 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1500 |
for item in raw_items:
|
| 1501 |
self._check_cancelled()
|
| 1502 |
-
title = item.get(
|
| 1503 |
if not title or not self._is_english_dominant(title):
|
| 1504 |
continue
|
| 1505 |
-
year = item.get(
|
| 1506 |
formatted_authors, last_names = [], []
|
| 1507 |
-
for authorship in item.get(
|
| 1508 |
-
|
| 1509 |
-
if not
|
| 1510 |
continue
|
| 1511 |
-
parts =
|
| 1512 |
-
|
| 1513 |
-
|
| 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 |
-
|
| 1528 |
-
|
| 1529 |
-
|
| 1530 |
-
|
| 1531 |
-
|
| 1532 |
-
|
| 1533 |
-
|
| 1534 |
-
|
| 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 |
-
|
| 1548 |
-
|
| 1549 |
-
|
| 1550 |
-
|
| 1551 |
-
|
|
|
|
|
|
|
|
|
|
| 1552 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1553 |
except PipelineCancelledError:
|
| 1554 |
raise
|
|
|
|
|
|
|
| 1555 |
except Exception as error:
|
| 1556 |
-
|
| 1557 |
-
status =
|
| 1558 |
-
|
| 1559 |
-
|
| 1560 |
-
|
| 1561 |
-
|
| 1562 |
-
|
| 1563 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 1570 |
for field in (
|
| 1571 |
-
|
| 1572 |
-
|
|
|
|
| 1573 |
):
|
| 1574 |
self.source_metrics[source][field] = 0
|
| 1575 |
-
self.source_metrics[source][
|
| 1576 |
-
self.source_metrics[source][
|
| 1577 |
-
|
| 1578 |
-
self.oa_calls = 0
|
| 1579 |
|
| 1580 |
-
llm_rag_context = []
|
| 1581 |
-
seen_identifiers = {}
|
| 1582 |
all_used_terms = set()
|
| 1583 |
-
|
| 1584 |
-
|
| 1585 |
-
|
| 1586 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1587 |
|
| 1588 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1589 |
self._check_cancelled()
|
| 1590 |
-
if
|
| 1591 |
-
|
| 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 |
-
|
| 1602 |
-
|
| 1603 |
-
temp=0.
|
| 1604 |
-
model_role=
|
| 1605 |
)
|
| 1606 |
-
|
| 1607 |
-
if isinstance(
|
| 1608 |
-
|
| 1609 |
-
|
| 1610 |
-
|
| 1611 |
-
|
| 1612 |
-
|
| 1613 |
-
|
| 1614 |
-
|
| 1615 |
-
|
| 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 |
-
|
| 1623 |
-
|
| 1624 |
-
|
| 1625 |
-
|
| 1626 |
-
|
| 1627 |
-
|
| 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 |
-
|
| 1686 |
len(self.citation_map),
|
| 1687 |
self.get_source_metrics(),
|
| 1688 |
)
|
| 1689 |
-
return
|
|
|
|
|
|
|
| 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
|
| 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
|
|
|
|
| 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":
|
| 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 |
-
|
| 2060 |
-
"
|
| 2061 |
-
|
| 2062 |
-
|
| 2063 |
-
|
| 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 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 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": "📏 目标总单词数(
|
| 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 (
|
| 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": "📏 Целевой объём (
|
| 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": "📏 Мақсатты көлем (
|
| 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
|
| 3 |
|
| 4 |
-
Cloudflare owns the
|
| 5 |
-
|
| 6 |
-
|
|
|
|
| 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
|
| 17 |
import time
|
| 18 |
from typing import Any, Callable
|
| 19 |
from urllib.parse import unquote, urlsplit
|
| 20 |
|
| 21 |
-
import
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
raise RuntimeError("SMARTPAPER_AUTH_API must be an HTTPS origin.")
|
| 72 |
-
return
|
| 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 |
-
|
| 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 |
-
|
| 143 |
if (
|
| 144 |
-
|
| 145 |
-
or not
|
| 146 |
-
or
|
| 147 |
-
or
|
| 148 |
-
or
|
| 149 |
-
or
|
| 150 |
-
or
|
| 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
|
| 163 |
raise RuntimeError("invalid_edge_assertion_identity")
|
| 164 |
-
payload["api_origin"] =
|
| 165 |
return payload
|
| 166 |
|
| 167 |
|
| 168 |
def _clear_generation_session_state() -> None:
|
| 169 |
for key in (
|
| 170 |
-
"sp_generation_task", "
|
| 171 |
-
"
|
| 172 |
-
"
|
| 173 |
-
"
|
|
|
|
|
|
|
|
|
|
| 174 |
):
|
| 175 |
st.session_state.pop(key, None)
|
| 176 |
|
| 177 |
|
| 178 |
def _clear_session() -> None:
|
| 179 |
for key in (
|
| 180 |
-
"
|
| 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
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 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
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 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 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 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 |
-
|
| 412 |
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 416 |
|
| 417 |
|
| 418 |
def generation_task_status(task_id: str) -> dict[str, Any]:
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 424 |
|
| 425 |
|
| 426 |
def cancel_generation_task(task_id: str | None = None) -> dict[str, Any]:
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 457 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 458 |
user_id = user.get("id", "—")
|
| 459 |
-
|
| 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,
|
| 587 |
<span class="sp-account-separator">•</span>
|
| 588 |
-
<span>{html.escape(tr(language,
|
| 589 |
<span class="sp-account-separator">•</span>
|
| 590 |
-
<span>{html.escape(tr(language,
|
| 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 |
-
|
| 7 |
-
tiktoken
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 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
|
| 3 |
|
| 4 |
-
The component is
|
| 5 |
-
|
|
|
|
|
|
|
| 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 |
-
"""
|
| 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
|
| 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
|
| 26 |
const LEGACY_COOKIE_NAME = "__Host-smartpaper_restore";
|
|
|
|
| 27 |
const MAX_TOKEN_LENGTH = 4096;
|
|
|
|
|
|
|
| 28 |
let lastResponseSignature = "";
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
function sendMessage(type, data) {
|
| 31 |
-
|
| 32 |
-
{ isStreamlitMessage: true, type: type },
|
| 33 |
-
data || {}
|
| 34 |
-
);
|
| 35 |
-
window.parent.postMessage(message, "*");
|
| 36 |
}
|
| 37 |
|
| 38 |
-
function setFrameHeight(
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
}
|
| 50 |
|
| 51 |
function normalizedToken(value) {
|
| 52 |
const token = typeof value === "string" ? value.trim() : "";
|
| 53 |
-
|
| 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
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
return "";
|
| 74 |
-
}
|
| 75 |
-
return normalizedToken(decodeURIComponent(entry.slice(prefix.length)));
|
| 76 |
-
} catch (_) {
|
| 77 |
-
return "";
|
| 78 |
-
}
|
| 79 |
}
|
| 80 |
|
| 81 |
function deleteLegacyCookie() {
|
| 82 |
-
try {
|
| 83 |
-
|
| 84 |
-
} catch (_) {
|
| 85 |
-
// The legacy cookie is optional. Storage operations continue normally.
|
| 86 |
-
}
|
| 87 |
}
|
| 88 |
|
| 89 |
function readStoredRecord() {
|
| 90 |
-
const
|
| 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 |
-
|
| 106 |
-
|
| 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 |
-
|
| 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
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
if (!record) {
|
| 152 |
-
const
|
| 153 |
-
if (
|
| 154 |
-
record = writeStoredRecord(legacyToken, "");
|
| 155 |
-
source = "legacy_cookie";
|
| 156 |
-
}
|
| 157 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
return {
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
|
|
|
| 164 |
};
|
| 165 |
-
}
|
|
|
|
| 166 |
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
token: record.token,
|
| 172 |
-
expires_at: record.expires_at || "",
|
| 173 |
-
source: source,
|
| 174 |
-
};
|
| 175 |
}
|
| 176 |
|
| 177 |
-
function
|
| 178 |
-
|
| 179 |
-
return {
|
| 180 |
-
action: "write",
|
| 181 |
-
command_id: commandId,
|
| 182 |
-
status: "written",
|
| 183 |
-
expires_at: record.expires_at || "",
|
| 184 |
-
};
|
| 185 |
}
|
| 186 |
|
| 187 |
-
function
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
const
|
| 191 |
-
return {
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
};
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 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 |
-
|
| 216 |
-
|
| 217 |
-
|
| 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:
|
| 241 |
-
setFrameHeight(
|
| 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
|
| 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 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 14 |
PYMUPDF_AVAILABLE = True
|
| 15 |
except ImportError:
|
| 16 |
PYMUPDF_AVAILABLE = False
|
| 17 |
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 26 |
-
|
| 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
|
| 38 |
-
|
|
|
|
| 39 |
for para in doc.paragraphs:
|
| 40 |
extracted_text += para.text + "\n"
|
| 41 |
-
if len(extracted_text) > max_chars:
|
| 42 |
break
|
| 43 |
else:
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
if len(extracted_text) > max_chars:
|
| 48 |
-
|
| 49 |
break
|
| 50 |
-
except Exception as
|
| 51 |
-
|
| 52 |
finally:
|
| 53 |
-
file
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
if len(df) > 100:
|
| 65 |
-
|
| 66 |
df = df.head(100)
|
| 67 |
return df.to_markdown(index=False)
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
st.warning(f"⚠️ 解析失败: {e}")
|
| 72 |
return ""
|
| 73 |
finally:
|
| 74 |
-
uploaded_file
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|