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:
co-authored by
Claude Opus 4.6
parent
117a8ddb58
commit
9f2a144f65
@@ -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
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user