diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 8ea7b9fb..040c2282 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -3782,6 +3782,12 @@ export const DUAL_CHECK_TEXT = { } as Record, limitAny: '上限', limitDailyNext: '今日核查费用已达上限。请在「设置 → 偏好设置 → 核查」中调高每日金额上限,或等到次日再运行', + /** + * 单次运行时限将至(日志带 cause: run_deadline,服务端 engine/deadline.py):一段执行(两次人工审批之间)有单次运行 + * 时限,核查节点为后续节点预留时间,剩下的时间不够就不再开始新的一项,开始了的按剩余时间缩短时长。双盲复算、反方审稿 + * 这几行共用这句副标题,seconds 是单次运行时限(设置页「运行环境」里的叫法) + */ + deadlineSub: (seconds: number) => `单次运行时限为 ${formatNumber(seconds)} 秒,已为后续节点预留时间`, log: { skippedDisabled: '双盲复算未启用,已跳过', skippedDisabledNext: '如需复算,请在节点的「启用双盲复算」中选择「开启」,或在「设置 → 偏好设置 → 核查」中启用双盲复算', @@ -3823,6 +3829,14 @@ export const DUAL_CHECK_TEXT = { unchecked: (n?: number) => (n ? `双盲复算有 ${formatNumber(n)} 个指标无法复算,已记为缺口` : '双盲复算有指标无法复算,已记为缺口'), uncheckedNext: '无法复算的原因见展开内容。来自合并查询、遮罩列的输入无法单独复算;盲算触顶、出错的,可调高「设置 → 偏好设置 → 核查」中的上限后重新运行', + /** + * dual_check_unchecked 带 cause: run_deadline:单次运行时限将至,没来得及盲算、缩短时长后到时没算完的指标(服务端 + * 另起这一条,原因都一样,副标题只点名)。调高每项的上限只会更快撞线,检查模型、数据源也无济于事:下一步说怎么腾出时间 + */ + deadline: (n?: number) => (n ? `运行时限将至,双盲复算有 ${formatNumber(n)} 个指标未能复算,已记为缺口` + : '运行时限将至,双盲复算有指标未能复算,已记为缺口'), + deadlineNext: '如需全部复算,可在节点的「复算的指标」中减少指标,或在双盲复算前加人工审批(时限按审批前后分段计算);' + + '确需更长时间,请联系管理员调大单次运行时限(AGENTLAB_MAX_RUN_SECONDS)', }, /** * 出具缺口里双盲复算、反方审稿报上来的那几条(缺口以「双盲复算「节点名」:」「反方审稿:」开头):时间线、成果卡片、 @@ -3980,6 +3994,19 @@ export const CHALLENGE_TEXT = { unreviewed: (n?: number) => (n ? `反方审稿有 ${formatNumber(n)} 句结论未能反查,已记为无法判断` : '反方审稿有结论未能反查,已记为无法判断'), unreviewedNext: '原因见展开内容。请检查核查模型和数据源是否可用,修正后重新运行;报告未引用数据库查询的结论无法反查', + /** + * 带 cause: run_deadline 的两条(服务端 engine/deadline.py):单次运行时限将至,反方审稿为后续节点预留时间。 + * challenge_unreviewed 是剩下的句子不再反查;challenge_limit 是开始了的句子按剩余时间缩短了反查时长、到时按查到的结果 + * 收尾(n:这样的句数,skipped:其中未能给出判断的)。调高每句的上限只会更快撞线,检查模型、数据源也无济于事 + */ + deadline: (n?: number) => (n ? `运行时限将至,反方审稿有 ${formatNumber(n)} 句结论未反查,已记为无法判断` + : '运行时限将至,反方审稿有结论未反查,已记为无法判断'), + deadlineLimit: (n?: number, skipped?: number) => { + const head = n ? `运行时限将至,反方审稿缩短了 ${formatNumber(n)} 句结论的反查时长` : '运行时限将至,反方审稿缩短了反查时长' + return skipped ? `${head},其中 ${formatNumber(skipped)} 句未能给出判断` : `${head},已按查到的结果给出判断` + }, + deadlineNext: '如需全部审完,可在节点或「设置 → 偏好设置 → 核查」中减少每份报告审的句数,或在反方审稿前加人工审批' + + '(时限按审批前后分段计算);确需更长时间,请联系管理员调大单次运行时限(AGENTLAB_MAX_RUN_SECONDS)', /** challenge_sources_lost:反查了,但该查的数据源有停用了的,判断不含它们的数据 */ sourcesLost: (n?: number) => (n ? `反方审稿有 ${formatNumber(n)} 句结论只查了部分数据源` : '反方审稿有结论只查了部分数据源'), sourcesLostSub: (names: string[]) => `未能查询:${names.map((x) => `「${x}」`).join('、')}(已不存在或已停用),判断不含其中的数据`, @@ -4043,9 +4070,14 @@ export const VERIFY_SETTING_TEXT = { } as Record, toleranceHint: '两边的值相差不超过这个比例时算一致,例如 0.000001 表示百万分之一,0 表示必须完全相同', unlimited: '不限', - /** 某项设成不限时写明它还受什么约束,不要悄悄生效。by:眼下还设着上限的相关项 */ - unlimitedNote: (kind: 'queries' | 'claims' | 'timeout' | 'daily', by: string[]) => { + /** + * 某项设成不限时写明它还受什么约束,不要悄悄生效。by:眼下还设着上限的相关项。runTimeout 是双盲复算、反方审稿的 + * 时长:它们在运行里执行,一段执行有单次运行时限,时限将至时按剩余时间缩短(服务端 engine/deadline.py),设成不限 + * 也一样;timeout 是审批质询的,不在运行的那一段里执行 + */ + unlimitedNote: (kind: 'queries' | 'claims' | 'timeout' | 'runTimeout' | 'daily', by: string[]) => { if (kind === 'timeout') return '不设上限,时长仅受模型接口自身的超时约束' + if (kind === 'runTimeout') return '不设上限,时长仍受单次运行时限约束,时限将至时按剩余时间缩短' const what = kind === 'queries' ? '查询次数' : kind === 'claims' ? '句数' : '每日核查费用' return by.length ? `不设上限,${what}仅受${by.join('、')}约束` : `不设上限,${what}不受其他上限约束` }, diff --git a/frontend/src/pages/VerifySettings.tsx b/frontend/src/pages/VerifySettings.tsx index e410375c..305ecff2 100644 --- a/frontend/src/pages/VerifySettings.tsx +++ b/frontend/src/pages/VerifySettings.tsx @@ -179,7 +179,8 @@ export function VerifySection({ cur, errors, onEdit, refresh }: { /** 某项设成不限时,它还受哪些眼下设着的上限约束 */ const note = (k: LimitKey): string => { - if (TIMEOUTS.has(k)) return S.unlimitedNote('timeout', []) + // 审批质询不在运行的那一段里执行,只受模型接口的超时约束;双盲复算、反方审稿还受单次运行时限约束 + if (TIMEOUTS.has(k)) return S.unlimitedNote(k === 'inquiry.timeout_s' ? 'timeout' : 'runTimeout', []) if (k === 'challenge.max_claims') return S.unlimitedNote('claims', [S.bound.claims]) if (k === 'daily_max_usd') { const counts = [...COUNTS].some((c) => cur[c] !== null) diff --git a/frontend/src/run/decode.ts b/frontend/src/run/decode.ts index f8799259..ea254aea 100644 --- a/frontend/src/run/decode.ts +++ b/frontend/src/run/decode.ts @@ -804,6 +804,14 @@ const cjkText = (v: unknown): string | undefined => (typeof v === 'string' && /[ /** 个数:给的是数就取整,给的是清单就数一下,别的(缺了、写坏了)当不知道 */ const countOf = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? Math.round(v) : Array.isArray(v) ? v.length : undefined) +/** + * 核查类日志的 cause:单次运行时限将至(服务端 engine/deadline.py)。核查节点为后续节点预留时间,没开始的、缩短时长后 + * 到时没做完的另起一条带上它。这几行不叫人调高核查的上限(只会更快撞线),也不叫人去查模型和数据源 + */ +const RUN_DEADLINE = 'run_deadline' +/** run_seconds(单次运行时限)→ 那句副标题;不是正数(缺了、写坏了)就不写 */ +const deadlineSub = (v: unknown): string | undefined => + (typeof v === 'number' && Number.isFinite(v) && v > 0 ? DC.deadlineSub(v) : undefined) /** * 几种「看着跑完了、其实没做成」的状况(NI-3/4/5)。后端这几条日志是写给排查的,原样 @@ -1136,17 +1144,20 @@ function explainLog(code: string | undefined, message: string, d: Record { const name = str(m.name) ?? str(m.id) - const why = cjkText(m.reason) + const why = late ? undefined : cjkText(m.reason) return name ? `「${name}」${why ? `:${why}` : ''}` : '' }).filter(Boolean) + const sub = late ? [parts.join('、'), deadlineSub(d.run_seconds)].filter(Boolean).join(';') : parts.join(';') return { - title: DC.log.unchecked(list.length || undefined), - sub: parts.length ? parts.join(';') : undefined, - next: DC.log.uncheckedNext, + title: (late ? DC.log.deadline : DC.log.unchecked)(list.length || undefined), + sub: sub || undefined, + next: late ? DC.log.deadlineNext : DC.log.uncheckedNext, level: 'warn', } } @@ -1189,6 +1200,16 @@ function explainLog(code: string | undefined, message: string, d: Record { + // 一段执行(两次人工审批之间)有单次运行时限。双盲复算、反方审稿为后续节点预留时间:剩下的时间不够就不再开始,开始了的 + // 按剩余时间缩短时长(服务端 engine/deadline.py)。以前这几项照常记成「已达时长上限」「未能反查」「无法复算」,下一步叫人 + // 调高每句、每项的上限(调高了只会更快撞线),或者去查核查模型和数据源(它们本来就没问题)。现在服务端另起一条、带 + // cause: run_deadline 和 run_seconds,界面照它说。先核对后端还是这么发的 + const dlPy = readFileSync(`${root}backend/app/engine/deadline.py`, 'utf8') + const chPy = readFileSync(`${root}backend/app/engine/nodes/challenge.py`, 'utf8') + const dcPy = readFileSync(`${root}backend/app/engine/nodes/dual_check.py`, 'utf8') + check('后端发的还是这几种形状', dlPy.includes('RUN_DEADLINE = "run_deadline"') + && chPy.includes('code="challenge_unreviewed", cause=deadline.RUN_DEADLINE') + && chPy.includes('{"cause": deadline.RUN_DEADLINE, "run_seconds": self.run_seconds, "claims": touched}') + && dcPy.includes('code="dual_check_unchecked", cause=late[0]["cause"], run_seconds=seconds'), + '改了 deadline.py、challenge.py、dual_check.py 里这几条日志的字段,就同步改 decode.ts 的 explainLog 和这里的夹具') + const E = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 1790000000 + seq, data }) + const LATE = '本次运行即将达到 600 秒的时长上限,为保证运行按时完成,剩下 3 句未审' + const BLIND = '本次运行即将达到 600 秒的时长上限,为保证运行按时完成,未进行盲算' + const events = [ + E(1, 'run.started', null, { total: 3 }), + E(2, 'node.started', 'dc', { node_type: 'dual_check', label: '复核五个指标' }), + // 同一节点里别的原因照旧一条,时限将至的另起一条 + E(3, 'log', 'dc', { level: 'warn', code: 'dual_check_unchecked', message: '双盲复算未能核对 1 个指标:「订单数」(盲算没有查到这个数),按无法复算计入缺口', + metrics: [{ id: 'orders', name: '订单数', reason: '盲算没有查到这个数' }] }), + E(4, 'log', 'dc', { level: 'warn', code: 'dual_check_unchecked', cause: 'run_deadline', run_seconds: 600, + message: `本次运行即将达到 600 秒的时长上限,为保证运行按时完成,双盲复算有 3 个指标未能复算:「付款客户数」「最大单笔」「low」,按无法复算计入缺口`, + metrics: [{ id: 'buyers', name: '付款客户数', reason: BLIND }, { id: 'top', name: '最大单笔', reason: BLIND }, { id: 'low', name: '', reason: BLIND }] }), + E(5, 'node.finished', 'dc', { duration_ms: 20 }), + E(6, 'node.started', 'ch', { node_type: 'challenge', label: '审周报' }), + // 第 1 句触了自己的每句时长上限,第 2 句的时长缩短过、到时没给出判断:两条都是 limit: timeout_s + E(7, 'log', 'ch', { level: 'warn', code: 'challenge_limit', limit: 'timeout_s', claims_skipped: 0, + message: '有 1 句反查已达上限(每句时长上限 90 秒),已按查到的结果收尾' }), + E(8, 'log', 'ch', { level: 'warn', code: 'challenge_limit', limit: 'timeout_s', claims_skipped: 1, cause: 'run_deadline', run_seconds: 600, claims: 1, + message: '有 1 句反查已达上限(本次运行即将达到 600 秒的时长上限,反查时长已按剩余时间收紧),已按查到的结果收尾,其中 1 句未能给出结论' }), + E(9, 'log', 'ch', { level: 'warn', code: 'challenge_unreviewed', claims_skipped: 1, reason: '反查没有执行成功任何查询', + message: '反方审稿有 1 句结论未能反查,已记为无法判断:反查没有执行成功任何查询' }), + E(10, 'log', 'ch', { level: 'warn', code: 'challenge_unreviewed', cause: 'run_deadline', run_seconds: 600, claims_skipped: 3, reason: LATE, + message: `反方审稿有 3 句结论未能反查,已记为无法判断:${LATE}` }), + E(11, 'node.finished', 'ch', { duration_ms: 30 }), + E(12, 'run.finished', null, { status: 'succeeded' }), + ] + const all = flatten(mod.decodeRun(events)) + const rows = (code) => all.filter((s) => s.code === code) + const [dcOther, dcLate] = rows('dual_check_unchecked') + const [ownLimit, lateLimit] = rows('challenge_limit') + const [chOther, chLate] = rows('challenge_unreviewed') + const late = [dcLate, lateLimit, chLate] + check('每条一行,挂在自己的节点下', all.find((s) => s.nodeId === 'dc' && s.kind === 'node')?.children?.filter((x) => x.code === 'dual_check_unchecked').length === 2 + && all.find((s) => s.nodeId === 'ch' && s.kind === 'node')?.children?.filter((x) => /^challenge_/.test(x.code ?? '')).length === 4, + all.map((s) => s.code).filter(Boolean).join('、')) + check('dual_check_unchecked:标题说运行时限将至,副标题只点名(没名字的写 id)、带上时限', dcLate?.title === '运行时限将至,双盲复算有 3 个指标未能复算,已记为缺口' + && dcLate.sub === '「付款客户数」、「最大单笔」、「low」;单次运行时限为 600 秒,已为后续节点预留时间' && dcLate.level === 'warn', `${dcLate?.title} / ${dcLate?.sub}`) + check('challenge_limit:缩短过的那条不说「已达时长上限」,说几句缩短了、几句没给出判断', lateLimit?.title === '运行时限将至,反方审稿缩短了 1 句结论的反查时长,其中 1 句未能给出判断' + && lateLimit.sub === '单次运行时限为 600 秒,已为后续节点预留时间' && lateLimit.level === 'warn', `${lateLimit?.title} / ${lateLimit?.sub}`) + check('challenge_unreviewed:标题说几句未反查、记为无法判断,副标题说时限', chLate?.title === '运行时限将至,反方审稿有 3 句结论未反查,已记为无法判断' + && chLate.sub === '单次运行时限为 600 秒,已为后续节点预留时间' && chLate.level === 'warn', `${chLate?.title} / ${chLate?.sub}`) + check('……三行的下一步都说怎么腾出时间:减少句数或指标、在核查前加人工审批、请管理员调大单次运行时限', late.every((s) => !!s?.next?.includes('人工审批') + && s.next.includes('单次运行时限') && s.next.includes('AGENTLAB_MAX_RUN_SECONDS')) + && dcLate?.next?.includes('「复算的指标」') && !!chLate?.next?.includes('句数') && lateLimit?.next === chLate?.next, late.map((s) => s?.next).join(' / ')) + check('……不叫人调高核查的上限,也不叫人去查核查模型和数据源', late.every((s) => !/调高|核查模型|数据源是否/.test(`${s?.title}${s?.sub ?? ''}${s?.next ?? ''}`)), + late.map((s) => s?.next).join(' / ')) + check('……原话留在展开区,标题、副标题、下一步里没有内部代码', late.every((s, i) => s?.detail === events[[3, 7, 9][i]].data.message + && !/run_deadline|cause|run_seconds|timeout_s/.test(`${s.title}${s.sub ?? ''}${s.next ?? ''}`))) + // 别的原因照旧:同一节点里的两条分得开,不是两行一样的「已达时长上限」 + check('别的原因那几行照旧:触了自己的上限仍叫人调高上限,未能反查、无法复算仍按原因说', ownLimit?.title === '反方审稿已达时长上限' + && !!ownLimit.next?.includes('调高') && ownLimit.title !== lateLimit?.title + && chOther?.title === '反方审稿有 1 句结论未能反查,已记为无法判断' && chOther.sub === '反查没有执行成功任何查询' && !!chOther.next?.includes('核查模型') + && dcOther?.title === '双盲复算有 1 个指标无法复算,已记为缺口' && dcOther.sub === '「订单数」:盲算没有查到这个数' && !!dcOther.next?.includes('合并查询'), + [ownLimit, chOther, dcOther].map((s) => `${s?.title}|${s?.sub ?? ''}`).join(' / ')) + + // 都缩短了、都给出了判断;缺字段、类型不对(别的服务端版本):不崩,不出 undefined、NaN、[object Object],不瞎填数 + const odd = flatten(mod.decodeRun([ + E(1, 'node.started', 'ch', { node_type: 'challenge' }), + E(2, 'log', 'ch', { level: 'warn', code: 'challenge_limit', limit: 'timeout_s', claims_skipped: 0, cause: 'run_deadline', run_seconds: 1800, claims: 2, message: 'x' }), + E(3, 'log', 'ch', { level: 'warn', code: 'challenge_limit', limit: 'timeout_s', cause: 'run_deadline', run_seconds: 'x', claims: 'y', message: 'x' }), + E(4, 'log', 'ch', { level: 'warn', code: 'challenge_unreviewed', cause: 'run_deadline', claims_skipped: 'x', reason: LATE, message: 'x' }), + E(5, 'log', 'ch', { level: 'warn', code: 'dual_check_unchecked', cause: 'run_deadline', run_seconds: -1, metrics: 'x', message: 'x' }), + E(6, 'node.finished', 'ch', {}), + ])).filter((s) => s.code && s.kind !== 'node') + const [settled, bareLimit, bareUnreviewed, bareUnchecked] = odd + check('都给出了判断:说按查到的结果给出判断,时限照写(大于 1,000 用千分位)', settled?.title === '运行时限将至,反方审稿缩短了 2 句结论的反查时长,已按查到的结果给出判断' + && settled.sub === '单次运行时限为 1,800 秒,已为后续节点预留时间', `${settled?.title} / ${settled?.sub}`) + check('缺字段:标题不带数,没给时限就不写那句副标题(未能反查的退回原因)', bareLimit?.title === '运行时限将至,反方审稿缩短了反查时长,已按查到的结果给出判断' + && bareLimit.sub === undefined && bareUnreviewed?.title === '运行时限将至,反方审稿有结论未反查,已记为无法判断' && bareUnreviewed.sub === LATE + && bareUnchecked?.title === '运行时限将至,双盲复算有指标未能复算,已记为缺口' && bareUnchecked.sub === undefined, + odd.map((s) => `${s.title}|${s.sub ?? ''}`).join(' ; ')) + check('……不出 undefined、NaN、[object Object]', !odd.some((s) => /undefined|NaN|object Object/.test(`${s.title}${s.sub ?? ''}${s.next ?? ''}`))) +}) + console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 解码器全部通过') process.exit(failed ? 1 : 0) diff --git a/scripts/check-verify.mjs b/scripts/check-verify.mjs index 7cb95b43..37b1ca7e 100644 --- a/scripts/check-verify.mjs +++ b/scripts/check-verify.mjs @@ -1601,6 +1601,42 @@ await section('设置页「核查」一节:老服务端没有这一组时不 } }) +await section('设置页「核查」一节:双盲复算、反方审稿的时长设成不限时,写明仍受单次运行时限约束;审批质询的照旧', async () => { + // 双盲复算、反方审稿在运行里执行:一段执行有单次运行时限,时限将至时每项、每句的时长按剩余时间缩短(服务端 + // engine/deadline.py),设成不限也一样。以前这里写「仅受模型接口自身的超时约束」,管理员会以为设成不限就能查到底。 + // 审批质询不在运行的那一段里执行,不受单次运行时限约束,照旧 + const off = (part) => ({ ...fx.verify[part], timeout_s: null }) + const { page, errors } = await settingsPage({ verify: { ...fx.verify, dual_check: off('dual_check'), challenge: off('challenge'), inquiry: off('inquiry') } }) + await page.waitForSelector('[data-verify-settings]') + const note = (k) => text(page.locator(`[data-verify-limit="${k}"] [data-verify-unlimited]`)) + const RUN = '不设上限,时长仍受单次运行时限约束,时限将至时按剩余时间缩短' + check('双盲复算、反方审稿:说仍受单次运行时限约束', await note('dual_check.timeout_s') === RUN && await note('challenge.timeout_s') === RUN, + `${await note('dual_check.timeout_s')} / ${await note('challenge.timeout_s')}`) + check('审批质询:照旧只受模型接口自身的超时约束', await note('inquiry.timeout_s') === '不设上限,时长仅受模型接口自身的超时约束', + await note('inquiry.timeout_s')) + check('没有运行时报错', errors.length === 0, errors.join(';')) + // 这句比原来长:390px 下照样折行、不横向溢出,深浅色都读得清 + for (const theme of ['dark', 'light']) { + const narrow = await settingsPage({ width: 390, height: 844, theme, + verify: { ...fx.verify, dual_check: off('dual_check'), challenge: off('challenge') } }) + await narrow.page.waitForSelector('[data-verify-settings]') + await narrow.page.locator('[data-verify-limit="challenge.timeout_s"]').scrollIntoViewIfNeeded() + const w = await narrow.page.evaluate(() => { + const box = document.querySelector('[data-verify-settings]') + const vw = document.documentElement.clientWidth + const right = Math.max(...[box, ...box.querySelectorAll('*')].map((e) => e.getBoundingClientRect().right)) + const hint = document.querySelector('[data-verify-limit="challenge.timeout_s"] [data-verify-unlimited]') + return { box: box.scrollWidth - box.clientWidth, beyond: Math.round(right - vw), color: hint && getComputedStyle(hint).color, + base: getComputedStyle(document.body).backgroundColor } + }) + const ratio = contrast(w.color, w.base, w.base) + check(`390px(${theme}):这句说明折行、不横向溢出,对比度 ≥ 4.5`, w.box <= 0 && w.beyond <= 0 && ratio >= 4.5, + `${JSON.stringify({ box: w.box, beyond: w.beyond })} · ${ratio.toFixed(2)}`) + await shot(narrow.page, `settings-run-timeout-${theme}`) + check(`390px(${theme}):没有运行时报错`, narrow.errors.length === 0, narrow.errors.join(';')) + } +}) + await browser.close() console.log(failed ? `\n✗ 双盲复算与反方审稿:${failed} 项未通过` : '\n✓ 双盲复算与反方审稿:全部通过') process.exit(failed ? 1 : 0)