document.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721
  1. from __future__ import annotations
  2. import json
  3. import html
  4. import re
  5. from datetime import datetime, timezone
  6. from math import ceil
  7. from sqlalchemy import delete, exists, func, or_, select
  8. from sqlalchemy.orm import Session
  9. from app.db.models.document import (
  10. BusinessDocument,
  11. DocumentExtraction,
  12. DocumentExtractionTemplate,
  13. DocumentProcessingRun,
  14. )
  15. from app.db.repositories.base import (
  16. create_model,
  17. delete_model,
  18. paginate,
  19. update_model,
  20. )
  21. ACTIVE_PROCESSING_STATUSES = ("pending", "running", "parsing", "extracting")
  22. EMPTY_VALUE_MARKERS = {
  23. "null",
  24. "none",
  25. "nan",
  26. "--",
  27. "-",
  28. "未提供",
  29. "未填写",
  30. "待补充",
  31. "待填写",
  32. "暂无",
  33. "暂无数据",
  34. }
  35. def _is_meaningful_extraction_value(value: object) -> bool:
  36. text = str(value or "").strip()
  37. if not text:
  38. return False
  39. try:
  40. parsed = json.loads(text)
  41. except (TypeError, ValueError, json.JSONDecodeError):
  42. parsed = None
  43. if isinstance(parsed, (list, dict)) and not parsed:
  44. return False
  45. return text.lower() not in EMPTY_VALUE_MARKERS
  46. def _template_field_keys(template: DocumentExtractionTemplate) -> set[str]:
  47. schema = template.extraction_schema or {}
  48. keys: set[str] = set()
  49. for group in schema.get("groups", []):
  50. if group.get("analysis_only"):
  51. continue
  52. for field in group.get("fields", []):
  53. key = str(field.get("key") or "").strip()
  54. if key:
  55. keys.add(key)
  56. return keys
  57. def _month_start(value: datetime) -> datetime:
  58. return value.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
  59. def _previous_month_start(value: datetime) -> datetime:
  60. if value.month == 1:
  61. return value.replace(year=value.year - 1, month=12)
  62. return value.replace(month=value.month - 1)
  63. def list_documents(
  64. db: Session,
  65. *,
  66. mine_id: str,
  67. category: str | None = None,
  68. keyword: str | None = None,
  69. parse_status: str | None = None,
  70. page: int = 1,
  71. page_size: int = 20,
  72. ) -> tuple[list[BusinessDocument], int]:
  73. statement = select(BusinessDocument).where(BusinessDocument.mine_id == mine_id)
  74. if category and category != "all":
  75. statement = statement.where(BusinessDocument.category == category)
  76. if parse_status:
  77. statement = statement.where(BusinessDocument.parse_status == parse_status)
  78. if keyword:
  79. query = f"%{keyword.strip()}%"
  80. statement = statement.where(
  81. or_(
  82. BusinessDocument.title.like(query),
  83. BusinessDocument.code.like(query),
  84. BusinessDocument.owner_name.like(query),
  85. BusinessDocument.summary.like(query),
  86. )
  87. )
  88. statement = statement.order_by(BusinessDocument.updated_at.desc())
  89. return paginate(db, statement, page, page_size)
  90. def list_documents_for_export(
  91. db: Session,
  92. *,
  93. mine_id: str,
  94. category: str | None = None,
  95. keyword: str | None = None,
  96. parse_status: str | None = None,
  97. ) -> list[BusinessDocument]:
  98. statement = select(BusinessDocument).where(BusinessDocument.mine_id == mine_id)
  99. if category and category != "all":
  100. statement = statement.where(BusinessDocument.category == category)
  101. if parse_status:
  102. statement = statement.where(BusinessDocument.parse_status == parse_status)
  103. if keyword:
  104. query = f"%{keyword.strip()}%"
  105. statement = statement.where(
  106. or_(
  107. BusinessDocument.title.like(query),
  108. BusinessDocument.code.like(query),
  109. BusinessDocument.owner_name.like(query),
  110. BusinessDocument.summary.like(query),
  111. )
  112. )
  113. return list(
  114. db.scalars(
  115. statement.order_by(BusinessDocument.updated_at.desc()).limit(20000)
  116. ).all()
  117. )
  118. def extraction_counts(
  119. db: Session,
  120. document_ids: list[str],
  121. ) -> dict[str, int]:
  122. if not document_ids:
  123. return {}
  124. rows = db.execute(
  125. select(
  126. DocumentExtraction.document_id,
  127. func.count(DocumentExtraction.id),
  128. )
  129. .where(DocumentExtraction.document_id.in_(document_ids))
  130. .group_by(DocumentExtraction.document_id)
  131. ).all()
  132. return {str(document_id): int(count) for document_id, count in rows}
  133. def _clean_search_text(value: str) -> str:
  134. text = html.unescape(str(value or ""))
  135. text = re.sub(r"<[^>]+>", " ", text)
  136. text = re.sub(r"[ \t\r\f\v]+", " ", text)
  137. text = re.sub(r"\n{3,}", "\n\n", text)
  138. return text.strip()
  139. def _search_snippet(text: str, keyword: str, radius: int = 80) -> str:
  140. cleaned = _clean_search_text(text)
  141. if not cleaned:
  142. return ""
  143. position = cleaned.lower().find(keyword.lower())
  144. if position < 0:
  145. return cleaned[: radius * 2].strip()
  146. start = max(0, position - radius)
  147. end = min(len(cleaned), position + len(keyword) + radius)
  148. return f"{'…' if start > 0 else ''}{cleaned[start:end].strip()}{'…' if end < len(cleaned) else ''}"
  149. def search_documents(
  150. db: Session,
  151. *,
  152. mine_id: str,
  153. query: str,
  154. category: str | None = None,
  155. page: int = 1,
  156. page_size: int = 10,
  157. ) -> tuple[list[dict], int]:
  158. keyword = query.strip()
  159. pattern = f"%{keyword}%"
  160. metadata_condition = or_(
  161. BusinessDocument.title.like(pattern),
  162. BusinessDocument.code.like(pattern),
  163. BusinessDocument.owner_name.like(pattern),
  164. BusinessDocument.department.like(pattern),
  165. BusinessDocument.file_name.like(pattern),
  166. BusinessDocument.summary.like(pattern),
  167. )
  168. parsed_text_condition = exists(
  169. select(DocumentProcessingRun.id).where(
  170. DocumentProcessingRun.document_id == BusinessDocument.id,
  171. DocumentProcessingRun.parsed_markdown.like(pattern),
  172. )
  173. )
  174. extraction_condition = exists(
  175. select(DocumentExtraction.id).where(
  176. DocumentExtraction.document_id == BusinessDocument.id,
  177. or_(
  178. DocumentExtraction.field_name.like(pattern),
  179. DocumentExtraction.field_value.like(pattern),
  180. DocumentExtraction.normalized_value.like(pattern),
  181. DocumentExtraction.evidence_text.like(pattern),
  182. ),
  183. )
  184. )
  185. statement = select(BusinessDocument).where(
  186. BusinessDocument.mine_id == mine_id,
  187. or_(
  188. metadata_condition,
  189. parsed_text_condition,
  190. extraction_condition,
  191. ),
  192. )
  193. if category and category != "all":
  194. statement = statement.where(BusinessDocument.category == category)
  195. statement = statement.order_by(BusinessDocument.updated_at.desc())
  196. documents, total = paginate(db, statement, page, page_size)
  197. hits: list[dict] = []
  198. for document in documents:
  199. score = 0.0
  200. snippets: list[dict[str, str]] = []
  201. metadata_values = [
  202. ("title", "资料名称", document.title),
  203. ("code", "文档编号", document.code or ""),
  204. ("owner", "资料责任人", document.owner_name or ""),
  205. ("department", "所属部门", document.department or ""),
  206. ("file_name", "文件名", document.file_name or ""),
  207. ("summary", "资料摘要", document.summary or ""),
  208. ]
  209. for source, label, value in metadata_values:
  210. if keyword.lower() in str(value or "").lower():
  211. score += {
  212. "title": 100,
  213. "code": 80,
  214. "owner": 55,
  215. "department": 45,
  216. "file_name": 60,
  217. "summary": 35,
  218. }[source]
  219. snippets.append(
  220. {
  221. "source": source,
  222. "label": label,
  223. "text": _search_snippet(value, keyword),
  224. }
  225. )
  226. latest_run = get_latest_processing_run(db, document.id)
  227. if (
  228. latest_run
  229. and latest_run.parsed_markdown
  230. and keyword.lower() in latest_run.parsed_markdown.lower()
  231. ):
  232. occurrences = latest_run.parsed_markdown.lower().count(keyword.lower())
  233. score += 30 + min(occurrences, 10) * 3
  234. snippets.append(
  235. {
  236. "source": "parsed_text",
  237. "label": "解析全文",
  238. "text": _search_snippet(latest_run.parsed_markdown, keyword),
  239. }
  240. )
  241. extraction_matches = list(
  242. db.scalars(
  243. select(DocumentExtraction)
  244. .where(
  245. DocumentExtraction.document_id == document.id,
  246. or_(
  247. DocumentExtraction.field_name.like(pattern),
  248. DocumentExtraction.field_value.like(pattern),
  249. DocumentExtraction.normalized_value.like(pattern),
  250. DocumentExtraction.evidence_text.like(pattern),
  251. ),
  252. )
  253. .limit(5)
  254. ).all()
  255. )
  256. for extraction in extraction_matches:
  257. score += 20
  258. value = extraction.normalized_value or extraction.field_value or ""
  259. snippets.append(
  260. {
  261. "source": "extraction",
  262. "label": f"关键字段 · {extraction.field_name}",
  263. "text": _search_snippet(f"{extraction.field_name}:{value}", keyword),
  264. }
  265. )
  266. hits.append(
  267. {
  268. "document": document,
  269. "score": round(score, 2),
  270. "snippets": snippets[:5],
  271. }
  272. )
  273. hits.sort(key=lambda item: item["score"], reverse=True)
  274. return hits, total
  275. def get_document(db: Session, document_id: str) -> BusinessDocument | None:
  276. return db.get(BusinessDocument, document_id)
  277. def list_extractions(db: Session, document_id: str) -> list[DocumentExtraction]:
  278. return list(
  279. db.scalars(
  280. select(DocumentExtraction)
  281. .where(DocumentExtraction.document_id == document_id)
  282. .order_by(DocumentExtraction.page_no, DocumentExtraction.field_name)
  283. ).all()
  284. )
  285. def category_summary(db: Session, mine_id: str) -> list[tuple[str, int]]:
  286. rows = db.execute(
  287. select(BusinessDocument.category, func.count(BusinessDocument.id))
  288. .where(BusinessDocument.mine_id == mine_id)
  289. .group_by(BusinessDocument.category)
  290. .order_by(BusinessDocument.category)
  291. ).all()
  292. return [(str(category), int(count)) for category, count in rows]
  293. def document_statistics(db: Session, mine_id: str) -> dict:
  294. now = datetime.now(timezone.utc).replace(tzinfo=None)
  295. month_start = _month_start(now)
  296. previous_month_start = _previous_month_start(month_start)
  297. total_documents = int(
  298. db.scalar(
  299. select(func.count(BusinessDocument.id)).where(
  300. BusinessDocument.mine_id == mine_id
  301. )
  302. )
  303. or 0
  304. )
  305. documents_added_this_month = int(
  306. db.scalar(
  307. select(func.count(BusinessDocument.id)).where(
  308. BusinessDocument.mine_id == mine_id,
  309. BusinessDocument.created_at >= month_start,
  310. )
  311. )
  312. or 0
  313. )
  314. documents = list(
  315. db.scalars(
  316. select(BusinessDocument).where(BusinessDocument.mine_id == mine_id)
  317. ).all()
  318. )
  319. document_ids = [document.id for document in documents]
  320. extraction_rows = db.execute(
  321. select(
  322. DocumentExtraction.document_id,
  323. DocumentExtraction.field_key,
  324. DocumentExtraction.created_at,
  325. DocumentExtraction.verified,
  326. DocumentExtraction.field_value,
  327. DocumentExtraction.edited,
  328. DocumentExtraction.extraction_correct,
  329. DocumentExtraction.edit_judgement_status,
  330. )
  331. .join(
  332. BusinessDocument,
  333. DocumentExtraction.document_id == BusinessDocument.id,
  334. )
  335. .where(BusinessDocument.mine_id == mine_id)
  336. ).all()
  337. extracted_fields = len(extraction_rows)
  338. latest_template_by_document: dict[str, str | None] = {}
  339. if document_ids:
  340. processing_runs = db.execute(
  341. select(
  342. DocumentProcessingRun.document_id,
  343. DocumentProcessingRun.template_id,
  344. )
  345. .where(DocumentProcessingRun.document_id.in_(document_ids))
  346. .order_by(DocumentProcessingRun.created_at.desc())
  347. ).all()
  348. for run in processing_runs:
  349. latest_template_by_document.setdefault(run.document_id, run.template_id)
  350. templates = list(db.scalars(select(DocumentExtractionTemplate)).all())
  351. template_by_id = {template.id: template for template in templates}
  352. template_by_code = {template.code: template for template in templates}
  353. template_by_type = {template.document_type: template for template in templates}
  354. rows_by_document: dict[str, dict[str, object]] = {}
  355. for row in extraction_rows:
  356. rows_by_document.setdefault(row.document_id, {})[row.field_key] = row
  357. expected_fields = 0
  358. valid_fields = 0
  359. invalid_fields = 0
  360. missing_fields = 0
  361. for document in documents:
  362. actual_rows = rows_by_document.get(document.id, {})
  363. template_id = latest_template_by_document.get(document.id)
  364. template = (
  365. template_by_id.get(template_id)
  366. if template_id
  367. else template_by_code.get(document.document_type)
  368. or template_by_type.get(document.document_type)
  369. )
  370. expected_keys = (
  371. _template_field_keys(template)
  372. if template is not None
  373. else set(actual_rows)
  374. )
  375. if not expected_keys:
  376. expected_keys = set(actual_rows)
  377. expected_fields += len(expected_keys)
  378. for key in expected_keys:
  379. row = actual_rows.get(key)
  380. if row is None:
  381. missing_fields += 1
  382. elif _is_meaningful_extraction_value(row.field_value):
  383. valid_fields += 1
  384. else:
  385. invalid_fields += 1
  386. verified_fields = sum(1 for row in extraction_rows if bool(row.verified))
  387. edited_fields = sum(1 for row in extraction_rows if bool(row.edited))
  388. pending_judgements = sum(
  389. 1 for row in extraction_rows if row.edit_judgement_status == "pending"
  390. )
  391. incorrect_fields = sum(
  392. 1
  393. for row in extraction_rows
  394. if row.edit_judgement_status != "pending"
  395. and not bool(row.extraction_correct)
  396. )
  397. accuracy_fields = extracted_fields - pending_judgements
  398. extraction_accuracy = (
  399. (accuracy_fields - incorrect_fields) / accuracy_fields * 100
  400. if accuracy_fields
  401. else 0.0
  402. )
  403. current_month_rows = [
  404. row
  405. for row in extraction_rows
  406. if row.created_at
  407. and row.created_at >= month_start
  408. and row.edit_judgement_status != "pending"
  409. ]
  410. previous_month_rows = [
  411. row
  412. for row in extraction_rows
  413. if row.created_at
  414. and previous_month_start <= row.created_at < month_start
  415. and row.edit_judgement_status != "pending"
  416. ]
  417. current_month_accuracy = (
  418. sum(1 for row in current_month_rows if row.extraction_correct)
  419. / len(current_month_rows)
  420. * 100
  421. if current_month_rows
  422. else 0.0
  423. )
  424. previous_month_accuracy = (
  425. sum(1 for row in previous_month_rows if row.extraction_correct)
  426. / len(previous_month_rows)
  427. * 100
  428. if previous_month_rows
  429. else 0.0
  430. )
  431. active_documents = list(
  432. db.scalars(
  433. select(BusinessDocument).where(
  434. BusinessDocument.mine_id == mine_id,
  435. BusinessDocument.parse_status.in_(ACTIVE_PROCESSING_STATUSES),
  436. )
  437. ).all()
  438. )
  439. estimated_seconds = 0.0
  440. for document in active_documents:
  441. if document.row_count:
  442. total_seconds = max(180.0, float(document.row_count) * 0.375)
  443. elif document.page_count:
  444. total_seconds = max(180.0, float(document.page_count) * 54)
  445. else:
  446. total_seconds = max(
  447. 180.0,
  448. (float(document.file_size or 0) / (1024 * 1024)) * 48,
  449. )
  450. progress = max(0, min(int(document.parse_progress or 0), 100))
  451. estimated_seconds += total_seconds * (1 - progress / 100)
  452. field_completeness = (
  453. valid_fields / expected_fields * 100 if expected_fields else 0.0
  454. )
  455. accuracy_change = (
  456. current_month_accuracy - previous_month_accuracy
  457. if current_month_rows and previous_month_rows
  458. else 0.0
  459. )
  460. return {
  461. "total_documents": total_documents,
  462. "documents_added_this_month": documents_added_this_month,
  463. "extracted_fields": extracted_fields,
  464. "field_completeness": round(field_completeness, 1),
  465. "extraction_accuracy": round(extraction_accuracy, 1),
  466. "accuracy_change": round(accuracy_change, 1),
  467. "pending_tasks": len(active_documents),
  468. "estimated_minutes": ceil(estimated_seconds / 60) if estimated_seconds else 0,
  469. "verified_fields": verified_fields,
  470. "edited_fields": edited_fields,
  471. "incorrect_fields": incorrect_fields,
  472. "expected_fields": expected_fields,
  473. "valid_fields": valid_fields,
  474. "invalid_fields": invalid_fields,
  475. "missing_fields": missing_fields,
  476. "pending_judgements": pending_judgements,
  477. "accuracy_fields": accuracy_fields,
  478. "updated_at": now,
  479. }
  480. def create_document(db: Session, data: dict) -> BusinessDocument:
  481. return create_model(db, BusinessDocument, data)
  482. def update_document(
  483. db: Session,
  484. document: BusinessDocument,
  485. data: dict,
  486. ) -> BusinessDocument:
  487. return update_model(db, document, data)
  488. def delete_document(db: Session, document: BusinessDocument) -> None:
  489. delete_model(db, document)
  490. def create_extraction(db: Session, data: dict) -> DocumentExtraction:
  491. return create_model(db, DocumentExtraction, data)
  492. def get_extraction(
  493. db: Session,
  494. extraction_id: str,
  495. ) -> DocumentExtraction | None:
  496. return db.get(DocumentExtraction, extraction_id)
  497. def update_extraction(
  498. db: Session,
  499. extraction: DocumentExtraction,
  500. data: dict,
  501. ) -> DocumentExtraction:
  502. return update_model(db, extraction, data)
  503. def delete_extractions(db: Session, document_id: str) -> None:
  504. db.execute(
  505. delete(DocumentExtraction).where(DocumentExtraction.document_id == document_id)
  506. )
  507. db.commit()
  508. def list_templates(
  509. db: Session,
  510. document_type: str | None = None,
  511. ) -> list[DocumentExtractionTemplate]:
  512. statement = select(DocumentExtractionTemplate).where(
  513. DocumentExtractionTemplate.enabled.is_(True)
  514. )
  515. if document_type:
  516. statement = statement.where(
  517. DocumentExtractionTemplate.document_type == document_type
  518. )
  519. return list(
  520. db.scalars(
  521. statement.order_by(DocumentExtractionTemplate.document_type)
  522. ).all()
  523. )
  524. def create_processing_run(
  525. db: Session,
  526. data: dict,
  527. ) -> DocumentProcessingRun:
  528. return create_model(db, DocumentProcessingRun, data)
  529. def get_processing_run(
  530. db: Session,
  531. run_id: str,
  532. ) -> DocumentProcessingRun | None:
  533. return db.get(DocumentProcessingRun, run_id)
  534. def get_latest_processing_run(
  535. db: Session,
  536. document_id: str,
  537. ) -> DocumentProcessingRun | None:
  538. return db.scalar(
  539. select(DocumentProcessingRun)
  540. .where(DocumentProcessingRun.document_id == document_id)
  541. .order_by(DocumentProcessingRun.created_at.desc())
  542. .limit(1)
  543. )
  544. def _processing_duration_seconds(
  545. *,
  546. status: str,
  547. started_at: datetime | None,
  548. finished_at: datetime | None,
  549. created_at: datetime | None,
  550. ) -> float | None:
  551. def naive(value: datetime | None) -> datetime | None:
  552. if value is None:
  553. return None
  554. return value.replace(tzinfo=None) if value.tzinfo else value
  555. started = naive(started_at or created_at)
  556. if started is None:
  557. return None
  558. if status in {"completed", "failed"}:
  559. finished = naive(finished_at)
  560. if finished is None:
  561. return None
  562. return max(0.0, round((finished - started).total_seconds(), 3))
  563. if status in {"running", "parsing", "extracting"}:
  564. now = datetime.now(timezone.utc).replace(tzinfo=None)
  565. return max(0.0, round((now - started).total_seconds(), 3))
  566. return None
  567. def list_processing_tasks(
  568. db: Session,
  569. *,
  570. mine_id: str,
  571. limit: int = 8,
  572. ) -> list[dict]:
  573. latest_created_at = (
  574. select(func.max(DocumentProcessingRun.created_at))
  575. .where(DocumentProcessingRun.document_id == BusinessDocument.id)
  576. .correlate(BusinessDocument)
  577. .scalar_subquery()
  578. )
  579. rows = db.execute(
  580. select(
  581. DocumentProcessingRun.id,
  582. DocumentProcessingRun.document_id,
  583. DocumentProcessingRun.status,
  584. DocumentProcessingRun.stage,
  585. DocumentProcessingRun.progress,
  586. DocumentProcessingRun.document_type.label("run_document_type"),
  587. DocumentProcessingRun.parser_engine,
  588. DocumentProcessingRun.error,
  589. DocumentProcessingRun.started_at,
  590. DocumentProcessingRun.finished_at,
  591. DocumentProcessingRun.created_at,
  592. DocumentProcessingRun.updated_at,
  593. BusinessDocument.title,
  594. BusinessDocument.file_name,
  595. BusinessDocument.file_ext,
  596. BusinessDocument.page_count,
  597. BusinessDocument.row_count,
  598. BusinessDocument.document_type.label("document_document_type"),
  599. BusinessDocument.parse_error,
  600. )
  601. .join(
  602. BusinessDocument,
  603. BusinessDocument.id == DocumentProcessingRun.document_id,
  604. )
  605. .where(
  606. BusinessDocument.mine_id == mine_id,
  607. DocumentProcessingRun.created_at == latest_created_at,
  608. )
  609. .order_by(DocumentProcessingRun.created_at.desc())
  610. .limit(limit)
  611. ).all()
  612. document_ids = [row.document_id for row in rows]
  613. counts: dict[str, int] = {}
  614. if document_ids:
  615. counts = {
  616. str(document_id): int(count)
  617. for document_id, count in db.execute(
  618. select(
  619. DocumentExtraction.document_id,
  620. func.count(DocumentExtraction.id),
  621. )
  622. .where(DocumentExtraction.document_id.in_(document_ids))
  623. .group_by(DocumentExtraction.document_id)
  624. ).all()
  625. }
  626. return [
  627. {
  628. "id": row.id,
  629. "document_id": row.document_id,
  630. "title": row.title,
  631. "file_name": row.file_name,
  632. "file_ext": row.file_ext,
  633. "status": row.status,
  634. "stage": row.stage,
  635. "progress": int(row.progress or 0),
  636. "document_type": row.run_document_type or row.document_document_type,
  637. "parser_engine": row.parser_engine,
  638. "page_count": row.page_count,
  639. "row_count": row.row_count,
  640. "field_count": counts.get(str(row.document_id), 0),
  641. "error": row.error or row.parse_error,
  642. "processing_seconds": _processing_duration_seconds(
  643. status=row.status,
  644. started_at=row.started_at,
  645. finished_at=row.finished_at,
  646. created_at=row.created_at,
  647. ),
  648. "started_at": row.started_at,
  649. "created_at": row.created_at,
  650. "updated_at": row.updated_at,
  651. }
  652. for row in rows
  653. ]