This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/apps/api/src/modules/loop-engine/__tests__/done-when-readings.test.ts
T
ChaiHaoandClaude Opus 5 259192f736 人手改的那一手真正落得下去;界面不撒谎那几处补齐
手改是**兜 AI 的错**的那一手 —— 这一轮把它从「摆在那儿但落不了地」做成真的:

件上就地改
· 保存**按地址收**,不看当下那张卡:载荷从前借道工单的 `blocks[i]` 取列名,
  于是熔断卡(没有 blocks)在屏上时,人补的那一格一个字都送不出去 ——
  而他要补的恰恰是把这一单卡住的那个必填格。授权面改由活动清单的台账声明把关。
· **能增删行**:错不止「这一格填错了」,还有「这一行不该在」与「漏了一行」。
  删挂记号不当场抹掉(看得见、可后悔),且**删要说出来**(`drops`)——
  「没回传就是删了」会把一次不完整的回传变成一次删表。
· **必填没填按不下提交**:与放行门 L1 同一个数同一套算法;闸上摆出差在哪一行哪一格。
· 只写真改了的那几行 —— 改一格却留「修订 4 行」的痕,那句话是假的。

判据与门
· 裁量判据带谓词时**谓词说了算**:「选填缺口已补齐」基座数得出来,
  人填好的目标价它照样判「客户未提供」的毛病由此消掉。
· 验收卡判据**读时现算** —— 人在成果件上补一格,判据跟着变。
· 放行门认「**办完了没有**」不认「有人写过」:从前写个 `false` 就把门推开,
  于是一单没打标就进了放行门。三个数全由基座现数。
· 拒回把「活没干完」说成「数或行对不上」的毛病:判据点名的表一行都没写时直说那一步没办。
  **指向两扇打不开的门,比不指还糟。**

界面不撒谎
· 日志只有三色:负责人绿 · 智能体蓝 · 系统灰(归侧五种是度量的分法,不是给人看的)。
· 验收收口答两件事:落了什么(按**交付物**点名,一份成果的几张表并成一行)、
  往哪儿去(转出哪一口、带过去哪些字段);一个口都没发就直说到此为止。
· 签完到收尾之间那一段有自己的话「字已签下,正在落地」—— 中间态本来就该说出来,只是要说对。
· 消息不再被内部事件刷屏:只有从通道进来的件才谈得上「无人认领」。
· 建客户公司与联系人**一次确认落两行**;联系人编码自增不问人;卡面摆表的人读名。

A1 三案例端到端全 released:全 A 类直通 12 拍 / 客户点名跳一条 10 拍 / 新客户缺必填 14 拍。
五道门禁全绿(971 passed)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JsTDjoHovERXyy6Wmw1ALp
2026-08-02 13:41:44 +08:00

177 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 收步判据要连着**此刻的读数**一起摆给模型 —— 「差在哪」不能靠猜。
*
* 这条是拿一次熔断换来的:A1 的 S2 收步判据是
* `missing_key_total + missing_minor_total = 0`,而草稿里 missing_key_total 是 1。
* 模型看不见这个数,以为缺口已经补齐了,就把同一批行再写一遍「确认一下」——
* 撞上空转检查被拒;拒了它还是不知道差在哪,再写一遍……连拒五拍熔断。
* 把读数摆出来,这一步就从死胡同变成一句话能办的事。
*/
import { describe, expect, it } from 'vitest';
import { readingsOf } from '../internal/domain/lifecycle.ts';
describe('收步判据的读数', () => {
it('把判据读的那几个值摆出来', () => {
const out = readingsOf('missing_key_total + missing_minor_total = 0', {
missing_key_total: 1,
missing_minor_total: 0,
});
expect(out).toContain('missing_key_total=1');
expect(out).toContain('missing_minor_total=0');
});
it('只摆判据读的,不摆整个任务视图', () => {
const out = readingsOf('line_total > 0', { line_total: 3, subject: '询价', pn: 'LM358DR' });
expect(out).toContain('line_total=3');
expect(out).not.toContain('subject');
expect(out).not.toContain('pn');
});
it('没写过的名字如实说没写过 —— 它正是这一步推不动的原因', () => {
expect(readingsOf('benchmark_total > 0', {})).toContain('benchmark_total=(没写过)');
});
it('保留字不当台账名去查', () => {
const out = readingsOf('need_sourcing = true and line_total > 0', { need_sourcing: false, line_total: 2 });
expect(out).toContain('need_sourcing=false');
expect(out).not.toContain('true=');
expect(out).not.toContain('and=');
});
it('字符串字面量里的词不当台账名', () => {
expect(readingsOf('disposition = "照原价报"', { disposition: '通过' })).toBe('(现在:disposition=通过)');
});
it('没有名字可摆时给空串 —— 不往提示词里塞空括号', () => {
expect(readingsOf('1 = 1', {})).toBe('');
});
});
/**
* 判据名归一要削掉**引擎自己贴的等级标记**。
*
* 提交被拒时门印的是「…没达标 —— 某判据(L3)」,紧接着让模型「照判据名原样写」;
* 模型照做,把 `(L3)` 一起抄进 criterion —— 裁定的键与清单里的判据名对不上,
* 裁了等于没裁,再提交再被拒,连拒五拍熔断(实测 A2 死于此)。
*/
import { criterionKey } from '../internal/domain/lifecycle.ts';
describe('判据名归一', () => {
it('模型抄进等级标记也要认得出是同一条', () => {
expect(criterionKey('每项数据来源可溯(以原厂官网规格书为准)(L3)')).toBe(
criterionKey('每项数据来源可溯(以原厂官网规格书为准)'),
);
});
it('五个等级都削', () => {
for (const level of ['L1', 'L2', 'L3', 'L4', 'L5']) {
expect(criterionKey(`某判据(${level})`)).toBe(criterionKey('某判据'));
}
});
it('全角括号的等级标记也削 —— 模型爱把半角敲成全角', () => {
expect(criterionKey('某判据(L3)')).toBe(criterionKey('某判据'));
});
it('只削末尾那一个,不碰判据名里本来就有的括号', () => {
expect(criterionKey('吃不准降 B(不许臆断 A)')).toBe('吃不准降b(不许臆断a)');
});
it('照旧折全角、去空白 —— 老毛病不许回来', () => {
expect(criterionKey('吃不准降B,不许臆断A')).toBe(criterionKey('吃不准降 B,不许臆断 A'));
});
});
/**
* 同一句交代不进两遍对话区。
*
* 实测试跑 2e091d25:「有两颗料客户没写清楚……你看是我起草一封信去问,还是你直接找对方?」
* 一字不差连着吐了三拍(先 start 那一项、又 start 了一次、才发出决策卡)——
* 人在对话区看到同一个问题被问三遍,像卡带。
*
* 判定放在这儿单测:去空白后逐字比、只比紧邻的上一句。
* 近义句不拦(那是两次不同的交代),隔了几拍说回同一句也不拦(局面绕回来了)。
*/
function isRepeatSay(say: string, trail: ReadonlyArray<{ kind: string; title: string }>): boolean {
const now = say.trim().replace(/\s+/g, '');
const last = [...trail].reverse().find((t) => t.kind === 'narration');
return last !== undefined && last.title.trim().replace(/\s+/g, '') === now;
}
describe('对话区不结巴', () => {
const n = (title: string) => ({ kind: 'narration', title });
it('跟上一句一字不差 —— 压下', () => {
expect(isRepeatSay('缺两颗料的信息,要我起草封信吗?', [n('缺两颗料的信息,要我起草封信吗?')])).toBe(true);
});
it('只差空白也算同一句 —— 模型抄自己时空格常飘', () => {
expect(isRepeatSay('缺 两颗料 的信息', [n('缺两颗料的信息')])).toBe(true);
});
it('中间隔着别的痕,比的仍是**上一句叙事**', () => {
const trail = [n('缺两颗料的信息'), { kind: 'action', title: '写草稿:x 1 行' }, { kind: 'engine-transition', title: 'manualStart:S2' }];
expect(isRepeatSay('缺两颗料的信息', trail)).toBe(true);
});
it('近义但不同的句子 —— 放行,那是两次不同的交代', () => {
expect(isRepeatSay('历史价都查到了,接下来打标。', [n('三颗料的历史价都查到了,接下来打标。')])).toBe(false);
});
it('隔了一句别的又说回同一句 —— 放行,局面可能真绕回来了', () => {
expect(isRepeatSay('缺两颗料的信息', [n('缺两颗料的信息'), n('客户回话了')])).toBe(false);
});
it('一条叙事都还没有 —— 放行', () => {
expect(isRepeatSay('第一句话', [{ kind: 'action', title: '写草稿' }])).toBe(false);
});
});
/**
* **行数对不上时,把是哪几行也摆出来**(2026-08-01,连跑三次熔断逼出来的)。
*
* 行数类判据拒回时只给两个数,模型看得见「10 ≠ 9」,却不知道多的是哪一行 ——
* 它只能猜:改数、重写、再提,三条路都不对,连拒五拍熔断。实测三种形态全是这一类:
* · 记录写了 10 行而条目数报 9(同一颗料两个 PN 写法,或把客户说跳过的那条也写了);
* · 分类表 0 行而记录 10 行(它说「打完标了」,其实一行没写);
* · 替代请求 1 张而 C 类 0 条(给那颗料开了单,却没把它标成 C)。
* 三种一摆主键就都是一句话能办的事。
*/
describe('行数判据拒回:连主键名单一起摆', () => {
it('谓词读了 _rows,就把那张表本单写下的行摆出来', () => {
const out = readingsOf('quote_intake_record_rows = line_total', {
quote_intake_record_rows: 10,
line_total: 9,
quote_intake_record_keys: 'LM358DR、TL072CDR、NE555P',
});
expect(out).toContain('quote_intake_record_rows=10');
expect(out).toContain('line_total=9');
expect(out).toContain('是这几行:LM358DR、TL072CDR、NE555P');
});
it('两张表都读了 → 两份名单都摆,差集自己对得出来', () => {
const out = readingsOf('quote_intake_type_table_rows = quote_intake_record_rows', {
quote_intake_type_table_rows: 2,
quote_intake_record_rows: 3,
quote_intake_type_table_keys: 'A、B',
quote_intake_record_keys: 'A、B、C',
});
expect(out).toContain('是这几行:A、B;');
expect(out).toContain('是这几行:A、B、C');
});
it('那张表一行没写就没有名单 —— 不摆一句空的「是这几行:」', () => {
const out = readingsOf('quote_intake_alt_request_rows = type_c_total', {
quote_intake_alt_request_rows: 0,
type_c_total: 0,
});
expect(out).toBe('(现在:quote_intake_alt_request_rows=0、type_c_total=0)');
});
it('不读 _rows 的判据照旧只摆读数 —— 名单不是每条判据都要的', () => {
expect(readingsOf('missing_key_total = 0', { missing_key_total: 1, quote_intake_record_keys: 'X' })).toBe(
'(现在:missing_key_total=1)',
);
});
});