feat: complete aihr mvp mobile workflow

This commit is contained in:
2026-07-03 16:43:37 +08:00
parent 0126b0c661
commit f869d66c90
46 changed files with 5879 additions and 219 deletions
@@ -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
) {
}
}
@@ -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();
}
@@ -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
+1 -1
View File
@@ -12,7 +12,7 @@
<artifactId>ruoyi-aihr</artifactId>
<description>
物业 AI 人力资源 Demo 业务模块
物业 AI 人力资源 MVP 业务模块
</description>
<dependencies>
@@ -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));
}
}
@@ -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());
@@ -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) {
}
}
@@ -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) {
}
@@ -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
) {
}
}
@@ -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"
)
);
}
}
@@ -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");
@@ -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) {
}
}
@@ -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 对练记录';