-
Notifications
You must be signed in to change notification settings - Fork 35
β‘ Bolt: Optimize Image Pipeline and Upvote Operation #354
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -44,10 +44,16 @@ async def _make_request(client, url, payload): | |
| logger.error(f"HF API Request Exception: {e}") | ||
| return [] | ||
|
|
||
| def _prepare_image_bytes(image: Union[Image.Image, bytes]) -> bytes: | ||
| def _prepare_image_bytes(image: Union[Image.Image, bytes, io.BytesIO]) -> bytes: | ||
| """Helper to ensure image is in bytes format for HF API.""" | ||
| if isinstance(image, bytes): | ||
| return image | ||
| if isinstance(image, io.BytesIO): | ||
| return image.getvalue() | ||
|
|
||
| # It's a PIL Image | ||
| img_byte_arr = io.BytesIO() | ||
| # Use JPEG as default if format is missing (e.g. for newly created images) | ||
| fmt = image.format if image.format else 'JPEG' | ||
| image.save(img_byte_arr, format=fmt) | ||
| return img_byte_arr.getvalue() | ||
|
Comment on lines
+47
to
59
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor edge case: saving RGBA image as JPEG will raise an error. When π‘οΈ Suggested defensive fix # It's a PIL Image
img_byte_arr = io.BytesIO()
# Use JPEG as default if format is missing (e.g. for newly created images)
- fmt = image.format if image.format else 'JPEG'
+ fmt = image.format if image.format else ('PNG' if image.mode == 'RGBA' else 'JPEG')
image.save(img_byte_arr, format=fmt)
return img_byte_arr.getvalue()π€ Prompt for AI Agents |
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,7 +16,7 @@ | |
| IssueCreateWithDeduplicationResponse, IssueCategory, NearbyIssueResponse, | ||
| DeduplicationCheckResponse, IssueSummaryResponse, VoteResponse, | ||
| IssueStatusUpdateRequest, IssueStatusUpdateResponse, PushSubscriptionRequest, | ||
| PushSubscriptionResponse | ||
| PushSubscriptionResponse, BlockchainVerifyResponse | ||
| ) | ||
| from backend.utils import ( | ||
| check_upload_limits, validate_uploaded_file, save_file_blocking, save_issue_db, | ||
|
|
@@ -70,10 +70,10 @@ async def create_issue( | |
| image_path = os.path.join(upload_dir, filename) | ||
|
|
||
| # Process image (validate, resize, strip EXIF) | ||
| processed_image = await process_uploaded_image(image) | ||
| _, processed_bytes = await process_uploaded_image(image) | ||
|
|
||
| # Save processed image to disk | ||
| await run_in_threadpool(save_processed_image, processed_image, image_path) | ||
| await run_in_threadpool(save_processed_image, processed_bytes, image_path) | ||
| except HTTPException: | ||
| # Re-raise HTTP exceptions (from validation) | ||
| raise | ||
|
|
@@ -247,23 +247,29 @@ async def create_issue( | |
|
|
||
| @router.post("/api/issues/{issue_id}/vote", response_model=VoteResponse) | ||
| def upvote_issue(issue_id: int, db: Session = Depends(get_db)): | ||
| issue = db.query(Issue).filter(Issue.id == issue_id).first() | ||
| if not issue: | ||
| raise HTTPException(status_code=404, detail="Issue not found") | ||
| """ | ||
| Optimized: Atomic upvote without loading full model instance. | ||
| Directly updates the database and returns only required fields. | ||
| """ | ||
| # Check existence and increment atomically in one query if possible | ||
| # but update() in SQLAlchemy doesn't easily return the new value in all dialects (SQLite) | ||
| # So we do: 1. Update, 2. Fetch only needed columns | ||
|
|
||
| # Increment upvotes atomically | ||
| if issue.upvotes is None: | ||
| issue.upvotes = 0 | ||
| update_count = db.query(Issue).filter(Issue.id == issue_id).update({ | ||
| Issue.upvotes: func.coalesce(Issue.upvotes, 0) + 1 | ||
| }, synchronize_session=False) | ||
|
|
||
| # Use SQLAlchemy expression for atomic update | ||
| issue.upvotes = Issue.upvotes + 1 | ||
| if not update_count: | ||
| raise HTTPException(status_code=404, detail="Issue not found") | ||
|
|
||
| db.commit() | ||
| db.refresh(issue) | ||
|
|
||
| # Fetch only needed data for response | ||
| row = db.query(Issue.id, Issue.upvotes).filter(Issue.id == issue_id).first() | ||
|
|
||
| return VoteResponse( | ||
| id=issue.id, | ||
| upvotes=issue.upvotes, | ||
| id=row.id, | ||
| upvotes=row.upvotes or 0, | ||
| message="Issue upvoted successfully" | ||
| ) | ||
|
|
||
|
|
@@ -340,16 +346,8 @@ async def verify_issue_endpoint( | |
| raise HTTPException(status_code=404, detail="Issue not found") | ||
|
|
||
| if image: | ||
| # AI Verification Logic | ||
| # Validate uploaded file | ||
| await validate_uploaded_file(image) | ||
| # We can ignore the returned PIL image here as we need bytes for the external API | ||
|
|
||
| try: | ||
| image_bytes = await image.read() | ||
| except Exception as e: | ||
| logger.error(f"Invalid image file: {e}", exc_info=True) | ||
| raise HTTPException(status_code=400, detail="Invalid image file") | ||
| # AI Verification Logic (Optimized Pipeline) | ||
| _, image_bytes = await process_uploaded_image(image) | ||
|
|
||
| # Construct question | ||
| category = issue.category.lower() if issue.category else "issue" | ||
|
|
@@ -613,3 +611,34 @@ def get_recent_issues( | |
| # Thread-safe cache update | ||
| recent_issues_cache.set(data, cache_key) | ||
| return data | ||
|
|
||
| @router.get("/api/issues/{issue_id}/blockchain-verify", response_model=BlockchainVerifyResponse) | ||
| async def verify_issue_blockchain(issue_id: int, db: Session = Depends(get_db)): | ||
| """ | ||
| Blockchain Verification: Verifies the integrity seal of a report. | ||
| Checks if the hash of the current issue matches its content and the previous hash. | ||
| """ | ||
| # Fetch current issue and its predecessor's hash | ||
| issue = await run_in_threadpool(lambda: db.query(Issue).filter(Issue.id == issue_id).first()) | ||
| if not issue: | ||
| raise HTTPException(status_code=404, detail="Issue not found") | ||
|
|
||
| # Get predecessor hash | ||
| prev_issue = await run_in_threadpool( | ||
| lambda: db.query(Issue.integrity_hash).filter(Issue.id < issue_id).order_by(Issue.id.desc()).first() | ||
| ) | ||
| prev_hash = prev_issue[0] if prev_issue and prev_issue[0] else "" | ||
|
|
||
| # Recalculate hash | ||
| hash_content = f"{issue.description}|{issue.category}|{prev_hash}" | ||
| calculated_hash = hashlib.sha256(hash_content.encode()).hexdigest() | ||
|
|
||
| is_valid = (calculated_hash == issue.integrity_hash) | ||
|
Comment on lines
+626
to
+636
|
||
|
|
||
| return BlockchainVerifyResponse( | ||
| issue_id=issue.id, | ||
| is_valid=is_valid, | ||
| integrity_hash=issue.integrity_hash or "", | ||
| calculated_hash=calculated_hash, | ||
| previous_hash=prev_hash | ||
| ) | ||
|
Comment on lines
+615
to
+644
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -138,10 +138,10 @@ async def validate_uploaded_file(file: UploadFile) -> Optional[Image.Image]: | |||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||
| return await run_in_threadpool(_validate_uploaded_file_sync, file) | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| def process_uploaded_image_sync(file: UploadFile) -> io.BytesIO: | ||||||||||||||||||||||||||||||||||||||||||
| def process_uploaded_image_sync(file: UploadFile): | ||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||
| Synchronously validate, resize, and strip EXIF from uploaded image. | ||||||||||||||||||||||||||||||||||||||||||
| Returns the processed image data as BytesIO. | ||||||||||||||||||||||||||||||||||||||||||
| Returns a tuple of (PIL.Image.Image, bytes). | ||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||
| # Check file size | ||||||||||||||||||||||||||||||||||||||||||
| file.file.seek(0, 2) | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -180,14 +180,14 @@ def process_uploaded_image_sync(file: UploadFile) -> io.BytesIO: | |||||||||||||||||||||||||||||||||||||||||
| img_no_exif = Image.new(img.mode, img.size) | ||||||||||||||||||||||||||||||||||||||||||
| img_no_exif.paste(img) | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| # Save to BytesIO | ||||||||||||||||||||||||||||||||||||||||||
| # Save to bytes | ||||||||||||||||||||||||||||||||||||||||||
| output = io.BytesIO() | ||||||||||||||||||||||||||||||||||||||||||
| # Preserve format or default to JPEG | ||||||||||||||||||||||||||||||||||||||||||
| fmt = img.format or 'JPEG' | ||||||||||||||||||||||||||||||||||||||||||
| img_no_exif.save(output, format=fmt, quality=85) | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
186
to
187
|
||||||||||||||||||||||||||||||||||||||||||
| fmt = img.format or 'JPEG' | |
| img_no_exif.save(output, format=fmt, quality=85) | |
| fmt = (img.format or 'JPEG').upper() | |
| save_kwargs = {} | |
| if fmt in ('JPEG', 'JPG', 'WEBP'): | |
| # Use quality setting for lossy formats | |
| save_kwargs['quality'] = 85 | |
| elif fmt == 'PNG': | |
| # Use appropriate options for PNG (lossless) | |
| save_kwargs['optimize'] = True | |
| save_kwargs['compress_level'] = 6 | |
| img_no_exif.save(output, format=fmt, **save_kwargs) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: Saving RGBA images as JPEG will raise OSError after resize.
After img.resize(...) (line 177), the returned Image object has format=None. The fallback on line 186 defaults to 'JPEG', but JPEG doesn't support the RGBA mode (e.g., from PNG images with transparency). This will raise OSError: cannot write mode RGBA as JPEG for any RGBA image larger than 1024px.
The same pattern exists in _validate_uploaded_file_sync (line 104), but since process_uploaded_image_sync is now the primary pipeline, this is the more impactful location.
π Proposed fix
# Save to bytes
output = io.BytesIO()
# Preserve format or default to JPEG
- fmt = img.format or 'JPEG'
+ fmt = img.format or ('PNG' if img_no_exif.mode == 'RGBA' else 'JPEG')
+ if fmt == 'JPEG' and img_no_exif.mode == 'RGBA':
+ img_no_exif = img_no_exif.convert('RGB')
img_no_exif.save(output, format=fmt, quality=85)
image_bytes = output.getvalue()π Committable suggestion
βΌοΈ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| # Save to bytes | |
| output = io.BytesIO() | |
| # Preserve format or default to JPEG | |
| fmt = img.format or 'JPEG' | |
| img_no_exif.save(output, format=fmt, quality=85) | |
| output.seek(0) | |
| image_bytes = output.getvalue() | |
| return output | |
| return img_no_exif, image_bytes | |
| # Save to bytes | |
| output = io.BytesIO() | |
| # Preserve format or default to JPEG | |
| fmt = img.format or ('PNG' if img_no_exif.mode == 'RGBA' else 'JPEG') | |
| if fmt == 'JPEG' and img_no_exif.mode == 'RGBA': | |
| img_no_exif = img_no_exif.convert('RGB') | |
| img_no_exif.save(output, format=fmt, quality=85) | |
| image_bytes = output.getvalue() | |
| return img_no_exif, image_bytes |
π§° Tools
πͺ Ruff (0.14.14)
[warning] 190-190: Consider moving this statement to an else block
(TRY300)
π€ Prompt for AI Agents
In `@backend/utils.py` around lines 183 - 190, The save path fails for RGBA images
because fmt = img.format or 'JPEG' will try to write RGBA as JPEG; update the
save logic in process_uploaded_image (and mirror in
_validate_uploaded_file_sync) to handle alpha modes: either choose 'PNG' when
img_no_exif.mode contains an alpha channel (e.g., 'RGBA' or 'LA') or convert
img_no_exif = img_no_exif.convert('RGB') before saving if you must keep JPEG;
ensure the fmt selection and/or conversion happens just before
img_no_exif.save(output, format=fmt, quality=85) so image_bytes is generated
without raising OSError.
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
|
|
@@ -36,7 +36,8 @@ def test_create_issue(): | |||
| patch("backend.tasks.generate_action_plan", new_callable=AsyncMock) as mock_plan: | ||||
|
|
||||
| import io | ||||
|
||||
| import io |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_prepare_image_bytesdefaults to JPEG whenimage.formatis missing. This can break for non-JPEG-compatible modes (e.g., RGBA images canβt be saved as JPEG). Consider choosing a default format based onimage.mode(e.g., PNG for RGBA/P) or converting to RGB when defaulting to JPEG.