Files
ThothII/frontend/src/viewers/SchemaLinkingViewer.tsx
T
marcopanandClaude Opus 4.8 bcaf6708de feat(frontend): English UI + 'Datamart Builder with Human In The Loop' tagline
The interface is now fully English regardless of the chat language (session
titles stay in whatever language the user typed). Changed the right-rail
subtitle to 'Datamart Builder with Human In The Loop'. Translated all chrome:
new-question/settings dialogs, workflow phase strip (F1-F8), session rail,
steer input, widgets (select/multiselect/freetext/fallback/reserved controls)
and viewers (SQL, results, schema linking). Test matchers updated in lockstep.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 18:27:11 +02:00

185 lines
5.5 KiB
TypeScript

import React, { useEffect, useState } from "react";
import { renderMermaid } from "./mermaid";
const CAP = 45;
export interface Candidate {
kind: "table" | "column";
name: string;
decision: "promoted" | "excluded" | "pending";
signals?: Record<string, unknown>;
evidence?: string[];
}
export interface Join {
from: string;
to: string;
source?: string;
}
export interface SchemaLinking {
question?: string;
candidates: Candidate[];
joins: Join[];
excluded: { kind: string; name: string }[];
open_questions: string[];
}
/** The inline "perché" comment for a candidate: evidence first, then signal keys. */
function reasonFor(c: Candidate): string {
if (c.evidence?.length) return c.evidence[0];
if (c.signals && Object.keys(c.signals).length)
return Object.keys(c.signals).join(", ");
return "";
}
function buildFlowchart(promoted: Candidate[], joins: Join[]): string {
const nodeId = (name: string) => name.replace(/[^a-zA-Z0-9]/g, "_");
const lines: string[] = ["flowchart TB"];
for (const c of promoted) {
const id = nodeId(c.name);
const reason = reasonFor(c);
const tooltip = reason ? ` %% ${reason}` : "";
const shape = c.kind === "table" ? `[${c.name}]` : `(${c.name})`;
lines.push(` ${id}${shape}${tooltip}`);
}
const promotedNames = new Set(promoted.map((c) => c.name));
for (const j of joins) {
if (promotedNames.has(j.from) && promotedNames.has(j.to)) {
lines.push(` ${nodeId(j.from)} --> ${nodeId(j.to)}`);
}
}
return lines.join("\n");
}
function buildHierarchicalRows(promoted: Candidate[]) {
const tables = promoted.filter((c) => c.kind === "table");
const columns = promoted.filter((c) => c.kind === "column");
// Group columns under their table (column name format: "table.column")
const grouped: { table: Candidate; columns: Candidate[] }[] = tables.map(
(t) => ({
table: t,
columns: columns.filter((col) => col.name.startsWith(t.name + ".")),
})
);
// Orphan columns not attached to any promoted table
const attachedCols = new Set(
grouped.flatMap((g) => g.columns.map((c) => c.name))
);
const orphans = columns.filter((c) => !attachedCols.has(c.name));
return { grouped, orphans };
}
export function SchemaLinkingViewer({ linking }: { linking: SchemaLinking }) {
const promoted = linking.candidates.filter((c) => c.decision === "promoted");
// The graph only contains promoted candidates, so cap on that count.
const oversized = promoted.length > CAP;
const [view, setView] = useState<"chart" | "table">(
oversized ? "table" : "chart"
);
const [svg, setSvg] = useState<string>("");
useEffect(() => {
if (view !== "chart" || oversized) return;
let cancelled = false;
const def = buildFlowchart(promoted, linking.joins);
renderMermaid(def).then((s) => {
if (!cancelled) setSvg(s);
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view, linking, oversized]);
const { grouped, orphans } = buildHierarchicalRows(promoted);
return (
<div className="schema-linking-viewer">
<div className="flex gap-2 mb-2">
<button
onClick={() => setView("chart")}
disabled={oversized}
className={view === "chart" ? "font-bold" : ""}
>
Graph
</button>
<button
onClick={() => setView("table")}
className={view === "table" ? "font-bold" : ""}
>
Table
</button>
</div>
{oversized && (
<p className="text-sm text-amber-600">
Too many elements for the graph, use the table
</p>
)}
{view === "chart" && !oversized && (
<div
className="mermaid-container overflow-auto"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: svg }}
/>
)}
{view === "table" && (
<table className="w-full text-sm border-collapse">
<thead>
<tr>
<th className="text-left border-b py-1">Name</th>
<th className="text-left border-b py-1">Type</th>
<th className="text-left border-b py-1">Why</th>
</tr>
</thead>
<tbody>
{grouped.map(({ table, columns: cols }) => (
<React.Fragment key={table.name}>
<tr className="font-semibold bg-muted/30">
<td className="py-1 pl-1">{table.name}</td>
<td>table</td>
<td className="text-xs text-muted-foreground">
{reasonFor(table)}
</td>
</tr>
{cols.map((col) => (
<tr key={col.name} className="pl-4">
<td className="py-0.5 pl-4 text-muted-foreground">
{col.name}
</td>
<td>column</td>
<td className="text-xs text-muted-foreground">
{reasonFor(col)}
</td>
</tr>
))}
</React.Fragment>
))}
{orphans.map((col) => (
<tr key={col.name}>
<td className="py-0.5 pl-1 text-muted-foreground">
{col.name}
</td>
<td>column</td>
<td className="text-xs text-muted-foreground">
{reasonFor(col)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}