| """ |
| Search REST API (universal). |
| |
| Phase 1 exposes admin/adjudicator read-only search at |
| ``/admin/api/search``. Read-only search is safe under every assignment |
| strategy and crowd backend (no self-selection), so it has no config |
| guard. Annotator search-and-claim is a separate, guarded endpoint. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import logging |
|
|
| from flask import Blueprint, jsonify, request, session |
|
|
| from .service import get_search, search_settings |
|
|
| logger = logging.getLogger(__name__) |
|
|
| search_bp = Blueprint("search", __name__) |
|
|
|
|
| def _config() -> dict: |
| from potato.server_utils.config_module import config |
| return config |
|
|
|
|
| def _annotator_claim_enabled() -> bool: |
| return bool(search_settings(_config()).get("annotator_claim")) |
|
|
|
|
| def _serialize(hits) -> list: |
| return [ |
| {"instance_id": h.instance_id, "snippet": h.snippet, "score": h.score} |
| for h in hits |
| ] |
|
|
|
|
| def _is_privileged() -> bool: |
| """Admin (API key) or adjudicator may use admin search.""" |
| username = session.get("username") |
| try: |
| from potato.admin import admin_dashboard |
| if admin_dashboard.check_admin_access(): |
| return True |
| except Exception: |
| pass |
| if username: |
| try: |
| from potato.adjudication import get_adjudication_manager |
| adj = get_adjudication_manager() |
| if adj and adj.is_adjudicator(username): |
| return True |
| except Exception: |
| pass |
| return False |
|
|
|
|
| @search_bp.route("/admin/api/search", methods=["GET"]) |
| def admin_search(): |
| if not _is_privileged(): |
| return jsonify({"error": "Admin or adjudicator access required"}), 403 |
| backend = get_search() |
| if backend is None: |
| return jsonify({ |
| "error": "Search is not enabled or unavailable in this " |
| "deployment.", |
| "hint": "Set search.enabled: true (requires a SQLite build " |
| "with FTS5).", |
| }), 503 |
| q = (request.args.get("q") or "").strip() |
| if not q: |
| return jsonify({"error": "Query parameter 'q' is required"}), 400 |
| try: |
| limit = max(1, min(int(request.args.get("limit", 50)), 500)) |
| except (TypeError, ValueError): |
| limit = 50 |
| hits = backend.query(q, limit=limit) |
| return jsonify({ |
| "query": q, "count": len(hits), "results": _serialize(hits), |
| }) |
|
|
|
|
| def _annotator_ctx(): |
| """(username, backend) for annotator search/claim, or an error tuple.""" |
| if not _annotator_claim_enabled(): |
| return None, None, ("disabled",) |
| username = session.get("username") |
| if not username: |
| return None, None, ("unauth",) |
| backend = get_search() |
| if backend is None: |
| return None, None, ("unavailable",) |
| return username, backend, None |
|
|
|
|
| def _annotator_err(err): |
| if err == ("disabled",): |
| return jsonify({ |
| "error": "Annotator search-and-claim is not enabled.", |
| "hint": "Set search.annotator_claim: true (subject to the " |
| "assignment-compatibility guard).", |
| }), 403 |
| if err == ("unauth",): |
| return jsonify({"error": "Not authenticated"}), 401 |
| if err == ("unavailable",): |
| return jsonify({"error": "Search is not enabled or unavailable."}), 503 |
| return None |
|
|
|
|
| @search_bp.route("/api/search", methods=["GET"]) |
| def annotator_search(): |
| """Annotator-facing corpus search. Gated by search.annotator_claim; |
| the startup guard guarantees the assignment design is compatible.""" |
| username, backend, err = _annotator_ctx() |
| if err: |
| return _annotator_err(err) |
| q = (request.args.get("q") or "").strip() |
| if not q: |
| return jsonify({"error": "Query parameter 'q' is required"}), 400 |
| try: |
| limit = max(1, min(int(request.args.get("limit", 50)), 200)) |
| except (TypeError, ValueError): |
| limit = 50 |
| hits = backend.query(q, limit=limit) |
| return jsonify({ |
| "query": q, "count": len(hits), "results": _serialize(hits), |
| }) |
|
|
|
|
| @search_bp.route("/api/search/claim", methods=["POST"]) |
| def annotator_claim(): |
| """Pull a matching instance into the requesting annotator's queue.""" |
| username, backend, err = _annotator_ctx() |
| if err: |
| return _annotator_err(err) |
| data = request.get_json(silent=True) or {} |
| instance_id = data.get("instance_id") |
| if not instance_id: |
| return jsonify({"error": "instance_id is required"}), 400 |
| try: |
| from potato.item_state_management import get_item_state_manager |
| from potato.user_state_management import get_user_state_manager |
| ism = get_item_state_manager() |
| try: |
| item = ism.get_item(str(instance_id)) |
| except Exception: |
| return jsonify({"error": f"Unknown instance {instance_id}"}), 404 |
| if item is None: |
| return jsonify({"error": f"Unknown instance {instance_id}"}), 404 |
| user_state = get_user_state_manager().get_user_state(username) |
| already = instance_id in user_state.get_assigned_instance_ids() |
| user_state.assign_instance(item) |
| except Exception as e: |
| logger.error(f"Claim failed for {instance_id}: {e}") |
| return jsonify({"error": "Could not claim instance"}), 500 |
| return jsonify({ |
| "claimed": str(instance_id), |
| "already_assigned": bool(already), |
| }) |
|
|