File size: 6,783 Bytes
bfd6783
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
"""Turn an uploaded file into text and rows.

Deliberately format-agnostic and bank-agnostic: this module only gets the
content *out* of the file. Deciding which column is a date and which is an
amount is :mod:`finbot.ingest`'s job, and is done by the model so that any
bank's export works without a hand-written layout per institution.

Nothing here calls the network, so all of it is unit-testable offline.
"""

from __future__ import annotations

import csv
import io
import logging
from dataclasses import dataclass, field

log = logging.getLogger(__name__)

#: Guard rails. A personal statement is a few hundred rows; anything far past
#: that is either a mistake or would blow the model's context and the user's
#: token budget, so it is truncated with a visible note rather than silently.
MAX_ROWS = 1500
MAX_TEXT_CHARS = 120_000
MAX_PDF_PAGES = 40


@dataclass
class ExtractedDocument:
    kind: str
    text: str = ""
    rows: list[list[str]] = field(default_factory=list)
    pages: int = 0
    truncated: bool = False
    note: str = ""

    @property
    def is_empty(self) -> bool:
        return not self.text.strip() and not self.rows


def _decode(data: bytes) -> str:
    for encoding in ("utf-8-sig", "utf-8", "cp1252", "latin-1"):
        try:
            return data.decode(encoding)
        except UnicodeDecodeError:
            continue
    return data.decode("utf-8", errors="replace")


def _clean_row(row: list) -> list[str]:
    return [("" if c is None else str(c)).strip() for c in row]


def _is_blank(row: list[str]) -> bool:
    return not any(cell for cell in row)


def extract_csv(data: bytes) -> ExtractedDocument:
    text = _decode(data)
    sample = text[:8192]

    try:
        dialect = csv.Sniffer().sniff(sample, delimiters=",;\t|")
    except csv.Error:
        # Sniffing fails on single-column or irregular exports; comma is the
        # safe default and the model tolerates a wrong split better than a crash.
        dialect = csv.excel

    rows: list[list[str]] = []
    truncated = False
    for row in csv.reader(io.StringIO(text), dialect):
        cleaned = _clean_row(row)
        if _is_blank(cleaned):
            continue
        rows.append(cleaned)
        if len(rows) >= MAX_ROWS:
            truncated = True
            break

    return ExtractedDocument(kind="csv", rows=rows, truncated=truncated)


def extract_xlsx(data: bytes) -> ExtractedDocument:
    from openpyxl import load_workbook

    workbook = load_workbook(io.BytesIO(data), read_only=True, data_only=True)
    rows: list[list[str]] = []
    truncated = False

    try:
        for sheet in workbook.worksheets:
            if truncated:
                break
            if len(workbook.worksheets) > 1:
                rows.append([f"# sheet: {sheet.title}"])
            for row in sheet.iter_rows(values_only=True):
                cleaned = _clean_row(list(row))
                if _is_blank(cleaned):
                    continue
                rows.append(cleaned)
                if len(rows) >= MAX_ROWS:
                    truncated = True
                    break
    finally:
        workbook.close()

    return ExtractedDocument(kind="xlsx", rows=rows, truncated=truncated)


def extract_pdf(data: bytes) -> ExtractedDocument:
    """Pull tables (preferred) and text out of a PDF statement.

    Scanned statements contain no text layer. That is detected and reported
    rather than silently returning nothing, because "0 transactions found" and
    "this file is a photograph of a statement" need very different user advice.
    """
    import pdfplumber

    rows: list[list[str]] = []
    text_parts: list[str] = []
    truncated = False
    page_count = 0

    with pdfplumber.open(io.BytesIO(data)) as pdf:
        page_count = len(pdf.pages)
        for index, page in enumerate(pdf.pages):
            if index >= MAX_PDF_PAGES:
                truncated = True
                break

            try:
                for table in page.extract_tables() or []:
                    for row in table:
                        cleaned = _clean_row(list(row))
                        if _is_blank(cleaned):
                            continue
                        rows.append(cleaned)
                        if len(rows) >= MAX_ROWS:
                            truncated = True
                            break
                    if truncated:
                        break
            except Exception as exc:
                log.warning("Table extraction failed on page %d: %s", index + 1, exc)

            try:
                if page_text := page.extract_text():
                    text_parts.append(page_text)
            except Exception as exc:
                log.warning("Text extraction failed on page %d: %s", index + 1, exc)

            if truncated:
                break

    text = "\n".join(text_parts)[:MAX_TEXT_CHARS]
    doc = ExtractedDocument(
        kind="pdf", text=text, rows=rows, pages=page_count, truncated=truncated
    )

    if doc.is_empty:
        doc.note = (
            "This PDF has no selectable text, so it is almost certainly a scan "
            "or photo of a statement. Send it as a photo instead and I'll read "
            "it with the vision model, or export a CSV/XLSX from your bank."
        )
    return doc


def extract(data: bytes, filename: str = "", mime_type: str = "") -> ExtractedDocument:
    """Dispatch on filename extension, falling back to MIME type."""
    name = (filename or "").lower()
    mime = (mime_type or "").lower()

    if name.endswith(".csv") or name.endswith(".tsv") or "csv" in mime:
        return extract_csv(data)
    if name.endswith((".xlsx", ".xlsm", ".xltx")) or "spreadsheet" in mime or "excel" in mime:
        return extract_xlsx(data)
    if name.endswith(".pdf") or "pdf" in mime:
        return extract_pdf(data)
    if name.endswith((".txt", ".md")) or mime.startswith("text/"):
        return ExtractedDocument(kind="text", text=_decode(data)[:MAX_TEXT_CHARS])

    # .xls (old binary format) is not readable by openpyxl -- say so precisely.
    if name.endswith(".xls"):
        return ExtractedDocument(
            kind="unsupported",
            note=(
                "Old .xls files aren't supported. Open it and re-save as .xlsx "
                "or export CSV, then send it again."
            ),
        )

    return ExtractedDocument(
        kind="unsupported",
        note=f"I can't read {filename or 'that file'}. Send a CSV, XLSX, PDF or a photo.",
    )


def rows_to_text(rows: list[list[str]], limit: int = MAX_ROWS) -> str:
    """Render rows as pipe-delimited lines for a model prompt."""
    lines = [" | ".join(cell for cell in row) for row in rows[:limit]]
    return "\n".join(lines)[:MAX_TEXT_CHARS]