Oviya commited on
Commit
f328ff1
·
1 Parent(s): 17dd67a
Files changed (4) hide show
  1. .gitignore +113 -0
  2. Dockerfile +30 -0
  3. requirements.txt +6 -0
  4. server.py +658 -0
.gitignore ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -----------------------
2
+ # Python / Flask basics
3
+ # -----------------------
4
+ __pycache__/
5
+ *.py[cod]
6
+ *$py.class
7
+
8
+ # Virtual envs
9
+ .venv/
10
+ venv/
11
+ env/
12
+ ENV/
13
+
14
+ # Build / packaging
15
+ build/
16
+ dist/
17
+ *.egg-info/
18
+ .eggs/
19
+ pip-wheel-metadata/
20
+
21
+ # Testing / coverage
22
+ .pytest_cache/
23
+ pytest_cache/
24
+ .coverage
25
+ .coverage.*
26
+ htmlcov/
27
+ .tox/
28
+
29
+ # Type checkers / linters (optional but handy)
30
+ .mypy_cache/
31
+ .dmypy.json
32
+ .pytype/
33
+ .ruff_cache/
34
+
35
+ # Jupyter
36
+ .ipynb_checkpoints/
37
+ *.ipynb # ignore notebooks unless you intend to track them
38
+
39
+ # Logs & PIDs
40
+ logs/
41
+ *.log
42
+ *.log.*
43
+ *.pid
44
+
45
+ # OS-specific
46
+ .DS_Store
47
+ Thumbs.db
48
+
49
+ # IDE / Editor
50
+ .vscode/
51
+ .idea/
52
+ *.iml
53
+ *.code-workspace
54
+
55
+ # -----------------------
56
+ # App-specific
57
+ # -----------------------
58
+
59
+ # Local environment files (keep examples if you want)
60
+ .env
61
+ .env.*
62
+ !.env.example
63
+
64
+ # Credentials / keys (VERY IMPORTANT)
65
+ *.pem
66
+ *.p12
67
+ *.key
68
+ *.crt
69
+ *.cer
70
+ *.der
71
+ *.pfx
72
+ *.enc
73
+ *service-account*.json
74
+ *credentials*.json
75
+ *credential*.json
76
+ *-sa.json
77
+ *secret*.json
78
+ learnenglish-ai-*.json
79
+ gcloud*.json
80
+
81
+ # Runtime/state files
82
+ sessions.json
83
+ *.sqlite
84
+ *.sqlite3
85
+ *.db
86
+ *.bak
87
+ *.sql
88
+ *.csv
89
+ *.tsv
90
+ *.parquet
91
+
92
+ # Media / generated assets
93
+ static/videos/
94
+ static/audio/
95
+ static/transcripts/
96
+ uploads/
97
+ tmp/
98
+ temp/
99
+ *.tmp
100
+
101
+ # MoviePy / temp renders
102
+ *.moviepy_temp*
103
+
104
+ # -----------------------
105
+ # Optional (Docker / Node)
106
+ # -----------------------
107
+ docker-compose.override.yml
108
+ *.local.yml
109
+
110
+ node_modules/
111
+ npm-debug.log*
112
+ yarn-error.log*
113
+ pnpm-debug.log*
Dockerfile ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Minimal Dockerfile for Flask app on Hugging Face Spaces (no DB/ODBC bits)
2
+
3
+ FROM python:3.11-slim
4
+
5
+ # Avoid interactive prompts
6
+ ENV DEBIAN_FRONTEND=noninteractive
7
+
8
+ # (Optional) system tools you may want; safe to keep
9
+ RUN apt-get update && apt-get install -y --no-install-recommends \
10
+ curl ca-certificates \
11
+ && rm -rf /var/lib/apt/lists/*
12
+
13
+ # Workdir
14
+ WORKDIR /app
15
+
16
+ # Install Python deps
17
+ COPY requirements.txt /app/
18
+ RUN pip install --no-cache-dir -r requirements.txt
19
+
20
+ # Copy app
21
+ COPY server.py /app/
22
+
23
+ # HF injects PORT; default for local run
24
+ ENV PORT=7860
25
+
26
+ # Expose for clarity (optional)
27
+ EXPOSE 7860
28
+
29
+ # Start the app
30
+ CMD ["python", "server.py"]
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ flask==3.0.3
2
+ flask-cors==4.0.1
3
+ pyodbc==5.1.0
4
+ pydantic==2.8.2
5
+ langchain-core==0.2.38
6
+ langchain-openai==0.2.3
server.py ADDED
@@ -0,0 +1,658 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # server.py
2
+ import os, uuid, json, random, threading, hashlib
3
+ from typing import Dict, List, Optional, Literal
4
+ from datetime import datetime
5
+
6
+ from flask import Flask, request, jsonify
7
+ from flask_cors import CORS
8
+ import pyodbc
9
+
10
+ # ---------- Optional LLM deps (fallback if missing) ----------
11
+ try:
12
+ from pydantic import BaseModel, Field
13
+ from langchain_core.prompts import ChatPromptTemplate
14
+ from langchain_core.output_parsers import PydanticOutputParser
15
+ from langchain_openai import ChatOpenAI
16
+ HAS_LLM_STACK = True
17
+ except Exception:
18
+ HAS_LLM_STACK = False
19
+
20
+ # ==============================
21
+ # Configuration / DB Connection
22
+ # ==============================
23
+ SQL_DRIVER = os.getenv("PYMATCH_SQL_DRIVER", "{SQL Server}")
24
+ SQL_SERVER = os.getenv("PYMATCH_SQL_SERVER", "localhost\SQLEXPRESS")
25
+ SQL_DB = os.getenv("PYMATCH_SQL_DB", "PyMatch")
26
+ SQL_TRUSTED = os.getenv("PYMATCH_SQL_TRUSTED", "yes") # yes/no
27
+
28
+ PROGRESS_TBL = os.getenv("PYMATCH_PROGRESS_TABLE", "LLMGeneratedQuestions")
29
+
30
+ def get_db_connection():
31
+ return pyodbc.connect(
32
+ f"DRIVER={SQL_DRIVER};"
33
+ f"SERVER={SQL_SERVER};"
34
+ f"DATABASE={SQL_DB};"
35
+ f"Trusted_Connection={SQL_TRUSTED};"
36
+ )
37
+
38
+ # ==========
39
+ # Flask App
40
+ # ==========
41
+ app = Flask(__name__)
42
+ CORS(app, resources={r"/*": {"origins": "*"}})
43
+
44
+ # ==========
45
+ # Utilities
46
+ # ==========
47
+ def hash_password(password: str) -> str:
48
+ return hashlib.sha256(password.encode("utf-8")).hexdigest()
49
+
50
+ def row_to_dict(cursor, row) -> Dict:
51
+ if row is None:
52
+ return {}
53
+ cols = [col[0] for col in cursor.description]
54
+ return {cols[i]: row[i] for i in range(len(cols))}
55
+
56
+ # =======================
57
+ # 1) AUTH / SIGNUP (auth)
58
+ # =======================
59
+ @app.post("/api/signup")
60
+ def signup():
61
+ data = request.get_json(force=True) or {}
62
+ name = data.get("name")
63
+ email = data.get("email")
64
+ password = data.get("password")
65
+
66
+ if not name or not email or not password:
67
+ return jsonify({"error": "Name, email, and password are required."}), 400
68
+
69
+ password_hash = hash_password(password)
70
+
71
+ try:
72
+ conn = get_db_connection()
73
+ cur = conn.cursor()
74
+ cur.execute("""
75
+ INSERT INTO Users (name, email, password)
76
+ VALUES (?, ?, ?)
77
+ """, (name, email, password_hash))
78
+ conn.commit()
79
+ return jsonify({"message": "User created successfully."}), 201
80
+ except pyodbc.Error as e:
81
+ return jsonify({"error": f"DB error: {e}"}), 500
82
+ finally:
83
+ try: conn.close()
84
+ except: pass
85
+
86
+ # ==================================================
87
+ # 2) ROLE SELECTION + STATIC QUESTION FETCH + SAVE
88
+ # (from app.py)
89
+ # ==================================================
90
+ @app.post("/api/questions/select-role")
91
+ def select_role():
92
+ data = request.get_json(force=True) or {}
93
+ user_id = data.get("user_id")
94
+ role_name = data.get("role_name")
95
+ assigned_at = data.get("assigned_at") # ISO or None
96
+
97
+ if not user_id or not role_name:
98
+ return jsonify({"error": "User ID and role name are required."}), 400
99
+
100
+ try:
101
+ conn = get_db_connection()
102
+ cur = conn.cursor()
103
+ cur.execute("""
104
+ INSERT INTO UserRoles (user_id, role_name, assigned_at)
105
+ VALUES (?, ?, ?)
106
+ """, (user_id, role_name, assigned_at))
107
+ conn.commit()
108
+ return jsonify({"message": "Role assigned successfully."}), 201
109
+ except pyodbc.Error as e:
110
+ return jsonify({"error": str(e)}), 500
111
+ finally:
112
+ try: conn.close()
113
+ except: pass
114
+
115
+ @app.get("/api/questions/<role>")
116
+ def get_questions(role):
117
+ if role not in ["marriage", "interview", "partnership"]:
118
+ return jsonify({"error": "Invalid role"}), 400
119
+
120
+ try:
121
+ conn = get_db_connection()
122
+ cur = conn.cursor()
123
+ cur.execute("""
124
+ SELECT question, options, input_type, column_key
125
+ FROM RoleQuestions
126
+ WHERE role_name = ?
127
+ ORDER BY id
128
+ """, (role,))
129
+ rows = cur.fetchall()
130
+ out = []
131
+ for r in rows:
132
+ label = r[0]
133
+ options = (r[1].split(",") if r[1] else [])
134
+ input_type = r[2]
135
+ column_key = r[3]
136
+ out.append({
137
+ "label": label,
138
+ "options": options,
139
+ "input_type": input_type,
140
+ "column_key": column_key
141
+ })
142
+ return jsonify(out), 200
143
+ except pyodbc.Error as e:
144
+ return jsonify({"error": str(e)}), 500
145
+ finally:
146
+ try: conn.close()
147
+ except: pass
148
+
149
+ @app.post("/api/questions/submit-answers/<role>")
150
+ def submit_answers(role):
151
+ data = request.get_json(force=True) or {}
152
+ user_id = data.get("user_id")
153
+ if not user_id:
154
+ return jsonify({"error": "User ID is required."}), 400
155
+
156
+ role_fields = {
157
+ "marriage": [
158
+ "full_name","date_of_birth","age_range","gender","current_city_country","marital_status",
159
+ "education_level","employment_status","number_of_siblings","family_type","hobbies_interests",
160
+ "conflict_approach","financial_style","income_range","relocation_willingness","created_at"
161
+ ],
162
+ "interview": [
163
+ "full_name","date_of_birth","gender","current_location","target_role_title",
164
+ "seniority_level","total_experience_years","key_skills","highest_education_level",
165
+ "work_location_preference","expected_salary_range","team_size_experience",
166
+ "leadership_experience_years","employment_type_preference","willingness_to_relocate","created_at"
167
+ ],
168
+ "partnership": [
169
+ "full_name","date_of_birth","gender","current_location","current_profession_business",
170
+ "years_of_experience_in_industry","business_domain","business_size","roles_you_offer",
171
+ "roles_expected_from_partner","time_commitment_per_week","partnership_structure_preference",
172
+ "prior_partnership_experience","decision_making_style","risk_appetite","created_at"
173
+ ]
174
+ }
175
+ if role not in role_fields:
176
+ return jsonify({"error": "Invalid role."}), 400
177
+
178
+ for f in role_fields[role]:
179
+ if f not in data:
180
+ return jsonify({"error": f"{f} is required."}), 400
181
+
182
+ table_name = {
183
+ "marriage": "Marriage",
184
+ "interview": "Interview",
185
+ "partnership": "Partnership"
186
+ }[role]
187
+
188
+ placeholders = ", ".join(["?"] * (len(role_fields[role]) + 1))
189
+ query = f"INSERT INTO {table_name} (user_id, {', '.join(role_fields[role])}) VALUES ({placeholders})"
190
+ values = [user_id] + [data[f] for f in role_fields[role]]
191
+
192
+ try:
193
+ conn = get_db_connection()
194
+ cur = conn.cursor()
195
+ cur.execute(query, values)
196
+ conn.commit()
197
+ return jsonify({"message": f"{role.capitalize()} record added successfully."}), 201
198
+ except pyodbc.Error as e:
199
+ return jsonify({"error": str(e)}), 500
200
+ finally:
201
+ try: conn.close()
202
+ except: pass
203
+
204
+ # ===========================================
205
+ # 3) LLM BATCH Q-GEN + COLOR % PERSIST (LLM)
206
+ # ===========================================
207
+ COLOR_KEYS = ["blue", "green", "red", "yellow"]
208
+ DOMAINS = ["general","marriage","interview","partnership","team","ceo","assistant"]
209
+
210
+ TOPIC_BANK_BY_DOMAIN = {
211
+ "general": ["team project deadline","budget overrun","new product idea","customer complaint","ambiguous requirements","unexpected risk","weekend planning","office relocation","time conflict","hiring a teammate","learning a new tool","meeting preparation"],
212
+ "marriage": ["household budget plan","holiday travel decision","child's school choice","conflict about chores","time with in-laws","health and fitness routine","weekend family schedule","saving vs spending debate","home renovation plan","vacation destination"],
213
+ "interview": ["role requirement clarity","skill gap discussion","offer negotiation","portfolio review","coding challenge approach","stakeholder communication","deadline pressure scenario","ambiguity in task","peer collaboration","culture add vs fit"],
214
+ "partnership": ["profit sharing plan","conflict resolution policy","market expansion idea","operating cadence","risk management","hiring first employee","brand positioning","cashflow crunch","vendor selection","equity vesting scheme"],
215
+ "team": ["sprint planning","retrospective outcomes","cross-team dependency","onboarding a new hire","resource reallocation","release checklist","incident postmortem","documentation debt","stand-up time change","QA escape defect"],
216
+ "ceo": ["board meeting prep","fundraising strategy","executive hiring plan","product pivot decision","crisis PR briefing","M&A target review","runway and burn trade-off","OKR reset","market entry analysis","high-churn quarter response"],
217
+ "assistant": ["calendar conflicts","travel itinerary","email triage and drafting","vendor coordination","expense report backlog","event logistics","visitor gatekeeping","task prioritization","confidential document handling","household maintenance scheduling"],
218
+ }
219
+ COLOR_PHRASES_BY_DOMAIN = {
220
+ "general": {"blue":"numbers-heavy decision","green":"process and scheduling","red":"people and action","yellow":"new ideas and ambiguity"},
221
+ "marriage": {"blue":"evidence-based family decision","green":"routine and planning at home","red":"direct discussion and action","yellow":"creative family options"},
222
+ "interview": {"blue":"evidence-based hiring decision","green":"process for structured evaluation","red":"decisive selection and expectation setting","yellow":"creative role fit and growth potential"},
223
+ "partnership": {"blue":"data-driven partnership choice","green":"operating process and governance","red":"stakeholder alignment and action plan","yellow":"vision and new market ideas"},
224
+ "team": {"blue":"metric-driven team choice","green":"structured workflow and checklist","red":"alignment and decisive action","yellow":"brainstorming and experimentation"},
225
+ "ceo": {"blue":"data-informed strategic choice","green":"operating cadence and process","red":"leadership move with stakeholders","yellow":"vision and bold direction"},
226
+ "assistant": {"blue":"fact-checked admin decision","green":"organized logistics and sequencing","red":"proactive stakeholder handling","yellow":"flexible options and ideas"},
227
+ }
228
+ MAX_QUESTIONS = int(os.getenv("PYMATCH_MAX_QUESTIONS", "50"))
229
+ DEFAULT_BATCH_SIZE = int(os.getenv("PYMATCH_BATCH_SIZE", "10"))
230
+
231
+ # ---- LLM chain (optional) ----
232
+ PARSER_BATCH = None
233
+ CHAIN_BATCH = None
234
+ if HAS_LLM_STACK and os.getenv("OPENAI_API_KEY"):
235
+ class Option(BaseModel):
236
+ text: str
237
+ color: Literal["blue","green","red","yellow"]
238
+
239
+ class QAItem(BaseModel):
240
+ question: str
241
+ options: List[Option] = Field(min_items=4, max_items=4)
242
+
243
+ class BatchQA(BaseModel):
244
+ items: List[QAItem] = Field(..., min_items=1)
245
+
246
+ SYSTEM_PROMPT = (
247
+ "You write short situational questions to reveal four colors:\n"
248
+ "- blue=analytical, data-driven\n- green=organized, process-oriented\n"
249
+ "- red=decisive, action & people\n- yellow=creative, big-picture\n"
250
+ "Rules:\n"
251
+ "1) STRICT JSON only, matching the schema.\n"
252
+ "2) <=20 words for the question; <=12 words per option.\n"
253
+ "3) Exactly one option for each color.\n"
254
+ "4) Simple English. No personal data.\n"
255
+ "Output must be valid JSON."
256
+ )
257
+ USER_PROMPT_BATCH = (
258
+ "User state (JSON): {state}\n"
259
+ "Themes (array of short strings): {themes_json}\n\n"
260
+ "{format_instructions}\n\n"
261
+ "Write ONE question per theme. The number of items must equal the number of themes."
262
+ )
263
+ PARSER_BATCH = PydanticOutputParser(pydantic_object=BatchQA)
264
+
265
+ def build_batch_chain():
266
+ llm = ChatOpenAI(
267
+ model="gpt-4o-mini",
268
+ temperature=0.7,
269
+ max_retries=2,
270
+ timeout=30,
271
+ model_kwargs={"response_format": {"type": "json_object"}},
272
+ )
273
+ prompt = ChatPromptTemplate.from_messages([
274
+ ("system", SYSTEM_PROMPT),
275
+ ("user", USER_PROMPT_BATCH),
276
+ ])
277
+ # prompt | llm | parser => RunnableSequence (no .right attribute)
278
+ return prompt | llm | PARSER_BATCH
279
+
280
+ CHAIN_BATCH = build_batch_chain()
281
+
282
+ def ensure_valid_colors(options: List[Dict]) -> List[Dict]:
283
+ seen, fixed = set(), []
284
+ defaults = {
285
+ "blue": "Verify facts and numbers",
286
+ "green": "Outline a clear process",
287
+ "red": "Coordinate people and act",
288
+ "yellow": "Propose a fresh idea",
289
+ }
290
+ for o in options:
291
+ c = str(o.get("color", "")).lower()
292
+ t = str(o.get("text", "")).strip()
293
+ if c in COLOR_KEYS and c not in seen and t:
294
+ seen.add(c); fixed.append({"text": t[:80], "color": c})
295
+ for c in COLOR_KEYS:
296
+ if c not in seen:
297
+ fixed.append({"text": defaults[c], "color": c})
298
+ return fixed[:4]
299
+
300
+ def summarize_profile(profile: Dict) -> Dict:
301
+ keys_in_priority = [
302
+ "age_range","current_city_country","values","goals","communication_style",
303
+ "conflict_approach","financial_style","target_role_title","seniority_level",
304
+ "total_experience_years","skills","preferred_industries","work_location_preference",
305
+ "business_domain","venture_stage","roles_you_offer","roles_expected_from_partner",
306
+ "risk_appetite","decision_making_style","reporting_cadence","user_id"
307
+ ]
308
+ out = {}
309
+ for k in keys_in_priority:
310
+ if k in profile and profile[k] not in (None, "", []):
311
+ out[k] = profile[k]
312
+ return out
313
+
314
+ def offline_generate_batch(themes: List[str], state: Dict) -> List[Dict]:
315
+ role = state.get("role", "general")
316
+ prof = state.get("profile", {}) or {}
317
+ hint = prof.get("target_role_title") or prof.get("business_domain") or prof.get("values") or ""
318
+ hint_text = f" ({hint})" if isinstance(hint, str) and hint else ""
319
+ items = []
320
+ for theme in themes:
321
+ q = f"For {role}{hint_text}: in a {theme}, what do you do first?"
322
+ opts = [
323
+ {"text":"Check data and facts","color":"blue"},
324
+ {"text":"Draft a step-by-step plan","color":"green"},
325
+ {"text":"Align people and act","color":"red"},
326
+ {"text":"Brainstorm bold ideas","color":"yellow"},
327
+ ]
328
+ random.shuffle(opts)
329
+ items.append({"question": q, "options": opts, "source": "fallback"})
330
+ return items
331
+
332
+ def generate_batch_questions(themes: List[str], state: Dict) -> List[Dict]:
333
+ # Try LLM path first
334
+ if CHAIN_BATCH is not None and PARSER_BATCH is not None:
335
+ try:
336
+ payload = {
337
+ "state": json.dumps(state, ensure_ascii=False),
338
+ "themes_json": json.dumps(themes, ensure_ascii=False),
339
+ "format_instructions": PARSER_BATCH.get_format_instructions(),
340
+ }
341
+ # CHAIN_BATCH = prompt | llm | PARSER_BATCH -> returns parsed object (BatchQA or dict)
342
+ result = CHAIN_BATCH.invoke(payload)
343
+
344
+ if hasattr(result, "items"):
345
+ items_raw = result.items # Pydantic BatchQA.items
346
+ elif isinstance(result, dict) and "items" in result:
347
+ items_raw = result["items"]
348
+ else:
349
+ items_raw = []
350
+
351
+ items: List[Dict] = []
352
+ for qa in items_raw:
353
+ out = qa.dict() if hasattr(qa, "dict") else dict(qa)
354
+ out["options"] = ensure_valid_colors(out.get("options", []))
355
+ out["source"] = "llm"
356
+ items.append(out)
357
+
358
+ if items:
359
+ return items
360
+ except Exception as e:
361
+ print("LLM batch generation failed:", e)
362
+
363
+ # Fallback generator (always returns items if themes not empty)
364
+ return offline_generate_batch(themes, state)
365
+
366
+ class SessionState:
367
+ def __init__(self, n_questions: int, batch_size: int, domain: str = "general", role: Optional[str] = None, profile: Optional[Dict] = None):
368
+ domain = (domain or role or "general").lower()
369
+ self.domain = domain if domain in DOMAINS else "general"
370
+ self.role = (role or self.domain)
371
+ self.profile = profile or {}
372
+ self.n_questions = max(1, min(n_questions, MAX_QUESTIONS))
373
+ self.batch_size = max(1, batch_size)
374
+ self.asked = 0
375
+ self.color_counts = {c: 0 for c in COLOR_KEYS}
376
+ self.history: List[Dict] = []
377
+ self.queue: List[Dict] = []
378
+ self.finished = False
379
+
380
+ def to_min_state(self) -> Dict:
381
+ total = sum(self.color_counts.values()) or 1
382
+ mix_percentages = {k: round((v / total) * 100, 2) for k, v in self.color_counts.items()}
383
+ dominant = max(self.color_counts, key=self.color_counts.get) if total else None
384
+ return {
385
+ "asked": self.asked,
386
+ "dominant": dominant,
387
+ "mix": mix_percentages,
388
+ "domain": self.domain,
389
+ "role": self.role,
390
+ "profile": summarize_profile(self.profile),
391
+ }
392
+
393
+ def remaining(self) -> int:
394
+ return self.n_questions - self.asked
395
+
396
+ SESSIONS_FILE = os.getenv("PYMATCH_SESSIONS_FILE", "sessions.json")
397
+ _sessions_lock = threading.Lock()
398
+ SESSIONS: Dict[str, SessionState] = {}
399
+
400
+ def save_sessions():
401
+ try:
402
+ with _sessions_lock:
403
+ serializable = {sid: s.__dict__ for sid, s in SESSIONS.items()}
404
+ tmp = SESSIONS_FILE + ".tmp"
405
+ with open(tmp, "w", encoding="utf-8") as f:
406
+ json.dump(serializable, f, ensure_ascii=False, indent=2, default=str)
407
+ os.replace(tmp, SESSIONS_FILE)
408
+ except Exception as e:
409
+ print("Failed to save sessions:", e)
410
+
411
+ def persist_final_progress(user_id: Optional[str], role: str, mix: Dict[str, float]) -> bool:
412
+ llm_id = str(uuid.uuid4())
413
+ blue = float(mix.get("blue", 0.0))
414
+ green = float(mix.get("green", 0.0))
415
+ yellow = float(mix.get("yellow", 0.0))
416
+ red = float(mix.get("red", 0.0))
417
+ try:
418
+ conn = get_db_connection()
419
+ cur = conn.cursor()
420
+ # Try with llm_id; if identity error, retry without it
421
+ try:
422
+ cur.execute(f"""
423
+ INSERT INTO [dbo].[{PROGRESS_TBL}]
424
+ ([llm_id],[user_id],[role],[blue],[green],[yellow],[red],[created_at])
425
+ VALUES (?,?,?,?,?,?,?,SYSUTCDATETIME())
426
+ """, (llm_id, str(user_id) if user_id is not None else None, role, blue, green, yellow, red))
427
+ conn.commit()
428
+ return True
429
+ except pyodbc.Error as e:
430
+ if "IDENTITY_INSERT" in str(e) or "(544)" in str(e):
431
+ cur.execute(f"""
432
+ INSERT INTO [dbo].[{PROGRESS_TBL}]
433
+ ([user_id],[role],[blue],[green],[yellow],[red],[created_at])
434
+ VALUES (?,?,?,?,?,?,SYSUTCDATETIME())
435
+ """, (str(user_id) if user_id is not None else None, role, blue, green, yellow, red))
436
+ conn.commit()
437
+ return True
438
+ else:
439
+ print("Persist failed:", e)
440
+ return False
441
+ except Exception as ex:
442
+ print("Persist final progress failed:", ex)
443
+ return False
444
+ finally:
445
+ try: conn.close()
446
+ except: pass
447
+
448
+ # -------------------------
449
+ # Profile fetch by role/id
450
+ # -------------------------
451
+ def fetch_profile_for_role(user_id: str, role: str) -> Dict:
452
+ """
453
+ Reads the correct table based on role and returns a dict of the latest row for that user.
454
+ Tables: Marriage | Interview | Partnership
455
+ """
456
+ table = {
457
+ "marriage": "Marriage",
458
+ "interview": "Interview",
459
+ "partnership": "Partnership"
460
+ }.get(role.lower())
461
+
462
+ if not table:
463
+ return {}
464
+
465
+ try:
466
+ conn = get_db_connection()
467
+ cur = conn.cursor()
468
+ # Prefer latest by created_at if present
469
+ cur.execute(f"""
470
+ SELECT TOP 1 *
471
+ FROM {table}
472
+ WHERE user_id = ?
473
+ ORDER BY created_at DESC
474
+ """, (user_id,))
475
+ row = cur.fetchone()
476
+ if row is None:
477
+ return {}
478
+ prof = row_to_dict(cur, row)
479
+ # Normalize hobbies_interests if it exists
480
+ if "hobbies_interests" in prof and isinstance(prof["hobbies_interests"], str):
481
+ if prof["hobbies_interests"].strip().startswith("["):
482
+ try:
483
+ prof["hobbies_interests"] = json.loads(prof["hobbies_interests"])
484
+ except Exception:
485
+ prof["hobbies_interests"] = [s.strip() for s in prof["hobbies_interests"].split(",") if s.strip()]
486
+ else:
487
+ prof["hobbies_interests"] = [s.strip() for s in prof["hobbies_interests"].split(",") if s.strip()]
488
+ prof["user_id"] = str(user_id)
489
+ return prof
490
+ except pyodbc.Error as e:
491
+ print("Profile fetch error:", e)
492
+ return {}
493
+ finally:
494
+ try: conn.close()
495
+ except: pass
496
+
497
+ # -------------------
498
+ # Theme chooser
499
+ # -------------------
500
+ def choose_themes(sess: SessionState, k: int) -> List[str]:
501
+ topics = TOPIC_BANK_BY_DOMAIN.get(sess.role, TOPIC_BANK_BY_DOMAIN["general"])
502
+ phrases = COLOR_PHRASES_BY_DOMAIN.get(sess.role, COLOR_PHRASES_BY_DOMAIN["general"])
503
+ themes: List[str] = []
504
+ for _ in range(k):
505
+ # bias to least-chosen color to balance
506
+ target = min(sess.color_counts, key=lambda c: sess.color_counts[c])
507
+ topic = random.choice(topics)
508
+ phrase = phrases[target]
509
+ themes.append(f"{phrase} around {topic}")
510
+ return themes
511
+
512
+ # ---------------
513
+ # Health / Home
514
+ # ---------------
515
+ @app.get("/health")
516
+ def health():
517
+ return {
518
+ "status": "ok",
519
+ "llm": ("openai" if CHAIN_BATCH is not None else "offline-fallback"),
520
+ "has_openai_key": bool(os.getenv("OPENAI_API_KEY")),
521
+ "db": {"server": SQL_SERVER, "database": SQL_DB, "table": PROGRESS_TBL},
522
+ }
523
+
524
+ @app.get("/")
525
+ def home():
526
+ return {
527
+ "message": "Unified Py-Match Service",
528
+ "try": [
529
+ "POST /api/signup",
530
+ "POST /api/questions/select-role",
531
+ "GET /api/questions/<role>",
532
+ "POST /api/questions/submit-answers/<role>",
533
+ "POST /llm/start (body: { user_id, role, n_questions, batch_size })",
534
+ "POST /llm/next (body: { session_id, selected_color })"
535
+ ]
536
+ }
537
+
538
+ # -------------------------
539
+ # LLM Session: start / next
540
+ # -------------------------
541
+ @app.post("/llm/start")
542
+ def llm_start():
543
+ """
544
+ Starts a session by fetching the profile for (user_id, role),
545
+ then generating the first question batch. No need to send profile in body.
546
+ Body:
547
+ { "user_id": "1", "role": "marriage", "n_questions": 5, "batch_size": 5 }
548
+ """
549
+ data = request.get_json(force=True) or {}
550
+ user_id = str(data.get("user_id") or "").strip()
551
+ role_in = (data.get("role") or "general").lower()
552
+ n_req = int(data.get("n_questions", 15))
553
+ b_req = int(data.get("batch_size", DEFAULT_BATCH_SIZE))
554
+
555
+ if not user_id:
556
+ return jsonify({"error": "user_id is required"}), 400
557
+ if role_in not in DOMAINS:
558
+ return jsonify({"error": f"Invalid role. Allowed: {', '.join(DOMAINS)}"}), 400
559
+
560
+ # Fetch profile from the correct table based on role
561
+ profile = fetch_profile_for_role(user_id, role_in)
562
+
563
+ # Create session
564
+ sid = str(uuid.uuid4())
565
+ sess = SessionState(n_questions=n_req, batch_size=b_req, domain=role_in, role=role_in, profile=profile)
566
+ SESSIONS[sid] = sess
567
+
568
+ to_generate = min(sess.batch_size, sess.remaining())
569
+ themes = choose_themes(sess, to_generate)
570
+ queue = generate_batch_questions(themes, sess.to_min_state())
571
+ if not queue:
572
+ return jsonify({"error": "Question generation failed"}), 500
573
+ sess.queue = queue
574
+
575
+ # Serve first question
576
+ first = sess.queue.pop(0)
577
+ sess.asked += 1
578
+ save_sessions()
579
+
580
+ return jsonify({
581
+ "session_id": sid,
582
+ "index": 1,
583
+ "total": sess.n_questions,
584
+ "question": first["question"],
585
+ "options": first["options"],
586
+ "source": first.get("source", "unknown"),
587
+ "role": sess.role,
588
+ "profile_used": bool(profile) # helpful flag
589
+ })
590
+
591
+ @app.post("/llm/next")
592
+ def llm_next():
593
+ """
594
+ Continue a running session with user's selected color.
595
+ Body:
596
+ { "session_id": "...", "selected_color": "blue|green|red|yellow" }
597
+ """
598
+ data = request.get_json(force=True) or {}
599
+ sid = data.get("session_id")
600
+ color = str(data.get("selected_color") or "").lower()
601
+
602
+ if not sid or sid not in SESSIONS:
603
+ return jsonify({"error": "Invalid or missing session_id"}), 400
604
+ if color not in COLOR_KEYS:
605
+ return jsonify({"error": "selected_color must be blue|green|red|yellow"}), 400
606
+
607
+ sess = SESSIONS[sid]
608
+ if sess.finished:
609
+ return jsonify({"done": True, "message": "Session already finished."})
610
+
611
+ # record answer
612
+ sess.color_counts[color] += 1
613
+ sess.history.append({"selected_color": color})
614
+
615
+ # finished?
616
+ if sess.asked >= sess.n_questions:
617
+ sess.finished = True
618
+ mix = sess.to_min_state()["mix"]
619
+ user_id = (sess.profile or {}).get("user_id")
620
+ db_ok = persist_final_progress(user_id=user_id, role=sess.role, mix=mix)
621
+ save_sessions()
622
+ return jsonify({"done": True, "message": "No more questions.", "mix": mix, "db_write": "ok" if db_ok else "failed"})
623
+
624
+ # ensure queue; refill if needed
625
+ if not sess.queue:
626
+ to_generate = min(sess.batch_size, sess.remaining())
627
+ themes = choose_themes(sess, to_generate)
628
+ sess.queue = generate_batch_questions(themes, sess.to_min_state())
629
+ if not sess.queue:
630
+ return jsonify({"error": "Question generation failed"}), 500
631
+
632
+ nxt = sess.queue.pop(0)
633
+ sess.asked += 1
634
+ save_sessions()
635
+
636
+ return jsonify({
637
+ "session_id": sid,
638
+ "index": sess.asked,
639
+ "total": sess.n_questions,
640
+ "question": nxt["question"],
641
+ "options": nxt["options"],
642
+ "progress": sess.to_min_state()["mix"],
643
+ "source": nxt.get("source", "unknown"),
644
+ "role": sess.role
645
+ })
646
+
647
+ # =========
648
+ # Run app
649
+ # =========
650
+ # if __name__ == "__main__":
651
+ # app.run(host="0.0.0.0", port=5000, debug=True)
652
+
653
+ if __name__ == "__main__":
654
+ import os
655
+ # Default to 5000 for local runs; HF Spaces injects PORT=7860 automatically
656
+ port = int(os.getenv("PORT", "5000"))
657
+ app.run(host="0.0.0.0", port=port, debug=False)
658
+