feat: 实现客户画像自动生成功能
1. 新增客户画像生成相关实体、Mapper、Service和控制器 2. 扩展CustomerProfile实体字段,增加自动生成标记相关属性 3. 新增画像生成日志表和相关业务逻辑 4. 前端客户列表页面增加画像管理Tab和批量生成功能 5. 补充完整的客户画像自动生成方案文档
This commit is contained in:
parent
db8aa25da6
commit
bd9920ca24
Binary file not shown.
@ -0,0 +1,80 @@
|
|||||||
|
package com.artedu.intent.controller;
|
||||||
|
|
||||||
|
import com.artedu.common.result.Result;
|
||||||
|
import com.artedu.intent.entity.ProfileGenerationLog;
|
||||||
|
import com.artedu.intent.service.ProfileGenerationService;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import javax.validation.constraints.Max;
|
||||||
|
import javax.validation.constraints.Min;
|
||||||
|
import javax.validation.constraints.NotBlank;
|
||||||
|
import javax.validation.constraints.NotEmpty;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户画像生成管理接口(Admin 后台用)
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Validated
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/intent/profiles")
|
||||||
|
public class ProfileGenerationController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ProfileGenerationService profileGenerationService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询无画像的客户列表
|
||||||
|
*/
|
||||||
|
@GetMapping("/without-profile")
|
||||||
|
public Result<List<Map<String, Object>>> listCustomersWithoutProfile(
|
||||||
|
@NotBlank @RequestParam("corpId") String corpId,
|
||||||
|
@RequestParam(value = "minMsgCount", defaultValue = "5") @Min(1) @Max(100) int minMsgCount,
|
||||||
|
@RequestParam(value = "limit", defaultValue = "50") @Min(1) @Max(200) int limit) {
|
||||||
|
List<Map<String, Object>> list = profileGenerationService.findCustomersWithoutProfile(corpId, limit, minMsgCount);
|
||||||
|
return Result.success(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为单个客户生成画像
|
||||||
|
*/
|
||||||
|
@PostMapping("/generate/{customerId}")
|
||||||
|
public Result<ProfileGenerationService.GenerationResult> generateSingle(
|
||||||
|
@NotBlank @PathVariable("customerId") String customerId,
|
||||||
|
@NotBlank @RequestParam("corpId") String corpId) {
|
||||||
|
log.info("手动生成客户画像: customerId={}, corpId={}", customerId, corpId);
|
||||||
|
ProfileGenerationService.GenerationResult result = profileGenerationService.generateFullProfile(customerId, corpId);
|
||||||
|
if (result.isSuccess()) {
|
||||||
|
return Result.success(result);
|
||||||
|
} else {
|
||||||
|
return Result.fail(result.getErrorMsg());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量生成画像(异步)
|
||||||
|
*/
|
||||||
|
@PostMapping("/generate/batch")
|
||||||
|
public Result<String> batchGenerate(@RequestBody @Validated BatchGenerateRequest request) {
|
||||||
|
log.info("批量生成画像: corpId={}, customerCount={}", request.getCorpId(), request.getCustomerIds().size());
|
||||||
|
profileGenerationService.batchGenerate(request.getCorpId(), request.getCustomerIds(), request.getOperator());
|
||||||
|
return Result.success("批量画像生成任务已启动,请稍后查看结果");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DTO ---
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class BatchGenerateRequest {
|
||||||
|
@NotBlank(message = "corpId不能为空")
|
||||||
|
private String corpId;
|
||||||
|
@NotEmpty(message = "customerIds不能为空")
|
||||||
|
private List<String> customerIds;
|
||||||
|
private String operator;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -35,6 +35,9 @@ public class CustomerProfile {
|
|||||||
private String slotData;
|
private String slotData;
|
||||||
private Integer conversationCount;
|
private Integer conversationCount;
|
||||||
private LocalDateTime lastConversationTime;
|
private LocalDateTime lastConversationTime;
|
||||||
|
private Integer autoGenerated;
|
||||||
|
private LocalDateTime lastGeneratedAt;
|
||||||
|
private String generatedBy;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,34 @@
|
|||||||
|
package com.artedu.intent.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 画像生成日志
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("profile_generation_logs")
|
||||||
|
public class ProfileGenerationLog {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private String customerId;
|
||||||
|
private String corpId;
|
||||||
|
private String generationType;
|
||||||
|
private Integer sourceMsgCount;
|
||||||
|
private Integer sourceSessionCount;
|
||||||
|
private LocalDateTime analyzedAt;
|
||||||
|
private String llmModel;
|
||||||
|
private Integer llmPromptLength;
|
||||||
|
private Integer llmResponseLength;
|
||||||
|
private Integer durationMs;
|
||||||
|
private Integer success;
|
||||||
|
private String errorMsg;
|
||||||
|
private String changesSnapshot;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
package com.artedu.intent.mapper;
|
||||||
|
|
||||||
|
import com.artedu.intent.entity.ProfileGenerationLog;
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface ProfileGenerationLogMapper extends BaseMapper<ProfileGenerationLog> {
|
||||||
|
}
|
||||||
@ -0,0 +1,473 @@
|
|||||||
|
package com.artedu.intent.service;
|
||||||
|
|
||||||
|
import com.artedu.common.result.PageResult;
|
||||||
|
import com.artedu.common.util.JsonUtils;
|
||||||
|
import com.artedu.intent.entity.CustomerProfile;
|
||||||
|
import com.artedu.intent.entity.ProfileGenerationLog;
|
||||||
|
import com.artedu.intent.mapper.CustomerProfileMapper;
|
||||||
|
import com.artedu.intent.mapper.ProfileGenerationLogMapper;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.*;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户画像自动生成服务
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class ProfileGenerationService {
|
||||||
|
|
||||||
|
@Value("${generation.service.url:http://generation-service:8086}")
|
||||||
|
private String generationServiceUrl;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private CustomerProfileMapper customerProfileMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ProfileGenerationLogMapper logMapper;
|
||||||
|
|
||||||
|
private final RestTemplate restTemplate = new RestTemplate();
|
||||||
|
|
||||||
|
private static final String SYSTEM_PROMPT = "你是一位专业的教育培训机构客户分析师。请根据学员与课程顾问的完整对话历史,提取学员画像信息。\n" +
|
||||||
|
"\n【分析要求】\n" +
|
||||||
|
"1. 仔细阅读所有对话,从中提取学员的真实情况和需求\n" +
|
||||||
|
"2. 不要凭空猜测,只提取对话中有明确证据支撑的信息\n" +
|
||||||
|
"3. 如果某个字段在对话中没有足够证据,请标记为\"未知\"并给出低置信度\n" +
|
||||||
|
"4. 对于同一字段的多个线索,综合判断取最可靠的结论\n" +
|
||||||
|
"5. 注意识别\"家长代询\"的情况(对话中出现\"我孩子\"、\"我家\"等表述)\n" +
|
||||||
|
"\n【输出格式】\n" +
|
||||||
|
"必须严格返回以下JSON格式,不要包含任何其他文字:\n" +
|
||||||
|
"{\"studentType\":{\"value\":\"\",\"confidence\":0,\"evidence\":\"\"},\"skillLevel\":{\"value\":\"\",\"confidence\":0,\"evidence\":\"\"},\"intentLevel\":{\"value\":\"\",\"confidence\":0,\"evidence\":\"\"},\"intentScore\":{\"value\":0,\"confidence\":0,\"evidence\":\"\"},\"concernFocus\":{\"value\":\"\",\"confidence\":0,\"evidence\":\"\"},\"decisionStage\":{\"value\":\"\",\"confidence\":0,\"evidence\":\"\"},\"interestedCourses\":{\"value\":[],\"confidence\":0,\"evidence\":\"\"},\"budgetHint\":{\"value\":\"\",\"confidence\":0,\"evidence\":\"\"},\"preferredCity\":{\"value\":\"\",\"confidence\":0,\"evidence\":\"\"},\"age\":{\"value\":null,\"confidence\":0,\"evidence\":\"\"},\"education\":{\"value\":\"\",\"confidence\":0,\"evidence\":\"\"},\"currentOccupation\":{\"value\":\"\",\"confidence\":0,\"evidence\":\"\"},\"summary\":\"\",\"keyQuotes\":[]}\n" +
|
||||||
|
"\n【字段枚举值】\n" +
|
||||||
|
"- studentType: 在校大学生/转行人员/在职提升/高中毕业生/家长代询/未知\n" +
|
||||||
|
"- skillLevel: 零基础/有美术基础/相关专业/有从业经验/未知\n" +
|
||||||
|
"- intentLevel: 高/中/低\n" +
|
||||||
|
"- concernFocus: 就业导向型/兴趣导向型/价格敏感型/品质导向型/未知\n" +
|
||||||
|
"- decisionStage: 初步了解/方案比较/决定报名/已报名/未知\n" +
|
||||||
|
"- education: 高中/大专/本科/硕士/博士/未知\n" +
|
||||||
|
"\n【关键字段特别说明】\n" +
|
||||||
|
"1. intentScore(意向分数0-100):即使没有明确数字评分,也必须根据对话强度推断。高意向=75-95,中意向=40-74,低意向=10-39\n" +
|
||||||
|
"2. interestedCourses:尽量提取具体课程方向,如\"场景地编\"优于\"模型课程\"\n" +
|
||||||
|
"\n【置信度评分标准】\n" +
|
||||||
|
"- 0.90-1.00: 学员明确亲口说过\n" +
|
||||||
|
"- 0.70-0.89: 有较强暗示或多处线索一致\n" +
|
||||||
|
"- 0.50-0.69: 有一处间接线索\n" +
|
||||||
|
"- 0.30-0.49: 只有微弱暗示\n" +
|
||||||
|
"- 0.00-0.29: 没有任何线索";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为单个客户生成画像(全量分析)
|
||||||
|
*/
|
||||||
|
public GenerationResult generateFullProfile(String customerId, String corpId) {
|
||||||
|
long startTime = System.currentTimeMillis();
|
||||||
|
GenerationResult result = new GenerationResult();
|
||||||
|
result.setCustomerId(customerId);
|
||||||
|
result.setCorpId(corpId);
|
||||||
|
|
||||||
|
// 1. 获取客户对话
|
||||||
|
List<Map<String, Object>> messages = selectCustomerMessages(corpId, customerId);
|
||||||
|
if (messages.isEmpty()) {
|
||||||
|
result.setSuccess(false);
|
||||||
|
result.setErrorMsg("该客户没有对话记录");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 格式化对话
|
||||||
|
String conversationText = formatConversation(messages);
|
||||||
|
int msgCount = messages.size();
|
||||||
|
int sessionCount = countDistinctSessions(corpId, customerId);
|
||||||
|
|
||||||
|
// 3. 调用 LLM
|
||||||
|
String llmResponse = callLLM(conversationText);
|
||||||
|
if (llmResponse == null) {
|
||||||
|
result.setSuccess(false);
|
||||||
|
result.setErrorMsg("LLM调用失败");
|
||||||
|
saveLog(customerId, corpId, "BATCH_FULL", msgCount, sessionCount, startTime, false, "LLM调用失败", null);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 解析结果
|
||||||
|
ProfileParseResult parsed = parseProfileResult(llmResponse);
|
||||||
|
if (!parsed.isValid()) {
|
||||||
|
result.setSuccess(false);
|
||||||
|
result.setErrorMsg("LLM返回解析失败: " + parsed.getError());
|
||||||
|
saveLog(customerId, corpId, "BATCH_FULL", msgCount, sessionCount, startTime, false, parsed.getError(), null);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 保存画像
|
||||||
|
CustomerProfile profile = convertToProfile(customerId, corpId, parsed);
|
||||||
|
CustomerProfile exist = customerProfileMapper.selectOne(
|
||||||
|
new LambdaQueryWrapper<CustomerProfile>()
|
||||||
|
.eq(CustomerProfile::getCustomerId, customerId)
|
||||||
|
.eq(CustomerProfile::getCorpId, corpId)
|
||||||
|
);
|
||||||
|
if (exist == null) {
|
||||||
|
profile.setCreatedAt(LocalDateTime.now());
|
||||||
|
customerProfileMapper.insert(profile);
|
||||||
|
} else {
|
||||||
|
profile.setId(exist.getId());
|
||||||
|
profile.setCreatedAt(exist.getCreatedAt());
|
||||||
|
customerProfileMapper.updateById(profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 记录日志
|
||||||
|
long duration = System.currentTimeMillis() - startTime;
|
||||||
|
saveLog(customerId, corpId, "BATCH_FULL", msgCount, sessionCount, startTime, true, null, llmResponse);
|
||||||
|
|
||||||
|
result.setSuccess(true);
|
||||||
|
result.setProfile(profile);
|
||||||
|
result.setDurationMs((int) duration);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量生成画像(异步)
|
||||||
|
*/
|
||||||
|
@Async
|
||||||
|
public void batchGenerate(String corpId, List<String> customerIds, String operator) {
|
||||||
|
log.info("开始批量画像生成: corpId={}, customerCount={}", corpId, customerIds.size());
|
||||||
|
int success = 0;
|
||||||
|
int fail = 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < customerIds.size(); i++) {
|
||||||
|
String customerId = customerIds.get(i);
|
||||||
|
try {
|
||||||
|
GenerationResult result = generateFullProfile(customerId, corpId);
|
||||||
|
if (result.isSuccess()) {
|
||||||
|
success++;
|
||||||
|
} else {
|
||||||
|
fail++;
|
||||||
|
log.warn("画像生成失败: customerId={}, reason={}", customerId, result.getErrorMsg());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
fail++;
|
||||||
|
log.error("画像生成异常: customerId={}", customerId, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每处理10个休眠1秒,避免LLM限流
|
||||||
|
if ((i + 1) % 10 == 0) {
|
||||||
|
try { Thread.sleep(1000); } catch (InterruptedException ignored) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("批量画像生成完成: corpId={}, success={}, fail={}", corpId, success, fail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询需要生成画像的客户列表
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> findCustomersWithoutProfile(String corpId, int limit, Integer minMsgCount) {
|
||||||
|
String sql = "SELECT c.customer_id, COUNT(m.id) as msg_count " +
|
||||||
|
"FROM customers c " +
|
||||||
|
"LEFT JOIN customer_profiles p ON c.customer_id = p.customer_id AND c.corp_id = p.corp_id " +
|
||||||
|
"LEFT JOIN archive_messages m ON c.customer_id = m.from_user AND c.corp_id = m.corp_id " +
|
||||||
|
" AND m.from_role = 'EXTERNAL' AND m.msgtype = 'text' AND m.decrypt_status = 1 " +
|
||||||
|
"WHERE c.corp_id = ? AND (p.id IS NULL OR p.auto_generated = 0) " +
|
||||||
|
"GROUP BY c.customer_id " +
|
||||||
|
"HAVING COUNT(m.id) >= ? " +
|
||||||
|
"ORDER BY COUNT(m.id) DESC " +
|
||||||
|
"LIMIT ?";
|
||||||
|
return jdbcTemplate.queryForList(sql, corpId, minMsgCount != null ? minMsgCount : 5, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 内部方法 ---
|
||||||
|
|
||||||
|
private List<Map<String, Object>> selectCustomerMessages(String corpId, String customerId) {
|
||||||
|
String sql = "SELECT from_role, content, msgtime, session_id " +
|
||||||
|
"FROM archive_messages " +
|
||||||
|
"WHERE corp_id = ? AND session_id IN (" +
|
||||||
|
" SELECT DISTINCT session_id FROM archive_messages " +
|
||||||
|
" WHERE corp_id = ? AND from_user = ? AND from_role = 'EXTERNAL'" +
|
||||||
|
") AND msgtype = 'text' AND decrypt_status = 1 AND content IS NOT NULL " +
|
||||||
|
"ORDER BY msgtime ASC " +
|
||||||
|
"LIMIT 100";
|
||||||
|
return jdbcTemplate.queryForList(sql, corpId, corpId, customerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int countDistinctSessions(String corpId, String customerId) {
|
||||||
|
String sql = "SELECT COUNT(DISTINCT session_id) FROM archive_messages " +
|
||||||
|
"WHERE corp_id = ? AND from_user = ? AND from_role = 'EXTERNAL'";
|
||||||
|
Integer count = jdbcTemplate.queryForObject(sql, Integer.class, corpId, customerId);
|
||||||
|
return count != null ? count : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatConversation(List<Map<String, Object>> messages) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MM-dd HH:mm");
|
||||||
|
|
||||||
|
for (Map<String, Object> msg : messages) {
|
||||||
|
String fromRole = (String) msg.get("from_role");
|
||||||
|
String content = (String) msg.get("content");
|
||||||
|
Long msgtime = ((Number) msg.get("msgtime")).longValue();
|
||||||
|
|
||||||
|
if (content == null || content.trim().isEmpty()) continue;
|
||||||
|
// 过滤引用消息和系统消息
|
||||||
|
if (content.startsWith("这是一条引用")) continue;
|
||||||
|
if (content.startsWith("- - - - -")) continue;
|
||||||
|
|
||||||
|
String role = "EXTERNAL".equals(fromRole) ? "【学员】" : "【员工】";
|
||||||
|
String time = LocalDateTime.ofInstant(Instant.ofEpochMilli(msgtime), ZoneId.of("Asia/Shanghai"))
|
||||||
|
.format(formatter);
|
||||||
|
|
||||||
|
sb.append(role).append(" ").append(time).append("\n");
|
||||||
|
// 截断过长消息
|
||||||
|
String text = content.length() > 300 ? content.substring(0, 300) + "..." : content;
|
||||||
|
sb.append(text).append("\n\n");
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String callLLM(String conversationText) {
|
||||||
|
try {
|
||||||
|
String prompt = "【对话历史】\n" + conversationText + "\n\n【请输出画像JSON】";
|
||||||
|
|
||||||
|
Map<String, Object> requestBody = new HashMap<>();
|
||||||
|
requestBody.put("system", SYSTEM_PROMPT);
|
||||||
|
requestBody.put("prompt", prompt);
|
||||||
|
requestBody.put("maxTokens", 2500);
|
||||||
|
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
|
||||||
|
HttpEntity<Map<String, Object>> request = new HttpEntity<>(requestBody, headers);
|
||||||
|
ResponseEntity<String> response = restTemplate.postForEntity(
|
||||||
|
generationServiceUrl + "/api/v1/generation/generate", request, String.class);
|
||||||
|
|
||||||
|
if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) {
|
||||||
|
Map<String, Object> result = JsonUtils.fromJsonMap(response.getBody());
|
||||||
|
if (result != null && result.get("data") != null) {
|
||||||
|
Map<String, Object> data = (Map<String, Object>) result.get("data");
|
||||||
|
return (String) data.get("content");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.warn("LLM调用失败: status={}, body={}", response.getStatusCode(), response.getBody());
|
||||||
|
return null;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("LLM调用异常: {}", e.getMessage(), e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private ProfileParseResult parseProfileResult(String response) {
|
||||||
|
ProfileParseResult result = new ProfileParseResult();
|
||||||
|
try {
|
||||||
|
// 清理 markdown
|
||||||
|
String json = response.trim();
|
||||||
|
if (json.startsWith("```json")) json = json.substring(7);
|
||||||
|
if (json.startsWith("```")) json = json.substring(3);
|
||||||
|
if (json.endsWith("```")) json = json.substring(0, json.length() - 3);
|
||||||
|
json = json.trim();
|
||||||
|
|
||||||
|
Map<String, Object> map = JsonUtils.fromJsonMap(json);
|
||||||
|
if (map == null) {
|
||||||
|
result.setError("JSON解析为空");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.setStudentType(extractField(map, "studentType"));
|
||||||
|
result.setSkillLevel(extractField(map, "skillLevel"));
|
||||||
|
result.setIntentLevel(extractField(map, "intentLevel"));
|
||||||
|
result.setIntentScore(extractField(map, "intentScore"));
|
||||||
|
result.setConcernFocus(extractField(map, "concernFocus"));
|
||||||
|
result.setDecisionStage(extractField(map, "decisionStage"));
|
||||||
|
result.setInterestedCourses(extractField(map, "interestedCourses"));
|
||||||
|
result.setBudgetHint(extractField(map, "budgetHint"));
|
||||||
|
result.setPreferredCity(extractField(map, "preferredCity"));
|
||||||
|
result.setAge(extractField(map, "age"));
|
||||||
|
result.setEducation(extractField(map, "education"));
|
||||||
|
result.setCurrentOccupation(extractField(map, "currentOccupation"));
|
||||||
|
result.setSummary(getString(map, "summary"));
|
||||||
|
result.setKeyQuotes(getStringList(map, "keyQuotes"));
|
||||||
|
result.setValid(true);
|
||||||
|
} catch (Exception e) {
|
||||||
|
result.setError("解析异常: " + e.getMessage());
|
||||||
|
log.warn("解析LLM画像结果失败: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private FieldValue extractField(Map<String, Object> map, String key) {
|
||||||
|
FieldValue fv = new FieldValue();
|
||||||
|
Object val = map.get(key);
|
||||||
|
if (val instanceof Map) {
|
||||||
|
Map<String, Object> m = (Map<String, Object>) val;
|
||||||
|
fv.setValue(m.get("value"));
|
||||||
|
Object conf = m.get("confidence");
|
||||||
|
fv.setConfidence(conf instanceof Number ? ((Number) conf).doubleValue() : 0);
|
||||||
|
fv.setEvidence(getString(m, "evidence"));
|
||||||
|
}
|
||||||
|
return fv;
|
||||||
|
}
|
||||||
|
|
||||||
|
private CustomerProfile convertToProfile(String customerId, String corpId, ProfileParseResult parsed) {
|
||||||
|
CustomerProfile p = new CustomerProfile();
|
||||||
|
p.setCustomerId(customerId);
|
||||||
|
p.setCorpId(corpId);
|
||||||
|
|
||||||
|
p.setStudentType(getFieldValue(parsed.getStudentType()));
|
||||||
|
p.setStudentTypeConfidence(getFieldConfidence(parsed.getStudentType()));
|
||||||
|
p.setSkillLevel(getFieldValue(parsed.getSkillLevel()));
|
||||||
|
p.setSkillLevelConfidence(getFieldConfidence(parsed.getSkillLevel()));
|
||||||
|
p.setIntentLevel(getFieldValue(parsed.getIntentLevel()));
|
||||||
|
p.setIntentScore(getFieldDouble(parsed.getIntentScore()));
|
||||||
|
p.setConcernFocus(getFieldValue(parsed.getConcernFocus()));
|
||||||
|
p.setConcernFocusConfidence(getFieldConfidence(parsed.getConcernFocus()));
|
||||||
|
p.setDecisionStage(getFieldValue(parsed.getDecisionStage()));
|
||||||
|
p.setDecisionStageConfidence(getFieldConfidence(parsed.getDecisionStage()));
|
||||||
|
p.setInterestedCourses(toJson(getFieldList(parsed.getInterestedCourses())));
|
||||||
|
p.setBudgetHint(getFieldValue(parsed.getBudgetHint()));
|
||||||
|
p.setPreferredCity(getFieldValue(parsed.getPreferredCity()));
|
||||||
|
p.setAge(getFieldInt(parsed.getAge()));
|
||||||
|
p.setEducation(getFieldValue(parsed.getEducation()));
|
||||||
|
p.setCurrentOccupation(getFieldValue(parsed.getCurrentOccupation()));
|
||||||
|
p.setAutoGenerated(1);
|
||||||
|
p.setLastGeneratedAt(LocalDateTime.now());
|
||||||
|
p.setGeneratedBy("AI_BATCH");
|
||||||
|
p.setUpdatedAt(LocalDateTime.now());
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveLog(String customerId, String corpId, String type, int msgCount, int sessionCount,
|
||||||
|
long startTime, boolean success, String error, String response) {
|
||||||
|
try {
|
||||||
|
ProfileGenerationLog log = new ProfileGenerationLog();
|
||||||
|
log.setCustomerId(customerId);
|
||||||
|
log.setCorpId(corpId);
|
||||||
|
log.setGenerationType(type);
|
||||||
|
log.setSourceMsgCount(msgCount);
|
||||||
|
log.setSourceSessionCount(sessionCount);
|
||||||
|
log.setAnalyzedAt(LocalDateTime.now());
|
||||||
|
log.setDurationMs((int) (System.currentTimeMillis() - startTime));
|
||||||
|
log.setSuccess(success ? 1 : 0);
|
||||||
|
log.setErrorMsg(error);
|
||||||
|
if (response != null) {
|
||||||
|
log.setLlmResponseLength(response.length());
|
||||||
|
}
|
||||||
|
logMapper.insert(log);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 日志保存失败不影响主流程
|
||||||
|
log.warn("保存画像生成日志失败: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 工具方法 ---
|
||||||
|
|
||||||
|
private String getString(Map<String, Object> map, String key) {
|
||||||
|
Object val = map.get(key);
|
||||||
|
return val != null ? val.toString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private List<String> getStringList(Map<String, Object> map, String key) {
|
||||||
|
Object val = map.get(key);
|
||||||
|
if (val instanceof List) {
|
||||||
|
return ((List<Object>) val).stream().map(Object::toString).collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getFieldValue(FieldValue fv) {
|
||||||
|
if (fv == null || fv.getValue() == null) return null;
|
||||||
|
String v = fv.getValue().toString();
|
||||||
|
return "未知".equals(v) || v.isEmpty() ? null : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Double getFieldConfidence(FieldValue fv) {
|
||||||
|
return fv != null ? fv.getConfidence() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Double getFieldDouble(FieldValue fv) {
|
||||||
|
if (fv == null || fv.getValue() == null) return 0.0;
|
||||||
|
try {
|
||||||
|
return Double.parseDouble(fv.getValue().toString());
|
||||||
|
} catch (Exception e) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Integer getFieldInt(FieldValue fv) {
|
||||||
|
if (fv == null || fv.getValue() == null) return null;
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(fv.getValue().toString());
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private List<String> getFieldList(FieldValue fv) {
|
||||||
|
if (fv == null || fv.getValue() == null) return Collections.emptyList();
|
||||||
|
Object v = fv.getValue();
|
||||||
|
if (v instanceof List) {
|
||||||
|
return ((List<Object>) v).stream().map(Object::toString).collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String toJson(List<String> list) {
|
||||||
|
if (list == null || list.isEmpty()) return null;
|
||||||
|
try {
|
||||||
|
return JsonUtils.toJson(list);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 内部DTO ---
|
||||||
|
|
||||||
|
@lombok.Data
|
||||||
|
public static class GenerationResult {
|
||||||
|
private boolean success;
|
||||||
|
private String customerId;
|
||||||
|
private String corpId;
|
||||||
|
private CustomerProfile profile;
|
||||||
|
private String errorMsg;
|
||||||
|
private int durationMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
@lombok.Data
|
||||||
|
public static class ProfileParseResult {
|
||||||
|
private boolean valid;
|
||||||
|
private String error;
|
||||||
|
private FieldValue studentType;
|
||||||
|
private FieldValue skillLevel;
|
||||||
|
private FieldValue intentLevel;
|
||||||
|
private FieldValue intentScore;
|
||||||
|
private FieldValue concernFocus;
|
||||||
|
private FieldValue decisionStage;
|
||||||
|
private FieldValue interestedCourses;
|
||||||
|
private FieldValue budgetHint;
|
||||||
|
private FieldValue preferredCity;
|
||||||
|
private FieldValue age;
|
||||||
|
private FieldValue education;
|
||||||
|
private FieldValue currentOccupation;
|
||||||
|
private String summary;
|
||||||
|
private List<String> keyQuotes;
|
||||||
|
}
|
||||||
|
|
||||||
|
@lombok.Data
|
||||||
|
public static class FieldValue {
|
||||||
|
private Object value;
|
||||||
|
private double confidence;
|
||||||
|
private String evidence;
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,14 +1,22 @@
|
|||||||
com\artedu\intent\IntentServiceApplication.class
|
com\artedu\intent\IntentServiceApplication.class
|
||||||
com\artedu\intent\controller\CustomerProfileController.class
|
com\artedu\intent\controller\CustomerProfileController.class
|
||||||
com\artedu\intent\dto\IntentResult$IntentInfo.class
|
com\artedu\intent\dto\IntentResult$IntentInfo.class
|
||||||
|
com\artedu\intent\service\ProfileGenerationService.class
|
||||||
com\artedu\intent\service\LLMIntentClient.class
|
com\artedu\intent\service\LLMIntentClient.class
|
||||||
com\artedu\intent\mapper\IntentCategoryMapper.class
|
com\artedu\intent\mapper\IntentCategoryMapper.class
|
||||||
com\artedu\intent\dto\IntentResult.class
|
com\artedu\intent\dto\IntentResult.class
|
||||||
com\artedu\intent\entity\CustomerProfile.class
|
com\artedu\intent\entity\CustomerProfile.class
|
||||||
|
com\artedu\intent\mapper\ProfileGenerationLogMapper.class
|
||||||
|
com\artedu\intent\controller\ProfileGenerationController.class
|
||||||
com\artedu\intent\mapper\CustomerProfileMapper.class
|
com\artedu\intent\mapper\CustomerProfileMapper.class
|
||||||
com\artedu\intent\mapper\IntentRecognitionMapper.class
|
com\artedu\intent\mapper\IntentRecognitionMapper.class
|
||||||
com\artedu\intent\service\CustomerProfileService.class
|
com\artedu\intent\service\CustomerProfileService.class
|
||||||
|
com\artedu\intent\service\ProfileGenerationService$ProfileParseResult.class
|
||||||
com\artedu\intent\controller\IntentController.class
|
com\artedu\intent\controller\IntentController.class
|
||||||
com\artedu\intent\entity\IntentRecognition.class
|
com\artedu\intent\entity\IntentRecognition.class
|
||||||
com\artedu\intent\entity\IntentCategory.class
|
com\artedu\intent\entity\IntentCategory.class
|
||||||
|
com\artedu\intent\service\ProfileGenerationService$FieldValue.class
|
||||||
|
com\artedu\intent\service\ProfileGenerationService$GenerationResult.class
|
||||||
com\artedu\intent\service\IntentRecognitionService.class
|
com\artedu\intent\service\IntentRecognitionService.class
|
||||||
|
com\artedu\intent\entity\ProfileGenerationLog.class
|
||||||
|
com\artedu\intent\controller\ProfileGenerationController$BatchGenerateRequest.class
|
||||||
|
|||||||
@ -1,13 +1,17 @@
|
|||||||
D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\controller\CustomerProfileController.java
|
D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\controller\CustomerProfileController.java
|
||||||
D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\controller\IntentController.java
|
D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\controller\IntentController.java
|
||||||
|
D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\controller\ProfileGenerationController.java
|
||||||
D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\dto\IntentResult.java
|
D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\dto\IntentResult.java
|
||||||
D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\entity\CustomerProfile.java
|
D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\entity\CustomerProfile.java
|
||||||
D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\entity\IntentCategory.java
|
D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\entity\IntentCategory.java
|
||||||
D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\entity\IntentRecognition.java
|
D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\entity\IntentRecognition.java
|
||||||
|
D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\entity\ProfileGenerationLog.java
|
||||||
D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\IntentServiceApplication.java
|
D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\IntentServiceApplication.java
|
||||||
D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\mapper\CustomerProfileMapper.java
|
D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\mapper\CustomerProfileMapper.java
|
||||||
D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\mapper\IntentCategoryMapper.java
|
D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\mapper\IntentCategoryMapper.java
|
||||||
D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\mapper\IntentRecognitionMapper.java
|
D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\mapper\IntentRecognitionMapper.java
|
||||||
|
D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\mapper\ProfileGenerationLogMapper.java
|
||||||
D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\service\CustomerProfileService.java
|
D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\service\CustomerProfileService.java
|
||||||
D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\service\IntentRecognitionService.java
|
D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\service\IntentRecognitionService.java
|
||||||
D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\service\LLMIntentClient.java
|
D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\service\LLMIntentClient.java
|
||||||
|
D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\service\ProfileGenerationService.java
|
||||||
|
|||||||
Binary file not shown.
@ -1,10 +1,12 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Table, Button, Tag, Space, Modal, Form, Input, Select, InputNumber, message, Popconfirm } from 'antd'
|
import { Table, Button, Tag, Space, Modal, Form, Input, Select, InputNumber, message, Popconfirm, Tabs, Alert, Badge } from 'antd'
|
||||||
|
import { RobotOutlined, UserOutlined } from '@ant-design/icons'
|
||||||
import request from '../utils/request'
|
import request from '../utils/request'
|
||||||
|
|
||||||
const CORP_ID = 'wwd483c2fba24ae30a'
|
const CORP_ID = 'wwd483c2fba24ae30a'
|
||||||
|
|
||||||
export default function CustomerList() {
|
export default function CustomerList() {
|
||||||
|
const [activeTab, setActiveTab] = useState('existing')
|
||||||
const [data, setData] = useState<any[]>([])
|
const [data, setData] = useState<any[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [modalVisible, setModalVisible] = useState(false)
|
const [modalVisible, setModalVisible] = useState(false)
|
||||||
@ -12,6 +14,13 @@ export default function CustomerList() {
|
|||||||
const [form] = Form.useForm()
|
const [form] = Form.useForm()
|
||||||
const [searchKeyword, setSearchKeyword] = useState('')
|
const [searchKeyword, setSearchKeyword] = useState('')
|
||||||
|
|
||||||
|
// 批量生成相关
|
||||||
|
const [pendingData, setPendingData] = useState<any[]>([])
|
||||||
|
const [pendingLoading, setPendingLoading] = useState(false)
|
||||||
|
const [selectedCustomers, setSelectedCustomers] = useState<string[]>([])
|
||||||
|
const [generateLoading, setGenerateLoading] = useState(false)
|
||||||
|
const [singleGenerateLoading, setSingleGenerateLoading] = useState<string | null>(null)
|
||||||
|
|
||||||
const loadData = async (keyword = '') => {
|
const loadData = async (keyword = '') => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
@ -24,14 +33,32 @@ export default function CustomerList() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadPending = async () => {
|
||||||
|
setPendingLoading(true)
|
||||||
|
try {
|
||||||
|
const res: any = await request.get('/v1/intent/profiles/without-profile', {
|
||||||
|
params: { corpId: CORP_ID, minMsgCount: 5, limit: 100 }
|
||||||
|
})
|
||||||
|
setPendingData(res.data || [])
|
||||||
|
} catch (e) {
|
||||||
|
message.error('加载无画像客户失败')
|
||||||
|
} finally {
|
||||||
|
setPendingLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (activeTab === 'existing') {
|
||||||
loadData()
|
loadData()
|
||||||
}, [])
|
} else {
|
||||||
|
loadPending()
|
||||||
|
}
|
||||||
|
}, [activeTab])
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
const values = await form.validateFields()
|
const values = await form.validateFields()
|
||||||
try {
|
try {
|
||||||
const payload = { ...values, corpId: CORP_ID }
|
const payload = { ...values, corpId: CORP_ID, manualOverride: true }
|
||||||
await request.post('/v1/intent/profiles', payload)
|
await request.post('/v1/intent/profiles', payload)
|
||||||
message.success(editing ? '更新成功' : '创建成功')
|
message.success(editing ? '更新成功' : '创建成功')
|
||||||
setModalVisible(false)
|
setModalVisible(false)
|
||||||
@ -79,6 +106,51 @@ export default function CustomerList() {
|
|||||||
setModalVisible(true)
|
setModalVisible(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 单个生成
|
||||||
|
const handleGenerateSingle = async (customerId: string) => {
|
||||||
|
setSingleGenerateLoading(customerId)
|
||||||
|
try {
|
||||||
|
const res: any = await request.post(`/v1/intent/profiles/generate/${customerId}?corpId=${CORP_ID}`)
|
||||||
|
if (res.code === 0) {
|
||||||
|
message.success('画像生成成功')
|
||||||
|
loadPending()
|
||||||
|
// 也刷新已有画像列表
|
||||||
|
if (activeTab === 'existing') loadData()
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '生成失败')
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.response?.data?.message || '生成失败')
|
||||||
|
} finally {
|
||||||
|
setSingleGenerateLoading(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量生成
|
||||||
|
const handleBatchGenerate = async () => {
|
||||||
|
if (selectedCustomers.length === 0) {
|
||||||
|
message.warning('请先选择客户')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setGenerateLoading(true)
|
||||||
|
try {
|
||||||
|
const adminInfo = JSON.parse(localStorage.getItem('admin_info') || '{}')
|
||||||
|
await request.post('/v1/intent/profiles/generate/batch', {
|
||||||
|
corpId: CORP_ID,
|
||||||
|
customerIds: selectedCustomers,
|
||||||
|
operator: adminInfo.username || 'admin'
|
||||||
|
})
|
||||||
|
message.success(`批量生成任务已启动,共 ${selectedCustomers.length} 个客户`)
|
||||||
|
setSelectedCustomers([])
|
||||||
|
// 3秒后刷新
|
||||||
|
setTimeout(() => loadPending(), 3000)
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.response?.data?.message || '批量生成失败')
|
||||||
|
} finally {
|
||||||
|
setGenerateLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const getIntentColor = (level?: string) => {
|
const getIntentColor = (level?: string) => {
|
||||||
switch (level) {
|
switch (level) {
|
||||||
case '高': return 'green'
|
case '高': return 'green'
|
||||||
@ -88,17 +160,25 @@ export default function CustomerList() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = [
|
const getSourceTag = (record: any) => {
|
||||||
|
if (record.autoGenerated === 1) {
|
||||||
|
return <Tag color="blue" icon={<RobotOutlined />}>AI生成</Tag>
|
||||||
|
}
|
||||||
|
return <Tag color="orange">人工录入</Tag>
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingColumns = [
|
||||||
{ title: '客户ID', dataIndex: 'customerId', ellipsis: true, width: 180 },
|
{ title: '客户ID', dataIndex: 'customerId', ellipsis: true, width: 180 },
|
||||||
|
{ title: '来源', width: 100, render: (_: any, record: any) => getSourceTag(record) },
|
||||||
{ title: '职业/名称', dataIndex: 'currentOccupation', width: 120 },
|
{ title: '职业/名称', dataIndex: 'currentOccupation', width: 120 },
|
||||||
{ title: '学员类型', dataIndex: 'studentType', width: 110 },
|
{ title: '学员类型', dataIndex: 'studentType', width: 110 },
|
||||||
{ title: '基础水平', dataIndex: 'skillLevel', width: 110 },
|
{ title: '基础水平', dataIndex: 'skillLevel', width: 110 },
|
||||||
{
|
{
|
||||||
title: '意向度',
|
title: '意向度',
|
||||||
dataIndex: 'intentLevel',
|
dataIndex: 'intentLevel',
|
||||||
width: 90,
|
width: 100,
|
||||||
render: (level: string, record: any) => (
|
render: (level: string, record: any) => (
|
||||||
<Tag color={getIntentColor(level)}>{level} {record.intentScore != null ? `(${record.intentScore})` : ''}</Tag>
|
<Tag color={getIntentColor(level)}>{level} {record.intentScore != null ? `(${Math.round(record.intentScore)})` : ''}</Tag>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{ title: '关注重点', dataIndex: 'concernFocus', width: 120 },
|
{ title: '关注重点', dataIndex: 'concernFocus', width: 120 },
|
||||||
@ -117,10 +197,41 @@ export default function CustomerList() {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const pendingColumns = [
|
||||||
|
{ title: '客户ID', dataIndex: 'customer_id', ellipsis: true, width: 200 },
|
||||||
|
{
|
||||||
|
title: '消息数',
|
||||||
|
dataIndex: 'msg_count',
|
||||||
|
width: 100,
|
||||||
|
render: (v: number) => <Badge count={v} style={{ backgroundColor: v >= 20 ? '#52c41a' : v >= 10 ? '#faad14' : '#d9d9d9' }} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 140,
|
||||||
|
render: (_: any, record: any) => (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
icon={<RobotOutlined />}
|
||||||
|
loading={singleGenerateLoading === record.customer_id}
|
||||||
|
onClick={() => handleGenerateSingle(record.customer_id)}
|
||||||
|
>
|
||||||
|
生成画像
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const rowSelection = {
|
||||||
|
selectedRowKeys: selectedCustomers,
|
||||||
|
onChange: (keys: React.Key[]) => setSelectedCustomers(keys as string[]),
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, alignItems: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, alignItems: 'center' }}>
|
||||||
<h2 style={{ margin: 0 }}>客户画像管理</h2>
|
<h2 style={{ margin: 0 }}>客户画像管理</h2>
|
||||||
|
{activeTab === 'existing' && (
|
||||||
<Space>
|
<Space>
|
||||||
<Input.Search
|
<Input.Search
|
||||||
placeholder="搜索客户ID或职业"
|
placeholder="搜索客户ID或职业"
|
||||||
@ -132,14 +243,71 @@ export default function CustomerList() {
|
|||||||
/>
|
/>
|
||||||
<Button type="primary" onClick={openCreate}>新增客户</Button>
|
<Button type="primary" onClick={openCreate}>新增客户</Button>
|
||||||
</Space>
|
</Space>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
activeKey={activeTab}
|
||||||
|
onChange={setActiveTab}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'existing',
|
||||||
|
label: (
|
||||||
|
<span>
|
||||||
|
<UserOutlined /> 已有画像
|
||||||
|
<Tag style={{ marginLeft: 4 }}>{data.length}</Tag>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
children: (
|
||||||
<Table
|
<Table
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
columns={columns}
|
columns={existingColumns}
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
scroll={{ x: 900 }}
|
scroll={{ x: 900 }}
|
||||||
/>
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pending',
|
||||||
|
label: (
|
||||||
|
<span>
|
||||||
|
<RobotOutlined /> 待生成画像
|
||||||
|
<Tag style={{ marginLeft: 4 }}>{pendingData.length}</Tag>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
children: (
|
||||||
|
<div>
|
||||||
|
<Alert
|
||||||
|
message="以下客户有对话记录但尚未生成画像,可勾选后批量生成"
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
loading={generateLoading}
|
||||||
|
disabled={selectedCustomers.length === 0}
|
||||||
|
onClick={handleBatchGenerate}
|
||||||
|
>
|
||||||
|
批量生成 ({selectedCustomers.length})
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Table
|
||||||
|
rowKey="customer_id"
|
||||||
|
columns={pendingColumns}
|
||||||
|
dataSource={pendingData}
|
||||||
|
loading={pendingLoading}
|
||||||
|
rowSelection={rowSelection}
|
||||||
|
pagination={{ pageSize: 20 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title={editing ? '编辑客户画像' : '新增客户画像'}
|
title={editing ? '编辑客户画像' : '新增客户画像'}
|
||||||
open={modalVisible}
|
open={modalVisible}
|
||||||
|
|||||||
506
product1.0/v3-客户画像自动生成方案.md
Normal file
506
product1.0/v3-客户画像自动生成方案.md
Normal file
@ -0,0 +1,506 @@
|
|||||||
|
# 客户画像自动生成与丰满技术方案
|
||||||
|
|
||||||
|
> 版本:v3.0
|
||||||
|
> 日期:2026-06-05
|
||||||
|
> 状态:设计稿,待评审
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、背景与痛点
|
||||||
|
|
||||||
|
### 1.1 当前画像体系
|
||||||
|
|
||||||
|
| 组件 | 现状 |
|
||||||
|
|------|------|
|
||||||
|
| `customer_profiles` 表 | 已有完整字段,但主要靠销售顾问在 sidebar 手动录入 |
|
||||||
|
| `archive_messages` 表 | 沉淀了大量学员对话,尚未用于画像挖掘 |
|
||||||
|
| `ProfileCard.tsx` | 录入表单只有基础字段(职业、学员类型、基础水平、意向度),其余字段为空 |
|
||||||
|
| LLM 自动分析 | **未实现**,代码里留了 TODO:"录入基础信息后,系统将通过会话存档自动完善画像" |
|
||||||
|
|
||||||
|
### 1.2 核心痛点
|
||||||
|
|
||||||
|
1. **老客户画像缺失**:系统上线前已有大量客户,他们从未被人工录入画像,但 archive_messages 中有丰富对话
|
||||||
|
2. **画像不够丰满**:人工录入只覆盖 4-5 个字段,大量高价值字段(关注点、决策阶段、兴趣课程、预算暗示)为空
|
||||||
|
3. **画像更新滞后**:客户状态会变化(如从"初步了解"到"方案比较"),人工不会每次手动更新
|
||||||
|
4. **顾问负担重**:每个客户都要手动填表单,效率低且标准不统一
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、目标
|
||||||
|
|
||||||
|
1. **批量自动生成**:为所有有对话记录的老客户自动生成初始画像
|
||||||
|
2. **持续自动丰满**:每次新会话后,自动分析增量信息并更新画像
|
||||||
|
3. **可解释性**:每个画像字段标注来源(哪条对话、什么时间点)和置信度
|
||||||
|
4. **人工可干预**:顾问可修正自动生成的画像,修正后优先级高于 AI 结果
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、数据模型
|
||||||
|
|
||||||
|
### 3.1 现有表 `customer_profiles`(已有)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE customer_profiles (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
customer_id VARCHAR(64) NOT NULL COMMENT '客户ID(企微external_userid)',
|
||||||
|
corp_id VARCHAR(64) NOT NULL,
|
||||||
|
|
||||||
|
-- 基础画像(当前已有)
|
||||||
|
student_type VARCHAR(50) COMMENT '学员类型: 在校大学生/转行人员/在职提升/高中毕业生/家长代询',
|
||||||
|
student_type_confidence DOUBLE DEFAULT 0 COMMENT '置信度 0-1',
|
||||||
|
skill_level VARCHAR(50) COMMENT '基础水平: 零基础/有美术基础/相关专业/有从业经验',
|
||||||
|
skill_level_confidence DOUBLE DEFAULT 0,
|
||||||
|
intent_level VARCHAR(20) COMMENT '意向度: 高/中/低',
|
||||||
|
intent_score DOUBLE DEFAULT 0 COMMENT '意向分数 0-100',
|
||||||
|
concern_focus VARCHAR(50) COMMENT '关注点: 就业导向型/兴趣导向型/价格敏感型/品质导向型',
|
||||||
|
concern_focus_confidence DOUBLE DEFAULT 0,
|
||||||
|
decision_stage VARCHAR(50) COMMENT '决策阶段: 初步了解/方案比较/决定报名/已报名',
|
||||||
|
decision_stage_confidence DOUBLE DEFAULT 0,
|
||||||
|
interested_courses TEXT COMMENT '兴趣课程 JSON数组',
|
||||||
|
budget_hint VARCHAR(200) COMMENT '预算暗示',
|
||||||
|
preferred_city VARCHAR(50) COMMENT '意向城市',
|
||||||
|
age INT COMMENT '年龄',
|
||||||
|
education VARCHAR(50) COMMENT '学历',
|
||||||
|
current_occupation VARCHAR(100) COMMENT '当前职业/名称',
|
||||||
|
|
||||||
|
-- 元数据(当前已有)
|
||||||
|
slot_data TEXT COMMENT '槽位数据 JSON',
|
||||||
|
conversation_count INT DEFAULT 0 COMMENT '会话次数',
|
||||||
|
last_conversation_time TIMESTAMP NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
UNIQUE KEY uk_customer_corp (customer_id, corp_id),
|
||||||
|
INDEX idx_corp_id (corp_id),
|
||||||
|
INDEX idx_updated_at (updated_at)
|
||||||
|
) ENGINE=InnoDB COMMENT='客户画像表';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 新增表 `profile_generation_logs`(建议新增)
|
||||||
|
|
||||||
|
记录每次画像生成的来源和变更,用于审计和可解释性。
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE profile_generation_logs (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
customer_id VARCHAR(64) NOT NULL,
|
||||||
|
corp_id VARCHAR(64) NOT NULL,
|
||||||
|
generation_type VARCHAR(20) NOT NULL COMMENT '生成类型: BATCH_FULL/INCREMENTAL/MANUAL',
|
||||||
|
source_msg_count INT DEFAULT 0 COMMENT '分析的消息条数',
|
||||||
|
source_session_count INT DEFAULT 0 COMMENT '分析的会话数',
|
||||||
|
analyzed_at TIMESTAMP NOT NULL COMMENT '分析时间',
|
||||||
|
llm_model VARCHAR(50) COMMENT '使用的模型',
|
||||||
|
llm_prompt_length INT COMMENT 'Prompt长度',
|
||||||
|
llm_response_length INT COMMENT 'Response长度',
|
||||||
|
duration_ms INT COMMENT '耗时毫秒',
|
||||||
|
success TINYINT DEFAULT 1 COMMENT '是否成功',
|
||||||
|
error_msg TEXT COMMENT '错误信息',
|
||||||
|
-- 变更快照(JSON):记录哪些字段发生了变化
|
||||||
|
changes_snapshot TEXT COMMENT '{"studentType":{"old":"未知","new":"在校大学生","confidence":0.85}}',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
INDEX idx_customer (customer_id, corp_id),
|
||||||
|
INDEX idx_analyzed_at (analyzed_at)
|
||||||
|
) ENGINE=InnoDB COMMENT='画像生成日志';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 字段置信度规则
|
||||||
|
|
||||||
|
| 置信度区间 | 含义 | 前端展示 |
|
||||||
|
|-----------|------|---------|
|
||||||
|
| 0.85 ~ 1.0 | 高置信 | 绿色标签,不提示 |
|
||||||
|
| 0.60 ~ 0.84 | 中置信 | 橙色标签,hover 提示"AI推测,建议确认" |
|
||||||
|
| 0.30 ~ 0.59 | 低置信 | 灰色标签,hover 提示"信息不足,建议补充" |
|
||||||
|
| 0 ~ 0.29 | 未知 | 显示"未知",引导录入 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、LLM Prompt 设计
|
||||||
|
|
||||||
|
### 4.1 全量画像生成 Prompt
|
||||||
|
|
||||||
|
**适用场景**:首次为老客户生成画像,输入该客户全部历史对话
|
||||||
|
|
||||||
|
```
|
||||||
|
你是一位专业的教育培训机构客户分析师。请根据以下学员与课程顾问的完整对话历史,提取学员画像信息。
|
||||||
|
|
||||||
|
【分析要求】
|
||||||
|
1. 仔细阅读所有对话,从中提取学员的真实情况和需求
|
||||||
|
2. 不要凭空猜测,只提取对话中有明确证据支撑的信息
|
||||||
|
3. 如果某个字段在对话中没有足够证据,请标记为"未知"并给出低置信度
|
||||||
|
4. 对于同一字段的多个线索,综合判断取最可靠的结论
|
||||||
|
5. 注意识别"家长代询"的情况(对话中出现"我孩子"、"我家"等表述)
|
||||||
|
|
||||||
|
【对话历史】
|
||||||
|
{conversation_text}
|
||||||
|
|
||||||
|
【输出格式】
|
||||||
|
必须严格返回以下JSON格式,不要包含任何其他文字:
|
||||||
|
{
|
||||||
|
"studentType": {"value": "", "confidence": 0, "evidence": ""},
|
||||||
|
"skillLevel": {"value": "", "confidence": 0, "evidence": ""},
|
||||||
|
"intentLevel": {"value": "", "confidence": 0, "evidence": ""},
|
||||||
|
"intentScore": {"value": 0, "confidence": 0, "evidence": ""},
|
||||||
|
"concernFocus": {"value": "", "confidence": 0, "evidence": ""},
|
||||||
|
"decisionStage": {"value": "", "confidence": 0, "evidence": ""},
|
||||||
|
"interestedCourses": {"value": [], "confidence": 0, "evidence": ""},
|
||||||
|
"budgetHint": {"value": "", "confidence": 0, "evidence": ""},
|
||||||
|
"preferredCity": {"value": "", "confidence": 0, "evidence": ""},
|
||||||
|
"age": {"value": null, "confidence": 0, "evidence": ""},
|
||||||
|
"education": {"value": "", "confidence": 0, "evidence": ""},
|
||||||
|
"currentOccupation": {"value": "", "confidence": 0, "evidence": ""},
|
||||||
|
"summary": "", // 用一句话总结这个学员
|
||||||
|
"keyQuotes": [] // 提取3-5条最能代表学员需求的原话
|
||||||
|
}
|
||||||
|
|
||||||
|
【字段枚举值】
|
||||||
|
- studentType: 在校大学生/转行人员/在职提升/高中毕业生/家长代询/未知
|
||||||
|
- skillLevel: 零基础/有美术基础/相关专业/有从业经验/未知
|
||||||
|
- intentLevel: 高/中/低
|
||||||
|
- concernFocus: 就业导向型/兴趣导向型/价格敏感型/品质导向型/未知
|
||||||
|
- decisionStage: 初步了解/方案比较/决定报名/已报名/未知
|
||||||
|
- interestedCourses: 从对话中提取的课程名称数组,如["3D场景UE地编","次世代角色模型"]
|
||||||
|
- education: 高中/大专/本科/硕士/博士/未知
|
||||||
|
|
||||||
|
【置信度评分标准】
|
||||||
|
- 0.90-1.00: 学员明确亲口说过(如"我是大学生")
|
||||||
|
- 0.70-0.89: 有较强暗示或多处线索一致
|
||||||
|
- 0.50-0.69: 有一处间接线索
|
||||||
|
- 0.30-0.49: 只有微弱暗示
|
||||||
|
- 0.00-0.29: 没有任何线索
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 增量画像更新 Prompt
|
||||||
|
|
||||||
|
**适用场景**:客户已有画像,分析新增对话后生成更新建议
|
||||||
|
|
||||||
|
```
|
||||||
|
你是一位客户画像更新分析师。请根据学员的【现有画像】和【新增对话】,判断哪些字段需要更新。
|
||||||
|
|
||||||
|
【现有画像】
|
||||||
|
{current_profile_json}
|
||||||
|
|
||||||
|
【新增对话】
|
||||||
|
{new_conversation_text}
|
||||||
|
|
||||||
|
【分析要求】
|
||||||
|
1. 对比新增对话与现有画像,识别变化
|
||||||
|
2. 如果新增对话推翻了现有画像的某个结论,建议更新
|
||||||
|
3. 如果新增对话补充了空白字段,建议填充
|
||||||
|
4. 如果现有画像已高置信且新增对话无冲突,保持不动
|
||||||
|
5. 特别关注决策阶段的变化(这是最重要的更新信号)
|
||||||
|
|
||||||
|
【输出格式】
|
||||||
|
{
|
||||||
|
"updates": [
|
||||||
|
{
|
||||||
|
"field": "decisionStage",
|
||||||
|
"oldValue": "初步了解",
|
||||||
|
"newValue": "方案比较",
|
||||||
|
"confidence": 0.85,
|
||||||
|
"reason": "学员主动询问课程安排和就业情况,表明已进入比较阶段",
|
||||||
|
"shouldUpdate": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"newInsights": [
|
||||||
|
{
|
||||||
|
"field": "budgetHint",
|
||||||
|
"value": "预算约2万",
|
||||||
|
"confidence": 0.72,
|
||||||
|
"evidence": "学员提到\"两万左右的课程\""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"intentChange": {
|
||||||
|
"oldScore": 55,
|
||||||
|
"newScore": 72,
|
||||||
|
"reason": "学员开始询问具体报名流程"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 对话格式化(输入预处理)
|
||||||
|
|
||||||
|
将 `archive_messages` 按时间排序,格式化为 LLM 可读的对话文本:
|
||||||
|
|
||||||
|
```
|
||||||
|
【会话 1】2025-05-20 14:30
|
||||||
|
员工(小王): 您好,请问有什么可以帮您?
|
||||||
|
学员(张三): 我想了解一下你们的美术课程
|
||||||
|
...
|
||||||
|
|
||||||
|
【会话 2】2025-05-21 10:15
|
||||||
|
员工(小王): 张同学,昨天说的课程资料发给您了
|
||||||
|
学员(张三): 看到了,我想问一下学费多少
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
**截断策略**:
|
||||||
|
- 单客户对话总长度超过 8000 字时,优先保留最近 6 个月的对话
|
||||||
|
- 若仍超限,保留最近 10 次会话的完整内容
|
||||||
|
- 若仍超限,对每条消息截取前 200 字
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、系统架构
|
||||||
|
|
||||||
|
### 5.1 整体架构图
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Admin 后台 │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
|
||||||
|
│ │ 画像生成任务 │ │ 画像列表/搜索 │ │ 单客户画像详情+编辑 │ │
|
||||||
|
│ │ (批量触发) │ │ │ │ (含变更历史) │ │
|
||||||
|
│ └──────┬───────┘ └──────┬───────┘ └──────────┬───────────┘ │
|
||||||
|
└─────────┼─────────────────┼─────────────────────┼──────────────┘
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ intent-service (画像服务) │
|
||||||
|
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ ProfileGeneration │ │ ProfileMerge │ │ ProfileQuery │ │
|
||||||
|
│ │ Service │ │ Service │ │ Service │ │
|
||||||
|
│ │ (LLM调用+解析) │ │ (置信度合并) │ │ (CRUD) │ │
|
||||||
|
│ └────────┬─────────┘ └────────┬─────────┘ └──────┬───────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ ▼ ▼ ▼ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 定时任务: DailyProfileUpdateJob │ │
|
||||||
|
│ │ (每天凌晨扫描昨日有新增对话的客户,触发增量分析) │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ generation-service (LLM) │
|
||||||
|
│ ┌────────────┐ ┌────────────┐ │
|
||||||
|
│ │ 全量生成 │ │ 增量更新 │ │
|
||||||
|
│ │ /generate │ │ /generate │ │
|
||||||
|
│ └────────────┘ └────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 数据库 │
|
||||||
|
│ ┌────────────────────┐ ┌────────────────────┐ ┌──────────┐ │
|
||||||
|
│ │ customer_profiles │ │ profile_generation │ │ customers│ │
|
||||||
|
│ │ (画像主表) │ │ _logs (生成日志) │ │ (客户基础)│ │
|
||||||
|
│ └────────────────────┘ └────────────────────┘ └──────────┘ │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ archive_messages (会话存档,通过 JDBC 查询,非本服务表) │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 核心服务类设计
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Service
|
||||||
|
public class ProfileGenerationService {
|
||||||
|
|
||||||
|
// 全量生成:为指定客户从历史对话生成完整画像
|
||||||
|
public GenerationResult generateFullProfile(String customerId, String corpId);
|
||||||
|
|
||||||
|
// 增量更新:分析新增对话,生成更新建议
|
||||||
|
public IncrementalResult generateIncrementalUpdate(String customerId, String corpId,
|
||||||
|
LocalDateTime since);
|
||||||
|
|
||||||
|
// 批量生成:为多个客户批量生成画像(带进度追踪)
|
||||||
|
@Async
|
||||||
|
public void batchGenerate(String corpId, List<String> customerIds,
|
||||||
|
LocalDateTime msgSince);
|
||||||
|
|
||||||
|
// 合并更新建议到现有画像
|
||||||
|
public void applyUpdates(String customerId, String corpId,
|
||||||
|
List<FieldUpdate> updates);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、画像合并策略
|
||||||
|
|
||||||
|
### 6.1 置信度加权合并
|
||||||
|
|
||||||
|
```
|
||||||
|
现有值: V_old, 置信度 C_old
|
||||||
|
新值: V_new, 置信度 C_new
|
||||||
|
|
||||||
|
如果 V_new == V_old:
|
||||||
|
合并后置信度 = 1 - (1 - C_old) * (1 - C_new * 0.5)
|
||||||
|
// 相同值时置信度提升,但边际递减
|
||||||
|
|
||||||
|
如果 V_new != V_old:
|
||||||
|
if C_new > C_old + 0.15:
|
||||||
|
采用新值,置信度 = C_new
|
||||||
|
else if C_new > C_old:
|
||||||
|
保持旧值,置信度 = C_old + (C_new - C_old) * 0.3
|
||||||
|
else:
|
||||||
|
保持旧值
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 特殊字段规则
|
||||||
|
|
||||||
|
| 字段 | 合并规则 |
|
||||||
|
|------|---------|
|
||||||
|
| `intentScore` | 新对话可能推高或拉低,直接采用增量分析的评分,但限制单次变化不超过 ±20 分 |
|
||||||
|
| `intentLevel` | 基于 intentScore 映射:≥70→高, 40-69→中, <40→低 |
|
||||||
|
| `decisionStage` | 只能正向推进(初步了解→方案比较→决定报名→已报名),不能回退 |
|
||||||
|
| `interestedCourses` | 合并数组,去重,新课程追加到末尾 |
|
||||||
|
| `age` | 数值型,取最新高置信度值 |
|
||||||
|
| `conversationCount` | 直接累加 |
|
||||||
|
| `lastConversationTime` | 取最新时间 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、接口设计
|
||||||
|
|
||||||
|
### 7.1 Admin 后台接口(新增)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/admin/profiles/generate/batch
|
||||||
|
请求: { corpId, customerIds?: string[], timeRange?: [start, end],
|
||||||
|
onlyMissing?: boolean }
|
||||||
|
响应: { taskId, message }
|
||||||
|
|
||||||
|
GET /api/v1/admin/profiles/generate/tasks/{taskId}/progress
|
||||||
|
响应: { taskId, status, total, completed, successCount, failCount }
|
||||||
|
|
||||||
|
GET /api/v1/admin/profiles
|
||||||
|
请求: { corpId, keyword?, hasProfile?, page, size }
|
||||||
|
响应: PageResult<CustomerProfileVO>
|
||||||
|
|
||||||
|
GET /api/v1/admin/profiles/{customerId}/logs
|
||||||
|
响应: List<ProfileGenerationLog>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Sidebar 接口(复用+扩展)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/intent/profiles/{customerId} (已有)
|
||||||
|
响应增加字段:
|
||||||
|
- autoGenerated: boolean (是否AI生成)
|
||||||
|
- lastGeneratedAt: timestamp
|
||||||
|
- fieldSources: { "studentType": "AI分析(置信度0.85)", "age": "人工录入" }
|
||||||
|
|
||||||
|
POST /api/v1/intent/profiles (已有,人工修正)
|
||||||
|
请求增加字段:
|
||||||
|
- manualOverride: boolean (标记为人工修正,置信度设为1.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、前端设计
|
||||||
|
|
||||||
|
### 8.1 Admin 后台新增「客户画像」页面
|
||||||
|
|
||||||
|
**页面结构:**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ 客户画像管理 │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ [搜索框] [筛选: 已生成/未生成/全部] [批量生成] │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ 客户列表 │
|
||||||
|
│ ┌────┬────────┬──────────┬────────────┐ │
|
||||||
|
│ │姓名│学员类型│意向度 │画像来源 │ │
|
||||||
|
│ │ │(AI) │高(85分) │AI生成 ✓ │ │
|
||||||
|
│ │ │(人工) │中(60分) │人工录入 │ │
|
||||||
|
│ │ │(未生成)│- │点击生成 │ │
|
||||||
|
│ └────┴────────┴──────────┴────────────┘ │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ 点击行 → 弹出画像详情 + 编辑 │
|
||||||
|
│ - 每个字段显示: 值 + 置信度条 + 来源 │
|
||||||
|
│ - AI生成的字段可人工覆盖 │
|
||||||
|
│ - 显示变更历史时间线 │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 Sidebar ProfileCard 增强
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ 张同学 (AI生成 · 3小时前更新) │
|
||||||
|
├─────────────────────────────────┤
|
||||||
|
│ 意向度: 高(82分) ▓▓▓▓▓▓▓▓░░ │
|
||||||
|
│ 学员类型: 在校大学生 ✓ │
|
||||||
|
│ 基础水平: 有美术基础 ~ │
|
||||||
|
│ 关注点: 就业导向型 ✓ │
|
||||||
|
│ 决策阶段: 方案比较 → │
|
||||||
|
│ 兴趣课程: UE地编, 角色模型 │
|
||||||
|
│ 预算: 2-3万 ~ │
|
||||||
|
│ 意向城市: 上海 ✓ │
|
||||||
|
├─────────────────────────────────┤
|
||||||
|
│ [编辑] [查看分析来源] │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
|
||||||
|
图例: ✓ 高置信 ~ 中置信 ? 低置信/未知
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、实施计划
|
||||||
|
|
||||||
|
### Phase A:基础设施(1-2 天)
|
||||||
|
|
||||||
|
- [ ] 新增 `profile_generation_logs` 表
|
||||||
|
- [ ] 在 intent-service 新增 `ProfileGenerationService` 骨架
|
||||||
|
- [ ] 新增 `CustomerProfile` 字段:`autoGenerated`, `lastGeneratedAt`, `generatedBy`
|
||||||
|
- [ ] 新增 `ProfileGenerationController`(Admin 后台接口)
|
||||||
|
|
||||||
|
### Phase B:全量生成(2-3 天)
|
||||||
|
|
||||||
|
- [ ] 实现 `generateFullProfile()`:对话聚合 → Prompt 构建 → LLM 调用 → 结果解析
|
||||||
|
- [ ] 实现 `batchGenerate()`:异步批量任务,带进度追踪
|
||||||
|
- [ ] Admin 后台「批量生成」页面
|
||||||
|
- [ ] 为小范围客户(如 10-20 个)试运行,人工校验 LLM 输出质量
|
||||||
|
- [ ] 根据校验结果优化 Prompt
|
||||||
|
|
||||||
|
### Phase C:增量更新(2 天)
|
||||||
|
|
||||||
|
- [ ] 实现 `generateIncrementalUpdate()`:增量对话分析
|
||||||
|
- [ ] 实现合并策略(置信度加权 + 特殊字段规则)
|
||||||
|
- [ ] 新增定时任务 `DailyProfileUpdateJob`(每天凌晨执行)
|
||||||
|
- [ ] 画像变更时发送通知(可选:推送到 sidebar 提醒顾问)
|
||||||
|
|
||||||
|
### Phase D:前端增强(2 天)
|
||||||
|
|
||||||
|
- [ ] Admin 后台「客户画像」完整页面(列表 + 详情 + 编辑 + 变更历史)
|
||||||
|
- [ ] Sidebar ProfileCard 增强(置信度可视化、来源标注、变更提示)
|
||||||
|
- [ ] 画像生成任务进度页面
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、风险与应对
|
||||||
|
|
||||||
|
| 风险 | 影响 | 应对策略 |
|
||||||
|
|------|------|---------|
|
||||||
|
| LLM 调用成本高 | 每月可能产生数百元 API 费用 | 首次全量控制范围(先活跃客户);增量只分析有变化的用户;使用本地缓存避免重复分析 |
|
||||||
|
| LLM 输出不稳定 | 画像质量参差不齐 | Prompt 加 Few-shot 示例;输出后做枚举值校验;低置信度字段不展示 |
|
||||||
|
| 隐私合规 | 对话内容含敏感信息 | LLM 调用时不传输客户真实姓名/手机号;分析结果脱敏存储;保留审计日志 |
|
||||||
|
| 顾问不信任 AI 结果 | 不愿使用 | 高置信度字段默认采用,低置信度标记为"待确认";提供一键采纳/拒绝交互 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、预期效果
|
||||||
|
|
||||||
|
| 指标 | 现状 | 目标 |
|
||||||
|
|------|------|------|
|
||||||
|
| 有画像的客户占比 | ~10%(人工录入) | ≥80%(AI 生成 + 人工) |
|
||||||
|
| 画像字段完整度 | 平均 3-4 个字段有值 | 平均 8-10 个字段有值 |
|
||||||
|
| 画像更新频率 | 几乎不更新 | 每次新会话后自动更新 |
|
||||||
|
| 顾问录入时间 | 每个客户 2-3 分钟 | 仅需确认/修正,30 秒 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录:Prompt 测试建议
|
||||||
|
|
||||||
|
在正式开发前,建议先用 5-10 个真实客户的对话手动测试 Prompt,验证以下指标:
|
||||||
|
|
||||||
|
1. **准确率**:LLM 提取的画像字段,与人工阅读对话后的判断一致率
|
||||||
|
2. **召回率**:人工能看出的信息,LLM 是否都提取到了
|
||||||
|
3. **幻觉率**:LLM 是否生成了对话中没有的信息
|
||||||
|
4. **稳定性**:同一组对话多次调用,结果是否一致
|
||||||
|
|
||||||
|
测试通过后再进入开发阶段,可大幅减少返工。
|
||||||
Loading…
Reference in New Issue
Block a user