code-analyser 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_analyser-0.1.0.dist-info/METADATA +283 -0
- code_analyser-0.1.0.dist-info/RECORD +34 -0
- code_analyser-0.1.0.dist-info/WHEEL +4 -0
- code_analyser-0.1.0.dist-info/licenses/LICENSE +21 -0
- codelens/__init__.py +7 -0
- codelens/__main__.py +19 -0
- codelens/analyzers/__init__.py +30 -0
- codelens/analyzers/base.py +139 -0
- codelens/analyzers/manager.py +207 -0
- codelens/analyzers/python_analyzer.py +344 -0
- codelens/analyzers/similarity_analyzer.py +512 -0
- codelens/api/__init__.py +1 -0
- codelens/api/routes/__init__.py +1 -0
- codelens/api/routes/analysis.py +441 -0
- codelens/api/routes/reports.py +438 -0
- codelens/api/routes/rubrics.py +349 -0
- codelens/api/schemas.py +305 -0
- codelens/cli.py +297 -0
- codelens/core/__init__.py +1 -0
- codelens/core/config.py +91 -0
- codelens/db/__init__.py +1 -0
- codelens/db/database.py +57 -0
- codelens/main.py +111 -0
- codelens/models/__init__.py +14 -0
- codelens/models/assignments.py +105 -0
- codelens/models/reports.py +172 -0
- codelens/models/rubrics.py +76 -0
- codelens/services/__init__.py +37 -0
- codelens/services/batch_processor.py +508 -0
- codelens/services/code_executor.py +310 -0
- codelens/services/sandbox.py +375 -0
- codelens/services/similarity_service.py +449 -0
- codelens/utils/__init__.py +29 -0
- codelens/utils/helpers.py +217 -0
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
"""
|
|
2
|
+
API endpoints for analysis reports and results
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import structlog
|
|
9
|
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
10
|
+
from fastapi import status as http_status
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
from sqlalchemy import and_, desc, func, select
|
|
13
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
14
|
+
|
|
15
|
+
from codelens.db.database import get_db
|
|
16
|
+
from codelens.models import AnalysisReport, Assignment, SimilarityMatch
|
|
17
|
+
|
|
18
|
+
logger = structlog.get_logger()
|
|
19
|
+
router = APIRouter()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ReportSummary(BaseModel):
|
|
23
|
+
"""Summary schema for analysis reports"""
|
|
24
|
+
id: int
|
|
25
|
+
submission_id: str
|
|
26
|
+
assignment_id: int | None = None
|
|
27
|
+
student_id: str | None = None
|
|
28
|
+
student_name: str | None = None
|
|
29
|
+
file_name: str
|
|
30
|
+
language: str
|
|
31
|
+
total_score: float
|
|
32
|
+
max_score: float
|
|
33
|
+
syntax_valid: bool
|
|
34
|
+
analyzed_at: datetime
|
|
35
|
+
status: str
|
|
36
|
+
|
|
37
|
+
class Config:
|
|
38
|
+
from_attributes = True
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ReportDetail(BaseModel):
|
|
42
|
+
"""Detailed schema for analysis reports"""
|
|
43
|
+
id: int
|
|
44
|
+
submission_id: str
|
|
45
|
+
assignment_id: int | None = None
|
|
46
|
+
student_id: str | None = None
|
|
47
|
+
student_name: str | None = None
|
|
48
|
+
file_name: str
|
|
49
|
+
file_size: int
|
|
50
|
+
file_hash: str
|
|
51
|
+
language: str
|
|
52
|
+
analysis_version: str
|
|
53
|
+
|
|
54
|
+
# Analysis results
|
|
55
|
+
syntax_valid: bool
|
|
56
|
+
syntax_errors: dict[str, Any] | None = None
|
|
57
|
+
quality_metrics: dict[str, Any]
|
|
58
|
+
test_results: dict[str, Any] | None = None
|
|
59
|
+
grade_breakdown: dict[str, Any]
|
|
60
|
+
total_score: float
|
|
61
|
+
max_score: float
|
|
62
|
+
similarity_results: dict[str, Any] | None = None
|
|
63
|
+
feedback: dict[str, Any]
|
|
64
|
+
|
|
65
|
+
# Metadata
|
|
66
|
+
processing_time: float
|
|
67
|
+
tools_used: dict[str, Any]
|
|
68
|
+
status: str
|
|
69
|
+
analyzed_at: datetime
|
|
70
|
+
|
|
71
|
+
class Config:
|
|
72
|
+
from_attributes = True
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class SimilarityMatchResponse(BaseModel):
|
|
76
|
+
"""Schema for similarity match results"""
|
|
77
|
+
id: int
|
|
78
|
+
report_id: int
|
|
79
|
+
matched_report_id: int
|
|
80
|
+
similarity_score: float
|
|
81
|
+
similarity_method: str
|
|
82
|
+
matched_sections: dict[str, Any]
|
|
83
|
+
confidence: float
|
|
84
|
+
flagged: bool
|
|
85
|
+
reviewed: bool
|
|
86
|
+
review_decision: str | None = None
|
|
87
|
+
reviewer_notes: str | None = None
|
|
88
|
+
detected_at: datetime
|
|
89
|
+
|
|
90
|
+
class Config:
|
|
91
|
+
from_attributes = True
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class AssignmentStatistics(BaseModel):
|
|
95
|
+
"""Statistics for an assignment"""
|
|
96
|
+
assignment_id: int
|
|
97
|
+
total_submissions: int
|
|
98
|
+
average_score: float
|
|
99
|
+
median_score: float
|
|
100
|
+
score_distribution: dict[str, int] # Grade ranges
|
|
101
|
+
common_issues: list[dict[str, Any]]
|
|
102
|
+
completion_rate: float
|
|
103
|
+
similarity_flags: int
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@router.get("/", response_model=list[ReportSummary])
|
|
107
|
+
async def list_reports(
|
|
108
|
+
assignment_id: int | None = None,
|
|
109
|
+
student_id: str | None = None,
|
|
110
|
+
language: str | None = None,
|
|
111
|
+
status: str | None = None,
|
|
112
|
+
limit: int = Query(50, le=500),
|
|
113
|
+
offset: int = Query(0, ge=0),
|
|
114
|
+
db: AsyncSession = Depends(get_db)
|
|
115
|
+
) -> list[ReportSummary]:
|
|
116
|
+
"""List analysis reports with filtering options"""
|
|
117
|
+
try:
|
|
118
|
+
query = select(AnalysisReport)
|
|
119
|
+
|
|
120
|
+
# Apply filters
|
|
121
|
+
if assignment_id:
|
|
122
|
+
query = query.where(AnalysisReport.assignment_id == assignment_id)
|
|
123
|
+
if student_id:
|
|
124
|
+
query = query.where(AnalysisReport.student_id == student_id)
|
|
125
|
+
if language:
|
|
126
|
+
query = query.where(AnalysisReport.language == language.lower())
|
|
127
|
+
if status:
|
|
128
|
+
query = query.where(AnalysisReport.status == status)
|
|
129
|
+
|
|
130
|
+
# Order by most recent first
|
|
131
|
+
query = query.order_by(desc(AnalysisReport.analyzed_at))
|
|
132
|
+
|
|
133
|
+
# Apply pagination
|
|
134
|
+
query = query.offset(offset).limit(limit)
|
|
135
|
+
|
|
136
|
+
result = await db.execute(query)
|
|
137
|
+
reports = result.scalars().all()
|
|
138
|
+
|
|
139
|
+
logger.info("Listed analysis reports",
|
|
140
|
+
count=len(reports),
|
|
141
|
+
assignment_id=assignment_id,
|
|
142
|
+
student_id=student_id)
|
|
143
|
+
|
|
144
|
+
return [ReportSummary.model_validate(report) for report in reports]
|
|
145
|
+
|
|
146
|
+
except Exception as e:
|
|
147
|
+
logger.error("Failed to list reports", error=str(e))
|
|
148
|
+
raise HTTPException(
|
|
149
|
+
status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
150
|
+
detail=f"Failed to list reports: {str(e)}"
|
|
151
|
+
) from None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@router.get("/{report_id}", response_model=ReportDetail)
|
|
155
|
+
async def get_report(
|
|
156
|
+
report_id: int,
|
|
157
|
+
db: AsyncSession = Depends(get_db)
|
|
158
|
+
) -> ReportDetail:
|
|
159
|
+
"""Get detailed analysis report by ID"""
|
|
160
|
+
try:
|
|
161
|
+
result = await db.execute(
|
|
162
|
+
select(AnalysisReport).where(AnalysisReport.id == report_id)
|
|
163
|
+
)
|
|
164
|
+
report = result.scalar_one_or_none()
|
|
165
|
+
|
|
166
|
+
if not report:
|
|
167
|
+
raise HTTPException(
|
|
168
|
+
status_code=http_status.HTTP_404_NOT_FOUND,
|
|
169
|
+
detail=f"Report {report_id} not found"
|
|
170
|
+
) from None
|
|
171
|
+
|
|
172
|
+
logger.info("Retrieved analysis report", report_id=report_id)
|
|
173
|
+
|
|
174
|
+
return ReportDetail.model_validate(report)
|
|
175
|
+
|
|
176
|
+
except HTTPException:
|
|
177
|
+
raise
|
|
178
|
+
except Exception as e:
|
|
179
|
+
logger.error("Failed to get report", report_id=report_id, error=str(e))
|
|
180
|
+
raise HTTPException(
|
|
181
|
+
status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
182
|
+
detail=f"Failed to get report: {str(e)}"
|
|
183
|
+
) from None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@router.get("/submission/{submission_id}", response_model=ReportDetail)
|
|
187
|
+
async def get_report_by_submission(
|
|
188
|
+
submission_id: str,
|
|
189
|
+
db: AsyncSession = Depends(get_db)
|
|
190
|
+
) -> ReportDetail:
|
|
191
|
+
"""Get analysis report by submission ID"""
|
|
192
|
+
try:
|
|
193
|
+
result = await db.execute(
|
|
194
|
+
select(AnalysisReport).where(AnalysisReport.submission_id == submission_id)
|
|
195
|
+
)
|
|
196
|
+
report = result.scalar_one_or_none()
|
|
197
|
+
|
|
198
|
+
if not report:
|
|
199
|
+
raise HTTPException(
|
|
200
|
+
status_code=http_status.HTTP_404_NOT_FOUND,
|
|
201
|
+
detail=f"Report for submission {submission_id} not found"
|
|
202
|
+
) from None
|
|
203
|
+
|
|
204
|
+
logger.info("Retrieved report by submission", submission_id=submission_id)
|
|
205
|
+
|
|
206
|
+
return ReportDetail.model_validate(report)
|
|
207
|
+
|
|
208
|
+
except HTTPException:
|
|
209
|
+
raise
|
|
210
|
+
except Exception as e:
|
|
211
|
+
logger.error("Failed to get report by submission",
|
|
212
|
+
submission_id=submission_id, error=str(e))
|
|
213
|
+
raise HTTPException(
|
|
214
|
+
status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
215
|
+
detail=f"Failed to get report: {str(e)}"
|
|
216
|
+
) from None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@router.get("/assignment/{assignment_id}/statistics", response_model=AssignmentStatistics)
|
|
220
|
+
async def get_assignment_statistics(
|
|
221
|
+
assignment_id: int,
|
|
222
|
+
db: AsyncSession = Depends(get_db)
|
|
223
|
+
) -> AssignmentStatistics:
|
|
224
|
+
"""Get statistics for an assignment"""
|
|
225
|
+
try:
|
|
226
|
+
# Verify assignment exists
|
|
227
|
+
assignment_result = await db.execute(
|
|
228
|
+
select(Assignment).where(Assignment.id == assignment_id)
|
|
229
|
+
)
|
|
230
|
+
assignment = assignment_result.scalar_one_or_none()
|
|
231
|
+
|
|
232
|
+
if not assignment:
|
|
233
|
+
raise HTTPException(
|
|
234
|
+
status_code=http_status.HTTP_404_NOT_FOUND,
|
|
235
|
+
detail=f"Assignment {assignment_id} not found"
|
|
236
|
+
) from None
|
|
237
|
+
|
|
238
|
+
# Get all reports for the assignment
|
|
239
|
+
reports_result = await db.execute(
|
|
240
|
+
select(AnalysisReport).where(AnalysisReport.assignment_id == assignment_id)
|
|
241
|
+
)
|
|
242
|
+
reports = reports_result.scalars().all()
|
|
243
|
+
|
|
244
|
+
if not reports:
|
|
245
|
+
return AssignmentStatistics(
|
|
246
|
+
assignment_id=assignment_id,
|
|
247
|
+
total_submissions=0,
|
|
248
|
+
average_score=0.0,
|
|
249
|
+
median_score=0.0,
|
|
250
|
+
score_distribution={},
|
|
251
|
+
common_issues=[],
|
|
252
|
+
completion_rate=0.0,
|
|
253
|
+
similarity_flags=0
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# Calculate statistics
|
|
257
|
+
scores = [report.total_score for report in reports]
|
|
258
|
+
total_submissions = len(reports)
|
|
259
|
+
average_score = sum(scores) / total_submissions
|
|
260
|
+
|
|
261
|
+
# Median score
|
|
262
|
+
sorted_scores = sorted(scores)
|
|
263
|
+
median_score = (
|
|
264
|
+
sorted_scores[total_submissions // 2] if total_submissions % 2 == 1
|
|
265
|
+
else (sorted_scores[total_submissions // 2 - 1] + sorted_scores[total_submissions // 2]) / 2
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
# Score distribution
|
|
269
|
+
score_distribution = {
|
|
270
|
+
"A (90-100)": len([s for s in scores if s >= 90]),
|
|
271
|
+
"B (80-89)": len([s for s in scores if 80 <= s < 90]),
|
|
272
|
+
"C (70-79)": len([s for s in scores if 70 <= s < 80]),
|
|
273
|
+
"D (60-69)": len([s for s in scores if 60 <= s < 70]),
|
|
274
|
+
"F (0-59)": len([s for s in scores if s < 60])
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
# Common issues (simplified - would need more sophisticated analysis)
|
|
278
|
+
common_issues = [
|
|
279
|
+
{"issue": "Style violations", "count": len([r for r in reports if r.quality_metrics.get("style_issues", [])])},
|
|
280
|
+
{"issue": "Type errors", "count": len([r for r in reports if r.quality_metrics.get("type_issues", [])])},
|
|
281
|
+
{"issue": "High complexity", "count": len([r for r in reports if r.quality_metrics.get("complexity", {}).get("cyclomatic", 0) > 10])}
|
|
282
|
+
]
|
|
283
|
+
|
|
284
|
+
# Completion rate (submissions with tests passing)
|
|
285
|
+
completed = len([r for r in reports if r.test_results and r.test_results.get("passed_tests", 0) > 0])
|
|
286
|
+
completion_rate = (completed / total_submissions) * 100 if total_submissions > 0 else 0
|
|
287
|
+
|
|
288
|
+
# Similarity flags
|
|
289
|
+
similarity_flags_result = await db.execute(
|
|
290
|
+
select(func.count(SimilarityMatch.id)).where(
|
|
291
|
+
and_(
|
|
292
|
+
SimilarityMatch.report_id.in_([r.id for r in reports]),
|
|
293
|
+
SimilarityMatch.flagged
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
similarity_flags = similarity_flags_result.scalar() or 0
|
|
298
|
+
|
|
299
|
+
statistics = AssignmentStatistics(
|
|
300
|
+
assignment_id=assignment_id,
|
|
301
|
+
total_submissions=total_submissions,
|
|
302
|
+
average_score=average_score,
|
|
303
|
+
median_score=median_score,
|
|
304
|
+
score_distribution=score_distribution,
|
|
305
|
+
common_issues=common_issues,
|
|
306
|
+
completion_rate=completion_rate,
|
|
307
|
+
similarity_flags=similarity_flags
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
logger.info("Generated assignment statistics",
|
|
311
|
+
assignment_id=assignment_id,
|
|
312
|
+
total_submissions=total_submissions)
|
|
313
|
+
|
|
314
|
+
return statistics
|
|
315
|
+
|
|
316
|
+
except HTTPException:
|
|
317
|
+
raise
|
|
318
|
+
except Exception as e:
|
|
319
|
+
logger.error("Failed to get assignment statistics",
|
|
320
|
+
assignment_id=assignment_id, error=str(e))
|
|
321
|
+
raise HTTPException(
|
|
322
|
+
status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
323
|
+
detail=f"Failed to get assignment statistics: {str(e)}"
|
|
324
|
+
) from None
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@router.get("/student/{student_id}", response_model=list[ReportSummary])
|
|
328
|
+
async def get_student_reports(
|
|
329
|
+
student_id: str,
|
|
330
|
+
assignment_id: int | None = None,
|
|
331
|
+
limit: int = Query(50, le=500),
|
|
332
|
+
offset: int = Query(0, ge=0),
|
|
333
|
+
db: AsyncSession = Depends(get_db)
|
|
334
|
+
) -> list[ReportSummary]:
|
|
335
|
+
"""Get all reports for a specific student"""
|
|
336
|
+
try:
|
|
337
|
+
query = select(AnalysisReport).where(AnalysisReport.student_id == student_id)
|
|
338
|
+
|
|
339
|
+
if assignment_id:
|
|
340
|
+
query = query.where(AnalysisReport.assignment_id == assignment_id)
|
|
341
|
+
|
|
342
|
+
query = query.order_by(desc(AnalysisReport.analyzed_at))
|
|
343
|
+
query = query.offset(offset).limit(limit)
|
|
344
|
+
|
|
345
|
+
result = await db.execute(query)
|
|
346
|
+
reports = result.scalars().all()
|
|
347
|
+
|
|
348
|
+
logger.info("Retrieved student reports",
|
|
349
|
+
student_id=student_id,
|
|
350
|
+
count=len(reports))
|
|
351
|
+
|
|
352
|
+
return [ReportSummary.model_validate(report) for report in reports]
|
|
353
|
+
|
|
354
|
+
except Exception as e:
|
|
355
|
+
logger.error("Failed to get student reports",
|
|
356
|
+
student_id=student_id, error=str(e))
|
|
357
|
+
raise HTTPException(
|
|
358
|
+
status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
359
|
+
detail=f"Failed to get student reports: {str(e)}"
|
|
360
|
+
) from None
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@router.get("/similarity/{report_id}", response_model=list[SimilarityMatchResponse])
|
|
364
|
+
async def get_similarity_matches(
|
|
365
|
+
report_id: int,
|
|
366
|
+
db: AsyncSession = Depends(get_db)
|
|
367
|
+
) -> list[SimilarityMatchResponse]:
|
|
368
|
+
"""Get similarity matches for a specific report"""
|
|
369
|
+
try:
|
|
370
|
+
# Verify report exists
|
|
371
|
+
report_result = await db.execute(
|
|
372
|
+
select(AnalysisReport).where(AnalysisReport.id == report_id)
|
|
373
|
+
)
|
|
374
|
+
report = report_result.scalar_one_or_none()
|
|
375
|
+
|
|
376
|
+
if not report:
|
|
377
|
+
raise HTTPException(
|
|
378
|
+
status_code=http_status.HTTP_404_NOT_FOUND,
|
|
379
|
+
detail=f"Report {report_id} not found"
|
|
380
|
+
) from None
|
|
381
|
+
|
|
382
|
+
# Get similarity matches
|
|
383
|
+
matches_result = await db.execute(
|
|
384
|
+
select(SimilarityMatch).where(
|
|
385
|
+
SimilarityMatch.report_id == report_id
|
|
386
|
+
).order_by(desc(SimilarityMatch.similarity_score))
|
|
387
|
+
)
|
|
388
|
+
matches = matches_result.scalars().all()
|
|
389
|
+
|
|
390
|
+
logger.info("Retrieved similarity matches",
|
|
391
|
+
report_id=report_id,
|
|
392
|
+
match_count=len(matches))
|
|
393
|
+
|
|
394
|
+
return [SimilarityMatchResponse.model_validate(match) for match in matches]
|
|
395
|
+
|
|
396
|
+
except HTTPException:
|
|
397
|
+
raise
|
|
398
|
+
except Exception as e:
|
|
399
|
+
logger.error("Failed to get similarity matches",
|
|
400
|
+
report_id=report_id, error=str(e))
|
|
401
|
+
raise HTTPException(
|
|
402
|
+
status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
403
|
+
detail=f"Failed to get similarity matches: {str(e)}"
|
|
404
|
+
) from None
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
@router.delete("/{report_id}", status_code=http_status.HTTP_204_NO_CONTENT)
|
|
408
|
+
async def delete_report(
|
|
409
|
+
report_id: int,
|
|
410
|
+
db: AsyncSession = Depends(get_db)
|
|
411
|
+
) -> None:
|
|
412
|
+
"""Delete an analysis report"""
|
|
413
|
+
try:
|
|
414
|
+
result = await db.execute(
|
|
415
|
+
select(AnalysisReport).where(AnalysisReport.id == report_id)
|
|
416
|
+
)
|
|
417
|
+
report = result.scalar_one_or_none()
|
|
418
|
+
|
|
419
|
+
if not report:
|
|
420
|
+
raise HTTPException(
|
|
421
|
+
status_code=http_status.HTTP_404_NOT_FOUND,
|
|
422
|
+
detail=f"Report {report_id} not found"
|
|
423
|
+
) from None
|
|
424
|
+
|
|
425
|
+
await db.delete(report)
|
|
426
|
+
await db.commit()
|
|
427
|
+
|
|
428
|
+
logger.info("Deleted analysis report", report_id=report_id)
|
|
429
|
+
|
|
430
|
+
except HTTPException:
|
|
431
|
+
raise
|
|
432
|
+
except Exception as e:
|
|
433
|
+
logger.error("Failed to delete report", report_id=report_id, error=str(e))
|
|
434
|
+
await db.rollback()
|
|
435
|
+
raise HTTPException(
|
|
436
|
+
status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
437
|
+
detail=f"Failed to delete report: {str(e)}"
|
|
438
|
+
) from None
|