"""Evolving SciPaths workflow graph (Neo4j-backed). Visual formatting follows Mina Brain's node chrome (filled circle + white inner stroke + colored ring, 25-char labels, write-pulse rings, #fafbfd stage, click detail card) but lays nodes out as a strict top-down n-ary tree: Target → Citing → Theme → Claim → Ingredient → Prior study. """ from __future__ import annotations import json import re from pathlib import Path from typing import Any, Optional import streamlit as st try: import neo4j_workflow as neo4j_store except Exception: # pragma: no cover neo4j_store = None # type: ignore # Short labels shared by Steps panel + workflow graph captions. STEP_COPY: dict[int, str] = { 1: "Load the paper", 2: "Find where it’s cited", 3: "Collect citation contexts", 4: "Classify how it’s used", 5: "Keep real reuse (uses / extends)", 6: "Pull the citing passages", 7: "Group similar reuse themes and summarize target contributions", 8: "Find enabling contributions and corresponding prior studies", } WORK_PANEL_STEPS: list[tuple[int, str]] = [(n, STEP_COPY[n]) for n in range(1, 9)] CANVAS_LABEL_MAX = 25 # Mina Brain NODE_COLORS mapped onto SciPaths kinds (fill / ring / text). KIND_META = { "target": { "fill": "#4F6EF7", "ring": "#A5B4FC", "text": "#1E3A8A", "r": 22, "type_label": "Target paper", "color": "#4F6EF7", "size": 22, }, "citing": { "fill": "#60A5FA", "ring": "#BFDBFE", "text": "#1D4ED8", "r": 14, "type_label": "Citing paper", "color": "#60A5FA", "size": 14, }, "cluster": { "fill": "#8B5CF6", "ring": "#DDD6FE", "text": "#5B21B6", "r": 16, "type_label": "Reuse theme", "color": "#8B5CF6", "size": 16, }, "claim": { "fill": "#FBBF24", "ring": "#FDE68A", "text": "#B45309", "r": 15, "type_label": "Target contribution", "color": "#FBBF24", "size": 15, }, "ingredient": { "fill": "#94A3B8", "ring": "#E2E8F0", "text": "#475569", "r": 12, "type_label": "Enabling contribution", "color": "#94A3B8", "size": 12, }, "study": { "fill": "#34D399", "ring": "#A7F3D0", "text": "#047857", "r": 13, "type_label": "Prior study", "color": "#34D399", "size": 13, }, } # Category order (top-down tree levels) + palette the canvas shell / rail consume. _KIND_ORDER = ["target", "citing", "cluster", "claim", "ingredient", "study"] _KIND_META_JS = { k: {"fill": v["fill"], "ring": v["ring"], "text": v["text"], "label": v["type_label"]} for k, v in KIND_META.items() } def _load_json(path: Path) -> Any | None: if not path.exists(): return None try: return json.loads(path.read_text(encoding="utf-8")) except Exception: return None def _clean(text: str) -> str: return re.sub(r"\s+", " ", (text or "").strip()) def canvas_label(text: str, *, limit: int = CANVAS_LABEL_MAX) -> str: cleaned = _clean(text) if len(cleaned) <= limit: return cleaned return cleaned[:limit].rstrip() + "…" def visible_step_from_events(events: list[str]) -> int: started = 0 for raw in events or []: text = str(raw) m = re.search(r"Step\s+(\d+)\s*(?:/|\s+complete)", text, re.IGNORECASE) if m: started = max(started, int(m.group(1))) continue lower = text.lower() if "[annotation]" in lower or "annotate" in lower: started = max(started, 8) return min(8, started) def pulse_step_from_events(events: list[str]) -> int: started: set[int] = set() completed: set[int] = set() for raw in events or []: text = str(raw) 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 lower = text.lower() if "[annotation]" in lower or "annotate" in lower: if "complete" in lower or "skipped" in lower: completed.add(8) else: started.add(8) active = [n for n in sorted(started) if n not in completed] return active[-1] if active else 0 def _paper_title_from_dir(paper_dir: Optional[Path]) -> str: if not paper_dir: return "Target paper" data = _load_json(paper_dir / "paper_metadata.json") record = None if isinstance(data, list) and data and isinstance(data[0], dict): record = data[0] elif isinstance(data, dict): record = data return _clean(str((record or {}).get("title") or "")) or "Target paper" def _paper_abstract_from_dir(paper_dir: Optional[Path]) -> str: if not paper_dir: return "" data = _load_json(paper_dir / "paper_metadata.json") record = None if isinstance(data, list) and data and isinstance(data[0], dict): record = data[0] elif isinstance(data, dict): record = data abstract = _clean(str((record or {}).get("abstract") or "")) abstract = re.sub(r"^(abstract)\s*[:.]?\s*", "", abstract, flags=re.IGNORECASE) abstract = re.sub(r"(?<=[.!?\)\]\"'”’])\d+$", "", abstract) return abstract def _node( *, nid: str, kind: str, short: str, title: str, detail: str, step_added: int, pulse: bool, ) -> dict[str, Any]: meta = KIND_META.get(kind, KIND_META["citing"]) return { "id": nid, "kind": kind, "label": canvas_label(short), "title": _clean(title) or short, "detail": _clean(detail), "step_added": step_added, "pulse": pulse, "color": meta["fill"], "fill": meta["fill"], "ring": meta["ring"], "text": meta["text"], "r": meta["r"], "size": meta["r"], "type_label": meta["type_label"], } def _collect_citing(paper_dir: Optional[Path], limit: int = 6) -> list[dict[str, str]]: if not paper_dir: return [] out: list[dict[str, str]] = [] seen: set[str] = set() contrib = _load_json(paper_dir / "usage_contributions.json") or {} items = contrib.get("contributions") if isinstance(contrib, dict) else None if isinstance(items, list): for item in items: if not isinstance(item, dict): continue cid = str(item.get("citing_paper_id") or "").strip() title = _clean(str(item.get("citing_title") or "Citing paper")) if not cid or cid in seen: continue seen.add(cid) out.append( { "id": f"citing:{cid}", "title": title, "detail": _clean( str(item.get("paper_claim") or item.get("claim") or item.get("evidence_span") or "") ), "label_tag": item.get("label") or "", } ) if len(out) >= limit: return out return out def _collect_clusters(paper_dir: Optional[Path], limit: int = 4) -> list[dict[str, str]]: if not paper_dir: return [] discovery = _load_json(paper_dir / "usage_discovery_from_contributions.json") or {} clusters = discovery.get("clusters") if isinstance(discovery, dict) else None out: list[dict[str, str]] = [] if not isinstance(clusters, list): return out for item in clusters[:limit]: if not isinstance(item, dict): continue cid = str(item.get("cluster_id") or f"C{len(out) + 1}") title = _clean( str(item.get("representative_claim") or item.get("cluster_title") or f"Theme {cid}") ) out.append( { "id": f"cluster:{cid}", "cluster_id": cid, "title": title, "detail": _clean(str(item.get("merge_rationale") or f"{item.get('count', '')} contribution instances")), } ) return out def _collect_annotation(payload: Optional[dict]) -> tuple[list[dict], list[dict], list[dict]]: claims: list[dict] = [] ingredients: list[dict] = [] studies: list[dict] = [] if not isinstance(payload, dict): return claims, ingredients, studies for claim in (payload.get("claims") or [])[:4]: if not isinstance(claim, dict): continue claim_id = str(claim.get("claim_id") or f"C{len(claims) + 1}") title = _clean(str(claim.get("rewritten_claim") or claim.get("text") or claim_id)) claims.append( { "id": f"claim:{claim_id}", "claim_id": claim_id, "cluster_id": str(claim.get("cluster_id") or ""), "title": title, "detail": _clean(str(claim.get("decision") or "")), } ) for ing in (claim.get("ingredients") or [])[:3]: if not isinstance(ing, dict): continue iid = str(ing.get("ingredient_id") or f"{claim_id}.I{len(ingredients) + 1}") ann = ing.get("canonical_annotation") if isinstance(ing.get("canonical_annotation"), dict) else {} ingredients.append( { "id": f"ing:{iid}", "ingredient_id": iid, "claim_id": claim_id, "title": _clean(str(ing.get("ingredient") or iid)), "detail": _clean( str((ann or {}).get("contribution") or (ann or {}).get("rationale") or "") ), "role": _clean(str((ann or {}).get("role") or "")), } ) g = ing.get("canonical_grounding") if isinstance(ing.get("canonical_grounding"), dict) else None if g: sid = str(g.get("paper_id") or g.get("ref_id") or g.get("ref_title") or iid) studies.append( { "id": f"study:{sid}", "ingredient_id": iid, "title": _clean(str(g.get("ref_title") or g.get("ref_id") or "Prior study")), "detail": _clean(str(g.get("ref_authors") or g.get("ref_year") or "")), } ) seen: set[str] = set() uniq: list[dict] = [] for s in studies: if s["id"] in seen: continue seen.add(s["id"]) uniq.append(s) return claims, ingredients, uniq[:6] def build_graph_model( *, paper_dir: Optional[Path], payload: Optional[dict], visible_step: int, pulse_step: int = 0, caption: str = "", ) -> dict[str, Any]: step = max(0, min(8, int(visible_step or 0))) nodes: list[dict[str, Any]] = [] edges: list[dict[str, Any]] = [] if step >= 1: title = _paper_title_from_dir(paper_dir) abstract = _paper_abstract_from_dir(paper_dir) nodes.append( _node( nid="target", kind="target", short="Target", title=title, detail=abstract[:500], step_added=1, pulse=pulse_step == 1, ) ) citing = _collect_citing(paper_dir) if step >= 2 else [] if step >= 2: rows = citing or [ {"id": f"citing:placeholder:{i}", "title": f"Citing paper {i+1}", "detail": "Waiting for citation artifacts…", "label_tag": ""} for i in range(3) ] for i, item in enumerate(rows): nodes.append( _node( nid=item["id"], kind="citing", short=f"Citing {i + 1}", title=item["title"], detail=item.get("detail") or item.get("label_tag") or "", step_added=2, pulse=pulse_step in {2, 3, 4, 5, 6}, ) ) edges.append( { "id": f"e-target-{item['id']}", "source": "target", "target": item["id"], "kind": "cite", "muted": step < 5, "pulse": pulse_step in {2, 3, 4, 5}, "step_added": 2, } ) # Step 7: reuse themes + target contributions arrive together. clusters = _collect_clusters(paper_dir) if step >= 7 else [] claims, ingredients, studies = ( _collect_annotation(payload) if step >= 7 else ([], [], []) ) if step >= 7 and clusters: for item in clusters: cid = item.get("cluster_id") or "?" nodes.append( _node( nid=item["id"], kind="cluster", short=f"Theme {cid}", title=item["title"], detail=item.get("detail") or "", step_added=7, pulse=pulse_step == 7, ) ) edges.append( { "id": f"e-cluster-{item['id']}", "source": "target", "target": item["id"], "kind": "theme", "pulse": pulse_step == 7, "muted": False, "step_added": 7, } ) if step >= 7: for item in claims: cid = item.get("claim_id") or "?" nodes.append( _node( nid=item["id"], kind="claim", short=f"Claim {cid}", title=item["title"], detail=item.get("detail") or "", step_added=7, pulse=pulse_step == 7, ) ) src = f"cluster:{item.get('cluster_id')}" if item.get("cluster_id") else "target" if not any(n["id"] == src for n in nodes): src = "target" edges.append( { "id": f"e-claim-{item['id']}", "source": src, "target": item["id"], "kind": "derive", "pulse": pulse_step == 7, "muted": False, "step_added": 7, } ) # Step 8: enabling contributions + prior studies. if step >= 8: for i, item in enumerate(ingredients): nodes.append( _node( nid=item["id"], kind="ingredient", short=f"Enable {i + 1}", title=item["title"], detail=" · ".join(x for x in [item.get("role") or "", item.get("detail") or ""] if x), step_added=8, pulse=pulse_step == 8, ) ) src = f"claim:{item.get('claim_id')}" if any(n["id"] == src for n in nodes): edges.append( { "id": f"e-ing-{item['id']}", "source": src, "target": item["id"], "kind": "enable", "pulse": pulse_step == 8, "muted": False, "step_added": 8, } ) for i, item in enumerate(studies): nodes.append( _node( nid=item["id"], kind="study", short=f"Prior {i + 1}", title=item["title"], detail=item.get("detail") or "", step_added=8, pulse=pulse_step == 8, ) ) src = f"ing:{item.get('ingredient_id')}" if any(n["id"] == src for n in nodes): edges.append( { "id": f"e-study-{item['id']}", "source": src, "target": item["id"], "kind": "ground", "pulse": pulse_step == 8, "muted": False, "step_added": 8, } ) if step <= 0: phase_caption = "Waiting to run" elif pulse_step: phase_caption = STEP_COPY.get(pulse_step, "") elif step >= 8: phase_caption = "Workflow complete" else: phase_caption = caption or STEP_COPY.get(step, "") return { "step": step, "pulse_step": pulse_step, "caption": phase_caption, "nodes": nodes, "edges": edges, "backend": "memory", } def _nodes_edges_payload( nodes_in: list[dict[str, Any]], edges_in: list[dict[str, Any]] ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: """Serialize nodes/edges for the canvas shell.""" nodes_js: list[dict[str, Any]] = [] for n in nodes_in: kind = str(n.get("kind") or "citing") meta = KIND_META.get(kind, KIND_META["citing"]) nodes_js.append( { "id": n["id"], "label": n.get("label") or "", "kind": kind, "fill": n.get("fill") or meta["fill"], "ring": n.get("ring") or meta["ring"], "text": n.get("text") or meta["text"], "r": float(n.get("r") or meta["r"]), "pulse": bool(n.get("pulse")), "fullTitle": n.get("title") or "", "detail": n.get("detail") or "", "typeLabel": n.get("type_label") or meta["type_label"], } ) edges_js: list[dict[str, Any]] = [] for e in edges_in: edges_js.append( { "id": e.get("id"), "source": e.get("source"), "target": e.get("target"), "pulse": bool(e.get("pulse")), "muted": bool(e.get("muted")), } ) return nodes_js, edges_js def _graph_payload(model: dict[str, Any]) -> dict[str, Any]: """Serialize a graph model into the payload the canvas shell consumes.""" nodes_js, edges_js = _nodes_edges_payload( list(model.get("nodes") or []), list(model.get("edges") or []) ) out: dict[str, Any] = { "nodes": nodes_js, "edges": edges_js, "kindMeta": _KIND_META_JS, "kindOrder": _KIND_ORDER, "frameKey": str(model.get("frame_key") or ""), } frame = model.get("frame") if isinstance(frame, dict) and frame.get("nodes"): f_nodes, f_edges = _nodes_edges_payload( list(frame.get("nodes") or []), list(frame.get("edges") or []) ) out["frame"] = {"nodes": f_nodes, "edges": f_edges} return out def courier_html(payload: dict[str, Any]) -> str: """Tiny invisible frame that posts a graph model into the persistent shell. Runs in its own Streamlit component iframe, reaches the parent document, and postMessages the model to the graph iframe — so the graph updates in place instead of the whole component being re-emitted (which reloads it). """ model_str = json.dumps(payload) embed = json.dumps(model_str).replace("", "<\\/") return ( "
" "" ) # Bump when canvas JS changes so Streamlit remounts the component iframe. _GRAPH_SHELL_VERSION = "tree-v4" def graph_shell_html(height: int = 520) -> str: """Model-free canvas shell. Data arrives via postMessage (incremental). HTML includes ``_GRAPH_SHELL_VERSION`` so layout/code updates remount the iframe. The graph is populated / grown by ``courier_html`` messages. """ payload = json.dumps( {"nodes": [], "edges": [], "kindMeta": _KIND_META_JS, "kindOrder": _KIND_ORDER} ) row_h = max(300, height - 30) ver = _GRAPH_SHELL_VERSION return f"""