Commit 55cc26ed authored by 李文光's avatar 李文光

feat(resumes): 简历解析异步化并在候选人列表实时显示进度

- 新增 resume_parse_tasks 异步解析任务表,独立于整包 state 同步

- POST /api/resume-tasks 落盘即返 taskId+文件信息+正则兜底字段,后台跑 LLM 深抽;GET 轮询/DELETE 清理,创建时自动清理 24h 前已完成/失败任务

- 新增/批量上传保存后立即关闭弹窗,候选人带 parseStatus 先入库,深抽结果回填

- 列表/详情显示解析中/解析失败进度,刷新后 boot 续跑未完成任务

- LLM 结构化抽取默认关推理(reasoning_effort=none)并收紧超时/重试
parent 7ebb14a3
...@@ -48,6 +48,10 @@ class Settings(BaseSettings): ...@@ -48,6 +48,10 @@ class Settings(BaseSettings):
openai_api_key: str = Field(default="", alias="OPENAI_API_KEY") openai_api_key: str = Field(default="", alias="OPENAI_API_KEY")
openai_base_url: str = Field(default="https://api.openai.com/v1", alias="OPENAI_BASE_URL") openai_base_url: str = Field(default="https://api.openai.com/v1", alias="OPENAI_BASE_URL")
openai_model: str = Field(default="gpt-4o-mini", alias="OPENAI_MODEL") openai_model: str = Field(default="gpt-4o-mini", alias="OPENAI_MODEL")
# 推理强度开关:本系统 LLM 走的是结构化抽取/评估,默认关闭推理(none),
# 避免 deepseek-v4 等推理模型输出大量 thinking token 拖慢响应、撑大用量。
# 需要更深入推理时设为 low/medium/high;切换到不支持该参数的非推理模型时设为空。
llm_reasoning_effort: str = Field(default="none", alias="LLM_REASONING_EFFORT")
# 分析报告分享(ADR 0002):默认关闭;开启后匿名分享接口才可用。 # 分析报告分享(ADR 0002):默认关闭;开启后匿名分享接口才可用。
report_share_enabled: bool = Field(default=False, alias="REPORT_SHARE_ENABLED") report_share_enabled: bool = Field(default=False, alias="REPORT_SHARE_ENABLED")
......
...@@ -286,6 +286,27 @@ class CandidateReport(Base): ...@@ -286,6 +286,27 @@ class CandidateReport(Base):
updated_at: Mapped[str] = mapped_column(String(40), default=utc_now_iso, nullable=False) updated_at: Mapped[str] = mapped_column(String(40), default=utc_now_iso, nullable=False)
class ResumeParseTask(Base):
"""简历异步解析任务(独立于整包 state 同步)。
候选人新增/批量上传后,文件落盘即返回 taskId,LLM 深度字段抽取在后台执行;
前端轮询 GET /api/resume-tasks/{id} 拿结果,回填到候选人记录后 DELETE 清理。
该表**刻意不加入** state_repository._OWNERED_MODELS,避免 PUT /api/state 整包覆盖时清空任务。
"""
__tablename__ = "resume_parse_tasks"
id: Mapped[str] = mapped_column(String(120), primary_key=True)
owner_id: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
filename: Mapped[str] = mapped_column(String(255), nullable=False)
stored_name: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[str] = mapped_column(String(40), default="parsing", nullable=False)
parsed_json: Mapped[str | None] = mapped_column(Text)
error: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[str] = mapped_column(String(40), default=utc_now_iso, nullable=False)
updated_at: Mapped[str] = mapped_column(String(40), default=utc_now_iso, nullable=False)
Index("idx_candidate_reports_candidate", CandidateReport.candidate_id) Index("idx_candidate_reports_candidate", CandidateReport.candidate_id)
Index("idx_candidates_primary_job", Candidate.primary_job_id) Index("idx_candidates_primary_job", Candidate.primary_job_id)
Index("idx_candidates_source", Candidate.source) Index("idx_candidates_source", Candidate.source)
......
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
from sqlalchemy import select
from sqlalchemy.orm import Session
from backend.app.models import ResumeParseTask, utc_now_iso
from backend.app.repositories.json_utils import dump_json, parse_json
TASK_PARSING = "parsing"
TASK_PARSED = "parsed"
TASK_ERROR = "error"
def create_task(session: Session, owner_id: str, filename: str, stored_name: str) -> str:
task_id = f"resume-task-{uuid4().hex[:16]}"
now = utc_now_iso()
session.add(
ResumeParseTask(
id=task_id,
owner_id=owner_id,
filename=filename,
stored_name=stored_name,
status=TASK_PARSING,
parsed_json=None,
error=None,
created_at=now,
updated_at=now,
)
)
session.commit()
return task_id
def get_task(session: Session, task_id: str, owner_id: str) -> ResumeParseTask | None:
return session.scalar(
select(ResumeParseTask).where(ResumeParseTask.id == task_id, ResumeParseTask.owner_id == owner_id)
)
def mark_task_result(
task_id: str,
status: str,
*,
parsed: dict[str, Any] | None = None,
error: str | None = None,
) -> None:
"""后台解析完成后更新任务状态;使用独立会话,避免依赖已关闭的请求会话。"""
from backend.app.db import SessionLocal
db = SessionLocal()
try:
task = db.get(ResumeParseTask, task_id)
if task is None:
return
task.status = status
if parsed is not None:
task.parsed_json = dump_json(parsed)
if error:
task.error = error
task.updated_at = utc_now_iso()
db.commit()
finally:
db.close()
def delete_task(session: Session, task_id: str, owner_id: str) -> bool:
task = get_task(session, task_id, owner_id)
if task is None:
return False
session.delete(task)
session.commit()
return True
def prune_old_tasks(session: Session, owner_id: str, max_age_seconds: int = 86400) -> int:
"""清理该工作区超过 max_age_seconds 的已完成/失败任务,避免孤立行堆积。"""
now = datetime.now(UTC)
cutoff = now.timestamp() - max_age_seconds
tasks = session.scalars(
select(ResumeParseTask).where(ResumeParseTask.owner_id == owner_id)
).all()
removed = 0
for task in tasks:
try:
ts = datetime.fromisoformat(task.updated_at.replace("Z", "+00:00")).timestamp()
except (ValueError, AttributeError):
ts = now.timestamp()
if task.status in (TASK_PARSED, TASK_ERROR) and ts < cutoff:
session.delete(task)
removed += 1
if removed:
session.commit()
return removed
def task_to_payload(task: ResumeParseTask) -> dict[str, Any]:
return {
"taskId": task.id,
"status": task.status,
"filename": task.filename,
"storedName": task.stored_name,
"parsed": parse_json(task.parsed_json, None) if task.parsed_json else None,
"error": task.error,
}
import asyncio
import mimetypes import mimetypes
from pathlib import Path
from fastapi import APIRouter, File, HTTPException, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from backend.app.db import get_db
from backend.app.dependencies import CurrentUser, get_current_user, resolve_owner
from backend.app.repositories.resume_task_repository import (
TASK_ERROR,
TASK_PARSED,
TASK_PARSING,
create_task,
delete_task,
get_task,
mark_task_result,
prune_old_tasks,
task_to_payload,
)
from backend.app.services.file_storage import resolve_resume_path, save_resume_bytes from backend.app.services.file_storage import resolve_resume_path, save_resume_bytes
from backend.app.services.resume_parser import parse_resume_path_async, parse_uploaded_bytes_async from backend.app.services.resume_parser import (
parse_resume_path,
parse_resume_path_async,
parse_uploaded_bytes_async,
)
router = APIRouter() router = APIRouter()
...@@ -23,12 +43,88 @@ async def upload_resume(resume: UploadFile = File(...)) -> dict: ...@@ -23,12 +43,88 @@ async def upload_resume(resume: UploadFile = File(...)) -> dict:
content = await resume.read() content = await resume.read()
saved = save_resume_bytes(content, resume.filename or "resume.bin", "upload") saved = save_resume_bytes(content, resume.filename or "resume.bin", "upload")
try: try:
parsed = await parse_resume_path_async(resolve_resume_path(saved["storedName"])) # 保存候选人时不能阻塞在 LLM 上:文件已落盘,这里用纯正则快速解析出结构,
# 交互式的 LLM 字段提取已由 /parse-resume(上传前预解析)提供,入库只需快速兜底。
parsed = parse_resume_path(resolve_resume_path(saved["storedName"]))
except Exception as exc: except Exception as exc:
parsed = {"parseWarning": str(exc)} parsed = {"parseWarning": str(exc)}
return {"ok": True, "file": {**saved, "url": f"/api/resumes/{saved['storedName']}"}, "parsed": parsed} return {"ok": True, "file": {**saved, "url": f"/api/resumes/{saved['storedName']}"}, "parsed": parsed}
@router.post("/resume-tasks")
async def create_resume_task(
resume: UploadFile = File(...),
db: Session = Depends(get_db),
_: CurrentUser = Depends(get_current_user),
owner: str = Depends(resolve_owner),
) -> dict:
"""新建简历解析任务:落盘后立即返回,LLM 深度字段抽取在后台执行。
响应中的 parsed 为纯正则快速兜底(用于候选人立即入库),真正的 LLM 完整抽取
完成后通过 GET /api/resume-tasks/{id} 轮询获取,前端回填候选人后 DELETE 清理。
"""
content = await resume.read()
saved = save_resume_bytes(content, resume.filename or "resume.bin", "task")
task_id = create_task(db, owner, saved["originalName"], saved["storedName"])
prune_old_tasks(db, owner)
path = resolve_resume_path(saved["storedName"])
# 快速正则兜底:文件已落盘,同步算一次作为候选人入库时的即时字段。
try:
regex_parsed = parse_resume_path(path) if path else {"parseWarning": "文件未落盘"}
except Exception as exc:
regex_parsed = {"parseWarning": str(exc)}
# 后台异步执行 LLM 完整抽取(读取文件 → 调用大模型 → 回写任务表)。
asyncio.create_task(_run_resume_parse(task_id, path))
return {
"ok": True,
"taskId": task_id,
"status": TASK_PARSING,
"file": {**saved, "url": f"/api/resumes/{saved['storedName']}"},
"parsed": regex_parsed,
}
@router.get("/resume-tasks/{task_id}")
def get_resume_task(
task_id: str,
db: Session = Depends(get_db),
_: CurrentUser = Depends(get_current_user),
owner: str = Depends(resolve_owner),
) -> dict:
task = get_task(db, task_id, owner)
if task is None:
raise HTTPException(status_code=404, detail="解析任务不存在")
return task_to_payload(task)
@router.delete("/resume-tasks/{task_id}")
def remove_resume_task(
task_id: str,
db: Session = Depends(get_db),
_: CurrentUser = Depends(get_current_user),
owner: str = Depends(resolve_owner),
) -> dict:
if not delete_task(db, task_id, owner):
raise HTTPException(status_code=404, detail="解析任务不存在")
return {"ok": True}
async def _run_resume_parse(task_id: str, path: Path | None) -> None:
"""后台解析任务:LLM 深抽成功写 parsed,异常写 error,不回写候选人(由前端轮询回填)。"""
if path is None:
mark_task_result(task_id, TASK_ERROR, error="简历文件不存在")
return
try:
parsed = await parse_resume_path_async(path)
except Exception as exc:
mark_task_result(task_id, TASK_ERROR, error=str(exc))
return
if not isinstance(parsed, dict):
mark_task_result(task_id, TASK_ERROR, error="解析结果异常")
return
mark_task_result(task_id, TASK_PARSED, parsed=parsed)
@router.get("/resumes/{filename}") @router.get("/resumes/{filename}")
def get_resume(filename: str) -> FileResponse: def get_resume(filename: str) -> FileResponse:
path = resolve_resume_path(filename) path = resolve_resume_path(filename)
......
...@@ -210,17 +210,43 @@ def _message_text(message: Any) -> str: ...@@ -210,17 +210,43 @@ def _message_text(message: Any) -> str:
return str(content or "") return str(content or "")
async def _chat_json(settings: Any, system: str, user: str, *, temperature: float = 0) -> dict[str, Any]: async def _chat_json(
settings: Any,
system: str,
user: str,
*,
temperature: float = 0,
timeout: float = 60,
max_retries: int = 1,
reasoning_effort: str | None = None,
) -> dict[str, Any]:
"""用 langchain-openai 的 ChatOpenAI 走 OpenAI 兼容接口,固定 JSON 模式。""" """用 langchain-openai 的 ChatOpenAI 走 OpenAI 兼容接口,固定 JSON 模式。"""
chat = ChatOpenAI( kwargs: dict[str, Any] = {
model=settings.effective_llm_model, "model": settings.effective_llm_model,
api_key=settings.effective_llm_api_key, "api_key": settings.effective_llm_api_key,
base_url=settings.effective_llm_base_url, "base_url": settings.effective_llm_base_url,
temperature=temperature, "temperature": temperature,
timeout=60, "timeout": timeout,
max_retries=1, "max_retries": max_retries,
).bind(response_format={"type": "json_object"}) }
response = await chat.ainvoke([SystemMessage(content=system), HumanMessage(content=user)]) # 结构抽取类任务默认不启用推理(none),减少 thinking token 带来的延迟与用量;
# 个别推理模型不支持 reasoning_effort 参数时去掉该参数重试一次,保证可回退。
effort = reasoning_effort or settings.llm_reasoning_effort or None
if effort:
kwargs["reasoning_effort"] = effort
def _build() -> ChatOpenAI:
return ChatOpenAI(**kwargs).bind(response_format={"type": "json_object"})
messages = [SystemMessage(content=system), HumanMessage(content=user)]
try:
response = await _build().ainvoke(messages)
except Exception:
if "reasoning_effort" in kwargs:
kwargs.pop("reasoning_effort")
response = await _build().ainvoke(messages)
else:
raise
return parse_json_from_text(_message_text(response)) return parse_json_from_text(_message_text(response))
...@@ -311,12 +337,16 @@ async def llm_resume_fields(text: str) -> dict[str, Any] | None: ...@@ -311,12 +337,16 @@ async def llm_resume_fields(text: str) -> dict[str, Any] | None:
"自我评价 / 个人优势段落的主观表述仅作参考,不得用来推断求职岗位、工作年限、学历等硬字段。\n\n" "自我评价 / 个人优势段落的主观表述仅作参考,不得用来推断求职岗位、工作年限、学历等硬字段。\n\n"
f"简历文本:\n{(text or '')[:16000]}" f"简历文本:\n{(text or '')[:16000]}"
) )
# 简历字段提取是高频、面向交互的功能:LLM 偶发缓慢/超时不应拖垮整个保存流程,
# 因此把单次调用收紧为 30s 且不重试,超时后由调用方立即回退纯正则解析。
try: try:
parsed = await _chat_json( parsed = await _chat_json(
settings, settings,
"你是企业招聘系统中的资深简历结构化解析助手。只返回 JSON,不要输出 Markdown。", "你是企业招聘系统中的资深简历结构化解析助手。只返回 JSON,不要输出 Markdown。",
user, user,
temperature=0, temperature=0,
timeout=30,
max_retries=0,
) )
except Exception: except Exception:
return None return None
......
...@@ -272,7 +272,7 @@ def test_fields_prompt_excludes_subjective_traits_from_skills(llm_enabled, monke ...@@ -272,7 +272,7 @@ def test_fields_prompt_excludes_subjective_traits_from_skills(llm_enabled, monke
"""需求14(解析链路):skills 只收硬技能,不收自我评价里的主观品质词;硬字段不被自述带偏。""" """需求14(解析链路):skills 只收硬技能,不收自我评价里的主观品质词;硬字段不被自述带偏。"""
captured = {} captured = {}
async def fake_chat_json(settings, system, user, *, temperature=0): async def fake_chat_json(settings, system, user, *, temperature=0, **kwargs):
captured["user"] = user captured["user"] = user
return { return {
"name": "王五", "name": "王五",
......
import time
def _resume_bytes() -> bytes:
return (
"张晓燕\n性别:女年龄:28\n电话:18734915261 邮箱:1822742034@qq.com\n销售支持专员\n教育经历\n"
"2017.09—2021.07 甘肃农业大学\n农林经济管理\n"
"主修课程:市场营销、管理学原理、人力资源管理"
).encode()
def test_resume_task_full_lifecycle(client):
"""上传 → 立即返回 taskId + 正则兜底 → 后台解析完成 → 删除任务。"""
uploaded = client.post(
"/api/resume-tasks",
files={"resume": ("张晓燕.txt", _resume_bytes(), "text/plain")},
)
assert uploaded.status_code == 200
body = uploaded.json()
assert body["ok"] is True
assert body["status"] == "parsing"
task_id = body["taskId"]
assert task_id.startswith("resume-task-")
assert body["file"]["url"].startswith("/api/resumes/")
# 正则兜底应立即可用(候选人入库靠它)
assert body["parsed"]["name"] == "张晓燕"
assert body["parsed"]["school"] == "甘肃农业大学"
# 轮询直到后台解析完成(测试环境无 LLM Key,走正则回退,应很快)
payload = None
for _ in range(100):
resp = client.get(f"/api/resume-tasks/{task_id}")
assert resp.status_code == 200
payload = resp.json()
if payload["status"] != "parsing":
break
time.sleep(0.05)
assert payload is not None
assert payload["status"] == "parsed"
assert payload["parsed"]["name"] == "张晓燕"
assert payload["parsed"]["jobTitle"] == "销售支持专员"
assert payload["parsed"]["school"] == "甘肃农业大学"
assert payload["parsed"]["major"] == "农林经济管理"
# 应用后清理任务,之后再查应为 404
deleted = client.delete(f"/api/resume-tasks/{task_id}")
assert deleted.status_code == 200
assert deleted.json()["ok"] is True
assert client.get(f"/api/resume-tasks/{task_id}").status_code == 404
def test_resume_task_owner_isolation(client):
"""任务按 owner 隔离:admin 仅能访问自己的工作区任务;换 owner 查询返回 404。"""
uploaded = client.post(
"/api/resume-tasks",
files={"resume": ("李四.txt", "李四 电话:13912345678 本科 北京大学 软件工程".encode(), "text/plain")},
)
task_id = uploaded.json()["taskId"]
resp = client.get(f"/api/resume-tasks/{task_id}", params={"owner": "someone_else"})
# resolve_owner: 非 admin 指定他人 owner 会 403;admin 指定他人但该任务不存在则 404。
# 这里 client 是 admin,因此会走 404(任务归属非 someone_else 工作区)。
assert resp.status_code == 404
client.delete(f"/api/resume-tasks/{task_id}")
...@@ -72,6 +72,30 @@ export async function uploadResumeFile(file) { ...@@ -72,6 +72,30 @@ export async function uploadResumeFile(file) {
return data return data
} }
// 简历异步解析任务:上传后立即返回 taskId + 文件信息 + 正则兜底字段,
// LLM 深度字段抽取在后台执行,前端通过 getResumeTask 轮询拿结果。
export async function createResumeTask(file) {
const formData = new FormData()
formData.append('resume', file)
const { data } = await http.post('/api/resume-tasks', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
if (data.error) throw new Error(data.error)
return data
}
export async function getResumeTask(taskId) {
const { data } = await http.get(`/api/resume-tasks/${taskId}`)
if (data.error) throw new Error(data.error)
return data
}
export async function deleteResumeTask(taskId) {
const { data } = await http.delete(`/api/resume-tasks/${taskId}`)
if (data.error) throw new Error(data.error)
return data
}
export async function analyzeResume(payload) { export async function analyzeResume(payload) {
const { data } = await http.post('/api/analyze-resume', payload) const { data } = await http.post('/api/analyze-resume', payload)
if (data.error) throw new Error(data.error) if (data.error) throw new Error(data.error)
......
...@@ -66,6 +66,7 @@ const handleFiles = async (uploadFile) => { ...@@ -66,6 +66,7 @@ const handleFiles = async (uploadFile) => {
id: uid('batch-resume'), id: uid('batch-resume'),
file, file,
status: 'parsing', status: 'parsing',
resumeTask: null,
parsed: {}, parsed: {},
jobId: store.candidateBatchMode === 'job' ? store.candidateBatchJobId : '', jobId: store.candidateBatchMode === 'job' ? store.candidateBatchJobId : '',
recommendedJobId: '', recommendedJobId: '',
...@@ -81,8 +82,11 @@ const handleFiles = async (uploadFile) => { ...@@ -81,8 +82,11 @@ const handleFiles = async (uploadFile) => {
parsing.value = true parsing.value = true
try { try {
const parsed = await store.parseResumeFile(entry.file) // 选文件即创建后台解析任务:正则兜底字段立即回显,LLM 深抽在后台,保存后轮询回填。
const task = await store.createResumeTask(entry.file)
if (!store.candidateBatchOpen) return if (!store.candidateBatchOpen) return
entry.resumeTask = task
const parsed = task?.parsed || {}
const suggestions = store.suggestJobs({ const suggestions = store.suggestJobs({
jobTitle: parsed.jobTitle || '', jobTitle: parsed.jobTitle || '',
resumeText: parsed.resumeText || '', resumeText: parsed.resumeText || '',
...@@ -97,17 +101,17 @@ const handleFiles = async (uploadFile) => { ...@@ -97,17 +101,17 @@ const handleFiles = async (uploadFile) => {
entry.suggestions = suggestions entry.suggestions = suggestions
entry.recommendedJobId = suggestions[0]?.jobId || '' entry.recommendedJobId = suggestions[0]?.jobId || ''
entry.parseWarning = parsed.parseWarning || '' entry.parseWarning = parsed.parseWarning || ''
entry.resumeFileDataUrl = task?.file?.url || ''
entry.resumeFilePreviewWarning = task?.file?.url ? '' : '原简历未写入后端存储,仅保留解析结果。'
entry.status = 'ready' entry.status = 'ready'
entry.jobId = store.candidateBatchMode === 'job' ? store.candidateBatchJobId : store.smartBatchJobId(entry) entry.jobId = store.candidateBatchMode === 'job' ? store.candidateBatchJobId : store.smartBatchJobId(entry)
entry.resumeFilePreviewWarning = ''
} catch (error) { } catch (error) {
entry.status = 'error' entry.status = 'error'
entry.error = error.message || '解析失败' entry.error = error.message || '创建解析任务失败'
} finally { } finally {
const stillParsing = store.candidateBatchEntries.some((item) => item.status === 'parsing') const stillParsing = store.candidateBatchEntries.some((item) => item.status === 'parsing')
store.candidateBatchParsing = stillParsing store.candidateBatchParsing = stillParsing
parsing.value = stillParsing parsing.value = stillParsing
if (!stillParsing) showToast('批量简历解析完成,请确认关联岗位')
} }
} }
...@@ -124,7 +128,7 @@ const entryStatusView = (entry) => { ...@@ -124,7 +128,7 @@ const entryStatusView = (entry) => {
const importBatch = async () => { const importBatch = async () => {
if (store.candidateBatchParsing || importing.value) { if (store.candidateBatchParsing || importing.value) {
showToast('简历仍在解析或入库中,请稍候', 'warning') showToast('简历仍在提交解析或入库中,请稍候', 'warning')
return return
} }
const importable = store.candidateBatchEntries.filter((entry) => entry.status === 'ready' && entry.jobId) const importable = store.candidateBatchEntries.filter((entry) => entry.status === 'ready' && entry.jobId)
...@@ -134,30 +138,21 @@ const importBatch = async () => { ...@@ -134,30 +138,21 @@ const importBatch = async () => {
} }
importing.value = true importing.value = true
try { try {
for (const entry of importable) {
if (!entry.file) continue
try {
entry.upload = await store.uploadResumeFile(entry.file)
} catch {
showToast(`上传原简历失败:${entry.file.name}`, 'error')
importing.value = false
return
}
}
// 需求 22:按 file_hash 库级去重——对比本人简历库与公司人才池已有简历的 hash // 需求 22:按 file_hash 库级去重——对比本人简历库与公司人才池已有简历的 hash
await store.loadPool() await store.loadPool()
const workspaceHashes = new Set(store.candidates.map((candidate) => candidate.resumeMeta?.hash).filter(Boolean)) const workspaceHashes = new Set(store.candidates.map((candidate) => candidate.resumeMeta?.hash).filter(Boolean))
const poolHashes = new Set(store.poolCandidates.map((candidate) => candidate.resumeMeta?.hash).filter(Boolean)) const poolHashes = new Set(store.poolCandidates.map((candidate) => candidate.resumeMeta?.hash).filter(Boolean))
const created = [] const created = []
const pendingTasks = []
let duplicateCount = 0 let duplicateCount = 0
let poolDuplicateCount = 0 let poolDuplicateCount = 0
importable.forEach((entry) => { importable.forEach((entry) => {
const upload = entry.upload || {} const task = entry.resumeTask || {}
const parsed = entry.parsed || upload.parsed || {} const parsed = entry.parsed || task.parsed || {}
const job = store.jobs.find((item) => item.id === entry.jobId) const job = store.jobs.find((item) => item.id === entry.jobId)
const name = cleanCandidateName(parsed.name || '', entry.file?.name) || '未命名候选人' const name = cleanCandidateName(parsed.name || '', entry.file?.name) || '未命名候选人'
const hash = String(upload.file?.fileHash || '') const hash = String(task.file?.fileHash || '')
const poolDuplicate = Boolean(hash && poolHashes.has(hash)) const poolDuplicate = Boolean(hash && poolHashes.has(hash))
const nameDuplicate = store.candidates.some( const nameDuplicate = store.candidates.some(
(candidate) => (candidate) =>
...@@ -174,12 +169,12 @@ const importBatch = async () => { ...@@ -174,12 +169,12 @@ const importBatch = async () => {
return return
} }
const resumeText = parsed.resumeText || '' const resumeText = parsed.resumeText || ''
const fileMeta = upload.file const fileMeta = task.file
? { ? {
name: upload.file.originalName || entry.file?.name, name: task.file.originalName || entry.file?.name,
type: upload.file.fileType || entry.file?.type || '', type: task.file.fileType || entry.file?.type || '',
size: upload.file.sizeBytes || entry.file?.size || 0, size: task.file.sizeBytes || entry.file?.size || 0,
hash: upload.file.fileHash || '', hash: task.file.fileHash || '',
} }
: entry.file : entry.file
? { name: entry.file.name, type: entry.file.type, size: entry.file.size } ? { name: entry.file.name, type: entry.file.type, size: entry.file.size }
...@@ -194,8 +189,8 @@ const importBatch = async () => { ...@@ -194,8 +189,8 @@ const importBatch = async () => {
match: 0, match: 0,
resumeName: fileMeta?.name || entry.file?.name || parsed.resumeName || '', resumeName: fileMeta?.name || entry.file?.name || parsed.resumeName || '',
resumeMeta: fileMeta, resumeMeta: fileMeta,
resumeFileDataUrl: upload?.file?.url || '', resumeFileDataUrl: task.file?.url || '',
resumeFilePreviewWarning: upload?.file?.url ? '' : '原简历未写入后端存储,仅保留解析结果。', resumeFilePreviewWarning: task.file?.url ? '' : '原简历未写入后端存储,仅保留解析结果。',
resumeText, resumeText,
phone: parsed.phone || '', phone: parsed.phone || '',
email: parsed.email || '', email: parsed.email || '',
...@@ -214,8 +209,12 @@ const importBatch = async () => { ...@@ -214,8 +209,12 @@ const importBatch = async () => {
parseWarning: parsed.parseWarning || '', parseWarning: parsed.parseWarning || '',
tags: ['新入库'], tags: ['新入库'],
evaluation: '批量上传入库', evaluation: '批量上传入库',
parseStatus: task.taskId ? 'parsing' : 'parsed',
parseError: '',
resumeTaskId: task.taskId || '',
}) })
created.push(candidate) created.push(candidate)
if (task.taskId) pendingTasks.push({ candidate, taskId: task.taskId })
store.logEvent('candidate_created', candidate, { store.logEvent('candidate_created', candidate, {
stage: candidate.stage, stage: candidate.stage,
source: candidate.source || '其他', source: candidate.source || '其他',
...@@ -241,6 +240,7 @@ const importBatch = async () => { ...@@ -241,6 +240,7 @@ const importBatch = async () => {
if (duplicateCount) message += `,工作区已存在跳过 ${duplicateCount} ` if (duplicateCount) message += `,工作区已存在跳过 ${duplicateCount} `
if (poolDuplicateCount) message += `,人才池已有 ${poolDuplicateCount} 份(可去人才池认领)` if (poolDuplicateCount) message += `,人才池已有 ${poolDuplicateCount} 份(可去人才池认领)`
showToast(message, 'success') showToast(message, 'success')
pendingTasks.forEach(({ candidate, taskId }) => store.enrichCandidateWithTask(candidate, taskId))
} finally { } finally {
importing.value = false importing.value = false
} }
...@@ -288,12 +288,14 @@ const importBatch = async () => { ...@@ -288,12 +288,14 @@ const importBatch = async () => {
accept=".pdf,.doc,.docx,.txt,.md" accept=".pdf,.doc,.docx,.txt,.md"
:on-change="handleFiles" :on-change="handleFiles"
> >
<el-button>{{ parsing ? '解析中...' : '选择多份简历' }}</el-button> <el-button>{{ parsing ? '提交解析中...' : '选择多份简历' }}</el-button>
</el-upload> </el-upload>
</el-form-item> </el-form-item>
</el-form> </el-form>
</section> </section>
<p class="batch-async-tip">选好文件后可直接确认入库;简历将在后台解析,进度实时显示在候选人列表。</p>
<div class="batch-upload-summary"> <div class="batch-upload-summary">
<span <span
>已选择 <b>{{ store.candidateBatchEntries.length }}</b></span >已选择 <b>{{ store.candidateBatchEntries.length }}</b></span
...@@ -312,7 +314,7 @@ const importBatch = async () => { ...@@ -312,7 +314,7 @@ const importBatch = async () => {
<el-table <el-table
:data="store.candidateBatchEntries" :data="store.candidateBatchEntries"
row-key="id" row-key="id"
empty-text="请选择多份简历文件,解析结果会逐行显示在这里。" empty-text="请选择多份简历文件;保存后将在候选人列表后台解析并实时显示进度。"
> >
<el-table-column label="候选人" min-width="150"> <el-table-column label="候选人" min-width="150">
<template #default="{ row }"> <template #default="{ row }">
...@@ -361,7 +363,7 @@ const importBatch = async () => { ...@@ -361,7 +363,7 @@ const importBatch = async () => {
<template #footer> <template #footer>
<el-button @click="close">取消</el-button> <el-button @click="close">取消</el-button>
<el-button type="primary" :disabled="importing || !canImport" @click="importBatch"> <el-button type="primary" :disabled="importing || !canImport" @click="importBatch">
{{ importing ? '正在保存原简历...' : `确认入库 ${ready ? `(${ready})` : ''}` }} {{ importing ? '正在入库...' : `确认入库 ${ready ? `(${ready})` : ''}` }}
</el-button> </el-button>
</template> </template>
</el-dialog> </el-dialog>
...@@ -378,6 +380,12 @@ const importBatch = async () => { ...@@ -378,6 +380,12 @@ const importBatch = async () => {
margin-bottom: 10px; margin-bottom: 10px;
} }
.batch-async-tip {
margin: 0 0 10px;
font-size: 12px;
color: var(--muted);
}
.batch-upload-summary { .batch-upload-summary {
display: flex; display: flex;
gap: 18px; gap: 18px;
......
...@@ -31,6 +31,8 @@ const form = reactive({ ...@@ -31,6 +31,8 @@ const form = reactive({
const resumeFile = ref(null) const resumeFile = ref(null)
const parsing = ref(false) const parsing = ref(false)
const saving = ref(false) const saving = ref(false)
// 选文件即创建后台解析任务:正则兜底字段立即回填表单,LLM 深抽在后台,保存后轮询回填候选人。
const resumeTask = ref(null)
const suggestions = computed(() => { const suggestions = computed(() => {
if (!form.resumeText && !form.jobTitle) return [] if (!form.resumeText && !form.jobTitle) return []
...@@ -43,6 +45,7 @@ const suggestions = computed(() => { ...@@ -43,6 +45,7 @@ const suggestions = computed(() => {
const close = () => { const close = () => {
store.candidateCreateOpen = false store.candidateCreateOpen = false
store.candidateCreateDraft = null store.candidateCreateDraft = null
resumeTask.value = null
} }
const onFileChange = async (uploadFile) => { const onFileChange = async (uploadFile) => {
...@@ -51,7 +54,9 @@ const onFileChange = async (uploadFile) => { ...@@ -51,7 +54,9 @@ const onFileChange = async (uploadFile) => {
resumeFile.value = file resumeFile.value = file
parsing.value = true parsing.value = true
try { try {
const parsed = await store.parseResumeFile(file) const task = await store.createResumeTask(file)
resumeTask.value = task
const parsed = task?.parsed || {}
if (parsed.name) form.name = cleanCandidateName(parsed.name, file.name) if (parsed.name) form.name = cleanCandidateName(parsed.name, file.name)
if (parsed.expectedSalary) form.expectedSalary = parsed.expectedSalary if (parsed.expectedSalary) form.expectedSalary = parsed.expectedSalary
if (parsed.jobTitle) { if (parsed.jobTitle) {
...@@ -70,7 +75,7 @@ const onFileChange = async (uploadFile) => { ...@@ -70,7 +75,7 @@ const onFileChange = async (uploadFile) => {
.join(' · ') .join(' · ')
form.evaluation = `${schoolNote ? `已识别教育背景:${schoolNote}。` : ''}已识别技能:${parsed.skills.join('、')}。` form.evaluation = `${schoolNote ? `已识别教育背景:${schoolNote}。` : ''}已识别技能:${parsed.skills.join('、')}。`
} }
showToast('简历字段已解析,请确认岗位匹配后保存') showToast('简历已提交解析,可确认岗位后保存,详细字段将在后台补齐')
} catch (error) { } catch (error) {
showToast(error.message, 'error') showToast(error.message, 'error')
} finally { } finally {
...@@ -89,7 +94,6 @@ const pickJob = (jobId) => { ...@@ -89,7 +94,6 @@ const pickJob = (jobId) => {
const submit = async () => { const submit = async () => {
if (saving.value) return if (saving.value) return
const file = resumeFile.value const file = resumeFile.value
let parsedResume = {}
let resumeText = form.resumeText let resumeText = form.resumeText
let resumeFileDataUrl = '' let resumeFileDataUrl = ''
let resumeFilePreviewWarning = '' let resumeFilePreviewWarning = ''
...@@ -106,19 +110,25 @@ const submit = async () => { ...@@ -106,19 +110,25 @@ const submit = async () => {
showToast('请选择并确认关联岗位', 'warning') showToast('请选择并确认关联岗位', 'warning')
return return
} }
const pendingParseTaskId = resumeTask.value?.taskId || ''
let parsedResume = {}
saving.value = true saving.value = true
try { try {
if (file) { if (file) {
const upload = await store.uploadResumeFile(file) // 选文件时已创建后台解析任务,这里直接用其返回的文件信息 + 正则兜底字段;
parsedResume = upload?.parsed || parsedResume // 真正的 LLM 深抽由 enrichCandidateWithTask 在保存后轮询回填。
resumeFileDataUrl = upload?.file?.url || '' const task = resumeTask.value
resumeFilePreviewWarning = upload?.file?.url ? '' : '原简历未写入后端存储,仅保留解析结果。' parsedResume = { ...(task?.parsed || parsedResume) }
if (upload?.file) { resumeFileDataUrl = task?.file?.url || ''
resumeFilePreviewWarning = task?.file?.url ? '' : '原简历未写入后端存储,仅保留解析结果。'
if (task?.file) {
fileMeta = { fileMeta = {
name: upload.file.originalName || file.name, name: task.file.originalName || file.name,
type: upload.file.fileType || file.type, type: task.file.fileType || file.type,
size: upload.file.sizeBytes || file.size, size: task.file.sizeBytes || file.size,
hash: task.file.fileHash || '',
} }
} }
resumeText = parsedResume.resumeText || resumeText resumeText = parsedResume.resumeText || resumeText
...@@ -153,6 +163,9 @@ const submit = async () => { ...@@ -153,6 +163,9 @@ const submit = async () => {
expectedSalary: parsedResume.expectedSalary || form.expectedSalary, expectedSalary: parsedResume.expectedSalary || form.expectedSalary,
tags: ['新入库'], tags: ['新入库'],
evaluation: form.evaluation, evaluation: form.evaluation,
parseStatus: file && pendingParseTaskId ? 'parsing' : 'parsed',
parseError: '',
resumeTaskId: pendingParseTaskId,
}) })
store.candidates.unshift(candidate) store.candidates.unshift(candidate)
store.reconcile() store.reconcile()
...@@ -162,10 +175,12 @@ const submit = async () => { ...@@ -162,10 +175,12 @@ const submit = async () => {
store.selectedCandidateId = candidate.id store.selectedCandidateId = candidate.id
store.candidateCreateDraft = null store.candidateCreateDraft = null
store.candidateCreateOpen = false store.candidateCreateOpen = false
store.resumeViewMode = 'detail' store.resumeViewMode = 'list'
store.resumeDetailSection = 'overview'
store.persist() store.persist()
showToast('候选人已保存并完成本地匹配') showToast(file ? '候选人已保存,正在后台解析简历' : '候选人已保存并完成本地匹配')
if (pendingParseTaskId) {
store.enrichCandidateWithTask(candidate, pendingParseTaskId)
}
} catch (error) { } catch (error) {
showToast(`保存失败:${error.message}`, 'error') showToast(`保存失败:${error.message}`, 'error')
} finally { } finally {
...@@ -189,8 +204,8 @@ const submit = async () => { ...@@ -189,8 +204,8 @@ const submit = async () => {
<div class="block-head"> <div class="block-head">
<b>01</b> <b>01</b>
<div> <div>
<strong>上传并解析简历</strong> <strong>上传简历并确认岗位</strong>
<p>支持 PDF、DOCX、TXT;上传后自动提取姓名、教育背景、经历与技能</p> <p>支持 PDF、DOCX、TXT;保存后系统在后台自动提取字段,进度可在候选人列表查看</p>
</div> </div>
</div> </div>
<el-upload <el-upload
...@@ -199,7 +214,7 @@ const submit = async () => { ...@@ -199,7 +214,7 @@ const submit = async () => {
accept=".pdf,.doc,.docx,.txt,.md" accept=".pdf,.doc,.docx,.txt,.md"
:on-change="onFileChange" :on-change="onFileChange"
> >
<el-button>{{ parsing ? '解析中...' : '选择简历文件' }}</el-button> <el-button>{{ parsing ? '提交解析中...' : '选择简历文件' }}</el-button>
</el-upload> </el-upload>
</section> </section>
...@@ -272,8 +287,8 @@ const submit = async () => { ...@@ -272,8 +287,8 @@ const submit = async () => {
<div v-if="saving" class="save-mask" role="status" aria-live="polite"> <div v-if="saving" class="save-mask" role="status" aria-live="polite">
<div class="save-card"> <div class="save-card">
<div class="save-spinner" aria-hidden="true"></div> <div class="save-spinner" aria-hidden="true"></div>
<strong>正在保存并匹配</strong> <strong>正在保存候选人</strong>
<span>正在上传原简历并生成快速匹配,请勿关闭或重复点击</span> <span>已上传简历,解析将在后台进行,候选人会立即出现在列表中</span>
</div> </div>
</div> </div>
</transition> </transition>
......
...@@ -820,6 +820,14 @@ const saveInterview = (candidate) => { ...@@ -820,6 +820,14 @@ const saveInterview = (candidate) => {
@opened="onDrawerOpened" @opened="onDrawerOpened"
> >
<div v-if="selected" class="dossier"> <div v-if="selected" class="dossier">
<div v-if="selected.parseStatus === 'parsing'" class="parse-banner is-parsing" role="status" aria-live="polite">
<span class="parse-dot" aria-hidden="true"></span>
简历正在后台解析,姓名、教育经历与匹配度将自动补全…
</div>
<div v-else-if="selected.parseStatus === 'error'" class="parse-banner is-error">
简历解析失败:{{ selected.parseError || '请稍后重试或修正字段' }}
</div>
<header class="dossier-head"> <header class="dossier-head">
<div class="head-left"> <div class="head-left">
<el-button class="head-back" text circle aria-label="返回候选人列表" title="返回候选人列表" @click="close"> <el-button class="head-back" text circle aria-label="返回候选人列表" title="返回候选人列表" @click="close">
...@@ -1399,6 +1407,45 @@ const saveInterview = (candidate) => { ...@@ -1399,6 +1407,45 @@ const saveInterview = (candidate) => {
background: var(--dossier-bg); background: var(--dossier-bg);
} }
// ---- 后台解析状态横幅 ----
.parse-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 18px;
font-size: 12px;
line-height: 1.5;
border-bottom: 1px solid var(--dossier-line);
&.is-parsing {
background: color-mix(in srgb, var(--yellow) 16%, #fff);
color: color-mix(in srgb, var(--yellow) 52%, var(--ink));
}
&.is-error {
background: color-mix(in srgb, var(--rose) 12%, #fff);
color: color-mix(in srgb, var(--rose) 80%, var(--ink));
}
.parse-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
animation: resume-parse-pulse 1s ease-in-out infinite;
}
}
@keyframes resume-parse-pulse {
0%,
100% {
opacity: 0.35;
}
50% {
opacity: 1;
}
}
// ---- 头部(玻璃拟态)---- // ---- 头部(玻璃拟态)----
.dossier-head { .dossier-head {
display: flex; display: flex;
......
...@@ -190,6 +190,12 @@ const stageToneClass = (stage) => { ...@@ -190,6 +190,12 @@ const stageToneClass = (stage) => {
return 'tone-neutral' return 'tone-neutral'
} }
const parseStatusView = (candidate) => {
if (candidate.parseStatus === 'parsing') return { text: '解析中', type: 'warning' }
if (candidate.parseStatus === 'error') return { text: '解析失败', type: 'danger' }
return null
}
const profileSummary = (candidate) => { const profileSummary = (candidate) => {
const bits = [candidate.education, candidate.school, candidate.major].filter(Boolean) const bits = [candidate.education, candidate.school, candidate.major].filter(Boolean)
const years = Number(candidate.years || 0) const years = Number(candidate.years || 0)
...@@ -472,6 +478,9 @@ const openBatch = () => { ...@@ -472,6 +478,9 @@ const openBatch = () => {
<div class="candidate-copy"> <div class="candidate-copy">
<div class="candidate-name-row"> <div class="candidate-name-row">
<strong>{{ candidate.name }}</strong> <strong>{{ candidate.name }}</strong>
<span v-if="parseStatusView(candidate)" class="parse-chip" :class="parseStatusView(candidate).type">
{{ parseStatusView(candidate).text }}
</span>
<span class="stage-chip" :class="stageToneClass(candidate.stage)">{{ <span class="stage-chip" :class="stageToneClass(candidate.stage)">{{
candidate.stage || '未筛选' candidate.stage || '未筛选'
}}</span> }}</span>
...@@ -910,6 +919,51 @@ const openBatch = () => { ...@@ -910,6 +919,51 @@ const openBatch = () => {
} }
} }
.parse-chip {
display: inline-flex;
align-items: center;
gap: 4px;
height: 22px;
padding: 0 9px;
border-radius: 999px;
font-size: 11px;
white-space: nowrap;
&.warning {
background: color-mix(in srgb, var(--yellow) 18%, #fff);
color: color-mix(in srgb, var(--yellow) 55%, var(--ink));
}
&.danger {
background: color-mix(in srgb, var(--rose) 12%, #fff);
color: color-mix(in srgb, var(--rose) 80%, var(--ink));
}
&::before {
content: '';
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
}
.parse-chip.warning {
&::before {
animation: parse-pulse 1s ease-in-out infinite;
}
}
@keyframes parse-pulse {
0%,
100% {
opacity: 0.35;
}
50% {
opacity: 1;
}
}
.refresh-chip { .refresh-chip {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
......
...@@ -320,6 +320,7 @@ export const useRecruitmentStore = defineStore('recruitment', { ...@@ -320,6 +320,7 @@ export const useRecruitmentStore = defineStore('recruitment', {
} finally { } finally {
await this.loadJobTypes() await this.loadJobTypes()
this.loading = false this.loading = false
this.resumePendingParseTasks()
} }
}, },
...@@ -848,6 +849,109 @@ export const useRecruitmentStore = defineStore('recruitment', { ...@@ -848,6 +849,109 @@ export const useRecruitmentStore = defineStore('recruitment', {
return api.uploadResumeFile(file) return api.uploadResumeFile(file)
}, },
// ---- 简历异步解析:候选人先入库(正则兜底),LLM 深抽在后台,轮询回填 ----
async createResumeTask(file) {
if (window.location.protocol === 'file:') {
return {
taskId: '',
status: 'parsed',
file: null,
parsed: { resumeName: file.name, parseWarning: '当前为 file 模式,未调用解析服务。' },
}
}
return api.createResumeTask(file)
},
async pollResumeTask(taskId) {
if (!taskId) return { status: 'parsed', parsed: {} }
let attempts = 0
for (;;) {
const payload = await api.getResumeTask(taskId)
if (payload.status === 'parsed' || payload.status === 'error') {
try {
await api.deleteResumeTask(taskId)
} catch {
/* 清理失败不影响结果 */
}
return payload
}
attempts += 1
// 冷启动/偶发超时:最多轮询约 96s,仍 parsing 时由下次 boot 续跑
if (attempts >= 120) return payload
await new Promise((resolve) => setTimeout(resolve, 800))
}
},
applyParsedToCandidate(candidate, parsed = {}) {
const fileName = candidate.resumeMeta?.name || candidate.resumeName || ''
const currentName = candidate.name || ''
const nameLooksLikeFile =
!currentName || currentName === fileName || /\.[a-z0-9]{2,5}$/i.test(currentName)
if (nameLooksLikeFile && parsed.name) {
candidate.name = cleanCandidateName(parsed.name, fileName)
}
const fill = (key) => {
const value = parsed[key]
if (value !== undefined && value !== null && value !== '' && !candidate[key]) candidate[key] = value
}
;[
'jobTitle',
'expectedSalary',
'phone',
'email',
'birthDate',
'age',
'years',
'education',
'school',
'major',
'city',
'source',
].forEach(fill)
if (parsed.skills?.length && !candidate.skills?.length) candidate.skills = parsed.skills
if (parsed.schoolTags?.length && !candidate.schoolTags?.length) candidate.schoolTags = parsed.schoolTags
if (parsed.resumeText && !candidate.resumeText) candidate.resumeText = parsed.resumeText
if (parsed.parseWarning) candidate.parseWarning = parsed.parseWarning
Object.assign(candidate, enrichCandidateFields(candidate))
return candidate
},
async enrichCandidateWithTask(candidate, taskId) {
if (!candidate || !taskId) return
candidate.parseStatus = 'parsing'
candidate.resumeTaskId = taskId
let payload
try {
payload = await this.pollResumeTask(taskId)
} catch (error) {
candidate.parseStatus = 'error'
candidate.parseError = error.message || '简历解析失败'
candidate.resumeTaskId = ''
this.persist()
return
}
if (payload.parsed) this.applyParsedToCandidate(candidate, payload.parsed)
if (payload.status === 'parsed') {
candidate.parseStatus = 'parsed'
candidate.resumeTaskId = ''
} else if (payload.status === 'error') {
candidate.parseStatus = 'error'
candidate.parseError = payload.error || '简历解析失败'
candidate.resumeTaskId = ''
} else {
// 仍解析中:保留 taskId,下一次 boot 续跑
candidate.parseStatus = 'parsing'
}
if (candidate.resumeText) this.localMatch(candidate.id)
this.persist()
},
resumePendingParseTasks() {
this.state.candidates
.filter((candidate) => candidate.parseStatus === 'parsing' && candidate.resumeTaskId)
.forEach((candidate) => this.enrichCandidateWithTask(candidate, candidate.resumeTaskId))
},
refreshOfferMaterials(offer) { refreshOfferMaterials(offer) {
const candidate = this.state.candidates.find((item) => item.id === offer.candidateId) || {} const candidate = this.state.candidates.find((item) => item.id === offer.candidateId) || {}
const materials = buildOfferApprovalMaterials(offer, candidate) const materials = buildOfferApprovalMaterials(offer, candidate)
......
...@@ -241,5 +241,8 @@ export function normalizeCandidate(candidate = {}, jobs = []) { ...@@ -241,5 +241,8 @@ export function normalizeCandidate(candidate = {}, jobs = []) {
tags: normalizeCandidateTags(candidate), tags: normalizeCandidateTags(candidate),
expectedSalary: enriched.expectedSalary, expectedSalary: enriched.expectedSalary,
evaluation: /本地匹配度为|当前只有简历文件名|已匹配到/.test(candidate.evaluation || '') ? '' : candidate.evaluation, evaluation: /本地匹配度为|当前只有简历文件名|已匹配到/.test(candidate.evaluation || '') ? '' : candidate.evaluation,
parseStatus: candidate.parseStatus || 'parsed',
parseError: candidate.parseError || '',
resumeTaskId: candidate.resumeTaskId || '',
} }
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment