v0.2 - 19 features: comparator, allergies, gamification, shopping list, achievements, stats, profile, share, bottom nav

This commit is contained in:
2026-02-10 18:52:42 -03:00
parent e8f4788a33
commit ecdd7546d3
33 changed files with 2105 additions and 309 deletions

View File

@@ -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

View File

@@ -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"}

View File

@@ -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

View 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))

View 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))

View File

@@ -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))

View 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}

View File

@@ -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)

View 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}

View 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",
}

View File

@@ -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",
}

View 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)

View 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}

View 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}

View File

@@ -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):

View 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