Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion backend/hf_api_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Comment on lines +56 to 57
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_prepare_image_bytes defaults to JPEG when image.format is 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 on image.mode (e.g., PNG for RGBA/P) or converting to RGB when defaulting to JPEG.

Suggested change
# Use JPEG as default if format is missing (e.g. for newly created images)
fmt = image.format if image.format else 'JPEG'
# Determine format, taking image.mode into account when format is missing.
fmt = image.format
if not fmt:
# For images with alpha or palette, default to PNG to avoid JPEG incompatibilities.
if image.mode in ("RGBA", "LA", "P"):
fmt = "PNG"
else:
# Preserve existing behavior for typical modes (e.g., RGB, L) by defaulting to JPEG.
fmt = "JPEG"
# If saving as JPEG, ensure the image is in a JPEG-compatible mode.
if fmt.upper() in ("JPEG", "JPG") and image.mode not in ("RGB", "L"):
image = image.convert("RGB")

Copilot uses AI. Check for mistakes.
image.save(img_byte_arr, format=fmt)
return img_byte_arr.getvalue()
Comment on lines +47 to 59
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor

Minor edge case: saving RGBA image as JPEG will raise an error.

When image.format is None and image.mode is 'RGBA' (e.g., from a PNG with transparency), image.save(..., format='JPEG') will raise an exception since JPEG doesn't support alpha. With the new pipeline this path is rarely hit (callers now pass bytes directly), but it could still be triggered by internal callers passing a PIL Image without a format set.

πŸ›‘οΈ 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
In `@backend/hf_api_service.py` around lines 47 - 59, In _prepare_image_bytes,
handle images with alpha channels to avoid raising when saving as JPEG: detect
when image.format is None and image.mode contains an alpha channel (e.g.,
'RGBA', 'LA' or 'P' with transparency) and either choose 'PNG' as the format or
convert the image to 'RGB' before saving as 'JPEG'; update the logic around fmt
= image.format if image.format else 'JPEG' and the image.save call so that
images with transparency are saved as PNG (or are converted to RGB when you
explicitly want JPEG), ensuring you call image.convert('RGB') when converting
and then save to img_byte_arr.getvalue() as before.

Expand Down
14 changes: 4 additions & 10 deletions backend/routers/detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -406,11 +406,8 @@ async def detect_graffiti_endpoint(image: UploadFile = File(...)):

@router.post("/api/detect-traffic-sign")
async def detect_traffic_sign_endpoint(request: Request, image: UploadFile = File(...)):
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")
# Optimized Image Processing: Validation + Optimization
_, image_bytes = await process_uploaded_image(image)

try:
client = get_http_client(request)
Expand All @@ -423,11 +420,8 @@ async def detect_traffic_sign_endpoint(request: Request, image: UploadFile = Fil

@router.post("/api/detect-abandoned-vehicle")
async def detect_abandoned_vehicle_endpoint(request: Request, image: UploadFile = File(...)):
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")
# Optimized Image Processing: Validation + Optimization
_, image_bytes = await process_uploaded_image(image)

try:
client = get_http_client(request)
Expand Down
77 changes: 53 additions & 24 deletions backend/routers/issues.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
)

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The blockchain verification logic assumes the β€œprevious hash” is from the issue with the greatest id less than issue_id, but the hash creation during issue creation uses β€œlatest issue at creation time”. Under concurrent issue creation, multiple issues can compute the same prev_hash, and later verification for one branch will fail. To make verification stable, persist the exact previous hash/previous issue id used at creation time (or compute within a serialized transaction/lock) and verify against that stored predecessor.

Copilot uses AI. Check for mistakes.

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
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New public endpoint /api/issues/{issue_id}/blockchain-verify is added without any corresponding test coverage. Please add at least a basic test that creates an issue with an integrity hash and asserts the endpoint returns is_valid=True (and a negative case where the issue content or predecessor hash is changed).

Copilot uses AI. Check for mistakes.
Comment on lines +614 to +644
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Blockchain chain verification can break if issues are deleted or created concurrently.

The predecessor lookup (Issue.id < issue_id, ordered desc, line 628) assumes the issue with the next-lower ID was the predecessor at creation time. This holds only if:

  1. No issues are ever deleted.
  2. Issues are created strictly sequentially (no concurrent inserts).

If either assumption is violated, the recalculated hash won't match, producing a false negative (is_valid=False). This is a design limitation of the simple ID-based chaining. Consider storing previous_issue_id explicitly on the Issue model to make the chain traversal deterministic.

Additionally, the endpoint fetches the full Issue model (line 622) but only needs description, category, and integrity_hash. Column projection would be consistent with the optimization theme of this PR.

♻️ Optional: use column projection
-    issue = await run_in_threadpool(lambda: db.query(Issue).filter(Issue.id == issue_id).first())
+    issue = await run_in_threadpool(
+        lambda: db.query(Issue.id, Issue.description, Issue.category, Issue.integrity_hash)
+        .filter(Issue.id == issue_id).first()
+    )
🧰 Tools
πŸͺ› Ruff (0.14.14)

[warning] 616-616: Do not perform function call Depends in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)

πŸ€– Prompt for AI Agents
In `@backend/routers/issues.py` around lines 614 - 644, The current
verify_issue_blockchain endpoint relies on querying the "previous" issue by ID
(Issue.id < issue_id ordered desc) which breaks if issues are deleted or created
concurrently; change the design to use an explicit previous pointer and optimize
the query: add a previous_issue_id column/field to the Issue model (and populate
it at creation time), update verify_issue_blockchain to load only the required
columns (description, category, integrity_hash, previous_issue_id) using a
projection via db.query(...) and then fetch the predecessor by previous_issue_id
(db.query(Issue.description, Issue.category,
Issue.integrity_hash).filter(Issue.id == issue.previous_issue_id).first())
instead of relying on ID ordering; keep the same hash recomputation logic
(hash_content = f"{description}|{category}|{previous_hash}") and compare to
issue.integrity_hash, and ensure you're still using run_in_threadpool wrappers
for the DB calls.

7 changes: 7 additions & 0 deletions backend/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,3 +266,10 @@ class ClosureStatusResponse(BaseModel):
required_confirmations: int = Field(..., description="Number of confirmations needed")
confirmation_deadline: Optional[datetime] = Field(None, description="Deadline for confirmations")
days_remaining: Optional[int] = Field(None, description="Days until deadline")

class BlockchainVerifyResponse(BaseModel):
issue_id: int = Field(..., description="Issue ID")
is_valid: bool = Field(..., description="Whether the integrity seal is valid")
integrity_hash: str = Field(..., description="Current integrity hash")
calculated_hash: str = Field(..., description="Calculated hash for verification")
previous_hash: str = Field(..., description="Previous hash in the chain")
32 changes: 16 additions & 16 deletions backend/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

process_uploaded_image_sync always passes quality=85 to PIL.Image.save() while preserving the original format. For formats like PNG/GIF/TIFF this parameter is not supported and can raise at runtime, despite those MIME types being allowed. Consider only passing JPEG/WebP-specific kwargs when fmt is JPEG/WebP, and using appropriate options for PNG (e.g., optimize/compress_level) or omitting quality entirely for non-lossy formats.

Suggested change
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)

Copilot uses AI. Check for mistakes.
output.seek(0)
image_bytes = output.getvalue()

return output
return img_no_exif, image_bytes
Comment on lines +183 to +190
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
# 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.


except Exception as pil_error:
logger.error(f"PIL processing failed: {pil_error}")
Expand All @@ -202,28 +202,28 @@ def process_uploaded_image_sync(file: UploadFile) -> io.BytesIO:
logger.error(f"Error processing file: {e}")
raise HTTPException(status_code=400, detail="Unable to process file.")

async def process_uploaded_image(file: UploadFile) -> io.BytesIO:
async def process_uploaded_image(file: UploadFile):
"""
Asynchronously validate, resize, and strip EXIF from uploaded image.
Returns a tuple of (PIL.Image.Image, bytes).
"""
return await run_in_threadpool(process_uploaded_image_sync, file)

def save_processed_image(file_obj: io.BytesIO, path: str):
"""Save processed BytesIO to disk."""
def save_processed_image(image_bytes: bytes, path: str):
"""Save processed bytes to disk."""
with open(path, "wb") as buffer:
shutil.copyfileobj(file_obj, buffer)
buffer.write(image_bytes)

async def process_and_detect(image: UploadFile, detection_func) -> DetectionResponse:
"""
Helper to process uploaded image and run detection.
Uses the optimized image processing pipeline.
Uses the optimized single-pass image processing pipeline.
"""
# Validate uploaded file
pil_image = await validate_uploaded_file(image)
# Optimized: Use process_uploaded_image which resizes and strips EXIF in one go
pil_image, _ = await process_uploaded_image(image)

# Validate image for processing (check integrity)
try:
if pil_image is None:
pil_image = await run_in_threadpool(Image.open, image.file)

# Validate image for processing
await run_in_threadpool(validate_image_for_processing, pil_image)
except HTTPException:
raise # Re-raise HTTP exceptions from validation
Expand Down
3 changes: 2 additions & 1 deletion tests/test_issue_creation.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ def test_create_issue():
patch("backend.tasks.generate_action_plan", new_callable=AsyncMock) as mock_plan:

import io
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

import io is now unused after switching the mocked process_uploaded_image return value to (PIL.Image, bytes). Consider removing it to keep the test clean and avoid lint noise.

Suggested change
import io

Copilot uses AI. Check for mistakes.
mock_process.return_value = io.BytesIO(b"processed image bytes")
from PIL import Image
mock_process.return_value = (Image.new('RGB', (10, 10)), b"processed image bytes")

mock_plan.return_value = {
"whatsapp": "Test WhatsApp",
Expand Down
7 changes: 4 additions & 3 deletions tests/test_verification_feature.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,12 @@ def mock_refresh(instance):
app.dependency_overrides = {}

# Test AI Verification
@patch("backend.routers.issues.validate_uploaded_file", new_callable=AsyncMock)
@patch("backend.routers.issues.process_uploaded_image", new_callable=AsyncMock)
@patch("backend.routers.issues.verify_resolution_vqa", new_callable=AsyncMock)
def test_ai_verification_resolved(mock_vqa, mock_validate, client):
def test_ai_verification_resolved(mock_vqa, mock_process, client):
# Setup mocks
mock_validate.return_value = None
from PIL import Image
mock_process.return_value = (Image.new('RGB', (10, 10)), b"fakeimage")
mock_vqa.return_value = {
"answer": "no",
"confidence": 0.95
Expand Down
Loading