feat: compose native research workflows in QuantFlow
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
# 第四阶段验收:QuantFlow 原生研究画布
|
||||
|
||||
日期:2026-09-08。范围:流程画布、固定流程版本、原生节点及共用研究执行。所有平台、账户和模型响应均为隔离合成数据,没有调用真实模型或 WorldQuant。
|
||||
|
||||
## 交付
|
||||
|
||||
- 研究编排分组增加 QuantFlow,保留原侧栏、最近访问和 AI 助手。流程库、画布、节点配置、连线、版本与运行记录可直接使用。
|
||||
- 画布支持拖动及方向键移动节点、缩放、适应宽度,配置素材版本、研究说明、变体方法、筛选结论和迭代轮数。草稿保留本地基准版本,冲突更新返回 409;历史版本可查看或另存。
|
||||
- 原生节点包括固定输入、特征方案、模板生成/增强、模板展开、两类变体、回测、规则评估、筛选、条件、汇总、有界迭代。固定六阶段研究可复制为内置配方。
|
||||
- 保存及启动都验证节点唯一性、连接端点、输入输出类型、普通连接无环、可达性、配置和迭代约束。首版一个流程最多 50 节点、100 连线;一个终点迭代节点重复整个流程,最多 100 轮,不能超过本次授权轮数。
|
||||
- 运行固定工作流版本、节点引用的模板/特征版本、输入、种子、规则和预算。启动不含模型节点的流程无需配置模型;所有预算仍须为有限正整数。
|
||||
- 模板、特征及结构变体复用统一表达式模块;设置变体冻结种子表达式及原设置,按独立目标输入计算合法设置,回测前再次核对授权范围。候选上限与采样种子保留。
|
||||
- 条件按规则报告的 pass/review/block 选择出边,未命中分支和空筛选不会执行后续模拟。汇总保存产物引用,避免递归复制整个上游图。
|
||||
- 完全复用研究运行、步骤、预算、预览、回测调度和评估实现;重复事件不增加模拟,刷新后恢复运行和节点产物显示。
|
||||
- 模型返回后先保存产物;特征转换和结构变体展开在下一次活跃步骤进行。暂停、停止、账户改变期间只收集已发结果,不继续转换或展开;恢复不重复已完成模型调用。
|
||||
- AI 可读取已有流程版本、研究运行及预算;普通 Chatbox 不能借来源字段获得自动执行授权。
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 验收项 | 结果 |
|
||||
|---|---|
|
||||
| 非法节点图、连线、循环和配置 | 通过;重复边、断连、类型不符、普通循环、布尔迭代上限、未知脚本配置、非法筛选和素材版本返回 422 |
|
||||
| 保存后修改模板和流程 | 通过;旧运行继续使用 v1,模板更新为无效表达式不影响已启动版本 |
|
||||
| 三路条件与汇总 | 通过;仅一个匹配分支执行,其余两个跳过;汇总保存多个上游引用 |
|
||||
| 固定特征 → 模板 → 展开 → 回测 → 筛选 → 迭代 | 通过;两轮共 4 条模拟、0 次模型调用,特征原版本可追溯 |
|
||||
| 模型特征与结构变体 | 通过;各使用一次模型调用,保存特征/模板、候选及种子关系 |
|
||||
| 市场与设置变体 | 通过;启动后种子被改动仍使用原表达式,目标输入和股票池严格对应,没有调用模型 |
|
||||
| 空筛选、重复完成事件 | 通过;不发后续模拟,不重复回测 |
|
||||
| 模型期间暂停、停止、账户变化 | 通过;模型产物持久化但没有新候选/回测,暂停后恢复复用产物,模型调用数保持 1 |
|
||||
| 后端全量回归 | 最终 220 passed,日志 `/tmp/wq-stage4-full-backend-final.log` |
|
||||
| 静态检查和前端构建 | Ruff、TypeScript、构建及 diff whitespace 检查通过;保留依赖 lottie-web 原有 eval 构建警告 |
|
||||
| 浏览器画布闭环 | 通过;新建 8 节点、连线、键盘移动、保存 v1/v2、预算确认、固定 v2 启动、分支和刷新恢复,模拟 2 条、模型调用 0 次 |
|
||||
| 浏览器回归 | 8 passed,覆盖 QuantFlow、固定流水线、特征/成果、侧栏、4 项 AI 行为;日志 `/tmp/wq-stage4-browser-regression.log` |
|
||||
| PostgreSQL 17 与备份恢复 | 通过;从第三阶段备份恢复,Alembic head 0008 无新增迁移差异,两轮原生图执行与重放正常;pg_dump/pg_restore 后完整运行/步骤快照摘要及旧研究备注一致。脚本 `backend/tests/research_quantflow_postgres.py`,日志 `/tmp/wq-stage4-postgres.log` |
|
||||
|
||||
画布连线路由和适应宽度调整后,单独复验 1 passed,日志 `/tmp/wq-stage4-canvas-final.log`。
|
||||
|
||||
最终截图:`/tmp/wq-stage-four-canvas.png`、`/tmp/wq-stage-four-run.png`。主代理已检查画布、运行状态、来源入口和中文界面。
|
||||
|
||||
独立只读核验已完成。其指出暂停后的模型回调仍执行变体展开,已拆成持久的模型产物与后续转换步骤,增加暂停、停止及账户变化回归。全量测试过程中发现测试辅助函数漏导入和 AI 工具重复注册,均已修正,以上列出的是最终通过结果。
|
||||
|
||||
## 边界与待联调
|
||||
|
||||
首版迭代重复整个流程,普通节点各接受一个上游,汇总允许多个上游;不提供任意脚本、CLI、外部事件自动启动或正式 Alpha 提交。流程编辑与启动分开,扩大预算和范围须重新确认新运行。
|
||||
|
||||
真实 WorldQuant 认证、元数据协议及模拟联调仍待当前对话确认授权。自动审批此前拒绝了读取 account.json 后向官方平台联调的提权命令,理由是未将任务目标记录中的授权认可为当前用户消息授权;没有绕过审批。本轮合成测试不能证明真实字段可用性和平台合法设置协议已联调通过。
|
||||
@@ -7,8 +7,8 @@ Status: ready-for-agent
|
||||
- [x] 一:算子、模板、表达式模块、两类变体与回测来源闭环。
|
||||
- [x] 二:特征方案、保存视图、关系、比较和评估。
|
||||
- [x] 三:固定自动研究、预算、持久步骤、恢复。
|
||||
- [ ] 四:原生 QuantFlow 画布与共用执行。
|
||||
- [ ] 验证:后端、前端、浏览器、PostgreSQL 迁移与恢复。
|
||||
- [x] 四:原生 QuantFlow 画布与共用执行。
|
||||
- [x] 验证:后端、前端、浏览器、PostgreSQL 迁移与恢复。
|
||||
|
||||
## Comments
|
||||
|
||||
@@ -19,3 +19,5 @@ Status: ready-for-agent
|
||||
第二阶段已通过后端、前端、浏览器和 PostgreSQL 迁移/恢复验证;报告见 `../acceptance/stage-2.md`。独立 Alpha 列表布局改动继续留在工作区,本次仅提交保存视图接入。
|
||||
|
||||
第三阶段固定研究及有限授权已验收,报告见 `../acceptance/stage-3.md`。
|
||||
|
||||
第四阶段原生 QuantFlow 已完成本地后端、前端、浏览器及 PostgreSQL 验收,报告见 `../acceptance/stage-4.md`。四阶段实现与模拟验证完成;真实 WorldQuant 联调未执行,限制详见各阶段报告。
|
||||
|
||||
@@ -46,6 +46,7 @@ class PageContext(Contract):
|
||||
"features",
|
||||
"variants",
|
||||
"pipeline",
|
||||
"quantflow",
|
||||
] = "alphas"
|
||||
research_run_id: str | None = Field(default=None, max_length=36)
|
||||
research_asset_id: str | None = Field(default=None, max_length=36)
|
||||
|
||||
@@ -310,8 +310,10 @@ class Experiments:
|
||||
preserve_source=True,
|
||||
)
|
||||
|
||||
async def setting_variants(self, body):
|
||||
parents = await self.parents([body.alpha_id], [])
|
||||
async def setting_variants(self, body, *, parent_snapshot=None, extra_evidence=None, kind="variant"):
|
||||
parents = (
|
||||
[parent_snapshot] if parent_snapshot is not None else await self.parents([body.alpha_id], [])
|
||||
)
|
||||
original = parents[0]
|
||||
base = SimulationSettings.model_validate(original["settings"])
|
||||
expression = original["expression"]
|
||||
@@ -362,12 +364,13 @@ class Experiments:
|
||||
)
|
||||
return await self.save(
|
||||
f"{body.alpha_id} · 设置变体",
|
||||
"variant",
|
||||
kind,
|
||||
body.hypothesis,
|
||||
snapshots,
|
||||
parents,
|
||||
candidates,
|
||||
{
|
||||
**(extra_evidence or {}),
|
||||
"method": "settings",
|
||||
"rejected": rejected,
|
||||
"operators_snapshot": operators_snapshot,
|
||||
|
||||
@@ -22,6 +22,7 @@ from .workspace_contracts import (
|
||||
ImportCommit,
|
||||
ImportPreview,
|
||||
SettingVariants,
|
||||
WorkflowSpec,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/research", tags=["research"], dependencies=[Depends(require_auth)])
|
||||
@@ -41,7 +42,7 @@ async def assets(
|
||||
limit: int = Query(25, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
if kind not in ("template", "feature", "view"):
|
||||
if kind not in ("template", "feature", "view", "workflow"):
|
||||
raise HTTPException(422, "当前素材类型尚未开放")
|
||||
async with request.app.state.sessions() as db:
|
||||
return await Assets(db).list(kind, q, limit, offset)
|
||||
@@ -49,7 +50,7 @@ async def assets(
|
||||
|
||||
@router.post("/assets", status_code=201)
|
||||
async def save_asset(body: AssetWrite, request: Request):
|
||||
if body.kind not in ("template", "feature", "view"):
|
||||
if body.kind not in ("template", "feature", "view", "workflow"):
|
||||
raise HTTPException(422, "当前素材类型尚未开放")
|
||||
async with request.app.state.sessions.begin() as db:
|
||||
return await Assets(db).save(body)
|
||||
@@ -63,7 +64,7 @@ async def asset(asset_id: str, request: Request, version: int | None = Query(Non
|
||||
|
||||
@router.put("/assets/{asset_id}")
|
||||
async def update_asset(asset_id: str, body: AssetWrite, request: Request):
|
||||
if body.kind not in ("template", "feature", "view"):
|
||||
if body.kind not in ("template", "feature", "view", "workflow"):
|
||||
raise HTTPException(422, "当前素材类型尚未开放")
|
||||
async with request.app.state.sessions.begin() as db:
|
||||
return await Assets(db).save(body, asset_id)
|
||||
@@ -240,21 +241,42 @@ async def fixed_recipe():
|
||||
async def start_flow(body: FlowStart, request: Request):
|
||||
from .workflows import Workflows
|
||||
|
||||
if body.workflow_id:
|
||||
raise HTTPException(422, "自定义流程将在 QuantFlow 阶段开放")
|
||||
async with request.app.state.sessions.begin() as db:
|
||||
config = await request.app.state.ai.config(db)
|
||||
result = await Workflows(db).start(body, config.revision)
|
||||
from .workflows import fixed_workflow
|
||||
from .workspace_contracts import WorkflowSpec
|
||||
|
||||
graph = (
|
||||
WorkflowSpec.model_validate(
|
||||
(await Assets(db).get(body.workflow_id, body.workflow_version, "workflow"))["content"]
|
||||
)
|
||||
if body.workflow_id
|
||||
else fixed_workflow(body.budget.max_rounds)
|
||||
)
|
||||
needs_model = any(
|
||||
n.type == "generate"
|
||||
or (n.type == "feature" and not n.config.get("asset_id"))
|
||||
or (n.type == "variant" and n.config.get("method", "structure") == "structure")
|
||||
for n in graph.nodes
|
||||
)
|
||||
config = await request.app.state.ai.config(db) if needs_model else None
|
||||
result = await Workflows(db).start(body, config.revision if config else None)
|
||||
request.app.state.research.wake.set()
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/flows/runs")
|
||||
async def flow_runs(request: Request, limit: int = Query(25, ge=1, le=100), offset: int = Query(0, ge=0)):
|
||||
async def flow_runs(
|
||||
request: Request,
|
||||
limit: int = Query(25, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
kind: str | None = None,
|
||||
):
|
||||
from .workflows import Workflows
|
||||
|
||||
async with request.app.state.sessions() as db:
|
||||
return await Workflows(db).list(limit, offset)
|
||||
if kind not in (None, "pipeline", "quantflow"):
|
||||
raise HTTPException(422, "未知研究运行类型")
|
||||
return await Workflows(db).list(limit, offset, kind)
|
||||
|
||||
|
||||
@router.get("/flows/runs/{run_id}")
|
||||
@@ -276,3 +298,17 @@ async def control_flow(run_id: str, body: FlowControl, request: Request):
|
||||
request.app.state.research.wake.set()
|
||||
request.app.state.runner.backtests.wake.set()
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/flows/nodes")
|
||||
async def flow_nodes():
|
||||
from .workflows import NODE_TYPES
|
||||
|
||||
return {"items": [{"type": key, **value} for key, value in NODE_TYPES.items()]}
|
||||
|
||||
|
||||
@router.post("/flows/validate")
|
||||
async def validate_flow(body: WorkflowSpec):
|
||||
from .workflows import validate_graph
|
||||
|
||||
return {"valid": True, "order": validate_graph(body)}
|
||||
|
||||
+214
-56
@@ -6,21 +6,32 @@ Backtest source fields are provenance, never a grant to execute automatically.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from ..backtests.contracts import ControlInput, StartInput
|
||||
from ..backtests.contracts import ControlInput, SimulationSettings, StartInput
|
||||
from ..backtests.service import Backtests, uid
|
||||
from ..models import Account, ResearchFlowRun, ResearchStepRun, now
|
||||
from .assets import Assets
|
||||
from .evaluations import Evaluations
|
||||
from .experiments import Experiments
|
||||
from .experiments import Experiments, scope_of
|
||||
from .features import Features
|
||||
from .model import request_model
|
||||
from .workflows import validate_graph
|
||||
from .workspace_contracts import AssetWrite, EvaluateInput, Expansion, Generation, TemplateSpec, WorkflowSpec
|
||||
from .workspace_contracts import (
|
||||
AssetWrite,
|
||||
EvaluateInput,
|
||||
Expansion,
|
||||
FeatureSpec,
|
||||
Generation,
|
||||
SettingVariants,
|
||||
TemplateSpec,
|
||||
WorkflowSpec,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
ACTIVE = ("queued", "running")
|
||||
@@ -162,7 +173,8 @@ class ResearchRuntime:
|
||||
model_work = await self.prepare(run_id)
|
||||
if model_work:
|
||||
step_id, context, revision = model_work
|
||||
result, evidence = await request_model(self.ai, context, TemplateSpec, revision)
|
||||
output_type = FeatureSpec if context.get("method") == "feature" else TemplateSpec
|
||||
result, evidence = await request_model(self.ai, context, output_type, revision)
|
||||
await self.finish_model(run_id, step_id, result, evidence)
|
||||
except HTTPException as exc:
|
||||
await self.fail(run_id, str(exc.detail))
|
||||
@@ -271,7 +283,7 @@ class ResearchRuntime:
|
||||
else None
|
||||
)
|
||||
step.output = {
|
||||
**(previous.output if previous else {}),
|
||||
**(previous.output if previous else {"template": run.authorization.get("template")}),
|
||||
"type": "context",
|
||||
"input_ids": run.authorization["input_ids"],
|
||||
}
|
||||
@@ -287,50 +299,45 @@ class ResearchRuntime:
|
||||
}
|
||||
else:
|
||||
return await self.reserve_model(db, run, step, data, node)
|
||||
elif node.type == "feature":
|
||||
reference = run.authorization.get("node_assets", {}).get(key)
|
||||
if step.output.get("feature"):
|
||||
ref = step.output["feature"]
|
||||
reference = await Assets(db).get(ref["id"], ref["version"], "feature")
|
||||
if reference:
|
||||
template = await Features(db).to_template(reference["id"], reference["version"])
|
||||
step.output = {
|
||||
**data,
|
||||
**step.output,
|
||||
"type": "context",
|
||||
"feature": {"id": reference["id"], "version": reference["version"]},
|
||||
"input_ids": reference["content"]["input_ids"],
|
||||
"template": {"id": template["id"], "version": template["version"]},
|
||||
}
|
||||
else:
|
||||
return await self.reserve_model(db, run, step, data, node)
|
||||
elif node.type == "expand":
|
||||
if not data.get("template"):
|
||||
raise HTTPException(422, "展开节点没有固定模板版本")
|
||||
body = Expansion(
|
||||
asset_id=data["template"]["id"],
|
||||
version=data["template"]["version"],
|
||||
input_ids=run.authorization["input_ids"],
|
||||
hypothesis=run.authorization["hypothesis"],
|
||||
settings=run.authorization["settings"],
|
||||
mode="random",
|
||||
limit=run.authorization["batch_candidates"],
|
||||
seed=run.authorization["seed"] + run.round,
|
||||
parent_alpha_ids=run.authorization["parent_alpha_ids"],
|
||||
parent_experiment_ids=data.get("parent_experiment_ids", []),
|
||||
reference = run.authorization.get("node_assets", {}).get(key) or data.get("template")
|
||||
await self.expand(db, run, step, {**data, "template": reference})
|
||||
return
|
||||
elif node.type == "variant":
|
||||
if node.config.get("method", "structure") == "structure":
|
||||
if step.output.get("template"):
|
||||
await self.expand(db, run, step, step.output)
|
||||
return
|
||||
return await self.reserve_model(db, run, step, data, node)
|
||||
experiment = await Experiments(db).setting_variants(
|
||||
SettingVariants(
|
||||
alpha_id=run.authorization["parent_alpha_ids"][0],
|
||||
input_ids=run.authorization["input_ids"],
|
||||
hypothesis=run.authorization["hypothesis"],
|
||||
),
|
||||
parent_snapshot=run.authorization["parents"][0],
|
||||
extra_evidence={"flow_run_id": run.id, "node_id": key, "round": run.round},
|
||||
kind=run.authorization["kind"],
|
||||
)
|
||||
frozen_parents = run.authorization["parents"] + await Experiments(db).parents(
|
||||
[], body.parent_experiment_ids
|
||||
)
|
||||
experiment = await Experiments(db).create(
|
||||
body,
|
||||
"pipeline",
|
||||
{
|
||||
"flow_run_id": run.id,
|
||||
"node_id": key,
|
||||
"round": run.round,
|
||||
"method": "pipeline",
|
||||
"authorized_seed_snapshots": run.authorization["parents"],
|
||||
},
|
||||
parent_snapshots=frozen_parents,
|
||||
)
|
||||
step.output = {
|
||||
"type": "candidates",
|
||||
"experiment_id": experiment["id"],
|
||||
"candidate_ids": [
|
||||
c["client_item_id"]
|
||||
for c in experiment["candidates"]
|
||||
if c["validation"]["status"] == "valid"
|
||||
],
|
||||
"template": data["template"],
|
||||
}
|
||||
if not step.output["candidate_ids"]:
|
||||
step.status, step.error = "blocked", "候选均未通过本地校验,请核实字段、算子和设置"
|
||||
halt(run, "needs_review", step.error)
|
||||
return
|
||||
self.candidates(run, step, experiment, {})
|
||||
return
|
||||
elif node.type == "backtest":
|
||||
await self.backtest(db, run, step, data)
|
||||
return
|
||||
@@ -349,6 +356,55 @@ class ResearchRuntime:
|
||||
"experiment_id": data["experiment_id"],
|
||||
"backtest_run_id": data["backtest_run_id"],
|
||||
}
|
||||
elif node.type == "condition":
|
||||
step.output = {**data, "type": "evaluation"}
|
||||
elif node.type == "filter":
|
||||
report = (await Evaluations(db).get(data["evaluation_id"]))["report"]
|
||||
ids = [
|
||||
r["client_item_id"]
|
||||
for r in report["records"]
|
||||
if r["verdict"] in node.config.get("verdicts", ["pass"])
|
||||
]
|
||||
experiment = await Experiments(db).get(data["experiment_id"])
|
||||
template = experiment["evidence"].get("template")
|
||||
step.output = {
|
||||
**data,
|
||||
"type": "candidates",
|
||||
"candidate_ids": ids,
|
||||
"template": {"id": template["id"], "version": template["version"]}
|
||||
if template
|
||||
else None,
|
||||
}
|
||||
if not ids:
|
||||
step.status, step.updated_at = "skipped", now()
|
||||
return
|
||||
elif node.type == "summarize":
|
||||
# References keep joins bounded, without recursively copying the upstream graph.
|
||||
step.output = {
|
||||
"type": "summary",
|
||||
"artifacts": [
|
||||
{
|
||||
"step_id": steps[e.source].id,
|
||||
"node_id": e.source,
|
||||
**{
|
||||
k: steps[e.source].output[k]
|
||||
for k in (
|
||||
"type",
|
||||
"template",
|
||||
"feature",
|
||||
"experiment_id",
|
||||
"evaluation_id",
|
||||
"backtest_run_id",
|
||||
"verdict",
|
||||
)
|
||||
if k in steps[e.source].output
|
||||
},
|
||||
}
|
||||
for e in upstream_edges
|
||||
if steps[e.source].status != "skipped"
|
||||
and (not e.branch or steps[e.source].output.get("verdict") == e.branch)
|
||||
],
|
||||
}
|
||||
elif node.type == "iterate":
|
||||
step.output = {
|
||||
**data,
|
||||
@@ -370,10 +426,16 @@ class ResearchRuntime:
|
||||
generation = Generation(
|
||||
name=run.name,
|
||||
hypothesis=run.authorization["hypothesis"],
|
||||
input_ids=run.authorization["input_ids"],
|
||||
input_ids=data.get("input_ids", run.authorization["input_ids"]),
|
||||
method="feature"
|
||||
if node.type == "feature"
|
||||
else "structure"
|
||||
if node.type == "variant"
|
||||
else "template",
|
||||
parent_experiment_ids=[data["experiment_id"]] if data.get("experiment_id") else [],
|
||||
)
|
||||
context = await Experiments(db).generation_context(generation)
|
||||
context["method"] = generation.method
|
||||
context["parents"] = run.authorization["parents"] + context["parents"]
|
||||
context["operators"] = run.authorization["operators_snapshot"]["content"]["items"][:100]
|
||||
context["node_prompt"] = node.config.get("prompt", "")
|
||||
@@ -414,9 +476,16 @@ class ResearchRuntime:
|
||||
step = await db.get(ResearchStepRun, step_id)
|
||||
if not step or step.status != "running":
|
||||
return
|
||||
if isinstance(result, FeatureSpec) and set(result.input_ids) != set(
|
||||
[i["id"] for i in step.output["context"]["inputs"]]
|
||||
):
|
||||
raise HTTPException(422, "模型不能改变已固定的输入范围")
|
||||
# A paused/stopped run may collect this already-issued model output, but cannot advance.
|
||||
asset = await Assets(db).save(
|
||||
AssetWrite(kind="template", content=result.model_dump(mode="json")),
|
||||
AssetWrite(
|
||||
kind="feature" if isinstance(result, FeatureSpec) else "template",
|
||||
content=result.model_dump(mode="json"),
|
||||
),
|
||||
provenance={
|
||||
"flow_run_id": run_id,
|
||||
"step_id": step_id,
|
||||
@@ -424,18 +493,92 @@ class ResearchRuntime:
|
||||
"context": step.output["context"],
|
||||
},
|
||||
)
|
||||
feature = (
|
||||
{"id": asset["id"], "version": asset["version"]} if isinstance(result, FeatureSpec) else None
|
||||
)
|
||||
mark_model_attempt(step, "completed")
|
||||
step.output = {
|
||||
"model_attempts": step.output.get("model_attempts", []),
|
||||
"type": "template",
|
||||
"template": {"id": asset["id"], "version": asset["version"]},
|
||||
"type": "context" if feature else "template",
|
||||
"feature": feature,
|
||||
"input_ids": result.input_ids
|
||||
if feature
|
||||
else [i["id"] for i in step.output["context"]["inputs"]],
|
||||
"template": None if feature else {"id": asset["id"], "version": asset["version"]},
|
||||
"parent_experiment_ids": step.output.get("parent_experiment_ids", []),
|
||||
"generation": evidence,
|
||||
"reserved_call": step.output["reserved_call"],
|
||||
}
|
||||
step.status, step.updated_at = "completed", now()
|
||||
node = next(n for n in run.definition["nodes"] if n["id"] == step.node_id)
|
||||
# Keep post-processing durable and separate: pause/account changes are checked again
|
||||
# before converting features or expanding variants on the next active tick.
|
||||
step.status = "generated" if node["type"] in ("variant", "feature") else "completed"
|
||||
step.updated_at = now()
|
||||
changed(run)
|
||||
|
||||
def candidates(self, run, step, experiment, data):
|
||||
step.output = {
|
||||
**data,
|
||||
"type": "candidates",
|
||||
"experiment_id": experiment["id"],
|
||||
"candidate_ids": [
|
||||
c["client_item_id"] for c in experiment["candidates"] if c["validation"]["status"] == "valid"
|
||||
],
|
||||
}
|
||||
ids = step.output["candidate_ids"]
|
||||
maximum = run.authorization["batch_candidates"]
|
||||
if len(ids) > maximum:
|
||||
step.output = {
|
||||
**step.output,
|
||||
"candidate_ids": random.Random(run.authorization["seed"] + run.round).sample(ids, maximum),
|
||||
}
|
||||
step.status, step.updated_at = "completed", now()
|
||||
if not step.output["candidate_ids"]:
|
||||
step.status, step.error = "blocked", "候选均未通过本地校验,请核实字段、算子和设置"
|
||||
if run.status in ACTIVE:
|
||||
halt(run, "needs_review", step.error)
|
||||
|
||||
async def expand(self, db, run, step, data):
|
||||
reference = data.get("template")
|
||||
if not reference:
|
||||
raise HTTPException(422, "展开节点没有固定模板版本")
|
||||
scope = scope_of(SimulationSettings.model_validate(run.authorization["settings"]))
|
||||
ids = [
|
||||
i["id"]
|
||||
for i in run.authorization["inputs"]
|
||||
if i["scope"] == scope and i["id"] in data.get("input_ids", run.authorization["input_ids"])
|
||||
]
|
||||
if not ids:
|
||||
raise HTTPException(422, "模板展开需要与基础设置匹配的固定输入")
|
||||
body = Expansion(
|
||||
asset_id=reference["id"],
|
||||
version=reference["version"],
|
||||
input_ids=ids,
|
||||
hypothesis=run.authorization["hypothesis"],
|
||||
settings=run.authorization["settings"],
|
||||
mode="random",
|
||||
limit=run.authorization["batch_candidates"],
|
||||
seed=run.authorization["seed"] + run.round,
|
||||
parent_alpha_ids=run.authorization["parent_alpha_ids"],
|
||||
parent_experiment_ids=data.get("parent_experiment_ids", []),
|
||||
)
|
||||
frozen_parents = run.authorization["parents"] + await Experiments(db).parents(
|
||||
[], body.parent_experiment_ids
|
||||
)
|
||||
experiment = await Experiments(db).create(
|
||||
body,
|
||||
run.authorization["kind"],
|
||||
{
|
||||
"flow_run_id": run.id,
|
||||
"node_id": step.node_id,
|
||||
"round": run.round,
|
||||
"method": run.authorization["kind"],
|
||||
"authorized_seed_snapshots": run.authorization["parents"],
|
||||
},
|
||||
parent_snapshots=frozen_parents,
|
||||
)
|
||||
self.candidates(run, step, experiment, data)
|
||||
|
||||
async def backtest(self, db, run, step, data):
|
||||
service = Backtests(db)
|
||||
if step.backtest_run_id:
|
||||
@@ -470,12 +613,27 @@ class ResearchRuntime:
|
||||
):
|
||||
raise HTTPException(409, "研究候选预览不再匹配保存的授权步骤")
|
||||
experiment = await Experiments(db).get(step.output["experiment_id"])
|
||||
if experiment["evidence"].get("flow_run_id") != run.id or {
|
||||
s["id"] for s in experiment["inputs"]
|
||||
} != set(run.authorization["input_ids"]):
|
||||
if (
|
||||
experiment["evidence"].get("flow_run_id") != run.id
|
||||
or {s["id"] for s in experiment["inputs"]}.issubset(set(run.authorization["input_ids"])) is False
|
||||
):
|
||||
raise HTTPException(403, "候选不属于此研究运行的固定输入范围")
|
||||
if "backtest" not in run.authorization["methods"] or any(
|
||||
c["settings"] != run.authorization["settings"]
|
||||
c["settings"]
|
||||
not in (
|
||||
run.authorization.get("allowed_settings", [run.authorization["settings"]])
|
||||
if experiment["evidence"].get("method") == "settings"
|
||||
else [run.authorization["settings"]]
|
||||
)
|
||||
or not c.get("input_ids")
|
||||
or any(
|
||||
not any(
|
||||
i["id"] == input_id
|
||||
and i["scope"] == scope_of(SimulationSettings.model_validate(c["settings"]))
|
||||
for i in run.authorization["inputs"]
|
||||
)
|
||||
for input_id in c["input_ids"]
|
||||
)
|
||||
for c in experiment["candidates"]
|
||||
if c["client_item_id"] in step.output["candidate_ids"]
|
||||
):
|
||||
|
||||
@@ -5,7 +5,7 @@ from collections import defaultdict
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from ..backtests.contracts import fingerprint
|
||||
from ..backtests.contracts import SimulationSettings, fingerprint
|
||||
from ..backtests.service import uid
|
||||
from ..models import Account, ResearchFlowRun, ResearchStepRun
|
||||
from .assets import Assets
|
||||
@@ -64,8 +64,12 @@ def validate_graph(graph):
|
||||
if node.type != "summarize" and len(incoming[node.id]) > 1:
|
||||
raise HTTPException(422, "仅汇总节点接受多个上游;其他节点需要唯一输入")
|
||||
allowed = (
|
||||
{"prompt"}
|
||||
if node.type in ("feature", "generate")
|
||||
{"prompt", "asset_id", "version"}
|
||||
if node.type == "feature"
|
||||
else {"asset_id", "version"}
|
||||
if node.type == "expand"
|
||||
else {"prompt"}
|
||||
if node.type == "generate"
|
||||
else {"method"}
|
||||
if node.type == "variant"
|
||||
else {"verdicts"}
|
||||
@@ -76,6 +80,15 @@ def validate_graph(graph):
|
||||
)
|
||||
if set(node.config) - allowed:
|
||||
raise HTTPException(422, f"节点 {node.id} 包含不支持的配置")
|
||||
if "asset_id" in node.config or "version" in node.config:
|
||||
if (
|
||||
not isinstance(node.config.get("asset_id"), str)
|
||||
or not node.config["asset_id"]
|
||||
or len(node.config["asset_id"]) > 36
|
||||
or type(node.config.get("version")) is not int
|
||||
or node.config["version"] < 1
|
||||
):
|
||||
raise HTTPException(422, "节点素材引用需要 ID 和正整数版本")
|
||||
if "prompt" in node.config and (
|
||||
not isinstance(node.config["prompt"], str) or len(node.config["prompt"]) > 10000
|
||||
):
|
||||
@@ -84,7 +97,7 @@ def validate_graph(graph):
|
||||
raise HTTPException(422, "未知变体方法")
|
||||
if node.type == "filter" and (
|
||||
not isinstance(node.config.get("verdicts", ["pass"]), list)
|
||||
or not set(node.config.get("verdicts", ["pass"])).issubset({"pass", "review", "block"})
|
||||
or any(v not in ("pass", "review", "block") for v in node.config.get("verdicts", ["pass"]))
|
||||
):
|
||||
raise HTTPException(422, "筛选结果必须为 pass/review/block")
|
||||
if node.type == "iterate" and (
|
||||
@@ -169,8 +182,48 @@ class Workflows:
|
||||
if node.type == "iterate" and node.config["max_rounds"] > body.budget.max_rounds:
|
||||
raise HTTPException(422, "流程迭代上限超过本次授权轮数")
|
||||
experiments = Experiments(self.db)
|
||||
inputs, _ = await experiments.inputs(body.input_ids, scope_of(body.settings))
|
||||
settings_variant = any(
|
||||
n.type == "variant" and n.config.get("method") == "settings" for n in graph.nodes
|
||||
)
|
||||
if settings_variant and len(body.parent_alpha_ids) != 1:
|
||||
raise HTTPException(422, "设置变体需要且只能选择一个种子 Alpha")
|
||||
if any(n.type == "variant" for n in graph.nodes) and not body.parent_alpha_ids:
|
||||
raise HTTPException(422, "变体节点需要种子 Alpha")
|
||||
inputs, _ = await experiments.inputs(
|
||||
body.input_ids, None if settings_variant else scope_of(body.settings)
|
||||
)
|
||||
parents = await experiments.parents(body.parent_alpha_ids, [])
|
||||
allowed_settings = [body.settings.model_dump(mode="json")]
|
||||
if settings_variant:
|
||||
base = SimulationSettings.model_validate(parents[0]["settings"])
|
||||
for snapshot in inputs:
|
||||
scope = snapshot["scope"]
|
||||
target = SimulationSettings.model_validate(
|
||||
{
|
||||
**base.model_dump(),
|
||||
"instrumentType": scope["instrument_type"],
|
||||
**{key: scope[key] for key in ("region", "universe", "delay")},
|
||||
}
|
||||
)
|
||||
errors, _ = await experiments.settings_check(target)
|
||||
if errors:
|
||||
raise HTTPException(422, "目标范围:" + ";".join(errors))
|
||||
if target.model_dump(mode="json") not in allowed_settings:
|
||||
allowed_settings.append(target.model_dump(mode="json"))
|
||||
node_assets = {}
|
||||
for node in graph.nodes:
|
||||
if node.config.get("asset_id"):
|
||||
ref = await Assets(self.db).get(
|
||||
node.config["asset_id"],
|
||||
node.config["version"],
|
||||
"feature" if node.type == "feature" else "template",
|
||||
)
|
||||
if node.type == "feature":
|
||||
if not set(ref["content"]["input_ids"]).issubset(body.input_ids):
|
||||
raise HTTPException(422, "特征方案输入超出本次固定范围")
|
||||
if not ref["content"].get("template"):
|
||||
raise HTTPException(422, "特征方案需要输出模板")
|
||||
node_assets[node.id] = ref
|
||||
errors, settings_snapshot = await experiments.settings_check(body.settings)
|
||||
if errors:
|
||||
raise HTTPException(422, ";".join(errors))
|
||||
@@ -187,6 +240,7 @@ class Workflows:
|
||||
if (
|
||||
any(
|
||||
node.type == "expand"
|
||||
and not node.config.get("asset_id")
|
||||
and any(
|
||||
e.target == node.id and next(n for n in graph.nodes if n.id == e.source).type == "input"
|
||||
for e in graph.edges
|
||||
@@ -212,6 +266,8 @@ class Workflows:
|
||||
"template": template,
|
||||
"workflow": asset,
|
||||
"methods": methods,
|
||||
"node_assets": node_assets,
|
||||
"allowed_settings": allowed_settings,
|
||||
"settings_snapshot": settings_snapshot,
|
||||
"operators_snapshot": operators_snapshot,
|
||||
"kind": "quantflow" if asset else "pipeline",
|
||||
@@ -272,13 +328,16 @@ class Workflows:
|
||||
}
|
||||
)
|
||||
|
||||
async def list(self, limit=25, offset=0):
|
||||
async def list(self, limit=25, offset=0, kind=None):
|
||||
query = select(ResearchFlowRun)
|
||||
if kind:
|
||||
query = query.where(ResearchFlowRun.authorization["kind"].as_string() == kind)
|
||||
rows = await self.db.scalars(
|
||||
select(ResearchFlowRun).order_by(ResearchFlowRun.created_at.desc()).limit(limit).offset(offset)
|
||||
query.order_by(ResearchFlowRun.created_at.desc()).limit(limit).offset(offset)
|
||||
)
|
||||
return {
|
||||
"items": [await self.get(row.id) for row in rows],
|
||||
"total": await self.db.scalar(select(func.count()).select_from(ResearchFlowRun)),
|
||||
"total": await self.db.scalar(select(func.count()).select_from(query.subquery())),
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
|
||||
@@ -184,6 +184,24 @@ async def list_flows(ctx, args):
|
||||
|
||||
|
||||
CAPABILITIES += (
|
||||
Capability(
|
||||
name="search_research_workflows",
|
||||
schema=AssetQuery,
|
||||
description="分页查阅 QuantFlow 原生流程与版本,不启动运行。",
|
||||
label="搜索研究流程",
|
||||
renderer="research",
|
||||
effect="query",
|
||||
handler=lambda ctx, args: Assets(ctx.business.db).list("workflow", **args.model_dump()),
|
||||
),
|
||||
Capability(
|
||||
name="get_research_workflow",
|
||||
schema=FixedAssetReference,
|
||||
description="读取指定研究流程版本及原生节点连接,配合 get_research_run 解释执行产物。",
|
||||
label="读取流程版本",
|
||||
renderer="research",
|
||||
effect="query",
|
||||
handler=lambda ctx, args: Assets(ctx.business.db).get(args.asset_id, args.version, "workflow"),
|
||||
),
|
||||
Capability(
|
||||
name="get_research_run",
|
||||
schema=FlowReference,
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Stage-four PostgreSQL acceptance in dedicated databases only."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
NAME = "wq_research_stage4_test"
|
||||
RESTORE = "wq_research_restore_stage4"
|
||||
os.environ.update(
|
||||
DATABASE_URL=f"postgresql+asyncpg://postgres:research-test-only@127.0.0.1:18436/{NAME}",
|
||||
ADMIN_PASSWORD="research-acceptance-only",
|
||||
ENCRYPTION_KEY=Fernet.generate_key().decode(),
|
||||
)
|
||||
|
||||
|
||||
def docker(*args, **kwargs):
|
||||
return subprocess.run(["docker", "exec", "-i", "wq-research-acceptance-pg", *args], check=True, **kwargs)
|
||||
|
||||
|
||||
async def acceptance():
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import Settings
|
||||
from app.main import create_app
|
||||
from app.models import TemplateInput
|
||||
from app.research.workspace_contracts import TemplateSpec
|
||||
from tests.test_ai import configure
|
||||
from tests.test_backtests import setup
|
||||
from tests.test_quantflow import graph, launch, save
|
||||
from tests.test_research_flows import drive
|
||||
from tests.test_research_workspace import template
|
||||
|
||||
app = create_app(Settings(_env_file=None, enable_runner=False, public_origin="http://testserver"))
|
||||
async with app.router.lifespan_context(app):
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app), base_url="http://testserver", headers={"X-WQ-Request": "1"}
|
||||
) as client:
|
||||
assert (
|
||||
await client.post(
|
||||
"/api/v1/auth/login", json={"username": "admin", "password": "research-acceptance-only"}
|
||||
)
|
||||
).status_code == 200
|
||||
# Configure a deterministic model; no provider or real platform network.
|
||||
from tests.ai_fake import fake_model
|
||||
|
||||
app.state.ai.model_factory = fake_model
|
||||
await configure(app, client)
|
||||
platform, lane = await setup(app)
|
||||
calls = []
|
||||
|
||||
async def model(ai, context, output_type, revision):
|
||||
calls.append(context)
|
||||
value = template()
|
||||
value["expression"] = f"rank({{field}}) + {len(calls)}"
|
||||
return TemplateSpec.model_validate(value), {
|
||||
"model": "fixture",
|
||||
"revision": revision,
|
||||
"usage": {"requests": 1},
|
||||
}
|
||||
|
||||
async with app.state.sessions() as db:
|
||||
fixed = await db.scalar(select(TemplateInput))
|
||||
body = {
|
||||
"request_id": "finite-run",
|
||||
"name": "PG 有限研究",
|
||||
"input_ids": [fixed.id],
|
||||
"hypothesis": "排名稳定性",
|
||||
"settings": {"region": "USA", "universe": "TOP3000", "delay": 1},
|
||||
"budget": {"max_rounds": 2, "max_simulations": 4, "max_model_calls": 3},
|
||||
"batch_candidates": 2,
|
||||
}
|
||||
body["request_id"] = "pg-quantflow"
|
||||
fixed_template = await save(client, "template", template())
|
||||
definition = graph("input", "expand", "backtest", "evaluate", "filter", "iterate")
|
||||
definition["nodes"][1]["config"] = {"asset_id": fixed_template["id"], "version": 1}
|
||||
definition["nodes"][4]["config"] = {"verdicts": ["pass", "review", "block"]}
|
||||
with patch("app.research.runtime.request_model", model):
|
||||
run = await launch(client, body, definition)
|
||||
result = await drive(app, client, run["id"], lane, ticks=60)
|
||||
assert result["status"] == "completed", result
|
||||
assert (
|
||||
result["round"] == 2
|
||||
and result["simulations_used"] == 4
|
||||
and result["model_calls_used"] == 0
|
||||
)
|
||||
assert not calls and len(platform.posts) == 2
|
||||
await app.state.research.recover()
|
||||
await app.state.research.advance(run["id"])
|
||||
assert len(platform.posts) == 2
|
||||
print("PASS PostgreSQL: versioned native graph, filtering, bounded iteration and replay")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
docker("createdb", "-U", "postgres", NAME)
|
||||
with Path("/tmp/wq-research-stage3.dump").open("rb") as source:
|
||||
docker("pg_restore", "-U", "postgres", "-d", NAME, stdin=source)
|
||||
config = Config("alembic.ini")
|
||||
command.upgrade(config, "0008")
|
||||
command.check(config)
|
||||
asyncio.run(acceptance())
|
||||
dump = Path("/tmp/wq-research-stage4.dump")
|
||||
with dump.open("wb") as output:
|
||||
docker("pg_dump", "-U", "postgres", "-Fc", NAME, stdout=output)
|
||||
docker("createdb", "-U", "postgres", RESTORE)
|
||||
with dump.open("rb") as source:
|
||||
docker("pg_restore", "-U", "postgres", "-d", RESTORE, stdin=source)
|
||||
query = "SELECT (SELECT md5(string_agg(row_to_json(t)::text, '' ORDER BY id)) FROM research_flow_runs t),(SELECT md5(string_agg(row_to_json(t)::text, '' ORDER BY id)) FROM research_step_runs t),(SELECT count(*) FROM research_revisions),(SELECT note FROM research WHERE alpha_id='OLD_RESEARCH')"
|
||||
a = docker("psql", "-U", "postgres", "-d", NAME, "-Atc", query, capture_output=True).stdout
|
||||
b = docker("psql", "-U", "postgres", "-d", RESTORE, "-Atc", query, capture_output=True).stdout
|
||||
assert a == b
|
||||
print(
|
||||
"PASS PostgreSQL 17: head 0008 unchanged, pg_dump/pg_restore preserves complete run/step snapshots and old research notes"
|
||||
)
|
||||
@@ -0,0 +1,286 @@
|
||||
"""Native graph execution shares immutable inputs, artifacts and budgeted backtests."""
|
||||
|
||||
import copy
|
||||
|
||||
import pytest
|
||||
|
||||
from app.alphas import upsert_alpha
|
||||
from app.models import Alpha, CatalogResource
|
||||
from app.research.workspace_contracts import FeatureSpec
|
||||
from tests.conftest import alpha
|
||||
from tests.test_catalog import SCOPE, prepare, sync
|
||||
from tests.test_research_flows import begin, drive, flow_setup, get
|
||||
from tests.test_research_workspace import catalog, research_input, template
|
||||
|
||||
__all__ = ["flow_setup", "catalog", "research_input"]
|
||||
|
||||
|
||||
def graph(*kinds):
|
||||
return {
|
||||
"name": "原生节点研究",
|
||||
"nodes": [
|
||||
{
|
||||
"id": f"n{i}",
|
||||
"type": kind,
|
||||
"label": kind,
|
||||
"config": {"max_rounds": 2} if kind == "iterate" else {},
|
||||
}
|
||||
for i, kind in enumerate(kinds)
|
||||
],
|
||||
"edges": [{"source": f"n{i}", "target": f"n{i + 1}"} for i in range(len(kinds) - 1)],
|
||||
}
|
||||
|
||||
|
||||
async def save(client, kind, content):
|
||||
r = await client.post("/api/v1/research/assets", json={"kind": kind, "content": content})
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
async def launch(client, body, definition):
|
||||
asset = await save(client, "workflow", definition)
|
||||
return await begin(client, {**body, "workflow_id": asset["id"], "workflow_version": asset["version"]})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case", ["cycle", "type", "duplicate", "disconnected", "loop", "config", "filter", "ref"]
|
||||
)
|
||||
async def test_invalid_graphs_rejected_before_save(logged_in, case):
|
||||
value = graph("input", "generate", "expand", "backtest", "evaluate")
|
||||
if case == "cycle":
|
||||
value["nodes"] += [{"id": "loop_a", "type": "generate"}, {"id": "loop_b", "type": "expand"}]
|
||||
value["edges"] += [{"source": "loop_a", "target": "loop_b"}, {"source": "loop_b", "target": "loop_a"}]
|
||||
if case == "type":
|
||||
value["edges"][0]["target"] = "n3"
|
||||
if case == "duplicate":
|
||||
value["edges"].append(value["edges"][0])
|
||||
if case == "disconnected":
|
||||
value["edges"].pop()
|
||||
if case == "loop":
|
||||
value["nodes"].append({"id": "iterate", "type": "iterate", "config": {"max_rounds": True}})
|
||||
value["edges"].append({"source": "n4", "target": "iterate"})
|
||||
if case == "config":
|
||||
value["nodes"][1]["config"] = {"command": "bash"}
|
||||
if case == "filter":
|
||||
value["nodes"].append({"id": "filter", "type": "filter", "config": {"verdicts": [{}]}})
|
||||
value["edges"].append({"source": "n4", "target": "filter"})
|
||||
if case == "ref":
|
||||
value["nodes"][2]["config"] = {"asset_id": "x", "version": True}
|
||||
for url in ("/api/v1/research/flows/validate", "/api/v1/research/assets"):
|
||||
r = await logged_in.post(
|
||||
url, json=value if url.endswith("validate") else {"kind": "workflow", "content": value}
|
||||
)
|
||||
assert r.status_code == 422, r.text
|
||||
|
||||
|
||||
async def test_versioned_template_branch_join_and_replayed_events(app, logged_in, flow_setup):
|
||||
body, platform, lane, calls = flow_setup
|
||||
fixed = await save(logged_in, "template", template())
|
||||
definition = graph("input", "expand", "backtest", "evaluate", "condition")
|
||||
definition["nodes"][1]["config"] = {"asset_id": fixed["id"], "version": 1}
|
||||
for verdict in ("pass", "review", "block"):
|
||||
definition["nodes"].append({"id": verdict, "type": "summarize"})
|
||||
definition["edges"].append({"source": "n4", "target": verdict, "branch": verdict})
|
||||
# A shared join receives the condition's evidence as well as direct evaluation.
|
||||
definition["nodes"].append({"id": "summary", "type": "summarize"})
|
||||
definition["edges"] += [{"source": "n4", "target": "summary"}, {"source": "n3", "target": "summary"}]
|
||||
run = await launch(logged_in, body, definition)
|
||||
workflow = run["authorization"]["workflow"]
|
||||
modified = copy.deepcopy(definition)
|
||||
modified["nodes"][1]["config"]["version"] = 2
|
||||
assert (
|
||||
await logged_in.put(
|
||||
f"/api/v1/research/assets/{workflow['id']}",
|
||||
json={"kind": "workflow", "version": 1, "content": modified},
|
||||
)
|
||||
).status_code == 200
|
||||
value = template()
|
||||
value["expression"] = "unknown({field})"
|
||||
assert (
|
||||
await logged_in.put(
|
||||
f"/api/v1/research/assets/{fixed['id']}",
|
||||
json={"kind": "template", "version": 1, "content": value},
|
||||
)
|
||||
).status_code == 200
|
||||
final = await drive(app, logged_in, run["id"], lane)
|
||||
assert final["status"] == "completed", final
|
||||
assert final["model_calls_used"] == 0 and not calls and final["simulations_used"] == 2
|
||||
branches = [s for s in final["steps"] if s["node_id"] in ("pass", "review", "block")]
|
||||
verdict = next(s for s in final["steps"] if s["node_id"] == "n3")["output"]["verdict"]
|
||||
assert [s["node_id"] for s in branches if s["status"] == "completed"] == [verdict]
|
||||
assert sum(s["status"] == "skipped" for s in branches) == 2
|
||||
assert len(next(s for s in final["steps"] if s["node_id"] == "summary")["output"]["artifacts"]) == 2
|
||||
assert final["authorization"]["node_assets"]["n1"]["version"] == 1
|
||||
for _ in range(3):
|
||||
await app.state.research.advance(run["id"])
|
||||
assert len(platform.posts) == 1
|
||||
assert (await logged_in.get("/api/v1/research/flows/runs?kind=quantflow")).json()["total"] == 1
|
||||
assert (await logged_in.get("/api/v1/research/flows/runs?kind=pipeline")).json()["total"] == 0
|
||||
|
||||
|
||||
async def test_feature_reference_conversion_and_bounded_loop(app, logged_in, flow_setup):
|
||||
body, _, lane, calls = flow_setup
|
||||
feature = await save(
|
||||
logged_in,
|
||||
"feature",
|
||||
{
|
||||
"name": "字段方案",
|
||||
"hypothesis": "排名稳定性",
|
||||
"input_ids": body["input_ids"],
|
||||
"steps": [{"name": "排名", "rationale": "截面比较", "expression": "rank(TEST_FIN_001)"}],
|
||||
"template": template(),
|
||||
},
|
||||
)
|
||||
definition = graph("input", "feature", "expand", "backtest", "evaluate", "filter", "iterate")
|
||||
definition["nodes"][1]["config"] = {"asset_id": feature["id"], "version": 1}
|
||||
definition["nodes"][5]["config"] = {"verdicts": ["pass", "review", "block"]}
|
||||
run = await launch(logged_in, body, definition)
|
||||
final = await drive(app, logged_in, run["id"], lane, ticks=60)
|
||||
assert final["status"] == "completed", final
|
||||
assert final["round"] == 2 and final["simulations_used"] == 4 and not calls
|
||||
outputs = [s["output"] for s in final["steps"] if s["node_id"] == "n1"]
|
||||
assert all(o["feature"]["id"] == feature["id"] for o in outputs)
|
||||
template_asset = (await logged_in.get(f"/api/v1/research/assets/{outputs[0]['template']['id']}")).json()
|
||||
assert template_asset["provenance"]["feature"]["version"] == 1
|
||||
|
||||
|
||||
async def test_native_feature_model_and_structure_variant(app, logged_in, flow_setup, monkeypatch):
|
||||
body, _, lane, calls = flow_setup
|
||||
|
||||
async def feature_model(ai, context, output_type, revision):
|
||||
assert output_type is FeatureSpec
|
||||
calls.append(context)
|
||||
return FeatureSpec.model_validate(
|
||||
{
|
||||
"name": "模型特征",
|
||||
"hypothesis": "排名",
|
||||
"input_ids": body["input_ids"],
|
||||
"steps": [{"name": "排名", "rationale": "比较", "expression": "rank(TEST_FIN_001)"}],
|
||||
"template": template(),
|
||||
}
|
||||
), {"model": "fixture", "revision": revision}
|
||||
|
||||
with monkeypatch.context() as patch:
|
||||
patch.setattr("app.research.runtime.request_model", feature_model)
|
||||
run = await launch(logged_in, body, graph("input", "feature", "expand", "backtest", "evaluate"))
|
||||
final = await drive(app, logged_in, run["id"], lane)
|
||||
assert final["status"] == "completed" and final["model_calls_used"] == 1, final
|
||||
assert final["steps"][1]["output"]["feature"]
|
||||
async with app.state.sessions.begin() as db:
|
||||
await upsert_alpha(db, alpha("seed", regular={"code": "rank(TEST_FIN_001)"}))
|
||||
run = await launch(
|
||||
logged_in,
|
||||
{**body, "request_id": "variant", "parent_alpha_ids": ["seed"]},
|
||||
graph("input", "variant", "backtest", "evaluate"),
|
||||
)
|
||||
final = await drive(app, logged_in, run["id"], lane)
|
||||
assert final["status"] == "completed" and final["model_calls_used"] == 1, final
|
||||
experiment = (
|
||||
await logged_in.get(f"/api/v1/research/experiments/{final['steps'][1]['output']['experiment_id']}")
|
||||
).json()
|
||||
assert experiment["parents"][0]["id"] == "seed" and experiment["kind"] == "quantflow"
|
||||
|
||||
|
||||
async def test_settings_variant_uses_frozen_seed_and_target_scope(app, logged_in, research_input, catalog):
|
||||
from tests.test_backtests import setup
|
||||
|
||||
body = {
|
||||
"request_id": "scope-run",
|
||||
"name": "范围变体",
|
||||
"input_ids": [research_input["id"]],
|
||||
"hypothesis": "跨股票池比较",
|
||||
"settings": {"region": "USA", "universe": "TOP3000", "delay": 1},
|
||||
"budget": {"max_rounds": 1, "max_simulations": 2, "max_model_calls": 1},
|
||||
}
|
||||
target = {**SCOPE, "universe": "TOP1000"}
|
||||
await sync(catalog, scope=target)
|
||||
version = (await sync(catalog, "TEST_FIN", scope=target))["id"]
|
||||
fixed = (await prepare(logged_in, version, scope=target)).json()
|
||||
async with app.state.sessions.begin() as db:
|
||||
await upsert_alpha(db, alpha("seed", regular={"code": "x = TEST_FIN_001; rank(x)"}))
|
||||
metadata = await db.get(CatalogResource, "settings")
|
||||
metadata.content = {
|
||||
"items": metadata.content["items"] + [{**target, "neutralizations": ["INDUSTRY"]}]
|
||||
}
|
||||
_, lane = await setup(app)
|
||||
definition = graph("input", "variant", "backtest", "evaluate")
|
||||
definition["nodes"][1]["config"] = {"method": "settings"}
|
||||
run = await launch(
|
||||
logged_in,
|
||||
{**body, "input_ids": body["input_ids"] + [fixed["id"]], "parent_alpha_ids": ["seed"]},
|
||||
definition,
|
||||
)
|
||||
async with app.state.sessions.begin() as db:
|
||||
seed = await db.get(Alpha, "seed")
|
||||
seed.expression = "rank(unknown)"
|
||||
final = await drive(app, logged_in, run["id"], lane)
|
||||
assert (
|
||||
final["status"] == "completed" and final["simulations_used"] == 1 and final["model_calls_used"] == 0
|
||||
), final
|
||||
experiment = (
|
||||
await logged_in.get(f"/api/v1/research/experiments/{final['steps'][1]['output']['experiment_id']}")
|
||||
).json()
|
||||
c = experiment["candidates"][0]
|
||||
assert c["expression"] == "x = TEST_FIN_001; rank(x)" and c["input_ids"] == [fixed["id"]]
|
||||
assert c["settings"]["universe"] == "TOP1000" and len(experiment["inputs"]) == 2
|
||||
|
||||
|
||||
async def test_empty_filter_skips_downstream_simulation(app, logged_in, flow_setup):
|
||||
body, platform, lane, _ = flow_setup
|
||||
definition = graph(
|
||||
"input", "generate", "expand", "backtest", "evaluate", "filter", "backtest", "evaluate"
|
||||
)
|
||||
definition["nodes"][5]["config"] = {"verdicts": []}
|
||||
run = await launch(logged_in, body, definition)
|
||||
final = await drive(app, logged_in, run["id"], lane)
|
||||
assert final["status"] == "completed" and len(platform.posts) == 1, final
|
||||
assert [s["status"] for s in final["steps"][-3:]] == ["skipped"] * 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action", ["pause", "stop", "account"])
|
||||
async def test_collected_variant_output_waits_for_authorized_next_tick(app, logged_in, flow_setup, action):
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.models import Account, ResearchExperiment
|
||||
from app.research.workspace_contracts import TemplateSpec
|
||||
|
||||
body, platform, lane, _ = flow_setup
|
||||
async with app.state.sessions.begin() as db:
|
||||
await upsert_alpha(db, alpha("seed", regular={"code": "rank(TEST_FIN_001)"}))
|
||||
run = await launch(
|
||||
logged_in, {**body, "parent_alpha_ids": ["seed"]}, graph("input", "variant", "backtest", "evaluate")
|
||||
)
|
||||
await app.state.research.advance(run["id"])
|
||||
work = await app.state.research.prepare(run["id"])
|
||||
current = await get(logged_in, run["id"])
|
||||
if action == "account":
|
||||
async with app.state.sessions.begin() as db:
|
||||
account = await db.get(Account, 1)
|
||||
account.wq_user_id = "OTHER_ACCOUNT"
|
||||
else:
|
||||
assert (
|
||||
await logged_in.post(
|
||||
f"/api/v1/research/flows/runs/{run['id']}/control",
|
||||
json={"action": action, "version": current["version"]},
|
||||
)
|
||||
).status_code == 200
|
||||
await app.state.research.finish_model(
|
||||
run["id"], work[0], TemplateSpec.model_validate(template()), {"model": "fixture"}
|
||||
)
|
||||
await app.state.research.advance(run["id"])
|
||||
current = await get(logged_in, run["id"])
|
||||
assert current["steps"][1]["status"] == "generated" and current["model_calls_used"] == 1
|
||||
assert current["status"] == {"pause": "paused", "stop": "stopped", "account": "interrupted"}[action]
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(ResearchExperiment)) == 0
|
||||
assert not platform.posts
|
||||
if action == "pause":
|
||||
assert (
|
||||
await logged_in.post(
|
||||
f"/api/v1/research/flows/runs/{run['id']}/control",
|
||||
json={"action": "resume", "version": current["version"]},
|
||||
)
|
||||
).status_code == 200
|
||||
await app.state.research.recover()
|
||||
final = await drive(app, logged_in, run["id"], lane)
|
||||
assert final["status"] == "completed" and final["model_calls_used"] == 1
|
||||
+22
-3
@@ -12,6 +12,7 @@ import {
|
||||
} from "@douyinfe/semi-ui-19";
|
||||
import zhCN from "@douyinfe/semi-ui-19/lib/es/locale/source/zh_CN";
|
||||
import { IconSetting, IconComment, IconHistory } from "@douyinfe/semi-icons";
|
||||
import { QuantFlowPage } from "./research/QuantFlowPage";
|
||||
import { PipelinePage } from "./research/PipelinePage";
|
||||
import { FeaturesPage } from "./research/FeaturesPage";
|
||||
import { ResearchWorkspace } from "./research/ResearchWorkspace";
|
||||
@@ -52,9 +53,14 @@ export default function App() {
|
||||
});
|
||||
useEffect(() => {
|
||||
if (
|
||||
["operators", "templates", "variants", "features", "pipeline"].includes(
|
||||
page,
|
||||
)
|
||||
[
|
||||
"operators",
|
||||
"templates",
|
||||
"variants",
|
||||
"features",
|
||||
"pipeline",
|
||||
"quantflow",
|
||||
].includes(page)
|
||||
)
|
||||
setVisitedResearch((old) => (old.includes(page) ? old : [...old, page]));
|
||||
}, [page]);
|
||||
@@ -376,6 +382,15 @@ export default function App() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{visitedResearch.includes("quantflow") && (
|
||||
<div className="alpha-page-view" hidden={page !== "quantflow"}>
|
||||
<QuantFlowPage
|
||||
active={page === "quantflow"}
|
||||
onAction={handleAction}
|
||||
onContext={setResearchContext}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{visitedResearch.includes("pipeline") && (
|
||||
<div className="alpha-page-view" hidden={page !== "pipeline"}>
|
||||
<PipelinePage
|
||||
@@ -462,6 +477,10 @@ export default function App() {
|
||||
onClose={() => setChatOpen(false)}
|
||||
context={
|
||||
{
|
||||
quantflow:
|
||||
researchContext.page === "quantflow"
|
||||
? researchContext
|
||||
: { page: "quantflow" as const },
|
||||
pipeline:
|
||||
researchContext.page === "pipeline"
|
||||
? researchContext
|
||||
|
||||
@@ -20,7 +20,8 @@ export type PageContext = {
|
||||
| "templates"
|
||||
| "variants"
|
||||
| "features"
|
||||
| "pipeline";
|
||||
| "pipeline"
|
||||
| "quantflow";
|
||||
research_run_id?: string | null;
|
||||
research_asset_id?: string;
|
||||
research_experiment_id?: string;
|
||||
|
||||
@@ -7,6 +7,8 @@ const contextLabels: Record<WorkspacePage, (context: PageContext) => string> = {
|
||||
operators: () => "上下文:算子库",
|
||||
templates: (context) =>
|
||||
`上下文:模板工坊${context.research_asset_id ? ` · ${context.research_asset_id}` : ""}${context.research_experiment_id ? ` · 实验 ${context.research_experiment_id}` : ""}`,
|
||||
quantflow: (context) =>
|
||||
`上下文:QuantFlow${context.research_run_id ? ` · ${context.research_run_id}` : ""}`,
|
||||
pipeline: (context) =>
|
||||
`上下文:研究流水线${context.research_run_id ? ` · ${context.research_run_id}` : ""}`,
|
||||
features: () => "上下文:特征工程",
|
||||
|
||||
@@ -28,6 +28,7 @@ const navigation = [
|
||||
{ id: "variants", label: "Alpha 变体", icon: IconBeaker, group: "研究实验" },
|
||||
{ id: "backtests", label: "回测研究", icon: IconBeaker, group: "研究实验" },
|
||||
{ id: "pipeline", label: "研究流水线", icon: IconBeaker, group: "研究编排" },
|
||||
{ id: "quantflow", label: "QuantFlow", icon: IconBeaker, group: "研究编排" },
|
||||
{ id: "alphas", label: "Alpha 管理", icon: IconGridView, group: "研究成果" },
|
||||
{ id: "account", label: "个人信息", icon: IconUser, group: "" },
|
||||
] as const;
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Button } from "@douyinfe/semi-ui-19";
|
||||
import type { FlowNode, FlowRun, Workflow } from "./flowTypes";
|
||||
import { flowStatus } from "./flowTypes";
|
||||
export type NodeType = {
|
||||
type: string;
|
||||
label: string;
|
||||
accepts: string[];
|
||||
produces: string;
|
||||
};
|
||||
export function FlowCanvas({
|
||||
graph,
|
||||
types,
|
||||
selected,
|
||||
onSelect,
|
||||
onChange,
|
||||
run,
|
||||
}: {
|
||||
graph: Workflow;
|
||||
types: NodeType[];
|
||||
selected: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
onChange?: (graph: Workflow) => void;
|
||||
run?: FlowRun | null;
|
||||
}) {
|
||||
const [zoom, setZoom] = useState(0.8);
|
||||
const viewport = useRef<HTMLDivElement>(null);
|
||||
const portNames: Record<string, string> = {
|
||||
context: "输入范围",
|
||||
template: "模板",
|
||||
candidates: "候选表达式",
|
||||
results: "回测结果",
|
||||
evaluation: "评估报告",
|
||||
summary: "研究汇总",
|
||||
iteration: "迭代产物",
|
||||
};
|
||||
const drag = useRef<{
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
left: number;
|
||||
top: number;
|
||||
} | null>(null);
|
||||
const width = Math.max(1120, ...graph.nodes.map((n) => n.x + 260));
|
||||
const height = Math.max(520, ...graph.nodes.map((n) => n.y + 180));
|
||||
function move(node: FlowNode, x: number, y: number) {
|
||||
onChange?.({
|
||||
...graph,
|
||||
nodes: graph.nodes.map((n) =>
|
||||
n.id === node.id
|
||||
? {
|
||||
...n,
|
||||
x: Math.max(0, Math.min(10000, x)),
|
||||
y: Math.max(0, Math.min(10000, y)),
|
||||
}
|
||||
: n,
|
||||
),
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div className="flow-canvas-shell">
|
||||
<div className="flow-canvas-toolbar">
|
||||
<span>
|
||||
{onChange
|
||||
? "拖动节点排列 · 选中后可用方向键微调"
|
||||
: "节点颜色与状态显示当前轮次"}
|
||||
</span>
|
||||
<div className="inline-actions">
|
||||
<Button
|
||||
size="small"
|
||||
aria-label="缩小画布"
|
||||
onClick={() => setZoom((z) => Math.max(0.4, z - 0.1))}
|
||||
>
|
||||
−
|
||||
</Button>
|
||||
<span>{Math.round(zoom * 100)}%</span>
|
||||
<Button
|
||||
size="small"
|
||||
aria-label="放大画布"
|
||||
onClick={() => setZoom((z) => Math.min(1.5, z + 0.1))}
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
<Button size="small" onClick={() => setZoom(0.8)}>
|
||||
重置缩放
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() =>
|
||||
setZoom(
|
||||
Math.max(
|
||||
0.25,
|
||||
Math.min(1, (viewport.current?.clientWidth || width) / width),
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
适应画布
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={viewport}
|
||||
className="flow-canvas-viewport"
|
||||
aria-label="QuantFlow 画布"
|
||||
>
|
||||
<div style={{ width: width * zoom, height: height * zoom }}>
|
||||
<div
|
||||
className="flow-canvas"
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
transform: `scale(${zoom})`,
|
||||
transformOrigin: "top left",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className="flow-edges"
|
||||
aria-label="节点连线"
|
||||
>
|
||||
{graph.edges.map((e, index) => {
|
||||
const a = graph.nodes.find((n) => n.id === e.source),
|
||||
b = graph.nodes.find((n) => n.id === e.target);
|
||||
if (!a || !b) return null;
|
||||
const x = a.x + 200,
|
||||
y = a.y + 50,
|
||||
tx = b.x,
|
||||
ty = b.y + 50;
|
||||
const sibling = graph.edges
|
||||
.filter((edge) => edge.source === e.source)
|
||||
.indexOf(e);
|
||||
const routed = Boolean(e.branch) || tx < x || tx - x > 130;
|
||||
const lane =
|
||||
tx < x
|
||||
? a.y + 130 + sibling * 14
|
||||
: Math.max(10, Math.min(a.y, b.y) - 30 - sibling * 14);
|
||||
const path = routed
|
||||
? `M${x} ${y} H${x + 18} V${lane} H${tx - 18} V${ty} H${tx}`
|
||||
: `M${x} ${y} C${x + 30} ${y}, ${tx - 30} ${ty}, ${tx} ${ty}`;
|
||||
const upstream = run?.steps.find(
|
||||
(step) =>
|
||||
step.node_id === e.source && step.round === run.round,
|
||||
);
|
||||
const inactive =
|
||||
upstream &&
|
||||
(upstream.status === "skipped" ||
|
||||
(e.branch && upstream.output.verdict !== e.branch));
|
||||
return (
|
||||
<g
|
||||
key={`${e.source}-${e.target}-${index}`}
|
||||
opacity={inactive ? 0.3 : 1}
|
||||
>
|
||||
<path d={path} />
|
||||
<path
|
||||
d={`M${tx - 8} ${ty - 5} L${tx} ${ty} L${tx - 8} ${ty + 5}`}
|
||||
/>
|
||||
{e.branch && (
|
||||
<text
|
||||
x={(x + tx) / 2}
|
||||
y={routed ? lane - 5 : (y + ty) / 2 - 8}
|
||||
>
|
||||
{e.branch}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
{graph.nodes.map((node) => {
|
||||
const type = types.find((t) => t.type === node.type);
|
||||
const step = run?.steps.find(
|
||||
(s) => s.node_id === node.id && s.round === run.round,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
key={node.id}
|
||||
type="button"
|
||||
aria-label={`节点 ${node.label || node.id}`}
|
||||
aria-pressed={selected === node.id}
|
||||
className={`flow-node ${selected === node.id ? "selected" : ""} ${step?.status || ""}`}
|
||||
style={{ left: node.x, top: node.y, touchAction: "none" }}
|
||||
onClick={() => onSelect(node.id)}
|
||||
onPointerDown={(e) => {
|
||||
onSelect(node.id);
|
||||
if (!onChange) return;
|
||||
drag.current = {
|
||||
id: node.id,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
left: node.x,
|
||||
top: node.y,
|
||||
};
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
const d = drag.current;
|
||||
if (d?.id === node.id)
|
||||
move(
|
||||
node,
|
||||
d.left + (e.clientX - d.x) / zoom,
|
||||
d.top + (e.clientY - d.y) / zoom,
|
||||
);
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
drag.current = null;
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
drag.current = null;
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
const delta: Record<string, [number, number]> = {
|
||||
ArrowLeft: [-10, 0],
|
||||
ArrowRight: [10, 0],
|
||||
ArrowUp: [0, -10],
|
||||
ArrowDown: [0, 10],
|
||||
};
|
||||
if (onChange && delta[e.key]) {
|
||||
e.preventDefault();
|
||||
move(
|
||||
node,
|
||||
node.x + delta[e.key][0],
|
||||
node.y + delta[e.key][1],
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="flow-node-kind">
|
||||
{type?.label || node.type}
|
||||
</span>
|
||||
<strong>{node.label || node.id}</strong>
|
||||
<span>
|
||||
{step
|
||||
? flowStatus[step.status] || step.status
|
||||
: `${type && type.accepts.length > 2 ? "多种研究产物" : type?.accepts.map((p) => portNames[p] || p).join(" / ") || "固定数据"} → ${portNames[type?.produces || ""] || "产物"}`}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export function FlowLaunchForm({
|
||||
const [templates, setTemplates] = useState<Asset[]>([]);
|
||||
const [ids, setIds] = useState<string[]>([]);
|
||||
const [template, setTemplate] = useState<string>();
|
||||
const [name, setName] = useState("固定研究流水线");
|
||||
const [name, setName] = useState(workflow?.name || "固定研究流水线");
|
||||
const [hypothesis, setHypothesis] = useState("");
|
||||
const [parent, setParent] = useState("");
|
||||
const [budget, setBudget] = useState<Budget>({
|
||||
@@ -284,10 +284,41 @@ export function FlowLaunchForm({
|
||||
<p>
|
||||
{confirmation.name}:{confirmation.hypothesis}
|
||||
</p>
|
||||
{workflow && (
|
||||
<p>
|
||||
流程:{workflow.name} · v{workflow.version}
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
{confirmation.input_ids.length} 个固定输入 · {settings.region}/
|
||||
{settings.universe}/D{settings.delay}
|
||||
</p>
|
||||
<ul>
|
||||
{confirmation.input_ids.map((id) => {
|
||||
const input = inputs.find((i) => i.id === id);
|
||||
return (
|
||||
<li key={id}>
|
||||
{input
|
||||
? `${input.dataset_id} · ${input.scope.region}/${input.scope.universe}/D${input.scope.delay}`
|
||||
: id}{" "}
|
||||
· {id.slice(0, 8)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{confirmation.parent_alpha_ids.length > 0 && (
|
||||
<p>
|
||||
种子 Alpha:{confirmation.parent_alpha_ids.join("、")}
|
||||
。市场与设置变体保持种子表达式及其他设置,仅使用上述固定输入的目标范围。
|
||||
</p>
|
||||
)}
|
||||
{confirmation.template_id && (
|
||||
<p>
|
||||
初始模板:
|
||||
{templates.find((t) => t.id === confirmation.template_id)?.name}{" "}
|
||||
· v{confirmation.template_version}
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
最多 {confirmation.budget.max_rounds} 轮、
|
||||
{confirmation.budget.max_simulations} 条模拟、
|
||||
|
||||
@@ -36,7 +36,7 @@ export function PipelinePage({
|
||||
async function refresh() {
|
||||
try {
|
||||
const list = await api<{ items: FlowRun[]; total: number }>(
|
||||
`/research/flows/runs?offset=${(page - 1) * 25}`,
|
||||
`/research/flows/runs?kind=pipeline&offset=${(page - 1) * 25}`,
|
||||
{ signal: c.signal },
|
||||
);
|
||||
setItems(list.items);
|
||||
|
||||
@@ -0,0 +1,775 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Input,
|
||||
InputNumber,
|
||||
Pagination,
|
||||
TextArea,
|
||||
Toast,
|
||||
} from "@douyinfe/semi-ui-19";
|
||||
import { api, post } from "../api";
|
||||
import type { PageContext, UIAction } from "../ai/types";
|
||||
import { FlowCanvas, type NodeType } from "./FlowCanvas";
|
||||
import { FlowLaunchForm } from "./FlowLaunchForm";
|
||||
import { FlowRunView } from "./FlowRunView";
|
||||
import type { FlowNode, FlowRun, Workflow } from "./flowTypes";
|
||||
import { flowStatus } from "./flowTypes";
|
||||
import "./workspace.css";
|
||||
type WorkflowAsset = {
|
||||
id: string;
|
||||
version: number;
|
||||
name: string;
|
||||
content: Workflow;
|
||||
};
|
||||
type RefAsset = { id: string; version: number; name: string; kind: string };
|
||||
const empty: Workflow = {
|
||||
name: "新研究流程",
|
||||
nodes: [
|
||||
{ id: "input", type: "input", label: "固定输入", x: 40, y: 80, config: {} },
|
||||
],
|
||||
edges: [],
|
||||
};
|
||||
function readDraft(key: string, fallback: Workflow): Workflow {
|
||||
try {
|
||||
const value = JSON.parse(
|
||||
localStorage.getItem(`quantflow-draft:${key}`) || "null",
|
||||
);
|
||||
const graph = value?.graph || value;
|
||||
return graph?.nodes && graph?.edges && graph?.name ? graph : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
export function QuantFlowPage({
|
||||
active,
|
||||
onContext,
|
||||
onAction,
|
||||
}: {
|
||||
active: boolean;
|
||||
onContext: (c: PageContext) => void;
|
||||
onAction: (a: UIAction) => void;
|
||||
}) {
|
||||
const [assets, setAssets] = useState<WorkflowAsset[]>([]),
|
||||
[total, setTotal] = useState(0),
|
||||
[page, setPage] = useState(1);
|
||||
const [types, setTypes] = useState<NodeType[]>([]),
|
||||
[refs, setRefs] = useState<RefAsset[]>([]);
|
||||
const [asset, setAsset] = useState<WorkflowAsset | null>(null),
|
||||
[versions, setVersions] = useState<{ version: number }[]>([]);
|
||||
const [graph, setGraph] = useState<Workflow>(() => readDraft("new", empty));
|
||||
const [selected, setSelected] = useState<string | null>("input"),
|
||||
[nodeType, setNodeType] = useState("generate");
|
||||
const [source, setSource] = useState(""),
|
||||
[target, setTarget] = useState(""),
|
||||
[branch, setBranch] = useState("");
|
||||
const [error, setError] = useState(""),
|
||||
[valid, setValid] = useState(false),
|
||||
[busy, setBusy] = useState(false);
|
||||
const [mode, setMode] = useState<"edit" | "launch" | "runs">(() =>
|
||||
localStorage.getItem("quantflow-mode") === "runs" ? "runs" : "edit",
|
||||
);
|
||||
const [runs, setRuns] = useState<FlowRun[]>([]),
|
||||
[runTotal, setRunTotal] = useState(0),
|
||||
[runPage, setRunPage] = useState(1);
|
||||
const [runId, setRunId] = useState<string | null>(() =>
|
||||
localStorage.getItem("quantflow-selected-run"),
|
||||
);
|
||||
const [run, setRun] = useState<FlowRun | null>(null),
|
||||
[revision, setRevision] = useState(0);
|
||||
useEffect(() => {
|
||||
localStorage.setItem("quantflow-mode", mode === "runs" ? "runs" : "edit");
|
||||
}, [mode]);
|
||||
const node = graph.nodes.find((n) => n.id === selected);
|
||||
const dirty =
|
||||
!asset || JSON.stringify(graph) !== JSON.stringify(asset.content);
|
||||
useEffect(() => {
|
||||
if (active)
|
||||
onContext({
|
||||
page: "quantflow",
|
||||
research_asset_id: asset?.id,
|
||||
research_run_id: mode === "runs" ? runId : null,
|
||||
});
|
||||
}, [active, asset?.id, runId, mode, onContext]);
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
`quantflow-draft:${asset?.id || "new"}`,
|
||||
JSON.stringify({ graph, asset }),
|
||||
);
|
||||
} catch {
|
||||
/* The in-memory draft remains usable when local storage is full. */
|
||||
}
|
||||
}, [graph, asset?.id]);
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const c = new AbortController();
|
||||
Promise.all([
|
||||
api<{ items: WorkflowAsset[]; total: number }>(
|
||||
`/research/assets?kind=workflow&offset=${(page - 1) * 25}`,
|
||||
{ signal: c.signal },
|
||||
),
|
||||
api<{ items: NodeType[] }>("/research/flows/nodes", { signal: c.signal }),
|
||||
api<{ items: RefAsset[] }>("/research/assets?kind=feature&limit=100", {
|
||||
signal: c.signal,
|
||||
}),
|
||||
api<{ items: RefAsset[] }>("/research/assets?kind=template&limit=100", {
|
||||
signal: c.signal,
|
||||
}),
|
||||
])
|
||||
.then(([a, t, f, m]) => {
|
||||
setAssets(a.items);
|
||||
setTotal(a.total);
|
||||
setTypes(t.items);
|
||||
setRefs([...f.items, ...m.items]);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!c.signal.aborted) setError(e.message);
|
||||
});
|
||||
return () => c.abort();
|
||||
}, [active, page, revision]);
|
||||
useEffect(() => {
|
||||
if (!active || mode !== "runs") return;
|
||||
const c = new AbortController();
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
async function refresh() {
|
||||
try {
|
||||
const list = await api<{ items: FlowRun[]; total: number }>(
|
||||
`/research/flows/runs?kind=quantflow&offset=${(runPage - 1) * 25}`,
|
||||
{ signal: c.signal },
|
||||
);
|
||||
setRuns(list.items);
|
||||
setRunTotal(list.total);
|
||||
if (runId)
|
||||
setRun(
|
||||
await api(`/research/flows/runs/${runId}`, { signal: c.signal }),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!c.signal.aborted) setError((e as Error).message);
|
||||
} finally {
|
||||
if (!c.signal.aborted) timer = setTimeout(() => void refresh(), 1500);
|
||||
}
|
||||
}
|
||||
void refresh();
|
||||
return () => {
|
||||
c.abort();
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [active, mode, runId, runPage, revision]);
|
||||
function change(value: Workflow) {
|
||||
setGraph(value);
|
||||
setValid(false);
|
||||
setError("");
|
||||
}
|
||||
async function open(item: WorkflowAsset, version?: number) {
|
||||
try {
|
||||
const loaded = version
|
||||
? await api<WorkflowAsset>(
|
||||
`/research/assets/${item.id}?version=${version}`,
|
||||
)
|
||||
: item;
|
||||
let baseline = loaded;
|
||||
if (!version) {
|
||||
try {
|
||||
const draft = JSON.parse(
|
||||
localStorage.getItem(`quantflow-draft:${loaded.id}`) || "null",
|
||||
);
|
||||
if (draft?.asset?.id === loaded.id) baseline = draft.asset;
|
||||
} catch {
|
||||
/* Keep the server version when the local draft is unavailable. */
|
||||
}
|
||||
}
|
||||
setAsset(baseline);
|
||||
change(version ? loaded.content : readDraft(loaded.id, loaded.content));
|
||||
setSelected(loaded.content.nodes[0]?.id || null);
|
||||
setMode("edit");
|
||||
setVersions(await api(`/research/assets/${item.id}/versions`));
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}
|
||||
async function validate(value = graph) {
|
||||
await post("/research/flows/validate", value);
|
||||
setValid(true);
|
||||
setError("");
|
||||
}
|
||||
async function save(copy = false) {
|
||||
setBusy(true);
|
||||
try {
|
||||
await validate();
|
||||
const result = await api<WorkflowAsset>(
|
||||
asset && !copy ? `/research/assets/${asset.id}` : "/research/assets",
|
||||
{
|
||||
method: asset && !copy ? "PUT" : "POST",
|
||||
body: JSON.stringify({
|
||||
kind: "workflow",
|
||||
content: graph,
|
||||
...(asset && !copy ? { version: asset.version } : {}),
|
||||
}),
|
||||
},
|
||||
);
|
||||
setAsset(result);
|
||||
setGraph(result.content);
|
||||
setValid(true);
|
||||
setVersions(await api(`/research/assets/${result.id}/versions`));
|
||||
setRevision((v) => v + 1);
|
||||
Toast.success(`已保存流程 v${result.version}`);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
function updateNode(config: Partial<FlowNode>) {
|
||||
if (node)
|
||||
change({
|
||||
...graph,
|
||||
nodes: graph.nodes.map((n) =>
|
||||
n.id === node.id ? { ...n, ...config } : n,
|
||||
),
|
||||
});
|
||||
}
|
||||
function addNode() {
|
||||
const type = types.find((t) => t.type === nodeType);
|
||||
if (!type || graph.nodes.length >= 50) return;
|
||||
const id = `${nodeType}_${crypto.randomUUID().slice(0, 8)}`;
|
||||
change({
|
||||
...graph,
|
||||
nodes: [
|
||||
...graph.nodes,
|
||||
{
|
||||
id,
|
||||
type: nodeType,
|
||||
label: type.label,
|
||||
x: 60 + (graph.nodes.length % 4) * 250,
|
||||
y: 80 + Math.floor(graph.nodes.length / 4) * 180,
|
||||
config:
|
||||
nodeType === "iterate"
|
||||
? { max_rounds: 3 }
|
||||
: nodeType === "variant"
|
||||
? { method: "structure" }
|
||||
: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
setSelected(id);
|
||||
}
|
||||
function addEdge() {
|
||||
const a = graph.nodes.find((n) => n.id === source),
|
||||
b = graph.nodes.find((n) => n.id === target);
|
||||
if (!a || !b || source === target) {
|
||||
setError("请选择两个不同的连线端点");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!types
|
||||
.find((t) => t.type === b.type)
|
||||
?.accepts.includes(types.find((t) => t.type === a.type)?.produces || "")
|
||||
) {
|
||||
setError("输入输出类型不兼容");
|
||||
return;
|
||||
}
|
||||
const edge = {
|
||||
source,
|
||||
target,
|
||||
branch: a.type === "condition" && branch ? branch : null,
|
||||
};
|
||||
if (
|
||||
graph.edges.some(
|
||||
(e) =>
|
||||
e.source === source &&
|
||||
e.target === target &&
|
||||
e.branch === edge.branch,
|
||||
)
|
||||
) {
|
||||
setError("连线重复");
|
||||
return;
|
||||
}
|
||||
change({ ...graph, edges: [...graph.edges, edge] });
|
||||
}
|
||||
return (
|
||||
<section className="research-workspace quantflow-workspace">
|
||||
<div className="section-toolbar">
|
||||
<div>
|
||||
<h2>QuantFlow</h2>
|
||||
<p className="muted">组合原生研究节点,复用回测、评估与有限预算。</p>
|
||||
</div>
|
||||
<div className="inline-actions">
|
||||
<Button onClick={() => setMode("edit")}>编辑画布</Button>
|
||||
<Button onClick={() => setMode("runs")}>运行记录</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setAsset(null);
|
||||
setVersions([]);
|
||||
change(readDraft("new", empty));
|
||||
setMode("edit");
|
||||
}}
|
||||
>
|
||||
新建流程
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const recipe = await api<Workflow>("/research/flows/recipe");
|
||||
setAsset(null);
|
||||
setVersions([]);
|
||||
change({ ...recipe, name: "固定研究配方副本" });
|
||||
setMode("edit");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
复制固定配方
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <Banner type="danger" description={error} />}
|
||||
<div className="research-layout">
|
||||
<aside className="research-library">
|
||||
<h3>{mode === "runs" ? "画布运行" : "流程库"}</h3>
|
||||
{mode === "runs" ? (
|
||||
<>
|
||||
{runs.map((r) => (
|
||||
<button
|
||||
className={`research-library-item ${runId === r.id ? "selected" : ""}`}
|
||||
key={r.id}
|
||||
onClick={() => {
|
||||
setRunId(r.id);
|
||||
setRun(null);
|
||||
localStorage.setItem("quantflow-selected-run", r.id);
|
||||
}}
|
||||
>
|
||||
<strong>{r.name}</strong>
|
||||
<span>
|
||||
{flowStatus[r.status]} · 第 {r.round} 轮
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<Pagination
|
||||
size="small"
|
||||
total={runTotal}
|
||||
currentPage={runPage}
|
||||
pageSize={25}
|
||||
onPageChange={setRunPage}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{assets.map((a) => (
|
||||
<button
|
||||
className={`research-library-item ${asset?.id === a.id ? "selected" : ""}`}
|
||||
key={a.id}
|
||||
onClick={() => void open(a)}
|
||||
>
|
||||
<strong>{a.name}</strong>
|
||||
<span>
|
||||
v{a.version} · {a.content.nodes.length} 个节点
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<Pagination
|
||||
size="small"
|
||||
total={total}
|
||||
currentPage={page}
|
||||
pageSize={25}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
<div className="research-main">
|
||||
{mode === "runs" ? (
|
||||
run ? (
|
||||
<>
|
||||
<FlowCanvas
|
||||
graph={run.definition}
|
||||
types={types}
|
||||
selected={selected}
|
||||
onSelect={setSelected}
|
||||
run={run}
|
||||
/>
|
||||
{selected && (
|
||||
<div className="research-card">
|
||||
<h4>所选节点产物</h4>
|
||||
{run.steps
|
||||
.filter((s) => s.node_id === selected)
|
||||
.map((s) => (
|
||||
<details key={s.id}>
|
||||
<summary>
|
||||
第 {s.round} 轮 · {flowStatus[s.status]}
|
||||
</summary>
|
||||
<pre className="code-block">
|
||||
{JSON.stringify(s.output, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<FlowRunView
|
||||
run={run}
|
||||
onRefresh={() => setRevision((v) => v + 1)}
|
||||
onAction={onAction}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<p className="research-card">选择一条运行查看节点状态和产物。</p>
|
||||
)
|
||||
) : mode === "launch" && asset ? (
|
||||
<>
|
||||
<Button onClick={() => setMode("edit")}>返回流程</Button>
|
||||
<FlowLaunchForm
|
||||
workflow={asset}
|
||||
onStarted={(r) => {
|
||||
setRun(r);
|
||||
setRunId(r.id);
|
||||
localStorage.setItem("quantflow-selected-run", r.id);
|
||||
setMode("runs");
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="research-card">
|
||||
<div className="section-toolbar">
|
||||
<Input
|
||||
aria-label="流程名称"
|
||||
value={graph.name}
|
||||
onChange={(name) => change({ ...graph, name })}
|
||||
/>
|
||||
<div className="inline-actions">
|
||||
{asset && (
|
||||
<label>
|
||||
历史版本
|
||||
<select
|
||||
aria-label="流程版本"
|
||||
value={asset.version}
|
||||
onChange={(e) =>
|
||||
void open(asset, Number(e.target.value))
|
||||
}
|
||||
>
|
||||
{versions.map((v) => (
|
||||
<option key={v.version} value={v.version}>
|
||||
v{v.version}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void validate().catch((e) => setError(e.message))
|
||||
}
|
||||
>
|
||||
校验连接
|
||||
</Button>
|
||||
<Button disabled={busy} onClick={() => void save()}>
|
||||
保存流程版本
|
||||
</Button>
|
||||
{asset && (
|
||||
<Button disabled={busy} onClick={() => void save(true)}>
|
||||
另存流程
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
theme="solid"
|
||||
disabled={dirty || busy}
|
||||
onClick={() => setMode("launch")}
|
||||
>
|
||||
启动已保存流程
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="research-hint">
|
||||
{valid
|
||||
? "连接校验通过。"
|
||||
: "保存时核验节点类型、连接和循环。"}{" "}
|
||||
{dirty
|
||||
? "有未保存草稿,启动前请保存版本。"
|
||||
: `当前 v${asset?.version},运行固定此版本。`}{" "}
|
||||
普通连接无环;终点迭代节点重复整个流程,每次运行都受预算约束。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flow-editor">
|
||||
<div>
|
||||
<FlowCanvas
|
||||
graph={graph}
|
||||
types={types}
|
||||
selected={selected}
|
||||
onSelect={setSelected}
|
||||
onChange={change}
|
||||
/>
|
||||
<div className="research-card">
|
||||
<h4>连线</h4>
|
||||
<div className="flow-connect">
|
||||
<select
|
||||
aria-label="连线起点"
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value)}
|
||||
>
|
||||
<option value="">选择上游</option>
|
||||
{graph.nodes.map((n) => (
|
||||
<option key={n.id} value={n.id}>
|
||||
{n.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span>→</span>
|
||||
<select
|
||||
aria-label="连线终点"
|
||||
value={target}
|
||||
onChange={(e) => setTarget(e.target.value)}
|
||||
>
|
||||
<option value="">选择下游</option>
|
||||
{graph.nodes.map((n) => (
|
||||
<option key={n.id} value={n.id}>
|
||||
{n.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
aria-label="连线条件"
|
||||
value={branch}
|
||||
disabled={
|
||||
graph.nodes.find((n) => n.id === source)?.type !==
|
||||
"condition"
|
||||
}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
>
|
||||
<option value="">始终</option>
|
||||
{["pass", "review", "block"].map((v) => (
|
||||
<option key={v}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
<Button onClick={addEdge}>添加连线</Button>
|
||||
</div>
|
||||
<div className="flow-edge-list">
|
||||
{graph.edges.map((e, i) => (
|
||||
<div key={i}>
|
||||
<span>
|
||||
{graph.nodes.find((n) => n.id === e.source)?.label}{" "}
|
||||
→{" "}
|
||||
{graph.nodes.find((n) => n.id === e.target)?.label}{" "}
|
||||
{e.branch && `· ${e.branch}`}
|
||||
</span>
|
||||
<Button
|
||||
size="small"
|
||||
aria-label={`删除连线 ${i + 1}`}
|
||||
onClick={() =>
|
||||
change({
|
||||
...graph,
|
||||
edges: graph.edges.filter((_, j) => j !== i),
|
||||
})
|
||||
}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<aside className="research-card flow-inspector">
|
||||
<h4>原生节点</h4>
|
||||
<select
|
||||
aria-label="新增节点类型"
|
||||
value={nodeType}
|
||||
onChange={(e) => setNodeType(e.target.value)}
|
||||
>
|
||||
{types
|
||||
.filter((t) => t.type !== "input")
|
||||
.map((t) => (
|
||||
<option key={t.type} value={t.type}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button disabled={graph.nodes.length >= 50} onClick={addNode}>
|
||||
添加节点
|
||||
</Button>
|
||||
{node && (
|
||||
<>
|
||||
<h4>节点配置</h4>
|
||||
<label>
|
||||
显示名称
|
||||
<Input
|
||||
aria-label="节点名称"
|
||||
value={node.label}
|
||||
onChange={(label) => updateNode({ label })}
|
||||
/>
|
||||
</label>
|
||||
<p className="research-hint">{node.id}</p>
|
||||
{["generate", "feature"].includes(node.type) && (
|
||||
<label>
|
||||
研究说明
|
||||
<TextArea
|
||||
aria-label="节点研究说明"
|
||||
value={String(node.config.prompt || "")}
|
||||
onChange={(prompt) =>
|
||||
updateNode({ config: { ...node.config, prompt } })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{["feature", "expand"].includes(node.type) && (
|
||||
<>
|
||||
<label>
|
||||
固定素材版本
|
||||
<select
|
||||
aria-label="节点素材"
|
||||
value={String(node.config.asset_id || "")}
|
||||
onChange={(e) => {
|
||||
const ref = refs.find(
|
||||
(r) => r.id === e.target.value,
|
||||
);
|
||||
const {
|
||||
asset_id: _,
|
||||
version: __,
|
||||
...rest
|
||||
} = node.config;
|
||||
updateNode({
|
||||
config: ref
|
||||
? {
|
||||
...rest,
|
||||
asset_id: ref.id,
|
||||
version: ref.version,
|
||||
}
|
||||
: rest,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">
|
||||
{node.type === "feature"
|
||||
? "由模型生成方案"
|
||||
: "使用上游或启动模板"}
|
||||
</option>
|
||||
{refs
|
||||
.filter(
|
||||
(r) =>
|
||||
r.kind ===
|
||||
(node.type === "feature"
|
||||
? "feature"
|
||||
: "template"),
|
||||
)
|
||||
.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name} · v{r.version}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{Boolean(node.config.asset_id) && (
|
||||
<label>
|
||||
素材版本
|
||||
<InputNumber
|
||||
aria-label="节点素材版本"
|
||||
min={1}
|
||||
precision={0}
|
||||
value={Number(node.config.version)}
|
||||
onChange={(version) => {
|
||||
if (typeof version === "number")
|
||||
updateNode({
|
||||
config: { ...node.config, version },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{node.type === "variant" && (
|
||||
<label>
|
||||
生成方法
|
||||
<select
|
||||
aria-label="变体方法"
|
||||
value={String(node.config.method || "structure")}
|
||||
onChange={(e) =>
|
||||
updateNode({ config: { method: e.target.value } })
|
||||
}
|
||||
>
|
||||
<option value="structure">结构与字段变体</option>
|
||||
<option value="settings">市场与设置变体</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{node.type === "filter" && (
|
||||
<fieldset>
|
||||
<legend>保留评估结论</legend>
|
||||
{["pass", "review", "block"].map((v) => (
|
||||
<label key={v}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(
|
||||
(node.config.verdicts as string[]) || ["pass"]
|
||||
).includes(v)}
|
||||
onChange={(e) => {
|
||||
const values = (node.config
|
||||
.verdicts as string[]) || ["pass"];
|
||||
updateNode({
|
||||
config: {
|
||||
verdicts: e.target.checked
|
||||
? [...values, v]
|
||||
: values.filter((x) => x !== v),
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{v}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
)}
|
||||
{node.type === "iterate" && (
|
||||
<label>
|
||||
整个流程最大轮数
|
||||
<InputNumber
|
||||
aria-label="节点迭代轮数"
|
||||
min={1}
|
||||
max={100}
|
||||
precision={0}
|
||||
value={Number(node.config.max_rounds || 1)}
|
||||
onChange={(max_rounds) => {
|
||||
if (typeof max_rounds === "number")
|
||||
updateNode({ config: { max_rounds } });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{node.type === "condition" && (
|
||||
<p>
|
||||
根据上游规则评估的 pass / review / block
|
||||
选择出边。模型建议不改变分支判断。
|
||||
</p>
|
||||
)}
|
||||
{node.type !== "input" && (
|
||||
<Button
|
||||
type="danger"
|
||||
onClick={() => {
|
||||
change({
|
||||
...graph,
|
||||
nodes: graph.nodes.filter(
|
||||
(n) => n.id !== node.id,
|
||||
),
|
||||
edges: graph.edges.filter(
|
||||
(e) =>
|
||||
e.source !== node.id && e.target !== node.id,
|
||||
),
|
||||
});
|
||||
setSelected("input");
|
||||
}}
|
||||
>
|
||||
删除节点
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,8 @@ export function ResearchToolCard({ call, onAction }: ToolCardProps) {
|
||||
</p>
|
||||
{typeof result.id === "string" &&
|
||||
!result.report &&
|
||||
!result.authorization && (
|
||||
!result.authorization &&
|
||||
result.kind !== "workflow" && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
onAction(
|
||||
|
||||
@@ -64,6 +64,7 @@ export type FlowRun = {
|
||||
};
|
||||
export const flowStatus: Record<string, string> = {
|
||||
queued: "等待推进",
|
||||
generated: "模型产物已保存",
|
||||
running: "执行中",
|
||||
completed: "已完成",
|
||||
skipped: "已跳过",
|
||||
|
||||
@@ -235,3 +235,149 @@
|
||||
.research-saved-views .semi-select {
|
||||
min-width: 210px;
|
||||
}
|
||||
|
||||
/* Native research graph: same compact workspace surfaces and focus states. */
|
||||
.flow-editor {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 235px;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
.flow-canvas-shell {
|
||||
border: 1px solid var(--semi-color-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--semi-color-bg-1);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.flow-canvas-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
color: var(--semi-color-text-2);
|
||||
font-size: 12px;
|
||||
}
|
||||
.flow-canvas-viewport {
|
||||
overflow: auto;
|
||||
height: 460px;
|
||||
background: radial-gradient(var(--semi-color-border) 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
.flow-canvas {
|
||||
position: relative;
|
||||
}
|
||||
.flow-edges {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.flow-edges path {
|
||||
fill: none;
|
||||
stroke: var(--semi-color-text-2);
|
||||
stroke-width: 1.6;
|
||||
}
|
||||
.flow-edges text {
|
||||
fill: var(--semi-color-text-1);
|
||||
font-size: 12px;
|
||||
}
|
||||
.flow-node {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 200px;
|
||||
height: 100px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--semi-color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--semi-color-bg-1);
|
||||
color: var(--semi-color-text-0);
|
||||
text-align: left;
|
||||
cursor: grab;
|
||||
box-shadow: 0 2px 8px #00000008;
|
||||
}
|
||||
.flow-node strong {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
.flow-node span {
|
||||
font-size: 11px;
|
||||
color: var(--semi-color-text-2);
|
||||
}
|
||||
.flow-node.selected,
|
||||
.flow-node:focus-visible {
|
||||
outline: 2px solid var(--semi-color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.flow-node.completed {
|
||||
border-left: 4px solid var(--semi-color-success);
|
||||
}
|
||||
.flow-node.running,
|
||||
.flow-node.waiting {
|
||||
border-left: 4px solid var(--semi-color-primary);
|
||||
}
|
||||
.flow-node.blocked,
|
||||
.flow-node.interrupted {
|
||||
border-left: 4px solid var(--semi-color-warning);
|
||||
}
|
||||
.flow-node.skipped {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.flow-inspector {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.quantflow-workspace select {
|
||||
border: 1px solid var(--semi-color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--semi-color-fill-0);
|
||||
color: var(--semi-color-text-0);
|
||||
padding: 7px 8px;
|
||||
max-width: 100%;
|
||||
}
|
||||
.flow-inspector label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.flow-inspector fieldset {
|
||||
border: 1px solid var(--semi-color-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.flow-inspector fieldset label {
|
||||
display: flex;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.flow-connect {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.flow-edge-list {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.flow-edge-list > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--semi-color-border);
|
||||
padding: 4px 0;
|
||||
}
|
||||
.quantflow-workspace .section-toolbar > .semi-input-wrapper {
|
||||
max-width: 240px;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.flow-editor {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.flow-inspector {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
const headers = { "X-WQ-Request": "1" };
|
||||
const scope = {
|
||||
instrument_type: "EQUITY",
|
||||
region: "USA",
|
||||
universe: "TOP3000",
|
||||
delay: 1,
|
||||
};
|
||||
test("native QuantFlow canvas saves versions, branches and restores artifacts", async ({
|
||||
page,
|
||||
}) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
await page.goto("/");
|
||||
await page.getByLabel("密码", { exact: true }).fill("browser-test-password");
|
||||
await page.getByRole("button", { name: "进入工作空间" }).click();
|
||||
await page
|
||||
.getByRole("navigation", { name: "主导航" })
|
||||
.getByRole("button", { name: "QuantFlow", exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "QuantFlow", exact: true }),
|
||||
).toBeVisible();
|
||||
await page.request.put("/api/v1/account/credentials", {
|
||||
headers,
|
||||
data: { email: "test@example.com", password: "synthetic-password" },
|
||||
});
|
||||
await page.request.post("/api/v1/account/connect", { headers });
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await (await page.request.get("/api/v1/account")).json())
|
||||
.connection_status,
|
||||
)
|
||||
.toBe("connected");
|
||||
const config = { base_url: "https://model.test/v1", model: "test-model" };
|
||||
await page.request.put("/api/v1/ai/settings", {
|
||||
headers,
|
||||
data: { ...config, api_key: "synthetic-key" },
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await (
|
||||
await page.request.post("/api/v1/ai/settings/test", { headers })
|
||||
).json()
|
||||
).ready,
|
||||
).toBe(true);
|
||||
await page.request.put("/api/v1/ai/settings", {
|
||||
headers,
|
||||
data: { ...config, enabled: true },
|
||||
});
|
||||
for (const dataset_id of [null, "TEST_FIN"]) {
|
||||
const job = await (
|
||||
await page.request.post("/api/v1/catalog/sync-jobs", {
|
||||
headers,
|
||||
data: { scope, dataset_id },
|
||||
})
|
||||
).json();
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await (await page.request.get(`/api/v1/sync-jobs/${job.id}`)).json())
|
||||
.status,
|
||||
)
|
||||
.toBe("completed");
|
||||
}
|
||||
const params = new URLSearchParams(
|
||||
Object.entries(scope).map(([k, v]) => [k, String(v)]),
|
||||
);
|
||||
const fields = await (
|
||||
await page.request.get(`/api/v1/catalog/datasets/TEST_FIN/fields?${params}`)
|
||||
).json();
|
||||
const fixed = await (
|
||||
await page.request.post("/api/v1/catalog/inputs", {
|
||||
headers,
|
||||
data: {
|
||||
scope,
|
||||
dataset_id: "TEST_FIN",
|
||||
collection_version: fields.collection_version,
|
||||
selection: "all",
|
||||
},
|
||||
})
|
||||
).json();
|
||||
expect(
|
||||
(
|
||||
await page.request.post("/api/v1/catalog/operators/refresh", { headers })
|
||||
).ok(),
|
||||
).toBe(true);
|
||||
expect(
|
||||
(
|
||||
await page.request.post("/api/v1/catalog/setting-options/refresh", {
|
||||
headers,
|
||||
})
|
||||
).ok(),
|
||||
).toBe(true);
|
||||
|
||||
const template = await (
|
||||
await page.request.post("/api/v1/research/assets", {
|
||||
headers,
|
||||
data: {
|
||||
kind: "template",
|
||||
content: {
|
||||
name: "画布测试模板",
|
||||
expression: "rank({field})",
|
||||
variables: {
|
||||
field: {
|
||||
kind: "field",
|
||||
field_type: "MATRIX",
|
||||
values: ["TEST_FIN_001", "TEST_FIN_002"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
).json();
|
||||
await page.reload();
|
||||
await page.getByLabel("流程名称", { exact: true }).fill("画布原生研究");
|
||||
async function add(kind: string, label: string) {
|
||||
await page.getByLabel("新增节点类型").selectOption(kind);
|
||||
await page.getByRole("button", { name: "添加节点", exact: true }).click();
|
||||
await page.getByLabel("节点名称", { exact: true }).fill(label);
|
||||
}
|
||||
await add("expand", "模板展开");
|
||||
await page.getByLabel("节点素材", { exact: true }).selectOption(template.id);
|
||||
await add("backtest", "模拟比较");
|
||||
await add("evaluate", "规则评估");
|
||||
await add("condition", "分支选择");
|
||||
await add("summarize", "通过汇总");
|
||||
await add("summarize", "待核实汇总");
|
||||
await add("summarize", "失败汇总");
|
||||
async function connect(source: string, target: string, branch?: string) {
|
||||
await page.getByLabel("连线起点").selectOption({ label: source });
|
||||
await page.getByLabel("连线终点").selectOption({ label: target });
|
||||
if (branch) await page.getByLabel("连线条件").selectOption(branch);
|
||||
await page.getByRole("button", { name: "添加连线", exact: true }).click();
|
||||
}
|
||||
await connect("固定输入", "模板展开");
|
||||
await connect("模板展开", "模拟比较");
|
||||
await connect("模拟比较", "规则评估");
|
||||
await connect("规则评估", "分支选择");
|
||||
await connect("分支选择", "通过汇总", "pass");
|
||||
await connect("分支选择", "待核实汇总", "review");
|
||||
await connect("分支选择", "失败汇总", "block");
|
||||
// Native node movement remains keyboard accessible and is saved in the version.
|
||||
const input = page.getByRole("button", {
|
||||
name: "节点 固定输入",
|
||||
exact: true,
|
||||
});
|
||||
await input.focus();
|
||||
await input.press("ArrowRight");
|
||||
await page.getByRole("button", { name: "校验连接", exact: true }).click();
|
||||
await expect(
|
||||
page.getByText("连接校验通过。", { exact: false }),
|
||||
).toBeVisible();
|
||||
const savedResponse = page.waitForResponse(
|
||||
(r) =>
|
||||
r.url().endsWith("/research/assets") && r.request().method() === "POST",
|
||||
);
|
||||
await page.getByRole("button", { name: "保存流程版本", exact: true }).click();
|
||||
const saved = await (await savedResponse).json();
|
||||
expect(saved.content.nodes[0].x).toBe(50);
|
||||
await page.getByLabel("流程名称", { exact: true }).fill("画布原生研究 v2");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "启动已保存流程", exact: true }),
|
||||
).toBeDisabled();
|
||||
await page.getByRole("button", { name: "保存流程版本", exact: true }).click();
|
||||
await expect(page.getByLabel("流程版本", { exact: true })).toHaveValue("2");
|
||||
await page.getByRole("button", { name: "适应画布", exact: true }).click();
|
||||
await page.screenshot({
|
||||
path: "/tmp/wq-stage-four-canvas.png",
|
||||
fullPage: true,
|
||||
});
|
||||
await page
|
||||
.getByRole("button", { name: "启动已保存流程", exact: true })
|
||||
.click();
|
||||
await page.getByLabel("自动研究假设").fill("画布条件分支与固定版本");
|
||||
await page.getByRole("combobox", { name: "自动研究固定输入" }).click();
|
||||
await page
|
||||
.getByRole("option")
|
||||
.filter({ hasText: fixed.id.slice(0, 8) })
|
||||
.click();
|
||||
await page.getByRole("heading", { name: "QuantFlow", exact: true }).click();
|
||||
for (const [label, value] of [
|
||||
["最大轮数", "1"],
|
||||
["最大模拟条目数", "2"],
|
||||
["最大模型调用数", "1"],
|
||||
["每轮候选上限", "2"],
|
||||
])
|
||||
await page.getByLabel(label, { exact: true }).fill(value);
|
||||
await page
|
||||
.getByRole("button", { name: "核对并启动研究", exact: true })
|
||||
.click();
|
||||
const dialog = page.getByRole("dialog", { name: "确认自动研究授权" });
|
||||
await expect(dialog).toContainText("流程:画布原生研究 v2 · v2");
|
||||
const started = page.waitForResponse(
|
||||
(r) =>
|
||||
r.url().endsWith("/research/flows/runs") &&
|
||||
r.request().method() === "POST",
|
||||
);
|
||||
await dialog.getByRole("button", { name: "confirm", exact: true }).click();
|
||||
const response = await started;
|
||||
expect(response.status()).toBe(201);
|
||||
const run = await response.json();
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(
|
||||
await (
|
||||
await page.request.get(`/api/v1/research/flows/runs/${run.id}`)
|
||||
).json()
|
||||
).status,
|
||||
{ timeout: 40000 },
|
||||
)
|
||||
.toBe("completed");
|
||||
const completed = await (
|
||||
await page.request.get(`/api/v1/research/flows/runs/${run.id}`)
|
||||
).json();
|
||||
expect(completed.model_calls_used).toBe(0);
|
||||
expect(completed.simulations_used).toBe(2);
|
||||
expect(
|
||||
completed.steps.filter((s: { status: string }) => s.status === "skipped"),
|
||||
).toHaveLength(2);
|
||||
expect(completed.authorization.workflow.version).toBe(2);
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByRole("region", { name: "研究运行详情" }),
|
||||
).toContainText("画布原生研究 v2");
|
||||
await page
|
||||
.getByRole("button", { name: "节点 规则评估", exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "所选节点产物" }),
|
||||
).toBeVisible();
|
||||
await page.screenshot({ path: "/tmp/wq-stage-four-run.png", fullPage: true });
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
Reference in New Issue
Block a user