SuZeAI commited on
Commit
ff5991d
·
1 Parent(s): d249b9c

feat: add token usage tracking for AI recommendations and subscription management

Browse files
app/api/v1/recommendations.py CHANGED
@@ -106,6 +106,7 @@ def recommend_outfit(
106
  role="ai",
107
  content=recommendation.get("recommendation", ""),
108
  selected_item_ids=[str(sid) for sid in selected_ids] or None,
 
109
  ))
110
  conv.updated_at = datetime.utcnow() # đẩy cuộc trò chuyện lên đầu danh sách
111
  db.commit()
 
106
  role="ai",
107
  content=recommendation.get("recommendation", ""),
108
  selected_item_ids=[str(sid) for sid in selected_ids] or None,
109
+ tokens_used=recommendation.get("tokens_used") or 0,
110
  ))
111
  conv.updated_at = datetime.utcnow() # đẩy cuộc trò chuyện lên đầu danh sách
112
  db.commit()
app/api/v1/subscription.py CHANGED
@@ -1,4 +1,5 @@
1
  from fastapi import APIRouter, Depends
 
2
  from sqlalchemy.orm import Session
3
  from datetime import datetime, timedelta, timezone
4
  from app.core.database import get_db
@@ -13,6 +14,9 @@ router = APIRouter()
13
  # Free-tier caps advertised on the landing page's pricing table.
14
  FREE_WARDROBE_ITEM_LIMIT = 30
15
  FREE_AI_RECOMMENDATIONS_PER_WEEK = 5
 
 
 
16
 
17
 
18
  def _build_response(db: Session, user: User) -> SubscriptionResponse:
@@ -33,6 +37,17 @@ def _build_response(db: Session, user: User) -> SubscriptionResponse:
33
  .count()
34
  )
35
 
 
 
 
 
 
 
 
 
 
 
 
36
  return SubscriptionResponse(
37
  plan=user.plan,
38
  renews_at=user.premium_renews_at,
@@ -40,6 +55,8 @@ def _build_response(db: Session, user: User) -> SubscriptionResponse:
40
  wardrobe_items_limit=None if is_premium else FREE_WARDROBE_ITEM_LIMIT,
41
  ai_recommendations_used=ai_used,
42
  ai_recommendations_limit=None if is_premium else FREE_AI_RECOMMENDATIONS_PER_WEEK,
 
 
43
  )
44
 
45
 
 
1
  from fastapi import APIRouter, Depends
2
+ from sqlalchemy import func
3
  from sqlalchemy.orm import Session
4
  from datetime import datetime, timedelta, timezone
5
  from app.core.database import get_db
 
14
  # Free-tier caps advertised on the landing page's pricing table.
15
  FREE_WARDROBE_ITEM_LIMIT = 30
16
  FREE_AI_RECOMMENDATIONS_PER_WEEK = 5
17
+ # AI Stylist usage is metered like an LLM API quota: total tokens consumed
18
+ # by "ai" responses over a rolling 30-day window.
19
+ FREE_AI_TOKEN_LIMIT_PER_30D = 50_000
20
 
21
 
22
  def _build_response(db: Session, user: User) -> SubscriptionResponse:
 
37
  .count()
38
  )
39
 
40
+ month_ago = datetime.now(timezone.utc) - timedelta(days=30)
41
+ tokens_used = (
42
+ db.query(func.coalesce(func.sum(ChatMessage.tokens_used), 0))
43
+ .filter(
44
+ ChatMessage.user_id == user.id,
45
+ ChatMessage.role == "ai",
46
+ ChatMessage.created_at >= month_ago,
47
+ )
48
+ .scalar()
49
+ )
50
+
51
  return SubscriptionResponse(
52
  plan=user.plan,
53
  renews_at=user.premium_renews_at,
 
55
  wardrobe_items_limit=None if is_premium else FREE_WARDROBE_ITEM_LIMIT,
56
  ai_recommendations_used=ai_used,
57
  ai_recommendations_limit=None if is_premium else FREE_AI_RECOMMENDATIONS_PER_WEEK,
58
+ ai_tokens_used=int(tokens_used or 0),
59
+ ai_tokens_limit=None if is_premium else FREE_AI_TOKEN_LIMIT_PER_30D,
60
  )
61
 
62
 
app/main.py CHANGED
@@ -31,6 +31,11 @@ try:
31
  conn.execute(text("ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS conversation_id UUID;"))
32
  except Exception:
33
  pass
 
 
 
 
 
34
  # Ensure users has plan/premium_renews_at (added after the table first shipped)
35
  try:
36
  conn.execute(text("ALTER TABLE users ADD COLUMN IF NOT EXISTS plan VARCHAR(20) NOT NULL DEFAULT 'free';"))
 
31
  conn.execute(text("ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS conversation_id UUID;"))
32
  except Exception:
33
  pass
34
+ # Ensure chat_messages has tokens_used (added after the table first shipped)
35
+ try:
36
+ conn.execute(text("ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS tokens_used INTEGER NOT NULL DEFAULT 0;"))
37
+ except Exception:
38
+ pass
39
  # Ensure users has plan/premium_renews_at (added after the table first shipped)
40
  try:
41
  conn.execute(text("ALTER TABLE users ADD COLUMN IF NOT EXISTS plan VARCHAR(20) NOT NULL DEFAULT 'free';"))
app/models/chat.py CHANGED
@@ -1,4 +1,4 @@
1
- from sqlalchemy import Column, String, DateTime, func, ForeignKey, Text
2
  from sqlalchemy.dialects.postgresql import UUID, ARRAY
3
  from app.core.database import Base
4
  import uuid
@@ -28,4 +28,6 @@ class ChatMessage(Base):
28
  content = Column(Text, nullable=False)
29
  # Với tin nhắn "ai" là gợi ý phối đồ: các ID sản phẩm được chọn (để dựng lại thumbnail).
30
  selected_item_ids = Column(ARRAY(UUID(as_uuid=True)), nullable=True)
 
 
31
  created_at = Column(DateTime(timezone=True), server_default=func.now())
 
1
+ from sqlalchemy import Column, String, DateTime, func, ForeignKey, Text, Integer
2
  from sqlalchemy.dialects.postgresql import UUID, ARRAY
3
  from app.core.database import Base
4
  import uuid
 
28
  content = Column(Text, nullable=False)
29
  # Với tin nhắn "ai" là gợi ý phối đồ: các ID sản phẩm được chọn (để dựng lại thumbnail).
30
  selected_item_ids = Column(ARRAY(UUID(as_uuid=True)), nullable=True)
31
+ # Số token LLM tiêu tốn cho tin nhắn "ai" này (dùng để tính quota trợ lý AI theo token).
32
+ tokens_used = Column(Integer, nullable=False, server_default="0", default=0)
33
  created_at = Column(DateTime(timezone=True), server_default=func.now())
app/schemas/subscription.py CHANGED
@@ -9,6 +9,8 @@ class SubscriptionResponse(BaseModel):
9
  wardrobe_items_limit: Optional[int] # None = unlimited
10
  ai_recommendations_used: int
11
  ai_recommendations_limit: Optional[int] # None = unlimited
 
 
12
 
13
  class Config:
14
  from_attributes = True
 
9
  wardrobe_items_limit: Optional[int] # None = unlimited
10
  ai_recommendations_used: int
11
  ai_recommendations_limit: Optional[int] # None = unlimited
12
+ ai_tokens_used: int
13
+ ai_tokens_limit: Optional[int] # None = unlimited
14
 
15
  class Config:
16
  from_attributes = True
app/services/llm_service.py CHANGED
@@ -76,17 +76,19 @@ class LLMService:
76
  prompt = self._build_prompt(user_profile, wardrobe_items, message, history, weather, event)
77
 
78
  try:
79
- content = self._chat_completion(prompt)
80
  data = self._parse_json(content)
81
  if "is_recommendation" not in data:
82
  data["is_recommendation"] = len(data.get("selected_item_ids", [])) > 0
 
83
  return data
84
  except Exception as e:
85
  logging.error(f"Error calling LLM provider '{self.provider}': {e}")
86
  return {
87
  "is_recommendation": False,
88
  "recommendation": "Hệ thống gặp lỗi khi liên kết với AI gợi ý. Hãy thử lại sau.",
89
- "selected_item_ids": []
 
90
  }
91
 
92
  # ------------------------------------------------------------------ prompt
@@ -159,7 +161,7 @@ class LLMService:
159
  """
160
 
161
  # --------------------------------------------------------------- providers
162
- def _chat_completion(self, prompt: str) -> str:
163
  if self.provider == "openai":
164
  url = "https://api.openai.com/v1/chat/completions"
165
  headers = {
@@ -192,7 +194,11 @@ class LLMService:
192
 
193
  response = requests.post(url, headers=headers, json=payload, timeout=timeout)
194
  response.raise_for_status()
195
- return response.json()["choices"][0]["message"]["content"]
 
 
 
 
196
 
197
  # ------------------------------------------------------------------ parsing
198
  @staticmethod
@@ -219,7 +225,8 @@ class LLMService:
219
  return {
220
  "is_recommendation": is_rec,
221
  "recommendation": "Mock Outfit: Áo sơ mi trắng phối quần tây đen nhã nhặn phù hợp cho môi trường văn phòng/phỏng vấn." if is_rec else f"Tôi hiểu bạn đang nói về: '{message}'. Trong vai trò trợ lý thời trang, tôi khuyên bạn nên tự tin thể hiện phong cách của mình!",
222
- "selected_item_ids": [str(item["id"]) for item in wardrobe_items[:2]] if (wardrobe_items and is_rec) else []
 
223
  }
224
 
225
 
 
76
  prompt = self._build_prompt(user_profile, wardrobe_items, message, history, weather, event)
77
 
78
  try:
79
+ content, tokens_used = self._chat_completion(prompt)
80
  data = self._parse_json(content)
81
  if "is_recommendation" not in data:
82
  data["is_recommendation"] = len(data.get("selected_item_ids", [])) > 0
83
+ data["tokens_used"] = tokens_used
84
  return data
85
  except Exception as e:
86
  logging.error(f"Error calling LLM provider '{self.provider}': {e}")
87
  return {
88
  "is_recommendation": False,
89
  "recommendation": "Hệ thống gặp lỗi khi liên kết với AI gợi ý. Hãy thử lại sau.",
90
+ "selected_item_ids": [],
91
+ "tokens_used": 0,
92
  }
93
 
94
  # ------------------------------------------------------------------ prompt
 
161
  """
162
 
163
  # --------------------------------------------------------------- providers
164
+ def _chat_completion(self, prompt: str) -> tuple[str, int]:
165
  if self.provider == "openai":
166
  url = "https://api.openai.com/v1/chat/completions"
167
  headers = {
 
194
 
195
  response = requests.post(url, headers=headers, json=payload, timeout=timeout)
196
  response.raise_for_status()
197
+ body = response.json()
198
+ content = body["choices"][0]["message"]["content"]
199
+ # Both OpenAI and NVIDIA's OpenAI-compatible endpoint return a "usage" object.
200
+ tokens_used = int((body.get("usage") or {}).get("total_tokens") or 0)
201
+ return content, tokens_used
202
 
203
  # ------------------------------------------------------------------ parsing
204
  @staticmethod
 
225
  return {
226
  "is_recommendation": is_rec,
227
  "recommendation": "Mock Outfit: Áo sơ mi trắng phối quần tây đen nhã nhặn phù hợp cho môi trường văn phòng/phỏng vấn." if is_rec else f"Tôi hiểu bạn đang nói về: '{message}'. Trong vai trò trợ lý thời trang, tôi khuyên bạn nên tự tin thể hiện phong cách của mình!",
228
+ "selected_item_ids": [str(item["id"]) for item in wardrobe_items[:2]] if (wardrobe_items and is_rec) else [],
229
+ "tokens_used": 0,
230
  }
231
 
232