| """ |
| DocMind — Attribution Parser |
| |
| Extracts [SOURCE: chunk_id] tags from LLM responses and strips |
| sentences that cite invalid or missing chunks. |
| """ |
|
|
| import logging |
| import re |
| from dataclasses import dataclass |
| from typing import List, Optional, Set |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| @dataclass |
| class AttributedSentence: |
| """A single sentence with its cited source chunk ID.""" |
| text: str |
| chunk_id: Optional[str] = None |
| raw_text: str = "" |
|
|
|
|
| |
| _SOURCE_PATTERN = re.compile(r"\[SOURCE:\s*([^\]]+)\]", re.IGNORECASE) |
|
|
|
|
| def parse_attributed_response(response_text: str) -> List[AttributedSentence]: |
| """ |
| Parse an LLM response into individual sentences with their source attributions. |
| |
| Each sentence is expected to end with [SOURCE: chunk_id]. |
| Sentences without attributions are still returned but with chunk_id=None. |
| |
| Args: |
| response_text: Raw LLM response text. |
| |
| Returns: |
| List of AttributedSentence objects. |
| """ |
| if not response_text or response_text.strip() == "INSUFFICIENT_CONTEXT": |
| return [] |
|
|
| sentences: List[AttributedSentence] = [] |
|
|
| |
| |
| |
| parts = _SOURCE_PATTERN.split(response_text) |
|
|
| |
| |
| i = 0 |
| while i < len(parts): |
| text_part = parts[i].strip() |
| chunk_id = parts[i + 1].strip() if i + 1 < len(parts) else None |
|
|
| if text_part: |
| |
| |
| sub_sentences = _split_into_sentences(text_part) |
| for j, sent in enumerate(sub_sentences): |
| sent = sent.strip() |
| if not sent: |
| continue |
| |
| cid = chunk_id if j == len(sub_sentences) - 1 else None |
| raw = f"{sent} [SOURCE: {cid}]" if cid else sent |
| sentences.append(AttributedSentence( |
| text=sent, |
| chunk_id=cid, |
| raw_text=raw, |
| )) |
|
|
| i += 2 |
|
|
| logger.info( |
| "Parsed %d attributed sentences (%d with valid sources)", |
| len(sentences), |
| sum(1 for s in sentences if s.chunk_id), |
| ) |
| return sentences |
|
|
|
|
| def _split_into_sentences(text: str) -> List[str]: |
| """Basic sentence splitter on . ! ? boundaries.""" |
| |
| raw_parts = re.split(r"(?<=[.!?])\s+", text) |
| return [p for p in raw_parts if p.strip()] |
|
|
|
|
| def strip_unattributed( |
| sentences: List[AttributedSentence], |
| valid_chunk_ids: Set[str], |
| ) -> List[AttributedSentence]: |
| """ |
| Remove sentences whose cited chunk_id is not in the valid set. |
| |
| Args: |
| sentences: Parsed attributed sentences. |
| valid_chunk_ids: Set of chunk IDs that actually exist in the current index. |
| |
| Returns: |
| Filtered list with only validly-attributed sentences. |
| """ |
| kept = [] |
| removed_count = 0 |
|
|
| for sent in sentences: |
| if sent.chunk_id and sent.chunk_id in valid_chunk_ids: |
| kept.append(sent) |
| elif sent.chunk_id is None: |
| |
| removed_count += 1 |
| else: |
| |
| removed_count += 1 |
|
|
| if removed_count > 0: |
| logger.info("Stripped %d unattributed/invalid sentences", removed_count) |
|
|
| return kept |
|
|