# Clinic Registry — Functional Specifications **Document Type**: Functional Specifications **Version**: 1.0 **Status**: Design Approved — Implementation Hold **Date**: 2026-04-05 **Author**: Claude Code, directed by Haewon Kam --- ## 1. 목적 (Purpose) 잠재 고객사 성형외과의 **사전 검증된 채널 마스터 DB**를 구축하여, INFINITH 분석 파이프라인의 discovery 단계를 **확률적 검색(probabilistic search)**에서 **결정적 조회(deterministic lookup)**로 전환한다. ## 2. Scope ### In-Scope - 국내 성형외과 **잠재 고객사 100곳 (MVP)** → 500곳 확장 - 병원 마스터 정보 (이름, 도메인, 웹사이트, 개원연도, 위치) - 사전 검증된 채널 핸들 (YouTube/Instagram/Facebook/Naver Blog/Naver Place/강남언니/TikTok) - Human-in-the-loop 방식 초기 구축 (Perplexity Comet 활용) - `discover-channels` Edge Function의 Registry-only 전환 - 미등록 도메인에 대한 에러 UX ### Out-of-Scope - 미등록 병원 자동 fallback discovery (명시적으로 제외) - 병원 자가 등록 포털 (추후 Sprint) - 경쟁사 자동 업데이트 추적 - 실시간 채널 메트릭 수집 (별도 `collect-channel-data` Function의 책임) ## 3. 사용자 스토리 (User Stories) ### US-1: 등록된 고객사 분석 **As a** INFINITH 운영자 **I want** 등록된 병원의 URL로 분석을 요청하면 **So that** 10초 이내에 검증된 채널 기반의 정확한 분석 결과를 받는다 **Acceptance Criteria**: - URL의 도메인이 `clinic_registry.domain`과 매칭되면 discovery 단계를 건너뛴다 - 분석 리포트에 "✓ Registry-verified" 배지가 표시된다 - 개원연도, 위치 정보가 자동 포함된다 ### US-2: 미등록 병원 차단 **As a** INFINITH 운영자 **I want** 등록되지 않은 병원 URL 입력 시 명확한 에러를 받고 **So that** 검색 오류가 있는 부정확한 분석을 방지한다 **Acceptance Criteria**: - 미등록 도메인 입력 시 `404 CLINIC_NOT_REGISTERED` 응답 - 사용자에게 "지원하지 않는 병원" 안내 + "등록 요청" CTA 표시 - 에러는 analytics에 기록되어 향후 등록 우선순위에 반영 ### US-3: Registry 데이터 큐레이션 **As a** INFINITH 운영자 **I want** Perplexity Comet으로 병원 정보를 수집하고 CSV로 import하여 **So that** 100곳의 검증된 마스터 데이터를 30–60분 내에 구축한다 **Acceptance Criteria**: - 제공된 CSV 템플릿에 맞는 데이터를 입력할 수 있다 - import 스크립트가 데이터 검증 (필수 필드, 중복 domain 체크) 후 DB 적재 - Import 결과 요약: 성공/실패/경고 카운트 ## 4. 데이터 모델 (Data Model) ### 4.1 Table: `clinic_registry` ```sql CREATE TABLE clinic_registry ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 식별 정보 name text NOT NULL, name_aliases text[] DEFAULT '{}', domain text UNIQUE NOT NULL, website_url text NOT NULL, -- 병원 메타 founded_year int, location_gu text, category text DEFAULT '성형외과', -- 채널 핸들 (사전 검증) youtube_handle text, youtube_channel_id text, instagram_handle text, facebook_handle text, facebook_url text, naver_blog_handle text, naver_place_id text, gangnam_unni_url text, tiktok_handle text, -- 큐레이션 메타 verified_by text NOT NULL CHECK (verified_by IN ('manual','llm','scrape')), verified_at timestamptz DEFAULT now(), last_checked_at timestamptz DEFAULT now(), is_active boolean DEFAULT true, notes text ); CREATE UNIQUE INDEX idx_clinic_registry_domain ON clinic_registry (domain) WHERE is_active = true; CREATE INDEX idx_clinic_registry_aliases ON clinic_registry USING gin (name_aliases); CREATE INDEX idx_clinic_registry_last_checked ON clinic_registry (last_checked_at); ``` ### 4.2 Field Dictionary | Field | Type | Required | Notes | |-------|------|----------|-------| | `name` | text | ✓ | 공식 병원명 (e.g., "아이디병원") | | `name_aliases` | text[] | | 별칭/영문명 (e.g., `{"아이디성형외과","ID Hospital"}`) | | `domain` | text | ✓ | `www.` 제거된 도메인. **Lookup key**. | | `website_url` | text | ✓ | 전체 URL (redirect 대응) | | `founded_year` | int | | 개원연도 (4-digit) | | `location_gu` | text | | "강남구", "서초구" 등 | | `youtube_channel_id` | text | | `UC...` 형식 (24자) | | `naver_place_id` | text | | Naver Place 고유 ID (동명이인 방지) | | `verified_by` | enum | ✓ | `manual` / `llm` / `scrape` | | `is_active` | boolean | | false면 lookup에서 제외 | ## 5. 기능 요구사항 (Functional Requirements) ### FR-1: Registry Lookup - **Input**: `url` (string, full URL) - **Process**: 1. `new URL(url).hostname` 추출 2. `^www\.` prefix 제거 3. `clinic_registry` 테이블에서 `domain = {hostname} AND is_active = true` 조회 - **Output (match)**: `{ clinicName, verifiedChannels, source: 'registry', foundedYear, location }` - **Output (miss)**: `{ error: 'CLINIC_NOT_REGISTERED', domain, status: 404 }` - **Latency**: <100ms (single indexed query) ### FR-2: VerifiedChannels 매핑 `clinic_registry` 레코드를 기존 `VerifiedChannels` 타입으로 변환: ```typescript function mapRegistryToVerifiedChannels(r: ClinicRegistry): VerifiedChannels { return { youtube: r.youtube_channel_id ? { handle: r.youtube_handle, channelId: r.youtube_channel_id, verified: true, url: `https://youtube.com/${r.youtube_handle}` } : null, instagram: r.instagram_handle ? [{ handle: r.instagram_handle, verified: true, url: `https://instagram.com/${r.instagram_handle}` }] : [], facebook: r.facebook_handle ? { handle: r.facebook_handle, verified: true, url: r.facebook_url ?? `https://facebook.com/${r.facebook_handle}` } : null, naverBlog: r.naver_blog_handle ? { handle: r.naver_blog_handle, verified: true, url: `https://blog.naver.com/${r.naver_blog_handle}` } : null, gangnamUnni: r.gangnam_unni_url ? { handle: '', url: r.gangnam_unni_url, verified: true } : null, tiktok: r.tiktok_handle ? { handle: r.tiktok_handle, verified: true, url: `https://tiktok.com/@${r.tiktok_handle}` } : null, }; } ``` ### FR-3: CSV Import - **Input**: CSV 파일 (컬럼: FR-1 field dictionary 참조) - **Validation**: - `name`, `domain`, `website_url`, `verified_by` 필수 - `domain` 중복 검사 (upsert on conflict) - `youtube_channel_id` 형식 체크 (`^UC[a-zA-Z0-9_-]{22}$`) - `founded_year` 범위 체크 (1950–현재) - **Output**: `{ inserted, updated, skipped, errors: [] }` ### FR-4: 미등록 병원 UX - `AnalysisLoadingPage.tsx`에서 `CLINIC_NOT_REGISTERED` 응답 감지 - 표시: 전용 페이지 (로딩 스피너 대신) - 헤드라인: "현재 지원하지 않는 병원입니다" - 본문: "{domain}은(는) 분석 대상 병원 목록에 포함되지 않습니다." - CTA: "병원 등록 요청하기" 버튼 → Slack 알림 또는 간단한 폼 ### FR-5: Registry-verified 배지 - `ClinicSnapshot.tsx`에서 `source === 'registry'`일 때 - 녹색 "✓ 검증된 병원" 배지 - 개원연도 + 위치 표시 (`founded_year` + `location_gu`) ## 6. 비기능 요구사항 (Non-Functional Requirements) | 항목 | 목표 | 근거 | |------|------|------| | Registry lookup 레이턴시 | <100ms | 인덱스된 단일 쿼리 | | Registry 정확도 | 100% | Human-in-the-loop 큐레이션 | | Registry 커버리지 (MVP) | 100곳 | 잠재 고객사 TOP 100 | | CSV import 처리량 | 500행/30초 | 1회성 bulk insert | | 재검증 주기 | 30일 | `last_checked_at` 기반 알림 | ## 7. API 계약 (API Contract) ### POST `/functions/v1/discover-channels` (변경됨) **Request**: ```json { "url": "https://www.idhospital.com", "clinicName": "아이디병원" } ``` **Response 200 (registry hit)**: ```json { "clinicName": "아이디병원", "verifiedChannels": { "youtube": {...}, "instagram": [...], ... }, "source": "registry", "foundedYear": 2005, "location": "강남구" } ``` **Response 404 (registry miss)**: ```json { "error": "CLINIC_NOT_REGISTERED", "message": "현재 지원하지 않는 병원입니다.", "domain": "unknown-clinic.com" } ``` **Breaking Changes**: - 기존 5개 외부 API 호출 전부 제거 - `source` 필드 추가 (항상 `"registry"` 또는 에러) - 미등록 도메인은 404 반환 (이전에는 부정확한 결과라도 200 반환) ## 8. 마이그레이션 전략 (Migration Strategy) ### Phase 0: 문서화 (완료) - Plan / Functional Specs / Retrospective ✓ ### Phase 1: Schema & Template (30min) 1. `supabase/migrations/20260405_clinic_registry.sql` 작성/적용 2. `data/clinic-registry-template.csv` 템플릿 배포 ### Phase 2: Human-in-the-loop 수집 (사용자 직접, 30–60min) 1. 사용자가 Perplexity Comet으로 100곳 정보 수집 2. CSV 템플릿에 기입 3. 수동 QA (명백한 오류 제거) ### Phase 3: Import & Integration (1.5h) 1. `scripts/import-registry.ts` 작성 + 실행 2. `discover-channels/index.ts` Registry-only 전환 3. `AnalysisLoadingPage.tsx` 에러 UX 4. `ClinicSnapshot.tsx` verified 배지 ### Phase 4: 검증 (30min) 1. 등록 도메인 테스트 (아이디병원) → registry hit + 정확한 채널 2. 미등록 도메인 테스트 → 404 + 등록 요청 UX 3. Edge Function 배포 + Vercel 배포 ## 9. 검증 시나리오 (Test Scenarios) | # | 시나리오 | Expected | |---|----------|----------| | T1 | 등록된 `idhospital.com`으로 분석 | `source: registry`, verified 배지, <100ms discovery | | T2 | 등록된 `www.banobagi.com`으로 분석 (`www.` 처리) | Registry hit | | T3 | 미등록 `random-clinic.com`으로 분석 | 404 + 등록 요청 UX | | T4 | `is_active=false`인 병원 분석 | 404 (폐업 병원 차단) | | T5 | 잘못된 CSV (중복 domain) import | Error 반환, insert 차단 | | T6 | Registry 레코드의 Instagram 핸들 수정 후 재분석 | 수정된 핸들로 즉시 분석 | ## 10. 의존성 & 리스크 (Dependencies & Risks) ### Dependencies - Supabase PostgreSQL (기존 인프라) - Perplexity Comet (사용자가 수집에 사용) - 기존 `collect-channel-data` Function (변경 없음) ### Risks | Risk | Mitigation | |------|-----------| | 병원 폐업/리브랜딩 추적 실패 | `last_checked_at` 30일 기반 알림, `is_active` 플래그 | | 채널 핸들 변경 (병원이 Instagram ID 교체) | 수동 업데이트 + 분석 시 404 응답 패턴 모니터링 | | 고객사 확장 시 curation 병목 | Admin UI 추후 개발 (Sprint 6) | | 미등록 URL 입력이 많아 UX 저하 | 등록 요청 CTA + 운영자 알림으로 scope 확장 | ## 11. Open Questions 1. 미등록 병원의 "등록 요청" CTA는 어떤 채널로 알림을 보낼지? (Slack / email / DB) 2. Registry 수정 권한 관리는 어떻게? (현재는 직접 SQL, 추후 Admin UI) 3. 경쟁사(non-고객사)를 비교 분석용으로 registry에 추가할 것인지? --- *This document specifies the Registry-first architecture that replaces probabilistic channel discovery with deterministic lookup over a curated customer scope.*