Spaces:
Sleeping
Sleeping
Oviya commited on
Commit ·
908ab0e
1
Parent(s): af572d4
deploy
Browse files- Dockerfile +8 -12
- requirements.txt +2 -1
- server.py +121 -84
Dockerfile
CHANGED
|
@@ -1,30 +1,26 @@
|
|
| 1 |
-
#
|
| 2 |
-
|
| 3 |
FROM python:3.11-slim
|
| 4 |
|
| 5 |
-
# Avoid interactive prompts
|
| 6 |
ENV DEBIAN_FRONTEND=noninteractive
|
|
|
|
| 7 |
|
| 8 |
-
#
|
| 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 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
PROGRESS_TBL = os.getenv("PYMATCH_PROGRESS_TABLE", "LLMGeneratedQuestions")
|
| 29 |
|
| 30 |
def get_db_connection():
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
)
|
| 37 |
|
| 38 |
# ==========
|
| 39 |
# Flask App
|
| 40 |
# ==========
|
| 41 |
app = Flask(__name__)
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 83 |
-
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 494 |
-
|
| 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":
|
| 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)
|
| 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)
|
|
|