AI 分析报告按用户隔离存储

个股/财务/复盘报告改为 data/user_data/reports/{username}/ 目录下,
API 端从 request.state.username 读取对应用户的报告。

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-06 14:29:10 +08:00
parent efdfe29ccc
commit 3a963a130f
6 changed files with 90 additions and 123 deletions
+7 -4
View File
@@ -151,11 +151,12 @@ class SaveReportRequest(BaseModel):
@router.get("/reports") @router.get("/reports")
def list_reports(request: Request): def list_reports(request: Request):
"""获取全部历史报告(按时间降序,后端已裁剪到上限)。无需 FINANCIAL 能力读取列表元信息。""" """获取当前用户的历史报告(按时间降序,后端已裁剪到上限)。"""
capset = request.app.state.capabilities capset = request.app.state.capabilities
if not capset.has(Cap.FINANCIAL): if not capset.has(Cap.FINANCIAL):
return {"reports": []} return {"reports": []}
return {"reports": ai_reports.list_reports()} username = getattr(request.state, "username", None)
return {"reports": ai_reports.list_reports(username)}
@router.post("/reports") @router.post("/reports")
@@ -163,6 +164,7 @@ def save_report(request: Request, req: SaveReportRequest):
"""保存一条报告。""" """保存一条报告。"""
capset = request.app.state.capabilities capset = request.app.state.capabilities
capset.require(Cap.FINANCIAL) capset.require(Cap.FINANCIAL)
username = getattr(request.state, "username", None)
report = ai_reports.save_report({ report = ai_reports.save_report({
"symbol": req.symbol, "symbol": req.symbol,
"name": req.name, "name": req.name,
@@ -170,7 +172,7 @@ def save_report(request: Request, req: SaveReportRequest):
"content": req.content, "content": req.content,
"periods": req.periods, "periods": req.periods,
"summary": req.summary, "summary": req.summary,
}) }, username)
return {"ok": True, "report": report} return {"ok": True, "report": report}
@@ -179,5 +181,6 @@ def delete_report(request: Request, report_id: str):
"""删除一条报告。""" """删除一条报告。"""
capset = request.app.state.capabilities capset = request.app.state.capabilities
capset.require(Cap.FINANCIAL) capset.require(Cap.FINANCIAL)
ok = ai_reports.delete_report(report_id) username = getattr(request.state, "username", None)
ok = ai_reports.delete_report(report_id, username)
return {"ok": ok} return {"ok": ok}
+7 -6
View File
@@ -81,13 +81,15 @@ class SaveReportRequest(BaseModel):
@router.get("/reports") @router.get("/reports")
def list_reports(request: Request): def list_reports(request: Request):
"""获取全部历史复盘(按时间降序,后端已裁剪到上限)。""" """获取当前用户的历史复盘(按时间降序,后端已裁剪到上限)。"""
return {"reports": market_recap_reports.list_reports()} username = getattr(request.state, "username", None)
return {"reports": market_recap_reports.list_reports(username)}
@router.post("/reports") @router.post("/reports")
def save_report(request: Request, req: SaveReportRequest): def save_report(request: Request, req: SaveReportRequest):
"""保存一条复盘报告。""" """保存一条复盘报告。"""
username = getattr(request.state, "username", None)
report = market_recap_reports.save_report({ report = market_recap_reports.save_report({
"as_of": req.as_of, "as_of": req.as_of,
"focus": req.focus, "focus": req.focus,
@@ -95,9 +97,7 @@ def save_report(request: Request, req: SaveReportRequest):
"summary": req.summary, "summary": req.summary,
"emotion_score": req.emotion_score, "emotion_score": req.emotion_score,
"emotion_label": req.emotion_label, "emotion_label": req.emotion_label,
}) }, username)
# 推送到飞书(可选): 与定时复盘共用同一开关 review_push_enabled 与 _maybe_push_review。
# 内部 try/except 静默降级, 不影响归档返回值。
from app.jobs.daily_pipeline import _maybe_push_review from app.jobs.daily_pipeline import _maybe_push_review
_maybe_push_review(req.content, { _maybe_push_review(req.content, {
"as_of": req.as_of, "as_of": req.as_of,
@@ -109,5 +109,6 @@ def save_report(request: Request, req: SaveReportRequest):
@router.delete("/reports/{report_id}") @router.delete("/reports/{report_id}")
def delete_report(request: Request, report_id: str): def delete_report(request: Request, report_id: str):
"""删除一条复盘报告。""" """删除一条复盘报告。"""
ok = market_recap_reports.delete_report(report_id) username = getattr(request.state, "username", None)
ok = market_recap_reports.delete_report(report_id, username)
return {"ok": ok} return {"ok": ok}
+7 -4
View File
@@ -191,13 +191,15 @@ class SaveReportRequest(BaseModel):
@router.get("/reports") @router.get("/reports")
def list_reports(request: Request): def list_reports(request: Request):
"""获取全部历史报告(按时间降序,后端已裁剪到上限)。""" """获取当前用户的历史报告(按时间降序,后端已裁剪到上限)。"""
return {"reports": stock_reports.list_reports()} username = getattr(request.state, "username", None)
return {"reports": stock_reports.list_reports(username)}
@router.post("/reports") @router.post("/reports")
def save_report(request: Request, req: SaveReportRequest): def save_report(request: Request, req: SaveReportRequest):
"""保存一条报告。""" """保存一条报告。"""
username = getattr(request.state, "username", None)
report = stock_reports.save_report({ report = stock_reports.save_report({
"symbol": req.symbol, "symbol": req.symbol,
"name": req.name, "name": req.name,
@@ -206,12 +208,13 @@ def save_report(request: Request, req: SaveReportRequest):
"summary": req.summary, "summary": req.summary,
"close": req.close, "close": req.close,
"levels": req.levels, "levels": req.levels,
}) }, username)
return {"ok": True, "report": report} return {"ok": True, "report": report}
@router.delete("/reports/{report_id}") @router.delete("/reports/{report_id}")
def delete_report(request: Request, report_id: str): def delete_report(request: Request, report_id: str):
"""删除一条报告。""" """删除一条报告。"""
ok = stock_reports.delete_report(report_id) username = getattr(request.state, "username", None)
ok = stock_reports.delete_report(report_id, username)
return {"ok": ok} return {"ok": ok}
+26 -40
View File
@@ -1,20 +1,8 @@
"""AI 财务分析报告持久化存储。 """AI 财务分析报告持久化存储 — 按用户名隔离
存储位置: data/user_data/ai_reports.json (数组,按 created_at 降序) 存储位置: data/user_data/reports/{username}/ai_reports.json
保留最近 MAX_REPORTS 条;超出自动裁剪最旧的。
每条报告结构:
{
"id": "rpt_xxx", # 唯一 id
"symbol": "600519.SH",
"name": "贵州茅台",
"focus": "", # 用户追加的关心点(可为空)
"content": "# ...markdown", # 报告正文
"periods": 4, # 基于几期数据生成
"summary": "metrics: 1期...", # 数据摘要
"created_at": "2026-06-25T10:00:00"
}
""" """
from __future__ import annotations from __future__ import annotations
import json import json
@@ -27,16 +15,19 @@ logger = logging.getLogger(__name__)
MAX_REPORTS = 20 MAX_REPORTS = 20
def _path() -> Path: def _path(username: str | None = None) -> Path:
from app.config import settings from app.config import settings
p = settings.data_dir / "user_data" / "ai_reports.json" if username:
p = settings.data_dir / "user_data" / "reports" / username / "ai_reports.json"
else:
p = settings.data_dir / "user_data" / "ai_reports.json"
p.parent.mkdir(parents=True, exist_ok=True) p.parent.mkdir(parents=True, exist_ok=True)
return p return p
def list_reports() -> list[dict]: def list_reports(username: str | None = None) -> list[dict]:
"""返回全部报告(按 created_at 降序)。""" """返回指定用户的全部报告(按 created_at 降序)。"""
p = _path() p = _path(username)
if not p.exists(): if not p.exists():
return [] return []
try: try:
@@ -48,54 +39,49 @@ def list_reports() -> list[dict]:
return [] return []
def _save_all(reports: list[dict]) -> None: def _save_all(reports: list[dict], username: str | None = None) -> None:
"""全量写入(裁剪到 MAX_REPORTS)。""" """全量写入(裁剪到 MAX_REPORTS)。"""
# 保持降序
reports.sort(key=lambda r: r.get("created_at", ""), reverse=True) reports.sort(key=lambda r: r.get("created_at", ""), reverse=True)
if len(reports) > MAX_REPORTS: if len(reports) > MAX_REPORTS:
reports = reports[:MAX_REPORTS] reports = reports[:MAX_REPORTS]
_path().write_text( _path(username).write_text(
json.dumps(reports, indent=2, ensure_ascii=False), encoding="utf-8", json.dumps(reports, indent=2, ensure_ascii=False), encoding="utf-8",
) )
def save_report(report: dict) -> dict: def save_report(report: dict, username: str | None = None) -> dict:
"""新增一条报告并持久化。返回保存后的报告(含 id / created_at)。 """新增一条报告并持久化。返回保存后的报告(含 id / created_at)。"""
reports = list_reports(username)
自动补全 id 与 created_at(若缺),并裁剪到上限。
"""
reports = list_reports()
if not report.get("id"): if not report.get("id"):
report["id"] = f"rpt_{int(time.time() * 1000)}_{report.get('symbol', 'x')}" report["id"] = f"rpt_{int(time.time() * 1000)}_{report.get('symbol', 'x')}"
if not report.get("created_at"): if not report.get("created_at"):
report["created_at"] = _now_iso() report["created_at"] = _now_iso()
reports.append(report) reports.append(report)
_save_all(reports) _save_all(reports, username)
logger.info("AI report saved: %s (%s), total %d", report.get("symbol"), report.get("id"), len(reports)) logger.info("AI report saved by %s: %s (%s), total %d", username or "?", report.get("symbol"), report.get("id"), len(reports))
return report return report
def delete_report(report_id: str) -> bool: def delete_report(report_id: str, username: str | None = None) -> bool:
"""删除指定报告。返回是否删除成功。""" """删除指定用户的报告。返回是否删除成功。"""
reports = list_reports() reports = list_reports(username)
before = len(reports) before = len(reports)
reports = [r for r in reports if r.get("id") != report_id] reports = [r for r in reports if r.get("id") != report_id]
if len(reports) < before: if len(reports) < before:
_save_all(reports) _save_all(reports, username)
return True return True
return False return False
def clear_reports() -> int: def clear_reports(username: str | None = None) -> int:
"""清空全部报告。返回删除数量。""" """清空指定用户的全部报告。返回删除数量。"""
reports = list_reports() reports = list_reports(username)
n = len(reports) n = len(reports)
if n > 0: if n > 0:
_save_all([]) _save_all([], username)
return n return n
def _now_iso() -> str: def _now_iso() -> str:
"""当前本地时间 ISO 字符串(带秒精度,前端 toLocaleString 友好)。"""
from datetime import datetime from datetime import datetime
return datetime.now().isoformat(timespec="seconds") return datetime.now().isoformat(timespec="seconds")
@@ -1,24 +1,8 @@
"""AI 大盘复盘报告持久化存储。 """AI 大盘复盘报告持久化存储 — 按用户名隔离
与 stock_reports.py(个股分析报告)/ ai_reports.py(财务分析报告)完全独立 —— 存储位置: data/user_data/reports/{username}/ai_market_recaps.json
单独的文件、字段、上限,互不影响。刻意不复用,避免引入 kind 判别字段与分支
(解耦 > 抽象)。
存储位置: data/user_data/ai_market_recaps.json (数组,按 created_at 降序)
保留最近 MAX_REPORTS 条;超出自动裁剪最旧的。
每条报告结构:
{
"id": "mkr_xxx", # 唯一 id(market-recap-report)
"as_of": "2026-06-27", # 复盘日期
"focus": "", # 用户追加的关心点(可为空)
"content": "# ...markdown", # 报告正文
"summary": "三大指数齐涨...", # 一句话摘要
"emotion_score": 68, # 情绪分(0-100, 复盘生成时的市场情绪雷达均分)
"emotion_label": "偏暖", # 情绪标签(强势/偏暖/震荡/偏冷/冰点)
"created_at": "2026-06-27T15:35:00"
}
""" """
from __future__ import annotations from __future__ import annotations
import json import json
@@ -31,16 +15,19 @@ logger = logging.getLogger(__name__)
MAX_REPORTS = 20 MAX_REPORTS = 20
def _path() -> Path: def _path(username: str | None = None) -> Path:
from app.config import settings from app.config import settings
p = settings.data_dir / "user_data" / "ai_market_recaps.json" if username:
p = settings.data_dir / "user_data" / "reports" / username / "ai_market_recaps.json"
else:
p = settings.data_dir / "user_data" / "ai_market_recaps.json"
p.parent.mkdir(parents=True, exist_ok=True) p.parent.mkdir(parents=True, exist_ok=True)
return p return p
def list_reports() -> list[dict]: def list_reports(username: str | None = None) -> list[dict]:
"""返回全部报告(按 created_at 降序)。""" """返回指定用户的全部报告(按 created_at 降序)。"""
p = _path() p = _path(username)
if not p.exists(): if not p.exists():
return [] return []
try: try:
@@ -52,37 +39,37 @@ def list_reports() -> list[dict]:
return [] return []
def _save_all(reports: list[dict]) -> None: def _save_all(reports: list[dict], username: str | None = None) -> None:
"""全量写入(裁剪到 MAX_REPORTS)。""" """全量写入(裁剪到 MAX_REPORTS)。"""
reports.sort(key=lambda r: r.get("created_at", ""), reverse=True) reports.sort(key=lambda r: r.get("created_at", ""), reverse=True)
if len(reports) > MAX_REPORTS: if len(reports) > MAX_REPORTS:
reports = reports[:MAX_REPORTS] reports = reports[:MAX_REPORTS]
_path().write_text( _path(username).write_text(
json.dumps(reports, indent=2, ensure_ascii=False), encoding="utf-8", json.dumps(reports, indent=2, ensure_ascii=False), encoding="utf-8",
) )
def save_report(report: dict) -> dict: def save_report(report: dict, username: str | None = None) -> dict:
"""新增一条报告并持久化。返回保存后的报告(含 id / created_at)。""" """新增一条报告并持久化。返回保存后的报告(含 id / created_at)。"""
reports = list_reports() reports = list_reports(username)
if not report.get("id"): if not report.get("id"):
report["id"] = f"mkr_{int(time.time() * 1000)}" report["id"] = f"mkr_{int(time.time() * 1000)}"
if not report.get("created_at"): if not report.get("created_at"):
report["created_at"] = _now_iso() report["created_at"] = _now_iso()
reports.append(report) reports.append(report)
_save_all(reports) _save_all(reports, username)
logger.info("Market recap saved: %s (as_of=%s), total %d", logger.info("Market recap saved by %s: %s (as_of=%s), total %d",
report.get("id"), report.get("as_of"), len(reports)) username or "?", report.get("id"), report.get("as_of"), len(reports))
return report return report
def delete_report(report_id: str) -> bool: def delete_report(report_id: str, username: str | None = None) -> bool:
"""删除指定报告。返回是否删除成功。""" """删除指定用户的报告。返回是否删除成功。"""
reports = list_reports() reports = list_reports(username)
before = len(reports) before = len(reports)
reports = [r for r in reports if r.get("id") != report_id] reports = [r for r in reports if r.get("id") != report_id]
if len(reports) < before: if len(reports) < before:
_save_all(reports) _save_all(reports, username)
return True return True
return False return False
+21 -34
View File
@@ -1,24 +1,8 @@
"""AI 个股分析报告持久化存储。 """AI 个股分析报告持久化存储 — 按用户名隔离
与 ai_reports.py(财务分析报告)完全独立 —— 单独的文件、字段、上限, 存储位置: data/user_data/reports/{username}/ai_stock_reports.json
互不影响。刻意不复用,避免引入 kind 判别字段与分支(解耦 > 抽象)。
存储位置: data/user_data/ai_stock_reports.json (数组,按 created_at 降序)
保留最近 MAX_REPORTS 条;超出自动裁剪最旧的。
每条报告结构:
{
"id": "sar_xxx", # 唯一 id(stock-analysis-report)
"symbol": "600519.SH",
"name": "贵州茅台",
"focus": "", # 用户追加的关心点(可为空)
"content": "# ...markdown", # 报告正文
"summary": "当前价 12.3 · 压力位...", # 价位/数据摘要
"levels": {...}, # 报告生成时的关键价位(供图表回放)
"close": 12.3, # 报告生成时的收盘价
"created_at": "2026-06-26T10:00:00"
}
""" """
from __future__ import annotations from __future__ import annotations
import json import json
@@ -31,16 +15,19 @@ logger = logging.getLogger(__name__)
MAX_REPORTS = 50 MAX_REPORTS = 50
def _path() -> Path: def _path(username: str | None = None) -> Path:
from app.config import settings from app.config import settings
p = settings.data_dir / "user_data" / "ai_stock_reports.json" if username:
p = settings.data_dir / "user_data" / "reports" / username / "ai_stock_reports.json"
else:
p = settings.data_dir / "user_data" / "ai_stock_reports.json"
p.parent.mkdir(parents=True, exist_ok=True) p.parent.mkdir(parents=True, exist_ok=True)
return p return p
def list_reports() -> list[dict]: def list_reports(username: str | None = None) -> list[dict]:
"""返回全部报告(按 created_at 降序)。""" """返回指定用户的全部报告(按 created_at 降序)。"""
p = _path() p = _path(username)
if not p.exists(): if not p.exists():
return [] return []
try: try:
@@ -52,36 +39,36 @@ def list_reports() -> list[dict]:
return [] return []
def _save_all(reports: list[dict]) -> None: def _save_all(reports: list[dict], username: str | None = None) -> None:
"""全量写入(裁剪到 MAX_REPORTS)。""" """全量写入(裁剪到 MAX_REPORTS)。"""
reports.sort(key=lambda r: r.get("created_at", ""), reverse=True) reports.sort(key=lambda r: r.get("created_at", ""), reverse=True)
if len(reports) > MAX_REPORTS: if len(reports) > MAX_REPORTS:
reports = reports[:MAX_REPORTS] reports = reports[:MAX_REPORTS]
_path().write_text( _path(username).write_text(
json.dumps(reports, indent=2, ensure_ascii=False), encoding="utf-8", json.dumps(reports, indent=2, ensure_ascii=False), encoding="utf-8",
) )
def save_report(report: dict) -> dict: def save_report(report: dict, username: str | None = None) -> dict:
"""新增一条报告并持久化。返回保存后的报告(含 id / created_at)。""" """新增一条报告并持久化。返回保存后的报告(含 id / created_at)。"""
reports = list_reports() reports = list_reports(username)
if not report.get("id"): if not report.get("id"):
report["id"] = f"sar_{int(time.time() * 1000)}_{report.get('symbol', 'x')}" report["id"] = f"sar_{int(time.time() * 1000)}_{report.get('symbol', 'x')}"
if not report.get("created_at"): if not report.get("created_at"):
report["created_at"] = _now_iso() report["created_at"] = _now_iso()
reports.append(report) reports.append(report)
_save_all(reports) _save_all(reports, username)
logger.info("Stock report saved: %s (%s), total %d", report.get("symbol"), report.get("id"), len(reports)) logger.info("Stock report saved by %s: %s (%s), total %d", username or "?", report.get("symbol"), report.get("id"), len(reports))
return report return report
def delete_report(report_id: str) -> bool: def delete_report(report_id: str, username: str | None = None) -> bool:
"""删除指定报告。返回是否删除成功。""" """删除指定用户的报告。返回是否删除成功。"""
reports = list_reports() reports = list_reports(username)
before = len(reports) before = len(reports)
reports = [r for r in reports if r.get("id") != report_id] reports = [r for r in reports if r.get("id") != report_id]
if len(reports) < before: if len(reports) < before:
_save_all(reports) _save_all(reports, username)
return True return True
return False return False