content-explorer/connectors/onco_reference.py

503 lines
18 KiB
Python
Raw Permalink Normal View History

"""Allowlist-only OnCo reference adapter.
Reads enumerated records from a local upstream checkout and writes a snapshot
under the gitignored reference directory. It does not publish into
``examples/`` and it does not import UI code. Edges that leave the allowlist
are reported and are not stored as relations.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from content_explorer.attribution import attribution_gaps
from content_explorer.contract import validate_documents
from content_explorer.ingest import build_manifest
PIN = "b594d7433bf4e408b44215e25e5304696d6f840e"
LICENSE = "CC-BY-NC-4.0"
LICENSE_URL = "https://creativecommons.org/licenses/by-nc/4.0/"
ATTRIBUTION = "Data from OnCo (onco.cc)"
MODIFICATION = (
"Projected into a content-explorer snapshot. Titles, summaries, and "
"in-slice relations keep the pinned OnCo wording. Edges outside the "
"allowlist are listed in the import report and are not stored."
)
RELATION_FIELDS = {
"targets": "targets",
"drugs": "drugs",
"trials": "trials",
"terms": "terms",
"technologies": "technologies",
"pathways": "pathways",
"companies": "companies",
"institutions": "institutions",
"cancers": "cancers",
"related": "related",
"people": "people",
"papers": "papers",
"keyPapers": "key-papers",
}
LOCAL_ROOT_NAME = Path("tests") / "onco-reference" / "local"
def load_authorization(path: Path) -> dict[str, Any]:
document = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(document, dict) or document.get("granted") is not True:
raise RuntimeError("OnCo reference use is not authorized")
if document.get("distribution_allowed") is not False or document.get("ci_allowed") is not False:
raise RuntimeError("this grant does not allow redistribution or public CI")
return document
def _skip_string(text: str, index: int) -> int:
quote = text[index]
index += 1
length = len(text)
while index < length:
char = text[index]
if char == "\\":
index += 2
continue
if quote == "`" and char == "$" and index + 1 < length and text[index + 1] == "{":
depth = 1
index += 2
while index < length and depth:
if text[index] in ('"', "'", "`"):
index = _skip_string(text, index)
continue
if text[index] == "{":
depth += 1
elif text[index] == "}":
depth -= 1
index += 1
continue
if char == quote:
return index + 1
index += 1
return index
def _skip_comment(text: str, index: int) -> int:
if text.startswith("//", index):
newline = text.find("\n", index)
return length if (length := len(text)) and newline < 0 else newline + 1
if text.startswith("/*", index):
end = text.find("*/", index + 2)
return len(text) if end < 0 else end + 2
return index
def find_object(text: str, entity_id: str) -> str:
"""Return the object literal whose own ``id`` is ``entity_id``."""
length = len(text)
index = 0
depth = 0
starts: list[int] = []
pending: tuple[int, int] | None = None
while index < length:
char = text[index]
if char in ('"', "'", "`"):
index = _skip_string(text, index)
continue
if char == "/" and index + 1 < length and text[index + 1] in "/*":
index = _skip_comment(text, index)
continue
if char == "{":
starts.append(index)
depth += 1
index += 1
continue
if char == "}":
start = starts.pop() if starts else 0
if pending and pending[0] == depth:
return text[start:index + 1]
depth -= 1
index += 1
continue
if text.startswith("id", index) and (index == 0 or not (text[index - 1].isalnum() or text[index - 1] == "_")):
cursor = index + 2
while cursor < length and text[cursor].isspace():
cursor += 1
if cursor < length and text[cursor] == ":":
cursor += 1
while cursor < length and text[cursor].isspace():
cursor += 1
if cursor < length and text[cursor] in ('"', "'"):
end = _skip_string(text, cursor)
literal = text[cursor + 1:end - 1]
if literal == entity_id and starts:
pending = (depth, starts[-1])
index += 1
raise ValueError(f"{entity_id} was not found")
def _string_literal(text: str, index: int) -> tuple[str, int]:
end = _skip_string(text, index)
return text[index + 1:end - 1], end
def object_fields(text: str, constants: dict[str, str]) -> dict[str, Any]:
"""Read depth-1 keys. Nested objects are skipped. String arrays are kept."""
fields: dict[str, Any] = {}
length = len(text)
index = 0
depth = 0
while index < length:
char = text[index]
if char in ('"', "'", "`"):
index = _skip_string(text, index)
continue
if char == "/" and index + 1 < length and text[index + 1] in "/*":
index = _skip_comment(text, index)
continue
if char == "{":
depth += 1
index += 1
continue
if char == "}":
depth -= 1
index += 1
continue
if depth != 1 or not (char.isalpha() or char == "_"):
index += 1
continue
start = index
while index < length and (text[index].isalnum() or text[index] == "_"):
index += 1
key = text[start:index]
while index < length and text[index].isspace():
index += 1
if index < length and text[index] in ",}":
if key in constants:
fields[key] = constants[key]
continue
if index >= length or text[index] != ":":
continue
index += 1
while index < length and text[index].isspace():
index += 1
if index >= length:
break
if text[index] in ('"', "'", "`"):
value, index = _string_literal(text, index)
fields[key] = value
continue
if text[index] == "[":
index += 1
values: list[str] = []
while index < length and text[index] != "]":
if text[index] in ('"', "'", "`"):
value, index = _string_literal(text, index)
values.append(value)
continue
if text[index] in ('"', "'", "`"):
index = _skip_string(text, index)
continue
if text[index] == "{":
nested = 1
index += 1
while index < length and nested:
if text[index] in ('"', "'", "`"):
index = _skip_string(text, index)
continue
if text[index] == "{":
nested += 1
elif text[index] == "}":
nested -= 1
index += 1
continue
index += 1
fields[key] = values
index += 1
continue
ident_start = index
while index < length and (text[index].isalnum() or text[index] == "_"):
index += 1
ident = text[ident_start:index]
if ident in constants:
fields[key] = constants[ident]
continue
if index == ident_start:
index += 1
continue
return fields
def file_constants(text: str) -> dict[str, str]:
constants: dict[str, str] = {}
marker = "const asOf = "
at = text.find(marker)
if at >= 0:
cursor = at + len(marker)
if cursor < len(text) and text[cursor] in ('"', "'"):
value, _end = _string_literal(text, cursor)
constants["asOf"] = value
return constants
OPTIONAL_FIELDS = ("aka", "status")
def read_record(upstream: Path, entry: dict[str, str]) -> dict[str, Any]:
path = upstream / entry["file"]
text = path.read_text(encoding="utf-8")
obj = find_object(text, entry["id"])
fields = object_fields(obj, file_constants(text))
if fields.get("id") != entry["id"]:
raise ValueError(f"{entry['id']} object did not keep its id")
title = str(fields.get("name") or "").strip()
tldr = str(fields.get("tldr") or "").strip()
if not title or not tldr:
raise ValueError(f"{entry['id']} is missing a name or tldr")
relations: list[tuple[str, str]] = []
for field, predicate in RELATION_FIELDS.items():
for target in fields.get(field) or []:
if isinstance(target, str) and target:
relations.append((predicate, target))
return {
"id": entry["id"],
"kind": entry["kind"],
"title": title,
"tldr": tldr,
"summary": str(fields.get("summary") or tldr).strip(),
"asOf": str(fields.get("asOf") or "2026-09-04"),
"relations": relations,
"file": entry["file"],
"missingFields": [
name for name in OPTIONAL_FIELDS
if name not in fields or fields.get(name) in ("", None, [])
],
}
def project_slice(
records: list[dict[str, Any]],
*,
source: dict[str, Any],
kinds: list[dict[str, str]],
relation_labels: dict[str, dict[str, str]],
require_size: bool = True,
) -> dict[str, Any]:
"""Build a snapshot. Outside-allowlist edges are reported, not stored."""
gaps = attribution_gaps(source)
if gaps:
raise RuntimeError("refusing to project a source with attribution gaps: " + ", ".join(gaps))
by_upstream = {record["id"]: record for record in records}
if len(by_upstream) != len(records):
raise RuntimeError("duplicate upstream id")
outside: list[dict[str, str]] = []
relations: list[dict[str, Any]] = []
entities: list[dict[str, Any]] = []
claims: list[dict[str, Any]] = []
predicates: list[str] = []
record_sources: list[dict[str, Any]] = []
dataspace = "onco-reference"
for record in records:
kind = record["kind"]
key = record["id"]
entity_id = f"urn:ce:{dataspace}:{kind}:{key}"
claim_id = f"claim:{kind}:{key}:tldr"
record_source = dict(source)
record_source["id"] = f"src:onco:{kind}:{key}"
record_source["uri"] = f"https://onco.cc/{kind}s/{key}"
record_source["title"] = record["title"]
record_gaps = attribution_gaps(record_source)
if record_gaps:
raise RuntimeError("record source is missing attribution: " + ", ".join(record_gaps))
record_sources.append(record_source)
entities.append({
"id": entity_id,
"kindId": kind,
"title": record["title"],
"tldr": record["tldr"],
"summary": record["summary"],
"aliases": [key],
"tags": [kind],
"sourceRefs": [source["id"], record_source["id"]],
"claimRefs": [claim_id],
"asOf": record["asOf"],
})
claims.append({
"id": claim_id,
"subjectId": entity_id,
"key": "tldr",
"value": record["tldr"],
"status": "asserted",
"evidenceRefs": [source["id"]],
"observedAt": record["asOf"],
})
seen: set[tuple[str, str]] = set()
index = 0
for predicate, target in record["relations"]:
if (predicate, target) in seen:
continue
seen.add((predicate, target))
if target not in by_upstream:
outside.append({"from": key, "predicate": predicate, "to": target})
continue
index += 1
target_kind = by_upstream[target]["kind"]
predicates.append(predicate)
relations.append({
"id": f"{kind}-{key}-{index}",
"predicate": predicate,
"from": entity_id,
"to": f"urn:ce:{dataspace}:{target_kind}:{target}",
"sourceRefs": [source["id"], record_source["id"]],
"asOf": record["asOf"],
})
entities.sort(key=lambda item: item["id"])
relations.sort(key=lambda item: item["id"])
claims.sort(key=lambda item: item["id"])
manifest, missing = build_manifest(
dataspace_id=dataspace,
heading="OnCo reference slice",
kinds=kinds,
relation_labels=relation_labels,
predicates=predicates,
fact_policy="show-all",
)
if missing or manifest is None:
raise RuntimeError("missing relation labels: " + ", ".join(missing))
for view in manifest["kindViews"].values():
view["columns"] = ["name", "tldr", "tags"]
view["facets"] = ["tags", "kind"]
record_sources.sort(key=lambda item: item["id"])
snapshot = {
"schemaVersion": "0.1",
"dataspace": {
"id": dataspace,
"title": "OnCo reference slice",
"description": "A local allowlisted OnCo conformance slice. Not medical advice. Not for redistribution.",
},
"kinds": kinds,
"entities": entities,
"relations": relations,
"sources": [source, *record_sources],
"claims": claims,
}
errors = validate_documents(snapshot, manifest, require_size=require_size)
if errors:
raise RuntimeError("projection failed validation: " + "; ".join(errors))
source_edges = sum(len({(predicate, target) for predicate, target in record["relations"]}) for record in records)
if len(relations) + len(outside) != source_edges:
raise RuntimeError("an edge was lost between the extract and the report")
report = {
"connectorVersion": "onco-reference/0.1.0",
"published": True,
"candidates": len(records),
"accepted": len(entities),
"outsideAllowlist": outside,
"storedRelations": len(relations),
"sourceEdges": source_edges,
"missingOptional": [
{"id": record["id"], "fields": list(record.get("missingFields") or [])}
for record in records
if record.get("missingFields")
],
"source": {"id": source["id"], "license": source["license"], "originSha": source.get("originSha")},
}
return {"snapshot": snapshot, "manifest": manifest, "report": report}
def assert_local_destination(destination: Path, repo: Path) -> None:
local = (repo / LOCAL_ROOT_NAME).resolve()
target = destination.resolve()
if local not in target.parents and target != local:
raise RuntimeError(f"refusing to write the OnCo slice outside {local}")
examples = (repo / "examples").resolve()
if examples in target.parents or target == examples:
raise RuntimeError("refusing to publish the OnCo slice into examples")
def write_projection(destination: Path, result: dict[str, Any], repo: Path) -> None:
assert_local_destination(destination, repo)
destination.mkdir(parents=True, exist_ok=True)
for name, document in (
("snapshot.json", result["snapshot"]),
("manifest.json", result["manifest"]),
("import-report.json", result["report"]),
):
(destination / name).write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
def upstream_sha(upstream: Path) -> str:
import subprocess
completed = subprocess.run(
["git", "-C", str(upstream), "rev-parse", "HEAD"],
check=True,
capture_output=True,
text=True,
)
return completed.stdout.strip()
def extract_allowlist(upstream: Path, allowlist: list[dict[str, str]], authorization: Path) -> list[dict[str, Any]]:
"""Read the allowlist only after a local grant that refuses redistribution."""
load_authorization(authorization)
sha = upstream_sha(upstream)
if sha != PIN:
raise RuntimeError(f"upstream pin is {sha}, expected {PIN}")
return [read_record(upstream, entry) for entry in allowlist]
def relation_labels() -> dict[str, dict[str, str]]:
wording = {
"targets": ("Targets", "Targeted by"),
"drugs": ("Drugs", "Drug for"),
"trials": ("Trials", "Trial of"),
"terms": ("Terms", "Term for"),
"technologies": ("Technologies", "Technology for"),
"pathways": ("Pathways", "Pathway for"),
"companies": ("Companies", "Company for"),
"institutions": ("Institutions", "Institution for"),
"cancers": ("Cancers", "Cancer for"),
"related": ("Related", "Related to"),
"people": ("People", "Person for"),
"papers": ("Papers", "Paper for"),
"key-papers": ("Key papers", "Key paper for"),
}
return {
predicate: {"forward": forward, "inverse": inverse}
for predicate, (forward, inverse) in wording.items()
}
def kinds_for(entries: list[dict[str, str]]) -> list[dict[str, str]]:
labels = {
"cancer": ("Cancer", "Cancers"),
"drug": ("Drug", "Drugs"),
"target": ("Target", "Targets"),
"technology": ("Technology", "Technologies"),
"term": ("Term", "Terms"),
"trial": ("Trial", "Trials"),
"company": ("Company", "Companies"),
"institution": ("Institution", "Institutions"),
"paper": ("Paper", "Papers"),
}
ordered: list[str] = []
for entry in entries:
if entry["kind"] not in ordered:
ordered.append(entry["kind"])
return [
{"id": kind, "label": labels[kind][0], "plural": labels[kind][1]}
for kind in ordered
]
def onco_source(retrieved_at: str) -> dict[str, str]:
return {
"id": "src:onco:reference-slice",
"uri": "https://onco.cc",
"title": "OnCo",
"license": LICENSE,
"licenseUrl": LICENSE_URL,
"attribution": ATTRIBUTION,
"originSha": PIN,
"modificationNotice": MODIFICATION,
"retrievedAt": retrieved_at,
}