From 388dbbbe2a42d249cd21104e9ffa5bb53cf118c9 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Fri, 16 Oct 2026 16:48:05 +0800 Subject: [PATCH 1/2] feat(research): time-anchored Explore history lens and evidence sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacks on #6616 instead of replacing its reader: the full-history pagination, typed read model, relationship graph, selection and replay invariants remain. The new default lens projects the same events on a record-time axis so long runs answer what the late phase produced and why direction changed. 前端默认"时间线"镜头,保留"关系图"切换;三等分卡片展示各段新问题、 来源结论标签与显式转向/继续;密度柱暴露记录空档;路线只由显式边与 父子分组相连,未关联问题单独成蜂群,时间相邻不产生边。finding 详情 按 writeback 拥有者的 Input revision/Applicability/Observation/ Interpretation 分段,并解析 path_delta 的 Outcome 及 retained/changed/stopped 列表,回答"为什么转向、为什么停止"。 非结构化结论保持原始散文,并明确提示无法拆出决策。 - Shared typed presentation module owns section parsing, thirds, routes and timeline bins; no new protocol vocabulary, graph authority or progress score. - Phase thirds are reading groups only; record time is never attempt time and marker position never encodes progress, cause, checking or adoption. - Detail selection, URL focus, keyboard Escape, refresh behavior and the old graph lens work from both lenses; 390px collapses phases and trail to one column with zero horizontal overflow (EdgeBench fixture verified). - Validated with the redacted EdgeBench capture (9.6d/364 events, no structured decisions) and a real local 2.9d/483-event game research Goal (51 path_delta decisions), through the packaged Chat over the real HTTP owner. Typecheck, CSS token check, control-plane tests (6), Python history test and packaged build pass. Signed-off-by: huangruiteng --- .../goal-research-history.css | 80 +++++++++ .../goal-research-history.tsx | 35 +++- .../goal-research-timeline.tsx | 162 ++++++++++++++++++ .../presentation/explore_timeline.ts | 116 +++++++++++++ .../control_plane_ts/explore_history.test.ts | 27 +++ 5 files changed, 417 insertions(+), 3 deletions(-) create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/goal-research-timeline.tsx create mode 100644 loopx/control_plane/presentation/explore_timeline.ts diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.css index 392c4009c2..6370020f58 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.css @@ -46,3 +46,83 @@ .research-history-all-records summary { cursor: pointer; padding-block: 6px; } .research-history-all-records > .research-history-records { max-height: 400px; overflow: auto; } .research-node-count { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +/* Lens switch and timeline reading (record time only; position never encodes progress). */ +.research-lens { display: inline-flex; border: 1px solid var(--pw-line); border-radius: 6px; overflow: hidden; } +.research-history-controls .research-lens button { border: 0; border-radius: 0; min-height: 34px; } +.research-history-controls .research-lens button[aria-pressed="true"] { background: var(--pw-text); color: var(--pw-bg); } +.research-history-canvas.is-timeline { max-height: none; overflow: visible; background: var(--pw-bg); padding: 16px; } +.research-timeline { display: grid; gap: 12px; min-width: 0; } +.research-phases { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; } +.goal-overview .research-phase { display: flex; flex-direction: column; align-items: stretch; gap: 8px; min-width: 0; padding: 14px; border: 1px solid var(--pw-line); border-radius: 10px; background: var(--pw-bg); color: var(--pw-text); font: inherit; text-align: left; cursor: pointer; } +.goal-overview .research-phase:hover { border-color: var(--pw-line-strong); } +.goal-overview .research-phase.is-late { background: var(--pw-blue-soft); border-color: var(--pw-blue); } +.goal-overview .research-phase.is-active { outline: 2px solid var(--pw-text); outline-offset: -1px; } +.research-phase-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; font-size: var(--text-body); } +.research-phase-head time { color: var(--pw-muted); font-size: var(--text-caption); white-space: nowrap; } +.research-phase-counts { display: flex; align-items: baseline; gap: 4px; font-size: var(--text-caption); color: var(--pw-muted); } +.research-phase-counts b { font-size: var(--text-heading); line-height: var(--leading-heading); color: var(--pw-text); font-weight: 600; } +.research-phase-counts b + b, .research-phase-counts b:not(:first-child) { margin-left: 12px; } +.research-phase-bar { display: flex; gap: 2px; height: 18px; border-radius: 4px; overflow: hidden; background: var(--pw-track); } +.research-phase-bar span { display: flex; align-items: center; min-width: 4px; padding-inline: 6px; overflow: hidden; color: var(--pw-bg); font-size: var(--text-caption); font-weight: 600; white-space: nowrap; } +.research-phase-decisions { font-size: var(--text-caption); color: var(--pw-text); } +.research-phase-line { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; font-size: var(--text-caption); color: var(--pw-muted); line-height: var(--leading-caption); } +.research-phase-line em, .research-trail-list em, .research-finding-sections dt { font-style: normal; font-weight: 600; color: var(--pw-text); margin-right: 6px; } +.is-confirmed { --research-tone: var(--pw-green); } +.is-refuted { --research-tone: var(--pw-red); } +.is-tentative { --research-tone: var(--pw-amber); } +.is-unlabeled { --research-tone: var(--pw-faint); } +.research-phase-bar .is-confirmed, .research-phase-bar .is-refuted, .research-phase-bar .is-tentative { background: var(--research-tone); } +.research-timeline-grid { display: grid; grid-template-columns: 180px minmax(0, 1fr); column-gap: 16px; row-gap: 10px; align-items: start; } +.research-row-label { display: flex; flex-direction: column; gap: 2px; min-width: 0; font-size: var(--text-caption); } +.research-row-label strong { font-size: var(--text-body); font-weight: 600; } +.research-row-label small { color: var(--pw-muted); font-size: var(--text-caption); display: flex; align-items: center; gap: 4px; flex-wrap: wrap; } +.research-row-heading { grid-column: 1 / -1; display: flex; align-items: baseline; gap: 10px; margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--pw-line); } +.research-row-heading strong { font-size: var(--text-body); font-weight: 600; } +.research-row-heading small { color: var(--pw-muted); font-size: var(--text-caption); } +.research-row-plot { position: relative; min-width: 0; } +.research-density { display: flex; align-items: flex-end; gap: 2px; height: 56px; } +.research-density span { flex: 1; min-width: 1px; border-radius: 2px 2px 0 0; background: var(--pw-blue-soft); border-top: 2px solid var(--pw-blue); } +.research-density span.is-gap { background: var(--pw-red-bg); border-top: 0; } +.research-decision-ticks { position: relative; height: 12px; margin-top: 4px; } +.research-decision-ticks span { position: absolute; top: 0; width: 3px; height: 10px; margin-left: -1px; border-radius: 1px; background: var(--pw-blue); } +.research-decision-ticks span.is-replan, .research-tick-key.is-replan { background: var(--pw-orbit); } +.research-tick-key { display: inline-block; width: 3px; height: 10px; border-radius: 1px; background: var(--pw-blue); } +.research-phase-divider { position: absolute; top: -4px; height: 76px; border-left: 1px dashed var(--pw-faint); pointer-events: none; } +.research-axis { position: relative; height: 18px; margin-top: 2px; border-top: 1px solid var(--pw-line); } +.research-axis span { position: absolute; top: 2px; transform: translateX(-50%); color: var(--pw-muted); font-size: var(--text-caption); white-space: nowrap; } +.research-route { display: contents; } +.research-route-caption { display: flex; align-items: baseline; gap: 6px; margin: 0 0 2px; min-width: 0; font-size: var(--text-caption); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.research-route-caption span { flex-shrink: 0; color: var(--pw-muted); } +.research-route-caption b { font-weight: 500; color: var(--research-tone); overflow: hidden; text-overflow: ellipsis; } +.research-route-track { position: relative; height: 44px; } +.research-route-track svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; } +.research-route-line { stroke: var(--pw-line-strong); stroke-width: 2; vector-effect: non-scaling-stroke; } +.goal-overview .research-timeline-marker { position: absolute; top: 25px; width: 14px; height: 14px; margin-left: -7px; padding: 0; border-radius: 50%; border: 2px solid var(--research-tone); background: var(--research-tone); cursor: pointer; } +.goal-overview .research-timeline-marker.is-open { background: var(--pw-bg); } +.goal-overview .research-timeline-marker.is-small { top: auto; width: 9px; height: 9px; margin-left: -4px; border-width: 1px; opacity: .85; } +.goal-overview .research-timeline-marker.is-selected { box-shadow: 0 0 0 3px var(--pw-bg), 0 0 0 5px var(--pw-text); opacity: 1; z-index: 1; } +.goal-overview .research-timeline-marker.is-dimmed, .research-decision-ticks .is-dimmed { opacity: .2; } +.goal-overview .research-timeline-marker:hover { transform: scale(1.25); } +.research-swarm { position: relative; border-bottom: 1px solid var(--pw-line); } +.research-trail { border-top: 1px solid var(--pw-line); padding-top: 12px; } +.research-trail h3 { display: flex; align-items: baseline; gap: 10px; margin: 0 0 10px; font-size: var(--text-body); font-weight: 600; } +.research-trail h3 small { color: var(--pw-muted); font-size: var(--text-caption); font-weight: 400; } +.research-trail-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin: 0; padding: 0; list-style: none; } +.goal-overview .research-trail-list button { display: flex; flex-direction: column; align-items: stretch; gap: 4px; width: 100%; height: 100%; padding: 12px; border: 1px solid var(--pw-line); border-radius: 10px; background: var(--pw-bg); color: var(--pw-text); font: inherit; font-size: var(--text-caption); line-height: var(--leading-caption); text-align: left; cursor: pointer; } +.goal-overview .research-trail-list button:hover { border-color: var(--pw-line-strong); } +.goal-overview .research-trail-list button.is-selected { border-color: var(--pw-text); outline: 1px solid var(--pw-text); } +.research-trail-list strong { font-size: var(--text-body); line-height: var(--leading-body); font-weight: 600; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } +.research-trail-list span:not(.research-trail-meta) { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } +.research-trail-list .is-muted, .research-finding-sections .is-muted { color: var(--pw-muted); } +.research-trail-meta { display: flex; align-items: center; gap: 8px; color: var(--pw-muted); } +.research-outcome { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: var(--text-caption); font-weight: 600; background: var(--pw-blue-soft); color: var(--pw-blue-ink); } +.research-outcome.is-replan { background: var(--pw-tone); color: var(--pw-orbit); } +.research-outcome.is-stop, .research-outcome.is-ask_human { background: var(--pw-amber-bg); color: var(--pw-text); } +.research-link { padding: 0; border: 0; background: none; color: var(--pw-blue-ink); font: inherit; text-decoration: underline; cursor: pointer; } +.research-trail > .research-link { margin-top: 8px; font-size: var(--text-caption); } +.research-finding-sections { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 4px 10px; margin: 8px 0; font-size: var(--text-caption); line-height: var(--leading-caption); } +.research-finding-sections dd { margin: 0; min-width: 0; } +.research-finding-sections ul { margin: 0; padding-left: 16px; } +@media (max-width: 900px) { .research-phases, .research-trail-list { grid-template-columns: 1fr; } .research-timeline-grid { grid-template-columns: minmax(0, 1fr); } .research-row-label { flex-direction: row; flex-wrap: wrap; gap: 8px; } .research-row-plot { margin-bottom: 8px; } } +@media (prefers-reduced-motion: reduce) { .goal-overview .research-timeline-marker:hover { transform: none; } } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx index fc15690b3f..52956339f6 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx @@ -1,10 +1,13 @@ import {useEffect, useId, useMemo, useRef, useState} from "react"; import {ArrowLeft, Circle, FlaskConical, GitBranch, RefreshCw, Sparkles} from "lucide-react"; +import {buildExploreTimeline, parseExploreFindingSections} from "../../../../../../loopx/control_plane/presentation/explore_timeline.js"; +import {GoalResearchTimeline} from "./goal-research-timeline"; import {requestJson} from "../../data/chat"; import {groupUnlinkedHistoryQuestions, layoutExploreHistory, parseExploreHistoryPage, projectExploreHistory, type ExploreHistoryEvent} from "../../../../../../loopx/control_plane/presentation/explore_history.js"; import "./goal-research-history.css"; type Selection = {node: string; day: string | null}; +type Lens = "timeline" | "graph"; type Read = {events: ExploreHistoryEvent[]; total: number | null; complete: boolean}; const selectedFromUrl = (goal: string): Selection => { const query = new URLSearchParams(window.location.search); @@ -22,6 +25,8 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0}: { const [selection, setSelection] = useState(() => selectedFromUrl(goalId)); const [recordsOpen, setRecordsOpen] = useState(false); const [recordLimit, setRecordLimit] = useState(50); + const [lens, setLens] = useState(() => new URLSearchParams(window.location.search).get("research_lens") === "graph" ? "graph" : "timeline"); + const [phase, setPhase] = useState(null); const presentationId = useId().replace(/:/g, ""); const detail = useRef(null); const graph = useRef(null); @@ -54,7 +59,24 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0}: { const timeLabel = (value: string | null) => value && Number.isFinite(Date.parse(value)) ? new Intl.DateTimeFormat(zh ? "zh-CN" : "en", {month: "short", day: "numeric", hour: "2-digit", minute: "2-digit"}).format(new Date(value)) : copy.unknown; - const findingElement = (finding: ExploreHistoryEvent) =>
{finding.title}{zh ? "来源标记" : "Source label"} · {stateLabel(finding.status)}{finding.confidence !== null ? ` · ${zh ? "来源自报置信度" : "Source-reported confidence"} ${finding.confidence}` : ""}

{finding.summary}

{finding.evidence_refs.map(ref => {ref})}
; + const sectionCopy = zh ? {inputRevision: "输入版本", applicability: "适用范围", observation: "观察", interpretation: "解读", changed: "变化", stopped: "停止", retained: "保留"} + : {inputRevision: "Input revision", applicability: "Applicability", observation: "Observation", interpretation: "Interpretation", changed: "Changed", stopped: "Stopped", retained: "Retained"}; + const outcomeCopy: Record = zh ? {replan: "转向", continue: "继续", stop: "停止", wait: "等待", no_change: "无变化", ask_human: "请人决定"} : {replan: "Replan", continue: "Continue", stop: "Stop", wait: "Wait", no_change: "No change", ask_human: "Ask human"}; + const findingBody = (finding: ExploreHistoryEvent) => { + const sections = parseExploreFindingSections(finding.summary); + if (!sections) return

{finding.summary}

; + const decision = sections.decision; + return
+
{sectionCopy.observation}
{sections.observation}
+ {decision ? <> +
{zh ? "决策" : "Decision"}
{outcomeCopy[decision.outcome] ?? decision.outcome}
+ {(["changed", "stopped", "retained"] as const).map(key => decision[key].length ? <>
{sectionCopy[key]}
    {decision[key].map(item =>
  • {item}
  • )}
: null)} + : <>
{sectionCopy.interpretation}
{sections.interpretation}
} +
{sectionCopy.applicability}
{sections.applicability}
+
{sectionCopy.inputRevision}
{sections.inputRevision}
+
; + }; + const findingElement = (finding: ExploreHistoryEvent) =>
{finding.title}{timeLabel(finding.recorded_at)} · {zh ? "来源标记" : "Source label"} · {stateLabel(finding.status)}{finding.confidence !== null ? ` · ${zh ? "来源自报置信度" : "Source-reported confidence"} ${finding.confidence}` : ""}{findingBody(finding)}{finding.evidence_refs.map(ref => {ref})}
; const updateRecords = (events: readonly ExploreHistoryEvent[]) =>
    {events.map(event =>
  1. {stateLabel(event.status)} · {event.title}{event.summary ? ` — ${event.summary}` : ""}{event.parent_id ? {copy.group}: {event.parent_id} : null}{event.evidence_refs.map(ref => {ref})}{event.run_id ? {event.run_id} : null}
  2. )}
; useEffect(() => { @@ -105,6 +127,10 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0}: { const value = history.value; const grouped = useMemo(() => groupUnlinkedHistoryQuestions(value?.nodes ?? [], value?.edges ?? []), [value]); const layout = useMemo(() => layoutExploreHistory(grouped.nodes, value?.edges ?? []), [grouped, value]); + const timeline = useMemo(() => value ? buildExploreTimeline(value.nodes, value.edges, value.events) : null, [value]); + const chooseLens = (next: Lens) => { setLens(next); const url = new URL(window.location.href); if (next === "graph") url.searchParams.set("research_lens", "graph"); else url.searchParams.delete("research_lens"); window.history.replaceState(window.history.state, "", url); }; + const fullTime = (at: number) => new Intl.DateTimeFormat(zh ? "zh-CN" : "en", {month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false}).format(new Date(at)); + const dayTime = (at: number) => new Intl.DateTimeFormat(zh ? "zh-CN" : "en", {month: "numeric", day: "numeric"}).format(new Date(at)); const positions = new Map(layout.map(item => [item.node.id, item])); const selectedId = selection.node; const selected = value?.nodes.find(node => node.id === selectedId); @@ -131,6 +157,7 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0}: {

{copy.scope}{value?.startAt ? <> · {copy.time} — : null}

{selectedId || selection.day !== null ? : null} +
{(["timeline", "graph"] as const).map(item => )}
@@ -140,7 +167,9 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0}: { {error || history.error ?

{copy.failure} {error || history.error}

: null} {busy && !read.events.length ?
{copy.loading}…
: null} {value?.nodes.length ?
-
+ {lens === "timeline" && timeline ?
+ choose(id)} timeLabel={fullTime} dayLabel={dayTime}/> +
:
{group.day ? `${group.day} UTC` : copy.unknown}{group.nodes.length} {zh ? "个问题 · 展开全部" : "questions · read all"} )}
-
+
} {selected ?
{if (event.key === "Escape") {event.stopPropagation(); choose("", false); graph.current?.focus();}}}>

{kindLabel(selected.latest.node_kind)} · {stateLabel(selected.latest.status)}

{selected.latest.title}

{selected.latest.summary || (zh ? "此节点尚无详细说明。" : "No detail was recorded for this node.")}

diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-timeline.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-timeline.tsx new file mode 100644 index 0000000000..6e59715d10 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-timeline.tsx @@ -0,0 +1,162 @@ +import {useState, type CSSProperties} from "react"; +import type {ExploreTimeline, TimelineFinding, TimelineNode, TimelinePhase} from "../../../../../../loopx/control_plane/presentation/explore_timeline.js"; + +/** Default whole-history reading: thirds, recording density, recorded routes, + * unlinked questions and explicit decisions on one record-time axis. + * Position means first record time only; it never encodes progress or cause. */ +type Copy = Record; +type Props = { + timeline: ExploreTimeline; zh: boolean; selectedId: string; phase: number | null; + onSelect: (id: string) => void; onPhase: (index: number | null) => void; + timeLabel: (value: number) => string; dayLabel: (value: number) => string; +}; + +const LABELS = ["confirmed", "refuted", "tentative"] as const; +const tone = (label: string) => LABELS.includes(label as typeof LABELS[number]) ? `is-${label}` : "is-unlabeled"; +const isOpen = (item: TimelineNode) => ["open", "exploring", "blocked"].includes(item.node.latest.status); + +export function GoalResearchTimeline({timeline, zh, selectedId, phase, onSelect, onPhase, timeLabel, dayLabel}: Props) { + const copy: Copy = zh ? { + early: "前段", middle: "中段", late: "后段", questions: "新问题", findings: "结论", + decisions: "显式决策", none: "未记录显式决策", changed: "变化", stopped: "停止", retained: "保留", + unstructured: "结论未按 writeback 结构记录,无法拆出依据与决策", + density: "记录密度", densityHint: "柱高为事件数;浅红为无记录空档", ticks: "转向 / 继续", + routes: "研究路线", routeHint: "仅由显式关联或父子分组相连", singles: "未关联问题", singlesHint: "不推断路线或因果", + trail: "为什么转向", trailHint: "每个问题最近一次显式决策,最近在前", more: "显示更多决策", all: "全部时段", + span: "记录跨度", start: "起", end: "止", replan: "转向", continue: "继续", stop: "停止", wait: "等待", no_change: "无变化", ask_human: "请人决定", + confirmed: "来源记为确认", refuted: "已反驳", tentative: "待检查", unlabeled: "无结论", phaseNote: "按记录时间三等分,仅为阅读分组,不是预声明的评估窗口。", + noTime: "没有可用的记录时间,无法放上时间轴;下方记录仍可完整阅读。", + } : { + early: "Early", middle: "Middle", late: "Late", questions: "New questions", findings: "Findings", + decisions: "Explicit decisions", none: "No explicit decision recorded", changed: "Changed", stopped: "Stopped", retained: "Retained", + unstructured: "Findings were not recorded in writeback sections; evidence and decisions cannot be separated", + density: "Recording density", densityHint: "Bar height is events; pale red is a recording gap", ticks: "Replan / continue", + routes: "Research routes", routeHint: "Connected only by recorded relationships or parent grouping", singles: "Unlinked questions", singlesHint: "No route or causality inferred", + trail: "Why direction changed", trailHint: "Latest explicit decision per question, newest first", more: "Show more decisions", all: "Whole history", + span: "Recorded span", start: "From", end: "To", replan: "Replan", continue: "Continue", stop: "Stop", wait: "Wait", no_change: "No change", ask_human: "Ask human", + confirmed: "Recorded confirmation", refuted: "Refuted", tentative: "Tentative", unlabeled: "No finding", phaseNote: "Equal thirds of record time, for reading only; not a pre-declared evaluation window.", + noTime: "No usable record time; the history cannot be placed on a time axis. All records remain readable below.", + }; + const {start, end} = timeline; + if (start === null || end === null) return

{copy.noTime}

; + const span = Math.max(1, end - start); + const pct = (at: number) => `${((at - start) / span) * 100}%`; + const inPhase = (at: number | null) => phase === null || (at !== null && at >= timeline.phases[phase]!.start && at <= timeline.phases[phase]!.end); + const label = (key: string) => copy[key] ?? key; + const hours = (from: number, to: number) => { const value = (to - from) / 3_600_000; return value >= 48 ? `${(value / 24).toFixed(1)} ${zh ? "天" : "d"}` : `${value.toFixed(1)} ${zh ? "小时" : "h"}`; }; + const maxEvents = Math.max(1, ...timeline.bins.map(bin => bin.events)); + const ticks: number[] = []; + const midnight = new Date(start); midnight.setHours(24, 0, 0, 0); + for (let at = midnight.getTime(); at <= end; at += 86_400_000) ticks.push(at); + const stride = Math.max(1, Math.ceil(ticks.length / 10)); + const marker = (item: TimelineNode, style: CSSProperties, extra = "") => { + const title = `${item.node.latest.title} · ${label(item.label || "unlabeled")}${item.first !== null ? ` · ${timeLabel(item.first)}` : ""}`; + return ; + }; + return
+
{timeline.phases.map(phaseCard)}
+

{copy.phaseNote}{phase !== null ? <> · : null}

+
+
{copy.density}{copy.densityHint}{timeline.decisions.length ? {copy.replan} {copy.continue} : null}
+
+
{timeline.bins.map((bin, index) => )}
+ {timeline.decisions.length ?
{timeline.decisions.map(item => )}
: null} + {timeline.phases.slice(1).map(item => )} +
{ticks.map((at, index) => index % stride ? null : {dayLabel(at)})}
+
+ {timeline.routes.length ?
{copy.routes}{copy.routeHint}
: null} + {timeline.routes.map(route => { + const members = route.nodeIds.map(id => timeline.nodes.get(id)!); + const tally = LABELS.map(key => [key, members.filter(item => item.label === key).length] as const).filter(([, value]) => value); + const first = members[0]!, last = members.at(-1)!; + const position = new Map(members.map(item => [item.node.id, item.first ?? start])); + const x = (id: string) => ((position.get(id)! - start) / span) * 1000; + const arcs = [ + ...[...timeline.nodes.values()].filter(item => item.routeId === route.id && item.node.latest.parent_id && position.has(item.node.latest.parent_id)) + .map(item => ({key: `parent:${item.node.id}`, from: item.node.latest.parent_id, to: item.node.id, kind: "is-grouping", title: ""})), + ...timeline.edges.filter(edge => position.has(edge.from_node) && position.has(edge.to_node)) + .map(edge => ({key: edge.event_id, from: edge.from_node, to: edge.to_node, kind: edge.edge_type === "refutes" ? "is-refutation" : "", title: `${edge.edge_type}: ${edge.summary}`})), + ]; + return
+
{members.length} {zh ? "个问题" : "questions"} + {dayLabel(route.first!)} – {dayLabel(route.last!)} · {copy.span} {hours(route.first!, route.last!)} + {tally.map(([key, value]) => `${label(key)} ${value}`).join(" · ") || copy.unlabeled}
+
+

{copy.start}{first.node.latest.title}{members.length > 1 ? <>{copy.end}{last.node.latest.title} : null}

+
+ + {members.map(item => marker(item, {left: pct(item.first ?? start)}))} +
+
+
; + })} + {swarm.length ? <> +
{copy.singles} · {swarm.length}{copy.singlesHint}
+
+ {swarm.map(({item, column, level}) => marker(item, {left: `${(column / 160) * 100}%`, bottom: level * 11 + 4}, " is-small"))} +
+ : null} +
+ {unknownTime ?

{zh ? "缺少记录时间的问题未放上时间轴" : "Questions without a record time are not placed on the axis"} · {unknownTime}

: null} +
+

{copy.trail}{copy.trailHint}

+ {!timeline.decisions.length ?

{timeline.structuredCount ? copy.none : copy.unstructured}

+ : } +
+
; +} + +function TrailList({items, copy, label, timeLabel, selectedId, onSelect}: {items: TimelineFinding[]; copy: Copy; label: (key: string) => string; timeLabel: (value: number) => string; selectedId: string; onSelect: (id: string) => void}) { + const [limit, setLimit] = useState(6); + return <> +
    {items.slice(0, limit).map(item => { + const decision = item.sections!.decision!; + return
  1. +
  2. ; + })}
+ {items.length > limit ? : null} + ; +} diff --git a/loopx/control_plane/presentation/explore_timeline.ts b/loopx/control_plane/presentation/explore_timeline.ts new file mode 100644 index 0000000000..57d6c5dc03 --- /dev/null +++ b/loopx/control_plane/presentation/explore_timeline.ts @@ -0,0 +1,116 @@ +/** Time-anchored reading model over recorded Explore history. + * + * Display projection only. Bins, thirds and routes organize recorded events + * for reading; they never become an evaluation window, a progress score or a + * causal relation. `recorded_at` is record time, not attempt time. + */ +import type {ExploreHistoryEvent, ExploreHistoryNode} from "./explore_history.ts"; + +/** Sections written by Explore result writeback (explore_result_writeback.ts / + * result_writeback.py). Reading that owner's labels is format decoding, not a + * prose heuristic; any other summary stays raw. */ +const SECTION_LABELS = ["Input revision", "Applicability", "Observation", "Interpretation"] as const; +const DECISION_LISTS = ["retained", "changed", "stopped"] as const; +export type ExploreDecision = {outcome: string; retained: string[]; changed: string[]; stopped: string[]}; +export type ExploreFindingSections = {inputRevision: string; applicability: string; observation: string; interpretation: string; decision: ExploreDecision | null}; + +export function parseExploreFindingSections(summary: string): ExploreFindingSections | null { + const text = summary.replace(/\s+/g, " ").trim(); + const values: string[] = []; + let cursor = 0; + for (const [index, label] of SECTION_LABELS.entries()) { + const marker = `${label}: `; + const start = index === 0 ? (text.startsWith(marker) ? 0 : -1) : text.indexOf(` ${marker}`, cursor); + if (start < 0) return null; + if (index) values[index - 1] = text.slice(cursor, start).trim(); + cursor = start + (index ? 1 : 0) + marker.length; + } + values[3] = text.slice(cursor).trim(); + return {inputRevision: values[0]!, applicability: values[1]!, observation: values[2]!, interpretation: values[3]!, decision: parseDecision(values[3]!)}; +} + +/** Explore path_delta writes `Outcome: ` then `retained:/changed:/stopped:` lists. */ +function parseDecision(interpretation: string): ExploreDecision | null { + const head = /^Outcome: ([a-z_]+)(?: |$)/.exec(interpretation); + if (!head) return null; + const decision: ExploreDecision = {outcome: head[1]!, retained: [], changed: [], stopped: []}; + const body = interpretation.slice(head[0].length); + const marks = DECISION_LISTS.map(key => ({key, at: body.search(new RegExp(`(?:^| )${key}: `))})).filter(mark => mark.at >= 0).sort((a, b) => a.at - b.at); + marks.forEach((mark, index) => { + const end = marks[index + 1]?.at ?? body.length; + const raw = body.slice(mark.at, end).replace(new RegExp(`^ ?${mark.key}: `), ""); + decision[mark.key] = raw.split(/(?:^| )- /).map(item => item.trim()).filter(Boolean); + }); + return decision; +} + +export type TimelineFinding = {event: ExploreHistoryEvent; at: number | null; sections: ExploreFindingSections | null}; +export type TimelineBin = {start: number; end: number; events: number; questions: number; labels: Record; decisions: Record}; +export type TimelinePhase = {index: number; start: number; end: number; questions: number; findings: number; labels: Record; decisions: Record; lastDecision: TimelineFinding | null}; +export type TimelineRoute = {id: string; nodeIds: string[]; first: number | null; last: number | null}; +export type TimelineNode = {node: ExploreHistoryNode; first: number | null; last: number | null; label: string; routeId: string | null}; +export type ExploreTimeline = { + start: number | null; end: number | null; + bins: TimelineBin[]; phases: TimelinePhase[]; + nodes: Map; routes: TimelineRoute[]; singles: string[]; edges: readonly ExploreHistoryEvent[]; + findings: TimelineFinding[]; decisions: TimelineFinding[]; structuredCount: number; +}; + +const time = (value: string) => { const at = Date.parse(value); return Number.isFinite(at) ? at : null; }; +const bump = (counts: Record, key: string) => { counts[key] = (counts[key] ?? 0) + 1; }; + +/** Routes are connected components of recorded typed edges and parent grouping only. */ +function routesOf(nodes: readonly ExploreHistoryNode[], edges: readonly ExploreHistoryEvent[]) { + const known = new Set(nodes.map(node => node.id)); + const parent = new Map(); + const find = (id: string): string => { let root = id; while (parent.get(root) && parent.get(root) !== root) root = parent.get(root)!; return root; }; + const join = (a: string, b: string) => { if (known.has(a) && known.has(b)) { const ra = find(a), rb = find(b); if (ra !== rb) parent.set(ra, rb); } }; + for (const edge of edges) join(edge.from_node, edge.to_node); + for (const node of nodes) if (node.latest.parent_id) join(node.id, node.latest.parent_id); + const groups = new Map(); + for (const node of nodes) { const root = find(node.id); groups.set(root, [...(groups.get(root) ?? []), node.id]); } + return [...groups.values()]; +} + +export function buildExploreTimeline(nodes: readonly ExploreHistoryNode[], edges: readonly ExploreHistoryEvent[], events: readonly ExploreHistoryEvent[], {targetBins = 36} = {}): ExploreTimeline { + const findings: TimelineFinding[] = events.filter(event => event.event_kind === "finding") + .map(event => ({event, at: time(event.recorded_at), sections: parseExploreFindingSections(event.summary)})); + const decisions = findings.filter(item => item.sections?.decision); + const times = events.map(event => time(event.recorded_at)).filter((at): at is number => at !== null); + const start = times.length ? Math.min(...times) : null; + const end = times.length ? Math.max(...times) : null; + const timelineNodes = new Map(); + for (const node of nodes) { + const stamps = [...node.updates, ...node.findings].map(event => time(event.recorded_at)).filter((at): at is number => at !== null); + const label = node.findings.at(-1)?.status ?? ""; + timelineNodes.set(node.id, {node, first: stamps.length ? Math.min(...stamps) : null, last: stamps.length ? Math.max(...stamps) : null, label, routeId: null}); + } + const routes: TimelineRoute[] = []; + const singles: string[] = []; + for (const members of routesOf(nodes, edges)) { + if (members.length < 2) { singles.push(members[0]!); continue; } + const ordered = members.sort((a, b) => (timelineNodes.get(a)!.first ?? Infinity) - (timelineNodes.get(b)!.first ?? Infinity)); + const firsts = ordered.map(id => timelineNodes.get(id)!.first).filter((at): at is number => at !== null); + const lasts = ordered.map(id => timelineNodes.get(id)!.last).filter((at): at is number => at !== null); + const id = `route:${ordered[0]}`; + for (const member of ordered) timelineNodes.get(member)!.routeId = id; + routes.push({id, nodeIds: ordered, first: firsts.length ? Math.min(...firsts) : null, last: lasts.length ? Math.max(...lasts) : null}); + } + routes.sort((a, b) => (a.first ?? Infinity) - (b.first ?? Infinity)); + singles.sort((a, b) => (timelineNodes.get(a)!.first ?? Infinity) - (timelineNodes.get(b)!.first ?? Infinity)); + const span = start !== null && end !== null ? Math.max(1, end - start) : 0; + const binCount = span ? Math.max(1, Math.min(targetBins, Math.ceil(span / 3_600_000))) : 0; + const bins: TimelineBin[] = Array.from({length: binCount}, (_, index) => ({start: start! + span * index / binCount, end: start! + span * (index + 1) / binCount, events: 0, questions: 0, labels: {}, decisions: {}})); + const phases: TimelinePhase[] = span ? [0, 1, 2].map(index => ({index, start: start! + span * index / 3, end: start! + span * (index + 1) / 3, questions: 0, findings: 0, labels: {}, decisions: {}, lastDecision: null})) : []; + const slot = (items: T[], at: number) => items[Math.min(items.length - 1, Math.floor((at - start!) / span * items.length))]!; + for (const event of events) { const at = time(event.recorded_at); if (at !== null && bins.length) slot(bins, at).events++; } + for (const item of timelineNodes.values()) if (item.first !== null && bins.length) { slot(bins, item.first).questions++; slot(phases, item.first).questions++; } + for (const item of findings) { + if (item.at === null || !bins.length) continue; + const bin = slot(bins, item.at), phase = slot(phases, item.at); + bump(bin.labels, item.event.status); bump(phase.labels, item.event.status); phase.findings++; + const outcome = item.sections?.decision?.outcome; + if (outcome) { bump(bin.decisions, outcome); bump(phase.decisions, outcome); phase.lastDecision = item; } + } + return {start, end, bins, phases, nodes: timelineNodes, routes, singles, edges, findings, decisions, structuredCount: findings.filter(item => item.sections).length}; +} diff --git a/tests/control_plane_ts/explore_history.test.ts b/tests/control_plane_ts/explore_history.test.ts index 3abec21346..d987d0c5b5 100644 --- a/tests/control_plane_ts/explore_history.test.ts +++ b/tests/control_plane_ts/explore_history.test.ts @@ -80,3 +80,30 @@ test("sparse question capture is grouped without inventing routes or hiding vers assert.equal(view.edges.length, 1, "date buckets create no source edges or scientific route"); assert.equal(new Set([...members, ...display.nodes].map(node => node.id)).size, 120); }); + +test("time-anchored reading keeps record-time thirds, routes and explicit decisions without causality", async () => { + const {buildExploreTimeline, parseExploreFindingSections} = await import("../../loopx/control_plane/presentation/explore_timeline.ts"); + const rows = [ + event({event_id: "n1", result_id: "q1", title: "Early question", recorded_at: "2026-01-01T00:00:00Z", status: "resolved"}), + event({event_id: "n2", result_id: "q2", title: "Mid question", recorded_at: "2026-01-02T00:00:00Z", status: "resolved", parent_id: "q1"}), + event({event_id: "n3", result_id: "q3", title: "Late question", recorded_at: "2026-01-03T00:00:00Z", status: "open"}), + event({event_id: "f1", event_kind: "finding", result_id: "finding1", node_id: "q1", status: "confirmed", evidence_refs: ["artifact:v1"]}), + event({event_id: "f3", event_kind: "finding", result_id: "finding3", node_id: "q3", status: "tentative", recorded_at: "2026-01-03T00:00:00Z", evidence_refs: ["artifact:v3"], + summary: "Input revision: plan:abc Applicability: exact input Observation: child resource finished Interpretation: Outcome: replan\nretained:\n- original owner\nchanged:\n- child DONE\nstopped:\n- do not rerun the old probe"}), + ]; + const view = projectExploreHistory(rows, goal); + const timeline = buildExploreTimeline(view.nodes, view.edges, view.events); + assert.equal(timeline.bins.length > 0, true); + assert.deepEqual(timeline.phases.map(phase => phase.questions), [1, 1, 1]); + assert.equal(timeline.routes.length, 1, "recorded parent grouping joins a reading route"); + assert.equal(timeline.routes[0]!.nodeIds[0], "q1"); + assert.equal(timeline.singles.length, 1, "time adjacency never joins q3 to the route"); + assert.equal(timeline.decisions.length, 1); + const decision = timeline.decisions[0]!.sections!.decision!; + assert.equal(decision.outcome, "replan"); + assert.deepEqual(decision.changed, ["child DONE"]); + assert.deepEqual(decision.stopped, ["do not rerun the old probe"]); + const sections = parseExploreFindingSections(rows[4]!.summary)!; + assert.equal(sections.observation, "child resource finished"); + assert.equal(parseExploreFindingSections("free-form finding text"), null, "unstructured findings stay raw prose"); +}); From 916cec603e8537c0791a9bb4042e07538c579e60 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Fri, 16 Oct 2026 17:21:41 +0800 Subject: [PATCH 2/2] feat(research): reusable Lark whiteboard renderer for the time-anchored history Same shared read model as the dashboard timeline, rendered to an SVG for lark-whiteboard publish. Verified byte-identical output against the manual prototype on both accepted datasets (local 483-event game log and the redacted EdgeBench fixture). Signed-off-by: huangruiteng --- examples/research-history-lark/README.md | 41 +++++ examples/research-history-lark/board.mts | 182 +++++++++++++++++++++++ 2 files changed, 223 insertions(+) create mode 100644 examples/research-history-lark/README.md create mode 100644 examples/research-history-lark/board.mts diff --git a/examples/research-history-lark/README.md b/examples/research-history-lark/README.md new file mode 100644 index 0000000000..98dfcc7962 --- /dev/null +++ b/examples/research-history-lark/README.md @@ -0,0 +1,41 @@ +# Research history Lark-whiteboard board + +Renders one Goal's recorded Explore history as an SVG for a Lark whiteboard. + +The board answers the long-run reading questions from the two-layer workspace +contract (`docs/architecture/rfcs/goal-workspace-surface-v0.zh-CN.md` §5.11): +what the early/middle/late thirds of record time contain, where recording gaps +are, which recorded routes explicit edges/parent grouping connect, and — when +findings use the Explore writeback sections — what changed, stopped and was +retained at each explicit replan/continue. + +It is a **display projection only**: thirds are reading groups, not a +pre-declared evaluation window; marker position is record time, not attempt +time or progress; unlinked questions never acquire edges; findings labels stay +source labels; no native score or evaluation is rendered. + +## Inputs + +Either an append-only `explore-result-log.jsonl`, or the redacted fixture JSON +(`apps/presentation/dashboard/src/data/fixtures/edgebench-research-history.json` +from PR #6616). + +```sh +node --no-warnings --experimental-strip-types examples/research-history-lark/board.mts \ + /goals//explore-result-log.jsonl /tmp/research.svg "" <goal_id> +``` + +## Whiteboard publish + +Follow the `lark-whiteboard` skill (render/geometric check → OpenAPI → write): + +```sh +npx -y @larksuite/whiteboard-cli@^0.2.13 -i /tmp/research.svg -f svg --check +npx -y @larksuite/whiteboard-cli@^0.2.13 -i /tmp/research.svg -o /tmp/research.png -f svg +npx -y @larksuite/whiteboard-cli@^0.2.13 -i /tmp/research.svg -f svg --to openapi --format json \ + | lark-cli whiteboard +update --as user --whiteboard-token <token> \ + --source - --input_format raw \ + --idempotent-token <unique-token> +``` + +Only publish public-safe data (the redacted fixture or an authorized projection). diff --git a/examples/research-history-lark/board.mts b/examples/research-history-lark/board.mts new file mode 100644 index 0000000000..7136c7b8f2 --- /dev/null +++ b/examples/research-history-lark/board.mts @@ -0,0 +1,182 @@ +#!/usr/bin/env -S node --no-warnings --experimental-strip-types +/** Render the time-anchored research history as a Lark-whiteboard SVG. + * + * Input is either a JSONL Explore result log or the redacted fixture JSON + * exported with PR #6616. The SVG is display-only: record-time thirds, routes + * from recorded edges/parent grouping and explicit path_delta decisions; it + * never adds edges, scores or evaluations. Upload via the lark-whiteboard skill: + * npx -y @larksuite/whiteboard-cli@^0.2.13 -i out.svg -f svg --check + * npx ... -i out.svg -f svg --to openapi --format json | lark-cli whiteboard +update ... + * + * Usage: node examples/research-history-lark/board.mts <log.jsonl|fixture.json> <out.svg> "<title>" <goal_id> + */ +// Prototype Lark board: time-anchored research history over the shared timeline model. +import {readFileSync, writeFileSync} from "node:fs"; +import {parseExploreHistoryPage, projectExploreHistory} from "../../loopx/control_plane/presentation/explore_history.ts"; +import {buildExploreTimeline, type TimelineNode} from "../../loopx/control_plane/presentation/explore_timeline.ts"; + +const [source, out, title, goal] = process.argv.slice(2); +let raw: unknown[]; +try { + const parsed = JSON.parse(readFileSync(source, "utf8")); + raw = Array.isArray(parsed) ? parsed : parsed.events; +} catch (error) { + if (!(error instanceof SyntaxError)) throw error; + raw = readFileSync(source, "utf8").trim().split("\n").filter(Boolean).map(line => JSON.parse(line)); +} +const page = parseExploreHistoryPage({ok: true, goal_id: goal, items: raw, total: raw.length, next_cursor: null}, goal); +const view = projectExploreHistory(page.items, goal); +const t = buildExploreTimeline(view.nodes, view.edges, view.events, {targetBins: 48}); +const esc = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); +const width = (s: string) => [...s].reduce((n, c) => n + (/[\u2E80-\uFFFF]/.test(c) ? 1 : 0.56), 0); +const cut = (s: string, w: number) => { let o = "", n = 0; for (const c of s) { const d = /[\u2E80-\uFFFF]/.test(c) ? 1.05 : 0.66; if (n + d > w - 1) return o + "…"; o += c; n += d; } return o; }; +const fmt = (at: number) => new Intl.DateTimeFormat("zh-CN", {month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false, timeZone: "Asia/Shanghai"}).format(at); +const day = (at: number) => new Intl.DateTimeFormat("zh-CN", {month: "numeric", day: "numeric", timeZone: "Asia/Shanghai"}).format(at); +const C = {text: "#1F2329", muted: "#646A73", faint: "#8F959E", line: "#DEE0E3", bg: "#F5F6F7", + confirmed: "#509863", confirmedBg: "#DFF5E5", refuted: "#D25D5A", refutedBg: "#FEE3E2", tentative: "#D4B45B", tentativeBg: "#FEF1CE", none: "#BBBFC4", noneBg: "#F2F3F5", + replan: "#8569CB", replanBg: "#EAE2FE", cont: "#5178C6", contBg: "#F0F4FC", edge: "#8F959E"}; +const labelColor = (l: string) => l === "confirmed" ? C.confirmed : l === "refuted" ? C.refuted : l === "tentative" ? C.tentative : C.none; +const labelBg = (l: string) => l === "confirmed" ? C.confirmedBg : l === "refuted" ? C.refutedBg : l === "tentative" ? C.tentativeBg : C.noneBg; +const LABEL = {confirmed: "来源记为确认", refuted: "已反驳", tentative: "待检查"} as Record<string, string>; +const OUT = {replan: "转向 replan", continue: "继续 continue", stop: "停止", wait: "等待", no_change: "无变化", ask_human: "请人决定"} as Record<string, string>; + +const W = 1680, M = 48, plotX = M + 210, plotW = W - plotX - M; +const s: string[] = []; +let y = 40; +const text = (x: number, yy: number, v: string, o: {size?: number; color?: string; weight?: number; anchor?: string} = {}) => + s.push(`<text x="${x}" y="${yy}" font-size="${o.size ?? 14}" fill="${o.color ?? C.text}"${o.weight ? ` font-weight="${o.weight}"` : ""}${o.anchor ? ` text-anchor="${o.anchor}"` : ""}>${esc(v)}</text>`); +const X = (at: number) => plotX + (at - t.start!) / Math.max(1, t.end! - t.start!) * plotW; + +// Header +text(M, y + 10, title, {size: 28, weight: 700}); +const spanH = (t.end! - t.start!) / 3_600_000; +text(M, y + 44, `Explore 已记录历史 · ${fmt(t.start!)} — ${fmt(t.end!)} · 跨度 ${spanH >= 48 ? `${(spanH / 24).toFixed(1)} 天` : `${spanH.toFixed(1)} 小时`} · ${view.events.length} 条记录 · ${view.nodes.length} 个问题 · ${t.findings.length} 条结论`, {size: 15, color: C.muted}); +text(M, y + 68, "时间为记录时间(含延后分析与回填),不是尝试发生时间;原生成绩未接入,本图不表达分数提升。", {size: 13, color: C.faint}); +y += 100; + +// Phase cards +const names = ["前段", "中段", "后段"]; +const cardW = (W - 2 * M - 2 * 24) / 3, cardH = 196; +for (const p of t.phases) { + const x = M + p.index * (cardW + 24); + const late = p.index === 2; + s.push(`<rect x="${x}" y="${y}" width="${cardW}" height="${cardH}" rx="12" fill="${late ? "#F0F4FC" : "#FFFFFF"}" stroke="${late ? C.cont : C.line}" stroke-width="2"/>`); + text(x + 20, y + 32, `${names[p.index]} · ${fmt(p.start)} — ${fmt(p.end)}`, {size: 16, weight: 700}); + text(x + 20, y + 62, `新问题 ${p.questions}`, {size: 22, weight: 700}); + text(x + 160, y + 62, `结论 ${p.findings}`, {size: 22, weight: 700}); + // stacked label bar + const total = Math.max(1, p.findings); let bx = x + 20; const bw = cardW - 40; + for (const l of ["confirmed", "refuted", "tentative"]) { + const n = p.labels[l] ?? 0; if (!n) continue; const w = bw * n / total; + s.push(`<rect x="${bx}" y="${y + 80}" width="${Math.max(2, w - 2)}" height="22" rx="4" fill="${labelColor(l)}"/>`); + if (w > 110) text(bx + 8, y + 96, `${LABEL[l]} ${n}`, {size: 12, color: "#FFFFFF", weight: 600}); + else if (w > 24) text(bx + 6, y + 96, `${n}`, {size: 12, color: "#FFFFFF", weight: 600}); + bx += w; + } + const d = Object.entries(p.decisions); + text(x + 20, y + 132, d.length ? `显式决策:${d.map(([k, v]) => `${OUT[k] ?? k} ${v}`).join(" · ")}` : "显式决策:未记录", {size: 14, color: d.length ? C.text : C.faint}); + const lastIn = [...t.decisions].reverse().find(item => item.at !== null && item.at >= p.start && item.at <= p.end); + if (lastIn) { + text(x + 20, y + 158, cut(`最近变化:${lastIn.sections!.decision!.changed[0] ?? "—"}`, (cardW - 40) / 13.5), {size: 13, color: C.muted}); + text(x + 20, y + 180, cut(`停止:${lastIn.sections!.decision!.stopped[0] ?? "—"}`, (cardW - 40) / 13.5), {size: 13, color: C.muted}); + } else text(x + 20, y + 158, t.structuredCount ? "本段无 path_delta 决策记录" : "结论未按 writeback 结构记录,无法拆出依据/决策", {size: 13, color: C.faint}); +} +y += cardH + 40; + +// Time axis + density +text(M, y + 14, "记录密度", {size: 15, weight: 700}); +text(M, y + 34, "柱高=事件数 · 浅红=无记录空档", {size: 12, color: C.faint}); +text(M, y + 54, "下方刻度:紫=转向 蓝=继续", {size: 12, color: C.faint}); +const maxE = Math.max(1, ...t.bins.map(b => b.events)), bandH = 56; +for (const b of t.bins) { + const x0 = X(b.start), x1 = X(b.end), h = b.events ? Math.max(3, bandH * b.events / maxE) : bandH; + s.push(b.events ? `<rect x="${x0 + 1}" y="${y + bandH - h}" width="${Math.max(1, x1 - x0 - 2)}" height="${h}" rx="2" fill="#BACEFD"/>` + : `<rect x="${x0 + 1}" y="${y}" width="${Math.max(1, x1 - x0 - 2)}" height="${bandH}" rx="2" fill="${C.refutedBg}"/>`); +} +for (const item of t.decisions) { const x = X(item.at!), r = item.sections!.decision!.outcome === "replan"; s.push(`<rect x="${x - 1.5}" y="${y + bandH + 4}" width="3" height="10" rx="1" fill="${r ? C.replan : C.cont}"/>`); } +for (const p of t.phases.slice(1)) s.push(`<line x1="${X(p.start)}" y1="${y - 8}" x2="${X(p.start)}" y2="${y + bandH + 8}" stroke="${C.faint}" stroke-dasharray="4 4"/>`); +y += bandH + 22; +// day ticks +const d0 = new Date(t.start!); d0.setUTCHours(16, 0, 0, 0); if (d0.getTime() < t.start!) d0.setUTCDate(d0.getUTCDate() + 1); +s.push(`<line x1="${plotX}" y1="${y}" x2="${plotX + plotW}" y2="${y}" stroke="${C.line}"/>`); +const ticks: number[] = []; for (let at = d0.getTime(); at <= t.end!; at += 86_400_000) ticks.push(at); +const stride = Math.ceil(ticks.length / 12); +ticks.forEach((at, i) => { if (i % stride) return; s.push(`<line x1="${X(at)}" y1="${y}" x2="${X(at)}" y2="${y + 6}" stroke="${C.faint}"/>`); text(X(at), y + 22, day(at), {size: 12, color: C.muted, anchor: "middle"}); }); +y += 44; + +// Routes: compact time-anchored chains (markers + recorded edge arcs), no staircase. +text(M, y, "研究路线(仅由显式关联/父子分组相连)", {size: 18, weight: 700}); +text(M + 420, y, "横轴=首次记录时间 · 实线弧=显式关联 · 红弧=反驳 · 虚线=父子分组 · 圆点颜色=最新结论来源标签 · 空心=仍开放", {size: 13, color: C.faint}); +y += 18; +const pos = new Map<string, {x: number; y: number}>(); +const lanes = t.routes.slice().sort((a, b) => b.nodeIds.length - a.nodeIds.length).slice(0, 8).sort((a, b) => (a.first ?? 0) - (b.first ?? 0)); +const laneH = 78; +for (const route of lanes) { + const members = route.nodeIds.map(id => t.nodes.get(id)!); + s.push(`<rect x="${M}" y="${y}" width="${W - 2 * M}" height="${laneH}" rx="10" fill="#FAFBFC" stroke="${C.line}" stroke-dasharray="4 4"/>`); + const labels = members.map(m => m.label); + const tally = ["confirmed", "refuted", "tentative"].map(l => labels.filter(x => x === l).length ? `${LABEL[l]} ${labels.filter(x => x === l).length}` : "").filter(Boolean).join(" · "); + text(M + 14, y + 24, `${members.length} 个问题 · ${day(route.first!)}–${day(route.last!)}`, {size: 13, weight: 700}); + text(M + 14, y + 44, tally || "无结论", {size: 12, color: C.muted}); + const span = (route.last! - route.first!) / 3_600_000; + text(M + 14, y + 62, `记录跨度 ${span >= 24 ? (span / 24).toFixed(1) + " 天" : span.toFixed(1) + " 小时"}`, {size: 12, color: C.faint}); + const cy = y + laneH - 22; + s.push(`<line x1="${X(route.first!)}" y1="${cy}" x2="${X(route.last!)}" y2="${cy}" stroke="${C.line}" stroke-width="2"/>`); + for (const m of members) pos.set(m.node.id, {x: X(m.first ?? t.start!), y: cy}); + const first = members[0]!, last = members.at(-1)!; + const x0 = X(first.first!), x1 = X(last.first!); + // Start label above the chain, stop label to the right of its end (left only if it would overflow). + text(Math.min(x0, plotX + plotW - 520), y + 22, cut(`起:${first.node.latest.title}`, 38), {size: 12}); + if (members.length > 1) { + const stop = cut(`止:${last.node.latest.title}`, 30), sw = width(stop) * 12.6; + const right = x1 + 14 + sw <= plotX + plotW; + text(right ? x1 + 14 : x1 - 14, cy + 4, stop, {size: 12, color: labelColor(last.label), anchor: right ? undefined : "end"}); + } + y += laneH + 10; +} +const edgeSvg: string[] = []; +const arc = (a: {x: number; y: number}, b: {x: number; y: number}, color: string, dash = "") => { + const [p, q] = a.x <= b.x ? [a, b] : [b, a]; const h = Math.min(26, 8 + (q.x - p.x) / 6); + edgeSvg.push(`<path d="M${p.x},${p.y - 6} C${p.x},${p.y - 6 - h} ${q.x},${q.y - 6 - h} ${q.x},${q.y - 6}" fill="none" stroke="${color}" stroke-width="1.5"${dash ? ` stroke-dasharray="${dash}"` : ""}/>`); +}; +for (const e of view.edges) { const a = pos.get(e.from_node), b = pos.get(e.to_node); if (a && b) arc(a, b, e.edge_type === "refutes" ? C.refuted : C.edge); } +for (const node of view.nodes) { const a = pos.get(node.id), b = pos.get(node.latest.parent_id); if (a && b) arc(a, b, C.faint, "3 4"); } +s.push(...edgeSvg); +for (const [id, p] of pos) { const m = t.nodes.get(id)!; const open = m.node.latest.status === "open" || m.node.latest.status === "exploring"; + s.push(`<circle cx="${p.x}" cy="${p.y}" r="6" fill="${open ? "#FFFFFF" : labelColor(m.label)}" stroke="${labelColor(m.label)}" stroke-width="2"/>`); } +// Singles beeswarm +y += 8; +const singles = t.singles.map(id => t.nodes.get(id)!).filter(n => n.first !== null); +const cols = new Map<number, number>(); const dot = 9; +let maxStack = 0; +const dots = singles.map(n => { const cx = X(n.first!), key = Math.round((cx - plotX) / (dot + 1)); const k = cols.get(key) ?? 0; cols.set(key, k + 1); maxStack = Math.max(maxStack, k + 1); return {n, cx: plotX + key * (dot + 1) + dot / 2, k}; }); +const swarmH = Math.max(40, maxStack * (dot + 1) + 24); +s.push(`<rect x="${M}" y="${y}" width="${W - 2 * M}" height="${swarmH}" rx="10" fill="#FAFBFC" stroke="${C.line}" stroke-dasharray="4 4"/>`); +text(M + 14, y + 26, "未关联的单点问题", {size: 13, weight: 700}); +text(M + 14, y + 46, `${singles.length} 个 · 不推断路线或因果`, {size: 12, color: C.muted}); +for (const {n, cx, k} of dots) s.push(`<circle cx="${cx}" cy="${y + swarmH - 12 - k * (dot + 1)}" r="${dot / 2}" fill="${labelColor(n.label)}" fill-opacity="${n.node.latest.status === "open" ? 0.45 : 0.9}"/>`); +y += swarmH + 36; + +// Decision trail (late first) +text(M, y, "为什么转向:每个问题最近一次显式决策(最近在前)", {size: 18, weight: 700}); +y += 16; +const seenNode = new Set<string>(); const trail = [...t.decisions].reverse().filter(item => !seenNode.has(item.event.node_id) && seenNode.add(item.event.node_id)).slice(0, 6); +if (!trail.length) { text(M, y + 24, "来源没有按 Outcome/retained/changed/stopped 记录决策;只能读到结论标签,无法回答“为什么转向”。", {size: 14, color: C.faint}); y += 48; } +const colW = (W - 2 * M - 24) / 2; +trail.forEach((item, i) => { + const d = item.sections!.decision!, x = M + (i % 2) * (colW + 24), yy = y + Math.floor(i / 2) * 150; + const replan = d.outcome === "replan"; + s.push(`<rect x="${x}" y="${yy}" width="${colW}" height="136" rx="10" fill="#FFFFFF" stroke="${C.line}" stroke-width="1.5"/>`); + s.push(`<rect x="${x + 16}" y="${yy + 14}" width="${width(OUT[d.outcome] ?? d.outcome) * 13 + 20}" height="24" rx="12" fill="${replan ? C.replanBg : C.contBg}"/>`); + text(x + 26, yy + 31, OUT[d.outcome] ?? d.outcome, {size: 13, color: replan ? C.replan : C.cont, weight: 700}); + text(x + width(OUT[d.outcome] ?? d.outcome) * 13 + 48, yy + 31, `${fmt(item.at!)} · ${LABEL[item.event.status] ?? item.event.status}`, {size: 12, color: C.muted}); + text(x + 16, yy + 60, cut(item.event.title, (colW - 32) / 15.8), {size: 14, weight: 600}); + text(x + 16, yy + 84, cut(`变化:${d.changed.join(";") || "—"}`, (colW - 32) / 14.2), {size: 13}); + text(x + 16, yy + 106, cut(`停止:${d.stopped.join(";") || "—"}`, (colW - 32) / 14.2), {size: 13, color: C.muted}); + text(x + 16, yy + 126, cut(`保留:${d.retained.join(";") || "—"}`, (colW - 32) / 13.2), {size: 12, color: C.faint}); +}); +y += Math.ceil(trail.length / 2) * 150 + 20; +text(M, y, "图中分段按记录时间三等分,仅为阅读分组,不是研究方预声明的评估窗口;结论标签沿用来源,不代表独立检查或实际采用。", {size: 12, color: C.faint}); +y += 30; +writeFileSync(out, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${y}" width="${W}" height="${y}" font-family="Noto Sans SC, PingFang SC, sans-serif"><rect x="0" y="0" width="${W}" height="${y}" fill="#FFFFFF"/>${s.join("")}</svg>`); +console.log(out, W, y, "routes", lanes.length, "singles", singles.length, "decisions", t.decisions.length);