documents.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516
  1. from __future__ import annotations
  2. from datetime import datetime
  3. from pathlib import Path
  4. from urllib.parse import quote
  5. from fastapi import (
  6. APIRouter,
  7. BackgroundTasks,
  8. Depends,
  9. File,
  10. Form,
  11. Query,
  12. UploadFile,
  13. status,
  14. )
  15. from fastapi.responses import FileResponse, StreamingResponse
  16. from sqlalchemy.orm import Session
  17. from app.core.config import PROJECT_ROOT, settings
  18. from app.core.errors import APIError
  19. from app.db.repositories import document as document_repo
  20. from app.db.repositories import mine as mine_repo
  21. from app.db.session import get_db
  22. from app.schemas.common import MessageResponse, Page
  23. from app.schemas.document import (
  24. BusinessDocumentCreate,
  25. BusinessDocumentDetail,
  26. BusinessDocumentRead,
  27. BusinessDocumentStatistics,
  28. DocumentPreviewRead,
  29. BusinessDocumentUpdate,
  30. DocumentCategorySummary,
  31. DocumentExtractionCreate,
  32. DocumentExtractionRead,
  33. DocumentExtractionUpdate,
  34. DocumentExtractionTemplateRead,
  35. DocumentProcessingRunRead,
  36. DocumentProcessingStart,
  37. DocumentProcessingTaskRead,
  38. DocumentSearchResponse,
  39. )
  40. from app.services.document_service import (
  41. add_extraction,
  42. create_document,
  43. delete_document,
  44. get_document_detail,
  45. process_extraction_edit_judgement,
  46. update_document,
  47. update_extraction,
  48. )
  49. from app.services.document_processing import (
  50. process_document_run,
  51. start_document_processing,
  52. )
  53. from app.services.document_business_templates import resolve_document_template
  54. from app.services.document_export import build_document_list_workbook
  55. from app.services.storage_service import document_storage_statistics
  56. router = APIRouter(prefix="/documents", tags=["业务资料"])
  57. MAX_UPLOAD_BYTES = 50 * 1024 * 1024
  58. MIME_TYPES = {
  59. ".pdf": "application/pdf",
  60. ".doc": "application/msword",
  61. ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
  62. ".xls": "application/vnd.ms-excel",
  63. ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
  64. ".csv": "text/csv",
  65. }
  66. def _resolve_document_file_path(document) -> Path:
  67. source = Path(document.file_path)
  68. if not source.is_absolute():
  69. source = PROJECT_ROOT / source
  70. resolved = source.resolve()
  71. upload_root = settings.upload_path.resolve()
  72. if not resolved.is_relative_to(upload_root):
  73. raise APIError(403, "document_file_forbidden", "资料文件路径不允许访问")
  74. return resolved
  75. @router.get("", response_model=Page[BusinessDocumentRead], summary="资料列表")
  76. def list_documents(
  77. mine_id: str = Query(...),
  78. category: str | None = Query(None),
  79. keyword: str | None = Query(None),
  80. parse_status: str | None = Query(None),
  81. page: int = Query(1, ge=1),
  82. page_size: int = Query(20, ge=1, le=200),
  83. db: Session = Depends(get_db),
  84. ) -> Page[BusinessDocumentRead]:
  85. items, total = document_repo.list_documents(
  86. db,
  87. mine_id=mine_id,
  88. category=category,
  89. keyword=keyword,
  90. parse_status=parse_status,
  91. page=page,
  92. page_size=page_size,
  93. )
  94. return Page(items=items, total=total, page=page, page_size=page_size)
  95. @router.get(
  96. "/categories",
  97. response_model=list[DocumentCategorySummary],
  98. summary="资料分类统计",
  99. )
  100. def category_summary(
  101. mine_id: str = Query(...),
  102. db: Session = Depends(get_db),
  103. ) -> list[DocumentCategorySummary]:
  104. return [
  105. DocumentCategorySummary(category=category, count=count)
  106. for category, count in document_repo.category_summary(db, mine_id)
  107. ]
  108. @router.get(
  109. "/statistics",
  110. response_model=BusinessDocumentStatistics,
  111. summary="业务资料抽取统计",
  112. )
  113. def statistics(
  114. mine_id: str = Query(...),
  115. db: Session = Depends(get_db),
  116. ) -> BusinessDocumentStatistics:
  117. statistics_data = document_repo.document_statistics(db, mine_id)
  118. statistics_data.update(document_storage_statistics())
  119. return BusinessDocumentStatistics(**statistics_data)
  120. @router.get(
  121. "/search",
  122. response_model=DocumentSearchResponse,
  123. summary="全资料库全文检索",
  124. )
  125. def search_documents(
  126. q: str = Query(..., min_length=1, max_length=200),
  127. mine_id: str = Query(...),
  128. category: str | None = Query(None),
  129. page: int = Query(1, ge=1),
  130. page_size: int = Query(10, ge=1, le=50),
  131. db: Session = Depends(get_db),
  132. ) -> DocumentSearchResponse:
  133. hits, total = document_repo.search_documents(
  134. db,
  135. mine_id=mine_id,
  136. query=q,
  137. category=category,
  138. page=page,
  139. page_size=page_size,
  140. )
  141. return DocumentSearchResponse(
  142. query=q,
  143. total=total,
  144. page=page,
  145. page_size=page_size,
  146. items=hits,
  147. )
  148. @router.get(
  149. "/processing-tasks",
  150. response_model=list[DocumentProcessingTaskRead],
  151. summary="最近业务资料处理任务",
  152. )
  153. def processing_tasks(
  154. mine_id: str = Query(...),
  155. limit: int = Query(8, ge=1, le=100),
  156. db: Session = Depends(get_db),
  157. ) -> list[DocumentProcessingTaskRead]:
  158. return [
  159. DocumentProcessingTaskRead(**item)
  160. for item in document_repo.list_processing_tasks(
  161. db,
  162. mine_id=mine_id,
  163. limit=limit,
  164. )
  165. ]
  166. @router.get(
  167. "/templates",
  168. response_model=list[DocumentExtractionTemplateRead],
  169. summary="资料抽取模板",
  170. )
  171. def extraction_templates(
  172. document_type: str | None = Query(None),
  173. db: Session = Depends(get_db),
  174. ) -> list[DocumentExtractionTemplateRead]:
  175. return document_repo.list_templates(db, document_type=document_type)
  176. @router.get(
  177. "/export",
  178. summary="导出业务资料清单",
  179. )
  180. def export_documents(
  181. mine_id: str = Query(...),
  182. category: str | None = Query(None),
  183. keyword: str | None = Query(None),
  184. parse_status: str | None = Query(None),
  185. db: Session = Depends(get_db),
  186. ) -> StreamingResponse:
  187. documents = document_repo.list_documents_for_export(
  188. db,
  189. mine_id=mine_id,
  190. category=category,
  191. keyword=keyword,
  192. parse_status=parse_status,
  193. )
  194. counts = document_repo.extraction_counts(
  195. db,
  196. [document.id for document in documents],
  197. )
  198. mine = mine_repo.get_mine(db, mine_id)
  199. mine_name = mine.name if mine else "矿井"
  200. category_label = category if category and category != "all" else "全部资料"
  201. document_type_labels: dict[str, str] = {}
  202. for template in document_repo.list_templates(db):
  203. document_type_labels[template.code] = template.name
  204. if template.document_type:
  205. document_type_labels.setdefault(template.document_type, template.name)
  206. workbook = build_document_list_workbook(
  207. documents,
  208. counts,
  209. mine_name=mine_name,
  210. category_label=category_label,
  211. keyword=keyword or "",
  212. document_type_labels=document_type_labels,
  213. )
  214. timestamp = datetime.now().strftime("%Y%m%d_%H%M")
  215. filename = f"{mine_name}业务资料清单_{timestamp}.xlsx"
  216. headers = {
  217. "Content-Disposition": (
  218. f"attachment; filename=document_list_{timestamp}.xlsx; "
  219. f"filename*=UTF-8''{quote(filename)}"
  220. )
  221. }
  222. return StreamingResponse(
  223. workbook,
  224. media_type=(
  225. "application/vnd.openxmlformats-officedocument."
  226. "spreadsheetml.sheet"
  227. ),
  228. headers=headers,
  229. )
  230. @router.post(
  231. "",
  232. response_model=BusinessDocumentRead,
  233. status_code=status.HTTP_201_CREATED,
  234. summary="创建资料元数据",
  235. )
  236. def create_document_record(
  237. payload: BusinessDocumentCreate,
  238. db: Session = Depends(get_db),
  239. ):
  240. return create_document(db, payload)
  241. @router.post(
  242. "/upload",
  243. response_model=BusinessDocumentRead,
  244. status_code=status.HTTP_201_CREATED,
  245. summary="上传业务资料",
  246. )
  247. async def upload_document(
  248. background_tasks: BackgroundTasks,
  249. mine_id: str = Form(...),
  250. title: str | None = Form(None),
  251. category: str = Form(...),
  252. owner_name: str = Form(""),
  253. department: str = Form(""),
  254. code: str | None = Form(None),
  255. parse_mode: str = Form("lightweight"),
  256. template_id: str | None = Form(None),
  257. file: UploadFile = File(...),
  258. db: Session = Depends(get_db),
  259. ):
  260. settings.upload_path.mkdir(parents=True, exist_ok=True)
  261. safe_name = Path(file.filename or "document.bin").name
  262. resolved_title = (title or Path(safe_name).stem).strip() or "未命名资料"
  263. template = resolve_document_template(
  264. db,
  265. title=resolved_title,
  266. template_id=template_id,
  267. )
  268. target = settings.upload_path / f"{mine_id}_{safe_name}"
  269. content = await file.read(MAX_UPLOAD_BYTES + 1)
  270. await file.close()
  271. if len(content) > MAX_UPLOAD_BYTES:
  272. raise APIError(413, "file_too_large", "单个资料文件不能超过 50 MiB")
  273. target.write_bytes(content)
  274. payload = BusinessDocumentCreate(
  275. mine_id=mine_id,
  276. code=code,
  277. title=resolved_title,
  278. category=category,
  279. file_name=safe_name,
  280. file_path=str(target),
  281. file_ext=Path(safe_name).suffix.lower(),
  282. file_size=len(content),
  283. owner_name=owner_name,
  284. department=department,
  285. )
  286. document = create_document(db, payload)
  287. run = start_document_processing(
  288. db,
  289. document.id,
  290. parse_mode=parse_mode,
  291. template_id=template.id,
  292. )
  293. background_tasks.add_task(process_document_run, run.id)
  294. return document
  295. @router.get(
  296. "/{document_id}",
  297. response_model=BusinessDocumentDetail,
  298. summary="资料详情",
  299. )
  300. def document_detail(document_id: str, db: Session = Depends(get_db)):
  301. document, extractions = get_document_detail(db, document_id)
  302. document_data = BusinessDocumentRead.model_validate(document).model_dump()
  303. document_data["metadata_json"] = document_data.pop("metadata")
  304. return BusinessDocumentDetail(
  305. **document_data,
  306. extractions=extractions,
  307. )
  308. @router.get(
  309. "/{document_id}/preview",
  310. response_model=DocumentPreviewRead,
  311. summary="资料预览",
  312. )
  313. def document_preview(
  314. document_id: str,
  315. db: Session = Depends(get_db),
  316. ) -> DocumentPreviewRead:
  317. document = document_repo.get_document(db, document_id)
  318. if document is None:
  319. raise APIError(404, "document_not_found", "业务资料不存在")
  320. source = _resolve_document_file_path(document)
  321. source_available = source.is_file()
  322. latest_run = document_repo.get_latest_processing_run(db, document_id)
  323. parsed_text = str(latest_run.parsed_markdown or "") if latest_run else ""
  324. extension = (document.file_ext or source.suffix).lower()
  325. if not parsed_text and source_available and extension == ".csv":
  326. try:
  327. parsed_text = source.read_text(encoding="utf-8-sig", errors="replace")
  328. except OSError:
  329. parsed_text = ""
  330. text_available = bool(parsed_text.strip())
  331. if source_available and extension == ".pdf":
  332. preview_type = "pdf"
  333. elif text_available:
  334. preview_type = "text"
  335. elif source_available:
  336. preview_type = "download_only"
  337. else:
  338. preview_type = "unavailable"
  339. base_url = f"{settings.api_v1_prefix}/documents/{document_id}"
  340. return DocumentPreviewRead(
  341. id=document.id,
  342. title=document.title,
  343. file_name=document.file_name,
  344. file_ext=extension,
  345. file_size=int(document.file_size or 0),
  346. preview_type=preview_type,
  347. source_available=source_available,
  348. text_available=text_available,
  349. can_download=source_available,
  350. file_url=f"{base_url}/file",
  351. download_url=f"{base_url}/file?download=1",
  352. text=parsed_text[:200000],
  353. )
  354. @router.get(
  355. "/{document_id}/file",
  356. summary="原文件预览或下载",
  357. )
  358. def document_file(
  359. document_id: str,
  360. download: bool = Query(False),
  361. db: Session = Depends(get_db),
  362. ) -> FileResponse:
  363. document = document_repo.get_document(db, document_id)
  364. if document is None:
  365. raise APIError(404, "document_not_found", "业务资料不存在")
  366. source = _resolve_document_file_path(document)
  367. if not source.is_file():
  368. raise APIError(404, "document_file_not_found", "原始文件不存在")
  369. filename = document.file_name or source.name
  370. suffix = source.suffix.lower()
  371. disposition = "attachment" if download else "inline"
  372. headers = {
  373. "Content-Disposition": (
  374. f"{disposition}; filename=document{suffix}; "
  375. f"filename*=UTF-8''{quote(filename)}"
  376. )
  377. }
  378. return FileResponse(
  379. source,
  380. media_type=MIME_TYPES.get(suffix, "application/octet-stream"),
  381. headers=headers,
  382. )
  383. @router.patch(
  384. "/{document_id}",
  385. response_model=BusinessDocumentRead,
  386. summary="更新资料",
  387. )
  388. def patch_document(
  389. document_id: str,
  390. payload: BusinessDocumentUpdate,
  391. db: Session = Depends(get_db),
  392. ):
  393. return update_document(db, document_id, payload)
  394. @router.delete(
  395. "/{document_id}",
  396. response_model=MessageResponse,
  397. summary="删除资料",
  398. )
  399. def remove_document(document_id: str, db: Session = Depends(get_db)) -> MessageResponse:
  400. delete_document(db, document_id)
  401. return MessageResponse(message="资料已删除")
  402. @router.post(
  403. "/{document_id}/process",
  404. response_model=DocumentProcessingRunRead,
  405. status_code=status.HTTP_202_ACCEPTED,
  406. summary="重新解析并抽取资料",
  407. )
  408. def process_document(
  409. document_id: str,
  410. payload: DocumentProcessingStart,
  411. background_tasks: BackgroundTasks,
  412. db: Session = Depends(get_db),
  413. ):
  414. run = start_document_processing(
  415. db,
  416. document_id,
  417. force=payload.force,
  418. parse_mode=payload.parse_mode,
  419. template_id=payload.template_id,
  420. )
  421. background_tasks.add_task(process_document_run, run.id)
  422. return run
  423. @router.get(
  424. "/{document_id}/processing",
  425. response_model=DocumentProcessingRunRead,
  426. summary="资料处理状态",
  427. )
  428. def processing_status(
  429. document_id: str,
  430. db: Session = Depends(get_db),
  431. ):
  432. run = document_repo.get_latest_processing_run(db, document_id)
  433. if run is None:
  434. raise APIError(404, "processing_run_not_found", "资料尚未开始解析")
  435. return run
  436. @router.post(
  437. "/{document_id}/extractions",
  438. response_model=DocumentExtractionRead,
  439. status_code=status.HTTP_201_CREATED,
  440. summary="新增结构化抽取字段",
  441. )
  442. def create_extraction(
  443. document_id: str,
  444. payload: DocumentExtractionCreate,
  445. db: Session = Depends(get_db),
  446. ):
  447. if payload.document_id != document_id:
  448. payload.document_id = document_id
  449. return add_extraction(db, payload)
  450. @router.patch(
  451. "/{document_id}/extractions/{extraction_id}",
  452. response_model=DocumentExtractionRead,
  453. summary="更新结构化抽取字段",
  454. )
  455. async def patch_extraction(
  456. document_id: str,
  457. extraction_id: str,
  458. payload: DocumentExtractionUpdate,
  459. background_tasks: BackgroundTasks,
  460. db: Session = Depends(get_db),
  461. ):
  462. extraction, judgement_task = update_extraction(
  463. db,
  464. document_id,
  465. extraction_id,
  466. payload,
  467. )
  468. if judgement_task is not None:
  469. background_tasks.add_task(
  470. process_extraction_edit_judgement,
  471. judgement_task,
  472. )
  473. return extraction