Phase 1.1: 프로젝트 생성 — 폼 기반 도메인 선택 + 인라인 JSON 생성
백엔드 (crawler_platform/app/api/routes.py):
- POST /projects/inline 추가: 파일 시스템 의존 없이 JSON 본문으로 ProjectConfig 인라인 생성
- CreateProjectInlineRequest + InlineSourceConfig Pydantic 모델
- GET /domains 추가: 미리 정의된 도메인 목록 (perfume, tea, coffee, candle, supplement, gift)
- 각 도메인의 entity_types, predicates, attribute_count 반환
프론트엔드 API 레이어 (src/lib/api/):
- domains.ts: domainsApi.list + Zod 스키마
- ontology.ts: ontologyApi.get(domain) + Zod 스키마
- projects.ts: createInline() 메서드 + CreateProjectInlineRequest 타입
TanStack Query 훅 (src/hooks/):
- useDomains: 도메인 목록 (30분 staleTime)
- useOntology(domain): 도메인 상세
- useCreateProjectInline: 인라인 생성 mutation + projects 캐시 무효화
- queryKeys에 domains, ontology 키 팩토리 추가
UI 프리미티브 (src/components/ui/):
- input.tsx, label.tsx, textarea.tsx
OnboardingPage 완전 교체 (src/pages/OnboardingPage.tsx):
- react-hook-form + zod 검증 (project_name 정규식, 도메인 필수)
- 도메인 카드 그리드 선택 (entity/predicate 개수 미리보기)
- 백엔드 /projects/inline POST → 성공 시 sonner 토스트 + /sources/{name}으로 이동
- 로딩/에러/재시도 UI
기타:
- Vite proxy에 /domains prefix 추가
- i18n locale 키 onboarding.* 추가 (한/영)
다음 단계: Phase 1.2 — 온톨로지 엔티티/클레임 직접 입력 + URL 추출
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"onboard": "Upload Ontology",
|
||||
"onboard": "New Project",
|
||||
"sources": "Sources",
|
||||
"crawl": "Crawl",
|
||||
"review": "Review",
|
||||
@@ -23,6 +23,18 @@
|
||||
"hint": "Create your first project to start building an ontology"
|
||||
}
|
||||
},
|
||||
"onboarding": {
|
||||
"title": "Create New Project",
|
||||
"formTitle": "Choose Ontology Domain",
|
||||
"formDesc": "Pick the domain of ontology you want to build and give your project a name.",
|
||||
"projectName": "Project Name",
|
||||
"projectNameHint": "Letters, digits, _ and - only (2~64 chars)",
|
||||
"domain": "Domain",
|
||||
"domainSummary": "{{entities}} entity types · {{predicates}} predicates",
|
||||
"submit": "Create Project",
|
||||
"created": "Project created: {{name}}",
|
||||
"createFailed": "Create failed: {{msg}}"
|
||||
},
|
||||
"common": {
|
||||
"retry": "Retry",
|
||||
"cancel": "Cancel",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "대시보드",
|
||||
"onboard": "온톨로지 업로드",
|
||||
"onboard": "프로젝트 생성",
|
||||
"sources": "참고 소스",
|
||||
"crawl": "크롤 진행",
|
||||
"review": "결과 검토",
|
||||
@@ -23,6 +23,18 @@
|
||||
"hint": "첫 프로젝트를 만들어 온톨로지 구축을 시작하세요"
|
||||
}
|
||||
},
|
||||
"onboarding": {
|
||||
"title": "새 프로젝트 만들기",
|
||||
"formTitle": "온톨로지 도메인 선택",
|
||||
"formDesc": "어떤 종류의 온톨로지를 구축할지 도메인을 선택하고 프로젝트 이름을 정해주세요.",
|
||||
"projectName": "프로젝트 이름",
|
||||
"projectNameHint": "영문, 숫자, _ , - 만 사용 (2~64자)",
|
||||
"domain": "도메인",
|
||||
"domainSummary": "엔티티 {{entities}}종 · 관계 {{predicates}}개",
|
||||
"submit": "프로젝트 만들기",
|
||||
"created": "프로젝트가 생성되었습니다: {{name}}",
|
||||
"createFailed": "생성 실패: {{msg}}"
|
||||
},
|
||||
"common": {
|
||||
"retry": "다시 시도",
|
||||
"cancel": "취소",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type = "text", ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const Label = React.forwardRef<
|
||||
HTMLLabelElement,
|
||||
React.LabelHTMLAttributes<HTMLLabelElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = "Label";
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||
|
||||
export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Textarea.displayName = "Textarea";
|
||||
@@ -5,4 +5,13 @@ export const queryKeys = {
|
||||
detail: (name: string) =>
|
||||
[...queryKeys.projects.all, "detail", name] as const,
|
||||
},
|
||||
domains: {
|
||||
all: ["domains"] as const,
|
||||
list: () => [...queryKeys.domains.all, "list"] as const,
|
||||
},
|
||||
ontology: {
|
||||
all: ["ontology"] as const,
|
||||
byDomain: (domain: string) =>
|
||||
[...queryKeys.ontology.all, "byDomain", domain] as const,
|
||||
},
|
||||
};
|
||||
|
||||
21
crawler_platform/app/web/frontend/src/hooks/useDomains.ts
Normal file
21
crawler_platform/app/web/frontend/src/hooks/useDomains.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { domainsApi, DomainSummary } from "@/lib/api/domains";
|
||||
import { ontologyApi, OntologyDetail } from "@/lib/api/ontology";
|
||||
import { queryKeys } from "./queryKeys";
|
||||
|
||||
export function useDomains() {
|
||||
return useQuery<DomainSummary[]>({
|
||||
queryKey: queryKeys.domains.list(),
|
||||
queryFn: () => domainsApi.list(),
|
||||
staleTime: 1000 * 60 * 30,
|
||||
});
|
||||
}
|
||||
|
||||
export function useOntology(domain: string | undefined) {
|
||||
return useQuery<OntologyDetail>({
|
||||
queryKey: queryKeys.ontology.byDomain(domain ?? ""),
|
||||
queryFn: () => ontologyApi.get(domain!),
|
||||
enabled: Boolean(domain),
|
||||
staleTime: 1000 * 60 * 30,
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
CreateProjectInlineRequest,
|
||||
CreateProjectRequest,
|
||||
projectsApi,
|
||||
ProjectSummary,
|
||||
@@ -31,3 +32,14 @@ export function useCreateProject() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateProjectInline() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: CreateProjectInlineRequest) =>
|
||||
projectsApi.createInline(body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
17
crawler_platform/app/web/frontend/src/lib/api/domains.ts
Normal file
17
crawler_platform/app/web/frontend/src/lib/api/domains.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { z } from "zod";
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export const domainSummarySchema = z.object({
|
||||
domain: z.string(),
|
||||
entity_types: z.array(z.string()),
|
||||
predicates: z.array(z.string()),
|
||||
attribute_count: z.number().int(),
|
||||
});
|
||||
|
||||
export const domainListSchema = z.array(domainSummarySchema);
|
||||
|
||||
export type DomainSummary = z.infer<typeof domainSummarySchema>;
|
||||
|
||||
export const domainsApi = {
|
||||
list: () => apiClient.get("/domains", domainListSchema),
|
||||
};
|
||||
@@ -1,2 +1,4 @@
|
||||
export { apiClient, ApiError } from "./client";
|
||||
export * from "./projects";
|
||||
export * from "./domains";
|
||||
export * from "./ontology";
|
||||
|
||||
20
crawler_platform/app/web/frontend/src/lib/api/ontology.ts
Normal file
20
crawler_platform/app/web/frontend/src/lib/api/ontology.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { z } from "zod";
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export const ontologyDetailSchema = z.object({
|
||||
domain: z.string(),
|
||||
entity_types: z.array(z.string()),
|
||||
predicates: z.array(z.string()),
|
||||
attributes: z.array(z.string()),
|
||||
aliases: z.record(z.string(), z.string()).default({}),
|
||||
});
|
||||
|
||||
export type OntologyDetail = z.infer<typeof ontologyDetailSchema>;
|
||||
|
||||
export const ontologyApi = {
|
||||
get: (domain: string) =>
|
||||
apiClient.get(
|
||||
`/ontology/${encodeURIComponent(domain)}`,
|
||||
ontologyDetailSchema,
|
||||
),
|
||||
};
|
||||
@@ -42,6 +42,29 @@ export interface CreateProjectRequest {
|
||||
config_path: string;
|
||||
}
|
||||
|
||||
export interface InlineSource {
|
||||
name: string;
|
||||
type?: string;
|
||||
trust_level?: number;
|
||||
base_url?: string | null;
|
||||
allowed_paths?: string[];
|
||||
parser?: string;
|
||||
fetcher?: string;
|
||||
rate_limit_per_minute?: number;
|
||||
respect_robots_txt?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateProjectInlineRequest {
|
||||
project_name: string;
|
||||
domain: string;
|
||||
target_entities?: string[];
|
||||
fields?: string[];
|
||||
sources?: InlineSource[];
|
||||
ontology?: Record<string, unknown>;
|
||||
recommendation?: Record<string, unknown>;
|
||||
update_policy?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const projectsApi = {
|
||||
list: () => apiClient.get("/projects", projectListSchema),
|
||||
detail: (projectName: string) =>
|
||||
@@ -51,4 +74,6 @@ export const projectsApi = {
|
||||
),
|
||||
create: (body: CreateProjectRequest) =>
|
||||
apiClient.post("/projects", createProjectResponseSchema, body),
|
||||
createInline: (body: CreateProjectInlineRequest) =>
|
||||
apiClient.post("/projects/inline", createProjectResponseSchema, body),
|
||||
};
|
||||
|
||||
@@ -1,41 +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="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl p-8 max-w-2xl w-full">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
{t("Upload Ontology")}
|
||||
<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>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{t("Step 1 of 4: Upload your domain ontology (YAML, JSON, or OWL)")}
|
||||
</p>
|
||||
|
||||
<div className="border-2 border-dashed border-blue-300 rounded-lg p-8 text-center bg-blue-50 mb-6">
|
||||
<p className="text-gray-600">
|
||||
{t("Drag and drop your ontology file here, or click to browse")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => navigate("/")}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition"
|
||||
>
|
||||
{t("Cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate("/sources/demo-project")}
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
|
||||
>
|
||||
{t("Next")}
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ const PROXY_PREFIXES = [
|
||||
"/projects",
|
||||
"/ontology",
|
||||
"/ontologies",
|
||||
"/domains",
|
||||
"/extractors",
|
||||
"/crawl",
|
||||
"/crawl-site",
|
||||
|
||||
Reference in New Issue
Block a user