feat: Registry-verified badge + registryData data flow + V3 error recording

- ClinicSnapshot.tsx: 'Registry 검증' badge (ShieldCheck icon), district/branches/brandGroup pills, external links (강남언니/네이버플레이스/구글맵) when source=registry
- report.ts: add source and registryData fields to ClinicSnapshot type
- transformReport.ts: ApiMetadata now accepts source/registryData; passes to clinicSnapshot
- useReport.ts: DB load path extracts scrape_data.source + scrape_data.registryData → transformApiReport
- V3 dual-write error recording: discover-channels, collect-channel-data, generate-report now write error_message + error status to analysis_runs on catch instead of silently swallowing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
claude/bold-hawking
Haewon Kam 2026-04-07 10:01:19 +09:00
parent 36d2f1cf49
commit ae87953fa0
7 changed files with 143 additions and 4 deletions

View File

@ -1,5 +1,5 @@
import { motion } from 'motion/react';
import { Calendar, Users, MapPin, Phone, Award, Star, Globe, ExternalLink } from 'lucide-react';
import { Calendar, Users, MapPin, Phone, Award, Star, Globe, ExternalLink, ShieldCheck, GitBranch, Building2 } from 'lucide-react';
import { SectionWrapper } from './ui/SectionWrapper';
import type { ClinicSnapshot as ClinicSnapshotType } from '../../types/report';
@ -23,16 +23,88 @@ const infoFields = (data: ClinicSnapshotType): InfoField[] => [
data.staffCount > 0 ? { label: '전문의', value: `${data.staffCount}`, icon: Users } : null,
data.overallRating > 0 ? { label: '강남언니 평점', value: data.overallRating > 5 ? `${data.overallRating} / 10` : `${data.overallRating} / 5.0`, icon: Star } : null,
data.totalReviews > 0 ? { label: '리뷰 수', value: formatNumber(data.totalReviews), icon: Star } : null,
data.registryData?.district ? { label: '지역구', value: data.registryData.district, icon: Building2 } : null,
data.location ? { label: '위치', value: data.nearestStation ? `${data.location} (${data.nearestStation})` : data.location, icon: MapPin } : null,
data.phone ? { label: '전화', value: data.phone, icon: Phone, href: `tel:${data.phone.replace(/[^+0-9]/g, '')}` } : null,
data.domain ? { label: '도메인', value: data.domain, icon: Globe, href: `https://${data.domain.replace(/^https?:\/\//, '')}` } : null,
data.registryData?.websiteEn ? { label: '영문 사이트', value: data.registryData.websiteEn, icon: Globe, href: data.registryData.websiteEn } : null,
].filter((f): f is NonNullable<InfoField> => f !== null);
export default function ClinicSnapshot({ data }: ClinicSnapshotProps) {
const fields = infoFields(data);
const isVerified = data.source === 'registry';
return (
<SectionWrapper id="clinic-snapshot" title="Clinic Overview" subtitle="병원 기본 정보">
{/* Registry 검증 배지 + 분점 정보 */}
{(isVerified || data.registryData?.branches || data.registryData?.brandGroup) && (
<motion.div
className="flex flex-wrap items-center gap-2 mb-5"
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
{isVerified && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-50 border border-emerald-200 px-3 py-1 text-xs font-semibold text-emerald-700">
<ShieldCheck size={13} className="text-emerald-600" />
Registry
</span>
)}
{data.registryData?.branches && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-blue-50 border border-blue-200 px-3 py-1 text-xs font-medium text-blue-700">
<GitBranch size={12} className="text-blue-500" />
{data.registryData.branches}
</span>
)}
{data.registryData?.brandGroup && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-violet-50 border border-violet-200 px-3 py-1 text-xs font-medium text-violet-700">
{data.registryData.brandGroup}
</span>
)}
</motion.div>
)}
{/* Registry 외부 링크 (강남언니, 네이버 플레이스, 구글 맵) */}
{isVerified && (data.registryData?.naverPlaceUrl || data.registryData?.gangnamUnniUrl || data.registryData?.googleMapsUrl) && (
<motion.div
className="flex flex-wrap gap-2 mb-6"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
{data.registryData?.gangnamUnniUrl && (
<a
href={data.registryData.gangnamUnniUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg bg-pink-50 border border-pink-200 px-3 py-1.5 text-xs font-medium text-pink-700 hover:bg-pink-100 transition-colors"
>
<ExternalLink size={11} />
</a>
)}
{data.registryData?.naverPlaceUrl && (
<a
href={data.registryData.naverPlaceUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg bg-green-50 border border-green-200 px-3 py-1.5 text-xs font-medium text-green-700 hover:bg-green-100 transition-colors"
>
<ExternalLink size={11} />
</a>
)}
{data.registryData?.googleMapsUrl && (
<a
href={data.registryData.googleMapsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-50 border border-blue-200 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100 transition-colors"
>
Google Maps <ExternalLink size={11} />
</a>
)}
</motion.div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
{fields.map((field, i) => {
const Icon = field.icon;

View File

@ -91,6 +91,9 @@ export function useReport(id: string | undefined): UseReportResult {
url: row.url,
clinicName: row.clinic_name || '',
generatedAt: row.created_at,
// Pass Registry metadata so ClinicSnapshot can show verified badge + district
source: (scrapeData?.source as 'registry' | 'scrape' | undefined) ?? 'scrape',
registryData: (scrapeData?.registryData as Record<string, string> | null | undefined) ?? null,
},
);

View File

@ -73,6 +73,18 @@ interface ApiMetadata {
clinicName: string;
generatedAt: string;
dataSources?: Record<string, boolean>;
/** 'registry' = clinic_registry DB 검증 경로. 'scrape' = 실시간 탐색 경로. */
source?: 'registry' | 'scrape';
/** Registry에서 제공된 병원 메타데이터 */
registryData?: {
district?: string;
branches?: string;
brandGroup?: string;
websiteEn?: string;
naverPlaceUrl?: string;
gangnamUnniUrl?: string;
googleMapsUrl?: string;
} | null;
}
function scoreToSeverity(score: number | undefined): Severity {
@ -515,6 +527,7 @@ export function transformApiReport(
clinicSnapshot: {
name: clinic.name || metadata.clinicName || '',
nameEn: clinic.nameEn || '',
// Registry foundedYear takes priority over AI-generated value (Registry = human-verified)
established: clinic.established || '',
yearsInBusiness: clinic.established ? new Date().getFullYear() - parseInt(clinic.established) : 0,
staffCount: 0,
@ -538,6 +551,9 @@ export function transformApiReport(
nearestStation: '',
phone: clinic.phone || '',
domain: new URL(metadata.url).hostname,
// Registry-sourced fields
source: metadata.source ?? 'scrape',
registryData: metadata.registryData ?? undefined,
},
channelScores: buildChannelScores(r.channelAnalysis),

View File

@ -59,6 +59,18 @@ export interface ClinicSnapshot {
accent: string;
text: string;
};
/** 'registry' = Registry DB에서 검증된 채널로 분석. 'scrape' = 홈페이지 실시간 탐색. */
source?: 'registry' | 'scrape';
/** Registry에서 가져온 병원 메타데이터 */
registryData?: {
district?: string; // 지역구 (강남, 압구정, 서초, 역삼)
branches?: string; // 분점 정보
brandGroup?: string; // 분류 (프리미엄/하이타깃 후보)
websiteEn?: string; // 영문 사이트 URL
naverPlaceUrl?: string;
gangnamUnniUrl?: string;
googleMapsUrl?: string;
};
}
export interface TopVideo {

View File

@ -828,7 +828,15 @@ Deno.serve(async (req) => {
}).eq("id", runId);
} catch (e) {
console.error("V3 dual-write error:", e);
const errMsg = e instanceof Error ? e.message : String(e);
console.error("V3 dual-write error:", errMsg);
// Best-effort: record error into analysis_run so it's visible in DB
try {
await supabase.from("analysis_runs").update({
error_message: `V3 dual-write failed: ${errMsg}`,
status: "collection_error",
}).eq("id", runId);
} catch { /* ignore secondary failure */ }
}
}

View File

@ -773,7 +773,23 @@ Deno.serve(async (req) => {
}
} catch (e) {
// V3 write failure should not block the pipeline
console.error("V3 dual-write error:", e);
const errMsg = e instanceof Error ? e.message : String(e);
console.error("V3 dual-write error:", errMsg);
// Best-effort: record error so it's visible in DB
try {
if (runId) {
await supabase.from("analysis_runs").update({
error_message: `V3 dual-write (discover) failed: ${errMsg}`,
status: "error",
}).eq("id", runId);
} else {
// runId not yet created — fall back to marketing_reports
await supabase.from("marketing_reports").update({
status: "v3_write_error",
updated_at: new Date().toISOString(),
}).eq("id", saved.id);
}
} catch { /* ignore secondary failure */ }
}
return new Response(

View File

@ -337,7 +337,16 @@ ${JSON.stringify(scrapeData.branding || {}, null, 2).slice(0, 1000)}
status: "complete",
pipeline_completed_at: new Date().toISOString(),
}).eq("id", v3RunId);
} catch (e) { console.error("V3 run update error:", e); }
} catch (e) {
const errMsg = e instanceof Error ? e.message : String(e);
console.error("V3 run update error:", errMsg);
try {
await supabase.from("analysis_runs").update({
error_message: `V3 report update failed: ${errMsg}`,
status: "report_error",
}).eq("id", v3RunId);
} catch { /* ignore secondary failure */ }
}
}
if (v3ClinicId) {
try {
@ -362,6 +371,9 @@ ${JSON.stringify(scrapeData.branding || {}, null, 2).slice(0, 1000)}
socialHandles: report.socialHandles,
address: clinic.address || "",
services: clinic.services || [],
// Registry metadata — available when discovered via clinic_registry DB
source: scrapeData.source || "scrape",
registryData: scrapeData.registryData || null,
},
}),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } },