Files
encoach_backend_new_v2/custom_addons/encoach_api/utils/cache.py
Yamen Ahmad 3972023a30 feat(backend): Phase 2/3 hardening release
Roadmap P0 — platform safety & ops
- Merge duplicate encoach.student.attempt/answer models into encoach_scoring
  and drop the stale encoach_exam_template copies.
- Remove duplicate /api/exam/* routes; canonicalize on one controller tree.
- Gate raw-SQL seeds in seed_demo_data.py behind an explicit env flag.
- Add /api/health and /api/health/ready (DB + LLM reachability) endpoints.
- Fix docker-compose + ship odoo-docker.conf for container-local runs.
- Enforce OpenAI request_timeout=30s and @jwt_required on all AI/coach routes.
- Promote canonical cefr_mapper to encoach_ai.services.cefr_mapper.
- JWT cache TTL=30s + invalidation hook on user mutation.

Roadmap P1 — exam correctness & data provenance
- Wire QualityChecker + IeltsValidator into exam submit with a
  pending_review gate (encoach_ai.services.question_validator).
- Populate RAG metadata (course_id, subject_id, entity_id, taxonomy) on
  encoach_vector embeddings and add a chunking pipeline (>2000 chars).
- Add provenance fields on encoach.question (model, prompt_hash, log_id)
  and validate LLM output with schema before DB insert.
- Unify response envelope to {items,total,page,size}.
- Approval reject rollback with savepoint atomicity.
- Ticket notifications on status/assignee change.

Roadmap P2 — performance & observability
- Reports: replace Python loops with SQL read_group aggregations.
- X-Request-ID middleware + structured JSON logs.
- In-process/Prometheus counters and openapi.py controller exporting a
  spec by scanning @http.route decorators.
- Paymob real checkout + HMAC-SHA512 webhook verification, backed by a
  new encoach.paymob.order model and ir.config_parameter credentials.
- JWT refresh tokens + revocation table.
- Composite DB indexes on hot report/ticket/attempt paths.

Roadmap P3 — human-in-the-loop & compliance
- Human-in-the-loop exam review workflow (pending_review → publish) with
  new review controller and status transitions.
- encoach.ai.prompt model + versioning + admin editor endpoints (one
  active version per key, render-preview dry run).
- Student feedback loop → encoach.ai.feedback (upsert per user/subject,
  admin triage + resolve endpoints).
- GDPR export (/api/gdpr/export) and right-to-erasure (/api/gdpr/delete)
  with anonymization, tombstone record, and admin-self-erasure guard.
- HttpCase smoke tests for /api/health and /api/health/ready.

Made-with: Cursor
2026-04-19 14:16:09 +04:00

107 lines
3.4 KiB
Python

"""Zero-dependency in-process TTL cache for hot endpoints.
Designed for read-heavy, mostly-idempotent endpoints like the admin report
pages (``/api/reports/stats-corporate``, ``/api/reports/student-performance``)
and the AI narrative generator. A single LRU-ish dict per worker with a hard
time bound is usually enough to absorb the dashboard refresh storm caused by
an admin tabbing between pages.
**Not** a substitute for Redis — cross-worker consistency and bounded memory
are out of scope. If ops ever needs those guarantees, swap the internal dict
for a ``redis.Redis.setex`` call; the public API (``memoize_ttl`` and
``invalidate``) stays identical.
"""
from __future__ import annotations
import hashlib
import json
import logging
import threading
import time
from functools import wraps
from typing import Any, Callable
_logger = logging.getLogger(__name__)
_cache: dict[str, tuple[float, Any]] = {}
_lock = threading.Lock()
_MAX_ENTRIES = 512
def _make_key(namespace: str, args: tuple, kwargs: dict) -> str:
payload = json.dumps([args, kwargs], sort_keys=True, default=str)
digest = hashlib.md5(payload.encode("utf-8")).hexdigest()
return f"{namespace}:{digest}"
def get(key: str):
entry = _cache.get(key)
if not entry:
return None
expiry, value = entry
if expiry < time.time():
with _lock:
_cache.pop(key, None)
return None
return value
def put(key: str, value, ttl_seconds: int) -> None:
with _lock:
if len(_cache) >= _MAX_ENTRIES:
oldest = sorted(_cache.items(), key=lambda kv: kv[1][0])[: _MAX_ENTRIES // 4]
for k, _v in oldest:
_cache.pop(k, None)
_cache[key] = (time.time() + max(1, ttl_seconds), value)
def invalidate(namespace: str | None = None) -> int:
"""Drop every cached entry, or just entries under ``namespace``.
Returns the number of removed entries. Callers should invoke this from
write endpoints that affect the cached read so subsequent reads observe
the new state.
"""
removed = 0
with _lock:
if namespace is None:
removed = len(_cache)
_cache.clear()
return removed
prefix = f"{namespace}:"
keys = [k for k in _cache if k.startswith(prefix)]
for k in keys:
_cache.pop(k, None)
removed = len(keys)
return removed
def memoize_ttl(namespace: str, ttl_seconds: int = 30):
"""Decorator: cache ``func(*args, **kwargs)`` for ``ttl_seconds`` per key.
The key is derived from the namespace + a stable JSON dump of the args,
so callers don't need to worry about mutable keyword order or unhashable
defaults. JWT decorator should run *before* this one so unauthenticated
traffic never hits the cache.
"""
def decorator(func: Callable):
@wraps(func)
def wrapper(*args, **kwargs):
try:
key = _make_key(namespace, args, kwargs)
except Exception:
return func(*args, **kwargs)
cached = get(key)
if cached is not None:
return cached
value = func(*args, **kwargs)
try:
put(key, value, ttl_seconds)
except Exception:
_logger.debug("cache put failed for %s", namespace, exc_info=True)
return value
wrapper._encoach_cache_namespace = namespace
return wrapper
return decorator