Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
P
pac
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ai-tools
pac
Commits
427202a6
Commit
427202a6
authored
Oct 23, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix(assistant): support exact patient assignment and live sheet receipts
parent
f9716ee1
Pipeline
#3867
failed in 0 seconds
Changes
18
Pipelines
1
Show whitespace changes
Inline
Side-by-side
Showing
18 changed files
with
415 additions
and
75 deletions
+415
-75
apps/pac-service/src/modules/assistant/assistant-prompts.ts
+1
-1
apps/pac-service/src/modules/assistant/assistant.controller.ts
+14
-1
apps/pac-service/src/modules/assistant/assistant.module.ts
+2
-1
apps/pac-service/src/modules/assistant/assistant.service.ts
+62
-30
apps/pac-service/src/modules/assistant/sheet-bridge.service.ts
+46
-0
apps/pac-service/src/modules/plan/assignment-facts.ts
+10
-2
apps/pac-service/src/modules/plan/assignment-proposal.service.ts
+23
-6
apps/pac-service/src/modules/plan/cohort-filter.ts
+3
-0
apps/pac-service/tests/assignment-contract.spec.ts
+2
-1
apps/pac-service/tests/assignment-specific-patient.spec.ts
+42
-0
apps/pac-service/tests/sheet-bridge.spec.ts
+48
-0
apps/pac-web/src/components/assistant/assignment-confirm-sheet.tsx
+38
-27
apps/pac-web/src/components/assistant/assistant-chat.tsx
+6
-3
apps/pac-web/src/components/assistant/chat-blocks.ts
+1
-1
apps/pac-web/src/components/assistant/sheet-command.test.ts
+43
-0
apps/pac-web/src/components/assistant/sheet-command.ts
+36
-0
apps/pac-web/src/components/assistant/use-assistant-chat.ts
+30
-0
packages/types/src/schemas/plan-assignment.ts
+8
-2
No files found.
apps/pac-service/src/modules/assistant/assistant-prompts.ts
View file @
427202a6
...
@@ -123,7 +123,7 @@ import { Permission } from '@pac/types';
...
@@ -123,7 +123,7 @@ import { Permission } from '@pac/types';
*/
*/
// ⚠️ `-b` 是 2026-08-17 那次「纪律挂在组上」的实验,已 revert(`git show 82f357e`)——
// ⚠️ `-b` 是 2026-08-17 那次「纪律挂在组上」的实验,已 revert(`git show 82f357e`)——
// ⛔ 别复用它:库里有那一版的 `agent_invocations` 行,复用等于两份不同正文同名。
// ⛔ 别复用它:库里有那一版的 `agent_invocations` 行,复用等于两份不同正文同名。
export
const
ASSISTANT_PROMPT_VERSION
=
'assistant@2026-
08-22-a
'
;
export
const
ASSISTANT_PROMPT_VERSION
=
'assistant@2026-
10-23-sheet-receipts
'
;
/**
/**
* ① 装置 —— 你是谁、和使用者什么关系、你看不见什么。
* ① 装置 —— 你是谁、和使用者什么关系、你看不见什么。
...
...
apps/pac-service/src/modules/assistant/assistant.controller.ts
View file @
427202a6
import
{
SheetBridgeService
}
from
'./sheet-bridge.service'
;
import
{
Body
,
Controller
,
Logger
,
Post
,
Req
,
Res
,
UploadedFile
,
UseInterceptors
}
from
'@nestjs/common'
;
import
{
Body
,
Controller
,
Logger
,
Post
,
Req
,
Res
,
UploadedFile
,
UseInterceptors
}
from
'@nestjs/common'
;
import
{
FileInterceptor
}
from
'@nestjs/platform-express'
;
import
{
FileInterceptor
}
from
'@nestjs/platform-express'
;
import
type
{
Request
,
Response
}
from
'express'
;
import
type
{
Request
,
Response
}
from
'express'
;
...
@@ -53,6 +54,7 @@ interface ChatBody {
...
@@ -53,6 +54,7 @@ interface ChatBody {
* ⚠️ 用 zod 现场解析而不是直接透传:字段少一个模型就会读到 `undefined` 并当成 0。
* ⚠️ 用 zod 现场解析而不是直接透传:字段少一个模型就会读到 `undefined` 并当成 0。
*/
*/
sheetState
?:
unknown
;
sheetState
?:
unknown
;
sheetBridge
?:
boolean
;
/**
/**
* 详情页当前开着的那位患者 —— 与 `activeClinicId` 同一条旁路。
* 详情页当前开着的那位患者 —— 与 `activeClinicId` 同一条旁路。
*
*
...
@@ -117,6 +119,7 @@ export class AssistantController {
...
@@ -117,6 +119,7 @@ export class AssistantController {
constructor(
constructor(
private readonly assistant: AssistantService,
private readonly assistant: AssistantService,
private readonly sheetBridge: SheetBridgeService,
private readonly transcriber: TranscribeService,
private readonly transcriber: TranscribeService,
) {}
) {}
...
@@ -167,6 +170,13 @@ export class AssistantController {
...
@@ -167,6 +170,13 @@ export class AssistantController {
}
}
}
}
@Post('sheet-receipt')
@ApiOperation({ summary: '回传当前用户本轮确认单执行结果' })
sheetReceipt(@TenantScope() scope: TenantScopeContext, @Body() body: { commandId?: string; snapshot?: unknown; message?: string }) {
this.sheetBridge.receive(scope, body.commandId ?? '', body);
return { received: true };
}
@Post('chat')
@Post('chat')
@ApiOperation({ summary: '助手对话(SSE)— 模型自主调 PAC MCP 工具' })
@ApiOperation({ summary: '助手对话(SSE)— 模型自主调 PAC MCP 工具' })
async chat(
async chat(
...
@@ -188,7 +198,7 @@ export class AssistantController {
...
@@ -188,7 +198,7 @@ export class AssistantController {
};
};
const ac = new AbortController();
const ac = new AbortController();
re
q
.on('close', () => ac.abort());
re
s
.on('close', () => ac.abort());
// 流式 artifact:render_artifact 的入参(HTML)逐字生成,边生成边推 → 前端实时"
长出
"卡片,
// 流式 artifact:render_artifact 的入参(HTML)逐字生成,边生成边推 → 前端实时"
长出
"卡片,
// 不必等整段(~30s)生成完。按 toolCallId 累积 JSON 入参,增量提取 html 字段,250ms 节流发送。
// 不必等整段(~30s)生成完。按 toolCallId 累积 JSON 入参,增量提取 html 字段,250ms 节流发送。
...
@@ -206,6 +216,9 @@ export class AssistantController {
...
@@ -206,6 +216,9 @@ export class AssistantController {
permissions: user.permissions,
permissions: user.permissions,
// ⭐ 侧信道:本地工具把确认单肥载荷直接推前端,不经模型上下文(见 AssistantChatInput)
// ⭐ 侧信道:本地工具把确认单肥载荷直接推前端,不经模型上下文(见 AssistantChatInput)
onSideEvent: send,
onSideEvent: send,
...(body.sheetBridge === true ? {
sheetCommand: (command: import('./sheet-bridge.service').SheetCommand) => this.sheetBridge.request(scope, command, send, ac.signal),
} : {}),
scope,
scope,
// ⭐ 角色层与现场层由服务端按权限装配(见 assistant-prompts 的分层)——
// ⭐ 角色层与现场层由服务端按权限装配(见 assistant-prompts 的分层)——
// ⚠️ 控制器只**供料**,⛔ 不在这里拼提示词:企微那条路也走同一个装配。
// ⚠️ 控制器只**供料**,⛔ 不在这里拼提示词:企微那条路也走同一个装配。
...
...
apps/pac-service/src/modules/assistant/assistant.module.ts
View file @
427202a6
import
{
SheetBridgeService
}
from
'./sheet-bridge.service'
;
import
{
Module
}
from
'@nestjs/common'
;
import
{
Module
}
from
'@nestjs/common'
;
import
{
AiModule
}
from
'../ai/ai.module'
;
import
{
AiModule
}
from
'../ai/ai.module'
;
import
{
AuthModule
}
from
'../auth/auth.module'
;
import
{
AuthModule
}
from
'../auth/auth.module'
;
...
@@ -20,7 +21,7 @@ import { DictationGateway } from './dictation.gateway';
...
@@ -20,7 +21,7 @@ import { DictationGateway } from './dictation.gateway';
imports
:
[
AiModule
,
AuthModule
,
PlanModule
],
imports
:
[
AiModule
,
AuthModule
,
PlanModule
],
// 🔬 AssistantLabController = 裸 agent 试验台,⛔ 与产品助手无代码关联(见该文件头)
// 🔬 AssistantLabController = 裸 agent 试验台,⛔ 与产品助手无代码关联(见该文件头)
controllers
:
[
AssistantController
,
AssistantLabController
],
controllers
:
[
AssistantController
,
AssistantLabController
],
providers
:
[
AssistantService
,
McpClientService
,
TranscribeService
,
DictationGateway
],
providers
:
[
SheetBridgeService
,
AssistantService
,
McpClientService
,
TranscribeService
,
DictationGateway
],
exports
:
[
AssistantService
],
// 供 WeixinAibotModule 等其它入口复用同一个助手大脑
exports
:
[
AssistantService
],
// 供 WeixinAibotModule 等其它入口复用同一个助手大脑
})
})
export
class
AssistantModule
{}
export
class
AssistantModule
{}
apps/pac-service/src/modules/assistant/assistant.service.ts
View file @
427202a6
import
type
{
SheetCommand
,
SheetReceipt
}
from
'./sheet-bridge.service'
;
import
{
Injectable
,
Logger
}
from
'@nestjs/common'
;
import
{
Injectable
,
Logger
}
from
'@nestjs/common'
;
import
{
ConfigService
}
from
'@nestjs/config'
;
import
{
ConfigService
}
from
'@nestjs/config'
;
import
{
streamText
,
tool
,
jsonSchema
,
stepCountIs
,
type
ModelMessage
,
type
ToolSet
}
from
'ai'
;
import
{
streamText
,
tool
,
jsonSchema
,
stepCountIs
,
type
ModelMessage
,
type
ToolSet
}
from
'ai'
;
...
@@ -10,6 +11,7 @@ import {
...
@@ -10,6 +11,7 @@ import {
TEMPERATURE_TOOL_DESC
,
TEMPERATURE_TOOL_DESC
,
TEMPERATURE_TOOL_VALUES
,
TEMPERATURE_TOOL_VALUES
,
type
SheetSnapshot
,
type
SheetSnapshot
,
type
SheetEditOp
,
type
TemperatureValue
,
type
TemperatureValue
,
}
from
'@pac/types'
;
}
from
'@pac/types'
;
import
{
AiProviderService
}
from
'../ai/core/ai-provider.service'
;
import
{
AiProviderService
}
from
'../ai/core/ai-provider.service'
;
...
@@ -138,6 +140,7 @@ export interface AssistantChatInput {
...
@@ -138,6 +140,7 @@ export interface AssistantChatInput {
* 见 `SheetSnapshotSchema`)。⚠️ 每次请求现取现发,⛔ 这里不许缓存。
* 见 `SheetSnapshotSchema`)。⚠️ 每次请求现取现发,⛔ 这里不许缓存。
*/
*/
sheetState
?:
SheetSnapshot
;
sheetState
?:
SheetSnapshot
;
sheetCommand
?:
(
command
:
SheetCommand
)
=>
Promise
<
SheetReceipt
>
;
/**
/**
* 详情页当前开着的那位患者 —— **只作指代的默认落点**,⛔ 不注入工具参数
* 详情页当前开着的那位患者 —— **只作指代的默认落点**,⛔ 不注入工具参数
* (注入等于堵死"查另一位",而他随时会问别人)。
* (注入等于堵死"查另一位",而他随时会问别人)。
...
@@ -299,6 +302,13 @@ export class AssistantService {
...
@@ -299,6 +302,13 @@ export class AssistantService {
let
guidanceKeys
:
string
[]
=
[];
let
guidanceKeys
:
string
[]
=
[];
/// 本轮出过确认单没有 —— show_sheet 只有在出过之后才有意义
/// 本轮出过确认单没有 —— show_sheet 只有在出过之后才有意义
let
sheetPushed
=
false
;
let
sheetPushed
=
false
;
let
currentRequestId
=
input
.
sheetState
?.
requestId
;
const
readSheet
=
async
()
=>
{
if
(
!
input
.
sheetCommand
)
return
{
snapshot
:
null
,
message
:
'当前客户端不支持实时回执,请刷新页面后重试'
};
const
receipt
=
await
input
.
sheetCommand
({
kind
:
'read'
,
requestId
:
currentRequestId
});
if
(
receipt
.
snapshot
)
currentRequestId
=
receipt
.
snapshot
.
requestId
;
return
receipt
;
};
tools
.
propose_assignment
=
tool
({
tools
.
propose_assignment
=
tool
({
/**
/**
* 🔴 2026-08-14 按裸台验过的写法重写。删掉的五句各有去处 —— ⛔ 别抄回来:
* 🔴 2026-08-14 按裸台验过的写法重写。删掉的五句各有去处 —— ⛔ 别抄回来:
...
@@ -386,6 +396,13 @@ export class AssistantService {
...
@@ -386,6 +396,13 @@ export class AssistantService {
* ⛔ 要恢复"按诊所选",正解是给一个**带名字的 enum**(取值域 = 他有权限的那几家),
* ⛔ 要恢复"按诊所选",正解是给一个**带名字的 enum**(取值域 = 他有权限的那几家),
* ⛔ 不是把这个裸 id 参数放回来。
* ⛔ 不是把这个裸 id 参数放回来。
*/
*/
patientIds
:
{
type
:
'array'
,
items
:
{
type
:
'string'
,
format
:
'uuid'
},
minItems
:
1
,
maxItems
:
100
,
description
:
'指定患者的精确 ID,从患者查询结果取得。点名分配时使用,只选择这些患者,不按人数或人群条件替代。未传时按原批量规则选人。'
,
},
assignee
:
{
type
:
'string'
,
description
:
'明确指定的接收人姓名;“我”表示当前登录人。仅与 patientIds 一起使用,不调整其他客服额度。'
,
},
potentialTreatment
:
{
potentialTreatment
:
{
type
:
'string'
,
type
:
'string'
,
description
:
POTENTIAL_TREATMENT_DESC
,
description
:
POTENTIAL_TREATMENT_DESC
,
...
@@ -519,6 +536,8 @@ export class AssistantService {
...
@@ -519,6 +536,8 @@ export class AssistantService {
}),
}),
execute
:
async
(
args
:
unknown
)
=>
{
execute
:
async
(
args
:
unknown
)
=>
{
const
a
=
(
args
??
{})
as
{
const
a
=
(
args
??
{})
as
{
patientIds
?:
string
[];
assignee
?:
string
;
potentialTreatment
?:
string
;
potentialTreatment
?:
string
;
temperature
?:
TemperatureValue
;
temperature
?:
TemperatureValue
;
personaTags
?:
string
;
personaTags
?:
string
;
...
@@ -535,17 +554,32 @@ export class AssistantService {
...
@@ -535,17 +554,32 @@ export class AssistantService {
if
(
!
clinicId
)
{
if
(
!
clinicId
)
{
return
'当前登录人没有绑定诊所,无法出确认单 —— 请主管确认数据范围。'
;
return
'当前登录人没有绑定诊所,无法出确认单 —— 请主管确认数据范围。'
;
}
}
const
sheet
=
await
this
.
proposals
.
propose
(
scope
,
{
...
a
,
clinicId
});
if
(
a
.
assignee
&&
!
a
.
patientIds
?.
length
)
return
'指定接收人时请同时提供指定患者'
;
let
assigneeUserId
:
string
|
undefined
;
if
(
a
.
assignee
)
{
if
(
a
.
assignee
.
trim
()
===
'我'
)
assigneeUserId
=
scope
.
userId
;
else
{
const
roster
=
await
this
.
roster
.
list
(
scope
,
clinicId
,
{
extraUserIds
:
[
scope
.
userId
]
});
const
matches
=
roster
.
agents
.
filter
(
x
=>
x
.
name
===
a
.
assignee
!
.
trim
());
if
(
matches
.
length
!==
1
)
return
'接收人未找到或存在同名,本次未生成确认单'
;
assigneeUserId
=
matches
[
0
]
!
.
userId
;
}
}
const
sheet
=
await
this
.
proposals
.
propose
(
scope
,
a
.
patientIds
?
{
clinicId
,
patientIds
:
a
.
patientIds
,
assigneeUserId
,
expiresInDays
:
a
.
expiresInDays
}
:
{
...
a
,
clinicId
});
// ⭐ 肥载荷走侧信道;requestId 由**服务端**在这里铸造(D-2)——
// ⭐ 肥载荷走侧信道;requestId 由**服务端**在这里铸造(D-2)——
// 前端确认时原样回传,这样重连/重试都命中同一个幂等键。
// 前端确认时原样回传,这样重连/重试都命中同一个幂等键。
const
requestId
=
`pa_
${
scope
.
userId
}
_
${
randomUUID
()}
`
;
const
requestId
=
`pa_
${
scope
.
userId
}
_
${
randomUUID
()}
`
;
push
({
type
:
'assignment_sheet'
,
requestId
,
sheet
});
push
({
type
:
'assignment_sheet'
,
requestId
,
sheet
});
currentRequestId
=
requestId
;
// 记下这一版有哪几类,供 show_guidance 校验 + 解析成 signalKey(见上面的闭包注释)
// 记下这一版有哪几类,供 show_guidance 校验 + 解析成 signalKey(见上面的闭包注释)
// ⚠️ 过滤条件必须与 `modelFacts` 的 `pick` **一致**:只有 action 层进模型上下文。
// ⚠️ 过滤条件必须与 `modelFacts` 的 `pick` **一致**:只有 action 层进模型上下文。
const
actions
=
sheet
.
signals
.
filter
((
s
)
=>
s
.
tier
===
'action'
);
const
actions
=
sheet
.
signals
.
filter
((
s
)
=>
s
.
tier
===
'action'
);
guidanceKinds
=
actions
.
map
((
s
)
=>
GUIDANCE_KIND
[
s
.
key
]
??
s
.
key
);
guidanceKinds
=
actions
.
map
((
s
)
=>
GUIDANCE_KIND
[
s
.
key
]
??
s
.
key
);
guidanceKeys
=
actions
.
map
((
s
)
=>
s
.
key
);
guidanceKeys
=
actions
.
map
((
s
)
=>
s
.
key
);
sheetPushed
=
true
;
sheetPushed
=
true
;
const
receipt
=
await
readSheet
();
/**
/**
* ⭐ 回给模型的是**结构化事实**,⛔ 不再是成品句子(P2,2026-08-12)。
* ⭐ 回给模型的是**结构化事实**,⛔ 不再是成品句子(P2,2026-08-12)。
*
*
...
@@ -562,7 +596,9 @@ export class AssistantService {
...
@@ -562,7 +596,9 @@ export class AssistantService {
*/
*/
return
JSON
.
stringify
(
return
JSON
.
stringify
(
{
{
确认单已呈现
:
true
,
确认单已呈现
:
receipt
.
snapshot
!=
null
,
回执
:
receipt
.
message
,
...(
receipt
.
snapshot
?
{
当前确认单
:
sheetSnapshotFacts
(
receipt
.
snapshot
)
}
:
{}),
/**
/**
* ⚠️ 事实,不是叮嘱:主管点确认之前一条都没真的分下去。
* ⚠️ 事实,不是叮嘱:主管点确认之前一条都没真的分下去。
*
*
...
@@ -573,7 +609,7 @@ export class AssistantService {
...
@@ -573,7 +609,7 @@ export class AssistantService {
* ⚠️ 措辞与提示词里那句「这一下才真的分下去」对齐 —— ⛔ 同一件事别起两个说法。
* ⚠️ 措辞与提示词里那句「这一下才真的分下去」对齐 —— ⛔ 同一件事别起两个说法。
*/
*/
已经分下去了
:
false
,
已经分下去了
:
false
,
...
modelFacts
(
sheet
),
...
(
a
.
patientIds
?
{
选择方式
:
'指定患者'
,
已排好
:
sheet
.
items
.
length
}
:
modelFacts
(
sheet
)
),
},
},
null
,
null
,
2
,
2
,
...
@@ -779,27 +815,27 @@ export class AssistantService {
...
@@ -779,27 +815,27 @@ export class AssistantService {
* 它连累加的材料都没有 —— 卡片上写着 3 天,它嘴上还说 1 天。
* 它连累加的材料都没有 —— 卡片上写着 3 天,它嘴上还说 1 天。
* ⇒ 现在两条一起补:回声带上当前值(推),这个工具随时能取(拉)。
* ⇒ 现在两条一起补:回声带上当前值(推),这个工具随时能取(拉)。
*
*
* ⚠️ 它查的是**随本次请求捎来的那份快照**,⛔ 不是服务端的什么状态 ——
* 每次通过本轮回执通道读取浏览器最新状态;回执不写业务表,不替代确认分配。
* 服务端没有这个东西,也⛔ 不许为它去开一条"草稿回传"的写路径。
* 只有现役那张单可查;明细可按患者或分页读取。⛔ 别加 requestId 之类的参数,
* ⚠️ 无参数:只有一张单可查(现役那张)。⛔ 别加 requestId 之类的参数,
* 模型没有办法知道该填哪个(同 `propose_assignment.clinicId` 被删掉那次的判断)。
* 模型没有办法知道该填哪个(同 `propose_assignment.clinicId` 被删掉那次的判断)。
*/
*/
tools
.
get_current_sheet
=
tool
({
tools
.
get_current_sheet
=
tool
({
description
:
description
:
'看他眼前那张确认单**现在**什么样:已排好几条、落在几位客服头上、'
+
'看他眼前那张确认单**现在**什么样:已排好几条、落在几位客服头上、'
+
'
还有几个待他定、时限几天、带没带
福利。'
+
'
患者姓名、病历号和当前客服归属,以及待分配、移出、时限和
福利。'
+
'
\
n什么时候调:他在卡片上动过手(拖人、移出、改时限、改福利)之后,'
+
'
\
n什么时候调:他在卡片上动过手(拖人、移出、改时限、改福利)之后,'
+
'你要报数、要判断"还差什么"、或者他问"现在什么情况",先调它。'
+
'你要报数、要判断"还差什么"、或者他问"现在什么情况",先调它。'
+
'
\
n⚠️ 卡片上的改动**你看不见** —— ⛔ 别拿出方案那一版的数去算现在的数。'
+
'
\
n⚠️ 卡片上的改动**你看不见** —— ⛔ 别拿出方案那一版的数去算现在的数。'
+
'
\
n⛔ 它不改任何东西,只是看一眼。'
,
'
\
n⛔ 它不改任何东西,只是看一眼。'
,
inputSchema
:
jsonSchema
({
type
:
'object'
,
properties
:
{}
}),
inputSchema
:
jsonSchema
({
type
:
'object'
,
properties
:
{
execute
:
async
()
=>
{
patient
:
{
type
:
'string'
,
description
:
'只查指定患者的姓名或病历号,精确匹配;不传读取名单。'
},
offset
:
{
type
:
'integer'
,
minimum
:
0
,
description
:
'名单起点,默认 0;继续读取用返回的下一页起点。'
},
limit
:
{
type
:
'integer'
,
minimum
:
1
,
maximum
:
100
,
description
:
'本次明细条数,默认 50,最多 100。概览仍为整单。'
},
}
}),
execute
:
async
(
args
:
unknown
)
=>
{
// ⚠️ 没有 = **他手上根本没有确认单**,⛔ 别回一个空壳让模型以为是"全 0 的单"
// ⚠️ 没有 = **他手上根本没有确认单**,⛔ 别回一个空壳让模型以为是"全 0 的单"
if
(
!
input
.
sheetState
)
{
const
receipt
=
await
readSheet
();
return
'他手上现在没有确认单 —— 还没出过,或者那一版已经作废了。'
;
return
JSON
.
stringify
({
回执
:
receipt
.
message
,
当前确认单
:
receipt
.
snapshot
?
sheetSnapshotFacts
(
receipt
.
snapshot
,
(
args
??
{})
as
{
patient
?:
string
;
offset
?:
number
;
limit
?:
number
})
:
null
});
}
this
.
logger
.
log
(
'工具调用:get_current_sheet'
);
return
JSON
.
stringify
(
sheetSnapshotFacts
(
input
.
sheetState
),
null
,
2
);
},
},
});
});
...
@@ -815,9 +851,7 @@ export class AssistantService {
...
@@ -815,9 +851,7 @@ export class AssistantService {
'
\
n患者用姓名或病历号指代,客服用姓名 —— 匹配由界面拿它手里那份单子完成。'
+
'
\
n患者用姓名或病历号指代,客服用姓名 —— 匹配由界面拿它手里那份单子完成。'
+
// ⚠️ 这里说的是**先后**不是**位置**:位置("上面")是他屏幕上的事,你看不见,
// ⚠️ 这里说的是**先后**不是**位置**:位置("上面")是他屏幕上的事,你看不见,
// 写成位置等于教模型去描述界面 —— 而 base 里明令 ⛔ 别描述界面位置。
// 写成位置等于教模型去描述界面 —— 而 base 里明令 ⛔ 别描述界面位置。
'
\
n⚠️ 改完**什么都不用补**:界面会把结果直接发进对话,而且**先于你这段话**到他那儿。'
+
'
\
n工具等待界面执行后返回真实回执和当前患者归属;未匹配、部分执行或未收到回执都不能当成成功。'
+
'⛔ 别说"已完成"(成没成只有界面知道),也⛔ 别说"等结果再告诉您" ——'
+
'他读到那句时结果早就在了,顺序是倒的。'
+
'
\
n⚠️ 批次**已经确认分配**之后:只有 `set_benefit` 还能改(福利只影响此后生成的话术,'
+
'
\
n⚠️ 批次**已经确认分配**之后:只有 `set_benefit` 还能改(福利只影响此后生成的话术,'
+
'界面会真的改并回报"有几条已经写好的话术作废了 / 几条客服已经打开过");'
+
'界面会真的改并回报"有几条已经写好的话术作废了 / 几条客服已经打开过");'
+
'人员和时限**在这张单上改不了** —— 单子已经在客服手上。'
+
'人员和时限**在这张单上改不了** —— 单子已经在客服手上。'
+
...
@@ -931,17 +965,10 @@ export class AssistantService {
...
@@ -931,17 +965,10 @@ export class AssistantService {
if
(
!
Array
.
isArray
(
ops
)
||
ops
.
length
===
0
)
return
'没有给出任何操作,确认单未改动。'
;
if
(
!
Array
.
isArray
(
ops
)
||
ops
.
length
===
0
)
return
'没有给出任何操作,确认单未改动。'
;
const
bad
=
checkEditOps
(
ops
,
SheetEditOpSchema
);
const
bad
=
checkEditOps
(
ops
,
SheetEditOpSchema
);
if
(
bad
)
return
bad
;
if
(
bad
)
return
bad
;
push
({
type
:
'assignment_sheet_edit'
,
ops
});
if
(
!
input
.
sheetCommand
)
return
'当前客户端不支持真实回执,确认单未修改;请刷新页面后重试'
;
/**
const
receipt
=
await
input
.
sheetCommand
({
kind
:
'edit'
,
requestId
:
currentRequestId
,
ops
:
ops
as
SheetEditOp
[]
});
* ⚠️ 只回**事实**,⛔ 一个字的指令都不许有。
if
(
receipt
.
snapshot
)
currentRequestId
=
receipt
.
snapshot
.
requestId
;
*
return
JSON
.
stringify
({
回执
:
receipt
.
message
,
当前确认单
:
receipt
.
snapshot
?
sheetSnapshotFacts
(
receipt
.
snapshot
)
:
null
});
* 🔴 2026-08-13:原来这里写着「请等那句结果,⛔ 不要自己宣布已完成」——
* 模型把它当话说了出去:「已把这条修改交给界面执行,等它回报结果再告诉您。」
* 而界面的结果**就在那句话上面一行**(它是同步注入的),读起来是倒的:
* 结果已经出来了,助手却还在说"等结果"。
* ⇒ 指令归工具描述,返回值只留事实(这条规矩 2026-08-08 就定了,这里漏网)。
*/
return
JSON
.
stringify
({
已交给界面执行
:
ops
.
length
,
界面已把结果发进对话
:
true
});
},
},
});
});
...
@@ -1194,7 +1221,7 @@ export class AssistantService {
...
@@ -1194,7 +1221,7 @@ export class AssistantService {
// 2. resolve provider(可切换)+ streamText 跑 model-driven tool-calling 循环
// 2. resolve provider(可切换)+ streamText 跑 model-driven tool-calling 循环
const
resolved
=
this
.
provider
.
resolve
(
input
.
modelId
??
'deepseek'
);
const
resolved
=
this
.
provider
.
resolve
(
input
.
modelId
??
'deepseek'
);
/**
/**
* ⚠️ 日志打**解析后的型号**(`deepseek-
v4-
flash`),⛔ 不是逻辑键(`deepseek`)。
* ⚠️ 日志打**解析后的型号**(`deepseek-flash`),⛔ 不是逻辑键(`deepseek`)。
* 裸键会被 `AI_DEFAULT_MODEL` 解析成具体型号,只打裸键的话
* 裸键会被 `AI_DEFAULT_MODEL` 解析成具体型号,只打裸键的话
* 「这一轮到底跑的哪个模型」从日志里根本答不出来 —— 实测被这个问住过。
* 「这一轮到底跑的哪个模型」从日志里根本答不出来 —— 实测被这个问住过。
*/
*/
...
@@ -1211,6 +1238,9 @@ export class AssistantService {
...
@@ -1211,6 +1238,9 @@ export class AssistantService {
* ⚠️ `scope` 缺失(比如某些内部入口)就整条跳过 —— hostId/tenantId 是必填列。
* ⚠️ `scope` 缺失(比如某些内部入口)就整条跳过 —— hostId/tenantId 是必填列。
*/
*/
const
toolTrace
:
ToolTraceEntry
[]
=
[];
const
toolTrace
:
ToolTraceEntry
[]
=
[];
// 同一轮的出单、改单、读单串行,防止模型并行调用读到前一版。
let
sheetQueue
:
Promise
<
unknown
>
=
Promise
.
resolve
();
const
sheetTools
=
new
Set
([
'propose_assignment'
,
'edit_assignment_sheet'
,
'get_current_sheet'
]);
// 统一给**所有**工具(MCP 拉来的 + 本地那几个)套一层计时留痕。
// 统一给**所有**工具(MCP 拉来的 + 本地那几个)套一层计时留痕。
// ⚠️ 装在这里而不是各自 execute 里:漏一个就等于那个工具永远不出现在验收数据里。
// ⚠️ 装在这里而不是各自 execute 里:漏一个就等于那个工具永远不出现在验收数据里。
for
(
const
name
of
Object
.
keys
(
tools
))
{
for
(
const
name
of
Object
.
keys
(
tools
))
{
...
@@ -1223,7 +1253,9 @@ export class AssistantService {
...
@@ -1223,7 +1253,9 @@ export class AssistantService {
t
.
execute
=
async
(
args
:
any
,
opts
:
any
)
=>
{
t
.
execute
=
async
(
args
:
any
,
opts
:
any
)
=>
{
const
t0
=
Date
.
now
();
const
t0
=
Date
.
now
();
try
{
try
{
const
r
=
await
orig
(
args
,
opts
);
const
operation
=
sheetTools
.
has
(
name
)
?
sheetQueue
.
then
(()
=>
orig
(
args
,
opts
))
:
orig
(
args
,
opts
);
if
(
sheetTools
.
has
(
name
))
sheetQueue
=
Promise
.
resolve
(
operation
).
catch
(()
=>
undefined
);
const
r
=
await
operation
;
toolTrace
.
push
({
name
,
ok
:
true
,
ms
:
Date
.
now
()
-
t0
,
args
:
briefToolArgs
(
args
)
});
toolTrace
.
push
({
name
,
ok
:
true
,
ms
:
Date
.
now
()
-
t0
,
args
:
briefToolArgs
(
args
)
});
return
r
;
return
r
;
}
catch
(
e
)
{
}
catch
(
e
)
{
...
...
apps/pac-service/src/modules/assistant/sheet-bridge.service.ts
0 → 100644
View file @
427202a6
import
{
Injectable
,
NotFoundException
}
from
'@nestjs/common'
;
import
{
randomUUID
}
from
'node:crypto'
;
import
{
SheetSnapshotSchema
,
type
SheetSnapshot
,
type
SheetEditOp
}
from
'@pac/types'
;
import
type
{
TenantScopeContext
}
from
'../../common/decorators/tenant-scope.decorator'
;
export
interface
SheetCommand
{
kind
:
'read'
|
'edit'
;
requestId
?:
string
;
ops
?:
SheetEditOp
[]
}
export
interface
SheetReceipt
{
snapshot
:
SheetSnapshot
|
null
;
message
:
string
}
/** 回执仅用于本轮工具核验,不是分配授权,也不写业务数据。 */
@
Injectable
()
export
class
SheetBridgeService
{
private
readonly
pending
=
new
Map
<
string
,
{
owner
:
string
;
requestId
?:
string
;
finish
:
(
r
:
SheetReceipt
)
=>
void
}
>
();
private
owner
(
s
:
TenantScopeContext
):
string
{
return
JSON
.
stringify
([
s
.
hostId
,
s
.
tenantId
,
s
.
userId
,
[...
s
.
clinicIds
].
sort
(),
[...
s
.
sourceUnits
].
sort
()]);
}
request
(
scope
:
TenantScopeContext
,
command
:
SheetCommand
,
send
:
(
e
:
Record
<
string
,
unknown
>
)
=>
void
,
signal
?:
AbortSignal
,
timeoutMs
=
12000
):
Promise
<
SheetReceipt
>
{
if
(
signal
?.
aborted
)
return
Promise
.
resolve
({
snapshot
:
null
,
message
:
'连接已断开,执行结果未确认'
});
const
commandId
=
randomUUID
();
return
new
Promise
(
resolve
=>
{
const
finish
=
(
r
:
SheetReceipt
)
=>
{
clearTimeout
(
timer
);
signal
?.
removeEventListener
(
'abort'
,
abort
);
this
.
pending
.
delete
(
commandId
);
resolve
(
r
);
};
const
abort
=
()
=>
finish
({
snapshot
:
null
,
message
:
'连接已断开,执行结果未确认;重新读取确认单后再操作'
});
const
timer
=
setTimeout
(()
=>
finish
({
snapshot
:
null
,
message
:
'未收到界面回执,执行结果未知;重新读取确认单后再操作'
}),
timeoutMs
);
this
.
pending
.
set
(
commandId
,
{
owner
:
this
.
owner
(
scope
),
requestId
:
command
.
requestId
,
finish
});
signal
?.
addEventListener
(
'abort'
,
abort
,
{
once
:
true
});
try
{
send
({
type
:
'assignment_sheet_command'
,
commandId
,
...
command
});
}
catch
{
abort
();
}
});
}
receive
(
scope
:
TenantScopeContext
,
id
:
string
,
raw
:
unknown
):
void
{
const
pending
=
this
.
pending
.
get
(
id
);
if
(
!
pending
||
pending
.
owner
!==
this
.
owner
(
scope
))
throw
new
NotFoundException
(
'回执已过期或不属于当前会话'
);
const
body
=
raw
as
{
snapshot
?:
unknown
;
message
?:
unknown
};
const
parsed
=
SheetSnapshotSchema
.
safeParse
(
body
?.
snapshot
);
if
(
parsed
.
success
&&
pending
.
requestId
&&
parsed
.
data
.
requestId
!==
pending
.
requestId
)
{
pending
.
finish
({
snapshot
:
null
,
message
:
'确认单版本不一致,执行结果未确认'
});
return
;
}
pending
.
finish
({
snapshot
:
parsed
.
success
?
parsed
.
data
:
null
,
message
:
typeof
body
?.
message
===
'string'
?
body
.
message
.
slice
(
0
,
12000
)
:
'未提供执行说明'
});
}
}
apps/pac-service/src/modules/plan/assignment-facts.ts
View file @
427202a6
...
@@ -32,7 +32,7 @@ import { GUIDANCE_KIND } from './assignment-signals';
...
@@ -32,7 +32,7 @@ import { GUIDANCE_KIND } from './assignment-signals';
* ⚠️ **例外一条、且只有一条**:每条引导上的 `开放方式`(见 `GUIDANCE_HOWTO`)。
* ⚠️ **例外一条、且只有一条**:每条引导上的 `开放方式`(见 `GUIDANCE_HOWTO`)。
* 2026-08-17 产品实测定的,破例的理由和它的边界都写在那个常量上。
* 2026-08-17 产品实测定的,破例的理由和它的边界都写在那个常量上。
* ⛔ 别照着它再开第二处 —— 一个"有过先例"的例外会变成没有边界。
* ⛔ 别照着它再开第二处 —— 一个"有过先例"的例外会变成没有边界。
*
⛔ 不含任何 planId / 患者姓名 —— 那些走侧信道给界面,模型手里不该有
。
*
批量概览不含患者明细;确认单核验工具按需要提供患者及归属
。
* ⚠️ 键名用中文:模型照着键名说就是对的,省掉一轮"把 inHandPending 翻译成人话"。
* ⚠️ 键名用中文:模型照着键名说就是对的,省掉一轮"把 inHandPending 翻译成人话"。
*/
*/
...
@@ -264,8 +264,16 @@ export function modelFacts(p: AssignmentProposal): Record<string, unknown> {
...
@@ -264,8 +264,16 @@ export function modelFacts(p: AssignmentProposal): Record<string, unknown> {
* 前者是"都处置完了",后者是"不知道"。⛔ 别学别处的 `
...(
x
?
{}
:
{})
` 省略法。
* 前者是"都处置完了",后者是"不知道"。⛔ 别学别处的 `
...(
x
?
{}
:
{})
` 省略法。
* ⚠️ `
已经分下去了
` 与 `
propose_assignment
` 返回值里那个键**同名同义**,⛔ 别起第二个说法。
* ⚠️ `
已经分下去了
` 与 `
propose_assignment
` 返回值里那个键**同名同义**,⛔ 别起第二个说法。
*/
*/
export function sheetSnapshotFacts(s: SheetSnapshot): Record<string, unknown> {
export function sheetSnapshotFacts(s: SheetSnapshot, query: { patient?: string; offset?: number; limit?: number } = {}): Record<string, unknown> {
const patient = query.patient?.trim();
const patients = s.patients?.filter(p => !patient || p.name === patient || p.medicalRecordNumber === patient);
const offset = Number.isInteger(query.offset) ? Math.max(0, query.offset!) : 0;
const limit = Number.isInteger(query.limit) ? Math.min(100, Math.max(1, query.limit!)) : 50;
return {
return {
确认单版本: s.requestId,
患者明细总数: patients?.length ?? null,
下一页起点: patients && offset + limit < patients.length ? offset + limit : null,
患者明细: patients?.slice(offset, offset + limit).map(p => ({ 姓名: p.name, 病历号: p.medicalRecordNumber, 客服: p.assigneeName, 状态: p.status })) ?? null,
/**
/**
* ⭐ **人群条件排在最前** —— 「这批人是谁」是重算的前提,⛔ 不能让它排在
* ⭐ **人群条件排在最前** —— 「这批人是谁」是重算的前提,⛔ 不能让它排在
* 一堆分配结果后面:模型读到「改成 200 人」时第一件要确认的就是这一格是什么。
* 一堆分配结果后面:模型读到「改成 200 人」时第一件要确认的就是这一格是什么。
...
...
apps/pac-service/src/modules/plan/assignment-proposal.service.ts
View file @
427202a6
...
@@ -126,6 +126,9 @@ export class AssignmentProposalService {
...
@@ -126,6 +126,9 @@ export class AssignmentProposalService {
scope
:
TenantScopeContext
,
scope
:
TenantScopeContext
,
input
:
{
input
:
{
clinicId
:
string
;
clinicId
:
string
;
/** 精确选择患者;接收人可指定,不影响原批量规则。 */
patientIds
?:
string
[];
assigneeUserId
?:
string
;
/** 画像「潜在治疗」8 类之一(矩阵 X 轴);不传 = 不按治疗项收窄 */
/** 画像「潜在治疗」8 类之一(矩阵 X 轴);不传 = 不按治疗项收窄 */
potentialTreatment
?:
string
;
potentialTreatment
?:
string
;
/** 窗口温度(矩阵 Y 轴)。⚠️ 必须与 potentialTreatment 同时给 */
/** 窗口温度(矩阵 Y 轴)。⚠️ 必须与 potentialTreatment 同时给 */
...
@@ -187,8 +190,14 @@ export class AssignmentProposalService {
...
@@ -187,8 +190,14 @@ export class AssignmentProposalService {
*/
*/
const
clinicId
=
resolveClinicId
(
scope
,
input
.
clinicId
);
const
clinicId
=
resolveClinicId
(
scope
,
input
.
clinicId
);
const
{
potentialTreatment
}
=
input
;
const
{
potentialTreatment
}
=
input
;
const
patientIds
=
input
.
patientIds
?
[...
new
Set
(
input
.
patientIds
)]
:
undefined
;
if
(
patientIds
&&
(
!
patientIds
.
length
||
patientIds
.
length
>
100
||
patientIds
.
some
(
id
=>
!
/^
[
0-9a-f
]{8}
-
[
0-9a-f
]{4}
-
[
0-9a-f
]{4}
-
[
0-9a-f
]{4}
-
[
0-9a-f
]{12}
$/i
.
test
(
id
))))
{
throw
new
Error
(
'指定患者应为 1–100 个有效患者 ID'
);
}
if
(
input
.
assigneeUserId
&&
!
patientIds
)
throw
new
Error
(
'指定接收人必须同时指定患者'
);
const
criteria
:
CohortCriteria
=
{
const
criteria
:
CohortCriteria
=
{
clinicId
,
clinicId
,
...(
patientIds
?
{
patientIds
}
:
{}),
...(
potentialTreatment
?
{
potentialTreatment
}
:
{}),
...(
potentialTreatment
?
{
potentialTreatment
}
:
{}),
...(
input
.
temperature
?
{
temperature
:
input
.
temperature
}
:
{}),
...(
input
.
temperature
?
{
temperature
:
input
.
temperature
}
:
{}),
...(
input
.
personaTags
?
{
personaTags
:
input
.
personaTags
}
:
{}),
...(
input
.
personaTags
?
{
personaTags
:
input
.
personaTags
}
:
{}),
...
@@ -196,10 +205,13 @@ export class AssignmentProposalService {
...
@@ -196,10 +205,13 @@ export class AssignmentProposalService {
};
};
assertCohortCriteria
(
criteria
);
assertCohortCriteria
(
criteria
);
const
[
roster
,
baseline
]
=
await
Promise
.
all
([
const
[
roster
,
baseline
]
=
await
Promise
.
all
([
this
.
roster
.
list
(
scope
,
clinicId
),
this
.
roster
.
list
(
scope
,
clinicId
,
input
.
assigneeUserId
===
scope
.
userId
?
{
extraUserIds
:
[
scope
.
userId
]
}
:
{}
),
this
.
resolveBaseline
(
scope
,
clinicId
),
this
.
resolveBaseline
(
scope
,
clinicId
),
]);
]);
const
agents
=
roster
.
agents
;
const
agents
=
roster
.
agents
;
if
(
input
.
assigneeUserId
&&
!
agents
.
some
(
a
=>
a
.
userId
===
input
.
assigneeUserId
))
{
throw
new
Error
(
'指定接收人不在当前诊所可分配名册中'
);
}
// ── 两个基数:本批人数 N + 时限 ──────────────────────────
// ── 两个基数:本批人数 N + 时限 ──────────────────────────
// 优先级:本次明确指定 > 沿用上一次 > 首次默认。
// 优先级:本次明确指定 > 沿用上一次 > 首次默认。
...
@@ -236,12 +248,12 @@ export class AssignmentProposalService {
...
@@ -236,12 +248,12 @@ export class AssignmentProposalService {
input
.
dailyCalls
!=
null
input
.
dailyCalls
!=
null
?
Math
.
min
(
60
,
Math
.
max
(
1
,
Math
.
floor
(
input
.
dailyCalls
)))
?
Math
.
min
(
60
,
Math
.
max
(
1
,
Math
.
floor
(
input
.
dailyCalls
)))
:
DAILY_CALLS_PER_AGENT
;
:
DAILY_CALLS_PER_AGENT
;
const
batchSize
=
const
batchSize
=
patientIds
?
patientIds
.
length
:
input
.
targetCount
!=
null
input
.
targetCount
!=
null
?
Math
.
max
(
0
,
Math
.
floor
(
input
.
targetCount
))
?
Math
.
max
(
0
,
Math
.
floor
(
input
.
targetCount
))
:
agents
.
length
*
dailyCalls
*
expiresInDays
;
:
agents
.
length
*
dailyCalls
*
expiresInDays
;
const
basis
:
'inherited'
|
'default'
|
'explicit'
=
const
basis
:
'inherited'
|
'default'
|
'explicit'
=
input
.
targetCount
!=
null
||
input
.
expiresInDays
!=
null
||
input
.
dailyCalls
!=
null
patientIds
||
input
.
targetCount
!=
null
||
input
.
expiresInDays
!=
null
||
input
.
dailyCalls
!=
null
?
'explicit'
?
'explicit'
:
'default'
;
:
'default'
;
// 按客服精调:本次传的压过沿用的(同一个人两处都有 → 本次赢);空对象 = 撤掉他的精调
// 按客服精调:本次传的压过沿用的(同一个人两处都有 → 本次赢);空对象 = 撤掉他的精调
...
@@ -290,6 +302,9 @@ export class AssignmentProposalService {
...
@@ -290,6 +302,9 @@ export class AssignmentProposalService {
this
.
poolOwnership
(
scope
,
criteria
,
agents
,
now
),
this
.
poolOwnership
(
scope
,
criteria
,
agents
,
now
),
]);
]);
const
candidateTotal
=
counts
.
total
;
const
candidateTotal
=
counts
.
total
;
if
(
patientIds
&&
new
Set
(
pool
.
map
(
c
=>
c
.
patientId
)).
size
!==
patientIds
.
length
)
{
throw
new
Error
(
'部分指定患者不在当前诊所可分配召回池中,本次未生成确认单'
);
}
// ⭐ **每一版提案都记下"按什么条件、圈出多少人"** —— 这不是调试日志,是这条生产线的黑匣子。
// ⭐ **每一版提案都记下"按什么条件、圈出多少人"** —— 这不是调试日志,是这条生产线的黑匣子。
// 主管连着说三句话就会出三版确认单,人数一版一个样时,唯一能分清
// 主管连着说三句话就会出三版确认单,人数一版一个样时,唯一能分清
// 「是条件变了」还是「是取数飘了」的证据就是这行(2026-08-06 排查 12→17→13 用的就是它)。
// 「是条件变了」还是「是取数飘了」的证据就是这行(2026-08-06 排查 12→17→13 用的就是它)。
...
@@ -373,7 +388,9 @@ export class AssignmentProposalService {
...
@@ -373,7 +388,9 @@ export class AssignmentProposalService {
:
[...
head
,
...
explore
];
:
[...
head
,
...
explore
];
// ── ② 落人:专属优先 → 无主补空 → 待分配(两种模式**同一套**)────────
// ── ② 落人:专属优先 → 无主补空 → 待分配(两种模式**同一套**)────────
const
raw
=
placeAgents
(
chosen
,
agents
,
dedicated
,
capOf
);
const
raw
=
input
.
assigneeUserId
?
{
placed
:
chosen
.
map
(
c
=>
({
...
c
,
assigneeUserId
:
input
.
assigneeUserId
!
,
assignStrategy
:
AssignStrategy
.
MANUAL
})),
unplaced
:
0
,
pending
:
[]
as
PendingItem
[]
}
:
placeAgents
(
chosen
,
agents
,
dedicated
,
capOf
);
// ⭐ 身份(姓名/病历号)在**落人之后**贴上 —— placeAgents 是纯算法,
// ⭐ 身份(姓名/病历号)在**落人之后**贴上 —— placeAgents 是纯算法,
// 让它认识"患者叫什么"只会让它更难测,而对分法没有任何影响。
// 让它认识"患者叫什么"只会让它更难测,而对分法没有任何影响。
/// 贴身份的公共部分 —— 已落人的和待分配的用**同一份**,⛔ 别各贴各的(会漂)
/// 贴身份的公共部分 —— 已落人的和待分配的用**同一份**,⛔ 别各贴各的(会漂)
...
@@ -636,8 +653,8 @@ export class AssignmentProposalService {
...
@@ -636,8 +653,8 @@ export class AssignmentProposalService {
:
`,没有需要您定的了。`
),
:
`,没有需要您定的了。`
),
}
;
}
;
// ⚠️ 「再筛一刀」单独查库,⛔ 不放进 computeSignals —— 那个函数要保持纯。
// ⚠️ 「再筛一刀」单独查库,⛔ 不放进 computeSignals —— 那个函数要保持纯。
const narrow = await this.narrowSignal(scope, criteria, now, input.preferPlaceable === true);
const narrow =
patientIds ? undefined :
await this.narrowSignal(scope, criteria, now, input.preferPlaceable === true);
return { ...proposal,
signals
: computeSignals(proposal as AssignmentProposal, narrow) };
return { ...proposal,
canRefill: patientIds ? false : proposal.canRefill, signals: patientIds ? []
: computeSignals(proposal as AssignmentProposal, narrow) };
}
}
/**
/**
...
...
apps/pac-service/src/modules/plan/cohort-filter.ts
View file @
427202a6
...
@@ -25,6 +25,8 @@ import { labelExistsSql, labelTemperatureExistsSql } from './reason-temperature.
...
@@ -25,6 +25,8 @@ import { labelExistsSql, labelTemperatureExistsSql } from './reason-temperature.
*/
*/
export
interface
CohortCriteria
{
export
interface
CohortCriteria
{
/** 定向分配的精确患者集合,仍受宿主、品牌、诊所和召回池范围限制。 */
patientIds
?:
string
[];
/** 批次不跨诊所 */
/** 批次不跨诊所 */
clinicId
:
string
;
clinicId
:
string
;
/** 矩阵 X 轴:画像「潜在治疗」8 类之一 */
/** 矩阵 X 轴:画像「潜在治疗」8 类之一 */
...
@@ -195,6 +197,7 @@ export function cohortWhereSql(
...
@@ -195,6 +197,7 @@ export function cohortWhereSql(
assertCohortCriteria
(
c
);
assertCohortCriteria
(
c
);
return
Prisma
.
sql
`
return
Prisma
.
sql
`
${
poolBaseSql
(
scope
,
c
.
clinicId
)}
${
poolBaseSql
(
scope
,
c
.
clinicId
)}
${
c
.
patientIds
?
Prisma
.
sql
`AND fp.patient_id = ANY(
${
c
.
patientIds
}
::uuid[])`
:
Prisma
.
empty
}
${
c
.
potentialTreatment
?
treatmentSql
(
c
.
potentialTreatment
)
:
Prisma
.
empty
}
${
c
.
potentialTreatment
?
treatmentSql
(
c
.
potentialTreatment
)
:
Prisma
.
empty
}
${
${
c
.
temperature
&&
c
.
potentialTreatment
c
.
temperature
&&
c
.
potentialTreatment
...
...
apps/pac-service/tests/assignment-contract.spec.ts
View file @
427202a6
...
@@ -117,7 +117,8 @@ describe('确认单草稿态 —— 前端产、请求带、工具读', () => {
...
@@ -117,7 +117,8 @@ describe('确认单草稿态 —— 前端产、请求带、工具读', () => {
expect
(
CHAT_HOOK
).
toMatch
(
/sheetState:
\s
*useAssistantStore
\.
getState
\(\)\.
sheetSnapshot/
);
expect
(
CHAT_HOOK
).
toMatch
(
/sheetState:
\s
*useAssistantStore
\.
getState
\(\)\.
sheetSnapshot/
);
expect
(
CONTROLLER
).
toMatch
(
/SheetSnapshotSchema
\.
safeParse
\(
body
\.
sheetState
\)
/
);
expect
(
CONTROLLER
).
toMatch
(
/SheetSnapshotSchema
\.
safeParse
\(
body
\.
sheetState
\)
/
);
expect
(
ASSISTANT
).
toMatch
(
/tools
\.
get_current_sheet
\s
*=
\s
*tool
\(
/
);
expect
(
ASSISTANT
).
toMatch
(
/tools
\.
get_current_sheet
\s
*=
\s
*tool
\(
/
);
expect
(
ASSISTANT
).
toContain
(
'sheetSnapshotFacts(input.sheetState)'
);
expect
(
ASSISTANT
).
toContain
(
'sheetSnapshotFacts(receipt.snapshot)'
);
expect
(
ASSISTANT
).
not
.
toContain
(
'sheetSnapshotFacts(input.sheetState)'
);
});
});
test
(
'⭐ 解不出来就当没有 —— ⛔ 不许把半份快照喂给模型(缺的字段会被读成 0)'
,
()
=>
{
test
(
'⭐ 解不出来就当没有 —— ⛔ 不许把半份快照喂给模型(缺的字段会被读成 0)'
,
()
=>
{
...
...
apps/pac-service/tests/assignment-specific-patient.spec.ts
0 → 100644
View file @
427202a6
import
{
AssignmentProposalService
}
from
'../src/modules/plan/assignment-proposal.service'
;
import
{
cohortWhereSql
}
from
'../src/modules/plan/cohort-filter'
;
const
pid
=
'11111111-1111-4111-8111-111111111111'
;
const
scope
=
{
hostId
:
'h'
,
tenantId
:
't'
,
userId
:
'admin'
,
clinicIds
:
[
'c'
],
sourceUnits
:
[
'brand'
]
};
function
setup
(
found
=
true
)
{
const
query
=
jest
.
fn
(
async
(
sql
:
{
strings
:
string
[];
values
:
unknown
[]
})
=>
{
const
text
=
sql
.
strings
.
join
(
' '
);
if
(
text
.
includes
(
'count(DISTINCT'
))
return
[{
n
:
BigInt
(
found
?
1
:
0
)
}];
return
found
?
[{
planId
:
'plan'
,
patientId
:
pid
,
priorityScore
:
85
}]
:
[];
});
const
service
=
new
AssignmentProposalService
({
$queryRaw
:
query
,
patient
:
{
findMany
:
async
()
=>
[{
id
:
pid
,
name
:
'于常瑞'
,
medicalRecordNumber
:
'demo'
,
preferences
:
null
}]
},
planAssignment
:
{
findFirst
:
async
()
=>
null
},
}
as
never
,
{
list
:
async
()
=>
({
agents
:
[{
userId
:
'admin'
,
name
:
'管理员'
,
inHand
:
39
},
{
userId
:
'other'
,
name
:
'其他客服'
,
inHand
:
0
}],
rosterNote
:
''
})
}
as
never
,
{}
as
never
);
return
{
service
,
query
};
}
describe
(
'指定患者分配与批量规则隔离'
,
()
=>
{
it
(
'仅选指定患者,直接给管理员,不调整其他客服额度'
,
async
()
=>
{
const
{
service
,
query
}
=
setup
();
const
result
=
await
service
.
propose
(
scope
,
{
clinicId
:
'c'
,
patientIds
:
[
pid
],
assigneeUserId
:
'admin'
});
expect
(
result
.
items
).
toHaveLength
(
1
);
expect
(
result
.
items
[
0
]).
toMatchObject
({
patientId
:
pid
,
assigneeUserId
:
'admin'
,
assignStrategy
:
'manual'
});
expect
(
result
.
agentOverrides
).
toEqual
({});
expect
(
result
.
target
).
toBe
(
1
);
expect
(
result
.
canRefill
).
toBe
(
false
);
for
(
const
[
sql
]
of
query
.
mock
.
calls
)
expect
(
sql
.
values
).
toContainEqual
([
pid
]);
});
it
(
'有患者不在可分配范围时整单拒绝,不换成别人补足人数'
,
async
()
=>
{
await
expect
(
setup
(
false
).
service
.
propose
(
scope
,
{
clinicId
:
'c'
,
patientIds
:
[
pid
],
assigneeUserId
:
'admin'
})).
rejects
.
toThrow
(
'不在当前诊所'
);
});
it
(
'跨诊所与未知接收人拒绝'
,
async
()
=>
{
await
expect
(
setup
().
service
.
propose
(
scope
,
{
clinicId
:
'other'
,
patientIds
:
[
pid
]
})).
rejects
.
toThrow
();
await
expect
(
setup
().
service
.
propose
(
scope
,
{
clinicId
:
'c'
,
patientIds
:
[
pid
],
assigneeUserId
:
'outsider'
})).
rejects
.
toThrow
(
'名册'
);
});
it
(
'指定患者的 SQL 保留品牌、租户和召回池约束'
,
()
=>
{
const
sql
=
cohortWhereSql
(
scope
,
{
clinicId
:
'c'
,
patientIds
:
[
pid
]
},
new
Date
());
expect
(
sql
.
sql
).
toContain
(
'assignee_user_id IS NULL'
);
expect
(
sql
.
sql
).
toContain
(
'source_unit'
);
expect
(
sql
.
values
).
toEqual
(
expect
.
arrayContaining
([
'h'
,
't'
,
'c'
,
'brand'
,
[
pid
]]));
expect
(
cohortWhereSql
(
scope
,
{
clinicId
:
'c'
},
new
Date
()).
sql
).
not
.
toContain
(
'fp.patient_id = ANY'
);
});
});
apps/pac-service/tests/sheet-bridge.spec.ts
0 → 100644
View file @
427202a6
import
{
SheetBridgeService
}
from
'../src/modules/assistant/sheet-bridge.service'
;
import
{
sheetSnapshotFacts
}
from
'../src/modules/plan/assignment-facts'
;
import
type
{
SheetSnapshot
}
from
'@pac/types'
;
const
scope
=
{
hostId
:
'h'
,
tenantId
:
't'
,
userId
:
'u'
,
clinicIds
:
[
'c'
],
sourceUnits
:
[
'brand'
]
};
const
snapshot
:
SheetSnapshot
=
{
requestId
:
's1'
,
confirmed
:
false
,
placed
:
1
,
agents
:
1
,
pending
:
0
,
dropped
:
0
,
moved
:
1
,
expiresInDays
:
3
,
rowExpiryOverrides
:
0
,
benefit
:
null
,
byAgent
:
[{
name
:
'管理员'
,
count
:
1
}],
criteria
:
{
potentialTreatment
:
null
,
temperature
:
null
,
narrowedBy
:
null
,
candidateTotal
:
1
,
batchSize
:
1
},
patients
:
[{
patientId
:
'p1'
,
planId
:
'plan1'
,
name
:
'于常瑞'
,
medicalRecordNumber
:
'demo'
,
assigneeUserId
:
'u'
,
assigneeName
:
'管理员'
,
status
:
'placed'
}]
};
describe
(
'确认单真实回执'
,
()
=>
{
it
(
'发出指令不等于成功,收到界面状态后才返回'
,
async
()
=>
{
const
bridge
=
new
SheetBridgeService
();
const
send
=
jest
.
fn
();
let
resolved
=
false
;
const
waiting
=
bridge
.
request
(
scope
,
{
kind
:
'edit'
,
requestId
:
's1'
,
ops
:
[]
},
send
).
then
(
r
=>
{
resolved
=
true
;
return
r
;
});
await
Promise
.
resolve
();
expect
(
resolved
).
toBe
(
false
);
bridge
.
receive
(
scope
,
send
.
mock
.
calls
[
0
][
0
].
commandId
,
{
snapshot
,
message
:
'于常瑞已改派给管理员'
});
expect
((
await
waiting
).
snapshot
?.
patients
?.[
0
]?.
assigneeName
).
toBe
(
'管理员'
);
expect
(
sheetSnapshotFacts
(
snapshot
).
患者明细
).
toEqual
([{
姓名
:
'于常瑞'
,
病历号
:
'demo'
,
客服
:
'管理员'
,
状态
:
'placed'
}]);
});
it
(
'大名单有明确分页,点名可直接查到末尾患者'
,
()
=>
{
const
large
=
{
...
snapshot
,
patients
:
Array
.
from
({
length
:
120
},
(
_
,
i
)
=>
({
...
snapshot
.
patients
!
[
0
]
!
,
name
:
`患者
${
i
}
`
}))
};
expect
(
sheetSnapshotFacts
(
large
)).
toMatchObject
({
患者明细总数
:
120
,
下一页起点
:
50
});
expect
(
sheetSnapshotFacts
(
large
).
患者明细
).
toHaveLength
(
50
);
expect
(
sheetSnapshotFacts
(
large
,
{
patient
:
'患者119'
})).
toMatchObject
({
患者明细总数
:
1
,
下一页起点
:
null
,
患者明细
:
[
expect
.
objectContaining
({
姓名
:
'患者119'
})]
});
expect
(
sheetSnapshotFacts
(
large
,
{
offset
:
100
}).
患者明细
).
toHaveLength
(
20
);
});
it
(
'不接受其他用户、品牌、诊所范围的回执'
,
async
()
=>
{
const
bridge
=
new
SheetBridgeService
();
const
send
=
jest
.
fn
();
const
waiting
=
bridge
.
request
(
scope
,
{
kind
:
'read'
},
send
);
const
id
=
send
.
mock
.
calls
[
0
][
0
].
commandId
;
for
(
const
other
of
[{
...
scope
,
userId
:
'other'
},
{
...
scope
,
sourceUnits
:
[
'other'
]
},
{
...
scope
,
clinicIds
:
[
'other'
]
}])
{
expect
(()
=>
bridge
.
receive
(
other
,
id
,
{
snapshot
})).
toThrow
();
}
bridge
.
receive
(
scope
,
id
,
{
snapshot
,
message
:
'最新'
});
await
waiting
;
expect
(()
=>
bridge
.
receive
(
scope
,
id
,
{
snapshot
})).
toThrow
();
});
it
(
'旧版本回执不能冒充当前确认单'
,
async
()
=>
{
const
bridge
=
new
SheetBridgeService
();
const
send
=
jest
.
fn
();
const
waiting
=
bridge
.
request
(
scope
,
{
kind
:
'read'
,
requestId
:
'new'
},
send
);
bridge
.
receive
(
scope
,
send
.
mock
.
calls
[
0
][
0
].
commandId
,
{
snapshot
});
expect
(
await
waiting
).
toMatchObject
({
snapshot
:
null
,
message
:
expect
.
stringContaining
(
'版本不一致'
)
});
});
it
(
'超时及断开均返回未确认,不报成功;晚回执拒绝'
,
async
()
=>
{
const
bridge
=
new
SheetBridgeService
();
const
send
=
jest
.
fn
();
const
result
=
await
bridge
.
request
(
scope
,
{
kind
:
'edit'
},
send
,
undefined
,
5
);
expect
(
result
).
toMatchObject
({
snapshot
:
null
,
message
:
expect
.
stringContaining
(
'未知'
)
});
expect
(()
=>
bridge
.
receive
(
scope
,
send
.
mock
.
calls
[
0
][
0
].
commandId
,
{
snapshot
})).
toThrow
();
const
abort
=
new
AbortController
();
const
waiting
=
bridge
.
request
(
scope
,
{
kind
:
'read'
},
send
,
abort
.
signal
);
abort
.
abort
();
expect
((
await
waiting
).
snapshot
).
toBeNull
();
});
});
apps/pac-web/src/components/assistant/assignment-confirm-sheet.tsx
View file @
427202a6
'use client'
;
'use client'
;
import
{
finishSheetCommand
}
from
'./sheet-command'
;
import
{
useCallback
,
useEffect
,
useMemo
,
useRef
,
useState
}
from
'react'
;
import
{
useCallback
,
useEffect
,
useMemo
,
useRef
,
useState
}
from
'react'
;
import
{
Check
,
ChevronDown
,
ChevronRight
,
GripVertical
,
Loader2
,
Undo2
,
Users
,
X
}
from
'lucide-react'
;
import
{
Check
,
ChevronDown
,
ChevronRight
,
GripVertical
,
Loader2
,
Undo2
,
Users
,
X
}
from
'lucide-react'
;
import
{
import
{
...
@@ -158,7 +159,7 @@ export function AssignmentConfirmSheet({
...
@@ -158,7 +159,7 @@ export function AssignmentConfirmSheet({
* 助手下发的语义编辑指令队列(edit_assignment_sheet)。
* 助手下发的语义编辑指令队列(edit_assignment_sheet)。
* ⚠️ 由**本组件**去匹配姓名 —— 模型手里没有 planId,那是故意的。
* ⚠️ 由**本组件**去匹配姓名 —— 模型手里没有 planId,那是故意的。
*/
*/
edits
?:
{
seq
:
number
;
ops
:
SheetEditOp
[]
}[];
edits
?:
{
seq
:
number
;
ops
:
SheetEditOp
[]
;
commandId
?:
string
}[];
/** 执行结果回一句进对话(既给主管看,也补进模型上下文 —— 否则它不知道到底成没成) */
/** 执行结果回一句进对话(既给主管看,也补进模型上下文 —— 否则它不知道到底成没成) */
/**
/**
* 改单结果进对话。
* 改单结果进对话。
...
@@ -464,6 +465,14 @@ export function AssignmentConfirmSheet({
...
@@ -464,6 +465,14 @@ export function AssignmentConfirmSheet({
const
snapshot
=
useMemo
<
SheetSnapshot
>
(
const
snapshot
=
useMemo
<
SheetSnapshot
>
(
()
=>
({
()
=>
({
requestId
,
requestId
,
patients
:
[...
sheet
.
items
,
...(
sheet
.
pending
??
[])].
map
(
p
=>
{
const
assignee
=
moveByPlan
[
p
.
planId
]
??
(
'assigneeUserId'
in
p
?
p
.
assigneeUserId
:
null
);
const
agent
=
[...
sheet
.
byAgent
,
...
sheet
.
skippedAgents
].
find
(
a
=>
a
.
userId
===
assignee
);
return
{
patientId
:
p
.
patientId
,
planId
:
p
.
planId
,
name
:
p
.
patientName
,
medicalRecordNumber
:
p
.
medicalRecordNumber
,
assigneeUserId
:
dropped
.
has
(
p
.
planId
)
?
null
:
assignee
,
assigneeName
:
dropped
.
has
(
p
.
planId
)
?
null
:
agent
?.
name
??
null
,
status
:
dropped
.
has
(
p
.
planId
)
?
'removed'
as
const
:
assignee
?
'placed'
as
const
:
'pending'
as
const
};
}),
confirmed
:
assignmentId
!=
null
,
confirmed
:
assignmentId
!=
null
,
placed
:
items
.
length
,
placed
:
items
.
length
,
agents
:
groups
.
length
,
agents
:
groups
.
length
,
...
@@ -492,6 +501,8 @@ export function AssignmentConfirmSheet({
...
@@ -492,6 +501,8 @@ export function AssignmentConfirmSheet({
items
,
items
,
groups
,
groups
,
pendingList
.
length
,
pendingList
.
length
,
moveByPlan
,
dropped
,
dropped
.
size
,
dropped
.
size
,
expiresInDays
,
expiresInDays
,
dayByPlan
,
dayByPlan
,
...
@@ -507,12 +518,13 @@ export function AssignmentConfirmSheet({
...
@@ -507,12 +518,13 @@ export function AssignmentConfirmSheet({
* ⛔ 不会报错。
* ⛔ 不会报错。
*/
*/
useEffect
(()
=>
{
useEffect
(()
=>
{
if
(
state
!==
'active'
&&
state
!==
'confirmed'
)
return
;
useAssistantStore
.
getState
().
setSheetSnapshot
(
snapshot
);
useAssistantStore
.
getState
().
setSheetSnapshot
(
snapshot
);
return
()
=>
{
return
()
=>
{
const
s
=
useAssistantStore
.
getState
();
const
s
=
useAssistantStore
.
getState
();
if
(
s
.
sheetSnapshot
?.
requestId
===
snapshot
.
requestId
)
s
.
setSheetSnapshot
(
null
);
if
(
s
.
sheetSnapshot
?.
requestId
===
snapshot
.
requestId
)
s
.
setSheetSnapshot
(
null
);
};
};
},
[
snapshot
]);
},
[
snapshot
,
state
]);
/**
/**
* 回声 —— **主管看增量,模型另外拿当前值**。
* 回声 —— **主管看增量,模型另外拿当前值**。
...
@@ -537,6 +549,17 @@ export function AssignmentConfirmSheet({
...
@@ -537,6 +549,17 @@ export function AssignmentConfirmSheet({
const
[
echoSeq
,
setEchoSeq
]
=
useState
(
0
);
const
[
echoSeq
,
setEchoSeq
]
=
useState
(
0
);
const
snapRef
=
useRef
(
snapshot
);
const
snapRef
=
useRef
(
snapshot
);
snapRef
.
current
=
snapshot
;
snapRef
.
current
=
snapshot
;
const
commandReceipts
=
useRef
<
{
id
:
string
;
message
:
string
}[]
>
([]);
const
[
receiptSeq
,
setReceiptSeq
]
=
useState
(
0
);
const
acknowledge
=
(
id
:
string
|
undefined
,
message
:
string
)
=>
{
if
(
!
id
)
return
;
commandReceipts
.
current
.
push
({
id
,
message
});
setReceiptSeq
(
n
=>
n
+
1
);
};
useEffect
(()
=>
{
const
receipts
=
commandReceipts
.
current
.
splice
(
0
);
for
(
const
r
of
receipts
)
finishSheetCommand
(
r
.
id
,
snapRef
.
current
,
r
.
message
);
},
[
receiptSeq
]);
const
echo
=
useCallback
((
summary
:
string
)
=>
{
const
echo
=
useCallback
((
summary
:
string
)
=>
{
echoQueue
.
current
.
push
(
summary
);
echoQueue
.
current
.
push
(
summary
);
setEchoSeq
((
n
)
=>
n
+
1
);
setEchoSeq
((
n
)
=>
n
+
1
);
...
@@ -728,33 +751,20 @@ export function AssignmentConfirmSheet({
...
@@ -728,33 +751,20 @@ export function AssignmentConfirmSheet({
// ⚠️ 2026-08-20 起它们**不是没辙了**:走「调整各客服在手的单」那条路(独立的一张单)。
// ⚠️ 2026-08-20 起它们**不是没辙了**:走「调整各客服在手的单」那条路(独立的一张单)。
// ⛔ 别再把话说成「只能撤销重分」,那句从这天起就是错的。
// ⛔ 别再把话说成「只能撤销重分」,那句从这天起就是错的。
if (assignmentId) {
if (assignmentId) {
for (const { ops } of fresh) {
void (async () => {
for (const { ops, commandId } of fresh) {
const results: string[] = [];
for (const op of ops) {
for (const op of ops) {
if (op.action === 'set_benefit') {
if (op.action !== 'set_benefit') { results.push('已确认批次不能在草稿上修改人员或时限,请使用在手调整'); continue; }
void assignmentsApi
try {
// ⚠️ schema 的 superRefine 保证 set_benefit 必有 text;`
??
''
` 只是类型兜底
const r = await assignmentsApi.setBenefit(assignmentId, op.text ?? '');
.setBenefit(assignmentId, op.text ?? '')
setBenefit(r.benefitText ?? ''); results.push(r.note);
.then((r) => {
} catch (e) { results.push(`
福利未修改:
$
{
e
instanceof
Error
?
e
.
message
:
'请求失败'
}
`); }
setBenefit(r.benefitText ?? '');
// ⭐ 回报**服务端那句**,不是前端自己编的 —— 里面有"作废了几条话术缓存"
// 和"几条客服已经打开过",这两个数只有服务端知道(T14)。
echo(`
确认单已更新
:
$
{
r
.
note
}
`);
})
.catch((e) =>
echo(
`
福利没改成
:
$
{
e
instanceof
Error
?
e
.
message
:
'接口报错'
}
——
请如实告诉主管
,
⛔
不要说已经改好了。
`,
),
);
continue;
}
notes.push(
`
这批
**
已经确认分配
**
了
,
「
$
{
OP_ZH
[
op
.
action
]
??
op
.
action
}
」在这张单上改不了
——
` +
`
单子已经在客服手上
——
让我把在手的摆出来调
(
换人、改时限、移出
),
` +
`
或者撤销整批重新分
`,
);
}
}
const message = results.join(';');
echo(message); acknowledge(commandId, message);
}
}
if (notes.length) echo(`
确认单没有改动
:
$
{
notes
.
join
(
';'
)}
。
`
);
})(
);
return;
return;
}
}
/**
/**
...
@@ -807,7 +817,7 @@ export function AssignmentConfirmSheet({
...
@@ -807,7 +817,7 @@ export function AssignmentConfirmSheet({
const k = q.trim();
const k = q.trim();
const inSheet = groups.find((g) => g.name === k || g.userId === k);
const inSheet = groups.find((g) => g.name === k || g.userId === k);
if (inSheet) return inSheet;
if (inSheet) return inSheet;
const idle =
sheet.skippedAgents
.find((a) => a.name === k || a.userId === k);
const idle =
[...sheet.byAgent.map(a => ({ userId: a.userId, name: a.name, inHand: a.inHandBefore })), ...sheet.skippedAgents]
.find((a) => a.name === k || a.userId === k);
return idle
return idle
? { userId: idle.userId, name: idle.name, inHandBefore: idle.inHand, list: [] }
? { userId: idle.userId, name: idle.name, inHandBefore: idle.inHand, list: [] }
: null;
: null;
...
@@ -1132,6 +1142,7 @@ export function AssignmentConfirmSheet({
...
@@ -1132,6 +1142,7 @@ export function AssignmentConfirmSheet({
setMoveByPlan((m) => ({ ...m, ...moveDraft }));
setMoveByPlan((m) => ({ ...m, ...moveDraft }));
setDropped((sset) => new Set([...sset, ...dropDraft]));
setDropped((sset) => new Set([...sset, ...dropDraft]));
if (notes.length) echo(`
确认单已更新
:
$
{
notes
.
join
(
';'
)}
。
`);
if (notes.length) echo(`
确认单已更新
:
$
{
notes
.
join
(
';'
)}
。
`);
for (const e of fresh) acknowledge(e.commandId, notes.join(';') || '没有匹配到可修改的内容');
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [edits]);
}, [edits]);
...
...
apps/pac-web/src/components/assistant/assistant-chat.tsx
View file @
427202a6
...
@@ -263,6 +263,7 @@ function Markdown({ text: raw }: { text: string }) {
...
@@ -263,6 +263,7 @@ function Markdown({ text: raw }: { text: string }) {
}
}
function
BlockView
({
function
BlockView
({
hideSheet
,
block
,
block
,
onSheetConfirmed
,
onSheetConfirmed
,
onSheetEdited
,
onSheetEdited
,
...
@@ -279,6 +280,7 @@ function BlockView({
...
@@ -279,6 +280,7 @@ function BlockView({
orphanKeys
,
orphanKeys
,
}:
{
}:
{
block
:
Block
;
block
:
Block
;
hideSheet
?:
boolean
;
onSheetConfirmed
?:
(
onSheetConfirmed
?:
(
requestId
:
string
,
requestId
:
string
,
assignmentId
:
string
,
assignmentId
:
string
,
...
@@ -357,7 +359,7 @@ function BlockView({
...
@@ -357,7 +359,7 @@ function BlockView({
}
}
if
(
block
.
kind
===
'assignment_sheet'
)
{
if
(
block
.
kind
===
'assignment_sheet'
)
{
return
(
return
(
<
AssignmentConfirmSheet
<
div
hidden=
{
hideSheet
}
><
AssignmentConfirmSheet
requestId=
{
block
.
requestId
}
requestId=
{
block
.
requestId
}
sheet=
{
block
.
sheet
}
sheet=
{
block
.
sheet
}
state=
{
block
.
state
}
state=
{
block
.
state
}
...
@@ -375,7 +377,7 @@ function BlockView({
...
@@ -375,7 +377,7 @@ function BlockView({
onConfirmed=
{
(
id
,
summary
,
modelFacts
)
=>
onConfirmed=
{
(
id
,
summary
,
modelFacts
)
=>
onSheetConfirmed
?.(
block
.
requestId
,
id
,
summary
,
modelFacts
)
onSheetConfirmed
?.(
block
.
requestId
,
id
,
summary
,
modelFacts
)
}
}
/>
/>
</
div
>
);
);
}
}
/**
/**
...
@@ -837,7 +839,7 @@ function MessageView({
...
@@ -837,7 +839,7 @@ function MessageView({
const
ordered
=
[
const
ordered
=
[
...
body
,
...
body
,
...(
streaming
?
[]
:
orphan
),
...(
streaming
?
[]
:
orphan
),
...(
anchorAt
!==
-
1
||
streaming
?
[]
:
sheets
),
...(
anchorAt
!==
-
1
?
[]
:
sheets
),
// 与确认单同一条规矩:漏调锚点 / 无根 → 等说完、钉末尾。⛔ 卡片永远不会不出现
// 与确认单同一条规矩:漏调锚点 / 无根 → 等说完、钉末尾。⛔ 卡片永远不会不出现
...(
rearrangeAnchorAt
!==
-
1
||
streaming
?
[]
:
rearranges
),
...(
rearrangeAnchorAt
!==
-
1
||
streaming
?
[]
:
rearranges
),
];
];
...
@@ -866,6 +868,7 @@ function MessageView({
...
@@ -866,6 +868,7 @@ function MessageView({
:
`b:${i}`
:
`b:${i}`
}
}
block=
{
b
}
block=
{
b
}
hideSheet=
{
b
.
kind
===
'assignment_sheet'
&&
!!
streaming
&&
anchorAt
===
-
1
}
placedKeys=
{
placedKeys
}
placedKeys=
{
placedKeys
}
signalOf=
{
signalOf
}
signalOf=
{
signalOf
}
streaming=
{
streaming
}
streaming=
{
streaming
}
...
...
apps/pac-web/src/components/assistant/chat-blocks.ts
View file @
427202a6
...
@@ -128,7 +128,7 @@ export type Block =
...
@@ -128,7 +128,7 @@ export type Block =
* 卡片组件按 `seq` 只执行新增的那一批,执行完把结果回一句进对话。
* 卡片组件按 `seq` 只执行新增的那一批,执行完把结果回一句进对话。
* ⚠️ 用队列而不是"最新一条":主管连着说两句时,两批指令都要生效。
* ⚠️ 用队列而不是"最新一条":主管连着说两句时,两批指令都要生效。
*/
*/
edits
?:
{
seq
:
number
;
ops
:
SheetEditOp
[]
}[];
edits
?:
{
seq
:
number
;
ops
:
SheetEditOp
[]
;
commandId
?:
string
}[];
}
}
/**
/**
* 调整单卡片**摆在这** —— 位置由模型决定(`show_rearrange` 工具)。
* 调整单卡片**摆在这** —— 位置由模型决定(`show_rearrange` 工具)。
...
...
apps/pac-web/src/components/assistant/sheet-command.test.ts
0 → 100644
View file @
427202a6
import
{
afterEach
,
describe
,
expect
,
it
,
vi
}
from
'vitest'
;
import
type
{
SheetSnapshot
}
from
'@pac/types'
;
import
{
useAssistantStore
}
from
'@/stores/assistant-store'
;
import
{
readLiveSheet
,
waitSheetEdit
,
finishSheetCommand
,
postSheetReceipt
}
from
'./sheet-command'
;
vi
.
mock
(
'@/stores/auth-store'
,
()
=>
({
useAuthStore
:
{
getState
:
()
=>
({
accessToken
:
'new-session'
})
}
}));
const
snapshot
=
(
requestId
:
string
)
=>
({
requestId
,
patients
:
[{
name
:
'患者甲'
,
assigneeName
:
'管理员'
}]
}
as
SheetSnapshot
);
afterEach
(()
=>
{
useAssistantStore
.
setState
({
sheetSnapshot
:
null
});
vi
.
useRealTimers
();
vi
.
unstubAllGlobals
();
});
describe
(
'确认单实时回执'
,
()
=>
{
it
(
'等新单挂载,不把旧单当成新单'
,
async
()
=>
{
useAssistantStore
.
setState
({
sheetSnapshot
:
snapshot
(
'old'
)
});
const
read
=
readLiveSheet
(
'new'
);
let
done
=
false
;
void
read
.
then
(()
=>
{
done
=
true
;
});
await
Promise
.
resolve
();
expect
(
done
).
toBe
(
false
);
useAssistantStore
.
setState
({
sheetSnapshot
:
snapshot
(
'new'
)
});
expect
((
await
read
).
snapshot
?.
requestId
).
toBe
(
'new'
);
});
it
(
'每次读取当前版本的患者及归属'
,
async
()
=>
{
useAssistantStore
.
setState
({
sheetSnapshot
:
snapshot
(
'a'
)
});
expect
((
await
readLiveSheet
()).
snapshot
?.
patients
?.[
0
]?.
assigneeName
).
toBe
(
'管理员'
);
const
changed
=
snapshot
(
'a'
);
changed
.
patients
!
[
0
]
!
.
assigneeName
=
'客服乙'
;
useAssistantStore
.
setState
({
sheetSnapshot
:
changed
});
expect
((
await
readLiveSheet
()).
snapshot
?.
patients
?.[
0
]?.
assigneeName
).
toBe
(
'客服乙'
);
});
it
(
'收到执行完成回执才返回,未知命令不影响待处理命令'
,
async
()
=>
{
const
edit
=
waitSheetEdit
(
'one'
);
let
done
=
false
;
void
edit
.
then
(()
=>
{
done
=
true
;
});
finishSheetCommand
(
'other'
,
snapshot
(
'a'
),
'无关'
);
await
Promise
.
resolve
();
expect
(
done
).
toBe
(
false
);
finishSheetCommand
(
'one'
,
snapshot
(
'a'
),
'已改派给管理员'
);
expect
(
await
edit
).
toMatchObject
({
message
:
'已改派给管理员'
,
snapshot
:
{
requestId
:
'a'
}
});
});
it
(
'丢回执和找不到指定版本明确返回未知,不宣告成功'
,
async
()
=>
{
vi
.
useFakeTimers
();
const
edit
=
waitSheetEdit
(
'timeout'
),
read
=
readLiveSheet
(
'missing'
);
await
vi
.
advanceTimersByTimeAsync
(
9000
);
expect
(
await
edit
).
toMatchObject
({
snapshot
:
null
,
message
:
expect
.
stringContaining
(
'未确认'
)
});
expect
(
await
read
).
toMatchObject
({
snapshot
:
null
,
message
:
expect
.
stringContaining
(
'未就绪'
)
});
});
it
(
'登录切换后不借新令牌发送旧回执'
,
async
()
=>
{
const
fetcher
=
vi
.
fn
();
vi
.
stubGlobal
(
'fetch'
,
fetcher
);
await
postSheetReceipt
(
'one'
,
{
snapshot
:
snapshot
(
'a'
),
message
:
'完成'
},
'old-session'
);
expect
(
fetcher
).
not
.
toHaveBeenCalled
();
});
});
apps/pac-web/src/components/assistant/sheet-command.ts
0 → 100644
View file @
427202a6
import
type
{
SheetSnapshot
}
from
'@pac/types'
;
import
{
useAssistantStore
}
from
'@/stores/assistant-store'
;
import
{
useAuthStore
}
from
'@/stores/auth-store'
;
import
{
env
}
from
'@/lib/env'
;
type
Receipt
=
{
snapshot
:
SheetSnapshot
|
null
;
message
:
string
};
const
pending
=
new
Map
<
string
,
(
r
:
Receipt
)
=>
void
>
();
/** 回执必须在卡片提交 React 状态之后调用。 */
export
function
finishSheetCommand
(
id
:
string
,
snapshot
:
SheetSnapshot
|
null
,
message
:
string
)
{
pending
.
get
(
id
)?.({
snapshot
,
message
});
}
export
function
waitSheetEdit
(
id
:
string
):
Promise
<
Receipt
>
{
return
new
Promise
(
resolve
=>
{
const
timer
=
setTimeout
(()
=>
finishSheetCommand
(
id
,
null
,
'界面执行未确认,请重新读取确认单'
),
9000
);
pending
.
set
(
id
,
r
=>
{
clearTimeout
(
timer
);
pending
.
delete
(
id
);
resolve
(
r
);
});
});
}
export
function
readLiveSheet
(
requestId
?:
string
):
Promise
<
Receipt
>
{
const
current
=
useAssistantStore
.
getState
().
sheetSnapshot
;
if
(
!
requestId
||
current
?.
requestId
===
requestId
)
return
Promise
.
resolve
({
snapshot
:
current
,
message
:
current
?
'已读取最新确认单'
:
'当前没有确认单'
});
return
new
Promise
(
resolve
=>
{
const
timer
=
setTimeout
(()
=>
{
unsubscribe
();
resolve
({
snapshot
:
null
,
message
:
'确认单版本已变化或未就绪,本次未执行'
});
},
5000
);
const
unsubscribe
=
useAssistantStore
.
subscribe
(
s
=>
{
if
(
s
.
sheetSnapshot
?.
requestId
!==
requestId
)
return
;
clearTimeout
(
timer
);
unsubscribe
();
resolve
({
snapshot
:
s
.
sheetSnapshot
,
message
:
'已读取最新确认单'
});
});
});
}
export
async
function
postSheetReceipt
(
commandId
:
string
,
receipt
:
Receipt
,
token
:
string
|
null
)
{
// 登录切换后,旧流的回执不能借用新会话发送。
if
(
!
token
||
useAuthStore
.
getState
().
accessToken
!==
token
)
return
;
await
fetch
(
new
URL
(
'/pac/v1/assistant/sheet-receipt'
,
env
.
apiBaseUrl
),
{
method
:
'POST'
,
headers
:
{
'Content-Type'
:
'application/json'
,
Authorization
:
`Bearer
${
token
}
`
},
body
:
JSON
.
stringify
({
commandId
,
...
receipt
}),
});
}
apps/pac-web/src/components/assistant/use-assistant-chat.ts
View file @
427202a6
'use client'
;
'use client'
;
import
{
readLiveSheet
,
waitSheetEdit
,
postSheetReceipt
,
finishSheetCommand
}
from
'./sheet-command'
;
import
{
useCallback
,
useRef
,
useState
}
from
'react'
;
import
{
useCallback
,
useRef
,
useState
}
from
'react'
;
import
type
{
import
type
{
AssignmentProposal
,
AssignmentProposal
,
...
@@ -246,6 +247,34 @@ export function useAssistantChat() {
...
@@ -246,6 +247,34 @@ export function useAssistantChat() {
}),
}),
);
);
break
;
break
;
case
'assignment_sheet_command'
:
{
const
commandId
=
String
(
evt
.
commandId
??
''
);
const
expectedId
=
typeof
evt
.
requestId
===
'string'
?
evt
.
requestId
:
undefined
;
const
receiptToken
=
useAuthStore
.
getState
().
accessToken
;
void
(
async
()
=>
{
const
current
=
await
readLiveSheet
(
expectedId
);
if
(
evt
.
kind
===
'read'
||
!
current
.
snapshot
)
{
await
postSheetReceipt
(
commandId
,
current
,
receiptToken
);
return
;
}
const
receipt
=
waitSheetEdit
(
commandId
);
setMessages
(
prev
=>
{
const
at
=
findActiveSheet
(
prev
);
if
(
!
at
)
{
queueMicrotask
(()
=>
finishSheetCommand
(
commandId
,
null
,
'当前没有有效确认单,未执行修改'
));
return
prev
;
}
const
{
messageIndex
:
mi
,
blockIndex
:
bi
}
=
at
;
const
b
=
prev
[
mi
]
!
.
blocks
[
bi
]
as
Extract
<
Block
,
{
kind
:
'assignment_sheet'
}
>
;
if
(
b
.
requestId
!==
current
.
snapshot
!
.
requestId
)
{
queueMicrotask
(()
=>
finishSheetCommand
(
commandId
,
null
,
'确认单版本已变化,未执行修改'
));
return
prev
;
}
const
next
:
Block
=
{
...
b
,
edits
:
[...(
b
.
edits
??
[]),
{
seq
:
(
b
.
edits
?.
length
??
0
)
+
1
,
ops
:
(
evt
.
ops
??
[])
as
SheetEditOp
[],
commandId
,
}]
};
return
prev
.
map
((
m
,
k
)
=>
k
===
mi
?
{
...
m
,
blocks
:
m
.
blocks
.
map
((
x
,
j
)
=>
j
===
bi
?
next
:
x
)
}
:
m
);
});
await
postSheetReceipt
(
commandId
,
await
receipt
,
receiptToken
);
})().
catch
(()
=>
{
/* 服务端超时会报告未确认,⛔ 不伪造成功 */
});
break
;
}
case
'assignment_sheet_edit'
:
case
'assignment_sheet_edit'
:
// ⭐ 助手要改**已经呈现**的那张卡:指令挂到最后一张确认单上,由它自己去匹配执行。
// ⭐ 助手要改**已经呈现**的那张卡:指令挂到最后一张确认单上,由它自己去匹配执行。
//
//
...
@@ -517,6 +546,7 @@ export function useAssistantChat() {
...
@@ -517,6 +546,7 @@ export function useAssistantChat() {
messages: apiMessages,
messages: apiMessages,
model,
model,
conversationId: conversationIdRef.current,
conversationId: conversationIdRef.current,
sheetBridge: true,
...(useAssistantStore.getState().activeClinicId
...(useAssistantStore.getState().activeClinicId
? { activeClinicId: useAssistantStore.getState().activeClinicId }
? { activeClinicId: useAssistantStore.getState().activeClinicId }
: {}),
: {}),
...
...
packages/types/src/schemas/plan-assignment.ts
View file @
427202a6
...
@@ -946,8 +946,8 @@ export type AssignmentProposal = z.infer<typeof AssignmentProposalSchema>;
...
@@ -946,8 +946,8 @@ export type AssignmentProposal = z.infer<typeof AssignmentProposalSchema>;
* ═══ 为什么它必须由浏览器产 ═══════════════════════════════════════
* ═══ 为什么它必须由浏览器产 ═══════════════════════════════════════
* 确认之前,改动**全部只存在于卡片的组件状态里**(改派 / 移出 / 时限 / 福利)——
* 确认之前,改动**全部只存在于卡片的组件状态里**(改派 / 移出 / 时限 / 福利)——
* 服务端手上只有最初生成的那一版提案,还是个请求内的闭包,没落库也没缓存。
* 服务端手上只有最初生成的那一版提案,还是个请求内的闭包,没落库也没缓存。
* ⇒ ⛔
别指望服务端"查"得到它:`get_current_sheet` 查的就是这份随请求捎来的快照,
* ⇒ ⛔
不从服务端缓存推测草稿状态:`get_current_sheet` 向浏览器实时读取。
*
⛔ 也别为它去开一条"草稿回传"的写路径(多一份要维护的状态,只为省模型一次加法)
。
*
读单和改单通过本轮回执返回最新快照;快照不写业务表,也不作为分配授权
。
*
*
* ⚠️ 它是**这一刻**的照片,不是账本:主管下一秒拖一下它就过期了。
* ⚠️ 它是**这一刻**的照片,不是账本:主管下一秒拖一下它就过期了。
* 所以它随**每次** chat 请求重新捎一份(与 `activeClinicId` 同一条旁路),
* 所以它随**每次** chat 请求重新捎一份(与 `activeClinicId` 同一条旁路),
...
@@ -956,6 +956,12 @@ export type AssignmentProposal = z.infer<typeof AssignmentProposalSchema>;
...
@@ -956,6 +956,12 @@ export type AssignmentProposal = z.infer<typeof AssignmentProposalSchema>;
* (`sheetSnapshotFacts`)—— 与 `modelFacts` 同一条纪律:事实投影只在服务端一处。
* (`sheetSnapshotFacts`)—— 与 `modelFacts` 同一条纪律:事实投影只在服务端一处。
*/
*/
export
const
SheetSnapshotSchema
=
z
.
object
({
export
const
SheetSnapshotSchema
=
z
.
object
({
/** 最新界面状态中的患者与归属;旧客户端缺失时明确不可核验,不能当成空名单。 */
patients
:
z
.
array
(
z
.
object
({
patientId
:
z
.
string
(),
planId
:
z
.
string
(),
name
:
z
.
string
().
nullable
(),
medicalRecordNumber
:
z
.
string
().
nullable
(),
assigneeUserId
:
z
.
string
().
nullable
(),
assigneeName
:
z
.
string
().
nullable
(),
status
:
z
.
enum
([
'placed'
,
'pending'
,
'removed'
]),
})).
max
(
20000
).
optional
(),
/// 哪一张单(服务端铸造的幂等键)——多轮之后能认出"说的是不是同一张"
/// 哪一张单(服务端铸造的幂等键)——多轮之后能认出"说的是不是同一张"
requestId
:
z
.
string
(),
requestId
:
z
.
string
(),
/// 已经点过确认了没有。⚠️ true 时人员/时限已锁死,只有福利还能改
/// 已经点过确认了没有。⚠️ true 时人员/时限已锁死,只有福利还能改
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment