285 lines
11 KiB
Markdown
285 lines
11 KiB
Markdown
# 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.*
|