feat(schema): v0.6.0 — PSA backfill migration + Yoast-integrated Drug/MedicalWebPage/FAQ schema

Piece 1 — Backfill migration 0004: copies PSA psa_molecular_formula/weight/aliases
into PR Core _pr_* meta. Idempotent + re-runnable. PubChem REST fallback for 4
posts missing PSA data (MAX_RETRIES=3, exp backoff). All PSA values UNTRUSTED.
PR_CORE_TARGET_SCHEMA_VERSION bumped 3 -> 4.

Piece 2 — Drug node enrichment + stable @id = {permalink}#drug (ratified contract).
Reads _pr_* schema-input meta. molecularWeight in g/mol. Empty-prop guard throughout.

Piece 3 — Yoast-integrated emission via wpseo_schema_graph_pieces (priority 11),
wpseo_schema_webpage_type (MedicalWebPage retype), wpseo_schema_graph (enrichment).
Standalone @graph fallback retained. No duplicate WebPage/BreadcrumbList.
FAQPage emitted only when _pr_faq_items populated.

Supporting: PR_Core_Schema_Sanitizers, PR_Core_Pubchem_Client, CONTEXT.md,
ARCHITECTURE.md, CONVENTIONS.md, CHANGELOG.md, uninstall.php purge.
Tests: test-migration-0004.php (20 assertions), test-jsonld.php (29 assertions).

Agent-Session: peptide-schema-backfill v0.6.0
This commit is contained in:
peptiderepo
2026-06-13 05:01:59 +00:00
parent cc5d06b970
commit 1e5999e663
17 changed files with 1919 additions and 117 deletions

View File

@@ -0,0 +1,265 @@
<?php
declare(strict_types=1);
/**
* Migration 0004: Backfill peptide schema meta from PSA psa_* keys.
*
* What: Copies PSA-stored PubChem data (molecular formula, weight, aliases)
* into PR Core's own _pr_molecular_formula, _pr_molecular_weight,
* _pr_aliases meta keys on all published peptide posts.
* For posts missing PSA data, attempts a PubChem REST lookup via
* PR_Core_Pubchem_Client.
*
* Who calls it: PR_Core_Migration_Runner::run_pending().
* Dependencies: WordPress get_post_meta(), update_post_meta(), get_posts().
* PR_Core_Pubchem_Client for PubChem fallback.
* PR_Core_Schema_Sanitizers for formula sanitization.
* PR_Core_Peptide_CPT::POST_TYPE for query.
*
* Idempotent: skips posts where all three PR Core meta keys are already non-empty.
* Re-runnable: safe to run multiple times.
*
* PSA source keys (all treated as UNTRUSTED — sanitized before writing):
* psa_molecular_formula => _pr_molecular_formula (string)
* psa_molecular_weight => _pr_molecular_weight (float string, strip "Da")
* psa_aliases => _pr_aliases (JSON array string)
*
* @see ARCHITECTURE.md — §v0.6.0 schema sprint.
* @see CONVENTIONS.md — How To: Add a New Migration.
* @see class-pr-core-pubchem-client.php — HTTP client for PubChem REST.
*/
class PR_Core_Migration_0004_Backfill_Peptide_Meta {
/** @var string Meta key: molecular formula (PR Core namespace). */
public const META_FORMULA = '_pr_molecular_formula';
/** @var string Meta key: molecular weight as float string (PR Core namespace). */
public const META_WEIGHT = '_pr_molecular_weight';
/** @var string Meta key: aliases JSON array (PR Core namespace). */
public const META_ALIASES = '_pr_aliases';
/** @var PR_Core_Pubchem_Client HTTP client for PubChem REST API. */
private PR_Core_Pubchem_Client $pubchem;
/**
* Construct migration with optional PubChem client (injectable for testing).
*
* @param PR_Core_Pubchem_Client|null $pubchem PubChem client; creates default if null.
*/
public function __construct( ?PR_Core_Pubchem_Client $pubchem = null ) {
$this->pubchem = $pubchem ?? new PR_Core_Pubchem_Client();
}
/**
* Run the backfill migration.
*
* Side effects: reads post meta, writes post meta, logs via error_log.
*
* @return void
*/
public function up(): void {
$peptide_ids = $this->get_all_peptide_ids();
if ( empty( $peptide_ids ) ) {
error_log( '[PR Core Migration 0004] No peptide posts found — nothing to backfill.' );
return;
}
$counts = [ 'skipped' => 0, 'copied_psa' => 0, 'pubchem_ok' => 0, 'pubchem_skip' => 0 ];
foreach ( $peptide_ids as $post_id ) {
$result = $this->backfill_post( $post_id );
$counts[ $result ] += 1;
}
error_log( sprintf(
'[PR Core Migration 0004] Complete. Skipped: %d, PSA-copied: %d, PubChem-ok: %d, PubChem-skip: %d',
$counts['skipped'],
$counts['copied_psa'],
$counts['pubchem_ok'],
$counts['pubchem_skip']
) );
}
/**
* Backfill meta for a single peptide post.
*
* @param int $post_id WordPress post ID.
* @return string Result: skipped|copied_psa|pubchem_ok|pubchem_skip.
*/
private function backfill_post( int $post_id ): string {
$existing_formula = (string) get_post_meta( $post_id, self::META_FORMULA, true );
$existing_weight = (string) get_post_meta( $post_id, self::META_WEIGHT, true );
$existing_aliases = (string) get_post_meta( $post_id, self::META_ALIASES, true );
if ( '' !== $existing_formula && '' !== $existing_weight && '' !== $existing_aliases ) {
return 'skipped';
}
$psa_formula = (string) get_post_meta( $post_id, 'psa_molecular_formula', true );
if ( '' !== $psa_formula ) {
$psa_weight = (string) get_post_meta( $post_id, 'psa_molecular_weight', true );
$psa_aliases = (string) get_post_meta( $post_id, 'psa_aliases', true );
$this->write_meta_from_psa( $post_id, $psa_formula, $psa_weight, $psa_aliases );
return 'copied_psa';
}
// No PSA data — attempt PubChem REST lookup.
$psa_cid = (string) get_post_meta( $post_id, 'psa_pubchem_cid', true );
$post = get_post( $post_id );
$name = $post ? sanitize_text_field( $post->post_title ) : '';
$data = ( '' !== $psa_cid && ctype_digit( $psa_cid ) )
? $this->pubchem->fetch_by_cid( $psa_cid )
: null;
if ( null === $data && '' !== $name ) {
$data = $this->pubchem->fetch_by_name( $name );
}
if ( null === $data ) {
error_log( sprintf(
'[PR Core Migration 0004] Post ID %d (%s): PubChem returned no data — leaving meta empty.',
$post_id,
esc_html( $name )
) );
return 'pubchem_skip';
}
$formula = PR_Core_Schema_Sanitizers::sanitize_molecular_formula( $data['formula'] );
$weight = (float) $data['weight'];
$aliases_json = $this->synonyms_to_json( $data['synonyms'] ?? [] );
$this->write_pr_meta( $post_id, $formula, $weight, $aliases_json );
error_log( sprintf(
'[PR Core Migration 0004] Post ID %d (%s): PubChem-sourced formula=%s weight=%s',
$post_id,
esc_html( $name ),
$formula,
(string) $weight
) );
return 'pubchem_ok';
}
/**
* Write PR Core meta from PSA source values.
*
* Input is UNTRUSTED — sanitizes before writing.
*
* @param int $post_id WordPress post ID.
* @param string $formula Raw psa_molecular_formula value.
* @param string $weight Raw psa_molecular_weight value (may include " Da").
* @param string $aliases Raw psa_aliases value (comma-separated).
* @return void
*/
private function write_meta_from_psa( int $post_id, string $formula, string $weight, string $aliases ): void {
$clean_formula = PR_Core_Schema_Sanitizers::sanitize_molecular_formula( $formula );
$clean_weight = $this->parse_weight( $weight );
$clean_aliases = $this->parse_aliases_string( $aliases );
$this->write_pr_meta( $post_id, $clean_formula, $clean_weight, $clean_aliases );
}
/**
* Write the three PR Core meta keys for a post.
*
* Only writes a field when it has a usable value to avoid overwriting
* existing data with empty placeholders.
*
* @param int $post_id WordPress post ID.
* @param string $formula Sanitized formula (empty = skip).
* @param float $weight Parsed weight in g/mol (0 = skip).
* @param string $aliases JSON array string (empty array = skip).
* @return void
*/
private function write_pr_meta( int $post_id, string $formula, float $weight, string $aliases ): void {
if ( '' !== $formula ) {
update_post_meta( $post_id, self::META_FORMULA, $formula );
}
if ( $weight > 0.0 ) {
update_post_meta( $post_id, self::META_WEIGHT, (string) $weight );
} elseif ( '' !== $formula ) {
error_log( '[PR Core Migration 0004] Post ID ' . $post_id . ': weight parsed to 0 — check source.' );
}
$decoded = json_decode( $aliases, true );
if ( is_array( $decoded ) && count( $decoded ) > 0 ) {
update_post_meta( $post_id, self::META_ALIASES, $aliases );
}
}
/**
* Parse a molecular weight string to a float.
*
* Strips trailing " Da" suffix (case-insensitive) and whitespace.
* Returns 0.0 on parse failure.
*
* @param string $value Raw weight value (e.g., "1419.5 Da").
* @return float Parsed weight in g/mol, 0.0 on failure.
*/
public function parse_weight( string $value ): float {
$clean = trim( preg_replace( '/\s*[Dd][Aa]\s*$/', '', trim( $value ) ) ?? '' );
return (float) $clean;
}
/**
* Parse a comma-separated alias string into a sanitized JSON array string.
*
* Input is UNTRUSTED — each alias is sanitized individually.
*
* @param string $value Comma-separated aliases.
* @return string JSON array of sanitized strings, or '[]' if empty.
*/
public function parse_aliases_string( string $value ): string {
if ( '' === trim( $value ) ) {
return '[]';
}
$clean = [];
foreach ( explode( ',', $value ) as $part ) {
$alias = sanitize_text_field( trim( $part ) );
if ( '' !== $alias ) {
$clean[] = $alias;
}
}
return wp_json_encode( $clean, JSON_UNESCAPED_UNICODE ) ?: '[]';
}
/**
* Convert a raw synonyms array from PubChem to a JSON string.
*
* Sanitizes each synonym. Returns '[]' on empty input.
*
* @param string[] $synonyms Raw synonym strings from PubChem.
* @return string JSON array of sanitized strings.
*/
private function synonyms_to_json( array $synonyms ): string {
$clean = [];
foreach ( $synonyms as $syn ) {
$alias = sanitize_text_field( (string) $syn );
if ( '' !== $alias ) {
$clean[] = $alias;
}
}
return wp_json_encode( $clean, JSON_UNESCAPED_UNICODE ) ?: '[]';
}
/**
* Get IDs of all published peptide posts.
*
* @return int[]
*/
private function get_all_peptide_ids(): array {
$posts = get_posts( [
'post_type' => PR_Core_Peptide_CPT::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => -1,
'fields' => 'ids',
] );
return array_map( 'intval', is_array( $posts ) ? $posts : [] );
}
}

View File

@@ -29,6 +29,7 @@ class PR_Core_Migration_Runner {
'PR_Core_Migration_0001_Dosing_Rows',
'PR_Core_Migration_0002_Legal_Cells',
'PR_Core_Migration_0003_Candidate_Queue',
'PR_Core_Migration_0004_Backfill_Peptide_Meta',
];
/**

View File

@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
/**
* Lightweight PubChem REST client for the backfill migration.
*
* What: Fetches molecular formula, molecular weight, and synonyms from the
* PubChem REST API for a given compound CID or name. Used exclusively
* by migration 0004 for the 4 peptide posts that lack PSA-sourced data.
*
* Who calls it: PR_Core_Migration_0004_Backfill_Peptide_Meta::fetch_pubchem().
* Dependencies: WordPress wp_remote_get() when available; falls back to
* file_get_contents with stream context (WP-CLI context).
*
* All values from PubChem are returned unsanitized — callers must sanitize
* before writing to post_meta.
*
* @see migrations/class-pr-core-migration-0004-backfill-peptide-meta.php — Caller.
* @see ARCHITECTURE.md — §v0.6.0 schema sprint.
*/
class PR_Core_Pubchem_Client {
/** @var string PubChem REST base URL. */
private const BASE = 'https://pubchem.ncbi.nlm.nih.gov/rest/pug';
/** @var int HTTP timeout per request (seconds). */
private const TIMEOUT = 8;
/** @var int Maximum retry attempts with exponential backoff. */
private const MAX_RETRIES = 3;
/** @var int Maximum synonyms to return (keeps post-meta size reasonable). */
private const MAX_SYNONYMS = 10;
/**
* Fetch properties and synonyms by PubChem CID.
*
* @param string $cid Numeric PubChem CID string.
* @return array{formula: string, weight: float, synonyms: string[]}|null Null on failure.
*/
public function fetch_by_cid( string $cid ): ?array {
if ( '' === $cid || ! ctype_digit( $cid ) ) {
return null;
}
$props = $this->fetch_properties( 'cid', $cid );
if ( null === $props ) {
return null;
}
$props['synonyms'] = $this->fetch_synonyms( 'cid', $cid );
return $props;
}
/**
* Fetch properties and synonyms by compound name.
*
* @param string $name Compound name (e.g., peptide post title).
* @return array{formula: string, weight: float, synonyms: string[]}|null Null on failure.
*/
public function fetch_by_name( string $name ): ?array {
if ( '' === trim( $name ) ) {
return null;
}
$props = $this->fetch_properties( 'name', $name );
if ( null === $props ) {
return null;
}
// If a CID was returned in the property response, use it for synonyms (more reliable).
$cid = $props['cid'] ?? '';
$type = ( '' !== $cid ) ? 'cid' : 'name';
$ref = ( '' !== $cid ) ? $cid : $name;
$props['synonyms'] = $this->fetch_synonyms( $type, $ref );
unset( $props['cid'] );
return $props;
}
/**
* Fetch MolecularFormula and MolecularWeight from PubChem.
*
* @param string $lookup_type 'cid' or 'name'.
* @param string $identifier CID number or compound name.
* @return array{formula: string, weight: float, cid: string}|null
*/
private function fetch_properties( string $lookup_type, string $identifier ): ?array {
$url = self::BASE . '/compound/' . $lookup_type . '/' . rawurlencode( $identifier )
. '/property/MolecularFormula,MolecularWeight/JSON';
$body = $this->get_with_retry( $url );
if ( null === $body ) {
return null;
}
$data = json_decode( $body, true );
$props = $data['PropertyTable']['Properties'][0] ?? null;
if ( ! is_array( $props ) ) {
return null;
}
$formula = (string) ( $props['MolecularFormula'] ?? '' );
$weight = (float) ( $props['MolecularWeight'] ?? 0 );
$cid = (string) ( $props['CID'] ?? '' );
if ( '' === $formula || $weight <= 0.0 ) {
return null;
}
return [
'formula' => $formula,
'weight' => $weight,
'cid' => $cid,
];
}
/**
* Fetch synonyms for a compound, limited to MAX_SYNONYMS.
*
* Returns an empty array on failure — callers treat this gracefully.
*
* @param string $lookup_type 'cid' or 'name'.
* @param string $identifier CID or name value.
* @return string[] Raw synonym strings (unsanitized).
*/
private function fetch_synonyms( string $lookup_type, string $identifier ): array {
$url = self::BASE . '/compound/' . $lookup_type . '/' . rawurlencode( $identifier ) . '/synonyms/JSON';
$body = $this->get_with_retry( $url );
if ( null === $body ) {
return [];
}
$data = json_decode( $body, true );
$synonyms = $data['InformationList']['Information'][0]['Synonym'] ?? [];
if ( ! is_array( $synonyms ) ) {
return [];
}
return array_slice( $synonyms, 0, self::MAX_SYNONYMS );
}
/**
* GET request with exponential-backoff retry (up to MAX_RETRIES).
*
* @param string $url URL to fetch.
* @return string|null Response body on HTTP 200, null after all retries fail.
*/
private function get_with_retry( string $url ): ?string {
for ( $attempt = 0; $attempt < self::MAX_RETRIES; $attempt++ ) {
if ( $attempt > 0 ) {
sleep( (int) pow( 2, $attempt - 1 ) ); // 1s, 2s.
}
$body = $this->get_once( $url );
if ( null !== $body ) {
return $body;
}
}
error_log( '[PR Core PubChem] Request failed after ' . self::MAX_RETRIES . ' attempts: ' . $url );
return null;
}
/**
* Single HTTP GET attempt.
*
* Uses wp_remote_get() when available; falls back to file_get_contents
* for WP-CLI contexts where the HTTP API may not be bootstrapped.
*
* @param string $url URL to fetch.
* @return string|null Body on HTTP 200, null on error or non-200.
*/
private function get_once( string $url ): ?string {
if ( function_exists( 'wp_remote_get' ) ) {
$response = wp_remote_get( $url, [
'timeout' => self::TIMEOUT,
'headers' => [ 'Accept' => 'application/json' ],
] );
if ( is_wp_error( $response ) ) {
return null;
}
if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
return null;
}
$body = wp_remote_retrieve_body( $response );
return is_string( $body ) ? $body : null;
}
// WP-CLI / plain PHP fallback.
$context = stream_context_create( [
'http' => [
'timeout' => self::TIMEOUT,
'header' => "Accept: application/json\r\n",
'method' => 'GET',
],
] );
$body = @file_get_contents( $url, false, $context );
return ( false === $body ) ? null : $body;
}
}