-
navigate(`/sources/${projectName}`)}
- aria-label={t("common.back", "이전")}
- >
-
-
-
-
-
- {t("editor.title", "온톨로지 직접 편집")}
-
- {project && (
-
- {project.name}
-
- {" "}
- · {project.domain}
-
-
- )}
+ /* --------------------- Filtered data --------------------- */
+
+ const filteredEntities = useMemo(() => {
+ const q = entitySearch.trim().toLowerCase();
+ return (entities.data ?? []).filter((e) => {
+ if (entityTypeFilter && e.type !== entityTypeFilter) return false;
+ if (!q) return true;
+ return (
+ e.name.toLowerCase().includes(q) || e.type.toLowerCase().includes(q)
+ );
+ });
+ }, [entities.data, entitySearch, entityTypeFilter]);
+
+ const entityTypeCounts = useMemo(() => {
+ const counts = new Map
();
+ (entities.data ?? []).forEach((e) =>
+ counts.set(e.type, (counts.get(e.type) ?? 0) + 1),
+ );
+ return counts;
+ }, [entities.data]);
+
+ const filteredClaims = useMemo(() => {
+ const q = claimSearch.trim().toLowerCase();
+ return (claims.data ?? []).filter((c) => {
+ if (!q) return true;
+ return [c.subject, c.predicate, c.object, c.object_value, c.source]
+ .map((v) => humanizeValue(v, "").toLowerCase())
+ .some((v) => v.includes(q));
+ });
+ }, [claims.data, claimSearch]);
+
+ /* --------------------- Entity table --------------------- */
+
+ const entityColumns: ColumnDef[] = [
+ {
+ id: "type",
+ header: "Type",
+ accessorFn: (row) => row.type,
+ cell: ({ row }) => {row.original.type} ,
+ size: 140,
+ enablePinning: true,
+ },
+ {
+ id: "name",
+ header: "Name",
+ accessorFn: (row) => row.name,
+ cell: ({ row }) => (
+ {row.original.name}
+ ),
+ size: 360,
+ },
+ {
+ id: "id",
+ header: "ID",
+ accessorFn: (row) => row.id,
+ cell: ({ row }) => (
+
+ #{row.original.id}
+
+ ),
+ size: 100,
+ meta: { align: "right" },
+ },
+ {
+ id: "actions",
+ header: "",
+ enableSorting: false,
+ enableHiding: false,
+ enableResizing: false,
+ cell: ({ row }) => (
+
+
+ {
+ e.stopPropagation();
+ if (
+ confirm(
+ t(
+ "editor.confirmDeleteEntity",
+ "엔티티 '{{name}}'을 삭제하시겠습니까?",
+ { name: row.original.name },
+ ),
+ )
+ ) {
+ deleteEntity.mutate(row.original.id);
+ }
+ }}
+ disabled={deleteEntity.isPending}
+ className="h-7 w-7"
+ >
+
+
+
+ ),
+ size: 60,
+ meta: { align: "right" },
+ },
+ ];
+
+ /* --------------------- Claim table --------------------- */
+
+ const claimColumns: ColumnDef[] = [
+ {
+ id: "subject",
+ header: "Subject",
+ accessorFn: (row) => humanizeValue(row.subject),
+ cell: ({ row }) => (
+
+ {humanizeValue(row.original.subject)}
+
+ ),
+ size: 200,
+ enablePinning: true,
+ },
+ {
+ id: "predicate",
+ header: "Predicate",
+ accessorFn: (row) => row.predicate,
+ cell: ({ row }) => (
+
+ {row.original.predicate}
+
+ ),
+ size: 160,
+ },
+ {
+ id: "object",
+ header: "Object",
+ accessorFn: (row) =>
+ humanizeValue(row.object ?? row.object_value),
+ cell: ({ row }) => (
+
+ {humanizeValue(row.original.object ?? row.original.object_value)}
+
+ ),
+ size: 260,
+ },
+ {
+ id: "source",
+ header: "Source",
+ accessorFn: (row) => row.source ?? "",
+ cell: ({ row }) => (
+
+ {humanizeValue(row.original.source)}
+
+ ),
+ size: 120,
+ },
+ {
+ id: "confidence",
+ header: "Confidence",
+ accessorFn: (row) => row.confidence ?? 0,
+ cell: ({ row }) =>
+ typeof row.original.confidence === "number" ? (
+
+ {(row.original.confidence * 100).toFixed(0)}%
+
+ ) : (
+ —
+ ),
+ size: 110,
+ meta: { align: "right" },
+ },
+ {
+ id: "status",
+ header: "Status",
+ accessorFn: (row) => row.status ?? "",
+ cell: ({ row }) =>
+ row.original.status ? (
+ {row.original.status}
+ ) : (
+ —
+ ),
+ size: 130,
+ },
+ {
+ id: "actions",
+ header: "",
+ enableSorting: false,
+ enableHiding: false,
+ enableResizing: false,
+ cell: ({ row }) => (
+
+
+ {
+ e.stopPropagation();
+ if (
+ confirm(
+ t(
+ "editor.confirmDeleteClaim",
+ "클레임을 삭제하시겠습니까?",
+ ),
+ )
+ ) {
+ deleteClaim.mutate(row.original.id);
+ }
+ }}
+ className="h-7 w-7"
+ >
+
+
+
+
+ ),
+ size: 60,
+ meta: { align: "right" },
+ },
+ ];
+
+ /* --------------------- Chips --------------------- */
+
+ const entityChips: ActiveChip[] = [];
+ if (entitySearch)
+ entityChips.push({
+ id: "search",
+ label: "Search",
+ value: `"${entitySearch}"`,
+ onClear: () => setEntitySearch(""),
+ });
+ if (entityTypeFilter)
+ entityChips.push({
+ id: "type",
+ label: "Type",
+ value: entityTypeFilter,
+ onClear: () => setEntityTypeFilter(""),
+ });
+
+ /* --------------------- Render --------------------- */
+
+ return (
+ <>
+
+ {tab === "entities" && (
+ setAddEntityOpen(true)}>
+
+ {t("editor.addEntity", "엔티티 추가")}
+
+ )}
+ {tab === "claims" && (
+ setAddClaimOpen(true)}>
+
+ {t("editor.addClaim", "클레임 추가")}
+
+ )}
+ >
+ }
+ />
+
+
+ {isError && (
+
refetch()}
+ />
+ )}
+
+ setTab(v as typeof tab)}>
+
+
+ {t("editor.entitiesTab", "엔티티")} · {entities.data?.length ?? 0}
+
+
+ {t("editor.claimsTab", "클레임")} · {claims.data?.length ?? 0}
+
+
+
+ {t("editor.bulkTab", "JSON 일괄 입력")}
+
+
+
+ {/* ── Entities Tab ──────────────────────────────────── */}
+
+ 0 ? : undefined
+ }
+ onClearAll={
+ entityChips.length > 0
+ ? () => {
+ setEntitySearch("");
+ setEntityTypeFilter("");
+ }
+ : undefined
+ }
+ >
+
+ ({
+ value: t,
+ label: t,
+ hint: entityTypeCounts.get(t) ?? 0,
+ }))}
+ />
+
+
+
+ tableId="entities"
+ columns={entityColumns}
+ data={filteredEntities}
+ getRowId={(row) => String(row.id)}
+ loading={entities.isLoading}
+ loadingRows={8}
+ height={620}
+ empty={
+ setAddEntityOpen(true)}>
+
+ {t("editor.addEntity", "엔티티 추가")}
+
+ }
+ />
+ }
+ />
+
+
+ {/* ── Claims Tab ────────────────────────────────────── */}
+
+ setClaimSearch(""),
+ },
+ ]}
+ />
+ ) : undefined
+ }
+ onClearAll={claimSearch ? () => setClaimSearch("") : undefined}
+ >
+
+
+
+
+ tableId="editor-claims"
+ columns={claimColumns}
+ data={filteredClaims}
+ getRowId={(row) => row.id}
+ loading={claims.isLoading}
+ loadingRows={8}
+ height={620}
+ empty={
+ setAddClaimOpen(true)}>
+
+ {t("editor.addClaim", "클레임 추가")}
+
+ }
+ />
+ }
+ />
+
+
+ {/* ── Bulk Tab ──────────────────────────────────────── */}
+
+
+
+ {t("editor.bulkTitle", "JSON 일괄 입력")}
+
+ {t(
+ "editor.bulkDesc",
+ "{ entities: [{ entity_type, name, metadata? }] } 형태의 JSON",
+ )}
+
+
+
+
+
+
+
- {isError && (
-
-
-
-
-
{(error as Error).message}
-
- refetch()}
- >
- {t("common.retry", "다시 시도")}
+ {/* ── Add Entity Drawer ─────────────────────────────────── */}
+
+ setAddEntityOpen(false)}>
+ {t("common.cancel", "취소")}
-
-
- )}
-
- setTab(v as typeof tab)}>
-
-
- {t("editor.entitiesTab", "엔티티")} ({entities.data?.length ?? 0})
-
-
-
- {t("editor.claimsTab", "클레임")} ({claims.data?.length ?? 0})
-
-
-
- {t("editor.bulkTab", "JSON 일괄 입력")}
-
-
-
- {/* ── Entities Tab ─────────────────────────────────────── */}
-
-
-
-
- {t("editor.addEntity", "엔티티 추가")}
-
- {t(
- "editor.addEntityDesc",
- "온톨로지 도메인의 entity_types 중에서 선택",
- )}
-
-
-
-
-
-
-
-
-
-
- {t("editor.entitiesList", "엔티티 목록")}
-
-
- {entities.data
- ? t("editor.entityCount", "{{count}}개", {
- count: entities.data.length,
- })
- : t("dashboard.loadFailed", "")}
-
-
-
- {entities.isLoading && (
-
- {Array.from({ length: 4 }).map((_, i) => (
-
- ))}
-
- )}
- {entities.data && entities.data.length === 0 && (
-
- {t("editor.entitiesEmpty", "아직 등록된 엔티티가 없습니다")}
-
- )}
- {entities.data && entities.data.length > 0 && (
-
- {entities.data.map((e) => (
-
-
-
- {e.type}
-
- {e.name}
-
-
-
- {
- if (
- confirm(
- t(
- "editor.confirmDeleteEntity",
- "엔티티 '{{name}}'을 삭제하시겠습니까?",
- { name: e.name },
- ),
- )
- ) {
- deleteEntity.mutate(e.id);
- }
- }}
- disabled={deleteEntity.isPending}
- >
-
-
-
- ))}
-
- )}
-
-
-
-
-
- {/* ── Claims Tab ───────────────────────────────────────── */}
-
-
-
-
- {t("editor.addClaim", "클레임 추가")}
-
- {t(
- "editor.addClaimDesc",
- "주어-술어-목적어 형태로 직접 입력",
- )}
-
-
-
-
-
-
-
-
-
- {t("editor.claimsList", "클레임 목록")}
-
- {claims.data &&
- t("editor.claimCount", "{{count}}개", {
- count: claims.data.length,
- })}
-
-
-
- {claims.isLoading && (
-
- {Array.from({ length: 3 }).map((_, i) => (
-
- ))}
-
- )}
- {claims.data && claims.data.length === 0 && (
-
- {t("editor.claimsEmpty", "아직 등록된 클레임이 없습니다")}
-
- )}
- {claims.data && claims.data.length > 0 && (
-
- {claims.data.map((c) => (
-
-
-
-
- {c.subject ?? "?"}
-
- {c.predicate}
-
- {c.object ?? String(c.object_value ?? "—")}
-
-
-
-
- {t("editor.source", "소스")}: {c.source ?? "—"}
-
- {typeof c.confidence === "number" && (
-
- {t("editor.confidence", "신뢰도")}:{" "}
- {c.confidence.toFixed(2)}
-
- )}
- {c.status && (
- {c.status}
- )}
-
-
- {
- if (
- confirm(
- t(
- "editor.confirmDeleteClaim",
- "클레임을 삭제하시겠습니까?",
- ),
- )
- ) {
- deleteClaim.mutate(c.id);
- }
- }}
- >
-
-
-
- ))}
-
- )}
-
-
-
-
-
- {/* ── Bulk Tab ─────────────────────────────────────────── */}
-
-
-
- {t("editor.bulkTitle", "JSON 일괄 입력")}
-
- {t(
- "editor.bulkDesc",
- "{ entities: [{ entity_type, name, metadata? }] } 형태의 JSON",
- )}
-
-
-
-
+ ) : (
+
+ {t("editor.objectValue", "값")}
+
+
+ )}
+
+
+ {t("editor.confidence", "신뢰도")}
+
+
+
+
+ >
);
}
diff --git a/ontology_platform/web/frontend/src/pages/QualityInspectorPage.tsx b/ontology_platform/web/frontend/src/pages/QualityInspectorPage.tsx
index bf6e9d2..6f348dd 100644
--- a/ontology_platform/web/frontend/src/pages/QualityInspectorPage.tsx
+++ b/ontology_platform/web/frontend/src/pages/QualityInspectorPage.tsx
@@ -1,24 +1,34 @@
-import { useMemo } from "react";
+import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
- AlertCircle,
AlertTriangle,
- ArrowLeft,
ClipboardCheck,
- Link,
+ Link as LinkIcon,
+ RefreshCw,
ShieldAlert,
} from "lucide-react";
import {
Card,
CardContent,
- CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge, BadgeProps } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
-import { Skeleton } from "@/components/ui/skeleton";
+import { DataTable, type ColumnDef } from "@/components/ui/data-table";
+import { Drawer } from "@/components/ui/drawer";
+import { EmptyState } from "@/components/ui/empty-state";
+import { ErrorState } from "@/components/ui/error-state";
+import { Tooltip } from "@/components/ui/tooltip";
+import {
+ FilterPanel,
+ FilterSearch,
+ FilterSelect,
+ FilterChips,
+ type ActiveChip,
+} from "@/components/ui/filter-panel";
+import { PageHeader } from "@/components/layout/PageHeader";
import { useClaims } from "@/hooks/useClaims";
import { useEntities } from "@/hooks/useEntities";
import { useProject } from "@/hooks/useProjects";
@@ -28,6 +38,10 @@ import { Entity } from "@/lib/api/entities";
import { OntologyRegistry } from "@/lib/api/platform";
import { formatPercent, humanizeValue } from "@/lib/display";
+/* ============================================================
+ * Issue model
+ * ========================================================== */
+
interface QualityIssue {
id: string;
type: string;
@@ -67,32 +81,26 @@ function severityVariant(severity: QualityIssue["severity"]): BadgeProps["varian
function normalizedName(value: unknown): string {
return humanizeValue(value).trim().toLowerCase().replace(/\s+/g, " ");
}
-
function claimObject(claim: Claim): string {
return humanizeValue(claim.object ?? claim.object_value);
}
-
function claimValueType(claim: Claim): string | undefined {
const valueType = (claim as Claim & { value_type?: unknown }).value_type;
return typeof valueType === "string" && valueType.trim()
? valueType.trim()
: undefined;
}
-
function relationKey(claim: Claim): string {
return `${claim.subject ?? ""}|${claim.predicate}|${claimObject(claim)}`.toLowerCase();
}
-
function allowedIncludes(values: string[], value: string | undefined | null) {
if (!value) return false;
const normalized = value.toLowerCase();
return values.some((item) => item.toLowerCase() === normalized);
}
-
function listLabel(values: string[]) {
return values.length ? values.join(", ") : "-";
}
-
function ruleNumber(
record: Record
| undefined,
keys: string[],
@@ -104,7 +112,6 @@ function ruleNumber(
}
return undefined;
}
-
function minConfidenceFor(rule: RelationRule | undefined): number {
return (
ruleNumber(rule?.confidence_rules, [
@@ -114,7 +121,6 @@ function minConfidenceFor(rule: RelationRule | undefined): number {
]) ?? 0.7
);
}
-
function daysSince(value: string | null | undefined): number | undefined {
if (!value) return undefined;
const time = new Date(value).getTime();
@@ -122,6 +128,10 @@ function daysSince(value: string | null | undefined): number | undefined {
return (Date.now() - time) / 86_400_000;
}
+/* ============================================================
+ * Issue computation — same logic as before, factored unchanged
+ * ========================================================== */
+
function buildIssues(
claims: Claim[],
entities: Entity[],
@@ -170,7 +180,6 @@ function buildIssues(
target,
});
}
-
if (!sourceUrl || !evidence) {
issues.push({
id: `source-${claim.id}`,
@@ -190,7 +199,6 @@ function buildIssues(
target,
});
}
-
if (evidence && claim.subject && object) {
const evidenceLower = evidence.toLowerCase();
const subjectLower = normalizedName(claim.subject);
@@ -205,12 +213,12 @@ function buildIssues(
type: "weak_evidence_alignment",
severity: "low",
title: "Evidence does not mention the relation terms",
- detail: "The snippet may support the page generally, but not this exact relation.",
+ detail:
+ "The snippet may support the page generally, but not this exact relation.",
target,
});
}
}
-
const minConfidence = minConfidenceFor(rule);
if (typeof claim.confidence !== "number") {
issues.push({
@@ -225,13 +233,15 @@ function buildIssues(
issues.push({
id: `confidence-${claim.id}`,
type: "low_confidence",
- severity: claim.confidence < Math.max(0.45, minConfidence - 0.25) ? "high" : "medium",
+ severity:
+ claim.confidence < Math.max(0.45, minConfidence - 0.25)
+ ? "high"
+ : "medium",
title: "Confidence is below rule threshold",
detail: `${formatPercent(claim.confidence)} is below ${formatPercent(minConfidence)}.`,
target,
});
}
-
if (rule?.allowed_subject_types.length) {
if (!subjectType) {
issues.push({
@@ -253,7 +263,6 @@ function buildIssues(
});
}
}
-
if (rule?.allowed_object_types.length) {
if (!objectType) {
issues.push({
@@ -275,7 +284,6 @@ function buildIssues(
});
}
}
-
if (rule?.allowed_page_types.length) {
if (!claim.page_type) {
issues.push({
@@ -297,7 +305,6 @@ function buildIssues(
});
}
}
-
if (rule?.allowed_source_zones.length) {
if (!claim.source_zone) {
issues.push({
@@ -308,7 +315,9 @@ function buildIssues(
detail: `Allowed source zones: ${listLabel(rule.allowed_source_zones)}.`,
target,
});
- } else if (!allowedIncludes(rule.allowed_source_zones, claim.source_zone)) {
+ } else if (
+ !allowedIncludes(rule.allowed_source_zones, claim.source_zone)
+ ) {
issues.push({
id: `source-zone-${claim.id}`,
type: "source_zone_mismatch",
@@ -319,7 +328,6 @@ function buildIssues(
});
}
}
-
if (
claim.validation_status &&
!okValidationStatuses.has(claim.validation_status.toLowerCase())
@@ -333,7 +341,6 @@ function buildIssues(
target,
});
}
-
if (
claim.graph_merge_status &&
!okGraphStatuses.has(claim.graph_merge_status.toLowerCase())
@@ -347,18 +354,17 @@ function buildIssues(
target,
});
}
-
if (claim.review_required || claim.conflict_status) {
issues.push({
id: `review-${claim.id}`,
type: "review_required",
severity: claim.conflict_status ? "high" : "medium",
title: "Human review is required",
- detail: claim.review_reason || claim.conflict_status || "review required",
+ detail:
+ claim.review_reason || claim.conflict_status || "review required",
target,
});
}
-
const staleDays = daysSince(claim.last_seen_at);
if (staleDays === undefined) {
issues.push({
@@ -379,7 +385,6 @@ function buildIssues(
target,
});
}
-
const subjectLabel = normalizedName(claim.subject);
const objectLabel = normalizedName(object);
if (genericLabels.has(subjectLabel) || subjectLabel.length <= 1) {
@@ -388,7 +393,8 @@ function buildIssues(
type: "generic_subject",
severity: "medium",
title: "Subject label is too generic",
- detail: "Generic entity labels should be normalized before graph commit.",
+ detail:
+ "Generic entity labels should be normalized before graph commit.",
target,
});
}
@@ -398,11 +404,11 @@ function buildIssues(
type: "generic_object",
severity: "low",
title: "Object label is too generic",
- detail: "The object value may need a more specific entity or literal label.",
+ detail:
+ "The object value may need a more specific entity or literal label.",
target,
});
}
-
const key = relationKey(claim);
const existing = seenRelations.get(key);
if (existing) {
@@ -420,7 +426,6 @@ function buildIssues(
if (claim.subject) connectedNames.add(normalizedName(claim.subject));
if (object) connectedNames.add(normalizedName(object));
}
-
for (const group of entityNameGroups.values()) {
if (group.length > 1) {
issues.push({
@@ -433,7 +438,6 @@ function buildIssues(
});
}
}
-
for (const entity of entities) {
const entityName = normalizedName(entity.name);
if (!connectedNames.has(entityName)) {
@@ -442,11 +446,14 @@ function buildIssues(
type: "isolated_entity",
severity: "low",
title: "Entity is isolated",
- detail: "No relation currently connects to this entity in the loaded claim set.",
+ detail:
+ "No relation currently connects to this entity in the loaded claim set.",
target: `[${entity.type}] ${entity.name}`,
});
}
- if (["entity", "unknown", "unknown_entity"].includes(entity.type.toLowerCase())) {
+ if (
+ ["entity", "unknown", "unknown_entity"].includes(entity.type.toLowerCase())
+ ) {
issues.push({
id: `generic-entity-type-${entity.id}`,
type: "generic_entity_type",
@@ -457,7 +464,6 @@ function buildIssues(
});
}
}
-
return issues;
}
@@ -471,6 +477,10 @@ function countByType(issues: QualityIssue[]) {
.sort((a, b) => b.count - a.count);
}
+/* ============================================================
+ * Page
+ * ========================================================== */
+
export default function QualityInspectorPage() {
const navigate = useNavigate();
const { projectId } = useParams<{ projectId: string }>();
@@ -484,6 +494,11 @@ export default function QualityInspectorPage() {
const registry = useOntologyRegistry(projectName);
const pipeline = usePipeline(projectName);
+ const [severityFilter, setSeverityFilter] = useState("");
+ const [typeFilter, setTypeFilter] = useState("");
+ const [search, setSearch] = useState("");
+ const [selectedIssue, setSelectedIssue] = useState(null);
+
const issues = useMemo(
() =>
buildIssues(
@@ -493,14 +508,28 @@ export default function QualityInspectorPage() {
),
[claims.data, entities.data, registry.data?.relation_types],
);
- const high = issues.filter((issue) => issue.severity === "high").length;
- const medium = issues.filter((issue) => issue.severity === "medium").length;
- const low = issues.filter((issue) => issue.severity === "low").length;
+
+ const filteredIssues = useMemo(() => {
+ const q = search.trim().toLowerCase();
+ return issues.filter((issue) => {
+ if (severityFilter && issue.severity !== severityFilter) return false;
+ if (typeFilter && issue.type !== typeFilter) return false;
+ if (!q) return true;
+ return [issue.title, issue.detail, issue.target, issue.type]
+ .map((v) => v.toLowerCase())
+ .some((v) => v.includes(q));
+ });
+ }, [issues, severityFilter, typeFilter, search]);
+
+ const high = issues.filter((i) => i.severity === "high").length;
+ const medium = issues.filter((i) => i.severity === "medium").length;
+ const low = issues.filter((i) => i.severity === "low").length;
const score = Math.max(
0,
Math.round(100 - high * 10 - medium * 5 - low * 1.5),
);
const issueCounts = countByType(issues);
+
const relationRuleNames = new Set(
(registry.data?.relation_types ?? []).map((row) => row.name),
);
@@ -517,274 +546,515 @@ export default function QualityInspectorPage() {
const reviewRate = claims.data?.length
? reviewedCount / claims.data.length
: 0;
- const schemaIssueCount = issues.filter((issue) =>
- [
- "unknown_predicate",
- "subject_type_mismatch",
- "object_type_mismatch",
- "page_type_mismatch",
- "source_zone_mismatch",
- "schema_violation",
- ].includes(issue.type),
- ).length;
- const evidenceIssueCount = issues.filter((issue) =>
- ["missing_source", "thin_evidence", "weak_evidence_alignment"].includes(
- issue.type,
- ),
- ).length;
- const visibleIssues = issues.slice(0, 250);
+
+ const isLoading =
+ claims.isLoading || entities.isLoading || registry.isLoading;
+ const errorObj =
+ (claims.error as Error | undefined) ??
+ (entities.error as Error | undefined) ??
+ (registry.error as Error | undefined);
+
+ /* ----- Filter chips ----- */
+ const chips: ActiveChip[] = [];
+ if (search)
+ chips.push({
+ id: "search",
+ label: "Search",
+ value: `"${search}"`,
+ onClear: () => setSearch(""),
+ });
+ if (severityFilter)
+ chips.push({
+ id: "sev",
+ label: "Severity",
+ value: severityFilter,
+ onClear: () => setSeverityFilter(""),
+ });
+ if (typeFilter)
+ chips.push({
+ id: "type",
+ label: "Type",
+ value: typeFilter,
+ onClear: () => setTypeFilter(""),
+ });
+ const clearAll = () => {
+ setSearch("");
+ setSeverityFilter("");
+ setTypeFilter("");
+ };
+
+ /* ----- Table columns ----- */
+ const columns: ColumnDef[] = [
+ {
+ id: "severity",
+ header: "Severity",
+ accessorFn: (row) =>
+ row.severity === "high" ? 0 : row.severity === "medium" ? 1 : 2,
+ cell: ({ row }) => (
+
+ {row.original.severity}
+
+ ),
+ size: 110,
+ enablePinning: true,
+ },
+ {
+ id: "type",
+ header: "Type",
+ accessorFn: (row) => row.type,
+ cell: ({ row }) => (
+ {row.original.type}
+ ),
+ size: 200,
+ },
+ {
+ id: "title",
+ header: "Title",
+ accessorFn: (row) => row.title,
+ cell: ({ row }) => (
+ {row.original.title}
+ ),
+ size: 280,
+ },
+ {
+ id: "target",
+ header: "Target",
+ accessorFn: (row) => row.target,
+ cell: ({ row }) => (
+
+ {row.original.target}
+
+ ),
+ size: 320,
+ enableSorting: false,
+ },
+ ];
return (
-
-
-
navigate(`/review/${projectName}`)}
- aria-label="Back"
- >
-
-
-
-
-
- Quality Inspector
-
-
- {project?.name ?? projectName} schema, evidence, duplicate, and graph readiness checks.
-
+ <>
+
+ {
+ claims.refetch();
+ entities.refetch();
+ registry.refetch();
+ pipeline.refetch();
+ }}
+ >
+
+ Refresh
+
+
+ }
+ />
+
+
+ {errorObj && (
+
{
+ claims.refetch();
+ entities.refetch();
+ registry.refetch();
+ }}
+ />
+ )}
+
+ {/* Top metrics */}
+
+
+
+
+ Quality Score
+
+
+
+ {score}
+
+ / 100
+
+
+
+
+
+
+
+
= 0.8 ? "success" : "warning"}
+ />
- {
- claims.refetch();
- entities.refetch();
- registry.refetch();
- pipeline.refetch();
- }}
- >
- Refresh
-
-
- {(claims.isError || entities.isError || registry.isError) && (
-
-
-
- {(claims.error as Error | undefined)?.message ??
- (entities.error as Error | undefined)?.message ??
- (registry.error as Error | undefined)?.message}
-
-
- )}
+
+
+
0 ? : undefined}
+ onClearAll={chips.length > 0 ? clearAll : undefined}
+ >
+
+
+ ({
+ value: type,
+ label: type,
+ hint: count,
+ }))}
+ />
+
-
-
-
- Quality score
- {score}
-
-
-
-
-
-
-
-
+
+ tableId="quality-issues"
+ columns={columns}
+ data={filteredIssues}
+ getRowId={(row) => row.id}
+ loading={isLoading}
+ loadingRows={8}
+ height={560}
+ onRowClick={(row) => setSelectedIssue(row)}
+ rowToneAccessor={(row) =>
+ row.severity === "high"
+ ? "danger"
+ : row.severity === "medium"
+ ? "warning"
+ : undefined
+ }
+ empty={
+ 0
+ ? "All issues filtered out. Try clearing filters."
+ : "The loaded scope is clean. Great work."
+ }
+ primaryAction={
+ chips.length > 0 ? (
+
+ Clear filters
+
+ ) : undefined
+ }
+ />
+ }
+ />
+
-
-
-
-
- Validation Issues
- {issues.length > visibleIssues.length && (
-
- Showing {visibleIssues.length} of {issues.length}
-
- )}
-
-
- Rule-based warnings generated from claim evidence and ontology registry constraints.
-
-
-
- {(claims.isLoading || entities.isLoading || registry.isLoading) && (
-
- {Array.from({ length: 5 }).map((_, index) => (
-
- ))}
-
- )}
- {!claims.isLoading &&
- !entities.isLoading &&
- !registry.isLoading &&
- issues.length === 0 && (
-
-
- No quality warnings were found in the loaded scope.
-
- )}
-
- {visibleIssues.map((issue) => (
-
-
-
- {issue.severity}
-
- {issue.type}
- {issue.title}
-
-
- {issue.detail}
-
-
- {issue.target}
-
-
- ))}
-
-
-
-
-
-
-
-
-
- Schema Coverage
-
-
- Predicate and type coverage against the ontology registry.
-
-
-
-
-
-
-
- Unknown predicates
-
- {unknownPredicates.length}
-
-
-
- {unknownPredicates.map((predicate) => (
-
- {predicate}
-
- ))}
- {unknownPredicates.length === 0 && (
-
- All loaded predicates are registered.
+ {/* Right rail */}
+
+
+
+
+
+ Schema Coverage
+
+
+
+
+
+ [
+ "unknown_predicate",
+ "subject_type_mismatch",
+ "object_type_mismatch",
+ "page_type_mismatch",
+ "source_zone_mismatch",
+ "schema_violation",
+ ].includes(i.type),
+ ).length
+ }
+ />
+
+ [
+ "missing_source",
+ "thin_evidence",
+ "weak_evidence_alignment",
+ ].includes(i.type),
+ ).length
+ }
+ />
+
+
+ Unknown predicates
- )}
-
-
-
-
-
-
-
-
- Graph Readiness
-
- Build output ready for graph and exports.
-
-
- stage.key === "crawled")
- ?.count ?? 0
- }
- />
- stage.key === "validated")
- ?.count ?? 0
- }
- />
- stage.key === "graph")
- ?.count ?? 0
- }
- />
-
-
-
-
-
-
-
- Issue Mix
-
-
-
-
- {issueCounts.slice(0, 8).map((item) => (
-
- {item.type}
- {item.count}
-
- ))}
- {issueCounts.length === 0 && (
-
- No issues in the loaded scope.
-
- )}
-
-
-
-
-
-
- Recommended Actions
-
-
-
- {high > 0 && (
- Resolve missing evidence, type violations, and conflicts before export.
- )}
- {medium > 0 && (
- Review stale claims, thin evidence, duplicate relations, and missing rule fields.
- )}
+ {unknownPredicates.length}
+
+
{unknownPredicates.length > 0 && (
- Add missing predicate rules in Schema Designer.
+
+ {unknownPredicates.slice(0, 12).map((predicate) => (
+
+ {predicate}
+
+ ))}
+
)}
- {score >= 90 && (
- The ontology is ready for graph inspection and Export/API handoff.
- )}
-
-
-
+
+
+
+
+
+
+
+ Graph Readiness
+
+
+
+ s.key === "crawled")
+ ?.count ?? 0
+ }
+ />
+ s.key === "validated")
+ ?.count ?? 0
+ }
+ />
+ s.key === "graph")
+ ?.count ?? 0
+ }
+ />
+
+
+
+
+
+
+
+ Issue Mix
+
+
+
+
+ {issueCounts.slice(0, 8).map((item) => (
+
+ setTypeFilter(item.type)}
+ className="truncate text-left font-mono text-xs hover:underline"
+ title="Filter by this type"
+ >
+ {item.type}
+
+
+ {item.count}
+
+
+ ))}
+ {issueCounts.length === 0 && (
+
+ No issues in scope.
+
+ )}
+
+
+
+
+
+
+ Recommendations
+
+
+
+ {high > 0 && (
+
+ Resolve missing evidence, type violations, and conflicts before export.
+
+ )}
+ {medium > 0 && (
+
+ Review stale claims, thin evidence, duplicates, and missing rule fields.
+
+ )}
+ {unknownPredicates.length > 0 && (
+
+ Add missing predicate rules in{" "}
+ navigate(`/schema/${projectName}`)}
+ className="text-brand-600 hover:underline dark:text-brand-300"
+ >
+ Schema Designer
+
+ .
+
+ )}
+ {score >= 90 && (
+
+ The ontology is ready for graph inspection and Export/API handoff.
+
+ )}
+ {issues.length === 0 && !isLoading && (
+
+ No warnings — quality looks great.
+
+ )}
+
+
+
+
-
+
+ {/* Issue detail Drawer */}
+ {
+ if (!open) setSelectedIssue(null);
+ }}
+ size="md"
+ title={selectedIssue?.title ?? ""}
+ description="Issue detail and remediation"
+ headerExtra={
+ selectedIssue ? (
+
+ {selectedIssue.severity}
+
+ ) : null
+ }
+ footer={
+ selectedIssue ? (
+ navigate(`/review/${projectName}`)}
+ >
+ Open in Claim Review
+
+ ) : null
+ }
+ >
+ {selectedIssue && (
+
+
+
+ Type
+
+
{selectedIssue.type}
+
+
+
+
+ Detail
+
+
+ {selectedIssue.detail}
+
+
+
+
+
+ Target
+
+
+ {selectedIssue.target}
+
+
+
+
+
+ Suggested action
+
+
+ {selectedIssue.severity === "high"
+ ? "Fix before exporting — this can corrupt downstream graph or API output."
+ : selectedIssue.severity === "medium"
+ ? "Review during the next QA pass to maintain ontology hygiene."
+ : "Optional cleanup — low impact on graph integrity."}
+
+
+
+ )}
+
+ >
);
}
+/* ============================================================
+ * Sub-components
+ * ========================================================== */
+
+const METRIC_TONE: Record = {
+ muted: "border-border",
+ warning: "border-warning-border bg-warning-subtle/30",
+ success: "border-success-border bg-success-subtle/30",
+ danger: "border-danger-border bg-danger-subtle/30",
+};
+
function Metric({
label,
value,
- tone,
+ tone = "muted",
}: {
label: string;
value: number | string;
- tone?: QualityIssue["severity"];
+ tone?: keyof typeof METRIC_TONE;
}) {
return (
-
-
- {label}
-
-
{value}
- {tone &&
{tone} }
+
+
+
+ {label}
+
+
+ {value}
@@ -793,9 +1063,9 @@ function Metric({
function ReadinessRow({ label, value }: { label: string; value: number }) {
return (
-
+
{label}
- {value.toLocaleString()}
+ {value.toLocaleString()}
);
}
diff --git a/ontology_platform/web/frontend/src/pages/ReviewPage.tsx b/ontology_platform/web/frontend/src/pages/ReviewPage.tsx
index 6c2a54c..b2a03b8 100644
--- a/ontology_platform/web/frontend/src/pages/ReviewPage.tsx
+++ b/ontology_platform/web/frontend/src/pages/ReviewPage.tsx
@@ -1,30 +1,31 @@
+import * as React from "react";
import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import {
- AlertCircle,
- ArrowLeft,
ArrowRight,
Check,
- Eye,
- Filter,
+ ExternalLink,
ListChecks,
- Search,
+ ShieldCheck,
X,
} from "lucide-react";
-import { cn } from "@/lib/utils";
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
+import { Card, CardContent } from "@/components/ui/card";
import { Badge, BadgeProps } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Select } from "@/components/ui/select";
-import { Skeleton } from "@/components/ui/skeleton";
+import { DataTable, type ColumnDef } from "@/components/ui/data-table";
+import { Drawer } from "@/components/ui/drawer";
+import { EmptyState } from "@/components/ui/empty-state";
+import { ErrorState } from "@/components/ui/error-state";
+import { Tooltip } from "@/components/ui/tooltip";
+import {
+ FilterPanel,
+ FilterSearch,
+ FilterSelect,
+ FilterChips,
+ type ActiveChip,
+} from "@/components/ui/filter-panel";
+import { PageHeader } from "@/components/layout/PageHeader";
import { useClaims, useUpdateClaimStatus } from "@/hooks/useClaims";
import { useProject } from "@/hooks/useProjects";
import {
@@ -50,15 +51,21 @@ function statusVariant(status: string | undefined): BadgeProps["variant"] {
function confidenceBucket(claim: Claim): string {
if (typeof claim.confidence !== "number") return "unknown";
- if (claim.confidence >= 0.9) return "auto-approve candidate";
- if (claim.confidence >= 0.7) return "normal review";
- return "priority review";
+ if (claim.confidence >= 0.9) return "auto-approve";
+ if (claim.confidence >= 0.7) return "normal";
+ return "priority";
}
-function claimObject(claim: Claim): string {
+function claimObjectText(claim: Claim): string {
return humanizeValue(claim.object ?? claim.object_value);
}
+const STATUS_LABELS: Record
= {
+ candidate: "Candidate",
+ approved: "Approved",
+ rejected: "Rejected",
+};
+
export default function ReviewPage() {
const navigate = useNavigate();
const { projectId } = useParams<{ projectId: string }>();
@@ -66,24 +73,28 @@ export default function ReviewPage() {
const { data: project } = useProject(projectName);
const claims = useClaims(projectName, {
includeCandidates: true,
- limit: 300,
+ limit: 500,
});
const updateStatus = useUpdateClaimStatus(projectName);
- const [statusFilter, setStatusFilter] = useState("all");
+
+ const [statusFilter, setStatusFilter] = useState("");
const [search, setSearch] = useState("");
const [selectedId, setSelectedId] = useState(null);
+ const [drawerOpen, setDrawerOpen] = useState(false);
+
+ /* --------------------- Derived data --------------------- */
const filteredClaims = useMemo(() => {
const query = search.trim().toLowerCase();
return (claims.data ?? []).filter((claim) => {
- if (statusFilter !== "all" && reviewLabel(claim.status) !== statusFilter) {
+ if (statusFilter && reviewLabel(claim.status) !== statusFilter) {
return false;
}
if (!query) return true;
return [
claim.subject,
claim.predicate,
- claimObject(claim),
+ claimObjectText(claim),
claim.evidence_text,
claim.page_url,
claim.source,
@@ -94,11 +105,9 @@ export default function ReviewPage() {
}, [claims.data, search, statusFilter]);
const selectedClaim = useMemo(() => {
- return (
- filteredClaims.find((claim) => claim.id === selectedId) ??
- filteredClaims[0]
- );
- }, [filteredClaims, selectedId]);
+ if (!selectedId) return null;
+ return claims.data?.find((claim) => claim.id === selectedId) ?? null;
+ }, [claims.data, selectedId]);
const counts = useMemo(() => {
const all = claims.data ?? [];
@@ -113,6 +122,8 @@ export default function ReviewPage() {
};
}, [claims.data]);
+ /* --------------------- Actions --------------------- */
+
const applyStatus = async (claim: Claim, status: string) => {
try {
await updateStatus.mutateAsync({
@@ -123,334 +134,491 @@ export default function ReviewPage() {
? "Rejected from Claim Review"
: "Updated from Claim Review",
});
- toast.success(`Claim ${status}`);
+ toast.success(
+ status === "validated_claim"
+ ? `Approved · ${humanizeValue(claim.subject)}`
+ : `Rejected · ${humanizeValue(claim.subject)}`,
+ );
} catch (error) {
toast.error((error as Error).message);
}
};
- return (
-
-
-
navigate(`/schema/${projectName}`)}
- aria-label="Back"
- >
-
-
-
-
-
- Claim Review
-
-
- {project?.name ?? projectName} · 후보, 승인, 반려 상태 분리 검토
-
+ const openDetail = (claim: Claim) => {
+ setSelectedId(claim.id);
+ setDrawerOpen(true);
+ };
+
+ /* --------------------- Filter chips --------------------- */
+
+ const chips: ActiveChip[] = [];
+ if (search)
+ chips.push({
+ id: "search",
+ label: "Search",
+ value: `"${search}"`,
+ onClear: () => setSearch(""),
+ });
+ if (statusFilter)
+ chips.push({
+ id: "status",
+ label: "Status",
+ value: STATUS_LABELS[statusFilter] ?? statusFilter,
+ onClear: () => setStatusFilter(""),
+ });
+ const clearAll = () => {
+ setSearch("");
+ setStatusFilter("");
+ };
+
+ /* --------------------- Table columns --------------------- */
+
+ const columns: ColumnDef
[] = [
+ {
+ id: "status",
+ header: "Status",
+ accessorFn: (row) => reviewLabel(row.status),
+ cell: ({ row }) => (
+
+ {reviewLabel(row.original.status)}
+
+ ),
+ size: 110,
+ enablePinning: true,
+ },
+ {
+ id: "subject",
+ header: "Subject",
+ accessorFn: (row) => humanizeValue(row.subject),
+ cell: ({ row }) => (
+
+ {humanizeValue(row.original.subject)}
+
+ ),
+ size: 220,
+ },
+ {
+ id: "predicate",
+ header: "Predicate",
+ accessorFn: (row) => row.predicate,
+ cell: ({ row }) => (
+
+ {row.original.predicate}
+
+ ),
+ size: 160,
+ },
+ {
+ id: "object",
+ header: "Object",
+ accessorFn: (row) => claimObjectText(row),
+ cell: ({ row }) => (
+
+ {claimObjectText(row.original)}
+
+ ),
+ size: 280,
+ enableSorting: false,
+ },
+ {
+ id: "confidence",
+ header: "Confidence",
+ accessorFn: (row) => row.confidence ?? 0,
+ cell: ({ row }) => ,
+ size: 140,
+ meta: { align: "right" },
+ },
+ {
+ id: "bucket",
+ header: "Bucket",
+ accessorFn: (row) => confidenceBucket(row),
+ cell: ({ row }) => {
+ const b = confidenceBucket(row.original);
+ const tone =
+ b === "auto-approve"
+ ? "pill-success"
+ : b === "priority"
+ ? "pill-warning"
+ : "pill-info";
+ return {b} ;
+ },
+ size: 130,
+ },
+ {
+ id: "source",
+ header: "Source",
+ accessorFn: (row) => row.source ?? "",
+ cell: ({ row }) => (
+
+ {humanizeValue(row.original.source)}
+
+ ),
+ size: 120,
+ },
+ {
+ id: "evidence",
+ header: "Evidence",
+ accessorFn: (row) => (row.evidence_text ? 1 : 0),
+ cell: ({ row }) =>
+ row.original.evidence_text ? (
+ yes
+ ) : (
+ missing
+ ),
+ size: 90,
+ meta: { align: "center" },
+ },
+ {
+ id: "actions",
+ header: "",
+ enableSorting: false,
+ enableHiding: false,
+ enableResizing: false,
+ cell: ({ row }) => (
+
+
+ {
+ e.stopPropagation();
+ applyStatus(row.original, "validated_claim");
+ }}
+ disabled={updateStatus.isPending}
+ className="h-7 w-7"
+ >
+
+
+
+
+ {
+ e.stopPropagation();
+ applyStatus(row.original, "rejected");
+ }}
+ disabled={updateStatus.isPending}
+ className="h-7 w-7"
+ >
+
+
+
- navigate(`/quality/${projectName}`)}>
- Quality Inspector
-
+ ),
+ size: 90,
+ meta: { align: "right" },
+ },
+ ];
+
+ /* --------------------- Render --------------------- */
+
+ return (
+ <>
+ navigate(`/quality/${projectName}`)}>
+
+ Quality Inspector
+
+ }
+ />
+
+
+ {claims.isError && (
+
claims.refetch()}
+ />
+ )}
+
+ {/* Metrics */}
+
+
+
+
+
+
+
+ {/* Filter panel */}
+ 0 ? : undefined}
+ onClearAll={chips.length > 0 ? clearAll : undefined}
+ >
+
+
+
+
+
+ tableId="review"
+ columns={columns}
+ data={filteredClaims}
+ getRowId={(row) => row.id}
+ loading={claims.isLoading}
+ loadingRows={8}
+ height={640}
+ onRowClick={openDetail}
+ rowToneAccessor={(row) => {
+ const r = reviewLabel(row.status);
+ if (r === "approved") return "success";
+ if (r === "rejected") return "danger";
+ return undefined;
+ }}
+ empty={
+ 0 ? (
+
+ Clear filters
+
+ ) : undefined
+ }
+ />
+ }
+ />
- {claims.isError && (
-
-
-
- {(claims.error as Error).message}
-
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Review Queue
-
- 신뢰도, 출처, 생성 방식, 검증 결과를 함께 확인합니다.
-
+ {/* Detail Drawer */}
+
{
+ setDrawerOpen(open);
+ if (!open) setSelectedId(null);
+ }}
+ size="lg"
+ title={
+ selectedClaim
+ ? `Claim #${selectedClaim.id} · ${selectedClaim.predicate}`
+ : ""
+ }
+ description="근거, 출처, 검증 결과, 히스토리"
+ headerExtra={
+ selectedClaim ? (
+
+ {reviewLabel(selectedClaim.status)}
+
+ ) : null
+ }
+ footer={
+ selectedClaim ? (
+ <>
+ applyStatus(selectedClaim, "rejected")}
+ disabled={updateStatus.isPending}
+ >
+
+ Reject
+
+ applyStatus(selectedClaim, "validated_claim")}
+ disabled={updateStatus.isPending}
+ >
+
+ Approve
+
+ >
+ ) : null
+ }
+ >
+ {selectedClaim && (
+
+ {/* Triple summary */}
+
+
+
+ {humanizeValue(selectedClaim.subject)}
+
+ {selectedClaim.subject_type && (
+
+ {selectedClaim.subject_type}
+
+ )}
+
+
+ {selectedClaim.predicate}
+
+
+
+ {claimObjectText(selectedClaim)}
+
-
-
-
- setSearch(event.target.value)}
- className="w-48 pl-9 sm:w-56"
- placeholder="Search claims"
- />
-
-
-
- setStatusFilter(event.target.value)}
- className="w-32 pl-9 sm:w-40"
- >
- All
- Candidate
- Approved
- Rejected
-
-
+
+
+ Confidence{" "}
+
+ {formatPercent(selectedClaim.confidence)}
+
+
+
+ Bucket{" "}
+
+ {confidenceBucket(selectedClaim)}
+
+
+ {selectedClaim.review_required && (
+ review required
+ )}
-
-
- {claims.isLoading && (
-
- {Array.from({ length: 5 }).map((_, index) => (
-
- ))}
-
- )}
- {!claims.isLoading && filteredClaims.length === 0 && (
-
- 조건에 맞는 클레임이 없습니다.
-
- )}
-
- {filteredClaims.map((claim) => {
- const isSelected = selectedClaim?.id === claim.id;
- return (
-
- setSelectedId(claim.id)}
- className="block w-full px-4 pt-4 pb-3 text-left"
- >
-
-
- {reviewLabel(claim.status)}
-
- {claim.predicate}
-
- {confidenceBucket(claim)}
-
-
-
-
- {humanizeValue(claim.subject)}
-
-
-
- {claimObject(claim)}
-
-
-
-
- Confidence{" "}
-
- {formatPercent(claim.confidence)}
-
-
-
- Source{" "}
-
- {humanizeValue(claim.source)}
-
-
-
- Evidence{" "}
-
- {claim.evidence_text ? "yes" : "missing"}
-
-
-
- Method{" "}
-
- {humanizeValue(claim.extraction_method)}
-
-
-
-
-
- setSelectedId(claim.id)}
- >
-
- Detail
-
- applyStatus(claim, "validated_claim")}
- disabled={updateStatus.isPending}
- >
-
- Approve
-
- applyStatus(claim, "rejected")}
- disabled={updateStatus.isPending}
- >
-
- Reject
-
-
-
- );
- })}
-
-
-
-
-
-
- Claim Detail
-
- 근거, 출처, 검증 결과, 히스토리
-
-
-
- {!selectedClaim && (
-
- 클레임을 선택하세요.
-
- )}
- {selectedClaim && (
-
-
-
- {reviewLabel(selectedClaim.status)}
-
-
- {formatPercent(selectedClaim.confidence)}
-
- {selectedClaim.review_required && (
- review required
- )}
-
-
-
-
-
-
-
-
-
-
- {selectedClaim.graph_merge_reason && (
-
- )}
- {selectedClaim.evidence_text && (
-
- Evidence Text
-
- {selectedClaim.evidence_text}
-
-
- )}
- {selectedClaim.confidence_breakdown && (
-
-
- Confidence Breakdown
-
-
- {JSON.stringify(
- selectedClaim.confidence_breakdown,
- null,
- 2,
- )}
-
-
- )}
- {selectedClaim.source_history &&
- selectedClaim.source_history.length > 0 && (
-
-
- Source History
-
-
- {JSON.stringify(selectedClaim.source_history, null, 2)}
-
-
- )}
-
-
- applyStatus(selectedClaim, "validated_claim")
- }
- disabled={updateStatus.isPending}
+ {/* Detail grid */}
+
+
+
+
+
+ {selectedClaim.page_url && (
+
-
- Approve
-
- applyStatus(selectedClaim, "rejected")}
- disabled={updateStatus.isPending}
- >
-
- Reject
-
-
-
- Last seen {formatDateTime(selectedClaim.last_seen_at)}
-
-
+
+
+ {selectedClaim.page_url}
+
+
+ }
+ full
+ />
)}
-
-
-
-
-
+ {selectedClaim.graph_merge_reason && (
+
+ )}
+
+
+ {/* Evidence */}
+ {selectedClaim.evidence_text && (
+
+
+ Evidence Text
+
+
+ {selectedClaim.evidence_text}
+
+
+ )}
+
+ {/* Confidence breakdown */}
+ {selectedClaim.confidence_breakdown && (
+
+
+ Confidence Breakdown
+
+
+ {JSON.stringify(
+ selectedClaim.confidence_breakdown,
+ null,
+ 2,
+ )}
+
+
+ )}
+
+ {/* Source history */}
+ {selectedClaim.source_history &&
+ selectedClaim.source_history.length > 0 && (
+
+
+ Source History ({selectedClaim.source_history.length})
+
+
+ {JSON.stringify(selectedClaim.source_history, null, 2)}
+
+
+ )}
+
+
+ Last seen {formatDateTime(selectedClaim.last_seen_at)}
+
+
+ )}
+
+ >
);
}
-function Metric({ label, value }: { label: string; value: number }) {
+/* ============================================================
+ * Sub-components
+ * ========================================================== */
+
+const METRIC_TONE: Record
= {
+ muted: "border-border",
+ warning: "border-warning-border bg-warning-subtle/30",
+ success: "border-success-border bg-success-subtle/30",
+ danger: "border-danger-border bg-danger-subtle/30",
+};
+
+function Metric({
+ label,
+ value,
+ tone = "muted",
+}: {
+ label: string;
+ value: number;
+ tone?: keyof typeof METRIC_TONE;
+}) {
return (
-
-
- {label}
-
+
+
+
+ {label}
+
+
{value.toLocaleString()}
@@ -458,11 +626,48 @@ function Metric({ label, value }: { label: string; value: number }) {
);
}
-function DetailRow({ label, value }: { label: string; value: unknown }) {
+function ConfidenceBar({ value }: { value: number | undefined }) {
+ if (typeof value !== "number") {
+ return — ;
+ }
+ const pct = Math.round(value * 100);
+ const tone =
+ value >= 0.9
+ ? "bg-success"
+ : value >= 0.7
+ ? "bg-info"
+ : value >= 0.5
+ ? "bg-warning"
+ : "bg-danger";
return (
-
-
{label}
-
{humanizeValue(value)}
+
+ );
+}
+
+function DetailRow({
+ label,
+ value,
+ full = false,
+}: {
+ label: string;
+ value: unknown;
+ full?: boolean;
+}) {
+ return (
+
+
+ {label}
+
+
+ {React.isValidElement(value) ? value : humanizeValue(value)}
+
);
}