Pepguy commited on
Commit
65cfd54
·
verified ·
1 Parent(s): 7a4693a

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +219 -305
app.py CHANGED
@@ -1,22 +1,23 @@
1
- #!/usr/bin/env python3
2
  """
3
- single_suggest_server_v2.py
4
 
5
- Improved single-endpoint suggestion server for the Walkthrough frontend.
 
 
 
6
 
7
- Endpoints:
8
- - POST /suggest -> accepts JSON or multipart/form-data
9
- - body fields: uid (optional), wardrobe_items (optional, array), user_inputs (required), max_candidates (optional)
10
- - files: optional 'audio' multipart file
11
- - GET /health -> quick health check (Gemini + Firestore availability)
12
- - GET /wardrobe/:uid (optional) -> fetches wardrobe from Firestore (for debugging)
13
  """
14
 
15
  import os
16
  import io
17
  import json
18
- import time
19
  import uuid
 
20
  import logging
21
  import difflib
22
  from typing import List, Dict, Any, Optional
@@ -24,7 +25,10 @@ from typing import List, Dict, Any, Optional
24
  from flask import Flask, request, jsonify
25
  from flask_cors import CORS
26
 
27
- # Optional Gemini client (Google GenAI)
 
 
 
28
  try:
29
  from google import genai
30
  from google.genai import types
@@ -34,7 +38,18 @@ except Exception:
34
  types = None
35
  GENAI_AVAILABLE = False
36
 
37
- # Firebase Admin
 
 
 
 
 
 
 
 
 
 
 
38
  try:
39
  import firebase_admin
40
  from firebase_admin import credentials as fb_credentials
@@ -46,79 +61,48 @@ except Exception:
46
  fb_firestore_module = None
47
  FIREBASE_AVAILABLE = False
48
 
49
- # logging
50
- logging.basicConfig(level=logging.INFO)
51
- log = logging.getLogger("single-suggest-v2")
52
-
53
- # config from env
54
- GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "").strip()
55
- FIREBASE_ADMIN_JSON = os.getenv("FIREBASE_ADMIN_JSON", "").strip() # stringified JSON expected
56
- MAX_CANDIDATES_DEFAULT = int(os.getenv("MAX_CANDIDATES_DEFAULT", "6"))
57
-
58
- # initialize optional Gemini client
59
- client = None
60
- if GEMINI_API_KEY and GENAI_AVAILABLE:
61
- try:
62
- client = genai.Client(api_key=GEMINI_API_KEY)
63
- log.info("Gemini client configured.")
64
- except Exception as e:
65
- log.exception("Failed to init Gemini client: %s", e)
66
- client = None
67
- else:
68
- if GEMINI_API_KEY:
69
- log.warning("GEMINI_API_KEY provided but genai SDK not available; skipping Gemini.")
70
- else:
71
- log.info("No GEMINI_API_KEY provided; using deterministic generator.")
72
-
73
- # Firestore: lazy init
74
  _firestore_client = None
75
  _firebase_app = None
76
 
 
 
77
 
78
  def init_firestore_if_needed():
79
  global _firestore_client, _firebase_app
80
  if _firestore_client is not None:
81
  return _firestore_client
 
 
 
82
  if not FIREBASE_AVAILABLE:
83
- log.warning("firebase_admin not installed; Firestore unavailable.")
 
 
 
 
 
84
  return None
85
- # prefer explicit env-provided service account JSON
86
- if FIREBASE_ADMIN_JSON:
87
- try:
88
- sa = json.loads(FIREBASE_ADMIN_JSON)
89
- cred = fb_credentials.Certificate(sa)
90
- try:
91
- _firebase_app = firebase_admin.get_app()
92
- except Exception:
93
- _firebase_app = firebase_admin.initialize_app(cred)
94
- _firestore_client = fb_firestore_module.client()
95
- log.info("Initialized Firestore from FIREBASE_ADMIN_JSON.")
96
- return _firestore_client
97
- except Exception as e:
98
- log.exception("Failed to init Firestore with FIREBASE_ADMIN_JSON: %s", e)
99
- # fallback to default app
100
- # fallback: try default application credentials
101
  try:
 
102
  try:
103
  _firebase_app = firebase_admin.get_app()
104
  except Exception:
105
- _firebase_app = firebase_admin.initialize_app()
106
  _firestore_client = fb_firestore_module.client()
107
- log.info("Initialized Firestore via default credentials.")
108
  return _firestore_client
109
  except Exception as e:
110
- log.exception("Firestore initialization failed: %s", e)
111
  return None
112
 
113
-
114
- # ---------- Category / mapping utilities ----------
115
  CATEGORIES = [
116
  "top", "shirt", "blouse", "tshirt", "sweater", "jacket", "coat", "dress", "skirt",
117
  "pants", "trousers", "shorts", "jeans", "shoe", "heels", "sneaker", "boot", "sandals",
118
  "bag", "belt", "hat", "accessory", "others",
119
  ]
120
 
121
-
122
  def map_type_to_category(item_type: str) -> str:
123
  if not item_type:
124
  return "others"
@@ -136,7 +120,6 @@ def map_type_to_category(item_type: str) -> str:
136
  return token
137
  return "others"
138
 
139
-
140
  def _safe_item_brand(itm: Dict[str, Any]) -> str:
141
  analysis = itm.get("analysis") or {}
142
  brand = analysis.get("brand") if isinstance(analysis, dict) else None
@@ -144,15 +127,11 @@ def _safe_item_brand(itm: Dict[str, Any]) -> str:
144
  brand = itm.get("brand") or ""
145
  return str(brand).strip()
146
 
 
147
 
148
  def _item_title_for_map(it: Dict[str, Any]) -> str:
149
  return str((it.get("title") or (it.get("analysis") or {}).get("type") or it.get("label") or "")).strip().lower()
150
 
151
-
152
- # prioritize top-like items first (for note consistency)
153
- TOP_LIKE_CATEGORIES = {"top", "shirt", "tshirt", "blouse", "sweater"}
154
-
155
-
156
  def prioritize_top_item(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
157
  if not items:
158
  return items
@@ -171,7 +150,6 @@ def prioritize_top_item(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
171
  item = new_items.pop(top_idx)
172
  new_items.insert(0, item)
173
  return new_items
174
- # fallback to highest confidence
175
  try:
176
  best_idx = max(range(len(items)), key=lambda i: float(items[i].get("confidence", 0.5)))
177
  if best_idx != 0:
@@ -183,8 +161,7 @@ def prioritize_top_item(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
183
  pass
184
  return items
185
 
186
-
187
- # ---------- Candidate generation (naive) ----------
188
  def naive_generate_candidates(wardrobe_items: List[Dict[str, Any]],
189
  user_inputs: Dict[str, Any],
190
  user_profile: Dict[str, Any],
@@ -254,8 +231,7 @@ def naive_generate_candidates(wardrobe_items: List[Dict[str, Any]],
254
  candidates.sort(key=lambda c: c.get("score", 0), reverse=True)
255
  return candidates
256
 
257
-
258
- # If Gemini enabled, use it to generate candidates (best-effort). If it fails, fall back to naive.
259
  def generate_candidates_with_gemini(wardrobe_items: List[Dict[str, Any]],
260
  user_inputs: Dict[str, Any],
261
  user_profile: Dict[str, Any],
@@ -275,8 +251,10 @@ def generate_candidates_with_gemini(wardrobe_items: List[Dict[str, Any]],
275
  "summary": (a.get("summary") or "")[:180],
276
  "brand": (a.get("brand") or "")[:80],
277
  "tags": a.get("tags") or [],
278
- "thumbnailUrl": it.get("thumbnailUrl") or it.get("thumbnail_url") or "",
279
  })
 
 
280
  prompt = (
281
  "You are a stylist assistant. Given WARDROBE array (id,type,summary,brand,tags,thumbnailUrl),\n"
282
  "USER_INPUT (moods, appearances, events, activity, preferred/excluded colors, keyBrands, etc.),\n"
@@ -284,6 +262,7 @@ def generate_candidates_with_gemini(wardrobe_items: List[Dict[str, Any]],
284
  "Return only valid JSON: {\"candidates\": [ {\"id\": \"..\", \"item_ids\": [..], \"score\": 0-1, \"notes\": \"one-line\", \"short_reason\": \"phrase\"}, ... ]}\n\n"
285
  "WARDROBE = {wardrobe}\nUSER_INPUT = {u}\nPAST_WEEK = {p}\n".format(max=max_candidates, wardrobe=json.dumps(summarized), u=json.dumps(user_inputs), p=json.dumps([p.get("id") for p in (past_week_items or [])]))
286
  )
 
287
  contents = [types.Content(role="user", parts=[types.Part.from_text(text=prompt)])]
288
  schema = {
289
  "type": "object",
@@ -306,12 +285,16 @@ def generate_candidates_with_gemini(wardrobe_items: List[Dict[str, Any]],
306
  "required": ["candidates"],
307
  }
308
  cfg = types.GenerateContentConfig(response_mime_type="application/json", response_schema=schema)
309
- resp = client.models.generate_content(model="gemini-2.5-flash", contents=contents, config=cfg)
 
 
 
 
310
  raw = resp.text or ""
311
- parsed = json.loads(raw)
312
  id_map = {str(it.get("id")): it for it in wardrobe_items}
313
  out = []
314
- for c in parsed.get("candidates", [])[:max_candidates]:
315
  items = []
316
  for iid in c.get("item_ids", []):
317
  itm = id_map.get(str(iid))
@@ -328,7 +311,7 @@ def generate_candidates_with_gemini(wardrobe_items: List[Dict[str, Any]],
328
  "notes": (c.get("notes") or "")[:300],
329
  })
330
  if not out:
331
- log.warning("Gemini returned no candidates; falling back.")
332
  return naive_generate_candidates(wardrobe_items, user_inputs, user_profile, past_week_items, max_candidates)
333
  out.sort(key=lambda x: x.get("score", 0), reverse=True)
334
  return out[:max_candidates]
@@ -336,86 +319,26 @@ def generate_candidates_with_gemini(wardrobe_items: List[Dict[str, Any]],
336
  log.exception("Gemini candidate generation failed: %s", e)
337
  return naive_generate_candidates(wardrobe_items, user_inputs, user_profile, past_week_items, max_candidates)
338
 
339
-
340
- # ---------- Refinement ----------
341
- def refine_candidates_with_constraints(candidates: List[Dict[str, Any]],
342
- wardrobe_items: List[Dict[str, Any]],
343
- constraints: Dict[str, Any]) -> Dict[str, Any]:
344
- require_brands = set([b.lower() for b in (constraints.get("require_brands") or []) if b])
345
- reject_brands = set([b.lower() for b in (constraints.get("reject_brands") or []) if b])
346
- past_ids = set([x.get("id") for x in (constraints.get("past_week_items") or []) if x.get("id")])
347
- allow_rerun = bool(constraints.get("allow_rerun", False))
348
-
349
- id_map = {str(it.get("id")): it for it in wardrobe_items}
350
- refined = []
351
- removed = []
352
-
353
- for cand in candidates:
354
- items = cand.get("items") or []
355
- resolved = []
356
- for i in items:
357
- iid = str(i.get("id"))
358
- full = id_map.get(iid)
359
- if full:
360
- resolved.append(full)
361
- else:
362
- resolved.append(i)
363
- if require_brands:
364
- if not any((_safe_item_brand(it).lower() in require_brands) for it in resolved):
365
- removed.append({"id": cand.get("id"), "reason": "missing required brand"})
366
- continue
367
- if reject_brands:
368
- if any((_safe_item_brand(it).lower() in reject_brands) for it in resolved):
369
- removed.append({"id": cand.get("id"), "reason": "contains rejected brand"})
370
- continue
371
- if past_ids and any((it.get("id") in past_ids) for it in resolved):
372
- if not allow_rerun:
373
- removed.append({"id": cand.get("id"), "reason": "uses recent items"})
374
- continue
375
- else:
376
- cand["_conflict_with_schedule"] = True
377
- cand["items"] = [
378
- {
379
- "id": it.get("id"),
380
- "label": it.get("label"),
381
- "title": it.get("title"),
382
- "thumbnailUrl": it.get("thumbnailUrl") if it.get("thumbnailUrl") is not None else it.get("thumbnail_url"),
383
- "analysis": it.get("analysis", {}),
384
- "confidence": it.get("confidence", 0.5),
385
- } for it in resolved
386
- ]
387
- refined.append(cand)
388
-
389
- if not refined:
390
- hint = "All candidates filtered out. Consider loosening constraints or allow rerun."
391
- return {"refined": [], "rerun_required": True, "rerun_hint": hint, "removed": removed}
392
- refined.sort(key=lambda c: c.get("score", 0), reverse=True)
393
- return {"refined": refined, "rerun_required": False, "rerun_hint": "", "removed": removed}
394
-
395
-
396
- # ---------- Final note (Gemini optional) ----------
397
  def finalize_suggestion_note_with_gemini(candidate: Dict[str, Any], user_inputs: Dict[str, Any], user_profile: Dict[str, Any]) -> str:
398
  if not client:
399
- # basic heuristic note
400
  moods = user_inputs.get("moods") or []
401
  events = user_inputs.get("events") or []
402
- return f"Because you chose {', '.join(moods[:2]) or 'your mood'} for {', '.join(events[:1]) or 'your event'} — practical and stylish."
 
 
403
  try:
404
- prompt = (
405
- "You are a concise stylist. Given CANDIDATE_ITEMS (list of short item descriptions) and USER_INPUT, "
406
- "write a single short friendly sentence (<=18 words) explaining why this outfit was chosen. Return plain text.\n\n"
407
- )
408
  candidate_items = []
409
  for it in candidate.get("items", []):
410
  desc = (it.get("analysis") or {}).get("summary") or it.get("label") or it.get("title") or ""
411
  brand = (it.get("analysis") or {}).get("brand") or ""
412
  candidate_items.append({"id": it.get("id"), "desc": desc[:160], "brand": brand[:60]})
413
- contents = [
414
- types.Content(role="user", parts=[types.Part.from_text(text=prompt)]),
415
- types.Content(role="user", parts=[types.Part.from_text(text="CANDIDATE_ITEMS: " + json.dumps(candidate_items))]),
416
- types.Content(role="user", parts=[types.Part.from_text(text="USER_INPUT: " + json.dumps(user_inputs or {}))]),
417
- types.Content(role="user", parts=[types.Part.from_text(text="Return only a single short sentence.")]),
418
- ]
419
  resp = client.models.generate_content(model="gemini-2.5-flash-lite", contents=contents)
420
  text = (resp.text or "").strip()
421
  return text.splitlines()[0] if text else "A curated outfit chosen for your preferences."
@@ -423,69 +346,18 @@ def finalize_suggestion_note_with_gemini(candidate: Dict[str, Any], user_inputs:
423
  log.exception("Gemini finalize note failed: %s", e)
424
  moods = user_inputs.get("moods") or []
425
  events = user_inputs.get("events") or []
426
- return f"Because you chose {', '.join(moods[:2]) or 'your mood'} for {', '.join(events[:1]) or 'your event'} — practical and stylish."
427
-
428
-
429
- # ---------- Firestore helpers ----------
430
- def get_or_create_user_summary(uid: str, fallback_from_inputs: Dict[str, Any]) -> str:
431
- fs = init_firestore_if_needed()
432
- gen_summary = None
433
- try:
434
- if not fs:
435
- gen_summary = _heuristic_summary_from_inputs(fallback_from_inputs)
436
- return gen_summary
437
- doc_ref = fs.collection("users").document(uid)
438
- doc = doc_ref.get()
439
- if doc.exists:
440
- data = doc.to_dict() or {}
441
- summary = data.get("summary")
442
- if summary:
443
- return summary
444
- gen_summary = _heuristic_summary_from_inputs(fallback_from_inputs)
445
- try:
446
- doc_ref.set({"summary": gen_summary, "updatedAt": int(time.time())}, merge=True)
447
- except Exception:
448
- pass
449
- return gen_summary
450
- else:
451
- gen_summary = _heuristic_summary_from_inputs(fallback_from_inputs)
452
- try:
453
- doc_ref.set({"summary": gen_summary, "createdAt": int(time.time())})
454
- except Exception:
455
- pass
456
- return gen_summary
457
- except Exception as e:
458
- log.exception("Error fetching/creating user summary: %s", e)
459
- return gen_summary or _heuristic_summary_from_inputs(fallback_from_inputs)
460
-
461
-
462
- def fetch_recent_suggestions(uid: str, days: int = 7) -> List[Dict[str, Any]]:
463
- fs = init_firestore_if_needed()
464
- if not fs:
465
- return []
466
- try:
467
- cutoff = int(time.time()) - days * 86400
468
- q = fs.collection("suggestions").where("uid", "==", uid).where("createdAtTs", ">=", cutoff).limit(50)
469
- docs = q.get()
470
- items = []
471
- for d in docs:
472
- dd = d.to_dict() or {}
473
- for it in dd.get("items", []) or []:
474
- items.append({"id": it.get("id"), "label": it.get("label")})
475
- return items
476
- except Exception as e:
477
- log.warning("Failed to fetch recent suggestions: %s", e)
478
- return []
479
-
480
 
 
481
  def fetch_wardrobe_from_firestore(uid: str) -> List[Dict[str, Any]]:
482
  fs = init_firestore_if_needed()
483
  if not fs:
484
  return []
485
  try:
486
- # try users/{uid}/wardrobe subcollection first
487
- subcol_ref = fs.collection("users").document(uid).collection("wardrobe")
488
- docs = subcol_ref.limit(200).get()
489
  items = []
490
  for d in docs:
491
  dd = d.to_dict() or {}
@@ -501,10 +373,9 @@ def fetch_wardrobe_from_firestore(uid: str) -> List[Dict[str, Any]]:
501
  if items:
502
  return items
503
  except Exception as e:
504
- log.warning("users/{uid}/wardrobe read failed: %s", e)
505
 
506
  try:
507
- # fallback to global collection 'wardrobe' (docs with uid field)
508
  q = fs.collection("wardrobe").where("uid", "==", uid).limit(500)
509
  docs = q.get()
510
  items = []
@@ -521,88 +392,101 @@ def fetch_wardrobe_from_firestore(uid: str) -> List[Dict[str, Any]]:
521
  })
522
  return items
523
  except Exception as e:
524
- log.warning("wardrobe collection read failed: %s", e)
525
  return []
526
 
 
 
 
 
 
 
 
 
 
 
 
 
527
 
528
- def _heuristic_summary_from_inputs(user_inputs: Dict[str, Any]) -> str:
529
- moods = user_inputs.get("moods") or []
530
- brands = user_inputs.get("keyBrands") or []
531
- events = user_inputs.get("events") or []
532
- parts = []
533
- if moods:
534
- parts.append("moods: " + ", ".join(moods[:3]))
535
- if brands:
536
- parts.append("likes brands: " + ", ".join(brands[:3]))
537
- if events:
538
- parts.append("often for: " + ", ".join(events[:2]))
539
- if not parts:
540
- return "A user who likes simple, practical outfits."
541
- return " & ".join(parts)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
542
 
 
 
 
 
 
543
 
544
  # ---------- Flask app ----------
545
  app = Flask(__name__)
546
  CORS(app)
547
 
548
-
549
  @app.route("/suggest", methods=["POST"])
550
  def suggest_all():
551
- """
552
- Main endpoint. Accepts multipart/form-data or JSON.
553
- Required: user_inputs (object)
554
- Optional: wardrobe_items (array) — if absent server will try to fetch from Firestore for uid
555
- audio file (multipart field 'audio') or audio_b64 (json)
556
- """
557
  is_multipart = request.content_type and request.content_type.startswith("multipart/form-data")
558
  try:
559
  if is_multipart:
560
  form = request.form
561
  files = request.files
562
  uid = (form.get("uid") or form.get("user_id") or "anon").strip() or "anon"
563
- user_inputs = {}
564
- try:
565
- ui_raw = form.get("user_inputs")
566
- if ui_raw:
567
- user_inputs = json.loads(ui_raw)
568
- else:
569
- user_inputs = {}
570
- except Exception:
571
- user_inputs = {}
572
- max_c = int(form.get("max_candidates") or MAX_CANDIDATES_DEFAULT)
573
- wardrobe_items = []
574
- w_raw = form.get("wardrobe_items")
575
- if w_raw:
576
- try:
577
- wardrobe_items = json.loads(w_raw)
578
- except Exception:
579
- wardrobe_items = []
580
  audio_file = files.get("audio")
581
  audio_b64 = None
582
  if audio_file:
583
- try:
584
- audio_bytes = audio_file.read()
585
- import base64
586
- audio_b64 = base64.b64encode(audio_bytes).decode("ascii")
587
- except Exception:
588
- audio_b64 = None
589
  else:
590
  body = request.get_json(force=True)
591
  uid = (body.get("uid") or body.get("user_id") or "anon").strip() or "anon"
592
  user_inputs = body.get("user_inputs") or {}
593
- max_c = int(body.get("max_candidates") or MAX_CANDIDATES_DEFAULT)
594
  wardrobe_items = body.get("wardrobe_items") or []
595
  audio_b64 = body.get("audio_b64")
596
  except Exception as e:
597
  log.exception("Invalid request payload: %s", e)
598
  return jsonify({"error": "invalid request payload"}), 400
599
 
600
- if not isinstance(user_inputs, dict):
601
- return jsonify({"error": "user_inputs must be an object"}), 400
602
-
603
- # Normalize wardrobe items (ensure thumbnailUrl field present)
604
- normalized_items = []
605
  try:
 
606
  for it in wardrobe_items or []:
607
  if not isinstance(it, dict):
608
  normalized_items.append(it)
@@ -615,33 +499,75 @@ def suggest_all():
615
  except Exception:
616
  pass
617
 
618
- # If no wardrobe provided, try fetching from Firestore
619
  if not wardrobe_items:
620
  try:
621
- wardrobe_items = fetch_wardrobe_from_firestore(uid)
622
  log.info("Fetched %d wardrobe items for uid=%s from Firestore", len(wardrobe_items), uid)
623
  except Exception as e:
624
- log.warning("Failed fetching wardrobe from Firestore: %s", e)
625
  wardrobe_items = []
626
 
 
 
627
  if not wardrobe_items:
628
  return jsonify({"error": "no wardrobe_items provided and none found in Firestore"}), 400
629
 
630
- # Step 0: user summary / past week items
631
- user_summary = get_or_create_user_summary(uid, user_inputs)
632
  try:
633
- past_week_items = fetch_recent_suggestions(uid, days=7) or []
634
- except Exception:
635
- past_week_items = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
636
 
637
- # Step 1: generate candidates
 
638
  try:
639
- candidates = generate_candidates_with_gemini(wardrobe_items, user_inputs, {"summary": user_summary}, past_week_items, max_candidates=max_c)
 
 
 
 
 
 
 
 
640
  except Exception as e:
641
- log.exception("Candidate generation failed, falling back to naive: %s", e)
 
 
 
 
 
 
 
 
 
642
  candidates = naive_generate_candidates(wardrobe_items, user_inputs, {"summary": user_summary}, past_week_items, max_candidates=max_c)
643
 
644
- # Step 2: refine candidates using constraints derived from user_inputs
645
  constraints = {
646
  "require_brands": user_inputs.get("keyBrands") or [],
647
  "reject_brands": user_inputs.get("reject_brands") or user_inputs.get("excluded_brands") or [],
@@ -650,29 +576,28 @@ def suggest_all():
650
  }
651
  refine_result = refine_candidates_with_constraints(candidates, wardrobe_items, constraints)
652
 
653
- # If refinement removed everything and rerun allowed, do looser run
654
  if refine_result.get("rerun_required") and constraints.get("allow_rerun"):
655
- log.info("Refine requested rerun; performing broader naive generation.")
656
- try:
657
- alt_candidates = naive_generate_candidates(wardrobe_items, user_inputs, {"summary": user_summary}, past_week_items, max_candidates=max(8, max_c * 2))
658
- refine_result = refine_candidates_with_constraints(alt_candidates, wardrobe_items, constraints)
659
- except Exception as e:
660
- log.exception("Rerun generation failed: %s", e)
661
 
662
  refined = refine_result.get("refined", [])
663
 
664
- # Step 3: finalize suggestions, produce single-line notes for each (Gemini optional)
665
  suggestions = []
666
  for cand in refined:
667
  try:
668
  cand_items = cand.get("items", []) or []
669
  cand_items = prioritize_top_item(cand_items)
670
  cand["items"] = cand_items
671
- note = finalize_suggestion_note_with_gemini(cand, user_inputs, {"summary": user_summary})
 
672
  except Exception as e:
673
- log.warning("Failed to produce note for candidate %s: %s", cand.get("id"), e)
674
  note = cand.get("notes") or cand.get("reason") or "A curated outfit."
 
675
  thumb_urls = [it.get("thumbnailUrl") for it in cand.get("items", []) if it.get("thumbnailUrl")]
 
676
  suggestion = {
677
  "id": cand.get("id") or str(uuid.uuid4()),
678
  "items": cand.get("items", []),
@@ -682,7 +607,7 @@ def suggest_all():
682
  "score": cand.get("score"),
683
  "meta": {
684
  "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
685
- "source": "single_suggest_pipeline_v2",
686
  "user_inputs": user_inputs,
687
  },
688
  "uid": uid,
@@ -690,9 +615,9 @@ def suggest_all():
690
  }
691
  suggestions.append(suggestion)
692
 
693
- # Persist suggestions to Firestore (best-effort)
 
694
  fs = init_firestore_if_needed()
695
- persisted = []
696
  if fs and suggestions:
697
  try:
698
  col = fs.collection("suggestions")
@@ -700,42 +625,31 @@ def suggest_all():
700
  try:
701
  doc_id = s["id"]
702
  col.document(doc_id).set(s)
703
- persisted.append(doc_id)
704
  except Exception as se:
705
  log.warning("Failed to persist suggestion %s: %s", s.get("id"), se)
706
  except Exception as e:
707
- log.warning("Failed to persist suggestions to Firestore: %s", e)
708
 
709
  debug = {
710
  "candidates_count": len(candidates),
711
  "refined_count": len(refined),
712
- "persisted_count": len(persisted),
713
- "persisted_ids": persisted,
714
- "rerun_hint": refine_result.get("rerun_hint", "")
715
  }
716
 
717
- # Return results (frontend expects a vertical array; we respect ordering)
718
  return jsonify({"ok": True, "user_summary": user_summary, "suggestions": suggestions, "debug": debug}), 200
719
 
720
-
721
  @app.route("/health", methods=["GET"])
722
  def health():
723
- fs_ok = bool(init_firestore_if_needed())
724
- gem_ok = bool(client)
725
- return jsonify({"ok": True, "time": int(time.time()), "gemini": gem_ok, "firestore": fs_ok}), 200
726
-
727
-
728
- @app.route("/wardrobe/<uid>", methods=["GET"])
729
- def get_wardrobe(uid):
730
- try:
731
- items = fetch_wardrobe_from_firestore(uid)
732
- return jsonify({"ok": True, "count": len(items), "items": items}), 200
733
- except Exception as e:
734
- log.exception("wardrobe read failed: %s", e)
735
- return jsonify({"ok": False, "error": str(e)}), 500
736
-
737
 
738
  if __name__ == "__main__":
739
- port = int(os.getenv("PORT", "7860"))
740
- log.info("Starting single-suggest-server-v2 on 0.0.0.0:%s", port)
741
- app.run(host="0.0.0.0", port=port, debug=True)
 
1
+ # single_suggest_server.py
2
  """
3
+ Single-endpoint suggestion server using Google Gemini (genai) exclusively.
4
 
5
+ Environment variables:
6
+ - GEMINI_API_KEY (optional) : API key for Google genai SDK
7
+ - FIREBASE_ADMIN_JSON (optional) : service account JSON (string) to initialize firebase-admin
8
+ - PORT (optional) : port to run (default 7860)
9
 
10
+ Behavior:
11
+ - If GEMINI_API_KEY & genai SDK present: uses Gemini for candidate generation and final notes.
12
+ - Otherwise falls back to deterministic naive generator (no placeholders).
13
+ - Firestore persistence if FIREBASE_ADMIN_JSON provided.
 
 
14
  """
15
 
16
  import os
17
  import io
18
  import json
 
19
  import uuid
20
+ import time
21
  import logging
22
  import difflib
23
  from typing import List, Dict, Any, Optional
 
25
  from flask import Flask, request, jsonify
26
  from flask_cors import CORS
27
 
28
+ # HTTP helper for generic fetches if needed
29
+ import requests
30
+
31
+ # Try to import Google GenAI SDK
32
  try:
33
  from google import genai
34
  from google.genai import types
 
38
  types = None
39
  GENAI_AVAILABLE = False
40
 
41
+ GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "").strip()
42
+ if GEMINI_API_KEY and GENAI_AVAILABLE:
43
+ client = genai.Client(api_key=GEMINI_API_KEY)
44
+ logging.info("Gemini client configured.")
45
+ else:
46
+ client = None
47
+ if GEMINI_API_KEY and not GENAI_AVAILABLE:
48
+ logging.warning("GEMINI_API_KEY provided but genai SDK not installed; Gemini disabled.")
49
+ else:
50
+ logging.info("GEMINI_API_KEY not provided; using deterministic fallback generator.")
51
+
52
+ # Firestore admin
53
  try:
54
  import firebase_admin
55
  from firebase_admin import credentials as fb_credentials
 
61
  fb_firestore_module = None
62
  FIREBASE_AVAILABLE = False
63
 
64
+ FIREBASE_ADMIN_JSON = os.getenv("FIREBASE_ADMIN_JSON", "").strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  _firestore_client = None
66
  _firebase_app = None
67
 
68
+ logging.basicConfig(level=logging.INFO)
69
+ log = logging.getLogger("single-suggest-gemini-server")
70
 
71
  def init_firestore_if_needed():
72
  global _firestore_client, _firebase_app
73
  if _firestore_client is not None:
74
  return _firestore_client
75
+ if not FIREBASE_ADMIN_JSON:
76
+ log.info("No FIREBASE_ADMIN_JSON set; Firestore not initialized.")
77
+ return None
78
  if not FIREBASE_AVAILABLE:
79
+ log.warning("FIREBASE_ADMIN_JSON provided but firebase-admin SDK not available; skip Firestore init.")
80
+ return None
81
+ try:
82
+ sa_obj = json.loads(FIREBASE_ADMIN_JSON)
83
+ except Exception as e:
84
+ log.exception("Failed parsing FIREBASE_ADMIN_JSON: %s", e)
85
  return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  try:
87
+ cred = fb_credentials.Certificate(sa_obj)
88
  try:
89
  _firebase_app = firebase_admin.get_app()
90
  except Exception:
91
+ _firebase_app = firebase_admin.initialize_app(cred)
92
  _firestore_client = fb_firestore_module.client()
93
+ log.info("Initialized Firestore client.")
94
  return _firestore_client
95
  except Exception as e:
96
+ log.exception("Failed to init Firestore: %s", e)
97
  return None
98
 
99
+ # ---------- categories / heuristics ----------
 
100
  CATEGORIES = [
101
  "top", "shirt", "blouse", "tshirt", "sweater", "jacket", "coat", "dress", "skirt",
102
  "pants", "trousers", "shorts", "jeans", "shoe", "heels", "sneaker", "boot", "sandals",
103
  "bag", "belt", "hat", "accessory", "others",
104
  ]
105
 
 
106
  def map_type_to_category(item_type: str) -> str:
107
  if not item_type:
108
  return "others"
 
120
  return token
121
  return "others"
122
 
 
123
  def _safe_item_brand(itm: Dict[str, Any]) -> str:
124
  analysis = itm.get("analysis") or {}
125
  brand = analysis.get("brand") if isinstance(analysis, dict) else None
 
127
  brand = itm.get("brand") or ""
128
  return str(brand).strip()
129
 
130
+ TOP_LIKE_CATEGORIES = {"top", "shirt", "tshirt", "blouse", "sweater"}
131
 
132
  def _item_title_for_map(it: Dict[str, Any]) -> str:
133
  return str((it.get("title") or (it.get("analysis") or {}).get("type") or it.get("label") or "")).strip().lower()
134
 
 
 
 
 
 
135
  def prioritize_top_item(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
136
  if not items:
137
  return items
 
150
  item = new_items.pop(top_idx)
151
  new_items.insert(0, item)
152
  return new_items
 
153
  try:
154
  best_idx = max(range(len(items)), key=lambda i: float(items[i].get("confidence", 0.5)))
155
  if best_idx != 0:
 
161
  pass
162
  return items
163
 
164
+ # ---------- deterministic naive generator (real) ----------
 
165
  def naive_generate_candidates(wardrobe_items: List[Dict[str, Any]],
166
  user_inputs: Dict[str, Any],
167
  user_profile: Dict[str, Any],
 
231
  candidates.sort(key=lambda c: c.get("score", 0), reverse=True)
232
  return candidates
233
 
234
+ # ---------- Gemini helpers ----------
 
235
  def generate_candidates_with_gemini(wardrobe_items: List[Dict[str, Any]],
236
  user_inputs: Dict[str, Any],
237
  user_profile: Dict[str, Any],
 
251
  "summary": (a.get("summary") or "")[:180],
252
  "brand": (a.get("brand") or "")[:80],
253
  "tags": a.get("tags") or [],
254
+ "thumbnailUrl": it.get("thumbnailUrl") or it.get("thumbnail_url") or ""
255
  })
256
+
257
+ # Compose a prompt asking for JSON candidates
258
  prompt = (
259
  "You are a stylist assistant. Given WARDROBE array (id,type,summary,brand,tags,thumbnailUrl),\n"
260
  "USER_INPUT (moods, appearances, events, activity, preferred/excluded colors, keyBrands, etc.),\n"
 
262
  "Return only valid JSON: {\"candidates\": [ {\"id\": \"..\", \"item_ids\": [..], \"score\": 0-1, \"notes\": \"one-line\", \"short_reason\": \"phrase\"}, ... ]}\n\n"
263
  "WARDROBE = {wardrobe}\nUSER_INPUT = {u}\nPAST_WEEK = {p}\n".format(max=max_candidates, wardrobe=json.dumps(summarized), u=json.dumps(user_inputs), p=json.dumps([p.get("id") for p in (past_week_items or [])]))
264
  )
265
+
266
  contents = [types.Content(role="user", parts=[types.Part.from_text(text=prompt)])]
267
  schema = {
268
  "type": "object",
 
285
  "required": ["candidates"],
286
  }
287
  cfg = types.GenerateContentConfig(response_mime_type="application/json", response_schema=schema)
288
+ resp = client.models.generate_content(
289
+ model="gemini-2.5-flash", # choose an appropriate Gemini model
290
+ contents=contents,
291
+ config=cfg
292
+ )
293
  raw = resp.text or ""
294
+ parsed = json.loads(raw) if raw else None
295
  id_map = {str(it.get("id")): it for it in wardrobe_items}
296
  out = []
297
+ for c in (parsed.get("candidates", []) if parsed else [])[:max_candidates]:
298
  items = []
299
  for iid in c.get("item_ids", []):
300
  itm = id_map.get(str(iid))
 
311
  "notes": (c.get("notes") or "")[:300],
312
  })
313
  if not out:
314
+ log.warning("Gemini returned no candidates; fallback to naive generator.")
315
  return naive_generate_candidates(wardrobe_items, user_inputs, user_profile, past_week_items, max_candidates)
316
  out.sort(key=lambda x: x.get("score", 0), reverse=True)
317
  return out[:max_candidates]
 
319
  log.exception("Gemini candidate generation failed: %s", e)
320
  return naive_generate_candidates(wardrobe_items, user_inputs, user_profile, past_week_items, max_candidates)
321
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
322
  def finalize_suggestion_note_with_gemini(candidate: Dict[str, Any], user_inputs: Dict[str, Any], user_profile: Dict[str, Any]) -> str:
323
  if not client:
324
+ # heuristic fallback
325
  moods = user_inputs.get("moods") or []
326
  events = user_inputs.get("events") or []
327
+ mood = moods[0] if moods else "your mood"
328
+ ev = events[0] if events else "your event"
329
+ return f"A curated outfit selected for {mood} at {ev} — stylish and practical."
330
  try:
 
 
 
 
331
  candidate_items = []
332
  for it in candidate.get("items", []):
333
  desc = (it.get("analysis") or {}).get("summary") or it.get("label") or it.get("title") or ""
334
  brand = (it.get("analysis") or {}).get("brand") or ""
335
  candidate_items.append({"id": it.get("id"), "desc": desc[:160], "brand": brand[:60]})
336
+ prompt = (
337
+ "You are a concise stylist. Given CANDIDATE_ITEMS (list of short item descriptions) and USER_INPUT, "
338
+ "write a single short friendly sentence (<=18 words) explaining why this outfit was chosen. Return plain text.\n\n"
339
+ f"CANDIDATE_ITEMS: {json.dumps(candidate_items)}\nUSER_INPUT: {json.dumps(user_inputs or {})}\nReturn only a single short sentence."
340
+ )
341
+ contents = [types.Content(role="user", parts=[types.Part.from_text(text=prompt)])]
342
  resp = client.models.generate_content(model="gemini-2.5-flash-lite", contents=contents)
343
  text = (resp.text or "").strip()
344
  return text.splitlines()[0] if text else "A curated outfit chosen for your preferences."
 
346
  log.exception("Gemini finalize note failed: %s", e)
347
  moods = user_inputs.get("moods") or []
348
  events = user_inputs.get("events") or []
349
+ mood = moods[0] if moods else "your mood"
350
+ ev = events[0] if events else "your event"
351
+ return f"A curated outfit selected for {mood} at {ev} — stylish and practical."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
 
353
+ # ---------- Firestore fetching ----------
354
  def fetch_wardrobe_from_firestore(uid: str) -> List[Dict[str, Any]]:
355
  fs = init_firestore_if_needed()
356
  if not fs:
357
  return []
358
  try:
359
+ subcol = fs.collection("users").document(uid).collection("wardrobe")
360
+ docs = subcol.limit(1000).get()
 
361
  items = []
362
  for d in docs:
363
  dd = d.to_dict() or {}
 
373
  if items:
374
  return items
375
  except Exception as e:
376
+ log.warning("users/{uid}/wardrobe subcollection read failed: %s", e)
377
 
378
  try:
 
379
  q = fs.collection("wardrobe").where("uid", "==", uid).limit(500)
380
  docs = q.get()
381
  items = []
 
392
  })
393
  return items
394
  except Exception as e:
395
+ log.warning("wardrobe collection query failed: %s", e)
396
  return []
397
 
398
+ # ---------- refinement ----------
399
+ def refine_candidates_with_constraints(candidates: List[Dict[str, Any]],
400
+ wardrobe_items: List[Dict[str, Any]],
401
+ constraints: Dict[str, Any]) -> Dict[str, Any]:
402
+ require_brands = set([b.lower() for b in (constraints.get("require_brands") or []) if b])
403
+ reject_brands = set([b.lower() for b in (constraints.get("reject_brands") or []) if b])
404
+ past_ids = set([x.get("id") for x in (constraints.get("past_week_items") or []) if x.get("id")])
405
+ allow_rerun = bool(constraints.get("allow_rerun", False))
406
+
407
+ id_map = {str(it.get("id")): it for it in wardrobe_items}
408
+ refined = []
409
+ removed = []
410
 
411
+ for cand in candidates:
412
+ items = cand.get("items") or []
413
+ resolved = []
414
+ for i in items:
415
+ iid = str(i.get("id"))
416
+ full = id_map.get(iid)
417
+ if full:
418
+ resolved.append(full)
419
+ else:
420
+ resolved.append(i)
421
+ if require_brands:
422
+ if not any((_safe_item_brand(it).lower() in require_brands) for it in resolved):
423
+ removed.append({"id": cand.get("id"), "reason": "missing required brand"})
424
+ continue
425
+ if reject_brands:
426
+ if any((_safe_item_brand(it).lower() in reject_brands) for it in resolved):
427
+ removed.append({"id": cand.get("id"), "reason": "contains rejected brand"})
428
+ continue
429
+ if past_ids and any((it.get("id") in past_ids) for it in resolved):
430
+ if not allow_rerun:
431
+ removed.append({"id": cand.get("id"), "reason": "uses recent items"})
432
+ continue
433
+ else:
434
+ cand["_conflict_with_schedule"] = True
435
+ cand["items"] = [
436
+ {
437
+ "id": it.get("id"),
438
+ "label": it.get("label"),
439
+ "title": it.get("title"),
440
+ "thumbnailUrl": it.get("thumbnailUrl") if it.get("thumbnailUrl") is not None else it.get("thumbnail_url"),
441
+ "analysis": it.get("analysis", {}),
442
+ "confidence": it.get("confidence", 0.5),
443
+ } for it in resolved
444
+ ]
445
+ refined.append(cand)
446
 
447
+ if not refined:
448
+ hint = "All candidates filtered out. Consider loosening constraints or allow rerun."
449
+ return {"refined": [], "rerun_required": True, "rerun_hint": hint, "removed": removed}
450
+ refined.sort(key=lambda c: c.get("score", 0), reverse=True)
451
+ return {"refined": refined, "rerun_required": False, "rerun_hint": "", "removed": removed}
452
 
453
  # ---------- Flask app ----------
454
  app = Flask(__name__)
455
  CORS(app)
456
 
 
457
  @app.route("/suggest", methods=["POST"])
458
  def suggest_all():
 
 
 
 
 
 
459
  is_multipart = request.content_type and request.content_type.startswith("multipart/form-data")
460
  try:
461
  if is_multipart:
462
  form = request.form
463
  files = request.files
464
  uid = (form.get("uid") or form.get("user_id") or "anon").strip() or "anon"
465
+ user_inputs_raw = form.get("user_inputs")
466
+ user_inputs = json.loads(user_inputs_raw) if user_inputs_raw else {}
467
+ max_c = int(form.get("max_candidates") or 6)
468
+ wardrobe_items_raw = form.get("wardrobe_items")
469
+ wardrobe_items = json.loads(wardrobe_items_raw) if wardrobe_items_raw else []
 
 
 
 
 
 
 
 
 
 
 
 
470
  audio_file = files.get("audio")
471
  audio_b64 = None
472
  if audio_file:
473
+ audio_bytes = audio_file.read()
474
+ import base64
475
+ audio_b64 = base64.b64encode(audio_bytes).decode("ascii")
 
 
 
476
  else:
477
  body = request.get_json(force=True)
478
  uid = (body.get("uid") or body.get("user_id") or "anon").strip() or "anon"
479
  user_inputs = body.get("user_inputs") or {}
480
+ max_c = int(body.get("max_candidates") or 6)
481
  wardrobe_items = body.get("wardrobe_items") or []
482
  audio_b64 = body.get("audio_b64")
483
  except Exception as e:
484
  log.exception("Invalid request payload: %s", e)
485
  return jsonify({"error": "invalid request payload"}), 400
486
 
487
+ # normalize wardrobe items -> ensure thumbnailUrl exists
 
 
 
 
488
  try:
489
+ normalized_items = []
490
  for it in wardrobe_items or []:
491
  if not isinstance(it, dict):
492
  normalized_items.append(it)
 
499
  except Exception:
500
  pass
501
 
502
+ # Try fetching from Firestore if wardrobe empty
503
  if not wardrobe_items:
504
  try:
505
+ wardrobe_items = fetch_wardrobe_from_firestore(uid) or []
506
  log.info("Fetched %d wardrobe items for uid=%s from Firestore", len(wardrobe_items), uid)
507
  except Exception as e:
508
+ log.warning("Failed to fetch wardrobe from Firestore: %s", e)
509
  wardrobe_items = []
510
 
511
+ if not isinstance(user_inputs, dict):
512
+ return jsonify({"error": "user_inputs must be an object"}), 400
513
  if not wardrobe_items:
514
  return jsonify({"error": "no wardrobe_items provided and none found in Firestore"}), 400
515
 
516
+ # build user summary (try Firestore, else heuristic)
 
517
  try:
518
+ fs = init_firestore_if_needed()
519
+ user_summary = None
520
+ if fs:
521
+ try:
522
+ doc_ref = fs.collection("users").document(uid)
523
+ doc = doc_ref.get()
524
+ if doc.exists:
525
+ data = doc.to_dict() or {}
526
+ user_summary = data.get("summary")
527
+ except Exception:
528
+ pass
529
+ if not user_summary:
530
+ moods = user_inputs.get("moods") or []
531
+ brands = user_inputs.get("keyBrands") or []
532
+ events = user_inputs.get("events") or []
533
+ parts = []
534
+ if moods:
535
+ parts.append("moods: " + ", ".join(moods[:3]))
536
+ if brands:
537
+ parts.append("likes brands: " + ", ".join(brands[:3]))
538
+ if events:
539
+ parts.append("often for: " + ", ".join(events[:2]))
540
+ user_summary = " & ".join(parts) if parts else "A user who likes practical, simple outfits."
541
+ except Exception as e:
542
+ log.exception("user summary build failed: %s", e)
543
+ user_summary = "A user who likes practical, simple outfits."
544
 
545
+ # fetch recent suggestions for penalization
546
+ past_week_items = []
547
  try:
548
+ fs = init_firestore_if_needed()
549
+ if fs:
550
+ cutoff = int(time.time()) - 7 * 86400
551
+ q = fs.collection("suggestions").where("uid", "==", uid).where("createdAtTs", ">=", cutoff).limit(200)
552
+ docs = q.get()
553
+ for d in docs:
554
+ dd = d.to_dict() or {}
555
+ for it in dd.get("items", []) or []:
556
+ past_week_items.append({"id": it.get("id"), "label": it.get("label")})
557
  except Exception as e:
558
+ log.warning("Failed to fetch recent suggestions: %s", e)
559
+
560
+ # candidate generation
561
+ try:
562
+ if client:
563
+ candidates = generate_candidates_with_gemini(wardrobe_items, user_inputs, {"summary": user_summary}, past_week_items, max_candidates=max_c)
564
+ else:
565
+ candidates = naive_generate_candidates(wardrobe_items, user_inputs, {"summary": user_summary}, past_week_items, max_candidates=max_c)
566
+ except Exception as e:
567
+ log.exception("candidate generation failed: %s", e)
568
  candidates = naive_generate_candidates(wardrobe_items, user_inputs, {"summary": user_summary}, past_week_items, max_candidates=max_c)
569
 
570
+ # refine
571
  constraints = {
572
  "require_brands": user_inputs.get("keyBrands") or [],
573
  "reject_brands": user_inputs.get("reject_brands") or user_inputs.get("excluded_brands") or [],
 
576
  }
577
  refine_result = refine_candidates_with_constraints(candidates, wardrobe_items, constraints)
578
 
 
579
  if refine_result.get("rerun_required") and constraints.get("allow_rerun"):
580
+ log.info("Refine requested rerun; performing deterministic rerun.")
581
+ alt_candidates = naive_generate_candidates(wardrobe_items, user_inputs, {"summary": user_summary}, past_week_items, max_candidates=max(8, max_c * 2))
582
+ refine_result = refine_candidates_with_constraints(alt_candidates, wardrobe_items, constraints)
 
 
 
583
 
584
  refined = refine_result.get("refined", [])
585
 
586
+ # finalize suggestion notes and build suggestion objects
587
  suggestions = []
588
  for cand in refined:
589
  try:
590
  cand_items = cand.get("items", []) or []
591
  cand_items = prioritize_top_item(cand_items)
592
  cand["items"] = cand_items
593
+
594
+ note = finalize_suggestion_note_with_gemini(cand, user_inputs, {"summary": user_summary}) if client else finalize_suggestion_note_with_gemini(cand, user_inputs, {"summary": user_summary})
595
  except Exception as e:
596
+ log.warning("Failed to produce final note for candidate %s: %s", cand.get("id"), e)
597
  note = cand.get("notes") or cand.get("reason") or "A curated outfit."
598
+
599
  thumb_urls = [it.get("thumbnailUrl") for it in cand.get("items", []) if it.get("thumbnailUrl")]
600
+
601
  suggestion = {
602
  "id": cand.get("id") or str(uuid.uuid4()),
603
  "items": cand.get("items", []),
 
607
  "score": cand.get("score"),
608
  "meta": {
609
  "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
610
+ "source": "single_suggest_pipeline_gemini" if client else "single_suggest_pipeline_naive",
611
  "user_inputs": user_inputs,
612
  },
613
  "uid": uid,
 
615
  }
616
  suggestions.append(suggestion)
617
 
618
+ # persist suggestions to Firestore (best-effort)
619
+ persisted_ids = []
620
  fs = init_firestore_if_needed()
 
621
  if fs and suggestions:
622
  try:
623
  col = fs.collection("suggestions")
 
625
  try:
626
  doc_id = s["id"]
627
  col.document(doc_id).set(s)
628
+ persisted_ids.append(doc_id)
629
  except Exception as se:
630
  log.warning("Failed to persist suggestion %s: %s", s.get("id"), se)
631
  except Exception as e:
632
+ log.warning("Failed to persist suggestions collection: %s", e)
633
 
634
  debug = {
635
  "candidates_count": len(candidates),
636
  "refined_count": len(refined),
637
+ "persisted": persisted_ids,
638
+ "rerun_hint": refine_result.get("rerun_hint", ""),
 
639
  }
640
 
 
641
  return jsonify({"ok": True, "user_summary": user_summary, "suggestions": suggestions, "debug": debug}), 200
642
 
 
643
  @app.route("/health", methods=["GET"])
644
  def health():
645
+ return jsonify({
646
+ "ok": True,
647
+ "time": int(time.time()),
648
+ "gemini": bool(client),
649
+ "firestore": bool(init_firestore_if_needed())
650
+ }), 200
 
 
 
 
 
 
 
 
651
 
652
  if __name__ == "__main__":
653
+ port = int(os.getenv("PORT", 7860))
654
+ log.info("Starting single-suggest server on 0.0.0.0:%d", port)
655
+ app.run(host="0.0.0.0", port=port, debug=False)