Files
pj0235-eai_agentplatform/docs/设计稿_岗位与知识对应能力.md
T
eaiadminandClaude Code 0455f064ac feat: 新增语音转文字(ASR)功能
- 后端:新增 /api/audio/transcribe 接口,调用 Ollama whisper 进行语音识别
- 前端:新增 AudioTranscribePage.vue 页面,支持 MP3/WAV/M4A/OGG/FLAC 等格式
- 注册路由、工具卡片、智能助手欢迎语更新

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-14 00:53:36 +08:00

407 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# pj0231 岗位与知识对应能力 —— 落地设计稿
> 版本:V0.1(草案) | 日期:2026-08-16
> 上位文档:《对比分析_pj0231_vs_pj006-zhilianyuan2.md》§5
> 目标:补齐 pj0231 缺失的「岗位 → 知识 → 考试」能力链,对齐 pj006 的岗位驱动思想,但**保持 pj0231 的极简单租户定位,不照搬 pj006 的洋葱模型与多租户机制**。
>
> **状态:已落地。** 本设计的 P0(岗位 → 知识映射 → 用户设岗 → 岗位驱动组卷)已在 V1.4 实现,`position` / `position_knowledge` / `position_exam_blueprint` 等表与接口见 `docs/changelog.md` V1.4–V1.5。
---
## 1. 背景与目标
### 1.1 现状痛点
对比分析确认 pj0231 存在**结构性缺失**:
- 用户只有 `role`(employee/admin),**没有岗位概念**;
- 题库只有粗粒度 `domain`(company / product / sales)+ 可选 `course_id`,**没有「某岗位该掌握哪些知识/课程/产品」的对应关系**;
- 考试组卷只能按 `domain` 抽题,**无法做到「按岗位应学范围出题」**;
- 因此「该岗位该学什么、该考什么」完全靠人工记忆,无法系统化。
### 1.2 本次目标
落地一条**最小的岗位驱动闭环**:
```
岗位(Position) ──绑定──> 用户(User)
│
└──映射──> 岗位知识要求(PositionKnowledge)
├── 知识域 domain(company/product/sales)
├── 课程 course_id(可选)
└── 产品 product_id(可选)
考试(ExamPaper.position_id) ──> 按岗位知识要求自动抽题组卷
```
### 1.3 非目标(明确不做,见 §9)
- 不引入 pj006 的 `competency_layers / levels / details` 三层洋葱模型;
- 不引入多租户 `org` 组织模型;
- 不做 LLM 自动生成岗位能力模型初稿;
- 不做学习进度 / 学情分析(依赖学习进度表,另行立项)。
---
## 2. 设计原则(强制)
1. **保持极简**:只加「岗位 + 映射」这一条能力链,不为对齐 pj006 而堆表。
2. **复用现有机制**:考试组卷复用 `exam.go` 的 `pickQuestions`;路由复用 `RequireAdmin`;迁移复用 GORM `AutoMigrate`。
3. **级别词汇对齐 pj006 的 L1-L4**,但**不建独立级别表**——`required_level` 用普通字符串字段 + Go 常量,降低复杂度。
4. **单岗位优先**:一个用户一个岗位(`user.position_id` 可空),满足绝大多数内部员工场景;多岗位需求出现时再升级为映射表。
5. **不破坏现有数据**:所有新列可空、新表独立,AutoMigrate 幂等。
---
## 3. 现状盘点(改动点清单)
| 现有资产 | 位置 | 改动 |
|---|---|---|
| `User` | `internal/model/user.go` | **加列** `PositionID *uint`(可空) |
| `ExamPaper` | `internal/model/exam_paper.go` | **加列** `PositionID *uint`(可空) |
| `Question` | `internal/model/question.go` | 不改 |
| `Course` / `Product` | `internal/model/` | 不改 |
| 迁移 | `internal/store/db.go` `AutoMigrate` | 追加 2 张新表 |
| 路由 | `internal/api/router.go` | 新增岗位相关端点(admin 组) |
| 抽题 | `internal/api/exam.go` `pickQuestions` | 增加「岗位知识映射」过滤分支 |
| 种子 | `internal/store/seed.go` | 可选:预置默认岗位 |
---
## 4. 数据模型设计
### 4.1 新增表:`position`(岗位主表)
```go
// internal/model/position.go
package model
import "time"
// Position 岗位表
type Position struct {
ID uint `gorm:"primaryKey" json:"id"`
Code string `gorm:"size:32;uniqueIndex;not null" json:"code"`
Name string `gorm:"size:64;not null" json:"name"`
Description string `gorm:"type:text" json:"description"`
Status string `gorm:"size:16;not null;default:active;index" json:"status"` // active / inactive
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (Position) TableName() string { return "position" }
```
```sql
CREATE TABLE IF NOT EXISTS position (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
created_at DATETIME,
updated_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_position_status ON position(status);
```
### 4.2 新增表:`position_knowledge`(岗位知识要求,核心映射)
一条记录 = 「某岗位在某个知识域(可细化到课程/产品)上要求达到某级别」。
```go
// internal/model/position_knowledge.go
package model
import "time"
// PositionKnowledge 岗位知识要求(岗位 ↔ 知识域/课程/产品 映射)
// domain 必填;course_id / product_id 可选(三者共同圈定岗位应学范围)。
type PositionKnowledge struct {
ID uint `gorm:"primaryKey" json:"id"`
PositionID uint `gorm:"not null;index" json:"position_id"`
Domain string `gorm:"size:16;not null;index" json:"domain"` // company / product / sales
CourseID *uint `gorm:"index" json:"course_id"` // 绑定具体课程,可空
ProductID *uint `gorm:"index" json:"product_id"` // 绑定具体产品,可空
RequiredLevel string `gorm:"size:16;not null;default:L1" json:"required_level"` // L1/L2/L3/L4
Weight float64 `gorm:"not null;default:1" json:"weight"`
IsMandatory bool `gorm:"not null;default:true" json:"is_mandatory"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (PositionKnowledge) TableName() string { return "position_knowledge" }
```
```sql
CREATE TABLE IF NOT EXISTS position_knowledge (
id INTEGER PRIMARY KEY AUTOINCREMENT,
position_id INTEGER NOT NULL,
domain TEXT NOT NULL, -- company / product / sales
course_id INTEGER, -- 可空
product_id INTEGER, -- 可空
required_level TEXT NOT NULL DEFAULT 'L1', -- L1/L2/L3/L4
weight REAL NOT NULL DEFAULT 1,
is_mandatory INTEGER NOT NULL DEFAULT 1,
created_at DATETIME,
updated_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_pk_position ON position_knowledge(position_id);
CREATE INDEX IF NOT EXISTS idx_pk_domain ON position_knowledge(domain);
```
> 说明:不做 `UNIQUE(position_id, domain, course_id, product_id)` 的唯一约束,允许同一岗位同一域多条记录(如按产品细分)。如后续需要防重复,可在 service 层做去重校验。
### 4.3 改动:`user` 加岗位列
在 [user.go](eai_agentplatform_app/backend-go/internal/model/user.go) 的 `User` 中追加:
```go
PositionID *uint `gorm:"index" json:"position_id"` // 所属岗位,可空(历史用户/管理员可为空)
```
### 4.4 改动:`exam_paper` 加岗位列
在 [exam_paper.go](eai_agentplatform_app/backend-go/internal/model/exam_paper.go) 的 `ExamPaper` 中追加:
```go
PositionID *uint `gorm:"index" json:"position_id"` // 关联岗位考试,可空;设置后按岗位知识映射抽题
```
### 4.5 级别常量(Go)
```go
// internal/model/position.go 或独立 constants 处
const (
LevelL1 = "L1" // 入门级:概念/定义/标准流程
LevelL2 = "L2" // 稳定执行级:标准应用/独立执行
LevelL3 = "L3" // 复杂处理级:跨场景迁移/带教
LevelL4 = "L4" // 体系策略级:方案设计/组织约束
)
var validLevels = map[string]bool{LevelL1: true, LevelL2: true, LevelL3: true, LevelL4: true}
```
---
## 5. API 设计
### 5.1 端点总表
| 方法 | 路径 | 权限 | 说明 |
|---|---|---|---|
| GET | `/api/positions` | admin | 岗位列表 |
| POST | `/api/positions` | admin | 新建岗位 |
| PUT | `/api/positions/:id` | admin | 编辑岗位 |
| DELETE | `/api/positions/:id` | admin | 停用岗位(软删除 status=inactive) |
| GET | `/api/positions/:id/knowledge` | admin | 某岗位知识映射列表 |
| PUT | `/api/positions/:id/knowledge` | admin | 保存映射(整表覆盖) |
| PUT | `/api/users/:id/position` | admin | 设置/清除用户岗位 |
| GET | `/api/my/position` | 员工 | 我的岗位 + 应学范围(P1) |
路由注册(`router.go` admin 组内追加):
```go
admin.GET("/positions", ListPositions)
admin.POST("/positions", CreatePosition)
admin.PUT("/positions/:id", UpdatePosition)
admin.DELETE("/positions/:id", DeletePosition)
admin.GET("/positions/:id/knowledge", ListPositionKnowledge)
admin.PUT("/positions/:id/knowledge", SavePositionKnowledge)
admin.PUT("/users/:id/position", SetUserPosition)
```
### 5.2 关键请求/响应
**PUT `/api/positions/:id/knowledge`(整表覆盖保存映射)**
```json
{
"items": [
{ "domain": "product", "course_id": null, "product_id": 3, "required_level": "L2", "weight": 1, "is_mandatory": true },
{ "domain": "sales", "course_id": 2, "product_id": null, "required_level": "L3", "weight": 1.5, "is_mandatory": true },
{ "domain": "company", "course_id": null, "product_id": null, "required_level": "L1", "weight": 1, "is_mandatory": false }
]
}
```
响应:保存后的完整映射列表(含 `id`)。
**PUT `/api/users/:id/position`**
```json
{ "position_id": 5 } // 设岗位
{ "position_id": null } // 清除岗位
```
---
## 6. 岗位驱动考试组卷(核心算法改动)
修改 [exam.go](eai_agentplatform_app/backend-go/internal/api/exam.go) 的 `pickQuestions`,在原有 `domain` 抽题之前,先判断 `p.PositionID` 是否为空:
```go
// pickQuestions 按 paper 抽题:优先按岗位知识映射圈定题池,否则回退 domain 抽题
func pickQuestions(p model.ExamPaper) ([]model.Question, error) {
pool := store.DB.Where("status = ?", "active")
if p.PositionID != nil {
// 岗位考试:圈定岗位应学范围
var pks []model.PositionKnowledge
if err := store.DB.Where("position_id = ?", *p.PositionID).Find(&pks).Error; err != nil {
return nil, err
}
domains := make([]string, 0, len(pks))
courseIDs := make([]uint, 0, len(pks))
for _, pk := range pks {
domains = append(domains, pk.Domain)
if pk.CourseID != nil {
courseIDs = append(courseIDs, *pk.CourseID)
}
}
domains = dedupe(domains)
if len(domains) > 0 {
pool = pool.Where("domain IN ?", domains)
}
if len(courseIDs) > 0 {
// 岗位明确绑定了课程:题目要么属于这些课程,要么未绑定课程但域匹配
pool = pool.Where("course_id IN ? OR course_id IS NULL", courseIDs)
}
} else {
// 原有逻辑:按 exam_paper.domain 抽题
domains := splitDomains(p.Domain)
if len(domains) > 0 {
pool = pool.Where("domain IN ?", domains)
}
}
var qs []model.Question
if err := pool.Order("id ASC").Find(&qs).Error; err != nil {
return nil, err
}
if len(qs) < p.QuestionCount {
return nil, fmt.Errorf("题库题目不足:需要 %d,可用 %d", p.QuestionCount, len(qs))
}
if p.Randomize {
rand.Shuffle(len(qs), func(i, j int) { qs[i], qs[j] = qs[j], qs[i] })
}
return qs[:p.QuestionCount], nil
}
```
> 说明:
> - `p.PositionID == nil` 时走原逻辑,**对现有考试零影响**;
> - 岗位映射为空(`len(pks)==0`)时 `domains` 为空,`pool` 不过滤域,会抽到全部题——需要在 `CreatePaper/UpdatePaper` 校验:**岗位考试必须存在至少一条知识映射**,否则拒绝创建/更新。
### 6.1 `CreatePaper` / `UpdatePaper` 追加校验
```go
if p.PositionID != nil {
var n int64
store.DB.Model(&model.PositionKnowledge{}).Where("position_id = ?", *p.PositionID).Count(&n)
if n == 0 {
web.Fail(c, web.NewBadRequest("岗位考试必须先配置岗位知识映射"))
return
}
}
```
---
## 7. 前端设计
### 7.1 导航位置
管理员「系统管理」下拉新增 **「岗位管理」**,位于「用户账号管理」之前:
```
系统管理【管理员】─┬─ 岗位管理 ← 新增
├─ 用户账号管理
├─ 全部考试成绩
└─ 系统参数配置
```
### 7.2 页面清单
| 页面 | 路由 | 说明 |
|---|---|---|
| 岗位列表 | `/admin/positions` | 表格:编码/名称/描述/状态;新建/编辑/停用 |
| 岗位知识映射 | 岗位列表行内抽屉或独立页 | 勾选知识域 + 绑定课程/产品下拉 + 选 L1-L4 + 权重 + 必学开关 |
| 用户管理改造 | 现有 `UserManage.vue` | 用户行加「岗位」下拉(可选岗位/清空) |
| 考试配置改造 | 现有题库管理/考试配置 | 考试表单加「关联岗位」下拉(可选,空=普通考试) |
### 7.3 岗位知识映射编辑面板交互(要点)
- 上方:岗位基本信息(只读展示)。
- 主体:映射条目列表,每行 = `domain 下拉` + `课程下拉(可空)` + `产品下拉(可空)` + `L1-L4 下拉` + `权重` + `必学开关` + 删除。
- 底部:「新增一行」+「保存」(整体 PUT 覆盖)。
---
## 8. 分阶段实施
### P0(本次落地,核心闭环)
1. 新增 `Position` / `PositionKnowledge` model + 追加 `User.PositionID` / `ExamPaper.PositionID`;
2. `db.go` AutoMigrate 追加 2 张表;
3. 岗位 CRUD + 映射保存 API(admin);
4. 用户设岗位 API + 用户管理页岗位下拉;
5. `pickQuestions` 岗位过滤分支 + `CreatePaper/UpdatePaper` 校验;
6. 前端「岗位管理」页 + 映射编辑面板 + 考试配置「关联岗位」下拉。
### P1(随后)
1. 学员端「我的岗位 + 应学清单」`GET /api/my/position`(按岗位映射返回应学课程/产品/知识域);
2. 考试成绩按岗位聚合(`ExamRecord` 冗余 `position_id` 快照,或 join `user.position_id`);
3. 岗位考试蓝图 `position_exam_blueprint`(维度/题型/题量/权重,对齐 pj006),驱动更精细组卷。
### P2(视需要,另行立项)
1. 错题本 + 学习档案(依赖学习进度表);
2. 简答题题型 + LLM 评分(复用现有 LLM 底座);
3. L1-L4 能力洋葱模型(若确需能力档案再上);
4. 后端 pytest 测试补齐。
---
## 9. 明确不做(边界)
- ❌ 不建 `competency_layers / competency_levels / competency_details` 三层洋葱模型;
- ❌ 不引入多租户 `orgs` / per-org 分库;
- ❌ 不做 LLM 自动生成岗位能力模型初稿;
- ❌ 不做多岗位绑定(单岗位优先,`user.position_id` 单列);
- ❌ 不做学习进度 / 学情分析 / 能力雷达图。
---
## 10. 迁移与回滚
- **迁移**:GORM `AutoMigrate` 自动建新表 + 给既有表加可空列,幂等、对现有数据零破坏。历史用户 `position_id` 为空,不影响登录与现有考试。
- **回滚**:新表删除即可(`DROP TABLE position; DROP TABLE position_knowledge;`);加列不影响回滚(可留空)。
- **部署**:单二进制,无额外依赖;`clonezilla` 交付前清数据即可。
---
## 11. 风险与注意事项
1. **岗位映射为空导致抽全题**:已在 `CreatePaper/UpdatePaper` 加校验拦截(§6.1)。
2. **course_id 与 domain 双重过滤语义**:岗位绑定了具体课程时,`course_id IN ? OR course_id IS NULL` 会把「未绑定课程但域匹配」的题也纳入,需与产品确认是否符合预期(倾向:岗位考试以课程为强约束时,改为 `course_id IN ?` 严格匹配)。
3. **级别字段暂不参与抽题**:P0 中 `required_level` 只做展示与后续 P1 蓝图使用,不参与 P0 组卷过滤(避免一开始就复杂化)。
4. **多岗位需求**:若未来一人多岗,`user.position_id` 单列不够,届时升级为 `user_position` 映射表(参考 pj006 `user_positions`)。
---
## 12. 与 pj006 的差异对照(本稿的取舍)
| 项 | pj006 做法 | 本稿(pj0231)取舍 |
|---|---|---|
| 岗位能力模型 | 三层洋葱 + L1-L4 独立表 | 仅 `required_level` 字符串 + 常量,不建级别表 |
| 岗位↔知识映射 | `position_knowledge_units`(挂知识单元) | `position_knowledge`(挂 domain/course/product,因 pj0231 无知识单元) |
| 考试蓝图 | `position_exam_blueprints` | P0 用 `exam_paper.position_id` 直接驱动,蓝图列 P1 |
| 用户绑定 | `user_positions` 独立表 | `user.position_id` 单列(极简) |
| 多租户 | `orgs` + per-org 分库 | 不做(单企业) |
---
> 本稿为草案,落地前需与产品确认 §11.2 的课程/域双重过滤语义,以及 §4.2 是否需要对映射去重约束。