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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user