from __future__ import annotations from typing import Any, Callable, Dict, Optional from .config import Settings, load_settings from .embeddings import OpenAIEmbedder, OpenAIEmbeddingClient from .ingest import BackfillService from .models import SearchQuery, search_response from .qdrant_store import QdrantStore from .refresh import FileRefreshState, RedmineRefreshService from .redmine import RedmineApiSource, RedmineMapper from .search import HybridSearchService def build_services(settings: Optional[Settings] = None) -> Dict[str, Any]: settings = settings or load_settings() embedding_client = OpenAIEmbeddingClient(api_key=settings.openai_api_key) embedder = OpenAIEmbedder(client=embedding_client) store = QdrantStore( url=settings.qdrant_url, api_key=settings.qdrant_api_key, collection=settings.qdrant_collection, ) redmine_source = RedmineApiSource( redmine_url=settings.redmine_url, api_key=settings.redmine_api_key or "", project_identifier=settings.redmine_project_identifier, ) search_service = HybridSearchService(embedder=embedder, store=store) backfill_service = BackfillService( source=redmine_source, embedder=embedder, store=store, mapper=RedmineMapper(redmine_url=settings.redmine_url, project_identifier=settings.redmine_project_identifier), ) refresh_service = RedmineRefreshService( source=redmine_source, embedder=embedder, store=store, mapper=RedmineMapper(redmine_url=settings.redmine_url, project_identifier=settings.redmine_project_identifier), state=FileRefreshState(settings.refresh_state_path), ) return { "settings": settings, "search": search_service, "backfill": backfill_service, "refresh": refresh_service, "store": store, "redmine_source": redmine_source, } def create_app(settings: Optional[Settings] = None, service_builder: Optional[Callable[[], Dict[str, Any]]] = None): try: from fastapi import FastAPI, Header, HTTPException except ImportError as exc: raise RuntimeError("Install fastapi and uvicorn to run the HTTP service") from exc services: Optional[Dict[str, Any]] = None app = FastAPI(title="Redmine Semantic Index", version="0.1.0") def get_services() -> Dict[str, Any]: nonlocal services if services is None: if service_builder is not None: services = service_builder() else: services = build_services(settings) return services def authorize(authorization: Optional[str]) -> None: api_key = get_services()["settings"].service_api_key if not api_key: return expected = f"Bearer {api_key}" if authorization != expected: raise HTTPException(status_code=401, detail="unauthorized") @app.get("/health") def health() -> Dict[str, str]: return {"status": "ok"} @app.post("/sources/redmine/backfill-sample") def backfill(payload: Dict[str, Any] | None = None, authorization: Optional[str] = Header(default=None)) -> Dict[str, Any]: authorize(authorization) active_services = get_services() limit = int((payload or {}).get("limit", active_services["settings"].sample_limit)) return active_services["backfill"].backfill_redmine_sample(limit=limit) @app.post("/sources/redmine/refresh") def refresh(payload: Dict[str, Any] | None = None, authorization: Optional[str] = Header(default=None)) -> Dict[str, Any]: authorize(authorization) payload = payload or {} project_limits = payload.get("project_limits") if not project_limits: project = payload.get("project_identifier") or get_services()["settings"].redmine_project_identifier if not project: raise HTTPException(status_code=400, detail="project_limits or project_identifier is required") project_limits = {project: int(payload.get("limit", get_services()["settings"].sample_limit))} return get_services()["refresh"].refresh_redmine_project_limits( {str(project): int(limit) for project, limit in project_limits.items()}, dry_run=bool(payload.get("dry_run", False)), force_rebuild=bool(payload.get("force_rebuild", False)), overlap_minutes=int(payload.get("overlap_minutes", 15)), ) @app.post("/search") def search(payload: Dict[str, Any], authorization: Optional[str] = Header(default=None)) -> Dict[str, Any]: authorize(authorization) query = SearchQuery( text=payload.get("query") or payload.get("text") or "", source=payload.get("source"), project_id=payload.get("project_id"), project_identifier=payload.get("project_identifier"), doc_type=payload.get("doc_type"), issue_id=payload.get("issue_id"), contact_id=payload.get("contact_id"), contact_email=payload.get("contact_email"), date_from=payload.get("date_from"), date_to=payload.get("date_to"), limit=int(payload.get("limit", 10)), include_snippets=bool(payload.get("include_snippets", True)), ) results = get_services()["search"].search(query) return search_response(query, results) @app.get("/projects") def projects(authorization: Optional[str] = Header(default=None)) -> Dict[str, Any]: authorize(authorization) return {"projects": get_services()["store"].list_projects(source="redmine")} @app.get("/documents/{document_id}") def document(document_id: str, authorization: Optional[str] = Header(default=None)) -> Dict[str, Any]: authorize(authorization) found = get_services()["search"].get_document(document_id) if found is None: raise HTTPException(status_code=404, detail="not_found") return found return app class LazyASGIApp: def __init__(self) -> None: self._app = None async def __call__(self, scope, receive, send): if self._app is None: self._app = create_app() await self._app(scope, receive, send) app = LazyASGIApp()