File size: 5,003 Bytes
6b62834
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Text modal processor — chunking, entity extraction, and annotation."""

from agentic_rag.services.knowledge.content_list import ContentItem, ContentType
from agentic_rag.services.knowledge.processors.base import BaseModalProcessor


class TextModalProcessor(BaseModalProcessor):
    """Process text content — chunking, cleaning, entity extraction."""

    content_type = ContentType.TEXT
    description = "Clean and chunk text content."

    def __init__(self, chunk_size: int = 512, chunk_overlap: int = 50):
        self.chunk_size = chunk_size
        self.chunk_overlap = chunk_overlap

    async def process(self, item: ContentItem) -> ContentItem:
        """Clean and optionally chunk text content."""
        if item.type != ContentType.TEXT:
            return item

        # Basic cleaning
        text = item.text.strip()
        text = self._clean_text(text)

        if not text:
            return item

        item.text = text
        return item

    async def process_batch(self, items: list[ContentItem]) -> list[ContentItem]:
        """Process text items. For texts longer than chunk_size, split into chunks."""
        results = []
        for item in items:
            processed = await self.process(item)
            if processed.text and len(processed.text) > self.chunk_size:
                # Split long text into overlapping chunks
                chunks = self._chunk_text(processed.text)
                for i, chunk in enumerate(chunks):
                    chunk_item = ContentItem(
                        type=ContentType.TEXT,
                        text=chunk,
                        page_idx=processed.page_idx,
                        metadata={
                            **processed.metadata,
                            "chunk_index": i,
                            "chunk_count": len(chunks),
                            "parent_item": id(processed),
                        },
                    )
                    results.append(chunk_item)
            else:
                results.append(processed)
        return results

    def _clean_text(self, text: str) -> str:
        """Clean up text — normalize whitespace, remove artifacts."""
        import re
        # Collapse multiple newlines
        text = re.sub(r'\n{3,}', '\n\n', text)
        # Collapse multiple spaces
        text = re.sub(r' {3,}', '  ', text)
        # Remove control characters except newlines
        text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', text)
        return text.strip()

    def _chunk_text(self, text: str) -> list[str]:
        """Split text into chunks at Chinese/English sentence boundaries.

        Overlap is sentence-aware — the last complete sentence(s) from the
        previous chunk are prepended to the next one, so no chunk starts
        with a mid-sentence fragment.
        """
        import re
        # Split on sentence-ending punctuation: Chinese + English
        # Also split on 】 (Chinese doc section headers) and ; (Chinese semicolon)
        sentence_end = r'(?<=[.!?。!?\n;;】])\s*'
        raw_parts = re.split(sentence_end, text)
        # Merge tiny fragments back
        sentences = []
        buf = ""
        for part in raw_parts:
            if not part.strip():
                continue
            if buf and len(buf) < 20:  # short fragment — merge with previous
                buf += part
            else:
                if buf.strip():
                    sentences.append(buf.strip())
                buf = part
        if buf.strip():
            sentences.append(buf.strip())

        if not sentences:
            return [text]

        chunks = []
        current_sentences = []  # track sentence indices in current chunk
        current_len = 0

        for i, sent in enumerate(sentences):
            if current_len + len(sent) > self.chunk_size and current_sentences:
                # Finalize current chunk
                chunk_text = "".join(sentences[j] for j in current_sentences)
                chunks.append(chunk_text)

                # Overlap: carry over the last 1-2 complete sentences
                overlap_chars = 0
                overlap_sentences = []
                for j in reversed(current_sentences):
                    s = sentences[j]
                    if overlap_chars + len(s) <= self.chunk_overlap:
                        overlap_sentences.insert(0, j)
                        overlap_chars += len(s)
                    else:
                        break
                # Rebuild current from overlap sentences + new sentence
                current_sentences = list(overlap_sentences)
                current_len = sum(len(sentences[j]) for j in current_sentences)

            current_sentences.append(i)
            current_len += len(sent)

        if current_sentences:
            chunk_text = "".join(sentences[j] for j in current_sentences)
            chunks.append(chunk_text)

        return chunks if chunks else [text]