From ff1ef10bd7e0fbff35b4a95e4570fcd6a7c11df7 Mon Sep 17 00:00:00 2001 From: Shay Date: Tue, 23 Jun 2026 15:03:38 -0700 Subject: [PATCH] feat(workbench-ui): expand Trace construction evidence inspection Expand the Trace Construction panel from a summary scaffold into a read-only evidence inspection surface. Adds detail sections for construction proposals, mentions, mention bindings, bound relations, contract assessments, and source-span exactness rows. Preserves the proposal/contract authority boundary: proposals remain diagnostic and contract assessments determine runnable/blocker disposition. Validation: workbench-ui, smoke, and lane-shas GitHub Actions passed on PR head d20d143b5744d15d73d07a515b466143af6a2d52. --- .../trace/ConstructionEvidencePanel.test.tsx | 116 ++++++++++ .../app/trace/ConstructionEvidencePanel.tsx | 60 +++++- .../constructionEvidencePanelModel.test.ts | 150 +++++++++++-- .../trace/constructionEvidencePanelModel.ts | 202 +++++++++++++++++- 4 files changed, 507 insertions(+), 21 deletions(-) create mode 100644 workbench-ui/src/app/trace/ConstructionEvidencePanel.test.tsx diff --git a/workbench-ui/src/app/trace/ConstructionEvidencePanel.test.tsx b/workbench-ui/src/app/trace/ConstructionEvidencePanel.test.tsx new file mode 100644 index 00000000..bef77f38 --- /dev/null +++ b/workbench-ui/src/app/trace/ConstructionEvidencePanel.test.tsx @@ -0,0 +1,116 @@ +import { render, screen, within } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import type { ConstructionEvidence } from "../../types/constructionEvidence"; +import { ConstructionEvidencePanel } from "./ConstructionEvidencePanel"; + +const recordedEvidence: ConstructionEvidence = { + schema_version: "construction_evidence_v1", + turn_id: 7, + status: "recorded", + missing_reason: null, + problem_text: "Lena has 3 marbles.", + proposals: [ + { + family_id: "binding.quantity_entity", + relation_type: "quantity_entity", + candidate_organ: "quantity_entity_binding_candidate.v1", + status: "proposed", + evidence_spans: [{ start: 9, end: 10, text: "3" }], + role_obligations: [ + { + role: "quantity", + required: true, + description: "source quantity mention", + }, + ], + diagnostic_only: true, + serving_allowed: false, + }, + ], + mentions: [ + { + mention_id: "m_lena", + kind: "entity", + surface: "Lena", + span: { start: 0, end: 4, text: "Lena" }, + fact_id: null, + }, + { + mention_id: "m_bad", + kind: "entity", + surface: "Lena", + span: { start: 0, end: 4, text: "Lena!" }, + fact_id: null, + }, + ], + bindings: [ + { + binding_type: "quantity_to_entity", + source_mention_id: "m_quantity", + target_mention_id: "m_lena", + evidence_spans: [{ start: 0, end: 10, text: "Lena has 3" }], + }, + ], + bound_relations: [ + { + relation_type: "quantity_entity", + roles: [ + { + role: "entity", + target_id: "m_lena", + evidence_spans: [{ start: 0, end: 4, text: "Lena" }], + }, + { + role: "quantity", + target_id: "m_quantity", + evidence_spans: [{ start: 9, end: 10, text: "3" }], + }, + ], + evidence_spans: [{ start: 0, end: 10, text: "Lena has 3" }], + }, + ], + contract_assessments: [ + { + candidate_organ: "quantity_entity_binding_candidate.v1", + family_id: "binding.quantity_entity", + missing_bindings: ["entity"], + unresolved_hazards: ["ambiguous_quantity"], + runnable: false, + explanation: "missing entity", + evidence_spans: [{ start: 0, end: 4, text: "Lena!" }], + }, + ], + diagnostic_only: true, + serving_allowed: false, +}; + +describe("ConstructionEvidencePanel", () => { + it("renders read-only construction detail sections without granting proposal authority", () => { + render( + String(error)} + />, + ); + + const panel = screen.getByTestId("construction-evidence-panel"); + expect(within(panel).getByText("Proposals")).toBeInTheDocument(); + expect(within(panel).getByText("Mentions")).toBeInTheDocument(); + expect(within(panel).getByText("Bindings")).toBeInTheDocument(); + expect(within(panel).getByText("Bound relations")).toBeInTheDocument(); + expect(within(panel).getByText("Contract assessments")).toBeInTheDocument(); + expect(within(panel).getByText("Source span checks")).toBeInTheDocument(); + + expect( + within(panel).getByText("diagnostic proposal only; contract assessment determines runnable status"), + ).toBeInTheDocument(); + expect(within(panel).getAllByText("false").length).toBeGreaterThan(0); + expect(within(panel).getAllByText(/quantity_entity_binding_candidate\.v1/).length).toBeGreaterThan(0); + expect(within(panel).getAllByText("0:4 Lena — exact").length).toBeGreaterThan(0); + expect(within(panel).getAllByText("0:4 Lena! — inexact").length).toBeGreaterThan(0); + expect(within(panel).getByText("curl /trace/7/construction")).toBeInTheDocument(); + }); +}); diff --git a/workbench-ui/src/app/trace/ConstructionEvidencePanel.tsx b/workbench-ui/src/app/trace/ConstructionEvidencePanel.tsx index 8d364c09..db0c854e 100644 --- a/workbench-ui/src/app/trace/ConstructionEvidencePanel.tsx +++ b/workbench-ui/src/app/trace/ConstructionEvidencePanel.tsx @@ -6,7 +6,10 @@ import { EmptyState } from "../../design/components/states/EmptyState"; import { ErrorState } from "../../design/components/states/ErrorState"; import { LoadingState } from "../../design/components/states/LoadingState"; import { traceConstructionReproducer } from "./constructionEvidenceEndpoint"; -import { constructionEvidencePanelModel } from "./constructionEvidencePanelModel"; +import { + type ConstructionEvidenceDetailSection, + constructionEvidencePanelModel, +} from "./constructionEvidencePanelModel"; export interface ConstructionEvidencePanelProps { evidence?: ConstructionEvidence | null; @@ -20,6 +23,36 @@ function rowValue(value: string) { return value; } +function DetailSection({ section }: { section: ConstructionEvidenceDetailSection }) { + return ( +
+

+ {section.title} +

+ {section.items.length === 0 ? ( +

{section.emptyMessage}

+ ) : ( +
+ {section.items.map((item) => ( +
+

+ {item.title} +

+ ({ + key: row.key, + value: row.value, + mono: true, + }))} + /> +
+ ))} +
+ )} +
+ ); +} + export function ConstructionEvidencePanel({ evidence, isLoading, @@ -91,6 +124,31 @@ export function ConstructionEvidencePanel({ /> ) : null} +
+ {model.detailSections.map((section) => ( + + ))} +
+ +
+

+ Source span checks +

+ {model.sourceSpanRows.length > 0 ? ( + ({ + key: row.key, + value: row.value, + mono: true, + }))} + /> + ) : ( +

+ No source spans recorded. +

+ )} +
+
reproducer diff --git a/workbench-ui/src/app/trace/constructionEvidencePanelModel.test.ts b/workbench-ui/src/app/trace/constructionEvidencePanelModel.test.ts index a911b46e..30ecfb63 100644 --- a/workbench-ui/src/app/trace/constructionEvidencePanelModel.test.ts +++ b/workbench-ui/src/app/trace/constructionEvidencePanelModel.test.ts @@ -17,6 +17,91 @@ const missingEvidence: ConstructionEvidence = { serving_allowed: false, }; +function recordedEvidence(): ConstructionEvidence { + return { + ...missingEvidence, + status: "recorded", + missing_reason: null, + problem_text: "Lena has 3 marbles.", + proposals: [ + { + family_id: "binding.quantity_entity", + relation_type: "quantity_entity", + candidate_organ: "quantity_entity_binding_candidate.v1", + status: "proposed", + evidence_spans: [{ start: 9, end: 10, text: "3" }], + role_obligations: [ + { + role: "quantity", + required: true, + description: "source quantity mention", + }, + { + role: "entity", + required: true, + description: "source entity mention", + }, + ], + diagnostic_only: true, + serving_allowed: false, + }, + ], + mentions: [ + { + mention_id: "m_lena", + kind: "entity", + surface: "Lena", + span: { start: 0, end: 4, text: "Lena" }, + fact_id: null, + }, + { + mention_id: "m_bad", + kind: "entity", + surface: "Lena", + span: { start: 0, end: 4, text: "Lena!" }, + fact_id: null, + }, + ], + bindings: [ + { + binding_type: "quantity_to_entity", + source_mention_id: "m_quantity", + target_mention_id: "m_lena", + evidence_spans: [{ start: 0, end: 10, text: "Lena has 3" }], + }, + ], + bound_relations: [ + { + relation_type: "quantity_entity", + roles: [ + { + role: "entity", + target_id: "m_lena", + evidence_spans: [{ start: 0, end: 4, text: "Lena" }], + }, + { + role: "quantity", + target_id: "m_quantity", + evidence_spans: [{ start: 9, end: 10, text: "3" }], + }, + ], + evidence_spans: [{ start: 0, end: 10, text: "Lena has 3" }], + }, + ], + contract_assessments: [ + { + candidate_organ: "quantity_entity_binding_candidate.v1", + family_id: "binding.quantity_entity", + missing_bindings: ["entity"], + unresolved_hazards: ["ambiguous_quantity"], + runnable: false, + explanation: "missing entity", + evidence_spans: [{ start: 0, end: 4, text: "Lena!" }], + }, + ], + }; +} + describe("construction evidence panel model", () => { it("models missing evidence honestly", () => { const model = constructionEvidencePanelModel(missingEvidence); @@ -38,27 +123,13 @@ describe("construction evidence panel model", () => { { key: "bound_relations", value: "0" }, { key: "contract_assessments", value: "0" }, ]); + expect(model.sourceSpanRows).toEqual([]); + expect(model.detailSections.find((section) => section.title === "Proposals")?.items).toEqual([]); expect(model.showRaw).toBe(false); }); it("models recorded contract assessment blockers", () => { - const model = constructionEvidencePanelModel({ - ...missingEvidence, - status: "recorded", - missing_reason: null, - problem_text: "Lena has 3 marbles.", - contract_assessments: [ - { - candidate_organ: "quantity_entity_binding_candidate.v1", - family_id: "binding.quantity_entity", - missing_bindings: ["entity"], - unresolved_hazards: ["ambiguous_quantity"], - runnable: false, - explanation: "missing entity", - evidence_spans: [], - }, - ], - }); + const model = constructionEvidencePanelModel(recordedEvidence()); expect(model.emptyMessage).toBe(null); expect(model.countRows.find((row) => row.key === "contract_assessments")?.value).toBe("1"); @@ -70,4 +141,49 @@ describe("construction evidence panel model", () => { ]); expect(model.showRaw).toBe(true); }); + + it("models proposals, mentions, bindings, bound relations, and exact span checks", () => { + const model = constructionEvidencePanelModel(recordedEvidence()); + + const proposals = model.detailSections.find((section) => section.title === "Proposals"); + expect(proposals?.items[0]).toMatchObject({ + title: "proposal 1: quantity_entity_binding_candidate.v1", + rows: expect.arrayContaining([ + { key: "status", value: "proposed" }, + { key: "diagnostic_only", value: "true" }, + { key: "serving_allowed", value: "false" }, + { + key: "authority", + value: "diagnostic proposal only; contract assessment determines runnable status", + }, + ]), + }); + + const mentions = model.detailSections.find((section) => section.title === "Mentions"); + expect(mentions?.items.map((item) => item.title)).toEqual([ + "mention m_lena: Lena", + "mention m_bad: Lena", + ]); + + const bindings = model.detailSections.find((section) => section.title === "Bindings"); + expect(bindings?.items[0].rows).toContainEqual({ + key: "target_mention_id", + value: "m_lena", + }); + + const relations = model.detailSections.find((section) => section.title === "Bound relations"); + expect(relations?.items[0].rows).toContainEqual({ + key: "roles", + value: "entity=m_lena, quantity=m_quantity", + }); + + expect(model.sourceSpanRows).toEqual( + expect.arrayContaining([ + { key: "proposal 1.1", value: "9:10 3 — exact" }, + { key: "mention m_lena", value: "0:4 Lena — exact" }, + { key: "mention m_bad", value: "0:4 Lena! — inexact" }, + { key: "contract_assessment 1.1", value: "0:4 Lena! — inexact" }, + ]), + ); + }); }); diff --git a/workbench-ui/src/app/trace/constructionEvidencePanelModel.ts b/workbench-ui/src/app/trace/constructionEvidencePanelModel.ts index 93d0f88e..c13c2bb6 100644 --- a/workbench-ui/src/app/trace/constructionEvidencePanelModel.ts +++ b/workbench-ui/src/app/trace/constructionEvidencePanelModel.ts @@ -1,8 +1,14 @@ -import type { ConstructionEvidence } from "../../types/constructionEvidence"; +import type { + BoundRelationRoleView, + ConstructionEvidence, + SourceSpanView, +} from "../../types/constructionEvidence"; import { assessmentBlockerSummary, assessmentDisposition, constructionEvidenceEmptyMessage, + sourceSpanIsExact, + sourceSpanLabel, } from "./constructionEvidenceView"; import { traceConstructionReproducer } from "./constructionEvidenceEndpoint"; @@ -11,6 +17,17 @@ export interface ConstructionEvidenceSummaryRow { value: string; } +export interface ConstructionEvidenceDetailItem { + title: string; + rows: ConstructionEvidenceSummaryRow[]; +} + +export interface ConstructionEvidenceDetailSection { + title: string; + emptyMessage: string; + items: ConstructionEvidenceDetailItem[]; +} + export interface ConstructionEvidencePanelModel { status: ConstructionEvidence["status"]; emptyMessage: string | null; @@ -18,9 +35,186 @@ export interface ConstructionEvidencePanelModel { authorityRows: ConstructionEvidenceSummaryRow[]; countRows: ConstructionEvidenceSummaryRow[]; assessmentRows: ConstructionEvidenceSummaryRow[]; + detailSections: ConstructionEvidenceDetailSection[]; + sourceSpanRows: ConstructionEvidenceSummaryRow[]; showRaw: boolean; } +function boolLabel(value: boolean): string { + return value ? "true" : "false"; +} + +function noneIfEmpty(values: readonly string[]): string { + return values.length > 0 ? values.join(", ") : "none"; +} + +function spanExactness(problemText: string | null, span: SourceSpanView): string { + const exactness = sourceSpanIsExact(problemText, span) ? "exact" : "inexact"; + return `${sourceSpanLabel(span)} — ${exactness}`; +} + +function spanList(problemText: string | null, spans: readonly SourceSpanView[]): string { + return spans.length > 0 + ? spans.map((span) => spanExactness(problemText, span)).join("; ") + : "none"; +} + +function roleList(roles: readonly BoundRelationRoleView[]): string { + return roles.length > 0 + ? roles.map((role) => `${role.role}=${role.target_id}`).join(", ") + : "none"; +} + +function sourceSpanRows(evidence: ConstructionEvidence): ConstructionEvidenceSummaryRow[] { + const rows: ConstructionEvidenceSummaryRow[] = []; + evidence.proposals.forEach((proposal, index) => { + proposal.evidence_spans.forEach((span, spanIndex) => { + rows.push({ + key: `proposal ${index + 1}.${spanIndex + 1}`, + value: spanExactness(evidence.problem_text, span), + }); + }); + }); + evidence.mentions.forEach((mention) => { + rows.push({ + key: `mention ${mention.mention_id}`, + value: spanExactness(evidence.problem_text, mention.span), + }); + }); + evidence.bindings.forEach((binding, index) => { + binding.evidence_spans.forEach((span, spanIndex) => { + rows.push({ + key: `binding ${index + 1}.${spanIndex + 1}`, + value: spanExactness(evidence.problem_text, span), + }); + }); + }); + evidence.bound_relations.forEach((relation, relationIndex) => { + relation.evidence_spans.forEach((span, spanIndex) => { + rows.push({ + key: `bound_relation ${relationIndex + 1}.${spanIndex + 1}`, + value: spanExactness(evidence.problem_text, span), + }); + }); + relation.roles.forEach((role, roleIndex) => { + role.evidence_spans.forEach((span, spanIndex) => { + rows.push({ + key: `bound_relation ${relationIndex + 1}.role ${roleIndex + 1}.${spanIndex + 1}`, + value: spanExactness(evidence.problem_text, span), + }); + }); + }); + }); + evidence.contract_assessments.forEach((assessment, index) => { + assessment.evidence_spans.forEach((span, spanIndex) => { + rows.push({ + key: `contract_assessment ${index + 1}.${spanIndex + 1}`, + value: spanExactness(evidence.problem_text, span), + }); + }); + }); + return rows; +} + +function detailSections(evidence: ConstructionEvidence): ConstructionEvidenceDetailSection[] { + return [ + { + title: "Proposals", + emptyMessage: "No construction proposals recorded.", + items: evidence.proposals.map((proposal, index) => ({ + title: `proposal ${index + 1}: ${proposal.candidate_organ}`, + rows: [ + { key: "family_id", value: proposal.family_id }, + { key: "relation_type", value: proposal.relation_type }, + { key: "status", value: proposal.status }, + { key: "diagnostic_only", value: boolLabel(proposal.diagnostic_only) }, + { key: "serving_allowed", value: boolLabel(proposal.serving_allowed) }, + { + key: "authority", + value: "diagnostic proposal only; contract assessment determines runnable status", + }, + { + key: "role_obligations", + value: + proposal.role_obligations.length > 0 + ? proposal.role_obligations + .map( + (role) => + `${role.role} ${role.required ? "required" : "optional"} — ${role.description}`, + ) + .join("; ") + : "none", + }, + { key: "evidence_spans", value: spanList(evidence.problem_text, proposal.evidence_spans) }, + ], + })), + }, + { + title: "Mentions", + emptyMessage: "No mentions recorded.", + items: evidence.mentions.map((mention) => ({ + title: `mention ${mention.mention_id}: ${mention.surface}`, + rows: [ + { key: "kind", value: mention.kind }, + { key: "surface", value: mention.surface }, + { key: "fact_id", value: mention.fact_id ?? "none" }, + { key: "span", value: spanExactness(evidence.problem_text, mention.span) }, + ], + })), + }, + { + title: "Bindings", + emptyMessage: "No mention bindings recorded.", + items: evidence.bindings.map((binding, index) => ({ + title: `binding ${index + 1}: ${binding.binding_type}`, + rows: [ + { key: "binding_type", value: binding.binding_type }, + { key: "source_mention_id", value: binding.source_mention_id }, + { key: "target_mention_id", value: binding.target_mention_id }, + { key: "evidence_spans", value: spanList(evidence.problem_text, binding.evidence_spans) }, + ], + })), + }, + { + title: "Bound relations", + emptyMessage: "No bound relations recorded.", + items: evidence.bound_relations.map((relation, index) => ({ + title: `bound relation ${index + 1}: ${relation.relation_type}`, + rows: [ + { key: "relation_type", value: relation.relation_type }, + { key: "roles", value: roleList(relation.roles) }, + { key: "evidence_spans", value: spanList(evidence.problem_text, relation.evidence_spans) }, + { + key: "role_spans", + value: + relation.roles.length > 0 + ? relation.roles + .map((role) => `${role.role}: ${spanList(evidence.problem_text, role.evidence_spans)}`) + .join("; ") + : "none", + }, + ], + })), + }, + { + title: "Contract assessments", + emptyMessage: "No contract assessments recorded.", + items: evidence.contract_assessments.map((assessment, index) => ({ + title: `assessment ${index + 1}: ${assessment.candidate_organ}`, + rows: [ + { key: "family_id", value: assessment.family_id ?? "unknown" }, + { key: "disposition", value: assessmentDisposition(assessment) }, + { key: "runnable", value: boolLabel(assessment.runnable) }, + { key: "missing_bindings", value: noneIfEmpty(assessment.missing_bindings) }, + { key: "unresolved_hazards", value: noneIfEmpty(assessment.unresolved_hazards) }, + { key: "explanation", value: assessment.explanation || "none" }, + { key: "evidence_spans", value: spanList(evidence.problem_text, assessment.evidence_spans) }, + ], + })), + }, + ]; +} + export function constructionEvidencePanelModel( evidence: ConstructionEvidence, ): ConstructionEvidencePanelModel { @@ -31,8 +225,8 @@ export function constructionEvidencePanelModel( authorityRows: [ { key: "schema_version", value: evidence.schema_version }, { key: "status", value: evidence.status }, - { key: "diagnostic_only", value: evidence.diagnostic_only ? "true" : "false" }, - { key: "serving_allowed", value: evidence.serving_allowed ? "true" : "false" }, + { key: "diagnostic_only", value: boolLabel(evidence.diagnostic_only) }, + { key: "serving_allowed", value: boolLabel(evidence.serving_allowed) }, { key: "missing_reason", value: evidence.missing_reason ?? "none" }, ], countRows: [ @@ -46,6 +240,8 @@ export function constructionEvidencePanelModel( key: `${assessment.candidate_organ}:${assessment.family_id ?? "unknown"}`, value: `${assessmentDisposition(assessment)} — ${assessmentBlockerSummary(assessment)}`, })), + detailSections: detailSections(evidence), + sourceSpanRows: sourceSpanRows(evidence), showRaw: evidence.status === "recorded", }; }