File size: 7,501 Bytes
7a0e93e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Requ Classify Small: reads a company's website and labels it.

Gives the industry (64 classes), a type for each menu item and heading (offering, team, activity,
audience, website, other), a customer-journey stage for each page, and sentences that show a known
business process. Needs numpy, onnxruntime, tokenizers and safetensors.

    python requ_classify.py < site.json

site.json: {"name", "description", "pages": [{"url", "title", "text", "items": [{"text", "kind"}]}]}
where an item's kind is one of: menu, footer, h1, h2, h3, button.
"""
import json, os, re, sys
from urllib.parse import urlsplit

import numpy as np
from safetensors.numpy import load_file

HERE = os.path.dirname(os.path.abspath(__file__))
KINDS = ['menu', 'footer', 'h1', 'h2', 'h3', 'button']
SENT = re.compile(r'(?<=[.!?])\s+')


class Embedder:
    """bge-small-en-v1.5 (quantised ONNX), CLS vector, normalised."""

    def __init__(self, folder):
        import onnxruntime as ort
        from tokenizers import Tokenizer
        opts = ort.SessionOptions()
        opts.intra_op_num_threads = int(os.environ.get('REQU_THREADS', '2'))
        self.session = ort.InferenceSession(os.path.join(folder, 'model_quantized.onnx'), opts, providers=['CPUExecutionProvider'])
        self.tok = Tokenizer.from_file(os.path.join(folder, 'tokenizer.json'))
        self.tok.enable_truncation(max_length=256)
        self.inputs = {i.name for i in self.session.get_inputs()}

    def __call__(self, texts, batch=64):
        out = []
        for start in range(0, len(texts), batch):
            self.tok.enable_padding(length=None)
            enc = self.tok.encode_batch(texts[start:start + batch])
            ids = np.array([e.ids for e in enc], dtype=np.int64)
            mask = np.array([e.attention_mask for e in enc], dtype=np.int64)
            feed = {'input_ids': ids, 'attention_mask': mask}
            if 'token_type_ids' in self.inputs:
                feed['token_type_ids'] = np.zeros_like(ids)
            cls = self.session.run(None, feed)[0][:, 0]
            out.append(cls / np.linalg.norm(cls, axis=1, keepdims=True))
        return np.vstack(out) if out else np.zeros((0, 384), dtype=np.float32)


def path_of(url):
    return urlsplit(url).path.strip('/').replace('-', ' ').replace('/', ' / ') or 'home'


def site_text(site):
    items = [i for p in site['pages'] for i in p.get('items', [])]
    heads = [i['text'] for i in items if i['kind'] in ('h1', 'h2')][:25]
    menu = [i['text'] for i in items if i['kind'] == 'menu'][:30]
    home = site['pages'][0]['text'][:800] if site['pages'] else ''
    return f"{site.get('name', '')}. {site.get('description', '')} Pages: {'; '.join(p.get('title', '') for p in site['pages'])}. Menu: {', '.join(menu)}. Headings: {', '.join(heads)}. {home}"


def site_pieces(site):
    items = [i for p in site['pages'] for i in p.get('items', [])]
    out = [f"{site.get('name', '')}. {site.get('description', '')}",
           'Menu: ' + ', '.join(i['text'] for i in items if i['kind'] == 'menu')[:600],
           'Headings: ' + ', '.join(i['text'] for i in items if i['kind'] in ('h1', 'h2', 'h3'))[:600]]
    out += [f"{p.get('title', '')}. {p['text'][:500]}" for p in site['pages'][:6]]
    return [x for x in out if len(x) > 12]


def sentences(page, limit=60):
    out = [s.strip() for s in SENT.split(page['text']) if 6 <= len(s.split()) <= 45]
    return out[:limit]


def softmax(z):
    e = np.exp(z - z.max(axis=-1, keepdims=True))
    return e / e.sum(axis=-1, keepdims=True)


class RequClassify:
    def __init__(self, folder=HERE):
        self.embed = Embedder(os.path.join(folder, 'model'))
        w = load_file(os.path.join(folder, 'heads.safetensors'))
        cfg = json.load(open(os.path.join(folder, 'config.json')))
        self.heads = {n: (w[f'{n}.weight'], w[f'{n}.bias'], cfg['classes'][n]) for n in ('industry', 'item_type', 'page_stage')}
        self.proof = (w['proof.weight'], w['proof.bias'])
        self.memory = w['memory'].astype(np.float32)
        self.memory_keys = np.array(cfg['memory_keys'])
        self.vectors = w['library_vectors']
        self.t = cfg['thresholds']
        self.library = json.load(open(os.path.join(folder, 'library.json')))['industries']

    def head(self, name, X):
        coef, bias, classes = self.heads[name]
        return softmax(X @ coef.T + bias), classes

    def __call__(self, site):
        pages = site['pages']
        X = np.hstack([self.embed([site_text(site)])[0], self.embed(site_pieces(site)).mean(0)])[None, :]
        p, classes = self.head('industry', X)
        order = np.argsort(-p[0])
        industry = classes[order[0]]
        result = {'industry': {'id': industry, 'p': round(float(p[0][order[0]]), 3), 'sure': bool(p[0][order[0]] >= self.t['industry_gate']),
                               'top3': [[classes[i], round(float(p[0][i]), 3)] for i in order[:3]]}}
        flat = [(pi, it) for pi, pg in enumerate(pages) for it in pg.get('items', [])]
        result['items'] = []
        if flat:
            E = self.embed([f"{it['text']} | {it['kind']} | {path_of(pages[pi]['url'])}" for pi, it in flat])
            K = np.array([[1.0 if it['kind'] == k else 0.0 for k in KINDS] for _, it in flat])
            q, tclasses = self.head('item_type', np.hstack([E, K]))
            result['items'] = [{'page': pi, 'text': it['text'], 'kind': it['kind'], 'type': tclasses[int(r.argmax())], 'p': round(float(r.max()), 3)} for (pi, it), r in zip(flat, q)]
        if pages:
            s, sclasses = self.head('page_stage', self.embed([f"{path_of(pg['url'])}. {pg.get('title', '')}. {pg['text'][:400]}" for pg in pages]))
            result['pageStages'] = [sclasses[int(r.argmax())] for r in s]
        # Sentences that show a business process of this industry, matched by the library wording
        # and by similar sentences from training sites.
        entry = self.library[industry]
        P = self.vectors[entry['rows']]
        keys = [pid if pid.startswith('s-') else f'{industry}:{pid}' for pid in entry['ids']]
        found = []
        sents = [(pi, s) for pi, pg in enumerate(pages) for s in sentences(pg)]
        if sents:
            S = self.embed([s for _, s in sents])
            keep = (1 / (1 + np.exp(-(S @ self.proof[0].T + self.proof[1]))))[:, 0]
            chosen = np.where(keep >= self.t['match_threshold'])[0]
            if len(chosen):
                S = S[chosen]
                score = S @ P.T
                simM = S @ self.memory.T
                for j, k in enumerate(keys):
                    cols = np.where(self.memory_keys == k)[0]
                    if len(cols):
                        score[:, j] = 0.4 * score[:, j] + 0.6 * np.sort(simM[:, cols], axis=1)[:, -3:].mean(axis=1)
                for row, i in enumerate(chosen):
                    o = np.argsort(-score[row])
                    pi, s = sents[i]
                    clear = bool(score[row][o[0]] - score[row][o[1]] >= self.t['process_margin']) if len(o) > 1 else True
                    found.append({'process': entry['ids'][int(o[0])], 'clear': clear, 'page': pi, 'quote': s, 'score': round(float(keep[i]), 3)})
        found.sort(key=lambda e: -e['score'])
        best = {}
        for e in found:
            best.setdefault(e['process'], []).append(e)
        result['evidence'] = [x for v in best.values() for x in v[:2]]
        return result


if __name__ == '__main__':
    json.dump(RequClassify()(json.load(sys.stdin)), sys.stdout, indent=1)