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
cab71e89
Commit
cab71e89
authored
Oct 14, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Plain Diff
Merge branch 'main' into test
parents
40d3cade
7f019b26
Pipeline
#3797
failed in 0 seconds
Changes
10
Pipelines
1
Show whitespace changes
Inline
Side-by-side
Showing
10 changed files
with
139 additions
and
17 deletions
+139
-17
apps/ai-service/src/modules/agent/context/common-context.service.ts
+11
-2
apps/ai-service/src/modules/agent/shared/patient-traits.ts
+35
-3
apps/ai-service/src/modules/pac/pac.service.ts
+10
-0
apps/ai-service/tests/address-hydration.spec.ts
+31
-0
apps/ai-service/tests/address-scope.spec.ts
+13
-0
apps/ai-service/tests/corpus.probe.spec.ts
+3
-3
apps/ai-service/tests/last-contact.spec.ts
+17
-0
apps/ai-web/src/app/embed/page.tsx
+9
-2
apps/ai-web/src/lib/pac-profile.ts
+4
-3
apps/pac-docs/content/docs/integration/friday-push-payload.mdx
+6
-4
No files found.
apps/ai-service/src/modules/agent/context/common-context.service.ts
View file @
cab71e89
import
{
PacService
}
from
'../../pac/pac.service'
;
import
{
hydrateAddressTraits
,
rememberPatientTraits
}
from
'../shared/patient-traits'
;
import
{
scopeKeys
}
from
'../llm/tool-scope'
;
import
{
scopeKeys
}
from
'../llm/tool-scope'
;
import
{
Injectable
}
from
'@nestjs/common'
;
import
{
Injectable
,
Logger
}
from
'@nestjs/common'
;
import
{
PrismaService
}
from
'../../../prisma/prisma.service'
;
import
{
PrismaService
}
from
'../../../prisma/prisma.service'
;
import
{
StorageService
}
from
'../../media/storage.service'
;
import
{
StorageService
}
from
'../../media/storage.service'
;
import
{
CorpusService
}
from
'../../corpus/corpus.service'
;
import
{
CorpusService
}
from
'../../corpus/corpus.service'
;
...
@@ -97,6 +99,7 @@ export class CommonContextService {
...
@@ -97,6 +99,7 @@ export class CommonContextService {
* ⚠️ 它缓存着 PAC 的「最近就诊诊所」,命中时是纯本地(见 `ServingClinicService` 头注)。
* ⚠️ 它缓存着 PAC 的「最近就诊诊所」,命中时是纯本地(见 `ServingClinicService` 头注)。
*/
*/
private
readonly
serving
:
ServingClinicService
,
private
readonly
serving
:
ServingClinicService
,
private
readonly
pac
:
PacService
,
)
{}
)
{}
async
build
(
async
build
(
...
@@ -366,6 +369,12 @@ export class CommonContextService {
...
@@ -366,6 +369,12 @@ export class CommonContextService {
* ⚠️ `speakerVids` 是按"最近在前"排的,所以按它的顺序排就对了。
* ⚠️ `speakerVids` 是按"最近在前"排的,所以按它的顺序排就对了。
* 单聊时只有一位,排序无意义但也无害。
* 单聊时只有一位,排序无意义但也无害。
*/
*/
await
hydrateAddressTraits
(
links
,
{
read
:
(
host
,
ids
)
=>
this
.
pac
.
getAddressPatientsForAccount
(
tenantId
,
conv
.
accountId
,
host
,
ids
),
remember
:
(
hostCode
,
hostPatientId
,
patient
)
=>
rememberPatientTraits
(
this
.
prisma
,
{
tenantId
,
hostCode
,
hostPatientId
},
patient
,
now
),
onError
:
()
=>
Logger
.
warn
(
'称呼资料补读或缓存失败,沿用已有依据'
,
'CommonContextService'
),
},
now
);
const
patients
:
PatientRef
[]
=
bySpeakerOrder
(
links
,
speakerVids
).
map
((
l
)
=>
({
const
patients
:
PatientRef
[]
=
bySpeakerOrder
(
links
,
speakerVids
).
map
((
l
)
=>
({
hostCode
:
l
.
hostCode
,
hostCode
:
l
.
hostCode
,
hostPatientId
:
l
.
hostPatientId
,
hostPatientId
:
l
.
hostPatientId
,
...
@@ -392,7 +401,7 @@ export class CommonContextService {
...
@@ -392,7 +401,7 @@ export class CommonContextService {
* ⭐⭐ 算法叫法(罗先生/王女士)—— **姓 + 性别 + 成年**齐了才出,缺一为 null(docs/17 §7.2)。
* ⭐⭐ 算法叫法(罗先生/王女士)—— **姓 + 性别 + 成年**齐了才出,缺一为 null(docs/17 §7.2)。
* ⭐ 和前一天提醒**同一个函数**(`algoAddressFor`,2026-10-04 用户:「尽量同出」):本人的微信,业务系统登记的姓、性别优先,
* ⭐ 和前一天提醒**同一个函数**(`algoAddressFor`,2026-10-04 用户:「尽量同出」):本人的微信,业务系统登记的姓、性别优先,
* 没有再看企微实名 / 企微 sex;家属的微信⛔ 不算。
* 没有再看企微实名 / 企微 sex;家属的微信⛔ 不算。
*
⚠️ 业务系统那几样用 link 上的本地缓存(读到患者资料时回写,见 `rememberPatientTraits`)⇒ ⛔ 没有多一次往返
。
*
业务系统资料优先读绑定缓存;缺失时上方按托管号权限补读,并立即用于本轮称呼
。
*/
*/
algoAddress
:
algoAddressFor
({
algoAddress
:
algoAddressFor
({
relation
:
l
.
relation
,
relation
:
l
.
relation
,
...
...
apps/ai-service/src/modules/agent/shared/patient-traits.ts
View file @
cab71e89
...
@@ -4,9 +4,9 @@ import { patientTraitsOf } from './patient-address';
...
@@ -4,9 +4,9 @@ import { patientTraitsOf } from './patient-address';
/**
/**
* ⭐⭐ 读到患者资料时,把称呼要的三样(姓、性别、年龄段)回写到这位患者的绑定上 —— **唯一**一处写(2026-10-04)。
* ⭐⭐ 读到患者资料时,把称呼要的三样(姓、性别、年龄段)回写到这位患者的绑定上 —— **唯一**一处写(2026-10-04)。
*
*
*
两处调用:AI 查患者要点(`get_patient_brief`)、前一天提醒读患者资料(`AppointmentConfirmService.briefsOf`)
。
*
调用方:AI 查患者要点、前一天提醒,以及对话开始时补读缺失的称呼资料
。
*
都是**本来就读回来了**的那一份 ⇒ ⛔ 不为它多问一次业务系统
。
*
复用已读取的患者卡,只缓存称呼所需派生属性
。
* ⭐ AI 聊天
装配「现在的情况」只读本地,靠这几列
和提醒用同一套叫法(`algoAddressFor`)。
* ⭐ AI 聊天和提醒用同一套叫法(`algoAddressFor`)。
*
*
* ⚠️ 按患者身份写(tenant + host + patient),⛔ 不按 peer_vid:一个患者的所有活动绑定都更新;
* ⚠️ 按患者身份写(tenant + host + patient),⛔ 不按 peer_vid:一个患者的所有活动绑定都更新;
* 家属那几条用不上(`algoAddressFor` 对家属⛔ 不算),存着也不会叫错人。
* 家属那几条用不上(`algoAddressFor` 对家属⛔ 不算),存着也不会叫错人。
...
@@ -31,3 +31,35 @@ export async function rememberPatientTraits(
...
@@ -31,3 +31,35 @@ export async function rememberPatientTraits(
data
,
data
,
});
});
}
}
/** 首次对话缺少称呼依据时补读患者卡,并在本轮直接使用;不依赖模型选工具。 */
export
async
function
hydrateAddressTraits
<
T
extends
{
hostCode
:
string
;
hostPatientId
:
string
;
relation
:
string
|
null
;
patientSurname
:
string
|
null
;
patientGender
:
string
|
null
;
patientAgeBand
:
string
|
null
;
}
>
(
links
:
T
[],
deps
:
{
read
:
(
host
:
string
,
ids
:
string
[])
=>
Promise
<
Array
<
{
id
:
string
;
name
?:
string
|
null
;
gender
?:
string
|
null
;
birthDate
?:
string
|
null
}
>>
;
remember
:
(
host
:
string
,
id
:
string
,
patient
:
{
name
?:
string
|
null
;
gender
?:
string
|
null
;
birthDate
?:
string
|
null
})
=>
Promise
<
void
>
;
onError
:
()
=>
void
;
},
now
:
Date
):
Promise
<
void
>
{
const
missing
=
links
.
filter
(
l
=>
(
!
l
.
relation
||
l
.
relation
===
'self'
)
&&
(
!
l
.
patientSurname
||
!
[
'male'
,
'female'
].
includes
(
l
.
patientGender
??
''
)
||
!
[
'adult'
,
'child'
].
includes
(
l
.
patientAgeBand
??
''
)));
for
(
const
host
of
new
Set
(
missing
.
map
(
l
=>
l
.
hostCode
)))
{
const
ids
=
[...
new
Set
(
missing
.
filter
(
l
=>
l
.
hostCode
===
host
).
map
(
l
=>
l
.
hostPatientId
))];
for
(
let
i
=
0
;
i
<
ids
.
length
;
i
+=
100
)
{
const
chunk
=
ids
.
slice
(
i
,
i
+
100
);
try
{
const
patients
=
await
deps
.
read
(
host
,
chunk
);
for
(
const
p
of
patients
)
{
if
(
!
chunk
.
includes
(
p
.
id
))
continue
;
const
t
=
patientTraitsOf
(
p
,
now
);
for
(
const
l
of
missing
.
filter
(
l
=>
l
.
hostCode
===
host
&&
l
.
hostPatientId
===
p
.
id
))
{
l
.
patientSurname
=
t
.
surname
??
l
.
patientSurname
;
l
.
patientGender
=
t
.
gender
??
l
.
patientGender
;
l
.
patientAgeBand
=
t
.
ageBand
??
l
.
patientAgeBand
;
}
try
{
await
deps
.
remember
(
host
,
p
.
id
,
p
);
}
catch
{
deps
.
onError
();
}
}
}
catch
{
deps
.
onError
();
}
// PAC 不可用仍能回应,保留已有依据或使用「您」。
}
}
}
apps/ai-service/src/modules/pac/pac.service.ts
View file @
cab71e89
...
@@ -200,6 +200,16 @@ export class PacService {
...
@@ -200,6 +200,16 @@ export class PacService {
return
items
[
0
]
??
null
;
return
items
[
0
]
??
null
;
}
}
/** 对话称呼仅补读患者卡;仍按托管号权限和宿主限定。 */
async
getAddressPatientsForAccount
(
tenantId
:
string
,
accountId
:
string
,
host
:
string
,
patientIds
:
string
[]):
Promise
<
PacPatient
[]
>
{
const
scope
=
await
this
.
scope
.
pacScopeForAccount
(
tenantId
,
accountId
);
if
(
scope
.
host
!==
host
||
patientIds
.
length
===
0
)
return
[];
const
client
=
this
.
require
();
return
this
.
call
(()
=>
client
.
getPatients
({
host
:
scope
.
host
,
tenantId
:
scope
.
tenantId
,
orgScope
:
scope
.
orgScope
,
patientIds
,
}));
}
/**
/**
* ⭐ **系统路径**的患者全貌 —— 给定时扫描用,**没有登录人**。
* ⭐ **系统路径**的患者全貌 —— 给定时扫描用,**没有登录人**。
*
*
...
...
apps/ai-service/tests/address-hydration.spec.ts
0 → 100644
View file @
cab71e89
import
{
hydrateAddressTraits
}
from
'../src/modules/agent/shared/patient-traits'
;
import
{
algoAddressFor
}
from
'../src/modules/agent/shared/patient-address'
;
const
now
=
new
Date
(
'2026-10-14T04:00:00Z'
);
const
link
=
()
=>
({
hostCode
:
'friday'
,
hostPatientId
:
'p1'
,
relation
:
'self'
,
patientSurname
:
null
as
string
|
null
,
patientGender
:
null
as
string
|
null
,
patientAgeBand
:
null
as
string
|
null
});
const
patient
=
{
id
:
'p1'
,
name
:
'王小华'
,
gender
:
'F'
,
birthDate
:
'1989-01-01'
};
describe
(
'对话称呼补读'
,
()
=>
{
it
(
'首次对话当轮获得称呼,缓存写失败不丢当前资料'
,
async
()
=>
{
const
l
=
link
();
const
read
=
jest
.
fn
().
mockResolvedValue
([
patient
]);
const
onError
=
jest
.
fn
();
await
hydrateAddressTraits
([
l
],{
read
,
remember
:
async
()
=>
{
throw
Error
(
'db'
);},
onError
},
now
);
expect
(
read
).
toHaveBeenCalledWith
(
'friday'
,[
'p1'
]);
expect
(
algoAddressFor
({
relation
:
l
.
relation
,
wechat
:{
realName
:
null
,
sex
:
null
},
patient
:{
surname
:
l
.
patientSurname
,
gender
:
l
.
patientGender
as
'female'
,
ageBand
:
l
.
patientAgeBand
as
'adult'
}})).
toBe
(
'王女士'
);
expect
(
onError
).
toHaveBeenCalledTimes
(
1
);
});
it
(
'完整缓存及家属不补读'
,
async
()
=>
{
const
read
=
jest
.
fn
();
await
hydrateAddressTraits
([{...
link
(),
patientSurname
:
'王'
,
patientGender
:
'female'
,
patientAgeBand
:
'adult'
},{...
link
(),
relation
:
'mother'
}],{
read
,
remember
:
jest
.
fn
(),
onError
:
jest
.
fn
()},
now
);
expect
(
read
).
not
.
toHaveBeenCalled
();
});
it
(
'相同编号跨宿主隔离,未请求患者不写入'
,
async
()
=>
{
const
a
=
link
(),
b
=
{...
link
(),
hostCode
:
'jvs-dw'
};
const
remember
=
jest
.
fn
();
await
hydrateAddressTraits
([
a
,
b
],{
read
:
async
host
=>
host
===
'friday'
?[
patient
,{...
patient
,
id
:
'other'
}]:[],
remember
,
onError
:
jest
.
fn
()},
now
);
expect
(
a
.
patientSurname
).
toBe
(
'王'
);
expect
(
b
.
patientSurname
).
toBeNull
();
expect
(
remember
).
toHaveBeenCalledTimes
(
1
);
});
it
(
'读取失败不阻断,儿童不会生成先生女士'
,
async
()
=>
{
const
l
=
link
();
const
onError
=
jest
.
fn
();
await
hydrateAddressTraits
([
l
],{
read
:
async
()
=>
{
throw
Error
(
'offline'
);},
remember
:
jest
.
fn
(),
onError
},
now
);
expect
(
l
.
patientAgeBand
).
toBeNull
();
expect
(
onError
).
toHaveBeenCalledTimes
(
1
);
await
hydrateAddressTraits
([
l
],{
read
:
async
()
=>
[{...
patient
,
birthDate
:
'2020-01-01'
}],
remember
:
jest
.
fn
(),
onError
},
now
);
expect
(
l
.
patientAgeBand
).
toBe
(
'child'
);
});
});
apps/ai-service/tests/address-scope.spec.ts
0 → 100644
View file @
cab71e89
import
{
PacService
}
from
'../src/modules/pac/pac.service'
;
describe
(
'称呼补读 PAC 权限'
,()
=>
{
it
(
'沿用托管号范围,跨宿主不发请求'
,
async
()
=>
{
const
getPatients
=
jest
.
fn
().
mockResolvedValue
([]);
const
pacScopeForAccount
=
jest
.
fn
().
mockResolvedValue
({
host
:
'friday'
,
tenantId
:
'data-tenant'
,
orgScope
:[
'clinic-a'
]});
const
service
=
{
scope
:{
pacScopeForAccount
},
require
:()
=>
({
getPatients
}),
call
:(
fn
:()
=>
unknown
)
=>
fn
()};
await
PacService
.
prototype
.
getAddressPatientsForAccount
.
call
(
service
as
never
,
'ai-tenant'
,
'account'
,
'jvs-dw'
,[
'p1'
]);
expect
(
getPatients
).
not
.
toHaveBeenCalled
();
await
PacService
.
prototype
.
getAddressPatientsForAccount
.
call
(
service
as
never
,
'ai-tenant'
,
'account'
,
'friday'
,[
'p1'
]);
expect
(
pacScopeForAccount
).
toHaveBeenLastCalledWith
(
'ai-tenant'
,
'account'
);
expect
(
getPatients
).
toHaveBeenCalledWith
({
host
:
'friday'
,
tenantId
:
'data-tenant'
,
orgScope
:[
'clinic-a'
],
patientIds
:[
'p1'
]});
});
});
apps/ai-service/tests/corpus.probe.spec.ts
View file @
cab71e89
...
@@ -330,7 +330,7 @@ run('语料 · 真库探针(回滚)', () => {
...
@@ -330,7 +330,7 @@ run('语料 · 真库探针(回滚)', () => {
await
raw
(
tx
,
id
[
'瑞尔'
]
!
,
{
kind
:
'script'
,
slot
:
'price'
,
body
:
'品牌的问价话术'
});
await
raw
(
tx
,
id
[
'瑞尔'
]
!
,
{
kind
:
'script'
,
slot
:
'price'
,
body
:
'品牌的问价话术'
});
await
raw
(
tx
,
id
[
'北京朝阳公园'
]
!
,
{
kind
:
'script'
,
slot
:
'price'
,
body
:
'门店的问价话术'
});
await
raw
(
tx
,
id
[
'北京朝阳公园'
]
!
,
{
kind
:
'script'
,
slot
:
'price'
,
body
:
'门店的问价话术'
});
const
ctxSvc
=
new
CommonContextService
(
tx
as
never
,
{
signedUrl
:
()
=>
null
}
as
never
,
{
recent
:
()
=>
[]
}
as
never
,
svc
,
servingAt
(
chainBJ
()));
const
ctxSvc
=
new
CommonContextService
(
tx
as
never
,
{
signedUrl
:
()
=>
null
}
as
never
,
{
recent
:
()
=>
[]
}
as
never
,
svc
,
servingAt
(
chainBJ
())
,
{
getAddressPatientsForAccount
:
async
()
=>
[]
}
as
never
);
const
ctx
=
await
ctxSvc
.
build
(
T
,
conv
.
id
);
const
ctx
=
await
ctxSvc
.
build
(
T
,
conv
.
id
);
expect
(
ctx
).
not
.
toBeNull
();
expect
(
ctx
).
not
.
toBeNull
();
expect
(
ctx
!
.
houseRules
).
toContain
(
'价格与优惠:⛔ 不主动给折扣'
);
expect
(
ctx
!
.
houseRules
).
toContain
(
'价格与优惠:⛔ 不主动给折扣'
);
...
@@ -360,7 +360,7 @@ run('语料 · 真库探针(回滚)', () => {
...
@@ -360,7 +360,7 @@ run('语料 · 真库探针(回滚)', () => {
await
raw
(
tx
,
id
[
'瑞尔'
]
!
,
{
kind
:
'redline'
,
slot
:
'price'
,
body
:
'不主动给折扣'
});
await
raw
(
tx
,
id
[
'瑞尔'
]
!
,
{
kind
:
'redline'
,
slot
:
'price'
,
body
:
'不主动给折扣'
});
await
raw
(
tx
,
id
[
'北京朝阳公园'
]
!
,
{
body
:
'周末只有一位医生'
});
await
raw
(
tx
,
id
[
'北京朝阳公园'
]
!
,
{
body
:
'周末只有一位医生'
});
const
ctxSvc
=
new
CommonContextService
(
tx
as
never
,
{
signedUrl
:
()
=>
null
}
as
never
,
{
recent
:
()
=>
[]
}
as
never
,
svc
,
servingAt
(
chainBJ
()));
const
ctxSvc
=
new
CommonContextService
(
tx
as
never
,
{
signedUrl
:
()
=>
null
}
as
never
,
{
recent
:
()
=>
[]
}
as
never
,
svc
,
servingAt
(
chainBJ
())
,
{
getAddressPatientsForAccount
:
async
()
=>
[]
}
as
never
);
const
ctx
=
await
ctxSvc
.
build
(
T
,
conv
.
id
);
const
ctx
=
await
ctxSvc
.
build
(
T
,
conv
.
id
);
expect
(
ctx
!
.
knowledgeCard
).
toBe
(
''
);
expect
(
ctx
!
.
knowledgeCard
).
toBe
(
''
);
expect
(
toolNamesFor
(
ctx
!
)).
not
.
toContain
(
GET_KNOWLEDGE
.
name
);
expect
(
toolNamesFor
(
ctx
!
)).
not
.
toContain
(
GET_KNOWLEDGE
.
name
);
...
@@ -383,7 +383,7 @@ run('语料 · 真库探针(回滚)', () => {
...
@@ -383,7 +383,7 @@ run('语料 · 真库探针(回滚)', () => {
expect
(
h
.
catalog
).
toBe
(
''
);
expect
(
h
.
catalog
).
toBe
(
''
);
expect
(
h
.
persona
).
toContain
(
'自称「小瑞」'
);
expect
(
h
.
persona
).
toContain
(
'自称「小瑞」'
);
const
ctxSvc
=
new
CommonContextService
(
tx
as
never
,
{
signedUrl
:
()
=>
null
}
as
never
,
{
recent
:
()
=>
[]
}
as
never
,
svc
,
servingAt
(
chainBJ
()));
const
ctxSvc
=
new
CommonContextService
(
tx
as
never
,
{
signedUrl
:
()
=>
null
}
as
never
,
{
recent
:
()
=>
[]
}
as
never
,
svc
,
servingAt
(
chainBJ
())
,
{
getAddressPatientsForAccount
:
async
()
=>
[]
}
as
never
);
const
ctx
=
await
ctxSvc
.
build
(
T
,
conv
.
id
);
const
ctx
=
await
ctxSvc
.
build
(
T
,
conv
.
id
);
const
system
=
buildSystemPrompt
({
toolNames
:
toolNamesFor
(
ctx
!
),
house
:
ctx
!
.
houseRules
,
catalog
:
ctx
!
.
knowledgeCard
,
persona
:
ctx
!
.
personaRules
});
const
system
=
buildSystemPrompt
({
toolNames
:
toolNamesFor
(
ctx
!
),
house
:
ctx
!
.
houseRules
,
catalog
:
ctx
!
.
knowledgeCard
,
persona
:
ctx
!
.
personaRules
});
expect
(
system
).
toContain
(
'自称「小瑞」'
);
expect
(
system
).
toContain
(
'自称「小瑞」'
);
...
...
apps/ai-service/tests/last-contact.spec.ts
0 → 100644
View file @
cab71e89
import
{
lastContact
}
from
'../../ai-web/src/lib/pac-profile'
;
import
type
{
PatientProfile
}
from
'../../ai-web/src/lib/api'
;
const
now
=
new
Date
(
'2026-10-14T04:00:00Z'
);
const
fact
=
(
at
:
string
|
null
,
kind
=
'actual'
)
=>
({
type
:
'consultation'
,
kind
,
occurredAt
:
at
,
plannedFor
:
null
});
const
profile
=
(
facts
:
unknown
[])
=>
({
facts
}
as
PatientProfile
);
describe
(
'距上次接触'
,()
=>
{
it
(
'跳过未来 actual,找到最近已发生事实'
,()
=>
{
expect
(
lastContact
(
profile
([
fact
(
'2026-10-30T00:00:00Z'
),
fact
(
'2026-10-12T04:00:00Z'
),
fact
(
'2026-10-01T04:00:00Z'
)]),
now
)?.
days
).
toBe
(
2
);
});
it
(
'同日未来时刻也不能算接触,计划和无发生时间都排除'
,()
=>
{
expect
(
lastContact
(
profile
([
fact
(
'2026-10-14T05:00:00Z'
),
fact
(
'2026-10-01T04:00:00Z'
,
'planned'
),{...
fact
(
null
),
plannedFor
:
'2026-10-01T04:00:00Z'
},
fact
(
'invalid'
)]),
now
)).
toBeNull
();
});
it
(
'此刻已发生算0天,无资料返回空'
,()
=>
{
expect
(
lastContact
(
profile
([
fact
(
now
.
toISOString
())]),
now
)?.
days
).
toBe
(
0
);
expect
(
lastContact
(
null
,
now
)).
toBeNull
();
});
});
apps/ai-web/src/app/embed/page.tsx
View file @
cab71e89
...
@@ -6,6 +6,7 @@ import { installSession, abandonHostEntry, type SessionTokens } from '@/lib/auth
...
@@ -6,6 +6,7 @@ import { installSession, abandonHostEntry, type SessionTokens } from '@/lib/auth
import
{
api
,
type
Me
}
from
'@/lib/api'
;
import
{
api
,
type
Me
}
from
'@/lib/api'
;
import
{
currentHostContext
}
from
'@/lib/auth-session'
;
import
{
currentHostContext
}
from
'@/lib/auth-session'
;
import
{
pendingHostEntry
}
from
'@/lib/host-entry'
;
import
{
pendingHostEntry
}
from
'@/lib/host-entry'
;
import
{
Loader2
}
from
'lucide-react'
;
import
{
Button
}
from
'@/components/ui/button'
;
import
{
Button
}
from
'@/components/ui/button'
;
function
Entry
()
{
function
Entry
()
{
...
@@ -45,15 +46,21 @@ function Entry() {
...
@@ -45,15 +46,21 @@ function Entry() {
}
catch
(
e
)
{
setError
(
e
instanceof
Error
?
e
.
message
:
'进入失败,请从宿主重新打开。'
);
}
}
catch
(
e
)
{
setError
(
e
instanceof
Error
?
e
.
message
:
'进入失败,请从宿主重新打开。'
);
}
}
)();
}
)();
}, [code, selectedSid, router, attempt]);
}, [code, selectedSid, router, attempt]);
if (!error) return
<
EntryLoading
/>
;
return
<
main
className=
"flex min-h-screen items-center justify-center bg-slate-50 p-6"
>
return
<
main
className=
"flex min-h-screen items-center justify-center bg-slate-50 p-6"
>
<
section
className=
"w-full max-w-sm rounded-xl bg-white p-6 text-center"
>
<
section
className=
"w-full max-w-sm rounded-xl bg-white p-6 text-center"
>
<
h1
className=
"text-lg font-semibold"
>
FridayAI
</
h1
>
<
h1
className=
"text-lg font-semibold"
>
FridayAI
</
h1
>
<
p
className=
"mt-3 text-sm text-slate-500"
>
{
error
??
'正在验证宿主授权…'
}
</
p
>
<
p
className=
"mt-3 text-sm text-slate-500"
>
{
error
}
</
p
>
{
error
&&
code
?
<
Button
className=
"mt-5"
onClick=
{
()
=>
{
started
.
current
=
false
;
setError
(
null
);
setAttempt
((
v
)
=>
v
+
1
);
}
}
>
重试当前进入
</
Button
>
:
null
}
{
error
&&
code
?
<
Button
className=
"mt-5"
onClick=
{
()
=>
{
started
.
current
=
false
;
setError
(
null
);
setAttempt
((
v
)
=>
v
+
1
);
}
}
>
重试当前进入
</
Button
>
:
null
}
{
error
?
<
p
className=
"mt-3 text-xs text-slate-400"
>
授权过期或被撤销时,请关闭此页,从宿主原有入口重新进入。
</
p
>
:
null
}
{
error
?
<
p
className=
"mt-3 text-xs text-slate-400"
>
授权过期或被撤销时,请关闭此页,从宿主原有入口重新进入。
</
p
>
:
null
}
</
section
>
</
section
>
</
main
>
;
</
main
>
;
}
}
function EntryLoading()
{
return
<
main
className=
"flex min-h-screen items-center justify-center bg-slate-50"
role=
"status"
aria
-
label=
"加载中"
>
<
Loader2
className=
"h-6 w-6 animate-spin text-slate-400"
aria
-
hidden=
"true"
/>
</
main
>;
}
export default function EmbedEntry()
{
export default function EmbedEntry()
{
return
<
Suspense
fallback=
{
<
p
className=
"p-6 text-sm text-slate-500"
>
正在加载…
</
p
>
}
><
Entry
/></
Suspense
>;
return
<
Suspense
fallback=
{
<
EntryLoading
/
>
}
><
Entry
/></
Suspense
>;
}
}
apps/ai-web/src/lib/pac-profile.ts
View file @
cab71e89
...
@@ -256,20 +256,21 @@ export function doctorRecommendations(p: PatientProfile | null): TreatmentLine[]
...
@@ -256,20 +256,21 @@ export function doctorRecommendations(p: PatientProfile | null): TreatmentLine[]
*/
*/
export function lastContact(
export function lastContact(
p: PatientProfile | null,
p: PatientProfile | null,
now = new Date(),
): { date: string; days: number; label: string } | null {
): { date: string; days: number; label: string } | null {
if (!p) return null;
if (!p) return null;
let best: { at: number; iso: string; type: string } | null = null;
let best: { at: number; iso: string; type: string } | null = null;
for (const f of p.facts) {
for (const f of p.facts) {
if (f.kind !== 'actual') continue;
if (f.kind !== 'actual') continue;
const iso = f
actAt(f)
;
const iso = f
.occurredAt
;
if (!iso) continue;
if (!iso) continue;
const t = Date.parse(iso);
const t = Date.parse(iso);
if (!Number.isFinite(t)) continue;
if (!Number.isFinite(t)
|| t > now.getTime()
) continue;
if (!best || t > best.at) best = { at: t, iso, type: f.type };
if (!best || t > best.at) best = { at: t, iso, type: f.type };
}
}
if (!best) return null;
if (!best) return null;
const d = new Date(best.at);
const d = new Date(best.at);
const today = n
ew Date()
;
const today = n
ow
;
const start = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime();
const start = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime();
const thatDay = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const thatDay = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
return {
return {
...
...
apps/pac-docs/content/docs/integration/friday-push-payload.mdx
View file @
cab71e89
...
@@ -72,12 +72,14 @@ icon: FileJson
...
@@ -72,12 +72,14 @@ icon: FileJson
#### 专属客服(待联调)
#### 专属客服(待联调)
- 来源:`customer.customer_follow_person`,`is_default = 1`。
- 来源:`customer.customer_follow_person`,`is_default = 1`。
- 关联:`tenant_id` + `customer_id`;多门店存在不同客服时,先确认有效归属,不任取一条。
- 归属:同一品牌内,取患者**最近一次实际就诊门店的当前有效默认专属客服**;按实际就诊时间确定门店,排除未来预约、取消及无效就诊,不取最近回访执行人。
- 关联:按 `tenant_id` + `customer_id` + 上述门店匹配客服关系,取 `is_default = 1`;不得跨品牌或回退取其他门店客服。
- 待确认:无有效就诊记录、最近就诊门店无法唯一确定,或该门店存在多个有效默认客服时,省略两个客服字段并记录待确认,不任取一条。
- 字段:`follow_people_id`(字符串用户 ID)、`follow_people`(姓名),来自同一条记录,inline 到 `customer_basic_info`。
- 字段:`follow_people_id`(字符串用户 ID)、`follow_people`(姓名),来自同一条记录,inline 到 `customer_basic_info`。
- 变更:
新增、换人、改名、取消时重推患者主档;
关系表单独变更也须触发,不能仅依赖主档更新时间。
- 变更:
最近实际就诊门店变化,或该门店客服新增、换人、改名、取消时,重推患者主档;就诊记录更正、取消及
关系表单独变更也须触发,不能仅依赖主档更新时间。
- 取消:`follow_people_id` 和 `follow_people` 同时显式传 `null`,清除原客服 ID 和姓名;不传字段保持原值。单字段 `null` 不代表取消。
- 取消:
确认所选门店未分配或已取消专属客服时,
`follow_people_id` 和 `follow_people` 同时显式传 `null`,清除原客服 ID 和姓名;不传字段保持原值。单字段 `null` 不代表取消。
- 补推:接入完成后,按当前关系分批补推历史患者主档。
- 补推:接入完成后,按当前关系分批补推历史患者主档。
- 验收:首次分配、换人、取消、
重复推送、跨品牌及多门店归属
。
- 验收:首次分配、换人、取消、
跨店就诊、未来预约不改变归属、就诊更正、无就诊及多默认客服、重复推送、跨品牌隔离
。
#### 跟进中患者不进召回池 —— `has_active_complex_case`(PAC 侧已就绪,待宿主推)
#### 跟进中患者不进召回池 —— `has_active_complex_case`(PAC 侧已就绪,待宿主推)
...
...
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