File size: 14,702 Bytes
db4d559
 
3786a3f
 
db4d559
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3786a3f
 
 
 
feca495
3786a3f
feca495
3786a3f
 
 
 
 
 
 
 
 
 
feca495
 
3786a3f
 
 
 
 
 
 
 
 
 
 
 
feca495
 
3786a3f
 
 
feca495
3786a3f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
feca495
 
3786a3f
 
 
 
 
 
 
 
 
 
 
 
 
 
feca495
 
3786a3f
 
 
db4d559
 
 
 
3786a3f
db4d559
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3786a3f
db4d559
 
 
 
3786a3f
db4d559
 
 
 
 
 
 
 
 
 
3786a3f
 
 
db4d559
3786a3f
 
f14eb69
db4d559
 
f14eb69
db4d559
 
 
 
f14eb69
db4d559
 
 
 
3786a3f
 
 
 
feca495
 
f14eb69
3786a3f
 
 
 
 
 
 
 
 
f14eb69
 
 
3786a3f
 
 
db4d559
feca495
 
 
db4d559
3786a3f
db4d559
 
 
 
3786a3f
 
db4d559
feca495
db4d559
3786a3f
 
db4d559
feca495
db4d559
 
 
 
 
 
3786a3f
 
db4d559
 
 
 
 
 
 
3786a3f
db4d559
 
 
 
 
3786a3f
db4d559
 
 
 
3786a3f
 
 
 
 
 
feca495
3786a3f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
db4d559
 
 
3786a3f
db4d559
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f14eb69
db4d559
 
f14eb69
 
 
 
 
 
 
 
 
 
 
 
db4d559
 
 
 
 
 
 
 
 
 
 
f14eb69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
db4d559
f14eb69
 
db4d559
 
 
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
import os
import logging
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import List
from ..models import Document
from .neo4j_client import Neo4jClient
from .entity_extractor import EntityExtractor
from .relationship_extractor import RelationshipExtractor
from .entity_resolver import EntityResolver
from .vector_retriever import VectorRetriever


logger = logging.getLogger(__name__)


class GraphBuilder:
    def __init__(self):
        logger.info("Initializing GraphBuilder orchestrator service.")
        self.neo4j_client = Neo4jClient()
        self.entity_extractor = EntityExtractor()
        self.relationship_extractor = RelationshipExtractor()
        self.entity_resolver = EntityResolver()
        self.vector_retriever = VectorRetriever()

    def _update_progress(self, doc, step: str, progress: int):
        """Update document processing progress for frontend polling."""
        doc.processing_step = step
        doc.processing_progress = progress
        doc.save(update_fields=['processing_step', 'processing_progress'])

    def _batch_create_entities(self, entities: List[dict], user_id: str, document_id: str):
        """Batch create entities using UNWIND to reduce N+1 queries."""
        if not entities:
            return
        batch_size = 50
        for i in range(0, len(entities), batch_size):
            batch = entities[i:i + batch_size]
            query = (
                "UNWIND $entities AS ent "
                "MERGE (e:Entity {name: ent.name, user_id: $user_id}) "
                "ON CREATE SET e.type = ent.type, e.description = ent.description, "
                "              e.source_doc = ent.source_doc, e.source_doc_id = $document_id, e.page = ent.page, e.created_at = timestamp() "
                "ON MATCH SET e.description = coalesce(e.description, ent.description), e.source_doc_id = $document_id, e.source_doc = ent.source_doc"
            )
            params = {
                "entities": [
                    {
                        "name": e["name"].strip(),
                        "type": e["type"].strip(),
                        "description": e["description"].strip(),
                        "source_doc": e.get("source_doc", ""),
                        "page": e.get("page", 0)
                    }
                    for e in batch
                ],
                "user_id": str(user_id),
                "document_id": str(document_id)
            }
            self.neo4j_client.execute_query(query, params)

    def _batch_create_relationships(self, relationships: List[dict], user_id: str, document_id: str):
        """Batch create relationships grouped by type using UNWIND."""
        if not relationships:
            return
        from .neo4j_client import VALID_RELATIONSHIP_TYPES

        # Group relationships by type
        grouped: dict[str, list] = {}
        for rel in relationships:
            rel_type = rel["relationship_type"].upper().strip()
            if rel_type not in VALID_RELATIONSHIP_TYPES:
                rel_type = "RELATED_TO"
            grouped.setdefault(rel_type, []).append(rel)

        for rel_type, rels in grouped.items():
            batch_size = 50
            for i in range(0, len(rels), batch_size):
                batch = rels[i:i + batch_size]
                query = (
                    "UNWIND $rels AS rel "
                    "MATCH (source:Entity {name: rel.source, user_id: $user_id}) "
                    "MATCH (target:Entity {name: rel.target, user_id: $user_id}) "
                    f"MERGE (source)-[r:{rel_type}]->(target) "
                    "ON CREATE SET r.description = rel.description, r.confidence = rel.confidence, "
                    "              r.source_doc = rel.source_doc, r.source_doc_id = $document_id, r.page = rel.page, r.created_at = timestamp() "
                    "ON MATCH SET r.source_doc_id = $document_id, r.source_doc = rel.source_doc "
                    "RETURN r LIMIT 1"
                )
                params = {
                    "rels": [
                        {
                            "source": rel["source_entity"].strip(),
                            "target": rel["target_entity"].strip(),
                            "description": rel["description"].strip(),
                            "confidence": float(rel["confidence"]),
                            "source_doc": rel.get("source_doc", ""),
                            "page": rel.get("page", 0)
                        }
                        for rel in batch
                    ],
                    "user_id": str(user_id),
                    "document_id": str(document_id)
                }
                self.neo4j_client.execute_query(query, params)

    def process_document(self, document_id, user_id):
        """
        Orchestrates the entire GraphRAG ingestion pipeline.
        Reads file, extracts entities & relationships, resolves duplicates, and writes to Neo4j.
        Updates processing_progress and processing_step at each stage for frontend polling.
        """
        try:
            doc = Document.objects.get(id=document_id)
        except Document.DoesNotExist:
            logger.error("Document with ID %s does not exist. Ingestion aborted.", document_id)
            return

        logger.info("Beginning background graph building for Document: %s (User ID: %s)", doc.name, user_id)
        
        # 1. Update status to PROCESSING
        doc.status = Document.Status.PROCESSING
        doc.save()

        try:
            filepath = doc.file.path
            if not os.path.exists(filepath):
                raise FileNotFoundError(f"File not found on disk: {filepath}")

            # 2. Parse file into sections/pages
            self._update_progress(doc, "Parsing document...", 5)
            sections = self._parse_file_to_sections(filepath)
            logger.info("Parsed document into %d sections for analysis.", len(sections))

            # 2b. Index document text in ChromaDB vector store
            self._update_progress(doc, "Indexing vectors in ChromaDB...", 15)
            full_text = "\n\n".join([sec["text"] for sec in sections])
            logger.info("Indexing document text in ChromaDB (Doc: %s, User: %s)...", doc.name, user_id)
            self.vector_retriever.index_document(
                text_content=full_text,
                doc_name=doc.name,
                user_id=user_id
            )

            all_entities = []
            all_relationships = []
            all_entities_lock = threading.Lock()
            all_relationships_lock = threading.Lock()
            total_sections = len(sections)

            def process_section(sec: dict) -> tuple[List[dict], List[dict]]:
                """Process a single section: extract entities and relationships."""
                import time
                text = sec["text"]
                page = sec["page"]
                time.sleep(2)
                ents = self.entity_extractor.extract_entities(text)
                for e in ents:
                    e["page"] = page
                    e["source_doc"] = doc.name
                time.sleep(2)
                rels = self.relationship_extractor.extract_relationships(text)
                for r in rels:
                    r["page"] = page
                    r["source_doc"] = doc.name
                return ents, rels

            # 3. Perform Entity and Relationship Extraction per section (parallel)
            completed = 0
            failed_count = 0
            last_error = None
            with ThreadPoolExecutor(max_workers=1) as executor:
                futures = {executor.submit(process_section, sec): sec for sec in sections}
                for future in as_completed(futures):
                    try:
                        ents, rels = future.result()
                        with all_entities_lock:
                            all_entities.extend(ents)
                        with all_relationships_lock:
                            all_relationships.extend(rels)
                    except Exception as e:
                         logger.error("Section processing failed: %s", str(e))
                         failed_count += 1
                         last_error = e
                    completed += 1
                    extraction_progress = 20 + int(55 * (completed / total_sections)) if total_sections > 0 else 20
                    self._update_progress(doc, f"Extracting entities... ({completed}/{total_sections})", extraction_progress)

            if failed_count == total_sections and total_sections > 0:
                raise RuntimeError(f"All sections failed to process. Last error: {last_error}")

            # 4. Run entity resolution (deduplicate entities and rewrite relationships)
            self._update_progress(doc, "Resolving duplicates...", 80)
            resolved_ents, rewritten_rels = self.entity_resolver.resolve_entities(
                all_entities, all_relationships
            )

            # 5. Batch store resolved nodes inside Neo4j
            self._update_progress(doc, "Building knowledge graph...", 85)
            logger.info("Writing %d resolved entities to Neo4j...", len(resolved_ents))
            self._batch_create_entities(resolved_ents, user_id, document_id)

            # 6. Batch store rewritten edges inside Neo4j
            self._update_progress(doc, "Writing relationships...", 92)
            logger.info("Writing %d rewritten relationships to Neo4j...", len(rewritten_rels))
            self._batch_create_relationships(rewritten_rels, user_id, document_id)

            # 7. Update status to COMPLETED and record counts
            doc.entity_count = len(resolved_ents)
            doc.relationship_count = len(rewritten_rels)
            doc.status = Document.Status.COMPLETED
            doc.error_message = None
            doc.processing_progress = 100
            doc.processing_step = "Complete"
            doc.save()
            logger.info("Successfully finished building knowledge graph for Document: %s", doc.name)

        except Exception as e:
            logger.error("Failed to process document: %s. Error: %s", doc.name, str(e), exc_info=True)
            doc.status = Document.Status.FAILED
            doc.error_message = str(e)
            doc.processing_step = f"Failed: {str(e)[:100]}"
            doc.save()

    def delete_document_data(self, document_id, user_id):
        """
        Cleans up and deletes associated Neo4j node/edge elements for a deleted document.
        Attempts both graph and vector cleanup independently to avoid orphaned data.
        """
        try:
            doc = Document.objects.get(id=document_id)
            logger.info("Triggering graph wipe for Document: %s (User ID: %s)", doc.name, user_id)

            # Attempt both cleanups independently
            neo4j_ok = True
            vector_ok = True

            try:
                self.neo4j_client.delete_document_nodes(doc.id, user_id)
            except Exception as e:
                logger.error("Neo4j cleanup failed for Document: %s. Error: %s", doc.name, str(e))
                neo4j_ok = False

            try:
                self.vector_retriever.delete_document_vectors(doc.name, user_id)
            except Exception as e:
                logger.error("ChromaDB cleanup failed for Document: %s. Error: %s", doc.name, str(e))
                vector_ok = False

            if neo4j_ok and vector_ok:
                logger.info("Finished Graph cleanup for Document: %s", doc.name)
            else:
                logger.warning("Partial cleanup for Document: %s (Neo4j: %s, Vector: %s)",
                               doc.name, "OK" if neo4j_ok else "FAIL", "OK" if vector_ok else "FAIL")

        except Document.DoesNotExist:
            logger.error("Document with ID %s does not exist. Cleanup aborted.", document_id)
        except Exception as e:
            logger.error("Failed to clean up graph data for Document ID: %s. Error: %s",
                         document_id, str(e), exc_info=True)

    def _parse_file_to_sections(self, filepath: str) -> List[dict]:
        """
        Loads document file and splits content into page/paragraph sections.
        """
        ext = filepath.split(".")[-1].lower()
        sections = []

        if ext == "pdf":
            import pypdf
            reader = pypdf.PdfReader(filepath)
            for idx, page in enumerate(reader.pages):
                text = page.extract_text()
                if text and text.strip():
                    sections.append({
                        "text": text.strip(),
                        "page": idx + 1
                    })
        elif ext in ["docx", "doc"]:
            import docx
            doc = docx.Document(filepath)
            current_chunk = []
            current_len = 0
            section_idx = 1
            for p in doc.paragraphs:
                txt = p.text.strip() if p.text else ""
                if txt:
                    current_chunk.append(txt)
                    current_len += len(txt)
                    if current_len >= 1500:
                        sections.append({
                            "text": "\n".join(current_chunk),
                            "page": section_idx
                        })
                        current_chunk = []
                        current_len = 0
                        section_idx += 1
            if current_chunk:
                sections.append({
                    "text": "\n".join(current_chunk),
                    "page": section_idx
                })
        else:
            # Default fallback for TXT, Markdown, etc.
            with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
                content = f.read()
            # Split by double newlines
            paragraphs = [p.strip() for p in content.split("\n\n") if p.strip()]
            current_chunk = []
            current_len = 0
            section_idx = 1
            for p in paragraphs:
                current_chunk.append(p)
                current_len += len(p)
                if current_len >= 1500:
                    sections.append({
                        "text": "\n\n".join(current_chunk),
                        "page": section_idx
                    })
                    current_chunk = []
                    current_len = 0
                    section_idx += 1
            if current_chunk:
                sections.append({
                    "text": "\n\n".join(current_chunk),
                    "page": section_idx
                })

        return sections