Pepguy commited on
Commit
22375a1
·
verified ·
1 Parent(s): 3668308

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +231 -239
app.py CHANGED
@@ -1,25 +1,15 @@
1
- # server_suggestions.py
2
  """
3
- Suggestion-focused server for outfit recommendations.
4
-
5
- Endpoints:
6
- - POST /suggest_candidates -> produce candidate outfits (Step 1)
7
- - POST /refine_candidates -> refine candidates with constraints (Step 2)
8
- - POST /finalize_suggestion -> finalize a candidate and return the suggestion (Step 3)
9
-
10
- This server intentionally omits image detection and upload endpoints — it expects
11
- the client to send pre-extracted wardrobe item summaries (id, analysis, title, thumbnail_url...).
12
-
13
- Features:
14
- - Optional Gemini integration (GEMINI_API_KEY)
15
- - Optional Firestore integration (FIREBASE_ADMIN_JSON) for:
16
- * reading/writing user summary at users/{uid}
17
- * reading recent suggestions in collection 'suggestions' for the past week
18
- All Firestore operations are wrapped with graceful failures so the service still works
19
- when Firestore isn't configured.
20
  """
21
 
22
  import os
 
23
  import json
24
  import logging
25
  import uuid
@@ -30,7 +20,7 @@ from typing import List, Dict, Any, Set, Optional
30
  from flask import Flask, request, jsonify
31
  from flask_cors import CORS
32
 
33
- # optional genai client
34
  try:
35
  from google import genai
36
  from google.genai import types
@@ -40,7 +30,7 @@ except Exception:
40
  types = None
41
  GENAI_AVAILABLE = False
42
 
43
- # optional firebase admin (Firestore)
44
  try:
45
  import firebase_admin
46
  from firebase_admin import credentials as fb_credentials
@@ -53,7 +43,7 @@ except Exception:
53
  FIREBASE_AVAILABLE = False
54
 
55
  logging.basicConfig(level=logging.INFO)
56
- log = logging.getLogger("suggestion-server")
57
 
58
  GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "").strip()
59
  if GEMINI_API_KEY and GENAI_AVAILABLE:
@@ -66,24 +56,19 @@ else:
66
  else:
67
  log.info("GEMINI_API_KEY not provided; using fallback heuristics.")
68
 
69
- # Firestore service account JSON in env variable (stringified JSON)
70
  FIREBASE_ADMIN_JSON = os.getenv("FIREBASE_ADMIN_JSON", "").strip()
71
 
72
- # Firestore runtime state
73
  _firestore_client = None
74
  _firebase_app = None
75
 
76
 
77
  def init_firestore_if_needed():
78
- """
79
- Initialize firebase admin and Firestore client if FIREBASE_ADMIN_JSON is set.
80
- Returns Firestore client or None on failure/unconfigured.
81
- """
82
  global _firestore_client, _firebase_app
83
  if _firestore_client is not None:
84
  return _firestore_client
85
  if not FIREBASE_ADMIN_JSON:
86
- log.info("No FIREBASE_ADMIN_JSON env var set; Firestore not initialized.")
87
  return None
88
  if not FIREBASE_AVAILABLE:
89
  log.warning("FIREBASE_ADMIN_JSON provided but firebase-admin SDK not installed; skip Firestore init.")
@@ -95,7 +80,6 @@ def init_firestore_if_needed():
95
  return None
96
  try:
97
  cred = fb_credentials.Certificate(sa_obj)
98
- # If app already exists, avoid re-initializing
99
  try:
100
  _firebase_app = firebase_admin.get_app()
101
  except Exception:
@@ -104,35 +88,15 @@ def init_firestore_if_needed():
104
  log.info("Initialized Firestore client.")
105
  return _firestore_client
106
  except Exception as e:
107
- log.exception("Failed to initialize Firestore: %s", e)
108
  return None
109
 
110
 
111
- # ---------- Category mapping (kept small and deterministic) ----------
112
  CATEGORIES = [
113
- "top",
114
- "shirt",
115
- "blouse",
116
- "tshirt",
117
- "sweater",
118
- "jacket",
119
- "coat",
120
- "dress",
121
- "skirt",
122
- "pants",
123
- "trousers",
124
- "shorts",
125
- "jeans",
126
- "shoe",
127
- "heels",
128
- "sneaker",
129
- "boot",
130
- "sandals",
131
- "bag",
132
- "belt",
133
- "hat",
134
- "accessory",
135
- "others",
136
  ]
137
 
138
 
@@ -154,8 +118,7 @@ def map_type_to_category(item_type: str) -> str:
154
  return "others"
155
 
156
 
157
- # ---------- Lightweight helpers for brands and matching ----------
158
-
159
  def _safe_item_brand(itm: Dict[str, Any]) -> str:
160
  analysis = itm.get("analysis") or {}
161
  brand = analysis.get("brand") if isinstance(analysis, dict) else None
@@ -164,26 +127,12 @@ def _safe_item_brand(itm: Dict[str, Any]) -> str:
164
  return str(brand).strip()
165
 
166
 
167
- def _item_matches_brand_constraints(itm: Dict[str, Any], require_brands: Set[str], reject_brands: Set[str]) -> bool:
168
- brand = _safe_item_brand(itm).lower()
169
- if require_brands and brand and brand not in require_brands:
170
- return False
171
- if reject_brands and brand and brand in reject_brands:
172
- return False
173
- return True
174
-
175
-
176
- # ---------- Naive candidate generator (fallback) ----------
177
-
178
  def naive_generate_candidates(wardrobe_items: List[Dict[str, Any]],
179
  user_inputs: Dict[str, Any],
180
  user_profile: Dict[str, Any],
181
  past_week_items: List[Dict[str, Any]],
182
  max_candidates: int = 6) -> List[Dict[str, Any]]:
183
- """
184
- Simple combinatorial generator that groups items by category and composes outfits.
185
- Returns a list of candidate dicts: { id, items: [...], score, reason, notes }.
186
- """
187
  grouped = {}
188
  for itm in wardrobe_items:
189
  title = (itm.get("title") or (itm.get("analysis") or {}).get("type") or itm.get("label") or "")
@@ -200,7 +149,6 @@ def naive_generate_candidates(wardrobe_items: List[Dict[str, Any]],
200
  outer = pick("jacket", 3) + pick("coat", 2)
201
  shoes = pick("shoe", 4) + pick("sneaker", 3) + pick("boot", 2) + pick("heels", 2)
202
  dresses = grouped.get("dress", [])[:4]
203
- accessories = pick("accessory", 3) + pick("bag", 2)
204
 
205
  seeds = dresses + tops
206
  if not seeds:
@@ -216,7 +164,7 @@ def naive_generate_candidates(wardrobe_items: List[Dict[str, Any]],
216
  items = [seed]
217
  if b and b.get("id") != seed.get("id"):
218
  items.append(b)
219
- if sh and sh.get("id") not in {seed.get("id"), b.get('id') if b else None}:
220
  items.append(sh)
221
  ids = tuple(sorted([str(x.get("id")) for x in items if x.get("id")]))
222
  if ids in used:
@@ -225,8 +173,8 @@ def naive_generate_candidates(wardrobe_items: List[Dict[str, Any]],
225
  score = sum(float(x.get("confidence", 0.5)) for x in items) / max(1, len(items))
226
  if any(x.get("id") in past_ids for x in items if x.get("id")):
227
  score -= 0.15
228
- # add small randomization
229
- score = max(0, min(1, score + (0.05 * (0.5 - (hash(ids) % 100) / 100.0))))
230
  candidate = {
231
  "id": str(uuid.uuid4()),
232
  "items": [{"id": x.get("id"), "label": x.get("label"), "title": x.get("title"), "thumbnail_url": x.get("thumbnail_url"), "analysis": x.get("analysis", {})} for x in items],
@@ -246,18 +194,16 @@ def naive_generate_candidates(wardrobe_items: List[Dict[str, Any]],
246
  return candidates
247
 
248
 
249
- # ---------- Gemini-backed candidate generator (optional) ----------
250
-
251
  def generate_candidates_with_gemini(wardrobe_items: List[Dict[str, Any]],
252
  user_inputs: Dict[str, Any],
253
  user_profile: Dict[str, Any],
254
  past_week_items: List[Dict[str, Any]],
255
  max_candidates: int = 6) -> List[Dict[str, Any]]:
256
  if not client:
257
- log.info("Gemini not configured; using naive generator.")
258
  return naive_generate_candidates(wardrobe_items, user_inputs, user_profile, past_week_items, max_candidates)
259
 
260
- # create concise wardrobe entries for the prompt
261
  summarized = []
262
  for it in wardrobe_items:
263
  a = it.get("analysis") or {}
@@ -271,11 +217,10 @@ def generate_candidates_with_gemini(wardrobe_items: List[Dict[str, Any]],
271
  })
272
 
273
  prompt = (
274
- "You are a stylist assistant. Given a user's WARDROBE array (id,type,summary,brand,tags),\n"
275
- "and USER_INPUT object containing moods, appearances, events, activity, preferred/excluded colors, keyBrands, etc.,\n"
276
- "and a list PAST_WEEK of recently used item ids, produce up to {max} candidate outfits.\n\n"
277
- "Return only valid JSON: {\"candidates\": [ {\"id\": \"..\", \"item_ids\": [..], \"score\": 0-1, \"notes\": \"one-line explanation\", \"short_reason\": \"phrase\"}, ... ]}\n\n"
278
- "Prefer diverse, practical combinations and avoid reusing PAST_WEEK item ids when possible.\n\n"
279
  "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 [])]))
280
  )
281
 
@@ -330,15 +275,10 @@ def generate_candidates_with_gemini(wardrobe_items: List[Dict[str, Any]],
330
  return naive_generate_candidates(wardrobe_items, user_inputs, user_profile, past_week_items, max_candidates)
331
 
332
 
333
- # ---------- Refinement logic (Step 2) ----------
334
-
335
  def refine_candidates_with_constraints(candidates: List[Dict[str, Any]],
336
  wardrobe_items: List[Dict[str, Any]],
337
  constraints: Dict[str, Any]) -> Dict[str, Any]:
338
- """
339
- Apply brand constraints, schedule constraints and attach item metadata.
340
- Returns dict with keys: refined, rerun_required (bool), rerun_hint, removed (list).
341
- """
342
  require_brands = set([b.lower() for b in (constraints.get("require_brands") or []) if b])
343
  reject_brands = set([b.lower() for b in (constraints.get("reject_brands") or []) if b])
344
  past_ids = set([x.get("id") for x in (constraints.get("past_week_items") or []) if x.get("id")])
@@ -350,33 +290,28 @@ def refine_candidates_with_constraints(candidates: List[Dict[str, Any]],
350
 
351
  for cand in candidates:
352
  items = cand.get("items") or []
353
- # If items are lightweight (only id), resolve them
354
- resolved_items = []
355
  for i in items:
356
  iid = str(i.get("id"))
357
  full = id_map.get(iid)
358
  if full:
359
- resolved_items.append(full)
360
  else:
361
- resolved_items.append(i)
362
- # Check brand constraints
363
  if require_brands:
364
- if not any((_safe_item_brand(it).lower() in require_brands) for it in resolved_items):
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_items):
369
  removed.append({"id": cand.get("id"), "reason": "contains rejected brand"})
370
  continue
371
- # Check schedule conflict
372
- if past_ids and any((it.get("id") in past_ids) for it in resolved_items):
373
  if not allow_rerun:
374
  removed.append({"id": cand.get("id"), "reason": "uses recent items"})
375
  continue
376
  else:
377
  cand["_conflict_with_schedule"] = True
378
-
379
- # attach metadata
380
  cand["items"] = [
381
  {
382
  "id": it.get("id"),
@@ -385,20 +320,18 @@ def refine_candidates_with_constraints(candidates: List[Dict[str, Any]],
385
  "thumbnail_url": it.get("thumbnail_url"),
386
  "analysis": it.get("analysis", {}),
387
  "confidence": it.get("confidence", 0.5),
388
- }
389
- for it in resolved_items
390
  ]
391
  refined.append(cand)
392
 
393
  if not refined:
394
- hint = "All candidates filtered out. Consider loosening brand/schedule constraints or allow rerun."
395
  return {"refined": [], "rerun_required": True, "rerun_hint": hint, "removed": removed}
396
  refined.sort(key=lambda c: c.get("score", 0), reverse=True)
397
  return {"refined": refined, "rerun_required": False, "rerun_hint": "", "removed": removed}
398
 
399
 
400
- # ---------- Final note generation (Step 3) ----------
401
-
402
  def finalize_suggestion_note_with_gemini(candidate: Dict[str, Any], user_inputs: Dict[str, Any], user_profile: Dict[str, Any]) -> str:
403
  if not client:
404
  moods = ", ".join(user_inputs.get("moods", [])[:2])
@@ -427,23 +360,15 @@ def finalize_suggestion_note_with_gemini(candidate: Dict[str, Any], user_inputs:
427
  log.exception("Gemini finalize note failed: %s", e)
428
  moods = ", ".join(user_inputs.get("moods", [])[:2])
429
  events = ", ".join(user_inputs.get("events", [])[:1])
430
- return f"Because you chose {moods or 'your mood'}, for {events or 'your event'} — practical and stylish."
431
-
432
 
433
- # ---------- Firestore helpers for user summary & recent suggestions (graceful) ----------
434
 
 
435
  def get_or_create_user_summary(uid: str, fallback_from_inputs: Dict[str, Any]) -> str:
436
- """
437
- Attempts to read users/{uid} document's 'summary' field.
438
- If missing or Firestore not configured, creates a heuristic summary and writes it back (if possible).
439
- Returns the summary string.
440
- """
441
  fs = init_firestore_if_needed()
442
  gen_summary = None
443
  try:
444
  if not fs:
445
- # Firestore not available
446
- log.info("Firestore not available. Building local user summary.")
447
  gen_summary = _heuristic_summary_from_inputs(fallback_from_inputs)
448
  return gen_summary
449
  doc_ref = fs.collection("users").document(uid)
@@ -453,22 +378,20 @@ def get_or_create_user_summary(uid: str, fallback_from_inputs: Dict[str, Any]) -
453
  summary = data.get("summary")
454
  if summary:
455
  return summary
456
- # if doc exists but no summary, create one
457
  gen_summary = _heuristic_summary_from_inputs(fallback_from_inputs)
458
  try:
459
  doc_ref.set({"summary": gen_summary, "updatedAt": int(time.time())}, merge=True)
460
  log.info("Wrote generated summary into users/%s", uid)
461
  except Exception as e:
462
- log.warning("Failed to write generated summary to Firestore: %s", e)
463
  return gen_summary
464
  else:
465
- # doc doesn't exist -> create with heuristic summary
466
  gen_summary = _heuristic_summary_from_inputs(fallback_from_inputs)
467
  try:
468
  doc_ref.set({"summary": gen_summary, "createdAt": int(time.time())})
469
  log.info("Created users/%s with summary", uid)
470
  except Exception as e:
471
- log.warning("Failed to create user doc in Firestore: %s", e)
472
  return gen_summary
473
  except Exception as e:
474
  log.exception("Error fetching/creating user summary: %s", e)
@@ -476,11 +399,6 @@ def get_or_create_user_summary(uid: str, fallback_from_inputs: Dict[str, Any]) -
476
 
477
 
478
  def fetch_recent_suggestions(uid: str, days: int = 7) -> List[Dict[str, Any]]:
479
- """
480
- Attempts to fetch recent suggestions from collection 'suggestions' for uid within `days`.
481
- Returns a list of item dicts used recently (flattened items).
482
- If Firestore not available or query fails, returns empty list.
483
- """
484
  fs = init_firestore_if_needed()
485
  if not fs:
486
  return []
@@ -499,6 +417,58 @@ def fetch_recent_suggestions(uid: str, days: int = 7) -> List[Dict[str, Any]]:
499
  return []
500
 
501
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
502
  def _heuristic_summary_from_inputs(user_inputs: Dict[str, Any]) -> str:
503
  moods = user_inputs.get("moods") or []
504
  brands = user_inputs.get("keyBrands") or []
@@ -515,37 +485,85 @@ def _heuristic_summary_from_inputs(user_inputs: Dict[str, Any]) -> str:
515
  return " & ".join(parts)
516
 
517
 
518
- # ---------- Flask app and endpoints ----------
519
-
520
  app = Flask(__name__)
521
  CORS(app)
522
 
523
 
524
- @app.route("/suggest_candidates", methods=["POST"])
525
- def suggest_candidates():
526
  """
527
- Step 1: Accepts wardrobe items and user inputs, returns candidate outfits.
528
- Body JSON:
529
- - uid (optional)
530
- - wardrobe_items: array of items { id, title, analysis: {type,summary,brand,tags}, thumbnail_url (optional), confidence (optional) }
531
- - user_inputs: object
532
- - max_candidates: optional int
533
- Response: { ok: True, candidates: [...], user_summary: "...", debug: {...} }
 
 
534
  """
 
535
  try:
536
- body = request.get_json(force=True)
537
- except Exception:
538
- return jsonify({"error": "invalid json"}), 400
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
539
 
540
- uid = (body.get("uid") or request.args.get("uid") or "anon").strip() or "anon"
541
- wardrobe_items = body.get("wardrobe_items") or []
542
- user_inputs = body.get("user_inputs") or {}
543
- max_c = int(body.get("max_candidates") or 6)
 
 
 
 
544
 
545
- if not isinstance(wardrobe_items, list) or not isinstance(user_inputs, dict):
546
- return jsonify({"error": "wardrobe_items(list) and user_inputs(object) required"}), 400
 
 
 
547
 
548
- # attempt to fetch user summary and recent usage from Firestore (graceful)
549
  try:
550
  user_summary = get_or_create_user_summary(uid, user_inputs)
551
  except Exception as e:
@@ -558,114 +576,88 @@ def suggest_candidates():
558
  log.warning("fetch_recent_suggestions failed: %s", e)
559
  past_week_items = []
560
 
561
- # generate candidates (Gemini or naive)
562
  try:
563
  candidates = generate_candidates_with_gemini(wardrobe_items, user_inputs, {"summary": user_summary}, past_week_items, max_candidates=max_c)
564
  except Exception as e:
565
  log.exception("candidate generation failed: %s", e)
566
  candidates = naive_generate_candidates(wardrobe_items, user_inputs, {"summary": user_summary}, past_week_items, max_candidates=max_c)
567
 
568
- # ensure thumbnails exist as None if missing
569
- for c in candidates:
570
- for it in c.get("items", []):
571
- it.setdefault("thumbnail_url", it.get("thumbnail_url") or None)
572
-
573
- return jsonify({"ok": True, "candidates": candidates, "user_summary": user_summary}), 200
574
-
575
-
576
- @app.route("/refine_candidates", methods=["POST"])
577
- def refine_candidates():
578
- """
579
- Step 2: Apply constraints and attach item metadata.
580
- Body JSON:
581
- - wardrobe_items: full items list
582
- - candidates: candidates returned by /suggest_candidates
583
- - constraints: { require_brands: [...], reject_brands: [...], past_week_items: [...], allow_rerun: bool }
584
- Response: { ok: True, refined: [...], rerun_required: bool, rerun_hint: str, removed: [...] }
585
- """
586
- try:
587
- body = request.get_json(force=True)
588
- except Exception:
589
- return jsonify({"error": "invalid json"}), 400
590
-
591
- wardrobe_items = body.get("wardrobe_items") or []
592
- candidates = body.get("candidates") or []
593
- constraints = body.get("constraints") or {}
594
-
595
- if not isinstance(wardrobe_items, list) or not isinstance(candidates, list):
596
- return jsonify({"error": "wardrobe_items(list) and candidates(list) required"}), 400
597
-
598
- try:
599
- result = refine_candidates_with_constraints(candidates, wardrobe_items, constraints)
600
- return jsonify({"ok": True, **result}), 200
601
- except Exception as e:
602
- log.exception("refine_candidates failed: %s", e)
603
- return jsonify({"error": "internal", "detail": str(e)}), 500
604
 
 
 
 
 
 
 
 
 
 
605
 
606
- @app.route("/finalize_suggestion", methods=["POST"])
607
- def finalize_suggestion():
608
- """
609
- Step 3: Finalize a candidate and return a suggestion object.
610
- Body JSON:
611
- - uid (optional)
612
- - candidate (object) OR candidate_id + candidates (list)
613
- - user_inputs (optional)
614
- - user_profile (optional)
615
- Response:
616
- { ok: True, suggestion: { id, items, thumbnail_urls, note, createdAt, meta } }
617
- """
618
- try:
619
- body = request.get_json(force=True)
620
- except Exception:
621
- return jsonify({"error": "invalid json"}), 400
622
-
623
- uid = (body.get("uid") or request.args.get("uid") or "anon").strip() or "anon"
624
- candidate = body.get("candidate")
625
- if not candidate:
626
- candidate_id = body.get("candidate_id")
627
- candidates = body.get("candidates") or []
628
- if candidate_id and isinstance(candidates, list):
629
- candidate = next((c for c in candidates if c.get("id") == candidate_id), None)
630
-
631
- if not candidate:
632
- return jsonify({"error": "candidate required (object or candidate_id + candidates)"}), 400
633
-
634
- user_inputs = body.get("user_inputs") or {}
635
- user_profile = body.get("user_profile") or {}
636
-
637
- # attach thumbnails list
638
- thumb_urls = [it.get("thumbnail_url") for it in candidate.get("items", []) if it.get("thumbnail_url")]
639
-
640
- # create final note (Gemini or heuristic)
641
- note = finalize_suggestion_note_with_gemini(candidate, user_inputs, user_profile)
642
-
643
- suggestion = {
644
- "id": candidate.get("id") or str(uuid.uuid4()),
645
- "items": candidate.get("items", []),
646
- "thumbnail_urls": thumb_urls,
647
- "note": note,
648
- "createdAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
649
- "createdAtTs": int(time.time()),
650
- "meta": {"source": "server_pipeline", "user_inputs": user_inputs, "user_profile": user_profile},
651
- "uid": uid,
652
- }
653
 
654
- # Try to persist suggestion into Firestore.collection("suggestions") if configured (graceful)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
655
  fs = init_firestore_if_needed()
656
- if fs:
 
657
  try:
658
  col = fs.collection("suggestions")
659
- doc_id = suggestion["id"]
660
- col.document(doc_id).set(suggestion)
661
- log.info("Persisted suggestion %s for uid=%s", doc_id, uid)
 
 
 
 
662
  except Exception as e:
663
- log.warning("Failed to persist suggestion to Firestore: %s", e)
 
 
 
 
 
 
 
664
 
665
- return jsonify({"ok": True, "suggestion": suggestion}), 200
666
 
667
 
668
- # Basic health endpoint
669
  @app.route("/health", methods=["GET"])
670
  def health():
671
  return jsonify({"ok": True, "time": int(time.time()), "gemini": bool(client), "firestore": bool(init_firestore_if_needed())}), 200
@@ -673,5 +665,5 @@ def health():
673
 
674
  if __name__ == "__main__":
675
  port = int(os.getenv("PORT", 7860))
676
- log.info("Starting suggestion server on 0.0.0.0:%d", port)
677
  app.run(host="0.0.0.0", port=port, debug=True)
 
1
+ # server_single_suggest.py
2
  """
3
+ Single-endpoint suggestion server.
4
+
5
+ Endpoint:
6
+ - POST /suggest -> accepts large form (wardrobe_items optional, user_inputs required, optional audio file)
7
+ runs full pipeline: fetch user summary, fetch recent history, generate candidates,
8
+ refine candidates, finalize suggestions (with one-line notes), persist suggestions.
 
 
 
 
 
 
 
 
 
 
 
9
  """
10
 
11
  import os
12
+ import io
13
  import json
14
  import logging
15
  import uuid
 
20
  from flask import Flask, request, jsonify
21
  from flask_cors import CORS
22
 
23
+ # Optional Gemini client
24
  try:
25
  from google import genai
26
  from google.genai import types
 
30
  types = None
31
  GENAI_AVAILABLE = False
32
 
33
+ # Optional Firebase Admin (Firestore)
34
  try:
35
  import firebase_admin
36
  from firebase_admin import credentials as fb_credentials
 
43
  FIREBASE_AVAILABLE = False
44
 
45
  logging.basicConfig(level=logging.INFO)
46
+ log = logging.getLogger("suggestion-single-server")
47
 
48
  GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "").strip()
49
  if GEMINI_API_KEY and GENAI_AVAILABLE:
 
56
  else:
57
  log.info("GEMINI_API_KEY not provided; using fallback heuristics.")
58
 
59
+ # Firestore service account JSON (stringified JSON expected)
60
  FIREBASE_ADMIN_JSON = os.getenv("FIREBASE_ADMIN_JSON", "").strip()
61
 
 
62
  _firestore_client = None
63
  _firebase_app = None
64
 
65
 
66
  def init_firestore_if_needed():
 
 
 
 
67
  global _firestore_client, _firebase_app
68
  if _firestore_client is not None:
69
  return _firestore_client
70
  if not FIREBASE_ADMIN_JSON:
71
+ log.info("No FIREBASE_ADMIN_JSON set; Firestore not initialized.")
72
  return None
73
  if not FIREBASE_AVAILABLE:
74
  log.warning("FIREBASE_ADMIN_JSON provided but firebase-admin SDK not installed; skip Firestore init.")
 
80
  return None
81
  try:
82
  cred = fb_credentials.Certificate(sa_obj)
 
83
  try:
84
  _firebase_app = firebase_admin.get_app()
85
  except Exception:
 
88
  log.info("Initialized Firestore client.")
89
  return _firestore_client
90
  except Exception as e:
91
+ log.exception("Failed to init Firestore: %s", e)
92
  return None
93
 
94
 
95
+ # ---------- Category mapping ----------
96
  CATEGORIES = [
97
+ "top", "shirt", "blouse", "tshirt", "sweater", "jacket", "coat", "dress", "skirt",
98
+ "pants", "trousers", "shorts", "jeans", "shoe", "heels", "sneaker", "boot", "sandals",
99
+ "bag", "belt", "hat", "accessory", "others",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  ]
101
 
102
 
 
118
  return "others"
119
 
120
 
121
+ # ---------- Brand helpers ----------
 
122
  def _safe_item_brand(itm: Dict[str, Any]) -> str:
123
  analysis = itm.get("analysis") or {}
124
  brand = analysis.get("brand") if isinstance(analysis, dict) else None
 
127
  return str(brand).strip()
128
 
129
 
130
+ # ---------- Simple local candidate generator ----------
 
 
 
 
 
 
 
 
 
 
131
  def naive_generate_candidates(wardrobe_items: List[Dict[str, Any]],
132
  user_inputs: Dict[str, Any],
133
  user_profile: Dict[str, Any],
134
  past_week_items: List[Dict[str, Any]],
135
  max_candidates: int = 6) -> List[Dict[str, Any]]:
 
 
 
 
136
  grouped = {}
137
  for itm in wardrobe_items:
138
  title = (itm.get("title") or (itm.get("analysis") or {}).get("type") or itm.get("label") or "")
 
149
  outer = pick("jacket", 3) + pick("coat", 2)
150
  shoes = pick("shoe", 4) + pick("sneaker", 3) + pick("boot", 2) + pick("heels", 2)
151
  dresses = grouped.get("dress", [])[:4]
 
152
 
153
  seeds = dresses + tops
154
  if not seeds:
 
164
  items = [seed]
165
  if b and b.get("id") != seed.get("id"):
166
  items.append(b)
167
+ if sh and sh.get("id") not in {seed.get("id"), b.get("id") if b else None}:
168
  items.append(sh)
169
  ids = tuple(sorted([str(x.get("id")) for x in items if x.get("id")]))
170
  if ids in used:
 
173
  score = sum(float(x.get("confidence", 0.5)) for x in items) / max(1, len(items))
174
  if any(x.get("id") in past_ids for x in items if x.get("id")):
175
  score -= 0.15
176
+ # small deterministic jitter
177
+ score = max(0, min(1, score + (0.02 * ((hash(ids) % 100) / 100.0))))
178
  candidate = {
179
  "id": str(uuid.uuid4()),
180
  "items": [{"id": x.get("id"), "label": x.get("label"), "title": x.get("title"), "thumbnail_url": x.get("thumbnail_url"), "analysis": x.get("analysis", {})} for x in items],
 
194
  return candidates
195
 
196
 
197
+ # ---------- Gemini-backed generator (optional) ----------
 
198
  def generate_candidates_with_gemini(wardrobe_items: List[Dict[str, Any]],
199
  user_inputs: Dict[str, Any],
200
  user_profile: Dict[str, Any],
201
  past_week_items: List[Dict[str, Any]],
202
  max_candidates: int = 6) -> List[Dict[str, Any]]:
203
  if not client:
204
+ log.info("Gemini disabled; using naive generator.")
205
  return naive_generate_candidates(wardrobe_items, user_inputs, user_profile, past_week_items, max_candidates)
206
 
 
207
  summarized = []
208
  for it in wardrobe_items:
209
  a = it.get("analysis") or {}
 
217
  })
218
 
219
  prompt = (
220
+ "You are a stylist assistant. Given WARDROBE array (id,type,summary,brand,tags),\n"
221
+ "USER_INPUT (moods, appearances, events, activity, preferred/excluded colors, keyBrands, etc.),\n"
222
+ "and PAST_WEEK (recent item ids), produce up to {max} candidate outfits.\n\n"
223
+ "Return only valid JSON: {\"candidates\": [ {\"id\": \"..\", \"item_ids\": [..], \"score\": 0-1, \"notes\": \"one-line\", \"short_reason\": \"phrase\"}, ... ]}\n\n"
 
224
  "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 [])]))
225
  )
226
 
 
275
  return naive_generate_candidates(wardrobe_items, user_inputs, user_profile, past_week_items, max_candidates)
276
 
277
 
278
+ # ---------- Refinement ----------
 
279
  def refine_candidates_with_constraints(candidates: List[Dict[str, Any]],
280
  wardrobe_items: List[Dict[str, Any]],
281
  constraints: Dict[str, Any]) -> Dict[str, Any]:
 
 
 
 
282
  require_brands = set([b.lower() for b in (constraints.get("require_brands") or []) if b])
283
  reject_brands = set([b.lower() for b in (constraints.get("reject_brands") or []) if b])
284
  past_ids = set([x.get("id") for x in (constraints.get("past_week_items") or []) if x.get("id")])
 
290
 
291
  for cand in candidates:
292
  items = cand.get("items") or []
293
+ resolved = []
 
294
  for i in items:
295
  iid = str(i.get("id"))
296
  full = id_map.get(iid)
297
  if full:
298
+ resolved.append(full)
299
  else:
300
+ resolved.append(i)
 
301
  if require_brands:
302
+ if not any((_safe_item_brand(it).lower() in require_brands) for it in resolved):
303
  removed.append({"id": cand.get("id"), "reason": "missing required brand"})
304
  continue
305
  if reject_brands:
306
+ if any((_safe_item_brand(it).lower() in reject_brands) for it in resolved):
307
  removed.append({"id": cand.get("id"), "reason": "contains rejected brand"})
308
  continue
309
+ if past_ids and any((it.get("id") in past_ids) for it in resolved):
 
310
  if not allow_rerun:
311
  removed.append({"id": cand.get("id"), "reason": "uses recent items"})
312
  continue
313
  else:
314
  cand["_conflict_with_schedule"] = True
 
 
315
  cand["items"] = [
316
  {
317
  "id": it.get("id"),
 
320
  "thumbnail_url": it.get("thumbnail_url"),
321
  "analysis": it.get("analysis", {}),
322
  "confidence": it.get("confidence", 0.5),
323
+ } for it in resolved
 
324
  ]
325
  refined.append(cand)
326
 
327
  if not refined:
328
+ hint = "All candidates filtered out. Consider loosening constraints or allow rerun."
329
  return {"refined": [], "rerun_required": True, "rerun_hint": hint, "removed": removed}
330
  refined.sort(key=lambda c: c.get("score", 0), reverse=True)
331
  return {"refined": refined, "rerun_required": False, "rerun_hint": "", "removed": removed}
332
 
333
 
334
+ # ---------- Final note ----------
 
335
  def finalize_suggestion_note_with_gemini(candidate: Dict[str, Any], user_inputs: Dict[str, Any], user_profile: Dict[str, Any]) -> str:
336
  if not client:
337
  moods = ", ".join(user_inputs.get("moods", [])[:2])
 
360
  log.exception("Gemini finalize note failed: %s", e)
361
  moods = ", ".join(user_inputs.get("moods", [])[:2])
362
  events = ", ".join(user_inputs.get("events", [])[:1])
363
+ return f"Because you chose {moods or 'your mood'} for {events or 'your event'} — practical and stylish."
 
364
 
 
365
 
366
+ # ---------- Firestore helpers ----------
367
  def get_or_create_user_summary(uid: str, fallback_from_inputs: Dict[str, Any]) -> str:
 
 
 
 
 
368
  fs = init_firestore_if_needed()
369
  gen_summary = None
370
  try:
371
  if not fs:
 
 
372
  gen_summary = _heuristic_summary_from_inputs(fallback_from_inputs)
373
  return gen_summary
374
  doc_ref = fs.collection("users").document(uid)
 
378
  summary = data.get("summary")
379
  if summary:
380
  return summary
 
381
  gen_summary = _heuristic_summary_from_inputs(fallback_from_inputs)
382
  try:
383
  doc_ref.set({"summary": gen_summary, "updatedAt": int(time.time())}, merge=True)
384
  log.info("Wrote generated summary into users/%s", uid)
385
  except Exception as e:
386
+ log.warning("Failed to write generated summary: %s", e)
387
  return gen_summary
388
  else:
 
389
  gen_summary = _heuristic_summary_from_inputs(fallback_from_inputs)
390
  try:
391
  doc_ref.set({"summary": gen_summary, "createdAt": int(time.time())})
392
  log.info("Created users/%s with summary", uid)
393
  except Exception as e:
394
+ log.warning("Failed to create user doc: %s", e)
395
  return gen_summary
396
  except Exception as e:
397
  log.exception("Error fetching/creating user summary: %s", e)
 
399
 
400
 
401
  def fetch_recent_suggestions(uid: str, days: int = 7) -> List[Dict[str, Any]]:
 
 
 
 
 
402
  fs = init_firestore_if_needed()
403
  if not fs:
404
  return []
 
417
  return []
418
 
419
 
420
+ def fetch_wardrobe_from_firestore(uid: str) -> List[Dict[str, Any]]:
421
+ """
422
+ Try to fetch wardrobe items for uid from Firestore.
423
+ Tries:
424
+ - users/{uid}/wardrobe subcollection
425
+ - collection 'wardrobe' where field 'uid' == uid (documents representing items)
426
+ Returns list of items or empty list.
427
+ """
428
+ fs = init_firestore_if_needed()
429
+ if not fs:
430
+ return []
431
+ try:
432
+ # try subcollection first
433
+ subcol = fs.collection("users").document(uid).collection("wardrobe")
434
+ docs = subcol.limit(200).get()
435
+ items = []
436
+ for d in docs:
437
+ dd = d.to_dict() or {}
438
+ items.append({
439
+ "id": dd.get("id") or d.id,
440
+ "label": dd.get("label") or dd.get("title") or "item",
441
+ "title": dd.get("title") or dd.get("label") or "",
442
+ "thumbnail_url": dd.get("thumbnail_url"),
443
+ "analysis": dd.get("analysis", {}),
444
+ "confidence": dd.get("confidence", 0.8),
445
+ })
446
+ if items:
447
+ return items
448
+ except Exception as e:
449
+ log.warning("users/{uid}/wardrobe subcollection read failed: %s", e)
450
+
451
+ try:
452
+ # fallback: global 'wardrobe' collection where docs have uid field
453
+ q = fs.collection("wardrobe").where("uid", "==", uid).limit(500)
454
+ docs = q.get()
455
+ items = []
456
+ for d in docs:
457
+ dd = d.to_dict() or {}
458
+ items.append({
459
+ "id": dd.get("id") or d.id,
460
+ "label": dd.get("label") or dd.get("title") or "item",
461
+ "title": dd.get("title") or dd.get("label") or "",
462
+ "thumbnail_url": dd.get("thumbnail_url"),
463
+ "analysis": dd.get("analysis", {}),
464
+ "confidence": dd.get("confidence", 0.8),
465
+ })
466
+ return items
467
+ except Exception as e:
468
+ log.warning("wardrobe collection query failed: %s", e)
469
+ return []
470
+
471
+
472
  def _heuristic_summary_from_inputs(user_inputs: Dict[str, Any]) -> str:
473
  moods = user_inputs.get("moods") or []
474
  brands = user_inputs.get("keyBrands") or []
 
485
  return " & ".join(parts)
486
 
487
 
488
+ # ---------- Flask app ----------
 
489
  app = Flask(__name__)
490
  CORS(app)
491
 
492
 
493
+ @app.route("/suggest", methods=["POST"])
494
+ def suggest_all():
495
  """
496
+ Single endpoint to run full pipeline.
497
+ Accepts JSON or multipart/form-data.
498
+
499
+ Expected fields (JSON or form):
500
+ - uid (optional) -- string
501
+ - wardrobe_items (optional) -- JSON array (if absent we'll try Firestore)
502
+ - user_inputs (required) -- JSON object with moods, appearances, events, activity, preferred/excluded colors, keyBrands, comfortAttributes, include/exclude categories, allow_rerun flag optional
503
+ - max_candidates (optional) -- int
504
+ - audio file key 'audio' (optional) in multipart/form-data OR audio_b64 in JSON (optional)
505
  """
506
+ is_multipart = request.content_type and request.content_type.startswith("multipart/form-data")
507
  try:
508
+ if is_multipart:
509
+ # access form fields and files
510
+ form = request.form
511
+ files = request.files
512
+ uid = (form.get("uid") or form.get("user_id") or "anon").strip() or "anon"
513
+ user_inputs = {}
514
+ try:
515
+ ui_raw = form.get("user_inputs")
516
+ if ui_raw:
517
+ user_inputs = json.loads(ui_raw)
518
+ else:
519
+ # collect obvious form fields into user_inputs if given
520
+ user_inputs = {}
521
+ except Exception:
522
+ user_inputs = {}
523
+ max_c = int(form.get("max_candidates") or 6)
524
+ wardrobe_items = []
525
+ w_raw = form.get("wardrobe_items")
526
+ if w_raw:
527
+ try:
528
+ wardrobe_items = json.loads(w_raw)
529
+ except Exception:
530
+ wardrobe_items = []
531
+ # audio file
532
+ audio_file = files.get("audio")
533
+ audio_b64 = None
534
+ if audio_file:
535
+ try:
536
+ audio_bytes = audio_file.read()
537
+ audio_b64 = base64.b64encode(audio_bytes).decode("ascii")
538
+ except Exception:
539
+ audio_b64 = None
540
+ else:
541
+ body = request.get_json(force=True)
542
+ uid = (body.get("uid") or body.get("user_id") or "anon").strip() or "anon"
543
+ user_inputs = body.get("user_inputs") or {}
544
+ max_c = int(body.get("max_candidates") or 6)
545
+ wardrobe_items = body.get("wardrobe_items") or []
546
+ audio_b64 = body.get("audio_b64")
547
+ except Exception as e:
548
+ log.exception("Invalid request payload: %s", e)
549
+ return jsonify({"error": "invalid request payload"}), 400
550
 
551
+ # if wardrobe_items empty, attempt to fetch from Firestore for uid
552
+ if not wardrobe_items:
553
+ try:
554
+ wardrobe_items = fetch_wardrobe_from_firestore(uid)
555
+ log.info("Fetched %d wardrobe items for uid=%s from Firestore", len(wardrobe_items), uid)
556
+ except Exception as e:
557
+ log.warning("Failed to fetch wardrobe from Firestore: %s", e)
558
+ wardrobe_items = []
559
 
560
+ if not isinstance(user_inputs, dict):
561
+ return jsonify({"error": "user_inputs must be an object"}), 400
562
+ if not wardrobe_items:
563
+ # no wardrobe info available -> cannot suggest
564
+ return jsonify({"error": "no wardrobe_items provided and none found in Firestore"}), 400
565
 
566
+ # Step 0: fetch or create user summary and recent items
567
  try:
568
  user_summary = get_or_create_user_summary(uid, user_inputs)
569
  except Exception as e:
 
576
  log.warning("fetch_recent_suggestions failed: %s", e)
577
  past_week_items = []
578
 
579
+ # Step 1: generate candidates (Gemini or naive)
580
  try:
581
  candidates = generate_candidates_with_gemini(wardrobe_items, user_inputs, {"summary": user_summary}, past_week_items, max_candidates=max_c)
582
  except Exception as e:
583
  log.exception("candidate generation failed: %s", e)
584
  candidates = naive_generate_candidates(wardrobe_items, user_inputs, {"summary": user_summary}, past_week_items, max_candidates=max_c)
585
 
586
+ # Step 2: refine candidates using constraints from user_inputs
587
+ # create constraints object heuristically from user_inputs
588
+ constraints = {
589
+ "require_brands": user_inputs.get("keyBrands") or [],
590
+ "reject_brands": user_inputs.get("reject_brands") or user_inputs.get("excluded_brands") or [],
591
+ "past_week_items": past_week_items,
592
+ "allow_rerun": bool(user_inputs.get("allow_rerun", True)),
593
+ }
594
+ refine_result = refine_candidates_with_constraints(candidates, wardrobe_items, constraints)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
595
 
596
+ # If refine indicates rerun_required and allow_rerun, try a looser rerun
597
+ if refine_result.get("rerun_required") and constraints.get("allow_rerun"):
598
+ log.info("Refine required rerun; performing looser candidate generation and refine again.")
599
+ # generate more candidates (bigger pool) with naive generator (less strict)
600
+ try:
601
+ alt_candidates = naive_generate_candidates(wardrobe_items, user_inputs, {"summary": user_summary}, past_week_items, max_candidates=max(8, max_c * 2))
602
+ refine_result = refine_candidates_with_constraints(alt_candidates, wardrobe_items, constraints)
603
+ except Exception as e:
604
+ log.exception("Rerun generation failed: %s", e)
605
 
606
+ refined = refine_result.get("refined", [])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
607
 
608
+ # Step 3: finalize suggestions (note per candidate)
609
+ suggestions = []
610
+ for cand in refined:
611
+ try:
612
+ note = finalize_suggestion_note_with_gemini(cand, user_inputs, {"summary": user_summary})
613
+ except Exception as e:
614
+ log.warning("Failed to produce final note for candidate %s: %s", cand.get("id"), e)
615
+ note = cand.get("notes") or cand.get("reason") or "A curated outfit."
616
+
617
+ thumb_urls = [it.get("thumbnail_url") for it in cand.get("items", []) if it.get("thumbnail_url")]
618
+
619
+ suggestion = {
620
+ "id": cand.get("id") or str(uuid.uuid4()),
621
+ "items": cand.get("items", []),
622
+ "thumbnail_urls": thumb_urls,
623
+ "note": note,
624
+ "score": cand.get("score"),
625
+ "meta": {
626
+ "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
627
+ "source": "single_suggest_pipeline",
628
+ "user_inputs": user_inputs,
629
+ },
630
+ "uid": uid,
631
+ "createdAtTs": int(time.time()),
632
+ }
633
+ suggestions.append(suggestion)
634
+
635
+ # persist suggestions to Firestore (best-effort)
636
  fs = init_firestore_if_needed()
637
+ persisted_ids = []
638
+ if fs and suggestions:
639
  try:
640
  col = fs.collection("suggestions")
641
+ for s in suggestions:
642
+ try:
643
+ doc_id = s["id"]
644
+ col.document(doc_id).set(s)
645
+ persisted_ids.append(doc_id)
646
+ except Exception as se:
647
+ log.warning("Failed to persist suggestion %s: %s", s.get("id"), se)
648
  except Exception as e:
649
+ log.warning("Failed to persist suggestions collection: %s", e)
650
+
651
+ debug = {
652
+ "candidates_count": len(candidates),
653
+ "refined_count": len(refined),
654
+ "persisted": persisted_ids,
655
+ "rerun_hint": refine_result.get("rerun_hint", ""),
656
+ }
657
 
658
+ return jsonify({"ok": True, "user_summary": user_summary, "suggestions": suggestions, "debug": debug}), 200
659
 
660
 
 
661
  @app.route("/health", methods=["GET"])
662
  def health():
663
  return jsonify({"ok": True, "time": int(time.time()), "gemini": bool(client), "firestore": bool(init_firestore_if_needed())}), 200
 
665
 
666
  if __name__ == "__main__":
667
  port = int(os.getenv("PORT", 7860))
668
+ log.info("Starting single-suggest server on 0.0.0.0:%d", port)
669
  app.run(host="0.0.0.0", port=port, debug=True)