feat: implement scoped dataset catalog and template input drafts
This commit is contained in:
@@ -0,0 +1,25 @@
|
|||||||
|
# 数据目录实现与验收
|
||||||
|
Type: task
|
||||||
|
Status: resolved
|
||||||
|
|
||||||
|
按已确认 spec.md 实现范围化目录、完整字段集合、备注与输入草稿,并接入现有持久化任务及浏览器验收。禁止真实平台写入、收费模型、部署及 Git 提交。
|
||||||
|
|
||||||
|
## 实现约定
|
||||||
|
- 独立同步批次保存分页和检查点,成功后原子切换当前版本;旧字段和草稿保留。
|
||||||
|
- scope_sketch:研究范围/分类筛选 → 单数据集 → 字段 Table/详情 → 输入草稿。
|
||||||
|
- lark_style_recipe:复用 Semi 2.103,白底、4px 间距、14px/22px/400 表体、浅边框;侧栏保留 #f9f9f9 / #1f23290d。
|
||||||
|
- ud_control_coverage:Table、Button、Input、Select、SideSheet、Checkbox、Radio、Pagination、TextArea。
|
||||||
|
- layout_signature_usage:复用工作空间侧栏与顶部导航,不新增标题或 Hero。
|
||||||
|
- icon_plan:新增操作采用有名称的文字按钮,无新增业务图标槽位;组件内置交互符号沿用现有控件。
|
||||||
|
- media_decision:数据研究工具无需插图。
|
||||||
|
- verification:HTTP 边界合成数据、API/执行器、浏览器完整流程、隔离 PostgreSQL 迁移及旧数据保留。
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
## Answer
|
||||||
|
|
||||||
|
已完成目录业务、0003 增量迁移、复用持久化任务、单数据集选择与输入草稿、备注 CAS、75%/30% 双层抽屉和 AI 状态恢复。字段选择使用已发布集合成员,输入由服务端再次解析并固定集合版本。
|
||||||
|
|
||||||
|
验证:后端 85 项、浏览器 8 项、前端生产构建及静态检查通过;隔离 PostgreSQL 17 迁移/回退再升级/元数据一致性/旧研究保留/实际业务事务验证通过。详见 `docs/verification.md` 的本次记录。
|
||||||
|
|
||||||
|
独立只读核验提出字段归属缺失和异常 next 两项问题,已收紧发布条件并补 HTTP 回归。未扩大到真实模板、回测或数据集 AI 工具;真实平台只读联调仍待后续授权。
|
||||||
@@ -31,6 +31,20 @@ docker compose ps
|
|||||||
|
|
||||||
工作空间和 AI 交互统一采用紧凑的 Lark 样式。Alpha 列表只滚动表体,分页保持在可用区域底部;个人信息页独立滚动。
|
工作空间和 AI 交互统一采用紧凑的 Lark 样式。Alpha 列表只滚动表体,分页保持在可用区域底部;个人信息页独立滚动。
|
||||||
|
|
||||||
|
## 数据集与数据字段
|
||||||
|
|
||||||
|
从侧栏进入“数据集”,设置 Region、Universe、Delay 后手动同步目录。范围选项表示本版支持的组合,平台账户实际权限以同步结果为准;分类和子分类来自已同步数据。
|
||||||
|
|
||||||
|
选中一个数据集后默认使用整集字段;首次使用先同步全部字段。字段列表、搜索、类型、覆盖率、排序及翻页均不改变输入范围,只有明确取消勾选才排除字段。表头选择作用于整个已完成集合,支持恢复全选。字段与详情采用 75% / 30% 的工作区右抽屉,窄屏展开为全宽;逐层关闭保留父层条件。抽屉顶部可打开 AI 助手,业务抽屉暂时隐藏,收起助手后恢复;不会发送字段或研究备注给模型。
|
||||||
|
|
||||||
|
“用于 Alpha 模板”目前进入**保存输入草稿**,尚未接入模板编辑器或回测。草稿在服务端固定数据集、研究范围、集合版本、字段 ID 和字段类型,可通过“已保存输入”查看。后续同步不会改变旧草稿。
|
||||||
|
|
||||||
|
数据集和字段备注单独保存,版本冲突保留当前草稿。字段同步沿用已有任务面板的进度、取消、重试、等待连接和人工验证;每页与检查点同事务保存。只有完整分页成功才发布新集合,失败或取消继续使用上一版;首次未完成时不可准备输入。异常字段归属、覆盖率单位或分页协议会失败,不以部分字段代替全集。
|
||||||
|
|
||||||
|
增量迁移 `0003` 只增加目录、集合、备注与输入表,不改写旧迁移。`/api/v1/catalog` 提供带会话和来源校验的目录/字段查询、完整集合成员、备注、同步创建和输入草稿接口;创建目录同步返回任务 ID,查询、取消及重试仍使用 `/api/v1/sync-jobs`。新任务 `payload` 显式记录范围及数据集,保留旧 Alpha 任务契约。
|
||||||
|
|
||||||
|
真实 WorldQuant 数据集 schema、字段所属数据集信息、0–1 覆盖率单位、范围权限和分页协议尚需只读联调。当前证据来自 HTTP 边界合成数据和隔离 PostgreSQL,不代表已验证真实平台兼容性。
|
||||||
|
|
||||||
## AI 研究助手
|
## AI 研究助手
|
||||||
|
|
||||||
1. 在“个人信息 → 大模型服务”填写 Base URL、API Key、模型标识,明确选择 Chat Completions 或 Responses。
|
1. 在“个人信息 → 大模型服务”填写 Base URL、API Key、模型标识,明确选择 Chat Completions 或 Responses。
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class ModelSettingsInput(Contract):
|
|||||||
|
|
||||||
|
|
||||||
class PageContext(Contract):
|
class PageContext(Contract):
|
||||||
page: Literal["alphas", "account"] = "alphas"
|
page: Literal["alphas", "account", "datasets"] = "alphas"
|
||||||
alpha_id: str | None = Field(default=None, max_length=100, pattern=r"^[A-Za-z0-9_-]+$")
|
alpha_id: str | None = Field(default=None, max_length=100, pattern=r"^[A-Za-z0-9_-]+$")
|
||||||
selected_ids: list[str] = Field(default_factory=list, max_length=100)
|
selected_ids: list[str] = Field(default_factory=list, max_length=100)
|
||||||
filters: AlphaFilters = Field(default_factory=AlphaFilters)
|
filters: AlphaFilters = Field(default_factory=AlphaFilters)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Scope-isolated data catalog and immutable template input preparation."""
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""Explicit research scope and catalog contracts; unknown platform types remain strings."""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
|
from pydantic import AfterValidator, BaseModel, Field, model_validator
|
||||||
|
|
||||||
|
from ..schemas import Contract
|
||||||
|
|
||||||
|
|
||||||
|
def utc_timestamp(value: datetime) -> datetime:
|
||||||
|
"""SQLite drops tzinfo; catalog source times always denote UTC instants."""
|
||||||
|
return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value
|
||||||
|
|
||||||
|
|
||||||
|
UTCTimestamp = Annotated[datetime, AfterValidator(utc_timestamp)]
|
||||||
|
|
||||||
|
|
||||||
|
# Supported research scopes, not an assertion about a connected account's permissions.
|
||||||
|
UNIVERSES = {
|
||||||
|
"USA": ["TOP3000", "TOP1000", "TOP500", "TOP200"],
|
||||||
|
"CHN": ["TOP2000"],
|
||||||
|
"EUR": ["TOP2500", "TOP1200"],
|
||||||
|
"ASI": ["TOP1000"],
|
||||||
|
"GLB": ["TOP3000"],
|
||||||
|
"JPN": ["TOP1600"],
|
||||||
|
"HKG": ["TOP800"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Scope(Contract):
|
||||||
|
instrument_type: Literal["EQUITY"] = "EQUITY"
|
||||||
|
region: str
|
||||||
|
universe: str
|
||||||
|
delay: int = Field(ge=0, le=1)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def valid_scope(self):
|
||||||
|
if self.universe not in UNIVERSES.get(self.region, []):
|
||||||
|
raise ValueError("不支持的 Region / Universe 组合")
|
||||||
|
return self
|
||||||
|
|
||||||
|
def key(self):
|
||||||
|
return f"{self.instrument_type}|{self.region}|{self.universe}|{self.delay}"
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogFilters(Scope):
|
||||||
|
q: str = Field(default="", max_length=300)
|
||||||
|
category: str | None = None
|
||||||
|
subcategory: str | None = None
|
||||||
|
field_type: str | None = None
|
||||||
|
coverage_min: float | None = Field(default=None, ge=0, le=1)
|
||||||
|
sort: Literal[
|
||||||
|
"id", "name", "category", "field_count", "coverage", "user_count", "alpha_count", "field_type"
|
||||||
|
] = "name"
|
||||||
|
direction: Literal["asc", "desc"] = "asc"
|
||||||
|
limit: int = Field(default=25, ge=1, le=100)
|
||||||
|
offset: int = Field(default=0, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogJobInput(Contract):
|
||||||
|
scope: Scope
|
||||||
|
dataset_id: str | None = Field(default=None, min_length=1, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
class NoteInput(Contract):
|
||||||
|
note: str = Field(max_length=20000)
|
||||||
|
version: int = Field(ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class InputPreparation(Contract):
|
||||||
|
scope: Scope
|
||||||
|
dataset_id: str = Field(min_length=1, max_length=200)
|
||||||
|
collection_version: str
|
||||||
|
selection: Literal["all", "explicit"] = "all"
|
||||||
|
excluded_ids: list[str] = Field(default_factory=list, max_length=100000)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def valid_selection(self):
|
||||||
|
if self.selection == "all" and self.excluded_ids:
|
||||||
|
raise ValueError("全部字段不能同时提供排除项")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class NoteOutput(BaseModel):
|
||||||
|
note: str
|
||||||
|
version: int
|
||||||
|
updated_at: UTCTimestamp
|
||||||
|
|
||||||
|
|
||||||
|
class EntryOutput(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str | None
|
||||||
|
category: str | None
|
||||||
|
subcategory: str | None
|
||||||
|
field_type: str | None
|
||||||
|
coverage: float | None
|
||||||
|
user_count: int | None
|
||||||
|
alpha_count: int | None
|
||||||
|
field_count: int | None
|
||||||
|
description: str | None
|
||||||
|
unit: str | None
|
||||||
|
synced_at: UTCTimestamp
|
||||||
|
collection_version: str | None = None
|
||||||
|
complete_count: int | None = None
|
||||||
|
research: NoteOutput | None = None
|
||||||
|
scope: Scope | None = None
|
||||||
|
dataset_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogPage(BaseModel):
|
||||||
|
items: list[EntryOutput]
|
||||||
|
total: int
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
|
collection_version: str | None
|
||||||
|
complete_count: int | None
|
||||||
|
synced_at: UTCTimestamp | None
|
||||||
|
categories: dict[str, list[str]] = Field(default_factory=dict)
|
||||||
|
field_types: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class InputOutput(BaseModel):
|
||||||
|
id: str
|
||||||
|
status: Literal["draft"] = "draft"
|
||||||
|
scope: Scope
|
||||||
|
dataset_id: str
|
||||||
|
collection_version: str
|
||||||
|
selection: str
|
||||||
|
field_ids: list[str]
|
||||||
|
field_types: dict[str, str | None]
|
||||||
|
created_at: UTCTimestamp
|
||||||
|
|
||||||
|
|
||||||
|
class CollectionOutput(BaseModel):
|
||||||
|
collection_version: str | None
|
||||||
|
field_ids: list[str]
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Authenticated catalog endpoints; writes inherit the application origin guard."""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
|
|
||||||
|
from ..schemas import JobOutput
|
||||||
|
from ..security import require_auth
|
||||||
|
from .contracts import (
|
||||||
|
UNIVERSES,
|
||||||
|
CatalogFilters,
|
||||||
|
CatalogJobInput,
|
||||||
|
CatalogPage,
|
||||||
|
CollectionOutput,
|
||||||
|
EntryOutput,
|
||||||
|
InputOutput,
|
||||||
|
InputPreparation,
|
||||||
|
NoteInput,
|
||||||
|
NoteOutput,
|
||||||
|
Scope,
|
||||||
|
)
|
||||||
|
from .service import Catalog
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/catalog", tags=["catalog"], dependencies=[Depends(require_auth)])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/scopes")
|
||||||
|
async def scopes() -> dict[str, list[str]]:
|
||||||
|
return UNIVERSES
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/datasets", response_model=CatalogPage)
|
||||||
|
async def datasets(request: Request, filters: Annotated[CatalogFilters, Query()]):
|
||||||
|
async with request.app.state.sessions() as db:
|
||||||
|
return await Catalog(db).search(filters)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/datasets/{dataset_id}", response_model=EntryOutput)
|
||||||
|
async def detail(request: Request, dataset_id: str, scope: Annotated[Scope, Query()]):
|
||||||
|
async with request.app.state.sessions() as db:
|
||||||
|
return await Catalog(db).detail(scope, dataset_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/datasets/{dataset_id}/fields", response_model=CatalogPage)
|
||||||
|
async def fields(request: Request, dataset_id: str, filters: Annotated[CatalogFilters, Query()]):
|
||||||
|
async with request.app.state.sessions() as db:
|
||||||
|
return await Catalog(db).search(filters, dataset_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/datasets/{dataset_id}/fields/{field_id}", response_model=EntryOutput)
|
||||||
|
async def field(request: Request, dataset_id: str, field_id: str, scope: Annotated[Scope, Query()]):
|
||||||
|
async with request.app.state.sessions() as db:
|
||||||
|
return await Catalog(db).detail(scope, dataset_id, field_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/datasets/{dataset_id}/research", response_model=NoteOutput)
|
||||||
|
async def note(request: Request, dataset_id: str, scope: Annotated[Scope, Query()], body: NoteInput):
|
||||||
|
async with request.app.state.sessions.begin() as db:
|
||||||
|
return await Catalog(db).save_note(scope, dataset_id, "", body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/datasets/{dataset_id}/fields/{field_id}/research", response_model=NoteOutput)
|
||||||
|
async def field_note(
|
||||||
|
request: Request, dataset_id: str, field_id: str, scope: Annotated[Scope, Query()], body: NoteInput
|
||||||
|
):
|
||||||
|
async with request.app.state.sessions.begin() as db:
|
||||||
|
return await Catalog(db).save_note(scope, dataset_id, field_id, body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sync-jobs", status_code=202, response_model=JobOutput)
|
||||||
|
async def sync(request: Request, body: CatalogJobInput):
|
||||||
|
async with request.app.state.sessions.begin() as db:
|
||||||
|
result = await Catalog(db).create_job(body)
|
||||||
|
request.app.state.runner.wake.set()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/inputs", status_code=201, response_model=InputOutput)
|
||||||
|
async def prepare(request: Request, body: InputPreparation):
|
||||||
|
async with request.app.state.sessions.begin() as db:
|
||||||
|
return await Catalog(db).prepare(body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/inputs", response_model=list[InputOutput])
|
||||||
|
async def inputs(request: Request, scope: Annotated[Scope, Query()]):
|
||||||
|
async with request.app.state.sessions() as db:
|
||||||
|
return await Catalog(db).inputs(scope)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/inputs/{input_id}", response_model=InputOutput)
|
||||||
|
async def get_input(request: Request, input_id: str):
|
||||||
|
async with request.app.state.sessions() as db:
|
||||||
|
return await Catalog(db).input(input_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/datasets/{dataset_id}/collection", response_model=CollectionOutput)
|
||||||
|
async def collection(request: Request, dataset_id: str, scope: Annotated[Scope, Query()]):
|
||||||
|
async with request.app.state.sessions() as db:
|
||||||
|
return await Catalog(db).collection(scope, dataset_id)
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
"""Catalog business operations. Callers own authorization and transaction commits.
|
||||||
|
|
||||||
|
The dataset row serializes collection publication and draft creation on PostgreSQL.
|
||||||
|
No page filters participate in template input selection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import timezone
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import func, or_, select, update
|
||||||
|
|
||||||
|
from ..models import (
|
||||||
|
Account,
|
||||||
|
CatalogBatch,
|
||||||
|
CatalogDataset,
|
||||||
|
CatalogEntry,
|
||||||
|
CatalogNote,
|
||||||
|
CatalogScope,
|
||||||
|
Job,
|
||||||
|
TemplateInput,
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
from ..schemas import JobOutput
|
||||||
|
from .contracts import EntryOutput, Scope
|
||||||
|
|
||||||
|
|
||||||
|
class Catalog:
|
||||||
|
def __init__(self, db):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def dataset(self, scope, dataset_id, lock=False):
|
||||||
|
query = select(CatalogDataset).where(
|
||||||
|
CatalogDataset.scope_key == scope.key(), CatalogDataset.id == dataset_id
|
||||||
|
)
|
||||||
|
row = await self.db.scalar(query.with_for_update() if lock else query)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "该范围的数据集尚未同步")
|
||||||
|
return row
|
||||||
|
|
||||||
|
async def search(self, filters, dataset_id=None):
|
||||||
|
scope = await self.db.get(CatalogScope, filters.key())
|
||||||
|
version = scope.catalog_version if scope else None
|
||||||
|
if dataset_id:
|
||||||
|
version = (await self.dataset(filters, dataset_id)).field_version
|
||||||
|
batch = await self.db.get(CatalogBatch, version) if version else None
|
||||||
|
base = (
|
||||||
|
select(CatalogEntry).where(CatalogEntry.batch_id == version)
|
||||||
|
if version
|
||||||
|
else select(CatalogEntry).where(False)
|
||||||
|
)
|
||||||
|
query = base
|
||||||
|
if filters.q:
|
||||||
|
pattern = "%" + filters.q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
|
||||||
|
query = query.where(
|
||||||
|
or_(
|
||||||
|
CatalogEntry.id.ilike(pattern, escape="\\"), CatalogEntry.name.ilike(pattern, escape="\\")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for key in ("category", "subcategory", "field_type"):
|
||||||
|
value = getattr(filters, key)
|
||||||
|
if value is not None:
|
||||||
|
query = query.where(getattr(CatalogEntry, key) == value)
|
||||||
|
if filters.coverage_min is not None:
|
||||||
|
query = query.where(CatalogEntry.coverage >= filters.coverage_min)
|
||||||
|
total = await self.db.scalar(select(func.count()).select_from(query.subquery()))
|
||||||
|
column = getattr(CatalogEntry, filters.sort)
|
||||||
|
if dataset_id is None and filters.sort == "field_count":
|
||||||
|
published_count = (
|
||||||
|
select(CatalogBatch.count)
|
||||||
|
.join(CatalogDataset, CatalogDataset.field_version == CatalogBatch.id)
|
||||||
|
.where(CatalogDataset.scope_key == filters.key(), CatalogDataset.id == CatalogEntry.id)
|
||||||
|
.correlate(CatalogEntry)
|
||||||
|
.scalar_subquery()
|
||||||
|
)
|
||||||
|
column = func.coalesce(published_count, CatalogEntry.field_count)
|
||||||
|
query = query.order_by(
|
||||||
|
(column.desc() if filters.direction == "desc" else column.asc()).nulls_last(), CatalogEntry.id
|
||||||
|
)
|
||||||
|
entries = (await self.db.scalars(query.limit(filters.limit).offset(filters.offset))).all()
|
||||||
|
items = [EntryOutput.model_validate(e, from_attributes=True).model_dump() for e in entries]
|
||||||
|
if not dataset_id and items:
|
||||||
|
datasets = (
|
||||||
|
await self.db.scalars(
|
||||||
|
select(CatalogDataset).where(
|
||||||
|
CatalogDataset.scope_key == filters.key(),
|
||||||
|
CatalogDataset.id.in_([i["id"] for i in items]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
versions = {d.id: d.field_version for d in datasets}
|
||||||
|
batches = (
|
||||||
|
await self.db.scalars(
|
||||||
|
select(CatalogBatch).where(CatalogBatch.id.in_([v for v in versions.values() if v]))
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
counts = {b.id: b.count for b in batches}
|
||||||
|
for item in items:
|
||||||
|
item["collection_version"] = versions.get(item["id"])
|
||||||
|
item["complete_count"] = counts.get(versions.get(item["id"]))
|
||||||
|
categories = {}
|
||||||
|
for category, subcategory in (
|
||||||
|
await self.db.execute(
|
||||||
|
base.with_only_columns(CatalogEntry.category, CatalogEntry.subcategory).distinct()
|
||||||
|
)
|
||||||
|
).all():
|
||||||
|
if category:
|
||||||
|
categories.setdefault(category, [])
|
||||||
|
if subcategory and subcategory not in categories[category]:
|
||||||
|
categories[category].append(subcategory)
|
||||||
|
types = (
|
||||||
|
await self.db.scalars(
|
||||||
|
base.with_only_columns(CatalogEntry.field_type)
|
||||||
|
.where(CatalogEntry.field_type.is_not(None))
|
||||||
|
.distinct()
|
||||||
|
.order_by(CatalogEntry.field_type)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return dict(
|
||||||
|
items=items,
|
||||||
|
total=total,
|
||||||
|
limit=filters.limit,
|
||||||
|
offset=filters.offset,
|
||||||
|
collection_version=version,
|
||||||
|
complete_count=batch.count if batch else None,
|
||||||
|
synced_at=batch.completed_at if batch else None,
|
||||||
|
categories=categories,
|
||||||
|
field_types=types,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def detail(self, scope, dataset_id, field_id=""):
|
||||||
|
dataset = await self.dataset(scope, dataset_id)
|
||||||
|
scope_row = await self.db.get(CatalogScope, scope.key())
|
||||||
|
version = dataset.field_version if field_id else scope_row.catalog_version
|
||||||
|
entry = await self.db.get(CatalogEntry, (version, field_id or dataset_id)) if version else None
|
||||||
|
if not entry:
|
||||||
|
raise HTTPException(404, "该范围的对象尚未完整同步")
|
||||||
|
note = await self.db.get(CatalogNote, (scope.key(), dataset_id, field_id))
|
||||||
|
batch = await self.db.get(CatalogBatch, dataset.field_version) if dataset.field_version else None
|
||||||
|
return dict(
|
||||||
|
**EntryOutput.model_validate(entry, from_attributes=True).model_dump(
|
||||||
|
exclude={"research", "scope", "dataset_id", "collection_version", "complete_count"}
|
||||||
|
),
|
||||||
|
research=dict(note=note.note, version=note.version, updated_at=note.updated_at),
|
||||||
|
scope=Scope.model_validate(scope.model_dump(include=set(Scope.model_fields))),
|
||||||
|
dataset_id=dataset_id,
|
||||||
|
collection_version=dataset.field_version,
|
||||||
|
complete_count=batch.count if batch else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def save_note(self, scope, dataset_id, field_id, body):
|
||||||
|
await self.detail(scope, dataset_id, field_id)
|
||||||
|
result = await self.db.execute(
|
||||||
|
update(CatalogNote)
|
||||||
|
.where(
|
||||||
|
CatalogNote.scope_key == scope.key(),
|
||||||
|
CatalogNote.dataset_id == dataset_id,
|
||||||
|
CatalogNote.field_id == field_id,
|
||||||
|
CatalogNote.version == body.version,
|
||||||
|
)
|
||||||
|
.values(note=body.note, version=CatalogNote.version + 1, updated_at=now())
|
||||||
|
)
|
||||||
|
if result.rowcount != 1:
|
||||||
|
raise HTTPException(409, "研究备注已被修改;当前草稿已保留,请载入最新记录后重新保存")
|
||||||
|
return dict(note=body.note, version=body.version + 1, updated_at=now())
|
||||||
|
|
||||||
|
async def create_job(self, body):
|
||||||
|
account = await self.db.scalar(select(Account).where(Account.id == 1).with_for_update())
|
||||||
|
if not account.password_encrypted or account.connection_status in ("disconnected", "error"):
|
||||||
|
raise HTTPException(409, "请先连接 WorldQuant")
|
||||||
|
if body.dataset_id:
|
||||||
|
await self.dataset(body.scope, body.dataset_id)
|
||||||
|
kind = "field_sync" if body.dataset_id else "catalog_sync"
|
||||||
|
payload = body.model_dump(mode="json")
|
||||||
|
jobs = (
|
||||||
|
await self.db.scalars(
|
||||||
|
select(Job).where(
|
||||||
|
Job.kind == kind,
|
||||||
|
Job.status.in_(("queued", "running", "waiting_auth", "waiting_connection")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for job in jobs:
|
||||||
|
if job.payload == payload:
|
||||||
|
return JobOutput.model_validate(job)
|
||||||
|
scope = await self.db.get(CatalogScope, body.scope.key())
|
||||||
|
if not scope:
|
||||||
|
self.db.add(CatalogScope(key=body.scope.key(), scope=body.scope.model_dump()))
|
||||||
|
await self.db.flush()
|
||||||
|
job = Job(id=str(uuid4()), kind=kind, payload=payload)
|
||||||
|
self.db.add(job)
|
||||||
|
await self.db.flush()
|
||||||
|
self.db.add(CatalogBatch(id=job.id, scope_key=body.scope.key(), dataset_id=body.dataset_id))
|
||||||
|
await self.db.flush()
|
||||||
|
return JobOutput.model_validate(job)
|
||||||
|
|
||||||
|
async def collection(self, scope, dataset_id):
|
||||||
|
"""Return membership only for the published collection, independent of table filters."""
|
||||||
|
dataset = await self.dataset(scope, dataset_id)
|
||||||
|
ids = []
|
||||||
|
if dataset.field_version:
|
||||||
|
ids = list(
|
||||||
|
(
|
||||||
|
await self.db.scalars(
|
||||||
|
select(CatalogEntry.id)
|
||||||
|
.where(CatalogEntry.batch_id == dataset.field_version)
|
||||||
|
.order_by(CatalogEntry.id)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
return dict(collection_version=dataset.field_version, field_ids=ids)
|
||||||
|
|
||||||
|
async def prepare(self, body):
|
||||||
|
dataset = await self.dataset(body.scope, body.dataset_id, lock=True)
|
||||||
|
if not dataset.field_version or dataset.field_version != body.collection_version:
|
||||||
|
raise HTTPException(409, "字段集合未完成或版本已变化,请重新读取后准备输入")
|
||||||
|
batch = await self.db.get(CatalogBatch, dataset.field_version)
|
||||||
|
if not batch.complete or batch.scope_key != body.scope.key() or batch.dataset_id != body.dataset_id:
|
||||||
|
raise HTTPException(409, "字段集合不完整")
|
||||||
|
entries = (
|
||||||
|
await self.db.scalars(
|
||||||
|
select(CatalogEntry).where(CatalogEntry.batch_id == batch.id).order_by(CatalogEntry.id)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
fields = {e.id: e.field_type for e in entries}
|
||||||
|
excluded = set(body.excluded_ids)
|
||||||
|
if excluded - fields.keys():
|
||||||
|
raise HTTPException(422, "排除项含未知、跨范围或其他数据集字段")
|
||||||
|
chosen = {key: value for key, value in fields.items() if key not in excluded}
|
||||||
|
if not chosen:
|
||||||
|
raise HTTPException(422, "模板输入至少需要一个字段")
|
||||||
|
row = TemplateInput(
|
||||||
|
id=str(uuid4()),
|
||||||
|
scope_key=body.scope.key(),
|
||||||
|
dataset_id=body.dataset_id,
|
||||||
|
collection_version=batch.id,
|
||||||
|
selection=body.selection,
|
||||||
|
field_ids=list(chosen),
|
||||||
|
field_types=chosen,
|
||||||
|
)
|
||||||
|
self.db.add(row)
|
||||||
|
await self.db.flush()
|
||||||
|
return await self.input(row.id)
|
||||||
|
|
||||||
|
async def input(self, input_id):
|
||||||
|
row = await self.db.get(TemplateInput, input_id)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "输入草稿不存在")
|
||||||
|
scope = await self.db.get(CatalogScope, row.scope_key)
|
||||||
|
return dict(
|
||||||
|
id=row.id,
|
||||||
|
status="draft",
|
||||||
|
scope=scope.scope,
|
||||||
|
dataset_id=row.dataset_id,
|
||||||
|
collection_version=row.collection_version,
|
||||||
|
selection=row.selection,
|
||||||
|
field_ids=row.field_ids,
|
||||||
|
field_types=row.field_types,
|
||||||
|
created_at=row.created_at.replace(tzinfo=timezone.utc)
|
||||||
|
if row.created_at.tzinfo is None
|
||||||
|
else row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def inputs(self, scope):
|
||||||
|
ids = (
|
||||||
|
await self.db.scalars(
|
||||||
|
select(TemplateInput.id)
|
||||||
|
.where(TemplateInput.scope_key == scope.key())
|
||||||
|
.order_by(TemplateInput.created_at.desc())
|
||||||
|
.limit(100)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return [await self.input(i) for i in ids]
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""Publish complete enumerations only; retain staging checkpoints and old versions."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from ..models import CatalogBatch, CatalogDataset, CatalogEntry, CatalogNote, CatalogScope, Job, now
|
||||||
|
from ..worldquant import WqError
|
||||||
|
from .contracts import Scope
|
||||||
|
|
||||||
|
|
||||||
|
def identifier(value):
|
||||||
|
if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9_.-]{1,200}", value):
|
||||||
|
raise WqError("平台目录包含无法识别的 ID,已保留进度", "invalid_response")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def label(value):
|
||||||
|
if isinstance(value, dict):
|
||||||
|
value = value.get("name") or value.get("id")
|
||||||
|
return value if isinstance(value, str) and value else None
|
||||||
|
|
||||||
|
|
||||||
|
def number(value, integer=False):
|
||||||
|
if (
|
||||||
|
isinstance(value, bool)
|
||||||
|
or not isinstance(value, (int, float))
|
||||||
|
or not math.isfinite(value)
|
||||||
|
or value < 0
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return int(value) if integer and value == int(value) else None if integer else value
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(raw, dataset_id):
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise WqError("平台目录记录格式无法识别", "invalid_response")
|
||||||
|
item_id = identifier(raw.get("id"))
|
||||||
|
owner = raw.get("dataset")
|
||||||
|
owner = owner.get("id") if isinstance(owner, dict) else owner
|
||||||
|
if dataset_id and owner != dataset_id:
|
||||||
|
raise WqError("平台返回了其他数据集的字段", "invalid_response")
|
||||||
|
coverage = number(raw.get("coverage"))
|
||||||
|
# BRAIN coverage is a fraction. Never guess that a value >1 means percent.
|
||||||
|
# Real-account schema/units still require read-only integration verification.
|
||||||
|
if coverage is not None and coverage > 1:
|
||||||
|
raise WqError("平台覆盖率单位无法确认,应为 0–1", "invalid_response")
|
||||||
|
return dict(
|
||||||
|
id=item_id,
|
||||||
|
name=label(raw.get("name")) or item_id,
|
||||||
|
category=label(raw.get("category")),
|
||||||
|
subcategory=label(raw.get("subcategory")),
|
||||||
|
field_type=label(raw.get("type")) if dataset_id else None,
|
||||||
|
coverage=coverage,
|
||||||
|
user_count=number(raw.get("userCount"), True),
|
||||||
|
alpha_count=number(raw.get("alphaCount"), True),
|
||||||
|
field_count=number(raw.get("fieldCount"), True),
|
||||||
|
description=label(raw.get("description")),
|
||||||
|
unit=label(raw.get("unit")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_catalog(runner, job_id, payload):
|
||||||
|
scope = Scope.model_validate(payload["scope"])
|
||||||
|
dataset_id = payload.get("dataset_id")
|
||||||
|
async with runner.sessions() as db:
|
||||||
|
checkpoint = (await db.get(Job, job_id)).checkpoint
|
||||||
|
if checkpoint.get("done"):
|
||||||
|
return
|
||||||
|
offset = checkpoint.get("offset", 0)
|
||||||
|
while True:
|
||||||
|
await runner.checkpoint(job_id, {"next_retry_at": None})
|
||||||
|
raw = await runner.client.catalog_page(scope.model_dump(), dataset_id, offset)
|
||||||
|
rows = raw.get("results")
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
raise WqError("平台目录缺少 results,已保留进度", "invalid_response")
|
||||||
|
entries = [normalize(r, dataset_id) for r in rows]
|
||||||
|
# Always probe to exhaustion if next is absent; count alone cannot prove completeness.
|
||||||
|
next_page = raw.get("next")
|
||||||
|
if "next" in raw and next_page is not None:
|
||||||
|
if not isinstance(next_page, str) or not next_page:
|
||||||
|
raise WqError("平台 next 分页格式无法识别", "invalid_response")
|
||||||
|
parsed = urlparse(next_page)
|
||||||
|
expected_path = "/data-fields" if dataset_id else "/data-sets"
|
||||||
|
offsets = parse_qs(parsed.query).get("offset", [])
|
||||||
|
if parsed.path.rstrip("/") != expected_path or offsets != [str(offset + len(rows))]:
|
||||||
|
raise WqError("平台 next 分页未按预期前进", "invalid_response")
|
||||||
|
more = next_page is not None if "next" in raw else bool(rows)
|
||||||
|
count = number(raw.get("count"), True)
|
||||||
|
if (more and not rows) or (not more and count is not None and offset + len(rows) < count):
|
||||||
|
raise WqError("平台分页提前结束,未发布不完整集合", "invalid_response")
|
||||||
|
async with runner.sessions() as db:
|
||||||
|
job = await db.get(Job, job_id)
|
||||||
|
if job.cancel_requested:
|
||||||
|
raise asyncio.CancelledError()
|
||||||
|
batch = await db.get(CatalogBatch, job_id)
|
||||||
|
added = 0
|
||||||
|
for entry in entries:
|
||||||
|
if await db.get(CatalogEntry, (job_id, entry["id"])):
|
||||||
|
continue
|
||||||
|
db.add(CatalogEntry(batch_id=job_id, **entry))
|
||||||
|
await db.flush()
|
||||||
|
added += 1
|
||||||
|
owner = dataset_id or entry["id"]
|
||||||
|
field_id = entry["id"] if dataset_id else ""
|
||||||
|
if not await db.get(CatalogNote, (scope.key(), owner, field_id)):
|
||||||
|
db.add(CatalogNote(scope_key=scope.key(), dataset_id=owner, field_id=field_id))
|
||||||
|
if rows and not added:
|
||||||
|
raise WqError("平台分页重复且未前进,已保留进度", "invalid_response")
|
||||||
|
batch.count += added
|
||||||
|
job.processed = batch.count
|
||||||
|
offset += len(rows)
|
||||||
|
job.checkpoint = dict(offset=offset, done=not more)
|
||||||
|
job.updated_at = now()
|
||||||
|
if not more:
|
||||||
|
batch.complete, batch.completed_at = True, now()
|
||||||
|
job.total = batch.count
|
||||||
|
if dataset_id:
|
||||||
|
dataset = await db.scalar(
|
||||||
|
select(CatalogDataset)
|
||||||
|
.where(CatalogDataset.scope_key == scope.key(), CatalogDataset.id == dataset_id)
|
||||||
|
.with_for_update()
|
||||||
|
)
|
||||||
|
dataset.field_version = job_id
|
||||||
|
else:
|
||||||
|
scope_row = await db.get(CatalogScope, scope.key())
|
||||||
|
scope_row.catalog_version, scope_row.synced_at = job_id, now()
|
||||||
|
ids = (
|
||||||
|
await db.scalars(select(CatalogEntry.id).where(CatalogEntry.batch_id == job_id))
|
||||||
|
).all()
|
||||||
|
for item_id in ids:
|
||||||
|
if not await db.get(CatalogDataset, (scope.key(), item_id)):
|
||||||
|
db.add(CatalogDataset(scope_key=scope.key(), id=item_id))
|
||||||
|
await db.commit()
|
||||||
|
if not more:
|
||||||
|
return
|
||||||
@@ -245,6 +245,10 @@ class Runner:
|
|||||||
await self.ensure_connected(force=kind == "connect")
|
await self.ensure_connected(force=kind == "connect")
|
||||||
if kind in ("connect", "profile"):
|
if kind in ("connect", "profile"):
|
||||||
await self.refresh_profile()
|
await self.refresh_profile()
|
||||||
|
elif kind in ("catalog_sync", "field_sync"):
|
||||||
|
from .catalog.sync import sync_catalog
|
||||||
|
|
||||||
|
await sync_catalog(self, job_id, payload)
|
||||||
elif kind == "full_sync":
|
elif kind == "full_sync":
|
||||||
await self.sync_all(job_id)
|
await self.sync_all(job_id)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from .ai.routes import router as ai_router
|
|||||||
from .ai.runtime import AIRuntime
|
from .ai.runtime import AIRuntime
|
||||||
from .alphas import list_statement, sorted_statement
|
from .alphas import list_statement, sorted_statement
|
||||||
from .business import Business, notify_job
|
from .business import Business, notify_job
|
||||||
|
from .catalog.routes import router as catalog_router
|
||||||
from .config import Settings
|
from .config import Settings
|
||||||
from .db import create_database
|
from .db import create_database
|
||||||
from .jobs import AUTH_KINDS, Runner, create_job
|
from .jobs import AUTH_KINDS, Runner, create_job
|
||||||
@@ -381,5 +382,6 @@ def create_app(settings=None, wq_client=None, ai_model_factory=None):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
app.include_router(api)
|
app.include_router(api)
|
||||||
|
app.include_router(catalog_router)
|
||||||
app.include_router(ai_router(ai_runtime))
|
app.include_router(ai_router(ai_runtime))
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -199,3 +199,69 @@ class AIToolCall(Base):
|
|||||||
status: Mapped[str] = mapped_column(String(30), default="pending")
|
status: Mapped[str] = mapped_column(String(30), default="pending")
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||||
__table_args__ = (UniqueConstraint("run_id", "call_id"),)
|
__table_args__ = (UniqueConstraint("run_id", "call_id"),)
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogScope(Base):
|
||||||
|
__tablename__ = "catalog_scopes"
|
||||||
|
key: Mapped[str] = mapped_column(String(200), primary_key=True)
|
||||||
|
scope: Mapped[dict] = mapped_column(JSON)
|
||||||
|
catalog_version: Mapped[str | None] = mapped_column(String(36))
|
||||||
|
synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogBatch(Base):
|
||||||
|
__tablename__ = "catalog_batches"
|
||||||
|
id: Mapped[str] = mapped_column(ForeignKey("sync_jobs.id"), primary_key=True)
|
||||||
|
scope_key: Mapped[str] = mapped_column(ForeignKey("catalog_scopes.key"), index=True)
|
||||||
|
dataset_id: Mapped[str | None] = mapped_column(String(200))
|
||||||
|
complete: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogDataset(Base):
|
||||||
|
__tablename__ = "catalog_datasets"
|
||||||
|
scope_key: Mapped[str] = mapped_column(ForeignKey("catalog_scopes.key"), primary_key=True)
|
||||||
|
id: Mapped[str] = mapped_column(String(200), primary_key=True)
|
||||||
|
field_version: Mapped[str | None] = mapped_column(ForeignKey("catalog_batches.id"))
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogEntry(Base):
|
||||||
|
"""Immutable published snapshots; staging rows remain invisible until batch completion."""
|
||||||
|
__tablename__ = "catalog_entries"
|
||||||
|
batch_id: Mapped[str] = mapped_column(ForeignKey("catalog_batches.id"), primary_key=True)
|
||||||
|
id: Mapped[str] = mapped_column(String(200), primary_key=True)
|
||||||
|
name: Mapped[str | None] = mapped_column(Text)
|
||||||
|
category: Mapped[str | None] = mapped_column(String(200))
|
||||||
|
subcategory: Mapped[str | None] = mapped_column(String(200))
|
||||||
|
field_type: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
coverage: Mapped[float | None] = mapped_column(Float)
|
||||||
|
user_count: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
alpha_count: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
field_count: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
unit: Mapped[str | None] = mapped_column(Text)
|
||||||
|
synced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogNote(Base):
|
||||||
|
__tablename__ = "catalog_notes"
|
||||||
|
scope_key: Mapped[str] = mapped_column(ForeignKey("catalog_scopes.key"), primary_key=True)
|
||||||
|
dataset_id: Mapped[str] = mapped_column(String(200), primary_key=True)
|
||||||
|
# Empty field_id denotes the dataset; platform identifiers cannot be empty.
|
||||||
|
field_id: Mapped[str] = mapped_column(String(200), primary_key=True, default="")
|
||||||
|
note: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
version: Mapped[int] = mapped_column(Integer, default=1)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateInput(Base):
|
||||||
|
__tablename__ = "template_inputs"
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||||
|
scope_key: Mapped[str] = mapped_column(ForeignKey("catalog_scopes.key"), index=True)
|
||||||
|
dataset_id: Mapped[str] = mapped_column(String(200))
|
||||||
|
collection_version: Mapped[str] = mapped_column(ForeignKey("catalog_batches.id"))
|
||||||
|
selection: Mapped[str] = mapped_column(String(20))
|
||||||
|
field_ids: Mapped[list] = mapped_column(JSON)
|
||||||
|
field_types: Mapped[dict] = mapped_column(JSON)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ class JobOutput(BaseModel):
|
|||||||
id: str
|
id: str
|
||||||
kind: str
|
kind: str
|
||||||
status: str
|
status: str
|
||||||
|
payload: dict = Field(default_factory=dict)
|
||||||
processed: int
|
processed: int
|
||||||
failed: int
|
failed: int
|
||||||
total: int | None
|
total: int | None
|
||||||
|
|||||||
@@ -277,3 +277,12 @@ class WqClient:
|
|||||||
|
|
||||||
async def pnl(self, alpha_id):
|
async def pnl(self, alpha_id):
|
||||||
return await self.get(f"/alphas/{alpha_id}/recordsets/pnl")
|
return await self.get(f"/alphas/{alpha_id}/recordsets/pnl")
|
||||||
|
|
||||||
|
async def catalog_page(self, scope, dataset_id, offset):
|
||||||
|
"""Read a single scoped page. IDs are query parameters, never upstream paths."""
|
||||||
|
params = {"instrumentType": scope["instrument_type"], "region": scope["region"],
|
||||||
|
"universe": scope["universe"], "delay": scope["delay"],
|
||||||
|
"limit": 50, "offset": offset}
|
||||||
|
if dataset_id is not None:
|
||||||
|
params["dataset.id"] = dataset_id
|
||||||
|
return await self.get("/data-fields" if dataset_id else "/data-sets", params)
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""scope catalog collections notes and input drafts"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = '0003'
|
||||||
|
down_revision = '0002'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('catalog_scopes',
|
||||||
|
sa.Column('key', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('scope', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('catalog_version', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('synced_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('key')
|
||||||
|
)
|
||||||
|
op.create_table('catalog_batches',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('scope_key', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('dataset_id', sa.String(length=200), nullable=True),
|
||||||
|
sa.Column('complete', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('count', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['id'], ['sync_jobs.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['scope_key'], ['catalog_scopes.key'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_catalog_batches_scope_key'), 'catalog_batches', ['scope_key'], unique=False)
|
||||||
|
op.create_table('catalog_notes',
|
||||||
|
sa.Column('scope_key', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('dataset_id', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('field_id', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('note', sa.Text(), nullable=False),
|
||||||
|
sa.Column('version', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['scope_key'], ['catalog_scopes.key'], ),
|
||||||
|
sa.PrimaryKeyConstraint('scope_key', 'dataset_id', 'field_id')
|
||||||
|
)
|
||||||
|
op.create_table('catalog_datasets',
|
||||||
|
sa.Column('scope_key', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('id', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('field_version', sa.String(length=36), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['field_version'], ['catalog_batches.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['scope_key'], ['catalog_scopes.key'], ),
|
||||||
|
sa.PrimaryKeyConstraint('scope_key', 'id')
|
||||||
|
)
|
||||||
|
op.create_table('catalog_entries',
|
||||||
|
sa.Column('batch_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('id', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('name', sa.Text(), nullable=True),
|
||||||
|
sa.Column('category', sa.String(length=200), nullable=True),
|
||||||
|
sa.Column('subcategory', sa.String(length=200), nullable=True),
|
||||||
|
sa.Column('field_type', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('coverage', sa.Float(), nullable=True),
|
||||||
|
sa.Column('user_count', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('alpha_count', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('field_count', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('unit', sa.Text(), nullable=True),
|
||||||
|
sa.Column('synced_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['batch_id'], ['catalog_batches.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('batch_id', 'id')
|
||||||
|
)
|
||||||
|
op.create_table('template_inputs',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('scope_key', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('dataset_id', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('collection_version', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('selection', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('field_ids', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('field_types', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['collection_version'], ['catalog_batches.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['scope_key'], ['catalog_scopes.key'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_template_inputs_scope_key'), 'template_inputs', ['scope_key'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_template_inputs_scope_key'), table_name='template_inputs')
|
||||||
|
op.drop_table('template_inputs')
|
||||||
|
op.drop_table('catalog_entries')
|
||||||
|
op.drop_table('catalog_datasets')
|
||||||
|
op.drop_table('catalog_notes')
|
||||||
|
op.drop_index(op.f('ix_catalog_batches_scope_key'), table_name='catalog_batches')
|
||||||
|
op.drop_table('catalog_batches')
|
||||||
|
op.drop_table('catalog_scopes')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -12,6 +12,7 @@ from app.main import create_app
|
|||||||
from app.models import Base
|
from app.models import Base
|
||||||
from app.worldquant import WqClient
|
from app.worldquant import WqClient
|
||||||
from tests.ai_fake import fake_model
|
from tests.ai_fake import fake_model
|
||||||
|
from tests.catalog_fake import catalog_response
|
||||||
|
|
||||||
TEST_PASSWORD = "browser-test-password"
|
TEST_PASSWORD = "browser-test-password"
|
||||||
|
|
||||||
@@ -104,6 +105,9 @@ def create_test_app():
|
|||||||
)
|
)
|
||||||
if request.method != "GET":
|
if request.method != "GET":
|
||||||
raise AssertionError("Browser acceptance attempted an upstream mutation")
|
raise AssertionError("Browser acceptance attempted an upstream mutation")
|
||||||
|
catalog = catalog_response(request)
|
||||||
|
if catalog is not None:
|
||||||
|
return catalog
|
||||||
if path == "/users/self":
|
if path == "/users/self":
|
||||||
return httpx.Response(
|
return httpx.Response(
|
||||||
200,
|
200,
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Synthetic HTTP catalog, including page overlap and unknown metrics."""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
def field_records(dataset="TEST_FIN", count=123):
|
||||||
|
return [
|
||||||
|
dict(
|
||||||
|
id=f"{dataset}_{i:03}",
|
||||||
|
name=f"TEST 字段 {i:03}",
|
||||||
|
dataset={"id": dataset},
|
||||||
|
type="FUTURE_TYPE" if i == 122 else "VECTOR" if i % 3 == 0 else "MATRIX",
|
||||||
|
coverage=None if i == 122 else 0.95 if i % 2 else 0.6,
|
||||||
|
userCount=None if i == 122 else i,
|
||||||
|
alphaCount=i * 2,
|
||||||
|
description=None if i == 122 else f"合成字段说明 {i}",
|
||||||
|
)
|
||||||
|
for i in range(count)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_response(request, fields=None):
|
||||||
|
path, params = request.url.path, request.url.params
|
||||||
|
if path not in ("/data-sets", "/data-fields"):
|
||||||
|
return None
|
||||||
|
assert request.method == "GET"
|
||||||
|
assert params["instrumentType"] == "EQUITY"
|
||||||
|
assert params["region"] and params["universe"] and params["delay"] in ("0", "1")
|
||||||
|
dataset = params.get("dataset.id", "TEST_FIN")
|
||||||
|
rows = (
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "TEST_FIN",
|
||||||
|
"name": "TEST 财务报表",
|
||||||
|
"category": {"name": "基本面"},
|
||||||
|
"subcategory": {"name": "财务报表"},
|
||||||
|
"fieldCount": 123,
|
||||||
|
"description": "合成数据,仅用于验收",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "TEST_NEWS",
|
||||||
|
"name": "TEST 新闻",
|
||||||
|
"category": {"name": "新闻"},
|
||||||
|
"subcategory": {"name": "情绪"},
|
||||||
|
"fieldCount": 3,
|
||||||
|
},
|
||||||
|
{"id": "TEST_UNKNOWN", "name": "TEST 未分类", "fieldCount": 0},
|
||||||
|
]
|
||||||
|
if path == "/data-sets"
|
||||||
|
else (fields if fields is not None else field_records(dataset, 123 if dataset == "TEST_FIN" else 3))
|
||||||
|
)
|
||||||
|
if path == "/data-fields" and len(rows) > 50:
|
||||||
|
rows = rows[:50] + [rows[49]] + rows[50:]
|
||||||
|
offset, limit = int(params.get("offset", 0)), int(params.get("limit", 50))
|
||||||
|
return httpx.Response(200, json={"results": rows[offset : offset + limit]})
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""One-off acceptance against the dedicated local PostgreSQL catalog_test database."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
|
database_name = os.environ.get("WQ_CATALOG_ACCEPTANCE_DATABASE", "catalog_flow_test")
|
||||||
|
if not re.fullmatch(r"catalog_[a-z0-9_]{1,40}", database_name):
|
||||||
|
raise ValueError("Acceptance requires a dedicated catalog_* database")
|
||||||
|
URL = f"postgresql+asyncpg://postgres:catalog-test-only@127.0.0.1:18436/{database_name}"
|
||||||
|
os.environ.update(
|
||||||
|
DATABASE_URL=URL, ADMIN_PASSWORD="migration-test-only", ENCRYPTION_KEY=Fernet.generate_key().decode()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def sql(statement):
|
||||||
|
engine = create_async_engine(URL)
|
||||||
|
async with engine.begin() as connection:
|
||||||
|
result = await connection.execute(text(statement))
|
||||||
|
value = result.fetchall() if result.returns_rows else None
|
||||||
|
await engine.dispose()
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
config = Config("alembic.ini")
|
||||||
|
if asyncio.run(sql("SELECT tablename FROM pg_tables WHERE schemaname='public'")):
|
||||||
|
raise RuntimeError("Acceptance database must be empty; existing data will not be overwritten")
|
||||||
|
command.upgrade(config, "0002")
|
||||||
|
asyncio.run(
|
||||||
|
sql(
|
||||||
|
"INSERT INTO alphas (id, hidden, settings, is_metrics, os_metrics, checks, synced_at, raw) VALUES ('MIGRATION_TEST', false, '{}', '{}', '{}', '[]', now(), '{}');"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
asyncio.run(
|
||||||
|
sql(
|
||||||
|
"INSERT INTO research (alpha_id, note, tags, favorite, state, updated_at, version) VALUES ('MIGRATION_TEST', 'preserve research', '[]', false, 'inbox', now(), 7);"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
command.upgrade(config, "head")
|
||||||
|
command.check(config)
|
||||||
|
assert asyncio.run(sql("SELECT note, version FROM research WHERE alpha_id='MIGRATION_TEST'")) == [
|
||||||
|
("preserve research", 7)
|
||||||
|
]
|
||||||
|
assert asyncio.run(sql("SELECT count(*) FROM catalog_batches")) == [(0,)]
|
||||||
|
command.downgrade(config, "0002")
|
||||||
|
command.upgrade(config, "head")
|
||||||
|
command.check(config)
|
||||||
|
assert asyncio.run(sql("SELECT note, version FROM research WHERE alpha_id='MIGRATION_TEST'")) == [
|
||||||
|
("preserve research", 7)
|
||||||
|
]
|
||||||
|
print(
|
||||||
|
"PostgreSQL 17: 0002 → 0003, downgrade/re-upgrade, metadata check, Alpha/research preservation passed"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def flow():
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
from app.main import create_app
|
||||||
|
from app.worldquant import WqClient
|
||||||
|
from tests.catalog_fake import catalog_response
|
||||||
|
from tests.test_catalog import SCOPE, prepare, search, sync
|
||||||
|
|
||||||
|
def upstream(request):
|
||||||
|
if request.url.path == "/authentication":
|
||||||
|
return httpx.Response(201, json={"token": {"expiry": 14400}})
|
||||||
|
if request.url.path == "/users/self":
|
||||||
|
return httpx.Response(200, json={"id": "PG_TEST_USER"})
|
||||||
|
assert request.method == "GET"
|
||||||
|
return catalog_response(request) or httpx.Response(404)
|
||||||
|
|
||||||
|
settings = Settings(_env_file=None, enable_runner=False, public_origin="http://testserver")
|
||||||
|
app = create_app(settings, WqClient(settings, transport=httpx.MockTransport(upstream)))
|
||||||
|
async with app.router.lifespan_context(app):
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=httpx.ASGITransport(app=app),
|
||||||
|
base_url="http://testserver",
|
||||||
|
headers={"X-WQ-Request": "1"},
|
||||||
|
) as client:
|
||||||
|
assert (
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/auth/login", json={"username": "admin", "password": "migration-test-only"}
|
||||||
|
)
|
||||||
|
).status_code == 200
|
||||||
|
await client.put(
|
||||||
|
"/api/v1/account/credentials",
|
||||||
|
json={"email": "pg@example.com", "password": "synthetic-only"},
|
||||||
|
)
|
||||||
|
job = (await client.post("/api/v1/account/connect")).json()
|
||||||
|
await app.state.runner.execute(job["id"])
|
||||||
|
catalog = (client, app.state.runner, {})
|
||||||
|
assert (await sync(catalog))["status"] == "completed"
|
||||||
|
version = (await sync(catalog, "TEST_FIN"))["id"]
|
||||||
|
result = await search(client, "/datasets/TEST_FIN/fields")
|
||||||
|
assert result["complete_count"] == 123
|
||||||
|
draft = (await prepare(client, version)).json()
|
||||||
|
assert len(draft["field_ids"]) == 123
|
||||||
|
responses = await asyncio.gather(
|
||||||
|
*[
|
||||||
|
client.patch(
|
||||||
|
"/api/v1/catalog/datasets/TEST_FIN/research",
|
||||||
|
params=SCOPE,
|
||||||
|
json={"version": 1, "note": value},
|
||||||
|
)
|
||||||
|
for value in ["one", "two"]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert sorted(r.status_code for r in responses) == [200, 409]
|
||||||
|
await sync(catalog, "TEST_FIN")
|
||||||
|
assert (await prepare(client, version)).status_code == 409
|
||||||
|
persisted = (await client.get("/api/v1/catalog/inputs/" + draft["id"])).json()
|
||||||
|
assert persisted == draft
|
||||||
|
print(
|
||||||
|
"PostgreSQL: real API/runner multi-page dedupe, immutable draft, refresh conflict and concurrent note CAS passed"
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.run(flow())
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"""Public API through real business/runner/database; only upstream HTTP is replaced."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.jobs import Runner
|
||||||
|
from app.models import Job
|
||||||
|
from app.worldquant import WqClient
|
||||||
|
from tests.catalog_fake import catalog_response, field_records
|
||||||
|
|
||||||
|
SCOPE = dict(instrument_type="EQUITY", region="USA", universe="TOP3000", delay=1)
|
||||||
|
BASE = "/api/v1/catalog"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def catalog(logged_in, app):
|
||||||
|
state = {"fail": False, "fields": field_records(), "calls": [], "mode": "", "block": None}
|
||||||
|
|
||||||
|
async def upstream(request):
|
||||||
|
state["calls"].append((request.url.path, int(request.url.params.get("offset", 0))))
|
||||||
|
if request.url.path == "/authentication":
|
||||||
|
if state.get("persona"):
|
||||||
|
return httpx.Response(
|
||||||
|
401, headers={"WWW-Authenticate": "persona", "Location": "/authentication/persona/test"}
|
||||||
|
)
|
||||||
|
return httpx.Response(201, json={"token": {"expiry": 14400}})
|
||||||
|
assert request.method == "GET"
|
||||||
|
if request.url.path == "/users/self":
|
||||||
|
return httpx.Response(200, json={"id": "TEST_USER"})
|
||||||
|
if request.url.path == "/data-fields":
|
||||||
|
if state.get("throttle"):
|
||||||
|
state["throttle"] = False
|
||||||
|
return httpx.Response(429, headers={"Retry-After": "2"})
|
||||||
|
if state["mode"] == "invalid-next":
|
||||||
|
return httpx.Response(200, json={"results": state["fields"][:50], "next": []})
|
||||||
|
if state["mode"] == "missing-owner":
|
||||||
|
return httpx.Response(200, json={"results": [{"id": "UNOWNED"}], "next": None})
|
||||||
|
if state["mode"] == "coverage-unit":
|
||||||
|
return httpx.Response(
|
||||||
|
200, json={"results": [{**state["fields"][0], "coverage": 95}], "next": None}
|
||||||
|
)
|
||||||
|
if int(request.url.params["offset"]) >= 50:
|
||||||
|
if state["block"]:
|
||||||
|
state["block"].set()
|
||||||
|
await asyncio.Future()
|
||||||
|
if state["fail"]:
|
||||||
|
return httpx.Response(403)
|
||||||
|
if state["mode"] == "early":
|
||||||
|
return httpx.Response(200, json={"results": [], "next": "/next", "count": 123})
|
||||||
|
if state["mode"] == "repeat":
|
||||||
|
return httpx.Response(200, json={"results": state["fields"][:50], "next": "/next"})
|
||||||
|
if state["mode"] == "wrong-owner":
|
||||||
|
return httpx.Response(200, json={"results": field_records("OTHER", 1)})
|
||||||
|
return catalog_response(request, state["fields"]) or httpx.Response(404)
|
||||||
|
|
||||||
|
await app.state.runner.client.close()
|
||||||
|
app.state.runner.client = WqClient(app.state.settings, transport=httpx.MockTransport(upstream))
|
||||||
|
client = logged_in
|
||||||
|
assert (
|
||||||
|
await client.put(
|
||||||
|
"/api/v1/account/credentials", json={"email": "test@example.com", "password": "test-only"}
|
||||||
|
)
|
||||||
|
).status_code == 200
|
||||||
|
connect = (await client.post("/api/v1/account/connect")).json()
|
||||||
|
await app.state.runner.execute(connect["id"])
|
||||||
|
return client, app.state.runner, state
|
||||||
|
|
||||||
|
|
||||||
|
async def sync(catalog, dataset=None, scope=SCOPE):
|
||||||
|
client, runner, _ = catalog
|
||||||
|
response = await client.post(BASE + "/sync-jobs", json={"scope": scope, "dataset_id": dataset})
|
||||||
|
assert response.status_code == 202, response.text
|
||||||
|
job = response.json()
|
||||||
|
await runner.execute(job["id"])
|
||||||
|
return (await client.get("/api/v1/sync-jobs/" + job["id"])).json()
|
||||||
|
|
||||||
|
|
||||||
|
async def search(client, suffix="/datasets", **params):
|
||||||
|
response = await client.get(BASE + suffix, params={**SCOPE, **params})
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def prepare(client, version, **changes):
|
||||||
|
return await client.post(
|
||||||
|
BASE + "/inputs",
|
||||||
|
json={
|
||||||
|
"scope": SCOPE,
|
||||||
|
"dataset_id": "TEST_FIN",
|
||||||
|
"collection_version": version,
|
||||||
|
"selection": "all",
|
||||||
|
**changes,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_complete_workflow_filters_notes_immutable_input(catalog):
|
||||||
|
client, _, state = catalog
|
||||||
|
assert (await search(client))["total"] == 0
|
||||||
|
assert (await sync(catalog))["status"] == "completed"
|
||||||
|
datasets = await search(client, category="基本面", subcategory="财务报表")
|
||||||
|
assert [r["id"] for r in datasets["items"]] == ["TEST_FIN"]
|
||||||
|
assert datasets["items"][0]["complete_count"] is None
|
||||||
|
assert (await sync(catalog, "TEST_FIN"))["processed"] == 123
|
||||||
|
fields = await search(client, "/datasets/TEST_FIN/fields", q="字段 12", limit=1)
|
||||||
|
assert fields["total"] == 3 and fields["complete_count"] == 123 and len(fields["items"]) == 1
|
||||||
|
version = fields["collection_version"]
|
||||||
|
response = await prepare(client, version)
|
||||||
|
assert response.status_code == 201, response.text
|
||||||
|
draft = response.json()
|
||||||
|
assert len(draft["field_ids"]) == 123 and draft["status"] == "draft"
|
||||||
|
for suffix in ["/datasets/TEST_FIN", "/datasets/TEST_FIN/fields/TEST_FIN_122"]:
|
||||||
|
detail = await search(client, suffix)
|
||||||
|
assert detail["research"]["version"] == 1
|
||||||
|
response = await client.patch(
|
||||||
|
BASE + suffix + "/research", params=SCOPE, json={"version": 1, "note": "保留研究备注"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert (
|
||||||
|
await client.patch(
|
||||||
|
BASE + suffix + "/research", params=SCOPE, json={"version": 1, "note": "不能覆盖"}
|
||||||
|
)
|
||||||
|
).status_code == 409
|
||||||
|
detail = await search(client, "/datasets/TEST_FIN/fields/TEST_FIN_122")
|
||||||
|
assert detail["coverage"] is None and detail["unit"] is None and detail["field_type"] == "FUTURE_TYPE"
|
||||||
|
assert (await search(client, "/datasets/TEST_FIN/fields", coverage_min=0))["total"] == 122
|
||||||
|
state["fields"] = field_records(count=125)
|
||||||
|
assert (await sync(catalog, "TEST_FIN"))["processed"] == 125
|
||||||
|
assert (await sync(catalog))["status"] == "completed"
|
||||||
|
newer = await search(client, "/datasets/TEST_FIN/fields")
|
||||||
|
assert newer["collection_version"] != version
|
||||||
|
assert (await prepare(client, version)).status_code == 409
|
||||||
|
assert len((await prepare(client, newer["collection_version"])).json()["field_ids"]) == 125
|
||||||
|
assert (await client.get(BASE + "/inputs/" + draft["id"])).json() == draft
|
||||||
|
assert (await search(client, "/datasets/TEST_FIN/fields/TEST_FIN_122"))["research"][
|
||||||
|
"note"
|
||||||
|
] == "保留研究备注"
|
||||||
|
assert (await search(client, "/datasets/TEST_FIN"))["research"]["note"] == "保留研究备注"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_partial_refresh_resume_cancel_restart_keeps_old_version(catalog):
|
||||||
|
client, runner, state = catalog
|
||||||
|
await sync(catalog)
|
||||||
|
state["fail"] = True
|
||||||
|
job = await sync(catalog, "TEST_FIN")
|
||||||
|
assert job["status"] == "failed" and job["processed"] == 50
|
||||||
|
assert (await search(client, "/datasets/TEST_FIN/fields"))["collection_version"] is None
|
||||||
|
assert (await prepare(client, job["id"])).status_code == 409
|
||||||
|
state["fail"] = False
|
||||||
|
state["calls"].clear()
|
||||||
|
assert (await client.post("/api/v1/sync-jobs/" + job["id"] + "/retry")).status_code == 200
|
||||||
|
await runner.execute(job["id"])
|
||||||
|
assert state["calls"][0] == ("/data-fields", 50)
|
||||||
|
old_version = (await search(client, "/datasets/TEST_FIN/fields"))["collection_version"]
|
||||||
|
state["fail"] = True
|
||||||
|
refresh = await sync(catalog, "TEST_FIN")
|
||||||
|
assert refresh["status"] == "failed"
|
||||||
|
assert (await search(client, "/datasets/TEST_FIN/fields"))["collection_version"] == old_version
|
||||||
|
state["fail"] = False
|
||||||
|
state["calls"].clear()
|
||||||
|
async with runner.sessions() as db:
|
||||||
|
row = await db.get(Job, refresh["id"])
|
||||||
|
row.status = "running"
|
||||||
|
await db.commit()
|
||||||
|
restarted = Runner(runner.sessions, runner.settings, runner.client)
|
||||||
|
await restarted.start()
|
||||||
|
async with asyncio.timeout(5):
|
||||||
|
while True:
|
||||||
|
response = (await client.get("/api/v1/sync-jobs/" + refresh["id"])).json()
|
||||||
|
if response["status"] in ("completed", "failed"):
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
assert response["status"] == "completed"
|
||||||
|
assert state["calls"][0] == ("/data-fields", 50)
|
||||||
|
state["block"] = asyncio.Event()
|
||||||
|
response = await client.post(BASE + "/sync-jobs", json={"scope": SCOPE, "dataset_id": "TEST_FIN"})
|
||||||
|
cancel_id = response.json()["id"]
|
||||||
|
restarted.wake.set()
|
||||||
|
await asyncio.wait_for(state["block"].wait(), 5)
|
||||||
|
await client.post("/api/v1/sync-jobs/" + cancel_id + "/cancel")
|
||||||
|
await restarted.cancel(cancel_id)
|
||||||
|
assert (await client.get("/api/v1/sync-jobs/" + cancel_id)).json()["status"] == "cancelled"
|
||||||
|
assert (await search(client, "/datasets/TEST_FIN/fields"))["collection_version"] == refresh["id"]
|
||||||
|
await restarted.stop()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"mode", ["early", "repeat", "wrong-owner", "invalid-next", "missing-owner", "coverage-unit"]
|
||||||
|
)
|
||||||
|
async def test_anomalous_pagination_is_never_complete(catalog, mode):
|
||||||
|
client, _, state = catalog
|
||||||
|
await sync(catalog)
|
||||||
|
state["mode"] = mode
|
||||||
|
assert (await sync(catalog, "TEST_FIN"))["status"] == "failed"
|
||||||
|
assert (await search(client, "/datasets/TEST_FIN/fields"))["collection_version"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_scope_ownership_empty_and_unknown_fields_are_rejected(catalog):
|
||||||
|
client, _, _ = catalog
|
||||||
|
await sync(catalog)
|
||||||
|
version = (await sync(catalog, "TEST_FIN"))["id"]
|
||||||
|
assert (await prepare(client, version, selection="explicit", excluded_ids=["OTHER"])).status_code == 422
|
||||||
|
assert (
|
||||||
|
await prepare(
|
||||||
|
client, version, selection="explicit", excluded_ids=[f"TEST_FIN_{i:03}" for i in range(123)]
|
||||||
|
)
|
||||||
|
).status_code == 422
|
||||||
|
assert (await prepare(client, version, dataset_id="TEST_NEWS")).status_code == 409
|
||||||
|
assert (await prepare(client, version, scope={**SCOPE, "delay": 0})).status_code == 404
|
||||||
|
assert (await prepare(client, version, scope={**SCOPE, "region": "CHN"})).status_code == 422
|
||||||
|
subset = await prepare(client, version, selection="explicit", excluded_ids=["TEST_FIN_110"])
|
||||||
|
assert subset.status_code == 201 and len(subset.json()["field_ids"]) == 122
|
||||||
|
assert "TEST_FIN_110" not in subset.json()["field_ids"]
|
||||||
|
other = {**SCOPE, "delay": 0}
|
||||||
|
await sync(catalog, scope=other)
|
||||||
|
await sync(catalog, "TEST_FIN", scope=other)
|
||||||
|
assert (await prepare(client, version, scope=other)).status_code == 409
|
||||||
|
assert len((await client.get(BASE + "/inputs", params=SCOPE)).json()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_catalog_authentication_and_origin(app, client):
|
||||||
|
assert (await client.get(BASE + "/datasets", params=SCOPE)).status_code == 401
|
||||||
|
assert (
|
||||||
|
await client.post(BASE + "/sync-jobs", headers={"Origin": "http://evil.test"}, json={"scope": SCOPE})
|
||||||
|
).status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
async def test_retry_after_auth_wait_disconnect_and_collection_manifest(catalog):
|
||||||
|
client, runner, state = catalog
|
||||||
|
await sync(catalog)
|
||||||
|
delays = []
|
||||||
|
|
||||||
|
async def sleep(delay):
|
||||||
|
delays.append(delay)
|
||||||
|
|
||||||
|
runner.client.sleep = sleep
|
||||||
|
state["throttle"] = True
|
||||||
|
job = await sync(catalog, "TEST_FIN")
|
||||||
|
assert job["status"] == "completed" and delays == [2]
|
||||||
|
manifest = await search(client, "/datasets/TEST_FIN/collection")
|
||||||
|
assert manifest["collection_version"] == job["id"] and len(manifest["field_ids"]) == 123
|
||||||
|
state["persona"] = True
|
||||||
|
runner.client.authenticated = False
|
||||||
|
waiting = await sync(catalog, "TEST_FIN")
|
||||||
|
assert waiting["status"] == "waiting_auth"
|
||||||
|
assert (await search(client, "/datasets/TEST_FIN/collection")) == manifest
|
||||||
|
await runner.disconnect()
|
||||||
|
assert (await client.get("/api/v1/sync-jobs/" + waiting["id"])).json()["status"] == "waiting_connection"
|
||||||
|
assert (await client.post(BASE + "/sync-jobs", json={"scope": SCOPE})).status_code == 409
|
||||||
|
# Explicit reconnect verifies the original account and resumes the same task.
|
||||||
|
state["persona"] = False
|
||||||
|
connect = (await client.post("/api/v1/account/connect")).json()
|
||||||
|
await runner.execute(connect["id"])
|
||||||
|
await runner.execute(waiting["id"])
|
||||||
|
assert (await client.get("/api/v1/sync-jobs/" + waiting["id"])).json()["status"] == "completed"
|
||||||
@@ -46,7 +46,7 @@ React + Semi Design + AI SDK UI 提供可调整宽度的聊天面板;FastAPI +
|
|||||||
|
|
||||||
## 模块与接口
|
## 模块与接口
|
||||||
|
|
||||||
模块为账户、Alpha、同步任务、WorldQuant 集成和 AI;业务查询、研究修改、任务控制统一进入 `business.py`。所有上游认证、会话、分页和退避集中封装。
|
模块为账户、Alpha、同步任务、WorldQuant 集成、AI 和数据目录。Alpha 与任务控制进入 `business.py`;范围化目录、研究备注和输入草稿进入 `catalog/service.py`,共用现有任务执行器。所有上游认证、会话、分页和退避集中封装。
|
||||||
页面读取本地数据库。`/api/v1/auth` 管理登录,`/account` 管理配置与资料,`/alphas` 管理查询及研究记录,`/alphas/{id}/pnl` 读取缓存,`/sync-jobs` 创建、查询、取消和重试任务。
|
页面读取本地数据库。`/api/v1/auth` 管理登录,`/account` 管理配置与资料,`/alphas` 管理查询及研究记录,`/alphas/{id}/pnl` 读取缓存,`/sync-jobs` 创建、查询、取消和重试任务。
|
||||||
长任务返回 job ID;前端轮询。首期单后端进程运行异步任务,任务及分页检查点持久化。
|
长任务返回 job ID;前端轮询。首期单后端进程运行异步任务,任务及分页检查点持久化。
|
||||||
每页原子落库、按 Alpha ID 更新、失败重试及重启恢复;429 遵守 Retry-After,其余暂时性错误有界退避。
|
每页原子落库、按 Alpha ID 更新、失败重试及重启恢复;429 遵守 Retry-After,其余暂时性错误有界退避。
|
||||||
|
|||||||
@@ -92,3 +92,26 @@ AI SDK UI `6.0.277` / `@ai-sdk/react 3.0.280`、Pydantic AI slim `1.97.0` 均锁
|
|||||||
- 已查看截图:`output/playwright/ai-approval.png`、`account.png`、`lark-chat-390.png`、`lark-chat-1440.png`。全部为合成账户与 Alpha,未读取真实凭据。
|
- 已查看截图:`output/playwright/ai-approval.png`、`account.png`、`lark-chat-390.png`、`lark-chat-1440.png`。全部为合成账户与 Alpha,未读取真实凭据。
|
||||||
|
|
||||||
旧会话曾记录真实账户的只读认证、个人资料、10 项权限、14,400 秒会话和活动用量联调;这是旧快照的历史记录,并非本轮重新验证。本轮没有迁移或存储结构变更,没有重新运行 Docker/备份验收,也未重新部署正式实例、访问真实 WorldQuant 或收费模型服务。
|
旧会话曾记录真实账户的只读认证、个人资料、10 项权限、14,400 秒会话和活动用量联调;这是旧快照的历史记录,并非本轮重新验证。本轮没有迁移或存储结构变更,没有重新运行 Docker/备份验收,也未重新部署正式实例、访问真实 WorldQuant 或收费模型服务。
|
||||||
|
|
||||||
|
## 数据集与数据字段验收(2026-09-08)
|
||||||
|
|
||||||
|
本次按 `.scratch/dataset-catalog/spec.md` 实施,新增范围化目录、完整字段集合版本、本地备注、输入草稿和双层抽屉;不包含真实模板消费或回测。
|
||||||
|
|
||||||
|
- `uv run ruff check app tests` 通过,`uv run pytest -q` **85 项通过**。新增 11 项数据目录测试覆盖真实 API/业务/数据库/任务执行器,仅替换 WorldQuant HTTP:目录分类、范围隔离、123 个字段多页重叠去重、全集/显式排除输入、未知/跨对象/空输入拒绝、输入版本冲突、刷新不改变旧输入、备注 CAS 和同步保留、缺失指标与未知类型、失败重试、取消、重启恢复、断开等待、人工验证、Retry-After。
|
||||||
|
- 完整性追加核验:缺失字段归属、错误归属、未知覆盖率单位、非列表 results、分页不前进或异常 next 均不会发布完整集合。没有 next 时探测到空页,不仅凭 count 判定完成。失败刷新保留上一版本。
|
||||||
|
- `pnpm build` 类型检查与生产构建通过;保留 Semi 间接依赖 lottie-web 的既有 eval 提示,未修改 CSP。
|
||||||
|
- `pnpm test` **8 项全部通过**:原有 5 项账户/Alpha/AI 验收、新增 3 项数据目录验收。实测筛选后仍保存 123 字段草稿、排除后保存 122 字段、取消全选禁用、恢复全选、非首页排除、备注保存、搜索/焦点逐层恢复、AI 开合恢复未保存备注、Esc/遮罩逐层关闭、范围联动。
|
||||||
|
- 布局实测 390/850/1280/1440/1920px,无整页横向溢出。1440px 工作区下字段抽屉 1080px、字段详情 432px;手机抽屉 390px。操作区在顶部、表体局部滚动、分页可达。已查看 `output/playwright/dataset-desktop.png` 与 `dataset-mobile.png`,均为合成数据。
|
||||||
|
- 生产数据库路径使用独立 `postgres:17-alpine` 容器 `wq-alpha-acceptance-catalog-98e6`,只映射回环地址 18436,未连接正式数据库。`0002 → 0003 → 0002 → 0003` 及 `alembic check` 通过;原 Alpha 与版本为 7 的研究备注保留。实际 PostgreSQL 上通过真实 API/执行器完成多页去重、123 字段草稿、重新同步后原草稿不变、旧版本输入拒绝、两个同时保存备注请求分别返回 200/409。脚本为 `backend/tests/catalog_migration_check.py`,拒绝非 `catalog_*` 名称和已有表的测试库。
|
||||||
|
|
||||||
|
复跑隔离 PostgreSQL 验收(专用测试名称与端口必须空闲):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --detach --rm --name wq-alpha-acceptance-catalog --env POSTGRES_PASSWORD=catalog-test-only --env POSTGRES_DB=catalog_flow_test --publish 127.0.0.1:18436:5432 postgres:17-alpine
|
||||||
|
# 等待 pg_isready 后,在 backend/ 执行:
|
||||||
|
uv run python tests/catalog_migration_check.py
|
||||||
|
# 仅清理上面专用测试容器;--rm 自动移除其匿名测试卷。
|
||||||
|
docker stop wq-alpha-acceptance-catalog
|
||||||
|
```
|
||||||
|
|
||||||
|
真实平台数据集 schema、范围权限、字段归属、0–1 覆盖率及分页协议仍未联调;缺少已支持的响应结构时会明确失败。完整枚举是本地完成版本,不意味着平台提供时间点一致性快照。本轮未执行全套部署/备份验收、没有部署或 Git 提交,没有读取真实凭据、调用真实平台或收费模型。
|
||||||
|
|||||||
+76
-30
@@ -13,6 +13,7 @@ import zhCN from "@douyinfe/semi-ui-19/lib/es/locale/source/zh_CN";
|
|||||||
import { api, post } from "./api";
|
import { api, post } from "./api";
|
||||||
import type { Account, Job } from "./types";
|
import type { Account, Job } from "./types";
|
||||||
import { AccountPage } from "./pages/AccountPage";
|
import { AccountPage } from "./pages/AccountPage";
|
||||||
|
import { DatasetPage } from "./pages/DatasetPage";
|
||||||
import { AlphaPage } from "./pages/AlphaPage";
|
import { AlphaPage } from "./pages/AlphaPage";
|
||||||
import { JobPanel } from "./components/JobPanel";
|
import { JobPanel } from "./components/JobPanel";
|
||||||
import { ChatPanel } from "./ai/ChatPanel";
|
import { ChatPanel } from "./ai/ChatPanel";
|
||||||
@@ -23,8 +24,13 @@ export default function App() {
|
|||||||
const [account, setAccount] = useState<Account | null>(null);
|
const [account, setAccount] = useState<Account | null>(null);
|
||||||
const [jobs, setJobs] = useState<Job[]>([]);
|
const [jobs, setJobs] = useState<Job[]>([]);
|
||||||
const [page, setPage] = useState(
|
const [page, setPage] = useState(
|
||||||
location.hash === "#account" ? "account" : "alphas",
|
location.hash === "#account"
|
||||||
|
? "account"
|
||||||
|
: location.hash === "#datasets"
|
||||||
|
? "datasets"
|
||||||
|
: "alphas",
|
||||||
);
|
);
|
||||||
|
const [catalogModal, setCatalogModal] = useState(false);
|
||||||
const [showJobs, setShowJobs] = useState(false);
|
const [showJobs, setShowJobs] = useState(false);
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
const [pollError, setPollError] = useState("");
|
const [pollError, setPollError] = useState("");
|
||||||
@@ -84,7 +90,13 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
window.addEventListener("session-expired", expired);
|
window.addEventListener("session-expired", expired);
|
||||||
const hash = () =>
|
const hash = () =>
|
||||||
setPage(location.hash === "#account" ? "account" : "alphas");
|
setPage(
|
||||||
|
location.hash === "#account"
|
||||||
|
? "account"
|
||||||
|
: location.hash === "#datasets"
|
||||||
|
? "datasets"
|
||||||
|
: "alphas",
|
||||||
|
);
|
||||||
window.addEventListener("hashchange", hash);
|
window.addEventListener("hashchange", hash);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("session-expired", expired);
|
window.removeEventListener("session-expired", expired);
|
||||||
@@ -154,7 +166,11 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="workspace">
|
<div className="workspace">
|
||||||
<aside className="sidebar" inert={chatOpen && viewport < 1440}>
|
<aside
|
||||||
|
className="sidebar"
|
||||||
|
inert={(chatOpen && viewport < 1440) || catalogModal}
|
||||||
|
aria-hidden={catalogModal || undefined}
|
||||||
|
>
|
||||||
<div className="brand">
|
<div className="brand">
|
||||||
<span className="brand-mark">α</span>
|
<span className="brand-mark">α</span>
|
||||||
<div>Alpha 研究</div>
|
<div>Alpha 研究</div>
|
||||||
@@ -167,6 +183,14 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
Alpha 管理
|
Alpha 管理
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="数据集"
|
||||||
|
className={`nav-item ${page === "datasets" ? "active" : ""}`}
|
||||||
|
aria-current={page === "datasets" ? "page" : undefined}
|
||||||
|
onClick={() => changePage("datasets")}
|
||||||
|
>
|
||||||
|
数据集
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-label="个人信息"
|
aria-label="个人信息"
|
||||||
className={`nav-item ${page === "account" ? "active" : ""}`}
|
className={`nav-item ${page === "account" ? "active" : ""}`}
|
||||||
@@ -190,7 +214,11 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<div className="main-shell" inert={chatOpen && viewport < 1440}>
|
<div
|
||||||
|
className="main-shell"
|
||||||
|
inert={(chatOpen && viewport < 1440) || catalogModal}
|
||||||
|
aria-hidden={catalogModal || undefined}
|
||||||
|
>
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<div className="breadcrumbs">研究工作空间</div>
|
<div className="breadcrumbs">研究工作空间</div>
|
||||||
<div className="top-actions">
|
<div className="top-actions">
|
||||||
@@ -234,7 +262,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main
|
<main
|
||||||
className={`page-content ${page === "alphas" ? "bounded-page" : "account-page"}`}
|
className={`page-content ${page !== "account" ? "bounded-page" : "account-page"}`}
|
||||||
>
|
>
|
||||||
{pollError && (
|
{pollError && (
|
||||||
<Banner
|
<Banner
|
||||||
@@ -249,6 +277,18 @@ export default function App() {
|
|||||||
onTask={taskCreated}
|
onTask={taskCreated}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="alpha-page-view" hidden={page !== "datasets"}>
|
||||||
|
<DatasetPage
|
||||||
|
account={account}
|
||||||
|
jobs={jobs}
|
||||||
|
active={page === "datasets"}
|
||||||
|
version={`${refreshKey}:${completedVersion}`}
|
||||||
|
suspended={showJobs || chatOpen}
|
||||||
|
onTask={taskCreated}
|
||||||
|
onModal={setCatalogModal}
|
||||||
|
onChat={() => setChatOpen(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="alpha-page-view" hidden={page !== "alphas"}>
|
<div className="alpha-page-view" hidden={page !== "alphas"}>
|
||||||
<AlphaPage
|
<AlphaPage
|
||||||
taskPanelOpen={showJobs}
|
taskPanelOpen={showJobs}
|
||||||
@@ -280,7 +320,7 @@ export default function App() {
|
|||||||
changePage("account");
|
changePage("account");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{!chatOpen && (
|
{!chatOpen && !catalogModal && (
|
||||||
<Button
|
<Button
|
||||||
className="ai-launcher"
|
className="ai-launcher"
|
||||||
aria-label="打开研究助手"
|
aria-label="打开研究助手"
|
||||||
@@ -298,30 +338,36 @@ export default function App() {
|
|||||||
onClick={() => setChatOpen(false)}
|
onClick={() => setChatOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ChatPanel
|
<div inert={catalogModal && !chatOpen}>
|
||||||
jobs={jobs}
|
<ChatPanel
|
||||||
open={chatOpen}
|
jobs={jobs}
|
||||||
width={chatWidth}
|
open={chatOpen}
|
||||||
onWidth={setChatWidth}
|
width={chatWidth}
|
||||||
onClose={() => setChatOpen(false)}
|
onWidth={setChatWidth}
|
||||||
context={page === "alphas" ? alphaContext : { page: "account" }}
|
onClose={() => setChatOpen(false)}
|
||||||
timezone={account?.timezone}
|
context={
|
||||||
onSettings={() => {
|
page === "alphas"
|
||||||
focusBusiness();
|
? alphaContext
|
||||||
changePage("account");
|
: { page: page === "datasets" ? "datasets" : "account" }
|
||||||
requestAnimationFrame(() =>
|
}
|
||||||
document
|
timezone={account?.timezone}
|
||||||
.getElementById("model-settings")
|
onSettings={() => {
|
||||||
?.scrollIntoView({ block: "start" }),
|
focusBusiness();
|
||||||
);
|
changePage("account");
|
||||||
}}
|
requestAnimationFrame(() =>
|
||||||
onChanged={actionDone}
|
document
|
||||||
onAction={(action) => {
|
.getElementById("model-settings")
|
||||||
focusBusiness();
|
?.scrollIntoView({ block: "start" }),
|
||||||
changePage("alphas");
|
);
|
||||||
setAIAction(action);
|
}}
|
||||||
}}
|
onChanged={actionDone}
|
||||||
/>
|
onAction={(action) => {
|
||||||
|
focusBusiness();
|
||||||
|
changePage("alphas");
|
||||||
|
setAIAction(action);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</LocaleProvider>
|
</LocaleProvider>
|
||||||
|
|||||||
@@ -434,9 +434,11 @@ export function ChatPanel({
|
|||||||
</div>
|
</div>
|
||||||
<footer className="ai-composer" ref={input}>
|
<footer className="ai-composer" ref={input}>
|
||||||
<div className="ai-context">
|
<div className="ai-context">
|
||||||
{context.page === "account"
|
{context.page === "datasets"
|
||||||
? "上下文:个人信息页"
|
? "上下文:数据目录(未发送字段与备注)"
|
||||||
: `上下文:${context.alpha_id ? `Alpha ${context.alpha_id}` : "Alpha 列表"}${context.selected_ids?.length ? ` · 已选 ${context.selected_ids.length} 条` : ""}`}
|
: context.page === "account"
|
||||||
|
? "上下文:个人信息页"
|
||||||
|
: `上下文:${context.alpha_id ? `Alpha ${context.alpha_id}` : "Alpha 列表"}${context.selected_ids?.length ? ` · 已选 ${context.selected_ids.length} 条` : ""}`}
|
||||||
</div>
|
</div>
|
||||||
<TextArea
|
<TextArea
|
||||||
aria-label="发送给研究助手"
|
aria-label="发送给研究助手"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export type ModelSettings = {
|
|||||||
test_results: Record<string, { ok: boolean; message: string }>;
|
test_results: Record<string, { ok: boolean; message: string }>;
|
||||||
};
|
};
|
||||||
export type PageContext = {
|
export type PageContext = {
|
||||||
page: "alphas" | "account";
|
page: "alphas" | "account" | "datasets";
|
||||||
alpha_id?: string | null;
|
alpha_id?: string | null;
|
||||||
selected_ids?: string[];
|
selected_ids?: string[];
|
||||||
filters?: Record<string, unknown>;
|
filters?: Record<string, unknown>;
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ export const stateOptions = Object.entries(stateLabels).map(
|
|||||||
([value, label]) => ({ value, label }),
|
([value, label]) => ({ value, label }),
|
||||||
);
|
);
|
||||||
export const jobLabels: Record<string, string> = {
|
export const jobLabels: Record<string, string> = {
|
||||||
|
catalog_sync: "同步数据集目录",
|
||||||
|
field_sync: "同步数据字段",
|
||||||
full_sync: "全量同步 Alpha",
|
full_sync: "全量同步 Alpha",
|
||||||
alpha_refresh: "导入 / 刷新 Alpha",
|
alpha_refresh: "导入 / 刷新 Alpha",
|
||||||
pnl_refresh: "获取 PnL",
|
pnl_refresh: "获取 PnL",
|
||||||
|
|||||||
@@ -71,6 +71,13 @@ export function JobPanel({
|
|||||||
{jobStateLabels[job.status] ?? job.status}
|
{jobStateLabels[job.status] ?? job.status}
|
||||||
</Tag>
|
</Tag>
|
||||||
</div>
|
</div>
|
||||||
|
{job.payload?.scope && (
|
||||||
|
<p>
|
||||||
|
{job.payload.dataset_id || "数据集目录"} ·{" "}
|
||||||
|
{job.payload.scope.region} · {job.payload.scope.universe} · Delay{" "}
|
||||||
|
{job.payload.scope.delay}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<p className="muted">{formatTime(job.created_at, timezone)}</p>
|
<p className="muted">{formatTime(job.created_at, timezone)}</p>
|
||||||
<div className="job-numbers">
|
<div className="job-numbers">
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,144 @@
|
|||||||
|
.catalog-page,
|
||||||
|
.catalog-layer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.catalog-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.catalog-page > .catalog-tools {
|
||||||
|
padding: 0 0 12px;
|
||||||
|
}
|
||||||
|
.catalog-tools > .semi-input-wrapper {
|
||||||
|
width: 240px;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
.catalog-tools > .semi-select {
|
||||||
|
min-width: 112px;
|
||||||
|
max-width: 220px;
|
||||||
|
}
|
||||||
|
.catalog-tools > span {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.catalog-sheet .semi-sidesheet-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.catalog-sheet .semi-sidesheet-body {
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.catalog-sheet .semi-sidesheet-inner {
|
||||||
|
box-shadow: none;
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.catalog-sheet .semi-sidesheet-mask {
|
||||||
|
background: #1f232926;
|
||||||
|
}
|
||||||
|
.catalog-layer > .catalog-tools {
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.catalog-table .semi-table-row-cell,
|
||||||
|
.catalog-table .semi-button {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 22px;
|
||||||
|
font-weight: 400;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.catalog-table .semi-table-row-cell {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.catalog-name {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
.catalog-name .semi-button-content {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.catalog-details-body {
|
||||||
|
padding: 16px;
|
||||||
|
overflow: auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.catalog-details-body > p,
|
||||||
|
.catalog-details-body > h2 {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.catalog-details-body dl {
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
.catalog-details-body dl > div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 80px minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.catalog-details-body dt {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.catalog-details-body dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.catalog-draft {
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
.catalog-draft details {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
.catalog-draft summary {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.catalog-tools {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
.catalog-tools > .semi-input-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.catalog-layer .catalog-tools {
|
||||||
|
max-height: 32%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.catalog-page > .catalog-tools {
|
||||||
|
max-height: 28%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.catalog-page .library-panel > .catalog-tools {
|
||||||
|
max-height: 32%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.catalog-layer .table-pagination {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.catalog-layer .table-pagination > span {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-sr-label {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip-path: inset(50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
@@ -75,6 +75,15 @@ export type AccountUsage = {
|
|||||||
errors: Record<string, string>;
|
errors: Record<string, string>;
|
||||||
};
|
};
|
||||||
export type Job = {
|
export type Job = {
|
||||||
|
payload?: {
|
||||||
|
scope?: {
|
||||||
|
instrument_type: string;
|
||||||
|
region: string;
|
||||||
|
universe: string;
|
||||||
|
delay: number;
|
||||||
|
};
|
||||||
|
dataset_id?: string;
|
||||||
|
};
|
||||||
id: string;
|
id: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ test("Lark workspace keeps pagination, account details and chat usable at every
|
|||||||
expect(before.y + before.height).toBeLessThanOrEqual(900);
|
expect(before.y + before.height).toBeLessThanOrEqual(900);
|
||||||
expect(before.y + before.height).toBeGreaterThan(864);
|
expect(before.y + before.height).toBeGreaterThan(864);
|
||||||
await page
|
await page
|
||||||
.locator(".semi-table-body")
|
.locator(".alpha-page-view:not([hidden]) .semi-table-body")
|
||||||
.evaluate((el) => el.scrollTo({ top: 1000 }));
|
.evaluate((el) => el.scrollTo({ top: 1000 }));
|
||||||
expect((await footer.boundingBox())!.y).toBeCloseTo(before.y, 0);
|
expect((await footer.boundingBox())!.y).toBeCloseTo(before.y, 0);
|
||||||
await page.getByRole("button", { name: "打开研究助手" }).click();
|
await page.getByRole("button", { name: "打开研究助手" }).click();
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import { test, expect, type Page } from "@playwright/test";
|
||||||
|
const scope = {
|
||||||
|
instrument_type: "EQUITY",
|
||||||
|
region: "USA",
|
||||||
|
universe: "TOP3000",
|
||||||
|
delay: 1,
|
||||||
|
};
|
||||||
|
const query = new URLSearchParams(
|
||||||
|
Object.entries(scope).map(([k, v]) => [k, String(v)]),
|
||||||
|
).toString();
|
||||||
|
const headers = { "X-WQ-Request": "1" };
|
||||||
|
async function setup(page: Page) {
|
||||||
|
await page.goto("/#datasets");
|
||||||
|
await page.getByLabel("密码", { exact: true }).fill("browser-test-password");
|
||||||
|
await page.getByRole("button", { name: "进入工作空间" }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "同步目录", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.request.put("/api/v1/account/credentials", {
|
||||||
|
headers,
|
||||||
|
data: { email: "test@example.com", password: "synthetic-only" },
|
||||||
|
});
|
||||||
|
const connect = await (
|
||||||
|
await page.request.post("/api/v1/account/connect", { headers })
|
||||||
|
).json();
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
(
|
||||||
|
await (
|
||||||
|
await page.request.get(`/api/v1/sync-jobs/${connect.id}`)
|
||||||
|
).json()
|
||||||
|
).status,
|
||||||
|
)
|
||||||
|
.toBe("completed");
|
||||||
|
await page.getByRole("button", { name: "同步目录", exact: true }).click();
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
(
|
||||||
|
await (
|
||||||
|
await page.request.get(`/api/v1/catalog/datasets?${query}`)
|
||||||
|
).json()
|
||||||
|
).total,
|
||||||
|
)
|
||||||
|
.toBe(3);
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "TEST 财务报表", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
async function openFields(page: Page) {
|
||||||
|
await page
|
||||||
|
.getByRole("row")
|
||||||
|
.filter({ hasText: "TEST 财务报表" })
|
||||||
|
.getByRole("button", { name: "查看字段", exact: true })
|
||||||
|
.click();
|
||||||
|
const sync = page
|
||||||
|
.getByRole("dialog", { name: "数据字段", exact: true })
|
||||||
|
.getByRole("button", { name: "同步全部字段", exact: true });
|
||||||
|
if (await sync.isVisible()) {
|
||||||
|
await sync.click();
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
(
|
||||||
|
await (
|
||||||
|
await page.request.get(
|
||||||
|
`/api/v1/catalog/datasets/TEST_FIN/fields?${query}`,
|
||||||
|
)
|
||||||
|
).json()
|
||||||
|
).complete_count,
|
||||||
|
)
|
||||||
|
.toBe(123);
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("目录筛选、双层详情、完整输入、排除、备注与刷新", async ({ page }) => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
page.on("pageerror", (e) => errors.push(e.message));
|
||||||
|
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||||
|
await setup(page);
|
||||||
|
await page.getByLabel("分类", { exact: true }).click();
|
||||||
|
await page.getByRole("option", { name: /基本面/ }).click();
|
||||||
|
await page.getByLabel("子分类", { exact: true }).click();
|
||||||
|
await page.getByRole("option", { name: /财务报表/ }).click();
|
||||||
|
await openFields(page);
|
||||||
|
const fields = page.getByRole("dialog", { name: "数据字段", exact: true });
|
||||||
|
await expect(fields).toBeVisible();
|
||||||
|
await expect(fields).toContainText("全部 123 个字段");
|
||||||
|
expect((await fields.boundingBox())!.width).toBeCloseTo(1080, 0);
|
||||||
|
await page.getByLabel("搜索字段").fill("字段 12");
|
||||||
|
await expect(fields.getByRole("button", { name: /TEST 字段/ })).toHaveCount(
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "TEST 字段 122", exact: true })
|
||||||
|
.click();
|
||||||
|
const detail = page.getByRole("dialog", { name: "字段详情", exact: true });
|
||||||
|
await expect(detail).toContainText("FUTURE_TYPE");
|
||||||
|
expect((await detail.boundingBox())!.width).toBeCloseTo(432, 0);
|
||||||
|
await page.getByLabel("研究备注", { exact: true }).fill("保留字段研究假设");
|
||||||
|
await page.getByRole("button", { name: "保存备注", exact: true }).click();
|
||||||
|
await expect(page.getByText("研究备注已保存")).toBeVisible();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(detail).not.toBeVisible();
|
||||||
|
await expect(page.getByLabel("搜索字段")).toHaveValue("字段 12");
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "TEST 字段 122", exact: true }),
|
||||||
|
).toBeFocused();
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "用于 Alpha 模板", exact: true })
|
||||||
|
.click();
|
||||||
|
await page.getByRole("button", { name: "保存输入草稿", exact: true }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "输入草稿已保存" }),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await (await page.request.get(`/api/v1/catalog/inputs?${query}`)).json()
|
||||||
|
)[0].field_ids,
|
||||||
|
).toHaveLength(123);
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await page
|
||||||
|
.getByRole("checkbox", { name: "选择TEST 字段 122", exact: true })
|
||||||
|
.press("Space");
|
||||||
|
await page.getByRole("button", { name: "覆盖率排序" }).click();
|
||||||
|
await expect(fields).toContainText("122 / 123 个字段");
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "用于 Alpha 模板", exact: true })
|
||||||
|
.click();
|
||||||
|
await page.getByRole("button", { name: "保存输入草稿", exact: true }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "输入草稿已保存" }),
|
||||||
|
).toBeVisible();
|
||||||
|
const subset = (
|
||||||
|
await (await page.request.get(`/api/v1/catalog/inputs?${query}`)).json()
|
||||||
|
)[0];
|
||||||
|
expect(subset.field_ids).toHaveLength(122);
|
||||||
|
expect(subset.field_ids).not.toContain("TEST_FIN_122");
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await page.getByRole("button", { name: "恢复全选" }).click();
|
||||||
|
await page
|
||||||
|
.getByRole("checkbox", { name: "选择本数据集全部字段" })
|
||||||
|
.press("Space");
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "用于 Alpha 模板", exact: true }),
|
||||||
|
).toBeDisabled();
|
||||||
|
await page.getByRole("button", { name: "恢复全选" }).click();
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "TEST 字段 122", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(page.getByLabel("研究备注", { exact: true })).toHaveValue(
|
||||||
|
"保留字段研究假设",
|
||||||
|
);
|
||||||
|
await page.screenshot({ path: "../output/playwright/dataset-desktop.png" });
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(page.getByLabel("分类", { exact: true })).toContainText(
|
||||||
|
"基本面",
|
||||||
|
);
|
||||||
|
await page.reload();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "已保存输入 (2)" }),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("窄屏抽屉、键盘隔离和研究范围切换", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await setup(page);
|
||||||
|
await openFields(page);
|
||||||
|
const fields = page.getByRole("dialog", { name: "数据字段", exact: true });
|
||||||
|
await expect(fields).toBeVisible();
|
||||||
|
expect((await fields.boundingBox())!.width).toBeCloseTo(390, 0);
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "TEST 字段 000", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("dialog", { name: "字段详情", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(fields).toBeVisible();
|
||||||
|
expect(
|
||||||
|
await page.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth <= innerWidth,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
await page.screenshot({ path: "../output/playwright/dataset-mobile.png" });
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await page.getByLabel("Region", { exact: true }).click();
|
||||||
|
await page.getByRole("option", { name: /CHN/ }).click();
|
||||||
|
await expect(page.getByLabel("Universe", { exact: true })).toContainText(
|
||||||
|
"TOP2000",
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "用于 Alpha 模板", exact: true }),
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("非首页排除、聊天暂存详情、遮罩逐层关闭和多尺寸", async ({ page }) => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
page.on("pageerror", (e) => errors.push(e.message));
|
||||||
|
await page.setViewportSize({ width: 1280, height: 900 });
|
||||||
|
await setup(page);
|
||||||
|
await openFields(page);
|
||||||
|
const fields = page.getByRole("dialog", { name: "数据字段", exact: true });
|
||||||
|
await fields.getByRole("button", { name: "Next", exact: true }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "TEST 字段 025", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await page
|
||||||
|
.getByRole("checkbox", { name: "选择TEST 字段 025", exact: true })
|
||||||
|
.press("Space");
|
||||||
|
await page.getByLabel("搜索字段").fill("字段 12");
|
||||||
|
await expect(fields).toContainText("122 / 123 个字段");
|
||||||
|
await page.getByLabel("字段类型", { exact: true }).click();
|
||||||
|
await page.getByRole("option", { name: /VECTOR/ }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "TEST 字段 120", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "TEST 字段 120", exact: true })
|
||||||
|
.click();
|
||||||
|
await page.getByLabel("研究备注", { exact: true }).fill("未保存草稿需要恢复");
|
||||||
|
await page
|
||||||
|
.getByRole("dialog", { name: "字段详情", exact: true })
|
||||||
|
.getByRole("button", { name: "AI 助手", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(fields).not.toBeVisible();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(page.getByLabel("研究备注", { exact: true })).toHaveValue(
|
||||||
|
"未保存草稿需要恢复",
|
||||||
|
);
|
||||||
|
for (const width of [850, 390, 1920]) {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "关闭详情", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(
|
||||||
|
await page.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth <= innerWidth,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
await page.mouse.click(10, 400);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("dialog", { name: "字段详情", exact: true }),
|
||||||
|
).not.toBeVisible();
|
||||||
|
await expect(fields).toBeVisible();
|
||||||
|
await expect(fields).toContainText("122 / 123 个字段");
|
||||||
|
await page.mouse.click(10, 400);
|
||||||
|
await expect(fields).not.toBeVisible();
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user