Upload 7 files
Browse files- app/google_ai_mode.py +4 -0
- app/jobs.py +34 -1
- app/main.py +52 -1
app/google_ai_mode.py
CHANGED
|
@@ -13,6 +13,7 @@ class PageRequest(NamedTuple):
|
|
| 13 |
page_number: int
|
| 14 |
image_path: Path
|
| 15 |
prompt: str
|
|
|
|
| 16 |
|
| 17 |
|
| 18 |
class GoogleAiModeClient:
|
|
@@ -405,6 +406,9 @@ async def _run_single_page_async(
|
|
| 405 |
await _enter_prompt_async(page, request.prompt)
|
| 406 |
await _submit_async(page)
|
| 407 |
await page.wait_for_timeout(5_000)
|
|
|
|
|
|
|
|
|
|
| 408 |
return await _wait_for_json_response_async(page, response_timeout_seconds)
|
| 409 |
finally:
|
| 410 |
await page.close()
|
|
|
|
| 13 |
page_number: int
|
| 14 |
image_path: Path
|
| 15 |
prompt: str
|
| 16 |
+
screenshot_path: Path | None = None
|
| 17 |
|
| 18 |
|
| 19 |
class GoogleAiModeClient:
|
|
|
|
| 406 |
await _enter_prompt_async(page, request.prompt)
|
| 407 |
await _submit_async(page)
|
| 408 |
await page.wait_for_timeout(5_000)
|
| 409 |
+
if request.screenshot_path:
|
| 410 |
+
request.screenshot_path.parent.mkdir(parents=True, exist_ok=True)
|
| 411 |
+
await page.screenshot(path=str(request.screenshot_path), full_page=False)
|
| 412 |
return await _wait_for_json_response_async(page, response_timeout_seconds)
|
| 413 |
finally:
|
| 414 |
await page.close()
|
app/jobs.py
CHANGED
|
@@ -22,6 +22,10 @@ class JobState:
|
|
| 22 |
total_batches: int = 0
|
| 23 |
completed_batches: int = 0
|
| 24 |
current_pages: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
output_file: str | None = None
|
| 26 |
error: str | None = None
|
| 27 |
run_dir: str | None = None
|
|
@@ -88,9 +92,11 @@ def _run_job(
|
|
| 88 |
image_dir = run_dir / "pages"
|
| 89 |
batch_dir = run_dir / "batch-json"
|
| 90 |
raw_dir = run_dir / "raw-output"
|
|
|
|
| 91 |
browser_profile_dir = (run_dir / "browser-profile").resolve()
|
| 92 |
batch_dir.mkdir(parents=True, exist_ok=True)
|
| 93 |
raw_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
| 94 |
|
| 95 |
jobs.update(
|
| 96 |
job_id,
|
|
@@ -119,12 +125,15 @@ def _run_job(
|
|
| 119 |
message=f"Wave {index}/{len(batches)}: opening AI Mode tabs for pages {current_pages}",
|
| 120 |
completed_batches=index - 1,
|
| 121 |
current_pages=current_pages,
|
|
|
|
| 122 |
progress=15 + int(((index - 1) / max(len(batches), 1)) * 70),
|
| 123 |
)
|
| 124 |
|
| 125 |
requests = []
|
|
|
|
| 126 |
for image_path in batch:
|
| 127 |
page_number = images.index(image_path) + 1
|
|
|
|
| 128 |
prompt = (
|
| 129 |
f"{BANK_STATEMENT_PROMPT}\n\n"
|
| 130 |
"The JSON block above is only the required output format. "
|
|
@@ -134,7 +143,15 @@ def _run_job(
|
|
| 134 |
f"The uploaded image is PDF page {page_number}. "
|
| 135 |
"Extract transactions only from this uploaded page image."
|
| 136 |
)
|
| 137 |
-
requests.append(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
|
| 139 |
wave_profile_dir = (browser_profile_dir / f"wave-{index:03d}").resolve()
|
| 140 |
with GoogleAiModeClient(ai_mode_url, wave_profile_dir) as client:
|
|
@@ -146,6 +163,13 @@ def _run_job(
|
|
| 146 |
progress=15 + int(((index - 0.25) / max(len(batches), 1)) * 70),
|
| 147 |
)
|
| 148 |
for page_number in sorted(responses):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
raw_response = responses[page_number]
|
| 150 |
raw_file = raw_dir / f"page-{page_number:04d}-raw.txt"
|
| 151 |
raw_file.write_text(raw_response, encoding="utf-8")
|
|
@@ -156,9 +180,17 @@ def _run_job(
|
|
| 156 |
encoding="utf-8",
|
| 157 |
)
|
| 158 |
page_json_files[page_number] = page_file
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
jobs.update(
|
| 160 |
job_id,
|
| 161 |
completed_batches=index,
|
|
|
|
| 162 |
message=f"Saved page wave {index} of {len(batches)}",
|
| 163 |
progress=15 + int((index / max(len(batches), 1)) * 70),
|
| 164 |
)
|
|
@@ -173,6 +205,7 @@ def _run_job(
|
|
| 173 |
progress=100,
|
| 174 |
output_file=str(final_json),
|
| 175 |
current_pages=None,
|
|
|
|
| 176 |
data_preview=combined["data"][:20],
|
| 177 |
)
|
| 178 |
except Exception as exc:
|
|
|
|
| 22 |
total_batches: int = 0
|
| 23 |
completed_batches: int = 0
|
| 24 |
current_pages: str | None = None
|
| 25 |
+
active_tabs: int = 0
|
| 26 |
+
completed_pages: int = 0
|
| 27 |
+
page_statuses: dict[str, str] = field(default_factory=dict)
|
| 28 |
+
latest_screenshot: str | None = None
|
| 29 |
output_file: str | None = None
|
| 30 |
error: str | None = None
|
| 31 |
run_dir: str | None = None
|
|
|
|
| 92 |
image_dir = run_dir / "pages"
|
| 93 |
batch_dir = run_dir / "batch-json"
|
| 94 |
raw_dir = run_dir / "raw-output"
|
| 95 |
+
browser_view_dir = run_dir / "browser-view"
|
| 96 |
browser_profile_dir = (run_dir / "browser-profile").resolve()
|
| 97 |
batch_dir.mkdir(parents=True, exist_ok=True)
|
| 98 |
raw_dir.mkdir(parents=True, exist_ok=True)
|
| 99 |
+
browser_view_dir.mkdir(parents=True, exist_ok=True)
|
| 100 |
|
| 101 |
jobs.update(
|
| 102 |
job_id,
|
|
|
|
| 125 |
message=f"Wave {index}/{len(batches)}: opening AI Mode tabs for pages {current_pages}",
|
| 126 |
completed_batches=index - 1,
|
| 127 |
current_pages=current_pages,
|
| 128 |
+
active_tabs=len(batch),
|
| 129 |
progress=15 + int(((index - 1) / max(len(batches), 1)) * 70),
|
| 130 |
)
|
| 131 |
|
| 132 |
requests = []
|
| 133 |
+
page_statuses = dict(jobs.get(job_id).page_statuses if jobs.get(job_id) else {})
|
| 134 |
for image_path in batch:
|
| 135 |
page_number = images.index(image_path) + 1
|
| 136 |
+
page_statuses[str(page_number)] = "queued"
|
| 137 |
prompt = (
|
| 138 |
f"{BANK_STATEMENT_PROMPT}\n\n"
|
| 139 |
"The JSON block above is only the required output format. "
|
|
|
|
| 143 |
f"The uploaded image is PDF page {page_number}. "
|
| 144 |
"Extract transactions only from this uploaded page image."
|
| 145 |
)
|
| 146 |
+
requests.append(
|
| 147 |
+
PageRequest(
|
| 148 |
+
page_number,
|
| 149 |
+
image_path,
|
| 150 |
+
prompt,
|
| 151 |
+
browser_view_dir / f"page-{page_number:04d}.png",
|
| 152 |
+
)
|
| 153 |
+
)
|
| 154 |
+
jobs.update(job_id, page_statuses=page_statuses)
|
| 155 |
|
| 156 |
wave_profile_dir = (browser_profile_dir / f"wave-{index:03d}").resolve()
|
| 157 |
with GoogleAiModeClient(ai_mode_url, wave_profile_dir) as client:
|
|
|
|
| 163 |
progress=15 + int(((index - 0.25) / max(len(batches), 1)) * 70),
|
| 164 |
)
|
| 165 |
for page_number in sorted(responses):
|
| 166 |
+
page_statuses[str(page_number)] = "parsing"
|
| 167 |
+
screenshot_path = browser_view_dir / f"page-{page_number:04d}.png"
|
| 168 |
+
jobs.update(
|
| 169 |
+
job_id,
|
| 170 |
+
page_statuses=page_statuses,
|
| 171 |
+
latest_screenshot=str(screenshot_path) if screenshot_path.exists() else None,
|
| 172 |
+
)
|
| 173 |
raw_response = responses[page_number]
|
| 174 |
raw_file = raw_dir / f"page-{page_number:04d}-raw.txt"
|
| 175 |
raw_file.write_text(raw_response, encoding="utf-8")
|
|
|
|
| 180 |
encoding="utf-8",
|
| 181 |
)
|
| 182 |
page_json_files[page_number] = page_file
|
| 183 |
+
page_statuses[str(page_number)] = "done"
|
| 184 |
+
jobs.update(
|
| 185 |
+
job_id,
|
| 186 |
+
completed_pages=len(page_json_files),
|
| 187 |
+
page_statuses=page_statuses,
|
| 188 |
+
latest_screenshot=str(screenshot_path) if screenshot_path.exists() else None,
|
| 189 |
+
)
|
| 190 |
jobs.update(
|
| 191 |
job_id,
|
| 192 |
completed_batches=index,
|
| 193 |
+
active_tabs=0,
|
| 194 |
message=f"Saved page wave {index} of {len(batches)}",
|
| 195 |
progress=15 + int((index / max(len(batches), 1)) * 70),
|
| 196 |
)
|
|
|
|
| 205 |
progress=100,
|
| 206 |
output_file=str(final_json),
|
| 207 |
current_pages=None,
|
| 208 |
+
active_tabs=0,
|
| 209 |
data_preview=combined["data"][:20],
|
| 210 |
)
|
| 211 |
except Exception as exc:
|
app/main.py
CHANGED
|
@@ -6,6 +6,7 @@ from pathlib import Path
|
|
| 6 |
from tempfile import NamedTemporaryFile
|
| 7 |
|
| 8 |
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
|
|
|
| 9 |
from fastapi.responses import FileResponse
|
| 10 |
from fastapi.staticfiles import StaticFiles
|
| 11 |
|
|
@@ -21,14 +22,45 @@ DEFAULT_BATCH_SIZE = int(os.getenv("PARALLEL_TABS", "10"))
|
|
| 21 |
RUNS_DIR.mkdir(exist_ok=True)
|
| 22 |
|
| 23 |
app = FastAPI(title="Bank Statement AI Mode Extractor")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
| 25 |
|
| 26 |
|
| 27 |
@app.get("/")
|
| 28 |
-
def index()
|
|
|
|
|
|
|
| 29 |
return FileResponse(STATIC_DIR / "index.html")
|
| 30 |
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
@app.post("/api/jobs")
|
| 33 |
async def create_job(
|
| 34 |
pdf: UploadFile = File(...),
|
|
@@ -59,6 +91,25 @@ def get_job(job_id: str) -> dict:
|
|
| 59 |
return asdict(job)
|
| 60 |
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
@app.get("/api/jobs/{job_id}/download")
|
| 63 |
def download_job(job_id: str) -> FileResponse:
|
| 64 |
job = jobs.get(job_id)
|
|
|
|
| 6 |
from tempfile import NamedTemporaryFile
|
| 7 |
|
| 8 |
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
| 9 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 10 |
from fastapi.responses import FileResponse
|
| 11 |
from fastapi.staticfiles import StaticFiles
|
| 12 |
|
|
|
|
| 22 |
RUNS_DIR.mkdir(exist_ok=True)
|
| 23 |
|
| 24 |
app = FastAPI(title="Bank Statement AI Mode Extractor")
|
| 25 |
+
app.add_middleware(
|
| 26 |
+
CORSMiddleware,
|
| 27 |
+
allow_origins=[
|
| 28 |
+
origin.strip()
|
| 29 |
+
for origin in os.getenv("CORS_ORIGINS", "*").split(",")
|
| 30 |
+
if origin.strip()
|
| 31 |
+
],
|
| 32 |
+
allow_credentials=False,
|
| 33 |
+
allow_methods=["*"],
|
| 34 |
+
allow_headers=["*"],
|
| 35 |
+
)
|
| 36 |
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
| 37 |
|
| 38 |
|
| 39 |
@app.get("/")
|
| 40 |
+
def index():
|
| 41 |
+
if os.getenv("API_ONLY", "false").strip().lower() in {"1", "true", "yes"}:
|
| 42 |
+
return FileResponse(STATIC_DIR / "backend.html")
|
| 43 |
return FileResponse(STATIC_DIR / "index.html")
|
| 44 |
|
| 45 |
|
| 46 |
+
@app.get("/api/info")
|
| 47 |
+
def api_info() -> dict[str, str]:
|
| 48 |
+
return {
|
| 49 |
+
"name": "Bank Statement AI Mode Extractor API",
|
| 50 |
+
"health": "/api/health",
|
| 51 |
+
"create_job": "POST /api/jobs with multipart fields pdf and optional pwd",
|
| 52 |
+
"job_status": "/api/jobs/{job_id}",
|
| 53 |
+
"latest_browser_view": "/api/jobs/{job_id}/browser-view/latest",
|
| 54 |
+
"download_json": "/api/jobs/{job_id}/download",
|
| 55 |
+
"download_csv": "/api/jobs/{job_id}/download.csv",
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@app.get("/api/health")
|
| 60 |
+
def health() -> dict[str, str]:
|
| 61 |
+
return {"status": "ok"}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
@app.post("/api/jobs")
|
| 65 |
async def create_job(
|
| 66 |
pdf: UploadFile = File(...),
|
|
|
|
| 91 |
return asdict(job)
|
| 92 |
|
| 93 |
|
| 94 |
+
@app.get("/api/jobs/{job_id}/browser-view/latest")
|
| 95 |
+
def latest_browser_view(job_id: str) -> FileResponse:
|
| 96 |
+
job = jobs.get(job_id)
|
| 97 |
+
if not job or not job.latest_screenshot:
|
| 98 |
+
raise HTTPException(status_code=404, detail="Browser screenshot is not ready.")
|
| 99 |
+
return FileResponse(job.latest_screenshot, media_type="image/png")
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@app.get("/api/jobs/{job_id}/browser-view/page/{page_number}")
|
| 103 |
+
def page_browser_view(job_id: str, page_number: int) -> FileResponse:
|
| 104 |
+
job = jobs.get(job_id)
|
| 105 |
+
if not job or not job.run_dir:
|
| 106 |
+
raise HTTPException(status_code=404, detail="Job not found.")
|
| 107 |
+
screenshot = Path(job.run_dir) / "browser-view" / f"page-{page_number:04d}.png"
|
| 108 |
+
if not screenshot.exists():
|
| 109 |
+
raise HTTPException(status_code=404, detail="Browser screenshot is not ready.")
|
| 110 |
+
return FileResponse(screenshot, media_type="image/png")
|
| 111 |
+
|
| 112 |
+
|
| 113 |
@app.get("/api/jobs/{job_id}/download")
|
| 114 |
def download_job(job_id: str) -> FileResponse:
|
| 115 |
job = jobs.get(job_id)
|