anj.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. from __future__ import annotations
  2. import json
  3. import re
  4. from typing import Any
  5. try:
  6. from app.extractor import parse_amount
  7. except ImportError:
  8. from extractor import parse_amount # type: ignore
  9. # 与 merge_dispute_template_fields 产出的扁平键一致,供 LLM 输出白名单过滤
  10. OLLAMA_DISPUTE_TEMPLATE_KEYS: frozenset[str] = frozenset(
  11. {
  12. "tmpl_primary_cause",
  13. "gen_applicant_info",
  14. "gen_respondent_info",
  15. "gen_facts_and_reasons",
  16. "lr1_pay_cycle",
  17. "lr1_pay_amount",
  18. "lr1_pay_form",
  19. "lr1_si_joined",
  20. "lr1_si_benefit_amount",
  21. "lr1_contract_signed",
  22. "lr1_open_ended_contract",
  23. "lr1_double_wage_no_contract",
  24. "lr1_relation_duration",
  25. "wi_lr_contract_signed",
  26. "wi_lr_relation_duration",
  27. "wi_benefit_pay_time",
  28. "wi_benefit_amount_total",
  29. "wi_benefit_disability",
  30. "wi_benefit_prosthetic",
  31. "wi_benefit_medical_allowance",
  32. "wi_benefit_travel",
  33. "wi_benefit_rehab",
  34. "wi_benefit_nursing",
  35. "wi_benefit_meal",
  36. "wi_benefit_pay_form",
  37. "wi_si_benefit_amt",
  38. "wi_si_joined",
  39. "wi_recognize_ec",
  40. "sr_pay_cycle",
  41. "sr_claim_amount",
  42. "sr_claim_deducted_pay",
  43. "sr_claim_overtime_pay",
  44. "sr_claim_living_allowance",
  45. "sr_high_temp_allowance",
  46. "sr_actual_pay_standard",
  47. "sr_agreed_pay_standard",
  48. "sr_annual_leave_pay",
  49. "sr_unpaid_period",
  50. "sr_overtime_amount",
  51. "ec_avg_salary_12m",
  52. "ec_claim_amount",
  53. "ec_double_wage_part",
  54. "ec_illegal_term_part",
  55. "ec_illegal_probation_part",
  56. "ec_extra_compensation_part",
  57. "ec_notice_pay",
  58. "ec_additional_damages",
  59. "ec_contract_duration",
  60. "ec_leave_reason",
  61. "ec_leave_date",
  62. "dm_claim_amount",
  63. "dm_illegal_dismissal_damages",
  64. "dm_contract_exists",
  65. "dm_terminate_reason",
  66. "dm_contract_continue",
  67. "mi_claim_amount",
  68. "mi_maternity_medical",
  69. "mi_maternity_allowance_salary",
  70. "mi_additional_damages",
  71. "mi_travel_accommodation",
  72. "mi_contract_continue",
  73. "mi_terminate_reason",
  74. }
  75. )
  76. _PRIMARY_CAUSE_CHOICES = (
  77. "劳动关系纠纷类",
  78. "工伤保险待遇纠纷",
  79. "追索劳动报酬",
  80. "经济补偿金纠纷",
  81. "赔偿金纠纷",
  82. "生育保险待遇纠纷",
  83. )
  84. _LLM_AMOUNT_KEYS: frozenset[str] = frozenset(
  85. {
  86. "lr1_pay_amount",
  87. "lr1_si_benefit_amount",
  88. "sr_claim_amount",
  89. "sr_claim_deducted_pay",
  90. "sr_claim_overtime_pay",
  91. "sr_claim_living_allowance",
  92. "sr_high_temp_allowance",
  93. "sr_annual_leave_pay",
  94. "sr_overtime_amount",
  95. "wi_benefit_amount_total",
  96. "wi_benefit_disability",
  97. "wi_benefit_prosthetic",
  98. "wi_benefit_medical_allowance",
  99. "wi_benefit_travel",
  100. "wi_benefit_rehab",
  101. "wi_benefit_nursing",
  102. "wi_benefit_meal",
  103. "wi_si_benefit_amt",
  104. "ec_avg_salary_12m",
  105. "ec_claim_amount",
  106. "ec_double_wage_part",
  107. "ec_illegal_term_part",
  108. "ec_illegal_probation_part",
  109. "ec_extra_compensation_part",
  110. "ec_notice_pay",
  111. "ec_additional_damages",
  112. "dm_claim_amount",
  113. "dm_illegal_dismissal_damages",
  114. "mi_claim_amount",
  115. "mi_maternity_medical",
  116. "mi_maternity_allowance_salary",
  117. "mi_additional_damages",
  118. "mi_travel_accommodation",
  119. }
  120. )
  121. class OllamaClaimsExtractor:
  122. """
  123. 将原来的 Flask+Ollama 脚本改造成“可被 FastAPI 直接调用的模块”。
  124. 只负责增强抽取:仲裁请求事项(claims.items)与汇总金额(claims.amount_total)。
  125. 其他字段仍由规则抽取器提供,避免 LLM 幻觉影响整体稳定性。
  126. """
  127. def __init__(self, ollama_host: str, model_name: str):
  128. # 延迟导入:即使未安装 ollama,也不影响不启用该功能的运行
  129. import ollama # type: ignore
  130. self.client = ollama.Client(host=ollama_host)
  131. self.model_name = model_name
  132. @staticmethod
  133. def extract_content_between_sections(text: str) -> str:
  134. pattern = r"(事实和理由详见).*"
  135. cleaned_text = re.sub(pattern, "", text or "", flags=re.DOTALL)
  136. return cleaned_text.strip() if cleaned_text else (text or "")
  137. def extract_claims(self, text: str) -> dict[str, Any]:
  138. """
  139. 返回标准结构:
  140. {
  141. "items": ["请求1", "请求2"],
  142. "amount_total": 10000.0
  143. }
  144. """
  145. extracted_content = self.extract_content_between_sections(text)
  146. prompt = (
  147. "请从以下劳动仲裁文本中抽取仲裁请求事项,并严格只返回 JSON(不要解释,不要 Markdown)。\n"
  148. "返回格式:\n"
  149. "{\n"
  150. ' \"items\": [\"请求1\", \"请求2\"],\n'
  151. ' \"amounts\": [8000, 2000]\n'
  152. "}\n"
  153. "要求:\n"
  154. "- items 用中文完整表述每一项请求;如果没有请求,items 为空数组\n"
  155. "- amounts 为与各请求对应的金额(元,数字),无法确定就返回空数组\n"
  156. "- 不要编造文本中没有的信息\n\n"
  157. f"{extracted_content}"
  158. )
  159. response = self.client.chat(
  160. model=self.model_name,
  161. messages=[
  162. {"role": "system", "content": "你是一个法律文本信息抽取助手,只输出JSON。"},
  163. {"role": "user", "content": prompt},
  164. ],
  165. stream=False,
  166. options={"temperature": 0},
  167. )
  168. content = (response.get("message") or {}).get("content") or ""
  169. content = content.strip()
  170. # 兼容:有些模型会把 JSON 包在 ``` 里
  171. content = re.sub(r"^```(?:json)?\s*", "", content)
  172. content = re.sub(r"\s*```$", "", content)
  173. try:
  174. data = json.loads(content)
  175. except Exception:
  176. # 回退:直接把内容当成一条 items,并用正则解析金额
  177. items = [line.strip() for line in re.split(r"[\n;;]+", content) if line.strip()]
  178. amounts = [parse_amount(it) for it in items]
  179. amounts = [a for a in amounts if a is not None]
  180. return {"items": items[:10], "amount_total": sum(amounts) if amounts else None}
  181. items = data.get("items") or []
  182. items = [str(x).strip() for x in items if str(x).strip()]
  183. amounts = data.get("amounts") or []
  184. clean_amounts: list[float] = []
  185. for a in amounts:
  186. try:
  187. clean_amounts.append(float(a))
  188. except Exception:
  189. continue
  190. amount_total = sum(clean_amounts) if clean_amounts else None
  191. # 若模型没给 amounts,则从 items 兜底解析
  192. if amount_total is None and items:
  193. parsed = [parse_amount(it) for it in items]
  194. parsed = [p for p in parsed if p is not None]
  195. amount_total = sum(parsed) if parsed else None
  196. return {"items": items[:10], "amount_total": amount_total}
  197. def extract_dispute_template_fields(self, text: str) -> dict[str, Any]:
  198. """
  199. 使用与 extract_claims 相同的 Ollama 模型,抽取案由模板扁平字段(与 merge_dispute_template_fields 键兼容)。
  200. 在规则抽取之后由 main 合并进 elements,并用 refresh_derived_element_fields 重算层级表。
  201. """
  202. extracted_content = self.extract_content_between_sections(text or "")
  203. body = extracted_content[:14000]
  204. keys_hint = ", ".join(sorted(OLLAMA_DISPUTE_TEMPLATE_KEYS))
  205. causes_hint = "、".join(_PRIMARY_CAUSE_CHOICES)
  206. prompt = (
  207. "你是劳动仲裁材料信息抽取助手。根据下列文书内容抽取要素,严格只返回一个 JSON 对象(不要解释,不要 Markdown)。\n\n"
  208. f"tmpl_primary_cause 必须是以下案由之一:{causes_hint}\n\n"
  209. "务必尽量填写:gen_applicant_info(申请人信息一行)、gen_respondent_info(被申请人信息一行)、"
  210. "gen_facts_and_reasons(事实与理由,可较长)。金额用数字(元),无法确定用 null;是/否类用「是」或「否」。\n\n"
  211. "其余键名必须来自下列英文键名(可省略或 null):\n"
  212. f"{keys_hint}\n\n"
  213. f"文书内容:\n{body}"
  214. )
  215. response = self.client.chat(
  216. model=self.model_name,
  217. messages=[
  218. {"role": "system", "content": "你只输出合法 JSON 对象,键名使用英文 snake_case。"},
  219. {"role": "user", "content": prompt},
  220. ],
  221. stream=False,
  222. options={"temperature": 0},
  223. )
  224. content = (response.get("message") or {}).get("content") or ""
  225. content = content.strip()
  226. content = re.sub(r"^```(?:json)?\s*", "", content)
  227. content = re.sub(r"\s*```$", "", content)
  228. try:
  229. data = json.loads(content)
  230. except Exception:
  231. return {}
  232. if not isinstance(data, dict):
  233. return {}
  234. out: dict[str, Any] = {}
  235. for k, v in data.items():
  236. if k not in OLLAMA_DISPUTE_TEMPLATE_KEYS:
  237. continue
  238. if v is None:
  239. continue
  240. if isinstance(v, str) and not v.strip():
  241. continue
  242. if k == "tmpl_primary_cause":
  243. s = str(v).strip()
  244. if s in _PRIMARY_CAUSE_CHOICES:
  245. out[k] = s
  246. continue
  247. if isinstance(v, (int, float)) and not isinstance(v, bool):
  248. out[k] = float(v)
  249. continue
  250. if isinstance(v, str):
  251. vs = v.strip()
  252. if k in _LLM_AMOUNT_KEYS:
  253. p = parse_amount(vs)
  254. out[k] = p if p is not None else vs
  255. else:
  256. out[k] = vs
  257. continue
  258. out[k] = v
  259. return out