| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286 |
- from __future__ import annotations
- import json
- import re
- from typing import Any
- try:
- from app.extractor import parse_amount
- except ImportError:
- from extractor import parse_amount # type: ignore
- # 与 merge_dispute_template_fields 产出的扁平键一致,供 LLM 输出白名单过滤
- OLLAMA_DISPUTE_TEMPLATE_KEYS: frozenset[str] = frozenset(
- {
- "tmpl_primary_cause",
- "gen_applicant_info",
- "gen_respondent_info",
- "gen_facts_and_reasons",
- "lr1_pay_cycle",
- "lr1_pay_amount",
- "lr1_pay_form",
- "lr1_si_joined",
- "lr1_si_benefit_amount",
- "lr1_contract_signed",
- "lr1_open_ended_contract",
- "lr1_double_wage_no_contract",
- "lr1_relation_duration",
- "wi_lr_contract_signed",
- "wi_lr_relation_duration",
- "wi_benefit_pay_time",
- "wi_benefit_amount_total",
- "wi_benefit_disability",
- "wi_benefit_prosthetic",
- "wi_benefit_medical_allowance",
- "wi_benefit_travel",
- "wi_benefit_rehab",
- "wi_benefit_nursing",
- "wi_benefit_meal",
- "wi_benefit_pay_form",
- "wi_si_benefit_amt",
- "wi_si_joined",
- "wi_recognize_ec",
- "sr_pay_cycle",
- "sr_claim_amount",
- "sr_claim_deducted_pay",
- "sr_claim_overtime_pay",
- "sr_claim_living_allowance",
- "sr_high_temp_allowance",
- "sr_actual_pay_standard",
- "sr_agreed_pay_standard",
- "sr_annual_leave_pay",
- "sr_unpaid_period",
- "sr_overtime_amount",
- "ec_avg_salary_12m",
- "ec_claim_amount",
- "ec_double_wage_part",
- "ec_illegal_term_part",
- "ec_illegal_probation_part",
- "ec_extra_compensation_part",
- "ec_notice_pay",
- "ec_additional_damages",
- "ec_contract_duration",
- "ec_leave_reason",
- "ec_leave_date",
- "dm_claim_amount",
- "dm_illegal_dismissal_damages",
- "dm_contract_exists",
- "dm_terminate_reason",
- "dm_contract_continue",
- "mi_claim_amount",
- "mi_maternity_medical",
- "mi_maternity_allowance_salary",
- "mi_additional_damages",
- "mi_travel_accommodation",
- "mi_contract_continue",
- "mi_terminate_reason",
- }
- )
- _PRIMARY_CAUSE_CHOICES = (
- "劳动关系纠纷类",
- "工伤保险待遇纠纷",
- "追索劳动报酬",
- "经济补偿金纠纷",
- "赔偿金纠纷",
- "生育保险待遇纠纷",
- )
- _LLM_AMOUNT_KEYS: frozenset[str] = frozenset(
- {
- "lr1_pay_amount",
- "lr1_si_benefit_amount",
- "sr_claim_amount",
- "sr_claim_deducted_pay",
- "sr_claim_overtime_pay",
- "sr_claim_living_allowance",
- "sr_high_temp_allowance",
- "sr_annual_leave_pay",
- "sr_overtime_amount",
- "wi_benefit_amount_total",
- "wi_benefit_disability",
- "wi_benefit_prosthetic",
- "wi_benefit_medical_allowance",
- "wi_benefit_travel",
- "wi_benefit_rehab",
- "wi_benefit_nursing",
- "wi_benefit_meal",
- "wi_si_benefit_amt",
- "ec_avg_salary_12m",
- "ec_claim_amount",
- "ec_double_wage_part",
- "ec_illegal_term_part",
- "ec_illegal_probation_part",
- "ec_extra_compensation_part",
- "ec_notice_pay",
- "ec_additional_damages",
- "dm_claim_amount",
- "dm_illegal_dismissal_damages",
- "mi_claim_amount",
- "mi_maternity_medical",
- "mi_maternity_allowance_salary",
- "mi_additional_damages",
- "mi_travel_accommodation",
- }
- )
- class OllamaClaimsExtractor:
- """
- 将原来的 Flask+Ollama 脚本改造成“可被 FastAPI 直接调用的模块”。
- 只负责增强抽取:仲裁请求事项(claims.items)与汇总金额(claims.amount_total)。
- 其他字段仍由规则抽取器提供,避免 LLM 幻觉影响整体稳定性。
- """
- def __init__(self, ollama_host: str, model_name: str):
- # 延迟导入:即使未安装 ollama,也不影响不启用该功能的运行
- import ollama # type: ignore
- self.client = ollama.Client(host=ollama_host)
- self.model_name = model_name
- @staticmethod
- def extract_content_between_sections(text: str) -> str:
- pattern = r"(事实和理由详见).*"
- cleaned_text = re.sub(pattern, "", text or "", flags=re.DOTALL)
- return cleaned_text.strip() if cleaned_text else (text or "")
- def extract_claims(self, text: str) -> dict[str, Any]:
- """
- 返回标准结构:
- {
- "items": ["请求1", "请求2"],
- "amount_total": 10000.0
- }
- """
- extracted_content = self.extract_content_between_sections(text)
- prompt = (
- "请从以下劳动仲裁文本中抽取仲裁请求事项,并严格只返回 JSON(不要解释,不要 Markdown)。\n"
- "返回格式:\n"
- "{\n"
- ' \"items\": [\"请求1\", \"请求2\"],\n'
- ' \"amounts\": [8000, 2000]\n'
- "}\n"
- "要求:\n"
- "- items 用中文完整表述每一项请求;如果没有请求,items 为空数组\n"
- "- amounts 为与各请求对应的金额(元,数字),无法确定就返回空数组\n"
- "- 不要编造文本中没有的信息\n\n"
- f"{extracted_content}"
- )
- response = self.client.chat(
- model=self.model_name,
- messages=[
- {"role": "system", "content": "你是一个法律文本信息抽取助手,只输出JSON。"},
- {"role": "user", "content": prompt},
- ],
- stream=False,
- options={"temperature": 0},
- )
- content = (response.get("message") or {}).get("content") or ""
- content = content.strip()
- # 兼容:有些模型会把 JSON 包在 ``` 里
- content = re.sub(r"^```(?:json)?\s*", "", content)
- content = re.sub(r"\s*```$", "", content)
- try:
- data = json.loads(content)
- except Exception:
- # 回退:直接把内容当成一条 items,并用正则解析金额
- items = [line.strip() for line in re.split(r"[\n;;]+", content) if line.strip()]
- amounts = [parse_amount(it) for it in items]
- amounts = [a for a in amounts if a is not None]
- return {"items": items[:10], "amount_total": sum(amounts) if amounts else None}
- items = data.get("items") or []
- items = [str(x).strip() for x in items if str(x).strip()]
- amounts = data.get("amounts") or []
- clean_amounts: list[float] = []
- for a in amounts:
- try:
- clean_amounts.append(float(a))
- except Exception:
- continue
- amount_total = sum(clean_amounts) if clean_amounts else None
- # 若模型没给 amounts,则从 items 兜底解析
- if amount_total is None and items:
- parsed = [parse_amount(it) for it in items]
- parsed = [p for p in parsed if p is not None]
- amount_total = sum(parsed) if parsed else None
- return {"items": items[:10], "amount_total": amount_total}
- def extract_dispute_template_fields(self, text: str) -> dict[str, Any]:
- """
- 使用与 extract_claims 相同的 Ollama 模型,抽取案由模板扁平字段(与 merge_dispute_template_fields 键兼容)。
- 在规则抽取之后由 main 合并进 elements,并用 refresh_derived_element_fields 重算层级表。
- """
- extracted_content = self.extract_content_between_sections(text or "")
- body = extracted_content[:14000]
- keys_hint = ", ".join(sorted(OLLAMA_DISPUTE_TEMPLATE_KEYS))
- causes_hint = "、".join(_PRIMARY_CAUSE_CHOICES)
- prompt = (
- "你是劳动仲裁材料信息抽取助手。根据下列文书内容抽取要素,严格只返回一个 JSON 对象(不要解释,不要 Markdown)。\n\n"
- f"tmpl_primary_cause 必须是以下案由之一:{causes_hint}\n\n"
- "务必尽量填写:gen_applicant_info(申请人信息一行)、gen_respondent_info(被申请人信息一行)、"
- "gen_facts_and_reasons(事实与理由,可较长)。金额用数字(元),无法确定用 null;是/否类用「是」或「否」。\n\n"
- "其余键名必须来自下列英文键名(可省略或 null):\n"
- f"{keys_hint}\n\n"
- f"文书内容:\n{body}"
- )
- response = self.client.chat(
- model=self.model_name,
- messages=[
- {"role": "system", "content": "你只输出合法 JSON 对象,键名使用英文 snake_case。"},
- {"role": "user", "content": prompt},
- ],
- stream=False,
- options={"temperature": 0},
- )
- content = (response.get("message") or {}).get("content") or ""
- content = content.strip()
- content = re.sub(r"^```(?:json)?\s*", "", content)
- content = re.sub(r"\s*```$", "", content)
- try:
- data = json.loads(content)
- except Exception:
- return {}
- if not isinstance(data, dict):
- return {}
- out: dict[str, Any] = {}
- for k, v in data.items():
- if k not in OLLAMA_DISPUTE_TEMPLATE_KEYS:
- continue
- if v is None:
- continue
- if isinstance(v, str) and not v.strip():
- continue
- if k == "tmpl_primary_cause":
- s = str(v).strip()
- if s in _PRIMARY_CAUSE_CHOICES:
- out[k] = s
- continue
- if isinstance(v, (int, float)) and not isinstance(v, bool):
- out[k] = float(v)
- continue
- if isinstance(v, str):
- vs = v.strip()
- if k in _LLM_AMOUNT_KEYS:
- p = parse_amount(vs)
- out[k] = p if p is not None else vs
- else:
- out[k] = vs
- continue
- out[k] = v
- return out
|