migration: preserve review journal alerts and assistant slice
This commit is contained in:
+6
-292
@@ -11,7 +11,6 @@ from http.server import BaseHTTPRequestHandler
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
|
||||
from assistant_agent import ReviewAssistantError, stream_review_assistant
|
||||
from api_access import ROUTES
|
||||
from backend.bootstrap.container import build_application_container
|
||||
from backend.bootstrap.settings import load_runtime_settings
|
||||
@@ -21,14 +20,14 @@ from backend.llm.http import LLMHttpMixin
|
||||
from backend.llm.service import LLMServiceMixin
|
||||
from backend.features.market import ChartDataError, MarketServiceMixin
|
||||
from backend.features.heaven import HeavenHttpMixin, HeavenServiceMixin, build_personal_field
|
||||
from backend.features.alerts import AlertHttpMixin, AlertServiceMixin
|
||||
from backend.features.review import ReviewHttpMixin, ReviewServiceMixin
|
||||
from backend.bootstrap.config import (
|
||||
DATA_DIR,
|
||||
MENTOR_SKILLS_DIR,
|
||||
PRIVATE_MENTOR_SKILLS_DIR,
|
||||
TOKEN_PATTERN,
|
||||
normalize_date,
|
||||
tushare_code,
|
||||
validate_stock_code,
|
||||
validate_text,
|
||||
)
|
||||
from database import ReviewDatabase
|
||||
@@ -79,6 +78,8 @@ class DashboardService(
|
||||
ScreenerServiceMixin,
|
||||
MentorServiceMixin,
|
||||
HeavenServiceMixin,
|
||||
AlertServiceMixin,
|
||||
ReviewServiceMixin,
|
||||
LLMServiceMixin,
|
||||
):
|
||||
def __init__(self) -> None:
|
||||
@@ -501,209 +502,6 @@ class DashboardService(
|
||||
return ""
|
||||
|
||||
|
||||
def alert_center(self, status: str = "all", as_of: str = "") -> dict[str, Any]:
|
||||
tracking = self.strategy_tracking.list_tracking(self.current_user_id, 12)
|
||||
self.alert_service.sync_strategy_tracking(self.current_user_id, tracking)
|
||||
return self.alert_service.list_alerts(
|
||||
self.current_user_id, status, as_of
|
||||
)
|
||||
|
||||
def create_alert(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
alert_id = self.alert_service.create_manual(self.current_user_id, payload)
|
||||
return {"id": alert_id, **self.alert_center()}
|
||||
|
||||
def mark_alert_read(self, alert_id: int) -> dict[str, Any]:
|
||||
self.alert_service.mark_read(self.current_user_id, alert_id)
|
||||
return self.alert_center()
|
||||
|
||||
def mark_all_alerts_read(self, as_of: str = "") -> dict[str, Any]:
|
||||
compact_date = self.alert_service.calendar_date(as_of or date.today().isoformat())
|
||||
self.alert_service.mark_all_read(self.current_user_id, compact_date)
|
||||
return self.alert_center(as_of=compact_date)
|
||||
|
||||
def delete_alert(self, alert_id: int) -> dict[str, Any]:
|
||||
deleted = self.alert_service.delete(self.current_user_id, alert_id)
|
||||
return {"deleted": deleted, **self.alert_center()}
|
||||
|
||||
def trade_entries(
|
||||
self, start_date: str = "", end_date: str = "", code: str = ""
|
||||
) -> dict[str, Any]:
|
||||
return self.trade_journal.list_entries(
|
||||
self.current_user_id, start_date, end_date, code
|
||||
)
|
||||
|
||||
def review_watchlist(self, trade_date: str) -> dict[str, Any]:
|
||||
normalized_date = normalize_date(trade_date)
|
||||
items = self.database.list_watchlist(self.current_user_id)
|
||||
if not items:
|
||||
return {"items": [], "trade_date": normalized_date}
|
||||
|
||||
resolved_date = normalized_date
|
||||
if self.configured:
|
||||
try:
|
||||
client = self._tushare_client()
|
||||
resolved_date, _ = client.resolve_trade_context(normalized_date)
|
||||
history = self.database.watchlist_price_history(
|
||||
[str(item["code"]) for item in items], resolved_date
|
||||
)
|
||||
missing_codes = [
|
||||
str(item["code"]) for item in items
|
||||
if len(history.get(str(item["code"])) or []) < 6
|
||||
]
|
||||
start_date = (
|
||||
datetime.strptime(resolved_date, "%Y%m%d") - timedelta(days=24)
|
||||
).strftime("%Y%m%d")
|
||||
for code in missing_codes:
|
||||
rows = client.query(
|
||||
"daily",
|
||||
{
|
||||
"ts_code": tushare_code(code),
|
||||
"start_date": start_date,
|
||||
"end_date": resolved_date,
|
||||
},
|
||||
"ts_code,trade_date,open,high,low,close,pct_chg,vol,amount",
|
||||
)
|
||||
if rows:
|
||||
self.database.upsert_daily_bars(rows)
|
||||
if missing_codes:
|
||||
history = self.database.watchlist_price_history(
|
||||
[str(item["code"]) for item in items], resolved_date
|
||||
)
|
||||
except (TushareError, ValueError):
|
||||
history = self.database.watchlist_price_history(
|
||||
[str(item["code"]) for item in items], resolved_date
|
||||
)
|
||||
else:
|
||||
history = self.database.watchlist_price_history(
|
||||
[str(item["code"]) for item in items], resolved_date
|
||||
)
|
||||
|
||||
auction_scores: dict[str, Any] = {}
|
||||
try:
|
||||
auction = self.auction_center(normalized_date, False)
|
||||
auction_scores = {
|
||||
str(row.get("code") or ""): row.get("attention_score")
|
||||
for row in (auction.get("watchlist_rows") or [])
|
||||
if row.get("available", True)
|
||||
}
|
||||
except (TushareError, ValueError):
|
||||
pass
|
||||
|
||||
enriched = []
|
||||
for item in items:
|
||||
code = str(item.get("code") or "")
|
||||
bars = history.get(code) or []
|
||||
latest = bars[-1] if bars else {}
|
||||
close = float(latest.get("close") or 0)
|
||||
base_close = float(bars[-6].get("close") or 0) if len(bars) >= 6 else 0
|
||||
enriched.append(
|
||||
{
|
||||
**item,
|
||||
"change": (
|
||||
round(float(latest.get("pct_chg") or 0), 2) if latest else None
|
||||
),
|
||||
"return_5d": (
|
||||
round((close / base_close - 1) * 100, 2)
|
||||
if close > 0 and base_close > 0 else None
|
||||
),
|
||||
"attention_score": auction_scores.get(code),
|
||||
"market_date": str(latest.get("trade_date") or ""),
|
||||
}
|
||||
)
|
||||
return {"items": enriched, "trade_date": resolved_date}
|
||||
|
||||
def save_trade_entry(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
trade_id = self.trade_journal.save(self.current_user_id, payload)
|
||||
return {"id": trade_id, **self.trade_entries()}
|
||||
|
||||
def delete_trade_entry(self, trade_id: int) -> dict[str, Any]:
|
||||
deleted = self.trade_journal.delete(self.current_user_id, trade_id)
|
||||
return {"deleted": deleted, **self.trade_entries()}
|
||||
|
||||
def assistant_messages(self) -> list[dict[str, Any]]:
|
||||
return self.database.list_assistant_messages(self.current_user_id)
|
||||
|
||||
def clear_assistant_messages(self) -> int:
|
||||
return self.database.delete_assistant_messages(self.current_user_id)
|
||||
|
||||
def assistant_stream(self, payload: dict[str, Any]):
|
||||
question = validate_text(payload.get("question"), "问题", 2000, required=True)
|
||||
trade_date = normalize_date(
|
||||
str(payload.get("trade_date") or date.today().isoformat())
|
||||
)
|
||||
context = self._assistant_context(trade_date)
|
||||
history = [
|
||||
{"role": item["role"], "content": str(item["content"])[:4000]}
|
||||
for item in self.assistant_messages()[-12:]
|
||||
if item.get("role") in {"user", "assistant"}
|
||||
]
|
||||
def generate():
|
||||
answer_parts: list[str] = []
|
||||
events = self.llm_gateway.stream(
|
||||
"assistant",
|
||||
"review-assistant-v1",
|
||||
lambda profile: stream_review_assistant(
|
||||
context,
|
||||
question,
|
||||
history,
|
||||
profile.api_key,
|
||||
profile.base_url,
|
||||
profile.model,
|
||||
),
|
||||
(ReviewAssistantError,),
|
||||
)
|
||||
for event in events:
|
||||
if event.kind == "delta":
|
||||
chunk = str(event.value or "")
|
||||
answer_parts.append(chunk)
|
||||
yield chunk
|
||||
elif event.kind == "complete":
|
||||
self.database.save_assistant_exchange(
|
||||
self.current_user_id,
|
||||
question,
|
||||
"".join(answer_parts).strip(),
|
||||
trade_date,
|
||||
)
|
||||
|
||||
return generate()
|
||||
|
||||
def _assistant_context(self, trade_date: str) -> dict[str, Any]:
|
||||
dashboard = self.get_dashboard(trade_date)
|
||||
actual_date = normalize_date(
|
||||
str((dashboard.get("meta") or {}).get("trade_date") or trade_date)
|
||||
)
|
||||
sentiment = self.sentiment_history(actual_date, 10)
|
||||
tracking = self.strategy_tracking.list_tracking(self.current_user_id, 5)
|
||||
alerts = self.alert_service.list_alerts(
|
||||
self.current_user_id, "all", date.today().isoformat()
|
||||
)
|
||||
trades = self.trade_journal.list_entries(
|
||||
self.current_user_id, end_date=actual_date
|
||||
)
|
||||
return {
|
||||
"data_date": actual_date,
|
||||
"market": {
|
||||
"overview": dashboard.get("overview") or {},
|
||||
"top_sectors": (dashboard.get("sectors") or [])[:8],
|
||||
"limit_performance": dashboard.get("limit_performance") or {},
|
||||
"sentiment_history": (sentiment.get("rows") or [])[-10:],
|
||||
},
|
||||
"personal": {
|
||||
"watchlist": self.database.list_watchlist(self.current_user_id)[:30],
|
||||
"review_notes": self.database.list_notes(
|
||||
self.current_user_id, scope="daily"
|
||||
)[:10],
|
||||
"strategy_tracking": {
|
||||
"summary": tracking.get("summary") or {},
|
||||
"batches": (tracking.get("batches") or [])[:5],
|
||||
},
|
||||
"alerts": (alerts.get("items") or [])[:20],
|
||||
"trade_summary": trades.get("summary") or {},
|
||||
"trade_entries": (trades.get("items") or [])[:30],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
SERVICE = DashboardService()
|
||||
@@ -714,6 +512,8 @@ class RequestHandler(
|
||||
SystemHttpMixin,
|
||||
MentorHttpMixin,
|
||||
HeavenHttpMixin,
|
||||
AlertHttpMixin,
|
||||
ReviewHttpMixin,
|
||||
LLMHttpMixin,
|
||||
HttpTransportMixin,
|
||||
BaseHTTPRequestHandler,
|
||||
@@ -1256,43 +1056,7 @@ class RequestHandler(
|
||||
return
|
||||
self.send_json({"error": "Not found"}, HTTPStatus.NOT_FOUND)
|
||||
|
||||
def save_alert(self) -> None:
|
||||
try:
|
||||
body = self.read_json_body()
|
||||
self.send_json({"ok": True, **SERVICE.create_alert(body)}, HTTPStatus.CREATED)
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||
|
||||
def save_trade_entry(self) -> None:
|
||||
try:
|
||||
body = self.read_json_body()
|
||||
self.send_json({"ok": True, **SERVICE.save_trade_entry(body)}, HTTPStatus.CREATED)
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||
|
||||
def stream_assistant_chat(self) -> None:
|
||||
try:
|
||||
body = self.read_json_body()
|
||||
stream = SERVICE.assistant_stream(body)
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||
return
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.send_header("Content-Type", "application/x-ndjson; charset=utf-8")
|
||||
self.send_header("Cache-Control", "no-cache, no-transform")
|
||||
self.send_header("X-Accel-Buffering", "no")
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
try:
|
||||
for chunk in stream:
|
||||
self._write_stream_event({"type": "delta", "content": chunk})
|
||||
self._write_stream_event({"type": "done"})
|
||||
except (ValueError, ReviewAssistantError) as exc:
|
||||
self._write_stream_event({"type": "error", "error": str(exc)})
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
finally:
|
||||
self.close_connection = True
|
||||
|
||||
def _write_stream_event(self, payload: dict[str, Any]) -> None:
|
||||
self.wfile.write(
|
||||
@@ -1301,56 +1065,6 @@ class RequestHandler(
|
||||
self.wfile.flush()
|
||||
|
||||
|
||||
def save_watchlist(self) -> None:
|
||||
try:
|
||||
body = self.read_json_body()
|
||||
code = validate_stock_code(str(body.get("code", "")))
|
||||
name = validate_text(body.get("name"), "股票名称", 30, required=True)
|
||||
sector = validate_text(body.get("sector"), "所属板块", 50)
|
||||
color = str(body.get("color") or "red")
|
||||
if color not in {"red", "blue", "green", "amber"}:
|
||||
raise ValueError("标记颜色不支持。")
|
||||
remark = validate_text(body.get("remark"), "跟踪备注", 240)
|
||||
SERVICE.database.save_watchlist(
|
||||
SERVICE.current_user_id, code, name, sector, color, remark
|
||||
)
|
||||
self.send_json(
|
||||
{
|
||||
"ok": True,
|
||||
"items": SERVICE.database.list_watchlist(SERVICE.current_user_id),
|
||||
}
|
||||
)
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||
|
||||
def save_note(self) -> None:
|
||||
try:
|
||||
body = self.read_json_body()
|
||||
code = str(body.get("code") or "").strip()
|
||||
if code:
|
||||
code = validate_stock_code(code)
|
||||
stock_name = validate_text(body.get("stock_name"), "股票名称", 30)
|
||||
trade_date = normalize_date(str(body.get("trade_date") or date.today().isoformat()))
|
||||
summary = validate_text(body.get("summary"), "盘面摘要", 500)
|
||||
content = validate_text(body.get("content"), "复盘内容", 5000)
|
||||
plan = validate_text(body.get("plan"), "明日计划", 2000)
|
||||
if not summary and not content and not plan:
|
||||
raise ValueError("每日复盘内容不能全部为空。")
|
||||
raw_id = body.get("id")
|
||||
note_id = int(raw_id) if raw_id else None
|
||||
saved_id = SERVICE.database.save_note(
|
||||
SERVICE.current_user_id,
|
||||
code,
|
||||
stock_name,
|
||||
trade_date,
|
||||
content,
|
||||
plan,
|
||||
note_id,
|
||||
summary=summary,
|
||||
)
|
||||
self.send_json({"ok": True, "id": saved_id})
|
||||
except (ValueError, TypeError, json.JSONDecodeError) as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||
|
||||
def save_reason(self) -> None:
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user