requ-classify-small / requ_classify.py
Richard3d7's picture
Requ Classify Small 0.1.0 (preview)
7a0e93e verified
Raw History Blame Contribute Delete
7.5 kB
"""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)