| """Terminology extraction: segmentation, frequency, POS chunking, keyword scoring.""" |
|
|
| import re |
| from collections import Counter |
| from dataclasses import dataclass, field |
|
|
|
|
| @dataclass |
| class TermEntry: |
| """A single extracted term.""" |
| term: str |
| frequency: int = 1 |
| score: float = 0.0 |
| word_type: str = "" |
| positions: list[int] = field(default_factory=list) |
| search_results: list[dict] = field(default_factory=list) |
|
|
|
|
| def extract(text: str, top_n: int = 30) -> list[TermEntry]: |
| """Extract key terminology from text. |
| |
| Chinese text: C-Value + pke_zh + jieba TF-IDF + TextRank + POS chunking |
| English text: C-Value + YAKE + regex proper nouns |
| |
| C-Value is a well-established algorithm that handles nested terms |
| (e.g., scores "ε
η«ζ£ζ₯ηΉζεΆε" higher than "ζ£ζ₯ηΉζεΆε"). |
| |
| Args: |
| text: Source text. |
| top_n: Maximum number of terms to return. |
| |
| Returns: |
| List of TermEntry sorted by score descending. |
| """ |
| terms: list[TermEntry] = [] |
|
|
| cn_chars = len(re.findall(r"[\u4e00-\u9fff]", text)) |
| en_chars = len(re.findall(r"[a-zA-Z]", text)) |
| is_cn = cn_chars > en_chars * 0.5 |
|
|
| if is_cn: |
| terms.extend(_extract_jieba(text, top_n)) |
| terms.extend(_extract_textrank(text, top_n)) |
| terms.extend(_extract_pke_zh(text, top_n)) |
| terms.extend(_extract_phrases(text, top_n)) |
| else: |
| terms.extend(_extract_yake(text, top_n)) |
| terms.extend(_extract_proper_nouns(text)) |
|
|
| |
| terms.extend(_extract_cvalue(text, top_n, is_cn)) |
|
|
| return _merge_and_rank(terms, top_n) |
|
|
|
|
| def extract_file(filepath: str, top_n: int = 30) -> list[TermEntry]: |
| """Extract terms from a text file.""" |
| with open(filepath, "r", encoding="utf-8") as f: |
| text = f.read() |
| return extract(text, top_n) |
|
|
|
|
| |
| |
| |
|
|
| def _extract_jieba(text: str, top_n: int) -> list[TermEntry]: |
| """Extract keywords using jieba TF-IDF.""" |
| try: |
| import jieba |
| import jieba.analyse |
|
|
| keywords = jieba.analyse.extract_tags(text, topK=top_n, withWeight=True) |
| results: list[TermEntry] = [] |
| for word, weight in keywords: |
| if len(word) < 2: |
| continue |
| results.append(TermEntry(term=word, score=weight * 0.85, word_type="keyword")) |
| return results |
| except ImportError: |
| return _fallback_chinese_freq(text, top_n) |
|
|
|
|
| def _fallback_chinese_freq(text: str, top_n: int) -> list[TermEntry]: |
| """Fallback: count Chinese bigrams by frequency.""" |
| cn_chars = re.findall(r"[\u4e00-\u9fff]", text) |
| bigrams = ["".join(cn_chars[i : i + 2]) for i in range(len(cn_chars) - 1)] |
| counter = Counter(bigrams) |
| total = max(1, sum(counter.values())) |
| return [ |
| TermEntry(term=bg, frequency=count, score=count / total, word_type="bigram") |
| for bg, count in counter.most_common(top_n) |
| if len(bg) == 2 |
| ] |
|
|
|
|
| |
| |
| |
|
|
| def _extract_textrank(text: str, top_n: int) -> list[TermEntry]: |
| """Extract keywords using jieba TextRank algorithm. |
| |
| TextRank uses a graph-based approach similar to PageRank, building |
| a co-occurrence graph of words and ranking them. It often produces |
| different (and sometimes better) results than TF-IDF. |
| """ |
| try: |
| import jieba.analyse |
|
|
| keywords = jieba.analyse.textrank( |
| text, topK=top_n, withWeight=True, |
| allowPOS=('ns', 'n', 'vn', 'v', 'nr', 'nt', 'nz', 'a', 'an'), |
| ) |
| results: list[TermEntry] = [] |
| for word, weight in keywords: |
| if len(word) >= 2: |
| |
| results.append(TermEntry(term=word, score=weight * 0.9, word_type="textrank")) |
| return results |
| except Exception: |
| return [] |
|
|
|
|
| |
| |
| |
|
|
| |
| _NOUN_POS = frozenset({ |
| 'n', 'nr', 'ns', 'nt', 'nz', 'ng', |
| 'vn', |
| 'an', |
| 'j', 'l', 'i', |
| }) |
|
|
| |
| _MOD_POS = frozenset({ |
| 'n', 'nr', 'ns', 'nt', 'nz', 'ng', 'vn', 'an', |
| 'a', 'ad', 'ag', |
| 'b', |
| 'v', 'vd', |
| 'eng', |
| }) |
|
|
| |
| _PHRASE_STOP_WORDS = frozenset({ |
| '\u7684', '\u4e86', '\u5728', '\u548c', '\u4e0e', '\u662f', '\u4e0d', |
| '\u8fd9', '\u90a3', '\u5176', '\u4e4b', '\u800c', '\u4e14', '\u53ca', '\u7b49', |
| '\u88ab', '\u628a', '\u5c06', '\u4ece', '\u5bf9', '\u5411', '\u7740', '\u8fc7', |
| '\u5f97', '\u5730', '\u8981', '\u4f1a', '\u80fd', '\u53ef', '\u4ee5', |
| '\u4e0a', '\u4e0b', '\u4e2d', '\u524d', '\u540e', '\u6709', '\u65e0', |
| '\u6765', '\u53bb', '\u5230', '\u51fa', '\u5165', '\u5927', '\u5c0f', |
| '\u591a', '\u5c11', '\u65b0', '\u65e7', '\u5f88', '\u66f4', '\u6700', |
| '\u5c31', '\u90fd', '\u4e5f', '\u8fd8', '\u53c8', '\u624d', |
| |
| '\u5305\u62ec', '\u6839\u636e', '\u901a\u8fc7', '\u9700\u8981', |
| '\u663e\u793a', '\u6210\u4e3a', '\u4f5c\u4e3a', '\u8fdb\u884c', |
| '\u4f7f\u7528', '\u91c7\u7528', '\u5177\u6709', '\u5b58\u5728', |
| '\u53d1\u751f', '\u51fa\u73b0', '\u9009\u62e9', '\u7ed3\u5408', |
| '\u5bf9\u6bd4', '\u8fd1\u5e74', '\u91cd\u8981', '\u5408\u9002', |
| '\u4f18\u52bf', '\u5176\u9ad8', '\u7ed3\u679c', '\u7a81\u7834', |
| '\u663e\u8457', '\u8f83\u4f4e', '\u5305\u62ec', |
| '\u65b9\u6cd5', '\u9886\u57df', '\u5b9e\u8df5', '\u4f18\u52bf', |
| |
| '\u5e38\u89c1', '\u4e3b\u8981', '\u57fa\u672c', '\u4e00\u822c', '\u67d0\u4e9b', |
| '\u5404\u79cd', '\u4e0d\u540c', '\u76f8\u5173', '\u5176\u4ed6', '\u8fd9\u79cd', |
| }) |
|
|
| |
| _SKIP_POS = frozenset({'w', 'x', 'm', 'q', 'r', 'e', 'o', 'y', 'z', 'k', 'h', 'f'}) |
|
|
|
|
| def _extract_phrases(text: str, top_n: int) -> list[TermEntry]: |
| """Extract multi-word phrases by POS chunking with stop-word filtering. |
| |
| Approach: |
| 1. Segment with jieba POS tagging |
| 2. Mark each token as content, stop, or connector |
| 3. Build noun-centered phrases of 2-5 consecutive content tokens |
| 4. Filter: must contain at least one noun, no edge stop words |
| 5. Score by frequency * length bonus |
| |
| This is based on the approach used by JioNLP and textrank4zh. |
| """ |
| try: |
| import jieba.posseg as pseg |
|
|
| words = list(pseg.cut(text)) |
|
|
| |
| tokens: list[dict] = [] |
| for w in words: |
| word = w.word.strip() |
| flag = w.flag |
|
|
| if not word: |
| continue |
|
|
| is_skip = ( |
| flag in _SKIP_POS or |
| word in _PHRASE_STOP_WORDS or |
| len(word) == 1 and flag not in ('eng', 'n', 'a', 'v') |
| ) |
|
|
| is_content = not is_skip and ( |
| flag in _MOD_POS or |
| (len(word) >= 2 and flag not in ('t', 'c', 'p', 'd', 'u', 'uj', 'ul', 'uv', 'ug')) |
| ) |
|
|
| tokens.append({ |
| "word": word, |
| "flag": flag, |
| "is_content": is_content, |
| "is_skip": is_skip, |
| "len": len(word), |
| }) |
|
|
| |
| candidates: list[tuple[str, int]] = [] |
|
|
| i = 0 |
| while i < len(tokens): |
| if not tokens[i]["is_content"]: |
| i += 1 |
| continue |
|
|
| parts = [] |
| j = i |
| while j < len(tokens) and (j - i) < 5: |
| t = tokens[j] |
| if t["is_content"]: |
| parts.append(t["word"]) |
| j += 1 |
| elif t["is_skip"]: |
| break |
| elif t["flag"] in ('uj', 'ul', 'uv'): |
| |
| if len(parts) > 0 and (j + 1) < len(tokens) and tokens[j + 1]["is_content"]: |
| parts.append(t["word"]) |
| j += 1 |
| else: |
| break |
| else: |
| break |
|
|
| if len(parts) >= 2: |
| phrase = "".join(parts) |
| |
| if 3 <= len(phrase) <= 24: |
| |
| has_noun = any( |
| tokens[i + k]["flag"] in _NOUN_POS |
| for k in range(len(parts)) |
| ) |
| if has_noun: |
| candidates.append((phrase, i)) |
|
|
| |
| i += 1 |
|
|
| |
| counter = Counter(p for p, _ in candidates) |
| total = max(1, sum(counter.values())) |
| results: list[TermEntry] = [] |
|
|
| for phrase, count in counter.most_common(top_n * 3): |
| if count < 1: |
| break |
| |
| length_bonus = min(2.0, 1.0 + max(0, len(phrase) - 4) * 0.1) |
| score = (count / total) * length_bonus |
| results.append(TermEntry( |
| term=phrase, frequency=count, |
| score=score, word_type="phrase", |
| )) |
|
|
| return results |
| except Exception: |
| return [] |
|
|
|
|
| |
| |
| |
|
|
| def _extract_proper_nouns(text: str) -> list[TermEntry]: |
| """Extract proper nouns: capitalized words, acronyms, numbers+units.""" |
| results: list[TermEntry] = [] |
|
|
| |
| capitalized = re.findall(r"\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\b", text) |
| for phrase in set(capitalized): |
| results.append(TermEntry(term=phrase, score=0.5, word_type="proper_noun")) |
|
|
| |
| pascal = re.findall(r"\b(?:[A-Z][a-z]+){2,}\b|\b[A-Z][a-zA-Z]*[a-z][A-Z][a-zA-Z]*\b", text) |
| for p in set(pascal): |
| if len(p) > 4: |
| results.append(TermEntry(term=p, score=0.5, word_type="proper_noun")) |
|
|
| |
| acronyms = re.findall(r"\b([A-Z]{2,5})\b", text) |
| for acr in set(acronyms): |
| results.append(TermEntry(term=acr, score=0.6, word_type="proper_noun")) |
|
|
| |
| |
| cn_count = len(re.findall(r"[\u4e00-\u9fff]", text)) |
| if cn_count > 0: |
| en_phrases = re.findall(r"\b([A-Za-z][a-z]+(?:\s+[a-z]+){1,3})\b", text) |
| for phrase in set(en_phrases): |
| |
| if 6 < len(phrase) < 40 and phrase.count(' ') <= 3: |
| |
| low = phrase.lower() |
| if not any(w in low for w in (' the ', ' a ', ' an ', ' is ', ' are ', ' was ', ' were ', ' has ', ' have ', ' had ', ' and ', ' for ', ' with ', ' that ', ' this ', ' their ', ' they ', ' shows ', ' plays ', ' role ')): |
| results.append(TermEntry(term=phrase, word_type="proper_noun")) |
| else: |
| |
| |
| pass |
|
|
| return results |
|
|
|
|
| |
| |
| |
|
|
| def _extract_pke_zh(text: str, top_n: int) -> list[TermEntry]: |
| """Extract Chinese keyphrases using pke_zh PositionRank. |
| |
| PositionRank is a graph-based algorithm that considers both word |
| frequency and position in the document. It produces better |
| multi-word phrases than pure TF-IDF or TextRank. |
| """ |
| try: |
| from pke_zh import PositionRank |
|
|
| m = PositionRank() |
| results_raw = m.extract(text) |
| results: list[TermEntry] = [] |
| for word, score in results_raw[:top_n]: |
| if len(word) >= 2: |
| |
| results.append(TermEntry( |
| term=word, |
| score=min(1.0, score) * 0.95, |
| word_type="positionrank", |
| )) |
| return results |
| except Exception: |
| return [] |
|
|
|
|
| |
| |
| |
|
|
| _EN_STOP_WORDS = frozenset({ |
| 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being', |
| 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'shall', |
| 'should', 'may', 'might', 'must', 'can', 'could', 'i', 'me', 'my', |
| 'we', 'our', 'you', 'your', 'he', 'she', 'it', 'they', 'them', |
| 'this', 'that', 'these', 'those', 'in', 'on', 'at', 'to', 'for', |
| 'of', 'from', 'by', 'with', 'about', 'as', 'into', 'through', |
| 'during', 'before', 'after', 'above', 'below', 'between', 'under', |
| 'and', 'but', 'or', 'nor', 'not', 'so', 'yet', 'if', 'than', |
| 'too', 'very', 'just', 'also', 'now', 'then', 'here', 'there', |
| 'all', 'each', 'every', 'both', 'few', 'more', 'most', 'other', |
| 'some', 'such', 'only', 'own', 'same', 'its', 'his', 'her', |
| 'their', 'our', 'no', 'up', 'out', 'over', 'down', 'off', |
| }) |
|
|
|
|
| def _extract_yake(text: str, top_n: int) -> list[TermEntry]: |
| """Extract English keyphrases using YAKE algorithm. |
| |
| YAKE is an unsupervised, language-independent keyphrase extraction |
| method that uses statistical features (casing, position, frequency, |
| relatedness, dispersion) to score candidate phrases. |
| |
| Falls back to a simple n-gram approach if YAKE is unavailable. |
| """ |
| try: |
| import yake |
|
|
| |
| kw_extractor = yake.KeywordExtractor( |
| lan="en", |
| n=3, |
| dedupLim=0.65, |
| top=top_n * 2, |
| features=None, |
| ) |
| raw = kw_extractor.extract_keywords(text) |
| results: list[TermEntry] = [] |
|
|
| |
| |
| if not raw: |
| return results |
| scores = [s for _, s in raw] |
| min_s, max_s = min(scores), max(scores) |
| score_range = max_s - min_s if max_s > min_s else 1.0 |
|
|
| for phrase, score in raw[:top_n]: |
| phrase = phrase.strip() |
| if len(phrase) < 3: |
| continue |
| |
| if len(phrase.split()) < 2: |
| continue |
| |
| if _is_grammar_fragment(phrase): |
| continue |
| normalized = 1.0 - (score - min_s) / score_range |
| |
| boost = _get_term_boost(phrase) |
| results.append(TermEntry( |
| term=phrase, |
| score=round(normalized * 0.85 * boost, 4), |
| word_type="yake", |
| )) |
| return results |
| except Exception: |
| return _fallback_en_phrases(text, top_n) |
|
|
|
|
| def _fallback_en_phrases(text: str, top_n: int) -> list[TermEntry]: |
| """Fallback English phrase extraction without YAKE.""" |
| import re |
| from collections import Counter |
|
|
| |
| words = re.findall(r"[a-zA-Z]{2,}", text.lower()) |
| if len(words) < 3: |
| return [] |
|
|
| |
| candidates: list[tuple[str, float]] = [] |
| for n in (2, 3, 4): |
| for i in range(len(words) - n + 1): |
| ngram = words[i:i + n] |
| |
| if ngram[0] in _EN_STOP_WORDS or ngram[-1] in _EN_STOP_WORDS: |
| continue |
| |
| stop_count = sum(1 for w in ngram if w in _EN_STOP_WORDS) |
| if stop_count > n // 3: |
| continue |
| phrase = " ".join(ngram) |
| candidates.append((phrase, n * 0.1)) |
|
|
| if not candidates: |
| return [] |
|
|
| counter = Counter(p for p, _ in candidates) |
| total = max(1, sum(counter.values())) |
| results: list[TermEntry] = [] |
| for phrase, count in counter.most_common(top_n): |
| if count < 2: |
| continue |
| score = (count / total) * (1 + min(0.3, len(phrase.split()) * 0.1)) |
| results.append(TermEntry( |
| term=phrase, |
| frequency=count, |
| score=score, |
| word_type="en-ngram", |
| )) |
| return results |
|
|
|
|
| |
| |
| |
|
|
| def _extract_cvalue(text: str, top_n: int, is_cn: bool) -> list[TermEntry]: |
| """Extract terms using the C-Value algorithm. |
| |
| C-Value is an established algorithm that: |
| 1. Extracts candidate noun phrases |
| 2. Scores by frequency Γ logβ(length) |
| 3. Penalizes nested sub-terms (e.g., "ζ£ζ₯ηΉ" nested in "ε
η«ζ£ζ₯ηΉζεΆε") |
| 4. Rewards context-independent terms |
| |
| This produces significantly cleaner results than pure frequency-based methods. |
| """ |
| import math |
| from collections import defaultdict |
|
|
| |
| candidates_raw: list[str] = [] |
| if is_cn: |
| from termprep.extractor import _extract_phrases as _phrases |
| for t in _phrases(text, top_n * 3): |
| candidates_raw.append(t.term) |
| else: |
| |
| try: |
| import yake |
| kw = yake.KeywordExtractor(lan="en", n=2, dedupLim=0.8, top=top_n * 2) |
| for phrase, _ in kw.extract_keywords(text): |
| phrase = phrase.strip() |
| if len(phrase) > 3 and len(phrase.split()) >= 2: |
| candidates_raw.append(phrase) |
| except Exception: |
| pass |
|
|
| if not candidates_raw: |
| return [] |
|
|
| |
| freq: dict[str, int] = defaultdict(int) |
| for phrase in candidates_raw: |
| freq[phrase] += 1 |
|
|
| |
| |
| def is_nested(shorter: str, longer: str) -> bool: |
| if len(shorter) >= len(longer): |
| return False |
| if is_cn: |
| return shorter in longer |
| else: |
| sw = set(shorter.lower().split()) |
| lw = set(longer.lower().split()) |
| return sw.issubset(lw) and shorter.lower() != longer.lower() |
|
|
| |
| all_phrases = list(freq.keys()) |
| scores: dict[str, float] = {} |
|
|
| for phrase in all_phrases: |
| word_count = len(phrase) if is_cn else len(phrase.split()) |
| f_a = freq[phrase] |
|
|
| |
| containing = [p for p in all_phrases if is_nested(phrase, p)] |
| containing_freq_sum = sum(freq[p] for p in containing) |
|
|
| if not containing: |
| |
| scores[phrase] = math.log2(max(2, word_count)) * f_a / max(1, len(phrase) / 4) |
| else: |
| |
| c = len(containing) |
| scores[phrase] = math.log2(max(2, word_count)) * (f_a - containing_freq_sum / c) |
|
|
| |
| min_score = max(0.01, max(scores.values()) * 0.05) if scores else 0 |
| results: list[TermEntry] = [] |
| for phrase, score in sorted(scores.items(), key=lambda x: -x[1]): |
| if score < min_score: |
| continue |
| if len(phrase) < 3: |
| continue |
| results.append(TermEntry( |
| term=phrase, |
| frequency=freq[phrase], |
| score=min(1.0, score / max(1, max(scores.values()))), |
| word_type="cvalue", |
| )) |
| if len(results) >= top_n: |
| break |
|
|
| return results |
|
|
|
|
| def _is_grammar_fragment(phrase: str) -> bool: |
| """Filter out grammatical fragments that aren't real terms.""" |
| words = phrase.lower().split() |
| if not words: |
| return True |
| |
| start_stop = {'the', 'a', 'an', 'in', 'on', 'at', 'to', 'for', 'of', 'by', 'with', 'and', 'or', 'is', 'are', 'was', 'were', 'be', 'been', 'versus', 'vs', 'per'} |
| end_stop = {'the', 'a', 'an', 'in', 'on', 'at', 'to', 'for', 'of', 'by', 'with', 'and', 'or', 'is', 'are', 'was', 'were', 'be', 'been'} |
| if words[0] in start_stop: |
| return True |
| if words[-1] in end_stop: |
| return True |
| |
| internal_stop = {' of ', ' and ', ' or ', ' but ', ' with ', ' for ', ' in ', ' on ', ' at ', ' by ', ' to ', ' from ', ' as ', ' that ', ' which ', ' who ', ' when ', ' where ', ' like '} |
| lowered = ' ' + phrase.lower() + ' ' |
| if any(token in lowered for token in internal_stop): |
| return True |
| |
| verb_patterns = {'examined', 'receiving', 'using', 'showed', 'compared', 'treated', 'demonstrated', 'shows', 'plays', 'includes', 'including', 'making', 'taken', 'given', 'found', 'generate', 'revolutionized', 'enabling', 'achieved', 'require', 'requires', 'required', 'requiring', 'learn', 'learns', 'learned', 'learning', 'achieve', 'achieves'} |
| if len(words) >= 2 and words[-1] in verb_patterns: |
| return True |
| |
| start_verbs = {'is', 'are', 'was', 'were', 'has', 'have', 'had', 'can', 'could', 'will', 'would', 'should', 'may', 'might', 'must', 'shall', 'does', 'did', 'do', 'generates', 'revolutionized', 'enables', 'allows', 'makes', 'takes', 'gives', 'shows', 'achieved', 'understand', 'generate', 'enabled', 'learning', 'requires', 'required', 'achieve', 'requiring', 'learned', 'learns', 'require'} |
| if words[0] in start_verbs: |
| return True |
| return False |
|
|
|
|
| def _get_term_boost(phrase: str) -> float: |
| """Boost score for terms validated by the local domain dictionary. |
| |
| Avoids network calls during the extraction hot path; Wikidata validation |
| is available separately via termbase.lookup_term(). |
| """ |
| try: |
| from termprep.termbase import DOMAIN_TERMS |
| low = phrase.lower() |
| for domain, terms in DOMAIN_TERMS.items(): |
| if any(t.lower() == low for t in terms): |
| return 1.3 |
| except Exception: |
| pass |
| return 1.0 |
|
|
|
|
| |
| |
| |
|
|
| def _merge_and_rank(terms: list[TermEntry], top_n: int) -> list[TermEntry]: |
| """Merge duplicate terms across extraction methods, remove overlaps, and rank. |
| |
| Strategy: |
| - Normalize keys by lowercasing and strip whitespace |
| - Filter out very short single English words (uninformative) |
| - Remove subsumed terms: if a longer phrase fully contains a shorter one, |
| keep only the longer, more complete phrase |
| - Sum frequencies and scores for merged terms |
| - Prefer more descriptive word_type labels |
| """ |
| TYPE_PRIORITY = {"phrase": 0, "proper_noun": 1, "positionrank": 2, "cvalue": 3, "textrank": 4, "keyword": 5, "yake": 6, "bigram": 7, "en-ngram": 8} |
|
|
| |
| merged: dict[str, TermEntry] = {} |
| for t in terms: |
| key = t.term.lower().strip() |
| if not key or len(key) < 2: |
| continue |
| |
| if len(key.split()) == 1 and not re.search(r'[\u4e00-\u9fff]', key): |
| |
| original = t.term.strip() |
| if len(original) >= 2 and len(original) <= 5 and original.isupper(): |
| pass |
| else: |
| continue |
| if key in merged: |
| existing = merged[key] |
| existing.score += t.score |
| existing.frequency += t.frequency |
| if TYPE_PRIORITY.get(t.word_type, 99) < TYPE_PRIORITY.get(existing.word_type, 99): |
| existing.word_type = t.word_type |
| else: |
| merged[key] = t |
|
|
| |
| sorted_keys = sorted(merged.keys(), key=lambda k: len(k), reverse=True) |
| to_remove: set[str] = set() |
|
|
| for long_key in sorted_keys: |
| if long_key in to_remove: |
| continue |
| long_words = long_key.split() |
| for short_key in sorted_keys: |
| if short_key == long_key or short_key in to_remove: |
| continue |
| if len(short_key) >= len(long_key): |
| continue |
|
|
| |
| if ' ' in long_key or ' ' in short_key: |
| |
| short_words = short_key.split() |
| if len(short_words) < 2 and len(long_words) <= 2: |
| |
| |
| continue |
| |
| try: |
| idx = 0 |
| for sw in short_words: |
| idx = long_words.index(sw, idx) + 1 |
| |
| to_remove.add(short_key) |
| except ValueError: |
| pass |
| else: |
| |
| if short_key in long_key and len(long_key) - len(short_key) >= 2: |
| to_remove.add(short_key) |
|
|
| |
| for k in to_remove: |
| if k in merged: |
| del merged[k] |
|
|
| |
| merged = {k: v for k, v in merged.items() if not _is_grammar_fragment(v.term)} |
|
|
| |
| if merged: |
| all_scores = [t.score for t in merged.values()] |
| max_sc = max(all_scores) |
| if max_sc > 0: |
| for t in merged.values(): |
| t.score = round(t.score / max_sc, 4) |
|
|
| sorted_terms = sorted(merged.values(), key=lambda x: (x.score, -len(x.term)), reverse=True) |
| return sorted_terms[:top_n] |
|
|
|
|
| |
| |
| |
|
|
| def get_frequency_table(terms: list[TermEntry]) -> str: |
| """Format terms as a frequency table string.""" |
| lines = [f"{'Term':<24} {'Freq':>6} {'Score':>8} {'Type':>14}"] |
| lines.append("-" * 56) |
| for t in terms: |
| lines.append( |
| f"{t.term:<24} {t.frequency:>6} {t.score:>8.4f} {t.word_type:>14}" |
| ) |
| return "\n".join(lines) |
|
|
|
|
| def debug_extraction(text: str, top_n: int = 10) -> str: |
| """Return a diagnostic string showing what each method extracted.""" |
| parts: list[str] = [] |
| parts.append("βββ TF-IDF βββ") |
| for t in _extract_jieba(text, top_n): |
| parts.append(f" {t.term:20s} score={t.score:.4f}") |
| parts.append("βββ TextRank βββ") |
| for t in _extract_textrank(text, top_n): |
| parts.append(f" {t.term:20s} score={t.score:.4f}") |
| parts.append("βββ POS Phrases βββ") |
| for t in _extract_phrases(text, top_n): |
| parts.append(f" {t.term:20s} freq={t.frequency} score={t.score:.4f}") |
| parts.append("βββ Proper Nouns βββ") |
| for t in _extract_proper_nouns(text): |
| parts.append(f" {t.term:20s} type={t.word_type}") |
| parts.append("βββ Merged Top-{top_n} βββ") |
| for t in extract(text, top_n): |
| parts.append(f" {t.term:20s} score={t.score:.4f} type={t.word_type}") |
| return "\n".join(parts) |
|
|