📚 Documentação inicial do ALETHEIA

- MANUAL-PRODUTO.md: Manual do usuário final
- MANUAL-VENDAS.md: Estratégia comercial e vendas
- MANUAL-TECNICO.md: Infraestrutura e deploy
- README.md: Visão geral do projeto
This commit is contained in:
2026-02-10 15:08:15 -03:00
commit 20a26affaa
16617 changed files with 3202171 additions and 0 deletions

0
backend/app/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

15
backend/app/config.py Normal file
View File

@@ -0,0 +1,15 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
DATABASE_URL: str = "sqlite+aiosqlite:///./aletheia.db"
SECRET_KEY: str = "aletheia-secret-key-change-in-production"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24
OPENAI_API_KEY: str = ""
OPENAI_MODEL: str = "gpt-4o-mini"
FREE_SCAN_LIMIT: int = 3
class Config:
env_file = ".env"
settings = Settings()

17
backend/app/database.py Normal file
View File

@@ -0,0 +1,17 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
engine = create_async_engine(settings.DATABASE_URL, echo=False)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db():
async with async_session() as session:
yield session
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)

View File

@@ -0,0 +1,27 @@
import httpx
from typing import Optional
async def fetch_product(barcode: str) -> Optional[dict]:
url = f"https://world.openfoodfacts.org/api/v2/product/{barcode}.json"
headers = {"User-Agent": "Aletheia/1.0 (contato@aletheia.app)"}
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(url, headers=headers)
if resp.status_code != 200:
return None
data = resp.json()
if data.get("status") != 1:
return None
p = data["product"]
return {
"name": p.get("product_name") or p.get("product_name_pt") or "Produto desconhecido",
"brand": p.get("brands", ""),
"category": p.get("categories", ""),
"ingredients_text": p.get("ingredients_text") or p.get("ingredients_text_pt") or "",
"nutri_score": (p.get("nutriscore_grade") or "").lower(),
"nova_group": p.get("nova_group"),
"nutrition": p.get("nutriments", {}),
"image_url": p.get("image_url", ""),
}
except Exception:
return None

View File

@@ -0,0 +1,86 @@
import json
from openai import AsyncOpenAI
from app.config import settings
SYSTEM_PROMPT = """Você é um nutricionista especialista brasileiro que analisa rótulos de alimentos.
Responda SEMPRE em JSON válido com esta estrutura exata:
{
"score": <int 0-100>,
"summary": "<resumo em 2-3 frases para leigo, em português>",
"positives": ["<ponto positivo 1>", ...],
"negatives": ["<ponto negativo 1>", ...],
"ingredients": [
{
"name": "<nome no rótulo>",
"popular_name": "<nome popular ou null>",
"explanation": "<o que é, 1 frase>",
"classification": "<good|warning|bad>",
"reason": "<motivo da classificação, 1 frase>"
}
]
}
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
Considere Nutri-Score, classificação NOVA, e ingredientes problemáticos.
Seja direto e honesto. Use linguagem simples."""
async def analyze_product(product_data: dict) -> dict:
if not settings.OPENAI_API_KEY:
return _mock_analysis(product_data)
client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY)
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')}
Analise este produto."""
try:
resp = await client.chat.completions.create(
model=settings.OPENAI_MODEL,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_msg}
],
response_format={"type": "json_object"},
temperature=0.3,
timeout=15,
)
return json.loads(resp.choices[0].message.content)
except Exception as e:
print(f"OpenAI error: {e}")
return _mock_analysis(product_data)
def _mock_analysis(product_data: dict) -> dict:
ingredients = product_data.get("ingredients_text", "")
score = 50
if any(w in ingredients.lower() for w in ["açúcar", "sugar", "xarope", "glucose"]):
score -= 15
if any(w in ingredients.lower() for w in ["hidrogenada", "trans"]):
score -= 20
if product_data.get("nova_group") == 4:
score -= 10
ns = product_data.get("nutri_score", "")
if ns == "e": score -= 10
elif ns == "d": score -= 5
elif ns == "a": score += 15
elif ns == "b": score += 10
score = max(0, min(100, score))
return {
"score": score,
"summary": f"Análise baseada em regras para {product_data.get('name', 'este produto')}. Configure OPENAI_API_KEY para análise completa com IA.",
"positives": ["Dados nutricionais disponíveis"],
"negatives": ["Análise IA indisponível - usando fallback"],
"ingredients": []
}

27
backend/app/main.py Normal file
View File

@@ -0,0 +1,27 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from app.database import init_db
from app.routers import auth, scan
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
yield
app = FastAPI(title="Aletheia API", version="0.1.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3080", "http://127.0.0.1:3080"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router)
app.include_router(scan.router)
@app.get("/api/health")
async def health():
return {"status": "ok", "app": "Aletheia API v0.1"}

View File

@@ -0,0 +1,5 @@
from app.models.user import User
from app.models.product import Product
from app.models.scan import Scan
__all__ = ["User", "Product", "Scan"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,17 @@
from sqlalchemy import Column, Integer, String, Text, DateTime
from datetime import datetime, timezone
from app.database import Base
class Product(Base):
__tablename__ = "products"
id = Column(Integer, primary_key=True, index=True)
barcode = Column(String, unique=True, index=True, nullable=False)
name = Column(String)
brand = Column(String)
category = Column(String)
ingredients_text = Column(Text)
nutri_score = Column(String)
nova_group = Column(Integer)
nutrition_json = Column(Text) # JSON string
image_url = Column(String)
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))

View File

@@ -0,0 +1,15 @@
from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime
from datetime import datetime, timezone
from app.database import Base
class Scan(Base):
__tablename__ = "scans"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
barcode = Column(String, nullable=False)
product_name = Column(String)
brand = Column(String)
score = Column(Integer)
summary = Column(Text)
analysis_json = Column(Text) # Full AI analysis as JSON
scanned_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))

View File

@@ -0,0 +1,12 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from datetime import datetime, timezone
from app.database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
name = Column(String, nullable=False)
password_hash = Column(String, nullable=False)
is_premium = Column(Boolean, default=False)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))

View File

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,43 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.models.user import User
from app.schemas.auth import RegisterRequest, LoginRequest, TokenResponse, UserResponse
from app.utils.security import hash_password, verify_password, create_access_token, get_current_user
router = APIRouter(prefix="/api/auth", tags=["auth"])
@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))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email já cadastrado")
user = User(email=req.email, name=req.name, password_hash=hash_password(req.password))
db.add(user)
await db.commit()
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}
)
@router.post("/login", response_model=TokenResponse)
async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.email == req.email))
user = result.scalar_one_or_none()
if not user or not verify_password(req.password, user.password_hash):
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}
)
@router.get("/me", response_model=UserResponse)
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)

106
backend/app/routers/scan.py Normal file
View File

@@ -0,0 +1,106 @@
import json
from datetime import datetime, timezone, date
from fastapi import APIRouter, Depends, HTTPException
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.product import Product
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.config import settings
from app.services.seed import SEED_PRODUCTS
router = APIRouter(prefix="/api", tags=["scan"])
@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(
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. 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()
product_data = None
source = "cache"
if product:
product_data = {
"name": product.name, "brand": product.brand, "category": product.category,
"ingredients_text": product.ingredients_text, "nutri_score": product.nutri_score,
"nova_group": product.nova_group, "nutrition": json.loads(product.nutrition_json or "{}"),
"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"
if product_data:
new_product = Product(
barcode=req.barcode, name=product_data.get("name"), brand=product_data.get("brand"),
category=product_data.get("category"), ingredients_text=product_data.get("ingredients_text"),
nutri_score=product_data.get("nutri_score"), nova_group=product_data.get("nova_group"),
nutrition_json=json.dumps(product_data.get("nutrition", {})),
image_url=product_data.get("image_url", ""),
)
db.add(new_product)
await db.commit()
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)
# Save scan
scan = Scan(
user_id=user.id, barcode=req.barcode, product_name=product_data.get("name"),
brand=product_data.get("brand"), score=analysis.get("score", 50),
summary=analysis.get("summary", ""), analysis_json=json.dumps(analysis),
)
db.add(scan)
await db.commit()
return ScanResult(
barcode=req.barcode,
product_name=product_data.get("name"),
brand=product_data.get("brand"),
category=product_data.get("category"),
image_url=product_data.get("image_url"),
score=analysis.get("score", 50),
summary=analysis.get("summary", ""),
positives=analysis.get("positives", []),
negatives=analysis.get("negatives", []),
ingredients=analysis.get("ingredients", []),
nutri_score=product_data.get("nutri_score"),
nova_group=product_data.get("nova_group"),
source=source,
)
@router.get("/history", response_model=list[ScanHistoryItem])
async def get_history(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Scan).where(Scan.user_id == user.id).order_by(Scan.scanned_at.desc()).limit(50)
)
scans = result.scalars().all()
return [ScanHistoryItem(
id=s.id, barcode=s.barcode, product_name=s.product_name,
brand=s.brand, score=s.score, scanned_at=s.scanned_at
) for s in scans]

View File

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,21 @@
from pydantic import BaseModel, EmailStr
class RegisterRequest(BaseModel):
email: str
name: str
password: str
class LoginRequest(BaseModel):
email: str
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user: dict
class UserResponse(BaseModel):
id: int
email: str
name: str
is_premium: bool

View File

@@ -0,0 +1,36 @@
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
class ScanRequest(BaseModel):
barcode: str
class IngredientAnalysis(BaseModel):
name: str
popular_name: Optional[str] = None
explanation: str
classification: str # "good", "warning", "bad"
reason: str
class ScanResult(BaseModel):
barcode: str
product_name: Optional[str] = None
brand: Optional[str] = None
category: Optional[str] = None
image_url: Optional[str] = None
score: int
summary: str
positives: List[str]
negatives: List[str]
ingredients: List[IngredientAnalysis]
nutri_score: Optional[str] = None
nova_group: Optional[int] = None
source: str = "open_food_facts"
class ScanHistoryItem(BaseModel):
id: int
barcode: str
product_name: Optional[str] = None
brand: Optional[str] = None
score: int
scanned_at: datetime

View File

Binary file not shown.

View File

@@ -0,0 +1,103 @@
# Seed data - Brazilian popular products
SEED_PRODUCTS = {
"7891000100103": {
"name": "Coca-Cola Original 350ml",
"brand": "Coca-Cola",
"category": "Refrigerantes",
"ingredients_text": "Água gaseificada, açúcar, extrato de noz de cola, cafeína, corante caramelo IV (INS 150d), acidulante ácido fosfórico (INS 338), aroma natural.",
"nutri_score": "e",
"nova_group": 4,
"nutrition": {"energy_kcal": 42, "sugars": 10.6, "fat": 0, "saturated_fat": 0, "sodium": 0.01, "fiber": 0, "proteins": 0},
"image_url": "",
},
"7891000053508": {
"name": "Nescau 2.0",
"brand": "Nestlé",
"category": "Achocolatados",
"ingredients_text": "Açúcar, cacau em pó, maltodextrina, composto lácteo (soro de leite em pó e gordura vegetal), minerais (ferro, zinco), sal, vitaminas (C, B3, B5, B6, A, ácido fólico, D, B12), emulsificante lecitina de soja, aromatizante.",
"nutri_score": "d",
"nova_group": 4,
"nutrition": {"energy_kcal": 380, "sugars": 72, "fat": 3.5, "saturated_fat": 2, "sodium": 0.1, "fiber": 3.5, "proteins": 5},
"image_url": "",
},
"7891000305232": {
"name": "Miojo Lámen Galinha Caipira",
"brand": "Nissin",
"category": "Macarrão instantâneo",
"ingredients_text": "Farinha de trigo enriquecida com ferro e ácido fólico, gordura vegetal, sal, reguladores de acidez (carbonato de potássio e carbonato de sódio), espessante goma guar, corante natural de cúrcuma, realçador de sabor glutamato monossódico, aromas, açúcar, cebola, alho, salsa.",
"nutri_score": "d",
"nova_group": 4,
"nutrition": {"energy_kcal": 436, "sugars": 4, "fat": 17, "saturated_fat": 8, "sodium": 1.6, "fiber": 2, "proteins": 9},
"image_url": "",
},
"7891962057620": {
"name": "Toddy Original",
"brand": "PepsiCo",
"category": "Achocolatados",
"ingredients_text": "Açúcar, cacau em pó, maltodextrina, minerais (fosfato de cálcio, pirofosfato férrico, óxido de zinco), vitaminas (C, PP, B6, B2, B1, A, ácido fólico, D, B12), sal, emulsificante lecitina de soja e aromatizante.",
"nutri_score": "d",
"nova_group": 4,
"nutrition": {"energy_kcal": 375, "sugars": 77, "fat": 2.2, "saturated_fat": 1.4, "sodium": 0.05, "fiber": 4.5, "proteins": 3.9},
"image_url": "",
},
"7891910000197": {
"name": "Guaraná Antarctica 350ml",
"brand": "Ambev",
"category": "Refrigerantes",
"ingredients_text": "Água gaseificada, açúcar, extrato de guaraná, acidulante ácido cítrico, aroma natural e corante caramelo IV.",
"nutri_score": "e",
"nova_group": 4,
"nutrition": {"energy_kcal": 40, "sugars": 10, "fat": 0, "saturated_fat": 0, "sodium": 0.01, "fiber": 0, "proteins": 0},
"image_url": "",
},
"7891150029392": {
"name": "Leite Moça Tradicional 395g",
"brand": "Nestlé",
"category": "Leite condensado",
"ingredients_text": "Leite integral, açúcar e lactose.",
"nutri_score": "d",
"nova_group": 2,
"nutrition": {"energy_kcal": 321, "sugars": 55, "fat": 8, "saturated_fat": 5, "sodium": 0.12, "fiber": 0, "proteins": 7},
"image_url": "",
},
"7896004800011": {
"name": "Biscoito Maizena Vitarella",
"brand": "Vitarella",
"category": "Biscoitos",
"ingredients_text": "Farinha de trigo enriquecida com ferro e ácido fólico, açúcar, gordura vegetal, amido de milho, soro de leite em pó, sal, fermentos químicos (bicarbonato de amônio, bicarbonato de sódio e fosfato monocálcico), emulsificante lecitina de soja, aromatizante.",
"nutri_score": "d",
"nova_group": 4,
"nutrition": {"energy_kcal": 443, "sugars": 22, "fat": 14, "saturated_fat": 6, "sodium": 0.3, "fiber": 1.5, "proteins": 7},
"image_url": "",
},
"7891000244203": {
"name": "Iogurte Nestlé Grego Tradicional",
"brand": "Nestlé",
"category": "Iogurtes",
"ingredients_text": "Leite integral e/ou reconstituído, preparado de açúcar (açúcar, água, amido modificado, cloreto de cálcio e conservador sorbato de potássio), creme de leite, proteínas do leite, fermento lácteo.",
"nutri_score": "c",
"nova_group": 4,
"nutrition": {"energy_kcal": 134, "sugars": 15, "fat": 6, "saturated_fat": 3.8, "sodium": 0.05, "fiber": 0, "proteins": 4.8},
"image_url": "",
},
"7622300830236": {
"name": "Biscoito Oreo Original",
"brand": "Mondelez",
"category": "Biscoitos recheados",
"ingredients_text": "Farinha de trigo enriquecida com ferro e ácido fólico, açúcar, gordura vegetal, cacau em pó, amido de milho, sal, fermentos químicos, emulsificante lecitina de soja, aromatizante vanilina.",
"nutri_score": "e",
"nova_group": 4,
"nutrition": {"energy_kcal": 476, "sugars": 36, "fat": 20, "saturated_fat": 10, "sodium": 0.4, "fiber": 2, "proteins": 5},
"image_url": "",
},
"7891000362006": {
"name": "Aveia Quaker Flocos Finos",
"brand": "Quaker",
"category": "Cereais",
"ingredients_text": "Aveia em flocos finos.",
"nutri_score": "a",
"nova_group": 1,
"nutrition": {"energy_kcal": 366, "sugars": 1, "fat": 7.5, "saturated_fat": 1.3, "sodium": 0.002, "fiber": 9.5, "proteins": 14},
"image_url": "",
},
}

Binary file not shown.

View File

@@ -0,0 +1,43 @@
from passlib.context import CryptContext
from jose import jwt, JWTError
from datetime import datetime, timedelta, timezone
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.config import settings
from app.database import get_db
from app.models.user import User
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
security = HTTPBearer()
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def create_access_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db)
) -> User:
try:
payload = jwt.decode(credentials.credentials, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(status_code=401, detail="Invalid token")
except JWTError:
raise HTTPException(status_code=401, detail="Invalid token")
result = await db.execute(select(User).where(User.id == int(user_id)))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=401, detail="User not found")
return user