feat(recap): 复盘系统提示词支持自定义配置

复盘页「定时」弹窗新增提示词编辑区块: 完整系统提示词可编辑,
支持填入默认模板/恢复默认; 偏好键 market_recap_system_prompt 持久化,
空值回退内置默认模板, 超 32k 字符拒绝。自定义后不再跟随版本默认模板更新。
This commit is contained in:
shy3130
2026-10-21 18:05:36 +08:00
parent 9f7d64651f
commit 78d8d54282
6 changed files with 327 additions and 1 deletions
+30
View File
@@ -699,6 +699,7 @@ def get_preferences() -> dict:
"review_schedule": preferences.get_review_schedule(),
"review_push_channels": preferences.get_review_push_channels(),
"review_push_mode": preferences.get_review_push_mode(),
"market_recap_system_prompt": preferences.get_market_recap_system_prompt(),
**preferences.get_mining_schedule(),
}
@@ -2144,6 +2145,35 @@ def update_review_push(req: ReviewPushIn) -> dict:
return {"review_push_channels": saved, "review_push_mode": mode}
class MarketRecapPromptIn(BaseModel):
prompt: str # 自定义系统提示词全文; 空串 = 恢复内置默认模板
@router.get("/preferences/market-recap-prompt")
def get_market_recap_prompt() -> dict:
"""复盘系统提示词: 自定义值 + 内置默认全文。
默认全文单独按需拉取 (约 2.4k 字符), 不塞进 GET /preferences 大 dict。
"""
from app.services import preferences
from app.services.market_recap import _SYSTEM_PROMPT
custom = preferences.get_market_recap_system_prompt()
return {"custom": custom, "default": _SYSTEM_PROMPT, "customized": bool(custom)}
@router.put("/preferences/market-recap-prompt")
def update_market_recap_prompt(req: MarketRecapPromptIn) -> dict:
"""保存自定义复盘系统提示词; 空串恢复默认模板。超长返回 400。"""
from app.services import preferences
try:
saved = preferences.set_market_recap_system_prompt(req.prompt)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return {"custom": saved, "customized": bool(saved)}
# ================================================================
# API Token 管理 (open-platform-plan §4) — 仅 UI 会话可达
# (挂在 /api/settings 前缀下, 受访问密码保护; Token 通道无对应 scope,
+13 -1
View File
@@ -93,6 +93,18 @@ _SYSTEM_PROMPT = """你是一位拥有 15 年 A 股一线研究经验的市场
现在请基于下方数据进行复盘。"""
def effective_system_prompt() -> str:
"""实际下发给 LLM 的系统提示词: 用户自定义优先, 空/未配置回退内置默认模板。
读取走 preferences 的 mtime 缓存, 每次复盘调用一次, 开销可忽略。
自定义值是保存时刻的快照 —— 后续版本升级默认模板不会自动同步到已自定义用户
(设置页 UI 有提示); 脏值由 setter 层拦截, 这里只做空值回退双保险。
"""
from app.services import preferences
return preferences.get_market_recap_system_prompt() or _SYSTEM_PROMPT
# ================================================================
# 用户消息构建(精简切片,控制 token)
# ================================================================
@@ -330,7 +342,7 @@ async def recap_market_stream(
got_content = False
async for delta in stream_ai_text(
[
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "system", "content": effective_system_prompt()},
{"role": "user", "content": user_prompt},
],
temperature=0.5,
+31
View File
@@ -653,6 +653,37 @@ def set_review_schedule(enabled: bool, hour: int, minute: int) -> dict:
return {"enabled": bool(enabled), "hour": h, "minute": m}
# 复盘系统提示词长度上限: 默认模板约 2.4k 字符, 留足自定义余量同时防止误存超长文本
MARKET_RECAP_PROMPT_MAX_CHARS = 32_000
def get_market_recap_system_prompt() -> str:
"""用户自定义的复盘系统提示词。空串 = 未自定义 (market_recap 回退内置默认模板)。"""
v = load().get("market_recap_system_prompt")
return v.strip() if isinstance(v, str) else ""
def set_market_recap_system_prompt(text: str | None) -> str:
"""保存自定义复盘系统提示词。空串/None = 恢复默认模板 (删除该键)。
超过 MARKET_RECAP_PROMPT_MAX_CHARS 字符时抛 ValueError, 由 API 层转 400。
"""
cleaned = text.strip() if isinstance(text, str) else ""
if not cleaned:
with _SAVE_LOCK:
current = load()
current.pop("market_recap_system_prompt", None)
atomic_write_text(_path(), json.dumps(current, indent=2, ensure_ascii=False))
_invalidate_cache()
return ""
if len(cleaned) > MARKET_RECAP_PROMPT_MAX_CHARS:
raise ValueError(
f"复盘提示词过长 ({len(cleaned)} > {MARKET_RECAP_PROMPT_MAX_CHARS} 字符)"
)
save({"market_recap_system_prompt": cleaned})
return cleaned
MINING_BUDGET_PROFILES = frozenset({"balanced", "strict"})
+152
View File
@@ -0,0 +1,152 @@
"""复盘系统提示词可配置 — preferences 键、生效链路与 API 端点。"""
import json
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.services import preferences
from app.services.market_recap import _SYSTEM_PROMPT, effective_system_prompt
@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_prompt_defaults_to_empty_when_unset():
assert preferences.get_market_recap_system_prompt() == ""
def test_prompt_save_and_read_roundtrip_strips_whitespace():
saved = preferences.set_market_recap_system_prompt(" 用自己的口吻复盘 ")
assert saved == "用自己的口吻复盘"
assert preferences.get_market_recap_system_prompt() == "用自己的口吻复盘"
def test_prompt_empty_or_none_restores_default_and_removes_key():
preferences.set_market_recap_system_prompt("自定义提示词")
assert preferences.set_market_recap_system_prompt(" ") == ""
assert preferences.get_market_recap_system_prompt() == ""
# 恢复默认应删除键, 而不是留空串脏值
assert "market_recap_system_prompt" not in preferences.load()
preferences.set_market_recap_system_prompt("自定义提示词")
assert preferences.set_market_recap_system_prompt(None) == ""
assert preferences.get_market_recap_system_prompt() == ""
def test_prompt_rejects_overlong_text():
with pytest.raises(ValueError):
preferences.set_market_recap_system_prompt("字" * (preferences.MARKET_RECAP_PROMPT_MAX_CHARS + 1))
# 拒绝后不落盘
assert preferences.get_market_recap_system_prompt() == ""
def test_prompt_dirty_non_string_value_falls_back_to_empty():
preferences.save({"market_recap_system_prompt": 123})
assert preferences.get_market_recap_system_prompt() == ""
# ---------------------------------------------------------------- 生效链路
def test_effective_prompt_falls_back_to_builtin_default():
assert effective_system_prompt() == _SYSTEM_PROMPT
def test_effective_prompt_uses_custom_and_reverts_after_clear():
preferences.set_market_recap_system_prompt("我的自定义复盘提示词")
assert effective_system_prompt() == "我的自定义复盘提示词"
preferences.set_market_recap_system_prompt("")
assert effective_system_prompt() == _SYSTEM_PROMPT
async def test_stream_passes_custom_prompt_to_llm(monkeypatch, tmp_path):
"""复盘流把自定义提示词作为 system message 下发; 清空后回退内置默认。"""
from app.services import market_recap
overview = {
"as_of": "2026-10-21",
"indices": [],
"emotion": {"score": 55, "label": "震荡"},
"breadth": {"up": 100, "down": 80, "flat": 10},
"amount": {"total": 1.5e12},
"limit": {"limit_up": 50},
}
monkeypatch.setattr(market_recap, "build_market_overview", lambda *a, **k: overview)
monkeypatch.setattr(
"app.services.dragon_tiger.build_recap_context", lambda data_dir: "",
)
monkeypatch.setattr(
"app.services.auction_benchmark.build_recap_context", lambda data_dir: "",
)
captured: list[list[dict]] = []
async def _fake_stream(messages, **kwargs):
captured.append(messages)
yield "正文"
monkeypatch.setattr("app.services.ai_provider.stream_ai_text", _fake_stream)
repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path))
preferences.set_market_recap_system_prompt("自定义系统提示词A")
events = [e async for e in market_recap.recap_market_stream(repo)]
assert "done" in [json.loads(e).get("type") for e in events]
assert captured[-1][0] == {"role": "system", "content": "自定义系统提示词A"}
preferences.set_market_recap_system_prompt("")
events = [e async for e in market_recap.recap_market_stream(repo)]
assert "done" in [json.loads(e).get("type") for e in events]
assert captured[-1][0] == {"role": "system", "content": _SYSTEM_PROMPT}
# ---------------------------------------------------------------- API 端点
def _client() -> TestClient:
from app.api.settings import router
app = FastAPI()
app.include_router(router)
return TestClient(app)
def test_api_get_returns_custom_default_and_flag():
resp = _client().get("/api/settings/preferences/market-recap-prompt")
assert resp.status_code == 200
body = resp.json()
assert body == {"custom": "", "default": _SYSTEM_PROMPT, "customized": False}
preferences.set_market_recap_system_prompt("自定义提示词")
body = _client().get("/api/settings/preferences/market-recap-prompt").json()
assert body["custom"] == "自定义提示词"
assert body["customized"] is True
assert body["default"] == _SYSTEM_PROMPT
def test_api_put_saves_and_empty_restores_default():
client = _client()
resp = client.put(
"/api/settings/preferences/market-recap-prompt", json={"prompt": " 新提示词 "}
)
assert resp.status_code == 200
assert resp.json() == {"custom": "新提示词", "customized": True}
assert preferences.get_market_recap_system_prompt() == "新提示词"
resp = client.put("/api/settings/preferences/market-recap-prompt", json={"prompt": ""})
assert resp.status_code == 200
assert resp.json() == {"custom": "", "customized": False}
assert preferences.get_market_recap_system_prompt() == ""
def test_api_put_overlong_returns_400():
resp = _client().put(
"/api/settings/preferences/market-recap-prompt",
json={"prompt": "字" * (preferences.MARKET_RECAP_PROMPT_MAX_CHARS + 1)},
)
assert resp.status_code == 400
assert "过长" in resp.json()["detail"]
+90
View File
@@ -162,6 +162,30 @@ export function ReviewPage() {
onError: () => { /* request() 已 toast */ },
})
// ===== 自定义复盘提示词 =====
// 折叠区展开时才拉取(自定义值 + 默认全文); 草稿 null=未编辑, 跟随服务端值
const [showPromptEditor, setShowPromptEditor] = useState(false)
const promptQuery = useQuery({
queryKey: ['market-recap-prompt'],
queryFn: api.getMarketRecapPrompt,
enabled: showPromptEditor,
staleTime: 30_000,
})
const [promptDraft, setPromptDraft] = useState<string | null>(null)
useEffect(() => { setPromptDraft(null) }, [promptQuery.data])
const promptCustomized = !!promptQuery.data?.customized
const promptText = promptDraft ?? promptQuery.data?.custom ?? ''
const promptMut = useMutation({
mutationFn: (prompt: string) => api.updateMarketRecapPrompt(prompt),
onSuccess: (_data, vars) => {
qc.invalidateQueries({ queryKey: QK.preferences })
qc.invalidateQueries({ queryKey: ['market-recap-prompt'] })
setPromptDraft(null)
toast(vars.trim() ? '已保存复盘提示词' : '已恢复默认提示词', 'success')
},
onError: () => { /* request() 已 toast */ },
})
// 自动滚动到报告底部(streaming 时)
useEffect(() => {
if (phase === 'streaming') {
@@ -583,6 +607,72 @@ export function ReviewPage() {
</p>
</div>
{/* 自定义复盘提示词(折叠区, 展开/保存/恢复默认/填入默认模板) */}
<div className="mt-3 rounded-btn bg-elevated/40 px-3 py-2.5">
<button
type="button"
onClick={() => setShowPromptEditor(v => !v)}
className="flex w-full items-center justify-between"
>
<span className="flex items-center gap-1.5 text-xs text-foreground">
<Wand2 className="h-3.5 w-3.5 text-accent" />
自定义复盘提示词
</span>
<span className="flex items-center gap-1.5">
<span className={cn('text-[9px]', promptCustomized ? 'text-accent' : 'text-muted/70')}>
{promptCustomized ? '已自定义' : '默认模板'}
</span>
{showPromptEditor
? <ChevronUp className="h-3.5 w-3.5 text-muted" />
: <ChevronDown className="h-3.5 w-3.5 text-muted" />}
</span>
</button>
{showPromptEditor && (
<div className="mt-2.5">
<textarea
value={promptText}
onChange={e => setPromptDraft(e.target.value)}
spellCheck={false}
placeholder="留空使用内置默认提示词;也可点下方「填入默认模板」后在其基础上修改"
className="h-44 w-full resize-y rounded-btn bg-base border border-border p-2.5 font-mono text-[11px] leading-relaxed text-foreground outline-none placeholder:text-muted/60 focus:border-accent/50"
/>
<div className="mt-1.5 flex items-center justify-between">
<span className="text-[9px] text-muted/70">
{promptText.length.toLocaleString()} / 32,000 字符
{promptCustomized && ' · 自定义后不再跟随版本默认模板更新'}
</span>
<div className="flex items-center gap-1.5">
<button
type="button"
disabled={promptQuery.isLoading || !promptQuery.data}
onClick={() => setPromptDraft(promptQuery.data?.default ?? '')}
className="rounded-btn bg-base px-2 py-1 text-[10px] text-secondary border border-border/60 transition-colors hover:text-foreground disabled:opacity-50"
>
填入默认模板
</button>
<button
type="button"
disabled={promptMut.isPending || !promptCustomized}
onClick={() => promptMut.mutate('')}
className="rounded-btn bg-base px-2 py-1 text-[10px] text-secondary border border-border/60 transition-colors hover:text-foreground disabled:opacity-50"
>
恢复默认
</button>
<button
type="button"
disabled={promptMut.isPending || promptQuery.isLoading}
onClick={() => promptMut.mutate(promptText)}
className="rounded-btn bg-accent px-2.5 py-1 text-[10px] font-medium text-white transition-colors hover:bg-accent/90 disabled:opacity-50"
>
{promptMut.isPending ? '保存中…' : '保存'}
</button>
</div>
</div>
</div>
)}
</div>
{!draft.enabled && (
<p className="mt-3 text-[10px] text-muted/70">
当前: 已关闭。开启后将按设定时间自动复盘。
+11
View File
@@ -2149,6 +2149,8 @@ export interface Preferences {
review_schedule: { enabled: boolean; hour: number; minute: number }
review_push_channels: string[]
review_push_mode?: 'auto' | 'manual'
/** 用户自定义复盘系统提示词;空串 = 使用内置默认模板 */
market_recap_system_prompt?: string
sse_refresh_pages: Record<string, boolean>
strategy_monitor_enabled: boolean
strategy_monitor_ids: string[]
@@ -2693,6 +2695,15 @@ export const api = {
method: 'PUT',
body: JSON.stringify({ channels, mode: mode ?? null }),
}),
/** 复盘系统提示词: 自定义值 + 内置默认全文(按需拉取, 供「填入默认模板」) */
getMarketRecapPrompt: () =>
request<{ custom: string; default: string; customized: boolean }>('/api/settings/preferences/market-recap-prompt'),
/** 保存自定义复盘系统提示词; 空串 = 恢复内置默认模板 */
updateMarketRecapPrompt: (prompt: string) =>
request<{ custom: string; customized: boolean }>('/api/settings/preferences/market-recap-prompt', {
method: 'PUT',
body: JSON.stringify({ prompt }),
}),
updateDepthPollingInterval: (interval: number) =>
request<{ depth_polling_interval: number }>('/api/settings/preferences/depth-polling-interval', {
method: 'PUT',