From 86061af2651bf7b5ad38b971400825bb252ef10a Mon Sep 17 00:00:00 2001 From: Raf Date: Thu, 7 May 2026 17:27:50 +0900 Subject: [PATCH 01/15] feat(raw_posts): StarStyle.com adapter (#466) (#481) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(raw_posts): StarStyle.com adapter (#466) WordPress SSR fashion 사이트(starstyle.com) 어댑터 추가. wots(#465) 인프라 재사용 — discovery_target='raw_posts' 글로벌 피드 모델, PrelabeledData 로 vision 단계 우회. 핵심 차이: per-item 사진 URL 이 없어 thumbnail_url=None → items_thumbnail processor 가 spots bbox 로 hero crop fallback path 사용. brand/title 분리는 보수적으로 product 통째로 (verify 단계 admin 처리). - adapters/_starstyle_html.py: parse_post / parse_sitemap / decode_skimresources (skim affiliate 디코드) - adapters/starstyle.py: thin httpx wrapper, fetch + sitemap discover - scripts/backfill_starstyle_posts.py: sitemap → JSONL streaming → PostgREST bulk INSERT (wots 미러) - service.rs: parse_starstyle_source — slug-sp{ID} 형식 검증 - admin UI: PLATFORM_FILTERS / PLATFORM_TABS / DiscoveryPipelineCard / URL builder - migration: pipeline_settings starstyle row (모든 cycle OFF) - 24 단위 테스트 추가 (parser + adapter + prelabeled round-trip) Closes #466. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(starstyle): force HTTPS on og:image URL (#466) starstyle 의 og:image 가 http:// 로 노출되는데 admin 은 HTTPS 라 mixed-content 로 이미지가 차단되던 문제. CDN 자체는 HTTPS 정상이라 파서 단계에서 https 강제 변환. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../scripts/backfill_starstyle_posts.py | 560 +++ packages/ai-server/src/config/_environment.py | 6 + .../services/raw_posts/adapters/__init__.py | 4 + .../raw_posts/adapters/_starstyle_html.py | 279 ++ .../services/raw_posts/adapters/starstyle.py | 224 ++ .../src/services/raw_posts/scheduler.py | 7 +- .../starstyle_post_cate_blanchett.html | 2066 ++++++++++++ .../fixtures/starstyle_post_olivia_culpo.html | 2071 ++++++++++++ .../fixtures/starstyle_post_taylor_hill.html | 2080 ++++++++++++ .../raw_posts/fixtures/starstyle_sitemap.xml | 3003 +++++++++++++++++ .../raw_posts/test_starstyle_adapter.py | 155 + .../services/raw_posts/test_starstyle_html.py | 137 + .../src/domains/raw_posts/service.rs | 59 +- .../web/app/admin/raw-post-sources/page.tsx | 37 +- packages/web/app/admin/raw-posts/page.tsx | 1 + .../DiscoveryPipelineCard.tsx | 4 +- ...0507120000_pipeline_settings_starstyle.sql | 21 + 17 files changed, 10704 insertions(+), 10 deletions(-) create mode 100644 packages/ai-server/scripts/backfill_starstyle_posts.py create mode 100644 packages/ai-server/src/services/raw_posts/adapters/_starstyle_html.py create mode 100644 packages/ai-server/src/services/raw_posts/adapters/starstyle.py create mode 100644 packages/ai-server/tests/unit/services/raw_posts/fixtures/starstyle_post_cate_blanchett.html create mode 100644 packages/ai-server/tests/unit/services/raw_posts/fixtures/starstyle_post_olivia_culpo.html create mode 100644 packages/ai-server/tests/unit/services/raw_posts/fixtures/starstyle_post_taylor_hill.html create mode 100644 packages/ai-server/tests/unit/services/raw_posts/fixtures/starstyle_sitemap.xml create mode 100644 packages/ai-server/tests/unit/services/raw_posts/test_starstyle_adapter.py create mode 100644 packages/ai-server/tests/unit/services/raw_posts/test_starstyle_html.py create mode 100644 supabase-assets/migrations/20260507120000_pipeline_settings_starstyle.sql diff --git a/packages/ai-server/scripts/backfill_starstyle_posts.py b/packages/ai-server/scripts/backfill_starstyle_posts.py new file mode 100644 index 00000000..e0a1b25f --- /dev/null +++ b/packages/ai-server/scripts/backfill_starstyle_posts.py @@ -0,0 +1,560 @@ +#!/usr/bin/env python3 +# pyright: reportMissingImports=false +"""StarStyle 백필 스크립트 (#466). + +설계 — wots 백필 미러 (전략 동일, source 가 다를 뿐): + 1. **Scan**: ``http://www.starstyle.com/sitemap.xml`` → 모든 post URL. + 매 URL fetch + parse_post 결과를 ``/tmp/starstyle-scan.jsonl`` 즉시 append + (streaming write). crash 나도 진행분 disk 보존, 재실행 시 빠진 slug 만 + 재 fetch. + 2. **INSERT**: PostgREST API 직접 호출 (``ASSETS_DATABASE_API_URL`` / + ``ASSETS_DATABASE_SERVICE_ROLE_KEY``). batch 100 row. + +raw_posts 직행 모델 (#465 / #466): post 마다 source row 만들지 않고 단일 +``raw_post_sources`` (platform=starstyle, source_type=feed, +source_identifier=global) 하나에 모든 raw_posts 가 매달림. + +Idempotent: + - JSONL 은 slug 별 마지막 line 우선. + - cloud INSERT 는 ``Prefer: resolution=ignore-duplicates`` 로 중복 무시. + - 시작 시 cloud 의 starstyle external_id 들을 1번 SELECT 해 INSERT 후보 제거. + +Usage: + cd packages/ai-server + uv run python scripts/backfill_starstyle_posts.py --dry-run + uv run python scripts/backfill_starstyle_posts.py + # 재실행 (resume): + uv run python scripts/backfill_starstyle_posts.py --resume +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import logging +import os +import sys +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional, Set + +import dotenv +import httpx + +# 스크립트로 실행될 때 ``src.*`` 가 sys.path 에 없으므로 명시 추가. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from src.services.raw_posts.adapters._starstyle_html import ( # noqa: E402 + parse_post, + parse_sitemap, +) + + +_ORIGIN = "http://www.starstyle.com" +_SITEMAP_URL = f"{_ORIGIN}/sitemap.xml" + +_USER_AGENT = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/122.0.0.0 Safari/537.36" +) + +_GLOBAL_FEED_SOURCE_IDENTIFIER = "global" +_GLOBAL_FEED_LABEL = "StarStyle global feed" + +_SCAN_CACHE = Path("/tmp/starstyle-scan.jsonl") + +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent +dotenv.load_dotenv(_REPO_ROOT / ".env.backend.dev", override=False) +dotenv.load_dotenv(_REPO_ROOT / "packages/ai-server/.dev.env", override=False) + + +def _env(name: str, *, required: bool = True) -> str: + val = os.environ.get(name, "") + if required and not val: + raise SystemExit(f"missing env: {name}") + return val + + +_SUPABASE_URL = _env("ASSETS_DATABASE_API_URL").rstrip("/") +_SERVICE_ROLE_KEY = _env("ASSETS_DATABASE_SERVICE_ROLE_KEY") + +_INSERT_BATCH = 100 + +logger = logging.getLogger("backfill_starstyle") + + +# ---------------------------------------------------------- types & helpers + + +@dataclass(frozen=True) +class PostData: + post_id: str + slug: str + url: str + image_url: str + caption: str + celebrity_name: Optional[str] + celebrity_slug: Optional[str] + published_at: Optional[str] + site_items: List[Dict[str, Any]] = field(default_factory=list) + + +def _to_site_item_dict(item) -> Dict[str, Any]: + """``StarStyleItem`` → ParsedItem 호환 dict (adapter 와 동일 매핑). + + starstyle 은 brand 와 title 분리 모호 → ``product`` 에 통째로, ``brand=None``. + per-item thumbnail 없음 → fallback path 가 처리. + """ + return { + "site_item_id": item.product_id, + "brand": None, + "product": item.title, + "price": None, + "category": None, + "thumbnail_url": None, + "original_url": item.retailer_url, + "url_candidates": ( + [{"url": item.retailer_url, "source": "skimresources"}] + if item.retailer_url + else None + ), + } + + +def _post_to_post_data(post) -> PostData: + return PostData( + post_id=post.post_id, + slug=post.slug, + url=post.url, + image_url=post.image_url, + caption=post.caption or "", + celebrity_name=post.celebrity_name, + celebrity_slug=post.celebrity_slug, + published_at=post.published_at, + site_items=[_to_site_item_dict(it) for it in post.items], + ) + + +def _post_to_jsonl_dict(d: PostData) -> Dict[str, Any]: + return { + "post_id": d.post_id, + "slug": d.slug, + "url": d.url, + "image_url": d.image_url, + "caption": d.caption, + "celebrity_name": d.celebrity_name, + "celebrity_slug": d.celebrity_slug, + "published_at": d.published_at, + "site_items": d.site_items, + } + + +def _jsonl_dict_to_post(j: Dict[str, Any]) -> PostData: + return PostData( + post_id=str(j["post_id"]), + slug=str(j["slug"]), + url=str(j["url"]), + image_url=str(j["image_url"]), + caption=str(j.get("caption") or ""), + celebrity_name=j.get("celebrity_name"), + celebrity_slug=j.get("celebrity_slug"), + published_at=j.get("published_at"), + site_items=list(j.get("site_items") or []), + ) + + +# ---------------------------------------------------- scan (streaming JSONL) + + +async def fetch_post_data(http: httpx.AsyncClient, url: str) -> Optional[PostData]: + resp = await http.get(url) + resp.raise_for_status() + parsed = parse_post(resp.text) + if parsed is None: + return None + return _post_to_post_data(parsed) + + +def _read_scan_cache() -> Dict[str, PostData]: + if not _SCAN_CACHE.exists(): + return {} + out: Dict[str, PostData] = {} + with _SCAN_CACHE.open("r") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + j = json.loads(line) + d = _jsonl_dict_to_post(j) + out[d.post_id] = d + except (json.JSONDecodeError, KeyError, TypeError, ValueError): + continue + return out + + +async def fetch_sitemap_urls(http: httpx.AsyncClient) -> List[str]: + resp = await http.get(_SITEMAP_URL) + resp.raise_for_status() + return parse_sitemap(resp.text) + + +async def scan_urls_streaming( + urls: List[str], + *, + concurrency: int, + skip_ids: Set[str], + delay_seconds: float, + on_progress, +) -> int: + """URL 들을 동시 ``concurrency`` 스캔. 매 결과 JSONL append.""" + sem = asyncio.Semaphore(concurrency) + write_lock = asyncio.Lock() + seen = 0 + new_hit = 0 + total = len(urls) + + f = _SCAN_CACHE.open("a", buffering=1) + + async with httpx.AsyncClient( + timeout=30, + headers={"User-Agent": _USER_AGENT, "Accept-Language": "en-US,en;q=0.9"}, + limits=httpx.Limits(max_connections=concurrency * 2), + follow_redirects=True, + ) as http: + + async def worker(url: str) -> None: + nonlocal seen, new_hit + async with sem: + # post_id 추출 후 skip 매칭 + from src.services.raw_posts.adapters._starstyle_html import ( + _extract_post_id, + ) + + pid = _extract_post_id(url) + if pid and pid in skip_ids: + seen += 1 + if seen % 50 == 0 or seen == total: + on_progress(seen, total, new_hit) + return + data: Optional[PostData] = None + for attempt in range(3): + try: + data = await fetch_post_data(http, url) + break + except httpx.HTTPError: + await asyncio.sleep(2**attempt) + else: + logger.warning("giving up on %s after retries", url) + if delay_seconds > 0: + await asyncio.sleep(delay_seconds) + seen += 1 + if data is not None: + new_hit += 1 + line = json.dumps(_post_to_jsonl_dict(data), ensure_ascii=False) + async with write_lock: + f.write(line + "\n") + f.flush() + os.fsync(f.fileno()) + if seen % 50 == 0 or seen == total: + on_progress(seen, total, new_hit) + + try: + await asyncio.gather(*(worker(u) for u in urls)) + finally: + f.close() + return new_hit + + +# ------------------------------------------------------- PostgREST INSERT + + +_PG_HEADERS = { + "apikey": _SERVICE_ROLE_KEY, + "Authorization": f"Bearer {_SERVICE_ROLE_KEY}", + "Content-Type": "application/json", +} + + +async def fetch_existing_external_ids(http: httpx.AsyncClient) -> Set[str]: + out: Set[str] = set() + page = 0 + page_size = 1000 + while True: + resp = await http.get( + f"{_SUPABASE_URL}/rest/v1/raw_posts", + params={ + "select": "external_id", + "platform": "eq.starstyle", + }, + headers={ + **_PG_HEADERS, + "Range-Unit": "items", + "Range": f"{page * page_size}-{(page + 1) * page_size - 1}", + }, + ) + if resp.status_code not in (200, 206): + resp.raise_for_status() + rows = resp.json() + if not rows: + break + out.update(str(r["external_id"]) for r in rows if r.get("external_id")) + if len(rows) < page_size: + break + page += 1 + if page > 100: + logger.warning("pagination exceeded 100k — bailing") + break + return out + + +async def ensure_global_feed_source(http: httpx.AsyncClient) -> str: + body = [ + { + "platform": "starstyle", + "source_type": "feed", + "source_identifier": _GLOBAL_FEED_SOURCE_IDENTIFIER, + "label": _GLOBAL_FEED_LABEL, + } + ] + resp = await http.post( + f"{_SUPABASE_URL}/rest/v1/raw_post_sources", + json=body, + headers={ + **_PG_HEADERS, + "Prefer": "return=representation,resolution=merge-duplicates", + }, + params={"on_conflict": "platform,source_identifier"}, + ) + if resp.status_code not in (200, 201): + raise RuntimeError( + f"ensure_global_feed_source: {resp.status_code} {resp.text[:300]}" + ) + rows = resp.json() + if not rows: + raise RuntimeError("ensure_global_feed_source: empty response") + return rows[0]["id"] + + +def _build_raw_post_row( + *, source_id: str, dispatch_id: str, data: PostData +) -> Dict[str, Any]: + platform_metadata = { + "url": data.image_url, + "post_slug": data.slug, + "celebrity_slug": data.celebrity_slug, + "published_at": data.published_at, + "site_labels": { + "artist_name_en": data.celebrity_name, + "items": data.site_items, + }, + } + platform_metadata = { + k: v + for k, v in platform_metadata.items() + if v not in (None, "") or k in ("site_labels", "url") + } + return { + "source_id": source_id, + "platform": "starstyle", + "external_id": data.post_id, + "external_url": data.url, + "image_url": data.image_url, + "caption": data.caption, + "author_name": data.celebrity_name, + "platform_metadata": platform_metadata, + "parse_status": "pending", + "parse_result": {}, + "parse_error": None, + "parse_attempts": 0, + "dispatch_id": dispatch_id, + "status": "NOT_STARTED", + "discovery_judgment": "pass", + } + + +async def insert_batch(http: httpx.AsyncClient, rows: List[Dict[str, Any]]) -> int: + if not rows: + return 0 + last_err = "" + for attempt in range(5): + resp = await http.post( + f"{_SUPABASE_URL}/rest/v1/raw_posts", + json=rows, + headers={ + **_PG_HEADERS, + "Prefer": "return=representation,resolution=ignore-duplicates", + }, + params={"on_conflict": "platform,external_id"}, + ) + if resp.status_code in (200, 201): + return len(resp.json()) + last_err = f"{resp.status_code} {resp.text[:300]}" + if resp.status_code in (429, 500, 502, 503, 504): + wait = 5 * (2**attempt) + logger.warning( + "PostgREST INSERT %s — sleep %ds (attempt %d/5)", + resp.status_code, + wait, + attempt + 1, + ) + await asyncio.sleep(min(wait, 120)) + continue + break + raise RuntimeError(f"PostgREST INSERT failed after retries: {last_err}") + + +# ------------------------------------------------------------------- main + + +def progress_logger(seen: int, total: int, new_hit: int) -> None: + pct = 100.0 * seen / max(1, total) + logger.info(" scan: %d/%d (%.1f%%) new=%d", seen, total, pct, new_hit) + + +def main(argv: List[str] | None = None) -> int: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--concurrency", type=int, default=4) + p.add_argument( + "--page-delay-ms", + type=int, + default=300, + help="per-worker delay between fetches (politeness)", + ) + p.add_argument("--dry-run", action="store_true") + p.add_argument( + "--resume", + action="store_true", + help="기존 /tmp/starstyle-scan.jsonl 우선 — 없는 ID 만 fetch.", + ) + p.add_argument( + "--skip-scan", + action="store_true", + help="sitemap scan 건너뛰고 JSONL 로 INSERT 만 진행.", + ) + p.add_argument("--verbose", "-v", action="store_true") + args = p.parse_args(argv) + + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + if args.concurrency < 1 or args.concurrency > 16: + logger.error("invalid concurrency (1-16)") + return 2 + + return asyncio.run(_run(args)) + + +async def _run(args) -> int: + cache_existing = _read_scan_cache() + logger.info("scan cache %s — %d posts cached", _SCAN_CACHE, len(cache_existing)) + + if not args.skip_scan: + skip_ids: Set[str] = set(cache_existing.keys()) + async with httpx.AsyncClient( + timeout=30, limits=httpx.Limits(max_connections=10) + ) as http: + try: + cloud_existing = await fetch_existing_external_ids(http) + skip_ids.update(cloud_existing) + logger.info( + "pre-scan skip set: %d cached + %d cloud = %d ids to skip", + len(cache_existing), + len(cloud_existing), + len(skip_ids), + ) + except Exception: + logger.warning( + "pre-scan cloud fetch failed — proceeding without cloud skip set", + exc_info=True, + ) + + async with httpx.AsyncClient( + timeout=30, + headers={"User-Agent": _USER_AGENT}, + follow_redirects=True, + ) as http: + urls = await fetch_sitemap_urls(http) + logger.info("sitemap: %d URLs", len(urls)) + + new_hit = await scan_urls_streaming( + urls, + concurrency=args.concurrency, + skip_ids=skip_ids, + delay_seconds=max(0.0, args.page_delay_ms / 1000.0), + on_progress=progress_logger, + ) + logger.info("scan complete: %d new posts added to cache", new_hit) + cache_existing = _read_scan_cache() + + posts = sorted(cache_existing.values(), key=lambda d: int(d.post_id)) + logger.info("total posts in cache: %d", len(posts)) + if not posts: + logger.warning("no posts in cache — aborting") + return 1 + + if args.dry_run: + logger.info("dry-run mode — skipping INSERT") + for d in posts[:3]: + logger.info( + " preview %s slug=%s celeb=%r items=%d", + d.post_id, + d.slug, + d.celebrity_name, + len(d.site_items), + ) + return 0 + + async with httpx.AsyncClient( + timeout=30, limits=httpx.Limits(max_connections=10) + ) as http: + logger.info("fetching existing external_ids from cloud …") + existing = await fetch_existing_external_ids(http) + logger.info(" cloud has %d starstyle raw_posts", len(existing)) + + posts_new = [d for d in posts if d.post_id not in existing] + logger.info( + "candidates: %d (cache %d - cloud %d already inserted)", + len(posts_new), + len(posts), + len(existing), + ) + if not posts_new: + logger.info("nothing to INSERT — done") + return 0 + + logger.info("ensuring global feed source …") + source_id = await ensure_global_feed_source(http) + logger.info(" source_id = %s", source_id) + + dispatch_id = f"backfill-{uuid.uuid4().hex[:12]}" + total_inserted = 0 + n_batches = (len(posts_new) + _INSERT_BATCH - 1) // _INSERT_BATCH + for i in range(0, len(posts_new), _INSERT_BATCH): + chunk = posts_new[i : i + _INSERT_BATCH] + rows = [ + _build_raw_post_row( + source_id=source_id, dispatch_id=dispatch_id, data=d + ) + for d in chunk + ] + inserted = await insert_batch(http, rows) + total_inserted += inserted + logger.info( + " batch %d/%d: inserted=%d (chunk size=%d, running total=%d)", + (i // _INSERT_BATCH) + 1, + n_batches, + inserted, + len(chunk), + total_inserted, + ) + logger.info("backfill done: inserted=%d new raw_posts", total_inserted) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packages/ai-server/src/config/_environment.py b/packages/ai-server/src/config/_environment.py index 07fab67b..1178c24e 100644 --- a/packages/ai-server/src/config/_environment.py +++ b/packages/ai-server/src/config/_environment.py @@ -139,6 +139,12 @@ class Environment(BaseModel): WHATSONTHESTAR_INCREMENTAL_LIMIT: int = 10 # per polling cycle WHATSONTHESTAR_PAGE_DELAY_MS: int = 1000 # polite delay between page fetches + # StarStyle adapter (#466) — WordPress SSR scraper for celebrity fashion posts. + STARSTYLE_INITIAL_LIMIT: int = 50 # posts per first deep scrape + STARSTYLE_INCREMENTAL_LIMIT: int = 5 # per discovery polling cycle + STARSTYLE_PAGE_DELAY_MS: int = 1500 # 1 req/sec polite delay + STARSTYLE_USER_AGENT: Optional[str] = None # None → adapter default UA + @staticmethod def from_environ(*, env_file: Optional[str] = None): """Load env file(s). If `env_file` is set, only that path is used when it exists. diff --git a/packages/ai-server/src/services/raw_posts/adapters/__init__.py b/packages/ai-server/src/services/raw_posts/adapters/__init__.py index 2c3fba47..3f4f8fea 100644 --- a/packages/ai-server/src/services/raw_posts/adapters/__init__.py +++ b/packages/ai-server/src/services/raw_posts/adapters/__init__.py @@ -4,6 +4,7 @@ - PinterestAdapter (platform='pinterest'): real Pinterest scraper (#214). - InstagramAdapter (platform='instagram'): instaloader + session (#259). - WhatsOnTheStarAdapter (platform='whatsonthestar'): Nuxt SSR scraper (#465). +- StarStyleAdapter (platform='starstyle'): WordPress SSR scraper (#466). """ from typing import Dict @@ -11,6 +12,7 @@ from .instagram import InstagramAdapter from .mock import MockAdapter from .pinterest import PinterestAdapter +from .starstyle import StarStyleAdapter from .whatsonthestar import WhatsOnTheStarAdapter from ..models import SourceAdapter @@ -23,6 +25,7 @@ def build_default_adapters(environment=None) -> Dict[str, SourceAdapter]: adapters.append(PinterestAdapter(environment)) adapters.append(InstagramAdapter(environment)) adapters.append(WhatsOnTheStarAdapter(environment)) + adapters.append(StarStyleAdapter(environment)) for adapter in adapters: registry[adapter.platform] = adapter return registry @@ -32,6 +35,7 @@ def build_default_adapters(environment=None) -> Dict[str, SourceAdapter]: "InstagramAdapter", "MockAdapter", "PinterestAdapter", + "StarStyleAdapter", "WhatsOnTheStarAdapter", "build_default_adapters", ] diff --git a/packages/ai-server/src/services/raw_posts/adapters/_starstyle_html.py b/packages/ai-server/src/services/raw_posts/adapters/_starstyle_html.py new file mode 100644 index 00000000..090e274d --- /dev/null +++ b/packages/ai-server/src/services/raw_posts/adapters/_starstyle_html.py @@ -0,0 +1,279 @@ +"""StarStyle.com HTML parsing helpers (#466). + +WordPress SSR 정적 HTML 이라 ``httpx + BeautifulSoup`` 만으로 충분하다. +파일은 pure 함수들만 두고 (네트워크 없음), adapter 가 ``httpx`` 호출 후 결과 +HTML 을 여기로 넘긴다 — pytest fixture HTML 로 단위 검증하기 좋게. + +추출 대상: + - ``parse_post(html)``: 포스트 페이지 → ``StarStylePost`` + * post_id (``-sp{ID}/`` regex), slug, og:image/description/title, + article:published_time, celebrity_name (description prefix), celebrity_slug + (article ``category-{slug}`` class), items (``a.title.product-title.external``). + - ``parse_sitemap(xml)``: ``sitemap.xml`` → URL 리스트 (입력 순서 보존). + - ``decode_skimresources(href)``: skimresources affiliate 래퍼에서 retailer URL + 꺼내기. 비-skim URL 은 그대로 반환. +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass, field +from typing import List, Optional +from urllib.parse import parse_qs, unquote, urlparse +from xml.etree import ElementTree as ET + +from bs4 import BeautifulSoup + + +logger = logging.getLogger(__name__) + + +_POST_ID_RE = re.compile(r"-sp(\d+)/?$") +_CATEGORY_CLASS_RE = re.compile(r"category-([a-z0-9-]+)") +_SKIM_HOSTS = {"go.skimresources.com", "go.linkby.com"} + + +@dataclass(frozen=True) +class StarStyleItem: + """단일 product 카드. brand/title 분리는 admin 이 verify 시점에 처리하므로 + 여기서는 보수적으로 ``title`` 에 전체 텍스트 두고 ``brand=None`` 로 둔다.""" + + product_id: str + title: str + retailer_url: Optional[str] + affiliate_url: str + + +@dataclass(frozen=True) +class StarStylePost: + """starstyle 포스트 페이지의 추출된 ground-truth dataset.""" + + post_id: str + slug: str + url: str + image_url: str + caption: str + celebrity_name: Optional[str] + celebrity_slug: Optional[str] + published_at: Optional[str] + items: List[StarStyleItem] = field(default_factory=list) + + +# -------------------------------------------------------------- Sitemap + + +def parse_sitemap(xml: str) -> List[str]: + """``sitemap.xml`` → ```` URL 리스트, 입력 순서 그대로. + + starstyle 의 sitemap 은 ``lastmod`` 가 없고 newest-first 정렬되어 있으므로 + 별도 정렬 없이 그대로 사용한다. 상위 ``urlset`` / 중첩 ``sitemapindex`` + 둘 다 지원 (전자만 발견됐지만 안전망). + """ + if not xml: + return [] + try: + root = ET.fromstring(xml) + except ET.ParseError as exc: + logger.warning("starstyle.sitemap: parse failed — %s", exc) + return [] + # namespace aware - tag local name 만 매칭. ```` + # 가 nested 하므로, ```` 직속 ```` 만 추출. sub-sitemap index + # (````) 는 별도 caller 가 처리. + urls: List[str] = [] + for child in root: + if child.tag.split("}", 1)[-1] not in ("url", "sitemap"): + continue + for sub in child: + if sub.tag.split("}", 1)[-1] == "loc" and sub.text: + text = sub.text.strip() + if text: + urls.append(text) + break + return urls + + +# ----------------------------------------------------------- Skimresources + + +def decode_skimresources(href: str) -> Optional[str]: + """skimresources affiliate 래퍼 → retailer URL. + + 예: ``https://go.skimresources.com/?id=...&url=https%3A%2F%2Ffarfetch.com%2F...`` + → ``https://farfetch.com/...`` + + skim 호스트 아닌 URL 은 그대로 (이미 raw retailer URL). 식별 못 하면 None. + """ + if not href: + return None + parsed = urlparse(href) + host = (parsed.netloc or "").lower() + if host not in _SKIM_HOSTS: + return href if parsed.scheme in ("http", "https") else None + qs = parse_qs(parsed.query) + raw = (qs.get("url") or [None])[0] + if not raw: + return None + decoded = unquote(raw) + if not decoded.startswith(("http://", "https://")): + return None + return decoded + + +# ------------------------------------------------------------------ Post + + +def parse_post(html: str) -> Optional[StarStylePost]: + """포스트 HTML → ``StarStylePost``. canonical / og:image 가 없으면 None. + + Caller (adapter) 가 None 을 받으면 해당 포스트 skip — 부분적으로 깨진 + 페이지가 있어도 backfill / discovery 가 멈추지 않는다. + """ + if not html: + return None + soup = BeautifulSoup(html, "html.parser") + + canonical = _meta_link(soup, "canonical") + og_url = _og(soup, "og:url") + url = canonical or og_url + if not url: + return None + post_id = _extract_post_id(url) + if not post_id: + return None + + image_url = _og(soup, "og:image") + if not image_url: + return None + # starstyle 은 og:image 를 ``http://`` 로 노출하지만 CDN 자체는 HTTPS 지원. + # admin (HTTPS) 에서 mixed-content 로 차단되므로 https 강제. + if image_url.startswith("http://"): + image_url = "https://" + image_url[len("http://") :] + caption = _og(soup, "og:description") or "" + published_at = _og(soup, "article:published_time") + slug = _slug_from_url(url) + + celebrity_name = _extract_celebrity_name(caption, _og(soup, "og:title")) + celebrity_slug = _extract_celebrity_slug(soup) + + items = _extract_items(soup) + + return StarStylePost( + post_id=post_id, + slug=slug, + url=url, + image_url=image_url, + caption=caption, + celebrity_name=celebrity_name, + celebrity_slug=celebrity_slug, + published_at=published_at, + items=items, + ) + + +# -------------------------------------------------------- Internal helpers + + +def _og(soup: BeautifulSoup, prop: str) -> Optional[str]: + tag = soup.find("meta", property=prop) + if not tag: + return None + val = tag.get("content") + if not isinstance(val, str): + return None + val = val.strip() + return val or None + + +def _meta_link(soup: BeautifulSoup, rel: str) -> Optional[str]: + tag = soup.find("link", rel=rel) + if not tag: + return None + val = tag.get("href") + if not isinstance(val, str): + return None + val = val.strip() + return val or None + + +def _extract_post_id(url: str) -> Optional[str]: + """``/{slug}-sp{ID}/`` → ``{ID}``.""" + if not url: + return None + path = urlparse(url).path or url + m = _POST_ID_RE.search(path.rstrip("/") + "/") + return m.group(1) if m else None + + +def _slug_from_url(url: str) -> str: + path = urlparse(url).path or "" + return path.strip("/") + + +def _extract_celebrity_name(description: str, title: Optional[str]) -> Optional[str]: + """og:description = ``"{Celebrity} wearing ..."`` prefix 가 가장 안정적. + + description 에 " wearing " 패턴이 없을 때만 og:title 에서 끝의 + ``" – Star Style"`` 를 떼고 추정. 둘 다 실패하면 None. + """ + if description: + head = description.split(" wearing ", 1)[0].strip() + if head and len(head) <= 80: + return head + if title: + # "Cate Blanchett Bfi Fellowship ... – Star Style" 같은 형태 — 너무 길어 + # 그대로 쓰면 noise. 보수적으로 None 반환 (admin 이 verify 시 채움). + pass + return None + + +def _extract_celebrity_slug(soup: BeautifulSoup) -> Optional[str]: + """``
`` 에서 추출. + + WP 가 모든 post 에 ``category-{slug}`` 형태로 카테고리 클래스를 박는다. + 모든 starstyle 포스트는 셀럽 이름이 카테고리이므로 신뢰할 수 있는 source. + """ + article = soup.find("article") + if article is None: + return None + classes = article.get("class") or [] + for cls in classes: + m = _CATEGORY_CLASS_RE.match(cls) + if m: + return m.group(1) + return None + + +def _extract_items(soup: BeautifulSoup) -> List[StarStyleItem]: + out: List[StarStyleItem] = [] + seen: set[str] = set() + for a in soup.select("a.title.product-title.external"): + product_id = (a.get("data-id") or "").strip() + if not product_id or product_id in seen: + continue + seen.add(product_id) + text = a.get_text(strip=True) + if not text: + continue + href = (a.get("href") or "").strip() + if not href: + continue + retailer = decode_skimresources(href) + out.append( + StarStyleItem( + product_id=product_id, + title=text, + retailer_url=retailer, + affiliate_url=href, + ) + ) + return out + + +__all__ = [ + "StarStyleItem", + "StarStylePost", + "parse_post", + "parse_sitemap", + "decode_skimresources", +] diff --git a/packages/ai-server/src/services/raw_posts/adapters/starstyle.py b/packages/ai-server/src/services/raw_posts/adapters/starstyle.py new file mode 100644 index 00000000..6e55abad --- /dev/null +++ b/packages/ai-server/src/services/raw_posts/adapters/starstyle.py @@ -0,0 +1,224 @@ +"""StarStyleAdapter — #466. + +WordPress SSR 정적 HTML 사이트(``http://www.starstyle.com``). bs4 만으로 +충분 — Nuxt IIFE 평가나 GraphQL 디코드 불필요. 셀럽-아이템 매핑이 +schema-like markup 으로 라벨링되어 있고 retailer 링크는 skimresources +affiliate 래퍼로 감싸져 있어 디코드만 처리. + +지원 source_type: + - ``post`` (Pipeline B): ``/{slug}-sp{ID}/`` 페이지에서 단일 post 추출. + ``source_identifier`` = full slug (예: ``hunter-schafer-gala-sp819444``). + - 그 외: ``NotImplementedError``. + +discover_related (Pipeline A): ``sitemap.xml`` 에서 최신 N 개 URL 가져와 +RawMedia 리스트로 반환. starstyle 의 sitemap 은 newest-first 정렬되어 있음. + +데이터 라벨 활용 (#466 / #465): + starstyle 는 brand+product (텍스트), retailer URL (skimresources 디코드 후), + celebrity 가 라벨링되어 있다. price / category 는 비일관 → null 로 두고 + verify 시 admin 이 보강. ``RawMedia.platform_metadata.site_labels`` 에 그대로 + 실어 보내면 processor 가 PrelabeledData 로 변환해 vision 단계 우회. + 단, per-item 사진 URL 은 없어 ``thumbnail_url=None`` — items_thumbnail 단계가 + spots bbox 로 hero 에서 crop → Nano Banana refine fallback path 사용. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any, Dict, List, Optional + +import httpx + +from ..models import FetchRequest, RawMedia +from ._starstyle_html import ( + StarStylePost, + parse_post, + parse_sitemap, +) + + +logger = logging.getLogger(__name__) + + +_ORIGIN = "http://www.starstyle.com" +_SITEMAP_URL = f"{_ORIGIN}/sitemap.xml" + +# Pinterest 어댑터와 동일 정책 — robots.txt 가 모든 봇 disallow 이지만 명시 +# Googlebot/Bing 은 허용. fashion 메타데이터 ground-truth 수집 한해 fair-use 운영, +# rate limit 1 req/sec 준수, retry-after 존중. +_DEFAULT_USER_AGENT = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/122.0.0.0 Safari/537.36" +) + + +class StarStyleAdapter: + """Implements ``SourceAdapter`` for starstyle.com.""" + + platform: str = "starstyle" + #: scheduler discovery_cycle 이 사용 — wots 와 동일하게 raw_posts 직행 + #: (global feed polling). source 그래프 없음. + discovery_target: str = "raw_posts" + #: 모든 raw_posts 가 매달릴 단일 전역 source. + global_source_identifier: str = "global" + global_source_label: str = "StarStyle global feed" + + def __init__(self, environment) -> None: + self._env = environment + + async def fetch(self, req: FetchRequest) -> List[RawMedia]: + """Pipeline B entry — post slug 1개 → RawMedia 1개.""" + if req.source_type != "post": + raise NotImplementedError( + f"StarStyleAdapter only supports source_type='post' " + f"(got {req.source_type!r})" + ) + media = await self._fetch_post(req.source_identifier) + return [media] if media is not None else [] + + async def discover_related( + self, seed_identifier: str, target: int = 50 + ) -> List[RawMedia]: + """Pipeline A entry — sitemap.xml 의 newest-first URL N 개 → RawMedia. + + starstyle 는 셀럽 그래프가 없어 ``seed_identifier`` 는 의미 없다. + sitemap 이 최신 ~500 개를 newest-first 로 노출하므로 거기서 target 개 만큼. + """ + return await self._fetch_sitemap_recent(target) + + # ---- Internal --------------------------------------------------------- + + async def _fetch_post(self, slug: str) -> Optional[RawMedia]: + slug = (slug or "").strip().strip("/") + if not slug: + raise ValueError("starstyle post slug is empty") + url = f"{_ORIGIN}/{slug}/" + async with self._client() as http: + resp = await http.get(url) + resp.raise_for_status() + html = resp.text + post = parse_post(html) + if post is None: + logger.warning("starstyle.adapter: parse_post returned None for %s", slug) + return None + return _post_to_raw_media(post) + + async def _fetch_sitemap_recent(self, target: int) -> List[RawMedia]: + target = max(1, int(target)) + delay = self._page_delay_seconds() + async with self._client() as http: + resp = await http.get(_SITEMAP_URL) + resp.raise_for_status() + urls = parse_sitemap(resp.text) + medias: List[RawMedia] = [] + for url in urls[:target]: + try: + page = await http.get(url) + page.raise_for_status() + except Exception as exc: + logger.warning( + "starstyle.adapter: skip %s (fetch error) — %s", url, exc + ) + continue + post = parse_post(page.text) + if post is None: + logger.warning("starstyle.adapter: skip %s (parse_post None)", url) + continue + media = _post_to_raw_media(post) + if media is not None: + medias.append(media) + if delay > 0 and len(medias) < target: + await asyncio.sleep(delay) + return medias + + def _page_delay_seconds(self) -> float: + ms = getattr(self._env, "STARSTYLE_PAGE_DELAY_MS", 1500) or 1500 + try: + return max(0.0, float(ms) / 1000.0) + except (TypeError, ValueError): + return 1.5 + + def _client(self) -> httpx.AsyncClient: + ua = getattr(self._env, "STARSTYLE_USER_AGENT", None) or _DEFAULT_USER_AGENT + return httpx.AsyncClient( + headers={ + "User-Agent": ua, + "Accept-Language": "en-US,en;q=0.9", + }, + follow_redirects=True, + timeout=30, + ) + + +# ---- Pure helpers (testable without httpx) ------------------------------- + + +def _post_to_raw_media(post: StarStylePost) -> Optional[RawMedia]: + if not post.post_id or not post.image_url: + return None + site_items = [_to_site_item_dict(it) for it in post.items] + site_items = [d for d in site_items if d is not None] + platform_metadata: Dict[str, Any] = { + # pipeline.py:112 규약 — composite 다운로드 / reparse 가 사용. + "url": post.image_url, + "post_slug": post.slug, + "celebrity_slug": post.celebrity_slug, + "published_at": post.published_at, + "site_labels": { + "artist_name_en": post.celebrity_name, + "items": site_items, + }, + } + platform_metadata = { + k: v + for k, v in platform_metadata.items() + if v not in (None, "") or k in ("site_labels", "url") + } + return RawMedia( + external_id=str(post.post_id), + external_url=post.url, + image_url=post.image_url, + caption=post.caption or "", + author_name=post.celebrity_name, + platform_metadata=platform_metadata, + # #466 — site 가 fashion-decode 가치 검증된 단일 셀럽 사진만 노출하므로 + # Gemma composite gate 우회. 곧장 processing_cycle 로. + bypass_composite_filter=True, + ) + + +def _to_site_item_dict(item) -> Optional[Dict[str, Any]]: + """``StarStyleItem`` → ``ParsedItem`` 호환 dict (``_prelabeled.py`` 가 빌드). + + starstyle 은 brand 와 title 이 한 줄 텍스트로 합쳐 있어 보수적으로 분리하지 + 않고 ``product`` 에 통째로 둔다 (``brand=None``). admin verify 단계에서 + 분리. ``thumbnail_url`` 은 사이트에 per-item 이미지가 없으므로 None → + items_thumbnail 단계에서 spots bbox crop fallback. + """ + if item is None or not getattr(item, "product_id", None): + return None + title = (item.title or "").strip() or None + if not title: + return None + retailer = item.retailer_url + return { + "site_item_id": item.product_id, + "brand": None, + "product": title, + "price": None, + "category": None, + "thumbnail_url": None, + "original_url": retailer, + "url_candidates": ( + [{"url": retailer, "source": "skimresources"}] if retailer else None + ), + } + + +__all__ = [ + "StarStyleAdapter", + "_post_to_raw_media", + "_to_site_item_dict", +] diff --git a/packages/ai-server/src/services/raw_posts/scheduler.py b/packages/ai-server/src/services/raw_posts/scheduler.py index 0cb5e296..113ebbe5 100644 --- a/packages/ai-server/src/services/raw_posts/scheduler.py +++ b/packages/ai-server/src/services/raw_posts/scheduler.py @@ -45,7 +45,12 @@ # 1-source-1-post 모델에서 v1 지원 platform — 어댑터 구현체가 있는 platform 만. # UI 토글이 ON 이어도 여기 없으면 expansion/discovery cycle 이 skip. -_SUPPORTED_PLATFORMS: tuple[str, ...] = ("pinterest", "instagram", "whatsonthestar") +_SUPPORTED_PLATFORMS: tuple[str, ...] = ( + "pinterest", + "instagram", + "whatsonthestar", + "starstyle", +) # APScheduler tick 간격. 실제 cycle 은 settings 의 cycle_seconds 와 last_run_at # 비교로 실행 여부 판단 — admin 의 UI inline-edit 가 다음 tick 부터 자연 반영. diff --git a/packages/ai-server/tests/unit/services/raw_posts/fixtures/starstyle_post_cate_blanchett.html b/packages/ai-server/tests/unit/services/raw_posts/fixtures/starstyle_post_cate_blanchett.html new file mode 100644 index 00000000..2d140dad --- /dev/null +++ b/packages/ai-server/tests/unit/services/raw_posts/fixtures/starstyle_post_cate_blanchett.html @@ -0,0 +1,2066 @@ + + + + + + +Cate Blanchett Bfi Fellowship Celebration for Guillermo Del Toro May 6, 2026 – Star Style + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+
+ + + + + + + + +
+ + + + + + + +
+ + + +
+
+
+ + + + +
+ + + diff --git a/packages/ai-server/tests/unit/services/raw_posts/fixtures/starstyle_post_olivia_culpo.html b/packages/ai-server/tests/unit/services/raw_posts/fixtures/starstyle_post_olivia_culpo.html new file mode 100644 index 00000000..62cb1a57 --- /dev/null +++ b/packages/ai-server/tests/unit/services/raw_posts/fixtures/starstyle_post_olivia_culpo.html @@ -0,0 +1,2071 @@ + + + + + + +Olivia Culpo Instagram May 6, 2026 – Star Style + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+
+ + + + + + + + +
+ + + + + + + +
+ + + +
+
+
+ + + + +
+ + + diff --git a/packages/ai-server/tests/unit/services/raw_posts/fixtures/starstyle_post_taylor_hill.html b/packages/ai-server/tests/unit/services/raw_posts/fixtures/starstyle_post_taylor_hill.html new file mode 100644 index 00000000..48ea6dd6 --- /dev/null +++ b/packages/ai-server/tests/unit/services/raw_posts/fixtures/starstyle_post_taylor_hill.html @@ -0,0 +1,2080 @@ + + + + + + +Taylor Hill Instagram Story May 6, 2026 – Star Style + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+
+ + + + + + + + +
+ + + + + + + +
+ + + +
+
+
+ + + + +
+ + + diff --git a/packages/ai-server/tests/unit/services/raw_posts/fixtures/starstyle_sitemap.xml b/packages/ai-server/tests/unit/services/raw_posts/fixtures/starstyle_sitemap.xml new file mode 100644 index 00000000..1deab343 --- /dev/null +++ b/packages/ai-server/tests/unit/services/raw_posts/fixtures/starstyle_sitemap.xml @@ -0,0 +1,3003 @@ + + + + http://www.starstyle.com/taylor-hill-instagram-story-sp926700/ + http://www.starstyle.com/wp-content/uploads/taylor-hill/926700.jpg + + 0.7 + + + http://www.starstyle.com/charli-damelio-instagram-sp926697/ + http://www.starstyle.com/wp-content/uploads/charli-damelio/926697.jpg + + 0.7 + + + http://www.starstyle.com/olivia-culpo-instagram-sp926695/ + http://www.starstyle.com/wp-content/uploads/olivia-culpo/926695.jpg + + 0.7 + + + http://www.starstyle.com/olivia-culpo-instagram-sp926693/ + http://www.starstyle.com/wp-content/uploads/olivia-culpo/926693.jpg + + 0.7 + + + http://www.starstyle.com/olivia-culpo-instagram-sp926692/ + http://www.starstyle.com/wp-content/uploads/olivia-culpo/926692.jpg + + 0.7 + + + http://www.starstyle.com/olivia-culpo-instagram-sp926688/ + http://www.starstyle.com/wp-content/uploads/olivia-culpo/926688.jpg + + 0.7 + + + http://www.starstyle.com/olivia-culpo-instagram-sp926685/ + http://www.starstyle.com/wp-content/uploads/olivia-culpo/926685.jpg + + 0.7 + + + http://www.starstyle.com/cate-blanchett-fellowship-celebration-guillermo-toro-sp926684/ + http://www.starstyle.com/wp-content/uploads/cate-blanchett/926684.jpg + + 0.7 + + + http://www.starstyle.com/tiffany-young-instagram-sp926679/ + http://www.starstyle.com/wp-content/uploads/tiffany-young/926679.jpg + + 0.7 + + + http://www.starstyle.com/kylie-jenner-knicks-game-sp926676/ + http://www.starstyle.com/wp-content/uploads/kylie-jenner/926676.jpg + + 0.7 + + + http://www.starstyle.com/rosie-huntington-whiteley-saint-laurent-gala-after-party-sp926083/ + http://www.starstyle.com/wp-content/uploads/rosie-huntington-whiteley/926083.jpg + + 0.7 + + + http://www.starstyle.com/jennie-gala-after-party-sp925961/ + http://www.starstyle.com/wp-content/uploads/jennie-kim/925961.jpg + + 0.7 + + + http://www.starstyle.com/karlie-kloss-angeles-sp926666/ + http://www.starstyle.com/wp-content/uploads/karlie-kloss/926666.jpg + + 0.7 + + + http://www.starstyle.com/karlie-kloss-york-city-sp926671/ + http://www.starstyle.com/wp-content/uploads/karlie-kloss/926671.jpg + + 0.7 + + + http://www.starstyle.com/alix-earle-tiktok-sp926663/ + http://www.starstyle.com/wp-content/uploads/alix-earle/926663.jpg + + 0.7 + + + http://www.starstyle.com/niki-demartino-instagram-sp926652/ + http://www.starstyle.com/wp-content/uploads/niki-demartino/926652.jpg + + 0.7 + + + http://www.starstyle.com/elsa-hosk-instagram-sp926643/ + http://www.starstyle.com/wp-content/uploads/elsa-hosk/926643.jpg + + 0.7 + + + http://www.starstyle.com/madison-lecroy-instagram-story-sp926641/ + http://www.starstyle.com/wp-content/uploads/madison-lecroy/926641.jpg + + 0.7 + + + http://www.starstyle.com/madison-lecroy-instagram-sp926635/ + http://www.starstyle.com/wp-content/uploads/madison-lecroy/926635.jpg + + 0.7 + + + http://www.starstyle.com/madison-lecroy-instagram-story-sp926633/ + http://www.starstyle.com/wp-content/uploads/madison-lecroy/926633.jpg + + 0.7 + + + http://www.starstyle.com/chiara-ferragni-instagram-story-sp926491/ + http://www.starstyle.com/wp-content/uploads/chiara-ferragni/926491.jpg + + 0.7 + + + http://www.starstyle.com/camila-morrone-something-very-going-happen-soho-house-west-hollywood-sp925013/ + http://www.starstyle.com/wp-content/uploads/camila-morrone/925013.jpg + + 0.7 + + + http://www.starstyle.com/sophie-turner-mallorca-sp909811/ + http://www.starstyle.com/wp-content/uploads/sophie-turner/909811.jpg + + 0.7 + + + http://www.starstyle.com/paris-hilton-signia-hilton-diplomat-beach-resort-grand-opening-sp926597/ + http://www.starstyle.com/wp-content/uploads/paris-hilton/926597.jpg + + 0.7 + + + http://www.starstyle.com/paris-hilton-karl-lagerfeld-odda-magazine-sp926613/ + http://www.starstyle.com/wp-content/uploads/paris-hilton/926613.jpg + + 0.7 + + + http://www.starstyle.com/zendaya-coleman-vogue-brasil-sp926525/ + http://www.starstyle.com/wp-content/uploads/zendaya-coleman/926525.jpg + + 0.7 + + + http://www.starstyle.com/zendaya-coleman-vogue-brasil-sp926526/ + http://www.starstyle.com/wp-content/uploads/zendaya-coleman/926526.jpg + + 0.7 + + + http://www.starstyle.com/phoebe-tonkin-miami-grand-prix-race-sp925716/ + http://www.starstyle.com/wp-content/uploads/phoebe-tonkin/925716.jpg + + 0.7 + + + http://www.starstyle.com/phoebe-tonkin-gala-after-party-sp926335/ + http://www.starstyle.com/wp-content/uploads/phoebe-tonkin/926335.jpg + + 0.7 + + + http://www.starstyle.com/zendaya-coleman-vogue-brasil-sp926495/ + http://www.starstyle.com/wp-content/uploads/zendaya-coleman/926495.jpg + + 0.7 + + + http://www.starstyle.com/paris-hilton-instagram-sp926596/ + http://www.starstyle.com/wp-content/uploads/paris-hilton/926596.jpg + + 0.7 + + + http://www.starstyle.com/kate-middleton-university-east-london-sp926578/ + http://www.starstyle.com/wp-content/uploads/kate-middleton/926578.jpg + + 0.7 + + + http://www.starstyle.com/zendaya-coleman-vogue-brasil-sp926492/ + http://www.starstyle.com/wp-content/uploads/zendaya-coleman/926492.jpg + + 0.7 + + + http://www.starstyle.com/kate-hudson-jennifer-hudson-show-sp926571/ + http://www.starstyle.com/wp-content/uploads/kate-hudson/926571.jpg + + 0.7 + + + http://www.starstyle.com/kate-hudson-running-point-sp926593/ + http://www.starstyle.com/wp-content/uploads/kate-hudson/926593.jpg + + 0.7 + + + http://www.starstyle.com/kate-hudson-running-point-sp926589/ + http://www.starstyle.com/wp-content/uploads/kate-hudson/926589.jpg + + 0.7 + + + http://www.starstyle.com/kate-hudson-running-point-sp926585/ + http://www.starstyle.com/wp-content/uploads/kate-hudson/926585.jpg + + 0.7 + + + http://www.starstyle.com/kate-hudson-running-point-sp926579/ + http://www.starstyle.com/wp-content/uploads/kate-hudson/926579.jpg + + 0.7 + + + http://www.starstyle.com/princess-eugenie-london-sp926570/ + http://www.starstyle.com/wp-content/uploads/princess-eugenie/926570.jpg + + 0.7 + + + http://www.starstyle.com/emma-roberts-instagram-story-sp926553/ + http://www.starstyle.com/wp-content/uploads/emma-roberts/926553.jpg + + 0.7 + + + http://www.starstyle.com/emma-roberts-instagram-story-sp926557/ + http://www.starstyle.com/wp-content/uploads/emma-roberts/926557.jpg + + 0.7 + + + http://www.starstyle.com/emma-roberts-miami-grand-prix-sp925473/ + http://www.starstyle.com/wp-content/uploads/emma-roberts/925473.jpg + + 0.7 + + + http://www.starstyle.com/alessandra-ambrosio-harpers-bazaar-brazil-sp926546/ + http://www.starstyle.com/wp-content/uploads/alessandra-ambrosio/926546.jpg + + 0.7 + + + http://www.starstyle.com/daisy-edgar-jones-harlem-sp924789/ + http://www.starstyle.com/wp-content/uploads/daisy-edgar-jones/924789.jpg + + 0.7 + + + http://www.starstyle.com/daisy-edgar-jones-york-city-sp925151/ + http://www.starstyle.com/wp-content/uploads/daisy-edgar-jones/925151.jpg + + 0.7 + + + http://www.starstyle.com/julianne-moore-gala-sp925710/ + http://www.starstyle.com/wp-content/uploads/julianne-moore/925710.jpg + + 0.7 + + + http://www.starstyle.com/karlie-kloss-york-city-sp926533/ + http://www.starstyle.com/wp-content/uploads/karlie-kloss/926533.jpg + + 0.7 + + + http://www.starstyle.com/karlie-kloss-york-city-sp926536/ + http://www.starstyle.com/wp-content/uploads/karlie-kloss/926536.jpg + + 0.7 + + + http://www.starstyle.com/gwyneth-paltrow-book-signing-william-sonoma-sp926507/ + http://www.starstyle.com/wp-content/uploads/gwyneth-paltrow/926507.jpg + + 0.7 + + + http://www.starstyle.com/jennie-incheon-airport-sp926539/ + http://www.starstyle.com/wp-content/uploads/jennie-kim/926539.jpg + + 0.7 + + + http://www.starstyle.com/carrie-underwood-american-idol-sp926522/ + http://www.starstyle.com/wp-content/uploads/carrie-underwood/926522.jpg + + 0.7 + + + http://www.starstyle.com/gwyneth-paltrow-goop-chinti-parker-sp926519/ + http://www.starstyle.com/wp-content/uploads/gwyneth-paltrow/926519.jpg + + 0.7 + + + http://www.starstyle.com/gwyneth-paltrow-goop-chinti-parker-sp926511/ + http://www.starstyle.com/wp-content/uploads/gwyneth-paltrow/926511.jpg + + 0.7 + + + http://www.starstyle.com/rose-gala-sp925656/ + http://www.starstyle.com/wp-content/uploads/rose/925656.jpg + + 0.7 + + + http://www.starstyle.com/daisy-edgar-jones-instagram-sp926500/ + http://www.starstyle.com/wp-content/uploads/daisy-edgar-jones/926500.jpg + + 0.7 + + + http://www.starstyle.com/grammy-awards-sp924839/ + http://www.starstyle.com/wp-content/uploads/charlotte-lawrence/924839.jpg + + 0.7 + + + http://www.starstyle.com/sydney-sweeney-euphoria-sp925378/ + http://www.starstyle.com/wp-content/uploads/sydney-sweeney/925378.jpg + + 0.7 + + + http://www.starstyle.com/dixie-damelio-instagram-sp926484/ + http://www.starstyle.com/wp-content/uploads/dixie-damelio/926484.jpg + + 0.7 + + + http://www.starstyle.com/dixie-damelio-instagram-sp926481/ + http://www.starstyle.com/wp-content/uploads/dixie-damelio/926481.jpg + + 0.7 + + + http://www.starstyle.com/dixie-damelio-instagram-sp926486/ + http://www.starstyle.com/wp-content/uploads/dixie-damelio/926486.jpg + + 0.7 + + + http://www.starstyle.com/dixie-damelio-instagram-sp926487/ + http://www.starstyle.com/wp-content/uploads/dixie-damelio/926487.jpg + + 0.7 + + + http://www.starstyle.com/dixie-damelio-instagram-sp926489/ + http://www.starstyle.com/wp-content/uploads/dixie-damelio/926489.jpg + + 0.7 + + + http://www.starstyle.com/margot-robbie-chinatown-sp926480/ + http://www.starstyle.com/wp-content/uploads/margot-robbie/926480.jpg + + 0.7 + + + http://www.starstyle.com/dixie-damelio-instagram-sp926478/ + http://www.starstyle.com/wp-content/uploads/dixie-damelio/926478.jpg + + 0.7 + + + http://www.starstyle.com/amelia-gray-york-city-sp925637/ + http://www.starstyle.com/wp-content/uploads/amelia-gray/925637.jpg + + 0.7 + + + http://www.starstyle.com/daisy-edgar-jones-york-city-sp926448/ + http://www.starstyle.com/wp-content/uploads/daisy-edgar-jones/926448.jpg + + 0.7 + + + http://www.starstyle.com/dixie-damelio-instagram-sp926468/ + http://www.starstyle.com/wp-content/uploads/dixie-damelio/926468.jpg + + 0.7 + + + http://www.starstyle.com/hunter-schafer-gala-sp819444/ + http://www.starstyle.com/wp-content/uploads/hunter-schafer/819444.jpg + + 0.7 + + + http://www.starstyle.com/dixie-damelio-vanity-fair-vanities-night-young-hollywood-event-sp926464/ + http://www.starstyle.com/wp-content/uploads/dixie-damelio/926464.jpg + + 0.7 + + + http://www.starstyle.com/alexa-demie-euphoria-season-trailer-sp916773/ + http://www.starstyle.com/wp-content/uploads/alexa-demie/916773.jpg + + 0.7 + + + http://www.starstyle.com/lipa-denmark-sp925153/ + http://www.starstyle.com/wp-content/uploads/dua-lipa/925153.jpg + + 0.7 + + + http://www.starstyle.com/sydney-sweeney-euphoria-season-trailer-sp918546/ + http://www.starstyle.com/wp-content/uploads/sydney-sweeney/918546.jpg + + 0.7 + + + http://www.starstyle.com/sydney-sweeney-euphoria-season-trailer-sp894799/ + http://www.starstyle.com/wp-content/uploads/sydney-sweeney/894799.jpg + + 0.7 + + + http://www.starstyle.com/euphoria-sp922006/ + http://www.starstyle.com/wp-content/uploads/sydney-sweeney/922006.jpg + + 0.7 + + + http://www.starstyle.com/sydney-sweeney-euphoria-season-press-sp915870/ + http://www.starstyle.com/wp-content/uploads/sydney-sweeney/915870.jpg + + 0.7 + + + http://www.starstyle.com/sydney-sweeney-euphoria-sp925374/ + http://www.starstyle.com/wp-content/uploads/sydney-sweeney/925374.jpg + + 0.7 + + + http://www.starstyle.com/karlie-kloss-york-city-sp925823/ + http://www.starstyle.com/wp-content/uploads/karlie-kloss/925823.jpg + + 0.7 + + + http://www.starstyle.com/zendaya-coleman-vogue-brasil-sp926453/ + http://www.starstyle.com/wp-content/uploads/zendaya-coleman/926453.jpg + + 0.7 + + + http://www.starstyle.com/zendaya-coleman-vogue-brasil-sp926452/ + http://www.starstyle.com/wp-content/uploads/zendaya-coleman/926452.jpg + + 0.7 + + + http://www.starstyle.com/laura-harrier-gala-after-party-sp925998/ + http://www.starstyle.com/wp-content/uploads/laura-harrier/925998.jpg + + 0.7 + + + http://www.starstyle.com/olivia-dejonge-instagram-sp926449/ + http://www.starstyle.com/wp-content/uploads/olivia-dejonge/926449.jpg + + 0.7 + + + http://www.starstyle.com/vittoria-ceretti-york-city-sp926446/ + http://www.starstyle.com/wp-content/uploads/vittoria-ceretti/926446.jpg + + 0.7 + + + http://www.starstyle.com/maya-hawke-gala-sp925673/ + http://www.starstyle.com/wp-content/uploads/maya-hawke/925673.jpg + + 0.7 + + + http://www.starstyle.com/jessica-chastain-weeks-sp926414/ + http://www.starstyle.com/wp-content/uploads/jessica-chastain/926414.jpg + + 0.7 + + + http://www.starstyle.com/karlie-kloss-york-city-sp926355/ + http://www.starstyle.com/wp-content/uploads/karlie-kloss/926355.jpg + + 0.7 + + + http://www.starstyle.com/karlie-kloss-york-city-sp926369/ + http://www.starstyle.com/wp-content/uploads/karlie-kloss/926369.jpg + + 0.7 + + + http://www.starstyle.com/alix-earle-tiktok-sp924783/ + http://www.starstyle.com/wp-content/uploads/alix-earle/924783.jpg + + 0.7 + + + http://www.starstyle.com/jisoo-airport-sp926406/ + http://www.starstyle.com/wp-content/uploads/jisoo/926406.jpg + + 0.7 + + + http://www.starstyle.com/zoey-deutch-gala-after-party-sp926098/ + http://www.starstyle.com/wp-content/uploads/zoey-deutch/926098.jpg + + 0.7 + + + http://www.starstyle.com/florence-pugh-tobysebastian-instagram-post-sp926433/ + http://www.starstyle.com/wp-content/uploads/florence-pugh/926433.jpg + + 0.7 + + + http://www.starstyle.com/tiffany-young-duvetica-event-sp926432/ + http://www.starstyle.com/wp-content/uploads/tiffany-young/926432.jpg + + 0.7 + + + http://www.starstyle.com/tate-mcrae-gala-sp925726/ + http://www.starstyle.com/wp-content/uploads/tate-mcrae/925726.jpg + + 0.7 + + + http://www.starstyle.com/sydney-sweeney-sydney-sp926422/ + http://www.starstyle.com/wp-content/uploads/sydney-sweeney/926422.jpg + + 0.7 + + + http://www.starstyle.com/taeyeon-instagram-story-sp926417/ + http://www.starstyle.com/wp-content/uploads/taeyeon/926417.jpg + + 0.7 + + + http://www.starstyle.com/jessica-chastain-minivan-sp926409/ + http://www.starstyle.com/wp-content/uploads/jessica-chastain/926409.jpg + + 0.7 + + + http://www.starstyle.com/lisa-airport-sp926404/ + http://www.starstyle.com/wp-content/uploads/lisa/926404.jpg + + 0.7 + + + http://www.starstyle.com/simone-ashley-harpers-bazaar-arabia-sp926398/ + http://www.starstyle.com/wp-content/uploads/simone-ashley/926398.jpg + + 0.7 + + + http://www.starstyle.com/lipa-instagram-sp926389/ + http://www.starstyle.com/wp-content/uploads/dua-lipa/926389.jpg + + 0.7 + + + http://www.starstyle.com/rosie-huntington-whiteley-gala-sp925753/ + http://www.starstyle.com/wp-content/uploads/rosie-huntington-whiteley/925753.jpg + + 0.7 + + + http://www.starstyle.com/gabi-demartino-instagram-story-sp926392/ + http://www.starstyle.com/wp-content/uploads/gabi-demartino/926392.jpg + + 0.7 + + + http://www.starstyle.com/gabi-demartino-instagram-story-sp926391/ + http://www.starstyle.com/wp-content/uploads/gabi-demartino/926391.jpg + + 0.7 + + + http://www.starstyle.com/chiara-ferragni-instagram-story-sp926388/ + http://www.starstyle.com/wp-content/uploads/chiara-ferragni/926388.jpg + + 0.7 + + + http://www.starstyle.com/christina-aguilera-mountain-closing-concert-ischgl-austria-sp926386/ + http://www.starstyle.com/wp-content/uploads/christina-aguilera/926386.jpg + + 0.7 + + + http://www.starstyle.com/charli-damelio-instagram-sp926382/ + http://www.starstyle.com/wp-content/uploads/charli-damelio/926382.jpg + + 0.7 + + + http://www.starstyle.com/christina-aguilera-mountain-closing-concert-ischgl-austria-sp925252/ + http://www.starstyle.com/wp-content/uploads/christina-aguilera/925252.jpg + + 0.7 + + + http://www.starstyle.com/suki-waterhouse-auter-campaign-sp926379/ + http://www.starstyle.com/wp-content/uploads/suki-waterhouse/926379.jpg + + 0.7 + + + http://www.starstyle.com/suki-waterhouse-gala-sp925695/ + http://www.starstyle.com/wp-content/uploads/suki-waterhouse/925695.jpg + + 0.7 + + + http://www.starstyle.com/suki-waterhouse-getting-ready-gala-sp926361/ + http://www.starstyle.com/wp-content/uploads/suki-waterhouse/926361.jpg + + 0.7 + + + http://www.starstyle.com/kate-middleton-mothers-sp926312/ + http://www.starstyle.com/wp-content/uploads/kate-middleton/926312.jpg + + 0.7 + + + http://www.starstyle.com/karlie-kloss-paris-sp926310/ + http://www.starstyle.com/wp-content/uploads/karlie-kloss/926310.jpg + + 0.7 + + + http://www.starstyle.com/karlie-kloss-york-city-sp926318/ + http://www.starstyle.com/wp-content/uploads/karlie-kloss/926318.jpg + + 0.7 + + + http://www.starstyle.com/karlie-kloss-york-city-sp926324/ + http://www.starstyle.com/wp-content/uploads/karlie-kloss/926324.jpg + + 0.7 + + + http://www.starstyle.com/karlie-kloss-york-city-sp926329/ + http://www.starstyle.com/wp-content/uploads/karlie-kloss/926329.jpg + + 0.7 + + + http://www.starstyle.com/karlie-kloss-york-city-sp926333/ + http://www.starstyle.com/wp-content/uploads/karlie-kloss/926333.jpg + + 0.7 + + + http://www.starstyle.com/instagram-sp926123/ + http://www.starstyle.com/wp-content/uploads/lily-rose-depp/926123.jpg + + 0.7 + + + http://www.starstyle.com/instagram-sp925903/ + http://www.starstyle.com/wp-content/uploads/chase-infiniti/925903.jpg + + 0.7 + + + http://www.starstyle.com/laufey-getting-ready-gala-sp926292/ + http://www.starstyle.com/wp-content/uploads/laufey/926292.jpg + + 0.7 + + + http://www.starstyle.com/rose-gala-after-party-sp925940/ + http://www.starstyle.com/wp-content/uploads/rose/925940.jpg + + 0.7 + + + http://www.starstyle.com/sabrina-carpenter-gala-sp925752/ + http://www.starstyle.com/wp-content/uploads/sabrina-carpenter/925752.jpg + + 0.7 + + + http://www.starstyle.com/joey-king-gala-after-party-sp926105/ + http://www.starstyle.com/wp-content/uploads/joey-king/926105.jpg + + 0.7 + + + http://www.starstyle.com/daisy-edgar-jones-gala-after-party-sp925999/ + http://www.starstyle.com/wp-content/uploads/daisy-edgar-jones/925999.jpg + + 0.7 + + + http://www.starstyle.com/katy-perry-gala-after-party-sp925996/ + http://www.starstyle.com/wp-content/uploads/katy-perry/925996.jpg + + 0.7 + + + http://www.starstyle.com/elizabeth-debicki-gala-after-party-sp926108/ + http://www.starstyle.com/wp-content/uploads/elizabeth-debicki/926108.jpg + + 0.7 + + + http://www.starstyle.com/chase-infiniti-gala-after-party-sp925957/ + http://www.starstyle.com/wp-content/uploads/chase-infiniti/925957.jpg + + 0.7 + + + http://www.starstyle.com/edebiri-gala-after-party-sp926238/ + http://www.starstyle.com/wp-content/uploads/ayo-edebiri/926238.jpg + + 0.7 + + + http://www.starstyle.com/hoyeon-jung-gala-after-party-sp925967/ + http://www.starstyle.com/wp-content/uploads/hoyeon-jung/925967.jpg + + 0.7 + + + http://www.starstyle.com/claire-york-city-sp925661/ + http://www.starstyle.com/wp-content/uploads/claire-foy/925661.jpg + + 0.7 + + + http://www.starstyle.com/camila-mendes-saint-laurent-gala-after-party-sp926022/ + http://www.starstyle.com/wp-content/uploads/camila-mendes/926022.jpg + + 0.7 + + + http://www.starstyle.com/lisa-into-archive-sp926256/ + http://www.starstyle.com/wp-content/uploads/lisa/926256.jpg + + 0.7 + + + http://www.starstyle.com/doja-saint-laurent-gala-after-party-sp926024/ + http://www.starstyle.com/wp-content/uploads/doja-cat/926024.jpg + + 0.7 + + + http://www.starstyle.com/hailey-baldwin-gala-after-party-sp925910/ + http://www.starstyle.com/wp-content/uploads/hailey-baldwin/925910.jpg + + 0.7 + + + http://www.starstyle.com/camila-mendes-gala-sp925698/ + http://www.starstyle.com/wp-content/uploads/camila-mendes/925698.jpg + + 0.7 + + + http://www.starstyle.com/edebiri-gala-sp925724/ + http://www.starstyle.com/wp-content/uploads/ayo-edebiri/925724.jpg + + 0.7 + + + http://www.starstyle.com/karlie-kloss-gala-after-party-sp926002/ + http://www.starstyle.com/wp-content/uploads/karlie-kloss/926002.jpg + + 0.7 + + + http://www.starstyle.com/gracie-abrams-gala-after-party-sp925964/ + http://www.starstyle.com/wp-content/uploads/gracie-abrams/925964.jpg + + 0.7 + + + http://www.starstyle.com/laura-harrier-gala-sp925743/ + http://www.starstyle.com/wp-content/uploads/laura-harrier/925743.jpg + + 0.7 + + + http://www.starstyle.com/miranda-kerr-gala-sp925714/ + http://www.starstyle.com/wp-content/uploads/miranda-kerr/925714.jpg + + 0.7 + + + http://www.starstyle.com/bella-thorne-instagram-sp926237/ + http://www.starstyle.com/wp-content/uploads/bella-thorne/926237.jpg + + 0.7 + + + http://www.starstyle.com/bella-thorne-instagram-sp926235/ + http://www.starstyle.com/wp-content/uploads/bella-thorne/926235.jpg + + 0.7 + + + http://www.starstyle.com/gabi-demartino-instagram-story-sp926228/ + http://www.starstyle.com/wp-content/uploads/gabi-demartino/926228.jpg + + 0.7 + + + http://www.starstyle.com/gabi-demartino-instagram-story-sp926226/ + http://www.starstyle.com/wp-content/uploads/gabi-demartino/926226.jpg + + 0.7 + + + http://www.starstyle.com/selena-gomez-instagram-sp926225/ + http://www.starstyle.com/wp-content/uploads/selena-gomez/926225.jpg + + 0.7 + + + http://www.starstyle.com/kris-jenner-gala-sp925788/ + http://www.starstyle.com/wp-content/uploads/kris-jenner/925788.jpg + + 0.7 + + + http://www.starstyle.com/jisoo-york-city-sp926218/ + http://www.starstyle.com/wp-content/uploads/jisoo/926218.jpg + + 0.7 + + + http://www.starstyle.com/amelia-gray-saint-laurent-gala-after-party-sp926021/ + http://www.starstyle.com/wp-content/uploads/amelia-gray/926021.jpg + + 0.7 + + + http://www.starstyle.com/amelia-gray-gala-sp925681/ + http://www.starstyle.com/wp-content/uploads/amelia-gray/925681.jpg + + 0.7 + + + http://www.starstyle.com/stassi-schroeder-instagram-sp925622/ + http://www.starstyle.com/wp-content/uploads/stassi-schroeder/925622.jpg + + 0.7 + + + http://www.starstyle.com/stassi-schroeder-instagram-story-sp925224/ + http://www.starstyle.com/wp-content/uploads/stassi-schroeder/925224.jpg + + 0.7 + + + http://www.starstyle.com/kardashian-north-west-sp926206/ + http://www.starstyle.com/wp-content/uploads/kim-kardashian/926206.jpg + + 0.7 + + + http://www.starstyle.com/teyana-taylor-gala-after-party-sp925958/ + http://www.starstyle.com/wp-content/uploads/teyana-taylor/925958.jpg + + 0.7 + + + http://www.starstyle.com/kardashian-fear-sp925444/ + http://www.starstyle.com/wp-content/uploads/kim-kardashian/925444.jpg + + 0.7 + + + http://www.starstyle.com/kardashian-gala-sp925762/ + http://www.starstyle.com/wp-content/uploads/kim-kardashian/925762.jpg + + 0.7 + + + http://www.starstyle.com/tate-mcrae-gala-after-party-sp925952/ + http://www.starstyle.com/wp-content/uploads/tate-mcrae/925952.jpg + + 0.7 + + + http://www.starstyle.com/kardashian-instagram-sp926202/ + http://www.starstyle.com/wp-content/uploads/kim-kardashian/926202.jpg + + 0.7 + + + http://www.starstyle.com/keke-palmer-gala-sp925787/ + http://www.starstyle.com/wp-content/uploads/keke-palmer/925787.jpg + + 0.7 + + + http://www.starstyle.com/sabrina-carpenter-gala-after-party-sp925909/ + http://www.starstyle.com/wp-content/uploads/kendall-jenner/925909.jpg + + 0.7 + + + http://www.starstyle.com/beyonce-knowles-gala-sp925905/ + http://www.starstyle.com/wp-content/uploads/beyonce-knowles/925905.jpg + + 0.7 + + + http://www.starstyle.com/kardashian-instagram-sp926195/ + http://www.starstyle.com/wp-content/uploads/kim-kardashian/926195.jpg + + 0.7 + + + http://www.starstyle.com/rachel-sennott-gala-after-party-sp926100/ + http://www.starstyle.com/wp-content/uploads/rachel-sennott/926100.jpg + + 0.7 + + + http://www.starstyle.com/olivia-rodrigo-gala-after-party-sp925963/ + http://www.starstyle.com/wp-content/uploads/olivia-rodrigo/925963.jpg + + 0.7 + + + http://www.starstyle.com/jisoo-gala-after-party-sp925943/ + http://www.starstyle.com/wp-content/uploads/jisoo/925943.jpg + + 0.7 + + + http://www.starstyle.com/lisa-gala-after-party-sp925944/ + http://www.starstyle.com/wp-content/uploads/lisa/925944.jpg + + 0.7 + + + http://www.starstyle.com/maude-apatow-saint-laurent-gala-after-party-sp926023/ + http://www.starstyle.com/wp-content/uploads/maude-apatow/926023.jpg + + 0.7 + + + http://www.starstyle.com/simone-ashley-gala-after-party-sp926005/ + http://www.starstyle.com/wp-content/uploads/simone-ashley/926005.jpg + + 0.7 + + + http://www.starstyle.com/vittoria-ceretti-saint-laurent-gala-after-party-sp926017/ + http://www.starstyle.com/wp-content/uploads/vittoria-ceretti/926017.jpg + + 0.7 + + + http://www.starstyle.com/vittoria-ceretti-gala-sp925675/ + http://www.starstyle.com/wp-content/uploads/vittoria-ceretti/925675.jpg + + 0.7 + + + http://www.starstyle.com/kravitz-saint-laurent-gala-after-party-sp926019/ + http://www.starstyle.com/wp-content/uploads/zoe-kravitz/926019.jpg + + 0.7 + + + http://www.starstyle.com/lila-grace-moss-gala-after-party-sp926003/ + http://www.starstyle.com/wp-content/uploads/lila-grace-moss/926003.jpg + + 0.7 + + + http://www.starstyle.com/amanda-seyfried-glamour-sp768150/ + http://www.starstyle.com/wp-content/uploads/amanda-seyfried/768150.jpg + + 0.7 + + + http://www.starstyle.com/gabrielle-union-gala-sp925760/ + http://www.starstyle.com/wp-content/uploads/gabrielle-union/925760.jpg + + 0.7 + + + http://www.starstyle.com/anne-hathaway-gala-sp925697/ + http://www.starstyle.com/wp-content/uploads/anne-hathaway/925697.jpg + + 0.7 + + + http://www.starstyle.com/charli-gala-after-party-sp925959/ + http://www.starstyle.com/wp-content/uploads/charli-xcx/925959.jpg + + 0.7 + + + http://www.starstyle.com/amanda-seyfried-gala-sp925670/ + http://www.starstyle.com/wp-content/uploads/amanda-seyfried/925670.jpg + + 0.7 + + + http://www.starstyle.com/annabelle-wallis-saint-laurent-gala-after-party-sp926014/ + http://www.starstyle.com/wp-content/uploads/annabelle-wallis/926014.jpg + + 0.7 + + + http://www.starstyle.com/kardashian-japan-sp926175/ + http://www.starstyle.com/wp-content/uploads/kim-kardashian/926175.jpg + + 0.7 + + + http://www.starstyle.com/chase-wonders-gala-after-party-sp926099/ + http://www.starstyle.com/wp-content/uploads/chase-sui-wonders/926099.jpg + + 0.7 + + + http://www.starstyle.com/amanda-seyfried-gala-sp726439/ + http://www.starstyle.com/wp-content/uploads/amanda-seyfried/726439.jpg + + 0.7 + + + http://www.starstyle.com/gigi-hadid-gala-sp925658/ + http://www.starstyle.com/wp-content/uploads/gigi-hadid/925658.jpg + + 0.7 + + + http://www.starstyle.com/chantel-jeffries-instagram-story-sp926152/ + http://www.starstyle.com/wp-content/uploads/chantel-jeffries/926152.jpg + + 0.7 + + + http://www.starstyle.com/madonna-gala-sp925793/ + http://www.starstyle.com/wp-content/uploads/madonna/925793.jpg + + 0.7 + + + http://www.starstyle.com/chiara-ferragni-instagram-sp926146/ + http://www.starstyle.com/wp-content/uploads/chiara-ferragni/926146.jpg + + 0.7 + + + http://www.starstyle.com/katy-perry-gala-sp925676/ + http://www.starstyle.com/wp-content/uploads/katy-perry/925676.jpg + + 0.7 + + + http://www.starstyle.com/sarah-pidgeon-gala-after-party-sp926097/ + http://www.starstyle.com/wp-content/uploads/sarah-pidgeon/926097.jpg + + 0.7 + + + http://www.starstyle.com/sarah-pidgeon-gala-sp925666/ + http://www.starstyle.com/wp-content/uploads/sarah-pidgeon/925666.jpg + + 0.7 + + + http://www.starstyle.com/camila-morrone-gala-after-party-with-suki-waterhouse-sp926133/ + http://www.starstyle.com/wp-content/uploads/camila-morrone/926133.jpg + + 0.7 + + + http://www.starstyle.com/suki-waterhouse-gala-after-party-with-camila-morrone-sp926134/ + http://www.starstyle.com/wp-content/uploads/suki-waterhouse/926134.jpg + + 0.7 + + + http://www.starstyle.com/kacey-musgraves-spotify-house-sp925649/ + http://www.starstyle.com/wp-content/uploads/kacey-musgraves/925649.jpg + + 0.7 + + + http://www.starstyle.com/ciara-gala-after-part-sp925931/ + http://www.starstyle.com/wp-content/uploads/ciara/925931.jpg + + 0.7 + + + http://www.starstyle.com/ciara-gala-sp925795/ + http://www.starstyle.com/wp-content/uploads/ciara/925795.jpg + + 0.7 + + + http://www.starstyle.com/doja-gala-sp925655/ + http://www.starstyle.com/wp-content/uploads/doja-cat/925655.jpg + + 0.7 + + + http://www.starstyle.com/doja-gala-sp925851/ + http://www.starstyle.com/wp-content/uploads/doja-cat/925851.jpg + + 0.7 + + + http://www.starstyle.com/elizabeth-debicki-gala-sp925771/ + http://www.starstyle.com/wp-content/uploads/elizabeth-debicki/925771.jpg + + 0.7 + + + http://www.starstyle.com/teyana-taylor-gala-sp925797/ + http://www.starstyle.com/wp-content/uploads/teyana-taylor/925797.jpg + + 0.7 + + + http://www.starstyle.com/rachel-zegler-gala-sp925764/ + http://www.starstyle.com/wp-content/uploads/rachel-zegler/925764.jpg + + 0.7 + + + http://www.starstyle.com/cara-delevingne-york-city-sp925625/ + http://www.starstyle.com/wp-content/uploads/cara-delevingne/925625.jpg + + 0.7 + + + http://www.starstyle.com/tessa-thompson-gala-after-party-sp926001/ + http://www.starstyle.com/wp-content/uploads/tessa-thompson/926001.jpg + + 0.7 + + + http://www.starstyle.com/margot-robbie-gala-after-party-sp926012/ + http://www.starstyle.com/wp-content/uploads/margot-robbie/926012.jpg + + 0.7 + + + http://www.starstyle.com/margot-robbie-gala-sp925718/ + http://www.starstyle.com/wp-content/uploads/margot-robbie/925718.jpg + + 0.7 + + + http://www.starstyle.com/charlotte-lawrence-saint-laurent-gala-after-party-sp926015/ + http://www.starstyle.com/wp-content/uploads/charlotte-lawrence/926015.jpg + + 0.7 + + + http://www.starstyle.com/meghan-markle-godsons-first-holy-communion-sp926106/ + http://www.starstyle.com/wp-content/uploads/meghan-markle/926106.jpg + + 0.7 + + + http://www.starstyle.com/nicole-kidman-gala-sp925906/ + http://www.starstyle.com/wp-content/uploads/nicole-kidman/925906.jpg + + 0.7 + + + http://www.starstyle.com/blake-lively-gala-sp925796/ + http://www.starstyle.com/wp-content/uploads/blake-lively/925796.jpg + + 0.7 + + + http://www.starstyle.com/chase-wonders-gala-sp925657/ + http://www.starstyle.com/wp-content/uploads/chase-sui-wonders/925657.jpg + + 0.7 + + + http://www.starstyle.com/claire-york-city-sp925631/ + http://www.starstyle.com/wp-content/uploads/claire-foy/925631.jpg + + 0.7 + + + http://www.starstyle.com/emily-blunt-cartier-broderie-cartier-ring-sp925723/ + http://www.starstyle.com/wp-content/uploads/emily-blunt/925723.jpg + + 0.7 + + + http://www.starstyle.com/olivia-cooke-gala-sp925665/ + http://www.starstyle.com/wp-content/uploads/joey-king/925665.jpg + + 0.7 + + + http://www.starstyle.com/kravitz-gala-sp925669/ + http://www.starstyle.com/wp-content/uploads/zoe-kravitz/925669.jpg + + 0.7 + + + http://www.starstyle.com/janelle-monae-gala-sp925708/ + http://www.starstyle.com/wp-content/uploads/janelle-monae/925708.jpg + + 0.7 + + + http://www.starstyle.com/lily-rose-depp-gala-sp925728/ + http://www.starstyle.com/wp-content/uploads/lily-rose-depp/925728.jpg + + 0.7 + + + http://www.starstyle.com/rihanna-gala-sp925807/ + http://www.starstyle.com/wp-content/uploads/rihanna/925807.jpg + + 0.7 + + + http://www.starstyle.com/rihanna-york-city-sp925618/ + http://www.starstyle.com/wp-content/uploads/rihanna/925618.jpg + + 0.7 + + + http://www.starstyle.com/hunter-schafer-saint-laurent-gala-after-party-sp926020/ + http://www.starstyle.com/wp-content/uploads/hunter-schafer/926020.jpg + + 0.7 + + + http://www.starstyle.com/hunter-schafer-gala-sp925672/ + http://www.starstyle.com/wp-content/uploads/hunter-schafer/925672.jpg + + 0.7 + + + http://www.starstyle.com/hailey-baldwin-gala-sp925713/ + http://www.starstyle.com/wp-content/uploads/hailey-baldwin/925713.jpg + + 0.7 + + + http://www.starstyle.com/olivia-wilde-gala-sp925686/ + http://www.starstyle.com/wp-content/uploads/olivia-wilde/925686.jpg + + 0.7 + + + http://www.starstyle.com/doechii-gala-sp925744/ + http://www.starstyle.com/wp-content/uploads/doechii/925744.jpg + + 0.7 + + + http://www.starstyle.com/rachel-sennott-gala-sp925782/ + http://www.starstyle.com/wp-content/uploads/rachel-sennott/925782.jpg + + 0.7 + + + http://www.starstyle.com/lisa-gala-sp925667/ + http://www.starstyle.com/wp-content/uploads/lisa/925667.jpg + + 0.7 + + + http://www.starstyle.com/gracie-abrams-gala-sp925729/ + http://www.starstyle.com/wp-content/uploads/gracie-abrams/925729.jpg + + 0.7 + + + http://www.starstyle.com/daisy-edgar-jones-gala-sp925745/ + http://www.starstyle.com/wp-content/uploads/daisy-edgar-jones/925745.jpg + + 0.7 + + + http://www.starstyle.com/tessa-thompson-gala-sp925722/ + http://www.starstyle.com/wp-content/uploads/tessa-thompson/925722.jpg + + 0.7 + + + http://www.starstyle.com/sabrina-carpenter-gala-after-party-sp925956/ + http://www.starstyle.com/wp-content/uploads/sabrina-carpenter/925956.jpg + + 0.7 + + + http://www.starstyle.com/alexa-chung-gala-sp925774/ + http://www.starstyle.com/wp-content/uploads/alexa-chung/925774.jpg + + 0.7 + + + http://www.starstyle.com/karlie-kloss-gala-sp925687/ + http://www.starstyle.com/wp-content/uploads/karlie-kloss/925687.jpg + + 0.7 + + + http://www.starstyle.com/camila-morrone-gala-sp925694/ + http://www.starstyle.com/wp-content/uploads/camila-morrone/925694.jpg + + 0.7 + + + http://www.starstyle.com/charli-gala-sp925652/ + http://www.starstyle.com/wp-content/uploads/charli-xcx/925652.jpg + + 0.7 + + + http://www.starstyle.com/sabrina-carpenter-gala-sp925895/ + http://www.starstyle.com/wp-content/uploads/sabrina-carpenter/925895.jpg + + 0.7 + + + http://www.starstyle.com/carey-mulligan-gala-sp925739/ + http://www.starstyle.com/wp-content/uploads/carey-mulligan/925739.jpg + + 0.7 + + + http://www.starstyle.com/hoyeon-jung-gala-sp925777/ + http://www.starstyle.com/wp-content/uploads/hoyeon-jung/925777.jpg + + 0.7 + + + http://www.starstyle.com/jasmine-tookes-gala-sp925803/ + http://www.starstyle.com/wp-content/uploads/jasmine-tookes/925803.jpg + + 0.7 + + + http://www.starstyle.com/kate-moss-gala-sp925773/ + http://www.starstyle.com/wp-content/uploads/kate-moss/925773.jpg + + 0.7 + + + http://www.starstyle.com/irina-shayk-gala-sp925663/ + http://www.starstyle.com/wp-content/uploads/irina-shayk/925663.jpg + + 0.7 + + + http://www.starstyle.com/laufey-gala-sp925680/ + http://www.starstyle.com/wp-content/uploads/laufey/925680.jpg + + 0.7 + + + http://www.starstyle.com/chase-infiniti-wayman-micahs-annual-gala-party-sp925449/ + http://www.starstyle.com/wp-content/uploads/chase-infiniti/925449.jpg + + 0.7 + + + http://www.starstyle.com/sabrina-carpenter-gala-sp925907/ + http://www.starstyle.com/wp-content/uploads/sabrina-carpenter/925907.jpg + + 0.7 + + + http://www.starstyle.com/jennie-gala-sp925699/ + http://www.starstyle.com/wp-content/uploads/jennie-kim/925699.jpg + + 0.7 + + + http://www.starstyle.com/jisoo-gala-sp925660/ + http://www.starstyle.com/wp-content/uploads/jisoo/925660.jpg + + 0.7 + + + http://www.starstyle.com/lila-grace-moss-gala-sp925712/ + http://www.starstyle.com/wp-content/uploads/lila-grace-moss/925712.jpg + + 0.7 + + + http://www.starstyle.com/beyonce-knowles-gala-sp925889/ + http://www.starstyle.com/wp-content/uploads/beyonce-knowles/925889.jpg + + 0.7 + + + http://www.starstyle.com/chase-infiniti-gala-sp925684/ + http://www.starstyle.com/wp-content/uploads/chase-infiniti/925684.jpg + + 0.7 + + + http://www.starstyle.com/simone-ashley-gala-sp925808/ + http://www.starstyle.com/wp-content/uploads/simone-ashley/925808.jpg + + 0.7 + + + http://www.starstyle.com/sarah-paulson-gala-sp925707/ + http://www.starstyle.com/wp-content/uploads/sarah-paulson/925707.jpg + + 0.7 + + + http://www.starstyle.com/greta-gerwig-gala-sp925786/ + http://www.starstyle.com/wp-content/uploads/greta-gerwig/925786.jpg + + 0.7 + + + http://www.starstyle.com/margot-robbie-angeles-sp925859/ + http://www.starstyle.com/wp-content/uploads/margot-robbie/925859.jpg + + 0.7 + + + http://www.starstyle.com/kylie-jenner-gala-sp925701/ + http://www.starstyle.com/wp-content/uploads/kylie-jenner/925701.jpg + + 0.7 + + + http://www.starstyle.com/naomi-watts-gala-sp925674/ + http://www.starstyle.com/wp-content/uploads/naomi-watts/925674.jpg + + 0.7 + + + http://www.starstyle.com/margot-robbie-angeles-sp925848/ + http://www.starstyle.com/wp-content/uploads/margot-robbie/925848.jpg + + 0.7 + + + http://www.starstyle.com/nicole-kidman-gala-sp925653/ + http://www.starstyle.com/wp-content/uploads/nicole-kidman/925653.jpg + + 0.7 + + + http://www.starstyle.com/margot-robbie-angeles-sp925842/ + http://www.starstyle.com/wp-content/uploads/margot-robbie/925842.jpg + + 0.7 + + + http://www.starstyle.com/maude-apatow-gala-sp925689/ + http://www.starstyle.com/wp-content/uploads/maude-apatow/925689.jpg + + 0.7 + + + http://www.starstyle.com/margot-robbie-angeles-sp925838/ + http://www.starstyle.com/wp-content/uploads/margot-robbie/925838.jpg + + 0.7 + + + http://www.starstyle.com/margot-robbie-angeles-sp925831/ + http://www.starstyle.com/wp-content/uploads/margot-robbie/925831.jpg + + 0.7 + + + http://www.starstyle.com/karlie-kloss-york-city-sp925717/ + http://www.starstyle.com/wp-content/uploads/karlie-kloss/925717.jpg + + 0.7 + + + http://www.starstyle.com/karlie-kloss-york-city-sp925719/ + http://www.starstyle.com/wp-content/uploads/karlie-kloss/925719.jpg + + 0.7 + + + http://www.starstyle.com/karlie-kloss-york-city-sp925820/ + http://www.starstyle.com/wp-content/uploads/karlie-kloss/925820.jpg + + 0.7 + + + http://www.starstyle.com/taeyeon-elle-korea-sp923712/ + http://www.starstyle.com/wp-content/uploads/taeyeon/923712.jpg + + 0.7 + + + http://www.starstyle.com/rita-instagram-sp925794/ + http://www.starstyle.com/wp-content/uploads/rita-ora/925794.jpg + + 0.7 + + + http://www.starstyle.com/rita-instagram-sp925790/ + http://www.starstyle.com/wp-content/uploads/rita-ora/925790.jpg + + 0.7 + + + http://www.starstyle.com/rita-instagram-sp925785/ + http://www.starstyle.com/wp-content/uploads/rita-ora/925785.jpg + + 0.7 + + + http://www.starstyle.com/rita-instagram-sp925784/ + http://www.starstyle.com/wp-content/uploads/rita-ora/925784.jpg + + 0.7 + + + http://www.starstyle.com/rita-instagram-sp925783/ + http://www.starstyle.com/wp-content/uploads/rita-ora/925783.jpg + + 0.7 + + + http://www.starstyle.com/rita-instagram-sp925779/ + http://www.starstyle.com/wp-content/uploads/rita-ora/925779.jpg + + 0.7 + + + http://www.starstyle.com/lucy-hale-instagram-sp925778/ + http://www.starstyle.com/wp-content/uploads/lucy-hale/925778.jpg + + 0.7 + + + http://www.starstyle.com/lucy-hale-instagram-sp925768/ + http://www.starstyle.com/wp-content/uploads/lucy-hale/925768.jpg + + 0.7 + + + http://www.starstyle.com/lauren-santo-domingo-devil-wears-prada-cinema-society-screening-sp925770/ + http://www.starstyle.com/wp-content/uploads/lauren-santo-domingo/925770.jpg + + 0.7 + + + http://www.starstyle.com/gigi-hadid-sp925082/ + http://www.starstyle.com/wp-content/uploads/gigi-hadid/925082.jpg + + 0.7 + + + http://www.starstyle.com/lucy-hale-instagram-sp925766/ + http://www.starstyle.com/wp-content/uploads/lucy-hale/925766.jpg + + 0.7 + + + http://www.starstyle.com/elsa-hosk-instagram-sp925755/ + http://www.starstyle.com/wp-content/uploads/elsa-hosk/925755.jpg + + 0.7 + + + http://www.starstyle.com/niki-demartino-instagram-story-sp925746/ + http://www.starstyle.com/wp-content/uploads/niki-demartino/925746.jpg + + 0.7 + + + http://www.starstyle.com/gabi-demartino-instagram-story-sp925742/ + http://www.starstyle.com/wp-content/uploads/gabi-demartino/925742.jpg + + 0.7 + + + http://www.starstyle.com/phoebe-tonkin-miami-sp925696/ + http://www.starstyle.com/wp-content/uploads/phoebe-tonkin/925696.jpg + + 0.7 + + + http://www.starstyle.com/rosie-huntington-whiteley-carlyle-hotel-sp924734/ + http://www.starstyle.com/wp-content/uploads/rosie-huntington-whiteley/924734.jpg + + 0.7 + + + http://www.starstyle.com/phoebe-tonkin-memoire-dinner-celebration-sp925679/ + http://www.starstyle.com/wp-content/uploads/phoebe-tonkin/925679.jpg + + 0.7 + + + http://www.starstyle.com/rosie-huntington-whiteley-memoire-dinner-celebration-sp924714/ + http://www.starstyle.com/wp-content/uploads/rosie-huntington-whiteley/924714.jpg + + 0.7 + + + http://www.starstyle.com/princess-eugenie-instagram-gwynethpaltrow-sp925471/ + http://www.starstyle.com/wp-content/uploads/gwyneth-paltrow/925471.jpg + + 0.7 + + + http://www.starstyle.com/suki-waterhouse-joanna-czech-anthem-sp925279/ + http://www.starstyle.com/wp-content/uploads/suki-waterhouse/925279.jpg + + 0.7 + + + http://www.starstyle.com/hailey-baldwin-miami-sp925121/ + http://www.starstyle.com/wp-content/uploads/hailey-baldwin/925121.jpg + + 0.7 + + + http://www.starstyle.com/gigi-hadid-york-city-sp925615/ + http://www.starstyle.com/wp-content/uploads/gigi-hadid/925615.jpg + + 0.7 + + + http://www.starstyle.com/bella-hadid-instagram-sp924405/ + http://www.starstyle.com/wp-content/uploads/bella-hadid/924405.jpg + + 0.7 + + + http://www.starstyle.com/kacey-musgraves-signing-event-toronto-sp924699/ + http://www.starstyle.com/wp-content/uploads/kacey-musgraves/924699.jpg + + 0.7 + + + http://www.starstyle.com/kacey-musgraves-texas-monthly-sp925592/ + http://www.starstyle.com/wp-content/uploads/kacey-musgraves/925592.jpg + + 0.7 + + + http://www.starstyle.com/kacey-musgraves-texas-monthly-sp925593/ + http://www.starstyle.com/wp-content/uploads/kacey-musgraves/925593.jpg + + 0.7 + + + http://www.starstyle.com/kacey-musgraves-show-sp925636/ + http://www.starstyle.com/wp-content/uploads/kacey-musgraves/925636.jpg + + 0.7 + + + http://www.starstyle.com/gwyneth-paltrow-instagram-gwynethpaltrow-sp925607/ + http://www.starstyle.com/wp-content/uploads/gwyneth-paltrow/925607.jpg + + 0.7 + + + http://www.starstyle.com/gwyneth-paltrow-instagram-gwynethpaltrow-sp925591/ + http://www.starstyle.com/wp-content/uploads/gwyneth-paltrow/925591.jpg + + 0.7 + + + http://www.starstyle.com/gwyneth-paltrow-instagram-gwynethpaltrow-sp925601/ + http://www.starstyle.com/wp-content/uploads/gwyneth-paltrow/925601.jpg + + 0.7 + + + http://www.starstyle.com/gwyneth-paltrow-instagram-gwynethpaltrow-sp925582/ + http://www.starstyle.com/wp-content/uploads/gwyneth-paltrow/925582.jpg + + 0.7 + + + http://www.starstyle.com/gwyneth-paltrow-instagram-gwynethpaltrow-sp925590/ + http://www.starstyle.com/wp-content/uploads/gwyneth-paltrow/925590.jpg + + 0.7 + + + http://www.starstyle.com/gwyneth-paltrow-instagram-gwynethpaltrow-sp925565/ + http://www.starstyle.com/wp-content/uploads/gwyneth-paltrow/925565.jpg + + 0.7 + + + http://www.starstyle.com/hailey-baldwin-instagram-sp924403/ + http://www.starstyle.com/wp-content/uploads/hailey-baldwin/924403.jpg + + 0.7 + + + http://www.starstyle.com/hailey-baldwin-frame-dinner-sp923906/ + http://www.starstyle.com/wp-content/uploads/hailey-baldwin/923906.jpg + + 0.7 + + + http://www.starstyle.com/gwyneth-paltrow-instagram-gwynethpaltrow-sp925564/ + http://www.starstyle.com/wp-content/uploads/gwyneth-paltrow/925564.jpg + + 0.7 + + + http://www.starstyle.com/gwyneth-paltrow-instagram-gwynethpaltrow-sp925540/ + http://www.starstyle.com/wp-content/uploads/gwyneth-paltrow/925540.jpg + + 0.7 + + + http://www.starstyle.com/gwyneth-paltrow-instagram-gwynethpaltrow-sp925517/ + http://www.starstyle.com/wp-content/uploads/gwyneth-paltrow/925517.jpg + + 0.7 + + + http://www.starstyle.com/gwyneth-paltrow-instagram-gwynethpaltrow-sp925532/ + http://www.starstyle.com/wp-content/uploads/gwyneth-paltrow/925532.jpg + + 0.7 + + + http://www.starstyle.com/gwyneth-paltrow-instagram-gwynethpaltrow-sp925544/ + http://www.starstyle.com/wp-content/uploads/gwyneth-paltrow/925544.jpg + + 0.7 + + + http://www.starstyle.com/gwyneth-paltrow-instagram-gwynethpaltrow-sp925557/ + http://www.starstyle.com/wp-content/uploads/gwyneth-paltrow/925557.jpg + + 0.7 + + + http://www.starstyle.com/gwyneth-paltrow-instagram-gwynethpaltrow-gwynofficial-collection-behind-scenes-sp925503/ + http://www.starstyle.com/wp-content/uploads/gwyneth-paltrow/925503.jpg + + 0.7 + + + http://www.starstyle.com/gwyneth-paltrow-instagram-gwynethpaltrow-sp925529/ + http://www.starstyle.com/wp-content/uploads/gwyneth-paltrow/925529.jpg + + 0.7 + + + http://www.starstyle.com/gwyneth-paltrow-instagram-gwynethpaltrow-sp925509/ + http://www.starstyle.com/wp-content/uploads/gwyneth-paltrow/925509.jpg + + 0.7 + + + http://www.starstyle.com/margot-robbie-york-city-sp925581/ + http://www.starstyle.com/wp-content/uploads/margot-robbie/925581.jpg + + 0.7 + + + http://www.starstyle.com/gwyneth-paltrow-florence-italy-sp925500/ + http://www.starstyle.com/wp-content/uploads/gwyneth-paltrow/925500.jpg + + 0.7 + + + http://www.starstyle.com/gwyneth-paltrow-instagram-gwynethpaltrow-sp919489/ + http://www.starstyle.com/wp-content/uploads/gwyneth-paltrow/919489.jpg + + 0.7 + + + http://www.starstyle.com/kacey-musgraves-gruene-hall-night-sp925583/ + http://www.starstyle.com/wp-content/uploads/kacey-musgraves/925583.jpg + + 0.7 + + + http://www.starstyle.com/kacey-musgraves-texas-monthly-sp925594/ + http://www.starstyle.com/wp-content/uploads/kacey-musgraves/925594.jpg + + 0.7 + + + http://www.starstyle.com/charli-york-city-sp925610/ + http://www.starstyle.com/wp-content/uploads/charli-xcx/925610.jpg + + 0.7 + + + http://www.starstyle.com/doja-york-city-sp925611/ + http://www.starstyle.com/wp-content/uploads/doja-cat/925611.jpg + + 0.7 + + + http://www.starstyle.com/millie-bobby-brown-instagram-story-sp925596/ + http://www.starstyle.com/wp-content/uploads/millie-bobby-brown/925596.jpg + + 0.7 + + + http://www.starstyle.com/stassi-schroeder-hulu-real-house-sp921242/ + http://www.starstyle.com/wp-content/uploads/stassi-schroeder/921242.jpg + + 0.7 + + + http://www.starstyle.com/stassi-schroeder-instagram-story-sp922734/ + http://www.starstyle.com/wp-content/uploads/stassi-schroeder/922734.jpg + + 0.7 + + + http://www.starstyle.com/stassi-schroeder-instagram-story-sp922730/ + http://www.starstyle.com/wp-content/uploads/stassi-schroeder/922730.jpg + + 0.7 + + + http://www.starstyle.com/stassi-schroeder-instagram-sp923973/ + http://www.starstyle.com/wp-content/uploads/stassi-schroeder/923973.jpg + + 0.7 + + + http://www.starstyle.com/stassi-schroeder-instagram-sp923972/ + http://www.starstyle.com/wp-content/uploads/stassi-schroeder/923972.jpg + + 0.7 + + + http://www.starstyle.com/stassi-schroeder-instagram-sp925229/ + http://www.starstyle.com/wp-content/uploads/stassi-schroeder/925229.jpg + + 0.7 + + + http://www.starstyle.com/gabi-demartino-tiktok-sp925548/ + http://www.starstyle.com/wp-content/uploads/gabi-demartino/925548.jpg + + 0.7 + + + http://www.starstyle.com/miranda-kerr-instagram-story-sp925578/ + http://www.starstyle.com/wp-content/uploads/miranda-kerr/925578.jpg + + 0.7 + + + http://www.starstyle.com/chantel-jeffries-instagram-story-sp925574/ + http://www.starstyle.com/wp-content/uploads/chantel-jeffries/925574.jpg + + 0.7 + + + http://www.starstyle.com/chiara-ferragni-instagram-story-sp925572/ + http://www.starstyle.com/wp-content/uploads/chiara-ferragni/925572.jpg + + 0.7 + + + http://www.starstyle.com/chiara-ferragni-instagram-sp925571/ + http://www.starstyle.com/wp-content/uploads/chiara-ferragni/925571.jpg + + 0.7 + + + http://www.starstyle.com/chiara-ferragni-instagram-sp925569/ + http://www.starstyle.com/wp-content/uploads/chiara-ferragni/925569.jpg + + 0.7 + + + http://www.starstyle.com/chiara-ferragni-instagram-story-sp925076/ + http://www.starstyle.com/wp-content/uploads/chiara-ferragni/925076.jpg + + 0.7 + + + http://www.starstyle.com/kylie-jenner-fear-sp925445/ + http://www.starstyle.com/wp-content/uploads/kylie-jenner/925445.jpg + + 0.7 + + + http://www.starstyle.com/olivia-rodrigo-lancome-skin-idole-makeup-magnet-setting-spray-sp925560/ + http://www.starstyle.com/wp-content/uploads/olivia-rodrigo/925560.jpg + + 0.7 + + + http://www.starstyle.com/olivia-rodrigo-york-city-sp925553/ + http://www.starstyle.com/wp-content/uploads/olivia-rodrigo/925553.jpg + + 0.7 + + + http://www.starstyle.com/meghan-markle-instagram-story-meghan-sp923616/ + http://www.starstyle.com/wp-content/uploads/meghan-markle/923616.jpg + + 0.7 + + + http://www.starstyle.com/nina-dobrev-that-shit-celebration-sp925546/ + http://www.starstyle.com/wp-content/uploads/nina-dobrev/925546.jpg + + 0.7 + + + http://www.starstyle.com/gabi-demartino-instagram-story-sp925545/ + http://www.starstyle.com/wp-content/uploads/gabi-demartino/925545.jpg + + 0.7 + + + http://www.starstyle.com/kendall-jenner-fear-sp925446/ + http://www.starstyle.com/wp-content/uploads/kendall-jenner/925446.jpg + + 0.7 + + + http://www.starstyle.com/madison-lecroy-instagram-story-sp925526/ + http://www.starstyle.com/wp-content/uploads/madison-lecroy/925526.jpg + + 0.7 + + + http://www.starstyle.com/maddie-ziegler-rollacoaster-magazine-sp925533/ + http://www.starstyle.com/wp-content/uploads/maddie-ziegler/925533.jpg + + 0.7 + + + http://www.starstyle.com/madison-lecroy-instagram-sp925505/ + http://www.starstyle.com/wp-content/uploads/madison-lecroy/925505.jpg + + 0.7 + + + http://www.starstyle.com/kravitz-york-sp925348/ + http://www.starstyle.com/wp-content/uploads/zoe-kravitz/925348.jpg + + 0.7 + + + http://www.starstyle.com/madison-lecroy-instagram-story-sp924883/ + http://www.starstyle.com/wp-content/uploads/madison-lecroy/924883.jpg + + 0.7 + + + http://www.starstyle.com/york-city-sp925346/ + http://www.starstyle.com/wp-content/uploads/sabrina-carpenter/925346.jpg + + 0.7 + + + http://www.starstyle.com/madison-lecroy-instagram-story-sp924905/ + http://www.starstyle.com/wp-content/uploads/madison-lecroy/924905.jpg + + 0.7 + + + http://www.starstyle.com/madison-lecroy-instagram-story-sp924879/ + http://www.starstyle.com/wp-content/uploads/madison-lecroy/924879.jpg + + 0.7 + + + http://www.starstyle.com/madison-lecroy-instagram-story-sp924875/ + http://www.starstyle.com/wp-content/uploads/madison-lecroy/924875.jpg + + 0.7 + + + http://www.starstyle.com/olivia-rodrigo-saturday-night-live-sp924974/ + http://www.starstyle.com/wp-content/uploads/olivia-rodrigo/924974.jpg + + 0.7 + + + http://www.starstyle.com/daisy-edgar-jones-york-city-sp925437/ + http://www.starstyle.com/wp-content/uploads/daisy-edgar-jones/925437.jpg + + 0.7 + + + http://www.starstyle.com/ariana-greenblatt-cultured-magazine-sp902007/ + http://www.starstyle.com/wp-content/uploads/ariana-greenblatt/902007.jpg + + 0.7 + + + http://www.starstyle.com/lipa-louisiana-museum-modern-denmark-sp925152/ + http://www.starstyle.com/wp-content/uploads/dua-lipa/925152.jpg + + 0.7 + + + http://www.starstyle.com/euphoria-sp922004/ + http://www.starstyle.com/wp-content/uploads/hunter-schafer/922004.jpg + + 0.7 + + + http://www.starstyle.com/princess-eugenie-lunch-soho-mews-house-mayfair-sp925463/ + http://www.starstyle.com/wp-content/uploads/princess-eugenie/925463.jpg + + 0.7 + + + http://www.starstyle.com/princess-eugenie-lunch-soho-mews-house-mayfair-sp925051/ + http://www.starstyle.com/wp-content/uploads/princess-eugenie/925051.jpg + + 0.7 + + + http://www.starstyle.com/princess-beatrice-lunch-soho-mews-house-mayfair-sp925049/ + http://www.starstyle.com/wp-content/uploads/princess-beatrice/925049.jpg + + 0.7 + + + http://www.starstyle.com/meghan-markle-lindsay-jill-roths-what-pretty-girls-made-book-launch-sp925322/ + http://www.starstyle.com/wp-content/uploads/meghan-markle/925322.jpg + + 0.7 + + + http://www.starstyle.com/york-city-sp925336/ + http://www.starstyle.com/wp-content/uploads/nicole-kidman/925336.jpg + + 0.7 + + + http://www.starstyle.com/chase-wonders-york-city-sp925438/ + http://www.starstyle.com/wp-content/uploads/chase-sui-wonders/925438.jpg + + 0.7 + + + http://www.starstyle.com/karlie-kloss-york-city-sp925434/ + http://www.starstyle.com/wp-content/uploads/karlie-kloss/925434.jpg + + 0.7 + + + http://www.starstyle.com/karlie-kloss-york-city-sp925436/ + http://www.starstyle.com/wp-content/uploads/karlie-kloss/925436.jpg + + 0.7 + + + http://www.starstyle.com/gabrielle-union-york-city-sp925435/ + http://www.starstyle.com/wp-content/uploads/gabrielle-union/925435.jpg + + 0.7 + + + http://www.starstyle.com/taeyeon-amazing-saturday-sp923825/ + http://www.starstyle.com/wp-content/uploads/taeyeon/923825.jpg + + 0.7 + + + http://www.starstyle.com/maude-apatow-euphoria-season-trailer-sp918543/ + http://www.starstyle.com/wp-content/uploads/maude-apatow/918543.jpg + + 0.7 + + + http://www.starstyle.com/tate-mcrae-with-sp925450/ + http://www.starstyle.com/wp-content/uploads/tate-mcrae/925450.jpg + + 0.7 + + + http://www.starstyle.com/olivia-rodrigo-york-city-sp925448/ + http://www.starstyle.com/wp-content/uploads/olivia-rodrigo/925448.jpg + + 0.7 + + + http://www.starstyle.com/jennie-york-city-sp925180/ + http://www.starstyle.com/wp-content/uploads/jennie-kim/925180.jpg + + 0.7 + + + http://www.starstyle.com/zendaya-coleman-euphoria-kitty-likes-dance-sp925419/ + http://www.starstyle.com/wp-content/uploads/zendaya-coleman/925419.jpg + + 0.7 + + + http://www.starstyle.com/karlie-kloss-adidas-photoshoot-sp925422/ + http://www.starstyle.com/wp-content/uploads/karlie-kloss/925422.jpg + + 0.7 + + + http://www.starstyle.com/karlie-kloss-york-city-sp925425/ + http://www.starstyle.com/wp-content/uploads/karlie-kloss/925425.jpg + + 0.7 + + + http://www.starstyle.com/teyana-taylor-gala-party-sp925198/ + http://www.starstyle.com/wp-content/uploads/teyana-taylor/925198.jpg + + 0.7 + + + http://www.starstyle.com/teyana-taylor-gala-party-sp925427/ + http://www.starstyle.com/wp-content/uploads/teyana-taylor/925427.jpg + + 0.7 + + + http://www.starstyle.com/alexa-demie-euphoria-sp925376/ + http://www.starstyle.com/wp-content/uploads/alexa-demie/925376.jpg + + 0.7 + + + http://www.starstyle.com/rosie-huntington-whiteley-york-sp925146/ + http://www.starstyle.com/wp-content/uploads/rosie-huntington-whiteley/925146.jpg + + 0.7 + + + http://www.starstyle.com/alexa-demie-euphoria-season-sp816661/ + http://www.starstyle.com/wp-content/uploads/alexa-demie/816661.jpg + + 0.7 + + + http://www.starstyle.com/euphoria-sp922008/ + http://www.starstyle.com/wp-content/uploads/zendaya-coleman/922008.jpg + + 0.7 + + + http://www.starstyle.com/zendaya-coleman-euphoria-sp925379/ + http://www.starstyle.com/wp-content/uploads/zendaya-coleman/925379.jpg + + 0.7 + + + http://www.starstyle.com/zendaya-coleman-euphoria-season-trailer-sp894800/ + http://www.starstyle.com/wp-content/uploads/zendaya-coleman/894800.jpg + + 0.7 + + + http://www.starstyle.com/euphoria-kitty-likes-dance-sp923897/ + http://www.starstyle.com/wp-content/uploads/rosalia/923897.jpg + + 0.7 + + + http://www.starstyle.com/rosalia-euphoria-season-sp795724/ + http://www.starstyle.com/wp-content/uploads/rosalia/795724.jpg + + 0.7 + + + http://www.starstyle.com/euphoria-season-sp917508/ + http://www.starstyle.com/wp-content/uploads/alexa-demie/917508.jpg + + 0.7 + + + http://www.starstyle.com/rosalia-euphoria-season-sp917398/ + http://www.starstyle.com/wp-content/uploads/rosalia/917398.jpg + + 0.7 + + + http://www.starstyle.com/naomi-watts-love-story-john-kennedy-carolyn-bessette-awards-event-sp923195/ + http://www.starstyle.com/wp-content/uploads/naomi-watts/923195.jpg + + 0.7 + + + http://www.starstyle.com/laura-harrier-instagram-story-sp925368/ + http://www.starstyle.com/wp-content/uploads/laura-harrier/925368.jpg + + 0.7 + + + http://www.starstyle.com/emily-blunt-york-city-sp925370/ + http://www.starstyle.com/wp-content/uploads/emily-blunt/925370.jpg + + 0.7 + + + http://www.starstyle.com/vittoria-ceretti-instagram-story-sp925367/ + http://www.starstyle.com/wp-content/uploads/vittoria-ceretti/925367.jpg + + 0.7 + + + http://www.starstyle.com/olivia-rodrigo-mexico-sp812576/ + http://www.starstyle.com/wp-content/uploads/olivia-rodrigo/812576.jpg + + 0.7 + + + http://www.starstyle.com/chase-infiniti-miami-sp924145/ + http://www.starstyle.com/wp-content/uploads/chase-infiniti/924145.jpg + + 0.7 + + + http://www.starstyle.com/mckenna-grace-miami-sp925102/ + http://www.starstyle.com/wp-content/uploads/mckenna-grace/925102.jpg + + 0.7 + + + http://www.starstyle.com/mckenna-grace-miami-grand-prix-race-sp925357/ + http://www.starstyle.com/wp-content/uploads/mckenna-grace/925357.jpg + + 0.7 + + + http://www.starstyle.com/emma-roberts-tell-lies-premiere-sp601324/ + http://www.starstyle.com/wp-content/uploads/emma-roberts/601324.jpg + + 0.7 + + + http://www.starstyle.com/taylor-swift-bliss-magazine-noevemrb-sp905870/ + http://www.starstyle.com/wp-content/uploads/taylor-swift/905870.jpg + + 0.7 + + + http://www.starstyle.com/meghan-markle-canada-event-sp925313/ + http://www.starstyle.com/wp-content/uploads/meghan-markle/925313.jpg + + 0.7 + + + http://www.starstyle.com/meghan-markle-pearson-international-airport-sp925301/ + http://www.starstyle.com/wp-content/uploads/meghan-markle/925301.jpg + + 0.7 + + + http://www.starstyle.com/karlie-kloss-york-city-sp925305/ + http://www.starstyle.com/wp-content/uploads/karlie-kloss/925305.jpg + + 0.7 + + + http://www.starstyle.com/meghan-markle-ballet-gala-toronto-sp925310/ + http://www.starstyle.com/wp-content/uploads/meghan-markle/925310.jpg + + 0.7 + + + http://www.starstyle.com/olivia-rodrigo-saturday-night-live-sp924999/ + http://www.starstyle.com/wp-content/uploads/olivia-rodrigo/924999.jpg + + 0.7 + + + http://www.starstyle.com/lisa-york-city-sp925147/ + http://www.starstyle.com/wp-content/uploads/lisa/925147.jpg + + 0.7 + + + http://www.starstyle.com/rose-puma-sp925292/ + http://www.starstyle.com/wp-content/uploads/rose/925292.jpg + + 0.7 + + + http://www.starstyle.com/karlie-kloss-york-city-sp925212/ + http://www.starstyle.com/wp-content/uploads/karlie-kloss/925212.jpg + + 0.7 + + + http://www.starstyle.com/lily-allen-york-sp925215/ + http://www.starstyle.com/wp-content/uploads/lily-allen/925215.jpg + + 0.7 + + + http://www.starstyle.com/lily-allen-chateau-marmont-sp925223/ + http://www.starstyle.com/wp-content/uploads/lily-allen/925223.jpg + + 0.7 + + + http://www.starstyle.com/lily-allen-leaving-noel-coward-theatre-sp925227/ + http://www.starstyle.com/wp-content/uploads/lily-allen/925227.jpg + + 0.7 + + + http://www.starstyle.com/lily-allen-noel-coward-theatre-sp925231/ + http://www.starstyle.com/wp-content/uploads/lily-allen/925231.jpg + + 0.7 + + + http://www.starstyle.com/lily-allen-noel-coward-theatre-sp925234/ + http://www.starstyle.com/wp-content/uploads/lily-allen/925234.jpg + + 0.7 + + + http://www.starstyle.com/lily-allen-through-lens-tribeca-chanel-womens-filmmaker-program-sp925243/ + http://www.starstyle.com/wp-content/uploads/lily-allen/925243.jpg + + 0.7 + + + http://www.starstyle.com/lily-allen-york-sp925248/ + http://www.starstyle.com/wp-content/uploads/lily-allen/925248.jpg + + 0.7 + + + http://www.starstyle.com/lily-allen-london-sp925253/ + http://www.starstyle.com/wp-content/uploads/lily-allen/925253.jpg + + 0.7 + + + http://www.starstyle.com/lily-allen-york-sp925264/ + http://www.starstyle.com/wp-content/uploads/lily-allen/925264.jpg + + 0.7 + + + http://www.starstyle.com/lily-allen-york-sp925267/ + http://www.starstyle.com/wp-content/uploads/lily-allen/925267.jpg + + 0.7 + + + http://www.starstyle.com/lily-allen-instagram-sp925262/ + http://www.starstyle.com/wp-content/uploads/lily-allen/925262.jpg + + 0.7 + + + http://www.starstyle.com/lily-allen-york-sp925209/ + http://www.starstyle.com/wp-content/uploads/lily-allen/925209.jpg + + 0.7 + + + http://www.starstyle.com/lily-allen-london-sp925288/ + http://www.starstyle.com/wp-content/uploads/lily-allen/925288.jpg + + 0.7 + + + http://www.starstyle.com/beyonce-knowles-tonight-show-with-leno-sp851641/ + http://www.starstyle.com/wp-content/uploads/beyonce-knowles/851641.jpg + + 0.7 + + + http://www.starstyle.com/meghan-markle-suitsaeseason-premiere-sp335234/ + http://www.starstyle.com/wp-content/uploads/meghan-markle/335234.jpg + + 0.7 + + + http://www.starstyle.com/meghan-markle-visiting-rwanda-with-world-vision-sp925232/ + http://www.starstyle.com/wp-content/uploads/meghan-markle/925232.jpg + + 0.7 + + + http://www.starstyle.com/meghan-markle-dave-interview-sp925241/ + http://www.starstyle.com/wp-content/uploads/meghan-markle/925241.jpg + + 0.7 + + + http://www.starstyle.com/meghan-markle-instagram-gusto-sp925247/ + http://www.starstyle.com/wp-content/uploads/meghan-markle/925247.jpg + + 0.7 + + + http://www.starstyle.com/meghan-markle-instagram-meghanmarkle-sp925261/ + http://www.starstyle.com/wp-content/uploads/meghan-markle/925261.jpg + + 0.7 + + + http://www.starstyle.com/meghan-markle-visiting-rwanda-with-world-vision-sp925235/ + http://www.starstyle.com/wp-content/uploads/meghan-markle/925235.jpg + + 0.7 + + + http://www.starstyle.com/meghan-markle-sp925274/ + http://www.starstyle.com/wp-content/uploads/meghan-markle/925274.jpg + + 0.7 + + + http://www.starstyle.com/meghan-markle-instagram-meghanmarkle-sp925280/ + http://www.starstyle.com/wp-content/uploads/meghan-markle/925280.jpg + + 0.7 + + + http://www.starstyle.com/meghan-markle-instagram-meghanmarkle-sp925291/ + http://www.starstyle.com/wp-content/uploads/meghan-markle/925291.jpg + + 0.7 + + + http://www.starstyle.com/meghan-markle-instagram-sp925293/ + http://www.starstyle.com/wp-content/uploads/meghan-markle/925293.jpg + + 0.7 + + + http://www.starstyle.com/jessica-alba-instagram-story-sp925290/ + http://www.starstyle.com/wp-content/uploads/jessica-alba/925290.jpg + + 0.7 + + + http://www.starstyle.com/meghan-markle-instagram-sp810370/ + http://www.starstyle.com/wp-content/uploads/meghan-markle/810370.jpg + + 0.7 + + + http://www.starstyle.com/meghan-markle-suits-sp805682/ + http://www.starstyle.com/wp-content/uploads/meghan-markle/805682.jpg + + 0.7 + + + http://www.starstyle.com/olivia-rodrigo-saturday-night-live-sp924980/ + http://www.starstyle.com/wp-content/uploads/olivia-rodrigo/924980.jpg + + 0.7 + + + http://www.starstyle.com/christina-aguilera-mountain-closing-concert-ischgl-austria-sp925260/ + http://www.starstyle.com/wp-content/uploads/christina-aguilera/925260.jpg + + 0.7 + + + http://www.starstyle.com/olivia-rodrigo-saturday-night-live-sp925251/ + http://www.starstyle.com/wp-content/uploads/olivia-rodrigo/925251.jpg + + 0.7 + + + http://www.starstyle.com/olivia-rodrigo-saturday-night-live-sp925001/ + http://www.starstyle.com/wp-content/uploads/olivia-rodrigo/925001.jpg + + 0.7 + + + http://www.starstyle.com/miranda-kerr-instagram-sp925237/ + http://www.starstyle.com/wp-content/uploads/miranda-kerr/925237.jpg + + 0.7 + + + http://www.starstyle.com/stassi-schroeder-instagram-sp925221/ + http://www.starstyle.com/wp-content/uploads/stassi-schroeder/925221.jpg + + 0.7 + + + http://www.starstyle.com/stassi-schroeder-instagram-sp925216/ + http://www.starstyle.com/wp-content/uploads/stassi-schroeder/925216.jpg + + 0.7 + + + http://www.starstyle.com/jennie-york-city-sp925011/ + http://www.starstyle.com/wp-content/uploads/jennie-kim/925011.jpg + + 0.7 + + + http://www.starstyle.com/olivia-rodrigo-saturday-night-live-sp924986/ + http://www.starstyle.com/wp-content/uploads/olivia-rodrigo/924986.jpg + + 0.7 + + + http://www.starstyle.com/olivia-rodrigo-saturday-night-live-sp925002/ + http://www.starstyle.com/wp-content/uploads/olivia-rodrigo/925002.jpg + + 0.7 + + + http://www.starstyle.com/jennifer-lawrence-york-city-sp923689/ + http://www.starstyle.com/wp-content/uploads/jennifer-lawrence/923689.jpg + + 0.7 + + + http://www.starstyle.com/jennifer-lawrence-york-city-sp923684/ + http://www.starstyle.com/wp-content/uploads/jennifer-lawrence/923684.jpg + + 0.7 + + + http://www.starstyle.com/lily-allen-york-sp925195/ + http://www.starstyle.com/wp-content/uploads/lily-allen/925195.jpg + + 0.7 + + + http://www.starstyle.com/jennie-york-city-sp924390/ + http://www.starstyle.com/wp-content/uploads/jennie-kim/924390.jpg + + 0.7 + + + http://www.starstyle.com/teyana-taylor-vogue-gala-party-sp924381/ + http://www.starstyle.com/wp-content/uploads/teyana-taylor/924381.jpg + + 0.7 + + + http://www.starstyle.com/olivia-rodrigo-saturday-night-live-sp924985/ + http://www.starstyle.com/wp-content/uploads/olivia-rodrigo/924985.jpg + + 0.7 + + + http://www.starstyle.com/olivia-rodrigo-saturday-night-live-sp924993/ + http://www.starstyle.com/wp-content/uploads/olivia-rodrigo/924993.jpg + + 0.7 + + + http://www.starstyle.com/simone-ashley-instagram-sp924264/ + http://www.starstyle.com/wp-content/uploads/simone-ashley/924264.jpg + + 0.7 + + + http://www.starstyle.com/saldana-lancome-longevity-dinner-aspire-sp924290/ + http://www.starstyle.com/wp-content/uploads/zoe-saldana/924290.jpg + + 0.7 + + + http://www.starstyle.com/aimee-wood-saturday-night-live-sp924430/ + http://www.starstyle.com/wp-content/uploads/aimee-lou-wood/924430.jpg + + 0.7 + + + http://www.starstyle.com/zendaya-coleman-event-sp923291/ + http://www.starstyle.com/wp-content/uploads/zendaya-coleman/923291.jpg + + 0.7 + + + http://www.starstyle.com/natasha-lyonne-cultured-magazine-valentino-valentino-beauty-celebrate-third-annual-cult-sp923801/ + http://www.starstyle.com/wp-content/uploads/natasha-lyonne/923801.jpg + + 0.7 + + + http://www.starstyle.com/york-city-sp924987/ + http://www.starstyle.com/wp-content/uploads/nicole-kidman/924987.jpg + + 0.7 + + + http://www.starstyle.com/keke-palmer-cultured-magazine-valentino-valentino-beauty-celebrate-third-annual-cult-sp923799/ + http://www.starstyle.com/wp-content/uploads/keke-palmer/923799.jpg + + 0.7 + + + http://www.starstyle.com/naomi-watts-cultured-magazine-valentino-valentino-beauty-celebrate-third-annual-cult-sp923800/ + http://www.starstyle.com/wp-content/uploads/naomi-watts/923800.jpg + + 0.7 + + + http://www.starstyle.com/kravitz-york-city-sp925149/ + http://www.starstyle.com/wp-content/uploads/zoe-kravitz/925149.jpg + + 0.7 + + + http://www.starstyle.com/naomi-watts-york-city-sp925028/ + http://www.starstyle.com/wp-content/uploads/naomi-watts/925028.jpg + + 0.7 + + + http://www.starstyle.com/lila-grace-moss-vogue-gala-party-sp924382/ + http://www.starstyle.com/wp-content/uploads/lila-grace-moss/924382.jpg + + 0.7 + + + http://www.starstyle.com/gracie-abrams-york-sp925173/ + http://www.starstyle.com/wp-content/uploads/gracie-abrams/925173.jpg + + 0.7 + + + http://www.starstyle.com/hunter-schafer-york-city-sp925025/ + http://www.starstyle.com/wp-content/uploads/hunter-schafer/925025.jpg + + 0.7 + + + http://www.starstyle.com/gabrielle-union-york-city-sp925029/ + http://www.starstyle.com/wp-content/uploads/gabrielle-union/925029.jpg + + 0.7 + + + http://www.starstyle.com/natasha-lyonne-york-city-sp925027/ + http://www.starstyle.com/wp-content/uploads/natasha-lyonne/925027.jpg + + 0.7 + + + http://www.starstyle.com/olivia-dean-loving-live-london-night-sp924619/ + http://www.starstyle.com/wp-content/uploads/olivia-dean/924619.jpg + + 0.7 + + + http://www.starstyle.com/olivia-dean-loving-live-london-night-sp924058/ + http://www.starstyle.com/wp-content/uploads/olivia-dean/924058.jpg + + 0.7 + + + http://www.starstyle.com/york-city-sp924317/ + http://www.starstyle.com/wp-content/uploads/irina-shayk/924317.jpg + + 0.7 + + + http://www.starstyle.com/york-city-sp924978/ + http://www.starstyle.com/wp-content/uploads/laura-harrier/924978.jpg + + 0.7 + + + http://www.starstyle.com/meghan-markle-instagram-takeover-with-people-style-sp810375/ + http://www.starstyle.com/wp-content/uploads/meghan-markle/810375.jpg + + 0.7 + + + http://www.starstyle.com/meghan-markle-universal-cable-entertainment-upfronts-sp925168/ + http://www.starstyle.com/wp-content/uploads/meghan-markle/925168.jpg + + 0.7 + + + http://www.starstyle.com/vittoria-ceretti-york-city-sp925026/ + http://www.starstyle.com/wp-content/uploads/vittoria-ceretti/925026.jpg + + 0.7 + + + http://www.starstyle.com/meghan-markle-rising-stars-producers-ball-sp805841/ + http://www.starstyle.com/wp-content/uploads/meghan-markle/805841.jpg + + 0.7 + + + http://www.starstyle.com/meghan-markle-year-party-sp805851/ + http://www.starstyle.com/wp-content/uploads/meghan-markle/805851.jpg + + 0.7 + + + http://www.starstyle.com/meghan-markle-directv-beach-bowl-sp925138/ + http://www.starstyle.com/wp-content/uploads/meghan-markle/925138.jpg + + 0.7 + + + http://www.starstyle.com/meghan-markle-marchesa-voyage-shopstyle-preview-sp925145/ + http://www.starstyle.com/wp-content/uploads/meghan-markle/925145.jpg + + 0.7 + + + http://www.starstyle.com/meghan-markle-relais-chateaux-anniversary-guest-chef-dinner-launch-sp925148/ + http://www.starstyle.com/wp-content/uploads/meghan-markle/925148.jpg + + 0.7 + + + http://www.starstyle.com/meghan-markle-basel-miami-sp925150/ + http://www.starstyle.com/wp-content/uploads/meghan-markle/925150.jpg + + 0.7 + + + http://www.starstyle.com/elsa-hosk-instagram-sp925164/ + http://www.starstyle.com/wp-content/uploads/elsa-hosk/925164.jpg + + 0.7 + + + http://www.starstyle.com/jessica-alba-miami-sp925160/ + http://www.starstyle.com/wp-content/uploads/jessica-alba/925160.jpg + + 0.7 + + + http://www.starstyle.com/jessica-alba-miami-sp924792/ + http://www.starstyle.com/wp-content/uploads/jessica-alba/924792.jpg + + 0.7 + + + http://www.starstyle.com/chantel-jeffries-instagram-story-sp925158/ + http://www.starstyle.com/wp-content/uploads/chantel-jeffries/925158.jpg + + 0.7 + + + http://www.starstyle.com/gabi-demartino-instagram-sp925154/ + http://www.starstyle.com/wp-content/uploads/gabi-demartino/925154.jpg + + 0.7 + + + http://www.starstyle.com/olivia-rodrigo-saturday-night-live-sp924983/ + http://www.starstyle.com/wp-content/uploads/olivia-rodrigo/924983.jpg + + 0.7 + + + http://www.starstyle.com/tate-mcrae-instagram-sp924571/ + http://www.starstyle.com/wp-content/uploads/tate-mcrae/924571.jpg + + 0.7 + + + http://www.starstyle.com/olivia-dean-loving-live-london-night-sp923604/ + http://www.starstyle.com/wp-content/uploads/olivia-dean/923604.jpg + + 0.7 + + + http://www.starstyle.com/emma-roberts-instagram-story-sp925134/ + http://www.starstyle.com/wp-content/uploads/emma-roberts/925134.jpg + + 0.7 + + + http://www.starstyle.com/joey-king-instagram-sp925128/ + http://www.starstyle.com/wp-content/uploads/joey-king/925128.jpg + + 0.7 + + + http://www.starstyle.com/anne-hathaway-erin-walsh-book-party-sp925129/ + http://www.starstyle.com/wp-content/uploads/anne-hathaway/925129.jpg + + 0.7 + + + http://www.starstyle.com/joey-king-feliz-sp925125/ + http://www.starstyle.com/wp-content/uploads/joey-king/925125.jpg + + 0.7 + + + http://www.starstyle.com/lily-james-london-sp925122/ + http://www.starstyle.com/wp-content/uploads/lily-james/925122.jpg + + 0.7 + + + http://www.starstyle.com/meghan-markle-muchmusic-video-awards-sp925116/ + http://www.starstyle.com/wp-content/uploads/meghan-markle/925116.jpg + + 0.7 + + + http://www.starstyle.com/teyana-taylor-revlon-super-lustrous-lipstick-campaign-sp925084/ + http://www.starstyle.com/wp-content/uploads/teyana-taylor/925084.jpg + + 0.7 + + + http://www.starstyle.com/teyana-taylor-revlon-super-lustrous-lipstick-campaign-sp925095/ + http://www.starstyle.com/wp-content/uploads/teyana-taylor/925095.jpg + + 0.7 + + + http://www.starstyle.com/teyana-taylor-revlon-super-lustrous-lipstick-campaign-sp925083/ + http://www.starstyle.com/wp-content/uploads/teyana-taylor/925083.jpg + + 0.7 + + + http://www.starstyle.com/aimee-wood-saturday-night-live-sp925109/ + http://www.starstyle.com/wp-content/uploads/aimee-lou-wood/925109.jpg + + 0.7 + + + http://www.starstyle.com/aimee-wood-saturday-night-live-promo-sp923649/ + http://www.starstyle.com/wp-content/uploads/aimee-lou-wood/923649.jpg + + 0.7 + + + http://www.starstyle.com/alix-earle-tiktok-sp925107/ + http://www.starstyle.com/wp-content/uploads/alix-earle/925107.jpg + + 0.7 + + + http://www.starstyle.com/meghan-markle-allen-schwartz-golden-globe-suites-sp925060/ + http://www.starstyle.com/wp-content/uploads/meghan-markle/925060.jpg + + 0.7 + + + http://www.starstyle.com/meghan-markle-entertainment-tonight-emmy-party-sp925065/ + http://www.starstyle.com/wp-content/uploads/meghan-markle/925065.jpg + + 0.7 + + + http://www.starstyle.com/meghan-markle-summer-star-party-sp925067/ + http://www.starstyle.com/wp-content/uploads/meghan-markle/925067.jpg + + 0.7 + + + http://www.starstyle.com/meghan-markle-networks-moths-storytelling-tour-more-perfect-union-stories-prejudice-power-sp925079/ + http://www.starstyle.com/wp-content/uploads/meghan-markle/925079.jpg + + 0.7 + + + http://www.starstyle.com/elsa-hosk-instagram-story-sp925080/ + http://www.starstyle.com/wp-content/uploads/elsa-hosk/925080.jpg + + 0.7 + + + http://www.starstyle.com/doja-sp925073/ + http://www.starstyle.com/wp-content/uploads/doja-cat/925073.jpg + + 0.7 + + + http://www.starstyle.com/kendall-jenner-gala-diner-sp925010/ + http://www.starstyle.com/wp-content/uploads/kendall-jenner/925010.jpg + + 0.7 + + + http://www.starstyle.com/kendall-jenner-york-city-sp924573/ + http://www.starstyle.com/wp-content/uploads/kendall-jenner/924573.jpg + + 0.7 + + + http://www.starstyle.com/camila-cabello-angeles-sp925070/ + http://www.starstyle.com/wp-content/uploads/camila-cabello/925070.jpg + + 0.7 + + + http://www.starstyle.com/princess-beatrice-london-sp925044/ + http://www.starstyle.com/wp-content/uploads/princess-beatrice/925044.jpg + + 0.7 + + + http://www.starstyle.com/rose-incheon-airport-sp924691/ + http://www.starstyle.com/wp-content/uploads/rose/924691.jpg + + 0.7 + + + http://www.starstyle.com/rose-incheon-airport-sp921766/ + http://www.starstyle.com/wp-content/uploads/rose/921766.jpg + + 0.7 + + + http://www.starstyle.com/lipa-instagram-sp924546/ + http://www.starstyle.com/wp-content/uploads/dua-lipa/924546.jpg + + 0.7 + + + http://www.starstyle.com/lipa-instagram-sp923867/ + http://www.starstyle.com/wp-content/uploads/dua-lipa/923867.jpg + + 0.7 + + + http://www.starstyle.com/laura-harrier-vogue-gala-party-sp924383/ + http://www.starstyle.com/wp-content/uploads/laura-harrier/924383.jpg + + 0.7 + + + http://www.starstyle.com/alix-earle-tiktok-sp924186/ + http://www.starstyle.com/wp-content/uploads/alix-earle/924186.jpg + + 0.7 + + + http://www.starstyle.com/taeyeon-amazing-saturday-sp925007/ + http://www.starstyle.com/wp-content/uploads/taeyeon/925007.jpg + + 0.7 + + + http://www.starstyle.com/olivia-rodrigo-saturday-night-live-sp924998/ + http://www.starstyle.com/wp-content/uploads/olivia-rodrigo/924998.jpg + + 0.7 + + + http://www.starstyle.com/olivia-rodrigo-saturday-night-live-sp924982/ + http://www.starstyle.com/wp-content/uploads/olivia-rodrigo/924982.jpg + + 0.7 + + \ No newline at end of file diff --git a/packages/ai-server/tests/unit/services/raw_posts/test_starstyle_adapter.py b/packages/ai-server/tests/unit/services/raw_posts/test_starstyle_adapter.py new file mode 100644 index 00000000..f21f31bf --- /dev/null +++ b/packages/ai-server/tests/unit/services/raw_posts/test_starstyle_adapter.py @@ -0,0 +1,155 @@ +"""Unit tests for ``StarStyleAdapter`` (#466). + +Pure helper (`_post_to_raw_media`, `_to_site_item_dict`) 만 검증. +HTTP path 는 fixture HTML 을 ``parse_post`` 통과시킨 결과로 우회 — adapter +자체는 thin wrapper 이므로 별도 mock 불필요. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from src.services.raw_posts.adapters._starstyle_html import ( + StarStyleItem, + parse_post, +) +from src.services.raw_posts.adapters.starstyle import ( + _post_to_raw_media, + _to_site_item_dict, +) +from src.services.raw_posts.processors._prelabeled import from_platform_metadata + + +_FIXTURES = Path(__file__).parent / "fixtures" + + +def _load(name: str) -> str: + return (_FIXTURES / name).read_text() + + +class TestToSiteItemDict: + def test_full_item(self): + item = StarStyleItem( + product_id="123", + title="Doen Amalia Printed Cotton Top", + retailer_url="https://www.mytheresa.com/p1155099", + affiliate_url="https://go.skimresources.com/?url=...", + ) + d = _to_site_item_dict(item) + assert d == { + "site_item_id": "123", + "brand": None, + "product": "Doen Amalia Printed Cotton Top", + "price": None, + "category": None, + "thumbnail_url": None, + "original_url": "https://www.mytheresa.com/p1155099", + "url_candidates": [ + { + "url": "https://www.mytheresa.com/p1155099", + "source": "skimresources", + } + ], + } + + def test_no_retailer_url_drops_candidates(self): + item = StarStyleItem( + product_id="1", title="X", retailer_url=None, affiliate_url="x" + ) + d = _to_site_item_dict(item) + assert d is not None + assert d["original_url"] is None + assert d["url_candidates"] is None + + def test_drop_when_empty_title(self): + item = StarStyleItem( + product_id="1", title=" ", retailer_url=None, affiliate_url="" + ) + assert _to_site_item_dict(item) is None + + def test_drop_when_no_product_id(self): + item = StarStyleItem( + product_id="", title="X", retailer_url=None, affiliate_url="" + ) + assert _to_site_item_dict(item) is None + + +class TestPostToRawMedia: + def test_taylor_hill_fixture_end_to_end(self): + post = parse_post(_load("starstyle_post_taylor_hill.html")) + assert post is not None + media = _post_to_raw_media(post) + assert media is not None + assert media.external_id == "926700" + assert media.external_url.endswith("/taylor-hill-instagram-story-sp926700/") + assert media.image_url.endswith("/926700.jpg") + assert media.author_name == "Taylor Hill" + # bypass_composite_filter — wots 와 동일하게 Gemma gate 우회 (#466). + assert media.bypass_composite_filter is True + + meta = media.platform_metadata + assert meta["url"] == media.image_url + assert meta["post_slug"].startswith("taylor-hill-instagram-story") + assert meta["celebrity_slug"] == "taylor-hill" + labels = meta["site_labels"] + assert labels["artist_name_en"] == "Taylor Hill" + items = labels["items"] + assert len(items) >= 5 + first = items[0] + assert first["brand"] is None # starstyle 보수 — verify 단계에서 분리 + assert first["product"] + assert first["price"] is None + assert first["category"] is None + assert first["thumbnail_url"] is None # per-item 사진 없음 — bbox crop fallback + assert first["original_url"] + assert first["original_url"].startswith("http") + assert first["url_candidates"] == [ + {"url": first["original_url"], "source": "skimresources"} + ] + + def test_prelabeled_round_trip(self): + """``platform_metadata.site_labels`` 가 ``PrelabeledData`` 로 빌드되는지. + + starstyle 의 ``item_thumbnail_external_urls`` 는 모두 None 이라 + items_thumbnail processor 가 fallback path (bbox crop) 를 타게 된다. + """ + post = parse_post(_load("starstyle_post_olivia_culpo.html")) + media = _post_to_raw_media(post) + prelabeled = from_platform_metadata(media.platform_metadata) + assert prelabeled is not None + assert prelabeled.artist_name_en == "Olivia Culpo" + assert len(prelabeled.items) >= 1 + # 모든 item 의 thumbnail external URL 이 None — fallback 검증. + assert all(t is None for t in prelabeled.item_thumbnail_external_urls) + + def test_missing_post_id_returns_none(self): + from src.services.raw_posts.adapters._starstyle_html import StarStylePost + + post = StarStylePost( + post_id="", + slug="x", + url="http://x/", + image_url="http://x.jpg", + caption="", + celebrity_name=None, + celebrity_slug=None, + published_at=None, + items=[], + ) + assert _post_to_raw_media(post) is None + + +class TestAdapterRegistration: + def test_registry_includes_starstyle(self): + from types import SimpleNamespace + from src.services.raw_posts.adapters import build_default_adapters + + env = SimpleNamespace(STARSTYLE_PAGE_DELAY_MS=1500, STARSTYLE_USER_AGENT=None) + registry = build_default_adapters(env) + assert "starstyle" in registry + adapter = registry["starstyle"] + assert adapter.platform == "starstyle" + assert adapter.discovery_target == "raw_posts" + assert adapter.global_source_identifier == "global" diff --git a/packages/ai-server/tests/unit/services/raw_posts/test_starstyle_html.py b/packages/ai-server/tests/unit/services/raw_posts/test_starstyle_html.py new file mode 100644 index 00000000..73765e30 --- /dev/null +++ b/packages/ai-server/tests/unit/services/raw_posts/test_starstyle_html.py @@ -0,0 +1,137 @@ +"""Unit tests for ``_starstyle_html`` (#466). + +Real fixture HTML / sitemap XML 로 pure helper 들 검증. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from src.services.raw_posts.adapters._starstyle_html import ( + decode_skimresources, + parse_post, + parse_sitemap, +) + + +_FIXTURES = Path(__file__).parent / "fixtures" + + +def _load(name: str) -> str: + return (_FIXTURES / name).read_text() + + +# ------------------------------------------------------- decode_skimresources + + +class TestDecodeSkimresources: + def test_skim_wrapper(self): + href = ( + "https://go.skimresources.com/?id=86841X&" + "url=https%3A%2F%2Fwww.mytheresa.com%2Fus%2Fen%2Fp01155099" + "&xcust=926701" + ) + assert decode_skimresources(href) == "https://www.mytheresa.com/us/en/p01155099" + + def test_raw_retailer_passthrough(self): + href = "https://farfetch.com/shopping/women/dress.aspx" + assert decode_skimresources(href) == href + + def test_skim_missing_url_param(self): + href = "https://go.skimresources.com/?id=foo&xcust=1" + assert decode_skimresources(href) is None + + def test_invalid_decoded_target(self): + href = "https://go.skimresources.com/?url=ftp%3A%2F%2Fbad.example" + assert decode_skimresources(href) is None + + def test_empty(self): + assert decode_skimresources("") is None + + +# -------------------------------------------------------- parse_sitemap + + +class TestParseSitemap: + def test_real_fixture_returns_post_urls(self): + urls = parse_sitemap(_load("starstyle_sitemap.xml")) + # ~500 posts in the live sitemap. + assert len(urls) >= 100 + assert all(u.startswith("http://www.starstyle.com/") for u in urls) + assert all(u.endswith("/") for u in urls) + # newest first preserved (no sort) — first entry should be a post URL. + assert "/sitemap" not in urls[0] + + def test_malformed_returns_empty(self): + assert parse_sitemap("= 5 + first = post.items[0] + assert first.product_id and first.product_id.isdigit() + assert first.title + # affiliate skim URL → decoded retailer. + assert first.retailer_url and first.retailer_url.startswith("http") + assert "go.skimresources.com" in first.affiliate_url + + def test_olivia_culpo(self): + post = parse_post(_load("starstyle_post_olivia_culpo.html")) + assert post is not None + assert post.post_id == "926695" + assert post.celebrity_name == "Olivia Culpo" + assert post.celebrity_slug == "olivia-culpo" + assert len(post.items) >= 1 + + def test_cate_blanchett(self): + post = parse_post(_load("starstyle_post_cate_blanchett.html")) + assert post is not None + assert post.post_id == "926684" + assert post.celebrity_name == "Cate Blanchett" + assert post.celebrity_slug == "cate-blanchett" + + def test_empty_html(self): + assert parse_post("") is None + + def test_missing_canonical_returns_none(self): + assert ( + parse_post( + "" + ) + is None + ) + + @pytest.mark.parametrize( + "url,expected", + [ + ("http://www.starstyle.com/foo-bar-sp123/", "123"), + ("http://www.starstyle.com/foo-bar-sp123", "123"), + ("http://www.starstyle.com/no-id/", None), + ], + ) + def test_post_id_extraction(self, url, expected): + from src.services.raw_posts.adapters._starstyle_html import ( + _extract_post_id, + ) + + assert _extract_post_id(url) == expected diff --git a/packages/api-server/src/domains/raw_posts/service.rs b/packages/api-server/src/domains/raw_posts/service.rs index adb78f4a..9d34c546 100644 --- a/packages/api-server/src/domains/raw_posts/service.rs +++ b/packages/api-server/src/domains/raw_posts/service.rs @@ -409,8 +409,10 @@ fn parse_source_identifier(platform: &str, raw: &str) -> Result<(String, String) } "instagram" => parse_instagram_source(raw), "whatsonthestar" => parse_whatsonthestar_source(raw), + "starstyle" => parse_starstyle_source(raw), other => Err(format!( - "unsupported platform: {other} (supported: pinterest, instagram, whatsonthestar)" + "unsupported platform: {other} \ + (supported: pinterest, instagram, whatsonthestar, starstyle)" )), } } @@ -450,6 +452,35 @@ fn parse_whatsonthestar_source(raw: &str) -> Result<(String, String), String> { )) } +/// StarStyle (#466) — post URL 또는 slug → (`post`, slug). +/// +/// 허용 입력: +/// - `http://www.starstyle.com/hunter-schafer-gala-sp819444/` +/// - `https://www.starstyle.com/hunter-schafer-gala-sp819444` +/// - `hunter-schafer-gala-sp819444` (slug 그대로) +fn parse_starstyle_source(raw: &str) -> Result<(String, String), String> { + let trimmed = raw.trim().trim_end_matches('/'); + if trimmed.is_empty() { + return Err("empty starstyle identifier".to_string()); + } + // 1) 포스트 URL → slug 추출. ``-sp{ID}`` suffix 가 붙은 마지막 path segment. + if let Some(captures) = regex_captures(r"starstyle\.com/([a-zA-Z0-9-]+-sp\d+)", trimmed) { + return Ok(("post".to_string(), captures[1].to_string())); + } + // 2) slug 직접 입력 — alphanum + hyphen 만, 끝이 ``-sp{digits}`` 형태여야 함. + let valid = trimmed + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-') + && regex_captures(r"-sp\d+$", trimmed).is_some(); + if valid { + return Ok(("post".to_string(), trimmed.to_string())); + } + Err(format!( + "unrecognized starstyle identifier: {raw:?} \ + (expected post URL or slug-sp{{id}} format like 'hunter-schafer-gala-sp819444')" + )) +} + fn regex_captures<'a>(pattern: &str, haystack: &'a str) -> Option> { regex::Regex::new(pattern) .ok() @@ -1579,6 +1610,32 @@ mod tests { assert!(err.contains("unrecognized whatsonthestar")); } + #[test] + fn parse_source_identifier_starstyle_post_url() { + let (t, id) = super::parse_source_identifier( + "starstyle", + "http://www.starstyle.com/hunter-schafer-gala-sp819444/", + ) + .unwrap(); + assert_eq!(t, "post"); + assert_eq!(id, "hunter-schafer-gala-sp819444"); + } + + #[test] + fn parse_source_identifier_starstyle_slug_direct() { + let (t, id) = + super::parse_source_identifier("starstyle", "taylor-hill-instagram-story-sp926700") + .unwrap(); + assert_eq!(t, "post"); + assert_eq!(id, "taylor-hill-instagram-story-sp926700"); + } + + #[test] + fn parse_source_identifier_starstyle_rejects_no_sp_suffix() { + let err = super::parse_source_identifier("starstyle", "hunter-schafer-gala").unwrap_err(); + assert!(err.contains("unrecognized starstyle")); + } + #[test] fn parse_source_identifier_instagram_at_handle() { let (_, id) = super::parse_source_identifier("instagram", "@Foo.Bar").unwrap(); diff --git a/packages/web/app/admin/raw-post-sources/page.tsx b/packages/web/app/admin/raw-post-sources/page.tsx index 828c7efa..d7bbca43 100644 --- a/packages/web/app/admin/raw-post-sources/page.tsx +++ b/packages/web/app/admin/raw-post-sources/page.tsx @@ -48,6 +48,7 @@ const PLATFORM_TABS: { { value: "pinterest", label: "Pinterest" }, { value: "instagram", label: "Instagram" }, { value: "whatsonthestar", label: "WhatsOnTheStar" }, + { value: "starstyle", label: "StarStyle" }, ]; const SELECT_CLASS = @@ -94,6 +95,11 @@ function buildSourceUrl(row: RawPostSource): string | null { if (!slug) return null; return `https://whatsonthestar.com/outfit/${slug}`; } + if (row.platform === "starstyle" && row.source_type === "post") { + const slug = row.source_identifier.replace(/^\/+|\/+$/g, "").trim(); + if (!slug) return null; + return `http://www.starstyle.com/${slug}/`; + } return null; } @@ -535,12 +541,20 @@ function CreateSourceModal({ helperText: "outfit URL 의 마지막 path segment (slug-{id}) 를 그대로 입력. site labels (brand/price/category/artist) 가 자동 추출됩니다.", } - : { - label: "Pin URL 또는 Pin ID", - placeholder: "https://www.pinterest.com/pin/925067579727148171/", - helperText: - "Pin URL 통째로 또는 숫자 pin ID 만 입력. 서버가 자동 정규화.", - }; + : platform === "starstyle" + ? { + label: "Post slug 또는 post URL", + placeholder: + "예: hunter-schafer-gala-sp819444 또는 http://www.starstyle.com/hunter-schafer-gala-sp819444/", + helperText: + "post URL 의 path segment (slug-sp{id}) 를 그대로 입력. site labels (artist/items/retailer URL) 가 자동 추출됩니다.", + } + : { + label: "Pin URL 또는 Pin ID", + placeholder: "https://www.pinterest.com/pin/925067579727148171/", + helperText: + "Pin URL 통째로 또는 숫자 pin ID 만 입력. 서버가 자동 정규화.", + }; return (
e.platform === platform); const showDiscovery = - platform === "pinterest" || platform === "whatsonthestar"; + platform === "pinterest" || + platform === "whatsonthestar" || + platform === "starstyle"; if (isLoading || !entry) { return ( diff --git a/supabase-assets/migrations/20260507120000_pipeline_settings_starstyle.sql b/supabase-assets/migrations/20260507120000_pipeline_settings_starstyle.sql new file mode 100644 index 00000000..9d677b3e --- /dev/null +++ b/supabase-assets/migrations/20260507120000_pipeline_settings_starstyle.sql @@ -0,0 +1,21 @@ +-- Seed `public.pipeline_settings` for the starstyle platform (#466). +-- +-- 모든 cycle 토글은 OFF 로 시작 — admin UI 에서 명시적으로 켜야 동작. +-- 머지 시점에 production scheduler 가 갑자기 fetching 을 시작하지 않도록 +-- 의도적으로 disabled. 백필은 별도 스크립트 실행. + +INSERT INTO public.pipeline_settings ( + platform, + discovery_enabled, discovery_cycle_seconds, discovery_batch_size, not_used_cap, + expansion_enabled, expansion_cycle_seconds, expansion_batch_size, + processing_enabled, processing_cycle_seconds, processing_batch_size, + processing_recheck_vision +) +VALUES ( + 'starstyle', + false, 600, 5, 1000, -- discovery: sitemap polling 10분, 5개씩. + false, 60, 10, -- expansion: post 1개씩 fetch (사용 안 함). + false, 60, 10, -- processing: hero/spots/thumbnail 처리. + false -- recheck_vision: 라벨 사이트는 재검증 불필요. +) +ON CONFLICT (platform) DO NOTHING; From efcb74f6d183af7a00189f0a5015ac2e5fb3076f Mon Sep 17 00:00:00 2001 From: cocoyoon Date: Thu, 7 May 2026 17:31:10 +0900 Subject: [PATCH 02/15] chore: retrigger CI with bump label From 4c0b3ffc666c81b512a04f392f8059741f045534 Mon Sep 17 00:00:00 2001 From: Raf Date: Thu, 7 May 2026 18:49:28 +0900 Subject: [PATCH 03/15] fix(starstyle-backfill): mirror hero images to R2 before INSERT (#466) (#484) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit starstyle 의 og:image 는 admin (HTTPS) 에서 직접 hotlink 가 막힌다: - HTTPS 요청 → 301 redirect to HTTP → mixed-content 차단 - HTTP 요청 → User-Agent / Referer 가 비어 있으면 403 백필 시점에 boto3 로 upstream 이미지 다운로드 → R2 (RAW_POSTS_R2_BUCKET) 에 ``starstyle/{shard}/{external_id}.jpg`` 키로 업로드하고, raw_posts.image_url 을 R2 public URL 로 저장한다. admin 은 R2 직접 fetch (HTTPS clean, hotlink 없음) → Vercel image-proxy 우회. - HEAD 로 R2 객체 존재 확인 → 재실행 시 중복 업로드 skip (idempotent) - 다운로드/업로드 실패 시 upstream URL 로 fallback (image_url 그대로) - RAW_POSTS_R2_* env 미설정 시 mirror skip + warning - PostData @dataclass(frozen=True) → frozen 제거 (image_url 교체 위해) 검증: 500 row 재백필, 479/500 R2 mirrored. 샘플 R2 URL HTTPS 200 확인. Co-authored-by: Claude Opus 4.7 (1M context) --- .../scripts/backfill_starstyle_posts.py | 147 +++++++++++++++++- 1 file changed, 146 insertions(+), 1 deletion(-) diff --git a/packages/ai-server/scripts/backfill_starstyle_posts.py b/packages/ai-server/scripts/backfill_starstyle_posts.py index e0a1b25f..c1602eab 100644 --- a/packages/ai-server/scripts/backfill_starstyle_posts.py +++ b/packages/ai-server/scripts/backfill_starstyle_posts.py @@ -81,6 +81,15 @@ def _env(name: str, *, required: bool = True) -> str: _SUPABASE_URL = _env("ASSETS_DATABASE_API_URL").rstrip("/") _SERVICE_ROLE_KEY = _env("ASSETS_DATABASE_SERVICE_ROLE_KEY") +# R2 — starstyle 의 og:image 는 hotlink 보호 + HTTPS→HTTP 리다이렉트 때문에 +# admin (HTTPS) 에서 직접 fetch 가 안 됨. 백필 시 R2 에 미러링해서 image_url 을 +# R2 public URL 로 저장 → admin 이 vercel proxy 우회. +_R2_ACCOUNT_ID = _env("RAW_POSTS_R2_ACCOUNT_ID", required=False) +_R2_ACCESS_KEY = _env("RAW_POSTS_R2_ACCESS_KEY_ID", required=False) +_R2_SECRET_KEY = _env("RAW_POSTS_R2_SECRET_ACCESS_KEY", required=False) +_R2_BUCKET = _env("RAW_POSTS_R2_BUCKET", required=False) or "raw" +_R2_PUBLIC_URL = (_env("RAW_POSTS_R2_PUBLIC_URL", required=False) or "").rstrip("/") + _INSERT_BATCH = 100 logger = logging.getLogger("backfill_starstyle") @@ -89,7 +98,7 @@ def _env(name: str, *, required: bool = True) -> str: # ---------------------------------------------------------- types & helpers -@dataclass(frozen=True) +@dataclass class PostData: post_id: str slug: str @@ -311,6 +320,93 @@ async def fetch_existing_external_ids(http: httpx.AsyncClient) -> Set[str]: return out +# --------------------------------------------------- R2 mirror (hero image) + + +def _r2_client(): + """boto3 S3 client for Cloudflare R2. Returns None if not configured.""" + if not (_R2_ACCOUNT_ID and _R2_ACCESS_KEY and _R2_SECRET_KEY): + return None + import boto3 # local import — backfill 만 사용 + from botocore.client import Config + + return boto3.client( + "s3", + endpoint_url=f"https://{_R2_ACCOUNT_ID}.r2.cloudflarestorage.com", + aws_access_key_id=_R2_ACCESS_KEY, + aws_secret_access_key=_R2_SECRET_KEY, + config=Config(signature_version="s3v4"), + region_name="auto", + ) + + +def _build_r2_key(external_id: str) -> str: + """``starstyle/{shard}/{id}.jpg``. pipeline._build_r2_key 와 같은 형식. + + starstyle 의 hero 는 항상 jpg (og:image 의 ``.jpg`` 확장자) — 다양한 + 포맷을 처리하지 않고 jpg 로 통일. 다른 포맷이 들어오면 그대로 jpg 로 저장 + (R2 ContentType 에서 결정, 브라우저는 sniffing). + """ + safe = "".join(c if c.isalnum() else "-" for c in external_id).strip("-") or "x" + shard = safe[:2] or "_" + return f"starstyle/{shard}/{safe}.jpg" + + +async def mirror_image_to_r2( + http: httpx.AsyncClient, + *, + s3, + image_url: str, + external_id: str, + referer: str, +) -> Optional[str]: + """upstream 이미지 다운로드 → R2 put → R2 public URL 반환. + + 실패 시 None — caller 가 image_url 로 upstream URL 그대로 사용. + 이미 R2 에 있으면 (HEAD 200) 다운로드 skip. + """ + if s3 is None or not _R2_PUBLIC_URL: + return None + key = _build_r2_key(external_id) + public_url = f"{_R2_PUBLIC_URL}/{key}" + # HEAD 로 존재 확인 — 재실행 시 중복 업로드 방지. + try: + await asyncio.to_thread(s3.head_object, Bucket=_R2_BUCKET, Key=key) + return public_url + except Exception: + pass + + try: + resp = await http.get( + image_url, + headers={"User-Agent": _USER_AGENT, "Referer": referer}, + follow_redirects=True, + timeout=30, + ) + resp.raise_for_status() + data = resp.content + ct = (resp.headers.get("content-type") or "image/jpeg").split(";")[0].strip() + if not ct.startswith("image/"): + logger.warning("mirror: skip %s — bad content-type %s", external_id, ct) + return None + except Exception as exc: + logger.warning("mirror: download failed for %s — %s", external_id, exc) + return None + + try: + await asyncio.to_thread( + s3.put_object, + Bucket=_R2_BUCKET, + Key=key, + Body=data, + ContentType=ct, + ) + except Exception as exc: + logger.warning("mirror: R2 put failed for %s — %s", external_id, exc) + return None + return public_url + + async def ensure_global_feed_source(http: httpx.AsyncClient) -> str: body = [ { @@ -531,6 +627,55 @@ async def _run(args) -> int: source_id = await ensure_global_feed_source(http) logger.info(" source_id = %s", source_id) + # R2 mirror — admin (HTTPS) 에서 starstyle 이미지 hotlink 가 막혀 + # backfill 시 R2 에 미러링하고 image_url 을 R2 public URL 로 저장. + s3 = _r2_client() + if s3 is not None: + logger.info( + "R2 mirror: bucket=%s public=%s — mirroring %d images", + _R2_BUCKET, + _R2_PUBLIC_URL, + len(posts_new), + ) + sem = asyncio.Semaphore(args.concurrency) + mirrored = 0 + failed = 0 + + async def _mirror_one(d: PostData): + nonlocal mirrored, failed + async with sem: + new_url = await mirror_image_to_r2( + http, + s3=s3, + image_url=d.image_url, + external_id=d.post_id, + referer=d.url, + ) + if new_url: + d.image_url = new_url + mirrored += 1 + else: + failed += 1 + if (mirrored + failed) % 50 == 0: + logger.info( + " mirror progress: %d done (%d mirrored, %d failed)", + mirrored + failed, + mirrored, + failed, + ) + + await asyncio.gather(*(_mirror_one(p) for p in posts_new)) + logger.info( + "R2 mirror done: %d mirrored, %d failed (fallback to upstream)", + mirrored, + failed, + ) + else: + logger.warning( + "R2 not configured (RAW_POSTS_R2_* env missing) — " + "image_url 은 upstream URL 그대로 (admin preview 깨짐)" + ) + dispatch_id = f"backfill-{uuid.uuid4().hex[:12]}" total_inserted = 0 n_batches = (len(posts_new) + _INSERT_BATCH - 1) // _INSERT_BATCH From 47f31a8d55a34cd2a7e22b248ab7355aaf9f3b80 Mon Sep 17 00:00:00 2001 From: Raf Date: Thu, 7 May 2026 19:13:13 +0900 Subject: [PATCH 04/15] feat(raw_posts): R2 cleanup on delete + storage rename (#466) (#486) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * release: starstyle R2 mirror fix (#466) (#485) * feat(raw_posts): StarStyle.com adapter (#466) (#481) * feat(raw_posts): StarStyle.com adapter (#466) WordPress SSR fashion 사이트(starstyle.com) 어댑터 추가. wots(#465) 인프라 재사용 — discovery_target='raw_posts' 글로벌 피드 모델, PrelabeledData 로 vision 단계 우회. 핵심 차이: per-item 사진 URL 이 없어 thumbnail_url=None → items_thumbnail processor 가 spots bbox 로 hero crop fallback path 사용. brand/title 분리는 보수적으로 product 통째로 (verify 단계 admin 처리). - adapters/_starstyle_html.py: parse_post / parse_sitemap / decode_skimresources (skim affiliate 디코드) - adapters/starstyle.py: thin httpx wrapper, fetch + sitemap discover - scripts/backfill_starstyle_posts.py: sitemap → JSONL streaming → PostgREST bulk INSERT (wots 미러) - service.rs: parse_starstyle_source — slug-sp{ID} 형식 검증 - admin UI: PLATFORM_FILTERS / PLATFORM_TABS / DiscoveryPipelineCard / URL builder - migration: pipeline_settings starstyle row (모든 cycle OFF) - 24 단위 테스트 추가 (parser + adapter + prelabeled round-trip) Closes #466. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(starstyle): force HTTPS on og:image URL (#466) starstyle 의 og:image 가 http:// 로 노출되는데 admin 은 HTTPS 라 mixed-content 로 이미지가 차단되던 문제. CDN 자체는 HTTPS 정상이라 파서 단계에서 https 강제 변환. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) * chore: retrigger CI with bump label * fix(starstyle-backfill): mirror hero images to R2 before INSERT (#466) (#484) starstyle 의 og:image 는 admin (HTTPS) 에서 직접 hotlink 가 막힌다: - HTTPS 요청 → 301 redirect to HTTP → mixed-content 차단 - HTTP 요청 → User-Agent / Referer 가 비어 있으면 403 백필 시점에 boto3 로 upstream 이미지 다운로드 → R2 (RAW_POSTS_R2_BUCKET) 에 ``starstyle/{shard}/{external_id}.jpg`` 키로 업로드하고, raw_posts.image_url 을 R2 public URL 로 저장한다. admin 은 R2 직접 fetch (HTTPS clean, hotlink 없음) → Vercel image-proxy 우회. - HEAD 로 R2 객체 존재 확인 → 재실행 시 중복 업로드 skip (idempotent) - 다운로드/업로드 실패 시 upstream URL 로 fallback (image_url 그대로) - RAW_POSTS_R2_* env 미설정 시 mirror skip + warning - PostData @dataclass(frozen=True) → frozen 제거 (image_url 교체 위해) 검증: 500 row 재백필, 479/500 R2 mirrored. 샘플 R2 URL HTTPS 200 확인. Co-authored-by: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) * chore(release): backend bump ai=1.5.1 api=0.10.1 [skip ci] * feat(raw_posts): R2 cleanup on raw_post delete (#466) raw_post 삭제 시 image_url 이 raw_posts R2 객체이고 verified posts 가 같은 URL 을 참조하지 않으면 R2 객체도 같이 삭제. verify 후에는 posts. image_url 가 raw_post.image_url 그대로 복사되므로 (#333) — verified post 가 참조 중이면 보존해야 깨지지 않는다. 또한 storage 명명을 db 패턴(db / assets_db) 에 맞춤: - storage_client → operation_storage - 신규 RAW_POSTS_R2_* → assets_storage - AppConfig.storage / assets_storage, AppState.operation_storage / assets_storage 일관 매핑. config: - StorageConfig 두 개 (storage / assets_storage), 별도 R2 버킷 wiring - RAW_POSTS_R2_{ACCOUNT_ID, ACCESS_KEY_ID, SECRET_ACCESS_KEY, BUCKET, PUBLIC_URL} service::delete_item: - 시그니처 (assets_db, prod_db, storage, public_url, id) — 4개의 DI - raw_post.image_url 추출 → DELETE 진행 - extract_assets_r2_key 로 R2 key 추출 (public_url prefix strip) - posts.image_url COUNT — 0 이면 storage.delete(key) 호출 - 실패는 best-effort warn (이미 raw_post 삭제는 성공) 테스트: 4개 단위 (key 추출 - prefix/trailing slash/other domain/empty url). solutions/tests.rs 의 AdminSolutionListQuery 빌드에 has_url=None 추가 (unrelated 사전 컴파일 에러 — 본 PR 의 cargo test --lib 통과 위해 보강). Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: decoded-ci --- packages/ai-server/pyproject.toml | 2 +- packages/api-server/Cargo.toml | 2 +- packages/api-server/src/app_state.rs | 28 ++++- packages/api-server/src/config.rs | 19 +++ .../api-server/src/domains/posts/service.rs | 14 +-- .../src/domains/raw_posts/handlers.rs | 9 +- .../src/domains/raw_posts/service.rs | 116 +++++++++++++++++- .../api-server/src/domains/solutions/tests.rs | 5 + packages/api-server/src/middleware/metrics.rs | 11 +- packages/api-server/src/tests/helpers.rs | 16 ++- 10 files changed, 202 insertions(+), 20 deletions(-) diff --git a/packages/ai-server/pyproject.toml b/packages/ai-server/pyproject.toml index d5119777..ef33a8d3 100644 --- a/packages/ai-server/pyproject.toml +++ b/packages/ai-server/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "decoded-ai" -version = "1.5.0" +version = "1.5.1" description = "" authors = [ {name = "CIOI",email = "rhkr9693@gmail.com"} diff --git a/packages/api-server/Cargo.toml b/packages/api-server/Cargo.toml index d7c92da2..e4de5283 100644 --- a/packages/api-server/Cargo.toml +++ b/packages/api-server/Cargo.toml @@ -75,7 +75,7 @@ tokio-test = "0.4" [package] name = "decoded-api" -version = "0.10.0" +version = "0.10.1" edition = "2021" license = "MIT" default-run = "decoded-api" diff --git a/packages/api-server/src/app_state.rs b/packages/api-server/src/app_state.rs index da22cb81..a65ebcc7 100644 --- a/packages/api-server/src/app_state.rs +++ b/packages/api-server/src/app_state.rs @@ -32,7 +32,10 @@ pub struct AppState { pub post_list_cache: Arc, // Trait 기반 클라이언트 - pub storage_client: Arc, + pub operation_storage: Arc, + /// raw_posts 전용 R2 (#466 R2 cleanup) — ai-server 가 hero/thumbnail 을 + /// 올리는 별도 버킷. delete_item 시 verify 안 된 raw_post 의 R2 객체 삭제. + pub assets_storage: Arc, pub search_client: Arc, pub affiliate_client: Arc, pub embedding_client: Arc, @@ -63,7 +66,7 @@ impl AppState { let assets_db = Arc::new(config.create_assets_db_connection().await?); tracing::info!("Assets DB connection established"); - let storage_client: Arc = + let operation_storage: Arc = match CloudflareR2Client::new(&config.storage).await { Ok(client) => { tracing::info!("CloudflareR2Client initialized successfully"); @@ -78,6 +81,24 @@ impl AppState { } }; + // raw_posts 전용 R2 (#466 R2 cleanup) — ai-server 가 쓰는 별도 버킷. + // 미설정 시 dummy 로 fallback (delete cleanup 만 영향, 운영 노이즈 X). + let assets_storage: Arc = + match CloudflareR2Client::new(&config.assets_storage).await { + Ok(client) => { + tracing::info!("raw_posts CloudflareR2Client initialized successfully"); + Arc::new(client) + } + Err(e) => { + tracing::warn!( + "Failed to initialize raw_posts CloudflareR2Client: {}. \ + R2 cleanup on raw_post delete will be skipped.", + e + ); + Arc::new(crate::services::DummyStorageClient::default()) + } + }; + let search_client: Arc = match MeilisearchClient::new(&config.search) { Ok(client) => { tracing::info!("MeilisearchClient initialized successfully"); @@ -156,7 +177,8 @@ impl AppState { config, category_cache, post_list_cache, - storage_client, + operation_storage, + assets_storage, search_client, affiliate_client, embedding_client, diff --git a/packages/api-server/src/config.rs b/packages/api-server/src/config.rs index 8e7f3239..0d923722 100644 --- a/packages/api-server/src/config.rs +++ b/packages/api-server/src/config.rs @@ -52,6 +52,10 @@ pub struct AppConfig { pub assets_database: AssetsDatabaseConfig, pub auth: AuthConfig, pub storage: StorageConfig, + /// raw_posts 전용 R2 (#466 R2 cleanup) — ai-server 가 hero/thumbnail 을 + /// 업로드하는 별도 버킷. ``RAW_POSTS_R2_*`` env. delete_item 시 verify + /// 안 된 raw_post 의 R2 객체 정리에 사용. + pub assets_storage: StorageConfig, pub search: SearchConfig, pub affiliate: AffiliateConfig, pub ai_service: AiServiceConfig, @@ -271,6 +275,21 @@ impl AppConfig { .unwrap_or_else(|_| "decoded-images".to_string()), public_url: std::env::var("R2_PUBLIC_URL").unwrap_or_else(|_| String::new()), }, + assets_storage: StorageConfig { + endpoint: std::env::var("RAW_POSTS_R2_ACCOUNT_ID") + .map(|id| format!("https://{}.r2.cloudflarestorage.com", id)) + .unwrap_or_else(|_| String::new()), + account_id: std::env::var("RAW_POSTS_R2_ACCOUNT_ID") + .unwrap_or_else(|_| String::new()), + access_key_id: std::env::var("RAW_POSTS_R2_ACCESS_KEY_ID") + .unwrap_or_else(|_| String::new()), + secret_access_key: std::env::var("RAW_POSTS_R2_SECRET_ACCESS_KEY") + .unwrap_or_else(|_| String::new()), + bucket_name: std::env::var("RAW_POSTS_R2_BUCKET") + .unwrap_or_else(|_| "raw".to_string()), + public_url: std::env::var("RAW_POSTS_R2_PUBLIC_URL") + .unwrap_or_else(|_| String::new()), + }, search: SearchConfig { url: std::env::var("MEILISEARCH_URL") .unwrap_or_else(|_| "http://localhost:7700".to_string()), diff --git a/packages/api-server/src/domains/posts/service.rs b/packages/api-server/src/domains/posts/service.rs index 47ff3fe9..55ddfbf5 100644 --- a/packages/api-server/src/domains/posts/service.rs +++ b/packages/api-server/src/domains/posts/service.rs @@ -291,9 +291,9 @@ pub async fn create_post_without_solutions( .inspect_err(|_e| { // Post 생성 실패 시 업로드된 이미지 삭제 let image_key_clone = image_key.clone(); - let storage_client = state.storage_client.clone(); + let storage = state.operation_storage.clone(); tokio::spawn(async move { - if let Err(delete_err) = storage_client.delete(&image_key_clone).await { + if let Err(delete_err) = storage.delete(&image_key_clone).await { tracing::warn!( "Failed to delete orphaned image {}: {}", image_key_clone, @@ -363,9 +363,9 @@ pub async fn create_post_with_solutions( .inspect_err(|_e| { // Post 생성 실패 시 업로드된 이미지 삭제 let image_key_clone = image_key.clone(); - let storage_client = state.storage_client.clone(); + let storage = state.operation_storage.clone(); tokio::spawn(async move { - if let Err(delete_err) = storage_client.delete(&image_key_clone).await { + if let Err(delete_err) = storage.delete(&image_key_clone).await { tracing::warn!( "Failed to delete orphaned image {}: {}", image_key_clone, @@ -1734,7 +1734,7 @@ pub async fn upload_image( // StorageClient를 사용하여 업로드 let image_url = state - .storage_client + .operation_storage .upload(&key, image_data, content_type) .await .map_err(|e| AppError::ExternalService(format!("Failed to upload image: {}", e)))?; @@ -2022,9 +2022,9 @@ pub async fn create_try_post( .inspect_err(|_| { // 실패 시 업로드된 이미지 삭제 let image_key_clone = image_key.clone(); - let storage_client = state.storage_client.clone(); + let storage = state.operation_storage.clone(); tokio::spawn(async move { - if let Err(e) = storage_client.delete(&image_key_clone).await { + if let Err(e) = storage.delete(&image_key_clone).await { tracing::warn!( "Failed to delete orphaned try image {}: {}", image_key_clone, diff --git a/packages/api-server/src/domains/raw_posts/handlers.rs b/packages/api-server/src/domains/raw_posts/handlers.rs index f55d2ab7..5f5f7390 100644 --- a/packages/api-server/src/domains/raw_posts/handlers.rs +++ b/packages/api-server/src/domains/raw_posts/handlers.rs @@ -267,7 +267,14 @@ pub async fn delete_item( State(state): State, Path(id): Path, ) -> AppResult { - service::delete_item(state.assets_db.as_ref(), id).await?; + service::delete_item( + state.assets_db.as_ref(), + state.db.as_ref(), + state.assets_storage.as_ref(), + &state.config.assets_storage.public_url, + id, + ) + .await?; Ok(StatusCode::NO_CONTENT) } diff --git a/packages/api-server/src/domains/raw_posts/service.rs b/packages/api-server/src/domains/raw_posts/service.rs index 9d34c546..33c19f62 100644 --- a/packages/api-server/src/domains/raw_posts/service.rs +++ b/packages/api-server/src/domains/raw_posts/service.rs @@ -12,9 +12,11 @@ use uuid::Uuid; use crate::entities::{ assets_pipeline_settings as settings_entity, assets_raw_post_sources as src_entity, - assets_raw_posts as post_entity, AssetsPipelineSettings, AssetsRawPostSources, AssetsRawPosts, + assets_raw_posts as post_entity, posts as posts_entity, AssetsPipelineSettings, + AssetsRawPostSources, AssetsRawPosts, Posts, }; use crate::error::{AppError, AppResult}; +use crate::services::StorageClient; use super::dto::{ CreateRawPostSourceDto, DiscoveryPipelineHealth, ListItemsQuery, ListSourcesQuery, @@ -597,17 +599,90 @@ pub async fn get_item(db: &DatabaseConnection, id: Uuid) -> AppResult { /// admin 이 discovered/processed/error 어느 상태든 raw_post 를 영구 삭제 /// (#359). pipeline_events 는 FK ON DELETE CASCADE 로 함께 정리. -pub async fn delete_item(db: &DatabaseConnection, id: Uuid) -> AppResult<()> { +/// +/// R2 cleanup (#466): raw_post.image_url 이 raw_posts R2 객체이고 +/// verified `posts.image_url` 가 동일 URL 을 참조하지 않으면 R2 객체도 삭제. +/// verify 후 posts 가 같은 URL 을 가리키는 경우 R2 보존 (verified post 가 깨짐). +pub async fn delete_item( + assets_db: &DatabaseConnection, + prod_db: &DatabaseConnection, + storage: &dyn StorageClient, + assets_r2_public_url: &str, + id: Uuid, +) -> AppResult<()> { + // 1) raw_post 조회 — image_url 만 필요. 없으면 404. + let raw = AssetsRawPosts::find_by_id(id) + .one(assets_db) + .await + .map_err(AppError::DatabaseError)? + .ok_or_else(|| AppError::NotFound(format!("raw_post {id} not found")))?; + + let image_url = raw.image_url.clone(); + + // 2) DELETE — 우선순위: prod posts FK violation 등 별도 cascade 없음. let res = AssetsRawPosts::delete_by_id(id) - .exec(db) + .exec(assets_db) .await .map_err(AppError::DatabaseError)?; if res.rows_affected == 0 { return Err(AppError::NotFound(format!("raw_post {id} not found"))); } + + // 3) R2 cleanup — best effort. 실패해도 raw_post 삭제는 이미 성공. + if let Some(url) = image_url { + if let Some(key) = extract_assets_r2_key(&url, assets_r2_public_url) { + // verify 후 posts 가 같은 image_url 을 참조 중이면 보존. + let referenced = Posts::find() + .filter(posts_entity::Column::ImageUrl.eq(url.clone())) + .count(prod_db) + .await + .unwrap_or(0); + if referenced == 0 { + if let Err(e) = storage.delete(&key).await { + tracing::warn!( + "raw_posts.delete_item: R2 delete failed for key={} err={}", + key, + e + ); + } else { + tracing::info!( + "raw_posts.delete_item: R2 object deleted (key={}, raw_post={})", + key, + id + ); + } + } else { + tracing::info!( + "raw_posts.delete_item: R2 object kept — referenced by {} verified post(s) (url={})", + referenced, + url + ); + } + } + } Ok(()) } +/// raw_posts R2 public URL 에서 객체 키 추출. +/// +/// 예: +/// public_url = "https://pub-xxx.r2.dev" +/// url = "https://pub-xxx.r2.dev/starstyle/92/926700.jpg" +/// → Some("starstyle/92/926700.jpg") +/// +/// 다른 도메인 / 빈 public_url / 키가 비면 None — caller 가 cleanup skip. +fn extract_assets_r2_key(url: &str, public_url: &str) -> Option { + let prefix = public_url.trim_end_matches('/'); + if prefix.is_empty() { + return None; + } + let stripped = url.strip_prefix(prefix)?.trim_start_matches('/'); + if stripped.is_empty() { + return None; + } + Some(stripped.to_string()) +} + /// Insert-or-update a single raw_post given by the ai-server callback. /// Uniqueness key: `(platform, external_id)`. pub async fn upsert_raw_post( @@ -1636,6 +1711,41 @@ mod tests { assert!(err.contains("unrecognized starstyle")); } + #[test] + fn extract_assets_r2_key_strips_public_url_prefix() { + let public = "https://pub-64ff29549cdf47ee94d338bca8d04819.r2.dev"; + let url = "https://pub-64ff29549cdf47ee94d338bca8d04819.r2.dev/starstyle/92/926700.jpg"; + assert_eq!( + super::extract_assets_r2_key(url, public).as_deref(), + Some("starstyle/92/926700.jpg") + ); + } + + #[test] + fn extract_assets_r2_key_handles_trailing_slash() { + let public = "https://pub-x.r2.dev/"; + let url = "https://pub-x.r2.dev/starstyle/92/x.jpg"; + assert_eq!( + super::extract_assets_r2_key(url, public).as_deref(), + Some("starstyle/92/x.jpg") + ); + } + + #[test] + fn extract_assets_r2_key_returns_none_for_other_domain() { + let public = "https://pub-x.r2.dev"; + let url = "http://www.starstyle.com/wp-content/uploads/x.jpg"; + assert_eq!(super::extract_assets_r2_key(url, public), None); + } + + #[test] + fn extract_assets_r2_key_returns_none_for_empty_public_url() { + assert_eq!( + super::extract_assets_r2_key("https://anywhere/x.jpg", ""), + None + ); + } + #[test] fn parse_source_identifier_instagram_at_handle() { let (_, id) = super::parse_source_identifier("instagram", "@Foo.Bar").unwrap(); diff --git a/packages/api-server/src/domains/solutions/tests.rs b/packages/api-server/src/domains/solutions/tests.rs index c3daf6e4..45f082e4 100644 --- a/packages/api-server/src/domains/solutions/tests.rs +++ b/packages/api-server/src/domains/solutions/tests.rs @@ -232,6 +232,7 @@ mod mock_db_tests { user_id: Some(fixtures::test_uuid(10)), sort: "recent".to_string(), pagination: Pagination::new(1, 20), + has_url: None, }; let result = service::admin_list_solutions(&db, query).await; assert!(result.is_ok(), "unexpected err: {:?}", result.err()); @@ -259,6 +260,7 @@ mod mock_db_tests { user_id: None, sort: "popular".to_string(), pagination: Pagination::new(1, 20), + has_url: None, }; assert!(service::admin_list_solutions(&db, query).await.is_ok()); } @@ -284,6 +286,7 @@ mod mock_db_tests { user_id: None, sort: "verified".to_string(), pagination: Pagination::new(1, 20), + has_url: None, }; assert!(service::admin_list_solutions(&db, query).await.is_ok()); } @@ -309,6 +312,7 @@ mod mock_db_tests { user_id: None, sort: "adopted".to_string(), pagination: Pagination::new(1, 20), + has_url: None, }; assert!(service::admin_list_solutions(&db, query).await.is_ok()); } @@ -334,6 +338,7 @@ mod mock_db_tests { user_id: None, sort: "unknown_sort_key".to_string(), pagination: Pagination::new(1, 20), + has_url: None, }; assert!(service::admin_list_solutions(&db, query).await.is_ok()); } diff --git a/packages/api-server/src/middleware/metrics.rs b/packages/api-server/src/middleware/metrics.rs index 1f275589..1cc1edc8 100644 --- a/packages/api-server/src/middleware/metrics.rs +++ b/packages/api-server/src/middleware/metrics.rs @@ -96,6 +96,14 @@ mod tests { bucket_name: "test".to_string(), public_url: String::new(), }, + assets_storage: StorageConfig { + endpoint: String::new(), + account_id: String::new(), + access_key_id: String::new(), + secret_access_key: String::new(), + bucket_name: "raw".to_string(), + public_url: String::new(), + }, search: SearchConfig { url: "http://localhost:7700".to_string(), api_key: String::new(), @@ -125,7 +133,8 @@ mod tests { config, category_cache: Arc::new(CategoryCache::new()), post_list_cache: Arc::new(PostListCache::new()), - storage_client: Arc::new(DummyStorageClient::default()), + operation_storage: Arc::new(DummyStorageClient::default()), + assets_storage: Arc::new(DummyStorageClient::default()), search_client: Arc::new(DummySearchClient), affiliate_client: Arc::new(DummyAffiliateClient), embedding_client: Arc::new(DummyEmbeddingClient), diff --git a/packages/api-server/src/tests/helpers.rs b/packages/api-server/src/tests/helpers.rs index 2710a4ea..2175e937 100644 --- a/packages/api-server/src/tests/helpers.rs +++ b/packages/api-server/src/tests/helpers.rs @@ -65,6 +65,14 @@ pub fn test_config() -> AppConfig { bucket_name: "test-bucket".to_string(), public_url: "https://cdn.test.com".to_string(), }, + assets_storage: StorageConfig { + endpoint: "https://test-assets.r2.cloudflarestorage.com".to_string(), + account_id: "test-account".to_string(), + access_key_id: "test-key".to_string(), + secret_access_key: "test-secret".to_string(), + bucket_name: "raw".to_string(), + public_url: "https://pub-test-assets.r2.dev".to_string(), + }, search: SearchConfig { url: "http://localhost:7700".to_string(), api_key: "test-master-key".to_string(), @@ -134,7 +142,8 @@ pub fn test_app_state_with_assets( config: test_config(), category_cache: Arc::new(CategoryCache::new()), post_list_cache: Arc::new(crate::domains::posts::cache::PostListCache::new()), - storage_client: Arc::new(DummyStorageClient::default()), + operation_storage: Arc::new(DummyStorageClient::default()), + assets_storage: Arc::new(DummyStorageClient::default()), search_client: Arc::new(DummySearchClient), affiliate_client: Arc::new(DummyAffiliateClient), embedding_client: Arc::new(DummyEmbeddingClient), @@ -158,7 +167,7 @@ pub fn test_app_state(db: DatabaseConnection) -> AppState { /// 커스텀 trait 클라이언트를 주입할 수 있는 AppState pub fn test_app_state_with_clients( db: Arc, - storage_client: Arc, + operation_storage: Arc, search_client: Arc, affiliate_client: Arc, embedding_client: Arc, @@ -176,7 +185,8 @@ pub fn test_app_state_with_clients( config: test_config(), category_cache: Arc::new(CategoryCache::new()), post_list_cache: Arc::new(crate::domains::posts::cache::PostListCache::new()), - storage_client, + operation_storage, + assets_storage: Arc::new(crate::services::DummyStorageClient::default()), search_client, affiliate_client, embedding_client, From 5443b424da960a228dbe703d8683f6c27e568532 Mon Sep 17 00:00:00 2001 From: thxforall <113906780+thxforall@users.noreply.github.com> Date: Thu, 7 May 2026 15:08:34 +0900 Subject: [PATCH 05/15] feat(web): add vton item and post selection --- packages/web/app/api/v1/vton/items/route.ts | 120 ++++-- packages/web/app/api/v1/vton/posts/route.ts | 139 +++++++ packages/web/lib/components/MobileNavBar.tsx | 12 +- .../lib/components/main-renewal/SmartNav.tsx | 17 +- .../web/lib/components/vton/VtonItemPanel.tsx | 192 +++++++--- .../web/lib/components/vton/VtonModal.tsx | 39 ++ .../vton/__tests__/VtonLab.test.tsx | 74 +++- packages/web/lib/hooks/useVtonPostFetch.ts | 45 +++ packages/web/lib/stores/vtonStore.ts | 5 + packages/web/tests/upload-intercept.spec.ts | 58 +-- packages/web/tests/upload-mobile.spec.ts | 26 +- supabase/seed.sql | 362 ++++++++++++++++++ 12 files changed, 922 insertions(+), 167 deletions(-) create mode 100644 packages/web/app/api/v1/vton/posts/route.ts create mode 100644 packages/web/lib/hooks/useVtonPostFetch.ts create mode 100644 supabase/seed.sql diff --git a/packages/web/app/api/v1/vton/items/route.ts b/packages/web/app/api/v1/vton/items/route.ts index 7f979aa2..4400ffda 100644 --- a/packages/web/app/api/v1/vton/items/route.ts +++ b/packages/web/app/api/v1/vton/items/route.ts @@ -1,14 +1,64 @@ import { NextRequest, NextResponse } from "next/server"; import { createSupabaseServerClient } from "@/lib/supabase/server"; +import type { Database, Json } from "@/lib/supabase/types"; -// Subcategory IDs for VTON-eligible fashion categories -const SUBCATEGORY_MAP: Record = { - tops: "230d533e-57b1-4fd9-ae7d-1e4e14fdb091", - bottoms: "d4367fd1-55bd-4ea6-b085-6fed80a65b99", +const VTON_CATEGORY_CODES = ["tops", "bottoms"] as const; +type VtonCategoryCode = (typeof VTON_CATEGORY_CODES)[number]; + +type SupabaseClient = Awaited>; +type SolutionRow = Database["public"]["Tables"]["solutions"]["Row"] & { + spots?: { + subcategory_id: string | null; + posts?: { image_url: string | null } | null; + } | null; }; -// For "all" query, include both -const ALL_SUBCATEGORY_IDS = Object.values(SUBCATEGORY_MAP); +function isVtonCategoryCode(value: string): value is VtonCategoryCode { + return VTON_CATEGORY_CODES.includes(value as VtonCategoryCode); +} + +function toKeywords(value: Json | null): string[] | null { + if (!Array.isArray(value)) return null; + const keywords = value.filter( + (entry): entry is string => typeof entry === "string" + ); + return keywords.length > 0 ? keywords : null; +} + +async function getVtonSubcategoryIds( + supabase: SupabaseClient, + category: string +) { + const codes = isVtonCategoryCode(category) ? [category] : VTON_CATEGORY_CODES; + const { data, error } = await supabase + .from("subcategories") + .select("id") + .in("code", codes); + + if (error) { + if (process.env.NODE_ENV === "development") { + console.error( + "Error fetching vton subcategories:", + JSON.stringify(error) + ); + } + return []; + } + + return (data ?? []).map((row) => row.id); +} + +function mapItems(data: SolutionRow[] | null) { + return (data || []) + .map((item) => ({ + id: item.id, + title: item.title || "Untitled item", + thumbnail_url: item.thumbnail_url || item.spots?.posts?.image_url || "", + description: item.description, + keywords: toKeywords(item.keywords), + })) + .filter((item) => item.thumbnail_url); +} export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); @@ -18,28 +68,44 @@ export async function GET(request: NextRequest) { try { const supabase = await createSupabaseServerClient(); + const subcategoryIds = await getVtonSubcategoryIds(supabase, category); - const subcategoryIds = - category && SUBCATEGORY_MAP[category] - ? [SUBCATEGORY_MAP[category]] - : ALL_SUBCATEGORY_IDS; - - let dbQuery = supabase - .from("solutions") - .select( - "id, title, thumbnail_url, description, keywords, spots!inner(subcategory_id)" - ) - .eq("status", "active") - .not("thumbnail_url", "is", null) - .in("spots.subcategory_id", subcategoryIds) - .order("accurate_count", { ascending: false }) - .limit(limit); + const buildQuery = () => + supabase + .from("solutions") + .select( + "id, title, thumbnail_url, description, keywords, spots!inner(subcategory_id, posts(image_url))" + ) + .order("accurate_count", { ascending: false }) + .limit(limit); + let dbQuery = buildQuery(); + if (subcategoryIds.length > 0) { + dbQuery = dbQuery.in("spots.subcategory_id", subcategoryIds); + } if (query) { dbQuery = dbQuery.ilike("title", `%${query}%`); } - const { data, error } = await dbQuery; + const result = await dbQuery; + let data = result.data as SolutionRow[] | null; + let error = result.error; + + if (!error && (!data || data.length === 0) && subcategoryIds.length > 0) { + let fallbackQuery = supabase + .from("solutions") + .select( + "id, title, thumbnail_url, description, keywords, spots(subcategory_id, posts(image_url))" + ) + .order("accurate_count", { ascending: false }) + .limit(limit); + if (query) { + fallbackQuery = fallbackQuery.ilike("title", `%${query}%`); + } + const fallback = await fallbackQuery; + data = fallback.data as SolutionRow[] | null; + error = fallback.error; + } if (error) { if (process.env.NODE_ENV === "development") { @@ -51,15 +117,7 @@ export async function GET(request: NextRequest) { ); } - const items = (data || []).map((item) => ({ - id: item.id, - title: item.title, - thumbnail_url: item.thumbnail_url, - description: item.description, - keywords: item.keywords, - })); - - return NextResponse.json({ items }); + return NextResponse.json({ items: mapItems(data as SolutionRow[] | null) }); } catch (err) { if (process.env.NODE_ENV === "development") { console.error("VTON items error:", err); diff --git a/packages/web/app/api/v1/vton/posts/route.ts b/packages/web/app/api/v1/vton/posts/route.ts new file mode 100644 index 00000000..a7c6ad07 --- /dev/null +++ b/packages/web/app/api/v1/vton/posts/route.ts @@ -0,0 +1,139 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; +import type { Json } from "@/lib/supabase/types"; + +const VTON_CATEGORY_CODES = ["tops", "bottoms"] as const; +type SupabaseClient = Awaited>; + +type SolutionRow = { + id: string; + title: string | null; + thumbnail_url: string | null; + description: string | null; + keywords: Json | null; + status: string | null; + accurate_count: number | null; +}; + +type SpotRow = { + subcategory_id: string | null; + solutions?: SolutionRow[] | null; +}; + +type PostRow = { + id: string; + title: string | null; + context: string | null; + artist_name: string | null; + group_name: string | null; + image_url: string | null; + spots?: SpotRow[] | null; +}; + +function toKeywords(value: Json | null): string[] | null { + if (!Array.isArray(value)) return null; + const keywords = value.filter( + (entry): entry is string => typeof entry === "string" + ); + return keywords.length > 0 ? keywords : null; +} + +async function getVtonSubcategoryIds(supabase: SupabaseClient) { + const { data, error } = await supabase + .from("subcategories") + .select("id") + .in("code", VTON_CATEGORY_CODES); + + if (error) { + if (process.env.NODE_ENV === "development") { + console.error( + "Error fetching vton subcategories:", + JSON.stringify(error) + ); + } + return new Set(); + } + + return new Set((data ?? []).map((row) => row.id)); +} + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const limit = Math.min(Number(searchParams.get("limit")) || 24, 50); + + try { + const supabase = await createSupabaseServerClient(); + const subcategoryIds = await getVtonSubcategoryIds(supabase); + const { data, error } = await supabase + .from("posts") + .select( + "id, title, context, artist_name, group_name, image_url, spots(subcategory_id, solutions(id, title, thumbnail_url, description, keywords, status, accurate_count))" + ) + .eq("status", "active") + .not("image_url", "is", null) + .order("created_at", { ascending: false }) + .limit(limit * 3); + + if (error) { + if (process.env.NODE_ENV === "development") { + console.error("Error fetching vton posts:", JSON.stringify(error)); + } + return NextResponse.json( + { posts: [], error: error.message }, + { status: 500 } + ); + } + + const posts = ((data ?? []) as PostRow[]) + .map((post) => { + const eligibleSpots = (post.spots ?? []).filter((spot) => + subcategoryIds.size > 0 && spot.subcategory_id + ? subcategoryIds.has(spot.subcategory_id) + : true + ); + const items = eligibleSpots + .flatMap((spot) => spot.solutions ?? []) + .filter( + (solution) => + solution.title && + (solution.thumbnail_url || post.image_url) && + (!solution.status || solution.status === "active") + ) + .sort((a, b) => (b.accurate_count ?? 0) - (a.accurate_count ?? 0)) + .map((solution) => ({ + id: solution.id, + title: solution.title || "Untitled item", + thumbnail_url: solution.thumbnail_url || post.image_url || "", + description: solution.description, + keywords: toKeywords(solution.keywords), + })); + + return { + id: post.id, + title: + post.title || + post.context || + post.artist_name || + post.group_name || + "Untitled post", + image_url: post.image_url || "", + artist_name: post.artist_name, + group_name: post.group_name, + context: post.context, + items, + }; + }) + .filter((post) => post.image_url && post.items.length > 0) + .slice(0, limit); + + return NextResponse.json({ posts }); + } catch (err) { + if (process.env.NODE_ENV === "development") { + console.error("VTON posts error:", err); + } + return NextResponse.json( + { posts: [], error: err instanceof Error ? err.message : "Proxy error" }, + { status: 502 } + ); + } +} diff --git a/packages/web/lib/components/MobileNavBar.tsx b/packages/web/lib/components/MobileNavBar.tsx index 9ac840e3..2074a00f 100644 --- a/packages/web/lib/components/MobileNavBar.tsx +++ b/packages/web/lib/components/MobileNavBar.tsx @@ -2,7 +2,7 @@ import { memo } from "react"; import { usePathname } from "next/navigation"; -import { Home, Search, Compass, Upload } from "lucide-react"; +import { Home, Search, Compass, Sparkles } from "lucide-react"; import { NavBar, NavItem } from "@/lib/design-system"; interface NavItemConfig { @@ -11,17 +11,15 @@ interface NavItemConfig { icon: React.ComponentType<{ className?: string }>; label: string; disabled?: boolean; - isAction?: boolean; } /** - * Navigation items for 1차 릴리즈: Home, Search, Explore (3 items) - * Upload/Profile은 추후 추가 예정 + * Navigation items for 1차 릴리즈. */ const navItems: NavItemConfig[] = [ { id: "home", href: "/", icon: Home, label: "Home" }, { id: "search", href: "/search", icon: Search, label: "Search" }, - { id: "upload", href: "/request/upload", icon: Upload, label: "Upload" }, + { id: "tryon", href: "/lab/vton", icon: Sparkles, label: "Try On" }, { id: "explore", href: "/explore", icon: Compass, label: "Explore" }, ]; @@ -30,7 +28,7 @@ const navItems: NavItemConfig[] = [ * * Design spec: * - Height: 64px - * - 5 items: Home, Search, Request, Feed, Profile + * - 4 items: Home, Search, Try On, Explore * - Each item: icon (22px) + label (10px, font-medium) * - Background: card color * - Padding: 8px 24px @@ -40,7 +38,7 @@ const navItems: NavItemConfig[] = [ * - Hidden on desktop (md:hidden) * - Active state with primary color * - Safe area support for iPhone notch - * - Request opens modal instead of page navigation + * - Try On routes through the VTON lab entry point */ export const MobileNavBar = memo(() => { const pathname = usePathname(); diff --git a/packages/web/lib/components/main-renewal/SmartNav.tsx b/packages/web/lib/components/main-renewal/SmartNav.tsx index a48ea1c1..5ace7b41 100644 --- a/packages/web/lib/components/main-renewal/SmartNav.tsx +++ b/packages/web/lib/components/main-renewal/SmartNav.tsx @@ -14,6 +14,8 @@ import { selectProfile, selectLogout, } from "@/lib/stores/authStore"; +import { useVtonStore } from "@/lib/stores/vtonStore"; +import { Sparkles } from "lucide-react"; const DecodedLogo = dynamic(() => import("@/lib/components/DecodedLogo"), { ssr: false, loading: () => ( @@ -50,6 +52,7 @@ export function SmartNav({ className }: SmartNavProps) { const user = useAuthStore(selectUser); const profile = useAuthStore(selectProfile); const logout = useAuthStore(selectLogout); + const openVton = useVtonStore((state) => state.open); const [isProfileOpen, setIsProfileOpen] = useState(false); const profileRef = useRef(null); @@ -172,12 +175,14 @@ export function SmartNav({ className }: SmartNavProps) { ); })} - {/* Upload — intercept route handles modal overlay vs full page */} + {/* Try On */} {/* Auth: Login / Profile Dropdown */} @@ -225,11 +230,11 @@ export function SmartNav({ className }: SmartNavProps) { + ); + })} +
+ {/* Category tabs — hidden in post mode */} - {!isPostMode && ( + {sourceMode === "items" && !isPostMode && (
{CATEGORIES.map((cat) => { const isActive = activeCategory === cat.key; @@ -100,13 +137,13 @@ export function VtonItemPanel({ {isPostMode && (

- 이 포스트의 아이템 — 여러 개 선택 가능 + Selected post items — choose one or more

)} {/* Search — hidden in post mode */} - {!isPostMode && ( + {sourceMode === "items" && !isPostMode && (
-

- {items.length} item{items.length !== 1 ? "s" : ""} -

-
- {items.map((item) => { - const isSelected = hasActiveJob - ? jobSelectedItems.some((j) => j.id === item.id) - : isPostMode - ? selectedPostItemIds.has(item.id) - : currentSelected?.id === item.id; - return ( - - ); - })} -
+
+

+ {post.title} +

+

+ {post.items.length} item + {post.items.length !== 1 ? "s" : ""} +

+
+ + ))} +
+ {!isLoadingPosts && posts.length === 0 && ( +

+ No try-on ready posts yet. +

+ )} + + ) : ( + <> +

+ {items.length} item{items.length !== 1 ? "s" : ""} +

+
+ {items.map((item) => { + const isSelected = hasActiveJob + ? jobSelectedItems.some((j) => j.id === item.id) + : isPostMode + ? selectedPostItemIds.has(item.id) + : currentSelected?.id === item.id; + return ( + + ); + })} +
+ + )}
{/* Selected summary + Try On */} diff --git a/packages/web/lib/components/vton/VtonModal.tsx b/packages/web/lib/components/vton/VtonModal.tsx index 29947628..612a14ee 100644 --- a/packages/web/lib/components/vton/VtonModal.tsx +++ b/packages/web/lib/components/vton/VtonModal.tsx @@ -10,6 +10,7 @@ import { type ItemData, type Category, } from "@/lib/hooks/useVtonItemFetch"; +import { useVtonPostFetch } from "@/lib/hooks/useVtonPostFetch"; import { useVtonTryOn } from "@/lib/hooks/useVtonTryOn"; import { VtonPhotoArea } from "./VtonPhotoArea"; import { VtonItemPanel } from "./VtonItemPanel"; @@ -21,6 +22,8 @@ export function VtonModal() { const close = useVtonStore((s) => s.close); const sourcePostId = useVtonStore((s) => s.sourcePostId); const preloadedItems = useVtonStore((s) => s.preloadedItems); + const setSourcePost = useVtonStore((s) => s.setSourcePost); + const clearSourcePost = useVtonStore((s) => s.clearSourcePost); const backgroundJob = useVtonStore((s) => s.backgroundJob); const startBackgroundJob = useVtonStore((s) => s.startBackgroundJob); const completeBackgroundJob = useVtonStore((s) => s.completeBackgroundJob); @@ -42,6 +45,7 @@ export function VtonModal() { const [loadingStage, setLoadingStage] = useState(0); const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(""); + const [sourceMode, setSourceMode] = useState<"items" | "posts">("items"); const [selectedPostItemIds, setSelectedPostItemIds] = useState>( new Set() ); @@ -78,6 +82,7 @@ export function VtonModal() { searchQuery, isPostMode ? preloadedItems : undefined ); + const { posts, isLoadingPosts } = useVtonPostFetch(isOpen); const selectedList = useMemo(() => { if (hasActiveJob && jobSelectedItems.length > 0) return jobSelectedItems; @@ -94,6 +99,10 @@ export function VtonModal() { ]); const selectedCount = selectedList.length; + useEffect(() => { + if (isOpen && isPostMode) setSourceMode("posts"); + }, [isOpen, isPostMode]); + // Sync loading from background job on re-open useEffect(() => { if (isOpen && backgroundJob?.status === "processing") { @@ -189,6 +198,7 @@ export function VtonModal() { setError(null); setSearchQuery(""); setActiveCategory("tops"); + setSourceMode("items"); }, 300); } }, [close, loading]); @@ -255,6 +265,29 @@ export function VtonModal() { [isPostMode, selectedItems] ); + const handleSourceModeChange = useCallback( + (mode: "items" | "posts") => { + setSourceMode(mode); + setSearchQuery(""); + if (mode === "items") { + clearSourcePost(); + setSelectedPostItemIds(new Set()); + } + }, + [clearSourcePost] + ); + + const handleSelectPost = useCallback( + (postId: string, postItems: ItemData[]) => { + setSourcePost(postId, postItems); + setSelectedPostItemIds(new Set(postItems.map((item) => item.id))); + setSelectedItems({ tops: null, bottoms: null }); + setSourceMode("posts"); + setError(null); + }, + [setSourcePost] + ); + if (!isOpen) return null; return ( @@ -288,6 +321,7 @@ export function VtonModal() { onReset={handleReset} /> { setActiveCategory(cat); setSearchQuery(""); }} onSearchChange={setSearchQuery} onSelectItem={handleSelectItem} + onSelectPost={handleSelectPost} onDeselect={handleDeselect} onTryOn={handleTryOn} /> diff --git a/packages/web/lib/components/vton/__tests__/VtonLab.test.tsx b/packages/web/lib/components/vton/__tests__/VtonLab.test.tsx index b9494d68..b7d3e292 100644 --- a/packages/web/lib/components/vton/__tests__/VtonLab.test.tsx +++ b/packages/web/lib/components/vton/__tests__/VtonLab.test.tsx @@ -1,6 +1,6 @@ /* @vitest-environment jsdom */ -import { render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { VtonLab } from "../VtonLab"; import { VtonModal } from "../VtonModal"; @@ -22,12 +22,7 @@ describe("VtonLab", () => { preloadedItems: [], backgroundJob: null, }); - vi.stubGlobal( - "fetch", - vi.fn(async () => ({ - json: async () => ({ items: [] }), - })) - ); + vi.stubGlobal("fetch", vi.fn(mockVtonFetch)); }); afterEach(() => { @@ -48,4 +43,69 @@ describe("VtonLab", () => { expect(screen.getByTestId("vton-item-panel")).toBeInTheDocument(); expect(screen.queryByText(/Select Item \(/i)).not.toBeInTheDocument(); }); + + it("lets users choose a post and preselect its try-on items", async () => { + render( + <> + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("vton-modal")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Posts" })); + fireEvent.click(await screen.findByRole("button", { name: /Stage Fit/i })); + + expect( + screen.getByText("Selected post items — choose one or more") + ).toBeInTheDocument(); + expect(screen.getByText("Post Jacket")).toBeInTheDocument(); + expect(screen.getByText("Post Pants")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /Try On 2 Items/i }) + ).toBeDisabled(); + }); }); + +async function mockVtonFetch(input: RequestInfo | URL) { + const url = String(input); + if (url.includes("/api/v1/vton/posts")) { + return { + json: async () => ({ + posts: [ + { + id: "post-tryon-1", + title: "Stage Fit", + image_url: "https://example.com/post.jpg", + artist_name: "Artist", + group_name: null, + context: null, + items: [ + { + id: "solution-top-1", + title: "Post Jacket", + thumbnail_url: "https://example.com/jacket.jpg", + description: null, + keywords: ["jacket"], + }, + { + id: "solution-bottom-1", + title: "Post Pants", + thumbnail_url: "https://example.com/pants.jpg", + description: null, + keywords: ["pants"], + }, + ], + }, + ], + }), + }; + } + + return { + json: async () => ({ items: [] }), + }; +} diff --git a/packages/web/lib/hooks/useVtonPostFetch.ts b/packages/web/lib/hooks/useVtonPostFetch.ts new file mode 100644 index 00000000..822e9349 --- /dev/null +++ b/packages/web/lib/hooks/useVtonPostFetch.ts @@ -0,0 +1,45 @@ +import { useEffect, useState } from "react"; +import type { ItemData } from "@/lib/hooks/useVtonItemFetch"; + +export interface VtonPostData { + id: string; + title: string; + image_url: string; + artist_name: string | null; + group_name: string | null; + context: string | null; + items: ItemData[]; +} + +interface UseVtonPostFetchResult { + posts: VtonPostData[]; + isLoadingPosts: boolean; +} + +export function useVtonPostFetch(isOpen: boolean): UseVtonPostFetchResult { + const [posts, setPosts] = useState([]); + const [isLoadingPosts, setIsLoadingPosts] = useState(false); + + useEffect(() => { + if (!isOpen) return; + + const controller = new AbortController(); + setIsLoadingPosts(true); + + fetch("/api/v1/vton/posts?limit=24", { signal: controller.signal }) + .then((res) => res.json()) + .then((data) => { + setPosts(data.posts || []); + setIsLoadingPosts(false); + }) + .catch((err) => { + if (err instanceof Error && err.name === "AbortError") return; + setPosts([]); + setIsLoadingPosts(false); + }); + + return () => controller.abort(); + }, [isOpen]); + + return { posts, isLoadingPosts }; +} diff --git a/packages/web/lib/stores/vtonStore.ts b/packages/web/lib/stores/vtonStore.ts index 6c028c73..4d841438 100644 --- a/packages/web/lib/stores/vtonStore.ts +++ b/packages/web/lib/stores/vtonStore.ts @@ -26,6 +26,8 @@ interface VtonState { backgroundJob: VtonBackgroundJob | null; open: () => void; openWithItems: (postId: string, items: VtonPreloadItem[]) => void; + setSourcePost: (postId: string, items: VtonPreloadItem[]) => void; + clearSourcePost: () => void; /** Reopen modal without clearing state — used when background job is active */ reopen: () => void; close: () => void; @@ -52,6 +54,9 @@ export const useVtonStore = create((set) => ({ open: () => set({ isOpen: true, sourcePostId: null, preloadedItems: [] }), openWithItems: (postId, items) => set({ isOpen: true, sourcePostId: postId, preloadedItems: items }), + setSourcePost: (postId, items) => + set({ sourcePostId: postId, preloadedItems: items }), + clearSourcePost: () => set({ sourcePostId: null, preloadedItems: [] }), reopen: () => set({ isOpen: true }), close: () => set({ isOpen: false }), startBackgroundJob: (personPreview, personImageBase64, selectedItems) => { diff --git a/packages/web/tests/upload-intercept.spec.ts b/packages/web/tests/upload-intercept.spec.ts index ad8ea890..a0f9c9c3 100644 --- a/packages/web/tests/upload-intercept.spec.ts +++ b/packages/web/tests/upload-intercept.spec.ts @@ -1,60 +1,20 @@ /** - * E2E for the intercepting /request/upload modal overlay. - * - * Covers #145 PR-3 Section 4: in-app navigation (Desktop SmartNav) routes - * through the intercept, so the upload flow renders inside RequestFlowModal - * on top of the previous page. Direct URL rendering is covered by - * upload-direct.spec.ts. + * E2E for the desktop header Try On action. */ import { test, expect } from "@playwright/test"; -test.describe("Upload intercept modal", () => { - test("in-app nav opens intercept modal on /request/upload", async ({ - page, - }) => { +test.describe("Header Try On action", () => { + test("desktop nav opens the VTON modal", async ({ page }) => { await page.goto("/"); - // SmartNav Upload button (desktop). Match case-insensitive; there may be - // multiple Upload buttons (main nav + profile dropdown) — take the first. - await page - .getByRole("button", { name: /upload/i }) - .first() - .click(); + const header = page.locator("header"); + await expect(header.getByRole("button", { name: /upload/i })).toHaveCount( + 0 + ); + await header.getByRole("button", { name: /try on/i }).click(); - await expect(page).toHaveURL(/\/request\/upload/); - await expect(page.getByTestId("request-flow-modal-dialog")).toBeVisible({ + await expect(page.getByTestId("vton-modal")).toBeVisible({ timeout: 15_000, }); }); - - test("ESC closes the intercept modal and routes back", async ({ page }) => { - await page.goto("/"); - await page - .getByRole("button", { name: /upload/i }) - .first() - .click(); - - const dialog = page.getByTestId("request-flow-modal-dialog"); - await expect(dialog).toBeVisible({ timeout: 15_000 }); - - await page.keyboard.press("Escape"); - - // GSAP close timeline is ~0.2s; give the router + unmount time to settle. - await expect(dialog).toBeHidden({ timeout: 5_000 }); - await expect(page).not.toHaveURL(/\/request\/upload/); - }); - - test("backdrop click closes the intercept modal", async ({ page }) => { - await page.goto("/"); - await page - .getByRole("button", { name: /upload/i }) - .first() - .click(); - - const dialog = page.getByTestId("request-flow-modal-dialog"); - await expect(dialog).toBeVisible({ timeout: 15_000 }); - - await page.getByTestId("request-flow-modal-backdrop").click(); - await expect(dialog).toBeHidden({ timeout: 5_000 }); - }); }); diff --git a/packages/web/tests/upload-mobile.spec.ts b/packages/web/tests/upload-mobile.spec.ts index 021adf2d..f3ba5e6e 100644 --- a/packages/web/tests/upload-mobile.spec.ts +++ b/packages/web/tests/upload-mobile.spec.ts @@ -1,27 +1,21 @@ /** - * E2E for the intercept modal under mobile viewport. - * - * Covers #145 PR-4 Section 9 / D5: iPhone SE viewport should render the - * intercepting RequestFlowModal with `mobileFullScreen=true`, producing a - * dialog that spans the full viewport width. + * E2E for the mobile Try On action. */ import { test, expect, devices } from "@playwright/test"; test.use({ ...devices["iPhone SE (3rd gen)"] }); -test.describe("Upload intercept modal — mobile", () => { - test("mobile viewport → intercept modal is full-screen", async ({ page }) => { +test.describe("Header Try On action — mobile", () => { + test("mobile nav opens the VTON modal", async ({ page }) => { await page.goto("/"); - await page - .getByRole("button", { name: /upload/i }) - .first() - .click(); - const dialog = page.getByTestId("request-flow-modal-dialog"); - await expect(dialog).toBeVisible({ timeout: 15_000 }); + const nav = page.getByRole("navigation", { name: "Main navigation" }); + await expect(nav.getByRole("button", { name: /upload/i })).toHaveCount(0); + await nav.getByRole("link", { name: /try on/i }).click(); - const box = await dialog.boundingBox(); - // iPhone SE width is 375 — allow ~5px margin for scrollbar / DPR rounding. - expect(box?.width ?? 0).toBeGreaterThanOrEqual(370); + await expect(page).toHaveURL(/\/lab\/vton/); + await expect(page.getByTestId("vton-modal")).toBeVisible({ + timeout: 15_000, + }); }); }); diff --git a/supabase/seed.sql b/supabase/seed.sql new file mode 100644 index 00000000..9991672a --- /dev/null +++ b/supabase/seed.sql @@ -0,0 +1,362 @@ +-- Local development seed data. +-- This file is applied by `supabase db reset` via supabase/config.toml. + +insert into public.categories ( + id, + code, + name, + description, + display_order, + is_active +) values ( + '11111111-1111-4111-8111-111111111111', + 'wearables', + '{"ko":"웨어러블","en":"Wearables"}'::jsonb, + '{"ko":"패션 아이템","en":"Fashion and wearable items"}'::jsonb, + 1, + true +) on conflict (id) do update set + code = excluded.code, + name = excluded.name, + description = excluded.description, + display_order = excluded.display_order, + is_active = excluded.is_active, + updated_at = now(); + +insert into public.subcategories ( + id, + category_id, + code, + name, + description, + display_order, + is_active +) values + ( + '22222222-2222-4222-8222-222222222221', + '11111111-1111-4111-8111-111111111111', + 'tops', + '{"ko":"상의","en":"Tops"}'::jsonb, + '{"ko":"상의 및 아우터","en":"Tops and outerwear"}'::jsonb, + 1, + true + ), + ( + '22222222-2222-4222-8222-222222222222', + '11111111-1111-4111-8111-111111111111', + 'bottoms', + '{"ko":"하의","en":"Bottoms"}'::jsonb, + '{"ko":"하의 및 신발","en":"Bottoms and footwear"}'::jsonb, + 2, + true + ) +on conflict (id) do update set + category_id = excluded.category_id, + code = excluded.code, + name = excluded.name, + description = excluded.description, + display_order = excluded.display_order, + is_active = excluded.is_active, + updated_at = now(); + +insert into auth.users ( + instance_id, + id, + aud, + role, + email, + encrypted_password, + email_confirmed_at, + raw_app_meta_data, + raw_user_meta_data, + is_super_admin, + created_at, + updated_at, + is_sso_user, + is_anonymous +) values ( + '00000000-0000-0000-0000-000000000000', + '33333333-3333-4333-8333-333333333333', + 'authenticated', + 'authenticated', + 'vton.seed@decoded.local', + null, + now(), + '{"provider":"email","providers":["email"]}'::jsonb, + '{"name":"VTON Seed User"}'::jsonb, + false, + now(), + now(), + false, + false +) on conflict (id) do update set + email = excluded.email, + aud = excluded.aud, + role = excluded.role, + raw_app_meta_data = excluded.raw_app_meta_data, + raw_user_meta_data = excluded.raw_user_meta_data, + updated_at = now(); + +insert into public.users ( + id, + email, + username, + display_name, + avatar_url, + bio, + is_admin, + rank, + total_points, + ink_credits, + style_dna +) values ( + '33333333-3333-4333-8333-333333333333', + 'vton.seed@decoded.local', + 'vton_seed', + 'VTON Seed User', + 'https://picsum.photos/seed/decoded-vton-user/320/320', + 'Local seed user for VTON QA data.', + false, + 'Contributor', + 100, + 10, + '{"keywords":["Street","Minimal","Layered"],"colors":["#111111","#eafd67"]}'::jsonb +) on conflict (id) do update set + email = excluded.email, + username = excluded.username, + display_name = excluded.display_name, + avatar_url = excluded.avatar_url, + bio = excluded.bio, + rank = excluded.rank, + total_points = excluded.total_points, + ink_credits = excluded.ink_credits, + style_dna = excluded.style_dna, + updated_at = now(); + +insert into public.posts ( + id, + user_id, + title, + context, + artist_name, + group_name, + image_url, + image_width, + image_height, + media_type, + media_metadata, + status, + created_with_solutions, + view_count, + trending_score +) values + ( + '44444444-4444-4444-8444-444444444441', + '33333333-3333-4333-8333-333333333333', + 'Urban Layers', + 'Oversized silhouettes meet structured tailoring', + 'Decoded Local', + null, + 'https://picsum.photos/seed/grid-street/600/750', + 600, + 750, + 'image', + '{"source":"local-seed"}'::jsonb, + 'active', + true, + 120, + 0 + ), + ( + '44444444-4444-4444-8444-444444444442', + '33333333-3333-4333-8333-333333333333', + 'Digital Nostalgia', + 'Y2K aesthetics for the new era', + 'Decoded Local', + null, + 'https://picsum.photos/seed/grid-y2k/600/480', + 600, + 480, + 'image', + '{"source":"local-seed"}'::jsonb, + 'active', + true, + 95, + 0 + ) +on conflict (id) do update set + user_id = excluded.user_id, + title = excluded.title, + context = excluded.context, + artist_name = excluded.artist_name, + group_name = excluded.group_name, + image_url = excluded.image_url, + image_width = excluded.image_width, + image_height = excluded.image_height, + media_type = excluded.media_type, + media_metadata = excluded.media_metadata, + status = excluded.status, + created_with_solutions = excluded.created_with_solutions, + view_count = excluded.view_count, + trending_score = excluded.trending_score, + updated_at = now(); + +insert into public.spots ( + id, + post_id, + user_id, + subcategory_id, + position_left, + position_top, + status +) values + ( + '55555555-5555-4555-8555-555555555551', + '44444444-4444-4444-8444-444444444441', + '33333333-3333-4333-8333-333333333333', + '22222222-2222-4222-8222-222222222221', + '35%', + '42%', + 'active' + ), + ( + '55555555-5555-4555-8555-555555555552', + '44444444-4444-4444-8444-444444444441', + '33333333-3333-4333-8333-333333333333', + '22222222-2222-4222-8222-222222222222', + '60%', + '78%', + 'active' + ), + ( + '55555555-5555-4555-8555-555555555553', + '44444444-4444-4444-8444-444444444442', + '33333333-3333-4333-8333-333333333333', + '22222222-2222-4222-8222-222222222221', + '50%', + '35%', + 'active' + ) +on conflict (id) do update set + post_id = excluded.post_id, + user_id = excluded.user_id, + subcategory_id = excluded.subcategory_id, + position_left = excluded.position_left, + position_top = excluded.position_top, + status = excluded.status, + updated_at = now(); + +insert into public.solutions ( + id, + spot_id, + user_id, + title, + thumbnail_url, + original_url, + affiliate_url, + description, + keywords, + metadata, + link_type, + match_type, + status, + is_verified, + is_adopted, + accurate_count, + different_count, + click_count, + purchase_count, + price_amount, + price_currency +) values + ( + '66666666-6666-4666-8666-666666666661', + '55555555-5555-4555-8555-555555555551', + '33333333-3333-4333-8333-333333333333', + 'COS Wool Overcoat', + 'https://picsum.photos/seed/vton-cos-overcoat/500/650', + 'https://example.com/cos-wool-overcoat', + null, + 'Structured wool overcoat for local VTON QA.', + '["overcoat","outerwear","tops","street"]'::jsonb, + '{"brand":"COS","source":"local-seed"}'::jsonb, + 'product', + 'manual', + 'active', + true, + true, + 10, + 0, + 0, + 0, + 389000, + 'KRW' + ), + ( + '66666666-6666-4666-8666-666666666662', + '55555555-5555-4555-8555-555555555552', + '33333333-3333-4333-8333-333333333333', + 'New Balance 990v6', + 'https://picsum.photos/seed/vton-nb-990v6/500/500', + 'https://example.com/new-balance-990v6', + null, + 'Heritage sneaker for local VTON QA.', + '["sneakers","bottoms","street"]'::jsonb, + '{"brand":"New Balance","source":"local-seed"}'::jsonb, + 'product', + 'manual', + 'active', + true, + true, + 8, + 0, + 0, + 0, + 269000, + 'KRW' + ), + ( + '66666666-6666-4666-8666-666666666663', + '55555555-5555-4555-8555-555555555553', + '33333333-3333-4333-8333-333333333333', + 'Miu Miu Crystal Top', + 'https://picsum.photos/seed/vton-miumiu-crystal-top/500/600', + 'https://example.com/miumiu-crystal-top', + null, + 'Crystal top for local VTON QA.', + '["top","y2k","statement"]'::jsonb, + '{"brand":"Miu Miu","source":"local-seed"}'::jsonb, + 'product', + 'manual', + 'active', + true, + true, + 7, + 0, + 0, + 0, + 1850000, + 'KRW' + ) +on conflict (id) do update set + spot_id = excluded.spot_id, + user_id = excluded.user_id, + title = excluded.title, + thumbnail_url = excluded.thumbnail_url, + original_url = excluded.original_url, + affiliate_url = excluded.affiliate_url, + description = excluded.description, + keywords = excluded.keywords, + metadata = excluded.metadata, + link_type = excluded.link_type, + match_type = excluded.match_type, + status = excluded.status, + is_verified = excluded.is_verified, + is_adopted = excluded.is_adopted, + accurate_count = excluded.accurate_count, + different_count = excluded.different_count, + click_count = excluded.click_count, + purchase_count = excluded.purchase_count, + price_amount = excluded.price_amount, + price_currency = excluded.price_currency, + updated_at = now(); From 23a7eb858b07c090c4fb1f6f95a0d40e8a975a58 Mon Sep 17 00:00:00 2001 From: thxforall <113906780+thxforall@users.noreply.github.com> Date: Thu, 7 May 2026 15:20:41 +0900 Subject: [PATCH 06/15] fix(web): use local supabase fallback for posts --- .../app/api/v1/posts/__tests__/route.test.ts | 69 ++++++++++++++++++ packages/web/app/api/v1/posts/route.ts | 25 +++++-- packages/web/app/api/v1/users/me/route.ts | 72 ++++++++++++++++++- packages/web/lib/server-env.ts | 4 ++ 4 files changed, 162 insertions(+), 8 deletions(-) diff --git a/packages/web/app/api/v1/posts/__tests__/route.test.ts b/packages/web/app/api/v1/posts/__tests__/route.test.ts index e5cc6589..23f0bf29 100644 --- a/packages/web/app/api/v1/posts/__tests__/route.test.ts +++ b/packages/web/app/api/v1/posts/__tests__/route.test.ts @@ -1,9 +1,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const createPostInSupabaseMock = vi.fn(); +const createSupabaseServerClientMock = vi.fn(); vi.mock("@/lib/server-env", () => ({ API_BASE_URL: "", + IS_LOCAL_DATABASE: false, + USE_SUPABASE_FALLBACK: false, +})); + +vi.mock("@/lib/supabase/server", () => ({ + createSupabaseServerClient: createSupabaseServerClientMock, })); vi.mock("../local-create", () => ({ @@ -24,6 +31,68 @@ function makeJsonRequest(body: unknown, authHeader = "Bearer token") { beforeEach(() => { vi.resetModules(); createPostInSupabaseMock.mockReset(); + createSupabaseServerClientMock.mockReset(); +}); + +function makeGetRequest( + url = "http://localhost/api/v1/posts?page=1&per_page=3" +) { + return new Request(url) as unknown as import("next/server").NextRequest; +} + +function createSupabaseQueryMock(response: unknown) { + const query = { + select: vi.fn(() => query), + eq: vi.fn(() => query), + not: vi.fn(() => query), + ilike: vi.fn(() => query), + order: vi.fn(() => query), + range: vi.fn(() => query), + then: (resolve: (value: unknown) => unknown) => + Promise.resolve(response).then(resolve), + }; + return query; +} + +describe("GET /api/v1/posts", () => { + it("uses Supabase fallback when backend API base URL is not configured", async () => { + const query = createSupabaseQueryMock({ + data: [ + { + id: "post-1", + image_url: "https://cdn.example.com/post.jpg", + artist_name: "Artist", + group_name: null, + context: "Street", + created_at: "2026-05-07T00:00:00Z", + view_count: 12, + media_type: "image", + title: "Seeded Post", + post_magazines: null, + users: { + id: "user-1", + username: "seed_user", + avatar_url: null, + rank: "member", + }, + }, + ], + count: 1, + error: null, + }); + createSupabaseServerClientMock.mockResolvedValue({ + from: vi.fn(() => query), + }); + + const { GET } = await import("../route"); + const res = await GET(makeGetRequest()); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(res.headers.get("X-Source")).toBe("supabase-fallback"); + expect(json.data).toHaveLength(1); + expect(json.data[0].title).toBe("Seeded Post"); + }); }); describe("POST /api/v1/posts", () => { diff --git a/packages/web/app/api/v1/posts/route.ts b/packages/web/app/api/v1/posts/route.ts index e22dda98..eff94da6 100644 --- a/packages/web/app/api/v1/posts/route.ts +++ b/packages/web/app/api/v1/posts/route.ts @@ -11,12 +11,16 @@ // the createPostUnified branching helper. import { NextRequest, NextResponse } from "next/server"; -import { API_BASE_URL, USE_SUPABASE_FALLBACK } from "@/lib/server-env"; +import { + API_BASE_URL, + IS_LOCAL_DATABASE, + USE_SUPABASE_FALLBACK, +} from "@/lib/server-env"; import { createSupabaseServerClient } from "@/lib/supabase/server"; import { createPostInSupabase } from "./local-create"; /** - * Build a 502 response for when the backend is unreachable and the + * Build a 502 response for when a configured backend is unreachable and the * Supabase fallback is disabled. Avoids silently masking backend errors * in dev/staging — opt in via USE_SUPABASE_FALLBACK=true. (#383) */ @@ -37,9 +41,17 @@ function backendUnavailableResponse(detail: string) { * Fetch posts list with optional query parameters */ export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + + const shouldUseSupabaseFallback = + !API_BASE_URL || USE_SUPABASE_FALLBACK || IS_LOCAL_DATABASE; + + if (!API_BASE_URL) { + return supabaseFallback(searchParams); + } + try { // Forward query parameters - const { searchParams } = new URL(request.url); const queryString = searchParams.toString(); const url = queryString ? `${API_BASE_URL}/api/v1/posts?${queryString}` @@ -72,7 +84,7 @@ export async function GET(request: NextRequest) { } // Backend failed → fall back to Supabase only when explicitly enabled (#383) - if (!USE_SUPABASE_FALLBACK) { + if (!shouldUseSupabaseFallback) { return NextResponse.json(data, { status: response.status }); } console.warn( @@ -84,12 +96,11 @@ export async function GET(request: NextRequest) { if (process.env.NODE_ENV === "development") { console.warn("Posts GET proxy error:", error); } - if (!USE_SUPABASE_FALLBACK) { + if (!shouldUseSupabaseFallback) { return backendUnavailableResponse( error instanceof Error ? error.message : String(error) ); } - const { searchParams } = new URL(request.url); return supabaseFallback(searchParams); } } @@ -170,7 +181,7 @@ async function supabaseFallback(searchParams: URLSearchParams) { view_count: post.view_count ?? 0, spot_count: 0, comment_count: 0, - title: post.post_magazines?.title ?? null, + title: post.title ?? post.post_magazines?.title ?? null, post_magazine_title: post.post_magazines?.title ?? null, media_source: { type: post.media_type ?? "unknown", description: null }, user: post.users diff --git a/packages/web/app/api/v1/users/me/route.ts b/packages/web/app/api/v1/users/me/route.ts index 038cc60c..1852b83b 100644 --- a/packages/web/app/api/v1/users/me/route.ts +++ b/packages/web/app/api/v1/users/me/route.ts @@ -7,7 +7,69 @@ */ import { NextRequest, NextResponse } from "next/server"; -import { API_BASE_URL } from "@/lib/server-env"; +import { + API_BASE_URL, + IS_LOCAL_DATABASE, + USE_SUPABASE_FALLBACK, +} from "@/lib/server-env"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; +import type { UserRow } from "@/lib/supabase/types"; + +function tokenFromAuthHeader(authHeader: string): string { + return authHeader.replace(/^Bearer\s+/i, "").trim(); +} + +function userResponseFromRow(row: UserRow) { + return { + id: row.id, + email: row.email, + username: row.username, + display_name: row.display_name, + avatar_url: row.avatar_url, + bio: row.bio, + rank: row.rank, + total_points: row.total_points, + ink_credits: row.ink_credits, + is_admin: row.is_admin, + style_dna: row.style_dna, + followers_count: 0, + following_count: 0, + }; +} + +async function fetchSupabaseProfile(authHeader: string) { + const supabase = await createSupabaseServerClient(); + const token = tokenFromAuthHeader(authHeader); + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(token || undefined); + + if (authError || !user) { + return NextResponse.json( + { message: "Authentication required" }, + { status: 401 } + ); + } + + const { data, error } = await supabase + .from("users") + .select( + "id,email,username,display_name,avatar_url,bio,rank,total_points,ink_credits,is_admin,style_dna" + ) + .eq("id", user.id) + .maybeSingle(); + + if (error) { + throw new Error(error.message); + } + + if (!data) { + return NextResponse.json({ message: "User not found" }, { status: 404 }); + } + + return NextResponse.json(userResponseFromRow(data as UserRow)); +} /** * GET /api/v1/users/me @@ -22,6 +84,10 @@ export async function GET(request: NextRequest) { ); } + if (!API_BASE_URL) { + return fetchSupabaseProfile(authHeader); + } + try { const response = await fetch(`${API_BASE_URL}/api/v1/users/me`, { method: "GET", @@ -42,6 +108,10 @@ export async function GET(request: NextRequest) { } return NextResponse.json(data, { status: response.status }); } catch (error) { + if (USE_SUPABASE_FALLBACK || IS_LOCAL_DATABASE) { + return fetchSupabaseProfile(authHeader); + } + if (process.env.NODE_ENV === "development") { console.error("Users/me GET proxy error:", error); } diff --git a/packages/web/lib/server-env.ts b/packages/web/lib/server-env.ts index 66e5f57a..5407ea76 100644 --- a/packages/web/lib/server-env.ts +++ b/packages/web/lib/server-env.ts @@ -11,6 +11,10 @@ function getEnvOrEmpty(name: string): string { * (#446 fixup) — chat 도 동일 원칙. AI_SERVER_HTTP_URL 도 제거됨. */ export const API_BASE_URL = getEnvOrEmpty("API_BASE_URL"); +const DATABASE_API_URL = getEnvOrEmpty("NEXT_PUBLIC_DATABASE_API_URL"); +export const IS_LOCAL_DATABASE = + DATABASE_API_URL.startsWith("http://127.0.0.1:") || + DATABASE_API_URL.startsWith("http://localhost:"); /** * When true, API routes that proxy to the Rust backend may transparently From 6a0316a429bedd87bdb4f68d0bdd1932207e3504 Mon Sep 17 00:00:00 2001 From: thxforall <113906780+thxforall@users.noreply.github.com> Date: Thu, 7 May 2026 15:25:02 +0900 Subject: [PATCH 07/15] fix(web): serve local vton seed images --- .../web/app/api/v1/vton/placeholder/route.ts | 61 +++++++++++++++++++ .../web/lib/components/vton/VtonItemPanel.tsx | 30 +++++++++ supabase/seed.sql | 12 ++-- 3 files changed, 97 insertions(+), 6 deletions(-) create mode 100644 packages/web/app/api/v1/vton/placeholder/route.ts diff --git a/packages/web/app/api/v1/vton/placeholder/route.ts b/packages/web/app/api/v1/vton/placeholder/route.ts new file mode 100644 index 00000000..90afbbc9 --- /dev/null +++ b/packages/web/app/api/v1/vton/placeholder/route.ts @@ -0,0 +1,61 @@ +import { NextRequest } from "next/server"; + +function escapeXml(value: string) { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function colorFromSeed(seed: string) { + let hash = 0; + for (let index = 0; index < seed.length; index += 1) { + hash = (hash * 31 + seed.charCodeAt(index)) % 360; + } + return { + primary: `hsl(${hash}, 72%, 54%)`, + secondary: `hsl(${(hash + 52) % 360}, 62%, 36%)`, + }; +} + +export function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const title = searchParams.get("title") || "Decoded"; + const kind = searchParams.get("kind") || "item"; + const width = Number(searchParams.get("w")) || 600; + const height = Number(searchParams.get("h")) || 750; + const { primary, secondary } = colorFromSeed(`${kind}:${title}`); + const label = escapeXml(title); + const eyebrow = escapeXml(kind.toUpperCase()); + + const svg = ` + + + + + + + + + + + + + + + + + ${eyebrow} + +
${label}
+
+
`; + + return new Response(svg, { + headers: { + "Content-Type": "image/svg+xml; charset=utf-8", + "Cache-Control": "public, max-age=31536000, immutable", + }, + }); +} diff --git a/packages/web/lib/components/vton/VtonItemPanel.tsx b/packages/web/lib/components/vton/VtonItemPanel.tsx index dc23afa8..1b6ad207 100644 --- a/packages/web/lib/components/vton/VtonItemPanel.tsx +++ b/packages/web/lib/components/vton/VtonItemPanel.tsx @@ -36,6 +36,27 @@ interface VtonItemPanelProps { onTryOn: () => void; } +function fallbackImageUrl(title: string, kind: "item" | "post") { + const params = new URLSearchParams({ + title, + kind, + w: kind === "post" ? "600" : "500", + h: kind === "post" ? "750" : "500", + }); + return `/api/v1/vton/placeholder?${params}`; +} + +function handleImageError( + event: React.SyntheticEvent, + title: string, + kind: "item" | "post" +) { + const img = event.currentTarget; + const fallback = fallbackImageUrl(title, kind); + if (img.src.endsWith(fallback)) return; + img.src = fallback; +} + export function VtonItemPanel({ sourceMode, isPostMode, @@ -186,6 +207,9 @@ export function VtonItemPanel({ {post.title} + handleImageError(event, post.title, "post") + } className="h-full w-full object-cover" />
@@ -233,6 +257,9 @@ export function VtonItemPanel({ {item.title} + handleImageError(event, item.title, "item") + } className="h-full w-full object-cover" /> @@ -276,6 +303,9 @@ export function VtonItemPanel({ {item.title} + handleImageError(event, item.title, "item") + } className="h-12 w-12 rounded-lg border border-[#eafd67]/50 object-cover" /> + ))} + + {/* Sentinel for infinite scroll trigger */} +
+ + {/* Loading more indicator */} + {isFetchingNextPage && ( +
+ {Array.from({ length: 2 }).map((_, i) => ( +
+ ))}
- - ))} - - {/* Sentinel for infinite scroll trigger */} -
- - {/* Loading more indicator */} - {isFetchingNextPage && ( -
- {Array.from({ length: 2 }).map((_, i) => ( -
- ))} -
- )} -
+ )} +
+ {selectedTry ? ( + setSelectedTry(null)} + /> + ) : null} + ); } diff --git a/packages/web/lib/components/profile/TryDetailModal.tsx b/packages/web/lib/components/profile/TryDetailModal.tsx new file mode 100644 index 00000000..e431c69d --- /dev/null +++ b/packages/web/lib/components/profile/TryDetailModal.tsx @@ -0,0 +1,196 @@ +"use client"; + +import { useEffect } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { X } from "lucide-react"; +import type { TryItem } from "@/lib/api/generated/models"; +import { BeforeAfterSlider } from "@/lib/components/vton/BeforeAfterSlider"; + +interface TryDetailModalProps { + tryItem: TryItem; + onClose: () => void; +} + +export function TryDetailModal({ tryItem, onClose }: TryDetailModalProps) { + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") onClose(); + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [onClose]); + + const savedDate = new Date(tryItem.created_at).toLocaleDateString("ko-KR", { + year: "numeric", + month: "short", + day: "numeric", + }); + + return ( +
{ + if (event.target === event.currentTarget) onClose(); + }} + > +
+ + +
+
+
+ {tryItem.person_original_image ? ( + + ) : ( + Try-on result + )} + {tryItem.person_original_image ? ( +
+ + drag to compare + +
+ ) : null} +
+
+ {tryItem.person_original_image ? "Original / Result" : "Result"} +
+
+ + {!tryItem.person_original_image ? ( +
+ Original image unavailable +
+ ) : null} + + {tryItem.source_post?.image_url ? ( +
+
+ Source post +
+
+ Source post +
+
+ ) : ( +
+ Source post unavailable +
+ )} +
+ + +
+
+ ); +} diff --git a/packages/web/lib/components/profile/__tests__/TriesGrid.test.tsx b/packages/web/lib/components/profile/__tests__/TriesGrid.test.tsx new file mode 100644 index 00000000..ca5e7b8b --- /dev/null +++ b/packages/web/lib/components/profile/__tests__/TriesGrid.test.tsx @@ -0,0 +1,64 @@ +/** + * @vitest-environment jsdom + */ +/* eslint-disable @next/next/no-img-element */ +import React from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { TriesGrid } from "../TriesGrid"; + +vi.mock("next/image", () => ({ + default: (props: React.ImgHTMLAttributes) => ( + + ), +})); + +vi.mock("@/lib/api/generated/users/users", () => ({ + getMyTries: vi.fn(async () => ({ + data: [ + { + id: "try-1", + image_url: "https://example.com/result.png", + created_at: "2026-05-07T00:00:00Z", + source_post_id: null, + selected_item_ids: [], + person_original_image: null, + source_post: null, + selected_items: [], + }, + ], + pagination: { + current_page: 1, + per_page: 20, + total: 1, + total_pages: 1, + }, + })), +})); + +function renderGrid() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + + ); +} + +describe("TriesGrid", () => { + it("opens the detail modal when a try card is clicked", async () => { + renderGrid(); + + await waitFor(() => + expect(screen.getByAltText("Try-on result")).toBeInTheDocument() + ); + fireEvent.click(screen.getByRole("button", { name: /open try-on/i })); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByText("Try-on details")).toBeInTheDocument(); + }); +}); diff --git a/packages/web/lib/components/profile/__tests__/TryDetailModal.test.tsx b/packages/web/lib/components/profile/__tests__/TryDetailModal.test.tsx new file mode 100644 index 00000000..fafd71e5 --- /dev/null +++ b/packages/web/lib/components/profile/__tests__/TryDetailModal.test.tsx @@ -0,0 +1,89 @@ +/** + * @vitest-environment jsdom + */ +/* eslint-disable @next/next/no-img-element */ +import React from "react"; +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { TryDetailModal } from "../TryDetailModal"; +import type { TryItem } from "@/lib/api/generated/models"; + +vi.mock("next/image", () => ({ + default: (props: React.ImgHTMLAttributes) => ( + + ), +})); + +const fullTry: TryItem = { + id: "try-1", + image_url: "https://example.com/result.png", + created_at: "2026-05-07T00:00:00Z", + source_post_id: "post-1", + selected_item_ids: ["item-1"], + person_original_image: "https://example.com/person.png", + source_post: { + id: "post-1", + title: "Source Look", + image_url: "https://example.com/post.png", + artist_name: "Artist", + group_name: null, + context: "Stage look", + }, + selected_items: [ + { + id: "item-1", + title: "Jacket", + thumbnail_url: "https://example.com/jacket.png", + description: "Black jacket", + keywords: ["outerwear"], + }, + ], +}; + +describe("TryDetailModal", () => { + it("renders comparison slider, source post, and selected items", () => { + render( {}} />); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByAltText("Result")).toBeInTheDocument(); + expect(screen.getByAltText("Original")).toBeInTheDocument(); + expect(screen.getByText("drag to compare")).toBeInTheDocument(); + expect(screen.getByText("Source Look")).toBeInTheDocument(); + expect(screen.getByText("Jacket")).toBeInTheDocument(); + expect(screen.getByText("outerwear")).toBeInTheDocument(); + }); + + it("calls onClose from the close button", () => { + const onClose = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: /close/i })); + + expect(onClose).toHaveBeenCalled(); + }); + + it("renders fallback text when metadata is missing", () => { + render( + {}} + /> + ); + + expect(screen.getByText("Original image unavailable")).toBeInTheDocument(); + expect( + screen.getAllByText("Source post unavailable").length + ).toBeGreaterThan(0); + expect(screen.getByText("Items unavailable")).toBeInTheDocument(); + }); +}); diff --git a/packages/web/lib/components/vton/VtonModal.tsx b/packages/web/lib/components/vton/VtonModal.tsx index 612a14ee..5669cd88 100644 --- a/packages/web/lib/components/vton/VtonModal.tsx +++ b/packages/web/lib/components/vton/VtonModal.tsx @@ -83,6 +83,19 @@ export function VtonModal() { isPostMode ? preloadedItems : undefined ); const { posts, isLoadingPosts } = useVtonPostFetch(isOpen); + const sourcePostSnapshot = useMemo(() => { + if (!sourcePostId) return null; + const post = posts.find((entry) => entry.id === sourcePostId); + if (!post) return null; + return { + id: post.id, + title: post.title, + image_url: post.image_url, + artist_name: post.artist_name, + group_name: post.group_name, + context: post.context, + }; + }, [posts, sourcePostId]); const selectedList = useMemo(() => { if (hasActiveJob && jobSelectedItems.length > 0) return jobSelectedItems; @@ -146,6 +159,7 @@ export function VtonModal() { personPreview, selectedItems: selectedList, sourcePostId, + sourcePostSnapshot, displayResultImage, abortControllerRef, onTryOnStart: () => { diff --git a/packages/web/lib/hooks/useVtonTryOn.ts b/packages/web/lib/hooks/useVtonTryOn.ts index 0ab7fb06..f476e9af 100644 --- a/packages/web/lib/hooks/useVtonTryOn.ts +++ b/packages/web/lib/hooks/useVtonTryOn.ts @@ -1,6 +1,11 @@ import { useState, useCallback } from "react"; import { toast } from "sonner"; import type { ItemData } from "@/lib/hooks/useVtonItemFetch"; +import type { VtonPostData } from "@/lib/hooks/useVtonPostFetch"; +import { + hasSupabaseBrowserConfig, + supabaseBrowserClient, +} from "@/lib/supabase/client"; async function dataUrlToBlob(dataUrl: string): Promise { const [header, base64] = dataUrl.split(","); @@ -24,11 +29,20 @@ async function copyImageToClipboard(dataUrl: string) { export { dataUrlToBlob, copyImageToClipboard }; +async function getBrowserAccessToken(): Promise { + if (!hasSupabaseBrowserConfig()) return null; + const { + data: { session }, + } = await supabaseBrowserClient.auth.getSession(); + return session?.access_token ?? null; +} + interface UseVtonTryOnOptions { personImage: string | null; personPreview: string | null; selectedItems: ItemData[]; sourcePostId: string | null; + sourcePostSnapshot: Omit | null; displayResultImage: string | null; abortControllerRef: React.RefObject; onTryOnStart: () => void; @@ -61,6 +75,7 @@ export function useVtonTryOn(options: UseVtonTryOnOptions): UseVtonTryOnResult { personPreview, selectedItems, sourcePostId, + sourcePostSnapshot, displayResultImage, abortControllerRef, onTryOnStart, @@ -131,13 +146,26 @@ export function useVtonTryOn(options: UseVtonTryOnOptions): UseVtonTryOnResult { abortControllerRef.current = new AbortController(); try { + const accessToken = await getBrowserAccessToken(); const res = await fetch("/api/v1/tries", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + }, body: JSON.stringify({ result_image: displayResultImage, + person_original_image: personPreview, source_post_id: sourcePostId, selected_item_ids: selectedItems.map((i) => i.id), + source_post_snapshot: sourcePostSnapshot, + selected_items_snapshot: selectedItems.map((item) => ({ + id: item.id, + title: item.title, + thumbnail_url: item.thumbnail_url, + description: item.description, + keywords: item.keywords, + })), }), signal: abortControllerRef.current.signal, }); @@ -159,8 +187,10 @@ export function useVtonTryOn(options: UseVtonTryOnOptions): UseVtonTryOnResult { } }, [ displayResultImage, + personPreview, savedToProfile, sourcePostId, + sourcePostSnapshot, selectedItems, abortControllerRef, ]); From b5ca3eab31fc2c0588293a95402bf42438673de2 Mon Sep 17 00:00:00 2001 From: philippe Date: Thu, 7 May 2026 21:02:06 +0900 Subject: [PATCH 11/15] fix(raw_posts): preserve vision log thumbnails (#490) Store the judged image URL with vision filter audit rows so Instagram skip decisions can still render admin thumbnails after raw_posts cleanup. Co-authored-by: Cursor --- .../ai-server/src/services/raw_posts/repository.py | 6 ++++-- .../ai-server/src/services/raw_posts/scheduler.py | 1 + packages/api-server/src/domains/raw_posts/dto.rs | 1 + packages/api-server/src/domains/raw_posts/service.rs | 7 +++++-- packages/web/lib/api/admin/raw-post-sources.ts | 1 + .../admin/raw-post-sources/VisionFilterLog.tsx | 3 +++ .../20260507133000_vision_filter_log_image_url.sql | 11 +++++++++++ 7 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 supabase-assets/migrations/20260507133000_vision_filter_log_image_url.sql diff --git a/packages/ai-server/src/services/raw_posts/repository.py b/packages/ai-server/src/services/raw_posts/repository.py index c6fae840..7f291ab0 100644 --- a/packages/ai-server/src/services/raw_posts/repository.py +++ b/packages/ai-server/src/services/raw_posts/repository.py @@ -1082,6 +1082,7 @@ async def insert_vision_filter_log( platform: str, external_id: str, source_identifier: Optional[str], + image_url: Optional[str], verdict: str, confidence: Optional[float], reason: Optional[str], @@ -1097,8 +1098,8 @@ async def insert_vision_filter_log( """ INSERT INTO public.vision_filter_log (platform, external_id, source_identifier, verdict, - confidence, reason, model) - VALUES ($1, $2, $3, $4, $5, $6, $7) + confidence, reason, model, image_url) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) """, platform, external_id, @@ -1107,6 +1108,7 @@ async def insert_vision_filter_log( confidence, (reason or None), model, + image_url, ) # ---- #368 cycle running indicator ----------------------------------- diff --git a/packages/ai-server/src/services/raw_posts/scheduler.py b/packages/ai-server/src/services/raw_posts/scheduler.py index 113ebbe5..ab335ebe 100644 --- a/packages/ai-server/src/services/raw_posts/scheduler.py +++ b/packages/ai-server/src/services/raw_posts/scheduler.py @@ -572,6 +572,7 @@ async def _vision_filter_cycle(self) -> None: platform=row.platform, external_id=row.external_id, source_identifier=row.external_id, # pin 일 경우 동일 + image_url=row.image_url, verdict=verdict, confidence=judgment.confidence, reason=judgment.reason, diff --git a/packages/api-server/src/domains/raw_posts/dto.rs b/packages/api-server/src/domains/raw_posts/dto.rs index 8044ba77..8dcf45f4 100644 --- a/packages/api-server/src/domains/raw_posts/dto.rs +++ b/packages/api-server/src/domains/raw_posts/dto.rs @@ -317,6 +317,7 @@ pub struct VisionFilterLogEntry { pub platform: String, pub external_id: String, pub source_identifier: Option, + pub image_url: Option, pub verdict: String, pub confidence: Option, pub reason: Option, diff --git a/packages/api-server/src/domains/raw_posts/service.rs b/packages/api-server/src/domains/raw_posts/service.rs index 33c19f62..1cc47f72 100644 --- a/packages/api-server/src/domains/raw_posts/service.rs +++ b/packages/api-server/src/domains/raw_posts/service.rs @@ -1057,7 +1057,7 @@ pub async fn list_vision_filter_log( Statement::from_sql_and_values( sea_orm::DatabaseBackend::Postgres, "SELECT id, platform, external_id, source_identifier, verdict, \ - confidence, reason, model, judged_at \ + image_url, confidence, reason, model, judged_at \ FROM public.vision_filter_log \ WHERE verdict = $1 \ ORDER BY judged_at DESC \ @@ -1068,7 +1068,7 @@ pub async fn list_vision_filter_log( Statement::from_sql_and_values( sea_orm::DatabaseBackend::Postgres, "SELECT id, platform, external_id, source_identifier, verdict, \ - confidence, reason, model, judged_at \ + image_url, confidence, reason, model, judged_at \ FROM public.vision_filter_log \ ORDER BY judged_at DESC \ LIMIT $1", @@ -1090,6 +1090,9 @@ pub async fn list_vision_filter_log( source_identifier: row .try_get("", "source_identifier") .map_err(AppError::DatabaseError)?, + image_url: row + .try_get("", "image_url") + .map_err(AppError::DatabaseError)?, verdict: row .try_get("", "verdict") .map_err(AppError::DatabaseError)?, diff --git a/packages/web/lib/api/admin/raw-post-sources.ts b/packages/web/lib/api/admin/raw-post-sources.ts index c7c5edcf..0b6e4783 100644 --- a/packages/web/lib/api/admin/raw-post-sources.ts +++ b/packages/web/lib/api/admin/raw-post-sources.ts @@ -206,6 +206,7 @@ export interface VisionFilterLogEntry { platform: string; external_id: string; source_identifier: string | null; + image_url: string | null; verdict: "pass" | "skip"; confidence: number | null; reason: string | null; diff --git a/packages/web/lib/components/admin/raw-post-sources/VisionFilterLog.tsx b/packages/web/lib/components/admin/raw-post-sources/VisionFilterLog.tsx index 8d00c050..8fe74c13 100644 --- a/packages/web/lib/components/admin/raw-post-sources/VisionFilterLog.tsx +++ b/packages/web/lib/components/admin/raw-post-sources/VisionFilterLog.tsx @@ -42,6 +42,9 @@ function postUrl(platform: string, externalId: string): string | null { } function previewSrc(entry: VisionFilterLogEntry): string | null { + if (entry.platform === "instagram" && entry.image_url) { + return entry.image_url; + } if (entry.platform === "pinterest" && /^\d+$/.test(entry.external_id)) { return `/api/admin/raw-post-sources/preview?platform=pinterest&pin=${encodeURIComponent( entry.external_id diff --git a/supabase-assets/migrations/20260507133000_vision_filter_log_image_url.sql b/supabase-assets/migrations/20260507133000_vision_filter_log_image_url.sql new file mode 100644 index 00000000..f9dcf81e --- /dev/null +++ b/supabase-assets/migrations/20260507133000_vision_filter_log_image_url.sql @@ -0,0 +1,11 @@ +-- Preserve the judged image URL for vision filter audit cards (#368). +-- +-- Instagram skip decisions may delete the raw_post row while the audit row stays. +-- Store the image URL at judgment time so admin thumbnails do not depend on +-- re-scraping Instagram OG metadata after deletion. + +ALTER TABLE public.vision_filter_log + ADD COLUMN IF NOT EXISTS image_url text; + +COMMENT ON COLUMN public.vision_filter_log.image_url IS + 'Image URL used at judgment time. Preserves audit thumbnails after raw_post deletion.'; From 6647c59a4bd15e96b708c5fbcdfd46e32dc626e4 Mon Sep 17 00:00:00 2001 From: philippe Date: Thu, 7 May 2026 21:14:22 +0900 Subject: [PATCH 12/15] fix(web): remove missing supabase client export (#491) Co-authored-by: Cursor --- packages/web/lib/hooks/useVtonTryOn.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/web/lib/hooks/useVtonTryOn.ts b/packages/web/lib/hooks/useVtonTryOn.ts index f476e9af..d53ec091 100644 --- a/packages/web/lib/hooks/useVtonTryOn.ts +++ b/packages/web/lib/hooks/useVtonTryOn.ts @@ -2,10 +2,7 @@ import { useState, useCallback } from "react"; import { toast } from "sonner"; import type { ItemData } from "@/lib/hooks/useVtonItemFetch"; import type { VtonPostData } from "@/lib/hooks/useVtonPostFetch"; -import { - hasSupabaseBrowserConfig, - supabaseBrowserClient, -} from "@/lib/supabase/client"; +import { supabaseBrowserClient } from "@/lib/supabase/client"; async function dataUrlToBlob(dataUrl: string): Promise { const [header, base64] = dataUrl.split(","); @@ -30,7 +27,6 @@ async function copyImageToClipboard(dataUrl: string) { export { dataUrlToBlob, copyImageToClipboard }; async function getBrowserAccessToken(): Promise { - if (!hasSupabaseBrowserConfig()) return null; const { data: { session }, } = await supabaseBrowserClient.auth.getSession(); From 28da9df5c35b1fc496b34aa9ad3f99f2bd107881 Mon Sep 17 00:00:00 2001 From: Raf Date: Fri, 8 May 2026 22:37:57 +0900 Subject: [PATCH 13/15] =?UTF-8?q?feat(verify):=20R2=20relocate=20raw=20?= =?UTF-8?q?=E2=86=92=20operation=20at=20verify=20time=20(#466)=20(#492)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 작업 1 (마이그레이션, 1080 row) 후속 — 신규 verify 데이터도 동일한 single-source 컨벤션 (r2.decoded.style/posts/{post_id} / r2.decoded.style/items/{solution_id}) 유지하도록 verify_raw_post 흐름 수정. 기존: raw_post.image_url (assets R2 dev pub URL) 그대로 posts.image_url 복사 → 운영 DB 가 dev 도메인 URL 들고 있는 버그. 새 흐름: 1. prod_txn 진입 전 R2 copy: - post_id 사전 생성 → assets_storage.download(raw_key) → operation_storage.upload("posts/{post_id}") - 각 visible item: solution_id 사전 생성 → items thumbnail 동일 패턴 2. prod_txn: create_post_from_raw / create_solution_for_verify 에 미리 생성한 UUID + 새 operation URL 전달 (시그니처에 명시 인자 추가) 3. assets_txn: raw_post.image_url 도 operation URL 로 갱신 (단일 진실 소스) 4. 두 트랜잭션 commit 후 raw 객체 best-effort delete (warn 만, orphan 허용) 코드 변경: - StorageClient trait 에 `download(key) -> (bytes, Option)` 추가 - CloudflareR2Client GetObject 구현, NotFound 매핑 - DummyStorageClient stub 구현 - 신규 `raw_posts/relocate.rs` — relocate_raw_to_operation helper + 4 단위 테스트 - extract_assets_r2_key → pub(crate) 격상 (relocate 가 재사용) - create_post_from_raw 시그니처: post_id, image_url 명시 인자 (내부 Uuid::new_v4 제거) - create_solution_for_verify 시그니처: solution_id 명시 인자 - verify_raw_post: prod_txn 전 R2 copy 단계 + raw cleanup post-commit 실패 처리: - copy 도중 실패 → BadRequest/NotFound/ExternalService Err, prod_txn 미시작 (DB 부작용 0) - 부분 성공 (hero copy OK / item N copy fail) → op bucket 에 hero orphan 잔존 (lifecycle rule / 별도 GC out of scope) - raw delete 실패 → warn 로깅만, verify 자체는 성공 유지 테스트: - 4 단위 (relocate happy/fallback/non-raw-rejection/not-found-propagation) - 24 회귀 통과 (parse_source_identifier_*, verify_*) Co-authored-by: Claude Opus 4.7 (1M context) --- .../api-server/src/domains/posts/service.rs | 10 +- .../api-server/src/domains/raw_posts/mod.rs | 1 + .../src/domains/raw_posts/relocate.rs | 229 ++++++++++++++++++ .../src/domains/raw_posts/service.rs | 88 ++++++- .../src/domains/solutions/service.rs | 3 +- .../api-server/src/services/storage/client.rs | 14 ++ .../api-server/src/services/storage/r2.rs | 26 ++ 7 files changed, 361 insertions(+), 10 deletions(-) create mode 100644 packages/api-server/src/domains/raw_posts/relocate.rs diff --git a/packages/api-server/src/domains/posts/service.rs b/packages/api-server/src/domains/posts/service.rs index 55ddfbf5..fdf3014f 100644 --- a/packages/api-server/src/domains/posts/service.rs +++ b/packages/api-server/src/domains/posts/service.rs @@ -2272,6 +2272,8 @@ pub async fn list_tries_by_spot( /// 까지 한 트랜잭션으로 묶을 수 있도록 변경 (#350). pub async fn create_post_from_raw( db: &C, + post_id: Uuid, + image_url: String, admin_id: Uuid, raw_post: &crate::entities::assets_raw_posts::Model, dto: crate::domains::raw_posts::dto::VerifyRawPostDto, @@ -2282,12 +2284,6 @@ pub async fn create_post_from_raw( subject_style_tags: Option<&[String]>, ) -> AppResult { let now = chrono::Utc::now().fixed_offset(); - let image_url = raw_post.image_url.clone().ok_or_else(|| { - AppError::BadRequest(format!( - "raw_post {} has no image_url — cannot create post", - raw_post.id - )) - })?; // title 우선순위: admin 입력 (or compose_title 결과 호출자가 미리 주입) → // pin caption (보통 비영어/길어 마지막 fallback). subject_parser.title_en 은 @@ -2331,7 +2327,7 @@ pub async fn create_post_from_raw( }; let post = ActiveModel { - id: Set(Uuid::new_v4()), + id: Set(post_id), user_id: Set(admin_id), image_url: Set(image_url), media_type: Set("image".to_string()), diff --git a/packages/api-server/src/domains/raw_posts/mod.rs b/packages/api-server/src/domains/raw_posts/mod.rs index 96fae581..27e83f7a 100644 --- a/packages/api-server/src/domains/raw_posts/mod.rs +++ b/packages/api-server/src/domains/raw_posts/mod.rs @@ -8,6 +8,7 @@ pub mod dto; pub mod handlers; +pub(crate) mod relocate; pub mod service; pub use handlers::router; diff --git a/packages/api-server/src/domains/raw_posts/relocate.rs b/packages/api-server/src/domains/raw_posts/relocate.rs new file mode 100644 index 00000000..e9ddfcfc --- /dev/null +++ b/packages/api-server/src/domains/raw_posts/relocate.rs @@ -0,0 +1,229 @@ +//! verify-time R2 relocation (#466 follow-up). +//! +//! Verify 시점에 raw bucket (assets storage) 의 hero / item thumbnail R2 객체를 +//! operation bucket 으로 복사한다. CopyObject 권한 이슈 회피 위해 +//! `download → upload` 두 단계로 처리 — bytes 가 우리 머신을 잠시 통과하지만 +//! 평균 1MB 이미지라 비용 무시. +//! +//! caller 책임: +//! - relocate 성공 후 raw 객체 best-effort delete (단일 책임 분리) +//! - new_key 의 사전 생성 (e.g. `posts/{post_id}`, `items/{solution_id}`) + +use crate::error::{AppError, AppResult}; +use crate::services::storage::StorageClient; + +use super::service::extract_assets_r2_key; + +/// raw bucket 의 객체를 operation bucket 의 새 키로 복사한다. +/// +/// `raw_url` 이 `raw_public_url` prefix 가 아니면 `BadRequest` — verify 흐름은 +/// raw bucket URL 만 가정 (외부 hotlink 은 별도 PR). +/// +/// 반환: operation public URL (`{op_public_url}/{new_key}`). +pub(crate) async fn relocate_raw_to_operation( + raw_storage: &dyn StorageClient, + op_storage: &dyn StorageClient, + raw_url: &str, + raw_public_url: &str, + op_public_url: &str, + new_key: &str, + fallback_content_type: &str, +) -> AppResult { + let raw_key = extract_assets_r2_key(raw_url, raw_public_url).ok_or_else(|| { + AppError::BadRequest(format!( + "non-raw URL cannot be relocated: {raw_url:?} (raw_public_url={raw_public_url:?})" + )) + })?; + + let (bytes, ct) = raw_storage.download(&raw_key).await?; + let content_type = ct.as_deref().unwrap_or(fallback_content_type); + + op_storage + .upload(new_key, bytes, content_type) + .await + .map_err(|e| { + AppError::ExternalService(format!( + "relocate: upload to operation failed (key={new_key}): {e}" + )) + })?; + + Ok(format!( + "{}/{}", + op_public_url.trim_end_matches('/'), + new_key + )) +} + +#[cfg(test)] +#[allow(clippy::disallowed_methods)] +mod tests { + use super::*; + + use async_trait::async_trait; + use std::collections::HashMap; + use std::sync::Mutex; + + /// 테스트용 in-memory StorageClient — download 가 받은 bytes 를 upload 가 + /// 받았는지 검증할 수 있도록 호출 기록 보존. + struct InMemoryStorage { + objects: Mutex, Option)>>, + upload_log: Mutex, String)>>, + public_url: String, + } + + impl InMemoryStorage { + fn new(public_url: &str) -> Self { + Self { + objects: Mutex::new(HashMap::new()), + upload_log: Mutex::new(Vec::new()), + public_url: public_url.to_string(), + } + } + + fn put(&self, key: &str, bytes: Vec, ct: Option) { + self.objects + .lock() + .unwrap() + .insert(key.to_string(), (bytes, ct)); + } + + fn upload_count(&self) -> usize { + self.upload_log.lock().unwrap().len() + } + } + + #[async_trait] + impl StorageClient for InMemoryStorage { + async fn upload( + &self, + key: &str, + data: Vec, + content_type: &str, + ) -> Result { + self.upload_log.lock().unwrap().push(( + key.to_string(), + data.clone(), + content_type.to_string(), + )); + self.objects + .lock() + .unwrap() + .insert(key.to_string(), (data, Some(content_type.to_string()))); + Ok(self.get_url(key)) + } + + async fn delete(&self, key: &str) -> Result<(), AppError> { + self.objects.lock().unwrap().remove(key); + Ok(()) + } + + async fn download(&self, key: &str) -> Result<(Vec, Option), AppError> { + self.objects + .lock() + .unwrap() + .get(key) + .cloned() + .ok_or_else(|| AppError::NotFound(format!("not found: {key}"))) + } + + fn get_url(&self, key: &str) -> String { + format!("{}/{}", self.public_url.trim_end_matches('/'), key) + } + } + + #[tokio::test] + async fn relocate_happy_path_passes_bytes_and_content_type() { + let raw = InMemoryStorage::new("https://pub-x.r2.dev"); + let op = InMemoryStorage::new("https://r2.decoded.style"); + + raw.put( + "starstyle/92/926700.jpg", + vec![0xff, 0xd8, 0xff, 0xe0], + Some("image/jpeg".to_string()), + ); + + let new_url = relocate_raw_to_operation( + &raw, + &op, + "https://pub-x.r2.dev/starstyle/92/926700.jpg", + "https://pub-x.r2.dev", + "https://r2.decoded.style", + "posts/abc-123", + "image/jpeg", + ) + .await + .unwrap(); + + assert_eq!(new_url, "https://r2.decoded.style/posts/abc-123"); + let log = op.upload_log.lock().unwrap(); + assert_eq!(log.len(), 1); + assert_eq!(log[0].0, "posts/abc-123"); + assert_eq!(log[0].1, vec![0xff, 0xd8, 0xff, 0xe0]); + assert_eq!(log[0].2, "image/jpeg"); + } + + #[tokio::test] + async fn relocate_uses_fallback_content_type_when_missing() { + let raw = InMemoryStorage::new("https://pub-x.r2.dev"); + let op = InMemoryStorage::new("https://r2.decoded.style"); + raw.put("starstyle/x.bin", vec![0u8; 8], None); + + relocate_raw_to_operation( + &raw, + &op, + "https://pub-x.r2.dev/starstyle/x.bin", + "https://pub-x.r2.dev", + "https://r2.decoded.style", + "posts/abc", + "image/png", + ) + .await + .unwrap(); + + let log = op.upload_log.lock().unwrap(); + assert_eq!(log[0].2, "image/png"); + } + + #[tokio::test] + async fn relocate_rejects_non_raw_url() { + let raw = InMemoryStorage::new("https://pub-x.r2.dev"); + let op = InMemoryStorage::new("https://r2.decoded.style"); + + let err = relocate_raw_to_operation( + &raw, + &op, + "https://i.pinimg.com/anything/abc.jpg", + "https://pub-x.r2.dev", + "https://r2.decoded.style", + "posts/abc", + "image/jpeg", + ) + .await + .unwrap_err(); + + assert!(matches!(err, AppError::BadRequest(_)), "got {err:?}"); + assert_eq!(op.upload_count(), 0); + } + + #[tokio::test] + async fn relocate_propagates_download_not_found() { + let raw = InMemoryStorage::new("https://pub-x.r2.dev"); + let op = InMemoryStorage::new("https://r2.decoded.style"); + // raw.put 안 함 — download 시 NotFound + + let err = relocate_raw_to_operation( + &raw, + &op, + "https://pub-x.r2.dev/missing.jpg", + "https://pub-x.r2.dev", + "https://r2.decoded.style", + "posts/abc", + "image/jpeg", + ) + .await + .unwrap_err(); + + assert!(matches!(err, AppError::NotFound(_)), "got {err:?}"); + assert_eq!(op.upload_count(), 0); + } +} diff --git a/packages/api-server/src/domains/raw_posts/service.rs b/packages/api-server/src/domains/raw_posts/service.rs index 1cc47f72..72be7d4d 100644 --- a/packages/api-server/src/domains/raw_posts/service.rs +++ b/packages/api-server/src/domains/raw_posts/service.rs @@ -671,7 +671,7 @@ pub async fn delete_item( /// → Some("starstyle/92/926700.jpg") /// /// 다른 도메인 / 빈 public_url / 키가 비면 None — caller 가 cleanup skip. -fn extract_assets_r2_key(url: &str, public_url: &str) -> Option { +pub(crate) fn extract_assets_r2_key(url: &str, public_url: &str) -> Option { let prefix = public_url.trim_end_matches('/'); if prefix.is_empty() { return None; @@ -1284,9 +1284,63 @@ pub async fn verify_raw_post( } } + // 3. R2 relocate (raw → operation) — prod_txn 시작 전 (#466 follow-up). + // raw bucket 의 hero / item thumbnail 객체를 operation bucket 의 정규 + // 키 (`posts/{post_id}`, `items/{solution_id}`) 로 복사하고, 새 URL 만 + // DB 에 들어가도록 한다. copy 도중 실패하면 verify 거부 (DB 부작용 0). + let raw_url = raw_post.image_url.clone().ok_or_else(|| { + AppError::BadRequest(format!("raw_post {id} has no image_url — cannot verify")) + })?; + let assets_pub = state.config.assets_storage.public_url.clone(); + let op_pub = state.config.storage.public_url.clone(); + let post_id = Uuid::new_v4(); + let new_post_url = super::relocate::relocate_raw_to_operation( + state.assets_storage.as_ref(), + state.operation_storage.as_ref(), + &raw_url, + &assets_pub, + &op_pub, + &format!("posts/{post_id}"), + "image/jpeg", + ) + .await?; + + // 각 visible item 에 대해 solution_id 사전 생성 + thumbnail copy. None 인 + // thumbnail 은 fallback path (raw 객체 자체가 없음) 라 그대로 None. + let mut prepared_items: Vec<(usize, Uuid, Option, Option)> = Vec::new(); + if let Some(payload) = &parse_payload { + for (idx, item) in payload.items.iter().enumerate().filter(|(_, i)| i.visible) { + let solution_id = Uuid::new_v4(); + let new_thumb = match item.thumbnail_url.as_deref() { + Some(url) if !url.trim().is_empty() => { + let new_url = super::relocate::relocate_raw_to_operation( + state.assets_storage.as_ref(), + state.operation_storage.as_ref(), + url, + &assets_pub, + &op_pub, + &format!("items/{solution_id}"), + "image/jpeg", + ) + .await?; + Some(new_url) + } + _ => None, + }; + // raw thumbnail key 는 두 트랜잭션 commit 후 best-effort delete 용으로 보관. + let raw_thumb_key = item + .thumbnail_url + .as_deref() + .and_then(|u| extract_assets_r2_key(u, &assets_pub)); + prepared_items.push((idx, solution_id, new_thumb, raw_thumb_key)); + } + } + let prod_txn = state.db.begin().await.map_err(AppError::DatabaseError)?; let post = crate::domains::posts::service::create_post_from_raw( &prod_txn, + post_id, + new_post_url.clone(), admin_id, &raw_post, dto, @@ -1368,13 +1422,22 @@ pub async fn verify_raw_post( Some(t.to_string()) } }); + // prepared_items[idx] 에서 사전 생성한 solution_id + 새 operation + // thumbnail_url 가져오기. 정상 흐름이면 매칭되지만, payload.items + // 와 prepared_items 의 idx 가 어긋나면 fallback (현재 동작 유지). + let prepared = prepared_items.iter().find(|(i, _, _, _)| *i == idx); + let (solution_id, thumb_for_db) = match prepared { + Some((_, sid, new_thumb, _)) => (*sid, new_thumb.clone()), + None => (Uuid::new_v4(), item.thumbnail_url.clone()), + }; crate::domains::solutions::service::create_solution_for_verify( &prod_txn, + solution_id, spot.id, admin_id, title, Some(serde_json::Value::Object(metadata)), - item.thumbnail_url.clone(), + thumb_for_db, original_url, ) .await?; @@ -1398,6 +1461,9 @@ pub async fn verify_raw_post( active.verified_at = Set(Some(now)); active.verified_by = Set(Some(admin_id)); active.updated_at = Set(now); + // raw_post.image_url 도 operation URL 로 갱신 (#466 옵션 C). raw bucket + // 객체는 곧 삭제 예정이라 stale URL 이 되지 않게 단일 진실 소스로 통일. + active.image_url = Set(Some(new_post_url.clone())); active.update(&txn).await.map_err(|e| { tracing::error!(raw_post_id=%id, post_id=%post.id, error=%e, "assets status=VERIFIED write FAILED after prod INSERT succeeded — \ @@ -1416,6 +1482,24 @@ pub async fn verify_raw_post( .await?; txn.commit().await.map_err(AppError::DatabaseError)?; + + // 4. raw 객체 best-effort delete (#466 follow-up). 두 트랜잭션 commit 완료 + // 후라 rollback 위험 없음. 실패는 warn 로깅만 — orphan 은 lifecycle rule + // 또는 별도 GC 잡 (out of scope). + if let Some(raw_hero_key) = extract_assets_r2_key(&raw_url, &assets_pub) { + if let Err(e) = state.assets_storage.delete(&raw_hero_key).await { + tracing::warn!(raw_post_id=%id, key=%raw_hero_key, error=%e, + "raw hero delete failed (post-verify cleanup) — orphan"); + } + } + for (_, _, _, raw_thumb_key) in &prepared_items { + if let Some(key) = raw_thumb_key { + if let Err(e) = state.assets_storage.delete(key).await { + tracing::warn!(raw_post_id=%id, key=%key, error=%e, + "raw thumbnail delete failed (post-verify cleanup) — orphan"); + } + } + } Ok(post) } diff --git a/packages/api-server/src/domains/solutions/service.rs b/packages/api-server/src/domains/solutions/service.rs index 22210122..019bd849 100644 --- a/packages/api-server/src/domains/solutions/service.rs +++ b/packages/api-server/src/domains/solutions/service.rs @@ -39,6 +39,7 @@ use super::dto::{ /// 반환값은 row id (#350). pub async fn create_solution_for_verify( db: &C, + solution_id: Uuid, spot_id: Uuid, user_id: Uuid, title: String, @@ -46,7 +47,7 @@ pub async fn create_solution_for_verify( thumbnail_url: Option, original_url: Option, ) -> AppResult { - let id = Uuid::new_v4(); + let id = solution_id; let now = chrono::Utc::now().fixed_offset(); let model = ActiveModel { id: Set(id), diff --git a/packages/api-server/src/services/storage/client.rs b/packages/api-server/src/services/storage/client.rs index b8422898..3aceaa71 100644 --- a/packages/api-server/src/services/storage/client.rs +++ b/packages/api-server/src/services/storage/client.rs @@ -33,6 +33,16 @@ pub trait StorageClient: Send + Sync { /// * `key` - 삭제할 파일 키 async fn delete(&self, key: &str) -> Result<(), AppError>; + /// 파일 다운로드 (#466 verify-time relocate) + /// + /// # Arguments + /// * `key` - 다운로드할 파일 키 + /// + /// # Returns + /// `(bytes, content_type)`. content_type 은 R2 의 Content-Type 헤더 — + /// 없으면 None (caller 가 fallback). + async fn download(&self, key: &str) -> Result<(Vec, Option), AppError>; + /// 공개 URL 생성 /// /// # Arguments @@ -78,6 +88,10 @@ impl StorageClient for DummyStorageClient { Ok(()) } + async fn download(&self, _key: &str) -> Result<(Vec, Option), AppError> { + Ok((vec![1, 2, 3], Some("image/jpeg".to_string()))) + } + fn get_url(&self, key: &str) -> String { format!("{}/{}", self.base_url, key) } diff --git a/packages/api-server/src/services/storage/r2.rs b/packages/api-server/src/services/storage/r2.rs index d0a7d40f..9af9c9d4 100644 --- a/packages/api-server/src/services/storage/r2.rs +++ b/packages/api-server/src/services/storage/r2.rs @@ -95,6 +95,32 @@ impl StorageClient for CloudflareR2Client { Ok(()) } + async fn download(&self, key: &str) -> Result<(Vec, Option), AppError> { + let resp = self + .client + .get_object() + .bucket(&self.bucket_name) + .key(key) + .send() + .await + .map_err(|e| { + let msg = e.to_string(); + if msg.contains("NotFound") || msg.contains("NoSuchKey") || msg.contains("404") { + AppError::NotFound(format!("R2 object not found: {}", key)) + } else { + AppError::ExternalService(format!("Failed to download from R2: {}", e)) + } + })?; + + let content_type = resp.content_type().map(String::from); + let body = resp + .body + .collect() + .await + .map_err(|e| AppError::ExternalService(format!("Failed to read R2 body: {}", e)))?; + Ok((body.into_bytes().to_vec(), content_type)) + } + fn get_url(&self, key: &str) -> String { format!("{}/{}", self.public_url, key) } From 910081f9d0ed18105ed8edf93572ad5423fe0154 Mon Sep 17 00:00:00 2001 From: Raf Date: Fri, 8 May 2026 22:40:46 +0900 Subject: [PATCH 14/15] fix(wiki): remove unknown tags (profile, vton) from profile-tries spec (#494) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wiki lint 가 docs/wiki/schema/tags.md 에 등록 안 된 태그 거부. api / ui 만 남겨 dev→main 머지 차단 해제. Co-authored-by: Claude Opus 4.7 (1M context) --- .../superpowers/specs/2026-05-07-profile-tries-detail-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-05-07-profile-tries-detail-design.md b/docs/superpowers/specs/2026-05-07-profile-tries-detail-design.md index d4e36e3d..2010e926 100644 --- a/docs/superpowers/specs/2026-05-07-profile-tries-detail-design.md +++ b/docs/superpowers/specs/2026-05-07-profile-tries-detail-design.md @@ -3,7 +3,7 @@ title: "Profile Tries Detail Modal Design" owner: human status: draft updated: 2026-05-07 -tags: [profile, vton, api, ui] +tags: [api, ui] --- # Profile Tries Detail Modal Design From 0a0889151ef3e12837aedfeb14ae95c2ddfd866f Mon Sep 17 00:00:00 2001 From: Raf Date: Thu, 14 May 2026 15:02:39 +0900 Subject: [PATCH 15/15] feat(cost-tracking): Gemini API per-call cost tracking + admin dashboard (#496) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cost-tracking): Gemini API per-call cost tracking + admin dashboard ai-server 의 Gemini 호출 (raw_post 6 + editorial 7) 모두를 호출 시점에 DB 적립 + 어드민 대시보드에서 일/step/model/pipeline 별 spend 가시화. 핵심 결정: - 단가는 코드 하드코딩 없음 — `gemini_pricing` DB 테이블 SOT (SCD-2, effective_from/to). 어드민 UI 에서 inline upsert. - per-call `pricing_snapshot` 보존 — 단가 변경 후에도 과거 cost 재현 가능. - fire-and-forget recorder — 추적이 본 파이프라인 latency/실패에 영향 0. - 실패도 row 적립 (ok=false, error_class) — 안전·정책 차단, quota, parse_error, timeout, no_pricing 등 가시화. - 5분 in-process pricing cache — 어드민 변경 후 최악 5분 stale 허용. DB (`supabase-assets/migrations/20260514140000_gemini_cost_tracking.sql`): - gemini_pricing (SCD-2, 12 seed row — Pro / Flash / Flash-Lite / Flash-Image / grounding) - gemini_usage_events (per-call, BRIN + composite index 시간순) - gemini_spend_daily (view, 대시보드 read-side) ai-server (`src/services/cost_tracking/`): - `track_call(step, model, extract, coro)` — coroutine wrapper. usage_metadata 자동 추출 + estimator (token × DB 단가 × cached 우대) + recorder INSERT. - raw_post scheduler / post_editorial service 에서 `set_context()` 1회 호출 → 이후 모든 Gemini 호출이 raw_post_id / post_id / pipeline 자동 적립. - 13 call site wrap: items/subject/spots/nano_banana(hero+thumbnail)/url_grounded/url_filter (raw_post 6), design_spec/image_analysis/item_search/news_research/editorial/review/celeb_search (editorial 7). api-server (`src/domains/admin/gemini_cost.rs`): - GET /spend/{daily,by-step,by-model,by-pipeline,today,top-raw-posts} - GET / POST /pricing (POST 가 기존 active row close + 새 row insert TX) - DELETE /pricing/{id} (retire) web (`/admin/gemini-cost`): - KPI row (Today / Yesterday / 7d / 30d) - Daily spend AreaChart (7/14/30d period selector) - Spend by step / model / pipeline (가로 Bar) - Top expensive raw_posts (drill-down → /admin/raw-posts/[id]) - Pricing editor (inline upsert + history toggle + retire) - /admin home 에 `` 카드 + sidebar 새 "Observability" 그룹 검증: - migration syntax check (local DB BEGIN; ... ROLLBACK;) - e2e unit test (scripts/cost_tracking_e2e_test.py) — Pro/Flash/Image/cached/ failure/no-pricing 6 case 모두 단가 계산 정확 (1000 in × \$1.25/M + 500 out × \$10/M = \$0.006250 등 모두 ✓) - 실측 (scripts/cost_probe.py) — 운영 raw_post 1건 풀 파이프라인: fixed \$0.041 + per-item \$0.075 = 3 items \$0.268, 5 items 추정 ~\$0.42 - ai-server import / api-server cargo check / web typecheck + lint 모두 clean Out of scope (별도 트랙): - Cloud Billing Catalog API 자동 sync (Vertex 이전 후) - daily threshold alert (Slack/텔레그램) - 비용 hot path 최적화 (thumbnail/grounded 감축) — 데이터 적립 후 결정 Co-Authored-By: Claude Opus 4.7 (1M context) * test(cost-tracking): real-parser wrap verification script 기존 cost_tracking_e2e_test.py 는 `track_call` 을 직접 호출 (mock coroutine). 이 스크립트는 실제 `SubjectParser` 클래스를 import + 호출 → 그 내부에 박힌 wrap 코드 라인이 작동해서 DB row 가 적립되는지 검증한다. 검증 결과 (운영 Pinterest 이미지 1장, ~$0.0005): step=subject_parser, model=gemini-2.5-flash, pipeline=raw_post, prompt_tokens=813, completion_tokens=98, cost_usd=$0.000489, pricing_snapshot={input_token: 0.0000003, output_token: 0.0000025} → wrap 코드 경로 + context 자동 전파 + 단가 cache lookup + INSERT 모두 실제로 작동함을 확인. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- packages/ai-server/scripts/cost_probe.py | 390 +++++++++++++ .../scripts/cost_tracking_e2e_test.py | 180 ++++++ .../scripts/cost_tracking_parser_verify.py | 151 +++++ .../src/managers/llm/adapters/nano_banana.py | 35 +- .../src/post_editorial/nodes/celeb_search.py | 20 +- .../src/post_editorial/nodes/design_spec.py | 21 +- .../src/post_editorial/nodes/editorial.py | 21 +- .../post_editorial/nodes/image_analysis.py | 27 +- .../src/post_editorial/nodes/item_search.py | 21 +- .../src/post_editorial/nodes/news_research.py | 21 +- .../src/post_editorial/nodes/review.py | 17 +- .../src/services/cost_tracking/__init__.py | 43 ++ .../src/services/cost_tracking/_impl.py | 397 +++++++++++++ .../post_editorial/post_editorial_service.py | 9 + .../raw_posts/processors/hero_reframe.py | 1 + .../raw_posts/processors/items_parser.py | 31 +- .../raw_posts/processors/items_thumbnail.py | 2 + .../raw_posts/processors/spots_parser.py | 31 +- .../raw_posts/processors/subject_parser.py | 31 +- .../raw_posts/processors/url_search.py | 32 +- .../src/services/raw_posts/scheduler.py | 8 + .../src/domains/admin/gemini_cost.rs | 526 ++++++++++++++++++ .../api-server/src/domains/admin/handlers.rs | 7 +- packages/api-server/src/domains/admin/mod.rs | 1 + packages/web/app/admin/gemini-cost/page.tsx | 475 ++++++++++++++++ packages/web/app/admin/page.tsx | 4 + .../api/admin/gemini-cost/[...path]/route.ts | 79 +++ .../web/lib/components/admin/AdminSidebar.tsx | 7 + .../admin/dashboard/GeminiCostMini.tsx | 70 +++ packages/web/lib/hooks/admin/useGeminiCost.ts | 235 ++++++++ .../20260514140000_gemini_cost_tracking.sql | 172 ++++++ 31 files changed, 2960 insertions(+), 105 deletions(-) create mode 100644 packages/ai-server/scripts/cost_probe.py create mode 100644 packages/ai-server/scripts/cost_tracking_e2e_test.py create mode 100644 packages/ai-server/scripts/cost_tracking_parser_verify.py create mode 100644 packages/ai-server/src/services/cost_tracking/__init__.py create mode 100644 packages/ai-server/src/services/cost_tracking/_impl.py create mode 100644 packages/api-server/src/domains/admin/gemini_cost.rs create mode 100644 packages/web/app/admin/gemini-cost/page.tsx create mode 100644 packages/web/app/api/admin/gemini-cost/[...path]/route.ts create mode 100644 packages/web/lib/components/admin/dashboard/GeminiCostMini.tsx create mode 100644 packages/web/lib/hooks/admin/useGeminiCost.ts create mode 100644 supabase-assets/migrations/20260514140000_gemini_cost_tracking.sql diff --git a/packages/ai-server/scripts/cost_probe.py b/packages/ai-server/scripts/cost_probe.py new file mode 100644 index 00000000..577541d7 --- /dev/null +++ b/packages/ai-server/scripts/cost_probe.py @@ -0,0 +1,390 @@ +#!/usr/bin/env python3 +# pyright: reportMissingImports=false +"""Single raw_post 의 전체 Gemini 파이프라인 비용을 실측한다. + +목적: cost-tracking 시스템 (DB pricing + per-call recorder) 을 만들기 전에 +"한 raw_post 처리에 실제로 token / image / grounding query 가 얼마나 +드는지" 를 ad-hoc 으로 측정. 결과로 plan 의 비용 추정치를 보강. + +원리: + - prod assets DB 에서 image_url 있는 raw_post 1건 random pick. + - prod 파이프라인이 만드는 8가지 호출 타입을 1회씩 (item 단위는 N=3 cap): + 1. hero_reframe gemini-2.5-flash-image image out + 2. subject gemini-2.5-flash image+text + 3. items gemini-2.5-pro image+text → N items + 4. spots gemini-2.5-flash hero+items + 5. thumbnail × N gemini-2.5-flash-image image out + 6. url_grounded × N gemini-2.5-flash + googleSearch + 7. url_filter × N gemini-2.5-flash + image + - 각 호출의 usage_metadata 캡처 (prompt_token_count / candidates_token_count / + cached_content_token_count, image 모델은 candidates_token_count 가 image + bytes 분 ≈ 1290). + - 끝에 합계 + Gemini 공시 단가 (스크립트 상단 상수) 로 USD 환산. + +단가 상수는 *프로브 한정* (production code 는 plan 대로 DB SOT). +출처는 `https://ai.google.dev/pricing` (2026-05 기준 manual 입력). + +Usage: + cd packages/ai-server + uv run python scripts/cost_probe.py + uv run python scripts/cost_probe.py --id +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import logging +import os +import sys +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Optional + +import asyncpg +import httpx +from google import genai +from google.genai import types as genai_types + + +_THIS = Path(__file__).resolve() +_AI_SERVER_ROOT = _THIS.parent.parent +sys.path.insert(0, str(_AI_SERVER_ROOT)) + +from src.services.raw_posts.processors.prompts import ( # noqa: E402 + HERO_REFRAME_PROMPT, + ITEM_THUMBNAIL_PROMPT, + ITEMS_PROMPT, + SPOTS_PROMPT, + SUBJECT_PROMPT, +) +from src.services.raw_posts.processors.items_parser import _ItemsResponse # noqa: E402 +from src.services.raw_posts.processors.spots_parser import _SpotsResponse # noqa: E402 +from src.services.raw_posts.processors.subject_parser import _SubjectDraft # noqa: E402 + + +logger = logging.getLogger("cost-probe") + + +# === Gemini 단가 (USD / unit) — 2026-05 ai.google.dev/pricing 기준 manual === +# 프로브 스크립트 한정. 운영 시스템은 DB pricing 테이블 SOT. +PRICING = { + "gemini-2.5-pro": { + "input_token": 1.25 / 1_000_000, # ≤200k context + "output_token": 10.0 / 1_000_000, + "cached_input_token": 0.3125 / 1_000_000, + }, + "gemini-2.5-flash": { + "input_token": 0.30 / 1_000_000, + "output_token": 2.50 / 1_000_000, + "cached_input_token": 0.075 / 1_000_000, + }, + "gemini-2.5-flash-image": { + "image_output": 0.039, # per image (1024x1024) + "input_token": 0.30 / 1_000_000, # image input still tokenized + }, + "grounding_query": 35.0 / 1_000, # $35 / 1000 google search queries +} + +ITEM_CAP = 3 # 실제 prod 평균 ≈ 5, probe 는 3 으로 cap (비용 ↓) + + +@dataclass +class CallLog: + step: str + model: str + ok: bool + prompt_tokens: int = 0 + completion_tokens: int = 0 + cached_tokens: int = 0 + image_output: int = 0 + grounding_queries: int = 0 + latency_ms: int = 0 + err: Optional[str] = None + est_cost_usd: float = 0.0 + + +calls: list[CallLog] = [] + + +def _extract_usage(resp: Any) -> tuple[int, int, int]: + um = getattr(resp, "usage_metadata", None) + if not um: + return (0, 0, 0) + return ( + getattr(um, "prompt_token_count", 0) or 0, + getattr(um, "candidates_token_count", 0) or 0, + getattr(um, "cached_content_token_count", 0) or 0, + ) + + +def _price_text(model: str, prompt: int, completion: int, cached: int) -> float: + p = PRICING.get(model, {}) + cost = 0.0 + cost += (prompt - (cached or 0)) * p.get("input_token", 0) + cost += completion * p.get("output_token", 0) + cost += (cached or 0) * p.get("cached_input_token", p.get("input_token", 0)) + return cost + + +async def _download(url: str) -> tuple[bytes, str]: + async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as c: + r = await c.get(url) + r.raise_for_status() + ct = (r.headers.get("content-type") or "image/jpeg").split(";")[0].strip() + return r.content, ct + + +async def _pick_sample(db_url: str, raw_id: Optional[str]) -> dict: + conn = await asyncpg.connect(db_url) + try: + if raw_id: + row = await conn.fetchrow( + "SELECT id::text AS id, image_url, caption, parse_result FROM public.raw_posts WHERE id=$1::uuid", + raw_id, + ) + else: + row = await conn.fetchrow( + """ + SELECT id::text AS id, image_url, caption, parse_result + FROM public.raw_posts + WHERE image_url IS NOT NULL AND image_url != '' + AND status = 'COMPLETED' + AND parse_result IS NOT NULL + AND jsonb_typeof(parse_result -> 'items') = 'array' + AND jsonb_array_length(parse_result -> 'items') >= 3 + ORDER BY random() LIMIT 1 + """ + ) + if not row: + raise SystemExit("no eligible raw_post found") + return dict(row) + finally: + await conn.close() + + +async def _call_text( + client: genai.Client, + *, + step: str, + model: str, + contents: list, + response_schema: Optional[Any] = None, +) -> Any: + cfg = genai_types.GenerateContentConfig(temperature=0.1) + if response_schema is not None: + cfg = genai_types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=response_schema, + temperature=0.1, + ) + t0 = time.monotonic() + log = CallLog(step=step, model=model, ok=False) + try: + resp = await client.aio.models.generate_content(model=model, contents=contents, config=cfg) + log.ok = True + p, c, ca = _extract_usage(resp) + log.prompt_tokens, log.completion_tokens, log.cached_tokens = p, c, ca + log.est_cost_usd = _price_text(model, p, c, ca) + return resp + except Exception as exc: # noqa: BLE001 + log.err = f"{type(exc).__name__}: {exc}" + raise + finally: + log.latency_ms = int((time.monotonic() - t0) * 1000) + calls.append(log) + + +async def _call_image( + client: genai.Client, + *, + step: str, + image_bytes: bytes, + content_type: str, + prompt: str, + aspect_ratio: str, + image_size: str, +) -> Any: + cfg = genai_types.GenerateContentConfig( + response_modalities=["IMAGE"], + image_config=genai_types.ImageConfig(aspect_ratio=aspect_ratio, image_size=image_size), + ) + t0 = time.monotonic() + log = CallLog(step=step, model="gemini-2.5-flash-image", ok=False) + try: + resp = await client.aio.models.generate_content( + model="gemini-2.5-flash-image", + contents=[ + genai_types.Part.from_bytes(data=image_bytes, mime_type=content_type), + prompt, + ], + config=cfg, + ) + log.ok = True + p, c, ca = _extract_usage(resp) + log.prompt_tokens = p + log.image_output = 1 + log.est_cost_usd = ( + PRICING["gemini-2.5-flash-image"]["image_output"] + + p * PRICING["gemini-2.5-flash-image"]["input_token"] + ) + return resp + except Exception as exc: # noqa: BLE001 + log.err = f"{type(exc).__name__}: {exc}" + raise + finally: + log.latency_ms = int((time.monotonic() - t0) * 1000) + calls.append(log) + + +async def _call_grounded( + api_key: str, *, step: str, brand: str, title: str, model: str +) -> dict: + prompt = f"Find official PDP URLs for fashion item: brand={brand!r}, product={title!r}. Return top 5 URLs." + payload = { + "contents": [{"role": "user", "parts": [{"text": prompt}]}], + "tools": [{"googleSearch": {}}], + "generationConfig": {"temperature": 0.2}, + } + url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent" + t0 = time.monotonic() + log = CallLog(step=step, model=model, ok=False, grounding_queries=1) + try: + async with httpx.AsyncClient(timeout=60.0) as c: + r = await c.post(url, params={"key": api_key}, json=payload) + r.raise_for_status() + data = r.json() + log.ok = True + um = (data or {}).get("usageMetadata") or {} + p = um.get("promptTokenCount", 0) or 0 + cc = um.get("candidatesTokenCount", 0) or 0 + log.prompt_tokens, log.completion_tokens = p, cc + log.est_cost_usd = ( + _price_text(model, p, cc, 0) + PRICING["grounding_query"] * 1 + ) + return data + except Exception as exc: # noqa: BLE001 + log.err = f"{type(exc).__name__}: {exc}" + raise + finally: + log.latency_ms = int((time.monotonic() - t0) * 1000) + calls.append(log) + + +async def main() -> int: + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s") + parser = argparse.ArgumentParser() + parser.add_argument("--id", type=str, default=None) + args = parser.parse_args() + + db_url = os.environ.get("ASSETS_DATABASE_URL") + api_key = os.environ.get("GEMINI_API_KEY") + if not db_url or not api_key: + raise SystemExit("ASSETS_DATABASE_URL + GEMINI_API_KEY required (source .env.backend.prod)") + + row = await _pick_sample(db_url, args.id) + logger.info("sample raw_post: id=%s image_url=%s", row["id"], row["image_url"]) + image_bytes, content_type = await _download(row["image_url"]) + logger.info("image: %d bytes, ct=%s", len(image_bytes), content_type) + + client = genai.Client(api_key=api_key) + img_part = genai_types.Part.from_bytes(data=image_bytes, mime_type=content_type) + + # === 1. hero_reframe (flash-image) === + logger.info("[1/7] hero_reframe …") + await _call_image( + client, step="hero_reframe", image_bytes=image_bytes, content_type=content_type, + prompt=HERO_REFRAME_PROMPT, aspect_ratio="4:5", image_size="2K", + ) + + # === 2. subject (flash) === + logger.info("[2/7] subject …") + caption_text = (row.get("caption") or "").strip() + subj_contents = [img_part, SUBJECT_PROMPT + (f"\n\nCaption: {caption_text[:300]}" if caption_text else "")] + await _call_text(client, step="subject", model="gemini-2.5-flash", + contents=subj_contents, response_schema=_SubjectDraft) + + # === 3. items (pro) === + logger.info("[3/7] items …") + items_resp = await _call_text(client, step="items", model="gemini-2.5-pro", + contents=[img_part, ITEMS_PROMPT], + response_schema=_ItemsResponse) + # raw image_url 은 합성 전이라 items parser 가 0 으로 돌아오는 게 정상. + # 실제 N 은 prod parse_result.items 에서 가져와 per-item 호출 시뮬레이션. + cached: list[dict] = [] + pr = row.get("parse_result") + if pr: + try: + pr_obj = pr if isinstance(pr, dict) else json.loads(pr) + cached = (pr_obj or {}).get("items", []) or [] + except Exception: # noqa: BLE001 + pass + n_items = min(len(cached), ITEM_CAP) + logger.info("items in cached parse_result: %d (probing %d)", len(cached), n_items) + items = cached + + # === 4. spots (flash) — image + items text === + logger.info("[4/7] spots …") + items_text = json.dumps([{"brand": (it or {}).get("brand"), "product": (it or {}).get("product")} for it in items[:n_items]]) + await _call_text(client, step="spots", model="gemini-2.5-flash", + contents=[img_part, SPOTS_PROMPT + "\n\nitems=" + items_text], + response_schema=_SpotsResponse) + + # === 5. thumbnail × N (flash-image) === + for i in range(n_items): + logger.info("[5/7] thumbnail %d/%d …", i + 1, n_items) + await _call_image( + client, step=f"thumbnail#{i+1}", image_bytes=image_bytes, content_type=content_type, + prompt=ITEM_THUMBNAIL_PROMPT, aspect_ratio="1:1", image_size="1K", + ) + + # === 6. url_search.grounded × N (flash + googleSearch) === + for i in range(n_items): + it = items[i] or {} + brand = (it.get("brand") or "unknown brand")[:80] + product = (it.get("product") or "unknown product")[:120] + logger.info("[6/7] url_grounded %d/%d (%s · %s) …", i + 1, n_items, brand, product) + try: + await _call_grounded(api_key, step=f"url_grounded#{i+1}", brand=brand, title=product, + model="gemini-2.5-flash") + except Exception as exc: # noqa: BLE001 + logger.warning("url_grounded failed: %s", exc) + + # === 7. url_search.filter × N (flash + thumbnail image) === + for i in range(n_items): + logger.info("[7/7] url_filter %d/%d …", i + 1, n_items) + await _call_text( + client, step=f"url_filter#{i+1}", model="gemini-2.5-flash", + contents=[img_part, "Evaluate top product URL candidates. Return JSON with best_url, confidence, domain_class."], + ) + + # === 결과 출력 === + print() + print(f"{'step':<22}{'model':<26}{'ok':<4}{'in tok':>8}{'out tok':>9}{'img':>5}{'grnd':>6}{'lat ms':>9}{'$':>10}") + print("-" * 99) + total = {"in": 0, "out": 0, "img": 0, "grnd": 0, "ms": 0, "usd": 0.0} + for c in calls: + print(f"{c.step:<22}{c.model:<26}{('✓' if c.ok else '✗'):<4}" + f"{c.prompt_tokens:>8}{c.completion_tokens:>9}{c.image_output:>5}" + f"{c.grounding_queries:>6}{c.latency_ms:>9}{c.est_cost_usd:>10.5f}") + total["in"] += c.prompt_tokens; total["out"] += c.completion_tokens + total["img"] += c.image_output; total["grnd"] += c.grounding_queries + total["ms"] += c.latency_ms; total["usd"] += c.est_cost_usd + print("-" * 99) + print(f"{'TOTAL':<22}{'':<26}{'':<4}{total['in']:>8}{total['out']:>9}{total['img']:>5}" + f"{total['grnd']:>6}{total['ms']:>9}{total['usd']:>10.5f}") + print() + print(f"raw_post id = {row['id']}") + print(f"items detected (prod 평균 ≈ 5) = {len(items)}, probe 사용 = {n_items}") + print(f"실측 ${total['usd']:.4f} / 1 raw_post (ITEM_CAP={ITEM_CAP})") + if n_items > 0: + per_item = total['usd'] - sum(c.est_cost_usd for c in calls if not c.step.startswith(('thumbnail', 'url_'))) + prod_est = (total['usd'] - per_item) + per_item / n_items * 5 + print(f"prod 추정 (5 items): ${prod_est:.4f}") + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/packages/ai-server/scripts/cost_tracking_e2e_test.py b/packages/ai-server/scripts/cost_tracking_e2e_test.py new file mode 100644 index 00000000..a744b8ec --- /dev/null +++ b/packages/ai-server/scripts/cost_tracking_e2e_test.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +"""cost_tracking 모듈 end-to-end 검증. + +local DB (port 54322) 에 마이그레이션 적용 후 실행: + ASSETS_DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres \\ + uv run python scripts/cost_tracking_e2e_test.py + +검증: + 1. PricingCache 가 DB seed 단가 로드 + 2. track_call 이 mock Gemini 호출 (usage_metadata 포함) 을 wrap + 3. estimate 가 단가 × token 으로 cost 계산 + 4. recorder 가 fire-and-forget INSERT + 5. 실패 path 도 row 적립 (ok=false, error_class) +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import sys +import uuid +from dataclasses import dataclass +from pathlib import Path + +import asyncpg + + +_THIS = Path(__file__).resolve() +_AI_SERVER_ROOT = _THIS.parent.parent +sys.path.insert(0, str(_AI_SERVER_ROOT)) + +from src.services import cost_tracking # noqa: E402 + + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") +logger = logging.getLogger("cost-tracking-test") + + +# --- mock Gemini response objects --- + +@dataclass +class _MockUsage: + prompt_token_count: int = 0 + candidates_token_count: int = 0 + cached_content_token_count: int = 0 + + +@dataclass +class _MockResp: + usage_metadata: _MockUsage + + +async def _fake_call(usage: _MockUsage): + """Mock coroutine — returns response with usage_metadata.""" + await asyncio.sleep(0.01) + return _MockResp(usage_metadata=usage) + + +async def _fake_failure(): + """Mock coroutine that raises.""" + await asyncio.sleep(0.005) + raise RuntimeError("safety_block: simulated") + + +async def main() -> int: + dsn = os.environ.get("ASSETS_DATABASE_URL") + if not dsn: + print("ASSETS_DATABASE_URL required", file=sys.stderr) + return 2 + + raw_post_id = str(uuid.uuid4()) + cost_tracking.set_context(raw_post_id=raw_post_id, pipeline="raw_post") + logger.info("context: raw_post_id=%s pipeline=raw_post", raw_post_id) + + # === 1. happy path — Pro text call === + logger.info("[1] items_parser (Pro) — mock 1000 in / 500 out") + await cost_tracking.track_call( + "items_parser", + "gemini-2.5-pro", + cost_tracking.extract_text_usage, + _fake_call(_MockUsage(prompt_token_count=1000, candidates_token_count=500)), + ) + + # === 2. happy path — Flash text call === + logger.info("[2] subject_parser (Flash) — mock 800 in / 100 out") + await cost_tracking.track_call( + "subject_parser", + "gemini-2.5-flash", + cost_tracking.extract_text_usage, + _fake_call(_MockUsage(prompt_token_count=800, candidates_token_count=100)), + ) + + # === 3. happy path — Flash text with cache === + logger.info("[3] subject_parser cached — mock 1000 in (400 cached) / 100 out") + await cost_tracking.track_call( + "subject_parser", + "gemini-2.5-flash", + cost_tracking.extract_text_usage, + _fake_call( + _MockUsage( + prompt_token_count=1000, + candidates_token_count=100, + cached_content_token_count=400, + ) + ), + ) + + # === 4. image output (flash-image) === + logger.info("[4] hero_reframe (flash-image)") + await cost_tracking.track_call( + "hero_reframe", + "gemini-2.5-flash-image", + cost_tracking.extract_image_usage, + _fake_call(_MockUsage(prompt_token_count=540, candidates_token_count=0)), + ) + + # === 5. failure path === + logger.info("[5] items_parser failure (safety_block)") + try: + await cost_tracking.track_call( + "items_parser", + "gemini-2.5-pro", + cost_tracking.extract_text_usage, + _fake_failure(), + ) + except RuntimeError as exc: + logger.info(" propagated as expected: %s", exc) + + # === 6. no-pricing model === + logger.info("[6] unknown model (no pricing)") + await cost_tracking.track_call( + "experimental_step", + "gemini-999-future", + cost_tracking.extract_text_usage, + _fake_call(_MockUsage(prompt_token_count=100, candidates_token_count=50)), + ) + + # === 모든 fire-and-forget task 끝날 때까지 대기 === + logger.info("waiting for fire-and-forget INSERTs to drain …") + await asyncio.sleep(2.0) + # 추가로 명시 shutdown — 풀의 모든 pending INSERT 가 끝남 + pending = [t for t in asyncio.all_tasks() if not t.done() and t is not asyncio.current_task()] + if pending: + await asyncio.gather(*pending, return_exceptions=True) + + # === DB 확인 === + conn = await asyncpg.connect(dsn) + try: + rows = await conn.fetch( + """ + SELECT step, model, ok, prompt_tokens, completion_tokens, cached_tokens, + image_output_count, est_cost_usd::float8 AS cost, + error_class, pricing_snapshot::text AS snap, latency_ms + FROM public.gemini_usage_events + WHERE raw_post_id = $1::uuid + ORDER BY id + """, + raw_post_id, + ) + finally: + await conn.close() + + print() + print(f"{'step':<24}{'model':<26}{'ok':<4}{'in':>6}{'out':>5}{'cache':>6}" + f"{'img':>4}{'cost $':>12}{'err':<16}") + print("-" * 110) + for r in rows: + print(f"{r['step']:<24}{r['model']:<26}{('✓' if r['ok'] else '✗'):<4}" + f"{(r['prompt_tokens'] or 0):>6}{(r['completion_tokens'] or 0):>5}" + f"{(r['cached_tokens'] or 0):>6}{(r['image_output_count'] or 0):>4}" + f"{r['cost']:>12.6f} {r['error_class'] or '':<14}") + print() + print(f"{len(rows)} rows inserted (expected 6)") + await cost_tracking.shutdown() + return 0 if len(rows) == 6 else 1 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/packages/ai-server/scripts/cost_tracking_parser_verify.py b/packages/ai-server/scripts/cost_tracking_parser_verify.py new file mode 100644 index 00000000..19d9e807 --- /dev/null +++ b/packages/ai-server/scripts/cost_tracking_parser_verify.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""실제 parser wrap 검증 — `SubjectParser` 가 cost_tracking 경유해 DB 적립하는지. + +이전 `cost_tracking_e2e_test.py` 는 mock coroutine + `track_call` 직접 호출. +이 스크립트는 *진짜 parser 클래스* 를 import + 호출 → 그 안에 박힌 wrap +코드 라인이 작동해서 `gemini_usage_events` 에 row 가 적립되는지 검증. + + ASSETS_DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres \\ + GEMINI_API_KEY=$(grep '^GEMINI_API_KEY=' ../../.env.backend.prod | cut -d= -f2-) \\ + uv run python scripts/cost_tracking_parser_verify.py + +비용: 약 \$0.0005 (Subject parser 1 회). +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import sys +import uuid +from pathlib import Path + +import asyncpg +import httpx + + +_THIS = Path(__file__).resolve() +_AI_SERVER_ROOT = _THIS.parent.parent +sys.path.insert(0, str(_AI_SERVER_ROOT)) + +from src.services import cost_tracking # noqa: E402 +from src.services.raw_posts.processors.subject_parser import SubjectParser # noqa: E402 + + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") +logger = logging.getLogger("cost-tracking-parser-verify") + + +# 테스트 이미지 — 운영 환경의 한 raw_post (Pinterest, 셀럽 사진). +TEST_IMAGE_URL = "https://pub-64ff29549cdf47ee94d338bca8d04819.r2.dev/pinterest/75/75646468738215900.png" + + +async def main() -> int: + dsn = os.environ.get("ASSETS_DATABASE_URL") + api_key = os.environ.get("GEMINI_API_KEY") + if not dsn or not api_key: + print("ASSETS_DATABASE_URL + GEMINI_API_KEY required", file=sys.stderr) + return 2 + + # 가짜 raw_post_id 로 context 설정 — DB 적립 시 이 ID 로 row 검증. + fake_raw_post_id = str(uuid.uuid4()) + cost_tracking.set_context(raw_post_id=fake_raw_post_id, pipeline="raw_post") + logger.info("context: raw_post_id=%s pipeline=raw_post", fake_raw_post_id) + + # 이미지 fetch + logger.info("fetching test image …") + async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as c: + r = await c.get(TEST_IMAGE_URL) + r.raise_for_status() + img_bytes = r.content + ct = (r.headers.get("content-type") or "image/jpeg").split(";")[0].strip() + logger.info("image: %d bytes, ct=%s", len(img_bytes), ct) + + # 실제 SubjectParser 호출 — 내부 `await cost_tracking.track_call(...)` 발동. + logger.info("calling SubjectParser.parse() — wrap 경유 …") + parser = SubjectParser(api_key=api_key, model="gemini-2.5-flash") + subject = await parser.parse( + composite_bytes=img_bytes, + content_type=ct, + caption=None, + ) + logger.info("subject result: %s", subject) + + # fire-and-forget INSERT drain + logger.info("waiting for fire-and-forget INSERT …") + await asyncio.sleep(2.0) + pending = [t for t in asyncio.all_tasks() if not t.done() and t is not asyncio.current_task()] + if pending: + await asyncio.gather(*pending, return_exceptions=True) + + # DB 검증 + conn = await asyncpg.connect(dsn) + try: + rows = await conn.fetch( + """ + SELECT step, model, ok, prompt_tokens, completion_tokens, + est_cost_usd::float8 AS cost, latency_ms, pipeline, + pricing_snapshot::text AS snap, error_class + FROM public.gemini_usage_events + WHERE raw_post_id = $1::uuid + ORDER BY id + """, + fake_raw_post_id, + ) + finally: + await conn.close() + + print() + if not rows: + print(f"❌ FAIL — no row found for raw_post_id={fake_raw_post_id}") + await cost_tracking.shutdown() + return 1 + + print(f"✅ {len(rows)} row(s) inserted for raw_post_id={fake_raw_post_id}") + print() + for r in rows: + print(f" step {r['step']}") + print(f" model {r['model']}") + print(f" pipeline {r['pipeline']}") + print(f" ok {r['ok']}") + print(f" prompt_tokens {r['prompt_tokens']}") + print(f" completion_tok {r['completion_tokens']}") + print(f" cost_usd ${r['cost']:.6f}") + print(f" latency_ms {r['latency_ms']}") + print(f" pricing_snapshot {r['snap']}") + print(f" error_class {r['error_class']}") + print() + + # 핵심 invariant 검증 + fail = False + row = rows[0] + if row["step"] != "subject_parser": + print(f"❌ step mismatch: {row['step']!r} != 'subject_parser'") + fail = True + if row["model"] != "gemini-2.5-flash": + print(f"❌ model mismatch: {row['model']!r} != 'gemini-2.5-flash'") + fail = True + if row["pipeline"] != "raw_post": + print(f"❌ pipeline mismatch: {row['pipeline']!r} != 'raw_post'") + fail = True + if not row["ok"]: + print(f"❌ ok=false (error_class={row['error_class']})") + fail = True + if (row["prompt_tokens"] or 0) <= 0: + print(f"❌ prompt_tokens missing: {row['prompt_tokens']}") + fail = True + if row["cost"] <= 0: + print(f"❌ cost_usd <= 0: {row['cost']}") + fail = True + + await cost_tracking.shutdown() + if fail: + print("❌ invariant check failed") + return 1 + print("✅ all invariants passed — wrap 코드 경로가 실제로 작동함") + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/packages/ai-server/src/managers/llm/adapters/nano_banana.py b/packages/ai-server/src/managers/llm/adapters/nano_banana.py index 2be2e48e..891bc8ae 100644 --- a/packages/ai-server/src/managers/llm/adapters/nano_banana.py +++ b/packages/ai-server/src/managers/llm/adapters/nano_banana.py @@ -17,6 +17,8 @@ from google import genai from google.genai import types as genai_types +from src.services import cost_tracking + logger = logging.getLogger(__name__) @@ -50,24 +52,33 @@ async def reframe( prompt: str, aspect_ratio: str = "4:5", image_size: str = "2K", + step: str = "nano_banana", ) -> bytes: """Call Nano Banana with an input image + prompt, return output bytes. Raises NanoBananaError when the API errors or returns no inline_data. + + `step` 은 cost_tracking 의 step 라벨 — 호출자 (hero_reframe / + items_thumbnail) 가 own 라벨 전달 시 cost 대시보드에서 분리 가능. """ try: - resp = await self._client.aio.models.generate_content( - model=self._model, - contents=[ - genai_types.Part.from_bytes( - data=image_bytes, mime_type=image_mime_type - ), - prompt, - ], - config=genai_types.GenerateContentConfig( - response_modalities=["IMAGE"], - image_config=genai_types.ImageConfig( - aspect_ratio=aspect_ratio, image_size=image_size + resp = await cost_tracking.track_call( + step, + self._model, + cost_tracking.extract_image_usage, + self._client.aio.models.generate_content( + model=self._model, + contents=[ + genai_types.Part.from_bytes( + data=image_bytes, mime_type=image_mime_type + ), + prompt, + ], + config=genai_types.GenerateContentConfig( + response_modalities=["IMAGE"], + image_config=genai_types.ImageConfig( + aspect_ratio=aspect_ratio, image_size=image_size + ), ), ), ) diff --git a/packages/ai-server/src/post_editorial/nodes/celeb_search.py b/packages/ai-server/src/post_editorial/nodes/celeb_search.py index 25beb73f..0e13ebb7 100644 --- a/packages/ai-server/src/post_editorial/nodes/celeb_search.py +++ b/packages/ai-server/src/post_editorial/nodes/celeb_search.py @@ -12,6 +12,7 @@ from pydantic import BaseModel from src.managers.database import DatabaseManager +from src.services import cost_tracking from ..state import PostEditorialState from ..config import get_settings @@ -189,13 +190,18 @@ def _build_celeb_ranking_prompt( async def _rank_celebs( client: genai.Client, prompt: str, model: str ) -> CelebRankingOutput: - response = await client.aio.models.generate_content( - model=model, - contents=prompt, - config=types.GenerateContentConfig( - response_mime_type="application/json", - response_schema=CelebRankingOutput, - temperature=0.3, + response = await cost_tracking.track_call( + "celeb_search", + model, + cost_tracking.extract_text_usage, + client.aio.models.generate_content( + model=model, + contents=prompt, + config=types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=CelebRankingOutput, + temperature=0.3, + ), ), ) raw_text = response.text or "{}" diff --git a/packages/ai-server/src/post_editorial/nodes/design_spec.py b/packages/ai-server/src/post_editorial/nodes/design_spec.py index b3a6cd0e..22454e39 100644 --- a/packages/ai-server/src/post_editorial/nodes/design_spec.py +++ b/packages/ai-server/src/post_editorial/nodes/design_spec.py @@ -7,6 +7,8 @@ from google import genai from google.genai import types +from src.services import cost_tracking + from ..models import DesignSpec, default_design_spec from ..state import PostEditorialState from ..config import get_settings @@ -54,13 +56,18 @@ def _get_genai_client() -> genai.Client: async def _generate_spec(client: genai.Client, prompt: str, model: str) -> DesignSpec: - response = await client.aio.models.generate_content( - model=model, - contents=prompt, - config=types.GenerateContentConfig( - response_mime_type="application/json", - response_schema=DesignSpec, - temperature=0.7, + response = await cost_tracking.track_call( + "design_spec", + model, + cost_tracking.extract_text_usage, + client.aio.models.generate_content( + model=model, + contents=prompt, + config=types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=DesignSpec, + temperature=0.7, + ), ), ) raw_text = response.text or "{}" diff --git a/packages/ai-server/src/post_editorial/nodes/editorial.py b/packages/ai-server/src/post_editorial/nodes/editorial.py index 2db9ff10..c039dcf7 100644 --- a/packages/ai-server/src/post_editorial/nodes/editorial.py +++ b/packages/ai-server/src/post_editorial/nodes/editorial.py @@ -8,6 +8,8 @@ from google.genai import types from pydantic import BaseModel +from src.services import cost_tracking + from ..state import PostEditorialState from ..config import get_settings from ..gemini_retry import call_gemini_with_fallback @@ -163,13 +165,18 @@ def _get_genai_client() -> genai.Client: async def _generate_editorial(client: genai.Client, prompt: str, model: str) -> EditorialOutput: - response = await client.aio.models.generate_content( - model=model, - contents=prompt, - config=types.GenerateContentConfig( - response_mime_type="application/json", - response_schema=EditorialOutput, - temperature=0.7, + response = await cost_tracking.track_call( + "editorial", + model, + cost_tracking.extract_text_usage, + client.aio.models.generate_content( + model=model, + contents=prompt, + config=types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=EditorialOutput, + temperature=0.7, + ), ), ) raw_text = response.text or "{}" diff --git a/packages/ai-server/src/post_editorial/nodes/image_analysis.py b/packages/ai-server/src/post_editorial/nodes/image_analysis.py index fcbf0ea4..24e52a97 100644 --- a/packages/ai-server/src/post_editorial/nodes/image_analysis.py +++ b/packages/ai-server/src/post_editorial/nodes/image_analysis.py @@ -9,6 +9,8 @@ from google.genai import types from pydantic import BaseModel +from src.services import cost_tracking + from ..state import PostEditorialState from ..config import get_settings from ..gemini_retry import call_gemini_with_fallback @@ -49,16 +51,21 @@ async def _download_image(url: str) -> tuple[bytes, str]: async def _analyze_image( client: genai.Client, model: str, prompt: str, image_bytes: bytes, mime_type: str ) -> ImageAnalysisOutput: - response = await client.aio.models.generate_content( - model=model, - contents=[ - prompt, - types.Part.from_bytes(data=image_bytes, mime_type=mime_type), - ], - config=types.GenerateContentConfig( - response_mime_type="application/json", - response_schema=ImageAnalysisOutput, - temperature=0.3, + response = await cost_tracking.track_call( + "image_analysis", + model, + cost_tracking.extract_text_usage, + client.aio.models.generate_content( + model=model, + contents=[ + prompt, + types.Part.from_bytes(data=image_bytes, mime_type=mime_type), + ], + config=types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=ImageAnalysisOutput, + temperature=0.3, + ), ), ) raw_text = response.text or "{}" diff --git a/packages/ai-server/src/post_editorial/nodes/item_search.py b/packages/ai-server/src/post_editorial/nodes/item_search.py index 3c45124f..32f22347 100644 --- a/packages/ai-server/src/post_editorial/nodes/item_search.py +++ b/packages/ai-server/src/post_editorial/nodes/item_search.py @@ -13,6 +13,8 @@ from google.genai import types from pydantic import BaseModel +from src.services import cost_tracking + from src.managers.database import DatabaseManager from ..state import PostEditorialState @@ -253,13 +255,18 @@ def _build_ranking_prompt( async def _rank_items( client: genai.Client, prompt: str, model: str ) -> ItemRankingOutput: - response = await client.aio.models.generate_content( - model=model, - contents=prompt, - config=types.GenerateContentConfig( - response_mime_type="application/json", - response_schema=ItemRankingOutput, - temperature=0.3, + response = await cost_tracking.track_call( + "item_search", + model, + cost_tracking.extract_text_usage, + client.aio.models.generate_content( + model=model, + contents=prompt, + config=types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=ItemRankingOutput, + temperature=0.3, + ), ), ) raw_text = response.text or "{}" diff --git a/packages/ai-server/src/post_editorial/nodes/news_research.py b/packages/ai-server/src/post_editorial/nodes/news_research.py index 3fb40f6e..f83434c9 100644 --- a/packages/ai-server/src/post_editorial/nodes/news_research.py +++ b/packages/ai-server/src/post_editorial/nodes/news_research.py @@ -15,6 +15,8 @@ from google.genai import types from pydantic import BaseModel +from src.services import cost_tracking + from ..state import PostEditorialState from ..config import get_settings from ..gemini_retry import call_gemini_with_fallback @@ -243,13 +245,18 @@ async def _filter_with_gemini( client = genai.Client(api_key=settings.gemini_api_key) async def _generate(model: str) -> NewsFilterOutput: - response = await client.aio.models.generate_content( - model=model, - contents=prompt, - config=types.GenerateContentConfig( - response_mime_type="application/json", - response_schema=NewsFilterOutput, - temperature=0.1, + response = await cost_tracking.track_call( + "news_research", + model, + cost_tracking.extract_text_usage, + client.aio.models.generate_content( + model=model, + contents=prompt, + config=types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=NewsFilterOutput, + temperature=0.1, + ), ), ) return NewsFilterOutput.model_validate_json(response.text or '{"articles":[]}') diff --git a/packages/ai-server/src/post_editorial/nodes/review.py b/packages/ai-server/src/post_editorial/nodes/review.py index e0d86c66..d566bb0e 100644 --- a/packages/ai-server/src/post_editorial/nodes/review.py +++ b/packages/ai-server/src/post_editorial/nodes/review.py @@ -11,6 +11,8 @@ from google.genai import types from pydantic import ValidationError +from src.services import cost_tracking + from ..models import PostMagazineLayout, ReviewResult, CriterionResult from ..state import PostEditorialState from ..config import get_settings @@ -147,11 +149,16 @@ async def review_node(state: PostEditorialState) -> dict: async def _call_review(model: str): return await asyncio.wait_for( - client.aio.models.generate_content( - model=model, - contents=prompt, - config=types.GenerateContentConfig( - response_mime_type="application/json", temperature=0.0 + cost_tracking.track_call( + "review", + model, + cost_tracking.extract_text_usage, + client.aio.models.generate_content( + model=model, + contents=prompt, + config=types.GenerateContentConfig( + response_mime_type="application/json", temperature=0.0 + ), ), ), timeout=_REVIEW_TIMEOUT, diff --git a/packages/ai-server/src/services/cost_tracking/__init__.py b/packages/ai-server/src/services/cost_tracking/__init__.py new file mode 100644 index 00000000..953110a6 --- /dev/null +++ b/packages/ai-server/src/services/cost_tracking/__init__.py @@ -0,0 +1,43 @@ +"""Gemini API per-call 비용 추적 (#cost-tracking). + +ai-server 모든 Gemini 호출의 사용량 / 비용을 *호출 시점에* DB +(`gemini_usage_events`) 에 적립한다. 단가는 DB SOT (`gemini_pricing`, +SCD-2) 에서 동적 lookup — 코드 하드코딩 없음. + +설계 원칙: + - **fire-and-forget**: 추적이 본 파이프라인 latency / 실패에 영향 없음. + - **실패도 row 적립**: ok=false, error_class 로 분류 — 가시화. + - **lazy init**: ASSETS_DATABASE_URL 미설정이면 silent no-op. + - **단가 cache**: 5 분 TTL 메모리 캐시, 어드민 단가 변경 시 자동 반영. + +호출 사이트 (parser 별): + resp = await track_call( + "items_parser", model, extract_text_usage, + client.aio.models.generate_content(model=..., contents=..., config=...), + ) + +Pipeline runner 가 진입 시 한 번: + cost_tracking.set_context(raw_post_id=..., pipeline="raw_post") +""" + +from ._impl import ( + extract_grounded_response, + extract_grounded_usage, + extract_image_usage, + extract_rest_text_usage, + extract_text_usage, + set_context, + shutdown, + track_call, +) + +__all__ = [ + "extract_grounded_response", + "extract_grounded_usage", + "extract_image_usage", + "extract_rest_text_usage", + "extract_text_usage", + "set_context", + "shutdown", + "track_call", +] diff --git a/packages/ai-server/src/services/cost_tracking/_impl.py b/packages/ai-server/src/services/cost_tracking/_impl.py new file mode 100644 index 00000000..01490a1a --- /dev/null +++ b/packages/ai-server/src/services/cost_tracking/_impl.py @@ -0,0 +1,397 @@ +"""cost_tracking 내부 구현 — pool · pricing cache · recorder · estimator · context. + +본 모듈은 `__init__.py` 가 re-export 하는 5개 심볼만 외부에 노출: + track_call, set_context, extract_{text,image,grounded}_usage, shutdown +""" + +from __future__ import annotations + +import asyncio +import contextvars +import json +import logging +import os +import time +from typing import Any, Awaitable, Callable, Optional + +import asyncpg + + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# pool — 자체 asyncpg pool. DatabaseManager 와 분리 (DI 변경 없이 동작). +# --------------------------------------------------------------------------- + +_pool: Optional[asyncpg.Pool] = None +_pool_lock = asyncio.Lock() +_pool_failed = False # init 실패 후 재시도 안 함 (조용한 no-op) + + +async def _get_pool() -> Optional[asyncpg.Pool]: + """ASSETS_DATABASE_URL 로 lazy init. 미설정 / 실패 시 None — no-op.""" + global _pool, _pool_failed + if _pool is not None: + return _pool + if _pool_failed: + return None + async with _pool_lock: + if _pool is not None: + return _pool + if _pool_failed: + return None + dsn = ( + os.environ.get("ASSETS_DATABASE_URL") + or os.environ.get("OPERATION_DATABASE_URL") + ) + if not dsn: + logger.info("cost_tracking: no DSN — disabled (no-op)") + _pool_failed = True + return None + try: + _pool = await asyncpg.create_pool( + dsn=dsn, min_size=1, max_size=3, command_timeout=10 + ) + except Exception as exc: # noqa: BLE001 + logger.warning("cost_tracking: pool init failed (%s) — disabled", exc) + _pool_failed = True + return None + logger.info("cost_tracking: pool initialized") + return _pool + + +async def shutdown() -> None: + """선택적 종료 — 테스트 / app shutdown 용.""" + global _pool + if _pool is not None: + await _pool.close() + _pool = None + + +# --------------------------------------------------------------------------- +# pricing cache — DB SOT 의 5분 TTL 메모리 캐시 +# --------------------------------------------------------------------------- + +_PRICE_TTL_SEC = 300.0 +_price_cache: dict[tuple[str, str, str], float] = {} +_price_loaded_at = 0.0 +_price_lock = asyncio.Lock() + + +async def _refresh_pricing_if_stale() -> None: + global _price_cache, _price_loaded_at + now = time.monotonic() + if now - _price_loaded_at < _PRICE_TTL_SEC: + return + async with _price_lock: + if now - _price_loaded_at < _PRICE_TTL_SEC: + return + pool = await _get_pool() + if pool is None: + return + try: + async with pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT model, unit_kind, tier, usd_per_unit::float8 AS rate + FROM public.gemini_pricing + WHERE effective_to IS NULL + """ + ) + except Exception as exc: # noqa: BLE001 + logger.warning("cost_tracking: pricing reload failed: %s", exc) + return + _price_cache = {(r["model"], r["unit_kind"], r["tier"]): r["rate"] for r in rows} + _price_loaded_at = time.monotonic() + logger.debug("cost_tracking: pricing cache reloaded (%d rows)", len(_price_cache)) + + +async def _price_lookup(model: str, unit_kind: str, tier: str = "default") -> Optional[float]: + await _refresh_pricing_if_stale() + return _price_cache.get((model, unit_kind, tier)) + + +async def _model_snapshot(model: str, tier: str = "default") -> dict[str, float]: + """현재 model 의 모든 unit_kind 단가 dict — pricing_snapshot 빌드용.""" + await _refresh_pricing_if_stale() + return { + uk: rate + for (m, uk, t), rate in _price_cache.items() + if m == model and t == tier + } + + +# --------------------------------------------------------------------------- +# estimator — usage dict + model → (cost, snapshot, error_class) +# --------------------------------------------------------------------------- + + +async def _estimate(model: str, usage: dict[str, Any]) -> tuple[float, dict[str, float], Optional[str]]: + snap = await _model_snapshot(model) + cost = 0.0 + used_snap: dict[str, float] = {} + err: Optional[str] = None + + prompt = int(usage.get("prompt_tokens") or 0) + completion = int(usage.get("completion_tokens") or 0) + cached = int(usage.get("cached_tokens") or 0) + image_n = int(usage.get("image_output_count") or 0) + grounding_n = int(usage.get("grounding_queries") or 0) + + has_any_token = (prompt + completion + cached) > 0 + if has_any_token and "input_token" not in snap and "output_token" not in snap: + err = "no_pricing" + + if "input_token" in snap: + rate = snap["input_token"] + used_snap["input_token"] = rate + cost += max(0, prompt - cached) * rate + if "output_token" in snap and completion: + rate = snap["output_token"] + used_snap["output_token"] = rate + cost += completion * rate + if cached and "cached_input_token" in snap: + rate = snap["cached_input_token"] + used_snap["cached_input_token"] = rate + cost += cached * rate + if image_n and "image_output" in snap: + rate = snap["image_output"] + used_snap["image_output"] = rate + cost += image_n * rate + if grounding_n: + grate = await _price_lookup("grounding", "grounding_query") + if grate is not None: + used_snap["grounding_query"] = grate + cost += grounding_n * grate + else: + err = err or "no_pricing" + + return (cost, used_snap, err) + + +# --------------------------------------------------------------------------- +# context — contextvars (raw_post_id / post_id / pipeline 자동 전파) +# --------------------------------------------------------------------------- + +_raw_post_id_var: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar( + "ct_raw_post_id", default=None +) +_post_id_var: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar( + "ct_post_id", default=None +) +_pipeline_var: contextvars.ContextVar[str] = contextvars.ContextVar( + "ct_pipeline", default="ad_hoc" +) + + +def set_context( + *, + raw_post_id: Optional[str] = None, + post_id: Optional[str] = None, + pipeline: str = "ad_hoc", +) -> None: + """파이프라인 진입부에서 한 번 호출. 이후 같은 async task 안의 모든 + `track_call` 이 자동으로 read.""" + _raw_post_id_var.set(raw_post_id) + _post_id_var.set(post_id) + _pipeline_var.set(pipeline) + + +# --------------------------------------------------------------------------- +# recorder — fire-and-forget INSERT +# --------------------------------------------------------------------------- + + +def _classify(exc: BaseException) -> str: + msg = str(exc).lower() + if "safety" in msg or "blocked" in msg or "block_reason" in msg: + return "safety_block" + if "quota" in msg or "rate limit" in msg or "resource_exhausted" in msg: + return "quota" + if "timeout" in msg or "deadline" in msg: + return "timeout" + if "parse" in msg or "json" in msg: + return "parse_error" + if any(c in msg for c in ("500", "502", "503", "504")): + return "http_5xx" + return "error" + + +def _record( # fire-and-forget + *, + step: str, + model: str, + ok: bool, + usage: dict[str, Any], + cost: float, + snapshot: dict[str, float], + error_class: Optional[str], + latency_ms: int, +) -> None: + raw_post_id = _raw_post_id_var.get() + post_id = _post_id_var.get() + pipeline = _pipeline_var.get() + + async def _insert() -> None: + pool = await _get_pool() + if pool is None: + return + try: + async with pool.acquire() as conn: + await conn.execute( + """ + INSERT INTO public.gemini_usage_events( + raw_post_id, post_id, step, pipeline, model, ok, + prompt_tokens, completion_tokens, cached_tokens, + image_output_count, grounding_queries, + est_cost_usd, pricing_snapshot, error_class, latency_ms + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13::jsonb,$14,$15) + """, + raw_post_id, + post_id, + step, + pipeline, + model, + ok, + usage.get("prompt_tokens"), + usage.get("completion_tokens"), + usage.get("cached_tokens"), + usage.get("image_output_count"), + usage.get("grounding_queries"), + cost, + json.dumps(snapshot) if snapshot else None, + error_class, + latency_ms, + ) + except Exception as exc: # noqa: BLE001 + logger.warning("cost_tracking: insert failed (step=%s): %s", step, exc) + + try: + asyncio.create_task(_insert()) + except RuntimeError: + # event loop 없는 컨텍스트 — silent skip (테스트 등) + pass + + +# --------------------------------------------------------------------------- +# usage extractors — SDK response → 공통 dict +# --------------------------------------------------------------------------- + + +def extract_text_usage(resp: Any) -> dict[str, Any]: + """google.genai 의 resp.usage_metadata → 공통 dict.""" + um = getattr(resp, "usage_metadata", None) + if not um: + return {} + return { + "prompt_tokens": getattr(um, "prompt_token_count", 0) or 0, + "completion_tokens": getattr(um, "candidates_token_count", 0) or 0, + "cached_tokens": getattr(um, "cached_content_token_count", 0) or 0, + } + + +def extract_image_usage(resp: Any) -> dict[str, Any]: + """flash-image: 1 image output + input image 토큰.""" + base = extract_text_usage(resp) + base["image_output_count"] = 1 + return base + + +def extract_grounded_usage(resp_dict: dict) -> dict[str, Any]: + """url_search.grounded_search — httpx response.json() (camelCase 키).""" + um = (resp_dict or {}).get("usageMetadata") or {} + return { + "prompt_tokens": um.get("promptTokenCount", 0) or 0, + "completion_tokens": um.get("candidatesTokenCount", 0) or 0, + "cached_tokens": um.get("cachedContentTokenCount", 0) or 0, + "grounding_queries": 1, + } + + +def extract_grounded_response(resp: Any) -> dict[str, Any]: + """httpx.Response → grounded usage. .json() 결과를 cache (httpx 가 알아서).""" + try: + data = resp.json() + except Exception: # noqa: BLE001 + return {"grounding_queries": 1} + return extract_grounded_usage(data) + + +def extract_rest_text_usage(resp: Any) -> dict[str, Any]: + """httpx.Response → text usage (camelCase 키, grounding 미사용).""" + try: + data = resp.json() + except Exception: # noqa: BLE001 + return {} + um = (data or {}).get("usageMetadata") or {} + return { + "prompt_tokens": um.get("promptTokenCount", 0) or 0, + "completion_tokens": um.get("candidatesTokenCount", 0) or 0, + "cached_tokens": um.get("cachedContentTokenCount", 0) or 0, + } + + +# --------------------------------------------------------------------------- +# track_call — public wrapper (어떤 Gemini 호출이든 통합) +# --------------------------------------------------------------------------- + + +async def track_call( + step: str, + model: str, + extract: Callable[[Any], dict[str, Any]], + coro: Awaitable[Any], +) -> Any: + """Gemini SDK 호출 coroutine 을 감싸 usage / cost 적립. + + `coro` 는 *이미 생성된* coroutine (e.g. `client.aio.models.generate_content(...)`). + 이 함수가 await + 결과 반환. 호출자 인터페이스는 변하지 않음. + + 실패 시 record_failure + re-raise. 본 파이프라인은 정상 에러 흐름 유지. + """ + t0 = time.monotonic() + try: + resp = await coro + except BaseException as exc: + latency = int((time.monotonic() - t0) * 1000) + _record( + step=step, + model=model, + ok=False, + usage={}, + cost=0.0, + snapshot={}, + error_class=_classify(exc), + latency_ms=latency, + ) + raise + latency = int((time.monotonic() - t0) * 1000) + try: + usage = extract(resp) or {} + cost, snap, err = await _estimate(model, usage) + except Exception as exc: # noqa: BLE001 + # extract / pricing 실패 — 본 결과는 반환, ok=true row 만 cost=0 + logger.warning("cost_tracking: usage/estimate failed (step=%s): %s", step, exc) + _record( + step=step, + model=model, + ok=True, + usage={}, + cost=0.0, + snapshot={}, + error_class="extract_error", + latency_ms=latency, + ) + return resp + _record( + step=step, + model=model, + ok=True, + usage=usage, + cost=cost, + snapshot=snap, + error_class=err, + latency_ms=latency, + ) + return resp diff --git a/packages/ai-server/src/services/post_editorial/post_editorial_service.py b/packages/ai-server/src/services/post_editorial/post_editorial_service.py index 25e3b543..eaadd170 100644 --- a/packages/ai-server/src/services/post_editorial/post_editorial_service.py +++ b/packages/ai-server/src/services/post_editorial/post_editorial_service.py @@ -67,6 +67,15 @@ async def post_editorial_job( metadata_extract_service = ctx.get("metadata_extract_service") from src.post_editorial.graph import create_post_editorial_graph + from src.services import cost_tracking + + # cost_tracking 컨텍스트 — 이후 모든 LangGraph 노드의 Gemini 호출이 + # post_id + pipeline 라벨로 자동 적립. + post_id = (post_data or {}).get("id") if isinstance(post_data, dict) else None + cost_tracking.set_context( + post_id=str(post_id) if post_id else None, + pipeline="post_editorial", + ) graph = create_post_editorial_graph() diff --git a/packages/ai-server/src/services/raw_posts/processors/hero_reframe.py b/packages/ai-server/src/services/raw_posts/processors/hero_reframe.py index 8c5d1078..d08fda27 100644 --- a/packages/ai-server/src/services/raw_posts/processors/hero_reframe.py +++ b/packages/ai-server/src/services/raw_posts/processors/hero_reframe.py @@ -57,6 +57,7 @@ async def reframe( image_mime_type=content_type or "image/jpeg", prompt=prompt, aspect_ratio="4:5", + step="hero_reframe", ) except NanoBananaError as exc: raise HeroReframeError(str(exc)) from exc diff --git a/packages/ai-server/src/services/raw_posts/processors/items_parser.py b/packages/ai-server/src/services/raw_posts/processors/items_parser.py index aed555f0..01dfc91a 100644 --- a/packages/ai-server/src/services/raw_posts/processors/items_parser.py +++ b/packages/ai-server/src/services/raw_posts/processors/items_parser.py @@ -15,6 +15,8 @@ from google.genai import types as genai_types from pydantic import BaseModel +from src.services import cost_tracking + from .prompts import ITEMS_PROMPT from .schemas import ParsedItem, SpotBox @@ -85,19 +87,24 @@ async def parse( if caption: text += f"\n\nThe pin caption is: {caption[:200]}" - resp = await self._client.aio.models.generate_content( - model=self._model, - contents=[ - genai_types.Part.from_bytes( - data=composite_bytes, - mime_type=content_type or "image/jpeg", + resp = await cost_tracking.track_call( + "items_parser", + self._model, + cost_tracking.extract_text_usage, + self._client.aio.models.generate_content( + model=self._model, + contents=[ + genai_types.Part.from_bytes( + data=composite_bytes, + mime_type=content_type or "image/jpeg", + ), + text, + ], + config=genai_types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=_ItemsResponse, + temperature=0.1, ), - text, - ], - config=genai_types.GenerateContentConfig( - response_mime_type="application/json", - response_schema=_ItemsResponse, - temperature=0.1, ), ) diff --git a/packages/ai-server/src/services/raw_posts/processors/items_thumbnail.py b/packages/ai-server/src/services/raw_posts/processors/items_thumbnail.py index b50eb036..0751d7e6 100644 --- a/packages/ai-server/src/services/raw_posts/processors/items_thumbnail.py +++ b/packages/ai-server/src/services/raw_posts/processors/items_thumbnail.py @@ -162,6 +162,7 @@ async def _maybe_generate_one( prompt=prompt, aspect_ratio="1:1", image_size="1K", + step="items_thumbnail", ) except NanoBananaError as exc: logger.warning( @@ -272,6 +273,7 @@ async def _maybe_download_one( prompt=prompt, aspect_ratio="1:1", image_size="1K", + step="items_thumbnail_refine", ) except NanoBananaError as exc: logger.warning( diff --git a/packages/ai-server/src/services/raw_posts/processors/spots_parser.py b/packages/ai-server/src/services/raw_posts/processors/spots_parser.py index 02094fbc..07755266 100644 --- a/packages/ai-server/src/services/raw_posts/processors/spots_parser.py +++ b/packages/ai-server/src/services/raw_posts/processors/spots_parser.py @@ -20,6 +20,8 @@ from google.genai import types as genai_types from pydantic import BaseModel, Field +from src.services import cost_tracking + from .prompts import SPOTS_PROMPT from .schemas import ParsedItem, SpotBox @@ -85,19 +87,24 @@ async def detect( ) text = f"{self._prompt}\n\nItems:\n{listing}" - resp = await self._client.aio.models.generate_content( - model=self._model, - contents=[ - genai_types.Part.from_bytes( - data=hero_bytes, - mime_type=hero_content_type or "image/png", + resp = await cost_tracking.track_call( + "spots_parser", + self._model, + cost_tracking.extract_text_usage, + self._client.aio.models.generate_content( + model=self._model, + contents=[ + genai_types.Part.from_bytes( + data=hero_bytes, + mime_type=hero_content_type or "image/png", + ), + text, + ], + config=genai_types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=_SpotsResponse, + temperature=0.1, ), - text, - ], - config=genai_types.GenerateContentConfig( - response_mime_type="application/json", - response_schema=_SpotsResponse, - temperature=0.1, ), ) diff --git a/packages/ai-server/src/services/raw_posts/processors/subject_parser.py b/packages/ai-server/src/services/raw_posts/processors/subject_parser.py index 456b75a8..5ff013cf 100644 --- a/packages/ai-server/src/services/raw_posts/processors/subject_parser.py +++ b/packages/ai-server/src/services/raw_posts/processors/subject_parser.py @@ -29,6 +29,8 @@ from google.genai import types as genai_types from pydantic import BaseModel +from src.services import cost_tracking + from .prompts import SUBJECT_PROMPT from .schemas import CONTEXT_ENUM, Subject @@ -80,19 +82,24 @@ async def parse( if caption: text += f"\n\nThe pin caption is: {caption[:300]}" - resp = await self._client.aio.models.generate_content( - model=self._model, - contents=[ - genai_types.Part.from_bytes( - data=composite_bytes, - mime_type=content_type or "image/jpeg", + resp = await cost_tracking.track_call( + "subject_parser", + self._model, + cost_tracking.extract_text_usage, + self._client.aio.models.generate_content( + model=self._model, + contents=[ + genai_types.Part.from_bytes( + data=composite_bytes, + mime_type=content_type or "image/jpeg", + ), + text, + ], + config=genai_types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=_SubjectDraft, + temperature=0.1, ), - text, - ], - config=genai_types.GenerateContentConfig( - response_mime_type="application/json", - response_schema=_SubjectDraft, - temperature=0.1, ), ) diff --git a/packages/ai-server/src/services/raw_posts/processors/url_search.py b/packages/ai-server/src/services/raw_posts/processors/url_search.py index 30fc1cd7..4ba60755 100644 --- a/packages/ai-server/src/services/raw_posts/processors/url_search.py +++ b/packages/ai-server/src/services/raw_posts/processors/url_search.py @@ -33,6 +33,8 @@ import httpx +from src.services import cost_tracking + from .schemas import ParsedItem @@ -211,10 +213,19 @@ async def _grounded_search( } url = f"{_GEMINI_BASE}/{self._gemini_model}:generateContent" try: - resp = await client.post( - url, params={"key": self._gemini_key}, json=payload, timeout=60 + async def _do_post(): + r = await client.post( + url, params={"key": self._gemini_key}, json=payload, timeout=60 + ) + r.raise_for_status() + return r + + resp = await cost_tracking.track_call( + "url_grounded_search", + self._gemini_model, + cost_tracking.extract_grounded_response, + _do_post(), ) - resp.raise_for_status() data = resp.json() except Exception as exc: logger.warning("grounded_search HTTP error: %s", exc) @@ -287,10 +298,19 @@ async def _filter( } url = f"{_GEMINI_BASE}/{self._gemini_model}:generateContent" try: - resp = await client.post( - url, params={"key": self._gemini_key}, json=payload, timeout=60 + async def _do_post(): + r = await client.post( + url, params={"key": self._gemini_key}, json=payload, timeout=60 + ) + r.raise_for_status() + return r + + resp = await cost_tracking.track_call( + "url_filter", + self._gemini_model, + cost_tracking.extract_rest_text_usage, + _do_post(), ) - resp.raise_for_status() data = resp.json() except Exception as exc: logger.warning("filter HTTP error: %s", exc) diff --git a/packages/ai-server/src/services/raw_posts/scheduler.py b/packages/ai-server/src/services/raw_posts/scheduler.py index ab335ebe..1e05520f 100644 --- a/packages/ai-server/src/services/raw_posts/scheduler.py +++ b/packages/ai-server/src/services/raw_posts/scheduler.py @@ -31,6 +31,7 @@ from apscheduler.triggers.interval import IntervalTrigger from src.managers.storage.r2_client import R2Client +from src.services import cost_tracking from .discovery.composite_filter import CompositeFilter, CompositeFilterError from .models import FetchRequest @@ -968,6 +969,13 @@ async def step_cb(step: str, ok: bool, note: Optional[str]) -> None: # 가 items / url_search / subject 단계 우회. prelabeled = from_platform_metadata(target.platform_metadata) + # cost_tracking 컨텍스트 — 이후 처리 안 Gemini 호출이 raw_post_id 와 + # pipeline 라벨을 자동 적립. + cost_tracking.set_context( + raw_post_id=str(target.raw_post_id), + pipeline="raw_post", + ) + try: processed = await self._processor.process( composite_bytes=composite_bytes, diff --git a/packages/api-server/src/domains/admin/gemini_cost.rs b/packages/api-server/src/domains/admin/gemini_cost.rs new file mode 100644 index 00000000..5e8e022e --- /dev/null +++ b/packages/api-server/src/domains/admin/gemini_cost.rs @@ -0,0 +1,526 @@ +//! Admin — Gemini API cost tracking dashboard endpoints. +//! +//! 데이터 SOT: `public.gemini_usage_events` + `public.gemini_spend_daily` (view). +//! 단가 SOT: `public.gemini_pricing` (SCD-2). ai-server 의 cost_tracking 이 +//! 호출 시점에 적립한다. + +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + routing::{delete, get}, + Json, Router, +}; +use sea_orm::{ConnectionTrait, DatabaseBackend, DatabaseConnection, Statement, TransactionTrait}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + config::{AppConfig, AppState}, + error::{AppError, AppResult}, + middleware::auth::User, +}; + +// === Query / Response types === + +#[derive(Debug, Deserialize)] +pub struct DaysQuery { + /// 조회 기간 (일). 1..=90. 기본 7. + #[serde(default)] + pub days: Option, +} + +#[derive(Debug, Deserialize)] +pub struct TopRawPostsQuery { + #[serde(default)] + pub days: Option, + #[serde(default)] + pub limit: Option, +} + +#[derive(Debug, Serialize)] +pub struct DailySpendRow { + pub day: String, // YYYY-MM-DD + pub spend_usd: f64, + pub ok_calls: i64, + pub failed_calls: i64, + pub prompt_tokens: i64, + pub completion_tokens: i64, + pub images: i64, + pub groundings: i64, +} + +#[derive(Debug, Serialize)] +pub struct DailySpendResponse { + pub days: Vec, + pub total_usd: f64, +} + +#[derive(Debug, Serialize)] +pub struct GroupSpendRow { + pub key: String, + pub spend_usd: f64, + pub ok_calls: i64, + pub failed_calls: i64, +} + +#[derive(Debug, Serialize)] +pub struct GroupSpendResponse { + pub rows: Vec, + pub total_usd: f64, +} + +#[derive(Debug, Serialize)] +pub struct TodaySpendResponse { + pub today_usd: f64, + pub today_calls: i64, + pub today_failed: i64, + pub yesterday_usd: f64, + pub last_7d_usd: f64, + pub last_30d_usd: f64, +} + +#[derive(Debug, Serialize)] +pub struct TopRawPostRow { + pub raw_post_id: String, + pub spend_usd: f64, + pub call_count: i64, +} + +#[derive(Debug, Serialize)] +pub struct TopRawPostsResponse { + pub rows: Vec, +} + +#[derive(Debug, Serialize)] +pub struct PricingRow { + pub id: String, + pub model: String, + pub unit_kind: String, + pub tier: String, + pub usd_per_unit: f64, + pub effective_from: String, + pub effective_to: Option, + pub source: String, + pub notes: Option, +} + +#[derive(Debug, Serialize)] +pub struct PricingListResponse { + pub active: Vec, + pub history: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct UpsertPricingRequest { + pub model: String, + pub unit_kind: String, + pub usd_per_unit: f64, + #[serde(default)] + pub tier: Option, + #[serde(default)] + pub notes: Option, +} + +// === Handlers === + +fn clamp_days(days: Option, default: i64) -> i64 { + days.unwrap_or(default).clamp(1, 90) +} + +pub async fn get_daily_spend( + State(state): State, + _user: axum::Extension, + Query(q): Query, +) -> AppResult> { + let days = clamp_days(q.days, 7); + let db = state.assets_db.as_ref(); + let stmt = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT date_trunc('day', occurred_at)::date::text AS day, + COALESCE(SUM(est_cost_usd), 0)::float8 AS spend_usd, + COUNT(*) FILTER (WHERE ok) AS ok_calls, + COUNT(*) FILTER (WHERE NOT ok) AS failed_calls, + COALESCE(SUM(prompt_tokens), 0) AS prompt_tokens, + COALESCE(SUM(completion_tokens), 0) AS completion_tokens, + COALESCE(SUM(image_output_count), 0) AS images, + COALESCE(SUM(grounding_queries), 0) AS groundings + FROM public.gemini_usage_events + WHERE occurred_at >= now() - ($1::int * interval '1 day') + GROUP BY 1 + ORDER BY 1 DESC", + vec![(days as i32).into()], + ); + let rows = db.query_all(stmt).await.map_err(AppError::DatabaseError)?; + let mut days_out = Vec::with_capacity(rows.len()); + let mut total = 0.0f64; + for r in rows { + let row = DailySpendRow { + day: r.try_get("", "day").map_err(AppError::DatabaseError)?, + spend_usd: r + .try_get("", "spend_usd") + .map_err(AppError::DatabaseError)?, + ok_calls: r.try_get("", "ok_calls").map_err(AppError::DatabaseError)?, + failed_calls: r + .try_get("", "failed_calls") + .map_err(AppError::DatabaseError)?, + prompt_tokens: r + .try_get("", "prompt_tokens") + .map_err(AppError::DatabaseError)?, + completion_tokens: r + .try_get("", "completion_tokens") + .map_err(AppError::DatabaseError)?, + images: r.try_get("", "images").map_err(AppError::DatabaseError)?, + groundings: r + .try_get("", "groundings") + .map_err(AppError::DatabaseError)?, + }; + total += row.spend_usd; + days_out.push(row); + } + Ok(Json(DailySpendResponse { + days: days_out, + total_usd: total, + })) +} + +async fn group_spend( + db: &DatabaseConnection, + group_col: &str, + days: i64, +) -> AppResult { + // group_col 은 whitelist 만 — SQL injection 회피. + let sql = format!( + "SELECT {gc}::text AS key, + COALESCE(SUM(est_cost_usd), 0)::float8 AS spend_usd, + COUNT(*) FILTER (WHERE ok) AS ok_calls, + COUNT(*) FILTER (WHERE NOT ok) AS failed_calls + FROM public.gemini_usage_events + WHERE occurred_at >= now() - ($1::int * interval '1 day') + GROUP BY 1 + ORDER BY spend_usd DESC", + gc = group_col, + ); + let stmt = + Statement::from_sql_and_values(DatabaseBackend::Postgres, &sql, vec![(days as i32).into()]); + let rows = db.query_all(stmt).await.map_err(AppError::DatabaseError)?; + let mut out = Vec::with_capacity(rows.len()); + let mut total = 0.0f64; + for r in rows { + let row = GroupSpendRow { + key: r.try_get("", "key").map_err(AppError::DatabaseError)?, + spend_usd: r + .try_get("", "spend_usd") + .map_err(AppError::DatabaseError)?, + ok_calls: r.try_get("", "ok_calls").map_err(AppError::DatabaseError)?, + failed_calls: r + .try_get("", "failed_calls") + .map_err(AppError::DatabaseError)?, + }; + total += row.spend_usd; + out.push(row); + } + Ok(GroupSpendResponse { + rows: out, + total_usd: total, + }) +} + +pub async fn get_spend_by_step( + State(state): State, + _user: axum::Extension, + Query(q): Query, +) -> AppResult> { + let days = clamp_days(q.days, 7); + Ok(Json( + group_spend(state.assets_db.as_ref(), "step", days).await?, + )) +} + +pub async fn get_spend_by_model( + State(state): State, + _user: axum::Extension, + Query(q): Query, +) -> AppResult> { + let days = clamp_days(q.days, 7); + Ok(Json( + group_spend(state.assets_db.as_ref(), "model", days).await?, + )) +} + +pub async fn get_spend_by_pipeline( + State(state): State, + _user: axum::Extension, + Query(q): Query, +) -> AppResult> { + let days = clamp_days(q.days, 7); + Ok(Json( + group_spend(state.assets_db.as_ref(), "pipeline", days).await?, + )) +} + +pub async fn get_today_spend( + State(state): State, + _user: axum::Extension, +) -> AppResult> { + let db = state.assets_db.as_ref(); + let stmt = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT + COALESCE(SUM(est_cost_usd) FILTER ( + WHERE occurred_at >= date_trunc('day', now())), 0)::float8 AS today_usd, + COUNT(*) FILTER ( + WHERE occurred_at >= date_trunc('day', now()) AND ok) AS today_calls, + COUNT(*) FILTER ( + WHERE occurred_at >= date_trunc('day', now()) AND NOT ok) AS today_failed, + COALESCE(SUM(est_cost_usd) FILTER ( + WHERE occurred_at >= date_trunc('day', now()) - interval '1 day' + AND occurred_at < date_trunc('day', now())), 0)::float8 AS yesterday_usd, + COALESCE(SUM(est_cost_usd) FILTER ( + WHERE occurred_at >= now() - interval '7 days'), 0)::float8 AS last_7d_usd, + COALESCE(SUM(est_cost_usd) FILTER ( + WHERE occurred_at >= now() - interval '30 days'), 0)::float8 AS last_30d_usd + FROM public.gemini_usage_events", + Vec::::new(), + ); + let row = db + .query_one(stmt) + .await + .map_err(AppError::DatabaseError)? + .ok_or_else(|| AppError::internal("today spend row missing"))?; + Ok(Json(TodaySpendResponse { + today_usd: row + .try_get("", "today_usd") + .map_err(AppError::DatabaseError)?, + today_calls: row + .try_get("", "today_calls") + .map_err(AppError::DatabaseError)?, + today_failed: row + .try_get("", "today_failed") + .map_err(AppError::DatabaseError)?, + yesterday_usd: row + .try_get("", "yesterday_usd") + .map_err(AppError::DatabaseError)?, + last_7d_usd: row + .try_get("", "last_7d_usd") + .map_err(AppError::DatabaseError)?, + last_30d_usd: row + .try_get("", "last_30d_usd") + .map_err(AppError::DatabaseError)?, + })) +} + +pub async fn get_top_raw_posts( + State(state): State, + _user: axum::Extension, + Query(q): Query, +) -> AppResult> { + let days = clamp_days(q.days, 7); + let limit = q.limit.unwrap_or(10).clamp(1, 100); + let db = state.assets_db.as_ref(); + let stmt = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT raw_post_id::text AS raw_post_id, + COALESCE(SUM(est_cost_usd), 0)::float8 AS spend_usd, + COUNT(*) AS call_count + FROM public.gemini_usage_events + WHERE raw_post_id IS NOT NULL + AND occurred_at >= now() - ($1::int * interval '1 day') + GROUP BY raw_post_id + ORDER BY spend_usd DESC + LIMIT $2", + vec![(days as i32).into(), (limit as i64).into()], + ); + let rows = db.query_all(stmt).await.map_err(AppError::DatabaseError)?; + let mut out = Vec::with_capacity(rows.len()); + for r in rows { + out.push(TopRawPostRow { + raw_post_id: r + .try_get("", "raw_post_id") + .map_err(AppError::DatabaseError)?, + spend_usd: r + .try_get("", "spend_usd") + .map_err(AppError::DatabaseError)?, + call_count: r + .try_get("", "call_count") + .map_err(AppError::DatabaseError)?, + }); + } + Ok(Json(TopRawPostsResponse { rows: out })) +} + +// === Pricing CRUD === + +fn row_to_pricing(r: sea_orm::QueryResult) -> AppResult { + let id: Uuid = r.try_get("", "id").map_err(AppError::DatabaseError)?; + let effective_from: chrono::DateTime = r + .try_get("", "effective_from") + .map_err(AppError::DatabaseError)?; + let effective_to: Option> = r + .try_get("", "effective_to") + .map_err(AppError::DatabaseError)?; + Ok(PricingRow { + id: id.to_string(), + model: r.try_get("", "model").map_err(AppError::DatabaseError)?, + unit_kind: r + .try_get("", "unit_kind") + .map_err(AppError::DatabaseError)?, + tier: r.try_get("", "tier").map_err(AppError::DatabaseError)?, + usd_per_unit: r + .try_get("", "usd_per_unit") + .map_err(AppError::DatabaseError)?, + effective_from: effective_from.to_rfc3339(), + effective_to: effective_to.map(|t| t.to_rfc3339()), + source: r.try_get("", "source").map_err(AppError::DatabaseError)?, + notes: r.try_get("", "notes").map_err(AppError::DatabaseError)?, + }) +} + +pub async fn list_pricing( + State(state): State, + _user: axum::Extension, +) -> AppResult> { + let db = state.assets_db.as_ref(); + let stmt = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT id, model, unit_kind, tier, usd_per_unit::float8 AS usd_per_unit, + effective_from, effective_to, source, notes + FROM public.gemini_pricing + ORDER BY model, unit_kind, tier, effective_from DESC", + Vec::::new(), + ); + let rows = db.query_all(stmt).await.map_err(AppError::DatabaseError)?; + let mut active = Vec::new(); + let mut history = Vec::new(); + for r in rows { + let p = row_to_pricing(r)?; + if p.effective_to.is_none() { + active.push(p); + } else { + history.push(p); + } + } + Ok(Json(PricingListResponse { active, history })) +} + +pub async fn upsert_pricing( + State(state): State, + _user: axum::Extension, + Json(body): Json, +) -> AppResult> { + if body.usd_per_unit < 0.0 { + return Err(AppError::bad_request("usd_per_unit must be >= 0")); + } + let allowed_units = [ + "input_token", + "output_token", + "cached_input_token", + "image_output", + "grounding_query", + ]; + if !allowed_units.contains(&body.unit_kind.as_str()) { + return Err(AppError::bad_request( + "unit_kind must be one of: input_token, output_token, cached_input_token, image_output, grounding_query", + )); + } + let tier = body.tier.unwrap_or_else(|| "default".to_string()); + let notes = body.notes; + + let db = state.assets_db.as_ref(); + // Two-step: close existing active row, then insert new active row. + // SeaORM `query_one` can run inside a Statement — but for transaction we + // use raw SQL fenced by BEGIN/COMMIT via `db.execute_unprepared`. + let txn_sql = " + BEGIN; + UPDATE public.gemini_pricing + SET effective_to = now(), updated_at = now() + WHERE model = $1 AND unit_kind = $2 AND tier = $3 AND effective_to IS NULL; + INSERT INTO public.gemini_pricing(model, unit_kind, tier, usd_per_unit, source, notes) + VALUES ($1, $2, $3, $4, 'manual', $5) + RETURNING id, model, unit_kind, tier, usd_per_unit::float8 AS usd_per_unit, + effective_from, effective_to, source, notes; + COMMIT; + "; + // sea_orm 의 Statement 는 single-statement 라 트랜잭션을 묶기 어렵다 — SQL 분할 실행. + let txn = db.begin().await.map_err(AppError::DatabaseError)?; + let close_stmt = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "UPDATE public.gemini_pricing + SET effective_to = now(), updated_at = now() + WHERE model = $1 AND unit_kind = $2 AND tier = $3 AND effective_to IS NULL", + vec![ + body.model.clone().into(), + body.unit_kind.clone().into(), + tier.clone().into(), + ], + ); + txn.execute(close_stmt) + .await + .map_err(AppError::DatabaseError)?; + + let insert_stmt = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "INSERT INTO public.gemini_pricing(model, unit_kind, tier, usd_per_unit, source, notes) + VALUES ($1, $2, $3, $4, 'manual', $5) + RETURNING id, model, unit_kind, tier, usd_per_unit::float8 AS usd_per_unit, + effective_from, effective_to, source, notes", + vec![ + body.model.into(), + body.unit_kind.into(), + tier.into(), + body.usd_per_unit.into(), + notes + .clone() + .map(|n| n.into()) + .unwrap_or(sea_orm::Value::String(None)), + ], + ); + let new_row = txn + .query_one(insert_stmt) + .await + .map_err(AppError::DatabaseError)? + .ok_or_else(|| AppError::internal("pricing insert returned no row"))?; + let pricing = row_to_pricing(new_row)?; + txn.commit().await.map_err(AppError::DatabaseError)?; + + let _ = txn_sql; // doc reference + Ok(Json(pricing)) +} + +pub async fn retire_pricing( + State(state): State, + _user: axum::Extension, + Path(id): Path, +) -> AppResult { + let db = state.assets_db.as_ref(); + let stmt = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "UPDATE public.gemini_pricing + SET effective_to = now(), updated_at = now() + WHERE id = $1 AND effective_to IS NULL", + vec![id.into()], + ); + db.execute(stmt).await.map_err(AppError::DatabaseError)?; + Ok(StatusCode::NO_CONTENT) +} + +// === Router === + +pub fn router(state: AppState, app_config: AppConfig) -> Router { + Router::new() + .route("/spend/daily", get(get_daily_spend)) + .route("/spend/by-step", get(get_spend_by_step)) + .route("/spend/by-model", get(get_spend_by_model)) + .route("/spend/by-pipeline", get(get_spend_by_pipeline)) + .route("/spend/today", get(get_today_spend)) + .route("/spend/top-raw-posts", get(get_top_raw_posts)) + .route("/pricing", get(list_pricing).post(upsert_pricing)) + .route("/pricing/{id}", delete(retire_pricing)) + .layer(axum::middleware::from_fn_with_state( + state, + crate::middleware::admin_db_middleware, + )) + .layer(axum::middleware::from_fn_with_state( + app_config, + crate::middleware::auth_middleware, + )) +} diff --git a/packages/api-server/src/domains/admin/handlers.rs b/packages/api-server/src/domains/admin/handlers.rs index 50a4e9fb..e1eecb84 100644 --- a/packages/api-server/src/domains/admin/handlers.rs +++ b/packages/api-server/src/domains/admin/handlers.rs @@ -9,7 +9,8 @@ use crate::{app_state::AppState, config::AppConfig}; use super::{ badges, categories, curations, dashboard, editorial_article_chat, editorial_articles, editorial_candidates, editorial_discovery_settings, editorial_pipeline_settings, - editorial_recommendations, magazine_sessions, monitoring, posts, solutions, spots, synonyms, + editorial_recommendations, gemini_cost, magazine_sessions, monitoring, posts, solutions, spots, + synonyms, }; use crate::domains::reports; @@ -53,6 +54,10 @@ pub fn router(state: AppState, app_config: AppConfig) -> Router { "/dashboard", dashboard::router(state.clone(), app_config.clone()), ) + .nest( + "/gemini-cost", + gemini_cost::router(state.clone(), app_config.clone()), + ) .nest("/badges", badges::router(app_config.clone())) .nest("/reports", reports::admin_router(app_config.clone())) .nest( diff --git a/packages/api-server/src/domains/admin/mod.rs b/packages/api-server/src/domains/admin/mod.rs index f79112df..4b124107 100644 --- a/packages/api-server/src/domains/admin/mod.rs +++ b/packages/api-server/src/domains/admin/mod.rs @@ -13,6 +13,7 @@ pub mod editorial_candidates; pub mod editorial_discovery_settings; pub mod editorial_pipeline_settings; pub mod editorial_recommendations; +pub mod gemini_cost; pub mod magazine_sessions; pub mod monitoring; pub mod posts; diff --git a/packages/web/app/admin/gemini-cost/page.tsx b/packages/web/app/admin/gemini-cost/page.tsx new file mode 100644 index 00000000..c9283f1d --- /dev/null +++ b/packages/web/app/admin/gemini-cost/page.tsx @@ -0,0 +1,475 @@ +"use client"; + +/** + * /admin/gemini-cost — Gemini API 비용 대시보드 (#cost-tracking). + * + * 데이터 SOT: `public.gemini_usage_events` + `public.gemini_pricing` (assets DB). + * ai-server cost_tracking 모듈이 호출 시점에 fire-and-forget 으로 적립. + */ + +import { useState } from "react"; +import { + ResponsiveContainer, + AreaChart, + Area, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, +} from "recharts"; + +import { + useGeminiDailySpend, + useGeminiSpendByStep, + useGeminiSpendByModel, + useGeminiSpendByPipeline, + useGeminiTodaySpend, + useGeminiTopRawPosts, + useGeminiPricing, + useUpsertGeminiPricing, + useRetireGeminiPricing, + type PricingRow, +} from "@/lib/hooks/admin/useGeminiCost"; + +const PERIOD_OPTIONS = [ + { label: "7D", value: 7 }, + { label: "14D", value: 14 }, + { label: "30D", value: 30 }, +]; + +const UNIT_KINDS = [ + "input_token", + "output_token", + "cached_input_token", + "image_output", + "grounding_query", +]; + +function fmtUsd(v: number | undefined): string { + if (v == null) return "—"; + if (v < 0.01) return `$${v.toFixed(5)}`; + if (v < 10) return `$${v.toFixed(3)}`; + return `$${v.toFixed(2)}`; +} + +function fmtInt(v: number | undefined): string { + if (v == null) return "—"; + return v.toLocaleString(); +} + +// ─── KPI Row ────────────────────────────────────────────────────────────────── + +function KpiRow() { + const { data } = useGeminiTodaySpend(); + const items = [ + { + label: "Today", + value: fmtUsd(data?.today_usd), + sub: `${fmtInt(data?.today_calls)} calls / ${fmtInt(data?.today_failed)} failed`, + }, + { label: "Yesterday", value: fmtUsd(data?.yesterday_usd), sub: "" }, + { label: "Last 7d", value: fmtUsd(data?.last_7d_usd), sub: "" }, + { label: "Last 30d", value: fmtUsd(data?.last_30d_usd), sub: "" }, + ]; + return ( +
+ {items.map((it) => ( +
+
+ {it.label} +
+
+ {it.value} +
+ {it.sub && ( +
{it.sub}
+ )} +
+ ))} +
+ ); +} + +// ─── Daily Spend Chart ──────────────────────────────────────────────────────── + +function DailyChart({ days }: { days: number }) { + const { data, isLoading } = useGeminiDailySpend(days); + const rows = (data?.days ?? []).slice().reverse(); // oldest → newest + + return ( +
+
+

Daily Spend

+
+ Total {days}d:{" "} + {fmtUsd(data?.total_usd)} +
+
+
+ {isLoading ? ( +
+ Loading… +
+ ) : rows.length === 0 ? ( +
+ No data yet +
+ ) : ( + + + + + + fmtUsd(typeof v === "number" ? v : Number(v))} + /> + + + + )} +
+
+ ); +} + +// ─── Breakdown bar charts ───────────────────────────────────────────────────── + +function BreakdownCard({ + title, + data, +}: { + title: string; + data: + | { + key: string; + spend_usd: number; + ok_calls: number; + failed_calls: number; + }[] + | undefined; +}) { + const rows = (data ?? []).slice(0, 10); + return ( +
+

{title}

+ {rows.length === 0 ? ( +
+ No data +
+ ) : ( +
+ + + + + + fmtUsd(typeof v === "number" ? v : Number(v))} + /> + + + +
+ )} +
+ ); +} + +// ─── Top raw_posts ──────────────────────────────────────────────────────────── + +function TopRawPostsCard({ days }: { days: number }) { + const { data } = useGeminiTopRawPosts(days, 10); + const rows = data?.rows ?? []; + return ( +
+

+ Top expensive raw_posts ({days}d) +

+ {rows.length === 0 ? ( +
No data
+ ) : ( + + + + + + + + + + {rows.map((r) => ( + + + + + + ))} + +
raw_post_idCallsSpend
+ + {r.raw_post_id.slice(0, 8)}… + + + {fmtInt(r.call_count)} + + {fmtUsd(r.spend_usd)} +
+ )} +
+ ); +} + +// ─── Pricing Editor ─────────────────────────────────────────────────────────── + +function PricingEditor() { + const { data } = useGeminiPricing(); + const upsert = useUpsertGeminiPricing(); + const retire = useRetireGeminiPricing(); + const [showHistory, setShowHistory] = useState(false); + + // 신규 row 추가 입력 상태 + const [model, setModel] = useState(""); + const [unitKind, setUnitKind] = useState("input_token"); + const [rate, setRate] = useState(""); + const [notes, setNotes] = useState(""); + + const handleAdd = async () => { + const value = parseFloat(rate); + if (!model.trim() || !Number.isFinite(value)) { + return; + } + await upsert.mutateAsync({ + model: model.trim(), + unit_kind: unitKind, + usd_per_unit: value, + notes: notes.trim() || null, + }); + setModel(""); + setRate(""); + setNotes(""); + }; + + const renderRow = (p: PricingRow, isActive: boolean) => ( + + {p.model} + {p.unit_kind} + + {p.usd_per_unit < 0.001 + ? `$${p.usd_per_unit.toExponential(3)}` + : `$${p.usd_per_unit.toFixed(6)}`} + + + {new Date(p.effective_from).toLocaleDateString()} →{" "} + {p.effective_to + ? new Date(p.effective_to).toLocaleDateString() + : "active"} + + {p.source} + + {isActive && ( + + )} + + + ); + + return ( +
+
+

+ Pricing (DB SOT) +

+ +
+ + + + + + + + + + + + + {(data?.active ?? []).map((p) => renderRow(p, true))} + {showHistory && (data?.history ?? []).map((p) => renderRow(p, false))} + +
ModelUnitUSD / unitEffectiveSource +
+ +
+ setModel(e.target.value)} + className="rounded border border-neutral-700 bg-neutral-900 px-2 py-1 text-xs text-neutral-100" + /> + + setRate(e.target.value)} + className="rounded border border-neutral-700 bg-neutral-900 px-2 py-1 text-xs font-mono text-neutral-100" + /> + setNotes(e.target.value)} + className="rounded border border-neutral-700 bg-neutral-900 px-2 py-1 text-xs text-neutral-100" + /> + +
+ {upsert.isError && ( +
+ {upsert.error?.message ?? "Failed"} +
+ )} +
+ ); +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + +export default function GeminiCostPage() { + const [days, setDays] = useState(7); + const byStep = useGeminiSpendByStep(days); + const byModel = useGeminiSpendByModel(days); + const byPipeline = useGeminiSpendByPipeline(days); + + return ( +
+
+
+

+ Gemini Cost +

+

+ Per-call cost tracking. Pricing SOT is the DB table below — no + hardcoding in code. +

+
+
+ {PERIOD_OPTIONS.map((opt) => ( + + ))} +
+
+ + + + + +
+ + + +
+ + + + +
+ ); +} diff --git a/packages/web/app/admin/page.tsx b/packages/web/app/admin/page.tsx index 359cdee9..8f340341 100644 --- a/packages/web/app/admin/page.tsx +++ b/packages/web/app/admin/page.tsx @@ -19,6 +19,7 @@ import { TodaySummarySkeleton, } from "@/lib/components/admin/dashboard/TodaySummary"; import { PipelineHealthCard } from "@/lib/components/admin/dashboard/PipelineHealthCard"; +import { GeminiCostMini } from "@/lib/components/admin/dashboard/GeminiCostMini"; /** * Admin Dashboard Page @@ -56,6 +57,9 @@ export default function AdminDashboardPage() { {/* Pipeline 헬스 — IN_PROGRESS > 0 시 라이브 폴링 (#361) */} + {/* Gemini API cost mini (#cost-tracking) */} + + {/* Traffic Chart */} {chartQuery.isLoading ? ( diff --git a/packages/web/app/api/admin/gemini-cost/[...path]/route.ts b/packages/web/app/api/admin/gemini-cost/[...path]/route.ts new file mode 100644 index 00000000..ccd85cf8 --- /dev/null +++ b/packages/web/app/api/admin/gemini-cost/[...path]/route.ts @@ -0,0 +1,79 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; +import { checkIsAdmin } from "@/lib/supabase/admin"; +import { API_BASE_URL } from "@/lib/server-env"; + +/** + * Catch-all proxy: /api/v1/admin/gemini-cost/* → api-server. + * + * Supports GET / POST / DELETE. Auth check + bearer forward. + */ +async function proxy( + request: NextRequest, + context: { params: Promise<{ path: string[] }> } +): Promise { + const supabase = await createSupabaseServerClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const isAdmin = await checkIsAdmin(supabase, user.id); + if (!isAdmin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + const { + data: { session }, + } = await supabase.auth.getSession(); + if (!session?.access_token) { + return NextResponse.json({ error: "No session" }, { status: 401 }); + } + + const { path } = await context.params; + const subpath = (path || []).join("/"); + const qs = request.nextUrl.searchParams.toString(); + const url = `${API_BASE_URL}/api/v1/admin/gemini-cost/${subpath}${ + qs ? `?${qs}` : "" + }`; + + const init: RequestInit = { + method: request.method, + headers: { + Authorization: `Bearer ${session.access_token}`, + }, + }; + if (request.method === "POST" || request.method === "PATCH") { + const body = await request.text(); + init.body = body; + (init.headers as Record)["Content-Type"] = + request.headers.get("content-type") || "application/json"; + } + + try { + const response = await fetch(url, init); + const text = await response.text(); + if (!text) { + return new NextResponse(null, { status: response.status }); + } + let data: unknown; + try { + data = JSON.parse(text); + } catch { + data = { message: text }; + } + return NextResponse.json(data, { status: response.status }); + } catch (error) { + if (process.env.NODE_ENV === "development") { + console.error("gemini-cost proxy error:", error); + } + return NextResponse.json( + { message: error instanceof Error ? error.message : "Proxy error" }, + { status: 502 } + ); + } +} + +export const GET = proxy; +export const POST = proxy; +export const DELETE = proxy; diff --git a/packages/web/lib/components/admin/AdminSidebar.tsx b/packages/web/lib/components/admin/AdminSidebar.tsx index 66876b76..de7b8c4b 100644 --- a/packages/web/lib/components/admin/AdminSidebar.tsx +++ b/packages/web/lib/components/admin/AdminSidebar.tsx @@ -15,6 +15,7 @@ import { Tag, UsersRound, Link2, + DollarSign, } from "lucide-react"; import { cn } from "@/lib/utils"; import { useAuthStore } from "@/lib/stores/authStore"; @@ -62,6 +63,12 @@ const SIDEBAR_ENTRIES: SidebarEntry[] = [ }, ], }, + { + label: "Observability", + items: [ + { href: "/admin/gemini-cost", label: "Gemini Cost", icon: DollarSign }, + ], + }, ]; interface AdminSidebarProps { diff --git a/packages/web/lib/components/admin/dashboard/GeminiCostMini.tsx b/packages/web/lib/components/admin/dashboard/GeminiCostMini.tsx new file mode 100644 index 00000000..25caeb4a --- /dev/null +++ b/packages/web/lib/components/admin/dashboard/GeminiCostMini.tsx @@ -0,0 +1,70 @@ +"use client"; + +/** + * Compact today-Gemini-cost card for the /admin home (#cost-tracking). + * Full breakdown lives at /admin/gemini-cost. + */ + +import Link from "next/link"; +import { useGeminiTodaySpend } from "@/lib/hooks/admin/useGeminiCost"; + +function fmtUsd(v: number | undefined): string { + if (v == null) return "—"; + if (v < 0.01) return `$${v.toFixed(5)}`; + if (v < 10) return `$${v.toFixed(3)}`; + return `$${v.toFixed(2)}`; +} + +export function GeminiCostMini() { + const { data, isLoading } = useGeminiTodaySpend(); + + return ( + +
+

Gemini Cost

+ view → +
+ {isLoading ? ( +
+ ) : ( +
+
+
+ Today +
+
+ {fmtUsd(data?.today_usd)} +
+
+
+
+ Yesterday +
+
+ {fmtUsd(data?.yesterday_usd)} +
+
+
+
+ 7d +
+
+ {fmtUsd(data?.last_7d_usd)} +
+
+
+
+ 30d +
+
+ {fmtUsd(data?.last_30d_usd)} +
+
+
+ )} + + ); +} diff --git a/packages/web/lib/hooks/admin/useGeminiCost.ts b/packages/web/lib/hooks/admin/useGeminiCost.ts new file mode 100644 index 00000000..2e9ce085 --- /dev/null +++ b/packages/web/lib/hooks/admin/useGeminiCost.ts @@ -0,0 +1,235 @@ +"use client"; + +/** + * Gemini cost tracking dashboard (#cost-tracking). + * + * GET /api/admin/gemini-cost/spend/daily?days= + * GET /api/admin/gemini-cost/spend/by-step?days= + * GET /api/admin/gemini-cost/spend/by-model?days= + * GET /api/admin/gemini-cost/spend/by-pipeline?days= + * GET /api/admin/gemini-cost/spend/today + * GET /api/admin/gemini-cost/spend/top-raw-posts?days=&limit= + * GET /api/admin/gemini-cost/pricing + * POST /api/admin/gemini-cost/pricing + * DEL /api/admin/gemini-cost/pricing/:id + */ + +import { + useMutation, + useQuery, + useQueryClient, + type UseMutationResult, + type UseQueryResult, +} from "@tanstack/react-query"; + +const BASE = "/api/admin/gemini-cost"; + +// ─── Types ────────────────────────────────────────────────────────────────── + +export interface DailySpendRow { + day: string; + spend_usd: number; + ok_calls: number; + failed_calls: number; + prompt_tokens: number; + completion_tokens: number; + images: number; + groundings: number; +} + +export interface DailySpendResponse { + days: DailySpendRow[]; + total_usd: number; +} + +export interface GroupSpendRow { + key: string; + spend_usd: number; + ok_calls: number; + failed_calls: number; +} + +export interface GroupSpendResponse { + rows: GroupSpendRow[]; + total_usd: number; +} + +export interface TodaySpendResponse { + today_usd: number; + today_calls: number; + today_failed: number; + yesterday_usd: number; + last_7d_usd: number; + last_30d_usd: number; +} + +export interface TopRawPostRow { + raw_post_id: string; + spend_usd: number; + call_count: number; +} + +export interface TopRawPostsResponse { + rows: TopRawPostRow[]; +} + +export interface PricingRow { + id: string; + model: string; + unit_kind: string; + tier: string; + usd_per_unit: number; + effective_from: string; + effective_to: string | null; + source: string; + notes: string | null; +} + +export interface PricingListResponse { + active: PricingRow[]; + history: PricingRow[]; +} + +export interface UpsertPricingRequest { + model: string; + unit_kind: string; + usd_per_unit: number; + tier?: string; + notes?: string | null; +} + +// ─── Fetch helper ──────────────────────────────────────────────────────────── + +async function getJSON(path: string, signal?: AbortSignal): Promise { + const res = await fetch(`${BASE}${path}`, { signal }); + if (!res.ok) { + throw new Error(`gemini-cost: ${path} → ${res.status}`); + } + return (await res.json()) as T; +} + +// ─── Queries ───────────────────────────────────────────────────────────────── + +export function useGeminiDailySpend( + days: number +): UseQueryResult { + return useQuery({ + queryKey: ["admin", "gemini-cost", "daily", days], + queryFn: ({ signal }) => + getJSON(`/spend/daily?days=${days}`, signal), + refetchInterval: 60_000, + staleTime: 30_000, + }); +} + +export function useGeminiSpendByStep( + days: number +): UseQueryResult { + return useQuery({ + queryKey: ["admin", "gemini-cost", "by-step", days], + queryFn: ({ signal }) => + getJSON(`/spend/by-step?days=${days}`, signal), + staleTime: 30_000, + }); +} + +export function useGeminiSpendByModel( + days: number +): UseQueryResult { + return useQuery({ + queryKey: ["admin", "gemini-cost", "by-model", days], + queryFn: ({ signal }) => + getJSON(`/spend/by-model?days=${days}`, signal), + staleTime: 30_000, + }); +} + +export function useGeminiSpendByPipeline( + days: number +): UseQueryResult { + return useQuery({ + queryKey: ["admin", "gemini-cost", "by-pipeline", days], + queryFn: ({ signal }) => + getJSON(`/spend/by-pipeline?days=${days}`, signal), + staleTime: 30_000, + }); +} + +export function useGeminiTodaySpend(): UseQueryResult { + return useQuery({ + queryKey: ["admin", "gemini-cost", "today"], + queryFn: ({ signal }) => + getJSON(`/spend/today`, signal), + refetchInterval: 60_000, + staleTime: 30_000, + }); +} + +export function useGeminiTopRawPosts( + days: number, + limit = 10 +): UseQueryResult { + return useQuery({ + queryKey: ["admin", "gemini-cost", "top-raw-posts", days, limit], + queryFn: ({ signal }) => + getJSON( + `/spend/top-raw-posts?days=${days}&limit=${limit}`, + signal + ), + staleTime: 60_000, + }); +} + +export function useGeminiPricing(): UseQueryResult { + return useQuery({ + queryKey: ["admin", "gemini-cost", "pricing"], + queryFn: ({ signal }) => getJSON(`/pricing`, signal), + staleTime: 60_000, + }); +} + +// ─── Mutations ─────────────────────────────────────────────────────────────── + +export function useUpsertGeminiPricing(): UseMutationResult< + PricingRow, + Error, + UpsertPricingRequest +> { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body) => { + const res = await fetch(`${BASE}/pricing`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const errBody = await res.text().catch(() => ""); + throw new Error(`upsert pricing: ${res.status} ${errBody}`.trim()); + } + return (await res.json()) as PricingRow; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["admin", "gemini-cost", "pricing"] }); + }, + }); +} + +export function useRetireGeminiPricing(): UseMutationResult< + void, + Error, + string +> { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id) => { + const res = await fetch(`${BASE}/pricing/${id}`, { method: "DELETE" }); + if (!res.ok && res.status !== 204) { + throw new Error(`retire pricing: ${res.status}`); + } + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["admin", "gemini-cost", "pricing"] }); + }, + }); +} diff --git a/supabase-assets/migrations/20260514140000_gemini_cost_tracking.sql b/supabase-assets/migrations/20260514140000_gemini_cost_tracking.sql new file mode 100644 index 00000000..023c7c64 --- /dev/null +++ b/supabase-assets/migrations/20260514140000_gemini_cost_tracking.sql @@ -0,0 +1,172 @@ +-- Gemini API 비용 추적 시스템 (#cost-tracking). +-- +-- 두 테이블: +-- public.gemini_pricing — 단가 SOT. SCD type 2 (effective_from/to). +-- 어드민이 UI 에서 update. source='manual' (default) +-- 또는 'catalog_sync' (future Vertex 이전 시). +-- public.gemini_usage_events — per-call 적립. raw_post / editorial 호출 모두. +-- fire-and-forget INSERT from ai-server. +-- ok=false 도 row 적립 (실패 가시화). +-- +-- 단가 변경 워크플로우 (admin UI): +-- 1. 기존 active row → UPDATE SET effective_to = now() -- 히스토리 close +-- 2. 새 row INSERT (effective_to NULL = active) +-- → 과거 호출은 pricing_snapshot 으로 호출 시점 단가 보존, 신규는 새 단가. +-- +-- View: +-- public.gemini_spend_daily — 일/step/model/pipeline 별 집계. 대시보드 read-side. +-- +-- 단가 seed 는 2026-05 ai.google.dev/pricing 기준. 추후 어드민이 update 가능. + +------------------------------------------------------------------------------ +-- 1. pricing SOT +------------------------------------------------------------------------------ + +CREATE TABLE IF NOT EXISTS public.gemini_pricing ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + model text NOT NULL, + -- input_token | output_token | cached_input_token | image_output | grounding_query + unit_kind text NOT NULL, + usd_per_unit numeric(18,10) NOT NULL CHECK (usd_per_unit >= 0), + tier text NOT NULL DEFAULT 'default', + effective_from timestamptz NOT NULL DEFAULT now(), + effective_to timestamptz, + source text NOT NULL DEFAULT 'manual', + notes text, + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT gemini_pricing_unit_kind_chk CHECK ( + unit_kind IN ('input_token','output_token','cached_input_token', + 'image_output','grounding_query') + ), + CONSTRAINT gemini_pricing_source_chk CHECK ( + source IN ('manual','catalog_sync') + ), + CONSTRAINT gemini_pricing_active_period_chk CHECK ( + effective_to IS NULL OR effective_to > effective_from + ), + UNIQUE (model, unit_kind, tier, effective_from) +); + +CREATE INDEX IF NOT EXISTS gemini_pricing_active_idx + ON public.gemini_pricing (model, unit_kind, tier) + WHERE effective_to IS NULL; + +COMMENT ON TABLE public.gemini_pricing IS + 'Gemini API 단가 SOT. SCD type 2 — admin UI 에서 update 시 기존 row close + 새 row insert.'; +COMMENT ON COLUMN public.gemini_pricing.unit_kind IS + 'input_token / output_token / cached_input_token (text models), image_output (flash-image, per image), grounding_query (googleSearch tool).'; +COMMENT ON COLUMN public.gemini_pricing.effective_to IS + 'NULL = active. 단가 변경 시 기존 row 의 이 컬럼을 now() 로 set.'; + +------------------------------------------------------------------------------ +-- 2. usage events (per Gemini call) +------------------------------------------------------------------------------ + +CREATE TABLE IF NOT EXISTS public.gemini_usage_events ( + id bigserial PRIMARY KEY, + occurred_at timestamptz NOT NULL DEFAULT now(), + raw_post_id uuid, + post_id uuid, + step text NOT NULL, + -- raw_post | post_editorial | editorial_article | ad_hoc + pipeline text NOT NULL, + model text NOT NULL, + ok boolean NOT NULL, + prompt_tokens integer, + completion_tokens integer, + cached_tokens integer, + image_output_count integer, + grounding_queries integer, + est_cost_usd numeric(14,8) NOT NULL DEFAULT 0, + pricing_snapshot jsonb, + error_class text, + latency_ms integer, + request_id text, + CONSTRAINT gemini_usage_pipeline_chk CHECK ( + pipeline IN ('raw_post','post_editorial','editorial_article','ad_hoc') + ) +); + +-- BRIN 으로 시간순 append 패턴 효율화 (millions 의 row 예상). step / model / +-- pipeline 별 일별 집계 쿼리는 covering composite 로. +CREATE INDEX IF NOT EXISTS gemini_usage_occurred_at_brin_idx + ON public.gemini_usage_events USING brin (occurred_at); +CREATE INDEX IF NOT EXISTS gemini_usage_raw_post_idx + ON public.gemini_usage_events (raw_post_id) + WHERE raw_post_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS gemini_usage_post_idx + ON public.gemini_usage_events (post_id) + WHERE post_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS gemini_usage_step_occurred_idx + ON public.gemini_usage_events (step, occurred_at); +CREATE INDEX IF NOT EXISTS gemini_usage_model_occurred_idx + ON public.gemini_usage_events (model, occurred_at); +CREATE INDEX IF NOT EXISTS gemini_usage_pipeline_occurred_idx + ON public.gemini_usage_events (pipeline, occurred_at); + +COMMENT ON TABLE public.gemini_usage_events IS + 'Per-Gemini-call usage log. ai-server fire-and-forget INSERT. 실패도 row 적립 (ok=false, error_class).'; +COMMENT ON COLUMN public.gemini_usage_events.pricing_snapshot IS + '호출 시점 단가 fingerprint. 단가 row 변경 후에도 과거 cost 재현/audit 가능.'; +COMMENT ON COLUMN public.gemini_usage_events.error_class IS + 'safety_block | http_5xx | quota | parse_error | no_pricing | timeout | null'; + +------------------------------------------------------------------------------ +-- 3. daily aggregation view +------------------------------------------------------------------------------ + +CREATE OR REPLACE VIEW public.gemini_spend_daily AS +SELECT + date_trunc('day', occurred_at) AS day, + step, + model, + pipeline, + count(*) FILTER (WHERE ok) AS ok_calls, + count(*) FILTER (WHERE NOT ok) AS failed_calls, + coalesce(sum(prompt_tokens), 0) AS prompt_tokens, + coalesce(sum(completion_tokens), 0) AS completion_tokens, + coalesce(sum(cached_tokens), 0) AS cached_tokens, + coalesce(sum(image_output_count), 0) AS images, + coalesce(sum(grounding_queries), 0) AS groundings, + sum(est_cost_usd) AS spend_usd +FROM public.gemini_usage_events +GROUP BY 1, 2, 3, 4; + +COMMENT ON VIEW public.gemini_spend_daily IS + 'Daily aggregation by step / model / pipeline. 대시보드 read-side.'; + +------------------------------------------------------------------------------ +-- 4. seed — 2026-05 ai.google.dev/pricing 기준 +------------------------------------------------------------------------------ + +-- gemini-2.5-pro (≤200k context) +INSERT INTO public.gemini_pricing (model, unit_kind, usd_per_unit, notes) VALUES + ('gemini-2.5-pro', 'input_token', 0.00000125, 'Pro ≤200k context'), + ('gemini-2.5-pro', 'output_token', 0.00001000, 'Pro output'), + ('gemini-2.5-pro', 'cached_input_token', 0.00000031, 'Pro cached input (~25%% of fresh)') +ON CONFLICT (model, unit_kind, tier, effective_from) DO NOTHING; + +-- gemini-2.5-flash +INSERT INTO public.gemini_pricing (model, unit_kind, usd_per_unit, notes) VALUES + ('gemini-2.5-flash', 'input_token', 0.00000030, 'Flash 2.5 input'), + ('gemini-2.5-flash', 'output_token', 0.00000250, 'Flash 2.5 output'), + ('gemini-2.5-flash', 'cached_input_token', 0.000000075, 'Flash cached input') +ON CONFLICT (model, unit_kind, tier, effective_from) DO NOTHING; + +-- gemini-2.5-flash-lite +INSERT INTO public.gemini_pricing (model, unit_kind, usd_per_unit, notes) VALUES + ('gemini-2.5-flash-lite', 'input_token', 0.000000075, 'Flash-Lite input'), + ('gemini-2.5-flash-lite', 'output_token', 0.00000030, 'Flash-Lite output'), + ('gemini-2.5-flash-lite', 'cached_input_token', 0.0000000187,'Flash-Lite cached input') +ON CONFLICT (model, unit_kind, tier, effective_from) DO NOTHING; + +-- gemini-2.5-flash-image (image generation) +INSERT INTO public.gemini_pricing (model, unit_kind, usd_per_unit, notes) VALUES + ('gemini-2.5-flash-image', 'image_output', 0.0390000000, 'per generated image (1024x1024)'), + ('gemini-2.5-flash-image', 'input_token', 0.00000030, 'image input still tokenized at flash rate') +ON CONFLICT (model, unit_kind, tier, effective_from) DO NOTHING; + +-- grounding query — model-agnostic (googleSearch tool) +INSERT INTO public.gemini_pricing (model, unit_kind, usd_per_unit, notes) VALUES + ('grounding', 'grounding_query', 0.0350000000, 'googleSearch tool: $35 / 1000 queries') +ON CONFLICT (model, unit_kind, tier, effective_from) DO NOTHING;