Commit e986f5ef by luoqi

fix(plan): 候选总数改 count 出来,别拿取数上限冒充

主管在矩阵点「充填 · 窗口外 1,080」,确认单却说「从 170 位候选里取前 100 人」
—— 170 正是 ceil(100*1.5)+20 这个取明细的 LIMIT。而 selectionNote 是要求助手
原话转述的句子(T14),等于让系统当着主管的面报一个他刚看过的、对不上的数。

· 新增 countCandidates():走同一份 cohortWhereSql,count(DISTINCT patient_id)
  —— 与矩阵格子同一种数法,不是 count(*)(同一患者两条召回会被数两次)。
· candidateTotal / selectionNote 三处判断全改用真候选数;取明细仍只取 1.5 倍窗口。
· zod describe 里那句"已按取数上限截断"一并改掉(它把 bug 写成了规格)。

测试侧同时修一个假绿:原来的假 prisma 不管 LIMIT、一律吐 candidateCount 行,
所以"截断"在测试里根本不可能发生。改成照 LIMIT 截断 + count/明细分流,
并加一条 1080 的回归(已验证:改回旧实现该用例即红)。

本地实测:矩阵 1,080 → 确认单「从 1080 位候选里取前 100 人」。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 541ea76a
...@@ -120,7 +120,18 @@ export class AssignmentProposalService { ...@@ -120,7 +120,18 @@ export class AssignmentProposalService {
// ── ① 收敛:按三层键取前 N ──────────────────────────────── // ── ① 收敛:按三层键取前 N ────────────────────────────────
// ⭐ 多取一些(1.5 倍)给去重和探索配额留余量;不取全量是因为大格子上万条。 // ⭐ 多取一些(1.5 倍)给去重和探索配额留余量;不取全量是因为大格子上万条。
const pool = await this.selectCandidates(scope, criteria, Math.ceil(target * 1.5) + 20, now); //
// 🔴 **候选总数必须单独 count,不能拿这次取回的行数充数**。
// 取回的行数上限就是这个 LIMIT(默认 170),于是 1,080 人的格子会被报成
// 「从 170 位候选里取前 100 人」—— 而这句是**要求助手原话转述**的话(T14),
// 主管刚在矩阵上看过 1,080,当场就对不上。这个数**只能来自 count**。
// ⚠️ 用 `count(DISTINCT patient_id)`,与矩阵格子同一种数法 —— 换成 count(*)
// 就变成「按 plan 数」,同一个人两条召回会被数两次,又对不上了。
const fetchLimit = Math.ceil(target * 1.5) + 20;
const [candidateTotal, pool] = await Promise.all([
this.countCandidates(scope, criteria, now),
this.selectCandidates(scope, criteria, fetchLimit, now),
]);
// 同患者只留一条(schema 注释承诺的 partial UNIQUE 实际不存在,不能当保障用) // 同患者只留一条(schema 注释承诺的 partial UNIQUE 实际不存在,不能当保障用)
const seenPatient = new Set<string>(); const seenPatient = new Set<string>();
...@@ -132,6 +143,9 @@ export class AssignmentProposalService { ...@@ -132,6 +143,9 @@ export class AssignmentProposalService {
// ── 探索配额 ──────────────────────────────────────────── // ── 探索配额 ────────────────────────────────────────────
// 从**排名之外**抽,不是从头部抽 —— 抽头部等于没探索。 // 从**排名之外**抽,不是从头部抽 —— 抽头部等于没探索。
// ⚠️ 「排名之外」的范围只有**取回的这 1.5 倍窗口**(第 rankN…fetchLimit 名),
// 不是整个候选池 —— 1,080 人的格子里第 500 名永远轮不到被探索。
// 要全池探索得另取一次样本;现在这样至少是确定性的,先不扩。
const ratio = Math.min(0.2, Math.max(0, input.exploreRatio ?? 0)); const ratio = Math.min(0.2, Math.max(0, input.exploreRatio ?? 0));
const exploreN = Math.floor(target * ratio); const exploreN = Math.floor(target * ratio);
const rankN = Math.max(0, target - exploreN); const rankN = Math.max(0, target - exploreN);
...@@ -163,7 +177,8 @@ export class AssignmentProposalService { ...@@ -163,7 +177,8 @@ export class AssignmentProposalService {
return { return {
clinicId, clinicId,
potentialTreatment: potentialTreatment ?? null, potentialTreatment: potentialTreatment ?? null,
candidateTotal: ranked.length, /// ⭐ 真候选总数(count 出来的),⛔ 不是 ranked.length —— 后者被 fetchLimit 截过
candidateTotal,
target, target,
placed: items.placed.length, placed: items.placed.length,
unplaced: items.unplaced, unplaced: items.unplaced,
...@@ -189,12 +204,12 @@ export class AssignmentProposalService { ...@@ -189,12 +204,12 @@ export class AssignmentProposalService {
// ⚠️ **候选不够时不能说"取前 N 人"** —— 主管从矩阵点了一个 44 人的格子进来, // ⚠️ **候选不够时不能说"取前 N 人"** —— 主管从矩阵点了一个 44 人的格子进来,
// 句子却说「排序取前 100 人」,他会以为有 56 个人被系统丢了。 // 句子却说「排序取前 100 人」,他会以为有 56 个人被系统丢了。
// 这是一句**要求助手原话转述**的话,措辞错了等于让助手替系统撒谎(T14)。 // 这是一句**要求助手原话转述**的话,措辞错了等于让助手替系统撒谎(T14)。
(ranked.length <= target (candidateTotal <= target
? `这批候选一共就 ${ranked.length} 人,**全部纳入**(未做取舍)。` ? `这批候选一共就 ${candidateTotal} 人,**全部纳入**(未做取舍)。`
: `按「未进过批次优先 → 优先级高优先 → 患者号」排序,从 ${ranked.length} 位候选里取前 ${target} 人;`) + : `按「未进过批次优先 → 优先级高优先 → 患者号」排序,从 ${candidateTotal} 位候选里取前 ${target} 人;`) +
(ranked.length > target && sizeBasis === 'default' (candidateTotal > target && sizeBasis === 'default'
? `批次规模 ${target} 人为**默认值**(一批做透好过多批做浅),要改直接说个数字。` ? `批次规模 ${target} 人为**默认值**(一批做透好过多批做浅),要改直接说个数字。`
: sizeBasis === 'capacity' && ranked.length > target : sizeBasis === 'capacity' && candidateTotal > target
? `⚠️ 只取到 ${target} 人 —— 在岗 ${agents.length} 位客服的剩余容量之和就这么多,不是候选不够。` ? `⚠️ 只取到 ${target} 人 —— 在岗 ${agents.length} 位客服的剩余容量之和就这么多,不是候选不够。`
: '') + : '') +
(exploreN > 0 (exploreN > 0
...@@ -233,6 +248,31 @@ export class AssignmentProposalService { ...@@ -233,6 +248,31 @@ export class AssignmentProposalService {
); );
} }
/**
* 候选**总数** —— 与取明细分开的一次 count。
*
* 🔴 存在的唯一理由:`selectCandidates` 带 LIMIT,它返回的行数**不是**候选数。
* 拿它当候选数,大格子就会被报成「从 170 位候选里取前 100 人」(170 正是那个 LIMIT),
* 而主管刚在矩阵上看的是 1,080。
* ⚠️ `count(DISTINCT fp.patient_id)` —— 与矩阵格子(cohort-attributes)**同一种数法**。
* ⛔ 别写成 `count(*)`:同一患者的两条召回会被数两次,又对不上矩阵了。
*/
private async countCandidates(
scope: TenantScopeContext,
criteria: CohortCriteria,
now: Date,
): Promise<number> {
const rows = await this.prisma.$queryRaw<Array<{ n: bigint | number }>>(
Prisma.sql`
SELECT count(DISTINCT fp.patient_id) AS n
FROM followup_plans fp
JOIN patients p ON p.id = fp.patient_id
WHERE ${cohortWhereSql(scope, criteria, now)}
`,
);
return Number(rows[0]?.n ?? 0);
}
private async dedicatedCsOf(patientIds: string[]): Promise<Map<string, string>> { private async dedicatedCsOf(patientIds: string[]): Promise<Map<string, string>> {
const ids = [...new Set(patientIds)]; const ids = [...new Set(patientIds)];
if (ids.length === 0) return new Map(); if (ids.length === 0) return new Map();
......
...@@ -170,13 +170,23 @@ describe('placeAgents —— 确定性', () => { ...@@ -170,13 +170,23 @@ describe('placeAgents —— 确定性', () => {
describe('selectionNote —— 候选不够时的措辞', () => { describe('selectionNote —— 候选不够时的措辞', () => {
const { AssignmentProposalService } = require('../src/modules/plan/assignment-proposal.service'); const { AssignmentProposalService } = require('../src/modules/plan/assignment-proposal.service');
/**
* ⚠️ 这个假 prisma 必须**照 LIMIT 截断**。
* 原来的写法不管 LIMIT、一律吐 candidateCount 行 —— 于是"候选数被取数上限截断"
* 这个真 bug 在测试里**根本不可能出现**,500 那条用例一直是假绿。
* count 查询与明细查询靠 SQL 文本区分,与 service 里的两条查询一一对应。
*/
function svcWith(candidateCount: number, agentCount: number) { function svcWith(candidateCount: number, agentCount: number) {
const prisma = { const prisma = {
$queryRaw: jest.fn(async () => $queryRaw: jest.fn(async (sql: { strings?: string[]; values?: unknown[] }) => {
Array.from({ length: candidateCount }, (_, i) => ({ const text = (sql?.strings ?? []).join(' ');
if (text.includes('count(DISTINCT')) return [{ n: BigInt(candidateCount) }];
// 明细:LIMIT 是模板里最后一个参数
const limit = Number((sql?.values ?? []).at(-1) ?? candidateCount);
return Array.from({ length: Math.min(candidateCount, limit) }, (_, i) => ({
planId: `p${i}`, patientId: `pat${i}`, priorityScore: 50 - i, planId: `p${i}`, patientId: `pat${i}`, priorityScore: 50 - i,
})), }));
), }),
patient: { findMany: jest.fn(async () => []) }, patient: { findMany: jest.fn(async () => []) },
}; };
const roster = { const roster = {
...@@ -203,4 +213,21 @@ describe('selectionNote —— 候选不够时的措辞', () => { ...@@ -203,4 +213,21 @@ describe('selectionNote —— 候选不够时的措辞', () => {
expect(r.selectionNote).toContain('从 500 位候选里取前 100 人'); expect(r.selectionNote).toContain('从 500 位候选里取前 100 人');
expect(r.selectionNote).toContain('默认值'); expect(r.selectionNote).toContain('默认值');
}); });
/**
* 🔴🔴 取数上限**不许**冒充候选数。
*
* 真实场景(2026-08-03 走查发现):主管在矩阵上点「充填 · 窗口外 1,080」,
* 确认单却说「从 170 位候选里取前 100 人」—— 170 正是 `ceil(100*1.5)+20` 这个 LIMIT。
* 而这句话助手要**原话转述**,等于让系统当着主管的面报一个他刚看过的、对不上的数。
*/
test('🔴 候选 1080 但取数只取回 170 → 说的必须是 1080,⛔ 不许出现 170', async () => {
const svc = svcWith(1080, 9);
const r = await svc.propose(SCOPE, { clinicId: 'c1', potentialTreatment: 'filling', temperature: 'cold' });
expect(r.candidateTotal).toBe(1080);
expect(r.selectionNote).toContain('从 1080 位候选里取前 100 人');
expect(r.selectionNote).not.toContain('170');
// 明细仍只取回 170 条(不因为要报真数就把整池拉回来)
expect(r.placed).toBe(100);
});
}); });
...@@ -279,7 +279,9 @@ export type ProposalAgentRow = z.infer<typeof ProposalAgentRowSchema>; ...@@ -279,7 +279,9 @@ export type ProposalAgentRow = z.infer<typeof ProposalAgentRowSchema>;
export const AssignmentProposalSchema = z.object({ export const AssignmentProposalSchema = z.object({
clinicId: z.string(), clinicId: z.string(),
potentialTreatment: z.string().nullable(), potentialTreatment: z.string().nullable(),
candidateTotal: z.number().int().describe('去重后的候选总数(不是格子总量,已按取数上限截断)'), /// ⭐ 与矩阵格子同一种数法(count DISTINCT patient_id),⛔ 不受取明细的 LIMIT 影响 ——
/// 从矩阵点进来的主管会拿这个数跟他刚看到的格子对
candidateTotal: z.number().int().describe('候选总数 = 该格子/该条件下的患者数(与矩阵格子对得上)'),
target: z.number().int().describe('拟分人数'), target: z.number().int().describe('拟分人数'),
placed: z.number().int(), placed: z.number().int(),
/// ⚠️ 分不下去的**不摊派**给已满的人:硬塞是 T5 的反面,而且会立刻造出 over_capacity 退回, /// ⚠️ 分不下去的**不摊派**给已满的人:硬塞是 T5 的反面,而且会立刻造出 over_capacity 退回,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment