Commit 5435092e authored by jiangyz's avatar jiangyz

代码提交

parent da58786b
package com.infoepoch.pms.agent.controller.request;
/**
* 精准关爱智能体流式会话请求。
*
* @param sessionId 会话唯一标识,用于串联短会话上下文
* @param message 用户发送给精准关爱智能体的自然语言内容
*/
public record CareAgentChatRequest(String sessionId, String message) {
}
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 : "-";
}
}
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();
}
}
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);
}
}
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);
}
}
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);
}
}
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;
}
}
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);
}
}
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);
}
}
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);
}
}
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 -> "通用对话";
};
}
}
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("调用模型失败"));
}
}
}
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
) {
}
}
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;
}
}
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";
}
}
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
) {
}
}
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;
}
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;
}
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);
}
}
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);
}
}
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);
}
}
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);
}
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());
}
}
}
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("户外"), "浦东", "周三", "摘要");
}
}
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("引导用户改问"));
}
}
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
);
}
}
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;
}
}
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());
}
}
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();
}
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment