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
3 changes: 3 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,6 @@
## 2025-05-30 - O(1) Blockchain Hash Lookup
**Learning:** Calculating a blockchain-style hash chain requires finding the previous record's hash. A naive DB scan is O(log N) with indexes, but high-concurrency follow operations can be optimized.
**Action:** Implement an in-memory thread-safe cache for the last hash of each grievance to achieve O(1) lookup during follower creation, falling back to DB on cache miss.
## 2026-07-14 - N+1 Query in Escalation Evaluation
**Learning:** The periodic escalation check loop in `EscalationEngine` was triggering an N+1 query bottleneck by accessing `grievance.jurisdiction` for each record. In FastAPI/SQLAlchemy, this can significantly slow down background tasks as the number of active grievances grows.
**Action:** Use `joinedload(Grievance.jurisdiction)` in the initial query to fetch all required data in a single efficient SQL JOIN.
62 changes: 55 additions & 7 deletions backend/escalation_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
"""

import datetime
import hashlib
import threading
from typing import List, Dict, Any, Optional
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import and_, or_, desc
from backend.models import Grievance, Jurisdiction, EscalationAudit, GrievanceStatus, JurisdictionLevel, EscalationReason, SeverityLevel
from backend.database import SessionLocal
from backend.routing_service import RoutingService
Expand All @@ -17,6 +19,11 @@ class EscalationEngine:
Engine for handling grievance escalations based on SLA breaches and severity changes.
"""

# Cache for O(1) blockchain integrity hash lookups
# Stores {grievance_id: last_audit_integrity_hash}
_audit_last_hash_cache = {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: The class-level cache retains one hash for every escalated grievance forever, so memory grows with the lifetime total rather than the active working set. Use a bounded or TTL cache, or provide explicit invalidation.

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At backend/escalation_engine.py, line 24:

<comment>The class-level cache retains one hash for every escalated grievance forever, so memory grows with the lifetime total rather than the active working set. Use a bounded or TTL cache, or provide explicit invalidation.</comment>

<file context>
@@ -17,6 +19,11 @@ class EscalationEngine:
 
+    # Cache for O(1) blockchain integrity hash lookups
+    # Stores {grievance_id: last_audit_integrity_hash}
+    _audit_last_hash_cache = {}
+    _cache_lock = threading.Lock()
+
</file context>

_cache_lock = threading.Lock()

def __init__(self, routing_service: RoutingService, sla_service: SLAConfigService,
rules_config: Dict[str, Any]):
"""
Expand Down Expand Up @@ -111,13 +118,13 @@ def escalate_grievance_severity(self, grievance_id: int, new_severity: SeverityL
if db is not SessionLocal():
db.close()

def manual_escalate(self, grievance_id: int, reason: str = "", db: Session = None) -> bool:
def manual_escalate(self, grievance_id: int, notes: str = "", db: Session = None) -> bool:
"""
Manually escalate a grievance.

Args:
grievance_id: Grievance ID
reason: Reason for manual escalation
notes: Notes explaining the manual escalation
db: Database session

Returns:
Expand All @@ -131,7 +138,7 @@ def manual_escalate(self, grievance_id: int, reason: str = "", db: Session = Non
if not grievance:
return False

return self._escalate_grievance(grievance, EscalationReason.MANUAL, db, reason)
return self._escalate_grievance(grievance, EscalationReason.MANUAL, db, notes)

finally:
if db is not SessionLocal():
Expand All @@ -140,6 +147,7 @@ def manual_escalate(self, grievance_id: int, reason: str = "", db: Session = Non
def _get_grievances_for_evaluation(self, db: Session) -> List[Grievance]:
"""
Get grievances that should be evaluated for escalation.
Bolt Optimization: Uses joinedload for Jurisdiction to avoid N+1 query problem.

Args:
db: Database session
Expand All @@ -150,7 +158,9 @@ def _get_grievances_for_evaluation(self, db: Session) -> List[Grievance]:
now = datetime.datetime.now(datetime.timezone.utc)

# Get grievances that are active and past SLA deadline
return db.query(Grievance).filter(
return db.query(Grievance).options(
joinedload(Grievance.jurisdiction)
).filter(
and_(
Grievance.status.in_([GrievanceStatus.OPEN, GrievanceStatus.IN_PROGRESS, GrievanceStatus.ESCALATED]),
Grievance.sla_deadline < now
Expand Down Expand Up @@ -248,25 +258,63 @@ def _escalate_grievance(self, grievance: Grievance, reason: EscalationReason,
# Recalculate SLA
self._recalculate_sla(grievance, db)

# Blockchain-style integrity hashing
# Get previous hash (O(1) from cache or O(log N) from indexed DB)
prev_hash = self._get_last_audit_hash(grievance.id, db)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: Concurrent escalations for one grievance can create sibling audit records with the same previous_integrity_hash, breaking the single ordered audit chain. Serialize hash selection through audit insert/commit with database-level coordination, then update the cache after the transaction succeeds.

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At backend/escalation_engine.py, line 263:

<comment>Concurrent escalations for one grievance can create sibling audit records with the same `previous_integrity_hash`, breaking the single ordered audit chain. Serialize hash selection through audit insert/commit with database-level coordination, then update the cache after the transaction succeeds.</comment>

<file context>
@@ -248,25 +258,63 @@ def _escalate_grievance(self, grievance: Grievance, reason: EscalationReason,
 
+            # Blockchain-style integrity hashing
+            # Get previous hash (O(1) from cache or O(log N) from indexed DB)
+            prev_hash = self._get_last_audit_hash(grievance.id, db)
+
+            # Calculate new integrity hash: SHA256(grievance_id | reason | prev_hash)
</file context>


# Calculate new integrity hash: SHA256(grievance_id | reason | prev_hash)
hash_input = f"{grievance.id}|{reason.value}|{prev_hash or 'GENESIS'}"
new_hash = hashlib.sha256(hash_input.encode()).hexdigest()

# Create audit log
audit_log = EscalationAudit(
grievance_id=grievance.id,
previous_authority=previous_authority,
new_authority=grievance.assigned_authority,
reason=reason,
notes=notes
notes=notes,
integrity_hash=new_hash,
previous_integrity_hash=prev_hash
)

db.add(audit_log)
db.commit()

# Update O(1) cache for next escalation
with self._cache_lock:
self._audit_last_hash_cache[grievance.id] = new_hash

return True

except Exception as e:
db.rollback()
print(f"Error during escalation: {e}")
return False

def _get_last_audit_hash(self, grievance_id: int, db: Session) -> Optional[str]:
"""
Retrieves the last audit integrity hash for a grievance.
Bolt Optimization: Uses thread-safe memory cache for O(1) lookup.
"""
with self._cache_lock:
if grievance_id in self._audit_last_hash_cache:
return self._audit_last_hash_cache[grievance_id]

# Cache miss: Fallback to indexed DB query
last_audit = db.query(EscalationAudit)\
.filter(EscalationAudit.grievance_id == grievance_id)\

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Cold-cache lookups can scan escalation_audits because EscalationAudit.grievance_id is not indexed, so the fallback does not scale as claimed. Add a composite (grievance_id, id) index for this filter-and-order query.

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At backend/escalation_engine.py, line 305:

<comment>Cold-cache lookups can scan `escalation_audits` because `EscalationAudit.grievance_id` is not indexed, so the fallback does not scale as claimed. Add a composite `(grievance_id, id)` index for this filter-and-order query.</comment>

<file context>
@@ -248,25 +258,63 @@ def _escalate_grievance(self, grievance: Grievance, reason: EscalationReason,
+
+        # Cache miss: Fallback to indexed DB query
+        last_audit = db.query(EscalationAudit)\
+            .filter(EscalationAudit.grievance_id == grievance_id)\
+            .order_by(desc(EscalationAudit.id))\
+            .first()
</file context>

.order_by(desc(EscalationAudit.id))\
.first()

last_hash = last_audit.integrity_hash if last_audit else None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: A grievance whose newest audit has a NULL integrity_hash starts a new GENESIS chain even though an audit already exists, leaving the new record disconnected from its history. Backfill or explicitly handle legacy unhashed rows before creating a chained audit.

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At backend/escalation_engine.py, line 309:

<comment>A grievance whose newest audit has a NULL `integrity_hash` starts a new GENESIS chain even though an audit already exists, leaving the new record disconnected from its history. Backfill or explicitly handle legacy unhashed rows before creating a chained audit.</comment>

<file context>
@@ -248,25 +258,63 @@ def _escalate_grievance(self, grievance: Grievance, reason: EscalationReason,
+            .order_by(desc(EscalationAudit.id))\
+            .first()
+
+        last_hash = last_audit.integrity_hash if last_audit else None
+
+        # Update cache for next time
</file context>


# Update cache for next time
if last_hash:
with self._cache_lock:
self._audit_last_hash_cache[grievance_id] = last_hash

return last_hash

def _recalculate_sla(self, grievance: Grievance, db: Session) -> None:
"""
Recalculate SLA deadline for a grievance.
Expand Down
6 changes: 3 additions & 3 deletions backend/grievance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,18 +319,18 @@ def escalate_grievance_severity(self, grievance_id: int, new_severity: SeverityL
"""
return self.escalation_engine.escalate_grievance_severity(grievance_id, new_severity, reason)

def manual_escalate(self, grievance_id: int, reason: str = "") -> bool:
def manual_escalate(self, grievance_id: int, notes: str = "") -> bool:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Callers using the documented service.manual_escalate(..., reason=...) form now fail with TypeError: unexpected keyword argument 'reason' before escalation runs, because this public service parameter was renamed without a compatibility alias. Preserving reason or accepting both names would keep existing callers functional.

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At backend/grievance_service.py, line 322:

<comment>Callers using the documented `service.manual_escalate(..., reason=...)` form now fail with `TypeError: unexpected keyword argument 'reason'` before escalation runs, because this public service parameter was renamed without a compatibility alias. Preserving `reason` or accepting both names would keep existing callers functional.</comment>

<file context>
@@ -319,18 +319,18 @@ def escalate_grievance_severity(self, grievance_id: int, new_severity: SeverityL
         return self.escalation_engine.escalate_grievance_severity(grievance_id, new_severity, reason)
 
-    def manual_escalate(self, grievance_id: int, reason: str = "") -> bool:
+    def manual_escalate(self, grievance_id: int, notes: str = "") -> bool:
         """
         Manually escalate a grievance.
</file context>

"""
Manually escalate a grievance.

Args:
grievance_id: Grievance ID
reason: Reason for escalation
notes: Notes for escalation

Returns:
True if escalation successful
"""
return self.escalation_engine.manual_escalate(grievance_id, reason)
return self.escalation_engine.manual_escalate(grievance_id, notes)

def run_escalation_check(self) -> Dict[str, int]:
"""
Expand Down
10 changes: 10 additions & 0 deletions backend/hf_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,16 @@ async def _make_request(client, image_bytes, labels):
raise ExternalAPIException("Hugging Face API", str(e)) from e

def _prepare_image_bytes(image: Union[Image.Image, bytes]) -> bytes:
if isinstance(image, bytes):
return image

img_byte_arr = io.BytesIO()
# Handle different image formats
fmt = getattr(image, 'format', 'JPEG') or 'JPEG'
image.save(img_byte_arr, format=fmt)
Comment on lines +96 to +97

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: RGBA and other non-RGB images without a source format are serialized as JPEG, which Pillow rejects and turns into an empty detection result. Converting non-RGB images before JPEG encoding, or choosing a format compatible with the image mode, preserves detection for these inputs.

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At backend/hf_service.py, line 96:

<comment>RGBA and other non-RGB images without a source format are serialized as JPEG, which Pillow rejects and turns into an empty detection result. Converting non-RGB images before JPEG encoding, or choosing a format compatible with the image mode, preserves detection for these inputs.</comment>

<file context>
@@ -88,6 +88,16 @@ async def _make_request(client, image_bytes, labels):
+
+    img_byte_arr = io.BytesIO()
+    # Handle different image formats
+    fmt = getattr(image, 'format', 'JPEG') or 'JPEG'
+    image.save(img_byte_arr, format=fmt)
+    return img_byte_arr.getvalue()
</file context>
Suggested change
fmt = getattr(image, 'format', 'JPEG') or 'JPEG'
image.save(img_byte_arr, format=fmt)
fmt = getattr(image, 'format', 'JPEG') or 'JPEG'
if fmt == 'JPEG' and image.mode != 'RGB':
image = image.convert('RGB')
image.save(img_byte_arr, format=fmt)

return img_byte_arr.getvalue()

async def detect_vandalism_clip(image: Union[Image.Image, bytes], client: httpx.AsyncClient = None) -> List[Dict[str, Any]]:
"""
Detects vandalism/graffiti using Zero-Shot Image Classification with CLIP (Async).
Includes retry logic with exponential backoff for transient failures.
Expand Down
Loading
Loading