Spaces:
Sleeping
Sleeping
File size: 7,329 Bytes
a20dd9f 1d905fa a20dd9f 7ce911a a20dd9f 1d905fa a20dd9f 1d905fa a20dd9f 1d905fa a20dd9f 1d905fa a20dd9f 1d905fa a20dd9f 1d905fa a20dd9f 7ce911a a20dd9f 7ce911a a20dd9f | 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 | """Conversational assistant that turns free-form instructions into
extraction settings (categories, notification keywords, value fields,
export filter) using a Hugging Face-hosted chat model.
"""
import json
import os
import re
from huggingface_hub import InferenceClient
import extractors
CATEGORY_CHOICES = list(extractors.KEYWORD_SETS.keys())
# Tried in order until one is accepted by an inference provider. An
# HF_CHAT_MODEL env var/Space variable always takes first priority.
CANDIDATE_MODELS = [
m for m in [
os.environ.get("HF_CHAT_MODEL"),
"Qwen/Qwen2.5-7B-Instruct",
"mistralai/Mistral-7B-Instruct-v0.3",
"Qwen/Qwen2.5-72B-Instruct",
] if m
]
DEFAULT_MODEL = CANDIDATE_MODELS[0]
# Set to whichever model actually served the most recent successful call,
# so the run-metadata audit trail records the real model used.
ACTIVE_MODEL = DEFAULT_MODEL
SYSTEM_PROMPT = f"""You are a configuration assistant for a document OCR/data-extraction tool.
The tool distinguishes two different kinds of things a user can ask for:
1. NOTIFICATIONS - keyword/phrase hits that flag an event worth a reviewer's
attention (a boolean "this happened somewhere on the page"). Built-in
category keyword sets:
- construction: change order, delay notice, punch list, non-conformance,
RFI, submittal rejected, back charge, stop work, safety violation,
schedule slip, etc.
- compliance: non-compliant, violation, deficiency, corrective action,
audit finding, expired, recall, citation, penalty, failed inspection.
- finance: past due, overdue, payment rejected, invoice disputed, credit
hold, chargeback, insufficient funds, write-off, collections, late fee.
- general: urgent, cancelled, rejected, approved, pending approval, on hold.
Users can also name their own free-text notification phrases.
2. FIELDS - labeled values to pull out of the document as data, not just
flag. Two kinds:
- Standard fields, auto-scoped to the categories selected above: PO
numbers + SAP document numbers (construction), certification codes
ISO/OSHA/ASTM/ANSI (compliance), invoice numbers + percentages
(finance), and dates/dollar amounts wherever relevant.
- Custom fields: any label the user names that appears in the document
followed by a value, e.g. "reference number", "gross weight",
"delivery date", "customer PO". These are looked up generically by
label text, so use the user's own wording for the label.
The user will describe in plain language what they want flagged vs. pulled
as data. Given their message and the current settings, respond with:
1. A short (1-3 sentence) conversational reply confirming what you changed,
or asking a clarifying question if the request is ambiguous.
2. On its own line, a fenced json block with the FULL updated settings:
```json
{{"categories": ["finance"], "custom_keywords": ["warranty claim"], "custom_fields": ["reference number", "gross weight"], "only_matches": true}}
```
Rules:
- "categories" must only contain values from {CATEGORY_CHOICES}.
- "custom_keywords" is a list of extra free-text NOTIFICATION phrases to
flag (empty list if none requested).
- "custom_fields" is a list of label names to extract as VALUES, in the
user's own wording (empty list if none requested).
- "only_matches" is true if only pages with a hit/field should be
exported, false to export every page regardless.
- Always include all four keys, carrying over prior values the user
didn't ask to change.
- If the user names something ambiguous, prefer treating it as a
custom_field when they clearly want a value (a number, date, code) back,
and as a custom_keyword when they clearly want an alert/flag instead.
- Never invent a category or capability that isn't listed above.
"""
def _extract_json_block(text):
match = re.search(r"```json\s*(\{.*?\})\s*```", text, re.DOTALL)
if not match:
match = re.search(r"(\{.*\})", text, re.DOTALL)
if not match:
return None
try:
return json.loads(match.group(1))
except json.JSONDecodeError:
return None
def _sanitize_settings(parsed, current_settings):
categories = current_settings.get("categories", [])
custom_keywords = current_settings.get("custom_keywords", [])
custom_fields = current_settings.get("custom_fields", [])
only_matches = current_settings.get("only_matches", True)
if isinstance(parsed, dict):
if isinstance(parsed.get("categories"), list):
categories = [c for c in parsed["categories"] if c in CATEGORY_CHOICES]
if isinstance(parsed.get("custom_keywords"), list):
custom_keywords = [str(k).strip() for k in parsed["custom_keywords"] if str(k).strip()]
if isinstance(parsed.get("custom_fields"), list):
custom_fields = [str(k).strip() for k in parsed["custom_fields"] if str(k).strip()]
if isinstance(parsed.get("only_matches"), bool):
only_matches = parsed["only_matches"]
return {
"categories": categories,
"custom_keywords": custom_keywords,
"custom_fields": custom_fields,
"only_matches": only_matches,
}
def chat_update(history, user_message, current_settings, hf_token=None):
"""Send the conversation to the HF-hosted model and return (reply, new_settings).
`history` is a list of {"role": ..., "content": ...} dicts (gr.Chatbot's
"messages" format). On any failure, the reply explains the error and
current_settings are returned unchanged.
"""
token = hf_token or os.environ.get("HF_TOKEN")
if not token:
return (
"I need a Hugging Face token to reach the chat model. Set the "
"HF_TOKEN secret on this Space, or paste a token in the field above.",
current_settings,
)
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
messages.append({
"role": "system",
"content": f"Current settings: {json.dumps(current_settings)}",
})
messages.extend(history)
messages.append({"role": "user", "content": user_message})
global ACTIVE_MODEL
client = InferenceClient(token=token, provider="auto")
content = None
last_exc = None
for model in CANDIDATE_MODELS:
try:
response = client.chat_completion(
messages=messages, model=model, max_tokens=400, temperature=0.2
)
content = response.choices[0].message.content
ACTIVE_MODEL = model
break
except Exception as exc:
last_exc = exc
if content is None:
return (
f"The chat model call failed for every candidate model "
f"({', '.join(CANDIDATE_MODELS)}). Last error: {last_exc}. "
"Check that HF_TOKEN is valid and has Inference Providers "
"permission, or set the HF_CHAT_MODEL env var to a model your "
"account can reach.",
current_settings,
)
parsed = _extract_json_block(content)
new_settings = _sanitize_settings(parsed, current_settings)
reply_text = re.sub(r"```json.*?```", "", content, flags=re.DOTALL).strip()
if not reply_text:
reply_text = "Updated the extraction settings."
return reply_text, new_settings
|