Add AI insights feature

This commit is contained in:
Urtzi Alfaro
2025-12-15 21:14:22 +01:00
parent 5642b5a0c0
commit c566967bea
39 changed files with 17729 additions and 404 deletions

View File

@@ -45,7 +45,7 @@ class SalesServiceClient(BaseServiceClient):
if product_id:
params["product_id"] = product_id
result = await self.get("sales", tenant_id=tenant_id, params=params)
result = await self.get("sales/sales", tenant_id=tenant_id, params=params)
# Handle both list and dict responses
if result is None:

View File

@@ -28,7 +28,7 @@ class SuppliersServiceClient(BaseServiceClient):
async def get_supplier_by_id(self, tenant_id: str, supplier_id: str) -> Optional[Dict[str, Any]]:
"""Get supplier details by ID"""
try:
result = await self.get(f"suppliers/{supplier_id}", tenant_id=tenant_id)
result = await self.get(f"suppliers/suppliers/{supplier_id}", tenant_id=tenant_id)
if result:
logger.info("Retrieved supplier details from suppliers service",
supplier_id=supplier_id, tenant_id=tenant_id)

View File

@@ -49,7 +49,8 @@
"batch_number": "BCN-HAR-20250115-001",
"created_at": "BASE_TS",
"enterprise_shared": true,
"source_location": "Central Warehouse - Barcelona"
"source_location": "Central Warehouse - Barcelona",
"staff_assigned": []
},
{
"id": "10000000-0000-0000-0000-000000002002",
@@ -64,7 +65,8 @@
"batch_number": "BCN-MAN-20250115-001",
"created_at": "BASE_TS",
"enterprise_shared": true,
"source_location": "Central Warehouse - Barcelona"
"source_location": "Central Warehouse - Barcelona",
"staff_assigned": []
},
{
"id": "20000000-0000-0000-0000-000000002001",
@@ -79,7 +81,8 @@
"batch_number": "BCN-BAG-20250115-001",
"created_at": "BASE_TS",
"enterprise_shared": true,
"source_location": "Central Production Facility - Barcelona"
"source_location": "Central Production Facility - Barcelona",
"staff_assigned": []
},
{
"id": "20000000-0000-0000-0000-000000002002",
@@ -94,7 +97,8 @@
"batch_number": "BCN-CRO-20250115-001",
"created_at": "BASE_TS",
"enterprise_shared": true,
"source_location": "Central Production Facility - Barcelona"
"source_location": "Central Production Facility - Barcelona",
"staff_assigned": []
}
],
"local_sales": [
@@ -203,7 +207,8 @@
"operator_id": "50000000-0000-0000-0000-000000000012",
"created_at": "BASE_TS",
"notes": "Producció matinal de baguettes a Barcelona",
"enterprise_location_production": true
"enterprise_location_production": true,
"staff_assigned": []
},
{
"id": "40000000-0000-0000-0000-000000002002",
@@ -222,7 +227,8 @@
"operator_id": "50000000-0000-0000-0000-000000000013",
"created_at": "BASE_TS",
"notes": "Producció de croissants en curs a Barcelona",
"enterprise_location_production": true
"enterprise_location_production": true,
"staff_assigned": []
}
],
"local_forecasts": [

View File

@@ -46,7 +46,8 @@
"batch_number": "MAD-HAR-20250115-001",
"created_at": "BASE_TS",
"enterprise_shared": true,
"source_location": "Central Warehouse - Madrid"
"source_location": "Central Warehouse - Madrid",
"staff_assigned": []
},
{
"id": "20000000-0000-0000-0000-000000001501",
@@ -61,7 +62,8 @@
"batch_number": "MAD-BAG-20250115-001",
"created_at": "BASE_TS",
"enterprise_shared": true,
"source_location": "Central Production Facility - Madrid"
"source_location": "Central Production Facility - Madrid",
"staff_assigned": []
}
],
"local_sales": [

View File

@@ -49,7 +49,8 @@
"batch_number": "VLC-HAR-20250115-001",
"created_at": "BASE_TS",
"enterprise_shared": true,
"source_location": "Central Warehouse - Valencia"
"source_location": "Central Warehouse - Valencia",
"staff_assigned": []
},
{
"id": "10000000-0000-0000-0000-000000003002",
@@ -64,7 +65,8 @@
"batch_number": "VLC-MAN-20250115-001",
"created_at": "BASE_TS",
"enterprise_shared": true,
"source_location": "Central Warehouse - Valencia"
"source_location": "Central Warehouse - Valencia",
"staff_assigned": []
},
{
"id": "10000000-0000-0000-0000-000000003003",
@@ -79,7 +81,8 @@
"batch_number": "VLC-SAL-20250115-001",
"created_at": "BASE_TS",
"enterprise_shared": true,
"source_location": "Central Warehouse - Valencia"
"source_location": "Central Warehouse - Valencia",
"staff_assigned": []
},
{
"id": "20000000-0000-0000-0000-000000003001",
@@ -94,7 +97,8 @@
"batch_number": "VLC-BAG-20250115-001",
"created_at": "BASE_TS",
"enterprise_shared": true,
"source_location": "Central Production Facility - Valencia"
"source_location": "Central Production Facility - Valencia",
"staff_assigned": []
},
{
"id": "20000000-0000-0000-0000-000000003002",
@@ -109,7 +113,8 @@
"batch_number": "VLC-PAN-20250115-001",
"created_at": "BASE_TS",
"enterprise_shared": true,
"source_location": "Central Production Facility - Valencia"
"source_location": "Central Production Facility - Valencia",
"staff_assigned": []
}
],
"local_sales": [
@@ -232,7 +237,8 @@
"operator_id": "50000000-0000-0000-0000-000000000013",
"created_at": "BASE_TS",
"notes": "Producción matinal de baguettes en Valencia",
"enterprise_location_production": true
"enterprise_location_production": true,
"staff_assigned": []
},
{
"id": "40000000-0000-0000-0000-000000003002",
@@ -251,7 +257,8 @@
"operator_id": "50000000-0000-0000-0000-000000000014",
"created_at": "BASE_TS",
"notes": "Producción de pan de campo completada",
"enterprise_location_production": true
"enterprise_location_production": true,
"staff_assigned": []
},
{
"id": "40000000-0000-0000-0000-000000003003",
@@ -270,7 +277,8 @@
"operator_id": "50000000-0000-0000-0000-000000000013",
"created_at": "BASE_TS",
"notes": "Lote programado para mañana - pedido de hotel",
"enterprise_location_production": true
"enterprise_location_production": true,
"staff_assigned": []
}
],
"local_forecasts": [

View File

@@ -85,7 +85,8 @@
"quantity": 40.0,
"delivery_time": "2025-01-15T16:00:00Z"
}
]
],
"staff_assigned": []
}
]
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,296 @@
#!/usr/bin/env python3
"""
Generate AI Insights Data for Professional Demo
Adds realistic stock movements and production worker data to enable AI insight generation
"""
import json
import random
from datetime import datetime, timedelta
from uuid import uuid4, UUID
from typing import List, Dict, Any
# Set random seed for reproducibility
random.seed(42)
# Key ingredients that need demand history (matching actual IDs in 03-inventory.json)
KEY_INGREDIENTS = [
{"id": "10000000-0000-0000-0000-000000000001", "name": "Harina de Trigo T55", "avg_daily": 45.0, "variability": 0.3, "unit_cost": 0.85},
{"id": "10000000-0000-0000-0000-000000000002", "name": "Harina de Trigo T65", "avg_daily": 35.0, "variability": 0.25, "unit_cost": 0.95},
{"id": "10000000-0000-0000-0000-000000000003", "name": "Harina de Fuerza W300", "avg_daily": 25.0, "variability": 0.35, "unit_cost": 1.15},
{"id": "10000000-0000-0000-0000-000000000011", "name": "Mantequilla sin Sal", "avg_daily": 8.5, "variability": 0.35, "unit_cost": 6.50},
{"id": "10000000-0000-0000-0000-000000000012", "name": "Leche Entera Fresca", "avg_daily": 18.0, "variability": 0.3, "unit_cost": 0.95},
{"id": "10000000-0000-0000-0000-000000000014", "name": "Huevos Frescos", "avg_daily": 5.5, "variability": 0.4, "unit_cost": 3.80},
{"id": "10000000-0000-0000-0000-000000000021", "name": "Levadura Fresca", "avg_daily": 3.5, "variability": 0.4, "unit_cost": 4.20},
{"id": "10000000-0000-0000-0000-000000000031", "name": "Sal Marina Fina", "avg_daily": 2.8, "variability": 0.2, "unit_cost": 1.50},
{"id": "10000000-0000-0000-0000-000000000032", "name": "Azúcar Blanco", "avg_daily": 12.0, "variability": 0.3, "unit_cost": 1.10},
{"id": "10000000-0000-0000-0000-000000000013", "name": "Nata para Montar", "avg_daily": 4.2, "variability": 0.35, "unit_cost": 2.80},
]
# Workers with different skill levels (matching users in 02-auth.json)
WORKERS = [
{"id": "c1a2b3c4-d5e6-47a8-b9c0-d1e2f3a4b5c6", "name": "María García (Owner - Master Baker)", "skill_level": 0.98, "shift": "morning"}, # Expert
{"id": "50000000-0000-0000-0000-000000000001", "name": "Juan Panadero (Baker)", "skill_level": 0.95, "shift": "morning"}, # Very skilled
{"id": "50000000-0000-0000-0000-000000000006", "name": "Isabel Producción (Production Manager)", "skill_level": 0.90, "shift": "afternoon"}, # Experienced
{"id": "50000000-0000-0000-0000-000000000005", "name": "Carlos Almacén (Warehouse - Occasional Baker)", "skill_level": 0.78, "shift": "afternoon"}, # Learning
]
TENANT_ID = "a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6"
def generate_stock_movements(days: int = 90) -> List[Dict[str, Any]]:
"""Generate realistic stock movements for AI insights"""
movements = []
# Generate PRODUCTION_USE movements (daily consumption)
for day in range(days, 0, -1):
for ingredient in KEY_INGREDIENTS:
# Skip some days randomly (not every ingredient used every day)
if random.random() < 0.15: # 15% chance to skip
continue
# Calculate quantity with variability
base_qty = ingredient["avg_daily"]
variability = ingredient["variability"]
quantity = base_qty * random.uniform(1 - variability, 1 + variability)
# Reduce usage on weekends (lower production)
date_offset = f"BASE_TS - {day}d"
day_of_week = (90 - day) % 7 # Approximate day of week
if day_of_week in [5, 6]: # Weekend
quantity *= 0.6
# Round to 2 decimals
quantity = round(quantity, 2)
movement = {
"id": str(uuid4()),
"tenant_id": TENANT_ID,
"ingredient_id": ingredient["id"],
"stock_id": None,
"movement_type": "PRODUCTION_USE",
"quantity": quantity,
"unit_cost": ingredient["unit_cost"],
"total_cost": round(quantity * ingredient["unit_cost"], 2),
"quantity_before": None,
"quantity_after": None,
"movement_date": date_offset,
"reason_code": "production_consumption",
"notes": f"Daily production usage - {ingredient['name']}",
"created_at": date_offset,
"created_by": "c1a2b3c4-d5e6-47a8-b9c0-d1e2f3a4b5c6"
}
movements.append(movement)
# Generate PURCHASE movements (supplier deliveries - weekly/bi-weekly)
for ingredient in KEY_INGREDIENTS:
# Calculate delivery frequency based on usage
weekly_usage = ingredient["avg_daily"] * 7
delivery_qty = weekly_usage * 2 # 2 weeks of stock
# Bi-weekly deliveries over 90 days = ~6-7 deliveries
num_deliveries = 6
delivery_interval = days // num_deliveries
for delivery_num in range(num_deliveries):
day_offset = days - (delivery_num * delivery_interval) - random.randint(0, 3)
if day_offset < 1:
continue
# Add some variability to delivery quantity
qty = delivery_qty * random.uniform(0.9, 1.1)
qty = round(qty, 2)
movement = {
"id": str(uuid4()),
"tenant_id": TENANT_ID,
"ingredient_id": ingredient["id"],
"stock_id": None,
"movement_type": "PURCHASE",
"quantity": qty,
"unit_cost": ingredient["unit_cost"],
"total_cost": round(qty * ingredient["unit_cost"], 2),
"quantity_before": None,
"quantity_after": None,
"movement_date": f"BASE_TS - {day_offset}d",
"reason_code": "supplier_delivery",
"notes": f"Weekly delivery from supplier - {ingredient['name']}",
"created_at": f"BASE_TS - {day_offset}d",
"created_by": "c1a2b3c4-d5e6-47a8-b9c0-d1e2f3a4b5c6"
}
movements.append(movement)
# Add occasional stockout events (0 inventory remaining)
# Add 5-8 stockout PRODUCTION_USE movements
for _ in range(random.randint(5, 8)):
ingredient = random.choice(KEY_INGREDIENTS)
day_offset = random.randint(1, days)
movement = {
"id": str(uuid4()),
"tenant_id": TENANT_ID,
"ingredient_id": ingredient["id"],
"stock_id": None,
"movement_type": "PRODUCTION_USE",
"quantity": round(ingredient["avg_daily"] * 1.3, 2), # Higher than usual
"unit_cost": ingredient["unit_cost"],
"total_cost": round(ingredient["avg_daily"] * 1.3 * ingredient["unit_cost"], 2),
"quantity_before": round(ingredient["avg_daily"] * 0.8, 2),
"quantity_after": 0.0, # Stockout!
"movement_date": f"BASE_TS - {day_offset}d",
"reason_code": "production_consumption_stockout",
"notes": f"STOCKOUT - Ran out of {ingredient['name']} during production",
"created_at": f"BASE_TS - {day_offset}d",
"created_by": "c1a2b3c4-d5e6-47a8-b9c0-d1e2f3a4b5c6"
}
movements.append(movement)
return movements
def add_worker_data_to_batches(batches: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Add staff_assigned and completed_at to production batches"""
updated_batches = []
for batch in batches:
# Skip if no yield data (can't assign skill-based worker)
if batch.get("yield_percentage") is None:
updated_batches.append(batch)
continue
# Assign worker based on yield (better yields = better workers)
yield_pct = batch["yield_percentage"]
if yield_pct >= 95:
# Expert workers for high yields
worker = random.choice(WORKERS[:2])
elif yield_pct >= 90:
# Experienced workers
worker = random.choice(WORKERS[1:3])
elif yield_pct >= 85:
# Competent workers
worker = random.choice(WORKERS[2:4])
else:
# Junior workers for lower yields
worker = random.choice(WORKERS[3:])
# Add staff_assigned
if "staff_assigned" not in batch or not isinstance(batch["staff_assigned"], list):
batch["staff_assigned"] = []
batch["staff_assigned"].append(worker["id"])
# Calculate completed_at from actual_start_time + planned_duration_minutes
if batch.get("actual_start_time") and batch.get("planned_duration_minutes"):
# Parse the BASE_TS offset
start_time_str = batch["actual_start_time"]
duration_mins = batch["planned_duration_minutes"]
# Add duration to start time with some variability
actual_duration = duration_mins * random.uniform(0.95, 1.15) # +/- 15% variability
# Parse the start time offset to calculate completion time
# Format: "BASE_TS - 6d 7h 30m" or "BASE_TS - 6d 7h"
if "BASE_TS" in start_time_str:
# Extract the offset parts
# Convert duration to hours for easier calculation
duration_hours = actual_duration / 60.0
# Parse existing offset
parts = start_time_str.replace("BASE_TS", "").strip()
# Simple approach: just add the duration to the hours component
# Example: "- 6d 7h 30m" -> add 3.5h -> "- 6d 10h 30m" (approximately)
# For simplicity, create a new timestamp offset
# Don't try to parse complex string, just create a note field
batch["actual_duration_minutes"] = round(actual_duration, 1)
# Don't set completed_at - let the system calculate it if needed
updated_batches.append(batch)
return updated_batches
def main():
"""Generate and update JSON files with AI insights data"""
print("🔧 Generating AI Insights Data for Professional Demo...")
print()
# 1. Generate stock movements
print("📊 Generating stock movements...")
stock_movements = generate_stock_movements(days=90)
usage_count = len([m for m in stock_movements if m["movement_type"] == "PRODUCTION_USE"])
in_count = len([m for m in stock_movements if m["movement_type"] == "PURCHASE"])
stockout_count = len([m for m in stock_movements if m.get("quantity_after") == 0.0])
print(f" ✓ Generated {len(stock_movements)} stock movements")
print(f" - PRODUCTION_USE movements: {usage_count}")
print(f" - PURCHASE movements (deliveries): {in_count}")
print(f" - Stockout events: {stockout_count}")
print()
# 2. Load and update inventory JSON
print("📦 Updating 03-inventory.json...")
with open("/Users/urtzialfaro/Documents/bakery-ia/shared/demo/fixtures/professional/03-inventory.json", "r") as f:
inventory_data = json.load(f)
# Append new movements to existing ones
existing_movements = inventory_data.get("stock_movements", [])
print(f" - Existing movements: {len(existing_movements)}")
inventory_data["stock_movements"] = existing_movements + stock_movements
print(f" - Total movements: {len(inventory_data['stock_movements'])}")
# Save updated inventory
with open("/Users/urtzialfaro/Documents/bakery-ia/shared/demo/fixtures/professional/03-inventory.json", "w") as f:
json.dump(inventory_data, f, indent=2, ensure_ascii=False)
print(" ✓ Updated inventory file")
print()
# 3. Load and update production JSON
print("🏭 Updating 06-production.json...")
with open("/Users/urtzialfaro/Documents/bakery-ia/shared/demo/fixtures/professional/06-production.json", "r") as f:
production_data = json.load(f)
# Update production batches with worker data
original_batches = production_data.get("batches", [])
print(f" - Total batches: {len(original_batches)}")
updated_batches = add_worker_data_to_batches(original_batches)
batches_with_workers = len([b for b in updated_batches if b.get("staff_assigned") and len(b.get("staff_assigned", [])) > 0])
batches_with_completion = len([b for b in updated_batches if b.get("completed_at")])
production_data["batches"] = updated_batches
print(f" - Batches with worker_id: {batches_with_workers}")
print(f" - Batches with completed_at: {batches_with_completion}")
# Save updated production
with open("/Users/urtzialfaro/Documents/bakery-ia/shared/demo/fixtures/professional/06-production.json", "w") as f:
json.dump(production_data, f, indent=2, ensure_ascii=False)
print(" ✓ Updated production file")
print()
# 4. Summary
print("=" * 60)
print("✅ AI INSIGHTS DATA GENERATION COMPLETE")
print("=" * 60)
print()
print("📊 DATA ADDED:")
print(f" • Stock movements (PRODUCTION_USE): {usage_count} records (90 days)")
print(f" • Stock movements (PURCHASE): {in_count} deliveries")
print(f" • Stockout events: {stockout_count}")
print(f" • Worker assignments: {batches_with_workers} batches")
print(f" • Completion timestamps: {batches_with_completion} batches")
print()
print("🎯 AI INSIGHTS READINESS:")
print(" ✓ Safety Stock Optimizer: READY (90 days demand data)")
print(" ✓ Yield Predictor: READY (worker data added)")
print(" ✓ Sustainability Metrics: READY (existing waste data)")
print()
print("🚀 Next steps:")
print(" 1. Test demo session creation")
print(" 2. Verify AI insights generation")
print(" 3. Check insight quality in frontend")
print()
if __name__ == "__main__":
main()

View File

@@ -449,7 +449,7 @@ class UnifiedEventPublisher:
elif event_class == "notification":
routing_key = f"notification.{event_domain}.info"
elif event_class == "recommendation":
routing_key = f"recommendation.{event_domain}.medium"
routing_key = f"recommendation.{event_domain}.{severity or 'medium'}"
else: # business events
routing_key = f"business.{event_type.replace('.', '_')}"
@@ -538,14 +538,16 @@ class UnifiedEventPublisher:
self,
event_type: str,
tenant_id: Union[str, uuid.UUID],
data: Dict[str, Any]
data: Dict[str, Any],
severity: Optional[str] = None
) -> bool:
"""Publish a recommendation (suggestion to user)"""
return await self.publish_event(
event_type=event_type,
tenant_id=tenant_id,
data=data,
event_class="recommendation"
event_class="recommendation",
severity=severity
)

View File

@@ -174,42 +174,81 @@ def resolve_time_marker(
# Just "BASE_TS" - return session_created_at
return session_created_at
# Parse operator and value
operator = offset_part[0]
value_part = offset_part[1:].strip()
# Handle complex multi-operation markers like "- 30d 6h + 4h 5m"
# Split by operators to handle multiple operations
import re
if operator not in ['+', '-']:
raise ValueError(f"Invalid operator in time marker: {time_marker}")
# Parse all operations in the format: [operator][value]
# Pattern matches: optional whitespace, operator (+/-), number with optional decimal, unit (d/h/m)
pattern = r'\s*([+-])\s*(\d+\.?\d*)\s*([dhm])'
operations = []
# Parse time components (supports decimals like 0.5d, 1.25h)
days = 0.0
hours = 0.0
minutes = 0.0
if 'd' in value_part:
# Handle days (supports decimals like 0.5d = 12 hours)
day_part, rest = value_part.split('d', 1)
days = float(day_part)
value_part = rest
if 'h' in value_part:
# Handle hours (supports decimals like 1.5h = 1h30m)
hour_part, rest = value_part.split('h', 1)
hours = float(hour_part)
value_part = rest
if 'm' in value_part:
# Handle minutes (supports decimals like 30.5m)
minute_part = value_part.split('m', 1)[0]
minutes = float(minute_part)
# Calculate offset using float values
offset = timedelta(days=days, hours=hours, minutes=minutes)
# Find all operations in the string
for match in re.finditer(pattern, offset_part):
operator = match.group(1)
value = float(match.group(2))
unit = match.group(3)
operations.append((operator, value, unit))
if operator == '+':
return session_created_at + offset
else:
return session_created_at - offset
if not operations:
# Fallback to old simple parsing for backwards compatibility
operator = offset_part[0]
value_part = offset_part[1:].strip()
if operator not in ['+', '-']:
raise ValueError(f"Invalid operator in time marker: {time_marker}")
# Parse time components (supports decimals like 0.5d, 1.25h)
days = 0.0
hours = 0.0
minutes = 0.0
if 'd' in value_part:
# Handle days (supports decimals like 0.5d = 12 hours)
day_part, rest = value_part.split('d', 1)
days = float(day_part)
value_part = rest
if 'h' in value_part:
# Handle hours (supports decimals like 1.5h = 1h30m)
hour_part, rest = value_part.split('h', 1)
hours = float(hour_part)
value_part = rest
if 'm' in value_part:
# Handle minutes (supports decimals like 30.5m)
minute_part = value_part.split('m', 1)[0]
minutes = float(minute_part)
# Calculate offset using float values
offset = timedelta(days=days, hours=hours, minutes=minutes)
if operator == '+':
return session_created_at + offset
else:
return session_created_at - offset
# Process multiple operations
result_time = session_created_at
for operator, value, unit in operations:
if unit == 'd':
offset = timedelta(days=value)
elif unit == 'h':
offset = timedelta(hours=value)
elif unit == 'm':
offset = timedelta(minutes=value)
else:
raise ValueError(f"Invalid time unit '{unit}' in time marker: {time_marker}")
if operator == '+':
result_time = result_time + offset
elif operator == '-':
result_time = result_time - offset
else:
raise ValueError(f"Invalid operator '{operator}' in time marker: {time_marker}")
return result_time
def shift_to_session_time(