[crawler_platform 삭제]
This commit is contained in:
465
ontology_platform/web/frontend/src/pages/BuildPipelinePage.tsx
Normal file
465
ontology_platform/web/frontend/src/pages/BuildPipelinePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
import { useNavigate, useParams } 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,
|
||||
ArrowRight,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
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 { useProject } from "@/hooks/useProjects";
|
||||
import { useCreateSource, useDeleteSource } from "@/hooks/useSources";
|
||||
|
||||
const SOURCE_TYPES = [
|
||||
"official",
|
||||
"review",
|
||||
"blog",
|
||||
"news",
|
||||
"community",
|
||||
"unknown",
|
||||
] as const;
|
||||
|
||||
const sourceSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(2, "최소 2자 이상")
|
||||
.max(64, "최대 64자")
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, "영문/숫자/_/-만 허용"),
|
||||
type: z.enum(SOURCE_TYPES),
|
||||
base_url: z
|
||||
.string()
|
||||
.url("올바른 URL 형식이 아닙니다")
|
||||
.or(z.literal(""))
|
||||
.optional(),
|
||||
trust_level: z
|
||||
.number({ invalid_type_error: "0~1 사이의 숫자" })
|
||||
.min(0)
|
||||
.max(1),
|
||||
rate_limit_per_minute: z
|
||||
.number({ invalid_type_error: "양의 정수" })
|
||||
.int()
|
||||
.min(1)
|
||||
.max(600),
|
||||
respect_robots_txt: z.boolean(),
|
||||
});
|
||||
|
||||
type SourceFormValues = z.infer<typeof sourceSchema>;
|
||||
|
||||
export default function ConfigureSourcesPage() {
|
||||
const navigate = useNavigate();
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const { t } = useTranslation();
|
||||
const projectName = projectId ?? "";
|
||||
|
||||
const {
|
||||
data: project,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useProject(projectName);
|
||||
|
||||
const createSource = useCreateSource(projectName);
|
||||
const deleteSource = useDeleteSource(projectName);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<SourceFormValues>({
|
||||
resolver: zodResolver(sourceSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
type: "official",
|
||||
base_url: "",
|
||||
trust_level: 0.7,
|
||||
rate_limit_per_minute: 30,
|
||||
respect_robots_txt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const onAddSource = async (values: SourceFormValues) => {
|
||||
try {
|
||||
await createSource.mutateAsync({
|
||||
...values,
|
||||
base_url: values.base_url || null,
|
||||
});
|
||||
toast.success(
|
||||
t("sources.added", "소스가 추가되었습니다: {{name}}", {
|
||||
name: values.name,
|
||||
}),
|
||||
);
|
||||
reset();
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
t("sources.addFailed", "추가 실패: {{msg}}", {
|
||||
msg: (e as Error).message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async (sourceName: string) => {
|
||||
if (
|
||||
!confirm(
|
||||
t("sources.confirmDelete", "정말 '{{name}}' 소스를 삭제하시겠습니까?", {
|
||||
name: sourceName,
|
||||
}),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteSource.mutateAsync(sourceName);
|
||||
toast.success(
|
||||
t("sources.deleted", "소스가 삭제되었습니다: {{name}}", {
|
||||
name: sourceName,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
t("sources.deleteFailed", "삭제 실패: {{msg}}", {
|
||||
msg: (e as Error).message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-6 py-10">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate("/")}
|
||||
aria-label={t("common.back", "이전")}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("sources.title", "참고 소스 설정")}
|
||||
</h1>
|
||||
{project && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project.name}{" "}
|
||||
<span className="capitalize text-muted-foreground/70">
|
||||
· {project.domain}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => navigate(`/crawl/${projectName}`)}
|
||||
disabled={!project || (project.sources?.length ?? 0) === 0}
|
||||
>
|
||||
{t("sources.next", "크롤 진행")}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<Card className="mb-6 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="grid gap-6 lg:grid-cols-[1fr_360px]">
|
||||
<section>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("sources.listTitle", "등록된 소스")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"sources.listDesc",
|
||||
"프로젝트 온톨로지 구축에 사용할 참고 사이트 목록",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading && (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{project && project.sources.length === 0 && (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t(
|
||||
"sources.empty",
|
||||
"아직 등록된 소스가 없습니다. 오른쪽 폼에서 추가하세요.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{project && project.sources.length > 0 && (
|
||||
<ul className="divide-y">
|
||||
{project.sources.map((s) => (
|
||||
<li
|
||||
key={s.id}
|
||||
className="flex items-center justify-between gap-4 py-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{s.name}</span>
|
||||
<span className="rounded bg-secondary px-1.5 py-0.5 text-xs text-secondary-foreground">
|
||||
{s.type}
|
||||
</span>
|
||||
{typeof s.trust_level === "number" && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("sources.trust", "신뢰도")}{" "}
|
||||
{s.trust_level.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{s.base_url && (
|
||||
<a
|
||||
href={s.base_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{s.base_url}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onDelete(s.name)}
|
||||
disabled={deleteSource.isPending}
|
||||
aria-label={t("sources.delete", "삭제")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<aside>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("sources.addTitle", "소스 추가")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t("sources.addDesc", "참고할 사이트 정보를 입력하세요")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
onSubmit={handleSubmit(onAddSource)}
|
||||
className="space-y-4"
|
||||
noValidate
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="src_name">
|
||||
{t("sources.name", "이름")}
|
||||
</Label>
|
||||
<Input
|
||||
id="src_name"
|
||||
placeholder="official_brand_site"
|
||||
{...register("name")}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.name.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="src_type">
|
||||
{t("sources.type", "타입")}
|
||||
</Label>
|
||||
<select
|
||||
id="src_type"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
{...register("type")}
|
||||
>
|
||||
{SOURCE_TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="src_url">
|
||||
{t("sources.baseUrl", "Base URL")}
|
||||
</Label>
|
||||
<Input
|
||||
id="src_url"
|
||||
placeholder="https://example.com"
|
||||
type="url"
|
||||
{...register("base_url")}
|
||||
/>
|
||||
{errors.base_url && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.base_url.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="src_trust">
|
||||
{t("sources.trust", "신뢰도")}
|
||||
</Label>
|
||||
<Input
|
||||
id="src_trust"
|
||||
type="number"
|
||||
step="0.05"
|
||||
min={0}
|
||||
max={1}
|
||||
{...register("trust_level", { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.trust_level && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.trust_level.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="src_rate">
|
||||
{t("sources.rateLimit", "rate/분")}
|
||||
</Label>
|
||||
<Input
|
||||
id="src_rate"
|
||||
type="number"
|
||||
min={1}
|
||||
max={600}
|
||||
{...register("rate_limit_per_minute", {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
{errors.rate_limit_per_minute && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.rate_limit_per_minute.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-input"
|
||||
{...register("respect_robots_txt")}
|
||||
/>
|
||||
<span>
|
||||
{t("sources.respectRobots", "robots.txt 준수")}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isSubmitting || createSource.isPending}
|
||||
>
|
||||
{createSource.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" />
|
||||
)}
|
||||
{t("sources.add", "소스 추가")}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
485
ontology_platform/web/frontend/src/pages/CrawlPage.tsx
Normal file
485
ontology_platform/web/frontend/src/pages/CrawlPage.tsx
Normal file
@@ -0,0 +1,485 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useParams } 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,
|
||||
ArrowRight,
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
Globe,
|
||||
Loader2,
|
||||
PlayCircle,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Badge, BadgeProps } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useProject } from "@/hooks/useProjects";
|
||||
import {
|
||||
useCancelCrawl,
|
||||
useCrawlJob,
|
||||
useStartSiteCrawl,
|
||||
} from "@/hooks/useCrawl";
|
||||
import { isCrawlTerminal } from "@/lib/api/crawl";
|
||||
|
||||
const startCrawlSchema = z.object({
|
||||
source_name: z.string().min(1, "소스를 선택하세요"),
|
||||
url: z.string().url("올바른 URL 형식이 아닙니다"),
|
||||
max_depth: z.number().int().min(0).max(10),
|
||||
max_pages: z.number().int().min(1).max(500),
|
||||
same_domain_only: z.boolean(),
|
||||
});
|
||||
|
||||
type StartCrawlFormValues = z.infer<typeof startCrawlSchema>;
|
||||
|
||||
function statusBadgeVariant(status: string): BadgeProps["variant"] {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "success";
|
||||
case "failed":
|
||||
return "destructive";
|
||||
case "canceled":
|
||||
return "secondary";
|
||||
case "cancel_requested":
|
||||
return "warning";
|
||||
case "running":
|
||||
case "pending":
|
||||
return "default";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
return <Badge variant={statusBadgeVariant(status)}>{status}</Badge>;
|
||||
}
|
||||
|
||||
export default function CrawlPage() {
|
||||
const navigate = useNavigate();
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const { t } = useTranslation();
|
||||
const projectName = projectId ?? "";
|
||||
|
||||
const { data: project, isLoading, isError, error, refetch } = useProject(projectName);
|
||||
const [activeJobId, setActiveJobId] = useState<string | null>(null);
|
||||
const { data: job } = useCrawlJob(activeJobId);
|
||||
const startCrawl = useStartSiteCrawl();
|
||||
const cancelCrawl = useCancelCrawl();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<StartCrawlFormValues>({
|
||||
resolver: zodResolver(startCrawlSchema),
|
||||
defaultValues: {
|
||||
source_name: "",
|
||||
url: "",
|
||||
max_depth: 2,
|
||||
max_pages: 30,
|
||||
same_domain_only: true,
|
||||
},
|
||||
});
|
||||
|
||||
const onStart = async (values: StartCrawlFormValues) => {
|
||||
try {
|
||||
const created = await startCrawl.mutateAsync({
|
||||
project_name: projectName,
|
||||
...values,
|
||||
});
|
||||
setActiveJobId(created.job_id);
|
||||
toast.success(
|
||||
t("crawl.started", "크롤이 시작되었습니다 (job #{{id}})", {
|
||||
id: created.job_id,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
t("crawl.startFailed", "시작 실패: {{msg}}", {
|
||||
msg: (e as Error).message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onCancel = async () => {
|
||||
if (!activeJobId) return;
|
||||
try {
|
||||
await cancelCrawl.mutateAsync(activeJobId);
|
||||
toast.info(t("crawl.cancelRequested", "취소 요청됨"));
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
t("crawl.cancelFailed", "취소 실패: {{msg}}", {
|
||||
msg: (e as Error).message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const sources = project?.sources ?? [];
|
||||
const sourceName = watch("source_name");
|
||||
const selectedSource = sources.find((s) => s.name === sourceName);
|
||||
const progress = job?.progress;
|
||||
const visited = progress?.visited_count ?? 0;
|
||||
const queued = progress?.queued_count ?? 0;
|
||||
const analyzed = progress?.analyzed_count ?? 0;
|
||||
const errors_ = progress?.errors ?? [];
|
||||
const max_pages = watch("max_pages");
|
||||
const progressPct = job && max_pages
|
||||
? Math.min(100, (visited / max_pages) * 100)
|
||||
: 0;
|
||||
const terminal = job ? isCrawlTerminal(job.status) : false;
|
||||
const running = job && !terminal;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6 py-10">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(`/sources/${projectName}`)}
|
||||
aria-label={t("common.back", "이전")}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("crawl.title", "시드 크롤")}
|
||||
</h1>
|
||||
{project && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project.name}
|
||||
<span className="capitalize text-muted-foreground/70">
|
||||
{" "}
|
||||
· {project.domain}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{terminal && job?.status === "completed" && (
|
||||
<Button onClick={() => navigate(`/pipeline/${projectName}`)}>
|
||||
{t("crawl.review", "파이프라인 확인")}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<Card className="mb-6 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="grid gap-6 lg:grid-cols-[420px_1fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("crawl.formTitle", "크롤 설정")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"crawl.formDesc",
|
||||
"시드 URL에서 시작해 링크를 따라가며 정보를 추출합니다",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
onSubmit={handleSubmit(onStart)}
|
||||
className="space-y-4"
|
||||
noValidate
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="source_name">
|
||||
{t("crawl.source", "참고 소스")}
|
||||
</Label>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-10" />
|
||||
) : (
|
||||
<Select
|
||||
id="source_name"
|
||||
{...register("source_name")}
|
||||
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", "소스를 선택하세요...")}
|
||||
</option>
|
||||
{sources.map((s) => (
|
||||
<option key={s.id} value={s.name}>
|
||||
{s.name} ({s.type})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
{sources.length === 0 && !isLoading && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"crawl.noSources",
|
||||
"등록된 소스가 없습니다. 먼저 참고 소스를 추가하세요.",
|
||||
)}{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary underline"
|
||||
onClick={() => navigate(`/sources/${projectName}`)}
|
||||
>
|
||||
{t("crawl.addSource", "소스 추가")}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
{errors.source_name && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.source_name.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="url">{t("crawl.seedUrl", "시드 URL")}</Label>
|
||||
<Input
|
||||
id="url"
|
||||
placeholder={
|
||||
selectedSource?.base_url ?? "https://example.com/start"
|
||||
}
|
||||
{...register("url")}
|
||||
/>
|
||||
{errors.url && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.url.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="max_depth">
|
||||
{t("crawl.maxDepth", "최대 깊이")}
|
||||
</Label>
|
||||
<Input
|
||||
id="max_depth"
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
{...register("max_depth", { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.max_depth && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.max_depth.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="max_pages">
|
||||
{t("crawl.maxPages", "최대 페이지")}
|
||||
</Label>
|
||||
<Input
|
||||
id="max_pages"
|
||||
type="number"
|
||||
min={1}
|
||||
max={500}
|
||||
{...register("max_pages", { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.max_pages && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.max_pages.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-input"
|
||||
{...register("same_domain_only")}
|
||||
/>
|
||||
<span>
|
||||
{t("crawl.sameDomainOnly", "동일 도메인만 따라가기")}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isSubmitting || startCrawl.isPending || Boolean(running)}
|
||||
>
|
||||
{startCrawl.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<PlayCircle className="h-4 w-4" />
|
||||
)}
|
||||
{t("crawl.start", "크롤 시작")}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{t("crawl.progressTitle", "진행 상태")}
|
||||
</CardTitle>
|
||||
{job && (
|
||||
<CardDescription className="flex items-center gap-2">
|
||||
<span>job #{job.job_id}</span>
|
||||
<StatusBadge status={job.status} />
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
{running && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
disabled={cancelCrawl.isPending}
|
||||
>
|
||||
<Ban className="h-4 w-4" />
|
||||
{t("crawl.cancel", "취소")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!job && (
|
||||
<div className="flex flex-col items-center gap-3 py-12 text-center text-muted-foreground">
|
||||
<Globe className="h-12 w-12 opacity-40" />
|
||||
<p>
|
||||
{t(
|
||||
"crawl.idleHint",
|
||||
"왼쪽에서 시드 URL을 입력하고 시작하세요",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{job && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t("crawl.visited", "방문")} {visited}
|
||||
{" / "}
|
||||
{max_pages}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t("crawl.queued", "대기")} {queued} ·{" "}
|
||||
{t("crawl.analyzed", "분석")} {analyzed}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={progressPct} indeterminate={running && visited === 0} />
|
||||
</div>
|
||||
|
||||
{job.url && (
|
||||
<div className="rounded-md bg-secondary/30 px-3 py-2 text-xs">
|
||||
<div className="text-muted-foreground">
|
||||
{t("crawl.seedUrl", "시드 URL")}
|
||||
</div>
|
||||
<a
|
||||
href={job.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="break-all text-foreground hover:underline"
|
||||
>
|
||||
{job.url}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{progress?.latest_page && (
|
||||
<div className="rounded-md border bg-background px-3 py-2 text-xs">
|
||||
<div className="mb-1 text-muted-foreground">
|
||||
{t("crawl.latestPage", "최근 페이지")}
|
||||
</div>
|
||||
<div className="truncate font-medium">
|
||||
{progress.latest_page.title || progress.latest_page.url}
|
||||
</div>
|
||||
{progress.latest_page.page_type && (
|
||||
<Badge variant="outline" className="mt-1">
|
||||
{progress.latest_page.page_type}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{job.error && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
<XCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
<span>{job.error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errors_.length > 0 && (
|
||||
<details className="rounded-md border bg-background">
|
||||
<summary className="cursor-pointer px-3 py-2 text-sm font-medium">
|
||||
{t("crawl.errorsCount", "에러 {{count}}건", {
|
||||
count: errors_.length,
|
||||
})}
|
||||
</summary>
|
||||
<ul className="max-h-40 overflow-y-auto px-4 py-2 text-xs text-muted-foreground">
|
||||
{errors_.map((e, i) => (
|
||||
<li key={i} className="border-b py-1 last:border-0">
|
||||
{e}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{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", "크롤 완료. 구축 파이프라인으로 이동하세요.")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
180
ontology_platform/web/frontend/src/pages/DashboardPage.tsx
Normal file
180
ontology_platform/web/frontend/src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, FolderOpen, AlertCircle, Clock } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
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);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { data: projects, isLoading, isError, error, refetch } = useProjects();
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-6 py-10">
|
||||
<div className="mb-8 flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{t("dashboard.title", "Ontology Builder")}
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
{t(
|
||||
"dashboard.subtitle",
|
||||
"Build and manage domain ontologies with AI-powered extraction",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => navigate("/onboard")}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("dashboard.newProject", "New Project")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("dashboard.projects", "Projects")}
|
||||
</h2>
|
||||
{projects && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("dashboard.projectCount", "{{count}} total", {
|
||||
count: projects.length,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-32" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<Card className="border-destructive">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-destructive">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
{t("dashboard.loadFailed", "Failed to load projects")}
|
||||
</CardTitle>
|
||||
<CardDescription>{(error as Error).message}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="outline" onClick={() => refetch()}>
|
||||
{t("common.retry", "Retry")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{projects && projects.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-4 py-12 text-center">
|
||||
<FolderOpen className="h-12 w-12 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{t("dashboard.empty.title", "No projects yet")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"dashboard.empty.hint",
|
||||
"Create your first project to start building an ontology",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => navigate("/onboard")}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("dashboard.newProject", "New Project")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{projects && projects.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((p) => (
|
||||
<ProjectCard key={p.id} project={p} onOpen={navigate} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
402
ontology_platform/web/frontend/src/pages/ExportApiPage.tsx
Normal file
402
ontology_platform/web/frontend/src/pages/ExportApiPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
567
ontology_platform/web/frontend/src/pages/GraphViewPage.tsx
Normal file
567
ontology_platform/web/frontend/src/pages/GraphViewPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
237
ontology_platform/web/frontend/src/pages/OnboardingPage.tsx
Normal file
237
ontology_platform/web/frontend/src/pages/OnboardingPage.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
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 {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
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 { useCreateProjectInline } from "@/hooks/useProjects";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const projectNameRegex = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
const schema = z.object({
|
||||
project_name: z
|
||||
.string()
|
||||
.min(2, "최소 2자 이상")
|
||||
.max(64, "최대 64자")
|
||||
.regex(projectNameRegex, "영문/숫자/_/- 만 허용"),
|
||||
domain: z.string().min(1, "도메인을 선택하세요"),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
data: domains,
|
||||
isLoading: domainsLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useDomains();
|
||||
const createProject = useCreateProjectInline();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { project_name: "", domain: "" },
|
||||
});
|
||||
|
||||
const selectedDomain = watch("domain");
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
const selected = domains?.find((d) => d.domain === values.domain);
|
||||
try {
|
||||
const created = await createProject.mutateAsync({
|
||||
project_name: values.project_name,
|
||||
domain: values.domain,
|
||||
target_entities: selected?.entity_types ?? [],
|
||||
});
|
||||
toast.success(
|
||||
t("onboarding.created", "프로젝트가 생성되었습니다: {{name}}", {
|
||||
name: created.name,
|
||||
}),
|
||||
);
|
||||
navigate(`/sources/${created.name}`);
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
t("onboarding.createFailed", "생성 실패: {{msg}}", {
|
||||
msg: (e as Error).message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-10">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate("/")}
|
||||
aria-label={t("common.back", "이전")}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("onboarding.title", "새 프로젝트 만들기")}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{t("onboarding.formTitle", "온톨로지 도메인 선택")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"onboarding.formDesc",
|
||||
"어떤 종류의 온톨로지를 구축할지 도메인을 선택하고 프로젝트 이름을 정해주세요.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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"
|
||||
aria-invalid={Boolean(errors.project_name)}
|
||||
{...register("project_name")}
|
||||
/>
|
||||
{errors.project_name && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.project_name.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"onboarding.projectNameHint",
|
||||
"영문, 숫자, _ , - 만 사용 (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>
|
||||
)}
|
||||
{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" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{domains && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{domains.map((d) => {
|
||||
const active = selectedDomain === d.domain;
|
||||
return (
|
||||
<button
|
||||
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",
|
||||
active && "border-primary bg-accent/60",
|
||||
)}
|
||||
>
|
||||
<div className="mb-1 font-semibold capitalize">
|
||||
{d.domain}
|
||||
</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>
|
||||
)}
|
||||
{errors.domain && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.domain.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 border-t pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate("/")}
|
||||
disabled={isSubmitting || createProject.isPending}
|
||||
>
|
||||
{t("common.cancel", "취소")}
|
||||
</Button>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
688
ontology_platform/web/frontend/src/pages/OntologyEditorPage.tsx
Normal file
688
ontology_platform/web/frontend/src/pages/OntologyEditorPage.tsx
Normal file
@@ -0,0 +1,688 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } 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,
|
||||
FileJson,
|
||||
Link2,
|
||||
Loader2,
|
||||
Network,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useProject } from "@/hooks/useProjects";
|
||||
import { useOntology } from "@/hooks/useDomains";
|
||||
import {
|
||||
useBulkCreateEntities,
|
||||
useCreateEntity,
|
||||
useDeleteEntity,
|
||||
useEntities,
|
||||
} from "@/hooks/useEntities";
|
||||
import {
|
||||
useClaims,
|
||||
useCreateClaim,
|
||||
useDeleteClaim,
|
||||
} from "@/hooks/useClaims";
|
||||
|
||||
const entitySchema = z.object({
|
||||
entity_type: z.string().min(1, "타입을 선택하세요"),
|
||||
name: z.string().min(1, "이름을 입력하세요").max(240),
|
||||
});
|
||||
type EntityFormValues = z.infer<typeof entitySchema>;
|
||||
|
||||
const claimSchema = z.object({
|
||||
source_name: z.string().min(1, "소스를 선택하세요"),
|
||||
subject_entity_id: z.number().int().min(1, "주어 엔티티 선택"),
|
||||
predicate: z.string().min(1, "술어를 선택하세요"),
|
||||
object_kind: z.enum(["entity", "value"]),
|
||||
object_entity_id: z.number().int().nullable().optional(),
|
||||
object_value: z.string().optional(),
|
||||
confidence: z.number().min(0).max(1),
|
||||
});
|
||||
type ClaimFormValues = z.infer<typeof claimSchema>;
|
||||
|
||||
export default function OntologyEditorPage() {
|
||||
const navigate = useNavigate();
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const { t } = useTranslation();
|
||||
const projectName = projectId ?? "";
|
||||
const [tab, setTab] = useState<"entities" | "claims" | "bulk">("entities");
|
||||
|
||||
const { data: project, isLoading: projectLoading, isError, error, refetch } =
|
||||
useProject(projectName);
|
||||
const { data: ontology } = useOntology(project?.domain);
|
||||
|
||||
const entities = useEntities(projectName);
|
||||
const claims = useClaims(projectName, { includeCandidates: true });
|
||||
|
||||
const createEntity = useCreateEntity(projectName);
|
||||
const bulkCreate = useBulkCreateEntities(projectName);
|
||||
const deleteEntity = useDeleteEntity(projectName);
|
||||
const createClaim = useCreateClaim(projectName);
|
||||
const deleteClaim = useDeleteClaim(projectName);
|
||||
|
||||
const sources = project?.sources ?? [];
|
||||
const entityTypeOptions = useMemo(() => {
|
||||
if (ontology?.entity_types?.length) return ontology.entity_types;
|
||||
return ["Entity", "Concept", "Attribute"];
|
||||
}, [ontology]);
|
||||
const predicateOptions = useMemo(() => {
|
||||
if (ontology?.predicates?.length) return ontology.predicates;
|
||||
return ["hasAttribute", "relatedTo", "sameAs"];
|
||||
}, [ontology]);
|
||||
|
||||
// ── Entity form ───────────────────────────────────────────────
|
||||
const entityForm = useForm<EntityFormValues>({
|
||||
resolver: zodResolver(entitySchema),
|
||||
defaultValues: { entity_type: "", name: "" },
|
||||
});
|
||||
|
||||
const onCreateEntity = async (values: EntityFormValues) => {
|
||||
try {
|
||||
await createEntity.mutateAsync({
|
||||
entity_type: values.entity_type,
|
||||
name: values.name,
|
||||
});
|
||||
toast.success(
|
||||
t("editor.entityAdded", "엔티티가 추가되었습니다: {{name}}", {
|
||||
name: values.name,
|
||||
}),
|
||||
);
|
||||
entityForm.reset({ entity_type: values.entity_type, name: "" });
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
t("editor.entityAddFailed", "추가 실패: {{msg}}", {
|
||||
msg: (e as Error).message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Claim form ────────────────────────────────────────────────
|
||||
const claimForm = useForm<ClaimFormValues>({
|
||||
resolver: zodResolver(claimSchema),
|
||||
defaultValues: {
|
||||
source_name: "",
|
||||
subject_entity_id: 0,
|
||||
predicate: "",
|
||||
object_kind: "entity",
|
||||
object_entity_id: null,
|
||||
object_value: "",
|
||||
confidence: 1.0,
|
||||
},
|
||||
});
|
||||
const objectKind = claimForm.watch("object_kind");
|
||||
|
||||
const onCreateClaim = async (values: ClaimFormValues) => {
|
||||
try {
|
||||
await createClaim.mutateAsync({
|
||||
source_name: values.source_name,
|
||||
subject_entity_id: values.subject_entity_id,
|
||||
predicate: values.predicate,
|
||||
object_entity_id:
|
||||
values.object_kind === "entity" ? values.object_entity_id : null,
|
||||
object_value:
|
||||
values.object_kind === "value" ? values.object_value : undefined,
|
||||
confidence: values.confidence,
|
||||
});
|
||||
toast.success(t("editor.claimAdded", "클레임이 추가되었습니다"));
|
||||
claimForm.reset({
|
||||
...claimForm.getValues(),
|
||||
object_entity_id: null,
|
||||
object_value: "",
|
||||
});
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
t("editor.claimAddFailed", "추가 실패: {{msg}}", {
|
||||
msg: (e as Error).message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Bulk JSON form ────────────────────────────────────────────
|
||||
const [bulkText, setBulkText] = useState(
|
||||
JSON.stringify(
|
||||
{
|
||||
entities: [
|
||||
{ entity_type: "Entity", name: "Example A" },
|
||||
{ entity_type: "Entity", name: "Example B" },
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
const [bulkError, setBulkError] = useState<string | null>(null);
|
||||
|
||||
const onBulkSubmit = async () => {
|
||||
setBulkError(null);
|
||||
try {
|
||||
const parsed = JSON.parse(bulkText);
|
||||
const list = Array.isArray(parsed.entities)
|
||||
? parsed.entities
|
||||
: Array.isArray(parsed)
|
||||
? parsed
|
||||
: null;
|
||||
if (!list || list.length === 0) {
|
||||
throw new Error("최상위에 entities 배열이 있어야 합니다");
|
||||
}
|
||||
const normalized = list.map((item: Record<string, unknown>, idx: number) => {
|
||||
if (
|
||||
typeof item !== "object" ||
|
||||
item === null ||
|
||||
typeof item.entity_type !== "string" ||
|
||||
typeof item.name !== "string"
|
||||
) {
|
||||
throw new Error(
|
||||
`항목 [${idx}]에 entity_type/name 문자열이 필요합니다`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
entity_type: item.entity_type,
|
||||
name: item.name,
|
||||
metadata:
|
||||
typeof item.metadata === "object" && item.metadata !== null
|
||||
? (item.metadata as Record<string, unknown>)
|
||||
: {},
|
||||
};
|
||||
});
|
||||
const res = await bulkCreate.mutateAsync({ entities: normalized });
|
||||
toast.success(
|
||||
t("editor.bulkAdded", "{{count}}개 엔티티가 추가되었습니다", {
|
||||
count: res.created,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
setBulkError((e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-6 py-10">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(`/sources/${projectName}`)}
|
||||
aria-label={t("common.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" />
|
||||
{t("editor.title", "온톨로지 직접 편집")}
|
||||
</h1>
|
||||
{project && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project.name}
|
||||
<span className="capitalize text-muted-foreground/70">
|
||||
{" "}
|
||||
· {project.domain}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<Card className="mb-6 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>
|
||||
)}
|
||||
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as typeof tab)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="entities">
|
||||
{t("editor.entitiesTab", "엔티티")} ({entities.data?.length ?? 0})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="claims">
|
||||
<Link2 className="mr-1 h-4 w-4" />
|
||||
{t("editor.claimsTab", "클레임")} ({claims.data?.length ?? 0})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="bulk">
|
||||
<FileJson className="mr-1 h-4 w-4" />
|
||||
{t("editor.bulkTab", "JSON 일괄 입력")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ── Entities Tab ─────────────────────────────────────── */}
|
||||
<TabsContent value="entities">
|
||||
<div className="grid gap-6 lg:grid-cols-[380px_1fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("editor.addEntity", "엔티티 추가")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"editor.addEntityDesc",
|
||||
"온톨로지 도메인의 entity_types 중에서 선택",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
onSubmit={entityForm.handleSubmit(onCreateEntity)}
|
||||
className="space-y-3"
|
||||
noValidate
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("editor.entityType", "타입")}</Label>
|
||||
<Select {...entityForm.register("entity_type")}>
|
||||
<option value="">
|
||||
{t("editor.pickType", "타입 선택...")}
|
||||
</option>
|
||||
{entityTypeOptions.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
{entityForm.formState.errors.entity_type && (
|
||||
<p className="text-xs text-destructive">
|
||||
{entityForm.formState.errors.entity_type.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("editor.entityName", "이름")}</Label>
|
||||
<Input
|
||||
placeholder="Chanel No.5"
|
||||
{...entityForm.register("name")}
|
||||
/>
|
||||
{entityForm.formState.errors.name && (
|
||||
<p className="text-xs text-destructive">
|
||||
{entityForm.formState.errors.name.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={createEntity.isPending}
|
||||
>
|
||||
{createEntity.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" />
|
||||
)}
|
||||
{t("editor.add", "추가")}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{t("editor.entitiesList", "엔티티 목록")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{entities.data
|
||||
? t("editor.entityCount", "{{count}}개", {
|
||||
count: entities.data.length,
|
||||
})
|
||||
: t("dashboard.loadFailed", "")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{entities.isLoading && (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{entities.data && entities.data.length === 0 && (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
{t("editor.entitiesEmpty", "아직 등록된 엔티티가 없습니다")}
|
||||
</p>
|
||||
)}
|
||||
{entities.data && entities.data.length > 0 && (
|
||||
<ul className="max-h-[600px] divide-y overflow-y-auto">
|
||||
{entities.data.map((e) => (
|
||||
<li
|
||||
key={e.id}
|
||||
className="flex items-center justify-between gap-3 py-2 text-sm"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{e.type}</Badge>
|
||||
<span className="truncate font-medium">
|
||||
{e.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
t(
|
||||
"editor.confirmDeleteEntity",
|
||||
"엔티티 '{{name}}'을 삭제하시겠습니까?",
|
||||
{ name: e.name },
|
||||
),
|
||||
)
|
||||
) {
|
||||
deleteEntity.mutate(e.id);
|
||||
}
|
||||
}}
|
||||
disabled={deleteEntity.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Claims Tab ───────────────────────────────────────── */}
|
||||
<TabsContent value="claims">
|
||||
<div className="grid gap-6 lg:grid-cols-[420px_1fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("editor.addClaim", "클레임 추가")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"editor.addClaimDesc",
|
||||
"주어-술어-목적어 형태로 직접 입력",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
onSubmit={claimForm.handleSubmit(onCreateClaim)}
|
||||
className="space-y-3"
|
||||
noValidate
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("editor.source", "소스")}</Label>
|
||||
<Select {...claimForm.register("source_name")}>
|
||||
<option value="">
|
||||
{t("editor.pickSource", "소스 선택...")}
|
||||
</option>
|
||||
{sources.map((s) => (
|
||||
<option key={s.id} value={s.name}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
{claimForm.formState.errors.source_name && (
|
||||
<p className="text-xs text-destructive">
|
||||
{claimForm.formState.errors.source_name.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("editor.subject", "주어 (Subject)")}</Label>
|
||||
<Select
|
||||
{...claimForm.register("subject_entity_id", {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
>
|
||||
<option value={0}>
|
||||
{t("editor.pickSubject", "엔티티 선택...")}
|
||||
</option>
|
||||
{entities.data?.map((e) => (
|
||||
<option key={e.id} value={e.id}>
|
||||
[{e.type}] {e.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
{claimForm.formState.errors.subject_entity_id && (
|
||||
<p className="text-xs text-destructive">
|
||||
{claimForm.formState.errors.subject_entity_id.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("editor.predicate", "술어 (Predicate)")}</Label>
|
||||
<Select {...claimForm.register("predicate")}>
|
||||
<option value="">
|
||||
{t("editor.pickPredicate", "술어 선택...")}
|
||||
</option>
|
||||
{predicateOptions.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("editor.objectKind", "목적어 유형")}</Label>
|
||||
<Select {...claimForm.register("object_kind")}>
|
||||
<option value="entity">
|
||||
{t("editor.objectEntity", "다른 엔티티")}
|
||||
</option>
|
||||
<option value="value">
|
||||
{t("editor.objectValue", "리터럴 값")}
|
||||
</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{objectKind === "entity" ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("editor.objectEntity", "목적 엔티티")}</Label>
|
||||
<Select
|
||||
{...claimForm.register("object_entity_id", {
|
||||
valueAsNumber: true,
|
||||
setValueAs: (v) =>
|
||||
v === "" || v === null || v === undefined
|
||||
? null
|
||||
: Number(v),
|
||||
})}
|
||||
>
|
||||
<option value="">
|
||||
{t("editor.pickObject", "엔티티 선택...")}
|
||||
</option>
|
||||
{entities.data?.map((e) => (
|
||||
<option key={e.id} value={e.id}>
|
||||
[{e.type}] {e.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("editor.objectValue", "값")}</Label>
|
||||
<Input
|
||||
placeholder="2024-01-15 또는 임의 문자열"
|
||||
{...claimForm.register("object_value")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("editor.confidence", "신뢰도")}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.05"
|
||||
min={0}
|
||||
max={1}
|
||||
{...claimForm.register("confidence", {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={createClaim.isPending}
|
||||
>
|
||||
{createClaim.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" />
|
||||
)}
|
||||
{t("editor.add", "추가")}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("editor.claimsList", "클레임 목록")}</CardTitle>
|
||||
<CardDescription>
|
||||
{claims.data &&
|
||||
t("editor.claimCount", "{{count}}개", {
|
||||
count: claims.data.length,
|
||||
})}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{claims.isLoading && (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{claims.data && claims.data.length === 0 && (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
{t("editor.claimsEmpty", "아직 등록된 클레임이 없습니다")}
|
||||
</p>
|
||||
)}
|
||||
{claims.data && claims.data.length > 0 && (
|
||||
<ul className="max-h-[600px] divide-y overflow-y-auto">
|
||||
{claims.data.map((c) => (
|
||||
<li
|
||||
key={c.id}
|
||||
className="flex items-start justify-between gap-3 py-3 text-sm"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="font-medium">
|
||||
{c.subject ?? "?"}
|
||||
</span>
|
||||
<Badge variant="secondary">{c.predicate}</Badge>
|
||||
<span className="font-medium">
|
||||
{c.object ?? String(c.object_value ?? "—")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t("editor.source", "소스")}: {c.source ?? "—"}
|
||||
</span>
|
||||
{typeof c.confidence === "number" && (
|
||||
<span>
|
||||
{t("editor.confidence", "신뢰도")}:{" "}
|
||||
{c.confidence.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
{c.status && (
|
||||
<Badge variant="outline">{c.status}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
t(
|
||||
"editor.confirmDeleteClaim",
|
||||
"클레임을 삭제하시겠습니까?",
|
||||
),
|
||||
)
|
||||
) {
|
||||
deleteClaim.mutate(c.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Bulk Tab ─────────────────────────────────────────── */}
|
||||
<TabsContent value="bulk">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("editor.bulkTitle", "JSON 일괄 입력")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"editor.bulkDesc",
|
||||
"{ entities: [{ entity_type, name, metadata? }] } 형태의 JSON",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Textarea
|
||||
value={bulkText}
|
||||
onChange={(e) => setBulkText(e.target.value)}
|
||||
rows={12}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{bulkError && (
|
||||
<p className="text-sm text-destructive">{bulkError}</p>
|
||||
)}
|
||||
<Button
|
||||
onClick={onBulkSubmit}
|
||||
disabled={bulkCreate.isPending || projectLoading}
|
||||
>
|
||||
{bulkCreate.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<FileJson className="h-4 w-4" />
|
||||
)}
|
||||
{t("editor.bulkSubmit", "일괄 추가")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
333
ontology_platform/web/frontend/src/pages/PageAnalysisPage.tsx
Normal file
333
ontology_platform/web/frontend/src/pages/PageAnalysisPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
526
ontology_platform/web/frontend/src/pages/ResearchPage.tsx
Normal file
526
ontology_platform/web/frontend/src/pages/ResearchPage.tsx
Normal file
@@ -0,0 +1,526 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useParams } 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,
|
||||
Brain,
|
||||
CheckCircle2,
|
||||
History,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
Target,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge, BadgeProps } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useProject } from "@/hooks/useProjects";
|
||||
import {
|
||||
useResearchSessions,
|
||||
useStartResearch,
|
||||
} from "@/hooks/useResearch";
|
||||
import { ResearchRunResult } from "@/lib/api/research";
|
||||
|
||||
const startResearchSchema = z.object({
|
||||
source_name: z.string().min(1, "소스를 선택하세요"),
|
||||
url: z
|
||||
.string()
|
||||
.url("올바른 URL 형식이 아닙니다")
|
||||
.or(z.literal(""))
|
||||
.optional(),
|
||||
goal: z
|
||||
.string()
|
||||
.min(3, "최소 3자")
|
||||
.max(500, "최대 500자"),
|
||||
max_depth: z.number().int().min(0).max(10),
|
||||
max_steps: z.number().int().min(1).max(50),
|
||||
max_branch: z.number().int().min(1).max(30),
|
||||
min_relevance: z.number().min(0).max(1),
|
||||
same_domain_only: z.boolean(),
|
||||
});
|
||||
|
||||
type StartResearchFormValues = z.infer<typeof startResearchSchema>;
|
||||
|
||||
function sessionStatusVariant(status?: string | null): BadgeProps["variant"] {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "success";
|
||||
case "failed":
|
||||
return "destructive";
|
||||
case "canceled":
|
||||
return "secondary";
|
||||
case "running":
|
||||
case "pending":
|
||||
return "default";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
}
|
||||
|
||||
export default function ResearchPage() {
|
||||
const navigate = useNavigate();
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const { t } = useTranslation();
|
||||
const projectName = projectId ?? "";
|
||||
|
||||
const { data: project, isLoading: projectLoading, isError, error, refetch } =
|
||||
useProject(projectName);
|
||||
const sessions = useResearchSessions(projectName);
|
||||
const startResearch = useStartResearch(projectName);
|
||||
|
||||
const [lastResult, setLastResult] = useState<ResearchRunResult | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<StartResearchFormValues>({
|
||||
resolver: zodResolver(startResearchSchema),
|
||||
defaultValues: {
|
||||
source_name: "",
|
||||
url: "",
|
||||
goal: "Semantic ontology exploration",
|
||||
max_depth: 2,
|
||||
max_steps: 12,
|
||||
max_branch: 8,
|
||||
min_relevance: 0.35,
|
||||
same_domain_only: true,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (values: StartResearchFormValues) => {
|
||||
try {
|
||||
setLastResult(null);
|
||||
const res = await startResearch.mutateAsync({
|
||||
project_name: projectName,
|
||||
source_name: values.source_name,
|
||||
url: values.url || undefined,
|
||||
goal: values.goal,
|
||||
max_depth: values.max_depth,
|
||||
max_steps: values.max_steps,
|
||||
max_branch: values.max_branch,
|
||||
min_relevance: values.min_relevance,
|
||||
same_domain_only: values.same_domain_only,
|
||||
});
|
||||
setLastResult(res);
|
||||
toast.success(t("research.completed", "자율 연구가 완료되었습니다"));
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
t("research.failed", "실패: {{msg}}", {
|
||||
msg: (e as Error).message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const sources = project?.sources ?? [];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6 py-10">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(`/sources/${projectName}`)}
|
||||
aria-label={t("common.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">
|
||||
<Brain className="h-6 w-6 text-primary" />
|
||||
{t("research.title", "자율 연구")}
|
||||
</h1>
|
||||
{project && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project.name}
|
||||
<span className="capitalize text-muted-foreground/70">
|
||||
{" "}
|
||||
· {project.domain}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<Card className="mb-6 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="grid gap-6 lg:grid-cols-[460px_1fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Target className="h-5 w-5" />
|
||||
{t("research.formTitle", "자율 연구 설정")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"research.formDesc",
|
||||
"AI가 시드에서 시작해 스스로 링크를 따라가며 온톨로지를 확장합니다",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
noValidate
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="source_name">
|
||||
{t("research.source", "참고 소스")}
|
||||
</Label>
|
||||
{projectLoading ? (
|
||||
<Skeleton className="h-10" />
|
||||
) : (
|
||||
<Select
|
||||
id="source_name"
|
||||
{...register("source_name")}
|
||||
onChange={(e) =>
|
||||
setValue("source_name", e.target.value, {
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
{t("research.pickSource", "소스를 선택하세요...")}
|
||||
</option>
|
||||
{sources.map((s) => (
|
||||
<option key={s.id} value={s.name}>
|
||||
{s.name} ({s.type})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
{errors.source_name && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.source_name.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="goal">{t("research.goal", "목표")}</Label>
|
||||
<Textarea
|
||||
id="goal"
|
||||
rows={2}
|
||||
placeholder={t(
|
||||
"research.goalPlaceholder",
|
||||
"예: 인기 브랜드 향수의 노트 구성과 시즌 추천 정보 수집",
|
||||
)}
|
||||
{...register("goal")}
|
||||
/>
|
||||
{errors.goal && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.goal.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="url">
|
||||
{t("research.seedUrl", "시드 URL")}{" "}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({t("research.optional", "선택")})
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="url"
|
||||
placeholder="https://example.com/start"
|
||||
{...register("url")}
|
||||
/>
|
||||
{errors.url && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.url.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="max_steps">
|
||||
{t("research.maxSteps", "최대 단계")}
|
||||
</Label>
|
||||
<Input
|
||||
id="max_steps"
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
{...register("max_steps", { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.max_steps && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.max_steps.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="max_branch">
|
||||
{t("research.maxBranch", "분기 폭")}
|
||||
</Label>
|
||||
<Input
|
||||
id="max_branch"
|
||||
type="number"
|
||||
min={1}
|
||||
max={30}
|
||||
{...register("max_branch", { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.max_branch && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.max_branch.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="max_depth">
|
||||
{t("research.maxDepth", "최대 깊이")}
|
||||
</Label>
|
||||
<Input
|
||||
id="max_depth"
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
{...register("max_depth", { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="min_relevance">
|
||||
{t("research.minRelevance", "최소 관련도")}
|
||||
</Label>
|
||||
<Input
|
||||
id="min_relevance"
|
||||
type="number"
|
||||
step="0.05"
|
||||
min={0}
|
||||
max={1}
|
||||
{...register("min_relevance", { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-input"
|
||||
{...register("same_domain_only")}
|
||||
/>
|
||||
<span>
|
||||
{t("research.sameDomainOnly", "동일 도메인만 탐색")}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isSubmitting || startResearch.isPending}
|
||||
>
|
||||
{startResearch.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4" />
|
||||
)}
|
||||
{t("research.start", "자율 연구 시작")}
|
||||
</Button>
|
||||
{startResearch.isPending && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"research.runningHint",
|
||||
"장시간 걸릴 수 있습니다. 완료될 때까지 페이지를 닫지 마세요.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("research.resultTitle", "최근 결과")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"research.resultDesc",
|
||||
"이번 세션에서 실행된 연구의 결과",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!lastResult && !startResearch.isPending && (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t(
|
||||
"research.idleHint",
|
||||
"왼쪽에서 자율 연구를 시작하면 결과가 여기에 표시됩니다",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{startResearch.isPending && (
|
||||
<div className="flex flex-col items-center gap-3 py-8 text-center text-muted-foreground">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p>{t("research.runningTitle", "AI가 연구 중입니다...")}</p>
|
||||
</div>
|
||||
)}
|
||||
{lastResult && !startResearch.isPending && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm text-green-700">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
{t("research.doneHint", "완료")}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
|
||||
<Stat
|
||||
label={t("research.stepsTaken", "단계")}
|
||||
value={lastResult.steps_taken}
|
||||
/>
|
||||
<Stat
|
||||
label={t("research.pagesVisited", "페이지")}
|
||||
value={lastResult.pages_visited}
|
||||
/>
|
||||
<Stat
|
||||
label={t("research.entitiesFound", "엔티티")}
|
||||
value={lastResult.entities_found}
|
||||
/>
|
||||
<Stat
|
||||
label={t("research.claimsAdded", "클레임")}
|
||||
value={lastResult.claims_added}
|
||||
/>
|
||||
</div>
|
||||
{lastResult.error && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
<span>{lastResult.error}</span>
|
||||
</div>
|
||||
)}
|
||||
<details className="rounded-md border bg-background">
|
||||
<summary className="cursor-pointer px-3 py-2 text-sm font-medium">
|
||||
{t("research.rawResult", "원시 응답 JSON")}
|
||||
</summary>
|
||||
<pre className="max-h-64 overflow-auto px-4 py-2 text-xs text-muted-foreground">
|
||||
{JSON.stringify(lastResult, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<History className="h-5 w-5" />
|
||||
{t("research.historyTitle", "세션 이력")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"research.historyDesc",
|
||||
"이 프로젝트의 자율 연구 세션 기록",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sessions.isLoading && (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{sessions.data && sessions.data.length === 0 && (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
{t("research.historyEmpty", "아직 실행된 세션이 없습니다")}
|
||||
</p>
|
||||
)}
|
||||
{sessions.data && sessions.data.length > 0 && (
|
||||
<ul className="divide-y">
|
||||
{sessions.data.map((s) => (
|
||||
<li
|
||||
key={s.job_id}
|
||||
className="flex items-center justify-between gap-3 py-3 text-sm"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">#{s.job_id}</span>
|
||||
{s.status && (
|
||||
<Badge variant={sessionStatusVariant(s.status)}>
|
||||
{s.status}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{s.goal && (
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{s.goal}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right text-xs text-muted-foreground">
|
||||
{typeof s.pages_visited === "number" && (
|
||||
<div>
|
||||
{s.pages_visited}{" "}
|
||||
{t("research.pages", "페이지")}
|
||||
</div>
|
||||
)}
|
||||
{s.started_at && (
|
||||
<div>{new Date(s.started_at).toLocaleString()}</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | undefined | null;
|
||||
}) {
|
||||
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 text-xl font-semibold">
|
||||
{typeof value === "number" ? value : "—"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
436
ontology_platform/web/frontend/src/pages/ReviewPage.tsx
Normal file
436
ontology_platform/web/frontend/src/pages/ReviewPage.tsx
Normal file
@@ -0,0 +1,436 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
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<{ 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="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>
|
||||
|
||||
{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">-></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>
|
||||
);
|
||||
}
|
||||
451
ontology_platform/web/frontend/src/pages/SchemaDesignerPage.tsx
Normal file
451
ontology_platform/web/frontend/src/pages/SchemaDesignerPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user