Coverage for src/qdrant_loader_core/graph/extractor/base_extractor.py: 99%
72 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-20 10:12 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-20 10:12 +0000
1from __future__ import annotations
3from typing import TYPE_CHECKING
5if TYPE_CHECKING:
6 from qdrant_loader.core.document import Document
8from abc import ABC, abstractmethod
9from typing import ClassVar
11from ..models import (
12 CoreEdgeType,
13 CoreNodeLabel,
14 GraphEdge,
15 GraphNode,
16 PersonInfo,
17 SubGraph,
18)
21# ------------------------
22# EntityExtractor Interface
23# ------------------------
24class EntityExtractor(ABC):
25 """
26 Interface for all entity extractors.
27 """
29 _registry: dict[str, type[EntityExtractor]] = {}
31 @abstractmethod
32 async def extract(self, doc: Document) -> SubGraph:
33 """
34 Map raw source data into a SubGraph
35 """
36 raise NotImplementedError
38 # -------- Registry --------
39 @classmethod
40 def register_extractor(cls, source_type: str, extractor_cls: type[EntityExtractor]):
41 if source_type in cls._registry:
42 raise ValueError(
43 f"Extractor already registered for source_type={source_type}"
44 )
45 cls._registry[source_type] = extractor_cls
47 @classmethod
48 def for_source(cls, source_type: str) -> EntityExtractor:
49 if source_type not in cls._registry:
50 raise ValueError(f"No extractor registered for source_type={source_type}")
51 return cls._registry[source_type]() # instantiate
54class BaseEntityExtractor(EntityExtractor):
55 """
56 Base extractor implementing shared graph extraction.
58 Common nodes:
59 - Document
60 - Person
61 - Container
62 - Label
64 Source-specific nodes are extracted via
65 _extract_source_specific().
66 """
68 source_type: ClassVar[str]
70 @classmethod
71 def get_source_type(cls) -> str:
72 return cls.source_type
74 async def extract(self, doc: Document) -> SubGraph:
75 project = self._project(doc)
77 nodes: list[GraphNode] = []
78 edges: list[GraphEdge] = []
80 # 1. Document
81 doc_node = self._build_document_node(doc, project)
82 nodes.append(doc_node)
84 # 2. People
85 people = self._extract_people(doc)
87 # Deduplicate people by canonical person id
88 seen_people: set[str] = set()
90 for person_info, role in people:
91 canonical_id = person_info.id
93 if canonical_id in seen_people:
94 continue
96 seen_people.add(canonical_id)
98 person_node = self._person_node(person_info, project)
99 nodes.append(person_node)
101 edges.append(
102 GraphEdge(
103 source=(
104 doc.metadata.get("key")
105 if self.get_source_type() == "jira"
106 else doc.id
107 ),
108 target=person_node.id,
109 edge_type=CoreEdgeType.AUTHORED_BY.value,
110 project=project,
111 properties={"role": role},
112 )
113 )
115 # 3. Container (project / space / repo)
116 container = self._extract_container(doc)
118 if container:
119 nodes.append(container)
121 edges.append(
122 GraphEdge(
123 source=(
124 doc.metadata.get("key")
125 if self.get_source_type() == "jira"
126 else doc.id
127 ),
128 target=container.id,
129 edge_type=CoreEdgeType.BELONGS_TO.value,
130 project=project,
131 )
132 )
134 # 4. Labels
135 labels = self._extract_labels(doc)
137 for label in labels:
138 nodes.append(label)
140 edges.append(
141 GraphEdge(
142 source=(
143 doc.metadata.get("key")
144 if self.get_source_type() == "jira"
145 else doc.id
146 ),
147 target=label.id,
148 edge_type=CoreEdgeType.HAS_LABEL.value,
149 project=project,
150 )
151 )
153 # 5. Cross references
154 link_nodes, link_edges = self._extract_links(doc)
156 nodes.extend(link_nodes)
157 edges.extend(link_edges)
159 # 6. Source specific
160 custom_nodes, custom_edges = self._extract_source_specific(doc)
162 nodes.extend(custom_nodes)
163 edges.extend(custom_edges)
165 return SubGraph(
166 nodes=nodes,
167 edges=edges,
168 )
170 def _person_node(
171 self,
172 person_info: PersonInfo,
173 project: str | None,
174 ) -> GraphNode:
175 return GraphNode(
176 id=person_info.id,
177 label=CoreNodeLabel.PERSON.value,
178 project=project,
179 properties={
180 "display_name": person_info.display_name,
181 },
182 )
184 # ------------------------------------------------------------------
185 # Required hooks
186 # ------------------------------------------------------------------
188 @abstractmethod
189 def _build_document_node(
190 self,
191 doc: Document,
192 project: str | None,
193 ) -> GraphNode:
194 """
195 Build the main document node.
196 """
198 @abstractmethod
199 def _project(self, doc: Document) -> str | None:
200 """
201 Return project / space / repository identifier.
202 """
204 @abstractmethod
205 def _extract_people(
206 self,
207 doc: Document,
208 ) -> list[tuple[PersonInfo, str]]:
209 """
210 Return [(person_info, role), ...]
211 """
213 @abstractmethod
214 def _extract_container(
215 self,
216 doc: Document,
217 ) -> GraphNode | None:
218 """
219 Return container node (project / space / repository)
220 """
222 # ------------------------------------------------------------------
223 # Optional hooks
224 # ------------------------------------------------------------------
226 def _extract_labels(
227 self,
228 doc: Document,
229 ) -> list[GraphNode]:
230 return []
232 def _extract_links(
233 self,
234 doc: Document,
235 ) -> tuple[list[GraphNode], list[GraphEdge]]:
236 return [], []
238 def _extract_source_specific(
239 self,
240 doc: Document,
241 ) -> tuple[list[GraphNode], list[GraphEdge]]:
242 return [], []