from __future__ import annotations import json import re from dataclasses import dataclass from datetime import date from pathlib import Path from typing import Any, Callable from app.hierarchy_extract import ( DEFAULT_CAUSE_TYPE, build_elements_hierarchy_for_cause, load_hierarchy_templates, ) _WS = r"[ \t\u3000]*" def _clean_text(text: str) -> str: if not text: return "" text = text.replace("\r\n", "\n").replace("\r", "\n") text = re.sub(r"[ \t\u3000]+", " ", text) return text def _first_group(pattern: str, text: str, flags: int = 0) -> str | None: m = re.search(pattern, text, flags) if not m: return None for i in range(1, (m.lastindex or 0) + 1): g = m.group(i) if g is not None and str(g).strip(): return str(g).strip() return None def _all_matches(pattern: str, text: str, flags: int = 0, group: int = 0) -> list[str]: out: list[str] = [] for m in re.finditer(pattern, text, flags): out.append((m.group(group) if group else m.group(0)).strip()) return out _CN_NUM = { "零": 0, "〇": 0, "一": 1, "二": 2, "两": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9, } _CN_UNIT = {"十": 10, "百": 100, "千": 1000, "万": 10000, "亿": 100000000} def parse_cn_number(s: str) -> float | None: """ 解析常见中文金额(如:八千元、二万三千、十二万)为数字。 只覆盖毕业设计 MVP 常见写法;不处理复杂小数表达。 """ if not s: return None s = s.strip() s = re.sub(r"[元整人民币¥\s]", "", s) if not s: return None # 纯中文数字/单位 total = 0 section = 0 number = 0 has_any = False for ch in s: if ch in _CN_NUM: number = _CN_NUM[ch] has_any = True elif ch in _CN_UNIT: unit = _CN_UNIT[ch] has_any = True if unit >= 10000: section = (section + (number or 0)) * unit total += section section = 0 else: section += (number or 1) * unit number = 0 else: return None return float(total + section + number) if has_any else None def parse_amount(text: str) -> float | None: """ 支持:8000元、8,000元、¥8000、八千元 等 """ if not text: return None text = text.strip() # 数字 + 元 m = re.search(r"(?:¥|人民币)?\s*([0-9]{1,3}(?:,[0-9]{3})*|[0-9]+)(?:\.[0-9]{1,2})?\s*元", text) if m: raw = m.group(1).replace(",", "") try: return float(raw) except ValueError: pass # 中文金额 m2 = re.search(r"([零〇一二两三四五六七八九十百千万亿]+)\s*元", text) if m2: return parse_cn_number(m2.group(1)) return None def parse_date_any(s: str) -> str | None: """ 统一解析日期格式并输出 YYYY-MM-DD(字符串)。 支持: - 2020年1月1日 - 2020.01.01 - 2020-01-01 - 2020/1/1 """ if not s: return None s = s.strip() m = re.search(r"([12][0-9]{3})\s*[年./-]\s*([01]?[0-9])\s*[月./-]\s*([0-3]?[0-9])\s*(?:日)?", s) if not m: return None y, mo, d = int(m.group(1)), int(m.group(2)), int(m.group(3)) try: _ = date(y, mo, d) except ValueError: return None return f"{y:04d}-{mo:02d}-{d:02d}" CAUSE_KEYWORDS: list[tuple[str, list[str]]] = [ ("工资报酬", ["工资", "拖欠工资", "未支付工资", "工资差额", "薪资", "报酬"]), ("经济补偿", ["经济补偿", "补偿金", "N+1", "补偿"]), ("工伤待遇", ["工伤", "工伤待遇", "伤残", "一次性伤残", "医疗费", "停工留薪"]), ("违法解除劳动合同", ["违法解除", "非法解除", "无故解除", "违法辞退", "解除劳动合同赔偿"]), ("加班费", ["加班费", "加班工资", "延时加班", "休息日加班", "法定节假日加班", "加班"]), ("未订立书面劳动合同", ["未签订劳动合同", "未订立书面劳动合同", "双倍工资", "二倍工资"]), ("年休假", ["年休假", "未休年假", "带薪年休假"]), ("社会保险", ["社会保险", "社保", "养老保险", "医疗保险", "失业保险", "未缴纳社保"]), ] _SCHEMA_PATH = Path(__file__).resolve().parent.parent / "data" / "case_elements_schema.json" def load_case_elements_schema() -> dict[str, Any]: if not _SCHEMA_PATH.is_file(): return {"groups": [], "field_labels": {}, "table_name": "", "version": ""} try: return json.loads(_SCHEMA_PATH.read_text(encoding="utf-8")) except Exception: return {"groups": [], "field_labels": {}, "table_name": "", "version": ""} def _table_rows_for_ids(flat: dict[str, Any], labels: dict[str, str], field_ids: list[str], covered: set[str]) -> list[dict[str, Any]]: rows: list[dict[str, Any]] = [] for fid in field_ids or []: covered.add(fid) rows.append( { "field_id": fid, "field_label": labels.get(fid, fid), "value": flat.get(fid), } ) return rows def _build_table_group_node(flat: dict[str, Any], labels: dict[str, str], g: dict[str, Any], covered: set[str]) -> dict[str, Any]: """递归:支持 sub_groups(对应要素分解模板的一层/二层/三层结构)。""" gid = g.get("id", "") node: dict[str, Any] = { "group_id": gid, "group_label": g.get("label", gid), "rows": _table_rows_for_ids(flat, labels, g.get("field_ids") or [], covered), "sub_groups": [], } for sg in g.get("sub_groups") or []: node["sub_groups"].append(_build_table_group_node(flat, labels, sg, covered)) return node def _count_table_rows(nodes: list[dict[str, Any]]) -> int: n = 0 for node in nodes: n += len(node.get("rows") or []) n += _count_table_rows(node.get("sub_groups") or []) return n def build_case_elements_table(flat: dict[str, Any], schema: dict[str, Any]) -> dict[str, Any]: """ 按 case_elements_schema.json 生成分组表;支持 sub_groups 嵌套。 schema 中每个 field_id 对应一行;抽取结果里存在但未声明的顶层字段追加「补充要素」。 """ labels = schema.get("field_labels") or {} covered: set[str] = set() groups_out: list[dict[str, Any]] = [_build_table_group_node(flat, labels, g, covered) for g in schema.get("groups") or []] skip_keys = {"case_elements_table"} extra_ids = sorted(k for k in flat.keys() if k not in skip_keys and k not in covered) if extra_ids: groups_out.append( { "group_id": "supplement", "group_label": "补充要素", "rows": [ { "field_id": fid, "field_label": labels.get(fid, fid), "value": flat.get(fid), } for fid in extra_ids ], "sub_groups": [], } ) return { "table_name": schema.get("table_name", "案件要素表"), "version": schema.get("version", ""), "groups": groups_out, "field_count": _count_table_rows(groups_out), } def detect_case_cause(text: str) -> str | None: t = text or "" for cause, keys in CAUSE_KEYWORDS: if any(k in t for k in keys): return cause return None def extract_law_refs(text: str) -> list[str]: t = text or "" # 《劳动合同法》第xx条 / 《xx法》 / 劳动合同法第xx条 refs = set() for item in _all_matches(r"《[^》]{2,30}》\s*第\s*[0-9一二三四五六七八九十百千]+\s*条", t): refs.add(item) for item in _all_matches(r"《[^》]{2,30}》", t): refs.add(item) for item in _all_matches(r"(劳动合同法|劳动争议调解仲裁法|工伤保险条例)\s*第\s*[0-9一二三四五六七八九十百千]+\s*条", t): refs.add(item) return sorted(refs) def extract_case_number(text: str) -> str | None: t = text or "" v = _first_group(r"(?:案号|案件编号|仲裁案号)[::]\s*([^\n,,。;;]{4,40})", t) if v: return v m = re.search(r"劳人仲案字\s*\[[0-9]{4}\]\s*第\s*[0-9一二三四五六七八九十百千]+\s*号", t) if m: return m.group(0).strip() m2 = re.search(r"\([12][0-9]{3}\)\s*第\s*[0-9]+\s*号", t) return m2.group(0).strip() if m2 else None def extract_filing_date(text: str) -> str | None: t = text or "" raw = _first_group(r"(?:立案日期|立案时间)[::]\s*([^\n,,。;;]{4,24})", t) return parse_date_any(raw) if raw else None def extract_arbitration_org(text: str) -> str | None: t = text or "" direct = _first_group(r"(?:仲裁机构|仲裁委员会)[::]\s*([^\n,,。;;]{4,60})", t) if direct: return direct m = re.search(r"([\u4e00-\u9fff]{2,20}劳动(?:人事)?争议仲裁委员会)", t) return m.group(1).strip() if m else None def extract_case_title(text: str) -> str | None: t = text or "" v = _first_group(r"(?:案件名称|标题)[::]\s*([^\n]{2,80})", t) if v: return v.strip() return _first_group(r"案由[::]\s*([^\n,,。;;]{2,40})", t) def extract_applicant_name(text: str) -> str | None: t = text or "" return _first_group(r"(?:申请人|申请方|申请者)[::]" + _WS + r"([^\n,,。;;]{2,20})", t) def extract_applicant_type(text: str) -> str | None: t = text or "" if re.search(r"申请人[::].{0,30}(公司|有限|集团|厂|中心|委员会)", t): return "用人单位(或用工单位)" if re.search(r"申请人类型[::]\s*([^\n,,]{2,20})", t): return _first_group(r"申请人类型[::]\s*([^\n,,]{2,20})", t) name = extract_applicant_name(t) or "" if len(name) <= 4 and not any(x in name for x in ["公司", "有限", "厂"]): return "自然人" return None def extract_employment_type(text: str) -> str | None: t = text or "" for label, keys in [ ("劳务派遣", ["劳务派遣", "派遣员工", "用工单位", "派遣单位"]), ("非全日制用工", ["非全日制"]), ("全日制用工", ["全日制", "标准工时"]), ("劳务关系", ["劳务关系", "劳务协议"]), ]: if any(k in t for k in keys): return label return None def extract_respondent_name(text: str) -> str | None: t = text or "" return _first_group(r"(?:被申请人|被申请方|答辩人)[::]" + _WS + r"([^\n,,。;;]{2,40})", t) def extract_employer_nature(text: str) -> str | None: t = text or "" # 优先从“单位性质”字段取 direct = _first_group(r"(?:单位性质|用人单位性质)[::]" + _WS + r"(企业|事业单位|个人|个体工商户|民办非企业|机关)", t) if direct: if direct in ("个体工商户",): return "个人" return direct # 根据名称后缀猜测 resp = extract_respondent_name(t) or "" if any(k in resp for k in ["有限公司", "有限责任公司", "股份有限公司", "公司", "厂", "集团", "企业"]): return "企业" if any(k in resp for k in ["医院", "学校", "研究院", "事业单位", "中心", "局", "委员会"]): return "事业单位" return None def extract_worker_position(text: str) -> str | None: t = text or "" return _first_group(r"(?:岗位|职位|工种)[::]" + _WS + r"([^\n,,。;;]{2,20})", t) def extract_entry_date(text: str) -> str | None: t = text or "" raw = _first_group(r"(?:入职时间|入职日期|到岗时间|参加工作时间)[::]?\s*([^\n,,。;;]{4,20})", t) if raw: return parse_date_any(raw) raw2 = _first_group(r"(?:于|在)\s*([12][0-9]{3}[年./-][01]?[0-9][月./-][0-3]?[0-9])\s*(?:入职|到岗|开始工作)", t) return parse_date_any(raw2) if raw2 else None def extract_leave_date(text: str) -> str | None: t = text or "" raw = _first_group(r"(?:离职时间|离职日期|解除时间|终止时间)[::]?\s*([^\n,,。;;]{4,20})", t) if raw: return parse_date_any(raw) raw2 = _first_group(r"(?:于|在)\s*([12][0-9]{3}[年./-][01]?[0-9][月./-][0-3]?[0-9])\s*(?:离职|解除劳动合同|解除)", t) return parse_date_any(raw2) if raw2 else None def extract_month_salary(text: str) -> float | None: t = text or "" raw = _first_group(r"(?:月工资|月薪|工资标准)[::]?\s*([^\n,,。;;]{2,30})", t) if raw: amt = parse_amount(raw + "元" if "元" not in raw else raw) if amt is not None: return amt # 兜底:出现“月工资8000元”这样的写法 m = re.search(r"(月工资|月薪|工资标准)" + _WS + r"(?:为|是|:)?\s*(?:人民币|¥)?\s*([0-9]{1,3}(?:,[0-9]{3})*|[0-9]+)\s*元", t) if m: return float(m.group(2).replace(",", "")) return None def extract_overtime_desc(text: str) -> str | None: t = text or "" # 抓取包含“加班”的句子/条目(简化:按换行/句号切) parts = re.split(r"[\n。;;]+", t) hits = [p.strip() for p in parts if "加班" in p and len(p.strip()) >= 6] if not hits: return None return ";".join(hits[:3]) def extract_dispute_focus(text: str) -> str | None: t = text or "" return _first_group(r"(?:争议焦点)[::]\s*([^\n]{2,120})", t) def extract_work_duration_text(text: str) -> str | None: t = text or "" m = re.search(r"(?:工作年限|用工期间|劳动关系存续)[:: ]?\s*([^\n。;;]{4,40})", t) if m: return m.group(1).strip() m2 = re.search(r"(?:自|从)[^\n]{0,30}(?:至|到)[^\n]{0,30}(?:止|共计)", t) return m2.group(0).strip()[:80] if m2 else None def extract_contract_type(text: str) -> str | None: t = text or "" if "无固定期限" in t: return "无固定期限劳动合同" if "固定期限" in t and "劳动合同" in t: return "固定期限劳动合同" if "未签订" in t and "劳动合同" in t: return "未订立书面劳动合同" if "劳动合同" in t and "期限" in t: return _first_group(r"(?:劳动合同|合同)(?:期限|类型)[::]\s*([^\n,,。;;]{2,30})", t) return None def extract_injury_related(text: str) -> str | None: t = text or "" if "工伤" not in t: return None parts = re.split(r"[\n。;;]+", t) hits = [p.strip() for p in parts if "工伤" in p and len(p.strip()) >= 6] return ";".join(hits[:2]) if hits else "涉及工伤" def extract_social_insurance_hint(text: str) -> str | None: t = text or "" if not any(k in t for k in ["社保", "社会保险", "五险", "养老保险", "医疗保险", "失业保险", "公积金"]): return None parts = re.split(r"[\n。;;]+", t) hits = [p.strip() for p in parts if any(k in p for k in ["社保", "社会保险", "五险", "未缴纳", "欠缴"]) and len(p.strip()) >= 6] return ";".join(hits[:2]) if hits else "涉及社会保险争议" def extract_evidence_materials(text: str) -> list[str] | None: t = text or "" block = _first_group( r"(?:证据(?:材料|清单)|附件|所附证据)[::]?\s*(.+?)(?:此致|仲裁请求|事实与理由|申请人[::]|$)", t, flags=re.S, ) if not block: lines = _all_matches(r"^\s*[0-9一二三四五六七八九十]+[.、]\s*([^\n]{2,60})", t, flags=re.M) return lines[:15] if lines else None lines = [ln.strip(" \t-—") for ln in re.split(r"[\n;;]+", block) if ln.strip()] out = [] for ln in lines: ln = re.sub(r"^\s*[((]?\s*[0-9一二三四五六七八九十]+\s*[))]?\s*[.、]?\s*", "", ln) if len(ln) >= 2: out.append(ln[:120]) return out[:15] if out else None def infer_claim_types(text: str, claims: dict[str, Any], case_cause: str | None) -> list[str]: t = text or "" types: set[str] = set() if case_cause: types.add(case_cause) mapping = [ ("工资", "工资报酬"), ("加班", "加班费"), ("经济补偿", "经济补偿"), ("赔偿金", "违法解除赔偿"), ("工伤", "工伤待遇"), ("年休假", "年休假"), ("双倍工资", "未订立书面劳动合同"), ("社保", "社会保险"), ("未签合同", "未订立书面劳动合同"), ] for kw, lab in mapping: if kw in t: types.add(lab) for it in (claims or {}).get("items") or []: s = str(it) for kw, lab in mapping: if kw in s: types.add(lab) return sorted(types) def extract_termination_reason(text: str) -> str | None: t = text or "" direct = _first_group(r"(?:解除原因|解除理由|辞退原因|解除劳动合同原因)[::]" + _WS + r"([^\n。;;]{2,60})", t) if direct: return direct # 兜底:包含“因……解除/辞退” m = re.search(r"(?:因|由于)\s*([^\n。;;]{2,40})\s*(?:被)?(?:辞退|解除劳动合同|解除)", t) if m: return m.group(1).strip() return None def extract_claims(text: str) -> dict[str, Any]: """ 返回: - items: list[str] - amount_total: float | None """ t = text or "" # 请求段落:仲裁请求/请求事项/请求如下 block = _first_group(r"(?:仲裁请求|请求事项|请求如下)[::]?\s*(.+?)(?:事实与理由|事实理由|此致|证据目录|$)", t, flags=re.S) items: list[str] = [] if block: lines = [ln.strip(" \t-—") for ln in re.split(r"[\n;;]+", block) if ln.strip()] # 若是 1. 2. 3. 形式 norm: list[str] = [] for ln in lines: ln = re.sub(r"^\s*[((]?\s*[0-9一二三四五六七八九十]+\s*[))]?\s*[.、]?\s*", "", ln) if ln: norm.append(ln) items = norm[:10] # 汇总金额:从 items 中找最大/累加(MVP:取最大或第一条) amounts = [] for it in items: a = parse_amount(it) if a is not None: amounts.append(a) amount_total = sum(amounts) if amounts else None return {"items": items, "amount_total": amount_total} def _yes_no_from_text(t: str, positive: list[str], negative: list[str]) -> str | None: for p in positive: if p in t: return "是" for n in negative: if n in t: return "否" return None def _snippet_near_keywords(t: str, keywords: tuple[str, ...], width: int = 120) -> str | None: for kw in keywords: i = t.find(kw) if i >= 0: frag = t[max(0, i - 20) : i + width].replace("\n", " ").strip() return frag[:200] if frag else None return None def _amount_near_keywords(t: str, keywords: tuple[str, ...]) -> float | None: for kw in keywords: i = t.find(kw) if i >= 0: window = t[i : i + 100] if (a := parse_amount(window)) is not None: return a return None def classify_template_primary_cause(text: str) -> str | None: """对应《案件普遍的智能分解模板》顶层案由分支(由关键词路由,可多案由时取最先命中)。""" t = text or "" ordered = [ ("生育保险待遇纠纷", ("生育津贴", "生育医疗", "产假工资", "生育保险待遇", "产检", "流产假")), ("赔偿金纠纷", ("违法解除劳动", "违法终止劳动", "违法辞退", "赔偿金", "2N", "二倍经济补偿")), ("经济补偿金纠纷", ("经济补偿金", "代通知金", "N+1", "解除合同经济补偿", "离职补偿")), ("追索劳动报酬", ("追索劳动报酬", "拖欠工资", "工资差额", "加班费", "高温津贴", "年休假工资", "未及时足额")), ("工伤保险待遇纠纷", ("工伤保险待遇", "劳动能力鉴定", "一次性伤残", "停工留薪", "工伤认定", "工伤")), ("劳动关系纠纷类", ("确认劳动关系", "未订立书面劳动合同", "未签订劳动合同", "二倍工资", "双倍工资")), ] for name, kws in ordered: if any(k in t for k in kws): return name return None def merge_dispute_template_fields(text: str, base: dict[str, Any]) -> dict[str, Any]: """ 按「案件普遍的智能分解模板」补充扁平字段(规则/关键词,可与 NLP 模型替换)。 base 为已跑完基础 extractors 的结果,用于拼装通用要素。 """ t = _clean_text(text) o: dict[str, Any] = {} o["tmpl_primary_cause"] = classify_template_primary_cause(t) ga: list[str] = [] if base.get("applicant_name"): ga.append(f"申请人:{base['applicant_name']}") if base.get("applicant_type"): ga.append(f"类型:{base['applicant_type']}") if base.get("worker_position"): ga.append(f"岗位:{base['worker_position']}") ph = _first_group(r"(?:手机|联系电话|电话)[::]?\s*([0-9\-\s]{7,22})", t) if ph: ga.append(f"联系方式:{ph.strip()}") o["gen_applicant_info"] = ";".join(ga) if ga else None gr: list[str] = [] if base.get("respondent_name"): gr.append(f"被申请人:{base['respondent_name']}") if base.get("employer_nature"): gr.append(f"单位性质:{base['employer_nature']}") o["gen_respondent_info"] = ";".join(gr) if gr else None facts = _first_group( r"(?:事实与理由|事实和理由)[::]?\s*(.+?)(?=仲裁请求|请求事项|此致|证据目录|$)", t, flags=re.S, ) o["gen_facts_and_reasons"] = (facts.strip()[:4000] if facts else None) or _snippet_near_keywords( t, ("事实", "理由", "入职", "离职", "工资"), 200 ) # --- 1 劳动关系纠纷类 --- o["lr1_pay_cycle"] = _first_group(r"(?:工资发放|劳动报酬发放|支付周期)[::]?\s*([^\n。;]{2,40})", t) or _snippet_near_keywords( t, ("按月", "每月", "月薪", "计件"), 30 ) ms = base.get("month_salary") try: ms_f = float(ms) if ms is not None and ms != "" else None except (TypeError, ValueError): ms_f = None o["lr1_pay_amount"] = _amount_near_keywords(t, ("劳动报酬", "工资标准", "月工资", "月薪")) or ms_f o["lr1_pay_form"] = _snippet_near_keywords(t, ("银行转账", "现金", "微信", "支付宝", "打卡")) o["lr1_si_joined"] = _yes_no_from_text( t, ["已缴纳社保", "参加社会保险", "缴纳五险", "有社保"], ["未缴纳社保", "未参加社会保险", "未缴社保", "无社保"], ) or base.get("social_insurance_hint") o["lr1_si_benefit_amount"] = _amount_near_keywords(t, ("保险待遇", "社保待遇", "补缴")) o["lr1_contract_signed"] = _yes_no_from_text( t, ["签订劳动合同", "订立书面劳动合同", "有书面合同"], ["未签订劳动合同", "未订立书面劳动合同", "无书面合同"], ) o["lr1_open_ended_contract"] = "是" if ("无固定期限" in t or "无固定期" in t) else None o["lr1_double_wage_no_contract"] = "是" if ("二倍工资" in t or "双倍工资" in t or "未签劳动合同" in t) else None o["lr1_relation_duration"] = base.get("work_duration_text") or _snippet_near_keywords( t, ("劳动关系", "用工期间", "入职", "离职"), 80 ) # --- 2 工伤保险待遇纠纷 --- o["wi_lr_contract_signed"] = o.get("lr1_contract_signed") o["wi_lr_relation_duration"] = base.get("work_duration_text") or o.get("lr1_relation_duration") o["wi_benefit_pay_time"] = _first_group(r"(?:待遇发放|支付时间|发放时间)[::]?\s*([^\n。;]{2,40})", t) o["wi_benefit_amount_total"] = _amount_near_keywords(t, ("工伤保险待遇", "工伤待遇", "一次性伤残", "补助金")) o["wi_benefit_disability"] = _amount_near_keywords(t, ("伤残津贴", "伤残补助")) o["wi_benefit_prosthetic"] = _amount_near_keywords(t, ("假肢", "辅助器具")) o["wi_benefit_medical_allowance"] = _amount_near_keywords(t, ("医疗补助金", "医疗补助")) o["wi_benefit_travel"] = _amount_near_keywords(t, ("交通费", "食宿费", "交通食宿")) o["wi_benefit_rehab"] = _amount_near_keywords(t, ("康复费", "医疗费")) o["wi_benefit_nursing"] = _amount_near_keywords(t, ("护理费",)) o["wi_benefit_meal"] = _amount_near_keywords(t, ("住院伙食", "伙食补助")) o["wi_benefit_pay_form"] = o.get("lr1_pay_form") o["wi_si_benefit_amt"] = o.get("lr1_si_benefit_amount") o["wi_si_joined"] = o.get("lr1_si_joined") o["wi_recognize_ec"] = _yes_no_from_text( t, ["认可经济补偿", "同意支付经济补偿", "支付经济补偿金"], ["不认可经济补偿", "无需支付经济补偿", "不同意经济补偿"], ) # --- 3 追索劳动报酬 --- o["sr_pay_cycle"] = o.get("lr1_pay_cycle") o["sr_claim_amount"] = _amount_near_keywords(t, ("主张金额", "请求金额", "仲裁请求")) or (base.get("claims") or {}).get( "amount_total" ) o["sr_claim_deducted_pay"] = _amount_near_keywords(t, ("克扣", "拖欠工资", "欠付工资")) o["sr_claim_overtime_pay"] = _amount_near_keywords(t, ("加班费", "加班工资")) or ( parse_amount(base.get("overtime_desc") or "") if base.get("overtime_desc") else None ) o["sr_claim_living_allowance"] = _amount_near_keywords(t, ("生活费", "待岗")) o["sr_high_temp_allowance"] = _amount_near_keywords(t, ("高温津贴", "防暑降温")) o["sr_actual_pay_standard"] = _snippet_near_keywords(t, ("实发工资", "实际支付", "实发"), 40) o["sr_agreed_pay_standard"] = _snippet_near_keywords(t, ("约定工资", "合同约定工资", "月薪约定"), 40) or ( str(base["month_salary"]) if base.get("month_salary") is not None else None ) o["sr_annual_leave_pay"] = _amount_near_keywords(t, ("年休假工资", "未休年假", "带薪年休假")) o["sr_unpaid_period"] = _first_group(r"(?:欠付期间|未支付期间|拖欠期间)[::]?\s*([^\n。;]{2,60})", t) or _snippet_near_keywords( t, ("至", "期间"), 50 ) o["sr_overtime_amount"] = o.get("sr_claim_overtime_pay") # --- 4 经济补偿金纠纷 --- o["ec_avg_salary_12m"] = _first_group( r"(?:离职前\s*12\s*个月|十二个月).{0,12}(?:平均|月均)工资[::]?\s*([^\n,。;]{2,40})", t, ) or _amount_near_keywords(t, ("平均工资", "月均工资", "月平均工资")) o["ec_claim_amount"] = o.get("sr_claim_amount") o["ec_double_wage_part"] = _amount_near_keywords(t, ("二倍工资", "双倍工资", "未签劳动合同")) o["ec_illegal_term_part"] = _amount_near_keywords(t, ("违法解除", "违法终止", "违法辞退")) o["ec_illegal_probation_part"] = _amount_near_keywords(t, ("违法约定试用期", "试用期赔偿")) o["ec_extra_compensation_part"] = _amount_near_keywords(t, ("额外补偿", "加付")) o["ec_notice_pay"] = _amount_near_keywords(t, ("代通知金", "提前三十日")) o["ec_additional_damages"] = _amount_near_keywords(t, ("加付赔偿金", "赔偿金50%")) o["ec_contract_duration"] = base.get("work_duration_text") o["ec_leave_reason"] = base.get("termination_reason") o["ec_leave_date"] = base.get("leave_date") # --- 5 赔偿金纠纷 --- o["dm_claim_amount"] = o.get("sr_claim_amount") o["dm_illegal_dismissal_damages"] = _amount_near_keywords(t, ("违法解除劳动", "违法辞退", "赔偿金", "2倍")) o["dm_contract_exists"] = o.get("lr1_contract_signed") o["dm_terminate_reason"] = base.get("termination_reason") o["dm_contract_continue"] = _yes_no_from_text(t, ["继续履行劳动合同", "恢复劳动关系"], ["不继续履行", "解除劳动关系"]) # --- 6 生育保险待遇纠纷 --- o["mi_claim_amount"] = o.get("sr_claim_amount") o["mi_maternity_medical"] = _amount_near_keywords(t, ("生育医疗费", "生育医疗费用", "产检费")) o["mi_maternity_allowance_salary"] = _amount_near_keywords(t, ("生育津贴", "产假工资")) o["mi_additional_damages"] = o.get("ec_additional_damages") o["mi_travel_accommodation"] = o.get("wi_benefit_travel") o["mi_contract_continue"] = o.get("dm_contract_continue") o["mi_terminate_reason"] = base.get("termination_reason") return o def refresh_derived_element_fields(out: dict[str, Any], text: str) -> None: """ 在 merge_dispute_template_fields 或 LLM 补丁写入 out 之后,重算 primary_cause_type、elements_hierarchy、case_elements_table。 """ t = _clean_text(text) templates = load_hierarchy_templates() cause = out.get("tmpl_primary_cause") or classify_template_primary_cause(t) or DEFAULT_CAUSE_TYPE if templates and cause not in templates: cause = DEFAULT_CAUSE_TYPE out["primary_cause_type"] = cause out["tmpl_primary_cause"] = cause if templates and cause in templates: hier = build_elements_hierarchy_for_cause(cause, out) if hier is not None: out["elements_hierarchy"] = hier schema = load_case_elements_schema() out["case_elements_table"] = build_case_elements_table(out, schema) @dataclass class RuleBasedLaborExtractor: """ MVP:基于规则/正则的劳动仲裁要素抽取器(后续可替换为 BERT 微调模型)。 要素分组与中文标签由 backend/data/case_elements_schema.json 定义,可替换为您自己的案件要素表。 """ extractors: list[tuple[str, Callable[[str], Any]]] | None = None _schema: dict[str, Any] | None = None def __post_init__(self) -> None: self._schema = load_case_elements_schema() if self.extractors is None: self.extractors = [ ("case_number", extract_case_number), ("filing_date", extract_filing_date), ("arbitration_org", extract_arbitration_org), ("case_title", extract_case_title), ("applicant_name", extract_applicant_name), ("applicant_type", extract_applicant_type), ("respondent_name", extract_respondent_name), ("employer_nature", extract_employer_nature), ("worker_position", extract_worker_position), ("employment_type", extract_employment_type), ("case_cause", detect_case_cause), ("dispute_focus", extract_dispute_focus), ("entry_date", extract_entry_date), ("leave_date", extract_leave_date), ("work_duration_text", extract_work_duration_text), ("month_salary", extract_month_salary), ("overtime_desc", extract_overtime_desc), ("termination_reason", extract_termination_reason), ("contract_type", extract_contract_type), ("injury_related", extract_injury_related), ("social_insurance_hint", extract_social_insurance_hint), ("claims", extract_claims), ("law_refs", extract_law_refs), ("evidence_materials", extract_evidence_materials), ] def extract(self, text: str) -> dict[str, Any]: text = _clean_text(text) out: dict[str, Any] = {} for key, fn in self.extractors or []: try: out[key] = fn(text) except Exception: out[key] = None try: out["claim_types"] = infer_claim_types(text, out.get("claims") or {}, out.get("case_cause")) except Exception: out["claim_types"] = [] try: out.update(merge_dispute_template_fields(text, out)) except Exception: pass refresh_derived_element_fields(out, text) return out