Spaces:
Sleeping
Restructure extraction into tidy tables, scoped fields, and an audit trail
Browse files- Split output into two tidy (one-fact-per-row) tables instead of one
wide sparse CSV: extracted fields and notification hits, plus a third
run-metadata CSV documenting the exact settings (categories, keywords,
custom fields, match filter, chat model/transcript) that produced the
run - a lineage record for auditing why data was or wasn't pulled.
- Standard field extraction (PO/SAP/invoice/cert codes) is now scoped to
the selected categories via CATEGORY_FIELD_MAP instead of always
running every pattern on every document.
- Added a generic label->value custom field extractor (extract_custom_fields)
so users can name arbitrary fields to pull as data (e.g. "reference
number", "gross weight") separate from notification keywords, which
only raise a flag. The chat assistant now configures both.
- README.md +49 -14
- app.py +137 -76
- chat_config.py +47 -26
- extractors.py +78 -3
|
@@ -23,25 +23,59 @@ and key fields out of paper-heavy workflows:
|
|
| 23 |
- **Business finance** — past-due invoices, payment rejections, credit
|
| 24 |
holds, invoice numbers, dollar amounts, due dates.
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
## How it works
|
| 27 |
|
| 28 |
1. **Tailor extraction (chat tab)** — describe in plain language what you
|
| 29 |
-
want flagged
|
| 30 |
-
and non-compliance notices, and pull
|
| 31 |
-
Face-hosted chat model turns that into
|
| 32 |
-
(categories,
|
| 33 |
-
Upload & Run tab automatically. You
|
| 34 |
-
|
| 35 |
2. Upload one or more PDFs or images.
|
| 36 |
3. Each page's text is pulled directly from the PDF when it has a text
|
| 37 |
layer; scanned/image-only pages are rendered and run through
|
| 38 |
Tesseract OCR.
|
| 39 |
-
4. The text is scanned
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
## Chat assistant setup
|
| 47 |
|
|
@@ -74,7 +108,8 @@ python app.py
|
|
| 74 |
|
| 75 |
- `app.py` — Gradio UI and orchestration.
|
| 76 |
- `ocr_engine.py` — PDF/image loading and OCR (PyMuPDF + Tesseract).
|
| 77 |
-
- `extractors.py` — keyword notification scanning
|
| 78 |
-
|
|
|
|
| 79 |
- `chat_config.py` — chat assistant that turns natural-language
|
| 80 |
instructions into extraction settings via a Hugging Face chat model.
|
|
|
|
| 23 |
- **Business finance** — past-due invoices, payment rejections, credit
|
| 24 |
holds, invoice numbers, dollar amounts, due dates.
|
| 25 |
|
| 26 |
+
## Notifications vs. fields
|
| 27 |
+
|
| 28 |
+
The tool treats two kinds of requests differently, rather than running
|
| 29 |
+
one blanket extraction pass over every document:
|
| 30 |
+
|
| 31 |
+
- **Notifications** — keyword/phrase hits that flag an event (a boolean
|
| 32 |
+
"this happened on this page"), e.g. "past due", "non-compliant",
|
| 33 |
+
"change order". Each category (construction/compliance/finance/general)
|
| 34 |
+
has its own built-in phrase list; you can add your own custom
|
| 35 |
+
notification keywords too.
|
| 36 |
+
- **Fields** — labeled values extracted as data, not just flagged.
|
| 37 |
+
Standard fields are *scoped to the categories you select* (see
|
| 38 |
+
`CATEGORY_FIELD_MAP` in `extractors.py`) so a compliance document
|
| 39 |
+
doesn't generate empty invoice-number columns: construction gets PO/SAP
|
| 40 |
+
numbers, compliance gets certification codes, finance gets invoice
|
| 41 |
+
numbers/percentages, and dates/amounts apply where relevant. On top of
|
| 42 |
+
that, you can name your own **custom fields** — any label that appears
|
| 43 |
+
in the document followed by a value (e.g. "reference number", "gross
|
| 44 |
+
weight", "delivery date") — and the app finds the label and captures
|
| 45 |
+
what follows it.
|
| 46 |
+
|
| 47 |
## How it works
|
| 48 |
|
| 49 |
1. **Tailor extraction (chat tab)** — describe in plain language what you
|
| 50 |
+
want flagged vs. pulled as data (e.g. *"flag overdue invoices over
|
| 51 |
+
$10k and non-compliance notices, and pull the reference number and
|
| 52 |
+
gross weight"*). A Hugging Face-hosted chat model turns that into
|
| 53 |
+
extraction settings (categories, notification keywords, custom fields,
|
| 54 |
+
export filter), which populate the Upload & Run tab automatically. You
|
| 55 |
+
can also skip the chat and set everything by hand there.
|
| 56 |
2. Upload one or more PDFs or images.
|
| 57 |
3. Each page's text is pulled directly from the PDF when it has a text
|
| 58 |
layer; scanned/image-only pages are rendered and run through
|
| 59 |
Tesseract OCR.
|
| 60 |
+
4. The text is scanned for notification keywords and both standard and
|
| 61 |
+
custom fields, per the scoping above.
|
| 62 |
+
5. Results are shown as two tidy (one-fact-per-row) tables — extracted
|
| 63 |
+
fields and notification hits — and exported as three CSVs:
|
| 64 |
+
- `ocr_fields_*.csv` — file, page, field_type (standard/custom),
|
| 65 |
+
field_name, field_value, OCR source/confidence.
|
| 66 |
+
- `ocr_notifications_*.csv` — file, page, category, keyword, context,
|
| 67 |
+
OCR source/confidence.
|
| 68 |
+
- `ocr_run_metadata_*.csv` — an audit record of exactly what produced
|
| 69 |
+
the run: categories, notification keywords, custom fields, the
|
| 70 |
+
match filter, the chat model used, and the full chat transcript.
|
| 71 |
+
This is the lineage trail for anyone auditing why a given row was
|
| 72 |
+
(or wasn't) extracted.
|
| 73 |
+
|
| 74 |
+
Long/tidy tables were chosen over one wide fixed-schema table so that
|
| 75 |
+
irrelevant fields never show up as blank columns, and so the output
|
| 76 |
+
loads cleanly into a database, pivot table, or BI tool without
|
| 77 |
+
reshaping — consistent with standard data-quality practice (e.g. DAMA
|
| 78 |
+
DMBOK's completeness, validity, and lineage dimensions).
|
| 79 |
|
| 80 |
## Chat assistant setup
|
| 81 |
|
|
|
|
| 108 |
|
| 109 |
- `app.py` — Gradio UI and orchestration.
|
| 110 |
- `ocr_engine.py` — PDF/image loading and OCR (PyMuPDF + Tesseract).
|
| 111 |
+
- `extractors.py` — keyword notification scanning, category-scoped
|
| 112 |
+
standard field extraction, and generic label->value custom field
|
| 113 |
+
extraction.
|
| 114 |
- `chat_config.py` — chat assistant that turns natural-language
|
| 115 |
instructions into extraction settings via a Hugging Face chat model.
|
|
@@ -2,11 +2,15 @@
|
|
| 2 |
|
| 3 |
Upload scanned PDFs or images (construction reports, compliance
|
| 4 |
certificates, invoices, etc.), scan the extracted text for domain
|
| 5 |
-
"notification" keywords, pull out
|
| 6 |
-
|
|
|
|
|
|
|
| 7 |
"""
|
|
|
|
| 8 |
import os
|
| 9 |
import tempfile
|
|
|
|
| 10 |
from datetime import datetime
|
| 11 |
|
| 12 |
import gradio as gr
|
|
@@ -21,14 +25,21 @@ CATEGORY_CHOICES = chat_config.CATEGORY_CHOICES
|
|
| 21 |
DEFAULT_SETTINGS = {
|
| 22 |
"categories": CATEGORY_CHOICES,
|
| 23 |
"custom_keywords": [],
|
|
|
|
| 24 |
"only_matches": True,
|
| 25 |
}
|
| 26 |
|
| 27 |
-
|
| 28 |
-
"file_name", "page", "
|
| 29 |
-
"
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
]
|
| 33 |
|
| 34 |
|
|
@@ -36,22 +47,41 @@ def _file_path(f):
|
|
| 36 |
return f if isinstance(f, str) else f.name
|
| 37 |
|
| 38 |
|
| 39 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
if not files:
|
| 41 |
-
return "Upload at least one PDF or image file.",
|
| 42 |
|
| 43 |
-
custom_keywords =
|
| 44 |
-
|
| 45 |
-
custom_keywords = [k.strip() for k in custom_keywords_str.split(",") if k.strip()]
|
| 46 |
|
| 47 |
-
|
|
|
|
|
|
|
| 48 |
full_text_log = []
|
| 49 |
warnings = []
|
| 50 |
total_pages = 0
|
|
|
|
| 51 |
|
| 52 |
for f in files:
|
| 53 |
path = _file_path(f)
|
| 54 |
file_name = os.path.basename(path)
|
|
|
|
| 55 |
try:
|
| 56 |
pages = ocr_engine.extract_pages(path)
|
| 57 |
except Exception as exc:
|
|
@@ -61,57 +91,78 @@ def process_files(files, categories, custom_keywords_str, only_matches):
|
|
| 61 |
for page_data in pages:
|
| 62 |
total_pages += 1
|
| 63 |
text = page_data["text"]
|
|
|
|
|
|
|
|
|
|
| 64 |
notifications = extractors.find_notifications(text, categories, custom_keywords)
|
| 65 |
-
|
| 66 |
-
|
| 67 |
|
| 68 |
-
full_text_log.append(
|
| 69 |
-
f"--- {file_name} | page {page_data['page']} | {page_data['source']} ---\n{text}\n"
|
| 70 |
-
)
|
| 71 |
|
|
|
|
| 72 |
if only_matches and not has_content:
|
| 73 |
continue
|
| 74 |
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
for n in notifications:
|
| 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 |
-
if not df.empty:
|
| 102 |
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 103 |
-
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
status_lines = [
|
| 107 |
f"Processed {len(files)} file(s), {total_pages} page(s) total.",
|
| 108 |
-
f"{len(
|
|
|
|
| 109 |
]
|
| 110 |
if warnings:
|
| 111 |
status_lines.append("Warnings: " + "; ".join(warnings))
|
| 112 |
status = "\n".join(status_lines)
|
| 113 |
|
| 114 |
-
return status,
|
| 115 |
|
| 116 |
|
| 117 |
def handle_chat(history, message, settings, hf_token):
|
|
@@ -119,7 +170,8 @@ def handle_chat(history, message, settings, hf_token):
|
|
| 119 |
if not message or not message.strip():
|
| 120 |
return (
|
| 121 |
history, "", settings, settings,
|
| 122 |
-
settings["categories"], ", ".join(settings["custom_keywords"]),
|
|
|
|
| 123 |
)
|
| 124 |
|
| 125 |
reply, new_settings = chat_config.chat_update(history, message.strip(), settings, hf_token)
|
|
@@ -127,10 +179,10 @@ def handle_chat(history, message, settings, hf_token):
|
|
| 127 |
{"role": "user", "content": message.strip()},
|
| 128 |
{"role": "assistant", "content": reply},
|
| 129 |
]
|
| 130 |
-
custom_kw_str = ", ".join(new_settings["custom_keywords"])
|
| 131 |
return (
|
| 132 |
history, "", new_settings, new_settings,
|
| 133 |
-
new_settings["categories"],
|
|
|
|
| 134 |
)
|
| 135 |
|
| 136 |
|
|
@@ -139,19 +191,23 @@ with gr.Blocks(title="OCR Notification & Data Extractor") as demo:
|
|
| 139 |
"# OCR Notification & Data Extractor\n"
|
| 140 |
"Upload scanned PDFs or images (construction reports, compliance "
|
| 141 |
"certificates, invoices, SAP printouts, etc.). The app OCRs each "
|
| 142 |
-
"page, flags notification-worthy keywords per domain,
|
| 143 |
-
"
|
| 144 |
-
"
|
|
|
|
|
|
|
|
|
|
| 145 |
)
|
| 146 |
|
| 147 |
settings_state = gr.State(dict(DEFAULT_SETTINGS))
|
| 148 |
|
| 149 |
with gr.Tab("1. Tailor extraction (chat)"):
|
| 150 |
gr.Markdown(
|
| 151 |
-
"Describe what you want flagged
|
| 152 |
-
"e.g. *\"
|
| 153 |
-
"notices, and pull
|
| 154 |
-
"
|
|
|
|
| 155 |
"hand there too."
|
| 156 |
)
|
| 157 |
hf_token_input = gr.Textbox(
|
|
@@ -163,7 +219,7 @@ with gr.Blocks(title="OCR Notification & Data Extractor") as demo:
|
|
| 163 |
with gr.Row():
|
| 164 |
chat_input = gr.Textbox(
|
| 165 |
label="Message", scale=4,
|
| 166 |
-
placeholder="e.g.
|
| 167 |
)
|
| 168 |
chat_send = gr.Button("Send", scale=1, variant="primary")
|
| 169 |
settings_display = gr.JSON(label="Active extraction settings", value=DEFAULT_SETTINGS)
|
|
@@ -179,10 +235,17 @@ with gr.Blocks(title="OCR Notification & Data Extractor") as demo:
|
|
| 179 |
choices=CATEGORY_CHOICES,
|
| 180 |
value=CATEGORY_CHOICES,
|
| 181 |
label="Notification categories",
|
|
|
|
| 182 |
)
|
| 183 |
custom_keywords_input = gr.Textbox(
|
| 184 |
-
label="Custom keywords (comma-separated
|
| 185 |
placeholder="e.g. re-inspection, warranty claim, hold payment",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
)
|
| 187 |
only_matches_input = gr.Checkbox(
|
| 188 |
value=True,
|
|
@@ -192,28 +255,26 @@ with gr.Blocks(title="OCR Notification & Data Extractor") as demo:
|
|
| 192 |
|
| 193 |
with gr.Column(scale=2):
|
| 194 |
status_output = gr.Textbox(label="Status", lines=3, interactive=False)
|
| 195 |
-
|
| 196 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
with gr.Accordion("Full OCR text (debug)", open=False):
|
| 198 |
text_output = gr.Textbox(label="Raw extracted text", lines=20)
|
| 199 |
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
)
|
| 206 |
-
chat_input.submit(
|
| 207 |
-
fn=handle_chat,
|
| 208 |
-
inputs=[chatbot, chat_input, settings_state, hf_token_input],
|
| 209 |
-
outputs=[chatbot, chat_input, settings_state, settings_display,
|
| 210 |
-
categories_input, custom_keywords_input, only_matches_input],
|
| 211 |
-
)
|
| 212 |
|
| 213 |
run_button.click(
|
| 214 |
fn=process_files,
|
| 215 |
-
inputs=[files_input, categories_input, custom_keywords_input, only_matches_input],
|
| 216 |
-
outputs=[status_output,
|
| 217 |
)
|
| 218 |
|
| 219 |
if __name__ == "__main__":
|
|
|
|
| 2 |
|
| 3 |
Upload scanned PDFs or images (construction reports, compliance
|
| 4 |
certificates, invoices, etc.), scan the extracted text for domain
|
| 5 |
+
"notification" keywords, pull out labeled data fields (standard IDs/dates/
|
| 6 |
+
amounts plus any custom labels the user names), and export everything to
|
| 7 |
+
CSV as tidy (one-fact-per-row) tables, alongside an audit record of the
|
| 8 |
+
settings that produced the run.
|
| 9 |
"""
|
| 10 |
+
import json
|
| 11 |
import os
|
| 12 |
import tempfile
|
| 13 |
+
import uuid
|
| 14 |
from datetime import datetime
|
| 15 |
|
| 16 |
import gradio as gr
|
|
|
|
| 25 |
DEFAULT_SETTINGS = {
|
| 26 |
"categories": CATEGORY_CHOICES,
|
| 27 |
"custom_keywords": [],
|
| 28 |
+
"custom_fields": [],
|
| 29 |
"only_matches": True,
|
| 30 |
}
|
| 31 |
|
| 32 |
+
FIELDS_COLUMNS = [
|
| 33 |
+
"run_id", "file_name", "page", "field_type", "field_name", "field_value",
|
| 34 |
+
"text_source", "ocr_confidence",
|
| 35 |
+
]
|
| 36 |
+
NOTIFICATIONS_COLUMNS = [
|
| 37 |
+
"run_id", "file_name", "page", "category", "keyword", "context",
|
| 38 |
+
"text_source", "ocr_confidence",
|
| 39 |
+
]
|
| 40 |
+
METADATA_COLUMNS = [
|
| 41 |
+
"run_id", "timestamp", "files", "categories", "custom_keywords",
|
| 42 |
+
"custom_fields", "only_matches_filter", "chat_model", "chat_transcript",
|
| 43 |
]
|
| 44 |
|
| 45 |
|
|
|
|
| 47 |
return f if isinstance(f, str) else f.name
|
| 48 |
|
| 49 |
|
| 50 |
+
def _parse_comma_list(s):
|
| 51 |
+
return [k.strip() for k in s.split(",") if k.strip()] if s and s.strip() else []
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _format_transcript(history):
|
| 55 |
+
if not history:
|
| 56 |
+
return ""
|
| 57 |
+
lines = []
|
| 58 |
+
for turn in history:
|
| 59 |
+
role = turn.get("role", "?")
|
| 60 |
+
content = turn.get("content", "")
|
| 61 |
+
lines.append(f"{role}: {content}")
|
| 62 |
+
return "\n".join(lines)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def process_files(files, categories, custom_keywords_str, custom_fields_str, only_matches, chat_history):
|
| 66 |
+
empty = (pd.DataFrame(columns=FIELDS_COLUMNS), pd.DataFrame(columns=NOTIFICATIONS_COLUMNS))
|
| 67 |
if not files:
|
| 68 |
+
return "Upload at least one PDF or image file.", empty[0], empty[1], None, "", {}
|
| 69 |
|
| 70 |
+
custom_keywords = _parse_comma_list(custom_keywords_str)
|
| 71 |
+
custom_field_labels = _parse_comma_list(custom_fields_str)
|
|
|
|
| 72 |
|
| 73 |
+
run_id = uuid.uuid4().hex[:8]
|
| 74 |
+
field_rows = []
|
| 75 |
+
notification_rows = []
|
| 76 |
full_text_log = []
|
| 77 |
warnings = []
|
| 78 |
total_pages = 0
|
| 79 |
+
file_names = []
|
| 80 |
|
| 81 |
for f in files:
|
| 82 |
path = _file_path(f)
|
| 83 |
file_name = os.path.basename(path)
|
| 84 |
+
file_names.append(file_name)
|
| 85 |
try:
|
| 86 |
pages = ocr_engine.extract_pages(path)
|
| 87 |
except Exception as exc:
|
|
|
|
| 91 |
for page_data in pages:
|
| 92 |
total_pages += 1
|
| 93 |
text = page_data["text"]
|
| 94 |
+
source = page_data["source"]
|
| 95 |
+
confidence = page_data["ocr_confidence"]
|
| 96 |
+
|
| 97 |
notifications = extractors.find_notifications(text, categories, custom_keywords)
|
| 98 |
+
std_fields = extractors.extract_fields(text, categories)
|
| 99 |
+
custom_field_values = extractors.extract_custom_fields(text, custom_field_labels)
|
| 100 |
|
| 101 |
+
full_text_log.append(f"--- {file_name} | page {page_data['page']} | {source} ---\n{text}\n")
|
|
|
|
|
|
|
| 102 |
|
| 103 |
+
has_content = bool(notifications) or any(std_fields.values()) or any(custom_field_values.values())
|
| 104 |
if only_matches and not has_content:
|
| 105 |
continue
|
| 106 |
|
| 107 |
+
for field_name, values in std_fields.items():
|
| 108 |
+
for value in values:
|
| 109 |
+
field_rows.append({
|
| 110 |
+
"run_id": run_id, "file_name": file_name, "page": page_data["page"],
|
| 111 |
+
"field_type": "standard", "field_name": field_name, "field_value": value,
|
| 112 |
+
"text_source": source, "ocr_confidence": confidence,
|
| 113 |
+
})
|
| 114 |
+
for field_name, values in custom_field_values.items():
|
| 115 |
+
for value in values:
|
| 116 |
+
field_rows.append({
|
| 117 |
+
"run_id": run_id, "file_name": file_name, "page": page_data["page"],
|
| 118 |
+
"field_type": "custom", "field_name": field_name, "field_value": value,
|
| 119 |
+
"text_source": source, "ocr_confidence": confidence,
|
| 120 |
+
})
|
| 121 |
for n in notifications:
|
| 122 |
+
notification_rows.append({
|
| 123 |
+
"run_id": run_id, "file_name": file_name, "page": page_data["page"],
|
| 124 |
+
"category": n["category"], "keyword": n["keyword"], "context": n["context"],
|
| 125 |
+
"text_source": source, "ocr_confidence": confidence,
|
| 126 |
+
})
|
| 127 |
+
|
| 128 |
+
fields_df = pd.DataFrame(field_rows, columns=FIELDS_COLUMNS)
|
| 129 |
+
notifications_df = pd.DataFrame(notification_rows, columns=NOTIFICATIONS_COLUMNS)
|
| 130 |
+
|
| 131 |
+
metadata_row = {
|
| 132 |
+
"run_id": run_id,
|
| 133 |
+
"timestamp": datetime.now().isoformat(timespec="seconds"),
|
| 134 |
+
"files": "; ".join(file_names),
|
| 135 |
+
"categories": "; ".join(categories),
|
| 136 |
+
"custom_keywords": "; ".join(custom_keywords),
|
| 137 |
+
"custom_fields": "; ".join(custom_field_labels),
|
| 138 |
+
"only_matches_filter": only_matches,
|
| 139 |
+
"chat_model": chat_config.DEFAULT_MODEL if chat_history else "",
|
| 140 |
+
"chat_transcript": _format_transcript(chat_history),
|
| 141 |
+
}
|
| 142 |
+
metadata_df = pd.DataFrame([metadata_row], columns=METADATA_COLUMNS)
|
| 143 |
+
|
| 144 |
+
csv_paths = []
|
| 145 |
+
if not fields_df.empty or not notifications_df.empty:
|
|
|
|
| 146 |
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 147 |
+
tmp_dir = tempfile.gettempdir()
|
| 148 |
+
fields_path = os.path.join(tmp_dir, f"ocr_fields_{timestamp}_{run_id}.csv")
|
| 149 |
+
notifications_path = os.path.join(tmp_dir, f"ocr_notifications_{timestamp}_{run_id}.csv")
|
| 150 |
+
metadata_path = os.path.join(tmp_dir, f"ocr_run_metadata_{timestamp}_{run_id}.csv")
|
| 151 |
+
fields_df.to_csv(fields_path, index=False)
|
| 152 |
+
notifications_df.to_csv(notifications_path, index=False)
|
| 153 |
+
metadata_df.to_csv(metadata_path, index=False)
|
| 154 |
+
csv_paths = [fields_path, notifications_path, metadata_path]
|
| 155 |
|
| 156 |
status_lines = [
|
| 157 |
f"Processed {len(files)} file(s), {total_pages} page(s) total.",
|
| 158 |
+
f"{len(fields_df)} field value(s), {len(notifications_df)} notification hit(s)"
|
| 159 |
+
+ (" (matches-only pages)." if only_matches else " (all pages)."),
|
| 160 |
]
|
| 161 |
if warnings:
|
| 162 |
status_lines.append("Warnings: " + "; ".join(warnings))
|
| 163 |
status = "\n".join(status_lines)
|
| 164 |
|
| 165 |
+
return status, fields_df, notifications_df, csv_paths, "\n".join(full_text_log), metadata_row
|
| 166 |
|
| 167 |
|
| 168 |
def handle_chat(history, message, settings, hf_token):
|
|
|
|
| 170 |
if not message or not message.strip():
|
| 171 |
return (
|
| 172 |
history, "", settings, settings,
|
| 173 |
+
settings["categories"], ", ".join(settings["custom_keywords"]),
|
| 174 |
+
", ".join(settings["custom_fields"]), settings["only_matches"],
|
| 175 |
)
|
| 176 |
|
| 177 |
reply, new_settings = chat_config.chat_update(history, message.strip(), settings, hf_token)
|
|
|
|
| 179 |
{"role": "user", "content": message.strip()},
|
| 180 |
{"role": "assistant", "content": reply},
|
| 181 |
]
|
|
|
|
| 182 |
return (
|
| 183 |
history, "", new_settings, new_settings,
|
| 184 |
+
new_settings["categories"], ", ".join(new_settings["custom_keywords"]),
|
| 185 |
+
", ".join(new_settings["custom_fields"]), new_settings["only_matches"],
|
| 186 |
)
|
| 187 |
|
| 188 |
|
|
|
|
| 191 |
"# OCR Notification & Data Extractor\n"
|
| 192 |
"Upload scanned PDFs or images (construction reports, compliance "
|
| 193 |
"certificates, invoices, SAP printouts, etc.). The app OCRs each "
|
| 194 |
+
"page, flags notification-worthy keywords per domain, and pulls out "
|
| 195 |
+
"labeled data fields - standard ones scoped to the categories you "
|
| 196 |
+
"pick, plus any custom labels you name (e.g. \"gross weight\", "
|
| 197 |
+
"\"delivery date\"). Every run exports three tidy CSVs: extracted "
|
| 198 |
+
"fields, notification hits, and a metadata/audit record of exactly "
|
| 199 |
+
"what settings produced them."
|
| 200 |
)
|
| 201 |
|
| 202 |
settings_state = gr.State(dict(DEFAULT_SETTINGS))
|
| 203 |
|
| 204 |
with gr.Tab("1. Tailor extraction (chat)"):
|
| 205 |
gr.Markdown(
|
| 206 |
+
"Describe what you want **flagged** (notifications) vs. **pulled "
|
| 207 |
+
"as data** (fields), in plain language, e.g. *\"flag overdue "
|
| 208 |
+
"invoices and non-compliance notices, and pull the reference "
|
| 209 |
+
"number, gross weight, and delivery date\"*. This updates the "
|
| 210 |
+
"settings used in **Upload & Run** - you can still edit them by "
|
| 211 |
"hand there too."
|
| 212 |
)
|
| 213 |
hf_token_input = gr.Textbox(
|
|
|
|
| 219 |
with gr.Row():
|
| 220 |
chat_input = gr.Textbox(
|
| 221 |
label="Message", scale=4,
|
| 222 |
+
placeholder="e.g. Flag overdue invoices; pull reference number, gross weight, delivery date",
|
| 223 |
)
|
| 224 |
chat_send = gr.Button("Send", scale=1, variant="primary")
|
| 225 |
settings_display = gr.JSON(label="Active extraction settings", value=DEFAULT_SETTINGS)
|
|
|
|
| 235 |
choices=CATEGORY_CHOICES,
|
| 236 |
value=CATEGORY_CHOICES,
|
| 237 |
label="Notification categories",
|
| 238 |
+
info="Also scopes which standard fields (PO/SAP/invoice/cert) get extracted - see CATEGORY_FIELD_MAP.",
|
| 239 |
)
|
| 240 |
custom_keywords_input = gr.Textbox(
|
| 241 |
+
label="Custom notification keywords (comma-separated)",
|
| 242 |
placeholder="e.g. re-inspection, warranty claim, hold payment",
|
| 243 |
+
info="Phrases that just raise a flag - not extracted as values.",
|
| 244 |
+
)
|
| 245 |
+
custom_fields_input = gr.Textbox(
|
| 246 |
+
label="Custom fields to extract as data (comma-separated labels)",
|
| 247 |
+
placeholder="e.g. reference number, gross weight, delivery date",
|
| 248 |
+
info="Labels looked up in the document; the value after each label is captured.",
|
| 249 |
)
|
| 250 |
only_matches_input = gr.Checkbox(
|
| 251 |
value=True,
|
|
|
|
| 255 |
|
| 256 |
with gr.Column(scale=2):
|
| 257 |
status_output = gr.Textbox(label="Status", lines=3, interactive=False)
|
| 258 |
+
fields_table_output = gr.Dataframe(label="Extracted fields (tidy)", headers=FIELDS_COLUMNS, wrap=True)
|
| 259 |
+
notifications_table_output = gr.Dataframe(
|
| 260 |
+
label="Notification hits (tidy)", headers=NOTIFICATIONS_COLUMNS, wrap=True
|
| 261 |
+
)
|
| 262 |
+
csv_output = gr.Files(label="Download CSVs (fields, notifications, run metadata)")
|
| 263 |
+
run_metadata_output = gr.JSON(label="This run's settings (audit record)")
|
| 264 |
with gr.Accordion("Full OCR text (debug)", open=False):
|
| 265 |
text_output = gr.Textbox(label="Raw extracted text", lines=20)
|
| 266 |
|
| 267 |
+
chat_outputs = [
|
| 268 |
+
chatbot, chat_input, settings_state, settings_display,
|
| 269 |
+
categories_input, custom_keywords_input, custom_fields_input, only_matches_input,
|
| 270 |
+
]
|
| 271 |
+
chat_send.click(fn=handle_chat, inputs=[chatbot, chat_input, settings_state, hf_token_input], outputs=chat_outputs)
|
| 272 |
+
chat_input.submit(fn=handle_chat, inputs=[chatbot, chat_input, settings_state, hf_token_input], outputs=chat_outputs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
|
| 274 |
run_button.click(
|
| 275 |
fn=process_files,
|
| 276 |
+
inputs=[files_input, categories_input, custom_keywords_input, custom_fields_input, only_matches_input, chatbot],
|
| 277 |
+
outputs=[status_output, fields_table_output, notifications_table_output, csv_output, text_output, run_metadata_output],
|
| 278 |
)
|
| 279 |
|
| 280 |
if __name__ == "__main__":
|
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"""Conversational assistant that turns free-form instructions into
|
| 2 |
-
extraction settings (categories,
|
| 3 |
-
Hugging Face-hosted chat model.
|
| 4 |
"""
|
| 5 |
import json
|
| 6 |
import os
|
|
@@ -16,38 +16,55 @@ DEFAULT_MODEL = os.environ.get("HF_CHAT_MODEL", "HuggingFaceH4/zephyr-7b-beta")
|
|
| 16 |
|
| 17 |
SYSTEM_PROMPT = f"""You are a configuration assistant for a document OCR/data-extraction tool.
|
| 18 |
|
| 19 |
-
The tool
|
| 20 |
-
|
| 21 |
-
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
1. A short (1-3 sentence) conversational reply confirming what you changed,
|
| 36 |
or asking a clarifying question if the request is ambiguous.
|
| 37 |
2. On its own line, a fenced json block with the FULL updated settings:
|
| 38 |
```json
|
| 39 |
-
{{"categories": ["finance"], "custom_keywords": ["warranty claim"], "only_matches": true}}
|
| 40 |
```
|
| 41 |
|
| 42 |
Rules:
|
| 43 |
- "categories" must only contain values from {CATEGORY_CHOICES}.
|
| 44 |
-
- "custom_keywords" is a list of extra free-text phrases
|
| 45 |
-
|
| 46 |
-
- "
|
| 47 |
-
|
| 48 |
-
-
|
| 49 |
-
|
| 50 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
"""
|
| 52 |
|
| 53 |
|
|
@@ -66,6 +83,7 @@ def _extract_json_block(text):
|
|
| 66 |
def _sanitize_settings(parsed, current_settings):
|
| 67 |
categories = current_settings.get("categories", [])
|
| 68 |
custom_keywords = current_settings.get("custom_keywords", [])
|
|
|
|
| 69 |
only_matches = current_settings.get("only_matches", True)
|
| 70 |
|
| 71 |
if isinstance(parsed, dict):
|
|
@@ -73,12 +91,15 @@ def _sanitize_settings(parsed, current_settings):
|
|
| 73 |
categories = [c for c in parsed["categories"] if c in CATEGORY_CHOICES]
|
| 74 |
if isinstance(parsed.get("custom_keywords"), list):
|
| 75 |
custom_keywords = [str(k).strip() for k in parsed["custom_keywords"] if str(k).strip()]
|
|
|
|
|
|
|
| 76 |
if isinstance(parsed.get("only_matches"), bool):
|
| 77 |
only_matches = parsed["only_matches"]
|
| 78 |
|
| 79 |
return {
|
| 80 |
"categories": categories,
|
| 81 |
"custom_keywords": custom_keywords,
|
|
|
|
| 82 |
"only_matches": only_matches,
|
| 83 |
}
|
| 84 |
|
|
|
|
| 1 |
"""Conversational assistant that turns free-form instructions into
|
| 2 |
+
extraction settings (categories, notification keywords, value fields,
|
| 3 |
+
export filter) using a Hugging Face-hosted chat model.
|
| 4 |
"""
|
| 5 |
import json
|
| 6 |
import os
|
|
|
|
| 16 |
|
| 17 |
SYSTEM_PROMPT = f"""You are a configuration assistant for a document OCR/data-extraction tool.
|
| 18 |
|
| 19 |
+
The tool distinguishes two different kinds of things a user can ask for:
|
| 20 |
+
|
| 21 |
+
1. NOTIFICATIONS - keyword/phrase hits that flag an event worth a reviewer's
|
| 22 |
+
attention (a boolean "this happened somewhere on the page"). Built-in
|
| 23 |
+
category keyword sets:
|
| 24 |
+
- construction: change order, delay notice, punch list, non-conformance,
|
| 25 |
+
RFI, submittal rejected, back charge, stop work, safety violation,
|
| 26 |
+
schedule slip, etc.
|
| 27 |
+
- compliance: non-compliant, violation, deficiency, corrective action,
|
| 28 |
+
audit finding, expired, recall, citation, penalty, failed inspection.
|
| 29 |
+
- finance: past due, overdue, payment rejected, invoice disputed, credit
|
| 30 |
+
hold, chargeback, insufficient funds, write-off, collections, late fee.
|
| 31 |
+
- general: urgent, cancelled, rejected, approved, pending approval, on hold.
|
| 32 |
+
Users can also name their own free-text notification phrases.
|
| 33 |
+
|
| 34 |
+
2. FIELDS - labeled values to pull out of the document as data, not just
|
| 35 |
+
flag. Two kinds:
|
| 36 |
+
- Standard fields, auto-scoped to the categories selected above: PO
|
| 37 |
+
numbers + SAP document numbers (construction), certification codes
|
| 38 |
+
ISO/OSHA/ASTM/ANSI (compliance), invoice numbers + percentages
|
| 39 |
+
(finance), and dates/dollar amounts wherever relevant.
|
| 40 |
+
- Custom fields: any label the user names that appears in the document
|
| 41 |
+
followed by a value, e.g. "reference number", "gross weight",
|
| 42 |
+
"delivery date", "customer PO". These are looked up generically by
|
| 43 |
+
label text, so use the user's own wording for the label.
|
| 44 |
+
|
| 45 |
+
The user will describe in plain language what they want flagged vs. pulled
|
| 46 |
+
as data. Given their message and the current settings, respond with:
|
| 47 |
1. A short (1-3 sentence) conversational reply confirming what you changed,
|
| 48 |
or asking a clarifying question if the request is ambiguous.
|
| 49 |
2. On its own line, a fenced json block with the FULL updated settings:
|
| 50 |
```json
|
| 51 |
+
{{"categories": ["finance"], "custom_keywords": ["warranty claim"], "custom_fields": ["reference number", "gross weight"], "only_matches": true}}
|
| 52 |
```
|
| 53 |
|
| 54 |
Rules:
|
| 55 |
- "categories" must only contain values from {CATEGORY_CHOICES}.
|
| 56 |
+
- "custom_keywords" is a list of extra free-text NOTIFICATION phrases to
|
| 57 |
+
flag (empty list if none requested).
|
| 58 |
+
- "custom_fields" is a list of label names to extract as VALUES, in the
|
| 59 |
+
user's own wording (empty list if none requested).
|
| 60 |
+
- "only_matches" is true if only pages with a hit/field should be
|
| 61 |
+
exported, false to export every page regardless.
|
| 62 |
+
- Always include all four keys, carrying over prior values the user
|
| 63 |
+
didn't ask to change.
|
| 64 |
+
- If the user names something ambiguous, prefer treating it as a
|
| 65 |
+
custom_field when they clearly want a value (a number, date, code) back,
|
| 66 |
+
and as a custom_keyword when they clearly want an alert/flag instead.
|
| 67 |
+
- Never invent a category or capability that isn't listed above.
|
| 68 |
"""
|
| 69 |
|
| 70 |
|
|
|
|
| 83 |
def _sanitize_settings(parsed, current_settings):
|
| 84 |
categories = current_settings.get("categories", [])
|
| 85 |
custom_keywords = current_settings.get("custom_keywords", [])
|
| 86 |
+
custom_fields = current_settings.get("custom_fields", [])
|
| 87 |
only_matches = current_settings.get("only_matches", True)
|
| 88 |
|
| 89 |
if isinstance(parsed, dict):
|
|
|
|
| 91 |
categories = [c for c in parsed["categories"] if c in CATEGORY_CHOICES]
|
| 92 |
if isinstance(parsed.get("custom_keywords"), list):
|
| 93 |
custom_keywords = [str(k).strip() for k in parsed["custom_keywords"] if str(k).strip()]
|
| 94 |
+
if isinstance(parsed.get("custom_fields"), list):
|
| 95 |
+
custom_fields = [str(k).strip() for k in parsed["custom_fields"] if str(k).strip()]
|
| 96 |
if isinstance(parsed.get("only_matches"), bool):
|
| 97 |
only_matches = parsed["only_matches"]
|
| 98 |
|
| 99 |
return {
|
| 100 |
"categories": categories,
|
| 101 |
"custom_keywords": custom_keywords,
|
| 102 |
+
"custom_fields": custom_fields,
|
| 103 |
"only_matches": only_matches,
|
| 104 |
}
|
| 105 |
|
|
@@ -61,7 +61,19 @@ FIELD_PATTERNS = {
|
|
| 61 |
),
|
| 62 |
}
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
CONTEXT_WINDOW = 60
|
|
|
|
| 65 |
|
| 66 |
|
| 67 |
def _dedupe(values):
|
|
@@ -73,10 +85,25 @@ def _dedupe(values):
|
|
| 73 |
return seen
|
| 74 |
|
| 75 |
|
| 76 |
-
def
|
| 77 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
fields = {}
|
| 79 |
-
for name
|
|
|
|
| 80 |
matches = pattern.findall(text)
|
| 81 |
flat = []
|
| 82 |
for m in matches:
|
|
@@ -88,6 +115,54 @@ def extract_fields(text):
|
|
| 88 |
return fields
|
| 89 |
|
| 90 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
def find_notifications(text, categories, custom_keywords=None):
|
| 92 |
"""Scan text for keyword hits across the given categories.
|
| 93 |
|
|
|
|
| 61 |
),
|
| 62 |
}
|
| 63 |
|
| 64 |
+
# Which standard field patterns are relevant to each domain. Extraction is
|
| 65 |
+
# scoped to the categories a user actually selects rather than running every
|
| 66 |
+
# pattern against every document ("blanket" extraction) - a compliance
|
| 67 |
+
# certificate has no business generating empty invoice_number columns.
|
| 68 |
+
CATEGORY_FIELD_MAP = {
|
| 69 |
+
"construction": ["po_numbers", "sap_doc_numbers", "dates", "amounts"],
|
| 70 |
+
"compliance": ["cert_codes", "dates"],
|
| 71 |
+
"finance": ["invoice_numbers", "amounts", "dates", "percentages"],
|
| 72 |
+
"general": ["dates"],
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
CONTEXT_WINDOW = 60
|
| 76 |
+
MAX_CUSTOM_VALUE_LEN = 80
|
| 77 |
|
| 78 |
|
| 79 |
def _dedupe(values):
|
|
|
|
| 85 |
return seen
|
| 86 |
|
| 87 |
|
| 88 |
+
def fields_for_categories(categories):
|
| 89 |
+
"""Union of standard field names relevant to the given categories."""
|
| 90 |
+
names = []
|
| 91 |
+
for cat in categories:
|
| 92 |
+
for name in CATEGORY_FIELD_MAP.get(cat, []):
|
| 93 |
+
if name not in names:
|
| 94 |
+
names.append(name)
|
| 95 |
+
return names
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def extract_fields(text, categories):
|
| 99 |
+
"""Pull the standard fields relevant to `categories` out of a page of text.
|
| 100 |
+
|
| 101 |
+
Returns {field_name: [values]} scoped to only the patterns those
|
| 102 |
+
categories map to - see CATEGORY_FIELD_MAP.
|
| 103 |
+
"""
|
| 104 |
fields = {}
|
| 105 |
+
for name in fields_for_categories(categories):
|
| 106 |
+
pattern = FIELD_PATTERNS[name]
|
| 107 |
matches = pattern.findall(text)
|
| 108 |
flat = []
|
| 109 |
for m in matches:
|
|
|
|
| 115 |
return fields
|
| 116 |
|
| 117 |
|
| 118 |
+
def _postprocess_custom_value(label, raw_value):
|
| 119 |
+
raw_value = raw_value.strip()[:MAX_CUSTOM_VALUE_LEN]
|
| 120 |
+
if "date" in label:
|
| 121 |
+
m = FIELD_PATTERNS["dates"].search(raw_value)
|
| 122 |
+
if m:
|
| 123 |
+
return m.group(0)
|
| 124 |
+
if any(k in label for k in ("weight", "amount", "qty", "quantity", "total", "count", "price")):
|
| 125 |
+
m = re.match(r"[\d,]+\.?\d*\s?[a-zA-Z%]*", raw_value)
|
| 126 |
+
if m and m.group(0).strip():
|
| 127 |
+
return m.group(0).strip()
|
| 128 |
+
# Generic fallback: a handful of tokens, cut before an obvious next column
|
| 129 |
+
# (two-or-more spaces, which OCR/tables often use as a column separator).
|
| 130 |
+
trimmed = re.split(r"\s{2,}", raw_value)[0]
|
| 131 |
+
m = re.match(r"\S+(?:\s\S+){0,4}", trimmed)
|
| 132 |
+
return m.group(0).strip() if m else trimmed.strip()
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def extract_custom_fields(text, field_labels):
|
| 136 |
+
"""Generic label->value extraction for user-defined fields.
|
| 137 |
+
|
| 138 |
+
For labels like "reference number" or "gross weight", finds the label in
|
| 139 |
+
the text and captures whatever follows it on the same line (or the next
|
| 140 |
+
line, if the label sits alone on its own line - common in OCR'd forms).
|
| 141 |
+
Returns {label: [values]}.
|
| 142 |
+
"""
|
| 143 |
+
results = {label: [] for label in field_labels if label.strip()}
|
| 144 |
+
if not results:
|
| 145 |
+
return results
|
| 146 |
+
|
| 147 |
+
lines = text.splitlines()
|
| 148 |
+
for i, line in enumerate(lines):
|
| 149 |
+
lower_line = line.lower()
|
| 150 |
+
for label in results:
|
| 151 |
+
label_clean = label.strip().lower()
|
| 152 |
+
idx = lower_line.find(label_clean)
|
| 153 |
+
if idx == -1:
|
| 154 |
+
continue
|
| 155 |
+
after = line[idx + len(label_clean):]
|
| 156 |
+
after = re.sub(r"^[\s:.\-–—]+", "", after).strip()
|
| 157 |
+
if not after and i + 1 < len(lines):
|
| 158 |
+
after = lines[i + 1].strip()
|
| 159 |
+
if after:
|
| 160 |
+
value = _postprocess_custom_value(label_clean, after)
|
| 161 |
+
if value and value not in results[label]:
|
| 162 |
+
results[label].append(value)
|
| 163 |
+
return results
|
| 164 |
+
|
| 165 |
+
|
| 166 |
def find_notifications(text, categories, custom_keywords=None):
|
| 167 |
"""Scan text for keyword hits across the given categories.
|
| 168 |
|