Spaces:
Sleeping
Sleeping
| """Layout-aware academic PDF parsing using PyMuPDF4LLM. | |
| The parser preserves page, reading order, section hierarchy, element type, and | |
| normalized coordinates. It deliberately performs no embedding or storage so | |
| the parse can be inspected before any derived index is replaced. | |
| """ | |
| from __future__ import annotations | |
| import re | |
| from collections import defaultdict | |
| from dataclasses import dataclass | |
| from typing import Any, Iterable | |
| import pymupdf | |
| import pymupdf4llm | |
| from app.rag.models import ( | |
| AcademicDocument, | |
| BoundingBox, | |
| EvidenceRelation, | |
| EvidenceType, | |
| EvidenceUnit, | |
| ParsedAcademicDocument, | |
| RelationType, | |
| SourceKind, | |
| make_evidence_id, | |
| normalize_evidence_text, | |
| ) | |
| _BOX_TYPES: dict[str, EvidenceType] = { | |
| "title": EvidenceType.TITLE, | |
| "section-header": EvidenceType.HEADING, | |
| "text": EvidenceType.PARAGRAPH, | |
| "list-item": EvidenceType.LIST, | |
| "table": EvidenceType.TABLE, | |
| "picture": EvidenceType.FIGURE, | |
| "image": EvidenceType.FIGURE, | |
| "figure": EvidenceType.FIGURE, | |
| "plot": EvidenceType.PLOT, | |
| "diagram": EvidenceType.DIAGRAM, | |
| "formula": EvidenceType.FORMULA, | |
| "caption": EvidenceType.CAPTION, | |
| "footnote": EvidenceType.FOOTNOTE, | |
| } | |
| _IGNORED_BOX_TYPES = {"page-header", "page-footer"} | |
| _REFERENCE_HEADING = re.compile(r"^(references|bibliography|works cited)\b", re.IGNORECASE) | |
| _ABSTRACT_HEADING = re.compile(r"^abstract\b", re.IGNORECASE) | |
| _MARKDOWN_HEADING = re.compile(r"^(#{1,6})\s+(.*)$") | |
| _MARKDOWN_IMAGE = re.compile(r"!\[[^]]*]\([^)]*\)") | |
| class _PageInfo: | |
| number: int | |
| width: float | |
| height: float | |
| def is_reference_section_heading(value: str) -> bool: | |
| """Recognize bibliography headings even when the PDF converter kept emphasis.""" | |
| plain = re.sub(r"[*_`]+", "", value or "").strip() | |
| return bool(_REFERENCE_HEADING.match(plain)) | |
| class AcademicDocumentParser: | |
| """Convert a PDF into deterministic evidence units and relations.""" | |
| def parse_pdf( | |
| self, | |
| file_bytes: bytes, | |
| filename: str, | |
| project_id: str, | |
| document_id: str, | |
| ) -> ParsedAcademicDocument: | |
| doc = pymupdf.open(stream=file_bytes, filetype="pdf") | |
| try: | |
| pages = pymupdf4llm.to_markdown( | |
| doc, | |
| page_chunks=True, | |
| extract_words=True, | |
| header=False, | |
| footer=False, | |
| show_progress=False, | |
| ) | |
| if not isinstance(pages, list): | |
| raise RuntimeError("PyMuPDF4LLM did not return page chunks") | |
| page_info = { | |
| index + 1: _PageInfo(index + 1, float(page.rect.width), float(page.rect.height)) | |
| for index, page in enumerate(doc) | |
| } | |
| return self._materialize( | |
| pages=pages, | |
| page_info=page_info, | |
| metadata=dict(doc.metadata or {}), | |
| filename=filename, | |
| project_id=project_id, | |
| document_id=document_id, | |
| ) | |
| finally: | |
| doc.close() | |
| def parse_text( | |
| self, | |
| text: str, | |
| filename: str, | |
| project_id: str, | |
| document_id: str, | |
| ) -> ParsedAcademicDocument: | |
| """Structure non-PDF text without reintroducing fixed-width chunks.""" | |
| units: list[EvidenceUnit] = [] | |
| section_path: list[str] = [] | |
| title = filename.rsplit(".", 1)[0] | |
| ordinal = 0 | |
| for block in (part.strip() for part in re.split(r"\n\s*\n+", text or "")): | |
| if not block: | |
| continue | |
| level, heading_text = _heading(block) | |
| is_heading = bool(_MARKDOWN_HEADING.match(block)) | |
| if is_heading: | |
| section_path = _update_section_path(section_path, level, heading_text) | |
| element_type = EvidenceType.HEADING | |
| content = heading_text | |
| if not units and heading_text: | |
| title = heading_text | |
| else: | |
| element_type = EvidenceType.PARAGRAPH | |
| content = _strip_markdown(block) | |
| for fragment in _semantic_fragments(content, element_type): | |
| evidence_id = make_evidence_id(project_id, document_id, 1, None, element_type, fragment) | |
| units.append( | |
| EvidenceUnit( | |
| evidence_id=evidence_id, | |
| project_id=project_id, | |
| document_id=document_id, | |
| element_type=element_type, | |
| page_start=1, | |
| page_end=1, | |
| section_path=list(section_path), | |
| ordinal=ordinal, | |
| raw_text=fragment, | |
| retrieval_text=_retrieval_text( | |
| title=title, | |
| section_path=section_path, | |
| element_type=element_type, | |
| content=fragment, | |
| ), | |
| quality_flags=["page_geometry_unavailable"], | |
| ) | |
| ) | |
| ordinal += 1 | |
| flags = ["page_geometry_unavailable"] | |
| if not units: | |
| flags.append("document_empty") | |
| relations = _build_relations(units, flags) | |
| cards, card_relations = _build_section_cards( | |
| units, project_id, document_id, title, start_ordinal=len(units) | |
| ) | |
| units.extend(cards) | |
| relations.extend(card_relations) | |
| windows, window_relations = _build_local_windows( | |
| units, project_id, document_id, title, start_ordinal=len(units) | |
| ) | |
| units.extend(windows) | |
| relations.extend(window_relations) | |
| return ParsedAcademicDocument( | |
| document=AcademicDocument( | |
| project_id=project_id, | |
| document_id=document_id, | |
| filename=filename, | |
| title=title, | |
| page_count=1 if units else 0, | |
| parse_quality="degraded" if units else "empty", | |
| quality_flags=flags, | |
| ), | |
| units=units, | |
| relations=_dedupe_relations(relations), | |
| ) | |
| def _materialize( | |
| self, | |
| *, | |
| pages: list[dict[str, Any]], | |
| page_info: dict[int, _PageInfo], | |
| metadata: dict[str, Any], | |
| filename: str, | |
| project_id: str, | |
| document_id: str, | |
| ) -> ParsedAcademicDocument: | |
| quality_flags: list[str] = [] | |
| units: list[EvidenceUnit] = [] | |
| active_sections: list[str] = [] | |
| first_title = "" | |
| ordinal = 0 | |
| for page_index, page_chunk in enumerate(pages): | |
| metadata_page = page_chunk.get("metadata") or {} | |
| page_number = _product_page_number(metadata_page.get("page_number"), page_index + 1) | |
| info = page_info.get(page_number) or page_info.get(page_index + 1) | |
| if info is None: | |
| quality_flags.append(f"page_dimensions_missing:{page_number}") | |
| continue | |
| page_text = str(page_chunk.get("text") or "") | |
| boxes = sorted(page_chunk.get("page_boxes") or [], key=lambda item: int(item.get("index", 0))) | |
| if not page_text.strip(): | |
| quality_flags.append(f"page_empty:{page_number}") | |
| if not boxes and page_text.strip(): | |
| quality_flags.append(f"page_boxes_missing:{page_number}") | |
| boxes = [{"class": "text", "bbox": [0, 0, info.width, info.height], "pos": [0, len(page_text)], "index": 0}] | |
| for box in boxes: | |
| box_class = str(box.get("class") or "text").strip().lower() | |
| if box_class in _IGNORED_BOX_TYPES: | |
| continue | |
| element_type = _BOX_TYPES.get(box_class, EvidenceType.PARAGRAPH) | |
| bbox = _normalized_box(box.get("bbox"), info) | |
| if bbox is None: | |
| quality_flags.append(f"bbox_invalid:{page_number}:{box.get('index', 0)}") | |
| content = _box_text(page_text, box) | |
| if element_type is EvidenceType.HEADING: | |
| heading_level, heading_text = _heading(content) | |
| if not heading_text: | |
| continue | |
| active_sections = _update_section_path(active_sections, heading_level, heading_text) | |
| content = heading_text | |
| elif element_type is EvidenceType.TITLE: | |
| content = _strip_markdown(content) | |
| if content and not first_title: | |
| first_title = content | |
| fragments = _semantic_fragments(content, element_type) | |
| if not fragments and element_type in { | |
| EvidenceType.FIGURE, | |
| EvidenceType.PLOT, | |
| EvidenceType.DIAGRAM, | |
| }: | |
| fragments = [""] | |
| for fragment_index, fragment in enumerate(fragments): | |
| effective_type = element_type | |
| section_path = list(active_sections) | |
| if section_path and is_reference_section_heading(section_path[-1]): | |
| if element_type in {EvidenceType.PARAGRAPH, EvidenceType.LIST}: | |
| effective_type = EvidenceType.REFERENCE | |
| table_markdown = fragment if effective_type is EvidenceType.TABLE else "" | |
| raw_text = "" if effective_type is EvidenceType.TABLE else fragment | |
| content_key = fragment or f"{box_class}:{box.get('index', 0)}:{fragment_index}" | |
| evidence_id = make_evidence_id( | |
| project_id, | |
| document_id, | |
| page_number, | |
| bbox, | |
| effective_type, | |
| content_key, | |
| ) | |
| retrieval_text = _retrieval_text( | |
| title=metadata.get("title") or first_title or filename, | |
| section_path=section_path, | |
| element_type=effective_type, | |
| content=fragment, | |
| ) | |
| unit_flags: list[str] = [] | |
| if not fragment: | |
| unit_flags.append("visual_description_pending") | |
| units.append( | |
| EvidenceUnit( | |
| evidence_id=evidence_id, | |
| project_id=project_id, | |
| document_id=document_id, | |
| element_type=effective_type, | |
| page_start=page_number, | |
| page_end=page_number, | |
| section_path=section_path, | |
| ordinal=ordinal, | |
| bbox_norm=bbox, | |
| raw_text=raw_text, | |
| retrieval_text=retrieval_text, | |
| table_markdown=table_markdown, | |
| quality_flags=unit_flags, | |
| source_kind=SourceKind.PAPER, | |
| metadata={ | |
| "box_class": box_class, | |
| "box_index": int(box.get("index", 0)), | |
| "fragment_index": fragment_index, | |
| }, | |
| ) | |
| ) | |
| ordinal += 1 | |
| title = normalize_evidence_text(str(metadata.get("title") or "")) or first_title or filename.rsplit(".", 1)[0] | |
| authors = _authors(str(metadata.get("author") or "")) | |
| abstract = _abstract_from_units(units) | |
| relations = _build_relations(units, quality_flags) | |
| section_cards, section_relations = _build_section_cards( | |
| units, project_id, document_id, title, start_ordinal=ordinal | |
| ) | |
| units.extend(section_cards) | |
| relations.extend(section_relations) | |
| windows, window_relations = _build_local_windows( | |
| units, project_id, document_id, title, start_ordinal=len(units) | |
| ) | |
| units.extend(windows) | |
| relations.extend(window_relations) | |
| leaf_units = [ | |
| unit | |
| for unit in units | |
| if unit.element_type not in {EvidenceType.SECTION_CARD, EvidenceType.LOCAL_WINDOW} | |
| ] | |
| if not leaf_units: | |
| parse_quality: str = "empty" | |
| quality_flags.append("document_empty") | |
| elif any( | |
| flag.startswith(("page_empty:", "page_boxes_missing:", "page_dimensions_missing:")) | |
| for flag in quality_flags | |
| ): | |
| parse_quality = "degraded" | |
| else: | |
| parse_quality = "good" | |
| document = AcademicDocument( | |
| project_id=project_id, | |
| document_id=document_id, | |
| filename=filename, | |
| title=title, | |
| authors=authors, | |
| abstract=abstract, | |
| page_count=len(pages), | |
| parse_quality=parse_quality, | |
| quality_flags=sorted(set(quality_flags)), | |
| ) | |
| return ParsedAcademicDocument(document=document, units=units, relations=_dedupe_relations(relations)) | |
| def _product_page_number(value: Any, fallback: int) -> int: | |
| """Use installed PyMuPDF4LLM's one-based page metadata, or index fallback.""" | |
| try: | |
| number = int(value) | |
| return number if number >= 1 else fallback | |
| except (TypeError, ValueError): | |
| return fallback | |
| def _normalized_box(value: Any, page: _PageInfo) -> BoundingBox | None: | |
| if not isinstance(value, (list, tuple)) or len(value) != 4 or page.width <= 0 or page.height <= 0: | |
| return None | |
| try: | |
| x0, y0, x1, y1 = (float(part) for part in value) | |
| x0 = min(max(x0 / page.width, 0.0), 1.0) | |
| y0 = min(max(y0 / page.height, 0.0), 1.0) | |
| x1 = min(max(x1 / page.width, 0.0), 1.0) | |
| y1 = min(max(y1 / page.height, 0.0), 1.0) | |
| if x1 <= x0 or y1 <= y0: | |
| return None | |
| return BoundingBox(x=x0, y=y0, w=x1 - x0, h=y1 - y0) | |
| except (TypeError, ValueError): | |
| return None | |
| def _box_text(page_text: str, box: dict[str, Any]) -> str: | |
| pos = box.get("pos") | |
| if isinstance(pos, (list, tuple)) and len(pos) == 2: | |
| try: | |
| start = max(0, int(pos[0])) | |
| end = min(len(page_text), int(pos[1])) | |
| return page_text[start:end].strip() | |
| except (TypeError, ValueError): | |
| pass | |
| return "" | |
| def _heading(content: str) -> tuple[int, str]: | |
| cleaned = content.strip() | |
| match = _MARKDOWN_HEADING.match(cleaned) | |
| if match: | |
| return len(match.group(1)), normalize_evidence_text(match.group(2)) | |
| return 1, _strip_markdown(cleaned) | |
| def _strip_markdown(content: str) -> str: | |
| content = _MARKDOWN_IMAGE.sub("", content or "") | |
| content = re.sub(r"^#{1,6}\s+", "", content.strip()) | |
| # PyMuPDF4LLM wraps emphasized text (title/author lines are often bolded in | |
| # the source PDF) in markdown emphasis markers; leaving them in leaks | |
| # literal "**"/"`" characters into titles shown to the user. | |
| content = re.sub(r"[*_`]+", "", content) | |
| return normalize_evidence_text(content) | |
| def _semantic_fragments(content: str, element_type: EvidenceType) -> list[str]: | |
| content = (content or "").strip() | |
| if not content: | |
| return [] | |
| if element_type in { | |
| EvidenceType.TABLE, | |
| EvidenceType.FORMULA, | |
| EvidenceType.CAPTION, | |
| EvidenceType.TITLE, | |
| EvidenceType.HEADING, | |
| EvidenceType.FOOTNOTE, | |
| EvidenceType.REFERENCE, | |
| }: | |
| cleaned = content if element_type is EvidenceType.TABLE else _strip_markdown(content) | |
| return [cleaned] if cleaned else [] | |
| parts = [part.strip() for part in re.split(r"\n\s*\n+", content) if part.strip()] | |
| fragments: list[str] = [] | |
| for part in parts: | |
| cleaned = _strip_markdown(part) | |
| if not cleaned: | |
| continue | |
| # Layout boxes normally align with paragraphs. Exceptionally large boxes | |
| # are split on sentence boundaries without crossing their page/section. | |
| if len(cleaned) <= 1800: | |
| fragments.append(cleaned) | |
| continue | |
| sentences = re.split(r"(?<=[.!?])\s+(?=[A-Z0-9])", cleaned) | |
| buffer: list[str] = [] | |
| size = 0 | |
| for sentence in sentences: | |
| if buffer and size + len(sentence) > 1400: | |
| fragments.append(" ".join(buffer)) | |
| buffer, size = [], 0 | |
| buffer.append(sentence) | |
| size += len(sentence) + 1 | |
| if buffer: | |
| fragments.append(" ".join(buffer)) | |
| return fragments | |
| def _update_section_path(path: list[str], level: int, heading: str) -> list[str]: | |
| level = min(max(level, 1), 6) | |
| next_path = list(path[: level - 1]) | |
| while len(next_path) < level - 1: | |
| next_path.append("Untitled section") | |
| next_path.append(heading) | |
| return next_path | |
| def _retrieval_text( | |
| *, | |
| title: str, | |
| section_path: list[str], | |
| element_type: EvidenceType, | |
| content: str, | |
| ) -> str: | |
| context = [f"Paper: {normalize_evidence_text(title)}"] | |
| if section_path: | |
| context.append(f"Section: {' > '.join(section_path)}") | |
| context.append(f"Element: {element_type.value}") | |
| if content.strip(): | |
| context.append(content.strip()) | |
| return "\n".join(context) | |
| def _authors(value: str) -> list[str]: | |
| if not value.strip(): | |
| return [] | |
| return [normalize_evidence_text(item) for item in re.split(r"[;,]", value) if item.strip()] | |
| def _abstract_from_units(units: list[EvidenceUnit]) -> str: | |
| abstract_parts = [ | |
| unit.raw_text | |
| for unit in units | |
| if unit.section_path and _ABSTRACT_HEADING.match(unit.section_path[-1]) and unit.raw_text | |
| ] | |
| return "\n".join(abstract_parts)[:6000] | |
| def _build_relations(units: list[EvidenceUnit], quality_flags: list[str]) -> list[EvidenceRelation]: | |
| relations: list[EvidenceRelation] = [] | |
| leaves = [ | |
| unit | |
| for unit in units | |
| if unit.element_type not in {EvidenceType.SECTION_CARD, EvidenceType.LOCAL_WINDOW} | |
| ] | |
| for previous, current in zip(leaves, leaves[1:]): | |
| relations.append( | |
| EvidenceRelation( | |
| source_evidence_id=previous.evidence_id, | |
| target_evidence_id=current.evidence_id, | |
| relation_type=RelationType.NEXT, | |
| derivation="parser", | |
| ) | |
| ) | |
| relations.append( | |
| EvidenceRelation( | |
| source_evidence_id=current.evidence_id, | |
| target_evidence_id=previous.evidence_id, | |
| relation_type=RelationType.PREVIOUS, | |
| derivation="parser", | |
| ) | |
| ) | |
| by_page: dict[int, list[EvidenceUnit]] = defaultdict(list) | |
| for unit in leaves: | |
| by_page[unit.page_start].append(unit) | |
| visual_types = {EvidenceType.FIGURE, EvidenceType.PLOT, EvidenceType.DIAGRAM, EvidenceType.TABLE} | |
| for page_number, page_units in by_page.items(): | |
| visuals = [unit for unit in page_units if unit.element_type in visual_types and unit.bbox_norm] | |
| captions = [unit for unit in page_units if unit.element_type is EvidenceType.CAPTION and unit.bbox_norm] | |
| for caption in captions: | |
| candidates = sorted( | |
| visuals, | |
| key=lambda visual: _vertical_distance(caption.bbox_norm, visual.bbox_norm), | |
| ) | |
| if not candidates: | |
| quality_flags.append(f"caption_unlinked:{page_number}:{caption.evidence_id}") | |
| continue | |
| target = candidates[0] | |
| if _vertical_distance(caption.bbox_norm, target.bbox_norm) > 0.25: | |
| quality_flags.append(f"caption_unlinked:{page_number}:{caption.evidence_id}") | |
| continue | |
| target.caption = caption.raw_text | |
| target.retrieval_text = f"{target.retrieval_text}\nCaption: {caption.raw_text}".strip() | |
| relations.append( | |
| EvidenceRelation( | |
| source_evidence_id=caption.evidence_id, | |
| target_evidence_id=target.evidence_id, | |
| relation_type=RelationType.CAPTION_OF, | |
| confidence=0.9, | |
| derivation="deterministic_linker", | |
| ) | |
| ) | |
| return relations | |
| def _vertical_distance(first: BoundingBox | None, second: BoundingBox | None) -> float: | |
| if first is None or second is None: | |
| return 1.0 | |
| first_mid = first.y + first.h / 2 | |
| second_mid = second.y + second.h / 2 | |
| return abs(first_mid - second_mid) | |
| def _build_section_cards( | |
| units: list[EvidenceUnit], | |
| project_id: str, | |
| document_id: str, | |
| title: str, | |
| *, | |
| start_ordinal: int, | |
| ) -> tuple[list[EvidenceUnit], list[EvidenceRelation]]: | |
| grouped: dict[tuple[str, ...], list[EvidenceUnit]] = defaultdict(list) | |
| for unit in units: | |
| if unit.section_path and unit.element_type not in {EvidenceType.HEADING, EvidenceType.TITLE}: | |
| grouped[tuple(unit.section_path)].append(unit) | |
| cards: list[EvidenceUnit] = [] | |
| relations: list[EvidenceRelation] = [] | |
| for offset, (path, children) in enumerate(grouped.items()): | |
| content = " ".join( | |
| child.raw_text or child.table_markdown or child.caption or child.visual_description | |
| for child in children | |
| if child.index_text.strip() | |
| ) | |
| preview = normalize_evidence_text(content)[:2400] | |
| first = children[0] | |
| evidence_id = make_evidence_id( | |
| project_id, | |
| document_id, | |
| first.page_start, | |
| None, | |
| EvidenceType.SECTION_CARD, | |
| " > ".join(path), | |
| ) | |
| card = EvidenceUnit( | |
| evidence_id=evidence_id, | |
| project_id=project_id, | |
| document_id=document_id, | |
| element_type=EvidenceType.SECTION_CARD, | |
| page_start=min(child.page_start for child in children), | |
| page_end=max(child.page_end for child in children), | |
| section_path=list(path), | |
| ordinal=start_ordinal + offset, | |
| raw_text=preview, | |
| retrieval_text=_retrieval_text( | |
| title=title, | |
| section_path=list(path), | |
| element_type=EvidenceType.SECTION_CARD, | |
| content=preview, | |
| ), | |
| source_kind=SourceKind.DERIVED_SUMMARY, | |
| metadata={"child_count": len(children)}, | |
| ) | |
| cards.append(card) | |
| for child in children: | |
| child.parent_id = evidence_id | |
| relations.append( | |
| EvidenceRelation( | |
| source_evidence_id=child.evidence_id, | |
| target_evidence_id=evidence_id, | |
| relation_type=RelationType.PARENT, | |
| derivation="deterministic_linker", | |
| ) | |
| ) | |
| return cards, relations | |
| def _build_local_windows( | |
| units: list[EvidenceUnit], | |
| project_id: str, | |
| document_id: str, | |
| title: str, | |
| *, | |
| start_ordinal: int, | |
| ) -> tuple[list[EvidenceUnit], list[EvidenceRelation]]: | |
| grouped: dict[tuple[str, ...], list[EvidenceUnit]] = defaultdict(list) | |
| eligible = { | |
| EvidenceType.PARAGRAPH, | |
| EvidenceType.LIST, | |
| EvidenceType.TABLE, | |
| EvidenceType.FORMULA, | |
| EvidenceType.CAPTION, | |
| EvidenceType.REFERENCE, | |
| } | |
| for unit in units: | |
| if unit.element_type in eligible and unit.section_path and unit.index_text.strip(): | |
| grouped[tuple(unit.section_path)].append(unit) | |
| windows: list[EvidenceUnit] = [] | |
| relations: list[EvidenceRelation] = [] | |
| ordinal = start_ordinal | |
| for path, children in grouped.items(): | |
| children.sort(key=lambda unit: unit.ordinal) | |
| for start in range(0, len(children), 3): | |
| group = children[start : start + 3] | |
| if len(group) < 2: | |
| continue | |
| content = "\n\n".join(child.raw_text or child.table_markdown for child in group) | |
| evidence_id = make_evidence_id( | |
| project_id, | |
| document_id, | |
| group[0].page_start, | |
| None, | |
| EvidenceType.LOCAL_WINDOW, | |
| "|".join(child.evidence_id for child in group), | |
| ) | |
| window = EvidenceUnit( | |
| evidence_id=evidence_id, | |
| project_id=project_id, | |
| document_id=document_id, | |
| element_type=EvidenceType.LOCAL_WINDOW, | |
| page_start=min(child.page_start for child in group), | |
| page_end=max(child.page_end for child in group), | |
| parent_id=group[0].parent_id, | |
| section_path=list(path), | |
| ordinal=ordinal, | |
| raw_text=content, | |
| retrieval_text=_retrieval_text( | |
| title=title, | |
| section_path=list(path), | |
| element_type=EvidenceType.LOCAL_WINDOW, | |
| content=content, | |
| ), | |
| source_kind=SourceKind.DERIVED_SUMMARY, | |
| metadata={"child_evidence_ids": [child.evidence_id for child in group]}, | |
| ) | |
| windows.append(window) | |
| ordinal += 1 | |
| for child in group: | |
| relations.append( | |
| EvidenceRelation( | |
| source_evidence_id=child.evidence_id, | |
| target_evidence_id=window.evidence_id, | |
| relation_type=RelationType.SUPPORTS, | |
| derivation="deterministic_linker", | |
| ) | |
| ) | |
| return windows, relations | |
| def _dedupe_relations(relations: Iterable[EvidenceRelation]) -> list[EvidenceRelation]: | |
| seen: set[tuple[str, str, str]] = set() | |
| out: list[EvidenceRelation] = [] | |
| for relation in relations: | |
| key = ( | |
| relation.source_evidence_id, | |
| relation.target_evidence_id, | |
| str(relation.relation_type), | |
| ) | |
| if key not in seen: | |
| seen.add(key) | |
| out.append(relation) | |
| return out | |