from __future__ import annotations import json from collections.abc import Iterator from typing import Annotated from fastapi import APIRouter, Path, Query, Request from fastapi.responses import StreamingResponse from backend.features.accounts.auth import ( AuthenticatedPrincipal, CsrfPrincipal, SmartAccessPrincipal, SmartWritePrincipal, ) from backend.features.review.schemas import ( AlertInput, AssistantInput, NoteInput, TradeInput, WatchInput, WatchRemarkInput, ) from backend.features.review.service import ReviewError from backend.http.errors import AppError from backend.llm.gateway import LLMGatewayError router = APIRouter(prefix="/review", tags=["review"]) @router.get("", response_model=dict) def workspace( request: Request, principal: AuthenticatedPrincipal, trade_date: Annotated[str, Query(alias="date")], ) -> dict: return _call(request, "workspace", principal, trade_date) @router.post("/watchlist", response_model=dict) def add_watch(payload: WatchInput, request: Request, principal: CsrfPrincipal) -> dict: return _call(request, "add_watch", principal, payload.identifier) @router.patch("/watchlist/{identifier}/remark", response_model=dict) def save_watch_remark( payload: WatchRemarkInput, request: Request, principal: CsrfPrincipal, identifier: Annotated[str, Path(min_length=1, max_length=40)], ) -> dict: _call(request, "save_watch_remark", principal, identifier, payload.remark) return {"message": "跟踪备注已保存。"} @router.delete("/watchlist/{identifier}", response_model=dict) def delete_watch( request: Request, principal: CsrfPrincipal, identifier: Annotated[str, Path(min_length=1, max_length=40)], ) -> dict: _call(request, "delete_watch", principal, identifier) return {"message": "已移出自选。"} @router.put("/notes", response_model=dict) def save_note(payload: NoteInput, request: Request, principal: CsrfPrincipal) -> dict: identifier = _call(request, "save_note", principal, payload.model_dump()) return {"id": identifier} @router.get("/stock-notes/{code}", response_model=list[dict]) def stock_notes( request: Request, principal: AuthenticatedPrincipal, code: Annotated[str, Path(min_length=1, max_length=12)], ) -> list[dict]: return _call(request, "stock_notes", principal, code) @router.delete("/notes/{note_id}", response_model=dict) def delete_note( request: Request, principal: CsrfPrincipal, note_id: Annotated[int, Path(gt=0)], ) -> dict: _call(request, "delete_note", principal, note_id) return {"message": "复盘记录已删除。"} @router.put("/trades", response_model=dict) def save_trade(payload: TradeInput, request: Request, principal: CsrfPrincipal) -> dict: identifier = _call(request, "save_trade", principal, payload.model_dump()) return {"id": identifier} @router.delete("/trades/{trade_id}", response_model=dict) def delete_trade( request: Request, principal: CsrfPrincipal, trade_id: Annotated[int, Path(gt=0)], ) -> dict: _call(request, "delete_trade", principal, trade_id) return {"message": "交易记录已删除。"} @router.get("/alerts", response_model=dict) def alerts( request: Request, principal: AuthenticatedPrincipal, unread: Annotated[bool, Query()] = False, ) -> dict: return _call(request, "alert_center", principal, unread) @router.post("/alerts", response_model=dict) def create_alert(payload: AlertInput, request: Request, principal: CsrfPrincipal) -> dict: identifier = _call(request, "create_alert", principal, payload.model_dump()) return {"id": identifier} @router.patch("/alerts/read-all", response_model=dict) def mark_all_alerts(request: Request, principal: CsrfPrincipal) -> dict: return {"updated": _call(request, "mark_all_alerts", principal)} @router.patch("/alerts/{alert_id}/read", response_model=dict) def mark_alert( request: Request, principal: CsrfPrincipal, alert_id: Annotated[int, Path(gt=0)], ) -> dict: _call(request, "mark_alert", principal, alert_id) return {"message": "提醒已读。"} @router.delete("/alerts/{alert_id}", response_model=dict) def delete_alert( request: Request, principal: CsrfPrincipal, alert_id: Annotated[int, Path(gt=0)], ) -> dict: _call(request, "delete_alert", principal, alert_id) return {"message": "提醒已删除。"} @router.get("/assistant/messages", response_model=list[dict]) def assistant_messages(request: Request, principal: SmartAccessPrincipal) -> list[dict]: return _call(request, "messages", principal) @router.delete("/assistant/messages", response_model=dict) def clear_assistant_messages(request: Request, principal: SmartWritePrincipal) -> dict: return {"deleted": _call(request, "clear_messages", principal)} @router.post("/assistant/chat") def assistant_chat( payload: AssistantInput, request: Request, principal: SmartWritePrincipal, ) -> StreamingResponse: prepared = _call(request, "prepare_assistant", principal, payload.trade_date, payload.question) def body() -> Iterator[bytes]: for event in request.app.state.container.review.stream_assistant(prepared): yield (json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n").encode() return StreamingResponse( body(), media_type="application/x-ndjson", headers={"Cache-Control": "no-cache, no-transform", "X-Accel-Buffering": "no"}, ) def _call(request: Request, method: str, *args): try: return getattr(request.app.state.container.review, method)(*args) except ReviewError as exc: raise AppError("review_unavailable", str(exc), 409) from exc except LLMGatewayError as exc: status = 403 if exc.code in {"membership_required", "quota_exhausted"} else 503 raise AppError(exc.code, str(exc), status) from exc