| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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<string, unknown> | null |
| requiredParams: string[] |
| similarity: number |
| } |
|
|
| export interface MatchResult { |
| matches: MethodologyVector[] |
| queryText: string |
| matchedAt: string |
| |
| source: 'edge-openai' | 'edge-pseudo' | 'client-pseudo' | 'text-jaccard' |
| } |
|
|
| const EMBED_DIM = 1536 |
|
|
| |
|
|
| 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<number>(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 |
| } |
|
|
| |
|
|
| 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) { |
| |
| console.warn('[methodologyMatcher] embed-text ํธ์ถ ์คํจ, ํด๋ผ์ด์ธํธ pseudo๋ก ํด๋ฐฑ:', err) |
| return { vec: clientPseudoEmbedding(text), source: 'client-pseudo' } |
| } |
| } |
|
|
| |
|
|
| function tokenize(s: string): Set<string> { |
| return new Set( |
| s |
| .toLowerCase() |
| .replace(/[^\p{L}\p{N}\s]/gu, ' ') |
| .split(/\s+/) |
| .filter((t) => t.length > 0), |
| ) |
| } |
|
|
| function jaccard(a: Set<string>, b: Set<string>): 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<MethodologyVector[]> { |
| 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) |
| } |
|
|
| |
|
|
| export async function matchMethodologies( |
| activityDescription: string, |
| matchCount = 5, |
| minScore = 0.5, |
| ): Promise<MatchResult> { |
| 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<string, unknown>) => ({ |
| 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<string, unknown>) ?? null, |
| requiredParams: (row.required_params as string[]) ?? [], |
| similarity: Number(row.similarity), |
| })) |
|
|
| return { |
| matches, |
| queryText: activityDescription, |
| matchedAt: new Date().toISOString(), |
| source: embedSource, |
| } |
| } catch (err) { |
| |
| console.warn('[methodologyMatcher] match_methodology RPC ์คํจ, ํ
์คํธ ์ ์ฌ๋ ํด๋ฐฑ:', err) |
| const matches = await textJaccardMatch(activityDescription, matchCount) |
| return { |
| matches, |
| queryText: activityDescription, |
| matchedAt: new Date().toISOString(), |
| source: 'text-jaccard', |
| } |
| } |
| } |
|
|
| |
|
|
| 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, |
| } |
| } |
|
|
| |
|
|
| export async function getMethodologyVectors(): Promise<MethodologyVector[]> { |
| 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, |
| })) |
| } |
|
|
| |
|
|
| const LOCAL_KNOWLEDGE: Record<string, Omit<MethodologyExplanation, 'success'>> = { |
| "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: "๋จ๋ง๊ธฐ ๋ฐฑ๊ทธ๋ผ์ด๋ ์์น ์์ง" } |
| ] |
| } |
| }; |
|
|
| |
| |
| |
| |
| export async function explainMethodologySuitability( |
| methodologyCode: string, |
| campaignTitle: string, |
| campaignDescription: string |
| ): Promise<MethodologyExplanation> { |
| 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); |
| |
| |
| const local = LOCAL_KNOWLEDGE[methodologyCode]; |
| if (local) { |
| return { |
| success: true, |
| ...local |
| }; |
| } |
|
|
| |
| return { |
| success: true, |
| eligibilityScore: 70, |
| explanation: `์ ์ํ์ ${methodologyCode} ํ์ ๋ฐฉ๋ฒ๋ก ์ ๋ณธ ์บ ํ์ธ(${campaignTitle}) ๊ธฐํ์์ ํต์ฌ ๊ฐ์ถ ๋ชฉํ์ ๋ํด ์ผ๋ฐ์ ์ธ ์ ๊ฒฉ์ฑ์ ์ ๊ณตํฉ๋๋ค. ํ๋ก์ ํธ ๊ณต์ ๋ฑ๋ก ์ ์ ๋ฐฉ๋ฒ๋ก ์ ์ธ๋ถ ์กฐํญ ๋ฐ ํ๋ ๋ฒ์ ์ ํฉ์ฑ์ ์ฌํ์ธํ์๊ธฐ ๋ฐ๋๋๋ค.`, |
| keyConstraints: [ |
| "๋ฐฉ๋ฒ๋ก ๊ณ ์ ์ ๊ฐ์ถ ๋ฐฐ์ถ๊ณ์ ํ๋น์ฑ ๊ฒํ ํ์", |
| "์ธ๋ถ ๋ ์ง์คํธ๋ฆฌ ๊ณต์ ์ ๊ฒฉ ์ฌ์ฌ(VVB)๋ฅผ ์ํ ์ต์ 3๊ฐ์์ ๋ฐ์ดํฐ ์์ง ์ํจ" |
| ], |
| requiredData: [ |
| { parameter: "activity_quantity", name: "๊ธฐ๋ณธ ๊ฐ์ถ ํ๋๋", purpose: "ํ๋๋น ๋ฐฐ์ถ๋ ์ ๊ฐ ์งํ ์ฐ์ ", source: "์ฌ์ฉ์ ์ฆ๋น ์ ์ถ ๋ฐ ์ง์ ์
๋ ฅ" } |
| ], |
| recommendedData: [] |
| }; |
| } |
| } |
|
|