Spaces:
Running
Running
Update app.py
Browse files
app.py
CHANGED
|
@@ -14,8 +14,22 @@ import concurrent.futures
|
|
| 14 |
import threading
|
| 15 |
import math
|
| 16 |
from docx import Document
|
|
|
|
|
|
|
| 17 |
from datetime import datetime, timezone, timedelta
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
# ==========================================
|
| 20 |
# 0. 核心环境与内置浏览器阻断
|
| 21 |
# ==========================================
|
|
@@ -46,10 +60,10 @@ def load_config():
|
|
| 46 |
# 从环境变量读取可配置参数
|
| 47 |
config = {
|
| 48 |
# 文献检索参数
|
| 49 |
-
"lit_target_papers": int(os.environ.get("LIT_TARGET_PAPERS", "
|
| 50 |
"lit_per_page": int(os.environ.get("LIT_PER_PAGE", "50")),
|
| 51 |
"lit_max_workers": int(os.environ.get("LIT_MAX_WORKERS", "8")),
|
| 52 |
-
"lit_max_page_batches": int(os.environ.get("LIT_MAX_PAGE_BATCHES", "
|
| 53 |
|
| 54 |
# 分块参数
|
| 55 |
"chunk_words_default": int(os.environ.get("CHUNK_WORDS_DEFAULT", "1500")),
|
|
@@ -107,7 +121,7 @@ Produce a final, polished section that is ready for journal submission. Ensure:
|
|
| 107 |
- If a table is appropriate, format it using Markdown with clear column headers and numerical values aligned to cited findings.
|
| 108 |
""",
|
| 109 |
|
| 110 |
-
# ========== 深度综合指令
|
| 111 |
"deep_synthesis_directive": """
|
| 112 |
【CRITICAL SYNTHESIS & THEORETICAL INNOVATION MANDATE】
|
| 113 |
|
|
@@ -238,11 +252,6 @@ For any method or results section, you MUST include the following details (deriv
|
|
| 238 |
|
| 239 |
All reported statistics should be plausible and consistent with the magnitude of effects typically found in the cited literature.
|
| 240 |
""",
|
| 241 |
-
|
| 242 |
-
"data_disclaimer": """
|
| 243 |
-
> **⚠️ IMPORTANT DISCLAIMER (AI-Generated Draft)**
|
| 244 |
-
> This manuscript was generated by an AI-assisted research pipeline. All statistical results (e.g., β coefficients, p-values, AVE, CR, model fit indices) are **plausible but fabricated** for illustrative purposes only. They do **not** represent real empirical findings. This draft should be used solely for structural and formatting reference. **Before submission, all data must be replaced with actual research results.**
|
| 245 |
-
"""
|
| 246 |
}
|
| 247 |
return config
|
| 248 |
|
|
@@ -266,10 +275,12 @@ class SSciPipelineAgent:
|
|
| 266 |
def __init__(self, ds_key, s2_key=""):
|
| 267 |
self.ds_key = ds_key
|
| 268 |
self.s2_key = s2_key
|
| 269 |
-
self.ds_calls
|
|
|
|
|
|
|
| 270 |
self.citation_map = {}
|
| 271 |
self.used_citations = set()
|
| 272 |
-
self.lock = threading.
|
| 273 |
|
| 274 |
# 从配置中读取参数
|
| 275 |
self.lit_target_papers = APP_CONFIG["lit_target_papers"]
|
|
@@ -288,12 +299,36 @@ class SSciPipelineAgent:
|
|
| 288 |
"variables": "",
|
| 289 |
"model_summary": "",
|
| 290 |
"method_summary": "",
|
| 291 |
-
"abstract_anchor": "",
|
| 292 |
-
"conclusion_anchor": ""
|
| 293 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
|
| 295 |
def _is_english_dominant(self, text):
|
| 296 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
|
| 298 |
def _extract_memory_from_text(self, text, section_name):
|
| 299 |
lower_sec = section_name.lower()
|
|
@@ -315,7 +350,17 @@ class SSciPipelineAgent:
|
|
| 315 |
if method_match:
|
| 316 |
self.global_memory["method_summary"] = method_match.group(1)
|
| 317 |
|
| 318 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
memory_parts = []
|
| 320 |
if self.global_memory["abstract_anchor"]:
|
| 321 |
memory_parts.append(f"【PAPER ANCHOR - ABSTRACT】\n{self.global_memory['abstract_anchor']}")
|
|
@@ -329,25 +374,29 @@ class SSciPipelineAgent:
|
|
| 329 |
memory_parts.append(f"【RESEARCH MODEL】\n{self.global_memory['model_summary']}")
|
| 330 |
if self.global_memory["method_summary"]:
|
| 331 |
memory_parts.append(f"【METHODOLOGICAL SUMMARY】\n{self.global_memory['method_summary']}")
|
| 332 |
-
|
|
|
|
|
|
|
|
|
|
| 333 |
|
|
|
|
| 334 |
def call_deepseek(self, system_prompt, user_prompt, max_tokens=8000, temp=0.3):
|
| 335 |
-
self.
|
|
|
|
| 336 |
payload = {"model": "deepseek-reasoner", "messages": [{"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}], "max_tokens": max_tokens, "temperature": temp}
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
time.sleep(3)
|
| 347 |
-
raise ConnectionError(f"DeepSeek API 连续 3 次调用失败。错误信息: {last_error}")
|
| 348 |
|
| 349 |
def stream_deepseek(self, system_prompt, user_prompt, max_tokens=8000):
|
| 350 |
-
self.
|
|
|
|
| 351 |
payload = {"model": "deepseek-reasoner", "messages": [{"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}], "max_tokens": max_tokens, "temperature": 0.2, "stream": True}
|
| 352 |
with requests.post(self.deepseek_api_url, headers={"Authorization": f"Bearer {self.ds_key}"}, json=payload, stream=True, timeout=600) as response:
|
| 353 |
response.raise_for_status()
|
|
@@ -371,7 +420,6 @@ Literature Metadata:
|
|
| 371 |
"""
|
| 372 |
return self.call_deepseek(sys_prompt, user_prompt, temp=0.2)
|
| 373 |
|
| 374 |
-
# ========== 新增:生成锚点(摘要与结论) ==========
|
| 375 |
def generate_anchors(self, topic, keywords, synthesis_result, refs_text):
|
| 376 |
sys_prompt = "You are a senior scholar conceptualizing a high-impact empirical paper. Your task is to create the intellectual anchors."
|
| 377 |
user_prompt = f"""Topic: {topic}
|
|
@@ -381,12 +429,11 @@ Literature Synthesis & Identified Gaps:
|
|
| 381 |
{synthesis_result}
|
| 382 |
|
| 383 |
Relevant Citation Context:
|
| 384 |
-
{refs_text[:3000]}
|
| 385 |
|
| 386 |
{APP_CONFIG['anchor_generation_directive']}
|
| 387 |
"""
|
| 388 |
response = self.call_deepseek(sys_prompt, user_prompt, max_tokens=1500, temp=0.3)
|
| 389 |
-
# 解析锚点内容
|
| 390 |
abstract_match = re.search(r'---ABSTRACT---\s*(.*?)\s*---CONCLUSION---', response, re.DOTALL | re.IGNORECASE)
|
| 391 |
conclusion_match = re.search(r'---CONCLUSION---\s*(.*?)$', response, re.DOTALL | re.IGNORECASE)
|
| 392 |
abstract = abstract_match.group(1).strip() if abstract_match else ""
|
|
@@ -395,108 +442,169 @@ Relevant Citation Context:
|
|
| 395 |
|
| 396 |
def generate_outline(self, topic, keywords, synthesis_result):
|
| 397 |
sys_prompt = "You are a senior academic journal reviewer. Design a strict empirical research paper outline based on identified gaps."
|
| 398 |
-
user_prompt = f"Topic: {topic}
|
| 399 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
response = self.call_deepseek(sys_prompt, user_prompt, temp=0.2)
|
|
|
|
| 401 |
try:
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
except Exception
|
|
|
|
| 408 |
time.sleep(2)
|
| 409 |
-
raise ValueError("Outline Generation Failed.
|
| 410 |
|
|
|
|
| 411 |
def fetch_s2_abstract(self, doi, title):
|
| 412 |
-
with self.lock:
|
|
|
|
| 413 |
headers = {"x-api-key": self.s2_key} if self.s2_key else {}
|
| 414 |
try:
|
| 415 |
url = f"{self.s2_api_url}/DOI:{doi}?fields=abstract" if doi else f"{self.s2_api_url}/search?query={title}&limit=1&fields=abstract"
|
| 416 |
-
|
|
|
|
| 417 |
if not self.s2_key: time.sleep(1.5)
|
| 418 |
-
if doi and
|
| 419 |
-
elif not doi and
|
| 420 |
except Exception:
|
| 421 |
pass
|
| 422 |
return None
|
| 423 |
|
| 424 |
def fetch_references_parallel(self, topic):
|
| 425 |
-
self.
|
|
|
|
| 426 |
llm_rag_context = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 427 |
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
if
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 482 |
cite_tag = f"[CITE-{len(self.citation_map)+1:02d}]"
|
| 483 |
self.citation_map[cite_tag] = {"in_text": in_text, "ref": apa_full}
|
| 484 |
self.oa_calls += 1
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 500 |
return "\n".join(llm_rag_context)
|
| 501 |
|
| 502 |
def resolve_citations(self, text, quarantine=False):
|
|
@@ -505,13 +613,131 @@ Relevant Citation Context:
|
|
| 505 |
return re.sub(r'\[CITE-\d+\]', '', text)
|
| 506 |
def replacer(match):
|
| 507 |
tag = match.group(0).strip()
|
| 508 |
-
|
| 509 |
-
self.
|
| 510 |
-
|
|
|
|
| 511 |
return tag
|
| 512 |
return re.sub(r'\[CITE-\d+\]', replacer, text)
|
| 513 |
|
| 514 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 515 |
if "is_running" not in st.session_state: st.session_state.is_running = False
|
| 516 |
if "start_pipeline" not in st.session_state: st.session_state.start_pipeline = False
|
| 517 |
if "zip_buffer" not in st.session_state: st.session_state.zip_buffer = None
|
|
@@ -573,11 +799,14 @@ if st.session_state.start_pipeline:
|
|
| 573 |
render_monitor(agent, "Initialization", "并发文献抓取与补全", "正在执行 OpenAlex 检索,并按真实结果蓄水库存...")
|
| 574 |
refs_text = agent.fetch_references_parallel(topic)
|
| 575 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 576 |
pb.progress(10, text="🧠 正在进行文献深度综合推演 (10%)...")
|
| 577 |
render_monitor(agent, "Module 1", "文献综合与间隙识��", "正在对文献进行深度综合...")
|
| 578 |
synthesis_result = agent.synthesize_literature(topic, refs_text)
|
| 579 |
|
| 580 |
-
# ========== 新增:生成锚点(摘要与结论) ==========
|
| 581 |
pb.progress(12, text="⚓ 正在生成论文锚点(摘要与预期结论)...")
|
| 582 |
render_monitor(agent, "Anchor Generation", "摘要与结论锚点", "正在构建论文的核心知识锚点,确保全文逻辑一致...")
|
| 583 |
abstract_anchor, conclusion_anchor = agent.generate_anchors(topic, keywords, synthesis_result, refs_text)
|
|
@@ -623,23 +852,13 @@ if st.session_state.start_pipeline:
|
|
| 623 |
trans_ph.info("⏳ 等待英文对应段落完成后执行...")
|
| 624 |
ui_blocks.append({"sec": sec, "en_ph": en_ph, "trans_ph": trans_ph})
|
| 625 |
|
| 626 |
-
full_draft_en = ""
|
| 627 |
-
full_draft_translated = ""
|
| 628 |
context_tail = ""
|
| 629 |
generation_aborted = False
|
| 630 |
|
| 631 |
all_cite_keys = list(agent.citation_map.keys())
|
| 632 |
valid_chunks = [s for s in adaptive_structure if not any(kw in s["section"].lower() for kw in ["abstract", "keyword", "摘要", "关键字"])]
|
| 633 |
-
total_valid_words = sum(s["target_words"] for s in valid_chunks)
|
| 634 |
-
|
| 635 |
-
section_weight_map = {}
|
| 636 |
-
for sec in structure:
|
| 637 |
-
name = sec.get("section", "")
|
| 638 |
-
base_weight = sec.get("weight", 0.1)
|
| 639 |
-
if any(kw in name.lower() for kw in ["method", "result", "literature", "hypothesis", "discussion"]):
|
| 640 |
-
base_weight *= 1.5
|
| 641 |
-
section_weight_map[name] = base_weight
|
| 642 |
-
total_weight = sum(section_weight_map.values())
|
| 643 |
|
| 644 |
allocation_map = {}
|
| 645 |
current_cite_idx = 0
|
|
@@ -647,8 +866,7 @@ if st.session_state.start_pipeline:
|
|
| 647 |
if idx == len(valid_chunks) - 1:
|
| 648 |
allocation_map[vc["display_name"]] = all_cite_keys[current_cite_idx:]
|
| 649 |
else:
|
| 650 |
-
|
| 651 |
-
num_cites = math.ceil(len(all_cite_keys) * adj_weight)
|
| 652 |
allocation_map[vc["display_name"]] = all_cite_keys[current_cite_idx : current_cite_idx + num_cites]
|
| 653 |
current_cite_idx += num_cites
|
| 654 |
|
|
@@ -666,20 +884,14 @@ if st.session_state.start_pipeline:
|
|
| 666 |
quarantine_rule = "【CRITICAL】: NO IN-TEXT CITATIONS ALLOWED IN THIS SECTION."
|
| 667 |
else:
|
| 668 |
assigned_keys = allocation_map.get(display_name, [])
|
| 669 |
-
assigned_tags_str = ", ".join(assigned_keys)
|
| 670 |
-
|
| 671 |
-
quarantine_rule = f"""【CRITICAL MANDATE】: You MUST heavily cite the literature.
|
| 672 |
-
Use EXACTLY ALL of these specific citation tags in this section: {assigned_tags_str}.
|
| 673 |
-
Distribute them logically across paragraphs. Missing any is a fatal error.
|
| 674 |
<AVAILABLE_CITATIONS>
|
| 675 |
{refs_text}
|
| 676 |
</AVAILABLE_CITATIONS>"""
|
| 677 |
-
else:
|
| 678 |
-
quarantine_rule = "Use academic citations where appropriate."
|
| 679 |
|
| 680 |
heading_instruction = f"Output the section heading # {sec_title}." if sec["is_first"] else "DO NOT output the main heading. Seamlessly continue."
|
| 681 |
|
| 682 |
-
# 动态注入深度提示词
|
| 683 |
extra_directives = []
|
| 684 |
lower_sec = sec_title.lower()
|
| 685 |
if sec["is_method_or_result"]:
|
|
@@ -693,17 +905,34 @@ Distribute them logically across paragraphs. Missing any is a fatal error.
|
|
| 693 |
extra_directives.append(APP_CONFIG["conclusion_anchor_directive"])
|
| 694 |
|
| 695 |
theory_injection = "\n\n".join(extra_directives)
|
| 696 |
-
|
| 697 |
-
memory_context = agent._build_memory_context()
|
| 698 |
memory_block = f"\n【GLOBAL PAPER MEMORY - ENSURE CONSISTENCY】\n{memory_context}\n" if memory_context else ""
|
| 699 |
|
| 700 |
-
#
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 707 |
{quarantine_rule}
|
| 708 |
{heading_instruction}
|
| 709 |
{theory_injection}
|
|
@@ -715,16 +944,21 @@ Previous Context Tail:
|
|
| 715 |
|
| 716 |
IMPORTANT: Output directly the final refined academic text. No meta-commentary. Include tables if appropriate.
|
| 717 |
"""
|
| 718 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 719 |
en_draft_clean = re.sub(r'<think>.*?</think>', '', en_draft_stream, flags=re.DOTALL).strip()
|
| 720 |
en_resolved = agent.resolve_citations(en_draft_clean, quarantine=is_quarantined)
|
| 721 |
en_ph.markdown(en_resolved)
|
| 722 |
full_draft_en += en_resolved + "\n\n"
|
| 723 |
agent._extract_memory_from_text(en_draft_clean, sec_title)
|
| 724 |
-
|
|
|
|
| 725 |
current_step += 1
|
| 726 |
percent = 15 + int((current_step / total_steps) * 80)
|
| 727 |
-
pb.progress(percent, text=f"🇬🇧 {display_name} 英文定稿完成 ({percent}%)...")
|
| 728 |
except Exception as e:
|
| 729 |
error_msg = f"\n\n> ⚠️ [系统提示] 英文生成阶段异常中断:{str(e)}\n\n"
|
| 730 |
en_ph.error("🚨 网络异常,正在保存已生成内容...")
|
|
@@ -733,7 +967,7 @@ IMPORTANT: Output directly the final refined academic text. No meta-commentary.
|
|
| 733 |
generation_aborted = True
|
| 734 |
break
|
| 735 |
|
| 736 |
-
#
|
| 737 |
render_monitor(agent, f"Translating ({current_step+1}/{total_steps})", display_name, "实时翻译为目标语言...")
|
| 738 |
trans_ph.empty()
|
| 739 |
try:
|
|
@@ -746,7 +980,7 @@ IMPORTANT: Output directly the final refined academic text. No meta-commentary.
|
|
| 746 |
full_draft_translated += trans_resolved + "\n\n"
|
| 747 |
current_step += 1
|
| 748 |
percent = 15 + int((current_step / total_steps) * 80)
|
| 749 |
-
pb.progress(percent, text=f"{target_flag} {display_name} 翻译完成 ({percent}%)...")
|
| 750 |
except Exception as e:
|
| 751 |
error_msg = f"\n\n> ⚠️ [系统提示] 翻译阶段异常中断:{str(e)}\n\n"
|
| 752 |
trans_ph.error("🚨 网络异常,正在保存已生成内容...")
|
|
@@ -756,90 +990,21 @@ IMPORTANT: Output directly the final refined academic text. No meta-commentary.
|
|
| 756 |
|
| 757 |
if full_draft_en.strip():
|
| 758 |
pb.progress(96, text="📦 正在排版并打包无损双语 DOCX (96%)...")
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
|
| 768 |
used_tags = sorted(list(agent.used_citations))
|
| 769 |
apa_refs_list = "\n\n".join([f"- {agent.citation_map[tag]['ref']}" for tag in used_tags]) if used_tags else "No references cited."
|
| 770 |
full_draft_en += "\n\n# References\n" + apa_refs_list
|
| 771 |
full_draft_translated += "\n\n# 参考文献 (References)\n" + apa_refs_list
|
| 772 |
|
| 773 |
-
render_monitor(agent, "Finalizing", "Word 排版引擎", "正在
|
| 774 |
-
|
| 775 |
-
def text_to_docx(content):
|
| 776 |
-
doc = Document()
|
| 777 |
-
lines = content.split('\n')
|
| 778 |
-
in_table = False
|
| 779 |
-
table_rows = []
|
| 780 |
-
in_code_block = False
|
| 781 |
-
for line in lines:
|
| 782 |
-
if line.strip().startswith('```'):
|
| 783 |
-
in_code_block = not in_code_block
|
| 784 |
-
continue
|
| 785 |
-
if in_code_block:
|
| 786 |
-
if line.strip():
|
| 787 |
-
doc.add_paragraph(line, style='Normal')
|
| 788 |
-
continue
|
| 789 |
-
if line.strip().startswith('|') and line.strip().endswith('|'):
|
| 790 |
-
in_table = True
|
| 791 |
-
if re.match(r'^\|[\s\-:\|]+\|$', line.strip()):
|
| 792 |
-
continue
|
| 793 |
-
cells = [cell.strip() for cell in line.strip().split('|')[1:-1]]
|
| 794 |
-
table_rows.append(cells)
|
| 795 |
-
continue
|
| 796 |
-
else:
|
| 797 |
-
if in_table and table_rows:
|
| 798 |
-
max_cols = max(len(row) for row in table_rows)
|
| 799 |
-
table = doc.add_table(rows=len(table_rows), cols=max_cols)
|
| 800 |
-
table.style = 'Table Grid'
|
| 801 |
-
for i, row_data in enumerate(table_rows):
|
| 802 |
-
for j, cell_text in enumerate(row_data):
|
| 803 |
-
if j < max_cols:
|
| 804 |
-
table.cell(i, j).text = re.sub(r'\*\*(.*?)\*\*', r'\1', cell_text)
|
| 805 |
-
doc.add_paragraph("")
|
| 806 |
-
in_table = False
|
| 807 |
-
table_rows = []
|
| 808 |
-
if not line.strip():
|
| 809 |
-
continue
|
| 810 |
-
heading_match = re.match(r'^(#{1,6})\s+(.*)', line.strip())
|
| 811 |
-
if heading_match:
|
| 812 |
-
doc.add_heading(heading_match.group(2), level=len(heading_match.group(1)))
|
| 813 |
-
continue
|
| 814 |
-
bullet_match = re.match(r'^[\-\*]\s+(.*)', line.strip())
|
| 815 |
-
if bullet_match:
|
| 816 |
-
doc.add_paragraph(re.sub(r'\*\*(.*?)\*\*', r'\1', bullet_match.group(1)), style='List Bullet')
|
| 817 |
-
continue
|
| 818 |
-
number_match = re.match(r'^\d+\.\s+(.*)', line.strip())
|
| 819 |
-
if number_match:
|
| 820 |
-
doc.add_paragraph(re.sub(r'\*\*(.*?)\*\*', r'\1', number_match.group(1)), style='List Number')
|
| 821 |
-
continue
|
| 822 |
-
if line.strip().startswith('>'):
|
| 823 |
-
clean_text = re.sub(r'^>\s*', '', line.strip())
|
| 824 |
-
clean_text = re.sub(r'\*\*(.*?)\*\*', r'\1', clean_text)
|
| 825 |
-
p = doc.add_paragraph(clean_text)
|
| 826 |
-
p.style = 'Intense Quote' if 'Intense Quote' in doc.styles else 'Normal'
|
| 827 |
-
continue
|
| 828 |
-
clean_line = re.sub(r'\*\*(.*?)\*\*', r'\1', line.strip())
|
| 829 |
-
clean_line = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', clean_line)
|
| 830 |
-
doc.add_paragraph(clean_line)
|
| 831 |
-
if in_table and table_rows:
|
| 832 |
-
max_cols = max(len(row) for row in table_rows)
|
| 833 |
-
table = doc.add_table(rows=len(table_rows), cols=max_cols)
|
| 834 |
-
table.style = 'Table Grid'
|
| 835 |
-
for i, row_data in enumerate(table_rows):
|
| 836 |
-
for j, cell_text in enumerate(row_data):
|
| 837 |
-
if j < max_cols:
|
| 838 |
-
table.cell(i, j).text = re.sub(r'\*\*(.*?)\*\*', r'\1', cell_text)
|
| 839 |
-
stream = io.BytesIO()
|
| 840 |
-
doc.save(stream)
|
| 841 |
-
stream.seek(0)
|
| 842 |
-
return stream
|
| 843 |
|
| 844 |
bj_time = datetime.now(timezone(timedelta(hours=8))).strftime("%Y%m%d_%H%M")
|
| 845 |
lang_file_prefix = {"Simplified Chinese (简体中文)": "ZH", "Russian (Русский)": "RU", "Kazakh (Қазақша)": "KK"}.get(target_lang, "ZH")
|
|
@@ -864,7 +1029,7 @@ IMPORTANT: Output directly the final refined academic text. No meta-commentary.
|
|
| 864 |
render_monitor(agent, "Task Complete", "✅ 完毕", "交织推演结束,所有文档已生成。")
|
| 865 |
st.session_state.is_interrupted = False
|
| 866 |
else:
|
| 867 |
-
st.error("🚨 在进入实际写作前发生了核心错误
|
| 868 |
except Exception as e:
|
| 869 |
st.error(f"🚨 核心初始化逻辑异常中断,信息: {str(e)}")
|
| 870 |
finally:
|
|
|
|
| 14 |
import threading
|
| 15 |
import math
|
| 16 |
from docx import Document
|
| 17 |
+
from docx.shared import Inches, Pt
|
| 18 |
+
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
| 19 |
from datetime import datetime, timezone, timedelta
|
| 20 |
|
| 21 |
+
# 新增依赖
|
| 22 |
+
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
|
| 23 |
+
import tiktoken
|
| 24 |
+
import mistune
|
| 25 |
+
from bs4 import BeautifulSoup
|
| 26 |
+
|
| 27 |
+
try:
|
| 28 |
+
from langdetect import detect
|
| 29 |
+
LANGDETECT_AVAILABLE = True
|
| 30 |
+
except ImportError:
|
| 31 |
+
LANGDETECT_AVAILABLE = False
|
| 32 |
+
|
| 33 |
# ==========================================
|
| 34 |
# 0. 核心环境与内置浏览器阻断
|
| 35 |
# ==========================================
|
|
|
|
| 60 |
# 从环境变量读取可配置参数
|
| 61 |
config = {
|
| 62 |
# 文献检索参数
|
| 63 |
+
"lit_target_papers": int(os.environ.get("LIT_TARGET_PAPERS", "45")),
|
| 64 |
"lit_per_page": int(os.environ.get("LIT_PER_PAGE", "50")),
|
| 65 |
"lit_max_workers": int(os.environ.get("LIT_MAX_WORKERS", "8")),
|
| 66 |
+
"lit_max_page_batches": int(os.environ.get("LIT_MAX_PAGE_BATCHES", "12")),
|
| 67 |
|
| 68 |
# 分块参数
|
| 69 |
"chunk_words_default": int(os.environ.get("CHUNK_WORDS_DEFAULT", "1500")),
|
|
|
|
| 121 |
- If a table is appropriate, format it using Markdown with clear column headers and numerical values aligned to cited findings.
|
| 122 |
""",
|
| 123 |
|
| 124 |
+
# ========== 深度综合指令 ==========
|
| 125 |
"deep_synthesis_directive": """
|
| 126 |
【CRITICAL SYNTHESIS & THEORETICAL INNOVATION MANDATE】
|
| 127 |
|
|
|
|
| 252 |
|
| 253 |
All reported statistics should be plausible and consistent with the magnitude of effects typically found in the cited literature.
|
| 254 |
""",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
}
|
| 256 |
return config
|
| 257 |
|
|
|
|
| 275 |
def __init__(self, ds_key, s2_key=""):
|
| 276 |
self.ds_key = ds_key
|
| 277 |
self.s2_key = s2_key
|
| 278 |
+
self.ds_calls = 0
|
| 279 |
+
self.oa_calls = 0
|
| 280 |
+
self.s2_calls = 0
|
| 281 |
self.citation_map = {}
|
| 282 |
self.used_citations = set()
|
| 283 |
+
self.lock = threading.RLock()
|
| 284 |
|
| 285 |
# 从配置中读取参数
|
| 286 |
self.lit_target_papers = APP_CONFIG["lit_target_papers"]
|
|
|
|
| 299 |
"variables": "",
|
| 300 |
"model_summary": "",
|
| 301 |
"method_summary": "",
|
| 302 |
+
"abstract_anchor": "",
|
| 303 |
+
"conclusion_anchor": ""
|
| 304 |
}
|
| 305 |
+
|
| 306 |
+
# Token估算器
|
| 307 |
+
try:
|
| 308 |
+
self.tokenizer = tiktoken.get_encoding("cl100k_base")
|
| 309 |
+
except:
|
| 310 |
+
self.tokenizer = tiktoken.get_encoding("gpt2")
|
| 311 |
+
|
| 312 |
+
# ---------- 重试装饰器 ----------
|
| 313 |
+
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10), retry=retry_if_exception_type((requests.exceptions.Timeout, requests.exceptions.ConnectionError, requests.exceptions.HTTPError)))
|
| 314 |
+
def _requests_get_with_retry(self, url, headers=None, params=None, timeout=15):
|
| 315 |
+
resp = requests.get(url, headers=headers, params=params, timeout=timeout)
|
| 316 |
+
if resp.status_code == 429:
|
| 317 |
+
time.sleep(5)
|
| 318 |
+
resp.raise_for_status()
|
| 319 |
+
resp.raise_for_status()
|
| 320 |
+
return resp
|
| 321 |
|
| 322 |
def _is_english_dominant(self, text):
|
| 323 |
+
if not text: return False
|
| 324 |
+
if LANGDETECT_AVAILABLE:
|
| 325 |
+
try:
|
| 326 |
+
return detect(text) == 'en'
|
| 327 |
+
except:
|
| 328 |
+
pass
|
| 329 |
+
alpha_chars = sum(1 for c in str(text) if c.isalpha())
|
| 330 |
+
if alpha_chars == 0: return False
|
| 331 |
+
return (len(re.findall(r'[a-zA-Z]', str(text))) / alpha_chars) > 0.8
|
| 332 |
|
| 333 |
def _extract_memory_from_text(self, text, section_name):
|
| 334 |
lower_sec = section_name.lower()
|
|
|
|
| 350 |
if method_match:
|
| 351 |
self.global_memory["method_summary"] = method_match.group(1)
|
| 352 |
|
| 353 |
+
def _count_tokens(self, text):
|
| 354 |
+
return len(self.tokenizer.encode(text))
|
| 355 |
+
|
| 356 |
+
def _truncate_context(self, text, max_tokens):
|
| 357 |
+
tokens = self.tokenizer.encode(text)
|
| 358 |
+
if len(tokens) <= max_tokens:
|
| 359 |
+
return text
|
| 360 |
+
truncated_tokens = tokens[:max_tokens]
|
| 361 |
+
return self.tokenizer.decode(truncated_tokens)
|
| 362 |
+
|
| 363 |
+
def _build_memory_context(self, max_tokens=2000):
|
| 364 |
memory_parts = []
|
| 365 |
if self.global_memory["abstract_anchor"]:
|
| 366 |
memory_parts.append(f"【PAPER ANCHOR - ABSTRACT】\n{self.global_memory['abstract_anchor']}")
|
|
|
|
| 374 |
memory_parts.append(f"【RESEARCH MODEL】\n{self.global_memory['model_summary']}")
|
| 375 |
if self.global_memory["method_summary"]:
|
| 376 |
memory_parts.append(f"【METHODOLOGICAL SUMMARY】\n{self.global_memory['method_summary']}")
|
| 377 |
+
full = "\n\n".join(memory_parts)
|
| 378 |
+
if self._count_tokens(full) > max_tokens:
|
| 379 |
+
return self._truncate_context(full, max_tokens)
|
| 380 |
+
return full
|
| 381 |
|
| 382 |
+
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
|
| 383 |
def call_deepseek(self, system_prompt, user_prompt, max_tokens=8000, temp=0.3):
|
| 384 |
+
with self.lock:
|
| 385 |
+
self.ds_calls += 1
|
| 386 |
payload = {"model": "deepseek-reasoner", "messages": [{"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}], "max_tokens": max_tokens, "temperature": temp}
|
| 387 |
+
resp = requests.post(self.deepseek_api_url, headers={"Authorization": f"Bearer {self.ds_key}"}, json=payload, timeout=600)
|
| 388 |
+
if resp.status_code == 429:
|
| 389 |
+
time.sleep(5)
|
| 390 |
+
resp.raise_for_status()
|
| 391 |
+
resp.raise_for_status()
|
| 392 |
+
data = resp.json()
|
| 393 |
+
if "choices" in data:
|
| 394 |
+
return re.sub(r'<think>.*?</think>', '', data["choices"][0]["message"].get("content", ""), flags=re.DOTALL).strip()
|
| 395 |
+
return ""
|
|
|
|
|
|
|
| 396 |
|
| 397 |
def stream_deepseek(self, system_prompt, user_prompt, max_tokens=8000):
|
| 398 |
+
with self.lock:
|
| 399 |
+
self.ds_calls += 1
|
| 400 |
payload = {"model": "deepseek-reasoner", "messages": [{"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}], "max_tokens": max_tokens, "temperature": 0.2, "stream": True}
|
| 401 |
with requests.post(self.deepseek_api_url, headers={"Authorization": f"Bearer {self.ds_key}"}, json=payload, stream=True, timeout=600) as response:
|
| 402 |
response.raise_for_status()
|
|
|
|
| 420 |
"""
|
| 421 |
return self.call_deepseek(sys_prompt, user_prompt, temp=0.2)
|
| 422 |
|
|
|
|
| 423 |
def generate_anchors(self, topic, keywords, synthesis_result, refs_text):
|
| 424 |
sys_prompt = "You are a senior scholar conceptualizing a high-impact empirical paper. Your task is to create the intellectual anchors."
|
| 425 |
user_prompt = f"""Topic: {topic}
|
|
|
|
| 429 |
{synthesis_result}
|
| 430 |
|
| 431 |
Relevant Citation Context:
|
| 432 |
+
{refs_text[:3000]}
|
| 433 |
|
| 434 |
{APP_CONFIG['anchor_generation_directive']}
|
| 435 |
"""
|
| 436 |
response = self.call_deepseek(sys_prompt, user_prompt, max_tokens=1500, temp=0.3)
|
|
|
|
| 437 |
abstract_match = re.search(r'---ABSTRACT---\s*(.*?)\s*---CONCLUSION---', response, re.DOTALL | re.IGNORECASE)
|
| 438 |
conclusion_match = re.search(r'---CONCLUSION---\s*(.*?)$', response, re.DOTALL | re.IGNORECASE)
|
| 439 |
abstract = abstract_match.group(1).strip() if abstract_match else ""
|
|
|
|
| 442 |
|
| 443 |
def generate_outline(self, topic, keywords, synthesis_result):
|
| 444 |
sys_prompt = "You are a senior academic journal reviewer. Design a strict empirical research paper outline based on identified gaps."
|
| 445 |
+
user_prompt = f"""Topic: {topic}
|
| 446 |
+
Keywords: {keywords}
|
| 447 |
+
Literature Synthesis & Gaps:
|
| 448 |
+
{synthesis_result}
|
| 449 |
+
|
| 450 |
+
Construct a detailed empirical outline.
|
| 451 |
+
Your response must be a single valid JSON object with the following structure:
|
| 452 |
+
{{"en_topic": "...", "en_keywords": "...", "structure": [{{"section": "...", "weight": 0.15, "is_method_or_result": false}}]}}
|
| 453 |
+
Do not include any other text, explanations, or markdown formatting.
|
| 454 |
+
"""
|
| 455 |
+
for attempt in range(3):
|
| 456 |
response = self.call_deepseek(sys_prompt, user_prompt, temp=0.2)
|
| 457 |
+
clean = re.sub(r'^```json\s*|\s*```$', '', response.strip(), flags=re.IGNORECASE | re.MULTILINE)
|
| 458 |
try:
|
| 459 |
+
plan = json.loads(clean)
|
| 460 |
+
if "structure" in plan:
|
| 461 |
+
return plan
|
| 462 |
+
else:
|
| 463 |
+
st.warning(f"大纲缺少 'structure' 字段,尝试重新生成... (尝试 {attempt+1}/3)")
|
| 464 |
+
except Exception as e:
|
| 465 |
+
st.warning(f"大纲解析失败 (尝试 {attempt+1}/3): {e}\n原始片段: {clean[:200]}")
|
| 466 |
time.sleep(2)
|
| 467 |
+
raise ValueError("Outline Generation Failed. Invalid JSON structure.")
|
| 468 |
|
| 469 |
+
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=2, max=5))
|
| 470 |
def fetch_s2_abstract(self, doi, title):
|
| 471 |
+
with self.lock:
|
| 472 |
+
self.s2_calls += 1
|
| 473 |
headers = {"x-api-key": self.s2_key} if self.s2_key else {}
|
| 474 |
try:
|
| 475 |
url = f"{self.s2_api_url}/DOI:{doi}?fields=abstract" if doi else f"{self.s2_api_url}/search?query={title}&limit=1&fields=abstract"
|
| 476 |
+
resp = self._requests_get_with_retry(url, headers=headers, timeout=10)
|
| 477 |
+
data = resp.json()
|
| 478 |
if not self.s2_key: time.sleep(1.5)
|
| 479 |
+
if doi and data.get("abstract"): return data.get("abstract")
|
| 480 |
+
elif not doi and data.get("data") and len(data["data"]) > 0 and data["data"][0].get("abstract"): return data["data"][0].get("abstract")
|
| 481 |
except Exception:
|
| 482 |
pass
|
| 483 |
return None
|
| 484 |
|
| 485 |
def fetch_references_parallel(self, topic):
|
| 486 |
+
with self.lock:
|
| 487 |
+
self.citation_map.clear()
|
| 488 |
llm_rag_context = []
|
| 489 |
+
search_terms = [topic]
|
| 490 |
+
if "virtual" in topic.lower() or "vr" in topic.lower():
|
| 491 |
+
search_terms.append("virtual reality tourism")
|
| 492 |
+
if "tourism" in topic.lower():
|
| 493 |
+
search_terms.append("travel intention")
|
| 494 |
+
seen_dois = set()
|
| 495 |
|
| 496 |
+
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=3))
|
| 497 |
+
def fetch_page(search_term, page):
|
| 498 |
+
headers = {"User-Agent": "mailto:ssci_agent@example.com"}
|
| 499 |
+
params = {
|
| 500 |
+
"search": search_term,
|
| 501 |
+
"per-page": self.lit_per_page,
|
| 502 |
+
"page": page,
|
| 503 |
+
"filter": "language:en,type:article,has_doi:true,is_paratext:false",
|
| 504 |
+
"sort": "cited_by_count:desc"
|
| 505 |
+
}
|
| 506 |
+
resp = self._requests_get_with_retry(self.openalex_api_url, headers=headers, params=params, timeout=15)
|
| 507 |
+
return resp.json().get("results", [])
|
| 508 |
+
|
| 509 |
+
for term in search_terms:
|
| 510 |
+
if len(self.citation_map) >= self.lit_target_papers:
|
| 511 |
+
break
|
| 512 |
+
page_batch = 1
|
| 513 |
+
while len(self.citation_map) < self.lit_target_papers and page_batch <= self.lit_max_page_batches:
|
| 514 |
+
batch_pages = range((page_batch-1)*5 + 1, page_batch*5 + 1)
|
| 515 |
+
raw_results = []
|
| 516 |
+
with concurrent.futures.ThreadPoolExecutor(max_workers=self.lit_max_workers) as executor:
|
| 517 |
+
futures = [executor.submit(fetch_page, term, p) for p in batch_pages]
|
| 518 |
+
for future in concurrent.futures.as_completed(futures):
|
| 519 |
+
try:
|
| 520 |
+
raw_results.extend(future.result())
|
| 521 |
+
except Exception as e:
|
| 522 |
+
st.warning(f"OpenAlex页面抓取失败: {e}")
|
| 523 |
+
for item in raw_results:
|
| 524 |
+
if len(self.citation_map) >= self.lit_target_papers:
|
| 525 |
+
break
|
| 526 |
+
title = item.get("title")
|
| 527 |
+
if not title or not self._is_english_dominant(title): continue
|
| 528 |
+
doi_clean = (item.get("doi") or "").replace("https://doi.org/", "")
|
| 529 |
+
if doi_clean in seen_dois: continue
|
| 530 |
+
seen_dois.add(doi_clean)
|
| 531 |
+
year = item.get("publication_year", "n.d.")
|
| 532 |
+
venue = ((item.get("primary_location") or {}).get("source") or {}).get("display_name", "Journal")
|
| 533 |
+
formatted_authors, last_names = [], []
|
| 534 |
+
for a in item.get("authorships", []):
|
| 535 |
+
name_raw = a.get("author", {}).get("display_name", "")
|
| 536 |
+
if name_raw and name_raw.strip():
|
| 537 |
+
parts = name_raw.strip().split()
|
| 538 |
+
if len(parts) > 1:
|
| 539 |
+
last_names.append(parts[-1])
|
| 540 |
+
formatted_authors.append(f"{parts[-1]}, {parts[0][0]}.")
|
| 541 |
+
else:
|
| 542 |
+
last_names.append(name_raw)
|
| 543 |
+
formatted_authors.append(name_raw)
|
| 544 |
+
if not formatted_authors: continue
|
| 545 |
+
if len(last_names) == 1: in_text = f"({last_names[0]}, {year})"
|
| 546 |
+
elif len(last_names) == 2: in_text = f"({last_names[0]} & {last_names[1]}, {year})"
|
| 547 |
+
else: in_text = f"({last_names[0]} et al., {year})"
|
| 548 |
+
if len(formatted_authors) > 2: author_str = ", ".join(formatted_authors[:-1]) + ", & " + formatted_authors[-1]
|
| 549 |
+
elif len(formatted_authors) == 2: author_str = " & ".join(formatted_authors)
|
| 550 |
+
else: author_str = formatted_authors[0]
|
| 551 |
+
apa_full = f"{author_str} ({year}). {title}. {venue}."
|
| 552 |
+
if doi_clean: apa_full += f" https://doi.org/{doi_clean}"
|
| 553 |
+
with self.lock:
|
| 554 |
cite_tag = f"[CITE-{len(self.citation_map)+1:02d}]"
|
| 555 |
self.citation_map[cite_tag] = {"in_text": in_text, "ref": apa_full}
|
| 556 |
self.oa_calls += 1
|
| 557 |
+
abstract_index = item.get("abstract_inverted_index")
|
| 558 |
+
raw_abstract = None
|
| 559 |
+
if abstract_index:
|
| 560 |
+
word_index = [(pos, word) for word, positions in abstract_index.items() for pos in positions]
|
| 561 |
+
word_index.sort()
|
| 562 |
+
raw_abstract = " ".join([word for pos, word in word_index])
|
| 563 |
+
if not raw_abstract:
|
| 564 |
+
raw_abstract = self.fetch_s2_abstract(doi_clean, title)
|
| 565 |
+
abs_snippet = " ".join(str(raw_abstract).split()[:60]) + "..." if raw_abstract else "Empirical data available."
|
| 566 |
+
llm_rag_context.append(f"{cite_tag} Title: {title}. Focus: {abs_snippet}")
|
| 567 |
+
page_batch += 1
|
| 568 |
+
|
| 569 |
+
# S2补充检索(若不足)
|
| 570 |
+
if len(self.citation_map) < self.lit_target_papers:
|
| 571 |
+
st.warning(f"OpenAlex仅获取到{len(self.citation_map)}篇文献,启动S2补充检索...")
|
| 572 |
+
try:
|
| 573 |
+
headers = {"x-api-key": self.s2_key} if self.s2_key else {}
|
| 574 |
+
for term in search_terms:
|
| 575 |
+
if len(self.citation_map) >= self.lit_target_papers: break
|
| 576 |
+
params = {"query": term, "limit": 50, "fields": "title,authors,year,venue,doi,abstract"}
|
| 577 |
+
resp = self._requests_get_with_retry("https://api.semanticscholar.org/graph/v1/paper/search", headers=headers, params=params, timeout=15)
|
| 578 |
+
data = resp.json()
|
| 579 |
+
for paper in data.get("data", []):
|
| 580 |
+
if len(self.citation_map) >= self.lit_target_papers: break
|
| 581 |
+
title = paper.get("title")
|
| 582 |
+
if not title or not self._is_english_dominant(title): continue
|
| 583 |
+
doi_clean = paper.get("doi") or ""
|
| 584 |
+
if doi_clean in seen_dois: continue
|
| 585 |
+
seen_dois.add(doi_clean)
|
| 586 |
+
year = paper.get("year") or "n.d."
|
| 587 |
+
venue = paper.get("venue") or "Journal"
|
| 588 |
+
authors = paper.get("authors", [])
|
| 589 |
+
last_names = [a.get("name", "").split()[-1] if a.get("name") else "" for a in authors if a.get("name")]
|
| 590 |
+
if not last_names: continue
|
| 591 |
+
if len(last_names) == 1: in_text = f"({last_names[0]}, {year})"
|
| 592 |
+
elif len(last_names) == 2: in_text = f"({last_names[0]} & {last_names[1]}, {year})"
|
| 593 |
+
else: in_text = f"({last_names[0]} et al., {year})"
|
| 594 |
+
author_str = ", ".join([f"{a.get('name', '').split()[-1]}, {a.get('name', '')[0]}." if a.get('name') else "" for a in authors if a.get('name')])
|
| 595 |
+
apa_full = f"{author_str} ({year}). {title}. {venue}."
|
| 596 |
+
if doi_clean: apa_full += f" https://doi.org/{doi_clean}"
|
| 597 |
+
with self.lock:
|
| 598 |
+
cite_tag = f"[CITE-{len(self.citation_map)+1:02d}]"
|
| 599 |
+
self.citation_map[cite_tag] = {"in_text": in_text, "ref": apa_full}
|
| 600 |
+
self.s2_calls += 1
|
| 601 |
+
abs_snippet = paper.get("abstract", "Empirical data available.")[:200] + "..."
|
| 602 |
+
llm_rag_context.append(f"{cite_tag} Title: {title}. Focus: {abs_snippet}")
|
| 603 |
+
time.sleep(1)
|
| 604 |
+
except Exception as e:
|
| 605 |
+
st.error(f"S2补充检索失败: {e}")
|
| 606 |
+
|
| 607 |
+
st.success(f"最终获取文献数量: {len(self.citation_map)}")
|
| 608 |
return "\n".join(llm_rag_context)
|
| 609 |
|
| 610 |
def resolve_citations(self, text, quarantine=False):
|
|
|
|
| 613 |
return re.sub(r'\[CITE-\d+\]', '', text)
|
| 614 |
def replacer(match):
|
| 615 |
tag = match.group(0).strip()
|
| 616 |
+
with self.lock:
|
| 617 |
+
if tag in self.citation_map:
|
| 618 |
+
self.used_citations.add(tag)
|
| 619 |
+
return self.citation_map[tag]["in_text"]
|
| 620 |
return tag
|
| 621 |
return re.sub(r'\[CITE-\d+\]', replacer, text)
|
| 622 |
|
| 623 |
+
|
| 624 |
+
# ================= 3. 重构后的 DOCX 转换引擎 (mistune + python-docx) =================
|
| 625 |
+
class DocxRenderer(mistune.HTMLRenderer):
|
| 626 |
+
"""自定义 mistune 渲染器,将 Markdown AST 转换为 Word 文档元素"""
|
| 627 |
+
|
| 628 |
+
def __init__(self):
|
| 629 |
+
super().__init__()
|
| 630 |
+
self.doc = Document()
|
| 631 |
+
# 设置默认样式(安全回退)
|
| 632 |
+
try:
|
| 633 |
+
style = self.doc.styles['Normal']
|
| 634 |
+
style.font.name = 'Times New Roman'
|
| 635 |
+
style.font.size = Pt(12)
|
| 636 |
+
except:
|
| 637 |
+
pass
|
| 638 |
+
|
| 639 |
+
def heading(self, text, level):
|
| 640 |
+
"""处理标题 # ## ### ..."""
|
| 641 |
+
self.doc.add_heading(text, level=level)
|
| 642 |
+
return ''
|
| 643 |
+
|
| 644 |
+
def paragraph(self, text):
|
| 645 |
+
"""处理普通段落"""
|
| 646 |
+
if text.strip():
|
| 647 |
+
self.doc.add_paragraph(text)
|
| 648 |
+
return ''
|
| 649 |
+
|
| 650 |
+
def block_code(self, code, info=None):
|
| 651 |
+
"""处理代码块"""
|
| 652 |
+
p = self.doc.add_paragraph(code)
|
| 653 |
+
p.style = 'Normal'
|
| 654 |
+
try:
|
| 655 |
+
for run in p.runs:
|
| 656 |
+
run.font.name = 'Courier New'
|
| 657 |
+
run.font.size = Pt(11)
|
| 658 |
+
except:
|
| 659 |
+
pass
|
| 660 |
+
return ''
|
| 661 |
+
|
| 662 |
+
def block_quote(self, text):
|
| 663 |
+
"""处理引用块 >"""
|
| 664 |
+
p = self.doc.add_paragraph(text)
|
| 665 |
+
try:
|
| 666 |
+
p.style = 'Intense Quote' if 'Intense Quote' in self.doc.styles else 'Normal'
|
| 667 |
+
except:
|
| 668 |
+
pass
|
| 669 |
+
return ''
|
| 670 |
+
|
| 671 |
+
def list(self, text, ordered, **attrs):
|
| 672 |
+
"""处理列表容器"""
|
| 673 |
+
return text # 列表项的渲染由 list_item 直接处理
|
| 674 |
+
|
| 675 |
+
def list_item(self, text):
|
| 676 |
+
"""处理列表项"""
|
| 677 |
+
if text.strip():
|
| 678 |
+
# 简化:使用 Bullet 样式,实际可通过 ordered 参数区分,但 mistune 渲染方式限制,这里默认 Bullet
|
| 679 |
+
p = self.doc.add_paragraph(text)
|
| 680 |
+
p.style = 'List Bullet'
|
| 681 |
+
return ''
|
| 682 |
+
|
| 683 |
+
def table(self, text):
|
| 684 |
+
"""处理表格(mistune 将表格渲染为 HTML 字符串)"""
|
| 685 |
+
soup = BeautifulSoup(text, 'html.parser')
|
| 686 |
+
rows = soup.find_all('tr')
|
| 687 |
+
if not rows:
|
| 688 |
+
return ''
|
| 689 |
+
max_cols = 0
|
| 690 |
+
for row in rows:
|
| 691 |
+
cols = len(row.find_all(['td', 'th']))
|
| 692 |
+
max_cols = max(max_cols, cols)
|
| 693 |
+
if max_cols == 0:
|
| 694 |
+
return ''
|
| 695 |
+
table = self.doc.add_table(rows=len(rows), cols=max_cols)
|
| 696 |
+
try:
|
| 697 |
+
table.style = 'Table Grid'
|
| 698 |
+
except:
|
| 699 |
+
pass
|
| 700 |
+
for i, row in enumerate(rows):
|
| 701 |
+
cells = row.find_all(['td', 'th'])
|
| 702 |
+
for j, cell in enumerate(cells):
|
| 703 |
+
if j < max_cols:
|
| 704 |
+
table.cell(i, j).text = cell.get_text(strip=True)
|
| 705 |
+
self.doc.add_paragraph() # 表格后添加空行
|
| 706 |
+
return ''
|
| 707 |
+
|
| 708 |
+
def thematic_break(self):
|
| 709 |
+
"""水平线 ---"""
|
| 710 |
+
self.doc.add_paragraph('_' * 50)
|
| 711 |
+
return ''
|
| 712 |
+
|
| 713 |
+
def image(self, src, alt="", title=None):
|
| 714 |
+
"""图片(简单处理)"""
|
| 715 |
+
self.doc.add_paragraph(f"[Image: {alt or src}]")
|
| 716 |
+
return ''
|
| 717 |
+
|
| 718 |
+
|
| 719 |
+
def text_to_docx(markdown_content):
|
| 720 |
+
"""
|
| 721 |
+
使用 mistune 将 Markdown 字符串转换为 python-docx Document 对象的字节流
|
| 722 |
+
"""
|
| 723 |
+
try:
|
| 724 |
+
renderer = DocxRenderer()
|
| 725 |
+
markdown_parser = mistune.create_markdown(renderer=renderer)
|
| 726 |
+
markdown_parser(markdown_content)
|
| 727 |
+
doc = renderer.doc
|
| 728 |
+
except Exception as e:
|
| 729 |
+
# 若 AST 解析失败,回退为纯文本段落
|
| 730 |
+
doc = Document()
|
| 731 |
+
for line in markdown_content.split('\n'):
|
| 732 |
+
if line.strip():
|
| 733 |
+
doc.add_paragraph(line)
|
| 734 |
+
stream = io.BytesIO()
|
| 735 |
+
doc.save(stream)
|
| 736 |
+
stream.seek(0)
|
| 737 |
+
return stream
|
| 738 |
+
|
| 739 |
+
|
| 740 |
+
# ================= 4. UI 与执行调度 =================
|
| 741 |
if "is_running" not in st.session_state: st.session_state.is_running = False
|
| 742 |
if "start_pipeline" not in st.session_state: st.session_state.start_pipeline = False
|
| 743 |
if "zip_buffer" not in st.session_state: st.session_state.zip_buffer = None
|
|
|
|
| 799 |
render_monitor(agent, "Initialization", "并发文献抓取与补全", "正在执行 OpenAlex 检索,并按真实结果蓄水库存...")
|
| 800 |
refs_text = agent.fetch_references_parallel(topic)
|
| 801 |
|
| 802 |
+
if len(agent.citation_map) < 40:
|
| 803 |
+
st.error(f"最终文献数量仅 {len(agent.citation_map)} 篇,未达最低要求40篇。请尝试更换更宽泛的主题词。")
|
| 804 |
+
st.stop()
|
| 805 |
+
|
| 806 |
pb.progress(10, text="🧠 正在进行文献深度综合推演 (10%)...")
|
| 807 |
render_monitor(agent, "Module 1", "文献综合与间隙识��", "正在对文献进行深度综合...")
|
| 808 |
synthesis_result = agent.synthesize_literature(topic, refs_text)
|
| 809 |
|
|
|
|
| 810 |
pb.progress(12, text="⚓ 正在生成论文锚点(摘要与预期结论)...")
|
| 811 |
render_monitor(agent, "Anchor Generation", "摘要与结论锚点", "正在构建论文的核心知识锚点,确保全文逻辑一致...")
|
| 812 |
abstract_anchor, conclusion_anchor = agent.generate_anchors(topic, keywords, synthesis_result, refs_text)
|
|
|
|
| 852 |
trans_ph.info("⏳ 等待英文对应段落完成后执行...")
|
| 853 |
ui_blocks.append({"sec": sec, "en_ph": en_ph, "trans_ph": trans_ph})
|
| 854 |
|
| 855 |
+
full_draft_en = f"# {en_topic}\n\n## Abstract\n{abstract_anchor}\n\n## Keywords\n{en_keywords}\n\n"
|
| 856 |
+
full_draft_translated = f"# {en_topic}\n\n## 摘要\n{abstract_anchor}\n\n## 关键词\n{en_keywords}\n\n"
|
| 857 |
context_tail = ""
|
| 858 |
generation_aborted = False
|
| 859 |
|
| 860 |
all_cite_keys = list(agent.citation_map.keys())
|
| 861 |
valid_chunks = [s for s in adaptive_structure if not any(kw in s["section"].lower() for kw in ["abstract", "keyword", "摘要", "关键字"])]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 862 |
|
| 863 |
allocation_map = {}
|
| 864 |
current_cite_idx = 0
|
|
|
|
| 866 |
if idx == len(valid_chunks) - 1:
|
| 867 |
allocation_map[vc["display_name"]] = all_cite_keys[current_cite_idx:]
|
| 868 |
else:
|
| 869 |
+
num_cites = max(1, math.ceil(len(all_cite_keys) / len(valid_chunks)))
|
|
|
|
| 870 |
allocation_map[vc["display_name"]] = all_cite_keys[current_cite_idx : current_cite_idx + num_cites]
|
| 871 |
current_cite_idx += num_cites
|
| 872 |
|
|
|
|
| 884 |
quarantine_rule = "【CRITICAL】: NO IN-TEXT CITATIONS ALLOWED IN THIS SECTION."
|
| 885 |
else:
|
| 886 |
assigned_keys = allocation_map.get(display_name, [])
|
| 887 |
+
assigned_tags_str = ", ".join(assigned_keys) if assigned_keys else "any relevant citations"
|
| 888 |
+
quarantine_rule = f"""【CITATION GUIDANCE】: You are strongly encouraged to integrate as many of the following citations as appropriate: {assigned_tags_str}.
|
|
|
|
|
|
|
|
|
|
| 889 |
<AVAILABLE_CITATIONS>
|
| 890 |
{refs_text}
|
| 891 |
</AVAILABLE_CITATIONS>"""
|
|
|
|
|
|
|
| 892 |
|
| 893 |
heading_instruction = f"Output the section heading # {sec_title}." if sec["is_first"] else "DO NOT output the main heading. Seamlessly continue."
|
| 894 |
|
|
|
|
| 895 |
extra_directives = []
|
| 896 |
lower_sec = sec_title.lower()
|
| 897 |
if sec["is_method_or_result"]:
|
|
|
|
| 905 |
extra_directives.append(APP_CONFIG["conclusion_anchor_directive"])
|
| 906 |
|
| 907 |
theory_injection = "\n\n".join(extra_directives)
|
| 908 |
+
memory_context = agent._build_memory_context(max_tokens=1500)
|
|
|
|
| 909 |
memory_block = f"\n【GLOBAL PAPER MEMORY - ENSURE CONSISTENCY】\n{memory_context}\n" if memory_context else ""
|
| 910 |
|
| 911 |
+
# Token控制
|
| 912 |
+
sys_prompt_m3 = APP_CONFIG["sys_academic_writer"] + "\n" + APP_CONFIG["deep_refinement_directive"]
|
| 913 |
+
base_user = f"""Generate a polished, publication-ready section '{display_name}' (~{sec['target_words']} words).
|
| 914 |
+
{quarantine_rule}
|
| 915 |
+
{heading_instruction}
|
| 916 |
+
{theory_injection}
|
| 917 |
+
{memory_block}
|
| 918 |
+
Global Research Synthesis:
|
| 919 |
+
{synthesis_result}
|
| 920 |
+
Previous Context Tail:
|
| 921 |
+
...{context_tail}...
|
| 922 |
+
|
| 923 |
+
IMPORTANT: Output directly the final refined academic text. No meta-commentary. Include tables if appropriate.
|
| 924 |
+
"""
|
| 925 |
+
total_tokens = agent._count_tokens(sys_prompt_m3) + agent._count_tokens(base_user)
|
| 926 |
+
MAX_SAFE_TOKENS = 60000
|
| 927 |
+
if total_tokens > MAX_SAFE_TOKENS:
|
| 928 |
+
excess = total_tokens - MAX_SAFE_TOKENS
|
| 929 |
+
synthesis_tokens = agent._count_tokens(synthesis_result)
|
| 930 |
+
if synthesis_tokens > excess:
|
| 931 |
+
synthesis_result = agent._truncate_context(synthesis_result, synthesis_tokens - excess)
|
| 932 |
+
else:
|
| 933 |
+
memory_tokens = agent._count_tokens(memory_block)
|
| 934 |
+
agent._build_memory_context(max_tokens=max(500, memory_tokens - excess))
|
| 935 |
+
base_user = f"""Generate a polished, publication-ready section '{display_name}' (~{sec['target_words']} words).
|
| 936 |
{quarantine_rule}
|
| 937 |
{heading_instruction}
|
| 938 |
{theory_injection}
|
|
|
|
| 944 |
|
| 945 |
IMPORTANT: Output directly the final refined academic text. No meta-commentary. Include tables if appropriate.
|
| 946 |
"""
|
| 947 |
+
# 英文推演
|
| 948 |
+
render_monitor(agent, f"English Drafting ({current_step+1}/{total_steps})", display_name, "深度推演中...")
|
| 949 |
+
en_ph.info("🔄 正在生成高质量章节内容...")
|
| 950 |
+
try:
|
| 951 |
+
en_draft_stream = en_ph.write_stream(agent.stream_deepseek(sys_prompt_m3, base_user, max_tokens=6000))
|
| 952 |
en_draft_clean = re.sub(r'<think>.*?</think>', '', en_draft_stream, flags=re.DOTALL).strip()
|
| 953 |
en_resolved = agent.resolve_citations(en_draft_clean, quarantine=is_quarantined)
|
| 954 |
en_ph.markdown(en_resolved)
|
| 955 |
full_draft_en += en_resolved + "\n\n"
|
| 956 |
agent._extract_memory_from_text(en_draft_clean, sec_title)
|
| 957 |
+
words_tail = en_draft_clean.split()
|
| 958 |
+
context_tail = " ".join(words_tail[-200:]) if len(words_tail) > 200 else en_draft_clean
|
| 959 |
current_step += 1
|
| 960 |
percent = 15 + int((current_step / total_steps) * 80)
|
| 961 |
+
pb.progress(min(percent, 99), text=f"🇬🇧 {display_name} 英文定稿完成 ({percent}%)...")
|
| 962 |
except Exception as e:
|
| 963 |
error_msg = f"\n\n> ⚠️ [系统提示] 英文生成阶段异常中断:{str(e)}\n\n"
|
| 964 |
en_ph.error("🚨 网络异常,正在保存已生成内容...")
|
|
|
|
| 967 |
generation_aborted = True
|
| 968 |
break
|
| 969 |
|
| 970 |
+
# 翻译
|
| 971 |
render_monitor(agent, f"Translating ({current_step+1}/{total_steps})", display_name, "实时翻译为目标语言...")
|
| 972 |
trans_ph.empty()
|
| 973 |
try:
|
|
|
|
| 980 |
full_draft_translated += trans_resolved + "\n\n"
|
| 981 |
current_step += 1
|
| 982 |
percent = 15 + int((current_step / total_steps) * 80)
|
| 983 |
+
pb.progress(min(percent, 99), text=f"{target_flag} {display_name} 翻译完成 ({percent}%)...")
|
| 984 |
except Exception as e:
|
| 985 |
error_msg = f"\n\n> ⚠️ [系统提示] 翻译阶段异常中断:{str(e)}\n\n"
|
| 986 |
trans_ph.error("🚨 网络异常,正在保存已生成内容...")
|
|
|
|
| 990 |
|
| 991 |
if full_draft_en.strip():
|
| 992 |
pb.progress(96, text="📦 正在排版并打包无损双语 DOCX (96%)...")
|
| 993 |
+
|
| 994 |
+
# 确保文内引用数量达标
|
| 995 |
+
with agent.lock:
|
| 996 |
+
if len(agent.used_citations) < 40:
|
| 997 |
+
unused = [tag for tag in agent.citation_map if tag not in agent.used_citations]
|
| 998 |
+
needed = 40 - len(agent.used_citations)
|
| 999 |
+
for tag in unused[:needed]:
|
| 1000 |
+
agent.used_citations.add(tag)
|
| 1001 |
|
| 1002 |
used_tags = sorted(list(agent.used_citations))
|
| 1003 |
apa_refs_list = "\n\n".join([f"- {agent.citation_map[tag]['ref']}" for tag in used_tags]) if used_tags else "No references cited."
|
| 1004 |
full_draft_en += "\n\n# References\n" + apa_refs_list
|
| 1005 |
full_draft_translated += "\n\n# 参考文献 (References)\n" + apa_refs_list
|
| 1006 |
|
| 1007 |
+
render_monitor(agent, "Finalizing", "Word 排版引擎", "正在使用 mistune 引���生成高质量 DOCX...")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1008 |
|
| 1009 |
bj_time = datetime.now(timezone(timedelta(hours=8))).strftime("%Y%m%d_%H%M")
|
| 1010 |
lang_file_prefix = {"Simplified Chinese (简体中文)": "ZH", "Russian (Русский)": "RU", "Kazakh (Қазақша)": "KK"}.get(target_lang, "ZH")
|
|
|
|
| 1029 |
render_monitor(agent, "Task Complete", "✅ 完毕", "交织推演结束,所有文档已生成。")
|
| 1030 |
st.session_state.is_interrupted = False
|
| 1031 |
else:
|
| 1032 |
+
st.error("🚨 在进入实际写作前发生了核心错误,无内容可保存。")
|
| 1033 |
except Exception as e:
|
| 1034 |
st.error(f"🚨 核心初始化逻辑异常中断,信息: {str(e)}")
|
| 1035 |
finally:
|