Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
P
pms-agent
Project
Project
Details
Activity
Releases
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
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
姜耀祖
pms-agent
Commits
5435092e
Commit
5435092e
authored
Apr 10, 2026
by
jiangyz
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
代码提交
parent
da58786b
Changes
29
Show whitespace changes
Inline
Side-by-side
Showing
29 changed files
with
3979 additions
and
0 deletions
+3979
-0
CareAgentChatRequest.java
...ch/pms/agent/controller/request/CareAgentChatRequest.java
+10
-0
CareTraceLogSupport.java
...oepoch/pms/agent/domain/care/log/CareTraceLogSupport.java
+166
-0
ActivityMatchUserRule.java
...ch/pms/agent/domain/care/model/ActivityMatchUserRule.java
+35
-0
ActivitySummary.java
...nfoepoch/pms/agent/domain/care/model/ActivitySummary.java
+39
-0
CareConversationState.java
...ch/pms/agent/domain/care/model/CareConversationState.java
+114
-0
CareQuery.java
.../com/infoepoch/pms/agent/domain/care/model/CareQuery.java
+52
-0
CareTaskType.java
...m/infoepoch/pms/agent/domain/care/model/CareTaskType.java
+20
-0
PagedResult.java
...om/infoepoch/pms/agent/domain/care/model/PagedResult.java
+22
-0
UserProfileSummary.java
...epoch/pms/agent/domain/care/model/UserProfileSummary.java
+38
-0
ActivityRecommendationExecutor.java
...ain/care/orchestrator/ActivityRecommendationExecutor.java
+294
-0
CareRecommendationOrchestrator.java
...ain/care/orchestrator/CareRecommendationOrchestrator.java
+74
-0
GeneralConversationExecutor.java
...domain/care/orchestrator/GeneralConversationExecutor.java
+113
-0
UserRecommendationExecutor.java
.../domain/care/orchestrator/UserRecommendationExecutor.java
+216
-0
CareConversationStateService.java
...agent/domain/care/state/CareConversationStateService.java
+79
-0
CareStreamingResponseAssembler.java
...nt/domain/care/stream/CareStreamingResponseAssembler.java
+226
-0
CareQueryUnderstandingService.java
...ain/care/understanding/CareQueryUnderstandingService.java
+777
-0
CareAgentProperties.java
...m/infoepoch/pms/agent/properties/CareAgentProperties.java
+28
-0
CareBusinessProperties.java
...nfoepoch/pms/agent/properties/CareBusinessProperties.java
+24
-0
CurrentUserProfileTool.java
...och/pms/agent/tool/union/care/CurrentUserProfileTool.java
+32
-0
EligibleActivitiesTool.java
...och/pms/agent/tool/union/care/EligibleActivitiesTool.java
+34
-0
UserSearchTool.java
...m/infoepoch/pms/agent/tool/union/care/UserSearchTool.java
+43
-0
CareBusinessDataProvider.java
...nt/tool/union/care/provider/CareBusinessDataProvider.java
+27
-0
HttpCareBusinessDataProvider.java
...ool/union/care/provider/HttpCareBusinessDataProvider.java
+580
-0
ActivityRecommendationExecutorTest.java
...care/orchestrator/ActivityRecommendationExecutorTest.java
+132
-0
GeneralConversationExecutorTest.java
...in/care/orchestrator/GeneralConversationExecutorTest.java
+32
-0
UserRecommendationExecutorTest.java
...ain/care/orchestrator/UserRecommendationExecutorTest.java
+160
-0
CareConversationStateServiceTest.java
...t/domain/care/state/CareConversationStateServiceTest.java
+146
-0
CareQueryUnderstandingServiceTest.java
...care/understanding/CareQueryUnderstandingServiceTest.java
+330
-0
HttpCareBusinessDataProviderTest.java
...union/care/provider/HttpCareBusinessDataProviderTest.java
+136
-0
No files found.
src/main/java/com/infoepoch/pms/agent/controller/request/CareAgentChatRequest.java
0 → 100644
View file @
5435092e
package
com
.
infoepoch
.
pms
.
agent
.
controller
.
request
;
/**
* 精准关爱智能体流式会话请求。
*
* @param sessionId 会话唯一标识,用于串联短会话上下文
* @param message 用户发送给精准关爱智能体的自然语言内容
*/
public
record
CareAgentChatRequest
(
String
sessionId
,
String
message
)
{
}
src/main/java/com/infoepoch/pms/agent/domain/care/log/CareTraceLogSupport.java
0 → 100644
View file @
5435092e
package
com
.
infoepoch
.
pms
.
agent
.
domain
.
care
.
log
;
import
org.springframework.util.CollectionUtils
;
import
org.springframework.util.StringUtils
;
import
java.util.Collection
;
import
java.util.Map
;
import
java.util.StringJoiner
;
import
java.util.UUID
;
/**
* 精准关爱日志辅助
*/
public
final
class
CareTraceLogSupport
{
private
static
final
int
MAX_PREVIEW_LENGTH
=
60
;
private
CareTraceLogSupport
()
{
}
/**
* 生成单次请求唯一 traceId。
*/
public
static
String
newTraceId
()
{
return
UUID
.
randomUUID
().
toString
().
replace
(
"-"
,
""
);
}
/**
* 统一拼接日志前缀。
*/
public
static
String
format
(
String
traceId
,
String
sessionId
,
String
stage
,
String
detail
)
{
return
"[链路ID=%s] [会话ID=%s] [阶段=%s] %s"
.
formatted
(
safeValue
(
traceId
),
safeValue
(
sessionId
),
safeValue
(
stage
),
safeValue
(
detail
)
);
}
/**
* 统一拼接完整模型请求日志,保留原始换行便于排查 prompt 结构。
*/
public
static
String
formatModelPrompt
(
String
traceId
,
String
sessionId
,
String
stage
,
String
prompt
)
{
return
"[链路ID=%s] [会话ID=%s] [阶段=%s] 模型请求内容:%n%s"
.
formatted
(
safeValue
(
traceId
),
safeValue
(
sessionId
),
safeValue
(
stage
),
prompt
==
null
?
""
:
prompt
);
}
/**
* 统一拼接完整模型返回日志,保留原始换行便于排查响应结构。
*/
public
static
String
formatModelResponse
(
String
traceId
,
String
sessionId
,
String
stage
,
String
response
)
{
return
"[链路ID=%s] [会话ID=%s] [阶段=%s] 模型返回内容:%n%s"
.
formatted
(
safeValue
(
traceId
),
safeValue
(
sessionId
),
safeValue
(
stage
),
response
==
null
?
""
:
response
);
}
/**
* 统一拼接流式模型分片日志,便于查看模型逐块返回的原始内容。
*/
public
static
String
formatModelStreamChunk
(
String
traceId
,
String
sessionId
,
String
stage
,
int
index
,
String
chunk
)
{
return
"[链路ID=%s] [会话ID=%s] [阶段=%s] chunkIndex=%s, chunkLength=%s, chunk内容:%n%s"
.
formatted
(
safeValue
(
traceId
),
safeValue
(
sessionId
),
safeValue
(
stage
),
index
,
chunk
==
null
?
0
:
chunk
.
length
(),
chunk
==
null
?
""
:
chunk
);
}
/**
* 返回 prompt 的原始长度,便于判断上下文体积。
*/
public
static
int
promptLength
(
String
prompt
)
{
return
prompt
==
null
?
0
:
prompt
.
length
();
}
/**
* 生成安全的消息摘要。
*/
public
static
String
safeMessageSummary
(
String
message
)
{
if
(!
StringUtils
.
hasText
(
message
))
{
return
"长度=0"
;
}
String
normalized
=
normalizeText
(
message
);
return
"长度=%d 摘要=%s"
.
formatted
(
message
.
length
(),
abbreviate
(
normalized
));
}
/**
* 生成安全的条件摘要。
*/
public
static
String
safeConditionsSummary
(
Map
<
String
,
Object
>
conditions
)
{
if
(
conditions
==
null
||
conditions
.
isEmpty
())
{
return
"空"
;
}
StringJoiner
joiner
=
new
StringJoiner
(
", "
,
"{"
,
"}"
);
conditions
.
forEach
((
key
,
value
)
->
joiner
.
add
(
key
+
"="
+
summarizeValue
(
value
)));
return
joiner
.
toString
();
}
/**
* 生成安全的文本摘要。
*/
public
static
String
safeText
(
String
text
)
{
if
(!
StringUtils
.
hasText
(
text
))
{
return
""
;
}
return
abbreviate
(
normalizeText
(
text
));
}
/**
* 生成安全的集合条数摘要。
*/
public
static
String
safeCount
(
String
label
,
Collection
<?>
values
)
{
int
size
=
CollectionUtils
.
isEmpty
(
values
)
?
0
:
values
.
size
();
return
label
+
"="
+
size
;
}
/**
* 兼容多种对象值的安全摘要。
*/
private
static
String
summarizeValue
(
Object
value
)
{
if
(
value
==
null
)
{
return
"空"
;
}
if
(
value
instanceof
Collection
<?>
collection
)
{
return
"数量="
+
collection
.
size
();
}
if
(
value
instanceof
Map
<?,
?>
map
)
{
return
"数量="
+
map
.
size
();
}
return
abbreviate
(
normalizeText
(
value
.
toString
()));
}
/**
* 归一化文本中的连续空白。
*/
private
static
String
normalizeText
(
String
text
)
{
return
text
.
replaceAll
(
"\\s+"
,
" "
).
trim
();
}
/**
* 截断过长文本,避免日志膨胀。
*/
private
static
String
abbreviate
(
String
text
)
{
if
(!
StringUtils
.
hasText
(
text
)
||
text
.
length
()
<=
MAX_PREVIEW_LENGTH
)
{
return
text
;
}
return
text
.
substring
(
0
,
MAX_PREVIEW_LENGTH
)
+
"..."
;
}
/**
* 将空值统一显示为短横线。
*/
private
static
String
safeValue
(
String
value
)
{
return
StringUtils
.
hasText
(
value
)
?
value
:
"-"
;
}
}
src/main/java/com/infoepoch/pms/agent/domain/care/model/ActivityMatchUserRule.java
0 → 100644
View file @
5435092e
package
com
.
infoepoch
.
pms
.
agent
.
domain
.
care
.
model
;
/**
* 活动匹配用户规则。
*
* @param activityKey 活动关键词,用于匹配活动语义
* @param sex 性别编码
* @param minAge 最小年龄
* @param maxAge 最大年龄
* @param bmi BMI 分类
* @param hobby 兴趣爱好,多个值使用英文逗号拼接
* @param specialty 特长标签,多个值使用英文逗号拼接
*/
public
record
ActivityMatchUserRule
(
String
activityKey
,
String
sex
,
Integer
minAge
,
Integer
maxAge
,
String
bmi
,
String
hobby
,
String
specialty
)
{
public
ActivityMatchUserRule
{
activityKey
=
trim
(
activityKey
);
sex
=
trim
(
sex
);
bmi
=
trim
(
bmi
);
hobby
=
trim
(
hobby
);
specialty
=
trim
(
specialty
);
}
private
static
String
trim
(
String
value
)
{
return
value
==
null
?
""
:
value
.
trim
();
}
}
src/main/java/com/infoepoch/pms/agent/domain/care/model/ActivitySummary.java
0 → 100644
View file @
5435092e
package
com
.
infoepoch
.
pms
.
agent
.
domain
.
care
.
model
;
import
java.util.Collections
;
import
java.util.List
;
/**
* 活动摘要。
*
* @param activityId 活动唯一标识,用于推荐结果去重和续轮定位
* @param activityName 活动名称,供模型理解和客户端展示
* @param activityType 活动类型,如体育、健康、节日等
* @param suitableFor 活动适合人群描述,由上游接口或摘要规则提供
* @param tags 活动标签列表,用于补充活动主题特征
* @param location 活动地点或举办区域
* @param timeWindow 活动时间范围,通常为开始时间到结束时间
* @param summary 活动摘要信息,包含描述、主办方、管理员、规模等补充内容
*/
public
record
ActivitySummary
(
String
activityId
,
String
activityName
,
String
activityType
,
String
suitableFor
,
List
<
String
>
tags
,
String
location
,
String
timeWindow
,
String
summary
)
{
public
ActivitySummary
{
activityId
=
activityId
==
null
?
""
:
activityId
.
trim
();
activityName
=
activityName
==
null
?
""
:
activityName
.
trim
();
activityType
=
activityType
==
null
?
""
:
activityType
.
trim
();
suitableFor
=
suitableFor
==
null
?
""
:
suitableFor
.
trim
();
location
=
location
==
null
?
""
:
location
.
trim
();
timeWindow
=
timeWindow
==
null
?
""
:
timeWindow
.
trim
();
summary
=
summary
==
null
?
""
:
summary
.
trim
();
tags
=
tags
==
null
?
Collections
.
emptyList
()
:
List
.
copyOf
(
tags
);
}
}
src/main/java/com/infoepoch/pms/agent/domain/care/model/CareConversationState.java
0 → 100644
View file @
5435092e
package
com
.
infoepoch
.
pms
.
agent
.
domain
.
care
.
model
;
import
java.time.LocalDateTime
;
import
java.util.Collection
;
import
java.util.Collections
;
import
java.util.LinkedHashMap
;
import
java.util.LinkedHashSet
;
import
java.util.Map
;
import
java.util.Set
;
/**
* 会话短记忆。
*
* @param sessionId 会话唯一标识,由调用方透传
* @param taskType 当前会话对应的任务类型
* @param requestedCount 当前轮次希望返回的推荐数量
* @param activityIntent 当前会话识别出的活动语义或活动名称
* @param preferenceSummary 当前会话识别出的偏好摘要
* @param searchConditions 当前会话累计的筛选条件
* @param nextPageNo 推荐用户场景下下一次继续检索的分页页码
* @param deliveredIds 当前会话已经下发给客户端的活动或用户 ID 集合
* @param updatedAt 会话状态最后更新时间
*/
public
record
CareConversationState
(
String
sessionId
,
CareTaskType
taskType
,
int
requestedCount
,
String
activityIntent
,
String
preferenceSummary
,
Map
<
String
,
Object
>
searchConditions
,
int
nextPageNo
,
Set
<
String
>
deliveredIds
,
LocalDateTime
updatedAt
)
{
public
CareConversationState
{
searchConditions
=
sanitize
(
searchConditions
);
deliveredIds
=
deliveredIds
==
null
?
Collections
.
emptySet
()
:
Collections
.
unmodifiableSet
(
new
LinkedHashSet
<>(
deliveredIds
));
activityIntent
=
activityIntent
==
null
?
""
:
activityIntent
.
trim
();
preferenceSummary
=
preferenceSummary
==
null
?
""
:
preferenceSummary
.
trim
();
updatedAt
=
updatedAt
==
null
?
LocalDateTime
.
now
()
:
updatedAt
;
}
/**
* 基于当前查询创建一份初始会话状态。
*/
public
static
CareConversationState
initial
(
String
sessionId
,
CareQuery
query
)
{
return
new
CareConversationState
(
sessionId
,
query
.
taskType
(),
query
.
requestedCount
(),
query
.
activityIntent
(),
query
.
preferenceSummary
(),
query
.
searchConditions
(),
1
,
Collections
.
emptySet
(),
LocalDateTime
.
now
()
);
}
/**
* 推进会话游标,并累计本轮已经下发的推荐对象。
*/
public
CareConversationState
advance
(
int
nextPageNo
,
Collection
<
String
>
newDeliveredIds
)
{
LinkedHashSet
<
String
>
ids
=
new
LinkedHashSet
<>(
deliveredIds
);
if
(
newDeliveredIds
!=
null
)
{
ids
.
addAll
(
newDeliveredIds
);
}
return
new
CareConversationState
(
sessionId
,
taskType
,
requestedCount
,
activityIntent
,
preferenceSummary
,
searchConditions
,
nextPageNo
,
ids
,
LocalDateTime
.
now
()
);
}
/**
* 使用新的理解结果覆盖当前会话的任务语义,并重置分页与已下发记录。
*/
public
CareConversationState
rewriteFromQuery
(
CareQuery
query
)
{
return
new
CareConversationState
(
sessionId
,
query
.
taskType
(),
query
.
requestedCount
(),
query
.
activityIntent
(),
query
.
preferenceSummary
(),
query
.
searchConditions
(),
1
,
Collections
.
emptySet
(),
LocalDateTime
.
now
()
);
}
/**
* 清洗筛选条件,仅保留非空键值对。
*/
private
static
Map
<
String
,
Object
>
sanitize
(
Map
<
String
,
Object
>
searchConditions
)
{
if
(
searchConditions
==
null
||
searchConditions
.
isEmpty
())
{
return
Collections
.
emptyMap
();
}
LinkedHashMap
<
String
,
Object
>
sanitized
=
new
LinkedHashMap
<>();
searchConditions
.
forEach
((
key
,
value
)
->
{
if
(
key
!=
null
&&
value
!=
null
)
{
sanitized
.
put
(
key
,
value
);
}
});
return
Map
.
copyOf
(
sanitized
);
}
}
src/main/java/com/infoepoch/pms/agent/domain/care/model/CareQuery.java
0 → 100644
View file @
5435092e
package
com
.
infoepoch
.
pms
.
agent
.
domain
.
care
.
model
;
import
java.util.Collections
;
import
java.util.LinkedHashMap
;
import
java.util.Map
;
/**
* 用户问题理解结果。
*
* @param taskType 当前输入归属的任务类型
* @param requestedCount 本轮希望返回的推荐数量
* @param continuation 当前输入是否属于“再来一些”等续轮请求
* @param originalMessage 用户本轮原始输入
* @param activityIntent 从输入中识别出的活动语义或活动名称
* @param preferenceSummary 从输入中提取的偏好摘要
* @param searchConditions 可直接用于工具检索的筛选条件
* @param directReply 预留的直接回复内容
*/
public
record
CareQuery
(
CareTaskType
taskType
,
int
requestedCount
,
boolean
continuation
,
String
originalMessage
,
String
activityIntent
,
String
preferenceSummary
,
Map
<
String
,
Object
>
searchConditions
,
String
directReply
)
{
public
CareQuery
{
searchConditions
=
sanitize
(
searchConditions
);
activityIntent
=
activityIntent
==
null
?
""
:
activityIntent
.
trim
();
preferenceSummary
=
preferenceSummary
==
null
?
""
:
preferenceSummary
.
trim
();
directReply
=
directReply
==
null
?
""
:
directReply
.
trim
();
}
/**
* 清洗筛选条件,仅保留非空键值对。
*/
private
static
Map
<
String
,
Object
>
sanitize
(
Map
<
String
,
Object
>
searchConditions
)
{
if
(
searchConditions
==
null
||
searchConditions
.
isEmpty
())
{
return
Collections
.
emptyMap
();
}
LinkedHashMap
<
String
,
Object
>
sanitized
=
new
LinkedHashMap
<>();
searchConditions
.
forEach
((
key
,
value
)
->
{
if
(
key
!=
null
&&
value
!=
null
)
{
sanitized
.
put
(
key
,
value
);
}
});
return
Map
.
copyOf
(
sanitized
);
}
}
src/main/java/com/infoepoch/pms/agent/domain/care/model/CareTaskType.java
0 → 100644
View file @
5435092e
package
com
.
infoepoch
.
pms
.
agent
.
domain
.
care
.
model
;
/**
* 智能体任务类型
*/
public
enum
CareTaskType
{
/** 为当前用户推荐适合参加的活动。 */
RECOMMEND_ACTIVITIES
,
/** 根据活动语义推荐适合参加的用户。 */
RECOMMEND_USERS
,
/** 处理自然对话场景。 */
GENERAL_CHAT
;
/**
* 判断当前任务是否属于推荐链路。
*/
public
boolean
isRecommendation
()
{
return
this
==
RECOMMEND_ACTIVITIES
||
this
==
RECOMMEND_USERS
;
}
}
src/main/java/com/infoepoch/pms/agent/domain/care/model/PagedResult.java
0 → 100644
View file @
5435092e
package
com
.
infoepoch
.
pms
.
agent
.
domain
.
care
.
model
;
import
java.util.Collections
;
import
java.util.List
;
/**
* 分页结果。
*
* @param records 当前页记录列表
* @param total 当前查询命中的总记录数
* @param hasNext 是否存在下一页数据
*/
public
record
PagedResult
<
T
>(
List
<
T
>
records
,
long
total
,
boolean
hasNext
)
{
public
PagedResult
{
records
=
records
==
null
?
Collections
.
emptyList
()
:
List
.
copyOf
(
records
);
}
}
src/main/java/com/infoepoch/pms/agent/domain/care/model/UserProfileSummary.java
0 → 100644
View file @
5435092e
package
com
.
infoepoch
.
pms
.
agent
.
domain
.
care
.
model
;
import
java.util.Collections
;
import
java.util.List
;
/**
* 用户画像摘要。
*
* @param userId 用户唯一标识,用于推荐结果去重和续轮定位
* @param userName 用户名称,供模型理解和客户端展示
* @param age 用户年龄
* @param gender 用户性别
* @param interests 用户兴趣爱好列表
* @param tags 用户标签列表,如特长、画像标签等
* @param region 用户所属区域、部门或组织归属
* @param summary 用户补充摘要,通常包含健康指标或其他辅助说明
*/
public
record
UserProfileSummary
(
String
userId
,
String
userName
,
Integer
age
,
String
gender
,
List
<
String
>
interests
,
List
<
String
>
tags
,
String
region
,
String
summary
)
{
public
UserProfileSummary
{
userId
=
userId
==
null
?
""
:
userId
.
trim
();
userName
=
userName
==
null
?
""
:
userName
.
trim
();
gender
=
gender
==
null
?
""
:
gender
.
trim
();
region
=
region
==
null
?
""
:
region
.
trim
();
summary
=
summary
==
null
?
""
:
summary
.
trim
();
interests
=
interests
==
null
?
Collections
.
emptyList
()
:
List
.
copyOf
(
interests
);
tags
=
tags
==
null
?
Collections
.
emptyList
()
:
List
.
copyOf
(
tags
);
}
}
src/main/java/com/infoepoch/pms/agent/domain/care/orchestrator/ActivityRecommendationExecutor.java
0 → 100644
View file @
5435092e
package
com
.
infoepoch
.
pms
.
agent
.
domain
.
care
.
orchestrator
;
import
com.alibaba.cloud.ai.graph.agent.ReactAgent
;
import
com.alibaba.cloud.ai.graph.streaming.OutputType
;
import
com.alibaba.cloud.ai.graph.streaming.StreamingOutput
;
import
com.infoepoch.pms.agent.common.LogHelper
;
import
com.infoepoch.pms.agent.domain.care.log.CareTraceLogSupport
;
import
com.infoepoch.pms.agent.domain.care.model.ActivitySummary
;
import
com.infoepoch.pms.agent.domain.care.model.CareConversationState
;
import
com.infoepoch.pms.agent.domain.care.model.CareQuery
;
import
com.infoepoch.pms.agent.domain.care.model.UserProfileSummary
;
import
com.infoepoch.pms.agent.domain.care.state.CareConversationStateService
;
import
com.infoepoch.pms.agent.domain.care.stream.CareStreamingResponseAssembler
;
import
com.infoepoch.pms.agent.properties.CareAgentProperties
;
import
com.infoepoch.pms.agent.tool.union.care.CurrentUserProfileTool
;
import
com.infoepoch.pms.agent.tool.union.care.EligibleActivitiesTool
;
import
org.springframework.ai.chat.messages.AbstractMessage
;
import
org.springframework.ai.chat.messages.AssistantMessage
;
import
org.springframework.beans.factory.annotation.Qualifier
;
import
org.springframework.stereotype.Component
;
import
org.springframework.util.CollectionUtils
;
import
org.springframework.util.StringUtils
;
import
reactor.core.publisher.Flux
;
import
java.util.*
;
import
java.util.concurrent.atomic.AtomicInteger
;
/**
* 推荐活动执行器
*/
@Component
public
class
ActivityRecommendationExecutor
{
private
final
CurrentUserProfileTool
currentUserProfileTool
;
private
final
EligibleActivitiesTool
eligibleActivitiesTool
;
private
final
CareConversationStateService
conversationStateService
;
private
final
CareStreamingResponseAssembler
responseAssembler
;
private
final
CareAgentProperties
properties
;
private
final
ReactAgent
careAgent
;
public
ActivityRecommendationExecutor
(
CurrentUserProfileTool
currentUserProfileTool
,
EligibleActivitiesTool
eligibleActivitiesTool
,
CareConversationStateService
conversationStateService
,
CareStreamingResponseAssembler
responseAssembler
,
CareAgentProperties
properties
,
@Qualifier
(
"CareAgent"
)
ReactAgent
careAgent
)
{
this
.
currentUserProfileTool
=
currentUserProfileTool
;
this
.
eligibleActivitiesTool
=
eligibleActivitiesTool
;
this
.
conversationStateService
=
conversationStateService
;
this
.
responseAssembler
=
responseAssembler
;
this
.
properties
=
properties
;
this
.
careAgent
=
careAgent
;
}
/**
* 执行活动推荐:一次性拉取活动,再在本地裁剪候选后交给模型匹配输出。
*/
public
Flux
<
String
>
execute
(
String
traceId
,
String
sessionId
,
CareQuery
query
,
CareConversationState
state
)
{
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"活动推荐开始"
,
"目标数量=%s,是否续轮=%s,历史已下发=%s"
.
formatted
(
query
.
requestedCount
(),
query
.
continuation
(),
state
==
null
?
0
:
state
.
deliveredIds
().
size
()
)
));
UserProfileSummary
currentUser
=
currentUserProfileTool
.
execute
(
traceId
,
sessionId
);
Set
<
String
>
deliveredIds
=
state
==
null
?
Set
.
of
()
:
state
.
deliveredIds
();
List
<
ActivitySummary
>
allActivities
=
eligibleActivitiesTool
.
execute
(
traceId
,
sessionId
);
List
<
ActivitySummary
>
candidates
=
new
ArrayList
<>();
int
candidateLimit
=
Math
.
min
(
Math
.
max
(
properties
.
getActivityCandidateLimit
(),
query
.
requestedCount
()
*
2
),
120
);
for
(
ActivitySummary
activity
:
allActivities
)
{
if
(!
StringUtils
.
hasText
(
activity
.
activityId
()))
{
continue
;
}
if
(
deliveredIds
.
contains
(
activity
.
activityId
()))
{
continue
;
}
candidates
.
add
(
activity
);
if
(
candidates
.
size
()
>=
candidateLimit
)
{
break
;
}
}
int
deliveredCount
=
Math
.
min
(
candidates
.
size
(),
query
.
requestedCount
());
List
<
ActivitySummary
>
promptCandidates
=
candidates
.
subList
(
0
,
deliveredCount
);
Set
<
String
>
displayedActivityIds
=
collectActivityIds
(
promptCandidates
);
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"活动候选已加载"
,
"活动总数=%s,候选上限=%s,候选数量=%s,实际发送给模型=%s,用户=%s"
.
formatted
(
allActivities
.
size
(),
candidateLimit
,
candidates
.
size
(),
promptCandidates
.
size
(),
CareTraceLogSupport
.
safeText
(
currentUser
.
userName
())
)
));
if
(
candidates
.
isEmpty
())
{
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"活动推荐完成"
,
"结束原因=无候选活动"
));
return
Flux
.
just
(
responseAssembler
.
buildNoActivityTemplate
(
query
));
}
String
prompt
=
buildPrompt
(
currentUser
,
query
,
promptCandidates
);
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"模型调用开始"
,
"场景=活动推荐,候选数量=%s,prompt长度=%s,提示词摘要=%s"
.
formatted
(
promptCandidates
.
size
(),
CareTraceLogSupport
.
promptLength
(
prompt
),
CareTraceLogSupport
.
safeMessageSummary
(
prompt
)
)
));
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
formatModelPrompt
(
traceId
,
sessionId
,
"活动推荐模型请求"
,
prompt
));
try
{
StringBuilder
responseBuffer
=
new
StringBuilder
();
AtomicInteger
chunkIndex
=
new
AtomicInteger
(
0
);
return
Flux
.
concat
(
Flux
.
just
(
responseAssembler
.
buildActivityIntro
(
query
)),
careAgent
.
stream
(
prompt
)
.
filter
(
StreamingOutput
.
class
::
isInstance
)
.
map
(
StreamingOutput
.
class
::
cast
)
.
filter
(
output
->
output
.
getOutputType
()
==
OutputType
.
AGENT_MODEL_STREAMING
)
.
map
(
StreamingOutput:
:
message
)
.
filter
(
AssistantMessage
.
class
::
isInstance
)
.
map
(
AssistantMessage
.
class
::
cast
)
.
map
(
AbstractMessage:
:
getText
)
.
filter
(
Objects:
:
nonNull
)
.
doOnNext
(
chunk
->
{
chunkIndex
.
incrementAndGet
();
responseBuffer
.
append
(
chunk
);
}),
Flux
.
just
(
responseAssembler
.
buildActivityClosing
(
deliveredCount
,
query
.
requestedCount
()))
)
.
doOnComplete
(()
->
{
conversationStateService
.
save
(
resolveNextState
(
sessionId
,
query
,
state
,
displayedActivityIds
));
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
formatModelResponse
(
traceId
,
sessionId
,
"活动推荐模型完整返回"
,
responseBuffer
.
toString
()
));
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"活动推荐完成"
,
"候选数量=%s,本轮新增已下发=%s,累计已下发=%s"
.
formatted
(
promptCandidates
.
size
(),
displayedActivityIds
.
size
(),
deliveredIds
.
size
()
+
displayedActivityIds
.
size
()
)
));
})
.
doOnError
(
error
->
LogHelper
.
error
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"流式请求异常"
,
"活动推荐失败,异常="
+
CareTraceLogSupport
.
safeText
(
error
.
getMessage
())
+
",已接收chunk数=%s"
.
formatted
(
chunkIndex
.
get
())
),
error
));
}
catch
(
Exception
e
)
{
LogHelper
.
error
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"流式请求异常"
,
"活动推荐立即失败,异常="
+
CareTraceLogSupport
.
safeText
(
e
.
getMessage
())
),
e
);
return
Flux
.
error
(
new
IllegalArgumentException
(
"调用careAgent异常"
));
}
}
/**
* 生成下一轮会话状态,记录已下发活动。
*/
private
CareConversationState
resolveNextState
(
String
sessionId
,
CareQuery
query
,
CareConversationState
state
,
Set
<
String
>
deliveredIds
)
{
CareConversationState
currentState
=
state
==
null
?
CareConversationState
.
initial
(
sessionId
,
query
)
:
state
.
rewriteFromQuery
(
query
);
return
currentState
.
advance
(
1
,
deliveredIds
);
}
/**
* 提取本轮实际视为已交付的活动 ID。
*/
private
Set
<
String
>
collectActivityIds
(
List
<
ActivitySummary
>
activities
)
{
LinkedHashSet
<
String
>
ids
=
new
LinkedHashSet
<>();
for
(
ActivitySummary
activity
:
activities
)
{
if
(
StringUtils
.
hasText
(
activity
.
activityId
()))
{
ids
.
add
(
activity
.
activityId
());
}
}
return
ids
;
}
/**
* 构造活动推荐提示词,把用户画像和活动候选压缩成模型可消费的文本。
*/
private
String
buildPrompt
(
UserProfileSummary
currentUser
,
CareQuery
query
,
List
<
ActivitySummary
>
candidates
)
{
StringBuilder
builder
=
new
StringBuilder
();
builder
.
append
(
"""
你正在执行精准关爱智能体的技能1:推荐合适的活动。
请严格按照以下顺序思考:
1. 先理解用户个人信息
2. 再查看活动候选
3. 从候选中挑选最匹配的活动
只输出推荐条目,不要输出标题、开场、结尾或解释说明。
输出必须严格使用以下 Markdown 模板,每个活动一块:
1. **活动名称**
匹配理由:...
活动类型:...
时间地点:...
补充说明:...
要求:
- 只推荐真实候选活动
- 优先依据用户年龄、兴趣、标签、区域和偏好描述进行匹配
- 每个活动必须有一句简短、真实、明确的匹配理由
- 活动类型、时间地点、补充说明没有合适信息时可以省略对应行
- 不要输出整段散文,不要输出JSON,不要提技术实现
你需要推荐的数量:"""
)
.
append
(
query
.
requestedCount
())
.
append
(
'\n'
);
builder
.
append
(
"用户偏好描述:"
)
.
append
(
StringUtils
.
hasText
(
query
.
preferenceSummary
())
?
query
.
preferenceSummary
()
:
"无额外偏好"
)
.
append
(
"\n\n"
);
builder
.
append
(
"用户信息:\n"
)
.
append
(
"- 用户ID:"
).
append
(
currentUser
.
userId
()).
append
(
'\n'
)
.
append
(
"- 姓名:"
).
append
(
currentUser
.
userName
()).
append
(
'\n'
)
.
append
(
"- 年龄:"
).
append
(
currentUser
.
age
()
==
null
?
"未知"
:
currentUser
.
age
()).
append
(
'\n'
)
.
append
(
"- 性别:"
).
append
(
defaultText
(
currentUser
.
gender
(),
"未知"
)).
append
(
'\n'
)
.
append
(
"- 区域:"
).
append
(
defaultText
(
currentUser
.
region
(),
"未知"
)).
append
(
'\n'
)
.
append
(
"- 兴趣:"
).
append
(
joinOrDefault
(
currentUser
.
interests
(),
"无"
)).
append
(
'\n'
)
.
append
(
"- 标签:"
).
append
(
joinOrDefault
(
currentUser
.
tags
(),
"无"
)).
append
(
'\n'
)
.
append
(
"- 摘要:"
).
append
(
defaultText
(
currentUser
.
summary
(),
"无"
)).
append
(
'\n'
);
builder
.
append
(
"\n活动候选:\n"
);
int
index
=
1
;
for
(
ActivitySummary
activity
:
candidates
)
{
builder
.
append
(
index
++)
.
append
(
". "
)
.
append
(
activity
.
activityName
())
.
append
(
";活动ID="
).
append
(
activity
.
activityId
())
.
append
(
";类型="
).
append
(
defaultText
(
activity
.
activityType
(),
"未提供"
))
.
append
(
";适合人群="
).
append
(
defaultText
(
activity
.
suitableFor
(),
"未提供"
))
.
append
(
";地点="
).
append
(
defaultText
(
activity
.
location
(),
"未提供"
))
.
append
(
";时间="
).
append
(
defaultText
(
activity
.
timeWindow
(),
"未提供"
))
.
append
(
";标签="
).
append
(
joinOrDefault
(
activity
.
tags
(),
"无"
))
.
append
(
";摘要="
).
append
(
defaultText
(
activity
.
summary
(),
"无"
))
.
append
(
'\n'
);
}
return
builder
.
toString
();
}
/**
* 将文本字段转成默认文案,避免提示词里出现空串。
*/
private
String
defaultText
(
String
value
,
String
defaultValue
)
{
return
StringUtils
.
hasText
(
value
)
?
value
:
defaultValue
;
}
/**
* 把列表字段拼接为中文顿号分隔文本。
*/
private
String
joinOrDefault
(
List
<
String
>
values
,
String
defaultValue
)
{
if
(
CollectionUtils
.
isEmpty
(
values
))
{
return
defaultValue
;
}
return
String
.
join
(
"、"
,
values
);
}
}
src/main/java/com/infoepoch/pms/agent/domain/care/orchestrator/CareRecommendationOrchestrator.java
0 → 100644
View file @
5435092e
package
com
.
infoepoch
.
pms
.
agent
.
domain
.
care
.
orchestrator
;
import
com.infoepoch.pms.agent.common.LogHelper
;
import
com.infoepoch.pms.agent.domain.care.log.CareTraceLogSupport
;
import
com.infoepoch.pms.agent.domain.care.model.CareConversationState
;
import
com.infoepoch.pms.agent.domain.care.model.CareQuery
;
import
com.infoepoch.pms.agent.domain.care.model.CareTaskType
;
import
com.infoepoch.pms.agent.domain.care.state.CareConversationStateService
;
import
com.infoepoch.pms.agent.domain.care.understanding.CareQueryUnderstandingService
;
import
org.springframework.stereotype.Component
;
import
reactor.core.publisher.Flux
;
/**
* 推荐编排入口
*/
@Component
public
class
CareRecommendationOrchestrator
{
private
final
CareQueryUnderstandingService
understandingService
;
private
final
CareConversationStateService
conversationStateService
;
private
final
ActivityRecommendationExecutor
activityRecommendationExecutor
;
private
final
UserRecommendationExecutor
userRecommendationExecutor
;
private
final
GeneralConversationExecutor
generalConversationExecutor
;
public
CareRecommendationOrchestrator
(
CareQueryUnderstandingService
understandingService
,
CareConversationStateService
conversationStateService
,
ActivityRecommendationExecutor
activityRecommendationExecutor
,
UserRecommendationExecutor
userRecommendationExecutor
,
GeneralConversationExecutor
generalConversationExecutor
)
{
this
.
understandingService
=
understandingService
;
this
.
conversationStateService
=
conversationStateService
;
this
.
activityRecommendationExecutor
=
activityRecommendationExecutor
;
this
.
userRecommendationExecutor
=
userRecommendationExecutor
;
this
.
generalConversationExecutor
=
generalConversationExecutor
;
}
/**
* 统一编排推荐请求:理解用户意图后分发到具体执行器。
*/
public
Flux
<
String
>
stream
(
String
traceId
,
String
sessionId
,
String
message
)
{
CareConversationState
currentState
=
conversationStateService
.
load
(
sessionId
).
orElse
(
null
);
CareQuery
query
=
understandingService
.
understand
(
traceId
,
sessionId
,
message
,
currentState
);
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"路由分发结果"
,
"任务类型=%s,是否续轮=%s"
.
formatted
(
toChineseTaskType
(
query
.
taskType
()),
query
.
continuation
()
)
));
if
(
query
.
taskType
()
==
CareTaskType
.
GENERAL_CHAT
)
{
return
generalConversationExecutor
.
execute
(
traceId
,
sessionId
,
query
);
}
if
(
query
.
taskType
()
==
CareTaskType
.
RECOMMEND_USERS
)
{
return
userRecommendationExecutor
.
execute
(
traceId
,
sessionId
,
query
,
currentState
);
}
return
activityRecommendationExecutor
.
execute
(
traceId
,
sessionId
,
query
,
currentState
);
}
/**
* 将任务类型转换成中文展示。
*/
private
String
toChineseTaskType
(
CareTaskType
taskType
)
{
if
(
taskType
==
null
)
{
return
"未知"
;
}
return
switch
(
taskType
)
{
case
RECOMMEND_ACTIVITIES
->
"推荐活动"
;
case
RECOMMEND_USERS
->
"推荐用户"
;
case
GENERAL_CHAT
->
"通用对话"
;
};
}
}
src/main/java/com/infoepoch/pms/agent/domain/care/orchestrator/GeneralConversationExecutor.java
0 → 100644
View file @
5435092e
package
com
.
infoepoch
.
pms
.
agent
.
domain
.
care
.
orchestrator
;
import
com.alibaba.cloud.ai.graph.agent.ReactAgent
;
import
com.alibaba.cloud.ai.graph.streaming.OutputType
;
import
com.alibaba.cloud.ai.graph.streaming.StreamingOutput
;
import
com.infoepoch.pms.agent.common.LogHelper
;
import
com.infoepoch.pms.agent.domain.care.log.CareTraceLogSupport
;
import
com.infoepoch.pms.agent.domain.care.model.CareQuery
;
import
com.infoepoch.pms.agent.domain.care.stream.CareStreamingResponseAssembler
;
import
org.springframework.ai.chat.messages.AbstractMessage
;
import
org.springframework.ai.chat.messages.AssistantMessage
;
import
org.springframework.beans.factory.annotation.Qualifier
;
import
org.springframework.stereotype.Component
;
import
org.springframework.util.StringUtils
;
import
reactor.core.publisher.Flux
;
import
java.util.concurrent.atomic.AtomicInteger
;
/**
* 通用对话执行器
*/
@Component
public
class
GeneralConversationExecutor
{
private
final
CareStreamingResponseAssembler
responseAssembler
;
private
final
ReactAgent
careAgent
;
public
GeneralConversationExecutor
(
CareStreamingResponseAssembler
responseAssembler
,
@Qualifier
(
"CareAgent"
)
ReactAgent
careAgent
)
{
this
.
responseAssembler
=
responseAssembler
;
this
.
careAgent
=
careAgent
;
}
/**
* 执行通用对话。
*/
public
Flux
<
String
>
execute
(
String
traceId
,
String
sessionId
,
CareQuery
query
)
{
String
prompt
=
query
.
originalMessage
();
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"通用对话开始"
,
"用户输入=%s"
.
formatted
(
CareTraceLogSupport
.
safeMessageSummary
(
query
.
originalMessage
()))
));
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"模型调用开始"
,
"场景=通用对话,prompt长度=%s,提示词摘要=%s"
.
formatted
(
CareTraceLogSupport
.
promptLength
(
prompt
),
CareTraceLogSupport
.
safeMessageSummary
(
prompt
)
)
));
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
formatModelPrompt
(
traceId
,
sessionId
,
"通用对话模型请求"
,
prompt
));
try
{
StringBuilder
responseBuffer
=
new
StringBuilder
();
AtomicInteger
chunkIndex
=
new
AtomicInteger
(
0
);
return
careAgent
.
stream
(
prompt
)
.
filter
(
StreamingOutput
.
class
::
isInstance
)
.
map
(
StreamingOutput
.
class
::
cast
)
.
filter
(
output
->
output
.
getOutputType
()
==
OutputType
.
AGENT_MODEL_STREAMING
)
.
map
(
StreamingOutput:
:
message
)
.
filter
(
AssistantMessage
.
class
::
isInstance
)
.
map
(
AssistantMessage
.
class
::
cast
)
.
map
(
AbstractMessage:
:
getText
)
.
filter
(
StringUtils:
:
hasText
)
.
switchIfEmpty
(
Flux
.
defer
(()
->
{
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"通用对话兜底"
,
"模型未返回有效内容"
));
return
Flux
.
just
(
responseAssembler
.
buildGeneralFallbackReply
());
}))
.
doOnComplete
(()
->
{
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
formatModelResponse
(
traceId
,
sessionId
,
"通用对话模型完整返回"
,
responseBuffer
.
toString
()
));
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"通用对话完成"
,
"通用对话处理完成"
));
})
.
doOnError
(
error
->
LogHelper
.
error
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"流式请求异常"
,
"通用对话失败,异常="
+
CareTraceLogSupport
.
safeText
(
error
.
getMessage
())
+
",已接收chunk数=%s"
.
formatted
(
chunkIndex
.
get
())
),
error
));
}
catch
(
Exception
e
)
{
LogHelper
.
error
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"流式请求异常"
,
"通用对话立即失败,异常="
+
CareTraceLogSupport
.
safeText
(
e
.
getMessage
())
),
e
);
return
Flux
.
error
(
new
IllegalArgumentException
(
"调用模型失败"
));
}
}
}
src/main/java/com/infoepoch/pms/agent/domain/care/orchestrator/UserRecommendationExecutor.java
0 → 100644
View file @
5435092e
package
com
.
infoepoch
.
pms
.
agent
.
domain
.
care
.
orchestrator
;
import
com.infoepoch.pms.agent.common.LogHelper
;
import
com.infoepoch.pms.agent.domain.care.log.CareTraceLogSupport
;
import
com.infoepoch.pms.agent.domain.care.model.CareConversationState
;
import
com.infoepoch.pms.agent.domain.care.model.CareQuery
;
import
com.infoepoch.pms.agent.domain.care.model.PagedResult
;
import
com.infoepoch.pms.agent.domain.care.model.UserProfileSummary
;
import
com.infoepoch.pms.agent.domain.care.state.CareConversationStateService
;
import
com.infoepoch.pms.agent.domain.care.stream.CareStreamingResponseAssembler
;
import
com.infoepoch.pms.agent.properties.CareAgentProperties
;
import
com.infoepoch.pms.agent.tool.union.care.UserSearchTool
;
import
org.springframework.stereotype.Component
;
import
reactor.core.publisher.Flux
;
import
reactor.core.publisher.Mono
;
import
java.time.Duration
;
import
java.util.ArrayList
;
import
java.util.LinkedHashSet
;
import
java.util.List
;
/**
* 推荐用户执行器
*/
@Component
public
class
UserRecommendationExecutor
{
private
final
UserSearchTool
userSearchTool
;
private
final
CareConversationStateService
conversationStateService
;
private
final
CareStreamingResponseAssembler
responseAssembler
;
private
final
CareAgentProperties
properties
;
public
UserRecommendationExecutor
(
UserSearchTool
userSearchTool
,
CareConversationStateService
conversationStateService
,
CareStreamingResponseAssembler
responseAssembler
,
CareAgentProperties
properties
)
{
this
.
userSearchTool
=
userSearchTool
;
this
.
conversationStateService
=
conversationStateService
;
this
.
responseAssembler
=
responseAssembler
;
this
.
properties
=
properties
;
}
/**
* 执行用户推荐:按页拉取、去重、累计并持续输出。
*/
public
Flux
<
String
>
execute
(
String
traceId
,
String
sessionId
,
CareQuery
query
,
CareConversationState
state
)
{
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"用户推荐开始"
,
"目标数量=%s,是否续轮=%s,筛选条件=%s"
.
formatted
(
query
.
requestedCount
(),
query
.
continuation
(),
CareTraceLogSupport
.
safeConditionsSummary
(
query
.
searchConditions
())
)
));
try
{
UserRecommendationPlan
plan
=
prepareUserRecommendationPlan
(
traceId
,
sessionId
,
query
,
state
);
Duration
itemDelay
=
Duration
.
ofMillis
(
Math
.
max
(
0
,
properties
.
getUserStreamItemDelayMillis
()));
CareConversationState
currentState
=
state
==
null
?
CareConversationState
.
initial
(
sessionId
,
query
)
:
state
.
rewriteFromQuery
(
query
);
CareConversationState
nextState
=
currentState
.
advance
(
plan
.
nextPageNo
(),
plan
.
deliveredIds
());
return
Flux
.
concat
(
Flux
.
just
(
responseAssembler
.
buildUserIntro
(
query
)),
buildDelayedUserItemsFlux
(
plan
.
userChunks
(),
itemDelay
),
buildClosingFlux
(
responseAssembler
.
buildUserClosing
(
plan
.
emittedCount
(),
query
.
requestedCount
()),
itemDelay
,
!
plan
.
userChunks
().
isEmpty
()
)
)
.
doOnComplete
(()
->
{
conversationStateService
.
save
(
nextState
);
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"用户推荐完成"
,
"输出数量=%s,累计已下发=%s,下一页=%s,结束原因=%s"
.
formatted
(
plan
.
emittedCount
(),
plan
.
deliveredIds
().
size
(),
plan
.
nextPageNo
(),
plan
.
stopReason
()
)
));
})
.
doOnError
(
error
->
LogHelper
.
error
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"流式请求异常"
,
"用户推荐失败,异常="
+
CareTraceLogSupport
.
safeText
(
error
.
getMessage
())
),
error
));
}
catch
(
Exception
exception
)
{
LogHelper
.
error
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"流式请求异常"
,
"用户推荐失败,异常="
+
CareTraceLogSupport
.
safeText
(
exception
.
getMessage
())
),
exception
);
return
Flux
.
error
(
new
IllegalArgumentException
(
"用户推荐过程中出现异常"
));
}
}
/**
* 同步完成分页检索与去重,生成本轮输出计划。
*/
private
UserRecommendationPlan
prepareUserRecommendationPlan
(
String
traceId
,
String
sessionId
,
CareQuery
query
,
CareConversationState
state
)
{
int
nextPageNo
=
state
!=
null
&&
query
.
continuation
()
?
state
.
nextPageNo
()
:
1
;
LinkedHashSet
<
String
>
deliveredIds
=
state
==
null
?
new
LinkedHashSet
<>()
:
new
LinkedHashSet
<>(
state
.
deliveredIds
());
List
<
String
>
userChunks
=
new
ArrayList
<>();
int
emittedCount
=
0
;
String
stopReason
=
"正常完成"
;
while
(
emittedCount
<
query
.
requestedCount
())
{
PagedResult
<
UserProfileSummary
>
page
=
userSearchTool
.
execute
(
traceId
,
sessionId
,
query
.
searchConditions
(),
nextPageNo
,
properties
.
getUserPageSize
()
);
if
(
page
.
records
().
isEmpty
())
{
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"用户分页已加载"
,
"页码=%s,每页数量=%s,返回数量=0,总数=%s"
.
formatted
(
nextPageNo
,
properties
.
getUserPageSize
(),
page
.
total
()
)
));
stopReason
=
"空页结束"
;
break
;
}
List
<
UserProfileSummary
>
freshUsers
=
page
.
records
().
stream
()
.
filter
(
user
->
!
deliveredIds
.
contains
(
user
.
userId
()))
.
limit
(
query
.
requestedCount
()
-
emittedCount
)
.
toList
();
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"用户分页已加载"
,
"页码=%s,每页数量=%s,返回数量=%s,去重后新增=%s,总数=%s,输出前累计=%s"
.
formatted
(
nextPageNo
,
properties
.
getUserPageSize
(),
page
.
records
().
size
(),
freshUsers
.
size
(),
page
.
total
(),
emittedCount
)
));
userChunks
.
addAll
(
responseAssembler
.
buildUserItems
(
freshUsers
,
emittedCount
+
1
,
query
));
for
(
UserProfileSummary
user
:
freshUsers
)
{
deliveredIds
.
add
(
user
.
userId
());
}
emittedCount
+=
freshUsers
.
size
();
nextPageNo
++;
if
(
emittedCount
>=
query
.
requestedCount
())
{
stopReason
=
"达到目标数量"
;
break
;
}
if
(!
page
.
hasNext
())
{
stopReason
=
"无下一页"
;
break
;
}
}
return
new
UserRecommendationPlan
(
userChunks
,
emittedCount
,
nextPageNo
,
deliveredIds
,
stopReason
);
}
/**
* 将用户推荐条目转换成逐条延迟输出的假流式。
*/
private
Flux
<
String
>
buildDelayedUserItemsFlux
(
List
<
String
>
userChunks
,
Duration
itemDelay
)
{
if
(
userChunks
.
isEmpty
())
{
return
Flux
.
empty
();
}
if
(
itemDelay
.
isZero
()
||
itemDelay
.
isNegative
())
{
return
Flux
.
fromIterable
(
userChunks
);
}
return
Flux
.
fromIterable
(
userChunks
)
.
concatMap
(
chunk
->
Mono
.
just
(
chunk
).
delayElement
(
itemDelay
));
}
/**
* 生成收尾文案,若已输出用户则沿用同样节奏延后显示。
*/
private
Flux
<
String
>
buildClosingFlux
(
String
closing
,
Duration
itemDelay
,
boolean
hasUserChunks
)
{
if
(!
hasUserChunks
||
itemDelay
.
isZero
()
||
itemDelay
.
isNegative
())
{
return
Flux
.
just
(
closing
);
}
return
Mono
.
just
(
closing
)
.
delaySubscription
(
itemDelay
)
.
flux
();
}
private
record
UserRecommendationPlan
(
List
<
String
>
userChunks
,
int
emittedCount
,
int
nextPageNo
,
LinkedHashSet
<
String
>
deliveredIds
,
String
stopReason
)
{
}
}
src/main/java/com/infoepoch/pms/agent/domain/care/state/CareConversationStateService.java
0 → 100644
View file @
5435092e
package
com
.
infoepoch
.
pms
.
agent
.
domain
.
care
.
state
;
import
com.infoepoch.pms.agent.common.LogHelper
;
import
com.infoepoch.pms.agent.config.JsonUtils
;
import
com.infoepoch.pms.agent.domain.care.model.CareConversationState
;
import
com.infoepoch.pms.agent.properties.CareAgentProperties
;
import
lombok.RequiredArgsConstructor
;
import
org.springframework.data.redis.core.RedisTemplate
;
import
org.springframework.stereotype.Service
;
import
org.springframework.util.StringUtils
;
import
java.time.Duration
;
import
java.util.Optional
;
/**
* 会话状态缓存
*/
@Service
@RequiredArgsConstructor
public
class
CareConversationStateService
{
private
static
final
String
CARE_STATE_KEY_PREFIX
=
"care:state:"
;
private
final
RedisTemplate
<
String
,
Object
>
redisTemplate
;
private
final
CareAgentProperties
properties
;
/**
* 按 sessionId 读取短会话状态。
*/
public
Optional
<
CareConversationState
>
load
(
String
sessionId
)
{
if
(!
StringUtils
.
hasText
(
sessionId
))
{
return
Optional
.
empty
();
}
Object
cached
=
redisTemplate
.
opsForValue
().
get
(
buildKey
(
sessionId
));
if
(
cached
==
null
)
{
return
Optional
.
empty
();
}
String
cachedType
=
cached
.
getClass
().
getName
();
try
{
String
json
=
JsonUtils
.
objectToJson
(
cached
);
if
(!
StringUtils
.
hasText
(
json
))
{
LogHelper
.
error
(
this
,
"读取会话状态转换失败,sessionId={},cachedType={},原因=对象转JSON为空"
,
sessionId
,
cachedType
);
return
Optional
.
empty
();
}
CareConversationState
state
=
JsonUtils
.
jsonToObject
(
json
,
CareConversationState
.
class
);
return
Optional
.
ofNullable
(
state
);
}
catch
(
RuntimeException
exception
)
{
LogHelper
.
error
(
this
,
"读取会话状态转换失败,sessionId={},cachedType={},异常={}"
,
sessionId
,
cachedType
,
exception
.
getMessage
()
);
return
Optional
.
empty
();
}
}
/**
* 写入短会话状态并刷新 TTL。
*/
public
void
save
(
CareConversationState
state
)
{
if
(
state
==
null
||
!
StringUtils
.
hasText
(
state
.
sessionId
()))
{
return
;
}
redisTemplate
.
opsForValue
().
set
(
buildKey
(
state
.
sessionId
()),
state
,
Duration
.
ofMinutes
(
properties
.
getConversationTtlMinutes
())
);
}
/**
* 生成 Redis key。
*/
private
String
buildKey
(
String
sessionId
)
{
return
CARE_STATE_KEY_PREFIX
+
sessionId
;
}
}
src/main/java/com/infoepoch/pms/agent/domain/care/stream/CareStreamingResponseAssembler.java
0 → 100644
View file @
5435092e
package
com
.
infoepoch
.
pms
.
agent
.
domain
.
care
.
stream
;
import
com.infoepoch.pms.agent.domain.care.model.CareQuery
;
import
com.infoepoch.pms.agent.domain.care.model.CareTaskType
;
import
com.infoepoch.pms.agent.domain.care.model.UserProfileSummary
;
import
org.springframework.stereotype.Component
;
import
org.springframework.util.CollectionUtils
;
import
org.springframework.util.StringUtils
;
import
java.util.ArrayList
;
import
java.util.List
;
import
java.util.StringJoiner
;
/**
* 用户可见文案组装
*/
@Component
public
class
CareStreamingResponseAssembler
{
/**
* 将直接回复转换成可直接流式返回的文本片段。
*/
public
List
<
String
>
directReplyChunks
(
String
message
)
{
return
List
.
of
(
ensureTrailingLineBreak
(
message
));
}
/**
* 生成通用对话场景的兜底回复。
*/
public
String
buildGeneralFallbackReply
()
{
return
"""
你好,我是精准关爱智能体。
请换一种说法,或者补充更多信息后再试一次。
"""
;
}
/**
* 生成活动推荐场景的标题和开场文案。
*/
public
String
buildActivityIntro
(
CareQuery
query
)
{
if
(
query
.
continuation
())
{
return
"""
### 活动推荐
我继续为你补充更匹配的活动:
"""
;
}
return
"""
### 活动推荐
已为你整理出以下更匹配的活动:
"""
;
}
/**
* 生成活动推荐场景的收尾文案。
*/
public
String
buildActivityClosing
(
int
deliveredCount
,
int
requestedCount
)
{
if
(
deliveredCount
<=
0
)
{
return
"暂时没有查询到可推荐的活动,建议稍后重试或补充更具体的偏好条件。\n"
;
}
if
(
deliveredCount
<
requestedCount
)
{
return
"\n目前先为你整理到这些更匹配的活动,如需继续收窄偏好,也可以直接告诉我。\n"
;
}
return
"\n如需继续推荐更多活动,可以直接说“再来一些”。\n"
;
}
/**
* 生成用户推荐场景的标题和开场文案。
*/
public
String
buildUserIntro
(
CareQuery
query
)
{
if
(
query
.
continuation
())
{
return
"""
### 用户推荐
我继续为你补充更匹配的用户:
"""
;
}
if
(
StringUtils
.
hasText
(
query
.
activityIntent
()))
{
return
"""
### 用户推荐
已为你整理出以下和“%s”更匹配的用户:
"""
.
formatted
(
query
.
activityIntent
());
}
return
"""
### 用户推荐
已为你整理出以下更匹配的用户:
"""
;
}
/**
* 把用户列表转换成连续编号的 Markdown 展示文案。
*/
public
List
<
String
>
buildUserItems
(
List
<
UserProfileSummary
>
users
,
int
startIndex
,
CareQuery
query
)
{
List
<
String
>
lines
=
new
ArrayList
<>();
int
counter
=
startIndex
;
for
(
UserProfileSummary
user
:
users
)
{
lines
.
add
(
formatUserLine
(
counter
++,
user
,
query
));
}
return
lines
;
}
/**
* 生成用户推荐场景的收尾文案。
*/
public
String
buildUserClosing
(
int
deliveredCount
,
int
requestedCount
)
{
if
(
deliveredCount
<=
0
)
{
return
"暂时没有整理到符合条件的用户,建议补充更具体的活动描述或筛选条件。\n"
;
}
if
(
deliveredCount
<
requestedCount
)
{
return
"\n目前先为你整理到这些更匹配的用户,如需继续收窄条件,也可以直接告诉我。\n"
;
}
return
"\n如需继续补充更多用户,可以直接说“再来一些”。\n"
;
}
/**
* 生成活动候选为空时的兜底提示。
*/
public
String
buildNoActivityDataMessage
()
{
return
"暂时没有查询到可推荐的活动,建议稍后重试或补充更具体的偏好条件。\n"
;
}
/**
* 生成按任务类型区分的通用错误文案。
*/
public
String
buildGenericErrorMessage
(
CareTaskType
taskType
)
{
if
(
taskType
==
CareTaskType
.
RECOMMEND_USERS
)
{
return
"用户推荐过程中出现异常,请稍后重试。\n"
;
}
if
(
taskType
==
CareTaskType
.
RECOMMEND_ACTIVITIES
)
{
return
"活动推荐过程中出现异常,请稍后重试。\n"
;
}
return
"智能体回复过程中出现异常,请稍后重试。\n"
;
}
/**
* 生成活动推荐条目为空时的模板化兜底提示。
*/
public
String
buildNoActivityTemplate
(
CareQuery
query
)
{
return
buildActivityIntro
(
query
)
+
buildNoActivityDataMessage
();
}
/**
* 将单个用户格式化成 Markdown 推荐条目。
*/
private
String
formatUserLine
(
int
index
,
UserProfileSummary
user
,
CareQuery
query
)
{
StringBuilder
builder
=
new
StringBuilder
();
builder
.
append
(
index
)
.
append
(
". **"
)
.
append
(
resolveDisplayName
(
user
))
.
append
(
"**\n"
);
String
basicInfo
=
buildUserBasicInfo
(
user
);
if
(
StringUtils
.
hasText
(
basicInfo
))
{
builder
.
append
(
" 基本信息:"
).
append
(
basicInfo
).
append
(
'\n'
);
}
builder
.
append
(
" 匹配理由:"
).
append
(
buildUserReason
(
user
,
query
)).
append
(
'\n'
);
if
(
StringUtils
.
hasText
(
user
.
summary
()))
{
builder
.
append
(
" 补充说明:"
).
append
(
user
.
summary
()).
append
(
'\n'
);
}
builder
.
append
(
'\n'
);
return
builder
.
toString
();
}
/**
* 生成用户展示名称。
*/
private
String
resolveDisplayName
(
UserProfileSummary
user
)
{
if
(
StringUtils
.
hasText
(
user
.
userName
()))
{
return
user
.
userName
();
}
if
(
StringUtils
.
hasText
(
user
.
userId
()))
{
return
"用户"
+
user
.
userId
();
}
return
"未命名用户"
;
}
/**
* 汇总用户基础信息为一行展示文本。
*/
private
String
buildUserBasicInfo
(
UserProfileSummary
user
)
{
StringJoiner
joiner
=
new
StringJoiner
(
" / "
);
if
(
user
.
age
()
!=
null
)
{
joiner
.
add
(
user
.
age
()
+
"岁"
);
}
if
(
StringUtils
.
hasText
(
user
.
gender
()))
{
joiner
.
add
(
user
.
gender
());
}
if
(
StringUtils
.
hasText
(
user
.
region
()))
{
joiner
.
add
(
user
.
region
());
}
return
joiner
.
toString
();
}
/**
* 生成用户推荐条目的匹配理由。
*/
private
String
buildUserReason
(
UserProfileSummary
user
,
CareQuery
query
)
{
List
<
String
>
reasonParts
=
new
ArrayList
<>();
String
target
=
StringUtils
.
hasText
(
query
.
activityIntent
())
?
"“"
+
query
.
activityIntent
()
+
"”"
:
"当前活动需求"
;
reasonParts
.
add
(
"结合"
+
target
+
"的需求,该用户与当前筛选方向较为匹配"
);
if
(!
CollectionUtils
.
isEmpty
(
user
.
interests
()))
{
reasonParts
.
add
(
"兴趣偏好为"
+
String
.
join
(
"、"
,
user
.
interests
()));
}
if
(!
CollectionUtils
.
isEmpty
(
user
.
tags
()))
{
reasonParts
.
add
(
"特征标签为"
+
String
.
join
(
"、"
,
user
.
tags
()));
}
else
if
(
StringUtils
.
hasText
(
user
.
region
()))
{
reasonParts
.
add
(
"所在区域为"
+
user
.
region
());
}
return
String
.
join
(
","
,
reasonParts
)
+
"。"
;
}
/**
* 确保返回给前端的片段以换行结束,便于连续阅读。
*/
private
String
ensureTrailingLineBreak
(
String
message
)
{
if
(!
StringUtils
.
hasText
(
message
))
{
return
"\n"
;
}
return
message
.
endsWith
(
"\n"
)
?
message
:
message
+
"\n"
;
}
}
src/main/java/com/infoepoch/pms/agent/domain/care/understanding/CareQueryUnderstandingService.java
0 → 100644
View file @
5435092e
package
com
.
infoepoch
.
pms
.
agent
.
domain
.
care
.
understanding
;
import
com.fasterxml.jackson.annotation.JsonIgnoreProperties
;
import
com.infoepoch.pms.agent.common.LogHelper
;
import
com.infoepoch.pms.agent.config.JsonUtils
;
import
com.infoepoch.pms.agent.domain.care.model.ActivityMatchUserRule
;
import
com.infoepoch.pms.agent.domain.care.log.CareTraceLogSupport
;
import
com.infoepoch.pms.agent.domain.care.model.CareConversationState
;
import
com.infoepoch.pms.agent.domain.care.model.CareQuery
;
import
com.infoepoch.pms.agent.domain.care.model.CareTaskType
;
import
com.infoepoch.pms.agent.properties.CareAgentProperties
;
import
com.infoepoch.pms.agent.tool.union.care.provider.CareBusinessDataProvider
;
import
org.springframework.ai.chat.model.ChatModel
;
import
org.springframework.beans.factory.annotation.Qualifier
;
import
org.springframework.stereotype.Service
;
import
org.springframework.util.StringUtils
;
import
java.util.ArrayList
;
import
java.util.Collections
;
import
java.util.LinkedHashMap
;
import
java.util.LinkedHashSet
;
import
java.util.List
;
import
java.util.Locale
;
import
java.util.Map
;
import
java.util.Optional
;
import
java.util.Set
;
import
java.util.regex.Matcher
;
import
java.util.regex.Pattern
;
/**
* 自然语言理解
*/
@Service
public
class
CareQueryUnderstandingService
{
private
static
final
String
ACTIVITY_SUFFIX
=
"活动"
;
private
static
final
List
<
Pattern
>
EXPLICIT_COUNT_PATTERNS
=
List
.
of
(
Pattern
.
compile
(
"(?:推荐|来|给我|帮我|请|麻烦)(?:[^\\d]{0,8}?)(\\d{1,4})\\s*(个|位|条|名|人)"
),
Pattern
.
compile
(
"(\\d{1,4})\\s*(个|位|条|名|人)(?:活动|用户|推荐)"
)
);
private
final
ChatModel
chatModel
;
private
final
CareAgentProperties
properties
;
private
final
CareBusinessDataProvider
careBusinessDataProvider
;
public
CareQueryUnderstandingService
(
@Qualifier
(
"SiliconFlowChatModel"
)
ChatModel
chatModel
,
CareAgentProperties
properties
,
CareBusinessDataProvider
careBusinessDataProvider
)
{
this
.
chatModel
=
chatModel
;
this
.
properties
=
properties
;
this
.
careBusinessDataProvider
=
careBusinessDataProvider
;
}
/**
* 将用户自然语言转换成统一的推荐查询对象。
*/
public
CareQuery
understand
(
String
traceId
,
String
sessionId
,
String
message
,
CareConversationState
state
)
{
//去除首尾空格
String
normalizedMessage
=
normalize
(
message
);
//提取用户输入中的数量 例如请帮我推荐400个用户参加健步走活动->400
int
requestedCount
=
resolveRequestedCount
(
normalizedMessage
,
state
);
//判断是否是轮续请求 再来一些|继续
boolean
continuation
=
isContinuation
(
normalizedMessage
);
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"意图识别开始"
,
"输入摘要=%s,存在会话状态=%s,是否续轮=%s"
.
formatted
(
CareTraceLogSupport
.
safeMessageSummary
(
normalizedMessage
),
state
!=
null
,
continuation
)
));
if
(
isRecommendationContinuation
(
continuation
,
state
)
&&
!
hasSubstantiveContentAfterContinuation
(
normalizedMessage
))
{
CareQuery
query
=
new
CareQuery
(
state
.
taskType
(),
requestedCount
,
true
,
normalizedMessage
,
state
.
activityIntent
(),
state
.
preferenceSummary
(),
state
.
searchConditions
(),
""
);
logIntentResult
(
traceId
,
sessionId
,
"沿用会话状态"
,
query
);
return
query
;
}
CareTaskType
taskType
=
resolveTaskType
(
traceId
,
sessionId
,
normalizedMessage
,
state
,
continuation
);
if
(
taskType
==
CareTaskType
.
GENERAL_CHAT
)
{
CareQuery
query
=
new
CareQuery
(
taskType
,
requestedCount
,
false
,
normalizedMessage
,
""
,
normalizedMessage
,
Collections
.
emptyMap
(),
""
);
logIntentResult
(
traceId
,
sessionId
,
"模型判定"
,
query
);
return
query
;
}
//意图识别请求
ModelUnderstandingResult
modelResult
=
callModel
(
traceId
,
sessionId
,
normalizedMessage
,
taskType
,
state
);
boolean
canReuseStateUnderstanding
=
canReuseStateUnderstanding
(
state
,
taskType
);
String
activityIntent
=
StringUtils
.
hasText
(
modelResult
.
activityIntent
())
?
modelResult
.
activityIntent
().
trim
()
:
canReuseStateUnderstanding
?
state
.
activityIntent
()
:
""
;
String
preferenceSummary
=
StringUtils
.
hasText
(
modelResult
.
preferenceSummary
())
?
modelResult
.
preferenceSummary
().
trim
()
:
normalizedMessage
;
Map
<
String
,
Object
>
searchConditions
=
mergeConditions
(
state
,
taskType
,
normalizeSearchConditions
(
modelResult
.
searchConditions
()),
continuation
);
if
(
taskType
==
CareTaskType
.
RECOMMEND_USERS
)
{
searchConditions
=
enrichRecommendUserConditions
(
traceId
,
sessionId
,
activityIntent
,
searchConditions
);
}
CareQuery
query
=
new
CareQuery
(
taskType
,
requestedCount
,
continuation
,
normalizedMessage
,
activityIntent
,
preferenceSummary
,
searchConditions
,
""
);
logIntentResult
(
traceId
,
sessionId
,
"模型理解"
,
query
);
return
query
;
}
/**
* 根据活动语义匹配规则,补全推荐用户检索条件。
*/
private
Map
<
String
,
Object
>
enrichRecommendUserConditions
(
String
traceId
,
String
sessionId
,
String
activityIntent
,
Map
<
String
,
Object
>
searchConditions
)
{
if
(!
StringUtils
.
hasText
(
activityIntent
))
{
return
searchConditions
;
}
try
{
List
<
ActivityMatchUserRule
>
rules
=
careBusinessDataProvider
.
listActivityMatchUserRules
(
traceId
,
sessionId
);
ActivityMatchUserRule
matchedRule
=
selectBestMatchingRule
(
activityIntent
,
rules
);
if
(
matchedRule
==
null
)
{
return
searchConditions
;
}
LinkedHashMap
<
String
,
Object
>
merged
=
new
LinkedHashMap
<>(
searchConditions
);
putIfAbsent
(
merged
,
"sex"
,
normalizeSex
(
matchedRule
.
sex
()));
putIfAbsent
(
merged
,
"minAge"
,
matchedRule
.
minAge
());
putIfAbsent
(
merged
,
"maxAge"
,
matchedRule
.
maxAge
());
mergeCsvCondition
(
merged
,
"bmi"
,
matchedRule
.
bmi
());
mergeCsvCondition
(
merged
,
"hobby"
,
matchedRule
.
hobby
());
mergeCsvCondition
(
merged
,
"specialty"
,
matchedRule
.
specialty
());
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"活动规则补全完成"
,
"活动语义=%s,命中规则=%s,补全后条件=%s"
.
formatted
(
CareTraceLogSupport
.
safeText
(
activityIntent
),
CareTraceLogSupport
.
safeText
(
matchedRule
.
activityKey
()),
CareTraceLogSupport
.
safeConditionsSummary
(
merged
)
)
));
return
merged
;
}
catch
(
RuntimeException
exception
)
{
LogHelper
.
error
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"活动规则补全失败"
,
"活动语义=%s,降级为模型条件,异常=%s"
.
formatted
(
CareTraceLogSupport
.
safeText
(
activityIntent
),
CareTraceLogSupport
.
safeText
(
exception
.
getMessage
())
)
),
exception
);
return
searchConditions
;
}
}
/**
* 清理消息首尾空白,保持后续处理稳定。
*/
private
String
normalize
(
String
message
)
{
return
message
==
null
?
""
:
message
.
trim
();
}
/**
* 从自然语言中提取目标数量,提取不到时走默认值或续轮值。
*/
private
int
resolveRequestedCount
(
String
message
,
CareConversationState
state
)
{
for
(
Pattern
pattern
:
EXPLICIT_COUNT_PATTERNS
)
{
Matcher
matcher
=
pattern
.
matcher
(
message
);
while
(
matcher
.
find
())
{
int
value
=
Integer
.
parseInt
(
matcher
.
group
(
1
));
if
(
value
>
0
)
{
return
Math
.
min
(
value
,
properties
.
getMaxRequestedCount
());
}
}
}
if
(
state
!=
null
&&
isContinuation
(
message
)
&&
state
.
taskType
().
isRecommendation
())
{
return
state
.
requestedCount
();
}
return
properties
.
getDefaultCount
();
}
/**
* 判断当前消息是否属于续轮请求。
*/
private
boolean
isContinuation
(
String
message
)
{
return
message
.
contains
(
"再来一些"
)
||
message
.
contains
(
"继续"
)
||
message
.
contains
(
"换一批"
)
||
message
.
contains
(
"更多"
);
}
/**
* 判断当前消息是否可沿用推荐链路继续执行。
*/
private
boolean
isRecommendationContinuation
(
boolean
continuation
,
CareConversationState
state
)
{
return
continuation
&&
state
!=
null
&&
state
.
taskType
().
isRecommendation
();
}
/**
* 判断续轮消息里是否携带了新的筛选信息。
*/
private
boolean
hasSubstantiveContentAfterContinuation
(
String
message
)
{
String
stripped
=
message
.
replace
(
"再来一些"
,
""
)
.
replace
(
"继续"
,
""
)
.
replace
(
"换一批"
,
""
)
.
replace
(
"更多"
,
""
)
.
trim
();
return
StringUtils
.
hasText
(
stripped
);
}
/**
* 续轮场景沿用状态,其余场景统一交给模型分类。
*/
private
CareTaskType
resolveTaskType
(
String
traceId
,
String
sessionId
,
String
message
,
CareConversationState
state
,
boolean
continuation
)
{
if
(
isRecommendationContinuation
(
continuation
,
state
)
&&
!
hasSubstantiveContentAfterContinuation
(
message
))
{
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"意图识别结果"
,
"识别方式=沿用会话状态,任务类型="
+
toChineseTaskType
(
state
.
taskType
())
));
return
state
.
taskType
();
}
String
classificationPrompt
=
"""
你是任务分类器。请根据用户的话,判断属于以下哪一种:
1. RECOMMEND_ACTIVITIES:用户想让你推荐活动
2. RECOMMEND_USERS:用户想让你推荐适合活动的用户
3. GENERAL_CHAT:其他需要直接对话回复的内容
只能输出一个英文枚举值,不要输出其他内容:
RECOMMEND_ACTIVITIES
RECOMMEND_USERS
GENERAL_CHAT
用户输入:%s
"""
.
formatted
(
message
);
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"模型调用开始"
,
"场景=任务分类,prompt长度=%s"
.
formatted
(
CareTraceLogSupport
.
promptLength
(
classificationPrompt
))
));
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
formatModelPrompt
(
traceId
,
sessionId
,
"任务分类模型请求"
,
classificationPrompt
));
try
{
String
modelDecision
=
chatModel
.
call
(
classificationPrompt
);
String
normalizedDecision
=
modelDecision
==
null
?
""
:
modelDecision
.
trim
();
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
formatModelResponse
(
traceId
,
sessionId
,
"任务分类模型返回"
,
normalizedDecision
));
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"意图识别结果"
,
"识别方式=模型判定,模型原始结果="
+
CareTraceLogSupport
.
safeText
(
normalizedDecision
)
));
if
(
"RECOMMEND_USERS"
.
equalsIgnoreCase
(
normalizedDecision
))
{
return
CareTaskType
.
RECOMMEND_USERS
;
}
if
(
"RECOMMEND_ACTIVITIES"
.
equalsIgnoreCase
(
normalizedDecision
))
{
return
CareTaskType
.
RECOMMEND_ACTIVITIES
;
}
}
catch
(
RuntimeException
exception
)
{
LogHelper
.
error
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"任务分类模型异常"
,
"分类失败,降级为通用对话,异常="
+
CareTraceLogSupport
.
safeText
(
exception
.
getMessage
())
),
exception
);
}
return
CareTaskType
.
GENERAL_CHAT
;
}
/**
* 让模型产出结构化理解结果,并在失败时退回保守兜底。
*/
private
ModelUnderstandingResult
callModel
(
String
traceId
,
String
sessionId
,
String
message
,
CareTaskType
taskType
,
CareConversationState
state
)
{
String
stateSummary
=
buildStateSummary
(
state
);
String
understandingPrompt
=
"""
你是精准关爱系统的请求理解助手。请把用户自然语言整理成JSON,不能输出任何JSON以外的内容。
taskType 只能是 RECOMMEND_ACTIVITIES 或 RECOMMEND_USERS。
activityIntent 填活动名称、活动主题或活动语义摘要;推荐活动场景可以留空。
preferenceSummary 填用户偏好和筛选要求的简述。
searchConditions 是一个对象,只能使用以下字段:sex、minAge、maxAge、bmi、hobby、specialty、regionName、departmentName。
sex 使用原始编码:男传 0,女传 1。
bmi、hobby、specialty 如有多个值,用英文逗号拼接成字符串。
当前任务倾向:%s
上下文状态:%s
用户输入:%s
输出格式:
{
"
taskType
": "
RECOMMEND_ACTIVITIES
",
"
activityIntent
": "",
"
preferenceSummary
": "",
"
searchConditions
": {}
}
"""
.
formatted
(
taskType
.
name
(),
stateSummary
,
message
);
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"模型调用开始"
,
"场景=意图理解,任务类型=%s,会话摘要=%s,prompt长度=%s"
.
formatted
(
toChineseTaskType
(
taskType
),
CareTraceLogSupport
.
safeText
(
stateSummary
),
CareTraceLogSupport
.
promptLength
(
understandingPrompt
)
)
));
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
formatModelPrompt
(
traceId
,
sessionId
,
"意图理解模型请求"
,
understandingPrompt
));
try
{
String
response
=
chatModel
.
call
(
understandingPrompt
);
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
formatModelResponse
(
traceId
,
sessionId
,
"意图理解模型返回"
,
response
));
String
sanitized
=
sanitizeJson
(
response
);
if
(!
StringUtils
.
hasText
(
sanitized
))
{
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"意图识别模型返回为空"
,
"模型未返回可解析JSON"
));
return
fallbackUnderstanding
(
taskType
,
message
,
state
);
}
ModelUnderstandingResult
result
=
JsonUtils
.
jsonToObject
(
sanitized
,
ModelUnderstandingResult
.
class
);
if
(
result
==
null
)
{
return
fallbackUnderstanding
(
taskType
,
message
,
state
);
}
return
result
;
}
catch
(
RuntimeException
exception
)
{
LogHelper
.
error
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"意图识别模型异常"
,
"模型理解调用或解析失败,异常="
+
CareTraceLogSupport
.
safeText
(
exception
.
getMessage
())
),
exception
);
return
fallbackUnderstanding
(
taskType
,
message
,
state
);
}
}
/**
* 构造供模型参考的短会话摘要。
*/
private
String
buildStateSummary
(
CareConversationState
state
)
{
if
(
state
==
null
)
{
return
"无"
;
}
return
"""
taskType=%s, requestedCount=%s, activityIntent=%s, preferenceSummary=%s
"""
.
formatted
(
state
.
taskType
(),
state
.
requestedCount
(),
state
.
activityIntent
(),
state
.
preferenceSummary
()
).
trim
();
}
/**
* 当模型理解失败时,退回到最保守的理解结果。
*/
private
ModelUnderstandingResult
fallbackUnderstanding
(
CareTaskType
taskType
,
String
message
,
CareConversationState
state
)
{
String
activityIntent
=
taskType
==
CareTaskType
.
RECOMMEND_USERS
?
message
:
""
;
if
(
canReuseStateUnderstanding
(
state
,
taskType
)
&&
isContinuation
(
message
))
{
activityIntent
=
state
.
activityIntent
();
}
return
new
ModelUnderstandingResult
(
taskType
.
name
(),
activityIntent
,
message
,
Collections
.
emptyMap
()
);
}
/**
* 将续轮条件与本轮模型结果合并成最终查询条件。
*/
private
Map
<
String
,
Object
>
mergeConditions
(
CareConversationState
state
,
CareTaskType
taskType
,
Map
<
String
,
Object
>
modelConditions
,
boolean
continuation
)
{
LinkedHashMap
<
String
,
Object
>
merged
=
new
LinkedHashMap
<>();
if
(
continuation
&&
canReuseStateUnderstanding
(
state
,
taskType
))
{
merged
.
putAll
(
Optional
.
ofNullable
(
state
.
searchConditions
()).
orElse
(
Collections
.
emptyMap
()));
}
if
(
modelConditions
!=
null
)
{
merged
.
putAll
(
modelConditions
);
}
return
merged
;
}
/**
* 仅在续轮仍处于同一推荐任务类型时沿用旧状态理解结果。
*/
private
boolean
canReuseStateUnderstanding
(
CareConversationState
state
,
CareTaskType
taskType
)
{
return
state
!=
null
&&
state
.
taskType
()
==
taskType
&&
taskType
!=
null
&&
taskType
.
isRecommendation
();
}
/**
* 将模型输出和兼容字段收敛为接口文档允许的查询条件。
*/
private
Map
<
String
,
Object
>
normalizeSearchConditions
(
Map
<
String
,
Object
>
conditions
)
{
if
(
conditions
==
null
||
conditions
.
isEmpty
())
{
return
Collections
.
emptyMap
();
}
LinkedHashMap
<
String
,
Object
>
normalized
=
new
LinkedHashMap
<>();
copyStringValue
(
conditions
,
normalized
,
"sex"
,
"sex"
,
"gender"
);
copyIntegerValue
(
conditions
,
normalized
,
"minAge"
,
"minAge"
,
"ageMin"
);
copyIntegerValue
(
conditions
,
normalized
,
"maxAge"
,
"maxAge"
,
"ageMax"
);
copyCsvValue
(
conditions
,
normalized
,
"bmi"
,
"bmi"
);
copyCsvValue
(
conditions
,
normalized
,
"hobby"
,
"hobby"
,
"interests"
,
"interest"
);
copyCsvValue
(
conditions
,
normalized
,
"specialty"
,
"specialty"
,
"specialties"
,
"talents"
,
"tags"
);
copyStringValue
(
conditions
,
normalized
,
"regionName"
,
"regionName"
,
"region"
,
"area"
,
"city"
);
copyStringValue
(
conditions
,
normalized
,
"departmentName"
,
"departmentName"
,
"department"
,
"org"
);
String
normalizedSex
=
normalizeSex
(
normalized
.
get
(
"sex"
));
if
(
StringUtils
.
hasText
(
normalizedSex
))
{
normalized
.
put
(
"sex"
,
normalizedSex
);
}
else
{
normalized
.
remove
(
"sex"
);
}
return
normalized
;
}
/**
* 从规则列表里选择最匹配当前活动语义的一条规则。
*/
private
ActivityMatchUserRule
selectBestMatchingRule
(
String
activityIntent
,
List
<
ActivityMatchUserRule
>
rules
)
{
if
(!
StringUtils
.
hasText
(
activityIntent
)
||
rules
==
null
||
rules
.
isEmpty
())
{
return
null
;
}
String
normalizedIntent
=
normalizeActivityKeyword
(
activityIntent
);
List
<
ActivityMatchUserRule
>
exactMatches
=
rules
.
stream
()
.
filter
(
rule
->
normalizedIntent
.
equals
(
normalizeActivityKeyword
(
rule
.
activityKey
())))
.
toList
();
if
(!
exactMatches
.
isEmpty
())
{
return
longestKeywordRule
(
exactMatches
);
}
List
<
ActivityMatchUserRule
>
fuzzyMatches
=
rules
.
stream
()
.
filter
(
rule
->
{
String
normalizedKey
=
normalizeActivityKeyword
(
rule
.
activityKey
());
return
StringUtils
.
hasText
(
normalizedKey
)
&&
(
normalizedIntent
.
contains
(
normalizedKey
)
||
normalizedKey
.
contains
(
normalizedIntent
));
})
.
toList
();
if
(
fuzzyMatches
.
isEmpty
())
{
return
null
;
}
return
longestKeywordRule
(
fuzzyMatches
);
}
/**
* 选择关键词最长的一条规则,降低短词误命中的概率。
*/
private
ActivityMatchUserRule
longestKeywordRule
(
List
<
ActivityMatchUserRule
>
rules
)
{
ActivityMatchUserRule
bestRule
=
null
;
int
bestLength
=
-
1
;
for
(
ActivityMatchUserRule
rule
:
rules
)
{
int
currentLength
=
normalizeActivityKeyword
(
rule
.
activityKey
()).
length
();
if
(
currentLength
>
bestLength
)
{
bestRule
=
rule
;
bestLength
=
currentLength
;
}
}
return
bestRule
;
}
/**
* 记录最终意图识别结果。
*/
private
void
logIntentResult
(
String
traceId
,
String
sessionId
,
String
resolvedBy
,
CareQuery
query
)
{
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"意图识别结果"
,
"识别方式=%s,任务类型=%s,目标数量=%s,是否续轮=%s,筛选条件=%s"
.
formatted
(
resolvedBy
,
toChineseTaskType
(
query
.
taskType
()),
query
.
requestedCount
(),
query
.
continuation
(),
CareTraceLogSupport
.
safeConditionsSummary
(
query
.
searchConditions
())
)
));
}
/**
* 复制单值字符串条件。
*/
private
void
copyStringValue
(
Map
<
String
,
Object
>
source
,
Map
<
String
,
Object
>
target
,
String
targetKey
,
String
...
aliases
)
{
for
(
String
alias
:
aliases
)
{
Object
value
=
source
.
get
(
alias
);
if
(
value
==
null
)
{
continue
;
}
String
text
=
stringify
(
value
);
if
(
StringUtils
.
hasText
(
text
))
{
target
.
put
(
targetKey
,
text
);
return
;
}
}
}
/**
* 复制整数条件。
*/
private
void
copyIntegerValue
(
Map
<
String
,
Object
>
source
,
Map
<
String
,
Object
>
target
,
String
targetKey
,
String
...
aliases
)
{
for
(
String
alias
:
aliases
)
{
Object
value
=
source
.
get
(
alias
);
Integer
number
=
toInteger
(
value
);
if
(
number
!=
null
)
{
target
.
put
(
targetKey
,
number
);
return
;
}
}
}
/**
* 将数组或多值字符串转成上游接口约定的英文逗号分隔字符串。
*/
private
void
copyCsvValue
(
Map
<
String
,
Object
>
source
,
Map
<
String
,
Object
>
target
,
String
targetKey
,
String
...
aliases
)
{
for
(
String
alias
:
aliases
)
{
Object
value
=
source
.
get
(
alias
);
String
csv
=
toCsv
(
value
);
if
(
StringUtils
.
hasText
(
csv
))
{
target
.
put
(
targetKey
,
csv
);
return
;
}
}
}
/**
* 统一性别编码,保证发给上游的是原始编码值。
*/
private
String
normalizeSex
(
Object
value
)
{
String
text
=
stringify
(
value
).
toLowerCase
(
Locale
.
ROOT
);
if
(!
StringUtils
.
hasText
(
text
))
{
return
""
;
}
if
(
"0"
.
equals
(
text
)
||
"男"
.
equals
(
text
)
||
"男性"
.
equals
(
text
)
||
"male"
.
equals
(
text
))
{
return
"0"
;
}
if
(
"1"
.
equals
(
text
)
||
"女"
.
equals
(
text
)
||
"女性"
.
equals
(
text
)
||
"female"
.
equals
(
text
))
{
return
"1"
;
}
return
""
;
}
/**
* 统一活动关键词,去掉常见后缀和分隔符,便于命中规则。
*/
private
String
normalizeActivityKeyword
(
String
value
)
{
String
normalized
=
stringify
(
value
)
.
replace
(
ACTIVITY_SUFFIX
,
""
)
.
replace
(
" "
,
""
)
.
replace
(
" "
,
""
)
.
replace
(
"("
,
""
)
.
replace
(
")"
,
""
)
.
replace
(
"("
,
""
)
.
replace
(
")"
,
""
)
.
replace
(
"、"
,
""
)
.
replace
(
"-"
,
""
)
.
replace
(
"_"
,
""
);
return
normalized
;
}
/**
* 将对象转换为整数。
*/
private
Integer
toInteger
(
Object
value
)
{
if
(
value
instanceof
Number
number
)
{
return
number
.
intValue
();
}
String
text
=
stringify
(
value
);
if
(!
StringUtils
.
hasText
(
text
))
{
return
null
;
}
try
{
return
Integer
.
parseInt
(
text
);
}
catch
(
NumberFormatException
exception
)
{
return
null
;
}
}
/**
* 将对象转换为逗号分隔字符串。
*/
private
String
toCsv
(
Object
value
)
{
if
(
value
==
null
)
{
return
""
;
}
if
(
value
instanceof
Iterable
<?>
iterable
)
{
List
<
String
>
parts
=
new
ArrayList
<>();
for
(
Object
item
:
iterable
)
{
String
text
=
stringify
(
item
);
if
(
StringUtils
.
hasText
(
text
))
{
parts
.
add
(
text
);
}
}
return
String
.
join
(
","
,
parts
);
}
String
text
=
stringify
(
value
);
if
(!
StringUtils
.
hasText
(
text
))
{
return
""
;
}
return
text
.
replace
(
","
,
","
);
}
/**
* 统一规则中的逗号分隔字符串格式。
*/
private
String
normalizeCsv
(
String
value
)
{
return
StringUtils
.
hasText
(
value
)
?
value
.
replace
(
","
,
","
).
trim
()
:
""
;
}
/**
* 将任意对象转成字符串。
*/
private
String
stringify
(
Object
value
)
{
return
value
==
null
?
""
:
value
.
toString
().
trim
();
}
/**
* 仅当目标字段为空时再补充规则值,保证用户显式条件优先。
*/
private
void
putIfAbsent
(
Map
<
String
,
Object
>
target
,
String
key
,
Object
value
)
{
if
(
target
.
containsKey
(
key
)
||
value
==
null
)
{
return
;
}
if
(
value
instanceof
String
text
&&
!
StringUtils
.
hasText
(
text
))
{
return
;
}
target
.
put
(
key
,
value
);
}
/**
* 合并逗号分隔的多值条件,保留用户原始顺序并对规则补充值去重。
*/
private
void
mergeCsvCondition
(
Map
<
String
,
Object
>
target
,
String
key
,
String
ruleValue
)
{
String
normalizedRuleValue
=
normalizeCsv
(
ruleValue
);
if
(!
StringUtils
.
hasText
(
normalizedRuleValue
))
{
return
;
}
if
(!
target
.
containsKey
(
key
))
{
target
.
put
(
key
,
normalizedRuleValue
);
return
;
}
String
existingValue
=
toCsv
(
target
.
get
(
key
));
if
(!
StringUtils
.
hasText
(
existingValue
))
{
target
.
put
(
key
,
normalizedRuleValue
);
return
;
}
target
.
put
(
key
,
mergeCsvValues
(
existingValue
,
normalizedRuleValue
));
}
/**
* 将用户条件与规则条件按顺序去重合并。
*/
private
String
mergeCsvValues
(
String
userValue
,
String
ruleValue
)
{
Set
<
String
>
merged
=
new
LinkedHashSet
<>();
appendCsvValues
(
merged
,
userValue
);
appendCsvValues
(
merged
,
ruleValue
);
return
String
.
join
(
","
,
merged
);
}
/**
* 拆分并追加逗号分隔的条件值。
*/
private
void
appendCsvValues
(
Set
<
String
>
target
,
String
value
)
{
String
normalized
=
normalizeCsv
(
value
);
if
(!
StringUtils
.
hasText
(
normalized
))
{
return
;
}
for
(
String
part
:
normalized
.
split
(
","
))
{
String
trimmed
=
part
.
trim
();
if
(
StringUtils
.
hasText
(
trimmed
))
{
target
.
add
(
trimmed
);
}
}
}
/**
* 从模型回复中截取可解析的 JSON 片段。
*/
private
String
sanitizeJson
(
String
text
)
{
if
(!
StringUtils
.
hasText
(
text
))
{
return
""
;
}
String
normalized
=
text
.
trim
()
.
replace
(
"```json"
,
""
)
.
replace
(
"```"
,
""
)
.
trim
();
int
start
=
normalized
.
indexOf
(
'{'
);
int
end
=
normalized
.
lastIndexOf
(
'}'
);
if
(
start
>=
0
&&
end
>
start
)
{
return
normalized
.
substring
(
start
,
end
+
1
);
}
return
""
;
}
/**
* 将任务类型转换成中文展示。
*/
private
String
toChineseTaskType
(
CareTaskType
taskType
)
{
if
(
taskType
==
null
)
{
return
"未知"
;
}
return
switch
(
taskType
)
{
case
RECOMMEND_ACTIVITIES
->
"推荐活动"
;
case
RECOMMEND_USERS
->
"推荐用户"
;
case
GENERAL_CHAT
->
"通用对话"
;
};
}
@JsonIgnoreProperties
(
ignoreUnknown
=
true
)
private
record
ModelUnderstandingResult
(
String
taskType
,
String
activityIntent
,
String
preferenceSummary
,
Map
<
String
,
Object
>
searchConditions
)
{
}
}
src/main/java/com/infoepoch/pms/agent/properties/CareAgentProperties.java
0 → 100644
View file @
5435092e
package
com
.
infoepoch
.
pms
.
agent
.
properties
;
import
lombok.Getter
;
import
lombok.Setter
;
import
org.springframework.boot.context.properties.ConfigurationProperties
;
/**
* 精准关爱智能体参数
*/
@Getter
@Setter
@ConfigurationProperties
(
prefix
=
"pms.care.agent"
)
public
class
CareAgentProperties
{
private
int
defaultCount
=
10
;
private
int
maxRequestedCount
=
500
;
private
int
userPageSize
=
100
;
private
int
activityPageSize
=
50
;
private
int
activityCandidateLimit
=
40
;
private
long
userStreamItemDelayMillis
=
250
;
private
long
conversationTtlMinutes
=
120
;
}
src/main/java/com/infoepoch/pms/agent/properties/CareBusinessProperties.java
0 → 100644
View file @
5435092e
package
com
.
infoepoch
.
pms
.
agent
.
properties
;
import
lombok.Getter
;
import
lombok.Setter
;
import
org.springframework.boot.context.properties.ConfigurationProperties
;
/**
* 精准关爱业务接口配置
*/
@Getter
@Setter
@ConfigurationProperties
(
prefix
=
"pms.care.business"
)
public
class
CareBusinessProperties
{
private
String
baseUrl
;
private
String
currentUserPath
;
private
String
eligibleActivitiesPath
;
private
String
userSearchPath
;
private
String
activityMatchUserRulesPath
;
}
src/main/java/com/infoepoch/pms/agent/tool/union/care/CurrentUserProfileTool.java
0 → 100644
View file @
5435092e
package
com
.
infoepoch
.
pms
.
agent
.
tool
.
union
.
care
;
import
com.infoepoch.pms.agent.common.LogHelper
;
import
com.infoepoch.pms.agent.domain.care.log.CareTraceLogSupport
;
import
com.infoepoch.pms.agent.domain.care.model.UserProfileSummary
;
import
com.infoepoch.pms.agent.tool.union.care.provider.CareBusinessDataProvider
;
import
lombok.RequiredArgsConstructor
;
import
org.springframework.stereotype.Component
;
/**
* 当前用户信息工具
*/
@Component
@RequiredArgsConstructor
public
class
CurrentUserProfileTool
{
private
final
CareBusinessDataProvider
careBusinessDataProvider
;
/**
* 查询当前会话绑定用户的基础画像信息。
*/
public
UserProfileSummary
execute
(
String
traceId
,
String
sessionId
)
{
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"当前用户工具分发"
,
"调用当前用户信息工具"
));
return
careBusinessDataProvider
.
getCurrentUserProfile
(
traceId
,
sessionId
);
}
}
src/main/java/com/infoepoch/pms/agent/tool/union/care/EligibleActivitiesTool.java
0 → 100644
View file @
5435092e
package
com
.
infoepoch
.
pms
.
agent
.
tool
.
union
.
care
;
import
com.infoepoch.pms.agent.common.LogHelper
;
import
com.infoepoch.pms.agent.domain.care.log.CareTraceLogSupport
;
import
com.infoepoch.pms.agent.domain.care.model.ActivitySummary
;
import
com.infoepoch.pms.agent.tool.union.care.provider.CareBusinessDataProvider
;
import
lombok.RequiredArgsConstructor
;
import
org.springframework.stereotype.Component
;
import
java.util.List
;
/**
* 可参加活动查询工具
*/
@Component
@RequiredArgsConstructor
public
class
EligibleActivitiesTool
{
private
final
CareBusinessDataProvider
careBusinessDataProvider
;
/**
* 根据会话用户一次性查询可推荐活动列表。
*/
public
List
<
ActivitySummary
>
execute
(
String
traceId
,
String
sessionId
)
{
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"活动工具分发"
,
"调用可推荐活动工具"
));
return
careBusinessDataProvider
.
searchEligibleActivities
(
traceId
,
sessionId
);
}
}
src/main/java/com/infoepoch/pms/agent/tool/union/care/UserSearchTool.java
0 → 100644
View file @
5435092e
package
com
.
infoepoch
.
pms
.
agent
.
tool
.
union
.
care
;
import
com.infoepoch.pms.agent.common.LogHelper
;
import
com.infoepoch.pms.agent.domain.care.log.CareTraceLogSupport
;
import
com.infoepoch.pms.agent.domain.care.model.PagedResult
;
import
com.infoepoch.pms.agent.domain.care.model.UserProfileSummary
;
import
com.infoepoch.pms.agent.tool.union.care.provider.CareBusinessDataProvider
;
import
lombok.RequiredArgsConstructor
;
import
org.springframework.stereotype.Component
;
import
java.util.Map
;
/**
* 用户检索工具
*/
@Component
@RequiredArgsConstructor
public
class
UserSearchTool
{
private
final
CareBusinessDataProvider
careBusinessDataProvider
;
/**
* 按接口文档支持的筛选条件分页检索用户。
*/
public
PagedResult
<
UserProfileSummary
>
execute
(
String
traceId
,
String
sessionId
,
Map
<
String
,
Object
>
conditions
,
int
pageNo
,
int
pageSize
)
{
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"用户检索工具分发"
,
"页码=%s,每页数量=%s,筛选条件=%s"
.
formatted
(
pageNo
,
pageSize
,
CareTraceLogSupport
.
safeConditionsSummary
(
conditions
)
)
));
return
careBusinessDataProvider
.
searchUsersByConditions
(
traceId
,
sessionId
,
conditions
,
pageNo
,
pageSize
);
}
}
src/main/java/com/infoepoch/pms/agent/tool/union/care/provider/CareBusinessDataProvider.java
0 → 100644
View file @
5435092e
package
com
.
infoepoch
.
pms
.
agent
.
tool
.
union
.
care
.
provider
;
import
com.infoepoch.pms.agent.domain.care.model.ActivitySummary
;
import
com.infoepoch.pms.agent.domain.care.model.ActivityMatchUserRule
;
import
com.infoepoch.pms.agent.domain.care.model.PagedResult
;
import
com.infoepoch.pms.agent.domain.care.model.UserProfileSummary
;
import
java.util.List
;
import
java.util.Map
;
/**
* 精准关爱业务数据提供者
*/
public
interface
CareBusinessDataProvider
{
UserProfileSummary
getCurrentUserProfile
(
String
traceId
,
String
sessionId
);
List
<
ActivitySummary
>
searchEligibleActivities
(
String
traceId
,
String
sessionId
);
List
<
ActivityMatchUserRule
>
listActivityMatchUserRules
(
String
traceId
,
String
sessionId
);
PagedResult
<
UserProfileSummary
>
searchUsersByConditions
(
String
traceId
,
String
sessionId
,
Map
<
String
,
Object
>
conditions
,
int
pageNo
,
int
pageSize
);
}
src/main/java/com/infoepoch/pms/agent/tool/union/care/provider/HttpCareBusinessDataProvider.java
0 → 100644
View file @
5435092e
package
com
.
infoepoch
.
pms
.
agent
.
tool
.
union
.
care
.
provider
;
import
com.fasterxml.jackson.databind.JsonNode
;
import
com.infoepoch.pms.agent.common.LogHelper
;
import
com.infoepoch.pms.agent.config.JsonUtils
;
import
com.infoepoch.pms.agent.domain.care.log.CareTraceLogSupport
;
import
com.infoepoch.pms.agent.domain.care.model.ActivityMatchUserRule
;
import
com.infoepoch.pms.agent.domain.care.model.ActivitySummary
;
import
com.infoepoch.pms.agent.domain.care.model.PagedResult
;
import
com.infoepoch.pms.agent.domain.care.model.UserProfileSummary
;
import
com.infoepoch.pms.agent.properties.CareBusinessProperties
;
import
org.springframework.http.MediaType
;
import
org.springframework.http.client.BufferingClientHttpRequestFactory
;
import
org.springframework.http.client.ClientHttpRequestFactory
;
import
org.springframework.http.client.SimpleClientHttpRequestFactory
;
import
org.springframework.stereotype.Service
;
import
org.springframework.util.StringUtils
;
import
org.springframework.web.client.RestClient
;
import
org.springframework.web.util.UriBuilder
;
import
java.net.URI
;
import
java.nio.charset.StandardCharsets
;
import
java.security.MessageDigest
;
import
java.security.NoSuchAlgorithmException
;
import
java.util.ArrayList
;
import
java.util.LinkedHashMap
;
import
java.util.List
;
import
java.util.Map
;
import
java.util.StringJoiner
;
/**
* 基于 HTTP 的业务数据访问
*/
@Service
public
class
HttpCareBusinessDataProvider
implements
CareBusinessDataProvider
{
private
static
final
int
SUCCESS_CODE
=
1
;
private
final
CareBusinessProperties
properties
;
private
final
RestClient
restClient
;
public
HttpCareBusinessDataProvider
(
CareBusinessProperties
properties
)
{
this
.
properties
=
properties
;
RestClient
.
Builder
builder
=
RestClient
.
builder
().
requestFactory
(
buildRequestFactory
());
if
(
StringUtils
.
hasText
(
properties
.
getBaseUrl
()))
{
builder
.
baseUrl
(
properties
.
getBaseUrl
());
}
this
.
restClient
=
builder
.
build
();
}
/**
* 调用当前用户接口并裁剪成统一用户摘要。
*/
@Override
public
UserProfileSummary
getCurrentUserProfile
(
String
traceId
,
String
sessionId
)
{
String
path
=
requiredPath
(
properties
.
getCurrentUserPath
(),
"当前用户信息接口未配置"
);
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"当前用户接口请求"
,
"接口路径="
+
path
));
try
{
JsonNode
root
=
postForJson
(
path
,
Map
.
of
(
"sessionId"
,
sessionId
),
null
);
JsonNode
dataNode
=
extractDataNode
(
root
,
path
,
true
);
UserProfileSummary
summary
=
toCurrentUserProfile
(
dataNode
,
sessionId
);
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"当前用户接口响应"
,
"用户=%s,兴趣数量=%s,标签数量=%s"
.
formatted
(
CareTraceLogSupport
.
safeText
(
summary
.
userName
()),
summary
.
interests
().
size
(),
summary
.
tags
().
size
()
)
));
return
summary
;
}
catch
(
Exception
exception
)
{
logToolError
(
traceId
,
sessionId
,
path
,
"getCurrentUserInfo"
,
exception
);
throw
exception
;
}
}
/**
* 调用活动接口并解析成活动列表。
*/
@Override
public
List
<
ActivitySummary
>
searchEligibleActivities
(
String
traceId
,
String
sessionId
)
{
String
path
=
requiredPath
(
properties
.
getEligibleActivitiesPath
(),
"活动查询接口未配置"
);
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"活动接口请求"
,
"接口路径="
+
path
));
try
{
JsonNode
root
=
postForJson
(
path
,
Map
.
of
(
"sessionId"
,
sessionId
),
null
);
JsonNode
dataNode
=
extractDataNode
(
root
,
path
,
false
);
if
(
dataNode
==
null
||
dataNode
.
isNull
()
||
!
dataNode
.
isArray
())
{
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"活动接口响应"
,
"活动数量=0"
));
return
List
.
of
();
}
List
<
ActivitySummary
>
activities
=
new
ArrayList
<>();
for
(
JsonNode
item
:
dataNode
)
{
activities
.
add
(
toActivitySummary
(
item
));
}
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"活动接口响应"
,
"活动数量="
+
activities
.
size
()
));
return
List
.
copyOf
(
activities
);
}
catch
(
Exception
exception
)
{
logToolError
(
traceId
,
sessionId
,
path
,
"getActivityInfoList"
,
exception
);
throw
exception
;
}
}
/**
* 调用活动匹配用户规则接口并解析成规则列表。
*/
@Override
public
List
<
ActivityMatchUserRule
>
listActivityMatchUserRules
(
String
traceId
,
String
sessionId
)
{
String
path
=
requiredPath
(
properties
.
getActivityMatchUserRulesPath
(),
"活动匹配用户规则接口未配置"
);
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"活动规则接口请求"
,
"接口路径="
+
path
));
try
{
JsonNode
root
=
postForJson
(
path
,
Map
.
of
(),
null
);
JsonNode
dataNode
=
extractDataNode
(
root
,
path
,
false
);
if
(
dataNode
==
null
||
dataNode
.
isNull
()
||
!
dataNode
.
isArray
())
{
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"活动规则接口响应"
,
"规则数量=0"
));
return
List
.
of
();
}
List
<
ActivityMatchUserRule
>
rules
=
new
ArrayList
<>();
for
(
JsonNode
item
:
dataNode
)
{
rules
.
add
(
toActivityMatchUserRule
(
item
));
}
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"活动规则接口响应"
,
"规则数量="
+
rules
.
size
()
));
return
List
.
copyOf
(
rules
);
}
catch
(
Exception
exception
)
{
logToolError
(
traceId
,
sessionId
,
path
,
"getActivityMatchUserRules"
,
exception
);
throw
exception
;
}
}
/**
* 调用用户检索接口并解析成分页用户结果。
*/
@Override
public
PagedResult
<
UserProfileSummary
>
searchUsersByConditions
(
String
traceId
,
String
sessionId
,
Map
<
String
,
Object
>
conditions
,
int
pageNo
,
int
pageSize
)
{
String
path
=
requiredPath
(
properties
.
getUserSearchPath
(),
"用户检索接口未配置"
);
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"用户检索接口请求"
,
"接口路径=%s,页码=%s,每页数量=%s,筛选条件=%s"
.
formatted
(
path
,
pageNo
,
pageSize
,
CareTraceLogSupport
.
safeConditionsSummary
(
conditions
)
)
));
try
{
JsonNode
root
=
postForJson
(
path
,
Map
.
of
(),
buildUserSearchBody
(
sessionId
,
conditions
,
pageNo
,
pageSize
));
JsonNode
dataNode
=
extractDataNode
(
root
,
path
,
false
);
if
(
dataNode
==
null
||
dataNode
.
isNull
()
||
!
dataNode
.
isObject
())
{
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"用户检索接口响应"
,
"页码=%s,返回数量=0,总数=0"
.
formatted
(
pageNo
)
));
return
new
PagedResult
<>(
List
.
of
(),
0
,
false
);
}
long
total
=
readLong
(
dataNode
,
"totalCount"
,
0
);
JsonNode
dataListNode
=
dataNode
.
get
(
"dataList"
);
List
<
UserProfileSummary
>
users
=
new
ArrayList
<>();
if
(
dataListNode
!=
null
&&
dataListNode
.
isArray
())
{
for
(
JsonNode
item
:
dataListNode
)
{
users
.
add
(
toListedUserProfile
(
item
));
}
}
boolean
hasNext
=
pageNo
*
(
long
)
pageSize
<
total
;
LogHelper
.
info
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"用户检索接口响应"
,
"页码=%s,返回数量=%s,总数=%s,是否有下一页=%s"
.
formatted
(
pageNo
,
users
.
size
(),
total
,
hasNext
)
));
return
new
PagedResult
<>(
users
,
total
,
hasNext
);
}
catch
(
Exception
exception
)
{
logToolError
(
traceId
,
sessionId
,
path
,
"getUserInfoList"
,
exception
);
throw
exception
;
}
}
/**
* 创建兼容业务网关响应的 HTTP 客户端请求工厂。
*/
private
ClientHttpRequestFactory
buildRequestFactory
()
{
return
new
BufferingClientHttpRequestFactory
(
new
SimpleClientHttpRequestFactory
());
}
/**
* 执行 HTTP POST 请求并将返回值解析为 JsonNode。
*/
private
JsonNode
postForJson
(
String
path
,
Map
<
String
,
Object
>
queryParams
,
Object
requestBody
)
{
RestClient
.
RequestBodySpec
spec
=
restClient
.
post
()
.
uri
(
uriBuilder
->
buildUri
(
uriBuilder
,
path
,
queryParams
))
.
contentType
(
MediaType
.
APPLICATION_JSON
);
if
(
requestBody
!=
null
)
{
spec
.
body
(
requestBody
);
}
String
response
=
spec
.
retrieve
().
body
(
String
.
class
);
if
(!
StringUtils
.
hasText
(
response
))
{
throw
new
IllegalStateException
(
"业务接口返回为空: "
+
path
);
}
JsonNode
jsonNode
=
JsonUtils
.
jsonToJsonNode
(
response
);
if
(
jsonNode
==
null
)
{
throw
new
IllegalStateException
(
"业务接口返回不是合法JSON: "
+
path
);
}
return
jsonNode
;
}
/**
* 从统一 AgentResponse 结构中读取 data 节点,并校验业务响应码。
*/
private
JsonNode
extractDataNode
(
JsonNode
root
,
String
path
,
boolean
requireData
)
{
if
(
root
==
null
||
!
root
.
isObject
())
{
throw
new
IllegalStateException
(
"业务接口返回结构不正确: "
+
path
);
}
int
code
=
root
.
path
(
"code"
).
asInt
();
String
msg
=
root
.
path
(
"msg"
).
asText
();
if
(
code
!=
SUCCESS_CODE
)
{
throw
new
IllegalStateException
(
StringUtils
.
hasText
(
msg
)
?
msg
:
"业务接口调用失败: "
+
path
);
}
JsonNode
dataNode
=
root
.
get
(
"data"
);
if
(
requireData
&&
(
dataNode
==
null
||
dataNode
.
isNull
()))
{
throw
new
IllegalStateException
(
StringUtils
.
hasText
(
msg
)
?
msg
:
"业务接口未返回数据: "
+
path
);
}
return
dataNode
;
}
/**
* 构造带查询参数的请求 URI。
*/
private
URI
buildUri
(
UriBuilder
uriBuilder
,
String
path
,
Map
<
String
,
Object
>
params
)
{
uriBuilder
.
path
(
path
);
params
.
forEach
((
key
,
value
)
->
{
if
(
value
!=
null
)
{
uriBuilder
.
queryParam
(
key
,
value
);
}
});
return
uriBuilder
.
build
();
}
/**
* 校验业务接口路径是否已配置。
*/
private
String
requiredPath
(
String
value
,
String
message
)
{
if
(!
StringUtils
.
hasText
(
value
))
{
throw
new
IllegalStateException
(
message
);
}
return
value
.
trim
();
}
/**
* 构造用户分页检索请求体,只保留接口文档支持的字段。
*/
private
Map
<
String
,
Object
>
buildUserSearchBody
(
String
sessionId
,
Map
<
String
,
Object
>
conditions
,
int
pageNo
,
int
pageSize
)
{
LinkedHashMap
<
String
,
Object
>
body
=
new
LinkedHashMap
<>();
body
.
put
(
"sessionId"
,
sessionId
);
body
.
put
(
"pageIndex"
,
pageNo
);
body
.
put
(
"pageSize"
,
pageSize
);
Map
<
String
,
Object
>
safeConditions
=
conditions
==
null
?
Map
.
of
()
:
conditions
;
copyIfPresent
(
body
,
safeConditions
,
"sex"
);
copyIfPresent
(
body
,
safeConditions
,
"minAge"
);
copyIfPresent
(
body
,
safeConditions
,
"maxAge"
);
copyIfPresent
(
body
,
safeConditions
,
"bmi"
);
copyIfPresent
(
body
,
safeConditions
,
"hobby"
);
copyIfPresent
(
body
,
safeConditions
,
"specialty"
);
copyIfPresent
(
body
,
safeConditions
,
"regionName"
);
copyIfPresent
(
body
,
safeConditions
,
"departmentName"
);
return
body
;
}
/**
* 将非空条件复制到请求体中。
*/
private
void
copyIfPresent
(
Map
<
String
,
Object
>
target
,
Map
<
String
,
Object
>
source
,
String
key
)
{
Object
value
=
source
.
get
(
key
);
if
(
value
==
null
)
{
return
;
}
if
(
value
instanceof
String
text
&&
!
StringUtils
.
hasText
(
text
))
{
return
;
}
target
.
put
(
key
,
value
);
}
/**
* 将当前用户 JSON 节点裁剪成统一用户摘要。
*/
private
UserProfileSummary
toCurrentUserProfile
(
JsonNode
node
,
String
sessionId
)
{
return
new
UserProfileSummary
(
"session:"
+
sessionId
,
readText
(
node
,
"name"
),
readInteger
(
node
,
"age"
),
readText
(
node
,
"sex"
),
splitInterestNames
(
node
.
path
(
"interestInfoList"
)),
extractNames
(
node
.
path
(
"talentInfoList"
),
"talentName"
),
readText
(
node
,
"departmentName"
),
buildCurrentUserSummary
(
node
)
);
}
/**
* 将分页用户列表中的 JSON 节点裁剪成统一用户摘要。
*/
private
UserProfileSummary
toListedUserProfile
(
JsonNode
node
)
{
String
mobile
=
readText
(
node
,
"mobile"
);
String
name
=
readText
(
node
,
"name"
);
String
departmentName
=
readText
(
node
,
"departmentName"
);
return
new
UserProfileSummary
(
syntheticId
(
"user"
,
name
,
mobile
),
name
,
readInteger
(
node
,
"age"
),
readText
(
node
,
"sex"
),
List
.
of
(),
List
.
of
(),
departmentName
,
buildListedUserSummary
(
node
)
);
}
/**
* 将活动 JSON 节点裁剪成统一活动摘要。
*/
private
ActivitySummary
toActivitySummary
(
JsonNode
node
)
{
String
activityName
=
readText
(
node
,
"activityName"
);
String
startTime
=
readText
(
node
,
"startTime"
);
String
endTime
=
readText
(
node
,
"endTime"
);
String
location
=
readText
(
node
,
"location"
);
String
activityTypeName
=
readText
(
node
,
"activityTypeName"
);
return
new
ActivitySummary
(
syntheticId
(
"activity"
,
activityName
,
startTime
,
location
),
activityName
,
activityTypeName
,
""
,
StringUtils
.
hasText
(
activityTypeName
)
?
List
.
of
(
activityTypeName
)
:
List
.
of
(),
location
,
joinWithDelimiter
(
" - "
,
startTime
,
endTime
),
buildActivitySummary
(
node
)
);
}
/**
* 将活动规则 JSON 节点裁剪成统一规则对象。
*/
private
ActivityMatchUserRule
toActivityMatchUserRule
(
JsonNode
node
)
{
return
new
ActivityMatchUserRule
(
readText
(
node
,
"activityKey"
),
readText
(
node
,
"sex"
),
readInteger
(
node
,
"minAge"
),
readInteger
(
node
,
"maxAge"
),
readText
(
node
,
"bmi"
),
readText
(
node
,
"hobby"
),
readText
(
node
,
"specialty"
)
);
}
/**
* 从数组对象中提取名称列表。
*/
private
List
<
String
>
extractNames
(
JsonNode
arrayNode
,
String
fieldName
)
{
if
(
arrayNode
==
null
||
arrayNode
.
isNull
()
||
!
arrayNode
.
isArray
())
{
return
List
.
of
();
}
List
<
String
>
names
=
new
ArrayList
<>();
for
(
JsonNode
item
:
arrayNode
)
{
String
name
=
readText
(
item
,
fieldName
);
if
(
StringUtils
.
hasText
(
name
))
{
names
.
add
(
name
);
}
}
return
List
.
copyOf
(
names
);
}
/**
* 从兴趣数组里提取并拆分逗号拼接的兴趣名称。
*/
private
List
<
String
>
splitInterestNames
(
JsonNode
arrayNode
)
{
if
(
arrayNode
==
null
||
arrayNode
.
isNull
()
||
!
arrayNode
.
isArray
())
{
return
List
.
of
();
}
List
<
String
>
interests
=
new
ArrayList
<>();
for
(
JsonNode
item
:
arrayNode
)
{
String
text
=
readText
(
item
,
"interestName"
);
if
(!
StringUtils
.
hasText
(
text
))
{
continue
;
}
for
(
String
part
:
text
.
replace
(
","
,
","
).
split
(
","
))
{
String
trimmed
=
part
.
trim
();
if
(
StringUtils
.
hasText
(
trimmed
))
{
interests
.
add
(
trimmed
);
}
}
}
return
List
.
copyOf
(
interests
);
}
/**
* 拼接当前用户摘要文本。
*/
private
String
buildCurrentUserSummary
(
JsonNode
node
)
{
return
joinWithDelimiter
(
";"
,
prefixed
(
"身高"
,
readText
(
node
,
"height"
)),
prefixed
(
"体重"
,
readText
(
node
,
"weight"
)),
prefixed
(
"BMI"
,
readText
(
node
,
"bmi"
)),
prefixed
(
"收缩压"
,
readText
(
node
,
"systolicBp"
)),
prefixed
(
"舒张压"
,
readText
(
node
,
"diastolicBp"
))
);
}
/**
* 拼接活动摘要文本。
*/
private
String
buildActivitySummary
(
JsonNode
node
)
{
return
joinWithDelimiter
(
";"
,
readText
(
node
,
"activityDescribe"
),
prefixed
(
"主办方"
,
readText
(
node
,
"sponsor"
)),
prefixed
(
"管理员"
,
readText
(
node
,
"activityAdmin"
)),
prefixed
(
"规模"
,
readText
(
node
,
"scale"
))
);
}
/**
* 拼接用户列表摘要文本。
*/
private
String
buildListedUserSummary
(
JsonNode
node
)
{
return
joinWithDelimiter
(
";"
,
prefixed
(
"手机号"
,
readText
(
node
,
"mobile"
)),
prefixed
(
"身高"
,
readText
(
node
,
"height"
)),
prefixed
(
"体重"
,
readText
(
node
,
"weight"
)),
prefixed
(
"BMI"
,
readText
(
node
,
"bmi"
)),
prefixed
(
"收缩压"
,
readText
(
node
,
"systolicBp"
)),
prefixed
(
"舒张压"
,
readText
(
node
,
"diastolicBp"
))
);
}
/**
* 记录业务接口异常。
*/
private
void
logToolError
(
String
traceId
,
String
sessionId
,
String
path
,
String
apiName
,
Exception
exception
)
{
LogHelper
.
error
(
this
,
CareTraceLogSupport
.
format
(
traceId
,
sessionId
,
"工具调用异常"
,
"接口=%s,路径=%s,异常=%s"
.
formatted
(
apiName
,
path
,
CareTraceLogSupport
.
safeText
(
exception
.
getMessage
())
)
),
exception
);
}
/**
* 给原始值增加标签前缀。
*/
private
String
prefixed
(
String
label
,
String
value
)
{
return
StringUtils
.
hasText
(
value
)
?
label
+
":"
+
value
:
""
;
}
/**
* 用指定分隔符拼接非空片段。
*/
private
String
joinWithDelimiter
(
String
delimiter
,
String
...
parts
)
{
StringJoiner
joiner
=
new
StringJoiner
(
delimiter
);
for
(
String
part
:
parts
)
{
if
(
StringUtils
.
hasText
(
part
))
{
joiner
.
add
(
part
);
}
}
return
joiner
.
toString
();
}
/**
* 读取文本字段。
*/
private
String
readText
(
JsonNode
node
,
String
fieldName
)
{
JsonNode
value
=
node
==
null
?
null
:
node
.
get
(
fieldName
);
if
(
value
!=
null
&&
!
value
.
isNull
()
&&
StringUtils
.
hasText
(
value
.
asText
()))
{
return
value
.
asText
().
trim
();
}
return
""
;
}
/**
* 读取整数字段。
*/
private
Integer
readInteger
(
JsonNode
node
,
String
fieldName
)
{
JsonNode
value
=
node
==
null
?
null
:
node
.
get
(
fieldName
);
if
(
value
!=
null
&&
value
.
canConvertToInt
())
{
return
value
.
asInt
();
}
return
null
;
}
/**
* 读取 long 字段。
*/
private
long
readLong
(
JsonNode
node
,
String
fieldName
,
long
defaultValue
)
{
JsonNode
value
=
node
==
null
?
null
:
node
.
get
(
fieldName
);
if
(
value
!=
null
&&
value
.
canConvertToLong
())
{
return
value
.
asLong
();
}
return
defaultValue
;
}
/**
* 生成稳定的合成业务 ID。
*/
private
String
syntheticId
(
String
prefix
,
String
...
values
)
{
String
raw
=
String
.
join
(
"|"
,
values
);
if
(!
StringUtils
.
hasText
(
raw
.
replace
(
"|"
,
""
).
trim
()))
{
raw
=
prefix
+
"|empty"
;
}
return
prefix
+
":"
+
md5
(
raw
);
}
/**
* 计算字符串的 MD5 摘要,保持 ID 稳定且长度可控。
*/
private
String
md5
(
String
value
)
{
try
{
MessageDigest
digest
=
MessageDigest
.
getInstance
(
"MD5"
);
byte
[]
bytes
=
digest
.
digest
(
value
.
getBytes
(
StandardCharsets
.
UTF_8
));
StringBuilder
builder
=
new
StringBuilder
();
for
(
byte
current
:
bytes
)
{
builder
.
append
(
String
.
format
(
"%02x"
,
current
));
}
return
builder
.
toString
();
}
catch
(
NoSuchAlgorithmException
exception
)
{
return
Integer
.
toHexString
(
value
.
hashCode
());
}
}
}
src/test/java/com/infoepoch/pms/agent/domain/care/orchestrator/ActivityRecommendationExecutorTest.java
0 → 100644
View file @
5435092e
package
com
.
infoepoch
.
pms
.
agent
.
domain
.
care
.
orchestrator
;
import
com.alibaba.cloud.ai.graph.agent.ReactAgent
;
import
com.infoepoch.pms.agent.domain.care.model.ActivitySummary
;
import
com.infoepoch.pms.agent.domain.care.model.CareConversationState
;
import
com.infoepoch.pms.agent.domain.care.model.CareQuery
;
import
com.infoepoch.pms.agent.domain.care.model.CareTaskType
;
import
com.infoepoch.pms.agent.domain.care.model.UserProfileSummary
;
import
com.infoepoch.pms.agent.domain.care.state.CareConversationStateService
;
import
com.infoepoch.pms.agent.domain.care.stream.CareStreamingResponseAssembler
;
import
com.infoepoch.pms.agent.properties.CareAgentProperties
;
import
com.infoepoch.pms.agent.tool.union.care.CurrentUserProfileTool
;
import
com.infoepoch.pms.agent.tool.union.care.EligibleActivitiesTool
;
import
org.junit.jupiter.api.BeforeEach
;
import
org.junit.jupiter.api.Test
;
import
org.junit.jupiter.api.extension.ExtendWith
;
import
org.mockito.ArgumentCaptor
;
import
org.mockito.Mock
;
import
org.mockito.junit.jupiter.MockitoExtension
;
import
reactor.core.publisher.Flux
;
import
java.time.LocalDateTime
;
import
java.util.List
;
import
java.util.Set
;
import
static
org
.
junit
.
jupiter
.
api
.
Assertions
.
assertEquals
;
import
static
org
.
junit
.
jupiter
.
api
.
Assertions
.
assertThrows
;
import
static
org
.
mockito
.
ArgumentMatchers
.
anyString
;
import
static
org
.
mockito
.
Mockito
.
never
;
import
static
org
.
mockito
.
Mockito
.
verify
;
import
static
org
.
mockito
.
Mockito
.
when
;
@ExtendWith
(
MockitoExtension
.
class
)
class
ActivityRecommendationExecutorTest
{
@Mock
private
CurrentUserProfileTool
currentUserProfileTool
;
@Mock
private
EligibleActivitiesTool
eligibleActivitiesTool
;
@Mock
private
CareConversationStateService
conversationStateService
;
@Mock
private
ReactAgent
careAgent
;
private
ActivityRecommendationExecutor
executor
;
@BeforeEach
void
setUp
()
{
CareAgentProperties
properties
=
new
CareAgentProperties
();
properties
.
setActivityCandidateLimit
(
4
);
executor
=
new
ActivityRecommendationExecutor
(
currentUserProfileTool
,
eligibleActivitiesTool
,
conversationStateService
,
new
CareStreamingResponseAssembler
(),
properties
,
careAgent
);
}
@Test
void
shouldSaveOnlyDisplayedActivitiesAfterSuccessfulCompletion
()
throws
Exception
{
CareQuery
query
=
new
CareQuery
(
CareTaskType
.
RECOMMEND_ACTIVITIES
,
2
,
false
,
"推荐活动"
,
""
,
"推荐活动"
,
java
.
util
.
Map
.
of
(),
""
);
CareConversationState
state
=
new
CareConversationState
(
"session-1"
,
CareTaskType
.
RECOMMEND_ACTIVITIES
,
2
,
""
,
"推荐活动"
,
java
.
util
.
Map
.
of
(),
1
,
Set
.
of
(
"old-1"
),
LocalDateTime
.
now
()
);
when
(
currentUserProfileTool
.
execute
(
"trace-1"
,
"session-1"
))
.
thenReturn
(
new
UserProfileSummary
(
"u-1"
,
"张三"
,
68
,
"男"
,
List
.
of
(
"健步"
),
List
.
of
(
"活跃"
),
"浦东"
,
""
));
when
(
eligibleActivitiesTool
.
execute
(
"trace-1"
,
"session-1"
))
.
thenReturn
(
List
.
of
(
activity
(
"a-1"
),
activity
(
"a-2"
),
activity
(
"a-3"
),
activity
(
"a-4"
)
));
when
(
careAgent
.
stream
(
anyString
())).
thenReturn
((
Flux
)
Flux
.
empty
());
executor
.
execute
(
"trace-1"
,
"session-1"
,
query
,
state
).
collectList
().
block
();
ArgumentCaptor
<
CareConversationState
>
captor
=
ArgumentCaptor
.
forClass
(
CareConversationState
.
class
);
verify
(
conversationStateService
).
save
(
captor
.
capture
());
assertEquals
(
Set
.
of
(
"old-1"
,
"a-1"
,
"a-2"
),
captor
.
getValue
().
deliveredIds
());
}
@Test
void
shouldNotSaveDeliveredActivitiesWhenStreamingFails
()
throws
Exception
{
CareQuery
query
=
new
CareQuery
(
CareTaskType
.
RECOMMEND_ACTIVITIES
,
2
,
false
,
"推荐活动"
,
""
,
"推荐活动"
,
java
.
util
.
Map
.
of
(),
""
);
when
(
currentUserProfileTool
.
execute
(
"trace-1"
,
"session-1"
))
.
thenReturn
(
new
UserProfileSummary
(
"u-1"
,
"张三"
,
68
,
"男"
,
List
.
of
(),
List
.
of
(),
"浦东"
,
""
));
when
(
eligibleActivitiesTool
.
execute
(
"trace-1"
,
"session-1"
))
.
thenReturn
(
List
.
of
(
activity
(
"a-1"
),
activity
(
"a-2"
),
activity
(
"a-3"
)));
when
(
careAgent
.
stream
(
anyString
())).
thenReturn
((
Flux
)
Flux
.
error
(
new
RuntimeException
(
"boom"
)));
assertThrows
(
RuntimeException
.
class
,
()
->
executor
.
execute
(
"trace-1"
,
"session-1"
,
query
,
null
).
collectList
().
block
());
verify
(
conversationStateService
,
never
()).
save
(
org
.
mockito
.
ArgumentMatchers
.
any
());
}
private
ActivitySummary
activity
(
String
id
)
{
return
new
ActivitySummary
(
id
,
"活动"
+
id
,
"运动"
,
"老人"
,
List
.
of
(
"户外"
),
"浦东"
,
"周三"
,
"摘要"
);
}
}
src/test/java/com/infoepoch/pms/agent/domain/care/orchestrator/GeneralConversationExecutorTest.java
0 → 100644
View file @
5435092e
package
com
.
infoepoch
.
pms
.
agent
.
domain
.
care
.
orchestrator
;
import
com.infoepoch.pms.agent.domain.care.stream.CareStreamingResponseAssembler
;
import
org.junit.jupiter.api.Test
;
import
java.lang.reflect.Method
;
import
static
org
.
junit
.
jupiter
.
api
.
Assertions
.
assertFalse
;
import
static
org
.
junit
.
jupiter
.
api
.
Assertions
.
assertTrue
;
class
GeneralConversationExecutorTest
{
@Test
void
shouldBuildPromptWithOnlyRoleAndUserMessage
()
throws
Exception
{
GeneralConversationExecutor
executor
=
new
GeneralConversationExecutor
(
new
CareStreamingResponseAssembler
(),
null
);
Method
method
=
GeneralConversationExecutor
.
class
.
getDeclaredMethod
(
"buildPrompt"
,
String
.
class
);
method
.
setAccessible
(
true
);
String
prompt
=
(
String
)
method
.
invoke
(
executor
,
"活动历史有哪些"
);
assertTrue
(
prompt
.
contains
(
"请以精准关爱智能体的身份回复用户。"
));
assertTrue
(
prompt
.
contains
(
"用户输入:活动历史有哪些"
));
assertFalse
(
prompt
.
contains
(
"你只有以下三个技能"
));
assertFalse
(
prompt
.
contains
(
"有限通用对话"
));
assertFalse
(
prompt
.
contains
(
"暂无推荐"
));
assertFalse
(
prompt
.
contains
(
"不扩展成百科问答或开放世界知识回答"
));
assertFalse
(
prompt
.
contains
(
"引导用户改问"
));
}
}
src/test/java/com/infoepoch/pms/agent/domain/care/orchestrator/UserRecommendationExecutorTest.java
0 → 100644
View file @
5435092e
package
com
.
infoepoch
.
pms
.
agent
.
domain
.
care
.
orchestrator
;
import
com.infoepoch.pms.agent.domain.care.model.CareConversationState
;
import
com.infoepoch.pms.agent.domain.care.model.CareQuery
;
import
com.infoepoch.pms.agent.domain.care.model.CareTaskType
;
import
com.infoepoch.pms.agent.domain.care.model.PagedResult
;
import
com.infoepoch.pms.agent.domain.care.model.UserProfileSummary
;
import
com.infoepoch.pms.agent.domain.care.state.CareConversationStateService
;
import
com.infoepoch.pms.agent.domain.care.stream.CareStreamingResponseAssembler
;
import
com.infoepoch.pms.agent.properties.CareAgentProperties
;
import
com.infoepoch.pms.agent.tool.union.care.UserSearchTool
;
import
org.junit.jupiter.api.BeforeEach
;
import
org.junit.jupiter.api.Test
;
import
org.junit.jupiter.api.extension.ExtendWith
;
import
org.mockito.ArgumentCaptor
;
import
org.mockito.Mock
;
import
org.mockito.junit.jupiter.MockitoExtension
;
import
reactor.test.StepVerifier
;
import
java.time.Duration
;
import
java.time.LocalDateTime
;
import
java.util.List
;
import
java.util.Map
;
import
java.util.Set
;
import
static
org
.
junit
.
jupiter
.
api
.
Assertions
.
assertEquals
;
import
static
org
.
junit
.
jupiter
.
api
.
Assertions
.
assertTrue
;
import
static
org
.
mockito
.
ArgumentMatchers
.
anyInt
;
import
static
org
.
mockito
.
ArgumentMatchers
.
anyMap
;
import
static
org
.
mockito
.
ArgumentMatchers
.
eq
;
import
static
org
.
mockito
.
Mockito
.
verify
;
import
static
org
.
mockito
.
Mockito
.
when
;
@ExtendWith
(
MockitoExtension
.
class
)
class
UserRecommendationExecutorTest
{
@Mock
private
UserSearchTool
userSearchTool
;
@Mock
private
CareConversationStateService
conversationStateService
;
private
UserRecommendationExecutor
executor
;
@BeforeEach
void
setUp
()
{
CareAgentProperties
properties
=
new
CareAgentProperties
();
properties
.
setUserPageSize
(
100
);
properties
.
setUserStreamItemDelayMillis
(
250
);
executor
=
new
UserRecommendationExecutor
(
userSearchTool
,
conversationStateService
,
new
CareStreamingResponseAssembler
(),
properties
);
}
@Test
void
shouldKeepDifferentUsersWhenNameMatchesButMobileDiffers
()
{
CareQuery
query
=
new
CareQuery
(
CareTaskType
.
RECOMMEND_USERS
,
2
,
false
,
"推荐用户"
,
"健步走"
,
"推荐用户"
,
Map
.
of
(),
""
);
when
(
userSearchTool
.
execute
(
eq
(
"trace-1"
),
eq
(
"session-1"
),
anyMap
(),
eq
(
1
),
anyInt
()))
.
thenReturn
(
new
PagedResult
<>(
List
.
of
(
user
(
"user:zhang-138"
,
"张三"
,
"13800000000"
),
user
(
"user:zhang-139"
,
"张三"
,
"13900000000"
)
),
2
,
false
));
StepVerifier
.
withVirtualTime
(()
->
executor
.
execute
(
"trace-1"
,
"session-1"
,
query
,
null
))
.
expectSubscription
()
.
expectNextMatches
(
chunk
->
chunk
.
contains
(
"### 用户推荐"
))
.
expectNoEvent
(
Duration
.
ofMillis
(
249
))
.
thenAwait
(
Duration
.
ofMillis
(
1
))
.
expectNextMatches
(
chunk
->
chunk
.
contains
(
"1. **张三**"
))
.
expectNoEvent
(
Duration
.
ofMillis
(
249
))
.
thenAwait
(
Duration
.
ofMillis
(
1
))
.
expectNextMatches
(
chunk
->
chunk
.
contains
(
"2. **张三**"
))
.
expectNoEvent
(
Duration
.
ofMillis
(
249
))
.
thenAwait
(
Duration
.
ofMillis
(
1
))
.
expectNextMatches
(
chunk
->
chunk
.
contains
(
"如需继续补充更多用户"
))
.
verifyComplete
();
ArgumentCaptor
<
CareConversationState
>
captor
=
ArgumentCaptor
.
forClass
(
CareConversationState
.
class
);
verify
(
conversationStateService
).
save
(
captor
.
capture
());
assertEquals
(
Set
.
of
(
"user:zhang-138"
,
"user:zhang-139"
),
captor
.
getValue
().
deliveredIds
());
}
@Test
void
shouldDeduplicateUsersWhenNameAndMobileBothMatch
()
{
CareQuery
query
=
new
CareQuery
(
CareTaskType
.
RECOMMEND_USERS
,
2
,
true
,
"再来一些"
,
"健步走"
,
"推荐用户"
,
Map
.
of
(),
""
);
CareConversationState
state
=
new
CareConversationState
(
"session-1"
,
CareTaskType
.
RECOMMEND_USERS
,
2
,
"健步走"
,
"推荐用户"
,
Map
.
of
(),
2
,
Set
.
of
(
"user:zhang-138"
),
LocalDateTime
.
now
()
);
when
(
userSearchTool
.
execute
(
eq
(
"trace-1"
),
eq
(
"session-1"
),
anyMap
(),
eq
(
2
),
anyInt
()))
.
thenReturn
(
new
PagedResult
<>(
List
.
of
(
user
(
"user:zhang-138"
,
"张三"
,
"13800000000"
),
user
(
"user:li-139"
,
"李四"
,
"13900000000"
)
),
2
,
false
));
StepVerifier
.
withVirtualTime
(()
->
executor
.
execute
(
"trace-1"
,
"session-1"
,
query
,
state
))
.
expectSubscription
()
.
expectNextMatches
(
chunk
->
chunk
.
contains
(
"### 用户推荐"
))
.
expectNoEvent
(
Duration
.
ofMillis
(
249
))
.
thenAwait
(
Duration
.
ofMillis
(
1
))
.
expectNextMatches
(
chunk
->
chunk
.
contains
(
"1. **李四**"
))
.
expectNoEvent
(
Duration
.
ofMillis
(
249
))
.
thenAwait
(
Duration
.
ofMillis
(
1
))
.
expectNextMatches
(
chunk
->
chunk
.
contains
(
"目前先为你整理到这些更匹配的用户"
))
.
verifyComplete
();
ArgumentCaptor
<
CareConversationState
>
captor
=
ArgumentCaptor
.
forClass
(
CareConversationState
.
class
);
verify
(
conversationStateService
).
save
(
captor
.
capture
());
assertEquals
(
Set
.
of
(
"user:zhang-138"
,
"user:li-139"
),
captor
.
getValue
().
deliveredIds
());
}
private
UserProfileSummary
user
(
String
userId
,
String
name
,
String
mobile
)
{
return
new
UserProfileSummary
(
userId
,
name
,
65
,
"男"
,
List
.
of
(),
List
.
of
(),
"部门A"
,
"手机号:"
+
mobile
);
}
}
src/test/java/com/infoepoch/pms/agent/domain/care/state/CareConversationStateServiceTest.java
0 → 100644
View file @
5435092e
package
com
.
infoepoch
.
pms
.
agent
.
domain
.
care
.
state
;
import
com.infoepoch.pms.agent.domain.care.model.CareConversationState
;
import
com.infoepoch.pms.agent.domain.care.model.CareQuery
;
import
com.infoepoch.pms.agent.domain.care.model.CareTaskType
;
import
com.infoepoch.pms.agent.domain.care.understanding.CareQueryUnderstandingService
;
import
com.infoepoch.pms.agent.properties.CareAgentProperties
;
import
com.infoepoch.pms.agent.tool.union.care.provider.CareBusinessDataProvider
;
import
org.junit.jupiter.api.BeforeEach
;
import
org.junit.jupiter.api.Test
;
import
org.junit.jupiter.api.extension.ExtendWith
;
import
org.mockito.Mock
;
import
org.mockito.junit.jupiter.MockitoExtension
;
import
org.springframework.ai.chat.model.ChatModel
;
import
org.springframework.data.redis.core.RedisTemplate
;
import
org.springframework.data.redis.core.ValueOperations
;
import
java.time.LocalDateTime
;
import
java.util.LinkedHashMap
;
import
java.util.List
;
import
java.util.Map
;
import
java.util.Optional
;
import
java.util.Set
;
import
static
org
.
junit
.
jupiter
.
api
.
Assertions
.
assertEquals
;
import
static
org
.
junit
.
jupiter
.
api
.
Assertions
.
assertFalse
;
import
static
org
.
junit
.
jupiter
.
api
.
Assertions
.
assertTrue
;
import
static
org
.
mockito
.
Mockito
.
mock
;
import
static
org
.
mockito
.
Mockito
.
never
;
import
static
org
.
mockito
.
Mockito
.
verify
;
import
static
org
.
mockito
.
Mockito
.
when
;
@ExtendWith
(
MockitoExtension
.
class
)
class
CareConversationStateServiceTest
{
@Mock
private
RedisTemplate
<
String
,
Object
>
redisTemplate
;
@Mock
private
ValueOperations
<
String
,
Object
>
valueOperations
;
private
CareConversationStateService
service
;
@BeforeEach
void
setUp
()
{
CareAgentProperties
properties
=
new
CareAgentProperties
();
properties
.
setConversationTtlMinutes
(
120
);
when
(
redisTemplate
.
opsForValue
()).
thenReturn
(
valueOperations
);
service
=
new
CareConversationStateService
(
redisTemplate
,
properties
);
}
@Test
void
shouldLoadTypedConversationStateDirectly
()
{
CareConversationState
expectedState
=
createState
();
when
(
valueOperations
.
get
(
"care:state:session-1"
)).
thenReturn
(
expectedState
);
Optional
<
CareConversationState
>
loaded
=
service
.
load
(
"session-1"
);
assertTrue
(
loaded
.
isPresent
());
assertEquals
(
expectedState
,
loaded
.
orElseThrow
());
}
@Test
void
shouldConvertLinkedHashMapToConversationState
()
{
CareConversationState
expectedState
=
createState
();
when
(
valueOperations
.
get
(
"care:state:session-1"
)).
thenReturn
(
toCachedMap
(
expectedState
));
Optional
<
CareConversationState
>
loaded
=
service
.
load
(
"session-1"
);
assertTrue
(
loaded
.
isPresent
());
CareConversationState
actual
=
loaded
.
orElseThrow
();
assertEquals
(
expectedState
.
sessionId
(),
actual
.
sessionId
());
assertEquals
(
expectedState
.
taskType
(),
actual
.
taskType
());
assertEquals
(
expectedState
.
requestedCount
(),
actual
.
requestedCount
());
assertEquals
(
expectedState
.
activityIntent
(),
actual
.
activityIntent
());
assertEquals
(
expectedState
.
preferenceSummary
(),
actual
.
preferenceSummary
());
assertEquals
(
expectedState
.
searchConditions
(),
actual
.
searchConditions
());
assertEquals
(
expectedState
.
nextPageNo
(),
actual
.
nextPageNo
());
assertEquals
(
expectedState
.
deliveredIds
(),
actual
.
deliveredIds
());
assertEquals
(
expectedState
.
updatedAt
(),
actual
.
updatedAt
());
}
@Test
void
shouldReturnEmptyWhenCachedObjectCannotConvertToConversationState
()
{
LinkedHashMap
<
String
,
Object
>
invalidState
=
new
LinkedHashMap
<>();
invalidState
.
put
(
"sessionId"
,
"session-1"
);
invalidState
.
put
(
"taskType"
,
"RECOMMEND_USERS"
);
invalidState
.
put
(
"updatedAt"
,
"not-a-date"
);
when
(
valueOperations
.
get
(
"care:state:session-1"
)).
thenReturn
(
invalidState
);
Optional
<
CareConversationState
>
loaded
=
service
.
load
(
"session-1"
);
assertFalse
(
loaded
.
isPresent
());
}
@Test
void
shouldReuseConvertedStateForContinuationQuery
()
{
when
(
valueOperations
.
get
(
"care:state:session-1"
)).
thenReturn
(
toCachedMap
(
createState
()));
Optional
<
CareConversationState
>
loaded
=
service
.
load
(
"session-1"
);
ChatModel
chatModel
=
mock
(
ChatModel
.
class
);
CareBusinessDataProvider
dataProvider
=
mock
(
CareBusinessDataProvider
.
class
);
CareAgentProperties
properties
=
new
CareAgentProperties
();
properties
.
setDefaultCount
(
10
);
properties
.
setMaxRequestedCount
(
500
);
CareQueryUnderstandingService
understandingService
=
new
CareQueryUnderstandingService
(
chatModel
,
properties
,
dataProvider
);
CareQuery
query
=
understandingService
.
understand
(
"trace-1"
,
"session-1"
,
"再来一些"
,
loaded
.
orElse
(
null
));
assertTrue
(
loaded
.
isPresent
());
assertTrue
(
query
.
continuation
());
assertEquals
(
CareTaskType
.
RECOMMEND_USERS
,
query
.
taskType
());
assertEquals
(
"健步走活动"
,
query
.
activityIntent
());
assertEquals
(
Map
.
of
(
"regionName"
,
"浦东新区"
),
query
.
searchConditions
());
verify
(
chatModel
,
never
()).
call
(
org
.
mockito
.
ArgumentMatchers
.
anyString
());
}
private
CareConversationState
createState
()
{
return
new
CareConversationState
(
"session-1"
,
CareTaskType
.
RECOMMEND_USERS
,
25
,
"健步走活动"
,
"推荐更多适合健步走的用户"
,
Map
.
of
(
"regionName"
,
"浦东新区"
),
2
,
Set
.
of
(
"u-1"
,
"u-2"
),
LocalDateTime
.
of
(
2026
,
4
,
8
,
14
,
38
,
5
)
);
}
private
Map
<
String
,
Object
>
toCachedMap
(
CareConversationState
state
)
{
LinkedHashMap
<
String
,
Object
>
cached
=
new
LinkedHashMap
<>();
cached
.
put
(
"sessionId"
,
state
.
sessionId
());
cached
.
put
(
"taskType"
,
state
.
taskType
().
name
());
cached
.
put
(
"requestedCount"
,
state
.
requestedCount
());
cached
.
put
(
"activityIntent"
,
state
.
activityIntent
());
cached
.
put
(
"preferenceSummary"
,
state
.
preferenceSummary
());
cached
.
put
(
"searchConditions"
,
new
LinkedHashMap
<>(
state
.
searchConditions
()));
cached
.
put
(
"nextPageNo"
,
state
.
nextPageNo
());
cached
.
put
(
"deliveredIds"
,
List
.
copyOf
(
state
.
deliveredIds
()));
cached
.
put
(
"updatedAt"
,
state
.
updatedAt
().
toString
());
return
cached
;
}
}
src/test/java/com/infoepoch/pms/agent/domain/care/understanding/CareQueryUnderstandingServiceTest.java
0 → 100644
View file @
5435092e
package
com
.
infoepoch
.
pms
.
agent
.
domain
.
care
.
understanding
;
import
com.infoepoch.pms.agent.domain.care.model.ActivityMatchUserRule
;
import
com.infoepoch.pms.agent.domain.care.model.CareConversationState
;
import
com.infoepoch.pms.agent.domain.care.model.CareQuery
;
import
com.infoepoch.pms.agent.domain.care.model.CareTaskType
;
import
com.infoepoch.pms.agent.properties.CareAgentProperties
;
import
com.infoepoch.pms.agent.tool.union.care.provider.CareBusinessDataProvider
;
import
org.junit.jupiter.api.BeforeEach
;
import
org.junit.jupiter.api.Test
;
import
org.junit.jupiter.api.extension.ExtendWith
;
import
org.mockito.Mock
;
import
org.mockito.junit.jupiter.MockitoExtension
;
import
org.springframework.ai.chat.model.ChatModel
;
import
java.time.LocalDateTime
;
import
java.util.List
;
import
java.util.Map
;
import
java.util.Set
;
import
static
org
.
junit
.
jupiter
.
api
.
Assertions
.
assertEquals
;
import
static
org
.
junit
.
jupiter
.
api
.
Assertions
.
assertTrue
;
import
static
org
.
mockito
.
ArgumentMatchers
.
anyString
;
import
static
org
.
mockito
.
Mockito
.
never
;
import
static
org
.
mockito
.
Mockito
.
times
;
import
static
org
.
mockito
.
Mockito
.
verify
;
import
static
org
.
mockito
.
Mockito
.
when
;
@ExtendWith
(
MockitoExtension
.
class
)
class
CareQueryUnderstandingServiceTest
{
@Mock
private
ChatModel
chatModel
;
@Mock
private
CareBusinessDataProvider
careBusinessDataProvider
;
private
CareQueryUnderstandingService
service
;
@BeforeEach
void
setUp
()
{
CareAgentProperties
properties
=
new
CareAgentProperties
();
properties
.
setDefaultCount
(
10
);
properties
.
setMaxRequestedCount
(
500
);
service
=
new
CareQueryUnderstandingService
(
chatModel
,
properties
,
careBusinessDataProvider
);
}
@Test
void
shouldUseGeneralChatForUnsupportedHistoryQuestion
()
{
when
(
chatModel
.
call
(
anyString
())).
thenReturn
(
"GENERAL_CHAT"
);
CareQuery
query
=
service
.
understand
(
"trace-1"
,
"session-1"
,
"活动历史有哪些"
,
null
);
assertEquals
(
CareTaskType
.
GENERAL_CHAT
,
query
.
taskType
());
assertEquals
(
""
,
query
.
directReply
());
verify
(
chatModel
,
times
(
1
)).
call
(
anyString
());
}
@Test
void
shouldKeepContinuationStateWithoutCallingModel
()
{
CareConversationState
state
=
new
CareConversationState
(
"session-1"
,
CareTaskType
.
RECOMMEND_USERS
,
25
,
"健步走活动"
,
"推荐更多适合健步走的用户"
,
Map
.
of
(
"regionName"
,
"浦东新区"
),
2
,
Set
.
of
(
"u-1"
),
LocalDateTime
.
now
()
);
CareQuery
query
=
service
.
understand
(
"trace-1"
,
"session-1"
,
"继续"
,
state
);
assertEquals
(
CareTaskType
.
RECOMMEND_USERS
,
query
.
taskType
());
assertTrue
(
query
.
continuation
());
assertEquals
(
"健步走活动"
,
query
.
activityIntent
());
assertEquals
(
Map
.
of
(
"regionName"
,
"浦东新区"
),
query
.
searchConditions
());
verify
(
chatModel
,
never
()).
call
(
anyString
());
}
@Test
void
shouldFallbackToGeneralChatWhenClassificationModelThrows
()
{
when
(
chatModel
.
call
(
anyString
())).
thenThrow
(
new
RuntimeException
(
"model error"
));
CareQuery
query
=
service
.
understand
(
"trace-1"
,
"session-1"
,
"帮我看看活动历史"
,
null
);
assertEquals
(
CareTaskType
.
GENERAL_CHAT
,
query
.
taskType
());
assertEquals
(
""
,
query
.
directReply
());
verify
(
chatModel
,
times
(
1
)).
call
(
anyString
());
}
@Test
void
shouldFallbackToGeneralChatWhenClassificationModelReturnsUnknownValue
()
{
when
(
chatModel
.
call
(
anyString
())).
thenReturn
(
"SOMETHING_ELSE"
);
CareQuery
query
=
service
.
understand
(
"trace-1"
,
"session-1"
,
"你好,你是谁"
,
null
);
assertEquals
(
CareTaskType
.
GENERAL_CHAT
,
query
.
taskType
());
assertEquals
(
""
,
query
.
directReply
());
verify
(
chatModel
,
times
(
1
)).
call
(
anyString
());
}
@Test
void
shouldNotTreatAgeAsRequestedCount
()
{
when
(
chatModel
.
call
(
anyString
())).
thenReturn
(
"GENERAL_CHAT"
);
CareQuery
query
=
service
.
understand
(
"trace-1"
,
"session-1"
,
"给65岁老人推荐活动"
,
null
);
assertEquals
(
10
,
query
.
requestedCount
());
assertEquals
(
CareTaskType
.
GENERAL_CHAT
,
query
.
taskType
());
verify
(
chatModel
,
times
(
1
)).
call
(
anyString
());
}
@Test
void
shouldNotTreatMonthAsRequestedCount
()
{
when
(
chatModel
.
call
(
anyString
())).
thenReturn
(
"GENERAL_CHAT"
);
CareQuery
query
=
service
.
understand
(
"trace-1"
,
"session-1"
,
"推荐3月的活动"
,
null
);
assertEquals
(
10
,
query
.
requestedCount
());
assertEquals
(
CareTaskType
.
GENERAL_CHAT
,
query
.
taskType
());
verify
(
chatModel
,
times
(
1
)).
call
(
anyString
());
}
@Test
void
shouldNotTreatBmiAsRequestedCount
()
{
when
(
chatModel
.
call
(
anyString
())).
thenReturn
(
"RECOMMEND_USERS"
)
.
thenReturn
(
"""
{
"
taskType
": "
RECOMMEND_USERS
",
"
activityIntent
": "
健康讲座
",
"
preferenceSummary
": "
BMI
24
的用户
",
"
searchConditions
": {
"
bmi
": "
24
"
}
}
"""
);
CareQuery
query
=
service
.
understand
(
"trace-1"
,
"session-1"
,
"推荐 BMI 24 的用户"
,
null
);
assertEquals
(
10
,
query
.
requestedCount
());
assertEquals
(
CareTaskType
.
RECOMMEND_USERS
,
query
.
taskType
());
assertEquals
(
"24"
,
query
.
searchConditions
().
get
(
"bmi"
));
verify
(
chatModel
,
times
(
2
)).
call
(
anyString
());
}
@Test
void
shouldExtractRequestedCountOnlyFromExplicitCountExpression
()
{
when
(
chatModel
.
call
(
anyString
())).
thenReturn
(
"GENERAL_CHAT"
);
CareQuery
query
=
service
.
understand
(
"trace-1"
,
"session-1"
,
"推荐5个活动"
,
null
);
assertEquals
(
5
,
query
.
requestedCount
());
assertEquals
(
CareTaskType
.
GENERAL_CHAT
,
query
.
taskType
());
verify
(
chatModel
,
times
(
1
)).
call
(
anyString
());
}
@Test
void
shouldReclassifyContinuationWhenMessageContainsNewIntent
()
{
CareConversationState
state
=
new
CareConversationState
(
"session-1"
,
CareTaskType
.
RECOMMEND_ACTIVITIES
,
10
,
"广场舞活动"
,
"推荐适合当前用户的活动"
,
Map
.
of
(
"departmentName"
,
"老年大学"
),
1
,
Set
.
of
(
"a-1"
),
LocalDateTime
.
now
()
);
when
(
chatModel
.
call
(
anyString
()))
.
thenReturn
(
"RECOMMEND_USERS"
)
.
thenReturn
(
"""
{
"
taskType
": "
RECOMMEND_USERS
",
"
activityIntent
": "
健步走
",
"
preferenceSummary
": "
继续推荐适合健步走的用户
",
"
searchConditions
": {
"
regionName
": "
浦东新区
"
}
}
"""
);
CareQuery
query
=
service
.
understand
(
"trace-1"
,
"session-1"
,
"继续推荐适合健步走的用户"
,
state
);
assertEquals
(
CareTaskType
.
RECOMMEND_USERS
,
query
.
taskType
());
assertTrue
(
query
.
continuation
());
assertEquals
(
"健步走"
,
query
.
activityIntent
());
assertEquals
(
"浦东新区"
,
query
.
searchConditions
().
get
(
"regionName"
));
assertEquals
(
1
,
query
.
searchConditions
().
size
());
verify
(
chatModel
,
times
(
2
)).
call
(
anyString
());
}
@Test
void
shouldFallbackWhenUnderstandingModelThrows
()
{
when
(
chatModel
.
call
(
anyString
()))
.
thenReturn
(
"RECOMMEND_USERS"
)
.
thenThrow
(
new
RuntimeException
(
"timeout"
));
CareQuery
query
=
service
.
understand
(
"trace-1"
,
"session-1"
,
"推荐参加健步走活动的用户"
,
null
);
assertEquals
(
CareTaskType
.
RECOMMEND_USERS
,
query
.
taskType
());
assertEquals
(
"推荐参加健步走活动的用户"
,
query
.
activityIntent
());
assertEquals
(
"推荐参加健步走活动的用户"
,
query
.
preferenceSummary
());
assertTrue
(
query
.
searchConditions
().
isEmpty
());
verify
(
chatModel
,
times
(
2
)).
call
(
anyString
());
}
@Test
void
shouldUseModelForRecommendUsersAndParseUnderstandingResult
()
{
when
(
chatModel
.
call
(
anyString
()))
.
thenReturn
(
"RECOMMEND_USERS"
)
.
thenReturn
(
"""
{
"
taskType
": "
RECOMMEND_USERS
",
"
activityIntent
": "
健步走活动
",
"
preferenceSummary
": "
推荐喜欢运动的老年用户
",
"
searchConditions
": {
"
regionName
": "
浦东新区
",
"
sex
": "
女
",
"
hobby
": ["
健步走
", "
太极
"]
}
}
"""
);
CareQuery
query
=
service
.
understand
(
"trace-1"
,
"session-1"
,
"推荐参加健步走活动的用户"
,
null
);
assertEquals
(
CareTaskType
.
RECOMMEND_USERS
,
query
.
taskType
());
assertEquals
(
"健步走活动"
,
query
.
activityIntent
());
assertEquals
(
"推荐喜欢运动的老年用户"
,
query
.
preferenceSummary
());
assertEquals
(
"浦东新区"
,
query
.
searchConditions
().
get
(
"regionName"
));
assertEquals
(
"1"
,
query
.
searchConditions
().
get
(
"sex"
));
assertEquals
(
"健步走,太极"
,
query
.
searchConditions
().
get
(
"hobby"
));
verify
(
chatModel
,
times
(
2
)).
call
(
anyString
());
}
@Test
void
shouldCompleteRecommendUserConditionsFromActivityRules
()
{
when
(
chatModel
.
call
(
anyString
()))
.
thenReturn
(
"RECOMMEND_USERS"
)
.
thenReturn
(
"""
{
"
taskType
": "
RECOMMEND_USERS
",
"
activityIntent
": "
健步走活动
",
"
preferenceSummary
": "
推荐适合健步走活动的用户
",
"
searchConditions
": {
"
regionName
": "
浦东新区
"
}
}
"""
);
when
(
careBusinessDataProvider
.
listActivityMatchUserRules
(
"trace-1"
,
"session-1"
))
.
thenReturn
(
List
.
of
(
new
ActivityMatchUserRule
(
"健步走"
,
""
,
55
,
75
,
"偏高"
,
"跑步,徒步"
,
""
)));
CareQuery
query
=
service
.
understand
(
"trace-1"
,
"session-1"
,
"健步走活动,推荐一些用户"
,
null
);
assertEquals
(
"浦东新区"
,
query
.
searchConditions
().
get
(
"regionName"
));
assertEquals
(
55
,
query
.
searchConditions
().
get
(
"minAge"
));
assertEquals
(
75
,
query
.
searchConditions
().
get
(
"maxAge"
));
assertEquals
(
"偏高"
,
query
.
searchConditions
().
get
(
"bmi"
));
assertEquals
(
"跑步,徒步"
,
query
.
searchConditions
().
get
(
"hobby"
));
}
@Test
void
shouldPreferExplicitConditionsOverActivityRules
()
{
when
(
chatModel
.
call
(
anyString
()))
.
thenReturn
(
"RECOMMEND_USERS"
)
.
thenReturn
(
"""
{
"
taskType
": "
RECOMMEND_USERS
",
"
activityIntent
": "
健步走活动
",
"
preferenceSummary
": "
推荐喜欢太极的用户
",
"
searchConditions
": {
"
hobby
": "
太极
,
徒步
",
"
bmi
": "
超重
",
"
sex
": "
女
"
}
}
"""
);
when
(
careBusinessDataProvider
.
listActivityMatchUserRules
(
"trace-1"
,
"session-1"
))
.
thenReturn
(
List
.
of
(
new
ActivityMatchUserRule
(
"健步走"
,
"0"
,
55
,
75
,
"偏高"
,
"跑步,徒步"
,
"组织活动"
)));
CareQuery
query
=
service
.
understand
(
"trace-1"
,
"session-1"
,
"健步走活动,推荐一些用户"
,
null
);
assertEquals
(
"1"
,
query
.
searchConditions
().
get
(
"sex"
));
assertEquals
(
"太极,徒步,跑步"
,
query
.
searchConditions
().
get
(
"hobby"
));
assertEquals
(
55
,
query
.
searchConditions
().
get
(
"minAge"
));
assertEquals
(
75
,
query
.
searchConditions
().
get
(
"maxAge"
));
assertEquals
(
"超重,偏高"
,
query
.
searchConditions
().
get
(
"bmi"
));
assertEquals
(
"组织活动"
,
query
.
searchConditions
().
get
(
"specialty"
));
}
@Test
void
shouldFallbackToModelConditionsWhenRuleLookupFails
()
{
when
(
chatModel
.
call
(
anyString
()))
.
thenReturn
(
"RECOMMEND_USERS"
)
.
thenReturn
(
"""
{
"
taskType
": "
RECOMMEND_USERS
",
"
activityIntent
": "
健步走活动
",
"
preferenceSummary
": "
推荐适合健步走的用户
",
"
searchConditions
": {
"
regionName
": "
浦东新区
"
}
}
"""
);
when
(
careBusinessDataProvider
.
listActivityMatchUserRules
(
"trace-1"
,
"session-1"
))
.
thenThrow
(
new
RuntimeException
(
"tool error"
));
CareQuery
query
=
service
.
understand
(
"trace-1"
,
"session-1"
,
"健步走活动,推荐一些用户"
,
null
);
assertEquals
(
Map
.
of
(
"regionName"
,
"浦东新区"
),
query
.
searchConditions
());
}
}
src/test/java/com/infoepoch/pms/agent/tool/union/care/provider/HttpCareBusinessDataProviderTest.java
0 → 100644
View file @
5435092e
package
com
.
infoepoch
.
pms
.
agent
.
tool
.
union
.
care
.
provider
;
import
com.infoepoch.pms.agent.domain.care.model.PagedResult
;
import
com.infoepoch.pms.agent.domain.care.model.UserProfileSummary
;
import
com.infoepoch.pms.agent.properties.CareBusinessProperties
;
import
com.sun.net.httpserver.HttpExchange
;
import
com.sun.net.httpserver.HttpServer
;
import
org.junit.jupiter.api.AfterEach
;
import
org.junit.jupiter.api.Test
;
import
java.io.IOException
;
import
java.io.OutputStream
;
import
java.net.InetSocketAddress
;
import
java.nio.charset.StandardCharsets
;
import
java.util.Map
;
import
static
org
.
junit
.
jupiter
.
api
.
Assertions
.
assertEquals
;
import
static
org
.
junit
.
jupiter
.
api
.
Assertions
.
assertNotEquals
;
class
HttpCareBusinessDataProviderTest
{
private
HttpServer
server
;
@AfterEach
void
tearDown
()
{
if
(
server
!=
null
)
{
server
.
stop
(
0
);
}
}
@Test
void
shouldDifferentiateUsersWithSameMobileButDifferentNames
()
throws
IOException
{
HttpCareBusinessDataProvider
provider
=
createProvider
(
"""
{
"
code
": 1,
"
msg
": "
ok
",
"
data
": {
"
totalCount
": 2,
"
dataList
": [
{ "
name
": "
张三
", "
mobile
": "
13800000000
", "
departmentName
": "
一部
" },
{ "
name
": "
李四
", "
mobile
": "
13800000000
", "
departmentName
": "
二部
" }
]
}
}
"""
);
PagedResult
<
UserProfileSummary
>
result
=
provider
.
searchUsersByConditions
(
"trace-1"
,
"session-1"
,
Map
.
of
(),
1
,
10
);
assertEquals
(
2
,
result
.
records
().
size
());
assertNotEquals
(
result
.
records
().
get
(
0
).
userId
(),
result
.
records
().
get
(
1
).
userId
());
}
@Test
void
shouldIgnoreDepartmentWhenMobileIsBlank
()
throws
IOException
{
HttpCareBusinessDataProvider
provider
=
createProvider
(
"""
{
"
code
": 1,
"
msg
": "
ok
",
"
data
": {
"
totalCount
": 2,
"
dataList
": [
{ "
name
": "
张三
", "
mobile
": "", "
departmentName
": "
一部
" },
{ "
name
": "
张三
", "
mobile
": "", "
departmentName
": "
二部
" }
]
}
}
"""
);
PagedResult
<
UserProfileSummary
>
result
=
provider
.
searchUsersByConditions
(
"trace-1"
,
"session-1"
,
Map
.
of
(),
1
,
10
);
assertEquals
(
2
,
result
.
records
().
size
());
assertEquals
(
result
.
records
().
get
(
0
).
userId
(),
result
.
records
().
get
(
1
).
userId
());
}
@Test
void
shouldDifferentiateUsersWithSameNameButDifferentMobiles
()
throws
IOException
{
HttpCareBusinessDataProvider
provider
=
createProvider
(
"""
{
"
code
": 1,
"
msg
": "
ok
",
"
data
": {
"
totalCount
": 2,
"
dataList
": [
{ "
name
": "
张三
", "
mobile
": "
13800000000
", "
departmentName
": "
一部
" },
{ "
name
": "
张三
", "
mobile
": "
13900000000
", "
departmentName
": "
一部
" }
]
}
}
"""
);
PagedResult
<
UserProfileSummary
>
result
=
provider
.
searchUsersByConditions
(
"trace-1"
,
"session-1"
,
Map
.
of
(),
1
,
10
);
assertEquals
(
2
,
result
.
records
().
size
());
assertNotEquals
(
result
.
records
().
get
(
0
).
userId
(),
result
.
records
().
get
(
1
).
userId
());
}
private
HttpCareBusinessDataProvider
createProvider
(
String
responseBody
)
throws
IOException
{
server
=
HttpServer
.
create
(
new
InetSocketAddress
(
0
),
0
);
server
.
createContext
(
"/union-js/api/functionCallTools/getUserInfoList"
,
exchange
->
writeJsonResponse
(
exchange
,
responseBody
));
server
.
start
();
CareBusinessProperties
properties
=
new
CareBusinessProperties
();
properties
.
setBaseUrl
(
"http://127.0.0.1:"
+
server
.
getAddress
().
getPort
());
properties
.
setUserSearchPath
(
"/union-js/api/functionCallTools/getUserInfoList"
);
return
new
HttpCareBusinessDataProvider
(
properties
);
}
private
void
writeJsonResponse
(
HttpExchange
exchange
,
String
responseBody
)
throws
IOException
{
byte
[]
body
=
responseBody
.
getBytes
(
StandardCharsets
.
UTF_8
);
exchange
.
getResponseHeaders
().
add
(
"Content-Type"
,
"application/json; charset=UTF-8"
);
exchange
.
sendResponseHeaders
(
200
,
body
.
length
);
try
(
OutputStream
outputStream
=
exchange
.
getResponseBody
())
{
outputStream
.
write
(
body
);
}
finally
{
exchange
.
close
();
}
}
}
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