DhyandevP commited on
Commit
2fe5c93
·
verified ·
1 Parent(s): b3032d5

Upload 4 files

Browse files
Files changed (4) hide show
  1. .gitignore +18 -0
  2. Dockerfile +29 -0
  3. main.py +353 -0
  4. requirements.txt +4 -0
.gitignore ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SQLite database files
2
+ jobs.db
3
+ *.db-shm
4
+ *.db-wal
5
+
6
+ # Python cache
7
+ __pycache__/
8
+ *.pyc
9
+ *.pyo
10
+ *.pyd
11
+
12
+ # Environment / secrets
13
+ .env
14
+ .env.*
15
+
16
+ # Editor
17
+ .vscode/
18
+ .idea/
Dockerfile ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Code Evaluator — Hugging Face Spaces
2
+ #
3
+ # Requirements:
4
+ # - Non-root user with UID 1000 (HF Spaces security policy)
5
+ # - Application must listen on port 7860
6
+ # - /home/user/.local/bin on PATH for pip-installed binaries
7
+
8
+ FROM python:3.11-slim
9
+
10
+ # Create the non-root user and /app in a single layer to minimise image size.
11
+ RUN useradd -m -u 1000 -s /bin/bash user \
12
+ && mkdir -p /app \
13
+ && chown user:user /app
14
+
15
+ WORKDIR /app
16
+
17
+ USER user
18
+
19
+ ENV PATH="/home/user/.local/bin:${PATH}"
20
+
21
+ # Copy deps first so Docker cache reuses this layer on source-only changes.
22
+ COPY --chown=user requirements.txt .
23
+ RUN pip install --no-cache-dir -r requirements.txt
24
+
25
+ COPY --chown=user . .
26
+
27
+ EXPOSE 7860
28
+
29
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
main.py ADDED
@@ -0,0 +1,353 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Code Evaluator — Asynchronous Python grading service
2
+ #
3
+ # Flow:
4
+ # POST /submit → inserts "Processing" row, enqueues background LLM call,
5
+ # returns {job_id, status} immediately.
6
+ # GET /report → returns current row state (Processing / Completed / Failed).
7
+
8
+ import json
9
+ import logging
10
+ import os
11
+ import sqlite3
12
+ import uuid
13
+ from contextlib import asynccontextmanager, contextmanager
14
+
15
+ from fastapi import BackgroundTasks, Depends, FastAPI, File, Header, HTTPException, UploadFile
16
+ from fastapi.responses import JSONResponse
17
+ from openai import AsyncOpenAI
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Logging
21
+ # ---------------------------------------------------------------------------
22
+ logging.basicConfig(
23
+ level=logging.INFO,
24
+ format="%(asctime)s %(levelname)-8s %(name)s — %(message)s",
25
+ datefmt="%Y-%m-%dT%H:%M:%S",
26
+ )
27
+ logger = logging.getLogger("code_evaluator")
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Configuration
31
+ # ---------------------------------------------------------------------------
32
+
33
+ # Shared key distributed to consuming services via the X-API-Key header.
34
+ # Set the CUSTOM_API_KEY environment secret in your deployment dashboard.
35
+ # The server refuses to start if this is missing — no silent fallback.
36
+ YOUR_CUSTOM_API_KEY: str | None = os.getenv("CUSTOM_API_KEY")
37
+ if not YOUR_CUSTOM_API_KEY:
38
+ raise RuntimeError(
39
+ "CUSTOM_API_KEY environment secret is not set. "
40
+ "Add it to your deployment secrets and restart."
41
+ )
42
+
43
+ # LLM provider API key — set as an environment secret, never hardcoded.
44
+ HUGGINGFACE_API_KEY: str | None = os.getenv("HUGGINGFACE_API_KEY")
45
+
46
+ if not HUGGINGFACE_API_KEY:
47
+ logger.warning(
48
+ "HUGGINGFACE_API_KEY is not set. "
49
+ "All /submit requests will fail during LLM inference. "
50
+ "Add the secret in your deployment environment and restart."
51
+ )
52
+
53
+ # AsyncOpenAI client pointed at the HuggingFace OpenAI-compatible gateway.
54
+ # Swap base_url and api_key env var to switch providers (e.g. OpenRouter)
55
+ # without touching any other part of the code.
56
+ openai_client = AsyncOpenAI(
57
+ api_key=HUGGINGFACE_API_KEY or "missing-key",
58
+ base_url="https://api-inference.huggingface.co/v1/",
59
+ )
60
+
61
+ MODEL_ID: str = "Qwen/Qwen2.5-Coder-32B-Instruct"
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Database
65
+ # ---------------------------------------------------------------------------
66
+
67
+ DB_PATH: str = os.getenv("DB_PATH", "jobs.db")
68
+
69
+ logger.info("Database path: %s", DB_PATH)
70
+
71
+
72
+ @contextmanager
73
+ def get_db_connection():
74
+ """
75
+ Yield a thread-safe SQLite connection and guarantee closure on exit.
76
+
77
+ check_same_thread=False is required because FastAPI's async event loop
78
+ may dispatch sync I/O across threads; operations here are short and safe.
79
+ """
80
+ conn: sqlite3.Connection | None = None
81
+ try:
82
+ conn = sqlite3.connect(DB_PATH, check_same_thread=False)
83
+ conn.row_factory = sqlite3.Row
84
+ yield conn
85
+ finally:
86
+ if conn is not None:
87
+ conn.close()
88
+
89
+
90
+ def init_db() -> None:
91
+ """Create the evaluations table if it does not already exist."""
92
+ logger.info("Initialising database schema …")
93
+ with get_db_connection() as conn:
94
+ conn.execute(
95
+ """
96
+ CREATE TABLE IF NOT EXISTS evaluations (
97
+ job_id TEXT PRIMARY KEY,
98
+ status TEXT NOT NULL,
99
+ report TEXT
100
+ )
101
+ """
102
+ )
103
+ conn.commit()
104
+ logger.info("Database ready.")
105
+
106
+
107
+ def db_create_job(job_id: str) -> None:
108
+ """Insert a new evaluation row with status 'Processing'."""
109
+ with get_db_connection() as conn:
110
+ conn.execute(
111
+ "INSERT INTO evaluations (job_id, status, report) VALUES (?, ?, ?)",
112
+ (job_id, "Processing", None),
113
+ )
114
+ conn.commit()
115
+ logger.info("[%s] Job created → status=Processing.", job_id)
116
+
117
+
118
+ def db_update_job(job_id: str, status: str, report: str) -> None:
119
+ """Update the status and report columns for the given job_id."""
120
+ with get_db_connection() as conn:
121
+ conn.execute(
122
+ "UPDATE evaluations SET status = ?, report = ? WHERE job_id = ?",
123
+ (status, report, job_id),
124
+ )
125
+ conn.commit()
126
+ logger.info("[%s] Job updated → status=%s.", job_id, status)
127
+
128
+
129
+ def db_get_job(job_id: str) -> sqlite3.Row | None:
130
+ """Return the evaluation row for job_id, or None if not found."""
131
+ with get_db_connection() as conn:
132
+ cursor = conn.execute(
133
+ "SELECT job_id, status, report FROM evaluations WHERE job_id = ?",
134
+ (job_id,),
135
+ )
136
+ return cursor.fetchone()
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Background Worker
141
+ # ---------------------------------------------------------------------------
142
+
143
+ SYSTEM_PROMPT: str = (
144
+ "You are an unyielding, expert Python code auditor. Your sole responsibility "
145
+ "is to evaluate student-submitted Python scripts and produce a precise, "
146
+ "objective, and actionable grading report.\n\n"
147
+ "CRITICAL OUTPUT CONTRACT:\n"
148
+ "- Respond with ONLY a raw JSON object. No Markdown fences, no prose, "
149
+ "no preamble, no trailing commentary.\n"
150
+ "- The JSON object MUST contain exactly these three keys:\n"
151
+ ' 1. "score" (integer, 0–100): Overall benchmark score reflecting '
152
+ "correctness, efficiency, readability, and best-practice adherence.\n"
153
+ ' 2. "percentage" (float, 0.0–100.0): Execution precision — how accurately '
154
+ "the code fulfils its apparent intent relative to a flawless implementation.\n"
155
+ ' 3. "mistakes" (array of strings): Every defect found — architectural '
156
+ "issues, logical fallacies, syntax bugs, missing optimisations, poor naming, "
157
+ "security vulnerabilities, or style violations. Each item must name the "
158
+ "problem and its recommended fix. Return [] if no mistakes are found.\n\n"
159
+ "SCORING RUBRIC:\n"
160
+ " 90–100 : Near-perfect — production-ready.\n"
161
+ " 70–89 : Good — minor issues only.\n"
162
+ " 50–69 : Adequate — notable defects.\n"
163
+ " 30–49 : Poor — significant bugs or anti-patterns.\n"
164
+ " 0–29 : Critical failure — non-functional or trivially wrong.\n\n"
165
+ "Output the raw JSON object as your entire response. "
166
+ "Do NOT add any key other than score, percentage, and mistakes."
167
+ )
168
+
169
+
170
+ async def process_evaluation(job_id: str, code_text: str) -> None:
171
+ """
172
+ Call the LLM to grade code_text and persist the result for job_id.
173
+
174
+ On any exception the row is updated to 'Failed' with a serialised error
175
+ payload — the job can never be stuck in 'Processing' indefinitely.
176
+ """
177
+ logger.info("[%s] Background evaluation started.", job_id)
178
+
179
+ user_message: str = (
180
+ "Evaluate the following Python script and return your grading report "
181
+ "as a raw JSON object exactly as specified in your instructions.\n\n"
182
+ f"```python\n{code_text}\n```"
183
+ )
184
+
185
+ try:
186
+ logger.info("[%s] Calling LLM (model=%s) …", job_id, MODEL_ID)
187
+
188
+ response = await openai_client.chat.completions.create(
189
+ model=MODEL_ID,
190
+ messages=[
191
+ {"role": "system", "content": SYSTEM_PROMPT},
192
+ {"role": "user", "content": user_message},
193
+ ],
194
+ response_format={"type": "json_object"},
195
+ temperature=0.1,
196
+ max_tokens=2048,
197
+ )
198
+
199
+ raw_json_text: str = response.choices[0].message.content.strip()
200
+ logger.info("[%s] LLM responded (%d chars). Validating …", job_id, len(raw_json_text))
201
+
202
+ parsed_report: dict = json.loads(raw_json_text)
203
+
204
+ missing_keys = {"score", "percentage", "mistakes"} - parsed_report.keys()
205
+ if missing_keys:
206
+ raise ValueError(
207
+ f"LLM response missing required keys: {missing_keys}. "
208
+ f"Raw snippet: {raw_json_text[:400]}"
209
+ )
210
+
211
+ db_update_job(job_id=job_id, status="Completed", report=json.dumps(parsed_report))
212
+ logger.info("[%s] Evaluation completed successfully.", job_id)
213
+
214
+ except Exception as exc:
215
+ logger.exception("[%s] Evaluation failed: %s", job_id, exc)
216
+ db_update_job(
217
+ job_id=job_id,
218
+ status="Failed",
219
+ report=json.dumps({"error_type": type(exc).__name__, "error_message": str(exc)}),
220
+ )
221
+
222
+
223
+ # ---------------------------------------------------------------------------
224
+ # Application
225
+ # ---------------------------------------------------------------------------
226
+
227
+ @asynccontextmanager
228
+ async def lifespan(app: FastAPI):
229
+ """Initialise the database on startup; nothing to clean up on shutdown."""
230
+ init_db()
231
+ logger.info("Code Evaluator started and ready.")
232
+ yield
233
+
234
+
235
+ app = FastAPI(
236
+ title="Code Evaluator",
237
+ description=(
238
+ "Asynchronous Python code grading service. "
239
+ "Submit a script, receive a job_id instantly, poll for the structured report."
240
+ ),
241
+ version="2.1.0",
242
+ lifespan=lifespan,
243
+ )
244
+
245
+
246
+ # ---------------------------------------------------------------------------
247
+ # Auth dependency
248
+ # ---------------------------------------------------------------------------
249
+
250
+ async def verify_api_key(x_api_key: str = Header(...)) -> None:
251
+ """Reject requests whose X-API-Key header does not match the shared key."""
252
+ if x_api_key != YOUR_CUSTOM_API_KEY:
253
+ logger.warning("Rejected request — invalid X-API-Key supplied.")
254
+ raise HTTPException(status_code=401, detail="Unauthorised: invalid or missing X-API-Key.")
255
+
256
+
257
+ # ---------------------------------------------------------------------------
258
+ # Endpoints
259
+ # ---------------------------------------------------------------------------
260
+
261
+ @app.post(
262
+ "/submit",
263
+ summary="Submit a Python script for asynchronous evaluation",
264
+ dependencies=[Depends(verify_api_key)],
265
+ )
266
+ async def submit_code(
267
+ background_tasks: BackgroundTasks,
268
+ file: UploadFile = File(..., description="Python source file (.py) to evaluate"),
269
+ ) -> JSONResponse:
270
+ """
271
+ Accept a .py upload, create a tracking row, enqueue LLM evaluation, and
272
+ return a job_id immediately — no waiting for inference to complete.
273
+ """
274
+ # Read at most MAX_UPLOAD_BYTES + 1 so we never load a huge file into
275
+ # memory. Reading one byte over the limit lets us detect and reject it
276
+ # before any processing happens.
277
+ MAX_UPLOAD_BYTES = 100_000 # 100 KB — more than enough for a student script
278
+ raw_bytes: bytes = await file.read(MAX_UPLOAD_BYTES + 1)
279
+
280
+ if len(raw_bytes) > MAX_UPLOAD_BYTES:
281
+ raise HTTPException(
282
+ status_code=413,
283
+ detail=f"File exceeds the {MAX_UPLOAD_BYTES // 1000} KB size limit. "
284
+ "Ensure you are submitting a Python source file, not a binary.",
285
+ )
286
+
287
+ code_text: str = raw_bytes.decode("utf-8")
288
+
289
+ logger.info("Received submission — filename=%s, size=%d bytes.", file.filename, len(raw_bytes))
290
+
291
+ job_id: str = str(uuid.uuid4())
292
+
293
+ # Insert the row BEFORE enqueuing so GET /report/{job_id} never returns 404
294
+ # in the window between submission and the background task starting.
295
+ db_create_job(job_id=job_id)
296
+ background_tasks.add_task(process_evaluation, job_id=job_id, code_text=code_text)
297
+
298
+ logger.info("[%s] Job enqueued for background processing.", job_id)
299
+ return JSONResponse(content={"job_id": job_id, "status": "Processing"})
300
+
301
+
302
+ @app.get(
303
+ "/report/{job_id}",
304
+ summary="Retrieve the grading report for a submitted job",
305
+ dependencies=[Depends(verify_api_key)],
306
+ )
307
+ async def get_report(job_id: str) -> JSONResponse:
308
+ """
309
+ Poll for the evaluation result of a previously submitted script.
310
+
311
+ Returns 404 if the job_id is unknown, otherwise returns the current
312
+ state: Processing, Completed (with report), or Failed (with error).
313
+ """
314
+ row: sqlite3.Row | None = db_get_job(job_id=job_id)
315
+
316
+ if row is None:
317
+ logger.warning("Report requested for unknown job_id=%s.", job_id)
318
+ raise HTTPException(
319
+ status_code=404,
320
+ detail=f"Job '{job_id}' not found. Verify the job_id or re-submit.",
321
+ )
322
+
323
+ status: str = row["status"]
324
+ report_raw: str | None = row["report"]
325
+
326
+ if status == "Processing":
327
+ return JSONResponse(content={
328
+ "job_id": job_id,
329
+ "status": "Processing",
330
+ "message": "Your submission is being evaluated. Retry in a few seconds.",
331
+ })
332
+
333
+ elif status == "Failed":
334
+ error_payload: dict = (
335
+ json.loads(report_raw)
336
+ if report_raw
337
+ else {"error_type": "Unknown", "error_message": "No details available."}
338
+ )
339
+ return JSONResponse(content={"job_id": job_id, "status": "Failed", "error": error_payload})
340
+
341
+ elif status == "Completed":
342
+ return JSONResponse(content={
343
+ "job_id": job_id,
344
+ "status": "Completed",
345
+ "report": json.loads(report_raw),
346
+ })
347
+
348
+ else:
349
+ logger.error("[%s] Unexpected status value '%s' in database.", job_id, status)
350
+ return JSONResponse(
351
+ status_code=500,
352
+ content={"job_id": job_id, "status": status, "message": "Unexpected internal state."},
353
+ )
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ fastapi
2
+ uvicorn[standard]
3
+ python-multipart
4
+ openai