## Clinic Registry - data/clinic-registry/clinic_registry_working.csv — 91개 병원 채널 마스터 DB - data/clinic-registry/INFINITH_Outbound_List.csv — BD팀 아웃바운드 리스트 (17컬럼) - data/clinic-registry/update_csv.py — 안전 CSV 업데이트 스크립트 (빈 필드만 채움) - data/clinic-registry/extract_place_ids.py — 네이버 플레이스 ID 추출기 - scripts/import-registry.ts — CSV → Supabase clinic_registry 테이블 임포트 - supabase/migrations/20260406_clinic_registry.sql — clinic_registry 테이블 스키마 ## Pipeline P0 Bug Fixes (전수 감사 후) - fix(collect-channel-data): 강남언니 rating 0-10 스케일 오변환 제거 - 기존: rating ≤ 5이면 ×2 → 4.8/10을 9.6/10으로 잘못 변환 - 수정: Firecrawl 프롬프트가 이미 0-10 지시 → rawValue 직접 신뢰 - fix(generate-report): Perplexity 단일 fetch → fetchWithRetry 교체 - maxRetries:2, backoffMs:[5000,15000], timeoutMs:90s - 기존: 타임아웃/429 시 리포트 생성 전체 실패 - 수정: 자동 재시도로 일시적 API 오류 극복 ## Docs - docs/PIPELINE_IMPROVEMENT_PLAN.md — Sprint 0/1/2 완료 표시 + 전수 감사 결과 추가 - docs/REGISTRY_FUNCTIONAL_SPECS.md, DB_SCHEMA_V3.md 외 기획 문서 다수 추가 ## New Components & Features - supabase/functions/generate-content-plan, adjust-strategy — 콘텐츠 플랜/전략 조정 - src/components/plan/EditEntryModal, StrategyAdjustmentSection — 플랜 편집 UI - supabase/functions/_shared/dataQuality, foundingYearExtractor, urlClassifier — 데이터 품질 유틸 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
193 lines
5.3 KiB
TypeScript
193 lines
5.3 KiB
TypeScript
/**
|
||
* Missing Value Guard & Report Quality Validator — Harness 3
|
||
*
|
||
* Detects all known variants of "no data" from LLM outputs and
|
||
* provides a quality score for generated reports.
|
||
*
|
||
* Usage:
|
||
* - isMissingValue(val) → boolean (use everywhere you check for missing data)
|
||
* - validateReportQuality(report) → DataQualityReport (use before saving reports)
|
||
*/
|
||
|
||
// ─── Missing Value Detection ───
|
||
|
||
/**
|
||
* All known LLM expressions for "no data available".
|
||
* Normalized to lowercase for comparison.
|
||
*/
|
||
const MISSING_PATTERNS: ReadonlyArray<string> = [
|
||
// Korean
|
||
"데이터 없음", "데이터없음", "데이터 미확인", "데이터미확인",
|
||
"정보없음", "정보 없음", "정보 미제공", "미제공",
|
||
"확인불가", "확인 불가", "미확인", "미발견",
|
||
"알 수 없음", "알수없음", "해당 없음", "해당없음",
|
||
"없음", "미정",
|
||
// English
|
||
"n/a", "na", "none", "null", "undefined",
|
||
"not available", "unknown", "not found", "no data",
|
||
// Symbols
|
||
"-", "—", "–", ".", "...", "N/A",
|
||
];
|
||
|
||
/**
|
||
* Check if a value represents missing/unavailable data.
|
||
* Handles null, undefined, empty strings, zero, and LLM "no data" variants.
|
||
*/
|
||
export function isMissingValue(val: unknown): boolean {
|
||
if (val == null) return true;
|
||
|
||
if (typeof val === "number") return val === 0 || isNaN(val);
|
||
|
||
const s = String(val).trim().toLowerCase();
|
||
if (s === "" || s === "0") return true;
|
||
|
||
return MISSING_PATTERNS.some((p) => s === p.toLowerCase());
|
||
}
|
||
|
||
/**
|
||
* Clean a value: return it if valid, or return the fallback if missing.
|
||
* Useful for providing defaults without silent data loss.
|
||
*/
|
||
export function cleanValue<T>(val: unknown, fallback: T): T | unknown {
|
||
return isMissingValue(val) ? fallback : val;
|
||
}
|
||
|
||
// ─── Report Quality Validation ───
|
||
|
||
export interface DataQualityReport {
|
||
score: number; // 0-100
|
||
missingCritical: string[]; // Missing critical fields
|
||
missingImportant: string[]; // Missing important fields
|
||
missingOptional: string[]; // Missing optional fields
|
||
warnings: string[]; // Human-readable warnings
|
||
}
|
||
|
||
/** Fields that MUST be present for a valid report */
|
||
const CRITICAL_FIELDS = [
|
||
"clinicInfo.name",
|
||
"clinicInfo.established",
|
||
];
|
||
|
||
/** Fields that significantly impact report quality */
|
||
const IMPORTANT_FIELDS = [
|
||
"clinicInfo.doctors",
|
||
"channelAnalysis.youtube",
|
||
"channelAnalysis.instagram",
|
||
"channelAnalysis.naverBlog",
|
||
"channelAnalysis.gangnamUnni",
|
||
];
|
||
|
||
/** Nice-to-have fields */
|
||
const OPTIONAL_FIELDS = [
|
||
"channelAnalysis.facebook",
|
||
"channelAnalysis.tiktok",
|
||
"channelAnalysis.naverPlace",
|
||
"channelAnalysis.googleMaps",
|
||
"clinicInfo.location",
|
||
];
|
||
|
||
/**
|
||
* Validate report data quality and return a score with details.
|
||
*
|
||
* Scoring:
|
||
* - Each critical field missing: -20 points
|
||
* - Each important field missing: -5 points
|
||
* - Each optional field missing: -2 points
|
||
*/
|
||
export function validateReportQuality(
|
||
report: Record<string, unknown>,
|
||
): DataQualityReport {
|
||
const result: DataQualityReport = {
|
||
score: 100,
|
||
missingCritical: [],
|
||
missingImportant: [],
|
||
missingOptional: [],
|
||
warnings: [],
|
||
};
|
||
|
||
// Check critical fields
|
||
for (const path of CRITICAL_FIELDS) {
|
||
const val = getNestedValue(report, path);
|
||
if (isMissingValue(val)) {
|
||
result.missingCritical.push(path);
|
||
result.score -= 20;
|
||
result.warnings.push(`❌ Critical: '${path}' is missing`);
|
||
}
|
||
}
|
||
|
||
// Check important fields
|
||
for (const path of IMPORTANT_FIELDS) {
|
||
const val = getNestedValue(report, path);
|
||
if (isMissingValue(val)) {
|
||
result.missingImportant.push(path);
|
||
result.score -= 5;
|
||
result.warnings.push(`⚠️ Important: '${path}' is missing`);
|
||
}
|
||
}
|
||
|
||
// Check optional fields
|
||
for (const path of OPTIONAL_FIELDS) {
|
||
const val = getNestedValue(report, path);
|
||
if (isMissingValue(val)) {
|
||
result.missingOptional.push(path);
|
||
result.score -= 2;
|
||
}
|
||
}
|
||
|
||
result.score = Math.max(0, result.score);
|
||
return result;
|
||
}
|
||
|
||
// ─── Helpers ───
|
||
|
||
/**
|
||
* Traverse a nested object by dot-separated path.
|
||
* e.g., getNestedValue({ a: { b: 1 } }, "a.b") → 1
|
||
*/
|
||
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||
return path.split(".").reduce(
|
||
(current, key) => {
|
||
if (current == null || typeof current !== "object") return undefined;
|
||
return (current as Record<string, unknown>)[key];
|
||
},
|
||
obj as unknown,
|
||
);
|
||
}
|
||
|
||
// ─── Self-Test ───
|
||
|
||
const MISSING_VALUE_TEST_CORPUS: ReadonlyArray<readonly [unknown, boolean]> = [
|
||
[null, true],
|
||
[undefined, true],
|
||
["", true],
|
||
[" ", true],
|
||
[0, true],
|
||
["데이터 없음", true],
|
||
["데이터없음", true],
|
||
["N/A", true],
|
||
["n/a", true],
|
||
["확인 불가", true],
|
||
["미확인", true],
|
||
["unknown", true],
|
||
["-", true],
|
||
["—", true],
|
||
["none", true],
|
||
// Valid values
|
||
["뷰성형외과", false],
|
||
[4.5, false],
|
||
["2004", false],
|
||
[387, false],
|
||
["https://example.com", false],
|
||
];
|
||
|
||
export function validateDataQuality(): { pass: boolean; failures: string[] } {
|
||
const failures: string[] = [];
|
||
for (const [val, expected] of MISSING_VALUE_TEST_CORPUS) {
|
||
const result = isMissingValue(val);
|
||
if (result !== expected) {
|
||
failures.push(`isMissingValue(${JSON.stringify(val)}): expected ${expected}, got ${result}`);
|
||
}
|
||
}
|
||
return { pass: failures.length === 0, failures };
|
||
}
|