feat(research): streamline template details and enable bot versioning
Deploy production / deploy (push) Successful in 1m35s

This commit is contained in:
yuxuanhui
2026-09-20 09:57:37 +08:00
parent ba60d8e5c4
commit 34f1a4fa77
15 changed files with 593 additions and 374 deletions
@@ -0,0 +1,14 @@
# 模板详情与回测准备迭代
Type: task
Status: ready-for-human
## 范围
模板详情移除 AI、假设、导入、删除、历史选择等交互;仅保留名称、类别、研究解释、表达式及占位符类型/描述,操作为保存和新增版本。回测准备按表达式、数据准备、展开选项、回测参数、生成候选集合排列。允许无取值模板保存,展开时按字段类型绑定固定输入;非字段参数不推测。保留现有不可变版本与并发保护,提供 bot 创建及新增版本能力。
## 验证
类型检查、后端模板保存/展开/版本/bot 回归、浏览器实际交互。
## Answer
已完成前后端实现。保存修改沿用不可变版本;新增版本允许内容不变时显式创建下一版。模板工坊仅配置字段类型与描述,已有显式 values 仍作为候选限制保留;空 field 从所选固定输入按类型绑定,非字段空参数返回明确 422。新增内置 bot 模板创建/版本能力和外部 MCP 版本、查询能力。
验证:前端 typecheck、改动文件 Ruff、git diff --check 通过。模板与工作空间 50 项测试通过;AI/MCP 回归 39 项通过,1 项因新增工具导致总数断言变化,更新断言后单独复跑通过。两条既有浏览器回归通过。Playwright CLI 实测字段去重/删除、描述保存、类型切换、保存 v1/新增 v2/编辑保存 v3、历史不变、选择数据准备后生成候选且未启动回测。截图位于 output/playwright/template-detail-editor.png 与 template-detail-prepare.png。验证使用隔离数据库和模拟平台。
+4 -1
View File
@@ -33,6 +33,9 @@ TOOLS = {
"get_pyramid_distribution": (c.PyramidQuery, "pyramid_distribution", "research:read", "实时读取指定 region(如 USA、GLB)和 delay(0/1)的个人 Pyramid Alpha 分布;必传 current_date(YYYY-MM-DD),自动按自然年四季度取完整起止日(如2026-09-13对应2026-07-01至2026-09-30),传给平台 startDate/endDate,不使用默认周期。按用户约定 alphaCount>=3 为 lit(已点亮),1–2 为 in_progress,0 为 unlit;每项含 category、alpha_count、距3条的 remaining。复用平台认证,未连接时先调用 authenticate_worldquant;缺失数据不当作0。不回测、不提交。"), "get_pyramid_distribution": (c.PyramidQuery, "pyramid_distribution", "research:read", "实时读取指定 region(如 USA、GLB)和 delay(0/1)的个人 Pyramid Alpha 分布;必传 current_date(YYYY-MM-DD),自动按自然年四季度取完整起止日(如2026-09-13对应2026-07-01至2026-09-30),传给平台 startDate/endDate,不使用默认周期。按用户约定 alphaCount>=3 为 lit(已点亮),1–2 为 in_progress,0 为 unlit;每项含 category、alpha_count、距3条的 remaining。复用平台认证,未连接时先调用 authenticate_worldquant;缺失数据不当作0。不回测、不提交。"),
"search_data_preparations": (c.PreparationSearch, "preparations", "research:read", "分页查询数据准备集合,返回固定范围、字段数及版本。研究可使用多个集合,各集合范围独立。"), "search_data_preparations": (c.PreparationSearch, "preparations", "research:read", "分页查询数据准备集合,返回固定范围、字段数及版本。研究可使用多个集合,各集合范围独立。"),
"get_data_preparation": (c.PreparationRead, "preparation", "research:read", "按集合 ID 与版本分页预览字段、类型、描述和数据集归属。提交回测时携带 preparation_refs,由服务端核对版本并固定独立输入快照;空集合不能用于研究。"), "get_data_preparation": (c.PreparationRead, "preparation", "research:read", "按集合 ID 与版本分页预览字段、类型、描述和数据集归属。提交回测时携带 preparation_refs,由服务端核对版本并固定独立输入快照;空集合不能用于研究。"),
"search_research_templates": (c.TemplateSearch, "templates", "research:read", "分页搜索模板工坊的模板与最新版本,不执行研究。"),
"get_research_template": (c.TemplateRead, "template", "research:read", "读取模板内容、字段定义和来源;新增版本前核对最新版本。"),
"create_research_template_version": (c.CreateTemplateVersion, "create_template_version", "research:write", "为已有模板新增不可变版本。先读取模板,携带 template_id、expected_version、完整 template、hypothesis、1–20 个已完成采集的 source_item_ids 及 idempotency_key。沿用创建模板的结构和来源校验;版本冲突须重新读取,不覆盖历史、不调用模型、不回测。"),
"create_research_template": (c.CreateTemplate, "create_template", "research:write", "将调用方大模型研究后自行总结的参数化模板保存到模板工坊,供用户后续批量回测。先用 get_backtest_results 阅读实际指标和检查,选择 1–20 个已完成采集的 source_item_ids,并说明 hypothesis;不要把 completed 当作检查通过。template 使用 {name} 占位符及逐一对应的 variables,字段变量须声明 MATRIX/VECTOR/GROUP,VECTOR 聚合须明确写入表达式。提供唯一名称和 idempotency_key,可附 reference。返回模板 ID、版本和理论组合数;仅核验结构及来源,不验证所有参数组合,不再次调用模型、不执行回测、不覆盖已有模板。"), "create_research_template": (c.CreateTemplate, "create_template", "research:write", "将调用方大模型研究后自行总结的参数化模板保存到模板工坊,供用户后续批量回测。先用 get_backtest_results 阅读实际指标和检查,选择 1–20 个已完成采集的 source_item_ids,并说明 hypothesis;不要把 completed 当作检查通过。template 使用 {name} 占位符及逐一对应的 variables,字段变量须声明 MATRIX/VECTOR/GROUP,VECTOR 聚合须明确写入表达式。提供唯一名称和 idempotency_key,可附 reference。返回模板 ID、版本和理论组合数;仅核验结构及来源,不验证所有参数组合,不再次调用模型、不执行回测、不覆盖已有模板。"),
"get_submission_check": (c.SelfCorrelationReference, "submission_check_context", "research:read", "读取已导入 Alpha 的表达式、Description、snapshot 和缓存检查结果;check_summary 分离 Alpha 检查和 REGULAR_SUBMISSION 提交限制,原始 checks 保留;限制不代表当前额度。不发起检查。先核对或生成三段 Description,再调用 check_submission。"), "get_submission_check": (c.SelfCorrelationReference, "submission_check_context", "research:read", "读取已导入 Alpha 的表达式、Description、snapshot 和缓存检查结果;check_summary 分离 Alpha 检查和 REGULAR_SUBMISSION 提交限制,原始 checks 保留;限制不代表当前额度。不发起检查。先核对或生成三段 Description,再调用 check_submission。"),
"check_submission": (c.SubmissionCheck, "check_submission", "research:refresh", "对单个待提交 Alpha 写回已获用户授权的 Description 并调用平台 GET /check,返回 job_id。须先用 get_submission_check 获取 snapshot;保留本地自相关门槛和冲突保护。通过 get_refresh_job 查进度、get_submission_check 读结果。无论检查结果如何,都不会调用 /submit 或正式提交 Alpha。"), "check_submission": (c.SubmissionCheck, "check_submission", "research:refresh", "对单个待提交 Alpha 写回已获用户授权的 Description 并调用平台 GET /check,返回 job_id。须先用 get_submission_check 获取 snapshot;保留本地自相关门槛和冲突保护。通过 get_refresh_job 查进度、get_submission_check 读结果。无论检查结果如何,都不会调用 /submit 或正式提交 Alpha。"),
@@ -82,7 +85,7 @@ class MCPResearchServer:
return types.ListToolsResult(tools=[types.Tool(name=name, description=description, return types.ListToolsResult(tools=[types.Tool(name=name, description=description,
inputSchema=schema.model_json_schema(), annotations=types.ToolAnnotations( inputSchema=schema.model_json_schema(), annotations=types.ToolAnnotations(
readOnlyHint=scope == "research:read", destructiveHint=method == "control", readOnlyHint=scope == "research:read", destructiveHint=method == "control",
idempotentHint=method in {"submit", "control", "create_template", "save_super_plan", "build_super_candidates"} or scope == "research:read", idempotentHint=method in {"submit", "control", "create_template", "create_template_version", "save_super_plan", "build_super_candidates"} or scope == "research:read",
openWorldHint=method in {"refresh", "submit", "metadata", "check_self_correlation", "check_submission", "authenticate", "pyramid_distribution", "preview_super_selection"})) openWorldHint=method in {"refresh", "submit", "metadata", "check_self_correlation", "check_submission", "authenticate", "pyramid_distribution", "preview_super_selection"}))
for name, (schema, method, scope, description) in TOOLS.items() for name, (schema, method, scope, description) in TOOLS.items()
if scope in principal.scopes and "research:read" in principal.scopes]) if scope in principal.scopes and "research:read" in principal.scopes])
+11 -2
View File
@@ -148,16 +148,25 @@ class Experiments:
) )
variables = {} variables = {}
for name, variable in template.variables.items(): for name, variable in template.variables.items():
values = variable.values
# Empty field definitions bind only to the selected immutable input scope.
# Existing explicit domains remain restrictions and are never silently widened.
if variable.kind == "field": if variable.kind == "field":
for value in variable.values: if not values:
values = sorted(field for field, kind in fields.items() if kind == variable.field_type)
if not values:
raise HTTPException(422, f"变量 {name} 没有匹配的 {variable.field_type} 字段,请调整数据准备")
for value in values:
if fields.get(str(value)) != variable.field_type: if fields.get(str(value)) != variable.field_type:
raise HTTPException(422, f"变量 {name} 的字段 {value} 不在固定输入中或类型不符") raise HTTPException(422, f"变量 {name} 的字段 {value} 不在固定输入中或类型不符")
if variable.kind == "group" and any( if variable.kind == "group" and any(
str(v) not in GROUPS and fields.get(str(v)) != "GROUP" for v in variable.values str(v) not in GROUPS and fields.get(str(v)) != "GROUP" for v in variable.values
): ):
raise HTTPException(422, f"分组变量 {name} 未在固定输入中核实") raise HTTPException(422, f"分组变量 {name} 未在固定输入中核实")
if not values:
raise HTTPException(422, f"变量 {name} 缺少候选取值,请通过模板接口补充,或将固定参数直接写入表达式")
variables[name] = [ variables[name] = [
json.dumps(v, ensure_ascii=False) if variable.kind == "string" else v for v in variable.values json.dumps(v, ensure_ascii=False) if variable.kind == "string" else v for v in values
] ]
try: try:
expanded = expand(template.expression, variables, body.mode, body.limit, body.seed) expanded = expand(template.expression, variables, body.mode, body.limit, body.seed)
+2 -1
View File
@@ -16,7 +16,8 @@ AssetKind = Literal["template", "feature", "view", "workflow", "superalpha_plan"
class Variable(Contract): class Variable(Contract):
kind: Literal["field", "operator", "integer", "number", "group", "string", "fragment"] kind: Literal["field", "operator", "integer", "number", "group", "string", "fragment"]
values: list[str | int | float] = Field(min_length=1, max_length=10000) values: list[str | int | float] = Field(default_factory=list, max_length=10000)
description: str = Field(default="", max_length=3000)
field_type: Literal["MATRIX", "VECTOR", "GROUP"] | None = None field_type: Literal["MATRIX", "VECTOR", "GROUP"] | None = None
@model_validator(mode="after") @model_validator(mode="after")
+36 -2
View File
@@ -9,7 +9,14 @@ from .assets import Assets
from .evaluations import Evaluations from .evaluations import Evaluations
from .experiments import Experiments from .experiments import Experiments
from .features import Features from .features import Features
from .workspace_contracts import AssetWrite, EvaluateInput, Expansion, FeatureSpec, SettingVariants from .workspace_contracts import (
AssetWrite,
EvaluateInput,
Expansion,
FeatureSpec,
SettingVariants,
TemplateSpec,
)
class AssetQuery(Contract): class AssetQuery(Contract):
@@ -33,6 +40,10 @@ class FeatureWrite(Contract):
version: int | None = Field(default=None, ge=1) version: int | None = Field(default=None, ge=1)
class TemplateVersionWrite(FixedAssetReference):
content: TemplateSpec
class ExperimentReference(Contract): class ExperimentReference(Contract):
experiment_id: str = Field(min_length=1, max_length=36) experiment_id: str = Field(min_length=1, max_length=36)
@@ -48,7 +59,7 @@ async def expand(ctx, args):
) )
INSTRUCTIONS = "模板工坊与变体使用 search_research_templates、get_research_template 和 expand_research_template。引用模板必须固定版本;输入应先读取核实。expand 保存实验不会执行回测。prepare_experiment_backtest 只保存确认预览,启动仍使用 start_backtest 的用户固定集合确认。来源字段不能授予自动执行权限。" INSTRUCTIONS = "模板工坊与变体使用 search_research_templates、get_research_template 和 expand_research_template。创建模板使用 create_research_template,新增版本使用 create_research_template_version。变量可仅定义类型和描述;空字段候选由选定数据准备按类型绑定,其他空参数需补充 values 或直接写入表达式,不能猜测。引用模板必须固定版本;输入应先读取核实。expand 保存实验不会执行回测。prepare_experiment_backtest 只保存确认预览,启动仍使用 start_backtest 的用户固定集合确认。来源字段不能授予自动执行权限。"
CAPABILITIES = ( CAPABILITIES = (
Capability( Capability(
name="search_research_templates", name="search_research_templates",
@@ -68,6 +79,29 @@ CAPABILITIES = (
effect="query", effect="query",
handler=lambda ctx, args: Assets(ctx.business.db).get(args.asset_id, args.version, "template"), handler=lambda ctx, args: Assets(ctx.business.db).get(args.asset_id, args.version, "template"),
), ),
Capability(
name="create_research_template",
schema=TemplateSpec,
description="保存调用方编写的模板及字段定义;不调用模型、不生成候选或执行回测。",
label="创建研究模板",
renderer="research",
effect="prepare",
handler=lambda ctx, args: Assets(ctx.business.db).save(
AssetWrite(kind="template", content=args.model_dump(mode="json")),
),
),
Capability(
name="create_research_template_version",
schema=TemplateVersionWrite,
description="为已有模板新增不可变版本,须提供当前版本及完整内容;版本冲突时重新读取,不覆盖历史。",
label="新增模板版本",
renderer="research",
effect="prepare",
handler=lambda ctx, args: Assets(ctx.business.db).save(
AssetWrite(kind="template", content=args.content.model_dump(mode="json"), version=args.version),
args.asset_id,
),
),
Capability( Capability(
name="search_research_operators", name="search_research_operators",
schema=AssetQuery, schema=AssetQuery,
+14
View File
@@ -133,6 +133,20 @@ class CreateTemplate(Contract):
return self return self
class CreateTemplateVersion(CreateTemplate):
template_id: RunId
expected_version: int = Field(ge=1)
class TemplateRead(Contract):
template_id: RunId
version: int | None = Field(default=None, ge=1)
class TemplateSearch(Page):
q: str = Field(default="", max_length=200)
class CatalogSearch(Contract): class CatalogSearch(Contract):
filters: CatalogFilters filters: CatalogFilters
dataset_id: str | None = Field(default=None, min_length=1, max_length=200) dataset_id: str | None = Field(default=None, min_length=1, max_length=200)
+26
View File
@@ -81,6 +81,8 @@ class ResearchAccess(SuperResearchAccess):
"metadata_only": True, "actual_platform_allowance": None, "metadata_only": True, "actual_platform_allowance": None,
"templates": { "templates": {
"create_with": "create_research_template", "required_scope": "research:write", "create_with": "create_research_template", "required_scope": "research:write",
"version_with": "create_research_template_version",
"read_with": "get_research_template", "search_with": "search_research_templates",
"authored_by": "caller", "max_source_items": 20, "authored_by": "caller", "max_source_items": 20,
"source_items_with": "get_backtest_results", "starts_backtests": False, "source_items_with": "get_backtest_results", "starts_backtests": False,
"web_url": f"{self.public_origin}/#templates", "web_url": f"{self.public_origin}/#templates",
@@ -285,6 +287,30 @@ class ResearchAccess(SuperResearchAccess):
return await self.remember("create_research_template", args, digest, result, return await self.remember("create_research_template", args, digest, result,
business_id=result["template_id"]) business_id=result["template_id"])
async def template(self, args):
"""Read the exact saved template revision without executing research."""
from ..research.assets import Assets
return await Assets(self.db).get(args.template_id, args.version, "template")
async def templates(self, args):
"""Search the same template library used by the browser."""
from ..research.assets import Assets
return await Assets(self.db).list("template", **args.model_dump())
async def create_template_version(self, args):
"""Append an idempotent, optimistic revision with refreshed source evidence."""
from .templates import create_template
operation = "create_research_template_version"
previous, digest = await self.previous(operation, args)
if previous:
return previous.response
result = await create_template(self.db, args, self.principal, asset_id=args.template_id)
result["web_url"] = f"{self.public_origin}/#templates"
return await self.remember(operation, args, digest, result, business_id=args.template_id)
async def previous(self, operation, args): async def previous(self, operation, args):
# PostgreSQL row lock is shared with HTTP start and catalog/job creation. # PostgreSQL row lock is shared with HTTP start and catalog/job creation.
account = await self.db.scalar(select(Account).where(Account.id == self.principal.account_id).with_for_update()) account = await self.db.scalar(select(Account).where(Account.id == self.principal.account_id).with_for_update())
+12 -4
View File
@@ -10,23 +10,27 @@ from ..research.workspace_contracts import AssetWrite
from .queries import item_summary from .queries import item_summary
async def create_template(db, args, principal): async def create_template(db, args, principal, *, asset_id=None):
"""Save a new asset inside the caller's account-locked, idempotent transaction. """Save a new asset or revision inside the caller's account-locked, idempotent transaction.
Args contain the external model's TemplateSpec and local source item IDs. Args contain the external model's TemplateSpec and local source item IDs.
Return the versioned asset and theoretical combination count. Raise Return the versioned asset and theoretical combination count. Raise
ResearchError for name conflicts or missing/incomplete research evidence. ResearchError for name conflicts or missing/incomplete research evidence.
Stored evidence proves provenance, not profitability or platform eligibility; Stored evidence proves provenance, not profitability or platform eligibility;
schema validation does not validate every expanded FASTEXPR combination. schema validation does not validate every expanded FASTEXPR combination.
Optional asset_id selects a version update guarded by args.expected_version;
a stale version raises HTTP 409 and cannot overwrite a historical revision.
""" """
from .service import ResearchError from .service import ResearchError
existing = await db.scalar(select(ResearchAsset).where( existing = await db.scalar(select(ResearchAsset).where(
ResearchAsset.kind == "template", ResearchAsset.name == args.template.name, ResearchAsset.kind == "template", ResearchAsset.name == args.template.name,
ResearchAsset.id != asset_id if asset_id else True,
).order_by(ResearchAsset.id).limit(1)) ).order_by(ResearchAsset.id).limit(1))
if existing: if existing:
raise ResearchError("TEMPLATE_NAME_CONFLICT", "模板名称已存在,请使用新名称;此工具不覆盖已有模板", raise ResearchError("TEMPLATE_NAME_CONFLICT", "模板名称已存在,请使用新名称;此工具不覆盖已有模板",
affected_items=[{"template_id": existing.id, "version": existing.version}]) affected_items=[{"template_id": existing.id, "version": existing.version}])
previous = await Assets(db).get(asset_id, expected_kind="template") if asset_id else None
rows = (await db.execute(select(BacktestItem, BacktestResult).outerjoin( rows = (await db.execute(select(BacktestItem, BacktestResult).outerjoin(
BacktestResult, BacktestResult.item_id == BacktestItem.id, BacktestResult, BacktestResult.item_id == BacktestItem.id,
).where(BacktestItem.id.in_(args.source_item_ids)))).all() ).where(BacktestItem.id.in_(args.source_item_ids)))).all()
@@ -48,12 +52,16 @@ async def create_template(db, args, principal):
"admin_id": principal.admin_id, "admin_id": principal.admin_id,
"source_items": [item_summary(*found[item_id]) for item_id in args.source_item_ids], "source_items": [item_summary(*found[item_id]) for item_id in args.source_item_ids],
} }
if previous:
provenance["parent_template"] = {"id": asset_id, "version": args.expected_version}
asset = await Assets(db).save(AssetWrite( asset = await Assets(db).save(AssetWrite(
kind="template", content=args.template.model_dump(mode="json"), kind="template", content=args.template.model_dump(mode="json"),
), provenance=provenance) version=args.expected_version if asset_id else None,
), asset_id=asset_id, provenance=provenance)
return { return {
**asset, "template_id": asset["id"], **asset, "template_id": asset["id"],
"combination_count": str(math.prod(len(v.values) for v in args.template.variables.values())), "combination_count": (str(math.prod(len(v.values) for v in args.template.variables.values()))
if all(v.values for v in args.template.variables.values()) else None),
"validation": {"structure": "valid", "source_evidence": "recorded", "validation": {"structure": "valid", "source_evidence": "recorded",
"expanded_candidates": "not_validated", "platform_semantics": "unknown"}, "expanded_candidates": "not_validated", "platform_semantics": "unknown"},
"next_step": "在模板工坊选择固定输入及模拟设置,展开并核验候选,再确认批量回测。", "next_step": "在模板工坊选择固定输入及模拟设置,展开并核验候选,再确认批量回测。",
+2 -1
View File
@@ -162,7 +162,8 @@ async def test_official_sdk_client_and_error_contract(mcp_app):
async with ClientSession(streams[0], streams[1]) as client: async with ClientSession(streams[0], streams[1]) as client:
await client.initialize() await client.initialize()
listed = await client.list_tools() listed = await client.list_tools()
assert len(listed.tools) == 29 assert len(listed.tools) == 32
assert {"search_research_templates", "get_research_template", "create_research_template_version"} <= {t.name for t in listed.tools}
assert any(tool.name == "get_pyramid_distribution" for tool in listed.tools) assert any(tool.name == "get_pyramid_distribution" for tool in listed.tools)
assert {"search_data_preparations", "get_data_preparation"} <= {t.name for t in listed.tools} assert {"search_data_preparations", "get_data_preparation"} <= {t.name for t in listed.tools}
caps = await client.call_tool("get_research_capabilities", {}) caps = await client.call_tool("get_research_capabilities", {})
+29
View File
@@ -236,3 +236,32 @@ async def test_browser_can_issue_template_only_and_all_permissions(app, logged_i
async with app.state.sessions() as db: async with app.state.sessions() as db:
principal = await authenticate(db, response.json()["token"]) principal = await authenticate(db, response.json()["token"])
assert principal.scopes == scopes assert principal.scopes == scopes
async def test_mcp_template_version_is_idempotent_and_preserves_history(app, completed_source):
principal, _ = await credentials(app, {"research:read", "research:write"})
body = template_request(completed_source["id"])
created = await invoke(app, principal, TOOL, body)
content = deepcopy(body["template"])
content["variables"]["field"] = {"kind": "field", "field_type": "MATRIX", "description": "数据准备中的矩阵字段"}
update = {**body, "template": content, "template_id": created["id"],
"expected_version": 1, "idempotency_key": "version-2"}
tool = "create_research_template_version"
first = await invoke(app, principal, tool, update)
replay = await invoke(app, principal, tool, update)
assert first == replay and first["version"] == 2
assert first["combination_count"] is None
assert first["provenance"]["parent_template"] == {"id": created["id"], "version": 1}
read = await invoke(app, principal, "get_research_template", {"template_id": created["id"], "version": 1})
assert read["content"]["variables"]["field"]["values"] == ["TEST_FIN_001", "TEST_FIN_002"]
listed = await invoke(app, principal, "search_research_templates", {"q": content["name"]})
assert listed["items"][0]["version"] == 2
stale = await app.state.mcp.invoke(principal, tool, update | {"idempotency_key": "stale-version"})
assert stale.is_error and stale.structured_content["error"]["code"] == "CONFLICT"
async with app.state.sessions() as db:
assert await db.scalar(select(func.count()).select_from(ResearchRevision)) == 2
denied, _ = await credentials(app, {"research:read"})
from fastapi import HTTPException
with pytest.raises(HTTPException) as forbidden:
await app.state.mcp.invoke(denied, tool, update)
assert forbidden.value.status_code == 403
+59
View File
@@ -526,3 +526,62 @@ async def test_model_cannot_append_preparation_references_to_fixed_inputs(app, l
assert response.status_code == 422 and "不能改变" in response.text assert response.status_code == 422 and "不能改变" in response.text
async with app.state.sessions() as db: async with app.state.sessions() as db:
assert not await db.scalar(select(ResearchAsset).where(ResearchAsset.name == "untrusted")) assert not await db.scalar(select(ResearchAsset).where(ResearchAsset.name == "untrusted"))
async def test_template_definitions_bind_selected_fields_without_mutating_asset(logged_in, research_input):
content = template()
content["expression"] = "rank({field}) + rank({field})"
content["variables"]["field"] = {"kind": "field", "field_type": "MATRIX", "description": "横截面字段"}
saved = await logged_in.post("/api/v1/research/assets", json={"kind": "template", "content": content})
assert saved.status_code == 201, saved.text
asset = saved.json()
assert asset["content"]["variables"]["field"] == {
"kind": "field", "field_type": "MATRIX", "description": "横截面字段", "values": [],
}
body = expansion(research_input["id"], asset_id=asset["id"], version=1, mode="random", limit=2, seed=19)
body.pop("template")
first = await logged_in.post("/api/v1/research/experiments", json=body)
second = await logged_in.post("/api/v1/research/experiments", json=body)
assert first.status_code == second.status_code == 201, first.text
assert first.json()["candidates"] == second.json()["candidates"]
assert len(first.json()["candidates"]) == 2
for candidate in first.json()["candidates"]:
field = candidate["bindings"]["field"]
assert research_input["field_types"][field] == "MATRIX"
assert candidate["expression"] == f"rank({field}) + rank({field})"
stored = (await logged_in.get(f"/api/v1/research/assets/{asset['id']}")).json()
assert stored["content"] == asset["content"]
async def test_empty_template_domains_fail_expansion_with_actionable_errors(logged_in, research_input):
body = expansion(research_input["id"])
body["template"]["variables"]["field"] = {"kind": "field", "field_type": "GROUP"}
response = await logged_in.post("/api/v1/research/experiments", json=body)
assert response.status_code == 422 and "没有匹配的 GROUP 字段" in response.text
body["template"]["expression"] = "ts_mean(TEST_FIN_001, {window})"
body["template"]["variables"] = {"window": {"kind": "integer", "description": "时间窗口"}}
response = await logged_in.post("/api/v1/research/experiments", json=body)
assert response.status_code == 422 and "变量 window 缺少候选取值" in response.text
async def test_native_bot_creates_template_and_immutable_version(app, logged_in):
from fastapi import HTTPException
from app.ai.capabilities import ToolContext
from app.ai.tools import CAPABILITIES
from app.business import Business
async with app.state.sessions.begin() as db:
ctx = ToolContext(Business(db))
content = template()
content["variables"]["field"].pop("values")
created = await CAPABILITIES["create_research_template"].invoke(ctx, content)
content["description"] = "修订后的研究解释"
args = {"asset_id": created["id"], "version": 1, "content": content}
updated = await CAPABILITIES["create_research_template_version"].invoke(ctx, args)
assert updated["version"] == 2
with pytest.raises(HTTPException) as conflict:
await CAPABILITIES["create_research_template_version"].invoke(ctx, args)
assert conflict.value.status_code == 409
old = (await logged_in.get(f"/api/v1/research/assets/{created['id']}?version=1")).json()
assert old["content"]["description"] == "测试经济假设"
+235 -291
View File
@@ -37,13 +37,6 @@ import { DeleteResearchButton } from "./DeleteResearchButton";
import { ComparisonPanel } from "./ComparisonPanel"; import { ComparisonPanel } from "./ComparisonPanel";
import "./workspace.css"; import "./workspace.css";
type ImportResult = {
templates: Template[];
digest: string;
conflicts: unknown[];
errors: unknown[];
differences?: unknown[];
};
export function ResearchWorkspace({ export function ResearchWorkspace({
page, page,
active, active,
@@ -90,8 +83,6 @@ export function ResearchWorkspace({
const [assetTotal, setAssetTotal] = useState(0); const [assetTotal, setAssetTotal] = useState(0);
const [historyTotal, setHistoryTotal] = useState(0); const [historyTotal, setHistoryTotal] = useState(0);
const refreshSequence = useRef(0); const refreshSequence = useRef(0);
const [importText, setImportText] = useState("");
const [importResult, setImportResult] = useState<ImportResult | null>(null);
const selectedInputs = inputs.filter((input) => inputIds.includes(input.id)); const selectedInputs = inputs.filter((input) => inputIds.includes(input.id));
const dirty = const dirty =
!asset || JSON.stringify(template) !== JSON.stringify(asset.content); !asset || JSON.stringify(template) !== JSON.stringify(asset.content);
@@ -170,32 +161,34 @@ export function ResearchWorkspace({
const next = await api<Asset>( const next = await api<Asset>(
`/research/assets/${action.asset_id}${action.version ? `?version=${action.version}` : ""}`, `/research/assets/${action.asset_id}${action.version ? `?version=${action.version}` : ""}`,
); );
setAsset(next); openTemplate(next);
setTemplate(next.content);
const feature = next.provenance?.feature;
if (feature) {
setInputIds(feature.content.input_ids);
setHypothesis(feature.content.hypothesis);
const fixed = feature.provenance?.inputs || [];
setInputs((old) => [
...old,
...fixed.filter((i) => !old.some((o) => o.id === i.id)),
]);
const first = fixed[0];
if (first)
setSettings((old) => ({
...old,
region: first.scope.region,
universe: first.scope.universe,
delay: first.scope.delay,
}));
}
}); });
if (action.type === "open_variant") { if (action.type === "open_variant") {
setParent(action.alpha_id); setParent(action.alpha_id);
setMethod("structure"); setMethod("structure");
} }
}, [active, action]); }, [active, action]);
function openTemplate(next: Asset) {
setAsset(next);
setTemplate(next.content);
setExperiment(null);
const feature = next.provenance?.feature;
const fixed = feature?.provenance?.inputs || [];
setInputIds(feature?.content.input_ids || []);
setInputs(fixed);
setHypothesis(feature?.content.hypothesis || "");
const first = fixed[0];
setSettings({
...initialSettings,
...(first
? {
region: first.scope.region,
universe: first.scope.universe,
delay: first.scope.delay,
}
: {}),
});
}
async function save() { async function save() {
const next = asset const next = asset
? await api<Asset>(`/research/assets/${asset.id}`, { ? await api<Asset>(`/research/assets/${asset.id}`, {
@@ -258,7 +251,10 @@ export function ResearchWorkspace({
asset_id: asset!.id, asset_id: asset!.id,
version: asset!.version, version: asset!.version,
...researchSelection(inputIds, inputs), ...researchSelection(inputIds, inputs),
hypothesis, hypothesis:
page === "templates"
? template.description.trim() || `使用模板:${template.name}`
: hypothesis,
settings, settings,
mode, mode,
limit, limit,
@@ -314,6 +310,10 @@ export function ResearchWorkspace({
setAsset(null); setAsset(null);
setTemplate(blankTemplate()); setTemplate(blankTemplate());
setExperiment(null); setExperiment(null);
setInputIds([]);
setInputs([]);
setHypothesis("");
setSettings(initialSettings);
setDetailTab("editor"); setDetailTab("editor");
setDetailMode("editor"); setDetailMode("editor");
setDetailOpen(true); setDetailOpen(true);
@@ -368,7 +368,7 @@ export function ResearchWorkspace({
dataSource={assets} dataSource={assets}
rowKey="id" rowKey="id"
scroll={{ x: 800, y: "100%" }} scroll={{ x: 800, y: "100%" }}
empty="还没有模板。新建或导入一个模板开始研究。" empty="还没有模板。新建一个模板开始研究。"
columns={[ columns={[
{ {
title: "模板名称", title: "模板名称",
@@ -383,9 +383,7 @@ export function ResearchWorkspace({
const next = await api<Asset>( const next = await api<Asset>(
`/research/assets/${item.id}`, `/research/assets/${item.id}`,
); );
setAsset(next); openTemplate(next);
setTemplate(next.content);
setExperiment(null);
setDetailTab("editor"); setDetailTab("editor");
setDetailMode("editor"); setDetailMode("editor");
setDetailOpen(true); setDetailOpen(true);
@@ -544,7 +542,7 @@ export function ResearchWorkspace({
{asset?.archived && ( {asset?.archived && (
<Banner <Banner
type="info" type="info"
description="此模板已删除,当前显示保留的历史版本;可另存新模板继续编辑。" description="此模板已删除,当前仅显示保留的历史版本。"
/> />
)} )}
{page === "templates" && ( {page === "templates" && (
@@ -559,125 +557,127 @@ export function ResearchWorkspace({
]} ]}
/> />
)} )}
<div hidden={page === "templates" && detailTab !== "editor"}> {page === "variants" && (
<section className="research-card"> <div>
<h3>研究输入与假设</h3> <section className="research-card">
{page === "variants" && ( <h3>研究输入与假设</h3>
<> {page === "variants" && (
<div className="research-methods"> <>
<Button <div className="research-methods">
theme={method === "structure" ? "solid" : "light"} <Button
onClick={() => setMethod("structure")} theme={method === "structure" ? "solid" : "light"}
> onClick={() => setMethod("structure")}
结构与字段变体 >
</Button> 结构与字段变体
<Button </Button>
theme={method === "settings" ? "solid" : "light"} <Button
onClick={() => setMethod("settings")} theme={method === "settings" ? "solid" : "light"}
> onClick={() => setMethod("settings")}
市场与设置变体 >
</Button> 市场与设置变体
</div> </Button>
<p> </div>
{method === "structure" <p>
? "神笔马良:保留种子与改动依据,研究新的表达式。" {method === "structure"
: "缘分一道桥:保持表达式,为各目标市场重新固定输入后检查可用范围。"} ? "神笔马良:保留种子与改动依据,研究新的表达式。"
</p> : "缘分一道桥:保持表达式,为各目标市场重新固定输入后检查可用范围。"}
<label> </p>
种子 Alpha <label>
<Input 种子 Alpha
aria-label="种子 Alpha" <Input
value={parent} aria-label="种子 Alpha"
onChange={setParent} value={parent}
placeholder="已同步的 Alpha ID" onChange={setParent}
/> placeholder="已同步的 Alpha ID"
</label> />
</> </label>
)} </>
<label> )}
固定研究输入
<ResearchDataInput
label="选择数据准备"
ids={inputIds}
inputs={inputs}
onChange={(ids, rows) => {
setInputs(rows);
setInputIds(ids);
const first = rows.find((r) => r.id === ids[0]);
if (first)
setSettings((old) => ({
...old,
region: first.scope.region,
universe: first.scope.universe,
delay: first.scope.delay,
}));
}}
/>
</label>
<p className="research-hint">
从数据准备选择集合;每个集合可包含同范围下多个数据集的字段。
</p>
{page === "variants" && method === "settings" ? (
<label> <label>
本次研究假设 固定研究输入
<TextArea <ResearchDataInput
aria-label="本次研究假设" label="选择数据准备"
value={hypothesis} ids={inputIds}
onChange={setHypothesis} inputs={inputs}
autosize={{ minRows: 2, maxRows: 5 }} onChange={(ids, rows) => {
setInputs(rows);
setInputIds(ids);
const first = rows.find((r) => r.id === ids[0]);
if (first)
setSettings((old) => ({
...old,
region: first.scope.region,
universe: first.scope.universe,
delay: first.scope.delay,
}));
}}
/> />
</label> </label>
) : ( <p className="research-hint">
<div className="research-ai-section"> 从数据准备选择集合;每个集合可包含同范围下多个数据集的字段。
<h4> </p>
<IconAIEditLevel3 aria-hidden="true" /> {page === "variants" && method === "settings" ? (
<label>
本次研究假设 本次研究假设
</h4> <TextArea
<ResearchInput aria-label="本次研究假设"
maxLength={10000} value={hypothesis}
label="本次研究假设" onChange={setHypothesis}
value={hypothesis} autosize={{ minRows: 2, maxRows: 5 }}
onChange={setHypothesis} />
placeholder={ </label>
page === "variants" ) : (
? "描述希望保留的信号与要探索的变体方向…" <div className="research-ai-section">
: "描述经济假设、信号方向与希望验证的关系…" <h4>
} <IconAIEditLevel3 aria-hidden="true" />
references={[ 本次研究假设
...selectedInputs.map((input) => ({ </h4>
id: input.id, <ResearchInput
name: `${input.name} · ${input.scope.region}/${input.scope.universe}/D${input.scope.delay} · ${input.field_ids.length} 字段 · ${input.id}`, maxLength={10000}
})), label="本次研究假设"
...(page === "variants" && parent value={hypothesis}
? [ onChange={setHypothesis}
{ placeholder={
id: "parent-alpha", page === "variants"
name: `种子 Alpha:${parent}`, ? "描述希望保留的信号与要探索的变体方向…"
}, : "描述经济假设、信号方向与希望验证的关系…"
] }
: []), references={[
]} ...selectedInputs.map((input) => ({
disabled={!!busy} id: input.id,
canSubmit={!!inputIds.length} name: `${input.name} · ${input.scope.region}/${input.scope.universe}/D${input.scope.delay} · ${input.field_ids.length} 字段 · ${input.id}`,
busy={busy === "AI 生成"} })),
submitLabel="AI 生成模板" ...(page === "variants" && parent
sendHotKey="shift+enter" ? [
onSubmit={() => void task("AI 生成", generate)} {
prompts={ id: "parent-alpha",
page === "variants" name: `种子 Alpha:${parent}`,
? ["保留种子的经济含义,探索不同时间窗口和字段组合"] },
: ["研究短期价格反转与成交量之间的关系"] ]
} : []),
hint={ ]}
inputIds.length disabled={!!busy}
? "基于已关联的固定输入生成,保留假设供下一轮调整。" canSubmit={!!inputIds.length}
: "请先关联固定研究输入。" busy={busy === "AI 生成"}
} submitLabel="AI 生成模板"
/> sendHotKey="shift+enter"
</div> onSubmit={() => void task("AI 生成", generate)}
)} prompts={
</section> page === "variants"
</div> ? ["保留种子的经济含义,探索不同时间窗口和字段组合"]
: ["研究短期价格反转与成交量之间的关系"]
}
hint={
inputIds.length
? "基于已关联的固定输入生成,保留假设供下一轮调整。"
: "请先关联固定研究输入。"
}
/>
</div>
)}
</section>
</div>
)}
{page !== "variants" || method === "structure" ? ( {page !== "variants" || method === "structure" ? (
<> <>
<div hidden={page === "templates" && detailTab !== "editor"}> <div hidden={page === "templates" && detailTab !== "editor"}>
@@ -692,85 +692,100 @@ export function ResearchWorkspace({
value={template} value={template}
onChange={setTemplate} onChange={setTemplate}
inputs={selectedInputs} inputs={selectedInputs}
definitionsOnly={page === "templates"}
/> />
{asset && ( {page === "templates" ? (
<div className="research-toolbar"> <div className="research-toolbar">
<span>版本记录</span>
<ResearchSelect
label="模板历史版本"
value={asset.version}
optionList={Array.from(
{
length:
assets.find((item) => item.id === asset.id)
?.version || asset.version,
},
(_, i) => ({ value: i + 1, label: `v${i + 1}` }),
)}
onChange={(version) =>
void task("读取历史版本", async () => {
const next = await api<Asset>(
`/research/assets/${asset.id}?version=${version}`,
);
setAsset(next);
setTemplate(next.content);
})
}
/>
<Button <Button
onClick={() => { theme="solid"
setAsset(null); disabled={!!busy || asset?.archived || !dirty}
setTemplate({ onClick={() => void task("保存模板", save)}
...template,
name: `${template.name}(副本)`,
});
}}
> >
另存新模板 保存
</Button>
<Button
disabled={!!busy || !asset || asset.archived}
onClick={() => void task("新增版本", save)}
>
新增版本
</Button> </Button>
</div> </div>
)} ) : (
<div className="research-toolbar"> <>
<Button {asset && (
disabled={!!busy || asset?.archived} <div className="research-toolbar">
onClick={() => void task("保存模板", save)} <span>版本记录</span>
> <ResearchSelect
保存模板版本 label="模板历史版本"
</Button> value={asset.version}
{asset && !asset.archived && ( optionList={Array.from(
<DeleteResearchButton {
name={asset.name} length:
label="模板" assets.find((item) => item.id === asset.id)
disabled={!!busy} ?.version || asset.version,
onConfirm={() => },
task("删除模板", () => removeAsset(asset)) (_, i) => ({
} value: i + 1,
/> label: `v${i + 1}`,
)} }),
<span>组合规模:{combination}</span> )}
</div> onChange={(version) =>
{page === "templates" && ( void task("读取历史版本", async () => {
<Button const next = await api<Asset>(
theme="solid" `/research/assets/${asset.id}?version=${version}`,
disabled={!!busy || dirty} );
onClick={() => setDetailTab("prepare")} setAsset(next);
> setTemplate(next.content);
下一步:回测准备 })
</Button> }
/>
<Button
onClick={() => {
setAsset(null);
setTemplate({
...template,
name: `${template.name}(副本)`,
});
}}
>
另存新模板
</Button>
</div>
)}
<div className="research-toolbar">
<Button
disabled={!!busy || asset?.archived}
onClick={() => void task("保存模板", save)}
>
保存模板版本
</Button>
{asset && !asset.archived && (
<DeleteResearchButton
name={asset.name}
label="模板"
disabled={!!busy}
onConfirm={() =>
task("删除模板", () => removeAsset(asset))
}
/>
)}
<span>组合规模:{combination}</span>
</div>
</>
)} )}
</section> </section>
</div> </div>
<div hidden={page === "templates" && detailTab !== "prepare"}> <div hidden={page === "templates" && detailTab !== "prepare"}>
<section className="research-card"> <section className="research-card">
<h3>回测准备</h3> <h3>回测准备</h3>
<p className="research-hint"> <div aria-label="回测表达式">
配置展开方式与模拟设置 → 生成候选研究集合 → <h4>表达式</h4>
生成回测确认预览 → 确认后开始回测。 <pre>{template.expression}</pre>
</p> </div>
{page === "templates" && ( {page === "templates" && (
<> <>
<label> <label>
固定研究输入 数据准备
<ResearchDataInput <ResearchDataInput
label="选择回测数据准备" label="选择回测数据准备"
ids={inputIds} ids={inputIds}
@@ -789,20 +804,6 @@ export function ResearchWorkspace({
}} }}
/> />
</label> </label>
<label>
本次研究假设
<TextArea
aria-label="回测研究假设"
value={hypothesis}
onChange={setHypothesis}
autosize={{ minRows: 2, maxRows: 5 }}
/>
</label>
<p>
模板:{template.name || "未命名"} ·{" "}
{asset ? `v${asset.version}` : "未保存"} · 组合规模:
{combination}
</p>
{dirty && ( {dirty && (
<Banner <Banner
type="warning" type="warning"
@@ -857,20 +858,22 @@ export function ResearchWorkspace({
disabled={ disabled={
dirty || dirty ||
!inputIds.length || !inputIds.length ||
!hypothesis || (page === "variants" && !hypothesis) ||
!!busy || !!busy ||
!settingsValid !settingsValid
} }
onClick={() => void task("生成候选", expand)} onClick={() => void task("生成候选", expand)}
> >
生成候选研究集合 生成候选集合
</Button> </Button>
{(!inputIds.length || !hypothesis || !settingsValid) && ( {(!inputIds.length ||
(page === "variants" && !hypothesis) ||
!settingsValid) && (
<p className="research-hint"> <p className="research-hint">
待完成: 待完成:
{[ {[
!inputIds.length && "选择固定研究输入", !inputIds.length && "选择固定研究输入",
!hypothesis && "填写研究假设", page === "variants" && !hypothesis && "填写研究假设",
!settingsValid && "检查模拟设置", !settingsValid && "检查模拟设置",
] ]
.filter(Boolean) .filter(Boolean)
@@ -919,65 +922,6 @@ export function ResearchWorkspace({
/> />
)} )}
</div> </div>
{page === "templates" && detailTab === "editor" && (
<details className="research-card">
<summary>导入旧模板</summary>
<p>
粘贴 cnhk 模板 JSON
或模板数组。先预览转换结果,同名模板不会自动覆盖。
</p>
<TextArea
aria-label="导入模板 JSON"
value={importText}
onChange={(text) => {
setImportText(text);
setImportResult(null);
}}
autosize={{ minRows: 4, maxRows: 12 }}
/>
<Button
disabled={!!busy || !importText}
onClick={() =>
void task("预览导入", async () => {
const data = JSON.parse(importText);
setImportResult(
await post("/research/templates/import-preview", {
templates: Array.isArray(data) ? data : [data],
}),
);
})
}
>
预览导入差异
</Button>
{importResult && (
<>
<pre>{JSON.stringify(importResult, null, 2)}</pre>
<Button
disabled={
!!busy ||
!!importResult.errors?.length ||
!!importResult.conflicts?.length
}
onClick={() =>
void task("导入模板", async () => {
await post("/research/templates/import", {
templates: importResult.templates,
digest: importResult.digest,
});
setImportResult(null);
setImportText("");
await refresh();
Toast.success("导入完成");
})
}
>
确认导入
</Button>
</>
)}
</details>
)}
{page === "variants" && ( {page === "variants" && (
<ComparisonPanel key={parent} baseline={parent} /> <ComparisonPanel key={parent} baseline={parent} />
)} )}
+127 -72
View File
@@ -14,10 +14,12 @@ export function TemplateEditor({
value, value,
onChange, onChange,
inputs, inputs,
definitionsOnly = false,
}: { }: {
value: Template; value: Template;
onChange: (value: Template) => void; onChange: (value: Template) => void;
inputs: InputSnapshot[]; inputs: InputSnapshot[];
definitionsOnly?: boolean;
}) { }) {
function expression(text: string) { function expression(text: string) {
const names = [ const names = [
@@ -33,11 +35,13 @@ export function TemplateEditor({
variables: Object.fromEntries( variables: Object.fromEntries(
names.map((name) => [ names.map((name) => [
name, name,
value.variables[name] || { Object.hasOwn(value.variables, name)
kind: "field", ? value.variables[name]
field_type: "MATRIX", : {
values: [], kind: "field",
}, field_type: "MATRIX",
values: [],
},
]), ]),
), ),
}); });
@@ -95,86 +99,137 @@ export function TemplateEditor({
使用 {"{name}"}{" "} 使用 {"{name}"}{" "}
声明变量;重复占位符共享同一个取值。组合片段只复用表达式,不修改平台算子定义。 声明变量;重复占位符共享同一个取值。组合片段只复用表达式,不修改平台算子定义。
</p> </p>
{Object.entries(value.variables).map(([name, item]) => ( {definitionsOnly && Object.keys(value.variables).length > 0 && (
<div className="research-variable" key={name}> <h4>字段配置</h4>
<strong>{`{${name}}`}</strong> )}
<ResearchSelect {Object.entries(value.variables).map(([name, item]) =>
label={`${name} 变量类型`} definitionsOnly ? (
value={item.kind} <div className="research-variable-definition" key={name}>
optionList={Object.entries(kinds).map(([value, label]) => ({ <strong>{`{${name}}`}</strong>
value, <label>
label, 字段类型
}))} <ResearchSelect
onChange={(kind) => label={`${name} 字段类型`}
variable(name, { value={item.kind === "field" ? item.field_type : item.kind}
kind: kind as Variable["kind"], optionList={[
values: [], ...["MATRIX", "VECTOR", "GROUP"].map((value) => ({
...(kind === "field" ? { field_type: "MATRIX" } : {}), value,
}) label: value,
} })),
/> ...Object.entries(kinds)
{item.kind === "field" && ( .filter(([kind]) => kind !== "field")
.map(([value, label]) => ({ value, label })),
]}
onChange={(type) => {
const isField = ["MATRIX", "VECTOR", "GROUP"].includes(
String(type),
);
variable(name, {
kind: isField ? "field" : (type as Variable["kind"]),
...(isField
? { field_type: type as Variable["field_type"] }
: {}),
description: item.description || "",
values: [],
});
}}
/>
</label>
<label>
字段描述
<Input
aria-label={`${name} 字段描述`}
value={item.description || ""}
maxLength={3000}
placeholder="说明字段含义与用途"
onChange={(description) =>
variable(name, { ...item, description })
}
/>
</label>
</div>
) : (
<div className="research-variable" key={name}>
<strong>{`{${name}}`}</strong>
<ResearchSelect <ResearchSelect
label={`${name} 字段类型`} label={`${name} 变量类型`}
value={item.field_type} value={item.kind}
optionList={["MATRIX", "VECTOR", "GROUP"].map((value) => ({ optionList={Object.entries(kinds).map(([value, label]) => ({
value, value,
label: value, label,
}))} }))}
onChange={(field_type) => onChange={(kind) =>
variable(name, { variable(name, {
...item, kind: kind as Variable["kind"],
field_type: field_type as Variable["field_type"],
values: [], values: [],
...(kind === "field" ? { field_type: "MATRIX" } : {}),
}) })
} }
/> />
)} {item.kind === "field" && (
<TextArea <ResearchSelect
aria-label={`${name} 候选取值`} label={`${name} 字段类型`}
placeholder={ value={item.field_type}
item.kind === "fragment" optionList={["MATRIX", "VECTOR", "GROUP"].map((value) => ({
? "每行一个完整表达式片段" value,
: "每行一个取值" label: value,
} }))}
value={item.values.join("\n")} onChange={(field_type) =>
autosize={{ minRows: 2, maxRows: 6 }} variable(name, {
onChange={(text) => ...item,
variable(name, { field_type: field_type as Variable["field_type"],
...item, values: [],
values: text })
.split("\n") }
.map((v) => />
["integer", "number"].includes(item.kind) && v.trim() !== "" )}
? Number(v) <TextArea
: v, aria-label={`${name} 候选取值`}
), placeholder={
}) item.kind === "fragment"
} ? "每行一个完整表达式片段"
/> : "每行一个取值"
{item.kind === "field" && ( }
<Button value={item.values.join("\n")}
disabled={!inputs.length} autosize={{ minRows: 2, maxRows: 6 }}
onClick={() => onChange={(text) =>
variable(name, { variable(name, {
...item, ...item,
values: [ values: text
...new Set( .split("\n")
inputs.flatMap((input) => .map((v) =>
input.field_ids.filter( ["integer", "number"].includes(item.kind) &&
(id) => input.field_types[id] === item.field_type, v.trim() !== ""
), ? Number(v)
), : v,
), ),
],
}) })
} }
> />
使用固定输入中的 {item.field_type} 字段 {item.kind === "field" && (
</Button> <Button
)} disabled={!inputs.length}
</div> onClick={() =>
))} variable(name, {
...item,
values: [
...new Set(
inputs.flatMap((input) =>
input.field_ids.filter(
(id) => input.field_types[id] === item.field_type,
),
),
),
],
})
}
>
使用固定输入中的 {item.field_type} 字段
</Button>
)}
</div>
),
)}
</div> </div>
); );
} }
+21
View File
@@ -374,3 +374,24 @@
flex-shrink: 0; flex-shrink: 0;
padding: 8px 0 12px; padding: 8px 0 12px;
} }
.research-variable-definition {
display: grid;
grid-template-columns: minmax(90px, 0.5fr) minmax(140px, 1fr) minmax(
200px,
2fr
);
gap: 12px;
align-items: center;
border-top: 1px solid var(--semi-color-border);
}
.research-variable-definition > strong {
overflow-wrap: anywhere;
}
@media (max-width: 640px) {
.research-variable-definition {
grid-template-columns: minmax(0, 1fr);
gap: 0;
padding-top: 12px;
}
}
+1
View File
@@ -10,6 +10,7 @@ export type Variable = {
| "string" | "string"
| "fragment"; | "fragment";
values: (string | number)[]; values: (string | number)[];
description?: string;
field_type?: "MATRIX" | "VECTOR" | "GROUP" | null; field_type?: "MATRIX" | "VECTOR" | "GROUP" | null;
}; };
export type Template = { export type Template = {