Files
sanguo_vnpy_v2/tests/api/test_routes_pipeline_hypotheses.py
T
claude_dev 877723ab4a
CI/CD / test (push) Successful in 40s
CI/CD / nas-deploy (push) Successful in 30s
CI/CD / nas-verify (push) Successful in 9s
feat(api+frontend): D7 落成边亮灯——假设→因子边可见性三处 [vps]
体验稿(8823)逐条过审拍板后动工,不造新机器只做可见性:

件① 三件套(spec §7):
- 1a 卡片已产因子清单:列表项内联 factors(registry hypothesis
  血统反查:名/表达式/origin/落成日);单因子名 factorId 前端退役
- 1b 分解批次历史:GET /pipeline/hypotheses/{id}/decompose-jobs
  台账新→旧(QA tasks/list 端点形状照抄),卡片折叠区按需拉取
- 1c 轮次进度:run_decompose 每轮 on_progress 回调→内存 job
  progress(currentRound/totalRounds/passed/regen;真轮数非 QA 摆设),
  台账仍只记起跑+终态;worker 协议改收 job_id

件② 工厂来源列:factors 端点带 origin/hypothesis 血统,
⚡decomposer/✍manual 徽标+回链假设池

件③ 琥珀待办:todos 聚合加 queued/data_check 卡(已确认未分解),
分解转 building 即消行——人工卡点②显性化

件④ 结果持久落卡:前端一次性弹层退役,终态摘要+清单+批次全在卡

测试:后端 TestD7Visibility 六件(历史端点/内联清单/进度中飞可见/
终态不带过程态/琥珀消行/工厂血统)+前端三件套三测;8 目录 2780 绿
2026-10-09 11:51:00 +08:00

508 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# tests/api/test_routes_pipeline_hypotheses.py
"""向导三端点:draft 不落库/保存再验/列表.全部 mock LLM,零真调用.
分解=job 契约(10-09):POST 202 立返+后台任务,卡片列表项 decomposeJob
轮询到终态——fixture 须 with TestClient 保活事件循环(裸用=每请求独立
portal,后台 asyncio 任务响应后即被弃);decompose_jobs 内存字典逐测清空.
"""
import pytest
from fastapi.testclient import TestClient
from sanguo_api import hypothesis_card as hc
from sanguo_api.app import create_app
from sanguo_api.auth import create_token, set_jwt_config
DRAFT = {
"title": "高管增持公告后 60 日超额收益为正",
"logic": "If 高管真金白银增持,则内部人信息优势预示基本面改善",
"expected_sign": "positive",
"falsifiable": "若增持公告后 60 日超额收益均值<=0 则证伪",
"data_needs": ["corpus_sentiment"],
}
@pytest.fixture()
def client(tmp_path, monkeypatch):
# fixture 逐字对齐 test_routes_pipeline_monthly.py 既有范式,
# 外包 with 保活后台任务(job 化前提,见模块 docstring)
monkeypatch.setenv("SANGUO_PIPELINE_DB", str(tmp_path / "pipeline.db"))
monkeypatch.setenv("SANGUO_LLM_API_KEY", "sk-test")
set_jwt_config(secret="test", expire_minutes=60)
from sanguo_api import decompose_jobs
decompose_jobs._JOBS.clear()
decompose_jobs._LATEST.clear()
app = create_app(db_path=str(tmp_path / "t.db"), file_dir=None)
with TestClient(app) as c:
c.headers.update({"Authorization": f"Bearer {create_token('admin')}"})
yield c
@pytest.fixture()
def fake_llm(client, monkeypatch):
"""把 LLMClient.chat_json 整个替成内存假实现(记录调用)."""
import asyncio
calls, queue = [], []
class Fake:
def __init__(self, *a, **k):
pass
async def chat_json(self, messages, **k):
calls.append(messages)
if queue:
out = queue.pop(0)
if isinstance(out, Exception):
raise out
return out
return dict(DRAFT)
import sanguo_api.routes_pipeline as rp
monkeypatch.setattr(rp, "LLMClient", Fake)
monkeypatch.setattr(hc, "load_domains",
lambda: ["corpus_sentiment", "bars_daily"])
monkeypatch.setattr(rp, "_wizard_domains", hc.load_domains)
return calls, queue
class TestDraft:
def test_draft_returns_five_fields_and_does_not_persist(self, client, fake_llm):
calls, _ = fake_llm
r = client.post("/api/v1/pipeline/hypotheses/draft",
json={"sentence": "高管增持之后股价会涨"})
assert r.status_code == 200
d = r.json()["draft"]
assert d["expectedSign"] == "positive"
assert d["dataNeeds"] == ["corpus_sentiment"]
# prompt 带白名单(同源纪律)
assert "corpus_sentiment" in calls[0][0]["content"]
# 不落库
assert client.get("/api/v1/pipeline/hypotheses").json()["items"] == []
def test_draft_sentence_too_long_400(self, client, fake_llm):
r = client.post("/api/v1/pipeline/hypotheses/draft",
json={"sentence": "长" * 501})
assert r.status_code == 400
def test_draft_llm_failure_502_no_retry_at_route(self, client, fake_llm):
_, queue = fake_llm
from sanguo_api.llm import LLMError
queue.append(LLMError("LLM 返回非 JSON(两轮): 上游体片段 sk-secret123"))
r = client.post("/api/v1/pipeline/hypotheses/draft",
json={"sentence": "x"})
assert r.status_code == 502
# P3-8: detail 固定文案,不透传上游响应体任何片段(端点/账号上下文)
assert r.json()["detail"] == "LLM 上游返回异常"
assert "sk-secret123" not in r.json()["detail"]
def test_draft_llm_bad_fields_502(self, client, fake_llm):
_, queue = fake_llm
queue.append({**DRAFT, "expected_sign": "横盘"})
r = client.post("/api/v1/pipeline/hypotheses/draft",
json={"sentence": "x"})
assert r.status_code == 502
def test_draft_unconfigured_503(self, client, monkeypatch):
monkeypatch.delenv("SANGUO_LLM_API_KEY")
r = client.post("/api/v1/pipeline/hypotheses/draft",
json={"sentence": "x"})
assert r.status_code == 503 and "SANGUO_LLM_API_KEY" in r.json()["detail"]
class TestSave:
def test_save_confirmed_card_201_and_lists(self, client, fake_llm):
body = {"title": DRAFT["title"], "logic": DRAFT["logic"],
"expectedSign": "positive",
"falsifiable": DRAFT["falsifiable"],
"dataNeeds": ["corpus_sentiment"],
"sentence": "高管增持之后股价会涨"}
r = client.post("/api/v1/pipeline/hypotheses", json=body)
assert r.status_code == 201
item = r.json()["item"]
assert item["state"] == "queued" and item["id"].startswith("hyp-")
items = client.get("/api/v1/pipeline/hypotheses").json()["items"]
assert len(items) == 1 and items[0]["falsifiable"] == DRAFT["falsifiable"]
def test_save_fabricated_domain_dropped(self, client, fake_llm):
body = {"title": "t", "logic": "If a,则 b", "expectedSign": "positive",
"falsifiable": "若 a<=0 则证伪",
"dataNeeds": ["corpus_sentiment", "编造域"]}
r = client.post("/api/v1/pipeline/hypotheses", json=body)
assert r.status_code == 201
assert r.json()["item"]["dataNeeds"] == ["corpus_sentiment"]
def test_save_invalid_sign_400(self, client, fake_llm):
body = {"title": "t", "logic": "l", "expectedSign": "横盘",
"falsifiable": "f", "dataNeeds": []}
assert client.post("/api/v1/pipeline/hypotheses",
json=body).status_code == 400
def test_save_sentence_too_long_400(self, client, fake_llm):
body = {"title": "t", "logic": "l", "expectedSign": "positive",
"falsifiable": "f", "dataNeeds": [],
"sentence": "长" * 501}
assert client.post("/api/v1/pipeline/hypotheses",
json=body).status_code == 400
class TestAuth:
def test_no_token_401(self, tmp_path):
set_jwt_config(secret="test", expire_minutes=60)
c = TestClient(create_app(db_path=str(tmp_path / "t.db"), file_dir=None))
assert c.get("/api/v1/pipeline/hypotheses").status_code == 401
DECOMP_GOOD = {"factors": [
{"name": "llm_mom20", "expression": "cs_rank(ts_mean(close, 20))",
"justification": "20 日动量承载假设"}]}
def _seed_card(client):
r = client.post("/api/v1/pipeline/hypotheses", json={
"title": "t", "logic": "If a,则 b", "expectedSign": "positive",
"falsifiable": "若 a<=0 则证伪", "dataNeeds": ["bars_daily"],
"sentence": "s"})
return r.json()["item"]["id"]
def _poll_job(client, hyp):
"""轮询卡片列表项 decomposeJob 到终态(job 契约=前端同款轮询源)."""
import time
for _ in range(200):
items = client.get("/api/v1/pipeline/hypotheses").json()["items"]
cur = next(i for i in items if i["id"] == hyp)["decomposeJob"]
if cur and cur["status"] != "running":
return cur
time.sleep(0.05)
raise AssertionError("decompose job 未到终态(10s)")
def _wait_job(client, hyp):
"""POST 分解→202 立返(running)→轮询到终态,返回终态 job."""
r = client.post(f"/api/v1/pipeline/hypotheses/{hyp}/decompose")
assert r.status_code == 202, r.text
assert r.json()["job"]["status"] == "running"
return _poll_job(client, hyp)
class TestDecompose:
def test_decompose_registers_and_moves_card(self, client, fake_llm,
tmp_path, monkeypatch):
import yaml as _yaml
reg = tmp_path / "factor_reg.yaml"
monkeypatch.setenv("SANGUO_FACTOR_REGISTRY", str(reg))
_, queue = fake_llm
queue.append(DECOMP_GOOD)
hyp = _seed_card(client)
job = _wait_job(client, hyp)
assert job["status"] == "completed" and job["rounds"] == 1
assert len(job["registered"]) == 1
assert job["registered"][0]["source"] == "bars_daily"
items = client.get("/api/v1/pipeline/hypotheses").json()["items"]
item = next(i for i in items if i["id"] == hyp)
assert item["state"] == "building"
assert item["factorId"] == "llm_mom20"
doc = _yaml.safe_load(reg.read_text())["factors"]["llm_mom20"]
assert doc["hypothesis"] == hyp and doc["status"] == "incubating"
assert doc["origin"] == "decomposer" # #91① 条目级身份戳建条即落
v = doc["versions"][0]
assert v["params"]["expression"].startswith("cs_rank")
assert v["params"]["origin"] == "decomposer"
assert v["params"]["source"] == "bars_daily"
def test_decompose_feedback_second_round(self, client, fake_llm,
tmp_path, monkeypatch):
monkeypatch.setenv("SANGUO_FACTOR_REGISTRY",
str(tmp_path / "fr.yaml"))
_, queue = fake_llm
queue.append({"factors": [
{"name": "llm_bad", "expression": "cs_rank(pledge_pct)",
"justification": "j"}]})
queue.append(DECOMP_GOOD)
hyp = _seed_card(client)
job = _wait_job(client, hyp)
assert job["rounds"] == 2
assert job["registered"][0]["name"] == "llm_mom20"
def test_decompose_404_409_503(self, client, fake_llm, monkeypatch):
assert client.post("/api/v1/pipeline/hypotheses/hyp-nope/decompose"
).status_code == 404
hyp = _seed_card(client)
from sanguo_portfolio import pipeline_store
pipeline_store.update_hypothesis_state(
pipeline_store.default_pipeline_db_path(), hyp, "graveyard",
death_reason="x")
assert client.post(f"/api/v1/pipeline/hypotheses/{hyp}/decompose"
).status_code == 409
monkeypatch.delenv("SANGUO_LLM_API_KEY")
hyp2 = _seed_card(client)
r = client.post(f"/api/v1/pipeline/hypotheses/{hyp2}/decompose")
assert r.status_code == 503
def test_decompose_llm_error_job_failed_no_leak(self, client, fake_llm):
_, queue = fake_llm
from sanguo_api.llm import LLMError
queue.append(LLMError("LLM 重试耗尽(HTTP 429): 上游体片段 sk-secret123"))
hyp = _seed_card(client)
job = _wait_job(client, hyp)
assert job["status"] == "failed"
# P3-8: job.error 固定文案,不透传上游响应体任何片段
assert job["error"] == "LLM 上游返回异常"
assert "sk-secret123" not in job["error"]
def test_decompose_twice_building_is_incremental(self, client, fake_llm,
tmp_path, monkeypatch):
# building 态二次分解:I-1 修复——D3 矩阵无 building→building 自迁移边,
# 二次分解=纯增量注册,卡片保持 building(I-1 前为 500 半成功)
import yaml as _yaml
reg = tmp_path / "fr_twice.yaml"
monkeypatch.setenv("SANGUO_FACTOR_REGISTRY", str(reg))
_, queue = fake_llm
queue.append(DECOMP_GOOD)
hyp = _seed_card(client)
job1 = _wait_job(client, hyp)
assert job1["status"] == "completed"
items = client.get("/api/v1/pipeline/hypotheses").json()["items"]
assert next(i for i in items if i["id"] == hyp)["state"] == "building"
queue.append({"factors": [
{"name": "llm_vol5", "expression": "cs_rank(ts_std(close, 5))",
"justification": "5 日波动承载假设"}]})
job2 = _wait_job(client, hyp)
assert job2["status"] == "completed"
assert job2["registered"][0]["name"] == "llm_vol5"
items = client.get("/api/v1/pipeline/hypotheses").json()["items"]
assert next(i for i in items if i["id"] == hyp)["state"] == "building"
factors = _yaml.safe_load(reg.read_text())["factors"]
assert "llm_mom20" in factors and "llm_vol5" in factors # 两次注册都在
def test_decompose_lock_exists(self):
# P2-6:decompose 全程持 _DECOMPOSE_LOCK(仿 _GRADUATE_LOCK,进程内
# 串行化同卡并发,registry 整文件覆盖丢更新窗口闭合)。并发真测不做
# ——线程池+async 端点组合测不划算;锁内注册+转态的行为回归由本类
# 既有各例钉死,此处只钉锁存在防回退。
import threading
from sanguo_api import routes_pipeline as rp
assert isinstance(rp._DECOMPOSE_LOCK, type(threading.Lock()))
assert isinstance(rp._GRADUATE_LOCK, type(threading.Lock()))
class TestDecomposeJobs:
"""job 化新增语义(10-09,QA 范式):202 立返/在飞 409/台账跨重启/僵尸判死."""
def test_post_returns_202_running_immediately(self, client, fake_llm,
tmp_path, monkeypatch):
import asyncio
monkeypatch.setenv("SANGUO_FACTOR_REGISTRY", str(tmp_path / "r.yaml"))
hyp = _seed_card(client)
async def slow(client_, card, **k):
await asyncio.sleep(0.4)
return {"passed": [], "failed": [], "rounds": 1}
import sanguo_api.routes_pipeline as rp
monkeypatch.setattr(rp, "_run_decompose", slow)
r = client.post(f"/api/v1/pipeline/hypotheses/{hyp}/decompose")
assert r.status_code == 202, r.text
job = r.json()["job"]
assert job["status"] == "running" and job["jobId"].startswith("job-")
# 在飞期间二次 POST → 409(同卡互斥,治重复点击堆积)
r2 = client.post(f"/api/v1/pipeline/hypotheses/{hyp}/decompose")
assert r2.status_code == 409
assert _poll_job(client, hyp)["status"] == "completed"
def test_result_survives_memory_clear(self, client, fake_llm,
tmp_path, monkeypatch):
"""台账跨重启:内存清空(=服务重启)后已完成结果仍可读(run 台账先于页面)."""
monkeypatch.setenv("SANGUO_FACTOR_REGISTRY", str(tmp_path / "r2.yaml"))
_, queue = fake_llm
queue.append(DECOMP_GOOD)
hyp = _seed_card(client)
assert _wait_job(client, hyp)["status"] == "completed"
from sanguo_api import decompose_jobs
decompose_jobs._JOBS.clear()
decompose_jobs._LATEST.clear()
items = client.get("/api/v1/pipeline/hypotheses").json()["items"]
cur = next(i for i in items if i["id"] == hyp)["decomposeJob"]
assert cur["status"] == "completed"
assert cur["registered"][0]["name"] == "llm_mom20"
def test_zombie_running_reconciled_failed(self, client, tmp_path,
monkeypatch):
"""台账僵尸 running(重启打断)无内存主→读侧判 failed 并回写."""
from sanguo_portfolio import pipeline_store
hyp = _seed_card(client)
pipeline_store.upsert_decompose_job(
pipeline_store.default_pipeline_db_path(),
{"jobId": "job-zombie", "hypId": hyp, "status": "running",
"rounds": None, "registered": [], "failed": [], "error": None,
# 未来时间戳保证它是最新的(started_at DESC 取这条)
"startedAt": "2099-01-01T00:00:00", "finishedAt": None})
items = client.get("/api/v1/pipeline/hypotheses").json()["items"]
cur = next(i for i in items if i["id"] == hyp)["decomposeJob"]
assert cur["status"] == "failed"
assert "中断" in cur["error"]
# 回写台账:再读仍 failed(一处真相,非每次读侧重算)
row = pipeline_store.latest_decompose_job(
pipeline_store.default_pipeline_db_path(), hyp)
assert row["status"] == "failed"
class TestD7Visibility:
"""D7 落成边亮灯(10-09 拍板):三件套数据面+工厂 origin+琥珀待办."""
def test_decompose_jobs_history_endpoint(self, client, tmp_path):
"""件①1b:批次历史端点=台账新→旧(QA tasks/list 同形状 {items})."""
from sanguo_portfolio import pipeline_store
db = pipeline_store.default_pipeline_db_path()
hyp = _seed_card(client)
for i, (jid, st) in enumerate([("job-old", "completed"),
("job-new", "completed")]):
pipeline_store.upsert_decompose_job(db, {
"jobId": jid, "hypId": hyp, "status": st, "rounds": 1,
"registered": [{"name": f"f{i}", "source": "bars_daily",
"expression": "e", "justification": "j"}],
"failed": [], "error": None,
"startedAt": f"2026-10-09T10:1{i}:00", "finishedAt": None})
r = client.get(f"/api/v1/pipeline/hypotheses/{hyp}/decompose-jobs")
assert r.status_code == 200
items = r.json()["items"]
assert [i["jobId"] for i in items] == ["job-new", "job-old"]
assert items[0]["registered"][0]["name"] == "f1"
# 不存在的卡 404
assert client.get("/api/v1/pipeline/hypotheses/hyp-x/decompose-jobs"
).status_code == 404
def test_hypotheses_list_includes_factors(self, client, fake_llm,
tmp_path, monkeypatch):
"""件①1a:列表项内联血统反查因子清单(名/表达式/origin/落成日)."""
monkeypatch.setenv("SANGUO_FACTOR_REGISTRY", str(tmp_path / "r1.yaml"))
_, queue = fake_llm
queue.append(DECOMP_GOOD)
hyp = _seed_card(client)
assert _wait_job(client, hyp)["status"] == "completed"
items = client.get("/api/v1/pipeline/hypotheses").json()["items"]
item = next(i for i in items if i["id"] == hyp)
assert [f["name"] for f in item["factors"]] == ["llm_mom20"]
assert item["factors"][0]["origin"] == "decomposer"
assert item["factors"][0]["expression"].startswith("cs_rank")
assert item["factors"][0]["createdAt"] # 落成日=首版 effective_from
def test_progress_reports_rounds_while_running(self, client, fake_llm,
tmp_path, monkeypatch):
"""件①1c:在飞卡片 decomposeJob.progress=进程内真轮数(非 QA 摆设)."""
import asyncio
monkeypatch.setenv("SANGUO_FACTOR_REGISTRY", str(tmp_path / "r3.yaml"))
hyp = _seed_card(client)
seen = []
async def slow(client_, card, **k):
cb = k.get("on_progress")
if cb:
cb(round_no=1, total_rounds=3, passed=3, regen=1,
message="第 1/3 轮校验完成")
await asyncio.sleep(0.15)
cb(round_no=2, total_rounds=3, passed=6, regen=1,
message="第 2/3 轮失败者反馈重生成中")
await asyncio.sleep(0.15)
seen.append(True)
return {"passed": [], "failed": [], "rounds": 3}
import sanguo_api.routes_pipeline as rp
monkeypatch.setattr(rp, "_run_decompose", slow)
r = client.post(f"/api/v1/pipeline/hypotheses/{hyp}/decompose")
assert r.status_code == 202
job = _poll_job(client, hyp)
assert job["status"] == "completed"
# 进度曾可观测(轮询窗口内读到 progress;0.15s×2>轮询间隔 50ms)
items = client.get("/api/v1/pipeline/hypotheses").json()["items"]
cur = next(i for i in items if i["id"] == hyp)["decomposeJob"]
assert cur.get("progress") is None # 终态不带过程态
def test_progress_visible_mid_flight(self, client, fake_llm, tmp_path,
monkeypatch):
"""件①1c 真断言:running 期间列表项 progress.currentRound 递增可见."""
import asyncio
import time
monkeypatch.setenv("SANGUO_FACTOR_REGISTRY", str(tmp_path / "r4.yaml"))
hyp = _seed_card(client)
async def slow(client_, card, **k):
cb = k.get("on_progress")
if cb:
cb(round_no=1, total_rounds=3, passed=3, regen=1,
message="第 1/3 轮校验完成")
await asyncio.sleep(0.4)
return {"passed": [], "failed": [], "rounds": 1}
import sanguo_api.routes_pipeline as rp
monkeypatch.setattr(rp, "_run_decompose", slow)
assert client.post(
f"/api/v1/pipeline/hypotheses/{hyp}/decompose").status_code == 202
got = None
for _ in range(80):
items = client.get("/api/v1/pipeline/hypotheses").json()["items"]
cur = next(i for i in items if i["id"] == hyp)["decomposeJob"]
if cur and cur.get("progress"):
got = cur["progress"]
break
time.sleep(0.02)
assert got and got["currentRound"] == 1 and got["totalRounds"] == 3
assert got["passed"] == 3 and got["regen"] == 1
assert _poll_job(client, hyp)["status"] == "completed"
def test_todos_amber_until_decomposed(self, client, fake_llm,
tmp_path, monkeypatch):
"""件③:queued 卡=琥珀待办;分解转 building 即消行."""
monkeypatch.setenv("SANGUO_FACTOR_REGISTRY", str(tmp_path / "r5.yaml"))
_, queue = fake_llm
hyp = _seed_card(client)
def _amber_titles():
return [t["title"] for t in client.get(
"/api/v1/pipeline/todos").json()["items"]
if t["id"] == f"decompose-{hyp}"]
assert _amber_titles(), "queued 卡应上琥珀待办"
queue.append(DECOMP_GOOD)
assert _wait_job(client, hyp)["status"] == "completed"
assert not _amber_titles(), "building 卡不应再上待办"
def test_factors_include_origin_and_hypothesis(self, client, fake_llm,
tmp_path, monkeypatch):
"""件②:工厂列表带 origin 身份戳+来源卡血统(manual 缺省)."""
monkeypatch.setenv("SANGUO_FACTOR_REGISTRY", str(tmp_path / "r6.yaml"))
_, queue = fake_llm
queue.append(DECOMP_GOOD)
hyp = _seed_card(client)
assert _wait_job(client, hyp)["status"] == "completed"
items = client.get("/api/v1/pipeline/factors").json()["items"]
row = next(i for i in items if i["id"] == "llm_mom20")
assert row["origin"] == "decomposer" and row["hypothesis"] == hyp
"""P3-7: save 端点 source 白名单+上限(此前零白名单零上限,任意串入库)."""
class TestSaveSourceGuard:
"""P3-7: save 端点 source 白名单+上限(此前零白名单零上限,任意串入库)."""
def test_save_source_off_whitelist_400(self, client, fake_llm):
body = {"title": "t", "logic": "If a,则 b", "expectedSign": "positive",
"falsifiable": "若 a<=0 则证伪", "dataNeeds": [],
"source": "任意自由文本来源"}
r = client.post("/api/v1/pipeline/hypotheses", json=body)
assert r.status_code == 400 and "source" in r.json()["detail"]
def test_save_source_manual_accepted(self, client, fake_llm):
body = {"title": "t", "logic": "If a,则 b", "expectedSign": "positive",
"falsifiable": "若 a<=0 则证伪", "dataNeeds": [],
"source": "manual"}
r = client.post("/api/v1/pipeline/hypotheses", json=body)
assert r.status_code == 201
assert r.json()["item"]["source"] == "manual"