Spaces:
Running
Running
Step 4A-6-C-R3.2.7 Long-Call Authority Draft UX Cancel Flow and Anonymous S2 Fix
#17
by GuXiaopeng - opened
- .gitattributes +0 -1
- __pycache__/app.cpython-313.pyc +0 -0
- __pycache__/config.cpython-313.pyc +0 -0
- __pycache__/desktop.ini +0 -2
- __pycache__/i18n.cpython-313.pyc +0 -0
- __pycache__/managed_auth.cpython-313.pyc +0 -0
- __pycache__/task_executor.cpython-313.pyc +0 -0
- app.py +50 -5
- config.py +9 -3
- core/__pycache__/checkpoint_store.cpython-313.pyc +0 -0
- core/__pycache__/desktop.ini +0 -2
- core/__pycache__/generation_runner.cpython-313.pyc +0 -0
- core/__pycache__/meta_extractor.cpython-313.pyc +0 -0
- core/__pycache__/pipeline_agent.cpython-313.pyc +0 -3
- core/__pycache__/production_guard.cpython-313.pyc +0 -0
- core/__pycache__/qualitative_engine.cpython-313.pyc +0 -0
- core/__pycache__/quantitative_engine.cpython-313.pyc +0 -0
- core/__pycache__/rag_engine.cpython-313.pyc +0 -0
- core/generation_runner.py +4 -1
- core/pipeline_agent.py +136 -86
- i18n.py +52 -16
- smartpaper_restore_bridge/__pycache__/__init__.cpython-313.pyc +0 -0
- smartpaper_restore_bridge/__pycache__/desktop.ini +0 -2
- smartpaper_restore_bridge/frontend/index.html +84 -3
- task_executor.py +50 -11
- utils/__pycache__/desktop.ini +0 -2
- utils/__pycache__/docx_builder.cpython-313.pyc +0 -0
- utils/__pycache__/file_parser.cpython-313.pyc +0 -0
.gitattributes
CHANGED
|
@@ -33,4 +33,3 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
-
core/__pycache__/pipeline_agent.cpython-313.pyc filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
__pycache__/app.cpython-313.pyc
DELETED
|
Binary file (50 kB)
|
|
|
__pycache__/config.cpython-313.pyc
DELETED
|
Binary file (23.2 kB)
|
|
|
__pycache__/desktop.ini
DELETED
|
@@ -1,2 +0,0 @@
|
|
| 1 |
-
[.ShellClassInfo]
|
| 2 |
-
IconResource=C:\Program Files\Google\Drive File Stream\129.0.1.0\GoogleDriveFS.exe,27
|
|
|
|
|
|
|
|
|
__pycache__/i18n.cpython-313.pyc
DELETED
|
Binary file (70.5 kB)
|
|
|
__pycache__/managed_auth.cpython-313.pyc
DELETED
|
Binary file (31 kB)
|
|
|
__pycache__/task_executor.cpython-313.pyc
DELETED
|
Binary file (31 kB)
|
|
|
app.py
CHANGED
|
@@ -320,6 +320,30 @@ def _task_preview_path(task_id: str) -> str:
|
|
| 320 |
return ""
|
| 321 |
|
| 322 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 323 |
def _task_output_path(task: dict) -> Path | None:
|
| 324 |
task_id = str(task.get("task_id") or "").strip()
|
| 325 |
filename = os.path.basename(str(task.get("output_filename") or "").strip())
|
|
@@ -498,7 +522,7 @@ def _process_pending_cancel() -> None:
|
|
| 498 |
return
|
| 499 |
result = cancel_generation_task(task_id)
|
| 500 |
if result.get("cancelled"):
|
| 501 |
-
|
| 502 |
st.session_state["sp_pending_cancel_task_id"] = ""
|
| 503 |
st.session_state["sp_generation_task"] = dict(result.get("task") or {}) or None
|
| 504 |
st.rerun()
|
|
@@ -659,6 +683,17 @@ def _watch_task_status_live(task: dict) -> dict | None:
|
|
| 659 |
"preview_path": _task_preview_path(task_id),
|
| 660 |
"expected_execution_epoch": int(task.get("execution_epoch") or 0),
|
| 661 |
"initial_checkpoint_seq": int(task.get("checkpoint_seq") or 0),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 662 |
},
|
| 663 |
key=f"sp_task_watch_{_safe_key(task_id)}",
|
| 664 |
)
|
|
@@ -705,12 +740,14 @@ def _render_checkpoint_preview(task: dict, lang_code: str) -> None:
|
|
| 705 |
"""Render one stable DOM target; browser preview updates mutate it in place."""
|
| 706 |
checkpoint = _load_confirmed_checkpoint(task)
|
| 707 |
draft = str((checkpoint or {}).get("full_draft_en") or "").strip()
|
| 708 |
-
|
|
|
|
|
|
|
| 709 |
st.markdown(tr(lang_code, "english_draft"))
|
| 710 |
st.markdown(
|
| 711 |
f"""
|
| 712 |
<div id='sp-live-manuscript-shell' style='border:1px solid #e5e7eb;border-radius:8px;padding:14px 16px;min-height:180px;max-height:68vh;overflow:auto;background:#ffffff;'>
|
| 713 |
-
<div id='sp-live-manuscript-preview' style='white-space:pre-wrap;overflow-wrap:anywhere;margin:0;font-family:inherit;line-height:1.72;color:#111827;'>{escaped}</div>
|
| 714 |
<span id='sp-live-manuscript-cursor' aria-hidden='true' style='display:inline-block;width:7px;height:1.05em;background:#111827;margin-left:2px;vertical-align:-0.12em;animation:spBlink 1s steps(1,end) infinite;'></span>
|
| 715 |
</div>
|
| 716 |
<style>@keyframes spBlink {{ 0%,49% {{opacity:1}} 50%,100% {{opacity:0}} }}</style>
|
|
@@ -877,7 +914,11 @@ def render_workspace_controls(user):
|
|
| 877 |
st.code(template, language="csv")
|
| 878 |
st.download_button(label=tr(lang_code, "download_amj_template"), data=template, file_name="AMJ_Data_Template.csv", mime="text/csv", use_container_width=True)
|
| 879 |
st.markdown("<div id='sp-generation-action-amj'></div>", unsafe_allow_html=True)
|
| 880 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
| 881 |
st.session_state["run_mode"] = "amj"
|
| 882 |
st.session_state["sp_pending_launch"] = {"mode": "amj", "idempotency_key": _new_generation_idempotency_key()}
|
| 883 |
st.session_state["sp_scroll_to_generation_action"] = True
|
|
@@ -895,7 +936,11 @@ def render_workspace_controls(user):
|
|
| 895 |
st.code(template, language="text")
|
| 896 |
st.download_button(label=tr(lang_code, "download_asq_template"), data=template, file_name="ASQ_Interview_Template.txt", mime="text/plain", use_container_width=True)
|
| 897 |
st.markdown("<div id='sp-generation-action-asq'></div>", unsafe_allow_html=True)
|
| 898 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
| 899 |
st.session_state["run_mode"] = "asq"
|
| 900 |
st.session_state["sp_pending_launch"] = {"mode": "asq", "idempotency_key": _new_generation_idempotency_key()}
|
| 901 |
st.session_state["sp_scroll_to_generation_action"] = True
|
|
|
|
| 320 |
return ""
|
| 321 |
|
| 322 |
|
| 323 |
+
def _cleanup_task_artifacts(task_id: str) -> None:
|
| 324 |
+
"""Delete local stage artifacts only after authoritative cancellation."""
|
| 325 |
+
task_dir = _task_dir(task_id)
|
| 326 |
+
token = ""
|
| 327 |
+
request_path = task_dir / "request.json"
|
| 328 |
+
if request_path.is_file():
|
| 329 |
+
try:
|
| 330 |
+
payload = json.loads(request_path.read_text(encoding="utf-8"))
|
| 331 |
+
token = str(payload.get("preview_token") or "").strip()
|
| 332 |
+
except Exception:
|
| 333 |
+
token = ""
|
| 334 |
+
if re.fullmatch(r"[A-Za-z0-9_-]{32,128}", token):
|
| 335 |
+
try:
|
| 336 |
+
(Path(__file__).resolve().parent / "static" / "previews" / f"{token}.json").unlink(missing_ok=True)
|
| 337 |
+
except Exception as exc:
|
| 338 |
+
logging.warning("[SP_UI] cancel_preview_cleanup_error task_id=%s error=%s", task_id, type(exc).__name__)
|
| 339 |
+
shutil.rmtree(task_dir, ignore_errors=True)
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
def _task_mode(task: dict) -> str:
|
| 343 |
+
mode = str((task or {}).get("mode") or st.session_state.get("run_mode") or "amj").strip().lower()
|
| 344 |
+
return "asq" if mode == "asq" else "amj"
|
| 345 |
+
|
| 346 |
+
|
| 347 |
def _task_output_path(task: dict) -> Path | None:
|
| 348 |
task_id = str(task.get("task_id") or "").strip()
|
| 349 |
filename = os.path.basename(str(task.get("output_filename") or "").strip())
|
|
|
|
| 522 |
return
|
| 523 |
result = cancel_generation_task(task_id)
|
| 524 |
if result.get("cancelled"):
|
| 525 |
+
_cleanup_task_artifacts(task_id)
|
| 526 |
st.session_state["sp_pending_cancel_task_id"] = ""
|
| 527 |
st.session_state["sp_generation_task"] = dict(result.get("task") or {}) or None
|
| 528 |
st.rerun()
|
|
|
|
| 683 |
"preview_path": _task_preview_path(task_id),
|
| 684 |
"expected_execution_epoch": int(task.get("execution_epoch") or 0),
|
| 685 |
"initial_checkpoint_seq": int(task.get("checkpoint_seq") or 0),
|
| 686 |
+
"best_effort_page_exit_cancel": True,
|
| 687 |
+
"draft_status_labels": {
|
| 688 |
+
"default": tr(str(st.session_state.get("sp_lang_code") or "zh"), "draft_preparing"),
|
| 689 |
+
"inputs_and_evidence": tr(str(st.session_state.get("sp_lang_code") or "zh"), "draft_stage_inputs"),
|
| 690 |
+
"S2 + OpenAlex": tr(str(st.session_state.get("sp_lang_code") or "zh"), "draft_stage_search"),
|
| 691 |
+
"literature_synthesis": tr(str(st.session_state.get("sp_lang_code") or "zh"), "draft_stage_synthesis"),
|
| 692 |
+
"research_logic": tr(str(st.session_state.get("sp_lang_code") or "zh"), "draft_stage_research_logic"),
|
| 693 |
+
"outline_planning": tr(str(st.session_state.get("sp_lang_code") or "zh"), "draft_stage_outline"),
|
| 694 |
+
"citation_planning": tr(str(st.session_state.get("sp_lang_code") or "zh"), "draft_stage_citation"),
|
| 695 |
+
"writing": tr(str(st.session_state.get("sp_lang_code") or "zh"), "draft_stage_writing"),
|
| 696 |
+
},
|
| 697 |
},
|
| 698 |
key=f"sp_task_watch_{_safe_key(task_id)}",
|
| 699 |
)
|
|
|
|
| 740 |
"""Render one stable DOM target; browser preview updates mutate it in place."""
|
| 741 |
checkpoint = _load_confirmed_checkpoint(task)
|
| 742 |
draft = str((checkpoint or {}).get("full_draft_en") or "").strip()
|
| 743 |
+
initial_text = draft if draft else tr(lang_code, "draft_preparing")
|
| 744 |
+
escaped = html.escape(initial_text)
|
| 745 |
+
placeholder = "0" if draft else "1"
|
| 746 |
st.markdown(tr(lang_code, "english_draft"))
|
| 747 |
st.markdown(
|
| 748 |
f"""
|
| 749 |
<div id='sp-live-manuscript-shell' style='border:1px solid #e5e7eb;border-radius:8px;padding:14px 16px;min-height:180px;max-height:68vh;overflow:auto;background:#ffffff;'>
|
| 750 |
+
<div id='sp-live-manuscript-preview' data-sp-placeholder='{placeholder}' style='white-space:pre-wrap;overflow-wrap:anywhere;margin:0;font-family:inherit;line-height:1.72;color:#111827;'>{escaped}</div>
|
| 751 |
<span id='sp-live-manuscript-cursor' aria-hidden='true' style='display:inline-block;width:7px;height:1.05em;background:#111827;margin-left:2px;vertical-align:-0.12em;animation:spBlink 1s steps(1,end) infinite;'></span>
|
| 752 |
</div>
|
| 753 |
<style>@keyframes spBlink {{ 0%,49% {{opacity:1}} 50%,100% {{opacity:0}} }}</style>
|
|
|
|
| 914 |
st.code(template, language="csv")
|
| 915 |
st.download_button(label=tr(lang_code, "download_amj_template"), data=template, file_name="AMJ_Data_Template.csv", mime="text/csv", use_container_width=True)
|
| 916 |
st.markdown("<div id='sp-generation-action-amj'></div>", unsafe_allow_html=True)
|
| 917 |
+
if active and _task_mode(task) == "amj":
|
| 918 |
+
if st.button(tr(lang_code, "cancel_generation"), key="cancel_amj"):
|
| 919 |
+
st.session_state["sp_pending_cancel_task_id"] = str(task.get("task_id") or "")
|
| 920 |
+
st.rerun()
|
| 921 |
+
elif st.button(tr(lang_code, "start_amj"), disabled=active, key="btn_amj"):
|
| 922 |
st.session_state["run_mode"] = "amj"
|
| 923 |
st.session_state["sp_pending_launch"] = {"mode": "amj", "idempotency_key": _new_generation_idempotency_key()}
|
| 924 |
st.session_state["sp_scroll_to_generation_action"] = True
|
|
|
|
| 936 |
st.code(template, language="text")
|
| 937 |
st.download_button(label=tr(lang_code, "download_asq_template"), data=template, file_name="ASQ_Interview_Template.txt", mime="text/plain", use_container_width=True)
|
| 938 |
st.markdown("<div id='sp-generation-action-asq'></div>", unsafe_allow_html=True)
|
| 939 |
+
if active and _task_mode(task) == "asq":
|
| 940 |
+
if st.button(tr(lang_code, "cancel_generation"), key="cancel_asq"):
|
| 941 |
+
st.session_state["sp_pending_cancel_task_id"] = str(task.get("task_id") or "")
|
| 942 |
+
st.rerun()
|
| 943 |
+
elif st.button(tr(lang_code, "start_asq"), disabled=active, key="btn_asq"):
|
| 944 |
st.session_state["run_mode"] = "asq"
|
| 945 |
st.session_state["sp_pending_launch"] = {"mode": "asq", "idempotency_key": _new_generation_idempotency_key()}
|
| 946 |
st.session_state["sp_scroll_to_generation_action"] = True
|
config.py
CHANGED
|
@@ -7,6 +7,12 @@ def load_config():
|
|
| 7 |
"lit_target_papers": int(os.environ.get("LIT_TARGET_PAPERS", "80")),
|
| 8 |
"lit_per_page": int(os.environ.get("LIT_PER_PAGE", "100")),
|
| 9 |
"lit_max_workers": int(os.environ.get("LIT_MAX_WORKERS", "5")),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
"chunk_words_default": int(os.environ.get("CHUNK_WORDS_DEFAULT", "1500")),
|
| 11 |
"target_words_min": 5000,
|
| 12 |
"target_words_default": 5000,
|
|
@@ -24,9 +30,9 @@ def load_config():
|
|
| 24 |
"rag_chunk_size": int(os.environ.get("RAG_CHUNK_SIZE", "3000")),
|
| 25 |
"rag_top_k": int(os.environ.get("RAG_TOP_K", "3")),
|
| 26 |
"max_extract_chars": int(os.environ.get("MAX_EXTRACT_CHARS", "150000")),
|
| 27 |
-
#
|
| 28 |
-
#
|
| 29 |
-
#
|
| 30 |
"api_timeouts": {
|
| 31 |
"standard": (15, 70),
|
| 32 |
"stream": (15, 70),
|
|
|
|
| 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 |
+
# R3.2.7: low-volume SmartPaper uses the public Semantic Scholar path
|
| 11 |
+
# by default. OpenAlex remains the primary resilient source.
|
| 12 |
+
"s2_anonymous_first": os.environ.get("S2_ANONYMOUS_FIRST", "true").strip().lower() in {"1", "true", "yes", "on"},
|
| 13 |
+
"s2_anonymous_min_interval_seconds": max(0.8, float(os.environ.get("S2_ANONYMOUS_MIN_INTERVAL_SECONDS", "1.10"))),
|
| 14 |
+
"citation_planner_batch_size": max(8, min(24, int(os.environ.get("CITATION_PLANNER_BATCH_SIZE", "18")))),
|
| 15 |
+
"citation_planner_max_tokens": max(1200, min(3200, int(os.environ.get("CITATION_PLANNER_MAX_TOKENS", "2200")))),
|
| 16 |
"chunk_words_default": int(os.environ.get("CHUNK_WORDS_DEFAULT", "1500")),
|
| 17 |
"target_words_min": 5000,
|
| 18 |
"target_words_default": 5000,
|
|
|
|
| 30 |
"rag_chunk_size": int(os.environ.get("RAG_CHUNK_SIZE", "3000")),
|
| 31 |
"rag_top_k": int(os.environ.get("RAG_TOP_K", "3")),
|
| 32 |
"max_extract_chars": int(os.environ.get("MAX_EXTRACT_CHARS", "150000")),
|
| 33 |
+
# Model requests may legitimately run longer than one Worker lease.
|
| 34 |
+
# The independent Executor renews authority continuously; model-token
|
| 35 |
+
# activity is observability only and never the liveness authority.
|
| 36 |
"api_timeouts": {
|
| 37 |
"standard": (15, 70),
|
| 38 |
"stream": (15, 70),
|
core/__pycache__/checkpoint_store.cpython-313.pyc
DELETED
|
Binary file (4.63 kB)
|
|
|
core/__pycache__/desktop.ini
DELETED
|
@@ -1,2 +0,0 @@
|
|
| 1 |
-
[.ShellClassInfo]
|
| 2 |
-
IconResource=C:\Program Files\Google\Drive File Stream\129.0.1.0\GoogleDriveFS.exe,27
|
|
|
|
|
|
|
|
|
core/__pycache__/generation_runner.cpython-313.pyc
DELETED
|
Binary file (35.9 kB)
|
|
|
core/__pycache__/meta_extractor.cpython-313.pyc
DELETED
|
Binary file (3.43 kB)
|
|
|
core/__pycache__/pipeline_agent.cpython-313.pyc
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:bb26d239e31ae5bca0996c1f27abaa2459fd38da852e452f089e784f3db5d269
|
| 3 |
-
size 169473
|
|
|
|
|
|
|
|
|
|
|
|
core/__pycache__/production_guard.cpython-313.pyc
DELETED
|
Binary file (11 kB)
|
|
|
core/__pycache__/qualitative_engine.cpython-313.pyc
DELETED
|
Binary file (9.81 kB)
|
|
|
core/__pycache__/quantitative_engine.cpython-313.pyc
DELETED
|
Binary file (10.1 kB)
|
|
|
core/__pycache__/rag_engine.cpython-313.pyc
DELETED
|
Binary file (4.36 kB)
|
|
|
core/generation_runner.py
CHANGED
|
@@ -326,7 +326,7 @@ class GenerationRunner:
|
|
| 326 |
|
| 327 |
adaptive_structure = state.get("adaptive_structure")
|
| 328 |
if not isinstance(adaptive_structure, list) or not adaptive_structure:
|
| 329 |
-
self._progress(14, "
|
| 330 |
research_logic_response = self._stream_text(
|
| 331 |
agent.stream_generate_research_logic(
|
| 332 |
topic, keywords, synthesis_result, refs_text,
|
|
@@ -340,6 +340,7 @@ class GenerationRunner:
|
|
| 340 |
raise RuntimeError("research_logic_contract_missing")
|
| 341 |
agent.global_memory["research_logic_contract"] = research_logic_contract
|
| 342 |
|
|
|
|
| 343 |
plan = agent.generate_outline(topic, keywords, mode=mode, data_metadata=data_metadata)
|
| 344 |
en_topic = str(plan.get("en_topic", topic))
|
| 345 |
structure = plan.get("structure", [])
|
|
@@ -354,6 +355,7 @@ class GenerationRunner:
|
|
| 354 |
jit_texts = agent.prefetch_jit_literature(topic, keywords, adaptive_structure)
|
| 355 |
if jit_texts:
|
| 356 |
rag_engine._initialize_chunks([jit_texts])
|
|
|
|
| 357 |
citation_plan = agent.build_claim_evidence_plan(
|
| 358 |
adaptive_structure, topic, synthesis_result, mode=mode, target_unique=CITATION_PLAN_TARGET
|
| 359 |
)
|
|
@@ -398,6 +400,7 @@ class GenerationRunner:
|
|
| 398 |
)
|
| 399 |
citation_plan = state.get("citation_plan") if isinstance(state.get("citation_plan"), dict) else {}
|
| 400 |
if not citation_plan:
|
|
|
|
| 401 |
citation_plan = agent.build_claim_evidence_plan(
|
| 402 |
adaptive_structure, topic, synthesis_result, mode=mode, target_unique=CITATION_PLAN_TARGET
|
| 403 |
)
|
|
|
|
| 326 |
|
| 327 |
adaptive_structure = state.get("adaptive_structure")
|
| 328 |
if not isinstance(adaptive_structure, list) or not adaptive_structure:
|
| 329 |
+
self._progress(14, "research_logic")
|
| 330 |
research_logic_response = self._stream_text(
|
| 331 |
agent.stream_generate_research_logic(
|
| 332 |
topic, keywords, synthesis_result, refs_text,
|
|
|
|
| 340 |
raise RuntimeError("research_logic_contract_missing")
|
| 341 |
agent.global_memory["research_logic_contract"] = research_logic_contract
|
| 342 |
|
| 343 |
+
self._progress(14, "outline_planning")
|
| 344 |
plan = agent.generate_outline(topic, keywords, mode=mode, data_metadata=data_metadata)
|
| 345 |
en_topic = str(plan.get("en_topic", topic))
|
| 346 |
structure = plan.get("structure", [])
|
|
|
|
| 355 |
jit_texts = agent.prefetch_jit_literature(topic, keywords, adaptive_structure)
|
| 356 |
if jit_texts:
|
| 357 |
rag_engine._initialize_chunks([jit_texts])
|
| 358 |
+
self._progress(15, "citation_planning")
|
| 359 |
citation_plan = agent.build_claim_evidence_plan(
|
| 360 |
adaptive_structure, topic, synthesis_result, mode=mode, target_unique=CITATION_PLAN_TARGET
|
| 361 |
)
|
|
|
|
| 400 |
)
|
| 401 |
citation_plan = state.get("citation_plan") if isinstance(state.get("citation_plan"), dict) else {}
|
| 402 |
if not citation_plan:
|
| 403 |
+
self._progress(15, "citation_planning")
|
| 404 |
citation_plan = agent.build_claim_evidence_plan(
|
| 405 |
adaptive_structure, topic, synthesis_result, mode=mode, target_unique=CITATION_PLAN_TARGET
|
| 406 |
)
|
core/pipeline_agent.py
CHANGED
|
@@ -66,6 +66,8 @@ class SSciPipelineAgent:
|
|
| 66 |
# exposed to the process as environment variables; Streamlit is not a
|
| 67 |
# dependency of the executor or pipeline.
|
| 68 |
self.s2_key = str(os.environ.get("S2_API_KEY", s2_key) or "").strip()
|
|
|
|
|
|
|
| 69 |
self.openalex_key = str(os.environ.get("OA_API_KEY", "") or "").strip()
|
| 70 |
self.openalex_email = str(APP_CONFIG.get("openalex_email") or "").strip()
|
| 71 |
|
|
@@ -90,7 +92,7 @@ class SSciPipelineAgent:
|
|
| 90 |
"key_present": bool(self.s2_key) if source == "s2" else bool(self.openalex_key),
|
| 91 |
"authenticated_status": None,
|
| 92 |
"anonymous_status": None,
|
| 93 |
-
"access_mode": "key" if source == "s2" and self.s2_key else "anonymous" if source == "s2" else "api",
|
| 94 |
}
|
| 95 |
for source in ("s2", "oa")
|
| 96 |
}
|
|
@@ -100,9 +102,11 @@ class SSciPipelineAgent:
|
|
| 100 |
# "key" means use the configured key, "anonymous" means a 401/403 key
|
| 101 |
# rejection was recovered by an anonymous probe, and "blocked" means
|
| 102 |
# both authenticated and anonymous access were rejected.
|
| 103 |
-
self._s2_access_mode = "key" if self.s2_key else "anonymous"
|
| 104 |
self._s2_probe_lock = threading.Lock()
|
| 105 |
-
self._s2_probe_complete = not bool(self.s2_key)
|
|
|
|
|
|
|
| 106 |
self.s2_fused = False
|
| 107 |
self.oa_fused = False
|
| 108 |
self.citation_map = {}
|
|
@@ -282,9 +286,13 @@ class SSciPipelineAgent:
|
|
| 282 |
self.citation_audit_log = list(state_dict.get("citation_audit_log", []))
|
| 283 |
self.global_memory = state_dict.get("global_memory", self.global_memory)
|
| 284 |
loaded_mode = str(state_dict.get("s2_access_mode") or "").strip().lower()
|
| 285 |
-
if
|
| 286 |
-
self._s2_access_mode =
|
| 287 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
logging.info(f"🔄 状态恢复成功: DS API={self.ds_calls}, 现有文献={len(self.citation_map)}")
|
| 289 |
|
| 290 |
def _requests_get_with_retry(self, url, headers=None, params=None, timeout=None):
|
|
@@ -1590,6 +1598,30 @@ Return ONLY a valid JSON array of strings."""
|
|
| 1590 |
self.oa_calls = int(self.source_metrics['oa']['eligible_result_count'])
|
| 1591 |
return merged
|
| 1592 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1593 |
def _s2_worker(self, term, offset):
|
| 1594 |
self._check_cancelled()
|
| 1595 |
query = self._normalize_search_query(term)
|
|
@@ -1614,11 +1646,7 @@ Return ONLY a valid JSON array of strings."""
|
|
| 1614 |
try:
|
| 1615 |
access_mode = self._s2_access_mode
|
| 1616 |
headers = {'x-api-key': self.s2_key} if access_mode == 'key' and self.s2_key else {}
|
| 1617 |
-
response = self.
|
| 1618 |
-
f"{self.s2_api_url}/paper/search",
|
| 1619 |
-
headers=headers,
|
| 1620 |
-
params=params,
|
| 1621 |
-
)
|
| 1622 |
status = int(response.status_code)
|
| 1623 |
self._source_metric_set('s2', 'last_status_code', status)
|
| 1624 |
if access_mode == 'key':
|
|
@@ -1633,11 +1661,7 @@ Return ONLY a valid JSON array of strings."""
|
|
| 1633 |
if not self._s2_probe_complete:
|
| 1634 |
self._s2_probe_complete = True
|
| 1635 |
self._source_metric_add('s2', 'request_count')
|
| 1636 |
-
anonymous = self.
|
| 1637 |
-
f"{self.s2_api_url}/paper/search",
|
| 1638 |
-
headers={},
|
| 1639 |
-
params=params,
|
| 1640 |
-
)
|
| 1641 |
anonymous_status = int(anonymous.status_code)
|
| 1642 |
self._source_metric_set('s2', 'anonymous_status', anonymous_status)
|
| 1643 |
logging.warning(
|
|
@@ -1655,9 +1679,7 @@ Return ONLY a valid JSON array of strings."""
|
|
| 1655 |
self._source_metric_set('s2', 'access_mode', 'blocked')
|
| 1656 |
elif self._s2_access_mode == 'anonymous':
|
| 1657 |
self._source_metric_add('s2', 'request_count')
|
| 1658 |
-
response = self.
|
| 1659 |
-
f"{self.s2_api_url}/paper/search", headers={}, params=params
|
| 1660 |
-
)
|
| 1661 |
status = int(response.status_code)
|
| 1662 |
self._source_metric_set('s2', 'anonymous_status', status)
|
| 1663 |
self._source_metric_set('s2', 'last_status_code', status)
|
|
@@ -1868,8 +1890,9 @@ Return ONLY a valid JSON array of strings."""
|
|
| 1868 |
self.source_metrics[source]['status'] = 'not_started'
|
| 1869 |
self.source_metrics[source]['authenticated_status'] = None
|
| 1870 |
self.source_metrics[source]['anonymous_status'] = None
|
| 1871 |
-
self._s2_access_mode = 'key' if self.s2_key else 'anonymous'
|
| 1872 |
-
self._s2_probe_complete = not bool(self.s2_key)
|
|
|
|
| 1873 |
self.source_metrics['s2']['key_present'] = bool(self.s2_key)
|
| 1874 |
self.source_metrics['s2']['access_mode'] = self._s2_access_mode
|
| 1875 |
self.s2_calls = self.oa_calls = 0
|
|
@@ -2000,12 +2023,15 @@ Return ONLY a valid JSON array of strings."""
|
|
| 2000 |
return {word for word in words if word not in stopwords}
|
| 2001 |
|
| 2002 |
def build_claim_evidence_plan(self, tasks, topic, synthesis_result, mode='amj', target_unique=60):
|
| 2003 |
-
"""
|
| 2004 |
-
|
| 2005 |
-
This replaces position-based
|
| 2006 |
-
|
| 2007 |
-
|
| 2008 |
-
|
|
|
|
|
|
|
|
|
|
| 2009 |
"""
|
| 2010 |
mode = 'asq' if str(mode).lower() == 'asq' else 'amj'
|
| 2011 |
allowed_ids = (
|
|
@@ -2058,41 +2084,71 @@ Return ONLY a valid JSON array of strings."""
|
|
| 2058 |
if sum(capacities.values()) >= target:
|
| 2059 |
break
|
| 2060 |
|
| 2061 |
-
|
| 2062 |
-
|
| 2063 |
-
|
| 2064 |
-
|
| 2065 |
-
|
| 2066 |
-
|
| 2067 |
-
|
| 2068 |
-
system_prompt =
|
| 2069 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2070 |
JSON schema:
|
| 2071 |
{{"assignments":[{{"tag":"[CITE-01]","section":"exact display_name from sections","claim":"one directly supported external/theoretical/methodological claim"}}]}}
|
| 2072 |
-
|
| 2073 |
INPUT:
|
| 2074 |
{json.dumps(payload, ensure_ascii=False)}"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2075 |
|
| 2076 |
-
|
| 2077 |
-
|
| 2078 |
-
raw = self.call_deepseek(
|
| 2079 |
-
system_prompt,
|
| 2080 |
-
user_prompt + ("\nPrevious attempt was incomplete. Return a fuller plan while keeping every claim directly evidence-supported." if attempt else ""),
|
| 2081 |
-
max_tokens=8000,
|
| 2082 |
-
temp=0.1 + attempt * 0.1,
|
| 2083 |
-
model_role='reasoning',
|
| 2084 |
-
thinking=True,
|
| 2085 |
-
allow_fallback=False,
|
| 2086 |
-
)
|
| 2087 |
-
parsed = self._extract_json_from_text(raw)
|
| 2088 |
-
rows = parsed.get('assignments') if isinstance(parsed, dict) else None
|
| 2089 |
-
if not isinstance(rows, list):
|
| 2090 |
-
continue
|
| 2091 |
-
allowed_sections = {item['name'] for item in sections}
|
| 2092 |
-
valid_tags = set(self.citation_map)
|
| 2093 |
-
unique_tags = set()
|
| 2094 |
-
cleaned = []
|
| 2095 |
-
section_loads = {item['name']: 0 for item in sections}
|
| 2096 |
for row in rows:
|
| 2097 |
if not isinstance(row, dict):
|
| 2098 |
continue
|
|
@@ -2101,32 +2157,31 @@ INPUT:
|
|
| 2101 |
tag = f'[{tag}]'
|
| 2102 |
section = str(row.get('section') or '').strip()
|
| 2103 |
claim = re.sub(r'\s+', ' ', str(row.get('claim') or '')).strip()
|
| 2104 |
-
if
|
| 2105 |
-
|
| 2106 |
-
|
|
|
|
|
|
|
| 2107 |
continue
|
| 2108 |
-
# Lightweight lexical sanity check. The semantic audit remains
|
| 2109 |
-
# authoritative later, but a zero-overlap plan is rejected now.
|
| 2110 |
evidence = str((self.citation_map.get(tag) or {}).get('evidence') or '')
|
| 2111 |
-
|
| 2112 |
-
if len(overlap) < 2:
|
| 2113 |
continue
|
| 2114 |
-
|
| 2115 |
-
|
| 2116 |
-
section_loads[section] =
|
| 2117 |
-
|
| 2118 |
-
best =
|
| 2119 |
-
|
| 2120 |
-
|
|
|
|
|
|
|
|
|
|
| 2121 |
|
| 2122 |
-
#
|
| 2123 |
-
#
|
| 2124 |
-
#
|
| 2125 |
-
# still requires nonempty abstract evidence and chooses the manuscript
|
| 2126 |
-
# unit by lexical relevance, while each source remains unique.
|
| 2127 |
if len(best) < target:
|
| 2128 |
-
|
| 2129 |
-
loads = {item['name']: sum(1 for row in best if row['section'] == item['name']) for item in sections}
|
| 2130 |
for candidate in candidates:
|
| 2131 |
if candidate['tag'] in used_tags:
|
| 2132 |
continue
|
|
@@ -2147,15 +2202,10 @@ INPUT:
|
|
| 2147 |
name = section['name']
|
| 2148 |
if loads[name] >= capacities.get(name, target):
|
| 2149 |
continue
|
| 2150 |
-
section_terms = self._citation_terms(
|
| 2151 |
-
name + ' ' + section['role']
|
| 2152 |
-
)
|
| 2153 |
overlap = len(evidence_terms & section_terms)
|
| 2154 |
if overlap <= 0:
|
| 2155 |
continue
|
| 2156 |
-
# Relevance is primary; normalized load prevents one unit
|
| 2157 |
-
# from absorbing the full citation plan when several units
|
| 2158 |
-
# are genuinely relevant.
|
| 2159 |
load_ratio = loads[name] / max(1, capacities.get(name, 1))
|
| 2160 |
ranked.append((overlap, -load_ratio, section['target_words'], name))
|
| 2161 |
if not ranked:
|
|
@@ -2177,8 +2227,8 @@ INPUT:
|
|
| 2177 |
'evidence': re.sub(r'\s+', ' ', str(meta.get('evidence') or '')).strip()[:900],
|
| 2178 |
})
|
| 2179 |
logging.info(
|
| 2180 |
-
'Citation-first plan built: target=%s planned=%s sections=%s',
|
| 2181 |
-
target, len(best), sum(1 for items in plan.values() if items),
|
| 2182 |
)
|
| 2183 |
return plan
|
| 2184 |
|
|
|
|
| 66 |
# exposed to the process as environment variables; Streamlit is not a
|
| 67 |
# dependency of the executor or pipeline.
|
| 68 |
self.s2_key = str(os.environ.get("S2_API_KEY", s2_key) or "").strip()
|
| 69 |
+
self.s2_anonymous_first = bool(APP_CONFIG.get("s2_anonymous_first", True))
|
| 70 |
+
self.s2_anonymous_min_interval_seconds = float(APP_CONFIG.get("s2_anonymous_min_interval_seconds", 1.10))
|
| 71 |
self.openalex_key = str(os.environ.get("OA_API_KEY", "") or "").strip()
|
| 72 |
self.openalex_email = str(APP_CONFIG.get("openalex_email") or "").strip()
|
| 73 |
|
|
|
|
| 92 |
"key_present": bool(self.s2_key) if source == "s2" else bool(self.openalex_key),
|
| 93 |
"authenticated_status": None,
|
| 94 |
"anonymous_status": None,
|
| 95 |
+
"access_mode": ("anonymous" if self.s2_anonymous_first else "key") if source == "s2" and self.s2_key else "anonymous" if source == "s2" else "api",
|
| 96 |
}
|
| 97 |
for source in ("s2", "oa")
|
| 98 |
}
|
|
|
|
| 102 |
# "key" means use the configured key, "anonymous" means a 401/403 key
|
| 103 |
# rejection was recovered by an anonymous probe, and "blocked" means
|
| 104 |
# both authenticated and anonymous access were rejected.
|
| 105 |
+
self._s2_access_mode = "anonymous" if self.s2_anonymous_first else ("key" if self.s2_key else "anonymous")
|
| 106 |
self._s2_probe_lock = threading.Lock()
|
| 107 |
+
self._s2_probe_complete = self.s2_anonymous_first or not bool(self.s2_key)
|
| 108 |
+
self._s2_request_lock = threading.Lock()
|
| 109 |
+
self._s2_last_request_at = 0.0
|
| 110 |
self.s2_fused = False
|
| 111 |
self.oa_fused = False
|
| 112 |
self.citation_map = {}
|
|
|
|
| 286 |
self.citation_audit_log = list(state_dict.get("citation_audit_log", []))
|
| 287 |
self.global_memory = state_dict.get("global_memory", self.global_memory)
|
| 288 |
loaded_mode = str(state_dict.get("s2_access_mode") or "").strip().lower()
|
| 289 |
+
if self.s2_anonymous_first:
|
| 290 |
+
self._s2_access_mode = "anonymous"
|
| 291 |
+
self._s2_probe_complete = True
|
| 292 |
+
else:
|
| 293 |
+
if loaded_mode in {"key", "anonymous", "blocked"}:
|
| 294 |
+
self._s2_access_mode = loaded_mode
|
| 295 |
+
self._s2_probe_complete = bool(state_dict.get("s2_probe_complete", self._s2_probe_complete))
|
| 296 |
logging.info(f"🔄 状态恢复成功: DS API={self.ds_calls}, 现有文献={len(self.citation_map)}")
|
| 297 |
|
| 298 |
def _requests_get_with_retry(self, url, headers=None, params=None, timeout=None):
|
|
|
|
| 1598 |
self.oa_calls = int(self.source_metrics['oa']['eligible_result_count'])
|
| 1599 |
return merged
|
| 1600 |
|
| 1601 |
+
def _s2_get(self, *, params, headers=None):
|
| 1602 |
+
"""Issue an S2 request with public-endpoint pacing when anonymous.
|
| 1603 |
+
|
| 1604 |
+
SmartPaper is low-volume, so one anonymous S2 request at a time is
|
| 1605 |
+
preferable to parallel bursts that trigger the shared public 429 pool.
|
| 1606 |
+
OpenAlex remains independently concurrent.
|
| 1607 |
+
"""
|
| 1608 |
+
normalized_headers = dict(headers or {})
|
| 1609 |
+
anonymous = not bool(normalized_headers.get('x-api-key'))
|
| 1610 |
+
if not anonymous:
|
| 1611 |
+
return self._requests_get_with_retry(
|
| 1612 |
+
f"{self.s2_api_url}/paper/search", headers=normalized_headers, params=params
|
| 1613 |
+
)
|
| 1614 |
+
with self._s2_request_lock:
|
| 1615 |
+
elapsed = time.monotonic() - float(self._s2_last_request_at or 0.0)
|
| 1616 |
+
wait = max(0.0, self.s2_anonymous_min_interval_seconds - elapsed)
|
| 1617 |
+
if wait > 0:
|
| 1618 |
+
time.sleep(wait)
|
| 1619 |
+
response = self._requests_get_with_retry(
|
| 1620 |
+
f"{self.s2_api_url}/paper/search", headers={}, params=params
|
| 1621 |
+
)
|
| 1622 |
+
self._s2_last_request_at = time.monotonic()
|
| 1623 |
+
return response
|
| 1624 |
+
|
| 1625 |
def _s2_worker(self, term, offset):
|
| 1626 |
self._check_cancelled()
|
| 1627 |
query = self._normalize_search_query(term)
|
|
|
|
| 1646 |
try:
|
| 1647 |
access_mode = self._s2_access_mode
|
| 1648 |
headers = {'x-api-key': self.s2_key} if access_mode == 'key' and self.s2_key else {}
|
| 1649 |
+
response = self._s2_get(params=params, headers=headers)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1650 |
status = int(response.status_code)
|
| 1651 |
self._source_metric_set('s2', 'last_status_code', status)
|
| 1652 |
if access_mode == 'key':
|
|
|
|
| 1661 |
if not self._s2_probe_complete:
|
| 1662 |
self._s2_probe_complete = True
|
| 1663 |
self._source_metric_add('s2', 'request_count')
|
| 1664 |
+
anonymous = self._s2_get(params=params, headers={})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1665 |
anonymous_status = int(anonymous.status_code)
|
| 1666 |
self._source_metric_set('s2', 'anonymous_status', anonymous_status)
|
| 1667 |
logging.warning(
|
|
|
|
| 1679 |
self._source_metric_set('s2', 'access_mode', 'blocked')
|
| 1680 |
elif self._s2_access_mode == 'anonymous':
|
| 1681 |
self._source_metric_add('s2', 'request_count')
|
| 1682 |
+
response = self._s2_get(params=params, headers={})
|
|
|
|
|
|
|
| 1683 |
status = int(response.status_code)
|
| 1684 |
self._source_metric_set('s2', 'anonymous_status', status)
|
| 1685 |
self._source_metric_set('s2', 'last_status_code', status)
|
|
|
|
| 1890 |
self.source_metrics[source]['status'] = 'not_started'
|
| 1891 |
self.source_metrics[source]['authenticated_status'] = None
|
| 1892 |
self.source_metrics[source]['anonymous_status'] = None
|
| 1893 |
+
self._s2_access_mode = 'anonymous' if self.s2_anonymous_first else ('key' if self.s2_key else 'anonymous')
|
| 1894 |
+
self._s2_probe_complete = self.s2_anonymous_first or not bool(self.s2_key)
|
| 1895 |
+
self._s2_last_request_at = 0.0
|
| 1896 |
self.source_metrics['s2']['key_present'] = bool(self.s2_key)
|
| 1897 |
self.source_metrics['s2']['access_mode'] = self._s2_access_mode
|
| 1898 |
self.s2_calls = self.oa_calls = 0
|
|
|
|
| 2023 |
return {word for word in words if word not in stopwords}
|
| 2024 |
|
| 2025 |
def build_claim_evidence_plan(self, tasks, topic, synthesis_result, mode='amj', target_unique=60):
|
| 2026 |
+
"""Build a bounded citation-first plan without one oversized model call.
|
| 2027 |
+
|
| 2028 |
+
This replaces position-based citation allocation. R3.2.7 plans sources
|
| 2029 |
+
in small independent batches. Each batch sees only
|
| 2030 |
+
a bounded evidence packet and may fail without invalidating the whole
|
| 2031 |
+
task; deterministic evidence-derived recovery then fills only remaining
|
| 2032 |
+
gaps. This prevents the former 80-source / 8000-token planner request
|
| 2033 |
+
from holding executor authority for minutes or ending at
|
| 2034 |
+
``finish_reason='length'``.
|
| 2035 |
"""
|
| 2036 |
mode = 'asq' if str(mode).lower() == 'asq' else 'amj'
|
| 2037 |
allowed_ids = (
|
|
|
|
| 2084 |
if sum(capacities.values()) >= target:
|
| 2085 |
break
|
| 2086 |
|
| 2087 |
+
allowed_sections = {item['name'] for item in sections}
|
| 2088 |
+
valid_tags = set(self.citation_map)
|
| 2089 |
+
section_loads = {item['name']: 0 for item in sections}
|
| 2090 |
+
best = []
|
| 2091 |
+
used_tags = set()
|
| 2092 |
+
batch_size = int(APP_CONFIG.get('citation_planner_batch_size', 18))
|
| 2093 |
+
planner_max_tokens = int(APP_CONFIG.get('citation_planner_max_tokens', 2200))
|
| 2094 |
+
system_prompt = (
|
| 2095 |
+
"You are a rigorous claim-evidence planner for a top management journal manuscript. "
|
| 2096 |
+
"Use only the supplied title/abstract evidence. Never use a source to support this study's "
|
| 2097 |
+
"own sample, coefficients, events, quotations, or findings. Each source may be assigned at "
|
| 2098 |
+
"most once. Each planned claim must be narrow enough that the supplied evidence directly "
|
| 2099 |
+
"supports it. Do not manufacture a claim merely to hit a quota. Return only valid JSON."
|
| 2100 |
+
)
|
| 2101 |
+
|
| 2102 |
+
for batch_index in range(0, len(candidates), batch_size):
|
| 2103 |
+
if len(best) >= target:
|
| 2104 |
+
break
|
| 2105 |
+
self._check_cancelled()
|
| 2106 |
+
batch = [row for row in candidates[batch_index:batch_index + batch_size] if row['tag'] not in used_tags]
|
| 2107 |
+
if not batch:
|
| 2108 |
+
continue
|
| 2109 |
+
remaining = target - len(best)
|
| 2110 |
+
payload = {
|
| 2111 |
+
'topic': str(topic or '')[:700],
|
| 2112 |
+
'mode': mode,
|
| 2113 |
+
'remaining_unique_sources': remaining,
|
| 2114 |
+
'sections': [
|
| 2115 |
+
dict(
|
| 2116 |
+
item,
|
| 2117 |
+
remaining_capacity=max(0, capacities[item['name']] - section_loads[item['name']]),
|
| 2118 |
+
)
|
| 2119 |
+
for item in sections
|
| 2120 |
+
],
|
| 2121 |
+
'sources': batch,
|
| 2122 |
+
}
|
| 2123 |
+
user_prompt = f"""Create one bounded claim-evidence batch.
|
| 2124 |
JSON schema:
|
| 2125 |
{{"assignments":[{{"tag":"[CITE-01]","section":"exact display_name from sections","claim":"one directly supported external/theoretical/methodological claim"}}]}}
|
| 2126 |
+
Use only tags supplied in this batch. Assign a source only when its evidence directly supports a useful claim for one permitted section. Do not assign literature to Results/Findings or Conclusion.
|
| 2127 |
INPUT:
|
| 2128 |
{json.dumps(payload, ensure_ascii=False)}"""
|
| 2129 |
+
rows = []
|
| 2130 |
+
try:
|
| 2131 |
+
raw = self.call_deepseek(
|
| 2132 |
+
system_prompt,
|
| 2133 |
+
user_prompt,
|
| 2134 |
+
max_tokens=planner_max_tokens,
|
| 2135 |
+
temp=0.1,
|
| 2136 |
+
model_role='reasoning',
|
| 2137 |
+
thinking=False,
|
| 2138 |
+
allow_fallback=False,
|
| 2139 |
+
)
|
| 2140 |
+
parsed = self._extract_json_from_text(raw)
|
| 2141 |
+
parsed_rows = parsed.get('assignments') if isinstance(parsed, dict) else None
|
| 2142 |
+
if isinstance(parsed_rows, list):
|
| 2143 |
+
rows = parsed_rows
|
| 2144 |
+
except Exception as exc:
|
| 2145 |
+
logging.warning(
|
| 2146 |
+
'Citation planner batch fallback: batch=%s size=%s error=%s',
|
| 2147 |
+
batch_index // batch_size + 1, len(batch), type(exc).__name__,
|
| 2148 |
+
)
|
| 2149 |
|
| 2150 |
+
batch_tags = {item['tag'] for item in batch}
|
| 2151 |
+
accepted = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2152 |
for row in rows:
|
| 2153 |
if not isinstance(row, dict):
|
| 2154 |
continue
|
|
|
|
| 2157 |
tag = f'[{tag}]'
|
| 2158 |
section = str(row.get('section') or '').strip()
|
| 2159 |
claim = re.sub(r'\s+', ' ', str(row.get('claim') or '')).strip()
|
| 2160 |
+
if (
|
| 2161 |
+
tag not in valid_tags or tag not in batch_tags or tag in used_tags
|
| 2162 |
+
or section not in allowed_sections or len(claim.split()) < 5
|
| 2163 |
+
or section_loads.get(section, 0) >= capacities.get(section, target)
|
| 2164 |
+
):
|
| 2165 |
continue
|
|
|
|
|
|
|
| 2166 |
evidence = str((self.citation_map.get(tag) or {}).get('evidence') or '')
|
| 2167 |
+
if len(self._citation_terms(claim) & self._citation_terms(evidence)) < 2:
|
|
|
|
| 2168 |
continue
|
| 2169 |
+
used_tags.add(tag)
|
| 2170 |
+
best.append({'tag': tag, 'section': section, 'claim': claim})
|
| 2171 |
+
section_loads[section] += 1
|
| 2172 |
+
accepted += 1
|
| 2173 |
+
if len(best) >= target:
|
| 2174 |
+
break
|
| 2175 |
+
logging.info(
|
| 2176 |
+
'Citation planner batch complete: batch=%s candidates=%s accepted=%s total=%s/%s',
|
| 2177 |
+
batch_index // batch_size + 1, len(batch), accepted, len(best), target,
|
| 2178 |
+
)
|
| 2179 |
|
| 2180 |
+
# Evidence-derived recovery is deliberately local and conservative. It
|
| 2181 |
+
# never invents a new empirical fact: the claim is copied from abstract
|
| 2182 |
+
# evidence, and the section is selected by lexical relevance + capacity.
|
|
|
|
|
|
|
| 2183 |
if len(best) < target:
|
| 2184 |
+
loads = dict(section_loads)
|
|
|
|
| 2185 |
for candidate in candidates:
|
| 2186 |
if candidate['tag'] in used_tags:
|
| 2187 |
continue
|
|
|
|
| 2202 |
name = section['name']
|
| 2203 |
if loads[name] >= capacities.get(name, target):
|
| 2204 |
continue
|
| 2205 |
+
section_terms = self._citation_terms(name + ' ' + section['role'])
|
|
|
|
|
|
|
| 2206 |
overlap = len(evidence_terms & section_terms)
|
| 2207 |
if overlap <= 0:
|
| 2208 |
continue
|
|
|
|
|
|
|
|
|
|
| 2209 |
load_ratio = loads[name] / max(1, capacities.get(name, 1))
|
| 2210 |
ranked.append((overlap, -load_ratio, section['target_words'], name))
|
| 2211 |
if not ranked:
|
|
|
|
| 2227 |
'evidence': re.sub(r'\s+', ' ', str(meta.get('evidence') or '')).strip()[:900],
|
| 2228 |
})
|
| 2229 |
logging.info(
|
| 2230 |
+
'Citation-first plan built: target=%s planned=%s sections=%s batch_size=%s',
|
| 2231 |
+
target, len(best), sum(1 for items in plan.values() if items), batch_size,
|
| 2232 |
)
|
| 2233 |
return plan
|
| 2234 |
|
i18n.py
CHANGED
|
@@ -214,9 +214,18 @@ TEXTS: dict[str, dict[str, str]] = {
|
|
| 214 |
"system_busy_title": "⏳ 系统忙碌",
|
| 215 |
"system_busy_warning": "🚨 有用户正在生成论文",
|
| 216 |
"system_busy_info": "💡 预计25–35分钟后可正常使用",
|
| 217 |
-
"checkpoint_warning": "⚠️
|
| 218 |
-
"resume": "🔄
|
| 219 |
-
"clear": "🗑️
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
"busy_short": "⏳ 有用户正在生成论文,预计25–35分钟后可用。",
|
| 221 |
"tab_amj": "✨ 定量研究(AMJ 引擎)",
|
| 222 |
"tab_asq": "📝 定性研究(ASQ 引擎)",
|
|
@@ -265,7 +274,7 @@ TEXTS: dict[str, dict[str, str]] = {
|
|
| 265 |
"outline_progress": "📝 大纲设计与路由分发……",
|
| 266 |
"jit_status": "🔄 正在为方法与结果章节预加载专属文献……",
|
| 267 |
"jit_done": "✅ 方法论预加载完成,已注入向量库。",
|
| 268 |
-
"english_draft": "#### 🇬🇧 英文
|
| 269 |
"target_draft": "#### {flag} 中文论文",
|
| 270 |
"restored": "✅ 已从快照安全恢复",
|
| 271 |
"waiting": "⏳ 等待执行……",
|
|
@@ -367,9 +376,18 @@ TEXTS: dict[str, dict[str, str]] = {
|
|
| 367 |
"system_busy_title": "⏳ System Busy",
|
| 368 |
"system_busy_warning": "🚨 Another user is generating a paper",
|
| 369 |
"system_busy_info": "💡 The system is expected to be available in 25–35 minutes",
|
| 370 |
-
"checkpoint_warning": "⚠️
|
| 371 |
-
"resume": "🔄
|
| 372 |
-
"clear": "🗑️
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 373 |
"busy_short": "⏳ Another user is generating a paper. Please try again in about 25–35 minutes.",
|
| 374 |
"tab_amj": "✨ Quantitative Research (AMJ Engine)",
|
| 375 |
"tab_asq": "📝 Qualitative Research (ASQ Engine)",
|
|
@@ -418,7 +436,7 @@ TEXTS: dict[str, dict[str, str]] = {
|
|
| 418 |
"outline_progress": "📝 Designing the outline and routing sections…",
|
| 419 |
"jit_status": "🔄 Preloading literature for the Method and Results sections…",
|
| 420 |
"jit_done": "✅ Method literature preloaded and added to the vector store.",
|
| 421 |
-
"english_draft": "#### 🇬🇧 English
|
| 422 |
"target_draft": "#### {flag} Chinese Paper",
|
| 423 |
"restored": "✅ Restored from checkpoint",
|
| 424 |
"waiting": "⏳ Waiting…",
|
|
@@ -520,9 +538,18 @@ TEXTS: dict[str, dict[str, str]] = {
|
|
| 520 |
"system_busy_title": "⏳ Система занята",
|
| 521 |
"system_busy_warning": "🚨 Другой пользователь формирует статью",
|
| 522 |
"system_busy_info": "💡 Ожидаемое время доступности — через 25–35 минут",
|
| 523 |
-
"checkpoint_warning": "⚠️ Обнаружена незавершённая задача
|
| 524 |
-
"resume": "🔄 Продолжить
|
| 525 |
-
"clear": "🗑️ Удалить
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 526 |
"busy_short": "⏳ Другой пользователь формирует статью. Повторите попытку примерно через 25–35 минут.",
|
| 527 |
"tab_amj": "✨ Количественное исследование (движок AMJ)",
|
| 528 |
"tab_asq": "📝 Качественное исследование (движок ASQ)",
|
|
@@ -571,7 +598,7 @@ TEXTS: dict[str, dict[str, str]] = {
|
|
| 571 |
"outline_progress": "📝 Проектирование структуры и распределение разделов…",
|
| 572 |
"jit_status": "🔄 Предварительная загрузка литературы для разделов методов и результатов…",
|
| 573 |
"jit_done": "✅ Методическая литература загружена в векторное хранилище.",
|
| 574 |
-
"english_draft": "#### 🇬🇧
|
| 575 |
"target_draft": "#### {flag} Русская версия статьи",
|
| 576 |
"restored": "✅ Восстановлено из контрольной точки",
|
| 577 |
"waiting": "⏳ Ожидание выполнения…",
|
|
@@ -673,9 +700,18 @@ TEXTS: dict[str, dict[str, str]] = {
|
|
| 673 |
"system_busy_title": "⏳ Жүйе бос емес",
|
| 674 |
"system_busy_warning": "🚨 Басқа пайдаланушы мақала дайындап жатыр",
|
| 675 |
"system_busy_info": "💡 Жүйе шамамен 25–35 минуттан кейін қолжетімді болады",
|
| 676 |
-
"checkpoint_warning": "⚠️
|
| 677 |
-
"resume": "🔄
|
| 678 |
-
"clear": "🗑️
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 679 |
"busy_short": "⏳ Басқа пайдаланушы мақала дайындап жатыр. Шамамен 25–35 минуттан кейін қайталап көріңіз.",
|
| 680 |
"tab_amj": "✨ Сандық зерттеу (AMJ қозғалтқышы)",
|
| 681 |
"tab_asq": "📝 Сапалық зерттеу (ASQ қозғалтқышы)",
|
|
@@ -724,7 +760,7 @@ TEXTS: dict[str, dict[str, str]] = {
|
|
| 724 |
"outline_progress": "📝 Құрылым жобаланып, бөлімдер бөлінуде…",
|
| 725 |
"jit_status": "🔄 Әдіс пен нәтижелер бөлімдеріне арналған әдебиеттер алдын ала жүктелуде…",
|
| 726 |
"jit_done": "✅ Әдістемелік әдебиеттер векторлық қорға қосылды.",
|
| 727 |
-
"english_draft": "#### 🇬🇧 Ағылшын тіліндегі
|
| 728 |
"target_draft": "#### {flag} Қазақ тіліндегі мақала",
|
| 729 |
"restored": "✅ Бақылау нүктесінен қалпына келтірілді",
|
| 730 |
"waiting": "⏳ Орындалуын күтуде…",
|
|
|
|
| 214 |
"system_busy_title": "⏳ 系统忙碌",
|
| 215 |
"system_busy_warning": "🚨 有用户正在生成论文",
|
| 216 |
"system_busy_info": "💡 预计25–35分钟后可正常使用",
|
| 217 |
+
"checkpoint_warning": "⚠️ 检测到暂停的未完成论文任务。您可以继续原任务,或删除后重新开始。",
|
| 218 |
+
"resume": "🔄 继续原任务",
|
| 219 |
+
"clear": "🗑️ 删除并重新开始",
|
| 220 |
+
"cancel_generation": "⛔ 取消生成",
|
| 221 |
+
"draft_preparing": "正在准备英文草稿…",
|
| 222 |
+
"draft_stage_inputs": "正在整理研究输入与证据…",
|
| 223 |
+
"draft_stage_search": "正在检索相关文献…",
|
| 224 |
+
"draft_stage_synthesis": "正在综合文献证据…",
|
| 225 |
+
"draft_stage_research_logic": "正在构建研究逻辑…",
|
| 226 |
+
"draft_stage_outline": "正在规划论文结构…",
|
| 227 |
+
"draft_stage_citation": "正在建立论点与引用关系…",
|
| 228 |
+
"draft_stage_writing": "正在准备正文写作…",
|
| 229 |
"busy_short": "⏳ 有用户正在生成论文,预计25–35分钟后可用。",
|
| 230 |
"tab_amj": "✨ 定量研究(AMJ 引擎)",
|
| 231 |
"tab_asq": "📝 定性研究(ASQ 引擎)",
|
|
|
|
| 274 |
"outline_progress": "📝 大纲设计与路由分发……",
|
| 275 |
"jit_status": "🔄 正在为方法与结果章节预加载专属文献……",
|
| 276 |
"jit_done": "✅ 方法论预加载完成,已注入向量库。",
|
| 277 |
+
"english_draft": "#### 🇬🇧 英文草稿",
|
| 278 |
"target_draft": "#### {flag} 中文论文",
|
| 279 |
"restored": "✅ 已从快照安全恢复",
|
| 280 |
"waiting": "⏳ 等待执行……",
|
|
|
|
| 376 |
"system_busy_title": "⏳ System Busy",
|
| 377 |
"system_busy_warning": "🚨 Another user is generating a paper",
|
| 378 |
"system_busy_info": "💡 The system is expected to be available in 25–35 minutes",
|
| 379 |
+
"checkpoint_warning": "⚠️ A paused unfinished paper task was detected. You can continue it or delete it and start again.",
|
| 380 |
+
"resume": "🔄 Continue Previous Task",
|
| 381 |
+
"clear": "🗑️ Delete and Start Again",
|
| 382 |
+
"cancel_generation": "⛔ Cancel Generation",
|
| 383 |
+
"draft_preparing": "Preparing the English draft…",
|
| 384 |
+
"draft_stage_inputs": "Organizing research inputs and evidence…",
|
| 385 |
+
"draft_stage_search": "Searching the academic literature…",
|
| 386 |
+
"draft_stage_synthesis": "Synthesizing literature evidence…",
|
| 387 |
+
"draft_stage_research_logic": "Building the research logic…",
|
| 388 |
+
"draft_stage_outline": "Planning the manuscript structure…",
|
| 389 |
+
"draft_stage_citation": "Mapping claims to supporting sources…",
|
| 390 |
+
"draft_stage_writing": "Preparing to draft the manuscript…",
|
| 391 |
"busy_short": "⏳ Another user is generating a paper. Please try again in about 25–35 minutes.",
|
| 392 |
"tab_amj": "✨ Quantitative Research (AMJ Engine)",
|
| 393 |
"tab_asq": "📝 Qualitative Research (ASQ Engine)",
|
|
|
|
| 436 |
"outline_progress": "📝 Designing the outline and routing sections…",
|
| 437 |
"jit_status": "🔄 Preloading literature for the Method and Results sections…",
|
| 438 |
"jit_done": "✅ Method literature preloaded and added to the vector store.",
|
| 439 |
+
"english_draft": "#### 🇬🇧 English Draft",
|
| 440 |
"target_draft": "#### {flag} Chinese Paper",
|
| 441 |
"restored": "✅ Restored from checkpoint",
|
| 442 |
"waiting": "⏳ Waiting…",
|
|
|
|
| 538 |
"system_busy_title": "⏳ Система занята",
|
| 539 |
"system_busy_warning": "🚨 Другой пользователь формирует статью",
|
| 540 |
"system_busy_info": "💡 Ожидаемое время доступности — через 25–35 минут",
|
| 541 |
+
"checkpoint_warning": "⚠️ Обнаружена приостановленная незавершённая задача. Вы можете продолжить её или удалить �� начать заново.",
|
| 542 |
+
"resume": "🔄 Продолжить предыдущую задачу",
|
| 543 |
+
"clear": "🗑️ Удалить и начать заново",
|
| 544 |
+
"cancel_generation": "⛔ Отменить генерацию",
|
| 545 |
+
"draft_preparing": "Подготовка черновика на английском языке…",
|
| 546 |
+
"draft_stage_inputs": "Систематизация исследовательских данных и доказательств…",
|
| 547 |
+
"draft_stage_search": "Поиск научной литературы…",
|
| 548 |
+
"draft_stage_synthesis": "Синтез литературных доказательств…",
|
| 549 |
+
"draft_stage_research_logic": "Формирование исследовательской логики…",
|
| 550 |
+
"draft_stage_outline": "Планирование структуры статьи…",
|
| 551 |
+
"draft_stage_citation": "Связывание тезисов с источниками…",
|
| 552 |
+
"draft_stage_writing": "Подготовка к написанию текста…",
|
| 553 |
"busy_short": "⏳ Другой пользователь формирует статью. Повторите попытку примерно через 25–35 минут.",
|
| 554 |
"tab_amj": "✨ Количественное исследование (движок AMJ)",
|
| 555 |
"tab_asq": "📝 Качественное исследование (движок ASQ)",
|
|
|
|
| 598 |
"outline_progress": "📝 Проектирование структуры и распределение разделов…",
|
| 599 |
"jit_status": "🔄 Предварительная загрузка литературы для разделов методов и результатов…",
|
| 600 |
"jit_done": "✅ Методическая литература загружена в векторное хранилище.",
|
| 601 |
+
"english_draft": "#### 🇬🇧 Черновик на английском языке",
|
| 602 |
"target_draft": "#### {flag} Русская версия статьи",
|
| 603 |
"restored": "✅ Восстановлено из контрольной точки",
|
| 604 |
"waiting": "⏳ Ожидание выполнения…",
|
|
|
|
| 700 |
"system_busy_title": "⏳ Жүйе бос емес",
|
| 701 |
"system_busy_warning": "🚨 Басқа пайдаланушы мақала дайындап жатыр",
|
| 702 |
"system_busy_info": "💡 Жүйе шамамен 25–35 минуттан кейін қолжетімді болады",
|
| 703 |
+
"checkpoint_warning": "⚠️ Аяқталмаған, уақытша тоқтатылған тапсырма анықталды. Оны жалғастыруға немесе өшіріп, қайта бастауға болады.",
|
| 704 |
+
"resume": "🔄 Алдыңғы тапсырманы жалғастыру",
|
| 705 |
+
"clear": "🗑️ Өшіріп, қайта бастау",
|
| 706 |
+
"cancel_generation": "⛔ Генерацияны тоқтату",
|
| 707 |
+
"draft_preparing": "Ағылшын тіліндегі бастапқы нұсқа дайындалуда…",
|
| 708 |
+
"draft_stage_inputs": "Зерттеу кірістері мен дәлелдер реттелуде…",
|
| 709 |
+
"draft_stage_search": "Ғылыми әдебиеттер ізделуде…",
|
| 710 |
+
"draft_stage_synthesis": "Әдебиет дәлелдері синтезделуде…",
|
| 711 |
+
"draft_stage_research_logic": "Зерттеу логикасы құрылуда…",
|
| 712 |
+
"draft_stage_outline": "Мақала құрылымы жоспарлануда…",
|
| 713 |
+
"draft_stage_citation": "Тұжырымдар дереккөздермен сәйкестендірілуде…",
|
| 714 |
+
"draft_stage_writing": "Негізгі мәтінді жазуға дайындалуда…",
|
| 715 |
"busy_short": "⏳ Басқа пайдаланушы мақала дайындап жатыр. Шамамен 25–35 минуттан кейін қайталап көріңіз.",
|
| 716 |
"tab_amj": "✨ Сандық зерттеу (AMJ қозғалтқышы)",
|
| 717 |
"tab_asq": "📝 Сапалық зерттеу (ASQ қозғалтқышы)",
|
|
|
|
| 760 |
"outline_progress": "📝 Құрылым жобаланып, бөлімдер бөлінуде…",
|
| 761 |
"jit_status": "🔄 Әдіс пен нәтижелер бөлімдеріне арналған әдебиеттер алдын ала жүктелуде…",
|
| 762 |
"jit_done": "✅ Әдістемелік әдебиеттер векторлық қорға қосылды.",
|
| 763 |
+
"english_draft": "#### 🇬🇧 Ағылшын тіліндегі бастапқы нұсқа",
|
| 764 |
"target_draft": "#### {flag} Қазақ тіліндегі мақала",
|
| 765 |
"restored": "✅ Бақылау нүктесінен қалпына келтірілді",
|
| 766 |
"waiting": "⏳ Орындалуын күтуде…",
|
smartpaper_restore_bridge/__pycache__/__init__.cpython-313.pyc
DELETED
|
Binary file (3.13 kB)
|
|
|
smartpaper_restore_bridge/__pycache__/desktop.ini
DELETED
|
@@ -1,2 +0,0 @@
|
|
| 1 |
-
[.ShellClassInfo]
|
| 2 |
-
IconResource=C:\Program Files\Google\Drive File Stream\129.0.1.0\GoogleDriveFS.exe,27
|
|
|
|
|
|
|
|
|
smartpaper_restore_bridge/frontend/index.html
CHANGED
|
@@ -26,6 +26,7 @@
|
|
| 26 |
let lastMonitorCheckpointSeq = 0;
|
| 27 |
let lastPreviewText = "";
|
| 28 |
let lastPreviewDisplayText = "";
|
|
|
|
| 29 |
|
| 30 |
const ACTION_PATHS = Object.freeze({
|
| 31 |
task_start: "/edge/task/start",
|
|
@@ -220,8 +221,19 @@
|
|
| 220 |
}
|
| 221 |
}
|
| 222 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
function renderPreviewContent(parentDocument, target, text) {
|
| 224 |
// Keep one stable text node and append only the newly generated suffix.
|
|
|
|
| 225 |
// Rebuilding the whole manuscript DOM on every preview poll caused visible
|
| 226 |
// repaint/flicker and did not feel like ChatGPT-style streaming.
|
| 227 |
const displayText = String(text || "")
|
|
@@ -295,7 +307,7 @@
|
|
| 295 |
} catch (_) {}
|
| 296 |
}
|
| 297 |
|
| 298 |
-
function updateTaskMonitor(task) {
|
| 299 |
let parentDocument;
|
| 300 |
try { parentDocument = window.parent.document; }
|
| 301 |
catch (_) { return; }
|
|
@@ -314,6 +326,7 @@
|
|
| 314 |
updateText(parentDocument, "sp-live-progress-text", `${progress}% — ${node}`);
|
| 315 |
const fill = parentDocument.getElementById("sp-live-progress-fill");
|
| 316 |
if (fill) fill.style.width = `${progress}%`;
|
|
|
|
| 317 |
|
| 318 |
if (node && node !== lastMonitorNode) {
|
| 319 |
appendTaskActivity(parentDocument, node, false);
|
|
@@ -325,9 +338,73 @@
|
|
| 325 |
}
|
| 326 |
}
|
| 327 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
function stopTaskWatch() {
|
| 329 |
const watch = activeTaskWatch;
|
| 330 |
activeTaskWatch = null;
|
|
|
|
| 331 |
if (watch && watch.timer) window.clearTimeout(watch.timer);
|
| 332 |
if (watch && watch.previewTimer) window.clearTimeout(watch.previewTimer);
|
| 333 |
if (watch && watch.controller) {
|
|
@@ -352,8 +429,11 @@
|
|
| 352 |
return;
|
| 353 |
}
|
| 354 |
|
| 355 |
-
const
|
|
|
|
|
|
|
| 356 |
activeTaskWatch = watch;
|
|
|
|
| 357 |
lastPreviewText = "";
|
| 358 |
lastPreviewDisplayText = "";
|
| 359 |
lastMonitorCheckpointSeq = Math.max(lastMonitorCheckpointSeq, initialCheckpointSeq);
|
|
@@ -377,12 +457,13 @@
|
|
| 377 |
if (activeTaskWatch !== watch || watch.stopped) return;
|
| 378 |
const task = result && result.task && typeof result.task === "object" ? result.task : null;
|
| 379 |
if (result.ok && task) {
|
| 380 |
-
updateTaskMonitor(task);
|
| 381 |
const status = String(task.status || "");
|
| 382 |
const checkpointSeq = asCount(task.checkpoint_seq);
|
| 383 |
if (TERMINAL_TASK_STATUSES.has(status) || status === "resumable") {
|
| 384 |
watch.stopped = true;
|
| 385 |
activeTaskWatch = null;
|
|
|
|
| 386 |
sendValue(Object.assign({}, result, {
|
| 387 |
action:"task_watch",
|
| 388 |
command_id:commandId,
|
|
|
|
| 26 |
let lastMonitorCheckpointSeq = 0;
|
| 27 |
let lastPreviewText = "";
|
| 28 |
let lastPreviewDisplayText = "";
|
| 29 |
+
let pageExitCancelCleanup = null;
|
| 30 |
|
| 31 |
const ACTION_PATHS = Object.freeze({
|
| 32 |
task_start: "/edge/task/start",
|
|
|
|
| 221 |
}
|
| 222 |
}
|
| 223 |
|
| 224 |
+
function updateDraftPreparation(parentDocument, node, labels) {
|
| 225 |
+
const target = parentDocument.getElementById("sp-live-manuscript-preview");
|
| 226 |
+
if (!target || lastPreviewText || target.querySelector("[data-sp-live-stream='1']")) return;
|
| 227 |
+
const map = labels && typeof labels === "object" ? labels : {};
|
| 228 |
+
const value = String(map[node] || map.writing || map.default || "").trim();
|
| 229 |
+
if (!value) return;
|
| 230 |
+
target.dataset.spPlaceholder = "1";
|
| 231 |
+
target.textContent = value;
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
function renderPreviewContent(parentDocument, target, text) {
|
| 235 |
// Keep one stable text node and append only the newly generated suffix.
|
| 236 |
+
target.dataset.spPlaceholder = "0";
|
| 237 |
// Rebuilding the whole manuscript DOM on every preview poll caused visible
|
| 238 |
// repaint/flicker and did not feel like ChatGPT-style streaming.
|
| 239 |
const displayText = String(text || "")
|
|
|
|
| 307 |
} catch (_) {}
|
| 308 |
}
|
| 309 |
|
| 310 |
+
function updateTaskMonitor(task, watch=null) {
|
| 311 |
let parentDocument;
|
| 312 |
try { parentDocument = window.parent.document; }
|
| 313 |
catch (_) { return; }
|
|
|
|
| 326 |
updateText(parentDocument, "sp-live-progress-text", `${progress}% — ${node}`);
|
| 327 |
const fill = parentDocument.getElementById("sp-live-progress-fill");
|
| 328 |
if (fill) fill.style.width = `${progress}%`;
|
| 329 |
+
updateDraftPreparation(parentDocument, node, watch && watch.draftStatusLabels);
|
| 330 |
|
| 331 |
if (node && node !== lastMonitorNode) {
|
| 332 |
appendTaskActivity(parentDocument, node, false);
|
|
|
|
| 338 |
}
|
| 339 |
}
|
| 340 |
|
| 341 |
+
function removePageExitCancel() {
|
| 342 |
+
if (typeof pageExitCancelCleanup === "function") {
|
| 343 |
+
try { pageExitCancelCleanup(); } catch (_) {}
|
| 344 |
+
}
|
| 345 |
+
pageExitCancelCleanup = null;
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
function installPageExitCancel(watch) {
|
| 349 |
+
removePageExitCancel();
|
| 350 |
+
if (!watch || !watch.bestEffortPageExitCancel) return;
|
| 351 |
+
let parentWindow;
|
| 352 |
+
try { parentWindow = window.parent; } catch (_) { return; }
|
| 353 |
+
let sent = false;
|
| 354 |
+
let suppressForReload = false;
|
| 355 |
+
const navigationHandler = (event) => {
|
| 356 |
+
if (event && String(event.navigationType || "") === "reload") suppressForReload = true;
|
| 357 |
+
};
|
| 358 |
+
const keyHandler = (event) => {
|
| 359 |
+
const key = String(event && event.key || "").toLowerCase();
|
| 360 |
+
if (key === "f5" || ((event.ctrlKey || event.metaKey) && key === "r")) suppressForReload = true;
|
| 361 |
+
};
|
| 362 |
+
try {
|
| 363 |
+
if (parentWindow.navigation && typeof parentWindow.navigation.addEventListener === "function") {
|
| 364 |
+
parentWindow.navigation.addEventListener("navigate", navigationHandler);
|
| 365 |
+
}
|
| 366 |
+
parentWindow.addEventListener("keydown", keyHandler, true);
|
| 367 |
+
} catch (_) {}
|
| 368 |
+
const handler = (event) => {
|
| 369 |
+
if (event && event.persisted) return;
|
| 370 |
+
if (suppressForReload) return;
|
| 371 |
+
if (sent || activeTaskWatch !== watch || watch.stopped) return;
|
| 372 |
+
sent = true;
|
| 373 |
+
const url = new URL(ACTION_PATHS.task_cancel, watch.apiOrigin).toString();
|
| 374 |
+
const body = JSON.stringify({ task_id:watch.taskId });
|
| 375 |
+
let beaconSent = false;
|
| 376 |
+
try {
|
| 377 |
+
if (parentWindow.navigator && typeof parentWindow.navigator.sendBeacon === "function") {
|
| 378 |
+
const blob = new Blob([body], { type:"application/json" });
|
| 379 |
+
beaconSent = parentWindow.navigator.sendBeacon(url, blob);
|
| 380 |
+
}
|
| 381 |
+
} catch (_) {}
|
| 382 |
+
if (!beaconSent) {
|
| 383 |
+
try {
|
| 384 |
+
parentWindow.fetch(url, {
|
| 385 |
+
method:"POST", credentials:"include", cache:"no-store", keepalive:true,
|
| 386 |
+
headers:{ "Accept":"application/json", "Content-Type":"application/json", "X-SmartPaper-Client":"browser-exit-v1" },
|
| 387 |
+
body
|
| 388 |
+
}).catch(() => {});
|
| 389 |
+
} catch (_) {}
|
| 390 |
+
}
|
| 391 |
+
};
|
| 392 |
+
parentWindow.addEventListener("pagehide", handler, { capture:true });
|
| 393 |
+
pageExitCancelCleanup = () => {
|
| 394 |
+
parentWindow.removeEventListener("pagehide", handler, { capture:true });
|
| 395 |
+
parentWindow.removeEventListener("keydown", keyHandler, true);
|
| 396 |
+
try {
|
| 397 |
+
if (parentWindow.navigation && typeof parentWindow.navigation.removeEventListener === "function") {
|
| 398 |
+
parentWindow.navigation.removeEventListener("navigate", navigationHandler);
|
| 399 |
+
}
|
| 400 |
+
} catch (_) {}
|
| 401 |
+
};
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
function stopTaskWatch() {
|
| 405 |
const watch = activeTaskWatch;
|
| 406 |
activeTaskWatch = null;
|
| 407 |
+
removePageExitCancel();
|
| 408 |
if (watch && watch.timer) window.clearTimeout(watch.timer);
|
| 409 |
if (watch && watch.previewTimer) window.clearTimeout(watch.previewTimer);
|
| 410 |
if (watch && watch.controller) {
|
|
|
|
| 429 |
return;
|
| 430 |
}
|
| 431 |
|
| 432 |
+
const draftStatusLabels = payload.draft_status_labels && typeof payload.draft_status_labels === "object" ? payload.draft_status_labels : {};
|
| 433 |
+
const bestEffortPageExitCancel = payload.best_effort_page_exit_cancel === true;
|
| 434 |
+
const watch = { commandId, taskId, apiOrigin, intervalMs, previewIntervalMs, previewUrl:resolvedPreviewUrl, expectedEpoch, draftStatusLabels, bestEffortPageExitCancel, timer:null, previewTimer:null, controller:null, stopped:false, lastCheckpointSeq:initialCheckpointSeq };
|
| 435 |
activeTaskWatch = watch;
|
| 436 |
+
installPageExitCancel(watch);
|
| 437 |
lastPreviewText = "";
|
| 438 |
lastPreviewDisplayText = "";
|
| 439 |
lastMonitorCheckpointSeq = Math.max(lastMonitorCheckpointSeq, initialCheckpointSeq);
|
|
|
|
| 457 |
if (activeTaskWatch !== watch || watch.stopped) return;
|
| 458 |
const task = result && result.task && typeof result.task === "object" ? result.task : null;
|
| 459 |
if (result.ok && task) {
|
| 460 |
+
updateTaskMonitor(task, watch);
|
| 461 |
const status = String(task.status || "");
|
| 462 |
const checkpointSeq = asCount(task.checkpoint_seq);
|
| 463 |
if (TERMINAL_TASK_STATUSES.has(status) || status === "resumable") {
|
| 464 |
watch.stopped = true;
|
| 465 |
activeTaskWatch = null;
|
| 466 |
+
removePageExitCancel();
|
| 467 |
sendValue(Object.assign({}, result, {
|
| 468 |
action:"task_watch",
|
| 469 |
command_id:commandId,
|
task_executor.py
CHANGED
|
@@ -16,6 +16,7 @@ import logging
|
|
| 16 |
import os
|
| 17 |
import re
|
| 18 |
import secrets
|
|
|
|
| 19 |
import sys
|
| 20 |
import threading
|
| 21 |
import time
|
|
@@ -270,6 +271,7 @@ class ExecutionState:
|
|
| 270 |
self._s2_calls = 0
|
| 271 |
self._oa_calls = 0
|
| 272 |
self._cancel = False
|
|
|
|
| 273 |
self._stop = threading.Event()
|
| 274 |
self._thread = None
|
| 275 |
|
|
@@ -298,6 +300,16 @@ class ExecutionState:
|
|
| 298 |
with self._lock:
|
| 299 |
return bool(self._cancel)
|
| 300 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
def start_heartbeat(self):
|
| 302 |
self._thread = threading.Thread(target=self._heartbeat_loop, name="smartpaper-executor-heartbeat", daemon=True)
|
| 303 |
self._thread.start()
|
|
@@ -327,8 +339,7 @@ class ExecutionState:
|
|
| 327 |
payload, _ = self._payload()
|
| 328 |
result = self.api.heartbeat(payload)
|
| 329 |
if result.get("cancel_requested"):
|
| 330 |
-
|
| 331 |
-
self._cancel = True
|
| 332 |
return result
|
| 333 |
|
| 334 |
def _heartbeat_loop(self):
|
|
@@ -336,25 +347,33 @@ class ExecutionState:
|
|
| 336 |
grace = int(APP_CONFIG.get("executor_activity_grace_seconds", 45))
|
| 337 |
while not self._stop.wait(interval):
|
| 338 |
payload, idle_seconds = self._payload()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
if idle_seconds > grace:
|
| 340 |
-
logging.
|
| 341 |
-
"[SP_EXECUTOR]
|
| 342 |
self.task_id, idle_seconds, payload.get("current_node"),
|
| 343 |
)
|
| 344 |
-
continue
|
| 345 |
try:
|
| 346 |
result = self.api.heartbeat(payload)
|
| 347 |
if result.get("cancel_requested"):
|
| 348 |
-
|
| 349 |
-
self._cancel = True
|
| 350 |
return
|
| 351 |
except ExecutorApiError as exc:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
if exc.error in {
|
| 353 |
"executor_superseded", "generation_lock_lost",
|
| 354 |
-
"task_already_finalized",
|
| 355 |
} or exc.status_code in {404, 409, 410}:
|
| 356 |
-
|
| 357 |
-
self._cancel = True
|
| 358 |
logging.warning(
|
| 359 |
"[SP_EXECUTOR] executor_no_longer_authoritative task_id=%s error=%s",
|
| 360 |
self.task_id, _safe(exc.error, 180),
|
|
@@ -365,6 +384,21 @@ class ExecutionState:
|
|
| 365 |
logging.warning("[SP_EXECUTOR] heartbeat_error task_id=%s error=%s", self.task_id, _safe(exc, 180))
|
| 366 |
|
| 367 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 368 |
def _load_request(task_dir: Path) -> dict:
|
| 369 |
path = task_dir / "request.json"
|
| 370 |
if not path.is_file():
|
|
@@ -529,7 +563,12 @@ def main() -> int:
|
|
| 529 |
return 0
|
| 530 |
except Exception as exc:
|
| 531 |
if state.cancelled():
|
| 532 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 533 |
return 3
|
| 534 |
if isinstance(exc, (ImportError, ModuleNotFoundError)):
|
| 535 |
logging.exception("[SP_EXECUTOR] bootstrap_import_failed task_id=%s epoch=%s error=%s", args.task_id, epoch, _safe(exc, 240))
|
|
|
|
| 16 |
import os
|
| 17 |
import re
|
| 18 |
import secrets
|
| 19 |
+
import shutil
|
| 20 |
import sys
|
| 21 |
import threading
|
| 22 |
import time
|
|
|
|
| 271 |
self._s2_calls = 0
|
| 272 |
self._oa_calls = 0
|
| 273 |
self._cancel = False
|
| 274 |
+
self._cancel_reason = ""
|
| 275 |
self._stop = threading.Event()
|
| 276 |
self._thread = None
|
| 277 |
|
|
|
|
| 300 |
with self._lock:
|
| 301 |
return bool(self._cancel)
|
| 302 |
|
| 303 |
+
def cancel_reason(self) -> str:
|
| 304 |
+
with self._lock:
|
| 305 |
+
return str(self._cancel_reason or "")
|
| 306 |
+
|
| 307 |
+
def _mark_cancelled(self, reason: str) -> None:
|
| 308 |
+
with self._lock:
|
| 309 |
+
self._cancel = True
|
| 310 |
+
if reason:
|
| 311 |
+
self._cancel_reason = str(reason)[:80]
|
| 312 |
+
|
| 313 |
def start_heartbeat(self):
|
| 314 |
self._thread = threading.Thread(target=self._heartbeat_loop, name="smartpaper-executor-heartbeat", daemon=True)
|
| 315 |
self._thread.start()
|
|
|
|
| 339 |
payload, _ = self._payload()
|
| 340 |
result = self.api.heartbeat(payload)
|
| 341 |
if result.get("cancel_requested"):
|
| 342 |
+
self._mark_cancelled("user_cancel")
|
|
|
|
| 343 |
return result
|
| 344 |
|
| 345 |
def _heartbeat_loop(self):
|
|
|
|
| 347 |
grace = int(APP_CONFIG.get("executor_activity_grace_seconds", 45))
|
| 348 |
while not self._stop.wait(interval):
|
| 349 |
payload, idle_seconds = self._payload()
|
| 350 |
+
# Long synchronous model calls can legitimately produce no Python
|
| 351 |
+
# callbacks for more than one Worker lease window. Model activity
|
| 352 |
+
# is diagnostic only; process/epoch ownership is the liveness
|
| 353 |
+
# authority. Therefore heartbeat must continue while this executor
|
| 354 |
+
# is alive and authoritative.
|
| 355 |
if idle_seconds > grace:
|
| 356 |
+
logging.info(
|
| 357 |
+
"[SP_EXECUTOR] heartbeat_during_long_call task_id=%s idle_s=%.1f node=%s",
|
| 358 |
self.task_id, idle_seconds, payload.get("current_node"),
|
| 359 |
)
|
|
|
|
| 360 |
try:
|
| 361 |
result = self.api.heartbeat(payload)
|
| 362 |
if result.get("cancel_requested"):
|
| 363 |
+
self._mark_cancelled("user_cancel")
|
|
|
|
| 364 |
return
|
| 365 |
except ExecutorApiError as exc:
|
| 366 |
+
if exc.error == "task_cancel_requested":
|
| 367 |
+
self._mark_cancelled("user_cancel")
|
| 368 |
+
logging.warning(
|
| 369 |
+
"[SP_EXECUTOR] task_cancel_requested task_id=%s", self.task_id
|
| 370 |
+
)
|
| 371 |
+
return
|
| 372 |
if exc.error in {
|
| 373 |
"executor_superseded", "generation_lock_lost",
|
| 374 |
+
"task_already_finalized",
|
| 375 |
} or exc.status_code in {404, 409, 410}:
|
| 376 |
+
self._mark_cancelled("authority_lost")
|
|
|
|
| 377 |
logging.warning(
|
| 378 |
"[SP_EXECUTOR] executor_no_longer_authoritative task_id=%s error=%s",
|
| 379 |
self.task_id, _safe(exc.error, 180),
|
|
|
|
| 384 |
logging.warning("[SP_EXECUTOR] heartbeat_error task_id=%s error=%s", self.task_id, _safe(exc, 180))
|
| 385 |
|
| 386 |
|
| 387 |
+
def _cleanup_user_cancelled_task_artifacts(task_dir: Path, request_data: dict) -> None:
|
| 388 |
+
"""Delete only local, user-cancelled stage artifacts. D1 audit state remains authoritative."""
|
| 389 |
+
try:
|
| 390 |
+
token = str((request_data or {}).get("preview_token") or "").strip()
|
| 391 |
+
if re.fullmatch(r"[A-Za-z0-9_-]{32,128}", token):
|
| 392 |
+
preview = Path(__file__).resolve().parent / "static" / "previews" / f"{token}.json"
|
| 393 |
+
preview.unlink(missing_ok=True)
|
| 394 |
+
except Exception as exc:
|
| 395 |
+
logging.warning("[SP_EXECUTOR] cancel_preview_cleanup_error task_id=%s error=%s", task_dir.name, _safe(exc, 160))
|
| 396 |
+
try:
|
| 397 |
+
shutil.rmtree(task_dir, ignore_errors=True)
|
| 398 |
+
except Exception as exc:
|
| 399 |
+
logging.warning("[SP_EXECUTOR] cancel_task_cleanup_error task_id=%s error=%s", task_dir.name, _safe(exc, 160))
|
| 400 |
+
|
| 401 |
+
|
| 402 |
def _load_request(task_dir: Path) -> dict:
|
| 403 |
path = task_dir / "request.json"
|
| 404 |
if not path.is_file():
|
|
|
|
| 563 |
return 0
|
| 564 |
except Exception as exc:
|
| 565 |
if state.cancelled():
|
| 566 |
+
reason = state.cancel_reason()
|
| 567 |
+
if reason == "user_cancel":
|
| 568 |
+
_cleanup_user_cancelled_task_artifacts(task_dir, request_data)
|
| 569 |
+
logging.info("[SP_EXECUTOR] cancelled_and_cleaned task_id=%s epoch=%s", args.task_id, epoch)
|
| 570 |
+
else:
|
| 571 |
+
logging.warning("[SP_EXECUTOR] stopped_non_authoritative task_id=%s epoch=%s reason=%s", args.task_id, epoch, reason or "unknown")
|
| 572 |
return 3
|
| 573 |
if isinstance(exc, (ImportError, ModuleNotFoundError)):
|
| 574 |
logging.exception("[SP_EXECUTOR] bootstrap_import_failed task_id=%s epoch=%s error=%s", args.task_id, epoch, _safe(exc, 240))
|
utils/__pycache__/desktop.ini
DELETED
|
@@ -1,2 +0,0 @@
|
|
| 1 |
-
[.ShellClassInfo]
|
| 2 |
-
IconResource=C:\Program Files\Google\Drive File Stream\129.0.1.0\GoogleDriveFS.exe,27
|
|
|
|
|
|
|
|
|
utils/__pycache__/docx_builder.cpython-313.pyc
DELETED
|
Binary file (9.39 kB)
|
|
|
utils/__pycache__/file_parser.cpython-313.pyc
DELETED
|
Binary file (8.02 kB)
|
|
|