opensanctions / opensanctions/poliloom
Validate supporting_quotes field against source document content during extraction
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 22
- Forks
- 2
- PR merge metrics
- No merged PRs in 30d
Description
Problem
Currently, when the LLM extracts politician properties during enrichment, it returns a proof field (stored as supporting_quotes in the database) that should contain a quote from the source document. However, we don't validate that this proof text actually exists in the document, which could lead to:
- Hallucinated proofs: The LLM might generate plausible-sounding quotes that don't exist in the source
- Paraphrased proofs: The LLM might summarize instead of quoting directly
- Data quality issues: Invalid proofs make it harder to verify extractions and reduce trust in the system
Current Flow
The enrichment pipeline (poliloom/enrichment.py) works as follows:
-
Fetch & Process (
_fetch_and_extract_from_page):- Fetches Wikipedia page and archives it
- Converts HTML to plain text:
content = " ".join(soup.get_text().split()) - Passes this
contentto extraction functions
-
Extract Properties (
extract_properties_genericandextract_two_stage_generic):- Calls OpenAI API with the
contentstring - Returns Pydantic models with
prooffields:ExtractedProperty(for dates)FreeFormPosition→ExtractedPosition(two-stage)FreeFormBirthplace→ExtractedBirthplace(two-stage)FreeFormCitizenship→ExtractedCitizenship(two-stage)
- Calls OpenAI API with the
-
Store Data (
store_extracted_data):- Saves to database with
supporting_quotesfield
- Saves to database with
Proposed Solution
Add model_validator to all Pydantic models that have a proof field to validate the proof appears in the source content.
Implementation Approach
Option 1: Validation Context (Recommended)
Use Pydantic's validation context to pass the source content during model parsing:
from pydantic import BaseModel, model_validator
from typing import Optional
class ExtractedProperty(BaseModel):
type: PropertyType
value: str
proof: str
@field_validator("value")
@classmethod
def validate_date_value(cls, v: str) -> str:
return WikidataDate.validate_date_format(v)
@model_validator(mode='after')
def validate_proof_in_content(self) -> 'ExtractedProperty':
"""Validate that proof text appears in the source content."""
# Access context passed during parsing
if hasattr(self, 'model_config') and 'content' in self.model_config.get('context', {}):
content = self.model_config['context']['content']
if self.proof not in content:
raise ValueError(
f"Proof text not found in source document: '{self.proof[:100]}...'"
)
return self
Then modify extract_properties_generic to pass context:
response = await openai_client.responses.parse(
model="gpt-5",
input=[
{"role": "system", "content": config.system_prompt},
{"role": "user", "content": user_prompt},
],
text_format=config.result_model,
reasoning={"effort": "minimal"},
# Pass content in validation context
validation_context={"content": content}
)
Option 2: Post-Extraction Validation
Add a validation function that runs after extraction but before storage:
def validate_proofs(extracted_items: List[Any], content: str) -> List[Any]:
"""Filter out items whose proof doesn't appear in content."""
valid_items = []
for item in extracted_items:
if hasattr(item, 'proof') and item.proof in content:
valid_items.append(item)
else:
logger.warning(f"Filtered out item with invalid proof: {item.proof[:100]}")
return valid_items
Call this in _fetch_and_extract_from_page after extraction:
date_properties = validate_proofs(date_properties, content) if date_properties else None
positions = validate_proofs(positions, content) if positions else None
# etc.
Models to Update
All models with proof fields:
-
Direct extraction (
extract_properties_generic):ExtractedProperty(line 65)
-
Two-stage extraction (
extract_two_stage_generic):FreeFormPosition(line 107)FreeFormBirthplace(line 132)FreeFormCitizenship(line 145)ExtractedPosition(line 78) - created in_map_single_itemExtractedBirthplace(line 94) - created in_map_single_itemExtractedCitizenship(line 158) - created in_map_single_item
Considerations
Text Normalization
The source content is normalized (" ".join(text.split())), so the proof might need similar normalization for matching:
def normalize_text(text: str) -> str:
"""Normalize text for proof matching."""
return " ".join(text.split())
# In validator:
if normalize_text(self.proof) not in normalize_text(content):
raise ValueError(...)
Substring vs. Exact Match
Should we require:
- Exact substring match (recommended for quotes)
- Fuzzy matching for slight variations
- Token-based overlap (e.g., 80% of words present)
Performance
- Substring search is O(n*m) but fast for reasonable text lengths
- Consider caching normalized content if validating many proofs
Two-Stage Extraction
For two-stage extraction (_map_single_item), the free-form models are validated first, then ExtractedPosition/ExtractedBirthplace/ExtractedCitizenship are created manually. Need to ensure content is available at both stages.
Testing
Add tests to verify:
- Valid proofs pass validation
- Invalid proofs are rejected
- Edge cases (empty proofs, very long proofs, special characters)
- Normalized text matches work correctly
Related Files
poliloom/enrichment.py- All extraction logicpoliloom/models/politician.py- Property model withsupporting_quotesfieldtests/test_enrichment.py- Add validation tests
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in poliloom/enrichment.py by tracing _fetch_and_extract_from_page, extract_properties_generic, extract_two_stage_generic, and _map_single_item. Review the proof-bearing models and tests/test_enrichment.py, then determine how source content should be passed and normalized. Done means valid quotes are retained, invalid proofs are rejected, and the listed edge cases are covered by tests.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- backend
- Issue type
- Feature
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Stale
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100