diff --git a/backend/app/api/tickets.py b/backend/app/api/tickets.py index bb4f2f7..db470ec 100644 --- a/backend/app/api/tickets.py +++ b/backend/app/api/tickets.py @@ -6,6 +6,8 @@ Returns data in a format aligned with the frontend TicketData interface: from __future__ import annotations +from collections import Counter + import httpx from litestar import get from litestar.exceptions import NotFoundException @@ -51,6 +53,53 @@ def _transform_ticket(t: dict) -> dict: } +@get("/api/tickets/summary") +async def get_tickets_summary() -> dict: + """GET /api/tickets/summary -- Aggregate ticket statistics. + + Returns: + { + "total": int, + "by_status": {"pending": N, "processing": N, "resolved": N}, + "by_priority": {"P0": N, "P1": N, "P2": N, "P3": N} + } + """ + # Fetch a large page to get representative counts. + # For a production system this would be a dedicated aggregation API; + # the Gongdan API only exposes list endpoints so we aggregate client-side. + url = f"{settings.gongdan_api_base}/api/tickets" + params = {"page": 1, "pageSize": 100} + + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get(url, params=params, headers=_gongdan_headers()) + resp.raise_for_status() + data = resp.json() + + tickets = data.get("tickets", []) + + status_counter: Counter[str] = Counter() + priority_counter: Counter[str] = Counter() + + for t in tickets: + status_counter[_map_status(t.get("status", ""))] += 1 + priority_counter[_map_priority(t.get("priority", ""))] += 1 + + return { + "total": len(tickets), + "by_status": { + "pending": status_counter.get("pending", 0), + "processing": status_counter.get("processing", 0), + "resolved": status_counter.get("resolved", 0), + }, + "by_priority": { + "P0": priority_counter.get("P0", 0), + "P1": priority_counter.get("P1", 0), + "P2": priority_counter.get("P2", 0), + "P3": priority_counter.get("P3", 0), + }, + } + + @get("/api/tickets") async def list_tickets(page: int = 1, page_size: int = 20) -> list[dict]: """GET /api/tickets — List tickets from Gongdan, formatted for frontend.""" diff --git a/backend/app/main.py b/backend/app/main.py index 8fc2f3b..4ee0dc0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -22,10 +22,12 @@ from app.api.conversations import ( update_conversation, ) from app.api.health import health_check -from app.api.tickets import get_ticket, list_tickets +from app.api.tickets import get_ticket, get_tickets_summary, list_tickets from app.cache.redis import close_redis +from app.storage.blob import close_blob_client from app.store.memory import close_checkpointer from app.store.postgres import create_tables, dispose_engine +from app.tasks.bus import close_service_bus cors_config = CORSConfig( allow_origins=["*"], @@ -42,6 +44,8 @@ async def lifespan(app: Litestar) -> AsyncGenerator[None, None]: yield await close_checkpointer() await close_redis() + await close_blob_client() + await close_service_bus() await dispose_engine() @@ -54,6 +58,7 @@ app = Litestar( create_conversation, update_conversation, delete_conversation, + get_tickets_summary, list_tickets, get_ticket, ], diff --git a/backend/app/tools/search.py b/backend/app/tools/search.py index 97abe02..f99f2cd 100644 --- a/backend/app/tools/search.py +++ b/backend/app/tools/search.py @@ -13,6 +13,7 @@ All results are cached in Redis with TTL=300s. from __future__ import annotations import asyncio +import contextvars import logging from typing import Any @@ -196,16 +197,18 @@ async def _search_pro(query: str) -> str: # ── The LangChain tool exposed to the ReAct agent ────────────────────── -# The model context is injected via the tool's config at call time. -# We store a thread-local-like mapping so the tool knows which model -# strategy to use. The chat handler sets this before invoking the graph. -_current_model: str = "flash" +# Use contextvars to safely pass the model strategy per-request in an +# async concurrent environment. Each asyncio Task (i.e. each SSE +# request handler) gets its own copy, so concurrent requests never +# overwrite each other's value. +_current_model: contextvars.ContextVar[str] = contextvars.ContextVar( + "search_model", default="flash" +) def set_search_model(model: str) -> None: - """Set the search strategy model for the current request.""" - global _current_model - _current_model = model + """Set the search strategy model for the current request context.""" + _current_model.set(model) @tool @@ -219,7 +222,7 @@ async def web_search(query: str) -> str: Args: query: The search query in natural language. """ - model = _current_model + model = _current_model.get() # Check Redis cache first cached = await get_cached_search(query, model)