Merge pull request #256 from SummerCaptain/feat/recap-push-gate

feat(preferences): 复盘推送 review_push_mode 门控
This commit is contained in:
wshy
2026-09-06 20:53:29 +08:00
committed by GitHub
8 changed files with 260 additions and 17 deletions
+10 -8
View File
@@ -19,7 +19,7 @@ from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from app.services import auction_benchmark, dragon_tiger, market_recap_reports
from app.services import auction_benchmark, dragon_tiger, market_recap_reports, preferences
from app.services.market_recap import recap_market_stream
logger = logging.getLogger(__name__)
@@ -122,6 +122,7 @@ class SaveReportRequest(BaseModel):
summary: str = ""
emotion_score: int | None = None
emotion_label: str = ""
push: bool = False # 是否显式外发推送(manual 模式下需显式传 true)
@router.get("/reports")
@@ -132,7 +133,7 @@ def list_reports(request: Request):
@router.post("/reports")
def save_report(request: Request, req: SaveReportRequest):
"""保存一条复盘报告。"""
"""保存一条复盘报告。req.push=True 或 review_push_mode=auto 时才推送到外部渠道。"""
report = market_recap_reports.save_report({
"as_of": req.as_of,
"focus": req.focus,
@@ -141,13 +142,14 @@ def save_report(request: Request, req: SaveReportRequest):
"emotion_score": req.emotion_score,
"emotion_label": req.emotion_label,
})
# 推送到飞书(可选): 与定时复盘共用同一开关 review_push_enabled 与 _maybe_push_review
# 推送门控: manual 模式需显式 push=True; auto 模式保持归档即推
# 内部 try/except 静默降级, 不影响归档返回值。
from app.jobs.daily_pipeline import _maybe_push_review
_maybe_push_review(req.content, {
"as_of": req.as_of,
"emotion_label": req.emotion_label,
})
if req.push or preferences.get_review_push_mode() == "auto":
from app.jobs.daily_pipeline import _maybe_push_review
_maybe_push_review(req.content, {
"as_of": req.as_of,
"emotion_label": req.emotion_label,
})
return {"ok": True, "report": report}
+11 -3
View File
@@ -557,6 +557,7 @@ def get_preferences() -> dict:
"depth_finalize_time": preferences.get_depth_finalize_time(),
"review_schedule": preferences.get_review_schedule(),
"review_push_channels": preferences.get_review_push_channels(),
"review_push_mode": preferences.get_review_push_mode(),
**preferences.get_mining_schedule(),
}
@@ -1908,16 +1909,23 @@ def update_review_schedule(req: ReviewScheduleIn, request: Request) -> dict:
class ReviewPushIn(BaseModel):
channels: list[str] # 多选: feishu / wecom / custom / email; 空数组=不推送
mode: str | None = None # 可选: auto=归档即推 / manual=仅显式 push; 不传则不变
@router.put("/preferences/review-push")
def update_review_push(req: ReviewPushIn) -> dict:
"""复盘推送渠道(多选) — 选定把复盘报告(手动生成 / 定时生成归档后)推送到哪些外部工具
"""复盘推送设置(渠道多选 + 触发方式)
纯偏好, 与定时复盘 / 实时行情完全独立, 常驻可单独设置。空数组=不推送。
实际推送由归档端点(POST /api/market-recap/reports)与定时任务(_run_scheduled_review)
在归档后读取本列表逐个推送。白名单外的渠道会被过滤掉。
在归档后读取渠道列表, 并按 review_push_mode 决定是否外发:
- manual: 定时复盘只归档不推送, 手动保存需显式 push=true
- auto: 归档即推(行为与旧逻辑一致)
白名单外的渠道会被过滤掉, 白名单外的 mode 值回退 manual。
"""
from app.services import preferences
saved = preferences.set_review_push_channels(req.channels)
return {"review_push_channels": saved}
mode = preferences.get_review_push_mode()
if req.mode is not None:
mode = preferences.set_review_push_mode(req.mode)
return {"review_push_channels": saved, "review_push_mode": mode}
+4 -2
View File
@@ -944,9 +944,11 @@ async def _run_scheduled_review(repo) -> None:
quote_service.push_review_event(json.dumps(
{"type": "done", "archived": True}, ensure_ascii=False))
# 推送到飞书(可选): 运行时读取配置, 用户改设置下次触发即生效。
# 推送门控: review_push_mode=manual 时定时复盘只归档不推送,
# 由用户对当日报告显式确认后才推; auto 时保持既有自动推送行为。
# 失败静默降级, 不影响已归档的报告。
_maybe_push_review(content, meta)
if _prefs.get_review_push_mode() == "auto":
_maybe_push_review(content, meta)
except Exception as e: # noqa: BLE001
logger.exception("scheduled review failed: %s", e)
# 兜底: 异常时通知前端停止「生成中」状态, 避免页面卡在 streaming
+20
View File
@@ -684,6 +684,26 @@ def set_review_push_channels(channels: list[str]) -> list[str]:
return cleaned
REVIEW_PUSH_MODES = frozenset({"auto", "manual"})
def get_review_push_mode() -> str:
"""复盘推送触发方式: auto=归档后自动推; manual=仅显式 push。默认 manual。
定时复盘与手动保存复盘共用此开关。manual 时定时路径只归档不推送,
手动路径需 save_report 显式传 push=True 才推。
"""
mode = load().get("review_push_mode", "manual")
return mode if mode in REVIEW_PUSH_MODES else "manual"
def set_review_push_mode(mode: str) -> str:
"""保存复盘推送触发方式, 白名单外的值回退 manual。"""
mode = mode if mode in REVIEW_PUSH_MODES else "manual"
save({"review_push_mode": mode})
return mode
# ===== 实时监控 =====
+30
View File
@@ -0,0 +1,30 @@
"""复盘推送触发方式 review_push_mode 最小 demo — 验证 auto/manual 白名单与默认值。
运行方式(在 backend/ 目录下, 已安装依赖):
python -m scripts.demo_recap_push_mode
隔离到临时偏好文件, 不污染真实偏好。
"""
from __future__ import annotations
import tempfile
from pathlib import Path
from app.services import preferences
def main() -> None:
prefs_path = Path(tempfile.mkdtemp()) / "preferences.json"
preferences._path = lambda: prefs_path # noqa: SLF001 - demo 隔离
preferences._invalidate_cache()
print("== 复盘推送模式 review_push_mode ==")
print(f" 默认 = {preferences.get_review_push_mode()} (期望 manual)")
print(f" 设为 auto = {preferences.set_review_push_mode('auto')}")
print(f" 非法值回退 = {preferences.set_review_push_mode('bogus')} (期望 manual)")
print("\n全部通过")
if __name__ == "__main__":
main()
+135
View File
@@ -0,0 +1,135 @@
"""复盘推送触发方式 review_push_mode 测试 — auto/manual 白名单与默认值 + 推送门控。"""
from __future__ import annotations
import asyncio
import pytest
from app.services import preferences
@pytest.fixture(autouse=True)
def _isolated(tmp_path, monkeypatch):
path = tmp_path / "preferences.json"
monkeypatch.setattr(preferences, "_path", lambda: path)
preferences._invalidate_cache()
yield path
preferences._invalidate_cache()
def test_review_push_mode_defaults_to_manual():
assert preferences.get_review_push_mode() == "manual"
def test_set_and_get_review_push_mode():
assert preferences.set_review_push_mode("auto") == "auto"
assert preferences.get_review_push_mode() == "auto"
assert preferences.set_review_push_mode("manual") == "manual"
assert preferences.get_review_push_mode() == "manual"
def test_set_review_push_mode_rejects_invalid_value():
assert preferences.set_review_push_mode("bogus") == "manual"
assert preferences.get_review_push_mode() == "manual"
# ── 推送门控 ────────────────────────────────────────────────────────
# 门控语义:
# manual: 定时复盘只归档不推送; 手动保存需显式 push=True 才推
# auto: 归档即推(与旧逻辑一致)
def test_save_report_manual_requires_explicit_push(monkeypatch):
from app.api import market_recap
from app.jobs import daily_pipeline
pushed: list[dict] = []
monkeypatch.setattr(
"app.services.market_recap_reports.save_report",
lambda d: {"id": "r1"},
)
monkeypatch.setattr(
daily_pipeline,
"_maybe_push_review",
lambda content, meta: pushed.append(meta),
)
preferences.set_review_push_mode("manual")
# 默认 push=False: manual 模式下只归档, 不外发
market_recap.save_report(None, market_recap.SaveReportRequest(as_of="2026-07-18", content="正文"))
assert pushed == []
# 显式 push=True: manual 模式下外发
market_recap.save_report(None, market_recap.SaveReportRequest(as_of="2026-07-18", content="正文", push=True))
assert pushed == [{"as_of": "2026-07-18", "emotion_label": ""}]
def test_save_report_auto_pushes_without_flag(monkeypatch):
from app.api import market_recap
from app.jobs import daily_pipeline
pushed: list[dict] = []
monkeypatch.setattr(
"app.services.market_recap_reports.save_report",
lambda d: {"id": "r1"},
)
monkeypatch.setattr(
daily_pipeline,
"_maybe_push_review",
lambda content, meta: pushed.append(meta),
)
preferences.set_review_push_mode("auto")
# auto 模式: 无需 push 标志即外发
market_recap.save_report(None, market_recap.SaveReportRequest(as_of="2026-07-18", content="正文"))
assert pushed == [{"as_of": "2026-07-18", "emotion_label": ""}]
def _patch_scheduled_review(monkeypatch, pushed: list, archived: list):
"""装配定时复盘的依赖: 有 AI key、流式产出固定内容、捕获归档与推送调用。"""
from app.jobs import daily_pipeline
async def _fake_stream(*a, **k):
return "正文", {"as_of": "2026-07-18", "emotion_label": "中性"}
monkeypatch.setattr("app.secrets_store.get_ai_key", lambda: "sk-test")
monkeypatch.setattr(daily_pipeline, "_stream_review_with_retry", _fake_stream)
monkeypatch.setattr(
"app.services.market_recap_reports.save_report",
lambda d: archived.append(d) or {"id": "r1"},
)
monkeypatch.setattr(
daily_pipeline,
"_maybe_push_review",
lambda content, meta: pushed.append(meta),
)
def test_scheduled_review_manual_archives_without_push(monkeypatch):
from app.jobs import daily_pipeline
pushed: list = []
archived: list = []
_patch_scheduled_review(monkeypatch, pushed, archived)
preferences.set_review_push_mode("manual")
asyncio.run(daily_pipeline._run_scheduled_review(None))
# manual 模式: 归档发生, 但不外发
assert len(archived) == 1
assert pushed == []
def test_scheduled_review_auto_pushes(monkeypatch):
from app.jobs import daily_pipeline
pushed: list = []
archived: list = []
_patch_scheduled_review(monkeypatch, pushed, archived)
preferences.set_review_push_mode("auto")
asyncio.run(daily_pipeline._run_scheduled_review(None))
# auto 模式: 归档并外发
assert len(archived) == 1
assert pushed == [{"as_of": "2026-07-18", "emotion_label": "中性"}]
+5 -3
View File
@@ -1774,6 +1774,7 @@ export interface Preferences {
depth_finalize_time: { hour: number; minute: number }
review_schedule: { enabled: boolean; hour: number; minute: number }
review_push_channels: string[]
review_push_mode?: 'auto' | 'manual'
sse_refresh_pages: Record<string, boolean>
strategy_monitor_enabled: boolean
strategy_monitor_ids: string[]
@@ -2153,10 +2154,10 @@ export const api = {
method: 'PUT',
body: JSON.stringify({ enabled, hour, minute }),
}),
updateReviewPush: (channels: string[]) =>
request<{ review_push_channels: string[] }>('/api/settings/preferences/review-push', {
updateReviewPush: (channels: string[], mode?: 'auto' | 'manual') =>
request<{ review_push_channels: string[]; review_push_mode: 'auto' | 'manual' }>('/api/settings/preferences/review-push', {
method: 'PUT',
body: JSON.stringify({ channels }),
body: JSON.stringify({ channels, mode: mode ?? null }),
}),
updateDepthPollingInterval: (interval: number) =>
request<{ depth_polling_interval: number }>('/api/settings/preferences/depth-polling-interval', {
@@ -3160,6 +3161,7 @@ export const api = {
reviewReportSave: (r: {
as_of: string; focus?: string; content: string
summary?: string; emotion_score?: number | null; emotion_label?: string
push?: boolean
}) =>
request<{ ok: boolean; report: AiReviewReport }>('/api/market-recap/reports', {
method: 'POST', body: JSON.stringify(r),
+45 -1
View File
@@ -118,6 +118,8 @@ export function Review() {
// 推送渠道是独立的顶层偏好(多选), 与定时 / 实时行情无关, 常驻可单独设置
// []=不推送; 可多选飞书、企微、第三方 Webhook 和邮件。
const reviewPushChannels = prefs.data?.review_push_channels ?? []
// 推送触发方式: auto=归档即推; manual=仅归档不自动外发。默认 manual。
const reviewPushMode = prefs.data?.review_push_mode ?? 'manual'
// 弹窗内的本地草稿: 开关和时间都在本地改, 点「保存」才真正提交(避免开关一拨就关弹窗)
const [draft, setDraft] = useState(reviewSched)
const openSchedule = useCallback(() => {
@@ -149,6 +151,15 @@ export function Review() {
: [...reviewPushChannels, ch]
pushMut.mutate(next)
}, [reviewPushChannels, pushMut])
// 推送触发方式(独立常驻): auto/manual 即时生效, 与渠道切换一致
const pushModeMut = useMutation({
mutationFn: (mode: 'auto' | 'manual') => api.updateReviewPush(reviewPushChannels, mode),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.preferences })
toast('已更新推送触发方式', 'success')
},
onError: () => { /* request() 已 toast */ },
})
// 自动滚动到报告底部(streaming 时)
useEffect(() => {
@@ -523,8 +534,41 @@ export function Review() {
</span>
</button>
</div>
{/* 推送触发方式: auto=归档即推 / manual=仅归档不自动外发 */}
<div className="mt-2 flex items-center justify-between">
<span className="text-[11px] text-foreground"></span>
<div className="flex items-center gap-0.5 rounded-btn bg-base p-0.5">
<button
type="button"
disabled={pushModeMut.isPending}
onClick={() => pushModeMut.mutate('auto')}
className={cn(
'rounded-btn px-2 py-0.5 text-[10px] transition-colors disabled:opacity-50',
reviewPushMode === 'auto' ? 'bg-accent text-white' : 'text-muted hover:text-secondary',
)}
>
</button>
<button
type="button"
disabled={pushModeMut.isPending}
onClick={() => pushModeMut.mutate('manual')}
className={cn(
'rounded-btn px-2 py-0.5 text-[10px] transition-colors disabled:opacity-50',
reviewPushMode === 'manual' ? 'bg-accent text-white' : 'text-muted hover:text-secondary',
)}
>
</button>
</div>
</div>
<p className="mt-1.5 text-[10px] leading-relaxed text-muted/70">
{reviewPushMode === 'auto'
? '定时与手动生成的复盘归档后都会自动推送完整报告。'
: '复盘仅归档保存,不自动外发。'}
{(
(reviewPushChannels.includes('feishu') && !feishuConfigured)
|| (reviewPushChannels.includes('wecom') && !wecomConfigured)