Spaces:
Runtime error
Runtime error
| # ner_trends.py | |
| from collections import Counter, defaultdict | |
| from typing import Optional | |
| import subprocess, sys | |
| try: | |
| import spacy | |
| spacy.load("en_core_web_sm") | |
| except (ImportError, OSError): | |
| subprocess.run([sys.executable, "-m", "spacy", "download", "en_core_web_sm"], | |
| check=True) | |
| # en_core_web_sm is ~12MB — fast on CPU, no GPU needed | |
| # Install: python -m spacy download en_core_web_sm | |
| _nlp: Optional[spacy.language.Language] = None | |
| # Entity types we care about | |
| _KEEP_LABELS = {"ORG", "PERSON", "GPE", "LOC"} | |
| # Common noise entities to suppress (appear in every news cycle, add no signal) | |
| _STOPLIST = { | |
| "reuters", "ap", "bloomberg", "associated press", "the new york times", | |
| "bbc", "cnn", "fox news", "the guardian", "wsj", "washington post", | |
| "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday", | |
| "january", "february", "march", "april", "may", "june", | |
| "july", "august", "september", "october", "november", "december", | |
| "u.s.", "us", "united states", # keep "US" for GPE but suppress generic | |
| } | |
| def _get_nlp(): | |
| global _nlp | |
| if _nlp is None: | |
| _nlp = spacy.load("en_core_web_sm", disable=["parser", "lemmatizer"]) | |
| return _nlp | |
| def extract_trending_entities( | |
| articles: list[dict], | |
| top_n: int = 8, | |
| ) -> dict[str, list[dict]]: | |
| """ | |
| Run NER over all article titles+snippets and return frequency-ranked | |
| entities split by type. | |
| Returns: | |
| { | |
| "ORG": [{"text": "OpenAI", "count": 8, "articles": [...]}, ...], | |
| "PERSON": [{"text": "Sam Altman", "count": 4, "articles": [...]}, ...], | |
| "GPE": [{"text": "China", "count": 7, "articles": [...]}, ...], | |
| } | |
| """ | |
| nlp = _get_nlp() | |
| # Build texts and keep index → article mapping | |
| texts = [ | |
| f"{a.get('title', '')}. {a.get('snippet', '')}".strip() | |
| for a in articles | |
| ] | |
| # entity_text_lower → {label, canonical_text, set of article indices} | |
| entity_map: dict[str, dict] = {} | |
| # Batch process — much faster than one doc at a time | |
| for idx, doc in enumerate(nlp.pipe(texts, batch_size=32)): | |
| for ent in doc.ents: | |
| if ent.label_ not in _KEEP_LABELS: | |
| continue | |
| normalized = ent.text.strip() | |
| key = normalized.lower() | |
| if key in _STOPLIST or len(key) < 2: | |
| continue | |
| if key not in entity_map: | |
| entity_map[key] = { | |
| "text": normalized, | |
| "label": ent.label_, | |
| "article_indices": set(), | |
| } | |
| # Use the longest seen form as canonical | |
| if len(normalized) > len(entity_map[key]["text"]): | |
| entity_map[key]["text"] = normalized | |
| entity_map[key]["article_indices"].add(idx) | |
| # Group by label, sort by article count descending | |
| by_label: dict[str, list[dict]] = defaultdict(list) | |
| for key, info in entity_map.items(): | |
| count = len(info["article_indices"]) | |
| # Slim article list: just title + url for the UI | |
| article_refs = [ | |
| {"title": articles[i]["title"], "url": articles[i].get("url", "#")} | |
| for i in sorted(info["article_indices"])[:3] # max 3 refs per entity | |
| ] | |
| by_label[info["label"]].append({ | |
| "text": info["text"], | |
| "count": count, | |
| "articles": article_refs, | |
| }) | |
| # Sort each bucket by count, take top N | |
| result = {} | |
| for label in ["ORG", "PERSON", "GPE"]: | |
| bucket = sorted(by_label.get(label, []), key=lambda x: x["count"], reverse=True) | |
| result[label] = bucket[:top_n] | |
| return result | |
| def entity_summary_for_briefing(trending: dict[str, list[dict]]) -> str: | |
| """ | |
| Returns a one-line plain-text summary of top entities. | |
| Used to inject entity context into the Groq prompt. | |
| """ | |
| parts = [] | |
| for label, entities in trending.items(): | |
| if entities: | |
| names = ", ".join(e["text"] for e in entities[:3]) | |
| parts.append(f"{label}: {names}") | |
| return " | ".join(parts) |