Refactor gateway into modular architecture
Split 1878-line server.py into 15 focused modules:
- config.py: all env vars and constants
- database.py: schema, init, seed logic
- sessions.py: session/token CRUD
- proxy.py: proxy_request, SERVICE_MAP, resolve_service
- responses.py: ResponseMixin for handler helpers
- auth.py: login/logout/register handlers
- dashboard.py: dashboard, apps, connections, pinning
- command.py: AI command bar
- integrations/booklore.py: auth, books, cover, import
- integrations/kindle.py: send-to-kindle, file finder
- integrations/karakeep.py: save/delete bookmarks
- integrations/qbittorrent.py: download status
- integrations/image_proxy.py: external image proxy
server.py is now thin routing only (~344 lines).
All routes, methods, status codes, and responses preserved exactly.
Added PYTHONUNBUFFERED=1 to Dockerfile for live logging.
2026-03-29 00:14:46 -05:00
"""
Platform Gateway — Command bar handler ( natural language actions via AI ) .
"""
import json
import urllib . request
from datetime import datetime
2026-03-29 07:02:09 -05:00
from config import OPENAI_API_KEY , OPENAI_MODEL , TRIPS_URL
Refactor gateway into modular architecture
Split 1878-line server.py into 15 focused modules:
- config.py: all env vars and constants
- database.py: schema, init, seed logic
- sessions.py: session/token CRUD
- proxy.py: proxy_request, SERVICE_MAP, resolve_service
- responses.py: ResponseMixin for handler helpers
- auth.py: login/logout/register handlers
- dashboard.py: dashboard, apps, connections, pinning
- command.py: AI command bar
- integrations/booklore.py: auth, books, cover, import
- integrations/kindle.py: send-to-kindle, file finder
- integrations/karakeep.py: save/delete bookmarks
- integrations/qbittorrent.py: download status
- integrations/image_proxy.py: external image proxy
server.py is now thin routing only (~344 lines).
All routes, methods, status codes, and responses preserved exactly.
Added PYTHONUNBUFFERED=1 to Dockerfile for live logging.
2026-03-29 00:14:46 -05:00
from sessions import get_service_token
import proxy as _proxy_module
from proxy import proxy_request
def handle_command ( handler , user , body ) :
""" Parse natural language command and execute it against the right service. """
try :
data = json . loads ( body )
except Exception as e :
handler . _send_json ( { " error " : " Invalid JSON " } , 400 )
return
command = data . get ( " command " , " " ) . strip ( )
if not command :
handler . _send_json ( { " error " : " No command provided " } , 400 )
return
if not OPENAI_API_KEY :
handler . _send_json ( { " error " : " AI not configured " } , 500 )
return
# Get context: user's trips list and today's date
trips_token = get_service_token ( user [ " id " ] , " trips " )
trips_context = " "
if trips_token :
s , _ , b = proxy_request ( f " { TRIPS_URL } /api/trips " , " GET " ,
{ " Authorization " : f " Bearer { trips_token [ ' auth_token ' ] } " } , timeout = 5 )
if s == 200 :
trips_list = json . loads ( b ) . get ( " trips " , [ ] )
trips_context = " Available trips: " + " , " . join (
f ' " { t [ " name " ] } " (id= { t [ " id " ] } , { t . get ( " start_date " , " " ) } to { t . get ( " end_date " , " " ) } ) ' for t in trips_list
)
today = datetime . now ( ) . strftime ( " % Y- % m- %d " )
system_prompt = f """ You are a command executor for a personal platform with two services: Trips and Fitness.
Today ' s date: {today}
Current user : { user [ " display_name " ] } ( id = { user [ " id " ] } )
{ trips_context }
Parse the user ' s natural language command and return a JSON action to execute.
Return ONLY valid JSON with this structure :
{ {
" service " : " trips " | " fitness " ,
" action " : " description of what was done " ,
" api_call " : { {
" method " : " POST " | " GET " ,
" path " : " /api/... " ,
" body " : { { . . . } }
} }
} }
AVAILABLE ACTIONS :
For Trips service :
- Add location / activity : POST / api / location with { { " trip_id " : " ... " , " name " : " ... " , " category " : " attraction|restaurant|cafe|hike|shopping " , " visit_date " : " YYYY-MM-DD " , " start_time " : " YYYY-MM-DDTHH:MM:SS " , " description " : " ... " } }
- Add lodging : POST / api / lodging with { { " trip_id " : " ... " , " name " : " ... " , " type " : " hotel " , " check_in " : " YYYY-MM-DDTHH:MM:SS " , " check_out " : " YYYY-MM-DDTHH:MM:SS " , " location " : " ... " } }
- Add transportation : POST / api / transportation with { { " trip_id " : " ... " , " name " : " ... " , " type " : " plane|car|train " , " from_location " : " ... " , " to_location " : " ... " , " date " : " YYYY-MM-DDTHH:MM:SS " , " flight_number " : " ... " } }
- Add note : POST / api / note with { { " trip_id " : " ... " , " name " : " ... " , " content " : " ... " , " date " : " YYYY-MM-DD " } }
- Create trip : POST / api / trip with { { " name " : " ... " , " start_date " : " YYYY-MM-DD " , " end_date " : " YYYY-MM-DD " , " description " : " ... " } }
For Fitness service :
- Log food ( quick add ) : POST / api / entries with { { " entry_type " : " quick_add " , " meal_type " : " breakfast|lunch|dinner|snack " , " snapshot_food_name " : " ... " , " snapshot_quantity " : number , " snapshot_calories " : number , " snapshot_protein " : number , " snapshot_carbs " : number , " snapshot_fat " : number , " date " : " YYYY-MM-DD " } }
IMPORTANT for snapshot_food_name : use just the FOOD NAME without the quantity ( e . g . " mini cinnabon " not " 1/2 mini cinnabon " ) . Put the numeric quantity in snapshot_quantity ( e . g . 0.5 for " half " , 0.75 for " 3/4 " ) .
- Search food first then log : If you know exact nutrition , use quick_add . Common foods : banana ( 105 cal , 1 g protein , 27 g carbs , 0 g fat ) , apple ( 95 cal ) , egg ( 78 cal , 6 g protein , 1 g carbs , 5 g fat ) , chicken breast ( 165 cal , 31 g protein , 0 g carbs , 3.6 g fat ) , rice 1 cup ( 206 cal , 4 g protein , 45 g carbs , 0 g fat ) , bread slice ( 79 cal , 3 g protein , 15 g carbs , 1 g fat ) , milk 1 cup ( 149 cal , 8 g protein , 12 g carbs , 8 g fat ) , coffee black ( 2 cal ) , oatmeal 1 cup ( 154 cal , 5 g protein , 27 g carbs , 3 g fat ) .
For Inventory service ( searches NocoDB ) :
- Search items : GET / search - records ? q = . . . ( note : NO / api prefix for inventory )
- If user asks to search inventory , find items , look up orders , check serial numbers - - use service " inventory " with GET method
Guidelines :
- For food logging , default meal_type to " snack " if not specified
- For food logging , use today ' s date if not specified
- For trips , match the trip name fuzzy ( e . g . " colorado " matches " Colorado " )
- Use the trip_id from the available trips list
- Default check - in to 3 PM , check - out to 11 AM for hotels
- Estimate reasonable nutrition values for foods you know
- For inventory searches , use service " inventory " with method GET and path / search - records ? q = searchterm
- IMPORTANT : Inventory paths do NOT start with / api / - - use bare paths like / search - records """
# Call OpenAI
try :
ai_body = json . dumps ( {
" model " : OPENAI_MODEL ,
" messages " : [
{ " role " : " system " , " content " : system_prompt } ,
{ " role " : " user " , " content " : command }
] ,
" max_completion_tokens " : 1000 ,
" temperature " : 0.1
} ) . encode ( )
req = urllib . request . Request (
" https://api.openai.com/v1/chat/completions " ,
data = ai_body ,
headers = {
" Content-Type " : " application/json " ,
" Authorization " : f " Bearer { OPENAI_API_KEY } "
} ,
method = " POST "
)
2026-03-29 07:02:09 -05:00
with urllib . request . urlopen ( req , timeout = 30 ) as resp :
Refactor gateway into modular architecture
Split 1878-line server.py into 15 focused modules:
- config.py: all env vars and constants
- database.py: schema, init, seed logic
- sessions.py: session/token CRUD
- proxy.py: proxy_request, SERVICE_MAP, resolve_service
- responses.py: ResponseMixin for handler helpers
- auth.py: login/logout/register handlers
- dashboard.py: dashboard, apps, connections, pinning
- command.py: AI command bar
- integrations/booklore.py: auth, books, cover, import
- integrations/kindle.py: send-to-kindle, file finder
- integrations/karakeep.py: save/delete bookmarks
- integrations/qbittorrent.py: download status
- integrations/image_proxy.py: external image proxy
server.py is now thin routing only (~344 lines).
All routes, methods, status codes, and responses preserved exactly.
Added PYTHONUNBUFFERED=1 to Dockerfile for live logging.
2026-03-29 00:14:46 -05:00
ai_result = json . loads ( resp . read ( ) . decode ( ) )
ai_text = ai_result [ " choices " ] [ 0 ] [ " message " ] [ " content " ]
# Parse AI response
ai_text = ai_text . strip ( )
if ai_text . startswith ( " ```json " ) :
ai_text = ai_text [ 7 : ]
if ai_text . startswith ( " ``` " ) :
ai_text = ai_text [ 3 : ]
if ai_text . endswith ( " ``` " ) :
ai_text = ai_text [ : - 3 ]
parsed = json . loads ( ai_text . strip ( ) )
except Exception as e :
handler . _send_json ( { " error " : f " AI parsing failed: { e } " } , 500 )
return
# Execute the action
service = parsed . get ( " service " , " " )
api_call = parsed . get ( " api_call " , { } )
action_desc = parsed . get ( " action " , " Unknown action " )
if not api_call or not api_call . get ( " path " ) :
handler . _send_json ( { " error " : " AI could not determine action " , " parsed " : parsed } , 400 )
return
# Get service token
svc_token = get_service_token ( user [ " id " ] , service )
if not svc_token and service not in ( " inventory " , ) :
handler . _send_json ( { " error " : f " { service } service not connected " } , 400 )
return
# Build request to service
target = _proxy_module . SERVICE_MAP . get ( service )
if not target :
handler . _send_json ( { " error " : f " Unknown service: { service } " } , 400 )
return
method = api_call . get ( " method " , " POST " )
path = api_call . get ( " path " , " " )
body_data = api_call . get ( " body " , { } )
headers = { " Content-Type " : " application/json " }
if svc_token :
headers [ " Authorization " ] = f " Bearer { svc_token [ ' auth_token ' ] } "
# For fitness food logging, use the resolve workflow instead of quick_add
if service == " fitness " and body_data . get ( " entry_type " ) == " quick_add " :
food_name = body_data . get ( " snapshot_food_name " , " " )
meal_type = body_data . get ( " meal_type " , " snack " )
entry_date = body_data . get ( " date " , datetime . now ( ) . strftime ( " % Y- % m- %d " ) )
# AI-parsed quantity from the command (more reliable than resolve engine's parser)
ai_quantity = body_data . get ( " snapshot_quantity " )
if food_name and svc_token :
try :
# Step 1: Resolve the food through the smart resolution engine
resolve_body = json . dumps ( {
" raw_phrase " : food_name ,
" meal_type " : meal_type ,
" entry_date " : entry_date ,
" source " : " command_bar "
} ) . encode ( )
rs , _ , rb = proxy_request (
f " { target } /api/foods/resolve " , " POST " ,
{ * * headers , " Content-Type " : " application/json " } , resolve_body , timeout = 15
)
if rs == 200 :
resolved = json . loads ( rb )
res_type = resolved . get ( " resolution_type " )
matched = resolved . get ( " matched_food " )
parsed_food = resolved . get ( " parsed " , { } )
if matched and res_type in ( " matched " , " ai_estimated " , " confirm " ) :
food_id = matched . get ( " id " )
# For new foods (ai_estimated): serving was created for the exact request
# e.g. "3/4 cup biryani" -> serving = "3/4 cup" at 300 cal -> qty = 1.0
# For existing foods (matched/confirm): use AI quantity as multiplier
# e.g. "half cinnabon" -> existing "1 mini cinnabon" at 350 cal -> qty = 0.5
if res_type == " ai_estimated " :
quantity = 1.0
elif ai_quantity and ai_quantity != 1 :
quantity = ai_quantity
else :
quantity = 1.0
body_data = {
" food_id " : food_id ,
" meal_type " : meal_type ,
" quantity " : quantity ,
" date " : entry_date ,
" entry_type " : " food "
}
# Use matching serving if available
servings = matched . get ( " servings " , [ ] )
if servings :
body_data [ " serving_id " ] = servings [ 0 ] . get ( " id " )
# Use snapshot name override if provided (includes modifiers)
if resolved . get ( " snapshot_name_override " ) :
body_data [ " snapshot_food_name_override " ] = resolved [ " snapshot_name_override " ]
if resolved . get ( " note " ) :
body_data [ " note " ] = resolved [ " note " ]
action_desc = f " Logged { matched . get ( ' name ' , food_name ) } for { meal_type } "
if res_type == " ai_estimated " :
action_desc + = " (AI estimated, added to food database) "
# Auto-fetch image if food doesn't have one
if not matched . get ( " image_path " ) and food_id :
try :
img_body = json . dumps ( { " query " : matched . get ( " name " , food_name ) + " food " } ) . encode ( )
si , _ , sb = proxy_request (
f " { target } /api/images/search " , " POST " ,
{ * * headers , " Content-Type " : " application/json " } , img_body , timeout = 8
)
if si == 200 :
imgs = json . loads ( sb ) . get ( " images " , [ ] )
if imgs :
img_url = imgs [ 0 ] . get ( " url " ) or imgs [ 0 ] . get ( " thumbnail " )
if img_url :
proxy_request (
f " { target } /api/foods/ { food_id } /image " , " POST " ,
{ * * headers , " Content-Type " : " application/json " } ,
json . dumps ( { " url " : img_url } ) . encode ( ) , timeout = 10
)
except Exception as e :
print ( f " [Command] Auto-image fetch failed: { e } " )
except Exception as e :
print ( f " [Command] Food resolve failed, falling back to quick_add: { e } " )
req_body = json . dumps ( body_data ) . encode ( ) if body_data else None
status , resp_headers , resp_body = proxy_request ( f " { target } { path } " , method , headers , req_body , timeout = 15 )
try :
result = json . loads ( resp_body )
except Exception as e :
result = { " raw " : resp_body . decode ( ) [ : 200 ] }
handler . _send_json ( {
" success " : status < 400 ,
" action " : action_desc ,
" service " : service ,
" status " : status ,
" result " : result
} )