Honest-Critic / analysis.py
Basementup's picture
Upload folder using huggingface_hub
68cd6b8 verified
Raw History Blame Contribute Delete
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}"