Download requ_classify.py from requstory/requ-classify-small: direct link, hf CLI and curl.
- Browser
- Download file 7.5 kB
-
https://huggingface.co/requstory/requ-classify-small/resolve/main/requ_classify.py
- Command line
-
hf download hf://requstory/requ-classify-small/requ_classify.py
-
curl -L -o requ_classify.py https://huggingface.co/requstory/requ-classify-small/resolve/main/requ_classify.py
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) | |