feat: 实现客户画像自动生成功能

1. 新增客户画像生成相关实体、Mapper、Service和控制器
2. 扩展CustomerProfile实体字段,增加自动生成标记相关属性
3. 新增画像生成日志表和相关业务逻辑
4. 前端客户列表页面增加画像管理Tab和批量生成功能
5. 补充完整的客户画像自动生成方案文档
This commit is contained in:
jiao 2026-06-08 10:01:49 +08:00
parent db8aa25da6
commit bd9920ca24
35 changed files with 1309 additions and 24 deletions

View File

@ -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;
}
}

View File

@ -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;
} }

View File

@ -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;
}

View File

@ -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> {
}

View File

@ -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;
}
}

View File

@ -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

View File

@ -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

View File

@ -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}

View 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. **稳定性**:同一组对话多次调用,结果是否一致
测试通过后再进入开发阶段,可大幅减少返工。