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.
This commit is contained in:
Shay 2026-06-23 15:03:38 -07:00 committed by GitHub
parent a19acac320
commit ff1ef10bd7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 507 additions and 21 deletions

View file

@ -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(
<ConstructionEvidencePanel
evidence={recordedEvidence}
isLoading={false}
error={null}
turnId={7}
errorMessage={(error) => 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();
});
});

View file

@ -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 className="grid gap-2 rounded-md border border-[var(--color-border-subtle)] p-3">
<h3 className="m-0 text-xs font-semibold uppercase text-[var(--color-text-secondary)]">
{section.title}
</h3>
{section.items.length === 0 ? (
<p className="m-0 text-sm text-[var(--color-text-secondary)]">{section.emptyMessage}</p>
) : (
<div className="grid gap-3">
{section.items.map((item) => (
<article className="grid gap-2" key={item.title}>
<h4 className="m-0 font-mono text-xs text-[var(--color-text-primary)]">
{item.title}
</h4>
<MetadataTable
rows={item.rows.map((row) => ({
key: row.key,
value: row.value,
mono: true,
}))}
/>
</article>
))}
</div>
)}
</section>
);
}
export function ConstructionEvidencePanel({
evidence,
isLoading,
@ -91,6 +124,31 @@ export function ConstructionEvidencePanel({
/>
) : null}
<div className="grid gap-3" data-testid="construction-evidence-details">
{model.detailSections.map((section) => (
<DetailSection key={section.title} section={section} />
))}
</div>
<section className="grid gap-2 rounded-md border border-[var(--color-border-subtle)] p-3">
<h3 className="m-0 text-xs font-semibold uppercase text-[var(--color-text-secondary)]">
Source span checks
</h3>
{model.sourceSpanRows.length > 0 ? (
<MetadataTable
rows={model.sourceSpanRows.map((row) => ({
key: row.key,
value: row.value,
mono: true,
}))}
/>
) : (
<p className="m-0 text-sm text-[var(--color-text-secondary)]">
No source spans recorded.
</p>
)}
</section>
<div>
<div className="mb-1 text-xs font-semibold text-[var(--color-text-secondary)]">
reproducer

View file

@ -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" },
]),
);
});
});

View file

@ -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",
};
}