Spaces:
Sleeping
Sleeping
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 +1 -0
- app/api/v1/subscription.py +17 -0
- app/main.py +5 -0
- app/models/chat.py +3 -1
- app/schemas/subscription.py +2 -0
- app/services/llm_service.py +12 -5
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|