File size: 9,107 Bytes
228ac01
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68cd6b8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
"""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}"