设置「偏好设置」加「分析」一节(服务端 analysis 一组):判定用的阈值、Agent 每次执行最多分析几次、最多下钻几层、列出前几段,分成变化拆解、波动基线与趋势判断、差异检验、次数与层数四组。照口径账本一节的写法:草稿由偏好页持有、和其余设置共用一条保存条,存的时候整组写 analysis(没改的照原值带上,不被冲回缺省);叫法、范围照契约 LABELS / RANGES(每项下面写范围,类型、范围当场按契约的说法说,保存键不能点),两两之间的大小交给服务端核、422 的原话写在保存条上;服务端把 2 存成 2.0、写「2.0」不算改;老服务端没有这一组时不显示。check-manage 加一节:十七项的叫法都是契约的、显示生效的值、超范围和整数项、保存条的叫法、整组写、服务端拒收、改回原值、老服务端

This commit is contained in:
ThearchyHelios
2026-10-21 18:20:10 +08:00
parent 78800511e0
commit 84e6c2e13f
3 changed files with 198 additions and 7 deletions
+33 -7
View File
@@ -44,6 +44,10 @@ import {
LedgerSection, ledgerChanged, ledgerDraftOf, ledgerErrors, ledgerLabels, ledgerPayload, type LedgerDraft, LedgerSection, ledgerChanged, ledgerDraftOf, ledgerErrors, ledgerLabels, ledgerPayload, type LedgerDraft,
} from './settings/LedgerSettings' } from './settings/LedgerSettings'
import { LEDGER_UI } from '../lib/termsLedger' import { LEDGER_UI } from '../lib/termsLedger'
import {
AnalysisSection, analysisChanged, analysisDraftOf, analysisErrors, analysisLabels, analysisPayload, type AnalysisDraft,
} from './settings/AnalysisSettings'
import { ANALYSIS_TEXT } from '../lib/termsAnalysis'
import { ACCESS_KEY_TEXT } from '../lib/termsApp' import { ACCESS_KEY_TEXT } from '../lib/termsApp'
// 提到模块级:tab 名同时是 URL 的最后一段,两处各写一份迟早对不上。 // 提到模块级:tab 名同时是 URL 的最后一段,两处各写一份迟早对不上。
@@ -697,6 +701,8 @@ function PrefsTab() {
const [verifySaves, setVerifySaves] = useState(0) const [verifySaves, setVerifySaves] = useState(0)
// 口径账本一组(自动按口径出具、每日上限、比对时限、升版后重算几组,pages/settings/LedgerSettings):草稿另放一份,共用保存条 // 口径账本一组(自动按口径出具、每日上限、比对时限、升版后重算几组,pages/settings/LedgerSettings):草稿另放一份,共用保存条
const [ledgerDraft, setLedgerDraft] = useState<LedgerDraft | null>(null) const [ledgerDraft, setLedgerDraft] = useState<LedgerDraft | null>(null)
// 分析一组(判定用的阈值、次数上限,pages/settings/AnalysisSettings):草稿另放一份,共用保存条
const [analysisDraft, setAnalysisDraft] = useState<AnalysisDraft | null>(null)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [savedFlash, setSavedFlash] = useState(0) const [savedFlash, setSavedFlash] = useState(0)
const [saveError, setSaveError] = useState<unknown>(null) const [saveError, setSaveError] = useState<unknown>(null)
@@ -791,9 +797,14 @@ function PrefsTab() {
const ledgerKeys = values?.ledger ? ledgerChanged(ledgerCur, ledgerSaved) : [] const ledgerKeys = values?.ledger ? ledgerChanged(ledgerCur, ledgerSaved) : []
const ledgerErrs = ledgerErrors(ledgerCur, ledgerSaved) const ledgerErrs = ledgerErrors(ledgerCur, ledgerSaved)
const ledgerInvalid = Object.values(ledgerErrs).some(Boolean) const ledgerInvalid = Object.values(ledgerErrs).some(Boolean)
const invalid = guardInvalid || judgeInvalid || verifyInvalid || ledgerInvalid || !!appsError const analysisSaved = analysisDraftOf(values?.analysis)
const analysisCur = analysisDraft ?? analysisSaved
const analysisKeys = values?.analysis ? analysisChanged(analysisCur, analysisSaved) : []
const analysisErrs = analysisErrors(analysisCur, analysisSaved)
const analysisInvalid = Object.values(analysisErrs).some(Boolean)
const invalid = guardInvalid || judgeInvalid || verifyInvalid || ledgerInvalid || analysisInvalid || !!appsError
const changed = (Object.keys(saved) as (keyof typeof saved)[]).filter((k) => cur[k] !== saved[k]) const changed = (Object.keys(saved) as (keyof typeof saved)[]).filter((k) => cur[k] !== saved[k])
const dirty = values ? changed.length + verifyKeys.length + ledgerKeys.length : 0 const dirty = values ? changed.length + verifyKeys.length + ledgerKeys.length + analysisKeys.length : 0
// 外壳唯一的 blocker 在地址(pathname)变化时问:切标签、导航、⌘K、⌥ 数字、后退都算 // 外壳唯一的 blocker 在地址(pathname)变化时问:切标签、导航、⌘K、⌥ 数字、后退都算
useLeaveGuard(dirty > 0, () => confirmLeave(dirty)) useLeaveGuard(dirty > 0, () => confirmLeave(dirty))
@@ -818,6 +829,11 @@ function PrefsTab() {
setFieldError(null) setFieldError(null)
setLedgerDraft({ ...ledgerCur, ...patch }) setLedgerDraft({ ...ledgerCur, ...patch })
} }
const editAnalysis = (patch: Partial<AnalysisDraft>) => {
setSaveError(null)
setFieldError(null)
setAnalysisDraft({ ...analysisCur, ...patch })
}
/** /**
* 署名只在这台浏览器里,先落本机:它不该跟着后端一起「没存上」。运行默认值 * 署名只在这台浏览器里,先落本机:它不该跟着后端一起「没存上」。运行默认值
@@ -853,6 +869,8 @@ function PrefsTab() {
const verify = verifyKeys.length ? verifyPayload(verifyCur) : null const verify = verifyKeys.length ? verifyPayload(verifyCur) : null
// 口径账本一组同样整组写(LedgerSettings 的 ledgerPayload) // 口径账本一组同样整组写(LedgerSettings 的 ledgerPayload)
const ledger = ledgerKeys.length ? ledgerPayload(ledgerCur) : null const ledger = ledgerKeys.length ? ledgerPayload(ledgerCur) : null
// 分析一组同样整组写(AnalysisSettings 的 analysisPayload)
const analysis = analysisKeys.length ? analysisPayload(analysisCur) : null
// 起草模型一组:两项一起写,空着的存 null(跟随助手的模型) // 起草模型一组:两项一起写,空着的存 null(跟随助手的模型)
const catalogDraft = draftChanged ? { provider: cur.draftProvider || null, model: cur.draftModel.trim() || null } : null const catalogDraft = draftChanged ? { provider: cur.draftProvider || null, model: cur.draftModel.trim() || null } : null
// 应用一组同样整组写:别的键(以后加的)照原样带回去 // 应用一组同样整组写:别的键(以后加的)照原样带回去
@@ -860,7 +878,7 @@ function PrefsTab() {
? { ...(values.apps ?? {}), on_demand_daily_limit: Number(cur.appsDaily.trim()), user_personal_apps: cur.appsPersonal, ? { ...(values.apps ?? {}), on_demand_daily_limit: Number(cur.appsDaily.trim()), user_personal_apps: cur.appsPersonal,
share_links: cur.appsShare } share_links: cur.appsShare }
: null : null
if (runChanged || judge || verify || ledger || catalogDraft || apps) { if (runChanged || judge || verify || ledger || analysis || catalogDraft || apps) {
const run = { const run = {
...(values.run ?? {}), ...(values.run ?? {}),
default_memory_scope: cur.scope, default_memory_scope: cur.scope,
@@ -874,7 +892,7 @@ function PrefsTab() {
} }
const out = await api.settings.put({ const out = await api.settings.put({
...(runChanged ? { run } : {}), ...(judge ? { judge } : {}), ...(verify ? { verify } : {}), ...(runChanged ? { run } : {}), ...(judge ? { judge } : {}), ...(verify ? { verify } : {}),
...(ledger ? { ledger } : {}), ...(ledger ? { ledger } : {}), ...(analysis ? { analysis } : {}),
...(catalogDraft ? { catalog_draft: catalogDraft } : {}), ...(apps ? { apps } : {}), ...(catalogDraft ? { catalog_draft: catalogDraft } : {}), ...(apps ? { apps } : {}),
}) })
setValues((v) => ({ setValues((v) => ({
@@ -883,6 +901,7 @@ function PrefsTab() {
...(judge ? { judge: out?.judge ?? judge } : {}), ...(judge ? { judge: out?.judge ?? judge } : {}),
...(verify ? { verify: out?.verify ?? verify } : {}), ...(verify ? { verify: out?.verify ?? verify } : {}),
...(ledger ? { ledger: out?.ledger ?? ledger } : {}), ...(ledger ? { ledger: out?.ledger ?? ledger } : {}),
...(analysis ? { analysis: out?.analysis ?? analysis } : {}),
...(catalogDraft ? { catalog_draft: out?.catalog_draft ?? catalogDraft } : {}), ...(catalogDraft ? { catalog_draft: out?.catalog_draft ?? catalogDraft } : {}),
...(apps ? { apps: out?.apps ?? apps } : {}), ...(apps ? { apps: out?.apps ?? apps } : {}),
})) }))
@@ -893,6 +912,7 @@ function PrefsTab() {
setDraft(null) setDraft(null)
setVerifyDraft(null) setVerifyDraft(null)
setLedgerDraft(null) setLedgerDraft(null)
setAnalysisDraft(null)
setSavedFlash(Date.now()) setSavedFlash(Date.now())
} catch (e) { } catch (e) {
// 署名已经存上了:留在草稿里的只剩运行默认值,条上的计数跟着变少 // 署名已经存上了:留在草稿里的只剩运行默认值,条上的计数跟着变少
@@ -1209,6 +1229,8 @@ function PrefsTab() {
{values.ledger && <LedgerSection cur={ledgerCur} errors={ledgerErrs} onEdit={editLedger} />} {values.ledger && <LedgerSection cur={ledgerCur} errors={ledgerErrs} onEdit={editLedger} />}
{values.analysis && <AnalysisSection cur={analysisCur} errors={analysisErrs} onEdit={editAnalysis} />}
{values.catalog_draft && ( {values.catalog_draft && (
<DraftModelSection provider={cur.draftProvider} model={cur.draftModel} onEdit={edit} <DraftModelSection provider={cur.draftProvider} model={cur.draftModel} onEdit={edit}
error={fieldError?.field === 'catalog_draft.provider' ? fieldError.message : undefined} /> error={fieldError?.field === 'catalog_draft.provider' ? fieldError.message : undefined} />
@@ -1273,7 +1295,7 @@ function PrefsTab() {
draftProvider: DRAFT_MODEL_SETTING_TEXT.provider, draftModel: DRAFT_MODEL_SETTING_TEXT.model, draftProvider: DRAFT_MODEL_SETTING_TEXT.provider, draftModel: DRAFT_MODEL_SETTING_TEXT.model,
appsDaily: APP_SETTING_TEXT.onDemandLimit, appsPersonal: APP_SETTING_TEXT.personalApps, appsDaily: APP_SETTING_TEXT.onDemandLimit, appsPersonal: APP_SETTING_TEXT.personalApps,
appsShare: APP_SETTING_TEXT.shareLinks, appsShare: APP_SETTING_TEXT.shareLinks,
})[k]).concat(verifyLabels(verifyKeys), ledgerLabels(ledgerKeys)).join('、')} })[k]).concat(verifyLabels(verifyKeys), ledgerLabels(ledgerKeys), analysisLabels(analysisKeys)).join('、')}
</span> </span>
</span> </span>
{saveError != null && ( {saveError != null && (
@@ -1284,13 +1306,17 @@ function PrefsTab() {
)} )}
<span className="flex-1" /> <span className="flex-1" />
<button className="btn btn-sm btn-ghost" disabled={saving} <button className="btn btn-sm btn-ghost" disabled={saving}
onClick={() => { setDraft(null); setVerifyDraft(null); setLedgerDraft(null); setSaveError(null); setFieldError(null) }}>放弃</button> onClick={() => {
setDraft(null); setVerifyDraft(null); setLedgerDraft(null); setAnalysisDraft(null); setSaveError(null)
setFieldError(null)
}}>放弃</button>
<button className="btn btn-sm btn-primary" disabled={saving || invalid} <button className="btn btn-sm btn-primary" disabled={saving || invalid}
title={guardInvalid ? '护栏设置中有无效值,请修正后再保存' title={guardInvalid ? '护栏设置中有无效值,请修正后再保存'
: judgeInvalid ? '证据裁判的上限有无效值,请修正后再保存' : judgeInvalid ? '证据裁判的上限有无效值,请修正后再保存'
: appsError ? APP_SETTING_TEXT.onDemandLimitInvalid : appsError ? APP_SETTING_TEXT.onDemandLimitInvalid
: verifyInvalid ? VERIFY_SETTING_TEXT.section.saveInvalid : verifyInvalid ? VERIFY_SETTING_TEXT.section.saveInvalid
: ledgerInvalid ? LEDGER_UI.settings.saveInvalid : undefined} : ledgerInvalid ? LEDGER_UI.settings.saveInvalid
: analysisInvalid ? ANALYSIS_TEXT.settings.invalid : undefined}
onClick={() => void save()}> onClick={() => void save()}>
{saving ? <Spinner size={11} /> : <Check size={12} aria-hidden />} 保存设置 {saving ? <Spinner size={11} /> : <Check size={12} aria-hidden />} 保存设置
</button> </button>
@@ -0,0 +1,84 @@
import { Field, SectionBar } from '../../components/ui'
import {
ANALYSIS_SETTING_GROUPS, ANALYSIS_SETTING_KEYS, ANALYSIS_SETTING_LABEL, ANALYSIS_TEXT, settingRangeText, settingValueProblem,
type AnalysisSettingKey,
} from '../../lib/termsAnalysis'
/*
* 设置页「偏好设置」里「分析」一节(服务端 api/settings.py 的 analysis 一组,docs/design/analysis-round/01-算子与判定.md §10):
* 判定用的阈值(「主要来源」的占比、「超出常规波动」的倍数……)、Agent 每次执行最多分析几次、最多下钻几层、列出前几段。
*
* 照 LedgerSettings.tsx 的写法:草稿由 PrefsTab 持有、和其余设置共用一条保存条(这一节只画、只报改动),存的时候整组写
* analysis——服务端对没给的项回落到缺省,只发改过的那一项会把别的项冲回缺省。字段名、范围都照服务端 analysis/contract.py
* (LABELS、RANGES,lib/termsAnalysis 一字不差地抄着,check-manage 逐项比对),422 的报错写的也是这些叫法。两两之间的大小
* (「明显超出」要大于「超出常规波动」、最多用几期不小于至少几期、「集中」不小于「主要来源」)交给服务端核,报错照原话显示。
*/
export type AnalysisDraft = Record<AnalysisSettingKey, string>
const isRecord = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
/** GET /settings 的 analysis 一组(服务端给的是生效的样子,没存过的补了缺省)→ 草稿 */
export function analysisDraftOf(group: unknown): AnalysisDraft {
const g = isRecord(group) ? group : {}
return Object.fromEntries(ANALYSIS_SETTING_KEYS.map((k) => {
const v = g[k]
return [k, typeof v === 'number' && Number.isFinite(v) ? String(v) : '']
})) as AnalysisDraft
}
/** 改过的项:字不同、而且不是同一个数(服务端把 2 存成 2.0,写「2.0」不算改) */
export function analysisChanged(cur: AnalysisDraft, saved: AnalysisDraft): AnalysisSettingKey[] {
const same = (a: string, b: string) => a.trim() === b.trim()
|| (a.trim() !== '' && b.trim() !== '' && Number.isFinite(Number(a)) && Number(a) === Number(b))
return ANALYSIS_SETTING_KEYS.filter((k) => !same(cur[k], saved[k]))
}
/** 保存条上这几项的叫法:和字段名、服务端报错同一套 */
export const analysisLabels = (keys: AnalysisSettingKey[]): string[] => keys.map((k) => ANALYSIS_SETTING_LABEL[k])
/** 各项的问题(类型、范围)。只校验改过的:存着的值不合规不该挡住别的设置保存 */
export function analysisErrors(cur: AnalysisDraft, saved: AnalysisDraft): Partial<Record<AnalysisSettingKey, string>> {
const out: Partial<Record<AnalysisSettingKey, string>> = {}
for (const k of analysisChanged(cur, saved)) {
const problem = settingValueProblem(k, cur[k])
if (problem) out[k] = problem
}
return out
}
/** 草稿 → 存进去的整组 analysis */
export function analysisPayload(cur: AnalysisDraft): Record<string, number> {
return Object.fromEntries(ANALYSIS_SETTING_KEYS.map((k) => [k, Number(cur[k].trim())]))
}
const id = (k: AnalysisSettingKey) => `pref-analysis-${k.replace(/_/g, '-')}`
export function AnalysisSection({ cur, errors, onEdit }: {
cur: AnalysisDraft
errors: Partial<Record<AnalysisSettingKey, string>>
onEdit: (patch: Partial<AnalysisDraft>) => void
}) {
return (
<section data-analysis-settings="">
<SectionBar title={ANALYSIS_TEXT.settings.title} hint={ANALYSIS_TEXT.settings.hint} />
<div className="space-y-4">
{ANALYSIS_SETTING_GROUPS.map((group) => (
<div key={group.title} data-analysis-settings-group={group.title}>
<div className="mb-1.5 text-2xs font-medium text-dim">{group.title}</div>
<div className="grid grid-cols-2 gap-3 max-sm:grid-cols-1">
{group.keys.map((k) => (
<Field key={k} label={ANALYSIS_SETTING_LABEL[k]} htmlFor={id(k)} error={errors[k]} hint={settingRangeText(k)}>
{(p) => (
<input {...p} className="field tnum max-w-40" inputMode="decimal" value={cur[k]} data-analysis-setting={k}
onChange={(e) => onEdit({ [k]: e.target.value } as Partial<AnalysisDraft>)} />
)}
</Field>
))}
</div>
</div>
))}
</div>
</section>
)
}
+81
View File
@@ -1135,6 +1135,87 @@ await section('设置 · 证据裁判', async () => {
await old.close() await old.close()
}) })
await section('设置 · 分析(判定用的阈值、次数上限:叫法同契约,整组写 analysis,值不合规不让存,服务端的报错照原话显示)', async () => {
// 读的是真后端补齐缺省之后的一组(有就用它),写走假的一份
let stored = await get('/settings')
if (!stored.analysis) {
check('服务端的设置里有 analysis 一组(分析能力一轮)', false, Object.keys(stored).join(','))
return
}
const saved = { ...stored.analysis }
let reject = null
const { page, sent, errors, close } = await open('/settings/prefs', {
handlers: [
[/^GET \/settings$/, (route) => json(stored)(route)],
[/^PUT \/settings$/, (route, { body }) => {
if (reject) return json({ detail: reject }, 422)(route)
stored = { ...stored, ...body.values }
return json(stored)(route)
}],
],
})
const sec = page.locator('[data-analysis-settings]')
await sec.waitFor({ timeout: 8000 }).catch(() => {})
const py = readFileSync(new URL('../backend/app/analysis/contract.py', import.meta.url), 'utf8')
const labels = [...(py.match(/^LABELS: dict\[str, str\] = \{([\s\S]*?)^\}/m)?.[1] ?? '').matchAll(/"([^"]+)":\s*"([^"]+)"/g)]
const text = await sec.innerText().catch(() => '')
check('这一节在偏好设置里,十七项的叫法都是契约 LABELS 的', labels.length === 17 && labels.every(([, , label]) => text.includes(label)),
labels.filter(([, , label]) => !text.includes(label)).map(([, k]) => k).join('、'))
check('……分成四组:变化拆解、波动基线与趋势判断、差异检验、次数与层数',
(await sec.locator('[data-analysis-settings-group]').evaluateAll((els) => els.map((e) => e.dataset.analysisSettingsGroup))).join('、')
=== '变化拆解、波动基线与趋势判断、差异检验、次数与层数')
const val = (k) => page.locator(`[data-analysis-setting="${k}"]`).inputValue()
check('显示的是服务端生效的值', await val('main_share') === String(saved.main_share) && await val('min_history') === String(saved.min_history)
&& await val('max_calls') === String(saved.max_calls), `${await val('main_share')} / ${await val('min_history')} / ${await val('max_calls')}`)
check('……每项下面写着范围(同契约 RANGES)', text.includes('0.3 到 0.9 之间') && text.includes('4 到 104 之间的整数'))
await page.locator('[data-analysis-setting="main_share"]').fill('0.95')
const bar = page.locator('[data-prefs-bar]')
await bar.waitFor({ timeout: 3000 }).catch(() => {})
check('超出范围:当场说(说法同服务端),保存键不能点', (await sec.innerText()).includes('「主要来源」的占比要在 0.3 到 0.9 之间')
&& await bar.getByRole('button', { name: '保存设置' }).isDisabled())
await page.locator('[data-analysis-setting="min_history"]').fill('9.5')
check('整数项填了小数:说要填整数', (await sec.innerText()).includes('至少几期历史要填整数'))
await page.locator('[data-analysis-setting="main_share"]').fill('0.6')
await page.locator('[data-analysis-setting="min_history"]').fill('10')
const barText = await bar.innerText().catch(() => '')
check('保存条写改了哪几项(契约的叫法)', barText.includes('有 2 项未保存') && barText.includes('「主要来源」的占比')
&& barText.includes('至少几期历史'), barText.replace(/\s+/g, ' '))
await bar.getByRole('button', { name: '保存设置' }).click()
await page.waitForTimeout(500)
const put = sent.filter((x) => x.key === 'PUT /settings').at(-1)?.body?.values ?? {}
const a = put.analysis ?? {}
check('整组写 analysis(十七项都在,没改的照原值、写成数)', Object.keys(a).length === 17 && a.main_share === 0.6 && a.min_history === 10
&& a.k_warn === saved.k_warn && a.table_top_k === saved.table_top_k, JSON.stringify(a).slice(0, 160))
check('……只改了分析:不碰别的组', Object.keys(put).join(',') === 'analysis', Object.keys(put).join(','))
check('……存完显示「已保存」', (await bar.innerText().catch(() => '')).includes('已保存'))
// 两两之间的大小交给服务端核:「明显超出」不大于「超出常规波动」时 422,原话写在保存条上,草稿留着
reject = '「明显超出」的倍数要大于「超出常规波动」的倍数'
await page.locator('[data-analysis-setting="k_alert"]').fill('1.5')
await bar.getByRole('button', { name: '保存设置' }).click()
await page.waitForTimeout(500)
const failed = await bar.innerText().catch(() => '')
check('服务端拒收:保存条照原话写为什么,草稿还在', failed.includes(reject) && await val('k_alert') === '1.5', failed.replace(/\s+/g, ' '))
reject = null
await page.locator('[data-analysis-setting="k_alert"]').fill(String(stored.analysis.k_alert))
check('改回原值:不再算改动', !(await bar.innerText().catch(() => '')).includes('未保存'))
if (SHOTS) {
await sec.scrollIntoViewIfNeeded()
await page.waitForTimeout(200)
await sec.screenshot({ path: `${SHOTS}/prefs-analysis-${THEME}.png` })
}
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await close()
// 老后端没有 analysis 这一组:不显示,也不写
const legacy = { ...stored }
delete legacy.analysis
const old = await open('/settings/prefs', { handlers: [[/^GET \/settings$/, (route) => json(legacy)(route)]] })
check('老后端没有分析设置:这一组不出现', await old.page.locator('[data-analysis-settings]').count() === 0)
await old.close()
})
await section('数据 · 数据库', async () => { await section('数据 · 数据库', async () => {
// 上传的表格按来源标记认(origin),不按路径:上传库的路径随版本变 // 上传的表格按来源标记认(origin),不按路径:上传库的路径随版本变
const dbs = sources.filter((s) => s.origin !== 'upload') const dbs = sources.filter((s) => s.origin !== 'upload')