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:
lasta
2026-05-14 14:46:03 +09:00
parent 37cad40472
commit 8681ac8ed3
15 changed files with 474 additions and 32 deletions

View File

@@ -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",

View File

@@ -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": "취소",

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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,
},
};

View 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,
});
}

View File

@@ -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 });
},
});
}

View 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),
};

View File

@@ -1,2 +1,4 @@
export { apiClient, ApiError } from "./client";
export * from "./projects";
export * from "./domains";
export * from "./ontology";

View 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,
),
};

View File

@@ -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),
};

View File

@@ -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>
);
}

View File

@@ -6,6 +6,7 @@ const PROXY_PREFIXES = [
"/projects",
"/ontology",
"/ontologies",
"/domains",
"/extractors",
"/crawl",
"/crawl-site",