extractor.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795
  1. from __future__ import annotations
  2. import json
  3. import re
  4. from dataclasses import dataclass
  5. from datetime import date
  6. from pathlib import Path
  7. from typing import Any, Callable
  8. from app.hierarchy_extract import (
  9. DEFAULT_CAUSE_TYPE,
  10. build_elements_hierarchy_for_cause,
  11. load_hierarchy_templates,
  12. )
  13. _WS = r"[ \t\u3000]*"
  14. def _clean_text(text: str) -> str:
  15. if not text:
  16. return ""
  17. text = text.replace("\r\n", "\n").replace("\r", "\n")
  18. text = re.sub(r"[ \t\u3000]+", " ", text)
  19. return text
  20. def _first_group(pattern: str, text: str, flags: int = 0) -> str | None:
  21. m = re.search(pattern, text, flags)
  22. if not m:
  23. return None
  24. for i in range(1, (m.lastindex or 0) + 1):
  25. g = m.group(i)
  26. if g is not None and str(g).strip():
  27. return str(g).strip()
  28. return None
  29. def _all_matches(pattern: str, text: str, flags: int = 0, group: int = 0) -> list[str]:
  30. out: list[str] = []
  31. for m in re.finditer(pattern, text, flags):
  32. out.append((m.group(group) if group else m.group(0)).strip())
  33. return out
  34. _CN_NUM = {
  35. "零": 0,
  36. "〇": 0,
  37. "一": 1,
  38. "二": 2,
  39. "两": 2,
  40. "三": 3,
  41. "四": 4,
  42. "五": 5,
  43. "六": 6,
  44. "七": 7,
  45. "八": 8,
  46. "九": 9,
  47. }
  48. _CN_UNIT = {"十": 10, "百": 100, "千": 1000, "万": 10000, "亿": 100000000}
  49. def parse_cn_number(s: str) -> float | None:
  50. """
  51. 解析常见中文金额(如:八千元、二万三千、十二万)为数字。
  52. 只覆盖毕业设计 MVP 常见写法;不处理复杂小数表达。
  53. """
  54. if not s:
  55. return None
  56. s = s.strip()
  57. s = re.sub(r"[元整人民币¥\s]", "", s)
  58. if not s:
  59. return None
  60. # 纯中文数字/单位
  61. total = 0
  62. section = 0
  63. number = 0
  64. has_any = False
  65. for ch in s:
  66. if ch in _CN_NUM:
  67. number = _CN_NUM[ch]
  68. has_any = True
  69. elif ch in _CN_UNIT:
  70. unit = _CN_UNIT[ch]
  71. has_any = True
  72. if unit >= 10000:
  73. section = (section + (number or 0)) * unit
  74. total += section
  75. section = 0
  76. else:
  77. section += (number or 1) * unit
  78. number = 0
  79. else:
  80. return None
  81. return float(total + section + number) if has_any else None
  82. def parse_amount(text: str) -> float | None:
  83. """
  84. 支持:8000元、8,000元、¥8000、八千元 等
  85. """
  86. if not text:
  87. return None
  88. text = text.strip()
  89. # 数字 + 元
  90. m = re.search(r"(?:¥|人民币)?\s*([0-9]{1,3}(?:,[0-9]{3})*|[0-9]+)(?:\.[0-9]{1,2})?\s*元", text)
  91. if m:
  92. raw = m.group(1).replace(",", "")
  93. try:
  94. return float(raw)
  95. except ValueError:
  96. pass
  97. # 中文金额
  98. m2 = re.search(r"([零〇一二两三四五六七八九十百千万亿]+)\s*元", text)
  99. if m2:
  100. return parse_cn_number(m2.group(1))
  101. return None
  102. def parse_date_any(s: str) -> str | None:
  103. """
  104. 统一解析日期格式并输出 YYYY-MM-DD(字符串)。
  105. 支持:
  106. - 2020年1月1日
  107. - 2020.01.01
  108. - 2020-01-01
  109. - 2020/1/1
  110. """
  111. if not s:
  112. return None
  113. s = s.strip()
  114. m = re.search(r"([12][0-9]{3})\s*[年./-]\s*([01]?[0-9])\s*[月./-]\s*([0-3]?[0-9])\s*(?:日)?", s)
  115. if not m:
  116. return None
  117. y, mo, d = int(m.group(1)), int(m.group(2)), int(m.group(3))
  118. try:
  119. _ = date(y, mo, d)
  120. except ValueError:
  121. return None
  122. return f"{y:04d}-{mo:02d}-{d:02d}"
  123. CAUSE_KEYWORDS: list[tuple[str, list[str]]] = [
  124. ("工资报酬", ["工资", "拖欠工资", "未支付工资", "工资差额", "薪资", "报酬"]),
  125. ("经济补偿", ["经济补偿", "补偿金", "N+1", "补偿"]),
  126. ("工伤待遇", ["工伤", "工伤待遇", "伤残", "一次性伤残", "医疗费", "停工留薪"]),
  127. ("违法解除劳动合同", ["违法解除", "非法解除", "无故解除", "违法辞退", "解除劳动合同赔偿"]),
  128. ("加班费", ["加班费", "加班工资", "延时加班", "休息日加班", "法定节假日加班", "加班"]),
  129. ("未订立书面劳动合同", ["未签订劳动合同", "未订立书面劳动合同", "双倍工资", "二倍工资"]),
  130. ("年休假", ["年休假", "未休年假", "带薪年休假"]),
  131. ("社会保险", ["社会保险", "社保", "养老保险", "医疗保险", "失业保险", "未缴纳社保"]),
  132. ]
  133. _SCHEMA_PATH = Path(__file__).resolve().parent.parent / "data" / "case_elements_schema.json"
  134. def load_case_elements_schema() -> dict[str, Any]:
  135. if not _SCHEMA_PATH.is_file():
  136. return {"groups": [], "field_labels": {}, "table_name": "", "version": ""}
  137. try:
  138. return json.loads(_SCHEMA_PATH.read_text(encoding="utf-8"))
  139. except Exception:
  140. return {"groups": [], "field_labels": {}, "table_name": "", "version": ""}
  141. def _table_rows_for_ids(flat: dict[str, Any], labels: dict[str, str], field_ids: list[str], covered: set[str]) -> list[dict[str, Any]]:
  142. rows: list[dict[str, Any]] = []
  143. for fid in field_ids or []:
  144. covered.add(fid)
  145. rows.append(
  146. {
  147. "field_id": fid,
  148. "field_label": labels.get(fid, fid),
  149. "value": flat.get(fid),
  150. }
  151. )
  152. return rows
  153. def _build_table_group_node(flat: dict[str, Any], labels: dict[str, str], g: dict[str, Any], covered: set[str]) -> dict[str, Any]:
  154. """递归:支持 sub_groups(对应要素分解模板的一层/二层/三层结构)。"""
  155. gid = g.get("id", "")
  156. node: dict[str, Any] = {
  157. "group_id": gid,
  158. "group_label": g.get("label", gid),
  159. "rows": _table_rows_for_ids(flat, labels, g.get("field_ids") or [], covered),
  160. "sub_groups": [],
  161. }
  162. for sg in g.get("sub_groups") or []:
  163. node["sub_groups"].append(_build_table_group_node(flat, labels, sg, covered))
  164. return node
  165. def _count_table_rows(nodes: list[dict[str, Any]]) -> int:
  166. n = 0
  167. for node in nodes:
  168. n += len(node.get("rows") or [])
  169. n += _count_table_rows(node.get("sub_groups") or [])
  170. return n
  171. def build_case_elements_table(flat: dict[str, Any], schema: dict[str, Any]) -> dict[str, Any]:
  172. """
  173. 按 case_elements_schema.json 生成分组表;支持 sub_groups 嵌套。
  174. schema 中每个 field_id 对应一行;抽取结果里存在但未声明的顶层字段追加「补充要素」。
  175. """
  176. labels = schema.get("field_labels") or {}
  177. covered: set[str] = set()
  178. groups_out: list[dict[str, Any]] = [_build_table_group_node(flat, labels, g, covered) for g in schema.get("groups") or []]
  179. skip_keys = {"case_elements_table"}
  180. extra_ids = sorted(k for k in flat.keys() if k not in skip_keys and k not in covered)
  181. if extra_ids:
  182. groups_out.append(
  183. {
  184. "group_id": "supplement",
  185. "group_label": "补充要素",
  186. "rows": [
  187. {
  188. "field_id": fid,
  189. "field_label": labels.get(fid, fid),
  190. "value": flat.get(fid),
  191. }
  192. for fid in extra_ids
  193. ],
  194. "sub_groups": [],
  195. }
  196. )
  197. return {
  198. "table_name": schema.get("table_name", "案件要素表"),
  199. "version": schema.get("version", ""),
  200. "groups": groups_out,
  201. "field_count": _count_table_rows(groups_out),
  202. }
  203. def detect_case_cause(text: str) -> str | None:
  204. t = text or ""
  205. for cause, keys in CAUSE_KEYWORDS:
  206. if any(k in t for k in keys):
  207. return cause
  208. return None
  209. def extract_law_refs(text: str) -> list[str]:
  210. t = text or ""
  211. # 《劳动合同法》第xx条 / 《xx法》 / 劳动合同法第xx条
  212. refs = set()
  213. for item in _all_matches(r"《[^》]{2,30}》\s*第\s*[0-9一二三四五六七八九十百千]+\s*条", t):
  214. refs.add(item)
  215. for item in _all_matches(r"《[^》]{2,30}》", t):
  216. refs.add(item)
  217. for item in _all_matches(r"(劳动合同法|劳动争议调解仲裁法|工伤保险条例)\s*第\s*[0-9一二三四五六七八九十百千]+\s*条", t):
  218. refs.add(item)
  219. return sorted(refs)
  220. def extract_case_number(text: str) -> str | None:
  221. t = text or ""
  222. v = _first_group(r"(?:案号|案件编号|仲裁案号)[::]\s*([^\n,,。;;]{4,40})", t)
  223. if v:
  224. return v
  225. m = re.search(r"劳人仲案字\s*\[[0-9]{4}\]\s*第\s*[0-9一二三四五六七八九十百千]+\s*号", t)
  226. if m:
  227. return m.group(0).strip()
  228. m2 = re.search(r"\([12][0-9]{3}\)\s*第\s*[0-9]+\s*号", t)
  229. return m2.group(0).strip() if m2 else None
  230. def extract_filing_date(text: str) -> str | None:
  231. t = text or ""
  232. raw = _first_group(r"(?:立案日期|立案时间)[::]\s*([^\n,,。;;]{4,24})", t)
  233. return parse_date_any(raw) if raw else None
  234. def extract_arbitration_org(text: str) -> str | None:
  235. t = text or ""
  236. direct = _first_group(r"(?:仲裁机构|仲裁委员会)[::]\s*([^\n,,。;;]{4,60})", t)
  237. if direct:
  238. return direct
  239. m = re.search(r"([\u4e00-\u9fff]{2,20}劳动(?:人事)?争议仲裁委员会)", t)
  240. return m.group(1).strip() if m else None
  241. def extract_case_title(text: str) -> str | None:
  242. t = text or ""
  243. v = _first_group(r"(?:案件名称|标题)[::]\s*([^\n]{2,80})", t)
  244. if v:
  245. return v.strip()
  246. return _first_group(r"案由[::]\s*([^\n,,。;;]{2,40})", t)
  247. def extract_applicant_name(text: str) -> str | None:
  248. t = text or ""
  249. return _first_group(r"(?:申请人|申请方|申请者)[::]" + _WS + r"([^\n,,。;;]{2,20})", t)
  250. def extract_applicant_type(text: str) -> str | None:
  251. t = text or ""
  252. if re.search(r"申请人[::].{0,30}(公司|有限|集团|厂|中心|委员会)", t):
  253. return "用人单位(或用工单位)"
  254. if re.search(r"申请人类型[::]\s*([^\n,,]{2,20})", t):
  255. return _first_group(r"申请人类型[::]\s*([^\n,,]{2,20})", t)
  256. name = extract_applicant_name(t) or ""
  257. if len(name) <= 4 and not any(x in name for x in ["公司", "有限", "厂"]):
  258. return "自然人"
  259. return None
  260. def extract_employment_type(text: str) -> str | None:
  261. t = text or ""
  262. for label, keys in [
  263. ("劳务派遣", ["劳务派遣", "派遣员工", "用工单位", "派遣单位"]),
  264. ("非全日制用工", ["非全日制"]),
  265. ("全日制用工", ["全日制", "标准工时"]),
  266. ("劳务关系", ["劳务关系", "劳务协议"]),
  267. ]:
  268. if any(k in t for k in keys):
  269. return label
  270. return None
  271. def extract_respondent_name(text: str) -> str | None:
  272. t = text or ""
  273. return _first_group(r"(?:被申请人|被申请方|答辩人)[::]" + _WS + r"([^\n,,。;;]{2,40})", t)
  274. def extract_employer_nature(text: str) -> str | None:
  275. t = text or ""
  276. # 优先从“单位性质”字段取
  277. direct = _first_group(r"(?:单位性质|用人单位性质)[::]" + _WS + r"(企业|事业单位|个人|个体工商户|民办非企业|机关)", t)
  278. if direct:
  279. if direct in ("个体工商户",):
  280. return "个人"
  281. return direct
  282. # 根据名称后缀猜测
  283. resp = extract_respondent_name(t) or ""
  284. if any(k in resp for k in ["有限公司", "有限责任公司", "股份有限公司", "公司", "厂", "集团", "企业"]):
  285. return "企业"
  286. if any(k in resp for k in ["医院", "学校", "研究院", "事业单位", "中心", "局", "委员会"]):
  287. return "事业单位"
  288. return None
  289. def extract_worker_position(text: str) -> str | None:
  290. t = text or ""
  291. return _first_group(r"(?:岗位|职位|工种)[::]" + _WS + r"([^\n,,。;;]{2,20})", t)
  292. def extract_entry_date(text: str) -> str | None:
  293. t = text or ""
  294. raw = _first_group(r"(?:入职时间|入职日期|到岗时间|参加工作时间)[::]?\s*([^\n,,。;;]{4,20})", t)
  295. if raw:
  296. return parse_date_any(raw)
  297. raw2 = _first_group(r"(?:于|在)\s*([12][0-9]{3}[年./-][01]?[0-9][月./-][0-3]?[0-9])\s*(?:入职|到岗|开始工作)", t)
  298. return parse_date_any(raw2) if raw2 else None
  299. def extract_leave_date(text: str) -> str | None:
  300. t = text or ""
  301. raw = _first_group(r"(?:离职时间|离职日期|解除时间|终止时间)[::]?\s*([^\n,,。;;]{4,20})", t)
  302. if raw:
  303. return parse_date_any(raw)
  304. raw2 = _first_group(r"(?:于|在)\s*([12][0-9]{3}[年./-][01]?[0-9][月./-][0-3]?[0-9])\s*(?:离职|解除劳动合同|解除)", t)
  305. return parse_date_any(raw2) if raw2 else None
  306. def extract_month_salary(text: str) -> float | None:
  307. t = text or ""
  308. raw = _first_group(r"(?:月工资|月薪|工资标准)[::]?\s*([^\n,,。;;]{2,30})", t)
  309. if raw:
  310. amt = parse_amount(raw + "元" if "元" not in raw else raw)
  311. if amt is not None:
  312. return amt
  313. # 兜底:出现“月工资8000元”这样的写法
  314. m = re.search(r"(月工资|月薪|工资标准)" + _WS + r"(?:为|是|:)?\s*(?:人民币|¥)?\s*([0-9]{1,3}(?:,[0-9]{3})*|[0-9]+)\s*元", t)
  315. if m:
  316. return float(m.group(2).replace(",", ""))
  317. return None
  318. def extract_overtime_desc(text: str) -> str | None:
  319. t = text or ""
  320. # 抓取包含“加班”的句子/条目(简化:按换行/句号切)
  321. parts = re.split(r"[\n。;;]+", t)
  322. hits = [p.strip() for p in parts if "加班" in p and len(p.strip()) >= 6]
  323. if not hits:
  324. return None
  325. return ";".join(hits[:3])
  326. def extract_dispute_focus(text: str) -> str | None:
  327. t = text or ""
  328. return _first_group(r"(?:争议焦点)[::]\s*([^\n]{2,120})", t)
  329. def extract_work_duration_text(text: str) -> str | None:
  330. t = text or ""
  331. m = re.search(r"(?:工作年限|用工期间|劳动关系存续)[:: ]?\s*([^\n。;;]{4,40})", t)
  332. if m:
  333. return m.group(1).strip()
  334. m2 = re.search(r"(?:自|从)[^\n]{0,30}(?:至|到)[^\n]{0,30}(?:止|共计)", t)
  335. return m2.group(0).strip()[:80] if m2 else None
  336. def extract_contract_type(text: str) -> str | None:
  337. t = text or ""
  338. if "无固定期限" in t:
  339. return "无固定期限劳动合同"
  340. if "固定期限" in t and "劳动合同" in t:
  341. return "固定期限劳动合同"
  342. if "未签订" in t and "劳动合同" in t:
  343. return "未订立书面劳动合同"
  344. if "劳动合同" in t and "期限" in t:
  345. return _first_group(r"(?:劳动合同|合同)(?:期限|类型)[::]\s*([^\n,,。;;]{2,30})", t)
  346. return None
  347. def extract_injury_related(text: str) -> str | None:
  348. t = text or ""
  349. if "工伤" not in t:
  350. return None
  351. parts = re.split(r"[\n。;;]+", t)
  352. hits = [p.strip() for p in parts if "工伤" in p and len(p.strip()) >= 6]
  353. return ";".join(hits[:2]) if hits else "涉及工伤"
  354. def extract_social_insurance_hint(text: str) -> str | None:
  355. t = text or ""
  356. if not any(k in t for k in ["社保", "社会保险", "五险", "养老保险", "医疗保险", "失业保险", "公积金"]):
  357. return None
  358. parts = re.split(r"[\n。;;]+", t)
  359. hits = [p.strip() for p in parts if any(k in p for k in ["社保", "社会保险", "五险", "未缴纳", "欠缴"]) and len(p.strip()) >= 6]
  360. return ";".join(hits[:2]) if hits else "涉及社会保险争议"
  361. def extract_evidence_materials(text: str) -> list[str] | None:
  362. t = text or ""
  363. block = _first_group(
  364. r"(?:证据(?:材料|清单)|附件|所附证据)[::]?\s*(.+?)(?:此致|仲裁请求|事实与理由|申请人[::]|$)",
  365. t,
  366. flags=re.S,
  367. )
  368. if not block:
  369. lines = _all_matches(r"^\s*[0-9一二三四五六七八九十]+[.、]\s*([^\n]{2,60})", t, flags=re.M)
  370. return lines[:15] if lines else None
  371. lines = [ln.strip(" \t-—") for ln in re.split(r"[\n;;]+", block) if ln.strip()]
  372. out = []
  373. for ln in lines:
  374. ln = re.sub(r"^\s*[((]?\s*[0-9一二三四五六七八九十]+\s*[))]?\s*[.、]?\s*", "", ln)
  375. if len(ln) >= 2:
  376. out.append(ln[:120])
  377. return out[:15] if out else None
  378. def infer_claim_types(text: str, claims: dict[str, Any], case_cause: str | None) -> list[str]:
  379. t = text or ""
  380. types: set[str] = set()
  381. if case_cause:
  382. types.add(case_cause)
  383. mapping = [
  384. ("工资", "工资报酬"),
  385. ("加班", "加班费"),
  386. ("经济补偿", "经济补偿"),
  387. ("赔偿金", "违法解除赔偿"),
  388. ("工伤", "工伤待遇"),
  389. ("年休假", "年休假"),
  390. ("双倍工资", "未订立书面劳动合同"),
  391. ("社保", "社会保险"),
  392. ("未签合同", "未订立书面劳动合同"),
  393. ]
  394. for kw, lab in mapping:
  395. if kw in t:
  396. types.add(lab)
  397. for it in (claims or {}).get("items") or []:
  398. s = str(it)
  399. for kw, lab in mapping:
  400. if kw in s:
  401. types.add(lab)
  402. return sorted(types)
  403. def extract_termination_reason(text: str) -> str | None:
  404. t = text or ""
  405. direct = _first_group(r"(?:解除原因|解除理由|辞退原因|解除劳动合同原因)[::]" + _WS + r"([^\n。;;]{2,60})", t)
  406. if direct:
  407. return direct
  408. # 兜底:包含“因……解除/辞退”
  409. m = re.search(r"(?:因|由于)\s*([^\n。;;]{2,40})\s*(?:被)?(?:辞退|解除劳动合同|解除)", t)
  410. if m:
  411. return m.group(1).strip()
  412. return None
  413. def extract_claims(text: str) -> dict[str, Any]:
  414. """
  415. 返回:
  416. - items: list[str]
  417. - amount_total: float | None
  418. """
  419. t = text or ""
  420. # 请求段落:仲裁请求/请求事项/请求如下
  421. block = _first_group(r"(?:仲裁请求|请求事项|请求如下)[::]?\s*(.+?)(?:事实与理由|事实理由|此致|证据目录|$)", t, flags=re.S)
  422. items: list[str] = []
  423. if block:
  424. lines = [ln.strip(" \t-—") for ln in re.split(r"[\n;;]+", block) if ln.strip()]
  425. # 若是 1. 2. 3. 形式
  426. norm: list[str] = []
  427. for ln in lines:
  428. ln = re.sub(r"^\s*[((]?\s*[0-9一二三四五六七八九十]+\s*[))]?\s*[.、]?\s*", "", ln)
  429. if ln:
  430. norm.append(ln)
  431. items = norm[:10]
  432. # 汇总金额:从 items 中找最大/累加(MVP:取最大或第一条)
  433. amounts = []
  434. for it in items:
  435. a = parse_amount(it)
  436. if a is not None:
  437. amounts.append(a)
  438. amount_total = sum(amounts) if amounts else None
  439. return {"items": items, "amount_total": amount_total}
  440. def _yes_no_from_text(t: str, positive: list[str], negative: list[str]) -> str | None:
  441. for p in positive:
  442. if p in t:
  443. return "是"
  444. for n in negative:
  445. if n in t:
  446. return "否"
  447. return None
  448. def _snippet_near_keywords(t: str, keywords: tuple[str, ...], width: int = 120) -> str | None:
  449. for kw in keywords:
  450. i = t.find(kw)
  451. if i >= 0:
  452. frag = t[max(0, i - 20) : i + width].replace("\n", " ").strip()
  453. return frag[:200] if frag else None
  454. return None
  455. def _amount_near_keywords(t: str, keywords: tuple[str, ...]) -> float | None:
  456. for kw in keywords:
  457. i = t.find(kw)
  458. if i >= 0:
  459. window = t[i : i + 100]
  460. if (a := parse_amount(window)) is not None:
  461. return a
  462. return None
  463. def classify_template_primary_cause(text: str) -> str | None:
  464. """对应《案件普遍的智能分解模板》顶层案由分支(由关键词路由,可多案由时取最先命中)。"""
  465. t = text or ""
  466. ordered = [
  467. ("生育保险待遇纠纷", ("生育津贴", "生育医疗", "产假工资", "生育保险待遇", "产检", "流产假")),
  468. ("赔偿金纠纷", ("违法解除劳动", "违法终止劳动", "违法辞退", "赔偿金", "2N", "二倍经济补偿")),
  469. ("经济补偿金纠纷", ("经济补偿金", "代通知金", "N+1", "解除合同经济补偿", "离职补偿")),
  470. ("追索劳动报酬", ("追索劳动报酬", "拖欠工资", "工资差额", "加班费", "高温津贴", "年休假工资", "未及时足额")),
  471. ("工伤保险待遇纠纷", ("工伤保险待遇", "劳动能力鉴定", "一次性伤残", "停工留薪", "工伤认定", "工伤")),
  472. ("劳动关系纠纷类", ("确认劳动关系", "未订立书面劳动合同", "未签订劳动合同", "二倍工资", "双倍工资")),
  473. ]
  474. for name, kws in ordered:
  475. if any(k in t for k in kws):
  476. return name
  477. return None
  478. def merge_dispute_template_fields(text: str, base: dict[str, Any]) -> dict[str, Any]:
  479. """
  480. 按「案件普遍的智能分解模板」补充扁平字段(规则/关键词,可与 NLP 模型替换)。
  481. base 为已跑完基础 extractors 的结果,用于拼装通用要素。
  482. """
  483. t = _clean_text(text)
  484. o: dict[str, Any] = {}
  485. o["tmpl_primary_cause"] = classify_template_primary_cause(t)
  486. ga: list[str] = []
  487. if base.get("applicant_name"):
  488. ga.append(f"申请人:{base['applicant_name']}")
  489. if base.get("applicant_type"):
  490. ga.append(f"类型:{base['applicant_type']}")
  491. if base.get("worker_position"):
  492. ga.append(f"岗位:{base['worker_position']}")
  493. ph = _first_group(r"(?:手机|联系电话|电话)[::]?\s*([0-9\-\s]{7,22})", t)
  494. if ph:
  495. ga.append(f"联系方式:{ph.strip()}")
  496. o["gen_applicant_info"] = ";".join(ga) if ga else None
  497. gr: list[str] = []
  498. if base.get("respondent_name"):
  499. gr.append(f"被申请人:{base['respondent_name']}")
  500. if base.get("employer_nature"):
  501. gr.append(f"单位性质:{base['employer_nature']}")
  502. o["gen_respondent_info"] = ";".join(gr) if gr else None
  503. facts = _first_group(
  504. r"(?:事实与理由|事实和理由)[::]?\s*(.+?)(?=仲裁请求|请求事项|此致|证据目录|$)",
  505. t,
  506. flags=re.S,
  507. )
  508. o["gen_facts_and_reasons"] = (facts.strip()[:4000] if facts else None) or _snippet_near_keywords(
  509. t, ("事实", "理由", "入职", "离职", "工资"), 200
  510. )
  511. # --- 1 劳动关系纠纷类 ---
  512. o["lr1_pay_cycle"] = _first_group(r"(?:工资发放|劳动报酬发放|支付周期)[::]?\s*([^\n。;]{2,40})", t) or _snippet_near_keywords(
  513. t, ("按月", "每月", "月薪", "计件"), 30
  514. )
  515. ms = base.get("month_salary")
  516. try:
  517. ms_f = float(ms) if ms is not None and ms != "" else None
  518. except (TypeError, ValueError):
  519. ms_f = None
  520. o["lr1_pay_amount"] = _amount_near_keywords(t, ("劳动报酬", "工资标准", "月工资", "月薪")) or ms_f
  521. o["lr1_pay_form"] = _snippet_near_keywords(t, ("银行转账", "现金", "微信", "支付宝", "打卡"))
  522. o["lr1_si_joined"] = _yes_no_from_text(
  523. t,
  524. ["已缴纳社保", "参加社会保险", "缴纳五险", "有社保"],
  525. ["未缴纳社保", "未参加社会保险", "未缴社保", "无社保"],
  526. ) or base.get("social_insurance_hint")
  527. o["lr1_si_benefit_amount"] = _amount_near_keywords(t, ("保险待遇", "社保待遇", "补缴"))
  528. o["lr1_contract_signed"] = _yes_no_from_text(
  529. t,
  530. ["签订劳动合同", "订立书面劳动合同", "有书面合同"],
  531. ["未签订劳动合同", "未订立书面劳动合同", "无书面合同"],
  532. )
  533. o["lr1_open_ended_contract"] = "是" if ("无固定期限" in t or "无固定期" in t) else None
  534. o["lr1_double_wage_no_contract"] = "是" if ("二倍工资" in t or "双倍工资" in t or "未签劳动合同" in t) else None
  535. o["lr1_relation_duration"] = base.get("work_duration_text") or _snippet_near_keywords(
  536. t, ("劳动关系", "用工期间", "入职", "离职"), 80
  537. )
  538. # --- 2 工伤保险待遇纠纷 ---
  539. o["wi_lr_contract_signed"] = o.get("lr1_contract_signed")
  540. o["wi_lr_relation_duration"] = base.get("work_duration_text") or o.get("lr1_relation_duration")
  541. o["wi_benefit_pay_time"] = _first_group(r"(?:待遇发放|支付时间|发放时间)[::]?\s*([^\n。;]{2,40})", t)
  542. o["wi_benefit_amount_total"] = _amount_near_keywords(t, ("工伤保险待遇", "工伤待遇", "一次性伤残", "补助金"))
  543. o["wi_benefit_disability"] = _amount_near_keywords(t, ("伤残津贴", "伤残补助"))
  544. o["wi_benefit_prosthetic"] = _amount_near_keywords(t, ("假肢", "辅助器具"))
  545. o["wi_benefit_medical_allowance"] = _amount_near_keywords(t, ("医疗补助金", "医疗补助"))
  546. o["wi_benefit_travel"] = _amount_near_keywords(t, ("交通费", "食宿费", "交通食宿"))
  547. o["wi_benefit_rehab"] = _amount_near_keywords(t, ("康复费", "医疗费"))
  548. o["wi_benefit_nursing"] = _amount_near_keywords(t, ("护理费",))
  549. o["wi_benefit_meal"] = _amount_near_keywords(t, ("住院伙食", "伙食补助"))
  550. o["wi_benefit_pay_form"] = o.get("lr1_pay_form")
  551. o["wi_si_benefit_amt"] = o.get("lr1_si_benefit_amount")
  552. o["wi_si_joined"] = o.get("lr1_si_joined")
  553. o["wi_recognize_ec"] = _yes_no_from_text(
  554. t,
  555. ["认可经济补偿", "同意支付经济补偿", "支付经济补偿金"],
  556. ["不认可经济补偿", "无需支付经济补偿", "不同意经济补偿"],
  557. )
  558. # --- 3 追索劳动报酬 ---
  559. o["sr_pay_cycle"] = o.get("lr1_pay_cycle")
  560. o["sr_claim_amount"] = _amount_near_keywords(t, ("主张金额", "请求金额", "仲裁请求")) or (base.get("claims") or {}).get(
  561. "amount_total"
  562. )
  563. o["sr_claim_deducted_pay"] = _amount_near_keywords(t, ("克扣", "拖欠工资", "欠付工资"))
  564. o["sr_claim_overtime_pay"] = _amount_near_keywords(t, ("加班费", "加班工资")) or (
  565. parse_amount(base.get("overtime_desc") or "") if base.get("overtime_desc") else None
  566. )
  567. o["sr_claim_living_allowance"] = _amount_near_keywords(t, ("生活费", "待岗"))
  568. o["sr_high_temp_allowance"] = _amount_near_keywords(t, ("高温津贴", "防暑降温"))
  569. o["sr_actual_pay_standard"] = _snippet_near_keywords(t, ("实发工资", "实际支付", "实发"), 40)
  570. o["sr_agreed_pay_standard"] = _snippet_near_keywords(t, ("约定工资", "合同约定工资", "月薪约定"), 40) or (
  571. str(base["month_salary"]) if base.get("month_salary") is not None else None
  572. )
  573. o["sr_annual_leave_pay"] = _amount_near_keywords(t, ("年休假工资", "未休年假", "带薪年休假"))
  574. o["sr_unpaid_period"] = _first_group(r"(?:欠付期间|未支付期间|拖欠期间)[::]?\s*([^\n。;]{2,60})", t) or _snippet_near_keywords(
  575. t, ("至", "期间"), 50
  576. )
  577. o["sr_overtime_amount"] = o.get("sr_claim_overtime_pay")
  578. # --- 4 经济补偿金纠纷 ---
  579. o["ec_avg_salary_12m"] = _first_group(
  580. r"(?:离职前\s*12\s*个月|十二个月).{0,12}(?:平均|月均)工资[::]?\s*([^\n,。;]{2,40})",
  581. t,
  582. ) or _amount_near_keywords(t, ("平均工资", "月均工资", "月平均工资"))
  583. o["ec_claim_amount"] = o.get("sr_claim_amount")
  584. o["ec_double_wage_part"] = _amount_near_keywords(t, ("二倍工资", "双倍工资", "未签劳动合同"))
  585. o["ec_illegal_term_part"] = _amount_near_keywords(t, ("违法解除", "违法终止", "违法辞退"))
  586. o["ec_illegal_probation_part"] = _amount_near_keywords(t, ("违法约定试用期", "试用期赔偿"))
  587. o["ec_extra_compensation_part"] = _amount_near_keywords(t, ("额外补偿", "加付"))
  588. o["ec_notice_pay"] = _amount_near_keywords(t, ("代通知金", "提前三十日"))
  589. o["ec_additional_damages"] = _amount_near_keywords(t, ("加付赔偿金", "赔偿金50%"))
  590. o["ec_contract_duration"] = base.get("work_duration_text")
  591. o["ec_leave_reason"] = base.get("termination_reason")
  592. o["ec_leave_date"] = base.get("leave_date")
  593. # --- 5 赔偿金纠纷 ---
  594. o["dm_claim_amount"] = o.get("sr_claim_amount")
  595. o["dm_illegal_dismissal_damages"] = _amount_near_keywords(t, ("违法解除劳动", "违法辞退", "赔偿金", "2倍"))
  596. o["dm_contract_exists"] = o.get("lr1_contract_signed")
  597. o["dm_terminate_reason"] = base.get("termination_reason")
  598. o["dm_contract_continue"] = _yes_no_from_text(t, ["继续履行劳动合同", "恢复劳动关系"], ["不继续履行", "解除劳动关系"])
  599. # --- 6 生育保险待遇纠纷 ---
  600. o["mi_claim_amount"] = o.get("sr_claim_amount")
  601. o["mi_maternity_medical"] = _amount_near_keywords(t, ("生育医疗费", "生育医疗费用", "产检费"))
  602. o["mi_maternity_allowance_salary"] = _amount_near_keywords(t, ("生育津贴", "产假工资"))
  603. o["mi_additional_damages"] = o.get("ec_additional_damages")
  604. o["mi_travel_accommodation"] = o.get("wi_benefit_travel")
  605. o["mi_contract_continue"] = o.get("dm_contract_continue")
  606. o["mi_terminate_reason"] = base.get("termination_reason")
  607. return o
  608. def refresh_derived_element_fields(out: dict[str, Any], text: str) -> None:
  609. """
  610. 在 merge_dispute_template_fields 或 LLM 补丁写入 out 之后,重算 primary_cause_type、elements_hierarchy、case_elements_table。
  611. """
  612. t = _clean_text(text)
  613. templates = load_hierarchy_templates()
  614. cause = out.get("tmpl_primary_cause") or classify_template_primary_cause(t) or DEFAULT_CAUSE_TYPE
  615. if templates and cause not in templates:
  616. cause = DEFAULT_CAUSE_TYPE
  617. out["primary_cause_type"] = cause
  618. out["tmpl_primary_cause"] = cause
  619. if templates and cause in templates:
  620. hier = build_elements_hierarchy_for_cause(cause, out)
  621. if hier is not None:
  622. out["elements_hierarchy"] = hier
  623. schema = load_case_elements_schema()
  624. out["case_elements_table"] = build_case_elements_table(out, schema)
  625. @dataclass
  626. class RuleBasedLaborExtractor:
  627. """
  628. MVP:基于规则/正则的劳动仲裁要素抽取器(后续可替换为 BERT 微调模型)。
  629. 要素分组与中文标签由 backend/data/case_elements_schema.json 定义,可替换为您自己的案件要素表。
  630. """
  631. extractors: list[tuple[str, Callable[[str], Any]]] | None = None
  632. _schema: dict[str, Any] | None = None
  633. def __post_init__(self) -> None:
  634. self._schema = load_case_elements_schema()
  635. if self.extractors is None:
  636. self.extractors = [
  637. ("case_number", extract_case_number),
  638. ("filing_date", extract_filing_date),
  639. ("arbitration_org", extract_arbitration_org),
  640. ("case_title", extract_case_title),
  641. ("applicant_name", extract_applicant_name),
  642. ("applicant_type", extract_applicant_type),
  643. ("respondent_name", extract_respondent_name),
  644. ("employer_nature", extract_employer_nature),
  645. ("worker_position", extract_worker_position),
  646. ("employment_type", extract_employment_type),
  647. ("case_cause", detect_case_cause),
  648. ("dispute_focus", extract_dispute_focus),
  649. ("entry_date", extract_entry_date),
  650. ("leave_date", extract_leave_date),
  651. ("work_duration_text", extract_work_duration_text),
  652. ("month_salary", extract_month_salary),
  653. ("overtime_desc", extract_overtime_desc),
  654. ("termination_reason", extract_termination_reason),
  655. ("contract_type", extract_contract_type),
  656. ("injury_related", extract_injury_related),
  657. ("social_insurance_hint", extract_social_insurance_hint),
  658. ("claims", extract_claims),
  659. ("law_refs", extract_law_refs),
  660. ("evidence_materials", extract_evidence_materials),
  661. ]
  662. def extract(self, text: str) -> dict[str, Any]:
  663. text = _clean_text(text)
  664. out: dict[str, Any] = {}
  665. for key, fn in self.extractors or []:
  666. try:
  667. out[key] = fn(text)
  668. except Exception:
  669. out[key] = None
  670. try:
  671. out["claim_types"] = infer_claim_types(text, out.get("claims") or {}, out.get("case_cause"))
  672. except Exception:
  673. out["claim_types"] = []
  674. try:
  675. out.update(merge_dispute_template_fields(text, out))
  676. except Exception:
  677. pass
  678. refresh_derived_element_fields(out, text)
  679. return out