From 454e82e211e7796b1b43332103b9013fbe92bf7e Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 21 Oct 2026 14:57:06 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=E5=88=86=E6=9E=90=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E7=9A=84=E3=80=8C=E4=BB=8A=E5=A4=A9=E3=80=8D=E6=8C=89=E9=A2=98?= =?UTF-8?q?=E9=9B=86=E7=9A=84=E6=97=B6=E5=8C=BA=E5=8F=96=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E7=9C=8B=E6=9C=AC=E6=9C=BA=E6=97=B6=E5=8C=BA=EF=BC=9A=E9=A2=98?= =?UTF-8?q?=E9=9B=86=E9=87=8C=E7=9A=84=E6=9C=9F=E6=AC=A1=EF=BC=88=E4=B8=8A?= =?UTF-8?q?=E5=91=A8=E3=80=81=E6=9C=AC=E5=91=A8=EF=BC=89=E6=8C=89=20suite.?= =?UTF-8?q?timezone=20=E7=AE=97=EF=BC=8C=E7=94=9F=E6=88=90=E8=99=9A?= =?UTF-8?q?=E6=9E=84=E5=BA=93=E3=80=81=E5=88=A4=E4=BB=8A=E5=A4=A9=E6=98=AF?= =?UTF-8?q?=E4=B8=8D=E6=98=AF=E5=91=A8=E4=B8=80=E4=B9=9F=E7=85=A7=E5=AE=83?= =?UTF-8?q?=EF=BC=8C=E6=9C=AC=E6=9C=BA=E5=9C=A8=E5=88=AB=E7=9A=84=E6=97=B6?= =?UTF-8?q?=E5=8C=BA=EF=BC=88=E6=88=96=E8=B7=A8=E5=8D=88=E5=A4=9C=E9=82=A3?= =?UTF-8?q?=E5=87=A0=E4=B8=AA=E9=92=9F=E5=A4=B4=EF=BC=89=E6=97=B6=E4=B8=A4?= =?UTF-8?q?=E8=BE=B9=E4=B8=8D=E6=98=AF=E5=90=8C=E4=B8=80=E5=A4=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_eval_analysis_runs.py:TZ 从题集读,新增 now()(datetime.now(ZoneInfo(TZ))),生成库用 now().date(),原来写死的 Asia/Shanghai 去掉 - test_eval_analysis_suite.py:从题集内容取时区算今天,判周一也按它 - test_evals_api.py:零售样例题集的「今天」同样从题集取时区 --- backend/tests/test_eval_analysis_runs.py | 5 +++-- backend/tests/test_eval_analysis_suite.py | 11 +++++++---- backend/tests/test_evals_api.py | 5 +++-- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/backend/tests/test_eval_analysis_runs.py b/backend/tests/test_eval_analysis_runs.py index e34e3e04..e6875410 100644 --- a/backend/tests/test_eval_analysis_runs.py +++ b/backend/tests/test_eval_analysis_runs.py @@ -32,8 +32,9 @@ from app.eval.gold import Context from tests import eval_samples as es from tests.fixtures.acceptance import retail_trends -TZ = "Asia/Shanghai" SUITE = json.loads((Path(__file__).parent / "fixtures" / "eval" / "analysis_suite.json").read_text(encoding="utf-8")) +#: 题集的时区:题目里的期次({{ schedule.* }})按它算,生成库的「今天」也按它取,不看本机时区 +TZ = SUITE["suite"]["timezone"] #: 题集里的第 1 题(上周销售额下降主要来自华东)和第 3 题(订单数的常规波动) REGION = next(c for c in SUITE["cases"] if c["seq"] == 1) ORDERS = next(c for c in SUITE["cases"] if c["seq"] == 3) @@ -53,7 +54,7 @@ async def engine_up(monkeypatch): @pytest.fixture async def source(tmp_path, scratch_source): - path = retail_trends.build(tmp_path / "trends.db", date.today()) + path = retail_trends.build(tmp_path / "trends.db", now().date()) sid = await scratch_source(retail_trends.SOURCE_NAME, path, schema=True) return {"id": sid, "name": retail_trends.SOURCE_NAME, "path": path} diff --git a/backend/tests/test_eval_analysis_suite.py b/backend/tests/test_eval_analysis_suite.py index 09a54222..0ce9c0e2 100644 --- a/backend/tests/test_eval_analysis_suite.py +++ b/backend/tests/test_eval_analysis_suite.py @@ -4,9 +4,10 @@ from __future__ import annotations import json -from datetime import date +from datetime import datetime from pathlib import Path from urllib.parse import quote +from zoneinfo import ZoneInfo import pytest from httpx import ASGITransport, AsyncClient @@ -33,9 +34,11 @@ async def engine_up(): async def test_the_analysis_suite_imports_and_every_expectation_computes(tmp_path, scratch_source): - path = retail_trends.build(tmp_path / "trends.db", date.today()) - sid = await scratch_source(retail_trends.SOURCE_NAME, path, schema=True) content = json.loads(SUITE_FILE.read_text(encoding="utf-8")) + # 「今天」按题集的时区取(题目里的期次按它算),不看本机时区:本机在别的时区时,跨日那几个钟头两边的今天不是同一天 + today = datetime.now(ZoneInfo(content["suite"]["timezone"])).date() + path = retail_trends.build(tmp_path / "trends.db", today) + sid = await scratch_source(retail_trends.SOURCE_NAME, path, schema=True) assert {c["seq"] for c in content["cases"]} == set(WANTED) assert all(c["gold"]["datasource"] == retail_trends.SOURCE_NAME for c in content["cases"]) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: @@ -44,7 +47,7 @@ async def test_the_analysis_suite_imports_and_every_expectation_computes(tmp_pat assert res.json()["imported"] == len(WANTED) and res.json()["skipped"] == [] suite = res.json()["suite"] assert all(c["gold"]["datasource_id"] == sid for c in suite["cases"]) - monday = date.today().weekday() == 0 + monday = today.weekday() == 0 for case in suite["cases"]: assert case["trial"]["status"] == "ok", (case["seq"], case["trial"]) trial = (await client.post(f"/api/evals/suites/{suite['id']}/cases/{case['id']}/trial")).json()["trial"] diff --git a/backend/tests/test_evals_api.py b/backend/tests/test_evals_api.py index 910938e7..1264129f 100644 --- a/backend/tests/test_evals_api.py +++ b/backend/tests/test_evals_api.py @@ -386,7 +386,9 @@ async def test_export_then_import_matches_sources_by_name(client, source): async def test_the_retail_sample_suite_trials_on_the_retail_database(client, tmp_path): """仓库里附的零售样例题集:在虚构零售库上导入,每道题都试算通过,期望和直接查库一样,陷阱按口径算。""" - today = datetime.now(ZoneInfo("Asia/Shanghai")).date() + # 「今天」按题集的时区取(题目里的期次按它算),不看本机时区:本机在别的时区时,跨日那几个钟头两边的今天不是同一天 + content = json.loads(SUITE_FILE.read_text(encoding="utf-8")) + today = datetime.now(ZoneInfo(content["suite"]["timezone"])).date() path = retail.build(tmp_path / "retail.db", today) await drop_source(name=retail.SOURCE_NAME) async with SessionLocal() as session: @@ -398,7 +400,6 @@ async def test_the_retail_sample_suite_trials_on_the_retail_database(client, tmp await session.commit() sid = row.id try: - content = json.loads(SUITE_FILE.read_text(encoding="utf-8")) res = await client.post("/api/evals/suites/import", json={"content": content}, headers=ACTOR) assert res.status_code == 201, res.text assert res.json()["imported"] == 6 and res.json()["skipped"] == [] From b0b8d3ef04e6fd91105b121e0554d37981e3b28e Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 21 Oct 2026 14:57:12 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=E8=AF=84=E6=B5=8B=E9=A1=B5=E5=88=86?= =?UTF-8?q?=E6=9E=90=E6=9C=9F=E6=9C=9B=E7=9A=84=E5=8F=82=E6=95=B0=E6=A0=BC?= =?UTF-8?q?=E5=92=8C=E5=88=86=E6=9E=90=E5=8D=A1=E7=94=A8=E5=90=8C=E4=B8=80?= =?UTF-8?q?=E4=BB=BD=EF=BC=9ACaseEditor=20=E5=8E=9F=E6=9D=A5=E8=87=AA?= =?UTF-8?q?=E6=8A=84=E7=9A=84=20PARAM=5FFIELDS=20=E5=8E=BB=E6=8E=89?= =?UTF-8?q?=EF=BC=8C=E6=94=B9=E7=94=A8=20canvas/analysisItems.paramSpecs?= =?UTF-8?q?=EF=BC=88=E5=B7=AE=E5=BC=82=E6=A3=80=E9=AA=8C=E7=9A=84=E6=AF=94?= =?UTF-8?q?=E7=8E=87=E3=80=81=E5=9D=87=E5=80=BC=E3=80=81=E9=80=90=E8=A1=8C?= =?UTF-8?q?=E4=B8=89=E6=A8=A1=E5=BC=8F=E4=B9=9F=E5=B8=A6=E4=B8=8A=E6=9C=9F?= =?UTF-8?q?=E6=AC=A1=E4=B8=89=E6=A0=BC=E4=BA=86=EF=BC=8C=E5=92=8C=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=99=A8=E5=8F=82=E6=95=B0=E6=A8=A1=E5=9E=8B=E4=B8=80?= =?UTF-8?q?=E8=87=B4=EF=BC=89=EF=BC=9B=E5=8F=82=E6=95=B0=E5=8F=AB=E6=B3=95?= =?UTF-8?q?=E5=8F=AA=E5=89=A9=20lib/termsAnalysis=20=E4=B8=80=E4=BB=BD?= =?UTF-8?q?=EF=BC=88EVAL=5FTEXT.analysis.param/basis/grain=20=E5=BC=95?= =?UTF-8?q?=E7=94=A8=20ANALYSIS=5FTEXT.node=EF=BC=89=EF=BC=8C=E6=A0=BC?= =?UTF-8?q?=E5=AD=90=E7=9A=84=E5=8F=96=E5=80=BC=E8=BD=AC=E6=8D=A2=E4=B9=9F?= =?UTF-8?q?=E7=94=A8=20paramText/paramValue=EF=BC=8C=E6=9C=9F=E6=AC=A1?= =?UTF-8?q?=E6=A0=BC=E4=BF=9D=E7=95=99=20{{=20}}=20=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E5=8F=98=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 参数格:basis、grain 两份下拉带上空着的缺省项(和分析卡同两行),逐行的值列、只比这几对等叫法跟分析卡一致 - test_frontend_eval_unions.py 新增一条:paramSpecs(含 PERIODS)的参数名和服务端各参数模型的交集/并集逐个对得上、评测页引用的是 paramSpecs、三张叫法表引用 ANALYSIS_TEXT.node --- backend/tests/test_frontend_eval_unions.py | 33 +++++++++ frontend/src/lib/termsEval.ts | 14 ++-- frontend/src/pages/evals/CaseEditor.tsx | 86 +++++++++++----------- 3 files changed, 81 insertions(+), 52 deletions(-) diff --git a/backend/tests/test_frontend_eval_unions.py b/backend/tests/test_frontend_eval_unions.py index c4b35465..0ce6a1e0 100644 --- a/backend/tests/test_frontend_eval_unions.py +++ b/backend/tests/test_frontend_eval_unions.py @@ -14,6 +14,10 @@ TYPES_EVAL = ROOT / "frontend" / "src" / "typesEval.ts" TERMS_EVAL = ROOT / "frontend" / "src" / "lib" / "termsEval.ts" #: 分析方法、模式的叫法只有这一份(termsEval 引用它,不另抄) TERMS_ANALYSIS = ROOT / "frontend" / "src" / "lib" / "termsAnalysis.ts" +#: 分析卡的一项摆哪几个参数格(评测页的分析期望和分析卡用同一份) +ANALYSIS_ITEMS = ROOT / "frontend" / "src" / "canvas" / "analysisItems.ts" +#: 评测页题目编辑框(分析期望那一块) +CASE_EDITOR = ROOT / "frontend" / "src" / "pages" / "evals" / "CaseEditor.tsx" _COMMENT = re.compile(r"/\*.*?\*/|//[^\n]*", re.S) @@ -138,3 +142,32 @@ def test_analysis_expectations_follow_the_server(): terms = _source(TERMS_EVAL) assert re.search(r"\n\s*op: ANALYSIS_OP_LABEL\b", terms) and re.search(r"\n\s*mode: ANALYSIS_MODE_LABEL\b", terms), \ "评测页的分析方法、模式叫法要引用 lib/termsAnalysis 的那一份" + + +def test_analysis_param_grids_follow_the_card_and_the_server(): + """分析期望的参数格和分析卡的一项同一份(canvas/analysisItems.paramSpecs):评测页(CaseEditor)引用它,不另抄。 + 参数名对得上服务端各参数模型(tools/builtin/analysis.param_names);叫法只有 lib/termsAnalysis 一份 + (ANALYSIS_TEXT.node.param / basis / grain),评测页(EVAL_TEXT.analysis.param / basis / grain)引用它。""" + from app.eval.judgements import VERDICT_OPS, param_names + + items = _source(ANALYSIS_ITEMS) + grid = re.search(r"export function paramSpecs\(op: AnalysisOp.*?\n\}\n", items, re.S) + assert grid is not None, "analysisItems.ts 里没有 export function paramSpecs" + # 期次三格(period、base、target)写在上面的 PERIODS 常量里,一起算 + periods = re.search(r"const PERIODS: ParamSpec\[\] = \[(.*?)\]\n", items, re.S) + assert periods is not None, "analysisItems.ts 里没有 const PERIODS" + grid_keys = {a or b for a, b in re.findall(r"(?:col|cols|val)\('(\w+)'\)|key: '(\w+)'", + grid.group(0) + periods.group(1))} + # 服务端各参数模型里期望能写的那些(去掉输入、总数核对、下钻、两份查询),格子里一个不少、一个不多 + server = {k for op in ("decompose", *VERDICT_OPS) for k in param_names(op)} + assert grid_keys == server, f"格子和服务端的参数模型对不上:少 {server - grid_keys},多 {grid_keys - server}" + editor = _source(CASE_EDITOR) + assert "paramSpecs(" in editor and "PARAM_FIELDS" not in editor, "评测页的参数格要引用分析卡的那一份(paramSpecs)" + terms = _source(TERMS_EVAL) + for table in ("param", "basis", "grain"): + assert re.search(rf"\n\s*{table}: ANALYSIS_TEXT\.node\.{table}\b", terms), \ + f"评测页的 {table} 叫法要引用 lib/termsAnalysis 的那一份" + analysis = _source(TERMS_ANALYSIS) + labels = re.search(r"\n\s*param: \{(.*?)\} as Record", analysis, re.S) + assert labels is not None, "termsAnalysis.ts 里没有 ANALYSIS_TEXT.node.param" + assert set(grid_keys) <= set(re.findall(r"(\w+): '", labels.group(1))), "参数格里有叫不出名字的参数" diff --git a/frontend/src/lib/termsEval.ts b/frontend/src/lib/termsEval.ts index 0fccaecf..ccf70e07 100644 --- a/frontend/src/lib/termsEval.ts +++ b/frontend/src/lib/termsEval.ts @@ -10,7 +10,7 @@ import type { EvalTrigger, EvalUpgradePolicy, EvalVerdict, } from '../typesEval' import { formatCost, formatNumber } from './format' -import { ANALYSIS_MODE_LABEL, ANALYSIS_OP_LABEL } from './termsAnalysis' +import { ANALYSIS_MODE_LABEL, ANALYSIS_OP_LABEL, ANALYSIS_TEXT } from './termsAnalysis' /** 比率写成百分数:0.756 → 「75.6%」;没有分母(null)写「—」 */ export function pct(rate: number | null | undefined, digits = 1): string { @@ -336,14 +336,10 @@ export const EVAL_TEXT = { ratio: (numerator: string, denominator: string) => `${numerator} ÷ ${denominator}`, /** 答案里同类的分析没有做成 */ refused: (reason: string) => `没有做成:${reason}`, - param: { - by: '维度列', period: '期次列', base: '基期', target: '本期', measure: '度量列', numerator: '分子列', denominator: '分母列', - product: '乘积列', factors: '因子列', label: '度量的叫法', basis: '比较方式', grain: '粒度', lag: '和往前第几期比', - pairs: '比哪几对', reference: '对照组', mean: '均值列', sd: '标准差列', sum: '合计列', sumsq: '平方和列', n: '样本数列', - value: '逐行的值列', - } as Record, - basis: { change: '和比较期比的变化率', level: '值本身' } as Record, - grain: { day: '天', week: '周', month: '月', quarter: '季度' } as Record, + /** 参数格的叫法只有 lib/termsAnalysis 一份(ANALYSIS_TEXT.node.param / basis / grain):分析卡和评测页的分析期望按同一份参数名 */ + param: ANALYSIS_TEXT.node.param, + basis: ANALYSIS_TEXT.node.basis, + grain: ANALYSIS_TEXT.node.grain, }, upgrade: { title: (seq: number) => `第 ${seq} 题:处置口径升版`, diff --git a/frontend/src/pages/evals/CaseEditor.tsx b/frontend/src/pages/evals/CaseEditor.tsx index b2f7bb4c..62f25e31 100644 --- a/frontend/src/pages/evals/CaseEditor.tsx +++ b/frontend/src/pages/evals/CaseEditor.tsx @@ -7,6 +7,8 @@ import { Field, Modal, Notice, Spinner, toast, useRadioGroup } from '../../compo import { errorMessage } from '../../lib/errors' import { formatDateTime, formatTime, shortId } from '../../lib/format' import { EVAL_TEXT } from '../../lib/termsEval' +import { ANALYSIS_TEXT, isAnalysisOp } from '../../lib/termsAnalysis' +import { paramSpecs, paramText, paramValue, type ParamSpec } from '../../canvas/analysisItems' import { useCatalog } from '../../store/catalog' import type { Run } from '../../types' import type { @@ -509,35 +511,19 @@ function SqlPart({ suiteId: _suiteId, sourceId, setSourceId, sql, setSql, previe // 分析期望:贡献、判定 // --------------------------------------------------------------------------- -type ParamKind = 'column' | 'columns' | 'text' | 'int' | 'choice' | 'pairs' -interface ParamField { key: string; kind: ParamKind; choices?: Record } - const A = EVAL_TEXT.analysis /** 判定期望能用的分析(贡献恒为变化拆解)、各自的模式(同服务端 contract.MODES) */ const VERDICT_OPS: EvalAnalysisOp[] = ['baseline', 'trend', 'differ', 'comparable'] const OP_MODES: Partial> = { decompose: ['additive', 'ratio', 'factor'], differ: ['rate', 'mean', 'rows'], } -const col = (key: string): ParamField => ({ key, kind: 'column' }) -const PERIODS: ParamField[] = [col('period'), { key: 'base', kind: 'text' }, { key: 'target', kind: 'text' }] -const LABEL: ParamField = { key: 'label', kind: 'text' } -const GRAIN: ParamField = { key: 'grain', kind: 'choice', choices: A.grain } -const GROUPS: ParamField[] = [{ key: 'by', kind: 'columns' }, { key: 'pairs', kind: 'pairs' }, { key: 'reference', kind: 'text' }] -/** 每种分析(模式)在表单里填哪几个参数:参数名同分析工具(服务端按同一份参数定义校验),叫法在 termsEval */ -const PARAM_FIELDS: Record = { - 'decompose:additive': [{ key: 'by', kind: 'columns' }, ...PERIODS, col('measure'), LABEL], - 'decompose:ratio': [{ key: 'by', kind: 'columns' }, ...PERIODS, col('numerator'), col('denominator'), LABEL], - 'decompose:factor': [...PERIODS, col('product'), { key: 'factors', kind: 'columns' }, LABEL], - baseline: [col('period'), col('measure'), { key: 'target', kind: 'text' }, - { key: 'basis', kind: 'choice', choices: A.basis }, GRAIN, { key: 'lag', kind: 'int' }, LABEL], - trend: [col('period'), col('measure'), GRAIN, LABEL], - 'differ:rate': [...GROUPS, col('numerator'), col('denominator'), LABEL], - 'differ:mean': [...GROUPS, col('mean'), col('sd'), col('sum'), col('sumsq'), col('n'), LABEL], - 'differ:rows': [...GROUPS, col('value'), LABEL], - comparable: [...PERIODS, col('measure'), GRAIN], -} -const fieldsOf = (e: EvalExpectation): ParamField[] => - PARAM_FIELDS[e.op === 'decompose' || e.op === 'differ' ? `${e.op}:${e.mode}` : e.op ?? ''] ?? [] +/** + * 每种分析(模式)在表单里填哪几个参数:和分析卡的一项同一份(canvas/analysisItems.paramSpecs),不另抄。 + * 那张表里本来就没有评测期望不许写的几样——输入(query)、总数核对(headline)、下钻(parent)、两份查询 + * (base_query、target_query):期望的输入只有期望 SQL 这一份快照(服务端 judgements._NOT_IN_EXPECTATION) + */ +const fieldsOf = (e: EvalExpectation): ParamSpec[] => + e.op && isAnalysisOp(e.op) ? paramSpecs(e.op, e.mode) : [] /** 换了分析方法、模式时,只留下新的那种分析还认的参数 */ function keepParams(e: EvalExpectation): EvalExpectation { @@ -607,7 +593,7 @@ function AnalysisPick({ columns, main, setMain }: { )} {fieldsOf(current).map((f) => ( - setParam(f.key, v)} /> ))} {fieldsOf(current).some((f) => f.key === 'base') && ( @@ -619,15 +605,17 @@ function AnalysisPick({ columns, main, setMain }: { ) } -function ParamInput({ field, columns, value, onChange }: { - field: ParamField; columns: string[]; value: unknown; onChange: (v: unknown) => void +function ParamInput({ spec, columns, value, onChange }: { + spec: ParamSpec; columns: string[]; value: unknown; onChange: (v: unknown) => void }) { - const id = `eval-analysis-param-${field.key}` - const label = A.param[field.key] ?? field.key - if (field.kind === 'columns') { + const key = spec.key + const id = `eval-analysis-param-${key}` + const label = A.param[key] ?? key + const text = paramText(spec.kind, value) + if (spec.kind === 'columns') { const picked = Array.isArray(value) ? value.map(String) : [] return ( -
+
{label}
{columns.map((c) => ( @@ -641,34 +629,46 @@ function ParamInput({ field, columns, value, onChange }: {
) } - if (field.kind === 'pairs') { - const text = Array.isArray(value) ? value.map((p) => (Array.isArray(p) ? p.join(',') : String(p))).join('\n') : '' + if (spec.kind === 'pairs') { return ( -