feat: brain service — self-contained second brain knowledge manager

Full backend service with:
- FastAPI REST API with CRUD, search, reprocess endpoints
- PostgreSQL + pgvector for items and semantic search
- Redis + RQ for background job processing
- Meilisearch for fast keyword/filter search
- Browserless/Chrome for JS rendering and screenshots
- OpenAI structured output for AI classification
- Local file storage with S3-ready abstraction
- Gateway auth via X-Gateway-User-Id header
- Own docker-compose stack (6 containers)

Classification: fixed folders (Home/Family/Work/Travel/Knowledge/Faith/Projects)
and fixed tags (28 predefined). AI assigns exactly 1 folder, 2-3 tags, title,
summary, and confidence score per item.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-01 11:48:29 -05:00
parent 51a8157fd4
commit 8275f3a71b
73 changed files with 24081 additions and 4209 deletions

View File

@@ -147,6 +147,8 @@ def init_db():
protein_per_base REAL NOT NULL DEFAULT 0,
carbs_per_base REAL NOT NULL DEFAULT 0,
fat_per_base REAL NOT NULL DEFAULT 0,
sugar_per_base REAL NOT NULL DEFAULT 0,
fiber_per_base REAL NOT NULL DEFAULT 0,
-- Base unit: "100g" for weight-based foods, or "piece"/"slice"/"serving" etc for countable
base_unit TEXT NOT NULL DEFAULT '100g',
-- Status: confirmed, ai_created, needs_review, archived
@@ -212,6 +214,8 @@ def init_db():
snapshot_protein REAL NOT NULL DEFAULT 0,
snapshot_carbs REAL NOT NULL DEFAULT 0,
snapshot_fat REAL NOT NULL DEFAULT 0,
snapshot_sugar REAL NOT NULL DEFAULT 0,
snapshot_fiber REAL NOT NULL DEFAULT 0,
-- Source & method
source TEXT NOT NULL DEFAULT 'web', -- where: web, telegram, api
entry_method TEXT NOT NULL DEFAULT 'manual', -- how: manual, search, template, ai_plate, ai_label, quick_add
@@ -240,6 +244,8 @@ def init_db():
protein REAL NOT NULL DEFAULT 150,
carbs REAL NOT NULL DEFAULT 200,
fat REAL NOT NULL DEFAULT 65,
sugar REAL NOT NULL DEFAULT 0,
fiber REAL NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
@@ -275,6 +281,8 @@ def init_db():
snapshot_protein REAL NOT NULL DEFAULT 0,
snapshot_carbs REAL NOT NULL DEFAULT 0,
snapshot_fat REAL NOT NULL DEFAULT 0,
snapshot_sugar REAL NOT NULL DEFAULT 0,
snapshot_fiber REAL NOT NULL DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (template_id) REFERENCES meal_templates(id) ON DELETE CASCADE,
FOREIGN KEY (food_id) REFERENCES foods(id)
@@ -415,6 +423,22 @@ def init_db():
except:
pass
for table, column, coltype in [
('foods', 'sugar_per_base', 'REAL NOT NULL DEFAULT 0'),
('foods', 'fiber_per_base', 'REAL NOT NULL DEFAULT 0'),
('food_entries', 'snapshot_sugar', 'REAL NOT NULL DEFAULT 0'),
('food_entries', 'snapshot_fiber', 'REAL NOT NULL DEFAULT 0'),
('meal_template_items', 'snapshot_sugar', 'REAL NOT NULL DEFAULT 0'),
('meal_template_items', 'snapshot_fiber', 'REAL NOT NULL DEFAULT 0'),
('goals', 'sugar', 'REAL NOT NULL DEFAULT 0'),
('goals', 'fiber', 'REAL NOT NULL DEFAULT 0')
]:
try:
cursor.execute(f"ALTER TABLE {table} ADD COLUMN {column} {coltype}")
conn.commit()
except:
pass
conn.commit()
conn.close()
@@ -691,6 +715,8 @@ def search_foods(query: str, user_id: str = None, limit: int = 20) -> list:
'protein_per_base': food.get('protein_per_base', 0),
'carbs_per_base': food.get('carbs_per_base', 0),
'fat_per_base': food.get('fat_per_base', 0),
'sugar_per_base': food.get('sugar_per_base', 0),
'fiber_per_base': food.get('fiber_per_base', 0),
'status': food.get('status', 'confirmed'),
'image_path': food.get('image_path'),
'servings': servings,
@@ -759,6 +785,8 @@ def search_openfoodfacts(query: str, limit: int = 5) -> list:
'protein_per_100g': round(float(nuts.get('proteins_100g', 0) or 0), 1),
'carbs_per_100g': round(float(nuts.get('carbohydrates_100g', 0) or 0), 1),
'fat_per_100g': round(float(nuts.get('fat_100g', 0) or 0), 1),
'sugar_per_100g': round(float(nuts.get('sugars_100g', 0) or 0), 1),
'fiber_per_100g': round(float(nuts.get('fiber_100g', 0) or 0), 1),
'serving_size_text': p.get('serving_size'),
'serving_grams': p.get('serving_quantity'),
'source': 'openfoodfacts',
@@ -791,6 +819,8 @@ def lookup_openfoodfacts_barcode(barcode: str) -> dict | None:
'protein_per_100g': round(float(nuts.get('proteins_100g', 0) or 0), 1),
'carbs_per_100g': round(float(nuts.get('carbohydrates_100g', 0) or 0), 1),
'fat_per_100g': round(float(nuts.get('fat_100g', 0) or 0), 1),
'sugar_per_100g': round(float(nuts.get('sugars_100g', 0) or 0), 1),
'fiber_per_100g': round(float(nuts.get('fiber_100g', 0) or 0), 1),
'serving_size_text': p.get('serving_size'),
'serving_grams': p.get('serving_quantity'),
'source': 'openfoodfacts',
@@ -834,6 +864,8 @@ def search_usda(query: str, limit: int = 5) -> list:
protein = nutrient_by_name.get('Protein', 0) or 0
carbs = nutrient_by_name.get('Carbohydrate, by difference', 0) or 0
fat = nutrient_by_name.get('Total lipid (fat)', 0) or 0
sugar = nutrient_by_name.get('Sugars, total including NLEA', 0) or nutrient_by_name.get('Sugars, total', 0) or 0
fiber = nutrient_by_name.get('Fiber, total dietary', 0) or 0
name = food.get('description', '').strip()
if not name or (not cal and not protein):
@@ -850,6 +882,8 @@ def search_usda(query: str, limit: int = 5) -> list:
'protein_per_100g': round(float(protein), 1),
'carbs_per_100g': round(float(carbs), 1),
'fat_per_100g': round(float(fat), 1),
'sugar_per_100g': round(float(sugar), 1),
'fiber_per_100g': round(float(fiber), 1),
'serving_size_text': None,
'serving_grams': None,
'source': 'usda',
@@ -910,6 +944,8 @@ def import_external_food(external_result: dict, user_id: str) -> dict:
'protein_per_base': external_result['protein_per_100g'],
'carbs_per_base': external_result['carbs_per_100g'],
'fat_per_base': external_result['fat_per_100g'],
'sugar_per_base': external_result.get('sugar_per_100g', 0),
'fiber_per_base': external_result.get('fiber_per_100g', 0),
'base_unit': '100g',
'status': 'confirmed',
'notes': f"Imported from {external_result.get('source', 'external')}",
@@ -1158,10 +1194,14 @@ Return a JSON object with these fields:
- protein: Total grams of protein for the entire quantity
- carbs: Total grams of carbohydrates for the entire quantity
- fat: Total grams of fat for the entire quantity
- sugar: Total grams of sugar for the entire quantity
- fiber: Total grams of fiber for the entire quantity
- per_serving_calories: Calories for ONE serving/piece
- per_serving_protein: Protein for ONE serving/piece
- per_serving_carbs: Carbs for ONE serving/piece
- per_serving_fat: Fat for ONE serving/piece
- per_serving_sugar: Sugar for ONE serving/piece
- per_serving_fiber: Fiber for ONE serving/piece
- base_unit: What one unit is — "piece", "scoop", "serving", "slice", etc.
- serving_description: Human-readable serving label, e.g. "1 taco", "1 scoop", "1 small pie"
- estimated_grams: Approximate grams per serving
@@ -1213,10 +1253,14 @@ IMPORTANT:
'protein': float(result.get('protein', 0)),
'carbs': float(result.get('carbs', 0)),
'fat': float(result.get('fat', 0)),
'sugar': float(result.get('sugar', 0)),
'fiber': float(result.get('fiber', 0)),
'calories_per_base': float(result.get('per_serving_calories', result.get('calories', 0))),
'protein_per_base': float(result.get('per_serving_protein', result.get('protein', 0))),
'carbs_per_base': float(result.get('per_serving_carbs', result.get('carbs', 0))),
'fat_per_base': float(result.get('per_serving_fat', result.get('fat', 0))),
'sugar_per_base': float(result.get('per_serving_sugar', result.get('sugar', 0))),
'fiber_per_base': float(result.get('per_serving_fiber', result.get('fiber', 0))),
'base_unit': str(result.get('base_unit', 'serving')),
'serving_description': str(result.get('serving_description', f'1 {unit}')),
'estimated_grams': float(result.get('estimated_grams', 0)) if result.get('estimated_grams') else None,
@@ -1437,6 +1481,8 @@ def resolve_food(raw_phrase: str, user_id: str, meal_type: str = None,
'protein_per_base': existing_match.get('protein_per_base', 0),
'carbs_per_base': existing_match.get('carbs_per_base', 0),
'fat_per_base': existing_match.get('fat_per_base', 0),
'sugar_per_base': existing_match.get('sugar_per_base', 0),
'fiber_per_base': existing_match.get('fiber_per_base', 0),
'status': existing_match.get('status', 'confirmed'),
'servings': existing_match.get('servings', []),
'score': existing_match['score'],
@@ -1451,6 +1497,8 @@ def resolve_food(raw_phrase: str, user_id: str, meal_type: str = None,
'protein_per_base': ai_estimate['protein_per_base'],
'carbs_per_base': ai_estimate['carbs_per_base'],
'fat_per_base': ai_estimate['fat_per_base'],
'sugar_per_base': ai_estimate.get('sugar_per_base', 0),
'fiber_per_base': ai_estimate.get('fiber_per_base', 0),
'base_unit': ai_estimate['base_unit'],
'status': 'ai_created',
'notes': f"AI estimated from: {raw_phrase}",
@@ -1470,6 +1518,8 @@ def resolve_food(raw_phrase: str, user_id: str, meal_type: str = None,
'protein_per_base': new_food.get('protein_per_base', 0),
'carbs_per_base': new_food.get('carbs_per_base', 0),
'fat_per_base': new_food.get('fat_per_base', 0),
'sugar_per_base': new_food.get('sugar_per_base', 0),
'fiber_per_base': new_food.get('fiber_per_base', 0),
'status': 'ai_created',
'servings': new_food.get('servings', []),
'score': ai_estimate['confidence'],
@@ -1548,13 +1598,14 @@ def create_food(data: dict, user_id: str) -> dict:
conn.execute(
"""INSERT INTO foods
(id, name, normalized_name, brand, brand_normalized, barcode, notes,
calories_per_base, protein_per_base, carbs_per_base, fat_per_base,
calories_per_base, protein_per_base, carbs_per_base, fat_per_base, sugar_per_base, fiber_per_base,
base_unit, status, created_by_user_id, is_shared)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(food_id, name, normalized, brand, brand_norm, data.get('barcode'),
data.get('notes'),
data.get('calories_per_base', 0), data.get('protein_per_base', 0),
data.get('carbs_per_base', 0), data.get('fat_per_base', 0),
data.get('sugar_per_base', 0), data.get('fiber_per_base', 0),
data.get('base_unit', '100g'), data.get('status', 'confirmed'),
user_id, 1 if data.get('is_shared', True) else 0)
)
@@ -1717,6 +1768,8 @@ def calculate_entry_nutrition(food: dict, quantity: float, serving_id: str = Non
base_protein = food.get('protein_per_base', 0)
base_carbs = food.get('carbs_per_base', 0)
base_fat = food.get('fat_per_base', 0)
base_sugar = food.get('sugar_per_base', 0)
base_fiber = food.get('fiber_per_base', 0)
# If a specific serving is selected, multiply by its base amount
multiplier = quantity
@@ -1732,6 +1785,8 @@ def calculate_entry_nutrition(food: dict, quantity: float, serving_id: str = Non
'protein': round(base_protein * multiplier, 1),
'carbs': round(base_carbs * multiplier, 1),
'fat': round(base_fat * multiplier, 1),
'sugar': round(base_sugar * multiplier, 1),
'fiber': round(base_fiber * multiplier, 1),
}
@@ -1770,6 +1825,8 @@ def create_food_entry(data: dict, user_id: str) -> dict:
snapshot_protein = data.get('snapshot_protein', 0)
snapshot_carbs = data.get('snapshot_carbs', 0)
snapshot_fat = data.get('snapshot_fat', 0)
snapshot_sugar = data.get('snapshot_sugar', 0)
snapshot_fiber = data.get('snapshot_fiber', 0)
entry_method = 'quick_add'
else:
# Get food and calculate nutrition snapshot
@@ -1784,6 +1841,8 @@ def create_food_entry(data: dict, user_id: str) -> dict:
snapshot_protein = nutrition['protein']
snapshot_carbs = nutrition['carbs']
snapshot_fat = nutrition['fat']
snapshot_sugar = nutrition['sugar']
snapshot_fiber = nutrition['fiber']
# Resolve serving label and grams for snapshot
if serving_id:
@@ -1803,16 +1862,18 @@ def create_food_entry(data: dict, user_id: str) -> dict:
conn.execute(
"""INSERT INTO food_entries
(id, user_id, food_id, meal_type, entry_date, entry_type,
quantity, unit, serving_description,
quantity, unit, serving_description,
snapshot_food_name, snapshot_serving_label, snapshot_grams,
snapshot_calories, snapshot_protein, snapshot_carbs, snapshot_fat,
snapshot_sugar, snapshot_fiber,
source, entry_method, raw_text, confidence_score, note, image_ref,
ai_metadata, idempotency_key)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(entry_id, user_id, food_id, meal_type, entry_date, entry_type,
quantity, unit, serving_description,
snapshot_name, snapshot_serving_label, snapshot_grams,
snapshot_cals, snapshot_protein, snapshot_carbs, snapshot_fat,
snapshot_sugar, snapshot_fiber,
source, entry_method, data.get('raw_text'), data.get('confidence_score'),
data.get('note'), image_ref,
json.dumps(data.get('ai_metadata')) if data.get('ai_metadata') else None,
@@ -1837,6 +1898,8 @@ def create_food_entry(data: dict, user_id: str) -> dict:
'snapshot_protein': snapshot_protein,
'snapshot_carbs': snapshot_carbs,
'snapshot_fat': snapshot_fat,
'snapshot_sugar': snapshot_sugar,
'snapshot_fiber': snapshot_fiber,
'source': source,
'entry_method': entry_method,
}
@@ -1874,6 +1937,8 @@ def get_daily_totals(user_id: str, entry_date: str) -> dict:
COALESCE(SUM(snapshot_protein), 0) as total_protein,
COALESCE(SUM(snapshot_carbs), 0) as total_carbs,
COALESCE(SUM(snapshot_fat), 0) as total_fat,
COALESCE(SUM(snapshot_sugar), 0) as total_sugar,
COALESCE(SUM(snapshot_fiber), 0) as total_fiber,
COUNT(*) as entry_count
FROM food_entries
WHERE user_id = ? AND entry_date = ?""",
@@ -1905,7 +1970,8 @@ def get_recent_foods(user_id: str, limit: int = 20) -> list:
conn = get_db()
rows = conn.execute(
"""SELECT DISTINCT fe.food_id, fe.snapshot_food_name, f.calories_per_base,
f.protein_per_base, f.carbs_per_base, f.fat_per_base, f.base_unit,
f.protein_per_base, f.carbs_per_base, f.fat_per_base,
f.sugar_per_base, f.fiber_per_base, f.base_unit,
MAX(fe.created_at) as last_used
FROM food_entries fe
JOIN foods f ON fe.food_id = f.id
@@ -1924,7 +1990,8 @@ def get_frequent_foods(user_id: str, limit: int = 20) -> list:
conn = get_db()
rows = conn.execute(
"""SELECT fe.food_id, fe.snapshot_food_name, f.calories_per_base,
f.protein_per_base, f.carbs_per_base, f.fat_per_base, f.base_unit,
f.protein_per_base, f.carbs_per_base, f.fat_per_base,
f.sugar_per_base, f.fiber_per_base, f.base_unit,
COUNT(*) as use_count, MAX(fe.created_at) as last_used
FROM food_entries fe
JOIN foods f ON fe.food_id = f.id
@@ -2355,13 +2422,15 @@ class CalorieHandler(BaseHTTPRequestHandler):
conn.execute(
"""INSERT INTO meal_template_items
(id, template_id, food_id, quantity, unit, serving_description,
snapshot_food_name, snapshot_calories, snapshot_protein, snapshot_carbs, snapshot_fat)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
snapshot_food_name, snapshot_calories, snapshot_protein, snapshot_carbs,
snapshot_fat, snapshot_sugar, snapshot_fiber)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(str(uuid.uuid4()), template_id, item['food_id'],
item.get('quantity', 1), item.get('unit', 'serving'),
item.get('serving_description'),
food['name'], nutrition['calories'], nutrition['protein'],
nutrition['carbs'], nutrition['fat'])
nutrition['carbs'], nutrition['fat'],
nutrition['sugar'], nutrition['fiber'])
)
conn.commit()
conn.close()
@@ -2513,10 +2582,12 @@ class CalorieHandler(BaseHTTPRequestHandler):
nutrition = calculate_entry_nutrition(food, quantity)
updates.extend([
'snapshot_calories = ?', 'snapshot_protein = ?',
'snapshot_carbs = ?', 'snapshot_fat = ?'
'snapshot_carbs = ?', 'snapshot_fat = ?',
'snapshot_sugar = ?', 'snapshot_fiber = ?'
])
params.extend([nutrition['calories'], nutrition['protein'],
nutrition['carbs'], nutrition['fat']])
nutrition['carbs'], nutrition['fat'],
nutrition['sugar'], nutrition['fiber']])
if updates:
params.append(entry_id)
@@ -2539,6 +2610,7 @@ class CalorieHandler(BaseHTTPRequestHandler):
for field in ['name', 'brand', 'barcode', 'notes',
'calories_per_base', 'protein_per_base', 'carbs_per_base', 'fat_per_base',
'sugar_per_base', 'fiber_per_base',
'base_unit', 'status', 'is_shared']:
if field in data:
if field == 'name':
@@ -2592,11 +2664,13 @@ class CalorieHandler(BaseHTTPRequestHandler):
goal_id = str(uuid.uuid4())
conn.execute(
"""INSERT INTO goals (id, user_id, start_date, end_date, calories, protein, carbs, fat, is_active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)""",
"""INSERT INTO goals
(id, user_id, start_date, end_date, calories, protein, carbs, fat, sugar, fiber, is_active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)""",
(goal_id, target_user, start_date, data.get('end_date'),
data.get('calories', 2000), data.get('protein', 150),
data.get('carbs', 200), data.get('fat', 65))
data.get('carbs', 200), data.get('fat', 65),
data.get('sugar', 0), data.get('fiber', 0))
)
conn.commit()
conn.close()