Files
AI/ontology_platform/web/frontend/src/pages/ResearchPage.tsx

527 lines
18 KiB
TypeScript
Raw Normal View History

Phase 1.4: 자율 연구 — by-project 엔드포인트 + ResearchPage + 세션 이력 백엔드 (crawler_platform/app/api/routes.py): - POST /research/run/by-project 추가 — config_path 의존 제거 - ResearchRunByProjectRequest: project_name 기반, DB project.config 재구성 - GraphResearchLoop 동기 실행 (장시간 가능), asdict(result) 반환 - 기존 /projects/{n}/research/sessions, /research/sessions/{job_id}는 변경 없음 프론트엔드 API (src/lib/api/): - research.ts: researchApi.startByProject/listSessions/getSession + Zod 스키마 - 결과 페이로드는 passthrough() (백엔드 dataclass 그대로 노출) TanStack Query 훅 (src/hooks/): - useResearch.ts: useStartResearch, useResearchSessions, useResearchSession - queryKeys에 research.sessions, research.session 키 ResearchPage 신규 (src/pages/ResearchPage.tsx): - 2열 레이아웃: 좌측 연구 설정 폼 + 우측 결과/이력 - 폼: 소스 선택, goal 텍스트(필수, 3~500자), 시드 URL(선택), max_steps/max_branch/max_depth/min_relevance, same_domain_only react-hook-form + zod 검증 - 결과 카드: 단계/페이지/엔티티/클레임 4종 stats + raw JSON details - 세션 이력 카드: GET /projects/{n}/research/sessions 결과 리스트 - 실행 중 안내 (장시간 가능), sonner 토스트 라우팅 & Sidebar: - App.tsx: /research/:projectId 라우트 추가 - AppShell: 사이드바에 "자율 연구" 메뉴 (Brain 아이콘) i18n: research.*, nav.research 키 (한/영) 다음 단계: Phase 1.5 — 엔티티/클레임 직접 입력 (백엔드 POST /projects/{n}/entities, /claims 신규 필요) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 19:01:52 +09:00
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>
);
}