Claude commited on
Commit
1d905fa
·
unverified ·
1 Parent(s): a20dd9f

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.

Files changed (4) hide show
  1. README.md +49 -14
  2. app.py +137 -76
  3. chat_config.py +47 -26
  4. extractors.py +78 -3
README.md CHANGED
@@ -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 and pulled (e.g. *"only flag overdue invoices over $10k
30
- and non-compliance notices, and pull SAP PO numbers"*). A Hugging
31
- Face-hosted chat model turns that into extraction settings
32
- (categories, custom keywords, export filter), which populate the
33
- Upload & Run tab automatically. You can also skip the chat and set
34
- these by hand.
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 against keyword sets for the categories you pick
40
- (or your own custom keywords), and structured fields (PO/SAP/invoice
41
- numbers, dates, dollar amounts, percentages, certification codes) are
42
- pulled out with regex.
43
- 5. Results are shown in a table and exported as a CSV, one row per page
44
- that has a hit (or every page, if you turn that filter off).
 
 
 
 
 
 
 
 
 
 
 
 
 
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 and structured field
78
- extraction (regex-based, easy to extend per domain).
 
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.
app.py CHANGED
@@ -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 structured fields (PO/SAP/invoice
6
- numbers, dates, amounts, cert codes), and export everything to CSV.
 
 
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
- COLUMNS = [
28
- "file_name", "page", "text_source", "ocr_confidence",
29
- "notification_flags", "notification_context",
30
- "po_numbers", "sap_doc_numbers", "invoice_numbers", "cert_codes",
31
- "dates", "amounts", "percentages",
 
 
 
 
 
 
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 process_files(files, categories, custom_keywords_str, only_matches):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  if not files:
41
- return "Upload at least one PDF or image file.", pd.DataFrame(columns=COLUMNS), None, ""
42
 
43
- custom_keywords = None
44
- if custom_keywords_str and custom_keywords_str.strip():
45
- custom_keywords = [k.strip() for k in custom_keywords_str.split(",") if k.strip()]
46
 
47
- rows = []
 
 
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
- fields = extractors.extract_fields(text)
66
- has_content = bool(notifications) or any(fields.values())
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
- contexts = []
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  for n in notifications:
77
- if n["context"] not in contexts:
78
- contexts.append(n["context"])
79
-
80
- rows.append({
81
- "file_name": file_name,
82
- "page": page_data["page"],
83
- "text_source": page_data["source"],
84
- "ocr_confidence": page_data["ocr_confidence"],
85
- "notification_flags": "; ".join(
86
- sorted({f"[{n['category']}] {n['keyword']}" for n in notifications})
87
- ),
88
- "notification_context": " | ".join(contexts[:5]),
89
- "po_numbers": "; ".join(fields["po_numbers"]),
90
- "sap_doc_numbers": "; ".join(fields["sap_doc_numbers"]),
91
- "invoice_numbers": "; ".join(fields["invoice_numbers"]),
92
- "cert_codes": "; ".join(fields["cert_codes"]),
93
- "dates": "; ".join(fields["dates"]),
94
- "amounts": "; ".join(fields["amounts"]),
95
- "percentages": "; ".join(fields["percentages"]),
96
- })
97
-
98
- df = pd.DataFrame(rows, columns=COLUMNS)
99
-
100
- csv_path = None
101
- if not df.empty:
102
  timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
103
- csv_path = os.path.join(tempfile.gettempdir(), f"ocr_extract_{timestamp}.csv")
104
- df.to_csv(csv_path, index=False)
 
 
 
 
 
 
105
 
106
  status_lines = [
107
  f"Processed {len(files)} file(s), {total_pages} page(s) total.",
108
- f"{len(df)} row(s) exported" + (" (matches only)." if only_matches else "."),
 
109
  ]
110
  if warnings:
111
  status_lines.append("Warnings: " + "; ".join(warnings))
112
  status = "\n".join(status_lines)
113
 
114
- return status, df, csv_path, "\n".join(full_text_log)
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"]), settings["only_matches"],
 
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"], custom_kw_str, new_settings["only_matches"],
 
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, extracts "
143
- "structured fields (PO/SAP/invoice numbers, dates, amounts, cert "
144
- "codes), and exports everything to CSV."
 
 
 
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 and pulled, in plain language, "
152
- "e.g. *\"Only flag overdue invoices over $10k and non-compliance "
153
- "notices, and pull SAP PO numbers\"*. This updates the settings "
154
- "used in the **Upload & Run** tab you can still edit them by "
 
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. Only flag overdue invoices and pull PO numbers, ignore construction stuff",
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, optional)",
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
- table_output = gr.Dataframe(label="Extracted records", headers=COLUMNS, wrap=True)
196
- csv_output = gr.File(label="Download CSV")
 
 
 
 
197
  with gr.Accordion("Full OCR text (debug)", open=False):
198
  text_output = gr.Textbox(label="Raw extracted text", lines=20)
199
 
200
- chat_send.click(
201
- fn=handle_chat,
202
- inputs=[chatbot, chat_input, settings_state, hf_token_input],
203
- outputs=[chatbot, chat_input, settings_state, settings_display,
204
- categories_input, custom_keywords_input, only_matches_input],
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, table_output, csv_output, text_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__":
chat_config.py CHANGED
@@ -1,6 +1,6 @@
1
  """Conversational assistant that turns free-form instructions into
2
- extraction settings (categories, custom keywords, export filter) using a
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 can flag document pages using built-in keyword categories, each with a
20
- preset list of "notification" phrases for that domain:
21
- - construction: change order, delay notice, punch list, non-conformance, RFI,
22
- submittal rejected, back charge, stop work, safety violation, schedule slip, etc.
23
- - compliance: non-compliant, violation, deficiency, corrective action, audit
24
- finding, expired, recall, citation, penalty, failed inspection, etc.
25
- - finance: past due, overdue, payment rejected, invoice disputed, credit hold,
26
- chargeback, insufficient funds, write-off, collections, late fee, etc.
27
- - general: urgent, cancelled, rejected, approved, pending approval, on hold.
28
-
29
- It always extracts these structured fields wherever present, regardless of
30
- category: PO numbers, SAP document numbers, invoice numbers, certification
31
- codes (ISO/OSHA/ASTM/ANSI), dates, dollar amounts, percentages.
32
-
33
- The user will describe in plain language what they want flagged or pulled.
34
- Given their message and the current settings, respond with:
 
 
 
 
 
 
 
 
 
 
 
 
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/terms to flag that
45
- aren't already covered by a category (empty list if none requested).
46
- - "only_matches" is true if only pages with a hit should be exported, false
47
- to export every page regardless of match.
48
- - Always include all three keys, carrying over prior values the user didn't
49
- ask to change.
50
- - Never invent a category, field, or capability that isn't listed above.
 
 
 
 
 
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
 
extractors.py CHANGED
@@ -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 extract_fields(text):
77
- """Pull structured fields out of a page/block of text."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  fields = {}
79
- for name, pattern in FIELD_PATTERNS.items():
 
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