feat(aihr): expose position SOP learning scope

This commit is contained in:
2026-07-14 03:29:59 +08:00
parent d98ff0ff8b
commit f9bce8f0ef
10 changed files with 151 additions and 4 deletions
@@ -9,6 +9,7 @@ import org.dromara.aihr.domain.AihrSopDto.LocalImportRequest;
import org.dromara.aihr.domain.AihrSopDto.LocalImportResponse;
import org.dromara.aihr.domain.AihrSopDto.LocalImportTaskResponse;
import org.dromara.aihr.domain.AihrSopDto.ProcessingOverviewResponse;
import org.dromara.aihr.domain.AihrSopDto.PositionSopResponse;
import org.dromara.aihr.domain.AihrSopDto.SearchRequest;
import org.dromara.aihr.domain.AihrSopDto.SearchResponse;
import org.dromara.aihr.domain.AihrSopDto.SopReviewRequest;
@@ -69,6 +70,12 @@ public class AihrSopController {
return R.ok(sopSeedService.search(request));
}
@GetMapping("/position-sop")
public R<PositionSopResponse> positionSop(@RequestParam(required = false) String position,
@RequestParam(required = false) String projectType) {
return R.ok(sopSeedService.positionSop(position, projectType));
}
@PostMapping("/answer-feedback")
public R<AnswerFeedbackResponse> answerFeedback(@RequestBody AnswerFeedbackRequest request) {
return R.ok(sopSeedService.answerFeedback(request));
@@ -10,6 +10,25 @@ public final class AihrSopDto {
public record SearchRequest(String queryText, String category, String position, String source, Integer limit) {
}
public record PositionSopResponse(
String position,
String projectType,
String routingMode,
Boolean formalApplicabilityConfigured,
String qualificationStatus,
String message,
List<PositionSopTopicResponse> topics
) {
}
public record PositionSopTopicResponse(
String category,
String question,
String reference,
String status
) {
}
public record SummaryCardRequest(String queryText, String category) {
}
@@ -19,6 +19,8 @@ import org.dromara.aihr.domain.AihrSopDto.LocalImportResponse;
import org.dromara.aihr.domain.AihrSopDto.LocalImportTaskResponse;
import org.dromara.aihr.domain.AihrSopDto.MetricResponse;
import org.dromara.aihr.domain.AihrSopDto.PipelineStepResponse;
import org.dromara.aihr.domain.AihrSopDto.PositionSopResponse;
import org.dromara.aihr.domain.AihrSopDto.PositionSopTopicResponse;
import org.dromara.aihr.domain.AihrSopDto.ProcessingEventResponse;
import org.dromara.aihr.domain.AihrSopDto.ProcessingOverviewResponse;
import org.dromara.aihr.domain.AihrSopDto.ProcessingRulesResponse;
@@ -156,6 +158,38 @@ public class AihrSopSeedService {
return withReviewId(noEvidenceResponse(queryText, category), source);
}
/**
* 一期只提供岗位学习导航摘要,不把它冒充成正式的 SOP 适用范围或上岗资格判断。
* 正式匹配需要 position_responsibility/sop_applicability 和组织项目数据落库后再开启。
*/
public PositionSopResponse positionSop(String requestedPosition, String projectType) {
String position = firstNonBlank(requestedPosition, "生活顾问");
String effectiveProjectType = firstNonBlank(projectType, "住宅");
if (!"生活顾问".equals(position)) {
return new PositionSopResponse(
position,
effectiveProjectType,
"pilot-seed-navigation",
false,
"NOT_CONFIGURED",
"当前一期只开放生活顾问试点,当前岗位尚未配置正式 SOP 适用范围。",
List.of()
);
}
List<PositionSopTopicResponse> topics = seeds.values().stream()
.map(seed -> new PositionSopTopicResponse(seed.category(), seed.question(), seed.reference(), "可学习"))
.toList();
return new PositionSopResponse(
position,
effectiveProjectType,
"pilot-seed-navigation",
false,
"NOT_CONFIGURED",
"当前为生活顾问一期学习导航;正式上岗资格仍需组织身份、SOP 适用范围和资格规则配置。",
topics
);
}
public SopReviewResponse reviewSearch(Long id, SopReviewRequest request) {
if (id == null) {
return null;
+1 -1
View File
@@ -11,7 +11,7 @@
| 正式试点数据导出 | `GET /api/train/practice/export?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD` | 起止日期必填且包含结束日;只统计窗口内能通过唯一手机号或外部 ID 映射到在职组织快照的正式会话,排除重复手机号和身份碰撞。完训定义为每人至少 10 次,校准必须关联同一窗口内正式会话;CSV 同时给出校准命中数和 SOP 可用数,避免用四舍五入后的比率反推门禁状态;汇总和明细均携带正式人员及项目口径,不混入历史 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,前端降级文本 |
| 案例沉淀 `/knowledge/cases` | `POST /api/knowledge/case/upload`、`/organize`、`/curate` | `/upload` 改为 multipart 真实语音上传并走 ASR;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化;不再用固定样例转写冒充成功 |
| SOP知识库 `/knowledge/sop` | `POST /api/knowledge/search`、`POST /api/knowledge/doc/upload` | 已接入 MySQL Fulltext + Qdrant 混合召回、OSS-first 文档上传、txt/md/PDF/Word/Excel/PPT 解析和 embedding 写入,失败回退 seed |
| 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` 目前只返回一期生活顾问学习导航,不代表正式上岗资格 |
| 资料处理 `/knowledge/processing` | `GET /api/knowledge/processing/overview`、`POST /api/knowledge/doc/upload-async`、`GET /api/knowledge/doc/upload-items`、`POST /api/knowledge/doc/upload-items/{id}/retry`、`POST /api/knowledge/doc/import-local-task`、`GET /api/knowledge/doc/import-tasks`、`POST /api/knowledge/doc/import-tasks/{id}/cancel` | 已接入解析任务状态聚合;**批量上传走异步队列**:接口只暂存+入队即秒回,后台 worker(并发 2)逐条解析/归类/向量化;ZIP 在 worker 内安全解压后把支持的子文件继续入同一批次队列,页面按批次轮询进度、失败可单文件重试;服务端目录导入、进度轮询和任务取消保留,失败回退 seed |
| 组织人员同步 | `POST /api/aihr/org/sync` | 从开放组织同步系统的 `/api/open/v1/sync/snapshot` 拉取 `company/department/employee` 快照,分页参数使用 `limit`;员工手机号只落 `person_phone` 用于移动端身份映射,不在组织列表响应暴露;岗位识别 `position/job_title/post/job_name/role/title` 等字段。默认 `replaceExisting=true`,写入前必须先用 `{"dryRun":true}`;dry-run 不访问本地快照表、不执行 DDL/写库,返回 `phoneLinked/maskedPhone/suspectText/warnings` 且 `syncedCount=0`。2026-07-10 源接口实测 3474 人仅 1 个可用手机号、3473 个脱敏手机号、8 条疑似乱码,因此未执行覆盖同步,正式试点需上游先开放至少 20 名试点人员手机号 |
| 移动端手机号登录 | `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 代码级强制失效) |
+2 -2
View File
@@ -33,7 +33,7 @@
| 缺口 | BRD依据 | 当前事实 | 建议实现顺序 |
|---|---|---|---|
| 岗位-SOP匹配与上岗资格 gating | 3.1、4.2、TechSpec P1 | 当前有岗位选择、SOP 搜索和训练任务,但未发现稳定的 `onboard_task` / `qualification_gate` 业务表与页面闭环 | 先建岗位适用 SOP 只读结果,再补培训任务状态,最后补“未通过不得标记上岗”的资格规则 |
| 岗位-SOP匹配与上岗资格 gating | 3.1、4.2、TechSpec P1 | 已补 `GET /api/knowledge/position-sop` 和员工学习页状态提示:一期只确认生活顾问内置学习导航;尚无 `sop_applicability`、`onboard_task`、`qualification_gate` 正式数据闭环,不能作为上岗资格判断 | 先接正式岗位/项目与 SOP 适用范围数据,再补培训任务状态;资格 gating 必须由 HR 明确规则和证据来源后实现 |
| 智能错题本 | 3.1、4.4、TechSpec P1 | 已补 `/api/aihr/mobile/practice/mistakes` 和员工“学”页错题本:按本人聚合低分/红线回合,展示原因、分数、再练状态,并可跳已有 retry assignment;暂未做复杂原因筛选和独立错题表 | 下一步补原因筛选、复盘详情关联和主管侧聚合,仍复用现有 session/assignment/annotation |
| 案例学习/视频详情 | 3.1、4.6、矩阵第35行 | `AihrCaseService` 当前用内存 `CaseState` + `aihr_case_record` 保存转写/摘要,没有稳定 media URL、学习点、主管点评和案例详情 API | 先扩持久化契约和权限,再接预渲染样片;没有真实媒体 URL 时不造播放按钮 |
| 案例库质量与数量 | L1-B、4.6/4.7 | 上传/整理/筛选路径已有;“至少20条脱敏案例”和每周每项目一条需要正式数据证明 | 增加案例统计/脱敏检查导出,按项目窗口采集,不用演示 seed 冒充 |
@@ -49,7 +49,7 @@
1. 先完成正式组织快照 dry-run 与项目范围核对;不修改正式数据,不用手机号猜角色。
2. 在本地/测试环境实现“错题本只读聚合”,复用现有训练记录和 retry assignment,补 API、员工入口和主管查看边界。
3. 再实现岗位-SOP匹配与培训任务状态;资格 gating 放在有真实资格规则后接入,避免先做一个无法运营的开关。
3. 已先补岗位学习适配摘要,但它只负责把一期生活顾问导航状态说清楚;下一步接正式岗位/项目/SOP 适用范围,再实现培训任务状态;资格 gating 放在有真实资格规则后接入,避免先做一个无法运营的开关。
4. 单独设计案例详情数据契约,确定媒体来源、样片、学习点和主管点评后再开发详情页。
5. 用正式试点窗口跑 BRD L1/M5,而不是继续增加视觉页面。
+49 -1
View File
@@ -108,6 +108,13 @@
{{ knowledgeLoading ? '检索中' : '重查' }}
</button>
</view>
<view v-if="positionSopSummary" class="position-sop-status">
<text class="position-sop-title">
{{ positionSopSummary.position }} · {{ positionSopSummary.topics.length }} 个学习主题
</text>
<text class="position-sop-note">{{ positionSopSummary.message }}</text>
<text v-if="!positionSopSummary.formalApplicabilityConfigured" class="position-sop-warning">当前摘要不代表正式上岗资格</text>
</view>
<view class="chip-row topic-row">
<button
v-for="topic in topics"
@@ -177,12 +184,13 @@ import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type {
KnowledgeSearchResponse,
PositionSopSummary,
PracticeAssignment,
PracticeMistake,
PracticePrepCard
} from '@/types/api';
import { getPracticeAssignments, getPracticeMistakes, getPracticePrepCard } from '@/services/practice';
import { searchKnowledge } from '@/services/knowledge';
import { getPositionSopSummary, searchKnowledge } from '@/services/knowledge';
import { getSelectedPositionProfile, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { buildLearningTopics, selectLearningTopic } from '@/services/learning';
import { navigateBackToToday, resetPageScroll } from '@/services/navigation';
@@ -198,6 +206,7 @@ const assignments = ref<PracticeAssignment[]>([]);
const mistakes = ref<PracticeMistake[]>([]);
const prepCard = ref<PracticePrepCard | null>(null);
const knowledgeResult = ref<KnowledgeSearchResponse | null>(null);
const positionSopSummary = ref<PositionSopSummary | null>(null);
const activeTopic = ref('岗前 SOP');
const assignmentLoading = ref(false);
const mistakeLoading = ref(false);
@@ -311,6 +320,16 @@ const loadKnowledge = async (topic: string) => {
}
};
const loadPositionSopSummary = async () => {
if (!loggedIn.value) return;
try {
positionSopSummary.value = await getPositionSopSummary(positionProfile.value.value);
} catch (error) {
positionSopSummary.value = null;
handleRequestError(error, '岗位学习范围加载失败');
}
};
const loadPage = async () => {
refreshAuthState();
message.value = '';
@@ -319,6 +338,7 @@ const loadPage = async () => {
mistakes.value = [];
prepCard.value = null;
knowledgeResult.value = null;
positionSopSummary.value = null;
return;
}
if (!(await ensureEmployeePosition('/pages/user/learning/index'))) {
@@ -328,6 +348,7 @@ const loadPage = async () => {
await Promise.all([loadAssignments(), loadMistakes()]);
activeTopic.value = selectLearningTopic(assignments.value, defaultTopic.value);
await Promise.all([
loadPositionSopSummary(),
loadKnowledge(activeTopic.value),
loadPrepCard()
]);
@@ -383,6 +404,33 @@ onShow(() => {
font-size: 12px;
}
.position-sop-status {
display: grid;
gap: 4px;
margin-bottom: 12px;
padding: 10px 12px;
border-radius: 10px;
background: #f3f7ff;
}
.position-sop-title {
color: #183b70;
font-size: 13px;
font-weight: 700;
}
.position-sop-note {
color: #60708a;
font-size: 12px;
line-height: 1.5;
}
.position-sop-warning {
color: #9a6700;
font-size: 12px;
line-height: 1.5;
}
.mistake-list {
display: grid;
gap: 10px;
+8
View File
@@ -2,6 +2,7 @@ import type {
AnswerFeedbackVerdict,
KnowledgeSearchResponse,
KnowledgeSnippet,
PositionSopSummary,
SummaryCardResponse
} from '@/types/api';
import { apiRequest } from './api';
@@ -36,6 +37,13 @@ export const searchKnowledge = async (queryText: string, position = getSelectedP
};
};
export const getPositionSopSummary = (position = getSelectedPosition()) =>
apiRequest<PositionSopSummary>({
url: `/api/knowledge/position-sop?position=${encodeURIComponent(position)}&projectType=住宅`,
method: 'GET',
timeout: 15000
});
export const submitAnswerFeedback = (params: {
queryText: string;
fragmentIds: number[];
+17
View File
@@ -103,6 +103,23 @@ export interface KnowledgeSearchResponse {
cautions?: string[];
}
export interface PositionSopTopic {
category: string;
question: string;
reference: string;
status: string;
}
export interface PositionSopSummary {
position: string;
projectType: string;
routingMode: string;
formalApplicabilityConfigured: boolean;
qualificationStatus: string;
message: string;
topics: PositionSopTopic[];
}
export type AnswerFeedbackVerdict = 'up' | 'down';
export interface SummaryCardStep {
@@ -148,3 +148,14 @@ test('学习页展示真实错题本并可进入已有再练任务', async () =>
assert.match(source, /pendingAssignmentKey/);
assert.match(practiceService, /\/api\/aihr\/mobile\/practice\/mistakes/);
});
test('学习页显示岗位 SOP 适配状态而不冒充上岗资格', async () => {
const [source, knowledgeService] = await Promise.all([
pageSource('../src/pages/user/learning/index.vue'),
pageSource('../src/services/knowledge.ts')
]);
assert.match(source, /positionSopSummary/);
assert.match(source, /正式上岗资格/);
assert.match(knowledgeService, /\/api\/knowledge\/position-sop/);
});
+3
View File
@@ -304,6 +304,7 @@ contains mobile-uni/src/services/knowledge.ts "/api/knowledge/answer-feedback"
contains mobile-uni/src/pages/user/sop/index.vue "有帮助"
contains mobile-uni/src/pages/user/sop/index.vue "没解决"
contains mobile-uni/src/services/knowledge.ts "/api/knowledge/summary-card"
contains mobile-uni/src/services/knowledge.ts "/api/knowledge/position-sop"
contains mobile-uni/src/services/case.ts "/api/knowledge/case/upload"
contains mobile-uni/src/services/candidate.ts "/api/recruit/interview/start"
contains mobile-uni/src/services/candidate.ts "/api/aihr/mobile/candidate/materials"
@@ -312,6 +313,7 @@ contains mobile-uni/src/pages/candidate/materials/index.vue "uploadCandidateMate
contains mobile-uni/src/pages/candidate/progress/index.vue "buildCandidateFlow"
contains mobile-uni/src/pages/candidate/study/index.vue "searchKnowledge"
contains mobile-uni/src/pages/user/learning/index.vue "岗位 SOP"
contains mobile-uni/src/pages/user/learning/index.vue "岗位学习范围加载失败"
contains mobile-uni/src/pages/user/learning/index.vue "getPracticeAssignments"
contains mobile-uni/src/pages/user/learning/index.vue "searchKnowledge"
contains mobile-uni/src/pages/user/learning/index.vue "getPracticePrepCard"
@@ -346,6 +348,7 @@ contains frontend/src/api/aihr/practice.ts "/api/train/practice/export"
contains frontend/src/api/aihr/practice.ts '/api/train/practice/scenarios/${id}/enabled'
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java "/scenarios/{id}/enabled"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java "String position"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java "/position-sop"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java 'firstNonBlank(position, "生活顾问")'
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "normalizeSearchSource"
contains frontend/src/views/train/practice.vue "practice_help"