BasitAliii commited on
Commit
f64656d
·
verified ·
1 Parent(s): b07380f

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +175 -317
app.py CHANGED
@@ -1,292 +1,96 @@
1
  from __future__ import annotations
2
- import os
3
- import json
4
  import uuid
5
  import re
6
- import time
7
- from dataclasses import dataclass, asdict
8
  from pathlib import Path
9
- from typing import List, Dict, Any, Optional, Tuple
10
  import streamlit as st
11
 
12
- # ---------- Config ----------
13
- DATA_FILE = Path("users.json")
14
- FEEDBACK_FILE = Path("feedback.json")
15
- AVATAR_DIR = Path("avatars")
16
- MODEL = "llama-3.3-70b-versatile"
17
-
18
- AVATAR_DIR.mkdir(exist_ok=True)
19
-
20
- if not DATA_FILE.exists():
21
- DATA_FILE.write_text("[]", encoding="utf-8")
22
-
23
- if not FEEDBACK_FILE.exists():
24
- FEEDBACK_FILE.write_text("[]", encoding="utf-8")
25
-
26
- # ---------- Data model ----------
27
- @dataclass
28
- class Profile:
29
- id: str
30
- username: str
31
- offers: List[str]
32
- wants: List[str]
33
- availability: str
34
- preferences: str
35
- avatar: Optional[str] = None
36
-
37
- @staticmethod
38
- def from_dict(d: Dict[str, Any]) -> "Profile":
39
- return Profile(
40
- id=str(d.get("id") or uuid.uuid4()),
41
- username=str(d.get("username") or "").strip(),
42
- offers=list(d.get("offers") or []),
43
- wants=list(d.get("wants") or []),
44
- availability=str(d.get("availability") or ""),
45
- preferences=str(d.get("preferences") or ""),
46
- avatar=d.get("avatar"),
47
- )
48
-
49
- def to_dict(self) -> Dict[str, Any]:
50
- return asdict(self)
51
-
52
- # ---------- Storage & validation ----------
53
- class ProfileStore:
54
- def __init__(self, path: Path = DATA_FILE) -> None:
55
- self.path = path
56
- self._ensure_file()
57
-
58
- def _ensure_file(self) -> None:
59
- if not self.path.exists():
60
- self.path.write_text("[]", encoding="utf-8")
61
-
62
- def load_all(self) -> List[Profile]:
63
- try:
64
- data = json.loads(self.path.read_text(encoding="utf-8"))
65
- return [Profile.from_dict(d) for d in data if isinstance(d, dict)]
66
- except json.JSONDecodeError:
67
- return []
68
-
69
- def save_all(self, profiles: List[Profile]) -> None:
70
- data = [p.to_dict() for p in profiles]
71
- self.path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
72
-
73
- def find_by_username(self, username: str) -> Optional[Profile]:
74
- username = (username or "").strip()
75
- if not username:
76
- return None
77
- for p in self.load_all():
78
- if p.username.lower() == username.lower():
79
- return p
80
- return None
81
-
82
- def add_or_update(self, profile: Profile) -> Tuple[bool, str]:
83
- ok, err = validate_profile(profile)
84
- if not ok:
85
- return False, f"Validation failed: {err}"
86
- profiles = self.load_all()
87
- existing = next((p for p in profiles if p.username.lower() == profile.username.lower()), None)
88
- if existing:
89
- existing.offers = profile.offers
90
- existing.wants = profile.wants
91
- existing.availability = profile.availability
92
- existing.preferences = profile.preferences
93
- existing.avatar = profile.avatar
94
- self.save_all(profiles)
95
- return True, "Profile updated."
96
- else:
97
- if not profile.id:
98
- profile.id = str(uuid.uuid4())
99
- profiles.append(profile)
100
- self.save_all(profiles)
101
- return True, "Profile created."
102
-
103
- def delete(self, username: str) -> Tuple[bool, str]:
104
- profiles = self.load_all()
105
- new = [p for p in profiles if p.username.lower() != username.lower()]
106
- if len(new) == len(profiles):
107
- return False, "Profile not found."
108
- self.save_all(new)
109
- return True, "Profile deleted."
110
-
111
- def validate_profile(profile: Profile) -> Tuple[bool, Optional[str]]:
112
- if not profile.username or not profile.username.strip():
113
- return False, "Username is required."
114
- if len(profile.username.strip()) > 60:
115
- return False, "Username must be 60 characters or fewer."
116
- if not profile.offers and not profile.wants:
117
- return False, "At least one offer or want is required."
118
- for s in profile.offers + profile.wants:
119
- if not isinstance(s, str) or not s.strip():
120
- return False, "Offers and wants must be non-empty strings."
121
- if len(s) > 120:
122
- return False, "Individual skill entries must be 120 characters or fewer."
123
- return True, None
124
-
125
- # ---------- Utilities ----------
126
- def normalize_skill_list(text: Optional[str]) -> List[str]:
127
- if not text:
128
- return []
129
- for sep in ["\n", ",", ";"]:
130
- text = text.replace(sep, "|")
131
- items = [i.strip() for i in text.split("|") if i.strip()]
132
- seen = set()
133
- out = []
134
- for it in items:
135
- key = it.lower()
136
- if key not in seen:
137
- seen.add(key)
138
- out.append(it)
139
- return out
140
-
141
- def make_prompt_for_matching(current_user: Profile, all_users: List[Profile], top_k: int = 5) -> Tuple[str, str]:
142
- users_desc = []
143
- for u in all_users:
144
- if u.id == current_user.id:
145
- continue
146
- users_desc.append({
147
- "id": u.id,
148
- "username": u.username,
149
- "offers": u.offers,
150
- "wants": u.wants,
151
- "availability": u.availability,
152
- "preferences": u.preferences,
153
- })
154
-
155
- system_instructions = (
156
- "You are a matchmaking assistant for a free skill-exchange platform. "
157
- "Recommend the best matches with JSON output only."
158
- )
159
-
160
- user_message = json.dumps({
161
- "current_user": current_user.to_dict(),
162
- "candidates": users_desc,
163
- "top_k": top_k
164
- }, ensure_ascii=False)
165
-
166
- return system_instructions, user_message
167
-
168
- # ---------- Groq helper ----------
169
- try:
170
- from groq import Groq
171
- except Exception:
172
- Groq = None
173
-
174
- def init_groq_client():
175
- api_key = os.getenv("GROQ_API_KEY")
176
- if not api_key or Groq is None:
177
- return None
178
- try:
179
- return Groq(api_key=api_key)
180
- except:
181
- return None
182
-
183
- def ask_groq_for_matches(system_instructions: str, user_message: str, model: str = MODEL):
184
- client = init_groq_client()
185
- if client is None:
186
- raise RuntimeError("Groq client not initialized. Set GROQ_API_KEY.")
187
-
188
- messages = [
189
- {"role": "system", "content": system_instructions},
190
- {"role": "user", "content": user_message},
191
- ]
192
- resp = client.chat.completions.create(messages=messages, model=model)
193
- content = resp.choices[0].message.content or ""
194
- json_match = re.search(r"(\[\s*\{[\s\S]*?\}\s*\])", content)
195
- if not json_match:
196
- raise RuntimeError("No JSON array found in LLM response.")
197
- return json.loads(json_match.group(1))
198
-
199
- # ---------- Feedback ----------
200
- def load_feedback() -> List[Dict[str, Any]]:
201
- try:
202
- return json.loads(FEEDBACK_FILE.read_text(encoding="utf-8"))
203
- except:
204
- return []
205
-
206
- def save_feedback(entry: Dict[str, Any]):
207
- data = load_feedback()
208
- data.append(entry)
209
- FEEDBACK_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
210
-
211
- # ---------- Local matching fallback ----------
212
- def calculate_local_matches(current: Profile, candidates: List[Profile], top_k: int = 3) -> List[Dict[str, Any]]:
213
- matches = []
214
- for cand in candidates:
215
- if cand.id == current.id:
216
- continue
217
-
218
- score = 0
219
- offers_set = set(o.lower() for o in cand.offers)
220
- wants_set = set(w.lower() for w in cand.wants)
221
- cur_offers = set(o.lower() for o in current.offers)
222
- cur_wants = set(w.lower() for w in current.wants)
223
-
224
- score += len(offers_set & cur_wants) * 30
225
- score += len(cur_offers & wants_set) * 30
226
-
227
- if cand.availability and current.availability:
228
- if cand.availability.lower() in current.availability.lower() or current.availability.lower() in cand.availability.lower():
229
- score += 20
230
-
231
- if cand.preferences and current.preferences:
232
- if cand.preferences.lower() in current.preferences.lower() or current.preferences.lower() in cand.preferences.lower():
233
- score += 20
234
-
235
- matches.append({
236
- "id": cand.id,
237
- "username": cand.username,
238
- "score": min(100, score),
239
- "reason": "Local scoring",
240
- "avatar": cand.avatar
241
- })
242
-
243
- return sorted(matches, key=lambda x: x["score"], reverse=True)[:top_k]
244
-
245
- # ---------- Streamlit UI ----------
246
- st.set_page_config(page_title="AI Skill Swap", page_icon="🤝", layout="wide")
247
-
248
- # ★ SIMPLE LIGHT THEME CSS ★
249
- base_css = """
250
- .card{padding:16px;border-radius:12px;background:#ffffff;color:#111827;box-shadow:0 6px 18px rgba(2,6,23,0.06);margin-bottom:12px}
251
- .avatar{width:64px;height:64px;border-radius:10px;object-fit:cover}
252
- .match-score{font-weight:700}
253
- .feedback-floating{position:fixed;bottom:20px;right:20px;background:#4f46e5;color:white;border-radius:50%;width:60px;height:60px;display:flex;align-items:center;justify-content:center;font-size:28px;cursor:pointer;box-shadow:0 4px 10px rgba(0,0,0,0.28);z-index:9999;}
254
- """
255
- st.markdown(f"<style>{base_css}</style>", unsafe_allow_html=True)
256
-
257
- # Header with logo
258
  col_logo, col_title = st.columns([1, 8])
 
259
  with col_logo:
260
  try:
261
  st.image("logo.jpg", width=100)
262
  except:
263
- st.image("https://via.placeholder.com/100x100/4F46E5/FFFFFF?text=🤝", width=100)
 
 
 
 
264
  with col_title:
265
  st.title("AI Skill Swap — Match & Exchange Skills")
266
  st.caption("Connect, Learn, and Grow Together")
267
 
 
268
  store = ProfileStore()
269
 
270
- # ----- Sidebar Profile Form -----
271
  with st.sidebar:
272
  st.header("Your profile")
 
273
  with st.form("profile_form"):
274
- username = st.text_input("Username", value=st.session_state.get("username", ""))
275
- offers_text = st.text_area("Skills you can teach", value=st.session_state.get("offers_text", ""))
276
- wants_text = st.text_area("Skills you want to learn", value=st.session_state.get("wants_text", ""))
277
- availability = st.text_input("Availability(Day/Night)", value=st.session_state.get("availability", ""))
278
- preferences = st.text_input("Language Preferences", value=st.session_state.get("preferences", ""))
279
- avatar_file = st.file_uploader("Upload avatar (optional)", type=["png", "jpg", "jpeg"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
  save = st.form_submit_button("Save / Update profile")
281
 
282
  if save:
283
  offers = normalize_skill_list(offers_text)
284
  wants = normalize_skill_list(wants_text)
 
285
  avatar_path = None
286
  if avatar_file and username.strip():
287
  safe = re.sub(r"[^A-Za-z0-9_.-]", "_", username.strip())
288
  ext = Path(avatar_file.name).suffix
289
- avatar_path = str(AVATAR_DIR / f"{safe}_{int(time.time())}{ext}")
 
 
290
  with open(avatar_path, "wb") as f:
291
  f.write(avatar_file.getbuffer())
292
 
@@ -297,10 +101,11 @@ with st.sidebar:
297
  wants=wants,
298
  availability=availability.strip(),
299
  preferences=preferences.strip(),
300
- avatar=avatar_path
301
  )
302
 
303
  ok, msg = store.add_or_update(profile)
 
304
  if ok:
305
  st.success(msg)
306
  st.session_state["username"] = username
@@ -313,6 +118,7 @@ with st.sidebar:
313
 
314
  st.markdown("---")
315
  st.header("Load / Delete")
 
316
  profiles = store.load_all()
317
  options = ["-- new profile --"] + [p.username for p in profiles]
318
  selected = st.selectbox("Choose profile", options)
@@ -328,121 +134,173 @@ with st.sidebar:
328
  st.rerun()
329
 
330
  if st.button("Delete profile") and selected != "-- new profile --":
331
- ok, m = store.delete(selected)
332
  if ok:
333
- st.success(m)
334
- for k in ["username","offers_text","wants_text","availability","preferences"]:
 
 
 
 
 
 
335
  st.session_state.pop(k, None)
336
  time.sleep(0.2)
337
  st.rerun()
338
 
339
- # ----- Main Layout -----
340
  col1, col2 = st.columns([2, 3])
341
 
342
- # COMMUNITY PROFILES
343
  with col1:
344
  st.subheader("Community profiles")
345
  profiles = store.load_all()
 
346
  if not profiles:
347
  st.info("No profiles yet.")
348
  else:
349
  for p in profiles:
350
- cols = st.columns([1, 4])
351
- with cols[0]:
 
352
  if p.avatar and Path(p.avatar).exists():
353
  st.image(p.avatar, width=64)
354
  else:
355
- st.image("https://via.placeholder.com/64?text=Avatar", width=64)
356
- with cols[1]:
 
 
 
 
357
  st.markdown(f"**{p.username}**")
358
  st.markdown(f"Offers: {', '.join(p.offers) or '—'}")
359
  st.markdown(f"Wants: {', '.join(p.wants) or '—'}")
360
  st.caption(f"{p.availability} • {p.preferences}")
361
 
362
- # AI MATCHMAKING
363
  with col2:
364
  st.subheader("Find Matches (AI)")
365
  profiles = store.load_all()
 
366
  if not profiles:
367
  st.info("Add profiles first.")
368
  else:
369
- pick = st.selectbox("Match for profile", [p.username for p in profiles])
370
- top_k = 3 # Fixed to 3 matches
 
 
 
 
371
 
372
  if st.button("Run AI matchmaking"):
373
  with st.spinner("Finding matches..."):
374
  current = store.find_by_username(pick)
375
-
376
  if not current:
377
  st.error("Selected profile not found!")
378
  st.stop()
379
 
380
  try:
381
- # Try AI matching first
382
- sys_ins, user_msg = make_prompt_for_matching(current, profiles, top_k)
383
- matches = ask_groq_for_matches(sys_ins, user_msg)
384
-
385
- # Ensure matches have score field
386
- for match in matches:
387
- if "score" not in match:
388
- match["score"] = 0
389
- except Exception as e:
390
- st.warning(f"AI matching failed: {e}. Using local matching...")
391
- # Fallback to local matching
392
- candidates = [p for p in profiles if p.id != current.id]
393
- matches = calculate_local_matches(current, candidates, top_k)
394
 
395
- st.markdown("<div class='card'><b>Top Matches</b></div>", unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
396
 
397
  for m in matches:
398
- if not isinstance(m, dict):
399
- continue
400
-
401
  c1, c2 = st.columns([1, 5])
 
402
  with c1:
403
  avatar = m.get("avatar")
404
  if avatar and Path(avatar).exists():
405
  st.image(avatar, width=72)
406
  else:
407
- st.image("https://via.placeholder.com/72?text=User", width=72)
 
 
 
408
 
409
  with c2:
410
- st.markdown(f"### {m.get('username', 'Unknown User')}")
411
-
412
- # Safely get score with default value
 
413
  score = m.get("score", 0)
414
- if not isinstance(score, (int, float)):
415
- try:
416
- score = float(score)
417
- except:
418
- score = 0
419
  score = max(0, min(100, score))
420
-
421
- st.markdown(f"**Match score:** <span class='match-score'>{score}%</span>", unsafe_allow_html=True)
422
- st.progress(score / 100)
423
-
424
- reason = m.get("reason", "AI matchmaking")
425
- st.caption(f"*{reason}*")
426
 
427
- # Feedback button (floating)
428
- st.markdown("""
429
- <div class='feedback-floating' onclick="document.querySelector('.feedback-form').style.display='block'">💬</div>
430
- """, unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
431
 
432
- # Hidden feedback form
433
  with st.sidebar:
434
  st.markdown("---")
435
  with st.expander("💬 Feedback"):
436
  feedback = st.text_area("Your feedback")
 
437
  if st.button("Submit Feedback"):
438
  if feedback.strip():
439
- save_feedback({
440
- "timestamp": time.time(),
441
- "feedback": feedback.strip(),
442
- "username": st.session_state.get("username", "anonymous")
443
- })
 
 
 
 
 
444
  st.success("Thank you for your feedback!")
445
  time.sleep(1)
446
  st.rerun()
447
  else:
448
- st.warning("Please enter some feedback.")
 
1
  from __future__ import annotations
2
+
3
+ import time
4
  import uuid
5
  import re
 
 
6
  from pathlib import Path
 
7
  import streamlit as st
8
 
9
+ from config import AVATAR_DIR
10
+ from models import Profile
11
+ from storage import ProfileStore
12
+ from utils import normalize_skill_list, make_prompt_for_matching
13
+ from groq_client import ask_groq_for_matches
14
+ from matching import calculate_local_matches
15
+ from feedback import save_feedback
16
+ from ui_styles import BASE_CSS
17
+
18
+ # ---------- Streamlit Config ----------
19
+ st.set_page_config(
20
+ page_title="AI Skill Swap",
21
+ page_icon="🤝",
22
+ layout="wide",
23
+ )
24
+
25
+ st.markdown(f"<style>{BASE_CSS}</style>", unsafe_allow_html=True)
26
+
27
+ # ---------- Header ----------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  col_logo, col_title = st.columns([1, 8])
29
+
30
  with col_logo:
31
  try:
32
  st.image("logo.jpg", width=100)
33
  except:
34
+ st.image(
35
+ "https://via.placeholder.com/100x100/4F46E5/FFFFFF?text=🤝",
36
+ width=100,
37
+ )
38
+
39
  with col_title:
40
  st.title("AI Skill Swap — Match & Exchange Skills")
41
  st.caption("Connect, Learn, and Grow Together")
42
 
43
+ # ---------- Store ----------
44
  store = ProfileStore()
45
 
46
+ # ---------- Sidebar: Profile Form ----------
47
  with st.sidebar:
48
  st.header("Your profile")
49
+
50
  with st.form("profile_form"):
51
+ username = st.text_input(
52
+ "Username",
53
+ value=st.session_state.get("username", ""),
54
+ )
55
+
56
+ offers_text = st.text_area(
57
+ "Skills you can teach",
58
+ value=st.session_state.get("offers_text", ""),
59
+ )
60
+
61
+ wants_text = st.text_area(
62
+ "Skills you want to learn",
63
+ value=st.session_state.get("wants_text", ""),
64
+ )
65
+
66
+ availability = st.text_input(
67
+ "Availability (Day/Night)",
68
+ value=st.session_state.get("availability", ""),
69
+ )
70
+
71
+ preferences = st.text_input(
72
+ "Language Preferences",
73
+ value=st.session_state.get("preferences", ""),
74
+ )
75
+
76
+ avatar_file = st.file_uploader(
77
+ "Upload avatar (optional)",
78
+ type=["png", "jpg", "jpeg"],
79
+ )
80
+
81
  save = st.form_submit_button("Save / Update profile")
82
 
83
  if save:
84
  offers = normalize_skill_list(offers_text)
85
  wants = normalize_skill_list(wants_text)
86
+
87
  avatar_path = None
88
  if avatar_file and username.strip():
89
  safe = re.sub(r"[^A-Za-z0-9_.-]", "_", username.strip())
90
  ext = Path(avatar_file.name).suffix
91
+ avatar_path = str(
92
+ AVATAR_DIR / f"{safe}_{int(time.time())}{ext}"
93
+ )
94
  with open(avatar_path, "wb") as f:
95
  f.write(avatar_file.getbuffer())
96
 
 
101
  wants=wants,
102
  availability=availability.strip(),
103
  preferences=preferences.strip(),
104
+ avatar=avatar_path,
105
  )
106
 
107
  ok, msg = store.add_or_update(profile)
108
+
109
  if ok:
110
  st.success(msg)
111
  st.session_state["username"] = username
 
118
 
119
  st.markdown("---")
120
  st.header("Load / Delete")
121
+
122
  profiles = store.load_all()
123
  options = ["-- new profile --"] + [p.username for p in profiles]
124
  selected = st.selectbox("Choose profile", options)
 
134
  st.rerun()
135
 
136
  if st.button("Delete profile") and selected != "-- new profile --":
137
+ ok, msg = store.delete(selected)
138
  if ok:
139
+ st.success(msg)
140
+ for k in [
141
+ "username",
142
+ "offers_text",
143
+ "wants_text",
144
+ "availability",
145
+ "preferences",
146
+ ]:
147
  st.session_state.pop(k, None)
148
  time.sleep(0.2)
149
  st.rerun()
150
 
151
+ # ---------- Main Layout ----------
152
  col1, col2 = st.columns([2, 3])
153
 
154
+ # ---------- Community Profiles ----------
155
  with col1:
156
  st.subheader("Community profiles")
157
  profiles = store.load_all()
158
+
159
  if not profiles:
160
  st.info("No profiles yet.")
161
  else:
162
  for p in profiles:
163
+ c1, c2 = st.columns([1, 4])
164
+
165
+ with c1:
166
  if p.avatar and Path(p.avatar).exists():
167
  st.image(p.avatar, width=64)
168
  else:
169
+ st.image(
170
+ "https://via.placeholder.com/64?text=Avatar",
171
+ width=64,
172
+ )
173
+
174
+ with c2:
175
  st.markdown(f"**{p.username}**")
176
  st.markdown(f"Offers: {', '.join(p.offers) or '—'}")
177
  st.markdown(f"Wants: {', '.join(p.wants) or '—'}")
178
  st.caption(f"{p.availability} • {p.preferences}")
179
 
180
+ # ---------- AI Matchmaking ----------
181
  with col2:
182
  st.subheader("Find Matches (AI)")
183
  profiles = store.load_all()
184
+
185
  if not profiles:
186
  st.info("Add profiles first.")
187
  else:
188
+ pick = st.selectbox(
189
+ "Match for profile",
190
+ [p.username for p in profiles],
191
+ )
192
+
193
+ top_k = 3
194
 
195
  if st.button("Run AI matchmaking"):
196
  with st.spinner("Finding matches..."):
197
  current = store.find_by_username(pick)
198
+
199
  if not current:
200
  st.error("Selected profile not found!")
201
  st.stop()
202
 
203
  try:
204
+ sys_ins, user_msg = make_prompt_for_matching(
205
+ current,
206
+ profiles,
207
+ top_k,
208
+ )
209
+ matches = ask_groq_for_matches(
210
+ sys_ins,
211
+ user_msg,
212
+ )
213
+
214
+ for m in matches:
215
+ if "score" not in m:
216
+ m["score"] = 0
217
 
218
+ except Exception as e:
219
+ st.warning(
220
+ f"AI matching failed: {e}. Using local matching..."
221
+ )
222
+ candidates = [
223
+ p for p in profiles if p.id != current.id
224
+ ]
225
+ matches = calculate_local_matches(
226
+ current,
227
+ candidates,
228
+ top_k,
229
+ )
230
+
231
+ st.markdown(
232
+ "<div class='card'><b>Top Matches</b></div>",
233
+ unsafe_allow_html=True,
234
+ )
235
 
236
  for m in matches:
 
 
 
237
  c1, c2 = st.columns([1, 5])
238
+
239
  with c1:
240
  avatar = m.get("avatar")
241
  if avatar and Path(avatar).exists():
242
  st.image(avatar, width=72)
243
  else:
244
+ st.image(
245
+ "https://via.placeholder.com/72?text=User",
246
+ width=72,
247
+ )
248
 
249
  with c2:
250
+ st.markdown(
251
+ f"### {m.get('username', 'Unknown User')}"
252
+ )
253
+
254
  score = m.get("score", 0)
255
+ try:
256
+ score = float(score)
257
+ except:
258
+ score = 0
259
+
260
  score = max(0, min(100, score))
 
 
 
 
 
 
261
 
262
+ st.markdown(
263
+ f"**Match score:** "
264
+ f"<span class='match-score'>{score}%</span>",
265
+ unsafe_allow_html=True,
266
+ )
267
+
268
+ st.progress(score / 100)
269
+ st.caption(
270
+ f"*{m.get('reason', 'AI matchmaking')}*"
271
+ )
272
+
273
+ # ---------- Floating Feedback Button ----------
274
+ st.markdown(
275
+ """
276
+ <div class='feedback-floating'
277
+ onclick="document.querySelector('.feedback-form').style.display='block'">
278
+ 💬
279
+ </div>
280
+ """,
281
+ unsafe_allow_html=True,
282
+ )
283
 
284
+ # ---------- Feedback ----------
285
  with st.sidebar:
286
  st.markdown("---")
287
  with st.expander("💬 Feedback"):
288
  feedback = st.text_area("Your feedback")
289
+
290
  if st.button("Submit Feedback"):
291
  if feedback.strip():
292
+ save_feedback(
293
+ {
294
+ "timestamp": time.time(),
295
+ "feedback": feedback.strip(),
296
+ "username": st.session_state.get(
297
+ "username",
298
+ "anonymous",
299
+ ),
300
+ }
301
+ )
302
  st.success("Thank you for your feedback!")
303
  time.sleep(1)
304
  st.rerun()
305
  else:
306
+ st.warning("Please enter some feedback.")