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)
|