BasitAliii commited on
Commit
f13c4f4
Β·
verified Β·
1 Parent(s): cf4c61a

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +388 -0
app.py ADDED
@@ -0,0 +1,388 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # ---------- Streamlit UI ----------
212
+ st.set_page_config(page_title="AI Skill Swap", page_icon="🀝", layout="wide")
213
+
214
+ # β˜… SIMPLE LIGHT THEME CSS β˜…
215
+ base_css = """
216
+ .card{padding:16px;border-radius:12px;background:#ffffff;color:#111827;box-shadow:0 6px 18px rgba(2,6,23,0.06);margin-bottom:12px}
217
+ .avatar{width:64px;height:64px;border-radius:10px;object-fit:cover}
218
+ .match-score{font-weight:700}
219
+ .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;}
220
+ """
221
+ st.markdown(f"<style>{base_css}</style>", unsafe_allow_html=True)
222
+
223
+ # Header with logo
224
+ col_logo, col_title = st.columns([1, 8])
225
+ with col_logo:
226
+ try:
227
+ st.image("logo.jpg", width=100)
228
+ except:
229
+ st.image("https://via.placeholder.com/100x100/4F46E5/FFFFFF?text=🀝", width=100)
230
+ with col_title:
231
+ st.title("AI Skill Swap β€” Match & Exchange Skills")
232
+ st.caption("Connect, Learn, and Grow Together")
233
+
234
+ store = ProfileStore()
235
+
236
+ # ----- Sidebar Profile Form -----
237
+ with st.sidebar:
238
+ st.header("Your profile")
239
+ with st.form("profile_form"):
240
+ username = st.text_input("Username", value=st.session_state.get("username", ""))
241
+ offers_text = st.text_area("Skills you can teach", value=st.session_state.get("offers_text", ""))
242
+ wants_text = st.text_area("Skills you want to learn", value=st.session_state.get("wants_text", ""))
243
+ availability = st.text_input("Availability(Day/Night)", value=st.session_state.get("availability", ""))
244
+ preferences = st.text_input("Language Preferences", value=st.session_state.get("preferences", ""))
245
+ avatar_file = st.file_uploader("Upload avatar (optional)", type=["png", "jpg", "jpeg"])
246
+ save = st.form_submit_button("Save / Update profile")
247
+
248
+ if save:
249
+ offers = normalize_skill_list(offers_text)
250
+ wants = normalize_skill_list(wants_text)
251
+ avatar_path = None
252
+ if avatar_file and username.strip():
253
+ safe = re.sub(r"[^A-Za-z0-9_.-]", "_", username.strip())
254
+ ext = Path(avatar_file.name).suffix
255
+ avatar_path = str(AVATAR_DIR / f"{safe}_{int(time.time())}{ext}")
256
+ with open(avatar_path, "wb") as f:
257
+ f.write(avatar_file.getbuffer())
258
+
259
+ profile = Profile(
260
+ id=str(uuid.uuid4()),
261
+ username=username.strip(),
262
+ offers=offers,
263
+ wants=wants,
264
+ availability=availability.strip(),
265
+ preferences=preferences.strip(),
266
+ avatar=avatar_path
267
+ )
268
+
269
+ ok, msg = store.add_or_update(profile)
270
+ if ok:
271
+ st.success(msg)
272
+ st.session_state["username"] = username
273
+ st.session_state["offers_text"] = offers_text
274
+ st.session_state["wants_text"] = wants_text
275
+ st.session_state["availability"] = availability
276
+ st.session_state["preferences"] = preferences
277
+ else:
278
+ st.error(msg)
279
+
280
+ st.markdown("---")
281
+ st.header("Load / Delete")
282
+ profiles = store.load_all()
283
+ options = ["-- new profile --"] + [p.username for p in profiles]
284
+ selected = st.selectbox("Choose profile", options)
285
+
286
+ if st.button("Load profile") and selected != "-- new profile --":
287
+ p = store.find_by_username(selected)
288
+ if p:
289
+ st.session_state["username"] = p.username
290
+ st.session_state["offers_text"] = "\n".join(p.offers)
291
+ st.session_state["wants_text"] = "\n".join(p.wants)
292
+ st.session_state["availability"] = p.availability
293
+ st.session_state["preferences"] = p.preferences
294
+ st.rerun()
295
+
296
+ if st.button("Delete profile") and selected != "-- new profile --":
297
+ ok, m = store.delete(selected)
298
+ if ok:
299
+ st.success(m)
300
+ for k in ["username","offers_text","wants_text","availability","preferences"]:
301
+ st.session_state.pop(k, None)
302
+ time.sleep(0.2)
303
+ st.rerun()
304
+
305
+ # ----- Main Layout -----
306
+ col1, col2 = st.columns([2, 3])
307
+
308
+ # COMMUNITY PROFILES
309
+ with col1:
310
+ st.subheader("Community profiles")
311
+ profiles = store.load_all()
312
+ if not profiles:
313
+ st.info("No profiles yet.")
314
+ else:
315
+ for p in profiles:
316
+ cols = st.columns([1, 4])
317
+ with cols[0]:
318
+ if p.avatar and Path(p.avatar).exists():
319
+ st.image(p.avatar, width=64)
320
+ else:
321
+ st.image("https://via.placeholder.com/64?text=Avatar", width=64)
322
+ with cols[1]:
323
+ st.markdown(f"**{p.username}**")
324
+ st.markdown(f"Offers: {', '.join(p.offers) or 'β€”'}")
325
+ st.markdown(f"Wants: {', '.join(p.wants) or 'β€”'}")
326
+ st.caption(f"{p.availability} β€’ {p.preferences}")
327
+
328
+ # AI MATCHMAKING
329
+ with col2:
330
+ st.subheader("Find Matches (AI)")
331
+ profiles = store.load_all()
332
+ if not profiles:
333
+ st.info("Add profiles first.")
334
+ else:
335
+ pick = st.selectbox("Match for profile", [p.username for p in profiles])
336
+ top_k = 3 # Fixed to 3 matches
337
+
338
+ if st.button("Run AI matchmaking"):
339
+ with st.spinner("Finding matches..."):
340
+ current = store.find_by_username(pick)
341
+
342
+ sys_ins, user_msg = make_prompt_for_matching(current, profiles, top_k)
343
+ try:
344
+ matches = ask_groq_for_matches(sys_ins, user_msg)
345
+ except:
346
+ # fallback local scoring
347
+ matches = []
348
+ for cand in profiles:
349
+ if cand.id == current.id:
350
+ continue
351
+ score = 0
352
+ offers_set = set(o.lower() for o in cand.offers)
353
+ wants_set = set(w.lower() for w in cand.wants)
354
+ cur_offers = set(o.lower() for o in current.offers)
355
+ cur_wants = set(w.lower() for w in current.wants)
356
+
357
+ score += len(offers_set & cur_wants) * 30
358
+ score += len(cur_offers & wants_set) * 30
359
+ if cand.availability and current.availability and cand.availability.lower() in current.availability.lower():
360
+ score += 20
361
+ if cand.preferences and current.preferences and cand.preferences.lower() in current.preferences.lower():
362
+ score += 20
363
+
364
+ matches.append({
365
+ "id": cand.id,
366
+ "username": cand.username,
367
+ "score": min(100, score),
368
+ "reason": "Local scoring",
369
+ "avatar": cand.avatar
370
+ })
371
+
372
+ matches = sorted(matches, key=lambda x: x["score"], reverse=True)[:top_k]
373
+
374
+ st.markdown("<div class='card'><b>Top Matches</b></div>", unsafe_allow_html=True)
375
+
376
+ for m in matches:
377
+ c1, c2 = st.columns([1, 5])
378
+ with c1:
379
+ if m.get("avatar") and Path(m["avatar"]).exists():
380
+ st.image(m["avatar"], width=72)
381
+ else:
382
+ st.image("https://via.placeholder.com/72?text=User", width=72)
383
+
384
+ with c2:
385
+ st.markdown(f"### {m['username']}")
386
+ st.markdown(f"**Match score:** <span class='match-score'>{m['score']}%</span>", unsafe_allow_html=True)
387
+ st.progress(int(m["score"]) / 100)
388
+ st.markdown("*πŸŽ‰ Yass! We found a buddy to swap your skills with!*")