fix: 파이프라인 3대 핵심 버그 수정

- generate-report: Harness 4 groundTruth 주입 레이어 추가 (IG/YT/FB/NaverBlog/NaverPlace/GangnamUnni 필드 강제 주입, diagnosis 폴백, qualityReport DB 저장)
- discover-channels: CLINIC_NOT_REGISTERED 조기 종료 제거 + clinics 캐시 fast-path 추가 (14일 TTL, Firecrawl fallback 재활성화)
- collect-channel-data: silent skip → {status, reason, attemptedAt} 구조적 기록 (naverBlog/naverPlace/googleMaps/gangnamUnni)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
claude/bold-hawking
Haewon Kam 2026-04-10 13:41:05 +09:00
parent aabba1534b
commit c753d8593f
3 changed files with 398 additions and 36 deletions

View File

@ -414,7 +414,14 @@ Deno.serve(async (req) => {
} }
if (!gangnamUnniUrl) { if (!gangnamUnniUrl) {
throw new Error("강남언니 URL을 찾을 수 없습니다 (검색 실패)"); console.log("[gangnamUnni] URL not found via any search — marking skipped");
channelData.gangnamUnni = {
status: "skipped",
reason: "URL_NOT_FOUND",
reasonDetail: "Firecrawl search across 3 query variants returned no gangnamunni.com/hospitals/ link",
attemptedAt: new Date().toISOString(),
};
return;
} }
const scrapeRes = await fetchWithRetry("https://api.firecrawl.dev/v1/scrape", { const scrapeRes = await fetchWithRetry("https://api.firecrawl.dev/v1/scrape", {
@ -438,11 +445,21 @@ Deno.serve(async (req) => {
waitFor: 5000, waitFor: 5000,
}), }),
}, { label: "firecrawl-gangnamunni", timeoutMs: 60000 }); }, { label: "firecrawl-gangnamunni", timeoutMs: 60000 });
if (!scrapeRes.ok) throw new Error(`Firecrawl 강남언니 scrape failed: ${scrapeRes.status}`); if (!scrapeRes.ok) {
channelData.gangnamUnni = {
status: "skipped",
reason: "SCRAPE_FAILED",
reasonDetail: `Firecrawl returned HTTP ${scrapeRes.status}`,
attemptedAt: new Date().toISOString(),
sourceUrl: gangnamUnniUrl,
};
return;
}
const data = await scrapeRes.json(); const data = await scrapeRes.json();
const hospital = data.data?.json; const hospital = data.data?.json;
if (hospital?.hospitalName) { if (hospital?.hospitalName) {
channelData.gangnamUnni = { channelData.gangnamUnni = {
status: "ok",
name: hospital.hospitalName, name: hospital.hospitalName,
rawRating: hospital.rating, rawRating: hospital.rating,
rating: typeof hospital.rating === 'number' && hospital.rating > 0 ? hospital.rating : null, rating: typeof hospital.rating === 'number' && hospital.rating > 0 ? hospital.rating : null,
@ -452,9 +469,21 @@ Deno.serve(async (req) => {
badges: hospital.badges || [], sourceUrl: gangnamUnniUrl, badges: hospital.badges || [], sourceUrl: gangnamUnniUrl,
}; };
} else { } else {
throw new Error("강남언니 scrape returned no hospital data"); channelData.gangnamUnni = {
status: "skipped",
reason: "EMPTY_SCRAPE_RESULT",
reasonDetail: "Firecrawl scraped the page but could not extract hospital data",
attemptedAt: new Date().toISOString(),
sourceUrl: gangnamUnniUrl,
};
} }
})); }));
} else {
channelData.gangnamUnni = {
status: "skipped",
reason: !FIRECRAWL_API_KEY ? "FIRECRAWL_API_KEY_MISSING" : "CLINIC_NAME_MISSING",
attemptedAt: new Date().toISOString(),
};
} }
// ─── 5. Naver Blog + Place ─── // ─── 5. Naver Blog + Place ───
@ -490,6 +519,7 @@ Deno.serve(async (req) => {
const totalMatch = xml.match(/<totalCount>(\d+)<\/totalCount>/) || xml.match(/<managedCount>(\d+)<\/managedCount>/); const totalMatch = xml.match(/<totalCount>(\d+)<\/totalCount>/) || xml.match(/<managedCount>(\d+)<\/managedCount>/);
const totalPosts = totalMatch ? Number(totalMatch[1]) : items.length; const totalPosts = totalMatch ? Number(totalMatch[1]) : items.length;
channelData.naverBlog = { channelData.naverBlog = {
status: "ok",
officialBlogUrl, officialBlogHandle, officialBlogUrl, officialBlogHandle,
totalResults: totalPosts, totalResults: totalPosts,
posts: items.slice(0, 10).map(i => ({ posts: items.slice(0, 10).map(i => ({
@ -500,13 +530,28 @@ Deno.serve(async (req) => {
}; };
console.log(`[naverBlog] RSS: ${items.length} posts from verified handle ${officialBlogHandle}`); console.log(`[naverBlog] RSS: ${items.length} posts from verified handle ${officialBlogHandle}`);
} catch (e) { } catch (e) {
console.warn(`[naverBlog] RSS fetch failed:`, e); const reason = e instanceof Error ? e.message : String(e);
// Fallback: at minimum expose the official URL even without post data console.warn(`[naverBlog] RSS fetch failed:`, reason);
channelData.naverBlog = { officialBlogUrl, officialBlogHandle, totalResults: 0, posts: [], officialContent: null }; // Expose the official URL even without post data so the frontend can still link out
channelData.naverBlog = {
status: "skipped",
reason: "RSS_FETCH_FAILED",
reasonDetail: reason,
attemptedAt: new Date().toISOString(),
officialBlogUrl, officialBlogHandle,
totalResults: 0, posts: [], officialContent: null,
};
} }
})); }));
} else { } else {
console.log(`[naverBlog] No verified handle in DB — skipping`); console.log(`[naverBlog] No verified handle in DB — marking skipped`);
channelData.naverBlog = {
status: "skipped",
reason: "NO_VERIFIED_HANDLE",
attemptedAt: new Date().toISOString(),
totalResults: 0,
posts: [],
};
} }
// naverPlace: use stored verified data if available, otherwise search once and save // naverPlace: use stored verified data if available, otherwise search once and save
@ -564,7 +609,7 @@ Deno.serve(async (req) => {
} }
if (found) { if (found) {
channelData.naverPlace = found; channelData.naverPlace = { status: "ok", ...found };
// Save to clinics.verified_channels so future runs skip the search // Save to clinics.verified_channels so future runs skip the search
if (inputClinicId) { if (inputClinicId) {
const { data: clinicRow } = await supabase.from('clinics').select('verified_channels').eq('id', inputClinicId).single(); const { data: clinicRow } = await supabase.from('clinics').select('verified_channels').eq('id', inputClinicId).single();
@ -576,9 +621,22 @@ Deno.serve(async (req) => {
} }
} }
} else { } else {
console.log(`[naverPlace] No confident match found — skipping to avoid wrong data`); console.log(`[naverPlace] No confident match found — marking skipped`);
channelData.naverPlace = {
status: "skipped",
reason: "NO_CONFIDENT_MATCH",
reasonDetail: `Tried ${queries.length} queries but none matched the domain or exact clinic name`,
attemptedAt: new Date().toISOString(),
attemptedQueries: queries,
};
} }
})); }));
} else {
channelData.naverPlace = {
status: "skipped",
reason: "NAVER_API_CREDENTIALS_MISSING",
attemptedAt: new Date().toISOString(),
};
} }
// ─── 6. Google Maps (Google Places API New) ─── // ─── 6. Google Maps (Google Places API New) ───
@ -587,6 +645,7 @@ Deno.serve(async (req) => {
const place = await searchGooglePlace(clinicName, address || undefined, GOOGLE_PLACES_API_KEY); const place = await searchGooglePlace(clinicName, address || undefined, GOOGLE_PLACES_API_KEY);
if (place) { if (place) {
channelData.googleMaps = { channelData.googleMaps = {
status: "ok",
name: place.name, rating: place.rating, reviewCount: place.reviewCount, name: place.name, rating: place.rating, reviewCount: place.reviewCount,
address: place.address, phone: place.phone, address: place.address, phone: place.phone,
clinicWebsite: place.clinicWebsite, clinicWebsite: place.clinicWebsite,
@ -596,9 +655,20 @@ Deno.serve(async (req) => {
topReviews: place.topReviews, topReviews: place.topReviews,
}; };
} else { } else {
throw new Error("Google Maps: no matching place found"); channelData.googleMaps = {
status: "skipped",
reason: "PLACE_NOT_FOUND",
attemptedAt: new Date().toISOString(),
searchQuery: clinicName,
};
} }
})); }));
} else {
channelData.googleMaps = {
status: "skipped",
reason: !GOOGLE_PLACES_API_KEY ? "API_KEY_MISSING" : "CLINIC_NAME_MISSING",
attemptedAt: new Date().toISOString(),
};
} }
// ─── 7. Market Analysis (Perplexity) ─── // ─── 7. Market Analysis (Perplexity) ───
@ -841,34 +911,41 @@ Deno.serve(async (req) => {
}); });
} }
// Helper: only snapshot channels that actually collected data.
// Skipped channels (status === 'skipped') still live in channelData for the
// generate-report step to render their skip reason, but shouldn't pollute
// the time-series channel_snapshots table.
const isCollected = (d: Record<string, unknown> | undefined): boolean =>
!!d && d.status !== "skipped";
const guData = channelData.gangnamUnni as Record<string, unknown> | undefined; const guData = channelData.gangnamUnni as Record<string, unknown> | undefined;
if (guData) { if (isCollected(guData)) {
snapshotInserts.push({ snapshotInserts.push({
clinic_id: clinicId, run_id: runId, channel: 'gangnamUnni', clinic_id: clinicId, run_id: runId, channel: 'gangnamUnni',
handle: guData.name, rating: guData.rating, rating_scale: 10, handle: guData!.name, rating: guData!.rating, rating_scale: 10,
reviews: guData.totalReviews, reviews: guData!.totalReviews,
health_score: computeHealthScore('gangnamUnni', guData), health_score: computeHealthScore('gangnamUnni', guData!),
details: guData, details: guData,
}); });
} }
const gmData = channelData.googleMaps as Record<string, unknown> | undefined; const gmData = channelData.googleMaps as Record<string, unknown> | undefined;
if (gmData) { if (isCollected(gmData)) {
snapshotInserts.push({ snapshotInserts.push({
clinic_id: clinicId, run_id: runId, channel: 'googleMaps', clinic_id: clinicId, run_id: runId, channel: 'googleMaps',
handle: gmData.name, rating: gmData.rating, rating_scale: 5, handle: gmData!.name, rating: gmData!.rating, rating_scale: 5,
reviews: gmData.reviewCount, reviews: gmData!.reviewCount,
health_score: computeHealthScore('googleMaps', gmData), health_score: computeHealthScore('googleMaps', gmData!),
details: gmData, details: gmData,
}); });
} }
const nbData = channelData.naverBlog as Record<string, unknown> | undefined; const nbData = channelData.naverBlog as Record<string, unknown> | undefined;
if (nbData) { if (isCollected(nbData)) {
snapshotInserts.push({ snapshotInserts.push({
clinic_id: clinicId, run_id: runId, channel: 'naverBlog', clinic_id: clinicId, run_id: runId, channel: 'naverBlog',
handle: nbData.officialBlogHandle, handle: nbData!.officialBlogHandle,
health_score: computeHealthScore('naverBlog', nbData), health_score: computeHealthScore('naverBlog', nbData!),
details: nbData, details: nbData,
}); });
} }

View File

@ -284,23 +284,122 @@ Deno.serve(async (req) => {
} }
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
// NOT REGISTERED: Return error for unregistered domains // REGISTRY MISS — secondary fast-path via `clinics` cache
// (Registry-only mode — no API fallback) // If this domain was previously auto-discovered and cached in `clinics.verified_channels`,
// reuse it instead of re-running the full Firecrawl+Perplexity discovery.
// Fresh runs only re-discover after 14 days so stale handles eventually refresh.
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
console.log(`[registry] Miss: ${registryDomain} — returning CLINIC_NOT_REGISTERED`); console.log(`[registry] Miss: ${registryDomain} — trying clinics cache + Firecrawl fallback`);
return new Response(
JSON.stringify({ if (registryDomain) {
success: false, try {
error: "CLINIC_NOT_REGISTERED", const { data: cachedClinic } = await supabase
message: "현재 지원하지 않는 병원입니다. 등록된 병원만 분석 가능합니다.", .from("clinics")
domain: registryDomain, .select("id, name, name_en, address, phone, services, branding, verified_channels, last_analyzed_at")
}), .eq("domain", registryDomain)
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }, .maybeSingle();
);
const cachedChannels = cachedClinic?.verified_channels as VerifiedChannels | null | undefined;
const hasAny = cachedChannels && (
((cachedChannels as Record<string, unknown>).instagram as unknown[] | undefined)?.length
|| (cachedChannels as Record<string, unknown>).youtube
|| (cachedChannels as Record<string, unknown>).facebook
|| (cachedChannels as Record<string, unknown>).naverBlog
);
const lastAnalyzed = cachedClinic?.last_analyzed_at ? new Date(cachedClinic.last_analyzed_at).getTime() : 0;
const CACHE_TTL_MS = 14 * 24 * 60 * 60 * 1000;
const cacheFresh = lastAnalyzed > 0 && (Date.now() - lastAnalyzed) < CACHE_TTL_MS;
if (cachedClinic && hasAny && cacheFresh) {
console.log(`[clinics-cache] Hit: ${cachedClinic.name} (${registryDomain}) — reusing cached channels`);
const scrapeDataFromCache = {
clinic: {
clinicName: cachedClinic.name,
clinicNameEn: cachedClinic.name_en,
address: cachedClinic.address,
phone: cachedClinic.phone,
services: cachedClinic.services || [],
},
branding: cachedClinic.branding || {},
siteLinks: [],
siteMap: [],
sourceUrl: url,
scrapedAt: new Date().toISOString(),
source: "clinics-cache",
};
const { data: saved, error: saveError } = await supabase
.from("marketing_reports")
.insert({
url,
clinic_name: cachedClinic.name,
status: "discovered",
verified_channels: cachedChannels,
scrape_data: scrapeDataFromCache,
report: {},
pipeline_started_at: new Date().toISOString(),
})
.select("id")
.single();
if (saveError) throw new Error(`DB save failed: ${saveError.message}`);
// Refresh last_analyzed_at so the cache stays warm
await supabase.from("clinics")
.update({ last_analyzed_at: new Date().toISOString() })
.eq("id", cachedClinic.id);
// V3 run record
let runId: string | null = null;
try {
const { data: runRow } = await supabase
.from("analysis_runs")
.insert({
clinic_id: cachedClinic.id,
status: "discovering",
scrape_data: scrapeDataFromCache,
discovered_channels: cachedChannels,
trigger: "manual",
pipeline_started_at: new Date().toISOString(),
})
.select("id")
.single();
runId = runRow?.id || null;
} catch (e) {
console.error("V3 dual-write error (clinics-cache):", e);
}
return new Response(
JSON.stringify({
success: true,
reportId: saved.id,
clinicId: cachedClinic.id,
runId,
clinicName: cachedClinic.name,
verifiedChannels: cachedChannels,
address: cachedClinic.address || "",
services: cachedClinic.services || [],
scrapeData: scrapeDataFromCache,
source: "clinics-cache",
}),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
} else if (cachedClinic && !cacheFresh) {
console.log(`[clinics-cache] Stale for ${registryDomain} — re-running discovery`);
}
} catch (e) {
console.warn("[clinics-cache] Lookup failed, falling through to full discovery:", e instanceof Error ? e.message : e);
}
}
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
// LEGACY FALLBACK: Full API discovery (disabled — registry-only mode) // FULL DISCOVERY: Firecrawl + extractSocialLinks + verifyHandles
// Kept for reference; unreachable in production // Runs for: unregistered domains, stale cache, or cache miss.
// Deterministic footer/link extraction (Source 1-3) is prioritized;
// Perplexity/Apify/Naver (Source 4-5) are AI fallbacks that only contribute
// when the regex path is empty. Everything that succeeds gets saved to
// `clinics.verified_channels` so the next run hits the cache fast-path above.
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
const FIRECRAWL_API_KEY = Deno.env.get("FIRECRAWL_API_KEY") || ""; const FIRECRAWL_API_KEY = Deno.env.get("FIRECRAWL_API_KEY") || "";
@ -806,6 +905,19 @@ Deno.serve(async (req) => {
} catch { /* ignore secondary failure */ } } catch { /* ignore secondary failure */ }
} }
// Log deterministic vs AI contribution so we can see whether the footer
// scraping alone was sufficient for this clinic (the developer's hypothesis).
const deterministicCount =
(linkHandles.instagram?.length || 0) +
(linkHandles.youtube?.length || 0) +
(linkHandles.facebook?.length || 0) +
(linkHandles.naverBlog?.length || 0) +
(buttonHandles.instagram?.length || 0) +
(buttonHandles.youtube?.length || 0) +
(buttonHandles.facebook?.length || 0) +
(buttonHandles.naverBlog?.length || 0);
console.log(`[discover] ${registryDomain} — deterministic handles: ${deterministicCount}, final verified channels: ig=${(verified.instagram || []).length}, yt=${verified.youtube ? 1 : 0}, fb=${verified.facebook ? 1 : 0}, blog=${verified.naverBlog ? 1 : 0}`);
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
success: true, reportId: saved.id, success: true, reportId: saved.id,
@ -815,6 +927,7 @@ Deno.serve(async (req) => {
address: clinic.address || "", address: clinic.address || "",
services: clinic.services || [], services: clinic.services || [],
scrapeData: scrapeDataFull, scrapeData: scrapeDataFull,
source: "firecrawl-fallback",
}), }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }, { headers: { ...corsHeaders, "Content-Type": "application/json" } },
); );

View File

@ -195,6 +195,157 @@ ${JSON.stringify(scrapeData.branding || {}, null, 2).slice(0, 1000)}
report.visionAnalysis = vision; report.visionAnalysis = vision;
} }
// ─── Harness 4: GroundTruth channel injection ───
// Perplexity occasionally drops channel metrics even though they exist in channelData.
// For each channel we force-inject the numeric fields from the actually-collected data.
// Same pattern as the Vision injection above (foundingYear / gangnamUnni), extended
// to instagram / youtube / facebook / naverBlog / naverPlace.
report.channelAnalysis = report.channelAnalysis || {};
const injectedChannels: string[] = [];
// Instagram — prefer instagramAccounts[0], fall back to channelData.instagram
const igAccountsList = (channelData.instagramAccounts as Record<string, unknown>[] | undefined) || [];
const igPrimary = (igAccountsList[0] || channelData.instagram) as Record<string, unknown> | undefined;
const igPostsSummary = channelData.instagramPosts as Record<string, unknown> | undefined;
if (igPrimary) {
const target = (report.channelAnalysis.instagram ||= {} as Record<string, unknown>);
if (isMissingValue(target.followers) && igPrimary.followers) {
target.followers = igPrimary.followers;
}
// Prefer profile-level post count; fall back to scraped posts summary
if (isMissingValue(target.posts)) {
if (igPrimary.posts) target.posts = igPrimary.posts;
else if (igPostsSummary?.totalPosts) target.posts = igPostsSummary.totalPosts;
}
if (isMissingValue(target.status)) target.status = "active";
injectedChannels.push(`instagram(f=${target.followers},p=${target.posts})`);
}
// YouTube
const ytGT = channelData.youtube as Record<string, unknown> | undefined;
if (ytGT) {
const target = (report.channelAnalysis.youtube ||= {} as Record<string, unknown>);
if (isMissingValue(target.subscribers) && ytGT.subscribers) {
target.subscribers = ytGT.subscribers;
}
if (isMissingValue((target as Record<string, unknown>).videos) && ytGT.totalVideos) {
(target as Record<string, unknown>).videos = ytGT.totalVideos;
}
if (isMissingValue(target.status)) target.status = "active";
injectedChannels.push(`youtube(s=${target.subscribers})`);
}
// Facebook
const fbGT = channelData.facebook as Record<string, unknown> | undefined;
if (fbGT) {
const target = (report.channelAnalysis.facebook ||= {} as Record<string, unknown>);
if (isMissingValue(target.followers) && fbGT.followers) {
target.followers = fbGT.followers;
}
if (isMissingValue((target as Record<string, unknown>).likes) && fbGT.likes) {
(target as Record<string, unknown>).likes = fbGT.likes;
}
if (isMissingValue(target.status)) target.status = "active";
injectedChannels.push(`facebook(f=${target.followers})`);
}
// Naver Blog — RSS provides totalResults + posts
const nbGT = channelData.naverBlog as Record<string, unknown> | undefined;
if (nbGT && !nbGT.skipped) {
const target = (report.channelAnalysis.naverBlog ||= {} as Record<string, unknown>);
if (isMissingValue(target.posts) && nbGT.totalResults) {
target.posts = nbGT.totalResults;
}
if (isMissingValue(target.status)) {
target.status = (nbGT.totalResults as number) > 0 ? "active" : "inactive";
}
injectedChannels.push(`naverBlog(p=${target.posts})`);
}
// Naver Place — Naver Local Search doesn't include rating/reviews, but stores the place metadata.
// If the place was found we at least set status=active; rating/reviews often come via Vision.
const npGT = channelData.naverPlace as Record<string, unknown> | undefined;
if (npGT && !npGT.skipped && npGT.name) {
const target = (report.channelAnalysis.naverPlace ||= {} as Record<string, unknown>);
if (isMissingValue(target.status)) target.status = "active";
if (isMissingValue((target as Record<string, unknown>).name) && npGT.name) {
(target as Record<string, unknown>).name = npGT.name;
}
// Rating/reviews may be injected by Vision block above; don't overwrite
injectedChannels.push(`naverPlace(${npGT.name})`);
}
// GangnamUnni — Vision block handled the rating/reviews path already.
// Here we only fill status/rating when they come directly from the scraper (D).
const guGT = channelData.gangnamUnni as Record<string, unknown> | undefined;
if (guGT && !guGT.skipped) {
const target = (report.channelAnalysis.gangnamUnni ||= {} as Record<string, unknown>);
if (isMissingValue(target.rating) && guGT.rating) {
target.rating = guGT.rating;
(target as Record<string, unknown>).ratingScale = 10;
}
if (isMissingValue(target.reviews) && (guGT.totalReviews || guGT.reviews)) {
target.reviews = guGT.totalReviews || guGT.reviews;
}
if (isMissingValue(target.status)) target.status = "active";
injectedChannels.push(`gangnamUnni(r=${target.rating})`);
}
if (injectedChannels.length > 0) {
console.log(`[report] GroundTruth injected: ${injectedChannels.join(", ")}`);
}
// ─── Harness 4b: Diagnosis fallback ───
// ProblemDiagnosis.tsx returns null when `problemDiagnosis` is empty, hiding the whole
// section. Make sure each weak channel contributes at least one diagnosis item so the
// frontend renders. Only runs when AI didn't fill diagnosis itself.
const DEFAULT_DIAGNOSIS: Record<string, { issue: string; recommendation: string }> = {
instagram: {
issue: "Instagram 업로드 빈도가 부족하거나 게시물 참여율이 낮습니다",
recommendation: "주 3회 이상 주제별 콘텐츠 발행 + 릴스 비중 40% 이상 확보",
},
youtube: {
issue: "YouTube 채널 활동이 저조하여 브랜드 신뢰도 확보가 어렵습니다",
recommendation: "시술 Before/After Shorts 주 2회, 롱폼 월 2회 업로드 루틴 수립",
},
facebook: {
issue: "Facebook 페이지 활동이 낮아 유입 기여가 제한적입니다",
recommendation: "광고 랜딩 채널로 재정의하고 월 4회 프로모션 포스트 자동화",
},
naverBlog: {
issue: "네이버 블로그 최신 포스팅 양이 부족하여 로컬 SEO 기회를 놓치고 있습니다",
recommendation: "주요 시술 키워드별 주 2건, 월 8건 이상 SEO 최적화 포스팅",
},
naverPlace: {
issue: "네이버 플레이스 리뷰/콘텐츠 관리가 체계화되어 있지 않습니다",
recommendation: "방문 리뷰 유도 프로세스 구축 + 키워드·사진 리뷰 월 20건 확보",
},
gangnamUnni: {
issue: "강남언니 프로필 정보와 답변률이 미흡합니다",
recommendation: "의사별 프로필 완성도 100% + 문의 24시간 내 응답 체계",
},
};
const weakStatuses = new Set(["inactive", "weak", "not_found", undefined]);
for (const ch of Object.keys(DEFAULT_DIAGNOSIS)) {
const node = (report.channelAnalysis as Record<string, unknown>)[ch] as
| Record<string, unknown>
| undefined;
if (!node) continue;
const existing = node.diagnosis as unknown[] | undefined;
if (Array.isArray(existing) && existing.length > 0) continue;
const score = typeof node.score === "number" ? node.score : null;
const status = node.status as string | undefined;
const needsFallback = (score !== null && score < 60) || weakStatuses.has(status);
if (!needsFallback) continue;
const def = DEFAULT_DIAGNOSIS[ch];
node.diagnosis = [{
issue: def.issue,
severity: score !== null && score < 40 ? "critical" : "warning",
recommendation: def.recommendation,
}];
console.log(`[report] Diagnosis fallback injected for ${ch} (score=${score}, status=${status})`);
}
// Embed channel enrichment data for frontend mergeEnrichment() // Embed channel enrichment data for frontend mergeEnrichment()
report.channelEnrichment = channelData; report.channelEnrichment = channelData;
report.enrichedAt = new Date().toISOString(); report.enrichedAt = new Date().toISOString();
@ -320,10 +471,31 @@ ${JSON.stringify(scrapeData.branding || {}, null, 2).slice(0, 1000)}
console.warn(`[harness] Low report quality (${qualityReport.score}/100):`, qualityReport.warnings); console.warn(`[harness] Low report quality (${qualityReport.score}/100):`, qualityReport.warnings);
} }
// Persist quality report into analysis_data.qualityReport so the frontend (and
// future debugging queries) can inspect why specific sections rendered empty.
// Downgrade status to 'partial' when too many important/critical fields are missing.
const totalMissing = qualityReport.missingCritical.length + qualityReport.missingImportant.length;
const reportStatus = (qualityReport.missingCritical.length > 0 || totalMissing >= 5)
? "partial"
: "complete";
const enrichedAnalysisData = {
...analysisData,
qualityReport: {
score: qualityReport.score,
missingCritical: qualityReport.missingCritical,
missingImportant: qualityReport.missingImportant,
missingOptional: qualityReport.missingOptional,
warnings: qualityReport.warnings,
injectedChannels,
generatedAt: new Date().toISOString(),
},
};
// Legacy: marketing_reports // Legacy: marketing_reports
await supabase.from("marketing_reports").update({ await supabase.from("marketing_reports").update({
report, report,
status: "complete", analysis_data: enrichedAnalysisData,
status: reportStatus,
data_quality_score: qualityReport.score, data_quality_score: qualityReport.score,
pipeline_completed_at: new Date().toISOString(), pipeline_completed_at: new Date().toISOString(),
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),