Files
pj0235-eai_agentplatform/docs/设计稿_岗位与知识对应能力.md
T
eaiadminandClaude Code 16d63de4e1 chore: 工作台产品化进行中的改动
把工作区里其余在制品一并入库,主要是工作台产品化的推进:

  后端:新增 capability_definition / project / my_app_center / office_skill
        接口与 action_definition / skill_definition / project / user_app_center
        模型,config 加路由健康上报。
  前端:新增 frontend/src/skills(Office 技能与 workbuddy 复刻)、
        项目管理、应用中心、能力目录页,以及配套 api / store / config;
        聊天侧新增 SpecialistChip / SpecialistPanel / SkillStrip / AppChatRail
        等组件。
  清理:移除旧 views/tools 下的单页工具(已并入工作台)、_frozen 冻结组件、
        cmd/inspect_oa_debug 调试入口,以及两份调试笔记。
  其它:文档与启动脚本同步。

(这批改动与上一提交的 SY23 工作并行进行,此前已在同一工作区内交织。)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 21:32:35 +08:00

16 KiB
Raw Blame History

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(岗位主表)

// 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" }
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(岗位知识要求,核心映射)

一条记录 = 「某岗位在某个知识域(可细化到课程/产品)上要求达到某级别」。

// 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" }
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 的 User 中追加:

	PositionID  *uint     `gorm:"index" json:"position_id"` // 所属岗位,可空(历史用户/管理员可为空)

4.4 改动:exam_paper 加岗位列

在 exam_paper.go 的 ExamPaper 中追加:

	PositionID  *uint     `gorm:"index" json:"position_id"` // 关联岗位考试,可空;设置后按岗位知识映射抽题

4.5 级别常量(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 组内追加):

	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(整表覆盖保存映射)

{
  "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

{ "position_id": 5 }      // 设岗位
{ "position_id": null }   // 清除岗位

6. 岗位驱动考试组卷(核心算法改动)

修改 exam.go 的 pickQuestions,在原有 domain 抽题之前,先判断 p.PositionID 是否为空:

// 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 追加校验

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 是否需要对映射去重约束。