mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
feat(recap): 复盘系统提示词支持自定义配置
复盘页「定时」弹窗新增提示词编辑区块: 完整系统提示词可编辑, 支持填入默认模板/恢复默认; 偏好键 market_recap_system_prompt 持久化, 空值回退内置默认模板, 超 32k 字符拒绝。自定义后不再跟随版本默认模板更新。
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"})
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -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">
|
||||
当前: 已关闭。开启后将按设定时间自动复盘。
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user