migration: preserve review journal alerts and assistant slice

This commit is contained in:
leefer
2026-07-31 11:55:27 +08:00
parent 11eebbf6cb
commit 26e67b3a92
13 changed files with 1077 additions and 767 deletions
+4 -88
View File
@@ -1,91 +1,7 @@
from __future__ import annotations
"""Compatibility alias for the canonical review-assistant implementation."""
import json
import urllib.error
import urllib.request
from collections.abc import Iterator
from typing import Any
import sys
from llm_stream import OpenAIStreamAccumulator
from backend.features.review import agent as _implementation
class ReviewAssistantError(RuntimeError):
pass
def stream_review_assistant(
context: dict[str, Any],
question: str,
history: list[dict[str, str]],
api_key: str,
base_url: str,
model: str,
timeout: int = 120,
) -> Iterator[str]:
if not api_key or not model:
raise ReviewAssistantError("智能解读服务尚未配置。")
messages = [{"role": "system", "content": _system_prompt(context)}]
messages.extend(history[-12:])
messages.append({"role": "user", "content": question})
request = urllib.request.Request(
f"{base_url.rstrip('/')}/chat/completions",
data=json.dumps(
{"model": model, "messages": messages, "stream": True}, ensure_ascii=False
).encode("utf-8"),
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
"User-Agent": "XiaobaiReviewWeb/1.0",
"Accept": "text/event-stream",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
yielded = False
accumulator = OpenAIStreamAccumulator()
for raw_line in response:
line = raw_line.decode("utf-8", errors="replace").strip()
if not line or line.startswith(":"):
continue
if line.startswith("data:"):
line = line[5:].strip()
if line == "[DONE]":
break
try:
payload = json.loads(line)
except json.JSONDecodeError:
continue
choices = payload.get("choices") or []
if not choices:
continue
choice = choices[0] or {}
content = accumulator.feed(choice)
if content:
yielded = True
yield str(content)
if not yielded:
raise ReviewAssistantError("智能解读未返回有效内容。")
except urllib.error.HTTPError as exc:
raise ReviewAssistantError(f"智能解读服务暂不可用({exc.code})。") from exc
except (urllib.error.URLError, TimeoutError, OSError) as exc:
raise ReviewAssistantError("智能解读连接中断,请稍后重试。") from exc
def _system_prompt(context: dict[str, Any]) -> str:
context_json = json.dumps(context, ensure_ascii=False, separators=(",", ":"))
return f"""
你是“小白复盘”的统一复盘助手。你负责把网页中已经存在的市场统计、策略跟踪、提醒、复盘笔记和手工交易日志连接起来,帮助用户复盘和形成下一步观察计划。
最高优先级规则:
1. 只能使用下方“网页复盘数据”,数据缺失就明确说明,不得补造行情、交易或胜率。
2. 不自动下单,不声称已执行任何操作,不修改策略、提醒、笔记或交易日志。
3. 不承诺收益,不给无条件买卖指令。建议必须写成条件、失效条件和风险边界。
4. 区分市场事实、用户记录和你的推断。引用数字时写明数据日期。
5. 优先结合用户自己的策略跟踪与交易日志寻找可验证的重复模式;样本不足时明确标注。
6. 使用中文,先直接回答,再给数据依据和下一步观察。避免空泛口号,不展示模型、接口或内部工程信息。
7. 控制在 800 个中文字符以内,除非用户明确要求展开。
网页复盘数据:
{context_json}
""".strip()
sys.modules[__name__] = _implementation
+6 -292
View File
@@ -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:
+17 -2
View File
@@ -1,3 +1,18 @@
from .service import AlertService
from .facade import AlertServiceMixin
from .http import AlertHttpMixin
from .repository import AlertRepositoryMixin
__all__ = ["AlertService"]
__all__ = [
"AlertHttpMixin",
"AlertRepositoryMixin",
"AlertService",
"AlertServiceMixin",
]
def __getattr__(name: str):
if name == "AlertService":
from .service import AlertService
return AlertService
raise AttributeError(name)
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
from datetime import date
from typing import Any
class AlertServiceMixin:
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()}
+16
View File
@@ -0,0 +1,16 @@
from __future__ import annotations
import json
from http import HTTPStatus
class AlertHttpMixin:
def save_alert(self) -> None:
try:
body = self.read_json_body()
self.send_json(
{"ok": True, **self.application_service.create_alert(body)},
HTTPStatus.CREATED,
)
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
+110
View File
@@ -0,0 +1,110 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
class AlertRepositoryMixin:
def save_alert(
self,
user_id: int,
kind: str,
title: str,
content: str,
available_date: str,
code: str,
dedupe_key: str,
) -> int:
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
connection.execute(
"""
INSERT INTO alerts
(user_id, kind, title, content, available_date, code, dedupe_key,
is_read, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
ON CONFLICT(user_id, dedupe_key) DO UPDATE SET
title=excluded.title, content=excluded.content,
available_date=excluded.available_date, updated_at=excluded.updated_at
""",
(
int(user_id), kind, title, content, available_date, code,
dedupe_key, now, now,
),
)
row = connection.execute(
"SELECT id FROM alerts WHERE user_id = ? AND dedupe_key = ?",
(int(user_id), dedupe_key),
).fetchone()
return int(row["id"])
def list_alerts(
self, user_id: int, as_of: str, unread_only: bool = False, limit: int = 100
) -> list[dict[str, Any]]:
with self.connect() as connection:
if unread_only:
rows = connection.execute(
"""
SELECT id, kind, title, content, available_date, code, is_read,
created_at, updated_at, read_at
FROM alerts
WHERE user_id = ? AND available_date <= ? AND is_read = 0
ORDER BY available_date DESC, id DESC LIMIT ?
""",
(int(user_id), as_of, max(1, min(300, int(limit)))),
).fetchall()
else:
rows = connection.execute(
"""
SELECT id, kind, title, content, available_date, code, is_read,
created_at, updated_at, read_at
FROM alerts WHERE user_id = ?
ORDER BY CASE WHEN available_date > ? THEN 0 ELSE 1 END,
is_read, available_date, id DESC LIMIT ?
""",
(int(user_id), as_of, max(1, min(300, int(limit)))),
).fetchall()
return [{**dict(row), "is_read": bool(row["is_read"])} for row in rows]
def count_unread_alerts(self, user_id: int, as_of: str) -> int:
with self.connect() as connection:
row = connection.execute(
"""
SELECT COUNT(*) AS total FROM alerts
WHERE user_id = ? AND available_date <= ? AND is_read = 0
""",
(int(user_id), as_of),
).fetchone()
return int(row["total"] if row else 0)
def mark_alert_read(self, user_id: int, alert_id: int) -> bool:
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
cursor = connection.execute(
"""
UPDATE alerts SET is_read = 1, read_at = ?, updated_at = ?
WHERE id = ? AND user_id = ?
""",
(now, now, int(alert_id), int(user_id)),
)
return cursor.rowcount > 0
def mark_all_alerts_read(self, user_id: int, as_of: str) -> int:
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
cursor = connection.execute(
"""
UPDATE alerts SET is_read = 1, read_at = ?, updated_at = ?
WHERE user_id = ? AND available_date <= ? AND is_read = 0
""",
(now, now, int(user_id), as_of),
)
return int(cursor.rowcount)
def delete_alert(self, user_id: int, alert_id: int) -> bool:
with self.connect() as connection:
cursor = connection.execute(
"DELETE FROM alerts WHERE id = ? AND user_id = ?",
(int(alert_id), int(user_id)),
)
return cursor.rowcount > 0
+22 -2
View File
@@ -1,3 +1,23 @@
from .trade_journal import EMOTIONS, TRADE_ACTIONS, TradeJournalService
from .agent import ReviewAssistantError, stream_review_assistant
from .http import ReviewHttpMixin
from .repository import ReviewRepositoryMixin
from .service import ReviewServiceMixin
__all__ = ["EMOTIONS", "TRADE_ACTIONS", "TradeJournalService"]
__all__ = [
"EMOTIONS",
"ReviewAssistantError",
"ReviewHttpMixin",
"ReviewRepositoryMixin",
"ReviewServiceMixin",
"TRADE_ACTIONS",
"TradeJournalService",
"stream_review_assistant",
]
def __getattr__(name: str):
if name in {"EMOTIONS", "TRADE_ACTIONS", "TradeJournalService"}:
from . import trade_journal
return getattr(trade_journal, name)
raise AttributeError(name)
+91
View File
@@ -0,0 +1,91 @@
from __future__ import annotations
import json
import urllib.error
import urllib.request
from collections.abc import Iterator
from typing import Any
from llm_stream import OpenAIStreamAccumulator
class ReviewAssistantError(RuntimeError):
pass
def stream_review_assistant(
context: dict[str, Any],
question: str,
history: list[dict[str, str]],
api_key: str,
base_url: str,
model: str,
timeout: int = 120,
) -> Iterator[str]:
if not api_key or not model:
raise ReviewAssistantError("智能解读服务尚未配置。")
messages = [{"role": "system", "content": _system_prompt(context)}]
messages.extend(history[-12:])
messages.append({"role": "user", "content": question})
request = urllib.request.Request(
f"{base_url.rstrip('/')}/chat/completions",
data=json.dumps(
{"model": model, "messages": messages, "stream": True}, ensure_ascii=False
).encode("utf-8"),
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
"User-Agent": "XiaobaiReviewWeb/1.0",
"Accept": "text/event-stream",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
yielded = False
accumulator = OpenAIStreamAccumulator()
for raw_line in response:
line = raw_line.decode("utf-8", errors="replace").strip()
if not line or line.startswith(":"):
continue
if line.startswith("data:"):
line = line[5:].strip()
if line == "[DONE]":
break
try:
payload = json.loads(line)
except json.JSONDecodeError:
continue
choices = payload.get("choices") or []
if not choices:
continue
choice = choices[0] or {}
content = accumulator.feed(choice)
if content:
yielded = True
yield str(content)
if not yielded:
raise ReviewAssistantError("智能解读未返回有效内容。")
except urllib.error.HTTPError as exc:
raise ReviewAssistantError(f"智能解读服务暂不可用({exc.code})。") from exc
except (urllib.error.URLError, TimeoutError, OSError) as exc:
raise ReviewAssistantError("智能解读连接中断,请稍后重试。") from exc
def _system_prompt(context: dict[str, Any]) -> str:
context_json = json.dumps(context, ensure_ascii=False, separators=(",", ":"))
return f"""
你是“小白复盘”的统一复盘助手。你负责把网页中已经存在的市场统计、策略跟踪、提醒、复盘笔记和手工交易日志连接起来,帮助用户复盘和形成下一步观察计划。
最高优先级规则:
1. 只能使用下方“网页复盘数据”,数据缺失就明确说明,不得补造行情、交易或胜率。
2. 不自动下单,不声称已执行任何操作,不修改策略、提醒、笔记或交易日志。
3. 不承诺收益,不给无条件买卖指令。建议必须写成条件、失效条件和风险边界。
4. 区分市场事实、用户记录和你的推断。引用数字时写明数据日期。
5. 优先结合用户自己的策略跟踪与交易日志寻找可验证的重复模式;样本不足时明确标注。
6. 使用中文,先直接回答,再给数据依据和下一步观察。避免空泛口号,不展示模型、接口或内部工程信息。
7. 控制在 800 个中文字符以内,除非用户明确要求展开。
网页复盘数据:
{context_json}
""".strip()
+97
View File
@@ -0,0 +1,97 @@
from __future__ import annotations
import json
from datetime import date
from http import HTTPStatus
from backend.bootstrap.config import normalize_date, validate_stock_code, validate_text
from backend.features.review.agent import ReviewAssistantError
class ReviewHttpMixin:
def save_trade_entry(self) -> None:
try:
body = self.read_json_body()
self.send_json(
{"ok": True, **self.application_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 = self.application_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 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 = self.application_service
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
service = self.application_service
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)
+281
View File
@@ -0,0 +1,281 @@
from __future__ import annotations
import json
from datetime import datetime
from typing import Any
class ReviewRepositoryMixin:
def list_watchlist(self, user_id: int) -> list[dict[str, Any]]:
with self.connect() as connection:
rows = connection.execute(
"""
SELECT code, name, sector, color, remark, created_at, updated_at
FROM watchlist WHERE user_id = ? ORDER BY updated_at DESC, code
""",
(int(user_id),),
).fetchall()
return [dict(row) for row in rows]
def save_watchlist(
self, user_id: int, code: str, name: str, sector: str, color: str,
remark: str | None = None,
) -> None:
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
existing = connection.execute(
"SELECT remark FROM watchlist WHERE user_id = ? AND code = ?",
(int(user_id), code),
).fetchone()
saved_remark = (
str(existing["remark"] or "") if remark is None and existing else str(remark or "")
)
connection.execute(
"""
INSERT INTO watchlist
(user_id, code, name, sector, color, remark, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id, code) DO UPDATE SET
name = excluded.name,
sector = excluded.sector,
color = excluded.color,
remark = excluded.remark,
updated_at = excluded.updated_at
""",
(int(user_id), code, name, sector, color, saved_remark, now, now),
)
def watchlist_price_history(
self, codes: list[str], end_date: str, limit_per_code: int = 6
) -> dict[str, list[dict[str, Any]]]:
result: dict[str, list[dict[str, Any]]] = {}
if not codes:
return result
with self.connect() as connection:
for code in codes:
rows = connection.execute(
"""
SELECT trade_date, ts_code, close, pct_chg
FROM daily_bars
WHERE substr(ts_code, 1, 6) = ? AND trade_date <= ?
ORDER BY trade_date DESC LIMIT ?
""",
(str(code), end_date, int(limit_per_code)),
).fetchall()
result[str(code)] = [dict(row) for row in reversed(rows)]
return result
def delete_watchlist(self, user_id: int, code: str) -> bool:
with self.connect() as connection:
cursor = connection.execute(
"DELETE FROM watchlist WHERE user_id = ? AND code = ?",
(int(user_id), code),
)
return cursor.rowcount > 0
def list_notes(
self,
user_id: int,
code: str = "",
trade_date: str = "",
scope: str = "all",
) -> list[dict[str, Any]]:
clauses: list[str] = ["user_id = ?"]
parameters: list[Any] = [int(user_id)]
if scope == "daily":
clauses.append("code = ''")
elif scope == "stock":
clauses.append("code <> ''")
if code:
clauses.append("code = ?")
parameters.append(code)
if trade_date:
clauses.append("trade_date = ?")
parameters.append(trade_date)
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
with self.connect() as connection:
rows = connection.execute(
f"""
SELECT id, code, stock_name, trade_date, summary, content, plan, created_at, updated_at
FROM review_notes {where}
ORDER BY trade_date DESC, updated_at DESC, id DESC LIMIT 200
""",
parameters,
).fetchall()
return [dict(row) for row in rows]
def save_note(
self,
user_id: int,
code: str,
stock_name: str,
trade_date: str,
content: str,
plan: str,
note_id: int | None = None,
summary: str = "",
) -> int:
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
if note_id:
cursor = connection.execute(
"""
UPDATE review_notes
SET code = ?, stock_name = ?, trade_date = ?, summary = ?, content = ?, plan = ?, updated_at = ?
WHERE id = ? AND user_id = ?
""",
(code, stock_name, trade_date, summary, content, plan, now, note_id, int(user_id)),
)
if cursor.rowcount == 0:
raise ValueError("复盘笔记不存在。")
return note_id
cursor = connection.execute(
"""
INSERT INTO review_notes
(user_id, code, stock_name, trade_date, summary, content, plan, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(int(user_id), code, stock_name, trade_date, summary, content, plan, now, now),
)
return int(cursor.lastrowid)
def delete_note(self, user_id: int, note_id: int) -> bool:
with self.connect() as connection:
cursor = connection.execute(
"DELETE FROM review_notes WHERE id = ? AND user_id = ?",
(note_id, int(user_id)),
)
return cursor.rowcount > 0
def save_trade_entry(
self,
user_id: int,
trade_date: str,
code: str,
name: str,
action: str,
price: float,
quantity: int,
position_pct: float,
pnl_amount: float | None,
pnl_pct: float | None,
thesis: str,
execution: str,
emotion: str,
tags: list[str],
trade_id: int | None = None,
) -> int:
now = datetime.now().astimezone().isoformat(timespec="seconds")
tags_json = json.dumps(tags, ensure_ascii=False, separators=(",", ":"))
with self.connect() as connection:
if trade_id:
cursor = connection.execute(
"""
UPDATE trade_entries SET
trade_date=?, code=?, name=?, action=?, price=?, quantity=?,
position_pct=?, pnl_amount=?, pnl_pct=?, thesis=?, execution=?,
emotion=?, tags=?, updated_at=?
WHERE id=? AND user_id=?
""",
(
trade_date, code, name, action, price, quantity, position_pct,
pnl_amount, pnl_pct, thesis, execution, emotion, tags_json, now,
int(trade_id), int(user_id),
),
)
if cursor.rowcount == 0:
raise ValueError("交易记录不存在或无权修改。")
return int(trade_id)
cursor = connection.execute(
"""
INSERT INTO trade_entries
(user_id, trade_date, code, name, action, price, quantity,
position_pct, pnl_amount, pnl_pct, thesis, execution, emotion,
tags, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
int(user_id), trade_date, code, name, action, price, quantity,
position_pct, pnl_amount, pnl_pct, thesis, execution, emotion,
tags_json, now, now,
),
)
return int(cursor.lastrowid)
def list_trade_entries(
self, user_id: int, start_date: str = "", end_date: str = "", code: str = "",
limit: int = 300,
) -> list[dict[str, Any]]:
clauses = ["user_id = ?"]
parameters: list[Any] = [int(user_id)]
if start_date:
clauses.append("trade_date >= ?")
parameters.append(start_date)
if end_date:
clauses.append("trade_date <= ?")
parameters.append(end_date)
if code:
clauses.append("code = ?")
parameters.append(code)
parameters.append(max(1, min(1000, int(limit))))
with self.connect() as connection:
rows = connection.execute(
f"""
SELECT * FROM trade_entries WHERE {' AND '.join(clauses)}
ORDER BY trade_date DESC, id DESC LIMIT ?
""",
parameters,
).fetchall()
return [dict(row) for row in rows]
def delete_trade_entry(self, user_id: int, trade_id: int) -> bool:
with self.connect() as connection:
cursor = connection.execute(
"DELETE FROM trade_entries WHERE id = ? AND user_id = ?",
(int(trade_id), int(user_id)),
)
return cursor.rowcount > 0
def save_assistant_exchange(
self, user_id: int, question: str, answer: str, context_date: str
) -> None:
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
connection.executemany(
"""
INSERT INTO assistant_messages
(user_id, role, content, context_date, created_at)
VALUES (?, ?, ?, ?, ?)
""",
[
(int(user_id), "user", question, context_date, now),
(int(user_id), "assistant", answer, context_date, now),
],
)
connection.execute(
"""
DELETE FROM assistant_messages WHERE user_id = ? AND id NOT IN (
SELECT id FROM assistant_messages
WHERE user_id = ? ORDER BY id DESC LIMIT 200
)
""",
(int(user_id), int(user_id)),
)
def list_assistant_messages(self, user_id: int, limit: int = 100) -> list[dict[str, Any]]:
with self.connect() as connection:
rows = connection.execute(
"""
SELECT role, content, context_date, created_at FROM assistant_messages
WHERE user_id = ? ORDER BY id DESC LIMIT ?
""",
(int(user_id), max(1, min(200, int(limit)))),
).fetchall()
return [dict(row) for row in reversed(rows)]
def delete_assistant_messages(self, user_id: int) -> int:
with self.connect() as connection:
cursor = connection.execute(
"DELETE FROM assistant_messages WHERE user_id = ?", (int(user_id),)
)
return int(cursor.rowcount)
+188
View File
@@ -0,0 +1,188 @@
from __future__ import annotations
from datetime import date, datetime, timedelta
from typing import Any
from backend.bootstrap.config import normalize_date, tushare_code, validate_text
from backend.data.providers.tushare_client import TushareError
from backend.features.review.agent import ReviewAssistantError, stream_review_assistant
class ReviewServiceMixin:
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],
},
}
+4 -383
View File
@@ -8,6 +8,7 @@ from typing import Any
from backend.database import MIGRATIONS, MigrationRunner, SQLiteConnectionFactory
from backend.features.accounts.repository import AccountRepositoryMixin
from backend.features.alerts.repository import AlertRepositoryMixin
from backend.features.auction.repository import AuctionRepositoryMixin
from backend.features.dragon_tiger.repository import DragonTigerRepositoryMixin
from backend.features.heaven.repository import HeavenRepositoryMixin
@@ -15,6 +16,7 @@ from backend.features.market.repository import MarketRepositoryMixin
from backend.features.mentor.repository import MentorRepositoryMixin
from backend.features.pools.repository import PoolRepositoryMixin
from backend.features.popularity.repository import PopularityRepositoryMixin
from backend.features.review.repository import ReviewRepositoryMixin
from backend.features.screener.repository import ScreenerRepositoryMixin
from backend.features.system.repository import SystemSettingsRepositoryMixin
from backend.llm.repository import LLMAuditRepositoryMixin
@@ -22,6 +24,7 @@ from backend.llm.repository import LLMAuditRepositoryMixin
class ReviewDatabase(
AccountRepositoryMixin,
AlertRepositoryMixin,
AuctionRepositoryMixin,
DragonTigerRepositoryMixin,
HeavenRepositoryMixin,
@@ -29,6 +32,7 @@ class ReviewDatabase(
MentorRepositoryMixin,
PoolRepositoryMixin,
PopularityRepositoryMixin,
ReviewRepositoryMixin,
ScreenerRepositoryMixin,
SystemSettingsRepositoryMixin,
LLMAuditRepositoryMixin,
@@ -662,147 +666,6 @@ class ReviewDatabase(
MigrationRunner().apply(connection, MIGRATIONS)
def list_watchlist(self, user_id: int) -> list[dict[str, Any]]:
with self.connect() as connection:
rows = connection.execute(
"""
SELECT code, name, sector, color, remark, created_at, updated_at
FROM watchlist WHERE user_id = ? ORDER BY updated_at DESC, code
""",
(int(user_id),),
).fetchall()
return [dict(row) for row in rows]
def save_watchlist(
self, user_id: int, code: str, name: str, sector: str, color: str,
remark: str | None = None,
) -> None:
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
existing = connection.execute(
"SELECT remark FROM watchlist WHERE user_id = ? AND code = ?",
(int(user_id), code),
).fetchone()
saved_remark = (
str(existing["remark"] or "") if remark is None and existing else str(remark or "")
)
connection.execute(
"""
INSERT INTO watchlist
(user_id, code, name, sector, color, remark, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id, code) DO UPDATE SET
name = excluded.name,
sector = excluded.sector,
color = excluded.color,
remark = excluded.remark,
updated_at = excluded.updated_at
""",
(int(user_id), code, name, sector, color, saved_remark, now, now),
)
def watchlist_price_history(
self, codes: list[str], end_date: str, limit_per_code: int = 6
) -> dict[str, list[dict[str, Any]]]:
result: dict[str, list[dict[str, Any]]] = {}
if not codes:
return result
with self.connect() as connection:
for code in codes:
rows = connection.execute(
"""
SELECT trade_date, ts_code, close, pct_chg
FROM daily_bars
WHERE substr(ts_code, 1, 6) = ? AND trade_date <= ?
ORDER BY trade_date DESC LIMIT ?
""",
(str(code), end_date, int(limit_per_code)),
).fetchall()
result[str(code)] = [dict(row) for row in reversed(rows)]
return result
def delete_watchlist(self, user_id: int, code: str) -> bool:
with self.connect() as connection:
cursor = connection.execute(
"DELETE FROM watchlist WHERE user_id = ? AND code = ?",
(int(user_id), code),
)
return cursor.rowcount > 0
def list_notes(
self,
user_id: int,
code: str = "",
trade_date: str = "",
scope: str = "all",
) -> list[dict[str, Any]]:
clauses: list[str] = ["user_id = ?"]
parameters: list[Any] = [int(user_id)]
if scope == "daily":
clauses.append("code = ''")
elif scope == "stock":
clauses.append("code <> ''")
if code:
clauses.append("code = ?")
parameters.append(code)
if trade_date:
clauses.append("trade_date = ?")
parameters.append(trade_date)
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
with self.connect() as connection:
rows = connection.execute(
f"""
SELECT id, code, stock_name, trade_date, summary, content, plan, created_at, updated_at
FROM review_notes {where}
ORDER BY trade_date DESC, updated_at DESC, id DESC LIMIT 200
""",
parameters,
).fetchall()
return [dict(row) for row in rows]
def save_note(
self,
user_id: int,
code: str,
stock_name: str,
trade_date: str,
content: str,
plan: str,
note_id: int | None = None,
summary: str = "",
) -> int:
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
if note_id:
cursor = connection.execute(
"""
UPDATE review_notes
SET code = ?, stock_name = ?, trade_date = ?, summary = ?, content = ?, plan = ?, updated_at = ?
WHERE id = ? AND user_id = ?
""",
(code, stock_name, trade_date, summary, content, plan, now, note_id, int(user_id)),
)
if cursor.rowcount == 0:
raise ValueError("复盘笔记不存在。")
return note_id
cursor = connection.execute(
"""
INSERT INTO review_notes
(user_id, code, stock_name, trade_date, summary, content, plan, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(int(user_id), code, stock_name, trade_date, summary, content, plan, now, now),
)
return int(cursor.lastrowid)
def delete_note(self, user_id: int, note_id: int) -> bool:
with self.connect() as connection:
cursor = connection.execute(
"DELETE FROM review_notes WHERE id = ? AND user_id = ?",
(note_id, int(user_id)),
)
return cursor.rowcount > 0
def list_sector_phase_overrides(self) -> dict[str, str]:
with self.connect() as connection:
@@ -832,10 +695,6 @@ class ReviewDatabase(
(name,),
)
return cursor.rowcount > 0
def list_wencai_saved_queries(
self, user_id: int, limit: int = 30
) -> list[dict[str, Any]]:
@@ -885,241 +744,3 @@ class ReviewDatabase(
(int(query_id), int(user_id)),
)
return cursor.rowcount > 0
def save_alert(
self,
user_id: int,
kind: str,
title: str,
content: str,
available_date: str,
code: str,
dedupe_key: str,
) -> int:
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
connection.execute(
"""
INSERT INTO alerts
(user_id, kind, title, content, available_date, code, dedupe_key,
is_read, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
ON CONFLICT(user_id, dedupe_key) DO UPDATE SET
title=excluded.title, content=excluded.content,
available_date=excluded.available_date, updated_at=excluded.updated_at
""",
(
int(user_id), kind, title, content, available_date, code,
dedupe_key, now, now,
),
)
row = connection.execute(
"SELECT id FROM alerts WHERE user_id = ? AND dedupe_key = ?",
(int(user_id), dedupe_key),
).fetchone()
return int(row["id"])
def list_alerts(
self, user_id: int, as_of: str, unread_only: bool = False, limit: int = 100
) -> list[dict[str, Any]]:
with self.connect() as connection:
if unread_only:
rows = connection.execute(
"""
SELECT id, kind, title, content, available_date, code, is_read,
created_at, updated_at, read_at
FROM alerts
WHERE user_id = ? AND available_date <= ? AND is_read = 0
ORDER BY available_date DESC, id DESC LIMIT ?
""",
(int(user_id), as_of, max(1, min(300, int(limit)))),
).fetchall()
else:
rows = connection.execute(
"""
SELECT id, kind, title, content, available_date, code, is_read,
created_at, updated_at, read_at
FROM alerts WHERE user_id = ?
ORDER BY CASE WHEN available_date > ? THEN 0 ELSE 1 END,
is_read, available_date, id DESC LIMIT ?
""",
(int(user_id), as_of, max(1, min(300, int(limit)))),
).fetchall()
return [{**dict(row), "is_read": bool(row["is_read"])} for row in rows]
def count_unread_alerts(self, user_id: int, as_of: str) -> int:
with self.connect() as connection:
row = connection.execute(
"""
SELECT COUNT(*) AS total FROM alerts
WHERE user_id = ? AND available_date <= ? AND is_read = 0
""",
(int(user_id), as_of),
).fetchone()
return int(row["total"] if row else 0)
def mark_alert_read(self, user_id: int, alert_id: int) -> bool:
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
cursor = connection.execute(
"""
UPDATE alerts SET is_read = 1, read_at = ?, updated_at = ?
WHERE id = ? AND user_id = ?
""",
(now, now, int(alert_id), int(user_id)),
)
return cursor.rowcount > 0
def mark_all_alerts_read(self, user_id: int, as_of: str) -> int:
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
cursor = connection.execute(
"""
UPDATE alerts SET is_read = 1, read_at = ?, updated_at = ?
WHERE user_id = ? AND available_date <= ? AND is_read = 0
""",
(now, now, int(user_id), as_of),
)
return int(cursor.rowcount)
def delete_alert(self, user_id: int, alert_id: int) -> bool:
with self.connect() as connection:
cursor = connection.execute(
"DELETE FROM alerts WHERE id = ? AND user_id = ?",
(int(alert_id), int(user_id)),
)
return cursor.rowcount > 0
def save_trade_entry(
self,
user_id: int,
trade_date: str,
code: str,
name: str,
action: str,
price: float,
quantity: int,
position_pct: float,
pnl_amount: float | None,
pnl_pct: float | None,
thesis: str,
execution: str,
emotion: str,
tags: list[str],
trade_id: int | None = None,
) -> int:
now = datetime.now().astimezone().isoformat(timespec="seconds")
tags_json = json.dumps(tags, ensure_ascii=False, separators=(",", ":"))
with self.connect() as connection:
if trade_id:
cursor = connection.execute(
"""
UPDATE trade_entries SET
trade_date=?, code=?, name=?, action=?, price=?, quantity=?,
position_pct=?, pnl_amount=?, pnl_pct=?, thesis=?, execution=?,
emotion=?, tags=?, updated_at=?
WHERE id=? AND user_id=?
""",
(
trade_date, code, name, action, price, quantity, position_pct,
pnl_amount, pnl_pct, thesis, execution, emotion, tags_json, now,
int(trade_id), int(user_id),
),
)
if cursor.rowcount == 0:
raise ValueError("交易记录不存在或无权修改。")
return int(trade_id)
cursor = connection.execute(
"""
INSERT INTO trade_entries
(user_id, trade_date, code, name, action, price, quantity,
position_pct, pnl_amount, pnl_pct, thesis, execution, emotion,
tags, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
int(user_id), trade_date, code, name, action, price, quantity,
position_pct, pnl_amount, pnl_pct, thesis, execution, emotion,
tags_json, now, now,
),
)
return int(cursor.lastrowid)
def list_trade_entries(
self, user_id: int, start_date: str = "", end_date: str = "", code: str = "",
limit: int = 300,
) -> list[dict[str, Any]]:
clauses = ["user_id = ?"]
parameters: list[Any] = [int(user_id)]
if start_date:
clauses.append("trade_date >= ?")
parameters.append(start_date)
if end_date:
clauses.append("trade_date <= ?")
parameters.append(end_date)
if code:
clauses.append("code = ?")
parameters.append(code)
parameters.append(max(1, min(1000, int(limit))))
with self.connect() as connection:
rows = connection.execute(
f"""
SELECT * FROM trade_entries WHERE {' AND '.join(clauses)}
ORDER BY trade_date DESC, id DESC LIMIT ?
""",
parameters,
).fetchall()
return [dict(row) for row in rows]
def delete_trade_entry(self, user_id: int, trade_id: int) -> bool:
with self.connect() as connection:
cursor = connection.execute(
"DELETE FROM trade_entries WHERE id = ? AND user_id = ?",
(int(trade_id), int(user_id)),
)
return cursor.rowcount > 0
def save_assistant_exchange(
self, user_id: int, question: str, answer: str, context_date: str
) -> None:
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
connection.executemany(
"""
INSERT INTO assistant_messages
(user_id, role, content, context_date, created_at)
VALUES (?, ?, ?, ?, ?)
""",
[
(int(user_id), "user", question, context_date, now),
(int(user_id), "assistant", answer, context_date, now),
],
)
connection.execute(
"""
DELETE FROM assistant_messages WHERE user_id = ? AND id NOT IN (
SELECT id FROM assistant_messages
WHERE user_id = ? ORDER BY id DESC LIMIT 200
)
""",
(int(user_id), int(user_id)),
)
def list_assistant_messages(self, user_id: int, limit: int = 100) -> list[dict[str, Any]]:
with self.connect() as connection:
rows = connection.execute(
"""
SELECT role, content, context_date, created_at FROM assistant_messages
WHERE user_id = ? ORDER BY id DESC LIMIT ?
""",
(int(user_id), max(1, min(200, int(limit)))),
).fetchall()
return [dict(row) for row in reversed(rows)]
def delete_assistant_messages(self, user_id: int) -> int:
with self.connect() as connection:
cursor = connection.execute(
"DELETE FROM assistant_messages WHERE user_id = ?", (int(user_id),)
)
return int(cursor.rowcount)
@@ -0,0 +1,211 @@
from __future__ import annotations
import ast
import hashlib
import unittest
from pathlib import Path
import assistant_agent
from backend.features.review import agent as canonical_agent
APP_ROOT = Path(__file__).resolve().parents[1]
ORIGINAL_ROOT = APP_ROOT.parent
ALERT_SERVICE_METHODS = {
"alert_center",
"create_alert",
"mark_alert_read",
"mark_all_alerts_read",
"delete_alert",
}
REVIEW_SERVICE_METHODS = {
"trade_entries",
"review_watchlist",
"save_trade_entry",
"delete_trade_entry",
"assistant_messages",
"clear_assistant_messages",
"assistant_stream",
"_assistant_context",
}
ALERT_REPOSITORY_METHODS = {
"save_alert",
"list_alerts",
"count_unread_alerts",
"mark_alert_read",
"mark_all_alerts_read",
"delete_alert",
}
REVIEW_REPOSITORY_METHODS = {
"list_watchlist",
"save_watchlist",
"watchlist_price_history",
"delete_watchlist",
"list_notes",
"save_note",
"delete_note",
"save_trade_entry",
"list_trade_entries",
"delete_trade_entry",
"save_assistant_exchange",
"list_assistant_messages",
"delete_assistant_messages",
}
ALERT_HTTP_METHODS = {"save_alert"}
REVIEW_HTTP_METHODS = {
"save_trade_entry",
"stream_assistant_chat",
"save_watchlist",
"save_note",
}
def sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def class_methods(path: Path, class_name: str) -> dict[str, str]:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
owner = next(
node
for node in tree.body
if isinstance(node, ast.ClassDef) and node.name == class_name
)
return {
node.name: ast.dump(node, include_attributes=False)
for node in owner.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
}
def top_level_definition(path: Path, name: str) -> str:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
node = next(
item
for item in tree.body
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
and item.name == name
)
return ast.dump(node, include_attributes=False)
class ReviewAlertsSliceSourceEquivalenceTests(unittest.TestCase):
def assert_methods_equal(
self,
original_path: Path,
original_class: str,
migrated_path: Path,
migrated_class: str,
expected: set[str],
) -> None:
original = class_methods(original_path, original_class)
migrated = class_methods(migrated_path, migrated_class)
self.assertEqual(set(migrated), expected)
for name in sorted(expected):
self.assertEqual(migrated[name], original[name], name)
def test_review_assistant_agent_is_an_exact_file(self) -> None:
self.assertEqual(
sha256(ORIGINAL_ROOT / "assistant_agent.py"),
sha256(APP_ROOT / "backend" / "features" / "review" / "agent.py"),
)
def test_review_assistant_compatibility_module_is_canonical(self) -> None:
self.assertIs(assistant_agent, canonical_agent)
def test_alert_and_review_service_methods_are_exact_original_ast(self) -> None:
self.assert_methods_equal(
ORIGINAL_ROOT / "server.py",
"DashboardService",
APP_ROOT / "backend" / "features" / "alerts" / "facade.py",
"AlertServiceMixin",
ALERT_SERVICE_METHODS,
)
self.assert_methods_equal(
ORIGINAL_ROOT / "server.py",
"DashboardService",
APP_ROOT / "backend" / "features" / "review" / "service.py",
"ReviewServiceMixin",
REVIEW_SERVICE_METHODS,
)
def test_alert_and_review_repository_methods_are_exact_original_ast(self) -> None:
self.assert_methods_equal(
ORIGINAL_ROOT / "database.py",
"ReviewDatabase",
APP_ROOT / "backend" / "features" / "alerts" / "repository.py",
"AlertRepositoryMixin",
ALERT_REPOSITORY_METHODS,
)
self.assert_methods_equal(
ORIGINAL_ROOT / "database.py",
"ReviewDatabase",
APP_ROOT / "backend" / "features" / "review" / "repository.py",
"ReviewRepositoryMixin",
REVIEW_REPOSITORY_METHODS,
)
def test_existing_alert_and_trade_journal_services_preserve_original_classes(self) -> None:
self.assertEqual(
top_level_definition(
ORIGINAL_ROOT / "backend" / "features" / "alerts" / "service.py",
"AlertService",
),
top_level_definition(
APP_ROOT / "backend" / "features" / "alerts" / "service.py",
"AlertService",
),
)
self.assertEqual(
top_level_definition(
ORIGINAL_ROOT / "backend" / "features" / "review" / "trade_journal.py",
"TradeJournalService",
),
top_level_definition(
APP_ROOT / "backend" / "features" / "review" / "trade_journal.py",
"TradeJournalService",
),
)
def test_original_classes_no_longer_duplicate_moved_methods(self) -> None:
remaining_service = class_methods(
APP_ROOT / "backend" / "application.py", "DashboardService"
)
remaining_database = class_methods(APP_ROOT / "database.py", "ReviewDatabase")
remaining_http = class_methods(
APP_ROOT / "backend" / "application.py", "RequestHandler"
)
self.assertTrue(ALERT_SERVICE_METHODS.isdisjoint(remaining_service))
self.assertTrue(REVIEW_SERVICE_METHODS.isdisjoint(remaining_service))
self.assertTrue(ALERT_REPOSITORY_METHODS.isdisjoint(remaining_database))
self.assertTrue(REVIEW_REPOSITORY_METHODS.isdisjoint(remaining_database))
self.assertTrue(ALERT_HTTP_METHODS.isdisjoint(remaining_http))
self.assertTrue(REVIEW_HTTP_METHODS.isdisjoint(remaining_http))
def test_http_mixins_preserve_all_endpoints_without_global_service(self) -> None:
alerts = class_methods(
APP_ROOT / "backend" / "features" / "alerts" / "http.py",
"AlertHttpMixin",
)
review = class_methods(
APP_ROOT / "backend" / "features" / "review" / "http.py",
"ReviewHttpMixin",
)
self.assertEqual(set(alerts), ALERT_HTTP_METHODS)
self.assertEqual(set(review), REVIEW_HTTP_METHODS)
for path in (
APP_ROOT / "backend" / "features" / "alerts" / "http.py",
APP_ROOT / "backend" / "features" / "review" / "http.py",
):
source = path.read_text(encoding="utf-8")
self.assertNotIn("SERVICE.", source)
self.assertIn("self.application_service.", source)
if __name__ == "__main__":
unittest.main()