File size: 21,793 Bytes
162cb6f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
"""
structural_segmenter.py
=======================
Permissive segmenter for plays and novels. Handles smushed lines.

Enhancements over v1:
  - Emotion tagging on dialogue blocks (lazily loaded EmotionAnalyzer)
  - Better character cue parsing (handles "ROMEO.", "LADY MACBETH", "FIRST WITCH")
  - French play/novel headings (ACTE, SCÈNE, CHAPITRE, PARTIE)
  - Stage direction parsing improved (handles both () and [])
  - Novel paragraph grouping: consecutive lines merged into paragraphs
  - language parameter propagated through for multilingual emotion analysis
"""

from __future__ import annotations

import re
import uuid
from collections import namedtuple
from typing import Any, Dict, List, Optional, Tuple

import os
from services.hf_inference_service import HFInferenceService

# Initialize HF Inference Service
_hf_inference = HFInferenceService(os.getenv("HF_API_TOKEN"))

def _ai_is_heading(text: str) -> bool:
    """
    Ask the cloud AI whether ``text`` is a chapter/section heading.
    Only called for short (≀ 80 char), isolated text blocks.
    """
    if os.getenv("USE_HF_INFERENCE") == "1" and _hf_inference.api_token:
        try:
            return _hf_inference.is_heading(text)
        except Exception as e:
            print(f"⚠️  AI heading check error: {e}")
            pass
    return False

# ── Data types ────────────────────────────────────────────────────────────────

Span = namedtuple("Span", ["text", "size", "flags", "page"])

# ── Helpers ───────────────────────────────────────────────────────────────────

def _uid() -> str:
    return str(uuid.uuid4())[:8]


# ── Heading regexes ───────────────────────────────────────────────────────────

# English play headings
_ACT_RE      = re.compile(r"\bACT\s+([IVX]+|\d+)\b", re.IGNORECASE)
_SCENE_RE    = re.compile(r"\bSCENE\s+([IVX]+|\d+)\b", re.IGNORECASE)
# Abbreviated scene (e.g. Folger Shakespeare "SC. 2")
_SCENE_SC_RE = re.compile(r"\bSC\.\s*(\d+|[IVX]+)\b", re.IGNORECASE)

# French play headings
_ACTE_RE     = re.compile(r"\bACTE\s+([IVX]+|\d+)\b", re.IGNORECASE)
_SCENE_FR_RE = re.compile(r"\bSC[ÈE]NE\s+([IVX]+|\d+)\b", re.IGNORECASE)

# Running-header detector: lines starting or ending with a page number, 
# or containing book title artifacts.
_RUNNING_HEADER_RE = re.compile(
    r"^\d{1,4}\s+\S|"          # "9 MACBETH..."
    r"^.*\s+\d{1,4}$|"         # "...Before Breakfast 3"
    r"^[A-Z\s]{5,}\s+\d{1,4}$" # "CHARLOTTE'S WEB 4"
)

# English novel headings
_CHAPTER_RE  = re.compile(
    r"\b(CHAPTER|PROLOGUE|EPILOGUE|PART|BOOK)\s+([\dIVX]+|[A-Z][a-z]+)?\b",
    re.IGNORECASE,
)

# French novel headings
_CHAPITRE_RE = re.compile(r"\bCHAPITRE\s+([\dIVX]+|[A-Z][a-z]+)?\b", re.IGNORECASE)
_PARTIE_RE   = re.compile(r"\bPARTIE\s+([\dIVX]+|[A-Z][a-z]+)?\b", re.IGNORECASE)
_LIVRE_RE    = re.compile(r"\bLIVRE\s+([\dIVX]+|[A-Z][a-z]+)?\b", re.IGNORECASE)

# Character cue: ALL CAPS line (1–5 words), optional period/colon, <30 chars total
# Handles: "ROMEO", "LADY MACBETH", "FIRST WITCH.", "THE GHOST:"
_CUE_RE = re.compile(
    r"^[A-Z][A-Z\s'\.\d]{0,28}[A-Z]\.?\:?\s*$"
)

# Stage directions
_STAGE_INLINE_RE = re.compile(r"^\[.*\]$|^\(.*\)$")

# Default neutral animation block
_NEUTRAL_ANIM = {
    "expression": "neutral",
    "eyebrows":   "neutral",
    "mouth":      "closed",
    "eyes":       "normal",
    "color_tint": "#f1f5f9",
}


# ── Segmenter ─────────────────────────────────────────────────────────────────

class StructuralSegmenter:
    """
    Converts a flat list of PyMuPDF block dicts into a hierarchical structure:
      - Plays:  [Act β†’ [Scene β†’ [blocks]]]
      - Novels: [Chapter β†’ [Section β†’ paragraphs]]

    After segmentation, dialogue blocks in plays can be optionally enriched
    with emotion data via the EmotionAnalyzer singleton.
    """

    # ── Public API ─────────────────────────────────────────────────────────────

    def segment(
        self,
        all_blocks: List[Dict[str, Any]],
        doc_type: str,
        language: str = "en",
        add_emotions: bool = True,
        ai_headings: Optional[List[Dict[str, str]]] = None,
    ) -> List[Dict[str, Any]]:
        """
        Segment blocks into hierarchical units.

        Parameters
        ----------
        all_blocks:   List of PyMuPDF block dicts.
        doc_type:     "play" | "novel" | "generic"
        language:     "en" | "fr"
        add_emotions: If True, run EmotionAnalyzer on dialogue blocks (plays only).
        ai_headings:  Optional list of metadata-inferred headings [{"title": "...", "text": "..."}].
        """
        self.ai_headings = ai_headings or []
        tokens = self._tokenise(all_blocks, doc_type)

        if doc_type == "play":
            units = self._build_play_hierarchy(tokens)
            if add_emotions:
                self._enrich_play_emotions(units, language)
            return units

        if doc_type == "poem":
            return self._build_poem_hierarchy(tokens)

        return self._build_novel_hierarchy(tokens)

    # ── Heading threshold ───────────────────────────────────────────────────

    @staticmethod
    def _compute_heading_threshold(spans: List[Span]) -> float:
        """Compute a font-size threshold above which text is likely a heading."""
        if not spans:
            return 14.0
        sizes = sorted(s.size for s in spans)
        n = len(sizes)
        median = sizes[n // 2] if n % 2 else (sizes[n // 2 - 1] + sizes[n // 2]) / 2
        max_size = sizes[-1]
        return median + (max_size - median) * 0.5

    # ── Tokenisation ──────────────────────────────────────────────────────────

    def _tokenise(
        self, blocks: List[Dict[str, Any]], doc_type: str
    ) -> List[Dict[str, Any]]:
        tokens: List[Dict[str, Any]] = []

        for b in blocks:
            if b.get("type") != 0:
                continue
            for line_obj in b.get("lines", []):
                txt = "".join(
                    s.get("text", "") for s in line_obj.get("spans", [])
                ).strip()
                if not txt:
                    continue

                level, matched = self._match_heading(txt, doc_type)
                if matched:
                    tokens.append({
                        "type":     "heading",
                        "level":    level,
                        "title":    txt,
                        "inferred": False,
                    })
                else:
                    tokens.append({"type": "content", "text": txt})

        return tokens

    def _match_heading(self, text: str, doc_type: str) -> Tuple[str, bool]:
        """
        Check for heading patterns. 
        Priority: AI-discovered headings -> Regex patterns.
        """
        clean_text = self._normalize_heading(text)
        
        # 1. AI Anchor Match (highest priority)
        if hasattr(self, "ai_headings") and self.ai_headings:
            for ah in self.ai_headings:
                target = self._normalize_heading(ah.get("text", ah.get("title", "")))
                if target and target in clean_text:
                    # Treat as chapter-level for novels/poems, act/scene follows regex logic below
                    return "chapter", True

        t = text[:30]
        # 2. Strong Regex Patterns (must check BEFORE running header as "CHAPTER 2" matches running header)
        if doc_type == "play":
            if _ACT_RE.search(t):      return "act",   True
            if _ACTE_RE.search(t):     return "act",   True
            if _SCENE_RE.search(t):    return "scene", True
            if _SCENE_SC_RE.search(t): return "scene", True
            if _SCENE_FR_RE.search(t): return "scene", True
        else:
            if _CHAPTER_RE.search(t):  return "chapter", True
            if _CHAPITRE_RE.search(t): return "chapter", True
            if _PARTIE_RE.search(t):   return "chapter", True
            if _LIVRE_RE.search(t):    return "chapter", True

        # 3. Skip running headers (page numbers, book titles at edges)
        if _RUNNING_HEADER_RE.match(text.strip()):
            return "none", False

        # 4. Mistral-7B fallback: ask the model for short, isolated text
        #    that looks like it could be an unnamed heading (e.g. "The Storm",
        #    "I.", roman numerals, numbered titles without the word CHAPTER).
        if len(text.strip()) <= 80 and _ai_is_heading(text.strip()):
            return "chapter", True

        return "none", False

    @staticmethod
    def _normalize_heading(text: str) -> str:
        """Lowercases and strips common punctuation/whitespace for comparison."""
        return re.sub(r'[^a-zA-Z0-9]', '', text.lower())

    # ── Play hierarchy ─────────────────────────────────────────────────────────

    def _build_play_hierarchy(
        self, tokens: List[Dict[str, Any]]
    ) -> List[Dict[str, Any]]:
        acts: List[Dict[str, Any]] = []
        cur_act:   Optional[Dict] = None
        cur_scene: Optional[Dict] = None
        content_buffer: List[str] = []

        def flush():
            if cur_scene is not None and content_buffer:
                cur_scene["blocks"].extend(
                    self._parse_play_content(content_buffer)
                )
                content_buffer.clear()

        for tok in tokens:
            if tok["type"] == "heading":
                if tok["level"] == "act":
                    flush()
                    if cur_act and cur_scene:
                        cur_act["children"].append(cur_scene)
                    if cur_act:
                        acts.append(cur_act)
                    cur_act   = {"id": _uid(), "title": tok["title"], "children": []}
                    cur_scene = None

                elif tok["level"] == "scene":
                    flush()
                    if cur_act and cur_scene:
                        cur_act["children"].append(cur_scene)
                    if cur_act is None:
                        cur_act = {"id": _uid(), "title": "ACT I", "children": [], "inferred": True}
                    cur_scene = {"id": _uid(), "title": tok["title"], "blocks": []}

            else:  # content token
                if cur_act is None:
                    continue
                if cur_scene is None:
                    cur_scene = {
                        "id": _uid(), "title": "Scene 1", "blocks": [],
                        "inferred": True,
                    }
                content_buffer.append(tok["text"])

        # Flush final scene/act
        flush()
        if cur_act and cur_scene:
            cur_act["children"].append(cur_scene)
        if cur_act:
            acts.append(cur_act)

        return acts if acts else self._play_fallback(tokens)

    def _parse_play_content(self, lines: List[str]) -> List[Dict[str, Any]]:
        """
        Convert flat text lines into structured block dicts.

        Block types:
          "dialogue"        – character speech
          "stage_direction" – [Enter Juliet] or (aside)
          "narrative"       – other text
        """
        blocks: List[Dict[str, Any]] = []

        for line in lines:
            stripped = line.strip()
            if not stripped:
                continue

            # Strip leading page numbers smushed with text (e.g. "11She's" -> "She's")
            stripped = re.sub(r'^\d{1,3}([A-Z])', r'\1', stripped)

            # Skip running headers
            if _RUNNING_HEADER_RE.match(stripped) or _RUNNING_HEADER_RE.search(stripped):
                if len(stripped) < 50: # Headers are usually short
                    continue

            # Stage direction: entire line wrapped in () or []
            if _STAGE_INLINE_RE.match(stripped):
                blocks.append({
                    "type":      "stage_direction",
                    "character": None,
                    "content":   stripped.strip("[]()").strip(),
                })
                continue

            # Character cue (ALL CAPS, ≀6 words, short line)
            word_count = len(stripped.split())
            if _CUE_RE.match(stripped) and word_count <= 6:
                cue = stripped.rstrip(":. \t")
                blocks.append({
                    "type":      "dialogue",
                    "character": cue,
                    "content":   "",
                    "emotion":   "neutral",
                    "intensity": 0.5,
                    "anim":      dict(_NEUTRAL_ANIM),
                })
                continue

            # Append to last dialogue block if mid-speech
            if blocks and blocks[-1]["type"] == "dialogue":
                # Skip bare page numbers (e.g. "5", "12") slipping into speech
                if re.match(r"^\d{1,4}$", stripped):
                    continue
                sep = " " if blocks[-1]["content"] else ""
                blocks[-1]["content"] += sep + stripped
                continue

            # Narrative line
            blocks.append({"type": "narrative", "content": stripped})

        return blocks

    def _play_fallback(self, tokens: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
        """Single-unit fallback when no act/scene headings found."""
        text_lines = [t["text"] for t in tokens if t["type"] == "content"]
        return [{
            "id":    _uid(),
            "title": "The Play",
            "children": [{
                "id":     _uid(),
                "title":  "Content",
                "blocks": self._parse_play_content(text_lines),
            }],
        }]

    # ── Novel hierarchy ────────────────────────────────────────────────────────

    def _build_novel_hierarchy(
        self, tokens: List[Dict[str, Any]]
    ) -> List[Dict[str, Any]]:
        chapters: List[Dict[str, Any]] = []
        MAX_CHUNK_WORDS = 250

        def new_chapter(title: str) -> Dict[str, Any]:
            return {
                "id": _uid(),
                "title": title,
                "children": [],  # We will add chunks here
                "content": "",
            }

        def new_chunk(title: str = "") -> Dict[str, Any]:
            return {
                "id": _uid(),
                "title": title,
                "paragraphs": [],
                "content": "",
                "word_count": 0
            }

        cur_chapter = new_chapter("Beginning")
        cur_chunk = new_chunk("Introduction")
        
        for tok in tokens:
            if tok["type"] == "heading" and tok["level"] == "chapter":
                # Flush current chunk and chapter
                if cur_chunk["paragraphs"]:
                    cur_chapter["children"].append(cur_chunk)
                if cur_chapter["children"]:
                    self._finalize_chapter(cur_chapter)
                    chapters.append(cur_chapter)
                
                cur_chapter = new_chapter(tok["title"])
                cur_chunk = new_chunk()
            
            elif tok["type"] == "content":
                text = tok["text"]
                words = len(text.split())
                
                # If adding this would explode the chunk, flush first
                if cur_chunk["word_count"] > 0 and (cur_chunk["word_count"] + words) > MAX_CHUNK_WORDS:
                    cur_chapter["children"].append(cur_chunk)
                    cur_chunk = new_chunk()
                
                cur_chunk["paragraphs"].append(text)
                cur_chunk["word_count"] += words

        # Final flush
        if cur_chunk["paragraphs"]:
            cur_chapter["children"].append(cur_chunk)
        if cur_chapter["children"]:
            self._finalize_chapter(cur_chapter)
            chapters.append(cur_chapter)

        return chapters

    @staticmethod
    def _finalize_chapter(chapter: Dict[str, Any]):
        """Finalize all chunks in the chapter and set overall content."""
        all_content = []
        for i, chunk in enumerate(chapter["children"]):
            chunk["content"] = "\n\n".join(chunk.get("paragraphs", []))
            # If no specific title, give it a sequence title
            if not chunk["title"]:
                chunk["title"] = f"Part {i+1}"
            all_content.append(chunk["content"])
        chapter["content"] = "\n\n".join(all_content)

    # ── Poem hierarchy ─────────────────────────────────────────────────────────

    def _build_poem_hierarchy(
        self, tokens: List[Dict[str, Any]]
    ) -> List[Dict[str, Any]]:
        """
        Build stanza-based hierarchy for poems.
        Groups consecutive content lines into stanzas, separated by blank gaps
        or heading tokens.
        """
        poems: List[Dict[str, Any]] = []
        current_poem: Optional[Dict] = None
        current_stanza: List[str] = []
        stanza_num = 0

        def flush_stanza():
            nonlocal stanza_num
            if current_poem is not None and current_stanza:
                stanza_num += 1
                current_poem["children"].append({
                    "id":    _uid(),
                    "title": f"Stanza {stanza_num}",
                    "blocks": [
                        {"type": "verse_line", "content": line}
                        for line in current_stanza
                    ],
                    "content": "\n".join(current_stanza),
                })
                current_stanza.clear()

        for tok in tokens:
            if tok["type"] == "heading":
                flush_stanza()
                if current_poem and current_poem["children"]:
                    poems.append(current_poem)
                current_poem = {"id": _uid(), "title": tok["title"], "children": []}
                stanza_num = 0
            else:
                text = tok.get("text", "").strip()
                if not text:
                    # Empty line = stanza break
                    flush_stanza()
                    continue

                if current_poem is None:
                    current_poem = {"id": _uid(), "title": "The Poem", "children": []}
                current_stanza.append(text)

        # Flush remaining
        flush_stanza()
        if current_poem and current_poem["children"]:
            poems.append(current_poem)

        if not poems:
            # Fallback: treat all content as one poem
            all_lines = [t["text"] for t in tokens if t["type"] == "content"]
            stanzas = []
            current: List[str] = []
            for line in all_lines:
                if not line.strip():
                    if current:
                        stanzas.append(current)
                        current = []
                else:
                    current.append(line)
            if current:
                stanzas.append(current)

            children = [
                {
                    "id": _uid(),
                    "title": f"Stanza {i + 1}",
                    "blocks": [{"type": "verse_line", "content": l} for l in st],
                    "content": "\n".join(st),
                }
                for i, st in enumerate(stanzas)
            ] if stanzas else [{
                "id": _uid(),
                "title": "Content",
                "blocks": [{"type": "verse_line", "content": l} for l in all_lines],
                "content": "\n".join(all_lines),
            }]

            poems = [{
                "id": _uid(),
                "title": "The Poem",
                "children": children,
            }]

        return poems

    # ── Emotion enrichment ────────────────────────────────────────────────────

    def _enrich_play_emotions(
        self, acts: List[Dict[str, Any]], language: str = "en"
    ):
        """
        Walk Act β†’ Scene β†’ Block tree and add emotion data to all dialogue blocks.
        """
        try:
            from .emotion_analyzer import get_emotion_analyzer
            ea = get_emotion_analyzer()
        except Exception as e:
            print(f"⚠️  StructuralSegmenter: emotion enrichment skipped ({e})")
            return

        for act in acts:
            for scene in act.get("children", []):
                blocks = scene.get("blocks", [])
                try:
                    ea.enrich_dialogue_blocks(blocks, language=language)
                except Exception as exc:
                    print(
                        f"⚠️  Emotion error in scene "
                        f"'{scene.get('title', '?')}': {exc}"
                    )