fix(backend): resolve search concurrency race and add tickets summary endpoint

- Replace module-level _current_model global with contextvars.ContextVar
  to prevent concurrent requests from overwriting each other's search
  strategy (flash vs pro)
- Add GET /api/tickets/summary returning {total, by_status, by_priority}
  aggregated from Gongdan API, registered before parameterized ticket routes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gongzhiyong
2026-04-08 16:37:42 +08:00
co-authored by Claude Opus 4.6
parent 117a8ddb58
commit 9f2a144f65
3 changed files with 66 additions and 9 deletions
+49
View File
@@ -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."""
+6 -1
View File
@@ -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,
],
+11 -8
View File
@@ -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)