"""Push validated ExtractedClaim objects into Atomic as Type/Claim atoms. Each claim becomes a tiny atom with: - source_url = `{parent_url}#claim={hash8}` so it dedups idempotently and so `parent_url = source_url.split("#")[0]` is trivial to recover later - tag inheritance from the parent (Country, Topic, SourceType, Credibility, Language) plus our two new tags: Type/Claim and Stance/ """ from __future__ import annotations from typing import Any from shared.atomic_api import AtomicApiError, AtomicClient from shared.logging import get_logger from shared.taxonomy import TagResolver from extractor.extract import ExtractedClaim log = get_logger(__name__) _STANCE_PATH = { "ASSERTS": "Stance/Asserts", "REPORTS": "Stance/Reports", "REFUTES": "Stance/Refutes", "QUESTIONS": "Stance/Questions", "NEUTRAL": "Stance/Neutral", } def build_claim_markdown( claim: ExtractedClaim, *, parent_title: str, parent_url: str, parent_atom_id: str, ) -> str: """Render a claim atom's body as Markdown. The structure is intentionally consistent so it can be parsed back later by Didi or by re-indexing scripts. """ return ( f"# Claim\n\n" f"{claim.claim}\n\n" f"## Quote\n" f"> {claim.quote}\n\n" f"## Source\n" f"- Document: [{parent_title}]({parent_url})\n" f"- Parent atom: `{parent_atom_id}`\n" f"- Stance in source: {claim.stance}\n" f"- Extraction confidence: {claim.confidence:.2f}\n" ) def build_claim_url(parent_url: str, claim: ExtractedClaim) -> str: """Stable hash-based URL fragment so re-extraction dedupes naturally.""" base = parent_url.split("#", 1)[0] return f"{base}#claim={claim.stable_hash()}" def inherit_tag_ids( parent_atom: dict[str, Any], claim: ExtractedClaim, resolver: TagResolver, ) -> list[str]: """Build the tag-id list for a new claim atom. Inherits all parent tags except Type/Document, and adds Type/Claim plus the appropriate Stance/. """ type_doc_id = resolver.require("Type/Document") type_claim_id = resolver.require("Type/Claim") stance_id = resolver.require(_STANCE_PATH[claim.stance]) parent_tag_ids = [ t["id"] for t in (parent_atom.get("tags") or []) if t.get("id") != type_doc_id ] return parent_tag_ids + [type_claim_id, stance_id] async def push_claim( atomic: AtomicClient, *, parent_atom: dict[str, Any], claim: ExtractedClaim, parent_title: str, resolver: TagResolver, ) -> tuple[dict[str, Any] | None, str]: """Create one claim atom in Atomic. Returns (atom_dict, status). Status is one of: - "created": new atom was created - "duplicate": same hash already exists, skipped - "error": creation failed (atom_dict is None) """ parent_url = parent_atom.get("source_url") or "" parent_id = parent_atom.get("id") or "" claim_url = build_claim_url(parent_url, claim) # Idempotency: same canonical hash → skip existing = await atomic.get_atom_by_source_url(claim_url) if existing: return existing, "duplicate" md = build_claim_markdown( claim, parent_title=parent_title, parent_url=parent_url, parent_atom_id=parent_id, ) tag_ids = inherit_tag_ids(parent_atom, claim, resolver) try: atom = await atomic.create_atom( content=md, source_url=claim_url, tag_ids=tag_ids, ) return atom, "created" except AtomicApiError as e: log.error("push_claim_failed", url=claim_url, status=e.status, body=e.body[:200]) return None, "error"