feat(aihr): add supervisor mistake aggregation

This commit is contained in:
2026-07-14 04:27:10 +08:00
parent 37ddb307cf
commit cd2c7fdd45
8 changed files with 185 additions and 7 deletions
@@ -162,6 +162,17 @@ public final class AihrPracticeDto {
) {
}
public record MistakeAggregateResponse(
String scenarioId,
String scenarioName,
String cause,
Integer count,
Integer affectedPeople,
Integer averageScore,
String latestTime
) {
}
public record DialogueResponse(String role, String label, String text, String audioUrl, Long audioOssId, Integer emotion, Integer trust, Boolean redFlag, String coachHint) {
public DialogueResponse(String role, String label, String text) {
this(role, label, text, "", null, null, null, null, "");
@@ -260,7 +271,8 @@ public final class AihrPracticeDto {
public record PracticeTeamResponse(
List<TeamMemberResponse> members,
List<RecordResponse> records,
List<PracticeAssignmentResponse> assignments
List<PracticeAssignmentResponse> assignments,
List<MistakeAggregateResponse> mistakes
) {
}
@@ -26,6 +26,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse;
import org.dromara.aihr.domain.AihrPracticeDto.MistakeResponse;
import org.dromara.aihr.domain.AihrPracticeDto.MistakeAggregateResponse;
import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest;
import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RubricDimensionResponse;
@@ -799,12 +800,13 @@ public class AihrPracticeSeedService {
TeamScope scope = teamScope(supervisorExtPartyId);
int safeLimit = normalizeTeamLimit(limit);
if (scope.scoped() && scope.extPartyIds().isEmpty()) {
return new PracticeTeamResponse(List.of(), List.of(), List.of());
return new PracticeTeamResponse(List.of(), List.of(), List.of(), List.of());
}
return new PracticeTeamResponse(
practiceTeamMembers(scope),
practiceTeamRecords(scope, safeLimit),
practiceTeamAssignments(scope, safeLimit)
practiceTeamAssignments(scope, safeLimit),
practiceTeamMistakes(scope, safeLimit)
);
}
@@ -920,6 +922,57 @@ public class AihrPracticeSeedService {
return jdbcTemplate.query(sql, this::mapAssignment, args.toArray());
}
private List<MistakeAggregateResponse> practiceTeamMistakes(TeamScope scope, int limit) {
ensurePracticeTable();
List<Object> args = new ArrayList<>();
args.add(TENANT_ID);
StringBuilder where = new StringBuilder("WHERE tenant_id = ? AND mode = 'mobile' AND finished_time IS NOT NULL");
if (scope.scoped()) {
where.append(" AND ext_party_id IN (").append(inClause(scope.extPartyIds().size())).append(")");
args.addAll(scope.extPartyIds());
}
args.add(limit);
String sql = """
SELECT scenario_id,
scenario_name,
cause,
COUNT(*) AS mistake_count,
COUNT(DISTINCT NULLIF(ext_party_id, '')) AS affected_people,
ROUND(AVG(COALESCE(total_score, 0))) AS average_score,
MAX(finished_time) AS latest_time
FROM (
SELECT scenario_id,
COALESCE(NULLIF(scenario_name, ''), '场景训练') AS scenario_name,
ext_party_id,
total_score,
finished_time,
CASE
WHEN dialogue_json LIKE '%%"redFlag":true%%' THEN '红线话术'
WHEN COALESCE(dim_emotion, 100) < 60 THEN '情绪承接'
WHEN COALESCE(dim_compliance, 100) < 60 THEN '流程合规'
ELSE '综合得分不足'
END AS cause
FROM aihr_practice_session
%s
AND (total_score < 80 OR dialogue_json LIKE '%%"redFlag":true%%')
) mistake_rows
GROUP BY scenario_id, scenario_name, cause
ORDER BY mistake_count DESC, latest_time DESC, scenario_name, cause
LIMIT ?
""".formatted(where);
return jdbcTemplate.query(sql, (rs, rowNum) -> new MistakeAggregateResponse(
rs.getString("scenario_id"),
firstNonBlank(rs.getString("scenario_name"), "场景训练"),
firstNonBlank(rs.getString("cause"), "综合得分不足"),
rs.getInt("mistake_count"),
rs.getInt("affected_people"),
rs.getInt("average_score"),
rs.getTimestamp("latest_time") == null
? ""
: rs.getTimestamp("latest_time").toLocalDateTime().format(TIME_FORMATTER)
), args.toArray());
}
public List<RecordResponse> pendingReviewRecords(int limit) {
return pendingReviewRecords("", "", "", limit);
}
+1 -1
View File
@@ -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 代码级强制失效) |
| 用户侧三端首页 `mobile-uni` hash 路由;旧 `/h5/user`、`/h5/candidate`、`/h5/supervisor` 兼容重定向 | `GET /api/aihr/mobile/home/{role}` | 已接入员工、候选人、主管首页公开只读 API;移动端本地 fallback 保演示 |
| 移动端登录后角色识别 | `GET /api/aihr/mobile/me` | 认证后按手机号匹配组织快照;`position_level` 为“主管/项目经理”时进入主管端,否则进入员工端;接口失败回退员工端 |
| 移动端员工训练与主管复盘闭环 | 员工复用 `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` 写入贡献度 |
| 移动端员工训练与主管复盘闭环 | 员工复用 `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,不伪造错题结论。主管 `/practice/team` 在同一项目权限范围内额外返回 `mistakes` 聚合,按场景/归因统计次数、影响人数、平均分和最近发生时间;普通员工返回“无主管权限”。主管接口以后端当前登录手机号映射在职组织快照,仅允许岗位为“主管/项目经理”的账号,并按租户和项目范围返回真实成员、全状态训练记录及非 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,不兜前端示例答案 |
## 后端落点
+1 -1
View File
@@ -36,7 +36,7 @@
|---|---|---|---|
| 岗位-SOP匹配与上岗资格 gating | 3.1、4.2、TechSpec P1 | 已补 `GET /api/knowledge/position-sop` 和员工学习页状态提示:一期只确认生活顾问内置学习导航;尚无 `sop_applicability`、`onboard_task`、`qualification_gate` 正式数据闭环,不能作为上岗资格判断 | 先接正式岗位/项目与 SOP 适用范围数据,再补培训任务状态;资格 gating 必须由 HR 明确规则和证据来源后实现 |
| 认证等级与激励规则可配置 | 4.5、10.2-A、TechSpec M6 | 成长激励页已有初/中/高级证据包和学分口径展示,但规则仍是前端硬编码;原“排行榜”开关无实际业务作用,已移除并明确标记只读;尚无 `cert_rule`/`incentive_rule` 的持久化、版本、角色权限和生效记录 | 先由 HR 确认学分来源、分值、防刷和初/中/高级条件,再设计规则表与管理员 CRUD;在规则契约明确前,不把静态展示称为可配置 |
| 智能错题本 | 3.1、4.4、TechSpec P1 | 已补 `/api/aihr/mobile/practice/mistakes` 和员工“学”页错题本:按本人聚合低分/红线回合,展示原因、分数、再练状态,支持按现有归因筛选,并可跳已有 retry assignment;尚未做独立错题表和主管侧聚合 | 后续复用现有 session/assignment/annotation 补复盘详情关联和主管侧聚合,不新增独立错题事实表 |
| 智能错题本 | 3.1、4.4、TechSpec P1 | 已补员工本人错题本和主管 `/api/aihr/mobile/practice/team` 内的 `mistakes` 聚合:按当前项目权限统计场景、归因、次数、影响人数、平均分和最近发生时间,员工端仍可跳已有 retry assignment;不新增独立错题事实表 | 继续用正式组织快照和试点窗口验证主管只看到本项目数据;后续可在真实错题量上来后补趋势/导出,不把本地演示样本当正式指标 |
| 案例学习/视频详情 | 3.1、4.6、矩阵第35行 | `AihrCaseService` 当前用内存 `CaseState` + `aihr_case_record` 保存转写/摘要,没有稳定 media URL、学习点、主管点评和案例详情 API;仓库也没有可用于一期验收的预渲染样片资产 | 先由业务提供样片或正式媒体 URL,并确定案例详情、学习点、主管点评字段,再扩持久化契约和权限;没有真实媒体 URL 时不造播放按钮 |
| 案例库质量与数量 | L1-B、4.6/4.7 | 上传/整理/筛选路径已有;“至少20条脱敏案例”和每周每项目一条需要正式数据证明,当前也没有可用于一期样片验收的真实视频资产 | 先按项目窗口采集并脱敏检查,再补案例统计/学习详情导出;不使用空壳视频或演示 seed 冒充 |
| RAG 可用率与知识治理 | 4.7、L1-C、G3 | 搜索、引用、反馈、知识缺口和人工评审入口已有;本轮已让员工反馈保存同一次搜索的 `reviewId`,但尚未保存模型/提示词答案版本;住宅核心流程覆盖率与可用率仍需正式评审数据,不能宣称完成逐问答全链路追溯 | 下一步由业务确认答案版本口径,再把 `prompt_version`/检索批次纳入评审批次;先用当前 `reviewId + query + fragment` 做可追溯统计,不伪造模型版本 |
+1 -1
View File
@@ -96,7 +96,7 @@
| 44 | 结论/行动项结果 | `87:815` | — | 未实现 | 未修正,阶段二 |
| 45 | 摘要/可编辑文字生成 | `87:865` | — | 未实现 | 未修正,阶段二 |
| 46 | 员工工作量化看板 | `89:9` | `frontend/src/views/competency/growth.vue` | 按二期计划已实现;按 Figma 部分对齐 | 已有训练时长、贡献度、测评分、等级、证据包、预警和派发;完整三期量化指标暂不补 |
| 47 | 主管团队量化看板 | `89:776` | `mobile-uni/src/pages/supervisor/team/index.vue` | 按二期计划已实现;按 Figma 部分对齐 | 已有团队成员、风险、训练记录和成长证据;完整团队经营指标需正式组织数据 |
| 47 | 主管团队量化看板 | `89:776` | `mobile-uni/src/pages/supervisor/team/index.vue` | 按二期计划已实现;按 Figma 部分对齐 | 已有团队成员、风险、训练记录、成长证据和按项目范围聚合的团队错题本;完整团队经营指标仍需正式组织数据 |
| 48 | HR/运营绩效看板 | `90:2` | — | 未实现 | 未修正,三期 |
| 49 | 工作积分排行榜 | `90:91` | — | 未实现 | 未修正,三期 |
| 50 | 等级/晋升条件/进度 | `90:867` | `frontend/src/views/competency/growth.vue` | 按二期计划已实现;按 Figma 部分对齐 | 已有初/中/高级成长路径和评级证据包;正式晋升申请/审核属于后续范围 |
+100 -1
View File
@@ -46,6 +46,27 @@
</view>
</view>
<view class="panel mistake-panel">
<view class="section-head">
<text class="panel-title">团队错题本</text>
<text class="muted">按项目范围聚合</text>
</view>
<view v-if="mistakes.length" class="mistake-list">
<view v-for="item in mistakes" :key="`${item.scenarioId}-${item.cause}`" class="mistake-row">
<view class="mistake-main">
<view class="mistake-title-row">
<text class="mistake-title">{{ item.scenarioName }}</text>
<text class="mistake-count">{{ item.count }} 次</text>
</view>
<text class="mistake-cause">{{ item.cause }} · {{ item.affectedPeople }} 名成员 · 平均 {{ item.averageScore }} 分</text>
<text class="mistake-time">最近发生 {{ item.latestTime || '暂无时间' }}</text>
</view>
<button class="secondary-button mistake-action" @click="openReviews">去待复盘</button>
</view>
</view>
<view v-else class="empty compact-empty">暂无团队错题记录</view>
</view>
<view class="panel team-panel">
<view class="section-head">
<text class="panel-title">成员列表</text>
@@ -120,7 +141,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { PracticeAssignment, PracticeRecord, PracticeTeamMember } from '@/types/api';
import type { PracticeAssignment, PracticeMistakeAggregate, PracticeRecord, PracticeTeamMember } from '@/types/api';
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
import { getPracticeTeamSnapshot } from '@/services/practice';
@@ -136,6 +157,7 @@ const currentPath = '/pages/supervisor/team/index';
const records = ref<PracticeRecord[]>([]);
const assignments = ref<PracticeAssignment[]>([]);
const members = ref<PracticeTeamMember[]>([]);
const mistakes = ref<PracticeMistakeAggregate[]>([]);
const loggedIn = ref(false);
const accessDenied = ref(false);
const loading = ref(false);
@@ -161,6 +183,7 @@ const clearData = () => {
records.value = [];
assignments.value = [];
members.value = [];
mistakes.value = [];
selectedMember.value = null;
};
@@ -201,6 +224,7 @@ const loadPage = async () => {
records.value = snapshot.records;
assignments.value = snapshot.assignments;
members.value = snapshot.members;
mistakes.value = snapshot.mistakes ?? [];
} catch (error) {
handleRequestError(error, '团队画像加载失败');
} finally {
@@ -223,6 +247,10 @@ const selectMember = (member: TeamProfile) => {
selectedMember.value = selectedMember.value?.key === member.key ? null : member;
};
const openReviews = () => {
uni.navigateTo({ url: '/pages/supervisor/reviews/index' });
};
const goLogin = () => {
rememberLoginRedirect(currentPath);
uni.redirectTo({ url: '/pages/auth/login/index' });
@@ -340,6 +368,77 @@ onShow(() => {
color: var(--employee-primary);
}
.mistake-panel {
margin-top: 14px;
}
.mistake-list {
margin-top: 8px;
}
.mistake-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 12px 0;
border-bottom: 1px solid #e5edf7;
}
.mistake-row:last-child {
border-bottom: 0;
}
.mistake-main {
min-width: 0;
flex: 1;
}
.mistake-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.mistake-title,
.mistake-cause,
.mistake-time {
display: block;
}
.mistake-title {
overflow: hidden;
color: var(--employee-text);
font-size: 14px;
font-weight: 900;
text-overflow: ellipsis;
white-space: nowrap;
}
.mistake-count {
flex: 0 0 auto;
color: #b91c1c;
font-size: 13px;
font-weight: 900;
}
.mistake-cause,
.mistake-time {
margin-top: 5px;
color: var(--employee-muted);
font-size: 11px;
line-height: 1.4;
}
.mistake-action {
min-width: 64px;
min-height: 34px;
flex: 0 0 auto;
padding: 0 8px;
font-size: 12px;
}
.team-list {
margin-top: 8px;
}
+11
View File
@@ -224,6 +224,16 @@ export interface PracticeMistake {
summary: string;
}
export interface PracticeMistakeAggregate {
scenarioId: string;
scenarioName: string;
cause: string;
count: number;
affectedPeople: number;
averageScore: number;
latestTime: string;
}
export interface PracticeTeamMember {
extPartyId: string;
name: string;
@@ -233,6 +243,7 @@ export interface PracticeTeamSnapshot {
members: PracticeTeamMember[];
records: PracticeRecord[];
assignments: PracticeAssignment[];
mistakes: PracticeMistakeAggregate[];
}
export interface ReviewDialogue {
+3
View File
@@ -340,6 +340,9 @@ contains frontend/src/api/aihr/practice.ts "/api/aihr/prompt-templates"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java "/api/train/practice"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPromptTemplateController.java "/api/aihr/prompt-templates"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "mobileCompletedCount"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java "MistakeAggregateResponse"
contains mobile-uni/src/pages/supervisor/team/index.vue "团队错题本"
contains mobile-uni/src/types/api.ts "PracticeMistakeAggregate"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "/practice/mistakes"
contains mobile-uni/src/pages/user/learning/index.vue "错题本"
contains mobile-uni/src/pages/user/learning/index.vue "mistakeFilters"