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