lovelymango commited on
Commit
978996e
ยท
verified ยท
1 Parent(s): 2310db1

Upload 25 files

Browse files
scripts/chunk_handlers.py CHANGED
@@ -922,7 +922,14 @@ def handle_credit_cards(data: Any, context: Dict[str, Any]) -> List[Dict[str, An
922
  if family_fee and isinstance(family_fee, dict) and family_fee.get("amount"):
923
  content += f" - ๊ฐ€์กฑ์นด๋“œ: {family_fee.get('amount'):,} {family_fee.get('currency', '')}\n"
924
  if waiver_conds:
925
- content += f" - ๋ฉด์ œ ์กฐ๊ฑด: {', '.join(waiver_conds[:2])}\n"
 
 
 
 
 
 
 
926
 
927
  # ์นด๋“œ ์œ ํ˜• (Phase 3)
928
  if card_type:
@@ -985,7 +992,14 @@ def handle_credit_cards(data: Any, context: Dict[str, Any]) -> List[Dict[str, An
985
  if lounge_access or lounge_visits:
986
  content += "\n๋ผ์šด์ง€ ํ˜œํƒ:\n"
987
  if lounge_access:
988
- content += f" - ์ด์šฉ ๊ฐ€๋Šฅ: {', '.join(lounge_access[:5])}\n"
 
 
 
 
 
 
 
989
  if lounge_visits:
990
  content += f" - ์—ฐ๊ฐ„ ์ด์šฉ: {lounge_visits}ํšŒ\n"
991
  if lounge_guest:
@@ -1195,10 +1209,53 @@ def handle_pricing(data: Any, context: Dict[str, Any]) -> List[Dict[str, Any]]:
1195
  """pricing, pricing_2026 ๋“ฑ ๋‹ค์–‘ํ•œ ํ˜•์‹ ์ฒ˜๋ฆฌ"""
1196
  chunks = []
1197
  hotel_name, hotel_name_ko, chain = get_hotel_info(context)
1198
-
1199
  if data is None:
1200
  return chunks
1201
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1202
  # --- ํ˜•์‹ 1: annual_statistics ๊ตฌ์กฐ (Westin ๋“ฑ) ---
1203
  annual_stats = data.get("annual_statistics", {})
1204
  points_stats = annual_stats.get("points", {})
@@ -1330,10 +1387,60 @@ def handle_pricing_analysis(data: Any, context: Dict[str, Any]) -> List[Dict[str
1330
  """pricing_analysis ํ˜•์‹ ์ฒ˜๋ฆฌ (Josun Palace ๋“ฑ)"""
1331
  chunks = []
1332
  hotel_name, hotel_name_ko, chain = get_hotel_info(context)
1333
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1334
  points_stats = data.get("points_price_stats", {})
1335
  cash_stats = data.get("cash_price_stats", {})
1336
-
1337
  if not points_stats:
1338
  return chunks
1339
 
@@ -1989,35 +2096,104 @@ def handle_member_rates(data: Any, context: Dict[str, Any]) -> List[Dict[str, An
1989
  # ===========================================================================
1990
 
1991
  def handle_hotel_facilities(data: Any, context: Dict[str, Any]) -> List[Dict[str, Any]]:
1992
- """hotel_facilities, other_facilities ์ฒ˜๋ฆฌ"""
 
 
 
 
 
 
 
1993
  chunks = []
1994
  hotel_name, hotel_name_ko, chain = get_hotel_info(context)
1995
 
1996
  if data is None:
1997
  return chunks
1998
 
1999
- facilities = data if isinstance(data, list) else [data]
2000
-
2001
- # ์‹œ์„ค์„ ํ•˜๋‚˜์˜ ์ฒญํฌ๋กœ ํ†ตํ•ฉ
2002
  content = format_hotel_header(hotel_name, hotel_name_ko, chain)
2003
- content += "ํ˜ธํ…” ์‹œ์„ค:\n"
2004
 
2005
- for facility in facilities[:15]:
2006
- if isinstance(facility, dict):
2007
- name = facility.get("name", facility.get("facility_name", "N/A"))
2008
- desc = facility.get("description", "")
2009
- content += f" - {name}"
2010
- if desc:
2011
- content += f": {desc[:60]}"
2012
- content += "\n"
2013
- elif isinstance(facility, str):
2014
- content += f" - {facility}\n"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2015
 
2016
  if len(content) > 100:
2017
  chunks.append({
2018
  "content": content.strip(),
2019
  "metadata": {
2020
- "type": "hotel_facilities",
2021
  "hotel_name": hotel_name
2022
  }
2023
  })
@@ -2358,6 +2534,82 @@ def handle_dining_programs(data: Any, context: Dict[str, Any]) -> List[Dict[str,
2358
  return chunks
2359
 
2360
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2361
  # ===========================================================================
2362
  # ํ•ธ๋“ค๋Ÿฌ: ํ•ญ๊ณต ํ”„๋กœ๊ทธ๋žจ (Phase 2)
2363
  # ===========================================================================
@@ -4399,7 +4651,12 @@ def handle_route_classifications(data: Any, context: Dict[str, Any]) -> List[Dic
4399
  # ===========================================================================
4400
 
4401
  def handle_award_chart_entries(data: Any, context: Dict[str, Any]) -> List[Dict[str, Any]]:
4402
- """award_chart_entries ์ฒ˜๋ฆฌ - ์–ด์›Œ๋“œ ์ฐจํŠธ ์ƒ์„ธ ํ•ญ๋ชฉ"""
 
 
 
 
 
4403
  chunks = []
4404
  if not data:
4405
  return chunks
@@ -4407,62 +4664,124 @@ def handle_award_chart_entries(data: Any, context: Dict[str, Any]) -> List[Dict[
4407
  items = data if isinstance(data, list) else [data]
4408
  airline = context.get("chain", "UNKNOWN")
4409
 
4410
- # ์ง€์—ญ๋ณ„๋กœ ๊ทธ๋ฃนํ™”
4411
- region_entries = {}
4412
- for item in items:
4413
- region = item.get("region", "OTHER")
4414
- if region not in region_entries:
4415
- region_entries[region] = []
4416
- region_entries[region].append(item)
4417
 
4418
- for region, entries in region_entries.items():
4419
- content = f"# {airline} ์–ด์›Œ๋“œ ์ฐจํŠธ - {region}\n\n"
4420
-
4421
- for entry in entries:
4422
- route_type = entry.get("route_type", "")
4423
- trip_type = entry.get("trip_type", "")
4424
- trip_label = "์™•๋ณต" if trip_type == "ROUND_TRIP" else "ํŽธ๋„"
4425
 
4426
- content += f"## {route_type} ({trip_label})\n"
 
 
4427
 
4428
- # ์ขŒ์„ ๋“ฑ๊ธ‰๋ณ„ ๋งˆ์ผ๋ฆฌ์ง€
4429
- economy_low = entry.get("economy_low")
4430
- economy_high = entry.get("economy_high")
4431
- if economy_low or economy_high:
4432
- content += f"- **์ผ๋ฐ˜์„**: {economy_low:,}~{economy_high:,} ๋งˆ์ผ\n"
4433
 
4434
- premium_economy_low = entry.get("premium_economy_low")
4435
- premium_economy_high = entry.get("premium_economy_high")
4436
- if premium_economy_low or premium_economy_high:
4437
- content += f"- **ํ”„๋ฆฌ๋ฏธ์—„ ์ด์ฝ”๋…ธ๋ฏธ**: {premium_economy_low:,}~{premium_economy_high:,} ๋งˆ์ผ\n"
4438
 
4439
- business_low = entry.get("business_low")
4440
- business_high = entry.get("business_high")
4441
- if business_low or business_high:
4442
- content += f"- **๋น„์ฆˆ๋‹ˆ์Šค/ํ”„๋ ˆ์Šคํ‹ฐ์ง€**: {business_low:,}~{business_high:,} ๋งˆ์ผ\n"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4443
 
4444
- first_low = entry.get("first_low")
4445
- first_high = entry.get("first_high")
4446
- if first_low or first_high:
4447
- content += f"- **์ผ๋“ฑ์„**: {first_low:,}~{first_high:,} ๋งˆ์ผ\n"
4448
 
4449
- notes = entry.get("notes", "")
4450
- if notes:
4451
- content += f"- ์ฐธ๊ณ : {notes}\n"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4452
 
4453
- content += "\n"
4454
-
4455
- content += DISCLAIMER_SHORT
4456
-
4457
- if len(content) > 100:
4458
- chunks.append({
4459
- "content": content.strip(),
4460
- "metadata": {
4461
- "type": "award_chart_entries",
4462
- "airline": airline,
4463
- "region": region
4464
- }
4465
- })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4466
 
4467
  return chunks
4468
 
@@ -4739,7 +5058,13 @@ def handle_key_insights(data: Any, context: Dict[str, Any]) -> List[Dict[str, An
4739
 
4740
 
4741
  def handle_usage_recommendations(data: Any, context: Dict[str, Any]) -> List[Dict[str, Any]]:
4742
- """usage_recommendations ์ฒ˜๋ฆฌ - ๋งˆ์ผ๋ฆฌ์ง€ ์‚ฌ์šฉ ๊ถŒ์žฅ์‚ฌํ•ญ"""
 
 
 
 
 
 
4743
  chunks = []
4744
  if not data:
4745
  return chunks
@@ -4748,53 +5073,97 @@ def handle_usage_recommendations(data: Any, context: Dict[str, Any]) -> List[Dic
4748
 
4749
  content = f"# {airline} ๋งˆ์ผ๋ฆฌ์ง€ ์‚ฌ์šฉ ๊ถŒ์žฅ์‚ฌํ•ญ\n\n"
4750
 
4751
- # highly_recommended
4752
- highly_rec = data.get("highly_recommended", {})
4753
- if highly_rec:
4754
- desc = highly_rec.get("description", "๊ฐ•๋ ฅ ์ถ”์ฒœ")
4755
- content += f"## {desc}\n"
4756
- routes = highly_rec.get("routes", [])
4757
- for route in routes[:10]:
4758
- if isinstance(route, dict):
4759
- r_name = route.get("route", "")
4760
- cabin = route.get("cabin_class", "")
4761
- season = route.get("recommended_season", "")
4762
- eff_range = route.get("efficiency_range", "")
4763
- content += f"- **{r_name}** ({cabin}, {season}): {eff_range}์›/๋งˆ์ผ\n"
4764
- content += "\n"
4765
-
4766
- # recommended
4767
- rec = data.get("recommended", {})
4768
- if rec:
4769
- desc = rec.get("description", "์ถ”์ฒœ")
4770
- content += f"## {desc}\n"
4771
- routes = rec.get("routes", [])
4772
- for route in routes[:10]:
4773
- if isinstance(route, dict):
4774
- r_name = route.get("route", "")
4775
- cabin = route.get("cabin_class", "")
4776
- season = route.get("recommended_season", "")
4777
- eff_range = route.get("efficiency_range", "")
4778
- content += f"- **{r_name}** ({cabin}, {season}): {eff_range}์›/๋งˆ์ผ\n"
4779
- content += "\n"
4780
 
4781
- # not_recommended
4782
- not_rec = data.get("not_recommended", {})
4783
- if not_rec:
4784
- desc = not_rec.get("description", "๋น„์ถ”์ฒœ")
4785
- content += f"## {desc}\n"
4786
- routes = not_rec.get("routes", [])
4787
- for route in routes[:10]:
4788
- if isinstance(route, dict):
4789
- r_name = route.get("route", "")
4790
- cabin = route.get("cabin_class", "")
4791
- eff_range = route.get("efficiency_range", "")
4792
- content += f"- **{r_name}** ({cabin}): {eff_range}์›/๋งˆ์ผ\n"
4793
- content += "\n"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4794
 
4795
  content += DISCLAIMER_SHORT
4796
 
4797
- if len(content) > 100:
4798
  chunks.append({
4799
  "content": content.strip(),
4800
  "metadata": {
@@ -5598,6 +5967,116 @@ def handle_news_updates(data: Any, context: Dict[str, Any]) -> List[Dict[str, An
5598
  return chunks
5599
 
5600
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5601
  # ===========================================================================
5602
  # ํ‚ค-ํ•ธ๋“ค๋Ÿฌ ๋งคํ•‘
5603
  # ===========================================================================
@@ -5663,6 +6142,25 @@ CHUNK_HANDLERS = {
5663
  # ํ˜ธํ…” ์‹œ์„ค
5664
  "hotel_facilities": handle_hotel_facilities,
5665
  "other_facilities": handle_hotel_facilities,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5666
 
5667
  # ๊ฐ์‹ค ์œ ํ˜•
5668
  "room_types": handle_room_types,
@@ -5742,11 +6240,13 @@ CHUNK_HANDLERS = {
5742
  # --- ํ•ญ๊ณต ํ”„๋กœ๊ทธ๋žจ (Phase 2) ---
5743
  "airline_programs": handle_airline_programs,
5744
  "airline_program": handle_airline_programs,
 
5745
  "airline_tiers": handle_airline_tiers,
5746
  "airline_tier": handle_airline_tiers,
5747
  "award_charts": handle_award_charts,
5748
  "award_chart": handle_award_charts,
5749
  "airline_earning_rules": handle_airline_earning_rules,
 
5750
 
5751
  # --- ํ•ญ๊ณต ์šด์ž„ (Airline Fares) ---
5752
  "airline_fare_tables": handle_airline_fare_tables,
@@ -5871,6 +6371,74 @@ CHUNK_HANDLERS = {
5871
  "mileage_purchase_programs": handle_mileage_purchase_programs,
5872
  "price_summary": handle_mileage_price_summary,
5873
  "insights": handle_mileage_insights,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5874
  }
5875
 
5876
  # ์ค‘์ฒฉ ํ‚ค ์ง€์› (์˜ˆ: facts.pricing_analysis)
@@ -5933,6 +6501,18 @@ IGNORED_KEYS = {
5933
  "collection_date", # ์ˆ˜์ง‘์ผ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ
5934
  "related_documents", # ๊ด€๋ จ ๋ฌธ์„œ ์ฐธ์กฐ
5935
 
 
 
 
 
 
 
 
 
 
 
 
 
5936
  # Deprecated ํ‚ค (์‚ฌ์šฉํ•˜์ง€ ์•Š์Œ)
5937
  # "loyalty_programs" (๋ณต์ˆ˜) - ๋‹จ, ํ•˜์œ„ ํ˜ธํ™˜์„ฑ ์œ„ํ•ด ํ•ธ๋“ค๋Ÿฌ๋Š” ์œ ์ง€
5938
  }
 
922
  if family_fee and isinstance(family_fee, dict) and family_fee.get("amount"):
923
  content += f" - ๊ฐ€์กฑ์นด๋“œ: {family_fee.get('amount'):,} {family_fee.get('currency', '')}\n"
924
  if waiver_conds:
925
+ # Handle both list of strings and list of dicts
926
+ waiver_strs = []
927
+ for item in waiver_conds[:2]:
928
+ if isinstance(item, dict):
929
+ waiver_strs.append(item.get("summary", item.get("condition", str(item))))
930
+ else:
931
+ waiver_strs.append(str(item))
932
+ content += f" - ๋ฉด์ œ ์กฐ๊ฑด: {', '.join(waiver_strs)}\n"
933
 
934
  # ์นด๋“œ ์œ ํ˜• (Phase 3)
935
  if card_type:
 
992
  if lounge_access or lounge_visits:
993
  content += "\n๋ผ์šด์ง€ ํ˜œํƒ:\n"
994
  if lounge_access:
995
+ # Handle both list of strings and list of dicts
996
+ lounge_names = []
997
+ for item in lounge_access[:5]:
998
+ if isinstance(item, dict):
999
+ lounge_names.append(item.get("lounge_name", item.get("name", str(item))))
1000
+ else:
1001
+ lounge_names.append(str(item))
1002
+ content += f" - ์ด์šฉ ๊ฐ€๋Šฅ: {', '.join(lounge_names)}\n"
1003
  if lounge_visits:
1004
  content += f" - ์—ฐ๊ฐ„ ์ด์šฉ: {lounge_visits}ํšŒ\n"
1005
  if lounge_guest:
 
1209
  """pricing, pricing_2026 ๋“ฑ ๋‹ค์–‘ํ•œ ํ˜•์‹ ์ฒ˜๋ฆฌ"""
1210
  chunks = []
1211
  hotel_name, hotel_name_ko, chain = get_hotel_info(context)
1212
+
1213
  if data is None:
1214
  return chunks
1215
+
1216
+ # --- ๋ฆฌ์ŠคํŠธ ๊ตฌ์กฐ ์ฒ˜๋ฆฌ (raw_pricing_data ๋“ฑ) ---
1217
+ if isinstance(data, list):
1218
+ for item in data[:10]:
1219
+ if not isinstance(item, dict):
1220
+ continue
1221
+
1222
+ room_type = item.get("room_type_code", item.get("room_type", ""))
1223
+ room_name = item.get("room_type_full_name", "")
1224
+ rate_type = item.get("rate_type", "")
1225
+ rate_krw = item.get("rate_krw", 0)
1226
+ rate_usd = item.get("rate_usd_approx", 0)
1227
+
1228
+ content = format_hotel_header(hotel_name, hotel_name_ko, chain)
1229
+ content += f"๊ฐ์‹ค ์œ ํ˜•: {room_type}\n"
1230
+ if room_name:
1231
+ content += f"๊ฐ์‹ค๋ช…: {room_name}\n"
1232
+ if rate_type:
1233
+ content += f"์š”๊ธˆ ์œ ํ˜•: {rate_type}\n"
1234
+
1235
+ content += f"๊ฐ€๊ฒฉ:\n"
1236
+ if rate_krw:
1237
+ content += f" - KRW: โ‚ฉ{rate_krw:,}\n"
1238
+ if rate_usd:
1239
+ content += f" - USD: ${rate_usd}\n"
1240
+
1241
+ content += DISCLAIMER_PRICE
1242
+
1243
+ chunks.append({
1244
+ "content": content.strip(),
1245
+ "metadata": {
1246
+ "type": "room_pricing_raw",
1247
+ "hotel_name": hotel_name,
1248
+ "room_type": room_type,
1249
+ "rate_krw": rate_krw
1250
+ }
1251
+ })
1252
+
1253
+ return chunks
1254
+
1255
+ # ๋”•์…”๋„ˆ๋ฆฌ ๊ตฌ์กฐ๊ฐ€ ์•„๋‹ˆ๋ฉด ๋นˆ ๋ฐฐ์—ด ๋ฐ˜ํ™˜
1256
+ if not isinstance(data, dict):
1257
+ return chunks
1258
+
1259
  # --- ํ˜•์‹ 1: annual_statistics ๊ตฌ์กฐ (Westin ๋“ฑ) ---
1260
  annual_stats = data.get("annual_statistics", {})
1261
  points_stats = annual_stats.get("points", {})
 
1387
  """pricing_analysis ํ˜•์‹ ์ฒ˜๋ฆฌ (Josun Palace ๋“ฑ)"""
1388
  chunks = []
1389
  hotel_name, hotel_name_ko, chain = get_hotel_info(context)
1390
+
1391
+ # ๋ฆฌ์ŠคํŠธ ๊ตฌ์กฐ์ธ ๊ฒฝ์šฐ ์ฒ˜๋ฆฌ (ClassicTravel ๋“ฑ)
1392
+ if isinstance(data, list):
1393
+ for item in data[:10]:
1394
+ if not isinstance(item, dict):
1395
+ continue
1396
+
1397
+ hotel_id = item.get("hotel_id", "")
1398
+ item_hotel_name = item.get("hotel_name", hotel_name)
1399
+ search_date = item.get("search_date", "")
1400
+ stay_nights = item.get("stay_nights", 1)
1401
+ currency = item.get("currency", "KRW")
1402
+
1403
+ content = format_hotel_header(item_hotel_name, hotel_name_ko, chain)
1404
+ content += f"ํ˜ธํ…” ID: {hotel_id}\n"
1405
+ content += f"๊ฒ€์ƒ‰ ๋‚ ์งœ: {search_date}\n"
1406
+ content += f"์ˆ™๋ฐ• ๊ธฐ๊ฐ„: {stay_nights}๋ฐ•\n\n"
1407
+
1408
+ room_rates = item.get("room_rates", [])
1409
+ if room_rates:
1410
+ content += "๊ฐ์‹ค ์š”๊ธˆ:\n"
1411
+ for rate in room_rates[:5]:
1412
+ room_type = rate.get("room_type", "")
1413
+ rate_plan = rate.get("rate_plan", "")
1414
+ rate_desc = rate.get("rate_plan_description", "")
1415
+ price_krw = rate.get("price_krw", 0)
1416
+
1417
+ content += f" - {room_type}\n"
1418
+ content += f" ์š”๊ธˆ์ œ: {rate_plan}\n"
1419
+ if rate_desc:
1420
+ content += f" ์„ค๋ช…: {rate_desc}\n"
1421
+ content += f" ๊ฐ€๊ฒฉ: โ‚ฉ{price_krw:,}\n"
1422
+
1423
+ content += DISCLAIMER_PRICE
1424
+
1425
+ chunks.append({
1426
+ "content": content.strip(),
1427
+ "metadata": {
1428
+ "type": "room_pricing",
1429
+ "hotel_name": item_hotel_name,
1430
+ "hotel_id": hotel_id,
1431
+ "currency": currency
1432
+ }
1433
+ })
1434
+
1435
+ return chunks
1436
+
1437
+ # ๊ธฐ์กด ๋”•์…”๋„ˆ๋ฆฌ ๊ตฌ์กฐ ์ฒ˜๋ฆฌ
1438
+ if not isinstance(data, dict):
1439
+ return chunks
1440
+
1441
  points_stats = data.get("points_price_stats", {})
1442
  cash_stats = data.get("cash_price_stats", {})
1443
+
1444
  if not points_stats:
1445
  return chunks
1446
 
 
2096
  # ===========================================================================
2097
 
2098
  def handle_hotel_facilities(data: Any, context: Dict[str, Any]) -> List[Dict[str, Any]]:
2099
+ """hotel_facilities, other_facilities, hotel_amenities_summary,
2100
+ executive_lounge, pulse8_wellness ๋“ฑ ๋‹ค์–‘ํ•œ ์‹œ์„ค ๋ฐ์ดํ„ฐ ์ฒ˜๋ฆฌ
2101
+
2102
+ ์ง€์› ํ˜•ํƒœ:
2103
+ 1. ๋ฆฌ์ŠคํŠธ ํ˜•ํƒœ (๊ธฐ์กด hotel_facilities)
2104
+ 2. ๋”•์…”๋„ˆ๋ฆฌ ํ˜•ํƒœ (executive_lounge, pulse8_wellness ๋“ฑ)
2105
+ 3. ๋ฌธ์ž์—ด ๋ฆฌ์ŠคํŠธ (hotel_amenities_summary)
2106
+ """
2107
  chunks = []
2108
  hotel_name, hotel_name_ko, chain = get_hotel_info(context)
2109
 
2110
  if data is None:
2111
  return chunks
2112
 
 
 
 
2113
  content = format_hotel_header(hotel_name, hotel_name_ko, chain)
 
2114
 
2115
+ # ๋ฐ์ดํ„ฐ ํ˜•ํƒœ์— ๋”ฐ๋ฅธ ์ฒ˜๋ฆฌ
2116
+ if isinstance(data, dict):
2117
+ # ๋”•์…”๋„ˆ๋ฆฌ ํ˜•ํƒœ (executive_lounge, pulse8_wellness ๋“ฑ)
2118
+ facility_name = data.get("name", data.get("lounge_name", "์‹œ์„ค"))
2119
+ location = data.get("location", "")
2120
+ hours = data.get("operating_hours", "")
2121
+
2122
+ content += f"์‹œ์„ค: {facility_name}\n"
2123
+ if location:
2124
+ content += f"์œ„์น˜: {location}\n"
2125
+ if hours:
2126
+ content += f"์šด์˜์‹œ๊ฐ„: {hours}\n"
2127
+
2128
+ # ์ „๋ง
2129
+ if data.get("view"):
2130
+ content += f"์ „๋ง: {data.get('view')}\n"
2131
+
2132
+ # ์„œ๋น„์Šค/์‹œ์„ค ๋ชฉ๋ก
2133
+ services = data.get("services", data.get("facilities", []))
2134
+ if services:
2135
+ content += "\n์ œ๊ณต ์„œ๋น„์Šค/์‹œ์„ค:\n"
2136
+ for service in services[:10]:
2137
+ if isinstance(service, dict):
2138
+ s_name = service.get("name", "N/A")
2139
+ s_desc = service.get("description", "")
2140
+ content += f" - {s_name}"
2141
+ if s_desc:
2142
+ content += f": {s_desc[:60]}"
2143
+ content += "\n"
2144
+ else:
2145
+ content += f" - {service}\n"
2146
+
2147
+ # ๋“œ๋ ˆ์Šค์ฝ”๋“œ
2148
+ if data.get("dress_code"):
2149
+ content += f"\n๋“œ๋ ˆ์Šค์ฝ”๋“œ: {data.get('dress_code')}\n"
2150
+
2151
+ # ์—ฐ๋ น ์ œํ•œ
2152
+ if data.get("age_restriction"):
2153
+ content += f"์—ฐ๋ น ์ œํ•œ: {data.get('age_restriction')}\n"
2154
+
2155
+ # ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ํƒ€์ž… ๊ฒฐ์ •
2156
+ meta_type = "hotel_facility"
2157
+ if "lounge" in str(data).lower():
2158
+ meta_type = "executive_lounge"
2159
+ elif "wellness" in str(data).lower() or "pulse" in str(data).lower():
2160
+ meta_type = "wellness_facility"
2161
+
2162
+ elif isinstance(data, list):
2163
+ # ๋ฆฌ์ŠคํŠธ ํ˜•ํƒœ
2164
+ facilities = data
2165
+
2166
+ # ์ฒซ ๋ฒˆ์งธ ํ•ญ๋ชฉ ํ™•์ธํ•˜์—ฌ ํƒ€์ž… ๊ฒฐ์ •
2167
+ first_item = facilities[0] if facilities else None
2168
+
2169
+ if first_item and isinstance(first_item, str):
2170
+ # ๋ฌธ์ž์—ด ๋ฆฌ์ŠคํŠธ (hotel_amenities_summary)
2171
+ content += "ํ˜ธํ…” ์‹œ์„ค ๋ฐ ์„œ๋น„์Šค ์š”์•ฝ:\n"
2172
+ for amenity in facilities[:20]:
2173
+ content += f" โœ“ {amenity}\n"
2174
+ meta_type = "hotel_amenities_summary"
2175
+ else:
2176
+ # ๋”•์…”๋„ˆ๋ฆฌ ๋ฆฌ์ŠคํŠธ (๊ธฐ์กด hotel_facilities)
2177
+ content += "ํ˜ธํ…” ์‹œ์„ค:\n"
2178
+ for facility in facilities[:15]:
2179
+ if isinstance(facility, dict):
2180
+ name = facility.get("name", facility.get("facility_name", "N/A"))
2181
+ desc = facility.get("description", "")
2182
+ content += f" - {name}"
2183
+ if desc:
2184
+ content += f": {desc[:60]}"
2185
+ content += "\n"
2186
+ elif isinstance(facility, str):
2187
+ content += f" - {facility}\n"
2188
+ meta_type = "hotel_facilities"
2189
+ else:
2190
+ return chunks
2191
 
2192
  if len(content) > 100:
2193
  chunks.append({
2194
  "content": content.strip(),
2195
  "metadata": {
2196
+ "type": meta_type,
2197
  "hotel_name": hotel_name
2198
  }
2199
  })
 
2534
  return chunks
2535
 
2536
 
2537
+ # ===========================================================================
2538
+ # ํ•ธ๋“ค๋Ÿฌ: ํ•ญ๊ณต ํŒŒํŠธ๋„ˆ์‹ญ (Flying Blue โ†’ Korean Air ๋“ฑ)
2539
+ # ===========================================================================
2540
+
2541
+ def handle_airline_partnership(data: Any, context: Dict[str, Any]) -> List[Dict[str, Any]]:
2542
+ """airline_partnership ์ฒ˜๋ฆฌ - ํŒŒํŠธ๋„ˆ ํ”„๋กœ๊ทธ๋žจ์œผ๋กœ ํƒ€ํ•ญ๊ณต์‚ฌ ์˜ˆ์•ฝ ์ •๋ณด"""
2543
+ chunks = []
2544
+
2545
+ if not data or not isinstance(data, dict):
2546
+ return chunks
2547
+
2548
+ partner_program = data.get("partner_program", "N/A")
2549
+ operating_airline = data.get("operating_airline", "UNKNOWN")
2550
+ alliance = data.get("alliance", "")
2551
+ currency_name = data.get("currency_name", "ํฌ์ธํŠธ")
2552
+ pricing_model = data.get("pricing_model", "")
2553
+ pricing_desc = data.get("pricing_model_description", "")
2554
+
2555
+ content = f"""# {partner_program}๋กœ {operating_airline} ์˜ˆ์•ฝํ•˜๊ธฐ
2556
+
2557
+ **ํŒŒํŠธ๋„ˆ ํ”„๋กœ๊ทธ๋žจ**: {partner_program}
2558
+ **์šดํ•ญ ํ•ญ๊ณต์‚ฌ**: {operating_airline}
2559
+ **์ œํœด**: {alliance if alliance else "N/A"}
2560
+ **ํฌ์ธํŠธ ๋‹จ์œ„**: {currency_name}
2561
+ """
2562
+
2563
+ if pricing_model:
2564
+ content += f"**๊ฐ€๊ฒฉ ์ •์ฑ…**: {pricing_model}"
2565
+ if pricing_desc:
2566
+ content += f" - {pricing_desc}"
2567
+ content += "\n"
2568
+
2569
+ # ์šด์˜ ์ฃผ์ฒด
2570
+ operator = data.get("partner_program_operator")
2571
+ if operator:
2572
+ content += f"**ํ”„๋กœ๊ทธ๋žจ ์šด์˜**: {operator}\n"
2573
+
2574
+ # ์ œํ•œ์‚ฌํ•ญ
2575
+ restrictions = data.get("restrictions", [])
2576
+ if restrictions:
2577
+ content += "\n## โš ๏ธ ์˜ˆ์•ฝ ์ œํ•œ์‚ฌํ•ญ\n"
2578
+ for restriction in restrictions[:10]:
2579
+ if isinstance(restriction, dict):
2580
+ r_type = restriction.get("restriction_type", "")
2581
+ desc = restriction.get("description", "")
2582
+ content += f"- **{r_type}**: {desc}\n"
2583
+ else:
2584
+ content += f"- {restriction}\n"
2585
+
2586
+ # ์˜ˆ์•ฝ ๊ฐ€๋Šฅ ํ•ญ๋ชฉ
2587
+ available = data.get("available_booking_classes", [])
2588
+ if available:
2589
+ content += "\n## โœ… ์˜ˆ์•ฝ ๊ฐ€๋Šฅ ํด๋ž˜์Šค\n"
2590
+ for cls in available[:5]:
2591
+ if isinstance(cls, dict):
2592
+ cabin = cls.get("cabin_class", "")
2593
+ is_available = cls.get("available", True)
2594
+ status = "โœ… ๊ฐ€๋Šฅ" if is_available else "โŒ ๋ถˆ๊ฐ€"
2595
+ content += f"- {cabin}: {status}\n"
2596
+
2597
+ content += DISCLAIMER_SHORT
2598
+
2599
+ if len(content) > 100:
2600
+ chunks.append({
2601
+ "content": content.strip(),
2602
+ "metadata": {
2603
+ "type": "airline_partnership",
2604
+ "partner_program": partner_program,
2605
+ "operating_airline": operating_airline,
2606
+ "alliance": alliance
2607
+ }
2608
+ })
2609
+
2610
+ return chunks
2611
+
2612
+
2613
  # ===========================================================================
2614
  # ํ•ธ๋“ค๋Ÿฌ: ํ•ญ๊ณต ํ”„๋กœ๊ทธ๋žจ (Phase 2)
2615
  # ===========================================================================
 
4651
  # ===========================================================================
4652
 
4653
  def handle_award_chart_entries(data: Any, context: Dict[str, Any]) -> List[Dict[str, Any]]:
4654
+ """award_chart_entries ์ฒ˜๋ฆฌ - ์–ด์›Œ๋“œ ์ฐจํŠธ ์ƒ์„ธ ํ•ญ๋ชฉ
4655
+
4656
+ ๋‘ ๊ฐ€์ง€ ๊ตฌ์กฐ ์ง€์›:
4657
+ 1) Flat ๊ตฌ์กฐ: [{"region": "...", "route_type": "...", "economy_low": ...}, ...]
4658
+ 2) Nested ๊ตฌ์กฐ: [{"region": "...", "routes": [{"route": "...", ...}]}, ...]
4659
+ """
4660
  chunks = []
4661
  if not data:
4662
  return chunks
 
4664
  items = data if isinstance(data, list) else [data]
4665
  airline = context.get("chain", "UNKNOWN")
4666
 
4667
+ # ๊ตฌ์กฐ ํƒ์ง€: ์ฒซ ๋ฒˆ์งธ ํ•ญ๋ชฉ์— 'routes' ํ‚ค๊ฐ€ ์žˆ์œผ๋ฉด ์ค‘์ฒฉ ๊ตฌ์กฐ
4668
+ is_nested = any(isinstance(item, dict) and "routes" in item for item in items)
 
 
 
 
 
4669
 
4670
+ if is_nested:
4671
+ # === ์ค‘์ฒฉ ๊ตฌ์กฐ ์ฒ˜๋ฆฌ (Flying Blue ์Šคํƒ€์ผ) ===
4672
+ for region_data in items:
4673
+ if not isinstance(region_data, dict):
4674
+ continue
 
 
4675
 
4676
+ region = region_data.get("region", "OTHER")
4677
+ region_name = region_data.get("region_name", region)
4678
+ routes = region_data.get("routes", [])
4679
 
4680
+ if not routes:
4681
+ continue
 
 
 
4682
 
4683
+ content = f"# {airline} ์–ด์›Œ๋“œ ์ฐจํŠธ - {region_name}\n\n"
4684
+ content += "| ๋…ธ์„  | ์ถœ๋ฐœ | ๋„์ฐฉ | ์ด์ฝ”๋…ธ๋ฏธ | ๋น„์ฆˆ๋‹ˆ์Šค | ๋น„๊ณ  |\n"
4685
+ content += "|------|------|------|----------|----------|------|\n"
 
4686
 
4687
+ for route in routes[:50]: # ์ง€์—ญ๋‹น ์ตœ๋Œ€ 50๊ฐœ ๋…ธ์„ 
4688
+ if not isinstance(route, dict):
4689
+ continue
4690
+
4691
+ route_code = route.get("route", "")
4692
+ origin_name = route.get("origin_name", route.get("origin", ""))
4693
+ dest_name = route.get("destination_name", route.get("destination", ""))
4694
+
4695
+ # ์ด์ฝ”๋…ธ๋ฏธ/๋น„์ฆˆ๋‹ˆ์Šค ํฌ์ธํŠธ
4696
+ eco_low = route.get("economy_low")
4697
+ eco_high = route.get("economy_high")
4698
+ biz_low = route.get("business_low")
4699
+ biz_high = route.get("business_high")
4700
+
4701
+ eco_str = f"{eco_low:,}" if eco_low else "N/A"
4702
+ if eco_high and eco_high != eco_low:
4703
+ eco_str += f"~{eco_high:,}"
4704
+
4705
+ if biz_low:
4706
+ biz_str = f"{biz_low:,}"
4707
+ if biz_high and biz_high != biz_low:
4708
+ biz_str += f"~{biz_high:,}"
4709
+ else:
4710
+ biz_str = "N/A"
4711
+
4712
+ notes = route.get("notes", "") or ""
4713
+
4714
+ content += f"| {route_code} | {origin_name} | {dest_name} | {eco_str} | {biz_str} | {notes} |\n"
4715
 
4716
+ content += DISCLAIMER_SHORT
 
 
 
4717
 
4718
+ if len(content) > 150:
4719
+ chunks.append({
4720
+ "content": content.strip(),
4721
+ "metadata": {
4722
+ "type": "award_chart_entries",
4723
+ "airline": airline,
4724
+ "region": region,
4725
+ "region_name": region_name
4726
+ }
4727
+ })
4728
+ else:
4729
+ # === ๊ธฐ์กด Flat ๊ตฌ์กฐ ์ฒ˜๋ฆฌ ===
4730
+ region_entries = {}
4731
+ for item in items:
4732
+ region = item.get("region", "OTHER")
4733
+ if region not in region_entries:
4734
+ region_entries[region] = []
4735
+ region_entries[region].append(item)
4736
+
4737
+ for region, entries in region_entries.items():
4738
+ content = f"# {airline} ์–ด์›Œ๋“œ ์ฐจํŠธ - {region}\n\n"
4739
 
4740
+ for entry in entries:
4741
+ route_type = entry.get("route_type", "")
4742
+ trip_type = entry.get("trip_type", "")
4743
+ trip_label = "์™•๋ณต" if trip_type == "ROUND_TRIP" else "ํŽธ๋„"
4744
+
4745
+ content += f"## {route_type} ({trip_label})\n"
4746
+
4747
+ # ์ขŒ์„ ๋“ฑ๊ธ‰๋ณ„ ๋งˆ์ผ๋ฆฌ์ง€
4748
+ economy_low = entry.get("economy_low")
4749
+ economy_high = entry.get("economy_high")
4750
+ if economy_low or economy_high:
4751
+ content += f"- **์ผ๋ฐ˜์„**: {economy_low:,}~{economy_high:,} ๋งˆ์ผ\n"
4752
+
4753
+ premium_economy_low = entry.get("premium_economy_low")
4754
+ premium_economy_high = entry.get("premium_economy_high")
4755
+ if premium_economy_low or premium_economy_high:
4756
+ content += f"- **ํ”„๋ฆฌ๋ฏธ์—„ ์ด์ฝ”๋…ธ๋ฏธ**: {premium_economy_low:,}~{premium_economy_high:,} ๋งˆ์ผ\n"
4757
+
4758
+ business_low = entry.get("business_low")
4759
+ business_high = entry.get("business_high")
4760
+ if business_low or business_high:
4761
+ content += f"- **๋น„์ฆˆ๋‹ˆ์Šค/ํ”„๋ ˆ์Šคํ‹ฐ์ง€**: {business_low:,}~{business_high:,} ๋งˆ์ผ\n"
4762
+
4763
+ first_low = entry.get("first_low")
4764
+ first_high = entry.get("first_high")
4765
+ if first_low or first_high:
4766
+ content += f"- **์ผ๋“ฑ์„**: {first_low:,}~{first_high:,} ๋งˆ์ผ\n"
4767
+
4768
+ notes = entry.get("notes", "")
4769
+ if notes:
4770
+ content += f"- ์ฐธ๊ณ : {notes}\n"
4771
+
4772
+ content += "\n"
4773
+
4774
+ content += DISCLAIMER_SHORT
4775
+
4776
+ if len(content) > 100:
4777
+ chunks.append({
4778
+ "content": content.strip(),
4779
+ "metadata": {
4780
+ "type": "award_chart_entries",
4781
+ "airline": airline,
4782
+ "region": region
4783
+ }
4784
+ })
4785
 
4786
  return chunks
4787
 
 
5058
 
5059
 
5060
  def handle_usage_recommendations(data: Any, context: Dict[str, Any]) -> List[Dict[str, Any]]:
5061
+ """usage_recommendations ์ฒ˜๋ฆฌ - ๋งˆ์ผ๋ฆฌ์ง€ ์‚ฌ์šฉ ๊ถŒ์žฅ์‚ฌํ•ญ
5062
+
5063
+ ์„ธ ๊ฐ€์ง€ ๊ตฌ์กฐ ์ง€์›:
5064
+ 1) ์ตœ์ƒ์œ„ List ๊ตฌ์กฐ: [{"category": "...", "routes": [...]}]
5065
+ 2) Dict ๊ตฌ์กฐ (๊ฐ’์ด dict): {"highly_recommended": {"description": "...", "routes": [...]}}
5066
+ 3) Dict ๊ตฌ์กฐ (๊ฐ’์ด list): {"highly_recommended": [{route_data...}], "recommended": [...]}
5067
+ """
5068
  chunks = []
5069
  if not data:
5070
  return chunks
 
5073
 
5074
  content = f"# {airline} ๋งˆ์ผ๋ฆฌ์ง€ ์‚ฌ์šฉ ๊ถŒ์žฅ์‚ฌํ•ญ\n\n"
5075
 
5076
+ # ์ตœ์ƒ์œ„๊ฐ€ ๋ฆฌ์ŠคํŠธ์ธ ๊ฒฝ์šฐ
5077
+ if isinstance(data, list):
5078
+ for idx, item in enumerate(data[:20]):
5079
+ if isinstance(item, dict):
5080
+ category = item.get("category", item.get("recommendation_type", f"ํ•ญ๋ชฉ {idx+1}"))
5081
+ desc = item.get("description", "")
5082
+ routes = item.get("routes", [])
5083
+
5084
+ content += f"## {category}\n"
5085
+ if desc:
5086
+ content += f"{desc}\n\n"
5087
+
5088
+ for route in routes[:10]:
5089
+ if isinstance(route, dict):
5090
+ r_name = route.get("route", route.get("route_code", ""))
5091
+ cabin = route.get("cabin_class", "")
5092
+ season = route.get("recommended_season", "")
5093
+ eff_range = route.get("efficiency_range", route.get("efficiency", ""))
5094
+ content += f"- **{r_name}** ({cabin}"
5095
+ if season:
5096
+ content += f", {season}"
5097
+ content += f"): {eff_range}\n"
5098
+ else:
5099
+ content += f"- {route}\n"
5100
+ content += "\n"
5101
+ else:
5102
+ # ๋‹จ์ˆœ ๋ฌธ์ž์—ด
5103
+ content += f"- {item}\n"
 
5104
 
5105
+ # ๋”•์…”๋„ˆ๋ฆฌ ๊ตฌ์กฐ ์ฒ˜๋ฆฌ
5106
+ elif isinstance(data, dict):
5107
+ categories = [
5108
+ ("highly_recommended", "๊ฐ•๋ ฅ ์ถ”์ฒœ", 5),
5109
+ ("recommended", "์ถ”์ฒœ", 3),
5110
+ ("not_recommended", "๋น„์ถ”์ฒœ", 1),
5111
+ ("booking_strategy", "์˜ˆ์•ฝ ์ „๋žต", 4),
5112
+ ]
5113
+
5114
+ for key, default_title, _ in categories:
5115
+ category_data = data.get(key)
5116
+ if not category_data:
5117
+ continue
5118
+
5119
+ # ๊ฐ’์ด ๋”•์…”๋„ˆ๋ฆฌ์ธ ๊ฒฝ์šฐ (๊ธฐ์กด ๊ตฌ์กฐ)
5120
+ if isinstance(category_data, dict):
5121
+ desc = category_data.get("description", default_title)
5122
+ content += f"## {desc}\n"
5123
+ routes = category_data.get("routes", [])
5124
+ for route in routes[:10]:
5125
+ if isinstance(route, dict):
5126
+ r_name = route.get("route", "")
5127
+ cabin = route.get("cabin_class", "")
5128
+ season = route.get("recommended_season", "")
5129
+ eff_range = route.get("efficiency_range", "")
5130
+ if key == "not_recommended":
5131
+ content += f"- **{r_name}** ({cabin}): {eff_range}์›/๋งˆ์ผ\n"
5132
+ else:
5133
+ content += f"- **{r_name}** ({cabin}, {season}): {eff_range}์›/๋งˆ์ผ\n"
5134
+ content += "\n"
5135
+
5136
+ # ๊ฐ’์ด ๋ฆฌ์ŠคํŠธ์ธ ๊ฒฝ์šฐ (Aeroplan ๊ตฌ์กฐ)
5137
+ elif isinstance(category_data, list):
5138
+ content += f"## {default_title}\n"
5139
+ for item in category_data[:10]:
5140
+ if isinstance(item, dict):
5141
+ # route_category ๋˜๋Š” route
5142
+ route_cat = item.get("route_category", item.get("route", ""))
5143
+ cabin = item.get("cabin_class", "")
5144
+ points = item.get("points_required", "")
5145
+ recommendation = item.get("recommendation", item.get("reason", ""))
5146
+ title = item.get("title", "")
5147
+ desc = item.get("description", "")
5148
+
5149
+ if title: # booking_strategy ํ˜•์‹
5150
+ content += f"- **{title}**: {desc}\n"
5151
+ elif route_cat:
5152
+ content += f"- **{route_cat}** ({cabin}"
5153
+ if points:
5154
+ # points๊ฐ€ ์ˆซ์ž์ธ์ง€ ํ™•์ธ ํ›„ ์ฒœ ๋‹จ์œ„ ๊ตฌ๋ถ„์ž ์ ์šฉ
5155
+ if isinstance(points, (int, float)):
5156
+ content += f", {points:,}pts"
5157
+ else:
5158
+ content += f", {points}pts"
5159
+ content += f"): {recommendation}\n"
5160
+ else:
5161
+ content += f"- {item}\n"
5162
+ content += "\n"
5163
 
5164
  content += DISCLAIMER_SHORT
5165
 
5166
+ if len(content) > 150:
5167
  chunks.append({
5168
  "content": content.strip(),
5169
  "metadata": {
 
5967
  return chunks
5968
 
5969
 
5970
+
5971
+ # ===========================================================================
5972
+ # ํ•ธ๋“ค๋Ÿฌ: ์›”๋ณ„ ๊ฐ€๊ฒฉ ์ƒ์„ธ (ํ˜„๊ธˆ/ํฌ์ธํŠธ)
5973
+ # ===========================================================================
5974
+
5975
+ def handle_monthly_prices_detailed(data: Any, context: Dict[str, Any]) -> List[Dict[str, Any]]:
5976
+ """monthly_cash_prices_detailed, monthly_points_prices_detailed ์ฒ˜๋ฆฌ
5977
+
5978
+ ์›”๋ณ„ ๋‚ ์งœ๋ณ„ ์ƒ์„ธ ๊ฐ€๊ฒฉ ๋ฐ์ดํ„ฐ๋ฅผ ์‹œ์ฆŒ๋ณ„๋กœ ์š”์•ฝํ•˜์—ฌ ์ฒญํฌ ์ƒ์„ฑ
5979
+ """
5980
+ chunks = []
5981
+ hotel_name, hotel_name_ko, chain = get_hotel_info(context)
5982
+
5983
+ if not isinstance(data, dict):
5984
+ return chunks
5985
+
5986
+ # ๊ฐ€๊ฒฉ ์œ ํ˜• ํŒ๋‹จ (ํฌ์ธํŠธ vs ํ˜„๊ธˆ)
5987
+ is_points = any("points" in str(list(v[0].keys()) if isinstance(v, list) and v else [])
5988
+ for v in data.values() if v)
5989
+
5990
+ price_type = "ํฌ์ธํŠธ" if is_points else "ํ˜„๊ธˆ"
5991
+ content_type = "monthly_points_prices" if is_points else "monthly_cash_prices"
5992
+
5993
+ content = format_hotel_header(hotel_name, hotel_name_ko, chain)
5994
+ content += f"์›”๋ณ„ {price_type} ๊ฐ€๊ฒฉ ์ƒ์„ธ:\n\n"
5995
+
5996
+ # ์›”๋ณ„๋กœ ํ†ต๊ณ„ ๊ณ„์‚ฐ
5997
+ for month_key, prices_list in list(data.items())[:12]:
5998
+ if not isinstance(prices_list, list) or not prices_list:
5999
+ continue
6000
+
6001
+ # ์›” ์ด๋ฆ„ ํฌ๋งท
6002
+ month_name = month_key.replace("_", " ").title()
6003
+
6004
+ # ๊ฐ€๊ฒฉ/ํฌ์ธํŠธ ์ถ”์ถœ
6005
+ if is_points:
6006
+ values = [p.get("points", 0) for p in prices_list if isinstance(p, dict) and p.get("points")]
6007
+ else:
6008
+ values = [p.get("price_krw", 0) for p in prices_list if isinstance(p, dict) and p.get("price_krw")]
6009
+
6010
+ if not values:
6011
+ continue
6012
+
6013
+ min_val = min(values)
6014
+ max_val = max(values)
6015
+
6016
+ if is_points:
6017
+ content += f"โ€ข {month_name}: {min_val:,}P ~ {max_val:,}P\n"
6018
+ else:
6019
+ content += f"โ€ข {month_name}: โ‚ฉ{min_val:,} ~ โ‚ฉ{max_val:,}\n"
6020
+
6021
+ content += DISCLAIMER_PRICE
6022
+
6023
+ if len(content) > 150:
6024
+ chunks.append({
6025
+ "content": content.strip(),
6026
+ "metadata": {
6027
+ "type": content_type,
6028
+ "hotel_name": hotel_name
6029
+ }
6030
+ })
6031
+
6032
+ return chunks
6033
+
6034
+
6035
+ # ===========================================================================
6036
+ # ํ•ธ๋“ค๋Ÿฌ: ๊ฐ์‹ค ์œ ํ˜•๋ณ„ ์š”๊ธˆ
6037
+ # ===========================================================================
6038
+
6039
+ def handle_room_rates_by_type(data: Any, context: Dict[str, Any]) -> List[Dict[str, Any]]:
6040
+ """room_rates_by_type ์ฒ˜๋ฆฌ - ๊ฐ์‹ค ์œ ํ˜•๋ณ„ ๋ฉค๋ฒ„ ์š”๊ธˆ"""
6041
+ chunks = []
6042
+ hotel_name, hotel_name_ko, chain = get_hotel_info(context)
6043
+
6044
+ if not isinstance(data, list) or not data:
6045
+ return chunks
6046
+
6047
+ content = format_hotel_header(hotel_name, hotel_name_ko, chain)
6048
+ content += "๊ฐ์‹ค ์œ ํ˜•๋ณ„ ๋ฉค๋ฒ„ ์š”๊ธˆ:\n\n"
6049
+
6050
+ for room in data[:15]:
6051
+ if not isinstance(room, dict):
6052
+ continue
6053
+
6054
+ room_type = room.get("room_type", "N/A")
6055
+ rate = room.get("member_rate_krw", room.get("rate", "N/A"))
6056
+ notes = room.get("notes", "")
6057
+
6058
+ if isinstance(rate, (int, float)):
6059
+ content += f"โ€ข {room_type}: โ‚ฉ{rate:,}"
6060
+ else:
6061
+ content += f"โ€ข {room_type}: {rate}"
6062
+
6063
+ if notes:
6064
+ content += f" ({notes})"
6065
+ content += "\n"
6066
+
6067
+ content += DISCLAIMER_PRICE
6068
+
6069
+ chunks.append({
6070
+ "content": content.strip(),
6071
+ "metadata": {
6072
+ "type": "room_rates_by_type",
6073
+ "hotel_name": hotel_name
6074
+ }
6075
+ })
6076
+
6077
+ return chunks
6078
+
6079
+
6080
  # ===========================================================================
6081
  # ํ‚ค-ํ•ธ๋“ค๋Ÿฌ ๋งคํ•‘
6082
  # ===========================================================================
 
6142
  # ํ˜ธํ…” ์‹œ์„ค
6143
  "hotel_facilities": handle_hotel_facilities,
6144
  "other_facilities": handle_hotel_facilities,
6145
+
6146
+ # ์ด๊ทธ์ œํํ‹ฐ๋ธŒ ๋ผ์šด์ง€ - hotel_facilities ํ•ธ๋“ค๋Ÿฌ๋กœ ํ†ตํ•ฉ ์ฒ˜๋ฆฌ
6147
+ "executive_lounge": handle_hotel_facilities,
6148
+
6149
+ # ์›ฐ๋‹ˆ์Šค ์‹œ์„ค - hotel_facilities ํ•ธ๋“ค๋Ÿฌ๋กœ ํ†ตํ•ฉ ์ฒ˜๋ฆฌ
6150
+ "pulse8_wellness": handle_hotel_facilities,
6151
+ "wellness_facility": handle_hotel_facilities,
6152
+ "spa_wellness": handle_hotel_facilities,
6153
+
6154
+ # ํ˜ธํ…” ์–ด๋ฉ”๋‹ˆํ‹ฐ ์š”์•ฝ - hotel_facilities ํ•ธ๋“ค๋Ÿฌ๋กœ ํ†ตํ•ฉ ์ฒ˜๋ฆฌ
6155
+ "hotel_amenities_summary": handle_hotel_facilities,
6156
+ "amenities_summary": handle_hotel_facilities,
6157
+
6158
+ # ์›”๋ณ„ ๊ฐ€๊ฒฉ ์ƒ์„ธ (๊ณ ์œ  ๊ตฌ์กฐ๋กœ ๋ณ„๋„ ํ•ธ๋“ค๋Ÿฌ ์œ ์ง€)
6159
+ "monthly_cash_prices_detailed": handle_monthly_prices_detailed,
6160
+ "monthly_points_prices_detailed": handle_monthly_prices_detailed,
6161
+
6162
+ # ๊ฐ์‹ค ์œ ํ˜•๋ณ„ ์š”๊ธˆ (๊ณ ์œ  ๊ตฌ์กฐ๋กœ ๋ณ„๋„ ํ•ธ๋“ค๋Ÿฌ ์œ ์ง€)
6163
+ "room_rates_by_type": handle_room_rates_by_type,
6164
 
6165
  # ๊ฐ์‹ค ์œ ํ˜•
6166
  "room_types": handle_room_types,
 
6240
  # --- ํ•ญ๊ณต ํ”„๋กœ๊ทธ๋žจ (Phase 2) ---
6241
  "airline_programs": handle_airline_programs,
6242
  "airline_program": handle_airline_programs,
6243
+ "airline_partnership": handle_airline_partnership, # Flying Blue โ†’ Korean Air ๋“ฑ
6244
  "airline_tiers": handle_airline_tiers,
6245
  "airline_tier": handle_airline_tiers,
6246
  "award_charts": handle_award_charts,
6247
  "award_chart": handle_award_charts,
6248
  "airline_earning_rules": handle_airline_earning_rules,
6249
+
6250
 
6251
  # --- ํ•ญ๊ณต ์šด์ž„ (Airline Fares) ---
6252
  "airline_fare_tables": handle_airline_fare_tables,
 
6371
  "mileage_purchase_programs": handle_mileage_purchase_programs,
6372
  "price_summary": handle_mileage_price_summary,
6373
  "insights": handle_mileage_insights,
6374
+
6375
+ # --- ๋ฏธ์ฒ˜๋ฆฌ ํ‚ค๋“ค์„ ์ ์ ˆํ•œ ํ•ธ๋“ค๋Ÿฌ์— ๋งคํ•‘ ---
6376
+ "raw_pricing_data": handle_pricing, # ClassicTravel ์›์‹œ ๊ฐ€๊ฒฉ ๋ฐ์ดํ„ฐ
6377
+ "rate_descriptions": handle_benefits, # ClassicTravel ์š”๊ธˆ ์„ค๋ช…
6378
+ "brunch_options": handle_breakfast, # ClassicTravel ๋ธŒ๋Ÿฐ์น˜ ์˜ต์…˜
6379
+ "regional_info": handle_hotel_facilities, # ClassicTravel ์ง€์—ญ ์ •๋ณด
6380
+ "breakfast_benefit_participating_brands": handle_benefits, # GHA Discovery ์กฐ์‹ ํ˜œํƒ ์ฐธ์—ฌ ๋ธŒ๋žœ๋“œ
6381
+ "direct_channels": handle_benefits, # GHA Discovery ์ง์ ‘ ์ฑ„๋„
6382
+ "irregular_prices_observed": handle_pricing, # ํ•ญ๊ณต์‚ฌ ๋ถˆ๊ทœ์น™ ๊ฐ€๊ฒฉ ๊ด€์ฐฐ
6383
+ "route_availability_patterns": handle_airline_programs, # ํ•ญ๊ณต์‚ฌ ๋…ธ์„  ๊ฐ€์šฉ์„ฑ ํŒจํ„ด
6384
+ "booking_policies": handle_benefits, # WhataHotel ์˜ˆ์•ฝ ์ •์ฑ… (์„œ๋น„์Šค ์ˆ˜์ˆ˜๋ฃŒ, ๊ฒฐ์ œ ์ •์ฑ…, ์ทจ์†Œ ์ •์ฑ…)
6385
+
6386
+ # --- AMEX Platinum ์นด๋“œ ์ „์šฉ ํ‚ค ๋งคํ•‘ ---
6387
+ "travel_programs": handle_benefits, # FHR, THC ๋“ฑ ์—ฌํ–‰ ํ”„๋กœ๊ทธ๋žจ
6388
+ "statement_credit_timing": handle_benefits, # ์Šคํ…Œ์ดํŠธ๋จผํŠธ ํฌ๋ ˆ๋”ง ์ฒ˜๋ฆฌ ์‹œ๊ฐ„
6389
+ "statement_credit_troubleshooting": handle_benefits, # ํฌ๋ ˆ๋”ง ๋ฌธ์ œ ํ•ด๊ฒฐ ํŒ
6390
+ "tier_eligibility_comparison": handle_benefits, # ๋“ฑ๊ธ‰ ์ž๊ฒฉ ๋น„๊ตํ‘œ
6391
+ "benefit_exclusions": handle_benefits, # ํ˜œํƒ ์ œ์™ธ ํ•ญ๋ชฉ
6392
+ "benefit_calendar": handle_benefits, # ํ˜œํƒ ์ผ์ • (์›”๊ฐ„/๋ถ„๊ธฐ/์—ฐ๊ฐ„)
6393
+ "contact_information": handle_benefits, # ์—ฐ๋ฝ์ฒ˜ ์ •๋ณด
6394
+ "important_notices": handle_benefits, # ์ค‘์š” ๊ณต์ง€์‚ฌํ•ญ
6395
+ "benefit_value_summary": handle_benefits, # ํ˜œํƒ ๊ฐ€์น˜ ์š”์•ฝ
6396
+
6397
+ # --- Chase ์นด๋“œ ๊ด€๋ จ ํ‚ค ๋งคํ•‘ ---
6398
+ "lounge_access_details": handle_benefits, # ๊ณตํ•ญ ๋ผ์šด์ง€ ์ ‘๊ทผ ์ •๋ณด
6399
+ "spending_tier_benefits": handle_benefits, # ์ง€์ถœ ๋“ฑ๊ธ‰๋ณ„ ํ˜œํƒ
6400
+ "balance_transfer_conditions": handle_benefits, # ์ž”์•ก ์ด์ฒด ์กฐ๊ฑด
6401
+ "chase_pay_over_time": handle_benefits, # Chase ํ• ๋ถ€ ๊ฒฐ์ œ ์˜ต์…˜
6402
+ "state_notices": handle_benefits, # ์ฃผ๋ณ„ ๊ณ ์ง€์‚ฌํ•ญ
6403
+ "mileageplus_integration": handle_benefits, # MileagePlus ์—ฐ๋™ ์ •๋ณด
6404
+ "privacy_legal": handle_benefits, # ๊ฐœ์ธ์ •๋ณด/๋ฒ•์  ๊ณ ์ง€
6405
+ "concierge_services": handle_benefits, # ์ปจ์‹œ์–ด์ง€ ์„œ๋น„์Šค
6406
+ "other_info": handle_benefits, # ๊ธฐํƒ€ ์ •๋ณด
6407
+
6408
+ # --- ์ถ”๊ฐ€ ๋ฏธ์ฒ˜๋ฆฌ ํ‚ค ๋งคํ•‘ (credit card ๊ด€๋ จ) ---
6409
+ "ihg_membership_earning_rules": handle_benefits, # IHG ๋ฉค๋ฒ„์‹ญ ์ ๋ฆฝ ๊ทœ์น™
6410
+ "points_expiration_policy": handle_benefits, # ํฌ์ธํŠธ ๋งŒ๋ฃŒ ์ •์ฑ…
6411
+ "terms_summary": handle_benefits, # ์•ฝ๊ด€ ์š”์•ฝ
6412
+ "hyatt_brands_reference": handle_hotel_brands, # Hyatt ๋ธŒ๋žœ๋“œ ๋ชฉ๋ก
6413
+ "ihg_program_earning_info": handle_benefits, # IHG ํ”„๋กœ๊ทธ๋žจ ์ ๋ฆฝ ์ •๋ณด
6414
+ "related_cards_comparison": handle_benefits, # ๊ด€๋ จ ์นด๋“œ ๋น„๊ต
6415
+ "value_analysis": handle_benefits, # ๊ฐ€์น˜ ๋ถ„์„
6416
+
6417
+ # --- Disney ์นด๋“œ ์ „์šฉ ํ‚ค ๋งคํ•‘ ---
6418
+ "dining_eligible_restaurants": handle_benefits, # Disney ์‹๋‹น ํ• ์ธ ๋Œ€์ƒ ๋ ˆ์Šคํ† ๋ž‘
6419
+ "tour_eligible_experiences": handle_benefits, # Disney ํˆฌ์–ด/์ฒดํ—˜ ํ• ์ธ ๋Œ€์ƒ
6420
+ "photo_opportunities": handle_benefits, # Disney ์นด๋“œ ํฌํ†  ๊ธฐํšŒ
6421
+
6422
+ # --- IHG/United ์นด๋“œ ๊ด€๋ จ ํ‚ค ๋งคํ•‘ ---
6423
+ "airline_program_integration": handle_benefits, # ํ•ญ๊ณต์‚ฌ ํ”„๋กœ๊ทธ๋žจ ์—ฐ๋™
6424
+
6425
+ # --- ์ถ”๊ฐ€ ๋ฏธ์ฒ˜๋ฆฌ ํ‚ค ๋งคํ•‘ (Citi/Barclays/BoA ์นด๋“œ) ---
6426
+ "aadvantage_program_tiers": handle_benefits, # AAdvantage ํ”„๋กœ๊ทธ๋žจ ๋“ฑ๊ธ‰
6427
+ "american_eagle_operators": handle_benefits, # American Eagle ์šดํ•ญ์‚ฌ
6428
+ "additional_benefits": handle_benefits, # ์ถ”๊ฐ€ ํ˜œํƒ
6429
+ "insurance_benefits": handle_benefits, # ๋ณดํ—˜ ํ˜œํƒ
6430
+ "security_features": handle_benefits, # ๋ณด์•ˆ ๊ธฐ๋Šฅ
6431
+ "entertainment_program": handle_benefits, # ์—”ํ„ฐํ…Œ์ธ๋จผํŠธ ํ”„๋กœ๊ทธ๋žจ
6432
+ "annual_benefit_value_summary": handle_benefits, # ์—ฐ๊ฐ„ ํ˜œํƒ ๊ฐ€์น˜ ์š”์•ฝ
6433
+ "card_benefits": handle_benefits, # ์นด๋“œ ํ˜œํƒ (์ตœ์ƒ์œ„ ํ‚ค)
6434
+ "eligibility_requirements": handle_benefits, # ์ž๊ฒฉ ์š”๊ฑด
6435
+ "balance_transfer_terms": handle_benefits, # ์ž”์•ก ์ด์ฒด ์กฐ๊ฑด
6436
+ "cash_equivalent_transactions": handle_benefits, # ํ˜„๊ธˆ ๋“ฑ๊ฐ€ ๊ฑฐ๋ž˜
6437
+ "points_forfeiture_conditions": handle_benefits, # ํฌ์ธํŠธ ๋ชฐ์ˆ˜ ์กฐ๊ฑด
6438
+ "trueblue_integration": handle_benefits, # TrueBlue ์—ฐ๋™
6439
+ "trueblue_points_important_info": handle_benefits, # TrueBlue ํฌ์ธํŠธ ์ค‘์š” ์ •๋ณด
6440
+ "additional_info": handle_benefits, # ์ถ”๊ฐ€ ์ •๋ณด
6441
+ "partnership_programs": handle_benefits, # ํŒŒํŠธ๋„ˆ์‹ญ ํ”„๋กœ๊ทธ๋žจ
6442
  }
6443
 
6444
  # ์ค‘์ฒฉ ํ‚ค ์ง€์› (์˜ˆ: facts.pricing_analysis)
 
6501
  "collection_date", # ์ˆ˜์ง‘์ผ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ
6502
  "related_documents", # ๊ด€๋ จ ๋ฌธ์„œ ์ฐธ์กฐ
6503
 
6504
+ # AMEX ์นด๋“œ ๊ด€๋ จ ์ฆ๊ฑฐ์ž๋ฃŒ ๋ฐ ๋ฒ•์  ์ •๋ณด (๊ฒ€์ƒ‰ ๊ฐ€์น˜ ๋‚ฎ์Œ)
6505
+ "seller_of_travel", # ์—ฌํ–‰ ํŒ๋งค์ž ๋ฒ•์  ์ •๋ณด
6506
+ "statement_credit_evidence", # ์Šคํ…Œ์ดํŠธ๋จผํŠธ ํฌ๋ ˆ๋”ง ์ฆ๊ฑฐ
6507
+ "verification_needed_evidence", # ํ™•์ธ ํ•„์š” ์‚ฌํ•ญ ์ฆ๊ฑฐ
6508
+
6509
+ # Chase ์นด๋“œ ๊ด€๋ จ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ (๊ฒ€์ƒ‰ ๊ฐ€์น˜ ๋‚ฎ์Œ)
6510
+ "related_links", # ๊ด€๋ จ ๋งํฌ (URL ์ฐธ์กฐ์šฉ)
6511
+ "benefit_value_summary_total", # ํ˜œํƒ ๊ฐ€์น˜ ์ดํ•ฉ (๋ฉ”ํƒ€๋ฐ์ดํ„ฐ)
6512
+ "spending_tier_120k_additional", # ์ถ”๊ฐ€ ์ง€์ถœ ๋“ฑ๊ธ‰ (์ƒ์„ธ ๋‚ด์šฉ ์—†์Œ)
6513
+ "benefit_value_evidence", # ํ˜œํƒ ๊ฐ€์น˜ ์ฆ๊ฑฐ (๋‚ด๋ถ€์šฉ)
6514
+ "legal_notices", # ๋ฒ•์  ๊ณ ์ง€์‚ฌํ•ญ
6515
+
6516
  # Deprecated ํ‚ค (์‚ฌ์šฉํ•˜์ง€ ์•Š์Œ)
6517
  # "loyalty_programs" (๋ณต์ˆ˜) - ๋‹จ, ํ•˜์œ„ ํ˜ธํ™˜์„ฑ ์œ„ํ•ด ํ•ธ๋“ค๋Ÿฌ๋Š” ์œ ์ง€
6518
  }
src/auth/asset_parser.py ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Asset Text Parser
3
+ ==================
4
+
5
+ ์‚ฌ์šฉ์ž๊ฐ€ ์ž…๋ ฅํ•œ ํ…์ŠคํŠธ์—์„œ ํฌ์ธํŠธ/๋งˆ์ผ๋ฆฌ์ง€ ์ •๋ณด๋ฅผ ์ž๋™ ์ถ”์ถœํ•ฉ๋‹ˆ๋‹ค.
6
+
7
+ ์˜ˆ์‹œ ์ž…๋ ฅ:
8
+ - "๋Œ€ํ•œํ•ญ๊ณต 45,000 ๋งˆ์ผ"
9
+ - "AMEX MR 50000์ "
10
+ - "์ฒด์ด์Šค UR 30,000"
11
+ """
12
+
13
+ import re
14
+ from typing import Dict, Any, List, Optional
15
+ from datetime import datetime
16
+
17
+ from .config import POINT_VALUATIONS
18
+
19
+ # =============================================================================
20
+ # ํ”„๋กœ๊ทธ๋žจ ๋ณ„์นญ ๋งคํ•‘
21
+ # =============================================================================
22
+
23
+ PROGRAM_ALIASES = {
24
+ # ํ•œ๊ตญ ํ•ญ๊ณต
25
+ "๋Œ€ํ•œํ•ญ๊ณต": "KOREAN_AIR",
26
+ "๋Œ€ํ•œ": "KOREAN_AIR",
27
+ "kal": "KOREAN_AIR",
28
+ "korean air": "KOREAN_AIR",
29
+ "์Šค์นด์ดํŒจ์Šค": "KOREAN_AIR",
30
+
31
+ "์•„์‹œ์•„๋‚˜": "ASIANA",
32
+ "oz": "ASIANA",
33
+ "asiana": "ASIANA",
34
+
35
+ # ๋ฏธ๊ตญ ํ•ญ๊ณต
36
+ "delta": "DELTA_SKYMILES",
37
+ "๋ธํƒ€": "DELTA_SKYMILES",
38
+ "skymiles": "DELTA_SKYMILES",
39
+
40
+ "united": "UNITED_MILEAGEPLUS",
41
+ "์œ ๋‚˜์ดํ‹ฐ๋“œ": "UNITED_MILEAGEPLUS",
42
+ "mileageplus": "UNITED_MILEAGEPLUS",
43
+
44
+ "american": "AA_AADVANTAGE",
45
+ "aa": "AA_AADVANTAGE",
46
+ "์•„๋ฉ”๋ฆฌ์นธ": "AA_AADVANTAGE",
47
+ "aadvantage": "AA_AADVANTAGE",
48
+
49
+ # ์‹ ์šฉ์นด๋“œ ํฌ์ธํŠธ
50
+ "amex": "AMEX_MR",
51
+ "์•„๋ฉ•์Šค": "AMEX_MR",
52
+ "mr": "AMEX_MR",
53
+ "membership rewards": "AMEX_MR",
54
+
55
+ "chase": "CHASE_UR",
56
+ "์ฒด์ด์Šค": "CHASE_UR",
57
+ "ur": "CHASE_UR",
58
+ "ultimate rewards": "CHASE_UR",
59
+
60
+ "citi": "CITI_TYP",
61
+ "์‹œํ‹ฐ": "CITI_TYP",
62
+ "typ": "CITI_TYP",
63
+ "thankyou": "CITI_TYP",
64
+
65
+ "capital one": "CAPITAL_ONE",
66
+ "์บํ”ผํƒˆ์›": "CAPITAL_ONE",
67
+
68
+ # ํ˜ธํ…” ํฌ์ธํŠธ
69
+ "marriott": "MARRIOTT_BONVOY",
70
+ "๋ฉ”๋ฆฌ์–ดํŠธ": "MARRIOTT_BONVOY",
71
+ "๋ณธ๋ณด์ด": "MARRIOTT_BONVOY",
72
+ "bonvoy": "MARRIOTT_BONVOY",
73
+
74
+ "hilton": "HILTON_HONORS",
75
+ "ํžํŠผ": "HILTON_HONORS",
76
+ "honors": "HILTON_HONORS",
77
+
78
+ "ihg": "IHG_REWARDS",
79
+
80
+ "hyatt": "HYATT_WOH",
81
+ "ํ•˜์–ํŠธ": "HYATT_WOH",
82
+ "woh": "HYATT_WOH",
83
+ "world of hyatt": "HYATT_WOH",
84
+ }
85
+
86
+
87
+ # =============================================================================
88
+ # ํŒŒ์‹ฑ ํ•จ์ˆ˜
89
+ # =============================================================================
90
+
91
+ def parse_asset_text(text: str) -> List[Dict[str, Any]]:
92
+ """
93
+ ํ…์ŠคํŠธ์—์„œ ํฌ์ธํŠธ/๋งˆ์ผ๋ฆฌ์ง€ ์ •๋ณด๋ฅผ ์ถ”์ถœํ•ฉ๋‹ˆ๋‹ค.
94
+
95
+ Args:
96
+ text: ์‚ฌ์šฉ์ž ์ž…๋ ฅ ํ…์ŠคํŠธ
97
+
98
+ Returns:
99
+ ์ถ”์ถœ๋œ ์ž์‚ฐ ๋ชฉ๋ก [{"program": "...", "amount": ...}, ...]
100
+ """
101
+ assets = []
102
+
103
+ # ์ค„ ๋‹จ์œ„ + ์‰ผํ‘œ ๋‹จ์œ„๋กœ ๋ถ„๋ฆฌ
104
+ lines = re.split(r'[\n,]', text)
105
+
106
+ for line in lines:
107
+ line = line.strip()
108
+ if not line:
109
+ continue
110
+
111
+ result = _parse_single_line(line)
112
+ if result:
113
+ # ์ค‘๋ณต ์ œ๊ฑฐ (๊ฐ™์€ ํ”„๋กœ๊ทธ๋žจ์ด๋ฉด ํ•ฉ์‚ฐ)
114
+ existing = next(
115
+ (a for a in assets if a["program"] == result["program"]),
116
+ None
117
+ )
118
+ if existing:
119
+ existing["amount"] += result["amount"]
120
+ else:
121
+ assets.append(result)
122
+
123
+ return assets
124
+
125
+
126
+ def _parse_single_line(line: str) -> Optional[Dict[str, Any]]:
127
+ """๋‹จ์ผ ๋ผ์ธ์—์„œ ํ”„๋กœ๊ทธ๋žจ๊ณผ ์ˆ˜๋Ÿ‰ ์ถ”์ถœ."""
128
+ line_lower = line.lower()
129
+
130
+ # ์ˆซ์ž ์ถ”์ถœ (์‰ผํ‘œ ์ œ๊ฑฐ)
131
+ numbers = re.findall(r'[\d,]+', line)
132
+ if not numbers:
133
+ return None
134
+
135
+ # ๊ฐ€์žฅ ํฐ ์ˆซ์ž๋ฅผ amount๋กœ ์‚ฌ์šฉ
136
+ amount = max(int(n.replace(',', '')) for n in numbers if n.replace(',', '').isdigit())
137
+ if amount == 0:
138
+ return None
139
+
140
+ # ํ”„๋กœ๊ทธ๋žจ ์‹๋ณ„
141
+ program = None
142
+ for alias, prog_id in PROGRAM_ALIASES.items():
143
+ if alias in line_lower:
144
+ program = prog_id
145
+ break
146
+
147
+ if not program:
148
+ # ์ˆซ์ž๋งŒ ์žˆ๋Š” ๊ฒฝ์šฐ - ๋ฌธ๋งฅ์œผ๋กœ ์ถ”๋ก  ๋ถˆ๊ฐ€
149
+ return None
150
+
151
+ return {"program": program, "amount": amount}
152
+
153
+
154
+ def normalize_program_name(name: str) -> Optional[str]:
155
+ """ํ”„๋กœ๊ทธ๋žจ ๋ณ„์นญ์„ ์ •๊ทœํ™”ํ•ฉ๋‹ˆ๋‹ค."""
156
+ name_lower = name.lower().strip()
157
+ return PROGRAM_ALIASES.get(name_lower)
158
+
159
+
160
+ # =============================================================================
161
+ # MCP Tool ํ•ธ๋“ค๋Ÿฌ
162
+ # =============================================================================
163
+
164
+ async def handle_parse_asset_text(
165
+ arguments: Dict[str, Any],
166
+ user_id: str
167
+ ) -> Dict[str, Any]:
168
+ """
169
+ user_parse_asset_text Tool ํ•ธ๋“ค๋Ÿฌ.
170
+
171
+ ์‚ฌ์šฉ์ž ์ž…๋ ฅ ํ…์ŠคํŠธ์—์„œ ํฌ์ธํŠธ/๋งˆ์ผ๋ฆฌ์ง€๋ฅผ ์ž๋™ ์ถ”์ถœํ•ฉ๋‹ˆ๋‹ค.
172
+ """
173
+ text = arguments.get("text", "")
174
+ save_to_profile = arguments.get("save_to_profile", False)
175
+
176
+ if not text:
177
+ return {
178
+ "success": False,
179
+ "error": "ํŒŒ์‹ฑํ•  ํ…์ŠคํŠธ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.",
180
+ "usage": "์˜ˆ: '๋Œ€ํ•œํ•ญ๊ณต 45000 ๋งˆ์ผ, AMEX MR 50000์ '"
181
+ }
182
+
183
+ # ํŒŒ์‹ฑ
184
+ assets = parse_asset_text(text)
185
+
186
+ if not assets:
187
+ return {
188
+ "success": False,
189
+ "error": "์ธ์‹ ๊ฐ€๋Šฅํ•œ ํฌ์ธํŠธ/๋งˆ์ผ๋ฆฌ์ง€ ์ •๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.",
190
+ "tip": "ํ”„๋กœ๊ทธ๋žจ๋ช…๊ณผ ์ˆซ์ž๋ฅผ ํ•จ๊ป˜ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”. ์˜ˆ: '๋Œ€ํ•œํ•ญ๊ณต 45000'",
191
+ "supported_programs": list(POINT_VALUATIONS.keys())
192
+ }
193
+
194
+ # ํ”„๋กœ๊ทธ๋žจ ์ด๋ฆ„ ๋ณด๊ฐ•
195
+ for asset in assets:
196
+ prog_info = POINT_VALUATIONS.get(asset["program"], {})
197
+ asset["program_name"] = prog_info.get("name", asset["program"])
198
+ asset["currency"] = prog_info.get("currency", "USD")
199
+
200
+ result = {
201
+ "success": True,
202
+ "parsed_assets": assets,
203
+ "count": len(assets),
204
+ "parsed_at": datetime.now().isoformat(),
205
+ "tip": "user_get_asset_valuation์„ ํ˜ธ์ถœํ•˜์—ฌ ์ด ๊ฐ€์น˜๋ฅผ ๊ณ„์‚ฐํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."
206
+ }
207
+
208
+ # ํ–ฅํ›„: save_to_profile์ด True๋ฉด DB์— ์ €์žฅ
209
+ if save_to_profile:
210
+ result["save_status"] = "not_implemented"
211
+ result["save_note"] = "์ž์‚ฐ ์ €์žฅ ๊ธฐ๋Šฅ์€ ํ–ฅํ›„ ๊ตฌํ˜„ ์˜ˆ์ •"
212
+
213
+ return result
src/auth/config.py CHANGED
@@ -111,3 +111,131 @@ def validate_config():
111
  )
112
 
113
  logger.info("์ธ์ฆ ์„ค์ • ๊ฒ€์ฆ ์™„๋ฃŒ")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  )
112
 
113
  logger.info("์ธ์ฆ ์„ค์ • ๊ฒ€์ฆ ์™„๋ฃŒ")
114
+
115
+
116
+ # =============================================================================
117
+ # ํฌ์ธํŠธ ๊ฐ€์น˜ ํ‰๊ฐ€ (Shadow Valuation)
118
+ # =============================================================================
119
+
120
+ # ๊ธฐ๋ณธ ํฌ์ธํŠธ ๊ฐ€์น˜ (CPP: Cents Per Point)
121
+ # base: ์ผ๋ฐ˜ ์‚ฌ์šฉ์ž, premium: ๋น„์ฆˆ๋‹ˆ์Šค/ํผ์ŠคํŠธ ์„ ํ˜ธ, cashback: ํ˜„๊ธˆ์„ฑ ์„ ํ˜ธ
122
+ POINT_VALUATIONS = {
123
+ # ์‹ ์šฉ์นด๋“œ ํฌ์ธํŠธ (๋‹จ์œ„: cents per point)
124
+ "AMEX_MR": {
125
+ "name": "American Express Membership Rewards",
126
+ "currency": "USD",
127
+ "base": 2.0,
128
+ "premium": 2.2,
129
+ "cashback": 0.6,
130
+ },
131
+ "CHASE_UR": {
132
+ "name": "Chase Ultimate Rewards",
133
+ "currency": "USD",
134
+ "base": 2.0,
135
+ "premium": 2.05,
136
+ "cashback": 1.25,
137
+ },
138
+ "CITI_TYP": {
139
+ "name": "Citi ThankYou Points",
140
+ "currency": "USD",
141
+ "base": 1.8,
142
+ "premium": 1.9,
143
+ "cashback": 1.0,
144
+ },
145
+ "CAPITAL_ONE": {
146
+ "name": "Capital One Miles",
147
+ "currency": "USD",
148
+ "base": 1.85,
149
+ "premium": 1.9,
150
+ "cashback": 0.5,
151
+ },
152
+
153
+ # ํ•ญ๊ณต ๋งˆ์ผ๋ฆฌ์ง€ - ํ•œ๊ตญ (๋‹จ์œ„: ์›/๋งˆ์ผ)
154
+ "KOREAN_AIR": {
155
+ "name": "๋Œ€ํ•œํ•ญ๊ณต ์Šค์นด์ดํŒจ์Šค",
156
+ "currency": "KRW",
157
+ "base": 18,
158
+ "domestic": 12,
159
+ "premium": 25,
160
+ },
161
+ "ASIANA": {
162
+ "name": "์•„์‹œ์•„๋‚˜ ์Šคํƒ€์–ผ๋ผ์ด์–ธ์Šค",
163
+ "currency": "KRW",
164
+ "base": 17,
165
+ "domestic": 11,
166
+ "premium": 24,
167
+ },
168
+
169
+ # ํ•ญ๊ณต ๋งˆ์ผ๋ฆฌ์ง€ - ๋ฏธ๊ตญ (๋‹จ์œ„: cents per mile)
170
+ "DELTA_SKYMILES": {
171
+ "name": "Delta SkyMiles",
172
+ "currency": "USD",
173
+ "base": 1.2,
174
+ "premium": 1.5,
175
+ "cashback": 1.0,
176
+ },
177
+ "UNITED_MILEAGEPLUS": {
178
+ "name": "United MileagePlus",
179
+ "currency": "USD",
180
+ "base": 1.3,
181
+ "premium": 1.6,
182
+ "cashback": 1.0,
183
+ },
184
+ "AA_AADVANTAGE": {
185
+ "name": "American Airlines AAdvantage",
186
+ "currency": "USD",
187
+ "base": 1.4,
188
+ "premium": 1.7,
189
+ "cashback": 0.8,
190
+ },
191
+
192
+ # ํ˜ธํ…” ํฌ์ธํŠธ (๋‹จ์œ„: cents per point)
193
+ "MARRIOTT_BONVOY": {
194
+ "name": "Marriott Bonvoy",
195
+ "currency": "USD",
196
+ "base": 0.8,
197
+ "premium": 1.0,
198
+ "cashback": 0.6,
199
+ },
200
+ "HILTON_HONORS": {
201
+ "name": "Hilton Honors",
202
+ "currency": "USD",
203
+ "base": 0.5,
204
+ "premium": 0.6,
205
+ "cashback": 0.4,
206
+ },
207
+ "IHG_REWARDS": {
208
+ "name": "IHG One Rewards",
209
+ "currency": "USD",
210
+ "base": 0.5,
211
+ "premium": 0.6,
212
+ "cashback": 0.5,
213
+ },
214
+ "HYATT_WOH": {
215
+ "name": "World of Hyatt",
216
+ "currency": "USD",
217
+ "base": 1.7,
218
+ "premium": 2.0,
219
+ "cashback": 1.5,
220
+ },
221
+ }
222
+
223
+ # ์‚ฌ์šฉ์ž ์—ฌํ–‰ ์Šคํƒ€์ผ ํ”„๋กœํ•„
224
+ TRAVEL_STYLES = {
225
+ "PREMIUM": {
226
+ "description": "๋น„์ฆˆ๋‹ˆ์Šค/ํผ์ŠคํŠธํด๋ž˜์Šค ์„ ํ˜ธ",
227
+ "description_en": "Prefers Business/First Class",
228
+ "multiplier_key": "premium",
229
+ },
230
+ "VALUE": {
231
+ "description": "๊ฐ€์„ฑ๋น„ ์ค‘์‹œ (์ด์ฝ”๋…ธ๋ฏธ + ํ˜ธํ…” ์—…๊ทธ๋ ˆ์ด๋“œ)",
232
+ "description_en": "Value-focused traveler",
233
+ "multiplier_key": "base",
234
+ },
235
+ "CASHBACK": {
236
+ "description": "ํ˜„๊ธˆ์„ฑ ์‚ฌ์šฉ ์„ ํ˜ธ (Statement Credit, ๊ธฐํ”„ํŠธ์นด๋“œ)",
237
+ "description_en": "Prefers cash-equivalent redemptions",
238
+ "multiplier_key": "cashback",
239
+ },
240
+ }
241
+
src/auth/credit_card_handlers.py ADDED
@@ -0,0 +1,684 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Credit Card Tool Handlers
3
+ =========================
4
+
5
+ user_* ์‹ ์šฉ์นด๋“œ ๊ด€๋ จ MCP Tool์˜ ์‹ค์ œ ์‹คํ–‰ ๋กœ์ง.
6
+ """
7
+
8
+ import logging
9
+ from typing import Dict, Any, List, Optional
10
+ from datetime import datetime, date
11
+
12
+ from .config import SERVER_BASE_URL
13
+
14
+ logger = logging.getLogger("eodi.auth.credit_card_handlers")
15
+
16
+
17
+ # =============================================================================
18
+ # ์ง€์› ์นด๋“œ ์ •๋ณด
19
+ # =============================================================================
20
+
21
+ # ์ง€์›ํ•˜๋Š” ์นด๋“œ ๋ฐœ๊ธ‰์‚ฌ ๋ฐ ์นด๋“œ ID ์ฒด๊ณ„
22
+ SUPPORTED_ISSUERS = {
23
+ "AMEX": {
24
+ "name": "American Express",
25
+ "cards": {
26
+ "AMEX_PLATINUM_US": "Platinum Card",
27
+ "AMEX_GOLD_US": "Gold Card",
28
+ "AMEX_GREEN_US": "Green Card",
29
+ "AMEX_MARRIOTT_BONVOY_US": "Marriott Bonvoy Card",
30
+ "AMEX_HILTON_HONORS_US": "Hilton Honors Card",
31
+ "AMEX_HILTON_SURPASS_US": "Hilton Honors Surpass Card",
32
+ "AMEX_HILTON_ASPIRE_US": "Hilton Honors Aspire Card",
33
+ "AMEX_DELTA_SKYMILES_GOLD_US": "Delta SkyMiles Gold Card",
34
+ "AMEX_DELTA_SKYMILES_PLATINUM_US": "Delta SkyMiles Platinum Card",
35
+ "AMEX_DELTA_SKYMILES_RESERVE_US": "Delta SkyMiles Reserve Card",
36
+ }
37
+ },
38
+ "CHASE": {
39
+ "name": "Chase",
40
+ "cards": {
41
+ "CHASE_SAPPHIRE_PREFERRED": "Sapphire Preferred",
42
+ "CHASE_SAPPHIRE_RESERVE": "Sapphire Reserve",
43
+ "CHASE_FREEDOM_UNLIMITED": "Freedom Unlimited",
44
+ "CHASE_FREEDOM_FLEX": "Freedom Flex",
45
+ "CHASE_UNITED_EXPLORER": "United Explorer",
46
+ "CHASE_UNITED_QUEST": "United Quest",
47
+ "CHASE_UNITED_CLUB": "United Club Infinite",
48
+ "CHASE_MARRIOTT_BOUNDLESS": "Marriott Bonvoy Boundless",
49
+ "CHASE_IHG_ONE_PREMIER": "IHG One Rewards Premier",
50
+ "CHASE_HYATT": "World of Hyatt",
51
+ }
52
+ },
53
+ "CITI": {
54
+ "name": "Citi",
55
+ "cards": {
56
+ "CITI_STRATA_PREMIER": "Strata Premier",
57
+ "CITI_DOUBLE_CASH": "Double Cash",
58
+ "CITI_CUSTOM_CASH": "Custom Cash",
59
+ "CITI_AA_EXECUTIVE": "AAdvantage Executive",
60
+ "CITI_AA_PLATINUM": "AAdvantage Platinum Select",
61
+ }
62
+ },
63
+ "CAPITAL_ONE": {
64
+ "name": "Capital One",
65
+ "cards": {
66
+ "CAPITAL_ONE_VENTURE_X": "Venture X",
67
+ "CAPITAL_ONE_VENTURE": "Venture",
68
+ "CAPITAL_ONE_SAVOR_ONE": "SavorOne",
69
+ }
70
+ },
71
+ }
72
+
73
+ # ์นด๋“œ๋ณ„ ํ˜œํƒ ์ถ”์  ๊ฐ€๋Šฅํ•œ ํฌ๋ ˆ๋”ง ์ •์˜ (KB ๋ฐ์ดํ„ฐ์™€ ์—ฐ๋™)
74
+ TRACKABLE_BENEFITS = {
75
+ "AMEX_PLATINUM_US": {
76
+ "amex_plat_hotel_credit": {
77
+ "name": "FHR/THC Hotel Credit",
78
+ "period_type": "semi-annually", # monthly, quarterly, semi-annually, yearly
79
+ "amount_per_period": 300,
80
+ "currency": "USD",
81
+ "periods": ["H1", "H2"], # H1=1-6์›”, H2=7-12์›”
82
+ },
83
+ "amex_plat_saks_credit": {
84
+ "name": "Saks Fifth Avenue Credit",
85
+ "period_type": "semi-annually",
86
+ "amount_per_period": 50,
87
+ "currency": "USD",
88
+ "periods": ["H1", "H2"],
89
+ },
90
+ "amex_plat_uber_cash": {
91
+ "name": "Uber Cash",
92
+ "period_type": "monthly",
93
+ "amount_per_period": 15,
94
+ "currency": "USD",
95
+ "december_bonus": 20,
96
+ },
97
+ "amex_plat_digital_entertainment": {
98
+ "name": "Digital Entertainment Credit",
99
+ "period_type": "monthly",
100
+ "amount_per_period": 25,
101
+ "currency": "USD",
102
+ },
103
+ "amex_plat_resy_credit": {
104
+ "name": "Resy Credit",
105
+ "period_type": "quarterly",
106
+ "amount_per_period": 100,
107
+ "currency": "USD",
108
+ "periods": ["Q1", "Q2", "Q3", "Q4"],
109
+ },
110
+ "amex_plat_lululemon_credit": {
111
+ "name": "lululemon Credit",
112
+ "period_type": "quarterly",
113
+ "amount_per_period": 75,
114
+ "currency": "USD",
115
+ "periods": ["Q1", "Q2", "Q3", "Q4"],
116
+ },
117
+ "amex_plat_airline_fee_credit": {
118
+ "name": "Airline Fee Credit",
119
+ "period_type": "yearly",
120
+ "amount_per_period": 200,
121
+ "currency": "USD",
122
+ },
123
+ "amex_plat_equinox_credit": {
124
+ "name": "Equinox Credit",
125
+ "period_type": "yearly",
126
+ "amount_per_period": 300,
127
+ "currency": "USD",
128
+ },
129
+ "amex_plat_clear_credit": {
130
+ "name": "CLEAR Plus Credit",
131
+ "period_type": "yearly",
132
+ "amount_per_period": 209,
133
+ "currency": "USD",
134
+ },
135
+ "amex_plat_walmart_credit": {
136
+ "name": "Walmart+ Credit",
137
+ "period_type": "monthly",
138
+ "amount_per_period": 12.95,
139
+ "currency": "USD",
140
+ },
141
+ },
142
+ "CHASE_SAPPHIRE_RESERVE": {
143
+ "chase_sr_travel_credit": {
144
+ "name": "Travel Credit",
145
+ "period_type": "yearly",
146
+ "amount_per_period": 300,
147
+ "currency": "USD",
148
+ },
149
+ "chase_sr_doordash": {
150
+ "name": "DoorDash DashPass",
151
+ "period_type": "yearly",
152
+ "amount_per_period": 0, # Free membership
153
+ "currency": "USD",
154
+ },
155
+ },
156
+ }
157
+
158
+
159
+ # =============================================================================
160
+ # ์œ ํ‹ธ๋ฆฌํ‹ฐ ํ•จ์ˆ˜
161
+ # =============================================================================
162
+
163
+ def get_current_period_ids(period_type: str, ref_date: date = None) -> List[str]:
164
+ """
165
+ ํ˜„์žฌ ๋‚ ์งœ ๊ธฐ์ค€ ๊ธฐ๊ฐ„ ID ๋ฐ˜ํ™˜.
166
+
167
+ Args:
168
+ period_type: monthly, quarterly, semi-annually, yearly
169
+ ref_date: ๊ธฐ์ค€ ๋‚ ์งœ (๊ธฐ๋ณธ: ์˜ค๋Š˜)
170
+
171
+ Returns:
172
+ ๊ธฐ๊ฐ„ ID ๋ฆฌ์ŠคํŠธ (์˜ˆ: ["2026-H1"], ["2026-Q1"], ["2026-01"])
173
+ """
174
+ if ref_date is None:
175
+ ref_date = date.today()
176
+
177
+ year = ref_date.year
178
+ month = ref_date.month
179
+
180
+ if period_type == "monthly":
181
+ return [f"{year}-{month:02d}"]
182
+ elif period_type == "quarterly":
183
+ quarter = (month - 1) // 3 + 1
184
+ return [f"{year}-Q{quarter}"]
185
+ elif period_type == "semi-annually":
186
+ half = "H1" if month <= 6 else "H2"
187
+ return [f"{year}-{half}"]
188
+ elif period_type == "yearly":
189
+ return [f"{year}"]
190
+ else:
191
+ return [f"{year}"]
192
+
193
+
194
+ def get_period_deadline(period_type: str, period_id: str) -> Optional[date]:
195
+ """
196
+ ๊ธฐ๊ฐ„ ID์˜ ๋งˆ๊ฐ์ผ ๋ฐ˜ํ™˜.
197
+
198
+ Args:
199
+ period_type: ๊ธฐ๊ฐ„ ์œ ํ˜•
200
+ period_id: ๊ธฐ๊ฐ„ ID (์˜ˆ: "2026-H1")
201
+
202
+ Returns:
203
+ ๋งˆ๊ฐ์ผ
204
+ """
205
+ try:
206
+ year = int(period_id.split("-")[0])
207
+
208
+ if period_type == "monthly":
209
+ month = int(period_id.split("-")[1])
210
+ if month == 12:
211
+ return date(year + 1, 1, 1)
212
+ else:
213
+ return date(year, month + 1, 1)
214
+ elif period_type == "quarterly":
215
+ quarter = period_id.split("-")[1]
216
+ if quarter == "Q1":
217
+ return date(year, 4, 1)
218
+ elif quarter == "Q2":
219
+ return date(year, 7, 1)
220
+ elif quarter == "Q3":
221
+ return date(year, 10, 1)
222
+ else:
223
+ return date(year + 1, 1, 1)
224
+ elif period_type == "semi-annually":
225
+ half = period_id.split("-")[1]
226
+ if half == "H1":
227
+ return date(year, 7, 1)
228
+ else:
229
+ return date(year + 1, 1, 1)
230
+ elif period_type == "yearly":
231
+ return date(year + 1, 1, 1)
232
+ except Exception as e:
233
+ logger.error(f"๊ธฐ๊ฐ„ ๋งˆ๊ฐ์ผ ๊ณ„์‚ฐ ์˜ค๋ฅ˜: {e}")
234
+ return None
235
+
236
+
237
+ def days_until_deadline(period_type: str, period_id: str, ref_date: date = None) -> int:
238
+ """
239
+ ๋งˆ๊ฐ์ผ๊นŒ์ง€ ๋‚จ์€ ์ผ์ˆ˜ ๊ณ„์‚ฐ.
240
+ """
241
+ if ref_date is None:
242
+ ref_date = date.today()
243
+
244
+ deadline = get_period_deadline(period_type, period_id)
245
+ if deadline:
246
+ return (deadline - ref_date).days
247
+ return 999 # ์•Œ ์ˆ˜ ์—†์Œ
248
+
249
+
250
+ # =============================================================================
251
+ # Tool ํ•ธ๋“ค๋Ÿฌ
252
+ # =============================================================================
253
+
254
+ async def handle_add_credit_card(arguments: Dict[str, Any], user_id: str) -> Dict[str, Any]:
255
+ """
256
+ user_add_credit_card Tool ํ•ธ๋“ค๋Ÿฌ.
257
+
258
+ ์‚ฌ์šฉ์ž ์‹ ์šฉ์นด๋“œ ๋“ฑ๋ก.
259
+ """
260
+ card_id = arguments.get("card_id", "").upper()
261
+ card_name = arguments.get("card_name", "")
262
+ issuer_code = arguments.get("issuer_code", "").upper()
263
+ region = arguments.get("region", "USA").upper()
264
+ card_open_date = arguments.get("card_open_date") # YYYY-MM-DD
265
+ anniversary_month = arguments.get("anniversary_month")
266
+ annual_fee = arguments.get("annual_fee")
267
+
268
+ # ํ•„์ˆ˜๊ฐ’ ๊ฒ€์ฆ
269
+ if not card_id:
270
+ return {
271
+ "success": False,
272
+ "error": "card_id๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.",
273
+ "supported_issuers": list(SUPPORTED_ISSUERS.keys()),
274
+ "example_cards": {
275
+ issuer: list(data["cards"].keys())[:3]
276
+ for issuer, data in SUPPORTED_ISSUERS.items()
277
+ }
278
+ }
279
+
280
+ # ๋ฐœ๊ธ‰์‚ฌ ๊ฒ€์ฆ (card_id์—์„œ ์ถ”์ถœ ๊ฐ€๋Šฅ)
281
+ if not issuer_code:
282
+ for issuer, data in SUPPORTED_ISSUERS.items():
283
+ if card_id in data["cards"]:
284
+ issuer_code = issuer
285
+ card_name = card_name or data["cards"][card_id]
286
+ break
287
+
288
+ if not issuer_code:
289
+ issuer_code = card_id.split("_")[0] if "_" in card_id else "OTHER"
290
+
291
+ if not card_name:
292
+ card_name = card_id.replace("_", " ").title()
293
+
294
+ try:
295
+ from src.db.supabase_adapter import SupabaseAdapter
296
+ adapter = SupabaseAdapter()
297
+
298
+ result = adapter.upsert_credit_card(
299
+ user_id=user_id,
300
+ card_id=card_id,
301
+ card_name=card_name,
302
+ issuer_code=issuer_code,
303
+ region=region,
304
+ card_open_date=card_open_date,
305
+ anniversary_month=anniversary_month,
306
+ annual_fee=annual_fee
307
+ )
308
+
309
+ if result:
310
+ # ์ถ”์  ๊ฐ€๋Šฅํ•œ ํ˜œํƒ ์ •๋ณด ํฌํ•จ
311
+ trackable = TRACKABLE_BENEFITS.get(card_id, {})
312
+
313
+ return {
314
+ "success": True,
315
+ "card": {
316
+ "card_id": card_id,
317
+ "card_name": card_name,
318
+ "issuer_code": issuer_code,
319
+ "region": region,
320
+ },
321
+ "trackable_benefits": list(trackable.keys()) if trackable else [],
322
+ "message": f"โœ… {card_name} ์นด๋“œ๊ฐ€ ๋“ฑ๋ก๋˜์—ˆ์Šต๋‹ˆ๋‹ค." +
323
+ (f"\n\n๐Ÿ’ก ์ถ”์  ๊ฐ€๋Šฅํ•œ ํ˜œํƒ: {len(trackable)}๊ฐœ" if trackable else "")
324
+ }
325
+ else:
326
+ return {
327
+ "success": False,
328
+ "error": "์นด๋“œ ๋“ฑ๋ก์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."
329
+ }
330
+ except Exception as e:
331
+ logger.error(f"์นด๋“œ ๋“ฑ๋ก ์˜ค๋ฅ˜: {e}")
332
+ return {
333
+ "success": False,
334
+ "error": f"์นด๋“œ ๋“ฑ๋ก ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {e}"
335
+ }
336
+
337
+
338
+ async def handle_get_credit_cards(arguments: Dict[str, Any], user_id: str) -> Dict[str, Any]:
339
+ """
340
+ user_get_credit_cards Tool ํ•ธ๋“ค๋Ÿฌ.
341
+
342
+ ์‚ฌ์šฉ์ž ๋ณด์œ  ์นด๋“œ ๋ชฉ๋ก ์กฐํšŒ.
343
+ """
344
+ try:
345
+ from src.db.supabase_adapter import SupabaseAdapter
346
+ adapter = SupabaseAdapter()
347
+
348
+ cards = adapter.get_user_credit_cards(user_id)
349
+
350
+ return {
351
+ "success": True,
352
+ "cards": [
353
+ {
354
+ "card_id": card["card_id"],
355
+ "card_name": card["card_name"],
356
+ "issuer_code": card["issuer_code"],
357
+ "region": card.get("region", "USA"),
358
+ "is_active": card.get("is_active", True),
359
+ "anniversary_month": card.get("anniversary_month"),
360
+ }
361
+ for card in cards
362
+ ],
363
+ "count": len(cards)
364
+ }
365
+ except Exception as e:
366
+ logger.error(f"์นด๋“œ ๋ชฉ๋ก ์กฐํšŒ ์˜ค๋ฅ˜: {e}")
367
+ return {
368
+ "success": False,
369
+ "error": f"์นด๋“œ ๋ชฉ๋ก ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {e}"
370
+ }
371
+
372
+
373
+ async def handle_delete_credit_card(arguments: Dict[str, Any], user_id: str) -> Dict[str, Any]:
374
+ """
375
+ user_delete_credit_card Tool ํ•ธ๋“ค๋Ÿฌ.
376
+ """
377
+ card_id = arguments.get("card_id", "").upper()
378
+
379
+ if not card_id:
380
+ return {
381
+ "success": False,
382
+ "error": "card_id๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."
383
+ }
384
+
385
+ try:
386
+ from src.db.supabase_adapter import SupabaseAdapter
387
+ adapter = SupabaseAdapter()
388
+
389
+ if adapter.delete_credit_card(user_id, card_id):
390
+ return {
391
+ "success": True,
392
+ "message": f"โœ… {card_id} ์นด๋“œ๊ฐ€ ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."
393
+ }
394
+ else:
395
+ return {
396
+ "success": False,
397
+ "error": "์นด๋“œ ์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."
398
+ }
399
+ except Exception as e:
400
+ logger.error(f"์นด๋“œ ์‚ญ์ œ ์˜ค๋ฅ˜: {e}")
401
+ return {
402
+ "success": False,
403
+ "error": f"์นด๋“œ ์‚ญ์ œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {e}"
404
+ }
405
+
406
+
407
+ async def handle_update_credit_usage(arguments: Dict[str, Any], user_id: str) -> Dict[str, Any]:
408
+ """
409
+ user_update_credit_usage Tool ํ•ธ๋“ค๋Ÿฌ.
410
+
411
+ ํฌ๋ ˆ๋”ง/ํ˜œํƒ ์‚ฌ์šฉ ๊ธฐ๋ก ์—…๋ฐ์ดํŠธ.
412
+ """
413
+ card_id = arguments.get("card_id", "").upper()
414
+ benefit_id = arguments.get("benefit_id", "").lower()
415
+ amount_used = arguments.get("amount_used")
416
+ usage_date = arguments.get("usage_date") # YYYY-MM-DD
417
+ description = arguments.get("description", "")
418
+
419
+ if not card_id or not benefit_id:
420
+ return {
421
+ "success": False,
422
+ "error": "card_id์™€ benefit_id๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.",
423
+ "available_benefits": list(TRACKABLE_BENEFITS.get(card_id, {}).keys())
424
+ }
425
+
426
+ # ํ˜œํƒ ์ •๋ณด ์กฐํšŒ
427
+ card_benefits = TRACKABLE_BENEFITS.get(card_id, {})
428
+ benefit_info = card_benefits.get(benefit_id)
429
+
430
+ if not benefit_info:
431
+ return {
432
+ "success": False,
433
+ "error": f"์ถ”์  ๊ฐ€๋Šฅํ•œ ํ˜œํƒ์ด ์•„๋‹™๋‹ˆ๋‹ค: {benefit_id}",
434
+ "available_benefits": list(card_benefits.keys())
435
+ }
436
+
437
+ # ํ˜„์žฌ ๊ธฐ๊ฐ„ ๊ณ„์‚ฐ
438
+ ref_date = date.fromisoformat(usage_date) if usage_date else date.today()
439
+ period_ids = get_current_period_ids(benefit_info["period_type"], ref_date)
440
+ period_id = period_ids[0] if period_ids else f"{ref_date.year}"
441
+
442
+ # ์‚ฌ์šฉ ๊ธˆ์•ก ๊ธฐ๋ณธ๊ฐ’: ๊ธฐ๊ฐ„๋‹น ํ•œ๋„ ์ „์ฒด
443
+ if amount_used is None:
444
+ amount_used = benefit_info["amount_per_period"]
445
+
446
+ try:
447
+ from src.db.supabase_adapter import SupabaseAdapter
448
+ adapter = SupabaseAdapter()
449
+
450
+ result = adapter.upsert_credit_usage(
451
+ user_id=user_id,
452
+ card_id=card_id,
453
+ benefit_id=benefit_id,
454
+ usage_period=period_id,
455
+ amount_used=float(amount_used),
456
+ amount_limit=benefit_info["amount_per_period"],
457
+ currency=benefit_info["currency"],
458
+ usage_date=usage_date or ref_date.isoformat(),
459
+ description=description
460
+ )
461
+
462
+ if result:
463
+ return {
464
+ "success": True,
465
+ "usage": {
466
+ "card_id": card_id,
467
+ "benefit_id": benefit_id,
468
+ "benefit_name": benefit_info["name"],
469
+ "period": period_id,
470
+ "amount_used": amount_used,
471
+ "amount_limit": benefit_info["amount_per_period"],
472
+ "currency": benefit_info["currency"],
473
+ },
474
+ "message": f"โœ… {benefit_info['name']} ์‚ฌ์šฉ ๊ธฐ๋ก์ด ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n"
475
+ f" {period_id}: ${amount_used}/${benefit_info['amount_per_period']}"
476
+ }
477
+ else:
478
+ return {
479
+ "success": False,
480
+ "error": "์‚ฌ์šฉ ๊ธฐ๋ก ์ €์žฅ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."
481
+ }
482
+ except Exception as e:
483
+ logger.error(f"์‚ฌ์šฉ ๊ธฐ๋ก ์ €์žฅ ์˜ค๋ฅ˜: {e}")
484
+ return {
485
+ "success": False,
486
+ "error": f"์‚ฌ์šฉ ๊ธฐ๋ก ์ €์žฅ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {e}"
487
+ }
488
+
489
+
490
+ async def handle_get_credit_recommendations(
491
+ arguments: Dict[str, Any],
492
+ user_id: str
493
+ ) -> Dict[str, Any]:
494
+ """
495
+ user_get_credit_recommendations Tool ํ•ธ๋“ค๋Ÿฌ.
496
+
497
+ ํ˜„์žฌ ์‹œ์ ์—์„œ ์‚ฌ์šฉํ•ด์•ผ ํ•  ํฌ๋ ˆ๋”ง/ํ˜œํƒ ์ถ”์ฒœ.
498
+ """
499
+ try:
500
+ from src.db.supabase_adapter import SupabaseAdapter
501
+ adapter = SupabaseAdapter()
502
+
503
+ # 1. ์‚ฌ์šฉ์ž ๋ณด์œ  ์นด๋“œ ์กฐํšŒ
504
+ cards = adapter.get_user_credit_cards(user_id)
505
+
506
+ if not cards:
507
+ return {
508
+ "success": True,
509
+ "recommendations": [],
510
+ "message": "๋“ฑ๋ก๋œ ์‹ ์šฉ์นด๋“œ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. user_add_credit_card๋กœ ์นด๋“œ๋ฅผ ๋จผ์ € ๋“ฑ๋กํ•ด์ฃผ์„ธ์š”."
511
+ }
512
+
513
+ # 2. ์‚ฌ์šฉ ๊ธฐ๋ก ์กฐํšŒ
514
+ usage_records = adapter.get_user_credit_usage(user_id)
515
+ usage_map = {
516
+ (u["card_id"], u["benefit_id"], u["usage_period"]): u
517
+ for u in usage_records
518
+ }
519
+
520
+ # 3. ํ˜„์žฌ ๋‚ ์งœ ๊ธฐ์ค€ ์ถ”์ฒœ ์ƒ์„ฑ
521
+ today = date.today()
522
+ recommendations = []
523
+
524
+ for card in cards:
525
+ card_id = card["card_id"]
526
+ card_benefits = TRACKABLE_BENEFITS.get(card_id, {})
527
+
528
+ for benefit_id, benefit_info in card_benefits.items():
529
+ period_type = benefit_info["period_type"]
530
+ period_ids = get_current_period_ids(period_type, today)
531
+
532
+ for period_id in period_ids:
533
+ # ์ด๋ฏธ ์‚ฌ์šฉํ•œ ๊ธฐ๋ก ํ™•์ธ
534
+ usage_key = (card_id, benefit_id, period_id)
535
+ usage = usage_map.get(usage_key)
536
+
537
+ amount_used = usage["amount_used"] if usage else 0
538
+ amount_limit = benefit_info["amount_per_period"]
539
+ remaining = amount_limit - amount_used
540
+ days_left = days_until_deadline(period_type, period_id, today)
541
+
542
+ if remaining > 0:
543
+ # ๊ธด๊ธ‰๋„ ๊ณ„์‚ฐ
544
+ if days_left <= 7:
545
+ urgency = "๐Ÿ”ด ๊ธด๊ธ‰"
546
+ priority = 1
547
+ elif days_left <= 30:
548
+ urgency = "๐ŸŸก ์ฃผ์˜"
549
+ priority = 2
550
+ else:
551
+ urgency = "๐ŸŸข ์—ฌ์œ "
552
+ priority = 3
553
+
554
+ recommendations.append({
555
+ "card_id": card_id,
556
+ "card_name": card["card_name"],
557
+ "benefit_id": benefit_id,
558
+ "benefit_name": benefit_info["name"],
559
+ "period": period_id,
560
+ "period_type": period_type,
561
+ "amount_remaining": remaining,
562
+ "amount_limit": amount_limit,
563
+ "currency": benefit_info["currency"],
564
+ "days_until_expiry": days_left,
565
+ "urgency": urgency,
566
+ "priority": priority,
567
+ })
568
+
569
+ # ์šฐ์„ ์ˆœ์œ„ ์ •๋ ฌ (๊ธด๊ธ‰๋„ โ†’ ๊ธˆ์•ก)
570
+ recommendations.sort(key=lambda x: (x["priority"], -x["amount_remaining"]))
571
+
572
+ # ์ƒ์œ„ 10๊ฐœ๋งŒ ๋ฐ˜ํ™˜
573
+ top_recommendations = recommendations[:10]
574
+
575
+ # ๋ฉ”์‹œ์ง€ ์ƒ์„ฑ
576
+ if top_recommendations:
577
+ urgent_count = sum(1 for r in top_recommendations if r["priority"] == 1)
578
+ message_parts = [f"๐Ÿ“‹ ์ถ”์ฒœ {len(top_recommendations)}๊ฐœ"]
579
+ if urgent_count:
580
+ message_parts.append(f"(๊ธด๊ธ‰ {urgent_count}๊ฐœ!)")
581
+ message = " ".join(message_parts)
582
+ else:
583
+ message = "โœ… ํ˜„์žฌ ์‚ฌ์šฉํ•ด์•ผ ํ•  ํฌ๋ ˆ๋”ง์ด ์—†์Šต๋‹ˆ๋‹ค."
584
+
585
+ return {
586
+ "success": True,
587
+ "recommendations": top_recommendations,
588
+ "total_count": len(recommendations),
589
+ "message": message,
590
+ "current_date": today.isoformat()
591
+ }
592
+
593
+ except Exception as e:
594
+ logger.error(f"์ถ”์ฒœ ์ƒ์„ฑ ์˜ค๋ฅ˜: {e}")
595
+ return {
596
+ "success": False,
597
+ "error": f"์ถ”์ฒœ ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {e}"
598
+ }
599
+
600
+
601
+ async def handle_get_credit_usage_summary(
602
+ arguments: Dict[str, Any],
603
+ user_id: str
604
+ ) -> Dict[str, Any]:
605
+ """
606
+ user_get_credit_usage_summary Tool ํ•ธ๋“ค๋Ÿฌ.
607
+
608
+ ํŠน์ • ์นด๋“œ์˜ ํฌ๋ ˆ๋”ง ์‚ฌ์šฉ ํ˜„ํ™ฉ ์š”์•ฝ.
609
+ """
610
+ card_id = arguments.get("card_id", "").upper()
611
+
612
+ try:
613
+ from src.db.supabase_adapter import SupabaseAdapter
614
+ adapter = SupabaseAdapter()
615
+
616
+ # ์‚ฌ์šฉ์ž ๋ณด์œ  ์นด๋“œ ํ™•์ธ
617
+ cards = adapter.get_user_credit_cards(user_id)
618
+ user_card_ids = [c["card_id"] for c in cards]
619
+
620
+ if card_id and card_id not in user_card_ids:
621
+ return {
622
+ "success": False,
623
+ "error": f"๋“ฑ๋ก๋˜์ง€ ์•Š์€ ์นด๋“œ์ž…๋‹ˆ๋‹ค: {card_id}",
624
+ "registered_cards": user_card_ids
625
+ }
626
+
627
+ # ์‚ฌ์šฉ ๊ธฐ๋ก ์กฐํšŒ
628
+ usage_records = adapter.get_user_credit_usage(user_id, card_id=card_id if card_id else None)
629
+
630
+ # ์นด๋“œ๋ณ„๋กœ ๊ทธ๋ฃนํ™”
631
+ today = date.today()
632
+ summary = {}
633
+
634
+ for card in cards:
635
+ cid = card["card_id"]
636
+ if card_id and cid != card_id:
637
+ continue
638
+
639
+ card_benefits = TRACKABLE_BENEFITS.get(cid, {})
640
+ card_usage = [u for u in usage_records if u["card_id"] == cid]
641
+
642
+ benefits_summary = []
643
+ for benefit_id, benefit_info in card_benefits.items():
644
+ period_type = benefit_info["period_type"]
645
+ period_ids = get_current_period_ids(period_type, today)
646
+ period_id = period_ids[0] if period_ids else f"{today.year}"
647
+
648
+ # ํ•ด๋‹น ๊ธฐ๊ฐ„ ์‚ฌ์šฉ๋Ÿ‰
649
+ usage = next(
650
+ (u for u in card_usage
651
+ if u["benefit_id"] == benefit_id and u["usage_period"] == period_id),
652
+ None
653
+ )
654
+
655
+ amount_used = usage["amount_used"] if usage else 0
656
+ amount_limit = benefit_info["amount_per_period"]
657
+
658
+ benefits_summary.append({
659
+ "benefit_id": benefit_id,
660
+ "benefit_name": benefit_info["name"],
661
+ "current_period": period_id,
662
+ "amount_used": amount_used,
663
+ "amount_limit": amount_limit,
664
+ "percent_used": round(amount_used / amount_limit * 100, 1) if amount_limit else 0,
665
+ "currency": benefit_info["currency"],
666
+ })
667
+
668
+ summary[cid] = {
669
+ "card_name": card["card_name"],
670
+ "benefits": benefits_summary,
671
+ }
672
+
673
+ return {
674
+ "success": True,
675
+ "summary": summary,
676
+ "current_date": today.isoformat()
677
+ }
678
+
679
+ except Exception as e:
680
+ logger.error(f"์‚ฌ์šฉ ํ˜„ํ™ฉ ์š”์•ฝ ์กฐํšŒ ์˜ค๋ฅ˜: {e}")
681
+ return {
682
+ "success": False,
683
+ "error": f"์‚ฌ์šฉ ํ˜„ํ™ฉ ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {e}"
684
+ }
src/auth/tool_handlers.py CHANGED
@@ -27,6 +27,7 @@ async def execute_user_tool(name: str, arguments: Dict[str, Any]) -> Dict[str, A
27
  Returns:
28
  Tool ์‹คํ–‰ ๊ฒฐ๊ณผ
29
  """
 
30
  if name == "user_get_profile":
31
  return await handle_get_profile(arguments)
32
  elif name == "user_update_membership":
@@ -37,10 +38,137 @@ async def execute_user_tool(name: str, arguments: Dict[str, Any]) -> Dict[str, A
37
  return await handle_request_auth(arguments)
38
  elif name == "user_verify_code":
39
  return await handle_verify_code(arguments)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  else:
41
  return {"success": False, "error": f"Unknown user tool: {name}"}
42
 
43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  async def handle_get_profile(arguments: Dict[str, Any]) -> Dict[str, Any]:
45
  """
46
  user_get_profile Tool ํ•ธ๋“ค๋Ÿฌ.
 
27
  Returns:
28
  Tool ์‹คํ–‰ ๊ฒฐ๊ณผ
29
  """
30
+ # ๊ธฐ์กด ์ธ์ฆ/๋ฉค๋ฒ„์‹ญ ๊ด€๋ จ Tool
31
  if name == "user_get_profile":
32
  return await handle_get_profile(arguments)
33
  elif name == "user_update_membership":
 
38
  return await handle_request_auth(arguments)
39
  elif name == "user_verify_code":
40
  return await handle_verify_code(arguments)
41
+
42
+ # ์‹ ์šฉ์นด๋“œ ๊ด€๋ จ Tool (์ธ์ฆ ํ•„์š”)
43
+ elif name in [
44
+ "user_add_credit_card",
45
+ "user_get_credit_cards",
46
+ "user_delete_credit_card",
47
+ "user_update_credit_usage",
48
+ "user_get_credit_recommendations",
49
+ "user_get_credit_usage_summary"
50
+ ]:
51
+ return await _handle_credit_card_tool(name, arguments)
52
+
53
+ # ๊ฐ€์น˜ ํ‰๊ฐ€ ๊ด€๋ จ Tool (์ธ์ฆ ํ•„์š”)
54
+ elif name in [
55
+ "user_get_asset_valuation",
56
+ "user_update_valuation_style",
57
+ "user_get_valuation_styles",
58
+ "user_parse_asset_text",
59
+ "calculate_miles_vs_cashback",
60
+ "generate_award_search_link"
61
+ ]:
62
+ return await _handle_valuation_tool(name, arguments)
63
+
64
  else:
65
  return {"success": False, "error": f"Unknown user tool: {name}"}
66
 
67
 
68
+ async def _handle_credit_card_tool(name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
69
+ """
70
+ ์‹ ์šฉ์นด๋“œ ๊ด€๋ จ Tool์„ ์œ„ํ•œ ๊ณตํ†ต ๋ž˜ํผ.
71
+ ์„ธ์…˜ ๊ฒ€์ฆ ํ›„ ํ•ด๋‹น ํ•ธ๋“ค๋Ÿฌ ํ˜ธ์ถœ.
72
+ """
73
+ from .credit_card_handlers import (
74
+ handle_add_credit_card,
75
+ handle_get_credit_cards,
76
+ handle_delete_credit_card,
77
+ handle_update_credit_usage,
78
+ handle_get_credit_recommendations,
79
+ handle_get_credit_usage_summary
80
+ )
81
+
82
+ session_token = arguments.get("session_token")
83
+ user_session_manager = get_user_session_manager()
84
+
85
+ # ์ธ์ฆ ํ™•์ธ
86
+ if not session_token:
87
+ return {
88
+ "success": False,
89
+ "error": "session_token์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. ๋จผ์ € user_get_profile์„ ํ˜ธ์ถœํ•˜์—ฌ ์ธ์ฆ ์ƒํƒœ๋ฅผ ํ™•์ธํ•˜์„ธ์š”.",
90
+ "auth_url": f"{SERVER_BASE_URL}/auth/login"
91
+ }
92
+
93
+ session = user_session_manager.get_session(session_token)
94
+ if not session:
95
+ return {
96
+ "success": False,
97
+ "error": "์„ธ์…˜์ด ๋งŒ๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ๋กœ๊ทธ์ธํ•ด์ฃผ์„ธ์š”.",
98
+ "auth_url": f"{SERVER_BASE_URL}/auth/login"
99
+ }
100
+
101
+ user_id = session.user_id
102
+
103
+ # ๊ฐ ํ•ธ๋“ค๋Ÿฌ ํ˜ธ์ถœ
104
+ if name == "user_add_credit_card":
105
+ return await handle_add_credit_card(arguments, user_id)
106
+ elif name == "user_get_credit_cards":
107
+ return await handle_get_credit_cards(arguments, user_id)
108
+ elif name == "user_delete_credit_card":
109
+ return await handle_delete_credit_card(arguments, user_id)
110
+ elif name == "user_update_credit_usage":
111
+ return await handle_update_credit_usage(arguments, user_id)
112
+ elif name == "user_get_credit_recommendations":
113
+ return await handle_get_credit_recommendations(arguments, user_id)
114
+ elif name == "user_get_credit_usage_summary":
115
+ return await handle_get_credit_usage_summary(arguments, user_id)
116
+ else:
117
+ return {"success": False, "error": f"Unknown credit card tool: {name}"}
118
+
119
+
120
+ async def _handle_valuation_tool(name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
121
+ """
122
+ ๊ฐ€์น˜ ํ‰๊ฐ€ ๊ด€๋ จ Tool์„ ์œ„ํ•œ ๊ณตํ†ต ๋ž˜ํผ.
123
+ ์„ธ์…˜ ๊ฒ€์ฆ ํ›„ ํ•ด๋‹น ํ•ธ๋“ค๋Ÿฌ ํ˜ธ์ถœ.
124
+ """
125
+ from .valuation_handlers import (
126
+ handle_get_asset_valuation,
127
+ handle_update_valuation_style,
128
+ handle_get_valuation_styles,
129
+ handle_calculate_miles_vs_cashback,
130
+ handle_generate_award_search_link
131
+ )
132
+ from .asset_parser import handle_parse_asset_text
133
+
134
+ session_token = arguments.get("session_token")
135
+ user_session_manager = get_user_session_manager()
136
+
137
+ # ์ธ์ฆ ํ™•์ธ
138
+ if not session_token:
139
+ return {
140
+ "success": False,
141
+ "error": "session_token์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. ๋จผ์ € user_get_profile์„ ํ˜ธ์ถœํ•˜์—ฌ ์ธ์ฆ ์ƒํƒœ๋ฅผ ํ™•์ธํ•˜์„ธ์š”.",
142
+ "auth_url": f"{SERVER_BASE_URL}/auth/login"
143
+ }
144
+
145
+ session = user_session_manager.get_session(session_token)
146
+ if not session:
147
+ return {
148
+ "success": False,
149
+ "error": "์„ธ์…˜์ด ๋งŒ๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ๋กœ๊ทธ์ธํ•ด์ฃผ์„ธ์š”.",
150
+ "auth_url": f"{SERVER_BASE_URL}/auth/login"
151
+ }
152
+
153
+ user_id = session.user_id
154
+
155
+ # ๊ฐ ํ•ธ๋“ค๋Ÿฌ ํ˜ธ์ถœ
156
+ if name == "user_get_asset_valuation":
157
+ return await handle_get_asset_valuation(arguments, user_id)
158
+ elif name == "user_update_valuation_style":
159
+ return await handle_update_valuation_style(arguments, user_id)
160
+ elif name == "user_get_valuation_styles":
161
+ return await handle_get_valuation_styles(arguments, user_id)
162
+ elif name == "user_parse_asset_text":
163
+ return await handle_parse_asset_text(arguments, user_id)
164
+ elif name == "calculate_miles_vs_cashback":
165
+ return await handle_calculate_miles_vs_cashback(arguments, user_id)
166
+ elif name == "generate_award_search_link":
167
+ return await handle_generate_award_search_link(arguments, user_id)
168
+ else:
169
+ return {"success": False, "error": f"Unknown valuation tool: {name}"}
170
+
171
+
172
  async def handle_get_profile(arguments: Dict[str, Any]) -> Dict[str, Any]:
173
  """
174
  user_get_profile Tool ํ•ธ๋“ค๋Ÿฌ.
src/auth/valuation_handlers.py ADDED
@@ -0,0 +1,530 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Shadow Valuation Handlers
3
+ =========================
4
+
5
+ ํฌ์ธํŠธ/๋งˆ์ผ๋ฆฌ์ง€ ๊ฐ€์น˜ ํ‰๊ฐ€๋ฅผ ์œ„ํ•œ MCP Tool ํ•ธ๋“ค๋Ÿฌ.
6
+
7
+ ์‚ฌ์šฉ์ž์˜ ์—ฌํ–‰ ์Šคํƒ€์ผ์— ๋”ฐ๋ผ ๋™์ผํ•œ ํฌ์ธํŠธ๋„ ๋‹ค๋ฅธ ๊ฐ€์น˜๋กœ ํ‰๊ฐ€๋ฉ๋‹ˆ๋‹ค:
8
+ - PREMIUM: ๋น„์ฆˆ๋‹ˆ์Šค/ํผ์ŠคํŠธ ๋ฐœ๊ถŒ ๋ชฉํ‘œ โ†’ ๋†’์€ ๊ฐ€์น˜
9
+ - VALUE: ์ผ๋ฐ˜ ์‚ฌ์šฉ โ†’ ํ‘œ์ค€ ๊ฐ€์น˜
10
+ - CASHBACK: ํ˜„๊ธˆ์„ฑ ์‚ฌ์šฉ โ†’ ๋‚ฎ์€ ๊ฐ€์น˜
11
+ """
12
+
13
+ from typing import Dict, Any, List, Optional
14
+ from datetime import datetime
15
+
16
+ from .config import POINT_VALUATIONS, TRAVEL_STYLES
17
+
18
+ # =============================================================================
19
+ # ์œ ํ‹ธ๋ฆฌํ‹ฐ ํ•จ์ˆ˜
20
+ # =============================================================================
21
+
22
+ def calculate_point_value(
23
+ program: str,
24
+ amount: float,
25
+ style: str = "VALUE",
26
+ custom_valuation: Optional[float] = None
27
+ ) -> Dict[str, Any]:
28
+ """
29
+ ๋‹จ์ผ ํฌ์ธํŠธ ํ”„๋กœ๊ทธ๋žจ์˜ ๊ฐ€์น˜๋ฅผ ๊ณ„์‚ฐํ•ฉ๋‹ˆ๋‹ค.
30
+
31
+ Args:
32
+ program: ํ”„๋กœ๊ทธ๋žจ ID (์˜ˆ: AMEX_MR, KOREAN_AIR)
33
+ amount: ํฌ์ธํŠธ/๋งˆ์ผ ์ˆ˜๋Ÿ‰
34
+ style: ์—ฌํ–‰ ์Šคํƒ€์ผ (PREMIUM, VALUE, CASHBACK)
35
+ custom_valuation: ์‚ฌ์šฉ์ž ์ •์˜ ๊ฐ€์น˜ (์žˆ์œผ๋ฉด ์šฐ์„  ์ ์šฉ)
36
+
37
+ Returns:
38
+ ๊ณ„์‚ฐ๋œ ๊ฐ€์น˜ ์ •๋ณด
39
+ """
40
+ if program not in POINT_VALUATIONS:
41
+ return {
42
+ "success": False,
43
+ "error": f"์ง€์›ํ•˜์ง€ ์•Š๋Š” ํ”„๋กœ๊ทธ๋žจ: {program}",
44
+ "supported_programs": list(POINT_VALUATIONS.keys())
45
+ }
46
+
47
+ prog_info = POINT_VALUATIONS[program]
48
+ currency = prog_info.get("currency", "USD")
49
+
50
+ # ๊ฐ€์น˜ ๊ฒฐ์ •: ์‚ฌ์šฉ์ž ์ •์˜ > ์Šคํƒ€์ผ ๊ธฐ๋ฐ˜ > base
51
+ if custom_valuation is not None:
52
+ cpp = custom_valuation
53
+ source = "custom"
54
+ else:
55
+ style_info = TRAVEL_STYLES.get(style, TRAVEL_STYLES["VALUE"])
56
+ multiplier_key = style_info.get("multiplier_key", "base")
57
+ cpp = prog_info.get(multiplier_key, prog_info.get("base", 1.0))
58
+ source = f"style:{style}"
59
+
60
+ # ๊ฐ€์น˜ ๊ณ„์‚ฐ
61
+ if currency == "USD":
62
+ # CPP (cents per point) โ†’ ๋‹ฌ๋Ÿฌ ๋ณ€ํ™˜
63
+ value_usd = (amount * cpp) / 100
64
+ return {
65
+ "success": True,
66
+ "program": program,
67
+ "program_name": prog_info.get("name", program),
68
+ "amount": amount,
69
+ "cpp": cpp,
70
+ "value": round(value_usd, 2),
71
+ "currency": "USD",
72
+ "source": source,
73
+ }
74
+ elif currency == "KRW":
75
+ # ์›/๋งˆ์ผ โ†’ ๊ทธ๋Œ€๋กœ
76
+ value_krw = amount * cpp
77
+ return {
78
+ "success": True,
79
+ "program": program,
80
+ "program_name": prog_info.get("name", program),
81
+ "amount": amount,
82
+ "value_per_unit": cpp,
83
+ "value": round(value_krw, 0),
84
+ "currency": "KRW",
85
+ "source": source,
86
+ }
87
+ else:
88
+ return {
89
+ "success": False,
90
+ "error": f"์ง€์›ํ•˜์ง€ ์•Š๋Š” ํ†ตํ™”: {currency}"
91
+ }
92
+
93
+
94
+ def calculate_total_value(
95
+ assets: List[Dict[str, Any]],
96
+ style: str = "VALUE",
97
+ custom_valuations: Optional[Dict[str, float]] = None
98
+ ) -> Dict[str, Any]:
99
+ """
100
+ ๋ณต์ˆ˜ ์ž์‚ฐ์˜ ์ด ๊ฐ€์น˜๋ฅผ ๊ณ„์‚ฐํ•ฉ๋‹ˆ๋‹ค.
101
+
102
+ Args:
103
+ assets: [{"program": "AMEX_MR", "amount": 50000}, ...]
104
+ style: ์—ฌํ–‰ ์Šคํƒ€์ผ
105
+ custom_valuations: ํ”„๋กœ๊ทธ๋žจ๋ณ„ ์‚ฌ์šฉ์ž ์ •์˜ ๊ฐ€์น˜
106
+
107
+ Returns:
108
+ ์ด ๊ฐ€์น˜ ๋ฐ ๊ฐœ๋ณ„ ์ƒ์„ธ
109
+ """
110
+ if not assets:
111
+ return {
112
+ "success": True,
113
+ "total_usd": 0,
114
+ "total_krw": 0,
115
+ "breakdown": [],
116
+ "message": "ํ‰๊ฐ€ํ•  ์ž์‚ฐ์ด ์—†์Šต๋‹ˆ๋‹ค."
117
+ }
118
+
119
+ custom_valuations = custom_valuations or {}
120
+ breakdown = []
121
+ total_usd = 0
122
+ total_krw = 0
123
+
124
+ for asset in assets:
125
+ program = asset.get("program", "").upper()
126
+ amount = asset.get("amount", 0)
127
+
128
+ if not program or amount <= 0:
129
+ continue
130
+
131
+ custom_val = custom_valuations.get(program)
132
+ result = calculate_point_value(program, amount, style, custom_val)
133
+
134
+ if result.get("success"):
135
+ breakdown.append(result)
136
+ if result.get("currency") == "USD":
137
+ total_usd += result.get("value", 0)
138
+ elif result.get("currency") == "KRW":
139
+ total_krw += result.get("value", 0)
140
+
141
+ return {
142
+ "success": True,
143
+ "style": style,
144
+ "style_description": TRAVEL_STYLES.get(style, {}).get("description", ""),
145
+ "total_usd": round(total_usd, 2),
146
+ "total_krw": round(total_krw, 0),
147
+ "breakdown": breakdown,
148
+ "evaluated_at": datetime.now().isoformat(),
149
+ }
150
+
151
+
152
+ # =============================================================================
153
+ # MCP Tool ํ•ธ๋“ค๋Ÿฌ
154
+ # =============================================================================
155
+
156
+ async def handle_get_asset_valuation(
157
+ arguments: Dict[str, Any],
158
+ user_id: str
159
+ ) -> Dict[str, Any]:
160
+ """
161
+ user_get_asset_valuation Tool ํ•ธ๋“ค๋Ÿฌ.
162
+
163
+ ์‚ฌ์šฉ์ž์˜ ํฌ์ธํŠธ/๋งˆ์ผ๋ฆฌ์ง€ ์ž์‚ฐ ์ด ๊ฐ€์น˜๋ฅผ ๊ณ„์‚ฐํ•ฉ๋‹ˆ๋‹ค.
164
+ """
165
+ from src.db import SupabaseAdapter
166
+
167
+ adapter = SupabaseAdapter()
168
+
169
+ # 1. ์‚ฌ์šฉ์ž ์„ค์ • ์กฐํšŒ
170
+ prefs = adapter.get_valuation_preferences(user_id)
171
+ style = prefs.get("travel_style", "VALUE") if prefs else "VALUE"
172
+ custom_valuations = prefs.get("custom_valuations", {}) if prefs else {}
173
+
174
+ # 2. ์ž…๋ ฅ ์ž์‚ฐ ํŒŒ์‹ฑ
175
+ # ๋ฐฉ๋ฒ• A: ์ง์ ‘ ์ž…๋ ฅ
176
+ assets = arguments.get("assets", [])
177
+
178
+ # ๋ฐฉ๋ฒ• B: DB์—์„œ ์กฐํšŒ (ํ–ฅํ›„ user_mileage_assets ํ…Œ์ด๋ธ”)
179
+ if not assets and arguments.get("from_db", False):
180
+ # ์•„์ง ๊ตฌํ˜„๋˜์ง€ ์•Š์Œ
181
+ return {
182
+ "success": False,
183
+ "error": "DB ์ž์‚ฐ ์กฐํšŒ ๊ธฐ๋Šฅ์€ ์•„์ง ๊ตฌํ˜„๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. assets ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ์ง์ ‘ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.",
184
+ "example": {
185
+ "assets": [
186
+ {"program": "AMEX_MR", "amount": 50000},
187
+ {"program": "KOREAN_AIR", "amount": 45000}
188
+ ]
189
+ }
190
+ }
191
+
192
+ if not assets:
193
+ return {
194
+ "success": False,
195
+ "error": "ํ‰๊ฐ€ํ•  ์ž์‚ฐ์ด ์—†์Šต๋‹ˆ๋‹ค.",
196
+ "usage": "assets ํŒŒ๋ผ๋ฏธํ„ฐ์— [{\"program\": \"AMEX_MR\", \"amount\": 50000}] ํ˜•์‹์œผ๋กœ ์ž…๋ ฅํ•˜์„ธ์š”.",
197
+ "supported_programs": list(POINT_VALUATIONS.keys())
198
+ }
199
+
200
+ # 3. ์Šคํƒ€์ผ ์˜ค๋ฒ„๋ผ์ด๋“œ (์ธ์ž๋กœ ์ „๋‹ฌ๋œ ๊ฒฝ์šฐ)
201
+ if "style" in arguments:
202
+ style = arguments["style"].upper()
203
+ if style not in TRAVEL_STYLES:
204
+ return {
205
+ "success": False,
206
+ "error": f"์ง€์›ํ•˜์ง€ ์•Š๋Š” ์Šคํƒ€์ผ: {style}",
207
+ "valid_styles": list(TRAVEL_STYLES.keys())
208
+ }
209
+
210
+ # 4. ๊ฐ€์น˜ ๊ณ„์‚ฐ
211
+ result = calculate_total_value(assets, style, custom_valuations)
212
+
213
+ # 5. ์‘๋‹ต ๋ณด๊ฐ•
214
+ result["user_style"] = style
215
+ result["tip"] = (
216
+ "์—ฌํ–‰ ์Šคํƒ€์ผ์— ๋”ฐ๋ผ ๊ฐ™์€ ํฌ์ธํŠธ๋„ ๋‹ค๋ฅด๊ฒŒ ํ‰๊ฐ€๋ฉ๋‹ˆ๋‹ค. "
217
+ "user_update_valuation_style๋กœ ์Šคํƒ€์ผ์„ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."
218
+ )
219
+
220
+ return result
221
+
222
+
223
+ async def handle_update_valuation_style(
224
+ arguments: Dict[str, Any],
225
+ user_id: str
226
+ ) -> Dict[str, Any]:
227
+ """
228
+ user_update_valuation_style Tool ํ•ธ๋“ค๋Ÿฌ.
229
+
230
+ ์‚ฌ์šฉ์ž์˜ ์—ฌํ–‰ ์Šคํƒ€์ผ ์„ค์ •์„ ๋ณ€๊ฒฝํ•ฉ๋‹ˆ๋‹ค.
231
+ """
232
+ from src.db import SupabaseAdapter
233
+
234
+ style = arguments.get("style", "").upper()
235
+ custom_valuations = arguments.get("custom_valuations")
236
+
237
+ # ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ
238
+ if style and style not in TRAVEL_STYLES:
239
+ return {
240
+ "success": False,
241
+ "error": f"์œ ํšจํ•˜์ง€ ์•Š์€ ์Šคํƒ€์ผ: {style}",
242
+ "valid_styles": {
243
+ k: v.get("description") for k, v in TRAVEL_STYLES.items()
244
+ }
245
+ }
246
+
247
+ adapter = SupabaseAdapter()
248
+
249
+ # ํ˜„์žฌ ์„ค์ • ์กฐํšŒ
250
+ current_prefs = adapter.get_valuation_preferences(user_id)
251
+
252
+ # ์—…๋ฐ์ดํŠธํ•  ๋ฐ์ดํ„ฐ ๊ตฌ์„ฑ
253
+ new_style = style if style else (current_prefs.get("travel_style", "VALUE") if current_prefs else "VALUE")
254
+ new_custom = custom_valuations if custom_valuations is not None else (
255
+ current_prefs.get("custom_valuations", {}) if current_prefs else {}
256
+ )
257
+
258
+ # ์ €์žฅ
259
+ result = adapter.upsert_valuation_preferences(
260
+ user_id=user_id,
261
+ travel_style=new_style,
262
+ custom_valuations=new_custom
263
+ )
264
+
265
+ if result:
266
+ return {
267
+ "success": True,
268
+ "message": f"์—ฌํ–‰ ์Šคํƒ€์ผ์ด '{new_style}'(์œผ)๋กœ ์„ค์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค.",
269
+ "style": new_style,
270
+ "style_description": TRAVEL_STYLES.get(new_style, {}).get("description", ""),
271
+ "custom_valuations": new_custom if new_custom else None,
272
+ }
273
+ else:
274
+ return {
275
+ "success": False,
276
+ "error": "์„ค์ • ์ €์žฅ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."
277
+ }
278
+
279
+
280
+ async def handle_get_valuation_styles(
281
+ arguments: Dict[str, Any],
282
+ user_id: str
283
+ ) -> Dict[str, Any]:
284
+ """
285
+ ์ง€์›ํ•˜๋Š” ์—ฌํ–‰ ์Šคํƒ€์ผ ๋ฐ ํฌ์ธํŠธ ํ”„๋กœ๊ทธ๋žจ ๋ชฉ๋ก ๋ฐ˜ํ™˜.
286
+ """
287
+ from src.db import SupabaseAdapter
288
+
289
+ adapter = SupabaseAdapter()
290
+
291
+ # ํ˜„์žฌ ์‚ฌ์šฉ์ž ์„ค์ •
292
+ prefs = adapter.get_valuation_preferences(user_id)
293
+ current_style = prefs.get("travel_style", "VALUE") if prefs else "VALUE"
294
+
295
+ # ์Šคํƒ€์ผ๋ณ„ ์˜ˆ์‹œ ๊ฐ€์น˜ (AMEX MR 10,000์  ๊ธฐ์ค€)
296
+ style_examples = {}
297
+ for style_name, style_info in TRAVEL_STYLES.items():
298
+ result = calculate_point_value("AMEX_MR", 10000, style_name)
299
+ if result.get("success"):
300
+ style_examples[style_name] = {
301
+ "description": style_info.get("description"),
302
+ "example_value": f"${result.get('value')} (10,000 MR)"
303
+ }
304
+
305
+ return {
306
+ "success": True,
307
+ "current_style": current_style,
308
+ "current_description": TRAVEL_STYLES.get(current_style, {}).get("description", ""),
309
+ "available_styles": style_examples,
310
+ "supported_programs": {
311
+ k: v.get("name") for k, v in POINT_VALUATIONS.items()
312
+ }
313
+ }
314
+
315
+
316
+ # =============================================================================
317
+ # ์›-ํˆฌ-๋งˆ์ผ ๊ณ„์‚ฐ๊ธฐ
318
+ # =============================================================================
319
+
320
+ async def handle_calculate_miles_vs_cashback(
321
+ arguments: Dict[str, Any],
322
+ user_id: str
323
+ ) -> Dict[str, Any]:
324
+ """
325
+ calculate_miles_vs_cashback Tool ํ•ธ๋“ค๋Ÿฌ.
326
+
327
+ ๋งˆ์ผ๋ฆฌ์ง€ ์ ๋ฆฝ ์นด๋“œ vs ํ˜„๊ธˆ ํ• ์ธ ์นด๋“œ ๋น„๊ต.
328
+ """
329
+ from src.db import SupabaseAdapter
330
+
331
+ # ํ•„์ˆ˜ ํŒŒ๋ผ๋ฏธํ„ฐ
332
+ amount = arguments.get("amount", 0)
333
+
334
+ # ๋งˆ์ผ๋ฆฌ์ง€ ์ ๋ฆฝ ์˜ต์…˜
335
+ mile_rate = arguments.get("mile_rate", 1) # 1000์›๋‹น ๋งˆ์ผ ์ˆ˜
336
+ mile_per = arguments.get("mile_per", 1000) # ๊ธฐ์ค€ ๊ธˆ์•ก (์›)
337
+ mile_program = arguments.get("mile_program", "KOREAN_AIR")
338
+
339
+ # ํ• ์ธ ์˜ต์…˜
340
+ discount_percent = arguments.get("discount_percent", 1.5)
341
+
342
+ if amount <= 0:
343
+ return {
344
+ "success": False,
345
+ "error": "๊ฒฐ์ œ ๊ธˆ์•ก(amount)์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.",
346
+ "example": {
347
+ "amount": 100000,
348
+ "mile_rate": 1,
349
+ "mile_per": 1000,
350
+ "discount_percent": 1.5
351
+ }
352
+ }
353
+
354
+ # ์‚ฌ์šฉ์ž ์Šคํƒ€์ผ ์กฐํšŒ
355
+ adapter = SupabaseAdapter()
356
+ prefs = adapter.get_valuation_preferences(user_id)
357
+ style = prefs.get("travel_style", "VALUE") if prefs else "VALUE"
358
+
359
+ # ๋งˆ์ผ๋ฆฌ์ง€ ๊ณ„์‚ฐ
360
+ earned_miles = (amount / mile_per) * mile_rate
361
+
362
+ # ๋งˆ์ผ ๊ฐ€์น˜ ๊ณ„์‚ฐ
363
+ mile_value_result = calculate_point_value(mile_program, earned_miles, style)
364
+ if not mile_value_result.get("success"):
365
+ return mile_value_result
366
+
367
+ # ํ†ตํ™” ๋ณ€ํ™˜ (USD โ†’ KRW ๊ทผ์‚ฌ: 1 USD = 1350 KRW)
368
+ if mile_value_result.get("currency") == "USD":
369
+ mile_value_krw = mile_value_result.get("value", 0) * 1350
370
+ else:
371
+ mile_value_krw = mile_value_result.get("value", 0)
372
+
373
+ # ํ• ์ธ ๊ธˆ์•ก ๊ณ„์‚ฐ
374
+ discount_value_krw = amount * (discount_percent / 100)
375
+
376
+ # ๋น„๊ต
377
+ difference = mile_value_krw - discount_value_krw
378
+ winner = "๋งˆ์ผ๋ฆฌ์ง€" if difference > 0 else ("ํ• ์ธ" if difference < 0 else "๋™๋“ฑ")
379
+
380
+ return {
381
+ "success": True,
382
+ "amount": amount,
383
+ "comparison": {
384
+ "mileage": {
385
+ "earned_miles": round(earned_miles, 0),
386
+ "program": mile_program,
387
+ "program_name": mile_value_result.get("program_name"),
388
+ "value_krw": round(mile_value_krw, 0),
389
+ "rate_description": f"{mile_rate}๋งˆ์ผ/{mile_per}์›"
390
+ },
391
+ "cashback": {
392
+ "discount_percent": discount_percent,
393
+ "value_krw": round(discount_value_krw, 0)
394
+ }
395
+ },
396
+ "result": {
397
+ "winner": winner,
398
+ "difference_krw": round(abs(difference), 0),
399
+ "recommendation": (
400
+ f"๋งˆ์ผ๋ฆฌ์ง€ ์นด๋“œ๊ฐ€ {round(abs(difference), 0):,}์› ๋” ์œ ๋ฆฌํ•ฉ๋‹ˆ๋‹ค."
401
+ if difference > 0 else
402
+ f"ํ• ์ธ ์นด๋“œ๊ฐ€ {round(abs(difference), 0):,}์› ๋” ์œ ๋ฆฌํ•ฉ๋‹ˆ๋‹ค."
403
+ if difference < 0 else
404
+ "๋‘ ์˜ต์…˜์ด ๋™๋“ฑํ•ฉ๋‹ˆ๋‹ค."
405
+ )
406
+ },
407
+ "style": style,
408
+ "note": f"'{style}' ์Šคํƒ€์ผ ๊ธฐ์ค€์œผ๋กœ ๊ณ„์‚ฐ๋จ. ์Šคํƒ€์ผ์— ๋”ฐ๋ผ ๋งˆ์ผ ๊ฐ€์น˜๊ฐ€ ๋‹ฌ๋ผ์ง‘๋‹ˆ๋‹ค."
409
+ }
410
+
411
+
412
+ # =============================================================================
413
+ # ๋”ฅ๋งํฌ ๋นŒ๋” (Award Search)
414
+ # =============================================================================
415
+
416
+ # ์ฃผ์š” ๊ณตํ•ญ ์ฝ”๋“œ
417
+ AIRPORT_CODES = {
418
+ # ํ•œ๊ตญ
419
+ "์„œ์šธ": "ICN", "์ธ์ฒœ": "ICN", "๊น€ํฌ": "GMP",
420
+ "๋ถ€์‚ฐ": "PUS", "์ œ์ฃผ": "CJU",
421
+ # ์ผ๋ณธ
422
+ "๋„์ฟ„": "NRT", "๋‚˜๋ฆฌํƒ€": "NRT", "ํ•˜๋„ค๋‹ค": "HND",
423
+ "์˜ค์‚ฌ์นด": "KIX", "๊ฐ„์‚ฌ์ด": "KIX",
424
+ "ํ›„์ฟ ์˜ค์นด": "FUK", "์‚ฟํฌ๋กœ": "CTS",
425
+ # ๋™๋‚จ์•„
426
+ "๋ฐฉ์ฝ•": "BKK", "์‹ฑ๊ฐ€ํฌ๋ฅด": "SIN",
427
+ "ํ˜ธ์น˜๋ฏผ": "SGN", "๋‹ค๋‚ญ": "DAD", "ํ•˜๋…ธ์ด": "HAN",
428
+ "๋ฐœ๋ฆฌ": "DPS", "๋งˆ๋‹๋ผ": "MNL",
429
+ # ์ค‘๊ตญ/ํ™์ฝฉ
430
+ "ํ™์ฝฉ": "HKG", "๋ฒ ์ด์ง•": "PEK", "์ƒํ•˜์ด": "PVG",
431
+ # ์œ ๋Ÿฝ
432
+ "๋Ÿฐ๋˜": "LHR", "ํŒŒ๋ฆฌ": "CDG",
433
+ "ํ”„๋ž‘ํฌํ‘ธ๋ฅดํŠธ": "FRA", "์•”์Šคํ…Œ๋ฅด๋‹ด": "AMS",
434
+ "๋กœ๋งˆ": "FCO", "๋ฐ€๋ผ๋…ธ": "MXP",
435
+ "๋ฐ”๋ฅด์…€๋กœ๋‚˜": "BCN", "๋งˆ๋“œ๋ฆฌ๋“œ": "MAD",
436
+ # ๋ฏธ๊ตญ
437
+ "๋‰ด์š•": "JFK", "๋กœ์Šค์•ค์ ค๋ ˆ์Šค": "LAX", "la": "LAX",
438
+ "์ƒŒํ”„๋ž€์‹œ์Šค์ฝ”": "SFO", "์‹œ์นด๊ณ ": "ORD",
439
+ "์‹œ์• ํ‹€": "SEA", "๋‹ฌ๋ผ์Šค": "DFW",
440
+ "๋งˆ์ด์• ๋ฏธ": "MIA", "ํ•˜์™€์ด": "HNL", "ํ˜ธ๋†€๋ฃฐ๋ฃจ": "HNL",
441
+ # ํ˜ธ์ฃผ
442
+ "์‹œ๋“œ๋‹ˆ": "SYD", "๋ฉœ๋ฒ„๋ฅธ": "MEL",
443
+ }
444
+
445
+ # ์ขŒ์„ ํด๋ž˜์Šค
446
+ CABIN_CLASSES = {
447
+ "์ด์ฝ”๋…ธ๋ฏธ": "Y", "economy": "Y",
448
+ "ํ”„๋ฆฌ๋ฏธ์—„ ์ด์ฝ”๋…ธ๋ฏธ": "W", "premium economy": "W", "ํ”„๋ฆฌ์ด์ฝ”": "W",
449
+ "๋น„์ฆˆ๋‹ˆ์Šค": "J", "business": "J", "๋น„์ง€๋‹ˆ์Šค": "J",
450
+ "ํผ์ŠคํŠธ": "F", "first": "F", "์ผ๋“ฑ์„": "F",
451
+ }
452
+
453
+
454
+ async def handle_generate_award_search_link(
455
+ arguments: Dict[str, Any],
456
+ user_id: str
457
+ ) -> Dict[str, Any]:
458
+ """
459
+ generate_award_search_link Tool ํ•ธ๋“ค๋Ÿฌ.
460
+
461
+ ์ž์—ฐ์–ด ์ž…๋ ฅ์„ based on Seats.aero/Point.me ๊ฒ€์ƒ‰ URL๋กœ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค.
462
+ """
463
+ # ํŒŒ๋ผ๋ฏธํ„ฐ ์ถ”์ถœ
464
+ origin = arguments.get("origin", "ICN")
465
+ destination = arguments.get("destination", "")
466
+ date = arguments.get("date", "") # YYYY-MM ๋˜๋Š” YYYY-MM-DD
467
+ cabin_class = arguments.get("cabin_class", "J") # ๊ธฐ๋ณธ ๋น„์ฆˆ๋‹ˆ์Šค
468
+
469
+ # ๋„์‹œ๋ช… โ†’ ๊ณตํ•ญ ์ฝ”๋“œ ๋ณ€ํ™˜
470
+ if origin.lower() in AIRPORT_CODES:
471
+ origin = AIRPORT_CODES[origin.lower()]
472
+ if destination.lower() in AIRPORT_CODES:
473
+ destination = AIRPORT_CODES[destination.lower()]
474
+
475
+ # ์ขŒ์„ ํด๋ž˜์Šค ๋ณ€ํ™˜
476
+ if cabin_class.lower() in CABIN_CLASSES:
477
+ cabin_class = CABIN_CLASSES[cabin_class.lower()]
478
+
479
+ if not destination:
480
+ return {
481
+ "success": False,
482
+ "error": "๋ชฉ์ ์ง€(destination)๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.",
483
+ "supported_cities": list(AIRPORT_CODES.keys())[:20],
484
+ "example": {
485
+ "origin": "์„œ์šธ",
486
+ "destination": "ํŒŒ๋ฆฌ",
487
+ "date": "2026-03",
488
+ "cabin_class": "๋น„์ฆˆ๋‹ˆ์Šค"
489
+ }
490
+ }
491
+
492
+ # URL ์ƒ์„ฑ
493
+ origin = origin.upper()
494
+ destination = destination.upper()
495
+ cabin_class = cabin_class.upper()
496
+
497
+ # Seats.aero URL
498
+ seats_aero_url = f"https://seats.aero/search?origin={origin}&destination={destination}"
499
+ if date:
500
+ seats_aero_url += f"&date={date}"
501
+ if cabin_class:
502
+ seats_aero_url += f"&class={cabin_class}"
503
+
504
+ # Point.me URL (๋‹ค๋ฅธ ํฌ๋งท)
505
+ point_me_url = f"https://point.me/search/{origin}-{destination}"
506
+ if date:
507
+ point_me_url += f"?date={date}"
508
+
509
+ return {
510
+ "success": True,
511
+ "search_params": {
512
+ "origin": origin,
513
+ "destination": destination,
514
+ "date": date or "flexible",
515
+ "cabin_class": cabin_class,
516
+ "cabin_class_name": {
517
+ "Y": "์ด์ฝ”๋…ธ๋ฏธ", "W": "ํ”„๋ฆฌ๋ฏธ์—„ ์ด์ฝ”๋…ธ๋ฏธ",
518
+ "J": "๋น„์ฆˆ๋‹ˆ์Šค", "F": "ํผ์ŠคํŠธ"
519
+ }.get(cabin_class, cabin_class)
520
+ },
521
+ "links": {
522
+ "seats_aero": seats_aero_url,
523
+ "point_me": point_me_url,
524
+ },
525
+ "tip": (
526
+ "Seats.aero๋Š” ํŠน๊ฐ€ ์•Œ๋ฆผ๊ณผ ์ƒ์„ธ ๊ฒ€์ƒ‰์—, "
527
+ "Point.me๋Š” ๋‹ค์–‘ํ•œ ๋งˆ์ผ๋ฆฌ์ง€ ํ”„๋กœ๊ทธ๋žจ ๋น„๊ต์— ์ ํ•ฉํ•ฉ๋‹ˆ๋‹ค."
528
+ )
529
+ }
530
+
src/db/supabase_adapter.py CHANGED
@@ -204,13 +204,46 @@ class SupabaseAdapter:
204
 
205
  records.append(record)
206
 
 
 
 
 
 
 
 
 
207
  try:
208
- result = self.client.table("kb_chunks").upsert(
209
- records,
210
- on_conflict="chunk_id"
211
- ).execute()
212
-
213
- return len(result.data) if result.data else len(records)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
 
215
  except Exception as e:
216
  raise RuntimeError(f"์ฒญํฌ ์ €์žฅ ์‹คํŒจ: {e}") from e
@@ -455,6 +488,314 @@ class SupabaseAdapter:
455
  except Exception as e:
456
  print(f"โš ๏ธ ๋ฉค๋ฒ„์‹ญ ์‚ญ์ œ ์‹คํŒจ ({user_id}, {chain}): {e}")
457
  return False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
458
 
459
 
460
  # =============================================================================
 
204
 
205
  records.append(record)
206
 
207
+ # ๋ฐฐ์น˜ ์ฒ˜๋ฆฌ (Supabase timeout ๋ฐฉ์ง€)
208
+ BATCH_SIZE = 30 # 50 โ†’ 30์œผ๋กœ ์ค„์—ฌ ์•ˆ์ •์„ฑ ํ–ฅ์ƒ
209
+ MAX_RETRIES = 3
210
+ BATCH_DELAY = 0.5 # ๋ฐฐ์น˜ ๊ฐ„ 0.5์ดˆ ๋”œ๋ ˆ์ด
211
+ total_saved = 0
212
+
213
+ import time
214
+
215
  try:
216
+ for i in range(0, len(records), BATCH_SIZE):
217
+ batch = records[i:i + BATCH_SIZE]
218
+
219
+ # ์žฌ์‹œ๋„ ๋กœ์ง
220
+ for attempt in range(MAX_RETRIES):
221
+ try:
222
+ result = self.client.table("kb_chunks").upsert(
223
+ batch,
224
+ on_conflict="chunk_id"
225
+ ).execute()
226
+
227
+ batch_saved = len(result.data) if result.data else len(batch)
228
+ total_saved += batch_saved
229
+ break # ์„ฑ๊ณต ์‹œ ์žฌ์‹œ๋„ ๋ฃจํ”„ ์ข…๋ฃŒ
230
+
231
+ except Exception as e:
232
+ if attempt < MAX_RETRIES - 1:
233
+ print(f" โš ๏ธ ๋ฐฐ์น˜ ์ €์žฅ ์‹คํŒจ (์‹œ๋„ {attempt + 1}/{MAX_RETRIES}), ์žฌ์‹œ๋„ ์ค‘...")
234
+ time.sleep(2) # ์žฌ์‹œ๋„ ์ „ 2์ดˆ ๋Œ€๊ธฐ
235
+ else:
236
+ raise # ๋งˆ์ง€๋ง‰ ์‹œ๋„ ์‹คํŒจ ์‹œ ์˜ˆ์™ธ ์ „ํŒŒ
237
+
238
+ # ์ง„ํ–‰ ์ƒํ™ฉ ์ถœ๋ ฅ (100๊ฐœ๋งˆ๋‹ค)
239
+ if (i + BATCH_SIZE) % 100 == 0 or i + BATCH_SIZE >= len(records):
240
+ print(f" ๐Ÿ’พ {min(i + BATCH_SIZE, len(records))}/{len(records)} ์ฒญํฌ ์ €์žฅ ์™„๋ฃŒ")
241
+
242
+ # ๋ฐฐ์น˜ ๊ฐ„ ๋”œ๋ ˆ์ด (์„œ๋ฒ„ ์—ฐ๊ฒฐ ์œ ์ง€)
243
+ if i + BATCH_SIZE < len(records):
244
+ time.sleep(BATCH_DELAY)
245
+
246
+ return total_saved
247
 
248
  except Exception as e:
249
  raise RuntimeError(f"์ฒญํฌ ์ €์žฅ ์‹คํŒจ: {e}") from e
 
488
  except Exception as e:
489
  print(f"โš ๏ธ ๋ฉค๋ฒ„์‹ญ ์‚ญ์ œ ์‹คํŒจ ({user_id}, {chain}): {e}")
490
  return False
491
+
492
+ # =========================================================================
493
+ # ์‹ ์šฉ์นด๋“œ (Credit Cards)
494
+ # =========================================================================
495
+
496
+ def get_user_credit_cards(self, user_id: str) -> List[Dict[str, Any]]:
497
+ """
498
+ ์‚ฌ์šฉ์ž์˜ ๋ณด์œ  ์‹ ์šฉ์นด๋“œ ๋ชฉ๋ก ์กฐํšŒ.
499
+
500
+ Args:
501
+ user_id: Supabase Auth UUID
502
+
503
+ Returns:
504
+ ์‹ ์šฉ์นด๋“œ ๋ฆฌ์ŠคํŠธ
505
+ """
506
+ try:
507
+ result = self.client.table("user_credit_cards")\
508
+ .select("*")\
509
+ .eq("user_id", user_id)\
510
+ .eq("is_active", True)\
511
+ .order("created_at")\
512
+ .execute()
513
+
514
+ return result.data or []
515
+ except Exception as e:
516
+ print(f"โš ๏ธ ์‹ ์šฉ์นด๋“œ ์กฐํšŒ ์‹คํŒจ ({user_id}): {e}")
517
+ return []
518
+
519
+ def upsert_credit_card(
520
+ self,
521
+ user_id: str,
522
+ card_id: str,
523
+ card_name: str,
524
+ issuer_code: str,
525
+ region: str = "USA",
526
+ card_open_date: str = None,
527
+ anniversary_month: int = None,
528
+ annual_fee: float = None
529
+ ) -> Optional[Dict[str, Any]]:
530
+ """
531
+ ์‹ ์šฉ์นด๋“œ ๋“ฑ๋ก/์ˆ˜์ •.
532
+
533
+ Args:
534
+ user_id: Supabase Auth UUID
535
+ card_id: ์นด๋“œ ๊ณ ์œ  ID (AMEX_PLATINUM_US ๋“ฑ)
536
+ card_name: ์นด๋“œ ์ด๋ฆ„
537
+ issuer_code: ๋ฐœ๊ธ‰์‚ฌ ์ฝ”๋“œ
538
+ region: ๋ฐœ๊ธ‰ ๊ตญ๊ฐ€
539
+ card_open_date: ์นด๋“œ ๊ฐœ์„ค์ผ (YYYY-MM-DD)
540
+ anniversary_month: ์—ฐํšŒ๋น„ ๊ฐฑ์‹  ์›” (1-12)
541
+ annual_fee: ์—ฐํšŒ๋น„
542
+
543
+ Returns:
544
+ ์ €์žฅ๋œ ์นด๋“œ ์ •๋ณด ๋˜๋Š” None
545
+ """
546
+ try:
547
+ data = {
548
+ "user_id": user_id,
549
+ "card_id": card_id.upper(),
550
+ "card_name": card_name,
551
+ "issuer_code": issuer_code.upper(),
552
+ "region": region.upper(),
553
+ "is_active": True
554
+ }
555
+
556
+ if card_open_date:
557
+ data["card_open_date"] = card_open_date
558
+ if anniversary_month:
559
+ data["anniversary_month"] = anniversary_month
560
+ if annual_fee is not None:
561
+ data["annual_fee_amount"] = annual_fee
562
+
563
+ # user_id + card_id ๋ณตํ•ฉํ‚ค๋กœ upsert
564
+ self.client.table("user_credit_cards")\
565
+ .delete()\
566
+ .eq("user_id", user_id)\
567
+ .eq("card_id", card_id.upper())\
568
+ .execute()
569
+
570
+ result = self.client.table("user_credit_cards")\
571
+ .insert(data)\
572
+ .execute()
573
+
574
+ return result.data[0] if result.data else None
575
+ except Exception as e:
576
+ print(f"โš ๏ธ ์‹ ์šฉ์นด๋“œ ์ €์žฅ ์‹คํŒจ ({user_id}, {card_id}): {e}")
577
+ return None
578
+
579
+ def delete_credit_card(self, user_id: str, card_id: str) -> bool:
580
+ """
581
+ ์‹ ์šฉ์นด๋“œ ์‚ญ์ œ (soft delete - is_active = False).
582
+
583
+ Args:
584
+ user_id: Supabase Auth UUID
585
+ card_id: ์นด๋“œ ๊ณ ์œ  ID
586
+
587
+ Returns:
588
+ ์‚ญ์ œ ์„ฑ๊ณต ์—ฌ๋ถ€
589
+ """
590
+ try:
591
+ self.client.table("user_credit_cards")\
592
+ .update({"is_active": False})\
593
+ .eq("user_id", user_id)\
594
+ .eq("card_id", card_id.upper())\
595
+ .execute()
596
+
597
+ return True
598
+ except Exception as e:
599
+ print(f"โš ๏ธ ์‹ ์šฉ์นด๋“œ ์‚ญ์ œ ์‹คํŒจ ({user_id}, {card_id}): {e}")
600
+ return False
601
+
602
+ # =========================================================================
603
+ # ํฌ๋ ˆ๋”ง ์‚ฌ์šฉ ์ถ”์  (Credit Usage Tracking)
604
+ # =========================================================================
605
+
606
+ def get_user_credit_usage(
607
+ self,
608
+ user_id: str,
609
+ card_id: str = None,
610
+ benefit_id: str = None
611
+ ) -> List[Dict[str, Any]]:
612
+ """
613
+ ์‚ฌ์šฉ์ž์˜ ํฌ๋ ˆ๋”ง ์‚ฌ์šฉ ๊ธฐ๋ก ์กฐํšŒ.
614
+
615
+ Args:
616
+ user_id: Supabase Auth UUID
617
+ card_id: ํŠน์ • ์นด๋“œ๋กœ ํ•„ํ„ฐ๋ง (์„ ํƒ)
618
+ benefit_id: ํŠน์ • ํ˜œํƒ์œผ๋กœ ํ•„ํ„ฐ๋ง (์„ ํƒ)
619
+
620
+ Returns:
621
+ ์‚ฌ์šฉ ๊ธฐ๋ก ๋ฆฌ์ŠคํŠธ
622
+ """
623
+ try:
624
+ query = self.client.table("user_credit_usage")\
625
+ .select("*")\
626
+ .eq("user_id", user_id)
627
+
628
+ if card_id:
629
+ query = query.eq("card_id", card_id.upper())
630
+ if benefit_id:
631
+ query = query.eq("benefit_id", benefit_id.lower())
632
+
633
+ result = query.order("usage_period", desc=True).execute()
634
+
635
+ return result.data or []
636
+ except Exception as e:
637
+ print(f"โš ๏ธ ํฌ๋ ˆ๋”ง ์‚ฌ์šฉ ์กฐํšŒ ์‹คํŒจ ({user_id}): {e}")
638
+ return []
639
+
640
+ def upsert_credit_usage(
641
+ self,
642
+ user_id: str,
643
+ card_id: str,
644
+ benefit_id: str,
645
+ usage_period: str,
646
+ amount_used: float,
647
+ amount_limit: float,
648
+ currency: str = "USD",
649
+ usage_date: str = None,
650
+ description: str = None
651
+ ) -> Optional[Dict[str, Any]]:
652
+ """
653
+ ํฌ๋ ˆ๋”ง ์‚ฌ์šฉ ๊ธฐ๋ก ์ €์žฅ/์ˆ˜์ •.
654
+
655
+ Args:
656
+ user_id: Supabase Auth UUID
657
+ card_id: ์นด๋“œ ID
658
+ benefit_id: ํ˜œํƒ ID
659
+ usage_period: ์‚ฌ์šฉ ๊ธฐ๊ฐ„ (2026-H1, 2026-Q1, 2026-01 ๋“ฑ)
660
+ amount_used: ์‚ฌ์šฉ ๊ธˆ์•ก
661
+ amount_limit: ํ•œ๋„ ๊ธˆ์•ก
662
+ currency: ํ†ตํ™”
663
+ usage_date: ์‹ค์ œ ์‚ฌ์šฉ์ผ (YYYY-MM-DD)
664
+ description: ์‚ฌ์šฉ ๋‚ด์—ญ
665
+
666
+ Returns:
667
+ ์ €์žฅ๋œ ๊ธฐ๋ก ๋˜๋Š” None
668
+ """
669
+ try:
670
+ data = {
671
+ "user_id": user_id,
672
+ "card_id": card_id.upper(),
673
+ "benefit_id": benefit_id.lower(),
674
+ "usage_period": usage_period,
675
+ "amount_used": amount_used,
676
+ "amount_limit": amount_limit,
677
+ "currency": currency
678
+ }
679
+
680
+ if usage_date:
681
+ data["usage_date"] = usage_date
682
+ if description:
683
+ data["description"] = description
684
+
685
+ # user_id + card_id + benefit_id + usage_period ๋ณตํ•ฉํ‚ค๋กœ upsert
686
+ self.client.table("user_credit_usage")\
687
+ .delete()\
688
+ .eq("user_id", user_id)\
689
+ .eq("card_id", card_id.upper())\
690
+ .eq("benefit_id", benefit_id.lower())\
691
+ .eq("usage_period", usage_period)\
692
+ .execute()
693
+
694
+ result = self.client.table("user_credit_usage")\
695
+ .insert(data)\
696
+ .execute()
697
+
698
+ return result.data[0] if result.data else None
699
+ except Exception as e:
700
+ print(f"โš ๏ธ ํฌ๋ ˆ๋”ง ์‚ฌ์šฉ ์ €์žฅ ์‹คํŒจ ({user_id}, {card_id}, {benefit_id}): {e}")
701
+ return None
702
+
703
+ def delete_credit_usage(
704
+ self,
705
+ user_id: str,
706
+ card_id: str,
707
+ benefit_id: str,
708
+ usage_period: str
709
+ ) -> bool:
710
+ """
711
+ ํฌ๋ ˆ๋”ง ์‚ฌ์šฉ ๊ธฐ๋ก ์‚ญ์ œ.
712
+
713
+ Args:
714
+ user_id: Supabase Auth UUID
715
+ card_id: ์นด๋“œ ID
716
+ benefit_id: ํ˜œํƒ ID
717
+ usage_period: ์‚ฌ์šฉ ๊ธฐ๊ฐ„
718
+
719
+ Returns:
720
+ ์‚ญ์ œ ์„ฑ๊ณต ์—ฌ๋ถ€
721
+ """
722
+ try:
723
+ self.client.table("user_credit_usage")\
724
+ .delete()\
725
+ .eq("user_id", user_id)\
726
+ .eq("card_id", card_id.upper())\
727
+ .eq("benefit_id", benefit_id.lower())\
728
+ .eq("usage_period", usage_period)\
729
+ .execute()
730
+
731
+ return True
732
+ except Exception as e:
733
+ print(f"โš ๏ธ ํฌ๋ ˆ๋”ง ์‚ฌ์šฉ ์‚ญ์ œ ์‹คํŒจ: {e}")
734
+ return False
735
+
736
+ # =========================================================================
737
+ # Valuation Preferences (๊ฐ€์น˜ ํ‰๊ฐ€ ์„ค์ •)
738
+ # =========================================================================
739
+
740
+ def get_valuation_preferences(self, user_id: str) -> Optional[Dict[str, Any]]:
741
+ """
742
+ ์‚ฌ์šฉ์ž์˜ ๊ฐ€์น˜ ํ‰๊ฐ€ ์„ค์ • ์กฐํšŒ.
743
+
744
+ Args:
745
+ user_id: Supabase Auth UUID
746
+
747
+ Returns:
748
+ ์„ค์ • ์ •๋ณด ๋˜๋Š” None
749
+ """
750
+ try:
751
+ result = self.client.table("user_valuation_preferences")\
752
+ .select("*")\
753
+ .eq("user_id", user_id)\
754
+ .execute()
755
+
756
+ return result.data[0] if result.data else None
757
+ except Exception as e:
758
+ print(f"โš ๏ธ ๊ฐ€์น˜ ํ‰๊ฐ€ ์„ค์ • ์กฐํšŒ ์‹คํŒจ ({user_id}): {e}")
759
+ return None
760
+
761
+ def upsert_valuation_preferences(
762
+ self,
763
+ user_id: str,
764
+ travel_style: str = "VALUE",
765
+ custom_valuations: Optional[Dict[str, float]] = None
766
+ ) -> Optional[Dict[str, Any]]:
767
+ """
768
+ ์‚ฌ์šฉ์ž์˜ ๊ฐ€์น˜ ํ‰๊ฐ€ ์„ค์ • ์ €์žฅ (Upsert).
769
+
770
+ Args:
771
+ user_id: Supabase Auth UUID
772
+ travel_style: ์—ฌํ–‰ ์Šคํƒ€์ผ (PREMIUM, VALUE, CASHBACK)
773
+ custom_valuations: ๊ฐœ๋ณ„ ํ”„๋กœ๊ทธ๋žจ ๊ฐ€์น˜ ์˜ค๋ฒ„๋ผ์ด๋“œ
774
+
775
+ Returns:
776
+ ์ €์žฅ๋œ ์„ค์ • ๋˜๋Š” None
777
+ """
778
+ try:
779
+ data = {
780
+ "user_id": user_id,
781
+ "travel_style": travel_style.upper(),
782
+ "custom_valuations": custom_valuations or {}
783
+ }
784
+
785
+ # user_id UNIQUE ์ œ์•ฝ์œผ๋กœ upsert
786
+ self.client.table("user_valuation_preferences")\
787
+ .delete()\
788
+ .eq("user_id", user_id)\
789
+ .execute()
790
+
791
+ result = self.client.table("user_valuation_preferences")\
792
+ .insert(data)\
793
+ .execute()
794
+
795
+ return result.data[0] if result.data else None
796
+ except Exception as e:
797
+ print(f"โš ๏ธ ๊ฐ€์น˜ ํ‰๊ฐ€ ์„ค์ • ์ €์žฅ ์‹คํŒจ ({user_id}): {e}")
798
+ return None
799
 
800
 
801
  # =============================================================================
src/mcp/server_streamable.py CHANGED
@@ -499,6 +499,373 @@ TOOLS = [
499
  },
500
  "required": ["code"]
501
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
502
  }
503
  ]
504
 
 
499
  },
500
  "required": ["code"]
501
  }
502
+ },
503
+ # ============================================================
504
+ # Credit Card Tools (์‹ ์šฉ์นด๋“œ ๊ด€๋ฆฌ/ํ˜œํƒ ์ถ”์ฒœ)
505
+ # ============================================================
506
+ {
507
+ "name": "user_add_credit_card",
508
+ "description": (
509
+ "๋ณด์œ  ์‹ ์šฉ์นด๋“œ๋ฅผ ๋“ฑ๋กํ•ฉ๋‹ˆ๋‹ค. "
510
+ "๋“ฑ๋ก๋œ ์นด๋“œ๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ํฌ๋ ˆ๋”ง/ํ˜œํƒ์„ ์ถ”์ ํ•˜๊ณ  ์ถ”์ฒœ๋ฐ›์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."
511
+ ),
512
+ "inputSchema": {
513
+ "type": "object",
514
+ "properties": {
515
+ "card_id": {
516
+ "type": "string",
517
+ "description": (
518
+ "์นด๋“œ ๊ณ ์œ  ID. ์˜ˆ: AMEX_PLATINUM_US, CHASE_SAPPHIRE_RESERVE. "
519
+ "์ง€์› ๋ฐœ๊ธ‰์‚ฌ: AMEX, CHASE, CITI, CAPITAL_ONE"
520
+ )
521
+ },
522
+ "card_name": {
523
+ "type": "string",
524
+ "description": "์นด๋“œ ์ด๋ฆ„ (์„ ํƒ, card_id๋กœ ์ž๋™ ์ถ”๋ก  ๊ฐ€๋Šฅ)"
525
+ },
526
+ "issuer_code": {
527
+ "type": "string",
528
+ "description": "๋ฐœ๊ธ‰์‚ฌ ์ฝ”๋“œ (์„ ํƒ, card_id๋กœ ์ž๋™ ์ถ”๋ก  ๊ฐ€๋Šฅ)"
529
+ },
530
+ "region": {
531
+ "type": "string",
532
+ "default": "USA",
533
+ "description": "๋ฐœ๊ธ‰ ๊ตญ๊ฐ€ (๊ธฐ๋ณธ: USA)"
534
+ },
535
+ "card_open_date": {
536
+ "type": "string",
537
+ "description": "์นด๋“œ ๊ฐœ์„ค์ผ (์„ ํƒ, YYYY-MM-DD)"
538
+ },
539
+ "anniversary_month": {
540
+ "type": "integer",
541
+ "minimum": 1,
542
+ "maximum": 12,
543
+ "description": "์—ฐํšŒ๋น„ ๊ฐฑ์‹ /์ฒญ๊ตฌ ์›” (์„ ํƒ, 1-12)"
544
+ },
545
+ "annual_fee": {
546
+ "type": "number",
547
+ "description": "์—ฐํšŒ๋น„ ๊ธˆ์•ก (์„ ํƒ)"
548
+ },
549
+ "session_token": {
550
+ "type": "string",
551
+ "description": "์ธ์ฆ ์„ธ์…˜ ํ† ํฐ"
552
+ }
553
+ },
554
+ "required": ["card_id", "session_token"]
555
+ }
556
+ },
557
+ {
558
+ "name": "user_get_credit_cards",
559
+ "description": "๋“ฑ๋ก๋œ ๋ณด์œ  ์‹ ์šฉ์นด๋“œ ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.",
560
+ "inputSchema": {
561
+ "type": "object",
562
+ "properties": {
563
+ "session_token": {
564
+ "type": "string",
565
+ "description": "์ธ์ฆ ์„ธ์…˜ ํ† ํฐ"
566
+ }
567
+ },
568
+ "required": ["session_token"]
569
+ }
570
+ },
571
+ {
572
+ "name": "user_delete_credit_card",
573
+ "description": "๋“ฑ๋ก๋œ ์‹ ์šฉ์นด๋“œ๋ฅผ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค.",
574
+ "inputSchema": {
575
+ "type": "object",
576
+ "properties": {
577
+ "card_id": {
578
+ "type": "string",
579
+ "description": "์‚ญ์ œํ•  ์นด๋“œ ID"
580
+ },
581
+ "session_token": {
582
+ "type": "string",
583
+ "description": "์ธ์ฆ ์„ธ์…˜ ํ† ํฐ"
584
+ }
585
+ },
586
+ "required": ["card_id", "session_token"]
587
+ }
588
+ },
589
+ {
590
+ "name": "user_update_credit_usage",
591
+ "description": (
592
+ "์‹ ์šฉ์นด๋“œ ํฌ๋ ˆ๋”ง/ํ˜œํƒ ์‚ฌ์šฉ ๊ธฐ๋ก์„ ์—…๋ฐ์ดํŠธํ•ฉ๋‹ˆ๋‹ค. "
593
+ "์˜ˆ: AMEX Platinum FHR ํฌ๋ ˆ๋”ง $300 ์‚ฌ์šฉ ์™„๋ฃŒ"
594
+ ),
595
+ "inputSchema": {
596
+ "type": "object",
597
+ "properties": {
598
+ "card_id": {
599
+ "type": "string",
600
+ "description": "์นด๋“œ ID (์˜ˆ: AMEX_PLATINUM_US)"
601
+ },
602
+ "benefit_id": {
603
+ "type": "string",
604
+ "description": (
605
+ "ํ˜œํƒ ID. ์˜ˆ: amex_plat_hotel_credit (FHR), "
606
+ "amex_plat_saks_credit, amex_plat_uber_cash, "
607
+ "amex_plat_digital_entertainment, amex_plat_resy_credit"
608
+ )
609
+ },
610
+ "amount_used": {
611
+ "type": "number",
612
+ "description": "์‚ฌ์šฉ ๊ธˆ์•ก (์„ ํƒ, ๋ฏธ์ž…๋ ฅ ์‹œ ์ „์ฒด ํ•œ๋„๋กœ ๊ธฐ๋ก)"
613
+ },
614
+ "usage_date": {
615
+ "type": "string",
616
+ "description": "์‚ฌ์šฉ์ผ (์„ ํƒ, YYYY-MM-DD, ๋ฏธ์ž…๋ ฅ ์‹œ ์˜ค๋Š˜)"
617
+ },
618
+ "description": {
619
+ "type": "string",
620
+ "description": "์‚ฌ์šฉ ๋‚ด์—ญ ๋ฉ”๋ชจ (์„ ํƒ)"
621
+ },
622
+ "session_token": {
623
+ "type": "string",
624
+ "description": "์ธ์ฆ ์„ธ์…˜ ํ† ํฐ"
625
+ }
626
+ },
627
+ "required": ["card_id", "benefit_id", "session_token"]
628
+ }
629
+ },
630
+ {
631
+ "name": "user_get_credit_recommendations",
632
+ "description": (
633
+ "ํ˜„์žฌ ์‹œ์ ์—์„œ ์‚ฌ์šฉํ•ด์•ผ ํ•  ํฌ๋ ˆ๋”ง/ํ˜œํƒ์„ ์ถ”์ฒœํ•ฉ๋‹ˆ๋‹ค. "
634
+ "๊ธฐ๊ฐ„๋ณ„ ๋งˆ๊ฐ์ผ์ด ์ž„๋ฐ•ํ•œ ํ˜œํƒ์„ ์šฐ์„ ์ˆœ์œ„๋กœ ๋ณด์—ฌ์ค๋‹ˆ๋‹ค. "
635
+ "์˜ˆ: 'AMEX Platinum FHR ์ƒ๋ฐ˜๊ธฐ ํฌ๋ ˆ๋”ง $300 - 6์›” 30์ผ ๋งˆ๊ฐ'"
636
+ ),
637
+ "inputSchema": {
638
+ "type": "object",
639
+ "properties": {
640
+ "session_token": {
641
+ "type": "string",
642
+ "description": "์ธ์ฆ ์„ธ์…˜ ํ† ํฐ"
643
+ }
644
+ },
645
+ "required": ["session_token"]
646
+ }
647
+ },
648
+ {
649
+ "name": "user_get_credit_usage_summary",
650
+ "description": (
651
+ "ํŠน์ • ์นด๋“œ ๋˜๋Š” ์ „์ฒด ์นด๋“œ์˜ ํฌ๋ ˆ๋”ง ์‚ฌ์šฉ ํ˜„ํ™ฉ ์š”์•ฝ์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. "
652
+ "๊ฐ ํ˜œํƒ๋ณ„ ์‚ฌ์šฉ/์ž”์—ฌ ๊ธˆ์•ก๊ณผ ๋น„์œจ์„ ๋ณด์—ฌ์ค๋‹ˆ๋‹ค."
653
+ ),
654
+ "inputSchema": {
655
+ "type": "object",
656
+ "properties": {
657
+ "card_id": {
658
+ "type": "string",
659
+ "description": "ํŠน์ • ์นด๋“œ ID๋กœ ํ•„ํ„ฐ๋ง (์„ ํƒ, ๋ฏธ์ž…๋ ฅ ์‹œ ์ „์ฒด ์นด๋“œ)"
660
+ },
661
+ "session_token": {
662
+ "type": "string",
663
+ "description": "์ธ์ฆ ์„ธ์…˜ ํ† ํฐ"
664
+ }
665
+ },
666
+ "required": ["session_token"]
667
+ }
668
+ },
669
+ # ============================================================
670
+ # Shadow Valuation Tools (ํฌ์ธํŠธ/๋งˆ์ผ๋ฆฌ์ง€ ๊ฐ€์น˜ ํ‰๊ฐ€)
671
+ # ============================================================
672
+ {
673
+ "name": "user_get_asset_valuation",
674
+ "description": (
675
+ "๋ณด์œ  ํฌ์ธํŠธ/๋งˆ์ผ๋ฆฌ์ง€์˜ ํ˜„์žฌ ๊ฐ€์น˜๋ฅผ ๊ณ„์‚ฐํ•ฉ๋‹ˆ๋‹ค. "
676
+ "์‚ฌ์šฉ์ž์˜ ์—ฌํ–‰ ์Šคํƒ€์ผ(PREMIUM/VALUE/CASHBACK)์— ๋”ฐ๋ผ ๋™์ผํ•œ ํฌ์ธํŠธ๋„ ๋‹ค๋ฅธ ๊ฐ€์น˜๋กœ ํ‰๊ฐ€๋ฉ๋‹ˆ๋‹ค. "
677
+ "์˜ˆ: AMEX MR 50,000์ ์€ PREMIUM ์Šคํƒ€์ผ์—์„œ $1,100, CASHBACK ์Šคํƒ€์ผ์—์„œ $300๋กœ ํ‰๊ฐ€๋ฉ๋‹ˆ๋‹ค."
678
+ ),
679
+ "inputSchema": {
680
+ "type": "object",
681
+ "properties": {
682
+ "assets": {
683
+ "type": "array",
684
+ "items": {
685
+ "type": "object",
686
+ "properties": {
687
+ "program": {
688
+ "type": "string",
689
+ "description": "ํ”„๋กœ๊ทธ๋žจ ID (์˜ˆ: AMEX_MR, CHASE_UR, KOREAN_AIR, MARRIOTT_BONVOY)"
690
+ },
691
+ "amount": {
692
+ "type": "number",
693
+ "description": "ํฌ์ธํŠธ/๋งˆ์ผ ์ˆ˜๋Ÿ‰"
694
+ }
695
+ },
696
+ "required": ["program", "amount"]
697
+ },
698
+ "description": "ํ‰๊ฐ€ํ•  ์ž์‚ฐ ๋ชฉ๋ก"
699
+ },
700
+ "style": {
701
+ "type": "string",
702
+ "enum": ["PREMIUM", "VALUE", "CASHBACK"],
703
+ "description": "์ด๋ฒˆ ๊ณ„์‚ฐ์— ์‚ฌ์šฉํ•  ์Šคํƒ€์ผ (์„ ํƒ, ๋ฏธ์ž…๋ ฅ ์‹œ ์ €์žฅ๋œ ์‚ฌ์šฉ์ž ์Šคํƒ€์ผ ์‚ฌ์šฉ)"
704
+ },
705
+ "session_token": {
706
+ "type": "string",
707
+ "description": "์ธ์ฆ ์„ธ์…˜ ํ† ํฐ"
708
+ }
709
+ },
710
+ "required": ["assets", "session_token"]
711
+ }
712
+ },
713
+ {
714
+ "name": "user_update_valuation_style",
715
+ "description": (
716
+ "ํฌ์ธํŠธ ๊ฐ€์น˜ ํ‰๊ฐ€์— ์‚ฌ์šฉํ•  ์—ฌํ–‰ ์Šคํƒ€์ผ์„ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค. "
717
+ "PREMIUM: ๋น„์ฆˆ๋‹ˆ์Šค/ํผ์ŠคํŠธ ๋ฐœ๊ถŒ ๋ชฉํ‘œ (๋†’์€ ๊ฐ€์น˜), "
718
+ "VALUE: ์ผ๋ฐ˜ ์‚ฌ์šฉ (ํ‘œ์ค€ ๊ฐ€์น˜), "
719
+ "CASHBACK: ํ˜„๊ธˆ์„ฑ ์‚ฌ์šฉ ์„ ํ˜ธ (๋‚ฎ์€ ๊ฐ€์น˜)"
720
+ ),
721
+ "inputSchema": {
722
+ "type": "object",
723
+ "properties": {
724
+ "style": {
725
+ "type": "string",
726
+ "enum": ["PREMIUM", "VALUE", "CASHBACK"],
727
+ "description": "์„ค์ •ํ•  ์—ฌํ–‰ ์Šคํƒ€์ผ"
728
+ },
729
+ "custom_valuations": {
730
+ "type": "object",
731
+ "description": (
732
+ "๊ฐœ๋ณ„ ํ”„๋กœ๊ทธ๋žจ ๊ฐ€์น˜ ์˜ค๋ฒ„๋ผ์ด๋“œ (์„ ํƒ). "
733
+ "์˜ˆ: {\"AMEX_MR\": 2.5, \"KOREAN_AIR\": 20}"
734
+ )
735
+ },
736
+ "session_token": {
737
+ "type": "string",
738
+ "description": "์ธ์ฆ ์„ธ์…˜ ํ† ํฐ"
739
+ }
740
+ },
741
+ "required": ["session_token"]
742
+ }
743
+ },
744
+ {
745
+ "name": "user_get_valuation_styles",
746
+ "description": (
747
+ "์ง€์›ํ•˜๋Š” ์—ฌํ–‰ ์Šคํƒ€์ผ๊ณผ ํฌ์ธํŠธ ํ”„๋กœ๊ทธ๋žจ ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. "
748
+ "๊ฐ ์Šคํƒ€์ผ๋ณ„ ์˜ˆ์‹œ ๊ฐ€์น˜์™€ ํ˜„์žฌ ์‚ฌ์šฉ์ž ์„ค์ •์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."
749
+ ),
750
+ "inputSchema": {
751
+ "type": "object",
752
+ "properties": {
753
+ "session_token": {
754
+ "type": "string",
755
+ "description": "์ธ์ฆ ์„ธ์…˜ ํ† ํฐ"
756
+ }
757
+ },
758
+ "required": ["session_token"]
759
+ }
760
+ },
761
+ # ============================================================
762
+ # Asset Parser / Calculator / Deeplink Tools
763
+ # ============================================================
764
+ {
765
+ "name": "user_parse_asset_text",
766
+ "description": (
767
+ "์‚ฌ์šฉ์ž๊ฐ€ ์ž…๋ ฅํ•œ ํ…์ŠคํŠธ์—์„œ ํฌ์ธํŠธ/๋งˆ์ผ๋ฆฌ์ง€ ์ •๋ณด๋ฅผ ์ž๋™์œผ๋กœ ์ถ”์ถœํ•ฉ๋‹ˆ๋‹ค. "
768
+ "์˜ˆ: '๋Œ€ํ•œํ•ญ๊ณต 45000 ๋งˆ์ผ, AMEX MR 50000์ ' โ†’ ๊ตฌ์กฐํ™”๋œ ์ž์‚ฐ ๋ชฉ๋ก์œผ๋กœ ๋ณ€ํ™˜"
769
+ ),
770
+ "inputSchema": {
771
+ "type": "object",
772
+ "properties": {
773
+ "text": {
774
+ "type": "string",
775
+ "description": (
776
+ "ํŒŒ์‹ฑํ•  ํ…์ŠคํŠธ. ํ”„๋กœ๊ทธ๋žจ๋ช…๊ณผ ์ˆซ์ž๋ฅผ ํฌํ•จํ•ด์ฃผ์„ธ์š”. "
777
+ "์˜ˆ: '๋Œ€ํ•œํ•ญ๊ณต 45000\\n์•„์‹œ์•„๋‚˜ 12000\\nAMEX MR 50000'"
778
+ )
779
+ },
780
+ "save_to_profile": {
781
+ "type": "boolean",
782
+ "default": False,
783
+ "description": "ํŒŒ์‹ฑ๋œ ์ž์‚ฐ์„ ํ”„๋กœํ•„์— ์ €์žฅํ• ์ง€ ์—ฌ๋ถ€ (ํ–ฅํ›„ ๊ธฐ๋Šฅ)"
784
+ },
785
+ "session_token": {
786
+ "type": "string",
787
+ "description": "์ธ์ฆ ์„ธ์…˜ ํ† ํฐ"
788
+ }
789
+ },
790
+ "required": ["text", "session_token"]
791
+ }
792
+ },
793
+ {
794
+ "name": "calculate_miles_vs_cashback",
795
+ "description": (
796
+ "๋งˆ์ผ๋ฆฌ์ง€ ์ ๋ฆฝ ์นด๋“œ vs ํ˜„๊ธˆ ํ• ์ธ ์นด๋“œ ์ค‘ ์–ด๋А ๊ฒƒ์ด ๋” ์œ ๋ฆฌํ•œ์ง€ ๊ณ„์‚ฐํ•ฉ๋‹ˆ๋‹ค. "
797
+ "์˜ˆ: 10๋งŒ์› ๊ฒฐ์ œ ์‹œ '1000์›๋‹น 1๋งˆ์ผ' vs '1.5% ํ• ์ธ' ๋น„๊ต"
798
+ ),
799
+ "inputSchema": {
800
+ "type": "object",
801
+ "properties": {
802
+ "amount": {
803
+ "type": "number",
804
+ "description": "๊ฒฐ์ œ ๊ธˆ์•ก (์›)"
805
+ },
806
+ "mile_rate": {
807
+ "type": "number",
808
+ "default": 1,
809
+ "description": "์ ๋ฆฝ ๋งˆ์ผ ์ˆ˜ (๊ธฐ๋ณธ: 1)"
810
+ },
811
+ "mile_per": {
812
+ "type": "number",
813
+ "default": 1000,
814
+ "description": "๋งˆ์ผ ์ ๋ฆฝ ๊ธฐ์ค€ ๊ธˆ์•ก (์›, ๊ธฐ๋ณธ: 1000)"
815
+ },
816
+ "mile_program": {
817
+ "type": "string",
818
+ "default": "KOREAN_AIR",
819
+ "description": "๋งˆ์ผ๋ฆฌ์ง€ ํ”„๋กœ๊ทธ๋žจ (๊ธฐ๋ณธ: KOREAN_AIR)"
820
+ },
821
+ "discount_percent": {
822
+ "type": "number",
823
+ "default": 1.5,
824
+ "description": "ํ• ์ธ์œจ (%, ๊ธฐ๋ณธ: 1.5)"
825
+ },
826
+ "session_token": {
827
+ "type": "string",
828
+ "description": "์ธ์ฆ ์„ธ์…˜ ํ† ํฐ"
829
+ }
830
+ },
831
+ "required": ["amount", "session_token"]
832
+ }
833
+ },
834
+ {
835
+ "name": "generate_award_search_link",
836
+ "description": (
837
+ "ํŠน๊ฐ€์„ ๊ฒ€์ƒ‰์„ ์œ„ํ•œ Seats.aero/Point.me URL์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. "
838
+ "๋„์‹œ๋ช…(ํ•œ๊ธ€/์˜๋ฌธ)์„ ๊ณตํ•ญ ์ฝ”๋“œ๋กœ ์ž๋™ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค. "
839
+ "์˜ˆ: '์„œ์šธ โ†’ ํŒŒ๋ฆฌ ๋น„์ฆˆ๋‹ˆ์Šค' โ†’ ๊ฒ€์ƒ‰ URL ์ƒ์„ฑ"
840
+ ),
841
+ "inputSchema": {
842
+ "type": "object",
843
+ "properties": {
844
+ "origin": {
845
+ "type": "string",
846
+ "default": "ICN",
847
+ "description": "์ถœ๋ฐœ์ง€ (๋„์‹œ๋ช… ๋˜๋Š” ๊ณตํ•ญ ์ฝ”๋“œ, ๊ธฐ๋ณธ: ICN)"
848
+ },
849
+ "destination": {
850
+ "type": "string",
851
+ "description": "๋ชฉ์ ์ง€ (๋„์‹œ๋ช… ๋˜๋Š” ๊ณตํ•ญ ์ฝ”๋“œ)"
852
+ },
853
+ "date": {
854
+ "type": "string",
855
+ "description": "๋‚ ์งœ (YYYY-MM ๋˜๋Š” YYYY-MM-DD ํ˜•์‹, ๋ฏธ์ž…๋ ฅ ์‹œ ์œ ์—ฐ ๊ฒ€์ƒ‰)"
856
+ },
857
+ "cabin_class": {
858
+ "type": "string",
859
+ "default": "J",
860
+ "description": "์ขŒ์„ ํด๋ž˜์Šค (Y/W/J/F ๋˜๋Š” ์ด์ฝ”๋…ธ๋ฏธ/๋น„์ฆˆ๋‹ˆ์Šค/ํผ์ŠคํŠธ)"
861
+ },
862
+ "session_token": {
863
+ "type": "string",
864
+ "description": "์ธ์ฆ ์„ธ์…˜ ํ† ํฐ"
865
+ }
866
+ },
867
+ "required": ["destination", "session_token"]
868
+ }
869
  }
870
  ]
871