o2o-infinith-demo/docs/REGISTRY_FUNCTIONAL_SPECS.md

285 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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곳의 검증된 마스터 데이터를 3060분 내에 구축한다
**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 수집 (사용자 직접, 3060min)
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.*