extractor.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. """
  2. Chinese Legal Element Extractor for Labor Arbitration.
  3. Uses a fine-tuned Chinese RoBERTa multi-task model (or falls back to rules).
  4. """
  5. from __future__ import annotations
  6. import re
  7. from typing import Any, Optional
  8. # NOTE: must be imported before torch on Windows
  9. import transformers # noqa: F401
  10. import torch
  11. import os as _os
  12. DEFAULT_MODEL_PATH = _os.environ.get(
  13. "NLP_MODEL_PATH",
  14. _os.path.join(_os.path.dirname(__file__), "..", "..", "models", "chinese_roberta_labor_extractor_v2"),
  15. )
  16. # Fallback to absolute path if relative doesn't exist
  17. if not _os.path.isdir(DEFAULT_MODEL_PATH):
  18. DEFAULT_MODEL_PATH = "D:/Graduation Project/second_type/nlp-service/models/chinese_roberta_labor_extractor_v2"
  19. # Fallback rule-based extraction patterns (used when model is not available)
  20. _CAUSE_PATTERNS = [
  21. ("生育保险待遇纠纷", ["生育津贴", "生育医疗", "产假工资", "生育保险待遇"]),
  22. ("赔偿金纠纷", ["违法解除劳动", "违法终止劳动", "违法辞退", "赔偿金", "2N"]),
  23. ("经济补偿金纠纷", ["经济补偿金", "代通知金", "N+1", "离职补偿"]),
  24. ("追索劳动报酬", ["追索劳动报酬", "拖欠工资", "工资差额", "加班费", "高温津贴"]),
  25. ("工伤保险待遇纠纷", ["工伤保险待遇", "一次性伤残", "停工留薪", "工伤认定"]),
  26. ("劳动关系纠纷类", ["确认劳动关系", "未订立书面劳动合同", "二倍工资", "双倍工资"]),
  27. ]
  28. class ChineseLegalElementExtractor:
  29. """
  30. Element extraction for labor arbitration case texts.
  31. Uses the trained Chinese RoBERTa multi-task model when available,
  32. falling back to rule-based extraction otherwise.
  33. """
  34. def __init__(self, model_path: Optional[str] = None):
  35. self._model = None
  36. self._tokenizer = None
  37. self._device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
  38. try:
  39. from .bert_multi_task_model import ChineseRobertaMultiTask
  40. from transformers import AutoTokenizer
  41. path = model_path or DEFAULT_MODEL_PATH
  42. self._model = ChineseRobertaMultiTask.from_pretrained(path)
  43. self._model.to(self._device)
  44. self._model.eval()
  45. model_config_path = f"{path}/model_config.json"
  46. import json, os
  47. if os.path.exists(model_config_path):
  48. with open(model_config_path, encoding="utf-8") as f:
  49. config = json.load(f)
  50. model_name = config.get("model_name", "hfl/chinese-roberta-wwm-ext")
  51. else:
  52. model_name = "hfl/chinese-roberta-wwm-ext"
  53. self._tokenizer = AutoTokenizer.from_pretrained(
  54. path if os.path.exists(f"{path}/vocab.txt") else model_name
  55. )
  56. self._use_model = True
  57. except Exception as e:
  58. import traceback
  59. print(f"[NLP] Model load failed: {e}")
  60. traceback.print_exc()
  61. self._use_model = False
  62. def extract(self, text: str) -> dict[str, Any]:
  63. if self._use_model and self._model is not None:
  64. return self._extract_with_model(text)
  65. return self._extract_with_rules(text)
  66. @torch.no_grad()
  67. def _extract_with_model(self, text: str) -> dict[str, Any]:
  68. # Use rules for entity spans (reliable) + model for classification
  69. rule_result = self._extract_with_rules(text)
  70. encoding = self._tokenizer(
  71. text,
  72. truncation=True,
  73. max_length=512,
  74. padding="max_length",
  75. return_tensors="pt",
  76. )
  77. input_ids = encoding["input_ids"].to(self._device)
  78. attention_mask = encoding["attention_mask"].to(self._device)
  79. preds = self._model.predict(input_ids, attention_mask, self._tokenizer)
  80. cause_classes = [
  81. "劳动关系纠纷类", "工伤保险待遇纠纷", "追索劳动报酬",
  82. "经济补偿金纠纷", "赔偿金纠纷", "生育保险待遇纠纷",
  83. ]
  84. contract_classes = [
  85. "无固定期限劳动合同", "固定期限劳动合同",
  86. "未订立书面劳动合同", "未知",
  87. ]
  88. cause_idx = preds["cause_predictions"][0] if preds["cause_predictions"] else 0
  89. contract_idx = preds["contract_predictions"][0] if preds["contract_predictions"] else 3
  90. # Decode NER spans as supplement to rules
  91. ner_spans = self._decode_ner_spans(
  92. input_ids[0].cpu().tolist(),
  93. preds["ner_predictions"][0] if preds["ner_predictions"] else [],
  94. )
  95. # Merge: model classification + rule-based entities
  96. result = {
  97. "parties": {
  98. "applicant_name": (
  99. ner_spans.get("APPLICANT_NAME")
  100. or rule_result.get("parties", {}).get("applicant_name")
  101. ),
  102. "respondent_name": (
  103. ner_spans.get("RESPONDENT_NAME")
  104. or rule_result.get("parties", {}).get("respondent_name")
  105. ),
  106. "worker_position": (
  107. ner_spans.get("WORKER_POSITION")
  108. or rule_result.get("parties", {}).get("worker_position")
  109. ),
  110. "arbitration_org": (
  111. ner_spans.get("ARBITRATION_ORG")
  112. or rule_result.get("parties", {}).get("arbitration_org")
  113. ),
  114. },
  115. "case_cause": {"type": cause_classes[cause_idx]},
  116. "tmpl_primary_cause": cause_classes[cause_idx],
  117. "facts": {
  118. "entry_date": (
  119. ner_spans.get("ENTRY_DATE")
  120. or rule_result.get("facts", {}).get("entry_date")
  121. ),
  122. "leave_date": (
  123. ner_spans.get("LEAVE_DATE")
  124. or rule_result.get("facts", {}).get("leave_date")
  125. ),
  126. "filing_date": (
  127. ner_spans.get("FILING_DATE")
  128. or rule_result.get("facts", {}).get("filing_date")
  129. ),
  130. "month_salary": (
  131. ner_spans.get("MONTH_SALARY")
  132. or rule_result.get("facts", {}).get("month_salary")
  133. ),
  134. "work_duration_text": (
  135. ner_spans.get("WORK_DURATION")
  136. or rule_result.get("facts", {}).get("work_duration_text")
  137. ),
  138. "overtime_desc": (
  139. ner_spans.get("OVERTIME_DESC")
  140. or rule_result.get("facts", {}).get("overtime_desc")
  141. ),
  142. "termination_reason": (
  143. ner_spans.get("TERMINATION_REASON")
  144. or rule_result.get("facts", {}).get("termination_reason")
  145. ),
  146. },
  147. "claims": {
  148. "amount_total": (
  149. ner_spans.get("CLAIM_AMOUNT")
  150. or rule_result.get("claims", {}).get("amount_total")
  151. ),
  152. },
  153. "contract_type": contract_classes[contract_idx],
  154. "law_refs": rule_result.get("law_refs", []),
  155. "evidence_materials": rule_result.get("evidence_materials", []),
  156. }
  157. return result
  158. def _decode_ner_spans(
  159. self,
  160. input_ids: list[int],
  161. ner_labels: list[int],
  162. ) -> dict[str, Any]:
  163. """Decode BIO NER predictions into span text using the tokenizer."""
  164. if self._tokenizer is None:
  165. return {}
  166. # Inline BIO decoding (avoids dependency on training/bio_schema.py)
  167. _ENTITY_TYPES = [
  168. "APPLICANT_NAME", "RESPONDENT_NAME", "ENTRY_DATE", "LEAVE_DATE",
  169. "FILING_DATE", "MONTH_SALARY", "CLAIM_AMOUNT", "WORKER_POSITION",
  170. "ARBITRATION_ORG", "LAW_REF", "CASE_NUMBER", "EVIDENCE",
  171. "TERMINATION_REASON", "OVERTIME_DESC", "WORK_DURATION",
  172. ]
  173. _id_to_label = {0: "O"}
  174. _idx = 1
  175. for et in _ENTITY_TYPES:
  176. _id_to_label[_idx] = f"B-{et}"
  177. _id_to_label[_idx + 1] = f"I-{et}"
  178. _idx += 2
  179. # Convert token IDs back to tokens
  180. tokens = self._tokenizer.convert_ids_to_tokens(input_ids)
  181. tokens = [t for t in tokens if t not in ("[PAD]", "[CLS]", "[SEP]")]
  182. # Skip [CLS] label
  183. n_skip = len(input_ids) - len(tokens)
  184. ner_labels = ner_labels[n_skip:n_skip + len(tokens)]
  185. ner_labels = ner_labels[:len(tokens)]
  186. while len(ner_labels) < len(tokens):
  187. ner_labels.append(0)
  188. # Decode BIO → entity spans (inline logic)
  189. spans: list[dict[str, Any]] = []
  190. current_entity = None
  191. current_tokens: list[str] = []
  192. for i, (token, label_id) in enumerate(zip(tokens, ner_labels)):
  193. label = _id_to_label.get(label_id, "O")
  194. if label.startswith("B-"):
  195. if current_entity is not None:
  196. spans.append({
  197. "entity": current_entity,
  198. "text": "".join(current_tokens),
  199. })
  200. current_entity = label[2:]
  201. current_tokens = [token]
  202. elif label.startswith("I-") and current_entity == label[2:]:
  203. current_tokens.append(token)
  204. else:
  205. if current_entity is not None:
  206. spans.append({
  207. "entity": current_entity,
  208. "text": "".join(current_tokens),
  209. })
  210. current_entity = None
  211. current_tokens = []
  212. if current_entity is not None:
  213. spans.append({"entity": current_entity, "text": "".join(current_tokens)})
  214. result: dict[str, list[str]] = {}
  215. for span in spans:
  216. text = span["text"].replace("##", "")
  217. if span["entity"] not in result:
  218. result[span["entity"]] = []
  219. result[span["entity"]].append(text)
  220. return {k: ";".join(v) for k, v in result.items()}
  221. @staticmethod
  222. def _extract_with_rules(text: str) -> dict[str, Any]:
  223. """Fallback: rule-based extraction for when model is not available."""
  224. if not text or not text.strip():
  225. return {
  226. "parties": {},
  227. "case_cause": {"type": "劳动争议"},
  228. "facts": {},
  229. "claims": {},
  230. "law_refs": [],
  231. }
  232. # Detect cause type
  233. cause_type = "劳动争议"
  234. for name, keywords in _CAUSE_PATTERNS:
  235. if any(kw in text for kw in keywords):
  236. cause_type = name
  237. break
  238. # Extract dates
  239. date_pattern = r"([12][0-9]{3})[年./-]([01]?[0-9])[月./-]([0-3]?[0-9])[日]?"
  240. entry_match = re.search(r"(入职|到岗).{0,10}" + date_pattern, text)
  241. leave_match = re.search(r"(离职|解除|终止).{0,10}" + date_pattern, text)
  242. # Extract amounts
  243. amount_match = re.search(r"([0-9]{3,8}(?:\.[0-9]{1,2})?)\s*元", text)
  244. salary_match = re.search(r"(月工资|月薪).{0,5}([0-9]{3,6})\s*元", text)
  245. # Extract legal references
  246. law_refs = re.findall(r"《[^》]{2,30}》", text)
  247. # Extract entity names
  248. applicant = re.search(r"申请人[::]\s*([^\n,,。;]{2,20})", text)
  249. respondent = re.search(r"被申请人[::]\s*([^\n,,。;]{2,40})", text)
  250. position = re.search(r"(岗位|职位|工种)[::]?\s*([^\n,,。;]{2,20})", text)
  251. return {
  252. "parties": {
  253. "applicant_name": applicant.group(1).strip() if applicant else None,
  254. "respondent_name": respondent.group(1).strip() if respondent else None,
  255. "worker_position": position.group(2).strip() if position else None,
  256. },
  257. "case_cause": {"type": cause_type},
  258. "facts": {
  259. "entry_date": _format_date(entry_match) if entry_match else None,
  260. "leave_date": _format_date(leave_match) if leave_match else None,
  261. "month_salary": float(salary_match.group(2)) if salary_match else None,
  262. "overtime_desc": "含加班" if "加班" in text else None,
  263. "termination_reason": None,
  264. },
  265. "claims": {
  266. "amount_total": float(amount_match.group(1)) if amount_match else None,
  267. },
  268. "law_refs": list(set(law_refs)) if law_refs else [],
  269. }
  270. def _format_date(match: re.Match) -> str | None:
  271. try:
  272. y, m, d = int(match.group(1)), int(match.group(2)), int(match.group(3))
  273. return f"{y:04d}-{m:02d}-{d:02d}"
  274. except (IndexError, ValueError):
  275. return None