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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,25 @@ Both endpoints are public (no `X-Internal-Secret` required). A coverage test
(`backend/tests/test_openapi_coverage.py`) asserts every registered route is
documented, so the spec never drifts from the code.

### `/predict` response cache

`POST /predict` is fronted by an in-process, content-addressed response cache
(`backend/predict_cache.py`). The cache key is `sha256` of the *normalised*
input text (via `utils/text_normalizer`) combined with the prediction options
and the live model version, so:

* repeated identical requests skip the full inference pipeline and return the
cached body, and
* a `POST /reload-model` hot-swap bumps the serving version and transparently
invalidates every prior entry — a stale model can never serve a cached answer.

Each response carries an `X-Cache: HIT|MISS` header. Send `Cache-Control:
no-cache` or `?fresh=1` to bypass the lookup and force a fresh computation.
Aggregate counters (hits, misses, size, evictions, hit rate — never any cached
content) are exposed at the public `GET /cache-stats`. The cache is bounded by
TTL and LRU eviction and configured via `PREDICT_CACHE_ENABLED`,
`PREDICT_CACHE_MAX_SIZE` and `PREDICT_CACHE_TTL_SECONDS`.

---
## 🧾 Model Registry & Provenance

Expand Down
50 changes: 49 additions & 1 deletion backend/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@
"/openapi.json",
"/docs",
"/metrics",
"/cache-stats",
"/model-info",
}

Expand Down Expand Up @@ -1001,6 +1002,17 @@ def rate_limit_status():
)


@app.route("/cache-stats", methods=["GET"])
@validate_request
def cache_stats():
"""Observability for the /predict response cache (issue #1008).

Public (see PUBLIC_PATHS): exposes only aggregate counters (hits, misses,
size, evictions, hit rate) -- never any cached message content.
"""
return jsonify(predict_cache.CACHE.stats())


# ============================================
# API DOCUMENTATION (OpenAPI 3.0 + Swagger UI)
# ============================================
Expand Down Expand Up @@ -1119,6 +1131,20 @@ def make_prediction_response(
return response


def _cache_bypass_requested():
"""True when the caller opts out of the /predict cache for this request.

Honours the standard ``Cache-Control: no-cache`` request directive and a
convenience ``?fresh=1`` query flag, so a client can force a fresh
computation (e.g. to confirm a just-reloaded model) without disabling the
cache process-wide.
"""
if request.args.get("fresh") == "1":
return True
cache_control = request.headers.get("Cache-Control", "")
return "no-cache" in cache_control.lower()


@app.route("/predict", methods=["POST"])
@validate_request
@validate_internal_request
Expand Down Expand Up @@ -1205,6 +1231,25 @@ def predict():
detected_language = "en"
translated = False

# Content-addressed response cache (issue #1008). Key on the normalised
# input plus the live serving version, so a model hot-swap (which bumps
# serving_state.version) transparently invalidates every prior entry and
# a stale model can never serve a cached answer. Look up before any of
# the expensive translation / analysis / inference work below; refresh
# the entry on a miss (and on an explicit bypass, so a forced-fresh
# request also repopulates the cache).
cache_options = {"type": input_type}
cache_key = predict_cache.make_cache_key(
normalizer.normalize(text), serving.version, cache_options
)
cache_bypass = _cache_bypass_requested()
if not cache_bypass:
cached_body = predict_cache.CACHE.get(cache_key)
if cached_body is not None:
response = jsonify(cached_body)
response.headers["X-Cache"] = "HIT"
return response

if input_type != "url" and text.strip():
try:
from langdetect import detect, DetectorFactory
Expand Down Expand Up @@ -1328,7 +1373,10 @@ def predict():

metrics.record_prediction(result=final_output, input_type=input_type)

return jsonify(response_data)
predict_cache.CACHE.set(cache_key, response_data)
response = jsonify(response_data)
response.headers["X-Cache"] = "MISS"
return response

except Exception as e:
request_id = getattr(g, "request_id", "unknown")
Expand Down
80 changes: 75 additions & 5 deletions backend/openapi_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,28 @@ def _core_paths():
"summary": "Classify a message or URL",
"operationId": "predict",
"tags": ["Prediction"],
"description": (
"Responses are served from a content-addressed cache keyed "
"by the normalised input, the prediction options and the "
"live model version; a model hot-swap invalidates it. Send "
"`Cache-Control: no-cache` or `?fresh=1` to bypass the "
"lookup and force a fresh computation. The `X-Cache` "
"response header reports whether the body was served from "
"cache."
),
"parameters": [
{
"name": "fresh",
"in": "query",
"required": False,
"schema": {"type": "string", "enum": ["1"]},
"description": (
"Set to '1' to bypass the response cache and force "
"a fresh computation (equivalent to sending "
"Cache-Control: no-cache)."
),
},
],
"requestBody": {
"required": True,
"content": {
Expand All @@ -155,11 +177,20 @@ def _core_paths():
},
},
"responses": {
"200": _json_response(
"Prediction result with confidence, URL risk and "
"explanation details.",
{"$ref": "#/components/schemas/PredictionResponse"},
),
"200": {
"description": (
"Prediction result with confidence, URL risk and "
"explanation details."
),
"headers": {"X-Cache": _x_cache_header()},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PredictionResponse"
}
}
},
},
"400": _error_response("Missing/invalid text or body."),
"403": _error_response("Missing or invalid internal secret."),
"500": _error_response("Inference error."),
Expand Down Expand Up @@ -574,6 +605,21 @@ def _extended_paths():
},
}
},
"/cache-stats": {
"get": {
"summary": "/predict response cache statistics",
"operationId": "getCacheStats",
"tags": ["System"],
"security": [],
"responses": {
"200": _json_response(
"Aggregate hit/miss/size counters for the /predict "
"response cache (never any cached content).",
{"$ref": "#/components/schemas/CacheStats"},
)
},
}
},
"/api/wordcloud": {
"get": {
"summary": "Spam word frequencies for the word cloud",
Expand Down Expand Up @@ -965,6 +1011,20 @@ def _extended_paths():

def _extended_schemas():
return {
"CacheStats": {
"type": "object",
"description": "Aggregate counters for the /predict response cache.",
"properties": {
"enabled": {"type": "boolean"},
"hits": {"type": "integer"},
"misses": {"type": "integer"},
"size": {"type": "integer"},
"max_size": {"type": "integer"},
"ttl_seconds": {"type": "number"},
"evictions": {"type": "integer"},
"hit_rate": {
"type": "number",
"description": "hits / (hits + misses), rounded to 4 dp.",
"ArtifactInfo": {
"type": "object",
"description": "Content fingerprint of one model artifact.",
Expand Down Expand Up @@ -1091,6 +1151,16 @@ def _error_response(description):
return _json_response(description, _ERROR)


def _x_cache_header():
return {
"description": (
"Whether the body was served from the /predict response cache: "
"HIT (cached) or MISS (freshly computed)."
),
"schema": {"type": "string", "enum": ["HIT", "MISS"]},
}


def _query_param(name, description, required=False, schema=None):
return {
"name": name,
Expand Down
Loading
Loading