合入第 2 波复核修复 S4、S9:评测页分析期望的参数格改用分析卡那一份(差异检验的比率、均值模式补上期次三格),参数叫法只留 termsAnalysis 一份;分析测试的「今天」按题集的时区取,不依赖本机时区

This commit is contained in:
ThearchyHelios
2026-10-21 15:02:04 +00:00
6 changed files with 94 additions and 60 deletions
+3 -2
View File
@@ -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}
+7 -4
View File
@@ -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"]
+3 -2
View File
@@ -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"] == []
@@ -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<string, string>", 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))), "参数格里有叫不出名字的参数"
+5 -9
View File
@@ -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<string, string>,
basis: { change: '和比较期比的变化率', level: '值本身' } as Record<string, string>,
grain: { day: '天', week: '周', month: '月', quarter: '季度' } as Record<string, string>,
/** 参数格的叫法只有 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} 题:处置口径升版`,
+43 -43
View File
@@ -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<string, string> }
const A = EVAL_TEXT.analysis
/** 判定期望能用的分析(贡献恒为变化拆解)、各自的模式(同服务端 contract.MODES) */
const VERDICT_OPS: EvalAnalysisOp[] = ['baseline', 'trend', 'differ', 'comparable']
const OP_MODES: Partial<Record<EvalAnalysisOp, EvalAnalysisMode[]>> = {
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<string, ParamField[]> = {
'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 }: {
</Field>
)}
{fieldsOf(current).map((f) => (
<ParamInput key={`${current.op}:${current.mode}:${f.key}`} field={f} columns={columns}
<ParamInput key={`${current.op}:${current.mode}:${f.key}`} spec={f} columns={columns}
value={(current.params ?? {})[f.key]} onChange={(v) => 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 (
<div className="sm:col-span-2" data-eval-analysis-param={field.key}>
<div className="sm:col-span-2" data-eval-analysis-param={key}>
<div className="label">{label}</div>
<div className="flex flex-wrap gap-2">
{columns.map((c) => (
@@ -641,34 +629,46 @@ function ParamInput({ field, columns, value, onChange }: {
</div>
)
}
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 (
<Field label={label} htmlFor={id} hint={T.analysisPairsHint} className="sm:col-span-2">
<textarea id={id} className="field min-h-12 text-xs" defaultValue={text} data-eval-analysis-param={field.key}
onChange={(e) => onChange(e.target.value.split('\n').map((line) => line.split(/[,,、]/).map((x) => x.trim())
.filter(Boolean)).filter((pair) => pair.length))} />
<textarea id={id} className="field min-h-12 text-xs" defaultValue={text} data-eval-analysis-param={key}
onChange={(e) => onChange(paramValue('pairs', e.target.value))} />
</Field>
)
}
if (field.kind === 'column' || field.kind === 'choice') {
const options = field.kind === 'column' ? Object.fromEntries(columns.map((c) => [c, c])) : field.choices ?? {}
if (spec.kind === 'basis' || spec.kind === 'grain') {
// 和分析卡同两份下拉:空着是缺省(比较方式缺省「和比较期比的变化率」、粒度按期的写法推断),其余每种各有叫法
const opts = spec.kind === 'basis' ? A.basis : A.grain
return (
<Field label={label} htmlFor={id}>
<select id={id} className="field" value={value == null ? '' : String(value)} data-eval-analysis-param={field.key}
<select id={id} className="field" value={text} data-eval-analysis-param={key}
onChange={(e) => onChange(e.target.value)}>
<option value="">{field.kind === 'column' ? T.analysisColumn : T.analysisNone}</option>
{Object.entries(opts).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</Field>
)
}
if (spec.kind === 'column') {
const options = Object.fromEntries(columns.map((c) => [c, c]))
return (
<Field label={label} htmlFor={id}>
<select id={id} className="field" value={text} data-eval-analysis-param={key}
onChange={(e) => onChange(e.target.value)}>
<option value="">{T.analysisColumn}</option>
{Object.entries(options).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</Field>
)
}
// 期次、对照组、叫法可以写 {{ schedule.* }} 这类时间变量;整数的格子写了不是整数的字照原样交给服务端校验
return (
<Field label={label} htmlFor={id}>
<input id={id} className={clsx('field', field.kind === 'int' && 'tnum w-24')} value={value == null ? '' : String(value)}
inputMode={field.kind === 'int' ? 'numeric' : undefined} placeholder={field.kind === 'text' ? T.analysisNone : undefined}
data-eval-analysis-param={field.key}
onChange={(e) => onChange(field.kind === 'int' ? (e.target.value.trim() ? Number(e.target.value) : '') : e.target.value)} />
<input id={id} className={clsx('field', spec.kind === 'int' && 'tnum w-24')} value={text}
inputMode={spec.kind === 'int' ? 'numeric' : undefined}
placeholder={ANALYSIS_TEXT.node.paramPlaceholder[key]}
data-eval-analysis-param={key}
onChange={(e) => onChange(paramValue(spec.kind, e.target.value))} />
</Field>
)
}