diff --git a/backend/common/target/common-1.0.0-SNAPSHOT.jar b/backend/common/target/common-1.0.0-SNAPSHOT.jar index e9f0508..b2945f2 100644 Binary files a/backend/common/target/common-1.0.0-SNAPSHOT.jar and b/backend/common/target/common-1.0.0-SNAPSHOT.jar differ diff --git a/backend/intent-service/src/main/java/com/artedu/intent/controller/ProfileGenerationController.java b/backend/intent-service/src/main/java/com/artedu/intent/controller/ProfileGenerationController.java new file mode 100644 index 0000000..e005702 --- /dev/null +++ b/backend/intent-service/src/main/java/com/artedu/intent/controller/ProfileGenerationController.java @@ -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>> 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> list = profileGenerationService.findCustomersWithoutProfile(corpId, limit, minMsgCount); + return Result.success(list); + } + + /** + * 为单个客户生成画像 + */ + @PostMapping("/generate/{customerId}") + public Result 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 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 customerIds; + private String operator; + } +} diff --git a/backend/intent-service/src/main/java/com/artedu/intent/entity/CustomerProfile.java b/backend/intent-service/src/main/java/com/artedu/intent/entity/CustomerProfile.java index b4f25af..5680ed1 100644 --- a/backend/intent-service/src/main/java/com/artedu/intent/entity/CustomerProfile.java +++ b/backend/intent-service/src/main/java/com/artedu/intent/entity/CustomerProfile.java @@ -35,6 +35,9 @@ public class CustomerProfile { private String slotData; private Integer conversationCount; private LocalDateTime lastConversationTime; + private Integer autoGenerated; + private LocalDateTime lastGeneratedAt; + private String generatedBy; private LocalDateTime createdAt; private LocalDateTime updatedAt; } diff --git a/backend/intent-service/src/main/java/com/artedu/intent/entity/ProfileGenerationLog.java b/backend/intent-service/src/main/java/com/artedu/intent/entity/ProfileGenerationLog.java new file mode 100644 index 0000000..0eaf470 --- /dev/null +++ b/backend/intent-service/src/main/java/com/artedu/intent/entity/ProfileGenerationLog.java @@ -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; +} diff --git a/backend/intent-service/src/main/java/com/artedu/intent/mapper/ProfileGenerationLogMapper.java b/backend/intent-service/src/main/java/com/artedu/intent/mapper/ProfileGenerationLogMapper.java new file mode 100644 index 0000000..07e1774 --- /dev/null +++ b/backend/intent-service/src/main/java/com/artedu/intent/mapper/ProfileGenerationLogMapper.java @@ -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 { +} diff --git a/backend/intent-service/src/main/java/com/artedu/intent/service/ProfileGenerationService.java b/backend/intent-service/src/main/java/com/artedu/intent/service/ProfileGenerationService.java new file mode 100644 index 0000000..92f9269 --- /dev/null +++ b/backend/intent-service/src/main/java/com/artedu/intent/service/ProfileGenerationService.java @@ -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> 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() + .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 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> 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> 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> messages) { + StringBuilder sb = new StringBuilder(); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MM-dd HH:mm"); + + for (Map 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 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> request = new HttpEntity<>(requestBody, headers); + ResponseEntity response = restTemplate.postForEntity( + generationServiceUrl + "/api/v1/generation/generate", request, String.class); + + if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) { + Map result = JsonUtils.fromJsonMap(response.getBody()); + if (result != null && result.get("data") != null) { + Map data = (Map) 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 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 map, String key) { + FieldValue fv = new FieldValue(); + Object val = map.get(key); + if (val instanceof Map) { + Map m = (Map) 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 map, String key) { + Object val = map.get(key); + return val != null ? val.toString() : null; + } + + @SuppressWarnings("unchecked") + private List getStringList(Map map, String key) { + Object val = map.get(key); + if (val instanceof List) { + return ((List) 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 getFieldList(FieldValue fv) { + if (fv == null || fv.getValue() == null) return Collections.emptyList(); + Object v = fv.getValue(); + if (v instanceof List) { + return ((List) v).stream().map(Object::toString).collect(Collectors.toList()); + } + return Collections.emptyList(); + } + + private String toJson(List 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 keyQuotes; + } + + @lombok.Data + public static class FieldValue { + private Object value; + private double confidence; + private String evidence; + } +} diff --git a/backend/intent-service/target/classes/com/artedu/intent/IntentServiceApplication.class b/backend/intent-service/target/classes/com/artedu/intent/IntentServiceApplication.class index e708257..dc8dca7 100644 Binary files a/backend/intent-service/target/classes/com/artedu/intent/IntentServiceApplication.class and b/backend/intent-service/target/classes/com/artedu/intent/IntentServiceApplication.class differ diff --git a/backend/intent-service/target/classes/com/artedu/intent/controller/CustomerProfileController.class b/backend/intent-service/target/classes/com/artedu/intent/controller/CustomerProfileController.class index 79b4b33..9840402 100644 Binary files a/backend/intent-service/target/classes/com/artedu/intent/controller/CustomerProfileController.class and b/backend/intent-service/target/classes/com/artedu/intent/controller/CustomerProfileController.class differ diff --git a/backend/intent-service/target/classes/com/artedu/intent/controller/IntentController.class b/backend/intent-service/target/classes/com/artedu/intent/controller/IntentController.class index 888424c..78775f9 100644 Binary files a/backend/intent-service/target/classes/com/artedu/intent/controller/IntentController.class and b/backend/intent-service/target/classes/com/artedu/intent/controller/IntentController.class differ diff --git a/backend/intent-service/target/classes/com/artedu/intent/controller/ProfileGenerationController$BatchGenerateRequest.class b/backend/intent-service/target/classes/com/artedu/intent/controller/ProfileGenerationController$BatchGenerateRequest.class new file mode 100644 index 0000000..6487855 Binary files /dev/null and b/backend/intent-service/target/classes/com/artedu/intent/controller/ProfileGenerationController$BatchGenerateRequest.class differ diff --git a/backend/intent-service/target/classes/com/artedu/intent/controller/ProfileGenerationController.class b/backend/intent-service/target/classes/com/artedu/intent/controller/ProfileGenerationController.class new file mode 100644 index 0000000..47ecba9 Binary files /dev/null and b/backend/intent-service/target/classes/com/artedu/intent/controller/ProfileGenerationController.class differ diff --git a/backend/intent-service/target/classes/com/artedu/intent/dto/IntentResult$IntentInfo.class b/backend/intent-service/target/classes/com/artedu/intent/dto/IntentResult$IntentInfo.class index c3f7d59..ea10301 100644 Binary files a/backend/intent-service/target/classes/com/artedu/intent/dto/IntentResult$IntentInfo.class and b/backend/intent-service/target/classes/com/artedu/intent/dto/IntentResult$IntentInfo.class differ diff --git a/backend/intent-service/target/classes/com/artedu/intent/dto/IntentResult.class b/backend/intent-service/target/classes/com/artedu/intent/dto/IntentResult.class index 736fabb..a7ba670 100644 Binary files a/backend/intent-service/target/classes/com/artedu/intent/dto/IntentResult.class and b/backend/intent-service/target/classes/com/artedu/intent/dto/IntentResult.class differ diff --git a/backend/intent-service/target/classes/com/artedu/intent/entity/CustomerProfile.class b/backend/intent-service/target/classes/com/artedu/intent/entity/CustomerProfile.class index 8bd743c..8b8d4e8 100644 Binary files a/backend/intent-service/target/classes/com/artedu/intent/entity/CustomerProfile.class and b/backend/intent-service/target/classes/com/artedu/intent/entity/CustomerProfile.class differ diff --git a/backend/intent-service/target/classes/com/artedu/intent/entity/IntentCategory.class b/backend/intent-service/target/classes/com/artedu/intent/entity/IntentCategory.class index 87534f8..cb882c3 100644 Binary files a/backend/intent-service/target/classes/com/artedu/intent/entity/IntentCategory.class and b/backend/intent-service/target/classes/com/artedu/intent/entity/IntentCategory.class differ diff --git a/backend/intent-service/target/classes/com/artedu/intent/entity/IntentRecognition.class b/backend/intent-service/target/classes/com/artedu/intent/entity/IntentRecognition.class index 655a1f6..68e3cde 100644 Binary files a/backend/intent-service/target/classes/com/artedu/intent/entity/IntentRecognition.class and b/backend/intent-service/target/classes/com/artedu/intent/entity/IntentRecognition.class differ diff --git a/backend/intent-service/target/classes/com/artedu/intent/entity/ProfileGenerationLog.class b/backend/intent-service/target/classes/com/artedu/intent/entity/ProfileGenerationLog.class new file mode 100644 index 0000000..402e3dc Binary files /dev/null and b/backend/intent-service/target/classes/com/artedu/intent/entity/ProfileGenerationLog.class differ diff --git a/backend/intent-service/target/classes/com/artedu/intent/mapper/CustomerProfileMapper.class b/backend/intent-service/target/classes/com/artedu/intent/mapper/CustomerProfileMapper.class index ab21922..612a318 100644 Binary files a/backend/intent-service/target/classes/com/artedu/intent/mapper/CustomerProfileMapper.class and b/backend/intent-service/target/classes/com/artedu/intent/mapper/CustomerProfileMapper.class differ diff --git a/backend/intent-service/target/classes/com/artedu/intent/mapper/IntentCategoryMapper.class b/backend/intent-service/target/classes/com/artedu/intent/mapper/IntentCategoryMapper.class index f7b7643..399bcaa 100644 Binary files a/backend/intent-service/target/classes/com/artedu/intent/mapper/IntentCategoryMapper.class and b/backend/intent-service/target/classes/com/artedu/intent/mapper/IntentCategoryMapper.class differ diff --git a/backend/intent-service/target/classes/com/artedu/intent/mapper/IntentRecognitionMapper.class b/backend/intent-service/target/classes/com/artedu/intent/mapper/IntentRecognitionMapper.class index 8eabddd..545a05c 100644 Binary files a/backend/intent-service/target/classes/com/artedu/intent/mapper/IntentRecognitionMapper.class and b/backend/intent-service/target/classes/com/artedu/intent/mapper/IntentRecognitionMapper.class differ diff --git a/backend/intent-service/target/classes/com/artedu/intent/mapper/ProfileGenerationLogMapper.class b/backend/intent-service/target/classes/com/artedu/intent/mapper/ProfileGenerationLogMapper.class new file mode 100644 index 0000000..cad832a Binary files /dev/null and b/backend/intent-service/target/classes/com/artedu/intent/mapper/ProfileGenerationLogMapper.class differ diff --git a/backend/intent-service/target/classes/com/artedu/intent/service/CustomerProfileService.class b/backend/intent-service/target/classes/com/artedu/intent/service/CustomerProfileService.class index 829ca21..f24fb55 100644 Binary files a/backend/intent-service/target/classes/com/artedu/intent/service/CustomerProfileService.class and b/backend/intent-service/target/classes/com/artedu/intent/service/CustomerProfileService.class differ diff --git a/backend/intent-service/target/classes/com/artedu/intent/service/IntentRecognitionService.class b/backend/intent-service/target/classes/com/artedu/intent/service/IntentRecognitionService.class index fa85717..568d877 100644 Binary files a/backend/intent-service/target/classes/com/artedu/intent/service/IntentRecognitionService.class and b/backend/intent-service/target/classes/com/artedu/intent/service/IntentRecognitionService.class differ diff --git a/backend/intent-service/target/classes/com/artedu/intent/service/LLMIntentClient.class b/backend/intent-service/target/classes/com/artedu/intent/service/LLMIntentClient.class index 44c916b..0690521 100644 Binary files a/backend/intent-service/target/classes/com/artedu/intent/service/LLMIntentClient.class and b/backend/intent-service/target/classes/com/artedu/intent/service/LLMIntentClient.class differ diff --git a/backend/intent-service/target/classes/com/artedu/intent/service/ProfileGenerationService$FieldValue.class b/backend/intent-service/target/classes/com/artedu/intent/service/ProfileGenerationService$FieldValue.class new file mode 100644 index 0000000..c2a2ffb Binary files /dev/null and b/backend/intent-service/target/classes/com/artedu/intent/service/ProfileGenerationService$FieldValue.class differ diff --git a/backend/intent-service/target/classes/com/artedu/intent/service/ProfileGenerationService$GenerationResult.class b/backend/intent-service/target/classes/com/artedu/intent/service/ProfileGenerationService$GenerationResult.class new file mode 100644 index 0000000..6da8b68 Binary files /dev/null and b/backend/intent-service/target/classes/com/artedu/intent/service/ProfileGenerationService$GenerationResult.class differ diff --git a/backend/intent-service/target/classes/com/artedu/intent/service/ProfileGenerationService$ProfileParseResult.class b/backend/intent-service/target/classes/com/artedu/intent/service/ProfileGenerationService$ProfileParseResult.class new file mode 100644 index 0000000..280fc2f Binary files /dev/null and b/backend/intent-service/target/classes/com/artedu/intent/service/ProfileGenerationService$ProfileParseResult.class differ diff --git a/backend/intent-service/target/classes/com/artedu/intent/service/ProfileGenerationService.class b/backend/intent-service/target/classes/com/artedu/intent/service/ProfileGenerationService.class new file mode 100644 index 0000000..c6cd81b Binary files /dev/null and b/backend/intent-service/target/classes/com/artedu/intent/service/ProfileGenerationService.class differ diff --git a/backend/intent-service/target/intent-service-1.0.0-SNAPSHOT.jar b/backend/intent-service/target/intent-service-1.0.0-SNAPSHOT.jar index d71dce4..633179f 100644 Binary files a/backend/intent-service/target/intent-service-1.0.0-SNAPSHOT.jar and b/backend/intent-service/target/intent-service-1.0.0-SNAPSHOT.jar differ diff --git a/backend/intent-service/target/intent-service-1.0.0-SNAPSHOT.jar.original b/backend/intent-service/target/intent-service-1.0.0-SNAPSHOT.jar.original index 7a98378..54a3460 100644 Binary files a/backend/intent-service/target/intent-service-1.0.0-SNAPSHOT.jar.original and b/backend/intent-service/target/intent-service-1.0.0-SNAPSHOT.jar.original differ diff --git a/backend/intent-service/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/backend/intent-service/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst index 4a91ffb..7c8dc3f 100644 --- a/backend/intent-service/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst +++ b/backend/intent-service/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst @@ -1,14 +1,22 @@ com\artedu\intent\IntentServiceApplication.class com\artedu\intent\controller\CustomerProfileController.class com\artedu\intent\dto\IntentResult$IntentInfo.class +com\artedu\intent\service\ProfileGenerationService.class com\artedu\intent\service\LLMIntentClient.class com\artedu\intent\mapper\IntentCategoryMapper.class com\artedu\intent\dto\IntentResult.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\IntentRecognitionMapper.class com\artedu\intent\service\CustomerProfileService.class +com\artedu\intent\service\ProfileGenerationService$ProfileParseResult.class com\artedu\intent\controller\IntentController.class com\artedu\intent\entity\IntentRecognition.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\entity\ProfileGenerationLog.class +com\artedu\intent\controller\ProfileGenerationController$BatchGenerateRequest.class diff --git a/backend/intent-service/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/backend/intent-service/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst index a49299b..1318056 100644 --- a/backend/intent-service/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst +++ b/backend/intent-service/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst @@ -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\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\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\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\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\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\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\ProfileGenerationService.java diff --git a/backend/intent-service/target/test-classes/com/artedu/intent/controller/IntentControllerTest.class b/backend/intent-service/target/test-classes/com/artedu/intent/controller/IntentControllerTest.class index d11ce72..512b5c7 100644 Binary files a/backend/intent-service/target/test-classes/com/artedu/intent/controller/IntentControllerTest.class and b/backend/intent-service/target/test-classes/com/artedu/intent/controller/IntentControllerTest.class differ diff --git a/frontend/admin/src/pages/CustomerList.tsx b/frontend/admin/src/pages/CustomerList.tsx index aa3e84e..ebe783d 100644 --- a/frontend/admin/src/pages/CustomerList.tsx +++ b/frontend/admin/src/pages/CustomerList.tsx @@ -1,10 +1,12 @@ 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' const CORP_ID = 'wwd483c2fba24ae30a' export default function CustomerList() { + const [activeTab, setActiveTab] = useState('existing') const [data, setData] = useState([]) const [loading, setLoading] = useState(false) const [modalVisible, setModalVisible] = useState(false) @@ -12,6 +14,13 @@ export default function CustomerList() { const [form] = Form.useForm() const [searchKeyword, setSearchKeyword] = useState('') + // 批量生成相关 + const [pendingData, setPendingData] = useState([]) + const [pendingLoading, setPendingLoading] = useState(false) + const [selectedCustomers, setSelectedCustomers] = useState([]) + const [generateLoading, setGenerateLoading] = useState(false) + const [singleGenerateLoading, setSingleGenerateLoading] = useState(null) + const loadData = async (keyword = '') => { setLoading(true) 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(() => { - loadData() - }, []) + if (activeTab === 'existing') { + loadData() + } else { + loadPending() + } + }, [activeTab]) const handleSave = async () => { const values = await form.validateFields() try { - const payload = { ...values, corpId: CORP_ID } + const payload = { ...values, corpId: CORP_ID, manualOverride: true } await request.post('/v1/intent/profiles', payload) message.success(editing ? '更新成功' : '创建成功') setModalVisible(false) @@ -79,6 +106,51 @@ export default function CustomerList() { 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) => { switch (level) { case '高': return 'green' @@ -88,17 +160,25 @@ export default function CustomerList() { } } - const columns = [ + const getSourceTag = (record: any) => { + if (record.autoGenerated === 1) { + return }>AI生成 + } + return 人工录入 + } + + const existingColumns = [ { title: '客户ID', dataIndex: 'customerId', ellipsis: true, width: 180 }, + { title: '来源', width: 100, render: (_: any, record: any) => getSourceTag(record) }, { title: '职业/名称', dataIndex: 'currentOccupation', width: 120 }, { title: '学员类型', dataIndex: 'studentType', width: 110 }, { title: '基础水平', dataIndex: 'skillLevel', width: 110 }, { title: '意向度', dataIndex: 'intentLevel', - width: 90, + width: 100, render: (level: string, record: any) => ( - {level} {record.intentScore != null ? `(${record.intentScore})` : ''} + {level} {record.intentScore != null ? `(${Math.round(record.intentScore)})` : ''} ) }, { title: '关注重点', dataIndex: 'concernFocus', width: 120 }, @@ -117,29 +197,117 @@ export default function CustomerList() { } ] + const pendingColumns = [ + { title: '客户ID', dataIndex: 'customer_id', ellipsis: true, width: 200 }, + { + title: '消息数', + dataIndex: 'msg_count', + width: 100, + render: (v: number) => = 20 ? '#52c41a' : v >= 10 ? '#faad14' : '#d9d9d9' }} /> + }, + { + title: '操作', + width: 140, + render: (_: any, record: any) => ( + + ) + } + ] + + const rowSelection = { + selectedRowKeys: selectedCustomers, + onChange: (keys: React.Key[]) => setSelectedCustomers(keys as string[]), + } + return (

客户画像管理

- - setSearchKeyword(e.target.value)} - onSearch={handleSearch} - style={{ width: 260 }} - allowClear - /> - - + {activeTab === 'existing' && ( + + setSearchKeyword(e.target.value)} + onSearch={handleSearch} + style={{ width: 260 }} + allowClear + /> + + + )}
- + 已有画像 + {data.length} + + ), + children: ( +
+ ), + }, + { + key: 'pending', + label: ( + + 待生成画像 + {pendingData.length} + + ), + children: ( +
+ + 批量生成 ({selectedCustomers.length}) + + } + /> +
+ + ), + }, + ]} /> + 版本: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 customerIds, + LocalDateTime msgSince); + + // 合并更新建议到现有画像 + public void applyUpdates(String customerId, String corpId, + List 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 + +GET /api/v1/admin/profiles/{customerId}/logs +响应: List +``` + +### 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. **稳定性**:同一组对话多次调用,结果是否一致 + +测试通过后再进入开发阶段,可大幅减少返工。