Spaces:
Running
Running
Commit ·
dbaefaf
1
Parent(s): 6ce52dc
Deploy SmartPaper v1.1 R2.1Fix (#4)
Browse files- Deploy SmartPaper v1.1 R2.1Fix (6069e4f7e41958457729b3868c051c3c61dc8469)
- app.py +55 -6
- config.py +9 -2
- core/pipeline_agent.py +74 -1
- managed_auth.py +34 -1
- smartpaper_restore_bridge/frontend/index.html +3 -14
app.py
CHANGED
|
@@ -20,6 +20,8 @@ warnings.filterwarnings("ignore", message="coroutine 'expire_cache' was never aw
|
|
| 20 |
import os
|
| 21 |
import streamlit.components.v1 as components
|
| 22 |
import time
|
|
|
|
|
|
|
| 23 |
import re
|
| 24 |
import math
|
| 25 |
import json
|
|
@@ -43,6 +45,11 @@ from i18n import LANGUAGE_OPTIONS, get_amj_csv_template, get_asq_txt_template, g
|
|
| 43 |
os.environ["STREAMLIT_SERVER_ENABLE_CORS"] = "false"
|
| 44 |
os.environ["STREAMLIT_SERVER_ENABLE_XSRF_PROTECTION"] = "false"
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
WORKSPACE_DIR = APP_CONFIG.get("workspace_dir", ".workspace")
|
| 47 |
os.makedirs(WORKSPACE_DIR, exist_ok=True)
|
| 48 |
LEGACY_CHECKPOINT_FILE = os.path.join(WORKSPACE_DIR, "snapshot.json")
|
|
@@ -52,7 +59,9 @@ META_SECTIONS_BLACKLIST = set(APP_CONFIG["blacklists"]["meta_sections"])
|
|
| 52 |
|
| 53 |
|
| 54 |
def update_heartbeat():
|
| 55 |
-
"""Refresh the
|
|
|
|
|
|
|
| 56 |
now = time.time()
|
| 57 |
task = st.session_state.get("sp_generation_task") or {}
|
| 58 |
task_id = str(task.get("task_id") or "").strip()
|
|
@@ -66,10 +75,12 @@ def update_heartbeat():
|
|
| 66 |
current_node=str(st.session_state.get("sp_current_node") or "")[:240],
|
| 67 |
)
|
| 68 |
st.session_state["sp_last_task_heartbeat"] = now
|
| 69 |
-
except Exception:
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
|
|
|
|
|
|
| 73 |
|
| 74 |
|
| 75 |
def is_system_busy():
|
|
@@ -407,6 +418,12 @@ def _begin_managed_generation_task(*, is_resume: bool, loaded_state: dict | None
|
|
| 407 |
st.session_state["sp_generation_idempotency_key"] = idempotency_key
|
| 408 |
st.session_state["sp_generation_task"] = task
|
| 409 |
st.session_state["sp_last_task_heartbeat"] = time.time()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 410 |
return task, idempotency_key
|
| 411 |
|
| 412 |
|
|
@@ -530,6 +547,12 @@ def _process_pending_task_finalization():
|
|
| 530 |
if not package_bytes:
|
| 531 |
raise RuntimeError("validated_package_file_empty")
|
| 532 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 533 |
response = finish_generation_task(
|
| 534 |
task_id,
|
| 535 |
status=status,
|
|
@@ -538,6 +561,13 @@ def _process_pending_task_finalization():
|
|
| 538 |
current_node=str(pending.get("current_node") or "")[:240],
|
| 539 |
)
|
| 540 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 541 |
if status == "completed":
|
| 542 |
filename = str(package.get("filename") or "SmartPaper.zip")
|
| 543 |
pending_path = str(package.get("pending_path") or "")
|
|
@@ -863,7 +893,12 @@ progress_bar = st.empty()
|
|
| 863 |
monitor_ui = st.empty()
|
| 864 |
|
| 865 |
def render_monitor(agent, phase, current_sec="", details=""):
|
| 866 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 867 |
monitor_ui.markdown(f"""
|
| 868 |
<div class='log-box'>
|
| 869 |
<div><b>{tr(lang_code, 'monitor_pipeline')}:</b> {phase}</div><div><b>{tr(lang_code, 'monitor_node')}:</b> {current_sec}</div><div><b>{tr(lang_code, 'monitor_log')}:</b> {details}</div><hr>
|
|
@@ -922,6 +957,7 @@ if st.session_state.start_pipeline or st.session_state.resume_pipeline:
|
|
| 922 |
|
| 923 |
st.session_state.is_running = True
|
| 924 |
agent = SSciPipelineAgent(global_ds_key, global_s2_key)
|
|
|
|
| 925 |
SAFE_CHUNK_WORDS = agent.chunk_words_default
|
| 926 |
qual_engine = get_qualitative_engine(agent)
|
| 927 |
quant_engine = get_quantitative_engine()
|
|
@@ -1355,6 +1391,13 @@ Output only final academic prose. Do not report the word count."""
|
|
| 1355 |
render_monitor(agent, tr(lang_code, "complete"), "✅", tr(lang_code, "document_generated"))
|
| 1356 |
|
| 1357 |
except Exception as e:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1358 |
if not completion_finalization_attempted:
|
| 1359 |
try:
|
| 1360 |
_finish_managed_generation_task(
|
|
@@ -1368,6 +1411,12 @@ Output only final academic prose. Do not report the word count."""
|
|
| 1368 |
pass
|
| 1369 |
st.error(tr(lang_code, "startup_error", error=e))
|
| 1370 |
finally:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1371 |
clear_heartbeat()
|
| 1372 |
st.session_state.is_running = False
|
| 1373 |
st.session_state.start_pipeline = False
|
|
|
|
| 20 |
import os
|
| 21 |
import streamlit.components.v1 as components
|
| 22 |
import time
|
| 23 |
+
import logging
|
| 24 |
+
import threading
|
| 25 |
import re
|
| 26 |
import math
|
| 27 |
import json
|
|
|
|
| 45 |
os.environ["STREAMLIT_SERVER_ENABLE_CORS"] = "false"
|
| 46 |
os.environ["STREAMLIT_SERVER_ENABLE_XSRF_PROTECTION"] = "false"
|
| 47 |
|
| 48 |
+
# Streamlit executes the app on its ScriptRunner thread rather than Python's
|
| 49 |
+
# process main thread. Capture that thread so background worker threads cannot
|
| 50 |
+
# emit browser/D1 heartbeats on behalf of work they do not own.
|
| 51 |
+
STREAMLIT_SCRIPT_THREAD_ID = threading.get_ident()
|
| 52 |
+
|
| 53 |
WORKSPACE_DIR = APP_CONFIG.get("workspace_dir", ".workspace")
|
| 54 |
os.makedirs(WORKSPACE_DIR, exist_ok=True)
|
| 55 |
LEGACY_CHECKPOINT_FILE = os.path.join(WORKSPACE_DIR, "snapshot.json")
|
|
|
|
| 59 |
|
| 60 |
|
| 61 |
def update_heartbeat():
|
| 62 |
+
"""Refresh D1 only while the Python generation process is actually active."""
|
| 63 |
+
if threading.get_ident() != STREAMLIT_SCRIPT_THREAD_ID:
|
| 64 |
+
return
|
| 65 |
now = time.time()
|
| 66 |
task = st.session_state.get("sp_generation_task") or {}
|
| 67 |
task_id = str(task.get("task_id") or "").strip()
|
|
|
|
| 75 |
current_node=str(st.session_state.get("sp_current_node") or "")[:240],
|
| 76 |
)
|
| 77 |
st.session_state["sp_last_task_heartbeat"] = now
|
| 78 |
+
except Exception as exc:
|
| 79 |
+
logging.warning(
|
| 80 |
+
"[SP_TASK] heartbeat_enqueue_failed task_id=%s error=%s",
|
| 81 |
+
task_id,
|
| 82 |
+
type(exc).__name__,
|
| 83 |
+
)
|
| 84 |
|
| 85 |
|
| 86 |
def is_system_busy():
|
|
|
|
| 418 |
st.session_state["sp_generation_idempotency_key"] = idempotency_key
|
| 419 |
st.session_state["sp_generation_task"] = task
|
| 420 |
st.session_state["sp_last_task_heartbeat"] = time.time()
|
| 421 |
+
logging.info(
|
| 422 |
+
"[SP_TASK] task_started task_id=%s mode=%s language=%s",
|
| 423 |
+
str(task.get("task_id") or ""),
|
| 424 |
+
mode,
|
| 425 |
+
language,
|
| 426 |
+
)
|
| 427 |
return task, idempotency_key
|
| 428 |
|
| 429 |
|
|
|
|
| 547 |
if not package_bytes:
|
| 548 |
raise RuntimeError("validated_package_file_empty")
|
| 549 |
|
| 550 |
+
logging.info(
|
| 551 |
+
"[SP_TASK] task_finalization_request task_id=%s status=%s node=%s",
|
| 552 |
+
task_id,
|
| 553 |
+
status,
|
| 554 |
+
str(pending.get("current_node") or "")[:120],
|
| 555 |
+
)
|
| 556 |
response = finish_generation_task(
|
| 557 |
task_id,
|
| 558 |
status=status,
|
|
|
|
| 561 |
current_node=str(pending.get("current_node") or "")[:240],
|
| 562 |
)
|
| 563 |
|
| 564 |
+
logging.info(
|
| 565 |
+
"[SP_TASK] task_finalization_confirmed task_id=%s status=%s reservation=%s",
|
| 566 |
+
task_id,
|
| 567 |
+
status,
|
| 568 |
+
str((response.get("reservation") or {}).get("status") or ""),
|
| 569 |
+
)
|
| 570 |
+
|
| 571 |
if status == "completed":
|
| 572 |
filename = str(package.get("filename") or "SmartPaper.zip")
|
| 573 |
pending_path = str(package.get("pending_path") or "")
|
|
|
|
| 893 |
monitor_ui = st.empty()
|
| 894 |
|
| 895 |
def render_monitor(agent, phase, current_sec="", details=""):
|
| 896 |
+
node = str(current_sec or phase or "")[:240]
|
| 897 |
+
st.session_state["sp_current_node"] = node
|
| 898 |
+
if node and node != st.session_state.get("sp_last_logged_node"):
|
| 899 |
+
task_id = str((st.session_state.get("sp_generation_task") or {}).get("task_id") or "")
|
| 900 |
+
logging.info("[SP_TASK] node_enter task_id=%s node=%s", task_id, node)
|
| 901 |
+
st.session_state["sp_last_logged_node"] = node
|
| 902 |
monitor_ui.markdown(f"""
|
| 903 |
<div class='log-box'>
|
| 904 |
<div><b>{tr(lang_code, 'monitor_pipeline')}:</b> {phase}</div><div><b>{tr(lang_code, 'monitor_node')}:</b> {current_sec}</div><div><b>{tr(lang_code, 'monitor_log')}:</b> {details}</div><hr>
|
|
|
|
| 957 |
|
| 958 |
st.session_state.is_running = True
|
| 959 |
agent = SSciPipelineAgent(global_ds_key, global_s2_key)
|
| 960 |
+
agent.set_execution_progress_hook(update_heartbeat)
|
| 961 |
SAFE_CHUNK_WORDS = agent.chunk_words_default
|
| 962 |
qual_engine = get_qualitative_engine(agent)
|
| 963 |
quant_engine = get_quantitative_engine()
|
|
|
|
| 1391 |
render_monitor(agent, tr(lang_code, "complete"), "✅", tr(lang_code, "document_generated"))
|
| 1392 |
|
| 1393 |
except Exception as e:
|
| 1394 |
+
task_id = str((st.session_state.get("sp_generation_task") or {}).get("task_id") or "")
|
| 1395 |
+
logging.exception(
|
| 1396 |
+
"[SP_TASK] generation_exception task_id=%s node=%s error_type=%s",
|
| 1397 |
+
task_id,
|
| 1398 |
+
str(st.session_state.get("sp_current_node") or "generation")[:120],
|
| 1399 |
+
type(e).__name__,
|
| 1400 |
+
)
|
| 1401 |
if not completion_finalization_attempted:
|
| 1402 |
try:
|
| 1403 |
_finish_managed_generation_task(
|
|
|
|
| 1411 |
pass
|
| 1412 |
st.error(tr(lang_code, "startup_error", error=e))
|
| 1413 |
finally:
|
| 1414 |
+
task_id = str((st.session_state.get("sp_generation_task") or {}).get("task_id") or "")
|
| 1415 |
+
logging.info(
|
| 1416 |
+
"[SP_TASK] pipeline_exit task_id=%s node=%s",
|
| 1417 |
+
task_id,
|
| 1418 |
+
str(st.session_state.get("sp_current_node") or "")[:120],
|
| 1419 |
+
)
|
| 1420 |
clear_heartbeat()
|
| 1421 |
st.session_state.is_running = False
|
| 1422 |
st.session_state.start_pipeline = False
|
config.py
CHANGED
|
@@ -21,11 +21,18 @@ def load_config():
|
|
| 21 |
"rag_chunk_size": int(os.environ.get("RAG_CHUNK_SIZE", "3000")),
|
| 22 |
"rag_top_k": int(os.environ.get("RAG_TOP_K", "3")),
|
| 23 |
"max_extract_chars": int(os.environ.get("MAX_EXTRACT_CHARS", "150000")),
|
|
|
|
|
|
|
|
|
|
| 24 |
"api_timeouts": {
|
| 25 |
-
"standard": (15,
|
| 26 |
-
"stream": (15,
|
| 27 |
"search": 15
|
| 28 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
# --- API 节点配置 ---
|
| 31 |
"deepseek_api_url": os.environ.get(
|
|
|
|
| 21 |
"rag_chunk_size": int(os.environ.get("RAG_CHUNK_SIZE", "3000")),
|
| 22 |
"rag_top_k": int(os.environ.get("RAG_TOP_K", "3")),
|
| 23 |
"max_extract_chars": int(os.environ.get("MAX_EXTRACT_CHARS", "150000")),
|
| 24 |
+
# A DeepSeek request must fail before the 90-second Worker generation
|
| 25 |
+
# lease can expire without real execution progress. Retries renew the
|
| 26 |
+
# lease through the execution heartbeat hook.
|
| 27 |
"api_timeouts": {
|
| 28 |
+
"standard": (15, 70),
|
| 29 |
+
"stream": (15, 70),
|
| 30 |
"search": 15
|
| 31 |
},
|
| 32 |
+
"deepseek_stream_idle_seconds": max(
|
| 33 |
+
20,
|
| 34 |
+
min(65, int(os.environ.get("DEEPSEEK_STREAM_IDLE_SECONDS", "60"))),
|
| 35 |
+
),
|
| 36 |
|
| 37 |
# --- API 节点配置 ---
|
| 38 |
"deepseek_api_url": os.environ.get(
|
core/pipeline_agent.py
CHANGED
|
@@ -96,6 +96,7 @@ class SSciPipelineAgent:
|
|
| 96 |
for source in ("s2", "oa")
|
| 97 |
}
|
| 98 |
self._cancel_checker = None
|
|
|
|
| 99 |
self.s2_fused = False
|
| 100 |
self.oa_fused = False
|
| 101 |
self.citation_map = {}
|
|
@@ -147,6 +148,10 @@ class SSciPipelineAgent:
|
|
| 147 |
self.openalex_api_url = APP_CONFIG["openalex_api_url"]
|
| 148 |
self.s2_api_url = APP_CONFIG["s2_api_url"]
|
| 149 |
self.timeouts = APP_CONFIG["api_timeouts"]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
self.global_memory = {
|
| 152 |
"hypotheses": "", "variables": "", "model_summary": "",
|
|
@@ -164,6 +169,23 @@ class SSciPipelineAgent:
|
|
| 164 |
def set_cancel_checker(self, checker):
|
| 165 |
self._cancel_checker = checker
|
| 166 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
def _check_cancelled(self):
|
| 168 |
if self._cancel_checker is not None:
|
| 169 |
result = self._cancel_checker()
|
|
@@ -708,6 +730,13 @@ class SSciPipelineAgent:
|
|
| 708 |
|
| 709 |
try:
|
| 710 |
self._check_cancelled()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 711 |
response = requests.post(
|
| 712 |
self.deepseek_api_url,
|
| 713 |
headers=self._deepseek_headers(self.ds_key),
|
|
@@ -716,6 +745,7 @@ class SSciPipelineAgent:
|
|
| 716 |
)
|
| 717 |
|
| 718 |
self._check_cancelled()
|
|
|
|
| 719 |
if response.status_code != 200:
|
| 720 |
self._raise_for_deepseek_status(response)
|
| 721 |
|
|
@@ -769,6 +799,7 @@ class SSciPipelineAgent:
|
|
| 769 |
self.deepseek_max_retries,
|
| 770 |
exc,
|
| 771 |
)
|
|
|
|
| 772 |
if is_last:
|
| 773 |
break
|
| 774 |
time.sleep(self._retry_delay(attempt))
|
|
@@ -844,10 +875,22 @@ class SSciPipelineAgent:
|
|
| 844 |
emitted_content = False
|
| 845 |
reasoning_open = False
|
| 846 |
reasoning_chunks = 0
|
|
|
|
|
|
|
|
|
|
| 847 |
start_time = time.time()
|
|
|
|
| 848 |
|
| 849 |
try:
|
| 850 |
self._check_cancelled()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 851 |
with requests.post(
|
| 852 |
self.deepseek_api_url,
|
| 853 |
headers=self._deepseek_headers(self.ds_key),
|
|
@@ -863,9 +906,22 @@ class SSciPipelineAgent:
|
|
| 863 |
):
|
| 864 |
self._check_cancelled()
|
| 865 |
event = self._parse_sse_line(raw_line)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 866 |
if not event:
|
| 867 |
continue
|
| 868 |
if event.get("done"):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 869 |
break
|
| 870 |
|
| 871 |
choices = event.get("choices") or []
|
|
@@ -879,7 +935,9 @@ class SSciPipelineAgent:
|
|
| 879 |
content = delta.get("content") or ""
|
| 880 |
|
| 881 |
if reasoning_content:
|
|
|
|
| 882 |
reasoning_chunks += 1
|
|
|
|
| 883 |
if not reasoning_open:
|
| 884 |
reasoning_open = True
|
| 885 |
logging.info(
|
|
@@ -902,6 +960,16 @@ class SSciPipelineAgent:
|
|
| 902 |
)
|
| 903 |
|
| 904 |
if content:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 905 |
if (
|
| 906 |
reasoning_open
|
| 907 |
and self.deepseek_show_reasoning
|
|
@@ -919,9 +987,13 @@ class SSciPipelineAgent:
|
|
| 919 |
"DeepSeek stream ended without final content."
|
| 920 |
)
|
| 921 |
|
|
|
|
| 922 |
logging.info(
|
| 923 |
-
"DeepSeek stream complete | seconds=%.1f",
|
| 924 |
time.time() - start_time,
|
|
|
|
|
|
|
|
|
|
| 925 |
)
|
| 926 |
return
|
| 927 |
|
|
@@ -953,6 +1025,7 @@ class SSciPipelineAgent:
|
|
| 953 |
"content had already been emitted."
|
| 954 |
) from exc
|
| 955 |
|
|
|
|
| 956 |
is_last = attempt == self.deepseek_max_retries - 1
|
| 957 |
if is_last:
|
| 958 |
break
|
|
|
|
| 96 |
for source in ("s2", "oa")
|
| 97 |
}
|
| 98 |
self._cancel_checker = None
|
| 99 |
+
self._execution_progress_hook = None
|
| 100 |
self.s2_fused = False
|
| 101 |
self.oa_fused = False
|
| 102 |
self.citation_map = {}
|
|
|
|
| 148 |
self.openalex_api_url = APP_CONFIG["openalex_api_url"]
|
| 149 |
self.s2_api_url = APP_CONFIG["s2_api_url"]
|
| 150 |
self.timeouts = APP_CONFIG["api_timeouts"]
|
| 151 |
+
self.deepseek_stream_idle_seconds = max(
|
| 152 |
+
20,
|
| 153 |
+
int(APP_CONFIG.get("deepseek_stream_idle_seconds", 60)),
|
| 154 |
+
)
|
| 155 |
|
| 156 |
self.global_memory = {
|
| 157 |
"hypotheses": "", "variables": "", "model_summary": "",
|
|
|
|
| 169 |
def set_cancel_checker(self, checker):
|
| 170 |
self._cancel_checker = checker
|
| 171 |
|
| 172 |
+
def set_execution_progress_hook(self, hook):
|
| 173 |
+
"""Register a best-effort callback tied to real backend execution."""
|
| 174 |
+
self._execution_progress_hook = hook
|
| 175 |
+
|
| 176 |
+
def _touch_execution_progress(self, stage=""):
|
| 177 |
+
hook = self._execution_progress_hook
|
| 178 |
+
if hook is None:
|
| 179 |
+
return
|
| 180 |
+
try:
|
| 181 |
+
hook()
|
| 182 |
+
except Exception as exc:
|
| 183 |
+
logging.warning(
|
| 184 |
+
"Execution heartbeat hook failed | stage=%s | error=%s",
|
| 185 |
+
str(stage or "unknown")[:80],
|
| 186 |
+
type(exc).__name__,
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
def _check_cancelled(self):
|
| 190 |
if self._cancel_checker is not None:
|
| 191 |
result = self._cancel_checker()
|
|
|
|
| 730 |
|
| 731 |
try:
|
| 732 |
self._check_cancelled()
|
| 733 |
+
self._touch_execution_progress("deepseek_static_request")
|
| 734 |
+
logging.info(
|
| 735 |
+
"DeepSeek static attempt start | attempt=%s/%s | model=%s",
|
| 736 |
+
attempt + 1,
|
| 737 |
+
self.deepseek_max_retries,
|
| 738 |
+
current_model,
|
| 739 |
+
)
|
| 740 |
response = requests.post(
|
| 741 |
self.deepseek_api_url,
|
| 742 |
headers=self._deepseek_headers(self.ds_key),
|
|
|
|
| 745 |
)
|
| 746 |
|
| 747 |
self._check_cancelled()
|
| 748 |
+
self._touch_execution_progress("deepseek_static_response")
|
| 749 |
if response.status_code != 200:
|
| 750 |
self._raise_for_deepseek_status(response)
|
| 751 |
|
|
|
|
| 799 |
self.deepseek_max_retries,
|
| 800 |
exc,
|
| 801 |
)
|
| 802 |
+
self._touch_execution_progress("deepseek_static_retry")
|
| 803 |
if is_last:
|
| 804 |
break
|
| 805 |
time.sleep(self._retry_delay(attempt))
|
|
|
|
| 875 |
emitted_content = False
|
| 876 |
reasoning_open = False
|
| 877 |
reasoning_chunks = 0
|
| 878 |
+
content_chunks = 0
|
| 879 |
+
content_chars = 0
|
| 880 |
+
first_content_at = None
|
| 881 |
start_time = time.time()
|
| 882 |
+
last_model_activity = start_time
|
| 883 |
|
| 884 |
try:
|
| 885 |
self._check_cancelled()
|
| 886 |
+
self._touch_execution_progress("deepseek_stream_request")
|
| 887 |
+
logging.info(
|
| 888 |
+
"DeepSeek stream attempt start | attempt=%s/%s | model=%s | idle_limit=%ss",
|
| 889 |
+
attempt + 1,
|
| 890 |
+
self.deepseek_max_retries,
|
| 891 |
+
current_model,
|
| 892 |
+
self.deepseek_stream_idle_seconds,
|
| 893 |
+
)
|
| 894 |
with requests.post(
|
| 895 |
self.deepseek_api_url,
|
| 896 |
headers=self._deepseek_headers(self.ds_key),
|
|
|
|
| 906 |
):
|
| 907 |
self._check_cancelled()
|
| 908 |
event = self._parse_sse_line(raw_line)
|
| 909 |
+
now = time.time()
|
| 910 |
+
if now - last_model_activity > self.deepseek_stream_idle_seconds:
|
| 911 |
+
raise DeepSeekAPIError(
|
| 912 |
+
"DeepSeek stream produced no model progress for "
|
| 913 |
+
f"{self.deepseek_stream_idle_seconds} seconds."
|
| 914 |
+
)
|
| 915 |
if not event:
|
| 916 |
continue
|
| 917 |
if event.get("done"):
|
| 918 |
+
logging.info(
|
| 919 |
+
"DeepSeek stream done marker | seconds=%.1f | reasoning_chunks=%s | content_chunks=%s | content_chars=%s",
|
| 920 |
+
now - start_time,
|
| 921 |
+
reasoning_chunks,
|
| 922 |
+
content_chunks,
|
| 923 |
+
content_chars,
|
| 924 |
+
)
|
| 925 |
break
|
| 926 |
|
| 927 |
choices = event.get("choices") or []
|
|
|
|
| 935 |
content = delta.get("content") or ""
|
| 936 |
|
| 937 |
if reasoning_content:
|
| 938 |
+
last_model_activity = time.time()
|
| 939 |
reasoning_chunks += 1
|
| 940 |
+
self._touch_execution_progress("deepseek_reasoning")
|
| 941 |
if not reasoning_open:
|
| 942 |
reasoning_open = True
|
| 943 |
logging.info(
|
|
|
|
| 960 |
)
|
| 961 |
|
| 962 |
if content:
|
| 963 |
+
last_model_activity = time.time()
|
| 964 |
+
self._touch_execution_progress("deepseek_content")
|
| 965 |
+
if first_content_at is None:
|
| 966 |
+
first_content_at = last_model_activity
|
| 967 |
+
logging.info(
|
| 968 |
+
"DeepSeek first content | seconds=%.1f",
|
| 969 |
+
first_content_at - start_time,
|
| 970 |
+
)
|
| 971 |
+
content_chunks += 1
|
| 972 |
+
content_chars += len(str(content))
|
| 973 |
if (
|
| 974 |
reasoning_open
|
| 975 |
and self.deepseek_show_reasoning
|
|
|
|
| 987 |
"DeepSeek stream ended without final content."
|
| 988 |
)
|
| 989 |
|
| 990 |
+
self._touch_execution_progress("deepseek_stream_complete")
|
| 991 |
logging.info(
|
| 992 |
+
"DeepSeek stream complete | seconds=%.1f | reasoning_chunks=%s | content_chunks=%s | content_chars=%s",
|
| 993 |
time.time() - start_time,
|
| 994 |
+
reasoning_chunks,
|
| 995 |
+
content_chunks,
|
| 996 |
+
content_chars,
|
| 997 |
)
|
| 998 |
return
|
| 999 |
|
|
|
|
| 1025 |
"content had already been emitted."
|
| 1026 |
) from exc
|
| 1027 |
|
| 1028 |
+
self._touch_execution_progress("deepseek_stream_retry")
|
| 1029 |
is_last = attempt == self.deepseek_max_retries - 1
|
| 1030 |
if is_last:
|
| 1031 |
break
|
managed_auth.py
CHANGED
|
@@ -29,6 +29,31 @@ from smartpaper_restore_bridge import edge_bridge
|
|
| 29 |
EDGE_ASSERTION_COOKIE_NAME = "smartpaper_edge_assertion"
|
| 30 |
EDGE_ASSERTION_AUDIENCE = "smartpaper-streamlit"
|
| 31 |
ASSERTION_CLOCK_SKEW_SECONDS = 30
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
|
| 34 |
def _runtime_value(name: str) -> str:
|
|
@@ -282,11 +307,19 @@ def heartbeat_generation_task(task_id: str, *, current_node: str = "") -> dict[s
|
|
| 282 |
normalized = str(task_id or "").strip()
|
| 283 |
if not normalized:
|
| 284 |
raise RuntimeError("task_id_required")
|
|
|
|
|
|
|
| 285 |
bucket = int(time.time() // 20)
|
| 286 |
_edge_call(
|
| 287 |
"task_heartbeat",
|
| 288 |
f"heartbeat:{normalized}:{bucket}",
|
| 289 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
fire_and_forget=True,
|
| 291 |
key_prefix="sp_task_heartbeat",
|
| 292 |
)
|
|
|
|
| 29 |
EDGE_ASSERTION_COOKIE_NAME = "smartpaper_edge_assertion"
|
| 30 |
EDGE_ASSERTION_AUDIENCE = "smartpaper-streamlit"
|
| 31 |
ASSERTION_CLOCK_SKEW_SECONDS = 30
|
| 32 |
+
EXECUTION_HEARTBEAT_PROOF_VERSION = "sp-execution-heartbeat-v1"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _execution_heartbeat_proof(task_id: str, current_node: str) -> tuple[int, str]:
|
| 36 |
+
"""Create a short-lived HMAC proof that only the HF backend can mint."""
|
| 37 |
+
user = st.session_state.get("sp_user") or {}
|
| 38 |
+
user_id = str(user.get("id") or "").strip()
|
| 39 |
+
normalized_task = str(task_id or "").strip()
|
| 40 |
+
normalized_node = str(current_node or "")[:240]
|
| 41 |
+
if not user_id or not normalized_task:
|
| 42 |
+
raise RuntimeError("execution_heartbeat_identity_required")
|
| 43 |
+
issued_at = int(time.time())
|
| 44 |
+
task_hash = hashlib.sha256(normalized_task.encode("utf-8")).hexdigest()
|
| 45 |
+
user_hash = hashlib.sha256(user_id.encode("utf-8")).hexdigest()
|
| 46 |
+
node_hash = hashlib.sha256(normalized_node.encode("utf-8")).hexdigest()
|
| 47 |
+
canonical = (
|
| 48 |
+
f"{EXECUTION_HEARTBEAT_PROOF_VERSION}:"
|
| 49 |
+
f"{task_hash}:{user_hash}:{node_hash}:{issued_at}"
|
| 50 |
+
)
|
| 51 |
+
signature = hmac.new(
|
| 52 |
+
_service_key().encode("utf-8"),
|
| 53 |
+
canonical.encode("ascii"),
|
| 54 |
+
hashlib.sha256,
|
| 55 |
+
).hexdigest()
|
| 56 |
+
return issued_at, signature
|
| 57 |
|
| 58 |
|
| 59 |
def _runtime_value(name: str) -> str:
|
|
|
|
| 307 |
normalized = str(task_id or "").strip()
|
| 308 |
if not normalized:
|
| 309 |
raise RuntimeError("task_id_required")
|
| 310 |
+
normalized_node = str(current_node or "")[:240]
|
| 311 |
+
issued_at, proof = _execution_heartbeat_proof(normalized, normalized_node)
|
| 312 |
bucket = int(time.time() // 20)
|
| 313 |
_edge_call(
|
| 314 |
"task_heartbeat",
|
| 315 |
f"heartbeat:{normalized}:{bucket}",
|
| 316 |
+
{
|
| 317 |
+
"task_id": normalized,
|
| 318 |
+
"current_node": normalized_node,
|
| 319 |
+
"heartbeat_source": "execution",
|
| 320 |
+
"heartbeat_issued_at": issued_at,
|
| 321 |
+
"heartbeat_proof": proof,
|
| 322 |
+
},
|
| 323 |
fire_and_forget=True,
|
| 324 |
key_prefix="sp_task_heartbeat",
|
| 325 |
)
|
smartpaper_restore_bridge/frontend/index.html
CHANGED
|
@@ -199,25 +199,14 @@
|
|
| 199 |
}
|
| 200 |
}
|
| 201 |
|
| 202 |
-
async function heartbeatActiveTask() {
|
| 203 |
-
const active = readJson(ACTIVE_TASK_KEY, null);
|
| 204 |
-
if (!active || !active.api_origin || !active.task_id) return;
|
| 205 |
-
const result = await postJson(active.api_origin, ACTION_PATHS.task_heartbeat, {
|
| 206 |
-
task_id:active.task_id,
|
| 207 |
-
current_node:active.current_node || "generation"
|
| 208 |
-
});
|
| 209 |
-
if (result.cancel_requested || [401,403,404,409].includes(Number(result.http_status || 0))) {
|
| 210 |
-
window.localStorage.removeItem(ACTIVE_TASK_KEY);
|
| 211 |
-
}
|
| 212 |
-
}
|
| 213 |
-
|
| 214 |
function startBackgroundWork() {
|
|
|
|
|
|
|
|
|
|
| 215 |
if (heartbeatTimer) return;
|
| 216 |
heartbeatTimer = window.setInterval(() => {
|
| 217 |
-
heartbeatActiveTask();
|
| 218 |
drainPending();
|
| 219 |
}, HEARTBEAT_MS);
|
| 220 |
-
heartbeatActiveTask();
|
| 221 |
drainPending();
|
| 222 |
}
|
| 223 |
|
|
|
|
| 199 |
}
|
| 200 |
}
|
| 201 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
function startBackgroundWork() {
|
| 203 |
+
// R2: the browser may retry commands that Python already emitted, but it
|
| 204 |
+
// must never manufacture task liveness merely because the tab remains open.
|
| 205 |
+
// Only real backend execution progress is allowed to enqueue task_heartbeat.
|
| 206 |
if (heartbeatTimer) return;
|
| 207 |
heartbeatTimer = window.setInterval(() => {
|
|
|
|
| 208 |
drainPending();
|
| 209 |
}, HEARTBEAT_MS);
|
|
|
|
| 210 |
drainPending();
|
| 211 |
}
|
| 212 |
|