main.py 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107
  1. from __future__ import annotations
  2. import hashlib
  3. import json
  4. import os
  5. import re
  6. import traceback
  7. from datetime import datetime
  8. from pathlib import Path
  9. from tempfile import NamedTemporaryFile
  10. from typing import Any
  11. from uuid import uuid4
  12. import magic
  13. from fastapi import BackgroundTasks, Depends, FastAPI, File, Form, HTTPException, UploadFile
  14. from fastapi.middleware.cors import CORSMiddleware
  15. from fastapi.responses import JSONResponse, Response
  16. from pydantic import BaseModel, Field
  17. from sqlalchemy import func
  18. from sqlalchemy.orm import Session
  19. from app.db import Base, engine, get_db
  20. from app.config import settings
  21. from app.extractor import (
  22. RuleBasedLaborExtractor,
  23. build_case_elements_table,
  24. load_case_elements_schema,
  25. merge_dispute_template_fields,
  26. refresh_derived_element_fields,
  27. )
  28. from app.migrate import ensure_mysql_schema
  29. from app.models import Case, CaseElementsVersion, CaseFile, ProcessingTask
  30. from app.services.document_parser import DocumentParser
  31. from app.services.hybrid_extractor import HybridExtractor
  32. from app.services.complexity_classifier import classify_complexity
  33. from app.services.portrait_generator import generate_portrait
  34. from app.services.risk_predictor import assess_risk
  35. app = FastAPI(title="Labor Arbitration Backend", version="0.1.0")
  36. app.add_middleware(
  37. CORSMiddleware,
  38. allow_origins=["*"],
  39. allow_credentials=True,
  40. allow_methods=["*"],
  41. allow_headers=["*"],
  42. )
  43. # 开发期轻量迁移(避免你改模型后 MySQL 表不同步导致 500)
  44. ensure_mysql_schema(engine)
  45. Base.metadata.create_all(bind=engine)
  46. extractor = RuleBasedLaborExtractor()
  47. hybrid_extractor = HybridExtractor()
  48. _ollama_claims = None
  49. if settings.use_ollama:
  50. try:
  51. from app.anj import OllamaClaimsExtractor
  52. _ollama_claims = OllamaClaimsExtractor(settings.ollama_base_url, settings.ollama_model_name)
  53. except Exception:
  54. _ollama_claims = None
  55. @app.exception_handler(Exception)
  56. async def global_exception_handler(request, exc: Exception):
  57. return JSONResponse(
  58. status_code=500,
  59. content={"detail": "服务器内部错误", "error": str(exc), "trace": traceback.format_exc()[:4000]},
  60. )
  61. def _upload_dir() -> Path:
  62. p = Path(os.getenv("UPLOAD_DIR", "uploads"))
  63. p.mkdir(parents=True, exist_ok=True)
  64. return p
  65. # 入库全文上限(MySQL LONGTEXT 足够;此处防止极端大文件占满内存)
  66. _MAX_STORED_APPLICATION_TEXT = 200_000
  67. # 接口返回给前端的最大长度(避免 JSON 过大)
  68. _MAX_API_APPLICATION_TEXT = 50_000
  69. def _sync_case_elements_table(elements: dict[str, Any], *, rebuild_hierarchy: bool = True) -> None:
  70. """扁平要素定稿后再生成分组表;可选按扁平字段重算案由层级树(避免 PUT 仅改层级时被覆盖)。"""
  71. elements["case_elements_table"] = build_case_elements_table(elements, load_case_elements_schema())
  72. if not rebuild_hierarchy:
  73. return
  74. from app.hierarchy_extract import build_elements_hierarchy_for_cause, load_hierarchy_templates
  75. cause = elements.get("primary_cause_type")
  76. tmpl = load_hierarchy_templates()
  77. if cause and tmpl.get(cause):
  78. h = build_elements_hierarchy_for_cause(cause, elements)
  79. if h is not None:
  80. elements["elements_hierarchy"] = h
  81. def _normalize_case_name(name: str) -> str:
  82. return (name or "").replace("\u3000", " ").strip()
  83. def _material_fingerprint_from_file_contents(contents: list[bytes]) -> str:
  84. if not contents:
  85. return hashlib.sha256(b"").hexdigest()
  86. digests = sorted(hashlib.sha256(c).hexdigest() for c in contents)
  87. return hashlib.sha256("|".join(digests).encode("utf-8")).hexdigest()
  88. def _normalized_text_fingerprint(merged_stored: str) -> str:
  89. """空白规范化后哈希,减轻解析换行/空格差异导致的重复漏判。"""
  90. slice_ = (merged_stored or "")[:_MAX_STORED_APPLICATION_TEXT]
  91. norm = re.sub(r"[\s\u3000]+", " ", slice_.strip())
  92. return hashlib.sha256(norm.encode("utf-8", errors="ignore")).hexdigest()
  93. def _merged_text_from_prepared(prepared: list[tuple[UploadFile, bytes, str]]) -> str:
  94. chunks: list[str] = []
  95. for f, content, _ in prepared:
  96. suffix = Path(f.filename).suffix.lower() or ".txt"
  97. tmp = NamedTemporaryFile(delete=False, suffix=suffix)
  98. try:
  99. tmp.write(content)
  100. tmp.close()
  101. chunks.append(DocumentParser.parse_file(tmp.name))
  102. finally:
  103. try:
  104. Path(tmp.name).unlink(missing_ok=True)
  105. except OSError:
  106. pass
  107. return "\n\n".join(t for t in chunks if t.strip())
  108. def _find_existing_duplicate_case(
  109. db: Session, norm_name: str, fingerprint: str, merged_stored: str
  110. ) -> Case | None:
  111. """
  112. 去重顺序:
  113. 1) 同名 + 材料指纹一致,或同名 + 规范化正文哈希一致(兼容旧行无指纹)
  114. 2) 任意案件材料指纹一致(材料相同即视为重复,不强制名称一致)
  115. 3) 最近若干条有正文的案件中,规范化正文哈希一致(无指纹旧数据)
  116. """
  117. text_h = _normalized_text_fingerprint(merged_stored)
  118. if norm_name:
  119. cands_name = db.query(Case).filter(func.trim(Case.case_name) == norm_name).all()
  120. for c in cands_name:
  121. mf = (c.material_fingerprint or "").strip()
  122. if mf and mf == fingerprint:
  123. return c
  124. app_raw = c.application_text or ""
  125. if app_raw and _normalized_text_fingerprint(app_raw) == text_h:
  126. return c
  127. row = db.query(Case).filter(Case.material_fingerprint == fingerprint).filter(Case.material_fingerprint.isnot(None)).first()
  128. if row:
  129. return row
  130. # 仅「同名 + 正文哈希一致」才命中,避免不同案件名称因正文规范化后相同而误命中旧案(导致前端一直显示首次抽取结果)
  131. recent = (
  132. db.query(Case)
  133. .filter(Case.application_text.isnot(None))
  134. .filter(Case.application_text != "")
  135. .order_by(Case.id.desc())
  136. .limit(400)
  137. .all()
  138. )
  139. for c in recent:
  140. if _normalize_case_name(c.case_name or "") != norm_name:
  141. continue
  142. if _normalized_text_fingerprint(c.application_text or "") == text_h:
  143. return c
  144. return None
  145. def _allowed(content_type: str, filename: str) -> bool:
  146. ext = Path(filename).suffix.lower()
  147. if ext in {".pdf", ".docx", ".txt"}:
  148. return True
  149. if content_type in {
  150. "application/pdf",
  151. "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
  152. "text/plain",
  153. }:
  154. return True
  155. return False
  156. def _jaccard(a: set[str], b: set[str]) -> float:
  157. if not a or not b:
  158. return 0.0
  159. inter = len(a & b)
  160. union = len(a | b)
  161. return inter / union if union else 0.0
  162. def _case_tag_set(elements: dict[str, Any]) -> set[str]:
  163. tags = set()
  164. for k in ["case_cause", "employer_nature", "worker_position", "applicant_name", "respondent_name"]:
  165. v = elements.get(k)
  166. if v:
  167. tags.add(str(v))
  168. for law in (elements.get("law_refs") or []):
  169. tags.add(str(law))
  170. claims = elements.get("claims") or {}
  171. for item in (claims.get("items") or []):
  172. if item:
  173. tags.add(str(item)[:20])
  174. for ct in elements.get("claim_types") or []:
  175. if ct:
  176. tags.add(str(ct))
  177. return tags
  178. def _elements_brief(elements: dict[str, Any] | None) -> dict[str, Any]:
  179. el = elements or {}
  180. claims = el.get("claims") or {}
  181. return {
  182. "case_number": el.get("case_number"),
  183. "arbitration_org": el.get("arbitration_org"),
  184. "applicant_name": el.get("applicant_name"),
  185. "respondent_name": el.get("respondent_name"),
  186. "employer_nature": el.get("employer_nature"),
  187. "worker_position": el.get("worker_position"),
  188. "case_cause": el.get("case_cause"),
  189. "claim_types": el.get("claim_types"),
  190. "entry_date": el.get("entry_date"),
  191. "leave_date": el.get("leave_date"),
  192. "month_salary": el.get("month_salary"),
  193. "claims_amount_total": claims.get("amount_total"),
  194. "claims_items_preview": (claims.get("items") or [])[:5],
  195. "law_refs": (el.get("law_refs") or [])[:8],
  196. }
  197. def _portrait_scores(portrait: dict[str, Any] | None) -> dict[str, Any]:
  198. p = portrait or {}
  199. scores = p.get("scores") or {}
  200. return {
  201. "legal": scores.get("legal"),
  202. "fact": scores.get("fact"),
  203. "risk": scores.get("risk"),
  204. "risk_level": p.get("risk_level"),
  205. }
  206. _SIM_WEIGHT_LEGAL = 0.45
  207. _SIM_WEIGHT_FACT = 0.35
  208. _SIM_WEIGHT_RISK = 0.20
  209. def _subscore(p: dict[str, Any] | None, dim: str, name: str) -> float | None:
  210. if not p:
  211. return None
  212. d = (p.get(dim) or {}).get("subscores") or {}
  213. v = d.get(name)
  214. try:
  215. return float(v) if v is not None else None
  216. except (TypeError, ValueError):
  217. return None
  218. def _sim_0_100(a: float | None, b: float | None) -> float | None:
  219. if a is None or b is None:
  220. return None
  221. return max(0.0, 100.0 - abs(float(a) - float(b)))
  222. def _weighted_similarity_from_portraits(base_p: dict[str, Any] | None, sim_p: dict[str, Any] | None) -> dict[str, Any]:
  223. """
  224. 计算法律/事实/风险三维度加权相似度(0-100 越高越相似),并返回 breakdown。
  225. - 法律维度:争议焦点、法律适用/法条引用(用画像 subscores 近似)
  226. - 事实维度:证据完备度、事实清晰度
  227. - 风险维度:诉求支持可能性、矛盾激化程度
  228. """
  229. legal_sim = None
  230. ls1 = _sim_0_100(_subscore(base_p, "legal_dimension", "争议焦点明确度"), _subscore(sim_p, "legal_dimension", "争议焦点明确度"))
  231. ls2 = _sim_0_100(_subscore(base_p, "legal_dimension", "法律条款引用充分度"), _subscore(sim_p, "legal_dimension", "法律条款引用充分度"))
  232. legal_parts = [x for x in (ls1, ls2) if x is not None]
  233. if legal_parts:
  234. legal_sim = sum(legal_parts) / len(legal_parts)
  235. fact_sim = None
  236. fs1 = _sim_0_100(_subscore(base_p, "fact_dimension", "证据完备度(近似)"), _subscore(sim_p, "fact_dimension", "证据完备度(近似)"))
  237. fs2 = _sim_0_100(_subscore(base_p, "fact_dimension", "事实描述清晰度"), _subscore(sim_p, "fact_dimension", "事实描述清晰度"))
  238. fact_parts = [x for x in (fs1, fs2) if x is not None]
  239. if fact_parts:
  240. fact_sim = sum(fact_parts) / len(fact_parts)
  241. risk_sim = None
  242. rs1 = _sim_0_100(
  243. _subscore(base_p, "risk_dimension", "诉求支持可能性(规则近似)"),
  244. _subscore(sim_p, "risk_dimension", "诉求支持可能性(规则近似)"),
  245. )
  246. rs2 = _sim_0_100(
  247. _subscore(base_p, "risk_dimension", "矛盾激化程度(规则近似)"),
  248. _subscore(sim_p, "risk_dimension", "矛盾激化程度(规则近似)"),
  249. )
  250. risk_parts = [x for x in (rs1, rs2) if x is not None]
  251. if risk_parts:
  252. risk_sim = sum(risk_parts) / len(risk_parts)
  253. breakdown: list[dict[str, Any]] = [
  254. {"dimension": "法律维度", "weight": _SIM_WEIGHT_LEGAL, "similarity": legal_sim},
  255. {"dimension": "事实维度", "weight": _SIM_WEIGHT_FACT, "similarity": fact_sim},
  256. {"dimension": "风险维度", "weight": _SIM_WEIGHT_RISK, "similarity": risk_sim},
  257. ]
  258. total_w = 0.0
  259. total = 0.0
  260. for r in breakdown:
  261. sim = r.get("similarity")
  262. if sim is None:
  263. continue
  264. w = float(r["weight"])
  265. total_w += w
  266. total += w * float(sim)
  267. overall = (total / total_w) if total_w > 0 else None
  268. return {"overall": overall, "breakdown": breakdown}
  269. def _comparison_with_current(base_el: dict[str, Any], sim_el: dict[str, Any]) -> list[dict[str, Any]]:
  270. rows: list[tuple[str, str, str]] = [
  271. ("案件编号", "case_number", "case_number"),
  272. ("仲裁机构", "arbitration_org", "arbitration_org"),
  273. ("案由类型", "case_cause", "case_cause"),
  274. ("申请人", "applicant_name", "applicant_name"),
  275. ("被申请人", "respondent_name", "respondent_name"),
  276. ("单位性质", "employer_nature", "employer_nature"),
  277. ("岗位", "worker_position", "worker_position"),
  278. ("入职日期", "entry_date", "entry_date"),
  279. ("离职日期", "leave_date", "leave_date"),
  280. ("月工资标准", "month_salary", "month_salary"),
  281. ]
  282. out: list[dict[str, Any]] = []
  283. for label, key, _ in rows:
  284. a, b = base_el.get(key), sim_el.get(key)
  285. out.append(
  286. {
  287. "dimension": label,
  288. "current": a,
  289. "similar": b,
  290. "aligned": a is not None and b is not None and str(a) == str(b),
  291. }
  292. )
  293. bc = (base_el.get("claims") or {}).get("amount_total")
  294. sc = (sim_el.get("claims") or {}).get("amount_total")
  295. amt_aligned = False
  296. if bc is not None and sc is not None:
  297. try:
  298. amt_aligned = float(bc) == float(sc)
  299. except (TypeError, ValueError):
  300. amt_aligned = str(bc) == str(sc)
  301. out.append(
  302. {
  303. "dimension": "请求金额合计",
  304. "current": bc,
  305. "similar": sc,
  306. "aligned": amt_aligned,
  307. }
  308. )
  309. return out
  310. def _flatten_hierarchy_values(elements: dict[str, Any] | None) -> dict[str, Any]:
  311. """
  312. 将 elements.elements_hierarchy 扁平化为 { \"一层 / 二层 / 三层\": value },仅保留有内容的叶子。
  313. """
  314. el = elements or {}
  315. root = el.get("elements_hierarchy") or {}
  316. schema = load_case_elements_schema()
  317. field_labels: dict[str, str] = schema.get("field_labels") or {}
  318. out: dict[tuple[str, ...], Any] = {}
  319. _COMMON_SEG_CN = {
  320. "name": "姓名",
  321. "gender": "性别",
  322. "birthday": "出生日期",
  323. "birthdate": "出生日期",
  324. "age": "年龄",
  325. "phone": "联系电话",
  326. "mobile": "联系电话",
  327. "tel": "联系电话",
  328. "residence": "住所地",
  329. "address": "地址",
  330. "identity_number": "身份证号",
  331. "id_number": "身份证号",
  332. "idcard": "身份证号",
  333. "nationality": "国籍/民族",
  334. "nation": "民族",
  335. "company": "单位名称",
  336. "company_name": "单位名称",
  337. "legal_representative": "法定代表人",
  338. "representative": "代表人",
  339. "position": "职务/岗位",
  340. }
  341. def cn_seg(seg: str) -> str:
  342. s = (seg or "").strip()
  343. if not s:
  344. return s
  345. # 若是已是中文则直接返回
  346. if re.search(r"[\u4e00-\u9fff]", s):
  347. return s
  348. if s in _COMMON_SEG_CN:
  349. return _COMMON_SEG_CN[s]
  350. # 扁平字段英文 key -> 中文 label(来自 schema)
  351. return field_labels.get(s, s)
  352. def walk(node: Any, path: list[str]) -> None:
  353. if isinstance(node, dict):
  354. for k, v in node.items():
  355. walk(v, path + [cn_seg(str(k))])
  356. return
  357. if node is None:
  358. return
  359. if isinstance(node, str) and not node.strip():
  360. return
  361. if not path:
  362. return
  363. out[tuple(path)] = node
  364. walk(root, [])
  365. return out
  366. def _hierarchy_comparison_with_current(base_el: dict[str, Any], sim_el: dict[str, Any]) -> list[dict[str, Any]]:
  367. a = _flatten_hierarchy_values(base_el)
  368. b = _flatten_hierarchy_values(sim_el)
  369. keys = sorted(set(a.keys()) | set(b.keys()))
  370. out: list[dict[str, Any]] = []
  371. for k in keys:
  372. va = a.get(k)
  373. vb = b.get(k)
  374. if va is None and vb is None:
  375. continue
  376. aligned = va is not None and vb is not None and str(va) == str(vb)
  377. out.append({"path_parts": list(k), "current": va, "similar": vb, "aligned": aligned})
  378. return out
  379. def _build_similar_case_detail(base_el: dict[str, Any], base_portrait: dict[str, Any] | None, row: Case, score: float) -> dict[str, Any]:
  380. sim_el = row.elements or {}
  381. full_text = row.application_text or ""
  382. api_text = full_text[:_MAX_API_APPLICATION_TEXT] if full_text else ""
  383. wsim = _weighted_similarity_from_portraits(base_portrait or {}, row.portrait or {})
  384. return {
  385. "case_id": row.id,
  386. "case_name": row.case_name,
  387. "score": round(float(score), 6),
  388. "similarity_percent": round(float(score) * 100, 2),
  389. "weighted_similarity_percent": (round(float(wsim["overall"]), 2) if wsim.get("overall") is not None else None),
  390. "weighted_similarity_breakdown": wsim.get("breakdown") or [],
  391. "application_text": api_text,
  392. "application_truncated": len(full_text) > len(api_text),
  393. "application_preview": (full_text[:320] + "…") if len(full_text) > 320 else full_text,
  394. "ruling_result": row.ruling_result
  395. or "暂无裁决结论(可在数据库为该案件录入 ruling_result,或后续对接裁决书解析模块)",
  396. "arbitration_org": row.arbitration_org or "未录入仲裁机构(可在数据库为该案件录入 arbitration_org)",
  397. "elements_brief": _elements_brief(sim_el),
  398. "portrait_scores": _portrait_scores(row.portrait or {}),
  399. "comparison_with_current": _comparison_with_current(base_el, sim_el),
  400. "hierarchy_comparison_with_current": _hierarchy_comparison_with_current(base_el, sim_el),
  401. }
  402. # -----------------------------
  403. # Pydantic Models(按接口定义)
  404. # -----------------------------
  405. class TaskResponse(BaseModel):
  406. task_id: str
  407. status: str
  408. progress: int
  409. message: str | None = None
  410. class ElementsResponse(BaseModel):
  411. case_id: int
  412. elements: dict[str, Any]
  413. version: int
  414. class ElementsUpdateRequest(BaseModel):
  415. updated_by: str | None = None
  416. patch: dict[str, Any] = Field(default_factory=dict, description="部分字段更新:只传要改的字段")
  417. class PortraitResponse(BaseModel):
  418. case_id: int
  419. portrait: dict[str, Any]
  420. complexity_level: dict[str, Any]
  421. risk_assessment: dict[str, Any]
  422. class SimilarRequest(BaseModel):
  423. top_k: int = 10
  424. class SimilarCaseDetailResponse(BaseModel):
  425. case_id: int
  426. case_name: str
  427. score: float
  428. similarity_percent: float
  429. application_text: str = ""
  430. application_truncated: bool = False
  431. application_preview: str = ""
  432. ruling_result: str = ""
  433. arbitration_org: str = ""
  434. elements_brief: dict[str, Any] = Field(default_factory=dict)
  435. portrait_scores: dict[str, Any] = Field(default_factory=dict)
  436. comparison_with_current: list[dict[str, Any]] = Field(default_factory=list)
  437. hierarchy_comparison_with_current: list[dict[str, Any]] = Field(default_factory=list)
  438. weighted_similarity_percent: float | None = None
  439. weighted_similarity_breakdown: list[dict[str, Any]] = Field(default_factory=list)
  440. class SimilarResponse(BaseModel):
  441. case_id: int
  442. items: list[SimilarCaseDetailResponse]
  443. class MetricsResponse(BaseModel):
  444. metrics: dict[str, Any]
  445. class UploadCompatResponse(BaseModel):
  446. """
  447. 兼容旧前端(React 版)用的同步返回格式:
  448. - extracted_elements: 规则/模型抽取结果
  449. - case_profile: 画像数据(含 complexity_level/risk_assessment 等)
  450. - similar_cases: 相似案件列表(Jaccard + 类案详情)
  451. """
  452. case_id: int
  453. case_name: str
  454. extracted_elements: dict[str, Any]
  455. case_profile: dict[str, Any]
  456. similar_cases: list[dict[str, Any]]
  457. current_application_text: str = ""
  458. current_application_truncated: bool = False
  459. current_elements_brief: dict[str, Any] = Field(default_factory=dict)
  460. reused_existing: bool = Field(False, description="True 表示命中已有案件,未新建 cases 行")
  461. def _build_upload_compat_response(db: Session, case: Case, *, reused_existing: bool) -> UploadCompatResponse:
  462. elements = case.elements or {}
  463. base_tags = _case_tag_set(elements)
  464. rows = db.query(Case).filter(Case.id != case.id).all()
  465. scored: list[tuple[float, Case]] = []
  466. for row in rows:
  467. s = _jaccard(base_tags, _case_tag_set(row.elements or {}))
  468. scored.append((s, row))
  469. scored.sort(key=lambda x: x[0], reverse=True)
  470. similar_cases = [_build_similar_case_detail(elements, case.portrait or {}, r, s) for s, r in scored[:10] if s > 0]
  471. full_text = case.application_text or ""
  472. cur_api_text = full_text[:_MAX_API_APPLICATION_TEXT]
  473. return UploadCompatResponse(
  474. case_id=case.id,
  475. case_name=case.case_name,
  476. extracted_elements=elements,
  477. case_profile=case.portrait or {},
  478. similar_cases=similar_cases,
  479. current_application_text=cur_api_text,
  480. current_application_truncated=len(full_text) > len(cur_api_text),
  481. current_elements_brief=_elements_brief(elements),
  482. reused_existing=reused_existing,
  483. )
  484. @app.get("/health")
  485. def health_check():
  486. return {"status": "ok"}
  487. @app.post("/api/cases/extract")
  488. async def extract_case(file: UploadFile = File(...)):
  489. """
  490. 上传单文件(.txt / .pdf / .docx),解析全文后使用混合抽取器返回 JSON 要素。
  491. """
  492. suffix = Path(file.filename or "").suffix.lower()
  493. if suffix not in (".txt", ".pdf", ".docx"):
  494. raise HTTPException(status_code=400, detail="仅支持 .txt、.pdf、.docx 格式")
  495. content = await file.read()
  496. if not content:
  497. raise HTTPException(status_code=400, detail="空文件")
  498. tmp_path: str | None = None
  499. try:
  500. tmp = NamedTemporaryFile(delete=False, suffix=suffix)
  501. tmp_path = tmp.name
  502. tmp.write(content)
  503. tmp.close()
  504. text = DocumentParser.parse_file(tmp_path)
  505. except Exception as e:
  506. raise HTTPException(status_code=400, detail=f"文件解析失败:{e}") from e
  507. finally:
  508. if tmp_path:
  509. try:
  510. Path(tmp_path).unlink(missing_ok=True)
  511. except OSError:
  512. pass
  513. if not (text or "").strip():
  514. raise HTTPException(status_code=400, detail="未解析到有效文本")
  515. return hybrid_extractor.extract(text)
  516. @app.post("/api/cases/upload", response_model=UploadCompatResponse)
  517. async def upload_case_compat(
  518. case_name: str = Form(...),
  519. files: list[UploadFile] = File(...),
  520. extractor_mode: str = Form("rules"),
  521. db: Session = Depends(get_db),
  522. ):
  523. """
  524. 兼容接口:旧前端会调用 /api/cases/upload 并期待立即返回抽取结果。
  525. 新版推荐走:/api/cases/{case_id}/files + /api/tasks/{task_id} 轮询。
  526. extractor_mode: rules / bert / ollama / hybrid
  527. """
  528. if not files:
  529. raise HTTPException(status_code=400, detail="未上传文件")
  530. upload_dir = _upload_dir()
  531. norm_name = _normalize_case_name(case_name)
  532. if not norm_name:
  533. raise HTTPException(status_code=400, detail="案件名称不能为空")
  534. prepared: list[tuple[UploadFile, bytes, str]] = []
  535. for f in files:
  536. content = await f.read()
  537. guessed = magic.from_buffer(content[:2048], mime=True) if content else ""
  538. if not _allowed(guessed, f.filename):
  539. raise HTTPException(status_code=400, detail=f"不支持的文件类型:{f.filename}({guessed})")
  540. prepared.append((f, content, guessed))
  541. merged_full = _merged_text_from_prepared(prepared)
  542. if not merged_full.strip():
  543. raise HTTPException(status_code=400, detail="未读取到有效文本")
  544. merged_stored = merged_full[:_MAX_STORED_APPLICATION_TEXT]
  545. fingerprint = _material_fingerprint_from_file_contents([b for _, b, _ in prepared])
  546. text_h = _normalized_text_fingerprint(merged_stored)
  547. case = _find_existing_duplicate_case(db, norm_name, fingerprint, merged_stored)
  548. # 补充:同名 + 正文一致即视为重复
  549. if not case and norm_name:
  550. cands = db.query(Case).filter(func.trim(Case.case_name) == norm_name).filter(Case.application_text.isnot(None)).filter(Case.application_text != "").all()
  551. for c in cands:
  552. if _normalized_text_fingerprint(c.application_text or "") == text_h:
  553. case = c
  554. break
  555. if case:
  556. # 案件已存在:直接返回已有数据,跳过模型抽取
  557. case.case_name = norm_name
  558. # 清理旧文件并保存新文件
  559. old_files = db.query(CaseFile).filter(CaseFile.case_id == case.id).all()
  560. for cf in old_files:
  561. try:
  562. Path(cf.storage_path).unlink(missing_ok=True)
  563. except OSError:
  564. pass
  565. db.query(CaseFile).filter(CaseFile.case_id == case.id).delete(synchronize_session=False)
  566. db.commit()
  567. for f, content, guessed in prepared:
  568. suffix = Path(f.filename).suffix.lower()
  569. storage = upload_dir / f"{case.id}_{uuid4().hex}{suffix}"
  570. storage.write_bytes(content)
  571. db.add(CaseFile(case_id=case.id, filename=f.filename, content_type=guessed, storage_path=str(storage), size_bytes=len(content)))
  572. db.commit()
  573. case.material_fingerprint = fingerprint
  574. case.application_text = merged_stored
  575. db.commit()
  576. return _build_upload_compat_response(db, case, reused_existing=False)
  577. # 新案件:走完整抽取流程
  578. case = Case(case_name=norm_name, elements={}, portrait={}, material_fingerprint=fingerprint)
  579. db.add(case)
  580. db.commit()
  581. db.refresh(case)
  582. for f, content, guessed in prepared:
  583. suffix = Path(f.filename).suffix.lower()
  584. storage = upload_dir / f"{case.id}_{uuid4().hex}{suffix}"
  585. storage.write_bytes(content)
  586. db.add(CaseFile(case_id=case.id, filename=f.filename, content_type=guessed, storage_path=str(storage), size_bytes=len(content)))
  587. db.commit()
  588. merged = merged_full
  589. hybrid_extractor.mode = extractor_mode
  590. if extractor_mode == "rules":
  591. elements = extractor.extract(merged)
  592. else:
  593. elements = hybrid_extractor.extract(merged)
  594. if _ollama_claims is not None and extractor_mode in ("ollama", "hybrid"):
  595. try:
  596. elements["claims"] = _ollama_claims.extract_claims(merged)
  597. elements.update(merge_dispute_template_fields(merged, elements))
  598. except Exception:
  599. pass
  600. try:
  601. patch = _ollama_claims.extract_dispute_template_fields(merged)
  602. for k, v in (patch or {}).items():
  603. if v is None:
  604. continue
  605. if isinstance(v, str) and not str(v).strip():
  606. continue
  607. elements[k] = v
  608. if elements.get("tmpl_primary_cause"):
  609. elements["primary_cause_type"] = elements["tmpl_primary_cause"]
  610. except Exception:
  611. pass
  612. refresh_derived_element_fields(elements, merged)
  613. portrait = generate_portrait(elements, raw_text=merged, evidence_count=len(files))
  614. complexity = classify_complexity(elements, evidence_count=len(files))
  615. risk = assess_risk(elements)
  616. latest_version = (
  617. db.query(CaseElementsVersion)
  618. .filter(CaseElementsVersion.case_id == case.id)
  619. .order_by(CaseElementsVersion.version.desc())
  620. .first()
  621. )
  622. next_ver = (latest_version.version + 1) if latest_version else 1
  623. db.add(CaseElementsVersion(case_id=case.id, version=next_ver, elements=elements, updated_by="system"))
  624. case.elements = elements
  625. case.portrait = {**portrait, "complexity_level": complexity, "risk_assessment": risk}
  626. case.application_text = merged_stored
  627. case.material_fingerprint = fingerprint
  628. _update_case_index_fields(case, elements, portrait, risk.get("level"))
  629. db.commit()
  630. return _build_upload_compat_response(db, case, reused_existing=False)
  631. def _create_or_get_case(db: Session, case_id: int) -> Case:
  632. case = db.query(Case).filter(Case.id == case_id).first()
  633. if case:
  634. return case
  635. # 若前端先创建 case_id 再上传文件,这里兜底自动创建
  636. case = Case(id=case_id, case_name=f"案件{case_id}", elements={}, portrait={})
  637. db.add(case)
  638. db.commit()
  639. db.refresh(case)
  640. return case
  641. def _update_case_index_fields(case: Case, elements: dict[str, Any], portrait: dict[str, Any], risk_level: str | None) -> None:
  642. case.applicant_name = elements.get("applicant_name")
  643. case.respondent_name = elements.get("respondent_name")
  644. case.case_cause = elements.get("case_cause")
  645. case.risk_level = risk_level or portrait.get("risk_level")
  646. def _process_case_task(task_id: str, case_id: int) -> None:
  647. """
  648. 后台任务:解析文件 -> 合并文本 -> 规则抽取 -> 写入 cases.elements + 版本历史 -> 画像/复杂度/风险
  649. """
  650. from app.db import SessionLocal
  651. db = SessionLocal()
  652. try:
  653. task = db.query(ProcessingTask).filter(ProcessingTask.id == task_id).first()
  654. if not task:
  655. return
  656. task.status = "RUNNING"
  657. task.progress = 5
  658. task.message = "解析文件中"
  659. db.commit()
  660. case = db.query(Case).filter(Case.id == case_id).first()
  661. if not case:
  662. task.status = "FAILED"
  663. task.error = "案件不存在"
  664. task.progress = 100
  665. db.commit()
  666. return
  667. files = db.query(CaseFile).filter(CaseFile.case_id == case_id).all()
  668. texts: list[str] = []
  669. for f in files:
  670. try:
  671. texts.append(DocumentParser.parse_file(f.storage_path))
  672. except Exception:
  673. continue
  674. merged = "\n\n".join([t for t in texts if t.strip()])
  675. task.progress = 45
  676. task.message = "要素抽取中"
  677. db.commit()
  678. elements = extractor.extract(merged)
  679. if _ollama_claims is not None:
  680. try:
  681. elements["claims"] = _ollama_claims.extract_claims(merged)
  682. elements.update(merge_dispute_template_fields(merged, elements))
  683. except Exception:
  684. pass
  685. try:
  686. patch = _ollama_claims.extract_dispute_template_fields(merged)
  687. for k, v in (patch or {}).items():
  688. if v is None:
  689. continue
  690. if isinstance(v, str) and not str(v).strip():
  691. continue
  692. elements[k] = v
  693. if elements.get("tmpl_primary_cause"):
  694. elements["primary_cause_type"] = elements["tmpl_primary_cause"]
  695. except Exception:
  696. pass
  697. refresh_derived_element_fields(elements, merged)
  698. task.progress = 70
  699. task.message = "构建画像中"
  700. db.commit()
  701. portrait = generate_portrait(elements, raw_text=merged, evidence_count=len(files))
  702. complexity = classify_complexity(elements, evidence_count=len(files))
  703. risk = assess_risk(elements)
  704. # 保存 elements 版本
  705. latest_version = (
  706. db.query(CaseElementsVersion)
  707. .filter(CaseElementsVersion.case_id == case_id)
  708. .order_by(CaseElementsVersion.version.desc())
  709. .first()
  710. )
  711. next_ver = (latest_version.version + 1) if latest_version else 1
  712. db.add(CaseElementsVersion(case_id=case_id, version=next_ver, elements=elements, updated_by="system"))
  713. merged_stored = merged[:_MAX_STORED_APPLICATION_TEXT]
  714. case.elements = elements
  715. case.application_text = merged_stored
  716. case.portrait = {
  717. **portrait,
  718. "complexity_level": complexity,
  719. "risk_assessment": risk,
  720. }
  721. _update_case_index_fields(case, elements, portrait, risk.get("level"))
  722. contents: list[bytes] = []
  723. for cf in files:
  724. try:
  725. contents.append(Path(cf.storage_path).read_bytes())
  726. except OSError:
  727. continue
  728. if contents:
  729. case.material_fingerprint = _material_fingerprint_from_file_contents(contents)
  730. db.commit()
  731. task.status = "SUCCEEDED"
  732. task.progress = 100
  733. task.message = "处理完成"
  734. db.commit()
  735. except Exception as e:
  736. try:
  737. task = db.query(ProcessingTask).filter(ProcessingTask.id == task_id).first()
  738. if task:
  739. task.status = "FAILED"
  740. task.progress = 100
  741. task.error = f"{e}\n{traceback.format_exc()}"
  742. db.commit()
  743. finally:
  744. pass
  745. finally:
  746. db.close()
  747. @app.post("/api/cases/{case_id}/files", response_model=TaskResponse)
  748. async def upload_case_files(
  749. case_id: int,
  750. background: BackgroundTasks,
  751. files: list[UploadFile] = File(...),
  752. db: Session = Depends(get_db),
  753. ):
  754. case = _create_or_get_case(db, case_id)
  755. if not files:
  756. raise HTTPException(status_code=400, detail="未上传文件")
  757. upload_dir = _upload_dir()
  758. saved = 0
  759. for f in files:
  760. content = await f.read()
  761. guessed = magic.from_buffer(content[:2048], mime=True) if content else ""
  762. if not _allowed(guessed, f.filename):
  763. raise HTTPException(status_code=400, detail=f"不支持的文件类型:{f.filename}({guessed})")
  764. suffix = Path(f.filename).suffix.lower()
  765. storage = upload_dir / f"{case_id}_{uuid4().hex}{suffix}"
  766. storage.write_bytes(content)
  767. db.add(
  768. CaseFile(
  769. case_id=case.id,
  770. filename=f.filename,
  771. content_type=guessed,
  772. storage_path=str(storage),
  773. size_bytes=len(content),
  774. )
  775. )
  776. saved += 1
  777. db.commit()
  778. task_id = uuid4().hex
  779. task = ProcessingTask(id=task_id, case_id=case.id, status="PENDING", progress=0, message=f"已接收{saved}个文件")
  780. db.add(task)
  781. db.commit()
  782. background.add_task(_process_case_task, task_id, case.id)
  783. return TaskResponse(task_id=task_id, status=task.status, progress=task.progress, message=task.message)
  784. @app.get("/api/tasks/{task_id}", response_model=TaskResponse)
  785. def get_task(task_id: str, db: Session = Depends(get_db)):
  786. task = db.query(ProcessingTask).filter(ProcessingTask.id == task_id).first()
  787. if not task:
  788. raise HTTPException(status_code=404, detail="任务不存在")
  789. return TaskResponse(task_id=task.id, status=task.status, progress=task.progress, message=task.message or task.error)
  790. @app.delete("/api/cases/{case_id}", status_code=204)
  791. def delete_case(case_id: int, db: Session = Depends(get_db)):
  792. """
  793. 删除案件及其关联数据。此前未暴露该接口,且 processing_tasks 外键指向 cases,
  794. 直接在库里删 cases 行可能被外键拒绝。
  795. """
  796. case = db.query(Case).filter(Case.id == case_id).first()
  797. if not case:
  798. raise HTTPException(status_code=404, detail="案件不存在")
  799. files = db.query(CaseFile).filter(CaseFile.case_id == case_id).all()
  800. for cf in files:
  801. try:
  802. Path(cf.storage_path).unlink(missing_ok=True)
  803. except OSError:
  804. pass
  805. # 必须先删子表行:MySQL 外键默认 RESTRICT,否则会报 1451(case_elements_versions 等)
  806. db.query(CaseElementsVersion).filter(CaseElementsVersion.case_id == case_id).delete(synchronize_session=False)
  807. db.query(CaseFile).filter(CaseFile.case_id == case_id).delete(synchronize_session=False)
  808. db.query(ProcessingTask).filter(ProcessingTask.case_id == case_id).delete(synchronize_session=False)
  809. db.query(Case).filter(Case.id == case_id).delete(synchronize_session=False)
  810. db.commit()
  811. return Response(status_code=204)
  812. @app.get("/api/cases/{case_id}/elements", response_model=ElementsResponse)
  813. def get_elements(case_id: int, db: Session = Depends(get_db)):
  814. case = db.query(Case).filter(Case.id == case_id).first()
  815. if not case:
  816. raise HTTPException(status_code=404, detail="案件不存在")
  817. latest_version = (
  818. db.query(CaseElementsVersion)
  819. .filter(CaseElementsVersion.case_id == case_id)
  820. .order_by(CaseElementsVersion.version.desc())
  821. .first()
  822. )
  823. ver = latest_version.version if latest_version else 0
  824. return ElementsResponse(case_id=case.id, elements=case.elements or {}, version=ver)
  825. @app.put("/api/cases/{case_id}/elements", response_model=ElementsResponse)
  826. def update_elements(case_id: int, payload: ElementsUpdateRequest, db: Session = Depends(get_db)):
  827. case = db.query(Case).filter(Case.id == case_id).first()
  828. if not case:
  829. raise HTTPException(status_code=404, detail="案件不存在")
  830. patch = payload.patch or {}
  831. current = dict(case.elements or {})
  832. current.update(patch)
  833. # PATCH 含 elements_hierarchy 时保留用户编辑的层级,不根据扁平字段重算覆盖
  834. _sync_case_elements_table(current, rebuild_hierarchy="elements_hierarchy" not in patch)
  835. latest_version = (
  836. db.query(CaseElementsVersion)
  837. .filter(CaseElementsVersion.case_id == case_id)
  838. .order_by(CaseElementsVersion.version.desc())
  839. .first()
  840. )
  841. next_ver = (latest_version.version + 1) if latest_version else 1
  842. db.add(CaseElementsVersion(case_id=case_id, version=next_ver, elements=current, updated_by=payload.updated_by))
  843. # 更新画像缓存(编辑后即时刷新)
  844. files_cnt = db.query(CaseFile).filter(CaseFile.case_id == case_id).count()
  845. portrait = generate_portrait(current, raw_text="", evidence_count=files_cnt)
  846. complexity = classify_complexity(current, evidence_count=files_cnt)
  847. risk = assess_risk(current)
  848. case.elements = current
  849. case.portrait = {**portrait, "complexity_level": complexity, "risk_assessment": risk}
  850. _update_case_index_fields(case, current, portrait, risk.get("level"))
  851. db.commit()
  852. return ElementsResponse(case_id=case.id, elements=case.elements or {}, version=next_ver)
  853. @app.get("/api/cases/{case_id}/portrait", response_model=PortraitResponse)
  854. def get_portrait(case_id: int, db: Session = Depends(get_db)):
  855. case = db.query(Case).filter(Case.id == case_id).first()
  856. if not case:
  857. raise HTTPException(status_code=404, detail="案件不存在")
  858. portrait = case.portrait or {}
  859. complexity = portrait.get("complexity_level") or {}
  860. risk = portrait.get("risk_assessment") or {}
  861. return PortraitResponse(case_id=case.id, portrait=portrait, complexity_level=complexity, risk_assessment=risk)
  862. @app.post("/api/cases/{case_id}/similar", response_model=SimilarResponse)
  863. def similar_cases(case_id: int, payload: SimilarRequest, db: Session = Depends(get_db)):
  864. base = db.query(Case).filter(Case.id == case_id).first()
  865. if not base:
  866. raise HTTPException(status_code=404, detail="案件不存在")
  867. base_el = base.elements or {}
  868. base_portrait = base.portrait or {}
  869. base_tags = _case_tag_set(base_el)
  870. rows = db.query(Case).filter(Case.id != case_id).all()
  871. scored: list[tuple[float, Case]] = []
  872. for row in rows:
  873. s = _jaccard(base_tags, _case_tag_set(row.elements or {}))
  874. scored.append((s, row))
  875. scored.sort(key=lambda x: x[0], reverse=True)
  876. top_k = max(1, min(50, payload.top_k))
  877. items = [
  878. SimilarCaseDetailResponse(**_build_similar_case_detail(base_el, base_portrait, r, s))
  879. for s, r in scored[:top_k]
  880. if s > 0
  881. ]
  882. return SimilarResponse(case_id=case_id, items=items)
  883. @app.get("/api/evaluation/metrics", response_model=MetricsResponse)
  884. def get_eval_metrics(db: Session = Depends(get_db)):
  885. """
  886. 评估页指标:
  887. 1) 要素抽取:Precision/Recall/F1(demo 样例;可替换为真实标注集)
  888. 2) 画像完整性/有效性:基于数据库历史案件的 portrait 字段统计
  889. """
  890. # 兼容以 `uvicorn app.main:app` 启动时的模块路径:此时并不存在顶层包名 backend
  891. from tools.evaluate_extractor import demo_samples, run_eval # type: ignore
  892. extraction_report = run_eval(demo_samples())
  893. rows = db.query(Case).all()
  894. total = len(rows)
  895. with_portrait = 0
  896. with_scores = 0
  897. with_subscores = 0
  898. with_keywords = 0
  899. with_tags = 0
  900. with_risk_assessment = 0
  901. with_complexity = 0
  902. for c in rows:
  903. p = c.portrait or {}
  904. if p:
  905. with_portrait += 1
  906. scores = (p.get("scores") or {}) if isinstance(p, dict) else {}
  907. if isinstance(scores, dict) and any(scores.get(k) is not None for k in ("legal", "fact", "risk")):
  908. with_scores += 1
  909. # subscores:三个维度任一包含 subscores 即认为完整
  910. ok_sub = False
  911. for dim in ("legal_dimension", "fact_dimension", "risk_dimension"):
  912. d = (p.get(dim) or {}) if isinstance(p, dict) else {}
  913. subs = d.get("subscores") if isinstance(d, dict) else None
  914. if isinstance(subs, dict) and len(subs) > 0:
  915. ok_sub = True
  916. if ok_sub:
  917. with_subscores += 1
  918. kws = p.get("keywords") if isinstance(p, dict) else None
  919. if isinstance(kws, list) and len(kws) > 0:
  920. with_keywords += 1
  921. tags = p.get("tags") if isinstance(p, dict) else None
  922. if isinstance(tags, list) and len(tags) > 0:
  923. with_tags += 1
  924. if isinstance(p.get("risk_assessment"), dict):
  925. with_risk_assessment += 1
  926. if isinstance(p.get("complexity_level"), dict):
  927. with_complexity += 1
  928. def ratio(x: int) -> float:
  929. return round((x / total) if total else 0.0, 4)
  930. portrait_report = {
  931. "total_cases": total,
  932. "has_portrait": {"count": with_portrait, "ratio": ratio(with_portrait)},
  933. "has_scores": {"count": with_scores, "ratio": ratio(with_scores)},
  934. "has_subscores": {"count": with_subscores, "ratio": ratio(with_subscores)},
  935. "has_keywords": {"count": with_keywords, "ratio": ratio(with_keywords)},
  936. "has_tags": {"count": with_tags, "ratio": ratio(with_tags)},
  937. "has_risk_assessment": {"count": with_risk_assessment, "ratio": ratio(with_risk_assessment)},
  938. "has_complexity_level": {"count": with_complexity, "ratio": ratio(with_complexity)},
  939. }
  940. metrics = {
  941. "extraction_prf": extraction_report,
  942. "portrait_quality": portrait_report,
  943. "weights": {"legal": _SIM_WEIGHT_LEGAL, "fact": _SIM_WEIGHT_FACT, "risk": _SIM_WEIGHT_RISK},
  944. }
  945. return MetricsResponse(metrics=metrics)