trretretret commited on
Commit
900e69d
·
1 Parent(s): 3f8da9e

Implement custom task grading and upload functionality

Browse files
README.md CHANGED
@@ -253,10 +253,54 @@ The environment implements the full OpenEnv interface:
253
 
254
  | Method | Path | Description |
255
  |--------|------|-------------|
256
- | `GET` | `/api/tasks` | List all task metadata |
257
  | `POST` | `/api/reset/{task_id}` | Start an episode |
258
  | `POST` | `/api/step` | Execute an action |
259
  | `GET` | `/api/state` | Get current state |
260
  | `POST` | `/api/auto_action` | Baseline agent picks next action |
 
 
261
 
262
  The container starts the FastAPI backend on port `7860`, which is compatible with a Hugging Face Docker Space contest deployment.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
 
254
  | Method | Path | Description |
255
  |--------|------|-------------|
256
+ | `GET` | `/api/tasks` | List all task metadata (static + uploaded) |
257
  | `POST` | `/api/reset/{task_id}` | Start an episode |
258
  | `POST` | `/api/step` | Execute an action |
259
  | `GET` | `/api/state` | Get current state |
260
  | `POST` | `/api/auto_action` | Baseline agent picks next action |
261
+ | `POST` | `/api/upload` | Upload custom code for review |
262
+ | `DELETE` | `/api/upload/{task_id}` | Delete an uploaded task |
263
 
264
  The container starts the FastAPI backend on port `7860`, which is compatible with a Hugging Face Docker Space contest deployment.
265
+
266
+ ## Custom Code Upload
267
+
268
+ You can upload your own code files for the AI agent to review. This enables dynamic code review scenarios beyond the built-in tasks.
269
+
270
+ ### Upload via UI
271
+
272
+ 1. Click **"Upload Custom Code"** in the toolbar
273
+ 2. Fill in the PR title and description
274
+ 3. Drag & drop or select modified files
275
+ 4. Optionally add original file versions for automatic diff generation
276
+ 5. Click **"Upload & Create Task"**
277
+ 6. Select the new task from the dropdown and start reviewing
278
+
279
+ ### Upload via API
280
+
281
+ ```bash
282
+ # Upload files for review
283
+ curl -X POST http://localhost:8000/api/upload \
284
+ -F "title=Fix authentication bug" \
285
+ -F "description=Remove admin check from public endpoint" \
286
+ -F "files=@path/to/modified_file.py" \
287
+ -F "original_files=@path/to/original_file.py"
288
+
289
+ # Response:
290
+ # {
291
+ # "task_id": "upload_20240407_120000_abc12345",
292
+ # "label": "Fix authentication bug",
293
+ # "changed_files": ["modified_file.py"],
294
+ # "message": "Task created successfully..."
295
+ # }
296
+
297
+ # Start review session with uploaded task
298
+ curl -X POST http://localhost:8000/api/reset/upload_20240407_120000_abc12345
299
+ ```
300
+
301
+ ### Upload Limits
302
+
303
+ - Maximum 10 files per upload
304
+ - Maximum 1MB per file
305
+ - Uploaded tasks expire after 1 hour (configurable)
306
+ - Grading uses "review_only" mode (scores coverage, not correctness)
backend/app.py CHANGED
@@ -6,7 +6,7 @@ import logging
6
  import os
7
  from pathlib import Path
8
 
9
- from fastapi import FastAPI
10
  from fastapi.responses import JSONResponse, FileResponse
11
  from fastapi.middleware.cors import CORSMiddleware
12
  from fastapi.staticfiles import StaticFiles
@@ -16,6 +16,8 @@ import uvicorn
16
  from baseline import BaselineAgent
17
  from env.environment import CodeReviewEnv
18
  from tasks.task_registry import get_available_tasks, get_task_catalog, load_task
 
 
19
  from grader.task_graders import get_grader
20
 
21
  # Setup logging
@@ -36,7 +38,7 @@ app = FastAPI(title="Code Review Assistant API")
36
  app.add_middleware(
37
  CORSMiddleware,
38
  allow_origins=ALLOWED_ORIGINS,
39
- allow_methods=["GET", "POST"],
40
  allow_headers=["Content-Type"],
41
  allow_credentials=True,
42
  )
@@ -61,9 +63,10 @@ class ActionRequest(BaseModel):
61
 
62
  @app.get("/api/tasks")
63
  def get_tasks():
64
- """Get all available tasks with metadata (cached)."""
65
  global _task_catalog_cache
66
 
 
67
  if _task_catalog_cache is None:
68
  meta = {"easy": "🟢", "medium": "🟡", "hard": "🔴"}
69
  tasks = []
@@ -81,19 +84,142 @@ def get_tasks():
81
  )
82
  _task_catalog_cache = {"tasks": tasks}
83
 
84
- return _task_catalog_cache
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
 
86
 
87
  @app.post("/api/reset/{task_name}")
88
  def reset(task_name: str):
89
- """Reset environment with a specific task."""
90
- if task_name not in get_available_tasks():
 
 
 
 
 
 
 
 
 
 
 
91
  logger.warning(f"Unknown task requested: {task_name}")
92
  return JSONResponse(status_code=400, content={"error": "Unknown task"})
93
 
94
  try:
95
- task = load_task(task_name)
96
- grader = get_grader(task_name)
97
  obs = _env.reset(task)
98
  _session.update({"task_name": task_name, "task": task, "grader": grader, "obs": obs, "done": False})
99
  logger.info(f"Task {task_name} reset successfully")
@@ -105,6 +231,7 @@ def reset(task_name: str):
105
  "description": task["description"],
106
  "issue_title": task["issue_title"],
107
  "issue_body": task["issue_body"],
 
108
  }
109
  except Exception as e:
110
  logger.error(f"Error resetting task {task_name}: {e}", exc_info=True)
 
6
  import os
7
  from pathlib import Path
8
 
9
+ from fastapi import FastAPI, UploadFile, File, Form
10
  from fastapi.responses import JSONResponse, FileResponse
11
  from fastapi.middleware.cors import CORSMiddleware
12
  from fastapi.staticfiles import StaticFiles
 
16
  from baseline import BaselineAgent
17
  from env.environment import CodeReviewEnv
18
  from tasks.task_registry import get_available_tasks, get_task_catalog, load_task
19
+ from tasks.dynamic_loader import create_dynamic_task, validate_uploaded_files
20
+ from tasks.dynamic_store import store_dynamic_task, get_dynamic_task, list_dynamic_tasks
21
  from grader.task_graders import get_grader
22
 
23
  # Setup logging
 
38
  app.add_middleware(
39
  CORSMiddleware,
40
  allow_origins=ALLOWED_ORIGINS,
41
+ allow_methods=["GET", "POST", "DELETE"],
42
  allow_headers=["Content-Type"],
43
  allow_credentials=True,
44
  )
 
63
 
64
  @app.get("/api/tasks")
65
  def get_tasks():
66
+ """Get all available tasks with metadata (static + dynamic)."""
67
  global _task_catalog_cache
68
 
69
+ # Build static tasks (cached)
70
  if _task_catalog_cache is None:
71
  meta = {"easy": "🟢", "medium": "🟡", "hard": "🔴"}
72
  tasks = []
 
84
  )
85
  _task_catalog_cache = {"tasks": tasks}
86
 
87
+ # Add dynamic uploaded tasks (not cached)
88
+ result = {"tasks": _task_catalog_cache["tasks"].copy()}
89
+ dynamic_tasks = list_dynamic_tasks()
90
+
91
+ for dt in dynamic_tasks:
92
+ result["tasks"].append({
93
+ "id": dt["id"],
94
+ "label": dt["label"],
95
+ "difficulty": "custom",
96
+ "icon": "📤",
97
+ "desc": dt["description"],
98
+ "issue_title": dt["label"],
99
+ "pass_threshold": 0.5,
100
+ "is_custom_upload": True,
101
+ })
102
+
103
+ return result
104
+
105
+
106
+ @app.post("/api/upload")
107
+ async def upload_code(
108
+ title: str = Form(...),
109
+ description: str = Form(...),
110
+ files: list[UploadFile] = File(...),
111
+ original_files: list[UploadFile] = File(default=[]),
112
+ ):
113
+ """
114
+ Upload custom code for review.
115
+
116
+ Args:
117
+ title: PR/Issue title
118
+ description: PR description
119
+ files: Modified/new files to review
120
+ original_files: Original versions (optional, for diff generation)
121
+
122
+ Returns:
123
+ Created task ID and metadata
124
+ """
125
+ try:
126
+ # Validate file count
127
+ total_files = len(files) + len(original_files)
128
+ if total_files > 20:
129
+ return JSONResponse(status_code=400, content={"error": "Maximum 20 files allowed"})
130
+
131
+ # Read and validate files
132
+ changed_files = {}
133
+ max_size = 1 * 1024 * 1024 # 1MB
134
+
135
+ # Build original files map
136
+ originals = {}
137
+ for orig in original_files:
138
+ content = await orig.read()
139
+ if len(content) > max_size:
140
+ return JSONResponse(status_code=400, content={"error": f"File '{orig.filename}' exceeds 1MB limit"})
141
+ originals[orig.filename] = content.decode("utf-8", errors="replace")
142
+
143
+ # Process modified files
144
+ for f in files:
145
+ content = await f.read()
146
+ if len(content) > max_size:
147
+ return JSONResponse(status_code=400, content={"error": f"File '{f.filename}' exceeds 1MB limit"})
148
+
149
+ modified_content = content.decode("utf-8", errors="replace")
150
+
151
+ # Check if we have original version
152
+ if f.filename in originals:
153
+ changed_files[f.filename] = {
154
+ "original": originals[f.filename],
155
+ "modified": modified_content,
156
+ }
157
+ else:
158
+ # New file - no original
159
+ changed_files[f.filename] = {
160
+ "modified": modified_content,
161
+ }
162
+
163
+ if not changed_files:
164
+ return JSONResponse(status_code=400, content={"error": "No files uploaded"})
165
+
166
+ # Create dynamic task
167
+ task = create_dynamic_task(
168
+ title=title,
169
+ description=description,
170
+ changed_files=changed_files,
171
+ issue_body=description,
172
+ )
173
+
174
+ # Store task
175
+ task_id = store_dynamic_task(task)
176
+
177
+ logger.info(f"Created custom task: {task_id} with {len(changed_files)} files")
178
+
179
+ return {
180
+ "task_id": task_id,
181
+ "label": title,
182
+ "description": description,
183
+ "changed_files": list(changed_files.keys()),
184
+ "message": f"Task created successfully. Select '{task_id}' to start review.",
185
+ }
186
+
187
+ except Exception as e:
188
+ logger.error(f"Upload error: {e}", exc_info=True)
189
+ return JSONResponse(status_code=500, content={"error": str(e)})
190
+
191
+
192
+ @app.delete("/api/upload/{task_id}")
193
+ def delete_upload(task_id: str):
194
+ """Delete an uploaded task."""
195
+ from tasks.dynamic_store import delete_dynamic_task
196
+
197
+ if delete_dynamic_task(task_id):
198
+ logger.info(f"Deleted custom task: {task_id}")
199
+ return {"message": f"Task {task_id} deleted"}
200
+ else:
201
+ return JSONResponse(status_code=404, content={"error": "Task not found"})
202
 
203
 
204
  @app.post("/api/reset/{task_name}")
205
  def reset(task_name: str):
206
+ """Reset environment with a specific task (static or dynamic)."""
207
+ # Check if it's a dynamic uploaded task
208
+ dynamic_task = get_dynamic_task(task_name)
209
+
210
+ if dynamic_task:
211
+ # Use the uploaded task
212
+ task = dynamic_task
213
+ grader = get_grader(task_name, is_custom=True)
214
+ elif task_name in get_available_tasks():
215
+ # Use static task
216
+ task = load_task(task_name)
217
+ grader = get_grader(task_name)
218
+ else:
219
  logger.warning(f"Unknown task requested: {task_name}")
220
  return JSONResponse(status_code=400, content={"error": "Unknown task"})
221
 
222
  try:
 
 
223
  obs = _env.reset(task)
224
  _session.update({"task_name": task_name, "task": task, "grader": grader, "obs": obs, "done": False})
225
  logger.info(f"Task {task_name} reset successfully")
 
231
  "description": task["description"],
232
  "issue_title": task["issue_title"],
233
  "issue_body": task["issue_body"],
234
+ "is_custom_upload": task.get("is_custom_upload", False),
235
  }
236
  except Exception as e:
237
  logger.error(f"Error resetting task {task_name}: {e}", exc_info=True)
backend/grader/grader.py CHANGED
@@ -9,11 +9,30 @@ from env.reward import RewardEngine
9
  class TaskGrader:
10
  """Grades a completed episode against hidden task metadata."""
11
 
12
- def __init__(self, task: Dict[str, Any]):
 
 
 
 
 
 
 
13
  self.task = task
 
14
  self._last_report: Dict[str, Any] = {}
15
 
16
  def grade_episode(self, actions_taken: List[Dict[str, Any]]) -> float:
 
 
 
 
 
 
 
 
 
 
 
17
  score, breakdown = RewardEngine.score_actions(self.task, actions_taken)
18
  status = "PASS" if score >= float(self.task["pass_threshold"]) else "FAIL"
19
 
@@ -39,6 +58,63 @@ class TaskGrader:
39
  "grade_status": status,
40
  }
41
  return score
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
  def generate_grade_report(self) -> Dict[str, Any]:
44
  """Return the report from the last grade_episode call."""
 
9
  class TaskGrader:
10
  """Grades a completed episode against hidden task metadata."""
11
 
12
+ def __init__(self, task: Dict[str, Any], review_only: bool = False):
13
+ """
14
+ Initialize grader.
15
+
16
+ Args:
17
+ task: Task dictionary
18
+ review_only: If True, grade without ground truth (custom uploads)
19
+ """
20
  self.task = task
21
+ self.review_only = review_only
22
  self._last_report: Dict[str, Any] = {}
23
 
24
  def grade_episode(self, actions_taken: List[Dict[str, Any]]) -> float:
25
+ """
26
+ Grade the episode based on actions taken.
27
+
28
+ For review_only mode (custom uploads), grades based on:
29
+ - How many files were inspected
30
+ - Whether comments were made
31
+ - Whether a decision was reached
32
+ """
33
+ if self.review_only:
34
+ return self._grade_review_only(actions_taken)
35
+
36
  score, breakdown = RewardEngine.score_actions(self.task, actions_taken)
37
  status = "PASS" if score >= float(self.task["pass_threshold"]) else "FAIL"
38
 
 
58
  "grade_status": status,
59
  }
60
  return score
61
+
62
+ def _grade_review_only(self, actions_taken: List[Dict[str, Any]]) -> float:
63
+ """Grade custom upload based on review completeness."""
64
+ inspected_diffs = set()
65
+ inspected_files = set()
66
+ comments = []
67
+ final_decision = None
68
+
69
+ changed_files = set(self.task.get("changed_files", []))
70
+
71
+ for action in actions_taken:
72
+ action_type = action.get("action_type")
73
+ path = action.get("path")
74
+ text = action.get("text", "")
75
+
76
+ if action_type == "inspect_diff" and path:
77
+ inspected_diffs.add(path)
78
+ elif action_type == "inspect_file" and path:
79
+ inspected_files.add(path)
80
+ elif action_type == "comment" and text:
81
+ comments.append(text)
82
+ elif action_type in ("approve", "reject", "escalate"):
83
+ final_decision = action_type
84
+
85
+ # Calculate coverage score
86
+ total_changed = len(changed_files) if changed_files else 1
87
+ coverage = len(inspected_diffs & changed_files) / total_changed
88
+
89
+ # Score components for review-only mode
90
+ coverage_score = min(coverage, 1.0) * 0.40 # 40% for file coverage
91
+ comment_score = min(len(comments) / 3, 1.0) * 0.30 # 30% for comments (up to 3)
92
+ decision_score = 0.30 if final_decision else 0.0 # 30% for making a decision
93
+
94
+ total_score = coverage_score + comment_score + decision_score
95
+
96
+ # Build report
97
+ self._last_report = {
98
+ "task_id": self.task["id"],
99
+ "difficulty": "custom",
100
+ "issue_title": self.task.get("issue_title", "Custom Review"),
101
+ "review_mode": "review_only",
102
+ "inspected_diffs": list(inspected_diffs),
103
+ "inspected_files": list(inspected_files),
104
+ "changed_files": list(changed_files),
105
+ "coverage": round(coverage, 2),
106
+ "comments_made": len(comments),
107
+ "comments_preview": [c[:100] + "..." if len(c) > 100 else c for c in comments[:3]],
108
+ "submitted_decision": final_decision,
109
+ "coverage_score": round(coverage_score, 4),
110
+ "comment_score": round(comment_score, 4),
111
+ "decision_score": round(decision_score, 4),
112
+ "final_score": round(total_score, 4),
113
+ "grade_status": "REVIEWED",
114
+ "note": "Custom uploads are graded on review completeness, not correctness.",
115
+ }
116
+
117
+ return total_score
118
 
119
  def generate_grade_report(self) -> Dict[str, Any]:
120
  """Return the report from the last grade_episode call."""
backend/grader/task_graders.py CHANGED
@@ -6,5 +6,23 @@ from grader.grader import TaskGrader
6
  from tasks.task_registry import load_task
7
 
8
 
9
- def get_grader(task_id: str) -> TaskGrader:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  return TaskGrader(load_task(task_id))
 
6
  from tasks.task_registry import load_task
7
 
8
 
9
+ def get_grader(task_id: str, is_custom: bool = False) -> TaskGrader:
10
+ """
11
+ Get a grader for a task.
12
+
13
+ Args:
14
+ task_id: Task identifier
15
+ is_custom: If True, use custom task grading (review-only mode)
16
+
17
+ Returns:
18
+ TaskGrader instance
19
+ """
20
+ if is_custom:
21
+ # For custom uploads, get from dynamic store
22
+ from tasks.dynamic_store import get_dynamic_task
23
+ task = get_dynamic_task(task_id)
24
+ if task:
25
+ return TaskGrader(task, review_only=True)
26
+ raise ValueError(f"Custom task not found: {task_id}")
27
+
28
  return TaskGrader(load_task(task_id))
backend/tasks/dynamic_loader.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dynamic task generation from user-uploaded code."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ import difflib
7
+ from datetime import datetime
8
+ from typing import Dict, Any, List
9
+
10
+
11
+ def generate_task_id() -> str:
12
+ """Generate a unique task ID for uploaded content."""
13
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
14
+ short_uuid = str(uuid.uuid4())[:8]
15
+ return f"upload_{timestamp}_{short_uuid}"
16
+
17
+
18
+ def create_diff(original: str, modified: str, filename: str) -> str:
19
+ """Create unified diff from original and modified file contents."""
20
+ original_lines = original.splitlines(keepends=True)
21
+ modified_lines = modified.splitlines(keepends=True)
22
+
23
+ diff = difflib.unified_diff(
24
+ original_lines,
25
+ modified_lines,
26
+ fromfile=f"a/{filename}",
27
+ tofile=f"b/{filename}",
28
+ lineterm=""
29
+ )
30
+
31
+ return "".join(diff)
32
+
33
+
34
+ def create_dynamic_task(
35
+ title: str,
36
+ description: str,
37
+ changed_files: Dict[str, Dict[str, str]],
38
+ context_files: Dict[str, str] | None = None,
39
+ issue_body: str | None = None,
40
+ ground_truth: Dict[str, Any] | None = None,
41
+ ) -> Dict[str, Any]:
42
+ """
43
+ Create a task dictionary from uploaded content.
44
+
45
+ Args:
46
+ title: PR/Issue title
47
+ description: PR description/summary
48
+ changed_files: Dict of {path: {"original": str, "modified": str}} or {path: {"diff": str}}
49
+ context_files: Optional dict of {path: content} for additional context
50
+ issue_body: Optional detailed issue description
51
+ ground_truth: Optional ground truth for scoring (if known)
52
+
53
+ Returns:
54
+ Task dictionary compatible with CodeReviewEnv
55
+ """
56
+ task_id = generate_task_id()
57
+
58
+ # Process changed files and generate diffs
59
+ diffs = {}
60
+ files = {}
61
+ changed_paths = []
62
+
63
+ for path, content in changed_files.items():
64
+ changed_paths.append(path)
65
+
66
+ # If diff is provided directly, use it
67
+ if "diff" in content:
68
+ diffs[path] = content["diff"]
69
+ # Store modified version as the file content
70
+ if "modified" in content:
71
+ files[path] = content["modified"]
72
+
73
+ # If original and modified are provided, generate diff
74
+ elif "original" in content and "modified" in content:
75
+ diffs[path] = create_diff(content["original"], content["modified"], path)
76
+ files[path] = content["modified"]
77
+
78
+ # If only modified is provided (new file)
79
+ elif "modified" in content:
80
+ files[path] = content["modified"]
81
+ # Generate diff showing entire file as added
82
+ diffs[path] = create_diff("", content["modified"], path)
83
+
84
+ # Add context files
85
+ if context_files:
86
+ files.update(context_files)
87
+
88
+ # Build available files list (all files that can be inspected)
89
+ available_files = list(set(changed_paths + list(context_files.keys() if context_files else [])))
90
+
91
+ # Create task structure
92
+ task = {
93
+ "id": task_id,
94
+ "difficulty": "custom",
95
+ "label": title,
96
+ "description": description,
97
+ "issue_title": title,
98
+ "issue_body": issue_body or description,
99
+ "changed_files": changed_paths,
100
+ "available_files": available_files,
101
+ "diffs": diffs,
102
+ "files": files,
103
+ "pass_threshold": 0.5, # Default for custom uploads
104
+ "is_custom_upload": True,
105
+ "uploaded_at": datetime.now().isoformat(),
106
+ }
107
+
108
+ # Add ground truth if provided (for testing)
109
+ if ground_truth:
110
+ task["ground_truth"] = ground_truth
111
+
112
+ return task
113
+
114
+
115
+ def validate_uploaded_files(files: List[Dict[str, Any]], max_size_mb: float = 1.0, max_files: int = 10) -> tuple[bool, str | None]:
116
+ """
117
+ Validate uploaded files for size and count limits.
118
+
119
+ Returns:
120
+ (is_valid, error_message)
121
+ """
122
+ if len(files) > max_files:
123
+ return False, f"Too many files: maximum {max_files} files allowed"
124
+
125
+ max_size_bytes = max_size_mb * 1024 * 1024
126
+
127
+ for file_info in files:
128
+ size = file_info.get("size", 0)
129
+ if size > max_size_bytes:
130
+ filename = file_info.get("filename", "unknown")
131
+ return False, f"File '{filename}' exceeds {max_size_mb}MB limit"
132
+
133
+ return True, None
134
+
135
+
136
+ # Example usage for testing
137
+ if __name__ == "__main__":
138
+ # Example: Simple file modification
139
+ test_task = create_dynamic_task(
140
+ title="Fix authentication bug",
141
+ description="Remove admin check from public endpoint",
142
+ changed_files={
143
+ "api/routes.py": {
144
+ "original": "@admin_required\ndef export_data():\n return data",
145
+ "modified": "def export_data():\n return data"
146
+ }
147
+ },
148
+ context_files={
149
+ "api/__init__.py": "from .routes import *"
150
+ }
151
+ )
152
+
153
+ print(f"Generated task: {test_task['id']}")
154
+ print(f"Changed files: {test_task['changed_files']}")
155
+ print(f"Available files: {test_task['available_files']}")
156
+ print(f"Diff sample:\n{test_task['diffs']['api/routes.py'][:200]}")
backend/tasks/dynamic_store.py ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Temporary storage for user-uploaded dynamic tasks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+ from datetime import datetime, timedelta
8
+ from typing import Dict, Any, List
9
+
10
+
11
+ class DynamicTaskStore:
12
+ """Thread-safe in-memory storage for uploaded tasks with TTL."""
13
+
14
+ def __init__(self, ttl_seconds: int = 3600):
15
+ """
16
+ Initialize task store.
17
+
18
+ Args:
19
+ ttl_seconds: Time-to-live for tasks in seconds (default: 1 hour)
20
+ """
21
+ self._tasks: Dict[str, Dict[str, Any]] = {}
22
+ self._timestamps: Dict[str, datetime] = {}
23
+ self._lock = threading.RLock()
24
+ self._ttl = timedelta(seconds=ttl_seconds)
25
+ self._cleanup_thread = None
26
+ self._running = False
27
+
28
+ def store_task(self, task: Dict[str, Any]) -> str:
29
+ """
30
+ Store a task and return its ID.
31
+
32
+ Args:
33
+ task: Task dictionary (must contain 'id' field)
34
+
35
+ Returns:
36
+ Task ID
37
+ """
38
+ task_id = task["id"]
39
+
40
+ with self._lock:
41
+ self._tasks[task_id] = task
42
+ self._timestamps[task_id] = datetime.now()
43
+
44
+ return task_id
45
+
46
+ def get_task(self, task_id: str) -> Dict[str, Any] | None:
47
+ """
48
+ Retrieve a task by ID.
49
+
50
+ Args:
51
+ task_id: Task identifier
52
+
53
+ Returns:
54
+ Task dictionary or None if not found/expired
55
+ """
56
+ with self._lock:
57
+ if task_id not in self._tasks:
58
+ return None
59
+
60
+ # Check if expired
61
+ if self._is_expired(task_id):
62
+ self._delete_task(task_id)
63
+ return None
64
+
65
+ return self._tasks[task_id]
66
+
67
+ def delete_task(self, task_id: str) -> bool:
68
+ """
69
+ Delete a task by ID.
70
+
71
+ Args:
72
+ task_id: Task identifier
73
+
74
+ Returns:
75
+ True if deleted, False if not found
76
+ """
77
+ with self._lock:
78
+ return self._delete_task(task_id)
79
+
80
+ def _delete_task(self, task_id: str) -> bool:
81
+ """Internal delete (assumes lock is held)."""
82
+ if task_id in self._tasks:
83
+ del self._tasks[task_id]
84
+ del self._timestamps[task_id]
85
+ return True
86
+ return False
87
+
88
+ def list_tasks(self) -> List[Dict[str, Any]]:
89
+ """
90
+ List all active (non-expired) tasks.
91
+
92
+ Returns:
93
+ List of task dictionaries
94
+ """
95
+ with self._lock:
96
+ self._cleanup_expired()
97
+ return [
98
+ {
99
+ "id": task["id"],
100
+ "label": task.get("label", task.get("id")),
101
+ "difficulty": task.get("difficulty", "custom"),
102
+ "description": task.get("description", ""),
103
+ "uploaded_at": task.get("uploaded_at"),
104
+ "is_custom_upload": True,
105
+ }
106
+ for task in self._tasks.values()
107
+ ]
108
+
109
+ def cleanup_expired(self) -> int:
110
+ """
111
+ Remove expired tasks.
112
+
113
+ Returns:
114
+ Number of tasks removed
115
+ """
116
+ with self._lock:
117
+ return self._cleanup_expired()
118
+
119
+ def _cleanup_expired(self) -> int:
120
+ """Internal cleanup (assumes lock is held)."""
121
+ expired = [
122
+ task_id for task_id in self._tasks.keys()
123
+ if self._is_expired(task_id)
124
+ ]
125
+
126
+ for task_id in expired:
127
+ self._delete_task(task_id)
128
+
129
+ return len(expired)
130
+
131
+ def _is_expired(self, task_id: str) -> bool:
132
+ """Check if a task has expired."""
133
+ if task_id not in self._timestamps:
134
+ return True
135
+
136
+ age = datetime.now() - self._timestamps[task_id]
137
+ return age > self._ttl
138
+
139
+ def start_background_cleanup(self, interval_seconds: int = 300):
140
+ """
141
+ Start background thread that periodically cleans up expired tasks.
142
+
143
+ Args:
144
+ interval_seconds: Cleanup interval (default: 5 minutes)
145
+ """
146
+ if self._running:
147
+ return
148
+
149
+ self._running = True
150
+
151
+ def cleanup_loop():
152
+ while self._running:
153
+ time.sleep(interval_seconds)
154
+ if self._running:
155
+ removed = self.cleanup_expired()
156
+ if removed > 0:
157
+ print(f"DynamicTaskStore: Cleaned up {removed} expired task(s)")
158
+
159
+ self._cleanup_thread = threading.Thread(target=cleanup_loop, daemon=True)
160
+ self._cleanup_thread.start()
161
+
162
+ def stop_background_cleanup(self):
163
+ """Stop the background cleanup thread."""
164
+ self._running = False
165
+ if self._cleanup_thread:
166
+ self._cleanup_thread.join(timeout=1)
167
+
168
+ def clear(self):
169
+ """Clear all tasks (for testing)."""
170
+ with self._lock:
171
+ self._tasks.clear()
172
+ self._timestamps.clear()
173
+
174
+ def __len__(self) -> int:
175
+ """Return number of stored tasks."""
176
+ with self._lock:
177
+ return len(self._tasks)
178
+
179
+
180
+ # Global instance
181
+ _store = DynamicTaskStore()
182
+
183
+
184
+ # Public API
185
+ def get_store() -> DynamicTaskStore:
186
+ """Get the global task store instance."""
187
+ return _store
188
+
189
+
190
+ def store_dynamic_task(task: Dict[str, Any]) -> str:
191
+ """Store a dynamic task."""
192
+ return _store.store_task(task)
193
+
194
+
195
+ def get_dynamic_task(task_id: str) -> Dict[str, Any] | None:
196
+ """Retrieve a dynamic task."""
197
+ return _store.get_task(task_id)
198
+
199
+
200
+ def delete_dynamic_task(task_id: str) -> bool:
201
+ """Delete a dynamic task."""
202
+ return _store.delete_task(task_id)
203
+
204
+
205
+ def list_dynamic_tasks() -> List[Dict[str, Any]]:
206
+ """List all dynamic tasks."""
207
+ return _store.list_tasks()
208
+
209
+
210
+ def cleanup_expired_tasks() -> int:
211
+ """Clean up expired tasks."""
212
+ return _store.cleanup_expired()
213
+
214
+
215
+ # Initialize background cleanup on import
216
+ _store.start_background_cleanup()
frontend/package-lock.json CHANGED
@@ -50,6 +50,7 @@
50
  "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
51
  "dev": true,
52
  "license": "MIT",
 
53
  "dependencies": {
54
  "@babel/code-frame": "^7.29.0",
55
  "@babel/generator": "^7.29.0",
@@ -1164,6 +1165,7 @@
1164
  "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
1165
  "dev": true,
1166
  "license": "MIT",
 
1167
  "dependencies": {
1168
  "@types/prop-types": "*",
1169
  "csstype": "^3.2.2"
@@ -1250,6 +1252,7 @@
1250
  }
1251
  ],
1252
  "license": "MIT",
 
1253
  "dependencies": {
1254
  "baseline-browser-mapping": "^2.10.12",
1255
  "caniuse-lite": "^1.0.30001782",
@@ -1791,6 +1794,7 @@
1791
  "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
1792
  "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
1793
  "license": "MIT",
 
1794
  "dependencies": {
1795
  "loose-envify": "^1.1.0"
1796
  },
@@ -1932,6 +1936,7 @@
1932
  "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
1933
  "dev": true,
1934
  "license": "MIT",
 
1935
  "dependencies": {
1936
  "esbuild": "^0.21.3",
1937
  "postcss": "^8.4.43",
 
50
  "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
51
  "dev": true,
52
  "license": "MIT",
53
+ "peer": true,
54
  "dependencies": {
55
  "@babel/code-frame": "^7.29.0",
56
  "@babel/generator": "^7.29.0",
 
1165
  "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
1166
  "dev": true,
1167
  "license": "MIT",
1168
+ "peer": true,
1169
  "dependencies": {
1170
  "@types/prop-types": "*",
1171
  "csstype": "^3.2.2"
 
1252
  }
1253
  ],
1254
  "license": "MIT",
1255
+ "peer": true,
1256
  "dependencies": {
1257
  "baseline-browser-mapping": "^2.10.12",
1258
  "caniuse-lite": "^1.0.30001782",
 
1794
  "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
1795
  "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
1796
  "license": "MIT",
1797
+ "peer": true,
1798
  "dependencies": {
1799
  "loose-envify": "^1.1.0"
1800
  },
 
1936
  "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
1937
  "dev": true,
1938
  "license": "MIT",
1939
+ "peer": true,
1940
  "dependencies": {
1941
  "esbuild": "^0.21.3",
1942
  "postcss": "^8.4.43",
frontend/src/App.jsx CHANGED
@@ -1,6 +1,7 @@
1
  import { useState, useCallback } from 'react';
2
  import Header from './components/Header';
3
  import TaskSelector from './components/TaskSelector';
 
4
  import PRViewer from './components/PRViewer';
5
  import ActionPanel from './components/ActionPanel';
6
  import StateViewer from './components/StateViewer';
@@ -19,6 +20,7 @@ export default function App() {
19
  const [toast, setToast] = useState(null);
20
  const [loading, setLoading] = useState(false);
21
  const [autoPilotRunning, setAutoPilotRunning] = useState(false);
 
22
 
23
  const showToast = (message, type = 'info') => {
24
  setToast({ message, type });
@@ -51,12 +53,14 @@ export default function App() {
51
  description: data.description,
52
  issue_title: data.issue_title,
53
  issue_body: data.issue_body,
 
54
  });
55
  setDone(false);
56
  setScore(null);
57
  setReport(null);
58
  setState(data.state);
59
- showToast(`Task "${data.task_id}" loaded ${data.issue_title}`, 'success');
 
60
  } catch (err) {
61
  const msg = err.response?.data?.error || 'Failed to start task';
62
  showToast(msg, 'error');
@@ -65,6 +69,14 @@ export default function App() {
65
  }
66
  };
67
 
 
 
 
 
 
 
 
 
68
  const handleAction = useCallback(async (actionType, payload) => {
69
  try {
70
  const data = await stepAction(actionType, payload);
@@ -76,9 +88,10 @@ export default function App() {
76
  setScore(data.score);
77
  setReport(data.report);
78
  const status = data.report?.grade_status || '';
 
79
  showToast(
80
  `Episode finished! Score: ${(data.score * 100).toFixed(1)}% — ${status}`,
81
- status === 'PASS' ? 'success' : 'error'
82
  );
83
  } else {
84
  const suffix = payload?.path || payload?.text || actionType;
@@ -115,9 +128,10 @@ export default function App() {
115
  setScore(data.score);
116
  setReport(data.report);
117
  const status = data.report?.grade_status || '';
 
118
  showToast(
119
  `Auto-pilot finished! Score: ${(data.score * 100).toFixed(1)}% — ${status}`,
120
- status === 'PASS' ? 'success' : 'error'
121
  );
122
  }
123
  } catch (err) {
@@ -149,6 +163,11 @@ export default function App() {
149
  disabled={loading || autoPilotRunning}
150
  loading={loading}
151
  selectedTask={selectedTask}
 
 
 
 
 
152
  />
153
  {observation && !done && (
154
  <button
 
1
  import { useState, useCallback } from 'react';
2
  import Header from './components/Header';
3
  import TaskSelector from './components/TaskSelector';
4
+ import UploadPanel from './components/UploadPanel';
5
  import PRViewer from './components/PRViewer';
6
  import ActionPanel from './components/ActionPanel';
7
  import StateViewer from './components/StateViewer';
 
20
  const [toast, setToast] = useState(null);
21
  const [loading, setLoading] = useState(false);
22
  const [autoPilotRunning, setAutoPilotRunning] = useState(false);
23
+ const [refreshTasks, setRefreshTasks] = useState(0);
24
 
25
  const showToast = (message, type = 'info') => {
26
  setToast({ message, type });
 
53
  description: data.description,
54
  issue_title: data.issue_title,
55
  issue_body: data.issue_body,
56
+ is_custom_upload: data.is_custom_upload,
57
  });
58
  setDone(false);
59
  setScore(null);
60
  setReport(null);
61
  setState(data.state);
62
+ const taskType = data.is_custom_upload ? '📤 Custom' : '';
63
+ showToast(`${taskType} Task "${data.task_id}" loaded — ${data.issue_title}`, 'success');
64
  } catch (err) {
65
  const msg = err.response?.data?.error || 'Failed to start task';
66
  showToast(msg, 'error');
 
69
  }
70
  };
71
 
72
+ const handleUploadComplete = (result) => {
73
+ showToast(`Uploaded! Task "${result.task_id}" created. Select it from the dropdown.`, 'success');
74
+ // Trigger task list refresh
75
+ setRefreshTasks(prev => prev + 1);
76
+ // Auto-select the new task
77
+ setSelectedTask(result.task_id);
78
+ };
79
+
80
  const handleAction = useCallback(async (actionType, payload) => {
81
  try {
82
  const data = await stepAction(actionType, payload);
 
88
  setScore(data.score);
89
  setReport(data.report);
90
  const status = data.report?.grade_status || '';
91
+ const statusType = status === 'PASS' || status === 'REVIEWED' ? 'success' : 'error';
92
  showToast(
93
  `Episode finished! Score: ${(data.score * 100).toFixed(1)}% — ${status}`,
94
+ statusType
95
  );
96
  } else {
97
  const suffix = payload?.path || payload?.text || actionType;
 
128
  setScore(data.score);
129
  setReport(data.report);
130
  const status = data.report?.grade_status || '';
131
+ const statusType = status === 'PASS' || status === 'REVIEWED' ? 'success' : 'error';
132
  showToast(
133
  `Auto-pilot finished! Score: ${(data.score * 100).toFixed(1)}% — ${status}`,
134
+ statusType
135
  );
136
  }
137
  } catch (err) {
 
163
  disabled={loading || autoPilotRunning}
164
  loading={loading}
165
  selectedTask={selectedTask}
166
+ refreshKey={refreshTasks}
167
+ />
168
+ <UploadPanel
169
+ onUploadComplete={handleUploadComplete}
170
+ disabled={loading || autoPilotRunning}
171
  />
172
  {observation && !done && (
173
  <button
frontend/src/components/TaskSelector.jsx CHANGED
@@ -1,12 +1,13 @@
1
  import { useState, useEffect } from 'react';
2
  import { fetchTasks } from '../services/api';
3
 
4
- export default function TaskSelector({ onSelect, onStart, disabled, loading, selectedTask }) {
5
  const [tasks, setTasks] = useState([]);
6
  const [fetchLoading, setFetchLoading] = useState(true);
7
  const [error, setError] = useState(null);
8
 
9
  useEffect(() => {
 
10
  fetchTasks()
11
  .then((data) => {
12
  setTasks(data.tasks || []);
@@ -16,7 +17,7 @@ export default function TaskSelector({ onSelect, onStart, disabled, loading, sel
16
  setError('Failed to load tasks');
17
  setFetchLoading(false);
18
  });
19
- }, []);
20
 
21
  const handleSelect = (e) => {
22
  const val = e.target.value;
 
1
  import { useState, useEffect } from 'react';
2
  import { fetchTasks } from '../services/api';
3
 
4
+ export default function TaskSelector({ onSelect, onStart, disabled, loading, selectedTask, refreshKey = 0 }) {
5
  const [tasks, setTasks] = useState([]);
6
  const [fetchLoading, setFetchLoading] = useState(true);
7
  const [error, setError] = useState(null);
8
 
9
  useEffect(() => {
10
+ setFetchLoading(true);
11
  fetchTasks()
12
  .then((data) => {
13
  setTasks(data.tasks || []);
 
17
  setError('Failed to load tasks');
18
  setFetchLoading(false);
19
  });
20
+ }, [refreshKey]);
21
 
22
  const handleSelect = (e) => {
23
  const val = e.target.value;
frontend/src/components/UploadPanel.jsx ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef } from 'react';
2
+ import { uploadCode } from '../services/api';
3
+
4
+ export default function UploadPanel({ onUploadComplete, disabled }) {
5
+ const [isOpen, setIsOpen] = useState(false);
6
+ const [title, setTitle] = useState('');
7
+ const [description, setDescription] = useState('');
8
+ const [files, setFiles] = useState([]);
9
+ const [originalFiles, setOriginalFiles] = useState([]);
10
+ const [uploading, setUploading] = useState(false);
11
+ const [error, setError] = useState(null);
12
+ const [dragActive, setDragActive] = useState(false);
13
+
14
+ const fileInputRef = useRef(null);
15
+ const originalInputRef = useRef(null);
16
+
17
+ const handleDrag = (e) => {
18
+ e.preventDefault();
19
+ e.stopPropagation();
20
+ if (e.type === 'dragenter' || e.type === 'dragover') {
21
+ setDragActive(true);
22
+ } else if (e.type === 'dragleave') {
23
+ setDragActive(false);
24
+ }
25
+ };
26
+
27
+ const handleDrop = (e) => {
28
+ e.preventDefault();
29
+ e.stopPropagation();
30
+ setDragActive(false);
31
+
32
+ const droppedFiles = [...e.dataTransfer.files];
33
+ setFiles(prev => [...prev, ...droppedFiles]);
34
+ };
35
+
36
+ const handleFileSelect = (e) => {
37
+ const selectedFiles = [...e.target.files];
38
+ setFiles(prev => [...prev, ...selectedFiles]);
39
+ };
40
+
41
+ const handleOriginalSelect = (e) => {
42
+ const selectedFiles = [...e.target.files];
43
+ setOriginalFiles(prev => [...prev, ...selectedFiles]);
44
+ };
45
+
46
+ const removeFile = (index, isOriginal = false) => {
47
+ if (isOriginal) {
48
+ setOriginalFiles(prev => prev.filter((_, i) => i !== index));
49
+ } else {
50
+ setFiles(prev => prev.filter((_, i) => i !== index));
51
+ }
52
+ };
53
+
54
+ const handleSubmit = async () => {
55
+ if (!title.trim()) {
56
+ setError('Title is required');
57
+ return;
58
+ }
59
+ if (!description.trim()) {
60
+ setError('Description is required');
61
+ return;
62
+ }
63
+ if (files.length === 0) {
64
+ setError('At least one file is required');
65
+ return;
66
+ }
67
+
68
+ setUploading(true);
69
+ setError(null);
70
+
71
+ try {
72
+ const result = await uploadCode(title, description, files, originalFiles);
73
+
74
+ // Reset form
75
+ setTitle('');
76
+ setDescription('');
77
+ setFiles([]);
78
+ setOriginalFiles([]);
79
+ setIsOpen(false);
80
+
81
+ // Notify parent
82
+ if (onUploadComplete) {
83
+ onUploadComplete(result);
84
+ }
85
+ } catch (err) {
86
+ setError(err.response?.data?.error || 'Upload failed');
87
+ } finally {
88
+ setUploading(false);
89
+ }
90
+ };
91
+
92
+ const handleClear = () => {
93
+ setTitle('');
94
+ setDescription('');
95
+ setFiles([]);
96
+ setOriginalFiles([]);
97
+ setError(null);
98
+ };
99
+
100
+ const handleClose = () => {
101
+ setIsOpen(false);
102
+ setError(null);
103
+ };
104
+
105
+ return (
106
+ <>
107
+ <button
108
+ className="btn btn-upload-toggle"
109
+ onClick={() => setIsOpen(true)}
110
+ disabled={disabled}
111
+ >
112
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
113
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
114
+ <polyline points="17 8 12 3 7 8"/>
115
+ <line x1="12" y1="3" x2="12" y2="15"/>
116
+ </svg>
117
+ Upload Code
118
+ </button>
119
+
120
+ {isOpen && (
121
+ <div className="upload-modal-overlay" onClick={handleClose}>
122
+ <div className="upload-modal" onClick={(e) => e.stopPropagation()}>
123
+ <div className="upload-modal-header">
124
+ <h3>📤 Upload Custom Code for Review</h3>
125
+ <button className="btn btn-icon" onClick={handleClose} title="Close">
126
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
127
+ <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
128
+ </svg>
129
+ </button>
130
+ </div>
131
+
132
+ <div className="upload-modal-body">
133
+ <div className="upload-form-row">
134
+ <div className="form-group">
135
+ <label>PR / Issue Title *</label>
136
+ <input
137
+ type="text"
138
+ value={title}
139
+ onChange={(e) => setTitle(e.target.value)}
140
+ placeholder="e.g., Fix authentication bug in login handler"
141
+ disabled={uploading}
142
+ />
143
+ </div>
144
+ </div>
145
+
146
+ <div className="upload-form-row">
147
+ <div className="form-group">
148
+ <label>Description *</label>
149
+ <textarea
150
+ value={description}
151
+ onChange={(e) => setDescription(e.target.value)}
152
+ placeholder="Describe what this PR/code change is about..."
153
+ rows={2}
154
+ disabled={uploading}
155
+ />
156
+ </div>
157
+ </div>
158
+
159
+ <div className="upload-form-columns">
160
+ <div className="form-group">
161
+ <label>Modified Files * (drag & drop)</label>
162
+ <div
163
+ className={`drop-zone ${dragActive ? 'active' : ''}`}
164
+ onDragEnter={handleDrag}
165
+ onDragLeave={handleDrag}
166
+ onDragOver={handleDrag}
167
+ onDrop={handleDrop}
168
+ onClick={() => fileInputRef.current?.click()}
169
+ >
170
+ <input
171
+ ref={fileInputRef}
172
+ type="file"
173
+ multiple
174
+ onChange={handleFileSelect}
175
+ style={{ display: 'none' }}
176
+ />
177
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
178
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
179
+ <polyline points="17 8 12 3 7 8"/>
180
+ <line x1="12" y1="3" x2="12" y2="15"/>
181
+ </svg>
182
+ <span>Drop files or click</span>
183
+ </div>
184
+ {files.length > 0 && (
185
+ <div className="file-list">
186
+ {files.map((file, i) => (
187
+ <div key={i} className="file-item">
188
+ <span className="file-name">📄 {file.name}</span>
189
+ <button className="btn btn-icon-sm" onClick={() => removeFile(i)}>×</button>
190
+ </div>
191
+ ))}
192
+ </div>
193
+ )}
194
+ </div>
195
+
196
+ <div className="form-group">
197
+ <label>Original Files (optional)</label>
198
+ <div
199
+ className="drop-zone drop-zone-secondary"
200
+ onClick={() => originalInputRef.current?.click()}
201
+ >
202
+ <input
203
+ ref={originalInputRef}
204
+ type="file"
205
+ multiple
206
+ onChange={handleOriginalSelect}
207
+ style={{ display: 'none' }}
208
+ />
209
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
210
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
211
+ <polyline points="14 2 14 8 20 8"/>
212
+ </svg>
213
+ <span>For diff generation</span>
214
+ </div>
215
+ {originalFiles.length > 0 && (
216
+ <div className="file-list file-list-original">
217
+ {originalFiles.map((file, i) => (
218
+ <div key={i} className="file-item">
219
+ <span className="file-name">📋 {file.name}</span>
220
+ <button className="btn btn-icon-sm" onClick={() => removeFile(i, true)}>×</button>
221
+ </div>
222
+ ))}
223
+ </div>
224
+ )}
225
+ </div>
226
+ </div>
227
+
228
+ {error && <div className="upload-error">{error}</div>}
229
+ </div>
230
+
231
+ <div className="upload-modal-footer">
232
+ <button
233
+ className="btn btn-secondary"
234
+ onClick={handleClear}
235
+ disabled={uploading}
236
+ >
237
+ Clear
238
+ </button>
239
+ <button
240
+ className="btn btn-primary"
241
+ onClick={handleSubmit}
242
+ disabled={uploading || files.length === 0}
243
+ >
244
+ {uploading ? (
245
+ <>
246
+ <span className="btn-spinner" />
247
+ Uploading...
248
+ </>
249
+ ) : (
250
+ <>
251
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
252
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
253
+ <polyline points="17 8 12 3 7 8"/>
254
+ <line x1="12" y1="3" x2="12" y2="15"/>
255
+ </svg>
256
+ Upload & Create Task
257
+ </>
258
+ )}
259
+ </button>
260
+ </div>
261
+ </div>
262
+ </div>
263
+ )}
264
+ </>
265
+ );
266
+ }
frontend/src/index.css CHANGED
@@ -1116,3 +1116,274 @@ body {
1116
 
1117
  .task-select { min-width: 180px; }
1118
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1116
 
1117
  .task-select { min-width: 180px; }
1118
  }
1119
+
1120
+ /* ═══════════ UPLOAD MODAL ═══════════ */
1121
+ .btn-upload-toggle {
1122
+ background: var(--surface-2);
1123
+ border: 1px dashed var(--border-hi);
1124
+ color: var(--text-2);
1125
+ }
1126
+
1127
+ .btn-upload-toggle:hover:not(:disabled) {
1128
+ background: var(--surface-3);
1129
+ border-color: var(--accent);
1130
+ color: var(--accent);
1131
+ }
1132
+
1133
+ .upload-modal-overlay {
1134
+ position: fixed;
1135
+ top: 0;
1136
+ left: 0;
1137
+ right: 0;
1138
+ bottom: 0;
1139
+ background: rgba(0, 0, 0, 0.7);
1140
+ backdrop-filter: blur(4px);
1141
+ display: flex;
1142
+ align-items: center;
1143
+ justify-content: center;
1144
+ z-index: 1000;
1145
+ animation: fadeIn 0.2s ease;
1146
+ }
1147
+
1148
+ @keyframes fadeIn {
1149
+ from { opacity: 0; }
1150
+ to { opacity: 1; }
1151
+ }
1152
+
1153
+ .upload-modal {
1154
+ background: var(--surface);
1155
+ border: 1px solid var(--border);
1156
+ border-radius: var(--radius-lg);
1157
+ width: 90%;
1158
+ max-width: 600px;
1159
+ max-height: 85vh;
1160
+ display: flex;
1161
+ flex-direction: column;
1162
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
1163
+ animation: slideUp 0.25s ease;
1164
+ }
1165
+
1166
+ @keyframes slideUp {
1167
+ from { transform: translateY(20px); opacity: 0; }
1168
+ to { transform: translateY(0); opacity: 1; }
1169
+ }
1170
+
1171
+ .upload-modal-header {
1172
+ display: flex;
1173
+ align-items: center;
1174
+ justify-content: space-between;
1175
+ padding: 16px 20px;
1176
+ background: var(--surface-2);
1177
+ border-bottom: 1px solid var(--border);
1178
+ border-radius: var(--radius-lg) var(--radius-lg) 0 0;
1179
+ }
1180
+
1181
+ .upload-modal-header h3 {
1182
+ font-size: 1rem;
1183
+ font-weight: 600;
1184
+ color: var(--text);
1185
+ margin: 0;
1186
+ }
1187
+
1188
+ .upload-modal-body {
1189
+ padding: 20px;
1190
+ overflow-y: auto;
1191
+ flex: 1;
1192
+ }
1193
+
1194
+ .upload-form-row {
1195
+ margin-bottom: 16px;
1196
+ }
1197
+
1198
+ .upload-form-columns {
1199
+ display: grid;
1200
+ grid-template-columns: 1fr 1fr;
1201
+ gap: 16px;
1202
+ }
1203
+
1204
+ .form-group {
1205
+ display: flex;
1206
+ flex-direction: column;
1207
+ gap: 6px;
1208
+ }
1209
+
1210
+ .form-group label {
1211
+ font-size: 0.75rem;
1212
+ font-weight: 600;
1213
+ color: var(--text-2);
1214
+ text-transform: uppercase;
1215
+ letter-spacing: 0.04em;
1216
+ }
1217
+
1218
+ .form-group input,
1219
+ .form-group textarea {
1220
+ background: var(--bg);
1221
+ color: var(--text);
1222
+ border: 1px solid var(--border);
1223
+ border-radius: var(--radius);
1224
+ padding: 10px 12px;
1225
+ font-family: var(--font);
1226
+ font-size: 0.85rem;
1227
+ transition: border-color 0.15s;
1228
+ }
1229
+
1230
+ .form-group input:focus,
1231
+ .form-group textarea:focus {
1232
+ outline: none;
1233
+ border-color: var(--accent);
1234
+ box-shadow: 0 0 0 2px rgba(124,110,240,0.15);
1235
+ }
1236
+
1237
+ .form-group input::placeholder,
1238
+ .form-group textarea::placeholder {
1239
+ color: var(--text-3);
1240
+ }
1241
+
1242
+ .drop-zone {
1243
+ display: flex;
1244
+ flex-direction: column;
1245
+ align-items: center;
1246
+ justify-content: center;
1247
+ gap: 6px;
1248
+ padding: 20px 12px;
1249
+ border: 2px dashed var(--border);
1250
+ border-radius: var(--radius);
1251
+ background: var(--bg);
1252
+ cursor: pointer;
1253
+ transition: all 0.2s;
1254
+ min-height: 100px;
1255
+ }
1256
+
1257
+ .drop-zone:hover,
1258
+ .drop-zone.active {
1259
+ border-color: var(--accent);
1260
+ background: rgba(124,110,240,0.05);
1261
+ }
1262
+
1263
+ .drop-zone svg {
1264
+ color: var(--text-3);
1265
+ transition: color 0.2s;
1266
+ }
1267
+
1268
+ .drop-zone:hover svg,
1269
+ .drop-zone.active svg {
1270
+ color: var(--accent);
1271
+ }
1272
+
1273
+ .drop-zone span {
1274
+ font-size: 0.78rem;
1275
+ color: var(--text-3);
1276
+ text-align: center;
1277
+ }
1278
+
1279
+ .drop-zone-secondary {
1280
+ border-style: dotted;
1281
+ background: var(--surface-2);
1282
+ }
1283
+
1284
+ .file-list {
1285
+ display: flex;
1286
+ flex-direction: column;
1287
+ gap: 4px;
1288
+ margin-top: 8px;
1289
+ max-height: 120px;
1290
+ overflow-y: auto;
1291
+ }
1292
+
1293
+ .file-item {
1294
+ display: flex;
1295
+ align-items: center;
1296
+ gap: 8px;
1297
+ padding: 6px 10px;
1298
+ background: var(--surface-2);
1299
+ border-radius: var(--radius);
1300
+ font-size: 0.8rem;
1301
+ }
1302
+
1303
+ .file-name {
1304
+ flex: 1;
1305
+ color: var(--text);
1306
+ font-family: var(--font-mono);
1307
+ font-size: 0.75rem;
1308
+ overflow: hidden;
1309
+ text-overflow: ellipsis;
1310
+ white-space: nowrap;
1311
+ }
1312
+
1313
+ .btn-icon-sm {
1314
+ padding: 2px 6px;
1315
+ font-size: 0.85rem;
1316
+ line-height: 1;
1317
+ border: none;
1318
+ background: transparent;
1319
+ color: var(--text-3);
1320
+ cursor: pointer;
1321
+ border-radius: 4px;
1322
+ }
1323
+
1324
+ .btn-icon-sm:hover {
1325
+ background: var(--red-bg);
1326
+ color: var(--red);
1327
+ }
1328
+
1329
+ .file-list-original .file-item {
1330
+ background: var(--blue-bg);
1331
+ border: 1px solid rgba(88,166,255,0.2);
1332
+ }
1333
+
1334
+ .upload-error {
1335
+ padding: 10px 12px;
1336
+ background: var(--red-bg);
1337
+ border: 1px solid rgba(248,81,73,0.3);
1338
+ border-radius: var(--radius);
1339
+ color: var(--red);
1340
+ font-size: 0.82rem;
1341
+ margin-top: 12px;
1342
+ }
1343
+
1344
+ .upload-modal-footer {
1345
+ display: flex;
1346
+ justify-content: flex-end;
1347
+ gap: 10px;
1348
+ padding: 16px 20px;
1349
+ background: var(--surface-2);
1350
+ border-top: 1px solid var(--border);
1351
+ border-radius: 0 0 var(--radius-lg) var(--radius-lg);
1352
+ }
1353
+
1354
+ .btn-secondary {
1355
+ background: var(--surface-2);
1356
+ border: 1px solid var(--border);
1357
+ color: var(--text-2);
1358
+ }
1359
+
1360
+ .btn-secondary:hover:not(:disabled) {
1361
+ background: var(--surface-3);
1362
+ color: var(--text);
1363
+ }
1364
+
1365
+ .btn-icon {
1366
+ padding: 4px;
1367
+ background: transparent;
1368
+ border: none;
1369
+ color: var(--text-3);
1370
+ cursor: pointer;
1371
+ border-radius: 4px;
1372
+ transition: all 0.15s;
1373
+ }
1374
+
1375
+ .btn-icon:hover {
1376
+ background: var(--surface-3);
1377
+ color: var(--text);
1378
+ }
1379
+
1380
+ @media (max-width: 600px) {
1381
+ .upload-form-columns {
1382
+ grid-template-columns: 1fr;
1383
+ }
1384
+
1385
+ .upload-modal {
1386
+ width: 95%;
1387
+ max-height: 90vh;
1388
+ }
1389
+ }
frontend/src/services/api.js CHANGED
@@ -32,4 +32,28 @@ export async function autoAction() {
32
  return res.data;
33
  }
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  export default api;
 
32
  return res.data;
33
  }
34
 
35
+ export async function uploadCode(title, description, files, originalFiles = []) {
36
+ const formData = new FormData();
37
+ formData.append('title', title);
38
+ formData.append('description', description);
39
+
40
+ files.forEach(file => {
41
+ formData.append('files', file);
42
+ });
43
+
44
+ originalFiles.forEach(file => {
45
+ formData.append('original_files', file);
46
+ });
47
+
48
+ const res = await api.post('/api/upload', formData, {
49
+ headers: { 'Content-Type': 'multipart/form-data' },
50
+ });
51
+ return res.data;
52
+ }
53
+
54
+ export async function deleteUpload(taskId) {
55
+ const res = await api.delete(`/api/upload/${taskId}`);
56
+ return res.data;
57
+ }
58
+
59
  export default api;