prepare_training_data.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. """
  2. Auto-labeling pipeline: runs the rule-based extractor on all cleaned cases,
  3. converts extracted fields to BIO token-level labels plus classification labels,
  4. and outputs a training dataset ready for model training.
  5. Run from project root:
  6. python -m nlp-service.training.prepare_training_data
  7. """
  8. from __future__ import annotations
  9. import json
  10. import re
  11. import sys
  12. from pathlib import Path
  13. from typing import Any
  14. # Ensure backend is on path
  15. BACKEND = Path(__file__).resolve().parent.parent.parent / "backend"
  16. sys.path.insert(0, str(BACKEND))
  17. from app.extractor import (
  18. RuleBasedLaborExtractor,
  19. _clean_text,
  20. parse_amount,
  21. )
  22. from bio_schema import (
  23. ENTITY_TYPES,
  24. ENTITY_TO_FIELD,
  25. LABEL2ID,
  26. NUM_LABELS,
  27. )
  28. RAW_CORPUS = BACKEND / "data" / "raw_corpus.json"
  29. OUTPUT = BACKEND / "data" / "training_dataset.json"
  30. CASE_CAUSE_CLASSES = [
  31. "劳动关系纠纷类",
  32. "工伤保险待遇纠纷",
  33. "追索劳动报酬",
  34. "经济补偿金纠纷",
  35. "赔偿金纠纷",
  36. "生育保险待遇纠纷",
  37. ]
  38. CONTRACT_TYPE_CLASSES = [
  39. "无固定期限劳动合同",
  40. "固定期限劳动合同",
  41. "未订立书面劳动合同",
  42. "未知",
  43. ]
  44. EMPLOYMENT_TYPE_CLASSES = [
  45. "劳务派遣",
  46. "非全日制用工",
  47. "全日制用工",
  48. "劳务关系",
  49. "未知",
  50. ]
  51. def _find_span_position(text: str, value: str) -> tuple[int, int] | None:
  52. """Find the character position of a value in the text. Returns (start, end)."""
  53. if not value or not text:
  54. return None
  55. idx = text.find(str(value))
  56. if idx >= 0:
  57. return idx, idx + len(str(value))
  58. return None
  59. def _find_spans_for_list(text: str, values: list[str]) -> list[tuple[int, int, str]]:
  60. """Find character positions for a list of values. Returns [(start, end, value), ...]."""
  61. spans = []
  62. for val in values:
  63. pos = _find_span_position(text, val)
  64. if pos:
  65. spans.append((pos[0], pos[1], val))
  66. return spans
  67. def _char_spans_to_token_bio(
  68. text: str,
  69. char_spans: list[tuple[int, int, str, str]], # (start, end, value, entity_type)
  70. ) -> tuple[list[str], list[int]]:
  71. """
  72. Convert character-level entity spans to token-level BIO labels.
  73. Uses a simple character-level tokenization approach (since Chinese
  74. has no natural word boundaries, we split by whitespace and then by
  75. individual characters within each segment).
  76. """
  77. # Tokenize: keep characters together, split on whitespace
  78. # For Chinese text, tokens will be individual Chinese characters
  79. # with multi-character sequences kept together when they form words
  80. # between whitespace boundaries
  81. tokens: list[str] = []
  82. token_char_starts: list[int] = []
  83. token_char_ends: list[int] = []
  84. for m in re.finditer(r"[^\s]+|\s+", text):
  85. word = m.group(0)
  86. if word.strip():
  87. # For Chinese, split each character as a token
  88. for i, ch in enumerate(word):
  89. tokens.append(ch)
  90. token_char_starts.append(m.start() + i)
  91. token_char_ends.append(m.start() + i + 1)
  92. else:
  93. # Keep whitespace as single token
  94. tokens.append(word)
  95. token_char_starts.append(m.start())
  96. token_char_ends.append(m.end())
  97. # Initialize all labels as O
  98. labels = [LABEL2ID["O"]] * len(tokens)
  99. # Mark BIO labels based on character spans
  100. for char_start, char_end, _value, entity_type in char_spans:
  101. b_key = f"B-{entity_type}"
  102. i_key = f"I-{entity_type}"
  103. if b_key not in LABEL2ID:
  104. continue
  105. first_token_idx = None
  106. for ti, (ts, te) in enumerate(zip(token_char_starts, token_char_ends)):
  107. if ts >= char_start and te <= char_end:
  108. if first_token_idx is None:
  109. first_token_idx = ti
  110. labels[ti] = LABEL2ID[b_key]
  111. else:
  112. labels[ti] = LABEL2ID[i_key]
  113. return tokens, labels
  114. def _extract_spans_from_elements(text: str, elements: dict[str, Any]) -> list[tuple[int, int, str, str]]:
  115. """
  116. Convert extracted elements dictionary into character-level entity spans.
  117. Returns list of (char_start, char_end, value_string, entity_type).
  118. """
  119. char_spans: list[tuple[int, int, str, str]] = []
  120. # Direct string fields
  121. str_fields = [
  122. ("applicant_name", "APPLICANT_NAME"),
  123. ("respondent_name", "RESPONDENT_NAME"),
  124. ("entry_date", "ENTRY_DATE"),
  125. ("leave_date", "LEAVE_DATE"),
  126. ("filing_date", "FILING_DATE"),
  127. ("arbitration_org", "ARBITRATION_ORG"),
  128. ("worker_position", "WORKER_POSITION"),
  129. ("case_number", "CASE_NUMBER"),
  130. ("termination_reason", "TERMINATION_REASON"),
  131. ("overtime_desc", "OVERTIME_DESC"),
  132. ("work_duration_text", "WORK_DURATION"),
  133. ]
  134. for field, entity_type in str_fields:
  135. val = elements.get(field)
  136. if val and isinstance(val, str) and val.strip():
  137. pos = _find_span_position(text, val.strip())
  138. if pos:
  139. char_spans.append((pos[0], pos[1], val.strip(), entity_type))
  140. # Numeric fields (need to convert to string for span matching)
  141. num_fields = [
  142. ("month_salary", "MONTH_SALARY"),
  143. ]
  144. for field, entity_type in num_fields:
  145. val = elements.get(field)
  146. if val is not None and val != "":
  147. val_str = str(int(val)) if isinstance(val, float) and val == int(val) else str(val)
  148. pos = _find_span_position(text, val_str)
  149. if pos:
  150. char_spans.append((pos[0], pos[1], val_str, entity_type))
  151. # List fields (law_refs, evidence_materials)
  152. law_refs = elements.get("law_refs") or []
  153. for law in law_refs:
  154. pos = _find_span_position(text, law)
  155. if pos:
  156. char_spans.append((pos[0], pos[1], law, "LAW_REF"))
  157. evidence = elements.get("evidence_materials") or []
  158. for ev in evidence:
  159. pos = _find_span_position(text, ev)
  160. if pos:
  161. char_spans.append((pos[0], pos[1], ev, "EVIDENCE"))
  162. # Claims amounts
  163. claims = elements.get("claims") or {}
  164. if isinstance(claims, dict):
  165. amount = claims.get("amount_total")
  166. if amount is not None:
  167. amount_str = str(int(amount)) if isinstance(amount, float) else str(amount)
  168. pos = _find_span_position(text, amount_str)
  169. if pos:
  170. char_spans.append((pos[0], pos[1], amount_str, "CLAIM_AMOUNT"))
  171. return char_spans
  172. def _extract_classification_labels(elements: dict[str, Any]) -> dict[str, int]:
  173. """Convert element fields to classification label indices."""
  174. labels = {}
  175. # Primary cause type (6 classes)
  176. cause = elements.get("tmpl_primary_cause") or elements.get("primary_cause_type") or "劳动关系纠纷类"
  177. if cause in CASE_CAUSE_CLASSES:
  178. labels["case_cause"] = CASE_CAUSE_CLASSES.index(cause)
  179. else:
  180. labels["case_cause"] = 0
  181. # Contract type (4 classes)
  182. ct = elements.get("contract_type") or "未知"
  183. if ct in CONTRACT_TYPE_CLASSES:
  184. labels["contract_type"] = CONTRACT_TYPE_CLASSES.index(ct)
  185. else:
  186. labels["contract_type"] = 3
  187. # Employment type (5 classes)
  188. et = elements.get("employment_type") or "未知"
  189. if et in EMPLOYMENT_TYPE_CLASSES:
  190. labels["employment_type"] = EMPLOYMENT_TYPE_CLASSES.index(et)
  191. else:
  192. labels["employment_type"] = 4
  193. # Binary/ternary fields
  194. binary_fields = {
  195. "lr1_contract_signed": {"是": 0, "否": 1, None: 2},
  196. "lr1_open_ended_contract": {"是": 0, "否": 1, None: 2},
  197. "lr1_double_wage_no_contract": {"是": 0, "否": 1, None: 2},
  198. "lr1_si_joined": {"是": 0, "否": 1, None: 2},
  199. "wi_si_joined": {"是": 0, "否": 1, None: 2},
  200. "wi_recognize_ec": {"是": 0, "否": 1, None: 2},
  201. "dm_contract_exists": {"是": 0, "否": 1, None: 2},
  202. "dm_contract_continue": {"是": 0, "否": 1, None: 2},
  203. "mi_contract_continue": {"是": 0, "否": 1, None: 2},
  204. }
  205. for field, mapping in binary_fields.items():
  206. raw = elements.get(field)
  207. labels[f"bool_{field}"] = mapping.get(raw, 2)
  208. return labels
  209. def _extract_numeric_labels(elements: dict[str, Any]) -> dict[str, float | None]:
  210. """Extract numeric field values for regression targets."""
  211. amount_keys = [
  212. "month_salary",
  213. "lr1_pay_amount", "lr1_si_benefit_amount",
  214. "wi_benefit_amount_total", "wi_benefit_disability",
  215. "wi_benefit_prosthetic", "wi_benefit_medical_allowance",
  216. "wi_benefit_travel", "wi_benefit_rehab", "wi_benefit_nursing",
  217. "wi_benefit_meal", "wi_si_benefit_amt",
  218. "sr_claim_amount", "sr_claim_deducted_pay",
  219. "sr_claim_overtime_pay", "sr_claim_living_allowance",
  220. "sr_high_temp_allowance", "sr_annual_leave_pay",
  221. "sr_overtime_amount",
  222. "ec_avg_salary_12m", "ec_claim_amount",
  223. "ec_double_wage_part", "ec_illegal_term_part",
  224. "ec_illegal_probation_part", "ec_extra_compensation_part",
  225. "ec_notice_pay", "ec_additional_damages",
  226. "dm_claim_amount", "dm_illegal_dismissal_damages",
  227. "mi_claim_amount", "mi_maternity_medical",
  228. "mi_maternity_allowance_salary", "mi_additional_damages",
  229. "mi_travel_accommodation",
  230. ]
  231. nums: dict[str, float | None] = {}
  232. for key in amount_keys:
  233. val = elements.get(key)
  234. if val is not None and val != "":
  235. try:
  236. nums[key] = float(val)
  237. except (ValueError, TypeError):
  238. nums[key] = None
  239. else:
  240. nums[key] = None
  241. return nums
  242. def generate_training_dataset() -> dict:
  243. """Main function: load corpus, auto-label, output training dataset."""
  244. if not RAW_CORPUS.exists():
  245. sys.exit(f"Raw corpus not found. Run prepare_dataset.py first.\n Missing: {RAW_CORPUS}")
  246. corpus = json.loads(RAW_CORPUS.read_text(encoding="utf-8"))
  247. cases = corpus["cases"]
  248. if not cases:
  249. sys.exit("No valid cases in corpus.")
  250. extractor = RuleBasedLaborExtractor()
  251. dataset = []
  252. for case in cases:
  253. text = case["text"]
  254. cleaned = _clean_text(text)
  255. # Run rule-based extraction
  256. try:
  257. elements = extractor.extract(cleaned)
  258. except Exception as e:
  259. print(f" ERROR extracting case {case['case_id']}: {e}")
  260. elements = {}
  261. # Generate BIO spans
  262. char_spans = _extract_spans_from_elements(cleaned, elements)
  263. tokens, bio_labels = _char_spans_to_token_bio(cleaned, char_spans)
  264. # Generate classification labels
  265. cls_labels = _extract_classification_labels(elements)
  266. # Generate numeric labels
  267. num_labels = _extract_numeric_labels(elements)
  268. dataset.append({
  269. "case_id": case["case_id"],
  270. "file": case["file"],
  271. "text": cleaned,
  272. "tokens": tokens,
  273. "bio_labels": bio_labels,
  274. "bio_label_names": [
  275. {v: k for k, v in LABEL2ID.items()}.get(l, "O")
  276. for l in bio_labels
  277. ],
  278. "classification_labels": cls_labels,
  279. "numeric_labels": num_labels,
  280. "rule_elements": elements,
  281. })
  282. # Count entities found
  283. n_entities = sum(1 for l in bio_labels if l != LABEL2ID["O"])
  284. print(f" Case {case['case_id']:>3}: {len(tokens):>5} tokens, "
  285. f"{len(char_spans):>3} spans, {n_entities:>4} labeled entities")
  286. # Save dataset
  287. output = {
  288. "version": "1.0",
  289. "total_cases": len(dataset),
  290. "label_schema": {
  291. "num_bio_labels": NUM_LABELS,
  292. "bio_label_to_id": LABEL2ID,
  293. "case_cause_classes": CASE_CAUSE_CLASSES,
  294. "contract_type_classes": CONTRACT_TYPE_CLASSES,
  295. "employment_type_classes": EMPLOYMENT_TYPE_CLASSES,
  296. },
  297. "dataset": dataset,
  298. }
  299. OUTPUT.write_text(json.dumps(output, ensure_ascii=False, indent=2), encoding="utf-8")
  300. print(f"\nTraining dataset saved: {OUTPUT}")
  301. print(f"Total cases: {len(dataset)}")
  302. return output
  303. if __name__ == "__main__":
  304. generate_training_dataset()