124 lines
3.6 KiB
Python
124 lines
3.6 KiB
Python
"""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/<X>
|
|
"""
|
|
|
|
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/<X>.
|
|
"""
|
|
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"
|