'GET', 'callback' => [ __CLASS__, 'organization' ], 'permission_callback' => '__return_true' ] ); register_rest_route( self::NS, '/entities/registry', [ 'methods' => 'GET', 'callback' => [ __CLASS__, 'entity_registry' ], 'permission_callback' => '__return_true' ] ); register_rest_route( self::NS, '/site-graph', [ 'methods' => 'GET', 'callback' => [ __CLASS__, 'site_graph' ], 'permission_callback' => '__return_true' ] ); // Per-page (accepts numeric ID or slug) register_rest_route( self::NS, '/context/(?P[a-zA-Z0-9_-]+)', [ 'methods' => 'GET', 'callback' => [ __CLASS__, 'context' ], 'permission_callback' => '__return_true' ] ); register_rest_route( self::NS, '/entities/(?P[a-zA-Z0-9_-]+)', [ 'methods' => 'GET', 'callback' => [ __CLASS__, 'entities' ], 'permission_callback' => '__return_true' ] ); register_rest_route( self::NS, '/schema/(?P[a-zA-Z0-9_-]+)', [ 'methods' => 'GET', 'callback' => [ __CLASS__, 'schema' ], 'permission_callback' => '__return_true' ] ); register_rest_route( self::NS, '/bundle/(?P[a-zA-Z0-9_-]+)', [ 'methods' => 'GET', 'callback' => [ __CLASS__, 'bundle' ], 'permission_callback' => '__return_true' ] ); } // ------------------------------------------------------------------------- // Site-wide endpoints // ------------------------------------------------------------------------- public static function organization( $req ) { $org = [ '@context' => 'https://schema.org', '@type' => 'Organization', 'name' => Semantic_Settings::org_name(), 'url' => Semantic_Settings::org_url(), 'description' => Semantic_Settings::org_description(), ]; $logo = Semantic_Settings::org_logo(); if ( $logo ) { $org['logo'] = [ '@type' => 'ImageObject', 'url' => $logo ]; } $email = Semantic_Settings::org_email(); if ( $email ) $org['email'] = $email; $socials = Semantic_Settings::org_socials(); if ( ! empty( $socials ) ) $org['sameAs'] = array_values( $socials ); return rest_ensure_response( $org ); } public static function entity_registry( $req ) { $page = max( 1, (int) ( $req->get_param( 'page' ) ?: 1 ) ); $per_page = min( 200, max( 1, (int) ( $req->get_param( 'per_page' ) ?: 50 ) ) ); // Public endpoint: only entities that occur in currently published content. $all = Semantic_Entity_Registry::get_all_public(); $total = count( $all ); $paged = array_slice( $all, ( $page - 1 ) * $per_page, $per_page ); $entities = array_map( fn( $row ) => self::format_registry_entity( $row ), $paged ); return rest_ensure_response( [ 'page' => $page, 'per_page' => $per_page, 'total' => $total, 'entities' => $entities, ] ); } public static function site_graph( $req ) { // Public endpoint: only entities/relationships from currently published content. $all = Semantic_Entity_Registry::get_all_public(); $entities = array_map( fn( $row ) => self::format_registry_entity( $row ), $all ); // Set of entity ids that actually exist in the registry, used below to // drop dangling relationship edges (endpoints the model referenced by // name but never extracted as an entity). $valid_ids = []; foreach ( $entities as $e ) { $valid_ids[ $e['entity_id'] ] = true; } $relationships = []; $seen = []; // Relationships only from completed analyses whose post is currently published, // public, password-free and not excluded — never from drafts/private content. global $wpdb; $types = Semantic_Settings::public_post_types(); if ( empty( $types ) ) { return rest_ensure_response( [ 'entities' => $entities, 'relationships' => [] ] ); } $ph = implode( ',', array_fill( 0, count( $types ), '%s' ) ); $args = array_merge( $types, [ Semantic_Settings::EXCLUDE_META ] ); $raw_rels = $wpdb->get_results( $wpdb->prepare( "SELECT sa.entity_relationships FROM {$wpdb->prefix}semantic_analyses sa JOIN {$wpdb->posts} p ON p.ID = sa.post_id WHERE sa.status = 'completed' AND p.post_status = 'publish' AND p.post_password = '' AND p.post_type IN ($ph) AND p.ID NOT IN ( SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value = '1' )", $args ), ARRAY_A ); foreach ( $raw_rels as $row ) { $rels = json_decode( $row['entity_relationships'] ?? '[]', true ) ?: []; foreach ( $rels as $rel ) { $source = $rel['source_entity'] ?? ''; $target = $rel['target_entity'] ?? ''; if ( '' === $source || '' === $target ) continue; // Canonical ids make the graph traversable — they join to the entity // registry and the public /entities/{id} pages. Relationships are // stored by surface label, so derive the id the same way the // analyzer does. $source_id = self::canonical_entity_id( $source ); $target_id = self::canonical_entity_id( $target ); $relation = $rel['relation_type'] ?? ''; // Drop dangling edges: keep only relationships whose BOTH // endpoints resolve to an entity that exists in the registry. // Relationships are stored by surface label and can reference a // concept that was never extracted, which would otherwise render // as a broken edge in the network graph. if ( ! isset( $valid_ids[ $source_id ] ) || ! isset( $valid_ids[ $target_id ] ) ) { continue; } // Dedupe identical edges that recur across analyses. $key = $source_id . '|' . $target_id . '|' . $relation; if ( isset( $seen[ $key ] ) ) continue; $seen[ $key ] = true; $relationships[] = [ 'source' => $source, 'source_id' => $source_id, 'target' => $target, 'target_id' => $target_id, 'relation_type' => $relation, 'confidence' => $rel['confidence'] ?? 0, ]; } } return rest_ensure_response( [ 'entities' => $entities, 'relationships' => $relationships, ] ); } // ------------------------------------------------------------------------- // Per-page endpoints // ------------------------------------------------------------------------- public static function context( $req ) { $post = self::resolve_post( $req['id'] ); if ( ! $post ) return new WP_Error( 'not_found', 'Page not found', [ 'status' => 404 ] ); $analysis = self::latest_completed_analysis( $post->ID ); return rest_ensure_response( [ 'post_id' => $post->ID, 'slug' => $post->post_name, 'url' => get_permalink( $post ), 'title' => $post->post_title, 'page_context' => [ 'primary_topic' => $analysis ? $analysis['primary_topic_entity'] : null, 'audience' => $analysis ? $analysis['user_stage'] : null, 'difficulty' => $analysis ? $analysis['difficulty'] : null, 'semantic_intent' => $analysis ? [ 'page_type' => $analysis['page_type'], 'primary_intent' => $analysis['primary_intent'], 'secondary_intents'=> json_decode( $analysis['secondary_intents'] ?? '[]', true ) ?: [], ] : null, ], 'sections' => $analysis ? ( json_decode( $analysis['content_sections'] ?? '[]', true ) ?: [] ) : [], 'llm_context' => $analysis ? $analysis['llm_context_block'] : null, 'summaries' => $analysis ? ( json_decode( $analysis['summaries'] ?? 'null', true ) ) : null, 'summary' => $analysis ? $analysis['summary'] : null, 'citable_snippet' => $analysis ? $analysis['citable_snippet'] : null, ] ); } public static function entities( $req ) { $post = self::resolve_post( $req['id'] ); if ( ! $post ) return new WP_Error( 'not_found', 'Page not found', [ 'status' => 404 ] ); $analysis = self::latest_completed_analysis( $post->ID ); if ( ! $analysis ) { return rest_ensure_response( [ 'post_id' => $post->ID, 'entities' => [] ] ); } global $wpdb; $entities = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}semantic_suggested_entities WHERE analysis_id = %d AND approval_status = 'approved' ORDER BY relevance_score DESC", $analysis['id'] ), ARRAY_A ); return rest_ensure_response( [ 'post_id' => $post->ID, 'entities' => array_map( fn( $e ) => self::format_entity( $e ), $entities ), ] ); } public static function schema( $req ) { $post = self::resolve_post( $req['id'] ); if ( ! $post ) return new WP_Error( 'not_found', 'Page not found', [ 'status' => 404 ] ); $analysis = self::latest_completed_analysis( $post->ID ); if ( ! $analysis ) { return rest_ensure_response( [ 'post_id' => $post->ID, 'schema' => [] ] ); } global $wpdb; $schema = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}semantic_suggested_schema WHERE analysis_id = %d AND approval_status = 'approved'", $analysis['id'] ), ARRAY_A ); // Build JSON-LD graph $graph = self::build_schema_graph( $post, $schema, $analysis ); return rest_ensure_response( $graph ); } public static function bundle( $req ) { $post = self::resolve_post( $req['id'] ); if ( ! $post ) return new WP_Error( 'not_found', 'Page not found', [ 'status' => 404 ] ); $ctx = self::context( $req )->data; $ents = self::entities( $req )->data; $sch = self::schema( $req )->data; return rest_ensure_response( [ 'context' => $ctx, 'entities' => $ents['entities'] ?? [], 'schema' => $sch, ] ); } // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- /** * Mirror of canonicalEntityId() in the Trigger.dev task: collapse surface-form * variants ("Artificial Intelligence (AI)" / "the Internet of Things") to one id. */ private static function canonical_entity_id( $name ) { $n = strtolower( trim( (string) $name ) ); $n = preg_replace( '/\s*\([^)]*\)\s*$/', '', $n ); // trailing parenthetical $n = preg_replace( '/^the\s+/', '', $n ); // leading "the " $slug = preg_replace( '/[^a-z0-9]+/', '_', $n ); $slug = trim( $slug, '_' ); if ( '' === $slug ) { $slug = trim( preg_replace( '/[^a-z0-9]+/', '_', strtolower( (string) $name ) ), '_' ); } return 'entity_' . $slug; } /** Map our internal entity type vocabulary to a schema.org type. */ private static function entity_schema_type( $type ) { $t = strtolower( (string) $type ); $map = [ 'person' => 'Person', 'organization' => 'Organization', 'manufacturer' => 'Organization', 'vendor_brand' => 'Organization', 'cloud_provider' => 'Organization', 'industry' => 'Organization', 'product' => 'Product', 'material' => 'Product', 'equipment' => 'Product', 'component' => 'Product', 'place' => 'Place', 'event' => 'Event', 'software' => 'SoftwareApplication', 'technology' => 'Thing', ]; return $map[ $t ] ?? 'Thing'; } private static function resolve_post( $id ) { // Try numeric ID first. is_publicly_servable() gates publish status, password, // exclusion and public type so non-public posts never resolve on a public route. if ( is_numeric( $id ) ) { $post = get_post( (int) $id ); if ( $post && Semantic_Settings::is_publicly_servable( $post ) ) return $post; } // Try slug $posts = get_posts( [ 'name' => $id, 'post_type' => Semantic_Settings::post_types(), 'post_status' => 'publish', 'posts_per_page' => 1, ] ); $post = ! empty( $posts ) ? $posts[0] : null; return ( $post && Semantic_Settings::is_publicly_servable( $post ) ) ? $post : null; } private static function latest_completed_analysis( $post_id ) { global $wpdb; return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}semantic_analyses WHERE post_id = %d AND status = 'completed' ORDER BY finished_at DESC LIMIT 1", $post_id ), ARRAY_A ); } private static function format_entity( array $e ) { return [ 'id' => $e['canonical_entity_id'] ?: 'entity_' . $e['id'], 'name' => $e['name'], 'type' => $e['type'], 'definition' => $e['definition'], 'relevance_score' => (float) $e['relevance_score'], 'wikidata_qid' => $e['wikidata_qid'], 'wikidata_verified' => (bool) $e['wikidata_verified'], 'sameAs' => json_decode( $e['same_as'] ?? '[]', true ) ?: [], 'aliases' => json_decode( $e['aliases'] ?? '[]', true ) ?: [], 'roles' => json_decode( $e['roles'] ?? '[]', true ) ?: [], 'related_entities' => json_decode( $e['related_entities'] ?? '[]', true ) ?: [], 'mentions' => json_decode( $e['mentions'] ?? '[]', true ) ?: [], ]; } private static function format_registry_entity( array $row ) { return [ 'entity_id' => $row['entity_id'], 'label' => $row['label'], 'canonical_label' => $row['canonical_label'], 'type' => $row['type'], 'wikidata_qid' => $row['wikidata_qid'], 'sameAs' => json_decode( $row['same_as'] ?? '[]', true ) ?: [], 'synonyms' => json_decode( $row['synonyms'] ?? '[]', true ) ?: [], 'definition' => $row['definition'], 'canonical_roles' => json_decode( $row['canonical_roles'] ?? '[]', true ) ?: [], 'role_frequency' => json_decode( $row['role_frequency'] ?? '{}', true ) ?: [], 'typical_contexts' => json_decode( $row['typical_contexts'] ?? '[]', true ) ?: [], 'occurrence_count' => (int) $row['occurrence_count'], 'mention_count' => (int) $row['mention_count'], ]; } private static function build_schema_graph( WP_Post $post, array $schema_rows, array $analysis ) { $domain = wp_parse_url( home_url(), PHP_URL_HOST ); $url = get_permalink( $post ); $graph = [ '@context' => 'https://schema.org', '@graph' => [], ]; // Page node — promote to Article/HowTo from the AI-detected type, and carry // the same richness as the on-page JSON-LD (dates, author, publisher, // abstract) so the REST schema and the crawlable schema are consistent. $ca = json_decode( $analysis['content_analysis'] ?? '{}', true ) ?: []; $suggested = $ca['suggested_schema_type'] ?? ''; $node_type = in_array( $suggested, [ 'Article', 'HowTo' ], true ) ? $suggested : 'WebPage'; $webpage = [ '@type' => $node_type, '@id' => $url . '#webpage', 'url' => $url, 'name' => $post->post_title, 'description' => $analysis['llm_description'] ?: wp_trim_words( $post->post_content, 30 ), 'inLanguage' => get_bloginfo( 'language' ), 'isPartOf' => [ '@id' => home_url() . '/#website' ], 'publisher' => [ '@id' => home_url() . '/#organization' ], 'datePublished' => get_post_time( 'c', true, $post ), 'dateModified' => get_post_modified_time( 'c', true, $post ), ]; if ( 'WebPage' !== $node_type ) $webpage['headline'] = $post->post_title; if ( $post->post_author ) { $webpage['author'] = [ '@type' => 'Person', 'name' => get_the_author_meta( 'display_name', $post->post_author ), 'url' => get_author_posts_url( $post->post_author ), ]; } if ( has_post_thumbnail( $post ) ) { $img = get_the_post_thumbnail_url( $post, 'full' ); if ( $img ) $webpage['image'] = $img; } if ( ! empty( $analysis['llm_context_block'] ) ) { $webpage['abstract'] = $analysis['llm_context_block']; } // about[] — built from approved entities so it carries the Wikidata-verified // sameAs/QID and @id-links to the public /entities/{id} pages (matching the // on-page JSON-LD). Falls back to suggested_schema rows if no entities. global $wpdb; $entity_rows = $wpdb->get_results( $wpdb->prepare( "SELECT name, type, definition, canonical_entity_id, wikidata_qid, same_as FROM {$wpdb->prefix}semantic_suggested_entities WHERE analysis_id = %d AND approval_status = 'approved' ORDER BY relevance_score DESC LIMIT 30", $analysis['id'] ), ARRAY_A ); $about = []; if ( ! empty( $entity_rows ) ) { foreach ( $entity_rows as $e ) { $thing = [ '@type' => self::entity_schema_type( $e['type'] ), 'name' => $e['name'] ]; if ( ! empty( $e['canonical_entity_id'] ) ) { $thing['@id'] = home_url( '/entities/' . $e['canonical_entity_id'] ); $thing['url'] = $thing['@id']; } if ( ! empty( $e['definition'] ) ) $thing['description'] = $e['definition']; $same_as = json_decode( $e['same_as'] ?? '[]', true ) ?: []; if ( ! empty( $same_as ) ) $thing['sameAs'] = array_values( $same_as ); if ( ! empty( $e['wikidata_qid'] ) ) { $thing['identifier'] = [ '@type' => 'PropertyValue', 'propertyID' => 'wikidata', 'value' => $e['wikidata_qid'] ]; } $about[] = $thing; } } else { foreach ( $schema_rows as $s ) { if ( empty( $s['use_in_schema'] ) ) continue; $props = json_decode( $s['schema_properties'] ?? '{}', true ) ?: []; $thing = [ '@type' => $s['type'], 'name' => $s['entity'] ]; if ( ! empty( $props['description'] ) ) $thing['description'] = $props['description']; if ( ! empty( $props['url'] ) ) $thing['url'] = $props['url']; $about[] = $thing; } } if ( ! empty( $about ) ) $webpage['about'] = $about; // FAQs global $wpdb; $faqs = $wpdb->get_results( $wpdb->prepare( "SELECT question, answer FROM {$wpdb->prefix}semantic_suggested_faqs WHERE analysis_id = %d AND approval_status = 'approved'", $analysis['id'] ), ARRAY_A ); if ( ! empty( $faqs ) ) { $webpage['mainEntity'] = [ '@type' => 'FAQPage', 'mainEntity' => array_map( fn( $f ) => [ '@type' => 'Question', 'name' => $f['question'], 'acceptedAnswer' => [ '@type' => 'Answer', 'text' => $f['answer'] ], ], $faqs ), ]; } $graph['@graph'][] = $webpage; // Organization node (logo + sameAs from settings, when present) $org = [ '@type' => 'Organization', '@id' => home_url() . '/#organization', 'name' => Semantic_Settings::org_name(), 'url' => Semantic_Settings::org_url(), ]; $logo = Semantic_Settings::org_logo(); if ( $logo ) $org['logo'] = [ '@type' => 'ImageObject', 'url' => $logo ]; $socials = Semantic_Settings::org_socials(); if ( ! empty( $socials ) ) $org['sameAs'] = array_values( $socials ); $graph['@graph'][] = $org; // WebSite node so the page node's isPartOf reference resolves. $graph['@graph'][] = [ '@type' => 'WebSite', '@id' => home_url() . '/#website', 'url' => home_url(), 'name' => Semantic_Settings::org_name(), 'publisher' => [ '@id' => home_url() . '/#organization' ], ]; return $graph; } }