Spaces:
Sleeping
Sleeping
Upload 2 files
Browse files
backend/media_analysis/public_routes.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Opt-in public media analysis (no research token in client code).
|
| 2 |
+
|
| 3 |
+
Enable only after setting budget-aware operational controls. In-memory throttling
|
| 4 |
+
is per Python worker, not a distributed quota; add a gateway quota for production.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
import os
|
| 8 |
+
import time
|
| 9 |
+
from collections import defaultdict, deque
|
| 10 |
+
from threading import Lock, BoundedSemaphore
|
| 11 |
+
from flask import Blueprint, jsonify, request
|
| 12 |
+
from .figure_analysis import analyze_media_question, MediaSelectionError
|
| 13 |
+
from .vision_client import VisionConfigurationError
|
| 14 |
+
|
| 15 |
+
media_public_bp = Blueprint("media_analysis_public", __name__)
|
| 16 |
+
_hits = defaultdict(deque)
|
| 17 |
+
_guard = Lock()
|
| 18 |
+
_busy = BoundedSemaphore(value=1)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _enabled():
|
| 22 |
+
return os.getenv("WATERSCOPE_ENABLE_PUBLIC_MEDIA", "0").lower() in ("1", "true", "yes")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@media_public_bp.post("/api/media/analyze")
|
| 26 |
+
def media_analyze_public():
|
| 27 |
+
if not _enabled():
|
| 28 |
+
return jsonify({"error": "Public media analysis is not enabled"}), 404
|
| 29 |
+
body = request.get_json(silent=True)
|
| 30 |
+
if not isinstance(body, dict):
|
| 31 |
+
return jsonify({"error": "JSON object required"}), 400
|
| 32 |
+
question = body.get("question")
|
| 33 |
+
idx = body.get("metadata_row_index")
|
| 34 |
+
if not isinstance(question, str) or not (1 <= len(question.strip()) <= 1200):
|
| 35 |
+
return jsonify({"error": "question must be 1-1200 characters"}), 400
|
| 36 |
+
if isinstance(idx, bool) or not isinstance(idx, int) or idx < 0:
|
| 37 |
+
return jsonify({"error": "Select one indexed publication with available media"}), 400
|
| 38 |
+
# The caller may request only a public indexed paper; source paths are resolved on the server.
|
| 39 |
+
from metadata_search.search_service import get_rows
|
| 40 |
+
rows = get_rows()
|
| 41 |
+
if idx >= len(rows):
|
| 42 |
+
return jsonify({"error": "Paper index out of range"}), 400
|
| 43 |
+
row = rows[idx]
|
| 44 |
+
paper = {"paper_id": str(idx), "metadata_row_index": idx,
|
| 45 |
+
"paper_title": str(row.get("Title") or row.get("title") or "")[:400],
|
| 46 |
+
"doi": str(row.get("DOI") or row.get("doi") or "")[:200]}
|
| 47 |
+
# Match only explicit Figure N or Table N; media is never chosen arbitrarily.
|
| 48 |
+
import re
|
| 49 |
+
if not re.search(r"\b(?:figure|fig\.?|table)\s*\d+\b", question, re.I):
|
| 50 |
+
return jsonify({"error": "Specify Figure N or Table N"}), 400
|
| 51 |
+
# Fixed provider and model on server, not client. Hard bound to avoid runaway bills.
|
| 52 |
+
provider = os.getenv("WATERSCOPE_PUBLIC_MEDIA_PROVIDER", "deepseek").strip().lower()
|
| 53 |
+
if provider not in ("azure", "deepseek"):
|
| 54 |
+
return jsonify({"error": "Media provider is not configured"}), 503
|
| 55 |
+
model = os.getenv("WATERSCOPE_PUBLIC_MEDIA_MODEL", "deepseek-flash").strip() or None
|
| 56 |
+
limit = max(1, min(30, int(os.getenv("WATERSCOPE_PUBLIC_MEDIA_HOURLY_PER_IP", "4"))))
|
| 57 |
+
ip = request.remote_addr or "unknown" # Use proxy-address validation at trusted gateway.
|
| 58 |
+
now = time.monotonic()
|
| 59 |
+
with _guard:
|
| 60 |
+
q = _hits[ip]
|
| 61 |
+
while q and now - q[0] > 3600: q.popleft()
|
| 62 |
+
if len(q) >= limit:
|
| 63 |
+
return jsonify({"error": "Media analysis hourly request limit reached"}), 429
|
| 64 |
+
q.append(now)
|
| 65 |
+
if len(_hits) > 5000:
|
| 66 |
+
for key in list(_hits):
|
| 67 |
+
if not _hits[key] or now - _hits[key][-1] > 3600: del _hits[key]
|
| 68 |
+
if not _busy.acquire(blocking=False):
|
| 69 |
+
with _guard:
|
| 70 |
+
q = _hits.get(ip)
|
| 71 |
+
if q:
|
| 72 |
+
try: q.remove(now)
|
| 73 |
+
except ValueError: pass
|
| 74 |
+
return jsonify({"error": "Media analysis is busy; please retry shortly"}), 429
|
| 75 |
+
try:
|
| 76 |
+
result = analyze_media_question(question.strip(), [paper], enabled=True,
|
| 77 |
+
provider=provider, model=model, max_output_tokens=4096,
|
| 78 |
+
evidence_mode="combined", export_csv=True, analysis_mode="auto")
|
| 79 |
+
# Do not send large duplicate tabular records: CSV already carries the data.
|
| 80 |
+
result.pop("structured_data", None)
|
| 81 |
+
return jsonify(result)
|
| 82 |
+
except (MediaSelectionError, VisionConfigurationError, ValueError, FileNotFoundError) as exc:
|
| 83 |
+
return jsonify({"error": str(exc)}), 400
|
| 84 |
+
except Exception as exc:
|
| 85 |
+
return jsonify({"error": "Media analysis temporarily unavailable", "error_type": type(exc).__name__}), 502
|
| 86 |
+
finally:
|
| 87 |
+
_busy.release()
|
metadata_search/app.py
CHANGED
|
@@ -141,6 +141,10 @@ app = Flask(__name__)
|
|
| 141 |
from backend.media_analysis.test_routes import media_test_bp
|
| 142 |
app.register_blueprint(media_test_bp)
|
| 143 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
|
| 145 |
UI_DIR = PROJECT_ROOT / "github_pages"
|
| 146 |
|
|
|
|
| 141 |
from backend.media_analysis.test_routes import media_test_bp
|
| 142 |
app.register_blueprint(media_test_bp)
|
| 143 |
|
| 144 |
+
# Public media route is gated by WATERSCOPE_ENABLE_PUBLIC_MEDIA.
|
| 145 |
+
from backend.media_analysis.public_routes import media_public_bp
|
| 146 |
+
app.register_blueprint(media_public_bp)
|
| 147 |
+
|
| 148 |
|
| 149 |
UI_DIR = PROJECT_ROOT / "github_pages"
|
| 150 |
|