Commit 89d54dfc by luoqi

feat(friday-ai): 接待/预约确认 Agent 收口 —— 单一意图词表、回复路由表、患者时间线、接管闸收窄

Agent 层
- shared/intent.ts:接待与预约确认共用一套意图词表,子集关系编译期钉死(原两套枚举只有 medical 重叠)
- shared/reply-routing.ts:intent × context → 处置表(转人工/改期建议/是否开 Task),escalation_reason 统一推导
- shared/human-actions.ts:一个 job_type 一个定义(escalate_to_human / propose_reschedule / call_to_confirm / parse_response),outlets 改为调用时传入
- reception:删掉空壳 replyTaskId;AI 能答的直接出站,「答不上来」不再生成 Task;草稿只在未发送时保留
- appointment-confirm:扫描改为按诊所(ref 树叶子)而非按托管账号,未绑企微的患者走电话确认
- prompts/layers.ts:IDENTITY 改为与实际行为一致(AI 会直接对患者说话),版本 reception@2026-09-01-a
- inbound:群聊先取一个主责号(created_at 最早,占位规则)

工作台
- patient-timeline.service.ts:按 (job_type, escalation_reason) 准入,只收真正影响患者时间线的 task
- conversation.service.ts:「我需要答」由新鲜草稿判定;agentActivity 增加 sending 态,只有真实 last_error 才出 detail
- takeover / common-context:接管判定加 source_type='human',去掉不可达分支
- agent-task:failed_retry → retry_pending(DB CHECK 里只有后者,原测试跟着错才一直绿)

出站闸门
- gate.service.ts:患者闸只认 manual_takeover;注释改为实际效果(hold 在队列,闸开即发)

CLI 安全
- reception-drill / appointment-drill:原来的收尾 UPDATE 未加范围会关掉库里所有 open task,现已限定到本次会话/本次时间;appt-drill 在 ai_auto_send=true 时拒绝运行

测试:新增 action-registry / reply-routing / timeline-admission / outcome-zh / ai-paused,以及读迁移 SQL 的九态闸;408 / 22 suites
eslint:忽略 .next*(NEXT_DIST_DIR 落到 .next-check)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
parent 0b3b7cd0
Pipeline #3652 failed in 0 seconds
......@@ -5,7 +5,8 @@
* 然后落 `llm_calls`、按结果建 Task / 存草稿。⛔ 不是一个"只调模型"的玩具。
*
* ⚠️ **会真的花钱**(一次 qwen3.8-max 调用),也会真的建 Task / 写草稿。
* ⛔ 但**不会发消息** —— 这个场景根本没有发消息的动作。
* ⛔ **不会发消息** —— ⚠️ 理由是下面**不传 `appInfo`**(发送的硬前提),
* ⛔ 不是"这个场景没有发消息的动作"(它有)。加上 `appInfo` 就会真发。
*
* 用法:pnpm --filter @pac/ai-service agent:advise "患者说的话"
*/
......
/**
* 预约确认**全流程演练** —— 五条出路各跑一遍,每轮都从干净状态开始
* 预约确认**全流程演练** —— 五条出路各跑一遍。
*
* ⚠️ 这是**联调工具不是单元测试**:它真的会建 Task、真的会往 outbox 排消息。
* 跑完自己收尾(Task 关掉、outbox 标 dead),但它**依赖 PAC 里有一条未来预约** ——
* 本地那条是手工插的测试事实 `appointment_record:TEST-FRIDAY-AGENT-1`。
*
* ⚠️ 出站一条都发不出去(租户闸 `ai_auto_send` 默认关),这是刻意的:
* 演练不该给真人发微信。⛔ 别为了"看看效果"去开那个闸。
* ═══ ⛔⛔ 2026-09-01:两个前提塌了,已就地补硬闸 ═══════════════════════════
*
* **① 「出站一条都发不出去(租户闸默认关)」—— 那是个假设,不是保证。**
* 闸是一列可变的库值,联调时被打开过。⇒ 现在**开跑前先查**,
* `ai_auto_send = true` 就**拒绝运行**。⛔ 别把安全性寄托在"默认值"上。
*
* **② 收尾的两条 UPDATE 原来没有 WHERE 范围** —— 全库把未完成 Task 关掉、
* 把所有 `outbound.ecom.text` 标 dead,而且**归零那条在循环里跑 5 次**。
* ⇒ 跑一次演练 = 抹掉工作台上所有真待办 + 干掉所有待发消息。
* 现在只收**这次演练自己建的**(按开跑时刻卡 `created_at`)。
*
* ⚠️⚠️ **③ 还有一处没修:`scan.created !== 1` 这个断言现在不成立。**
* 扫描单位从「一个托管号」改成「一家诊所」之后,一次 scan 会为**所有诊所**
* 建 Task(本地 5 家)。⇒ 这个演练需要重做成"只扫一家诊所",
* ⛔ 在那之前它会红,而那是**对的** —— 别把断言改松来让它变绿。
*
* 用法:pnpm --filter @pac/ai-service agent:appt-drill
*/
......@@ -29,16 +40,40 @@ async function main() {
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error'] });
const prisma = app.get(PrismaService);
const scene = app.get(AppointmentConfirmService);
/**
* ⛔⛔ **硬闸:租户的 AI 自动发送必须是关的。**
* ⚠️ 这个演练会把真提醒排进 outbox,而闸开着的话 worker 会**真的发给患者**。
* 原注释说"闸默认关所以发不出去" —— 默认值不是保证,实测它被打开过。
*/
const gate = await prisma.$queryRaw<{ id: string; name: string }[]>`
SELECT id, name FROM app.tenants WHERE ai_auto_send = true`;
if (gate.length) {
console.error(
`⛔ 拒绝运行:${gate.map((t) => t.name).join('、')} 的 ai_auto_send 是开的 —— ` +
'演练排进 outbox 的提醒会真的发给患者。\n' +
' 先关掉那个闸再跑。',
);
await app.close();
process.exit(1);
}
/** ⭐ 水位线 —— 收尾只收**这一次跑**建出来的东西,⛔ 别再全库 UPDATE */
const startedAt = (await prisma.$queryRaw<{ now: Date }[]>`SELECT now() AS now`)[0]!.now;
let pass = 0;
const fails: string[] = [];
for (const d of DRILLS) {
// ── 归零:把上一轮的 Task 收掉,让 uq_tasks_active 放行 ──
// ── 归零:把**上一轮**的 Task 收掉,让 uq_tasks_active 放行 ──
// ⛔ 只收水位线之后的 —— 全库那版会把工作台上所有真待办一起关掉
await prisma.$executeRaw`
UPDATE app.jobs SET status='skipped', finished_at=now() WHERE status='pending'`;
UPDATE app.jobs SET status='skipped', finished_at=now()
WHERE status='pending'
AND task_id IN (SELECT id FROM app.tasks WHERE created_at >= ${startedAt})`;
await prisma.$executeRaw`
UPDATE app.tasks SET status='closed', closed_reason='演练归零', finished_at=now()
WHERE status NOT IN ('completed','closed')`;
WHERE created_at >= ${startedAt} AND status NOT IN ('completed','closed')`;
const scan = await scene.scan();
if (scan.created !== 1) {
......@@ -79,15 +114,18 @@ async function main() {
);
}
// ── 收尾:别把演练残留留在工作台上,也别留一条能发出去的消息 ──
// ── 收尾 —— ⛔ 三条都只收**水位线之后**的,理由见文件头注 ② ──
await prisma.$executeRaw`
UPDATE app.jobs SET status='skipped', finished_at=now() WHERE status='pending'`;
UPDATE app.jobs SET status='skipped', finished_at=now()
WHERE status='pending'
AND task_id IN (SELECT id FROM app.tasks WHERE created_at >= ${startedAt})`;
await prisma.$executeRaw`
UPDATE app.tasks SET status='closed', closed_reason='演练残留', finished_at=now()
WHERE status NOT IN ('completed','closed')`;
WHERE created_at >= ${startedAt} AND status NOT IN ('completed','closed')`;
await prisma.$executeRaw`
UPDATE app.outbox SET status='dead', last_error='【演练残留】⛔ 不许真发'
WHERE task_type IN ('outbound.ecom.text') AND status <> 'dead'`;
WHERE task_type = 'outbound.ecom.text' AND status <> 'dead'
AND created_at >= ${startedAt}`;
console.log(`\n${pass}/${DRILLS.length} 通过`);
if (fails.length) {
......
......@@ -2,7 +2,11 @@
* 通用会话接待**演练** —— 医疗信号兜底的四种情况各跑一遍。
*
* ⚠️ 联调工具不是单元测试:它真的会建 Task / Job。跑完自己收尾。
* ⚠️ 这个场景**一条消息都不发**(见 `RECEPTION_ACTIONS`),所以没有出站残留。
* ⚠️⚠️ **没有出站残留 —— 但理由不是"这个场景不发消息"。**
* 这个场景**会发**(症状/交接告知走 Job,自由回复直接入队)。
* 真正的理由是:下面 `handle()` **不传 `appInfo`**,而那是发送的硬前提
* (「⛔ 没有 appInfo 就不发」—— 幂等键就是它)。
* ⛔ 谁要给演练加上 `appInfo`,就是在给真患者发消息。
*
* 用法:pnpm --filter @pac/ai-service agent:reception-drill
*/
......@@ -33,12 +37,6 @@ async function main() {
const prisma = app.get(PrismaService);
const scene = app.get(ReceptionService);
// ── 归零 ──
await prisma.$executeRaw`UPDATE app.jobs SET status='skipped', finished_at=now() WHERE status='pending'`;
await prisma.$executeRaw`
UPDATE app.tasks SET status='closed', closed_reason='接待演练归零', finished_at=now()
WHERE status NOT IN ('completed','closed')`;
const conv = await prisma.$queryRaw<{ id: string; tenant_id: string }[]>`
SELECT c.id, c.tenant_id FROM app.conversations c
JOIN app.contact_links l
......@@ -50,13 +48,51 @@ async function main() {
await app.close();
process.exit(1);
}
const { id: convId, tenant_id: tenantId } = conv[0];
/**
* ── 归零 ──
*
* ═══ ⛔⛔ 这两条**曾经没有 WHERE 范围** ═══════════════════════════════════
*
* 原来是全库 `UPDATE app.tasks SET status='closed' WHERE status NOT IN (...)`
* 和全库 `UPDATE app.jobs SET status='skipped' WHERE status='pending'`。
* ⇒ **跑一次演练 = 把库里所有没做完的待办全关掉**,而且盖上「接待演练归零」的戳。
*
* ⚠️ 那不是理论风险,是**已经发生过**的:库里两条真实待办的关闭理由至今写着
* 「原标注『接待演练归零』**有误**:这是患者真实消息触发的」——
* 有人事后回来一条条改回去。而当时若不是恰好有人发现,那些活就永远丢了。
*
* ⇒ 现在**只动这一条会话**。别的会话、别的诊所的预约确认待办一律不碰。
* ⚠️ 但演练对象仍然是**真患者的真会话**(没有 fixture 数据),
* 所以它还是会关掉这条会话上真实的未完成待办 ⇒ **先打印出来给人看**。
* ⛔ 别把这段打印删了当"太吵"。
*/
const willClose = await prisma.$queryRaw<{ id: string; task_kind: string; title: string }[]>`
SELECT id, task_kind, title FROM app.tasks
WHERE tenant_id = ${tenantId}::uuid AND conversation_id = ${convId}::uuid
AND status NOT IN ('completed','closed')`;
if (willClose.length) {
console.log(`⚠️ 这条会话上有 ${willClose.length} 条未完成待办,演练会先把它们关掉:`);
for (const t of willClose) console.log(` · [${t.task_kind}] ${t.title}`);
console.log('');
}
await prisma.$executeRaw`
UPDATE app.jobs SET status='skipped', finished_at=now()
WHERE status='pending'
AND task_id IN (SELECT id FROM app.tasks
WHERE tenant_id = ${tenantId}::uuid AND conversation_id = ${convId}::uuid)`;
await prisma.$executeRaw`
UPDATE app.tasks SET status='closed', closed_reason='接待演练归零', finished_at=now()
WHERE tenant_id = ${tenantId}::uuid AND conversation_id = ${convId}::uuid
AND status NOT IN ('completed','closed')`;
let taskId: string | null = null;
const fails: string[] = [];
for (const s of STEPS) {
const r = await scene.handle({
tenantId: conv[0].tenant_id,
conversationId: conv[0].id,
tenantId,
conversationId: convId,
text: s.say,
});
// ⚠️ 这份演练只覆盖**规则**那一层(医疗信号)。模型那一层由 agent:advise 单独试 ——
......@@ -95,8 +131,8 @@ async function main() {
started_at=coalesce(started_at, now()), finished_at=now()
WHERE task_id = ${taskId}::uuid AND executor_type='human' AND status='pending'`;
const r = await scene.handle({
tenantId: conv[0].tenant_id,
conversationId: conv[0].id,
tenantId,
conversationId: convId,
text: '又开始疼了',
});
const pending = await prisma.$queryRaw<{ n: bigint }[]>`
......@@ -109,11 +145,16 @@ async function main() {
console.log(` 待办数 ${Number(pending[0]!.n)}(应为 1)\n`);
}
// ── 收尾 ──
await prisma.$executeRaw`UPDATE app.jobs SET status='skipped', finished_at=now() WHERE status='pending'`;
// ── 收尾 —— ⛔ 同样只收**这一条会话**,理由见上面「归零」那段 ──
await prisma.$executeRaw`
UPDATE app.jobs SET status='skipped', finished_at=now()
WHERE status='pending'
AND task_id IN (SELECT id FROM app.tasks
WHERE tenant_id = ${tenantId}::uuid AND conversation_id = ${convId}::uuid)`;
await prisma.$executeRaw`
UPDATE app.tasks SET status='closed', closed_reason='接待演练残留', finished_at=now()
WHERE status NOT IN ('completed','closed')`;
WHERE tenant_id = ${tenantId}::uuid AND conversation_id = ${convId}::uuid
AND status NOT IN ('completed','closed')`;
console.log(fails.length ? `❌ ${fails.length} 项失败:\n · ${fails.join('\n · ')}` : '✅ 全部通过');
await app.close();
......
......@@ -14,6 +14,7 @@ import { AgentInboundHandler } from './inbound/agent-inbound.handler';
import { ReplyModelService } from './scenes/appointment-confirm/reply-model.service';
import { AppointmentConfirmService } from './scenes/appointment-confirm/appointment-confirm.service';
import { ReceptionService } from './scenes/reception/reception.service';
import { ScopeModule } from '../scope/scope.module';
/**
* Agent 层 —— [14](docs/14-Agent层数据库设计.md) / [15](docs/15-Agent实现规准.md) 的运行时。
......@@ -46,7 +47,10 @@ import { ReceptionService } from './scenes/reception/reception.service';
* 要"从界面上手工跑一次",加一个控制器**在这个模块里**。
*/
@Module({
imports: [PacModule, OutboundModule],
// ⚠️ `ScopeModule` —— 预约确认的扫描单位从"托管号"改成"诊所"之后要它
// (解析 PAC 坐标 + 列出可扫的组织节点)。⛔ 不加会在**启动时**炸 DI,
// 而不是运行时 —— 那反而是好的:漏了立刻知道。
imports: [PacModule, OutboundModule, ScopeModule],
providers: [
TaskFactory,
JobRunnerService,
......
......@@ -262,6 +262,14 @@ export class CommonContextService {
FROM app.tasks t
WHERE t.tenant_id = ${tenantId}::uuid
AND t.status IN ('needs_human','human_working')
-- ⭐⭐ **只有人显式接管才算「同事在跟」** —— 2026-09-01 收窄,
-- 这是同一条判据的**第三处**(另两处:GateService 的接管闸、
-- TakeoverService 的标题栏状态)。三处都要一起,漏一处的表现是**说假话**:
-- 实测漏了这处 ⇒ 患者身上挂着一条改期待办,提示词里就塞进
-- 「这个患者正在被同事人工处理」,模型照着回了
-- 「已有同事在人工跟进,无需插话」—— 而根本没人在跟。
-- ⚠️ 一条待办要人办 ≠ 这段对话归人。前者不该让 AI 对这个患者哑掉。
AND t.source_type = 'human'
AND EXISTS (
SELECT 1 FROM unnest(${codes}::text[], ${pids}::text[]) AS a(code, pid)
WHERE a.code = t.host_code AND a.pid = t.host_patient_id)`;
......
......@@ -63,6 +63,36 @@ export class AgentInboundHandler implements OutboxHandler {
* 人会以为 AI 坏了(实测撞到:用户连问了两次同一个问题)。
* ⚠️ 拿不到 accountId 就不推,⛔ 不为此让整条处理失败 —— 它只是个提示。
*/
/**
* ⭐⭐ **群:只有「主责号」那一行跑 agent。**
*
* ⚠️⚠️ 同一个企微群会在 `conversations` 里存**多行** —— 一个托管号一行
* (实测本地 17 个群各被 2 个号看到)。而入站那条判据("说话的是我们认得的
* 患者")对每一行都成立 ⇒ **同一句话会让 agent 跑两遍**:两次模型调用、
* 两条草稿、两条 Task,运气不好还是两句回复发进同一个群。
*
* ⚠️ 这里的选法是**占位**:取该群下建得最早的那个号。
* ⛔ 别把它当设计 —— 真正的分发规则(主责号优先答、@到谁谁的 agent 答、
* 其余不答)还没定。占位的作用只是让"一个群只有一个 agent 在跑"
* 这件事**现在就成立**,后面换判据只改这一个查询。
* ⚠️ 排序带 `a.id` 兜底:`created_at` 撞秒时也要有确定的结果,
* ⛔ 否则同一个群在两次投递里可能选出不同的号。
* ⚠️ 单聊不走这里(`room_id` 为空)—— 它本来就只有一行。
*/
const primary = await this.prisma.$queryRaw<{ id: string }[]>`
SELECT c2.id
FROM app.conversations c0
JOIN app.conversations c2
ON c2.tenant_id = c0.tenant_id AND c2.room_id = c0.room_id AND c2.kind = 'group'
JOIN app.wecom_accounts a ON a.id = c2.account_id
WHERE c0.id = ${p.conversationId}::uuid AND c0.room_id IS NOT NULL
ORDER BY a.created_at ASC, a.id ASC
LIMIT 1`;
if (primary[0] && primary[0].id !== p.conversationId) {
this.logger.log(`群里不是主责号那一行,跳过 conv=${p.conversationId}`);
return { ok: true };
}
const accountId = await this.accountOf(p.conversationId);
if (accountId) this.publish(task.tenantId, accountId, p.conversationId);
......
import { Injectable, Logger } from '@nestjs/common';
import { generateText, hasToolCall, stepCountIs, tool, type ToolSet } from 'ai';
import { z } from 'zod';
import { RECEPTION_INTENTS } from '../shared/intent';
import { LlmProviderService } from './provider.service';
import { guardDraft, type GuardVerdict } from './output-guard';
import { emptyLedger, type FactLedger } from './fact-ledger';
......@@ -45,9 +46,12 @@ import { renderCommonContext } from '../context/common-context';
*/
const SubmitSchema = z.object({
intent: z
.enum(['medical', 'appointment', 'price', 'complaint', 'question', 'chitchat', 'other'])
.describe('这句话主要在说什么'),
/**
* ⚠️⚠️ 取值来自 `shared/intent.ts` 的 `RECEPTION_INTENTS`,⛔ 别在这里写字面量。
* ⚠️ 2026-09-01 起 `other` 并进了 `unknown` —— 两个名字本来就是同一件事,
* 而分成两个的后果是同一句话在两条腿上判出不同的档。
*/
intent: z.enum(RECEPTION_INTENTS).describe('这句话主要在说什么'),
needsHuman: z.boolean().describe('是否必须有人来处理'),
reason: z.string().max(120).describe('为什么这么判,一句话,给同事看'),
draft: z.string().max(200).nullable().describe('给患者的回复草稿;拿不准或该转人工就填 null'),
......@@ -56,7 +60,12 @@ const SubmitSchema = z.object({
export type Advice = z.infer<typeof SubmitSchema>;
/** ⚠️ 改提示词或工具清单**都要** bump —— [14 §5.5] `prompt_version` 是重建当时输入的关键 */
export const RECEPTION_PROMPT_VERSION = 'reception@2026-08-31-c';
/**
* ⚠️ 改提示词**必须同时改这个版本号** —— 它落 `llm_calls.prompt_version`,
* 是「哪一版提示词产出了这条回复」的唯一线索。不改的话,
* 改版前后的输出在账本里混成一堆,质量对比无从做起。
*/
export const RECEPTION_PROMPT_VERSION = 'reception@2026-09-01-a';
/**
* 一轮最多几步。
......@@ -287,7 +296,9 @@ export class ReceptionAdvisorService {
private fallbackFrom(text: string | undefined): Advice {
const t = (text ?? '').trim();
return {
intent: 'other',
// ⚠️ 原来是 `other` —— 并进 `unknown` 了(见 shared/intent.ts)。
// 这一支的含义正是"归不了类",和预约确认那边的 unknown 一模一样。
intent: 'unknown',
needsHuman: true,
reason: '模型没有按约定调用 submit,按需要人工处理',
draft: t ? t.slice(0, 200) : null,
......
......@@ -26,11 +26,16 @@
*/
/** ① 装置 —— 你是谁,你看不见什么 */
export const IDENTITY = `你是一家口腔诊所的微信客服助手。你在帮同事**看**患者发来的消息
判断这条要不要人来处理,并在有把握时给一份**回复草稿**。
export const IDENTITY = `你是一家口腔诊所的微信客服。患者在微信上找的就是你
你直接回他。有些事你答不了,那时候交给同事,但**对接他的人一直是你**。
## 你是什么
- 你**不直接对患者说话**。你写的草稿要经同事过目才可能发出去。
- 你写的话**就是要发给患者的那句话**(发不发得出去由系统的闸门决定,不用你操心)。
- ⛔ **不要把自己说成第三方。** 不要说"我安排同事来跟您对接""让同事联系您"——
那会让患者以为要换个人重新说一遍。要问同事的**某件具体的事**,说法是
"我帮您问一下,问到了回您"——**问的人和回的人都还是你**。
- ⚠️ 例外是**真的要换人**的两种:医疗症状(见医疗红线那段,照它的话说)
和改期/取消(那要同事去业务系统里操作)。⛔ 除此之外别提"安排同事"。
- 你看到的对话**只有我们接管这个微信号之后的**,更早的你看不到。`;
/**
......@@ -42,7 +47,9 @@ export const HONESTY = `## 不许编
一律**不要写进草稿**——编出来会被系统拦下,草稿直接作废。
- 医生姓名**只有 \`get_appointment\` 返回 \`doctor\` 时才能说**(说成"X 医生")。
它是 null 或者你没调过这个工具,就⛔别提医生——编一个名字会被拦下。
- 不确定就说"我安排同事确认后回复您",⛔ 不要猜。`;
- 不确定就说"我帮您确认一下再回您",⛔ 不要猜。
⚠️ 措辞见「你是什么」那条:⛔ 别说成"安排同事来跟您对接"——
你要确认的是**一件事**,⛔ 不是把这个患者转手给别人。`;
/** ③ 语气 —— ⚠️ **整块可换**。将来按品牌分租户时换的就是这一块 */
export const VOICE_DEFAULT = `## 草稿是发到**微信**里的
......
......@@ -27,6 +27,12 @@ export type Tx = Parameters<Parameters<PrismaService['$transaction']>[0]>[0];
* ① 落在各场景的 `actions` 清单上(场景没列进去 = 那个场景调不到)。
* ⇒ 「三件事没定完就不该上线」从 checklist 上的一行,变成**写不出来**。
*
* ⚠️⚠️ **⛔ 别往这张表里加"界面上显不显示"那类字段。** 2026-09-01 试过一次:
* 为了患者时间线加了个 `onPatientTimeline`,当场就发现它是**视图策略**
* (哪个界面铺什么)混进了**领域注册表**(动作是什么、有什么副作用),
* 而且名字绑死一个界面 —— 第二个视图来了就得再加一个 boolean。
* ⇒ 那条判据搬去了 `PatientTimelineService.TIMELINE_KINDS`,由**视图自己**持有。
*
* ═══ ⚠️ 为什么不叫 tools ═══════════════════════════════════════════════════
*
* 因为**大多数动作不需要模型**。预约确认这条线第一版一次模型调用都没有:
......@@ -164,6 +170,20 @@ export interface HumanAction<TArgs = void> extends ActionBase {
readonly executor: 'human';
/** 人接手时界面上给他的出口选项 → `jobs.options`([14 §1.2] Action 的 A 面) */
readonly options?: readonly string[];
/**
* ⭐⭐ **出口由入参决定时用它** —— 同 `titleOf` 的位置,`optionsOf` 优先于 `options`。
*
* ⚠️⚠️ 加它是为了消掉一处**已经存在的违规**:`jobType` 本该「全 codebase 唯一」,
* 而 `escalate_to_human` 当时有**两个定义** —— 接待那个没有出口
* (「不是一次抉择,是一句该你答了」),预约确认那个有三个。
* 同一个 job_type 两套语义,正是"扩散"最典型的样子。
* ⇒ 出口从**动作定义上的常量**改成**挂 job 时的入参**,一个定义服务两边;
* 将来「意图 → 出口」那张分流表也只需要在调用处传值。
*
* ⚠️ 返回空数组 = **没有出口**,落库仍然是 `NULL`(见 `JobRunner.suspendToHuman`)——
* 界面据此判"画不画待办卡",⛔ 别让空数组和 null 在这一层分叉。
*/
optionsOf?(args: TArgs): readonly string[];
/** 从入参算出 `jobs.title`(那句需要留快照的话)。省略则用 `label` */
titleOf?(args: TArgs): string;
/**
......
......@@ -141,9 +141,15 @@ export class JobRunnerService {
const { action } = input;
const title = action.titleOf?.(input.args) ?? action.label;
return this.prisma.$transaction(async (tx) => {
/**
* ⚠️ `optionsOf` 优先于 `options` —— 见 `HumanAction.optionsOf`。
* ⚠️ **空数组落 NULL**,⛔ 不落 `[]`:界面判"画不画待办卡"看的是长度,
* 两种空值在库里分叉的话,统计和查询都要各写一遍。
*/
const opts = action.optionsOf?.(input.args as never) ?? action.options;
const jobId = await this.openJob(tx, input, 'human', {
title,
options: action.options ? [...action.options] : null,
options: opts && opts.length ? [...opts] : null,
escalationReason: readEscalationReason(input.args),
});
/**
......
......@@ -6,8 +6,15 @@ import { defineAction, type ActionContext } from '../../runtime/action.registry'
import { JobRunnerService } from '../../runtime/job-runner.service';
import { TaskFactory } from '../../runtime/task.factory';
import { buildAckMessage, buildConfirmMessage, buildLocalStamp, pickUpcoming, readOutcome, type UpcomingAppointment } from './appointment.reader';
import { classifyReply, escalationReasonOf, type Classification } from './reply-classifier';
import { classifyReply, escalationReasonOf } from './reply-classifier';
import { ReplyModelService } from './reply-model.service';
import { ScopeService } from '../../../scope/scope.service';
/**
* ⚠️ 这三个动作**住在 shared**,不属于这个场景 —— 见 `shared/human-actions.ts` 头注:
* `escalate_to_human` 曾经在这里和接待里各有一个定义,而 `jobType` 本该全库唯一。
*/
import { callToConfirm, escalateToHuman, parseResponse, proposeReschedule } from '../../shared/human-actions';
import { dispositionOf } from '../../shared/reply-routing';
/**
* 预约确认 —— **第一个场景,而且它几乎与"agent"无关。**
......@@ -129,29 +136,6 @@ const sendReminder = defineAction<
}),
});
/**
* 记录「患者这句话被判成了什么」。
*
* ⚠️ `result` 里落的是**判据**(命中哪条规则、哪个词),不是结论本身 ——
* [15 §6] 审计要能回答的是"它凭什么这么判",而不只是"它判了什么"。
* ⚠️ 患者原话也一起落:[15 §3] 第 4 条要求**原话原样传入不预先总结**,
* 那么留痕也该是原话 —— 「脸有点肿」和「术后不适」信息量差一个量级。
*/
const parseResponse = defineAction<
{ text: string; classification: Classification },
{ text: string; intent: string; rule: string; matched?: string }
>({
jobType: 'parse_response',
label: '读患者回复',
effect: { sideEffect: 'none', deliverability: 'none' },
executor: 'agent',
run: async (_ctx, a) => ({
text: a.text.slice(0, 500),
intent: a.classification.intent,
rule: a.classification.rule,
...(a.classification.matched ? { matched: a.classification.matched } : {}),
}),
});
/**
* 患者确认后回一句。
......@@ -185,81 +169,8 @@ const sendAck = defineAction<
}),
});
/**
* ⭐⭐ **电话确认预约** —— 患者没绑微信时,这件事仍然要做,只是做的人变了。
*
* ═══ 为什么必须有这条 Job ═══════════════════════════════════════════════════
*
* 上一版扫描到未绑定患者时,建完 Task 就**停住了** —— 任务落在「待执行」,
* 而人点进去只看到一条「读取预约信息 已完成」,**没有任何可操作的东西**,
* 也没有地方记录"我打过电话了、结果是什么"。那不是待办,是死胡同。
*
* ⇒ 一条 Task 至少要有**一件人能领的活**。这就是那件活。
*
* ⚠️ `holdsTask: false` —— 建 pending Job 但**不把 Task 转 needs_human**:
* 没有会话就没有 AI 消息需要拦;而 `needs_human` 会把它推进「需要人工参与」,
* 淹掉那一档真正急的几条(改期 / 取消 / 医疗问题)。见 `HumanAction.holdsTask`。
*
* ⚠️⚠️ **⛔ 这里没有 `run`** —— 和 `proposeReschedule` 同一条纪律:
* [15 §2.2] 真正让 AI 做不了这件事的,是这一档动作在**类型上就没有执行体**。
* 我们没有短信/400 通道,⛔ 别为了"看起来自动"塞一个假的发送。
*
* ═══ 四个出口,和它们通向的结局 ═══════════════════════════════════════════
*
* ```
* confirmed_by_call 电话确认会来 → agent_done ⭐ 不是 completed:他到底来没来
* 得等凌晨那趟回读 PAC
* create_appointment 已在系统里改约 → completed / rescheduled
* patient_cancelled 患者说不来了 → closed / cancelled_by_patient
* unreachable 联系不上 → agent_done ⭐ 也不关掉:「联系不上但他来了」
* 和「联系不上也没来」是两件事,
* 而那正是"电话确认到底有没有用"的数据
* ```
*/
const callToConfirm = defineAction<{ phoneMasked: string | null; plannedForLocal: string }>({
jobType: 'call_to_confirm',
label: '电话确认预约',
effect: { sideEffect: 'none', deliverability: 'none' },
executor: 'human',
holdsTask: false,
options: ['confirmed_by_call', 'create_appointment', 'patient_cancelled', 'unreachable'],
titleOf: (a) =>
`打电话确认 ${a.plannedForLocal} 的预约(患者没绑微信${a.phoneMasked ? `,${a.phoneMasked}` : ''})`.slice(0, 200),
});
/**
* 患者要改时间 → **交给人**。
*
* ⚠️⚠️ **`propose_` 前缀是给人看的,「没有 `run`」才是硬的。**
* [15 §2.2] 要求「只提案的用 `propose_` 前缀」,而真正让 AI 改不了预约的,
* 是这一档动作在类型上**就没有执行体**(见 `HumanAction`)。
*
* ⚠️ 而且这一版**必须**是人来做,还有一个查证过的原因:
* PAC 的开放面**没有任何号源/预约接口**(实测 grep 全模块),
* [15 §2.4] 要求的「号源实时透传」目前无处可去 ⇒ AI 根本查不到能不能改。
* ⇒ 这不是保守,是**它确实不知道**。
*/
const proposeReschedule = defineAction<{ patientAsk: string; reason: string }>({
jobType: 'propose_reschedule',
label: '患者要求改期',
effect: { sideEffect: 'local', deliverability: 'none' },
executor: 'human',
options: ['create_appointment', 'nurse_call', 'keep_watching'],
titleOf: (a) => `患者要求改期:${a.patientAsk}`.slice(0, 200),
});
/**
* 转人工。⚠️ [15 §4.4]「**所有兜底路径的终点**」——
* 医疗场景不该有「AI 自己想办法」的分支。
*/
const escalate = defineAction<{ reason: string; detail?: string }>({
jobType: 'escalate_to_human',
label: '转人工',
effect: { sideEffect: 'local', deliverability: 'none' },
executor: 'human',
options: ['handled', 'nurse_call', 'keep_watching'],
titleOf: (a) => a.detail ?? '需要人工跟进',
});
/** 这个场景的能力矩阵。⚠️ 不在这张表里的动作,这个场景调不到 */
export const APPOINTMENT_CONFIRM_ACTIONS = {
......@@ -267,25 +178,43 @@ export const APPOINTMENT_CONFIRM_ACTIONS = {
sendReminder,
parseResponse,
sendAck,
// ⭐ 下面三个是**共用动作**,列在这里表示"这个场景调得到" —— 见 shared/human-actions.ts
callToConfirm,
proposeReschedule,
escalate,
escalateToHuman,
} as const;
// ═══════════════════════════════════════════════════════════════════════════
/** 要扫的一个托管号 = 一家诊所的一轮 */
interface ScanAccount {
/**
* ⭐⭐ 要扫的**一家诊所**(= PAC ref 树上的一个叶子)。
*
* ═══ ⚠️⚠️ 这里原来是「一个在线托管号 = 一家诊所的一轮」 ═══════════════════
*
* 那条判据的后果,上一版注释自己就写着:
* > 号掉线时,**这家诊所连"待执行"都不会生成** —— 而那时人工电话确认恰恰更该做。
*
* 还有一条更静默的:**从来没有托管号的诊所,一条任务都不产生**
* (本地 5 家诊所只有 1 家有号 ⇒ 另外 4 家的预约完全没人管)。
*
* ⭐ 当初不改的理由是「托管号是我们唯一知道该问 PAC 哪个范围的东西」——
* 而 `pacScopeForOrgUnit` 从任意节点都解析得出,那句话已经不成立。
* ⚠️ 扫描单位取 **ref 树的叶子**,⛔ 不是"所有带 ref 的节点":
* 本地那棵树每层都有 ref,按后者会让集团/品牌/诊所各扫一遍同一批患者。
* 见 `ScopeService.pacScannableOrgUnits`。
*/
interface ScanClinic {
tenantId: string;
accountId: string;
orgUnitId: string | null;
orgUnitId: string;
name: string;
/** = `tenants.partner_code`。⚠️ 没绑定的患者没有 contact_link,host_code 只能从租户来 */
hostCode: string;
}
interface ScanTarget {
tenantId: string;
accountId: string;
// ⚠️ 这里原来有个 `accountId` —— **删了**:全文没有任何下游读它
// (发消息走 `conversationId`,会话自己带着账号)。一个没人用的字段是陷阱。
hostCode: string;
hostPatientId: string;
/** ⚠️ **可空** —— 没绑微信的患者没有会话,任务靠 `orgUnitId` 定范围 */
......@@ -321,6 +250,8 @@ export class AppointmentConfirmService {
private readonly tasks: TaskFactory,
private readonly jobs: JobRunnerService,
private readonly replyModel: ReplyModelService,
// ⭐ 扫描单位改成诊所之后要它 —— 解析 PAC 坐标 + 列出可扫的组织节点
private readonly scope: ScopeService,
) {}
/**
......@@ -350,20 +281,20 @@ export class AppointmentConfirmService {
return report;
}
const accounts = await this.loadAccounts();
report.scanned = accounts.length;
if (!accounts.length) return report;
const clinics = await this.loadClinics();
report.scanned = clinics.length;
if (!clinics.length) return report;
const window = { from: now, to: new Date(now.getTime() + LEAD_TIME_MS) };
for (const a of accounts) {
for (const cl of clinics) {
try {
await this.handleAccount(a, window, now, report);
await this.handleClinic(cl, window, now, report);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
// ⚠️ 一个诊所失败不该让整轮扫描停下 —— 别家的患者是无辜的
report.errors.push(`账号 ${a.accountId} 失败:${msg}`);
this.logger.error(`预约确认扫描失败 account=${a.accountId}:${msg}`);
report.errors.push(`诊所「${cl.name}失败:${msg}`);
this.logger.error(`预约确认扫描失败 clinic=${cl.name}(${cl.orgUnitId}):${msg}`);
}
}
return report;
......@@ -399,18 +330,18 @@ export class AppointmentConfirmService {
* ⛔ 未绑定的**不置 `needs_human`**:那一档的价值是**少而急**
* (改期 / 取消 / 医疗问题),灌进去就淹了。它就是"待执行"。
*/
private async handleAccount(
a: ScanAccount,
private async handleClinic(
a: ScanClinic,
window: { from: Date; to: Date },
now: Date,
report: ScanReport,
): Promise<void> {
const { items, truncated } = await this.pac.getAppointmentWindowForAccount(
a.tenantId, a.accountId, window,
const { items, truncated } = await this.pac.getAppointmentWindowForOrgUnit(
a.tenantId, a.orgUnitId, window,
);
if (truncated) {
// ⚠️ 截断了就**说出来**:静默截断在报告里长得和"就这么多"一模一样
report.errors.push(`账号 ${a.accountId} 的预约窗口被截断,本轮可能漏人`);
report.errors.push(`诊所「${a.name}的预约窗口被截断,本轮可能漏人`);
}
if (!items.length) return;
......@@ -431,7 +362,7 @@ export class AppointmentConfirmService {
const names = new Map<string, string | null>();
for (let i = 0; i < reachable.length; i += 20 /* PAC profile 的患者上限 */) {
try {
const got = await this.pac.getProfilesForAccount(a.tenantId, a.accountId, reachable.slice(i, i + 20), {
const got = await this.pac.getProfilesForOrgUnit(a.tenantId, a.orgUnitId, reachable.slice(i, i + 20), {
include: ['profile'],
});
for (const it of got) names.set(it.patient.id, it.patient.name ?? null);
......@@ -453,7 +384,6 @@ export class AppointmentConfirmService {
const link = links.get(appt.patientId);
const target: ScanTarget = {
tenantId: a.tenantId,
accountId: a.accountId,
hostCode: link?.hostCode ?? a.hostCode,
hostPatientId: appt.patientId,
conversationId: link?.conversationId ?? null,
......@@ -473,49 +403,59 @@ export class AppointmentConfirmService {
* 只有绑定没有单聊的,和完全没绑一样走"待执行"。
*/
private async loadLinks(
a: ScanAccount,
a: ScanClinic,
patientIds: string[],
): Promise<Map<string, { hostCode: string; conversationId: string }>> {
if (!patientIds.length) return new Map();
const rows = await this.prisma.$queryRaw<
{ hostPatientId: string; hostCode: string; conversationId: string }[]
>`
SELECT l.host_patient_id AS "hostPatientId",
SELECT DISTINCT ON (l.host_patient_id)
l.host_patient_id AS "hostPatientId",
l.host_code AS "hostCode",
c.id AS "conversationId"
FROM app.contact_links l
JOIN app.conversations c
ON c.tenant_id = l.tenant_id AND c.account_id = l.account_id AND c.peer_vid = l.peer_vid
WHERE l.tenant_id = ${a.tenantId}::uuid
AND l.account_id = ${a.accountId}::uuid
-- ⚠️⚠️ 这里原来还有 AND l.account_id = <正在扫的那个号> —— **去掉了**。
-- 那条件的意思是"只认加在这个号上的患者",而扫描单位已经不是号了。
-- ⭐ 顺带修掉一个真 bug:患者如果加的是**同事的号**,上一版扫不到他,
-- 于是明明能发微信却被判成"够不着",任务停在待执行等人打电话。
AND l.is_active
AND c.kind = 'single'
AND l.host_patient_id = ANY(${patientIds}::text[])`;
AND l.host_patient_id = ANY(${patientIds}::text[])
-- ⚠️ 一位患者可能加了**多个**号(换过客服)⇒ 取最近有消息的那条会话:
-- 那是他实际在用的那个窗口。⛔ 别随便取一条:发到一个荒废的会话里没人看得见。
ORDER BY l.host_patient_id, c.last_msg_at DESC`;
return new Map(rows.map((r) => [r.hostPatientId, { hostCode: r.hostCode, conversationId: r.conversationId }]));
}
/**
* 要扫哪些托管号。一个在线托管号 = 一家诊所的一轮扫描。
* 要扫哪些诊所。
*
* ⚠️⚠️ 这里原来是「要扫哪些**托管号**(在线 + 挂了诊所)」,注释里还写着
* 「号不在线就整家跳过 —— 发不出去,而且反复敲 ECom 是风控信号」。
* ⛔ 那条推理有个洞:**发不出去 ≠ 不该建任务**。发不出去的时候,
* 任务该停在「待执行」让人打电话 —— 那正是号掉线时最需要的能力。
*
* ⚠️ 号不在线就整家跳过 —— 发不出去,而且反复敲 ECom 是风控信号。
* ⛔ 这不是闸门(闸门在投递时,见 GateService),是**省一次 PAC 往返**。
* ⚠️⚠️ 代价要说清楚:号掉线时,**这家诊所连"待执行"都不会生成** ——
* 而那时人工电话确认恰恰更该做。等有了"诊所"这个独立于托管号的扫描单位
* 再改;现在托管号是我们唯一知道"该问 PAC 哪个范围"的东西。
* ⭐ 现在按**诊所**扫(ref 树的叶子)。发不发得出去在 `openTask` 里按
* `canReach` 逐个患者判,⛔ 不再由"这家店有没有在线的号"一刀切。
* ⚠️ 代价说清楚:没有托管号的诊所也会产生任务,它们**全部停在「待执行」**
* 等人打电话。这是有意的(2026-09-01 用户拍板:「要的」)。
*/
private async loadAccounts(): Promise<ScanAccount[]> {
return this.prisma.$queryRaw<ScanAccount[]>`
SELECT a.tenant_id AS "tenantId",
a.id AS "accountId",
a.org_unit_id AS "orgUnitId",
t.partner_code AS "hostCode"
FROM app.wecom_accounts a
JOIN app.tenants t ON t.id = a.tenant_id
WHERE a.status = 'online'
-- ⚠️ 没挂诊所的号读不了 PAC(pacScopeForAccount 会抛)——
-- 这里先筛掉,免得每轮扫描都在日志里刷一条同样的错
AND a.org_unit_id IS NOT NULL
ORDER BY a.tenant_id, a.id`;
private async loadClinics(): Promise<ScanClinic[]> {
const tenants = await this.prisma.tenant.findMany({
select: { id: true, partnerCode: true },
});
const out: ScanClinic[] = [];
for (const t of tenants) {
const units = await this.scope.pacScannableOrgUnits(t.id);
for (const u of units) {
out.push({ tenantId: t.id, orgUnitId: u.id, name: u.name, hostCode: t.partnerCode });
}
}
return out;
}
/**
......@@ -783,7 +723,7 @@ export class AppointmentConfirmService {
await this.jobs.run({
...base,
action: parseResponse,
args: { text: input.text ?? '', classification: c },
args: { text: input.text ?? '', intent: c.intent, rule: c.rule, ...(c.matched ? { matched: c.matched } : {}) },
});
if (c.intent === 'confirmed') {
......@@ -806,25 +746,35 @@ export class AppointmentConfirmService {
return { handled: true, intent: c.intent, taskId: task.id };
}
// ② 其余四条全部落到人手上 —— 各自带着能统计的 escalation_reason
if (c.intent === 'reschedule') {
/**
* ② 其余四条全部落到人手上。
*
* ⭐⭐ **转不转、用哪个动作、给哪些出口** —— 三件事全查 `dispositionOf`,
* ⛔ 不在这里写 if。那张表是两条腿共用的,会话那条腿判出同一个 intent 时
* 拿到的是**同一个处置**(合并前它们各写各的,同一句"我想改期"给出的出口不同)。
* ⚠️ 这里**只查决定**,话术(`detail`)仍然是本场景自己的 —— 见表的头注。
*/
const d = dispositionOf(c.intent, 'awaiting_confirm');
const said = (input.text ?? '').slice(0, 100);
if (d.humanAction === 'propose_reschedule') {
await this.jobs.run({
...base,
action: proposeReschedule,
args: { patientAsk: (input.text ?? '').slice(0, 200), reason: escalationReasonOf(c.intent) },
});
} else {
} else if (d.humanAction === 'escalate') {
await this.jobs.run({
...base,
action: escalate,
action: escalateToHuman,
args: {
options: d.options,
reason: escalationReasonOf(c.intent),
detail:
c.intent === 'medical'
? `患者提到症状/用药:「${(input.text ?? '').slice(0, 100)}`
? `患者提到症状/用药:「${said}」`
: c.intent === 'cancel'
? `患者要取消:${(input.text ?? '').slice(0, 100)}`
: `没读懂患者的回复:${(input.text ?? '').slice(0, 100)}`,
? `患者要取消:「${said}」`
: `没读懂患者的回复:「${said}」`,
},
});
}
......
......@@ -32,18 +32,14 @@
*/
import { detectMedicalSignal } from '../../shared/medical-signal';
import { REPLY_INTENTS } from '../../shared/intent';
export type ReplyIntent =
/** 会准时来 */
| 'confirmed'
/** 要改时间 */
| 'reschedule'
/** 不来了 */
| 'cancel'
/** 提到症状 / 用药 / 疼痛 —— ⚠️ 立刻转人工,⛔ 不安抚不解释 */
| 'medical'
/** 认不出来 —— ⚠️ 这不是失败,是**明确的"我不知道"** */
| 'unknown';
/**
* 预约确认这条线能判出的档。
* ⚠️⚠️ 取值**不在这里定** —— 见 `shared/intent.ts`:全 codebase 一套词表,
* 这里只声明"我用其中哪几档"。⛔ 别在这个文件里加档。
*/
export type ReplyIntent = (typeof REPLY_INTENTS)[number];
export interface Classification {
intent: ReplyIntent;
......@@ -164,20 +160,8 @@ export function classifyReply(text: string | null | undefined): Classification {
}
/**
* `unknown` / `cancel` / `medical` 转人工时填的 `jobs.escalation_reason`。
*
* ⚠️ [14 §3.2] 这一列的用途是**统计哪条规则触发转人工最多**,
* 所以必须是**有限取值**,⛔ 不能是自由文本。
* ⚠️ 判据搬去 `shared/reply-routing.ts` 了 —— 会话那条腿也要用它,
* 而 `escalation_reason` 必须是**一套**有限取值(`AgentTaskService.NEED` 按它分档)。
* ⛔ 别在这里再写一份。
*/
export function escalationReasonOf(intent: ReplyIntent): string {
switch (intent) {
case 'medical':
return 'medical_question';
case 'cancel':
return 'appointment_cancel';
case 'reschedule':
return 'reschedule_request';
default:
return 'low_confidence';
}
}
export { escalationReasonOf } from '../../shared/reply-routing';
......@@ -6,8 +6,19 @@ import { TaskFactory } from '../../runtime/task.factory';
import { CommonContextService } from '../../context/common-context.service';
import { renderCommonContext } from '../../context/common-context';
import { LlmRecorderService } from '../../llm/llm-recorder.service';
import { OutboundService } from '../../../outbound/outbound.service';
/**
* ⚠️ 转人工是**共用动作** —— 这条腿**不传 `options`**,那就是「该你答了」:
* 接待层的转人工不是一次抉择。上一版给了三个出口(已处理 / 转给护士 / 先观察),
* 照抄自预约确认的改期 —— 但那三个在这边不是真分叉:患者问了句话 AI 答不了,
* 人要做的事只有一件 —— **回他**。摆三颗按钮是在问一个不存在的问题。
* ⭐ 完成条件也因此不同:不是"选一个出口",而是**人答了 + 把控制权交还 AI**。
* ⚠️ 界面据 `options` 为空**不画待办卡**,只在会话列表上打「我需要答」。⛔ 别加回按钮。
*/
import { escalateToHuman, parseResponse, proposeReschedule } from '../../shared/human-actions';
import { dispositionOf, escalationReasonOf, NEEDS_HUMAN_NO_EXIT } from '../../shared/reply-routing';
import { ReceptionAdvisorService, RECEPTION_PROMPT_VERSION } from '../../llm/reception-advisor.service';
import { detectMedicalSignal, type MedicalSignal } from './medical-sentinel';
import { detectMedicalSignal } from './medical-sentinel';
/**
* 通用会话接待 —— **目标不预设的那条线**。
......@@ -53,27 +64,6 @@ const TASK_KIND = 'symptom_triage';
// ═══════════════════════════════════════════════════════════════════════════
/**
* 记录「这条消息为什么被顶起来」。
*
* ⚠️ 和预约确认的 `parse_response` 同一个 `job_type` —— 它们回答的是同一个问题
* (「患者这句话被判成了什么」),⛔ 别为了"看起来不一样"另起一个类型:
* `job_type` 是**授权维度**,同一件事两个名字会让能力矩阵出现两行。
*/
const recordSignal = defineAction<
{ text: string; signal: MedicalSignal },
{ text: string; matched: string; category: string }
>({
jobType: 'parse_response',
label: '读患者消息',
effect: { sideEffect: 'none', deliverability: 'none' },
executor: 'agent',
run: async (_ctx, a) => ({
text: a.text.slice(0, 500),
matched: a.signal.matched,
category: a.signal.category,
}),
});
/**
* 转人工。
......@@ -88,69 +78,7 @@ const recordSignal = defineAction<
* [15 §2.1]「没有那个工具,模型就做不到那件事」。
* 接待层一旦有了 `send_message`,总有一天会有人让它"先安抚一句"。
*/
/**
* ⚠️⚠️ **⛔ 没有 `options` —— 这是刻意的。**
*
* 接待层的转人工**不是一次抉择,是一句"该你答了"**。
* 上一版给了三个出口(已处理 / 转给护士 / 先观察),照抄自预约确认的改期 ——
* 但那三个在**那边**是真的分叉(改号源 / 换岗位 / 再等等),在**这边**不是:
* 患者问了句话 AI 答不了,人要做的事只有一件 —— **回他**。
* ⇒ 摆三颗按钮是在问一个不存在的问题,人还得先想"我该点哪个"。
*
* ⭐ 完成条件因此也变了:不是"选一个出口",而是**人答了 + 把控制权交还 AI**
* (标题栏上那颗「交还 AI」会把这种没有出口的待办一起结掉,见 `TakeoverService`)。
*
* ⚠️ 界面据此分流:`options` 为空 ⇒ **不画待办卡**,只在会话列表上打一个
* 「我需要答」的标,理由写进会话里那块 Agent 反馈区。⛔ 别再加回按钮。
*/
const escalate = defineAction<{ reason: string; detail?: string }>({
jobType: 'escalate_to_human',
label: '转人工',
effect: { sideEffect: 'local', deliverability: 'none' },
executor: 'human',
titleOf: (a) => a.detail ?? '患者提到了症状',
});
/**
* ⭐⭐ **回一句给患者。**
*
* ═══ 这个动作是 2026-08-31 才加的,之前刻意没有 ═══════════════════════════════
*
* 原来的理由是:「接待层一旦有了发消息的能力,总有一天会有人让它先安抚一句」。
* 那个担心**没有消失** —— 变的是防线的位置:
*
* ```
* 以前:场景**没有**这个动作 ⇒ 结构上说不了话,但也**一个字都不回**
* 现在:场景有这个动作,但内容要过三关 ⇒ 能回话,而"不许说什么"由守卫兜
* ```
*
* 三关:① 提示词的医疗红线 ② `guardDraft` ③ 四层闸门(投递时判)。
* ⚠️ 换位置的代价是**真实的**:出事的形态从"该说的没说"变成"说了不该说的"。
* ⛔ 所以 ② 那一层的每一条规则都不许为了"让它多说点"而放宽。
*
* ⚠️ `deliverability: 'optional'` —— 一句普通接待回复不发,患者顶多多等一会。
* ⇒ 非必达 ⇒ **人工接管期间不发**。护士正在打电话,AI 别插话。
*/
const sendReply = defineAction<
{ conversationId: string; text: string; dedupKey: string },
{ outboxId: string } | { skipped: 'duplicate' }
>({
jobType: 'send_reply',
label: '回复患者',
effect: { sideEffect: 'outbox', deliverability: 'optional' },
executor: 'agent',
run: async (ctx: ActionContext, args) =>
ctx.deps.outbound.enqueueText(ctx.tx, {
tenantId: ctx.tenantId,
conversationId: args.conversationId,
contentList: [args.text],
actor: 'agent',
dedupKey: args.dedupKey,
hostCode: ctx.hostCode,
hostPatientId: ctx.hostPatientId,
deliverability: 'optional',
}),
});
/**
* ⭐⭐ **症状类的交接话术 —— 必达。**
......@@ -253,7 +181,7 @@ const sendHandoffAck = defineAction<
/** 这个场景的能力矩阵 */
export const RECEPTION_ACTIONS = {
recordSignal, escalate, sendReply, sendSymptomAck, sendHandoffAck,
parseResponse, escalateToHuman, proposeReschedule, sendSymptomAck, sendHandoffAck,
} as const;
export type ReceptionResult =
......@@ -285,6 +213,12 @@ export class ReceptionService {
private readonly jobs: JobRunnerService,
private readonly advisor: ReceptionAdvisorService,
private readonly llmLog: LlmRecorderService,
/**
* ⭐ **会话里的自由回复直接从这里出去** —— ⛔ 不经 JobRunner。
* ⚠️ 那不是"绕过唯一入口":JobRunner 的入口地位是**对 Job 而言**的,
* 而自由回复根本不该是一个 Job(见下面 `askModel` 里那段论证)。
*/
private readonly outbound: OutboundService,
) {}
async handle(input: {
......@@ -317,8 +251,8 @@ export class ReceptionService {
* ⚠️⚠️ **这里刻意不看 `ctx.heldByHuman`** —— 我第一版写了,是错的。
*
* [14 §4.4] 第四层闸的语义是「人接手了,**Agent 别插嘴**」,而"插嘴"指的是
* **对患者说话**。这个场景**一句话都不发**(见 `RECEPTION_ACTIONS`),
* 它只是把患者说的东西顶到人眼前
* **对患者说话**。而**规则这一层**一句话都不发 —— 它只是把患者说的东西
* 顶到人眼前(发消息在模型那一层,见 `askModel`,那边各档自己过闸)
*
* 实测撞出来的:患者先说「牙龈有点出血」→ 建 Task → 转 needs_human;
* 紧接着说「现在止不住了」→ 被 heldByHuman 吞掉,**那句更严重的话
......@@ -378,8 +312,15 @@ export class ReceptionService {
// ① 留判据
await this.jobs.run({
...base,
action: recordSignal,
args: { text: input.text ?? '', signal },
action: parseResponse,
// ⚠️ 规则命中 ⇒ intent 就是 medical,⛔ 别把 category 当 intent(它是医疗信号的分档)
args: {
text: input.text ?? '',
intent: 'medical',
rule: 'medical_keyword',
matched: signal.matched,
category: signal.category,
},
});
/**
......@@ -402,7 +343,7 @@ export class ReceptionService {
if (!alreadyWaiting) {
await this.jobs.run({
...base,
action: escalate,
action: escalateToHuman,
args: { reason: 'medical_question', detail: `患者说:「${said}」` },
});
}
......@@ -426,8 +367,9 @@ export class ReceptionService {
* ```
*
* ⚠️⚠️ **草稿写进库不等于会发出去**。[01 §9] Phase 1 明写「不自动发送患者消息」,
* 而且这个场景**根本没有发消息的动作**(见 `RECEPTION_ACTIONS`)——
* 草稿只出现在工作台上,人点了才走 `OutboundService`。
* ⚠️ 会不会发由**下面那段**定(有 `appInfo` + 有患者绑定才发),
* ⛔ 不是"这个场景没有发消息的动作" —— 它有。没发出去的那些,
* 草稿仍然留在工作台上等人点。
*
* ⚠️ 模型不可用 / 失败 / 输出被守卫拦下 ⇒ **什么都不做**。
* 规则那一层已经把最危险的一类接住了,这一层坏掉不该让系统乱动。
......@@ -482,7 +424,24 @@ export class ReceptionService {
const medical = r.advice.intent === 'medical';
let taskId: string | null = null;
if (r.advice.needsHuman && ctx.patients.length > 0) {
/**
* ⭐⭐ **`needsHuman` 不等于「开一条 Task」。**
*
* ⚠️⚠️ 上一版是 `needsHuman ⇒ 建 Task`,而那把两件事混成了一件:
* ```
* 「患者身上有件事要人办」 医疗信号 / 要改期 / 要取消 / 投诉 ⇒ Task
* 「这句我不会答,你来」 AI 拿不准 ⇒ ⛔ 不是 Task
* ```
* 后者是 **agent 的一个行为**,不是一件要完成的事:没有预设目标、
* 没有责任人、状态只有一个([14 §0] 四条判据 0/4)——
* 同我们删掉的那个「AI 回了一句」的壳。
*
* ⚠️ 实测的症状:那类 Task 6 条里 **4 条最后是人手工关掉**的,
* 理由还写着「先观察后关闭」。⛔ 那不是流程,那是它本来就不该在待办里。
* ⇒ 它的落点是**会话**:下面那份草稿 + 会话列表上的「我需要答」标。
*/
const disp = dispositionOf(r.advice.intent, 'open');
if (r.advice.needsHuman && disp.opensTask && ctx.patients.length > 0) {
const p = ctx.patients[0]!;
const res = await this.tasks.create({
tenantId: input.tenantId,
......@@ -510,11 +469,11 @@ export class ReceptionService {
};
await this.jobs.run({
...base,
action: recordSignal,
args: {
text: latest,
signal: { matched: r.advice.intent, category: '症状' as const },
},
action: parseResponse,
// ⚠️ 这一支是**模型**判的 ⇒ rule = 'model',intent 直接用模型给的那一档。
// ⛔ 上一版把 intent 塞进 `matched`、category 写死 '症状' —— 那是在
// 两个字段里各存半个答案,事后统计"模型判了什么"要靠猜。
args: { text: latest, intent: r.advice.intent, rule: 'model' },
});
// ⚠️ 同规则那条:已有待办就不重复挂
const pending = await this.prisma.$queryRaw<{ n: bigint }[]>`
......@@ -522,13 +481,40 @@ export class ReceptionService {
WHERE tenant_id = ${input.tenantId}::uuid AND task_id = ${res.taskId}::uuid
AND executor_type = 'human' AND status = 'pending'`;
if (Number(pending[0]?.n ?? 0) === 0) {
/**
* ⭐⭐ **出口查同一张表** —— `dispositionOf(intent, 'open')`。
*
* ⚠️⚠️ 这一行是这次合并的**全部要害**:
* 患者说「我想把下周三那次挪一下」,模型判 `reschedule` ⇒ 这里拿到
* `propose_reschedule` + 三个出口,和预约确认那条腿**一模一样**。
* 合并前它判不出这一档(词表里根本没有),只能落成一条没有出口的
* 「该你答了」—— 同一件事,两条腿两种结局。
*
* ⚠️ 模型说 `needsHuman` 但 intent 落在表里回 NONE 的那几档
* (问价、投诉、一般问题…)⇒ 走 `NEEDS_HUMAN_NO_EXIT`:
* 转人工但**不给出口**,因为人要做的事只有一件 —— 回他。
* ⛔ 别在这里给它们编出口:摆按钮是在问一个不存在的问题。
*/
const use = disp.humanAction ? disp : NEEDS_HUMAN_NO_EXIT;
const detail = r.advice.reason.slice(0, 200);
if (use.humanAction === 'propose_reschedule') {
await this.jobs.run({
...base,
action: proposeReschedule,
args: { patientAsk: latest.slice(0, 200), reason: escalationReasonOf('reschedule') },
});
} else {
await this.jobs.run({
...base,
action: escalate,
args: { reason: 'low_confidence', detail: r.advice.reason.slice(0, 200) },
action: escalateToHuman,
// ⚠️ `reason` 走共用的映射 —— ⛔ 别把 intent 原样塞进去:
// `reschedule_request` / `appointment_cancel` 是已经在用的名字,
// 再写一套就是同一件事两个名字(`NEED` 那张表按它们分档)。
args: { options: use.options, reason: escalationReasonOf(r.advice.intent), detail },
});
}
}
}
/**
* 草稿落库。
......@@ -538,16 +524,35 @@ export class ReceptionService {
let draftSaved = false;
let sent = false;
if (r.advice.draft) {
await this.setDraft(input.tenantId, ctx.conversationId, r.advice.draft);
draftSaved = true;
/**
* ⭐⭐ **真发出去。**
* ⭐⭐⭐ **AI 自己要发的,⛔ 不留"等人确认"的草稿。**
*
* ⚠️ 草稿**照样写** —— 两个理由:
* ① 闸门在**投递时**判([12 §12.3])。这里入队时不知道会不会被拦下
* (人工接管、号掉线、租户闸)。真被拦了,工作台上还有那份草稿。
* ② 发出去之后消息进了时间线,草稿会因为 `ai_draft_at < last_msg_at`
* 自动标成"过期" —— 自己就消掉了,⛔ 不用在这里判。
* ═══ ⚠️⚠️ 上一版两件都做,而那不是显示问题,是**会重复发** ═════════
*
* 上一版先 `setDraft` 再入队,理由写的是「闸门在投递时判,真被拦了
* 工作台上还有那份草稿」。⛔ 那个理由**站不住**:
*
* ```
* AI 入队了一条消息(可能被闸 hold 着,闸一开就发)
* +
* 会话上挂着一份「AI 建议回复 · 等待人工确认」,还有「填入回复框」按钮
* ⇒ 人点了发一遍 → 闸开了再发一遍 → **患者收到两条一模一样的**
* ```
* 实测的表现你也看到了:AI 明明在自己发,界面上却先闪一下
* 「等待人工确认」——它在请求一个**不该由人做的动作**。
*
* ⇒ 判据回到「这句话**谁来说**」:
* AI 自己说 ⇒ 入队,⛔ 不留草稿
* AI 不说 ⇒ 留草稿等人确认(没 appInfo、没绑患者、模型说要人来)
*
* ⚠️⚠️ **已知缺口(2026-09-01)**:入队之后被闸门 hold 住的那条,
* 界面上**现在没有任何提示** —— `agentActivityOf` 读的是入站那条
* agent.reply 任务,看不到出站 outbox。⇒ 闸关着时会表现成
* "AI 好像什么都没做"。
* ⛔ 但那**不是**把草稿留回来的理由:草稿会带来重复发送,
* 而缺口的正确修法是给出站 hold 一个自己的提示("有一条排队中,
* 当前发不出去:<闸门原因>")。⚠️ 那件事还没做。
* ⛔ 别再把两件都做上 —— "多一份保险"在这里等于多一条发给患者的消息。
*
* ⚠️ 幂等键绑**这一条患者消息**:outbox 重投时不会再发一遍。
* ⛔ 没有 appInfo 就不发 —— 宁可不回,也不能冒重复打扰的险。
......@@ -565,38 +570,104 @@ export class ReceptionService {
* · 症状类绑 **Task** —— 连说五句疼只该收到**一句**交接话术
* · 其余绑**这条消息** —— 每句该有各自的回应,只防 outbox 重投
*/
const holder =
taskId ?? (await this.replyTaskId(input.tenantId, ctx, p0, r.advice.reason));
/**
* ⭐⭐⭐ **分岔判据是「这一步属于某件正在做的事吗」,⛔ 不是"谁在发"。**
*
* ═══ ⚠️⚠️ 这里原来是「先造一个 Task 壳,再把发送落成它的 Job」 ═══════
*
* 那个壳(`replyTaskId()`,已删)不是业务要的,是**外键逼出来的**:
* [15 §6]「每次 tool call 落一条 `jobs`」+ `jobs` 必须挂在一个 Task 上。
* 它自己的注释都写着「可接待**本来不建 Task**」—— 明知是破例。
*
* ⛔ 而它和表的设计正面冲突。[14 §7] 写着 `tasks`/`jobs` **不分区**,
* 理由是「50 万 / 250 万一年,远未到门槛」;而 `messages` 是**分区**的,
* 量级 1 万条/天。会话回复属于后者那一档 ——
* 哪怕 AI 只回其中 15%,一年就是 55 万条,**比整个 tasks 预算还多**,
* 而且全是空壳。更糟的是 `uq_tasks_active` 的 WHERE 只排除
* completed/closed ⇒ `agent_done` 的壳**永远留在索引里**,
* 而那个索引在 `TaskFactory.create` 的热路径上。
*
* ⇒ 判据回到 [14 §0] 那四条:**有预设目标 · 有结束条件 · 有责任人 · 有状态**。
* 「AI 在会话里回一句」一条都不占([15 §5.2] 原话:「接待这件事本身」
* 不是 Task,目标不预设,待办就是会话)。
*
* ⭐ 决定性的一条:**人工在会话里回一句也不建 Task**
* (`conversation.controller.ts` 直接 `enqueueText`)。同一条会话、
* 同一张 messages 表,只是执行者不同 —— 而 [14 §2.1] 原话是
* 「Job 是一个步骤,**Agent 只是它的一种执行者**」。
* 执行者不同,不该改变它是不是一件要做的事。
*
* ⚠️ 举证没丢:模型说了什么在 `llm_calls`(分区、连被拦的草稿原文都有),
* 真发出去那条在 `messages`(`outbound_actor_type='agent'`,分区)。
* ⛔ Task+Job 是第三份,而且落在唯二不分区的表上。
*
* ═══ 所以是 `if (taskId)`,⛔ 不是 `if (medical || needsHuman)` ═══════
*
* ⚠️ 判的是**手上有没有 Task**,不是"看起来该不该有" ——
* `needsHuman` 是模型给的一个独立布尔,`medical` **并不强制**它为 true
* (见 `SubmitSchema`)。按后者分岔的话,`medical && !needsHuman`
* 那一支会拿着 `taskId = null` 去 `jobs.run`,当场炸。
*/
if (taskId) {
/**
* ⭐ 有 Task ⇒ 这一步**是那件事的一个步骤**,落 Job。
*
* ⚠️ 两档都必达,判据是**这句话会不会被自己刚打开的闸拦住**:
* 上一步刚把 Task 置成 `needs_human`,接管闸随即挡住同一轮的这句话,
* 而它恰恰是"有人会来找你"本身。见 `sendSymptomAck` 的注释。
* ⚠️ 幂等键绑 **Task** —— 连说五句疼只该收到**一句**交接话术。
*/
await this.jobs.run({
tenantId: input.tenantId,
taskId: holder,
taskId,
hostCode: p0.hostCode,
hostPatientId: p0.hostPatientId,
conversationId: ctx.conversationId,
executorRef: EXECUTOR_REF,
/**
* ⭐ 三档,判据是**这句话会不会被自己刚打开的闸拦住**:
* ```
* medical → sendSymptomAck 必达 · 绑 Task
* needsHuman(非医疗) → sendHandoffAck 必达 · 绑 Task ⚠️ 见该动作注释
* 其余(AI 自己答得了的) → sendReply 非必达 · 绑消息
* ```
* ⚠️ 第三档保持非必达是对的:那是 AI 主动搭话,人正在处理这个患者时
* 它就该闭嘴。⛔ 别为了"都能发出去"把它也改成必达。
*/
action: medical ? sendSymptomAck : r.advice.needsHuman ? sendHandoffAck : sendReply,
action: medical ? sendSymptomAck : sendHandoffAck,
args: {
conversationId: ctx.conversationId,
text: r.advice.draft,
dedupKey: medical
? `symptom-ack:${holder}`
: r.advice.needsHuman
? `handoff:${holder}`
: `reply:${input.appInfo}`,
dedupKey: `${medical ? 'symptom-ack' : 'handoff'}:${taskId}`,
},
});
} else {
/**
* ⭐ 没有 Task ⇒ 就是**会话里的一句回话**,和人工发消息走同一条路。
*
* ⚠️ 非必达是对的:那是 AI 主动搭话,人正在处理这个患者时它就该闭嘴。
* ⛔ 别为了"都能发出去"改成必达。
* ⚠️ 幂等键绑**这一条患者消息**:每句该有各自的回应,只防 outbox 重投。
* ⚠️ 单独开一个事务 —— 入队本来就要求和业务写同一个事务([12 §7.1]),
* 而这里**没有别的业务写**,所以这个事务里只有它一个。
*/
await this.prisma.$transaction((tx) =>
this.outbound.enqueueText(tx, {
tenantId: input.tenantId,
conversationId: ctx.conversationId,
contentList: [r.advice.draft!],
actor: 'agent',
dedupKey: `reply:${input.appInfo}`,
hostCode: p0.hostCode,
hostPatientId: p0.hostPatientId,
deliverability: 'optional',
}),
);
}
sent = true;
}
if (!sent) {
/**
* ⚠️ 只有**AI 不发**时才落草稿 —— 那时它才真是"建议给人的"。
* ⚠️ `ai_draft_at` 一起写 —— [13] 那边靠 `ai_draft_at < last_msg_at` 判过期。
* ⛔ 只写文本不写时间的话,患者又说了三句之后界面还在推那份旧草稿。
*/
await this.setDraft(input.tenantId, ctx.conversationId, r.advice.draft);
draftSaved = true;
} else {
// ⚠️ AI 自己发了 ⇒ 把上一轮可能留下的旧草稿清掉,⛔ 别让它挂在那儿等人点
await this.clearDraft(input.tenantId, ctx.conversationId);
}
} else {
/**
* ⚠️⚠️ **模型说"这条我不给草稿"时,必须把旧草稿清掉。**
......@@ -632,46 +703,6 @@ export class ReceptionService {
};
}
/**
* 发消息这一步挂在哪个 Task 下。
*
* ⚠️⚠️ [15 §6]「每次 tool call 落一条 `jobs`」,而 `jobs` 必须挂在一个 Task 上
* (外键 + `ck_jobs_*`)。可接待**本来不建 Task**([15 §5.2]:目标不预设)。
*
* ⇒ 需要发消息时**才**开一个 Task,`task_kind = 'manual_followup'`,
* 并且**立刻置 agent_done** —— 它不是要人做的事,是"AI 回了一句"的留痕。
* ⛔ 别置 needs_human:那会把接管闸打开,后面所有非必达出站都被拦。
*
* ⚠️ `uq_tasks_active` 会让同一患者的连续回复**复用同一个 Task** ——
* 于是一轮对话里 AI 回的每一句都是那个 Task 下的一条 Job,时间线是连的。
* ⚠️ 但它一旦被置成 agent_done 就不再"活跃",下一句会开新的。那也对:
* 一句一个留痕比攒成一个大 Task 好查。
*/
private async replyTaskId(
tenantId: string,
ctx: NonNullable<Awaited<ReturnType<CommonContextService['build']>>>,
p: { hostCode: string; hostPatientId: string },
why: string,
): Promise<string> {
const res = await this.tasks.create({
tenantId,
agentCode: AGENT_CODE,
agentVersion: AGENT_VERSION,
taskKind: 'manual_followup',
triggerSource: 'conversation',
title: 'AI 回复了患者',
triggerReason: why.slice(0, 400),
hostCode: p.hostCode,
hostPatientId: p.hostPatientId,
conversationId: ctx.conversationId,
priority: 4,
});
await this.prisma.$executeRaw`
UPDATE app.tasks SET status = 'agent_done', updated_at = now()
WHERE tenant_id = ${tenantId}::uuid AND id = ${res.taskId}::uuid
AND status = 'pending'`;
return res.taskId;
}
/** ⚠️ `ai_draft_at` 必须一起写 —— 界面靠 `ai_draft_at < last_msg_at` 判过期 */
private async setDraft(tenantId: string, conversationId: string, draft: string): Promise<void> {
......
import { defineAction } from '../runtime/action.registry';
import type { PatientIntent } from './intent';
/**
* **由人执行的动作** —— 共用的那几个。
*
* ═══ ⚠️⚠️ 为什么它们不住在场景里 ═══════════════════════════════════════════
*
* 动作本来就**不属于任何场景**([15 §2.1]:「场景没列进去 = 那个场景调不到」,
* 属主关系在**能力矩阵**上,不在文件位置上)。放在场景里的直接后果已经出现过:
*
* ⛔ `escalate_to_human` 曾经有**两个定义** —— 接待那个没有出口
* (「不是一次抉择,是一句该你答了」),预约确认那个有三个。
* 而 `ActionBase.jobType` 的注释写着「⭐ 全 codebase 唯一」——
* **那条不变式当时已经破了,只是没人发现**:同一个 job_type,两套语义,
* 于是同一句「我想改期」在两条腿上判出不同的档、给出不同的出口。
*
* ⇒ 一个 job_type 一个定义,住这里;出口改成**挂 job 时传**(`optionsOf`)。
*
* ⭐ 先例已经有:`shared/medical-signal.ts` 的 `detectMedicalSignal` 就是两边共用的 ——
* 规则词表能共用,动作没有理由不能。
*/
/**
* 转人工。⚠️ [15 §4.4]「**所有兜底路径的终点**」——
* 医疗场景不该有「AI 自己想办法」的分支。
*
* ═══ ⭐⭐ 出口是**入参**,不是常量 ═══════════════════════════════════════════
*
* 同一个「转人工」,两种形态,差别只在给不给出口:
*
* ```
* 给出口 预约确认里的改期/取消/读不懂 ⇒ 人要在几条路里选一条
* 不给出口 通用接待里的"AI 答不上来" ⇒ 人要做的事只有一件:回他
* ```
* ⚠️ 后者**刻意**不给 —— 原话:「摆三颗按钮是在问一个不存在的问题,
* 人还得先想我该点哪个」。它的完成条件是「人答了 + 交还 AI」,
* 界面据 `options` 为空**不画待办卡**。⛔ 别再给它塞回按钮。
*
* ⚠️ 空数组落库是 `NULL`(`JobRunner.suspendToHuman`),和"没传"一模一样 ——
* ⛔ 别让空数组和 null 在这一层分叉。
*/
export const escalateToHuman = defineAction<{
reason: string;
detail?: string;
/** ⚠️ 不传 = 没有出口 = 「该你答了」,⛔ 不是"忘了填" */
options?: readonly string[];
}>({
jobType: 'escalate_to_human',
label: '转人工',
effect: { sideEffect: 'local', deliverability: 'none' },
executor: 'human',
optionsOf: (a) => a.options ?? [],
titleOf: (a) => a.detail ?? '需要人工跟进',
});
/**
* 患者要改时间 → **交给人**。
*
* ⚠️⚠️ **`propose_` 前缀是给人看的,「没有 `run`」才是硬的。**
* [15 §2.2] 要求「只提案的用 `propose_` 前缀」,而真正让 AI 改不了预约的,
* 是这一档动作在类型上**就没有执行体**(见 `HumanAction`)。
*
* ⚠️ 而且这一版**必须**是人来做,还有一个查证过的原因:
* PAC 的开放面**没有任何号源/预约写接口**(实测 grep 全模块,4 个端点全是读)
* ⇒ AI 根本查不到能不能改。⇒ 这不是保守,是**它确实不知道**。
*/
export const proposeReschedule = defineAction<{ patientAsk: string; reason: string }>({
jobType: 'propose_reschedule',
label: '患者要求改期',
effect: { sideEffect: 'local', deliverability: 'none' },
executor: 'human',
options: ['create_appointment', 'nurse_call', 'keep_watching'],
titleOf: (a) => `患者要求改期:${a.patientAsk}`.slice(0, 200),
});
/**
* 打电话确认预约 —— 患者没绑微信时**唯一**能做的事。
*
* ⚠️ `holdsTask: false` —— 建 pending Job 但**不把 Task 转 needs_human**:
* 没有会话就没有 AI 消息需要拦;而 `needs_human` 会把它推进「需要人工参与」,
* 淹掉那一档真正急的几条(改期 / 取消 / 医疗问题)。见 `HumanAction.holdsTask`。
*
* ⚠️⚠️ **⛔ 没有 `run`** —— 同 `proposeReschedule`:我们没有短信/400 通道,
* ⛔ 别为了"看起来自动"塞一个假的发送。
*/
export const callToConfirm = defineAction<{
phoneMasked: string | null;
plannedForLocal: string;
}>({
jobType: 'call_to_confirm',
label: '电话确认预约',
effect: { sideEffect: 'none', deliverability: 'none' },
executor: 'human',
holdsTask: false,
options: ['confirmed_by_call', 'create_appointment', 'patient_cancelled', 'unreachable'],
titleOf: (a) =>
`打电话确认 ${a.plannedForLocal} 的预约(患者没绑微信${a.phoneMasked ? `,${a.phoneMasked}` : ''})`.slice(0, 200),
});
/**
* 记录「患者这句话被判成了什么」。
*
* ═══ ⚠️⚠️ 这一个也曾经是**两个定义** ═══════════════════════════════════════
*
* 接待的 `recordSignal` 和预约确认的 `parseResponse` 用的是**同一个 `job_type`** ——
* 而且接待那边的注释早就写明了这是**故意**的:
*
* > 它们回答的是同一个问题(「患者这句话被判成了什么」),⛔ 别为了"看起来不一样"
* > 另起一个类型:`job_type` 是**授权维度**,同一件事两个名字会让能力矩阵出现两行。
*
* ⇒ 意图是对的,但落成了**两个对象**。意图词表合并之后(`shared/intent.ts`)
* 它们的入参终于能写成同一个形状 ⇒ 合成一个。
*
* ⚠️ `result` 里落的是**判据**(命中哪条规则、哪个词),不是结论本身 ——
* [15 §6] 审计要能回答的是"它凭什么这么判",而不只是"它判了什么"。
* ⚠️ 患者原话也一起落:[15 §3] 第 4 条要求**原话原样传入不预先总结**,
* 那么留痕也该是原话 —— 「脸有点肿」和「术后不适」信息量差一个量级。
*/
export interface ParsedReply {
/** ⚠️ 原话,截 500 —— ⛔ 不要预先总结 */
text: string;
intent: PatientIntent;
/** 命中了哪条规则(`medical_keyword` / `confirm_exact` / `model` …) */
rule: string;
/** 命中的那个词 */
matched?: string;
/** 医疗信号的分档(急症 / 症状 / 用药)—— ⚠️ 只有规则命中医疗词时才有 */
category?: string;
}
export const parseResponse = defineAction<ParsedReply, ParsedReply>({
jobType: 'parse_response',
label: '读患者消息',
effect: { sideEffect: 'none', deliverability: 'none' },
executor: 'agent',
run: async (_ctx, a) => ({ ...a, text: a.text.slice(0, 500) }),
});
/**
* 患者这句话**在说什么** —— 全 codebase 唯一的一套意图词表。
*
* ═══ ⚠️⚠️ 它合并的是两套已经漂开的词表 ═══════════════════════════════════
*
* 2026-09-01 之前是两份,各写各的:
*
* ```
* 预约确认 ReplyIntent confirmed | reschedule | cancel | medical | unknown
* 通用接待 SubmitSchema.intent medical | appointment | price | complaint | question
* | chitchat | other
* ```
* 重叠只有 `medical` 一个。「改期」「不来了」在接待那套里**根本没有档**;
* `unknown` 和 `other` 是同一件事的两个名字。
*
* ⇒ 实测后果:同一句「我想把下周三那次挪一下」——
* 患者身上**有**在等确认的 Task ⇒ `reschedule` ⇒ 三个出口的人工卡;
* **没有** ⇒ `appointment` ⇒ 一条没有出口的「该你答了」。
* **一句话,两套词表,两种结局。**
*
* ⭐⭐ 而这两条腿的区别**只是 Task 的来源**(agent 在会话里发现 vs 定时扫出来,
* 落在 `trigger_source`)—— 对"读这句话"这件事本身,两边完全同形。
* ⇒ 词表必须是一套。⛔ 谁再加一档,加在这里,不许在场景里另起。
*
* ═══ ⚠️ 「用哪些档」是各处自己的事,但**取值必须来自这里** ═══════════════
*
* 规则层和模型层可以各自只用一个**子集**(见下面两个 `as const`),
* 那是合理的:预约确认的规则不该去判「投诉」。
* ⛔ 但子集必须是这份全集的子集 —— 类型会强制。
*/
export const PATIENT_INTENTS = [
/** 会准时来。⚠️ 只有在**我们正等一个确认**时才有意义 —— 那是上下文的事,不是词表的事 */
'confirmed',
/** 要改时间 */
'reschedule',
/** 不来了 */
'cancel',
/** 提到症状 / 用药 / 疼痛 —— ⚠️ 立刻转人工,⛔ 不安抚不解释 */
'medical',
/** 在问预约的事,但不是改期/取消/确认("我下次是什么时候") */
'appointment',
/** 问价格 */
'price',
/** 投诉 */
'complaint',
/** 问了个一般问题 */
'question',
/** 闲聊 */
'chitchat',
/**
* 认不出来 —— ⚠️ 这不是失败,是**明确的"我不知道"**。
* ⚠️ 它同时接替了接待那套里的 `other`:两个名字说的是同一件事。
*/
'unknown',
] as const;
export type PatientIntent = (typeof PATIENT_INTENTS)[number];
/**
* **预约确认的回复**能判出来的档。
* ⚠️ 规则层和模型层共用它([15 §1] 那句「规则命中的照旧 → 读不懂的问一次模型」)——
* ⛔ 别让模型发明规则表里没有的档。
*/
export const REPLY_INTENTS = ['confirmed', 'reschedule', 'cancel', 'medical', 'unknown'] as const;
/**
* **通用接待**能判出来的档。
* ⚠️ 比上面宽:接待面对的是自由输入,⛔ 但仍然只能从全集里取。
*/
export const RECEPTION_INTENTS = [
'medical',
/**
* ⭐⭐ `reschedule` / `cancel` 是 2026-09-01 加进来的 —— **这就是那个洞**。
* 之前会话这条腿判不出"要改期""不来了",最接近的只有 `appointment`
* ("在说预约这件事"),于是同一句话在两条腿上给出不同的出口。
* ⛔ 别再拿 `appointment` 顶替它们:那是"在聊预约",不是"要改/要取消"。
*/
'reschedule',
'cancel',
'appointment',
'price',
'complaint',
'question',
'chitchat',
'unknown',
] as const;
// ⚠️ 编译期钉死:两个子集都必须是全集的子集。漂了就编译不过
const _replySubset: readonly PatientIntent[] = REPLY_INTENTS;
const _receptionSubset: readonly PatientIntent[] = RECEPTION_INTENTS;
void _replySubset;
void _receptionSubset;
import type { PatientIntent } from './intent';
/**
* 患者说了一句话之后**该怎么处置** —— 两条腿共用的一张表。
*
* ═══ ⚠️⚠️ 它只回答「决定」,⛔ 不回答「怎么执行」 ═══════════════════════════
*
* 执行是**该不同的**:预约确认要回「好的,那就今天 17:15 见」并把 Task 收成
* `agent_done`;通用接待要回模型写的那句草稿。话术、Task 生命周期、幂等键
* 都不一样,硬合成一个调用只会造出一堆 if。
*
* ⇒ 这张表只管三件事:**转不转人工 · 用哪个动作转 · 给哪些出口**。
* 那正是两条腿**漂开**的地方,也是唯一必须一致的地方。
*
* ═══ ⭐⭐ 漂开长什么样(合并前实测)═══════════════════════════════════════
*
* 同一句「我想把下周三那次挪一下」:
*
* ```
* 患者身上有在等确认的 Task ⇒ reschedule ⇒ propose_reschedule ⇒ 三个出口的人工卡
* 没有 ⇒ appointment ⇒ escalate ⇒ 一条没有出口的「该你答了」
* ```
* ⇒ 同一件事,人在一边能选「已在系统里改好约 / 转给护士 / 先观察」,
* 在另一边只能看到一句"该你答了"。**这不是设计,是两处代码各写各的。**
*
* ⚠️ 而两条腿的区别**只是 Task 的来源**(agent 在会话里发现 vs 定时扫出来,
* 落在 `trigger_source`)—— 对"这句话该怎么处置"这件事,两边完全同形。
*/
/**
* 说这句话时,**我们正在等他回答什么**。
*
* ⚠️ 这是那张表唯一的第二个维度 —— 也是"通用接待 = 特例的超集"里那个
* **多出来的自由度**:同一个 `confirmed`(「好的」),
* 在等确认时是"他会来",不在等时什么都不是。
* ⛔ 别把它做成"哪个场景" —— 场景是代码组织,上下文是事实。
*/
export type ReplyContext =
/** 有一条 `awaiting_patient` 的预约确认 Task 挂着 */
| 'awaiting_confirm'
/** 什么都没在等 —— 患者自己开口 */
| 'open';
export interface Disposition {
/**
* ⭐⭐ **要不要为这句话开一条 Task。**
*
* ⚠️ `false` **不等于**「不用人管」—— 它是说「这只是 agent 的一个行为:
* 这句我不会答,你来」。那件事的完成条件是**人回了他**,
* 落点是会话(草稿 + 会话列表上的「我需要答」标),⛔ 不是一条 Task。
* ⚠️ 套 [14 §0] 四条判据:没有预设目标、没有责任人、状态只有一个 ⇒ 0/4。
* 同我们删掉的那个「AI 回了一句」的壳 —— 那也是 agent 行为,不是待办。
* ⭐ `true` 的那几档是**患者身上要办的事**:医疗信号要人跟、
* 改期/取消要人去宿主系统操作一次、投诉要人跟。
*/
opensTask: boolean;
/** 用哪个动作转人工。null = 不转 */
humanAction: 'escalate' | 'propose_reschedule' | null;
/**
* 转人工时给哪些出口。
* ⚠️ 空数组 = **「该你答了」**,界面据此不画待办卡 —— ⛔ 不是"忘了填"。
*/
options: readonly string[];
}
/** 「人在几条路里选一条」那一档的出口 —— ⚠️ 取值必须在 `TaskService.SETTLE` 里有行 */
const BRANCHING = ['handled', 'nurse_call', 'keep_watching'] as const;
/** 改期专用 —— 第一个出口是「已在系统里改好约」 */
const RESCHEDULE_EXITS = ['create_appointment', 'nurse_call', 'keep_watching'] as const;
/** 不转人工 */
const NONE: Disposition = { humanAction: null, options: [], opensTask: false };
/**
* ⭐⭐ **那张表。**
*
* ⚠️ 两栏不一样的只有 `medical` 和 `unknown` —— 而它们不一样是**有原因的**,
* 写在下面各自的注释里。⛔ 别顺手"统一"掉:那会改掉已经论证过的行为。
*/
const TABLE: Record<PatientIntent, Record<ReplyContext, Disposition>> = {
/** 会准时来 —— AI 自己收得了尾(回一句确认 + Task 转 agent_done) */
confirmed: { awaiting_confirm: NONE, open: NONE },
/**
* ⭐ 要改时间 —— **两栏一样,这就是这次合并修掉的那个洞。**
* ⚠️ 用 `propose_` 那个动作:AI 改不了预约(PAC 开放面 4 个端点全是读),
* 所以出口第一条是「**已在系统里改好约**」—— 人去宿主改完回来记一笔。
*/
reschedule: {
awaiting_confirm: { humanAction: 'propose_reschedule', options: RESCHEDULE_EXITS, opensTask: true },
open: { humanAction: 'propose_reschedule', options: RESCHEDULE_EXITS, opensTask: true },
},
/** ⭐ 不来了 —— 同上,两栏一样。取消是**业务决定**,AI 不做 */
cancel: {
awaiting_confirm: { humanAction: 'escalate', options: BRANCHING, opensTask: true },
open: { humanAction: 'escalate', options: BRANCHING, opensTask: true },
},
/**
* 提到症状 / 用药 / 疼痛 —— 立刻转人工,⛔ 不安抚不解释。
*
* ⚠️⚠️ **两栏的出口不一样,这是刻意保留的**:
* · 在等确认时:那条 Task 还挂着一个**预约**要收尾 ⇒ 人得决定它怎么办
* · 会话里:人要做的事只有一件 —— **回他**。摆三颗按钮是在问一个不存在的问题
*
* ⭐ 观察(留给下一刀):差异其实来自**这条 Task 还有没有别的结局要定**,
* 而不是来自 intent。真要再收敛,该按那个判据,⛔ 不是按场景。
*/
medical: {
awaiting_confirm: { humanAction: 'escalate', options: BRANCHING, opensTask: true },
// ⭐ 医疗信号**要建 Task**:那是「有件事要人跟」,⛔ 不是「这句我不会答」
open: { humanAction: 'escalate', options: [], opensTask: true },
},
/** 读不懂 —— ⚠️ 这不是失败,是明确的"我不知道"。出口差异同 `medical` */
unknown: {
awaiting_confirm: { humanAction: 'escalate', options: BRANCHING, opensTask: true },
/**
* ⛔⛔ **不建 Task** —— 这就是「AI 不知道怎么答,你来」。
* ⚠️ 会话里已经有它的落点:草稿 + 会话列表那个「我需要答」标。
* 再开一条 Task 的后果实测过:6 条里 4 条最后是**人手工关掉**的
* (理由还写着「先观察后关闭」)—— 那正是「它本来就不该是一条待办」的症状。
*/
open: { humanAction: 'escalate', options: [], opensTask: false },
},
/**
* 下面几档目前**只有会话那条腿判得出来**(预约确认的规则/模型词表里没有)。
* ⚠️ 它们要不要转人工由模型的 `needsHuman` 定,⛔ 不由 intent 定 ——
* 「你们几点下班」和「你们几点下班?我投诉过一次」是同一个 intent、
* 两种处置。⇒ 表在这里回 NONE,调用方按 `needsHuman` 决定要不要转。
*/
appointment: { awaiting_confirm: NONE, open: NONE },
price: { awaiting_confirm: NONE, open: NONE },
/** ⭐ 投诉是**有件事要人跟**,⛔ 不是「这句我不会答」 ⇒ 建 Task */
complaint: {
awaiting_confirm: NONE,
open: { humanAction: 'escalate', options: [], opensTask: true },
},
question: { awaiting_confirm: NONE, open: NONE },
chitchat: { awaiting_confirm: NONE, open: NONE },
};
export function dispositionOf(intent: PatientIntent, ctx: ReplyContext): Disposition {
return TABLE[intent][ctx];
}
/**
* 模型判了 `needsHuman` 但 intent 落在"表里回 NONE"的那几档时,用它。
* ⚠️ 走的是**没有出口**那一支 —— 同 `medical` 在会话里的形态:
* 人要做的事只有一件,回他。
*/
export const NEEDS_HUMAN_NO_EXIT: Disposition = { humanAction: 'escalate', options: [], opensTask: false };
/**
* 转人工时填的 `jobs.escalation_reason`。
*
* ⚠️ [14 §3.2] 这一列的用途是**统计哪条规则触发转人工最多**,所以必须是
* **有限取值**,⛔ 不能是自由文本、也⛔ 不能直接把 intent 原样塞进去 ——
* `reschedule_request` / `appointment_cancel` 是已经在用的名字
* (`AgentTaskService.NEED` 那张能力/资格判据表就按它们分档),
* 再写一套 `reschedule` / `cancel` 就是同一件事两个名字。
*
* ⚠️ 原来它住在 `appointment-confirm/reply-classifier.ts`、只吃 5 档。
* 会话那条腿现在也要转人工 ⇒ 搬来这里,放宽到全词表。
* ⚠️ 认不出的一律 `low_confidence` —— `humanNeed()` 对未知键 fail-safe 到
* `authority`(需要人工参与那一档),⛔ 别让新档静默落进"缺能力"。
*/
export function escalationReasonOf(intent: PatientIntent): string {
switch (intent) {
case 'medical':
return 'medical_question';
case 'cancel':
return 'appointment_cancel';
case 'reschedule':
return 'reschedule_request';
default:
return 'low_confidence';
}
}
......@@ -30,9 +30,9 @@ export type GateVerdict =
* | 层 | 位置 | 效果 |
* |---|---|---|
* | 全局 | 停 worker(`AI_OUTBOX_WORKER=off`) | 出站不投递、入站不处理 |
* | 租户 | `tenants.ai_auto_send` | **AI 降级为只出草稿,人照常工作** |
* | 租户 | `tenants.ai_auto_send` | **AI 的消息停在队列里(hold),闸一开就发;人照常工作** |
* | 账号 | `wecom_accounts.sending_enabled` | **所有出站停止(含人工发的)** |
* | 患者接管 | `tasks` 里 needs_human/human_working | 这个患者已转人工,AI 别插嘴 |
* | 患者接管 | `tasks` 里 **manual_takeover** 且 needs_human/human_working | 人**显式**接管了这段对话,AI 别插嘴 |
*
* 另有一条**不是闸门、但拦在同一处**的:`wecom_accounts.status !== 'online'`。
* 号不在线时 ECom 的接口本来就会失败(「企业wx必须全程登录在线」),
......@@ -130,8 +130,19 @@ export class GateService {
return { pass: false, gate: 'tenant_ai_autosend', reason: `租户状态是 ${tenant.status}` };
}
if (!tenant.aiAutoSend) {
// ⚠️ 这是**默认状态**(ai_auto_send DEFAULT false)。第一版就该是这样:
// AI 只出草稿,人点了才发([01 §9] Phase 1「不自动发送患者消息」)。
/**
* ⚠️ 这是**默认状态**(`ai_auto_send DEFAULT false`)——[01 §9] Phase 1
* 「不自动发送患者消息」。
*
* ⚠️⚠️ **2026-09-01 起这条闸的效果变了,别再说成「降级为只出草稿」。**
* `ReceptionService` 现在只在 **AI 不发**时才落草稿 —— AI 决定自己发
* 就直接入队、⛔ 不留草稿(留了会重复发送:队列里那条闸一开就发,
* 而草稿上那颗「填入回复框」会让人再发一遍)。
* ⇒ 闸关着时的真实形态是:**消息 hold 在 outbox 里,会话上没有草稿**,
* 界面显示「AI 已写好,正在发送」(`agentActivityOf` 的 `sending` 那一档)。
* ⇒ 想要「只出草稿」那种形态,得让 AI **决定不发**(模型判 needsHuman),
* ⛔ 不是靠关这道闸。
*/
return { pass: false, gate: 'tenant_ai_autosend', reason: 'AI 自动发送未开启(默认关闭)' };
}
......@@ -146,6 +157,23 @@ export class GateService {
// ⚠️ 这个谓词对应 `idx_tasks_holding` 那个部分索引 —— 稳态下这两种状态的行很少,
// 索引极小,所以放在出站热路径上是可以承受的([14 §4.4])
status: { in: ['needs_human', 'human_working'] },
/**
* ⭐⭐ **只有「手动接管」才关闸** —— 2026-09-01 收窄。
*
* ⚠️⚠️ 原来任何 `needs_human` 的 Task 都关闸,而那把两件事混成了一件:
* ```
* 「这个患者身上有一件事要人处理」 ← 改期待批、要打个电话
* 「这段对话现在归人,AI 别插嘴」 ← 才是闸要表达的
* ```
* 前者不该让 AI 对这个患者**整个哑掉**:他明天还可能问"几点营业",
* 而那句话 AI 答得了。⇒ 一条待办挂在那儿,AI 就一句话都不说,
* 是**用错了闸**。
*
* ⛔ 别改回 "任何 needs_human"。真要停 AI 的入口只有一个:
* 人在标题栏点「我来接手 / 暂停 AI」,那条走 `manual_takeover`。
* ⚠️ 另外三层闸(全局 / 租户 / 账号)一个都没动。
*/
taskKind: 'manual_takeover',
},
});
if (holding > 0) {
......
......@@ -203,9 +203,48 @@ export class PacService {
returnVisitLimit?: number;
} = {},
): Promise<PacPatientProfileItem[]> {
return this.profilesIn(
await this.scope.pacScopeForAccount(tenantId, accountId),
patientIds,
opts,
);
}
/**
* ⭐ **按组织节点**取患者全貌 —— 预约确认扫描取姓名走这条。
* ⚠️ 同 `getAppointmentWindowForOrgUnit`:范围从诊所来,⛔ 不经托管号。
*/
async getProfilesForOrgUnit(
tenantId: string,
orgUnitId: string,
patientIds: string[],
opts: {
factLimit?: number;
factTypes?: string[];
include?: ('profile' | 'persona' | 'facts' | 'returnVisits')[];
returnVisitLimit?: number;
} = {},
): Promise<PacPatientProfileItem[]> {
return this.profilesIn(
await this.scope.pacScopeForOrgUnit(tenantId, orgUnitId),
patientIds,
opts,
);
}
/** 两个入口共用 —— ⛔ 别各写一份(分批上限那类细节漏一处就是静默少给) */
private async profilesIn(
scope: { tenantId: string; orgScope: string[] },
patientIds: string[],
opts: {
factLimit?: number;
factTypes?: string[];
include?: ('profile' | 'persona' | 'facts' | 'returnVisits')[];
returnVisitLimit?: number;
},
): Promise<PacPatientProfileItem[]> {
if (patientIds.length === 0) return [];
const c = this.require();
const scope = await this.scope.pacScopeForAccount(tenantId, accountId);
return this.call(() =>
c.getPatientProfiles({
tenantId: scope.tenantId,
......@@ -245,8 +284,40 @@ export class PacService {
window: { from: Date; to: Date },
opts: { maxPages?: number; pageSize?: number } = {},
): Promise<{ items: PacAppointment[]; truncated: boolean }> {
return this.appointmentWindow(
await this.scope.pacScopeForAccount(tenantId, accountId),
window,
opts,
);
}
/**
* ⭐⭐ **按组织节点**取预约窗口 —— 预约确认的扫描走这条。
*
* ⚠️ 和 `...ForAccount` 的区别只有范围从哪来:那条是「号 → 挂在哪家店 → ref」,
* 而中间那一跳是**绕路**(见 `ScopeService.pacScopeForOrgUnit` 的头注)。
* 绕路的代价:**没有托管号的诊所一条任务都不生成**,号一掉线这家店当天停产。
*/
async getAppointmentWindowForOrgUnit(
tenantId: string,
orgUnitId: string,
window: { from: Date; to: Date },
opts: { maxPages?: number; pageSize?: number } = {},
): Promise<{ items: PacAppointment[]; truncated: boolean }> {
return this.appointmentWindow(
await this.scope.pacScopeForOrgUnit(tenantId, orgUnitId),
window,
opts,
);
}
/** 两个入口共用的翻页 —— ⛔ 别各写一份,那道 `maxPages` 硬上限漏一处就是无限翻页 */
private async appointmentWindow(
scope: { tenantId: string; orgScope: string[] },
window: { from: Date; to: Date },
opts: { maxPages?: number; pageSize?: number },
): Promise<{ items: PacAppointment[]; truncated: boolean }> {
const c = this.require();
const scope = await this.scope.pacScopeForAccount(tenantId, accountId);
const maxPages = opts.maxPages ?? 20;
const out: PacAppointment[] = [];
let cursor: string | undefined;
......@@ -266,7 +337,8 @@ export class PacService {
cursor = r.nextCursor;
}
this.logger.warn(
`预约窗口翻页到上限 ${maxPages} 页仍未取完(account=${accountId})—— 结果被截断,` +
// ⚠️ 报**范围**不报账号 —— 两个入口共用这段,account 未必存在
`预约窗口翻页到上限 ${maxPages} 页仍未取完(orgScope=${scope.orgScope.join(',')})—— 结果被截断,` +
`检查时间窗是不是开太大了`,
);
return { items: out, truncated: true };
......
......@@ -203,6 +203,46 @@ export class ScopeService {
}
/**
* ⭐⭐ 「这个人够得着哪些 Task」—— **两条轴各判各的**,交给 SQL 的那个 OR。
*
* ```
* 带会话的任务 → c.account_id = ANY(accountIds) 托管轴(和会话页同一条边)
* 无会话的任务 → t.org_unit_id = ANY(orgUnitIds) 授权轴(user_org_scopes)
* ```
* ⚠️ 两支都是 `null = 不限`,⛔ 不是空集 —— 空数组的含义是"一条都不匹配"。
* 把 null 当空集传下去,表现是 tenant 档但没绑号的人(院长)一条都看不见。
*
* ═══ ⚠️⚠️ 为什么组织那一支**不从"号挂在哪"反推** ═══════════════════════
*
* 上一版是 `accounts.map(a => a.orgUnitId)` —— 那等于说"你能管哪些店,看你的号
* 挂在哪家"。而企业微信上**一个人只有一个号**,那个号可以跨很多组织、也可能挂在
* 品牌层 ⇒ 这条反推从根上不成立。
* ⚠️ 实测后果:授权了上海世纪公园、但没有号挂在上海 ⇒ 上海那些**没有会话**的
* 预约确认(患者没绑微信、只能打电话的)既看不见也点不动,而按授权它们本来就是他的活。
*
* ⚠️⚠️ 这是一次**放宽**,所以边界要说死:它**只**影响没有会话的那些任务 ——
* 带会话的任务照旧由 `accountIds` 那一支把关。
* ⛔ 别把 orgUnitIds 用到带会话的任务上:那才是真的开洞。
*
* ⚠️ 只有**两条轴都空**才是真的什么都碰不到 ⇒ 那时才回 null,
* 调用方直接回空,⛔ 别把空数组丢给 `= ANY` 再跑一次没意义的查询。
*
* ⚠️⚠️ 抽在这里是因为它有**两个使用者**(`AgentTaskService.reach`、
* `PatientTimelineService`),而它们必须永远一致 —— 同 `orgAllowed` 那条教训:
* 只改一边,漂掉的那一侧是**静默**的洞。
*/
async taskReach(
user: AuthUser,
): Promise<{ accountIds: string[] | null; orgUnitIds: string[] | null } | null> {
const s = await this.accountScope(user);
if (s.all) return { accountIds: null, orgUnitIds: null };
const org = await this.orgUnitScope(user);
if (org.all) return { accountIds: s.accountIds, orgUnitIds: null };
if (!s.accountIds.length && !org.orgUnitIds.length) return null;
return { accountIds: s.accountIds, orgUnitIds: org.orgUnitIds };
}
/**
* 诊所切换器的候选项。
*
* ═══ ⭐⭐ 候选 = 「我够得着的诊所」,⛔ 不是「有托管号的那些」 ═══════════════
......@@ -400,6 +440,66 @@ export class ScopeService {
}
/**
* ⭐⭐ **系统路径**的 PAC 坐标 —— 直接给一个组织节点,⛔ 不经托管号。
*
* ═══ 为什么要它 ═══════════════════════════════════════════════════════════
*
* `pacScopeForAccount` 是「号 → org_unit → ref」,中间那一跳是**绕路**:
* 它当初存在的理由是「托管号是我们唯一知道该问 PAC 哪个范围的东西」——
* 而那句话今天不成立了(`resolvePacScope` 从任意节点都解析得出)。
*
* ⚠️ 绕路的代价是实打实的:预约确认的扫描单位曾经是「在线托管号」⇒
* **没有号的诊所一条任务都不生成**,号一掉线这家店当天就停产 ——
* 而那时人工电话确认恰恰更该做。
*
* ⚠️⚠️ 这**不是绕过权限**:和 `pacScopeForAccount` 同一条理由 ——
* 范围由**数据本身**给出(扫描是逐个诊所走的,用那家店的 ref 问 PAC),
* ⛔ 不存在"系统用户看全部"的旁路。
*/
async pacScopeForOrgUnit(tenantId: string, orgUnitId: string): Promise<PacScope> {
return this.resolvePacScope(tenantId, orgUnitId);
}
/**
* ⭐⭐ 扫描单位:**ref 树的叶子** —— 有 PAC ref、且**没有带 ref 的后代**的节点。
*
* ═══ ⚠️⚠️ 为什么不是"所有带 ref 的节点" ═══════════════════════════════════
*
* 实测本地这棵树**每一层都有 ref**:
* ```
* 瑞尔集团(group, ref)
* 瑞尔(brand, ref) → 北京朝阳公园 / 杭州大厦 / 杭州高德(各有 ref)
* 瑞泰(brand, ref) → 上海世纪公园 / 通善口腔学前街(各有 ref)
* ```
* 「有 ref 就扫」的话:集团扫一遍**全部**、两个品牌各扫一遍子集、五家店再各扫一遍
* ⇒ 同一位患者的同一个预约被扫 **3 次**,任务重复三份(去重键是每患者一条活跃
* `appointment_confirm`,所以表现是相互抢占而不是三条,但扫描量白涨 3 倍)。
*
* ⚠️ 判据也**不能按 `kind='clinic'` 取** —— 那是拿命名当结构:
* 哪天有个品牌下面直接挂患者(没有 clinic 层),按 kind 过滤就把它整个漏掉。
* ⭐ 「没有带 ref 的后代」才是真正的判据:它保证**不重叠且穷尽**。
*
* ⚠️ 树在应用层判(几百个节点,同 `visibleOrgUnitIds` 的取舍),
* ⛔ 不写递归 CTE。
*/
async pacScannableOrgUnits(
tenantId: string,
): Promise<Array<{ id: string; name: string; sourceRef: string }>> {
const all = await this.prisma.orgUnit.findMany({
where: { tenantId },
select: { id: true, name: true, parentId: true, sourceRef: true },
});
// 谁的父节点上挂着"有 ref 的孩子" —— 那个父节点就不是叶子
const hasRefChild = new Set(
all.filter((u) => u.sourceRef && u.parentId).map((u) => u.parentId!),
);
return all
.filter((u) => u.sourceRef && !hasRefChild.has(u.id))
.map((u) => ({ id: u.id, name: u.name, sourceRef: u.sourceRef! }))
.sort((a, b) => a.name.localeCompare(b.name, 'zh'));
}
/**
* 纯解析:org_unit → PAC 坐标(往上借 ref)。
*
* ⚠️ **这里没有任何越权校验** —— 调用方必须先自己判完。
......
......@@ -51,7 +51,7 @@ export type ConfirmBucket = 'human' | 'pending' | 'waiting' | 'done' | 'settled'
*
* ⚠️ `pending` / `running` 归到 waiting —— 那是"刚建出来还没发完"的一瞬,
* 单独给它一档等于在界面上放一个几乎永远是 0 的筛选项。
* ⚠️ `failed_retry` 也归 waiting:它对人的含义是"还没发到",和"发了在等"
* ⚠️ `retry_pending` 也归 waiting:它对人的含义是"还没发到",和"发了在等"
* 是同一类操心。真要单独看,那是运维视角,不是接待视角。
*/
/**
......@@ -139,12 +139,17 @@ const BUCKETS: Record<Exclude<ConfirmBucket, 'all'>, readonly string[]> = {
*/
human: ['needs_human', 'human_working'],
pending: ['pending'],
waiting: ['running', 'awaiting_patient', 'failed_retry'],
// ⚠️ `retry_pending` —— ⛔ 别写成 `failed_retry`:`ck_tasks_status` 里没有那个值,
// 写错就是一个**永远匹配不到**的分档(2026-09-01 实测到时它已经错着上线了)。
waiting: ['running', 'awaiting_patient', 'retry_pending'],
done: ['agent_done'],
settled: ['completed', 'closed'],
};
/** 九态的中文 —— ⚠️ 与 SRS §10.3 那张表逐字对齐,⛔ 别自己另起一套说法 */
/**
* 九态的中文 —— ⚠️ 与 SRS §10.3 那张表逐字对齐,⛔ 别自己另起一套说法。
* ⚠️ 患者时间线(`PatientTimelineService`)也读它,经 `statusZh()`。⛔ 别再抄一张。
*/
const STATUS_ZH: Record<string, string> = {
pending: '待执行',
running: '执行中',
......@@ -153,7 +158,7 @@ const STATUS_ZH: Record<string, string> = {
needs_human: '需要人工参与',
human_working: '人工处理中',
completed: '已完成',
failed_retry: '失败待重试',
retry_pending: '失败待重试',
closed: '已关闭',
};
......@@ -184,6 +189,19 @@ const OUTCOME_ZH: Record<string, string> = {
rescheduled: '人已在系统里改好约',
handled_by_human: '人工处理完了',
watching: '先观察',
/**
* ⚠️⚠️ 下面两条是 2026-09-01 补的 —— 它们**一直在往库里写,只是没人翻译**。
*
* `outcomeZh` 认不出时会 `return outcome`,也就是把**英文码原样摆到屏幕上**。
* 之前没暴露,是因为只有预约确认那一页读这张表,而那页的任务不走这两条路。
* 患者时间线一并进来,`manual_followup` 的 10 条 `handed_back_to_ai` 当场
* 在患者面板上显示成了一串英文。
* ⛔ 别把这张表当成"预约确认的词表" —— 它是 `tasks.outcome` /
* `tasks.closed_reason` **这两列**的词表,谁写这两列谁就得在这里有一行
* (`tests/outcome-zh.spec.ts` 会去源码里数,漏一个就红)。
*/
handed_back_to_ai: '交还给 AI',
cancelled_by_patient: '患者说不来了',
};
export interface ConfirmTaskCard {
......@@ -236,18 +254,20 @@ export class AgentTaskService {
) {}
/**
* 这个人**够得着**哪些任务。
*
* ⭐⭐ 两条腿,但**同一个源头**:
* · 有会话的任务 → 按 `account_id`(`ScopeService.accountScope`,和会话页同一条边)
* · 没会话的任务 → 按 `org_unit_id`,而那批 org_unit **就是上面那批账号挂的诊所**
*
* ⚠️⛔ 别为第二条另起一套"用户可见哪些诊所"的判据 —— 那就是两套范围模型,
* 必然漂,而漂掉的那一侧是**静默**的洞([12 §4.2] 同一条教训)。
* 从账号推诊所,保证了"看不见这个号 ⇒ 也看不见它那家店的无会话任务"。
* 这个人**够得着**哪些任务 —— 判据整段搬去了 `ScopeService.taskReach`。
*
* @returns null = 一个号都够不着(⇒ 调用方直接回空 / 404)
* ⚠️⚠️ 搬家的理由:`PatientTimelineService` 需要**逐字相同**的那条谓词
* (患者时间线上的 Task 和这一页是同一批东西)。抄一份的话,
* 两处必然漂 —— 而漂掉的那一侧是**静默**的洞(同 `orgAllowed` 那条教训)。
* ⛔ 别把这个薄壳删掉改成到处 `this.scope.taskReach(...)`:留着它,
* 下面十几处 `reach(user)` 的调用点读起来才还是"这个人够得着哪些任务"。
*/
private async reach(
user: AuthUser,
): Promise<{ accountIds: string[] | null; orgUnitIds: string[] | null } | null> {
return this.scope.taskReach(user);
}
/**
* ⭐ 「当前诊所」→ 一组 org_unit_id(含子树)。**界面收窄专用**,⛔ 不是权限。
*
......@@ -264,45 +284,6 @@ export class AgentTaskService {
return this.scope.subtreeOf(user.tenantId, clinicId);
}
private async reach(
user: AuthUser,
): Promise<{ accountIds: string[] | null; orgUnitIds: string[] | null } | null> {
const s = await this.scope.accountScope(user);
if (s.all) return { accountIds: null, orgUnitIds: null };
const org = await this.scope.orgUnitScope(user);
if (org.all) return { accountIds: s.accountIds, orgUnitIds: null };
/**
* ⚠️⚠️ 这里原来第一行就是 `没号 return null`。**去掉了** ——
* 它和下面那段注释**自相矛盾**:注释论证"组织范围来自授权、⛔ 不从号反推",
* 而那一行的意思恰恰是"没号就什么都别看"。于是一个被授权了上海、
* 但还没绑企微号的同事,连**上海那些只能打电话的待办**都看不见 ——
* 而打电话跟微信号没有半点关系。
* ⭐ 现在两条轴各自判、各自进 SQL 的那个 OR:
* ```
* 带会话的任务 c.account_id = ANY(accountIds) 没号 空数组 一条都不匹配
* 无会话的任务 t.org_unit_id = ANY(orgUnitIds) 按授权走
* ```
* ⚠️ 只有**两条轴都空**才是真的什么都碰不到 —— 那时才回 null
* (下面注释说的"别把空数组传给 = ANY 然后跑一次没意义的 count"仍然成立)。
*/
if (!s.accountIds.length && !org.orgUnitIds.length) return null;
/**
* ⭐⭐ **组织范围来自授权(`user_org_scopes`),⛔ 不再从"号挂在哪"反推。**
*
* 上一版是 `accounts.map(a => a.orgUnitId)` —— 那等于说"你能管哪些店,
* 看你的号挂在哪家"。而企业微信上**一个人只有一个号**,那个号可以跨很多组织、
* 也可能挂在品牌层 ⇒ 这条反推从根上不成立。
* ⚠️ 实测后果:授权了上海世纪公园、但没有号挂在上海 ⇒ 上海那些
* **没有会话**的预约确认(患者没绑微信、只能打电话的)既看不见也点不动,
* 而按授权它们本来就是他的活。
*
* ⚠️⚠️ 这是一次**放宽**,所以边界要说死:它**只**影响没有会话的那些任务 ——
* 带会话的任务照旧由 `accountIds` 那一支把关(见下面查询里的 OR)。
* ⛔ 别把 orgUnitIds 用到带会话的任务上:那才是真的开洞。
*/
return { accountIds: s.accountIds, orgUnitIds: org.orgUnitIds };
}
/**
* 预约确认的任务队列。
*
......
......@@ -404,6 +404,8 @@ export class ConversationService {
id: true, convKey: true, kind: true, peerVid: true, roomId: true,
lastMsgAt: true, lastMsgPreview: true, waitingSince: true,
riskLevel: true, riskReason: true, pendingMentionUserId: true,
// ⭐ 列表上那个「我需要答」标要它 —— 见下面 freshDraft
aiDraft: true, aiDraftAt: true,
lastReplyActorType: true, accountId: true,
},
});
......@@ -634,7 +636,22 @@ export class ConversationService {
* "别人在处理"和"等我处理"在一屏里长得一样。
*/
const hold = holdChip.get(c.id);
/**
* ⭐⭐ 「我需要答」现在有**两个来源**,接管态优先。
*
* ⚠️⚠️ 2026-09-01:「AI 答不上来 → 转人工」**不再建 Task** 了
* (见 `Disposition.opensTask`:那是 agent 的一个行为,不是一件要完成的事)。
* ⇒ 那个信号如果只从 Task 算,就**整个断掉**了 —— 而人正是从列表开始工作的,
* 断了就等于他得挨个点开才知道哪条等着他。
*
* ⭐ 新来源是**草稿**:AI 写了一份等人确认的回复 = 「该你答了」。
* ⚠️ 而且它**自己会消**:草稿旧了(`ai_draft_at < last_msg_at`)就不再算,
* ⛔ 不用再维护一条 Task 的生命周期 —— 那正是上一版 6 条里 4 条
* 要人手工关掉的病根。
*/
const freshDraft = !!c.aiDraft && !(c.aiDraftAt && c.aiDraftAt < c.lastMsgAt);
if (hold) chips.push(hold);
else if (freshDraft) chips.push({ text: '我需要答', tone: 'amber' });
chips.push(isGroup ? { text: '企微群', tone: 'slate' } : { text: peer?.channel === 'wechat' ? '个微' : '企微', tone: 'slate' });
if (c.pendingMentionUserId) chips.push({ text: '@我', tone: 'indigo' });
if (c.riskLevel) chips.push({ text: RISK_ZH[c.riskLevel] ?? c.riskLevel, tone: RISK_TONE[c.riskLevel] ?? 'slate' });
......@@ -1073,7 +1090,7 @@ export class ConversationService {
* ⚠️ processing 那档走 `idx_outbox_reclaim` 的分区再过滤 —— 并发上限就那么几条。
* · `llm_calls` → `idx_llm_conv (conversation_id, created_at DESC)`
*/
const [agentQueue, lastLlm] = await Promise.all([
const [agentQueue, lastLlm, outPending] = await Promise.all([
this.prisma.$queryRaw<{ status: string; attempt: number; created_at: Date; last_error: string | null }[]>`
SELECT status, attempt, created_at, last_error
FROM app.outbox
......@@ -1085,6 +1102,26 @@ export class ConversationService {
FROM app.llm_calls
WHERE conversation_id = ${conv.id}::uuid
ORDER BY created_at DESC LIMIT 1`,
/**
* ⭐⭐ **AI 自己那条已经排出去、但还没发到患者手上的消息。**
*
* ⚠️⚠️ 补这个查询是因为一个真缺口:AI 决定自己发之后**不再留草稿**
* (留了会重复发送,见 `ReceptionService` 那段)。于是闸门 hold 住时
* 界面上一点动静都没有 —— 表现成"AI 好像什么都没做"。
*
* ⚠️ `route_key` 是按**账号**串行的(`acct:<id>`),⛔ 不是按会话
* ⇒ 只能从 payload 里筛会话。
* ⚠️ `actor='agent'` —— ⛔ 人自己发的不在这里报:他点了发送按钮,
* 那一刻接口已经把闸门结论当场告诉他了。
*/
this.prisma.$queryRaw<{ created_at: Date }[]>`
SELECT created_at
FROM app.outbox
WHERE task_type = 'outbound.ecom.text'
AND status IN ('pending', 'processing')
AND payload ->> 'conversationId' = ${conv.id}
AND payload ->> 'actor' = 'agent'
ORDER BY created_at DESC LIMIT 1`,
]);
// 这条会话关联到哪位患者。⚠️ 只回**关联本身**(本地表,很便宜),
......@@ -1167,6 +1204,7 @@ export class ConversationService {
aiReplied: aiAlreadyReplied(msgs),
agentActivity: agentActivityOf({
queue: agentQueue[0] ?? null,
outPendingAt: outPending[0]?.created_at ?? null,
llm: lastLlm[0] ?? null,
// ⚠️ 拿**最新一条患者消息**当基准:比它旧的模型调用是上一轮的,说了什么都跟这条无关
lastInboundAt: msgs.find((m) => m.direction === 'inbound')?.sendTime ?? null,
......@@ -1224,10 +1262,11 @@ export interface AgentActivity {
* `queued` 排在 outbox 里,worker 还没轮到
* `working` 正在跑(读上下文 / 调模型 / 写草稿)
* `declined` 跑完了,**决定不给建议** —— `detail` 是它自己的理由
* `sending` AI 已经写好并交出去了,正在发给患者
* `blocked` 写了一句,但被输出守卫拦下 —— `detail` 是拦下的原因
* `none` 没什么在发生
*/
state: 'queued' | 'working' | 'declined' | 'blocked' | 'none';
state: 'queued' | 'working' | 'sending' | 'declined' | 'blocked' | 'none';
/** 这个状态从什么时候开始(界面拿它算"已经 N 秒") */
since: string | null;
detail: string | null;
......@@ -1243,16 +1282,53 @@ export interface AgentActivity {
*/
export function agentActivityOf(input: {
queue: { status: string; attempt: number; created_at: Date; last_error: string | null } | null;
/** AI 自己那条已入队、还没到患者手上的消息的入队时刻。null = 没有 */
outPendingAt?: Date | null;
llm: { output_text: string | null; guard_verdict: string; guard_reason: string | null; created_at: Date } | null;
lastInboundAt: Date | null;
}): AgentActivity {
const { queue, llm, lastInboundAt } = input;
/**
* ⭐⭐ **AI 已经写好、正在发** —— 排在"正在读"之后、"它说了什么"之前。
*
* ⚠️ 补这一档是因为:AI 决定自己发之后**不再留草稿**(留了会重复发送)。
* 闸门 hold 住时界面上原本一点动静都没有,表现成"AI 好像什么都没做"。
*
* ⚠️⚠️ 文案**只说"正在发"**,⛔ 不露 outbox / 排队 / 闸门这些实现:
* 一线员工对这些做不了任何事,说了只会让他以为要他去处理。
* ⚠️ 而且这句话是**诚实**的 —— 那条消息确实在往外走的路上,
* 只是可能被闸拦一会儿。⛔ 别写成"已发送":那才是说假话。
*/
if (input.outPendingAt) {
return { state: 'sending', since: input.outPendingAt.toISOString(), detail: null };
}
if (queue) {
return {
state: queue.status === 'processing' ? 'working' : 'queued',
since: queue.created_at.toISOString(),
// ⚠️ 重试过就说出来 —— ⛔ 别把"第 3 次重试"显示成"正在处理",那是两回事
detail: queue.attempt > 0 ? `第 ${queue.attempt + 1} 次尝试:${queue.last_error ?? ''}`.trim() : null,
/**
* ⚠️⚠️ **这一行只在"它真的在反复失败"时才出现。**
*
* ⛔ 改过两版,两版都不知所云:
* v1 `第 N 次尝试:${last_error ?? ''}` —— 没有 error 时渲染成
* **「第 2 次尝试:」**,一个吊着的冒号。
* v2 `上次没跑完(第 N 次)` —— 措辞是通了,但**依然没人看得懂**,
* 因为它说的是**我们的**内部状态(outbox 的 attempt 计数)。
*
* ⭐ 想清楚判据之后才发现:`attempt > 0` **根本不是"出事了"**。
* worker 认领时就 +1,进程重启 / 部署会让任务被回收重投 ——
* attempt 涨了,但它下一秒就会重新跑,**一线员工什么都不用做**。
* 对做不了任何事的人报一个状态,就是噪音。
*
* ⇒ 判据换成**真失败过**(有 `last_error`)。那时才有可操作性:
* 这条可能永远出不来,得有人去看。
* ⚠️ 措辞也不提 attempt —— 说"失败了 N 次",⛔ 不说"第 N 次尝试"
* (后者要人自己换算 attempt 从 0 还是 1 开始)。
*/
// ⚠️ 原因**必须带上** —— 「失败了 3 次」不带原因,人只知道出事了,
// 不知道该找谁。原因才是它可操作的那一半。
detail: queue.last_error ? `处理失败了 ${queue.attempt} 次:${queue.last_error}` : null,
};
}
// ⚠️ 上一轮的模型调用**不作数**(见函数头注)
......
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
import { ScopeService } from '../scope/scope.service';
import { outcomeZh, statusZh } from './agent-task.service';
import { TaskService } from './task.service';
/** 时间线上的一条 Task。⚠️ **一条 Task = 一行**,⛔ 不铺它底下的 job */
export interface PatientTimelineTask {
taskId: string;
taskKind: string;
/** `task_kind` 的中文([15 附录 A])。⚠️ 后端给,⛔ 别在前端再维护一张码表 */
kindLabel: string;
title: string;
/** 触发原因 —— 没有待办描述时退到它 */
triggerReason: string | null;
status: string;
/** 右列那格:结局优先(「已到诊」/「已改约」),没有结局才回状态 */
statusLabel: string;
/** ⭐ 已经结束了(completed / closed)⇒ 界面**不给按钮**、样式压低 */
settled: boolean;
createdAt: string;
/**
* 当前那条**挂着的人工 Job** —— 描述和按钮都从它来。
* ⚠️ null = 这条现在不需要人动(AI 还拿着,或者已经结束了)。
*/
humanJob: {
jobId: string;
title: string | null;
/** `jobs.escalation_reason` —— 行上那句描述 */
reason: string | null;
options: { value: string; label: string }[];
} | null;
}
/**
* 患者时间线上**我们自己做过的事**。
*
* ═══ 它补的是哪一块 ═══════════════════════════════════════════════════════
*
* 右栏那条时间线原来只有两种东西:
* · PAC 的**事实**(就诊 / 治疗 / 收费 / 影像 / 病历)—— 宿主系统里发生的
* · PAC 的**回访**记录 —— 诊所自己打的那些电话
* 两种都是**别人**做的。而 friday-ai 自己做的事(发了确认微信、挂了一条要人打的
* 电话、把患者的症状转给了人)一条都不在上面 ⇒ 接电话的人看不出
* 「这个患者昨天已经被我们提醒过了」,于是再提醒一遍。
*
* ═══ ⭐⭐ 哪些 Task 上,哪些不上 ═══════════════════════════════════════════
*
* ⛔ **不是全部 Task** —— 判据在文件末尾的 `TIMELINE_JOBS`,一句话:
* 这条 Task 上有没有一件**对患者的业务动作**(主动提醒 / 打电话 / 改约 / 取消)。
* 「AI 答不上来,人来回他」不算 —— 那是会话的事,会话面板就在旁边那一栏。
*
* ⚠️⚠️ **⛔ 判据不看 `task_kind`** —— 上一版是一张 kind 白名单,而那是错的:
* 同一件「患者要改期」,定时腿发现的落 `appointment_confirm`、
* agent 在会话里发现的落 `manual_followup` ⇒ 白名单会让**后者在时间线上消失**。
* 而两者的区别只是 **Task 的来源**(`trigger_source`),对患者来说是同一件事。
* ⭐ 换成上面两条之后判据**与来源无关** —— 这正是"展示同形"。
*
* ═══ ⚠️ 已结束的也出 ═══════════════════════════════════════════════════════
*
* 只出 `needs_human` / `human_working` 的话,这条"时间线"就只是一张待办列表。
* 时间线的价值恰恰是**回溯"当时做了什么"** ⇒ `completed` / `closed` 一起出,
* 只是**不给按钮**、样式压低(`settled: true`)。
*
* ═══ ⚠️⚠️ 不按事实的时间窗截断 ═══════════════════════════════════════════
*
* PAC 那侧的事实是**截断**的(`factsTruncated`,实测一位患者 60 条封顶),
* 这里给的是**全部** Task。两边窗口不一样 ⇒ 界面上必须在截断提示里说清楚
* (见 `Overview` 里那行)。
* ⛔ 别为了"对齐"把 Task 也截掉:Task 一位患者只有个位数,截掉省不了什么,
* 却会让人以为"我们没做过",而那正是这块数据存在的理由。
*/
@Injectable()
export class PatientTimelineService {
constructor(
private readonly prisma: PrismaService,
private readonly scope: ScopeService,
) {}
/**
* @param patientId PAC 的患者 id(= `tasks.host_patient_id`)
*
* ⚠️ 权限走 `ScopeService.taskReach` —— 和预约确认那一页**逐字相同**的两条轴。
* ⛔ 别因为「患者档案已经过了 PAC 的范围校验」就省掉它:那校验管的是
* 「这位患者你看得看」,管不了「这条待办归谁」(会话可能是同事的号)。
*/
async forPatient(user: AuthUser, patientId: string): Promise<PatientTimelineTask[]> {
if (!patientId) return [];
const reach = await this.scope.taskReach(user);
if (!reach) return [];
/**
* ⚠️ 按 `host_code` 一起过滤 —— ⛔ 别只用 `host_patient_id`:
* 患者 id 是**宿主给的**,换一个宿主就可能撞号,而撞上的后果是
* 把另一个人的待办画在这位患者的时间线上。
*/
const tenant = await this.prisma.tenant.findUnique({
where: { id: user.tenantId },
select: { partnerCode: true },
});
if (!tenant) return [];
const rows = await this.prisma.$queryRaw<
{
id: string; task_kind: string; title: string; trigger_reason: string | null;
status: string; outcome: string | null; closed_reason: string | null;
created_at: Date;
job_id: string | null; job_title: string | null;
escalation_reason: string | null; options: string[] | null;
settled_by_human: string | null;
}[]
>`
SELECT t.id, t.task_kind, t.title, t.trigger_reason,
t.status, t.outcome, t.closed_reason, t.created_at,
hj.id AS job_id, hj.title AS job_title,
hj.escalation_reason, hj.options,
hd.chosen_option AS settled_by_human
FROM app.tasks t
-- ⚠️ LEFT:Task 可以没有会话(预约确认里那些没绑微信的)
LEFT JOIN app.conversations c
ON c.tenant_id = t.tenant_id AND c.id = t.conversation_id
-- ⭐ 当前挂着的那条人工 Job —— 描述和按钮都从它来。同一条 Task 只取最后一条
LEFT JOIN LATERAL (
SELECT j.id, j.title, j.escalation_reason, j.options
FROM app.jobs j
WHERE j.task_id = t.id AND j.executor_type = 'human' AND j.status = 'pending'
ORDER BY j.seq DESC LIMIT 1) hj ON true
-- ⭐ 这件事最后是**人**结的吗 —— 决定 agent_done 显示「AI 已执行」还是
-- 「已人工执行」。⚠️ 电话确认走的也是 agent_done,而那从头到尾是人做的:
-- 照搬状态词会在屏幕上对刚打完电话的护士说「AI 已执行」。
LEFT JOIN LATERAL (
SELECT j.chosen_option FROM app.jobs j
WHERE j.task_id = t.id AND j.executor_type = 'human' AND j.status = 'done'
AND j.chosen_option IS NOT NULL
ORDER BY j.seq DESC LIMIT 1) hd ON true
WHERE t.tenant_id = ${user.tenantId}::uuid
AND t.host_code = ${tenant.partnerCode}
AND t.host_patient_id = ${patientId}
-- ⭐⭐ 准入判据 —— 见文件末尾 TIMELINE_JOBS。⛔ 不看 task_kind
-- 判据是 (job_type, escalation_reason),同 AgentTaskService 那张 NEED 表的形状
AND EXISTS (
SELECT 1 FROM app.jobs j
WHERE j.task_id = t.id
AND (j.job_type = ANY(${TIMELINE_TYPES}::text[])
OR (j.job_type || ':' || coalesce(j.escalation_reason, ''))
= ANY(${TIMELINE_KEYS}::text[])))
-- ⭐⭐ 两条轴各判各的(同 AgentTaskService 那一页)。⚠️ null = 不限,⛔ 不是空集
AND (${reach.accountIds === null}::boolean
OR c.account_id = ANY(${reach.accountIds ?? []}::uuid[])
OR (${reach.orgUnitIds === null}::boolean
OR t.org_unit_id = ANY(${reach.orgUnitIds ?? []}::uuid[])))
-- ⚠️ 排序键是 created_at(同前端归并用的那个),⛔ 不是 due_at
ORDER BY t.created_at DESC`;
return rows.map((r) => {
const settled = r.status === 'completed' || r.status === 'closed';
return {
taskId: r.id,
taskKind: r.task_kind,
kindLabel: KIND_ZH[r.task_kind] ?? r.task_kind,
title: r.title,
triggerReason: r.trigger_reason,
status: r.status,
statusLabel:
outcomeZh(r.status, r.outcome, r.closed_reason) ??
statusZh(r.status, r.settled_by_human !== null),
settled,
createdAt: r.created_at.toISOString(),
/**
* ⚠️ 已结束的**不给** humanJob —— 结束了还挂着 pending 人工 Job 是数据不一致,
* 但真出现时也不该在时间线上给一个按下去会报错的按钮。
*/
humanJob:
!settled && r.job_id
? {
jobId: r.job_id,
title: r.job_title,
reason: r.escalation_reason,
options: (r.options ?? []).map((v) => ({ value: v, label: TaskService.labelOf(v) })),
}
: null,
};
});
}
}
/**
* ⭐⭐ **患者时间线的准入表** —— 判据是 `(job_type, escalation_reason)`。
*
* ═══ ⚠️⚠️ 判据换过两版,两版都错,理由都值得留着 ═══════════════════════════
*
* **v1 `task_kind` 白名单** —— 错在**和来源绑死**:同一件「患者要改期」,
* 定时腿发现的落 `appointment_confirm`、agent 在会话里发现的落 `manual_followup`
* ⇒ 白名单会让后者在时间线上**消失**。而两者的区别只是 `trigger_source`。
*
* **v2 「有没有出口(`jobs.options` 非空)」** —— 判据本身讲得通,
* 但它**读的是一列会过时的数据**:接待层的转人工曾经也给三个出口
* (「照抄自预约确认的改期」),后来撤掉了,可**历史行还带着**。
* ⇒ 实测当场回归:罗金龙那 4 条纯会话往来又全冒回时间线上。
* ⭐ 教训:判据要读**语义稳定**的列,⛔ 别读一个策略变过的列。
*
* **v3(当前)`(job_type, escalation_reason)`** —— 两列都是**有限取值**且语义没变过:
* `escalation_reason` 的用途从第一天起就是「统计哪条规则触发转人工最多」
* ([14 §3.2]),取值由 `escalationReasonOf` 收口。
* ⚠️ 形状**照抄 `AgentTaskService.NEED`**(能力/资格那张表)—— 同一个 codebase
* 里同一种判据长同一个样,⛔ 别另发明一种。
*
* ═══ 判据 ═══════════════════════════════════════════════════════════════════
*
* > 这条 Task 上有没有一件**对患者的业务动作**?
*
* ⚠️ 「AI 答不上来,人来回他」**不是**业务动作 —— 患者那侧什么都没发生,
* 而会话面板逐条都有。⛔ 在时间线上再铺一遍 = 把聊天记录抄一遍。
*
* ⚠️ ⛔ **写 `false` 的行也必须留着**,不能删成白名单 ——
* `tests/timeline-admission.spec.ts` 会去动作表和 `escalationReasonOf` 里数,
* 漏一个就红。删掉的话新动作会**静默**缺席,而空时间线和
* "这位患者确实没记录"长得一模一样。
*/
const TIMELINE_JOBS: ReadonlyArray<{ key: string; on: boolean; why: string }> = [
// ── 对患者的业务动作 ────────────────────────────────────────────────────
{
key: 'send_appointment_reminder',
on: true,
why: '主动找上门 —— 时间线的用途正是让下一个人看出「昨天已经提醒过了」,别再提醒一遍。',
},
{
key: 'call_to_confirm',
on: true,
why: '人打电话给患者。⭐ 而且这一档**没有会话**(患者没绑微信正是它的前提)⇒ 时间线是它唯一的落点。',
},
{
key: 'propose_reschedule',
on: true,
why: '改约 —— 人要去业务系统操作一次(PAC 开放面 4 个端点全是读,AI 改不了),那是一件独立的活。',
},
{
key: 'escalate_to_human:appointment_cancel',
on: true,
why: '取消是**业务决定**,落在患者的预约上,⛔ 不是会话里的一句回话。',
},
{
key: 'escalate_to_human:reschedule_request',
on: true,
why: '同上 —— 会话腿判出改期时走 escalate 的那一支(见 reply-routing 那张表)。',
},
// ── 会话里的事 ─────────────────────────────────────────────────────────
{
key: 'escalate_to_human:medical_question',
on: false,
why: '患者提了症状、人要回他 —— 患者那侧还没发生任何事,而会话面板那块待办区在管。',
},
{
key: 'escalate_to_human:low_confidence',
on: false,
why: 'AI 答不上来、人来回他。实测它在时间线上刷出的是「模型判为「question」…」,那是写给同事看的归因。',
},
{
key: 'send_reply',
on: false,
why: '会话里的一问一答。每回一句就添一行 = 把聊天记录抄一遍,把真正的临床事件淹掉。',
},
{
key: 'send_appointment_ack',
on: false,
why: '应答患者的确认回复,整段在会话里。⛔ 别因为"它也是 outbox"就跟着上。',
},
{
key: 'send_symptom_ack',
on: false,
why: '会话里的交接告知(「有人会来找你」)—— 在回患者刚说的那句话,不是主动找上门。',
},
{
key: 'send_handoff_ack',
on: false,
why: '同上,只是非医疗那一档 —— 患者开的口,我们接的话,整段在会话里。',
},
{
key: 'read_context',
on: false,
why: '读了一份预约快照 —— 纯内部,患者那侧什么都没发生。',
},
{
key: 'parse_response',
on: false,
why: '给患者那句话归了个类 —— 纯内部判断。',
},
];
/** 不带 `:` 的键 = 只看 job_type。⭐ 形状同 `AgentTaskService` 的 CAP_TYPES / CAP_KEYS */
const TIMELINE_TYPES: readonly string[] = TIMELINE_JOBS.filter((k) => k.on && !k.key.includes(':')).map((k) => k.key);
const TIMELINE_KEYS: readonly string[] = TIMELINE_JOBS.filter((k) => k.on && k.key.includes(':')).map((k) => k.key);
/**
* `task_kind` 的中文 —— ⚠️ 纯**显示**,⛔ 和准入判据无关(判据不看 kind)。
* ⛔ 认不出的原样回:新场景上线时宁可显示英文码,也别显示一个猜的名字。
*/
const KIND_ZH: Record<string, string> = {
appointment_confirm: '预约确认',
symptom_triage: '症状分诊',
manual_followup: '随访',
manual_takeover: '人工接管',
};
/** ⚠️ 给测试用 —— ⛔ 运行时读上面那两个数组 */
export const TIMELINE_JOB_TABLE = TIMELINE_JOBS;
......@@ -2,6 +2,7 @@ import { Controller, Get, Param, Query } from '@nestjs/common';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
import { PacService } from '../pac/pac.service';
import { PatientTimelineService } from './patient-timeline.service';
/**
* 患者检索 —— 给「把会话关联到患者」那个面板用。
......@@ -14,7 +15,10 @@ import { PacService } from '../pac/pac.service';
*/
@Controller('patients')
export class PatientController {
constructor(private readonly pac: PacService) {}
constructor(
private readonly pac: PacService,
private readonly timeline: PatientTimelineService,
) {}
/**
* 检索 / 翻名册。
......@@ -74,9 +78,24 @@ export class PatientController {
@Query('clinic') clinic?: string,
@Query('factLimit') factLimit?: string,
) {
const item = await this.pac.getPatientProfile(user, clinic ?? null, id, {
/**
* ⭐⭐ **我们自己做过的事**和 PAC 的事实一起给 —— 右栏那条时间线要把两边归并。
*
* ⚠️ 并进同一个响应而不是另开一个接口:同上面那条纪律(打开一条会话不该变成
* 两次往返)。⚠️ 而且 Task 是**本库**的查询,不是跨系统调用 ——
* 它挂在这里几乎不加延迟。
* ⚠️ 两边**互不阻塞**:PAC 挂了不该让"我们做过什么"也消失(反之亦然),
* 所以并发发出去,⛔ 别串行 await。
* ⚠️ 患者读不到(`item` 为 null)时整块回 null —— 那时面板显示的是
* 「读不到患者信息」,⛔ 别单独把 tasks 顶上去:一条没有患者的时间线
* 看着像一位不存在的人的记录。
*/
const [item, tasks] = await Promise.all([
this.pac.getPatientProfile(user, clinic ?? null, id, {
factLimit: factLimit ? Number(factLimit) : undefined,
});
return { item };
}),
this.timeline.forPatient(user, id),
]);
return { item: item ? { ...item, tasks } : null };
}
}
......@@ -70,6 +70,15 @@ export class TakeoverService {
LEFT JOIN app.users u ON u.id = t.assignee_user_id AND u.tenant_id = t.tenant_id
WHERE t.tenant_id = ${tenantId}::uuid
AND t.status IN ('needs_human', 'human_working')
-- ⭐⭐ **只有人显式接管才算「AI 已暂停」** —— 2026-09-01 收窄。
-- ⚠️⚠️ 原来任何 needs_human 都让标题栏显示「AI 已暂停 / 还没人接手」,
-- 而那把两件事混成了一件:
-- 「这个患者身上有一件事要人办」 改期待批、要打个电话
-- 「这段对话现在归人,AI 别插嘴」 ← 才是这块状态要说的
-- 一条改期待办挂着,界面就说 AI 停了 —— 而它根本没停(闸也只认手动接管了,
-- 见 GateService)。⇒ 界面和事实对不上,那比没有状态更糟。
-- ⛔ 别改回"任何 needs_human"。停 AI 的入口只有一个:标题栏那颗按钮。
AND t.source_type = 'human'
AND (t.conversation_id = ${conversationId}::uuid
OR (${patient !== null}::boolean
AND t.host_code = ${patient?.hostCode ?? ''}
......@@ -363,16 +372,21 @@ export function controlOf(holds: readonly HoldRow[], meId: string, hasPatient: b
since: head.since,
// ⭐ 徽章只回答一件事:**AI 还开不开口**
label: 'AI 已暂停',
who: !claimed
? // ⚠️ AI 交出来了但没人认 —— 这**不是**「有人在处理」。说成那样会让
// 两个同事都以为对方在跟,结果谁都没跟。
'还没人接手'
: mine
? '我在跟'
: `${head.assigneeName ?? '同事'}在跟`,
// ⭐ 只有「还没人认」时才让别人来接 —— ⛔ 不做"从同事手里抢过来":
// 那是一次静默的越权,而屏幕那头的人正在打字。
canTake: !claimed,
/**
* ⚠️⚠️ 这里原来还有一支「还没人接手」(`!claimed`)—— **删了,因为它到不了**:
* 收窄成 `source_type='human'` 之后,这批全是人**显式**按下按钮建的,
* 而那一刻 `assignee_user_id` 就设成了他自己 ⇒ 永远已认领。
* ⛔ 留着一支到不了的分支,下一个人会以为"AI 交出来没人认"这个状态还存在,
* 然后照着它去写别的判断。
*/
who: mine ? '我在跟' : `${head.assigneeName ?? '同事'}在跟`,
/**
* ⚠️ 已经停了就没有"再接一次"。⛔ 也不做"从同事手里抢过来":
* 那是一次静默的越权,而屏幕那头的人正在打字。
* ⚠️ 恒为 false —— 界面上那颗「我来接手」也跟着删了(见 `ControlSwitch`):
* 一条会话只属于一个号、一个号只属于一个人 ⇒ 能发消息的只有一个人。
*/
canTake: false,
takeBlocked: claimed
? mine
? '你已经在跟这条了'
......
......@@ -14,6 +14,7 @@ import { ContactLinkService } from './contact-link.service';
import { ContactController } from './contact.controller';
import { ContactService } from './contact.service';
import { PatientController } from './patient.controller';
import { PatientTimelineService } from './patient-timeline.service';
import { UserController } from './user.controller';
import { ProvisionService } from './provision.service';
import { TakeoverService } from './takeover.service';
......@@ -36,6 +37,7 @@ import { PacModule } from '../pac/pac.module';
providers: [
ConversationService, ContactLinkService, ContactService,
ClinicMembershipService, TakeoverService, TaskService, AgentTaskService,
PatientTimelineService,
ProvisionService,
],
})
......
import { PATIENT_INTENTS, RECEPTION_INTENTS, REPLY_INTENTS } from '../src/modules/agent/shared/intent';
import { escalateToHuman } from '../src/modules/agent/shared/human-actions';
import { APPOINTMENT_CONFIRM_ACTIONS } from '../src/modules/agent/scenes/appointment-confirm/appointment-confirm.service';
import { RECEPTION_ACTIONS } from '../src/modules/agent/scenes/reception/reception.service';
/**
* 动作注册表的两条不变式 —— **都被破过,所以都要钉住**。
*/
describe('一个 job_type 一个定义', () => {
/**
* ⚠️⚠️ `ActionBase.jobType` 的注释写着「⭐ 全 codebase 唯一」,
* 而 2026-09-01 之前 `escalate_to_human` **有两个定义**(接待一个、预约确认一个),
* 出口一个空一个三条。同一个 job_type 两套语义 —— 而且**没有任何东西报错**。
* ⇒ 判据是**对象同一性**:同一个动作出现在多张能力矩阵里是**复用**(对的);
* 两个不同对象共用一个 jobType 才是违规。
*/
it('⚠️⚠️ 同名 jobType 必须是同一个对象 —— 复用可以,⛔ 各写一份不行', () => {
const all = [...Object.values(RECEPTION_ACTIONS), ...Object.values(APPOINTMENT_CONFIRM_ACTIONS)];
const byType = new Map<string, unknown>();
const clashes: string[] = [];
for (const a of all) {
const seen = byType.get(a.jobType);
if (seen && seen !== a) clashes.push(a.jobType);
else byType.set(a.jobType, a);
}
expect(clashes).toEqual([]);
});
it('⭐ 转人工确实是被两边复用的**同一个对象**', () => {
expect(RECEPTION_ACTIONS.escalateToHuman).toBe(escalateToHuman);
expect(RECEPTION_ACTIONS.parseResponse).toBe(APPOINTMENT_CONFIRM_ACTIONS.parseResponse);
expect(APPOINTMENT_CONFIRM_ACTIONS.escalateToHuman).toBe(escalateToHuman);
});
/**
* ⭐ 出口从"动作上的常量"改成"挂 job 时的入参"之后,两种形态必须都还在。
* ⚠️ 空数组在 `JobRunner` 里落库是 NULL —— 界面据此判"画不画待办卡"。
*/
it('⚠️ 接待那条腿**不给出口**(该你答了),预约确认给三条', () => {
expect(escalateToHuman.optionsOf?.({ reason: 'low_confidence' })).toEqual([]);
expect(
escalateToHuman.optionsOf?.({
reason: 'reschedule',
options: ['handled', 'nurse_call', 'keep_watching'],
}),
).toEqual(['handled', 'nurse_call', 'keep_watching']);
});
});
describe('一套意图词表', () => {
/**
* ⚠️⚠️ 合并之前是两套,重叠只有 `medical`:
* 预约确认 confirmed|reschedule|cancel|medical|unknown
* 通用接待 medical|appointment|price|complaint|question|chitchat|other
* ⇒ 同一句「我想把下周三那次挪一下」,有没有在等确认,判出的档和给的出口都不同。
*/
it('⚠️ 两条腿用的都是全集的**子集** —— ⛔ 谁也不许自己发明档', () => {
const all = new Set<string>(PATIENT_INTENTS);
for (const i of [...REPLY_INTENTS, ...RECEPTION_INTENTS]) expect(all.has(i)).toBe(true);
});
it('⛔ `other` 已经并进 `unknown` —— 两个名字说的是同一件事', () => {
expect(PATIENT_INTENTS).not.toContain('other');
expect(RECEPTION_INTENTS).toContain('unknown');
expect(REPLY_INTENTS).toContain('unknown');
});
it('⭐ 「改期 / 不来了」现在两条腿都表达得了 —— 那是合并的**目的**', () => {
// ⚠️ 接待暂时还没在词表里用它们(第 1 刀的分流表才会用),
// 但它们已经在全集里、且预约确认那条腿在用 ⇒ ⛔ 不会再出现"接待没有这一档"
for (const i of ['reschedule', 'cancel'] as const) {
expect(PATIENT_INTENTS).toContain(i);
expect(REPLY_INTENTS).toContain(i);
}
});
});
......@@ -36,12 +36,25 @@ describe('agentActivityOf', () => {
expect(r.detail).toBeNull();
});
it('⚠️ 重试要说出来 —— ⛔ 第 3 次尝试不能显示成"一切正常"', () => {
it('⚠️ 真失败过要说出来,**而且带原因** —— ⛔ 不能显示成"一切正常"', () => {
const r = agentActivityOf({ queue: queue('pending', 2, '连接超时'), llm: null, lastInboundAt: T0 });
expect(r.detail).toContain('第 3 次尝试');
expect(r.detail).toContain('失败');
// ⭐ 原因才是可操作的那一半:光说"失败了 3 次",人不知道该找谁
expect(r.detail).toContain('连接超时');
});
/**
* ⚠️⚠️ `attempt > 0` **不是"出事了"**:worker 认领时就 +1,
* 进程重启 / 部署会让任务被回收重投 —— 它下一秒就会重新跑,
* 一线员工什么都不用做。对做不了任何事的人报状态就是噪音。
* ⛔ 这条钉住"没有 error 就不说话"。前两版分别渲染成
* 「第 2 次尝试:」(吊着的冒号)和「上次没跑完(第 2 次)」(没人看得懂)。
*/
it('⛔ 只是被中断重投(没有 error)⇒ **一个字都不说**', () => {
const r = agentActivityOf({ queue: queue('pending', 1, null), llm: null, lastInboundAt: T0 });
expect(r.detail).toBeNull();
});
it('⭐ 跑完了、没草稿 → declined,detail 是**模型自己写的理由**', () => {
const r = agentActivityOf({ queue: null, llm: llm(), lastInboundAt: T0 });
expect(r.state).toBe('declined');
......@@ -116,3 +129,36 @@ describe('agentActivityOf', () => {
expect(agentActivityOf({ queue: null, llm: llm(), lastInboundAt: null }).state).toBe('declined');
});
});
/**
* ⭐⭐ 「AI 已写好,正在发送」—— 补的是一个**真缺口**:
* AI 决定自己发之后不再留草稿(留了会重复发送),于是闸门 hold 住时
* 界面上一点动静都没有,表现成"AI 好像什么都没做"。
*/
describe('sending', () => {
it('⭐ 有 AI 自己那条待发 ⇒ sending,且**压过**"正在读"', () => {
const r = agentActivityOf({
queue: queue('processing', 0, null),
outPendingAt: new Date(T0),
llm: null,
lastInboundAt: T0,
});
expect(r.state).toBe('sending');
expect(r.since).toBe(new Date(T0).toISOString());
});
it('⛔ 没有待发就不该冒出这个状态', () => {
const r = agentActivityOf({ queue: null, outPendingAt: null, llm: null, lastInboundAt: T0 });
expect(r.state).not.toBe('sending');
});
/**
* ⚠️ 文案不在这一层,但判据在:`detail` 恒为 null ——
* ⛔ 别把闸门原因、outbox id、attempt 塞进去。一线员工对它们做不了任何事,
* 说了只会让他以为要他去处理。
*/
it('⚠️ 不带 detail —— ⛔ 不露 outbox / 闸门这些实现', () => {
const r = agentActivityOf({ queue: null, outPendingAt: new Date(T0), llm: null, lastInboundAt: T0 });
expect(r.detail).toBeNull();
});
});
......@@ -21,7 +21,12 @@ describe('bucketOf —— 九态归五档', () => {
['pending', 'pending'],
['running', 'waiting'],
['awaiting_patient', 'waiting'],
['failed_retry', 'waiting'],
/**
* ⚠️ 2026-09-01 改名:测试里原来写的是 `failed_retry`,而 `ck_tasks_status`
* 里根本没有那个值 —— 生产代码和测试**一起错着**,于是那一档
* 永远匹配不到,测试也照样绿。⛔ 状态取值以 DB 的 CHECK 为准。
*/
['retry_pending', 'waiting'],
['agent_done', 'done'],
['completed', 'settled'],
['closed', 'settled'],
......@@ -41,7 +46,7 @@ describe('bucketOf —— 九态归五档', () => {
it('⚠️ 九态**一个不漏** —— 少一个就是那一档的任务在界面上人间蒸发', () => {
const NINE = [
'pending', 'running', 'awaiting_patient', 'agent_done', 'needs_human',
'human_working', 'completed', 'failed_retry', 'closed',
'human_working', 'completed', 'retry_pending', 'closed',
];
expect(NINE.filter((s) => bucketOf(s) === null)).toEqual([]);
});
......@@ -51,7 +56,7 @@ describe('bucketOf —— 九态归五档', () => {
});
it('⭐ 「要我处理」只有转人工那两个状态 —— ⛔ agent_done 不许混进来', () => {
const human = ['pending', 'running', 'awaiting_patient', 'agent_done', 'completed', 'closed', 'failed_retry']
const human = ['pending', 'running', 'awaiting_patient', 'agent_done', 'completed', 'closed', 'retry_pending']
.filter((s) => bucketOf(s, 'authority') === 'human');
expect(human).toEqual([]);
});
......@@ -339,3 +344,34 @@ describe('hostWriteBack —— 结果写回宿主了吗(没有)', () => {
}
});
});
/**
* ⚠️⚠️ **状态取值必须和 DB 的 `ck_tasks_status` 对齐。**
*
* 2026-09-01 实测到:生产代码和测试**一起**把 `retry_pending` 写成了 `failed_retry`,
* 于是那个分档永远匹配不到、那句中文永远显示不出来,而**测试照样绿** ——
* 因为两边错得一模一样。⇒ 判据换成读 `prisma/schema.prisma` 里的 MIGRATION 注释,
* 那是离 DB 最近的一份声明。
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
describe('九态', () => {
it('⚠️⚠️ 代码里用到的状态,一个都不许是 schema 里没有的', () => {
// ⚠️ 读**迁移 SQL**,⛔ 不读 schema.prisma —— 后者只写了约束名,没写取值
const sql = readFileSync(
join(__dirname, '../prisma/migrations/20260825100200_agent/migration.sql'),
'utf8',
);
const line = sql.split('\n').find((l) => l.includes('ck_tasks_status'));
expect(line).toBeTruthy();
const allowed = new Set([...line!.matchAll(/'([a-z_]+)'/g)].map((m) => m[1]!));
// ⚠️ 兜底自检:注释格式变了就该红,⛔ 别让它静默通过
expect(allowed.has('awaiting_patient')).toBe(true);
for (const st of ['pending', 'running', 'awaiting_patient', 'agent_done', 'needs_human',
'human_working', 'completed', 'retry_pending', 'closed']) {
expect({ st, ok: allowed.has(st) }).toEqual({ st, ok: true });
}
expect(allowed.has('failed_retry')).toBe(false);
});
});
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
/**
* 「**AI 该不该闭嘴**」这条判据散在**三处**,必须一起收窄。
*
* ═══ ⚠️⚠️ 它漂过两次,两次都是漏改 ═══════════════════════════════════════
*
* 2026-09-01 把「AI 已暂停」的含义收窄成**只有人显式接管**(`source_type='human'`):
* 一条改期待办要人办,⛔ 不等于这段对话归人 —— 前者不该让 AI 对这个患者哑掉。
*
* 改的时候一处一处发现的:
* ```
* ① GateService 投递时拦不拦这条消息 —— 第一轮改了
* ② TakeoverService 标题栏说没说「AI 已暂停」 —— 第二轮才发现漏了
* ③ CommonContextService 提示词里告不告诉模型 —— 第三轮才发现漏了
* ```
* ③ 漏掉的表现最坏:提示词里塞了「这个患者正在被同事人工处理」,
* 模型照着回「已有同事在人工跟进,无需插话」—— **而根本没人在跟**。
* 界面说 AI 在跑、AI 自己说有人在跟,两句话互相打脸。
*
* ⇒ 这条测试直接读那三个文件,要求那段谓词旁边必须有 `source_type = 'human'`。
*/
const SITES = [
['src/modules/outbound/gate.service.ts', '投递闸:拦不拦这条消息'],
['src/modules/workbench/takeover.service.ts', '标题栏:说不说「AI 已暂停」'],
['src/modules/agent/context/common-context.service.ts', '提示词:告不告诉模型有人在跟'],
] as const;
describe('「AI 已暂停」三处判据', () => {
it('⚠️⚠️ 三处都必须只认**人显式接管**', () => {
for (const [rel, what] of SITES) {
const src = readFileSync(join(__dirname, '..', rel), 'utf8');
// ⚠️ 兜底自检:谓词本身还在不在 —— 不在说明上游重构了,这条测试要跟着改
expect({ what, has: /needs_human/.test(src) }).toEqual({ what, has: true });
expect({ what, narrowed: /source_type = 'human'|taskKind: 'manual_takeover'/.test(src) }).toEqual({
what,
narrowed: true,
});
}
});
});
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { outcomeZh } from '../src/modules/workbench/agent-task.service';
/**
* `tasks.outcome` / `tasks.closed_reason` 的**中文覆盖**。
*
* ═══ ⚠️⚠️ 这条测试是有来历的 ═══════════════════════════════════════════════
*
* `outcomeZh` 认不出一个值时会 `return outcome` —— 把**英文码原样摆到屏幕上**。
* 那个兜底本身是对的(人工关闭时 `closed_reason` 是一句手写的话,原样显示才准确),
* 但它同时让"忘了加翻译"变成**静默**的:界面上多了一串
* `handed_back_to_ai`,没有任何错误、没有任何日志。
*
* 实测 2026-09-01:患者时间线接进来的当天,`manual_followup` 那 10 条
* `handed_back_to_ai` 就直接显示在患者面板上了。⛔ 而这个值从上线第一天就在写。
*
* ⇒ 这条测试直接去**源码里数**「谁往这两列写常量」,一个个要求有中文。
* ⚠️ 这样第四个写入方出现时也会被抓到,⛔ 不用有人记得回来改测试。
*/
/**
* 「这一行在往那两列写东西吗」。
*
* ⚠️⚠️ ⛔ **别只写 `/reason: '(...)'/`** —— 那会把 `escalate({ reason })` 的动作入参
* 也捞进来(实测捞到 `medical_question` / `low_confidence`,它们根本不落这两列)。
* 判据必须带上**列名**或 SETTLE 那两条的 `end:` 上下文。
*/
const SETTLE_ROWS = [
/end: 'completed', outcome: '([a-z_]+)'/g,
/end: 'closed', reason: '([a-z_]+)'/g,
];
/** 原始 SQL / Prisma 那几处 —— ⚠️ 整行取字面量,好把三元也覆盖掉 */
const COLUMN_LINE = /(closedReason|closed_reason|\boutcome\s*=)/;
/**
* ⚠️ 同一行上的**状态值**要剔掉:`SET status = 'completed', outcome = '...'`
* 整行取字面量时会把 `completed` 也带出来,而它是 `tasks.status` 不是结局。
*/
const STATUS_WORDS = new Set(['completed', 'closed', 'pending', 'running', 'agent_done']);
/** ⚠️ 只扫这几支 —— 这两列的写入方全在这里。扫全 src 会把同名字段的别的东西也捞进来 */
const ROOTS = ['src/modules/workbench', 'src/modules/agent/scenes'];
function walk(dir: string): string[] {
const out: string[] = [];
for (const name of readdirSync(dir)) {
const full = join(dir, name);
if (statSync(full).isDirectory()) out.push(...walk(full));
else if (name.endsWith('.ts')) out.push(full);
}
return out;
}
describe('outcomeZh —— 每一个会落库的结局都得有中文', () => {
it('⚠️⚠️ 源码里写进 outcome / closed_reason 的常量,一个都不许露出英文码', () => {
const files = ROOTS.flatMap((r) => walk(join(__dirname, '..', r)));
const found = new Set<string>();
for (const f of files) {
const src = readFileSync(f, 'utf8');
for (const re of SETTLE_ROWS) {
for (const m of src.matchAll(re)) found.add(m[1]!);
}
for (const line of src.split('\n')) {
if (!COLUMN_LINE.test(line)) continue;
for (const m of line.matchAll(/'([a-z_]+)'/g)) {
if (!STATUS_WORDS.has(m[1]!)) found.add(m[1]!);
}
}
}
// ⚠️ 兜底自检:一个都没扫到 = 正则跟着重构漂了,那比漏一个值更糟
expect(found.size).toBeGreaterThan(4);
const naked = [...found].filter((v) => {
// ⚠️ completed 和 closed 两条支路的兜底不一样 —— 两边都问一遍
const a = outcomeZh('completed', v, null);
const b = outcomeZh('closed', null, v);
return a === v || b === v;
});
expect(naked).toEqual([]);
});
it('⭐ `attended:*` 走前缀 —— ⛔ 宿主的原状态不该被要求逐个翻译', () => {
expect(outcomeZh('completed', 'attended:arrived', null)).toBe('已到诊');
expect(outcomeZh('completed', 'attended:随便一个宿主状态', null)).toBe('已到诊');
});
it('⭐ 人工手写的关闭理由**原样显示** —— ⛔ 别翻成「已关闭」', () => {
// 那句话本身就是最准确的解释,翻掉等于把唯一有信息量的部分丢掉
expect(outcomeZh('closed', null, '人工先观察后关闭(原标注有误)')).toBe(
'人工先观察后关闭(原标注有误)',
);
});
it('⚠️ 补的那两条在 —— 它们从上线第一天就在写库,只是没人翻译', () => {
expect(outcomeZh('completed', 'handed_back_to_ai', null)).toBe('交还给 AI');
expect(outcomeZh('closed', null, 'cancelled_by_patient')).toBe('患者说不来了');
});
});
import {
dispositionOf,
escalationReasonOf,
NEEDS_HUMAN_NO_EXIT,
type ReplyContext,
} from '../src/modules/agent/shared/reply-routing';
import { PATIENT_INTENTS, type PatientIntent } from '../src/modules/agent/shared/intent';
/**
* 处置表 —— 两条腿共用的那一张。
*
* ⚠️⚠️ 这张表存在的**唯一**理由是:合并前同一句话在两条腿上给出不同的出口。
* 下面第一条就是那个洞的回归测试。
*/
describe('dispositionOf', () => {
const BOTH: ReplyContext[] = ['awaiting_confirm', 'open'];
/**
* ⭐⭐ 合并前实测:
* 有在等确认 ⇒ reschedule ⇒ propose_reschedule + 三个出口
* 没有 ⇒ appointment ⇒ escalate + **没有出口**
* ⇒ 同一件事,人在一边能选,在另一边只能看到一句"该你答了"。
*/
it('⭐⭐ 「要改期」两种上下文**处置相同** —— 这就是这次合并修的洞', () => {
const [a, b] = BOTH.map((c) => dispositionOf('reschedule', c));
expect(a).toEqual(b);
expect(a!.humanAction).toBe('propose_reschedule');
// ⚠️ 第一个出口是「已在系统里改好约」—— AI 改不了预约(PAC 开放面全是读)
expect(a!.options[0]).toBe('create_appointment');
});
it('⭐ 「不来了」同样两栏一致 —— 取消是业务决定,AI 不做', () => {
const [a, b] = BOTH.map((c) => dispositionOf('cancel', c));
expect(a).toEqual(b);
expect(a!.humanAction).toBe('escalate');
expect(a!.options.length).toBe(3);
});
/**
* ⚠️⚠️ 这两档**刻意保留差异** —— ⛔ 谁看到"两栏不一样"就顺手统一,会改掉论证过的行为。
* 在等确认时那条 Task 还挂着一个**预约**要收尾 ⇒ 人得决定它怎么办;
* 会话里人要做的事只有一件 —— 回他。摆三颗按钮是在问一个不存在的问题。
*/
it('⚠️ 「医疗 / 读不懂」在会话里**不给出口**,在等确认时给三个', () => {
for (const i of ['medical', 'unknown'] as const) {
expect(dispositionOf(i, 'open').options).toEqual([]);
expect(dispositionOf(i, 'awaiting_confirm').options.length).toBe(3);
// ⚠️ 但两边都要转人工 —— 差的只是出口
expect(dispositionOf(i, 'open').humanAction).toBe('escalate');
}
});
it('⭐ 「会准时来」两栏都不转人工 —— AI 自己收得了尾', () => {
for (const c of BOTH) expect(dispositionOf('confirmed', c).humanAction).toBeNull();
});
it('⚠️ 全词表每一档都有行 —— ⛔ 漏一档就是运行时 undefined', () => {
for (const i of PATIENT_INTENTS) {
for (const c of BOTH) expect(dispositionOf(i as PatientIntent, c)).toBeDefined();
}
});
it('⚠️ 表里回"不转人工"的那几档,模型说 needsHuman 时走**无出口**那一支', () => {
// 「你们几点下班」和「几点下班?我投诉过一次」是同一个 intent、两种处置
// ⚠️ `complaint` 除外 —— 投诉是**有件事要人跟**,它自己就开 Task
for (const i of ['appointment', 'price', 'question', 'chitchat'] as const) {
expect(dispositionOf(i, 'open').humanAction).toBeNull();
}
expect(NEEDS_HUMAN_NO_EXIT.humanAction).toBe('escalate');
expect(NEEDS_HUMAN_NO_EXIT.options).toEqual([]);
});
});
/**
* `escalation_reason` 必须是**一套**有限取值 —— `AgentTaskService.NEED`
* 那张能力/资格判据表就按这些名字分档。
*/
describe('escalationReasonOf', () => {
it('⛔ 不许把 intent 原样当 reason —— 那是同一件事两个名字', () => {
expect(escalationReasonOf('reschedule')).toBe('reschedule_request');
expect(escalationReasonOf('cancel')).toBe('appointment_cancel');
expect(escalationReasonOf('medical')).toBe('medical_question');
});
it('⚠️ 认不出的一律 low_confidence —— ⛔ 别让新档冒出没人认识的 reason', () => {
for (const i of ['appointment', 'price', 'complaint', 'question', 'chitchat', 'unknown'] as const) {
expect(escalationReasonOf(i)).toBe('low_confidence');
}
});
});
/**
* ⭐⭐ `opensTask` —— 「要人管」和「开一条 Task」是两件事。
*/
describe('opensTask', () => {
it('⛔ 「这句我不会答,你来」**不开 Task** —— 那是 agent 行为,不是待办', () => {
// 实测症状:那类 Task 6 条里 4 条最后是人手工关掉的,理由写着「先观察后关闭」
expect(dispositionOf('unknown', 'open').opensTask).toBe(false);
expect(NEEDS_HUMAN_NO_EXIT.opensTask).toBe(false);
});
it('⭐ 患者身上**要办的事**才开 Task', () => {
for (const i of ['medical', 'reschedule', 'cancel', 'complaint'] as const) {
expect(dispositionOf(i, 'open').opensTask).toBe(true);
}
});
it('⚠️ 在等确认那一栏**都开** —— 那条 Task 本来就存在(是它把患者问出来的)', () => {
for (const i of ['reschedule', 'cancel', 'medical', 'unknown'] as const) {
expect(dispositionOf(i, 'awaiting_confirm').opensTask).toBe(true);
}
});
});
......@@ -39,14 +39,21 @@ describe('controlOf —— 谁在说话', () => {
expect(c.takeBlocked).toContain('本来就不会主动开口');
});
it('⚠️⚠️ AI 交出来但没人认领 ⇒「还没人接手」,⛔ 不能说成"有人在处理"', () => {
const c = controlOf([hold()], ME, true);
expect(c.holder).toBe('human');
expect(c.label).toBe('AI 已暂停');
expect(c.who).toBe('还没人接手');
expect(c.by).toBeNull();
// ⭐ 这时任何人都能接手
expect(c.canTake).toBe(true);
/**
* ⚠️⚠️ 这条以前断言的是「AI 交出来但没人认领 ⇒ 还没人接手 ⇒ 任何人都能接手」。
* **那个状态 2026-09-01 起到不了了**,两处一起收窄的结果:
* ① 「AI 已暂停」只认人**显式**接管(`source_type='human'`)——
* 一条改期待办不该让界面说 AI 停了,而它根本没停(闸也只认手动接管)
* ② 而显式接管那一刻 `assignee_user_id` 就设成了按按钮的人 ⇒ 永远已认领
* ⇒ 「还没人接手」和「我来接手」两个都删了。
* ⛔ 别把它们加回来当"多人抢单"的入口:一条会话只属于一个托管号、
* 一个号只属于一个人 ⇒ **能发消息的只有一个人**,那颗按钮在问一个
* 只有一个答案的问题。
*/
it('⛔ 「还没人接手」这个状态不该再出现 —— 停 AI 的人就是认领人', () => {
const c = controlOf([hold({ sourceType: 'human', assigneeUserId: ME, assigneeName: '王护长' })], ME, true);
expect(c.who).not.toBe('还没人接手');
expect(c.canTake).toBe(false);
});
it('我拿着 ⇒「我在跟」,可以恢复 AI,⛔ 不能再暂停一次', () => {
......
import { TIMELINE_JOB_TABLE } from '../src/modules/workbench/patient-timeline.service';
import { escalationReasonOf } from '../src/modules/agent/shared/reply-routing';
import { PATIENT_INTENTS, type PatientIntent } from '../src/modules/agent/shared/intent';
import { APPOINTMENT_CONFIRM_ACTIONS } from '../src/modules/agent/scenes/appointment-confirm/appointment-confirm.service';
import { RECEPTION_ACTIONS } from '../src/modules/agent/scenes/reception/reception.service';
/**
* 患者时间线的**准入判据**。判据换过两版都错,两版的教训都钉在这儿。
*/
describe('时间线准入', () => {
const declared = new Set(TIMELINE_JOB_TABLE.map((k) => k.key));
const on = (key: string) => TIMELINE_JOB_TABLE.find((k) => k.key === key)?.on;
/**
* ⚠️⚠️ **v1 是 `task_kind` 白名单** —— 错在和**来源**绑死:
* 同一件「患者要改期」,定时腿落 `appointment_confirm`、
* 会话腿落 `manual_followup` ⇒ 白名单让后者在时间线上消失。
* ⇒ 判据里**不许出现 task_kind**。
*/
it('⛔ 判据不看 task_kind —— 两条腿的区别只是来源', () => {
for (const kind of ['appointment_confirm', 'manual_followup', 'symptom_triage', 'manual_takeover']) {
expect(declared.has(kind)).toBe(false);
}
});
/**
* ⚠️⚠️ **v2 是「有没有出口(`jobs.options` 非空)」** —— 判据讲得通,
* 但读的是**一列会过时的数据**:接待层的转人工曾经也给三个出口,后来撤了,
* 而历史行还带着。实测当场回归:4 条纯会话往来又冒回时间线。
* ⇒ 判据只读**语义稳定**的列。`escalation_reason` 从第一天起就是有限取值。
*/
it('⭐ 「AI 答不上来」那两档必须不上 —— 哪怕历史行上带着出口', () => {
expect(on('escalate_to_human:low_confidence')).toBe(false);
expect(on('escalate_to_human:medical_question')).toBe(false);
});
it('⭐ 对患者的**业务动作**才上', () => {
expect(TIMELINE_JOB_TABLE.filter((k) => k.on).map((k) => k.key).sort()).toEqual([
'call_to_confirm',
'escalate_to_human:appointment_cancel',
'escalate_to_human:reschedule_request',
'propose_reschedule',
'send_appointment_reminder',
]);
});
/**
* ⭐⭐ 这条是「展示同形」的回归测试:会话腿判出改期/取消时,
* 走的是 `propose_reschedule` 和 `escalate_to_human:reschedule_request`
* / `:appointment_cancel` —— 三个都在表里 ⇒ **它和定时腿一样上时间线**。
*/
it('⭐⭐ 会话里发现的改期 / 取消,和定时腿一样上时间线', () => {
expect(on('propose_reschedule')).toBe(true);
expect(on(`escalate_to_human:${escalationReasonOf('reschedule')}`)).toBe(true);
expect(on(`escalate_to_human:${escalationReasonOf('cancel')}`)).toBe(true);
});
it('⚠️⚠️ 每个动作都必须在表里表过态 —— 漏了是**静默**缺席', () => {
const all = [...Object.values(RECEPTION_ACTIONS), ...Object.values(APPOINTMENT_CONFIRM_ACTIONS)];
expect(all.length).toBeGreaterThanOrEqual(8);
for (const a of all) {
if (a.jobType === 'escalate_to_human') continue; // 它按 reason 分行,下一条测
expect(declared.has(a.jobType)).toBe(true);
}
});
it('⚠️⚠️ `escalate_to_human` 的**每一个** reason 都必须有行', () => {
const reasons = new Set(PATIENT_INTENTS.map((i) => escalationReasonOf(i as PatientIntent)));
expect(reasons.size).toBeGreaterThanOrEqual(4);
for (const r of reasons) expect(declared.has(`escalate_to_human:${r}`)).toBe(true);
});
it('⚠️ 每一行都得写清楚为什么 —— ⛔ 没有理由的开关表,下一个人只会照着抄', () => {
for (const k of TIMELINE_JOB_TABLE) expect(k.why.length).toBeGreaterThan(15);
});
});
......@@ -535,11 +535,17 @@ function Operation({
· 有会话的 = AI 撞到业务边界交出来的(改期/取消/医疗)⇒ 红,要马上看
· 没会话的 = 本来就该人做的常规活(打电话确认)⇒ 琥珀,是待办不是告警
⛔ 都画成红的话,「今天有 20 个电话要打」会看起来像 20 个异常。
⚠️ 区分**只靠底色 + chip + 文字色**,⛔ 不再加边框。
上一版是 `border-t-[3px]` 的重顶边 —— 这一页别的块(`Field`、摘要)
全是「圆角 + 底色、无边框」,就这一块带边,看起来像另一套设计。
⭐ 底色本来就够分:rose-50 和 amber-50 一眼分得开,⛔ 边框是重复表达。
⚠️ 内边距也跟着降到 `px-3.5 py-3` —— 和本页其它块**同一个值**。
*/}
{d.humanJob ? (
<div
className={`rounded-[10px] border-t-[3px] px-4 py-3.5 ${
d.conversationId ? 'border-rose-600 bg-rose-50' : 'border-amber-500 bg-amber-50'
className={`rounded-[10px] px-3.5 py-3 ${
d.conversationId ? 'bg-rose-50' : 'bg-amber-50'
}`}
>
<div className="flex flex-wrap items-center gap-1.5">
......
......@@ -16,7 +16,7 @@ import { useConvStream } from '@/lib/use-conv-stream';
import { useClinic } from '@/lib/use-clinic';
import { humanWait } from '@/lib/utils';
import { ConvList, type Filter } from '@/components/conv-list';
import { ConvDetail, type PendingTask } from '@/components/conv-detail';
import { ConvDetail } from '@/components/conv-detail';
import { ConvHeader } from '@/components/conv-header';
import { AccountGate } from '@/components/account-gate';
import { PatientPanel } from '@/components/patient-panel';
......@@ -118,6 +118,16 @@ function Workbench() {
* ⚠️ 现问 PAC,不存副本([03 D9])。取不到时静默 null —— 界面显示"读不到"。
*/
const [boundPatient, setBoundPatient] = useState<PatientProfile | null>(null);
/**
* ⚠️ 患者全貌的**重取信号**。
*
* 那条时间线现在也铺我们自己的 Task ⇒ 人在时间线上点完出口按钮之后,
* 这一份必须跟着刷新,否则那一行还挂在「需要人工参与」上 ——
* 人会以为没点上,然后再点一次。
* ⛔ 别改成"点完自己改本地 state":后端结算一条 Job 可能连带把 Task 结掉、
* 解除接管闸,前端猜不全那些联动。
*/
const [profileNonce, setProfileNonce] = useState(0);
/** 401 一律跳登录页 —— 令牌过期 / 被 token_version 作废都会走到这里。 */
/**
......@@ -411,66 +421,31 @@ function Workbench() {
[activeId, guard, refreshDetail],
);
/**
* 这条会话上 Agent 交给人的待办。
*
* ⚠️ 单独一次请求,⛔ 不塞进 `detail` —— 两个理由:
* ① `detail()` 已经很重(消息 + 媒体 + 患者),再挂一段会让打开会话更慢;
* ② 待办要能**单独刷新**:人点完一个出口只需要重拉这一小段,
* 重拉整个详情会把滚动位置弹回去。
*/
const [tasks, setTasks] = useState<PendingTask[]>([]);
const refreshTasks = useCallback(
async (id: string) => {
try {
const r = await api.get<{ items: PendingTask[] }>(`/conversations/${id}/tasks`);
/**
* ⚠️⚠️ **回来时人可能已经切走了** —— 和 `refreshDetail` / `refreshList` 同一条纪律,
* 这里原来漏了。表现不只是显示错:待办卡上那几个出口是**可点的**,
* 于是人会对着 B 会话,把 A 会话的待办给结了 —— 而界面上没有任何地方露馅。
* ⚠️ 用 `activeRef` 不用闭包里的 `activeId`:这个回调的依赖是空数组(故意的,
* 它要在实时刷新里被反复调用),闭包里那个永远是第一次渲染时的值。
*/
if (activeRef.current !== id) return;
setTasks(r.items ?? []);
} catch {
// ⚠️ 静默 —— 待办拉不到不该让整条会话打不开(同患者卡那条)
// ⚠️ 但**清空也要判**:A 的请求失败不该把 B 已经显示出来的待办抹掉
if (activeRef.current === id) setTasks([]);
}
},
[],
);
useEffect(() => {
if (!activeId) {
setTasks([]);
return;
}
void refreshTasks(activeId);
}, [activeId, refreshTasks]);
/**
* 人选了一个出口。
*
* ⚠️ 成功后**三样都要刷**:待办(它没了)、详情、列表 ——
* ⚠️ 成功后**三样都要刷**:详情、列表、**患者全貌**(待办卡就在患者全貌里)——
* Task 离开 needs_human 会**解除人工接管闸**([14 §4.4]),
* 那意味着 AI 又能对这个患者说话了,列表上的状态也跟着变。
* ⚠️ 第四样是后加的:右栏那条时间线现在也铺 Task,⛔ 漏了它那一行就不更新。
*/
const chooseTask = useCallback(
async (jobId: string, option: string) => {
if (!activeId) return false;
try {
await api.post(`/conversations/jobs/${jobId}/choose`, { option });
await Promise.all([refreshTasks(activeId), refreshDetail(activeId), refreshList()]);
await Promise.all([refreshDetail(activeId), refreshList()]);
// ⚠️ 第四样:右栏那条时间线上也有这条 Task(见 profileNonce 的注释)
setProfileNonce((n) => n + 1);
return true;
} catch (e) {
guard(e);
return false;
}
},
[activeId, guard, refreshTasks, refreshDetail, refreshList],
[activeId, guard, refreshDetail, refreshList],
);
/**
......@@ -485,14 +460,14 @@ function Workbench() {
if (!activeId) return false;
try {
await api.post(`/conversations/${activeId}/${path}`);
await Promise.all([refreshDetail(activeId), refreshList(), refreshTasks(activeId)]);
await Promise.all([refreshDetail(activeId), refreshList()]);
return true;
} catch (e) {
guard(e);
return false;
}
},
[activeId, guard, refreshDetail, refreshList, refreshTasks],
[activeId, guard, refreshDetail, refreshList],
);
const takeOver = useCallback(() => setControl('takeover'), [setControl]);
const releaseToAi = useCallback(() => setControl('release'), [setControl]);
......@@ -529,7 +504,7 @@ function Workbench() {
return () => {
alive = false;
};
}, [detail?.patientLink, clinicId]);
}, [detail?.patientLink, clinicId, profileNonce]);
useEffect(() => {
if (listLoading || items.length > 0) return;
......@@ -689,8 +664,6 @@ function Workbench() {
onSendMedia={sendMedia}
onTake={takeOver}
onRelease={releaseToAi}
tasks={tasks}
onChooseTask={chooseTask}
canSendMedia={me?.capabilities?.sendMedia ?? false}
preset={preset}
/>
......@@ -698,6 +671,7 @@ function Workbench() {
<PatientPanel
conv={detail}
profile={boundPatient}
onChooseTask={chooseTask}
onQuote={(text) => setPreset({ text, at: Date.now() })}
/>
</div>
......
......@@ -20,75 +20,7 @@ export interface PendingTask {
options: { value: string; label: string }[];
}
/** 转人工的原因 → 人话。⚠️ 取值来自 `jobs.escalation_reason`,是有限集 */
const ESCALATION_ZH: Record<string, string> = {
medical_question: '患者提到了症状或用药',
reschedule_request: '患者要求改时间',
appointment_cancel: '患者要取消',
low_confidence: 'AI 没读懂患者的回复',
};
/**
* Agent 交给人的待办。
*
* ⚠️⚠️ **它必须显眼,而且必须在消息上面** —— 这块存在的全部理由是:
* AI 判断"这件事我不该自己做"之后,人得看得见。看不见的话,
* 患者说了要改期,系统认真地记了一笔,然后**再也没有人知道**。
*
* ⚠️ 按钮文案一律来自服务端(`options[].label`),⛔ 前端不另写一份 ——
* 「选了之后这件事算不算完」的真源在 `TaskService.SETTLE`,
* 两处写就会漂,而漂了的表现是"点了『已处理』但任务还挂着"。
*
* ⚠️ 「已在系统里改好约」的措辞是**刻意的**:我们没有预约写接口,
* 这颗按钮的含义是**人自己去宿主系统改完了,回来记一笔**。
* ⛔ 别改成"帮我改约"——那是在承诺一个做不到的动作。
*/
function TaskCard({
task,
onChoose,
}: {
task: PendingTask;
onChoose: (jobId: string, option: string) => Promise<boolean>;
}) {
const [busy, setBusy] = useState<string | null>(null);
const why = task.escalationReason ? ESCALATION_ZH[task.escalationReason] : null;
return (
<div className="rounded-lg border border-amber-200 bg-amber-50/70 p-3">
<div className="flex items-start gap-2">
<span className="mt-[1px] shrink-0 rounded bg-amber-500 px-1.5 py-[1px] text-[10px] font-medium text-white">
待你处理
</span>
<div className="min-w-0 flex-1">
<div className="text-[13px] font-medium text-slate-800">{task.title}</div>
<div className="mt-0.5 text-[11px] text-slate-500">
{why ?? task.triggerReason}
</div>
</div>
</div>
<div className="mt-2.5 flex flex-wrap gap-1.5">
{task.options.map((o) => (
<Button
key={o.value}
size="sm"
variant="outline"
disabled={busy !== null}
className="h-7 bg-white text-[12px]"
onClick={async () => {
setBusy(o.value);
try {
if (await onChoose(task.jobId, o.value)) notify.ok('已记下', o.label);
} finally {
setBusy(null);
}
}}
>
{busy === o.value ? '处理中…' : o.label}
</Button>
))}
</div>
</div>
);
}
/**
* Chat 面板 —— 稿子里 grid 左边那张白卡。
......@@ -110,8 +42,6 @@ export function ConvDetail({
onSendMedia,
onTake,
onRelease,
tasks,
onChooseTask,
canSendMedia,
preset,
}: {
......@@ -129,9 +59,7 @@ export function ConvDetail({
onTake: () => Promise<boolean>;
onRelease: () => Promise<boolean>;
/** 这条会话上 Agent 交给人的待办([14 §1.2] Action 的 A 面) */
tasks: PendingTask[];
/** 人选了一个出口(B 面)。返回 false = 失败 */
onChooseTask: (jobId: string, option: string) => Promise<boolean>;
/**
* 这个部署能不能发图片/文件(服务端 `capabilities.sendMedia`)。
*
......@@ -154,7 +82,6 @@ export function ConvDetail({
* ⚠️ 没有出口的(接待层的转人工)不画卡 —— 它只是"该你答了",
* 标在会话列表上、理由在下面的 Agent 反馈区。见渲染处的注释。
*/
const actionable = tasks.filter((t) => t.options.length > 0);
/** 正在上传/发送的附件名。⚠️ 有值时禁掉再次选择 —— 连点两次会发两份 */
const [attaching, setAttaching] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
......@@ -270,23 +197,18 @@ export function ConvDetail({
⛔ 别再挪回去 —— "画在消息上面"和"在滚动区里画在最上面"是两件事。
*/}
{/*
⭐⭐ **只画"真有出口要选"的待办。**
⚠️ 接待层的转人工**没有出口**(见后端 `escalate` 的注释)——
它不是一次抉择,是一句"该你答了"。给它画一张带按钮的卡,
是在问一个不存在的问题,人还得先想"我该点哪个"。
⇒ 那一类改走两处:会话列表上一个「我需要答」的标(一眼扫完,
⛔ 不用点进来才知道),理由写进下面那块 Agent 反馈区。
⚠️ 有出口的(预约确认的改期/取消)**照旧画卡** —— 那三个选项是真的分叉。
⚠️⚠️ **带出口的待办卡从这儿搬走了 —— 现在只在右栏时间线上出现。**
⛔ 两处各画一张是**同一条 job 的 options** 画了两个样子:左边黄底大卡、
右边时间线里一行小按钮。人会以为那是两件事。
⭐ 留右边的理由:那条待办**本来就是患者时间线上的一件事**(改约 / 取消 /
打电话确认),它该和「上次治疗」「上次回访」排在同一条线上被读到 ——
而不是浮在聊天记录顶上当横幅。
⚠️ 原来放这儿是为了"别被滚出视野"(消息列表每来一条就滚到底)。
那个理由在右栏不成立:时间线是**倒序**的,新建的那条永远在最上面。
⚠️ 没有出口的转人工(接待层的"该你答了")**两边都不画** —— 见
`escalateToHuman` 的注释:它不是一次抉择。它走会话列表上那个「我需要答」的标。
*/}
{actionable.length > 0 ? (
<div className="shrink-0 border-b border-amber-100 bg-amber-50/40 px-3.5 py-2.5">
<div className="flex flex-col gap-2">
{actionable.map((t) => (
<TaskCard key={t.jobId} task={t} onChoose={onChooseTask} />
))}
</div>
</div>
) : null}
<div className="flex min-h-0 flex-1 flex-col gap-2.5 overflow-auto p-3.5">
{detail.timeline.length === 0 ? (
......@@ -524,7 +446,7 @@ function AiAssist({
* 对这条新消息的回应 —— 那比什么都不显示更糟。
* ⛔ 也别只靠 `stale` 那行小字:实测证明一行小字盖不住一整块草稿。
*/
if (activity.state === 'queued' || activity.state === 'working') {
if (activity.state === 'queued' || activity.state === 'working' || activity.state === 'sending') {
return <Working activity={activity} />;
}
/**
......@@ -711,18 +633,19 @@ function ControlSwitch({
{busy ? '处理中' : '暂停 AI'}
</Button>
) : (
<>
{/* ⭐ 还没人认领时先出这颗:AI 已经停了,缺的是**谁来跟** */}
{control.canTake ? (
<Button
size="sm"
disabled={busy}
title="把这条挂到你名下,同事就知道有人在跟了"
onClick={() => run(onTake, '你接手了这条会话', '同事那边会看到是你在跟。')}
>
{busy ? '处理中' : '我来接手'}
</Button>
) : null}
/*
⭐⭐ **就两态:暂停 AI ⇄ 恢复 AI。**
⚠️⚠️ 这里原来还有一颗「我来接手」(`canTake` 那支),已经删了 ——
它在问一个**只有一个答案**的问题:一条会话只属于一个托管号
(`conversations.account_id` 单值),而一个号只属于一个 friday 用户
(`wecom_accounts.user_id` 1:1) ⇒ **能发消息的只有一个人**。
⚠️ 而且暂停这件事本身就是那个人做的:`TakeoverService` 建任务时
直接把 `assignee_user_id` 设成他 ⇒ 永远"已认领",那颗按钮永不出现。
⛔ 别把它当"多人协作"的入口加回来。真要多人抢单,那是**队列**的问题,
不是这颗按钮的问题 —— 而队列的前提是一条会话能被多个号发送,
那件事今天不成立。
*/
<Button
variant="outline"
size="sm"
......@@ -732,7 +655,6 @@ function ControlSwitch({
>
{busy ? '处理中' : '恢复 AI'}
</Button>
</>
)}
</div>
);
......@@ -769,11 +691,20 @@ function Working({ activity }: { activity: Detail['agentActivity'] }) {
}, [activity.since]);
const queued = activity.state === 'queued';
/**
* ⭐⭐ AI 已经写好、正在发给患者。
* ⚠️⚠️ 文案**只说"正在发"** —— ⛔ 不露"排队""闸门""outbox"这些实现:
* 一线员工对它们做不了任何事,说了只会让他以为要他去处理。
* ⚠️ 也⛔ 不说"已发送" —— 那是说假话:它还在路上,可能被闸拦一会儿。
*/
const sending = activity.state === 'sending';
return (
<div className="shrink-0 bg-violet-50 px-3 py-[11px]">
<div className="flex items-center gap-1.5">
<Loader2 className="size-3 shrink-0 animate-spin text-violet-500" />
<Badge tone="ai">{queued ? 'AI 排队中' : 'AI 正在读这条消息'}</Badge>
<Badge tone="ai">
{sending ? 'AI 已写好,正在发送' : queued ? 'AI 排队中' : 'AI 正在读这条消息'}
</Badge>
<span className="text-[10.5px] text-slate-400">已经 {secs}</span>
{/*
⚠️ 重试要**说出来** —— ⛔ 别把"第 3 次尝试"显示成"正在处理":
......@@ -788,7 +719,9 @@ function Working({ activity }: { activity: Detail['agentActivity'] }) {
人要的是"还要多久""是不是卡了",而不是被安慰。
*/}
<div className="mt-1.5 text-[11px] leading-[1.7] text-slate-500">
{queued
{sending
? '消息已经交出去了,正在发给患者。'
: queued
? '排在处理队列里,马上开始。'
: '它在读这条会话、必要时查患者资料,然后决定要不要回。通常 20–30 秒。'}
</div>
......
'use client';
import * as React from 'react';
import { Fragment, useState } from 'react';
import { Fragment, useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import type { ConvDetail, PatientProfile } from '@/lib/api';
import { Badge } from '@/components/ui/badge';
......@@ -50,6 +50,7 @@ export function PatientPanel({
conv,
profile,
onQuote,
onChooseTask,
}: {
conv: ConvDetail | null;
/**
......@@ -60,6 +61,13 @@ export function PatientPanel({
profile: PatientProfile | null;
/** 把一句话填进回复框(问手机号 / 问出生年份) */
onQuote: (text: string) => void;
/**
* ⭐ 时间线上那条 Task 的出口按钮。
* ⚠️ **和会话待办区用的是同一个 handler**(`page.tsx` 的 `chooseTask`)——
* ⛔ 别在这里另写一套 POST + 刷新:两处刷的东西不一样时,
* 点完按钮这一侧不更新,人会以为没生效然后再点一次。
*/
onChooseTask: (jobId: string, option: string) => Promise<boolean>;
}) {
const link = conv?.patientLink ?? null;
const mode: Mode = link ? 'linked' : 'intro';
......@@ -94,7 +102,7 @@ export function PatientPanel({
<div className="flex min-h-0 flex-1 flex-col gap-2.5 overflow-auto p-3">
{mode === 'intro' ? <Intro conv={conv} onQuote={onQuote} /> : null}
{mode === 'linked' ? <PatientTabs profile={profile} /> : null}
{mode === 'linked' ? <PatientTabs profile={profile} onChooseTask={onChooseTask} /> : null}
</div>
</aside>
);
......@@ -210,9 +218,25 @@ function Intro({ conv, onQuote }: { conv: ConvDetail; onQuote: (t: string) => vo
* 那是别人的临床内容,写在一个**真实患者**的面板上,会被当成他的记录。
* ⛔ 也不写「暂无 EMR 记录」:那是"问过 PAC 了,没有",而我们没问过。
*/
function PatientTabs({ profile }: { profile: PatientProfile | null }) {
function PatientTabs({
profile,
onChooseTask,
}: {
profile: PatientProfile | null;
onChooseTask: (jobId: string, option: string) => Promise<boolean>;
}) {
const patient = profile?.patient ?? null;
const [tab, setTab] = useState<'overview' | 'emr'>('overview');
/**
* ⭐⭐ 概述里点「病历」→ 切到 EMR 页并**展开那一次就诊**。
*
* ⚠️ 状态提到这一层是**必须的**:概述和 EMR 是**兄弟**,
* 一个要发起跳转、一个要接住,⛔ 谁也没法单独持有它。
* ⚠️ 存的是 `emr_record` 的 `subjectId`(不是列表下标)——
* 两个列表的下标各算各的,对不上(见 `EmrEncounter.subjectId` 的注释)。
* ⚠️ ⛔ 不清空:留着它,人切回概述再切回来时那条还是展开的。
*/
const [focusEmr, setFocusEmr] = useState<string | null>(null);
const tabBtn = (k: 'overview' | 'emr', label: string) => (
<button
type="button"
......@@ -236,7 +260,18 @@ function PatientTabs({ profile }: { profile: PatientProfile | null }) {
{tabBtn('emr', 'EMR')}
</div>
<div className="flex flex-col gap-2.5 px-3 pt-3">
{tab === 'overview' ? <Overview profile={profile} /> : <Emr profile={profile} />}
{tab === 'overview' ? (
<Overview
profile={profile}
onChooseTask={onChooseTask}
onOpenEmr={(subjectId) => {
setFocusEmr(subjectId);
setTab('emr');
}}
/>
) : (
<Emr profile={profile} focus={focusEmr} />
)}
<p className="pt-1 text-center text-[11px] text-slate-500">
往后发出去的消息都会记到{patient?.name ?? '他'}名下。
</p>
......@@ -264,7 +299,16 @@ function PatientTabs({ profile }: { profile: PatientProfile | null }) {
* ⚠️ 截断了要说出来(`factsTruncated`)—— 悄悄少给几条,人会把
* "没看到这次治疗"当成"没做过这次治疗"。
*/
function Overview({ profile }: { profile: PatientProfile | null }) {
function Overview({
profile,
onOpenEmr,
onChooseTask,
}: {
profile: PatientProfile | null;
/** ⭐ 点「病历」行时调 —— 切到 EMR 页并展开那一次(见 `PatientTabs` 里的说明) */
onOpenEmr: (subjectId: string) => void;
onChooseTask: (jobId: string, option: string) => Promise<boolean>;
}) {
const rows = timelineRows(profile);
return (
......@@ -273,7 +317,7 @@ function Overview({ profile }: { profile: PatientProfile | null }) {
<div className="min-w-0">
<div className="text-[13px] font-semibold">治疗与跟进</div>
<div className="mt-0.5 text-[11px] leading-[1.55] text-slate-500">
咨询、预约、到诊、诊断、治疗、收费、随访按时间倒序。
咨询、预约、到诊、诊断、治疗、收费、随访,和我们跟进过的事,按时间倒序。
</div>
</div>
{rows.length ? <Badge tone="slate">{rows.length}</Badge> : null}
......@@ -281,8 +325,10 @@ function Overview({ profile }: { profile: PatientProfile | null }) {
{rows.length === 0 ? (
<div className="px-3 py-6 text-center text-[11.5px] leading-[1.8] text-slate-400">
PAC 里还没有这位患者的记录
<div className="text-[10.5px] text-slate-300">他可能是新客,或者就诊数据还没同步过来</div>
还没有这位患者的记录
<div className="text-[10.5px] text-slate-300">
PAC 里没有,我们也还没跟进过 —— 他可能是新客,或者就诊数据还没同步过来
</div>
</div>
) : (
<div className="px-3 pt-1 pb-2.5">
......@@ -308,14 +354,19 @@ function Overview({ profile }: { profile: PatientProfile | null }) {
</div>
</div>
) : null}
<TimelineLine row={r} />
<TimelineLine row={r} onOpenEmr={onOpenEmr} onChooseTask={onChooseTask} />
</Fragment>
))}
</div>
{profile?.factsTruncated ? (
// ⚠️ 必须说 —— 见函数头注
/*
⚠️ 必须说 —— 见函数头注。
⚠️⚠️ 而且要说**截断的是哪一半**:PAC 的事实是截断的,
我们自己的跟进是全的。⛔ 只写「只显示最近 N 条」会让人以为
两边都被砍过 —— 而"我们做过的事"少给一条的后果是重复打扰患者。
*/
<p className="pt-2 text-center text-[10.5px] leading-[1.7] text-slate-400 text-pretty">
只显示最近 {rows.length} 条。更早的记录去 PAC 看
PAC 的记录只显示了最近一批,更早的去 PAC 看;我们跟进过的都在这儿
</p>
) : null}
</div>
......@@ -348,9 +399,37 @@ function Overview({ profile }: { profile: PatientProfile | null }) {
* ⚠️ 样式仍用本项目的规范(Badge 组件 / 本 app 的字号密度),
* ⛔ 不抄稿子里的棕色按钮和那套配色。
*/
function TimelineLine({ row }: { row: TimelineRow }) {
function TimelineLine({
row,
onOpenEmr,
onChooseTask,
}: {
row: TimelineRow;
onOpenEmr: (subjectId: string) => void;
onChooseTask: (jobId: string, option: string) => Promise<boolean>;
}) {
/**
* ⚠️ 点下去到刷新回来之间要**锁住整行的按钮**,⛔ 不是只锁被点的那颗:
* 出口是互斥的(选了「已处理」就不能再选「护士回电」),
* 而这几颗挨得很近 —— 不锁的话手快点两下就是两次结算。
*/
const [busy, setBusy] = useState(false);
const job = row.task?.settled ? null : (row.task?.humanJob ?? null);
return (
<div className="col-span-4 grid grid-cols-subgrid items-start gap-x-2.5 border-b border-white px-0.5 py-[9px] last:border-b-0">
<div
className={`col-span-4 grid grid-cols-subgrid items-start gap-x-2.5 border-b border-white py-[9px] last:border-b-0 ${
/*
⭐⭐ **等人处理的那一行用黄底**(和原来会话顶上那张卡同一个色)。
⚠️ 判据是「**现在有出口等你选**」,⛔ 不是"这是一条 task":
已结束的、AI 还拿着的都不染 —— 染了就等于说"这些也要你动手"。
⚠️ 黄不是告警(那是 rose),是**待办** —— 见 Badge 里那段:
「今天有 20 个电话要打」不该看起来像 20 个异常。
⚠️ 负 margin 把底色铺满整行:外层有 px-3 的槽,不铺的话色块两边缺一截。
*/
job ? 'bg-amber-50 -mx-3 px-3' : 'px-0.5'
}`}
>
{/* ① 类型 —— 竖排,一行可能有两个(比如「预约 + 计划」) */}
<div className="flex flex-col items-start gap-1">
{row.tags.map((t) => (
......@@ -368,17 +447,86 @@ function TimelineLine({ row }: { row: TimelineRow }) {
</div>
{/* ③ 正文 */}
<div className="min-w-0">
<div className="text-[12.5px] leading-[1.4] font-semibold break-words text-slate-800">
{/*
⭐ 「病历」行的标题可点 —— 跳到 EMR 页并展开那一次就诊。
⚠️ 只让**标题**可点,⛔ 不是整行:别的行都不可点,整行 hover 会让人
以为每一行都能点进去(而它们没有落点)。
⚠️ 用 `button` 不是 `a` —— 它不导航,只是切一个页内 tab。
*/}
{row.emrSubjectId ? (
<button
type="button"
onClick={() => onOpenEmr(row.emrSubjectId!)}
className="cursor-pointer text-left text-[12.5px] leading-[1.4] font-semibold break-words text-brand-700 underline decoration-brand-200 underline-offset-2 transition hover:decoration-brand-600"
>
{row.title}
</button>
) : (
<div
className={`text-[12.5px] leading-[1.4] font-semibold break-words ${
row.task?.settled ? 'text-slate-500' : 'text-slate-800'
}`}
>
{row.title}
</div>
)}
{row.desc ? (
<div className="mt-0.5 text-[11.5px] leading-[1.55] break-words text-slate-600">{row.desc}</div>
<div
className={`mt-0.5 text-[11.5px] leading-[1.55] break-words ${
row.task?.settled ? 'text-slate-400' : 'text-slate-600'
}`}
>
{row.desc}
</div>
) : null}
</div>
{/* ④ 状态 */}
<div className="text-[11.5px] font-semibold whitespace-nowrap text-slate-500">
{row.right ?? ''}
</div>
{/*
⭐⭐ 当前那条人工 Job 的**出口按钮** —— 用户稿子里那条 `└`。
⚠️ 只有**没结束**的才画(`job` 已经把 settled 过滤掉了):
已结束的任务还给按钮,点下去后端只会回「待办不存在或已被处理」。
⚠️ 这几颗是**结算动作**(会改 Task 状态、可能解除人工接管闸),
⛔ 别缩成 chip 的样子 —— 实测那样看起来像标签,人不会去点。
⚠️ 用共用的 `Button`(`xs` + `outline`),⛔ 别手搓一套 className:
预约确认那一页的出口按钮就是这一档,两处手搓必然漂成两种按钮。
═══ ⚠️⚠️ 为什么它是**第 5 个子元素 + col-span-4**,而不是塞在第③列里 ═══
塞在第③列(标题/描述那一列)时它只有 ~180px,实测「电话确认预约」那四个
出口要**折三行**,一条任务就把整条时间线压下去了。
⇒ 单独占一行、跨满四列,同样四颗只折两行。
⛔ 而**跨列不能只跨 3–4 或 2–4**:subgrid 里那样会把宽度算回父网格的轨道 ——
实测第①列从 61px 涨到 240px、第③列塌成 0(列是共用的,一行挤歪了全歪)。
只有**跨满**才不动轨道宽度。
⚠️ `pl-24` 是**视觉缩进**,⛔ 不是在跟标题列对齐:前两列是 `auto`(宽度随
chip / 日期变),根本对不死。别为了"对齐"去写一个从轨道宽算出来的魔数。
*/}
{job?.options.length ? (
<div className="col-span-4 mt-1 flex flex-wrap items-center gap-1.5 pl-24">
{job.options.map((o) => (
<Button
key={o.value}
size="xs"
variant="outline"
disabled={busy}
onClick={() => {
setBusy(true);
// ⚠️ 不 finally 解锁:成功时这一行会随刷新消失/变样,
// 解锁只在**失败**那一支有意义(让人能再试一次)。
void onChooseTask(job.jobId, o.value).then((ok) => {
if (!ok) setBusy(false);
});
}}
>
{o.label}
</Button>
))}
</div>
) : null}
</div>
);
}
......@@ -412,7 +560,7 @@ function TimelineLine({ row }: { row: TimelineRow }) {
* ⛔ 仍然不抄稿子里的示例内容(「36牙种植一期」「青霉素过敏」)——
* 那是别人的临床记录,写在真实患者的面板上会被当成他的。
*/
function Emr({ profile }: { profile: PatientProfile | null }) {
function Emr({ profile, focus }: { profile: PatientProfile | null; focus: string | null }) {
const patient = profile?.patient ?? null;
const known = (v: string | null | undefined) => v || '—';
const head = emrHeader(profile);
......@@ -476,20 +624,48 @@ function Emr({ profile }: { profile: PatientProfile | null }) {
PAC 里还没有这位患者的病历
</div>
) : (
encounters.map((e) => <Encounter key={e.key} enc={e} />)
encounters.map((e) => <Encounter key={e.key} enc={e} focused={e.subjectId === focus} />)
)}
</>
);
}
function Encounter({ enc }: { enc: ReturnType<typeof emrEncounters>[number] }) {
const [open, setOpen] = useState(false);
function Encounter({
enc,
focused,
}: {
enc: ReturnType<typeof emrEncounters>[number];
/** ⭐ 从概述的「病历」行跳过来的那一条 —— 自动展开并滚进可视区 */
focused: boolean;
}) {
/**
* ⚠️ 初值就用 `focused` —— ⛔ 别写成"先 false 再 useEffect 打开":
* 那会先渲染一帧收起状态,人看到的是列表跳一下才展开。
*/
const [open, setOpen] = useState(focused);
const box = useRef<HTMLDivElement>(null);
/**
* ⚠️⚠️ 只在**被指名**时滚,而且滚的是**这一条**,⛔ 不是滚到顶。
* `block: 'nearest'` —— 已经在可视区里就不动,省得每次切 tab 都抖一下。
* ⚠️ 依赖里放 `focused`:同一位患者点第二条病历时要重新滚。
*/
useEffect(() => {
if (!focused) return;
setOpen(true);
box.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}, [focused]);
const d = enc.at ? new Date(enc.at) : null;
// ⚠️ chips-only 的行(辅助检查)也算「有内容」—— 它的 text 是 null
const filled = enc.detail.filter((r) => !!r.text || !!r.chips?.length).length;
return (
<div className="overflow-hidden rounded-[10px] bg-slate-50">
<div
ref={box}
className={`overflow-hidden rounded-[10px] bg-slate-50 ${
// ⚠️ 一圈很淡的框,只为回答"我点的是哪一条" —— ⛔ 别用告警色,它不是告警
focused ? 'ring-1 ring-brand-200' : ''
}`}
>
<button
type="button"
aria-expanded={open}
......
......@@ -327,7 +327,7 @@ export interface ConvDetail {
* 本来就是"给同事看"的)—— ⛔ 别再用界面上那句猜的话代替它。
*/
agentActivity: {
state: 'queued' | 'working' | 'declined' | 'blocked' | 'none';
state: 'queued' | 'working' | 'sending' | 'declined' | 'blocked' | 'none';
since: string | null;
detail: string | null;
};
......@@ -535,6 +535,40 @@ export interface PatientProfile {
*/
returnVisits?: PacReturnVisit[];
returnVisitsTruncated?: boolean;
/**
* ⭐⭐ **我们自己做过的事** —— Task 并进同一条时间线。
*
* ⚠️⚠️ 它和上面全部字段**不是一回事**:那些是 PAC 的(宿主系统里发生的),
* 这一条是 friday-ai 自己的库。放在同一个对象里是因为界面要归并显示,
* ⛔ 别因此以为它也是 PAC 给的(改这块时别去 PAC 找接口)。
* ⚠️ ⛔ **不是全部 Task** —— 只有"对患者发生过的"那些。判据在后端
* `patient-visible.ts`,纯 agent 内部判断不给。
* ⚠️ 和 `facts` **不同一个时间窗**:事实截断在最近若干条,这里是全部。
*/
tasks?: PatientTimelineTask[];
}
/** 时间线上的一条 Task。⚠️ **一条 Task = 一行**,⛔ 不铺它底下的 job */
export interface PatientTimelineTask {
taskId: string;
taskKind: string;
/** `task_kind` 的中文。⚠️ 后端给,⛔ 别在前端再维护一张码表 */
kindLabel: string;
title: string;
triggerReason: string | null;
status: string;
/** 右列那格:结局优先(「已到诊」),没有结局才回状态 */
statusLabel: string;
/** ⭐ 已结束(completed / closed)⇒ **不给按钮**、样式压低 */
settled: boolean;
createdAt: string;
/** 当前挂着的人工 Job —— 描述和按钮都从它来。null = 现在不需要人动 */
humanJob: {
jobId: string;
title: string | null;
reason: string | null;
options: { value: string; label: string }[];
} | null;
}
/** 一条回访任务记录。⚠️ 字段本来就是**中文**,⛔ 别再翻一遍 */
......
import type { PacFact, PacPersonaFeature, PatientProfile } from '@/lib/api';
import type { PacFact, PacPersonaFeature, PatientProfile, PatientTimelineTask } from '@/lib/api';
import { factStatusNameZh } from '@pac/types';
/**
......@@ -304,8 +304,25 @@ export interface TimelineRow {
tags: { text: string; tone: Tone }[];
title: string;
desc: string | null;
/** 右侧那一列:金额 / 状态 */
/** 右侧那一列:状态(⛔ 不是金额,见 `rightCell`) */
right: string | null;
/**
* ⭐ 「病历」行可以跳到 EMR 页并展开对应那次就诊。null = 这行跳不了。
* ⚠️ 值是 `emr_record` 事实的 `subjectId`,和 `EmrEncounter.subjectId` 对齐。
*/
emrSubjectId?: string | null;
/**
* ⭐⭐ 这一行是**我们做的事**(一条 Task),⛔ 不是 PAC 的事实。
*
* ⚠️ 它决定两件事:行尾画不画出口按钮、样式压不压低。
* ⚠️ null / undefined = 这行是 PAC 的事实或回访。
*/
task?: {
taskId: string;
/** 已结束 ⇒ ⛔ 不给按钮(点下去只会报错),样式压低 */
settled: boolean;
humanJob: PatientTimelineTask['humanJob'];
} | null;
}
/**
......@@ -346,6 +363,8 @@ export function timelineRows(p: PatientProfile | null): TimelineRow[] {
title: f.title ?? meta.label,
desc: f.summary ?? str(c.chief_complaint) ?? str(c.complaint_text) ?? str(c.illness_desc),
right: rightCell(f, c),
// ⭐ 只有病历行跳得了 —— 别的类型在 EMR 页上没有对应的落点
emrSubjectId: f.type === 'emr_record' ? f.subjectId : null,
}),
};
});
......@@ -388,10 +407,53 @@ export function timelineRows(p: PatientProfile | null): TimelineRow[] {
});
/**
* ⚠️ 归并后**重新倒序** —— 两边各自有序,拼起来不是。
* ⭐⭐ **我们自己做过的事**并进来 —— 这条时间线上第三种、也是最后一种东西。
*
* ```
* facts 宿主系统里发生的事实(就诊 / 治疗 / 收费 / 影像 / 病历)
* visits 诊所自己打的回访电话(PAC 的独立表)
* ours friday-ai 做的事 ←—— 这一批
* ```
* ⚠️ 少了这一批,人看不出「这位患者昨天已经被我们提醒过一次了」,于是再提醒一遍。
*
* ⚠️ **一条 Task = 一行**,⛔ 不铺它底下的 job:一条预约确认有 4 个 job,
* 全铺会把 PAC 的事实淹掉,而那些 job 里有两个是纯内部动作(读上下文、归类)。
* ⚠️ 描述取**当前挂着的那条人工 Job** 的 `reason` —— 那句话才是"现在卡在哪";
* 没有人工 Job 时(AI 还拿着 / 已经结束)退回 `triggerReason`,⛔ 别留空。
* ⚠️ 排序键是 `createdAt`(Task 建出来的时刻),⛔ 不是 `dueAt` ——
* 时间线回答的是"当时做了什么",而 `dueAt` 是未来的安排。
*
* ⭐ 用 `ai` 色(violet)标 —— Badge 那边的原话是「AI 的声音要和品牌、
* 和患者都分得开」。这里借的是同一条:**我们做的**和**宿主的事实**要一眼分得开。
* ⚠️ ⛔ 别因为 `manual_*` 是人做的就换色:这一列区分的是"谁的系统",不是"谁动的手"。
*/
const ours: Array<{ at: string | null; row: TimelineRow }> = (p.tasks ?? []).map((t) => ({
at: t.createdAt,
row: rowOf(t.createdAt, {
key: `task:${t.taskId}`,
// ⚠️ Task 永远是**已经发生**的(它是被建出来的那一刻)⇒ ⛔ 不是 planned
planned: false,
tags: [{ text: t.kindLabel, tone: 'ai' }],
title: t.title,
/**
* ⚠️⚠️ 描述取**人工 Job 的标题**,⛔ 不取 `escalation_reason`。
* 上一版取的是 reason —— 而它是**统计用的有限取值**([14 §3.2]:
* 「统计哪条规则触发转人工最多」),实测在时间线上刷出一行
* **`reschedule_request`** 这样的英文码。同 `handed_back_to_ai` 那一类。
* ⭐ `title` 才是给人看的那句(「患者要求改期:我想把下周三那次挪一下…」)。
* ⚠️ 都没有才退到 `triggerReason`。
*/
desc: t.humanJob?.title ?? t.triggerReason,
right: t.statusLabel,
task: { taskId: t.taskId, settled: t.settled, humanJob: t.humanJob },
}),
}));
/**
* ⚠️ 归并后**重新倒序** —— 三边各自有序,拼起来不是。
* ⚠️ 没有时间的排最后(⛔ 不是最前:`null` 参与比较会把它顶到头上)。
*/
return [...facts, ...visits]
return [...facts, ...visits, ...ours]
.sort((a, b) => {
if (!a.at && !b.at) return 0;
if (!a.at) return 1;
......@@ -470,6 +532,13 @@ export interface EmrDetailRow {
export interface EmrEncounter {
key: string;
/**
* ⭐ 这条就诊对应的那条 `emr_record` 事实。
* ⚠️ 概述时间线上的「病历」行靠它跳过来 —— 两边必须是**同一个值**。
* ⛔ 别拿 `key` 当锚点:它带着列表下标(`:${i}`)只为 React 去重,
* 而时间线那侧的下标是在**另一个数组**里算的,对不上。
*/
subjectId: string;
/** ISO */
at: string | null;
doctor: string | null;
......@@ -605,7 +674,7 @@ export function emrEncounters(p: PatientProfile | null): EmrEncounter[] {
{ label: '医嘱', text: str(c.doctor_advice) },
];
return { key: `${f.subjectId}:${i}`, at: factAt(f), doctor: str(c.doctor_name), detail };
return { key: `${f.subjectId}:${i}`, subjectId: f.subjectId, at: factAt(f), doctor: str(c.doctor_name), detail };
});
}
......
......@@ -6,7 +6,13 @@ export default tseslint.config(
{
ignores: [
'**/dist/**',
'**/.next/**',
/**
* ⚠️ `.next*` 不是 `.next` —— `NEXT_DIST_DIR` 会让 `next build` 落到
* 别的目录(本地用 `.next-check` 把它和 dev server 的 `.next` 分开,
* 见 apps/ai-web/next.config.mjs)。只忽略 `.next` 的话,
* ⚠️ 实测 eslint 会去 lint 那 11,000 行构建产物,把真问题淹掉。
*/
'**/.next*/**',
'**/node_modules/**',
'**/coverage/**',
'**/.turbo/**',
......
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