TokenTutor commited on
Commit
e32f3cb
·
verified ·
1 Parent(s): 32abab0

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +223 -0
app.py ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ from typing import Any, Dict, Optional
4
+
5
+ from flask import Flask, request, jsonify
6
+
7
+ from openai import OpenAI
8
+
9
+ from langchain_community.vectorstores import Chroma
10
+ from langchain_community.embeddings.sentence_transformer import SentenceTransformerEmbeddings
11
+
12
+ # ----------------------------
13
+ # Configuration
14
+ # ----------------------------
15
+ CHROMA_DIR = os.getenv("CHROMA_DIR", "./chroma_db")
16
+ EMBEDDING_MODEL_NAME = os.getenv("EMBEDDING_MODEL_NAME", "thenlper/gte-large")
17
+ OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4.1-nano")
18
+
19
+ # Hugging Face Spaces: put your key in "Settings -> Secrets" as OPENAI_API_KEY
20
+ OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
21
+
22
+ if not OPENAI_API_KEY:
23
+ raise RuntimeError(
24
+ "Missing OPENAI_API_KEY environment variable. "
25
+ "In Hugging Face Spaces, add it in Settings -> Secrets."
26
+ )
27
+
28
+ client = OpenAI(api_key=OPENAI_API_KEY)
29
+
30
+ # ----------------------------
31
+ # Prompt + JSON schema (from notebook)
32
+ # ----------------------------
33
+ DEV_PROMPT = """
34
+ You are a Texas Grade 5 Mathematics tutor for kids, and you also support parents and teachers.
35
+ Your tone must be kid-safe, friendly, clear, and encouraging. Keep explanations simple, accurate, and non-judgmental.
36
+
37
+ CRITICAL OUTPUT RULES:
38
+ - Output MUST be valid JSON only (no markdown, no code fences, no extra text).
39
+ - Use double quotes for all JSON keys/strings.
40
+ - Do not include trailing commas.
41
+ - Keep responses safe for kids (no unsafe, hateful, sexual, violent, or scary content).
42
+
43
+ SCOPE RULE:
44
+ - Only answer mathematics (Grade 5 level preferred; you may briefly define advanced terms if asked).
45
+ - If the user asks anything not related to mathematics, respond ONLY with:
46
+ {"type":"Refusal","message":"Sorry, I can't answer questions other than mathematics."}
47
+
48
+ OUTPUT TYPES:
49
+ 1) Concept explanation:
50
+ {
51
+ "type": "Concept",
52
+ "message": "Short kid-friendly explanation..."
53
+ }
54
+
55
+ 2) Practice questions (MCQ):
56
+ - Create up to 5 questions maximum. If user requests more than 5, generate only 5.
57
+ - Each question MUST have exactly 4 answer choices.
58
+ - The correct answer MUST be one of the 4 choices.
59
+ - Provide the correct answer explicitly using "CorrectOption" (A/B/C/D) and "CorrectAnswer" (exact matching text from Answers).
60
+ - Keep math appropriate and compute accurately. Avoid trick questions.
61
+
62
+ {
63
+ "type": "Questions",
64
+ "message": [
65
+ {
66
+ "Q1": "Question text",
67
+ "Answers": { "A": "Option 1", "B": "Option 2", "C": "Option 3", "D": "Option 4" },
68
+ "CorrectOption": "B",
69
+ "CorrectAnswer": "Option 2"
70
+ }
71
+ ]
72
+ }
73
+
74
+ ACCURACY / ANTI-HALLUCINATION RULES:
75
+ - Do the math carefully. Ensure only one correct option unless the user explicitly asks for multiple correct answers.
76
+ - If you detect ambiguity (missing numbers/units), ask ONE clarifying question using:
77
+ {"type":"Concept","message":"I need one detail to answer: ..."}
78
+ (Keep it math-only and kid-safe.)
79
+
80
+ STYLE GUIDELINES:
81
+ - Use simple words and short sentences for kids.
82
+ - For parents/teachers, add a brief note on how to support learning (1–2 sentences).
83
+ - Avoid unrelated topics, brand names, or personal data requests.
84
+ """.strip()
85
+
86
+ JSON_SCHEMA: Dict[str, Any] = {
87
+ "type": "object",
88
+ "additionalProperties": False,
89
+ "required": ["type", "message"],
90
+ "properties": {
91
+ "type": {"type": "string", "enum": ["Concept", "Questions", "Refusal"]},
92
+ "message": {
93
+ "anyOf": [
94
+ {"type": "string"},
95
+ {
96
+ "type": "array",
97
+ "maxItems": 5,
98
+ "items": {
99
+ "type": "object",
100
+ "additionalProperties": False,
101
+ "required": ["Q1", "Answers", "CorrectOption", "CorrectAnswer"],
102
+ "properties": {
103
+ "Q1": {"type": "string"},
104
+ "Answers": {
105
+ "type": "object",
106
+ "additionalProperties": False,
107
+ "required": ["A", "B", "C", "D"],
108
+ "properties": {
109
+ "A": {"type": "string"},
110
+ "B": {"type": "string"},
111
+ "C": {"type": "string"},
112
+ "D": {"type": "string"},
113
+ },
114
+ },
115
+ "CorrectOption": {"type": "string", "enum": ["A", "B", "C", "D"]},
116
+ "CorrectAnswer": {"type": "string"},
117
+ },
118
+ },
119
+ },
120
+ ]
121
+ },
122
+ },
123
+ }
124
+
125
+ # ----------------------------
126
+ # Vector DB + Retriever (loaded once at startup)
127
+ # ----------------------------
128
+ embedding_model = SentenceTransformerEmbeddings(model_name=EMBEDDING_MODEL_NAME)
129
+
130
+ vectorstore = Chroma(
131
+ persist_directory=CHROMA_DIR,
132
+ embedding_function=embedding_model,
133
+ )
134
+
135
+ retriever = vectorstore.as_retriever(
136
+ search_type="similarity",
137
+ search_kwargs={"k": 3},
138
+ )
139
+
140
+ # ----------------------------
141
+ # RAG helpers
142
+ # ----------------------------
143
+ def generate_context_from_input(user_input: str) -> str:
144
+ """Retrieve relevant chunks from the vector store and return as a single context string."""
145
+ rel_docs = retriever.invoke(user_input)
146
+ context_list = [d.page_content for d in rel_docs]
147
+ return ". ".join(context_list)
148
+
149
+ def get_llm_response(user_input: str, context: str = "") -> str:
150
+ """Call OpenAI Chat/Responses API and return the model output text (JSON string)."""
151
+ messages = [{"role": "developer", "content": DEV_PROMPT}]
152
+
153
+ if context and context.strip():
154
+ messages.append(
155
+ {
156
+ "role": "developer",
157
+ "content": (
158
+ "Use the following CONTEXT only if it is relevant to the user's request. "
159
+ "Do not invent facts that are not in the context.\n"
160
+ "BEGIN_CONTEXT\n"
161
+ f"{context}\n"
162
+ "END_CONTEXT"
163
+ ),
164
+ }
165
+ )
166
+
167
+ messages.append({"role": "user", "content": user_input})
168
+
169
+ resp = client.responses.create(
170
+ model=OPENAI_MODEL,
171
+ input=messages,
172
+ temperature=0.2,
173
+ max_output_tokens=800,
174
+ text={
175
+ "format": {
176
+ "type": "json_schema",
177
+ "name": "grade5_math_response",
178
+ "strict": True,
179
+ "schema": JSON_SCHEMA,
180
+ }
181
+ },
182
+ )
183
+ return resp.output_text
184
+
185
+ def generate_response_from_rag(user_input: str) -> Dict[str, Any]:
186
+ context = generate_context_from_input(user_input)
187
+ raw = get_llm_response(user_input=user_input, context=context)
188
+
189
+ # Ensure we return valid JSON to the client even if model output is slightly off.
190
+ try:
191
+ return json.loads(raw)
192
+ except Exception:
193
+ return {"type": "Concept", "message": raw}
194
+
195
+ # ----------------------------
196
+ # Flask API
197
+ # ----------------------------
198
+ app = Flask(__name__)
199
+
200
+ @app.get("/")
201
+ def health():
202
+ return jsonify({"status": "ok"})
203
+
204
+ @app.post("/MathQuestion")
205
+ def math_question():
206
+ payload = request.get_json(silent=True) or {}
207
+ query = payload.get("Query") or payload.get("query")
208
+
209
+ if not query or not isinstance(query, str):
210
+ return jsonify({"error": 'Missing required field "Query" (string).'}), 400
211
+
212
+ try:
213
+ result = generate_response_from_rag(query.strip())
214
+ return jsonify(result)
215
+ except Exception as e:
216
+ # Avoid leaking secrets; return safe error.
217
+ return jsonify({"error": "Server error while generating response.", "details": str(e)}), 500
218
+
219
+
220
+ if __name__ == "__main__":
221
+ # Hugging Face Spaces (Docker) expects port 7860
222
+ port = int(os.getenv("PORT", "7860"))
223
+ app.run(host="0.0.0.0", port=port)