feat: complete aihr mvp mobile workflow
This commit is contained in:
@@ -7,13 +7,17 @@ import cn.hutool.core.codec.Base64;
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.util.ObjectUtil;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import me.zhyd.oauth.model.AuthResponse;
|
||||
import me.zhyd.oauth.model.AuthUser;
|
||||
import me.zhyd.oauth.request.AuthRequest;
|
||||
import me.zhyd.oauth.utils.AuthStateUtils;
|
||||
import org.dromara.common.core.constant.RegexConstants;
|
||||
import org.dromara.common.core.constant.SystemConstants;
|
||||
import org.dromara.common.core.constant.TenantConstants;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.common.core.domain.model.LoginBody;
|
||||
import org.dromara.common.core.domain.model.RegisterBody;
|
||||
@@ -67,6 +71,9 @@ import java.util.concurrent.TimeUnit;
|
||||
@RequestMapping("/auth")
|
||||
public class AuthController {
|
||||
|
||||
private static final String MOBILE_CLIENT_ID = "428a8310cd442757ae699df5d894f051";
|
||||
private static final String MOBILE_GRANT_TYPE = "sms";
|
||||
|
||||
private final SocialProperties socialProperties;
|
||||
private final SysLoginService loginService;
|
||||
private final SysRegisterService registerService;
|
||||
@@ -114,6 +121,31 @@ public class AuthController {
|
||||
return R.ok(loginVo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动端短信登录,复用 app 客户端和 sms 授权策略。
|
||||
*/
|
||||
@PostMapping("/mobile/sms-login")
|
||||
public R<LoginVo> mobileSmsLogin(@Validated @RequestBody MobileSmsLoginBody body) {
|
||||
SysClientVo client = clientService.queryByClientId(MOBILE_CLIENT_ID);
|
||||
if (ObjectUtil.isNull(client) || !StringUtils.contains(client.getGrantType(), MOBILE_GRANT_TYPE)) {
|
||||
log.info("移动端客户端id: {} 认证类型:{} 异常!.", MOBILE_CLIENT_ID, MOBILE_GRANT_TYPE);
|
||||
return R.fail(MessageUtils.message("auth.grant.type.error"));
|
||||
} else if (!SystemConstants.NORMAL.equals(client.getStatus())) {
|
||||
return R.fail(MessageUtils.message("auth.grant.type.blocked"));
|
||||
}
|
||||
|
||||
String tenantId = StringUtils.blankToDefault(body.tenantId(), TenantConstants.DEFAULT_TENANT_ID);
|
||||
loginService.checkTenant(tenantId);
|
||||
|
||||
Map<String, String> loginBody = new HashMap<>();
|
||||
loginBody.put("clientId", MOBILE_CLIENT_ID);
|
||||
loginBody.put("grantType", MOBILE_GRANT_TYPE);
|
||||
loginBody.put("tenantId", tenantId);
|
||||
loginBody.put("phonenumber", body.phonenumber());
|
||||
loginBody.put("smsCode", body.smsCode());
|
||||
return R.ok(IAuthStrategy.login(JsonUtils.toJsonString(loginBody), client, MOBILE_GRANT_TYPE));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取跳转URL
|
||||
*
|
||||
@@ -240,4 +272,14 @@ public class AuthController {
|
||||
return R.ok(result);
|
||||
}
|
||||
|
||||
public record MobileSmsLoginBody(
|
||||
@NotBlank(message = "{user.phonenumber.not.blank}")
|
||||
@Pattern(regexp = RegexConstants.MOBILE, message = "{user.mobile.phone.number.not.valid}")
|
||||
String phonenumber,
|
||||
@NotBlank(message = "{sms.code.not.blank}")
|
||||
String smsCode,
|
||||
String tenantId
|
||||
) {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+15
-6
@@ -7,10 +7,12 @@ import cn.hutool.captcha.generator.RandomGenerator;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.core.util.RandomUtil;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.common.core.constant.Constants;
|
||||
import org.dromara.common.core.constant.GlobalConstants;
|
||||
import org.dromara.common.core.constant.RegexConstants;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.common.core.utils.SpringUtils;
|
||||
@@ -26,6 +28,7 @@ import org.dromara.sms4j.api.SmsBlend;
|
||||
import org.dromara.sms4j.api.entity.SmsResponse;
|
||||
import org.dromara.sms4j.core.factory.SmsFactory;
|
||||
import org.dromara.web.domain.vo.CaptchaVo;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.expression.Expression;
|
||||
import org.springframework.expression.ExpressionParser;
|
||||
import org.springframework.expression.spel.standard.SpelExpressionParser;
|
||||
@@ -52,6 +55,9 @@ public class CaptchaController {
|
||||
private final CaptchaProperties captchaProperties;
|
||||
private final MailProperties mailProperties;
|
||||
|
||||
@Value("${aihr.sms.login-template-id:}")
|
||||
private String smsLoginTemplateId;
|
||||
|
||||
/**
|
||||
* 短信验证码
|
||||
*
|
||||
@@ -59,20 +65,23 @@ public class CaptchaController {
|
||||
*/
|
||||
@RateLimiter(key = "#phonenumber", time = 60, count = 1)
|
||||
@GetMapping("/resource/sms/code")
|
||||
public R<Void> smsCode(@NotBlank(message = "{user.phonenumber.not.blank}") String phonenumber) {
|
||||
public R<Void> smsCode(
|
||||
@NotBlank(message = "{user.phonenumber.not.blank}")
|
||||
@Pattern(regexp = RegexConstants.MOBILE, message = "{user.mobile.phone.number.not.valid}") String phonenumber) {
|
||||
if (StringUtils.isBlank(smsLoginTemplateId)) {
|
||||
return R.fail("短信模板未配置:请设置 aihr.sms.login-template-id");
|
||||
}
|
||||
String key = GlobalConstants.CAPTCHA_CODE_KEY + phonenumber;
|
||||
String code = RandomUtil.randomNumbers(4);
|
||||
RedisUtils.setCacheObject(key, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
|
||||
// 验证码模板id 自行处理 (查数据库或写死均可)
|
||||
String templateId = "";
|
||||
LinkedHashMap<String, String> map = new LinkedHashMap<>(1);
|
||||
map.put("code", code);
|
||||
SmsBlend smsBlend = SmsFactory.getSmsBlend("config1");
|
||||
SmsResponse smsResponse = smsBlend.sendMessage(phonenumber, templateId, map);
|
||||
SmsResponse smsResponse = smsBlend.sendMessage(phonenumber, smsLoginTemplateId, map);
|
||||
if (!smsResponse.isSuccess()) {
|
||||
log.error("验证码短信发送异常 => {}", smsResponse);
|
||||
return R.fail(smsResponse.getData().toString());
|
||||
return R.fail(String.valueOf(smsResponse.getData()));
|
||||
}
|
||||
RedisUtils.setCacheObject(key, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
|
||||
+15
-5
@@ -12,6 +12,7 @@ import org.dromara.common.core.constant.SystemConstants;
|
||||
import org.dromara.common.core.domain.model.LoginUser;
|
||||
import org.dromara.common.core.domain.model.SmsLoginBody;
|
||||
import org.dromara.common.core.enums.LoginType;
|
||||
import org.dromara.common.core.enums.UserType;
|
||||
import org.dromara.common.core.exception.user.CaptchaExpireException;
|
||||
import org.dromara.common.core.exception.user.UserException;
|
||||
import org.dromara.common.core.utils.MessageUtils;
|
||||
@@ -51,8 +52,8 @@ public class SmsAuthStrategy implements IAuthStrategy {
|
||||
String phonenumber = loginBody.getPhonenumber();
|
||||
String smsCode = loginBody.getSmsCode();
|
||||
LoginUser loginUser = TenantHelper.dynamic(tenantId, () -> {
|
||||
SysUserVo user = loadUserByPhonenumber(phonenumber);
|
||||
loginService.checkLogin(LoginType.SMS, tenantId, user.getUserName(), () -> !validateSmsCode(tenantId, phonenumber, smsCode));
|
||||
loginService.checkLogin(LoginType.SMS, tenantId, phonenumber, () -> !validateSmsCode(tenantId, phonenumber, smsCode));
|
||||
SysUserVo user = loadOrRegisterUserByPhonenumber(tenantId, phonenumber);
|
||||
// 此处可根据登录用户的数据不同 自行创建 loginUser 属性不够用继承扩展就行了
|
||||
return loginService.buildLoginUser(user);
|
||||
});
|
||||
@@ -87,11 +88,20 @@ public class SmsAuthStrategy implements IAuthStrategy {
|
||||
return code.equals(smsCode);
|
||||
}
|
||||
|
||||
private SysUserVo loadUserByPhonenumber(String phonenumber) {
|
||||
private SysUserVo loadOrRegisterUserByPhonenumber(String tenantId, String phonenumber) {
|
||||
SysUserVo user = userMapper.selectVoOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getPhonenumber, phonenumber));
|
||||
if (ObjectUtil.isNull(user)) {
|
||||
log.info("登录用户:{} 不存在.", phonenumber);
|
||||
throw new UserException("user.not.exists", phonenumber);
|
||||
SysUser sysUser = new SysUser();
|
||||
sysUser.setTenantId(tenantId);
|
||||
sysUser.setUserName(phonenumber);
|
||||
sysUser.setNickName("手机用户" + phonenumber.substring(7));
|
||||
sysUser.setUserType(UserType.APP_USER.getUserType());
|
||||
sysUser.setPhonenumber(phonenumber);
|
||||
sysUser.setStatus(SystemConstants.NORMAL);
|
||||
sysUser.setRemark("移动端短信自动注册");
|
||||
userMapper.insert(sysUser);
|
||||
log.info("短信登录自动注册用户:{}", phonenumber);
|
||||
user = userMapper.selectVoById(sysUser.getUserId());
|
||||
} else if (SystemConstants.DISABLE.equals(user.getStatus())) {
|
||||
log.info("登录用户:{} 已被停用.", phonenumber);
|
||||
throw new UserException("user.blocked", phonenumber);
|
||||
|
||||
@@ -155,6 +155,9 @@ mail:
|
||||
|
||||
--- # sms 短信 支持 阿里云 腾讯云 云片 等等各式各样的短信服务商
|
||||
# https://sms4j.com/doc3/ 差异配置文档地址 支持单厂商多配置,可以配置多个同时使用
|
||||
aihr:
|
||||
sms:
|
||||
login-template-id: ${AIHR_SMS_LOGIN_TEMPLATE_ID:}
|
||||
sms:
|
||||
# 配置源类型用于标定配置来源(interface,yaml)
|
||||
config-type: yaml
|
||||
@@ -172,11 +175,11 @@ sms:
|
||||
# 框架定义的厂商名称标识,标定此配置是哪个厂商,详细请看厂商标识介绍部分
|
||||
supplier: alibaba
|
||||
# 有些称为accessKey有些称之为apiKey,也有称为sdkKey或者appId。
|
||||
access-key-id: 您的accessKey
|
||||
access-key-id: ${ALIYUN_SMS_ACCESS_KEY_ID:您的accessKey}
|
||||
# 称为accessSecret有些称之为apiSecret
|
||||
access-key-secret: 您的accessKeySecret
|
||||
signature: 您的短信签名
|
||||
sdk-app-id: 您的sdkAppId
|
||||
access-key-secret: ${ALIYUN_SMS_ACCESS_KEY_SECRET:您的accessKeySecret}
|
||||
signature: ${ALIYUN_SMS_SIGN_NAME:您的短信签名}
|
||||
sdk-app-id: ${ALIYUN_SMS_SDK_APP_ID:您的sdkAppId}
|
||||
config2:
|
||||
# 厂商标识,标定此配置是哪个厂商,详细请看厂商标识介绍部分
|
||||
supplier: tencent
|
||||
|
||||
@@ -158,6 +158,9 @@ mail:
|
||||
|
||||
--- # sms 短信 支持 阿里云 腾讯云 云片 等等各式各样的短信服务商
|
||||
# https://sms4j.com/doc3/ 差异配置文档地址 支持单厂商多配置,可以配置多个同时使用
|
||||
aihr:
|
||||
sms:
|
||||
login-template-id: ${AIHR_SMS_LOGIN_TEMPLATE_ID:}
|
||||
sms:
|
||||
# 配置源类型用于标定配置来源(interface,yaml)
|
||||
config-type: yaml
|
||||
@@ -175,11 +178,11 @@ sms:
|
||||
# 框架定义的厂商名称标识,标定此配置是哪个厂商,详细请看厂商标识介绍部分
|
||||
supplier: alibaba
|
||||
# 有些称为accessKey有些称之为apiKey,也有称为sdkKey或者appId。
|
||||
access-key-id: 您的accessKey
|
||||
access-key-id: ${ALIYUN_SMS_ACCESS_KEY_ID:您的accessKey}
|
||||
# 称为accessSecret有些称之为apiSecret
|
||||
access-key-secret: 您的accessKeySecret
|
||||
signature: 您的短信签名
|
||||
sdk-app-id: 您的sdkAppId
|
||||
access-key-secret: ${ALIYUN_SMS_ACCESS_KEY_SECRET:您的accessKeySecret}
|
||||
signature: ${ALIYUN_SMS_SIGN_NAME:您的短信签名}
|
||||
sdk-app-id: ${ALIYUN_SMS_SDK_APP_ID:您的sdkAppId}
|
||||
config2:
|
||||
# 厂商标识,标定此配置是哪个厂商,详细请看厂商标识介绍部分
|
||||
supplier: tencent
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<artifactId>ruoyi-aihr</artifactId>
|
||||
|
||||
<description>
|
||||
物业 AI 人力资源 Demo 业务模块
|
||||
物业 AI 人力资源 MVP 业务模块
|
||||
</description>
|
||||
|
||||
<dependencies>
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package org.dromara.aihr.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaIgnore;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.CompetencyResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse;
|
||||
import org.dromara.aihr.domain.vo.AihrMobileDto.HomeResponse;
|
||||
import org.dromara.aihr.service.AihrMobileSeedService;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Mobile H5 home APIs.
|
||||
*/
|
||||
@SaIgnore
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/api/aihr/mobile")
|
||||
public class AihrMobileController {
|
||||
|
||||
private final AihrMobileSeedService mobileSeedService;
|
||||
|
||||
@GetMapping("/home/{role}")
|
||||
public R<HomeResponse> home(@PathVariable String role) {
|
||||
return R.ok(mobileSeedService.home(role));
|
||||
}
|
||||
|
||||
@GetMapping("/practice/history")
|
||||
public R<List<RecordResponse>> practiceHistory(@RequestParam(required = false) String extPartyId) {
|
||||
return R.ok(mobileSeedService.practiceHistory(extPartyId));
|
||||
}
|
||||
|
||||
@GetMapping("/practice/reviews")
|
||||
public R<List<RecordResponse>> practiceReviews() {
|
||||
return R.ok(mobileSeedService.practiceReviews());
|
||||
}
|
||||
|
||||
@GetMapping("/practice/reviews/{id}")
|
||||
public R<ReviewDetailResponse> practiceReview(@PathVariable Long id) {
|
||||
ReviewDetailResponse detail = mobileSeedService.practiceReview(id);
|
||||
return detail == null ? R.fail("复盘记录不存在") : R.ok(detail);
|
||||
}
|
||||
|
||||
@PostMapping("/practice/reviews/{id}/reviewed")
|
||||
public R<Void> markReviewed(@PathVariable Long id) {
|
||||
return mobileSeedService.markReviewed(id) ? R.ok() : R.fail("复盘记录不存在");
|
||||
}
|
||||
|
||||
@GetMapping("/profile")
|
||||
public R<CompetencyResponse> profile(@RequestParam(required = false) String extPartyId) {
|
||||
return R.ok(mobileSeedService.profile(extPartyId));
|
||||
}
|
||||
}
|
||||
+17
@@ -8,6 +8,8 @@ import org.dromara.aihr.domain.AihrSopDto.ProcessingOverviewResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SearchRequest;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SearchResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.UploadResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.VectorIndexStatusResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.VectorizeResponse;
|
||||
import org.dromara.aihr.service.AihrSopSeedService;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -51,6 +53,21 @@ public class AihrSopController {
|
||||
return R.ok(sopSeedService.startLocalImportTask(request));
|
||||
}
|
||||
|
||||
@PostMapping("/doc/vectorize-missing")
|
||||
public R<VectorizeResponse> vectorizeMissing() {
|
||||
return R.ok(sopSeedService.vectorizeMissing());
|
||||
}
|
||||
|
||||
@GetMapping("/doc/vector-index-status")
|
||||
public R<VectorIndexStatusResponse> vectorIndexStatus() {
|
||||
return R.ok(sopSeedService.vectorIndexStatus());
|
||||
}
|
||||
|
||||
@PostMapping("/doc/rebuild-vector-index")
|
||||
public R<VectorizeResponse> rebuildVectorIndex() {
|
||||
return R.ok(sopSeedService.rebuildVectorIndex());
|
||||
}
|
||||
|
||||
@GetMapping("/doc/import-tasks")
|
||||
public R<List<LocalImportTaskResponse>> localImportTasks() {
|
||||
return R.ok(sopSeedService.localImportTasks());
|
||||
|
||||
+23
-1
@@ -56,6 +56,28 @@ public final class AihrPracticeDto {
|
||||
public record DimensionResponse(String label, Object value, String note) {
|
||||
}
|
||||
|
||||
public record RecordResponse(String time, String trainee, String scene, Integer score, String status, String summary) {
|
||||
public record RecordResponse(Long id, String sessionId, String time, String trainee, String scene, Integer score, String status, String summary) {
|
||||
}
|
||||
|
||||
public record DialogueResponse(String role, String label, String text) {
|
||||
}
|
||||
|
||||
public record ReviewDetailResponse(
|
||||
Long id,
|
||||
String sessionId,
|
||||
String time,
|
||||
String trainee,
|
||||
String scene,
|
||||
Integer score,
|
||||
String status,
|
||||
String summary,
|
||||
String mentorRewrite,
|
||||
String aiComment,
|
||||
List<DimensionResponse> scoreItems,
|
||||
List<DialogueResponse> dialogue
|
||||
) {
|
||||
}
|
||||
|
||||
public record CompetencyResponse(Integer score, Integer completed, Integer pendingReview, List<DimensionResponse> dimensions) {
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -13,6 +13,24 @@ public final class AihrSopDto {
|
||||
public record UploadResponse(String docId, Long ossId, String fileName, String category, Integer fragments, String summary, List<String> tags, List<SnippetResponse> snippets) {
|
||||
}
|
||||
|
||||
public record VectorizeResponse(Integer documents, Integer fragments, String model) {
|
||||
}
|
||||
|
||||
public record VectorIndexStatusResponse(
|
||||
String modelName,
|
||||
Integer modelDimension,
|
||||
String collection,
|
||||
Integer qdrantDimension,
|
||||
Long qdrantPoints,
|
||||
Integer fragments,
|
||||
Integer embeddedFragments,
|
||||
String embeddingModel,
|
||||
Integer embeddingDimension,
|
||||
Boolean matched,
|
||||
String message
|
||||
) {
|
||||
}
|
||||
|
||||
public record LocalImportRequest(String directory, String category, Integer limit) {
|
||||
}
|
||||
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package org.dromara.aihr.domain.vo;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Mobile H5 seed DTOs.
|
||||
*/
|
||||
public final class AihrMobileDto {
|
||||
|
||||
private AihrMobileDto() {
|
||||
}
|
||||
|
||||
public record HomeResponse(
|
||||
String role,
|
||||
String roleLabel,
|
||||
String avatarTone,
|
||||
String greeting,
|
||||
String location,
|
||||
String toolTitle,
|
||||
String recommend,
|
||||
PrimaryTaskResponse primary,
|
||||
ReviewTaskResponse review,
|
||||
List<ToolResponse> tools,
|
||||
List<MetricResponse> metrics,
|
||||
List<TabResponse> tabs,
|
||||
TeamOverviewResponse team,
|
||||
List<TodoResponse> todos,
|
||||
SuggestionResponse suggestion
|
||||
) {
|
||||
}
|
||||
|
||||
public record PrimaryTaskResponse(
|
||||
String badge,
|
||||
String title,
|
||||
String desc,
|
||||
String progressText,
|
||||
Integer progress,
|
||||
String cta,
|
||||
String icon
|
||||
) {
|
||||
}
|
||||
|
||||
public record ReviewTaskResponse(
|
||||
String title,
|
||||
String desc
|
||||
) {
|
||||
}
|
||||
|
||||
public record ToolResponse(
|
||||
String icon,
|
||||
String title,
|
||||
String desc,
|
||||
String tone
|
||||
) {
|
||||
}
|
||||
|
||||
public record MetricResponse(
|
||||
String icon,
|
||||
String title,
|
||||
String value,
|
||||
String desc,
|
||||
String tone,
|
||||
Integer progress
|
||||
) {
|
||||
}
|
||||
|
||||
public record TabResponse(
|
||||
String label,
|
||||
String icon,
|
||||
Boolean active
|
||||
) {
|
||||
}
|
||||
|
||||
public record TeamOverviewResponse(
|
||||
String title,
|
||||
String progressText,
|
||||
Integer progress,
|
||||
String completionLabel,
|
||||
String detailCta,
|
||||
List<MiniStatResponse> stats
|
||||
) {
|
||||
}
|
||||
|
||||
public record MiniStatResponse(
|
||||
String icon,
|
||||
String title,
|
||||
String value,
|
||||
String tone
|
||||
) {
|
||||
}
|
||||
|
||||
public record TodoResponse(
|
||||
String icon,
|
||||
String title,
|
||||
String desc,
|
||||
Integer count,
|
||||
String tone
|
||||
) {
|
||||
}
|
||||
|
||||
public record SuggestionResponse(
|
||||
String icon,
|
||||
String title,
|
||||
String desc,
|
||||
String tone
|
||||
) {
|
||||
}
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
package org.dromara.aihr.service;
|
||||
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.CompetencyResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse;
|
||||
import org.dromara.aihr.domain.vo.AihrMobileDto.HomeResponse;
|
||||
import org.dromara.aihr.domain.vo.AihrMobileDto.MetricResponse;
|
||||
import org.dromara.aihr.domain.vo.AihrMobileDto.MiniStatResponse;
|
||||
import org.dromara.aihr.domain.vo.AihrMobileDto.PrimaryTaskResponse;
|
||||
import org.dromara.aihr.domain.vo.AihrMobileDto.ReviewTaskResponse;
|
||||
import org.dromara.aihr.domain.vo.AihrMobileDto.SuggestionResponse;
|
||||
import org.dromara.aihr.domain.vo.AihrMobileDto.TabResponse;
|
||||
import org.dromara.aihr.domain.vo.AihrMobileDto.TeamOverviewResponse;
|
||||
import org.dromara.aihr.domain.vo.AihrMobileDto.TodoResponse;
|
||||
import org.dromara.aihr.domain.vo.AihrMobileDto.ToolResponse;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Seed data for mobile MVP home pages.
|
||||
*/
|
||||
@Service
|
||||
public class AihrMobileSeedService {
|
||||
|
||||
private static final int TEAM_SIZE = 15;
|
||||
private final AihrPracticeSeedService practiceSeedService;
|
||||
|
||||
public AihrMobileSeedService(AihrPracticeSeedService practiceSeedService) {
|
||||
this.practiceSeedService = practiceSeedService;
|
||||
}
|
||||
|
||||
public HomeResponse home(String role) {
|
||||
return switch (normalize(role)) {
|
||||
case "candidate" -> candidateHome();
|
||||
case "supervisor" -> supervisorHome();
|
||||
default -> userHome();
|
||||
};
|
||||
}
|
||||
|
||||
public List<RecordResponse> practiceHistory(String extPartyId) {
|
||||
return practiceSeedService.mobileHistory(extPartyId, 5);
|
||||
}
|
||||
|
||||
public List<RecordResponse> practiceReviews() {
|
||||
return practiceSeedService.pendingReviewRecords(5);
|
||||
}
|
||||
|
||||
public ReviewDetailResponse practiceReview(Long id) {
|
||||
return practiceSeedService.reviewDetail(id);
|
||||
}
|
||||
|
||||
public boolean markReviewed(Long id) {
|
||||
return practiceSeedService.markReviewed(id);
|
||||
}
|
||||
|
||||
public CompetencyResponse profile(String extPartyId) {
|
||||
return practiceSeedService.competency(extPartyId);
|
||||
}
|
||||
|
||||
private static String normalize(String role) {
|
||||
if (role == null) {
|
||||
return "user";
|
||||
}
|
||||
return switch (role.toLowerCase()) {
|
||||
case "candidate" -> "candidate";
|
||||
case "supervisor", "manager", "admin" -> "supervisor";
|
||||
default -> "user";
|
||||
};
|
||||
}
|
||||
|
||||
private HomeResponse userHome() {
|
||||
return workerHome("user", "员工端", "avatar-woman", "早上好,王敏", "星河湾一期 · 客服管家");
|
||||
}
|
||||
|
||||
private static HomeResponse candidateHome() {
|
||||
return new HomeResponse(
|
||||
"candidate",
|
||||
"候选人端",
|
||||
"avatar-candidate",
|
||||
"早上好,李娜",
|
||||
"星河湾一期 · 客服管家候选人",
|
||||
"面试准备",
|
||||
"本周推荐:投诉接待基础",
|
||||
new PrimaryTaskResponse(
|
||||
"面试任务",
|
||||
"AI结构化面试",
|
||||
"8分钟问答 · 完成后生成面试记录",
|
||||
"0/1",
|
||||
22,
|
||||
"开始面试",
|
||||
"briefcase"
|
||||
),
|
||||
new ReviewTaskResponse(
|
||||
"岗位SOP预习",
|
||||
"今日需完成 · 客服接待基础流程"
|
||||
),
|
||||
List.of(
|
||||
new ToolResponse("mic", "面试练习", "模拟问答\n熟悉岗位", "teal"),
|
||||
new ToolResponse("book-search", "查岗位SOP", "快速了解\n标准流程", "blue"),
|
||||
new ToolResponse("folder", "案例学习", "典型场景\n提前预习", "orange"),
|
||||
new ToolResponse("upload", "补充资料", "提交证书\n与经历", "purple")
|
||||
),
|
||||
List.of(
|
||||
new MetricResponse("bars", "进度", "2/4", "候选任务", "teal", 50),
|
||||
new MetricResponse("target", "匹配度", "86", "岗位画像分", "blue", 86),
|
||||
new MetricResponse("clipboard", "待补充", "1", "资料项", "orange", 35)
|
||||
),
|
||||
List.of(
|
||||
new TabResponse("首页", "home", true),
|
||||
new TabResponse("面试", "briefcase", false),
|
||||
new TabResponse("知识", "book", false),
|
||||
new TabResponse("我的", "user", false)
|
||||
),
|
||||
null,
|
||||
List.of(),
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private HomeResponse workerHome(String role, String roleLabel, String avatarTone, String greeting, String location) {
|
||||
int completed = practiceSeedService.mobileCompletedCount();
|
||||
int pendingReview = 2 + practiceSeedService.mobilePendingReviewCount();
|
||||
return new HomeResponse(
|
||||
role,
|
||||
roleLabel,
|
||||
avatarTone,
|
||||
greeting,
|
||||
location,
|
||||
"学习与工具",
|
||||
"本周推荐:催费沟通专项",
|
||||
new PrimaryTaskResponse(
|
||||
"重点训练",
|
||||
"投诉接待三角色对练",
|
||||
"3分钟语音练习 · 完成后自动评分",
|
||||
"1/1",
|
||||
100,
|
||||
"开始训练",
|
||||
"headset"
|
||||
),
|
||||
new ReviewTaskResponse(
|
||||
"岗前SOP复习",
|
||||
"今日需完成 · 住宅投诉处理流程"
|
||||
),
|
||||
List.of(
|
||||
new ToolResponse("mic", "每日一练", "每日3分钟\n提升服务能力", "teal"),
|
||||
new ToolResponse("book-search", "查SOP", "随时查找\n标准流程", "blue"),
|
||||
new ToolResponse("folder", "案例学习", "优秀案例\n对标提升", "orange"),
|
||||
new ToolResponse("pen", "提交素材", "记录工作场景\n供审核入库", "purple")
|
||||
),
|
||||
List.of(
|
||||
new MetricResponse("bars", "完成", (6 + completed) + "/10", "今日任务进度", "teal", Math.min(100, 60 + completed * 10)),
|
||||
new MetricResponse("target", "能力", String.valueOf(Math.min(95, 82 + completed * 2)), "综合能力分", "blue", Math.min(95, 82 + completed * 2)),
|
||||
new MetricResponse("clipboard", "待复盘", String.valueOf(pendingReview), "待复盘任务", "orange", Math.min(100, 48 + completed * 10))
|
||||
),
|
||||
List.of(
|
||||
new TabResponse("首页", "home", true),
|
||||
new TabResponse("训练", "graduation", false),
|
||||
new TabResponse("知识", "book", false),
|
||||
new TabResponse("我的", "user", false)
|
||||
),
|
||||
null,
|
||||
List.of(),
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private HomeResponse supervisorHome() {
|
||||
int mobileCompleted = practiceSeedService.mobileCompletedCount();
|
||||
int completed = Math.min(TEAM_SIZE, 12 + mobileCompleted);
|
||||
int pending = Math.max(0, TEAM_SIZE - completed);
|
||||
int pendingReview = 3 + practiceSeedService.mobilePendingReviewCount();
|
||||
int completionRate = Math.round(completed * 100F / TEAM_SIZE);
|
||||
return new HomeResponse(
|
||||
"supervisor",
|
||||
"主管端",
|
||||
"avatar-man",
|
||||
"早上好,李主管",
|
||||
"星河湾一期 · 项目主管",
|
||||
"快捷操作",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
List.of(
|
||||
new ToolResponse("presentation", "指派训练", "为员工分配训练任务", "teal"),
|
||||
new ToolResponse("target", "团队短板", "查看短板分布\n与改进建议", "blue"),
|
||||
new ToolResponse("upload", "提交案例素材", "上传案例素材\n供审核与沉淀", "orange"),
|
||||
new ToolResponse("book-search", "查SOP", "快速搜索制度与\n标准流程", "purple")
|
||||
),
|
||||
List.of(),
|
||||
List.of(
|
||||
new TabResponse("首页", "home", true),
|
||||
new TabResponse("团队", "users", false),
|
||||
new TabResponse("案例", "folder", false),
|
||||
new TabResponse("我的", "user", false)
|
||||
),
|
||||
new TeamOverviewResponse(
|
||||
"客服团队训练进度",
|
||||
completed + "人已完成 · " + pending + "人待跟进",
|
||||
completionRate,
|
||||
"完训率",
|
||||
"查看详情",
|
||||
List.of(
|
||||
new MiniStatResponse("users", "完训率", completionRate + "%", "teal"),
|
||||
new MiniStatResponse("bars", "平均能力", String.valueOf(Math.min(90, 78 + mobileCompleted)), "teal"),
|
||||
new MiniStatResponse("warning", "高频短板", "3", "orange")
|
||||
)
|
||||
),
|
||||
List.of(
|
||||
new TodoResponse("presentation", "待复盘对练", "查看对练记录,辅导与点评", pendingReview, "teal"),
|
||||
new TodoResponse("folder", "待提交本周案例", "提交优质案例素材,供审核入库", 1, "orange"),
|
||||
new TodoResponse("clipboard", "待指派专项", "为低分或薄弱项员工指派训练专项", 2, "blue")
|
||||
),
|
||||
new SuggestionResponse(
|
||||
"bulb",
|
||||
"建议:给催费沟通低分员工指派专项",
|
||||
"基于团队短板分析,建议开展「催费沟通技巧」专项训练",
|
||||
"amber"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -295,7 +295,7 @@ public class AihrModelSeedService {
|
||||
ArrayNode messages = body.putArray("messages");
|
||||
ObjectNode system = messages.addObject();
|
||||
system.put("role", "system");
|
||||
system.put("content", isBlank(systemPrompt) ? "你是物业 AI 人力资源系统助手,回答要适合 Demo 演示,简洁、可执行。" : systemPrompt);
|
||||
system.put("content", isBlank(systemPrompt) ? "你是物业 AI 人力资源系统助手,回答要适合 MVP 演示,简洁、可执行。" : systemPrompt);
|
||||
|
||||
ObjectNode user = messages.addObject();
|
||||
user.put("role", "user");
|
||||
|
||||
+448
-3
@@ -1,19 +1,34 @@
|
||||
package org.dromara.aihr.service;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.CompetencyResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.DialogueResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.DimensionResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.FinishRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.FinishResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PersonaResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.StartRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.StartResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.TurnRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.TurnResponse;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
|
||||
/**
|
||||
* 三角色对练 seed 编排器。P0 先稳定前后端契约,后续再替换为会话表、ASR 和 LLM 评分。
|
||||
@@ -21,17 +36,31 @@ import java.util.Map;
|
||||
@Service
|
||||
public class AihrPracticeSeedService {
|
||||
|
||||
private static final String TENANT_ID = "000000";
|
||||
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("MM-dd HH:mm");
|
||||
|
||||
private final Map<String, ScenarioSeed> scenarios = buildScenarios();
|
||||
private final ConcurrentMap<String, ActiveSession> activeSessions = new ConcurrentHashMap<>();
|
||||
private final ObjectMapper objectMapper;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private volatile boolean practiceTableReady;
|
||||
|
||||
public AihrPracticeSeedService(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
public StartResponse start(StartRequest request) {
|
||||
ScenarioSeed scenario = resolveScenario(request == null ? null : request.scenarioId(), null);
|
||||
RoundSeed firstRound = scenario.rounds().get(0);
|
||||
String sessionId = "seed-" + scenario.id() + "-" + System.currentTimeMillis();
|
||||
String trainee = resolveTrainee(request, scenario);
|
||||
activeSessions.put(sessionId, new ActiveSession(scenario.id(), trainee, resolveExtPartyId(request, trainee), isMobile(request), LocalDateTime.now(), new ArrayList<>()));
|
||||
return new StartResponse(
|
||||
sessionId,
|
||||
scenario.id(),
|
||||
scenario.name(),
|
||||
scenario.trainee(),
|
||||
trainee,
|
||||
firstRound.customer(),
|
||||
firstRound.emotion(),
|
||||
scenario.trustStart(),
|
||||
@@ -43,6 +72,7 @@ public class AihrPracticeSeedService {
|
||||
public TurnResponse turn(TurnRequest request) {
|
||||
ScenarioSeed scenario = resolveScenario(null, request == null ? null : request.sessionId());
|
||||
int roundIndex = normalizeRoundIndex(request == null ? null : request.roundIndex(), scenario.rounds().size());
|
||||
rememberTraineeReply(request, roundIndex);
|
||||
RoundSeed currentRound = scenario.rounds().get(roundIndex);
|
||||
int nextRoundIndex = roundIndex + 1;
|
||||
boolean finished = nextRoundIndex >= scenario.rounds().size();
|
||||
@@ -60,17 +90,426 @@ public class AihrPracticeSeedService {
|
||||
}
|
||||
|
||||
public FinishResponse finish(FinishRequest request) {
|
||||
ScenarioSeed scenario = resolveScenario(null, request == null ? null : request.sessionId());
|
||||
String sessionId = request == null ? null : request.sessionId();
|
||||
ActiveSession activeSession = sessionId == null ? null : activeSessions.remove(sessionId);
|
||||
ScenarioSeed scenario = resolveScenario(activeSession == null ? null : activeSession.scenarioId(), sessionId);
|
||||
String trainee = activeSession == null ? scenario.trainee() : activeSession.trainee();
|
||||
RecordResponse record = new RecordResponse(null, sessionId, formatNow(), trainee, scenario.name(), scenario.total(), "待复盘", scenario.summary());
|
||||
savePracticeRecord(sessionId, activeSession, scenario, record);
|
||||
return new FinishResponse(
|
||||
scenario.total(),
|
||||
scenario.scores(),
|
||||
scenario.rewrite(),
|
||||
scenario.summary(),
|
||||
List.of(scenario.trustStart(), scenario.rounds().get(0).trust(), scenario.trustEnd()),
|
||||
List.of(new RecordResponse("07-02 10:42", scenario.trainee(), scenario.name(), scenario.total(), "已完成", scenario.summary()))
|
||||
List.of(record)
|
||||
);
|
||||
}
|
||||
|
||||
public int mobileCompletedCount() {
|
||||
return countMobileRecords("");
|
||||
}
|
||||
|
||||
public int mobilePendingReviewCount() {
|
||||
return countPendingReview("");
|
||||
}
|
||||
|
||||
public List<RecordResponse> mobileHistory(String extPartyId, int limit) {
|
||||
ensurePracticeTable();
|
||||
int safeLimit = normalizeLimit(limit);
|
||||
if (isBlank(extPartyId)) {
|
||||
return jdbcTemplate.query("""
|
||||
SELECT id, session_id, finished_time, trainee_name, scenario_name, total_score, status, summary
|
||||
FROM aihr_practice_session
|
||||
WHERE tenant_id = ? AND mode = 'mobile'
|
||||
ORDER BY finished_time DESC, id DESC
|
||||
LIMIT ?
|
||||
""", this::mapRecord, TENANT_ID, safeLimit);
|
||||
}
|
||||
return jdbcTemplate.query("""
|
||||
SELECT id, session_id, finished_time, trainee_name, scenario_name, total_score, status, summary
|
||||
FROM aihr_practice_session
|
||||
WHERE tenant_id = ? AND mode = 'mobile' AND ext_party_id = ?
|
||||
ORDER BY finished_time DESC, id DESC
|
||||
LIMIT ?
|
||||
""", this::mapRecord, TENANT_ID, extPartyId.trim(), safeLimit);
|
||||
}
|
||||
|
||||
public List<RecordResponse> pendingReviewRecords(int limit) {
|
||||
ensurePracticeTable();
|
||||
return jdbcTemplate.query("""
|
||||
SELECT id, session_id, finished_time, trainee_name, scenario_name, total_score, status, summary
|
||||
FROM aihr_practice_session
|
||||
WHERE tenant_id = ? AND mode = 'mobile' AND status = '待复盘'
|
||||
ORDER BY finished_time DESC, id DESC
|
||||
LIMIT ?
|
||||
""", this::mapRecord, TENANT_ID, normalizeLimit(limit));
|
||||
}
|
||||
|
||||
public ReviewDetailResponse reviewDetail(Long id) {
|
||||
ensurePracticeTable();
|
||||
if (id == null) {
|
||||
return null;
|
||||
}
|
||||
List<ReviewDetailResponse> rows = jdbcTemplate.query("""
|
||||
SELECT id, session_id, finished_time, trainee_name, scenario_id, scenario_name, total_score, status,
|
||||
summary, mentor_rewrite, ai_comment, dim_compliance, dim_emotion, dim_communication, dim_marketing, dialogue_json
|
||||
FROM aihr_practice_session
|
||||
WHERE tenant_id = ? AND mode = 'mobile' AND id = ?
|
||||
""", this::mapReviewDetail, TENANT_ID, id);
|
||||
return rows.isEmpty() ? null : rows.get(0);
|
||||
}
|
||||
|
||||
public boolean markReviewed(Long id) {
|
||||
ensurePracticeTable();
|
||||
if (id == null) {
|
||||
return false;
|
||||
}
|
||||
return jdbcTemplate.update("""
|
||||
UPDATE aihr_practice_session
|
||||
SET status = '已复盘', update_time = ?
|
||||
WHERE tenant_id = ? AND mode = 'mobile' AND id = ?
|
||||
""", Timestamp.valueOf(LocalDateTime.now()), TENANT_ID, id) > 0;
|
||||
}
|
||||
|
||||
public CompetencyResponse competency(String extPartyId) {
|
||||
ensurePracticeTable();
|
||||
String party = isBlank(extPartyId) ? "" : extPartyId.trim();
|
||||
List<CompetencySnapshot> rows = jdbcTemplate.query("""
|
||||
SELECT
|
||||
COUNT(*) completed,
|
||||
COALESCE(ROUND(AVG(total_score)), 82) score,
|
||||
SUM(CASE WHEN status = '待复盘' THEN 1 ELSE 0 END) pending_review,
|
||||
COALESCE(ROUND(AVG(dim_compliance)), 82) compliance,
|
||||
COALESCE(ROUND(AVG(dim_communication)), 82) communication,
|
||||
COALESCE(ROUND(AVG(dim_emotion)), 82) emotion
|
||||
FROM aihr_practice_session
|
||||
WHERE tenant_id = ? AND mode = 'mobile' AND (? = '' OR ext_party_id = ?)
|
||||
""", (rs, rowNum) -> new CompetencySnapshot(
|
||||
rs.getInt("completed"),
|
||||
rs.getInt("score"),
|
||||
rs.getInt("pending_review"),
|
||||
rs.getInt("compliance"),
|
||||
rs.getInt("communication"),
|
||||
rs.getInt("emotion")
|
||||
), TENANT_ID, party, party);
|
||||
CompetencySnapshot snapshot = rows.isEmpty() ? new CompetencySnapshot(0, 82, 0, 82, 82, 82) : rows.get(0);
|
||||
return new CompetencyResponse(
|
||||
snapshot.score(),
|
||||
snapshot.completed(),
|
||||
snapshot.pendingReview(),
|
||||
List.of(
|
||||
new DimensionResponse("合规", snapshot.compliance(), "SOP关键点覆盖"),
|
||||
new DimensionResponse("沟通", snapshot.communication(), "承诺与表达清晰度"),
|
||||
new DimensionResponse("情绪", snapshot.emotion(), "安抚与承接能力")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private boolean isMobile(StartRequest request) {
|
||||
return request != null && "mobile".equalsIgnoreCase(request.mode());
|
||||
}
|
||||
|
||||
private String resolveExtPartyId(StartRequest request, String trainee) {
|
||||
if (request == null || isBlank(request.extPartyId())) {
|
||||
return trainee;
|
||||
}
|
||||
return request.extPartyId().trim();
|
||||
}
|
||||
|
||||
private String resolveTrainee(StartRequest request, ScenarioSeed scenario) {
|
||||
if (!isMobile(request) || request.extPartyId() == null || request.extPartyId().isBlank()) {
|
||||
return scenario.trainee();
|
||||
}
|
||||
String extPartyId = request.extPartyId().trim();
|
||||
if (extPartyId.matches("\\d{11}")) {
|
||||
return "手机用户" + extPartyId.substring(7);
|
||||
}
|
||||
return extPartyId;
|
||||
}
|
||||
|
||||
private String formatNow() {
|
||||
return LocalDateTime.now().format(TIME_FORMATTER);
|
||||
}
|
||||
|
||||
private void savePracticeRecord(String sessionId, ActiveSession activeSession, ScenarioSeed scenario, RecordResponse record) {
|
||||
ensurePracticeTable();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime started = activeSession == null ? now : activeSession.startedAt();
|
||||
String id = isBlank(sessionId) ? "seed-" + scenario.id() + "-" + System.currentTimeMillis() : sessionId;
|
||||
boolean mobile = activeSession != null && activeSession.mobile();
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO aihr_practice_session
|
||||
(tenant_id, session_id, ext_party_id, trainee_name, scenario_id, scenario_name, mode,
|
||||
total_score, dim_compliance, dim_emotion, dim_communication, dim_marketing,
|
||||
mentor_rewrite, ai_comment, summary, dialogue_json, trust_curve, status, started_time, finished_time, create_time, update_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
total_score = VALUES(total_score),
|
||||
dim_compliance = VALUES(dim_compliance),
|
||||
dim_emotion = VALUES(dim_emotion),
|
||||
dim_communication = VALUES(dim_communication),
|
||||
dim_marketing = VALUES(dim_marketing),
|
||||
mentor_rewrite = VALUES(mentor_rewrite),
|
||||
ai_comment = VALUES(ai_comment),
|
||||
summary = VALUES(summary),
|
||||
dialogue_json = VALUES(dialogue_json),
|
||||
trust_curve = VALUES(trust_curve),
|
||||
status = VALUES(status),
|
||||
finished_time = VALUES(finished_time),
|
||||
update_time = VALUES(update_time)
|
||||
""",
|
||||
TENANT_ID,
|
||||
id,
|
||||
activeSession == null ? record.trainee() : activeSession.extPartyId(),
|
||||
record.trainee(),
|
||||
scenario.id(),
|
||||
scenario.name(),
|
||||
mobile ? "mobile" : "text",
|
||||
scenario.total(),
|
||||
scoreValue(scenario, "合规"),
|
||||
scoreValue(scenario, "情绪"),
|
||||
scoreValue(scenario, "沟通"),
|
||||
scoreValue(scenario, "营销"),
|
||||
scenario.rewrite(),
|
||||
scenario.summary(),
|
||||
record.summary(),
|
||||
dialogueJson(activeSession, scenario),
|
||||
scenario.trustStart() + "," + scenario.rounds().get(0).trust() + "," + scenario.trustEnd(),
|
||||
record.status(),
|
||||
Timestamp.valueOf(started),
|
||||
Timestamp.valueOf(now),
|
||||
Timestamp.valueOf(now),
|
||||
Timestamp.valueOf(now)
|
||||
);
|
||||
}
|
||||
|
||||
private int countMobileRecords(String extPartyId) {
|
||||
ensurePracticeTable();
|
||||
if (isBlank(extPartyId)) {
|
||||
return count("""
|
||||
SELECT COUNT(*)
|
||||
FROM aihr_practice_session
|
||||
WHERE tenant_id = ? AND mode = 'mobile'
|
||||
""", TENANT_ID);
|
||||
}
|
||||
return count("""
|
||||
SELECT COUNT(*)
|
||||
FROM aihr_practice_session
|
||||
WHERE tenant_id = ? AND mode = 'mobile' AND ext_party_id = ?
|
||||
""", TENANT_ID, extPartyId.trim());
|
||||
}
|
||||
|
||||
private int countPendingReview(String extPartyId) {
|
||||
ensurePracticeTable();
|
||||
if (isBlank(extPartyId)) {
|
||||
return count("""
|
||||
SELECT COUNT(*)
|
||||
FROM aihr_practice_session
|
||||
WHERE tenant_id = ? AND mode = 'mobile' AND status = '待复盘'
|
||||
""", TENANT_ID);
|
||||
}
|
||||
return count("""
|
||||
SELECT COUNT(*)
|
||||
FROM aihr_practice_session
|
||||
WHERE tenant_id = ? AND mode = 'mobile' AND status = '待复盘' AND ext_party_id = ?
|
||||
""", TENANT_ID, extPartyId.trim());
|
||||
}
|
||||
|
||||
private int count(String sql, Object... args) {
|
||||
Integer value = jdbcTemplate.queryForObject(sql, Integer.class, args);
|
||||
return value == null ? 0 : value;
|
||||
}
|
||||
|
||||
private RecordResponse mapRecord(ResultSet rs, int rowNum) throws SQLException {
|
||||
Timestamp finishedTime = rs.getTimestamp("finished_time");
|
||||
String time = finishedTime == null ? "" : finishedTime.toLocalDateTime().format(TIME_FORMATTER);
|
||||
return new RecordResponse(
|
||||
rs.getLong("id"),
|
||||
rs.getString("session_id"),
|
||||
time,
|
||||
rs.getString("trainee_name"),
|
||||
rs.getString("scenario_name"),
|
||||
rs.getInt("total_score"),
|
||||
rs.getString("status"),
|
||||
rs.getString("summary")
|
||||
);
|
||||
}
|
||||
|
||||
private ReviewDetailResponse mapReviewDetail(ResultSet rs, int rowNum) throws SQLException {
|
||||
Timestamp finishedTime = rs.getTimestamp("finished_time");
|
||||
String time = finishedTime == null ? "" : finishedTime.toLocalDateTime().format(TIME_FORMATTER);
|
||||
ScenarioSeed scenario = resolveScenario(rs.getString("scenario_id"), rs.getString("session_id"));
|
||||
return new ReviewDetailResponse(
|
||||
rs.getLong("id"),
|
||||
rs.getString("session_id"),
|
||||
time,
|
||||
rs.getString("trainee_name"),
|
||||
rs.getString("scenario_name"),
|
||||
rs.getInt("total_score"),
|
||||
rs.getString("status"),
|
||||
rs.getString("summary"),
|
||||
rs.getString("mentor_rewrite"),
|
||||
rs.getString("ai_comment"),
|
||||
scoreItems(rs),
|
||||
readDialogue(rs.getString("dialogue_json"), scenario)
|
||||
);
|
||||
}
|
||||
|
||||
private List<DimensionResponse> scoreItems(ResultSet rs) throws SQLException {
|
||||
List<DimensionResponse> items = new ArrayList<>();
|
||||
addScoreItem(items, "合规", rs.getObject("dim_compliance"), "SOP关键点覆盖");
|
||||
addScoreItem(items, "沟通", rs.getObject("dim_communication"), "承诺与表达清晰度");
|
||||
addScoreItem(items, "情绪", rs.getObject("dim_emotion"), "安抚与承接能力");
|
||||
addScoreItem(items, "营销", rs.getObject("dim_marketing"), "增值转化意识");
|
||||
return items;
|
||||
}
|
||||
|
||||
private void addScoreItem(List<DimensionResponse> items, String label, Object value, String note) {
|
||||
if (value instanceof Number number) {
|
||||
items.add(new DimensionResponse(label, number.intValue(), note));
|
||||
}
|
||||
}
|
||||
|
||||
private void rememberTraineeReply(TurnRequest request, int roundIndex) {
|
||||
if (request == null || isBlank(request.sessionId()) || isBlank(request.traineeText())) {
|
||||
return;
|
||||
}
|
||||
ActiveSession session = activeSessions.get(request.sessionId());
|
||||
if (session == null) {
|
||||
return;
|
||||
}
|
||||
List<String> replies = session.traineeReplies();
|
||||
synchronized (replies) {
|
||||
while (replies.size() <= roundIndex) {
|
||||
replies.add("");
|
||||
}
|
||||
replies.set(roundIndex, request.traineeText().trim());
|
||||
}
|
||||
}
|
||||
|
||||
private String dialogueJson(ActiveSession activeSession, ScenarioSeed scenario) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(dialogueTurns(activeSession, scenario));
|
||||
} catch (JsonProcessingException ignored) {
|
||||
return "[]";
|
||||
}
|
||||
}
|
||||
|
||||
private List<DialogueResponse> readDialogue(String json, ScenarioSeed scenario) {
|
||||
if (!isBlank(json)) {
|
||||
try {
|
||||
List<DialogueResponse> result = new ArrayList<>();
|
||||
JsonNode root = objectMapper.readTree(json);
|
||||
if (root.isArray()) {
|
||||
for (JsonNode item : root) {
|
||||
result.add(new DialogueResponse(
|
||||
item.path("role").asText(),
|
||||
item.path("label").asText(),
|
||||
item.path("text").asText()
|
||||
));
|
||||
}
|
||||
if (!result.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
} catch (JsonProcessingException ignored) {
|
||||
// Fall through to seed dialogue.
|
||||
}
|
||||
}
|
||||
return dialogueTurns(null, scenario);
|
||||
}
|
||||
|
||||
private List<DialogueResponse> dialogueTurns(ActiveSession activeSession, ScenarioSeed scenario) {
|
||||
List<DialogueResponse> turns = new ArrayList<>();
|
||||
List<String> replies = activeSession == null ? List.of() : activeSession.traineeReplies();
|
||||
for (int i = 0; i < scenario.rounds().size(); i++) {
|
||||
RoundSeed round = scenario.rounds().get(i);
|
||||
turns.add(new DialogueResponse("customer", "AI业主", round.customer()));
|
||||
if (i < replies.size() && !isBlank(replies.get(i))) {
|
||||
turns.add(new DialogueResponse("trainee", "员工话术", replies.get(i)));
|
||||
}
|
||||
turns.add(new DialogueResponse("coach", "AI教练", round.coachHint()));
|
||||
}
|
||||
return turns;
|
||||
}
|
||||
|
||||
private Integer scoreValue(ScenarioSeed scenario, String label) {
|
||||
for (DimensionResponse score : scenario.scores()) {
|
||||
if (label.equals(score.label()) && score.value() instanceof Number) {
|
||||
return ((Number) score.value()).intValue();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private int normalizeLimit(int limit) {
|
||||
if (limit <= 0) {
|
||||
return 5;
|
||||
}
|
||||
return Math.min(limit, 20);
|
||||
}
|
||||
|
||||
private void ensurePracticeTable() {
|
||||
if (practiceTableReady) {
|
||||
return;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (practiceTableReady) {
|
||||
return;
|
||||
}
|
||||
// ponytail: create-if-missing keeps current dev DB usable without forcing reset; replace with migration when schema tooling exists.
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE IF NOT EXISTS `aihr_practice_session` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
|
||||
`session_id` varchar(100) NOT NULL COMMENT '训练会话ID',
|
||||
`ext_party_id` varchar(100) DEFAULT NULL COMMENT '外部主体或手机号',
|
||||
`trainee_name` varchar(100) DEFAULT NULL COMMENT '学员名称',
|
||||
`scenario_id` varchar(80) DEFAULT NULL COMMENT '场景ID',
|
||||
`scenario_name` varchar(100) DEFAULT NULL COMMENT '场景名称',
|
||||
`mode` varchar(30) DEFAULT 'text' COMMENT '训练模式',
|
||||
`total_score` int DEFAULT NULL COMMENT '总分',
|
||||
`dim_compliance` int DEFAULT NULL COMMENT '合规分',
|
||||
`dim_emotion` int DEFAULT NULL COMMENT '情绪分',
|
||||
`dim_communication` int DEFAULT NULL COMMENT '沟通分',
|
||||
`dim_marketing` int DEFAULT NULL COMMENT '营销分',
|
||||
`mentor_rewrite` varchar(1000) DEFAULT NULL COMMENT '导师改写',
|
||||
`ai_comment` varchar(1000) DEFAULT NULL COMMENT 'AI点评',
|
||||
`summary` varchar(1000) DEFAULT NULL COMMENT '摘要',
|
||||
`dialogue_json` text DEFAULT NULL COMMENT '对练话术JSON',
|
||||
`trust_curve` varchar(100) DEFAULT NULL COMMENT '信任曲线',
|
||||
`status` varchar(30) DEFAULT '待复盘' COMMENT '状态',
|
||||
`started_time` datetime DEFAULT NULL COMMENT '开始时间',
|
||||
`finished_time` datetime DEFAULT NULL COMMENT '完成时间',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_aihr_practice_session` (`tenant_id`, `session_id`),
|
||||
KEY `idx_aihr_practice_party` (`tenant_id`, `ext_party_id`, `finished_time`),
|
||||
KEY `idx_aihr_practice_review` (`tenant_id`, `mode`, `status`, `finished_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练记录';
|
||||
""");
|
||||
ensurePracticeColumn("dialogue_json", "ALTER TABLE aihr_practice_session ADD COLUMN `dialogue_json` text DEFAULT NULL COMMENT '对练话术JSON' AFTER `summary`");
|
||||
practiceTableReady = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void ensurePracticeColumn(String column, String ddl) {
|
||||
Integer exists = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'aihr_practice_session' AND COLUMN_NAME = ?
|
||||
""", Integer.class, column);
|
||||
if (exists == null || exists == 0) {
|
||||
jdbcTemplate.execute(ddl);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
private ScenarioSeed resolveScenario(String scenarioId, String sessionId) {
|
||||
if (scenarioId != null && scenarios.containsKey(scenarioId)) {
|
||||
return scenarios.get(scenarioId);
|
||||
@@ -185,4 +624,10 @@ public class AihrPracticeSeedService {
|
||||
|
||||
private record RoundSeed(String customer, String coachHint, Integer emotion, Integer trust) {
|
||||
}
|
||||
|
||||
private record CompetencySnapshot(Integer completed, Integer score, Integer pendingReview, Integer compliance, Integer communication, Integer emotion) {
|
||||
}
|
||||
|
||||
private record ActiveSession(String scenarioId, String trainee, String extPartyId, boolean mobile, LocalDateTime startedAt, List<String> traineeReplies) {
|
||||
}
|
||||
}
|
||||
|
||||
+450
-52
@@ -21,6 +21,8 @@ import org.dromara.aihr.domain.AihrSopDto.SearchRequest;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SearchResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SnippetResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.UploadResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.VectorIndexStatusResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.VectorizeResponse;
|
||||
import org.apache.tika.metadata.Metadata;
|
||||
import org.apache.tika.metadata.TikaCoreProperties;
|
||||
import org.apache.tika.parser.AutoDetectParser;
|
||||
@@ -54,6 +56,7 @@ import java.sql.Timestamp;
|
||||
import java.time.Duration;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Comparator;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashSet;
|
||||
@@ -76,6 +79,9 @@ public class AihrSopSeedService {
|
||||
|
||||
private static final String TENANT_ID = "000000";
|
||||
private static final String AUTO_CATEGORY = "__auto__";
|
||||
private static final String LOCAL_EMBEDDING_MODEL = "local-hash-v1";
|
||||
private static final String LOCAL_EMBEDDING_BASE_URL = "local";
|
||||
private static final int LOCAL_EMBEDDING_DIMENSION = 1536;
|
||||
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
|
||||
|
||||
private final Map<String, SopSeed> seeds = buildSeeds();
|
||||
@@ -94,9 +100,11 @@ public class AihrSopSeedService {
|
||||
}
|
||||
|
||||
public SearchResponse search(SearchRequest request) {
|
||||
SopSeed seed = seeds.getOrDefault(request == null ? null : request.category(), seeds.get("投诉处理 SOP"));
|
||||
String requestedCategory = request == null ? "" : request.category();
|
||||
SopSeed seed = seeds.getOrDefault(requestedCategory, seeds.get("投诉处理 SOP"));
|
||||
String category = isBlank(requestedCategory) ? seed.category() : requestedCategory;
|
||||
String queryText = request == null || request.queryText() == null || request.queryText().isBlank() ? seed.question() : request.queryText();
|
||||
SearchResponse dbResponse = dbSearch(seed.category(), queryText, request == null ? null : request.limit());
|
||||
SearchResponse dbResponse = dbSearch(category, queryText, request == null ? null : request.limit());
|
||||
if (dbResponse != null && !dbResponse.snippets().isEmpty()) {
|
||||
return dbResponse;
|
||||
}
|
||||
@@ -127,6 +135,83 @@ public class AihrSopSeedService {
|
||||
return saveDocument(fileName, category, () -> ossService.upload(file), () -> readContent(file, fileName), () -> fileFingerprint(file));
|
||||
}
|
||||
|
||||
public VectorizeResponse vectorizeMissing() {
|
||||
List<VectorizeTarget> targets = jdbcTemplate.query("""
|
||||
select f.knowledge_id,
|
||||
i.name as category,
|
||||
f.doc_id,
|
||||
count(*) as fragments
|
||||
from aihr_knowledge_fragment f
|
||||
join aihr_knowledge_info i on i.id = f.knowledge_id and i.tenant_id = f.tenant_id
|
||||
where f.tenant_id = ?
|
||||
and (f.embedding_json is null or f.embedding_json = '')
|
||||
group by f.knowledge_id, i.name, f.doc_id
|
||||
order by max(f.update_time) desc
|
||||
limit 200
|
||||
""", (rs, rowNum) -> new VectorizeTarget(
|
||||
rs.getLong("knowledge_id"),
|
||||
rs.getString("category"),
|
||||
rs.getString("doc_id")
|
||||
), TENANT_ID);
|
||||
|
||||
int documents = 0;
|
||||
int fragments = 0;
|
||||
String model = "";
|
||||
for (VectorizeTarget target : targets) {
|
||||
List<String> rows = jdbcTemplate.queryForList("""
|
||||
select content
|
||||
from aihr_knowledge_fragment
|
||||
where tenant_id = ? and knowledge_id = ? and doc_id = ?
|
||||
order by idx asc
|
||||
""", String.class, TENANT_ID, target.knowledgeId(), target.docId());
|
||||
EmbeddingBatch batch = embedFragments(target.knowledgeId(), target.category(), target.docId(), rows);
|
||||
if (batch.count() > 0) {
|
||||
documents++;
|
||||
fragments += batch.count();
|
||||
model = batch.modelName();
|
||||
}
|
||||
}
|
||||
return new VectorizeResponse(documents, fragments, firstNonBlank(model, LOCAL_EMBEDDING_MODEL));
|
||||
}
|
||||
|
||||
public VectorIndexStatusResponse vectorIndexStatus() {
|
||||
EmbeddingRuntime runtime = embeddingRuntimes().get(0);
|
||||
VectorDbStats db = vectorDbStats();
|
||||
QdrantStats qdrant = qdrantStats();
|
||||
int expectedDimension = runtime.dimension();
|
||||
boolean matched = expectedDimension > 0
|
||||
&& qdrant.dimension() != null
|
||||
&& qdrant.dimension() == expectedDimension
|
||||
&& (db.embeddedFragments() == 0 || Objects.equals(db.embeddingDimension(), expectedDimension))
|
||||
&& (qdrant.points() == null || qdrant.points() == db.embeddedFragments());
|
||||
String message = matched
|
||||
? "向量库与当前模型一致"
|
||||
: firstNonBlank(qdrant.message(), "向量库与当前模型不一致,建议重建");
|
||||
return new VectorIndexStatusResponse(
|
||||
runtime.modelName(),
|
||||
expectedDimension,
|
||||
qdrantCollection(),
|
||||
qdrant.dimension(),
|
||||
qdrant.points(),
|
||||
db.fragments(),
|
||||
db.embeddedFragments(),
|
||||
db.embeddingModel(),
|
||||
db.embeddingDimension(),
|
||||
matched,
|
||||
message
|
||||
);
|
||||
}
|
||||
|
||||
public VectorizeResponse rebuildVectorIndex() {
|
||||
jdbcTemplate.update("""
|
||||
update aihr_knowledge_fragment
|
||||
set embedding_json = null, embedding_model = null, embedding_time = null, update_time = now()
|
||||
where tenant_id = ?
|
||||
""", TENANT_ID);
|
||||
deleteQdrantCollection();
|
||||
return vectorizeMissing();
|
||||
}
|
||||
|
||||
public LocalImportResponse importLocalDocs(LocalImportRequest request) {
|
||||
LocalImportPlan plan = localImportPlan(request);
|
||||
LocalImportRun run = executeLocalImport(plan, null);
|
||||
@@ -525,6 +610,79 @@ public class AihrSopSeedService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 优先查 category='vision',无则回退 category='chat'(大多数多模态模型通用)。 */
|
||||
private Optional<ChatRuntime> visionRuntime() {
|
||||
try {
|
||||
List<ChatRuntime> rows = jdbcTemplate.query("""
|
||||
select c.model_name,
|
||||
coalesce(nullif(c.api_host, ''), nullif(p.api_host, '')) as resolved_api_host,
|
||||
coalesce(nullif(c.api_key, ''), nullif(p.api_key, '')) as resolved_api_key
|
||||
from aihr_model_config c
|
||||
left join aihr_model_provider p
|
||||
on p.tenant_id = c.tenant_id and p.provider_code = c.provider_code
|
||||
where c.tenant_id = ?
|
||||
and c.category in ('vision', 'chat')
|
||||
and c.enabled = 1
|
||||
and (p.status is null or p.status = '0')
|
||||
order by case c.category when 'vision' then 0 else 1 end,
|
||||
case when c.model_show = 'Y' then 0 else 1 end, c.id asc
|
||||
limit 1
|
||||
""", (rs, rowNum) -> new ChatRuntime(
|
||||
rs.getString("model_name"),
|
||||
rs.getString("resolved_api_host"),
|
||||
rs.getString("resolved_api_key")
|
||||
), TENANT_ID);
|
||||
return rows.stream()
|
||||
.filter(runtime -> !isBlank(runtime.modelName()) && !isBlank(runtime.baseUrl()))
|
||||
.findFirst();
|
||||
} catch (DataAccessException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private String callVisionOcr(ChatRuntime runtime, byte[] imageBytes, String mimeType) throws Exception {
|
||||
String dataUrl = "data:" + mimeType + ";base64," + Base64.getEncoder().encodeToString(imageBytes);
|
||||
|
||||
ObjectNode body = objectMapper.createObjectNode();
|
||||
body.put("model", runtime.modelName());
|
||||
body.put("temperature", 0);
|
||||
body.put("max_tokens", 4096);
|
||||
|
||||
ArrayNode messages = body.putArray("messages");
|
||||
ObjectNode user = messages.addObject();
|
||||
user.put("role", "user");
|
||||
ArrayNode content = user.putArray("content");
|
||||
|
||||
ObjectNode textPart = content.addObject();
|
||||
textPart.put("type", "text");
|
||||
textPart.put("text", "请识别图片中的所有文字内容,保持原有格式和段落结构,直接输出识别到的文字,不要添加任何解释说明。如果图片不含可识别文字,返回空字符串。");
|
||||
|
||||
ObjectNode imagePart = content.addObject();
|
||||
imagePart.put("type", "image_url");
|
||||
imagePart.putObject("image_url").put("url", dataUrl).put("detail", "high");
|
||||
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder()
|
||||
.uri(URI.create(normalizeBaseUrl(runtime.baseUrl()) + "/chat/completions"))
|
||||
.timeout(Duration.ofSeconds(120))
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)));
|
||||
if (!isBlank(runtime.apiKey())) {
|
||||
builder.header("Authorization", "Bearer " + runtime.apiKey());
|
||||
}
|
||||
HttpResponse<String> response = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(15))
|
||||
.build()
|
||||
.send(builder.build(), HttpResponse.BodyHandlers.ofString());
|
||||
if (!ok(response.statusCode())) {
|
||||
throw new IllegalStateException("vision OCR HTTP " + response.statusCode());
|
||||
}
|
||||
JsonNode choices = objectMapper.readTree(response.body()).path("choices");
|
||||
if (!choices.isArray() || choices.isEmpty()) {
|
||||
throw new IllegalStateException("vision OCR response missing choices");
|
||||
}
|
||||
return choices.get(0).path("message").path("content").asText();
|
||||
}
|
||||
|
||||
private Optional<DuplicateHit> duplicateHit(String fileName, DocumentFingerprint fingerprint) {
|
||||
return duplicateHits(fileName, fingerprint).stream().findFirst();
|
||||
}
|
||||
@@ -683,7 +841,7 @@ public class AihrSopSeedService {
|
||||
.map(this::processingEvent)
|
||||
.toList();
|
||||
|
||||
ProcessingRulesResponse rules = new ProcessingRulesResponse(true, false, true, true, failed);
|
||||
ProcessingRulesResponse rules = new ProcessingRulesResponse(true, visionRuntime().isPresent(), true, true, failed);
|
||||
return new ProcessingOverviewResponse(metrics, categories, tasks, pipeline, events, rules);
|
||||
}
|
||||
|
||||
@@ -1077,30 +1235,44 @@ public class AihrSopSeedService {
|
||||
return "success";
|
||||
}
|
||||
|
||||
private void embedFragments(long knowledgeId, String category, String docId, List<String> fragments) {
|
||||
Optional<EmbeddingRuntime> runtime = embeddingRuntime();
|
||||
if (runtime.isEmpty() || fragments.isEmpty()) {
|
||||
return;
|
||||
private EmbeddingBatch embedFragments(long knowledgeId, String category, String docId, List<String> fragments) {
|
||||
if (fragments.isEmpty()) {
|
||||
return new EmbeddingBatch("", 0);
|
||||
}
|
||||
try {
|
||||
List<String> embeddings = callEmbeddings(runtime.get(), fragments);
|
||||
for (EmbeddingRuntime runtime : embeddingRuntimes()) {
|
||||
List<String> embeddings;
|
||||
try {
|
||||
embeddings = callEmbeddings(runtime, fragments);
|
||||
} catch (Exception ignored) {
|
||||
// ponytail: vector config is optional; try the next runtime before falling back to fulltext.
|
||||
continue;
|
||||
}
|
||||
if (embeddings.size() != fragments.size()) {
|
||||
continue;
|
||||
}
|
||||
for (int i = 0; i < embeddings.size(); i++) {
|
||||
jdbcTemplate.update("""
|
||||
update aihr_knowledge_fragment
|
||||
set embedding_json = ?, embedding_model = ?, embedding_time = now(), update_time = now()
|
||||
where tenant_id = ? and knowledge_id = ? and doc_id = ? and idx = ?
|
||||
""", embeddings.get(i), runtime.get().modelName(), TENANT_ID, knowledgeId, docId, i + 1);
|
||||
""", embeddings.get(i), runtime.modelName(), TENANT_ID, knowledgeId, docId, i + 1);
|
||||
}
|
||||
upsertQdrant(knowledgeId, category, docId, fragments, embeddings, runtime.get().modelName());
|
||||
} catch (Exception ignored) {
|
||||
// ponytail: embedding is opportunistic; upload/search should survive bad vector config.
|
||||
try {
|
||||
upsertQdrant(knowledgeId, category, docId, fragments, embeddings, runtime.modelName());
|
||||
} catch (Exception ignored) {
|
||||
// ponytail: keep MySQL embeddings; the status panel tells users when Qdrant needs rebuild.
|
||||
}
|
||||
return new EmbeddingBatch(runtime.modelName(), embeddings.size());
|
||||
}
|
||||
return new EmbeddingBatch("", 0);
|
||||
}
|
||||
|
||||
private Optional<EmbeddingRuntime> embeddingRuntime() {
|
||||
private List<EmbeddingRuntime> embeddingRuntimes() {
|
||||
List<EmbeddingRuntime> runtimes = new ArrayList<>();
|
||||
try {
|
||||
List<EmbeddingRuntime> rows = jdbcTemplate.query("""
|
||||
runtimes.addAll(jdbcTemplate.query("""
|
||||
select c.model_name,
|
||||
coalesce(c.model_dimension, 0) as model_dimension,
|
||||
coalesce(nullif(c.api_host, ''), nullif(p.api_host, '')) as resolved_api_host,
|
||||
coalesce(nullif(c.api_key, ''), nullif(p.api_key, '')) as resolved_api_key
|
||||
from aihr_model_config c
|
||||
@@ -1111,21 +1283,27 @@ public class AihrSopSeedService {
|
||||
and c.enabled = 1
|
||||
and (p.status is null or p.status = '0')
|
||||
order by c.id asc
|
||||
limit 1
|
||||
limit 3
|
||||
""", (rs, rowNum) -> new EmbeddingRuntime(
|
||||
rs.getString("model_name"),
|
||||
rs.getString("resolved_api_host"),
|
||||
rs.getString("resolved_api_key")
|
||||
), TENANT_ID);
|
||||
return rows.stream()
|
||||
rs.getString("resolved_api_key"),
|
||||
rs.getInt("model_dimension")
|
||||
), TENANT_ID).stream()
|
||||
.filter(runtime -> configured(runtime.baseUrl(), runtime.modelName(), runtime.apiKey()))
|
||||
.findFirst();
|
||||
} catch (DataAccessException e) {
|
||||
return Optional.empty();
|
||||
.toList());
|
||||
} catch (DataAccessException ignored) {
|
||||
// fall through to local fallback
|
||||
}
|
||||
runtimes.add(new EmbeddingRuntime(LOCAL_EMBEDDING_MODEL, LOCAL_EMBEDDING_BASE_URL, "", LOCAL_EMBEDDING_DIMENSION));
|
||||
return runtimes;
|
||||
}
|
||||
|
||||
private List<String> callEmbeddings(EmbeddingRuntime runtime, List<String> fragments) throws Exception {
|
||||
if (LOCAL_EMBEDDING_BASE_URL.equals(runtime.baseUrl())) {
|
||||
return localEmbeddings(fragments);
|
||||
}
|
||||
|
||||
ObjectNode body = objectMapper.createObjectNode();
|
||||
body.put("model", runtime.modelName());
|
||||
ArrayNode input = body.putArray("input");
|
||||
@@ -1159,13 +1337,55 @@ public class AihrSopSeedService {
|
||||
return embeddings.size() == fragments.size() ? embeddings : List.of();
|
||||
}
|
||||
|
||||
private List<String> localEmbeddings(List<String> fragments) {
|
||||
return fragments.stream()
|
||||
.map(this::localEmbedding)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private String localEmbedding(String text) {
|
||||
double[] vector = new double[LOCAL_EMBEDDING_DIMENSION];
|
||||
String cleaned = Optional.ofNullable(text).orElse("")
|
||||
.toLowerCase()
|
||||
.replaceAll("[^\\p{IsHan}\\p{Alnum}]+", " ")
|
||||
.trim();
|
||||
for (String term : cleaned.split("\\s+")) {
|
||||
if (term.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
addLocalEmbeddingTerm(vector, term);
|
||||
for (int i = 0; i + 2 <= term.length(); i++) {
|
||||
addLocalEmbeddingTerm(vector, term.substring(i, i + 2));
|
||||
}
|
||||
}
|
||||
double norm = 0;
|
||||
for (double value : vector) {
|
||||
norm += value * value;
|
||||
}
|
||||
norm = Math.sqrt(norm);
|
||||
if (norm == 0) {
|
||||
vector[0] = 1;
|
||||
norm = 1;
|
||||
}
|
||||
ArrayNode array = objectMapper.createArrayNode();
|
||||
for (double value : vector) {
|
||||
array.add(Math.round((value / norm) * 1_000_000d) / 1_000_000d);
|
||||
}
|
||||
return array.toString();
|
||||
}
|
||||
|
||||
private static void addLocalEmbeddingTerm(double[] vector, String term) {
|
||||
int hash = term.hashCode();
|
||||
int index = Math.floorMod(hash, vector.length);
|
||||
vector[index] += (hash & 1) == 0 ? 1 : -1;
|
||||
}
|
||||
|
||||
private SearchResponse dbSearch(String category, String queryText, Integer limit) {
|
||||
try {
|
||||
int limitValue = limitValue(limit);
|
||||
List<KnowledgeHit> hits = rrf(fulltextHits(category, queryText, limitValue), vectorHits(category, queryText, limitValue), limitValue);
|
||||
if (hits.isEmpty()) {
|
||||
hits = likeHits(category, queryText, limitValue);
|
||||
}
|
||||
List<KnowledgeHit> keywordHits = likeHits(category, queryText, limitValue);
|
||||
List<KnowledgeHit> textHits = rrf(keywordHits, fulltextHits(category, queryText, limitValue), limitValue);
|
||||
List<KnowledgeHit> hits = rrf(textHits, vectorHits(category, queryText, limitValue), limitValue);
|
||||
if (hits.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
@@ -1176,25 +1396,25 @@ public class AihrSopSeedService {
|
||||
}
|
||||
|
||||
private List<KnowledgeHit> vectorHits(String category, String queryText, int limit) {
|
||||
Optional<EmbeddingRuntime> runtime = embeddingRuntime();
|
||||
if (runtime.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
try {
|
||||
List<String> embeddings = callEmbeddings(runtime.get(), List.of(queryText));
|
||||
if (embeddings.isEmpty()) {
|
||||
return List.of();
|
||||
for (EmbeddingRuntime runtime : embeddingRuntimes()) {
|
||||
try {
|
||||
List<String> embeddings = callEmbeddings(runtime, List.of(queryText));
|
||||
if (embeddings.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
List<VectorMatch> matches = queryQdrant(category, embeddings.get(0), limit);
|
||||
List<KnowledgeHit> hits = new ArrayList<>();
|
||||
for (VectorMatch match : matches) {
|
||||
hydrateQdrantHit(match).ifPresent(hits::add);
|
||||
}
|
||||
if (!hits.isEmpty()) {
|
||||
return hits;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// ponytail: vector recall is additive; try the next runtime before fulltext fallback.
|
||||
}
|
||||
List<VectorMatch> matches = queryQdrant(category, embeddings.get(0), limit);
|
||||
List<KnowledgeHit> hits = new ArrayList<>();
|
||||
for (VectorMatch match : matches) {
|
||||
hydrateQdrantHit(match).ifPresent(hits::add);
|
||||
}
|
||||
return hits;
|
||||
} catch (Exception ignored) {
|
||||
// ponytail: vector recall is additive; MySQL fulltext remains the demo-safe path.
|
||||
return List.of();
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private List<KnowledgeHit> fulltextHits(String category, String queryText, int limit) {
|
||||
@@ -1226,23 +1446,40 @@ public class AihrSopSeedService {
|
||||
}
|
||||
|
||||
private List<KnowledgeHit> likeHits(String category, String queryText, int limit) {
|
||||
return jdbcTemplate.query("""
|
||||
List<String> terms = searchTerms(queryText);
|
||||
if (terms.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> scoreParts = new ArrayList<>();
|
||||
List<String> whereParts = new ArrayList<>();
|
||||
for (int i = 0; i < terms.size(); i++) {
|
||||
scoreParts.add("case when f.content like ? escape '\\\\' then 1 else 0 end");
|
||||
whereParts.add("f.content like ? escape '\\\\'");
|
||||
}
|
||||
String sql = """
|
||||
select coalesce(a.name, i.name) as title,
|
||||
i.name as category,
|
||||
coalesce(i.description, '') as description,
|
||||
f.doc_id,
|
||||
f.content,
|
||||
f.idx,
|
||||
0.1 as score
|
||||
(%s) as score
|
||||
from aihr_knowledge_fragment f
|
||||
join aihr_knowledge_info i on i.id = f.knowledge_id and i.tenant_id = f.tenant_id
|
||||
left join aihr_knowledge_attach a on a.knowledge_id = f.knowledge_id and a.doc_id = f.doc_id and a.tenant_id = f.tenant_id
|
||||
where f.tenant_id = ?
|
||||
and i.name = ?
|
||||
and f.content like ?
|
||||
order by f.idx asc
|
||||
and (%s)
|
||||
order by score desc, f.idx asc
|
||||
limit ?
|
||||
""", (rs, rowNum) -> new KnowledgeHit(
|
||||
""".formatted(String.join(" + ", scoreParts), String.join(" or ", whereParts));
|
||||
List<Object> args = new ArrayList<>();
|
||||
terms.stream().map(AihrSopSeedService::likePattern).forEach(args::add);
|
||||
args.add(TENANT_ID);
|
||||
args.add(category);
|
||||
terms.stream().map(AihrSopSeedService::likePattern).forEach(args::add);
|
||||
args.add(limit);
|
||||
return jdbcTemplate.query(sql, (rs, rowNum) -> new KnowledgeHit(
|
||||
rs.getString("title"),
|
||||
rs.getString("category"),
|
||||
rs.getString("description"),
|
||||
@@ -1250,7 +1487,7 @@ public class AihrSopSeedService {
|
||||
rs.getString("content"),
|
||||
rs.getInt("idx"),
|
||||
rs.getDouble("score")
|
||||
), TENANT_ID, category, "%" + queryText + "%", limit);
|
||||
), args.toArray());
|
||||
}
|
||||
|
||||
private Optional<KnowledgeHit> hydrateQdrantHit(VectorMatch match) {
|
||||
@@ -1333,6 +1570,37 @@ public class AihrSopSeedService {
|
||||
return Math.max(1, Math.min(limit, 10));
|
||||
}
|
||||
|
||||
private static List<String> searchTerms(String queryText) {
|
||||
String cleaned = Optional.ofNullable(queryText).orElse("")
|
||||
.toLowerCase()
|
||||
.replaceAll("[^\\p{IsHan}\\p{Alnum}]+", " ")
|
||||
.trim();
|
||||
if (cleaned.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
LinkedHashSet<String> terms = new LinkedHashSet<>();
|
||||
for (String token : cleaned.split("\\s+")) {
|
||||
if (token.length() >= 2 && token.length() <= 16) {
|
||||
terms.add(token);
|
||||
}
|
||||
for (int i = 0; i + 2 <= token.length() && terms.size() < 12; i++) {
|
||||
terms.add(token.substring(i, i + 2));
|
||||
}
|
||||
if (terms.size() >= 12) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return terms.stream().limit(12).toList();
|
||||
}
|
||||
|
||||
private static String likePattern(String term) {
|
||||
String escaped = term
|
||||
.replace("\\", "\\\\")
|
||||
.replace("%", "\\%")
|
||||
.replace("_", "\\_");
|
||||
return "%" + escaped + "%";
|
||||
}
|
||||
|
||||
private static String scoreText(double score) {
|
||||
return score <= 0 ? "片段" : String.format("%.2f", score);
|
||||
}
|
||||
@@ -1490,9 +1758,69 @@ public class AihrSopSeedService {
|
||||
return matches;
|
||||
}
|
||||
|
||||
private VectorDbStats vectorDbStats() {
|
||||
try {
|
||||
return jdbcTemplate.queryForObject("""
|
||||
select count(*) as fragments,
|
||||
sum(case when embedding_json is not null and embedding_json <> '' then 1 else 0 end) as embedded_fragments,
|
||||
min(embedding_model) as min_model,
|
||||
max(embedding_model) as max_model,
|
||||
min(json_length(embedding_json)) as min_dimension,
|
||||
max(json_length(embedding_json)) as max_dimension
|
||||
from aihr_knowledge_fragment
|
||||
where tenant_id = ?
|
||||
""", (rs, rowNum) -> {
|
||||
String minModel = rs.getString("min_model");
|
||||
String maxModel = rs.getString("max_model");
|
||||
int minDimension = rs.getInt("min_dimension");
|
||||
int maxDimension = rs.getInt("max_dimension");
|
||||
String model = Objects.equals(minModel, maxModel) ? firstNonBlank(minModel, "") : "mixed";
|
||||
Integer dimension = minDimension > 0 && minDimension == maxDimension ? minDimension : null;
|
||||
return new VectorDbStats(
|
||||
rs.getInt("fragments"),
|
||||
rs.getInt("embedded_fragments"),
|
||||
model,
|
||||
dimension
|
||||
);
|
||||
}, TENANT_ID);
|
||||
} catch (DataAccessException e) {
|
||||
return new VectorDbStats(0, 0, "", null);
|
||||
}
|
||||
}
|
||||
|
||||
private QdrantStats qdrantStats() {
|
||||
try {
|
||||
HttpResponse<String> response = qdrantRequest("GET", "/collections/" + qdrantCollection(), null);
|
||||
if (response.statusCode() == 404) {
|
||||
return new QdrantStats(null, 0L, "Qdrant collection 不存在");
|
||||
}
|
||||
if (!ok(response.statusCode())) {
|
||||
return new QdrantStats(null, null, "Qdrant 状态读取失败");
|
||||
}
|
||||
JsonNode result = objectMapper.readTree(response.body()).path("result");
|
||||
Integer dimension = result.path("config").path("params").path("vectors").path("size").isInt()
|
||||
? result.path("config").path("params").path("vectors").path("size").asInt()
|
||||
: null;
|
||||
long points = result.path("points_count").asLong(0);
|
||||
return new QdrantStats(dimension, points, "");
|
||||
} catch (Exception e) {
|
||||
return new QdrantStats(null, null, "Qdrant 不可用");
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureQdrantCollection(int vectorSize) throws Exception {
|
||||
HttpResponse<String> current = qdrantRequest("GET", "/collections/" + qdrantCollection(), null);
|
||||
if (ok(current.statusCode())) {
|
||||
int currentSize = objectMapper.readTree(current.body())
|
||||
.path("result")
|
||||
.path("config")
|
||||
.path("params")
|
||||
.path("vectors")
|
||||
.path("size")
|
||||
.asInt(0);
|
||||
if (currentSize > 0 && currentSize != vectorSize) {
|
||||
throw new IllegalStateException("qdrant dimension mismatch " + currentSize + " != " + vectorSize);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (current.statusCode() != 404) {
|
||||
@@ -1510,6 +1838,20 @@ public class AihrSopSeedService {
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteQdrantCollection() {
|
||||
try {
|
||||
HttpResponse<String> response = qdrantRequest("DELETE", "/collections/" + qdrantCollection(), null);
|
||||
if (response.statusCode() == 404) {
|
||||
return;
|
||||
}
|
||||
if (!ok(response.statusCode())) {
|
||||
throw new IllegalStateException("qdrant delete collection HTTP " + response.statusCode());
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// ponytail: rebuild still refreshes MySQL embeddings; status shows if Qdrant stayed stale.
|
||||
}
|
||||
}
|
||||
|
||||
private ObjectNode qdrantFilter(Long knowledgeId, String docId, String category) {
|
||||
ObjectNode filter = objectMapper.createObjectNode();
|
||||
ArrayNode must = filter.putArray("must");
|
||||
@@ -1563,10 +1905,23 @@ public class AihrSopSeedService {
|
||||
.send(builder.build(), HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
|
||||
private static String readContent(MultipartFile file, String fileName) {
|
||||
private String readContent(MultipartFile file, String fileName) {
|
||||
if (textFile(fileName)) {
|
||||
return normalizeMarkdown(readUtf8(file));
|
||||
}
|
||||
if (imageFile(fileName)) {
|
||||
Optional<ChatRuntime> runtime = visionRuntime();
|
||||
if (runtime.isPresent()) {
|
||||
try {
|
||||
byte[] bytes = file.getBytes();
|
||||
String text = callVisionOcr(runtime.get(), bytes, guessMimeType(fileName));
|
||||
return normalizeExtractedText(text);
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
try (InputStream input = file.getInputStream()) {
|
||||
BodyContentHandler handler = new BodyContentHandler(-1);
|
||||
Metadata metadata = new Metadata();
|
||||
@@ -1578,10 +1933,23 @@ public class AihrSopSeedService {
|
||||
}
|
||||
}
|
||||
|
||||
private static String readContent(Path file, String fileName) {
|
||||
private String readContent(Path file, String fileName) {
|
||||
if (textFile(fileName)) {
|
||||
return normalizeMarkdown(readUtf8(file));
|
||||
}
|
||||
if (imageFile(fileName)) {
|
||||
Optional<ChatRuntime> runtime = visionRuntime();
|
||||
if (runtime.isPresent()) {
|
||||
try {
|
||||
byte[] bytes = Files.readAllBytes(file);
|
||||
String text = callVisionOcr(runtime.get(), bytes, guessMimeType(fileName));
|
||||
return normalizeExtractedText(text);
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
try (InputStream input = Files.newInputStream(file)) {
|
||||
BodyContentHandler handler = new BodyContentHandler(-1);
|
||||
Metadata metadata = new Metadata();
|
||||
@@ -1737,6 +2105,7 @@ public class AihrSopSeedService {
|
||||
private static boolean supportedFile(String fileName) {
|
||||
String lower = fileName.toLowerCase();
|
||||
return textFile(lower)
|
||||
|| imageFile(lower)
|
||||
|| lower.endsWith(".pdf")
|
||||
|| lower.endsWith(".doc")
|
||||
|| lower.endsWith(".docx")
|
||||
@@ -1751,6 +2120,23 @@ public class AihrSopSeedService {
|
||||
return lower.endsWith(".txt") || lower.endsWith(".md") || lower.endsWith(".markdown");
|
||||
}
|
||||
|
||||
private static boolean imageFile(String fileName) {
|
||||
String lower = fileName.toLowerCase();
|
||||
return lower.endsWith(".jpg") || lower.endsWith(".jpeg")
|
||||
|| lower.endsWith(".png") || lower.endsWith(".gif")
|
||||
|| lower.endsWith(".webp") || lower.endsWith(".bmp");
|
||||
}
|
||||
|
||||
private static String guessMimeType(String fileName) {
|
||||
String lower = fileName.toLowerCase();
|
||||
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
||||
if (lower.endsWith(".png")) return "image/png";
|
||||
if (lower.endsWith(".gif")) return "image/gif";
|
||||
if (lower.endsWith(".webp")) return "image/webp";
|
||||
if (lower.endsWith(".bmp")) return "image/bmp";
|
||||
return "image/jpeg";
|
||||
}
|
||||
|
||||
private static String fileType(String fileName) {
|
||||
return fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase();
|
||||
}
|
||||
@@ -1924,7 +2310,19 @@ public class AihrSopSeedService {
|
||||
private record KnowledgeConfig(long knowledgeId, int blockSize, int overlap) {
|
||||
}
|
||||
|
||||
private record EmbeddingRuntime(String modelName, String baseUrl, String apiKey) {
|
||||
private record EmbeddingRuntime(String modelName, String baseUrl, String apiKey, int dimension) {
|
||||
}
|
||||
|
||||
private record EmbeddingBatch(String modelName, int count) {
|
||||
}
|
||||
|
||||
private record VectorDbStats(int fragments, int embeddedFragments, String embeddingModel, Integer embeddingDimension) {
|
||||
}
|
||||
|
||||
private record QdrantStats(Integer dimension, Long points, String message) {
|
||||
}
|
||||
|
||||
private record VectorizeTarget(long knowledgeId, String category, String docId) {
|
||||
}
|
||||
|
||||
private record ChatRuntime(String modelName, String baseUrl, String apiKey) {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
-- 物业 AI HR 对练记录最小表结构。
|
||||
-- ponytail: one session table covers history, review list and profile aggregation for MVP.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `aihr_practice_session` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
|
||||
`session_id` varchar(100) NOT NULL COMMENT '训练会话ID',
|
||||
`ext_party_id` varchar(100) DEFAULT NULL COMMENT '外部主体或手机号',
|
||||
`trainee_name` varchar(100) DEFAULT NULL COMMENT '学员名称',
|
||||
`scenario_id` varchar(80) DEFAULT NULL COMMENT '场景ID',
|
||||
`scenario_name` varchar(100) DEFAULT NULL COMMENT '场景名称',
|
||||
`mode` varchar(30) DEFAULT 'text' COMMENT '训练模式',
|
||||
`total_score` int DEFAULT NULL COMMENT '总分',
|
||||
`dim_compliance` int DEFAULT NULL COMMENT '合规分',
|
||||
`dim_emotion` int DEFAULT NULL COMMENT '情绪分',
|
||||
`dim_communication` int DEFAULT NULL COMMENT '沟通分',
|
||||
`dim_marketing` int DEFAULT NULL COMMENT '营销分',
|
||||
`mentor_rewrite` varchar(1000) DEFAULT NULL COMMENT '导师改写',
|
||||
`ai_comment` varchar(1000) DEFAULT NULL COMMENT 'AI点评',
|
||||
`summary` varchar(1000) DEFAULT NULL COMMENT '摘要',
|
||||
`dialogue_json` text DEFAULT NULL COMMENT '对练话术JSON',
|
||||
`trust_curve` varchar(100) DEFAULT NULL COMMENT '信任曲线',
|
||||
`status` varchar(30) DEFAULT '待复盘' COMMENT '状态',
|
||||
`started_time` datetime DEFAULT NULL COMMENT '开始时间',
|
||||
`finished_time` datetime DEFAULT NULL COMMENT '完成时间',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_aihr_practice_session` (`tenant_id`, `session_id`),
|
||||
KEY `idx_aihr_practice_party` (`tenant_id`, `ext_party_id`, `finished_time`),
|
||||
KEY `idx_aihr_practice_review` (`tenant_id`, `mode`, `status`, `finished_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练记录';
|
||||
Reference in New Issue
Block a user