Oviya commited on
Commit
908ab0e
·
1 Parent(s): af572d4
Files changed (3) hide show
  1. Dockerfile +8 -12
  2. requirements.txt +2 -1
  3. server.py +121 -84
Dockerfile CHANGED
@@ -1,30 +1,26 @@
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"]
 
1
+ # Dockerfile
 
2
  FROM python:3.11-slim
3
 
 
4
  ENV DEBIAN_FRONTEND=noninteractive
5
+ ENV ACCEPT_EULA=Y
6
 
7
+ # unixODBC + Microsoft repo + ODBC 17
8
  RUN apt-get update && apt-get install -y --no-install-recommends \
9
+ curl gnupg apt-transport-https ca-certificates build-essential unixodbc unixodbc-dev \
10
+ && curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add - \
11
+ && curl https://packages.microsoft.com/config/debian/12/prod.list > /etc/apt/sources.list.d/mssql-release.list \
12
+ && apt-get update \
13
+ && apt-get install -y --no-install-recommends msodbcsql17 \
14
  && rm -rf /var/lib/apt/lists/*
15
 
 
16
  WORKDIR /app
17
 
 
18
  COPY requirements.txt /app/
19
  RUN pip install --no-cache-dir -r requirements.txt
20
 
 
21
  COPY server.py /app/
22
 
 
23
  ENV PORT=7860
 
 
24
  EXPOSE 7860
25
 
 
26
  CMD ["python", "server.py"]
requirements.txt CHANGED
@@ -3,4 +3,5 @@ flask-cors==4.0.1
3
  pyodbc==5.1.0
4
  pydantic==2.8.2
5
  langchain-core==0.3.29 # any >=0.3.12,<0.4.0 works
6
- langchain-openai==0.2.3
 
 
3
  pyodbc==5.1.0
4
  pydantic==2.8.2
5
  langchain-core==0.3.29 # any >=0.3.12,<0.4.0 works
6
+ langchain-openai==0.2.3
7
+ python-dotenv==1.0.1
server.py CHANGED
@@ -1,12 +1,24 @@
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
@@ -20,26 +32,47 @@ except Exception:
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
@@ -54,13 +87,13 @@ def row_to_dict(cursor, row) -> Dict:
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:
@@ -71,21 +104,52 @@ def signup():
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():
@@ -105,12 +169,10 @@ def select_role():
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):
@@ -127,6 +189,8 @@ def get_questions(role):
127
  ORDER BY id
128
  """, (role,))
129
  rows = cur.fetchall()
 
 
130
  out = []
131
  for r in rows:
132
  label = r[0]
@@ -142,9 +206,6 @@ def get_questions(role):
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):
@@ -194,12 +255,10 @@ def submit_answers(role):
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)
@@ -230,7 +289,7 @@ 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
@@ -274,7 +333,6 @@ if HAS_LLM_STACK and os.getenv("OPENAI_API_KEY"):
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()
@@ -330,7 +388,6 @@ def offline_generate_batch(themes: List[str], state: Dict) -> List[Dict]:
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 = {
@@ -338,11 +395,10 @@ def generate_batch_questions(themes: List[str], state: Dict) -> List[Dict]:
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:
@@ -360,7 +416,6 @@ def generate_batch_questions(themes: List[str], state: Dict) -> List[Dict]:
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:
@@ -401,9 +456,16 @@ 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)
@@ -417,7 +479,6 @@ def persist_final_progress(user_id: Optional[str], role: str, mix: Dict[str, flo
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}]
@@ -425,6 +486,7 @@ def persist_final_progress(user_id: Optional[str], role: str, mix: Dict[str, flo
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):
@@ -434,49 +496,40 @@ def persist_final_progress(user_id: Optional[str], role: str, mix: Dict[str, flo
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:
@@ -490,19 +543,12 @@ def fetch_profile_for_role(user_id: str, role: str) -> Dict:
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]
@@ -518,7 +564,7 @@ def health():
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("/")
@@ -527,6 +573,7 @@ def home():
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>",
@@ -540,12 +587,6 @@ def home():
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()
@@ -557,10 +598,8 @@ def llm_start():
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
@@ -572,7 +611,6 @@ def llm_start():
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()
@@ -585,16 +623,11 @@ def llm_start():
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()
@@ -608,11 +641,9 @@ def llm_next():
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"]
@@ -621,7 +652,6 @@ def llm_next():
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)
@@ -644,15 +674,22 @@ def llm_next():
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
-
 
1
  # server.py
2
+ # -----------------------------------------------------------------------------
3
+ # Unified Py-Match Service (Flask)
4
+ # - Local Windows: Trusted_Connection to SQL Server (e.g., localhost\SQLEXPRESS)
5
+ # - Cloud (AWS RDS / Hugging Face): SQL auth via UID/PWD + Encrypt
6
+ # - ODBC driver name defaults to {ODBC Driver 17 for SQL Server}
7
+ # -----------------------------------------------------------------------------
8
+
9
  import os, uuid, json, random, threading, hashlib
10
  from typing import Dict, List, Optional, Literal
11
  from datetime import datetime
12
 
13
+ from flask import Flask, request, jsonify, make_response
14
  from flask_cors import CORS
15
+ from dotenv import load_dotenv
16
  import pyodbc
17
 
18
+ # Load .env first
19
+ BASEDIR = os.path.abspath(os.path.dirname(__file__))
20
+ load_dotenv(os.path.join(BASEDIR, ".env"))
21
+
22
  # ---------- Optional LLM deps (fallback if missing) ----------
23
  try:
24
  from pydantic import BaseModel, Field
 
32
  # ==============================
33
  # Configuration / DB Connection
34
  # ==============================
35
+ # Server / DB
36
+ DB_SERVER = os.getenv("PYMATCH_SQL_SERVER", "localhost\\SQLEXPRESS")
37
+ DB_DATABASE = os.getenv("PYMATCH_SQL_DB", "PyMatch")
38
+
39
+ # Credentials (prefer the names you used in the other project)
40
+ DB_USER = os.getenv("DB_USER") or os.getenv("PYMATCH_SQL_USER")
41
+ DB_PASSWORD = os.getenv("DB_PASSWORD") or os.getenv("PYMATCH_SQL_PASSWORD")
42
+
43
+ # Driver (use v17 string to match your other app)
44
+ ODBC_DRIVER = os.getenv("PYMATCH_ODBC_DRIVER", "{ODBC Driver 17 for SQL Server}")
45
+
46
+ # Build a single connection string like your verification service:
47
+ # - If localhost or an instance name is present -> Trusted_Connection (Windows only)
48
+ # - Else -> SQL auth with Encrypt + TrustServerCertificate (for RDS/HF)
49
+ if DB_SERVER.lower().startswith("localhost") or "\\" in DB_SERVER:
50
+ CONN_STR = f"DRIVER={{SQL Server}};SERVER={DB_SERVER};DATABASE={DB_DATABASE};Trusted_Connection=yes"
51
+ else:
52
+ CONN_STR = (
53
+ f"DRIVER={ODBC_DRIVER};"
54
+ f"SERVER={DB_SERVER};DATABASE={DB_DATABASE};"
55
+ f"UID={DB_USER};PWD={DB_PASSWORD};"
56
+ "Encrypt=yes;TrustServerCertificate=yes"
57
+ )
58
 
59
  PROGRESS_TBL = os.getenv("PYMATCH_PROGRESS_TABLE", "LLMGeneratedQuestions")
60
 
61
  def get_db_connection():
62
+ """Make a short-timeout connection. Fail clearly if secrets are missing."""
63
+ if "Trusted_Connection=yes" not in CONN_STR:
64
+ if not DB_USER or not DB_PASSWORD:
65
+ raise RuntimeError("DB_USER/DB_PASSWORD are not set for SQL authentication.")
66
+ return pyodbc.connect(CONN_STR, timeout=5)
 
67
 
68
  # ==========
69
  # Flask App
70
  # ==========
71
  app = Flask(__name__)
72
+ # CORS: allow your dev UI by default; override with ALLOWED_ORIGINS in .env
73
+ _origins = os.getenv("ALLOWED_ORIGINS", "http://localhost:4200")
74
+ ALLOWED_ORIGINS = [o.strip() for o in _origins.split(",") if o.strip()]
75
+ CORS(app, supports_credentials=True, origins=ALLOWED_ORIGINS)
76
 
77
  # ==========
78
  # Utilities
 
87
  return {cols[i]: row[i] for i in range(len(cols))}
88
 
89
  # =======================
90
+ # 1) AUTH / SIGNUP & LOGIN
91
  # =======================
92
  @app.post("/api/signup")
93
  def signup():
94
  data = request.get_json(force=True) or {}
95
  name = data.get("name")
96
+ email = (data.get("email") or "").strip()
97
  password = data.get("password")
98
 
99
  if not name or not email or not password:
 
104
  try:
105
  conn = get_db_connection()
106
  cur = conn.cursor()
107
+
108
+ # prevent duplicate email
109
+ cur.execute("SELECT 1 FROM Users WHERE email = ?", (email,))
110
+ if cur.fetchone():
111
+ conn.close()
112
+ return jsonify({"error": "Email already registered."}), 409
113
+
114
  cur.execute("""
115
  INSERT INTO Users (name, email, password)
116
  VALUES (?, ?, ?)
117
  """, (name, email, password_hash))
118
  conn.commit()
119
+ conn.close()
120
  return jsonify({"message": "User created successfully."}), 201
121
  except pyodbc.Error as e:
122
  return jsonify({"error": f"DB error: {e}"}), 500
123
+
124
+ @app.post("/api/login")
125
+ def login():
126
+ data = request.get_json(force=True) or {}
127
+ email = (data.get("email") or "").strip()
128
+ password = data.get("password") or ""
129
+ if not email or not password:
130
+ return jsonify({"error": "Email and password are required."}), 400
131
+
132
+ try:
133
+ conn = get_db_connection()
134
+ cur = conn.cursor()
135
+ cur.execute("SELECT id, name, password FROM Users WHERE email = ?", (email,))
136
+ row = cur.fetchone()
137
+ conn.close()
138
+ except pyodbc.Error as e:
139
+ return jsonify({"error": f"DB error: {e}"}), 500
140
+
141
+ if not row:
142
+ return jsonify({"error": "Invalid credentials."}), 401
143
+
144
+ user_id, name, stored_hash = int(row[0]), str(row[1]), str(row[2])
145
+ if hash_password(password) != stored_hash:
146
+ return jsonify({"error": "Invalid credentials."}), 401
147
+
148
+ # Simple session payload (your UI likely just needs these)
149
+ return jsonify({"user_id": user_id, "name": name, "email": email}), 200
150
 
151
  # ==================================================
152
  # 2) ROLE SELECTION + STATIC QUESTION FETCH + SAVE
 
153
  # ==================================================
154
  @app.post("/api/questions/select-role")
155
  def select_role():
 
169
  VALUES (?, ?, ?)
170
  """, (user_id, role_name, assigned_at))
171
  conn.commit()
172
+ conn.close()
173
  return jsonify({"message": "Role assigned successfully."}), 201
174
  except pyodbc.Error as e:
175
  return jsonify({"error": str(e)}), 500
 
 
 
176
 
177
  @app.get("/api/questions/<role>")
178
  def get_questions(role):
 
189
  ORDER BY id
190
  """, (role,))
191
  rows = cur.fetchall()
192
+ conn.close()
193
+
194
  out = []
195
  for r in rows:
196
  label = r[0]
 
206
  return jsonify(out), 200
207
  except pyodbc.Error as e:
208
  return jsonify({"error": str(e)}), 500
 
 
 
209
 
210
  @app.post("/api/questions/submit-answers/<role>")
211
  def submit_answers(role):
 
255
  cur = conn.cursor()
256
  cur.execute(query, values)
257
  conn.commit()
258
+ conn.close()
259
  return jsonify({"message": f"{role.capitalize()} record added successfully."}), 201
260
  except pyodbc.Error as e:
261
  return jsonify({"error": str(e)}), 500
 
 
 
262
 
263
  # ===========================================
264
  # 3) LLM BATCH Q-GEN + COLOR % PERSIST (LLM)
 
289
 
290
  # ---- LLM chain (optional) ----
291
  PARSER_BATCH = None
292
+ CHAIN_BATCH = None
293
  if HAS_LLM_STACK and os.getenv("OPENAI_API_KEY"):
294
  class Option(BaseModel):
295
  text: str
 
333
  ("system", SYSTEM_PROMPT),
334
  ("user", USER_PROMPT_BATCH),
335
  ])
 
336
  return prompt | llm | PARSER_BATCH
337
 
338
  CHAIN_BATCH = build_batch_chain()
 
388
  return items
389
 
390
  def generate_batch_questions(themes: List[str], state: Dict) -> List[Dict]:
 
391
  if CHAIN_BATCH is not None and PARSER_BATCH is not None:
392
  try:
393
  payload = {
 
395
  "themes_json": json.dumps(themes, ensure_ascii=False),
396
  "format_instructions": PARSER_BATCH.get_format_instructions(),
397
  }
 
398
  result = CHAIN_BATCH.invoke(payload)
399
 
400
  if hasattr(result, "items"):
401
+ items_raw = result.items
402
  elif isinstance(result, dict) and "items" in result:
403
  items_raw = result["items"]
404
  else:
 
416
  except Exception as e:
417
  print("LLM batch generation failed:", e)
418
 
 
419
  return offline_generate_batch(themes, state)
420
 
421
  class SessionState:
 
456
  try:
457
  with _sessions_lock:
458
  serializable = {sid: s.__dict__ for sid, s in SESSIONS.items()}
459
+
460
+ # Convert datetime objects to string to avoid JSON issues
461
+ for s in serializable.values():
462
+ for k, v in list(s.items()):
463
+ if isinstance(v, datetime):
464
+ s[k] = v.isoformat()
465
+
466
  tmp = SESSIONS_FILE + ".tmp"
467
  with open(tmp, "w", encoding="utf-8") as f:
468
+ json.dump(serializable, f, ensure_ascii=False, indent=2)
469
  os.replace(tmp, SESSIONS_FILE)
470
  except Exception as e:
471
  print("Failed to save sessions:", e)
 
479
  try:
480
  conn = get_db_connection()
481
  cur = conn.cursor()
 
482
  try:
483
  cur.execute(f"""
484
  INSERT INTO [dbo].[{PROGRESS_TBL}]
 
486
  VALUES (?,?,?,?,?,?,?,SYSUTCDATETIME())
487
  """, (llm_id, str(user_id) if user_id is not None else None, role, blue, green, yellow, red))
488
  conn.commit()
489
+ conn.close()
490
  return True
491
  except pyodbc.Error as e:
492
  if "IDENTITY_INSERT" in str(e) or "(544)" in str(e):
 
496
  VALUES (?,?,?,?,?,?,SYSUTCDATETIME())
497
  """, (str(user_id) if user_id is not None else None, role, blue, green, yellow, red))
498
  conn.commit()
499
+ conn.close()
500
  return True
501
  else:
502
  print("Persist failed:", e)
503
+ conn.close()
504
  return False
505
  except Exception as ex:
506
  print("Persist final progress failed:", ex)
507
  return False
 
 
 
508
 
 
 
 
509
  def fetch_profile_for_role(user_id: str, role: str) -> Dict:
 
 
 
 
510
  table = {
511
  "marriage": "Marriage",
512
  "interview": "Interview",
513
  "partnership": "Partnership"
514
  }.get(role.lower())
 
515
  if not table:
516
  return {}
517
 
518
  try:
519
  conn = get_db_connection()
520
  cur = conn.cursor()
 
521
  cur.execute(f"""
522
+ SELECT TOP 1 * FROM {table}
 
523
  WHERE user_id = ?
524
  ORDER BY created_at DESC
525
  """, (user_id,))
526
  row = cur.fetchone()
527
  if row is None:
528
+ conn.close()
529
  return {}
530
  prof = row_to_dict(cur, row)
531
+ conn.close()
532
+
533
  if "hobbies_interests" in prof and isinstance(prof["hobbies_interests"], str):
534
  if prof["hobbies_interests"].strip().startswith("["):
535
  try:
 
543
  except pyodbc.Error as e:
544
  print("Profile fetch error:", e)
545
  return {}
546
+
547
+ def choose_themes(sess: 'SessionState', k: int) -> List[str]:
 
 
 
 
 
 
548
  topics = TOPIC_BANK_BY_DOMAIN.get(sess.role, TOPIC_BANK_BY_DOMAIN["general"])
549
  phrases = COLOR_PHRASES_BY_DOMAIN.get(sess.role, COLOR_PHRASES_BY_DOMAIN["general"])
550
  themes: List[str] = []
551
  for _ in range(k):
 
552
  target = min(sess.color_counts, key=lambda c: sess.color_counts[c])
553
  topic = random.choice(topics)
554
  phrase = phrases[target]
 
564
  "status": "ok",
565
  "llm": ("openai" if CHAIN_BATCH is not None else "offline-fallback"),
566
  "has_openai_key": bool(os.getenv("OPENAI_API_KEY")),
567
+ "db": {"server": DB_SERVER, "database": DB_DATABASE, "table": PROGRESS_TBL},
568
  }
569
 
570
  @app.get("/")
 
573
  "message": "Unified Py-Match Service",
574
  "try": [
575
  "POST /api/signup",
576
+ "POST /api/login",
577
  "POST /api/questions/select-role",
578
  "GET /api/questions/<role>",
579
  "POST /api/questions/submit-answers/<role>",
 
587
  # -------------------------
588
  @app.post("/llm/start")
589
  def llm_start():
 
 
 
 
 
 
590
  data = request.get_json(force=True) or {}
591
  user_id = str(data.get("user_id") or "").strip()
592
  role_in = (data.get("role") or "general").lower()
 
598
  if role_in not in DOMAINS:
599
  return jsonify({"error": f"Invalid role. Allowed: {', '.join(DOMAINS)}"}), 400
600
 
 
601
  profile = fetch_profile_for_role(user_id, role_in)
602
 
 
603
  sid = str(uuid.uuid4())
604
  sess = SessionState(n_questions=n_req, batch_size=b_req, domain=role_in, role=role_in, profile=profile)
605
  SESSIONS[sid] = sess
 
611
  return jsonify({"error": "Question generation failed"}), 500
612
  sess.queue = queue
613
 
 
614
  first = sess.queue.pop(0)
615
  sess.asked += 1
616
  save_sessions()
 
623
  "options": first["options"],
624
  "source": first.get("source", "unknown"),
625
  "role": sess.role,
626
+ "profile_used": bool(profile)
627
  })
628
 
629
  @app.post("/llm/next")
630
  def llm_next():
 
 
 
 
 
631
  data = request.get_json(force=True) or {}
632
  sid = data.get("session_id")
633
  color = str(data.get("selected_color") or "").lower()
 
641
  if sess.finished:
642
  return jsonify({"done": True, "message": "Session already finished."})
643
 
 
644
  sess.color_counts[color] += 1
645
  sess.history.append({"selected_color": color})
646
 
 
647
  if sess.asked >= sess.n_questions:
648
  sess.finished = True
649
  mix = sess.to_min_state()["mix"]
 
652
  save_sessions()
653
  return jsonify({"done": True, "message": "No more questions.", "mix": mix, "db_write": "ok" if db_ok else "failed"})
654
 
 
655
  if not sess.queue:
656
  to_generate = min(sess.batch_size, sess.remaining())
657
  themes = choose_themes(sess, to_generate)
 
674
  "role": sess.role
675
  })
676
 
677
+ # ---------- Diagnostics ----------
678
+ @app.get("/db/ping")
679
+ def db_ping():
680
+ try:
681
+ conn = get_db_connection()
682
+ cur = conn.cursor()
683
+ cur.execute("SELECT 1")
684
+ v = cur.fetchone()[0]
685
+ conn.close()
686
+ return {"ok": True, "value": int(v)}
687
+ except Exception as e:
688
+ return {"ok": False, "error": str(e)}, 500
689
+
690
  # =========
691
  # Run app
692
  # =========
 
 
 
693
  if __name__ == "__main__":
 
 
694
  port = int(os.getenv("PORT", "5000"))
695
  app.run(host="0.0.0.0", port=port, debug=False)