Commit 3605d466 authored by 李文光's avatar 李文光

fix(jd): JD 粘贴解析保留序号/列表符号,匹配与外发时再清洗

粘贴 JD/需求模板解析时不再剥离 1、-、• 等列表符号,让「JD 与匹配标准」下的核心职责、任职要求展示为带序号/符号的列表;匹配评分与对外 JD 导出时统一去掉行首符号,避免污染关键词。
parent 0e40e7ce
...@@ -235,10 +235,20 @@ def _parse_internal(lines: list[str]) -> dict[str, str]: ...@@ -235,10 +235,20 @@ def _parse_internal(lines: list[str]) -> dict[str, str]:
def _join_section(lines: list[str], limit: int = 3000) -> str: def _join_section(lines: list[str], limit: int = 3000) -> str:
"""合并小节内容为多行文本,保留原文的序号/列表符号(如 1、2、3、或 -、•)。
列表符号在「JD 与匹配标准」等展示处希望原样显示,因此这里不再剥离;
外发 JD 与匹配在消费这些字段时(split_rule_lines / splitField)会再次清洗掉序号。
"""
cleaned: list[str] = [] cleaned: list[str] = []
for line in lines: for line in lines:
text = _clean_bullet(line) text = line.strip()
if text and text not in cleaned: if not text:
continue
# 只有列表符号、没有内容的空条目(如 "-"、"1、")不保留
if not _clean_bullet(text):
continue
if text not in cleaned:
cleaned.append(text) cleaned.append(text)
return "\n".join(cleaned)[:limit] return "\n".join(cleaned)[:limit]
...@@ -274,7 +284,8 @@ async def _parse_pasted_jd_llm(text: str, settings: Any) -> dict[str, Any] | Non ...@@ -274,7 +284,8 @@ async def _parse_pasted_jd_llm(text: str, settings: Any) -> dict[str, Any] | Non
1. 不编造:原文没有的字段一律返回空字符串;不确定的宁可留空,也不要猜测。 1. 不编造:原文没有的字段一律返回空字符串;不确定的宁可留空,也不要猜测。
2. jd = 岗位说明 1-2 句(定位 + 解决什么问题);原文没有概述段则留空。 2. jd = 岗位说明 1-2 句(定位 + 解决什么问题);原文没有概述段则留空。
3. responsibilities / requirements / mustHave / niceToHave / knockout / competency: 3. responsibilities / requirements / mustHave / niceToHave / knockout / competency:
按原文对应小节逐条要点抽取,多条用换行分隔,去掉序号与列表符号。 按原文对应小节逐条要点抽取,多条用换行分隔;保留原文的序号与列表符号
(如 1、2、3、或 -、•),原文没有序号或列表符号时就按换行分隔,不要自行添加。
4. matchKeywords:6-10 个岗位关键词,用中文逗号分隔。 4. matchKeywords:6-10 个岗位关键词,用中文逗号分隔。
5. title/department/workLocation/salaryRange/headcount:从基本信息或正文提取; 5. title/department/workLocation/salaryRange/headcount:从基本信息或正文提取;
headcount 只输出数字,没有则空字符串。 headcount 只输出数字,没有则空字符串。
......
...@@ -35,10 +35,21 @@ _RISK_DIMENSION_RULE = ( ...@@ -35,10 +35,21 @@ _RISK_DIMENSION_RULE = (
) )
def _strip_list_marker(value: str) -> str:
# JD 导入时会保留原文序号/列表符号(如 1、-、•),抽取匹配规则时需去掉这些前缀,
# 避免把「1、负责…」当成关键词去和简历文本匹配。
return re.sub(
r"^\s*(?:[-•·*▪◦>]+(?:\s+|$)|(?:\d+|[一二三四五六七八九十]+)\s*[.、).)]|\(\d+\)|(\d+)|[①②③④⑤⑥⑦⑧⑨⑩⑪⑫]+(?:\s+|$))",
"",
value,
).strip()
def split_rule_lines(value: str | list[str] | None) -> list[str]: def split_rule_lines(value: str | list[str] | None) -> list[str]:
if isinstance(value, list): if isinstance(value, list):
return [str(item).strip() for item in value if str(item).strip()] return [_strip_list_marker(str(item)) for item in value if str(item).strip()]
return [item.strip() for item in re.split(r"[\n,,、;;]+", str(value or "")) if item.strip()] text = "\n".join(_strip_list_marker(line) for line in str(value or "").splitlines())
return [item.strip() for item in re.split(r"[\n,,、;;]+", text) if item.strip()]
def job_criteria(job: dict[str, Any]) -> dict[str, list[str]]: def job_criteria(job: dict[str, Any]) -> dict[str, list[str]]:
......
...@@ -105,6 +105,8 @@ def test_local_parse_template_roundtrip(): ...@@ -105,6 +105,8 @@ def test_local_parse_template_roundtrip():
assert parsed["salaryRange"] == "20-35k" assert parsed["salaryRange"] == "20-35k"
assert "开发与成交" in parsed["jd"] assert "开发与成交" in parsed["jd"]
assert "合同谈判" in parsed["responsibilities"] assert "合同谈判" in parsed["responsibilities"]
# 保留原文序号/列表符号,让「JD 与匹配标准」展示时是带序号的有序列表
assert parsed["responsibilities"].splitlines()[0].startswith("1、")
assert "5 年以上大客户销售经验" in parsed["requirements"] assert "5 年以上大客户销售经验" in parsed["requirements"]
assert "500 万" in parsed["mustHave"] assert "500 万" in parsed["mustHave"]
assert "售电公司经验" in parsed["niceToHave"] assert "售电公司经验" in parsed["niceToHave"]
...@@ -123,6 +125,7 @@ def test_local_parse_generic_jd_without_internal(): ...@@ -123,6 +125,7 @@ def test_local_parse_generic_jd_without_internal():
parsed = parse_pasted_jd_local(GENERIC_JD) parsed = parse_pasted_jd_local(GENERIC_JD)
assert parsed["title"] == "" assert parsed["title"] == ""
assert "工程化" in parsed["responsibilities"] assert "工程化" in parsed["responsibilities"]
assert "1、" in parsed["responsibilities"]
assert "Vue3" in parsed["requirements"] assert "Vue3" in parsed["requirements"]
assert "微前端" in parsed["niceToHave"] assert "微前端" in parsed["niceToHave"]
for key in ("genderRestriction", "ageRestriction", "probationCriteria", "nonCompete", "verification"): for key in ("genderRestriction", "ageRestriction", "probationCriteria", "nonCompete", "verification"):
......
...@@ -1226,6 +1226,7 @@ const mainAction = async () => { ...@@ -1226,6 +1226,7 @@ const mainAction = async () => {
margin: 6px 0 0; margin: 6px 0 0;
color: var(--muted); color: var(--muted);
font-size: 13px; font-size: 13px;
white-space: pre-line;
} }
} }
} }
......
...@@ -2,6 +2,7 @@ import { defineStore } from 'pinia' ...@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { AUTH_STORAGE_KEY, seedState, DEFAULT_JOB_TYPES } from '@/utils/constants' import { AUTH_STORAGE_KEY, seedState, DEFAULT_JOB_TYPES } from '@/utils/constants'
import { JOB_STATUS } from '@/utils/jobStatus' import { JOB_STATUS } from '@/utils/jobStatus'
import { stripListMarkers } from '@/utils/job'
import * as api from '@/api/recruitment' import * as api from '@/api/recruitment'
import { import {
enrichCandidateFields, enrichCandidateFields,
...@@ -1000,7 +1001,7 @@ function buildJobSnapshot(job = {}) { ...@@ -1000,7 +1001,7 @@ function buildJobSnapshot(job = {}) {
function buildJobCriteria(job = {}) { function buildJobCriteria(job = {}) {
const split = (value) => const split = (value) =>
String(value || '') stripListMarkers(value)
.split(/[,、,;;\n\r]+/) .split(/[,、,;;\n\r]+/)
.map((item) => item.trim()) .map((item) => item.trim())
.filter(Boolean) .filter(Boolean)
......
import { describe, it, expect } from 'vitest'
import { stripListMarkers, splitRuleLines } from '../job'
// JD 导入会保留原文序号/列表符号(如 1、-、•),抽取规则时应按行去掉,避免污染关键词。
describe('stripListMarkers', () => {
it('strips ordered and unordered list prefixes per line', () => {
expect(stripListMarkers('1、制定计划\n2、推动落地')).toBe('制定计划\n推动落地')
expect(stripListMarkers('- 负责A\n• 负责B')).toBe('负责A\n负责B')
})
it('keeps plain lines unchanged', () => {
expect(stripListMarkers('本科及以上,5 年经验\n熟悉 Vue3')).toBe('本科及以上,5 年经验\n熟悉 Vue3')
})
})
describe('splitRuleLines', () => {
it('splits marker-preserved content into clean items', () => {
expect(splitRuleLines('1、制定计划\n2、推动落地')).toEqual(['制定计划', '推动落地'])
})
it('keeps existing enumeration splitting behavior', () => {
expect(splitRuleLines('负责投标、报价和合同谈判')).toEqual(['负责投标', '报价和合同谈判'])
expect(splitRuleLines('本科,5年经验')).toEqual(['本科', '5年经验'])
})
})
...@@ -13,7 +13,15 @@ const SECTION_FIELDS = [ ...@@ -13,7 +13,15 @@ const SECTION_FIELDS = [
function splitField(value = '', separators = /[;;\n]+/) { function splitField(value = '', separators = /[;;\n]+/) {
return String(value || '') return String(value || '')
.split(separators) .split(separators)
.map((item) => item.trim()) .map((item) =>
item
.trim()
.replace(
/^\s*(?:[-•·*▪◦>]+(?:\s+|$)|(?:\d+|[一二三四五六七八九十]+)\s*[.、).)]|\(\d+\)|(\d+)|[①②③④⑤⑥⑦⑧⑨⑩⑪⑫]+(?:\s+|$))/,
''
)
.trim()
)
.filter(Boolean) .filter(Boolean)
} }
......
import { extractKeywords } from './keywords.js' import { extractKeywords } from './keywords.js'
import { jobTypeLabel } from './constants.js' import { jobTypeLabel } from './constants.js'
export function splitRuleLines(value = '') { const LIST_MARKER_RE =
/^\s*(?:[-•·*▪◦>]+(?:\s+|$)|(?:\d+|[一二三四五六七八九十]+)\s*[.、).)]|\(\d+\)|(\d+)|[①②③④⑤⑥⑦⑧⑨⑩⑪⑫]+(?:\s+|$))/
// JD 导入时会保留原文序号/列表符号(如 1、-、•),抽取规则时按行去掉前缀,避免污染关键词。
export function stripListMarkers(value = '') {
return String(value || '') return String(value || '')
.split('\n')
.map((line) => line.replace(LIST_MARKER_RE, '').trim())
.join('\n')
}
export function splitRuleLines(value = '') {
return stripListMarkers(value)
.split(/[,、,;;\n\r]+/) .split(/[,、,;;\n\r]+/)
.map((item) => item.trim()) .map((item) => item.trim())
.filter(Boolean) .filter(Boolean)
......
...@@ -3,10 +3,11 @@ import { jobTypeLabel } from './constants.js' ...@@ -3,10 +3,11 @@ import { jobTypeLabel } from './constants.js'
import { jobCandidates } from './links.js' import { jobCandidates } from './links.js'
import { analysisNeedsRefresh, quickMatchNeedsRefresh } from './analysis.js' import { analysisNeedsRefresh, quickMatchNeedsRefresh } from './analysis.js'
import { JOB_STATUS, JD_STATUS, APPROVAL_STATUS, CHANNEL_STATUS } from './jobStatus.js' import { JOB_STATUS, JD_STATUS, APPROVAL_STATUS, CHANNEL_STATUS } from './jobStatus.js'
import { stripListMarkers } from './job.js'
export function buildJobCriteria(job = {}) { export function buildJobCriteria(job = {}) {
const split = (value) => const split = (value) =>
String(value || '') stripListMarkers(value)
.split(/[,、,;;\n\r]+/) .split(/[,、,;;\n\r]+/)
.map((item) => item.trim()) .map((item) => item.trim())
.filter(Boolean) .filter(Boolean)
......
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