// AI Methodology Matcher: vector similarity matching via pgvector // Patent claims 1, 3: AI-based methodology auto-matching // // 다층 폴백 전략: // 1) embed-text Edge Function (OpenAI text-embedding-3-small 또는 서버 pseudo) // 2) 실패 시 클라이언트 pseudo-embedding (djb2 + mulberry32) — Edge Function 시드와 동일 알고리즘 // 3) pgvector RPC match_methodology 는 어느 embedding source 든 동일하게 작동 // 4) RPC 실패 시 클라이언트 측 텍스트 유사도(fallback): Jaccard on tokens import { supabase } from './supabase' import { invokeEdgeFunction } from './apiAdapter' import type { MethodologyMatch } from '../types' export interface DataSpec { parameter: string name: string purpose: string source: string } export interface MethodologyExplanation { success: boolean eligibilityScore: number explanation: string keyConstraints: string[] requiredData: DataSpec[] recommendedData: DataSpec[] } export interface MethodologyVector { id: string methodologyCode: string registry: string nameKo: string nameEn: string | null applicability: string emissionFactors: Record | null requiredParams: string[] similarity: number } export interface MatchResult { matches: MethodologyVector[] queryText: string matchedAt: string /** 매칭 소스 경로 (디버깅·UI 배지용) */ source: 'edge-openai' | 'edge-pseudo' | 'client-pseudo' | 'text-jaccard' } const EMBED_DIM = 1536 // --- Deterministic pseudo-embedding (Edge Function과 동일 알고리즘) --- function djb2(str: string): number { let hash = 5381 for (let i = 0; i < str.length; i++) { hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0 } return hash } function mulberry32(seed: number) { let s = seed >>> 0 return () => { s = (s + 0x6d2b79f5) >>> 0 let t = s t = Math.imul(t ^ (t >>> 15), t | 1) t ^= t + Math.imul(t ^ (t >>> 7), t | 61) return ((t ^ (t >>> 14)) >>> 0) / 4294967296 } } function clientPseudoEmbedding(text: string): number[] { const normalized = text.trim().toLowerCase() const seed = djb2(normalized) const rng = mulberry32(seed) const vec = new Array(EMBED_DIM) let sumSq = 0 for (let i = 0; i < EMBED_DIM; i++) { const v = rng() * 2 - 1 vec[i] = v sumSq += v * v } const norm = Math.sqrt(sumSq) || 1 for (let i = 0; i < EMBED_DIM; i++) vec[i] /= norm return vec } // --- Embedding acquisition (fallback chain) --- async function getEmbedding(text: string): Promise<{ vec: number[]; source: 'edge-openai' | 'edge-pseudo' | 'client-pseudo' }> { try { const payload = await invokeEdgeFunction<{ text: string }, { embedding: number[]; source?: string }>('embed-text', { text }) if (Array.isArray(payload?.embedding) && payload.embedding.length === EMBED_DIM) { return { vec: payload.embedding, source: payload.source === 'openai' ? 'edge-openai' : 'edge-pseudo', } } throw new Error('invalid embedding response shape') } catch (err) { // 로컬 dev / 오프라인 / Edge Function 미배포 → 클라이언트 폴백 console.warn('[methodologyMatcher] embed-text 호출 실패, 클라이언트 pseudo로 폴백:', err) return { vec: clientPseudoEmbedding(text), source: 'client-pseudo' } } } // --- Text Jaccard (최후 폴백 — pgvector RPC 자체가 실패할 때) --- function tokenize(s: string): Set { return new Set( s .toLowerCase() .replace(/[^\p{L}\p{N}\s]/gu, ' ') .split(/\s+/) .filter((t) => t.length > 0), ) } function jaccard(a: Set, b: Set): number { if (a.size === 0 || b.size === 0) return 0 let inter = 0 for (const t of a) if (b.has(t)) inter += 1 const uni = a.size + b.size - inter return uni === 0 ? 0 : inter / uni } async function textJaccardMatch( query: string, matchCount: number, ): Promise { const all = await getMethodologyVectors() const qTokens = tokenize(query) return all .map((m) => ({ ...m, similarity: jaccard(qTokens, tokenize(`${m.nameKo} ${m.applicability}`)), })) .sort((a, b) => b.similarity - a.similarity) .slice(0, matchCount) } // --- Match methodologies by text description --- export async function matchMethodologies( activityDescription: string, matchCount = 5, minScore = 0.5, ): Promise { const { vec: embedding, source: embedSource } = await getEmbedding(activityDescription) try { const { data, error } = await supabase.rpc('match_methodology', { p_embedding: embedding, p_match_count: matchCount, p_min_score: minScore, }) if (error) throw error const matches: MethodologyVector[] = (data ?? []).map((row: Record) => ({ id: row.id as string, methodologyCode: row.methodology_code as string, registry: row.registry as string, nameKo: row.name_ko as string, nameEn: (row.name_en as string) ?? null, applicability: row.applicability as string, emissionFactors: (row.emission_factors as Record) ?? null, requiredParams: (row.required_params as string[]) ?? [], similarity: Number(row.similarity), })) return { matches, queryText: activityDescription, matchedAt: new Date().toISOString(), source: embedSource, } } catch (err) { // pgvector RPC 실패 (예: methodology_vectors seed 미반영 DB) → 텍스트 Jaccard 폴백 console.warn('[methodologyMatcher] match_methodology RPC 실패, 텍스트 유사도 폴백:', err) const matches = await textJaccardMatch(activityDescription, matchCount) return { matches, queryText: activityDescription, matchedAt: new Date().toISOString(), source: 'text-jaccard', } } } // --- Convert MethodologyVector to existing MethodologyMatch type --- export function toMethodologyMatch( vector: MethodologyVector, campaignContext: { activityType: string organization: string reductionTons: number dataRows: number sourceFileId?: string sourceFileName?: string }, ): MethodologyMatch { const emissionFactor = vector.emissionFactors ? Object.entries(vector.emissionFactors) .map(([k, v]) => `${k}: ${v}`) .join(', ') : '—' const score = Math.round(vector.similarity * 100); return { id: vector.id, activityType: campaignContext.activityType, organization: campaignContext.organization, methodology: vector.nameKo, methodologyCode: vector.methodologyCode, registry: vector.registry as MethodologyMatch['registry'], fitScore: score, status: score >= 80 ? 'matched' : 'review', matchedAt: new Date().toISOString(), reductionTons: campaignContext.reductionTons, sourceFileId: campaignContext.sourceFileId, sourceFileName: campaignContext.sourceFileName, dataRows: campaignContext.dataRows, emissionFactor, requiredParams: vector.requiredParams, description: vector.applicability, } } // --- Query all methodology vectors (admin) --- export async function getMethodologyVectors(): Promise { const { data, error } = await supabase .from('methodology_vectors') .select('id, methodology_code, registry, name_ko, name_en, applicability, emission_factors, required_params') .order('methodology_code', { ascending: true }) if (error) throw error return (data ?? []).map((row) => ({ id: row.id, methodologyCode: row.methodology_code, registry: row.registry, nameKo: row.name_ko, nameEn: row.name_en, applicability: row.applicability, emissionFactors: row.emission_factors, requiredParams: row.required_params ?? [], similarity: 0, })) } // --- Local Knowledge Base for Methodology Suitability & Data Specification Fallback --- const LOCAL_KNOWLEDGE: Record> = { "GS-PWR-v3.2": { eligibilityScore: 96, explanation: "GS-PWR-v3.2 방법론은 텀블러·다회용기 사용을 통해 음용 소비 시 사용되는 일회용 컵(종이/플라스틱) 발생을 억제하여, 생산 및 매립/소각 주기의 온실가스 배출을 원천적으로 회피합니다. 제주 다회용 컵 파일럿 등 일상 속 시민 참여형 순환 행동에 극우수한 적격성을 지닙니다. [Waste Prevention and Avoidance of Single-use Cups]", keyConstraints: [ "1회 사용당 일회용컵 1개(종이컵 기본 배출계수 21gCO2e) 대체 산정", "동일 가맹점 내 24시간 내 반복 참여 어뷰징 방지 필터 요함 (QA/QC 등급 지정)" ], requiredData: [ { parameter: "cup_count", name: "다회용 컵 사용 횟수 (회)", purpose: "회피된 컵 개수당 BE(베이스라인 배출량) 정밀 산정", source: "영수증 OCR 할인 인식 / POS API 로그" }, { parameter: "store_id", name: "가맹점 ID 및 상호명", purpose: "실재 가맹점 대조 및 지역 감축 공헌 추적", source: "영수증 상호/주소 파싱" } ], recommendedData: [ { parameter: "gps_coords", name: "GPS 위치 정보", purpose: "동일 가맹점 내 초고빈도 어뷰징 방지 교차 대조", source: "단말기 위치 서비스" }, { parameter: "photo_proof", name: "텀블러/다회용컵 사용 실물 사진", purpose: "위조 영수증 의심 시 2차 보조 MRV 검증 증빙", source: "참여자 실시간 사진 촬영" } ] }, "K-VER-LIVESTOCK": { eligibilityScore: 92, explanation: "K-VER-LIVESTOCK 방법론은 반추동물(소/젖소 등)의 장내 발효로 인한 메탄가스(CH4) 배출을 저메탄 사료 급여를 통해 물리적으로 저감합니다. 대단위 축산 농가 및 공공 기금 지원 사업에 높은 정밀성을 가집니다. [Enteric Methane Reduction via Low-Methane Feed]", keyConstraints: [ "농림축산식품부 또는 공인 검증원 승인 저메탄 사료 공급명세서 대조 필수", "농장주 사료 구입 영수증 및 급여 두수(마리수) 일일 급여 대장 감사 증빙 필수" ], requiredData: [ { parameter: "feed_purchase_tons", name: "저메탄 사료 구입량 (톤)", purpose: "공급 대비 적정 급여량 환산 및 베이스라인 대조", source: "사료 공급 증명서 / 매입 계산서" }, { parameter: "head_count", name: "급여 대상 가축 두수 (두)", purpose: "마리당 기준 급여량 및 배출 원단위 일치 확인", source: "이력관리시스템 등록 정보" } ], recommendedData: [ { parameter: "monthly_milk_yield", name: "월별 우유 생산량/체중 통계", purpose: "급여 활동으로 인한 생산성 변화 및 간접 영향 모니터링", source: "농가 출하 대장" } ] }, "VCS-VM0042": { eligibilityScore: 94, explanation: "VCS-VM0042 방법론은 화석연료를 연소하는 기존 이동 차량(승용차/버스 등)을 전기 자전거, 일반 자전거 및 도보 등 무동력/친환경 교통수단으로 대체하여 대기 중 직접적인 배출 유입을 차단합니다. [Non-Motorized and Active Transport System conversion]", keyConstraints: [ "참여자의 무동력 이동 거리 및 대체 교통편 실재성 입증을 위한 GPS 데이터 필수", "이중 계산 방지를 위해 기타 탄소 크레딧 중복 거래 여부 검증 요망" ], requiredData: [ { parameter: "trip_distance_km", name: "이동 거리 (km)", purpose: "수단 전환에 따른 베이스라인 가솔린 연소 대체량 산정", source: "단말기 GPS 트랙 실적" }, { parameter: "mode_of_transport", name: "이동 수단 유형 (자전거/도보)", purpose: "이동수단별 배출 원단위 및 칼로리 소비 매칭", source: "센서 데이터 또는 UI 선택" } ], recommendedData: [ { parameter: "gps_waypoints", name: "GPS 웨이포인트 경로", purpose: "평균 이동 속도 판정을 통해 차량 탑승 부정행위 필터링 QA/QC", source: "단말기 백그라운드 위치 수집" } ] } }; /** * 특정 탄소 방법론에 대하여 캠페인과의 적합성(RAG) 설명 및 필수/권장 수집 데이터 사양을 반환합니다. * 우선적으로 Deno Edge Function을 통해 AI 진단을 생성하며, 실패/오프라인 시 클라이언트 로컬 RAG 템플릿으로 안전하게 폴백합니다. */ export async function explainMethodologySuitability( methodologyCode: string, campaignTitle: string, campaignDescription: string ): Promise { try { const data = await invokeEdgeFunction<{ methodologyCode: string campaignTitle: string campaignDescription: string }, MethodologyExplanation>('explain-methodology', { methodologyCode, campaignTitle, campaignDescription }); if (data && data.success) { return data; } throw new Error('invalid explain-methodology response structure'); } catch (err) { console.warn(`[methodologyMatcher] explain-methodology Edge Function 호출 실패, 로컬 RAG 템플릿 폴백:`, err); // Find local knowledge fallback const local = LOCAL_KNOWLEDGE[methodologyCode]; if (local) { return { success: true, ...local }; } // Generic fallback for unrecognized codes return { success: true, eligibilityScore: 70, explanation: `제시하신 ${methodologyCode} 탄소 방법론은 본 캠페인(${campaignTitle}) 기획서의 핵심 감축 목표에 대해 일반적인 적격성을 제공합니다. 프로젝트 공식 등록 전에 방법론의 세부 조항 및 활동 범위 적합성을 재확인하시기 바랍니다.`, keyConstraints: [ "방법론 고유의 감축 배출계수 타당성 검토 필요", "외부 레지스트리 공식 적격 심사(VVB)를 위한 최소 3개월의 데이터 수집 요함" ], requiredData: [ { parameter: "activity_quantity", name: "기본 감축 활동량", purpose: "활동당 배출량 절감 지표 산정", source: "사용자 증빙 제출 및 직접 입력" } ], recommendedData: [] }; } }