fix: gangnamUnni always-try + leadDoctor in Perplexity prompt

- collect-channel-data: gangnamUnni scraping no longer requires
  verified=true. Fallback: Firecrawl search for gangnamunni.com URL
  when discover-channels failed to verify. Solves empty ratings/reviews.
- generate-report: Perplexity prompt now explicitly requests leadDoctor
  (name, specialty, rating, reviewCount) and staffCount in clinicInfo.
- transformReport: clinicInfo type extended with leadDoctor + staffCount;
  transformation prefers clinic.leadDoctor over doctors[0] fallback.

Root cause: clinic_registry table not yet in DB → discover-channels
always falls back to API search → gangnamUnni URL not found →
collect-channel-data skips gangnamUnni → all clinic metrics empty.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
claude/bold-hawking
Haewon Kam 2026-04-07 10:29:10 +09:00
parent 2cda26a649
commit 6e8f6940bf
3 changed files with 47 additions and 12 deletions

View File

@ -12,7 +12,9 @@ interface ApiReport {
address?: string;
phone?: string;
services?: string[];
doctors?: { name: string; specialty: string }[];
doctors?: { name: string; specialty: string; rating?: number; reviews?: number }[];
leadDoctor?: { name: string; specialty: string; rating?: number; reviewCount?: number };
staffCount?: number;
};
newChannelProposals?: { channel?: string; priority?: string; rationale?: string }[];
executiveSummary?: string;
@ -516,7 +518,7 @@ export function transformApiReport(
): MarketingReport {
const r = apiReport;
const clinic = r.clinicInfo || {};
const doctor = clinic.doctors?.[0];
const doctor = clinic.leadDoctor || clinic.doctors?.[0];
return {
id: reportId,
@ -530,12 +532,12 @@ export function transformApiReport(
// 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,
staffCount: typeof clinic.staffCount === 'number' ? clinic.staffCount : (clinic.doctors?.length ?? 0),
leadDoctor: {
name: doctor?.name || '',
credentials: doctor?.specialty || '',
rating: 0,
reviewCount: 0,
rating: doctor?.rating ?? 0,
reviewCount: doctor?.reviewCount ?? doctor?.reviews ?? 0,
},
// 강남언니 is 10-point scale. AI sometimes gives 5-point — auto-correct.
overallRating: (() => {

View File

@ -380,15 +380,48 @@ Deno.serve(async (req) => {
}));
}
// ─── 4. 강남언니 ───
// ─── 4. 강남언니 (항상 시도 — verified 여부 무관) ───
const guVerified = verified.gangnamUnni as Record<string, unknown> | null;
if (FIRECRAWL_API_KEY && guVerified?.verified && guVerified.url) {
if (FIRECRAWL_API_KEY && clinicName) {
channelTasks.push(wrapChannelTask("gangnamUnni", async () => {
let gangnamUnniUrl = (guVerified?.verified && guVerified.url) ? String(guVerified.url) : "";
// Fallback: 강남언니 URL을 Firecrawl 검색으로 직접 찾기
if (!gangnamUnniUrl) {
const shortName = clinicName.replace(/성형외과|의원|병원|클리닉|피부과/g, '').trim();
const searchQueries = [
`${clinicName} site:gangnamunni.com`,
`${shortName} 성형외과 site:gangnamunni.com`,
`${clinicName} 강남언니 병원`,
];
for (const q of searchQueries) {
try {
const sRes = await fetch("https://api.firecrawl.dev/v1/search", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${FIRECRAWL_API_KEY}` },
body: JSON.stringify({ query: q, limit: 5 }),
});
const sData = await sRes.json();
const found = (sData.data || [])
.map((r: Record<string, string>) => r.url)
.find((u: string) => u?.includes('gangnamunni.com/hospitals/'));
if (found) { gangnamUnniUrl = found; break; }
} catch { /* try next query */ }
}
if (gangnamUnniUrl) {
console.log(`[gangnamUnni] Fallback search found: ${gangnamUnniUrl}`);
}
}
if (!gangnamUnniUrl) {
throw new Error("강남언니 URL을 찾을 수 없습니다 (검색 실패)");
}
const scrapeRes = await fetchWithRetry("https://api.firecrawl.dev/v1/scrape", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${FIRECRAWL_API_KEY}` },
body: JSON.stringify({
url: guVerified!.url as string,
url: gangnamUnniUrl,
formats: ["json"],
jsonOptions: {
prompt: "Extract: hospital name, overall rating (강남언니 rating is always out of 10, NOT out of 5), total review count, doctors with names/ratings/review counts/specialties, procedures offered, address, certifications/badges",
@ -412,13 +445,11 @@ Deno.serve(async (req) => {
channelData.gangnamUnni = {
name: hospital.hospitalName,
rawRating: hospital.rating,
// 강남언니 rating is always /10 (enforced in Firecrawl prompt) — trust the value directly.
// Do NOT multiply by 2: a score of 4.8 means 4.8/10, not 9.6/10.
rating: typeof hospital.rating === 'number' && hospital.rating > 0 ? hospital.rating : null,
ratingScale: "/10",
totalReviews: hospital.totalReviews, doctors: (hospital.doctors || []).slice(0, 10),
procedures: hospital.procedures || [], address: hospital.address,
badges: hospital.badges || [], sourceUrl: guVerified!.url as string,
badges: hospital.badges || [], sourceUrl: gangnamUnniUrl,
};
} else {
throw new Error("강남언니 scrape returned no hospital data");

View File

@ -90,7 +90,9 @@ ${JSON.stringify(scrapeData.branding || {}, null, 2).slice(0, 1000)}
"address": "주소",
"phone": "전화번호",
"services": ["시술1", "시술2"],
"doctors": [{"name": "의사명", "specialty": "전문분야"}]
"doctors": [{"name": "의사명", "specialty": "전문분야"}],
"leadDoctor": {"name": "대표원장 이름", "specialty": "전문분야/학력", "rating": 0, "reviewCount": 0},
"staffCount": 0
},
"executiveSummary": "경영진 요약 (3-5문장)",
"overallScore": 0-100,