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