digifreely commited on
Commit
9e2655d
Β·
verified Β·
1 Parent(s): c4165e5

Upload 4 files

Browse files
Files changed (4) hide show
  1. Dockerfile +12 -0
  2. README.md +4 -7
  3. app.py +274 -0
  4. requirements.txt +6 -0
Dockerfile ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY app.py .
9
+
10
+ EXPOSE 7860
11
+
12
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,11 +1,8 @@
1
  ---
2
- title: Init
3
- emoji: πŸ”₯
4
- colorFrom: green
5
- colorTo: green
6
  sdk: docker
7
  pinned: false
8
- license: mit
9
  ---
10
-
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
+ title: Maria PT API
3
+ emoji: πŸŽ“
4
+ colorFrom: blue
5
+ colorTo: purple
6
  sdk: docker
7
  pinned: false
 
8
  ---
 
 
app.py ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Maria PT - HuggingFace Spaces Backend
3
+ ======================================
4
+ Auth: SHA-256 hash check (primary) or Cloudflare Referer/Domain check (fallback)
5
+ Endpoints: GET /ping | POST /base_start
6
+ """
7
+
8
+ import os
9
+ import hashlib
10
+ import logging
11
+ import httpx
12
+ from fastapi import FastAPI, Request, HTTPException, status
13
+ from fastapi.responses import JSONResponse
14
+ from fastapi.middleware.cors import CORSMiddleware
15
+ from pydantic import BaseModel
16
+ from pymongo import MongoClient
17
+ from pymongo.errors import ConnectionFailure, OperationFailure
18
+ from typing import Optional
19
+
20
+ # ─────────────────────────────────────────────
21
+ # Logging
22
+ # ─────────────────────────────────────────────
23
+ logging.basicConfig(level=logging.INFO)
24
+ logger = logging.getLogger("maria_pt")
25
+
26
+ # ─────────────────────────────────────────────
27
+ # Environment / Secrets (set in HF Space Settings β†’ Secrets)
28
+ # ─────────────────────────────────────────────
29
+ EXPECTED_HASH = os.environ.get("EXPECTED_HASH",
30
+ "7208839b7c5d8e5418a492ee2eadaf15d9d32ba7f7c9a4c0f0486cfc358a96c2")
31
+ MONGO_PASSWORD = os.environ.get("MONGO_PASSWORD", "")
32
+ MONGO_URI = os.environ.get(
33
+ "MONGO_URI",
34
+ f"mongodb+srv://testuser:{MONGO_PASSWORD}@cluster0.ntz2mpi.mongodb.net/"
35
+ )
36
+ MONGO_DB = os.environ.get("MONGO_DB", "MariaPTDB")
37
+ MONGO_COLLECTION = os.environ.get("MONGO_COLL", "MariaPTColl")
38
+
39
+ # Cloudflare Turnstile secret (placeholder – paste your real secret in HF Secrets)
40
+ CF_TURNSTILE_SECRET = os.environ.get("CF_TURNSTILE_SECRET", "PLACEHOLDER_CF_TURNSTILE_SECRET")
41
+ ALLOWED_DOMAIN = os.environ.get("ALLOWED_DOMAIN", "buildwithsupratim.github.io")
42
+
43
+ # ─────────────────────────────────────────────
44
+ # FastAPI App
45
+ # ─────────────────────────────────────────────
46
+ app = FastAPI(title="Maria PT API", version="1.0.0", docs_url=None, redoc_url=None)
47
+
48
+ app.add_middleware(
49
+ CORSMiddleware,
50
+ allow_origins=["https://buildwithsupratim.github.io"],
51
+ allow_credentials=True,
52
+ allow_methods=["GET", "POST"],
53
+ allow_headers=["*"],
54
+ )
55
+
56
+ # ─────────────────────────────────────────────
57
+ # MongoDB Client (lazy singleton)
58
+ # ─────────────────────────────────────────────
59
+ _mongo_client: Optional[MongoClient] = None
60
+
61
+ def get_mongo_collection():
62
+ global _mongo_client
63
+ if _mongo_client is None:
64
+ _mongo_client = MongoClient(MONGO_URI, serverSelectionTimeoutMS=5000)
65
+ db = _mongo_client[MONGO_DB]
66
+ coll = db[MONGO_COLLECTION]
67
+ # Ensure capped (100 MB) – silently ignored if collection already exists
68
+ try:
69
+ db.create_collection(
70
+ MONGO_COLLECTION,
71
+ capped=True,
72
+ size=100_000_000,
73
+ )
74
+ except Exception:
75
+ pass
76
+ return coll
77
+
78
+
79
+ # ─────────────────────────────────────────────
80
+ # Auth Helpers
81
+ # ─────────────────────────────────────────────
82
+ def _hash_auth_code(auth_code: str) -> str:
83
+ return hashlib.sha256(auth_code.encode()).hexdigest()
84
+
85
+
86
+ def _primary_auth(request: Request) -> bool:
87
+ """Check SHA-256 of auth_code header against EXPECTED_HASH."""
88
+ auth_code = request.headers.get("auth_code") or request.headers.get("Auth-Code")
89
+ if not auth_code:
90
+ return False
91
+ return _hash_auth_code(auth_code) == EXPECTED_HASH
92
+
93
+
94
+ async def _cloudflare_domain_check(request: Request) -> bool:
95
+ """
96
+ Cloudflare-backed fallback:
97
+ 1. Extract the Referer / Origin header.
98
+ 2. Verify it belongs to ALLOWED_DOMAIN (domain-level check).
99
+ 3. Optionally verify a Cloudflare Turnstile token if present in headers.
100
+ """
101
+ # ── Step 1: Domain/Referer check ──────────────────────────────────────
102
+ referer = request.headers.get("referer", "")
103
+ origin = request.headers.get("origin", "")
104
+
105
+ referer_ok = ALLOWED_DOMAIN in referer
106
+ origin_ok = ALLOWED_DOMAIN in origin
107
+
108
+ if not (referer_ok or origin_ok):
109
+ logger.warning("Blocked – domain not allowed. Referer=%s Origin=%s", referer, origin)
110
+ return False
111
+
112
+ # ── Step 2: Cloudflare Turnstile token (optional header) ─────��───────
113
+ # If the front-end sends a CF-Turnstile-Token header we verify it.
114
+ # If no token is sent we fall back to domain-only gating.
115
+ cf_token = request.headers.get("CF-Turnstile-Token")
116
+ if cf_token and CF_TURNSTILE_SECRET != "PLACEHOLDER_CF_TURNSTILE_SECRET":
117
+ try:
118
+ async with httpx.AsyncClient(timeout=5.0) as client:
119
+ resp = await client.post(
120
+ "https://challenges.cloudflare.com/turnstile/v0/siteverify",
121
+ data={
122
+ "secret": CF_TURNSTILE_SECRET,
123
+ "response": cf_token,
124
+ "remoteip": request.client.host,
125
+ },
126
+ )
127
+ result = resp.json()
128
+ if not result.get("success"):
129
+ logger.warning("Cloudflare Turnstile rejected: %s", result)
130
+ return False
131
+ except Exception as exc:
132
+ logger.error("Turnstile verification error: %s", exc)
133
+ return False
134
+
135
+ return True
136
+
137
+
138
+ async def require_auth(request: Request):
139
+ """
140
+ Gate every protected endpoint.
141
+ Primary β†’ SHA-256 hash check on auth_code header.
142
+ Fallback β†’ Cloudflare domain / Turnstile check.
143
+ Block β†’ 403 + client IP is logged (extend here for real IP-ban storage).
144
+ """
145
+ if _primary_auth(request):
146
+ return # βœ… Primary auth passed
147
+
148
+ if await _cloudflare_domain_check(request):
149
+ return # βœ… Cloudflare check passed
150
+
151
+ # ❌ Both checks failed β†’ block
152
+ client_ip = request.client.host
153
+ logger.warning("BLOCKED IP: %s", client_ip)
154
+ raise HTTPException(
155
+ status_code=status.HTTP_403_FORBIDDEN,
156
+ detail="Access denied. Invalid credentials or unauthorized origin.",
157
+ )
158
+
159
+
160
+ # ─────────────────────────────────────────────
161
+ # Request Schema
162
+ # ─────────────────────────────────────────────
163
+ class BaseStartRequest(BaseModel):
164
+ request: str # e.g. "send_code"
165
+ student_name: str # e.g. "Greti"
166
+
167
+
168
+ # ─────────────────────────────────────────────
169
+ # Response Builders
170
+ # ─────────────────────────────────────────────
171
+ def build_learning_path(doc: dict, student_name: str) -> dict:
172
+ """
173
+ Merge the MongoDB document with the student_name from the API call,
174
+ then build the full learning_path response with blank/initialised fields.
175
+ """
176
+ curriculum_objectives = doc.get("curriculum_objectives", [])
177
+
178
+ # ── Normalise curriculum (topic β†’ topics key) ─────────────────────────
179
+ normalised_curriculum = []
180
+ for item in curriculum_objectives:
181
+ normalised_curriculum.append({
182
+ "topics": item.get("topic", ""),
183
+ "content": item.get("content", ""),
184
+ "learning_objectives": item.get("learning_objectives", []),
185
+ })
186
+
187
+ # ── assessment_stages: initialise with FIRST topic only ───────────────
188
+ first_topic = curriculum_objectives[0] if curriculum_objectives else {}
189
+ first_learning_objectives = [
190
+ {
191
+ "goal": goal,
192
+ "teach": "not_complete",
193
+ "re_teach": "not_complete",
194
+ "show_and_tell":"not_complete",
195
+ "assess": "not_complete",
196
+ }
197
+ for goal in first_topic.get("learning_objectives", [])
198
+ ]
199
+
200
+ current_learning = [
201
+ {
202
+ "topic": first_topic.get("topic", ""),
203
+ "content": first_topic.get("content", ""),
204
+ "learning_objectives": first_learning_objectives,
205
+ }
206
+ ] if first_topic else []
207
+
208
+ return {
209
+ "learning_path": {
210
+ "board": doc.get("board", ""),
211
+ "class": doc.get("class", ""),
212
+ "subject": doc.get("subject", ""),
213
+ "student_name": student_name, # from API call
214
+ "teacher_persona": doc.get("teacher_persona", ""),
215
+ "curriculum_objectives": normalised_curriculum,
216
+ "chat_history": [], # blank – yet to start
217
+ "scratchpad": [], # blank – yet to start
218
+ "assessment_stages": {
219
+ "current_learning": current_learning,
220
+ },
221
+ }
222
+ }
223
+
224
+
225
+ # ─────────────────────────────────────────────
226
+ # Endpoints
227
+ # ─────────────────────────────────────────────
228
+
229
+ @app.get("/ping")
230
+ async def ping(request: Request):
231
+ """Health-check endpoint – wakes the Space if sleeping."""
232
+ await require_auth(request)
233
+ return JSONResponse(content={"status": "alive"})
234
+
235
+
236
+ @app.post("/base_start")
237
+ async def base_start(request: Request, body: BaseStartRequest):
238
+ """
239
+ Accepts { request, student_name }, queries MongoDB for matching request,
240
+ and returns an initialised learning_path JSON.
241
+ """
242
+ await require_auth(request)
243
+
244
+ # ── MongoDB Lookup ────────────────────────────────────────────────────
245
+ try:
246
+ collection = get_mongo_collection()
247
+ doc = collection.find_one(
248
+ {"request": body.request},
249
+ {"_id": 0}, # exclude Mongo internal _id
250
+ )
251
+ except (ConnectionFailure, OperationFailure) as exc:
252
+ logger.error("MongoDB error: %s", exc)
253
+ raise HTTPException(
254
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
255
+ detail="Database connection error. Please try again later.",
256
+ )
257
+
258
+ if doc is None:
259
+ raise HTTPException(
260
+ status_code=status.HTTP_404_NOT_FOUND,
261
+ detail=f"No curriculum found for request '{body.request}'.",
262
+ )
263
+
264
+ # ── Build & Return Response ───────────────────────────────────────────
265
+ response_payload = build_learning_path(doc, body.student_name)
266
+ return JSONResponse(content=response_payload)
267
+
268
+
269
+ # ─────────────────────────────────────────────
270
+ # Entry point (for local testing)
271
+ # ─────────────────────────────────────────────
272
+ if __name__ == "__main__":
273
+ import uvicorn
274
+ uvicorn.run("app:app", host="0.0.0.0", port=7860, reload=True)
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ fastapi==0.115.0
2
+ uvicorn[standard]==0.30.6
3
+ pymongo[srv]==4.8.0
4
+ httpx==0.27.2
5
+ pydantic==2.8.2
6
+ python-dotenv==1.0.1