Step 4A-6-C-R3.2.7 Long-Call Authority Draft UX Cancel Flow and Anonymous S2 Fix

#17
.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
- shutil.rmtree(_task_dir(task_id), ignore_errors=True)
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
- escaped = html.escape(draft)
 
 
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 st.button(tr(lang_code, "start_amj"), disabled=active, key="btn_amj"):
 
 
 
 
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 st.button(tr(lang_code, "start_asq"), disabled=active, key="btn_asq"):
 
 
 
 
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
- # A DeepSeek request must fail before the 90-second Worker generation
28
- # lease can expire without real execution progress. Retries renew the
29
- # lease through the execution heartbeat hook.
30
  "api_timeouts": {
31
  "standard": (15, 70),
32
  "stream": (15, 70),
 
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, "research_logic_and_outline")
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 loaded_mode in {"key", "anonymous", "blocked"}:
286
- self._s2_access_mode = loaded_mode
287
- self._s2_probe_complete = bool(state_dict.get("s2_probe_complete", self._s2_probe_complete))
 
 
 
 
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._requests_get_with_retry(
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._requests_get_with_retry(
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._requests_get_with_retry(
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
- """Plan citations by claim/evidence relevance before prose is written.
2004
-
2005
- This replaces position-based tag allocation. A source is assigned to at
2006
- most one manuscript unit and only together with a claim that its title/
2007
- abstract evidence can directly support. Results/Findings, conclusion,
2008
- abstract-like and appendix units are excluded from literature quotas.
 
 
 
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
- payload = {
2062
- 'topic': str(topic or '')[:700],
2063
- 'mode': mode,
2064
- 'target_unique_sources': target,
2065
- 'sections': [dict(item, citation_capacity=capacities[item['name']]) for item in sections],
2066
- 'sources': candidates,
2067
- }
2068
- system_prompt = """You are a rigorous claim-evidence planner for a top management journal manuscript. Build the citation architecture BEFORE prose is written. Use only the supplied title/abstract evidence. Never use a source to support this study's own sample, coefficients, events, quotations, or findings. Each source may be assigned at most once. Each planned claim must be narrow enough that the supplied evidence directly supports it. Do not manufacture a claim merely to hit the target. Return only valid JSON."""
2069
- user_prompt = f"""Create a claim-evidence plan.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2070
  JSON schema:
2071
  {{"assignments":[{{"tag":"[CITE-01]","section":"exact display_name from sections","claim":"one directly supported external/theoretical/methodological claim"}}]}}
2072
- Aim for {target} distinct sources when the evidence permits. Distribute sources across appropriate non-results sections; do not assign literature to Results/Findings or Conclusion.
2073
  INPUT:
2074
  {json.dumps(payload, ensure_ascii=False)}"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2075
 
2076
- best = []
2077
- for attempt in range(2):
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 tag not in valid_tags or tag in unique_tags or section not in allowed_sections or len(claim.split()) < 5:
2105
- continue
2106
- if section_loads.get(section, 0) >= capacities.get(section, target):
 
 
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
- overlap = self._citation_terms(claim) & self._citation_terms(evidence)
2112
- if len(overlap) < 2:
2113
  continue
2114
- unique_tags.add(tag)
2115
- cleaned.append({'tag': tag, 'section': section, 'claim': claim})
2116
- section_loads[section] = section_loads.get(section, 0) + 1
2117
- if len(cleaned) > len(best):
2118
- best = cleaned
2119
- if len(best) >= target:
2120
- break
 
 
 
2121
 
2122
- # If a model response is truncated or structurally incomplete, recover
2123
- # only with claims copied from the supplied abstract evidence itself.
2124
- # This is not the old position-based citation allocation: the fallback
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
- used_tags = {row['tag'] for row in best}
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": "⚠️ An unfinished paper task from a network or unexpected interruption was detected.",
371
- "resume": "🔄 Resume from Checkpoint",
372
- "clear": "🗑️ Clear Temporary Data",
 
 
 
 
 
 
 
 
 
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 Evidence Draft",
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 watch = { commandId, taskId, apiOrigin, intervalMs, previewIntervalMs, previewUrl:resolvedPreviewUrl, expectedEpoch, timer:null, previewTimer:null, controller:null, stopped:false, lastCheckpointSeq:initialCheckpointSeq };
 
 
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
- with self._lock:
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.warning(
341
- "[SP_EXECUTOR] heartbeat_paused task_id=%s idle_s=%.1f node=%s",
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
- with self._lock:
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", "task_cancel_requested",
355
  } or exc.status_code in {404, 409, 410}:
356
- with self._lock:
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
- logging.warning("[SP_EXECUTOR] stopped_non_authoritative task_id=%s epoch=%s", args.task_id, epoch)
 
 
 
 
 
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)