Emeritus-21 commited on
Commit
344cec3
·
verified ·
1 Parent(s): 4c2468b

Upload 39 files

Browse files
Files changed (39) hide show
  1. backend/.github/workflows/ci-cd.yml +0 -0
  2. backend/.gitignore +29 -0
  3. backend/app/__pycache__/config.cpython-311.pyc +0 -0
  4. backend/app/__pycache__/main.cpython-311.pyc +0 -0
  5. backend/app/config.py +31 -0
  6. backend/app/core/__init__.py +0 -0
  7. backend/app/core/__pycache__/__init__.cpython-311.pyc +0 -0
  8. backend/app/core/__pycache__/websocket_handler.cpython-311.pyc +0 -0
  9. backend/app/core/websocket_handler.py +68 -0
  10. backend/app/main.py +17 -0
  11. backend/app/models/__pycache__/schemas.cpython-311.pyc +0 -0
  12. backend/app/models/schemas.py +65 -0
  13. backend/app/routers/__pycache__/agent.cpython-311.pyc +0 -0
  14. backend/app/routers/__pycache__/chat.cpython-311.pyc +0 -0
  15. backend/app/routers/__pycache__/compare.cpython-311.pyc +0 -0
  16. backend/app/routers/__pycache__/scan.cpython-311.pyc +0 -0
  17. backend/app/routers/agent.py +33 -0
  18. backend/app/routers/chat.py +17 -0
  19. backend/app/routers/scan.py +76 -0
  20. backend/app/services/__pycache__/gamification_service.cpython-311.pyc +0 -0
  21. backend/app/services/__pycache__/graph_store.cpython-311.pyc +0 -0
  22. backend/app/services/__pycache__/ocr_service.cpython-311.pyc +0 -0
  23. backend/app/services/__pycache__/predictive_engine.cpython-311.pyc +0 -0
  24. backend/app/services/__pycache__/rag_service.cpython-311.pyc +0 -0
  25. backend/app/services/__pycache__/recommendation_service.cpython-311.pyc +0 -0
  26. backend/app/services/__pycache__/vector_store.cpython-311.pyc +0 -0
  27. backend/app/services/gamification_service.py +22 -0
  28. backend/app/services/graph_store.py +62 -0
  29. backend/app/services/ocr_service.py +66 -0
  30. backend/app/services/predictive_engine.py +72 -0
  31. backend/app/services/rag_service.py +102 -0
  32. backend/app/services/recommendation_service.py +10 -0
  33. backend/app/services/vector_store.py +63 -0
  34. backend/app/utils/__pycache__/preprocessor.cpython-311.pyc +0 -0
  35. backend/app/utils/preprocessor.py +21 -0
  36. backend/backend/.dockerignore +10 -0
  37. backend/data/ingredient_graph.json +174 -0
  38. backend/docker-compose.yml +58 -0
  39. backend/requirements.txt +19 -0
backend/.github/workflows/ci-cd.yml ADDED
File without changes
backend/.gitignore ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Secrets & Env
2
+ .env
3
+ *.env.local
4
+ .env.*.local
5
+
6
+ # Python
7
+ __pycache__/
8
+ *.pyc
9
+ *.pyo
10
+ *.egg-info/
11
+ dist/
12
+ build/
13
+ .venv/
14
+ venv/
15
+
16
+ # Node
17
+ node_modules/
18
+ frontend/dist/
19
+
20
+ # OS
21
+ .DS_Store
22
+ Thumbs.db
23
+
24
+ # IDE
25
+ .vscode/
26
+ .idea/
27
+
28
+ # Docker
29
+ .docker/
backend/app/__pycache__/config.cpython-311.pyc ADDED
Binary file (2 kB). View file
 
backend/app/__pycache__/main.cpython-311.pyc ADDED
Binary file (1.38 kB). View file
 
backend/app/config.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic_settings import BaseSettings
2
+ from typing import Optional, List
3
+
4
+ class Settings(BaseSettings):
5
+ APP_NAME: str = "scanleni-pro"
6
+ API_PREFIX: str = "/api/v1"
7
+ SECRET_KEY: str = "change-in-production-use-openssl-rand-hex-32"
8
+ ALGORITHM: str = "HS256"
9
+ ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
10
+
11
+ OCR_CONFIDENCE_THRESHOLD: float = 0.45
12
+ OCR_MAX_DIM: int = 1500
13
+
14
+ LLM_PROVIDER: str = "openai" # openai | gemini | claude | local
15
+ LLM_API_KEY: Optional[str] = None
16
+ LLM_BASE_URL: Optional[str] = None
17
+ LLM_MODEL: str = "gpt-4o-mini"
18
+ EMBEDDING_MODEL: str = "all-MiniLM-L6-v2"
19
+ VECTOR_TOP_K: int = 4
20
+
21
+ DATABASE_URL: str = "postgresql://postgres:postgres@localhost:5432/scanleni"
22
+ REDIS_URL: str = "redis://localhost:6379/0"
23
+
24
+ RATE_LIMIT_REQUESTS: int = 30
25
+ RATE_LIMIT_WINDOW: int = 60
26
+
27
+ class Config:
28
+ env_file = ".env"
29
+ case_sensitive = True
30
+
31
+ settings = Settings()
backend/app/core/__init__.py ADDED
File without changes
backend/app/core/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (171 Bytes). View file
 
backend/app/core/__pycache__/websocket_handler.cpython-311.pyc ADDED
Binary file (5.13 kB). View file
 
backend/app/core/websocket_handler.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import time
3
+ from fastapi import WebSocket
4
+ from app.services.ocr_service import ocr_service
5
+ from app.services.predictive_engine import track_exposure, get_user_trends
6
+ from app.services.graph_store import graph_store
7
+ import logging
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ class ScanWebSocketManager:
12
+ def __init__(self):
13
+ self.active_connections: dict[str, WebSocket] = {}
14
+ self.debounce_seconds = 1.2
15
+ self.last_scan_time: dict[str, float] = {}
16
+
17
+ async def connect(self, websocket: WebSocket, user_id: str):
18
+ await websocket.accept()
19
+ self.active_connections[user_id] = websocket
20
+ logger.info("WS connected: %s", user_id)
21
+
22
+ def disconnect(self, user_id: str):
23
+ self.active_connections.pop(user_id, None)
24
+ self.last_scan_time.pop(user_id, None)
25
+ logger.info("WS disconnected: %s", user_id)
26
+
27
+ async def send_json(self, user_id: str, data: dict):
28
+ if ws := self.active_connections.get(user_id):
29
+ try:
30
+ await ws.send_json(data)
31
+ except Exception:
32
+ self.disconnect(user_id)
33
+
34
+ async def process_frame(self, user_id: str, image_bytes: bytes, profile: dict):
35
+ now = time.time()
36
+ last = self.last_scan_time.get(user_id, 0)
37
+ if now - last < self.debounce_seconds:
38
+ return
39
+
40
+ self.last_scan_time[user_id] = now
41
+ try:
42
+ ocr_data = ocr_service.extract(image_bytes)
43
+ harmful_count = sum(1 for b in ocr_data if b.is_harmful)
44
+ health_score = max(0, 100 - (harmful_count * 12))
45
+
46
+ # Predictive & Graph enrichment
47
+ track_exposure(user_id, ocr_data)
48
+ trends = get_user_trends(user_id)
49
+ graph_insights = {}
50
+ for b in ocr_data:
51
+ if b.is_harmful:
52
+ related = graph_store.get_related(b.text.lower().replace(" ", "_"), depth=1)
53
+ if related:
54
+ graph_insights[b.text] = related
55
+
56
+ await self.send_json(user_id, {
57
+ "type": "scan_update",
58
+ "ocr_data": [b.model_dump() for b in ocr_data],
59
+ "health_score": health_score,
60
+ "trends": trends,
61
+ "graph_insights": graph_insights,
62
+ "timestamp": now
63
+ })
64
+ except Exception as e:
65
+ logger.error("WS OCR failed for %s: %s", user_id, e)
66
+ await self.send_json(user_id, {"type": "error", "message": "Processing failed. Hold steady."})
67
+
68
+ ws_manager = ScanWebSocketManager()
backend/app/main.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from app.routers import scan, chat
4
+ from app.config import settings
5
+ import logging
6
+
7
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
8
+
9
+ app = FastAPI(title=settings.APP_NAME, version="1.0.0")
10
+ app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
11
+
12
+ app.include_router(scan.router, prefix=settings.API_PREFIX)
13
+ app.include_router(chat.router, prefix=settings.API_PREFIX)
14
+
15
+ @app.get("/health")
16
+ def health():
17
+ return {"status": "ok", "service": settings.APP_NAME}
backend/app/models/__pycache__/schemas.cpython-311.pyc ADDED
Binary file (5 kB). View file
 
backend/app/models/schemas.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, Field
2
+ from typing import List, Optional
3
+ from datetime import datetime
4
+
5
+ class BBox(BaseModel):
6
+ points: List[List[float]]
7
+
8
+ class TextBlock(BaseModel):
9
+ text: str
10
+ confidence: float = Field(..., ge=0.0, le=1.0)
11
+ bbox: BBox
12
+ is_harmful: bool = False
13
+ harm_reason: Optional[str] = None
14
+ category: Optional[str] = None
15
+
16
+ class RiskAnalysis(BaseModel):
17
+ health_score: int = Field(..., ge=0, le=100)
18
+ risk_level: str
19
+ flagged_ingredients: List[str] = []
20
+ allergens_detected: List[str] = []
21
+ ultra_processed_score: float = Field(..., ge=0.0, le=1.0)
22
+ summary: str
23
+
24
+ class Recommendation(BaseModel):
25
+ product_name: str
26
+ reason: str
27
+ match_score: float
28
+ image_url: Optional[str] = None
29
+ category: str
30
+
31
+ class GamificationState(BaseModel):
32
+ streak_days: int = 0
33
+ points: int = 0
34
+ badges: List[str] = []
35
+ level: int = 1
36
+ next_level_points: int = 100
37
+
38
+ class UserProfile(BaseModel):
39
+ user_id: str = "anonymous"
40
+ allergies: List[str] = []
41
+ conditions: List[str] = []
42
+ dietary: List[str] = []
43
+ skin_type: Optional[str] = None
44
+
45
+ class ARScanResponse(BaseModel):
46
+ status: str
47
+ ocr_data: List[TextBlock] = []
48
+ risk_analysis: RiskAnalysis
49
+ recommendations: List[Recommendation] = []
50
+ gamification: GamificationState
51
+ ai_summary: str
52
+ timestamp: datetime = Field(default_factory=datetime.utcnow)
53
+
54
+ class ChatRequest(BaseModel):
55
+ message: str
56
+ context_product: Optional[str] = None
57
+ conversation_id: Optional[str] = None
58
+ user_profile: Optional[UserProfile] = None
59
+
60
+ class ChatResponse(BaseModel):
61
+ conversation_id: str
62
+ reply: str
63
+ sources: List[str] = []
64
+ confidence: float = 0.0
65
+ timestamp: datetime = Field(default_factory=datetime.utcnow)
backend/app/routers/__pycache__/agent.cpython-311.pyc ADDED
Binary file (2.21 kB). View file
 
backend/app/routers/__pycache__/chat.cpython-311.pyc ADDED
Binary file (1.58 kB). View file
 
backend/app/routers/__pycache__/compare.cpython-311.pyc ADDED
Binary file (3.74 kB). View file
 
backend/app/routers/__pycache__/scan.cpython-311.pyc ADDED
Binary file (4.12 kB). View file
 
backend/app/routers/agent.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+ from app.models.schemas import ChatRequest, ChatResponse
3
+ from app.services.rag_service import rag_service
4
+ import json
5
+
6
+ router = APIRouter(prefix="/agent", tags=["AI Agent"])
7
+
8
+ @router.post("/", response_model=ChatResponse)
9
+ async def run_agent(req: ChatRequest):
10
+ intent_prompt = f"""Classify this request into one category ONLY:
11
+ - skincare_routine
12
+ - product_comparison
13
+ - health_analysis
14
+ - general_qa
15
+ Request: "{req.message}"
16
+ Return ONLY the category name."""
17
+
18
+ intent_resp = await rag_service.llm.generate([{"role": "system", "content": intent_prompt}])
19
+ category = intent_resp.strip().lower()
20
+
21
+ if "comparison" in category:
22
+ steps = "1. Identify products\n2. Fetch profiles\n3. Calculate diff scores\n4. Output recommendation"
23
+ elif "routine" in category:
24
+ steps = "1. Identify skin type/budget\n2. Match products to profile\n3. Order steps (cleanse→treat→moisturize)\n4. Add warnings"
25
+ elif "health" in category:
26
+ steps = "1. Scan history analysis\n2. Exposure trend check\n3. Predict risk\n4. Suggest intervention"
27
+ else:
28
+ steps = "1. Retrieve safety context\n2. Personalize to profile\n3. Generate structured answer\n4. Provide next steps"
29
+
30
+ plan_msg = f"Agent Plan ({category}): [{steps}]\nExecute step-by-step and output final recommendation."
31
+ req.message = f"{req.message}\n\n{plan_msg}"
32
+
33
+ return await rag_service.chat(req)
backend/app/routers/chat.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException
2
+ from app.services.rag_service import rag_service
3
+ from app.models.schemas import ChatRequest, ChatResponse
4
+ import logging
5
+
6
+ logger = logging.getLogger(__name__)
7
+ router = APIRouter(prefix="/chat", tags=["AI Assistant"])
8
+
9
+ @router.post("/", response_model=ChatResponse)
10
+ async def chat_with_ai(req: ChatRequest):
11
+ try:
12
+ logger.info("Chat request: conv_id=%s, msg=%s", req.conversation_id, req.message[:50])
13
+ response = await rag_service.chat(req)
14
+ return response
15
+ except Exception as e:
16
+ logger.error("Chat endpoint failed: %s", e)
17
+ raise HTTPException(status_code=500, detail="AI chat service failed. Please try again.")
backend/app/routers/scan.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, File, UploadFile, HTTPException, Query
2
+ from app.services.ocr_service import ocr_service
3
+ from app.services.gamification_service import calculate_gamification
4
+ from app.services.recommendation_service import find_alternatives
5
+ from app.models.schemas import ARScanResponse, RiskAnalysis, GamificationState
6
+ import logging
7
+
8
+ router = APIRouter(prefix="/scan", tags=["Scanning"])
9
+ logger = logging.getLogger(__name__)
10
+
11
+ # Lightweight fallback keywords for immediate flagging.
12
+ # The AI/RAG engine will replace this with dynamic, profile-aware analysis in Phase 3.
13
+ HARMFUL_KEYWORDS = [
14
+ "paraben", "sulfate", "phthalate", "formaldehyde", "fragrance", "parfum",
15
+ "high fructose corn syrup", "red 40", "yellow 5", "sodium benzoate", "msg",
16
+ "alcohol denat", "mineral oil", "retinol"
17
+ ]
18
+
19
+ @router.post("/", response_model=ARScanResponse)
20
+ async def scan_product(file: UploadFile = File(...), conditions: str = Query(default="")):
21
+ logger.info("Scan request received: %s", file.filename)
22
+ contents = await file.read()
23
+
24
+ # 1. Run OCR Pipeline
25
+ try:
26
+ ocr_data = ocr_service.extract(contents)
27
+ except Exception as e:
28
+ logger.error("OCR processing failed: %s", str(e))
29
+ raise HTTPException(status_code=500, detail="OCR processing failed. Please try a clearer image.")
30
+
31
+ if not ocr_data:
32
+ raise HTTPException(status_code=400, detail="No text detected in the uploaded image.")
33
+
34
+ # 2. Flag harmful ingredients & attach reasons
35
+ flagged = []
36
+ for block in ocr_data:
37
+ text_lower = block.text.lower()
38
+ if any(keyword in text_lower for keyword in HARMFUL_KEYWORDS):
39
+ block.is_harmful = True
40
+ block.harm_reason = "Contains potentially harmful or controversial ingredient."
41
+ flagged.append(block.text)
42
+
43
+ # 3. Calculate health score & risk level
44
+ health_score = max(0, 100 - (len(flagged) * 15))
45
+ if health_score > 80:
46
+ risk_level = "SAFE"
47
+ elif health_score > 50:
48
+ risk_level = "MODERATE"
49
+ else:
50
+ risk_level = "HIGH"
51
+
52
+ # 4. Build RiskAnalysis (ALL required schema fields included)
53
+ risk = RiskAnalysis(
54
+ health_score=health_score,
55
+ risk_level=risk_level,
56
+ flagged_ingredients=flagged,
57
+ allergens_detected=[], # Required by schema. AI engine will populate later.
58
+ ultra_processed_score=0.0, # Required by schema. AI engine will calculate later.
59
+ summary=f"Detected {len(flagged)} flagged ingredients. Health score: {health_score}/100."
60
+ )
61
+
62
+ # 5. Run Gamification & Recommendation Engines
63
+ gamification = calculate_gamification(risk, GamificationState())
64
+ recommendations = find_alternatives(risk)
65
+
66
+ logger.info("Scan complete. Risk: %s, Score: %d, Flagged: %d", risk_level, health_score, len(flagged))
67
+
68
+ # 6. Return unified AR-ready payload
69
+ return ARScanResponse(
70
+ status="success",
71
+ ocr_data=ocr_data,
72
+ risk_analysis=risk,
73
+ recommendations=recommendations,
74
+ gamification=gamification,
75
+ ai_summary=risk.summary
76
+ )
backend/app/services/__pycache__/gamification_service.cpython-311.pyc ADDED
Binary file (1.54 kB). View file
 
backend/app/services/__pycache__/graph_store.cpython-311.pyc ADDED
Binary file (6.29 kB). View file
 
backend/app/services/__pycache__/ocr_service.cpython-311.pyc ADDED
Binary file (5.62 kB). View file
 
backend/app/services/__pycache__/predictive_engine.cpython-311.pyc ADDED
Binary file (6.42 kB). View file
 
backend/app/services/__pycache__/rag_service.cpython-311.pyc ADDED
Binary file (9 kB). View file
 
backend/app/services/__pycache__/recommendation_service.cpython-311.pyc ADDED
Binary file (1.11 kB). View file
 
backend/app/services/__pycache__/vector_store.cpython-311.pyc ADDED
Binary file (6.45 kB). View file
 
backend/app/services/gamification_service.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.models.schemas import RiskAnalysis, GamificationState
2
+
3
+ def calculate_gamification(risk: RiskAnalysis, current_state: GamificationState) -> GamificationState:
4
+ points = 10
5
+ if risk.risk_level == "HIGH":
6
+ points += 5
7
+ elif risk.risk_level == "SAFE":
8
+ points += 15
9
+
10
+ current_state.points += points
11
+ current_state.streak_days += 1
12
+ current_state.level = max(1, current_state.points // 100 + 1)
13
+ current_state.next_level_points = (current_state.level * 100) - current_state.points
14
+
15
+ if risk.health_score >= 90 and "Clean Choice" not in current_state.badges:
16
+ current_state.badges.append("Clean Choice")
17
+ if current_state.streak_days >= 7 and "Weekly Warrior" not in current_state.badges:
18
+ current_state.badges.append("Weekly Warrior")
19
+ if current_state.points >= 500 and "Ingredient Detective" not in current_state.badges:
20
+ current_state.badges.append("Ingredient Detective")
21
+
22
+ return current_state
backend/app/services/graph_store.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import networkx as nx
2
+ import json
3
+ from pathlib import Path
4
+ from typing import Dict, Any
5
+
6
+ GRAPH_PATH = Path(__file__).parent.parent.parent / "data" / "ingredient_graph.json"
7
+
8
+ class IngredientGraph:
9
+ def __init__(self):
10
+ self.graph = nx.DiGraph()
11
+ self._load_or_init()
12
+
13
+ def _load_or_init(self):
14
+ if GRAPH_PATH.exists():
15
+ data = json.loads(GRAPH_PATH.read_text())
16
+ for node, attrs in data.get("nodes", {}).items():
17
+ self.graph.add_node(node, **attrs)
18
+ for u, v, attrs in data.get("edges", []):
19
+ self.graph.add_edge(u, v, **attrs)
20
+ else:
21
+ self._build_default()
22
+ self.save()
23
+
24
+ def _build_default(self):
25
+ ingredients = {
26
+ "parabens": {"category": "preservative", "risk": "high", "effects": ["endocrine_disruption", "skin_irritation"]},
27
+ "retinol": {"category": "active", "risk": "moderate", "effects": ["photosensitivity", "anti_aging"]},
28
+ "fragrance": {"category": "additive", "risk": "moderate", "effects": ["allergen", "respiratory_irritation"]},
29
+ "niacinamide": {"category": "active", "risk": "low", "effects": ["barrier_repair", "anti_inflammatory"]},
30
+ "sodium_lauryl_sulfate": {"category": "surfactant", "risk": "moderate", "effects": ["barrier_stripping", "acne_trigger"]},
31
+ "alcohol_denat": {"category": "solvent", "risk": "moderate", "effects": ["drying", "barrier_disruption"]},
32
+ "mineral_oil": {"category": "emollient", "risk": "moderate", "effects": ["comedogenic", "occlusive"]}
33
+ }
34
+ for ing, meta in ingredients.items():
35
+ self.graph.add_node(ing, **meta)
36
+ for effect in meta["effects"]:
37
+ self.graph.add_edge(ing, effect, relation="causes")
38
+
39
+ def save(self):
40
+ data = {
41
+ "nodes": {n: dict(self.graph.nodes[n]) for n in self.graph.nodes()},
42
+ "edges": [(u, v, dict(d)) for u, v, d in self.graph.edges(data=True)]
43
+ }
44
+ GRAPH_PATH.parent.mkdir(parents=True, exist_ok=True)
45
+ GRAPH_PATH.write_text(json.dumps(data, indent=2))
46
+
47
+ def get_related(self, ingredient: str, depth: int = 2) -> Dict[str, Any]:
48
+ normalized = ingredient.lower().replace(" ", "_")
49
+ if normalized not in self.graph:
50
+ return {}
51
+ sub = nx.ego_graph(self.graph, normalized, radius=depth)
52
+ return {n: dict(sub.nodes[n]) for n in sub.nodes()}
53
+
54
+ def query_effects(self, effects: list[str]) -> Dict[str, Any]:
55
+ matches = {}
56
+ for ing in self.graph.nodes():
57
+ ing_effects = self.graph.nodes[ing].get("effects", [])
58
+ if any(e in ing_effects for e in effects):
59
+ matches[ing] = self.graph.nodes[ing]
60
+ return matches
61
+
62
+ graph_store = IngredientGraph()
backend/app/services/ocr_service.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cv2
2
+ import numpy as np
3
+ from PIL import Image, ImageEnhance, ImageFilter
4
+ import io
5
+ from rapidocr_onnxruntime import RapidOCR
6
+ from app.models.schemas import TextBlock, BBox
7
+ from app.config import settings
8
+ import re
9
+
10
+ class OCRService:
11
+ def __init__(self):
12
+ # Optimized for product labels: angle classification, higher det limit, batch processing
13
+ self.engine = RapidOCR(
14
+ use_angle_cls=True,
15
+ det_limit_side_len=1500,
16
+ rec_batch_num=6,
17
+ print_verbose=False
18
+ )
19
+ self.threshold = settings.OCR_CONFIDENCE_THRESHOLD
20
+
21
+ def _enhance_image(self, img: Image.Image) -> Image.Image:
22
+ """Product label optimization: sharpen, boost contrast, reduce color noise"""
23
+ img = img.filter(ImageFilter.SHARPEN)
24
+ img = ImageEnhance.Contrast(img).enhance(1.4)
25
+ img = ImageEnhance.Color(img).enhance(0.7) # Slight desaturation helps text pop
26
+ return img
27
+
28
+ def _clean_text(self, text: str) -> str:
29
+ """Fix OCR fragmentation, remove noise, merge broken words"""
30
+ text = re.sub(r'[^a-zA-Z0-9\s,./()-]', '', text)
31
+ text = re.sub(r'\s+', ' ', text).strip()
32
+ # Drop single-character noise unless it's a known initial (e.g., "E", "U")
33
+ if len(text) <= 1 and text.upper() not in "ABCDEFGHIJKLMNOPQRSTUVWXYZ":
34
+ return ""
35
+ return text
36
+
37
+ def extract(self, image_bytes: bytes) -> list[TextBlock]:
38
+ img = Image.open(io.BytesIO(image_bytes)).convert("RGB")
39
+ img = self._enhance_image(img)
40
+
41
+ if max(img.size) > settings.OCR_MAX_DIM:
42
+ img.thumbnail((settings.OCR_MAX_DIM, settings.OCR_MAX_DIM))
43
+
44
+ result, _ = self.engine(np.array(img))
45
+ blocks = []
46
+ if result:
47
+ for box, text, conf in result:
48
+ cleaned = self._clean_text(text)
49
+ if conf >= self.threshold and len(cleaned) > 1:
50
+ blocks.append(TextBlock(
51
+ text=cleaned,
52
+ confidence=float(conf),
53
+ bbox=BBox(points=box),
54
+ category=self._classify(cleaned)
55
+ ))
56
+ return blocks
57
+
58
+ def _classify(self, text: str) -> str:
59
+ t = text.lower()
60
+ if any(k in t for k in ["water", "aqua", "glycerin", "oil", "corn", "sugar", "flour"]): return "base"
61
+ if any(k in t for k in ["paraben", "sulfate", "phthalate", "benzoate"]): return "preservative"
62
+ if any(k in t for k in ["fragrance", "parfum", "aroma"]): return "fragrance"
63
+ if any(k in t for k in ["vitamin", "niacinamide", "retinol", "zinc"]): return "active"
64
+ return "other"
65
+
66
+ ocr_service = OCRService()
backend/app/services/predictive_engine.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ import json
3
+ from collections import defaultdict
4
+ from typing import Dict, List, Any
5
+ from redis import Redis
6
+ from app.config import settings
7
+
8
+ # Fallback to in-memory dict if Redis is unavailable (dev mode)
9
+ redis = Redis.from_url(settings.REDIS_URL, decode_responses=True) if hasattr(settings, "REDIS_URL") else None
10
+ in_memory_store: Dict[str, List[Dict]] = defaultdict(list)
11
+ in_memory_exposures: Dict[str, Dict] = defaultdict(lambda: defaultdict(int))
12
+
13
+ def track_exposure(user_id: str, ocr_data: list):
14
+ now = int(time.time())
15
+ flagged = [b.text.lower().replace(" ", "_") for b in ocr_data if b.is_harmful]
16
+
17
+ if redis:
18
+ key = f"user:{user_id}:scans"
19
+ redis.rpush(key, json.dumps({"ts": now, "flagged": len(flagged)}))
20
+ redis.expire(key, 60*60*24*90)
21
+ for ing in flagged:
22
+ exp_key = f"user:{user_id}:exposure:{ing}"
23
+ redis.hincrby(exp_key, "count", 1)
24
+ redis.hset(exp_key, "last_seen", now)
25
+ else:
26
+ in_memory_store[user_id].append({"ts": now, "flagged": len(flagged)})
27
+ if len(in_memory_store[user_id]) > 100:
28
+ in_memory_store[user_id] = in_memory_store[user_id][-100:]
29
+ for ing in flagged:
30
+ in_memory_exposures[user_id][ing] += 1
31
+
32
+ def get_user_trends(user_id: str) -> Dict[str, Any]:
33
+ scans = []
34
+ exposures = {}
35
+
36
+ if redis:
37
+ scan_key = f"user:{user_id}:scans"
38
+ scans_raw = redis.lrange(scan_key, 0, 29)
39
+ scans = [json.loads(s) for s in scans_raw]
40
+ exp_keys = redis.keys(f"user:{user_id}:exposure:*")
41
+ for key in exp_keys:
42
+ name = key.split(":")[-1]
43
+ count = int(redis.hget(key, "count") or 0)
44
+ if count > 0:
45
+ exposures[name] = count
46
+ else:
47
+ scans = in_memory_store.get(user_id, [])
48
+ exposures = dict(in_memory_exposures.get(user_id, {}))
49
+
50
+ if not scans:
51
+ return {"avg_flagged": 0, "trend": "stable", "top_exposures": [], "insight": "Scan more products to build your health baseline."}
52
+
53
+ flagged_counts = [s["flagged"] for s in scans]
54
+ avg = sum(flagged_counts) / len(flagged_counts)
55
+ recent = flagged_counts[-5:]
56
+ older = flagged_counts[:5] if len(flagged_counts) > 5 else []
57
+ trend = "improving" if len(recent) > 0 and len(older) > 0 and sum(recent) < sum(older) else "stable"
58
+
59
+ top_exp = sorted(exposures.items(), key=lambda x: x[1], reverse=True)[:3]
60
+ insight = f"Average flagged: {avg:.1f}/scan. "
61
+ if trend == "improving":
62
+ insight += "Your choices are getting cleaner."
63
+ elif top_exp:
64
+ insight += f"Frequent exposure: {', '.join(t[0].replace('_', ' ') for t in top_exp)}."
65
+
66
+ return {
67
+ "avg_flagged": round(avg, 1),
68
+ "trend": trend,
69
+ "top_exposures": [{"ingredient": k.replace('_', ' '), "count": v} for k, v in top_exp],
70
+ "insight": insight,
71
+ "scan_count": len(scans)
72
+ }
backend/app/services/rag_service.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import httpx
2
+ import uuid
3
+ import logging
4
+ from typing import List, Optional, Dict
5
+ from app.models.schemas import ChatRequest, ChatResponse
6
+ from app.services.vector_store import vector_store
7
+ from app.config import settings
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ class LLMClient:
12
+ def __init__(self, provider: str, api_key: Optional[str], base_url: str, model: str):
13
+ self.provider = provider
14
+ self.api_key = api_key
15
+ self.base_url = base_url.rstrip("/")
16
+ self.model = model
17
+ self.timeout = 30.0
18
+
19
+ async def generate(self, messages: List[Dict[str, str]]) -> str:
20
+ if not self.api_key or not self.api_key.startswith("gsk_"):
21
+ return "⚠️ AI service not configured. Please add a valid Groq API key to your .env file."
22
+
23
+ headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}
24
+ payload = {"model": self.model, "messages": messages, "temperature": 0.4, "max_tokens": 600, "top_p": 0.9}
25
+
26
+ try:
27
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
28
+ response = await client.post(f"{self.base_url}/chat/completions", json=payload, headers=headers)
29
+ response.raise_for_status()
30
+ data = response.json()
31
+ return data["choices"][0]["message"]["content"].strip()
32
+ except httpx.TimeoutException:
33
+ return "⏳ AI response timed out. Please try again."
34
+ except httpx.HTTPStatusError as e:
35
+ logger.error("LLM API error: %s - %s", e.response.status_code, e.response.text)
36
+ return f"⚠️ AI service error ({e.response.status_code}). Check API key or rate limits."
37
+ except Exception as e:
38
+ logger.error("Unexpected LLM error: %s", e)
39
+ return "⚠️ AI service temporarily unavailable. Please try again later."
40
+
41
+ class RAGService:
42
+ def __init__(self):
43
+ self.llm = LLMClient(settings.LLM_PROVIDER, settings.LLM_API_KEY, settings.LLM_BASE_URL, settings.LLM_MODEL)
44
+ self.conversations: Dict[str, List[Dict[str, str]]] = {}
45
+ self.max_memory_turns = 6
46
+ self.system_prompt = """You are ScanLeni AI, an intelligent product analysis assistant.
47
+ Users scan labels via camera OCR, which often produces fragmented, uppercase, or misspelled text (e.g., "cofn" = corn flour, "SWEETCORN", "yummy").
48
+ Your job:
49
+ 1. Interpret OCR output intelligently. Correct obvious typos/fragments silently.
50
+ 2. Guess the product type (snack, skincare, supplement, household, etc.) based on ingredients.
51
+ 3. Analyze safety, allergens, and health impact concisely.
52
+ 4. If text is unclear, explain what it likely means and give practical advice. Only ask for clarification if absolutely necessary.
53
+ 5. Keep responses conversational, structured, and actionable. Avoid robotic disclaimers like "I couldn't identify...".
54
+ 6. Never give medical diagnoses. Suggest consulting professionals for severe allergies/conditions.
55
+ Format naturally: Product Guess → Ingredient Breakdown → Safety Note → Quick Tip."""
56
+
57
+ async def chat(self, req: ChatRequest) -> ChatResponse:
58
+ conv_id = req.conversation_id or str(uuid.uuid4())
59
+ if conv_id not in self.conversations:
60
+ self.conversations[conv_id] = []
61
+
62
+ context_results = vector_store.search(req.message, top_k=settings.VECTOR_TOP_K)
63
+ context_texts = [text for text, _, _ in context_results]
64
+ context_str = "\n".join(context_texts) if context_texts else "No specific safety data retrieved."
65
+
66
+ profile_ctx = ""
67
+ if req.user_profile:
68
+ parts = []
69
+ if req.user_profile.conditions: parts.append(f"Conditions: {', '.join(req.user_profile.conditions)}")
70
+ if req.user_profile.allergies: parts.append(f"Allergies: {', '.join(req.user_profile.allergies)}")
71
+ if req.user_profile.dietary: parts.append(f"Dietary: {', '.join(req.user_profile.dietary)}")
72
+ if req.user_profile.skin_type: parts.append(f"Skin Type: {req.user_profile.skin_type}")
73
+ profile_ctx = " | ".join(parts) if parts else ""
74
+
75
+ # Clean OCR context before injecting
76
+ raw_ctx = req.context_product or ""
77
+ cleaned_ctx = raw_ctx.replace("Scanned ingredients: ", "").strip()
78
+ product_section = f"Scanned OCR Output: {cleaned_ctx}" if cleaned_ctx else ""
79
+
80
+ system_context = "\n".join(filter(None, [f"Retrieved Safety Knowledge:\n{context_str}", product_section, f"User Profile: {profile_ctx}"]))
81
+
82
+ messages = [
83
+ {"role": "system", "content": self.system_prompt},
84
+ {"role": "system", "content": system_context},
85
+ *self.conversations[conv_id][-self.max_memory_turns:],
86
+ {"role": "user", "content": req.message}
87
+ ]
88
+
89
+ reply = await self.llm.generate(messages)
90
+ self.conversations[conv_id].append({"role": "user", "content": req.message})
91
+ self.conversations[conv_id].append({"role": "assistant", "content": reply})
92
+ if len(self.conversations[conv_id]) > self.max_memory_turns * 2:
93
+ self.conversations[conv_id] = self.conversations[conv_id][-self.max_memory_turns * 2:]
94
+
95
+ return ChatResponse(
96
+ conversation_id=conv_id,
97
+ reply=reply,
98
+ sources=context_texts,
99
+ confidence=0.85 if context_texts else 0.60
100
+ )
101
+
102
+ rag_service = RAGService()
backend/app/services/recommendation_service.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.models.schemas import RiskAnalysis, Recommendation
2
+
3
+ def find_alternatives(risk: RiskAnalysis, category: str = "general") -> list[Recommendation]:
4
+ if risk.risk_level == "SAFE":
5
+ return []
6
+ return [
7
+ Recommendation(product_name="PureGlow Serum", reason="Fragrance-free, paraben-free, niacinamide-rich.", match_score=0.92, category="skincare"),
8
+ Recommendation(product_name="CleanBite Snacks", reason="No HFCS, whole grain, low sodium.", match_score=0.88, category="food"),
9
+ Recommendation(product_name="EcoHome Cleaner", reason="Plant-based, no phthalates, biodegradable.", match_score=0.85, category="household")
10
+ ]
backend/app/services/vector_store.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import faiss
2
+ import numpy as np
3
+ from sentence_transformers import SentenceTransformer
4
+ from typing import List, Tuple
5
+ from app.config import settings
6
+ import logging
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ class VectorStore:
11
+ def __init__(self):
12
+ self.embedder = SentenceTransformer(settings.EMBEDDING_MODEL)
13
+ self.index = None
14
+ self.documents = []
15
+ self.metadata = []
16
+ self._load_default_kb()
17
+
18
+ def _load_default_kb(self):
19
+ kb_data = [
20
+ {"text": "sweetcorn, corn, maize: whole grain, generally safe, high in fiber. May cause bloating in sensitive individuals.", "category": "food", "risk": "low"},
21
+ {"text": "cofn, corn flour, maize flour: refined corn starch, safe, commonly used as thickener. Low nutritional value.", "category": "food", "risk": "low"},
22
+ {"text": "sugar, sucrose, cane sugar: high glycemic index, linked to metabolic issues, dental decay. Limit intake.", "category": "sweetener", "risk": "moderate"},
23
+ {"text": "high fructose corn syrup, hfcs: ultra-processed sweetener, strongly linked to insulin resistance and fatty liver.", "category": "sweetener", "risk": "high"},
24
+ {"text": "parabens, methylparaben, propylparaben: preservatives, potential endocrine disruptors, avoid in pregnancy/skincare.", "category": "preservative", "risk": "high"},
25
+ {"text": "sodium lauryl sulfate, sls, sodium laureth sulfate, sles: harsh surfactants, strip skin barrier, cause irritation.", "category": "surfactant", "risk": "moderate"},
26
+ {"text": "fragrance, parfum, aroma: umbrella term for hidden chemicals, common allergen, avoid for sensitive skin/asthma.", "category": "fragrance", "risk": "moderate"},
27
+ {"text": "retinol, retinyl palmitate, vitamin a: anti-aging active, increases sun sensitivity, strictly avoid during pregnancy.", "category": "active", "risk": "moderate"},
28
+ {"text": "niacinamide, vitamin b3: barrier repair, anti-inflammatory, safe for acne, rosacea, and pregnancy.", "category": "active", "risk": "low"},
29
+ {"text": "titanium dioxide, zinc oxide: mineral UV filters, non-nano forms are safe, reef-friendly, low irritation.", "category": "uv_filter", "risk": "low"},
30
+ {"text": "phenoxyethanol: preservative, safe under 1%, can cause contact dermatitis in high concentrations.", "category": "preservative", "risk": "low"},
31
+ {"text": "mineral oil, paraffinum liquidum: occlusive, non-comedogenic but traps debris, avoid for acne-prone skin.", "category": "emollient", "risk": "moderate"},
32
+ {"text": "alcohol denat, sd alcohol: drying solvent, disrupts skin barrier, causes redness, avoid in leave-on skincare.", "category": "solvent", "risk": "moderate"},
33
+ {"text": "red 40, yellow 5, artificial colors: synthetic dyes, linked to hyperactivity in children, potential allergens.", "category": "additive", "risk": "high"},
34
+ {"text": "sodium benzoate, potassium sorbate: common preservatives, safe alone but can form benzene with vitamin C in acidic drinks.", "category": "preservative", "risk": "moderate"},
35
+ {"text": "msg, monosodium glutamate: flavor enhancer, safe for most, may trigger headaches/flushing in sensitive users.", "category": "additive", "risk": "low"},
36
+ {"text": "xanthan gum, guar gum: natural thickeners, fermented, generally safe, may cause mild digestive upset.", "category": "thickener", "risk": "low"},
37
+ {"text": "citric acid, ascorbic acid: natural preservatives, pH adjusters, safe, antioxidant properties.", "category": "active", "risk": "low"}
38
+ ]
39
+ self.documents = [item["text"] for item in kb_data]
40
+ self.metadata = kb_data
41
+ if self.documents:
42
+ embeddings = self.embedder.encode(self.documents, convert_to_numpy=True)
43
+ dim = embeddings.shape[1]
44
+ self.index = faiss.IndexFlatL2(dim)
45
+ self.index.add(embeddings)
46
+ logger.info("Vector store initialized with %d documents", len(self.documents))
47
+
48
+ def search(self, query: str, top_k: int = 4) -> List[Tuple[str, float, dict]]:
49
+ if not self.index or len(self.documents) == 0:
50
+ return []
51
+ try:
52
+ q_emb = self.embedder.encode([query], convert_to_numpy=True)
53
+ D, I = self.index.search(q_emb, top_k)
54
+ results = []
55
+ for idx, dist in zip(I[0], D[0]):
56
+ if idx < len(self.documents):
57
+ results.append((self.documents[idx], float(dist), self.metadata[idx]))
58
+ return results
59
+ except Exception as e:
60
+ logger.error("Vector search failed: %s", e)
61
+ return []
62
+
63
+ vector_store = VectorStore()
backend/app/utils/__pycache__/preprocessor.cpython-311.pyc ADDED
Binary file (2.24 kB). View file
 
backend/app/utils/preprocessor.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cv2
2
+ import numpy as np
3
+ from PIL import Image
4
+ import io
5
+
6
+ def preprocess_image(image_bytes: bytes, max_dim: int = 1500) -> Image.Image:
7
+ np_img = np.frombuffer(image_bytes, np.uint8)
8
+ img = cv2.imdecode(np_img, cv2.IMREAD_COLOR)
9
+ h, w = img.shape[:2]
10
+ if max(h, w) > max_dim:
11
+ scale = max_dim / max(h, w)
12
+ img = cv2.resize(img, (int(w*scale), int(h*scale)), interpolation=cv2.INTER_AREA)
13
+ img = cv2.fastNlMeansDenoisingColored(img, None, 10, 10, 7, 21)
14
+ lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB)
15
+ l, a, b = cv2.split(lab)
16
+ clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
17
+ l = clahe.apply(l)
18
+ img = cv2.merge((l, a, b))
19
+ img = cv2.cvtColor(img, cv2.COLOR_LAB2BGR)
20
+ _, encoded = cv2.imencode('.jpg', img)
21
+ return Image.open(io.BytesIO(encoded.tobytes())).convert("RGB")
backend/backend/.dockerignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ .venv
2
+ __pycache__
3
+ *.pyc
4
+ .git
5
+ .github
6
+ .env
7
+ .env.local
8
+ tests/
9
+ *.md
10
+ docker-compose.yml
backend/data/ingredient_graph.json ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "nodes": {
3
+ "parabens": {
4
+ "category": "preservative",
5
+ "risk": "high",
6
+ "effects": [
7
+ "endocrine_disruption",
8
+ "skin_irritation"
9
+ ]
10
+ },
11
+ "endocrine_disruption": {},
12
+ "skin_irritation": {},
13
+ "retinol": {
14
+ "category": "active",
15
+ "risk": "moderate",
16
+ "effects": [
17
+ "photosensitivity",
18
+ "anti_aging"
19
+ ]
20
+ },
21
+ "photosensitivity": {},
22
+ "anti_aging": {},
23
+ "fragrance": {
24
+ "category": "additive",
25
+ "risk": "moderate",
26
+ "effects": [
27
+ "allergen",
28
+ "respiratory_irritation"
29
+ ]
30
+ },
31
+ "allergen": {},
32
+ "respiratory_irritation": {},
33
+ "niacinamide": {
34
+ "category": "active",
35
+ "risk": "low",
36
+ "effects": [
37
+ "barrier_repair",
38
+ "anti_inflammatory"
39
+ ]
40
+ },
41
+ "barrier_repair": {},
42
+ "anti_inflammatory": {},
43
+ "sodium_lauryl_sulfate": {
44
+ "category": "surfactant",
45
+ "risk": "moderate",
46
+ "effects": [
47
+ "barrier_stripping",
48
+ "acne_trigger"
49
+ ]
50
+ },
51
+ "barrier_stripping": {},
52
+ "acne_trigger": {},
53
+ "alcohol_denat": {
54
+ "category": "solvent",
55
+ "risk": "moderate",
56
+ "effects": [
57
+ "drying",
58
+ "barrier_disruption"
59
+ ]
60
+ },
61
+ "drying": {},
62
+ "barrier_disruption": {},
63
+ "mineral_oil": {
64
+ "category": "emollient",
65
+ "risk": "moderate",
66
+ "effects": [
67
+ "comedogenic",
68
+ "occlusive"
69
+ ]
70
+ },
71
+ "comedogenic": {},
72
+ "occlusive": {}
73
+ },
74
+ "edges": [
75
+ [
76
+ "parabens",
77
+ "endocrine_disruption",
78
+ {
79
+ "relation": "causes"
80
+ }
81
+ ],
82
+ [
83
+ "parabens",
84
+ "skin_irritation",
85
+ {
86
+ "relation": "causes"
87
+ }
88
+ ],
89
+ [
90
+ "retinol",
91
+ "photosensitivity",
92
+ {
93
+ "relation": "causes"
94
+ }
95
+ ],
96
+ [
97
+ "retinol",
98
+ "anti_aging",
99
+ {
100
+ "relation": "causes"
101
+ }
102
+ ],
103
+ [
104
+ "fragrance",
105
+ "allergen",
106
+ {
107
+ "relation": "causes"
108
+ }
109
+ ],
110
+ [
111
+ "fragrance",
112
+ "respiratory_irritation",
113
+ {
114
+ "relation": "causes"
115
+ }
116
+ ],
117
+ [
118
+ "niacinamide",
119
+ "barrier_repair",
120
+ {
121
+ "relation": "causes"
122
+ }
123
+ ],
124
+ [
125
+ "niacinamide",
126
+ "anti_inflammatory",
127
+ {
128
+ "relation": "causes"
129
+ }
130
+ ],
131
+ [
132
+ "sodium_lauryl_sulfate",
133
+ "barrier_stripping",
134
+ {
135
+ "relation": "causes"
136
+ }
137
+ ],
138
+ [
139
+ "sodium_lauryl_sulfate",
140
+ "acne_trigger",
141
+ {
142
+ "relation": "causes"
143
+ }
144
+ ],
145
+ [
146
+ "alcohol_denat",
147
+ "drying",
148
+ {
149
+ "relation": "causes"
150
+ }
151
+ ],
152
+ [
153
+ "alcohol_denat",
154
+ "barrier_disruption",
155
+ {
156
+ "relation": "causes"
157
+ }
158
+ ],
159
+ [
160
+ "mineral_oil",
161
+ "comedogenic",
162
+ {
163
+ "relation": "causes"
164
+ }
165
+ ],
166
+ [
167
+ "mineral_oil",
168
+ "occlusive",
169
+ {
170
+ "relation": "causes"
171
+ }
172
+ ]
173
+ ]
174
+ }
backend/docker-compose.yml ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: '3.8'
2
+
3
+ services:
4
+ backend:
5
+ build:
6
+ context: ./backend
7
+ dockerfile: Dockerfile
8
+ ports:
9
+ - "8000:8000"
10
+ environment:
11
+ - LLM_API_KEY=${LLM_API_KEY}
12
+ - LLM_BASE_URL=${LLM_BASE_URL:-https://api.groq.com/openai/v1}
13
+ - LLM_MODEL=${LLM_MODEL:-llama-3.1-8b-instant}
14
+ - REDIS_URL=redis://redis:6379/0
15
+ - DATABASE_URL=postgresql://postgres:postgres@db:5432/scanleni
16
+ depends_on:
17
+ - redis
18
+ - db
19
+ volumes:
20
+ - ./backend/app:/app/app:ro
21
+ restart: unless-stopped
22
+
23
+ frontend:
24
+ image: node:20-alpine
25
+ working_dir: /app
26
+ volumes:
27
+ - ./frontend:/app
28
+ ports:
29
+ - "3000:3000"
30
+ command: sh -c "npm install && npm run dev -- --host"
31
+ depends_on:
32
+ - backend
33
+ restart: unless-stopped
34
+
35
+ redis:
36
+ image: redis:7-alpine
37
+ ports:
38
+ - "6379:6379"
39
+ volumes:
40
+ - redis_data:/data
41
+ command: redis-server --appendonly yes
42
+ restart: unless-stopped
43
+
44
+ db:
45
+ image: pgvector/pgvector:pg16
46
+ environment:
47
+ POSTGRES_USER: postgres
48
+ POSTGRES_PASSWORD: postgres
49
+ POSTGRES_DB: scanleni
50
+ ports:
51
+ - "5432:5432"
52
+ volumes:
53
+ - pg_data:/var/lib/postgresql/data
54
+ restart: unless-stopped
55
+
56
+ volumes:
57
+ redis_data:
58
+ pg_data:
backend/requirements.txt ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.109.0
2
+ uvicorn[standard]==0.27.0
3
+ pydantic==2.5.3
4
+ pydantic-settings==2.1.0
5
+ python-multipart==0.0.6
6
+ python-dotenv==1.0.0
7
+ httpx==0.26.0
8
+ redis==5.0.1
9
+ sqlalchemy==2.0.25
10
+ psycopg2-binary==2.9.9
11
+ faiss-cpu==1.7.4
12
+ sentence-transformers==2.2.2
13
+ numpy==1.26.4
14
+ pillow>=10.0.0
15
+ rapidocr_onnxruntime==1.4.4
16
+ onnxruntime==1.19.2
17
+ opencv-python-headless==4.9.0.80
18
+ prometheus-client==0.19.0
19
+ pytest>=7.4.0