File size: 11,627 Bytes
f74b782
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Workplace-Pulse-Telemetry: Semantic & Discursive Document Chunker
Engineered by Fabio Torres (neurodeveloper11)

Deconstructs multi-page corporate documents (meeting minutes, COPASST acts, incident reports, logs)
into semantically coherent discursive units without diluting psychometric indicators.
"""

import re
import uuid
from typing import List, Optional
from datetime import datetime

from src.schemas import DocumentChunk, RawMessage, ChannelType
from src.document_parser import ExtractedDocument, ExtractedSection

# Regex for speaker turn detection in meeting minutes and testimonies
SPEAKER_TURN_REGEX = re.compile(
    r"^(?:[-*•]\s*)?("
    r"(?:(?:Ing\.|Dr\.|Dra\.|Lic\.|Abg\.|Sr\.|Sra\.)\s+)?"
    r"(?:Presidente|Secretario|Secretaria|Trabajador|Trabajadora|Colaborador|Colaboradora|"
    r"Jefe(?:\s+de\s+\w+)?|Director|Directora|Ingeniero|Ingeniera|Licenciado|Licenciada|Doctor|Doctora|"
    r"Abogado|Abogada|Fiscal|Auditor|Auditora|[A-ZÁÉÍÓÚÑ][a-záéíóúñ]+(?:\s+[A-ZÁÉÍÓÚÑ][a-záéíóúñ]+)?)"
    r"(?:\s*\([^)]+\))?"
    r")\s*:\s*(.*)",
    re.IGNORECASE
)

# Regex for numbered agenda points or conclusions
NUMBERED_ITEM_REGEX = re.compile(
    r"^(?:(?:Punto|Tema|Compromiso|Acuerdo|Hecho|Cargo|Descargo)\s+)?(\d+[\.\)]|[a-zA-Z][\.\)])\s+(.*)",
    re.IGNORECASE
)


class DiscursiveChunker:
    """
    Intelligent occupational document chunker.
    Splits text along natural discursive and dialogical boundaries:
    - Speaker interventions (turns in committee minutes)
    - Agenda items and commitments
    - Narrative tabular records (shift logs, complaint sheets)
    - Sliding windows for monolithic expository prose
    """

    def __init__(self, target_max_words: int = 250, target_min_words: int = 5, overlap_words: int = 25):
        self.target_max_words = target_max_words
        self.target_min_words = target_min_words
        self.overlap_words = overlap_words

    def chunk_document(self, doc: ExtractedDocument) -> List[DocumentChunk]:
        """Processes all extracted sections of a document into discrete DocumentChunks."""
        chunks: List[DocumentChunk] = []

        for sec in doc.sections:
            sec_chunks = self._chunk_section(sec, doc)
            chunks.extend(sec_chunks)

        # If document had no sections or generated nothing, fallback to full text
        if not chunks and doc.raw_full_text.strip():
            fallback_chunks = self._chunk_free_text(
                text=doc.raw_full_text,
                doc=doc,
                section_title=doc.title or "Documento Completo",
                default_speaker=doc.author or "Autor Desconocido",
                default_timestamp=doc.detected_date or datetime.now()
            )
            chunks.extend(fallback_chunks)

        return chunks

    def _chunk_section(self, sec: ExtractedSection, doc: ExtractedDocument) -> List[DocumentChunk]:
        """Segments an individual ExtractedSection into one or more DocumentChunks."""
        content = sec.content.strip()
        if not content:
            return []

        doc_date = sec.timestamp or doc.detected_date or datetime.now()
        doc_speaker = sec.speaker_or_author or doc.author or "Colaborador"

        # 1. Tabular records (from Excel / CSV) - usually each row is self-contained
        if sec.section_type == "tabular_record":
            words = content.split()
            if len(words) < self.target_min_words:
                return []
            return [
                DocumentChunk(
                    chunk_id=f"chk-{uuid.uuid4().hex[:8]}",
                    file_path=doc.file_path,
                    file_name=doc.file_name,
                    department=doc.department,
                    section_title=sec.section_title or "Registro Tabular",
                    speaker=sec.speaker_or_author or doc_speaker,
                    timestamp=doc_date,
                    text_content=content,
                    chunk_type="tabular_record",
                    word_count=len(words)
                )
            ]

        # 2. Check for speaker turns line by line (typical in Actas de Comité de Convivencia)
        lines = [line.strip() for line in content.split("\n") if line.strip()]
        line_chunks: List[DocumentChunk] = []
        current_speaker = doc_speaker
        current_buffer: List[str] = []

        for line in lines:
            speaker_match = SPEAKER_TURN_REGEX.match(line)
            numbered_match = NUMBERED_ITEM_REGEX.match(line)

            if speaker_match:
                # Flush previous speaker buffer if exists
                if current_buffer:
                    buf_text = " ".join(current_buffer)
                    words_count = len(buf_text.split())
                    if words_count >= self.target_min_words:
                        line_chunks.append(
                            DocumentChunk(
                                chunk_id=f"chk-{uuid.uuid4().hex[:8]}",
                                file_path=doc.file_path,
                                file_name=doc.file_name,
                                department=doc.department,
                                section_title=sec.section_title or "Intervención",
                                speaker=current_speaker,
                                timestamp=doc_date,
                                text_content=buf_text,
                                chunk_type="speaker_intervention",
                                word_count=words_count
                            )
                        )
                    current_buffer = []

                current_speaker = speaker_match.group(1).strip()
                speech_text = speaker_match.group(2).strip()
                if speech_text:
                    current_buffer.append(speech_text)

            elif numbered_match and not current_buffer:
                # Numbered agenda point
                item_text = line
                words_count = len(item_text.split())
                if words_count >= self.target_min_words:
                    line_chunks.append(
                        DocumentChunk(
                            chunk_id=f"chk-{uuid.uuid4().hex[:8]}",
                            file_path=doc.file_path,
                            file_name=doc.file_name,
                            department=doc.department,
                            section_title=f"{sec.section_title or 'Punto'}: {numbered_match.group(1)}",
                            speaker=current_speaker,
                            timestamp=doc_date,
                            text_content=item_text,
                            chunk_type="agenda_item",
                            word_count=words_count
                        )
                    )
            else:
                current_buffer.append(line)

        # Flush trailing buffer
        if current_buffer:
            buf_text = " ".join(current_buffer)
            words_count = len(buf_text.split())
            if words_count >= self.target_min_words:
                line_chunks.append(
                    DocumentChunk(
                        chunk_id=f"chk-{uuid.uuid4().hex[:8]}",
                        file_path=doc.file_path,
                        file_name=doc.file_name,
                        department=doc.department,
                        section_title=sec.section_title or "Intervención",
                        speaker=current_speaker,
                        timestamp=doc_date,
                        text_content=buf_text,
                        chunk_type="speaker_intervention" if current_speaker != doc_speaker else "paragraph",
                        word_count=words_count
                    )
                )

        if line_chunks:
            # Check if any chunk in line_chunks is excessively large (> target_max_words)
            final_chunks: List[DocumentChunk] = []
            for chk in line_chunks:
                if chk.word_count > self.target_max_words:
                    sub_chunks = self._chunk_free_text(
                        text=chk.text_content,
                        doc=doc,
                        section_title=chk.section_title,
                        default_speaker=chk.speaker or doc_speaker,
                        default_timestamp=chk.timestamp
                    )
                    final_chunks.extend(sub_chunks)
                else:
                    final_chunks.append(chk)
            return final_chunks

        # 3. Fallback: windowing for general paragraph or narrative
        return self._chunk_free_text(
            text=content,
            doc=doc,
            section_title=sec.section_title or "Sección",
            default_speaker=doc_speaker,
            default_timestamp=doc_date
        )

    def _chunk_free_text(
        self,
        text: str,
        doc: ExtractedDocument,
        section_title: Optional[str],
        default_speaker: str,
        default_timestamp: datetime
    ) -> List[DocumentChunk]:
        """Breaks monolithic text into word-capped chunks with overlap."""
        words = text.split()
        total_words = len(words)

        if total_words < self.target_min_words:
            return []

        if total_words <= self.target_max_words:
            return [
                DocumentChunk(
                    chunk_id=f"chk-{uuid.uuid4().hex[:8]}",
                    file_path=doc.file_path,
                    file_name=doc.file_name,
                    department=doc.department,
                    section_title=section_title,
                    speaker=default_speaker,
                    timestamp=default_timestamp,
                    text_content=text,
                    chunk_type="paragraph",
                    word_count=total_words
                )
            ]

        # Windowing with overlap
        chunks: List[DocumentChunk] = []
        step = max(1, self.target_max_words - self.overlap_words)

        for start_idx in range(0, total_words, step):
            end_idx = min(start_idx + self.target_max_words, total_words)
            chunk_words = words[start_idx:end_idx]

            if len(chunk_words) < self.target_min_words:
                break

            sub_text = " ".join(chunk_words)
            part_num = len(chunks) + 1
            chunks.append(
                DocumentChunk(
                    chunk_id=f"chk-{uuid.uuid4().hex[:8]}",
                    file_path=doc.file_path,
                    file_name=doc.file_name,
                    department=doc.department,
                    section_title=f"{section_title} (Parte {part_num})",
                    speaker=default_speaker,
                    timestamp=default_timestamp,
                    text_content=sub_text,
                    chunk_type="paragraph",
                    word_count=len(chunk_words)
                )
            )

            if end_idx >= total_words:
                break

        return chunks

    @staticmethod
    def chunk_to_raw_message(chunk: DocumentChunk) -> RawMessage:
        """Converts a DocumentChunk into a RawMessage for seamless ingestion into the Telemetry Engine."""
        channel_type = ChannelType.DOCUMENT
        if chunk.file_name.lower().endswith(".eml"):
            channel_type = ChannelType.EMAIL

        return RawMessage(
            message_id=chunk.chunk_id,
            sender_id=chunk.speaker or "Colaborador_Documental",
            department=chunk.department,
            timestamp=chunk.timestamp,
            text_content=chunk.text_content,
            channel=channel_type
        )