This commit is contained in:
LASTA_DEV01\lasta
2026-05-19 20:31:52 +09:00
parent 00407e7a08
commit e260e5f218
104 changed files with 12898 additions and 1709 deletions

View File

@@ -7,6 +7,12 @@ import ResearchPage from "@/pages/ResearchPage";
import OntologyEditorPage from "@/pages/OntologyEditorPage";
import ReviewPage from "@/pages/ReviewPage";
import DashboardPage from "@/pages/DashboardPage";
import BuildPipelinePage from "@/pages/BuildPipelinePage";
import PageAnalysisPage from "@/pages/PageAnalysisPage";
import SchemaDesignerPage from "@/pages/SchemaDesignerPage";
import QualityInspectorPage from "@/pages/QualityInspectorPage";
import GraphViewPage from "@/pages/GraphViewPage";
import ExportApiPage from "@/pages/ExportApiPage";
function App() {
return (
@@ -16,9 +22,15 @@ function App() {
<Route path="/onboard" element={<OnboardingPage />} />
<Route path="/sources/:projectId" element={<ConfigureSourcesPage />} />
<Route path="/crawl/:projectId" element={<CrawlPage />} />
<Route path="/pipeline/:projectId" element={<BuildPipelinePage />} />
<Route path="/analysis/:projectId" element={<PageAnalysisPage />} />
<Route path="/schema/:projectId" element={<SchemaDesignerPage />} />
<Route path="/research/:projectId" element={<ResearchPage />} />
<Route path="/editor/:projectId" element={<OntologyEditorPage />} />
<Route path="/review/:projectId" element={<ReviewPage />} />
<Route path="/quality/:projectId" element={<QualityInspectorPage />} />
<Route path="/graph/:projectId" element={<GraphViewPage />} />
<Route path="/export/:projectId" element={<ExportApiPage />} />
</Route>
</Routes>
);

View File

@@ -14,9 +14,15 @@ import {
Settings2,
Activity,
Brain,
Code2,
GitBranch,
Network,
ListChecks,
Menu,
SearchCheck,
ShieldCheck,
ClipboardCheck,
Layers3,
} from "lucide-react";
import { RootState } from "@/stores";
import { toggleSidebar } from "@/stores/slices/uiSlice";
@@ -28,25 +34,38 @@ interface NavItem {
to?: string;
projectPath?: string;
labelKey: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
}
const navItems: NavItem[] = [
{ to: "/", labelKey: "nav.dashboard", icon: LayoutDashboard },
{ to: "/onboard", labelKey: "nav.onboard", icon: UploadCloud },
{ projectPath: "sources", labelKey: "nav.sources", icon: Settings2 },
{ projectPath: "crawl", labelKey: "nav.crawl", icon: Activity },
{ projectPath: "research", labelKey: "nav.research", icon: Brain },
{ projectPath: "editor", labelKey: "nav.editor", icon: Network },
{ projectPath: "review", labelKey: "nav.review", icon: ListChecks },
{ to: "/", labelKey: "nav.dashboard", label: "Dashboard", icon: LayoutDashboard },
{ to: "/onboard", labelKey: "nav.onboard", label: "New Project", icon: UploadCloud },
{ projectPath: "sources", labelKey: "nav.sources", label: "Source Explorer", icon: Settings2 },
{ projectPath: "crawl", labelKey: "nav.crawl", label: "Seed Crawl", icon: Activity },
{ projectPath: "pipeline", labelKey: "nav.pipeline", label: "Build Pipeline", icon: Layers3 },
{ projectPath: "analysis", labelKey: "nav.analysis", label: "Page Analysis", icon: SearchCheck },
{ projectPath: "schema", labelKey: "nav.schema", label: "Schema Designer", icon: ShieldCheck },
{ projectPath: "editor", labelKey: "nav.editor", label: "Entity Manager", icon: Network },
{ projectPath: "review", labelKey: "nav.review", label: "Claim Review", icon: ListChecks },
{ projectPath: "quality", labelKey: "nav.quality", label: "Quality Inspector", icon: ClipboardCheck },
{ projectPath: "graph", labelKey: "nav.graph", label: "Graph View", icon: GitBranch },
{ projectPath: "export", labelKey: "nav.export", label: "Export / API", icon: Code2 },
{ projectPath: "research", labelKey: "nav.research", label: "Graph Research", icon: Brain },
];
const projectRoutePatterns = [
"/sources/:projectId",
"/crawl/:projectId",
"/pipeline/:projectId",
"/analysis/:projectId",
"/schema/:projectId",
"/research/:projectId",
"/editor/:projectId",
"/review/:projectId",
"/quality/:projectId",
"/graph/:projectId",
"/export/:projectId",
];
function projectIdFromPathname(pathname: string): string | undefined {
@@ -137,7 +156,7 @@ export default function AppShell() {
)}
<nav className="flex flex-col gap-1 p-2">
{navItems.map(({ to, projectPath, labelKey, icon: Icon }) => {
{navItems.map(({ to, projectPath, labelKey, label, icon: Icon }) => {
const href =
to ??
(currentProjectId
@@ -155,7 +174,7 @@ export default function AppShell() {
)}
>
<Icon className="h-4 w-4 flex-shrink-0" />
{sidebarOpen && <span>{t(labelKey)}</span>}
{sidebarOpen && <span>{t(labelKey, label)}</span>}
</div>
);
}
@@ -175,7 +194,7 @@ export default function AppShell() {
}
>
<Icon className="h-4 w-4 flex-shrink-0" />
{sidebarOpen && <span>{t(labelKey)}</span>}
{sidebarOpen && <span>{t(labelKey, label)}</span>}
</NavLink>
);
})}

View File

@@ -43,7 +43,67 @@ export const queryKeys = {
},
claims: {
all: ["claims"] as const,
list: (projectName: string, status?: string) =>
[...queryKeys.claims.all, "list", projectName, status ?? "*"] as const,
list: (projectName: string, status?: string, limit?: number) =>
[
...queryKeys.claims.all,
"list",
projectName,
status ?? "*",
limit ?? "*",
] as const,
},
platform: {
all: ["platform"] as const,
pipeline: (projectName: string) =>
[...queryKeys.platform.all, "pipeline", projectName] as const,
extractionLogs: (projectName: string, limit: number) =>
[
...queryKeys.platform.all,
"extractionLogs",
projectName,
limit,
] as const,
registry: (projectName: string) =>
[...queryKeys.platform.all, "registry", projectName] as const,
proposals: (projectName: string, limit: number) =>
[...queryKeys.platform.all, "proposals", projectName, limit] as const,
triples: (projectName: string, status: string, limit: number) =>
[
...queryKeys.platform.all,
"triples",
projectName,
status,
limit,
] as const,
graph: (
projectName: string,
entityId: string,
includeCandidates: boolean,
status: string,
limit: number,
) =>
[
...queryKeys.platform.all,
"graph",
projectName,
entityId,
includeCandidates,
status,
limit,
] as const,
exportJson: (
projectName: string,
status: string,
includeEvidence: boolean,
limit: number,
) =>
[
...queryKeys.platform.all,
"exportJson",
projectName,
status,
includeEvidence,
limit,
] as const,
},
};

View File

@@ -4,14 +4,15 @@ import { queryKeys } from "./queryKeys";
export function useClaims(
projectName: string,
options: { status?: string; includeCandidates?: boolean } = {},
options: { status?: string; includeCandidates?: boolean; limit?: number } = {},
) {
return useQuery<Claim[]>({
queryKey: queryKeys.claims.list(projectName, options.status),
queryKey: queryKeys.claims.list(projectName, options.status, options.limit),
queryFn: () =>
claimsApi.list(projectName, {
status: options.status,
includeCandidates: options.includeCandidates,
limit: options.limit,
}),
enabled: Boolean(projectName),
});
@@ -42,3 +43,29 @@ export function useDeleteClaim(projectName: string) {
},
});
}
export function useUpdateClaimStatus(projectName: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
claimId,
status,
reason,
}: {
claimId: string | number;
status: string;
reason?: string | null;
}) => claimsApi.updateStatus(claimId, { status, reason }),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.claims.all,
});
queryClient.invalidateQueries({
queryKey: queryKeys.platform.all,
});
queryClient.invalidateQueries({
queryKey: queryKeys.platform.pipeline(projectName),
});
},
});
}

View File

@@ -0,0 +1,146 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
CreateSchemaEntityTypeRequest,
CreateSchemaRelationTypeRequest,
ExtractionLog,
ExportJson,
GraphNeighborhood,
OntologyProposal,
OntologyRegistry,
OntologyTriple,
PipelineSummary,
RerunStageResponse,
platformApi,
} from "@/lib/api/platform";
import { queryKeys } from "./queryKeys";
export function usePipeline(projectName: string) {
return useQuery<PipelineSummary>({
queryKey: queryKeys.platform.pipeline(projectName),
queryFn: () => platformApi.pipeline(projectName),
enabled: Boolean(projectName),
});
}
export function useExtractionLogs(projectName: string, limit = 50) {
return useQuery<ExtractionLog[]>({
queryKey: queryKeys.platform.extractionLogs(projectName, limit),
queryFn: () => platformApi.extractionLogs(projectName, limit),
enabled: Boolean(projectName),
});
}
export function useOntologyRegistry(projectName: string) {
return useQuery<OntologyRegistry>({
queryKey: queryKeys.platform.registry(projectName),
queryFn: () => platformApi.registry(projectName),
enabled: Boolean(projectName),
});
}
export function useOntologyProposals(projectName: string, limit = 100) {
return useQuery<OntologyProposal[]>({
queryKey: queryKeys.platform.proposals(projectName, limit),
queryFn: () => platformApi.proposals(projectName, limit),
enabled: Boolean(projectName),
});
}
export function useOntologyTriples(
projectName: string,
status?: string,
limit = 200,
) {
return useQuery<OntologyTriple[]>({
queryKey: queryKeys.platform.triples(projectName, status ?? "*", limit),
queryFn: () => platformApi.triples(projectName, status, limit),
enabled: Boolean(projectName),
});
}
export function useGraphNeighborhood(
projectName: string,
options: {
entityId?: string | number | null;
includeCandidates?: boolean;
status?: string;
limit?: number;
} = {},
) {
const entityKey = options.entityId == null ? "*" : String(options.entityId);
const statusKey = options.status ?? "*";
const limit = options.limit ?? 200;
return useQuery<GraphNeighborhood>({
queryKey: queryKeys.platform.graph(
projectName,
entityKey,
Boolean(options.includeCandidates),
statusKey,
limit,
),
queryFn: () => platformApi.graphNeighborhood(projectName, options),
enabled: Boolean(projectName),
});
}
export function useExportJson(
projectName: string,
options: { status?: string; includeEvidence?: boolean; limit?: number } = {},
) {
const status = options.status ?? "validated_claim";
const includeEvidence = options.includeEvidence ?? true;
const limit = options.limit ?? 1000;
return useQuery<ExportJson>({
queryKey: queryKeys.platform.exportJson(
projectName,
status,
includeEvidence,
limit,
),
queryFn: () =>
platformApi.exportJson(projectName, {
status,
includeEvidence,
limit,
}),
enabled: Boolean(projectName),
});
}
export function useRerunPipelineStage(projectName: string) {
const queryClient = useQueryClient();
return useMutation<RerunStageResponse, Error, string>({
mutationFn: (stage) => platformApi.rerunStage(projectName, stage),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.platform.pipeline(projectName),
});
},
});
}
export function useCreateSchemaEntityType(projectName: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: CreateSchemaEntityTypeRequest) =>
platformApi.createSchemaEntityType(projectName, body),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.platform.registry(projectName),
});
},
});
}
export function useCreateSchemaRelationType(projectName: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: CreateSchemaRelationTypeRequest) =>
platformApi.createSchemaRelationType(projectName, body),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.platform.registry(projectName),
});
},
});
}

View File

@@ -11,6 +11,7 @@ export const claimSchema = z
subject_type: z.string().optional(),
predicate: z.string(),
object: z.string().nullable().optional(),
object_type: z.string().nullable().optional(),
object_value: z.unknown().nullable().optional(),
source: z.string().nullable().optional(),
page_url: z.string().nullable().optional(),
@@ -19,6 +20,18 @@ export const claimSchema = z
status: z.string().optional(),
evidence_text: z.string().nullable().optional(),
evidence_summary: z.string().nullable().optional(),
page_type: z.string().nullable().optional(),
source_zone: z.string().nullable().optional(),
source_selector: z.string().nullable().optional(),
extraction_method: z.string().nullable().optional(),
validation_status: z.string().nullable().optional(),
graph_merge_status: z.string().nullable().optional(),
graph_merge_reason: z.string().nullable().optional(),
confidence_breakdown: z.record(z.string(), z.unknown()).nullable().optional(),
review_required: z.boolean().nullable().optional(),
review_reason: z.string().nullable().optional(),
conflict_status: z.string().nullable().optional(),
source_history: z.array(z.unknown()).optional(),
last_seen_at: z.string().optional(),
})
.passthrough();
@@ -52,6 +65,17 @@ export interface CreateClaimRequest {
metadata?: Record<string, unknown>;
}
export interface UpdateClaimStatusRequest {
status: string;
reason?: string | null;
}
export const updateClaimStatusResponseSchema = z.object({
ok: z.boolean(),
claim_id: idLike,
status: z.string(),
});
export const claimsApi = {
list: (
projectName: string,
@@ -83,4 +107,10 @@ export const claimsApi = {
`/projects/${encodeURIComponent(projectName)}/claims/${claimId}`,
deleteResponseSchema,
),
updateStatus: (claimId: string | number, body: UpdateClaimStatusRequest) =>
apiClient.patch(
`/claims/${encodeURIComponent(String(claimId))}/status`,
updateClaimStatusResponseSchema,
body,
),
};

View File

@@ -7,7 +7,7 @@ export class ApiError extends Error {
public body: unknown,
public url: string,
) {
super(`${status} ${statusText} ${url}`);
super(`${status} ${statusText} - ${url}`);
this.name = "ApiError";
}
}
@@ -28,11 +28,11 @@ function buildUrl(path: string, query?: RequestOptions["query"]): string {
return qs ? `${path}?${qs}` : path;
}
async function request<T>(
async function request<S extends z.ZodTypeAny>(
path: string,
schema: z.ZodType<T>,
schema: S,
options: RequestOptions = {},
): Promise<T> {
): Promise<z.output<S>> {
const { body, query, headers, ...rest } = options;
const url = buildUrl(path, query);
@@ -70,20 +70,34 @@ async function request<T>(
}
export const apiClient = {
get: <T>(path: string, schema: z.ZodType<T>, opts?: RequestOptions) =>
request(path, schema, { ...opts, method: "GET" }),
post: <T>(
get: <S extends z.ZodTypeAny>(
path: string,
schema: z.ZodType<T>,
schema: S,
opts?: RequestOptions,
) =>
request(path, schema, { ...opts, method: "GET" }),
post: <S extends z.ZodTypeAny>(
path: string,
schema: S,
body?: unknown,
opts?: RequestOptions,
) => request(path, schema, { ...opts, method: "POST", body }),
put: <T>(
put: <S extends z.ZodTypeAny>(
path: string,
schema: z.ZodType<T>,
schema: S,
body?: unknown,
opts?: RequestOptions,
) => request(path, schema, { ...opts, method: "PUT", body }),
delete: <T>(path: string, schema: z.ZodType<T>, opts?: RequestOptions) =>
patch: <S extends z.ZodTypeAny>(
path: string,
schema: S,
body?: unknown,
opts?: RequestOptions,
) => request(path, schema, { ...opts, method: "PATCH", body }),
delete: <S extends z.ZodTypeAny>(
path: string,
schema: S,
opts?: RequestOptions,
) =>
request(path, schema, { ...opts, method: "DELETE" }),
};

View File

@@ -7,3 +7,4 @@ export * from "./crawl";
export * from "./research";
export * from "./entities";
export * from "./claims";
export * from "./platform";

View File

@@ -0,0 +1,356 @@
import { z } from "zod";
import { apiClient } from "./client";
const idLike = z.union([z.string(), z.number()]).transform(String);
const recordSchema = z.record(z.string(), z.unknown());
export const pipelineStageSchema = z
.object({
key: z.string(),
count: z.number().default(0),
extra: recordSchema.optional(),
})
.passthrough();
export const pipelineSummarySchema = z
.object({
stages: z.array(pipelineStageSchema).default([]),
entity_types: z
.array(
z.object({
type: z.string(),
count: z.number(),
}),
)
.default([]),
recent_pages: z
.array(
z
.object({
id: idLike,
url: z.string(),
title: z.string().nullable().optional(),
status_code: z.number().nullable().optional(),
page_type: z.string().nullable().optional(),
fetched_at: z.string().nullable().optional(),
})
.passthrough(),
)
.default([]),
recent_claims: z
.array(
z
.object({
id: idLike,
subject: z.string().nullable().optional(),
predicate: z.string(),
confidence: z.number().optional(),
status: z.string().optional(),
last_seen_at: z.string().nullable().optional(),
})
.passthrough(),
)
.default([]),
})
.passthrough();
export const extractionLogSchema = z
.object({
id: idLike,
page_url: z.string().nullable().optional(),
extractor_name: z.string(),
provider: z.string(),
error: z.string().nullable().optional(),
created_at: z.string(),
validation: z.unknown().optional(),
page_context: z.unknown().optional(),
candidate_count: z.number().default(0),
raw_output: z.unknown().optional(),
})
.passthrough();
export const ontologyRegistrySchema = z
.object({
entity_types: z
.array(
z
.object({
id: idLike,
name: z.string(),
domain: z.string().optional(),
description: z.string().nullable().optional(),
status: z.string().optional(),
version: z.string().optional(),
confidence: z.number().optional(),
metadata: recordSchema.optional(),
})
.passthrough(),
)
.default([]),
relation_types: z
.array(
z
.object({
id: idLike,
name: z.string(),
domain: z.string().optional(),
description: z.string().nullable().optional(),
allowed_subject_types: z.array(z.string()).default([]),
allowed_object_types: z.array(z.string()).default([]),
allowed_page_types: z.array(z.string()).default([]),
allowed_source_zones: z.array(z.string()).default([]),
semantic_constraints: recordSchema.default({}),
confidence_rules: recordSchema.default({}),
status: z.string().optional(),
version: z.string().optional(),
confidence: z.number().optional(),
metadata: recordSchema.optional(),
})
.passthrough(),
)
.default([]),
})
.passthrough();
export const ontologyProposalSchema = z
.object({
id: idLike,
proposal_type: z.string(),
name: z.string(),
reason: z.string().nullable().optional(),
evidence: z.string().nullable().optional(),
status: z.string(),
confidence: z.number().optional(),
metadata: recordSchema.optional(),
updated_at: z.string().nullable().optional(),
})
.passthrough();
export const ontologyTripleSchema = z
.object({
id: idLike,
claim_id: z.union([z.string(), z.number(), z.null()]).nullable().optional(),
subject: z.string().nullable().optional(),
subject_type: z.string().nullable().optional(),
predicate: z.string(),
object: z.string().nullable().optional(),
object_type: z.string().nullable().optional(),
object_value: z.unknown().nullable().optional(),
value_type: z.string().optional(),
status: z.string().optional(),
confidence: z.number().optional(),
support_count: z.number().optional(),
metadata: recordSchema.optional(),
last_seen_at: z.string().nullable().optional(),
})
.passthrough();
export const graphNodeSchema = z
.object({
id: idLike,
name: z.string(),
type: z.string().optional(),
canonical_name: z.string().nullable().optional(),
metadata: recordSchema.optional(),
})
.passthrough();
export const graphEdgeSchema = z
.object({
claim_id: idLike,
source: idLike,
target: z.union([z.string(), z.number(), z.null()]).nullable().optional(),
target_value: z.unknown().nullable().optional(),
predicate: z.string(),
status: z.string().optional(),
confidence: z.number().optional(),
last_seen_at: z.string().nullable().optional(),
metadata: recordSchema.optional(),
object: z
.object({
id: idLike,
name: z.string(),
})
.nullable()
.optional(),
})
.passthrough();
export const graphNeighborhoodSchema = z
.object({
nodes: z.array(graphNodeSchema).default([]),
edges: z.array(graphEdgeSchema).default([]),
})
.passthrough();
export const exportJsonSchema = z
.object({
project: z.string(),
status: z.string(),
count: z.number(),
claims: z.array(z.record(z.string(), z.unknown())).default([]),
})
.passthrough();
export const rerunStageResponseSchema = z
.object({
ok: z.boolean().optional(),
stage: z.string(),
action: z.string(),
route: z.string().optional(),
message: z.string().optional(),
job_id: idLike.optional(),
status: z.string().optional(),
})
.passthrough();
export const schemaEntityTypeResponseSchema = z
.object({
id: idLike,
name: z.string(),
domain: z.string().optional(),
status: z.string().optional(),
})
.passthrough();
export const schemaRelationTypeResponseSchema = z
.object({
id: idLike,
name: z.string(),
domain: z.string().optional(),
status: z.string().optional(),
})
.passthrough();
export type PipelineSummary = z.infer<typeof pipelineSummarySchema>;
export type ExtractionLog = z.infer<typeof extractionLogSchema>;
export type OntologyRegistry = z.infer<typeof ontologyRegistrySchema>;
export type OntologyProposal = z.infer<typeof ontologyProposalSchema>;
export type OntologyTriple = z.infer<typeof ontologyTripleSchema>;
export type GraphNeighborhood = z.infer<typeof graphNeighborhoodSchema>;
export type GraphNode = z.infer<typeof graphNodeSchema>;
export type GraphEdge = z.infer<typeof graphEdgeSchema>;
export type ExportJson = z.infer<typeof exportJsonSchema>;
export type RerunStageResponse = z.infer<typeof rerunStageResponseSchema>;
export interface CreateSchemaEntityTypeRequest {
name: string;
domain?: string;
description?: string | null;
}
export interface CreateSchemaRelationTypeRequest {
name: string;
domain?: string;
description?: string | null;
allowed_subject_types?: string[];
allowed_object_types?: string[];
min_confidence?: number;
}
export const platformApi = {
pipeline: (projectName: string) =>
apiClient.get(
`/projects/${encodeURIComponent(projectName)}/pipeline`,
pipelineSummarySchema,
),
extractionLogs: (projectName: string, limit = 50) =>
apiClient.get(
`/projects/${encodeURIComponent(projectName)}/extraction-logs`,
z.array(extractionLogSchema),
{ query: { limit } },
),
registry: (projectName: string) =>
apiClient.get(
`/projects/${encodeURIComponent(projectName)}/ontology/registry`,
ontologyRegistrySchema,
),
proposals: (projectName: string, limit = 100) =>
apiClient.get(
`/projects/${encodeURIComponent(projectName)}/ontology/proposals`,
z.array(ontologyProposalSchema),
{ query: { limit } },
),
triples: (projectName: string, status?: string, limit = 200) =>
apiClient.get(
`/projects/${encodeURIComponent(projectName)}/ontology/triples`,
z.array(ontologyTripleSchema),
{ query: { status, limit } },
),
graphNeighborhood: (
projectName: string,
options: {
entityId?: string | number | null;
limit?: number;
includeCandidates?: boolean;
status?: string;
} = {},
) =>
apiClient.get(
`/projects/${encodeURIComponent(projectName)}/graph/neighborhood`,
graphNeighborhoodSchema,
{
query: {
entity_id: options.entityId,
limit: options.limit ?? 200,
include_candidates: options.includeCandidates,
status: options.status,
},
},
),
exportJson: (
projectName: string,
options: { status?: string; includeEvidence?: boolean; limit?: number } = {},
) =>
apiClient.get(
`/projects/${encodeURIComponent(projectName)}/export`,
exportJsonSchema,
{
query: {
format: "json",
status: options.status ?? "validated_claim",
include_evidence: options.includeEvidence ?? true,
limit: options.limit ?? 1000,
},
},
),
exportUrl: (
projectName: string,
format: "json" | "csv" | "turtle",
status = "validated_claim",
includeEvidence = true,
limit = 1000,
) => {
const params = new URLSearchParams({
format,
status,
include_evidence: String(includeEvidence),
limit: String(limit),
});
return `/projects/${encodeURIComponent(projectName)}/export?${params.toString()}`;
},
rerunStage: (projectName: string, stage: string) =>
apiClient.post(
`/projects/${encodeURIComponent(projectName)}/pipeline/rerun`,
rerunStageResponseSchema,
{ stage },
),
createSchemaEntityType: (
projectName: string,
body: CreateSchemaEntityTypeRequest,
) =>
apiClient.post(
`/projects/${encodeURIComponent(projectName)}/schema/entity-types`,
schemaEntityTypeResponseSchema,
body,
),
createSchemaRelationType: (
projectName: string,
body: CreateSchemaRelationTypeRequest,
) =>
apiClient.post(
`/projects/${encodeURIComponent(projectName)}/schema/relation-types`,
schemaRelationTypeResponseSchema,
body,
),
};

View File

@@ -0,0 +1,64 @@
export function humanizeValue(value: unknown, empty = "-"): string {
if (value === null || value === undefined || value === "") return empty;
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
if (Array.isArray(value)) {
const rendered = value
.map((item) => humanizeValue(item, ""))
.filter(Boolean);
return rendered.length ? rendered.join(", ") : empty;
}
if (typeof value === "object") {
const record = value as Record<string, unknown>;
for (const key of [
"label",
"name",
"title",
"value",
"text",
"display",
"canonical_name",
]) {
if (record[key] !== undefined && record[key] !== null) {
return humanizeValue(record[key], empty);
}
}
const compact = Object.entries(record)
.filter(([, item]) => item !== undefined && item !== null && item !== "")
.slice(0, 4)
.map(([key, item]) => `${key}: ${humanizeValue(item, "")}`)
.filter((item) => !item.endsWith(": "));
return compact.length ? compact.join(" · ") : empty;
}
return String(value);
}
export function formatPercent(value: unknown, empty = "-"): string {
if (typeof value !== "number" || Number.isNaN(value)) return empty;
return `${Math.round(value * 100)}%`;
}
export function formatDateTime(value: unknown, empty = "-"): string {
if (typeof value !== "string" || !value) return empty;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
}
export function reviewLabel(status: string | undefined | null): string {
switch ((status ?? "").toLowerCase()) {
case "validated_claim":
return "approved";
case "rejected":
return "rejected";
case "active":
case "candidate_claim":
case "rule_candidate":
return "candidate";
default:
return status || "candidate";
}
}

View File

@@ -0,0 +1,465 @@
import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
AlertCircle,
ArrowLeft,
CheckCircle2,
Database,
ExternalLink,
FileSearch,
Layers3,
ListChecks,
PlayCircle,
RotateCw,
} 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 { useProject } from "@/hooks/useProjects";
import { usePipeline, useRerunPipelineStage } from "@/hooks/usePlatform";
import { formatDateTime, formatPercent } from "@/lib/display";
interface PipelineStep {
key: string;
name: string;
description: string;
count: number;
target: number;
status: "done" | "running" | "waiting" | "issue";
extra?: string;
}
const navigableStages = new Set(["human-review", "ontology-commit", "export"]);
function numberFrom(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function statusVariant(status: PipelineStep["status"]): BadgeProps["variant"] {
switch (status) {
case "done":
return "success";
case "running":
return "default";
case "issue":
return "warning";
default:
return "outline";
}
}
function actionLabel(step: PipelineStep): string {
if (step.key === "human-review") return "Open review";
if (step.key === "ontology-commit") return "Open graph";
if (step.key === "export") return "Open export";
return "Rerun";
}
export default function BuildPipelinePage() {
const navigate = useNavigate();
const { projectId } = useParams<{ projectId: string }>();
const projectName = projectId ?? "";
const { data: project } = useProject(projectName);
const pipeline = usePipeline(projectName);
const rerunStage = useRerunPipelineStage(projectName);
const [notice, setNotice] = useState<{
tone: "success" | "error" | "info";
text: string;
} | null>(null);
const steps = useMemo<PipelineStep[]>(() => {
const stages = pipeline.data?.stages ?? [];
const byKey = new Map(stages.map((stage) => [stage.key, stage]));
const crawled = numberFrom(byKey.get("crawled")?.count);
const extracted = numberFrom(byKey.get("extracted")?.count);
const extractionEvents = numberFrom(byKey.get("extracted")?.extra?.events);
const extractionErrors = numberFrom(byKey.get("extracted")?.extra?.errors);
const claimStatuses = byKey.get("claims")?.extra ?? {};
const claims = numberFrom(byKey.get("claims")?.count);
const validated = numberFrom(byKey.get("validated")?.count);
const rejected = numberFrom(claimStatuses.rejected);
const candidates =
numberFrom(claimStatuses.active) +
numberFrom(claimStatuses.rule_candidate) +
numberFrom(claimStatuses.candidate_claim);
const graph = numberFrom(byKey.get("graph")?.count);
const entities = numberFrom(byKey.get("graph")?.extra?.entities);
const pageTyped =
pipeline.data?.recent_pages.filter((page) => page.page_type).length ?? 0;
return [
{
key: "source-crawl",
name: "Source Crawl",
description: "Fetch source HTML, metadata, and in-domain links.",
count: crawled,
target: Math.max(crawled, 1),
status: crawled > 0 ? "done" : "waiting",
},
{
key: "page-clean",
name: "Page Clean",
description: "Normalize page text and remove repeated UI noise.",
count: crawled,
target: Math.max(crawled, 1),
status: crawled > 0 ? "done" : "waiting",
},
{
key: "page-classification",
name: "Page Classification",
description: "Classify product, brand, review, and supporting pages.",
count: pageTyped,
target: Math.max(crawled, 1),
status: pageTyped > 0 ? "done" : crawled > 0 ? "running" : "waiting",
},
{
key: "entity-extraction",
name: "Entity Extraction",
description: "Extract candidate entities from typed pages.",
count: entities,
target: Math.max(entities, 1),
status: entities > 0 ? "done" : extracted > 0 ? "running" : "waiting",
extra: `${extractionEvents} extraction events`,
},
{
key: "claim-generation",
name: "Claim Generation",
description: "Turn entities and page evidence into relation claims.",
count: claims,
target: Math.max(claims, 1),
status: claims > 0 ? "done" : entities > 0 ? "running" : "waiting",
},
{
key: "deduplication",
name: "Deduplication",
description: "Collapse repeated entities and duplicate relation claims.",
count: Math.max(claims - candidates, 0),
target: Math.max(claims, 1),
status: claims > 0 ? "done" : "waiting",
},
{
key: "validation",
name: "Validation",
description: "Check schema fit, evidence quality, and conflicts.",
count: Math.max(claims - candidates, 0),
target: Math.max(claims, 1),
status: extractionErrors > 0 ? "issue" : claims > 0 ? "done" : "waiting",
extra: extractionErrors > 0 ? `${extractionErrors} errors` : undefined,
},
{
key: "human-review",
name: "Human Review",
description: "Approve, reject, or send uncertain claims back for cleanup.",
count: validated + rejected,
target: Math.max(claims, 1),
status:
validated + rejected > 0
? "done"
: claims > 0
? "running"
: "waiting",
extra: `${validated} approved / ${rejected} rejected`,
},
{
key: "ontology-commit",
name: "Ontology Commit",
description: "Project accepted claims into the graph triple store.",
count: graph,
target: Math.max(validated, 1),
status: graph > 0 ? "done" : validated > 0 ? "running" : "waiting",
},
{
key: "export",
name: "Export",
description: "Prepare JSON, CSV, Turtle, and API access for downstream use.",
count: graph,
target: Math.max(graph, 1),
status: graph > 0 ? "done" : "waiting",
},
];
}, [pipeline.data]);
const completed = steps.filter((step) => step.status === "done").length;
const overall = steps.length ? (completed / steps.length) * 100 : 0;
async function handleStageAction(step: PipelineStep) {
setNotice(null);
try {
const response = await rerunStage.mutateAsync(step.key);
if (response.action === "navigate" && response.route) {
navigate(response.route);
return;
}
setNotice({
tone: "success",
text:
response.job_id != null
? `Started job #${response.job_id} for ${step.name}.`
: response.message ?? `${step.name} action completed.`,
});
pipeline.refetch();
} catch (error) {
setNotice({
tone: "error",
text: error instanceof Error ? error.message : "Stage action failed.",
});
}
}
return (
<div className="mx-auto max-w-7xl px-6 py-8">
<div className="mb-6 flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/crawl/${projectName}`)}
aria-label="Back"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1">
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Layers3 className="h-6 w-6 text-primary" />
Build Pipeline
</h1>
<p className="text-sm text-muted-foreground">
{project?.name ?? projectName} pipeline status and recovery actions
</p>
</div>
<Button variant="outline" onClick={() => pipeline.refetch()}>
<RotateCw className="h-4 w-4" />
Refresh
</Button>
</div>
{notice && (
<Card
className={
notice.tone === "error"
? "mb-6 border-destructive"
: "mb-6 border-green-200"
}
>
<CardContent className="flex items-center gap-2 py-4 text-sm">
<AlertCircle className="h-4 w-4" />
{notice.text}
</CardContent>
</Card>
)}
{pipeline.isError && (
<Card className="mb-6 border-destructive">
<CardContent className="flex items-center gap-2 py-4 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
{(pipeline.error as Error).message}
</CardContent>
</Card>
)}
<div className="mb-6 grid gap-4 md:grid-cols-4">
<MetricCard
icon={Database}
label="Collected pages"
value={pipeline.data?.stages.find((s) => s.key === "crawled")?.count}
/>
<MetricCard
icon={FileSearch}
label="Extraction pages"
value={pipeline.data?.stages.find((s) => s.key === "extracted")?.count}
/>
<MetricCard
icon={ListChecks}
label="Claims"
value={pipeline.data?.stages.find((s) => s.key === "claims")?.count}
/>
<MetricCard
icon={CheckCircle2}
label="Overall"
value={formatPercent(overall / 100)}
/>
</div>
<Card className="mb-6">
<CardHeader>
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle>Ontology Build Flow</CardTitle>
<CardDescription>
The ten major build stages, current counts, and recovery entry points.
</CardDescription>
</div>
<Badge variant="outline">
{completed}/{steps.length} complete
</Badge>
</div>
</CardHeader>
<CardContent>
<Progress value={overall} className="mb-5" />
{pipeline.isLoading ? (
<div className="space-y-3">
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton key={index} className="h-20" />
))}
</div>
) : (
<div className="grid gap-3 lg:grid-cols-2">
{steps.map((step, index) => {
const progress =
step.target > 0
? Math.min(100, (step.count / step.target) * 100)
: 0;
const isNavigable = navigableStages.has(step.key);
return (
<article
key={step.key}
className="rounded-md border bg-background p-4"
>
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<div className="text-xs text-muted-foreground">
Step {index + 1}
</div>
<h3 className="font-semibold">{step.name}</h3>
<p className="mt-1 text-sm text-muted-foreground">
{step.description}
</p>
</div>
<Badge variant={statusVariant(step.status)}>
{step.status}
</Badge>
</div>
<div className="flex items-center gap-3">
<Progress value={progress} className="h-2" />
<span className="w-16 text-right text-xs text-muted-foreground">
{Math.round(progress)}%
</span>
</div>
<div className="mt-3 flex items-center justify-between gap-3 text-xs text-muted-foreground">
<div>
<span>{step.count.toLocaleString()} items</span>
{step.extra && <span> / {step.extra}</span>}
</div>
<Button
size="sm"
variant={isNavigable ? "outline" : "secondary"}
disabled={!projectName || rerunStage.isPending}
onClick={() => handleStageAction(step)}
>
{isNavigable ? (
<ExternalLink className="h-3.5 w-3.5" />
) : (
<RotateCw className="h-3.5 w-3.5" />
)}
{rerunStage.isPending
? "Working"
: actionLabel(step)}
</Button>
</div>
</article>
);
})}
</div>
)}
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Recent Pages</CardTitle>
<CardDescription>Most recently collected and typed pages.</CardDescription>
</CardHeader>
<CardContent>
<ul className="divide-y">
{(pipeline.data?.recent_pages ?? []).map((page) => (
<li key={page.id} className="py-3 text-sm">
<div className="flex items-center gap-2">
<Badge variant="outline">{page.page_type ?? "unknown"}</Badge>
<span className="truncate font-medium">
{page.title || page.url}
</span>
</div>
<div className="mt-1 truncate text-xs text-muted-foreground">
{page.url}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{formatDateTime(page.fetched_at)}
</div>
</li>
))}
{pipeline.data?.recent_pages.length === 0 && (
<li className="py-6 text-center text-sm text-muted-foreground">
No collected pages yet.
</li>
)}
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Recent Claims</CardTitle>
<CardDescription>Latest relation claims from the build flow.</CardDescription>
</CardHeader>
<CardContent>
<ul className="divide-y">
{(pipeline.data?.recent_claims ?? []).map((claim) => (
<li key={claim.id} className="py-3 text-sm">
<div className="flex items-center gap-2">
<PlayCircle className="h-4 w-4 text-muted-foreground" />
<span className="truncate font-medium">
{claim.subject || "-"}
</span>
<Badge variant="secondary">{claim.predicate}</Badge>
</div>
<div className="mt-1 flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>{formatPercent(claim.confidence)}</span>
<span>{claim.status}</span>
<span>{formatDateTime(claim.last_seen_at)}</span>
</div>
</li>
))}
{pipeline.data?.recent_claims.length === 0 && (
<li className="py-6 text-center text-sm text-muted-foreground">
No claims have been generated yet.
</li>
)}
</ul>
</CardContent>
</Card>
</div>
</div>
);
}
function MetricCard({
icon: Icon,
label,
value,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
value: number | string | undefined;
}) {
return (
<Card>
<CardContent className="flex items-center justify-between gap-4 py-4">
<div>
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-1 text-2xl font-semibold">
{typeof value === "number" ? value.toLocaleString() : value ?? "-"}
</div>
</div>
<Icon className="h-5 w-5 text-muted-foreground" />
</CardContent>
</Card>
);
}

View File

@@ -175,8 +175,8 @@ export default function CrawlPage() {
)}
</div>
{terminal && job?.status === "completed" && (
<Button onClick={() => navigate(`/review/${projectName}`)}>
{t("crawl.review", "결과 검토")}
<Button onClick={() => navigate(`/pipeline/${projectName}`)}>
{t("crawl.review", "파이프라인 확인")}
<ArrowRight className="h-4 w-4" />
</Button>
)}
@@ -228,11 +228,21 @@ export default function CrawlPage() {
<Select
id="source_name"
{...register("source_name")}
onChange={(e) =>
setValue("source_name", e.target.value, {
onChange={(e) => {
const nextSourceName = e.target.value;
const nextSource = sources.find(
(source) => source.name === nextSourceName,
);
setValue("source_name", nextSourceName, {
shouldValidate: true,
})
}
shouldDirty: true,
});
setValue("url", nextSource?.base_url ?? "", {
shouldValidate: Boolean(nextSource?.base_url),
shouldDirty: true,
});
}}
>
<option value="">
{t("crawl.pickSource", "소스를 선택하세요...")}
@@ -462,7 +472,7 @@ export default function CrawlPage() {
{terminal && job.status === "completed" && (
<div className="flex items-center gap-2 text-sm text-green-700">
<CheckCircle2 className="h-4 w-4" />
{t("crawl.doneHint", "크롤 완료. 결과 검토로 이동하세요.")}
{t("crawl.doneHint", "크롤 완료. 구축 파이프라인으로 이동하세요.")}
</div>
)}
</div>

View File

@@ -11,6 +11,9 @@ import {
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { useProjects } from "@/hooks/useProjects";
import { usePipeline } from "@/hooks/usePlatform";
import { formatPercent } from "@/lib/display";
import { ProjectSummary } from "@/lib/api/projects";
function formatRelative(iso: string): string {
const d = new Date(iso);
@@ -108,27 +111,7 @@ export default function DashboardPage() {
{projects && projects.length > 0 && (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{projects.map((p) => (
<Card
key={p.id}
role="button"
tabIndex={0}
onClick={() => navigate(`/sources/${p.name}`)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
navigate(`/sources/${p.name}`);
}
}}
className="cursor-pointer transition-colors hover:bg-accent/40"
>
<CardHeader>
<CardTitle>{p.name}</CardTitle>
<CardDescription>{p.domain}</CardDescription>
</CardHeader>
<CardContent className="flex items-center gap-2 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
{formatRelative(p.updated_at)}
</CardContent>
</Card>
<ProjectCard key={p.id} project={p} onOpen={navigate} />
))}
</div>
)}
@@ -136,3 +119,62 @@ export default function DashboardPage() {
</div>
);
}
function ProjectCard({
project,
onOpen,
}: {
project: ProjectSummary;
onOpen: (path: string) => void;
}) {
const pipeline = usePipeline(project.name);
const claimStage = pipeline.data?.stages.find((stage) => stage.key === "claims");
const approvedStage = pipeline.data?.stages.find(
(stage) => stage.key === "validated",
);
const pageStage = pipeline.data?.stages.find((stage) => stage.key === "crawled");
const claimCount = claimStage?.count ?? 0;
const approvedCount = approvedStage?.count ?? 0;
const approvalRate = claimCount > 0 ? approvedCount / claimCount : 0;
return (
<Card
role="button"
tabIndex={0}
onClick={() => onOpen(`/sources/${project.name}`)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
onOpen(`/sources/${project.name}`);
}
}}
className="cursor-pointer transition-colors hover:bg-accent/40"
>
<CardHeader>
<CardTitle>{project.name}</CardTitle>
<CardDescription>{project.domain}</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-2 text-xs">
<ProjectStat label="Pages" value={pageStage?.count ?? 0} />
<ProjectStat label="Claims" value={claimCount} />
<ProjectStat label="Approved" value={formatPercent(approvalRate)} />
</div>
<div className="mt-3 flex items-center gap-2 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
{formatRelative(project.updated_at)}
</div>
</CardContent>
</Card>
);
}
function ProjectStat({ label, value }: { label: string; value: number | string }) {
return (
<div className="rounded-md border bg-background px-2 py-1">
<div className="text-muted-foreground">{label}</div>
<div className="font-semibold">
{typeof value === "number" ? value.toLocaleString() : value}
</div>
</div>
);
}

View File

@@ -0,0 +1,402 @@
import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
AlertCircle,
ArrowLeft,
Braces,
Clipboard,
Code2,
FileJson,
FileSpreadsheet,
FileText,
RefreshCw,
} from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
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 { useExportJson } from "@/hooks/usePlatform";
import { useProject } from "@/hooks/useProjects";
import { platformApi } from "@/lib/api/platform";
import { formatDateTime, formatPercent, humanizeValue } from "@/lib/display";
import { cn } from "@/lib/utils";
const statusOptions = [
{ value: "validated_claim", label: "Validated claims" },
{ value: "active", label: "Active claims" },
{ value: "candidate_claim", label: "Candidate claims" },
{ value: "rule_candidate", label: "Rule candidates" },
{ value: "rejected", label: "Rejected claims" },
{ value: "all", label: "All statuses" },
];
function valueFrom(row: Record<string, unknown>, key: string): string {
return humanizeValue(row[key]);
}
export default function ExportApiPage() {
const navigate = useNavigate();
const { projectId } = useParams<{ projectId: string }>();
const projectName = projectId ?? "";
const { data: project } = useProject(projectName);
const [status, setStatus] = useState("validated_claim");
const [includeEvidence, setIncludeEvidence] = useState(true);
const [limit, setLimit] = useState(1000);
const [copied, setCopied] = useState<string | null>(null);
const exportData = useExportJson(projectName, {
status,
includeEvidence,
limit,
});
const urls = useMemo(
() => ({
json: platformApi.exportUrl(
projectName,
"json",
status,
includeEvidence,
limit,
),
csv: platformApi.exportUrl(
projectName,
"csv",
status,
includeEvidence,
limit,
),
turtle: platformApi.exportUrl(
projectName,
"turtle",
status,
includeEvidence,
limit,
),
}),
[includeEvidence, limit, projectName, status],
);
const origin =
typeof window !== "undefined" && window.location?.origin
? window.location.origin
: "";
const absoluteJsonUrl = `${origin}${urls.json}`;
const curlSnippet = `curl -L "${absoluteJsonUrl}"`;
const fetchSnippet = `const response = await fetch("${urls.json}");\nconst ontology = await response.json();`;
const previewRows = exportData.data?.claims.slice(0, 8) ?? [];
const avgConfidence = exportData.data?.claims.length
? exportData.data.claims.reduce((sum, row) => {
const confidence = row.confidence;
return sum + (typeof confidence === "number" ? confidence : 0);
}, 0) / exportData.data.claims.length
: 0;
async function copy(text: string, key: string) {
try {
await navigator.clipboard.writeText(text);
setCopied(key);
window.setTimeout(() => setCopied(null), 1600);
} catch {
setCopied("failed");
}
}
return (
<div className="mx-auto max-w-7xl px-6 py-8">
<div className="mb-6 flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/graph/${projectName}`)}
aria-label="Back"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1">
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Code2 className="h-6 w-6 text-primary" />
Export / API Center
</h1>
<p className="text-sm text-muted-foreground">
{project?.name ?? projectName} exports, endpoint settings, and integration snippets.
</p>
</div>
<Button variant="outline" onClick={() => exportData.refetch()}>
<RefreshCw className="h-4 w-4" />
Refresh
</Button>
</div>
{exportData.isError && (
<Card className="mb-6 border-destructive">
<CardContent className="flex items-center gap-2 py-4 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
{(exportData.error as Error).message}
</CardContent>
</Card>
)}
<div className="mb-6 grid gap-3 lg:grid-cols-[220px_180px_1fr_180px]">
<Select value={status} onChange={(event) => setStatus(event.target.value)}>
{statusOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
<Input
type="number"
min={1}
max={5000}
value={limit}
onChange={(event) =>
setLimit(Math.max(1, Math.min(5000, Number(event.target.value) || 1)))
}
aria-label="Export row limit"
/>
<label className="flex items-center gap-2 rounded-md border px-3 text-sm">
<input
type="checkbox"
checked={includeEvidence}
onChange={(event) => setIncludeEvidence(event.target.checked)}
/>
Include evidence text
</label>
<Badge variant="outline" className="justify-center py-2">
{exportData.data?.count ?? 0} rows
</Badge>
</div>
<div className="mb-6 grid gap-4 md:grid-cols-3">
<Card>
<CardContent className="py-4">
<div className="text-xs text-muted-foreground">Export rows</div>
<div className="mt-1 text-3xl font-semibold">
{(exportData.data?.count ?? 0).toLocaleString()}
</div>
</CardContent>
</Card>
<Card>
<CardContent className="py-4">
<div className="text-xs text-muted-foreground">Status scope</div>
<div className="mt-1 text-lg font-semibold">
{statusOptions.find((option) => option.value === status)?.label}
</div>
</CardContent>
</Card>
<Card>
<CardContent className="py-4">
<div className="text-xs text-muted-foreground">Avg confidence</div>
<div className="mt-1 text-3xl font-semibold">
{formatPercent(avgConfidence)}
</div>
</CardContent>
</Card>
</div>
<div className="grid gap-6 xl:grid-cols-[1fr_380px]">
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle>Export Preview</CardTitle>
<CardDescription>
First rows from the JSON export endpoint.
</CardDescription>
</div>
<div className="flex flex-wrap gap-2">
<DownloadLink href={urls.json} icon={FileJson} label="JSON" />
<DownloadLink href={urls.csv} icon={FileSpreadsheet} label="CSV" />
<DownloadLink href={urls.turtle} icon={FileText} label="Turtle" />
</div>
</div>
</CardHeader>
<CardContent>
{exportData.isLoading ? (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, index) => (
<Skeleton key={index} className="h-16" />
))}
</div>
) : previewRows.length === 0 ? (
<div className="py-12 text-center text-sm text-muted-foreground">
No export rows match the current settings.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="border-b text-xs text-muted-foreground">
<tr>
<th className="py-2 pr-3">Claim</th>
<th className="py-2 pr-3">Subject</th>
<th className="py-2 pr-3">Predicate</th>
<th className="py-2 pr-3">Object</th>
<th className="py-2 pr-3">Confidence</th>
<th className="py-2 pr-3">Last seen</th>
</tr>
</thead>
<tbody className="divide-y">
{previewRows.map((row, index) => (
<tr key={`${valueFrom(row, "claim_id")}-${index}`}>
<td className="py-3 pr-3 font-medium">
#{valueFrom(row, "claim_id")}
</td>
<td className="max-w-[180px] truncate py-3 pr-3">
{valueFrom(row, "subject")}
</td>
<td className="py-3 pr-3">
<Badge variant="secondary">
{valueFrom(row, "predicate")}
</Badge>
</td>
<td className="max-w-[220px] truncate py-3 pr-3">
{valueFrom(row, "object")}
</td>
<td className="py-3 pr-3">
{typeof row.confidence === "number"
? formatPercent(row.confidence)
: "-"}
</td>
<td className="py-3 pr-3">
{formatDateTime(
typeof row.last_seen_at === "string"
? row.last_seen_at
: undefined,
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
<aside className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Braces className="h-5 w-5" />
API Endpoint
</CardTitle>
<CardDescription>Same settings as the preview.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<CodeBlock value={absoluteJsonUrl} />
<Button
variant="outline"
className="w-full"
onClick={() => copy(absoluteJsonUrl, "endpoint")}
>
<Clipboard className="h-4 w-4" />
{copied === "endpoint" ? "Copied" : "Copy endpoint"}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Integration Snippets</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Snippet
title="cURL"
value={curlSnippet}
copied={copied === "curl"}
onCopy={() => copy(curlSnippet, "curl")}
/>
<Snippet
title="JavaScript"
value={fetchSnippet}
copied={copied === "fetch"}
onCopy={() => copy(fetchSnippet, "fetch")}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Formats</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<FormatRow label="JSON" detail="API-first claim objects with evidence." />
<FormatRow label="CSV" detail="Spreadsheet-friendly rows for review." />
<FormatRow label="Turtle" detail="RDF triples for semantic graph tools." />
</CardContent>
</Card>
</aside>
</div>
</div>
);
}
function DownloadLink({
href,
icon: Icon,
label,
}: {
href: string;
icon: React.ComponentType<{ className?: string }>;
label: string;
}) {
return (
<a
href={href}
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
<Icon className="h-4 w-4" />
{label}
</a>
);
}
function CodeBlock({ value }: { value: string }) {
return (
<pre className="max-h-40 overflow-auto rounded-md bg-secondary/40 p-3 text-xs">
<code>{value}</code>
</pre>
);
}
function Snippet({
title,
value,
copied,
onCopy,
}: {
title: string;
value: string;
copied: boolean;
onCopy: () => void;
}) {
return (
<div className="rounded-md border p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="text-sm font-medium">{title}</span>
<Button variant="ghost" size="sm" onClick={onCopy}>
<Clipboard className="h-4 w-4" />
{copied ? "Copied" : "Copy"}
</Button>
</div>
<CodeBlock value={value} />
</div>
);
}
function FormatRow({ label, detail }: { label: string; detail: string }) {
return (
<div className="rounded-md border px-3 py-2">
<div className="font-medium">{label}</div>
<div className="text-xs text-muted-foreground">{detail}</div>
</div>
);
}

View File

@@ -0,0 +1,567 @@
import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
AlertCircle,
ArrowLeft,
CircleDot,
GitBranch,
Network,
RefreshCw,
Search,
SlidersHorizontal,
} from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import { Select } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { useGraphNeighborhood } from "@/hooks/usePlatform";
import { useProject } from "@/hooks/useProjects";
import { GraphEdge, GraphNode } from "@/lib/api/platform";
import { formatDateTime, formatPercent, humanizeValue } from "@/lib/display";
const COLORS = [
"#2563eb",
"#059669",
"#d97706",
"#7c3aed",
"#db2777",
"#0891b2",
"#4b5563",
"#dc2626",
];
interface VisualNode {
id: string;
name: string;
type: string;
x: number;
y: number;
radius: number;
color: string;
source: "entity" | "literal";
raw?: GraphNode;
}
interface VisualEdge {
id: string;
source: string;
target: string;
predicate: string;
confidence: number;
raw: GraphEdge;
}
function nodeColor(type: string, types: string[]): string {
const index = Math.max(types.indexOf(type), 0);
return COLORS[index % COLORS.length];
}
function buildGraph(
nodes: GraphNode[],
edges: GraphEdge[],
options: {
search: string;
predicate: string;
entityType: string;
minConfidence: number;
},
) {
const search = options.search.trim().toLowerCase();
const types = Array.from(
new Set(nodes.map((node) => node.type || "Entity").concat("Literal")),
).sort();
const nodeMap = new Map<string, VisualNode>();
nodes.forEach((node) => {
const type = node.type || "Entity";
nodeMap.set(String(node.id), {
id: String(node.id),
name: node.name,
type,
x: 0,
y: 0,
radius: 18,
color: nodeColor(type, types),
source: "entity",
raw: node,
});
});
const visualEdges: VisualEdge[] = [];
edges.forEach((edge) => {
const confidence = edge.confidence ?? 0;
if (confidence < options.minConfidence) return;
if (options.predicate && edge.predicate !== options.predicate) return;
const source = String(edge.source);
const target =
edge.target !== null && edge.target !== undefined
? String(edge.target)
: `literal-${edge.claim_id}`;
if (!nodeMap.has(source)) return;
if (!nodeMap.has(target)) {
const label = humanizeValue(edge.target_value ?? edge.object?.name);
nodeMap.set(target, {
id: target,
name: label || "(empty value)",
type: "Literal",
x: 0,
y: 0,
radius: 14,
color: nodeColor("Literal", types),
source: "literal",
});
}
visualEdges.push({
id: String(edge.claim_id),
source,
target,
predicate: edge.predicate,
confidence,
raw: edge,
});
});
let visualNodes = Array.from(nodeMap.values()).filter((node) => {
if (options.entityType && node.type !== options.entityType) return false;
if (!search) return true;
return (
node.name.toLowerCase().includes(search) ||
node.type.toLowerCase().includes(search)
);
});
const visibleIds = new Set(visualNodes.map((node) => node.id));
const filteredEdges = visualEdges.filter(
(edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target),
);
const connectedIds = new Set<string>();
filteredEdges.forEach((edge) => {
connectedIds.add(edge.source);
connectedIds.add(edge.target);
});
if (options.predicate || options.minConfidence > 0 || search) {
visualNodes = visualNodes.filter((node) => connectedIds.has(node.id));
}
const centerX = 430;
const centerY = 300;
const radius = Math.max(160, Math.min(260, visualNodes.length * 13));
visualNodes.forEach((node, index) => {
const angle = (Math.PI * 2 * index) / Math.max(visualNodes.length, 1);
node.x = centerX + Math.cos(angle) * radius;
node.y = centerY + Math.sin(angle) * radius;
});
return { nodes: visualNodes, edges: filteredEdges, types };
}
function edgeEndpoint(edge: VisualEdge, nodes: VisualNode[]) {
const source = nodes.find((node) => node.id === edge.source);
const target = nodes.find((node) => node.id === edge.target);
return { source, target };
}
export default function GraphViewPage() {
const navigate = useNavigate();
const { projectId } = useParams<{ projectId: string }>();
const projectName = projectId ?? "";
const { data: project } = useProject(projectName);
const [statusScope, setStatusScope] = useState("validated_claim");
const [includeCandidates, setIncludeCandidates] = useState(true);
const [search, setSearch] = useState("");
const [predicate, setPredicate] = useState("");
const [entityType, setEntityType] = useState("");
const [minConfidence, setMinConfidence] = useState(0);
const [selected, setSelected] = useState<VisualNode | VisualEdge | null>(null);
const graph = useGraphNeighborhood(projectName, {
includeCandidates: includeCandidates && statusScope === "validated_claim",
limit: 300,
status:
includeCandidates && statusScope === "validated_claim"
? undefined
: statusScope,
});
const predicates = useMemo(
() =>
Array.from(
new Set((graph.data?.edges ?? []).map((edge) => edge.predicate)),
).sort(),
[graph.data?.edges],
);
const visual = useMemo(
() =>
buildGraph(graph.data?.nodes ?? [], graph.data?.edges ?? [], {
search,
predicate,
entityType,
minConfidence,
}),
[
entityType,
graph.data?.edges,
graph.data?.nodes,
minConfidence,
predicate,
search,
],
);
const averageConfidence = visual.edges.length
? visual.edges.reduce((sum, edge) => sum + edge.confidence, 0) /
visual.edges.length
: 0;
return (
<div className="mx-auto max-w-7xl px-6 py-8">
<div className="mb-6 flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/quality/${projectName}`)}
aria-label="Back"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1">
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Network className="h-6 w-6 text-primary" />
Graph View
</h1>
<p className="text-sm text-muted-foreground">
{project?.name ?? projectName} relation graph, candidates, and edge evidence.
</p>
</div>
<Button variant="outline" onClick={() => graph.refetch()}>
<RefreshCw className="h-4 w-4" />
Refresh
</Button>
</div>
{graph.isError && (
<Card className="mb-6 border-destructive">
<CardContent className="flex items-center gap-2 py-4 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
{(graph.error as Error).message}
</CardContent>
</Card>
)}
<div className="mb-4 grid gap-3 xl:grid-cols-[1fr_180px_180px_180px_220px]">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
className="pl-9"
placeholder="Search node or type"
/>
</div>
<Select
value={entityType}
onChange={(event) => setEntityType(event.target.value)}
>
<option value="">All types</option>
{visual.types.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</Select>
<Select
value={predicate}
onChange={(event) => setPredicate(event.target.value)}
>
<option value="">All predicates</option>
{predicates.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</Select>
<Select
value={statusScope}
onChange={(event) => setStatusScope(event.target.value)}
>
<option value="validated_claim">Validated</option>
<option value="candidate_claim">Candidate</option>
<option value="rule_candidate">Rule candidate</option>
<option value="active">Active</option>
<option value="all">All statuses</option>
</Select>
<label className="flex items-center gap-2 rounded-md border px-3 text-sm">
<input
type="checkbox"
checked={includeCandidates}
disabled={statusScope !== "validated_claim"}
onChange={(event) => setIncludeCandidates(event.target.checked)}
/>
Blend candidates
</label>
</div>
<div className="mb-6 rounded-md border px-4 py-3">
<div className="mb-2 flex items-center justify-between text-sm">
<span className="flex items-center gap-2 text-muted-foreground">
<SlidersHorizontal className="h-4 w-4" />
Minimum confidence
</span>
<span>{formatPercent(minConfidence)}</span>
</div>
<input
type="range"
min={0}
max={1}
step={0.05}
value={minConfidence}
onChange={(event) => setMinConfidence(Number(event.target.value))}
className="w-full"
/>
</div>
<div className="grid gap-6 xl:grid-cols-[1fr_360px]">
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle>Ontology Network</CardTitle>
<CardDescription>
{visual.nodes.length} nodes / {visual.edges.length} edges shown
</CardDescription>
</div>
<div className="flex flex-wrap gap-2">
{visual.types.slice(0, 8).map((type) => (
<span key={type} className="flex items-center gap-1 text-xs">
<span
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: nodeColor(type, visual.types) }}
/>
{type}
</span>
))}
</div>
</div>
</CardHeader>
<CardContent>
{graph.isLoading ? (
<Skeleton className="h-[640px]" />
) : visual.nodes.length === 0 ? (
<div className="flex h-[500px] flex-col items-center justify-center gap-2 text-center text-sm text-muted-foreground">
<GitBranch className="h-12 w-12 opacity-50" />
No graph is available for the current filters.
</div>
) : (
<svg
viewBox="0 0 860 600"
className="h-[640px] w-full rounded-md border bg-secondary/20"
role="img"
aria-label="Ontology graph"
>
<defs>
<marker
id="arrow"
markerWidth="10"
markerHeight="10"
refX="10"
refY="3"
orient="auto"
markerUnits="strokeWidth"
>
<path d="M0,0 L0,6 L9,3 z" fill="#64748b" />
</marker>
</defs>
{visual.edges.map((edge) => {
const { source, target } = edgeEndpoint(edge, visual.nodes);
if (!source || !target) return null;
const midX = (source.x + target.x) / 2;
const midY = (source.y + target.y) / 2;
return (
<g
key={edge.id}
className="cursor-pointer"
onClick={() => setSelected(edge)}
>
<line
x1={source.x}
y1={source.y}
x2={target.x}
y2={target.y}
stroke="#64748b"
strokeWidth={1 + edge.confidence * 3}
strokeOpacity="0.65"
markerEnd="url(#arrow)"
/>
<text
x={midX}
y={midY}
textAnchor="middle"
className="fill-slate-600 text-[10px]"
>
{edge.predicate}
</text>
</g>
);
})}
{visual.nodes.map((node) => (
<g
key={node.id}
className="cursor-pointer"
onClick={() => setSelected(node)}
>
<circle
cx={node.x}
cy={node.y}
r={node.radius}
fill={node.color}
stroke={
selected && "id" in selected && selected.id === node.id
? "#111827"
: "#ffffff"
}
strokeWidth="2"
/>
<text
x={node.x}
y={node.y + node.radius + 14}
textAnchor="middle"
className="fill-slate-800 text-[11px] font-medium"
>
{node.name.length > 24
? `${node.name.slice(0, 24)}...`
: node.name}
</text>
</g>
))}
</svg>
)}
</CardContent>
</Card>
<aside className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Selection</CardTitle>
<CardDescription>Click a node or edge to inspect it.</CardDescription>
</CardHeader>
<CardContent>
{!selected && (
<p className="py-8 text-center text-sm text-muted-foreground">
Nothing selected.
</p>
)}
{selected && "predicate" in selected && (
<div className="space-y-3">
<Badge variant="secondary">{selected.predicate}</Badge>
<Detail label="Claim" value={`#${selected.id}`} />
<Detail label="Status" value={selected.raw.status} />
<Detail
label="Confidence"
value={formatPercent(selected.confidence)}
/>
<Detail
label="Object"
value={selected.raw.object?.name ?? selected.raw.target_value}
/>
<Detail
label="Last seen"
value={formatDateTime(selected.raw.last_seen_at)}
/>
<Detail
label="Metadata"
value={humanizeValue(selected.raw.metadata)}
/>
</div>
)}
{selected && !("predicate" in selected) && (
<div className="space-y-3">
<Badge variant="outline">{selected.type}</Badge>
<Detail label="Name" value={selected.name} />
<Detail label="Source" value={selected.source} />
<Detail label="Canonical" value={selected.raw?.canonical_name} />
<Detail
label="Metadata"
value={humanizeValue(selected.raw?.metadata)}
/>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Graph Health</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<HealthRow label="Visible nodes" value={visual.nodes.length} />
<HealthRow label="Visible edges" value={visual.edges.length} />
<HealthRow
label="Avg confidence"
value={
visual.edges.length ? formatPercent(averageConfidence) : "-"
}
/>
<Progress value={averageConfidence * 100} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CircleDot className="h-5 w-5" />
Predicate Counts
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm">
{predicates.map((name) => {
const count = (graph.data?.edges ?? []).filter(
(edge) => edge.predicate === name,
).length;
return (
<li
key={name}
className="flex items-center justify-between rounded-md border px-3 py-2"
>
<span>{name}</span>
<span className="font-medium">{count}</span>
</li>
);
})}
{predicates.length === 0 && (
<li className="py-4 text-center text-muted-foreground">
No predicates found.
</li>
)}
</ul>
</CardContent>
</Card>
</aside>
</div>
</div>
);
}
function Detail({ label, value }: { label: string; value: unknown }) {
return (
<div className="rounded-md border px-3 py-2 text-sm">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-1 break-words font-medium">{humanizeValue(value)}</div>
</div>
);
}
function HealthRow({ label, value }: { label: string; value: number | string }) {
return (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium">
{typeof value === "number" ? value.toLocaleString() : value}
</span>
</div>
);
}

View File

@@ -0,0 +1,333 @@
import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
AlertCircle,
ArrowLeft,
Code2,
FileText,
Highlighter,
Loader2,
SearchCheck,
} from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { useClaims } from "@/hooks/useClaims";
import { useProject } from "@/hooks/useProjects";
import { useExtractionLogs } from "@/hooks/usePlatform";
import { formatDateTime, formatPercent, humanizeValue } from "@/lib/display";
import { Claim } from "@/lib/api/claims";
import { ExtractionLog } from "@/lib/api/platform";
function asRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null
? (value as Record<string, unknown>)
: {};
}
function firstText(...values: unknown[]): string {
for (const value of values) {
if (typeof value === "string" && value.trim()) return value;
}
return "";
}
function candidateClaims(log: ExtractionLog | undefined): unknown[] {
const raw = asRecord(log?.raw_output);
const direct = raw.candidate_claims;
if (Array.isArray(direct)) return direct;
const validation = asRecord(raw.validation);
const nested = validation.candidate_claims;
return Array.isArray(nested) ? nested : [];
}
export default function PageAnalysisPage() {
const navigate = useNavigate();
const { projectId } = useParams<{ projectId: string }>();
const projectName = projectId ?? "";
const { data: project } = useProject(projectName);
const logs = useExtractionLogs(projectName, 100);
const claims = useClaims(projectName, {
includeCandidates: true,
limit: 300,
});
const [selectedId, setSelectedId] = useState<string | null>(null);
const selectedLog = useMemo(() => {
const all = logs.data ?? [];
return all.find((log) => log.id === selectedId) ?? all[0];
}, [logs.data, selectedId]);
const selectedPageClaims = useMemo<Claim[]>(() => {
if (!selectedLog?.page_url) return [];
return (claims.data ?? []).filter(
(claim) => claim.page_url === selectedLog.page_url,
);
}, [claims.data, selectedLog?.page_url]);
const pageContext = asRecord(selectedLog?.page_context);
const rawOutput = asRecord(selectedLog?.raw_output);
const rawContext = asRecord(rawOutput.page_context);
const cleanedText = firstText(
pageContext.clean_text,
pageContext.cleaned_text,
pageContext.text,
rawContext.clean_text,
rawContext.cleaned_text,
rawContext.text,
);
const htmlText = firstText(
pageContext.html,
pageContext.raw_html,
rawContext.html,
rawContext.raw_html,
);
return (
<div className="mx-auto max-w-7xl px-6 py-8">
<div className="mb-6 flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/pipeline/${projectName}`)}
aria-label="Back"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1">
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
<SearchCheck className="h-6 w-6 text-primary" />
Page Analysis
</h1>
<p className="text-sm text-muted-foreground">
{project?.name ?? projectName} ·
</p>
</div>
<Button variant="outline" onClick={() => logs.refetch()}>
Refresh
</Button>
</div>
{(logs.isError || claims.isError) && (
<Card className="mb-6 border-destructive">
<CardContent className="flex items-center gap-2 py-4 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
{(logs.error as Error | undefined)?.message ??
(claims.error as Error | undefined)?.message}
</CardContent>
</Card>
)}
<div className="grid gap-6 lg:grid-cols-[360px_1fr]">
<Card>
<CardHeader>
<CardTitle>Analysis Runs</CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent>
{logs.isLoading && (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, index) => (
<Skeleton key={index} className="h-16" />
))}
</div>
)}
{logs.data?.length === 0 && (
<p className="py-8 text-center text-sm text-muted-foreground">
. .
</p>
)}
<ul className="max-h-[720px] divide-y overflow-y-auto">
{(logs.data ?? []).map((log) => (
<li key={log.id} className="py-2">
<button
type="button"
onClick={() => setSelectedId(log.id)}
className="w-full rounded-md px-3 py-2 text-left transition-colors hover:bg-accent/10"
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-medium">
{log.page_url ?? `run #${log.id}`}
</span>
<Badge variant={log.error ? "destructive" : "success"}>
{log.error ? "error" : "ok"}
</Badge>
</div>
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
<span>{log.extractor_name}</span>
<span>{log.provider}</span>
<span>{log.candidate_count} candidates</span>
</div>
<div className="mt-1 text-xs text-muted-foreground">
{formatDateTime(log.created_at)}
</div>
</button>
</li>
))}
</ul>
</CardContent>
</Card>
<div className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>Selected Page</CardTitle>
<CardDescription>
{selectedLog?.page_url ?? "분석 로그를 선택하세요"}
</CardDescription>
</div>
{selectedLog && (
<Badge variant={selectedLog.error ? "destructive" : "outline"}>
{selectedLog.provider}
</Badge>
)}
</div>
</CardHeader>
<CardContent>
{!selectedLog && (
<p className="py-8 text-center text-sm text-muted-foreground">
.
</p>
)}
{selectedLog && (
<div className="grid gap-4 md:grid-cols-3">
<InfoBox label="Extractor" value={selectedLog.extractor_name} />
<InfoBox
label="Candidates"
value={String(selectedLog.candidate_count)}
/>
<InfoBox
label="Validation"
value={humanizeValue(selectedLog.validation)}
/>
</div>
)}
{selectedLog?.error && (
<div className="mt-4 rounded-md border border-destructive bg-destructive/5 px-3 py-2 text-sm text-destructive">
{selectedLog.error}
</div>
)}
</CardContent>
</Card>
<div className="grid gap-6 xl:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Cleaned Text
</CardTitle>
<CardDescription>
AI
</CardDescription>
</CardHeader>
<CardContent>
<pre className="max-h-80 overflow-auto whitespace-pre-wrap rounded-md bg-secondary/30 p-3 text-xs leading-relaxed">
{cleanedText || "정제 본문이 저장되어 있지 않습니다."}
</pre>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Code2 className="h-5 w-5" />
Raw Context
</CardTitle>
<CardDescription>
HTML
</CardDescription>
</CardHeader>
<CardContent>
<pre className="max-h-80 overflow-auto whitespace-pre-wrap rounded-md bg-secondary/30 p-3 text-xs leading-relaxed">
{htmlText || JSON.stringify(pageContext || rawContext, null, 2)}
</pre>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Extracted Claims</CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent>
{claims.isLoading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
<div className="space-y-3">
{selectedPageClaims.map((claim) => (
<article key={claim.id} className="rounded-md border p-4">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-medium">
{humanizeValue(claim.subject)}
</span>
<Badge variant="secondary">{claim.predicate}</Badge>
<span className="font-medium">
{humanizeValue(claim.object ?? claim.object_value)}
</span>
<Badge variant="outline">
{formatPercent(claim.confidence)}
</Badge>
</div>
{claim.evidence_text && (
<div className="mt-3 rounded-md bg-yellow-50 px-3 py-2 text-sm text-yellow-950">
<Highlighter className="mr-1 inline h-4 w-4" />
{claim.evidence_text}
</div>
)}
</article>
))}
{!claims.isLoading && selectedPageClaims.length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">
URL과 .
</p>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Candidate Payload</CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent>
<pre className="max-h-96 overflow-auto whitespace-pre-wrap rounded-md bg-secondary/30 p-3 text-xs">
{JSON.stringify(candidateClaims(selectedLog), null, 2)}
</pre>
</CardContent>
</Card>
</div>
</div>
</div>
);
}
function InfoBox({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-md border bg-background px-3 py-2">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-1 truncate text-sm font-medium">{value || "-"}</div>
</div>
);
}

View File

@@ -0,0 +1,801 @@
import { useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
AlertCircle,
AlertTriangle,
ArrowLeft,
ClipboardCheck,
Link,
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 { useClaims } from "@/hooks/useClaims";
import { useEntities } from "@/hooks/useEntities";
import { useProject } from "@/hooks/useProjects";
import { useOntologyRegistry, usePipeline } from "@/hooks/usePlatform";
import { Claim } from "@/lib/api/claims";
import { Entity } from "@/lib/api/entities";
import { OntologyRegistry } from "@/lib/api/platform";
import { formatPercent, humanizeValue } from "@/lib/display";
interface QualityIssue {
id: string;
type: string;
severity: "high" | "medium" | "low";
title: string;
detail: string;
target: string;
}
type RelationRule = OntologyRegistry["relation_types"][number];
const okValidationStatuses = new Set(["valid", "ok", "passed", "clean"]);
const okGraphStatuses = new Set(["merged", "active", "synced", "ok", "ready"]);
const genericLabels = new Set([
"value",
"keyword",
"name",
"item",
"product",
"brand",
"accord",
"note",
"unknown",
]);
function severityVariant(severity: QualityIssue["severity"]): BadgeProps["variant"] {
switch (severity) {
case "high":
return "destructive";
case "medium":
return "warning";
default:
return "outline";
}
}
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<string, unknown> | undefined,
keys: string[],
): number | undefined {
if (!record) return undefined;
for (const key of keys) {
const value = record[key];
if (typeof value === "number" && Number.isFinite(value)) return value;
}
return undefined;
}
function minConfidenceFor(rule: RelationRule | undefined): number {
return (
ruleNumber(rule?.confidence_rules, [
"min_confidence",
"minimum_confidence",
"minConfidence",
]) ?? 0.7
);
}
function daysSince(value: string | null | undefined): number | undefined {
if (!value) return undefined;
const time = new Date(value).getTime();
if (!Number.isFinite(time)) return undefined;
return (Date.now() - time) / 86_400_000;
}
function buildIssues(
claims: Claim[],
entities: Entity[],
relationRules: RelationRule[],
): QualityIssue[] {
const issues: QualityIssue[] = [];
const seenRelations = new Map<string, Claim>();
const entityNameGroups = new Map<string, Entity[]>();
const connectedNames = new Set<string>();
const entityTypeByName = new Map<string, string>();
const relationRuleByName = new Map<string, RelationRule>();
relationRules.forEach((rule) => {
relationRuleByName.set(rule.name.toLowerCase(), rule);
});
for (const entity of entities) {
const key = normalizedName(entity.name);
if (!key) continue;
const group = entityNameGroups.get(key) ?? [];
group.push(entity);
entityNameGroups.set(key, group);
entityTypeByName.set(key, entity.type);
}
for (const claim of claims) {
const object = claimObject(claim);
const target = `${humanizeValue(claim.subject)} ${claim.predicate} ${object}`;
const rule = relationRuleByName.get(claim.predicate.toLowerCase());
const subjectType =
claim.subject_type ?? entityTypeByName.get(normalizedName(claim.subject));
const objectType =
claim.object_type ??
entityTypeByName.get(normalizedName(object)) ??
claimValueType(claim);
const evidence = claim.evidence_text?.trim() ?? "";
const sourceUrl = claim.page_url?.trim() ?? "";
if (!rule) {
issues.push({
id: `unknown-predicate-${claim.id}`,
type: "unknown_predicate",
severity: "medium",
title: "Predicate is not registered",
detail: `${claim.predicate} is missing from the ontology registry.`,
target,
});
}
if (!sourceUrl || !evidence) {
issues.push({
id: `source-${claim.id}`,
type: "missing_source",
severity: "high",
title: "Source or evidence is missing",
detail: "Every claim should carry both page URL and evidence text.",
target,
});
} else if (evidence.length < 24) {
issues.push({
id: `thin-evidence-${claim.id}`,
type: "thin_evidence",
severity: "medium",
title: "Evidence snippet is too thin",
detail: "The supporting text is short enough to be ambiguous.",
target,
});
}
if (evidence && claim.subject && object) {
const evidenceLower = evidence.toLowerCase();
const subjectLower = normalizedName(claim.subject);
const objectLower = normalizedName(object);
const subjectMentioned =
subjectLower.length > 2 && evidenceLower.includes(subjectLower);
const objectMentioned =
objectLower.length > 2 && evidenceLower.includes(objectLower);
if (!subjectMentioned && !objectMentioned) {
issues.push({
id: `evidence-alignment-${claim.id}`,
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.",
target,
});
}
}
const minConfidence = minConfidenceFor(rule);
if (typeof claim.confidence !== "number") {
issues.push({
id: `confidence-missing-${claim.id}`,
type: "missing_confidence",
severity: "medium",
title: "Confidence is missing",
detail: "Claims need confidence for review prioritization.",
target,
});
} else if (claim.confidence < minConfidence) {
issues.push({
id: `confidence-${claim.id}`,
type: "low_confidence",
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({
id: `subject-type-missing-${claim.id}`,
type: "missing_subject_type",
severity: "medium",
title: "Subject type is missing",
detail: `Allowed subject types: ${listLabel(rule.allowed_subject_types)}.`,
target,
});
} else if (!allowedIncludes(rule.allowed_subject_types, subjectType)) {
issues.push({
id: `subject-type-${claim.id}`,
type: "subject_type_mismatch",
severity: "high",
title: "Subject type violates relation rule",
detail: `${subjectType} is not one of ${listLabel(rule.allowed_subject_types)}.`,
target,
});
}
}
if (rule?.allowed_object_types.length) {
if (!objectType) {
issues.push({
id: `object-type-missing-${claim.id}`,
type: "missing_object_type",
severity: "medium",
title: "Object type is missing",
detail: `Allowed object types: ${listLabel(rule.allowed_object_types)}.`,
target,
});
} else if (!allowedIncludes(rule.allowed_object_types, objectType)) {
issues.push({
id: `object-type-${claim.id}`,
type: "object_type_mismatch",
severity: "high",
title: "Object type violates relation rule",
detail: `${objectType} is not one of ${listLabel(rule.allowed_object_types)}.`,
target,
});
}
}
if (rule?.allowed_page_types.length) {
if (!claim.page_type) {
issues.push({
id: `page-type-missing-${claim.id}`,
type: "missing_page_type",
severity: "low",
title: "Page type is missing",
detail: `Allowed page types: ${listLabel(rule.allowed_page_types)}.`,
target,
});
} else if (!allowedIncludes(rule.allowed_page_types, claim.page_type)) {
issues.push({
id: `page-type-${claim.id}`,
type: "page_type_mismatch",
severity: "medium",
title: "Page type violates relation rule",
detail: `${claim.page_type} is not one of ${listLabel(rule.allowed_page_types)}.`,
target,
});
}
}
if (rule?.allowed_source_zones.length) {
if (!claim.source_zone) {
issues.push({
id: `source-zone-missing-${claim.id}`,
type: "missing_source_zone",
severity: "low",
title: "Source zone is missing",
detail: `Allowed source zones: ${listLabel(rule.allowed_source_zones)}.`,
target,
});
} else if (!allowedIncludes(rule.allowed_source_zones, claim.source_zone)) {
issues.push({
id: `source-zone-${claim.id}`,
type: "source_zone_mismatch",
severity: "medium",
title: "Source zone violates relation rule",
detail: `${claim.source_zone} is not one of ${listLabel(rule.allowed_source_zones)}.`,
target,
});
}
}
if (
claim.validation_status &&
!okValidationStatuses.has(claim.validation_status.toLowerCase())
) {
issues.push({
id: `schema-${claim.id}`,
type: "schema_violation",
severity: "high",
title: "Validation status needs attention",
detail: claim.validation_status,
target,
});
}
if (
claim.graph_merge_status &&
!okGraphStatuses.has(claim.graph_merge_status.toLowerCase())
) {
issues.push({
id: `graph-merge-${claim.id}`,
type: "graph_merge_warning",
severity: "medium",
title: "Graph merge status is not ready",
detail: claim.graph_merge_reason || claim.graph_merge_status,
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",
target,
});
}
const staleDays = daysSince(claim.last_seen_at);
if (staleDays === undefined) {
issues.push({
id: `last-seen-missing-${claim.id}`,
type: "missing_last_seen",
severity: "low",
title: "Last seen timestamp is missing",
detail: "Freshness checks need a last_seen_at value.",
target,
});
} else if (staleDays > 180) {
issues.push({
id: `stale-${claim.id}`,
type: "stale_claim",
severity: "medium",
title: "Claim is stale",
detail: `Last observed ${Math.round(staleDays)} days ago.`,
target,
});
}
const subjectLabel = normalizedName(claim.subject);
const objectLabel = normalizedName(object);
if (genericLabels.has(subjectLabel) || subjectLabel.length <= 1) {
issues.push({
id: `generic-subject-${claim.id}`,
type: "generic_subject",
severity: "medium",
title: "Subject label is too generic",
detail: "Generic entity labels should be normalized before graph commit.",
target,
});
}
if (genericLabels.has(objectLabel) || objectLabel.length <= 1) {
issues.push({
id: `generic-object-${claim.id}`,
type: "generic_object",
severity: "low",
title: "Object label is too generic",
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) {
issues.push({
id: `duplicate-claim-${claim.id}`,
type: "duplicate_claim",
severity: existing.status !== claim.status ? "medium" : "low",
title: "Duplicate relation claim",
detail: `Claim #${existing.id} already has the same subject, predicate, and object.`,
target,
});
} else {
seenRelations.set(key, claim);
}
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({
id: `duplicate-entity-${group[0].id}`,
type: "duplicate_entity",
severity: "medium",
title: "Duplicate entity candidate",
detail: `${group.length} entities share the same normalized name.`,
target: group[0].name,
});
}
}
for (const entity of entities) {
const entityName = normalizedName(entity.name);
if (!connectedNames.has(entityName)) {
issues.push({
id: `isolated-${entity.id}`,
type: "isolated_entity",
severity: "low",
title: "Entity is isolated",
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())) {
issues.push({
id: `generic-entity-type-${entity.id}`,
type: "generic_entity_type",
severity: "low",
title: "Entity type is generic",
detail: "The entity should be assigned to a domain-specific type.",
target: `[${entity.type}] ${entity.name}`,
});
}
}
return issues;
}
function countByType(issues: QualityIssue[]) {
const counts = new Map<string, number>();
issues.forEach((issue) => {
counts.set(issue.type, (counts.get(issue.type) ?? 0) + 1);
});
return Array.from(counts.entries())
.map(([type, count]) => ({ type, count }))
.sort((a, b) => b.count - a.count);
}
export default function QualityInspectorPage() {
const navigate = useNavigate();
const { projectId } = useParams<{ projectId: string }>();
const projectName = projectId ?? "";
const { data: project } = useProject(projectName);
const claims = useClaims(projectName, {
includeCandidates: true,
limit: 300,
});
const entities = useEntities(projectName, undefined);
const registry = useOntologyRegistry(projectName);
const pipeline = usePipeline(projectName);
const issues = useMemo(
() =>
buildIssues(
claims.data ?? [],
entities.data ?? [],
registry.data?.relation_types ?? [],
),
[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 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),
);
const unknownPredicates = Array.from(
new Set(
(claims.data ?? [])
.map((claim) => claim.predicate)
.filter((predicate) => !relationRuleNames.has(predicate)),
),
);
const reviewedCount = (claims.data ?? []).filter((claim) =>
["validated_claim", "rejected"].includes(claim.status ?? ""),
).length;
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);
return (
<div className="mx-auto max-w-7xl px-6 py-8">
<div className="mb-6 flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/review/${projectName}`)}
aria-label="Back"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1">
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
<ClipboardCheck className="h-6 w-6 text-primary" />
Quality Inspector
</h1>
<p className="text-sm text-muted-foreground">
{project?.name ?? projectName} schema, evidence, duplicate, and graph readiness checks.
</p>
</div>
<Button
variant="outline"
onClick={() => {
claims.refetch();
entities.refetch();
registry.refetch();
pipeline.refetch();
}}
>
Refresh
</Button>
</div>
{(claims.isError || entities.isError || registry.isError) && (
<Card className="mb-6 border-destructive">
<CardContent className="flex items-center gap-2 py-4 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
{(claims.error as Error | undefined)?.message ??
(entities.error as Error | undefined)?.message ??
(registry.error as Error | undefined)?.message}
</CardContent>
</Card>
)}
<div className="mb-6 grid gap-4 md:grid-cols-5">
<Card>
<CardContent className="py-4">
<div className="text-xs text-muted-foreground">Quality score</div>
<div className="mt-1 text-3xl font-semibold">{score}</div>
<Progress value={score} className="mt-3" />
</CardContent>
</Card>
<Metric label="High" value={high} tone="high" />
<Metric label="Medium" value={medium} tone="medium" />
<Metric label="Low" value={low} tone="low" />
<Metric label="Review rate" value={formatPercent(reviewRate)} />
</div>
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-3">
<CardTitle>Validation Issues</CardTitle>
{issues.length > visibleIssues.length && (
<Badge variant="outline">
Showing {visibleIssues.length} of {issues.length}
</Badge>
)}
</div>
<CardDescription>
Rule-based warnings generated from claim evidence and ontology registry constraints.
</CardDescription>
</CardHeader>
<CardContent>
{(claims.isLoading || entities.isLoading || registry.isLoading) && (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, index) => (
<Skeleton key={index} className="h-20" />
))}
</div>
)}
{!claims.isLoading &&
!entities.isLoading &&
!registry.isLoading &&
issues.length === 0 && (
<div className="flex flex-col items-center gap-2 py-12 text-center text-sm text-muted-foreground">
<ClipboardCheck className="h-10 w-10 opacity-50" />
No quality warnings were found in the loaded scope.
</div>
)}
<div className="space-y-3">
{visibleIssues.map((issue) => (
<article key={issue.id} className="rounded-md border p-4">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={severityVariant(issue.severity)}>
{issue.severity}
</Badge>
<Badge variant="outline">{issue.type}</Badge>
<span className="font-medium">{issue.title}</span>
</div>
<p className="mt-2 text-sm text-muted-foreground">
{issue.detail}
</p>
<div className="mt-2 rounded-md bg-secondary/30 px-3 py-2 text-sm">
{issue.target}
</div>
</article>
))}
</div>
</CardContent>
</Card>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShieldAlert className="h-5 w-5" />
Schema Coverage
</CardTitle>
<CardDescription>
Predicate and type coverage against the ontology registry.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<ReadinessRow label="Relation rules" value={relationRuleNames.size} />
<ReadinessRow label="Schema warnings" value={schemaIssueCount} />
<ReadinessRow label="Evidence warnings" value={evidenceIssueCount} />
<div className="flex items-center justify-between text-sm">
<span>Unknown predicates</span>
<Badge variant={unknownPredicates.length ? "warning" : "success"}>
{unknownPredicates.length}
</Badge>
</div>
<div className="flex flex-wrap gap-2">
{unknownPredicates.map((predicate) => (
<Badge key={predicate} variant="outline">
{predicate}
</Badge>
))}
{unknownPredicates.length === 0 && (
<span className="text-sm text-muted-foreground">
All loaded predicates are registered.
</span>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Link className="h-5 w-5" />
Graph Readiness
</CardTitle>
<CardDescription>Build output ready for graph and exports.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<ReadinessRow
label="Collected pages"
value={
pipeline.data?.stages.find((stage) => stage.key === "crawled")
?.count ?? 0
}
/>
<ReadinessRow
label="Approved claims"
value={
pipeline.data?.stages.find((stage) => stage.key === "validated")
?.count ?? 0
}
/>
<ReadinessRow
label="Graph triples"
value={
pipeline.data?.stages.find((stage) => stage.key === "graph")
?.count ?? 0
}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5" />
Issue Mix
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm">
{issueCounts.slice(0, 8).map((item) => (
<li
key={item.type}
className="flex items-center justify-between rounded-md border px-3 py-2"
>
<span>{item.type}</span>
<span className="font-medium">{item.count}</span>
</li>
))}
{issueCounts.length === 0 && (
<li className="py-4 text-center text-muted-foreground">
No issues in the loaded scope.
</li>
)}
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Recommended Actions</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm text-muted-foreground">
{high > 0 && (
<li>Resolve missing evidence, type violations, and conflicts before export.</li>
)}
{medium > 0 && (
<li>Review stale claims, thin evidence, duplicate relations, and missing rule fields.</li>
)}
{unknownPredicates.length > 0 && (
<li>Add missing predicate rules in Schema Designer.</li>
)}
{score >= 90 && (
<li>The ontology is ready for graph inspection and Export/API handoff.</li>
)}
</ul>
</CardContent>
</Card>
</div>
</div>
</div>
);
}
function Metric({
label,
value,
tone,
}: {
label: string;
value: number | string;
tone?: QualityIssue["severity"];
}) {
return (
<Card>
<CardContent className="py-4">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-1 flex items-center justify-between gap-2">
<span className="text-2xl font-semibold">{value}</span>
{tone && <Badge variant={severityVariant(tone)}>{tone}</Badge>}
</div>
</CardContent>
</Card>
);
}
function ReadinessRow({ label, value }: { label: string; value: number }) {
return (
<div className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium">{value.toLocaleString()}</span>
</div>
);
}

View File

@@ -1,51 +1,436 @@
import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
AlertCircle,
ArrowLeft,
Check,
Eye,
Filter,
ListChecks,
Search,
X,
} 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 { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { useClaims, useUpdateClaimStatus } from "@/hooks/useClaims";
import { useProject } from "@/hooks/useProjects";
import {
formatDateTime,
formatPercent,
humanizeValue,
reviewLabel,
} from "@/lib/display";
import { Claim } from "@/lib/api/claims";
function statusVariant(status: string | undefined): BadgeProps["variant"] {
switch (reviewLabel(status)) {
case "approved":
return "success";
case "rejected":
return "destructive";
case "candidate":
return "warning";
default:
return "outline";
}
}
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";
}
function claimObject(claim: Claim): string {
return humanizeValue(claim.object ?? claim.object_value);
}
export default function ReviewPage() {
const navigate = useNavigate();
const { projectId } = useParams();
const { t } = useTranslation();
const { projectId } = useParams<{ projectId: string }>();
const projectName = projectId ?? "";
const { data: project } = useProject(projectName);
const claims = useClaims(projectName, {
includeCandidates: true,
limit: 300,
});
const updateStatus = useUpdateClaimStatus(projectName);
const [statusFilter, setStatusFilter] = useState("all");
const [search, setSearch] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(null);
const filteredClaims = useMemo(() => {
const query = search.trim().toLowerCase();
return (claims.data ?? []).filter((claim) => {
if (statusFilter !== "all" && reviewLabel(claim.status) !== statusFilter) {
return false;
}
if (!query) return true;
return [
claim.subject,
claim.predicate,
claimObject(claim),
claim.evidence_text,
claim.page_url,
claim.source,
]
.map((value) => humanizeValue(value, "").toLowerCase())
.some((value) => value.includes(query));
});
}, [claims.data, search, statusFilter]);
const selectedClaim = useMemo(() => {
return (
filteredClaims.find((claim) => claim.id === selectedId) ??
filteredClaims[0]
);
}, [filteredClaims, selectedId]);
const counts = useMemo(() => {
const all = claims.data ?? [];
return {
total: all.length,
candidate: all.filter((claim) => reviewLabel(claim.status) === "candidate")
.length,
approved: all.filter((claim) => reviewLabel(claim.status) === "approved")
.length,
rejected: all.filter((claim) => reviewLabel(claim.status) === "rejected")
.length,
};
}, [claims.data]);
const applyStatus = async (claim: Claim, status: string) => {
try {
await updateStatus.mutateAsync({
claimId: claim.id,
status,
reason:
status === "rejected"
? "Rejected from Claim Review"
: "Updated from Claim Review",
});
toast.success(`Claim ${status}`);
} catch (error) {
toast.error((error as Error).message);
}
};
return (
<div className="min-h-screen bg-gray-50 p-4">
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
{t("Review & Validate")}
</h1>
<p className="text-gray-600 mb-6">
{t("Step 4 of 4: Review extracted entities and relations")}
</p>
<div className="grid grid-cols-2 gap-6 mb-6">
<div className="bg-white rounded-lg shadow p-6">
<h3 className="font-semibold text-lg mb-2">
{t("Phase 5 Entity Merges")}
</h3>
<p className="text-gray-500">{t("Review merge suggestions")}</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h3 className="font-semibold text-lg mb-2">
{t("Phase 7 Extractions")}
</h3>
<p className="text-gray-500">{t("Validate extracted claims")}</p>
</div>
<div className="mx-auto max-w-7xl px-6 py-8">
<div className="mb-6 flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/schema/${projectName}`)}
aria-label="Back"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1">
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
<ListChecks className="h-6 w-6 text-primary" />
Claim Review
</h1>
<p className="text-sm text-muted-foreground">
{project?.name ?? projectName} · , ,
</p>
</div>
<Button onClick={() => navigate(`/quality/${projectName}`)}>
Quality Inspector
</Button>
</div>
<div className="flex gap-4">
<button
onClick={() => navigate(`/crawl/${projectId}`)}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition"
>
{t("Back")}
</button>
<button
onClick={() => navigate("/")}
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition"
>
{t("Complete")}
</button>
</div>
{claims.isError && (
<Card className="mb-6 border-destructive">
<CardContent className="flex items-center gap-2 py-4 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
{(claims.error as Error).message}
</CardContent>
</Card>
)}
<div className="mb-6 grid gap-4 md:grid-cols-4">
<Metric label="Total" value={counts.total} />
<Metric label="Candidate" value={counts.candidate} />
<Metric label="Approved" value={counts.approved} />
<Metric label="Rejected" value={counts.rejected} />
</div>
<div className="grid gap-6 lg:grid-cols-[1fr_420px]">
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle>Review Queue</CardTitle>
<CardDescription>
, , , .
</CardDescription>
</div>
<div className="flex min-w-0 flex-wrap gap-2">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
className="w-56 pl-9"
placeholder="Search claims"
/>
</div>
<div className="relative">
<Filter className="pointer-events-none absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Select
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value)}
className="w-40 pl-9"
>
<option value="all">All</option>
<option value="candidate">Candidate</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
</Select>
</div>
</div>
</div>
</CardHeader>
<CardContent>
{claims.isLoading && (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, index) => (
<Skeleton key={index} className="h-24" />
))}
</div>
)}
{!claims.isLoading && filteredClaims.length === 0 && (
<p className="py-10 text-center text-sm text-muted-foreground">
.
</p>
)}
<div className="space-y-3">
{filteredClaims.map((claim) => (
<article
key={claim.id}
className="rounded-md border bg-background p-4"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<button
type="button"
onClick={() => setSelectedId(claim.id)}
className="min-w-0 flex-1 text-left"
>
<div className="mb-2 flex flex-wrap items-center gap-2">
<Badge variant={statusVariant(claim.status)}>
{reviewLabel(claim.status)}
</Badge>
<Badge variant="secondary">{claim.predicate}</Badge>
<span className="text-xs text-muted-foreground">
{confidenceBucket(claim)}
</span>
</div>
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-medium">
{humanizeValue(claim.subject)}
</span>
<span className="text-muted-foreground">-&gt;</span>
<span className="font-medium">{claimObject(claim)}</span>
</div>
<div className="mt-2 flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>Confidence {formatPercent(claim.confidence)}</span>
<span>Source {humanizeValue(claim.source)}</span>
<span>
Evidence {claim.evidence_text ? "yes" : "missing"}
</span>
<span>
Method {humanizeValue(claim.extraction_method)}
</span>
</div>
</button>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
onClick={() => setSelectedId(claim.id)}
>
<Eye className="h-4 w-4" />
Detail
</Button>
<Button
variant="outline"
size="sm"
onClick={() => applyStatus(claim, "validated_claim")}
disabled={updateStatus.isPending}
>
<Check className="h-4 w-4" />
Approve
</Button>
<Button
variant="outline"
size="sm"
onClick={() => applyStatus(claim, "rejected")}
disabled={updateStatus.isPending}
>
<X className="h-4 w-4" />
Reject
</Button>
</div>
</div>
</article>
))}
</div>
</CardContent>
</Card>
<aside className="lg:sticky lg:top-4 lg:self-start">
<Card>
<CardHeader>
<CardTitle>Claim Detail</CardTitle>
<CardDescription>
, , ,
</CardDescription>
</CardHeader>
<CardContent>
{!selectedClaim && (
<p className="py-8 text-center text-sm text-muted-foreground">
.
</p>
)}
{selectedClaim && (
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
<Badge variant={statusVariant(selectedClaim.status)}>
{reviewLabel(selectedClaim.status)}
</Badge>
<Badge variant="outline">
{formatPercent(selectedClaim.confidence)}
</Badge>
{selectedClaim.review_required && (
<Badge variant="warning">review required</Badge>
)}
</div>
<DetailRow label="Subject" value={selectedClaim.subject} />
<DetailRow label="Subject Type" value={selectedClaim.subject_type} />
<DetailRow label="Predicate" value={selectedClaim.predicate} />
<DetailRow
label="Object"
value={selectedClaim.object ?? selectedClaim.object_value}
/>
<DetailRow label="Source" value={selectedClaim.source} />
<DetailRow label="Source URL" value={selectedClaim.page_url} />
<DetailRow
label="Page Type"
value={selectedClaim.page_type}
/>
<DetailRow
label="Created By"
value={selectedClaim.extraction_method}
/>
<DetailRow
label="Validation"
value={
selectedClaim.validation_status ??
selectedClaim.graph_merge_status
}
/>
{selectedClaim.graph_merge_reason && (
<DetailRow
label="Graph Reason"
value={selectedClaim.graph_merge_reason}
/>
)}
{selectedClaim.evidence_text && (
<section>
<h3 className="mb-2 text-sm font-medium">Evidence Text</h3>
<div className="rounded-md bg-yellow-50 px-3 py-2 text-sm leading-relaxed text-yellow-950">
{selectedClaim.evidence_text}
</div>
</section>
)}
{selectedClaim.confidence_breakdown && (
<section>
<h3 className="mb-2 text-sm font-medium">
Confidence Breakdown
</h3>
<pre className="max-h-44 overflow-auto rounded-md bg-secondary/30 p-3 text-xs">
{JSON.stringify(
selectedClaim.confidence_breakdown,
null,
2,
)}
</pre>
</section>
)}
{selectedClaim.source_history &&
selectedClaim.source_history.length > 0 && (
<section>
<h3 className="mb-2 text-sm font-medium">
Source History
</h3>
<pre className="max-h-44 overflow-auto rounded-md bg-secondary/30 p-3 text-xs">
{JSON.stringify(selectedClaim.source_history, null, 2)}
</pre>
</section>
)}
<div className="grid grid-cols-2 gap-2 pt-2">
<Button
onClick={() =>
applyStatus(selectedClaim, "validated_claim")
}
disabled={updateStatus.isPending}
>
<Check className="h-4 w-4" />
Approve
</Button>
<Button
variant="outline"
onClick={() => applyStatus(selectedClaim, "rejected")}
disabled={updateStatus.isPending}
>
<X className="h-4 w-4" />
Reject
</Button>
</div>
<div className="text-xs text-muted-foreground">
Last seen {formatDateTime(selectedClaim.last_seen_at)}
</div>
</div>
)}
</CardContent>
</Card>
</aside>
</div>
</div>
);
}
function Metric({ label, value }: { label: string; value: number }) {
return (
<Card>
<CardContent className="py-4">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-1 text-2xl font-semibold">
{value.toLocaleString()}
</div>
</CardContent>
</Card>
);
}
function DetailRow({ label, value }: { label: string; value: unknown }) {
return (
<div className="rounded-md border bg-background px-3 py-2 text-sm">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-1 break-words font-medium">{humanizeValue(value)}</div>
</div>
);
}

View File

@@ -0,0 +1,451 @@
import { FormEvent, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import {
AlertCircle,
ArrowLeft,
BookOpen,
GitBranch,
Loader2,
Plus,
ShieldCheck,
} from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Skeleton } from "@/components/ui/skeleton";
import { useOntology } from "@/hooks/useDomains";
import { useProject } from "@/hooks/useProjects";
import {
useCreateSchemaEntityType,
useCreateSchemaRelationType,
useOntologyProposals,
useOntologyRegistry,
} from "@/hooks/usePlatform";
import { formatDateTime, formatPercent, humanizeValue } from "@/lib/display";
function splitList(value: string): string[] {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
export default function SchemaDesignerPage() {
const navigate = useNavigate();
const { projectId } = useParams<{ projectId: string }>();
const projectName = projectId ?? "";
const { data: project } = useProject(projectName);
const ontology = useOntology(project?.domain);
const registry = useOntologyRegistry(projectName);
const proposals = useOntologyProposals(projectName, 100);
const createEntityType = useCreateSchemaEntityType(projectName);
const createRelationType = useCreateSchemaRelationType(projectName);
const [entityName, setEntityName] = useState("");
const [entityDescription, setEntityDescription] = useState("");
const [relationName, setRelationName] = useState("");
const [relationSubjectTypes, setRelationSubjectTypes] = useState("");
const [relationObjectTypes, setRelationObjectTypes] = useState("");
const [relationDescription, setRelationDescription] = useState("");
const domainTypes = useMemo(
() => new Set(ontology.data?.entity_types ?? []),
[ontology.data?.entity_types],
);
const registeredEntityNames = useMemo(
() =>
new Set(
(registry.data?.entity_types ?? []).map((row) =>
row.name.toLowerCase(),
),
),
[registry.data?.entity_types],
);
const unregisteredDomainTypes = (ontology.data?.entity_types ?? []).filter(
(name) => !registeredEntityNames.has(name.toLowerCase()),
);
const onCreateEntity = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!entityName.trim()) return;
try {
await createEntityType.mutateAsync({
name: entityName.trim(),
domain: project?.domain ?? "generic",
description: entityDescription.trim() || null,
});
toast.success("Entity type saved");
setEntityName("");
setEntityDescription("");
} catch (error) {
toast.error((error as Error).message);
}
};
const onCreateRelation = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!relationName.trim()) return;
try {
await createRelationType.mutateAsync({
name: relationName.trim(),
domain: project?.domain ?? "generic",
description: relationDescription.trim() || null,
allowed_subject_types: splitList(relationSubjectTypes),
allowed_object_types: splitList(relationObjectTypes),
min_confidence: 0.8,
});
toast.success("Predicate rule saved");
setRelationName("");
setRelationSubjectTypes("");
setRelationObjectTypes("");
setRelationDescription("");
} catch (error) {
toast.error((error as Error).message);
}
};
return (
<div className="mx-auto max-w-7xl px-6 py-8">
<div className="mb-6 flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/analysis/${projectName}`)}
aria-label="Back"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1">
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
<ShieldCheck className="h-6 w-6 text-primary" />
Schema Designer
</h1>
<p className="text-sm text-muted-foreground">
{project?.name ?? projectName} · Predicate
</p>
</div>
<Button variant="outline" onClick={() => registry.refetch()}>
Refresh
</Button>
</div>
{(registry.isError || ontology.isError || proposals.isError) && (
<Card className="mb-6 border-destructive">
<CardContent className="flex items-center gap-2 py-4 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
{(registry.error as Error | undefined)?.message ??
(ontology.error as Error | undefined)?.message ??
(proposals.error as Error | undefined)?.message}
</CardContent>
</Card>
)}
<div className="mb-6 grid gap-4 md:grid-cols-4">
<Metric label="Entity types" value={registry.data?.entity_types.length} />
<Metric
label="Predicates"
value={registry.data?.relation_types.length}
/>
<Metric
label="Schema proposals"
value={proposals.data?.length}
/>
<Metric
label="Domain coverage"
value={
ontology.data?.entity_types.length
? formatPercent(
1 -
unregisteredDomainTypes.length /
ontology.data.entity_types.length,
)
: "-"
}
/>
</div>
<div className="grid gap-6 xl:grid-cols-[380px_1fr]">
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BookOpen className="h-5 w-5" />
Entity Type
</CardTitle>
<CardDescription>
AI가 .
</CardDescription>
</CardHeader>
<CardContent>
<form className="space-y-3" onSubmit={onCreateEntity}>
<div className="space-y-1.5">
<Label htmlFor="entityName">Name</Label>
<Input
id="entityName"
value={entityName}
onChange={(event) => setEntityName(event.target.value)}
placeholder="Product"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="entityDescription">Description</Label>
<Textarea
id="entityDescription"
value={entityDescription}
onChange={(event) =>
setEntityDescription(event.target.value)
}
rows={2}
placeholder="상품 또는 서비스 개체"
/>
</div>
<Button
type="submit"
className="w-full"
disabled={createEntityType.isPending}
>
{createEntityType.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
Save entity type
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<GitBranch className="h-5 w-5" />
Predicate Rule
</CardTitle>
<CardDescription>
/ .
</CardDescription>
</CardHeader>
<CardContent>
<form className="space-y-3" onSubmit={onCreateRelation}>
<div className="space-y-1.5">
<Label htmlFor="relationName">Predicate</Label>
<Input
id="relationName"
value={relationName}
onChange={(event) => setRelationName(event.target.value)}
placeholder="hasBrand"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="subjectTypes">Subject types</Label>
<Input
id="subjectTypes"
value={relationSubjectTypes}
onChange={(event) =>
setRelationSubjectTypes(event.target.value)
}
placeholder="Product, Perfume"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="objectTypes">Object types</Label>
<Input
id="objectTypes"
value={relationObjectTypes}
onChange={(event) =>
setRelationObjectTypes(event.target.value)
}
placeholder="Brand"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="relationDescription">Description</Label>
<Textarea
id="relationDescription"
value={relationDescription}
onChange={(event) =>
setRelationDescription(event.target.value)
}
rows={2}
placeholder="상품이 특정 브랜드에 속함"
/>
</div>
<Button
type="submit"
className="w-full"
disabled={createRelationType.isPending}
>
{createRelationType.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
Save predicate rule
</Button>
</form>
</CardContent>
</Card>
</div>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Registered Entity Types</CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent>
{registry.isLoading ? (
<Skeleton className="h-32" />
) : (
<div className="grid gap-3 md:grid-cols-2">
{(registry.data?.entity_types ?? []).map((row) => (
<article key={row.id} className="rounded-md border p-3">
<div className="flex items-center justify-between gap-3">
<span className="font-medium">{row.name}</span>
<Badge variant={domainTypes.has(row.name) ? "success" : "outline"}>
{row.domain ?? "generic"}
</Badge>
</div>
<p className="mt-2 line-clamp-2 text-sm text-muted-foreground">
{row.description || humanizeValue(row.metadata)}
</p>
</article>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Predicate Rules</CardTitle>
<CardDescription>
. .
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{(registry.data?.relation_types ?? []).map((row) => (
<article key={row.id} className="rounded-md border p-4">
<div className="mb-2 flex flex-wrap items-center gap-2">
<span className="font-medium">{row.name}</span>
<Badge variant="outline">{row.status ?? "active"}</Badge>
<span className="text-xs text-muted-foreground">
{formatPercent(row.confidence)}
</span>
</div>
<div className="grid gap-3 text-sm md:grid-cols-2">
<SchemaList
label="Subject"
values={row.allowed_subject_types}
/>
<SchemaList
label="Object"
values={row.allowed_object_types}
/>
</div>
<p className="mt-3 text-sm text-muted-foreground">
{row.description ||
humanizeValue(row.semantic_constraints)}
</p>
</article>
))}
{registry.data?.relation_types.length === 0 && (
<p className="py-6 text-center text-sm text-muted-foreground">
Predicate .
</p>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Schema Proposals</CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent>
<ul className="divide-y">
{(proposals.data ?? []).map((proposal) => (
<li key={proposal.id} className="py-3 text-sm">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">
{proposal.proposal_type}
</Badge>
<span className="font-medium">{proposal.name}</span>
<Badge variant="outline">{proposal.status}</Badge>
</div>
<p className="mt-1 text-muted-foreground">
{proposal.reason || proposal.evidence || "-"}
</p>
<div className="mt-1 text-xs text-muted-foreground">
{formatDateTime(proposal.updated_at)}
</div>
</li>
))}
{proposals.data?.length === 0 && (
<li className="py-6 text-center text-sm text-muted-foreground">
.
</li>
)}
</ul>
</CardContent>
</Card>
</div>
</div>
</div>
);
}
function Metric({
label,
value,
}: {
label: string;
value: number | string | undefined;
}) {
return (
<Card>
<CardContent className="py-4">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-1 text-2xl font-semibold">
{typeof value === "number" ? value.toLocaleString() : value ?? "-"}
</div>
</CardContent>
</Card>
);
}
function SchemaList({ label, values }: { label: string; values: string[] }) {
return (
<div>
<div className="mb-1 text-xs text-muted-foreground">{label}</div>
<div className="flex flex-wrap gap-1">
{values.length ? (
values.map((value) => (
<Badge key={value} variant="outline">
{value}
</Badge>
))
) : (
<span className="text-xs text-muted-foreground">Any</span>
)}
</div>
</div>
);
}