research_routes.py 13.8 KB
from __future__ import annotations

from flask import Blueprint, jsonify, request

from backend import research_tasks as research_backend
from services.application.analysis import AnalysisRunQueryService
from services.application.report import ReportJobQueryService
from services.application.research import ResearchTaskNotFoundError, ResearchTaskService, ResearchTaskViewService
from services.shared.logging import bind_logger

research_bp = Blueprint("research", __name__, url_prefix="/api/research")
research_task_resource_bp = Blueprint(
    "research_task_resources",
    __name__,
    url_prefix="/api/research-tasks",
)

RESEARCH_TASK_APP_SERVICE = ResearchTaskService(research_backend.research_task_service)
_LOGGER = bind_logger(source="backend.research_routes")


class _ResearchTaskAppServiceProxy:
    """Keep a patchable module-level handle while resolving task wiring lazily."""

    def __getattr__(self, name: str):
        return getattr(RESEARCH_TASK_APP_SERVICE, name)


_RESEARCH_ANALYSIS_APP_SERVICE: AnalysisRunQueryService | None = None
_DEFAULT_RESEARCH_ANALYSIS_APP_SERVICE: AnalysisRunQueryService | None = None


def configure_research_analysis_app_service(service: AnalysisRunQueryService | None) -> None:
    """Allow bootstrap/runtime wiring to inject the runtime-wired analysis query facade."""

    global _RESEARCH_ANALYSIS_APP_SERVICE
    _RESEARCH_ANALYSIS_APP_SERVICE = service


def _get_research_analysis_app_service() -> AnalysisRunQueryService:
    """Resolve the injected analysis query service, falling back to a shared default singleton."""

    global _DEFAULT_RESEARCH_ANALYSIS_APP_SERVICE

    service = _RESEARCH_ANALYSIS_APP_SERVICE
    if service is not None:
        return service

    if _DEFAULT_RESEARCH_ANALYSIS_APP_SERVICE is None:
        _DEFAULT_RESEARCH_ANALYSIS_APP_SERVICE = AnalysisRunQueryService()
    return _DEFAULT_RESEARCH_ANALYSIS_APP_SERVICE


def _get_analysis_run_query_service() -> AnalysisRunQueryService:
    """Backward-compatible alias for the research analysis app-service getter."""

    return _get_research_analysis_app_service()


class _AnalysisRunQueryServiceProxy:
    """Keep a patchable module-level handle while resolving the current run store lazily."""

    def __getattr__(self, name: str):
        return getattr(_get_research_analysis_app_service(), name)


RESEARCH_ANALYSIS_APP_SERVICE = _AnalysisRunQueryServiceProxy()


def _get_crawler_app_service():
    """Build a task-aware crawler application service using the current route wiring."""

    from backend.crawler import routes as crawler_routes

    return crawler_routes.CRAWLER_APP_SERVICE


class _CrawlerAppServiceProxy:
    """Keep a patchable module-level handle while resolving crawler wiring lazily."""

    def __getattr__(self, name: str):
        return getattr(_get_crawler_app_service(), name)


RESEARCH_CRAWLER_APP_SERVICE = _CrawlerAppServiceProxy()


_REPORT_APP_SERVICE: ReportJobQueryService | None = None


class ReportAppServiceNotConfiguredError(RuntimeError):
    """Raised when report routes are hit before runtime wiring is configured."""


def configure_report_app_service(service: ReportJobQueryService | None) -> None:
    """Allow app/bootstrap wiring to inject the runtime-wired report query service."""

    global _REPORT_APP_SERVICE
    _REPORT_APP_SERVICE = service


def _get_report_app_service() -> ReportJobQueryService:
    """Resolve the injected report query service lazily."""

    service = _REPORT_APP_SERVICE
    if service is not None:
        return service
    raise ReportAppServiceNotConfiguredError("Report query service is not configured.")


class _ReportAppServiceProxy:
    """Keep a patchable module-level handle while resolving report wiring lazily."""

    def __getattr__(self, name: str):
        return getattr(_get_report_app_service(), name)


RESEARCH_REPORT_APP_SERVICE = _ReportAppServiceProxy()

_RESEARCH_TASK_VIEW_APP_SERVICE: ResearchTaskViewService | None = None


class ResearchTaskViewServiceNotConfiguredError(RuntimeError):
    """Raised when task-view routes are hit before runtime wiring is configured."""


def configure_research_task_view_app_service(service: ResearchTaskViewService | None) -> None:
    """Allow bootstrap/runtime wiring to inject the task-view application facade."""

    global _RESEARCH_TASK_VIEW_APP_SERVICE
    _RESEARCH_TASK_VIEW_APP_SERVICE = service


def _get_research_task_view_app_service() -> ResearchTaskViewService:
    """Resolve the injected task-view application facade lazily."""

    service = _RESEARCH_TASK_VIEW_APP_SERVICE
    if service is not None:
        return service
    raise ResearchTaskViewServiceNotConfiguredError("Research task view service is not configured.")


class _ResearchTaskViewAppServiceProxy:
    """Keep a patchable module-level handle while resolving task-view wiring lazily."""

    def __getattr__(self, name: str):
        return getattr(_get_research_task_view_app_service(), name)


RESEARCH_TASK_VIEW_APP_SERVICE = _ResearchTaskViewAppServiceProxy()


def _build_task_detail_response(task_id: str):
    """Return a single research task resource payload."""

    request_logger = _LOGGER.bind(task_id=task_id, action="build_task_detail_response")
    try:
        task = RESEARCH_TASK_APP_SERVICE.get_task_dto(task_id)
    except ResearchTaskNotFoundError:
        request_logger.warning("Research task not found while building task detail response")
        return jsonify({"success": False, "message": "Research task not found."}), 404
    return jsonify({"success": True, "task": task.to_response_item()})


@research_bp.get("/options")
def get_research_options():
    """Return research form options for the frontend."""
    return jsonify(
        {
            "success": True,
            "venue_types": research_backend.RESEARCH_VENUE_TYPE_OPTIONS,
            "research_focuses": research_backend.RESEARCH_FOCUS_OPTIONS,
            "time_ranges": research_backend.RESEARCH_TIME_RANGE_OPTIONS,
            "status_labels": research_backend.RESEARCH_STATUS_LABELS,
        }
    )


@research_bp.get("/tasks")
def get_research_tasks():
    """Return the active task and recent task list."""
    payload = RESEARCH_TASK_APP_SERVICE.get_task_snapshot().to_response_payload()
    return jsonify({"success": True, **payload})


@research_task_resource_bp.get("")
def get_research_task_collection():
    """Return the resource-oriented research-task collection view."""

    return get_research_tasks()


@research_task_resource_bp.get("/task-views")
def get_research_task_view_collection():
    """Return a list-oriented task summary collection for task-centered UI pages."""

    snapshot = RESEARCH_TASK_APP_SERVICE.get_task_snapshot()
    try:
        payload = RESEARCH_TASK_VIEW_APP_SERVICE.build_task_view_collection_payload(snapshot)
    except ReportAppServiceNotConfiguredError:
        _LOGGER.bind(action="get_research_task_view_collection").error(
            "Report query service is not configured for task view collection"
        )
        return jsonify({"success": False, "message": "Report service is not configured."}), 503
    return jsonify({"success": True, **payload})


@research_bp.post("/tasks")
def save_research_task():
    """Create or update a venue research task."""
    payload = request.get_json(silent=True) or {}
    if not isinstance(payload, dict):
        return jsonify({"success": False, "message": "Request body must be a JSON object."}), 400

    try:
        task = RESEARCH_TASK_APP_SERVICE.create_task(payload)
        snapshot = RESEARCH_TASK_APP_SERVICE.get_task_snapshot().to_response_payload()
    except ValueError as exc:
        return jsonify({"success": False, "message": str(exc)}), 400
    except Exception as exc:
        _LOGGER.bind(
            action="save_research_task",
            venue_name=str(payload.get("venue_name") or payload.get("title") or ""),
        ).exception("Failed to save research task")
        return jsonify({"success": False, "message": f"Failed to save research task: {exc}"}), 500

    return jsonify({"success": True, "task": task.to_response_item(), **snapshot})


@research_task_resource_bp.post("")
def save_research_task_resource():
    """Create or update a research task through the resource-oriented path."""

    return save_research_task()


@research_bp.post("/tasks/<task_id>/activate")
def activate_research_task(task_id: str):
    """Mark a historical research task as the active task."""
    try:
        payload = RESEARCH_TASK_APP_SERVICE.activate_task(task_id).to_response_payload()
    except ResearchTaskNotFoundError:
        _LOGGER.bind(task_id=task_id, action="activate_research_task").warning(
            "Research task not found while activating task"
        )
        return jsonify({"success": False, "message": "Research task not found."}), 404

    return jsonify({"success": True, **payload})


@research_task_resource_bp.post("/<task_id>/activate")
def activate_research_task_resource(task_id: str):
    """Mark a historical research task as the active task through the resource path."""

    return activate_research_task(task_id)


@research_task_resource_bp.get("/<task_id>")
def get_research_task_resource(task_id: str):
    """Return a single research task through the resource-oriented path."""

    return _build_task_detail_response(task_id)


@research_bp.get("/tasks/<task_id>/analysis-run")
def get_research_task_analysis_run(task_id: str):
    """Return the latest analysis-run snapshot associated with a research task."""
    try:
        payload = RESEARCH_TASK_VIEW_APP_SERVICE.build_task_analysis_run_payload(task_id)
    except ResearchTaskNotFoundError:
        _LOGGER.bind(task_id=task_id, action="get_research_task_analysis_run").warning(
            "Research task not found while building task-scoped analysis-run resource"
        )
        return jsonify({"success": False, "message": "Research task not found."}), 404
    return jsonify({"success": True, **payload})


@research_task_resource_bp.get("/<task_id>/analysis-run")
def get_research_task_analysis_run_resource(task_id: str):
    """Return the latest analysis-run snapshot through the resource-oriented path."""

    return get_research_task_analysis_run(task_id)


@research_bp.get("/tasks/<task_id>/analysis")
def get_research_task_analysis(task_id: str):
    """Return a resource-oriented analysis view for the specified research task."""
    try:
        payload = RESEARCH_TASK_VIEW_APP_SERVICE.build_task_analysis_resource_payload(task_id)
    except ResearchTaskNotFoundError:
        _LOGGER.bind(task_id=task_id, action="get_research_task_analysis").warning(
            "Research task not found while building task-scoped analysis resource"
        )
        return jsonify({"success": False, "message": "Research task not found."}), 404
    return jsonify({"success": True, **payload})


@research_task_resource_bp.get("/<task_id>/analysis")
def get_research_task_analysis_resource(task_id: str):
    """Return the resource-oriented analysis view for the specified research task."""

    return get_research_task_analysis(task_id)


@research_bp.get("/tasks/<task_id>/analysis-runs")
def list_research_task_analysis_runs(task_id: str):
    """Return persisted analysis-run history for the specified research task."""
    try:
        payload = RESEARCH_TASK_VIEW_APP_SERVICE.build_task_analysis_runs_payload(task_id)
    except ResearchTaskNotFoundError:
        _LOGGER.bind(task_id=task_id, action="list_research_task_analysis_runs").warning(
            "Research task not found while building task-scoped analysis-run history"
        )
        return jsonify({"success": False, "message": "Research task not found."}), 404
    return jsonify({"success": True, **payload})


@research_task_resource_bp.get("/<task_id>/analysis-runs")
def list_research_task_analysis_runs_resource(task_id: str):
    """Return persisted analysis-run history through the resource-oriented path."""

    return list_research_task_analysis_runs(task_id)


@research_bp.get("/tasks/<task_id>/crawler")
def get_research_task_crawler(task_id: str):
    """Return a task-scoped crawler resource view for the specified research task."""
    try:
        payload = RESEARCH_TASK_VIEW_APP_SERVICE.build_task_crawler_resource_payload(task_id)
    except ResearchTaskNotFoundError:
        _LOGGER.bind(task_id=task_id, action="get_research_task_crawler").warning(
            "Research task not found while building task-scoped crawler resource"
        )
        return jsonify({"success": False, "message": "Research task not found."}), 404
    return jsonify({"success": True, **payload})


@research_task_resource_bp.get("/<task_id>/crawler")
def get_research_task_crawler_resource(task_id: str):
    """Return the resource-oriented crawler view for the specified research task."""

    return get_research_task_crawler(task_id)


@research_bp.get("/tasks/<task_id>/report")
def get_research_task_report(task_id: str):
    """Return a task-scoped report resource view for the specified research task."""
    try:
        payload = RESEARCH_TASK_VIEW_APP_SERVICE.build_task_report_resource_payload(task_id)
    except ResearchTaskNotFoundError:
        _LOGGER.bind(task_id=task_id, action="get_research_task_report").warning(
            "Research task not found while building task-scoped report resource"
        )
        return jsonify({"success": False, "message": "Research task not found."}), 404
    except ReportAppServiceNotConfiguredError:
        _LOGGER.bind(task_id=task_id, action="get_research_task_report").error(
            "Report query service is not configured for task-scoped report resource"
        )
        return jsonify({"success": False, "message": "Report service is not configured."}), 503
    return jsonify({"success": True, **payload})


@research_task_resource_bp.get("/<task_id>/report")
def get_research_task_report_resource(task_id: str):
    """Return the resource-oriented report view for the specified research task."""

    return get_research_task_report(task_id)