from __future__ import annotations import secrets from collections.abc import Iterator from dataclasses import dataclass from datetime import date, datetime from typing import Any from zoneinfo import ZoneInfo from backend.data.gateway import DataGateway from backend.database.connection import Database from backend.features.accounts.models import Principal from backend.features.review.prompt import PROMPT_VERSION, messages from backend.features.review.repository import ReviewRepository from backend.features.review.views import alert as alert_view from backend.features.review.views import note as note_view from backend.features.review.views import trade_summary, trades from backend.features.screener.service import ScreenerService from backend.llm.gateway import LLMCall, LLMGateway, LLMGatewayError SHANGHAI = ZoneInfo("Asia/Shanghai") class ReviewError(RuntimeError): pass @dataclass(frozen=True, slots=True) class PreparedAssistant: call: LLMCall prompt: list[dict[str, str]] context_date: str class ReviewService: def __init__( self, database: Database, repository: ReviewRepository, gateway: DataGateway, screener: ScreenerService, llm: LLMGateway, ) -> None: self._database = database self._repository = repository self._gateway = gateway self._screener = screener self._llm = llm def workspace(self, principal: Principal, requested_date: str) -> dict[str, Any]: trade_date = self._trade_date(requested_date) with self._database.read() as connection: watches = self._watch_rows(connection, principal.user.id, trade_date) daily = self._repository.note(connection, principal.user.id, "", trade_date) history = self._repository.notes(connection, principal.user.id, "", 60) trade_rows = trades(self._repository.trades(connection, principal.user.id)) return { "trade_date": trade_date, "watchlist": watches, "daily": note_view(daily), "history": [note_view(row) for row in history], "trades": trade_rows, "trade_summary": trade_summary(trade_rows), } def add_watch(self, principal: Principal, identifier: str) -> dict[str, Any]: normalized = identifier.strip().upper() candidates = self._gateway.search(normalized) item = next( ( candidate for candidate in candidates if candidate.entity_type == "stock" and candidate.identifier == normalized ), None, ) if item is None: raise ReviewError("未找到可加入自选的股票。") with self._database.transaction() as connection: self._repository.save_watch( connection, principal.user.id, {"identifier": item.identifier, "name": item.name, "sector": item.sector}, _now(), ) return {"identifier": item.identifier, "name": item.name} def save_watch_remark(self, principal: Principal, identifier: str, remark: str) -> None: normalized = _text(remark, 500) with self._database.transaction() as connection: if not self._repository.save_watch_remark( connection, principal.user.id, identifier, normalized ): raise ReviewError("自选记录不存在。") def delete_watch(self, principal: Principal, identifier: str) -> None: with self._database.transaction() as connection: if not self._repository.delete_watch(connection, principal.user.id, identifier): raise ReviewError("自选记录不存在。") def save_note(self, principal: Principal, payload: dict[str, Any]) -> int: data = { "trade_date": _date(payload["trade_date"]), "code": payload.get("code", "").strip().upper(), "stock_name": _text(payload.get("stock_name", ""), 40), "summary": _text(payload.get("summary", ""), 500), "content": _text(payload.get("content", ""), 5000), "plan": _text(payload.get("plan", ""), 2000), } if data["code"] and not data["stock_name"]: raise ReviewError("个股名称不能为空。") with self._database.transaction() as connection: return self._repository.save_note(connection, principal.user.id, data, _now()) def stock_notes(self, principal: Principal, code: str) -> list[dict[str, Any]]: with self._database.read() as connection: return [ note_view(row) for row in self._repository.notes( connection, principal.user.id, code.strip().upper() ) ] def delete_note(self, principal: Principal, note_id: int) -> None: with self._database.transaction() as connection: if not self._repository.delete_note(connection, principal.user.id, note_id): raise ReviewError("复盘记录不存在。") def save_trade(self, principal: Principal, payload: dict[str, Any]) -> int: data = dict(payload) data["trade_date"] = _date(str(data["trade_date"])) data["code"] = str(data["code"]).strip().upper() data["name"] = _text(data["name"], 40, True) data["thesis"] = _text(data.get("thesis", ""), 2000) data["execution"] = _text(data.get("execution", ""), 2000) data["tags"] = [_text(item, 20, True) for item in data.get("tags", [])][:8] with self._database.transaction() as connection: return self._repository.save_trade(connection, principal.user.id, data, _now()) def delete_trade(self, principal: Principal, trade_id: int) -> None: with self._database.transaction() as connection: if not self._repository.delete_trade(connection, principal.user.id, trade_id): raise ReviewError("交易记录不存在。") def alert_center(self, principal: Principal, unread: bool = False) -> dict[str, Any]: self._sync_tracking_alerts(principal.user.id) today = date.today().isoformat() with self._database.read() as connection: rows = self._repository.alerts(connection, principal.user.id, unread) items = [alert_view(row, today) for row in rows] if unread: items = [item for item in items if item["due"]] count = self._repository.unread_count(connection, principal.user.id, today) return {"items": items, "unread_count": count, "as_of": today} def create_alert(self, principal: Principal, payload: dict[str, Any]) -> int: data = { "kind": "manual", "title": _text(payload["title"], 80, True), "content": _text(payload.get("content", ""), 500), "available_date": _date(payload["remind_date"]), "code": _text(payload.get("code", ""), 12), "dedupe_key": f"manual:{secrets.token_hex(12)}", } with self._database.transaction() as connection: return self._repository.save_alert(connection, principal.user.id, data, _now()) def mark_alert(self, principal: Principal, alert_id: int) -> None: with self._database.transaction() as connection: if not self._repository.mark_alert(connection, principal.user.id, alert_id, _now()): raise ReviewError("提醒不存在。") def mark_all_alerts(self, principal: Principal) -> int: with self._database.transaction() as connection: return self._repository.mark_all_alerts( connection, principal.user.id, date.today().isoformat(), _now() ) def delete_alert(self, principal: Principal, alert_id: int) -> None: with self._database.transaction() as connection: if not self._repository.delete_alert(connection, principal.user.id, alert_id): raise ReviewError("提醒不存在。") def messages(self, principal: Principal) -> list[dict[str, Any]]: with self._database.read() as connection: return [dict(row) for row in self._repository.messages(connection, principal.user.id)] def clear_messages(self, principal: Principal) -> int: with self._database.transaction() as connection: return self._repository.clear_messages(connection, principal.user.id) def prepare_assistant( self, principal: Principal, requested_date: str, question: str ) -> PreparedAssistant: context_date = self._trade_date(requested_date) normalized = _text(question, 2000, True) context = self._assistant_context(principal, context_date) history = self._history(principal.user.id) prompt = messages(context, history, normalized) call = self._llm.prepare( principal, feature="review_assistant", prompt_version=PROMPT_VERSION, business_id=context_date, input_chars=sum(len(item["content"]) for item in prompt), ) with self._database.transaction() as connection: self._repository.add_message( connection, principal.user.id, { "role": "user", "content": normalized, "context_date": context_date, "request_id": call.request_id, "status": "complete", }, _now(), ) return PreparedAssistant(call, prompt, context_date) def stream_assistant(self, prepared: PreparedAssistant) -> Iterator[dict[str, Any]]: answer = "" stream = self._llm.stream(prepared.call, prepared.prompt) try: for event in stream: if event.type == "delta": answer += event.content yield { "type": "delta", "content": event.content, "request_id": event.request_id, } elif event.type == "done": self._save_assistant(prepared, answer, "complete") yield {"type": "done", "request_id": event.request_id} except GeneratorExit: stream.close() if answer: self._save_assistant(prepared, answer, "stopped") raise except LLMGatewayError as exc: if answer: self._save_assistant(prepared, answer, "error") yield {"type": "error", "code": exc.code, "message": str(exc), "partial": exc.partial} def _watch_rows(self, connection, user_id: int, trade_date: str) -> list[dict[str, Any]]: factors = self._repository.latest_factors(connection, trade_date) auction_scores = self._repository.latest_auction_scores(connection, trade_date) rows = [] for watch in self._repository.watchlist(connection, user_id): factor = factors.get(str(watch["identifier"]), {}) rows.append( { **dict(watch), "code": str(watch["identifier"]).split(".")[0], "pct_chg": factor.get("pct_chg"), "return_5d": factor.get("return_5d"), "attention_score": auction_scores.get(str(watch["identifier"])), "remark": str(watch["remark"]), } ) return rows def _sync_tracking_alerts(self, user_id: int) -> None: tracks = self._screener.tracks(user_id) batches: dict[int, list[dict]] = {} for item in tracks: batches.setdefault(int(item["run_id"]), []).append(item) with self._database.transaction() as connection: for run_id, items in batches.items(): name = str(items[0]["strategy_name"]) if any(item.get("t1_return") is not None for item in items): self._repository.save_alert( connection, user_id, { "kind": "strategy_t1", "title": f"{name} 已有 T+1 反馈", "content": ( f"{sum(item.get('t1_return') is not None for item in items)}" f"/{len(items)} 只标的已有首日表现。" ), "available_date": date.today().isoformat(), "code": "", "dedupe_key": f"strategy:{run_id}:t1", }, _now(), ) if items and all(item.get("t5_return") is not None for item in items): self._repository.save_alert( connection, user_id, { "kind": "strategy_t5", "title": f"{name} 五日跟踪完成", "content": f"本批 {len(items)} 只标的已完成 T+5 跟踪。", "available_date": date.today().isoformat(), "code": "", "dedupe_key": f"strategy:{run_id}:t5", }, _now(), ) def _assistant_context(self, principal: Principal, trade_date: str) -> dict[str, Any]: summary = self._gateway.summary(trade_date) workspace = self.workspace(principal, trade_date) alerts = self.alert_center(principal)["items"][:20] return { "data_date": trade_date, "market_facts": summary["values"], "user_records": { "watchlist": workspace["watchlist"][:30], "daily_reviews": workspace["history"][:10], "strategy_tracking": self._screener.tracks(principal.user.id)[:30], "alerts": alerts, "trade_summary": workspace["trade_summary"], "trade_entries": workspace["trades"][:30], }, } def _history(self, user_id: int) -> list[dict[str, str]]: with self._database.read() as connection: rows = self._repository.messages(connection, user_id, 20) return [ {"role": str(row["role"]), "content": str(row["content"])[:4000]} for row in rows[-12:] ] def _save_assistant(self, prepared: PreparedAssistant, answer: str, status: str) -> None: if not answer: return with self._database.transaction() as connection: self._repository.add_message( connection, prepared.call.user_id, { "role": "assistant", "content": answer, "context_date": prepared.context_date, "request_id": prepared.call.request_id, "status": status, }, _now(), ) def _trade_date(self, value: str) -> str: requested = _date(value) return self._gateway.trade_context(requested).actual_date or requested def _date(value: str) -> str: try: return date.fromisoformat(value).isoformat() except ValueError as exc: raise ReviewError("日期格式无效。") from exc def _text(value: Any, limit: int, required: bool = False) -> str: normalized = " ".join(str(value or "").split()) if required and not normalized: raise ReviewError("必填内容不能为空。") if len(normalized) > limit: raise ReviewError(f"内容不能超过{limit}个字符。") return normalized def _now() -> str: return datetime.now(SHANGHAI).isoformat(timespec="seconds")