domain builder

This commit is contained in:
LASTA_DEV01\lasta
2026-05-20 20:59:40 +09:00
parent 6a6befc485
commit de46b657c2
17 changed files with 5111 additions and 1243 deletions

View File

@@ -1,6 +1,7 @@
import { Routes, Route } from "react-router-dom";
import AppShell from "@/components/layout/AppShell";
import OnboardingPage from "@/pages/OnboardingPage";
import DomainBuilderPage from "@/pages/DomainBuilderPage";
import ConfigureSourcesPage from "@/pages/ConfigureSourcesPage";
import CrawlPage from "@/pages/CrawlPage";
import ResearchPage from "@/pages/ResearchPage";
@@ -20,6 +21,7 @@ function App() {
<Route element={<AppShell />}>
<Route path="/" element={<DashboardPage />} />
<Route path="/onboard" element={<OnboardingPage />} />
<Route path="/domains" element={<DomainBuilderPage />} />
<Route path="/sources/:projectId" element={<ConfigureSourcesPage />} />
<Route path="/crawl/:projectId" element={<CrawlPage />} />
<Route path="/pipeline/:projectId" element={<BuildPipelinePage />} />

View File

@@ -5,6 +5,7 @@ import {
ClipboardCheck,
Code2,
GitBranch,
Globe2,
Layers3,
LayoutDashboard,
ListChecks,
@@ -66,6 +67,15 @@ export const NAV_GROUPS: NavGroup[] = [
keywords: ["create", "start", "wizard"],
shortcut: "g n",
},
{
id: "domains",
to: "/domains",
labelKey: "nav.domains",
label: "Domain Builder",
icon: Globe2,
keywords: ["domain", "schema", "reference", "crawler"],
shortcut: "g m",
},
],
},
{

View File

@@ -808,6 +808,7 @@ function TableShell<T>({
scope="col"
style={{
width: header.getSize(),
maxWidth: header.getSize(),
...getPinStyle(col, "header"),
}}
aria-sort={
@@ -921,16 +922,19 @@ function TableShell<T>({
key={cell.id}
style={{
width: col.getSize(),
maxWidth: col.getSize(),
...getPinStyle(col, "cell"),
}}
className={cn(
cellPadClass,
alignClass,
"text-foreground",
"overflow-hidden text-foreground align-middle",
pinned && "bg-surface",
)}
>
{flexRender(col.columnDef.cell, cell.getContext())}
<div className="min-w-0 break-words">
{flexRender(col.columnDef.cell, cell.getContext())}
</div>
</td>
);
})}

View File

@@ -92,7 +92,7 @@ export const DialogPanel = React.forwardRef<HTMLDivElement, DialogPanelProps>(
"fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2",
"flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden",
"rounded-xl border border-border bg-surface-raised shadow-xl outline-none",
"data-[state=open]:animate-fade-in-up",
"data-[state=open]:animate-fade-in",
className,
)}
{...props}

View File

@@ -1,5 +1,14 @@
import { useQuery } from "@tanstack/react-query";
import { domainsApi, DomainSummary } from "@/lib/api/domains";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
domainsApi,
DomainDefinitionRequest,
DomainDefinitionUpdateRequest,
DomainDiscoveryJob,
DomainDiscoveryRequest,
DomainReferenceSource,
DomainCandidate,
DomainSummary,
} from "@/lib/api/domains";
import { ontologyApi, OntologyDetail } from "@/lib/api/ontology";
import { queryKeys } from "./queryKeys";
@@ -11,6 +20,151 @@ export function useDomains() {
});
}
export function useCreateDomain() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: DomainDefinitionRequest) => domainsApi.create(body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.domains.all });
queryClient.invalidateQueries({ queryKey: queryKeys.ontology.all });
},
});
}
export function useUpdateDomain() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
domain,
body,
}: {
domain: string;
body: DomainDefinitionUpdateRequest;
}) => domainsApi.update(domain, body),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.domains.all });
queryClient.invalidateQueries({
queryKey: queryKeys.ontology.byDomain(variables.domain),
});
},
});
}
export function useDomainDiscoveryJobs(domain: string | undefined) {
return useQuery<DomainDiscoveryJob[]>({
queryKey: [...queryKeys.domains.all, "discoveryJobs", domain ?? ""],
queryFn: () => domainsApi.discoveryJobs(domain!),
enabled: Boolean(domain),
refetchInterval: (query) => {
const jobs = query.state.data;
return jobs?.some((job) => ["pending", "running", "cancel_requested"].includes(job.status))
? 2000
: false;
},
});
}
export function useDomainReferenceSources(domain: string | undefined) {
return useQuery<DomainReferenceSource[]>({
queryKey: [...queryKeys.domains.all, "referenceSources", domain ?? ""],
queryFn: () => domainsApi.referenceSources(domain!),
enabled: Boolean(domain),
});
}
export function useDomainCandidates(domain: string | undefined) {
return useQuery<DomainCandidate[]>({
queryKey: [...queryKeys.domains.all, "candidates", domain ?? ""],
queryFn: () => domainsApi.candidates(domain!),
enabled: Boolean(domain),
refetchInterval: 2000,
});
}
export function useStartDomainDiscovery() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
domain,
body,
}: {
domain: string;
body: DomainDiscoveryRequest;
}) => domainsApi.startDiscovery(domain, body),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: [...queryKeys.domains.all, "discoveryJobs", variables.domain],
});
queryClient.invalidateQueries({
queryKey: [...queryKeys.domains.all, "referenceSources", variables.domain],
});
queryClient.invalidateQueries({
queryKey: [...queryKeys.domains.all, "candidates", variables.domain],
});
},
});
}
export function useCancelDomainDiscovery() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
domain,
jobId,
}: {
domain: string;
jobId: string;
}) => domainsApi.cancelDiscovery(domain, jobId),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: [...queryKeys.domains.all, "discoveryJobs", variables.domain],
});
},
});
}
export function useApplyDomainCandidates() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
domain,
candidateIds,
}: {
domain: string;
candidateIds: string[];
}) => domainsApi.applyCandidates(domain, candidateIds),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.domains.all });
queryClient.invalidateQueries({
queryKey: queryKeys.ontology.byDomain(variables.domain),
});
queryClient.invalidateQueries({
queryKey: [...queryKeys.domains.all, "candidates", variables.domain],
});
},
});
}
export function useUpdateDomainCandidateStatus() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
domain,
candidateId,
status,
}: {
domain: string;
candidateId: string;
status: "pending_review" | "approved" | "rejected";
}) => domainsApi.updateCandidateStatus(domain, candidateId, status),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: [...queryKeys.domains.all, "candidates", variables.domain],
});
},
});
}
export function useOntology(domain: string | undefined) {
return useQuery<OntologyDetail>({
queryKey: queryKeys.ontology.byDomain(domain ?? ""),

View File

@@ -43,3 +43,31 @@ export function useCreateProjectInline() {
},
});
}
export function useResetProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (projectName: string) => projectsApi.reset(projectName),
onSuccess: (_data, projectName) => {
queryClient.invalidateQueries({ queryKey: queryKeys.projects.all });
queryClient.invalidateQueries({
queryKey: queryKeys.projects.detail(projectName),
});
queryClient.invalidateQueries({ queryKey: queryKeys.platform.all });
},
});
}
export function useDeleteProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (projectName: string) => projectsApi.delete(projectName),
onSuccess: (_data, projectName) => {
queryClient.invalidateQueries({ queryKey: queryKeys.projects.all });
queryClient.removeQueries({
queryKey: queryKeys.projects.detail(projectName),
});
queryClient.invalidateQueries({ queryKey: queryKeys.platform.all });
},
});
}

View File

@@ -3,15 +3,177 @@ import { apiClient } from "./client";
export const domainSummarySchema = z.object({
domain: z.string(),
description: z.string().nullable().optional(),
entity_types: z.array(z.string()),
predicates: z.array(z.string()),
attribute_count: z.number().int(),
attributes: z.array(z.string()).default([]),
aliases: z.record(z.string(), z.string()).default({}),
is_builtin: z.boolean().default(false),
is_custom: z.boolean().default(false),
});
export const domainListSchema = z.array(domainSummarySchema);
export type DomainSummary = z.infer<typeof domainSummarySchema>;
export const domainDiscoveryJobSchema = z.object({
job_id: z.union([z.string(), z.number()]).transform(String),
domain: z.string(),
status: z.string(),
seed_urls: z.array(z.string()).default([]),
max_pages: z.number(),
max_depth: z.number(),
same_domain_only: z.boolean(),
fetcher: z.string(),
respect_robots_txt: z.boolean(),
error: z.string().nullable().optional(),
progress: z.record(z.unknown()).default({}),
result_summary: z.record(z.unknown()).default({}),
created_at: z.string().nullable().optional(),
started_at: z.string().nullable().optional(),
finished_at: z.string().nullable().optional(),
});
export const domainReferenceSourceSchema = z.object({
id: z.union([z.string(), z.number()]).transform(String),
domain: z.string(),
url: z.string(),
label: z.string().nullable().optional(),
status: z.string(),
last_crawled_at: z.string().nullable().optional(),
metadata: z.record(z.unknown()).default({}),
created_at: z.string().nullable().optional(),
updated_at: z.string().nullable().optional(),
});
export const domainCandidateEvidenceSchema = z.object({
id: z.union([z.string(), z.number()]).transform(String),
job_id: z.union([z.string(), z.number()]).nullable().optional().transform((v) =>
v == null ? null : String(v),
),
url: z.string(),
title: z.string().nullable().optional(),
snippet: z.string(),
metadata: z.record(z.unknown()).default({}),
created_at: z.string().nullable().optional(),
});
export const domainCandidateSchema = z.object({
id: z.union([z.string(), z.number()]).transform(String),
domain: z.string(),
job_id: z.union([z.string(), z.number()]).nullable().optional().transform((v) =>
v == null ? null : String(v),
),
candidate_type: z.string(),
name: z.string(),
description: z.string().nullable().optional(),
confidence: z.number(),
occurrence_count: z.number(),
status: z.string(),
metadata: z.record(z.unknown()).default({}),
evidence: z.array(domainCandidateEvidenceSchema).default([]),
created_at: z.string().nullable().optional(),
updated_at: z.string().nullable().optional(),
});
export const domainDiscoveryJobListSchema = z.array(domainDiscoveryJobSchema);
export const domainReferenceSourceListSchema = z.array(domainReferenceSourceSchema);
export const domainCandidateListSchema = z.array(domainCandidateSchema);
export const domainCandidateApplyResponseSchema = z.object({
ok: z.boolean(),
domain: z.string(),
applied: z.record(z.number()),
definition: domainSummarySchema,
});
export type DomainDiscoveryJob = z.infer<typeof domainDiscoveryJobSchema>;
export type DomainReferenceSource = z.infer<typeof domainReferenceSourceSchema>;
export type DomainCandidate = z.infer<typeof domainCandidateSchema>;
export type DomainCandidateApplyResponse = z.infer<
typeof domainCandidateApplyResponseSchema
>;
export interface DomainDefinitionRequest {
domain: string;
description?: string | null;
entity_types: string[];
predicates: string[];
attributes: string[];
aliases?: Record<string, string>;
}
export type DomainDefinitionUpdateRequest = Omit<
DomainDefinitionRequest,
"domain"
>;
export interface DomainDiscoveryRequest {
seed_urls: string[];
max_pages?: number;
max_depth?: number;
same_domain_only?: boolean;
fetcher?: string;
respect_robots_txt?: boolean;
force_recrawl?: boolean;
}
export const domainsApi = {
list: () => apiClient.get("/domains", domainListSchema),
create: (body: DomainDefinitionRequest) =>
apiClient.post("/domains", domainSummarySchema, body),
update: (domain: string, body: DomainDefinitionUpdateRequest) =>
apiClient.put(
`/domains/${encodeURIComponent(domain)}`,
domainSummarySchema,
body,
),
discoveryJobs: (domain: string) =>
apiClient.get(
`/domains/${encodeURIComponent(domain)}/discovery/jobs`,
domainDiscoveryJobListSchema,
),
referenceSources: (domain: string) =>
apiClient.get(
`/domains/${encodeURIComponent(domain)}/reference-sources`,
domainReferenceSourceListSchema,
),
startDiscovery: (domain: string, body: DomainDiscoveryRequest) =>
apiClient.post(
`/domains/${encodeURIComponent(domain)}/discovery/jobs`,
domainDiscoveryJobSchema,
body,
),
discoveryJob: (domain: string, jobId: string) =>
apiClient.get(
`/domains/${encodeURIComponent(domain)}/discovery/jobs/${encodeURIComponent(jobId)}`,
domainDiscoveryJobSchema,
),
cancelDiscovery: (domain: string, jobId: string) =>
apiClient.post(
`/domains/${encodeURIComponent(domain)}/discovery/jobs/${encodeURIComponent(jobId)}/cancel`,
domainDiscoveryJobSchema,
),
candidates: (domain: string, status?: string) =>
apiClient.get(
`/domains/${encodeURIComponent(domain)}/candidates`,
domainCandidateListSchema,
{ query: { status } },
),
applyCandidates: (domain: string, candidateIds: string[]) =>
apiClient.post(
`/domains/${encodeURIComponent(domain)}/candidates/apply`,
domainCandidateApplyResponseSchema,
{ candidate_ids: candidateIds.map(Number) },
),
updateCandidateStatus: (
domain: string,
candidateId: string,
status: "pending_review" | "approved" | "rejected",
) =>
apiClient.post(
`/domains/${encodeURIComponent(domain)}/candidates/${encodeURIComponent(candidateId)}/status`,
domainCandidateSchema,
{ status },
),
};

View File

@@ -35,9 +35,17 @@ export const createProjectResponseSchema = z.object({
domain: z.string(),
});
export const projectActionResponseSchema = z.object({
ok: z.boolean(),
name: z.string(),
domain: z.string().optional(),
deleted: z.record(z.number()).optional(),
});
export type ProjectSummary = z.infer<typeof projectSummarySchema>;
export type ProjectDetail = z.infer<typeof projectDetailSchema>;
export type ProjectSource = z.infer<typeof projectSourceSchema>;
export type ProjectActionResponse = z.infer<typeof projectActionResponseSchema>;
export interface CreateProjectRequest {
config_path: string;
@@ -77,4 +85,14 @@ export const projectsApi = {
apiClient.post("/projects", createProjectResponseSchema, body),
createInline: (body: CreateProjectInlineRequest) =>
apiClient.post("/projects/inline", createProjectResponseSchema, body),
reset: (projectName: string) =>
apiClient.post(
`/projects/${encodeURIComponent(projectName)}/reset`,
projectActionResponseSchema,
),
delete: (projectName: string) =>
apiClient.delete(
`/projects/${encodeURIComponent(projectName)}`,
projectActionResponseSchema,
),
};

View File

@@ -1,6 +1,15 @@
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Plus, FolderOpen, AlertCircle, Clock } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import {
Plus,
FolderOpen,
AlertCircle,
Clock,
DatabaseZap,
Trash2,
} from "lucide-react";
import {
Card,
CardContent,
@@ -9,8 +18,21 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogBody,
DialogDescription,
DialogFooter,
DialogHeader,
DialogPanel,
DialogTitle,
} from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { useProjects } from "@/hooks/useProjects";
import {
useDeleteProject,
useProjects,
useResetProject,
} from "@/hooks/useProjects";
import { usePipeline } from "@/hooks/usePlatform";
import { formatPercent } from "@/lib/display";
import { ProjectSummary } from "@/lib/api/projects";
@@ -128,6 +150,11 @@ function ProjectCard({
onOpen: (path: string) => void;
}) {
const pipeline = usePipeline(project.name);
const resetProject = useResetProject();
const deleteProject = useDeleteProject();
const [confirmAction, setConfirmAction] = useState<"reset" | "delete" | null>(
null,
);
const claimStage = pipeline.data?.stages.find((stage) => stage.key === "claims");
const approvedStage = pipeline.data?.stages.find(
(stage) => stage.key === "validated",
@@ -136,35 +163,136 @@ function ProjectCard({
const claimCount = claimStage?.count ?? 0;
const approvedCount = approvedStage?.count ?? 0;
const approvalRate = claimCount > 0 ? approvedCount / claimCount : 0;
const isDeleting = deleteProject.isPending;
const isResetting = resetProject.isPending;
const handleProjectAction = async () => {
if (!confirmAction) return;
try {
if (confirmAction === "reset") {
await resetProject.mutateAsync(project.name);
toast.success(`${project.name} 데이터가 초기화되었습니다`);
} else {
await deleteProject.mutateAsync(project.name);
toast.success(`${project.name} 프로젝트가 삭제되었습니다`);
}
setConfirmAction(null);
} catch (error) {
toast.error((error as Error).message);
}
};
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)} />
<>
<Card
role="button"
tabIndex={0}
onClick={() => onOpen(`/sources/${project.name}`)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
onOpen(`/sources/${project.name}`);
}
}}
className="relative cursor-pointer transition-colors hover:bg-accent/40"
>
<div className="absolute right-3 top-3 z-10 flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
aria-label={`${project.name} 데이터 초기화`}
title="데이터 초기화"
disabled={isDeleting || isResetting}
onClick={(e) => {
e.stopPropagation();
setConfirmAction("reset");
}}
>
<DatabaseZap className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
aria-label={`${project.name} 프로젝트 삭제`}
title="프로젝트 삭제"
disabled={isDeleting || isResetting}
onClick={(e) => {
e.stopPropagation();
setConfirmAction("delete");
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</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>
<CardHeader className="pr-24">
<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>
<Dialog
open={confirmAction !== null}
onOpenChange={(open) => {
if (!open && !isDeleting && !isResetting) setConfirmAction(null);
}}
ariaLabel="프로젝트 작업 확인"
>
<DialogPanel>
<DialogHeader onClose={() => setConfirmAction(null)}>
<DialogTitle>
{confirmAction === "delete" ? "프로젝트 삭제" : "데이터 초기화"}
</DialogTitle>
<DialogDescription>
{confirmAction === "delete"
? "프로젝트와 연결된 모든 데이터가 삭제됩니다."
: "프로젝트는 유지하고 크롤링/엔티티/클레임 데이터를 초기화합니다."}
</DialogDescription>
</DialogHeader>
<DialogBody>
<p className="text-sm">
<span className="font-semibold">{project.name}</span>
{confirmAction === "delete"
? " 프로젝트를 삭제할까요?"
: " 데이터를 초기화할까요?"}
</p>
</DialogBody>
<DialogFooter>
<Button
variant={confirmAction === "delete" ? "destructive" : "default"}
onClick={handleProjectAction}
disabled={isDeleting || isResetting}
>
{isDeleting || isResetting
? "처리 중..."
: confirmAction === "delete"
? "삭제"
: "초기화"}
</Button>
<Button
variant="outline"
onClick={() => setConfirmAction(null)}
disabled={isDeleting || isResetting}
>
</Button>
</DialogFooter>
</DialogPanel>
</Dialog>
</>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,11 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { toast } from "sonner";
import { AlertCircle, ArrowLeft, Loader2 } from "lucide-react";
import { AlertCircle, ArrowLeft, Loader2, Pencil, Plus } from "lucide-react";
import {
Card,
CardContent,
@@ -12,30 +13,235 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogBody,
DialogDescription,
DialogFooter,
DialogHeader,
DialogPanel,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { useDomains } from "@/hooks/useDomains";
import { Textarea } from "@/components/ui/textarea";
import {
useCreateDomain,
useDomains,
useUpdateDomain,
} from "@/hooks/useDomains";
import { useCreateProjectInline } from "@/hooks/useProjects";
import { DomainSummary } from "@/lib/api/domains";
import { cn } from "@/lib/utils";
const projectNameRegex = /^[a-zA-Z0-9_-]+$/;
const domainNameRegex = /^[a-z0-9][a-z0-9_-]{1,79}$/;
const schema = z.object({
project_name: z
.string()
.min(2, "최소 2자 이상")
.max(64, "최대 64자")
.regex(projectNameRegex, "영문/숫자/_/- 만 허용"),
domain: z.string().min(1, "도메인을 선택하세요"),
.min(2, "At least 2 characters")
.max(64, "Up to 64 characters")
.regex(projectNameRegex, "Use letters, numbers, underscore, or hyphen"),
domain: z.string().min(1, "Select a domain"),
});
type FormValues = z.infer<typeof schema>;
interface DomainEditorState {
mode: "create" | "edit";
domain?: DomainSummary;
}
function parseList(value: string): string[] {
const seen = new Set<string>();
return value
.split(/[\n,]/)
.map((item) => item.trim())
.filter((item) => {
if (!item || seen.has(item)) return false;
seen.add(item);
return true;
});
}
function formatList(values: string[] | undefined): string {
return (values ?? []).join("\n");
}
function parseAliases(value: string): Record<string, string> {
const aliases: Record<string, string> = {};
for (const line of value.split("\n")) {
const [rawKey, ...rawValue] = line.split("=");
const key = rawKey?.trim();
const aliasValue = rawValue.join("=").trim();
if (key && aliasValue) aliases[key] = aliasValue;
}
return aliases;
}
function formatAliases(values: Record<string, string> | undefined): string {
return Object.entries(values ?? {})
.map(([key, value]) => `${key}=${value}`)
.join("\n");
}
function DomainEditorDialog({
state,
onClose,
onSelect,
}: {
state: DomainEditorState | null;
onClose: () => void;
onSelect: (domain: string) => void;
}) {
const createDomain = useCreateDomain();
const updateDomain = useUpdateDomain();
const editing = state?.mode === "edit";
const domain = state?.domain;
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [entityTypes, setEntityTypes] = useState("");
const [predicates, setPredicates] = useState("");
const [attributes, setAttributes] = useState("");
const [aliases, setAliases] = useState("");
useEffect(() => {
if (!state) return;
setName(domain?.domain ?? "");
setDescription(domain?.description ?? "");
setEntityTypes(formatList(domain?.entity_types));
setPredicates(formatList(domain?.predicates));
setAttributes(formatList(domain?.attributes));
setAliases(formatAliases(domain?.aliases));
}, [domain, state]);
const saving = createDomain.isPending || updateDomain.isPending;
const save = async () => {
const normalized = name.trim().toLowerCase();
if (!domainNameRegex.test(normalized)) {
toast.error("도메인은 소문자, 숫자, _, - 조합의 2~80자로 입력하세요.");
return;
}
const body = {
description: description.trim() || null,
entity_types: parseList(entityTypes),
predicates: parseList(predicates),
attributes: parseList(attributes),
aliases: parseAliases(aliases),
};
if (body.entity_types.length === 0) {
toast.error("엔티티 타입을 최소 1개 이상 입력하세요.");
return;
}
try {
const saved = editing
? await updateDomain.mutateAsync({ domain: normalized, body })
: await createDomain.mutateAsync({ domain: normalized, ...body });
toast.success(editing ? "도메인을 수정했습니다." : "도메인을 생성했습니다.");
onSelect(saved.domain);
onClose();
} catch (e) {
toast.error((e as Error).message);
}
};
return (
<Dialog open={Boolean(state)} onOpenChange={(open) => !open && onClose()}>
<DialogPanel ariaLabel={editing ? "도메인 편집" : "도메인 생성"} className="max-w-2xl">
<DialogHeader onClose={onClose}>
<DialogTitle>{editing ? "도메인 편집" : "새 도메인 생성"}</DialogTitle>
<DialogDescription>
, , 릿 .
</DialogDescription>
</DialogHeader>
<DialogBody className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="domain-name"> ID</Label>
<Input
id="domain-name"
value={name}
disabled={editing}
placeholder="cosmetic_ingredient"
onChange={(event) => setName(event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="domain-description"></Label>
<Input
id="domain-description"
value={description}
placeholder="화장품 성분 지식 그래프"
onChange={(event) => setDescription(event.target.value)}
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="domain-entities"> </Label>
<Textarea
id="domain-entities"
value={entityTypes}
className="min-h-[160px]"
placeholder={"Ingredient\nBrand\nProduct\nSkinConcern"}
onChange={(event) => setEntityTypes(event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="domain-predicates"></Label>
<Textarea
id="domain-predicates"
value={predicates}
className="min-h-[160px]"
placeholder={"hasIngredient\nsuitableForSkinType\nhasWarning"}
onChange={(event) => setPredicates(event.target.value)}
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="domain-attributes"></Label>
<Textarea
id="domain-attributes"
value={attributes}
placeholder={"name\nsource_url\nupdated_at\nconfidence"}
onChange={(event) => setAttributes(event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="domain-aliases"> </Label>
<Textarea
id="domain-aliases"
value={aliases}
placeholder={"ingredients=hasIngredient\nwarnings=hasWarning"}
onChange={(event) => setAliases(event.target.value)}
/>
</div>
</div>
</DialogBody>
<DialogFooter>
<Button type="button" onClick={save} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
</Button>
<Button type="button" variant="outline" onClick={onClose} disabled={saving}>
</Button>
</DialogFooter>
</DialogPanel>
</Dialog>
);
}
export default function OnboardingPage() {
const navigate = useNavigate();
const { t } = useTranslation();
const [editorState, setEditorState] = useState<DomainEditorState | null>(null);
const {
data: domains,
isLoading: domainsLoading,
@@ -65,6 +271,15 @@ export default function OnboardingPage() {
project_name: values.project_name,
domain: values.domain,
target_entities: selected?.entity_types ?? [],
fields: selected?.attributes ?? [],
ontology: selected
? {
entity_types: selected.entity_types,
predicates: selected.predicates,
attributes: selected.attributes,
aliases: selected.aliases,
}
: {},
});
toast.success(
t("onboarding.created", "프로젝트가 생성되었습니다: {{name}}", {
@@ -99,29 +314,33 @@ export default function OnboardingPage() {
<Card>
<CardHeader>
<CardTitle>
{t("onboarding.formTitle", "온톨로지 도메인 선택")}
</CardTitle>
<CardDescription>
{t(
"onboarding.formDesc",
"어떤 종류의 온톨로지를 구축할지 도메인을 선택하고 프로젝트 이름을 정해주세요.",
)}
</CardDescription>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle>{t("onboarding.formTitle", "온톨로지 도메인 선택")}</CardTitle>
<CardDescription>
.
</CardDescription>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" onClick={() => navigate("/domains")}>
</Button>
<Button type="button" variant="outline" onClick={() => setEditorState({ mode: "create" })}>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<form
onSubmit={handleSubmit(onSubmit)}
className="space-y-6"
noValidate
>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6" noValidate>
<div className="space-y-2">
<Label htmlFor="project_name">
{t("onboarding.projectName", "프로젝트 이름")}
</Label>
<Input
id="project_name"
placeholder="my_perfume_project"
placeholder="my_ontology_project"
aria-invalid={Boolean(errors.project_name)}
{...register("project_name")}
/>
@@ -131,37 +350,27 @@ export default function OnboardingPage() {
</p>
)}
<p className="text-xs text-muted-foreground">
{t(
"onboarding.projectNameHint",
"영문, 숫자, _ , - 만 사용 (2~64자)",
)}
, , _, - (2~64)
</p>
</div>
<div className="space-y-2">
<Label>{t("onboarding.domain", "도메인")}</Label>
{isError && (
<Card className="border-destructive">
<CardContent className="flex items-center justify-between gap-3 py-4">
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
<span>{(error as Error).message}</span>
</div>
<Button
variant="outline"
size="sm"
type="button"
onClick={() => refetch()}
>
{t("common.retry", "다시 시도")}
</Button>
</CardContent>
</Card>
<div className="flex items-center justify-between gap-3 rounded-md border border-destructive px-4 py-3">
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
<span>{(error as Error).message}</span>
</div>
<Button variant="outline" size="sm" type="button" onClick={() => refetch()}>
{t("common.retry", "다시 시도")}
</Button>
</div>
)}
{domainsLoading && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-24" />
<Skeleton key={i} className="h-28" />
))}
</div>
)}
@@ -170,43 +379,56 @@ export default function OnboardingPage() {
{domains.map((d) => {
const active = selectedDomain === d.domain;
return (
<button
<div
key={d.domain}
type="button"
onClick={() =>
setValue("domain", d.domain, {
shouldValidate: true,
})
}
className={cn(
"rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"rounded-lg border bg-card transition-colors",
active && "border-primary bg-accent/60",
)}
>
<div className="mb-1 font-semibold capitalize">
{d.domain}
<div className="flex items-start gap-2 p-4">
<button
type="button"
onClick={() =>
setValue("domain", d.domain, {
shouldValidate: true,
})
}
className="min-w-0 flex-1 text-left focus-visible:outline-none"
>
<div className="mb-1 flex items-center gap-2">
<span className="font-semibold capitalize">{d.domain}</span>
{d.is_custom && (
<span className="rounded border px-1.5 py-0.5 text-[10px] text-muted-foreground">
custom
</span>
)}
</div>
<div className="text-xs text-muted-foreground">
{d.entity_types.length} · {d.predicates.length} · {d.attribute_count}
</div>
<div className="mt-2 line-clamp-2 text-xs text-muted-foreground/70">
{d.entity_types.slice(0, 6).join(", ")}
{d.entity_types.length > 6 && " ..."}
</div>
</button>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={`${d.domain} 편집`}
onClick={() => setEditorState({ mode: "edit", domain: d })}
>
<Pencil className="h-4 w-4" />
</Button>
</div>
<div className="text-xs text-muted-foreground">
{t("onboarding.domainSummary", {
entities: d.entity_types.length,
predicates: d.predicates.length,
defaultValue:
"엔티티 {{entities}}종 · 관계 {{predicates}}개",
})}
</div>
<div className="mt-2 line-clamp-2 text-xs text-muted-foreground/70">
{d.entity_types.slice(0, 5).join(", ")}
{d.entity_types.length > 5 && " …"}
</div>
</button>
</div>
);
})}
</div>
)}
{errors.domain && (
<p className="text-sm text-destructive">
{errors.domain.message}
</p>
<p className="text-sm text-destructive">{errors.domain.message}</p>
)}
</div>
@@ -219,19 +441,20 @@ export default function OnboardingPage() {
>
{t("common.cancel", "취소")}
</Button>
<Button
type="submit"
disabled={isSubmitting || createProject.isPending}
>
{createProject.isPending && (
<Loader2 className="h-4 w-4 animate-spin" />
)}
<Button type="submit" disabled={isSubmitting || createProject.isPending}>
{createProject.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
{t("onboarding.submit", "프로젝트 만들기")}
</Button>
</div>
</form>
</CardContent>
</Card>
<DomainEditorDialog
state={editorState}
onClose={() => setEditorState(null)}
onSelect={(domain) => setValue("domain", domain, { shouldValidate: true })}
/>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,30 +1,31 @@
import * as React from "react";
import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import {
AlertCircle,
ArrowLeft,
ArrowRight,
Check,
Eye,
Filter,
ExternalLink,
ListChecks,
Search,
ShieldCheck,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import { Badge, BadgeProps } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { DataTable, type ColumnDef } from "@/components/ui/data-table";
import { Drawer } from "@/components/ui/drawer";
import { EmptyState } from "@/components/ui/empty-state";
import { ErrorState } from "@/components/ui/error-state";
import { Tooltip } from "@/components/ui/tooltip";
import {
FilterPanel,
FilterSearch,
FilterSelect,
FilterChips,
type ActiveChip,
} from "@/components/ui/filter-panel";
import { PageHeader } from "@/components/layout/PageHeader";
import { useClaims, useUpdateClaimStatus } from "@/hooks/useClaims";
import { useProject } from "@/hooks/useProjects";
import {
@@ -50,15 +51,21 @@ function statusVariant(status: string | undefined): BadgeProps["variant"] {
function confidenceBucket(claim: Claim): string {
if (typeof claim.confidence !== "number") return "unknown";
if (claim.confidence >= 0.9) return "auto-approve candidate";
if (claim.confidence >= 0.7) return "normal review";
return "priority review";
if (claim.confidence >= 0.9) return "auto-approve";
if (claim.confidence >= 0.7) return "normal";
return "priority";
}
function claimObject(claim: Claim): string {
function claimObjectText(claim: Claim): string {
return humanizeValue(claim.object ?? claim.object_value);
}
const STATUS_LABELS: Record<string, string> = {
candidate: "Candidate",
approved: "Approved",
rejected: "Rejected",
};
export default function ReviewPage() {
const navigate = useNavigate();
const { projectId } = useParams<{ projectId: string }>();
@@ -66,24 +73,28 @@ export default function ReviewPage() {
const { data: project } = useProject(projectName);
const claims = useClaims(projectName, {
includeCandidates: true,
limit: 300,
limit: 500,
});
const updateStatus = useUpdateClaimStatus(projectName);
const [statusFilter, setStatusFilter] = useState("all");
const [statusFilter, setStatusFilter] = useState<string>("");
const [search, setSearch] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
/* --------------------- Derived data --------------------- */
const filteredClaims = useMemo(() => {
const query = search.trim().toLowerCase();
return (claims.data ?? []).filter((claim) => {
if (statusFilter !== "all" && reviewLabel(claim.status) !== statusFilter) {
if (statusFilter && reviewLabel(claim.status) !== statusFilter) {
return false;
}
if (!query) return true;
return [
claim.subject,
claim.predicate,
claimObject(claim),
claimObjectText(claim),
claim.evidence_text,
claim.page_url,
claim.source,
@@ -94,11 +105,9 @@ export default function ReviewPage() {
}, [claims.data, search, statusFilter]);
const selectedClaim = useMemo(() => {
return (
filteredClaims.find((claim) => claim.id === selectedId) ??
filteredClaims[0]
);
}, [filteredClaims, selectedId]);
if (!selectedId) return null;
return claims.data?.find((claim) => claim.id === selectedId) ?? null;
}, [claims.data, selectedId]);
const counts = useMemo(() => {
const all = claims.data ?? [];
@@ -113,6 +122,8 @@ export default function ReviewPage() {
};
}, [claims.data]);
/* --------------------- Actions --------------------- */
const applyStatus = async (claim: Claim, status: string) => {
try {
await updateStatus.mutateAsync({
@@ -123,334 +134,491 @@ export default function ReviewPage() {
? "Rejected from Claim Review"
: "Updated from Claim Review",
});
toast.success(`Claim ${status}`);
toast.success(
status === "validated_claim"
? `Approved · ${humanizeValue(claim.subject)}`
: `Rejected · ${humanizeValue(claim.subject)}`,
);
} catch (error) {
toast.error((error as Error).message);
}
};
return (
<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>
const openDetail = (claim: Claim) => {
setSelectedId(claim.id);
setDrawerOpen(true);
};
/* --------------------- Filter chips --------------------- */
const chips: ActiveChip[] = [];
if (search)
chips.push({
id: "search",
label: "Search",
value: `"${search}"`,
onClear: () => setSearch(""),
});
if (statusFilter)
chips.push({
id: "status",
label: "Status",
value: STATUS_LABELS[statusFilter] ?? statusFilter,
onClear: () => setStatusFilter(""),
});
const clearAll = () => {
setSearch("");
setStatusFilter("");
};
/* --------------------- Table columns --------------------- */
const columns: ColumnDef<Claim, any>[] = [
{
id: "status",
header: "Status",
accessorFn: (row) => reviewLabel(row.status),
cell: ({ row }) => (
<Badge variant={statusVariant(row.original.status)}>
{reviewLabel(row.original.status)}
</Badge>
),
size: 110,
enablePinning: true,
},
{
id: "subject",
header: "Subject",
accessorFn: (row) => humanizeValue(row.subject),
cell: ({ row }) => (
<span className="line-clamp-2 break-words font-medium text-foreground">
{humanizeValue(row.original.subject)}
</span>
),
size: 220,
},
{
id: "predicate",
header: "Predicate",
accessorFn: (row) => row.predicate,
cell: ({ row }) => (
<Badge variant="secondary" className="font-mono">
{row.original.predicate}
</Badge>
),
size: 160,
},
{
id: "object",
header: "Object",
accessorFn: (row) => claimObjectText(row),
cell: ({ row }) => (
<span className="line-clamp-2 break-words text-foreground">
{claimObjectText(row.original)}
</span>
),
size: 280,
enableSorting: false,
},
{
id: "confidence",
header: "Confidence",
accessorFn: (row) => row.confidence ?? 0,
cell: ({ row }) => <ConfidenceBar value={row.original.confidence} />,
size: 140,
meta: { align: "right" },
},
{
id: "bucket",
header: "Bucket",
accessorFn: (row) => confidenceBucket(row),
cell: ({ row }) => {
const b = confidenceBucket(row.original);
const tone =
b === "auto-approve"
? "pill-success"
: b === "priority"
? "pill-warning"
: "pill-info";
return <span className={tone}>{b}</span>;
},
size: 130,
},
{
id: "source",
header: "Source",
accessorFn: (row) => row.source ?? "",
cell: ({ row }) => (
<span className="text-xs text-muted-foreground">
{humanizeValue(row.original.source)}
</span>
),
size: 120,
},
{
id: "evidence",
header: "Evidence",
accessorFn: (row) => (row.evidence_text ? 1 : 0),
cell: ({ row }) =>
row.original.evidence_text ? (
<span className="text-xs font-medium text-success">yes</span>
) : (
<span className="text-xs font-medium text-warning">missing</span>
),
size: 90,
meta: { align: "center" },
},
{
id: "actions",
header: "",
enableSorting: false,
enableHiding: false,
enableResizing: false,
cell: ({ row }) => (
<div className="flex items-center justify-end gap-1">
<Tooltip content="Approve" shortcut="A">
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
applyStatus(row.original, "validated_claim");
}}
disabled={updateStatus.isPending}
className="h-7 w-7"
>
<Check className="h-3.5 w-3.5 text-success" />
</Button>
</Tooltip>
<Tooltip content="Reject" shortcut="X">
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
applyStatus(row.original, "rejected");
}}
disabled={updateStatus.isPending}
className="h-7 w-7"
>
<X className="h-3.5 w-3.5 text-danger" />
</Button>
</Tooltip>
</div>
<Button onClick={() => navigate(`/quality/${projectName}`)}>
Quality Inspector
</Button>
),
size: 90,
meta: { align: "right" },
},
];
/* --------------------- Render --------------------- */
return (
<>
<PageHeader
icon={ListChecks}
breadcrumbs={[
{ label: "Dashboard", to: "/" },
{ label: projectName, to: `/sources/${projectName}` },
{ label: "Claim Review" },
]}
title="Claim Review"
description={
project &&
`${project.name} · 후보·승인·반려 상태 분리 검토 · ${counts.total.toLocaleString()} claims`
}
actions={
<Button onClick={() => navigate(`/quality/${projectName}`)}>
<ShieldCheck className="h-4 w-4" />
Quality Inspector
</Button>
}
/>
<div className="mx-auto max-w-7xl px-6 py-6">
{claims.isError && (
<ErrorState
className="mb-4"
inline
severity="error"
title="Failed to load claims"
description={claims.error as Error}
onRetry={() => claims.refetch()}
/>
)}
{/* Metrics */}
<div className="mb-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Metric label="Total" value={counts.total} tone="muted" />
<Metric label="Candidate" value={counts.candidate} tone="warning" />
<Metric label="Approved" value={counts.approved} tone="success" />
<Metric label="Rejected" value={counts.rejected} tone="danger" />
</div>
{/* Filter panel */}
<FilterPanel
className="mb-4"
chips={chips.length > 0 ? <FilterChips chips={chips} /> : undefined}
onClearAll={chips.length > 0 ? clearAll : undefined}
>
<FilterSearch
value={search}
onChange={setSearch}
placeholder="Search subject, predicate, object, evidence…"
/>
<FilterSelect
label="Status"
value={statusFilter}
onChange={setStatusFilter}
placeholder="All statuses"
options={[
{ value: "candidate", label: "Candidate", hint: counts.candidate, swatch: "hsl(var(--warning))" },
{ value: "approved", label: "Approved", hint: counts.approved, swatch: "hsl(var(--success))" },
{ value: "rejected", label: "Rejected", hint: counts.rejected, swatch: "hsl(var(--danger))" },
]}
/>
</FilterPanel>
<DataTable<Claim>
tableId="review"
columns={columns}
data={filteredClaims}
getRowId={(row) => row.id}
loading={claims.isLoading}
loadingRows={8}
height={640}
onRowClick={openDetail}
rowToneAccessor={(row) => {
const r = reviewLabel(row.status);
if (r === "approved") return "success";
if (r === "rejected") return "danger";
return undefined;
}}
empty={
<EmptyState
icon={ListChecks}
title="조건에 맞는 클레임이 없습니다"
description="필터를 조정하거나 클레임을 추가하세요."
primaryAction={
chips.length > 0 ? (
<Button variant="outline" onClick={clearAll}>
Clear filters
</Button>
) : undefined
}
/>
}
/>
</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 xl:grid-cols-[minmax(0,1fr)_420px]">
<Card className="min-w-0">
<CardHeader>
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="min-w-0">
<CardTitle>Review Queue</CardTitle>
<CardDescription>
, , , .
</CardDescription>
{/* Detail Drawer */}
<Drawer
open={drawerOpen && !!selectedClaim}
onOpenChange={(open) => {
setDrawerOpen(open);
if (!open) setSelectedId(null);
}}
size="lg"
title={
selectedClaim
? `Claim #${selectedClaim.id} · ${selectedClaim.predicate}`
: ""
}
description="근거, 출처, 검증 결과, 히스토리"
headerExtra={
selectedClaim ? (
<Badge variant={statusVariant(selectedClaim.status)}>
{reviewLabel(selectedClaim.status)}
</Badge>
) : null
}
footer={
selectedClaim ? (
<>
<Button
variant="outline"
onClick={() => applyStatus(selectedClaim, "rejected")}
disabled={updateStatus.isPending}
>
<X className="h-4 w-4" />
Reject
</Button>
<Button
onClick={() => applyStatus(selectedClaim, "validated_claim")}
disabled={updateStatus.isPending}
>
<Check className="h-4 w-4" />
Approve
</Button>
</>
) : null
}
>
{selectedClaim && (
<div className="space-y-5">
{/* Triple summary */}
<div className="rounded-md border border-border bg-background-subtle p-3">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-medium text-foreground">
{humanizeValue(selectedClaim.subject)}
</span>
{selectedClaim.subject_type && (
<span className="pill-muted">
{selectedClaim.subject_type}
</span>
)}
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />
<Badge variant="secondary" className="font-mono">
{selectedClaim.predicate}
</Badge>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium text-foreground">
{claimObjectText(selectedClaim)}
</span>
</div>
<div className="flex 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-48 pl-9 sm:w-56"
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-32 pl-9 sm:w-40"
>
<option value="all">All</option>
<option value="candidate">Candidate</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
</Select>
</div>
<div className="mt-2 flex flex-wrap gap-3 text-2xs text-muted-foreground">
<span>
Confidence{" "}
<span className="font-medium text-foreground tabular-nums">
{formatPercent(selectedClaim.confidence)}
</span>
</span>
<span>
Bucket{" "}
<span className="font-medium text-foreground">
{confidenceBucket(selectedClaim)}
</span>
</span>
{selectedClaim.review_required && (
<span className="pill-warning">review required</span>
)}
</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) => {
const isSelected = selectedClaim?.id === claim.id;
return (
<article
key={claim.id}
className={cn(
"rounded-md border bg-surface transition-colors",
isSelected
? "border-brand-500 ring-1 ring-brand-500/30"
: "border-border hover:border-border-strong",
)}
>
<button
type="button"
onClick={() => setSelectedId(claim.id)}
className="block w-full px-4 pt-4 pb-3 text-left"
>
<div className="mb-2 flex flex-wrap items-center gap-1.5">
<Badge variant={statusVariant(claim.status)}>
{reviewLabel(claim.status)}
</Badge>
<Badge variant="secondary">{claim.predicate}</Badge>
<span className="text-2xs text-muted-foreground">
{confidenceBucket(claim)}
</span>
</div>
<div className="flex flex-wrap items-baseline gap-1.5 text-sm">
<span className="break-all font-medium text-foreground">
{humanizeValue(claim.subject)}
</span>
<ArrowRight className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
<span className="break-all font-medium text-foreground">
{claimObject(claim)}
</span>
</div>
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-2xs text-muted-foreground">
<span>
Confidence{" "}
<span className="font-medium text-foreground tabular-nums">
{formatPercent(claim.confidence)}
</span>
</span>
<span>
Source{" "}
<span className="font-medium text-foreground">
{humanizeValue(claim.source)}
</span>
</span>
<span>
Evidence{" "}
<span
className={
claim.evidence_text
? "font-medium text-success"
: "font-medium text-warning"
}
>
{claim.evidence_text ? "yes" : "missing"}
</span>
</span>
<span>
Method{" "}
<span className="font-medium text-foreground">
{humanizeValue(claim.extraction_method)}
</span>
</span>
</div>
</button>
<div className="flex flex-wrap items-center justify-end gap-1.5 border-t border-border bg-background-subtle px-3 py-2">
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedId(claim.id)}
>
<Eye className="h-3.5 w-3.5" />
Detail
</Button>
<Button
variant="outline"
size="sm"
onClick={() => applyStatus(claim, "validated_claim")}
disabled={updateStatus.isPending}
>
<Check className="h-3.5 w-3.5" />
Approve
</Button>
<Button
variant="outline"
size="sm"
onClick={() => applyStatus(claim, "rejected")}
disabled={updateStatus.isPending}
>
<X className="h-3.5 w-3.5" />
Reject
</Button>
</div>
</article>
);
})}
</div>
</CardContent>
</Card>
<aside className="xl:sticky xl:top-4 xl: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}
{/* Detail grid */}
<div className="grid gap-3 sm:grid-cols-2">
<DetailRow label="Source" value={selectedClaim.source} />
<DetailRow
label="Page Type"
value={selectedClaim.page_type}
/>
<DetailRow
label="Extraction Method"
value={selectedClaim.extraction_method}
/>
<DetailRow
label="Validation"
value={
selectedClaim.validation_status ??
selectedClaim.graph_merge_status
}
/>
{selectedClaim.page_url && (
<DetailRow
label="Source URL"
value={
<a
href={selectedClaim.page_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-brand-600 hover:underline dark:text-brand-300"
>
<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>
<ExternalLink className="h-3 w-3" />
<span className="max-w-[240px] truncate">
{selectedClaim.page_url}
</span>
</a>
}
full
/>
)}
</CardContent>
</Card>
</aside>
</div>
</div>
{selectedClaim.graph_merge_reason && (
<DetailRow
label="Graph Reason"
value={selectedClaim.graph_merge_reason}
full
/>
)}
</div>
{/* Evidence */}
{selectedClaim.evidence_text && (
<section>
<h3 className="mb-1.5 text-2xs font-semibold uppercase tracking-wider text-muted-foreground">
Evidence Text
</h3>
<blockquote className="rounded-md border-l-4 border-warning bg-warning-subtle px-3 py-2 text-sm leading-relaxed text-foreground">
{selectedClaim.evidence_text}
</blockquote>
</section>
)}
{/* Confidence breakdown */}
{selectedClaim.confidence_breakdown && (
<section>
<h3 className="mb-1.5 text-2xs font-semibold uppercase tracking-wider text-muted-foreground">
Confidence Breakdown
</h3>
<pre className="max-h-44 overflow-auto rounded-md border border-border bg-background-subtle p-3 text-2xs">
{JSON.stringify(
selectedClaim.confidence_breakdown,
null,
2,
)}
</pre>
</section>
)}
{/* Source history */}
{selectedClaim.source_history &&
selectedClaim.source_history.length > 0 && (
<section>
<h3 className="mb-1.5 text-2xs font-semibold uppercase tracking-wider text-muted-foreground">
Source History ({selectedClaim.source_history.length})
</h3>
<pre className="max-h-44 overflow-auto rounded-md border border-border bg-background-subtle p-3 text-2xs">
{JSON.stringify(selectedClaim.source_history, null, 2)}
</pre>
</section>
)}
<div className="border-t border-border pt-3 text-2xs text-muted-foreground">
Last seen {formatDateTime(selectedClaim.last_seen_at)}
</div>
</div>
)}
</Drawer>
</>
);
}
function Metric({ label, value }: { label: string; value: number }) {
/* ============================================================
* Sub-components
* ========================================================== */
const METRIC_TONE: Record<string, string> = {
muted: "border-border",
warning: "border-warning-border bg-warning-subtle/30",
success: "border-success-border bg-success-subtle/30",
danger: "border-danger-border bg-danger-subtle/30",
};
function Metric({
label,
value,
tone = "muted",
}: {
label: string;
value: number;
tone?: keyof typeof METRIC_TONE;
}) {
return (
<Card>
<CardContent className="py-4">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-1 text-2xl font-semibold">
<Card className={`border ${METRIC_TONE[tone]}`}>
<CardContent className="py-3">
<div className="text-2xs uppercase tracking-wider text-muted-foreground">
{label}
</div>
<div className="mt-1 text-2xl font-semibold tabular-nums text-foreground">
{value.toLocaleString()}
</div>
</CardContent>
@@ -458,11 +626,48 @@ function Metric({ label, value }: { label: string; value: number }) {
);
}
function DetailRow({ label, value }: { label: string; value: unknown }) {
function ConfidenceBar({ value }: { value: number | undefined }) {
if (typeof value !== "number") {
return <span className="text-xs text-muted-foreground/60"></span>;
}
const pct = Math.round(value * 100);
const tone =
value >= 0.9
? "bg-success"
: value >= 0.7
? "bg-info"
: value >= 0.5
? "bg-warning"
: "bg-danger";
return (
<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 className="flex items-center justify-end gap-2">
<div className="h-1.5 w-14 overflow-hidden rounded-full bg-muted">
<div className={`h-full ${tone}`} style={{ width: `${pct}%` }} />
</div>
<span className="text-xs tabular-nums text-foreground">{pct}%</span>
</div>
);
}
function DetailRow({
label,
value,
full = false,
}: {
label: string;
value: unknown;
full?: boolean;
}) {
return (
<div
className={`rounded-md border border-border bg-background-subtle px-3 py-2 ${full ? "sm:col-span-2" : ""}`}
>
<div className="text-2xs uppercase tracking-wider text-muted-foreground">
{label}
</div>
<div className="mt-1 break-words text-sm font-medium text-foreground">
{React.isValidElement(value) ? value : humanizeValue(value)}
</div>
</div>
);
}