diff --git a/docs/full_integration_test_prompt.md b/docs/full_integration_test_prompt.md index c3315fd..a869753 100644 --- a/docs/full_integration_test_prompt.md +++ b/docs/full_integration_test_prompt.md @@ -133,7 +133,7 @@ provider 已识别、key env 已继承但真实 key 未打印、各数据/因子 摘要(screenshots / page-state)作为真实前端操作证据。 **当前 IA:7 个顶层页签(tabs)** —— 「LLM 因子工作台」(内含两个模块:单因子研究 / -多因子策略回测)·「研究历史」·「数据」·「注册表」·「文档」·「扩展」·「记忆治理」。 +多因子策略回测)·「研究历史」·「数据」·「已注册因子」·「文档」·「扩展」·「记忆治理」。 顶层 tab id 依次为 `lab-tab-factor` / `lab-tab-history` / `lab-tab-data` / `lab-tab-registry` / `lab-tab-docs` / `lab-tab-extensions` / `lab-tab-memory`; 工作台内两个模块 id 为 `lab-module-single`(单因子研究)与 @@ -158,6 +158,28 @@ provider 已识别、key env 已继承但真实 key 未打印、各数据/因子 minimax/openai_compatible)可在此注册启用,缺省 model 的预设需补填模型名。 - 控制令牌门控 UX(token-gating):需要令牌时页面提示明确,粘贴/预置后功能解锁。 +#### 4.1.1 运行时切换大模型 / runtime LLM switch(NEW,`POST /api/settings/llm`) + +网页端可在**不改 YAML、不重启**的前提下注册/切换 provider 并输入密钥。作为验收步骤逐项验证: + +- **预设可见性:** `#llm-provider` 下拉除已配置 provider 外,还应列出 6 个内置预设 + (`deepseek` / `openai` / `openai_compatible` / `glm` / `claude` / `minimax`);未在 YAML + 配过的预设标注**「预设未启用」**(`configured=false`)。 +- **注册预设 + 注入密钥:** 选一个未启用预设 → `#llm-api-key-mode` 切「前端输入(注入本次 + 运行)」→ 填一个 **dummy 密钥**(缺省 model 的预设如 openai/glm/claude 会多出模型名输入框 + `#llm-model`;`openai_compatible` 还多出 `#llm-base-url`,可填本地 `http://127.0.0.1:11434/v1`) + → 点「保存并启用」(`#llm-settings-save`)。**期望:** 状态提示「已保存…」、`#llm-api-key` + 立即清空、运行时状态条切成该 provider、该预设的 `runtime_ready` 翻成 `true`。 +- **密钥保密(HONESTY / P4):** dummy 密钥值**绝不**出现在任何响应体、`/api/status`、页面 + DOM、日志或工作区磁盘;`#llm-api-key` 属性为 `data-secret-policy="runtime-memory-only"`; + 保存后重启容器则失效(进程内存语义)。 +- **base_url 重定向护栏(SECURITY):** 对一个 **env 里已有常驻密钥**的 provider(如 deepseek) + 只改 `base_url`、不重填密钥 → 期望 **400**(错误含「re-supplying api_key」),且原 base_url + 未被改写。带上密钥再改则允许。此举防止把宿主常驻密钥发往新指定地址。 +- **切回不回归:** 切回原配置 provider(`{"provider":"deepseek"}`,不带密钥)应成功、 + `key_updated=false`、真实 DeepSeek 链路 readiness 仍为 `ready`。 +- **令牌门控一致:** Docker/`0.0.0.0` 绑定下,无令牌 `POST /api/settings/llm` 返回 `401`。 + ### 4.2 单因子研究 / single-factor module(真实 DeepSeek) 切到「单因子研究」模块。用三个 seed(**逐字**,见附录 C)各跑一遍。 @@ -329,7 +351,9 @@ seed)、`last_accepted_factor_id`、`last_explored_factor_id`、 - 「数据」(`#lab-panel-data`):字段目录(catalog)、覆盖范围(coverage)、质量门 (quality gate)。 -- 「注册表」(`#lab-panel-registry`):因子列表 + 详情 + 证据链(evidence chain)。 +- 「已注册因子」(`#lab-panel-registry`):因子列表 + 详情 + 证据链(evidence chain)。 + 标签文案是**「已注册因子」**(不再是旧的「注册表」);内部 id 仍是 `lab-tab-registry` / + `lab-panel-registry`(深链与路由不变)。 - 「文档」(`#lab-panel-docs`):docs 目录 + 渲染后的 markdown(应能看到本文件与 `frontend_contributing.md`)。 - 「扩展」(`#lab-panel-extensions`):扩展 manifest 清单 + 校验状态(validation status)。 @@ -374,6 +398,15 @@ seed)、`last_accepted_factor_id`、`last_explored_factor_id`、 `/api` 路径 → 返回 index shell(深链与拼写错误都落到首页外壳)。 - **payload 卫生(hygiene):** 任何 payload 都**不得含绝对路径**;尤其 `GET /api/data/status` 不得泄漏 `data_root` / `panel_path`(artifact 字段应为 basename)。 +- **LLM 运行时设置端点(NEW,`POST /api/settings/llm`):** + - `GET /api/status` 的 `llm.providers[]` 除已配置项外还带内置预设,每项含 + `configured` / `runtime_ready` / `api_key_env`(**只有环境变量名,无 key 值**)。 + - `POST` 一个预设 + dummy `api_key` → `200`,响应含 `key_updated:true`,但**响应体、 + `/api/status`、任何日志/磁盘都不含 key 值**;该预设 `runtime_ready` 翻 `true`。 + - **护栏 400:** 对已有常驻密钥的 provider 只改 `base_url`、不带 `api_key` → `400` + (错误含 `re-supplying api_key`),base_url 不变。 + - **门控:** 无令牌 `POST /api/settings/llm` → `401`。非法输入(未知 provider / 含空白的 + key / 非 http(s) 的 base_url / 预设缺 model / `provider:"rule"`)→ 同步 `400`。 - **合成端点形状:** - `GET /api/synthesis/methods` 返回 `{methods, standardizations}`:4 个方法 (`equal_weight` / `weighted` 先验 + `ic_weighted` / `icir_weighted` 拟合,P6 终态全部 @@ -524,7 +557,7 @@ doctor 必须 DeepSeek runtime-ready;llm-smoke 必须完成一次真实解析 阶段 3 真实前端联调(按能力阶梯取最高级:L1 真实桌面 Chrome,L2 真实 Chrome 程序化, L3 API 仅补充非验收):走 4.0–4.5——6-tab + 两模块 IA;provider 与 key 控件;单因子 -三个 seed(附录 C,逐字)解析→评测→RD;多因子模块合成→回测;只读面(数据/注册表/文档/ +三个 seed(附录 C,逐字)解析→评测→RD;多因子模块合成→回测;只读面(数据/已注册因子/文档/ 扩展);横切(深链 reload、console 零错误、暗色 + 375px via CDP device metrics)。保留截图/ 页面状态证据。禁止用 /api/jobs/* 替代点击。 @@ -576,7 +609,7 @@ L3 API 仅补充非验收):走 4.0–4.5——6-tab + 两模块 IA;provide **一个多因子合成配方 / a multi-factor recipe(用于 4.3):** -- 从注册表/已产出的因子里挑 2–3 个(例如上面三个 seed 验证后注册的因子)。 +- 从「已注册因子」/已产出的因子里挑 2–3 个(例如上面三个 seed 验证后注册的因子)。 - 方法选 `weighted`,为每个因子填一个权重,例如 `0.5 / 0.3 / 0.2`(方向按定义 `+1`, 除非要显式反向 `-1`);标准化选 `zscore`。 - `holding_days = 5`(必填);其余留空由后端 profile 默认值决定。 diff --git a/docs/integration_workflow.md b/docs/integration_workflow.md index ffb08c6..9edef23 100644 --- a/docs/integration_workflow.md +++ b/docs/integration_workflow.md @@ -329,7 +329,9 @@ API/HTTP 调用只允许核对后端 contract、artifact 路径、日期窗口 1. 打开桌面 Chrome,访问本地 Web URL。 2. 检查左侧 runtime strip 是否展示当前 `LLM`、`data`、`factors`、`values`、`overlay`、`artifacts`。 -3. 在 `LLM Provider` 中选择 DeepSeek 或当前配置的云端 provider。 +3. 在 `LLM Provider` 中选择 DeepSeek 或当前配置的云端 provider。下拉除已配置 provider 外, + 还应列出内置预设(deepseek/openai/openai_compatible/glm/claude/minimax),未在 YAML 配过的 + 标注“预设未启用”。 4. 检查 `LLM API Key` 控件: - 默认模式应为“配置文件 / 环境变量加载”。 - 如果 provider key 已通过配置和环境变量加载,password 输入框必须置灰。 @@ -339,6 +341,14 @@ API/HTTP 调用只允许核对后端 contract、artifact 路径、日期窗口 - 密钥仅存在于运行中进程的内存,不写入磁盘、配置、日志、artifact 或报告,任何响应 (含 `/api/status`)都不得回显密钥值;重启后失效,持久化仍走 `configs/*.local.env`。 - 手动输入的密钥不得进入 parse/validate 请求体;正式调用仍从后端环境变量读取。 +4b. 运行时切换大模型(NEW)联调: + - 选一个“预设未启用”的 provider → 切“前端输入” → 填 dummy 密钥(缺省 model 的预设会多出 + 模型名输入框;openai_compatible 还多出 base_url 框,可填本地 `http://127.0.0.1:11434/v1`) + → 点“保存并启用”。期望:状态提示“已保存…”、密钥框清空、runtime strip 切成该 provider、 + 该预设 `runtime_ready` 翻 true。全程 dummy 密钥不得出现在任何响应/DOM/日志/磁盘。 + - base_url 护栏:对一个 env 已有常驻密钥的 provider 只改 base_url、不重填密钥 → 期望 400 + (错误含 “re-supplying api_key”),原 base_url 不变;带密钥再改则允许。 + - 切回原配置 provider(不带密钥)应成功、`key_updated=false`、真实链路 readiness 仍 ready。 5. 输入自然语言因子观点,点击“解析因子”。 6. 解析完成后应只展示 factor 草稿和待确认参数,不应立即执行评价/回测。 7. 检查评测参数区是否自动填入默认值: diff --git a/src/quant_forge/llm_factor_parser.py b/src/quant_forge/llm_factor_parser.py index 99ae5e7..d980452 100644 --- a/src/quant_forge/llm_factor_parser.py +++ b/src/quant_forge/llm_factor_parser.py @@ -20,6 +20,7 @@ # Reuse it here instead of defining a parallel copy so the web parse path and # the spec flow can never drift apart. from quant_forge.specs.nl_flow import ( # noqa: E402 (grouped with the contract note above) + out_of_scope_data_warnings as out_of_scope_data_warnings, _FALLBACK_WARNING as GENERIC_FALLBACK_WARNING, _GENERIC_FALLBACK_FORMULA as GENERIC_FALLBACK_FORMULA, ) @@ -60,6 +61,18 @@ class ParsedFactor: warnings: tuple[str, ...] = () +def parse_warnings(formula: str, text: str) -> tuple[str, ...]: + """Every honest-parse warning for a result: the generic-fallback flag plus + the out-of-scope-data flag, deduplicated and order-preserving. Both parse + modes assemble their ``warnings`` through this single helper.""" + + combined: list[str] = [] + for warning in (*generic_fallback_warnings(formula), *out_of_scope_data_warnings(text)): + if warning not in combined: + combined.append(warning) + return tuple(combined) + + def generic_fallback_warnings(formula: str) -> tuple[str, ...]: """Warnings a parse result must carry when it landed on the generic formula. @@ -84,7 +97,7 @@ def parse_factor_idea(text: str, llm: LLMSettings, *, mode: str = "llm") -> Pars source="rule", provider="rule", model="deterministic", - warnings=generic_fallback_warnings(factor.formula), + warnings=parse_warnings(factor.formula, text), ) if mode != "llm": raise ValueError(f"unsupported parser mode: {mode}") @@ -103,7 +116,7 @@ def _parse_with_configured_llm(text: str, llm: LLMSettings) -> ParsedFactor: provider=result.provider, model=result.model, raw_response=result.content, - warnings=generic_fallback_warnings(factor.formula), + warnings=parse_warnings(factor.formula, text), ) diff --git a/src/quant_forge/specs/nl_flow.py b/src/quant_forge/specs/nl_flow.py index 01fe595..b7e57ff 100644 --- a/src/quant_forge/specs/nl_flow.py +++ b/src/quant_forge/specs/nl_flow.py @@ -25,6 +25,58 @@ "not have understood the idea - review before running" ) +# No-silent-fallback, extended past the rank(close) catch-all. The public +# workbench panel has only price-volume fields (close / is_st / market_cap / +# return_* / volatility_* / volume). When an idea references a data domain the +# panel cannot express -- financial statements, valuation multiples, alt-data -- +# the parser (LLM or rule) can only degrade to a price-volume formula, so it may +# silently return e.g. rank(return_5d) for a fundamentals idea and look +# confident. Detect the domain mismatch from the idea text and attach a review +# warning so an approximation is never presented as a faithful implementation. +# +# This is a curated, deliberately conservative heuristic (recall over a fixed +# set, not exhaustive): terms are chosen to avoid colliding with in-scope +# price-volume vocabulary (收益/回报/动量/波动/成交量/市值/价格) and with the +# built-in demo seeds (notably the bare word 估值 is intentionally NOT listed, +# so the "低估值" seed keeps its existing rank(close) catch-all behavior). +_OUT_OF_SCOPE_DATA_TERMS: tuple[str, ...] = ( + # fundamentals / financial statements (Chinese) + "基本面", "利润", "净利", "毛利", "营收", "营业收入", "财报", "年报", "季报", + "中报", "业绩", "每股收益", "净资产", "资产负债", "现金流", "负债", "商誉", + "分红", "股息", "派息", "应收", "存货", "扣非", + # valuation multiples that need fundamentals (the bare 估值/价值 is excluded) + "市盈", "市净", "市销", "股息率", + # alternative data (Chinese) + "舆情", "研报", "分析师", "北向", "龙虎榜", "机构持仓", "增减持", "股东户数", + # fundamentals / valuation / alt-data (English, multi-char to avoid matches + # inside unrelated words like "approach" / "broad") + "earnings", "revenue", "profit", "dividend", "cash flow", "cashflow", + "book value", "ebitda", "fundamental", "p/e", "p/b", "sentiment", "analyst", +) +_OUT_OF_SCOPE_DATA_WARNING = ( + "本数据集只有量价字段(close/market_cap/return/volatility/volume 等)," + "无法表达该想法涉及的基本面/估值/另类数据,公式只是最接近的量价近似," + "并非该想法的真实实现,请复核 / this idea references data not in the " + "price-volume dataset; the formula is only a nearest price-volume " + "approximation, not a faithful implementation - review before running" +) + + +def out_of_scope_data_warnings(text: str) -> tuple[str, ...]: + """Warn when an idea references a data domain the price-volume panel lacks. + + Returns a single review warning (or empty) so both parse modes can attach + it: an idea about profit growth, valuation multiples, or alt-data can only + degrade to a price-volume formula here, and that degradation must not be + presented as a confident match. + """ + + lowered = text.lower() + for term in _OUT_OF_SCOPE_DATA_TERMS: + if term in lowered: + return (_OUT_OF_SCOPE_DATA_WARNING,) + return () + def factor_spec_from_idea(text: str) -> tuple[FactorSpec, SpecValidationResult]: """Deterministically draft a FactorSpec from a beginner idea and gate it. @@ -49,6 +101,9 @@ def factor_spec_from_idea(text: str) -> tuple[FactorSpec, SpecValidationResult]: result = validate_factor_spec(spec) if definition.formula == _GENERIC_FALLBACK_FORMULA: result = replace(result, warnings=result.warnings + (_FALLBACK_WARNING,)) + extra = out_of_scope_data_warnings(text) + if extra: + result = replace(result, warnings=result.warnings + extra) return spec, result diff --git a/tests/test_llm_factor_parser.py b/tests/test_llm_factor_parser.py index eb39191..0dbd828 100644 --- a/tests/test_llm_factor_parser.py +++ b/tests/test_llm_factor_parser.py @@ -434,6 +434,87 @@ def test_llm_mode_generic_formula_carries_identical_fallback_warning(monkeypatch assert parsed.warnings == (GENERIC_FALLBACK_WARNING,) +class FakeMomentumHandler(BaseHTTPRequestHandler): + """Mimics the real degradation: an unmappable fundamentals idea comes back + as a confident-looking momentum formula (the workbench prompt only allows a + handful of price-volume formulas, so the model picks the nearest one).""" + + def do_POST(self) -> None: + length = int(self.headers["Content-Length"]) + json.loads(self.rfile.read(length).decode("utf-8")) + content = { + "name": "profit_growth_momentum", + "formula": "rank(return_5d)", + "description": "年报、季报利润同比增加高的公司,在未来21天的表现会更好", + "horizon_days": 21, + "universe_filters": [], + } + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"choices": [{"message": {"content": json.dumps(content)}}]}).encode("utf-8")) + + def log_message(self, format: str, *args: object) -> None: + return + + +def test_out_of_scope_data_warns_on_fundamentals(monkeypatch: pytest.MonkeyPatch) -> None: + # The exact reported case: a fundamentals idea the price-volume panel cannot + # express degrades to rank(return_5d) but must NOT look confident -- the + # parse carries an out-of-scope review warning even though the formula is a + # valid non-fallback (rank(return_5d), not rank(close)). + server = ThreadingHTTPServer(("127.0.0.1", 0), FakeMomentumHandler) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + monkeypatch.setenv("QF_TEST_DEEPSEEK_KEY", "test-key") + try: + parsed = parse_factor_idea( + "年报、季报利润同比增加高的公司,在未来21天的表现会更好", + LLMSettings( + provider="deepseek", + model="fake-deepseek", + base_url=f"http://127.0.0.1:{server.server_port}", + api_key_env="QF_TEST_DEEPSEEK_KEY", + ), + ) + finally: + server.shutdown() + thread.join(timeout=5) + + assert parsed.factor.formula == "rank(return_5d)" + assert parsed.warnings, "fundamentals idea must carry a review warning" + assert any("基本面" in w or "price-volume" in w for w in parsed.warnings) + # rank(return_5d) is NOT the rank(close) catch-all, so the generic-fallback + # warning must not be what fired here. + assert GENERIC_FALLBACK_WARNING not in parsed.warnings + + +def test_out_of_scope_detector_recall_and_no_false_positives() -> None: + from quant_forge.specs.nl_flow import out_of_scope_data_warnings + + # Out-of-scope data domains warn. + for idea in ( + "年报、季报利润同比增加高的公司未来更好", + "现金流充裕、负债率低的公司", + "市盈率低、股息率高的股票", + "stocks with rising earnings and revenue", + "high analyst sentiment names", + ): + assert out_of_scope_data_warnings(idea), idea + + # In-scope price-volume ideas and the built-in demo seeds do NOT warn, and + # short English terms never match inside unrelated words (approach/broad). + for idea in ( + "非ST的小市值股票未来表现更好", + "过去5天涨幅较大的股票,短期动量继续", + "低波动率的股票更稳", + "成交量放大的股票", + "估值越低的股票,长期收益越好", # bare 估值 demo seed: keeps rank(close) path + "approach the broad market with high volume", + ): + assert not out_of_scope_data_warnings(idea), idea + + def test_llm_mode_specific_formula_carries_no_fallback_warning(monkeypatch: pytest.MonkeyPatch) -> None: server = ThreadingHTTPServer(("127.0.0.1", 0), FakeLLMHandler) thread = Thread(target=server.serve_forever, daemon=True)