v0.2 - 19 features: comparator, allergies, gamification, shopping list, achievements, stats, profile, share, bottom nav
This commit is contained in:
@@ -19,7 +19,7 @@ Responda SEMPRE em JSON válido com esta estrutura exata:
|
||||
"proteinas": "<valor, ex: 0g>",
|
||||
"carboidratos": "<valor, ex: 35g>"
|
||||
},
|
||||
"nutrition_verdict": "<frase curta sobre o perfil nutricional, ex: Alto em açúcar, zero fibras>",
|
||||
"nutrition_verdict": "<frase curta sobre o perfil nutricional>",
|
||||
"ingredients": [
|
||||
{
|
||||
"name": "<nome no rótulo>",
|
||||
@@ -31,32 +31,50 @@ Responda SEMPRE em JSON válido com esta estrutura exata:
|
||||
],
|
||||
"recipe": {
|
||||
"title": "<nome da receita saudável>",
|
||||
"description": "<descrição curta, 1-2 frases>",
|
||||
"description": "<descrição curta>",
|
||||
"prep_time": "<tempo de preparo>",
|
||||
"calories": "<calorias aproximadas da receita>",
|
||||
"ingredients_list": ["<ingrediente 1>", "<ingrediente 2>", ...],
|
||||
"steps": ["<passo 1>", "<passo 2>", ...],
|
||||
"tip": "<dica nutricional relacionada>"
|
||||
}
|
||||
"calories": "<calorias aproximadas>",
|
||||
"ingredients_list": ["<ingrediente 1>", ...],
|
||||
"steps": ["<passo 1>", ...],
|
||||
"tip": "<dica nutricional>"
|
||||
},
|
||||
"substitutions": [
|
||||
{
|
||||
"name": "<nome do produto alternativo>",
|
||||
"brand": "<marca sugerida ou genérica>",
|
||||
"reason": "<por que é melhor, 1 frase>",
|
||||
"estimated_score": <int 0-100>
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
REGRAS PARA SUBSTITUIÇÕES:
|
||||
- SOMENTE inclua "substitutions" se o score for < 50
|
||||
- Sugira 3 produtos REAIS brasileiros que sejam alternativas mais saudáveis
|
||||
- Se score >= 50, retorne "substitutions": null
|
||||
|
||||
Para a receita:
|
||||
- Se o produto for SAUDÁVEL (score > 70): sugira uma receita usando o produto
|
||||
- Se o produto for RUIM (score <= 70): sugira uma alternativa saudável que substitua o produto
|
||||
- A receita deve ser simples, rápida e brasileira quando possível
|
||||
- Se score > 70: sugira receita usando o produto
|
||||
- Se score <= 70: sugira alternativa saudável
|
||||
|
||||
Critérios para o score:
|
||||
- 90-100: Alimento natural, minimamente processado, sem aditivos
|
||||
- 70-89: Bom, com poucos aditivos ou processamento leve
|
||||
- 50-69: Médio, processado mas aceitável com moderação
|
||||
- 30-49: Ruim, ultraprocessado com vários aditivos
|
||||
- 0-29: Péssimo, alto em açúcar/sódio/gordura trans, muitos aditivos
|
||||
- 90-100: Natural, minimamente processado
|
||||
- 70-89: Bom, poucos aditivos
|
||||
- 50-69: Médio, processado mas aceitável
|
||||
- 30-49: Ruim, ultraprocessado
|
||||
- 0-29: Péssimo, alto em açúcar/sódio/gordura trans
|
||||
|
||||
Use os dados nutricionais fornecidos quando disponíveis. Estime quando não disponíveis.
|
||||
Considere Nutri-Score, classificação NOVA, e ingredientes problemáticos.
|
||||
Seja direto e honesto. Use linguagem simples."""
|
||||
Use linguagem simples e direta."""
|
||||
|
||||
async def analyze_product(product_data: dict) -> dict:
|
||||
HEALTH_PROFILE_PROMPTS = {
|
||||
"normal": "",
|
||||
"crianca": "\n⚠️ PERFIL: CRIANÇA. Alerte sobre: excesso de açúcar, corantes artificiais, cafeína, sódio alto. Seja mais rigoroso com ultraprocessados.",
|
||||
"gestante": "\n⚠️ PERFIL: GESTANTE. Alerte sobre: cafeína, adoçantes artificiais, sódio excessivo, conservantes. Priorize folato, ferro, cálcio.",
|
||||
"diabetico": "\n⚠️ PERFIL: DIABÉTICO. Alerte sobre: açúcares, carboidratos refinados, índice glicêmico alto. Valorize fibras e proteínas.",
|
||||
"hipertenso": "\n⚠️ PERFIL: HIPERTENSO. Alerte sobre: sódio (>400mg é ALTO), glutamato monossódico, conservantes com sódio. Limite: <2g sódio/dia.",
|
||||
}
|
||||
|
||||
async def analyze_product(product_data: dict, user_context: dict = None) -> dict:
|
||||
if not settings.OPENAI_API_KEY:
|
||||
return _mock_analysis(product_data)
|
||||
|
||||
@@ -65,21 +83,30 @@ async def analyze_product(product_data: dict) -> dict:
|
||||
nutrition_info = product_data.get('nutrition', {})
|
||||
nutrition_str = json.dumps(nutrition_info, ensure_ascii=False) if nutrition_info else 'Não disponível'
|
||||
|
||||
system = SYSTEM_PROMPT
|
||||
extra = ""
|
||||
if user_context:
|
||||
hp = user_context.get("health_profile", "normal")
|
||||
system += HEALTH_PROFILE_PROMPTS.get(hp, "")
|
||||
allergies = user_context.get("allergies", [])
|
||||
if allergies:
|
||||
extra = f"\n\n⚠️ ALERGIAS DO USUÁRIO: {', '.join(allergies)}. Destaque QUALQUER ingrediente que possa conter esses alérgenos."
|
||||
|
||||
user_msg = f"""Produto: {product_data.get('name', 'Desconhecido')}
|
||||
Marca: {product_data.get('brand', '')}
|
||||
Categoria: {product_data.get('category', '')}
|
||||
Ingredientes: {product_data.get('ingredients_text', 'Não disponível')}
|
||||
Nutri-Score: {product_data.get('nutri_score', 'N/A')}
|
||||
NOVA: {product_data.get('nova_group', 'N/A')}
|
||||
Dados Nutricionais: {nutrition_str}
|
||||
Dados Nutricionais: {nutrition_str}{extra}
|
||||
|
||||
Analise este produto com informações nutricionais detalhadas e sugira uma receita."""
|
||||
Analise este produto completo."""
|
||||
|
||||
try:
|
||||
resp = await client.chat.completions.create(
|
||||
model=settings.OPENAI_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user_msg}
|
||||
],
|
||||
response_format={"type": "json_object"},
|
||||
@@ -115,5 +142,74 @@ def _mock_analysis(product_data: dict) -> dict:
|
||||
"nutrition": {},
|
||||
"nutrition_verdict": "",
|
||||
"ingredients": [],
|
||||
"recipe": None
|
||||
"recipe": None,
|
||||
"substitutions": None,
|
||||
}
|
||||
|
||||
|
||||
PHOTO_PROMPT = """Analise esta foto de um produto alimentar/suplemento. A foto pode mostrar o rótulo, tabela nutricional, ingredientes ou embalagem.
|
||||
|
||||
IMPORTANTE: Mesmo que a foto esteja parcial, TENTE extrair o máximo de informações. NUNCA retorne erro se conseguir identificar o produto.
|
||||
|
||||
Responda em JSON com este formato:
|
||||
{
|
||||
"product_name": "<nome>",
|
||||
"brand": "<marca>",
|
||||
"category": "<categoria>",
|
||||
"ingredients_text": "<ingredientes>",
|
||||
"nutri_score": "<a-e ou null>",
|
||||
"nova_group": <1-4 ou null>,
|
||||
"score": <int 0-100>,
|
||||
"summary": "<resumo 2-3 frases>",
|
||||
"positives": ["..."],
|
||||
"negatives": ["..."],
|
||||
"nutrition": {"calorias":"...","acucar":"...","gordura_total":"...","gordura_saturada":"...","sodio":"...","carboidratos":"...","fibras":"...","proteinas":"..."},
|
||||
"nutrition_verdict": "<frase curta>",
|
||||
"ingredients": [{"name":"...","popular_name":"...","explanation":"...","classification":"good|warning|bad","reason":"..."}],
|
||||
"recipe": {"title":"...","description":"...","prep_time":"...","calories":"...","ingredients_list":["..."],"steps":["..."],"tip":"..."},
|
||||
"substitutions": null
|
||||
}
|
||||
|
||||
Se score < 50, inclua "substitutions" com 3 alternativas reais brasileiras.
|
||||
SOMENTE retorne {"error": "mensagem"} se NÃO for alimento."""
|
||||
|
||||
async def analyze_photo(b64_image: str, user_context: dict = None) -> dict:
|
||||
if not settings.OPENAI_API_KEY:
|
||||
return None
|
||||
|
||||
client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY)
|
||||
|
||||
system = "Voce eh um nutricionista brasileiro expert em analise de rotulos alimentares."
|
||||
if user_context:
|
||||
hp = user_context.get("health_profile", "normal")
|
||||
system += HEALTH_PROFILE_PROMPTS.get(hp, "")
|
||||
|
||||
prompt = PHOTO_PROMPT
|
||||
if user_context and user_context.get("allergies"):
|
||||
prompt += f"\n\n⚠️ ALERGIAS: {', '.join(user_context['allergies'])}. Destaque alérgenos!"
|
||||
|
||||
for detail_level in ["low", "auto"]:
|
||||
try:
|
||||
resp = await client.chat.completions.create(
|
||||
model="gpt-4o",
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": [
|
||||
{"type": "text", "text": prompt},
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64_image}", "detail": detail_level}}
|
||||
]}
|
||||
],
|
||||
response_format={"type": "json_object"},
|
||||
temperature=0.3,
|
||||
timeout=30,
|
||||
max_tokens=1500,
|
||||
)
|
||||
result = json.loads(resp.choices[0].message.content)
|
||||
if result.get("error"):
|
||||
continue
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"OpenAI photo error (detail={detail_level}): {e}")
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
from app.database import init_db
|
||||
from app.database import init_db, async_session
|
||||
from app.routers import auth, scan
|
||||
from app.routers import compare, profile, stats, achievements, shopping, share
|
||||
from app.services.achievements import seed_achievements
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await init_db()
|
||||
# Seed achievements
|
||||
async with async_session() as db:
|
||||
await seed_achievements(db)
|
||||
yield
|
||||
|
||||
app = FastAPI(title="Aletheia API", version="0.1.0", lifespan=lifespan)
|
||||
app = FastAPI(title="Aletheia API", version="0.2.0", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:3080", "http://127.0.0.1:3080"],
|
||||
allow_origins=["http://localhost:3080", "http://127.0.0.1:3080", "http://198.199.84.130:3080", "*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
@@ -21,7 +26,13 @@ app.add_middleware(
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(scan.router)
|
||||
app.include_router(compare.router)
|
||||
app.include_router(profile.router)
|
||||
app.include_router(stats.router)
|
||||
app.include_router(achievements.router)
|
||||
app.include_router(shopping.router)
|
||||
app.include_router(share.router)
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status": "ok", "app": "Aletheia API v0.1"}
|
||||
return {"status": "ok", "app": "Aletheia API v0.2"}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from app.models.user import User
|
||||
from app.models.product import Product
|
||||
from app.models.scan import Scan
|
||||
|
||||
__all__ = ["User", "Product", "Scan"]
|
||||
from app.models.achievement import Achievement, UserAchievement
|
||||
from app.models.shopping_list import ShoppingItem
|
||||
|
||||
19
backend/app/models/achievement.py
Normal file
19
backend/app/models/achievement.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean
|
||||
from datetime import datetime, timezone
|
||||
from app.database import Base
|
||||
|
||||
class Achievement(Base):
|
||||
__tablename__ = "achievements"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
code = Column(String, unique=True, nullable=False)
|
||||
name = Column(String, nullable=False)
|
||||
description = Column(String, nullable=False)
|
||||
emoji = Column(String, default="🏆")
|
||||
target = Column(Integer, default=1) # number needed to unlock
|
||||
|
||||
class UserAchievement(Base):
|
||||
__tablename__ = "user_achievements"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
achievement_id = Column(Integer, ForeignKey("achievements.id"), nullable=False)
|
||||
unlocked_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
12
backend/app/models/shopping_list.py
Normal file
12
backend/app/models/shopping_list.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean
|
||||
from datetime import datetime, timezone
|
||||
from app.database import Base
|
||||
|
||||
class ShoppingItem(Base):
|
||||
__tablename__ = "shopping_list"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
product_name = Column(String, nullable=False)
|
||||
barcode = Column(String, nullable=True)
|
||||
checked = Column(Boolean, default=False)
|
||||
added_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text
|
||||
from datetime import datetime, timezone
|
||||
from app.database import Base
|
||||
|
||||
@@ -9,4 +9,6 @@ class User(Base):
|
||||
name = Column(String, nullable=False)
|
||||
password_hash = Column(String, nullable=False)
|
||||
is_premium = Column(Boolean, default=False)
|
||||
allergies = Column(Text, default="[]") # JSON array of allergies
|
||||
health_profile = Column(String, default="normal") # normal, crianca, gestante, diabetico, hipertenso
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
57
backend/app/routers/achievements.py
Normal file
57
backend/app/routers/achievements.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.scan import Scan
|
||||
from app.models.achievement import Achievement, UserAchievement
|
||||
from app.utils.security import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["achievements"])
|
||||
|
||||
@router.get("/achievements")
|
||||
async def get_achievements(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
# Get all achievements
|
||||
res = await db.execute(select(Achievement).order_by(Achievement.id))
|
||||
all_achievements = res.scalars().all()
|
||||
|
||||
# Get user unlocked
|
||||
unlocked_res = await db.execute(
|
||||
select(UserAchievement).where(UserAchievement.user_id == user.id)
|
||||
)
|
||||
unlocked = {ua.achievement_id: ua.unlocked_at for ua in unlocked_res.scalars().all()}
|
||||
|
||||
# Get progress counts
|
||||
total_scans_res = await db.execute(select(func.count(Scan.id)).where(Scan.user_id == user.id))
|
||||
total_scans = total_scans_res.scalar() or 0
|
||||
|
||||
photo_scans_res = await db.execute(
|
||||
select(func.count(Scan.id)).where(Scan.user_id == user.id, Scan.barcode == "PHOTO")
|
||||
)
|
||||
photo_scans = photo_scans_res.scalar() or 0
|
||||
|
||||
progress_map = {
|
||||
"first_scan": min(total_scans, 1),
|
||||
"detective": min(total_scans, 10),
|
||||
"expert": min(total_scans, 50),
|
||||
"master": min(total_scans, 100),
|
||||
"photographer": min(photo_scans, 5),
|
||||
"comparator": 0, # tracked separately
|
||||
}
|
||||
|
||||
result = []
|
||||
for a in all_achievements:
|
||||
is_unlocked = a.id in unlocked
|
||||
result.append({
|
||||
"id": a.id,
|
||||
"code": a.code,
|
||||
"name": a.name,
|
||||
"description": a.description,
|
||||
"emoji": a.emoji,
|
||||
"target": a.target,
|
||||
"progress": progress_map.get(a.code, 0),
|
||||
"unlocked": is_unlocked,
|
||||
"unlocked_at": unlocked[a.id].isoformat() if is_unlocked else None,
|
||||
})
|
||||
|
||||
return {"achievements": result, "total_scans": total_scans}
|
||||
@@ -1,6 +1,7 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
import json
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import RegisterRequest, LoginRequest, TokenResponse, UserResponse
|
||||
@@ -8,6 +9,14 @@ from app.utils.security import hash_password, verify_password, create_access_tok
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
def user_dict(user: User) -> dict:
|
||||
return {
|
||||
"id": user.id, "email": user.email, "name": user.name,
|
||||
"is_premium": user.is_premium,
|
||||
"allergies": json.loads(user.allergies or "[]"),
|
||||
"health_profile": user.health_profile or "normal",
|
||||
}
|
||||
|
||||
@router.post("/register", response_model=TokenResponse)
|
||||
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
existing = await db.execute(select(User).where(User.email == req.email))
|
||||
@@ -20,10 +29,7 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
await db.refresh(user)
|
||||
|
||||
token = create_access_token({"sub": str(user.id)})
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
user={"id": user.id, "email": user.email, "name": user.name, "is_premium": user.is_premium}
|
||||
)
|
||||
return TokenResponse(access_token=token, user=user_dict(user))
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
@@ -33,11 +39,8 @@ async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
raise HTTPException(status_code=401, detail="Email ou senha incorretos")
|
||||
|
||||
token = create_access_token({"sub": str(user.id)})
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
user={"id": user.id, "email": user.email, "name": user.name, "is_premium": user.is_premium}
|
||||
)
|
||||
return TokenResponse(access_token=token, user=user_dict(user))
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
@router.get("/me")
|
||||
async def me(user: User = Depends(get_current_user)):
|
||||
return UserResponse(id=user.id, email=user.email, name=user.name, is_premium=user.is_premium)
|
||||
return user_dict(user)
|
||||
|
||||
48
backend/app/routers/compare.py
Normal file
48
backend/app/routers/compare.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
import json
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.scan import Scan
|
||||
from app.models.product import Product
|
||||
from app.utils.security import get_current_user
|
||||
from app.services.achievements import check_achievements
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["compare"])
|
||||
|
||||
@router.post("/compare")
|
||||
async def compare_products(data: dict, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
scan_ids = data.get("scan_ids", [])
|
||||
if len(scan_ids) < 2 or len(scan_ids) > 4:
|
||||
raise HTTPException(status_code=400, detail="Selecione entre 2 e 4 produtos")
|
||||
|
||||
results = []
|
||||
for sid in scan_ids:
|
||||
res = await db.execute(select(Scan).where(Scan.id == sid, Scan.user_id == user.id))
|
||||
scan = res.scalar_one_or_none()
|
||||
if not scan:
|
||||
raise HTTPException(status_code=404, detail=f"Scan {sid} não encontrado")
|
||||
|
||||
analysis = json.loads(scan.analysis_json or '{}')
|
||||
prod_res = await db.execute(select(Product).where(Product.barcode == scan.barcode))
|
||||
product = prod_res.scalar_one_or_none()
|
||||
|
||||
results.append({
|
||||
"scan_id": scan.id,
|
||||
"product_name": scan.product_name,
|
||||
"brand": scan.brand,
|
||||
"score": scan.score,
|
||||
"image_url": product.image_url if product else None,
|
||||
"nutri_score": product.nutri_score if product else None,
|
||||
"nova_group": product.nova_group if product else None,
|
||||
"positives": analysis.get("positives", []),
|
||||
"negatives": analysis.get("negatives", []),
|
||||
"nutrition": analysis.get("nutrition", {}),
|
||||
"nutrition_verdict": analysis.get("nutrition_verdict", ""),
|
||||
})
|
||||
|
||||
# Check comparison achievement
|
||||
await check_achievements(user.id, db, action="compare")
|
||||
|
||||
return {"products": results}
|
||||
51
backend/app/routers/profile.py
Normal file
51
backend/app/routers/profile.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
import json
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.utils.security import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["profile"])
|
||||
|
||||
class ProfileUpdate(BaseModel):
|
||||
allergies: Optional[List[str]] = None
|
||||
health_profile: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
|
||||
VALID_PROFILES = ["normal", "crianca", "gestante", "diabetico", "hipertenso"]
|
||||
|
||||
@router.put("/profile")
|
||||
async def update_profile(data: ProfileUpdate, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
if data.allergies is not None:
|
||||
user.allergies = json.dumps(data.allergies)
|
||||
if data.health_profile is not None:
|
||||
if data.health_profile not in VALID_PROFILES:
|
||||
raise HTTPException(status_code=400, detail=f"Perfil inválido. Use: {', '.join(VALID_PROFILES)}")
|
||||
user.health_profile = data.health_profile
|
||||
if data.name is not None:
|
||||
user.name = data.name
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
return {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"is_premium": user.is_premium,
|
||||
"allergies": json.loads(user.allergies or "[]"),
|
||||
"health_profile": user.health_profile or "normal",
|
||||
}
|
||||
|
||||
@router.get("/profile")
|
||||
async def get_profile(user: User = Depends(get_current_user)):
|
||||
return {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"is_premium": user.is_premium,
|
||||
"allergies": json.loads(user.allergies or "[]"),
|
||||
"health_profile": user.health_profile or "normal",
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, File, UploadFile
|
||||
import json
|
||||
from datetime import datetime, timezone, date
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from app.database import get_db
|
||||
@@ -10,15 +10,21 @@ from app.models.scan import Scan
|
||||
from app.schemas.scan import ScanRequest, ScanResult, ScanHistoryItem
|
||||
from app.utils.security import get_current_user
|
||||
from app.integrations.open_food_facts import fetch_product
|
||||
from app.integrations.openai_client import analyze_product
|
||||
from app.integrations.openai_client import analyze_product, analyze_photo
|
||||
from app.config import settings
|
||||
from app.services.seed import SEED_PRODUCTS
|
||||
from app.services.achievements import check_achievements
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["scan"])
|
||||
|
||||
def get_user_context(user: User) -> dict:
|
||||
"""Build user context for AI analysis."""
|
||||
allergies = json.loads(user.allergies or "[]")
|
||||
health_profile = user.health_profile or "normal"
|
||||
return {"allergies": allergies, "health_profile": health_profile}
|
||||
|
||||
@router.post("/scan", response_model=ScanResult)
|
||||
async def scan_product(req: ScanRequest, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
# Rate limit check
|
||||
if not user.is_premium:
|
||||
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
result = await db.execute(
|
||||
@@ -28,7 +34,6 @@ async def scan_product(req: ScanRequest, user: User = Depends(get_current_user),
|
||||
if count >= settings.FREE_SCAN_LIMIT:
|
||||
raise HTTPException(status_code=429, detail=f"Limite de {settings.FREE_SCAN_LIMIT} scans/dia atingido. Faça upgrade para Premium!")
|
||||
|
||||
# Check local cache
|
||||
result = await db.execute(select(Product).where(Product.barcode == req.barcode))
|
||||
product = result.scalar_one_or_none()
|
||||
|
||||
@@ -43,12 +48,10 @@ async def scan_product(req: ScanRequest, user: User = Depends(get_current_user),
|
||||
"image_url": product.image_url,
|
||||
}
|
||||
else:
|
||||
# Check seed data
|
||||
if req.barcode in SEED_PRODUCTS:
|
||||
product_data = SEED_PRODUCTS[req.barcode].copy()
|
||||
source = "seed"
|
||||
else:
|
||||
# Fetch from Open Food Facts
|
||||
product_data = await fetch_product(req.barcode)
|
||||
source = "open_food_facts"
|
||||
|
||||
@@ -66,8 +69,38 @@ async def scan_product(req: ScanRequest, user: User = Depends(get_current_user),
|
||||
if not product_data:
|
||||
raise HTTPException(status_code=404, detail="Produto não encontrado. Tente inserir manualmente.")
|
||||
|
||||
# AI Analysis
|
||||
analysis = await analyze_product(product_data)
|
||||
user_context = get_user_context(user)
|
||||
analysis = await analyze_product(product_data, user_context=user_context)
|
||||
|
||||
# Add allergen alerts
|
||||
allergies = json.loads(user.allergies or "[]")
|
||||
allergen_alerts = []
|
||||
if allergies and analysis.get("ingredients"):
|
||||
for ing in analysis["ingredients"]:
|
||||
ing_name = (ing.get("name", "") + " " + ing.get("popular_name", "")).lower()
|
||||
for allergy in allergies:
|
||||
allergy_lower = allergy.lower()
|
||||
# Map common allergy names to ingredient keywords
|
||||
allergy_keywords = {
|
||||
"glúten": ["glúten", "trigo", "centeio", "cevada", "aveia", "farinha de trigo", "wheat", "gluten"],
|
||||
"lactose": ["lactose", "leite", "soro de leite", "whey", "caseína", "lácteo", "milk", "dairy"],
|
||||
"amendoim": ["amendoim", "peanut"],
|
||||
"soja": ["soja", "lecitina de soja", "soy"],
|
||||
"ovo": ["ovo", "albumina", "egg"],
|
||||
"frutos do mar": ["camarão", "peixe", "lagosta", "caranguejo", "marisco", "fish", "shrimp"],
|
||||
"nozes": ["nozes", "castanha", "amêndoa", "avelã", "nuts", "almond"],
|
||||
}
|
||||
keywords = allergy_keywords.get(allergy_lower, [allergy_lower])
|
||||
for kw in keywords:
|
||||
if kw in ing_name:
|
||||
allergen_alerts.append({
|
||||
"ingredient": ing.get("name", ""),
|
||||
"allergy": allergy,
|
||||
})
|
||||
ing["is_allergen"] = True
|
||||
break
|
||||
|
||||
analysis["allergen_alerts"] = allergen_alerts
|
||||
|
||||
# Save scan
|
||||
scan = Scan(
|
||||
@@ -77,8 +110,12 @@ async def scan_product(req: ScanRequest, user: User = Depends(get_current_user),
|
||||
)
|
||||
db.add(scan)
|
||||
await db.commit()
|
||||
|
||||
# Check achievements
|
||||
new_badges = await check_achievements(user.id, db, action="scan")
|
||||
|
||||
return ScanResult(
|
||||
id=scan.id,
|
||||
barcode=req.barcode,
|
||||
product_name=product_data.get("name"),
|
||||
brand=product_data.get("brand"),
|
||||
@@ -94,6 +131,9 @@ async def scan_product(req: ScanRequest, user: User = Depends(get_current_user),
|
||||
nutrition=analysis.get("nutrition"),
|
||||
nutrition_verdict=analysis.get("nutrition_verdict"),
|
||||
recipe=analysis.get("recipe"),
|
||||
substitutions=analysis.get("substitutions"),
|
||||
allergen_alerts=allergen_alerts,
|
||||
new_badges=new_badges,
|
||||
source=source,
|
||||
)
|
||||
|
||||
@@ -108,37 +148,6 @@ async def get_history(user: User = Depends(get_current_user), db: AsyncSession =
|
||||
brand=s.brand, score=s.score, scanned_at=s.scanned_at
|
||||
) for s in scans]
|
||||
|
||||
@router.get("/history/{scan_id}")
|
||||
async def get_scan_detail(scan_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(
|
||||
select(Scan).where(Scan.id == scan_id, Scan.user_id == user.id)
|
||||
)
|
||||
scan = result.scalar_one_or_none()
|
||||
if not scan:
|
||||
raise HTTPException(status_code=404, detail="Scan não encontrado")
|
||||
|
||||
analysis = json.loads(scan.analysis_json or '{}')
|
||||
# Also get product info
|
||||
prod_result = await db.execute(select(Product).where(Product.barcode == scan.barcode))
|
||||
product = prod_result.scalar_one_or_none()
|
||||
|
||||
return {
|
||||
"id": scan.id,
|
||||
"barcode": scan.barcode,
|
||||
"product_name": scan.product_name,
|
||||
"brand": scan.brand,
|
||||
"score": scan.score,
|
||||
"summary": scan.summary,
|
||||
"scanned_at": scan.scanned_at.isoformat() if scan.scanned_at else None,
|
||||
"category": product.category if product else None,
|
||||
"image_url": product.image_url if product else None,
|
||||
"nutri_score": product.nutri_score if product else None,
|
||||
"nova_group": product.nova_group if product else None,
|
||||
"positives": analysis.get("positives", []),
|
||||
"negatives": analysis.get("negatives", []),
|
||||
"ingredients": analysis.get("ingredients", []),
|
||||
}
|
||||
|
||||
@router.get("/history/{scan_id}")
|
||||
async def get_scan_detail(scan_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(
|
||||
@@ -170,4 +179,88 @@ async def get_scan_detail(scan_id: int, user: User = Depends(get_current_user),
|
||||
"nutrition": analysis.get("nutrition", {}),
|
||||
"nutrition_verdict": analysis.get("nutrition_verdict", ""),
|
||||
"recipe": analysis.get("recipe"),
|
||||
"substitutions": analysis.get("substitutions"),
|
||||
"allergen_alerts": analysis.get("allergen_alerts", []),
|
||||
}
|
||||
|
||||
@router.post("/scan/photo")
|
||||
async def scan_photo(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), file: UploadFile = File(...)):
|
||||
if not user.is_premium:
|
||||
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
result = await db.execute(
|
||||
select(func.count(Scan.id)).where(Scan.user_id == user.id, Scan.scanned_at >= today_start)
|
||||
)
|
||||
count = result.scalar()
|
||||
if count >= settings.FREE_SCAN_LIMIT:
|
||||
raise HTTPException(status_code=429, detail=f"Limite de {settings.FREE_SCAN_LIMIT} scans/dia atingido.")
|
||||
|
||||
contents = await file.read()
|
||||
if len(contents) > 10 * 1024 * 1024:
|
||||
raise HTTPException(status_code=400, detail="Imagem muito grande. Máximo 10MB.")
|
||||
|
||||
import base64
|
||||
from PIL import Image
|
||||
import io
|
||||
try:
|
||||
img = Image.open(io.BytesIO(contents))
|
||||
img = img.convert("RGB")
|
||||
max_dim = 1024
|
||||
if max(img.size) > max_dim:
|
||||
ratio = max_dim / max(img.size)
|
||||
img = img.resize((int(img.size[0]*ratio), int(img.size[1]*ratio)), Image.LANCZOS)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=85)
|
||||
b64 = base64.b64encode(buf.getvalue()).decode()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Imagem inválida: {str(e)}")
|
||||
|
||||
user_context = get_user_context(user)
|
||||
analysis = await analyze_photo(b64, user_context=user_context)
|
||||
|
||||
if not analysis:
|
||||
raise HTTPException(status_code=422, detail="Não foi possível analisar a imagem. Tente uma foto mais nítida do rótulo.")
|
||||
|
||||
scan = Scan(
|
||||
user_id=user.id, barcode="PHOTO",
|
||||
product_name=analysis.get("product_name", "Produto (foto)"),
|
||||
brand=analysis.get("brand", ""), score=analysis.get("score", 50),
|
||||
summary=analysis.get("summary", ""), analysis_json=json.dumps(analysis),
|
||||
)
|
||||
db.add(scan)
|
||||
|
||||
if analysis.get("product_name"):
|
||||
new_product = Product(
|
||||
barcode="PHOTO_" + str(hash(b64[:100]))[-8:],
|
||||
name=analysis.get("product_name"), brand=analysis.get("brand", ""),
|
||||
category=analysis.get("category", ""), ingredients_text=analysis.get("ingredients_text", ""),
|
||||
nutri_score=analysis.get("nutri_score"), nova_group=analysis.get("nova_group"),
|
||||
nutrition_json=json.dumps(analysis.get("nutrition", {})),
|
||||
)
|
||||
db.add(new_product)
|
||||
|
||||
await db.commit()
|
||||
|
||||
new_badges = await check_achievements(user.id, db, action="scan")
|
||||
|
||||
return {
|
||||
"id": scan.id,
|
||||
"barcode": "PHOTO",
|
||||
"product_name": analysis.get("product_name", "Produto (foto)"),
|
||||
"brand": analysis.get("brand", ""),
|
||||
"category": analysis.get("category", ""),
|
||||
"image_url": None,
|
||||
"score": analysis.get("score", 50),
|
||||
"summary": analysis.get("summary", ""),
|
||||
"positives": analysis.get("positives", []),
|
||||
"negatives": analysis.get("negatives", []),
|
||||
"ingredients": analysis.get("ingredients", []),
|
||||
"nutrition": analysis.get("nutrition", {}),
|
||||
"nutrition_verdict": analysis.get("nutrition_verdict", ""),
|
||||
"recipe": analysis.get("recipe"),
|
||||
"substitutions": analysis.get("substitutions"),
|
||||
"allergen_alerts": analysis.get("allergen_alerts", []),
|
||||
"nutri_score": analysis.get("nutri_score"),
|
||||
"nova_group": analysis.get("nova_group"),
|
||||
"new_badges": new_badges,
|
||||
"source": "photo",
|
||||
}
|
||||
|
||||
58
backend/app/routers/share.py
Normal file
58
backend/app/routers/share.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
import json
|
||||
from app.database import get_db
|
||||
from app.models.scan import Scan
|
||||
from app.models.product import Product
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["share"])
|
||||
|
||||
@router.get("/scan/{scan_id}/share", response_class=HTMLResponse)
|
||||
async def share_scan(scan_id: int, db: AsyncSession = Depends(get_db)):
|
||||
res = await db.execute(select(Scan).where(Scan.id == scan_id))
|
||||
scan = res.scalar_one_or_none()
|
||||
if not scan:
|
||||
raise HTTPException(status_code=404, detail="Scan não encontrado")
|
||||
|
||||
analysis = json.loads(scan.analysis_json or '{}')
|
||||
score = scan.score or 0
|
||||
color = '#10B981' if score >= 70 else '#EAB308' if score >= 50 else '#F97316' if score >= 30 else '#EF4444'
|
||||
label = 'Excelente' if score >= 90 else 'Bom' if score >= 70 else 'Regular' if score >= 50 else 'Ruim' if score >= 30 else 'Péssimo'
|
||||
|
||||
positives = ''.join(f'<li style="color:#10B981">✅ {p}</li>' for p in analysis.get("positives", []))
|
||||
negatives = ''.join(f'<li style="color:#EF4444">❌ {n}</li>' for n in analysis.get("negatives", []))
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta property="og:title" content="ALETHEIA: {scan.product_name} - Score {score}/100">
|
||||
<meta property="og:description" content="{scan.summary or ''}">
|
||||
<title>ALETHEIA - {scan.product_name}</title>
|
||||
<style>
|
||||
*{{margin:0;padding:0;box-sizing:border-box}}
|
||||
body{{background:#0A0E17;color:white;font-family:Inter,system-ui,sans-serif;padding:20px;max-width:500px;margin:0 auto}}
|
||||
.card{{background:#111827;border-radius:20px;padding:24px;margin-bottom:16px;border:1px solid rgba(255,255,255,0.05)}}
|
||||
.score{{width:120px;height:120px;border-radius:50%;border:6px solid {color};display:flex;align-items:center;justify-content:center;margin:0 auto 16px;flex-direction:column}}
|
||||
.score span{{font-size:36px;font-weight:900;color:{color}}}
|
||||
.score small{{font-size:12px;color:#9CA3AF}}
|
||||
h1{{font-size:20px;text-align:center;margin-bottom:4px}}
|
||||
h2{{font-size:14px;color:#9CA3AF;text-align:center;margin-bottom:16px}}
|
||||
.label{{display:inline-block;padding:4px 16px;border-radius:20px;font-weight:700;font-size:14px;color:{color};background:{color}15;text-align:center;margin:0 auto 20px;display:block;width:fit-content}}
|
||||
ul{{list-style:none;padding:0}}li{{font-size:13px;margin-bottom:6px;color:#D1D5DB}}
|
||||
.logo{{text-align:center;margin-top:24px;font-size:12px;color:#6B7280}}
|
||||
.logo b{{background:linear-gradient(135deg,#00D4AA,#7C3AED);-webkit-background-clip:text;-webkit-text-fill-color:transparent}}
|
||||
</style></head><body>
|
||||
<div class="card">
|
||||
<div class="score"><span>{score}</span><small>/100</small></div>
|
||||
<h1>{scan.product_name or 'Produto'}</h1>
|
||||
<h2>{scan.brand or ''}</h2>
|
||||
<div class="label">{label}</div>
|
||||
<p style="font-size:13px;color:#D1D5DB;text-align:center;margin-bottom:16px">{scan.summary or ''}</p>
|
||||
{f'<ul>{positives}</ul>' if positives else ''}
|
||||
{f'<ul style="margin-top:12px">{negatives}</ul>' if negatives else ''}
|
||||
</div>
|
||||
<div class="logo">Analisado por <b>ALETHEIA</b> — A verdade sobre o que você come</div>
|
||||
</body></html>"""
|
||||
return HTMLResponse(content=html)
|
||||
52
backend/app/routers/shopping.py
Normal file
52
backend/app/routers/shopping.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.shopping_list import ShoppingItem
|
||||
from app.utils.security import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["shopping"])
|
||||
|
||||
class ShoppingAdd(BaseModel):
|
||||
product_name: str
|
||||
barcode: Optional[str] = None
|
||||
|
||||
@router.post("/shopping-list")
|
||||
async def add_to_list(data: ShoppingAdd, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
item = ShoppingItem(user_id=user.id, product_name=data.product_name, barcode=data.barcode)
|
||||
db.add(item)
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
return {"id": item.id, "product_name": item.product_name, "barcode": item.barcode, "checked": item.checked, "added_at": item.added_at.isoformat()}
|
||||
|
||||
@router.get("/shopping-list")
|
||||
async def get_list(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
res = await db.execute(
|
||||
select(ShoppingItem).where(ShoppingItem.user_id == user.id).order_by(ShoppingItem.added_at.desc())
|
||||
)
|
||||
items = res.scalars().all()
|
||||
return [{"id": i.id, "product_name": i.product_name, "barcode": i.barcode, "checked": i.checked,
|
||||
"added_at": i.added_at.isoformat()} for i in items]
|
||||
|
||||
@router.delete("/shopping-list/{item_id}")
|
||||
async def delete_item(item_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
res = await db.execute(select(ShoppingItem).where(ShoppingItem.id == item_id, ShoppingItem.user_id == user.id))
|
||||
item = res.scalar_one_or_none()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item não encontrado")
|
||||
await db.delete(item)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@router.put("/shopping-list/{item_id}/toggle")
|
||||
async def toggle_item(item_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
res = await db.execute(select(ShoppingItem).where(ShoppingItem.id == item_id, ShoppingItem.user_id == user.id))
|
||||
item = res.scalar_one_or_none()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item não encontrado")
|
||||
item.checked = not item.checked
|
||||
await db.commit()
|
||||
return {"id": item.id, "checked": item.checked}
|
||||
87
backend/app/routers/stats.py
Normal file
87
backend/app/routers/stats.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc, asc
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.scan import Scan
|
||||
from app.utils.security import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["stats"])
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_stats(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
now = datetime.now(timezone.utc)
|
||||
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
# Total scans
|
||||
total_res = await db.execute(select(func.count(Scan.id)).where(Scan.user_id == user.id))
|
||||
total_scans = total_res.scalar() or 0
|
||||
|
||||
# Average score
|
||||
avg_res = await db.execute(select(func.avg(Scan.score)).where(Scan.user_id == user.id))
|
||||
avg_score = round(avg_res.scalar() or 0, 1)
|
||||
|
||||
# Monthly scans
|
||||
monthly_res = await db.execute(
|
||||
select(func.count(Scan.id)).where(Scan.user_id == user.id, Scan.scanned_at >= month_start)
|
||||
)
|
||||
monthly_scans = monthly_res.scalar() or 0
|
||||
|
||||
# Top 10 best this month
|
||||
best_res = await db.execute(
|
||||
select(Scan).where(Scan.user_id == user.id, Scan.scanned_at >= month_start)
|
||||
.order_by(desc(Scan.score)).limit(10)
|
||||
)
|
||||
best = [{"id": s.id, "product_name": s.product_name, "brand": s.brand, "score": s.score,
|
||||
"scanned_at": s.scanned_at.isoformat() if s.scanned_at else None} for s in best_res.scalars().all()]
|
||||
|
||||
# Top 10 worst this month
|
||||
worst_res = await db.execute(
|
||||
select(Scan).where(Scan.user_id == user.id, Scan.scanned_at >= month_start)
|
||||
.order_by(asc(Scan.score)).limit(10)
|
||||
)
|
||||
worst = [{"id": s.id, "product_name": s.product_name, "brand": s.brand, "score": s.score,
|
||||
"scanned_at": s.scanned_at.isoformat() if s.scanned_at else None} for s in worst_res.scalars().all()]
|
||||
|
||||
return {
|
||||
"total_scans": total_scans,
|
||||
"avg_score": avg_score,
|
||||
"monthly_scans": monthly_scans,
|
||||
"best": best,
|
||||
"worst": worst,
|
||||
}
|
||||
|
||||
@router.get("/stats/evolution")
|
||||
async def get_evolution(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
now = datetime.now(timezone.utc)
|
||||
three_months_ago = now - timedelta(days=90)
|
||||
|
||||
result = await db.execute(
|
||||
select(Scan.scanned_at, Scan.score)
|
||||
.where(Scan.user_id == user.id, Scan.scanned_at >= three_months_ago)
|
||||
.order_by(Scan.scanned_at)
|
||||
)
|
||||
scans = result.all()
|
||||
|
||||
# Group by week
|
||||
weeks = {}
|
||||
for scanned_at, score in scans:
|
||||
# ISO week
|
||||
week_key = scanned_at.strftime("%Y-W%W")
|
||||
week_start = scanned_at - timedelta(days=scanned_at.weekday())
|
||||
if week_key not in weeks:
|
||||
weeks[week_key] = {"week": week_start.strftime("%d/%m"), "scores": [], "count": 0}
|
||||
weeks[week_key]["scores"].append(score)
|
||||
weeks[week_key]["count"] += 1
|
||||
|
||||
evolution = []
|
||||
for key in sorted(weeks.keys()):
|
||||
w = weeks[key]
|
||||
evolution.append({
|
||||
"week": w["week"],
|
||||
"avg_score": round(sum(w["scores"]) / len(w["scores"]), 1),
|
||||
"count": w["count"],
|
||||
})
|
||||
|
||||
return {"evolution": evolution}
|
||||
@@ -8,9 +8,10 @@ class ScanRequest(BaseModel):
|
||||
class IngredientAnalysis(BaseModel):
|
||||
name: str
|
||||
popular_name: Optional[str] = None
|
||||
explanation: str
|
||||
classification: str
|
||||
reason: str
|
||||
explanation: str = ""
|
||||
classification: str = "warning"
|
||||
reason: str = ""
|
||||
is_allergen: Optional[bool] = False
|
||||
|
||||
class RecipeInfo(BaseModel):
|
||||
title: Optional[str] = None
|
||||
@@ -21,7 +22,18 @@ class RecipeInfo(BaseModel):
|
||||
steps: Optional[List[str]] = None
|
||||
tip: Optional[str] = None
|
||||
|
||||
class SubstitutionItem(BaseModel):
|
||||
name: str
|
||||
brand: Optional[str] = None
|
||||
reason: str = ""
|
||||
estimated_score: Optional[int] = None
|
||||
|
||||
class AllergenAlert(BaseModel):
|
||||
ingredient: str
|
||||
allergy: str
|
||||
|
||||
class ScanResult(BaseModel):
|
||||
id: Optional[int] = None
|
||||
barcode: str
|
||||
product_name: Optional[str] = None
|
||||
brand: Optional[str] = None
|
||||
@@ -31,12 +43,15 @@ class ScanResult(BaseModel):
|
||||
summary: str
|
||||
positives: List[str]
|
||||
negatives: List[str]
|
||||
ingredients: List[IngredientAnalysis]
|
||||
ingredients: List[Any] # Allow flexible ingredient format
|
||||
nutrition: Optional[Dict[str, Any]] = None
|
||||
nutrition_verdict: Optional[str] = None
|
||||
recipe: Optional[RecipeInfo] = None
|
||||
recipe: Optional[Any] = None
|
||||
nutri_score: Optional[str] = None
|
||||
nova_group: Optional[int] = None
|
||||
substitutions: Optional[List[Any]] = None
|
||||
allergen_alerts: Optional[List[Any]] = None
|
||||
new_badges: Optional[List[str]] = None
|
||||
source: str = "open_food_facts"
|
||||
|
||||
class ScanHistoryItem(BaseModel):
|
||||
|
||||
59
backend/app/services/achievements.py
Normal file
59
backend/app/services/achievements.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from app.models.achievement import Achievement, UserAchievement
|
||||
from app.models.scan import Scan
|
||||
|
||||
ACHIEVEMENT_DEFS = [
|
||||
{"code": "first_scan", "name": "Primeiro Scan", "description": "Escaneie seu primeiro produto", "emoji": "🎯", "target": 1},
|
||||
{"code": "detective", "name": "Detetive de Rótulos", "description": "Escaneie 10 produtos", "emoji": "🔍", "target": 10},
|
||||
{"code": "expert", "name": "Expert", "description": "Escaneie 50 produtos", "emoji": "🧠", "target": 50},
|
||||
{"code": "master", "name": "Master", "description": "Escaneie 100 produtos", "emoji": "👑", "target": 100},
|
||||
{"code": "photographer", "name": "Fotógrafo", "description": "Analise 5 produtos por foto", "emoji": "📸", "target": 5},
|
||||
{"code": "comparator", "name": "Comparador", "description": "Compare produtos 3 vezes", "emoji": "⚖️", "target": 3},
|
||||
]
|
||||
|
||||
async def seed_achievements(db: AsyncSession):
|
||||
for a_def in ACHIEVEMENT_DEFS:
|
||||
res = await db.execute(select(Achievement).where(Achievement.code == a_def["code"]))
|
||||
if not res.scalar_one_or_none():
|
||||
db.add(Achievement(**a_def))
|
||||
await db.commit()
|
||||
|
||||
async def check_achievements(user_id: int, db: AsyncSession, action: str = "scan"):
|
||||
"""Check and unlock achievements after an action."""
|
||||
# Get all achievements
|
||||
res = await db.execute(select(Achievement))
|
||||
all_achievements = {a.code: a for a in res.scalars().all()}
|
||||
|
||||
# Get already unlocked
|
||||
unlocked_res = await db.execute(select(UserAchievement).where(UserAchievement.user_id == user_id))
|
||||
unlocked_ids = {ua.achievement_id for ua in unlocked_res.scalars().all()}
|
||||
|
||||
# Count scans
|
||||
total_res = await db.execute(select(func.count(Scan.id)).where(Scan.user_id == user_id))
|
||||
total_scans = total_res.scalar() or 0
|
||||
|
||||
photo_res = await db.execute(
|
||||
select(func.count(Scan.id)).where(Scan.user_id == user_id, Scan.barcode == "PHOTO")
|
||||
)
|
||||
photo_scans = photo_res.scalar() or 0
|
||||
|
||||
checks = {
|
||||
"first_scan": total_scans >= 1,
|
||||
"detective": total_scans >= 10,
|
||||
"expert": total_scans >= 50,
|
||||
"master": total_scans >= 100,
|
||||
"photographer": photo_scans >= 5,
|
||||
}
|
||||
|
||||
new_unlocked = []
|
||||
for code, condition in checks.items():
|
||||
if code in all_achievements and all_achievements[code].id not in unlocked_ids and condition:
|
||||
ua = UserAchievement(user_id=user_id, achievement_id=all_achievements[code].id)
|
||||
db.add(ua)
|
||||
new_unlocked.append(all_achievements[code].name)
|
||||
|
||||
if new_unlocked:
|
||||
await db.commit()
|
||||
|
||||
return new_unlocked
|
||||
Reference in New Issue
Block a user