◆ wport | pm_36

AI 匯入職缺 Universal Parser Mockup PRD(PM_36)

AI 匯入職缺(Universal Parser)

來源 doc/feature/pm_36/ai-job-import-universal-parser-mockup.md

AI 匯入職缺 Universal Parser Mockup PRD(PM_36)

此文件定義「AI 匯入職缺」與「履歷解析」共用同一條 Pipeline,供 backend-api-spec-generator 與前後端實作對齊。


1. 文件資訊

  • 文件類型:Mockup 需求規格書(前後端共用視圖)
  • 適用對象:前端開發、後端開發、產品、QA
  • 最後更新:2026/04/29
  • 版本:2.1.0
  • 對應 StorybookTBD(尚未建立對應 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_codesenior 改為 executivesalary_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.vue
    • W101-Web/main-web/packages/recruitment/src/views/jobs/edit-job/index.vue
    • W101-Web/main-web/packages/recruitment/src/components/JobManager/JobDescriptionCard.vue
    • W101-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 僅支援 RESUMEJOB
    • 職缺 AI 抽取涵蓋 §10.4 列出的可由 AI 抽取項目(涵蓋率與每項對照見 §10.4)。
    • Dict mapping 二分策略:embedding(4 張大 dict)+ prompt-constrain(17 張小/中 dict,≤ ~25 筆),詳見 §11.1.2。
  • 排除範圍
    • 不支援檔案上傳與 OCR;本版僅接受純文字輸入(input_type = TEXT)。
    • 不做多類型以外(如 cover letter、portfolio)的 schema 支援。
    • 不做直接發佈;匯入結果皆先進可編輯草稿,使用者於 edit-job 頁補齊必填後才可切刊登(BR-008,見 §11.9)。
    • 不做「公司層級預設值」回填機制(延後,見 §12);AI 抽不到的欄位由使用者於 edit-job 手動補齊。

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 並未實作此 gatepublishJob 只檢查 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通用解析請求參數匯入入口
UniversalParserCreateVmPOST 建立任務的回應建立後即時回傳
UniversalParseJobStatusVmGET 輪詢解析狀態的回應FE 輪詢直到 parsed/failed
ResumeSchemaPayload履歷結構化輸出(parsed_data 內容)履歷編輯頁
JobSchemaPayload職缺結構化輸出(parsed_data 內容)職缺編輯頁

5.2 實體欄位定義(TypeScript 介面)

本 API 採非同步輪詢模型:POST 建立解析任務 → 回 parse_job_id + placeholder draft_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 狀態輪詢

  1. POST 建立任務(同步):API 同一 DB transaction 內建立 parse_job + placeholder job + 必要 1:1 子表(細節見 §11.2),立即回 enc_parse_job_id + enc_draft_job_id
  2. 背景解析(非同步):後端 worker 把原始文字轉 Markdown → LLM 抽結構化 JSON → 通過 schema 驗證 → 透過 optimistic lock 寫回對應 jobs_* 子表(見 §11.3)。
  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 + placeholder jobs row + 必要的 1:1 子表(見 §11.2),fire-and-forget 觸發 process_document(parse_job_id);立刻回 UniversalParserCreateVm
    • Auth:需登入,identity = 企業模式(COMPANY),permission = CREATE_JOBcompany_id 取自登入 context,不由 request body 傳入(防越權)。
    • 配額超限:直接回 HTTP 429 + Retry-After header;不建立 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=parsedparsed_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)對應 dictLLM 抽取可行性AI 匯入後狀態
1職務名稱title✅ 高Parser 寫回
2工作性質job_feature_codedict_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_linedict_area(embedding)⚠️ 中Parser 寫回(match 不到 → area_code NULL,address_line 保留原文)
7上班時段working_hour_codes[]dict_working_hour_type(5,prompt)⚠️ 低 — JD 通常不寫Parser 寫回(抽不到 → 空陣列)
8休假制度vacation_policy_codedict_week_rest_policies(3,prompt)⚠️ 低 — JD 通常不寫Parser 寫回(抽不到 → NULL)
9可上班日available_start_period_codedict_available_start_period(6,prompt)⚠️ 低 — JD 通常不寫Parser 寫回(抽不到 → NULL)
10需求人數headcount.code + headcount.numberdict_headcount(2,prompt)⚠️ 低 — JD 偶爾寫「徵 N 名」Parser 寫回(抽不到 → 兩者 NULL)
11工作經驗work_experience_codeenum(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_codedict_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_areawork_area.area_codehierarchical,取最深命中
dict_job_classificationjob_classification_codes[]hierarchical
dict_job_skillwork_skill_codes[]多選
dict_skill_languageslanguage_skills[].language_codeproficiency_level_code 配對;任一項 confidence 不足整筆跳過

B. 小/中 dict — Prompt-constrain(LLM 直接從清單選 code,超出清單視為 SCHEMA_INVALID

Dict 表Schema 欄位大小可選 code
dict_job_featurejob_feature_code5full_time / part_time / internship / executive / dispatch
dict_compensation_schemescompensation.scheme_code3standard / piecework / negotiable
dict_salary_unitcompensation.salary_unit_code4hourly / daily / monthly / yearly
dict_working_hour_typeworking_hour_codes[]5day_shift / evening_shift / night_shift / weekend_shift / rotating_shift
dict_week_rest_policiesvacation_policy_code3company_rule / two_days / roster
dict_available_start_periodavailable_start_period_code6anytime / week / two_weeks / month / two_months / three_months
dict_headcountheadcount.code2unlimited / limited
dict_work_experienceswork_experience_code4no_exp / over_1_year / over_3_years / over_5_years
dict_degreeseducation_requirement_codes[]6junior_high_or_below / senior_high / associate / bachelor / master / phd
dict_business_travelbusiness_travel_codes[]2travel / expatriate
dict_remote_modesremote_work_code3none / partial / full
dict_identity_typewelcome_identity_codes[]4second_employment / indigenous / disabled / mid_older_age
dict_work_identity_typewelcome_work_identity_codes[]7overseas_hk_macao_student / intl_student_enrolled / intl_student_job_seeker / foreign_professional / new_resident_with_residence / migrant_worker_retention / other
dict_driving_license_typesdriving_license_codes[]11scooter / light_motorcycle / heavy_motorcycle / truck / bus / trailer / pro_small_car / pro_truck / pro_bus / pro_trailer / light_car
dict_company_benefitscompany_benefit_codes[]22員工旅遊 / 供餐 / 交通補助 / 員工認股 … 等(完整清單見 DB)
dict_department_classdepartment_class_codes[]23工程 / 商管 / 設計 / 教育 … 等(完整清單見 DB)
dict_proficiency_levelslanguage_skills[].proficiency_level_code4beginner / daily_convo / proficient / native
  • 嚴禁 LLM 自行補預設值:JD 沒寫的欄位一律輸出 null[],不得猜「週休二日」「面議」等常見值,避免誤導使用者「已填妥」。
  • 小 dict code 清單與 DB 對齊;dict 內容變更(新增 / 停用 / 改名)需同步更新本表。

11.2 佔位草稿(Placeholder Job)行為

  • jobs.status enum 保持原樣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✅ 扣 1LLM 亂吐或輸入品質差,算 1 次嘗試
    Retry,舊 error_code = PARSED_EMPTY✅ 扣 1使用者輸入資訊不足,token 已燒,算 1 次嘗試
    Retry,舊 error_code = LLM_OUTPUT_INVALID✅ 扣 1LLM 回傳無法解析為 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)

  1. description 為空字串或 null
  2. 四大 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 patherror.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 層)缺項代碼
1title 非空且不為 i18n 預設 titlejob_title
2job_feature_code 已選job_feature
3job_classification_codes[] 至少 1 筆job_classifications
4description 非空job_content
5compensation 完整:scheme_code=standardamount_min / amount_max / salary_unit_codepiecework / negotiablecompensation
6work_area.area_code 已選(或 hierarchical 至少 1 筆)work_area
7working_hour_codes[] 至少 1 筆work_hours
8vacation_policy_code 已選vacation_policy
9available_start_period_code 已選available_start_date
10headcount.code 已選;若 code=limitedheadcount.number > 0headcount
11work_experience_code 已選work_experience
12education_requirement_codes[] 至少 1 筆education_requirements
13contact.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.md BR-008 狀態(見 §4.1 備註),實作完成後改為「✅ 已實作 via validateJobCompletenessForPublishRule」。
  • 延後 / 另開 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.ts SUPPORTED_LOCALES

文件結束