techprotrade commited on
Commit
b673d62
·
verified ·
1 Parent(s): c396d28

PDF workflow hub + skills ocean: backend/api/pdf_workflow_routes.py

Browse files
Files changed (1) hide show
  1. backend/api/pdf_workflow_routes.py +498 -0
backend/api/pdf_workflow_routes.py ADDED
@@ -0,0 +1,498 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Annator PDF Workflow Hub — skills ocean + orchestrator + real PDF extract.
3
+
4
+ Priority surface for HF: make PDF pipelines operable without optional OCR stacks.
5
+ Uses PyPDF2 for text PDFs; reports when scan/OCR is needed.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import io
10
+ import logging
11
+ import re
12
+ import uuid
13
+ from datetime import datetime, timezone
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ from fastapi import APIRouter, File, Form, HTTPException, UploadFile
17
+ from pydantic import BaseModel, Field
18
+
19
+ logger = logging.getLogger("pdf_workflow_hub")
20
+
21
+ router = APIRouter(prefix="/api/pdf", tags=["PDF Workflow"])
22
+
23
+ try:
24
+ import PyPDF2
25
+ except ImportError: # pragma: no cover
26
+ PyPDF2 = None # type: ignore
27
+
28
+ # In-memory job store (HF container; swap to DB/Neon later)
29
+ _JOBS: Dict[str, Dict[str, Any]] = {}
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Skill ocean (PDF-domain)
33
+ # ---------------------------------------------------------------------------
34
+
35
+ PDF_SKILLS: List[Dict[str, Any]] = [
36
+ {
37
+ "id": "pdf-text-extract",
38
+ "name": "PDF Text Extract",
39
+ "category": "pdf",
40
+ "status": "ready",
41
+ "description": "Võtab tekstist loetavast PDF-ist lehekülgede teksti (PyPDF2).",
42
+ "inputs": ["file"],
43
+ "outputs": ["text", "pages"],
44
+ },
45
+ {
46
+ "id": "pdf-ocr",
47
+ "name": "PDF OCR",
48
+ "category": "pdf",
49
+ "status": "degraded",
50
+ "description": "OCR skannitud PDF-idele (vajab tesseract/easyocr/docling; HF peal fallback).",
51
+ "inputs": ["file"],
52
+ "outputs": ["text"],
53
+ },
54
+ {
55
+ "id": "bank-statement-reader",
56
+ "name": "Bank Statement Reader",
57
+ "category": "finance",
58
+ "status": "ready",
59
+ "description": "Heuristiline pangaväljavõtte tehingute tuvastus tekstist.",
60
+ "inputs": ["text"],
61
+ "outputs": ["transactions", "summary"],
62
+ },
63
+ {
64
+ "id": "loan-doc-classifier",
65
+ "name": "Loan Document Classifier",
66
+ "category": "finance",
67
+ "status": "ready",
68
+ "description": "Klassifitseerib laenudokumendi tüübi teksti põhjal.",
69
+ "inputs": ["text"],
70
+ "outputs": ["doc_type", "confidence"],
71
+ },
72
+ {
73
+ "id": "pdf-redaction-plan",
74
+ "name": "PDF Redaction Planner",
75
+ "category": "pdf",
76
+ "status": "ready",
77
+ "description": "Leiab PII/IBAN mustreid ja koostab redaktsiooniplaani (HITL).",
78
+ "inputs": ["text"],
79
+ "outputs": ["findings", "plan"],
80
+ },
81
+ {
82
+ "id": "pdf-orchestrator",
83
+ "name": "PDF Orchestrator",
84
+ "category": "orchestration",
85
+ "status": "ready",
86
+ "description": "Valib skill-ahela eesmärgi põhjal ja juhib töövoo samme.",
87
+ "inputs": ["goal", "file?"],
88
+ "outputs": ["plan", "execution"],
89
+ },
90
+ {
91
+ "id": "llm-orchestrator",
92
+ "name": "LLM Orchestrator",
93
+ "category": "ai",
94
+ "status": "ready",
95
+ "description": "Sidumine Autoflow/LLM plaaniga (plan_only kui mudel puudub).",
96
+ "inputs": ["goal", "context"],
97
+ "outputs": ["plan"],
98
+ },
99
+ ]
100
+
101
+ WORKFLOWS: List[Dict[str, Any]] = [
102
+ {
103
+ "id": "wf-pdf-bank-analysis",
104
+ "name": "PDF + pangaväljavõtte analüüs",
105
+ "category": "pdf",
106
+ "status": "ready",
107
+ "steps": ["pdf-text-extract", "bank-statement-reader", "loan-doc-classifier", "pdf-orchestrator"],
108
+ },
109
+ {
110
+ "id": "wf-pdf-loan-pack",
111
+ "name": "Laenupaketi PDF pipeline",
112
+ "category": "pdf",
113
+ "status": "ready",
114
+ "steps": ["pdf-text-extract", "loan-doc-classifier", "pdf-redaction-plan", "pdf-orchestrator"],
115
+ },
116
+ {
117
+ "id": "wf-pdf-ocr-fallback",
118
+ "name": "OCR fallback skannidele",
119
+ "category": "pdf",
120
+ "status": "degraded",
121
+ "steps": ["pdf-text-extract", "pdf-ocr", "bank-statement-reader"],
122
+ },
123
+ ]
124
+
125
+
126
+ class PlanRequest(BaseModel):
127
+ goal: str = Field(..., min_length=1)
128
+ domain: str = "pdf"
129
+ mode: str = "plan_only"
130
+
131
+
132
+ class SkillExecuteRequest(BaseModel):
133
+ skill_id: str
134
+ text: Optional[str] = None
135
+ goal: Optional[str] = None
136
+ params: Dict[str, Any] = Field(default_factory=dict)
137
+
138
+
139
+ class WorkflowRunRequest(BaseModel):
140
+ workflow_id: str = "wf-pdf-bank-analysis"
141
+ goal: str = "PDF analüüs"
142
+ text: Optional[str] = None # if no file uploaded via multipart
143
+
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # Core extractors
147
+ # ---------------------------------------------------------------------------
148
+
149
+ def _now() -> str:
150
+ return datetime.now(timezone.utc).isoformat()
151
+
152
+
153
+ def extract_pdf_text(data: bytes) -> Dict[str, Any]:
154
+ if not PyPDF2:
155
+ raise HTTPException(status_code=503, detail="PyPDF2 not installed")
156
+ reader = PyPDF2.PdfReader(io.BytesIO(data))
157
+ pages: List[Dict[str, Any]] = []
158
+ chunks: List[str] = []
159
+ for i, page in enumerate(reader.pages):
160
+ try:
161
+ t = page.extract_text() or ""
162
+ except Exception as exc: # noqa: BLE001
163
+ t = ""
164
+ logger.warning("page %s extract failed: %s", i + 1, exc)
165
+ pages.append({"page": i + 1, "text": t, "char_count": len(t)})
166
+ chunks.append(t)
167
+ full = "\n".join(chunks)
168
+ page_count = len(pages)
169
+ total = len(full)
170
+ text_ratio = min(total / max(page_count * 800, 1), 1.0)
171
+ return {
172
+ "method": "pypdf2",
173
+ "success": True,
174
+ "page_count": page_count,
175
+ "total_chars": total,
176
+ "text_ratio": text_ratio,
177
+ "needs_ocr": text_ratio < 0.08 or total < 80,
178
+ "extracted_text": full,
179
+ "pages": pages,
180
+ }
181
+
182
+
183
+ _IBAN_RE = re.compile(r"\b[A-Z]{2}\d{2}[A-Z0-9]{10,30}\b")
184
+ _AMOUNT_RE = re.compile(
185
+ r"(?<!\w)(-?\d{1,3}(?:[ \u00a0]\d{3})*(?:[.,]\d{2})|-?\d+[.,]\d{2})(?!\w)"
186
+ )
187
+ _DATE_RE = re.compile(r"\b(\d{1,2}[./-]\d{1,2}[./-]\d{2,4}|\d{4}-\d{2}-\d{2})\b")
188
+ _EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
189
+ _PERSONAL_CODE_RE = re.compile(r"\b[1-6]\d{10}\b") # EE-like isikukood shape
190
+
191
+
192
+ def parse_bank_statement(text: str) -> Dict[str, Any]:
193
+ lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
194
+ txs: List[Dict[str, Any]] = []
195
+ for ln in lines:
196
+ amounts = _AMOUNT_RE.findall(ln)
197
+ dates = _DATE_RE.findall(ln)
198
+ if amounts and (dates or any(k in ln.lower() for k in ("makse", "laekumine", "tasu", "transfer", "payment"))):
199
+ txs.append(
200
+ {
201
+ "date": dates[0] if dates else None,
202
+ "amount_raw": amounts[-1],
203
+ "description": ln[:240],
204
+ }
205
+ )
206
+ ibans = list(dict.fromkeys(_IBAN_RE.findall(text)))
207
+ return {
208
+ "transaction_count": len(txs),
209
+ "transactions": txs[:200],
210
+ "ibans_found": ibans[:20],
211
+ "line_count": len(lines),
212
+ "summary": {
213
+ "approx_credit_lines": sum(1 for t in txs if not str(t["amount_raw"]).startswith("-")),
214
+ "approx_debit_lines": sum(1 for t in txs if str(t["amount_raw"]).startswith("-")),
215
+ },
216
+ }
217
+
218
+
219
+ def classify_loan_doc(text: str) -> Dict[str, Any]:
220
+ t = text.lower()
221
+ scores = {
222
+ "bank_statement": sum(1 for k in ("konto", "väljavõte", "valjavote", "saldo", "iban", "tehing") if k in t),
223
+ "loan_application": sum(1 for k in ("laen", "taotlus", "sissetulek", "kohustus", "tagatis") if k in t),
224
+ "income_proof": sum(1 for k in ("palk", "tööleping", "tooleping", "tulumaks") if k in t),
225
+ "id_document": sum(1 for k in ("isikutunnistus", "pass", "id-kaart", "id kaart") if k in t),
226
+ "contract": sum(1 for k in ("leping", "tingimused", "allkiri", "pool") if k in t),
227
+ "generic_pdf": 1,
228
+ }
229
+ doc_type = max(scores, key=scores.get)
230
+ conf = min(0.95, 0.35 + 0.12 * scores[doc_type])
231
+ return {"doc_type": doc_type, "confidence": round(conf, 2), "scores": scores}
232
+
233
+
234
+ def redaction_plan(text: str) -> Dict[str, Any]:
235
+ findings = []
236
+ for m in _IBAN_RE.finditer(text):
237
+ findings.append({"type": "iban", "value": m.group(0), "start": m.start(), "end": m.end()})
238
+ for m in _EMAIL_RE.finditer(text):
239
+ findings.append({"type": "email", "value": m.group(0), "start": m.start(), "end": m.end()})
240
+ for m in _PERSONAL_CODE_RE.finditer(text):
241
+ findings.append({"type": "personal_code_like", "value": m.group(0), "start": m.start(), "end": m.end()})
242
+ return {
243
+ "finding_count": len(findings),
244
+ "findings": findings[:100],
245
+ "plan": [
246
+ "HITL: kinnita enne redaktsiooni",
247
+ "Maski IBAN/email/isikukood PDF kihis (mitte ainult pealmine tekst)",
248
+ "Säilita auditilogi ilma plain-text PII-ta",
249
+ ],
250
+ "requires_approval": True,
251
+ }
252
+
253
+
254
+ def orchestrate(goal: str, text: Optional[str] = None, extract: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
255
+ g = goal.lower()
256
+ steps: List[str] = ["pdf-text-extract"]
257
+ if any(k in g for k in ("ocr", "skann", "scan")):
258
+ steps.append("pdf-ocr")
259
+ if any(k in g for k in ("pank", "väljavõte", "valjavote", "bank", "statement", "iban")):
260
+ steps.append("bank-statement-reader")
261
+ if any(k in g for k in ("laen", "loan", "taotlus")):
262
+ steps.append("loan-doc-classifier")
263
+ if any(k in g for k in ("redact", "pii", "isiku", "peida", "mask")):
264
+ steps.append("pdf-redaction-plan")
265
+ if "pdf-orchestrator" not in steps:
266
+ steps.append("pdf-orchestrator")
267
+
268
+ results: Dict[str, Any] = {}
269
+ body = text or (extract or {}).get("extracted_text") or ""
270
+ if body:
271
+ if "bank-statement-reader" in steps:
272
+ results["bank-statement-reader"] = parse_bank_statement(body)
273
+ if "loan-doc-classifier" in steps:
274
+ results["loan-doc-classifier"] = classify_loan_doc(body)
275
+ if "pdf-redaction-plan" in steps:
276
+ results["pdf-redaction-plan"] = redaction_plan(body)
277
+
278
+ return {
279
+ "goal": goal,
280
+ "selected_skills": steps,
281
+ "extract_meta": {
282
+ "page_count": (extract or {}).get("page_count"),
283
+ "total_chars": (extract or {}).get("total_chars"),
284
+ "needs_ocr": (extract or {}).get("needs_ocr"),
285
+ "method": (extract or {}).get("method"),
286
+ },
287
+ "skill_results": results,
288
+ "status": "completed" if body else "planned",
289
+ "warnings": (
290
+ ["PDF sisaldab vähe teksti — soovitatav OCR"]
291
+ if (extract or {}).get("needs_ocr")
292
+ else []
293
+ ),
294
+ }
295
+
296
+
297
+ # ---------------------------------------------------------------------------
298
+ # Routes
299
+ # ---------------------------------------------------------------------------
300
+
301
+ @router.get("/status")
302
+ async def pdf_status():
303
+ return {
304
+ "status": "ok",
305
+ "service": "pdf-workflow-hub",
306
+ "pypdf2": bool(PyPDF2),
307
+ "skills": len(PDF_SKILLS),
308
+ "workflows": len(WORKFLOWS),
309
+ "jobs_in_memory": len(_JOBS),
310
+ "endpoints": {
311
+ "skills": "/api/pdf/skills",
312
+ "workflows": "/api/pdf/workflows",
313
+ "ocr": "POST /api/pdf/ocr",
314
+ "jobs": "POST /api/pdf/jobs",
315
+ "run": "POST /api/pdf/workflow/run",
316
+ "plan": "POST /api/pdf/plan",
317
+ },
318
+ }
319
+
320
+
321
+ @router.get("/skills")
322
+ async def list_pdf_skills():
323
+ return {"ok": True, "count": len(PDF_SKILLS), "skills": PDF_SKILLS}
324
+
325
+
326
+ @router.get("/workflows")
327
+ async def list_pdf_workflows():
328
+ return {"ok": True, "count": len(WORKFLOWS), "workflows": WORKFLOWS}
329
+
330
+
331
+ @router.post("/plan")
332
+ async def plan_pdf_workflow(req: PlanRequest):
333
+ plan = orchestrate(req.goal)
334
+ return {
335
+ "ok": True,
336
+ "mode": req.mode,
337
+ "domain": req.domain,
338
+ "selected_adapter": "pdf-orchestrator",
339
+ "plan": plan["selected_skills"],
340
+ "result": plan,
341
+ }
342
+
343
+
344
+ @router.post("/ocr")
345
+ async def pdf_ocr_upload(file: UploadFile = File(...)):
346
+ if not file.filename or not file.filename.lower().endswith(".pdf"):
347
+ raise HTTPException(status_code=400, detail="Only .pdf supported")
348
+ data = await file.read()
349
+ if not data:
350
+ raise HTTPException(status_code=400, detail="Empty file")
351
+ result = extract_pdf_text(data)
352
+ job_id = str(uuid.uuid4())
353
+ _JOBS[job_id] = {
354
+ "id": job_id,
355
+ "type": "ocr",
356
+ "filename": file.filename,
357
+ "created_at": _now(),
358
+ "status": "completed",
359
+ "result": result,
360
+ }
361
+ return {"ok": True, "job_id": job_id, **result}
362
+
363
+
364
+ @router.post("/jobs")
365
+ async def create_pdf_job(
366
+ file: UploadFile = File(...),
367
+ goal: str = Form("PDF analüüs"),
368
+ workflow_id: str = Form("wf-pdf-bank-analysis"),
369
+ ):
370
+ if not file.filename or not file.filename.lower().endswith(".pdf"):
371
+ raise HTTPException(status_code=400, detail="Only .pdf supported")
372
+ data = await file.read()
373
+ if not data:
374
+ raise HTTPException(status_code=400, detail="Empty file")
375
+
376
+ extract = extract_pdf_text(data)
377
+ orch = orchestrate(goal, extract=extract)
378
+ job_id = str(uuid.uuid4())
379
+ record = {
380
+ "id": job_id,
381
+ "type": "workflow",
382
+ "workflow_id": workflow_id,
383
+ "goal": goal,
384
+ "filename": file.filename,
385
+ "created_at": _now(),
386
+ "status": orch["status"],
387
+ "extract": {
388
+ k: extract[k]
389
+ for k in ("method", "page_count", "total_chars", "text_ratio", "needs_ocr", "success")
390
+ },
391
+ "orchestration": orch,
392
+ # keep text for follow-up skill calls (not logged externally)
393
+ "_text": extract.get("extracted_text", "")[:500_000],
394
+ }
395
+ _JOBS[job_id] = record
396
+ public = {k: v for k, v in record.items() if not k.startswith("_")}
397
+ public["text_preview"] = (extract.get("extracted_text") or "")[:1500]
398
+ return {"ok": True, "job": public}
399
+
400
+
401
+ @router.get("/jobs/{job_id}")
402
+ async def get_pdf_job(job_id: str):
403
+ job = _JOBS.get(job_id)
404
+ if not job:
405
+ raise HTTPException(status_code=404, detail="Job not found")
406
+ public = {k: v for k, v in job.items() if not k.startswith("_")}
407
+ return {"ok": True, "job": public}
408
+
409
+
410
+ @router.post("/skills/execute")
411
+ async def execute_skill(req: SkillExecuteRequest):
412
+ skill = next((s for s in PDF_SKILLS if s["id"] == req.skill_id), None)
413
+ if not skill:
414
+ raise HTTPException(status_code=404, detail=f"Unknown skill: {req.skill_id}")
415
+
416
+ text = req.text or ""
417
+ if req.skill_id == "pdf-text-extract":
418
+ return {"ok": True, "skill": req.skill_id, "result": {"note": "Upload via POST /api/pdf/ocr"}}
419
+ if req.skill_id == "bank-statement-reader":
420
+ if not text:
421
+ raise HTTPException(status_code=400, detail="text required")
422
+ return {"ok": True, "skill": req.skill_id, "result": parse_bank_statement(text)}
423
+ if req.skill_id == "loan-doc-classifier":
424
+ if not text:
425
+ raise HTTPException(status_code=400, detail="text required")
426
+ return {"ok": True, "skill": req.skill_id, "result": classify_loan_doc(text)}
427
+ if req.skill_id == "pdf-redaction-plan":
428
+ if not text:
429
+ raise HTTPException(status_code=400, detail="text required")
430
+ return {"ok": True, "skill": req.skill_id, "result": redaction_plan(text)}
431
+ if req.skill_id in ("pdf-orchestrator", "llm-orchestrator"):
432
+ return {
433
+ "ok": True,
434
+ "skill": req.skill_id,
435
+ "result": orchestrate(req.goal or "PDF workflow", text=text),
436
+ }
437
+ if req.skill_id == "pdf-ocr":
438
+ return {
439
+ "ok": True,
440
+ "skill": req.skill_id,
441
+ "result": {
442
+ "status": "degraded",
443
+ "message": "Full OCR stack not installed on HF; use text PDF or attach tesseract/docling.",
444
+ },
445
+ }
446
+ raise HTTPException(status_code=400, detail="Skill not executable")
447
+
448
+
449
+ @router.post("/workflow/run")
450
+ async def run_workflow_multipart(
451
+ file: Optional[UploadFile] = File(None),
452
+ workflow_id: str = Form("wf-pdf-bank-analysis"),
453
+ goal: str = Form("PDF + pangaväljavõtte analüüs"),
454
+ ):
455
+ """End-to-end: upload PDF → extract → skill chain → orchestration result."""
456
+ extract = None
457
+ text = ""
458
+ filename = None
459
+ if file and file.filename:
460
+ if not file.filename.lower().endswith(".pdf"):
461
+ raise HTTPException(status_code=400, detail="Only .pdf supported")
462
+ data = await file.read()
463
+ extract = extract_pdf_text(data)
464
+ text = extract.get("extracted_text") or ""
465
+ filename = file.filename
466
+
467
+ wf = next((w for w in WORKFLOWS if w["id"] == workflow_id), None)
468
+ if not wf:
469
+ raise HTTPException(status_code=404, detail="Unknown workflow")
470
+
471
+ orch = orchestrate(goal, text=text, extract=extract)
472
+ job_id = str(uuid.uuid4())
473
+ record = {
474
+ "id": job_id,
475
+ "type": "workflow_run",
476
+ "workflow": wf,
477
+ "goal": goal,
478
+ "filename": filename,
479
+ "created_at": _now(),
480
+ "status": orch["status"],
481
+ "orchestration": orch,
482
+ "_text": text[:500_000],
483
+ }
484
+ _JOBS[job_id] = record
485
+ return {
486
+ "ok": True,
487
+ "job_id": job_id,
488
+ "workflow_id": workflow_id,
489
+ "status": orch["status"],
490
+ "selected_skills": orch["selected_skills"],
491
+ "skill_results": orch["skill_results"],
492
+ "extract_meta": orch["extract_meta"],
493
+ "warnings": orch["warnings"],
494
+ "text_preview": text[:1500],
495
+ }
496
+
497
+
498
+ __all__ = ["router"]