eodi-mcp / src /db /supabase_adapter.py
lovelymango's picture
Upload 25 files
978996e verified
"""
Supabase Adapter
================
Supabase(PostgreSQL + pgvector) ์—ฐ๋™ ์–ด๋Œ‘ํ„ฐ.
ํ˜ธํ…” ๋กœ์—ดํ‹ฐ ๋ฐ์ดํ„ฐ์˜ ์ €์žฅ ๋ฐ ๋ฒกํ„ฐ ๊ฒ€์ƒ‰์„ ๋‹ด๋‹นํ•ฉ๋‹ˆ๋‹ค.
ํ…Œ์ด๋ธ” ๊ตฌ์กฐ (supabase/migrations/20260109_001_kb_schema.sql ์ฐธ์กฐ):
- kb_documents: ๋ฌธ์„œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ + extracted_knowledge (JSONB)
- kb_chunks: ์ฒญํฌ ํ…์ŠคํŠธ + ๋ฒกํ„ฐ ์ž„๋ฒ ๋”ฉ (pgvector 768์ฐจ์›)
"""
import os
import json
from typing import Dict, Any, List, Optional
from datetime import datetime, timezone
from dotenv import load_dotenv
load_dotenv()
class SupabaseAdapter:
"""
Supabase ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์–ด๋Œ‘ํ„ฐ.
ํ™˜๊ฒฝ๋ณ€์ˆ˜:
SUPABASE_URL: Supabase ํ”„๋กœ์ ํŠธ URL
SUPABASE_KEY: Supabase anon/service_role ํ‚ค
"""
def __init__(
self,
url: Optional[str] = None,
key: Optional[str] = None
):
"""
Args:
url: Supabase URL (์—†์œผ๋ฉด ํ™˜๊ฒฝ๋ณ€์ˆ˜์—์„œ ๋กœ๋“œ)
key: Supabase API Key (์—†์œผ๋ฉด ํ™˜๊ฒฝ๋ณ€์ˆ˜์—์„œ ๋กœ๋“œ)
"""
from supabase import create_client, Client
self.url = url or os.getenv("SUPABASE_URL")
self.key = key or os.getenv("SUPABASE_KEY")
if not self.url or not self.key:
raise ValueError(
"SUPABASE_URL๊ณผ SUPABASE_KEY๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. "
"๋กœ์ปฌ: .env ํŒŒ์ผ์„ ํ™•์ธํ•˜์„ธ์š”. "
"HF Space: Settings > Repository secrets์— ์„ค์ •ํ•˜์„ธ์š”."
)
self.client: Client = create_client(self.url, self.key)
self._embeddings = None
@property
def embeddings(self):
"""Lazy ๋กœ๋”ฉ๋œ ์ž„๋ฒ ๋”ฉ ์ƒ์„ฑ๊ธฐ. EMBEDDING_MODEL ํ™˜๊ฒฝ๋ณ€์ˆ˜๋กœ ์„ ํƒ."""
if self._embeddings is None:
embedding_model = os.getenv("EMBEDDING_MODEL", "bge-m3").lower()
if embedding_model == "gemini":
from .embeddings import GeminiEmbeddings
self._embeddings = GeminiEmbeddings()
else: # ๊ธฐ๋ณธ๊ฐ’: bge-m3
from .bge_embeddings import BGEEmbeddings
self._embeddings = BGEEmbeddings()
return self._embeddings
# =========================================================================
# ๋ฌธ์„œ ์ €์žฅ (kb_documents ํ…Œ์ด๋ธ”)
# =========================================================================
def upsert_knowledge(
self,
doc_id: str,
chain: str,
source_file: str,
identity: Dict[str, Any],
extracted_knowledge: Dict[str, Any],
source_info: Optional[Dict[str, Any]] = None,
version_info: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
์ถ”์ถœ๋œ ์ง€์‹์„ Supabase์— ์ €์žฅ/์—…๋ฐ์ดํŠธ.
Args:
doc_id: ๊ณ ์œ  ๋ฌธ์„œ ID
chain: ํ˜ธํ…” ์ฒด์ธ (IHG, MARRIOTT, ACCOR ๋“ฑ)
source_file: ์†Œ์Šค ํŒŒ์ผ ๊ฒฝ๋กœ
identity: ๋ฌธ์„œ identity ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ
extracted_knowledge: ์ถ”์ถœ๋œ ์ง€์‹ (JSONB)
source_info: ์†Œ์Šค ์ •๋ณด (์„ ํƒ์ )
version_info: ๋ฒ„์ „ ์ •๋ณด (์„ ํƒ์ )
Returns:
์ €์žฅ๋œ ๋ ˆ์ฝ”๋“œ
"""
record = {
"doc_id": doc_id,
"chain": chain,
"source_file": source_file,
"identity": json.dumps(identity, ensure_ascii=False),
"extracted_knowledge": json.dumps(extracted_knowledge, ensure_ascii=False),
"source_info": json.dumps(source_info or {}, ensure_ascii=False),
"version_info": json.dumps(version_info or {}, ensure_ascii=False),
"updated_at": datetime.now(timezone.utc).isoformat()
}
try:
result = self.client.table("kb_documents").upsert(
record,
on_conflict="doc_id"
).execute()
return result.data[0] if result.data else record
except Exception as e:
raise RuntimeError(f"๋ฌธ์„œ ์ €์žฅ ์‹คํŒจ ({doc_id}): {e}") from e
def get_knowledge(self, doc_id: str) -> Optional[Dict[str, Any]]:
"""๋ฌธ์„œ ์กฐํšŒ"""
try:
result = self.client.table("kb_documents")\
.select("*")\
.eq("doc_id", doc_id)\
.execute()
if result.data:
record = result.data[0]
# JSONB ํ•„๋“œ ํŒŒ์‹ฑ
for field in ["identity", "extracted_knowledge", "source_info", "version_info"]:
if field in record and isinstance(record[field], str):
record[field] = json.loads(record[field])
return record
return None
except Exception as e:
raise RuntimeError(f"๋ฌธ์„œ ์กฐํšŒ ์‹คํŒจ ({doc_id}): {e}") from e
def list_documents(self, chain: Optional[str] = None) -> List[Dict[str, Any]]:
"""๋ฌธ์„œ ๋ชฉ๋ก ์กฐํšŒ"""
try:
query = self.client.table("kb_documents").select("doc_id, chain, source_file, updated_at")
if chain:
query = query.eq("chain", chain.upper())
result = query.execute()
return result.data or []
except Exception as e:
raise RuntimeError(f"๋ฌธ์„œ ๋ชฉ๋ก ์กฐํšŒ ์‹คํŒจ: {e}") from e
# =========================================================================
# ์ฒญํฌ ์ €์žฅ (kb_chunks ํ…Œ์ด๋ธ” + pgvector)
# =========================================================================
def upsert_chunks(
self,
chunks: List[Dict[str, Any]],
generate_embeddings: bool = True
) -> int:
"""
ํ…์ŠคํŠธ ์ฒญํฌ๋ฅผ ๋ฒกํ„ฐ ์ž„๋ฒ ๋”ฉ๊ณผ ํ•จ๊ป˜ ์ €์žฅ.
Args:
chunks: ์ฒญํฌ ๋ฆฌ์ŠคํŠธ. ๊ฐ ์ฒญํฌ๋Š” ๋‹ค์Œ ํ•„๋“œ ํฌํ•จ:
- chunk_id: ๊ณ ์œ  ID
- doc_id: ๋ถ€๋ชจ ๋ฌธ์„œ ID
- chain: ํ˜ธํ…” ์ฒด์ธ
- content: ์ฒญํฌ ํ…์ŠคํŠธ
- metadata: ์ถ”๊ฐ€ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ (์„ ํƒ์ )
generate_embeddings: ์ž„๋ฒ ๋”ฉ ์ƒ์„ฑ ์—ฌ๋ถ€
Returns:
์ €์žฅ๋œ ์ฒญํฌ ์ˆ˜
"""
if not chunks:
return 0
records = []
for chunk in chunks:
record = {
"chunk_id": chunk["chunk_id"],
"doc_id": chunk["doc_id"],
"chain": chunk.get("chain", "UNKNOWN"),
"chunk_type": chunk.get("metadata", {}).get("type"),
"content": chunk["content"],
"metadata": json.dumps(chunk.get("metadata", {}), ensure_ascii=False),
"updated_at": datetime.now(timezone.utc).isoformat()
}
# ์ž„๋ฒ ๋”ฉ ์ƒ์„ฑ
if generate_embeddings and chunk["content"]:
try:
embedding = self.embeddings.embed_text(chunk["content"])
record["embedding"] = embedding
except Exception as e:
print(f"โš ๏ธ ์ž„๋ฒ ๋”ฉ ์ƒ์„ฑ ์‹คํŒจ ({chunk['chunk_id']}): {e}")
record["embedding"] = None
records.append(record)
# ๋ฐฐ์น˜ ์ฒ˜๋ฆฌ (Supabase timeout ๋ฐฉ์ง€)
BATCH_SIZE = 30 # 50 โ†’ 30์œผ๋กœ ์ค„์—ฌ ์•ˆ์ •์„ฑ ํ–ฅ์ƒ
MAX_RETRIES = 3
BATCH_DELAY = 0.5 # ๋ฐฐ์น˜ ๊ฐ„ 0.5์ดˆ ๋”œ๋ ˆ์ด
total_saved = 0
import time
try:
for i in range(0, len(records), BATCH_SIZE):
batch = records[i:i + BATCH_SIZE]
# ์žฌ์‹œ๋„ ๋กœ์ง
for attempt in range(MAX_RETRIES):
try:
result = self.client.table("kb_chunks").upsert(
batch,
on_conflict="chunk_id"
).execute()
batch_saved = len(result.data) if result.data else len(batch)
total_saved += batch_saved
break # ์„ฑ๊ณต ์‹œ ์žฌ์‹œ๋„ ๋ฃจํ”„ ์ข…๋ฃŒ
except Exception as e:
if attempt < MAX_RETRIES - 1:
print(f" โš ๏ธ ๋ฐฐ์น˜ ์ €์žฅ ์‹คํŒจ (์‹œ๋„ {attempt + 1}/{MAX_RETRIES}), ์žฌ์‹œ๋„ ์ค‘...")
time.sleep(2) # ์žฌ์‹œ๋„ ์ „ 2์ดˆ ๋Œ€๊ธฐ
else:
raise # ๋งˆ์ง€๋ง‰ ์‹œ๋„ ์‹คํŒจ ์‹œ ์˜ˆ์™ธ ์ „ํŒŒ
# ์ง„ํ–‰ ์ƒํ™ฉ ์ถœ๋ ฅ (100๊ฐœ๋งˆ๋‹ค)
if (i + BATCH_SIZE) % 100 == 0 or i + BATCH_SIZE >= len(records):
print(f" ๐Ÿ’พ {min(i + BATCH_SIZE, len(records))}/{len(records)} ์ฒญํฌ ์ €์žฅ ์™„๋ฃŒ")
# ๋ฐฐ์น˜ ๊ฐ„ ๋”œ๋ ˆ์ด (์„œ๋ฒ„ ์—ฐ๊ฒฐ ์œ ์ง€)
if i + BATCH_SIZE < len(records):
time.sleep(BATCH_DELAY)
return total_saved
except Exception as e:
raise RuntimeError(f"์ฒญํฌ ์ €์žฅ ์‹คํŒจ: {e}") from e
# =========================================================================
# ๋ฒกํ„ฐ ๊ฒ€์ƒ‰ (Semantic Search)
# =========================================================================
def search_similar(
self,
query: str,
limit: int = 5,
chain: Optional[str] = None,
threshold: float = 0.5
) -> List[Dict[str, Any]]:
"""
์ฟผ๋ฆฌ์™€ ์œ ์‚ฌํ•œ ์ฒญํฌ๋ฅผ ๋ฒกํ„ฐ ๊ฒ€์ƒ‰.
Args:
query: ๊ฒ€์ƒ‰ ์ฟผ๋ฆฌ
limit: ๋ฐ˜ํ™˜ํ•  ์ตœ๋Œ€ ๊ฒฐ๊ณผ ์ˆ˜
chain: ํŠน์ • ์ฒด์ธ์œผ๋กœ ํ•„ํ„ฐ๋ง (์„ ํƒ์ )
threshold: ์ตœ์†Œ ์œ ์‚ฌ๋„ ์ž„๊ณ„๊ฐ’ (0-1)
Returns:
์œ ์‚ฌํ•œ ์ฒญํฌ ๋ฆฌ์ŠคํŠธ (์œ ์‚ฌ๋„ ์ ์ˆ˜ ํฌํ•จ)
"""
# ์ฟผ๋ฆฌ ์ž„๋ฒ ๋”ฉ ์ƒ์„ฑ
query_embedding = self.embeddings.embed_query(query)
try:
# Supabase RPC ํ•จ์ˆ˜ ํ˜ธ์ถœ (pgvector match_documents)
# ์ฃผ์˜: ์ด ํ•จ์ˆ˜๋Š” Supabase์—์„œ ๋ณ„๋„๋กœ ์ƒ์„ฑํ•ด์•ผ ํ•จ
result = self.client.rpc(
"match_kb_chunks",
{
"query_embedding": query_embedding,
"match_threshold": threshold,
"match_count": limit,
"filter_chain": chain
}
).execute()
return result.data or []
except Exception as e:
# RPC ํ•จ์ˆ˜๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ ๋Œ€์ฒด ๋ฐฉ๋ฒ• (๋น„ํšจ์œจ์ )
print(f"โš ๏ธ match_kb_chunks RPC ํ•จ์ˆ˜๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: {e}")
print(" supabase/migrations/20260109_001_kb_schema.sql์„ ์ฐธ์กฐํ•˜์„ธ์š”.")
return []
# =========================================================================
# ์œ ํ‹ธ๋ฆฌํ‹ฐ
# =========================================================================
def health_check(self) -> Dict[str, Any]:
"""์—ฐ๊ฒฐ ์ƒํƒœ ํ™•์ธ"""
try:
# ๊ฐ„๋‹จํ•œ ์ฟผ๋ฆฌ๋กœ ์—ฐ๊ฒฐ ํ…Œ์ŠคํŠธ
result = self.client.table("kb_documents")\
.select("count", count="exact")\
.limit(1)\
.execute()
return {
"status": "healthy",
"documents_count": result.count,
"url": self.url[:30] + "..."
}
except Exception as e:
return {
"status": "error",
"error": str(e)
}
def get_stats(self) -> Dict[str, Any]:
"""๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํ†ต๊ณ„"""
try:
docs = self.client.table("kb_documents")\
.select("chain", count="exact")\
.execute()
chunks = self.client.table("kb_chunks")\
.select("chain", count="exact")\
.execute()
return {
"documents": docs.count or 0,
"chunks": chunks.count or 0
}
except Exception as e:
return {"error": str(e)}
# =========================================================================
# ์‚ฌ์šฉ์ž ํ”„๋กœํ•„ (User Gate)
# =========================================================================
def get_user_profile(self, user_id: str) -> Optional[Dict[str, Any]]:
"""
์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์กฐํšŒ.
Args:
user_id: Supabase Auth UUID
Returns:
ํ”„๋กœํ•„ ์ •๋ณด ๋˜๋Š” None
"""
try:
result = self.client.table("user_profiles")\
.select("*")\
.eq("user_id", user_id)\
.execute()
return result.data[0] if result.data else None
except Exception as e:
print(f"โš ๏ธ ํ”„๋กœํ•„ ์กฐํšŒ ์‹คํŒจ ({user_id}): {e}")
return None
def upsert_user_profile(
self,
user_id: str,
preferred_airports: List[str] = None,
display_name: str = None
) -> Optional[Dict[str, Any]]:
"""
์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์ƒ์„ฑ/์ˆ˜์ •.
Args:
user_id: Supabase Auth UUID
preferred_airports: ์„ ํ˜ธ ๊ณตํ•ญ ์ฝ”๋“œ ๋ฆฌ์ŠคํŠธ
display_name: ํ‘œ์‹œ ์ด๋ฆ„
Returns:
์ €์žฅ๋œ ํ”„๋กœํ•„ ๋˜๋Š” None
"""
try:
data = {"user_id": user_id}
if preferred_airports is not None:
data["preferred_airports"] = preferred_airports
if display_name is not None:
data["display_name"] = display_name
result = self.client.table("user_profiles")\
.upsert(data, on_conflict="user_id")\
.execute()
return result.data[0] if result.data else None
except Exception as e:
print(f"โš ๏ธ ํ”„๋กœํ•„ ์ €์žฅ ์‹คํŒจ ({user_id}): {e}")
return None
# =========================================================================
# ๋ฉค๋ฒ„์‹ญ (User Gate)
# =========================================================================
def get_user_memberships(self, user_id: str) -> List[Dict[str, Any]]:
"""
์‚ฌ์šฉ์ž์˜ ๋ชจ๋“  ๋ฉค๋ฒ„์‹ญ ์กฐํšŒ.
Args:
user_id: Supabase Auth UUID
Returns:
๋ฉค๋ฒ„์‹ญ ๋ฆฌ์ŠคํŠธ
"""
try:
result = self.client.table("user_memberships")\
.select("*")\
.eq("user_id", user_id)\
.execute()
return result.data or []
except Exception as e:
print(f"โš ๏ธ ๋ฉค๋ฒ„์‹ญ ์กฐํšŒ ์‹คํŒจ ({user_id}): {e}")
return []
def upsert_membership(
self,
user_id: str,
chain: str,
tier: str,
expires_at: str = None
) -> Optional[Dict[str, Any]]:
"""
๋ฉค๋ฒ„์‹ญ ๋“ฑ๋ก/์ˆ˜์ •.
Args:
user_id: Supabase Auth UUID
chain: ํ˜ธํ…” ์ฒด์ธ ์ฝ”๋“œ (HILTON, MARRIOTT ๋“ฑ)
tier: ๋“ฑ๊ธ‰ (Gold, Platinum ๋“ฑ)
expires_at: ๋งŒ๋ฃŒ์ผ (์„ ํƒ, YYYY-MM-DD)
Returns:
์ €์žฅ๋œ ๋ฉค๋ฒ„์‹ญ ๋˜๋Š” None
"""
try:
data = {
"user_id": user_id,
"chain": chain.upper(),
"tier": tier
}
if expires_at:
data["expires_at"] = expires_at
# user_id + chain ๋ณตํ•ฉํ‚ค๋กœ upsert
# Supabase๋Š” on_conflict์— ๋ณตํ•ฉํ‚ค๋ฅผ ์ง์ ‘ ์ง€์›ํ•˜์ง€ ์•Š์œผ๋ฏ€๋กœ
# ๋จผ์ € ์‚ญ์ œ ํ›„ ์‚ฝ์ž…ํ•˜๋Š” ๋ฐฉ์‹ ์‚ฌ์šฉ
self.client.table("user_memberships")\
.delete()\
.eq("user_id", user_id)\
.eq("chain", chain.upper())\
.execute()
result = self.client.table("user_memberships")\
.insert(data)\
.execute()
return result.data[0] if result.data else None
except Exception as e:
print(f"โš ๏ธ ๋ฉค๋ฒ„์‹ญ ์ €์žฅ ์‹คํŒจ ({user_id}, {chain}): {e}")
return None
def delete_membership(self, user_id: str, chain: str) -> bool:
"""
๋ฉค๋ฒ„์‹ญ ์‚ญ์ œ.
Args:
user_id: Supabase Auth UUID
chain: ํ˜ธํ…” ์ฒด์ธ ์ฝ”๋“œ
Returns:
์‚ญ์ œ ์„ฑ๊ณต ์—ฌ๋ถ€
"""
try:
self.client.table("user_memberships")\
.delete()\
.eq("user_id", user_id)\
.eq("chain", chain.upper())\
.execute()
return True
except Exception as e:
print(f"โš ๏ธ ๋ฉค๋ฒ„์‹ญ ์‚ญ์ œ ์‹คํŒจ ({user_id}, {chain}): {e}")
return False
# =========================================================================
# ์‹ ์šฉ์นด๋“œ (Credit Cards)
# =========================================================================
def get_user_credit_cards(self, user_id: str) -> List[Dict[str, Any]]:
"""
์‚ฌ์šฉ์ž์˜ ๋ณด์œ  ์‹ ์šฉ์นด๋“œ ๋ชฉ๋ก ์กฐํšŒ.
Args:
user_id: Supabase Auth UUID
Returns:
์‹ ์šฉ์นด๋“œ ๋ฆฌ์ŠคํŠธ
"""
try:
result = self.client.table("user_credit_cards")\
.select("*")\
.eq("user_id", user_id)\
.eq("is_active", True)\
.order("created_at")\
.execute()
return result.data or []
except Exception as e:
print(f"โš ๏ธ ์‹ ์šฉ์นด๋“œ ์กฐํšŒ ์‹คํŒจ ({user_id}): {e}")
return []
def upsert_credit_card(
self,
user_id: str,
card_id: str,
card_name: str,
issuer_code: str,
region: str = "USA",
card_open_date: str = None,
anniversary_month: int = None,
annual_fee: float = None
) -> Optional[Dict[str, Any]]:
"""
์‹ ์šฉ์นด๋“œ ๋“ฑ๋ก/์ˆ˜์ •.
Args:
user_id: Supabase Auth UUID
card_id: ์นด๋“œ ๊ณ ์œ  ID (AMEX_PLATINUM_US ๋“ฑ)
card_name: ์นด๋“œ ์ด๋ฆ„
issuer_code: ๋ฐœ๊ธ‰์‚ฌ ์ฝ”๋“œ
region: ๋ฐœ๊ธ‰ ๊ตญ๊ฐ€
card_open_date: ์นด๋“œ ๊ฐœ์„ค์ผ (YYYY-MM-DD)
anniversary_month: ์—ฐํšŒ๋น„ ๊ฐฑ์‹  ์›” (1-12)
annual_fee: ์—ฐํšŒ๋น„
Returns:
์ €์žฅ๋œ ์นด๋“œ ์ •๋ณด ๋˜๋Š” None
"""
try:
data = {
"user_id": user_id,
"card_id": card_id.upper(),
"card_name": card_name,
"issuer_code": issuer_code.upper(),
"region": region.upper(),
"is_active": True
}
if card_open_date:
data["card_open_date"] = card_open_date
if anniversary_month:
data["anniversary_month"] = anniversary_month
if annual_fee is not None:
data["annual_fee_amount"] = annual_fee
# user_id + card_id ๋ณตํ•ฉํ‚ค๋กœ upsert
self.client.table("user_credit_cards")\
.delete()\
.eq("user_id", user_id)\
.eq("card_id", card_id.upper())\
.execute()
result = self.client.table("user_credit_cards")\
.insert(data)\
.execute()
return result.data[0] if result.data else None
except Exception as e:
print(f"โš ๏ธ ์‹ ์šฉ์นด๋“œ ์ €์žฅ ์‹คํŒจ ({user_id}, {card_id}): {e}")
return None
def delete_credit_card(self, user_id: str, card_id: str) -> bool:
"""
์‹ ์šฉ์นด๋“œ ์‚ญ์ œ (soft delete - is_active = False).
Args:
user_id: Supabase Auth UUID
card_id: ์นด๋“œ ๊ณ ์œ  ID
Returns:
์‚ญ์ œ ์„ฑ๊ณต ์—ฌ๋ถ€
"""
try:
self.client.table("user_credit_cards")\
.update({"is_active": False})\
.eq("user_id", user_id)\
.eq("card_id", card_id.upper())\
.execute()
return True
except Exception as e:
print(f"โš ๏ธ ์‹ ์šฉ์นด๋“œ ์‚ญ์ œ ์‹คํŒจ ({user_id}, {card_id}): {e}")
return False
# =========================================================================
# ํฌ๋ ˆ๋”ง ์‚ฌ์šฉ ์ถ”์  (Credit Usage Tracking)
# =========================================================================
def get_user_credit_usage(
self,
user_id: str,
card_id: str = None,
benefit_id: str = None
) -> List[Dict[str, Any]]:
"""
์‚ฌ์šฉ์ž์˜ ํฌ๋ ˆ๋”ง ์‚ฌ์šฉ ๊ธฐ๋ก ์กฐํšŒ.
Args:
user_id: Supabase Auth UUID
card_id: ํŠน์ • ์นด๋“œ๋กœ ํ•„ํ„ฐ๋ง (์„ ํƒ)
benefit_id: ํŠน์ • ํ˜œํƒ์œผ๋กœ ํ•„ํ„ฐ๋ง (์„ ํƒ)
Returns:
์‚ฌ์šฉ ๊ธฐ๋ก ๋ฆฌ์ŠคํŠธ
"""
try:
query = self.client.table("user_credit_usage")\
.select("*")\
.eq("user_id", user_id)
if card_id:
query = query.eq("card_id", card_id.upper())
if benefit_id:
query = query.eq("benefit_id", benefit_id.lower())
result = query.order("usage_period", desc=True).execute()
return result.data or []
except Exception as e:
print(f"โš ๏ธ ํฌ๋ ˆ๋”ง ์‚ฌ์šฉ ์กฐํšŒ ์‹คํŒจ ({user_id}): {e}")
return []
def upsert_credit_usage(
self,
user_id: str,
card_id: str,
benefit_id: str,
usage_period: str,
amount_used: float,
amount_limit: float,
currency: str = "USD",
usage_date: str = None,
description: str = None
) -> Optional[Dict[str, Any]]:
"""
ํฌ๋ ˆ๋”ง ์‚ฌ์šฉ ๊ธฐ๋ก ์ €์žฅ/์ˆ˜์ •.
Args:
user_id: Supabase Auth UUID
card_id: ์นด๋“œ ID
benefit_id: ํ˜œํƒ ID
usage_period: ์‚ฌ์šฉ ๊ธฐ๊ฐ„ (2026-H1, 2026-Q1, 2026-01 ๋“ฑ)
amount_used: ์‚ฌ์šฉ ๊ธˆ์•ก
amount_limit: ํ•œ๋„ ๊ธˆ์•ก
currency: ํ†ตํ™”
usage_date: ์‹ค์ œ ์‚ฌ์šฉ์ผ (YYYY-MM-DD)
description: ์‚ฌ์šฉ ๋‚ด์—ญ
Returns:
์ €์žฅ๋œ ๊ธฐ๋ก ๋˜๋Š” None
"""
try:
data = {
"user_id": user_id,
"card_id": card_id.upper(),
"benefit_id": benefit_id.lower(),
"usage_period": usage_period,
"amount_used": amount_used,
"amount_limit": amount_limit,
"currency": currency
}
if usage_date:
data["usage_date"] = usage_date
if description:
data["description"] = description
# user_id + card_id + benefit_id + usage_period ๋ณตํ•ฉํ‚ค๋กœ upsert
self.client.table("user_credit_usage")\
.delete()\
.eq("user_id", user_id)\
.eq("card_id", card_id.upper())\
.eq("benefit_id", benefit_id.lower())\
.eq("usage_period", usage_period)\
.execute()
result = self.client.table("user_credit_usage")\
.insert(data)\
.execute()
return result.data[0] if result.data else None
except Exception as e:
print(f"โš ๏ธ ํฌ๋ ˆ๋”ง ์‚ฌ์šฉ ์ €์žฅ ์‹คํŒจ ({user_id}, {card_id}, {benefit_id}): {e}")
return None
def delete_credit_usage(
self,
user_id: str,
card_id: str,
benefit_id: str,
usage_period: str
) -> bool:
"""
ํฌ๋ ˆ๋”ง ์‚ฌ์šฉ ๊ธฐ๋ก ์‚ญ์ œ.
Args:
user_id: Supabase Auth UUID
card_id: ์นด๋“œ ID
benefit_id: ํ˜œํƒ ID
usage_period: ์‚ฌ์šฉ ๊ธฐ๊ฐ„
Returns:
์‚ญ์ œ ์„ฑ๊ณต ์—ฌ๋ถ€
"""
try:
self.client.table("user_credit_usage")\
.delete()\
.eq("user_id", user_id)\
.eq("card_id", card_id.upper())\
.eq("benefit_id", benefit_id.lower())\
.eq("usage_period", usage_period)\
.execute()
return True
except Exception as e:
print(f"โš ๏ธ ํฌ๋ ˆ๋”ง ์‚ฌ์šฉ ์‚ญ์ œ ์‹คํŒจ: {e}")
return False
# =========================================================================
# Valuation Preferences (๊ฐ€์น˜ ํ‰๊ฐ€ ์„ค์ •)
# =========================================================================
def get_valuation_preferences(self, user_id: str) -> Optional[Dict[str, Any]]:
"""
์‚ฌ์šฉ์ž์˜ ๊ฐ€์น˜ ํ‰๊ฐ€ ์„ค์ • ์กฐํšŒ.
Args:
user_id: Supabase Auth UUID
Returns:
์„ค์ • ์ •๋ณด ๋˜๋Š” None
"""
try:
result = self.client.table("user_valuation_preferences")\
.select("*")\
.eq("user_id", user_id)\
.execute()
return result.data[0] if result.data else None
except Exception as e:
print(f"โš ๏ธ ๊ฐ€์น˜ ํ‰๊ฐ€ ์„ค์ • ์กฐํšŒ ์‹คํŒจ ({user_id}): {e}")
return None
def upsert_valuation_preferences(
self,
user_id: str,
travel_style: str = "VALUE",
custom_valuations: Optional[Dict[str, float]] = None
) -> Optional[Dict[str, Any]]:
"""
์‚ฌ์šฉ์ž์˜ ๊ฐ€์น˜ ํ‰๊ฐ€ ์„ค์ • ์ €์žฅ (Upsert).
Args:
user_id: Supabase Auth UUID
travel_style: ์—ฌํ–‰ ์Šคํƒ€์ผ (PREMIUM, VALUE, CASHBACK)
custom_valuations: ๊ฐœ๋ณ„ ํ”„๋กœ๊ทธ๋žจ ๊ฐ€์น˜ ์˜ค๋ฒ„๋ผ์ด๋“œ
Returns:
์ €์žฅ๋œ ์„ค์ • ๋˜๋Š” None
"""
try:
data = {
"user_id": user_id,
"travel_style": travel_style.upper(),
"custom_valuations": custom_valuations or {}
}
# user_id UNIQUE ์ œ์•ฝ์œผ๋กœ upsert
self.client.table("user_valuation_preferences")\
.delete()\
.eq("user_id", user_id)\
.execute()
result = self.client.table("user_valuation_preferences")\
.insert(data)\
.execute()
return result.data[0] if result.data else None
except Exception as e:
print(f"โš ๏ธ ๊ฐ€์น˜ ํ‰๊ฐ€ ์„ค์ • ์ €์žฅ ์‹คํŒจ ({user_id}): {e}")
return None
# =============================================================================
# Supabase SQL ์„ค์ • (์ฐธ๊ณ ์šฉ)
# =============================================================================
SUPABASE_SETUP_SQL = """
-- 1. pgvector ํ™•์žฅ ํ™œ์„ฑํ™”
CREATE EXTENSION IF NOT EXISTS vector;
-- 2. knowledge_documents ํ…Œ์ด๋ธ”
CREATE TABLE IF NOT EXISTS knowledge_documents (
id SERIAL PRIMARY KEY,
doc_id TEXT UNIQUE NOT NULL,
chain TEXT NOT NULL,
source_file TEXT,
identity JSONB,
extracted_knowledge JSONB,
source_info JSONB,
version_info JSONB,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_knowledge_chain ON knowledge_documents(chain);
CREATE INDEX idx_knowledge_doc_id ON knowledge_documents(doc_id);
-- 3. doc_chunks ํ…Œ์ด๋ธ” (๋ฒกํ„ฐ ์ €์žฅ)
CREATE TABLE IF NOT EXISTS doc_chunks (
id SERIAL PRIMARY KEY,
chunk_id TEXT UNIQUE NOT NULL,
doc_id TEXT REFERENCES knowledge_documents(doc_id),
chain TEXT NOT NULL,
content TEXT NOT NULL,
metadata JSONB,
embedding vector(768), -- Gemini text-embedding-004 = 768์ฐจ์›
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_chunks_doc_id ON doc_chunks(doc_id);
CREATE INDEX idx_chunks_chain ON doc_chunks(chain);
-- 4. ๋ฒกํ„ฐ ๊ฒ€์ƒ‰์šฉ ์ธ๋ฑ์Šค (HNSW - ๊ถŒ์žฅ)
CREATE INDEX ON doc_chunks USING hnsw (embedding vector_cosine_ops);
-- 5. ๋ฒกํ„ฐ ๊ฒ€์ƒ‰ RPC ํ•จ์ˆ˜
CREATE OR REPLACE FUNCTION match_chunks(
query_embedding vector(768),
match_threshold float,
match_count int,
filter_chain text DEFAULT NULL
)
RETURNS TABLE (
chunk_id text,
doc_id text,
chain text,
content text,
metadata jsonb,
similarity float
)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
SELECT
dc.chunk_id,
dc.doc_id,
dc.chain,
dc.content,
dc.metadata,
1 - (dc.embedding <=> query_embedding) AS similarity
FROM doc_chunks dc
WHERE
(filter_chain IS NULL OR dc.chain = filter_chain)
AND 1 - (dc.embedding <=> query_embedding) > match_threshold
ORDER BY dc.embedding <=> query_embedding
LIMIT match_count;
END;
$$;
"""
if __name__ == "__main__":
print("๐Ÿ—„๏ธ Supabase Adapter ํ…Œ์ŠคํŠธ")
print("=" * 50)
try:
adapter = SupabaseAdapter()
health = adapter.health_check()
print(f"์ƒํƒœ: {health['status']}")
if health['status'] == 'healthy':
print(f"๋ฌธ์„œ ์ˆ˜: {health.get('documents_count', 'N/A')}")
print("โœ… ์—ฐ๊ฒฐ ์„ฑ๊ณต")
else:
print(f"์˜ค๋ฅ˜: {health.get('error')}")
except Exception as e:
print(f"โŒ ์ดˆ๊ธฐํ™” ์‹คํŒจ: {e}")
print("\n๐Ÿ“‹ Supabase ์„ค์ • SQL:")
print(SUPABASE_SETUP_SQL[:500] + "...")