diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 27e32f07..c5740e93 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -44,6 +44,10 @@ import { LedgerSection, ledgerChanged, ledgerDraftOf, ledgerErrors, ledgerLabels, ledgerPayload, type LedgerDraft, } from './settings/LedgerSettings' 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' // 提到模块级:tab 名同时是 URL 的最后一段,两处各写一份迟早对不上。 @@ -697,6 +701,8 @@ function PrefsTab() { const [verifySaves, setVerifySaves] = useState(0) // 口径账本一组(自动按口径出具、每日上限、比对时限、升版后重算几组,pages/settings/LedgerSettings):草稿另放一份,共用保存条 const [ledgerDraft, setLedgerDraft] = useState(null) + // 分析一组(判定用的阈值、次数上限,pages/settings/AnalysisSettings):草稿另放一份,共用保存条 + const [analysisDraft, setAnalysisDraft] = useState(null) const [saving, setSaving] = useState(false) const [savedFlash, setSavedFlash] = useState(0) const [saveError, setSaveError] = useState(null) @@ -791,9 +797,14 @@ function PrefsTab() { const ledgerKeys = values?.ledger ? ledgerChanged(ledgerCur, ledgerSaved) : [] const ledgerErrs = ledgerErrors(ledgerCur, ledgerSaved) 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 dirty = values ? changed.length + verifyKeys.length + ledgerKeys.length : 0 + const dirty = values ? changed.length + verifyKeys.length + ledgerKeys.length + analysisKeys.length : 0 // 外壳唯一的 blocker 在地址(pathname)变化时问:切标签、导航、⌘K、⌥ 数字、后退都算 useLeaveGuard(dirty > 0, () => confirmLeave(dirty)) @@ -818,6 +829,11 @@ function PrefsTab() { setFieldError(null) setLedgerDraft({ ...ledgerCur, ...patch }) } + const editAnalysis = (patch: Partial) => { + setSaveError(null) + setFieldError(null) + setAnalysisDraft({ ...analysisCur, ...patch }) + } /** * 署名只在这台浏览器里,先落本机:它不该跟着后端一起「没存上」。运行默认值 @@ -853,6 +869,8 @@ function PrefsTab() { const verify = verifyKeys.length ? verifyPayload(verifyCur) : null // 口径账本一组同样整组写(LedgerSettings 的 ledgerPayload) const ledger = ledgerKeys.length ? ledgerPayload(ledgerCur) : null + // 分析一组同样整组写(AnalysisSettings 的 analysisPayload) + const analysis = analysisKeys.length ? analysisPayload(analysisCur) : 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, share_links: cur.appsShare } : null - if (runChanged || judge || verify || ledger || catalogDraft || apps) { + if (runChanged || judge || verify || ledger || analysis || catalogDraft || apps) { const run = { ...(values.run ?? {}), default_memory_scope: cur.scope, @@ -874,7 +892,7 @@ function PrefsTab() { } const out = await api.settings.put({ ...(runChanged ? { run } : {}), ...(judge ? { judge } : {}), ...(verify ? { verify } : {}), - ...(ledger ? { ledger } : {}), + ...(ledger ? { ledger } : {}), ...(analysis ? { analysis } : {}), ...(catalogDraft ? { catalog_draft: catalogDraft } : {}), ...(apps ? { apps } : {}), }) setValues((v) => ({ @@ -883,6 +901,7 @@ function PrefsTab() { ...(judge ? { judge: out?.judge ?? judge } : {}), ...(verify ? { verify: out?.verify ?? verify } : {}), ...(ledger ? { ledger: out?.ledger ?? ledger } : {}), + ...(analysis ? { analysis: out?.analysis ?? analysis } : {}), ...(catalogDraft ? { catalog_draft: out?.catalog_draft ?? catalogDraft } : {}), ...(apps ? { apps: out?.apps ?? apps } : {}), })) @@ -893,6 +912,7 @@ function PrefsTab() { setDraft(null) setVerifyDraft(null) setLedgerDraft(null) + setAnalysisDraft(null) setSavedFlash(Date.now()) } catch (e) { // 署名已经存上了:留在草稿里的只剩运行默认值,条上的计数跟着变少 @@ -1209,6 +1229,8 @@ function PrefsTab() { {values.ledger && } + {values.analysis && } + {values.catalog_draft && ( @@ -1273,7 +1295,7 @@ function PrefsTab() { draftProvider: DRAFT_MODEL_SETTING_TEXT.provider, draftModel: DRAFT_MODEL_SETTING_TEXT.model, appsDaily: APP_SETTING_TEXT.onDemandLimit, appsPersonal: APP_SETTING_TEXT.personalApps, appsShare: APP_SETTING_TEXT.shareLinks, - })[k]).concat(verifyLabels(verifyKeys), ledgerLabels(ledgerKeys)).join('、')} + })[k]).concat(verifyLabels(verifyKeys), ledgerLabels(ledgerKeys), analysisLabels(analysisKeys)).join('、')} {saveError != null && ( @@ -1284,13 +1306,17 @@ function PrefsTab() { )} + onClick={() => { + setDraft(null); setVerifyDraft(null); setLedgerDraft(null); setAnalysisDraft(null); setSaveError(null) + setFieldError(null) + }}>放弃 diff --git a/frontend/src/pages/settings/AnalysisSettings.tsx b/frontend/src/pages/settings/AnalysisSettings.tsx new file mode 100644 index 00000000..0e65f67d --- /dev/null +++ b/frontend/src/pages/settings/AnalysisSettings.tsx @@ -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 + +const isRecord = (v: unknown): v is Record => !!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> { + const out: Partial> = {} + 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 { + 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> + onEdit: (patch: Partial) => void +}) { + return ( +
+ +
+ {ANALYSIS_SETTING_GROUPS.map((group) => ( +
+
{group.title}
+
+ {group.keys.map((k) => ( + + {(p) => ( + onEdit({ [k]: e.target.value } as Partial)} /> + )} + + ))} +
+
+ ))} +
+
+ ) +} diff --git a/scripts/check-manage.mjs b/scripts/check-manage.mjs index e9514a80..7986ead6 100644 --- a/scripts/check-manage.mjs +++ b/scripts/check-manage.mjs @@ -1135,6 +1135,87 @@ await section('设置 · 证据裁判', async () => { 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 () => { // 上传的表格按来源标记认(origin),不按路径:上传库的路径随版本变 const dbs = sources.filter((s) => s.origin !== 'upload')