feat(aihr): add pilot satisfaction evidence loop
This commit is contained in:
+8
@@ -16,6 +16,8 @@ import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse;
|
|||||||
import org.dromara.aihr.domain.AihrPracticeDto.MistakeResponse;
|
import org.dromara.aihr.domain.AihrPracticeDto.MistakeResponse;
|
||||||
import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest;
|
import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest;
|
||||||
import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse;
|
import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse;
|
||||||
|
import org.dromara.aihr.domain.AihrPracticeDto.SatisfactionRequest;
|
||||||
|
import org.dromara.aihr.domain.AihrPracticeDto.SatisfactionResponse;
|
||||||
import org.dromara.aihr.domain.vo.AihrMobileDto.HomeResponse;
|
import org.dromara.aihr.domain.vo.AihrMobileDto.HomeResponse;
|
||||||
import org.dromara.aihr.domain.vo.AihrMobileDto.IdentityResponse;
|
import org.dromara.aihr.domain.vo.AihrMobileDto.IdentityResponse;
|
||||||
import org.dromara.aihr.service.AihrOrgSyncService;
|
import org.dromara.aihr.service.AihrOrgSyncService;
|
||||||
@@ -156,6 +158,12 @@ public class AihrMobileController {
|
|||||||
return response == null ? R.fail("每日三题不存在") : R.ok(response);
|
return response == null ? R.fail("每日三题不存在") : R.ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/practice/satisfaction")
|
||||||
|
public R<SatisfactionResponse> submitSatisfaction(@RequestBody SatisfactionRequest request) {
|
||||||
|
SatisfactionResponse response = mobileSeedService.submitSatisfaction(request, ownMobileExtPartyId(null));
|
||||||
|
return response == null ? R.fail("满意度提交失败,请确认训练已完成且评分为1-5分") : R.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
private static String ownMobileExtPartyId(String requested) {
|
private static String ownMobileExtPartyId(String requested) {
|
||||||
String username = currentAppUsername();
|
String username = currentAppUsername();
|
||||||
return username.isBlank() ? requested : username;
|
return username.isBlank() ? requested : username;
|
||||||
|
|||||||
+6
@@ -136,6 +136,12 @@ public final class AihrPracticeDto {
|
|||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record SatisfactionRequest(String sessionId, Integer score, String comment) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SatisfactionResponse(String sessionId, Integer score, String comment, String submittedAt) {
|
||||||
|
}
|
||||||
|
|
||||||
public record DimensionResponse(String label, Object value, String note) {
|
public record DimensionResponse(String label, Object value, String note) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
@@ -12,6 +12,8 @@ import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse;
|
|||||||
import org.dromara.aihr.domain.AihrPracticeDto.MistakeResponse;
|
import org.dromara.aihr.domain.AihrPracticeDto.MistakeResponse;
|
||||||
import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest;
|
import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest;
|
||||||
import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse;
|
import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse;
|
||||||
|
import org.dromara.aihr.domain.AihrPracticeDto.SatisfactionRequest;
|
||||||
|
import org.dromara.aihr.domain.AihrPracticeDto.SatisfactionResponse;
|
||||||
import org.dromara.aihr.domain.vo.AihrMobileDto.HomeResponse;
|
import org.dromara.aihr.domain.vo.AihrMobileDto.HomeResponse;
|
||||||
import org.dromara.aihr.domain.vo.AihrMobileDto.MetricResponse;
|
import org.dromara.aihr.domain.vo.AihrMobileDto.MetricResponse;
|
||||||
import org.dromara.aihr.domain.vo.AihrMobileDto.MiniStatResponse;
|
import org.dromara.aihr.domain.vo.AihrMobileDto.MiniStatResponse;
|
||||||
@@ -98,6 +100,10 @@ public class AihrMobileSeedService {
|
|||||||
return practiceSeedService.markReviewed(id, request, supervisorExtPartyId);
|
return practiceSeedService.markReviewed(id, request, supervisorExtPartyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public SatisfactionResponse submitSatisfaction(SatisfactionRequest request, String extPartyId) {
|
||||||
|
return practiceSeedService.submitSatisfaction(request, extPartyId);
|
||||||
|
}
|
||||||
|
|
||||||
public CompetencyResponse profile(String extPartyId) {
|
public CompetencyResponse profile(String extPartyId) {
|
||||||
return practiceSeedService.competency(extPartyId);
|
return practiceSeedService.competency(extPartyId);
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-2
@@ -36,6 +36,8 @@ import org.dromara.aihr.domain.AihrPracticeDto.RubricSaveRequest;
|
|||||||
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioEnabledRequest;
|
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioEnabledRequest;
|
||||||
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioResponse;
|
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioResponse;
|
||||||
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioSaveRequest;
|
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioSaveRequest;
|
||||||
|
import org.dromara.aihr.domain.AihrPracticeDto.SatisfactionRequest;
|
||||||
|
import org.dromara.aihr.domain.AihrPracticeDto.SatisfactionResponse;
|
||||||
import org.dromara.aihr.domain.AihrPracticeDto.StartRequest;
|
import org.dromara.aihr.domain.AihrPracticeDto.StartRequest;
|
||||||
import org.dromara.aihr.domain.AihrPracticeDto.StartResponse;
|
import org.dromara.aihr.domain.AihrPracticeDto.StartResponse;
|
||||||
import org.dromara.aihr.domain.AihrPracticeDto.TurnRequest;
|
import org.dromara.aihr.domain.AihrPracticeDto.TurnRequest;
|
||||||
@@ -176,6 +178,8 @@ public class AihrPracticeSeedService {
|
|||||||
(SELECT COUNT(*) FROM formal_sessions WHERE status = '已复盘') AS reviewed_count,
|
(SELECT COUNT(*) FROM formal_sessions WHERE status = '已复盘') AS reviewed_count,
|
||||||
(SELECT COUNT(*) FROM formal_sessions WHERE total_score < 80 OR status = '待复盘') AS alert_count,
|
(SELECT COUNT(*) FROM formal_sessions WHERE total_score < 80 OR status = '待复盘') AS alert_count,
|
||||||
COALESCE((SELECT AVG(total_score) FROM formal_sessions), 0) AS average_score,
|
COALESCE((SELECT AVG(total_score) FROM formal_sessions), 0) AS average_score,
|
||||||
|
(SELECT COUNT(*) FROM formal_sessions WHERE satisfaction_score IS NOT NULL) AS satisfaction_count,
|
||||||
|
COALESCE((SELECT AVG(satisfaction_score) FROM formal_sessions WHERE satisfaction_score IS NOT NULL), 0) AS satisfaction_average,
|
||||||
(SELECT COUNT(*) FROM calibration_rows) AS calibration_count,
|
(SELECT COUNT(*) FROM calibration_rows) AS calibration_count,
|
||||||
COALESCE((SELECT SUM(CASE
|
COALESCE((SELECT SUM(CASE
|
||||||
WHEN (CASE WHEN original_score < 60 THEN 0 WHEN original_score < 75 THEN 1 WHEN original_score < 90 THEN 2 ELSE 3 END)
|
WHEN (CASE WHEN original_score < 60 THEN 0 WHEN original_score < 75 THEN 1 WHEN original_score < 90 THEN 2 ELSE 3 END)
|
||||||
@@ -188,6 +192,7 @@ public class AihrPracticeSeedService {
|
|||||||
FROM safe_org o WHERE o.ext_party_id = s.formal_ext_party_id) AS project_codes,
|
FROM safe_org o WHERE o.ext_party_id = s.formal_ext_party_id) AS project_codes,
|
||||||
s.session_id, s.trainee_name, s.scenario_name, s.total_score, s.status,
|
s.session_id, s.trainee_name, s.scenario_name, s.total_score, s.status,
|
||||||
s.finished_time, s.summary, s.review_advice, s.incentive_point,
|
s.finished_time, s.summary, s.review_advice, s.incentive_point,
|
||||||
|
s.satisfaction_score, s.satisfaction_comment,
|
||||||
s.total_score AS ai_score,
|
s.total_score AS ai_score,
|
||||||
c.original_score AS calibration_original_score,
|
c.original_score AS calibration_original_score,
|
||||||
c.corrected_score AS human_score,
|
c.corrected_score AS human_score,
|
||||||
@@ -1005,6 +1010,8 @@ public class AihrPracticeSeedService {
|
|||||||
rs.getInt("reviewed_count"),
|
rs.getInt("reviewed_count"),
|
||||||
rs.getInt("alert_count"),
|
rs.getInt("alert_count"),
|
||||||
rs.getDouble("average_score"),
|
rs.getDouble("average_score"),
|
||||||
|
rs.getInt("satisfaction_count"),
|
||||||
|
rs.getDouble("satisfaction_average"),
|
||||||
rs.getInt("calibration_count"),
|
rs.getInt("calibration_count"),
|
||||||
rs.getInt("calibration_matched")
|
rs.getInt("calibration_matched")
|
||||||
), TENANT_ID, startTime, endTime);
|
), TENANT_ID, startTime, endTime);
|
||||||
@@ -1027,6 +1034,7 @@ public class AihrPracticeSeedService {
|
|||||||
boolean calibrationReady = metrics.calibrationCount() >= 20
|
boolean calibrationReady = metrics.calibrationCount() >= 20
|
||||||
&& metrics.calibrationMatched() * 100 >= metrics.calibrationCount() * 70;
|
&& metrics.calibrationMatched() * 100 >= metrics.calibrationCount() * 70;
|
||||||
boolean sopReady = sopStats.total() > 0 && sopStats.usable() * 100 >= sopStats.total() * 80;
|
boolean sopReady = sopStats.total() > 0 && sopStats.usable() * 100 >= sopStats.total() * 80;
|
||||||
|
boolean satisfactionReady = metrics.satisfactionCount() > 0 && metrics.satisfactionAverage() >= 4.0D;
|
||||||
StringBuilder csv = new StringBuilder();
|
StringBuilder csv = new StringBuilder();
|
||||||
appendCsvRow(csv, "metric", "value");
|
appendCsvRow(csv, "metric", "value");
|
||||||
appendCsvRow(csv, "pilot_start_date", startDate);
|
appendCsvRow(csv, "pilot_start_date", startDate);
|
||||||
@@ -1062,9 +1070,13 @@ public class AihrPracticeSeedService {
|
|||||||
appendCsvRow(csv, "sop_answer_usable_rate", percent(sopStats.usable(), sopStats.total()));
|
appendCsvRow(csv, "sop_answer_usable_rate", percent(sopStats.usable(), sopStats.total()));
|
||||||
appendCsvRow(csv, "sop_answer_usable_target", "80.0%");
|
appendCsvRow(csv, "sop_answer_usable_target", "80.0%");
|
||||||
appendCsvRow(csv, "sop_answer_usable_ready", sopReady);
|
appendCsvRow(csv, "sop_answer_usable_ready", sopReady);
|
||||||
appendCsvRow(csv, "pilot_metric_ready", metrics.scenarioCount() >= 12 && completionReady && calibrationReady && sopReady);
|
appendCsvRow(csv, "satisfaction_response_count", metrics.satisfactionCount());
|
||||||
|
appendCsvRow(csv, "satisfaction_average", String.format(java.util.Locale.ROOT, "%.1f", metrics.satisfactionAverage()));
|
||||||
|
appendCsvRow(csv, "satisfaction_target", ">=4.0/5");
|
||||||
|
appendCsvRow(csv, "satisfaction_ready", satisfactionReady);
|
||||||
|
appendCsvRow(csv, "pilot_metric_ready", metrics.scenarioCount() >= 12 && completionReady && calibrationReady && sopReady && satisfactionReady);
|
||||||
csv.append('\n');
|
csv.append('\n');
|
||||||
appendCsvRow(csv, "formal_ext_party_id", "project_codes", "session_id", "trainee", "scene", "score", "status", "finished_time", "summary", "review_advice", "incentive_point", "ai_score", "calibration_original_score", "human_score", "calibration_reviewer", "calibration_time", "final_adopted_score");
|
appendCsvRow(csv, "formal_ext_party_id", "project_codes", "session_id", "trainee", "scene", "score", "status", "finished_time", "summary", "review_advice", "incentive_point", "satisfaction_score", "satisfaction_comment", "ai_score", "calibration_original_score", "human_score", "calibration_reviewer", "calibration_time", "final_adopted_score");
|
||||||
sessionRows.forEach(row -> appendCsvRow(
|
sessionRows.forEach(row -> appendCsvRow(
|
||||||
csv,
|
csv,
|
||||||
row.get("formal_ext_party_id"),
|
row.get("formal_ext_party_id"),
|
||||||
@@ -1078,6 +1090,8 @@ public class AihrPracticeSeedService {
|
|||||||
row.get("summary"),
|
row.get("summary"),
|
||||||
row.get("review_advice"),
|
row.get("review_advice"),
|
||||||
row.get("incentive_point"),
|
row.get("incentive_point"),
|
||||||
|
row.get("satisfaction_score"),
|
||||||
|
row.get("satisfaction_comment"),
|
||||||
row.get("ai_score"),
|
row.get("ai_score"),
|
||||||
row.get("calibration_original_score"),
|
row.get("calibration_original_score"),
|
||||||
row.get("human_score"),
|
row.get("human_score"),
|
||||||
@@ -1216,6 +1230,31 @@ public class AihrPracticeSeedService {
|
|||||||
return markReviewed(id, request);
|
return markReviewed(id, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public SatisfactionResponse submitSatisfaction(SatisfactionRequest request, String extPartyId) {
|
||||||
|
ensurePracticeTable();
|
||||||
|
if (request == null || isBlank(request.sessionId()) || isBlank(extPartyId)
|
||||||
|
|| request.score() == null || request.score() < 1 || request.score() > 5) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String comment = request.comment() == null ? "" : request.comment().trim();
|
||||||
|
if (comment.length() > 500) {
|
||||||
|
comment = comment.substring(0, 500);
|
||||||
|
}
|
||||||
|
comment = maskSensitiveText(comment);
|
||||||
|
Timestamp submittedAt = Timestamp.valueOf(LocalDateTime.now());
|
||||||
|
int updated = jdbcTemplate.update("""
|
||||||
|
UPDATE aihr_practice_session
|
||||||
|
SET satisfaction_score = ?, satisfaction_comment = ?, update_time = ?
|
||||||
|
WHERE tenant_id = ? AND mode = 'mobile' AND session_id = ? AND ext_party_id = ? AND finished_time IS NOT NULL
|
||||||
|
""",
|
||||||
|
request.score(), comment, submittedAt, TENANT_ID, request.sessionId().trim(), extPartyId.trim());
|
||||||
|
if (updated == 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new SatisfactionResponse(request.sessionId().trim(), request.score(), comment, submittedAt.toString());
|
||||||
|
}
|
||||||
|
|
||||||
public List<AnnotationResponse> saveAnnotations(String sessionId, AnnotationSaveRequest request) {
|
public List<AnnotationResponse> saveAnnotations(String sessionId, AnnotationSaveRequest request) {
|
||||||
ensurePracticeTable();
|
ensurePracticeTable();
|
||||||
if (isBlank(sessionId)) {
|
if (isBlank(sessionId)) {
|
||||||
@@ -2902,6 +2941,8 @@ public class AihrPracticeSeedService {
|
|||||||
`ai_comment` varchar(1000) DEFAULT NULL COMMENT 'AI点评',
|
`ai_comment` varchar(1000) DEFAULT NULL COMMENT 'AI点评',
|
||||||
`review_advice` varchar(1000) DEFAULT NULL COMMENT '主管复盘建议',
|
`review_advice` varchar(1000) DEFAULT NULL COMMENT '主管复盘建议',
|
||||||
`incentive_point` int DEFAULT NULL COMMENT '主管人工贡献分',
|
`incentive_point` int DEFAULT NULL COMMENT '主管人工贡献分',
|
||||||
|
`satisfaction_score` tinyint DEFAULT NULL COMMENT '员工训练满意度1-5分',
|
||||||
|
`satisfaction_comment` varchar(500) DEFAULT NULL COMMENT '员工训练满意度意见',
|
||||||
`summary` varchar(1000) DEFAULT NULL COMMENT '摘要',
|
`summary` varchar(1000) DEFAULT NULL COMMENT '摘要',
|
||||||
`dialogue_json` text DEFAULT NULL COMMENT '对练话术JSON',
|
`dialogue_json` text DEFAULT NULL COMMENT '对练话术JSON',
|
||||||
`annotations_json` text DEFAULT NULL COMMENT '逐句标注JSON',
|
`annotations_json` text DEFAULT NULL COMMENT '逐句标注JSON',
|
||||||
@@ -2921,6 +2962,8 @@ public class AihrPracticeSeedService {
|
|||||||
ensurePracticeColumn("annotations_json", "ALTER TABLE aihr_practice_session ADD COLUMN `annotations_json` text DEFAULT NULL COMMENT '逐句标注JSON' AFTER `dialogue_json`");
|
ensurePracticeColumn("annotations_json", "ALTER TABLE aihr_practice_session ADD COLUMN `annotations_json` text DEFAULT NULL COMMENT '逐句标注JSON' AFTER `dialogue_json`");
|
||||||
ensurePracticeColumn("review_advice", "ALTER TABLE aihr_practice_session ADD COLUMN `review_advice` varchar(1000) DEFAULT NULL COMMENT '主管复盘建议' AFTER `ai_comment`");
|
ensurePracticeColumn("review_advice", "ALTER TABLE aihr_practice_session ADD COLUMN `review_advice` varchar(1000) DEFAULT NULL COMMENT '主管复盘建议' AFTER `ai_comment`");
|
||||||
ensurePracticeColumn("incentive_point", "ALTER TABLE aihr_practice_session ADD COLUMN `incentive_point` int DEFAULT NULL COMMENT '主管人工贡献分' AFTER `review_advice`");
|
ensurePracticeColumn("incentive_point", "ALTER TABLE aihr_practice_session ADD COLUMN `incentive_point` int DEFAULT NULL COMMENT '主管人工贡献分' AFTER `review_advice`");
|
||||||
|
ensurePracticeColumn("satisfaction_score", "ALTER TABLE aihr_practice_session ADD COLUMN `satisfaction_score` tinyint DEFAULT NULL COMMENT '员工训练满意度1-5分' AFTER `incentive_point`");
|
||||||
|
ensurePracticeColumn("satisfaction_comment", "ALTER TABLE aihr_practice_session ADD COLUMN `satisfaction_comment` varchar(500) DEFAULT NULL COMMENT '员工训练满意度意见' AFTER `satisfaction_score`");
|
||||||
practiceTableReady = true;
|
practiceTableReady = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3450,6 +3493,8 @@ public class AihrPracticeSeedService {
|
|||||||
int reviewedCount,
|
int reviewedCount,
|
||||||
int alertCount,
|
int alertCount,
|
||||||
double averageScore,
|
double averageScore,
|
||||||
|
int satisfactionCount,
|
||||||
|
double satisfactionAverage,
|
||||||
int calibrationCount,
|
int calibrationCount,
|
||||||
int calibrationMatched
|
int calibrationMatched
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_session` (
|
|||||||
`ai_comment` varchar(1000) DEFAULT NULL COMMENT 'AI点评',
|
`ai_comment` varchar(1000) DEFAULT NULL COMMENT 'AI点评',
|
||||||
`review_advice` varchar(1000) DEFAULT NULL COMMENT '主管复盘建议',
|
`review_advice` varchar(1000) DEFAULT NULL COMMENT '主管复盘建议',
|
||||||
`incentive_point` int DEFAULT NULL COMMENT '主管人工贡献分',
|
`incentive_point` int DEFAULT NULL COMMENT '主管人工贡献分',
|
||||||
|
`satisfaction_score` tinyint DEFAULT NULL COMMENT '员工训练满意度1-5分',
|
||||||
|
`satisfaction_comment` varchar(500) DEFAULT NULL COMMENT '员工训练满意度意见',
|
||||||
`summary` varchar(1000) DEFAULT NULL COMMENT '摘要',
|
`summary` varchar(1000) DEFAULT NULL COMMENT '摘要',
|
||||||
`dialogue_json` text DEFAULT NULL COMMENT '对练话术JSON',
|
`dialogue_json` text DEFAULT NULL COMMENT '对练话术JSON',
|
||||||
`annotations_json` text DEFAULT NULL COMMENT '逐句标注JSON',
|
`annotations_json` text DEFAULT NULL COMMENT '逐句标注JSON',
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| AI面试 `/recruit/interview` | `POST /api/recruit/interview/start`、`/answer`、`/finish` | 已接入真实模型优先链路:配置 chat 模型后 `/start` 动态生成面试题,`/finish` 按前端提交的真实回答做结构化评分;未配置模型时使用本地 Rubric 估分,不返回固定候选人分数 |
|
| AI面试 `/recruit/interview` | `POST /api/recruit/interview/start`、`/answer`、`/finish` | 已接入真实模型优先链路:配置 chat 模型后 `/start` 动态生成面试题,`/finish` 按前端提交的真实回答做结构化评分;未配置模型时使用本地 Rubric 估分,不返回固定候选人分数 |
|
||||||
| 三角色对练 `/train/practice` | `POST /api/train/practice/start`、`/turn`、`/finish` | 已接入编排 API;数据库启用 chat 模型后,`/turn` 客户回复按人设走真 LLM 生成(seed 剧本作剧情锚点),`/finish` 走单次 temperature=0 结构化评分(4 维分+导师改写+点评);模型未配置或调用失败自动回退 seed,契约不变 |
|
| 三角色对练 `/train/practice` | `POST /api/train/practice/start`、`/turn`、`/finish` | 已接入编排 API;数据库启用 chat 模型后,`/turn` 客户回复按人设走真 LLM 生成(seed 剧本作剧情锚点),`/finish` 走单次 temperature=0 结构化评分(4 维分+导师改写+点评);模型未配置或调用失败自动回退 seed,契约不变 |
|
||||||
| 正式试点数据导出 | `GET /api/train/practice/export?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD` | 起止日期必填且包含结束日;只统计窗口内能通过唯一手机号或外部 ID 映射到在职组织快照的正式会话,排除重复手机号和身份碰撞。完训定义为每人至少 10 次,校准必须关联同一窗口内正式会话;CSV 同时给出校准命中数、SOP 可用数,以及明细级 AI 分、人工校准分、校准人、校准时间和最终采用分,避免用四舍五入后的比率反推门禁状态;汇总和明细均携带正式人员及项目口径,不混入历史 seed/开发身份 |
|
| 正式试点数据导出 | `GET /api/train/practice/export?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD` | 起止日期必填且包含结束日;只统计窗口内能通过唯一手机号或外部 ID 映射到在职组织快照的正式会话,排除重复手机号和身份碰撞。完训定义为每人至少 10 次,校准必须关联同一窗口内正式会话;CSV 同时给出校准命中数、SOP 可用数、满意度响应数/平均分,以及明细级 AI 分、人工校准分、校准人、校准时间、最终采用分、满意度分和意见,避免用四舍五入后的比率反推门禁状态;汇总和明细均携带正式人员及项目口径,不混入历史 seed/开发身份 |
|
||||||
| 对练语音 | `POST /api/ai/asr`(multipart 字段 `file`,≤5MB)、`POST /api/ai/tts`(JSON `{text≤300字, voice}`,返回 `{audioUrl}` base64 dataURL) | 走 OpenAI-compatible audio 接口(如硅基流动 SenseVoice/CosyVoice2);模型管理需启用 `category=asr/tts` 配置;移动端优先用浏览器录音,`getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用 `audio/*` file input 选择/录制音频后继续调同一 ASR 接口;ASR/TTS 未配置或失败返回 fail,前端降级文本 |
|
| 对练语音 | `POST /api/ai/asr`(multipart 字段 `file`,≤5MB)、`POST /api/ai/tts`(JSON `{text≤300字, voice}`,返回 `{audioUrl}` base64 dataURL) | 走 OpenAI-compatible audio 接口(如硅基流动 SenseVoice/CosyVoice2);模型管理需启用 `category=asr/tts` 配置;移动端优先用浏览器录音,`getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用 `audio/*` file input 选择/录制音频后继续调同一 ASR 接口;ASR/TTS 未配置或失败返回 fail,前端降级文本 |
|
||||||
| 案例沉淀 `/knowledge/cases` | `POST /api/knowledge/case/upload`、`/organize`、`/curate` | `/upload` 改为 multipart 真实语音上传并走 ASR;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化;不再用固定样例转写冒充成功 |
|
| 案例沉淀 `/knowledge/cases` | `POST /api/knowledge/case/upload`、`/organize`、`/curate` | `/upload` 改为 multipart 真实语音上传并走 ASR;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化;不再用固定样例转写冒充成功 |
|
||||||
| SOP知识库 `/knowledge/sop` | `POST /api/knowledge/search`、`GET /api/knowledge/position-sop`、`POST /api/knowledge/doc/upload` | 已接入 MySQL Fulltext + Qdrant 混合召回、岗位学习适配摘要、OSS-first 文档上传、txt/md/PDF/Word/Excel/PPT 解析和 embedding 写入,失败回退 seed;`position-sop` 目前只返回一期生活顾问学习导航,不代表正式上岗资格 |
|
| SOP知识库 `/knowledge/sop` | `POST /api/knowledge/search`、`GET /api/knowledge/position-sop`、`POST /api/knowledge/doc/upload` | 已接入 MySQL Fulltext + Qdrant 混合召回、岗位学习适配摘要、OSS-first 文档上传、txt/md/PDF/Word/Excel/PPT 解析和 embedding 写入,失败回退 seed;`position-sop` 目前只返回一期生活顾问学习导航,不代表正式上岗资格 |
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
| 移动端手机号登录 | `GET /resource/sms/code`、`POST /auth/mobile/sms-login` | 已复用 sms4j 阿里云配置 `config1` 和 RuoYi `sms` 授权策略;短信发送成功后才写 Redis 验证码;手机号不存在时自动注册 `app_user`;`aihr.sms.dev-fixed-code` 非空时不真发短信、验证码固定(dev 默认 `123456`,prod profile 代码级强制失效) |
|
| 移动端手机号登录 | `GET /resource/sms/code`、`POST /auth/mobile/sms-login` | 已复用 sms4j 阿里云配置 `config1` 和 RuoYi `sms` 授权策略;短信发送成功后才写 Redis 验证码;手机号不存在时自动注册 `app_user`;`aihr.sms.dev-fixed-code` 非空时不真发短信、验证码固定(dev 默认 `123456`,prod profile 代码级强制失效) |
|
||||||
| 用户侧三端首页 `mobile-uni` hash 路由;旧 `/h5/user`、`/h5/candidate`、`/h5/supervisor` 兼容重定向 | `GET /api/aihr/mobile/home/{role}` | 已接入员工、候选人、主管首页公开只读 API;移动端本地 fallback 保演示 |
|
| 用户侧三端首页 `mobile-uni` hash 路由;旧 `/h5/user`、`/h5/candidate`、`/h5/supervisor` 兼容重定向 | `GET /api/aihr/mobile/home/{role}` | 已接入员工、候选人、主管首页公开只读 API;移动端本地 fallback 保演示 |
|
||||||
| 移动端登录后角色识别 | `GET /api/aihr/mobile/me` | 认证后按手机号匹配组织快照;`position_level` 为“主管/项目经理”时进入主管端,否则进入员工端;接口失败回退员工端 |
|
| 移动端登录后角色识别 | `GET /api/aihr/mobile/me` | 认证后按手机号匹配组织快照;`position_level` 为“主管/项目经理”时进入主管端,否则进入员工端;接口失败回退员工端 |
|
||||||
| 移动端员工训练与主管复盘闭环 | 员工复用 `POST /api/train/practice/start`、`/turn`、`/finish`,查询 `GET /api/aihr/mobile/practice/history`、`/practice/mistakes`、`/profile`;主管查询 `GET /api/aihr/mobile/practice/team`、`/practice/alerts`、`/practice/reviews`、`/practice/reviews/{id}`,标记 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed`,指派 `POST /api/aihr/mobile/practice/assignments` | 员工端登录后带 `Authorization` 与 `clientid` 调用;`mode=mobile` 完成后写入 `aihr_practice_session`。错题本按员工本人聚合低分/红线回合,并关联已有 `retry` assignment,不伪造错题结论。主管接口以后端当前登录手机号映射在职组织快照,仅允许岗位为“主管/项目经理”的账号,并按租户和项目范围返回真实成员、全状态训练记录及非 daily 专项;普通员工返回“无主管权限”。复盘标记只允许首次 `待复盘 -> 已复盘` 创建后续专项,并发重复提交幂等;可带 `incentivePoint` 写入贡献度 |
|
| 移动端员工训练与主管复盘闭环 | 员工复用 `POST /api/train/practice/start`、`/turn`、`/finish`,查询 `GET /api/aihr/mobile/practice/history`、`/practice/mistakes`、`/profile`;训练完成后提交 `POST /api/aihr/mobile/practice/satisfaction`;主管查询 `GET /api/aihr/mobile/practice/team`、`/practice/alerts`、`/practice/reviews`、`/practice/reviews/{id}`,标记 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed`,指派 `POST /api/aihr/mobile/practice/assignments` | 员工端登录后带 `Authorization` 与 `clientid` 调用;`mode=mobile` 完成后写入 `aihr_practice_session`。满意度接口只接受本人已完成训练的 1-5 分,意见脱敏后落库,未填写不补默认值。错题本按员工本人聚合低分/红线回合,并关联已有 `retry` assignment,不伪造错题结论。主管接口以后端当前登录手机号映射在职组织快照,仅允许岗位为“主管/项目经理”的账号,并按租户和项目范围返回真实成员、全状态训练记录及非 daily 专项;普通员工返回“无主管权限”。复盘标记只允许首次 `待复盘 -> 已复盘` 创建后续专项,并发重复提交幂等;可带 `incentivePoint` 写入贡献度 |
|
||||||
| 移动端候选人闭环 | 页面拆为 `/pages/candidate/index/index`、`/interview/index`、`/materials/index`、`/progress/index`、`/study/index`;面试复用 `POST /api/recruit/interview/start`、`/answer`、`/finish` 和 `GET /records`;资料 `POST/GET /api/aihr/mobile/candidate/materials`;预习 `POST /api/knowledge/search`;HR 审核 `GET /api/aihr/hr/candidate/materials`、`POST /api/aihr/hr/candidate/materials/{id}/review` | 候选人端登录后带 `Authorization` 与 `clientid` 调用;首页只按当前手机号对应的真实面试记录和最新资料状态分流,不读取公开 home seed。APP 候选人身份以后端登录手机号为准,前端 `candidateId/candidateName` 只作非 APP 场景兼容参数;面试拉题/评分走真实模型优先;资料写 `sys_oss`/MinIO 和 `aihr_candidate_material`,HR 审核后进度页同步三态;岗前预习查询正式 SOP,不兜前端示例答案 |
|
| 移动端候选人闭环 | 页面拆为 `/pages/candidate/index/index`、`/interview/index`、`/materials/index`、`/progress/index`、`/study/index`;面试复用 `POST /api/recruit/interview/start`、`/answer`、`/finish` 和 `GET /records`;资料 `POST/GET /api/aihr/mobile/candidate/materials`;预习 `POST /api/knowledge/search`;HR 审核 `GET /api/aihr/hr/candidate/materials`、`POST /api/aihr/hr/candidate/materials/{id}/review` | 候选人端登录后带 `Authorization` 与 `clientid` 调用;首页只按当前手机号对应的真实面试记录和最新资料状态分流,不读取公开 home seed。APP 候选人身份以后端登录手机号为准,前端 `candidateId/candidateName` 只作非 APP 场景兼容参数;面试拉题/评分走真实模型优先;资料写 `sys_oss`/MinIO 和 `aihr_candidate_material`,HR 审核后进度页同步三态;岗前预习查询正式 SOP,不兜前端示例答案 |
|
||||||
|
|
||||||
## 后端落点
|
## 后端落点
|
||||||
@@ -132,7 +132,7 @@ curl -fsS "$API_BASE/api/aihr/org/sync" \
|
|||||||
- 模型配置 API 文件:`frontend/src/api/aihr/model.ts`
|
- 模型配置 API 文件:`frontend/src/api/aihr/model.ts`
|
||||||
- 模型配置页面:`frontend/src/views/system/model/index.vue`
|
- 模型配置页面:`frontend/src/views/system/model/index.vue`
|
||||||
- 用户侧工程:`mobile-uni/`,路由为 `/h5/#/pages/user/today/index`、`/h5/#/pages/user/practice/index`、`/h5/#/pages/user/sop/index`、`/h5/#/pages/user/profile/index`、`/h5/#/pages/candidate/index/index`、`/h5/#/pages/supervisor/index/index`。未登录先走手机号短信登录,登录后 API 优先请求真实业务接口,不复用后台页面路由。语音输入不要新增移动端专用接口,浏览器录音和文件选择兜底都复用 `/api/ai/asr`。
|
- 用户侧工程:`mobile-uni/`,路由为 `/h5/#/pages/user/today/index`、`/h5/#/pages/user/practice/index`、`/h5/#/pages/user/sop/index`、`/h5/#/pages/user/profile/index`、`/h5/#/pages/candidate/index/index`、`/h5/#/pages/supervisor/index/index`。未登录先走手机号短信登录,登录后 API 优先请求真实业务接口,不复用后台页面路由。语音输入不要新增移动端专用接口,浏览器录音和文件选择兜底都复用 `/api/ai/asr`。
|
||||||
- 员工端训练:底部“练”进入 `/h5/#/pages/user/practice/index`,展示今日任务、每日三题、专项训练、训练前预习卡和训练对话;完成后记录写入 `aihr_practice_session`,员工端展示训练历史和能力画像,主管端展示待复盘列表,点进单条可看评分、话术、导师改写,并可标记“已复盘”。
|
- 员工端训练:底部“练”进入 `/h5/#/pages/user/practice/index`,展示今日任务、每日三题、专项训练、训练前预习卡和训练对话;完成后记录写入 `aihr_practice_session`,员工端可提交 1-5 分训练体验和可选意见,员工端展示训练历史和能力画像,主管端展示待复盘列表,点进单条可看评分、话术、导师改写,并可标记“已复盘”。
|
||||||
- 候选人端:`/h5/#/pages/candidate/index/index` 的“开始面试/面试练习”复用 AI 面试 API;“补充资料”走候选资料上传接口,成功后展示资料类型和审核状态。管理端 `/recruit/interview` 底部展示“候选资料审核”,支持通过/驳回。
|
- 候选人端:`/h5/#/pages/candidate/index/index` 的“开始面试/面试练习”复用 AI 面试 API;“补充资料”走候选资料上传接口,成功后展示资料类型和审核状态。管理端 `/recruit/interview` 底部展示“候选资料审核”,支持通过/驳回。
|
||||||
- 演示 fallback 只作为未配置模型或外部接口异常时的降级,不作为真实测试通过证据。
|
- 演示 fallback 只作为未配置模型或外部接口异常时的降级,不作为真实测试通过证据。
|
||||||
|
|
||||||
|
|||||||
@@ -25,9 +25,9 @@
|
|||||||
|
|
||||||
| 缺口 | BRD依据 | 当前事实 | 下一步 |
|
| 缺口 | BRD依据 | 当前事实 | 下一步 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 正式主管身份、项目范围和数据权限 | 5.4、L1-B、G3 | 代码已有 `aihr_org_snapshot`、岗位/项目过滤和主管接口;移动端角色查询失败已改为安全降级员工端,但正式上游快照的手机号关联仍不足,线上视觉测试不能替代真实主管身份验证 | 用正式组织同步 dry-run 先核对项目、岗位、`ext_party_id`、手机号;只在上游数据合规后执行正式同步 |
|
| 正式主管身份、项目范围和数据权限 | 5.4、L1-B、G3 | 代码已有 `aihr_org_snapshot`、岗位/项目过滤和主管接口;生产快照当前为 22 条 active、2 个项目,但手机号关联为 0/22;移动端角色查询失败已改为安全降级员工端,线上视觉测试不能替代真实主管身份验证 | 先用正式组织接口做 dry-run,核对项目、岗位、`ext_party_id`、手机号;补齐手机号映射并确认主管身份后,才执行正式同步和主管数据权限验收 |
|
||||||
| 管理端/后端生产发布证据 | 6.3、12.1、L1-C | 当前项目只定义了移动端 H5 的生产地址、远端目录和发布命令;没有可核实的管理端/后端生产 URL、发布脚本、备份位置或健康检查结果,因此 H5 上线不等于整套系统已发布 | 由运维确认管理端/后端生产入口、部署方式、回滚和健康检查,再单独完成管理端/后端发布验证;未确认前不声称全系统上线 |
|
| 管理端/后端生产发布证据 | 6.3、12.1、L1-C | 2026-07-14 已核实并发布:生产根站点 `https://peilian.njzhmj.top/`、Caddy `/prod-api`、systemd `wygj-aihr.service` 和后端 jar 均可核查;管理端静态包、后端 jar 已与本地构建 hash 对齐,租户接口和移动端首页 API 返回 200 | 发布证据已具备;后续仍需把正式发布命令、回滚脚本和生产部署凭据纳入运维交接,不能把“服务可访问”直接等同于正式试点通过 |
|
||||||
| M5 正式试点证据 | L1-B/C/D、G1/G3/G4 | 本地严格门禁和导出能力已存在;本地样本不能代表正式窗口,不能用 seed/烟测数据替代;当前没有正式满意度问卷/评分采集入口,因此无法证明“满意度 ≥4/5” | 固定 1–2 个住宅项目和窗口,按唯一在职身份采集训练、人工校准、SOP 评审、完训率和满意度;先确定问卷量表、采集对象和导出字段 |
|
| M5 正式试点证据 | L1-B/C/D、G1/G3/G4 | 已补员工训练后 1–5 分满意度和可选意见采集,意见脱敏后写入 `aihr_practice_session`,CSV/严格门禁可读满意度响应数与平均分;但本地样本不能代表正式窗口,仍不能用 seed/烟测数据替代,尚无法证明“满意度 ≥4/5” | 固定 1–2 个住宅项目和窗口,按唯一在职身份采集训练、人工校准、SOP 评审、完训率和满意度;严格门禁要求满意度有真实响应且平均分 ≥4.0/5 |
|
||||||
| AI 分数与人工复核边界 | 4.1、4.5、G1/G2 | 面试和对练已有人工复核/校准入口;正式试点 CSV 已补明细级 AI 分、人工校准分、校准人、校准时间和最终采用分;正式试点仍需用真实窗口验证人工可否决证据 | 在正式试点报告中抽查校准记录与最终采用值,确认人工分可覆盖 AI 建议且不直接挂钩绩效/晋升 |
|
| AI 分数与人工复核边界 | 4.1、4.5、G1/G2 | 面试和对练已有人工复核/校准入口;正式试点 CSV 已补明细级 AI 分、人工校准分、校准人、校准时间和最终采用分;正式试点仍需用真实窗口验证人工可否决证据 | 在正式试点报告中抽查校准记录与最终采用值,确认人工分可覆盖 AI 建议且不直接挂钩绩效/晋升 |
|
||||||
|
|
||||||
### P1:一期 BRD 功能仍未形成完整业务闭环
|
### P1:一期 BRD 功能仍未形成完整业务闭环
|
||||||
@@ -66,8 +66,12 @@
|
|||||||
|
|
||||||
## 6. 本轮线上核对记录(2026-07-14)
|
## 6. 本轮线上核对记录(2026-07-14)
|
||||||
|
|
||||||
- 线上入口 `https://peilian.njzhmj.top/h5/` 返回 `HTTP 200`;生产 HTML 的 `Last-Modified` 为 `2026-07-13 19:09`。
|
- 线上入口 `https://peilian.njzhmj.top/h5/` 返回 `HTTP 200`;本轮管理端发布后根站点 `https://peilian.njzhmj.top/` 也返回 `HTTP 200`,生产 HTML 的 `Last-Modified` 为 `2026-07-13 19:53:16 GMT`。
|
||||||
- 发布前线上 HTML 引用 `index-DSualpPE.js`;同步后已改为 `index-DJfe2tlN.js`,主 JS SHA-256 均为 `56afec70ae0f404a3e4a2520b604ea797dc7c1f2a16ce558e6652cea246b45a9`。
|
- 发布前线上 HTML 引用 `index-DSualpPE.js`;同步后已改为 `index-DJfe2tlN.js`,主 JS SHA-256 均为 `56afec70ae0f404a3e4a2520b604ea797dc7c1f2a16ce558e6652cea246b45a9`。
|
||||||
- 使用本地数据库已有手机号和 dev 固定验证码登录线上 H5,岗位确认、员工首页和 `/pages/user/practice/index` 均可打开;这证明线上登录/基础路由可用,不证明线上包已追平当前代码。
|
- 使用本地数据库已有手机号和 dev 固定验证码登录线上 H5,岗位确认、员工首页和 `/pages/user/practice/index` 均可打开;这证明线上登录/基础路由可用,不证明线上包已追平当前代码。
|
||||||
- 发布后真实浏览器登录并打开 `/pages/user/learning/index`,页面已出现“错题本”“全部/红线话术/情绪承接/流程合规/综合得分不足”;打开 `/pages/user/practice/index` 可见训练前预习、最近练过和每日三题。
|
- 发布后真实浏览器登录并打开 `/pages/user/learning/index`,页面已出现“错题本”“全部/红线话术/情绪承接/流程合规/综合得分不足”;打开 `/pages/user/practice/index` 可见训练前预习、最近练过和每日三题。
|
||||||
- 本次远端备份为 `/opt/wygj/backups/h5-20260714034906`;静态发布已完成,后续发布仍必须保留备份、资源哈希和浏览器回归三项证据。
|
- 本次远端备份为 `/opt/wygj/backups/h5-20260714034906`(H5)和 `/opt/wygj/backups/release-20260714035418`(管理端 index/assets 与后端 jar);静态包、后端服务和 H5 资源均已发布,后续发布仍必须保留备份、资源哈希和浏览器回归三项证据。
|
||||||
|
- 发布后 hash 证据:管理端主 JS `index-CVcvQ1KE.js` 为 `a49ad7d3009ea64f37922cc042f09c1930557052e4fe20c11f8083222e0e4531`,后端 `ruoyi-admin.jar` 为 `3998f075a06f3a8014c270f66ad777606c7a04117233b7258e3326dbe516e211`,H5 主 JS 仍为 `56afec70ae0f404a3e4a2520b604ea797dc7c1f2a16ce558e6652cea246b45a9`;线上 `wygj-aihr.service` 为 active,`/prod-api/auth/tenant/list` 与 `/prod-api/api/aihr/mobile/home/user` 返回 200。
|
||||||
|
- 生产浏览器回归:管理员可登录首页和 `/system/model`;页面可见“提示词模板”列表、新增模板入口和启停控件,说明本轮管理端 UI 已加载。当前验证使用生产 admin 测试账号和 Redis 验证码,不在文档记录账号或个人数据。
|
||||||
|
- 2026-07-14 04:05 后续发布:移动端满意度采集、CSV 指标和后端接口已同步到生产;备份为 `/opt/wygj/backups/release-20260714040515`,H5 主 JS `index-5mNjsnek.js` 本地与线上 SHA-256 均为 `5c5af11be7b2b3ef7ceafd5b820fdd3e1326fcfc612b89c69b00dfe06635573c`,后端 jar SHA-256 为 `aa0bd917a2ab94ffd740ac709af2c59dd5bed6c8db995fa9aa1e80bcc4662c07`;生产浏览器实际完成训练、提交 1–5 分满意度并收到成功提示,随后已清理临时训练和派发数据。
|
||||||
|
- 本次线上核对发现 schema 漂移:生产库已存在 `aihr_practice_session.satisfaction_score`、`satisfaction_comment`,但尚未显式创建 `aihr_practice_audio`。当前代码仍有运行时懒建表兜底,满意度链路不受影响;正式音频留痕试点前必须执行并核验显式迁移,不能依赖首次请求触发。
|
||||||
|
|||||||
@@ -193,6 +193,40 @@
|
|||||||
<text>{{ result.mentorRewrite }}</text>
|
<text>{{ result.mentorRewrite }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view v-if="result" class="panel satisfaction-card employee-card">
|
||||||
|
<view class="satisfaction-head">
|
||||||
|
<text class="panel-title">本次训练体验如何?</text>
|
||||||
|
<text class="muted">帮助我们改进训练内容</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="!satisfactionSubmitted" class="satisfaction-options">
|
||||||
|
<button
|
||||||
|
v-for="score in satisfactionOptions"
|
||||||
|
:key="score"
|
||||||
|
class="satisfaction-option"
|
||||||
|
:class="{ selected: satisfactionScore === score }"
|
||||||
|
@click="satisfactionScore = score"
|
||||||
|
>
|
||||||
|
{{ score }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<textarea
|
||||||
|
v-if="!satisfactionSubmitted"
|
||||||
|
v-model="satisfactionComment"
|
||||||
|
class="textarea satisfaction-textarea"
|
||||||
|
maxlength="500"
|
||||||
|
placeholder="可选:写下你希望改进的地方"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="!satisfactionSubmitted"
|
||||||
|
class="primary-button small-button"
|
||||||
|
:disabled="satisfactionBusy || satisfactionScore === 0"
|
||||||
|
@click="sendSatisfaction"
|
||||||
|
>
|
||||||
|
{{ satisfactionBusy ? '提交中' : '提交体验反馈' }}
|
||||||
|
</button>
|
||||||
|
<text v-else class="satisfaction-thanks">感谢反馈,已记录本次训练体验。</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -207,7 +241,7 @@ import type {
|
|||||||
PracticeTurn,
|
PracticeTurn,
|
||||||
PracticeRecord
|
PracticeRecord
|
||||||
} from '@/types/api';
|
} from '@/types/api';
|
||||||
import { answerDailyDrill, finishPractice, getPracticeAssignments, getPracticeHistory, getPracticePrepCard, startPractice, submitPracticeTurn } from '@/services/practice';
|
import { answerDailyDrill, finishPractice, getPracticeAssignments, getPracticeHistory, getPracticePrepCard, startPractice, submitPracticeSatisfaction, submitPracticeTurn } from '@/services/practice';
|
||||||
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||||
import { searchKnowledge } from '@/services/knowledge';
|
import { searchKnowledge } from '@/services/knowledge';
|
||||||
import { resetPageScroll } from '@/services/navigation';
|
import { resetPageScroll } from '@/services/navigation';
|
||||||
@@ -234,6 +268,11 @@ const draft = ref('');
|
|||||||
const message = ref('');
|
const message = ref('');
|
||||||
const turns = ref<PracticeTurn[]>([]);
|
const turns = ref<PracticeTurn[]>([]);
|
||||||
const result = ref<PracticeFinishResponse | null>(null);
|
const result = ref<PracticeFinishResponse | null>(null);
|
||||||
|
const satisfactionOptions = [1, 2, 3, 4, 5];
|
||||||
|
const satisfactionScore = ref(0);
|
||||||
|
const satisfactionComment = ref('');
|
||||||
|
const satisfactionBusy = ref(false);
|
||||||
|
const satisfactionSubmitted = ref(false);
|
||||||
const selectedScenarioId = ref(defaultScenarioId);
|
const selectedScenarioId = ref(defaultScenarioId);
|
||||||
const prepCard = ref<PracticePrepCard | null>(null);
|
const prepCard = ref<PracticePrepCard | null>(null);
|
||||||
const prepLoading = ref(false);
|
const prepLoading = ref(false);
|
||||||
@@ -324,6 +363,10 @@ const clearPracticeData = () => {
|
|||||||
prepLoading.value = false;
|
prepLoading.value = false;
|
||||||
turns.value = [];
|
turns.value = [];
|
||||||
result.value = null;
|
result.value = null;
|
||||||
|
satisfactionScore.value = 0;
|
||||||
|
satisfactionComment.value = '';
|
||||||
|
satisfactionBusy.value = false;
|
||||||
|
satisfactionSubmitted.value = false;
|
||||||
sessionId.value = '';
|
sessionId.value = '';
|
||||||
roundIndex.value = 0;
|
roundIndex.value = 0;
|
||||||
draft.value = '';
|
draft.value = '';
|
||||||
@@ -591,6 +634,9 @@ const start = async (scenarioId = defaultScenarioId, assignmentId?: number) => {
|
|||||||
status.value = 'starting';
|
status.value = 'starting';
|
||||||
message.value = '';
|
message.value = '';
|
||||||
result.value = null;
|
result.value = null;
|
||||||
|
satisfactionScore.value = 0;
|
||||||
|
satisfactionComment.value = '';
|
||||||
|
satisfactionSubmitted.value = false;
|
||||||
turns.value = [];
|
turns.value = [];
|
||||||
draft.value = '';
|
draft.value = '';
|
||||||
clearDraftAudio();
|
clearDraftAudio();
|
||||||
@@ -756,6 +802,20 @@ const finish = async (force = false) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sendSatisfaction = async () => {
|
||||||
|
if (!sessionId.value || satisfactionScore.value < 1 || satisfactionScore.value > 5 || satisfactionBusy.value) return;
|
||||||
|
satisfactionBusy.value = true;
|
||||||
|
message.value = '';
|
||||||
|
try {
|
||||||
|
await submitPracticeSatisfaction(sessionId.value, satisfactionScore.value, satisfactionComment.value.trim());
|
||||||
|
satisfactionSubmitted.value = true;
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, '体验反馈提交失败');
|
||||||
|
} finally {
|
||||||
|
satisfactionBusy.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleShow = async () => {
|
const handleShow = async () => {
|
||||||
resetPageScroll();
|
resetPageScroll();
|
||||||
refreshAuthState();
|
refreshAuthState();
|
||||||
@@ -852,7 +912,8 @@ onShow(() => {
|
|||||||
.dialogue-card,
|
.dialogue-card,
|
||||||
.reply-card,
|
.reply-card,
|
||||||
.help-card,
|
.help-card,
|
||||||
.result-card {
|
.result-card,
|
||||||
|
.satisfaction-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
@@ -1285,6 +1346,45 @@ onShow(() => {
|
|||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.satisfaction-head {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.satisfaction-options {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.satisfaction-option {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid #d8e1ef;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #5a6270;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.satisfaction-option.selected {
|
||||||
|
border-color: #2f6bff;
|
||||||
|
background: #eef4ff;
|
||||||
|
color: #2f6bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.satisfaction-textarea {
|
||||||
|
min-height: 76px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.satisfaction-thanks {
|
||||||
|
color: #168243;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
/* 练页入口态:与员工端蓝白轻卡片体系保持一致 */
|
/* 练页入口态:与员工端蓝白轻卡片体系保持一致 */
|
||||||
.practice-page {
|
.practice-page {
|
||||||
padding: 0 16px 108px;
|
padding: 0 16px 108px;
|
||||||
|
|||||||
@@ -73,6 +73,14 @@ export const finishPractice = (sessionId: string, assignmentId?: number) =>
|
|||||||
timeout: 45000
|
timeout: 45000
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const submitPracticeSatisfaction = (sessionId: string, score: number, comment = '') =>
|
||||||
|
apiRequest<{ sessionId: string; score: number; comment: string; submittedAt: string }>({
|
||||||
|
url: '/api/aihr/mobile/practice/satisfaction',
|
||||||
|
method: 'POST',
|
||||||
|
data: { sessionId, score, comment },
|
||||||
|
timeout: 15000
|
||||||
|
});
|
||||||
|
|
||||||
export const getPracticeHistory = () => {
|
export const getPracticeHistory = () => {
|
||||||
const extPartyId = encodeURIComponent(currentExtPartyId());
|
const extPartyId = encodeURIComponent(currentExtPartyId());
|
||||||
return apiRequest<PracticeRecord[]>({
|
return apiRequest<PracticeRecord[]>({
|
||||||
|
|||||||
+10
-2
@@ -80,7 +80,7 @@ check_pilot_samples() {
|
|||||||
[[ "$pilot_start" > "$pilot_end" ]] && fail "pilot start date must not be after end date"
|
[[ "$pilot_start" > "$pilot_end" ]] && fail "pilot start date must not be after end date"
|
||||||
|
|
||||||
local scenario_count completed_people pilot_people org_active org_phone_linked org_pilot_people org_pilot_projects org_completed_people
|
local scenario_count completed_people pilot_people org_active org_phone_linked org_pilot_people org_pilot_projects org_completed_people
|
||||||
local calibration_stats calibration_count calibration_matched sop_total sop_usable sop_pending
|
local calibration_stats calibration_count calibration_matched sop_total sop_usable sop_pending satisfaction_count satisfaction_average
|
||||||
local org_identity_cte="
|
local org_identity_cte="
|
||||||
WITH active_org AS (
|
WITH active_org AS (
|
||||||
SELECT DISTINCT ext_party_id, project_code, person_phone
|
SELECT DISTINCT ext_party_id, project_code, person_phone
|
||||||
@@ -241,7 +241,9 @@ check_pilot_samples() {
|
|||||||
sop_total="$(pilot_sql_scalar "SELECT COUNT(*) FROM aihr_sop_answer_review WHERE tenant_id = '000000' AND status = '已评审' AND reviewed_time >= '${pilot_start} 00:00:00' AND reviewed_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)")"
|
sop_total="$(pilot_sql_scalar "SELECT COUNT(*) FROM aihr_sop_answer_review WHERE tenant_id = '000000' AND status = '已评审' AND reviewed_time >= '${pilot_start} 00:00:00' AND reviewed_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)")"
|
||||||
sop_usable="$(pilot_sql_scalar "SELECT COALESCE(SUM(CASE WHEN usable = 1 THEN 1 ELSE 0 END), 0) FROM aihr_sop_answer_review WHERE tenant_id = '000000' AND status = '已评审' AND reviewed_time >= '${pilot_start} 00:00:00' AND reviewed_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)")"
|
sop_usable="$(pilot_sql_scalar "SELECT COALESCE(SUM(CASE WHEN usable = 1 THEN 1 ELSE 0 END), 0) FROM aihr_sop_answer_review WHERE tenant_id = '000000' AND status = '已评审' AND reviewed_time >= '${pilot_start} 00:00:00' AND reviewed_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)")"
|
||||||
sop_pending="$(pilot_sql_scalar "SELECT COUNT(*) FROM aihr_sop_answer_review WHERE tenant_id = '000000' AND status <> '已评审' AND create_time >= '${pilot_start} 00:00:00' AND create_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)")"
|
sop_pending="$(pilot_sql_scalar "SELECT COUNT(*) FROM aihr_sop_answer_review WHERE tenant_id = '000000' AND status <> '已评审' AND create_time >= '${pilot_start} 00:00:00' AND create_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)")"
|
||||||
echo "OK: pilot gates window=${pilot_start}..${pilot_end} scenarios=${scenario_count}/12 ten_sessions=${completed_people}/${pilot_people} org_login=${org_phone_linked}/${org_active} org_pilot=${org_pilot_people}/20 projects=${org_pilot_projects}/1-2 org_ten_sessions=${org_completed_people}/${org_pilot_people} calibration=${calibration_count}/20 consistency=${calibration_matched}/${calibration_count} sop_usable=${sop_usable}/${sop_total} sop_pending=${sop_pending}"
|
satisfaction_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM aihr_practice_session s WHERE s.tenant_id = '000000' AND s.mode = 'mobile' AND s.finished_time >= '${pilot_start} 00:00:00' AND s.finished_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY) AND s.satisfaction_score IS NOT NULL AND EXISTS (SELECT 1 FROM aihr_org_snapshot o WHERE o.tenant_id = s.tenant_id AND o.employment_status = 'active' AND (o.ext_party_id = s.ext_party_id OR o.person_phone = s.ext_party_id))")"
|
||||||
|
satisfaction_average="$(pilot_sql_scalar "SELECT COALESCE(AVG(s.satisfaction_score), 0) FROM aihr_practice_session s WHERE s.tenant_id = '000000' AND s.mode = 'mobile' AND s.finished_time >= '${pilot_start} 00:00:00' AND s.finished_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY) AND s.satisfaction_score IS NOT NULL AND EXISTS (SELECT 1 FROM aihr_org_snapshot o WHERE o.tenant_id = s.tenant_id AND o.employment_status = 'active' AND (o.ext_party_id = s.ext_party_id OR o.person_phone = s.ext_party_id))")"
|
||||||
|
echo "OK: pilot gates window=${pilot_start}..${pilot_end} scenarios=${scenario_count}/12 ten_sessions=${completed_people}/${pilot_people} org_login=${org_phone_linked}/${org_active} org_pilot=${org_pilot_people}/20 projects=${org_pilot_projects}/1-2 org_ten_sessions=${org_completed_people}/${org_pilot_people} calibration=${calibration_count}/20 consistency=${calibration_matched}/${calibration_count} sop_usable=${sop_usable}/${sop_total} sop_pending=${sop_pending} satisfaction=${satisfaction_average}/5 responses=${satisfaction_count}"
|
||||||
|
|
||||||
[[ "${AIHR_PILOT_STRICT:-false}" != "true" ]] || {
|
[[ "${AIHR_PILOT_STRICT:-false}" != "true" ]] || {
|
||||||
(( scenario_count >= 12 )) || fail "pilot enabled scenarios ${scenario_count}/12"
|
(( scenario_count >= 12 )) || fail "pilot enabled scenarios ${scenario_count}/12"
|
||||||
@@ -253,6 +255,8 @@ check_pilot_samples() {
|
|||||||
(( calibration_matched * 100 >= calibration_count * 70 )) || fail "pilot AI/human band consistency below 70%"
|
(( calibration_matched * 100 >= calibration_count * 70 )) || fail "pilot AI/human band consistency below 70%"
|
||||||
(( sop_total > 0 )) || fail "pilot SOP review samples missing"
|
(( sop_total > 0 )) || fail "pilot SOP review samples missing"
|
||||||
(( sop_usable * 100 >= sop_total * 80 )) || fail "pilot SOP usable rate below 80%"
|
(( sop_usable * 100 >= sop_total * 80 )) || fail "pilot SOP usable rate below 80%"
|
||||||
|
(( satisfaction_count > 0 )) || fail "pilot satisfaction responses missing"
|
||||||
|
awk -v average="$satisfaction_average" 'BEGIN { exit !(average >= 4.0) }' || fail "pilot satisfaction average below 4/5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,6 +365,10 @@ contains mobile-uni/src/pages/user/practice/index.vue "loadHelp"
|
|||||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "turnEvidence"
|
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "turnEvidence"
|
||||||
contains mobile-uni/src/pages/user/practice/index.vue "每日三题"
|
contains mobile-uni/src/pages/user/practice/index.vue "每日三题"
|
||||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "pilot_metric_ready"
|
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "pilot_metric_ready"
|
||||||
|
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "/practice/satisfaction"
|
||||||
|
contains backend/script/sql/aihr_practice_mysql8.sql "satisfaction_score"
|
||||||
|
contains mobile-uni/src/services/practice.ts "/api/aihr/mobile/practice/satisfaction"
|
||||||
|
contains mobile-uni/src/pages/user/practice/index.vue "本次训练体验如何"
|
||||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "incentive_point"
|
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "incentive_point"
|
||||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java "cost-guard-fallback"
|
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java "cost-guard-fallback"
|
||||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSpeechService.java "Duration.ofSeconds(5)"
|
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSpeechService.java "Duration.ofSeconds(5)"
|
||||||
|
|||||||
Reference in New Issue
Block a user