starfriend commited on
Commit
f1deb6a
·
verified ·
1 Parent(s): d5d68f6

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