Spaces:
Sleeping
Sleeping
Oviya
commited on
Commit
·
f328ff1
1
Parent(s):
17dd67a
deploy
Browse files- .gitignore +113 -0
- Dockerfile +30 -0
- requirements.txt +6 -0
- 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 |
+
|