AI 匯入職缺 Universal Parser Mockup PRD(PM_36)
此文件定義「AI 匯入職缺」與「履歷解析」共用同一條 Pipeline,供 backend-api-spec-generator 與前後端實作對齊。
1. 文件資訊
- 文件類型:Mockup 需求規格書(前後端共用視圖)
- 適用對象:前端開發、後端開發、產品、QA
- 最後更新:2026/04/29
- 版本:2.1.0
- 對應 Storybook:
TBD(尚未建立對應 story) - Storybook 連結:
TBD - 建立日期:2026/04/15
v2.1.0 變動摘要(2026/04/29):
- AI 抽取範圍擴大到「edit-job 所有可填欄位」:除必填 14 項外,新增 10 項非必填欄位(管理人數 / 出差 / 遠端 / 歡迎身份 / 工作身份 / 福利 / 駕照 / 科系 / 語言能力 / 工作技能)。AI「JD 有寫就盡量帶入」,抽不到一律 NULL/空陣列。
- Embedding 範圍擴張:新增
dict_skill_languages(183 項)→ 大 dict 共 4 張。- Prompt-constrain 上限放寬至 ~25 筆:涵蓋
dict_company_benefits(22) /dict_department_class(23) 等中型 dict。v2.0.0 變動摘要(2026/04/29):
- AI 抽取範圍擴大:原 Phase A 只抽 ~9 項、其餘 5 項由使用者手填;本版改為 AI 嘗試抽全部 13 項必填(除「應徵方式」由系統預設)。抽不到一律 NULL,嚴禁 LLM 自行補預設值。
- Dict mapping 策略二分:3 張大 dict(area / classification / skill)走 embedding;其餘小 dict(≤10 筆)一律走 prompt-constrain。
- PRD 抽象化:§11.3 移除實作層 SQL,改以「Parser 透過 optimistic lock 寫回對應子表」抽象描述,DB 細節留給工程 spec。
- Schema bug 修正:
job_feature_code的senior改為executive;salary_unit_code移除實際不存在的project。- Retry 範圍縮小:「再次解析」按鈕只在
failed出現,parsed不允許重抽(簡化 lock 邏輯)。
2. 開發進度與設計來源
開發進度
- 前端 PR #:TBD
- 後端 PR #:TBD(分支
feature/pm_36-ai-job-import)
設計來源
- Figma 連結:待補上
- 既有畫面來源(增量基準):
W101-Web/main-web/packages/recruitment/src/views/jobs/index.vueW101-Web/main-web/packages/recruitment/src/views/jobs/edit-job/index.vueW101-Web/main-web/packages/recruitment/src/components/JobManager/JobDescriptionCard.vueW101-Web/main-web/packages/recruitment/src/components/JobManager/RecruitmentConditionsCard.vue
3. 模擬頁面摘要(Mockup Summary)
3.1 功能概述
- 功能名稱:AI 匯入(職缺 / 履歷)通用解析器
- 主要使用者角色:
- 企業 HR/招募人員(匯入職缺內容並快速產生可編輯草稿)
- 核心目標:
- 建立同一套可重用的 AI 解析流水線,避免職缺與履歷各自維護一份邏輯。
- 讓輸入來源(純文字)最終都能轉為可驗證的結構化 JSON。
- 前端統一接收 JSON,依類型映射到不同 UI 表單。
3.2 範圍界定
- 包含範圍:
- 定義唯一內部 orchestrator:
process_document(parse_job_id)(見 §6.1 非同步執行模型)。 - 支援輸入型態:純文字。
- 文字內容先轉 Markdown(
convert_to_md)。 - 進入
extract_data(md_content, schema_type)時,依target_type切換 Schema。 target_type僅支援RESUME與JOB。- 職缺 AI 抽取涵蓋 §10.4 列出的可由 AI 抽取項目(涵蓋率與每項對照見 §10.4)。
- Dict mapping 二分策略:embedding(4 張大 dict)+ prompt-constrain(17 張小/中 dict,≤ ~25 筆),詳見 §11.1.2。
- 定義唯一內部 orchestrator:
- 排除範圍:
- 不支援檔案上傳與 OCR;本版僅接受純文字輸入(
input_type = TEXT)。 - 不做多類型以外(如 cover letter、portfolio)的 schema 支援。
- 不做直接發佈;匯入結果皆先進可編輯草稿,使用者於 edit-job 頁補齊必填後才可切刊登(BR-008,見 §11.9)。
- 不做「公司層級預設值」回填機制(延後,見 §12);AI 抽不到的欄位由使用者於 edit-job 手動補齊。
- 不支援檔案上傳與 OCR;本版僅接受純文字輸入(
3.3 成功條件
- 同一份 Markdown 輸入可透過不同
target_type產生不同結構化結果。 - 回傳 JSON 一律通過 schema validator(格式可驗證)。
- 前端可依
target_type將 JSON 預填到對應 UI,不需分叉解析邏輯。 - AI 匯入產出的 placeholder 草稿,能通過既有 edit-job 頁的編輯 / 儲存流程;補齊必填後可通過 §11.9 的完整度 gate 並切刊登。
4. 商業規則對齊(Business Rules Alignment)
4.1 本功能使用到的業務規則
| 規則 ID | 規則摘要 | 是否關鍵 | 備註 |
|---|---|---|---|
| BR-008 | 職缺發布前需通過欄位完整度檢查 | ✅ 是 | ⚠️ 2026/04/22 trace 確認:現行 code 並未實作此 gate — publishJob 只檢查 ownership / 不可操作狀態 / 公司審核,沒有欄位完整度檢查;docs/domain/business-context.md 所載「✅ 已實作 via JobsValidationService」為誤記(JobsValidationService 僅處理分頁 / 代碼 / 狀態 enum 驗證)。本 PR 一併補上,見 §11.9 |
| BR-COMPANY-JOB-SCOPE | 企業使用者僅能對自己公司的職缺操作 | ✅ 是 | 匯入結果掛在呼叫者所屬 company_id 下,不得越權 |
| BR-NEW-AI-PIPELINE | 履歷與職缺共用同一條 Pipeline,輸出 JSON 必須通過 schema 驗證 | ✅ 是 | 不得為職缺 / 履歷各建一套 parser;LLM 輸出不符 schema 即視為 SCHEMA_INVALID |
4.2 補充說明
- 解析正確率與內容品質可允許人工修正;但「JSON 格式合法性」必須達成 100%。
- 本模組只處理「解析與轉換」,不直接覆蓋線上資料,也不直接觸發刊登。
5. 資料實體與欄位(Data Entities & Fields)
5.1 實體列表
| 實體名稱 | 說明 | 來源頁面/區塊 |
|---|---|---|
| UniversalParserRequest | 通用解析請求參數 | 匯入入口 |
| UniversalParserCreateVm | POST 建立任務的回應 | 建立後即時回傳 |
| UniversalParseJobStatusVm | GET 輪詢解析狀態的回應 | FE 輪詢直到 parsed/failed |
| ResumeSchemaPayload | 履歷結構化輸出(parsed_data 內容) | 履歷編輯頁 |
| JobSchemaPayload | 職缺結構化輸出(parsed_data 內容) | 職缺編輯頁 |
5.2 實體欄位定義(TypeScript 介面)
本 API 採非同步輪詢模型:POST 建立解析任務 → 回
parse_job_id+ placeholderdraft_job_id→ FE 輪詢 GET 直到status=parsed | failed。
type TargetType = "RESUME" | "JOB";
type InputType = "TEXT";
type ParseJobStatus = "parsing" | "parsed" | "failed";
type ParseErrorCode =
| "INPUT_EMPTY" // sanitize 後內容為空(純空白 / 全為控制字元)
| "INPUT_TOO_LARGE" // 原文 UTF-8 byte 長度超過 50 KB
| "LLM_TIMEOUT" // LLM 逾時
| "LLM_UNAVAILABLE" // LLM provider 故障
| "LLM_OUTPUT_INVALID" // LLM 回傳無法解析為 JSON / 結構嚴重損毀
| "SCHEMA_INVALID" // LLM 輸出不符 24 欄 schema
| "PARSED_EMPTY"; // LLM 正常運作但抽不到實質欄位(資訊不足,見 §11.4.1)
// SOT = src/modules/ai-parser/enums/parse-error-code.enum.ts
interface UniversalParserRequest {
input_type: InputType;
/**
* 原始文字內容(TEXT 時必填)。
* 上限:50 KB / 約 20,000 全形字(超出回 HTTP 400 + `error.ai_parser.input_too_long`)。
*/
raw_content?: string;
target_type: TargetType;
// SOT = src/modules/jobs/dtos/create-job-ai-import.dto.ts SUPPORTED_LOCALES
locale?: "zh-TW" | "en" | "en-US" | "vi" | "vi-VN" | "id" | "id-ID" | "th" | "th-TH";
enc_parse_job_id?: string; // retry 時帶上舊 parse_job 的 enc_id,見 §11.4
}
interface ResumeSchemaPayload {
full_name: string | null;
education: Array<{
school_name: string;
degree?: string | null;
major?: string | null;
start_date?: string | null;
end_date?: string | null;
}>;
skills: string[];
}
/**
* JobSchemaPayload — AI 抽取欄位範圍
*
* 欄位涵蓋率與必填對照見 §10.4;dict mapping 策略見 §11.1.2;
* 寫回 jobs_detail_* 子表契約見 §11.3。
*/
interface JobSchemaPayload {
// ── 描述類(自由文字)─────────────────────────────
title: string | null;
description: string | null;
requirements_text: string | null;
// ── 薪資 ─────────────────────────────────────────
compensation: {
scheme_code: "standard" | "piecework" | "negotiable" | null;
salary_unit_code: "hourly" | "daily" | "monthly" | "yearly" | null;
/** 最低金額(scheme_code=standard 時有效) */
amount_min: number | null;
/** 最高金額(scheme_code=standard 時有效) */
amount_max: number | null;
};
// ── 工作地點(embedding match)────────────────────
work_area: {
area_code: string | null; // dict_area.code(embedding)
address_line: string | null; // 詳細地址(自由文字)
};
// ── 職缺基礎屬性(小 dict,prompt-constrain)────────
job_feature_code: "full_time" | "part_time" | "internship" | "executive" | "dispatch" | null;
job_classification_codes: string[]; // dict_job_classification.code[](embedding)
work_skill_codes: string[]; // dict_job_skill.code[](embedding)
// ── 招募條件(小 dict,prompt-constrain)────────────
work_experience_code: "no_exp" | "over_1_year" | "over_3_years" | "over_5_years" | null;
education_requirement_codes: ("junior_high_or_below" | "senior_high" | "associate" | "bachelor" | "master" | "phd")[];
// ── 工作條件(v2.0 新增;小 dict,prompt-constrain)─
working_hour_codes: ("day_shift" | "evening_shift" | "night_shift" | "weekend_shift" | "rotating_shift")[];
vacation_policy_code: "company_rule" | "two_days" | "roster" | null;
available_start_period_code: "anytime" | "week" | "two_weeks" | "month" | "two_months" | "three_months" | null;
// ── 需求人數(v2.0 新增)──────────────────────────
headcount: {
code: "unlimited" | "limited" | null;
/** 僅當 code=limited 時有意義;JD 無「徵 N 名」字樣則為 null */
number: number | null;
};
// ── 聯絡人(v2.0 新增;自由文字)────────────────────
contact: {
name: string | null;
phone: string | null;
visit_address: string | null;
email: string | null;
};
// ── 補充工作條件(非必填;v2.1 新增)────────────────
/** 管理人數(如 JD 寫「帶 5 人團隊」抽 5;無則 null) */
management_responsibility: number | null;
business_travel_codes: ("travel" | "expatriate")[];
remote_work_code: "none" | "partial" | "full" | null;
// ── 歡迎條件(非必填;v2.1 新增)────────────────────
welcome_identity_codes: ("second_employment" | "indigenous" | "disabled" | "mid_older_age")[];
welcome_work_identity_codes: (
| "overseas_hk_macao_student"
| "intl_student_enrolled"
| "intl_student_job_seeker"
| "foreign_professional"
| "new_resident_with_residence"
| "migrant_worker_retention"
| "other"
)[];
// ── 福利與條件(非必填;v2.1 新增;prompt-constrain)─
company_benefit_codes: string[]; // dict_company_benefits.code[](22 項,prompt)
driving_license_codes: string[]; // dict_driving_license_types.code[](11 項,prompt)
department_class_codes: string[]; // dict_department_class.code[](23 項,prompt)
// ── 語言能力(非必填;v2.1 新增)────────────────────
/** 多筆「語言 + 精通度」組合;JD 沒寫一律空陣列 */
language_skills: Array<{
language_code: string; // dict_skill_languages.code(183 項,embedding match)
proficiency_level_code: "beginner" | "daily_convo" | "proficient" | "native";
}>;
}
// POST /ai-import 的回應(建立後,解析尚未開始)
interface UniversalParserCreateVm {
enc_parse_job_id: string;
enc_draft_job_id: string; // 對應 placeholder jobs.id
status: "parsing";
created_at: string;
quota_remaining: number | null; // Redis 失效時為 null
}
// GET /ai-import/:enc_parse_job_id 的回應(輪詢用)
interface UniversalParseJobStatusVm {
enc_parse_job_id: string;
enc_draft_job_id: string;
target_type: TargetType;
status: ParseJobStatus;
locale: "zh-TW" | "en" | null;
error_code: ParseErrorCode | null;
error_message: string | null; // 後端 i18n 後的訊息
parsed_data: ResumeSchemaPayload | JobSchemaPayload | null; // 僅 status=parsed 時有值
started_at: string | null;
completed_at: string | null;
created_at: string;
}
配額超限為 HTTP
429直接回錯,不屬於ParseJobStatus(parse_job row 在此情境下根本不會建立)。Worker 內部 payload type、Ajv schema、prompt template 等實作細節見 工程 spec。
6. 通用流水線定義(Universal Pipeline)
6.1 執行模型(API 契約層)
採非同步 fire-and-forget + DB 狀態輪詢:
- POST 建立任務(同步):API 同一 DB transaction 內建立 parse_job + placeholder job + 必要 1:1 子表(細節見 §11.2),立即回
enc_parse_job_id + enc_draft_job_id。 - 背景解析(非同步):後端 worker 把原始文字轉 Markdown → LLM 抽結構化 JSON → 通過 schema 驗證 → 透過 optimistic lock 寫回對應
jobs_*子表(見 §11.3)。 - FE 輪詢:以
GET /ai-import/:enc_parse_job_id拉狀態,直到status ∈ {parsed, failed}。
Pipeline 內部 step 拆解、orchestrator 函式簽名、LLM provider strategy、Ajv validator 等實作細節見工程 spec §1、§4。
7. Schema 模板(雙模板切換)
7.1 Resume_Schema(type == "RESUME")
{
"full_name": "string | null",
"education": [
{
"school_name": "string",
"degree": "string | null",
"major": "string | null",
"start_date": "string | null",
"end_date": "string | null"
}
],
"skills": ["string"]
}
7.2 Job_Schema(type == "JOB")
- 欄位完整定義見 §5.2
JobSchemaPayload;必填對照與抽取可行性見 §10.4。
{
"title": "string | null",
"description": "string | null",
"requirements_text": "string | null",
"compensation": {
"scheme_code": "standard | piecework | negotiable | null",
"salary_unit_code": "hourly | daily | monthly | yearly | null",
"amount_min": "number | null",
"amount_max": "number | null"
},
"work_area": {
"area_code": "string | null",
"address_line": "string | null"
},
"job_feature_code": "full_time | part_time | internship | executive | dispatch | null",
"job_classification_codes": ["string"],
"work_skill_codes": ["string"],
"work_experience_code": "no_exp | over_1_year | over_3_years | over_5_years | null",
"education_requirement_codes": ["junior_high_or_below | senior_high | associate | bachelor | master | phd"],
"working_hour_codes": ["day_shift | evening_shift | night_shift | weekend_shift | rotating_shift"],
"vacation_policy_code": "company_rule | two_days | roster | null",
"available_start_period_code": "anytime | week | two_weeks | month | two_months | three_months | null",
"headcount": {
"code": "unlimited | limited | null",
"number": "number | null"
},
"contact": {
"name": "string | null",
"phone": "string | null",
"visit_address": "string | null",
"email": "string | null"
},
"management_responsibility": "number | null",
"business_travel_codes": ["travel | expatriate"],
"remote_work_code": "none | partial | full | null",
"welcome_identity_codes": ["second_employment | indigenous | disabled | mid_older_age"],
"welcome_work_identity_codes": ["overseas_hk_macao_student | intl_student_enrolled | intl_student_job_seeker | foreign_professional | new_resident_with_residence | migrant_worker_retention | other"],
"company_benefit_codes": ["string"],
"driving_license_codes": ["string"],
"department_class_codes": ["string"],
"language_skills": [
{
"language_code": "string",
"proficiency_level_code": "beginner | daily_convo | proficient | native"
}
]
}
8. 實作參考(移至工程 Spec)
原本此章節收錄「可直接餵給 Cursor 的 Prompt」;自 v1.1.0 起移除 — PRD 不耦合特定 AI 工具的 prompt 格式,避免隨工具改版 rot。
工程實作指引請見 spec:
docs/superpowers/specs/2026-04-21-pm_36-ai-job-import-universal-parser-design.md。
9. API Hints(提示用,非最終 API 設計)
9.1 資料寫入需求(Write)
- HINT-MUTATION-1:建立通用解析任務
- 輸入:
UniversalParserRequest({ input_type, raw_content, target_type, locale?, enc_parse_job_id? }) - 行為:於同一 DB transaction 建立
parse_jobs+ placeholderjobsrow + 必要的 1:1 子表(見 §11.2),fire-and-forget 觸發process_document(parse_job_id);立刻回UniversalParserCreateVm。 - Auth:需登入,identity = 企業模式(
COMPANY),permission =CREATE_JOB;company_id取自登入 context,不由 request body 傳入(防越權)。 - 配額超限:直接回 HTTP
429+Retry-Afterheader;不建立 parse_job(見 §11.4)。
- 輸入:
9.2 資料讀取需求(Read)
- HINT-GET-1:查詢解析任務狀態
- 輸入:
enc_parse_job_id - 輸出:
UniversalParseJobStatusVm - Auth:需登入,identity = 企業模式(
COMPANY),permission =CREATE_JOB;後端檢查parse_jobs.company_id與呼叫者company_id一致,否則回 404。 - 語意:FE 輪詢直到
status ∈ {parsed, failed};其中status=parsed時parsed_data才有值。 - FE polling 建議:interval 2 秒、timeout 5 分鐘。
- Polling timeout 行為:FE 達 timeout 時
parse_job.status可能仍是parsing,此時顯示「解析逾時,正在處理中」訊息,不主動提供「再次解析」按鈕。後端會在數分鐘內把卡住的 row 自動轉成failed,FE 重新查狀態後再依 §10.3 規則顯示「再次解析」(自動標 failed 的時間窗見工程 spec §4.9)。 - 「再次解析」按鈕的觸發條件統一定義於 §11.4,僅在
parse_job.status = failed出現。
- 輸入:
10. Flowcharts(User / System)
10.1 User Flow
flowchart TD
A[使用者點擊 AI 新增職缺] --> B[貼上職缺文字]
B --> C{POST 建立 parse job}
C -->|HTTP 429 rate_limited| N[顯示黃色配額上限警告 + Retry-After 倒數]
C -->|200 OK| D[Jobs list 建立佔位草稿<br/>title: i18n 預設文案<br/>parse_job.status: parsing]
D --> E[背景 worker 執行解析;FE 靠輪詢 + jobs list 重新查狀態接續]
E --> J{解析結果}
J -->|parsed| K[parser optimistic update 寫回 jobs_*子表<br/>parse_job.status: parsed]
K --> L[自動導入 edit-job]
J -->|failed| M[parse_job.status: failed<br/>顯示紅色錯誤原因]
M --> O[使用者點擊再次解析]
O --> B
N --> P{倒數是否結束}
P -->|否| N
P -->|是| Q[配額釋放;使用者重新走匯入入口(非 retry)]
Q --> B
L --> R[使用者補齊 FE 必填欄位 §10.4]
R --> S{按切刊登}
S --> T[publishJob 完整度 gate §11.9]
T -->|通過| U[status: PUBLISHED]
T -->|缺必填| V[回 400 + 缺項清單,FE 引導使用者補]
V --> R
無論使用者留在頁面或離開,解析都在後端背景執行;FE 以輪詢(見 §9.2)或返回 jobs list 時重新查狀態接續,兩者行為等價。
rate_limited為 POST 建立時的 HTTP 429,不進 parse_job lifecycle;parsed / failed才是parse_jobs.status的終態。
10.2 System Flow
後端 FE / API / Worker / LLM 之間的 sequence diagram 屬實作層,移到工程 spec §1.2「資料流」。本節保留錨點供既有引用穩定。
10.3 Jobs list 狀態對照
jobs.status enum 保持既有 NOT_PUBLISHED / PUBLISHED,不新增 PARSING;解析中/失敗狀態靠 parse_job_id + parse_job.status 組合判斷。
| 判斷條件 | Jobs list 顯示狀態 | 可執行動作 |
|---|---|---|
parse_job_id IS NOT NULL && parse_job.status = parsing | 解析中(灰色 spinner / 進度指示) | 僅可查看進度;編輯頁需 read-only 或 disabled |
parse_job_id IS NOT NULL && parse_job.status = failed | 解析失敗(紅色錯誤原因) | 可刪除;可按「再次解析」 |
parse_job_id IS NOT NULL && parse_job.status = parsed | 已解析草稿(可帶「AI 匯入」badge) | 進入 edit-job 頁編輯、補必填、嘗試切刊登 |
parse_job_id IS NULL && jobs.status = NOT_PUBLISHED | 一般草稿 | 正常編輯、發布流程 |
parse_job_id IS NOT NULL && jobs.status = PUBLISHED | 已發布(曾經 AI 匯入) | 正常發布態;parse_job_id 保留作 audit |
10.4 Edit-job 頁面欄位 — AI 抽取與必填對照
依 FE 目前實作(2026/04/22 截圖確認)。#1–#14 為 FE 標
*必填、進 §11.9 完整度 gate;#15 為非必填,AI 仍嘗試抽取但不影響刊登。若 FE 調整必填清單,§11.9 檢查項目與本表須同步。
| # | 必填? | 欄位 | 對應 schema 欄位(§5.2) | 對應 dict | LLM 抽取可行性 | AI 匯入後狀態 |
|---|---|---|---|---|---|---|
| 1 | ✅ | 職務名稱 | title | — | ✅ 高 | Parser 寫回 |
| 2 | ✅ | 工作性質 | job_feature_code | dict_job_feature(5 筆,prompt) | ⚠️ 中 | Parser 寫回(抽不到 → NULL) |
| 3 | ✅ | 職務類別 | job_classification_codes[] | dict_job_classification(embedding) | ⚠️ 中 | Parser 寫回(confidence 不足 → 跳過該項) |
| 4 | ✅ | 職務內容 | description | — | ✅ 高 | Parser 寫回 |
| 5 | ✅ | 薪資待遇 | compensation.* | dict_compensation_schemes(3)/ dict_salary_unit(4) | ✅ 高 | Parser 寫回 |
| 6 | ✅ | 工作地點 | work_area.area_code + address_line | dict_area(embedding) | ⚠️ 中 | Parser 寫回(match 不到 → area_code NULL,address_line 保留原文) |
| 7 | ✅ | 上班時段 | working_hour_codes[] | dict_working_hour_type(5,prompt) | ⚠️ 低 — JD 通常不寫 | Parser 寫回(抽不到 → 空陣列) |
| 8 | ✅ | 休假制度 | vacation_policy_code | dict_week_rest_policies(3,prompt) | ⚠️ 低 — JD 通常不寫 | Parser 寫回(抽不到 → NULL) |
| 9 | ✅ | 可上班日 | available_start_period_code | dict_available_start_period(6,prompt) | ⚠️ 低 — JD 通常不寫 | Parser 寫回(抽不到 → NULL) |
| 10 | ✅ | 需求人數 | headcount.code + headcount.number | dict_headcount(2,prompt) | ⚠️ 低 — JD 偶爾寫「徵 N 名」 | Parser 寫回(抽不到 → 兩者 NULL) |
| 11 | ✅ | 工作經驗 | work_experience_code | enum(4,prompt) | ⚠️ 部分(如「5 年以上」) | Parser 寫回(抽不到 → NULL) |
| 12 | ✅ | 學歷 | education_requirement_codes[] | dict_degrees(6,prompt) | ⚠️ 部分(如「大學以上」) | Parser 寫回(抽不到 → 空陣列) |
| 13 | ✅ | 聯絡人 | contact.{name,phone,visit_address,email} | — | ⚠️ 低 — JD 末段偶有 | Parser 寫回每一項(抽不到 → 該項 NULL) |
| 14 | ✅ | 應徵方式 | — | — | — 系統預設 | Placeholder 建立時 WPORT 職航站應徵 = true;若 AI 抽到 contact.email/phone,FE 可自動勾對應應徵方式 |
| 15 | ❌ 非必填 | 工作技能 | work_skill_codes[] | dict_job_skill(embedding) | ⚠️ 中 — JD 偶有列舉 | Parser 寫回(confidence 不足 → 跳過該項;空陣列也可刊登) |
| 16 | ❌ 非必填 | 管理人數 | management_responsibility | — | ⚠️ 中 — JD 常見「帶 N 人團隊」 | Parser 寫回(抽不到 → NULL) |
| 17 | ❌ 非必填 | 出差需求 | business_travel_codes[] | dict_business_travel(2,prompt) | ⚠️ 中 — JD 偶寫「需出差 / 外派」 | Parser 寫回(抽不到 → 空陣列) |
| 18 | ❌ 非必填 | 遠端工作 | remote_work_code | dict_remote_modes(3,prompt) | ⚠️ 中 — JD 偶寫「可遠端 / hybrid」 | Parser 寫回(抽不到 → NULL) |
| 19 | ❌ 非必填 | 歡迎身份 | welcome_identity_codes[] | dict_identity_type(4,prompt) | ⚠️ 低 — JD 罕見明寫 | Parser 寫回(抽不到 → 空陣列) |
| 20 | ❌ 非必填 | 工作身份 | welcome_work_identity_codes[] | dict_work_identity_type(7,prompt) | ⚠️ 低 — JD 罕見明寫 | Parser 寫回(抽不到 → 空陣列) |
| 21 | ❌ 非必填 | 福利制度 | company_benefit_codes[] | dict_company_benefits(22,prompt) | ⚠️ 中 — JD 常列福利條目 | Parser 寫回(抽不到 → 空陣列) |
| 22 | ❌ 非必填 | 駕照需求 | driving_license_codes[] | dict_driving_license_types(11,prompt) | ⚠️ 低 — JD 偶要求「需機車駕照」 | Parser 寫回(抽不到 → 空陣列) |
| 23 | ❌ 非必填 | 科系需求 | department_class_codes[] | dict_department_class(23,prompt) | ⚠️ 中 — JD 偶寫「相關科系」 | Parser 寫回(抽不到 → 空陣列) |
| 24 | ❌ 非必填 | 語言能力 | language_skills[] | dict_skill_languages(183,embedding)+ dict_proficiency_levels(4,prompt) | ⚠️ 中 — JD 常寫「英文流利 / 日文 N2」 | Parser 寫回(語言 confidence 不足或精通度抽不到 → 跳過該筆) |
關鍵觀察:
- AI 嘗試抽 #1–#13 + #15–#24;#14(應徵方式)由系統於 placeholder 建立時預設
WPORT 職航站應徵 = true,不走 AI。 - 必填(#1–#14)抽不到 → 使用者於 edit-job 頁補齊才能通過 §11.9 gate;非必填(#15–#24)抽不到不影響刊登。
- #7 / #8 / #9 / #10 / #13 在 JD 中通常缺寫,AI 命中率預期低於 30%;#19 / #20 / #22 同樣命中率低,常需使用者手填。
- 非必填欄位納入 schema 是為了「JD 有寫就盡量帶入,省去使用者手 key」;抽不到一律 NULL/空陣列,嚴禁 LLM 自行補預設值(見 §11.1.2)。
- 「直接切刊登」可達性取決於 JD 完整度。後續若導入「公司層級預設值」(見 §12),可大幅提升該可達性。
11. 產品行為與保證(Product Behaviors & Guarantees)
本章定義 AI 匯入功能的對外行為契約:使用者看得到的狀態、欄位處理規則、配額政策、保留期限、發布門檻。Parser 內部架構、class/module 名稱、env、cron 排程、LLM provider strategy 等實作細節全部見工程 spec。
11.1 Dict Mapping 策略(影響 API 回傳值的契約)
§5.2 JobSchemaPayload 涵蓋的 dict-coded 欄位以兩種策略對應到 dict code,本表是 API contract 一部分(影響呼叫端可能拿到 null/空陣列的情境)。實作層的 embedding model、confidence 門檻、re-embed 觸發等細節見工程 spec。
A. 大 dict — Embedding match(4 張,confidence 不足會回 null)
| Dict 表 | Schema 欄位 | 備註 |
|---|---|---|
dict_area | work_area.area_code | hierarchical,取最深命中 |
dict_job_classification | job_classification_codes[] | hierarchical |
dict_job_skill | work_skill_codes[] | 多選 |
dict_skill_languages | language_skills[].language_code | 與 proficiency_level_code 配對;任一項 confidence 不足整筆跳過 |
B. 小/中 dict — Prompt-constrain(LLM 直接從清單選 code,超出清單視為 SCHEMA_INVALID)
| Dict 表 | Schema 欄位 | 大小 | 可選 code |
|---|---|---|---|
dict_job_feature | job_feature_code | 5 | full_time / part_time / internship / executive / dispatch |
dict_compensation_schemes | compensation.scheme_code | 3 | standard / piecework / negotiable |
dict_salary_unit | compensation.salary_unit_code | 4 | hourly / daily / monthly / yearly |
dict_working_hour_type | working_hour_codes[] | 5 | day_shift / evening_shift / night_shift / weekend_shift / rotating_shift |
dict_week_rest_policies | vacation_policy_code | 3 | company_rule / two_days / roster |
dict_available_start_period | available_start_period_code | 6 | anytime / week / two_weeks / month / two_months / three_months |
dict_headcount | headcount.code | 2 | unlimited / limited |
dict_work_experiences | work_experience_code | 4 | no_exp / over_1_year / over_3_years / over_5_years |
dict_degrees | education_requirement_codes[] | 6 | junior_high_or_below / senior_high / associate / bachelor / master / phd |
dict_business_travel | business_travel_codes[] | 2 | travel / expatriate |
dict_remote_modes | remote_work_code | 3 | none / partial / full |
dict_identity_type | welcome_identity_codes[] | 4 | second_employment / indigenous / disabled / mid_older_age |
dict_work_identity_type | welcome_work_identity_codes[] | 7 | overseas_hk_macao_student / intl_student_enrolled / intl_student_job_seeker / foreign_professional / new_resident_with_residence / migrant_worker_retention / other |
dict_driving_license_types | driving_license_codes[] | 11 | scooter / light_motorcycle / heavy_motorcycle / truck / bus / trailer / pro_small_car / pro_truck / pro_bus / pro_trailer / light_car |
dict_company_benefits | company_benefit_codes[] | 22 | 員工旅遊 / 供餐 / 交通補助 / 員工認股 … 等(完整清單見 DB) |
dict_department_class | department_class_codes[] | 23 | 工程 / 商管 / 設計 / 教育 … 等(完整清單見 DB) |
dict_proficiency_levels | language_skills[].proficiency_level_code | 4 | beginner / daily_convo / proficient / native |
- 嚴禁 LLM 自行補預設值:JD 沒寫的欄位一律輸出
null/[],不得猜「週休二日」「面議」等常見值,避免誤導使用者「已填妥」。 - 小 dict code 清單與 DB 對齊;dict 內容變更(新增 / 停用 / 改名)需同步更新本表。
11.2 佔位草稿(Placeholder Job)行為
jobs.statusenum 保持原樣(NOT_PUBLISHED / PUBLISHED),不新增PARSING值。解析中的狀態由parse_job_id IS NOT NULL && parse_job.status=parsing組合判斷(見 §10.3)。- POST
/ai-import成功後,使用者立刻看到一筆「解析中」草稿出現在 jobs list;title 顯示 i18n 預設文案(key 例:job.ai_import.default_title,由 locale 決定 zh-TW / en);薪資預設顯示「面議」。 - Parser 完成後依 §11.3 寫回;若解析失敗(§11.4 retry 流程),placeholder 暫不刪除(保留 audit)。
- 使用者離開 jobs 頁時 parse job 仍在後台執行;返回 list 時自動接續顯示最新狀態。
- Placeholder row 的具體 DB 結構、預設 dict id 取得方式、transaction 邊界等實作細節見工程 spec §2.4。
11.3 Parser 寫回保證
- 原子性:解析成功後,§5.2
JobSchemaPayload的全部欄位以一個原子操作寫回;不會出現「title 是 AI、description 還是預設」這種半完成狀態。 - 不覆蓋使用者編輯:若 retry 場景下舊 parser 延遲完成、或使用者已搶先編輯 placeholder(即使 §10.3 已要求 FE 解析中 read-only),寫回會 no-op、整批 rollback,絕不部分覆寫。
- Audit 保留:寫回失敗時,
parse_jobs.parsed_data仍完整保留供分析(不會因為沒寫回就丟失 LLM 結果)。
Optimistic lock 條件、transaction 邊界、INSERT vs UPDATE 策略等實作細節見工程 spec §4.4。
11.4 Retry 與配額政策
Retry 規則(使用者可見行為)
- 「再次解析」按鈕僅在
parse_job.status = failed出現;parsed不允許重抽(避免覆寫使用者已基於 AI 結果做的編輯)。對已解析結果不滿意 → 手動刪除草稿後重新建立。 - 按下後:建立新 parse_job row;舊 row 保留
failed終態作 audit(短期內不刪;長期清理見 §11.5)。 - Backend 卡住的舊解析會被 sweeper 機制守住,不影響新 parse_job 正確性(sweeper 細節見工程 spec §4.9)。
配額政策(產品規則)
-
單位與上限:每家公司、每個
target_type每日獨立計算;MVP 預設JOB = 20 次/天,每日 UTC 0:00 重置。 -
扣配額判斷:
情境 扣配額? 理由 新建 parse_job(非 retry) ✅ 扣 1 預設 Retry,舊 error_code = LLM_TIMEOUT❌ 不扣 後端/第三方故障,不算使用者頭上 Retry,舊 error_code = LLM_UNAVAILABLE❌ 不扣 同上 Retry,舊 error_code = SCHEMA_INVALID✅ 扣 1 LLM 亂吐或輸入品質差,算 1 次嘗試 Retry,舊 error_code = PARSED_EMPTY✅ 扣 1 使用者輸入資訊不足,token 已燒,算 1 次嘗試 Retry,舊 error_code = LLM_OUTPUT_INVALID✅ 扣 1 LLM 回傳無法解析為 JSON,算 1 次嘗試 Retry,舊 error_code = INPUT_EMPTY / INPUT_TOO_LARGE✅ 扣 1 純輸入問題(空白、超過 byte 上限) 配額已滿 — 直接回 HTTP 429+Retry-After,不建立 parse_job -
不設「免費 retry 次數上限」— 錯誤分類已足夠防濫用。
-
上限預設值、Redis key、retry peek 實作見工程 spec §3.6 / §6.2。
11.4.1 PARSED_EMPTY 判定規則(資訊不足)
LLM 正常完成且 schema 通過,但抽出的內容無法構成有意義的職缺草稿時,視為解析失敗並標 PARSED_EMPTY,不寫回子表,FE 顯示「文件資訊不足,無法解析出職缺內容,請補充更多細節後再試」並可走 retry。
判定條件(兩者全部成立才算 empty):
description為空字串或 null- 四大 dict 命中總數為 0:
len(job_classification_codes) + len(work_skill_codes) + len(department_class_codes) + len(language_skills) === 0
設計理由:
title不參與判定——LLM 從任何輸入幾乎都能擠出一行字串(甚至直接抄第一行),不是「LLM 真的看懂 JD」的訊號;只有description或 dict 命中才算抽到實質內容。因此「只有 title、其他全空」也視為 empty,引導使用者補 context 重試,而不是丟空殼草稿給使用者自己填。- description 是 fallback——只要 LLM 抓到一段有意義的職務描述,即使 dict 全 0 命中,仍視為成功,使用者進 edit-job 頁自行補齊。
- 不採用 LLM 自評 confidence score——可信度低,改用「實際命中欄位數」這類 deterministic 訊號。
- 實作時將上述條件封裝為 pure function(
_rules/is-parsed-payload-empty.rule.ts),spec 與 code 共用同一份規則,避免漂移。 - 註:早期 spec 曾列「五大 dict」並包含
company_benefit_codes,但JobSchemaPayload從一開始就沒有此欄位(company_benefit是 Company 維度,不附在 job 上),LLM prompt 也未抽福利。實作以 4 大為 SOT。
配額:照常扣 1 次。理由是 LLM token 已實際消耗,配額本質是成本控制;若不扣,攻擊者可貼廢話探測 LLM 行為而零成本。寬容處理應做在 FE 文案(明確告知扣了 1 次 + 提示如何避免),不要做在配額機制。
i18n path:error.ai_parser.parsed_empty(須走 i18n-generator 流程匯入,不可手動 SQL)。
11.5 資料保留政策
| 物件 | 保留期 | 期滿後行為 |
|---|---|---|
parse_jobs PII 內容(原始貼文 / Markdown / 解析結果) | 30 天 | PII 欄位清空,統計欄位(token、解析時長、錯誤碼)保留 |
| 失敗且未 retry 的草稿 | 7 天 | 一併硬刪除(避免使用者列表累積廢棄草稿) |
已發布職缺的 jobs.parse_job_id 對應 | 永久保留 | 即使原 parse_jobs PII 已過期清空,FK 仍在,供轉換率 / 留存率分析 |
上述清理由背景排程執行;cron 排程、清空欄位列表、判斷 SQL 等實作見工程 spec §4.9。
11.6 通知與 UX 補充
- 尚未建置 notification 前,預設 UX 為留在 jobs list 等待結果;後續可升級為背景通知導流。
11.7 既有系統影響面
| 層面 | 影響評估 | 處理方式 |
|---|---|---|
jobs.status enum | ❌ 不新增 PARSING | 解析中狀態靠 parse_job_id + parse_job.status 組合判斷(見 §10.3) |
| 既有 jobs list / search / filter 查詢 | ✅ 需驗證 placeholder 不影響結果 | placeholder 用 status=NOT_PUBLISHED,既有查詢若預設過濾「已刊登」不受影響;管理端若讀未刊登需補 parse_job.status != 'parsing' 過濾條件 |
| 既有「公司未發佈職缺上限」規則 | ⚠️ 需 PM 確認 | 建議 placeholder 在 parse_job.status=parsing 期間不計入未發佈上限,parsed 後才計入 |
| BR-008 完整度 gate 目前未實作 | ❌ 既有 bug:空白職缺可直接切刊登 | 本 PR 一併補實作(見 §11.9);docs/domain/business-context.md 對 BR-008 狀態的記載有誤,實作完成後需同步更新該 domain doc |
| 職缺審核流程(AMS) | ✅ 無直接影響 | 匯入只產生 NOT_PUBLISHED 草稿;走既有發布流程送審 |
| 公司 / 使用者權限 | ✅ 沿用既有 CREATE_JOB / TOGGLE_JOB_STATUS | 不新增權限碼 |
11.8 管理後台(W101-AMS)連動檢查
- 本功能無需 AMS 端變更:
- 審核流程沿用既有「發布前審核」路徑,AMS 不需知道職缺是 AI 匯入來源。
jobs.parse_job_id欄位對 AMS 讀取 API 透明(不需顯示)。
- 若未來 AMS 要呈現「AI 匯入職缺」篩選,再另行提 ticket。
11.9 Publish 完整度 Gate(BR-008)
背景:2026/04/22 trace 確認現行職缺發布流程沒有欄位完整度 gate — BR-008 並未實作;本 PR 一併補上。
11.9.1 行為契約
- 使用者按「切刊登」 → 後端在發布前檢查「§10.4 14 項必填」是否齊全。
- 任一缺失 → 回 HTTP 400 + 缺項代碼陣列;FE 對應 label 引導使用者補齊。
- 全部齊全 → 通過進入既有公司審核 / 上架流程。
Rule 在
publishJob流程中的位置、exception payload 結構等實作見工程 spec §4.x。
11.9.2 必填檢查清單(14 項,對應 §10.4 #1–#14)
| # | 檢查條件(schema 層) | 缺項代碼 |
|---|---|---|
| 1 | title 非空且不為 i18n 預設 title | job_title |
| 2 | job_feature_code 已選 | job_feature |
| 3 | job_classification_codes[] 至少 1 筆 | job_classifications |
| 4 | description 非空 | job_content |
| 5 | compensation 完整:scheme_code=standard 需 amount_min / amount_max / salary_unit_code;piecework / negotiable 免 | compensation |
| 6 | work_area.area_code 已選(或 hierarchical 至少 1 筆) | work_area |
| 7 | working_hour_codes[] 至少 1 筆 | work_hours |
| 8 | vacation_policy_code 已選 | vacation_policy |
| 9 | available_start_period_code 已選 | available_start_date |
| 10 | headcount.code 已選;若 code=limited 需 headcount.number > 0 | headcount |
| 11 | work_experience_code 已選 | work_experience |
| 12 | education_requirement_codes[] 至少 1 筆 | education_requirements |
| 13 | contact.name 非空 | contact_name |
| 14 | 應徵方式至少有一個為 true(WPORT 預設、電話、親洽、電子信箱、其他其一) | apply_method |
注意:本清單需與 FE edit-job 的必填
*標示保持同步。若 FE 調整(新增 / 移除必填),本清單同步更新,PRD 版本號遞增。 §10.4 #15–#24 為非必填,不進此 gate。
11.9.3 i18n key(FE / BE 共用契約)
- 新增
error.job.publish_required_fields_missing(zh-TW / en):- zh-TW:「職缺有必填欄位尚未填寫,請於編輯頁補齊後再切換刊登」
- en:「Required fields are missing. Please complete them in the edit page before publishing.」
- 缺項代碼(
job_title等)本身不做 i18n 翻譯;由 FE 對應既有欄位 label。
12. 下一步(Next Steps)
- 補齊 Storybook mockup 與
PM_36對應故事連結。 - i18n key
job.ai_import.default_title(zh-TW / en)文案由產品/設計確認並登錄,見 §11.2。 - FE 依 §10.3 狀態對照表補齊 jobs list 的解析中 / 失敗 row 視覺樣式(含「再次解析」按鈕僅在
failed出現的規則)。 - FE 依 §10.4 必填清單實作「切刊登」按下後顯示缺項;對齊 §11.9 後端回傳的
missing_fields。 - FE 在
parse_job.status = parsing期間將 edit-job 頁設為 read-only / disabled(§10.3 與 §11.3 的契約前提)。 - PM 確認 §11.7「公司未發佈職缺上限」與 placeholder 是否計入的規則。
- 更新 W101-TalentSearchHub
docs/domain/business-context.mdBR-008 狀態(見 §4.1 備註),實作完成後改為「✅ 已實作 viavalidateJobCompletenessForPublishRule」。 - 延後 / 另開 ticket 項目:
- 公司層級招募預設值:讓公司在「公司資訊」頁設定預設上班時段 / 休假 / 聯絡人 / 應徵方式等,AI 匯入 placeholder 時自動帶入;可大幅提高「AI 匯入後直接切刊登」的可行性(JD 通常缺寫的欄位多數可用預設值補)。
- 更多大 dict embedding 擴張:未來若
dict_department_class/dict_certificate/dict_tech_tool/dict_identity_type等需自動 mapping,再評估是否從 prompt-constrain 升級到 embedding。 - Resume(
target_type=RESUME)UX Flow 細節(本 PRD 重點在 JOB)。 - Prompt injection 安全威脅模型(AI 類功能應建立固定章節)。
- LLM 月度成本預估與 quota 依據再校準。
- locale 範圍:已擴張至 9 個(5 BCP47 主碼 + 4 短碼 alias):
zh-TW / en / en-US / vi / vi-VN / id / id-ID / th / th-TH。SOT =src/modules/jobs/dtos/create-job-ai-import.dto.tsSUPPORTED_LOCALES。
文件結束