Spaces:
Running
Running
R14-TQ2-PQ1-R4-R1 Production Readiness Consolidated Fix
#66
by GuXiaopeng - opened
- app.py +11 -2
- config.py +11 -0
- core/generation_runner.py +70 -1
- core/pipeline_agent.py +334 -98
- core/production_guard.py +8 -0
- core/qualitative_engine.py +22 -8
- i18n.py +4 -4
- smartpaper_restore_bridge/frontend/index.html +105 -71
app.py
CHANGED
|
@@ -759,8 +759,8 @@ def _render_monitor_box(task: dict, lang_code: str) -> None:
|
|
| 759 |
"""
|
| 760 |
|
| 761 |
st.markdown(f"""
|
| 762 |
-
{progress_markup}
|
| 763 |
<div class='log-box' id='sp-live-monitor'>
|
|
|
|
| 764 |
<div><b>{tr(lang_code, 'monitor_pipeline')}:</b> <span id='sp-live-status'>{html.escape(status_label)}</span></div>
|
| 765 |
<div><b>{tr(lang_code, 'monitor_node')}:</b> <span id='sp-live-node'>{html.escape(node_label)}</span></div>
|
| 766 |
<div><b>{tr(lang_code, 'monitor_log')}:</b> <span id='sp-live-checkpoint-stage'>{html.escape(tr(lang_code, 'monitor_checkpoint_saved', seq=checkpoint_seq))}</span><span id='sp-live-checkpoint-seq' style='display:none'>{checkpoint_seq}</span></div>
|
|
@@ -794,6 +794,14 @@ def _watch_task_status_live(task: dict) -> dict | None:
|
|
| 794 |
if not api_origin:
|
| 795 |
logging.warning("[SP_UI] live_watch_missing_api_origin task_id=%s", task_id)
|
| 796 |
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 797 |
try:
|
| 798 |
watch_result = edge_bridge(
|
| 799 |
action="task_watch",
|
|
@@ -805,8 +813,9 @@ def _watch_task_status_live(task: dict) -> dict | None:
|
|
| 805 |
"preview_interval_ms": 700,
|
| 806 |
"preview_path": _task_preview_path(task_id),
|
| 807 |
"expected_execution_epoch": int(task.get("execution_epoch") or 0),
|
|
|
|
| 808 |
"initial_checkpoint_seq": int(task.get("checkpoint_seq") or 0),
|
| 809 |
-
"best_effort_page_exit_cancel":
|
| 810 |
"draft_status_labels": {
|
| 811 |
"default": tr(str(st.session_state.get("sp_lang_code") or "zh"), "draft_preparing"),
|
| 812 |
"inputs_and_evidence": tr(str(st.session_state.get("sp_lang_code") or "zh"), "draft_stage_inputs"),
|
|
|
|
| 759 |
"""
|
| 760 |
|
| 761 |
st.markdown(f"""
|
|
|
|
| 762 |
<div class='log-box' id='sp-live-monitor'>
|
| 763 |
+
{progress_markup}
|
| 764 |
<div><b>{tr(lang_code, 'monitor_pipeline')}:</b> <span id='sp-live-status'>{html.escape(status_label)}</span></div>
|
| 765 |
<div><b>{tr(lang_code, 'monitor_node')}:</b> <span id='sp-live-node'>{html.escape(node_label)}</span></div>
|
| 766 |
<div><b>{tr(lang_code, 'monitor_log')}:</b> <span id='sp-live-checkpoint-stage'>{html.escape(tr(lang_code, 'monitor_checkpoint_saved', seq=checkpoint_seq))}</span><span id='sp-live-checkpoint-seq' style='display:none'>{checkpoint_seq}</span></div>
|
|
|
|
| 794 |
if not api_origin:
|
| 795 |
logging.warning("[SP_UI] live_watch_missing_api_origin task_id=%s", task_id)
|
| 796 |
return None
|
| 797 |
+
expected_request_hash = str(task.get("request_hash") or "").strip().lower()
|
| 798 |
+
if not expected_request_hash:
|
| 799 |
+
try:
|
| 800 |
+
request_path = _task_dir(task_id) / "request.json"
|
| 801 |
+
if request_path.is_file():
|
| 802 |
+
expected_request_hash = str(json.loads(request_path.read_text(encoding="utf-8")).get("request_hash") or "").strip().lower()
|
| 803 |
+
except Exception:
|
| 804 |
+
expected_request_hash = ""
|
| 805 |
try:
|
| 806 |
watch_result = edge_bridge(
|
| 807 |
action="task_watch",
|
|
|
|
| 813 |
"preview_interval_ms": 700,
|
| 814 |
"preview_path": _task_preview_path(task_id),
|
| 815 |
"expected_execution_epoch": int(task.get("execution_epoch") or 0),
|
| 816 |
+
"expected_request_hash": expected_request_hash,
|
| 817 |
"initial_checkpoint_seq": int(task.get("checkpoint_seq") or 0),
|
| 818 |
+
"best_effort_page_exit_cancel": False,
|
| 819 |
"draft_status_labels": {
|
| 820 |
"default": tr(str(st.session_state.get("sp_lang_code") or "zh"), "draft_preparing"),
|
| 821 |
"inputs_and_evidence": tr(str(st.session_state.get("sp_lang_code") or "zh"), "draft_stage_inputs"),
|
config.py
CHANGED
|
@@ -15,6 +15,17 @@ def load_config():
|
|
| 15 |
"s2_max_logical_searches": max(1, min(6, int(os.environ.get("S2_MAX_LOGICAL_SEARCHES", "3")))),
|
| 16 |
"citation_planner_batch_size": max(8, min(24, int(os.environ.get("CITATION_PLANNER_BATCH_SIZE", "18")))),
|
| 17 |
"citation_planner_max_tokens": max(1200, min(3200, int(os.environ.get("CITATION_PLANNER_MAX_TOKENS", "2200")))),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
"chunk_words_default": int(os.environ.get("CHUNK_WORDS_DEFAULT", "1500")),
|
| 19 |
"target_words_min": 5000,
|
| 20 |
"target_words_default": 5000,
|
|
|
|
| 15 |
"s2_max_logical_searches": max(1, min(6, int(os.environ.get("S2_MAX_LOGICAL_SEARCHES", "3")))),
|
| 16 |
"citation_planner_batch_size": max(8, min(24, int(os.environ.get("CITATION_PLANNER_BATCH_SIZE", "18")))),
|
| 17 |
"citation_planner_max_tokens": max(1200, min(3200, int(os.environ.get("CITATION_PLANNER_MAX_TOKENS", "2200")))),
|
| 18 |
+
# R14-TQ2-PQ1-R4-R1: pre-draft reasoning is bounded and observable.
|
| 19 |
+
# These are diagnostic SLA budgets, not manuscript-failure authorities.
|
| 20 |
+
"prewrite_reasoning_max_tokens": {
|
| 21 |
+
"literature_synthesis": max(1200, min(3200, int(os.environ.get("PREWRITE_SYNTHESIS_REASONING_MAX_TOKENS", "2400")))),
|
| 22 |
+
"qualitative_packet": max(1200, min(3200, int(os.environ.get("PREWRITE_QUAL_PACKET_REASONING_MAX_TOKENS", "2400")))),
|
| 23 |
+
},
|
| 24 |
+
"prewrite_sla_seconds": {
|
| 25 |
+
"literature_synthesis": max(30, int(os.environ.get("PREWRITE_SYNTHESIS_SLA_SECONDS", "90"))),
|
| 26 |
+
"research_logic": max(15, int(os.environ.get("PREWRITE_RESEARCH_LOGIC_SLA_SECONDS", "30"))),
|
| 27 |
+
"qualitative_packet": max(30, int(os.environ.get("PREWRITE_QUAL_PACKET_SLA_SECONDS", "75"))),
|
| 28 |
+
},
|
| 29 |
"chunk_words_default": int(os.environ.get("CHUNK_WORDS_DEFAULT", "1500")),
|
| 30 |
"target_words_min": 5000,
|
| 31 |
"target_words_default": 5000,
|
core/generation_runner.py
CHANGED
|
@@ -1721,7 +1721,7 @@ class GenerationRunner:
|
|
| 1721 |
)
|
| 1722 |
|
| 1723 |
@staticmethod
|
| 1724 |
-
def _evidence_coverage_summary(*, status, early_risk, final_reference_count, minimums, validated_by_top_level, claims_by_top_level, events):
|
| 1725 |
sections = []
|
| 1726 |
for section_id, planned_minimum in dict(minimums or {}).items():
|
| 1727 |
validated = len(set((validated_by_top_level or {}).get(section_id) or []))
|
|
@@ -1739,9 +1739,52 @@ class GenerationRunner:
|
|
| 1739 |
"early_risk_detected": bool(early_risk),
|
| 1740 |
"final_reference_count": max(0, int(final_reference_count or 0)),
|
| 1741 |
"sections": sections,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1742 |
"events": list(events or []),
|
| 1743 |
}
|
| 1744 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1745 |
def _resolve_paths(self, items) -> list[str]:
|
| 1746 |
output = []
|
| 1747 |
for item in items or []:
|
|
@@ -1834,7 +1877,10 @@ class GenerationRunner:
|
|
| 1834 |
)
|
| 1835 |
else:
|
| 1836 |
raw_interviews = extract_text_from_files(interview_paths[:1]) if interview_paths else ""
|
|
|
|
|
|
|
| 1837 |
user_data_md, _, data_metadata = qual_engine.resolve_qualitative_data(topic, keywords, raw_interviews)
|
|
|
|
| 1838 |
state.update(
|
| 1839 |
{
|
| 1840 |
"topic": topic,
|
|
@@ -1894,9 +1940,12 @@ class GenerationRunner:
|
|
| 1894 |
synthesis_result = str(state.get("synthesis_result") or "")
|
| 1895 |
if not synthesis_result:
|
| 1896 |
self._progress(12, "literature_synthesis")
|
|
|
|
|
|
|
| 1897 |
synthesis_result = self._stream_text(
|
| 1898 |
agent.stream_synthesize_literature(topic, refs_text, user_core_literature=user_lit_context)
|
| 1899 |
)
|
|
|
|
| 1900 |
synthesis_result = re.sub(r"<think>.*?</think>", "", synthesis_result, flags=re.DOTALL).strip()
|
| 1901 |
state.update({"synthesis_result": synthesis_result, "agent_state": agent.export_state()})
|
| 1902 |
self._checkpoint("synthesis_ready", state, 13)
|
|
@@ -1904,12 +1953,15 @@ class GenerationRunner:
|
|
| 1904 |
adaptive_structure = state.get("adaptive_structure")
|
| 1905 |
if not isinstance(adaptive_structure, list) or not adaptive_structure:
|
| 1906 |
self._progress(14, "research_logic")
|
|
|
|
|
|
|
| 1907 |
research_logic_response = self._stream_text(
|
| 1908 |
agent.stream_generate_research_logic(
|
| 1909 |
topic, keywords, synthesis_result, refs_text,
|
| 1910 |
data_evidence=planning_data_md, mode=mode,
|
| 1911 |
)
|
| 1912 |
)
|
|
|
|
| 1913 |
research_logic_contract = re.sub(
|
| 1914 |
r"<think>.*?</think>", "", research_logic_response, flags=re.DOTALL
|
| 1915 |
).strip()
|
|
@@ -2416,6 +2468,8 @@ class GenerationRunner:
|
|
| 2416 |
f"Already completed: {completed_label}. Do not restate their background, gap, or contribution."
|
| 2417 |
if completed_label else ""
|
| 2418 |
)
|
|
|
|
|
|
|
| 2419 |
voice = str(APP_CONFIG.get("journal_voice_profiles", {}).get(mode) or "")
|
| 2420 |
sys_prompt = (
|
| 2421 |
APP_CONFIG.get("sys_academic_writer", "") + "\n"
|
|
@@ -2575,6 +2629,10 @@ Output only final academic prose. Do not report the word count."""
|
|
| 2575 |
data_metadata=data_metadata,
|
| 2576 |
quote_invocation_plan=quote_invocation_plan,
|
| 2577 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2578 |
full_draft_en += en_resolved + "\n\n"
|
| 2579 |
if assigned_tag_list:
|
| 2580 |
logging.info(
|
|
@@ -2647,6 +2705,8 @@ Output only final academic prose. Do not report the word count."""
|
|
| 2647 |
repeated_quotes = agent.find_repeated_qualitative_quotes(full_draft_en, data_metadata)
|
| 2648 |
if repeated_quotes:
|
| 2649 |
raise RuntimeError(f"Repeated qualitative quotations: {repeated_quotes[:5]}")
|
|
|
|
|
|
|
| 2650 |
references = []
|
| 2651 |
seen_refs = set()
|
| 2652 |
for tag in sorted(agent.used_citations, key=agent._citation_tag_number):
|
|
@@ -2667,6 +2727,13 @@ Output only final academic prose. Do not report the word count."""
|
|
| 2667 |
claims_by_top_level[section_id] = claims_by_top_level.get(section_id, 0) + int(
|
| 2668 |
citation_required_claim_counts_by_unit.get(unit_name, 0) or 0
|
| 2669 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2670 |
final_shortfalls = []
|
| 2671 |
for section_id, planned_minimum in citation_top_level_minimums.items():
|
| 2672 |
validated_tags = set(validated_by_top_level.get(section_id) or [])
|
|
@@ -2704,6 +2771,8 @@ Output only final academic prose. Do not report the word count."""
|
|
| 2704 |
validated_by_top_level=validated_by_top_level,
|
| 2705 |
claims_by_top_level=claims_by_top_level,
|
| 2706 |
events=state.get("evidence_coverage_events") or self._evidence_coverage_events,
|
|
|
|
|
|
|
| 2707 |
)
|
| 2708 |
state["evidence_status"] = evidence_status
|
| 2709 |
state["evidence_coverage_summary"] = evidence_coverage_summary
|
|
|
|
| 1721 |
)
|
| 1722 |
|
| 1723 |
@staticmethod
|
| 1724 |
+
def _evidence_coverage_summary(*, status, early_risk, final_reference_count, minimums, validated_by_top_level, claims_by_top_level, events, method_authority_count=0, method_external_claims=0):
|
| 1725 |
sections = []
|
| 1726 |
for section_id, planned_minimum in dict(minimums or {}).items():
|
| 1727 |
validated = len(set((validated_by_top_level or {}).get(section_id) or []))
|
|
|
|
| 1739 |
"early_risk_detected": bool(early_risk),
|
| 1740 |
"final_reference_count": max(0, int(final_reference_count or 0)),
|
| 1741 |
"sections": sections,
|
| 1742 |
+
"methodology_authority": {
|
| 1743 |
+
"validated_method_authority_citations": max(0, int(method_authority_count or 0)),
|
| 1744 |
+
"methodology_external_claims": max(0, int(method_external_claims or 0)),
|
| 1745 |
+
},
|
| 1746 |
"events": list(events or []),
|
| 1747 |
}
|
| 1748 |
|
| 1749 |
+
@staticmethod
|
| 1750 |
+
def _paragraph_token_set(value):
|
| 1751 |
+
text = re.sub(r'\[CITE-\d+\]', ' ', str(value or ''), flags=re.I)
|
| 1752 |
+
return set(re.findall(r'[A-Za-z][A-Za-z0-9_-]{2,}', text.lower()))
|
| 1753 |
+
|
| 1754 |
+
@classmethod
|
| 1755 |
+
def _remove_severe_cross_unit_duplicates(cls, candidate, prior_text):
|
| 1756 |
+
"""Drop only very high-confidence cross-unit duplicate paragraphs."""
|
| 1757 |
+
previous = [p.strip() for p in re.split(r'\n\s*\n', str(prior_text or '')) if len(p.split()) >= 55 and not p.lstrip().startswith('#')]
|
| 1758 |
+
kept, diagnostics = [], []
|
| 1759 |
+
for paragraph in re.split(r'(\n\s*\n)', str(candidate or '')):
|
| 1760 |
+
if re.fullmatch(r'\n\s*\n', paragraph or ''):
|
| 1761 |
+
kept.append(paragraph); continue
|
| 1762 |
+
value = str(paragraph or '').strip()
|
| 1763 |
+
if len(value.split()) < 55 or value.startswith('#'):
|
| 1764 |
+
kept.append(paragraph); continue
|
| 1765 |
+
tokens = cls._paragraph_token_set(value)
|
| 1766 |
+
citations = set(re.findall(r'\[CITE-\d+\]', value, flags=re.I))
|
| 1767 |
+
numerics = set(re.findall(r'(?<![A-Za-z])[-+]?\d+(?:\.\d+)?%?(?![A-Za-z])', value))
|
| 1768 |
+
duplicate = None
|
| 1769 |
+
for old in previous:
|
| 1770 |
+
old_tokens = cls._paragraph_token_set(old)
|
| 1771 |
+
union = tokens | old_tokens
|
| 1772 |
+
similarity = len(tokens & old_tokens) / max(1, len(union))
|
| 1773 |
+
if similarity < 0.90:
|
| 1774 |
+
continue
|
| 1775 |
+
old_citations = set(re.findall(r'\[CITE-\d+\]', old, flags=re.I))
|
| 1776 |
+
old_numerics = set(re.findall(r'(?<![A-Za-z])[-+]?\d+(?:\.\d+)?%?(?![A-Za-z])', old))
|
| 1777 |
+
# Never delete a paragraph carrying different evidence/facts.
|
| 1778 |
+
if citations == old_citations and numerics == old_numerics:
|
| 1779 |
+
duplicate = (similarity, old[:180])
|
| 1780 |
+
break
|
| 1781 |
+
if duplicate:
|
| 1782 |
+
diagnostics.append({"similarity": round(duplicate[0], 4), "removed_preview": value[:180]})
|
| 1783 |
+
continue
|
| 1784 |
+
kept.append(paragraph)
|
| 1785 |
+
previous.append(value)
|
| 1786 |
+
return ''.join(kept).strip(), diagnostics
|
| 1787 |
+
|
| 1788 |
def _resolve_paths(self, items) -> list[str]:
|
| 1789 |
output = []
|
| 1790 |
for item in items or []:
|
|
|
|
| 1877 |
)
|
| 1878 |
else:
|
| 1879 |
raw_interviews = extract_text_from_files(interview_paths[:1]) if interview_paths else ""
|
| 1880 |
+
prewrite_started = time.monotonic()
|
| 1881 |
+
prewrite_calls = agent.ds_calls
|
| 1882 |
user_data_md, _, data_metadata = qual_engine.resolve_qualitative_data(topic, keywords, raw_interviews)
|
| 1883 |
+
agent.record_prewrite_stage("qualitative_packet", prewrite_started, prewrite_calls)
|
| 1884 |
state.update(
|
| 1885 |
{
|
| 1886 |
"topic": topic,
|
|
|
|
| 1940 |
synthesis_result = str(state.get("synthesis_result") or "")
|
| 1941 |
if not synthesis_result:
|
| 1942 |
self._progress(12, "literature_synthesis")
|
| 1943 |
+
prewrite_started = time.monotonic()
|
| 1944 |
+
prewrite_calls = agent.ds_calls
|
| 1945 |
synthesis_result = self._stream_text(
|
| 1946 |
agent.stream_synthesize_literature(topic, refs_text, user_core_literature=user_lit_context)
|
| 1947 |
)
|
| 1948 |
+
agent.record_prewrite_stage("literature_synthesis", prewrite_started, prewrite_calls)
|
| 1949 |
synthesis_result = re.sub(r"<think>.*?</think>", "", synthesis_result, flags=re.DOTALL).strip()
|
| 1950 |
state.update({"synthesis_result": synthesis_result, "agent_state": agent.export_state()})
|
| 1951 |
self._checkpoint("synthesis_ready", state, 13)
|
|
|
|
| 1953 |
adaptive_structure = state.get("adaptive_structure")
|
| 1954 |
if not isinstance(adaptive_structure, list) or not adaptive_structure:
|
| 1955 |
self._progress(14, "research_logic")
|
| 1956 |
+
prewrite_started = time.monotonic()
|
| 1957 |
+
prewrite_calls = agent.ds_calls
|
| 1958 |
research_logic_response = self._stream_text(
|
| 1959 |
agent.stream_generate_research_logic(
|
| 1960 |
topic, keywords, synthesis_result, refs_text,
|
| 1961 |
data_evidence=planning_data_md, mode=mode,
|
| 1962 |
)
|
| 1963 |
)
|
| 1964 |
+
agent.record_prewrite_stage("research_logic", prewrite_started, prewrite_calls)
|
| 1965 |
research_logic_contract = re.sub(
|
| 1966 |
r"<think>.*?</think>", "", research_logic_response, flags=re.DOTALL
|
| 1967 |
).strip()
|
|
|
|
| 2468 |
f"Already completed: {completed_label}. Do not restate their background, gap, or contribution."
|
| 2469 |
if completed_label else ""
|
| 2470 |
)
|
| 2471 |
+
if re.search(r"\(Part\s+2/2\)", display_name, flags=re.I):
|
| 2472 |
+
anti_repeat += " Part 2/2 must extend and complement Part 1/2; do not recap its background, mechanism explanation, evidence, or contribution framing."
|
| 2473 |
voice = str(APP_CONFIG.get("journal_voice_profiles", {}).get(mode) or "")
|
| 2474 |
sys_prompt = (
|
| 2475 |
APP_CONFIG.get("sys_academic_writer", "") + "\n"
|
|
|
|
| 2629 |
data_metadata=data_metadata,
|
| 2630 |
quote_invocation_plan=quote_invocation_plan,
|
| 2631 |
)
|
| 2632 |
+
en_resolved, duplicate_rows = self._remove_severe_cross_unit_duplicates(en_resolved, full_draft_en)
|
| 2633 |
+
if duplicate_rows:
|
| 2634 |
+
logging.warning("Severe cross-unit redundancy removed | unit=%s count=%s max_similarity=%s", display_name, len(duplicate_rows), max(row["similarity"] for row in duplicate_rows))
|
| 2635 |
+
quality_diagnostics.append({"scope":"manuscript_redundancy","name":display_name,"status":"bounded_duplicate_removed","count":len(duplicate_rows),"examples":duplicate_rows[:3]})
|
| 2636 |
full_draft_en += en_resolved + "\n\n"
|
| 2637 |
if assigned_tag_list:
|
| 2638 |
logging.info(
|
|
|
|
| 2705 |
repeated_quotes = agent.find_repeated_qualitative_quotes(full_draft_en, data_metadata)
|
| 2706 |
if repeated_quotes:
|
| 2707 |
raise RuntimeError(f"Repeated qualitative quotations: {repeated_quotes[:5]}")
|
| 2708 |
+
actual_used_tags = {f"[{tag.upper()}]" for tag in re.findall(r"CITE-\d+", full_draft_en, flags=re.I)}
|
| 2709 |
+
agent.used_citations = set(tag for tag in actual_used_tags if tag in agent.citation_map)
|
| 2710 |
references = []
|
| 2711 |
seen_refs = set()
|
| 2712 |
for tag in sorted(agent.used_citations, key=agent._citation_tag_number):
|
|
|
|
| 2727 |
claims_by_top_level[section_id] = claims_by_top_level.get(section_id, 0) + int(
|
| 2728 |
citation_required_claim_counts_by_unit.get(unit_name, 0) or 0
|
| 2729 |
)
|
| 2730 |
+
method_section_id = 'methods' if mode == 'asq' else 'methodology'
|
| 2731 |
+
method_validated_tags = set(validated_by_top_level.get(method_section_id) or []) & set(agent.used_citations)
|
| 2732 |
+
method_authority_tags = {
|
| 2733 |
+
tag for tag in method_validated_tags
|
| 2734 |
+
if 'method_authority' in set((agent.citation_map.get(tag) or {}).get('evidence_lanes') or [])
|
| 2735 |
+
}
|
| 2736 |
+
method_external_claims = max(0, int(claims_by_top_level.get(method_section_id, 0) or 0))
|
| 2737 |
final_shortfalls = []
|
| 2738 |
for section_id, planned_minimum in citation_top_level_minimums.items():
|
| 2739 |
validated_tags = set(validated_by_top_level.get(section_id) or [])
|
|
|
|
| 2771 |
validated_by_top_level=validated_by_top_level,
|
| 2772 |
claims_by_top_level=claims_by_top_level,
|
| 2773 |
events=state.get("evidence_coverage_events") or self._evidence_coverage_events,
|
| 2774 |
+
method_authority_count=len(method_authority_tags),
|
| 2775 |
+
method_external_claims=method_external_claims,
|
| 2776 |
)
|
| 2777 |
state["evidence_status"] = evidence_status
|
| 2778 |
state["evidence_coverage_summary"] = evidence_coverage_summary
|
core/pipeline_agent.py
CHANGED
|
@@ -139,6 +139,7 @@ class SSciPipelineAgent:
|
|
| 139 |
# R3.2.8.6: translation validators emit hierarchical assurance evidence.
|
| 140 |
# Low-level diagnostics cannot independently terminate a manuscript.
|
| 141 |
self.translation_assurance_log = []
|
|
|
|
| 142 |
self.lock = threading.RLock()
|
| 143 |
|
| 144 |
self.lit_target_papers = APP_CONFIG["lit_target_papers"]
|
|
@@ -223,6 +224,25 @@ class SSciPipelineAgent:
|
|
| 223 |
type(exc).__name__,
|
| 224 |
)
|
| 225 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
def _check_cancelled(self):
|
| 227 |
if self._cancel_checker is not None:
|
| 228 |
result = self._cancel_checker()
|
|
@@ -293,6 +313,7 @@ class SSciPipelineAgent:
|
|
| 293 |
"used_citations": list(self.used_citations),
|
| 294 |
"citation_audit_log": list(self.citation_audit_log),
|
| 295 |
"translation_assurance_log": list(self.translation_assurance_log),
|
|
|
|
| 296 |
"global_memory": self.global_memory,
|
| 297 |
"s2_access_mode": self._s2_access_mode,
|
| 298 |
"s2_logical_searches": int(self._s2_logical_searches),
|
|
@@ -315,6 +336,7 @@ class SSciPipelineAgent:
|
|
| 315 |
self.used_citations = set(state_dict.get("used_citations", []))
|
| 316 |
self.citation_audit_log = list(state_dict.get("citation_audit_log", []))
|
| 317 |
self.translation_assurance_log = list(state_dict.get("translation_assurance_log", []))
|
|
|
|
| 318 |
self.global_memory = state_dict.get("global_memory", self.global_memory)
|
| 319 |
loaded_mode = str(state_dict.get("s2_access_mode") or "").strip().lower()
|
| 320 |
expected_mode = "key" if self.s2_key else "anonymous"
|
|
@@ -1227,9 +1249,11 @@ class SSciPipelineAgent:
|
|
| 1227 |
|
| 1228 |
def _stream_with_reasoning_exhaustion_recovery():
|
| 1229 |
try:
|
|
|
|
| 1230 |
yield from self.stream_deepseek(
|
| 1231 |
sys_prompt,
|
| 1232 |
user_prompt,
|
|
|
|
| 1233 |
model_role="reasoning",
|
| 1234 |
thinking=True,
|
| 1235 |
allow_fallback=False,
|
|
@@ -1252,6 +1276,7 @@ class SSciPipelineAgent:
|
|
| 1252 |
yield from self.stream_deepseek(
|
| 1253 |
recovery_system,
|
| 1254 |
user_prompt,
|
|
|
|
| 1255 |
model_role="reasoning",
|
| 1256 |
thinking=False,
|
| 1257 |
allow_fallback=False,
|
|
@@ -1291,34 +1316,18 @@ Literature Synthesis:
|
|
| 1291 |
{evidence_block}
|
| 1292 |
{mode_directive}
|
| 1293 |
Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It must guide later sections without fixing an expected result or conclusion. Do not use citations in the contract."""
|
| 1294 |
-
def
|
| 1295 |
-
|
| 1296 |
-
|
| 1297 |
-
|
| 1298 |
-
|
| 1299 |
-
max_tokens=2200,
|
| 1300 |
-
model_role="reasoning",
|
| 1301 |
-
thinking=True,
|
| 1302 |
-
allow_fallback=False,
|
| 1303 |
-
reasoning_only_recovery=True,
|
| 1304 |
-
)
|
| 1305 |
-
return
|
| 1306 |
-
except DeepSeekReasoningOnlyExhaustionError as exc:
|
| 1307 |
-
logging.warning(
|
| 1308 |
-
"Research logic reasoning-only exhaustion; retrying once with thinking disabled: %s",
|
| 1309 |
-
exc,
|
| 1310 |
-
)
|
| 1311 |
-
self._touch_execution_progress("research_logic_direct_recovery")
|
| 1312 |
-
|
| 1313 |
-
recovery_system = (
|
| 1314 |
sys_prompt
|
| 1315 |
-
+ "\n\
|
| 1316 |
-
"Do not perform extended hidden reasoning. Preserve
|
| 1317 |
-
"
|
| 1318 |
-
"Do not invent findings, coefficients, causal claims, or facts."
|
| 1319 |
)
|
| 1320 |
yield from self.stream_deepseek(
|
| 1321 |
-
|
| 1322 |
user_prompt,
|
| 1323 |
max_tokens=2200,
|
| 1324 |
model_role="reasoning",
|
|
@@ -1326,7 +1335,7 @@ Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It
|
|
| 1326 |
allow_fallback=False,
|
| 1327 |
)
|
| 1328 |
|
| 1329 |
-
return
|
| 1330 |
|
| 1331 |
@staticmethod
|
| 1332 |
def _front_matter_keywords(value, minimum=4, maximum=6):
|
|
@@ -1821,56 +1830,87 @@ USER KEYWORD SEEDS: {keywords}
|
|
| 1821 |
violations.append({"tag": item.get("tag"), "count": count})
|
| 1822 |
return violations
|
| 1823 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1824 |
def prefetch_jit_literature(self, topic, keywords, structure):
|
|
|
|
| 1825 |
jit_rag_texts = []
|
| 1826 |
-
queries_to_fetch =
|
| 1827 |
-
|
| 1828 |
-
for sec in structure:
|
| 1829 |
-
sec_title = sec.get("section", "")
|
| 1830 |
-
if any(kw in sec_title.lower() for kw in ["method", "methodology", "analysis", "result", "finding", "setting"]):
|
| 1831 |
-
jit_prompt = f"Topic: '{topic}'. Keywords: '{keywords}'. Section: '{sec_title}'. Generate exactly 1 highly specific English search query (e.g., a specific statistical method, measurement scale, or analytical framework needed). Keep it UNDER 5 terms. NO complex boolean logic. Return ONLY the query string, no quotes."
|
| 1832 |
-
query = self.call_deepseek("You are an expert academic librarian.", jit_prompt, max_tokens=50, temp=0.2, model_role="fast").strip(' "\'')
|
| 1833 |
-
if query:
|
| 1834 |
-
queries_to_fetch.add(query)
|
| 1835 |
-
|
| 1836 |
if not queries_to_fetch:
|
| 1837 |
return ""
|
| 1838 |
-
|
| 1839 |
-
logging.info(f"🔍 [Pre-fetch JIT] 生成了 {len(queries_to_fetch)} 个定向检索词: {queries_to_fetch}")
|
| 1840 |
-
|
| 1841 |
with concurrent.futures.ThreadPoolExecutor(max_workers=self.lit_max_workers) as executor:
|
| 1842 |
-
|
| 1843 |
for query in queries_to_fetch:
|
| 1844 |
-
|
| 1845 |
-
|
| 1846 |
-
|
| 1847 |
-
for future in concurrent.futures.as_completed(futures):
|
| 1848 |
results = future.result()
|
|
|
|
| 1849 |
with self.lock:
|
| 1850 |
for res in results:
|
| 1851 |
result_keys = self._reference_keys(res)
|
| 1852 |
existing_keys = {
|
| 1853 |
-
key
|
| 1854 |
-
for metadata in self.citation_map.values()
|
| 1855 |
-
for key in self._reference_keys(metadata)
|
| 1856 |
}
|
| 1857 |
if result_keys and any(key in existing_keys for key in result_keys):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1858 |
continue
|
| 1859 |
cite_tag = f"[CITE-{len(self.citation_map)+1:02d}]"
|
| 1860 |
self.citation_map[cite_tag] = {
|
| 1861 |
-
"in_text": res["in_text"],
|
| 1862 |
-
"ref": res["ref"],
|
| 1863 |
"id": result_keys[0] if result_keys else ('title_year', res.get('title'), res.get('year')),
|
| 1864 |
-
"doi": res.get("doi", ""),
|
| 1865 |
-
"
|
| 1866 |
-
"
|
| 1867 |
-
"
|
| 1868 |
-
"evidence": res.get("rag_text", ""),
|
| 1869 |
-
"sources": [res.get("source", "jit")],
|
| 1870 |
}
|
| 1871 |
jit_rag_texts.append(f"{cite_tag} {res.get('rag_text', '')}")
|
| 1872 |
-
|
| 1873 |
-
logging.info(f"✅ [Pre-fetch JIT] 成功抓取并注入了 {len(jit_rag_texts)} 篇底层支撑文献。")
|
| 1874 |
return "\n".join(jit_rag_texts)
|
| 1875 |
|
| 1876 |
def generate_search_queries(self, topic, keywords):
|
|
@@ -2008,6 +2048,36 @@ Return ONLY a valid JSON array of strings."""
|
|
| 2008 |
before_request=self._s2_before_http_attempt,
|
| 2009 |
)
|
| 2010 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2011 |
def _s2_worker(self, term, offset):
|
| 2012 |
# Serialize complete logical searches. This is deliberately separate
|
| 2013 |
# from the HTTP-attempt gate: OpenAlex can still run concurrently, while
|
|
@@ -2084,15 +2154,9 @@ Return ONLY a valid JSON array of strings."""
|
|
| 2084 |
if not title or not year or not self._is_english_dominant(title):
|
| 2085 |
continue
|
| 2086 |
authors = paper.get('authors') or []
|
| 2087 |
-
last_names, formatted_authors =
|
| 2088 |
-
|
| 2089 |
-
|
| 2090 |
-
if not name:
|
| 2091 |
-
continue
|
| 2092 |
-
parts = name.split()
|
| 2093 |
-
last = parts[-1]
|
| 2094 |
-
last_names.append(last)
|
| 2095 |
-
formatted_authors.append(f"{last}, {parts[0][0]}." if len(parts) > 1 else last)
|
| 2096 |
if not last_names:
|
| 2097 |
continue
|
| 2098 |
if len(last_names) == 1:
|
|
@@ -2188,14 +2252,10 @@ Return ONLY a valid JSON array of strings."""
|
|
| 2188 |
if not title or not self._is_english_dominant(title):
|
| 2189 |
continue
|
| 2190 |
year = item.get('publication_year') or 'n.d.'
|
| 2191 |
-
|
| 2192 |
-
|
| 2193 |
-
|
| 2194 |
-
|
| 2195 |
-
continue
|
| 2196 |
-
parts = name.split(); last = parts[-1]
|
| 2197 |
-
last_names.append(last)
|
| 2198 |
-
formatted_authors.append(f"{last}, {parts[0][0]}." if len(parts) > 1 else last)
|
| 2199 |
if not formatted_authors:
|
| 2200 |
continue
|
| 2201 |
if len(last_names) == 1:
|
|
@@ -2448,6 +2508,7 @@ Return ONLY a valid JSON array of strings."""
|
|
| 2448 |
'title': str(meta.get('title') or '')[:240],
|
| 2449 |
'year': str(meta.get('year') or ''),
|
| 2450 |
'evidence': evidence[:900],
|
|
|
|
| 2451 |
})
|
| 2452 |
target = min(max(1, int(target_unique)), len(candidates))
|
| 2453 |
if not candidates:
|
|
@@ -2476,7 +2537,8 @@ Return ONLY a valid JSON array of strings."""
|
|
| 2476 |
"Use only the supplied title/abstract evidence. Never use a source to support this study's "
|
| 2477 |
"own sample, coefficients, events, quotations, or findings. Each source may be assigned at "
|
| 2478 |
"most once. Each planned claim must be narrow enough that the supplied evidence directly "
|
| 2479 |
-
"supports it.
|
|
|
|
| 2480 |
)
|
| 2481 |
|
| 2482 |
for batch_index in range(0, len(candidates), batch_size):
|
|
@@ -2547,6 +2609,10 @@ INPUT:
|
|
| 2547 |
if len(self._citation_terms(claim) & self._citation_terms(evidence)) < 2:
|
| 2548 |
continue
|
| 2549 |
section_meta = next((item for item in sections if item['name'] == section), {})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2550 |
section_context = ' '.join([
|
| 2551 |
str(topic or ''), section, str(section_meta.get('role') or ''),
|
| 2552 |
])
|
|
@@ -2707,6 +2773,32 @@ INPUT:
|
|
| 2707 |
break
|
| 2708 |
return selected
|
| 2709 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2710 |
def fetch_contextual_jit_recovery_items(
|
| 2711 |
self, *, topic, section_name, section_context, needed=1, excluded_tags=None,
|
| 2712 |
):
|
|
@@ -2722,27 +2814,9 @@ INPUT:
|
|
| 2722 |
if needed <= 0:
|
| 2723 |
return []
|
| 2724 |
excluded = {str(tag or '').strip() for tag in (excluded_tags or set()) if str(tag or '').strip()}
|
| 2725 |
-
|
| 2726 |
-
term for term in sorted(self._citation_terms(section_context))
|
| 2727 |
-
if term not in self._CITATION_RECOVERY_GENERIC_TERMS
|
| 2728 |
-
][:12]
|
| 2729 |
-
prompt = (
|
| 2730 |
-
"Generate exactly one concise English academic search query of 3-5 terms. "
|
| 2731 |
-
"It must target the named manuscript unit and must not introduce a different application domain. "
|
| 2732 |
-
"Return only the query.\n"
|
| 2733 |
-
f"Topic: {str(topic or '')[:300]}\nSection: {section_name}\n"
|
| 2734 |
-
f"Context terms: {' '.join(context_words)}"
|
| 2735 |
-
)
|
| 2736 |
-
try:
|
| 2737 |
-
query = self.call_deepseek(
|
| 2738 |
-
'You are a precise academic librarian.', prompt, max_tokens=50, temp=0.1,
|
| 2739 |
-
model_role='fast', thinking=False, allow_fallback=False,
|
| 2740 |
-
).strip(' "\'')
|
| 2741 |
-
query = self._normalize_search_query(query)
|
| 2742 |
-
except Exception as exc:
|
| 2743 |
-
logging.warning('Contextual citation JIT query generation failed: section=%s error=%s', section_name, type(exc).__name__)
|
| 2744 |
-
return []
|
| 2745 |
if not query:
|
|
|
|
| 2746 |
return []
|
| 2747 |
logging.info('Contextual citation JIT: section=%s query=%r needed=%s', section_name, query, needed)
|
| 2748 |
results = []
|
|
@@ -3161,6 +3235,41 @@ INPUT:
|
|
| 3161 |
passages.extend(re.findall(pattern, str(value)))
|
| 3162 |
return [passage for passage in passages if len(passage.strip()) >= 8]
|
| 3163 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3164 |
def semantic_audit_claim_specific_citations(
|
| 3165 |
self,
|
| 3166 |
text,
|
|
@@ -3439,6 +3548,14 @@ Return exactly:
|
|
| 3439 |
audited = "".join(rebuilt)
|
| 3440 |
audited = re.sub(r"[ \t]+\n", "\n", audited)
|
| 3441 |
audited = re.sub(r"\n{3,}", "\n\n", audited).strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3442 |
violations = self.find_multi_citation_violations(audited)
|
| 3443 |
if violations:
|
| 3444 |
raise DeepSeekRequestError(
|
|
@@ -4948,6 +5065,114 @@ Output only the repaired section."""
|
|
| 4948 |
and "{'numeric':" in message
|
| 4949 |
)
|
| 4950 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4951 |
@classmethod
|
| 4952 |
def _protect_translation_tokens(cls, value, claim_id='C000001'):
|
| 4953 |
"""Protect one claim while keeping factual values in a private server ledger."""
|
|
@@ -4969,6 +5194,7 @@ Output only the repaired section."""
|
|
| 4969 |
'canonical': cls._translation_fact_canonical(original, anchor_type),
|
| 4970 |
'render_policy': cls._translation_fact_render_policy(anchor_type),
|
| 4971 |
'claim_id': str(claim_id or 'C000001'),
|
|
|
|
| 4972 |
# Kept only as diagnostic compatibility; never authoritative.
|
| 4973 |
'sentence_index': 0,
|
| 4974 |
}
|
|
@@ -5003,6 +5229,7 @@ Output only the repaired section."""
|
|
| 5003 |
'canonical': cls._translation_fact_canonical(original, anchor_type),
|
| 5004 |
'render_policy': cls._translation_fact_render_policy(anchor_type),
|
| 5005 |
'claim_id': _claim_id,
|
|
|
|
| 5006 |
'sentence_index': 0,
|
| 5007 |
}
|
| 5008 |
return placeholder
|
|
@@ -5022,6 +5249,7 @@ Output only the repaired section."""
|
|
| 5022 |
'fact_id': str(item.get('fact_id') or placeholder),
|
| 5023 |
'type': str(item.get('type') or 'IMMUTABLE'),
|
| 5024 |
'claim_id': str(item.get('claim_id') or 'C000001'),
|
|
|
|
| 5025 |
'mobility': 'same_claim',
|
| 5026 |
})
|
| 5027 |
return safe
|
|
@@ -5039,6 +5267,7 @@ Output only the repaired section."""
|
|
| 5039 |
'type': str(item.get('type') or 'IMMUTABLE'),
|
| 5040 |
'canonical': str(item.get('canonical') or item.get('source') or ''),
|
| 5041 |
'render_policy': str(item.get('render_policy') or 'exact'),
|
|
|
|
| 5042 |
})
|
| 5043 |
payload = json.dumps(records, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode('utf-8')
|
| 5044 |
return hashlib.sha256(payload).hexdigest()
|
|
@@ -5212,10 +5441,11 @@ Output only the repaired section."""
|
|
| 5212 |
return (
|
| 5213 |
message.startswith('Translation order-sensitive relation changed')
|
| 5214 |
or message.startswith('Translation fact order changed')
|
|
|
|
| 5215 |
)
|
| 5216 |
|
| 5217 |
@classmethod
|
| 5218 |
-
def _validate_translation_skeleton(cls, value, mapping, block_id='', claim_sources=None, glossary=None):
|
| 5219 |
text = str(value or '')
|
| 5220 |
found = Counter(re.findall(r'\[\[SP-T\d{6}\]\]', text))
|
| 5221 |
expected = Counter((mapping or {}).keys())
|
|
@@ -5240,10 +5470,16 @@ Output only the repaired section."""
|
|
| 5240 |
for placeholder, raw_manifest in (mapping or {}).items():
|
| 5241 |
manifest = raw_manifest if isinstance(raw_manifest, dict) else {'type':'IMMUTABLE'}
|
| 5242 |
claim_id = str(manifest.get('claim_id') or 'C000001')
|
| 5243 |
-
|
|
|
|
| 5244 |
raise DeepSeekRequestError(
|
| 5245 |
f'Translation fact moved across claim boundary in {block_id}: {manifest.get("fact_id") or placeholder}.'
|
| 5246 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5247 |
|
| 5248 |
for claim_id in claim_order:
|
| 5249 |
target_claim = str(target_claims.get(claim_id) or '')
|
|
@@ -5298,7 +5534,7 @@ Output only the repaired section."""
|
|
| 5298 |
):
|
| 5299 |
text = str(value or '')
|
| 5300 |
cls._validate_translation_skeleton(
|
| 5301 |
-
text, mapping, block_id, claim_sources=claim_sources, glossary=glossary
|
| 5302 |
)
|
| 5303 |
for placeholder, manifest in (mapping or {}).items():
|
| 5304 |
text = text.replace(placeholder, cls._render_translation_fact(manifest, target_lang))
|
|
|
|
| 139 |
# R3.2.8.6: translation validators emit hierarchical assurance evidence.
|
| 140 |
# Low-level diagnostics cannot independently terminate a manuscript.
|
| 141 |
self.translation_assurance_log = []
|
| 142 |
+
self.prewrite_metrics = {}
|
| 143 |
self.lock = threading.RLock()
|
| 144 |
|
| 145 |
self.lit_target_papers = APP_CONFIG["lit_target_papers"]
|
|
|
|
| 224 |
type(exc).__name__,
|
| 225 |
)
|
| 226 |
|
| 227 |
+
def record_prewrite_stage(self, stage, started_at, start_ds_calls):
|
| 228 |
+
"""Record bounded non-secret latency/call diagnostics for pre-draft stages."""
|
| 229 |
+
key = str(stage or "unknown")
|
| 230 |
+
duration = max(0.0, time.monotonic() - float(started_at or time.monotonic()))
|
| 231 |
+
calls = max(0, int(self.ds_calls) - int(start_ds_calls or 0))
|
| 232 |
+
sla = float((APP_CONFIG.get("prewrite_sla_seconds") or {}).get(key, 0) or 0)
|
| 233 |
+
row = {
|
| 234 |
+
"duration_seconds": round(duration, 3),
|
| 235 |
+
"deepseek_calls": calls,
|
| 236 |
+
"sla_seconds": sla,
|
| 237 |
+
"status": "within_sla" if not sla or duration <= sla else "over_sla",
|
| 238 |
+
}
|
| 239 |
+
self.prewrite_metrics[key] = row
|
| 240 |
+
logging.info(
|
| 241 |
+
"Prewrite stage metric | stage=%s duration=%.3fs deepseek_calls=%s sla=%.1fs status=%s",
|
| 242 |
+
key, duration, calls, sla, row["status"],
|
| 243 |
+
)
|
| 244 |
+
return dict(row)
|
| 245 |
+
|
| 246 |
def _check_cancelled(self):
|
| 247 |
if self._cancel_checker is not None:
|
| 248 |
result = self._cancel_checker()
|
|
|
|
| 313 |
"used_citations": list(self.used_citations),
|
| 314 |
"citation_audit_log": list(self.citation_audit_log),
|
| 315 |
"translation_assurance_log": list(self.translation_assurance_log),
|
| 316 |
+
"prewrite_metrics": dict(self.prewrite_metrics),
|
| 317 |
"global_memory": self.global_memory,
|
| 318 |
"s2_access_mode": self._s2_access_mode,
|
| 319 |
"s2_logical_searches": int(self._s2_logical_searches),
|
|
|
|
| 336 |
self.used_citations = set(state_dict.get("used_citations", []))
|
| 337 |
self.citation_audit_log = list(state_dict.get("citation_audit_log", []))
|
| 338 |
self.translation_assurance_log = list(state_dict.get("translation_assurance_log", []))
|
| 339 |
+
self.prewrite_metrics = dict(state_dict.get("prewrite_metrics") or {})
|
| 340 |
self.global_memory = state_dict.get("global_memory", self.global_memory)
|
| 341 |
loaded_mode = str(state_dict.get("s2_access_mode") or "").strip().lower()
|
| 342 |
expected_mode = "key" if self.s2_key else "anonymous"
|
|
|
|
| 1249 |
|
| 1250 |
def _stream_with_reasoning_exhaustion_recovery():
|
| 1251 |
try:
|
| 1252 |
+
reasoning_budget = int((APP_CONFIG.get("prewrite_reasoning_max_tokens") or {}).get("literature_synthesis", 2400))
|
| 1253 |
yield from self.stream_deepseek(
|
| 1254 |
sys_prompt,
|
| 1255 |
user_prompt,
|
| 1256 |
+
max_tokens=reasoning_budget,
|
| 1257 |
model_role="reasoning",
|
| 1258 |
thinking=True,
|
| 1259 |
allow_fallback=False,
|
|
|
|
| 1276 |
yield from self.stream_deepseek(
|
| 1277 |
recovery_system,
|
| 1278 |
user_prompt,
|
| 1279 |
+
max_tokens=8000,
|
| 1280 |
model_role="reasoning",
|
| 1281 |
thinking=False,
|
| 1282 |
allow_fallback=False,
|
|
|
|
| 1316 |
{evidence_block}
|
| 1317 |
{mode_directive}
|
| 1318 |
Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It must guide later sections without fixing an expected result or conclusion. Do not use citations in the contract."""
|
| 1319 |
+
def _stream_direct_research_logic():
|
| 1320 |
+
# R4-A: research logic is a concise planning contract. A reasoning-first
|
| 1321 |
+
# request repeatedly spent the whole hidden-reasoning budget before any
|
| 1322 |
+
# usable content, so direct final-output mode is now the default.
|
| 1323 |
+
direct_system = (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1324 |
sys_prompt
|
| 1325 |
+
+ "\n\nDIRECT PLANNING MODE: Produce the final provisional research logic contract directly. "
|
| 1326 |
+
"Do not perform extended hidden reasoning. Preserve uncertainty and evidence-strength boundaries; "
|
| 1327 |
+
"do not invent findings, coefficients, causal claims, or facts."
|
|
|
|
| 1328 |
)
|
| 1329 |
yield from self.stream_deepseek(
|
| 1330 |
+
direct_system,
|
| 1331 |
user_prompt,
|
| 1332 |
max_tokens=2200,
|
| 1333 |
model_role="reasoning",
|
|
|
|
| 1335 |
allow_fallback=False,
|
| 1336 |
)
|
| 1337 |
|
| 1338 |
+
return _stream_direct_research_logic()
|
| 1339 |
|
| 1340 |
@staticmethod
|
| 1341 |
def _front_matter_keywords(value, minimum=4, maximum=6):
|
|
|
|
| 1830 |
violations.append({"tag": item.get("tag"), "count": count})
|
| 1831 |
return violations
|
| 1832 |
|
| 1833 |
+
@staticmethod
|
| 1834 |
+
def _method_authority_queries(structure):
|
| 1835 |
+
"""Derive bounded methodology queries from design language, never topic keywords."""
|
| 1836 |
+
text_parts = []
|
| 1837 |
+
for sec in structure or []:
|
| 1838 |
+
if not isinstance(sec, dict):
|
| 1839 |
+
continue
|
| 1840 |
+
section = str(sec.get('section') or sec.get('section_id') or '')
|
| 1841 |
+
if not re.search(r'(?i)method|methodology', section):
|
| 1842 |
+
continue
|
| 1843 |
+
text_parts.extend([
|
| 1844 |
+
section,
|
| 1845 |
+
str(sec.get('required_content') or ''),
|
| 1846 |
+
str(sec.get('subsection') or ''),
|
| 1847 |
+
])
|
| 1848 |
+
for child in sec.get('subsections') or []:
|
| 1849 |
+
if isinstance(child, dict):
|
| 1850 |
+
text_parts.extend([str(child.get('subsection') or ''), str(child.get('required_content') or '')])
|
| 1851 |
+
else:
|
| 1852 |
+
text_parts.append(str(child or ''))
|
| 1853 |
+
text = ' '.join(text_parts).lower()
|
| 1854 |
+
if not text.strip():
|
| 1855 |
+
return []
|
| 1856 |
+
queries = []
|
| 1857 |
+
if re.search(r'interview|qualitative|case study|coding|thematic|process analys|triangulat|reflexiv|gioia|grounded theor', text):
|
| 1858 |
+
queries.extend([
|
| 1859 |
+
'qualitative case study methodology',
|
| 1860 |
+
'qualitative process analysis methodology',
|
| 1861 |
+
'qualitative triangulation reflexivity',
|
| 1862 |
+
])
|
| 1863 |
+
if re.search(r'regression|structural equation|sem\b|pls|mediat|moderation|bootstrap|reliab|validity|factor analys|panel data', text):
|
| 1864 |
+
queries.extend([
|
| 1865 |
+
'structural equation modeling methodology',
|
| 1866 |
+
'bootstrap mediation methodology',
|
| 1867 |
+
'measurement validity methodology',
|
| 1868 |
+
])
|
| 1869 |
+
if not queries:
|
| 1870 |
+
queries.append('empirical research design methodology')
|
| 1871 |
+
return list(dict.fromkeys(queries))[:3]
|
| 1872 |
+
|
| 1873 |
def prefetch_jit_literature(self, topic, keywords, structure):
|
| 1874 |
+
"""Retrieve a dedicated methodology-authority lane without an LLM query call."""
|
| 1875 |
jit_rag_texts = []
|
| 1876 |
+
queries_to_fetch = self._method_authority_queries(structure)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1877 |
if not queries_to_fetch:
|
| 1878 |
return ""
|
| 1879 |
+
logging.info("METHOD_AUTHORITY retrieval | queries=%s", queries_to_fetch)
|
|
|
|
|
|
|
| 1880 |
with concurrent.futures.ThreadPoolExecutor(max_workers=self.lit_max_workers) as executor:
|
| 1881 |
+
future_lane = {}
|
| 1882 |
for query in queries_to_fetch:
|
| 1883 |
+
future_lane[executor.submit(self._s2_worker, query, 0)] = 'method_authority'
|
| 1884 |
+
future_lane[executor.submit(self._oa_worker, query, 1, "language:en,type:article,is_paratext:false")] = 'method_authority'
|
| 1885 |
+
for future in concurrent.futures.as_completed(future_lane):
|
|
|
|
| 1886 |
results = future.result()
|
| 1887 |
+
lane = future_lane[future]
|
| 1888 |
with self.lock:
|
| 1889 |
for res in results:
|
| 1890 |
result_keys = self._reference_keys(res)
|
| 1891 |
existing_keys = {
|
| 1892 |
+
key for metadata in self.citation_map.values() for key in self._reference_keys(metadata)
|
|
|
|
|
|
|
| 1893 |
}
|
| 1894 |
if result_keys and any(key in existing_keys for key in result_keys):
|
| 1895 |
+
# If already present, promote the existing source to the
|
| 1896 |
+
# methodology lane without duplicating the reference.
|
| 1897 |
+
for metadata in self.citation_map.values():
|
| 1898 |
+
if any(key in self._reference_keys(metadata) for key in result_keys):
|
| 1899 |
+
metadata.setdefault('evidence_lanes', [])
|
| 1900 |
+
if lane not in metadata['evidence_lanes']:
|
| 1901 |
+
metadata['evidence_lanes'].append(lane)
|
| 1902 |
continue
|
| 1903 |
cite_tag = f"[CITE-{len(self.citation_map)+1:02d}]"
|
| 1904 |
self.citation_map[cite_tag] = {
|
| 1905 |
+
"in_text": res["in_text"], "ref": res["ref"],
|
|
|
|
| 1906 |
"id": result_keys[0] if result_keys else ('title_year', res.get('title'), res.get('year')),
|
| 1907 |
+
"doi": res.get("doi", ""), "external_id": res.get("external_id", ""),
|
| 1908 |
+
"title": res.get("title", ""), "year": res.get("year", ""),
|
| 1909 |
+
"evidence": res.get("rag_text", ""), "sources": [res.get("source", "jit")],
|
| 1910 |
+
"evidence_lanes": [lane],
|
|
|
|
|
|
|
| 1911 |
}
|
| 1912 |
jit_rag_texts.append(f"{cite_tag} {res.get('rag_text', '')}")
|
| 1913 |
+
logging.info("METHOD_AUTHORITY retrieval complete | accepted=%s", len(jit_rag_texts))
|
|
|
|
| 1914 |
return "\n".join(jit_rag_texts)
|
| 1915 |
|
| 1916 |
def generate_search_queries(self, topic, keywords):
|
|
|
|
| 2048 |
before_request=self._s2_before_http_attempt,
|
| 2049 |
)
|
| 2050 |
|
| 2051 |
+
@staticmethod
|
| 2052 |
+
def _format_reference_authors(names):
|
| 2053 |
+
"""Return deterministic author display data or reject clearly malformed records."""
|
| 2054 |
+
last_names, formatted = [], []
|
| 2055 |
+
for raw in names or []:
|
| 2056 |
+
name = re.sub(r"\s+", " ", str(raw or "")).strip(" ,;")
|
| 2057 |
+
if not name:
|
| 2058 |
+
continue
|
| 2059 |
+
parts = [part.strip(" ,.;") for part in name.split() if part.strip(" ,.;")]
|
| 2060 |
+
if not parts:
|
| 2061 |
+
continue
|
| 2062 |
+
# API metadata occasionally collapses a full author list into initials
|
| 2063 |
+
# (e.g. J., M., J., K.). Such data is not repairable without invention.
|
| 2064 |
+
substantive = [part for part in parts if sum(ch.isalpha() for ch in part) >= 2]
|
| 2065 |
+
if not substantive:
|
| 2066 |
+
continue
|
| 2067 |
+
if sum(ch.isalpha() for ch in parts[-1]) >= 2:
|
| 2068 |
+
last = parts[-1]
|
| 2069 |
+
given = parts[0] if len(parts) > 1 else ""
|
| 2070 |
+
elif sum(ch.isalpha() for ch in parts[0]) >= 2 and len(parts) >= 2:
|
| 2071 |
+
# Bounded support for surname-first metadata such as "Li J".
|
| 2072 |
+
last = parts[0]
|
| 2073 |
+
given = parts[-1]
|
| 2074 |
+
else:
|
| 2075 |
+
continue
|
| 2076 |
+
first_alpha = next((ch for ch in given if ch.isalpha()), "")
|
| 2077 |
+
last_names.append(last)
|
| 2078 |
+
formatted.append(f"{last}, {first_alpha.upper()}." if first_alpha else last)
|
| 2079 |
+
return last_names, formatted
|
| 2080 |
+
|
| 2081 |
def _s2_worker(self, term, offset):
|
| 2082 |
# Serialize complete logical searches. This is deliberately separate
|
| 2083 |
# from the HTTP-attempt gate: OpenAlex can still run concurrently, while
|
|
|
|
| 2154 |
if not title or not year or not self._is_english_dominant(title):
|
| 2155 |
continue
|
| 2156 |
authors = paper.get('authors') or []
|
| 2157 |
+
last_names, formatted_authors = self._format_reference_authors(
|
| 2158 |
+
[str(author.get('name') or '') for author in authors if isinstance(author, dict)]
|
| 2159 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2160 |
if not last_names:
|
| 2161 |
continue
|
| 2162 |
if len(last_names) == 1:
|
|
|
|
| 2252 |
if not title or not self._is_english_dominant(title):
|
| 2253 |
continue
|
| 2254 |
year = item.get('publication_year') or 'n.d.'
|
| 2255 |
+
last_names, formatted_authors = self._format_reference_authors([
|
| 2256 |
+
str((authorship.get('author') or {}).get('display_name') or '')
|
| 2257 |
+
for authorship in (item.get('authorships') or []) if isinstance(authorship, dict)
|
| 2258 |
+
])
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2259 |
if not formatted_authors:
|
| 2260 |
continue
|
| 2261 |
if len(last_names) == 1:
|
|
|
|
| 2508 |
'title': str(meta.get('title') or '')[:240],
|
| 2509 |
'year': str(meta.get('year') or ''),
|
| 2510 |
'evidence': evidence[:900],
|
| 2511 |
+
'evidence_lanes': list(meta.get('evidence_lanes') or []),
|
| 2512 |
})
|
| 2513 |
target = min(max(1, int(target_unique)), len(candidates))
|
| 2514 |
if not candidates:
|
|
|
|
| 2537 |
"Use only the supplied title/abstract evidence. Never use a source to support this study's "
|
| 2538 |
"own sample, coefficients, events, quotations, or findings. Each source may be assigned at "
|
| 2539 |
"most once. Each planned claim must be narrow enough that the supplied evidence directly "
|
| 2540 |
+
"supports it. Sources marked method_authority may be used only in Methods/Methodology and only "
|
| 2541 |
+
"for methodological rationale, never for this study's own facts. Do not manufacture a claim merely to hit a quota. Return only valid JSON."
|
| 2542 |
)
|
| 2543 |
|
| 2544 |
for batch_index in range(0, len(candidates), batch_size):
|
|
|
|
| 2609 |
if len(self._citation_terms(claim) & self._citation_terms(evidence)) < 2:
|
| 2610 |
continue
|
| 2611 |
section_meta = next((item for item in sections if item['name'] == section), {})
|
| 2612 |
+
lanes = set((self.citation_map.get(tag) or {}).get('evidence_lanes') or [])
|
| 2613 |
+
if 'method_authority' in lanes and str(section_meta.get('section_id') or '') not in {'methods', 'methodology'}:
|
| 2614 |
+
logging.info('Citation planner rejected METHOD_AUTHORITY outside methods: section=%s tag=%s', section, tag)
|
| 2615 |
+
continue
|
| 2616 |
section_context = ' '.join([
|
| 2617 |
str(topic or ''), section, str(section_meta.get('role') or ''),
|
| 2618 |
])
|
|
|
|
| 2773 |
break
|
| 2774 |
return selected
|
| 2775 |
|
| 2776 |
+
def _deterministic_contextual_jit_query(self, topic, section_name, section_context):
|
| 2777 |
+
"""Build one bounded section-aware recovery query without a model call."""
|
| 2778 |
+
generic = set(self._CITATION_RECOVERY_GENERIC_TERMS)
|
| 2779 |
+
context_terms = [
|
| 2780 |
+
term for term in sorted(self._citation_terms(section_context))
|
| 2781 |
+
if term not in generic and len(term) >= 3
|
| 2782 |
+
]
|
| 2783 |
+
topic_terms = [
|
| 2784 |
+
term for term in sorted(self._citation_terms(topic))
|
| 2785 |
+
if term not in generic and len(term) >= 3
|
| 2786 |
+
]
|
| 2787 |
+
section_terms = [
|
| 2788 |
+
term for term in re.findall(r"[A-Za-z][A-Za-z-]{2,}", str(section_name or '').lower())
|
| 2789 |
+
if term not in generic
|
| 2790 |
+
]
|
| 2791 |
+
selected = []
|
| 2792 |
+
# Context is authoritative because JIT exists to repair one current unit,
|
| 2793 |
+
# not to broaden the manuscript into another application domain.
|
| 2794 |
+
for term in context_terms + section_terms + topic_terms:
|
| 2795 |
+
normalized = self._normalize_search_query(term)
|
| 2796 |
+
if normalized and normalized not in selected:
|
| 2797 |
+
selected.append(normalized)
|
| 2798 |
+
if len(selected) >= 5:
|
| 2799 |
+
break
|
| 2800 |
+
return ' '.join(selected[:5]) if len(selected) >= 3 else ''
|
| 2801 |
+
|
| 2802 |
def fetch_contextual_jit_recovery_items(
|
| 2803 |
self, *, topic, section_name, section_context, needed=1, excluded_tags=None,
|
| 2804 |
):
|
|
|
|
| 2814 |
if needed <= 0:
|
| 2815 |
return []
|
| 2816 |
excluded = {str(tag or '').strip() for tag in (excluded_tags or set()) if str(tag or '').strip()}
|
| 2817 |
+
query = self._deterministic_contextual_jit_query(topic, section_name, section_context)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2818 |
if not query:
|
| 2819 |
+
logging.info('Contextual citation JIT skipped: no bounded deterministic query for section=%s', section_name)
|
| 2820 |
return []
|
| 2821 |
logging.info('Contextual citation JIT: section=%s query=%r needed=%s', section_name, query, needed)
|
| 2822 |
results = []
|
|
|
|
| 3235 |
passages.extend(re.findall(pattern, str(value)))
|
| 3236 |
return [passage for passage in passages if len(passage.strip()) >= 8]
|
| 3237 |
|
| 3238 |
+
@classmethod
|
| 3239 |
+
def _repair_local_discourse_after_citation_removal(cls, original, audited, section_name=''):
|
| 3240 |
+
"""Repair only enumeration/ordinal framing after fail-closed sentence removal."""
|
| 3241 |
+
before = str(original or '')
|
| 3242 |
+
value = str(audited or '')
|
| 3243 |
+
if not value or value == before:
|
| 3244 |
+
return value
|
| 3245 |
+
ordinal_words = {'first':1, 'second':2, 'third':3, 'fourth':4, 'fifth':5, 'sixth':6}
|
| 3246 |
+
ordinal_matches = list(re.finditer(r'\bThe\s+(first|second|third|fourth|fifth|sixth)\b', value, flags=re.I))
|
| 3247 |
+
if len(ordinal_matches) < 2:
|
| 3248 |
+
return value
|
| 3249 |
+
nums = [ordinal_words[m.group(1).lower()] for m in ordinal_matches]
|
| 3250 |
+
if nums == list(range(1, len(nums)+1)):
|
| 3251 |
+
return value
|
| 3252 |
+
words = {1:'first',2:'second',3:'third',4:'fourth',5:'fifth',6:'sixth'}
|
| 3253 |
+
index = 0
|
| 3254 |
+
def repl(match):
|
| 3255 |
+
nonlocal index
|
| 3256 |
+
index += 1
|
| 3257 |
+
return 'The ' + words[index]
|
| 3258 |
+
repaired = re.sub(r'\bThe\s+(?:first|second|third|fourth|fifth|sixth)\b', repl, value, flags=re.I)
|
| 3259 |
+
count_words = {'two':2,'three':3,'four':4,'five':5,'six':6}
|
| 3260 |
+
reverse = {2:'two',3:'three',4:'four',5:'five',6:'six'}
|
| 3261 |
+
def count_repl(match):
|
| 3262 |
+
old_n = count_words.get(match.group(1).lower(), 0)
|
| 3263 |
+
noun = match.group(2)
|
| 3264 |
+
if old_n >= max(nums) and len(nums) in reverse:
|
| 3265 |
+
return f"{reverse[len(nums)]} {noun}"
|
| 3266 |
+
return match.group(0)
|
| 3267 |
+
repaired = re.sub(
|
| 3268 |
+
r'\b(two|three|four|five|six)\s+(process\s+domains?|mechanisms?|dimensions?|stages?|patterns?|components?)\b',
|
| 3269 |
+
count_repl, repaired, flags=re.I,
|
| 3270 |
+
)
|
| 3271 |
+
return repaired
|
| 3272 |
+
|
| 3273 |
def semantic_audit_claim_specific_citations(
|
| 3274 |
self,
|
| 3275 |
text,
|
|
|
|
| 3548 |
audited = "".join(rebuilt)
|
| 3549 |
audited = re.sub(r"[ \t]+\n", "\n", audited)
|
| 3550 |
audited = re.sub(r"\n{3,}", "\n\n", audited).strip()
|
| 3551 |
+
if unsupported:
|
| 3552 |
+
repaired = self._repair_local_discourse_after_citation_removal(text, audited, section_name)
|
| 3553 |
+
if repaired != audited:
|
| 3554 |
+
self._append_citation_audit(
|
| 3555 |
+
section_name, "repair_local_discourse_framing", audited, repaired,
|
| 3556 |
+
"Renumbered surviving ordinal/enumeration framing after unsupported claim removal; no external claim added.",
|
| 3557 |
+
)
|
| 3558 |
+
audited = repaired
|
| 3559 |
violations = self.find_multi_citation_violations(audited)
|
| 3560 |
if violations:
|
| 3561 |
raise DeepSeekRequestError(
|
|
|
|
| 5065 |
and "{'numeric':" in message
|
| 5066 |
)
|
| 5067 |
|
| 5068 |
+
_TRANSLATION_NUMERIC_ROLE_PATTERNS = (
|
| 5069 |
+
('INTERVIEW_COUNT', r'\b(?:interviews?|interview sessions?)\b'),
|
| 5070 |
+
('PARTICIPANT_COUNT', r'\b(?:participants?|respondents?|informants?)\b'),
|
| 5071 |
+
('SAMPLE_SIZE', r'\b(?:samples?|sample size|observations?|respondents?)\b'),
|
| 5072 |
+
('WAVE_COUNT', r'\b(?:waves?)\b'),
|
| 5073 |
+
('GROUP_COUNT', r'\b(?:groups?|conditions?|arms?)\b'),
|
| 5074 |
+
('ITEM_COUNT', r'\b(?:items?|questions?|scale items?)\b'),
|
| 5075 |
+
('CASE_COUNT', r'\b(?:cases?|sites?|organizations?|organisations?)\b'),
|
| 5076 |
+
('TEAM_COUNT', r'\b(?:teams?)\b'),
|
| 5077 |
+
)
|
| 5078 |
+
|
| 5079 |
+
@classmethod
|
| 5080 |
+
def _translation_numeric_semantic_role(cls, source, start, end, anchor_type):
|
| 5081 |
+
if str(anchor_type or '') not in {'NUMBER', 'QUANTITY'}:
|
| 5082 |
+
return ''
|
| 5083 |
+
text = str(source or '')
|
| 5084 |
+
left = text[max(0, int(start)-36):int(start)]
|
| 5085 |
+
right = text[int(end):min(len(text), int(end)+52)]
|
| 5086 |
+
# Prefer the noun immediately following the quantity ("44 interviews").
|
| 5087 |
+
for role, pattern in cls._TRANSLATION_NUMERIC_ROLE_PATTERNS:
|
| 5088 |
+
if re.match(rf'^[\s,;:()\-]*(?:[A-Za-z-]+\s+)?{pattern}', right, flags=re.I):
|
| 5089 |
+
return role
|
| 5090 |
+
# Then allow bounded noun-before-number constructions ("participants: 38").
|
| 5091 |
+
for role, pattern in cls._TRANSLATION_NUMERIC_ROLE_PATTERNS:
|
| 5092 |
+
if re.search(rf'{pattern}[\s:=,()\-]*$', left, flags=re.I):
|
| 5093 |
+
return role
|
| 5094 |
+
return ''
|
| 5095 |
+
|
| 5096 |
+
@classmethod
|
| 5097 |
+
def _translation_role_lexemes(cls, target_lang, role):
|
| 5098 |
+
lang = str(target_lang or '').lower()
|
| 5099 |
+
lexicons = {
|
| 5100 |
+
'INTERVIEW_COUNT': {
|
| 5101 |
+
'zh': (r'访谈', r'采访'),
|
| 5102 |
+
'ru': (r'интервью', r'бесед'),
|
| 5103 |
+
'kk': (r'сұхбат', r'интервью'),
|
| 5104 |
+
},
|
| 5105 |
+
'PARTICIPANT_COUNT': {
|
| 5106 |
+
'zh': (r'参与者', r'受访者'),
|
| 5107 |
+
'ru': (r'участник', r'респондент', r'информант'),
|
| 5108 |
+
'kk': (r'қатысуш', r'респондент', r'ақпарат беруш'),
|
| 5109 |
+
},
|
| 5110 |
+
'SAMPLE_SIZE': {
|
| 5111 |
+
'zh': (r'样本', r'观测'),
|
| 5112 |
+
'ru': (r'выборк', r'наблюден'),
|
| 5113 |
+
'kk': (r'іріктем', r'таңдам', r'бақыла'),
|
| 5114 |
+
},
|
| 5115 |
+
'WAVE_COUNT': {'zh': (r'轮', r'波'), 'ru': (r'волн', r'этап'), 'kk': (r'толқын', r'кезең')},
|
| 5116 |
+
'GROUP_COUNT': {'zh': (r'组', r'条件'), 'ru': (r'групп', r'услов'), 'kk': (r'топ', r'шарт')},
|
| 5117 |
+
'ITEM_COUNT': {'zh': (r'题项', r'项目', r'条目'), 'ru': (r'пункт', r'элемент', r'вопрос'), 'kk': (r'тармақ', r'элемент', r'сұрақ')},
|
| 5118 |
+
'CASE_COUNT': {'zh': (r'案例', r'个案', r'站点', r'组织'), 'ru': (r'случа', r'кейс', r'организац', r'сайт'), 'kk': (r'жағдай', r'кейс', r'ұйым', r'алаң')},
|
| 5119 |
+
'TEAM_COUNT': {'zh': (r'团队',), 'ru': (r'команд',), 'kk': (r'команда', r'топ')},
|
| 5120 |
+
}
|
| 5121 |
+
key = 'zh' if ('chinese' in lang or '中文' in lang or '简体' in lang) else 'ru' if ('russian' in lang or 'рус' in lang) else 'kk' if ('kazakh' in lang or 'қазақ' in lang) else ''
|
| 5122 |
+
localized = tuple((lexicons.get(str(role or '')) or {}).get(key) or ())
|
| 5123 |
+
english = tuple(pattern for role_key, pattern in cls._TRANSLATION_NUMERIC_ROLE_PATTERNS if role_key == str(role or ''))
|
| 5124 |
+
# Source-language role words are accepted only for role binding. The
|
| 5125 |
+
# independent deterministic lexical-purity Gate still rejects ordinary
|
| 5126 |
+
# English residue in a finished ZH/RU/KK translation.
|
| 5127 |
+
return localized + english
|
| 5128 |
+
|
| 5129 |
+
@classmethod
|
| 5130 |
+
def _translation_semantic_role_matches(cls, target_claim, placeholder, role, target_lang):
|
| 5131 |
+
"""Bind each numeric anchor to the nearest recognized quantitative-role noun."""
|
| 5132 |
+
expected = str(role or '')
|
| 5133 |
+
if not cls._translation_role_lexemes(target_lang, expected):
|
| 5134 |
+
return True
|
| 5135 |
+
value = str(target_claim or '')
|
| 5136 |
+
marker = str(placeholder or '')
|
| 5137 |
+
pos = value.find(marker)
|
| 5138 |
+
if pos < 0:
|
| 5139 |
+
return False
|
| 5140 |
+
end = pos + len(marker)
|
| 5141 |
+
# First bind inside the local punctuation-delimited proposition. This
|
| 5142 |
+
# prevents a noun in the following clause (for example after a Chinese
|
| 5143 |
+
# comma) from stealing ownership from a noun-before-number construction
|
| 5144 |
+
# such as ``团队数为20,参与者总数为300``.
|
| 5145 |
+
clause_left = max(
|
| 5146 |
+
value.rfind(",", 0, pos), value.rfind(",", 0, pos),
|
| 5147 |
+
value.rfind(";", 0, pos), value.rfind(";", 0, pos),
|
| 5148 |
+
value.rfind("。", 0, pos), value.rfind(".", 0, pos),
|
| 5149 |
+
value.rfind("\n", 0, pos),
|
| 5150 |
+
) + 1
|
| 5151 |
+
right_hits = [idx for token in (",", ",", ";", ";", "。", ".", "\n")
|
| 5152 |
+
if (idx := value.find(token, end)) >= 0]
|
| 5153 |
+
clause_right = min(right_hits) if right_hits else len(value)
|
| 5154 |
+
window = value[clause_left:clause_right]
|
| 5155 |
+
marker_start = pos - clause_left
|
| 5156 |
+
marker_end = end - clause_left
|
| 5157 |
+
candidates = []
|
| 5158 |
+
for candidate_role, _pattern in cls._TRANSLATION_NUMERIC_ROLE_PATTERNS:
|
| 5159 |
+
for pattern in cls._translation_role_lexemes(target_lang, candidate_role):
|
| 5160 |
+
for match in re.finditer(pattern, window, flags=re.I):
|
| 5161 |
+
if match.end() <= marker_start:
|
| 5162 |
+
distance = marker_start - match.end()
|
| 5163 |
+
elif match.start() >= marker_end:
|
| 5164 |
+
distance = match.start() - marker_end
|
| 5165 |
+
else:
|
| 5166 |
+
distance = 0
|
| 5167 |
+
candidates.append((distance, candidate_role))
|
| 5168 |
+
if not candidates:
|
| 5169 |
+
# No deterministic role noun is visible. Defer this claim to the
|
| 5170 |
+
# existing semantic QA rather than guessing from script/word order.
|
| 5171 |
+
return True
|
| 5172 |
+
minimum = min(distance for distance, _candidate_role in candidates)
|
| 5173 |
+
nearest_roles = {candidate_role for distance, candidate_role in candidates if distance == minimum}
|
| 5174 |
+
return expected in nearest_roles
|
| 5175 |
+
|
| 5176 |
@classmethod
|
| 5177 |
def _protect_translation_tokens(cls, value, claim_id='C000001'):
|
| 5178 |
"""Protect one claim while keeping factual values in a private server ledger."""
|
|
|
|
| 5194 |
'canonical': cls._translation_fact_canonical(original, anchor_type),
|
| 5195 |
'render_policy': cls._translation_fact_render_policy(anchor_type),
|
| 5196 |
'claim_id': str(claim_id or 'C000001'),
|
| 5197 |
+
'semantic_role': cls._translation_numeric_semantic_role(source, match.start(), match.end(), anchor_type),
|
| 5198 |
# Kept only as diagnostic compatibility; never authoritative.
|
| 5199 |
'sentence_index': 0,
|
| 5200 |
}
|
|
|
|
| 5229 |
'canonical': cls._translation_fact_canonical(original, anchor_type),
|
| 5230 |
'render_policy': cls._translation_fact_render_policy(anchor_type),
|
| 5231 |
'claim_id': _claim_id,
|
| 5232 |
+
'semantic_role': cls._translation_numeric_semantic_role(claim_source, match.start(), match.end(), anchor_type),
|
| 5233 |
'sentence_index': 0,
|
| 5234 |
}
|
| 5235 |
return placeholder
|
|
|
|
| 5249 |
'fact_id': str(item.get('fact_id') or placeholder),
|
| 5250 |
'type': str(item.get('type') or 'IMMUTABLE'),
|
| 5251 |
'claim_id': str(item.get('claim_id') or 'C000001'),
|
| 5252 |
+
'semantic_role': str(item.get('semantic_role') or ''),
|
| 5253 |
'mobility': 'same_claim',
|
| 5254 |
})
|
| 5255 |
return safe
|
|
|
|
| 5267 |
'type': str(item.get('type') or 'IMMUTABLE'),
|
| 5268 |
'canonical': str(item.get('canonical') or item.get('source') or ''),
|
| 5269 |
'render_policy': str(item.get('render_policy') or 'exact'),
|
| 5270 |
+
'semantic_role': str(item.get('semantic_role') or ''),
|
| 5271 |
})
|
| 5272 |
payload = json.dumps(records, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode('utf-8')
|
| 5273 |
return hashlib.sha256(payload).hexdigest()
|
|
|
|
| 5441 |
return (
|
| 5442 |
message.startswith('Translation order-sensitive relation changed')
|
| 5443 |
or message.startswith('Translation fact order changed')
|
| 5444 |
+
or message.startswith('Translation semantic-role relation changed')
|
| 5445 |
)
|
| 5446 |
|
| 5447 |
@classmethod
|
| 5448 |
+
def _validate_translation_skeleton(cls, value, mapping, block_id='', claim_sources=None, glossary=None, target_lang=''):
|
| 5449 |
text = str(value or '')
|
| 5450 |
found = Counter(re.findall(r'\[\[SP-T\d{6}\]\]', text))
|
| 5451 |
expected = Counter((mapping or {}).keys())
|
|
|
|
| 5470 |
for placeholder, raw_manifest in (mapping or {}).items():
|
| 5471 |
manifest = raw_manifest if isinstance(raw_manifest, dict) else {'type':'IMMUTABLE'}
|
| 5472 |
claim_id = str(manifest.get('claim_id') or 'C000001')
|
| 5473 |
+
target_claim = str(target_claims.get(claim_id) or '')
|
| 5474 |
+
if placeholder not in target_claim:
|
| 5475 |
raise DeepSeekRequestError(
|
| 5476 |
f'Translation fact moved across claim boundary in {block_id}: {manifest.get("fact_id") or placeholder}.'
|
| 5477 |
)
|
| 5478 |
+
role = str(manifest.get('semantic_role') or '')
|
| 5479 |
+
if role and not cls._translation_semantic_role_matches(target_claim, placeholder, role, target_lang):
|
| 5480 |
+
raise DeepSeekRequestError(
|
| 5481 |
+
f'Translation semantic-role relation changed in {block_id}: {manifest.get("fact_id") or placeholder} role={role}.'
|
| 5482 |
+
)
|
| 5483 |
|
| 5484 |
for claim_id in claim_order:
|
| 5485 |
target_claim = str(target_claims.get(claim_id) or '')
|
|
|
|
| 5534 |
):
|
| 5535 |
text = str(value or '')
|
| 5536 |
cls._validate_translation_skeleton(
|
| 5537 |
+
text, mapping, block_id, claim_sources=claim_sources, glossary=glossary, target_lang=target_lang
|
| 5538 |
)
|
| 5539 |
for placeholder, manifest in (mapping or {}).items():
|
| 5540 |
text = text.replace(placeholder, cls._render_translation_fact(manifest, target_lang))
|
core/production_guard.py
CHANGED
|
@@ -532,6 +532,14 @@ def build_evidence_coverage_report(summary: dict[str, Any] | None) -> str:
|
|
| 532 |
lines.append(
|
| 533 |
f"- {section}: validated direct citations={validated}; evidence requirement={required}; externally oriented claims={external_claims}."
|
| 534 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 535 |
if limited:
|
| 536 |
lines.extend([
|
| 537 |
"",
|
|
|
|
| 532 |
lines.append(
|
| 533 |
f"- {section}: validated direct citations={validated}; evidence requirement={required}; externally oriented claims={external_claims}."
|
| 534 |
)
|
| 535 |
+
method = data.get("methodology_authority") or {}
|
| 536 |
+
if isinstance(method, dict):
|
| 537 |
+
lines.extend([
|
| 538 |
+
"",
|
| 539 |
+
"Methodology-authority coverage:",
|
| 540 |
+
f"- validated method-authority citations={max(0, int(method.get('validated_method_authority_citations') or 0))}; methodology external claims={max(0, int(method.get('methodology_external_claims') or 0))}.",
|
| 541 |
+
"- Methodology-authority sources support methodological rationale only; they are not evidence for this study's own sample, procedures, or results.",
|
| 542 |
+
])
|
| 543 |
if limited:
|
| 544 |
lines.extend([
|
| 545 |
"",
|
core/qualitative_engine.py
CHANGED
|
@@ -335,16 +335,30 @@ Rules:
|
|
| 335 |
# content retries here. Only a completed but locally invalid packet
|
| 336 |
# reaches the second attempt.
|
| 337 |
if local_attempt == 0:
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
base_prompt,
|
| 343 |
-
model_role="reasoning",
|
| 344 |
-
thinking=
|
| 345 |
-
allow_fallback=False,
|
| 346 |
)
|
| 347 |
-
)
|
| 348 |
else:
|
| 349 |
retry_prompt = base_prompt + (
|
| 350 |
"\n\nLOCAL VALIDATION RETRY: The previous completed response failed local "
|
|
|
|
| 335 |
# content retries here. Only a completed but locally invalid packet
|
| 336 |
# reaches the second attempt.
|
| 337 |
if local_attempt == 0:
|
| 338 |
+
reasoning_budget = int((APP_CONFIG.get("prewrite_reasoning_max_tokens") or {}).get("qualitative_packet", 2400))
|
| 339 |
+
try:
|
| 340 |
+
response = "".join(
|
| 341 |
+
str(chunk or "")
|
| 342 |
+
for chunk in self.agent.stream_deepseek(
|
| 343 |
+
system_prompt,
|
| 344 |
+
base_prompt,
|
| 345 |
+
max_tokens=reasoning_budget,
|
| 346 |
+
model_role="reasoning",
|
| 347 |
+
thinking=True,
|
| 348 |
+
allow_fallback=False,
|
| 349 |
+
reasoning_only_recovery=True,
|
| 350 |
+
)
|
| 351 |
+
)
|
| 352 |
+
except Exception as exc:
|
| 353 |
+
if exc.__class__.__name__ != "DeepSeekReasoningOnlyExhaustionError":
|
| 354 |
+
raise
|
| 355 |
+
logging.warning("Qualitative packet reasoning-only exhaustion; switching once to direct final-output mode.")
|
| 356 |
+
response = self.agent.call_deepseek(
|
| 357 |
+
system_prompt + "\n\nDIRECT OUTPUT MODE: Produce the complete evidence packet directly without extended hidden reasoning.",
|
| 358 |
base_prompt,
|
| 359 |
+
max_tokens=8000, temp=0.0, model_role="reasoning",
|
| 360 |
+
thinking=False, allow_fallback=False, response_format={"type": "json_object"},
|
|
|
|
| 361 |
)
|
|
|
|
| 362 |
else:
|
| 363 |
retry_prompt = base_prompt + (
|
| 364 |
"\n\nLOCAL VALIDATION RETRY: The previous completed response failed local "
|
i18n.py
CHANGED
|
@@ -351,7 +351,7 @@ TEXTS: dict[str, dict[str, str]] = {
|
|
| 351 |
"qualitative_ready": "✅ 定性材料已准备完成。",
|
| 352 |
"core_literature_parsing": "🧠 正在深度解析用户上传的核心文献……",
|
| 353 |
"section_complete_progress": "🇬🇧 【{name}】英文完成,翻译任务已进入后台。",
|
| 354 |
-
"login_title": "SmartPaper
|
| 355 |
"service_description": "提供中文、英语、俄语、哈萨克语四种语言服务。",
|
| 356 |
"secure_login_notice": "安全登录仅支持支付宝 App 扫码。登录后系统对数据加密,保护您的隐私。",
|
| 357 |
"secure_login_title": "支付宝安全登录",
|
|
@@ -551,7 +551,7 @@ TEXTS: dict[str, dict[str, str]] = {
|
|
| 551 |
"qualitative_ready": "✅ Qualitative materials are ready.",
|
| 552 |
"core_literature_parsing": "🧠 Parsing the uploaded core literature in depth…",
|
| 553 |
"section_complete_progress": "🇬🇧 English section 【{name}】 complete; translation is running in the background.",
|
| 554 |
-
"login_title": "
|
| 555 |
"service_description": "Services are available in Chinese, English, Russian, and Kazakh.",
|
| 556 |
"secure_login_notice": "Secure sign-in supports the Alipay app only. After sign-in, the system encrypts data to protect your privacy.",
|
| 557 |
"secure_login_title": "Secure Sign-in with Alipay",
|
|
@@ -751,7 +751,7 @@ TEXTS: dict[str, dict[str, str]] = {
|
|
| 751 |
"qualitative_ready": "✅ Качественные материалы подготовлены.",
|
| 752 |
"core_literature_parsing": "🧠 Выполняется углублённая обработка загруженной основной литературы…",
|
| 753 |
"section_complete_progress": "🇬🇧 Английский раздел 【{name}】 завершён; перевод выполняется в фоне.",
|
| 754 |
-
"login_title": "
|
| 755 |
"service_description": "Услуги доступны на китайском, английском, русском и казахском языках.",
|
| 756 |
"secure_login_notice": "Безопасный вход поддерживается только в приложении Alipay. После входа система шифрует данные для защиты вашей конфиденциальности.",
|
| 757 |
"secure_login_title": "Безопасный вход через Alipay",
|
|
@@ -951,7 +951,7 @@ TEXTS: dict[str, dict[str, str]] = {
|
|
| 951 |
"qualitative_ready": "✅ Сапалық материалдар дайындалды.",
|
| 952 |
"core_literature_parsing": "🧠 Жүктелген негізгі әдебиеттер терең өңделуде…",
|
| 953 |
"section_complete_progress": "🇬🇧 【{name}】 ағылшын бөлімі аяқталды; аударма фондық режимде орындалуда.",
|
| 954 |
-
"login_title": "SmartPaper
|
| 955 |
"service_description": "Қызмет қытай, ағылшын, орыс және қазақ тілдерінде қолжетімді.",
|
| 956 |
"secure_login_notice": "Қауіпсіз кіру тек Alipay қолданбасы арқылы жүзеге асырылады. Кіргеннен кейін жүйе құпиялылығыңызды қорғау үшін деректерді шифрлайды.",
|
| 957 |
"secure_login_title": "Alipay арқылы қауіпсіз кіру",
|
|
|
|
| 351 |
"qualitative_ready": "✅ 定性材料已准备完成。",
|
| 352 |
"core_literature_parsing": "🧠 正在深度解析用户上传的核心文献……",
|
| 353 |
"section_complete_progress": "🇬🇧 【{name}】英文完成,翻译任务已进入后台。",
|
| 354 |
+
"login_title": "SmartPaper",
|
| 355 |
"service_description": "提供中文、英语、俄语、哈萨克语四种语言服务。",
|
| 356 |
"secure_login_notice": "安全登录仅支持支付宝 App 扫码。登录后系统对数据加密,保护您的隐私。",
|
| 357 |
"secure_login_title": "支付宝安全登录",
|
|
|
|
| 551 |
"qualitative_ready": "✅ Qualitative materials are ready.",
|
| 552 |
"core_literature_parsing": "🧠 Parsing the uploaded core literature in depth…",
|
| 553 |
"section_complete_progress": "🇬🇧 English section 【{name}】 complete; translation is running in the background.",
|
| 554 |
+
"login_title": "SmartPaper",
|
| 555 |
"service_description": "Services are available in Chinese, English, Russian, and Kazakh.",
|
| 556 |
"secure_login_notice": "Secure sign-in supports the Alipay app only. After sign-in, the system encrypts data to protect your privacy.",
|
| 557 |
"secure_login_title": "Secure Sign-in with Alipay",
|
|
|
|
| 751 |
"qualitative_ready": "✅ Качественные материалы подготовлены.",
|
| 752 |
"core_literature_parsing": "🧠 Выполняется углублённая обработка загруженной основной литературы…",
|
| 753 |
"section_complete_progress": "🇬🇧 Английский раздел 【{name}】 завершён; перевод выполняется в фоне.",
|
| 754 |
+
"login_title": "SmartPaper",
|
| 755 |
"service_description": "Услуги доступны на китайском, английском, русском и казахском языках.",
|
| 756 |
"secure_login_notice": "Безопасный вход поддерживается только в приложении Alipay. После входа система шифрует данные для защиты вашей конфиденциальности.",
|
| 757 |
"secure_login_title": "Безопасный вход через Alipay",
|
|
|
|
| 951 |
"qualitative_ready": "✅ Сапалық материалдар дайындалды.",
|
| 952 |
"core_literature_parsing": "🧠 Жүктелген негізгі әдебиеттер терең өңделуде…",
|
| 953 |
"section_complete_progress": "🇬🇧 【{name}】 ағылшын бөлімі аяқталды; аударма фондық режимде орындалуда.",
|
| 954 |
+
"login_title": "SmartPaper",
|
| 955 |
"service_description": "Қызмет қытай, ағылшын, орыс және қазақ тілдерінде қолжетімді.",
|
| 956 |
"secure_login_notice": "Қауіпсіз кіру тек Alipay қолданбасы арқылы жүзеге асырылады. Кіргеннен кейін жүйе құпиялылығыңызды қорғау үшін деректерді шифрлайды.",
|
| 957 |
"secure_login_title": "Alipay арқылы қауіпсіз кіру",
|
smartpaper_restore_bridge/frontend/index.html
CHANGED
|
@@ -29,12 +29,71 @@
|
|
| 29 |
let lastPreviewText = "";
|
| 30 |
let lastTargetPreviewText = "";
|
| 31 |
let pageExitCancelCleanup = null;
|
|
|
|
| 32 |
const renderChannels = {
|
| 33 |
english:{ authoritative:"", displayed:"", queue:[], timer:null },
|
| 34 |
target:{ authoritative:"", displayed:"", queue:[], timer:null }
|
| 35 |
};
|
| 36 |
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
function readSessionJson(key, fallback=null) {
|
| 39 |
try {
|
| 40 |
const raw = window.sessionStorage.getItem(key);
|
|
@@ -271,12 +330,12 @@
|
|
| 271 |
}
|
| 272 |
|
| 273 |
function updateText(parentDocument, id, value) {
|
| 274 |
-
const element =
|
| 275 |
if (element) element.textContent = String(value ?? "");
|
| 276 |
}
|
| 277 |
|
| 278 |
function appendTaskActivity(parentDocument, text, complete=false) {
|
| 279 |
-
const feed =
|
| 280 |
const value = String(text || "").trim();
|
| 281 |
if (!feed || !value) return;
|
| 282 |
const row = parentDocument.createElement("div");
|
|
@@ -362,7 +421,7 @@
|
|
| 362 |
}
|
| 363 |
|
| 364 |
function updateEvidenceWarning(parentDocument, payload, labels) {
|
| 365 |
-
const node =
|
| 366 |
if (!node) return;
|
| 367 |
const status = String(payload && payload.evidence_status || "").trim().toLowerCase();
|
| 368 |
const map = labels && typeof labels === "object" ? labels : {};
|
|
@@ -376,7 +435,7 @@
|
|
| 376 |
}
|
| 377 |
|
| 378 |
function updateDraftPreparation(parentDocument, node, labels, task=null) {
|
| 379 |
-
const status =
|
| 380 |
const channel = renderChannels.english;
|
| 381 |
if (!status) return;
|
| 382 |
if (channel.authoritative) {
|
|
@@ -402,9 +461,10 @@
|
|
| 402 |
|
| 403 |
function ensureTargetStatusNode(parentDocument) {
|
| 404 |
const shell = parentDocument.getElementById("sp-live-translation-shell");
|
| 405 |
-
const
|
| 406 |
-
|
| 407 |
-
|
|
|
|
| 408 |
if (!status) {
|
| 409 |
status = parentDocument.createElement("div");
|
| 410 |
status.id = "sp-live-translation-status";
|
|
@@ -423,13 +483,13 @@
|
|
| 423 |
status.style.zIndex = "1";
|
| 424 |
status.style.background = "#ffffff";
|
| 425 |
status.style.display = "none";
|
| 426 |
-
|
| 427 |
}
|
| 428 |
return status;
|
| 429 |
}
|
| 430 |
|
| 431 |
function updateTargetPreparation(parentDocument, payload, labels) {
|
| 432 |
-
const target =
|
| 433 |
const channel = renderChannels.target;
|
| 434 |
if (!target) return;
|
| 435 |
const map = labels && typeof labels === "object" ? labels : {};
|
|
@@ -610,7 +670,7 @@
|
|
| 610 |
const commit = normalizePreviewDisplay(commitText);
|
| 611 |
if (!commit) return;
|
| 612 |
const channel = renderChannels.english;
|
| 613 |
-
const target =
|
| 614 |
const shell = parentDocument.getElementById("sp-live-manuscript-shell");
|
| 615 |
if (!target || !shell) return;
|
| 616 |
if (channel.displayed.startsWith(commit)) return;
|
|
@@ -639,7 +699,7 @@
|
|
| 639 |
}
|
| 640 |
|
| 641 |
function updateOnePreview(parentDocument, parentWindow, id, shellId, text, channel) {
|
| 642 |
-
const target =
|
| 643 |
const shell = parentDocument.getElementById(shellId);
|
| 644 |
if (!target || !shell || !text) return;
|
| 645 |
if (shell.dataset.spScrollGuard !== "1") {
|
|
@@ -664,7 +724,7 @@
|
|
| 664 |
const targetEnglishCommitText = String(payload && payload.target_english_commit_text || "");
|
| 665 |
if (englishText && englishText !== lastPreviewText) {
|
| 666 |
updateOnePreview(parentDocument, parentWindow, "sp-live-manuscript-preview", "sp-live-manuscript-shell", englishText, renderChannels.english);
|
| 667 |
-
const englishStatus =
|
| 668 |
if (englishStatus) {
|
| 669 |
englishStatus.textContent = "";
|
| 670 |
englishStatus.style.display = "none";
|
|
@@ -686,13 +746,32 @@
|
|
| 686 |
const response = await fetch(watch.previewUrl + `?t=${Date.now()}`, {
|
| 687 |
method:"GET", cache:"no-store", credentials:"same-origin", headers:{"Accept":"application/json"}
|
| 688 |
});
|
| 689 |
-
if (!response.ok)
|
| 690 |
-
|
| 691 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 692 |
const epoch = asCount(payload.execution_epoch);
|
| 693 |
-
if (watch.expectedEpoch > 0 && epoch !== watch.expectedEpoch)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 694 |
updateManuscriptPreview(payload, watch);
|
| 695 |
-
|
|
|
|
|
|
|
|
|
|
| 696 |
}
|
| 697 |
|
| 698 |
function updateTaskMonitor(task, watch=null) {
|
|
@@ -718,7 +797,7 @@
|
|
| 718 |
updateText(parentDocument, "sp-live-s2", asCount(task && task.s2_calls));
|
| 719 |
updateText(parentDocument, "sp-live-oa", asCount(task && task.oa_calls));
|
| 720 |
updateText(parentDocument, "sp-live-progress-text", `${progress}% — ${nodeLabel}`);
|
| 721 |
-
const fill =
|
| 722 |
if (fill) fill.style.width = `${progress}%`;
|
| 723 |
updateDraftPreparation(parentDocument, node, labels, task);
|
| 724 |
|
|
@@ -744,59 +823,13 @@
|
|
| 744 |
}
|
| 745 |
|
| 746 |
function installPageExitCancel(watch) {
|
|
|
|
|
|
|
|
|
|
| 747 |
removePageExitCancel();
|
| 748 |
-
if (
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
let sent = false;
|
| 752 |
-
let suppressForReload = false;
|
| 753 |
-
const navigationHandler = (event) => {
|
| 754 |
-
if (event && String(event.navigationType || "") === "reload") suppressForReload = true;
|
| 755 |
-
};
|
| 756 |
-
const keyHandler = (event) => {
|
| 757 |
-
const key = String(event && event.key || "").toLowerCase();
|
| 758 |
-
if (key === "f5" || ((event.ctrlKey || event.metaKey) && key === "r")) suppressForReload = true;
|
| 759 |
-
};
|
| 760 |
-
try {
|
| 761 |
-
if (parentWindow.navigation && typeof parentWindow.navigation.addEventListener === "function") {
|
| 762 |
-
parentWindow.navigation.addEventListener("navigate", navigationHandler);
|
| 763 |
-
}
|
| 764 |
-
parentWindow.addEventListener("keydown", keyHandler, true);
|
| 765 |
-
} catch (_) {}
|
| 766 |
-
const handler = (event) => {
|
| 767 |
-
if (event && event.persisted) return;
|
| 768 |
-
if (suppressForReload) return;
|
| 769 |
-
if (sent || activeTaskWatch !== watch || watch.stopped) return;
|
| 770 |
-
sent = true;
|
| 771 |
-
const url = new URL(ACTION_PATHS.task_cancel, watch.apiOrigin).toString();
|
| 772 |
-
const body = JSON.stringify({ task_id:watch.taskId });
|
| 773 |
-
let beaconSent = false;
|
| 774 |
-
try {
|
| 775 |
-
if (parentWindow.navigator && typeof parentWindow.navigator.sendBeacon === "function") {
|
| 776 |
-
const blob = new Blob([body], { type:"application/json" });
|
| 777 |
-
beaconSent = parentWindow.navigator.sendBeacon(url, blob);
|
| 778 |
-
}
|
| 779 |
-
} catch (_) {}
|
| 780 |
-
if (!beaconSent) {
|
| 781 |
-
try {
|
| 782 |
-
parentWindow.fetch(url, {
|
| 783 |
-
method:"POST", credentials:"include", cache:"no-store", keepalive:true,
|
| 784 |
-
headers:{ "Accept":"application/json", "Content-Type":"application/json", "X-SmartPaper-Client":"browser-exit-v1" },
|
| 785 |
-
body
|
| 786 |
-
}).catch(() => {});
|
| 787 |
-
} catch (_) {}
|
| 788 |
-
}
|
| 789 |
-
};
|
| 790 |
-
parentWindow.addEventListener("pagehide", handler, { capture:true });
|
| 791 |
-
pageExitCancelCleanup = () => {
|
| 792 |
-
parentWindow.removeEventListener("pagehide", handler, { capture:true });
|
| 793 |
-
parentWindow.removeEventListener("keydown", keyHandler, true);
|
| 794 |
-
try {
|
| 795 |
-
if (parentWindow.navigation && typeof parentWindow.navigation.removeEventListener === "function") {
|
| 796 |
-
parentWindow.navigation.removeEventListener("navigate", navigationHandler);
|
| 797 |
-
}
|
| 798 |
-
} catch (_) {}
|
| 799 |
-
};
|
| 800 |
}
|
| 801 |
|
| 802 |
function stopTaskWatch() {
|
|
@@ -822,6 +855,7 @@
|
|
| 822 |
const previewIntervalMs = Math.max(400, Math.min(2500, Number.isFinite(requestedPreviewInterval) ? requestedPreviewInterval : 700));
|
| 823 |
const resolvedPreviewUrl = previewUrl(payload.preview_path || "");
|
| 824 |
const expectedEpoch = asCount(payload.expected_execution_epoch);
|
|
|
|
| 825 |
const initialCheckpointSeq = asCount(payload.initial_checkpoint_seq);
|
| 826 |
if (!apiOrigin || !taskId) {
|
| 827 |
sendValue({ ok:false, action:"task_watch", command_id:commandId, error:"invalid_task_watch" });
|
|
@@ -831,7 +865,7 @@
|
|
| 831 |
const draftStatusLabels = payload.draft_status_labels && typeof payload.draft_status_labels === "object" ? payload.draft_status_labels : {};
|
| 832 |
const targetStatusLabels = payload.target_status_labels && typeof payload.target_status_labels === "object" ? payload.target_status_labels : {};
|
| 833 |
const bestEffortPageExitCancel = payload.best_effort_page_exit_cancel === true;
|
| 834 |
-
const watch = { commandId, taskId, apiOrigin, intervalMs, previewIntervalMs, previewUrl:resolvedPreviewUrl, expectedEpoch, draftStatusLabels, targetStatusLabels, bestEffortPageExitCancel, timer:null, previewTimer:null, controller:null, stopped:false, lastCheckpointSeq:initialCheckpointSeq };
|
| 835 |
activeTaskWatch = watch;
|
| 836 |
recordNavDiagnostic("task_watch_start");
|
| 837 |
installPageExitCancel(watch);
|
|
|
|
| 29 |
let lastPreviewText = "";
|
| 30 |
let lastTargetPreviewText = "";
|
| 31 |
let pageExitCancelCleanup = null;
|
| 32 |
+
const ownedShadowHosts = new WeakSet();
|
| 33 |
const renderChannels = {
|
| 34 |
english:{ authoritative:"", displayed:"", queue:[], timer:null },
|
| 35 |
target:{ authoritative:"", displayed:"", queue:[], timer:null }
|
| 36 |
};
|
| 37 |
|
| 38 |
|
| 39 |
+
const LIVE_HOST_BY_ID = Object.freeze({
|
| 40 |
+
"sp-live-progress-wrap":"sp-live-monitor",
|
| 41 |
+
"sp-live-progress-text":"sp-live-monitor",
|
| 42 |
+
"sp-live-progress-fill":"sp-live-monitor",
|
| 43 |
+
"sp-live-status":"sp-live-monitor",
|
| 44 |
+
"sp-live-node":"sp-live-monitor",
|
| 45 |
+
"sp-live-checkpoint-stage":"sp-live-monitor",
|
| 46 |
+
"sp-live-checkpoint-seq":"sp-live-monitor",
|
| 47 |
+
"sp-live-evidence-warning":"sp-live-monitor",
|
| 48 |
+
"sp-live-activity-feed":"sp-live-monitor",
|
| 49 |
+
"sp-live-ds":"sp-live-monitor",
|
| 50 |
+
"sp-live-s2":"sp-live-monitor",
|
| 51 |
+
"sp-live-oa":"sp-live-monitor",
|
| 52 |
+
"sp-live-manuscript-status":"sp-live-manuscript-shell",
|
| 53 |
+
"sp-live-manuscript-preview":"sp-live-manuscript-shell",
|
| 54 |
+
"sp-live-manuscript-cursor":"sp-live-manuscript-shell",
|
| 55 |
+
"sp-live-translation-status":"sp-live-translation-shell",
|
| 56 |
+
"sp-live-translation-preview":"sp-live-translation-shell",
|
| 57 |
+
"sp-live-translation-cursor":"sp-live-translation-shell"
|
| 58 |
+
});
|
| 59 |
+
|
| 60 |
+
function ensureOwnedShadow(parentDocument, hostId) {
|
| 61 |
+
const host = parentDocument && parentDocument.getElementById ? parentDocument.getElementById(hostId) : null;
|
| 62 |
+
if (!host) return null;
|
| 63 |
+
let root = host.shadowRoot;
|
| 64 |
+
if (!root) {
|
| 65 |
+
try {
|
| 66 |
+
root = host.attachShadow({mode:"open"});
|
| 67 |
+
root.innerHTML = host.innerHTML;
|
| 68 |
+
ownedShadowHosts.add(host);
|
| 69 |
+
recordNavDiagnostic("live_shadow_owner_mounted", {host:String(hostId || "").slice(0,80)});
|
| 70 |
+
} catch (_) { return null; }
|
| 71 |
+
}
|
| 72 |
+
return root;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
function liveGetById(parentDocument, id) {
|
| 76 |
+
const hostId = LIVE_HOST_BY_ID[String(id || "")];
|
| 77 |
+
if (hostId) {
|
| 78 |
+
const root = ensureOwnedShadow(parentDocument, hostId);
|
| 79 |
+
const node = root && root.getElementById ? root.getElementById(id) : null;
|
| 80 |
+
if (node) return node;
|
| 81 |
+
}
|
| 82 |
+
return parentDocument && parentDocument.getElementById ? parentDocument.getElementById(id) : null;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
function liveRootForHost(parentDocument, hostId) {
|
| 86 |
+
return ensureOwnedShadow(parentDocument, hostId);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
function recordPreviewDiagnostic(code, detail={}) {
|
| 90 |
+
const safe = { code:String(code || "preview_error").slice(0,80) };
|
| 91 |
+
if (detail && Number.isFinite(Number(detail.http_status))) safe.http_status = Number(detail.http_status);
|
| 92 |
+
if (detail && typeof detail.stage === "string") safe.stage = detail.stage.slice(0,80);
|
| 93 |
+
recordNavDiagnostic("preview_transport", safe);
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
|
| 97 |
function readSessionJson(key, fallback=null) {
|
| 98 |
try {
|
| 99 |
const raw = window.sessionStorage.getItem(key);
|
|
|
|
| 330 |
}
|
| 331 |
|
| 332 |
function updateText(parentDocument, id, value) {
|
| 333 |
+
const element = liveGetById(parentDocument, id);
|
| 334 |
if (element) element.textContent = String(value ?? "");
|
| 335 |
}
|
| 336 |
|
| 337 |
function appendTaskActivity(parentDocument, text, complete=false) {
|
| 338 |
+
const feed = liveGetById(parentDocument, "sp-live-activity-feed");
|
| 339 |
const value = String(text || "").trim();
|
| 340 |
if (!feed || !value) return;
|
| 341 |
const row = parentDocument.createElement("div");
|
|
|
|
| 421 |
}
|
| 422 |
|
| 423 |
function updateEvidenceWarning(parentDocument, payload, labels) {
|
| 424 |
+
const node = liveGetById(parentDocument, "sp-live-evidence-warning");
|
| 425 |
if (!node) return;
|
| 426 |
const status = String(payload && payload.evidence_status || "").trim().toLowerCase();
|
| 427 |
const map = labels && typeof labels === "object" ? labels : {};
|
|
|
|
| 435 |
}
|
| 436 |
|
| 437 |
function updateDraftPreparation(parentDocument, node, labels, task=null) {
|
| 438 |
+
const status = liveGetById(parentDocument, "sp-live-manuscript-status");
|
| 439 |
const channel = renderChannels.english;
|
| 440 |
if (!status) return;
|
| 441 |
if (channel.authoritative) {
|
|
|
|
| 461 |
|
| 462 |
function ensureTargetStatusNode(parentDocument) {
|
| 463 |
const shell = parentDocument.getElementById("sp-live-translation-shell");
|
| 464 |
+
const root = liveRootForHost(parentDocument, "sp-live-translation-shell");
|
| 465 |
+
const target = liveGetById(parentDocument, "sp-live-translation-preview");
|
| 466 |
+
if (!shell || !root || !target) return null;
|
| 467 |
+
let status = liveGetById(parentDocument, "sp-live-translation-status");
|
| 468 |
if (!status) {
|
| 469 |
status = parentDocument.createElement("div");
|
| 470 |
status.id = "sp-live-translation-status";
|
|
|
|
| 483 |
status.style.zIndex = "1";
|
| 484 |
status.style.background = "#ffffff";
|
| 485 |
status.style.display = "none";
|
| 486 |
+
root.insertBefore(status, target);
|
| 487 |
}
|
| 488 |
return status;
|
| 489 |
}
|
| 490 |
|
| 491 |
function updateTargetPreparation(parentDocument, payload, labels) {
|
| 492 |
+
const target = liveGetById(parentDocument, "sp-live-translation-preview");
|
| 493 |
const channel = renderChannels.target;
|
| 494 |
if (!target) return;
|
| 495 |
const map = labels && typeof labels === "object" ? labels : {};
|
|
|
|
| 670 |
const commit = normalizePreviewDisplay(commitText);
|
| 671 |
if (!commit) return;
|
| 672 |
const channel = renderChannels.english;
|
| 673 |
+
const target = liveGetById(parentDocument, "sp-live-manuscript-preview");
|
| 674 |
const shell = parentDocument.getElementById("sp-live-manuscript-shell");
|
| 675 |
if (!target || !shell) return;
|
| 676 |
if (channel.displayed.startsWith(commit)) return;
|
|
|
|
| 699 |
}
|
| 700 |
|
| 701 |
function updateOnePreview(parentDocument, parentWindow, id, shellId, text, channel) {
|
| 702 |
+
const target = liveGetById(parentDocument, id);
|
| 703 |
const shell = parentDocument.getElementById(shellId);
|
| 704 |
if (!target || !shell || !text) return;
|
| 705 |
if (shell.dataset.spScrollGuard !== "1") {
|
|
|
|
| 724 |
const targetEnglishCommitText = String(payload && payload.target_english_commit_text || "");
|
| 725 |
if (englishText && englishText !== lastPreviewText) {
|
| 726 |
updateOnePreview(parentDocument, parentWindow, "sp-live-manuscript-preview", "sp-live-manuscript-shell", englishText, renderChannels.english);
|
| 727 |
+
const englishStatus = liveGetById(parentDocument, "sp-live-manuscript-status");
|
| 728 |
if (englishStatus) {
|
| 729 |
englishStatus.textContent = "";
|
| 730 |
englishStatus.style.display = "none";
|
|
|
|
| 746 |
const response = await fetch(watch.previewUrl + `?t=${Date.now()}`, {
|
| 747 |
method:"GET", cache:"no-store", credentials:"same-origin", headers:{"Accept":"application/json"}
|
| 748 |
});
|
| 749 |
+
if (!response.ok) {
|
| 750 |
+
recordPreviewDiagnostic("HTTP_STATUS", {http_status:response.status});
|
| 751 |
+
return;
|
| 752 |
+
}
|
| 753 |
+
let payload;
|
| 754 |
+
try { payload = await response.json(); }
|
| 755 |
+
catch (_) { recordPreviewDiagnostic("INVALID_JSON", {http_status:response.status}); return; }
|
| 756 |
+
if (!payload || String(payload.task_id || "") !== watch.taskId) {
|
| 757 |
+
recordPreviewDiagnostic("TASK_ID_MISMATCH");
|
| 758 |
+
return;
|
| 759 |
+
}
|
| 760 |
const epoch = asCount(payload.execution_epoch);
|
| 761 |
+
if (watch.expectedEpoch > 0 && epoch !== watch.expectedEpoch) {
|
| 762 |
+
recordPreviewDiagnostic("EXECUTION_EPOCH_MISMATCH");
|
| 763 |
+
return;
|
| 764 |
+
}
|
| 765 |
+
const requestHash = String(payload.request_hash || "").trim().toLowerCase();
|
| 766 |
+
if (watch.expectedRequestHash && requestHash !== watch.expectedRequestHash) {
|
| 767 |
+
recordPreviewDiagnostic("REQUEST_HASH_MISMATCH");
|
| 768 |
+
return;
|
| 769 |
+
}
|
| 770 |
updateManuscriptPreview(payload, watch);
|
| 771 |
+
recordPreviewDiagnostic("OK", {http_status:response.status, stage:String(payload.stage || "")});
|
| 772 |
+
} catch (_) {
|
| 773 |
+
recordPreviewDiagnostic("FETCH_FAILED");
|
| 774 |
+
}
|
| 775 |
}
|
| 776 |
|
| 777 |
function updateTaskMonitor(task, watch=null) {
|
|
|
|
| 797 |
updateText(parentDocument, "sp-live-s2", asCount(task && task.s2_calls));
|
| 798 |
updateText(parentDocument, "sp-live-oa", asCount(task && task.oa_calls));
|
| 799 |
updateText(parentDocument, "sp-live-progress-text", `${progress}% — ${nodeLabel}`);
|
| 800 |
+
const fill = liveGetById(parentDocument, "sp-live-progress-fill");
|
| 801 |
if (fill) fill.style.width = `${progress}%`;
|
| 802 |
updateDraftPreparation(parentDocument, node, labels, task);
|
| 803 |
|
|
|
|
| 823 |
}
|
| 824 |
|
| 825 |
function installPageExitCancel(watch) {
|
| 826 |
+
// R4-R1: browser lifecycle events are observability only. Explicit Cancel
|
| 827 |
+
// owns cancellation; refresh/tab close/pagehide must never stop the
|
| 828 |
+
// independent Executor or consume a second reservation on reattach.
|
| 829 |
removePageExitCancel();
|
| 830 |
+
if (watch && watch.bestEffortPageExitCancel) {
|
| 831 |
+
recordNavDiagnostic("page_exit_cancel_ignored", { reason:"explicit_cancel_only" });
|
| 832 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 833 |
}
|
| 834 |
|
| 835 |
function stopTaskWatch() {
|
|
|
|
| 855 |
const previewIntervalMs = Math.max(400, Math.min(2500, Number.isFinite(requestedPreviewInterval) ? requestedPreviewInterval : 700));
|
| 856 |
const resolvedPreviewUrl = previewUrl(payload.preview_path || "");
|
| 857 |
const expectedEpoch = asCount(payload.expected_execution_epoch);
|
| 858 |
+
const expectedRequestHash = String(payload.expected_request_hash || "").trim().toLowerCase();
|
| 859 |
const initialCheckpointSeq = asCount(payload.initial_checkpoint_seq);
|
| 860 |
if (!apiOrigin || !taskId) {
|
| 861 |
sendValue({ ok:false, action:"task_watch", command_id:commandId, error:"invalid_task_watch" });
|
|
|
|
| 865 |
const draftStatusLabels = payload.draft_status_labels && typeof payload.draft_status_labels === "object" ? payload.draft_status_labels : {};
|
| 866 |
const targetStatusLabels = payload.target_status_labels && typeof payload.target_status_labels === "object" ? payload.target_status_labels : {};
|
| 867 |
const bestEffortPageExitCancel = payload.best_effort_page_exit_cancel === true;
|
| 868 |
+
const watch = { commandId, taskId, apiOrigin, intervalMs, previewIntervalMs, previewUrl:resolvedPreviewUrl, expectedEpoch, expectedRequestHash, draftStatusLabels, targetStatusLabels, bestEffortPageExitCancel, timer:null, previewTimer:null, controller:null, stopped:false, lastCheckpointSeq:initialCheckpointSeq };
|
| 869 |
activeTaskWatch = watch;
|
| 870 |
recordNavDiagnostic("task_watch_start");
|
| 871 |
installPageExitCancel(watch);
|