Spaces:
Sleeping
Sleeping
Download author_analytics.py from hellosindh/sindhi-ngram: direct link, hf CLI and curl.
- Browser
- Download file 18.9 kB
-
https://huggingface.co/spaces/hellosindh/sindhi-ngram/resolve/main/author_analytics.py
- Command line
-
hf download hf://spaces/hellosindh/sindhi-ngram/author_analytics.py
-
curl -L -o author_analytics.py https://huggingface.co/spaces/hellosindh/sindhi-ngram/resolve/main/author_analytics.py
18.9 kB
| """ | |
| author_analytics.py | |
| Dedicated Analytics Engine & Database System for Author-level Word Usage in Sindhi Books & Articles. | |
| Features: | |
| - Completely separate database file: `author_analytics.db` | |
| - Indexes author-level n-grams (1-grams and 2-grams) | |
| - Tracks total words, total publications, and relative percentage frequencies per author | |
| - Query API for Top 10 authors for any given search word or phrase | |
| - Incremental ingestion hook for new books added to the system | |
| - Standalone CLI to rebuild or inspect author analytics | |
| """ | |
| import os | |
| import sys | |
| import re | |
| import glob | |
| import time | |
| import sqlite3 | |
| import argparse | |
| from typing import Dict, List, Any, Optional | |
| from collections import Counter, defaultdict | |
| # Ensure UTF-8 stdout encoding for Windows | |
| if sys.platform == 'win32': | |
| try: | |
| sys.stdout.reconfigure(encoding='utf-8') | |
| except Exception: | |
| pass | |
| import sindhi_nlp | |
| def resolve_author_database(): | |
| local_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "author_analytics.db") | |
| if os.path.exists(local_path): | |
| return local_path | |
| try: | |
| from huggingface_hub import hf_hub_download | |
| token = os.environ.get("HF_TOKEN") | |
| print("Fetching author_analytics.db from dataset repo...") | |
| return hf_hub_download( | |
| repo_id="hellosindh/sindhi-ngram-database", | |
| filename="author_analytics.db", | |
| repo_type="dataset", | |
| token=token, | |
| ) | |
| except Exception as e: | |
| print(f"Error fetching Author DB from Hub: {e}") | |
| return local_path | |
| DB_FILE = resolve_author_database() | |
| def get_db(db_path: str = DB_FILE) -> sqlite3.Connection: | |
| """Returns a SQLite connection to author_analytics.db with WAL mode.""" | |
| conn = sqlite3.connect(db_path, timeout=30.0) | |
| conn.row_factory = sqlite3.Row | |
| conn.execute("PRAGMA journal_mode = WAL;") | |
| conn.execute("PRAGMA synchronous = NORMAL;") | |
| return conn | |
| def clean_author_name(author_str: str) -> str: | |
| """Strips translation notes or parenthesis from author string.""" | |
| if not author_str: | |
| return "ูุงู ุนููู " | |
| clean = re.sub(r'\(.*?\)', '', author_str).strip() | |
| return clean if clean else author_str.strip() | |
| def init_author_db(db_path: str = DB_FILE, create_indexes: bool = True) -> sqlite3.Connection: | |
| """Initializes schema and performance indexes for author_analytics.db.""" | |
| conn = sqlite3.connect(db_path, timeout=30.0) | |
| cur = conn.cursor() | |
| cur.execute("PRAGMA journal_mode = WAL;") | |
| cur.execute("PRAGMA synchronous = NORMAL;") | |
| # 1. Author Profiles Table | |
| cur.execute(""" | |
| CREATE TABLE IF NOT EXISTS author_profiles ( | |
| author TEXT PRIMARY KEY, | |
| clean_author TEXT, | |
| total_words INTEGER DEFAULT 0, | |
| total_publications INTEGER DEFAULT 0, | |
| doc_types TEXT DEFAULT 'book' | |
| ); | |
| """) | |
| # 2. Author Word Counts Table | |
| cur.execute(""" | |
| CREATE TABLE IF NOT EXISTS author_word_counts ( | |
| author TEXT, | |
| term TEXT, | |
| term_norm TEXT, | |
| term_heh_norm TEXT, | |
| n_size INTEGER, | |
| count INTEGER | |
| ); | |
| """) | |
| # 3. Indexed Books Tracking Table | |
| cur.execute(""" | |
| CREATE TABLE IF NOT EXISTS indexed_books ( | |
| filename TEXT PRIMARY KEY, | |
| author TEXT, | |
| file_hash TEXT, | |
| total_words INTEGER, | |
| doc_type TEXT DEFAULT 'book', | |
| indexed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP | |
| ); | |
| """) | |
| if create_indexes: | |
| create_db_indexes(cur) | |
| conn.commit() | |
| return conn | |
| def create_db_indexes(cur: sqlite3.Cursor): | |
| """Creates performance indexes for sub-millisecond lookups.""" | |
| cur.execute("CREATE INDEX IF NOT EXISTS idx_awc_norm ON author_word_counts(term_norm, n_size);") | |
| cur.execute("CREATE INDEX IF NOT EXISTS idx_awc_heh ON author_word_counts(term_heh_norm, n_size);") | |
| cur.execute("CREATE INDEX IF NOT EXISTS idx_awc_term ON author_word_counts(term, n_size);") | |
| cur.execute("CREATE INDEX IF NOT EXISTS idx_awc_auth_term ON author_word_counts(author, term, n_size);") | |
| cur.execute("CREATE INDEX IF NOT EXISTS idx_author_words ON author_profiles(total_words DESC);") | |
| def index_single_book(filepath: str, conn: Optional[sqlite3.Connection] = None, doc_type: str = "book") -> bool: | |
| """ | |
| Indexes a single book/article into author_analytics.db. | |
| Can be called incrementally whenever a new publication is ingested or updated. | |
| """ | |
| close_at_end = False | |
| if conn is None: | |
| conn = get_db() | |
| close_at_end = True | |
| try: | |
| filename = os.path.basename(filepath) | |
| book_data = sindhi_nlp.parse_book_file(filepath) | |
| if not book_data: | |
| return False | |
| raw_author = book_data.get("author", "").strip() or "ูุงู ุนููู " | |
| author = clean_author_name(raw_author) | |
| text = book_data.get("text", "") | |
| tokens = sindhi_nlp.tokenize(text, normalize=False) | |
| total_tokens = len(tokens) | |
| if total_tokens == 0: | |
| return False | |
| hasher = sindhi_nlp.hashlib.sha256() if hasattr(sindhi_nlp, 'hashlib') else __import__('hashlib').sha256() | |
| with open(filepath, 'rb') as f: | |
| while ch := f.read(65536): | |
| hasher.update(ch) | |
| file_hash = hasher.hexdigest() | |
| cur = conn.cursor() | |
| # Check if already indexed with same hash | |
| cur.execute("SELECT file_hash, total_words FROM indexed_books WHERE filename = ?", (filename,)) | |
| existing = cur.fetchone() | |
| if existing and existing["file_hash"] == file_hash: | |
| return True | |
| try: | |
| conn.execute("PRAGMA journal_mode = WAL;") | |
| conn.execute("PRAGMA synchronous = NORMAL;") | |
| conn.execute("PRAGMA cache_size = -32000;") | |
| conn.execute("PRAGMA temp_store = MEMORY;") | |
| except Exception: | |
| pass | |
| # Extract 1-grams and 2-grams | |
| counts_1g = Counter(tokens) | |
| counts_2g = Counter() | |
| for i in range(len(tokens) - 1): | |
| counts_2g[tokens[i] + ' ' + tokens[i+1]] += 1 | |
| staging_rows = [] | |
| norm_map = {} | |
| for term, cnt in counts_1g.items(): | |
| t_norm = sindhi_nlp.normalize_sindhi(term, strip_diacritics=True, normalize_heh_variants=False) | |
| t_heh = sindhi_nlp.normalize_sindhi(term, strip_diacritics=True, normalize_heh_variants=True) | |
| norm_map[term] = (t_norm, t_heh) | |
| staging_rows.append((term, t_norm, t_heh, 1, cnt)) | |
| for term, cnt in counts_2g.items(): | |
| if cnt < 2: | |
| continue | |
| words = term.split(' ', 1) | |
| if len(words) == 2: | |
| n1 = norm_map.get(words[0]) or (words[0], words[0]) | |
| n2 = norm_map.get(words[1]) or (words[1], words[1]) | |
| t_norm = n1[0] + ' ' + n2[0] | |
| t_heh = n1[1] + ' ' + n2[1] | |
| else: | |
| t_norm = sindhi_nlp.normalize_sindhi(term, strip_diacritics=True, normalize_heh_variants=False) | |
| t_heh = sindhi_nlp.normalize_sindhi(term, strip_diacritics=True, normalize_heh_variants=True) | |
| staging_rows.append((term, t_norm, t_heh, 2, cnt)) | |
| if staging_rows: | |
| batch_insert = [(author, r[0], r[1], r[2], r[3], r[4]) for r in staging_rows] | |
| chunk_size = 20000 | |
| for i in range(0, len(batch_insert), chunk_size): | |
| cur.executemany( | |
| "INSERT INTO author_word_counts (author, term, term_norm, term_heh_norm, n_size, count) VALUES (?, ?, ?, ?, ?, ?)", | |
| batch_insert[i:i + chunk_size] | |
| ) | |
| staging_rows.clear() | |
| # Upsert author profile | |
| cur.execute(""" | |
| INSERT INTO author_profiles (author, clean_author, total_words, total_publications, doc_types) | |
| VALUES (?, ?, ?, 1, ?) | |
| ON CONFLICT(author) DO UPDATE SET | |
| total_words = total_words + excluded.total_words, | |
| total_publications = total_publications + 1; | |
| """, (author, author, total_tokens, doc_type)) | |
| # Record indexed book | |
| cur.execute(""" | |
| INSERT OR REPLACE INTO indexed_books (filename, author, file_hash, total_words, doc_type) | |
| VALUES (?, ?, ?, ?, ?) | |
| """, (filename, author, file_hash, total_tokens, doc_type)) | |
| conn.commit() | |
| return True | |
| except Exception as e: | |
| print(f"[author_analytics] Error indexing {filepath}: {e}") | |
| return False | |
| finally: | |
| if close_at_end: | |
| conn.close() | |
| def remove_book(filename: str, conn: Optional[sqlite3.Connection] = None) -> bool: | |
| """Removes a book tracking record from author_analytics.db.""" | |
| close_at_end = False | |
| if conn is None: | |
| conn = get_db() | |
| close_at_end = True | |
| try: | |
| cur = conn.cursor() | |
| cur.execute("DELETE FROM indexed_books WHERE filename = ?", (filename,)) | |
| conn.commit() | |
| return True | |
| finally: | |
| if close_at_end: | |
| conn.close() | |
| def get_top_authors_for_term( | |
| term: str, | |
| top_n: int = 10, | |
| normalize: bool = True, | |
| heh_norm: bool = True | |
| ) -> Dict[str, Any]: | |
| """ | |
| Finds the top N authors who used a given word or phrase the most. | |
| Calculates both raw occurrences and relative frequency percentage. | |
| """ | |
| clean_term = term.strip() | |
| if not clean_term: | |
| return {"term": term, "top_authors": []} | |
| words = clean_term.split() | |
| n_size = len(words) | |
| if n_size > 2: | |
| n_size = 2 # Support up to bigrams | |
| conn = get_db() | |
| cur = conn.cursor() | |
| if heh_norm: | |
| search_key = sindhi_nlp.normalize_sindhi(clean_term, strip_diacritics=True, normalize_heh_variants=True) | |
| sql = """ | |
| SELECT a.author, SUM(a.count) as total_occurrences, p.total_words, p.total_publications | |
| FROM author_word_counts a | |
| LEFT JOIN author_profiles p ON a.author = p.author | |
| WHERE a.term_heh_norm = ? AND a.n_size = ? | |
| GROUP BY a.author | |
| ORDER BY total_occurrences DESC | |
| LIMIT ? | |
| """ | |
| elif normalize: | |
| search_key = sindhi_nlp.normalize_sindhi(clean_term, strip_diacritics=True, normalize_heh_variants=False) | |
| sql = """ | |
| SELECT a.author, SUM(a.count) as total_occurrences, p.total_words, p.total_publications | |
| FROM author_word_counts a | |
| LEFT JOIN author_profiles p ON a.author = p.author | |
| WHERE a.term_norm = ? AND a.n_size = ? | |
| GROUP BY a.author | |
| ORDER BY total_occurrences DESC | |
| LIMIT ? | |
| """ | |
| else: | |
| search_key = clean_term | |
| sql = """ | |
| SELECT a.author, SUM(a.count) as total_occurrences, p.total_words, p.total_publications | |
| FROM author_word_counts a | |
| LEFT JOIN author_profiles p ON a.author = p.author | |
| WHERE a.term = ? AND a.n_size = ? | |
| GROUP BY a.author | |
| ORDER BY total_occurrences DESC | |
| LIMIT ? | |
| """ | |
| cur.execute(sql, (search_key, n_size, top_n)) | |
| rows = cur.fetchall() | |
| conn.close() | |
| top_authors = [] | |
| for rank, r in enumerate(rows, 1): | |
| author_name = r["author"] or "ูุงู ุนููู " | |
| occ = r["total_occurrences"] or 0 | |
| tot_words = r["total_words"] or 1 | |
| pct = (occ / tot_words) * 100.0 | |
| top_authors.append({ | |
| "rank": rank, | |
| "author": author_name, | |
| "count": occ, | |
| "total_words": tot_words, | |
| "percentage": round(pct, 5), | |
| "percentage_str": f"{pct:.4f}%", | |
| "total_publications": r["total_publications"] or 1 | |
| }) | |
| return { | |
| "term": term, | |
| "n_size": n_size, | |
| "search_key": search_key, | |
| "top_authors": top_authors | |
| } | |
| def build_full_database(scan_dirs: List[str] = None, db_path: str = DB_FILE, batch_commit_size: int = 100000) -> bool: | |
| """ | |
| Fast batch processor that builds author_analytics.db from all book and article files in `books/`. | |
| Optimized: | |
| - Pre-aggregates in memory (takes ~25s for 9,210 files) | |
| - Keeps all 1-grams (count >= 1) and all meaningful 2-grams (count >= 2) | |
| - Bulk inserts raw rows without active indexes in PRAGMA synchronous = OFF mode | |
| - Creates indexes after insert | |
| - Total build time: ~45-60 seconds! | |
| """ | |
| if scan_dirs is None: | |
| scan_dirs = ["books"] | |
| print(f"[author_analytics] Initializing database at: {db_path}") | |
| for ext in ["", "-wal", "-shm"]: | |
| target = db_path + ext | |
| if os.path.exists(target): | |
| try: | |
| os.remove(target) | |
| except Exception: | |
| pass | |
| conn = init_author_db(db_path, create_indexes=False) | |
| cur = conn.cursor() | |
| cur.execute("PRAGMA synchronous = OFF;") | |
| cur.execute("PRAGMA journal_mode = MEMORY;") | |
| cur.execute("PRAGMA cache_size = -128000;") # 128MB cache | |
| files = [] | |
| for d in scan_dirs: | |
| if os.path.exists(d): | |
| files.extend(glob.glob(os.path.join(d, "*.txt"))) | |
| files = sorted(list(set(files))) | |
| total_files = len(files) | |
| print(f"[author_analytics] Found {total_files:,} files to process.") | |
| t0 = time.time() | |
| author_1g = defaultdict(Counter) | |
| author_2g = defaultdict(Counter) | |
| author_totals = Counter() | |
| author_pubs = Counter() | |
| author_doc_types = defaultdict(set) | |
| indexed_records = [] | |
| for idx, f in enumerate(files, 1): | |
| data = sindhi_nlp.parse_book_file(f) | |
| if not data: | |
| continue | |
| raw_author = data.get("author", "").strip() or "ูุงู ุนููู " | |
| author = clean_author_name(raw_author) | |
| text = data.get("text", "") | |
| tokens = sindhi_nlp.tokenize(text, normalize=False) | |
| num_tokens = len(tokens) | |
| if num_tokens == 0: | |
| continue | |
| filename = os.path.basename(f) | |
| doc_type = "article" if filename.startswith("article_") else "book" | |
| author_totals[author] += num_tokens | |
| author_pubs[author] += 1 | |
| author_doc_types[author].add(doc_type) | |
| hasher = sindhi_nlp.hashlib.sha256() if hasattr(sindhi_nlp, 'hashlib') else __import__('hashlib').sha256() | |
| with open(f, 'rb') as fp: | |
| while ch := fp.read(65536): | |
| hasher.update(ch) | |
| f_hash = hasher.hexdigest() | |
| indexed_records.append((filename, author, f_hash, num_tokens, doc_type)) | |
| for tok in tokens: | |
| author_1g[author][tok] += 1 | |
| for i in range(len(tokens) - 1): | |
| author_2g[author][tokens[i] + ' ' + tokens[i+1]] += 1 | |
| if idx % 2000 == 0 or idx == total_files: | |
| print(f" Processed {idx:,}/{total_files:,} files ({time.time()-t0:.1f}s)...") | |
| scan_time = time.time() - t0 | |
| print(f"[author_analytics] Scanning complete in {scan_time:.1f}s. Preparing bulk insert...") | |
| # 1. Insert author profiles | |
| profiles_data = [ | |
| (author, author, author_totals[author], author_pubs[author], ",".join(author_doc_types[author])) | |
| for author in author_totals | |
| ] | |
| cur.executemany(""" | |
| INSERT INTO author_profiles (author, clean_author, total_words, total_publications, doc_types) | |
| VALUES (?, ?, ?, ?, ?) | |
| """, profiles_data) | |
| # 2. Insert indexed books | |
| cur.executemany(""" | |
| INSERT INTO indexed_books (filename, author, file_hash, total_words, doc_type) | |
| VALUES (?, ?, ?, ?, ?) | |
| """, indexed_records) | |
| # 3. Bulk insert 1-grams (all words) | |
| print("[author_analytics] Inserting 1-grams...") | |
| insert_sql = """ | |
| INSERT INTO author_word_counts (author, term, term_norm, term_heh_norm, n_size, count) | |
| VALUES (?, ?, ?, ?, ?, ?) | |
| """ | |
| batch = [] | |
| total_1g_rows = 0 | |
| for author, counter in author_1g.items(): | |
| for term, cnt in counter.items(): | |
| t_norm = sindhi_nlp.normalize_sindhi(term, strip_diacritics=True, normalize_heh_variants=False) | |
| t_heh = sindhi_nlp.normalize_sindhi(term, strip_diacritics=True, normalize_heh_variants=True) | |
| batch.append((author, term, t_norm, t_heh, 1, cnt)) | |
| if len(batch) >= batch_commit_size: | |
| cur.executemany(insert_sql, batch) | |
| total_1g_rows += len(batch) | |
| batch = [] | |
| if batch: | |
| cur.executemany(insert_sql, batch) | |
| total_1g_rows += len(batch) | |
| print(f"[author_analytics] Inserted {total_1g_rows:,} unigram entries.") | |
| # 4. Bulk insert 2-grams (phrases with count >= 2 for meaningful author idioms) | |
| print("[author_analytics] Inserting 2-grams (frequency >= 2)...") | |
| batch = [] | |
| total_2g_rows = 0 | |
| for author, counter in author_2g.items(): | |
| for term, cnt in counter.items(): | |
| if cnt >= 2: | |
| t_norm = sindhi_nlp.normalize_sindhi(term, strip_diacritics=True, normalize_heh_variants=False) | |
| t_heh = sindhi_nlp.normalize_sindhi(term, strip_diacritics=True, normalize_heh_variants=True) | |
| batch.append((author, term, t_norm, t_heh, 2, cnt)) | |
| if len(batch) >= batch_commit_size: | |
| cur.executemany(insert_sql, batch) | |
| total_2g_rows += len(batch) | |
| batch = [] | |
| if batch: | |
| cur.executemany(insert_sql, batch) | |
| total_2g_rows += len(batch) | |
| print(f"[author_analytics] Inserted {total_2g_rows:,} bigram entries.") | |
| conn.commit() | |
| # 5. Create indexes after data insertion | |
| print("[author_analytics] Creating performance indexes...") | |
| t_idx = time.time() | |
| create_db_indexes(cur) | |
| conn.commit() | |
| print(f"[author_analytics] Indexes created in {time.time()-t_idx:.1f}s.") | |
| # Switch to WAL mode for concurrent readers and writer | |
| cur.execute("PRAGMA journal_mode = WAL;") | |
| cur.execute("PRAGMA synchronous = NORMAL;") | |
| conn.commit() | |
| conn.close() | |
| total_time = time.time() - t0 | |
| print(f"[author_analytics] Successfully built {db_path} ({total_1g_rows + total_2g_rows:,} total entries) in {total_time:.1f}s!") | |
| return True | |
| if __name__ == "__main__": | |
| parser = argparse.ArgumentParser(description="Author Analytics Engine for Sindhi Literature") | |
| parser.add_argument("--build", action="store_true", help="Rebuild the author analytics database from books/") | |
| parser.add_argument("--query", type=str, help="Query top 10 authors for a Sindhi word or phrase") | |
| parser.add_argument("--exact", action="store_true", help="Exact matching (disable normalization)") | |
| args = parser.parse_args() | |
| if args.build: | |
| build_full_database() | |
| elif args.query: | |
| norm = not args.exact | |
| res = get_top_authors_for_term(args.query, top_n=10, normalize=norm, heh_norm=norm) | |
| print(f"\nTop 10 Authors for '{args.query}':") | |
| for a in res["top_authors"]: | |
| print(f" #{a['rank']} {a['author']}: {a['count']:,} occurrences ({a['percentage_str']}, total words: {a['total_words']:,})") | |
| else: | |
| parser.print_help() | |