Spaces:
Running on Zero
Running on Zero
Download analysis.py from Basementup/Honest-Critic: direct link, hf CLI and curl.
- Browser
- Download file 9.11 kB
-
https://huggingface.co/spaces/Basementup/Honest-Critic/resolve/main/analysis.py
- Command line
-
hf download hf://spaces/Basementup/Honest-Critic/analysis.py
-
curl -L -o analysis.py https://huggingface.co/spaces/Basementup/Honest-Critic/resolve/main/analysis.py
9.11 kB
| """Deterministic, model-free analysis: syllables, stress, rhyme scheme, repetition, cliches, readability.""" | |
| import re | |
| from collections import Counter | |
| import pronouncing | |
| import pyphen | |
| import textstat | |
| _dic = pyphen.Pyphen(lang="en_GB") | |
| WORD = re.compile(r"[A-Za-z']+") | |
| CLICHES = [ | |
| "heart of gold", "tears like rain", "broken heart", "shattered heart", "dark night of the soul", | |
| "sea of tears", "cold as ice", "time stood still", "deafening silence", "silence is golden", | |
| "as old as time", "at the end of the day", "every cloud", "silver lining", "light at the end of the tunnel", | |
| "burning desire", "endless love", "forever and always", "heart and soul", "my heart bleeds", | |
| "stars in your eyes", "lost in your eyes", "soul mate", "fly away", "spread my wings", "rise from the ashes", | |
| "like a phoenix", "storm inside", "walls come crashing", "pierce my heart", "dance in the rain", | |
| "chasing dreams", "follow your heart", "against all odds", "fire in my soul", "fire within", | |
| "only time will tell", "the calm before the storm", "a heart of stone", "white as snow", "black as night", | |
| "red as blood", "frozen in time", "lost my way", "ocean of", "a single tear", "tear rolled down", | |
| "whispering wind", "gentle breeze", "golden sun", "silver moon", "crimson sky", "velvet night", | |
| "dreams come true", "never let go", "hold me tight", "lonely road", "shadows of my mind", | |
| "demons inside", "inner demons", "empty inside", "pain inside", "darkness consumes", "embrace the darkness", | |
| "piece of my heart", "rollercoaster of emotions", "in the blink of an eye", "beyond my wildest dreams", | |
| "the sands of time", "eyes are the window", "head over heels", "love is blind", "time heals", | |
| ] | |
| STOP = set("""a an the and or but if of to in on at by for with from as is are was were be been am i you he she it we they | |
| me my your his her its our their this that these those not no so do does did have has had will would can could shall | |
| should may might must there here then than when what who whom which while where how all any some just into out up down | |
| over under again too very s t don o""".split()) | |
| def words(text): | |
| return [w.lower().strip("'") for w in WORD.findall(text) if w.strip("'")] | |
| def _norm(w): | |
| w = w.lower().strip("'") | |
| return w[:-2] + "ed" if w.endswith("'d") else w | |
| def syllables(word): | |
| w = _norm(word) | |
| ph = pronouncing.phones_for_word(w) | |
| if ph: | |
| return pronouncing.syllable_count(ph[0]) | |
| return max(1, len(_dic.inserted(w).split("-"))) | |
| def stress(word): | |
| """CMUdict stress: 1/2 = stressed (/), 0 = unstressed (x). Monosyllables shown as '?' (context-dependent).""" | |
| ph = pronouncing.phones_for_word(_norm(word)) | |
| if not ph: | |
| n = syllables(word) | |
| return "?" * n | |
| s = pronouncing.stresses(ph[0]) | |
| if len(s) == 1: | |
| return "/" if word.lower() not in STOP else "x" | |
| return "".join("/" if c in "12" else "x" for c in s) | |
| def rhyme_key(word): | |
| ph = pronouncing.phones_for_word(_norm(word)) | |
| if ph: | |
| return pronouncing.rhyming_part(ph[0]) | |
| w = word.lower() | |
| m = re.search(r"[aeiouy]+[^aeiouy]*$", w) | |
| return m.group(0) if m else w[-2:] | |
| def rhyme_scheme(lines): | |
| seen, scheme, labels = [], [], "ABCDEFGHIJKLMNOPQRSTUVWXYZ" | |
| for ln in lines: | |
| ws = WORD.findall(ln) | |
| if not ws: | |
| scheme.append("-") | |
| continue | |
| w = _norm(ws[-1]) | |
| k = rhyme_key(w) | |
| match = None | |
| for kk, ww, lab in seen: | |
| if k == kk or (len(w) > 3 and len(ww) > 3 and w[-3:] == ww[-3:]): | |
| match = lab | |
| break | |
| if match is None: | |
| match = labels[len({l for _, _, l in seen}) % 26] | |
| seen.append((k, w, match)) | |
| scheme.append(match) | |
| return scheme | |
| def guess_metre(stress_str, syl): | |
| s = stress_str.replace("?", "x") | |
| if not s: | |
| return "" | |
| best, score = "free verse / irregular", 0.0 | |
| feet = {"iambic": "x/", "trochaic": "/x", "anapaestic": "xx/", "dactylic": "/xx"} | |
| for name, foot in feet.items(): | |
| pat = (foot * 20)[: len(s)] | |
| sc = sum(a == b for a, b in zip(s, pat)) / len(s) | |
| if sc > score: | |
| best, score = name, sc | |
| if score < 0.7: | |
| return "irregular" | |
| n = {2: "x/", 3: "xx/"}.get(len(feet[best])) | |
| count = round(syl / len(feet[best])) | |
| names = {1: "monometer", 2: "dimeter", 3: "trimeter", 4: "tetrameter", 5: "pentameter", 6: "hexameter", 7: "heptameter"} | |
| return f"{best} {names.get(count, '')}".strip() + f" (~{int(score*100)}% fit)" | |
| def scan_lines(text): | |
| lines = [l.rstrip() for l in text.splitlines()] | |
| content = [l for l in lines if l.strip()] | |
| scheme = rhyme_scheme(content) | |
| rows = [] | |
| for ln, sch in zip(content, scheme): | |
| ws = WORD.findall(ln) | |
| syl = sum(syllables(w) for w in ws) | |
| st = " ".join(stress(w) for w in ws) | |
| rows.append({"line": ln.strip(), "syllables": syl, "stress": st, | |
| "rhyme": sch, "metre": guess_metre(st.replace(" ", ""), syl)}) | |
| return rows | |
| def find_cliches(text): | |
| t = text.lower() | |
| return [c for c in CLICHES if c in t] | |
| def repeated_words(text, n=12): | |
| c = Counter(w for w in words(text) if w not in STOP and len(w) > 2) | |
| return [(w, k) for w, k in c.most_common(n) if k > 1] | |
| def sound_devices(lines): | |
| allit = [] | |
| for ln in lines: | |
| ws = [w.lower() for w in WORD.findall(ln) if w.lower() not in STOP] | |
| for a, b in zip(ws, ws[1:]): | |
| if a[0] == b[0] and a[0].isalpha(): | |
| allit.append(f"{a} / {b}") | |
| return allit[:15] | |
| def readability(text): | |
| try: | |
| return { | |
| "Flesch reading ease": round(textstat.flesch_reading_ease(text), 1), | |
| "Flesch-Kincaid grade": round(textstat.flesch_kincaid_grade(text), 1), | |
| "Words": len(words(text)), | |
| "Unique-word ratio": round(len(set(words(text))) / max(1, len(words(text))), 2), | |
| "Avg sentence length (words)": round(textstat.avg_sentence_length(text), 1) if hasattr(textstat, "avg_sentence_length") else "", | |
| } | |
| except Exception: | |
| return {"Words": len(words(text))} | |
| def analyse(text, poetry=True): | |
| out = {"readability": readability(text), "repeated": repeated_words(text), "cliches": find_cliches(text)} | |
| if poetry: | |
| rows = scan_lines(text) | |
| out["scansion"] = rows | |
| out["scheme"] = "".join(r["rhyme"] for r in rows) | |
| out["alliteration"] = sound_devices([r["line"] for r in rows]) | |
| return out | |
| def analysis_markdown(a, max_rows=60): | |
| md = ["### Deterministic analysis (no AI)"] | |
| md.append("**Readability:** " + "; ".join(f"{k}: {v}" for k, v in a["readability"].items() if v != "")) | |
| if a.get("scheme"): | |
| md.append(f"**Rhyme scheme (end-sound match):** `{a['scheme']}`") | |
| if a.get("scansion"): | |
| tbl = ["| # | Line | Syl | Stress (x = light, / = stressed, ? = unknown) | Rhyme | Metre guess |\n|---|---|---|---|---|---|"] | |
| for i, r in enumerate(a["scansion"][:max_rows], 1): | |
| line = r["line"].replace("|", "/") | |
| tbl.append(f"| {i} | {line} | {r['syllables']} | `{r['stress']}` | {r['rhyme']} | {r['metre']} |") | |
| md.append("\n".join(tbl)) | |
| if len(a["scansion"]) > max_rows: | |
| md.append(f"\n*…{len(a['scansion']) - max_rows} more lines in the downloadable report.*") | |
| md.append("**Most repeated content words:** " + (", ".join(f"{w} ×{k}" for w, k in a["repeated"]) or "none")) | |
| md.append("**Stock phrases / clichés found:** " + (", ".join(f"“{c}”" for c in a["cliches"]) or "none from the built-in list")) | |
| if a.get("alliteration"): | |
| md.append("**Alliterative pairs:** " + ", ".join(a["alliteration"])) | |
| md.append("\n*Scansion uses dictionary stresses (CMUdict); single-syllable words are context-dependent, so treat it as a helper, not a verdict.*") | |
| return "\n\n".join(md) | |
| def detect_form(text): | |
| rows = scan_lines(text) | |
| n = len(rows) | |
| scheme = "".join(r["rhyme"] for r in rows) | |
| syl = [r["syllables"] for r in rows] | |
| stanzas = [len([l for l in b.splitlines() if l.strip()]) for b in re.split(r"\n\s*\n", text) if b.strip()] | |
| avg = sum(syl) / max(1, n) | |
| if n == 14 and 9 <= avg <= 11.5: | |
| if scheme.endswith(scheme[-1] * 2) and len(set(scheme[-2:])) == 1: | |
| return "English (Shakespearean) sonnet: three quatrains and a closing rhyming couplet, iambic pentameter" | |
| return "sonnet (14 lines, roughly pentameter)" | |
| if n == 19 and len(stanzas) == 6: | |
| return "villanelle" | |
| if n == 3 and syl[:3] in ([5, 7, 5], [4, 7, 5], [5, 7, 4], [5, 8, 5]): | |
| return "haiku" | |
| if n == 5 and scheme[:5] in ("AABBA",): | |
| return "limerick" | |
| if stanzas and all(s == 4 for s in stanzas): | |
| return f"quatrain stanzas, rhyme scheme {scheme}" | |
| if stanzas and all(s == 2 for s in stanzas): | |
| return "couplets" | |
| if n and len(set(scheme)) > 0.8 * n: | |
| return "free verse (little or no end-rhyme)" | |
| return f"{n} lines in {len(stanzas)} stanza(s), rhyme scheme {scheme}" | |