diff --git "a/hf_space/streamlit_app.py" "b/hf_space/streamlit_app.py"
new file mode 100644--- /dev/null
+++ "b/hf_space/streamlit_app.py"
@@ -0,0 +1,3307 @@
+import json
+import os
+import re
+import sys
+import time
+import html
+import urllib.error
+import urllib.request
+import xml.etree.ElementTree as ET
+from pathlib import Path
+from typing import Any, Optional
+
+import streamlit as st
+try:
+ from huggingface_hub import HfApi
+except Exception:
+ HfApi = None
+
+SRC = Path(__file__).resolve().parent
+REPO_ROOT = SRC.parent
+for extra in (SRC, REPO_ROOT / "src"):
+ extra_str = str(extra)
+ if extra_str not in sys.path:
+ sys.path.insert(0, extra_str)
+
+import runner as runner_module
+from runner import PipelineConfig
+from common.paper_package import load_paper_package
+from step_08_annotation.pipeline import TwoPassAnnotationPipeline
+from streamlit_config import EXAMPLES, TAB_NAMES
+import replay as replay_module
+import system_run_data as system_run_module
+import system_live_runner as system_live_module
+from workflow_graph import STEP_COPY, WORK_PANEL_STEPS
+
+try:
+ from streamlit_config import APP_NAV, APP_VIEWS
+except ImportError:
+ APP_VIEWS = [
+ "Annotation Process",
+ "System Run",
+ ]
+ APP_NAV = [
+ {"view": "Annotation Process", "icon": ":material/account_tree:"},
+ {"view": "System Run", "icon": ":material/play_circle:"},
+ ]
+
+DEFAULT_SOURCE_ROOT = str(REPO_ROOT / "src" / "processed_papers")
+DEFAULT_OUTPUT_ROOT = str(REPO_ROOT / "hf_space" / "runs")
+REPLAY_STEP_DELAY_SEC = float(os.getenv("REPLAY_STEP_DELAY_SEC", "0.35"))
+# Extra hold after a step completes so the workflow pulse is readable.
+REPLAY_STEP_HOLD_SEC = float(os.getenv("REPLAY_STEP_HOLD_SEC", "0.55"))
+DEFAULT_APP_VIEW = APP_VIEWS[0]
+
+CUSTOM_CSS = """
+
+"""
+
+
+def get_secret(name: str, default: str = "") -> str:
+ value = os.getenv(name)
+ if value:
+ return value
+ try:
+ return st.secrets[name]
+ except Exception:
+ return default
+
+
+def run_repo_config() -> tuple[str | None, str, str | None]:
+ repo_id = get_secret("RUNS_REPO_ID", "")
+ repo_type = get_secret("RUNS_REPO_TYPE", "dataset")
+ token = get_secret("HF_WRITE_TOKEN", "") or get_secret("HF_TOKEN", "")
+ return repo_id or None, repo_type, token or None
+
+
+def remote_run_prefix(job_id: str) -> str:
+ return f"runs/{job_id}"
+
+
+def upload_run_artifact(job_dir: Path) -> str:
+ repo_id, repo_type, token = run_repo_config()
+ if not repo_id or not token:
+ return ""
+ if HfApi is None:
+ return "upload_failed: huggingface_hub is not installed"
+
+ job_id = job_dir.name
+ remote_prefix = remote_run_prefix(job_id)
+ uploaded: list[str] = []
+ try:
+ api = HfApi(token=token)
+ for name in ["input_ids.json", "run_config.json", "summary.txt"]:
+ path = job_dir / name
+ if path.exists():
+ api.upload_file(
+ path_or_fileobj=str(path),
+ path_in_repo=f"{remote_prefix}/{name}",
+ repo_id=repo_id,
+ repo_type=repo_type,
+ commit_message=f"Upload {name} for {job_id}",
+ )
+ uploaded.append(name)
+
+ for folder_name in ["logs", "processed_papers", "two_pass_outputs"]:
+ folder = job_dir / folder_name
+ if not folder.exists():
+ continue
+ files = [path for path in folder.rglob("*") if path.is_file()]
+ if not files:
+ continue
+ api.upload_folder(
+ folder_path=str(folder),
+ path_in_repo=f"{remote_prefix}/{folder_name}",
+ repo_id=repo_id,
+ repo_type=repo_type,
+ commit_message=f"Upload {folder_name} for {job_id}",
+ ignore_patterns=["__pycache__/*", "*.pyc", "*.zip"],
+ )
+ uploaded.append(f"{folder_name}[{len(files)} files]")
+
+ return f"{repo_type}:{repo_id}/{remote_prefix}/ (uploaded: {', '.join(uploaded) or 'nothing'})"
+ except Exception as exc:
+ return f"upload_failed: {exc}"
+
+
+def _load_json(path: Path) -> Optional[dict]:
+ if not path.exists():
+ return None
+ try:
+ return json.loads(path.read_text(encoding="utf-8"))
+ except Exception:
+ return None
+
+
+def _status_from_line(line: str, current: str) -> str:
+ text = (line or "").strip()
+ text = _display_log_line(text)
+ if text.startswith("Pipeline stopped:"):
+ return "Stopped"
+ if text.startswith("Step "):
+ return text
+ if "failed" in text.lower():
+ return f"Failed: {text}"
+ if "completed successfully" in text.lower():
+ return "Completed"
+ return current
+
+
+def _display_log_line(line: str) -> str:
+ text = (line or "").strip()
+ if text.startswith("Step ") and " failed." in text:
+ return text.splitlines()[0]
+ if text == "[annotation] starting cluster-first two-pass annotation":
+ return f"Step 8/8: {STEP_COPY[8]}"
+ if text.startswith("[annotation] complete:"):
+ return "Step 8 complete"
+ # Rewrite engineering step labels to the shared audience-facing copy.
+ m = re.match(r"Step\s+(\d+)\s*/\s*\d+\s*:\s*(.*)$", text)
+ if m:
+ n = int(m.group(1))
+ if n in STEP_COPY:
+ return f"Step {n}/8: {STEP_COPY[n]}"
+ if text == "Pipeline completed successfully.":
+ return text
+ return text
+
+
+def _resolve_graph_sources(paper_input: str) -> tuple[Optional[Path], Optional[dict]]:
+ """Paper dir + annotation payload for the workflow graph (trace or session)."""
+ entry = _paper_result_for_input(paper_input)
+ if entry:
+ paper_dir_path = entry.get("paper_dir_path")
+ payload_path = entry.get("annotation_payload_path")
+ paper_dir = Path(paper_dir_path) if paper_dir_path else None
+ payload = _load_json(Path(payload_path)) if payload_path else None
+ if paper_dir and paper_dir.exists():
+ return paper_dir, payload if isinstance(payload, dict) else None
+
+ trace = replay_module.load_trace(paper_input)
+ if not trace:
+ return None, None
+ paper_dir_path = trace.get("paper_dir_path")
+ payload_path = trace.get("annotation_payload_path")
+ paper_dir = Path(paper_dir_path) if paper_dir_path else None
+ payload = _load_json(Path(payload_path)) if payload_path else None
+ return paper_dir, payload if isinstance(payload, dict) else None
+
+
+def _workflow_run_id(paper_input: str) -> str:
+ arxiv_id = replay_module.parse_arxiv_id(paper_input or "") or "default"
+ return f"scipaths:{arxiv_id}"
+
+
+def _mount_graph_shell(graph_slot) -> None:
+ """Mount the persistent graph canvas once (constant HTML → Streamlit reuses it)."""
+ if graph_slot is None:
+ return
+ from workflow_graph import render_graph_shell
+
+ render_graph_shell(graph_slot)
+
+
+def _push_graph(courier_slot, paper_input: str, events: list[str]) -> None:
+ """Push an incremental graph model into the mounted shell via postMessage.
+
+ This never re-emits the graph iframe, so the canvas grows in place instead of
+ reloading on every step.
+ """
+ if courier_slot is None:
+ return
+ paper_dir, payload = _resolve_graph_sources(paper_input)
+ render_events = list(events or [])
+ if not render_events and paper_dir is not None:
+ # Idle selected paper: show target only (no active pulse).
+ render_events = ["Step 1/8: Load the paper", "Step 1 complete"]
+ # Import at call-time so Streamlit always picks up workflow_graph changes.
+ from workflow_graph import build_synced_model, push_graph_update
+
+ model = build_synced_model(
+ paper_dir=paper_dir,
+ payload=payload,
+ events=render_events,
+ run_id=_workflow_run_id(paper_input),
+ )
+ push_graph_update(courier_slot, model)
+
+
+def _format_step_event(line: str) -> str:
+ text = _display_log_line(line)
+ if not text:
+ return ""
+ return text
+
+
+STATE_DEFAULTS = {
+ "paper_input": "",
+ "run_status": "Idle",
+ "run_logs": [],
+ "run_events": [],
+ "artifact_path": None,
+ "run_dir_path": None,
+ "paper_dir_path": None,
+ "annotation_payload_path": None,
+ "run_summary": None,
+ "annotation_skipped_reason": None,
+ "pipeline_stopped_reason": None,
+ "pipeline_failed_reason": None,
+ "remote_artifact_ref": "",
+ "replay_mode": False,
+ "live_run_mode": False,
+ # Locked demo selection (survives run clears / post-run reruns).
+ "selected_demo_url": "",
+ # System Run case-study selection (independent of Annotation Process).
+ "system_run_mode": "prerun", # "prerun" | "live"
+ "selected_system_method": system_run_module.DEFAULT_SYSTEM_METHOD,
+ "selected_system_case": system_run_module.DEFAULT_SYSTEM_CASE,
+ "selected_live_method": "codeagent_parametric",
+ # Per-method Live Run state (parametric vs websearch_deep are independent).
+ "live_run_by_method": {},
+ "app_view": DEFAULT_APP_VIEW,
+ # Per-paper result cache so Clusters/Decomposition follow the selected paper.
+ "run_results_by_paper": {},
+}
+
+# Widget keys owned by Streamlit; assign defaults instead of deleting them.
+WIDGET_STATE_KEYS = {"paper_input"}
+
+# Survives per-run clears; only wiped by Reset session.
+PRESERVED_STATE_KEYS = {
+ "app_view",
+ "run_results_by_paper",
+ "live_run_mode",
+ "selected_demo_url",
+ "system_run_mode",
+ "selected_system_method",
+ "selected_system_case",
+ "selected_live_method",
+ "live_run_by_method",
+}
+
+
+def _copy_default(value: Any) -> Any:
+ if isinstance(value, list):
+ return list(value)
+ if isinstance(value, dict):
+ return dict(value)
+ return value
+
+
+def _ensure_state():
+ for key, value in STATE_DEFAULTS.items():
+ st.session_state.setdefault(key, _copy_default(value))
+
+
+def _clear_run_state(*, keep_paper_input: bool = True) -> None:
+ """Reset transient run UI state for a new pipeline/replay.
+
+ Never writes ``paper_input`` after the text-input widget exists. Preserves
+ ``run_results_by_paper`` so other papers' results stay available.
+ """
+ for key, value in STATE_DEFAULTS.items():
+ if key in WIDGET_STATE_KEYS or key in PRESERVED_STATE_KEYS:
+ continue
+ st.session_state[key] = _copy_default(value)
+ if not keep_paper_input:
+ # Defer clearing/replacing the widget value until before it is created.
+ st.session_state["_pending_paper_input"] = ""
+
+
+def _store_paper_result(arxiv_id: str, result: dict) -> None:
+ if not arxiv_id:
+ return
+ cache = st.session_state.setdefault("run_results_by_paper", {})
+ cache[str(arxiv_id)] = dict(result)
+
+
+def _paper_result_for_input(paper_input: str) -> Optional[dict]:
+ arxiv_id = replay_module.parse_arxiv_id(paper_input or "")
+ if not arxiv_id:
+ return None
+ cache = st.session_state.get("run_results_by_paper") or {}
+ entry = cache.get(arxiv_id)
+ return entry if isinstance(entry, dict) else None
+
+
+def _apply_pending_paper_input() -> None:
+ """Apply deferred paper_input changes before the text_input widget mounts."""
+ if "_pending_paper_input" not in st.session_state:
+ return
+ st.session_state["paper_input"] = st.session_state.pop("_pending_paper_input")
+
+
+def _paper_1_url() -> str:
+ urls = list(EXAMPLES.values())
+ return urls[0] if urls else ""
+
+
+def _set_demo_paper(url: str) -> None:
+ """Pin the demo paper selection (source of truth across reruns)."""
+ st.session_state["live_run_mode"] = False
+ st.session_state["selected_demo_url"] = url
+ st.session_state["_pending_paper_input"] = url
+
+
+def _on_pick_demo(url: str) -> None:
+ _set_demo_paper(url)
+
+
+def _on_pick_live_run() -> None:
+ st.session_state["live_run_mode"] = True
+ st.session_state["_pending_paper_input"] = ""
+
+
+def _select_paper_1() -> None:
+ """Select demo Paper 1 in the picker (clears live-run mode)."""
+ _set_demo_paper(_paper_1_url())
+
+
+def _set_system_method(method_id: str) -> None:
+ """Pin System Run method and reset case when the method changes."""
+ prev = st.session_state.get("selected_system_method")
+ st.session_state["selected_system_method"] = method_id
+ if prev != method_id:
+ st.session_state["selected_system_case"] = system_run_module.default_case_for_method(
+ method_id
+ )
+
+
+def _set_system_case(case_key: str) -> None:
+ st.session_state["selected_system_case"] = case_key
+
+
+def _on_pick_system_method(method_id: str) -> None:
+ _set_system_method(method_id)
+
+
+def _on_pick_system_case(case_key: str) -> None:
+ _set_system_case(case_key)
+
+
+def _on_pick_system_prerun() -> None:
+ st.session_state["system_run_mode"] = "prerun"
+
+
+def _on_pick_system_live() -> None:
+ st.session_state["system_run_mode"] = "live"
+
+
+def _on_pick_live_method(method_id: str) -> None:
+ st.session_state["selected_live_method"] = method_id
+
+
+def _empty_live_method_state() -> dict[str, Any]:
+ return {
+ "result": None,
+ "judged": None,
+ "logs": [],
+ "status": "Idle",
+ "pending_action": None, # "run" | "evaluate" | None
+ "ran_once": False,
+ "evaluated_once": False,
+ }
+
+
+def _live_method_state(method_id: str) -> dict[str, Any]:
+ """Return the mutable per-method Live Run slot (isolated across settings)."""
+ store = st.session_state.get("live_run_by_method")
+ if not isinstance(store, dict):
+ store = {}
+ st.session_state["live_run_by_method"] = store
+ slot = store.get(method_id)
+ if not isinstance(slot, dict):
+ slot = _empty_live_method_state()
+ store[method_id] = slot
+ else:
+ slot.setdefault("result", None)
+ slot.setdefault("judged", None)
+ slot.setdefault("logs", [])
+ slot.setdefault("status", "Idle")
+ slot.setdefault("pending_action", None)
+ slot.setdefault("ran_once", False)
+ slot.setdefault("evaluated_once", False)
+ return slot
+
+
+def _render_system_live_run_view() -> None:
+ """Live Run: one AVerImaTeC example + parametric / websearch_deep + Task A eval."""
+ try:
+ example = system_live_module.example_claim()
+ except Exception as exc:
+ st.error(f"Could not load demo claim: {exc}")
+ return
+
+ live_method = str(
+ st.session_state.get("selected_live_method") or "codeagent_parametric"
+ ).strip()
+ method_ids = {m["id"] for m in system_live_module.METHOD_UI}
+ if live_method not in method_ids:
+ live_method = "codeagent_parametric"
+ st.session_state["selected_live_method"] = live_method
+
+ slot = _live_method_state(live_method)
+ status = str(slot.get("status") or "Idle")
+ busy = status in {"Running", "Evaluating"}
+ can_evaluate = bool(slot.get("result")) and not busy
+ run_label = "Re-run System" if slot.get("ran_once") else "Run SciFy System"
+ eval_label = "Re-evaluate" if slot.get("evaluated_once") else "Evaluate"
+
+ st.markdown('
Example claim
', unsafe_allow_html=True)
+ paper_title = str(example.get("paper_title") or "").strip() or "Unknown paper"
+ st.markdown(
+ f"""
+
+
+
Claim
+
{_escape(_ensure_claim_period(example.get("claim")))}
+
Paper: {_escape(paper_title)}
+
+
+ """,
+ unsafe_allow_html=True,
+ )
+
+ st.markdown('Setting
', unsafe_allow_html=True)
+ method_cols = st.columns(len(system_live_module.METHOD_UI))
+ for i, item in enumerate(system_live_module.METHOD_UI):
+ with method_cols[i]:
+ st.button(
+ item["label"],
+ key=f"live_method::{item['id']}",
+ type="primary" if item["id"] == live_method else "secondary",
+ use_container_width=True,
+ on_click=_on_pick_live_method,
+ args=(item["id"],),
+ disabled=busy,
+ )
+
+ st.markdown('Action
', unsafe_allow_html=True)
+ run_col, eval_col = st.columns(2)
+ with run_col:
+ run_clicked = st.button(
+ run_label,
+ type="secondary",
+ use_container_width=True,
+ key=f"live_run_system_btn::{live_method}",
+ disabled=busy,
+ )
+ with eval_col:
+ eval_clicked = st.button(
+ eval_label,
+ type="secondary",
+ use_container_width=True,
+ key=f"live_eval_btn::{live_method}",
+ disabled=busy or not can_evaluate,
+ )
+
+ with st.container(key="live_run_terminal"):
+ log_box = st.empty()
+ logs = list(slot.get("logs") or [])
+
+ def _flush_logs() -> None:
+ body = "\n".join(logs[-400:]) if logs else "Waiting for run output…"
+ log_box.code(body, language="text")
+ st.caption(f"Status: {status}")
+
+ _flush_logs()
+
+ # Phase 1: click → mark this method busy and rerun so buttons grey out immediately.
+ if run_clicked and not busy:
+ slot["pending_action"] = "run"
+ slot["judged"] = None
+ slot["result"] = None
+ slot["logs"] = []
+ slot["status"] = "Running"
+ st.rerun()
+
+ if eval_clicked and not busy and slot.get("result"):
+ slot["pending_action"] = "evaluate"
+ slot["status"] = "Evaluating"
+ st.rerun()
+
+ # Phase 2: execute pending action while buttons stay disabled.
+ pending = slot.get("pending_action")
+ if pending == "run" and status == "Running":
+ slot["pending_action"] = None
+ logs = []
+ status = "Running"
+
+ def _log(line: str) -> None:
+ logs.append(line)
+ slot["logs"] = list(logs)
+ log_box.code("\n".join(logs[-400:]), language="text")
+
+ try:
+ result = system_live_module.run_live_method(live_method, log_callback=_log)
+ slot["result"] = result
+ slot["status"] = "Completed"
+ slot["ran_once"] = True
+ status = "Completed"
+ _log(f"Done. ingredients={len(result.get('ingredients') or [])}")
+ except Exception as exc:
+ slot["result"] = None
+ slot["status"] = "Failed"
+ slot["ran_once"] = True
+ status = "Failed"
+ _log(f"ERROR: {exc}")
+ st.rerun()
+
+ if pending == "evaluate" and status == "Evaluating" and slot.get("result"):
+ slot["pending_action"] = None
+ logs = list(slot.get("logs") or [])
+ status = "Evaluating"
+
+ def _log_eval(line: str) -> None:
+ if line.strip().startswith("{") and '"ok"' in line:
+ return
+ logs.append(line)
+ slot["logs"] = list(logs)
+ log_box.code("\n".join(logs[-400:]), language="text")
+
+ try:
+ judged = system_live_module.evaluate_live_run(
+ slot["result"],
+ log_callback=_log_eval,
+ )
+ slot["judged"] = judged
+ slot["status"] = "Evaluated"
+ slot["evaluated_once"] = True
+ status = "Evaluated"
+ _log_eval(
+ f"Evaluated. F1={float(judged.get('f1') or 0):.2f} "
+ f"R={float(judged.get('recall') or 0):.2f} "
+ f"P={float(judged.get('precision') or 0):.2f}"
+ )
+ except Exception as exc:
+ slot["status"] = "Eval failed"
+ slot["evaluated_once"] = True
+ status = "Eval failed"
+ _log_eval(f"ERROR: {exc}")
+ st.rerun()
+
+ run_result = slot.get("result")
+ judged = slot.get("judged")
+ if run_result:
+ case = system_live_module.live_result_to_case_card(run_result, judged)
+ # Claim/paper already shown above in the example block; card is results only.
+ result_html = _system_case_card_html(case)
+ st.markdown(
+ f'{result_html}
',
+ unsafe_allow_html=True,
+ )
+
+
+def _normalize_abstract(text: str) -> str:
+ cleaned = re.sub(r"\s+", " ", (text or "").strip())
+ cleaned = re.sub(r"^(abstract)\s*[:.]?\s*", "", cleaned, flags=re.IGNORECASE)
+ # Drop trailing PDF footnote markers like ").1" / ".”2".
+ cleaned = re.sub(r"(?<=[.!?\)\]\"'”’])\d+$", "", cleaned)
+ return cleaned.strip()
+
+
+def _metadata_from_paper_dir(paper_dir: Path) -> Optional[dict[str, str]]:
+ data = _load_json(paper_dir / "paper_metadata.json")
+ record = None
+ if isinstance(data, list) and data:
+ record = data[0] if isinstance(data[0], dict) else None
+ elif isinstance(data, dict):
+ record = data
+ if not record:
+ return None
+ title = str(record.get("title") or "").strip()
+ abstract = _normalize_abstract(str(record.get("abstract") or ""))
+ if not title and not abstract:
+ return None
+ return {"title": title, "abstract": abstract}
+
+
+@st.cache_data(show_spinner=False, ttl=6 * 60 * 60)
+def _fetch_arxiv_title_abstract(arxiv_id: str) -> Optional[dict[str, str]]:
+ url = f"http://export.arxiv.org/api/query?id_list={arxiv_id}"
+ try:
+ with urllib.request.urlopen(url, timeout=12) as resp: # noqa: S310
+ payload = resp.read()
+ except (urllib.error.URLError, TimeoutError, OSError):
+ return None
+ try:
+ root = ET.fromstring(payload)
+ except ET.ParseError:
+ return None
+ ns = {"atom": "http://www.w3.org/2005/Atom"}
+ entry = root.find("atom:entry", ns)
+ if entry is None:
+ return None
+ title = re.sub(r"\s+", " ", (entry.findtext("atom:title", default="", namespaces=ns) or "").strip())
+ abstract = _normalize_abstract(entry.findtext("atom:summary", default="", namespaces=ns) or "")
+ if not title and not abstract:
+ return None
+ return {"title": title, "abstract": abstract}
+
+
+def _resolve_paper_preview(paper_input: str) -> Optional[dict[str, str]]:
+ """Title/abstract for the selected paper (trace metadata, run dir, or arXiv)."""
+ arxiv_id = replay_module.parse_arxiv_id(paper_input or "")
+ if not arxiv_id:
+ return None
+
+ entry = _paper_result_for_input(paper_input)
+ if entry and entry.get("paper_dir_path"):
+ meta = _metadata_from_paper_dir(Path(entry["paper_dir_path"]))
+ if meta:
+ return meta
+
+ trace_dir = replay_module.find_trace_dir(paper_input)
+ if trace_dir is not None:
+ meta = _metadata_from_paper_dir(trace_dir / "processed_papers" / arxiv_id)
+ if meta:
+ return meta
+
+ return _fetch_arxiv_title_abstract(arxiv_id)
+
+
+def _render_paper_preview(paper_input: str) -> None:
+ preview = _resolve_paper_preview(paper_input)
+ if not preview:
+ st.caption("Paper metadata unavailable for this ID yet.")
+ return
+ title = preview.get("title") or "Untitled paper"
+ abstract = preview.get("abstract") or ""
+ # Title as HTML; abstract as markdown so `$...$` goes through Streamlit KaTeX.
+ body = (
+ f""
+ f"
{_esc(title)}
"
+ f"
"
+ )
+ if abstract:
+ body = f"{body}\n\n{abstract}"
+ st.markdown(body, unsafe_allow_html=True)
+
+
+def _derive_work_step_states(events: list[str]) -> dict[int, str]:
+ """Map pipeline step numbers to pending | active | done | failed."""
+ order = [n for n, _ in WORK_PANEL_STEPS]
+ started: set[int] = set()
+ completed: set[int] = set()
+ failed = False
+ pipeline_complete = False
+
+ for raw in events or []:
+ text = str(raw)
+ lower = text.lower()
+ if text == "Pipeline completed successfully.":
+ pipeline_complete = True
+ if "failed" in lower:
+ failed = True
+ m_done = re.search(r"Step\s+(\d+)\s+complete", text, re.IGNORECASE)
+ if m_done:
+ completed.add(int(m_done.group(1)))
+ continue
+ m_start = re.search(r"Step\s+(\d+)\s*/", text)
+ if m_start:
+ started.add(int(m_start.group(1)))
+ continue
+ if "[annotation]" in lower or "annotate target contributions" in lower:
+ if "complete" in lower or "skipped" in lower:
+ completed.add(8)
+ else:
+ started.add(8)
+
+ max_started = max(started) if started else 0
+ states: dict[int, str] = {}
+ for n in order:
+ if n in completed or (n in started and n < max_started):
+ states[n] = "done"
+ elif n in started:
+ states[n] = "failed" if failed else "active"
+ else:
+ states[n] = "pending"
+
+ if pipeline_complete and not failed:
+ for n in order:
+ # Replay traces often omit explicit completes for every step.
+ if n < 8 or 8 in completed or 8 in started:
+ states[n] = "done"
+ if 8 in completed:
+ states[8] = "done"
+ return states
+
+
+def _display_work_status(status: str) -> str:
+ """Steps badge only shows Idle / Running (Stopped/Failed/Completed → Idle)."""
+ text = (status or "").strip()
+ if not text:
+ return "Idle"
+ lower = text.lower()
+ if lower in {"running", "starting"} or text.startswith("Step "):
+ return "Running"
+ return "Idle"
+
+
+def _work_panel_html(
+ events: list[str],
+ *,
+ status: str = "Idle",
+ mode_note: str = "",
+) -> str:
+ states = _derive_work_step_states(events)
+ status_label = f"{_display_work_status(status)}{mode_note}"
+ rows: list[str] = []
+ for n, label in WORK_PANEL_STEPS:
+ state = states.get(n, "pending")
+ busy = " aria-busy='true'" if state == "active" else ""
+ rows.append(
+ ""
+ ""
+ f"{_esc(label)}"
+ ""
+ )
+ body = (
+ f""
+ if rows
+ else "Waiting for the first step…
"
+ )
+ return (
+ ""
+ "
"
+ "
Steps
"
+ f"
{_esc(status_label)}
"
+ "
"
+ f"{body}"
+ "
"
+ )
+
+
+def _render_work_panel(
+ placeholder,
+ events: list[str],
+ *,
+ status: str = "Idle",
+ mode_note: str = "",
+) -> None:
+ placeholder.markdown(
+ _work_panel_html(events, status=status, mode_note=mode_note),
+ unsafe_allow_html=True,
+ )
+
+
+def _reset_all_session_state() -> None:
+ """Hard reset used by Reset session: wipe every session key, then re-seed defaults."""
+ for key in list(st.session_state.keys()):
+ del st.session_state[key]
+ for key, value in STATE_DEFAULTS.items():
+ st.session_state[key] = _copy_default(value)
+
+
+def _metric_card(label: str, value: Any):
+ return (
+ f""
+ f"
{_esc(label)}
"
+ f"
{_esc(value)}
"
+ f"
"
+ )
+
+
+def _esc(value: Any) -> str:
+ return html.escape("" if value is None else str(value))
+
+
+def _safe_int(value: Any, default: int = 0) -> int:
+ try:
+ return int(value)
+ except (TypeError, ValueError):
+ return default
+
+
+def _grounding_html(grounding: Optional[dict], label: str, kind: str) -> str:
+ if not grounding:
+ return ""
+ title = (
+ grounding.get("ref_title")
+ or grounding.get("title")
+ or grounding.get("paper_id")
+ or grounding.get("ref_id")
+ or "__NONE__"
+ )
+ meta = []
+ if grounding.get("paper_id"):
+ meta.append(f"paper_id: {grounding.get('paper_id')}")
+ elif grounding.get("ref_id"):
+ meta.append(f"ref_id: {grounding.get('ref_id')}")
+ if grounding.get("ref_year"):
+ meta.append(str(grounding.get("ref_year")))
+ authors = grounding.get("ref_authors")
+ if isinstance(authors, list) and authors:
+ meta.append(", ".join(str(author) for author in authors[:3]))
+ meta_html = f"{_esc(' · '.join(meta))}
" if meta else ""
+ return (
+ ""
+ f"
{_esc(label)}
"
+ f"
{_esc(title)}
"
+ f"{meta_html}"
+ "
"
+ )
+
+
+def _study_key(item: dict) -> str:
+ for key in ["paper_id", "ref_id", "ref_title", "title"]:
+ value = item.get(key)
+ if value:
+ return str(value).lower()
+ return ""
+
+
+def _collect_grounded_studies(discoveries: list[dict], ingredients: list[dict]) -> list[dict]:
+ studies: list[dict] = []
+ seen: set[str] = set()
+ for item in discoveries:
+ if not isinstance(item, dict):
+ continue
+ copied = dict(item)
+ copied["_grounding_kind"] = "grounding"
+ copied["_grounding_label"] = "Grounding"
+ key = _study_key(copied)
+ if key:
+ seen.add(key)
+ studies.append(copied)
+
+ for idx, ingredient in enumerate(ingredients, start=1):
+ if not isinstance(ingredient, dict):
+ continue
+ canonical = ingredient.get("canonical_grounding") or {}
+ canonical_key = _study_key(canonical) if isinstance(canonical, dict) else ""
+ annotation = ingredient.get("canonical_annotation") or {}
+ for ref in ingredient.get("additional_groundings") or []:
+ if not isinstance(ref, dict):
+ continue
+ key = _study_key(ref)
+ if key and (key == canonical_key or key in seen):
+ continue
+ copied = dict(ref)
+ copied["_grounding_kind"] = "grounding"
+ copied["_grounding_label"] = f"Grounding for enabling contribution {idx}"
+ copied.setdefault("role", annotation.get("role") or ", ".join(annotation.get("roles") or []))
+ copied.setdefault("contribution", annotation.get("contribution"))
+ copied.setdefault("rationale", annotation.get("rationale"))
+ if key:
+ seen.add(key)
+ studies.append(copied)
+ return studies
+
+
+def _render_reference_list(discoveries: list[dict], ingredients: Optional[list[dict]] = None):
+ studies = _collect_grounded_studies(discoveries, ingredients or [])
+ if not studies:
+ st.markdown("No grounded studies listed for this target contribution.
", unsafe_allow_html=True)
+ return
+ for item in studies:
+ title = item.get("ref_title") or item.get("title") or item.get("ref_id") or item.get("paper_id") or "Untitled reference"
+ meta = []
+ if item.get("_grounding_label"):
+ meta.append(str(item.get("_grounding_label")))
+ if item.get("role"):
+ meta.append(str(item.get("role")))
+ if item.get("ref_year"):
+ meta.append(str(item.get("ref_year")))
+ class_name = "cluster-card additional-study"
+ body = [f"{_esc(title)}
"]
+ if meta:
+ body.append(f"
{_esc(' · '.join(meta))}
")
+ if item.get("contribution"):
+ body.append(f"
Contribution. {_esc(item.get('contribution'))}
")
+ if item.get("rationale"):
+ body.append(f"
Rationale. {_esc(item.get('rationale'))}
")
+ body.append("
")
+ st.markdown("".join(body), unsafe_allow_html=True)
+
+
+def _render_claims_tab(payload: Optional[dict]):
+ if not payload:
+ st.markdown("No annotation payload is available yet.
", unsafe_allow_html=True)
+ return
+ claims = payload.get("claims") or []
+ if not claims:
+ st.markdown("The run completed, but no target contributions were produced.
", unsafe_allow_html=True)
+ return
+
+ st.markdown(
+ "This decomposition is done in hindsight: SciPaths uses observed downstream citation clusters to identify reusable target contributions, then reconstructs the enabling contributions and groundings behind each one.
",
+ unsafe_allow_html=True,
+ )
+
+ for idx, claim in enumerate(claims, start=1):
+ claim_id = claim.get("claim_id") or f"C{idx}"
+ claim_text = claim.get("rewritten_claim") or claim.get("text") or "(missing target contribution text)"
+ ingredients = claim.get("ingredients") or []
+ discoveries = claim.get("enabling_discoveries") or []
+ grounded_studies = _collect_grounded_studies(discoveries, ingredients)
+ meta_bits = []
+ if claim.get("decision"):
+ meta_bits.append(str(claim.get("decision")))
+ if claim.get("cluster_id"):
+ meta_bits.append(f"cluster {claim.get('cluster_id')}")
+ meta_bits.append(f"{len(ingredients)} enabling contribution{'s' if len(ingredients) != 1 else ''}")
+ meta_bits.append(f"{len(grounded_studies)} grounded stud{'ies' if len(grounded_studies) != 1 else 'y'}")
+
+ st.markdown(
+ f"""
+
+
+
Target contribution {idx} · {_esc(claim_id)}
+
{_esc(claim_text)}
+
{_esc(' · '.join(meta_bits))}
+
+
+ """,
+ unsafe_allow_html=True,
+ )
+ left, right = st.columns([1.7, 1.0], gap="large")
+ with left:
+ st.markdown("Decomposition
", unsafe_allow_html=True)
+ if not ingredients:
+ st.markdown("No enabling contributions for this target contribution.
", unsafe_allow_html=True)
+ for ingredient_idx, ingredient in enumerate(ingredients, start=1):
+ annotation = ingredient.get("canonical_annotation") or {}
+ role = annotation.get("role") or ", ".join(annotation.get("roles") or []) or "UNSPECIFIED"
+ canonical_grounding = ingredient.get("canonical_grounding") or {}
+ extras = ingredient.get("additional_groundings") or []
+ grounding_parts = []
+ if canonical_grounding:
+ grounding_parts.append(
+ _grounding_html(canonical_grounding, "Grounding", "grounding")
+ )
+ for ref in extras:
+ if not isinstance(ref, dict):
+ continue
+ if canonical_grounding and (
+ ref.get("paper_id") == canonical_grounding.get("paper_id")
+ or ref.get("ref_id") == canonical_grounding.get("ref_id")
+ ):
+ continue
+ grounding_parts.append(
+ _grounding_html(ref, "Grounding", "grounding")
+ )
+ if not grounding_parts:
+ canonical_ref_id = ingredient.get("canonical_ref_id") or "__NONE__"
+ grounding_parts.append(
+ ""
+ "
Grounding
"
+ f"
{_esc(canonical_ref_id)}
"
+ "
"
+ )
+ grounding_block = (
+ ""
+ f"
Groundings for enabling contribution {ingredient_idx}
"
+ + "".join(grounding_parts)
+ + "
"
+ )
+ st.markdown(
+ f"""
+
+
+
{ingredient_idx}. {_esc(ingredient.get('ingredient') or '(missing enabling contribution)')}
+
{_esc(role)}
+
+
Contribution. {_esc(annotation.get('contribution') or '')}
+
Rationale. {_esc(annotation.get('rationale') or '')}
+
Evidence. {_esc(annotation.get('evidence_span') or '')}
+ {grounding_block}
+
+ """,
+ unsafe_allow_html=True,
+ )
+ with right:
+ st.markdown("Groundings
", unsafe_allow_html=True)
+ _render_reference_list(discoveries, ingredients)
+
+
+def _render_clusters_tab(discovery: Optional[dict], contributions: list[dict]):
+ if not discovery:
+ st.markdown("No refined cluster file is available yet.
", unsafe_allow_html=True)
+ return
+ st.markdown(
+ "Citation clusters look forward: they summarize how later papers use or extend the input paper, showing which contributions became reusable.
",
+ unsafe_allow_html=True,
+ )
+ clusters = discovery.get("clusters") or []
+ dropped = discovery.get("dropped_clusters") or []
+ if not clusters:
+ st.markdown("No valid downstream usage clusters survived refinement and filtering.
", unsafe_allow_html=True)
+ if dropped:
+ with st.expander(f"Dropped clusters ({len(dropped)})", expanded=False):
+ st.json(dropped)
+ return
+
+ for cluster in clusters:
+ cluster_id = cluster.get("cluster_id", "")
+ rep = cluster.get("representative_claim") or cluster.get("cluster_title") or "(missing representative claim)"
+ count = _safe_int(cluster.get("count"), len(cluster.get("claim_indices") or []))
+ source_ids = cluster.get("source_cluster_ids") or []
+ merge_rationale = cluster.get("merge_rationale") or ""
+ st.markdown(
+ f"""
+
+
{_esc(rep)}
+
Cluster {_esc(cluster_id)} · {count} contribution instance{'s' if count != 1 else ''}
+
+ """,
+ unsafe_allow_html=True,
+ )
+ meta_cols = st.columns([1.3, 1.3, 1.4])
+ with meta_cols[0]:
+ st.caption("Cluster ID")
+ st.code(str(cluster_id), language="text")
+ with meta_cols[1]:
+ st.caption("Source clusters")
+ st.code(", ".join(str(x) for x in source_ids) if source_ids else "singleton", language="text")
+ with meta_cols[2]:
+ st.caption("Merge rationale")
+ st.write(merge_rationale or "—")
+
+ claim_indices = cluster.get("claim_indices") or []
+ if claim_indices:
+ with st.expander(f"Linked contribution instances ({len(claim_indices)})", expanded=False):
+ for idx in claim_indices:
+ try:
+ j = int(idx)
+ except Exception:
+ continue
+ if 0 <= j < len(contributions):
+ item = contributions[j] or {}
+ title = item.get("citing_title") or item.get("citing_paper_id") or "Unknown citing paper"
+ claim = item.get("paper_claim") or item.get("claim") or "(missing claim)"
+ rationale = item.get("rationale") or ""
+ evidence = item.get("evidence_span") or ""
+ st.markdown(f"**{title}**")
+ st.write(claim)
+ if rationale:
+ st.caption(f"Rationale: {rationale}")
+ if evidence:
+ st.caption(f"Evidence: {evidence}")
+ st.divider()
+
+ if dropped:
+ with st.expander(f"Dropped clusters ({len(dropped)})", expanded=False):
+ st.json(dropped)
+
+
+def run_replay_stream(
+ paper_input: str,
+ work_panel=None,
+ metrics_slot=None,
+ graph_slot=None,
+ courier_slot=None,
+) -> bool:
+ """Replay a saved demo trace into session state. Returns False if no trace exists."""
+ trace = replay_module.load_trace(paper_input)
+ if not trace:
+ return False
+
+ _clear_run_state(keep_paper_input=True)
+ st.session_state["run_status"] = "Running"
+ st.session_state["replay_mode"] = True
+
+ # Graph can read the finished trace artifacts while steps reveal progressively.
+ st.session_state["paper_dir_path"] = trace.get("paper_dir_path")
+ st.session_state["annotation_payload_path"] = trace.get("annotation_payload_path")
+
+ try:
+ import neo4j_workflow as _neo
+
+ _neo.reset_run(_workflow_run_id(paper_input))
+ except Exception:
+ pass
+
+ panel = work_panel if work_panel is not None else st.empty()
+ events: list[str] = []
+ _render_work_panel(panel, events, status="Running")
+ _push_graph(courier_slot, paper_input, events)
+
+ for line in trace.get("run_events") or []:
+ display_line = _display_log_line(str(line))
+ if not display_line:
+ continue
+ if display_line not in events:
+ events.append(display_line)
+ _render_work_panel(panel, events, status="Running")
+ _push_graph(courier_slot, paper_input, events)
+ delay = REPLAY_STEP_DELAY_SEC
+ if re.search(r"Step\s+\d+\s+complete", display_line, re.IGNORECASE):
+ delay += REPLAY_STEP_HOLD_SEC
+ time.sleep(max(0.0, delay))
+
+ # Commit final replay payload for the Clusters / Decomposition tabs.
+ st.session_state["run_status"] = trace.get("run_status") or trace.get("status") or "Completed"
+ st.session_state["run_logs"] = list(trace.get("run_logs") or events)
+ st.session_state["run_events"] = list(trace.get("run_events") or events)
+ st.session_state["artifact_path"] = trace.get("artifact_path")
+ st.session_state["run_dir_path"] = trace.get("run_dir_path")
+ st.session_state["paper_dir_path"] = trace.get("paper_dir_path")
+ st.session_state["annotation_payload_path"] = trace.get("annotation_payload_path")
+ st.session_state["annotation_skipped_reason"] = trace.get("annotation_skipped_reason")
+ st.session_state["pipeline_stopped_reason"] = trace.get("pipeline_stopped_reason")
+ st.session_state["pipeline_failed_reason"] = trace.get("pipeline_failed_reason")
+ st.session_state["remote_artifact_ref"] = ""
+ st.session_state["replay_mode"] = True
+
+ arxiv_id = str(trace.get("arxiv_id") or replay_module.parse_arxiv_id(paper_input) or "")
+ _store_paper_result(
+ arxiv_id,
+ {
+ "run_status": st.session_state["run_status"],
+ "run_logs": list(st.session_state["run_logs"]),
+ "run_events": list(st.session_state["run_events"]),
+ "artifact_path": st.session_state.get("artifact_path"),
+ "run_dir_path": st.session_state.get("run_dir_path"),
+ "paper_dir_path": st.session_state.get("paper_dir_path"),
+ "annotation_payload_path": st.session_state.get("annotation_payload_path"),
+ "annotation_skipped_reason": st.session_state.get("annotation_skipped_reason"),
+ "pipeline_stopped_reason": st.session_state.get("pipeline_stopped_reason"),
+ "pipeline_failed_reason": st.session_state.get("pipeline_failed_reason"),
+ "replay_mode": True,
+ },
+ )
+
+ _render_work_panel(
+ panel,
+ st.session_state["run_events"],
+ status=st.session_state["run_status"],
+ )
+ _push_graph(courier_slot, paper_input, st.session_state["run_events"])
+ _refresh_overview_for_paper(paper_input, metrics_slot)
+ return True
+
+
+def run_two_pass_annotation(
+ paper_dir: Path,
+ annotation_output_root: Path,
+ llm_provider: str,
+ llm_model: str,
+ formatter_model: str,
+ judge_model: str,
+ candidate_count: int,
+):
+ paper = load_paper_package(paper_dir)
+ pipeline = TwoPassAnnotationPipeline(
+ provider=llm_provider,
+ model=llm_model,
+ formatter_model=formatter_model or None,
+ judge_model=judge_model or None,
+ output_root=annotation_output_root,
+ annotator_id="streamlit_hf_space",
+ candidate_count=max(1, int(candidate_count)),
+ formatter_max_attempts=3,
+ include_reference_examples=True,
+ prompt_profile="full",
+ )
+ result = pipeline.run(paper)
+ return result.result, result.run_dir
+
+
+def run_pipeline_stream(
+ paper_input: str,
+ source_root: str,
+ output_root: str,
+ llm_provider: str,
+ llm_model: str,
+ llm_model_step4: str,
+ formatter_model: str,
+ judge_model: str,
+ candidate_count: int,
+ work_panel=None,
+ metrics_slot=None,
+ graph_slot=None,
+ courier_slot=None,
+):
+ gemini_key = get_secret("GEMINI_API_KEY")
+ if gemini_key:
+ os.environ["GEMINI_API_KEY"] = gemini_key
+
+ # Drop previous success/failure UI before this run starts rendering progress.
+ _clear_run_state(keep_paper_input=True)
+ st.session_state["run_status"] = "Starting"
+ try:
+ import neo4j_workflow as _neo
+
+ _neo.reset_run(_workflow_run_id(paper_input))
+ except Exception:
+ pass
+
+ cfg = PipelineConfig(
+ repo_root=REPO_ROOT,
+ source_root=Path(source_root).expanduser().resolve(),
+ paper_input=paper_input.strip(),
+ llm_provider=llm_provider.strip() or "gemini",
+ llm_model=llm_model.strip() or "gemini-3.1-pro-preview",
+ llm_model_step4=llm_model_step4.strip() or "gemini-3-flash-preview",
+ model_path="Deep-Citation/Workspace/acl_scicite_wksp_trl/best_model.pt",
+ model_data_dir="Deep-Citation/Data",
+ model_class_def="Deep-Citation/Data/class_def.json",
+ model_lm="scibert",
+ device="cpu",
+ embedding_model="sentence-transformers/all-mpnet-base-v2",
+ )
+
+ panel = work_panel if work_panel is not None else st.empty()
+ status = "Starting"
+ logs: list[str] = []
+ events: list[str] = []
+ seen_events: set[str] = set()
+ artifact_path = None
+ annotation_payload_path = None
+ annotation_skipped_reason = None
+ run_summary = None
+ pipeline_stopped_reason = None
+ pipeline_failed_reason = None
+
+ def render_activity(items: list[str], *, current_status: str | None = None):
+ _render_work_panel(
+ panel,
+ items,
+ status=current_status or status,
+ mode_note="",
+ )
+ _push_graph(courier_slot, paper_input, items)
+
+ def append_display_line(line: str):
+ nonlocal status
+ display_line = _display_log_line(line)
+ if not display_line:
+ return
+ logs.append(display_line)
+ status = _status_from_line(display_line, status)
+ event = _format_step_event(display_line)
+ if event and event not in seen_events:
+ seen_events.add(event)
+ events.append(event)
+ render_activity(events, current_status=status)
+
+ render_activity(events, current_status=status)
+
+ for line, maybe_artifact in runner_module.run_pipeline(cfg, Path(output_root).expanduser().resolve()):
+ if line:
+ if line.strip() == "Pipeline completed successfully.":
+ if maybe_artifact:
+ artifact_path = maybe_artifact
+ continue
+ display_line = _display_log_line(line)
+ if display_line:
+ logs.append(display_line)
+ status = _status_from_line(display_line, status)
+ if display_line.startswith("Pipeline stopped:"):
+ pipeline_stopped_reason = display_line
+ if "failed" in display_line.lower():
+ pipeline_failed_reason = display_line
+ event = _format_step_event(display_line)
+ if event and event not in seen_events:
+ seen_events.add(event)
+ events.append(event)
+ if maybe_artifact:
+ artifact_path = maybe_artifact
+ try:
+ job_dir = Path(str(maybe_artifact)).with_suffix("")
+ paper_id = runner_module.parse_arxiv_id(paper_input.strip())
+ st.session_state["paper_dir_path"] = str(job_dir / "processed_papers" / paper_id)
+ except Exception:
+ pass
+ render_activity(events, current_status=status)
+
+ run_dir_path = None
+ paper_dir_path = None
+ remote_artifact_ref = ""
+ if artifact_path:
+ job_dir = Path(str(artifact_path)).with_suffix("")
+ run_dir_path = str(job_dir)
+ paper_id = runner_module.parse_arxiv_id(paper_input.strip())
+ paper_dir = job_dir / "processed_papers" / paper_id
+ paper_dir_path = str(paper_dir)
+ if pipeline_failed_reason:
+ annotation_skipped_reason = f"{pipeline_failed_reason} Annotation was not run."
+ elif pipeline_stopped_reason:
+ annotation_skipped_reason = f"{pipeline_stopped_reason} Annotation was not run."
+ else:
+ discovery = _load_json(paper_dir / "usage_discovery_from_contributions.json") or {}
+ refined_clusters = discovery.get("clusters") or []
+ if not refined_clusters:
+ annotation_skipped_reason = "No valid downstream usage clusters remained after refinement and filtering. Annotation was skipped."
+ logs.append("[annotation] skipped: no refined downstream usage clusters")
+ else:
+ append_display_line("[annotation] starting cluster-first two-pass annotation")
+ try:
+ run_output, annotation_run_dir = run_two_pass_annotation(
+ paper_dir=paper_dir,
+ annotation_output_root=job_dir / "two_pass_outputs",
+ llm_provider=llm_provider,
+ llm_model=llm_model,
+ formatter_model=formatter_model,
+ judge_model=judge_model,
+ candidate_count=candidate_count,
+ )
+ payload_path = run_output.get("ui_payload_path") if isinstance(run_output, dict) else None
+ if payload_path and Path(payload_path).exists():
+ annotation_payload_path = str(Path(payload_path))
+ append_display_line(f"[annotation] complete: {annotation_run_dir}")
+ except Exception as exc:
+ pipeline_failed_reason = f"Annotation failed: {exc}"
+ annotation_skipped_reason = pipeline_failed_reason
+ logs.append(f"[annotation] failed: {exc}")
+ logs.append("[upload] uploading run artifact to Hugging Face dataset")
+ remote_artifact_ref = upload_run_artifact(job_dir)
+ if remote_artifact_ref:
+ logs.append(f"[upload] {remote_artifact_ref}")
+ else:
+ logs.append("[upload] skipped: RUNS_REPO_ID/HF_WRITE_TOKEN not configured")
+ if not pipeline_stopped_reason and not pipeline_failed_reason:
+ append_display_line("Pipeline completed successfully.")
+
+ if pipeline_failed_reason:
+ status = "Failed"
+ elif artifact_path and pipeline_stopped_reason:
+ status = "Stopped"
+ else:
+ status = "Completed" if artifact_path else "Failed"
+ render_activity(events, current_status=status)
+
+ st.session_state["run_status"] = status
+ st.session_state["run_logs"] = logs
+ st.session_state["run_events"] = events
+ st.session_state["artifact_path"] = artifact_path
+ st.session_state["run_dir_path"] = run_dir_path
+ st.session_state["paper_dir_path"] = paper_dir_path
+ st.session_state["annotation_payload_path"] = annotation_payload_path
+ st.session_state["annotation_skipped_reason"] = annotation_skipped_reason
+ st.session_state["pipeline_stopped_reason"] = pipeline_stopped_reason
+ st.session_state["pipeline_failed_reason"] = pipeline_failed_reason
+ st.session_state["run_summary"] = run_summary
+ st.session_state["remote_artifact_ref"] = remote_artifact_ref
+ st.session_state["replay_mode"] = False
+
+ arxiv_id = replay_module.parse_arxiv_id(paper_input) or ""
+ _store_paper_result(
+ arxiv_id,
+ {
+ "run_status": status,
+ "run_logs": list(logs),
+ "run_events": list(events),
+ "artifact_path": artifact_path,
+ "run_dir_path": run_dir_path,
+ "paper_dir_path": paper_dir_path,
+ "annotation_payload_path": annotation_payload_path,
+ "annotation_skipped_reason": annotation_skipped_reason,
+ "pipeline_stopped_reason": pipeline_stopped_reason,
+ "pipeline_failed_reason": pipeline_failed_reason,
+ "replay_mode": False,
+ },
+ )
+ _push_graph(courier_slot, paper_input, events)
+ _refresh_overview_for_paper(paper_input, metrics_slot)
+
+
+def _load_result_bundle(paper_input: Optional[str] = None):
+ """Load clusters/decomposition for the selected paper only (session cache)."""
+ selected = paper_input if paper_input is not None else st.session_state.get("paper_input", "")
+ entry = _paper_result_for_input(selected)
+ if not entry:
+ return None, None, [], None
+
+ paper_dir_path = entry.get("paper_dir_path")
+ annotation_payload_path = entry.get("annotation_payload_path")
+ paper_dir = Path(paper_dir_path) if paper_dir_path else None
+ payload = _load_json(Path(annotation_payload_path)) if annotation_payload_path else None
+ discovery = (
+ _load_json(paper_dir / "usage_discovery_from_contributions.json")
+ if paper_dir and paper_dir.exists()
+ else None
+ )
+ contributions_data = (
+ _load_json(paper_dir / "usage_contributions.json")
+ if paper_dir and paper_dir.exists()
+ else None
+ )
+ contributions = (contributions_data or {}).get("contributions") or []
+ return paper_dir, discovery, contributions, payload
+
+
+def _overview_counts(
+ payload: Optional[dict], discovery: Optional[dict]
+) -> tuple[int, int, int, int]:
+ claims = (payload or {}).get("claims") or []
+ ingredients = sum(len(claim.get("ingredients") or []) for claim in claims)
+ studies = sum(
+ len(_collect_grounded_studies(claim.get("enabling_discoveries") or [], claim.get("ingredients") or []))
+ for claim in claims
+ )
+ clusters = len((discovery or {}).get("clusters") or [])
+ return clusters, len(claims), ingredients, studies
+
+
+def _overview_html(payload: Optional[dict], discovery: Optional[dict]) -> str:
+ clusters, n_claims, ingredients, studies = _overview_counts(payload, discovery)
+ return (
+ ""
+ f"{_metric_card('Refined clusters', clusters)}"
+ f"{_metric_card('Target contributions', n_claims)}"
+ f"{_metric_card('Enabling contributions', ingredients)}"
+ f"{_metric_card('Grounded studies', studies)}"
+ "
"
+ )
+
+
+def _render_overview(
+ payload: Optional[dict],
+ discovery: Optional[dict],
+ placeholder=None,
+) -> None:
+ html_body = _overview_html(payload, discovery)
+ if placeholder is not None:
+ placeholder.markdown(html_body, unsafe_allow_html=True)
+ else:
+ st.markdown(html_body, unsafe_allow_html=True)
+
+
+def _refresh_overview_for_paper(paper_input: str, placeholder) -> None:
+ """Update metrics in-place after a run finishes (labels stay put; counts change)."""
+ if placeholder is None:
+ return
+ _paper_dir, discovery, _contributions, payload = _load_result_bundle(paper_input)
+ _render_overview(payload, discovery, placeholder=placeholder)
+
+def _build_public_export(discovery: Optional[dict], payload: Optional[dict]) -> dict:
+ claims = []
+ for claim in (payload or {}).get("claims") or []:
+ if not isinstance(claim, dict):
+ continue
+ ingredients = []
+ for ingredient in claim.get("ingredients") or []:
+ if not isinstance(ingredient, dict):
+ continue
+ ingredients.append({
+ "ingredient_id": ingredient.get("ingredient_id"),
+ "enabling_contribution": ingredient.get("ingredient"),
+ "canonical_annotation": ingredient.get("canonical_annotation") or {},
+ "primary_grounding": ingredient.get("canonical_grounding") or {},
+ "additional_groundings": ingredient.get("additional_groundings") or [],
+ })
+ claims.append({
+ "claim_id": claim.get("claim_id"),
+ "target_contribution": claim.get("rewritten_claim") or claim.get("text"),
+ "cluster_id": claim.get("cluster_id"),
+ "decision": claim.get("decision"),
+ "enabling_contributions": ingredients,
+ "grounded_studies": _collect_grounded_studies(claim.get("enabling_discoveries") or [], claim.get("ingredients") or []),
+ })
+
+ return {
+ "citation_clusters": (discovery or {}).get("clusters") or [],
+ "target_contribution_decompositions": claims,
+ }
+
+
+def _escape(text: Any) -> str:
+ return html.escape(str(text or ""), quote=True)
+
+
+def _format_analysis_bullet(text: Any) -> str:
+ """Bold leading taxonomy tag; render as 'Tag:' instead of '[Tag]'."""
+ raw = str(text or "")
+ match = re.match(r"^\[([^\]]+)\]\s*(.*)$", raw, flags=re.DOTALL)
+ if not match:
+ return _escape(raw)
+ tag = match.group(1).strip()
+ rest = match.group(2)
+ label = f"{tag}:"
+ if rest:
+ return f"{_escape(label)} {_escape(rest)}"
+ return f"{_escape(label)}"
+
+
+def _render_gold_item(
+ item: dict, *, paired: bool = False, show_match_notes: bool = True, show_judge: bool = True
+) -> str:
+ badge = str(item.get("badge") or "miss")
+ role = _escape(item.get("role") or "")
+ desc = _escape(item.get("description") or "")
+ notes = []
+ if show_match_notes:
+ for note in item.get("match_notes") or []:
+ cls = "system-match-note partial" if badge == "partial" else "system-match-note"
+ notes.append(f"{_escape(note)}
")
+ if show_judge:
+ judge = item.get("judge_note") or ""
+ if judge:
+ notes.append(f"{_escape(judge)}
")
+ paired_cls = " is-paired" if paired else ""
+ return (
+ f""
+ "
"
+ f"{badge}"
+ f"{role}"
+ "
"
+ f"
{desc}
"
+ f"{''.join(notes)}"
+ "
"
+ )
+
+
+def _render_pred_item(item: dict, *, paired: bool = False, show_rationale: bool = True) -> str:
+ idx = item.get("idx") or ""
+ role = _escape(item.get("role") or "")
+ desc = _escape(item.get("description") or "")
+ rationale = (item.get("rationale") or "") if show_rationale else ""
+ rat_html = f"{_escape(rationale)}
" if rationale else ""
+ paired_cls = " is-paired" if paired else ""
+ return (
+ f""
+ "
"
+ f"{_escape(idx)}"
+ f"{role}"
+ "
"
+ f"
{desc}
"
+ f"{rat_html}"
+ "
"
+ )
+
+
+def _render_gold_column(items: list[dict]) -> str:
+ rows = [_render_gold_item(item) for item in items]
+ body = "".join(rows) if rows else "None
"
+ return f"Gold ingredients
{body}"
+
+
+def _render_pred_column(items: list[dict]) -> str:
+ rows = [_render_pred_item(item) for item in items]
+ body = "".join(rows) if rows else "None
"
+ return f"SciFy Predictions
{body}"
+
+
+def _render_paired_ingredient_grid(case: dict) -> str:
+ """Success layout: gold left, predictions right, lines between matched pairs."""
+ gold = case.get("gold_ingredients") or []
+ predicted = case.get("predicted_ingredients") or []
+ rows = system_run_module.build_pair_rows(gold, predicted)
+ body = [
+ "",
+ "
Gold ingredients
SciFy Predictions
",
+ ]
+ for row in rows:
+ linked = bool(row.get("linked"))
+ gold_item = row.get("gold")
+ pred_item = row.get("pred")
+ left = (
+ # Pair lines already show the match; keep judge/rationale text.
+ _render_gold_item(
+ gold_item, paired=linked, show_match_notes=False, show_judge=True
+ )
+ if gold_item
+ else "
"
+ )
+ right = (
+ _render_pred_item(pred_item, paired=linked, show_rationale=True)
+ if pred_item
+ else "
"
+ )
+ link_cls = "system-pair-link" if linked else "system-pair-link is-empty"
+ body.append(
+ f"
"
+ )
+ body.append("
")
+ return "".join(body)
+
+
+def _render_tool_panel_html(panel: dict) -> str:
+ kind = panel.get("kind")
+ if kind == "retrieval":
+ queries = "".join(
+ f"{_escape(q)}" for q in (panel.get("queries") or [])
+ )
+ docs = "".join(
+ f"{_escape(d)}" for d in (panel.get("retrieved_docs") or [])
+ )
+ return (
+ ""
+ )
+ if kind == "deep":
+ queries = "".join(
+ f"{_escape(q)}" for q in (panel.get("queries") or [])
+ )
+ hits = "".join(f"{_escape(h)}" for h in (panel.get("hits") or []))
+ deep_items = []
+ for item in panel.get("deep_fetches") or []:
+ cls = "ok" if item.get("ok") else "bad"
+ deep_items.append(
+ f"{_escape(item.get('url'))}"
+ f"{_escape(item.get('outcome'))}
"
+ )
+ return (
+ ""
+ )
+ return ""
+
+
+def _ensure_claim_period(text: Any) -> str:
+ claim = str(text or "").strip()
+ if claim and claim[-1] not in ".!?":
+ claim += "."
+ return claim
+
+
+def _system_claim_header_html(case: dict) -> str:
+ """Claim + paper outside the result card (Decomposition-style claim layout)."""
+ paper_title = str(case.get("paper_title") or "").strip()
+ paper_line = (
+ f'Paper: {_escape(paper_title)}
'
+ if paper_title
+ else ""
+ )
+ return (
+ ''
+ '
'
+ '
Claim
'
+ f'
{_escape(_ensure_claim_period(case.get("claim")))}
'
+ f"{paper_line}"
+ "
"
+ "
"
+ )
+
+
+def _system_case_card_html(case: dict) -> str:
+ tone = str(case.get("outcome_tone") or "part")
+ f1 = float(case.get("f1") or 0.0)
+ recall = float(case.get("recall") or 0.0)
+ precision = float(case.get("precision") or 0.0)
+ case_key = str(case.get("case_key") or "")
+ if case_key == "featured":
+ pills = (
+ ""
+ "Unevaluated"
+ "
"
+ )
+ else:
+ pills = (
+ ""
+ f"F1 {f1:.2f}"
+ f"Recall {recall:.2f}"
+ f"Precision {precision:.2f}"
+ "
"
+ )
+ # Success: gold | predictions with pair connector lines.
+ # Failure: keep the original two-column layout (pairing UI deferred).
+ # Featured / unevaluated live runs: predictions only.
+ if case_key == "success":
+ ingredients_html = _render_paired_ingredient_grid(case)
+ elif case_key == "featured":
+ pred_col = _render_pred_column(case.get("predicted_ingredients") or [])
+ ingredients_html = f'{pred_col}
'
+ else:
+ gold_col = _render_gold_column(case.get("gold_ingredients") or [])
+ pred_col = _render_pred_column(case.get("predicted_ingredients") or [])
+ ingredients_html = (
+ f'{gold_col}{pred_col}
'
+ )
+ tool_html = _render_tool_panel_html(case.get("tool_panel") or {})
+ return (
+ f''
+ f'
{_escape(case.get("case_chip"))}
'
+ f"{pills}"
+ f"{ingredients_html}"
+ f"{tool_html}"
+ "
"
+ )
+
+
+def _system_analysis_panel_html(
+ method_id: str, case_key: str, *, tone: str = "bad"
+) -> str:
+ view = system_run_module.analysis_view_for_case(method_id, case_key)
+ blocks = view.get("blocks") or []
+ if not blocks:
+ return (
+ ""
+ "
No analysis available for this case.
"
+ "
"
+ )
+ html_parts = [
+ "",
+ ]
+ for block in blocks:
+ btype = block.get("type")
+ if btype == "diagnosis":
+ text = str(block.get("text") or "")
+ diag_cls = "system-diagnosis is-part" if tone == "part" else "system-diagnosis"
+ if block.get("strong_first") and text.startswith("Diagnosis."):
+ rest = text[len("Diagnosis.") :].lstrip()
+ html_parts.append(
+ f"
Diagnosis. {_escape(rest)}
"
+ )
+ elif block.get("strong_first"):
+ lines = text.split("\n", 1)
+ if len(lines) == 2:
+ html_parts.append(
+ f"
{_escape(lines[0])}"
+ f"
{_escape(lines[1])}
"
+ )
+ else:
+ html_parts.append(
+ f"
{_escape(text)}
"
+ )
+ else:
+ html_parts.append(f"
{_escape(text)}
")
+ elif btype == "two_col":
+ cols = []
+ for col in block.get("cols") or []:
+ items = "".join(
+ f"
{_format_analysis_bullet(x)}" for x in (col.get("items") or [])
+ )
+ cols.append(
+ "
"
+ f"
{_escape(col.get('title'))}
"
+ f"
"
+ "
"
+ )
+ html_parts.append(f"
{''.join(cols)}
")
+ elif btype == "list":
+ items = "".join(
+ f"
{_format_analysis_bullet(x)}" for x in (block.get("items") or [])
+ )
+ html_parts.append(f"
{_escape(block.get('title'))}
")
+ html_parts.append("
")
+ return "".join(html_parts)
+
+
+def _render_system_run_view():
+ method_id = str(st.session_state.get("selected_system_method") or "").strip()
+ if method_id not in system_run_module.method_ids():
+ method_id = system_run_module.DEFAULT_SYSTEM_METHOD
+ st.session_state["selected_system_method"] = method_id
+
+ available_cases = system_run_module.list_cases(method_id)
+ case_keys = {key for key, _label in available_cases}
+ case_key = str(st.session_state.get("selected_system_case") or "").strip()
+ if case_key not in case_keys:
+ case_key = system_run_module.default_case_for_method(method_id)
+ st.session_state["selected_system_case"] = case_key
+
+ mode = str(st.session_state.get("system_run_mode") or "prerun").strip()
+ if mode not in {"prerun", "live"}:
+ mode = "prerun"
+ st.session_state["system_run_mode"] = mode
+
+ st.markdown(
+ """
+
+
SciFy System Run on SciPaths Claims
+
SciFy CodeAgent
+
+ """,
+ unsafe_allow_html=True,
+ )
+
+ st.markdown('Mode
', unsafe_allow_html=True)
+ mode_cols = st.columns(2)
+ with mode_cols[0]:
+ st.button(
+ "Pre-run Cases",
+ key="system_mode::prerun",
+ type="primary" if mode == "prerun" else "secondary",
+ use_container_width=True,
+ on_click=_on_pick_system_prerun,
+ )
+ with mode_cols[1]:
+ st.button(
+ "Live Run",
+ key="system_mode::live",
+ type="primary" if mode == "live" else "secondary",
+ use_container_width=True,
+ on_click=_on_pick_system_live,
+ )
+
+ if mode == "live":
+ _render_system_live_run_view()
+ return
+
+ st.markdown('Setting
', unsafe_allow_html=True)
+ method_items = system_run_module.SYSTEM_RUN_METHODS
+ method_cols = st.columns(len(method_items))
+ for i, item in enumerate(method_items):
+ with method_cols[i]:
+ mid = item["id"]
+ st.button(
+ item["label"],
+ key=f"system_method::{mid}",
+ type="primary" if mid == method_id else "secondary",
+ use_container_width=True,
+ on_click=_on_pick_system_method,
+ args=(mid,),
+ )
+
+ st.markdown('Case
', unsafe_allow_html=True)
+ case_cols = st.columns(max(len(available_cases), 1))
+ for i, (ckey, clabel) in enumerate(available_cases):
+ with case_cols[i]:
+ st.button(
+ clabel,
+ key=f"system_case::{method_id}::{ckey}",
+ type="primary" if ckey == case_key else "secondary",
+ use_container_width=True,
+ on_click=_on_pick_system_case,
+ args=(ckey,),
+ )
+
+ try:
+ case = system_run_module.get_case(method_id, case_key)
+ except Exception as exc:
+ st.error(f"Could not load case study: {exc}")
+ return
+
+ claim_html = _system_claim_header_html(case)
+ result_html = _system_case_card_html(case)
+ analysis_html = _system_analysis_panel_html(
+ method_id, case_key, tone=str(case.get("outcome_tone") or "bad")
+ )
+ st.markdown(claim_html, unsafe_allow_html=True)
+ result_col, analysis_col = st.columns([1.25, 1], gap="large")
+ with result_col:
+ st.markdown(
+ f'{result_html}
',
+ unsafe_allow_html=True,
+ )
+ with analysis_col:
+ st.markdown(analysis_html, unsafe_allow_html=True)
+
+
+def _render_annotation_process_view(
+ *,
+ source_root: str,
+ output_root: str,
+ llm_provider: str,
+ llm_model: str,
+ llm_model_step4: str,
+ formatter_model: str,
+ judge_model: str,
+ candidate_count: int,
+):
+ if not get_secret("GEMINI_API_KEY"):
+ st.warning("GEMINI_API_KEY is missing for live runs. Demo examples still work without it.")
+
+ # Pin demo selection so post-run reruns cannot jump to another paper.
+ if not st.session_state.get("live_run_mode"):
+ demo = str(st.session_state.get("selected_demo_url") or "").strip()
+ if not demo:
+ _select_paper_1()
+ else:
+ # Re-assert the locked demo before the picker / text input mounts.
+ st.session_state["_pending_paper_input"] = demo
+
+ _apply_pending_paper_input()
+ current_paper = st.session_state.get("paper_input", "")
+ selected_demo = str(st.session_state.get("selected_demo_url") or "").strip()
+ selected_demo_id = replay_module.parse_arxiv_id(selected_demo)
+
+ st.markdown(
+ """
+
+
Annotation Process
+
Forecasting pathways to scientific discovery
+
+ """,
+ unsafe_allow_html=True,
+ )
+
+ live_run_mode = bool(st.session_state.get("live_run_mode"))
+ st.markdown('Paper
', unsafe_allow_html=True)
+ example_items = list(EXAMPLES.items())
+ picker_count = len(example_items) + 1 # demos + Live run
+ cols = st.columns(picker_count)
+ for i, (button_label, value) in enumerate(example_items):
+ with cols[i]:
+ example_id = replay_module.parse_arxiv_id(value)
+ is_selected = (not live_run_mode) and bool(
+ selected_demo_id and example_id == selected_demo_id
+ )
+ st.button(
+ button_label,
+ key=f"example::{button_label}",
+ type="primary" if is_selected else "secondary",
+ use_container_width=True,
+ on_click=_on_pick_demo,
+ args=(value,),
+ )
+ with cols[-1]:
+ st.button(
+ "Live Run",
+ key="example::live_run",
+ icon=":material/edit_note:",
+ type="primary" if live_run_mode else "secondary",
+ use_container_width=True,
+ on_click=_on_pick_live_run,
+ )
+
+ tabs = st.tabs(TAB_NAMES)
+
+ with tabs[0]:
+ if live_run_mode:
+ st.caption("Enter any arXiv paper and run the live pipeline.")
+ paper_input = st.text_input(
+ "arXiv URL or ID",
+ key="paper_input",
+ placeholder="https://arxiv.org/abs/2311.14919",
+ )
+ else:
+ paper_input = selected_demo or current_paper
+ if not paper_input.strip():
+ st.caption("Select a demo paper above, or choose Live Run to enter a custom arXiv ID.")
+
+ paper_result = _paper_result_for_input(paper_input) if paper_input.strip() else None
+ status = (paper_result or {}).get("run_status") or "Idle"
+ events = list((paper_result or {}).get("run_events") or [])
+ work_panel = None
+ graph_slot = None
+ courier_slot = None
+ run_clicked = False
+
+ if paper_input.strip():
+ left_col, right_col = st.columns([1.55, 1], gap="large")
+ with left_col:
+ _render_paper_preview(paper_input)
+ with right_col:
+ work_panel = st.empty()
+ _render_work_panel(work_panel, events, status=status)
+ run_clicked = st.button(
+ "Run pipeline",
+ type="primary",
+ use_container_width=True,
+ key="run_pipeline_btn",
+ )
+
+ # Persistent graph canvas (mounted once) + invisible courier that
+ # streams incremental model updates into it without re-emitting it.
+ graph_slot = st.empty()
+ courier_slot = st.empty()
+ _mount_graph_shell(graph_slot)
+ _push_graph(courier_slot, paper_input, events)
+
+ if paper_result and paper_result.get("pipeline_failed_reason"):
+ st.error(paper_result["pipeline_failed_reason"])
+ if paper_result and paper_result.get("annotation_skipped_reason"):
+ st.warning(paper_result["annotation_skipped_reason"])
+ if (
+ paper_result
+ and paper_result.get("pipeline_stopped_reason")
+ and not paper_result.get("pipeline_failed_reason")
+ ):
+ st.warning(paper_result["pipeline_stopped_reason"])
+
+ # Stable slot so only metric values change when a run finishes (not a full remount flash).
+ metrics_slot = st.empty()
+ paper_dir, discovery, contributions, payload = _load_result_bundle(paper_input)
+ _render_overview(payload, discovery, placeholder=metrics_slot)
+
+ if run_clicked:
+ if not paper_input.strip():
+ _clear_run_state()
+ st.session_state["pipeline_failed_reason"] = "Paper input is required."
+ st.session_state["run_status"] = "Failed"
+ st.rerun()
+
+ # Demo papers use saved traces; Live run always executes the live pipeline.
+ used_trace = (
+ False
+ if live_run_mode
+ else run_replay_stream(
+ paper_input,
+ work_panel=work_panel,
+ metrics_slot=metrics_slot,
+ graph_slot=graph_slot,
+ courier_slot=courier_slot,
+ )
+ )
+ if not used_trace:
+ _clear_run_state()
+ st.session_state["run_status"] = "Starting"
+ st.session_state["replay_mode"] = False
+ run_pipeline_stream(
+ paper_input=paper_input,
+ source_root=source_root,
+ output_root=output_root,
+ llm_provider=llm_provider,
+ llm_model=llm_model,
+ llm_model_step4=llm_model_step4,
+ formatter_model=formatter_model,
+ judge_model=judge_model,
+ candidate_count=candidate_count,
+ work_panel=work_panel,
+ metrics_slot=metrics_slot,
+ graph_slot=graph_slot,
+ courier_slot=courier_slot,
+ )
+ # Keep the same demo selected after finish; do not advance to Paper 2.
+ if not live_run_mode and paper_input.strip():
+ _set_demo_paper(paper_input.strip())
+ st.rerun()
+
+ with tabs[1]:
+ paper_dir, discovery, contributions, payload = _load_result_bundle(
+ st.session_state.get("paper_input", "")
+ )
+ _render_clusters_tab(discovery, contributions)
+
+ with tabs[2]:
+ paper_dir, discovery, contributions, payload = _load_result_bundle(
+ st.session_state.get("paper_input", "")
+ )
+ _render_claims_tab(payload)
+
+
+def main():
+ llm_provider = os.getenv("LLM_PROVIDER", "gemini")
+ llm_model = os.getenv("LLM_MODEL", "gemini-3.1-pro-preview")
+ llm_model_step4 = os.getenv("LLM_MODEL_STEP4", "gemini-3-flash-preview")
+ formatter_model = os.getenv("ANNOTATION_FORMATTER_MODEL", "gemini/gemini-3.1-pro-preview")
+ judge_model = os.getenv("ANNOTATION_JUDGE_MODEL", "gemini/gemini-3.1-pro-preview")
+ candidate_count = int(os.getenv("ANNOTATION_CANDIDATE_COUNT", "3"))
+ source_root = DEFAULT_SOURCE_ROOT
+ output_root = DEFAULT_OUTPUT_ROOT
+
+ st.set_page_config(
+ page_title="SciPaths",
+ page_icon="🔬",
+ layout="wide",
+ initial_sidebar_state="collapsed",
+ )
+ st.markdown(CUSTOM_CSS, unsafe_allow_html=True)
+ _ensure_state()
+
+ if st.button("Reset session", type="tertiary", key="reset_session_btn"):
+ _reset_all_session_state()
+ st.rerun()
+
+ nav_col, main_col = st.columns([1, 5.5], gap="medium")
+
+ with nav_col:
+ current = st.session_state.get("app_view", DEFAULT_APP_VIEW)
+ if current not in APP_VIEWS:
+ current = DEFAULT_APP_VIEW
+ st.session_state["app_view"] = current
+
+ st.markdown(
+ """
+
+
SciPaths Demo
+
Workspace
+
+ """,
+ unsafe_allow_html=True,
+ )
+ for item in APP_NAV:
+ view = item["view"]
+ icon = item.get("icon") or ""
+ is_active = view == current
+ if st.button(
+ view,
+ key=f"nav::{view}",
+ icon=icon,
+ type="primary" if is_active else "secondary",
+ use_container_width=True,
+ ):
+ if view != current:
+ st.session_state["app_view"] = view
+ if view == "Annotation Process":
+ _select_paper_1()
+ st.rerun()
+
+ selected = st.session_state.get("app_view", DEFAULT_APP_VIEW)
+
+ with main_col:
+ if selected == "System Run":
+ _render_system_run_view()
+ else:
+ _render_annotation_process_view(
+ source_root=source_root,
+ output_root=output_root,
+ llm_provider=llm_provider,
+ llm_model=llm_model,
+ llm_model_step4=llm_model_step4,
+ formatter_model=formatter_model,
+ judge_model=judge_model,
+ candidate_count=candidate_count,
+ )
+
+if __name__ == "__main__":
+ main()