post_status, array( 'trash', 'auto-draft', 'inherit' ), true ) ) {
return;
}
$previous = get_post_meta( $post_id, self::META_KEY, true );
if ( ! is_array( $previous ) ) {
return;
}
$expectations = (array) ( $previous['expectations'] ?? array() );
$expectations['source'] = (string) ( $previous['source'] ?? 'topical_map' );
$expectations['provenance'] = (array) ( $previous['provenance'] ?? array() );
self::evaluate( $post_id, $expectations, true );
}
/**
* Evaluate one draft.
*
* @param int $post_id WordPress post ID.
* @param array $expectations Generator expectations (length, format, links).
* @param bool $persist Whether to persist the report.
* @return array|\WP_Error
*/
public static function evaluate( $post_id, $expectations = array(), $persist = true ) {
$post = get_post( $post_id );
if ( ! $post ) {
return new \WP_Error( 'not_found', __( 'Draft not found.', 'aeo-god-mode' ) );
}
$provenance = self::normalize_provenance( (array) ( $expectations['provenance'] ?? array() ) );
$source = sanitize_key( (string) ( $expectations['source'] ?? 'generated' ) );
$expectations = self::normalize_expectations( $expectations );
$checks = array();
$plain = wp_strip_all_tags( (string) $post->post_content );
$word_count = str_word_count( $plain );
$metadata = class_exists( __NAMESPACE__ . '\\MetadataWriter' )
? MetadataWriter::read( $post_id )
: array( 'meta_title' => '', 'meta_description' => '' );
$meta_title = trim( (string) ( $metadata['meta_title'] ?? '' ) );
$meta_description = trim( (string) ( $metadata['meta_description'] ?? '' ) );
// Render once with the correct singular-post globals, then give that
// immutable snapshot to every scorer in this evaluation.
$rendered_content = ContentGaps::render_post_snapshot( $post );
$gaps = ( new ContentGaps() )->analyze_post( $post, $rendered_content );
$rendered_analysis = is_array( $gaps['rendered_analysis'] ?? null )
? $gaps['rendered_analysis']
: null;
$density = is_array( $rendered_analysis['answer_density'] ?? null )
? $rendered_analysis['answer_density']
: ( class_exists( __NAMESPACE__ . '\\Answer_Density' )
? Answer_Density::scan_post( $post_id, $rendered_content )
: array() );
if ( class_exists( __NAMESPACE__ . '\\RenderedPageEvaluator' ) ) {
if ( ! is_array( $rendered_analysis ) ) {
$schema_data = class_exists( __NAMESPACE__ . '\\Schema' )
? ( new Schema() )->get_for_post( $post_id )
: array( 'schemas' => array() );
$rendered_analysis = RenderedPageEvaluator::evaluate(
array(
'url' => get_permalink( $post ),
'content_html' => $rendered_content,
'title' => '' !== $meta_title ? $meta_title : get_the_title( $post ),
'meta_description' => $meta_description,
'schemas' => (array) ( $schema_data['schemas'] ?? array() ),
'page_kind' => 'editorial',
'answer_density' => $density,
),
array(
'profile' => 'generated_draft',
'answerability_eligible' => true,
'content_depth_eligible' => true,
'require_reliable_content' => false,
)
);
}
}
$aeo_score = is_array( $rendered_analysis ) && isset( $rendered_analysis['scores']['aeo'] )
? (int) $rendered_analysis['scores']['aeo']
: max( 0, 100 - (int) ( $gaps['gap_score'] ?? 0 ) );
$shared_ready = ! is_array( $rendered_analysis ) || ! empty( $rendered_analysis['machine_ready'] );
$sidebar_ready = $aeo_score >= self::MIN_AEO_SCORE;
self::add_check(
$checks,
'sidebar_aeo',
__( 'Editor AEO score', 'aeo-god-mode' ),
$sidebar_ready ? 'pass' : 'fail',
sprintf(
/* translators: 1: score, 2: target score */
__( '%1$d/100 (target: %2$d+; shared rendered-page evaluator).', 'aeo-god-mode' ),
$aeo_score,
self::MIN_AEO_SCORE
),
true
);
$schema_quality = self::shared_check( $rendered_analysis, 'schema_errors' );
if ( is_array( $schema_quality ) ) {
$schema_status = (string) ( $schema_quality['status'] ?? 'not_applicable' );
self::add_check(
$checks,
'schema_quality',
__( 'Schema quality', 'aeo-god-mode' ),
in_array( $schema_status, array( 'pass', 'fail' ), true ) ? $schema_status : 'not_applicable',
(string) ( $schema_quality['message'] ?? __( 'Schema quality could not be evaluated.', 'aeo-god-mode' ) ),
true,
array( 'messages' => (array) ( $schema_quality['details']['messages'] ?? array() ) )
);
}
$schema_presence = self::shared_check( $rendered_analysis, 'no_schema' );
$schema_presence_status = is_array( $schema_presence )
? (string) ( $schema_presence['status'] ?? 'unknown' )
: 'unknown';
$schema_quality_status = is_array( $schema_quality )
? (string) ( $schema_quality['status'] ?? 'not_applicable' )
: 'not_applicable';
$schema_verifiable = in_array( $schema_presence_status, array( 'pass', 'fail' ), true )
&& in_array( $schema_quality_status, array( 'pass', 'fail' ), true );
if ( ! $schema_verifiable ) {
self::add_check(
$checks,
'schema_verification',
__( 'Rendered schema verification', 'aeo-god-mode' ),
'fail',
__( 'Schema is expected from another provider, but its rendered JSON-LD was not available to this draft preflight. Preview the public page and verify schema presence and quality before publishing.', 'aeo-god-mode' ),
false
);
}
$question_count = (int) ( $density['question_headings'] ?? 0 );
$density_score = $question_count > 0 ? (int) ( $density['answer_density_score'] ?? 0 ) : null;
if ( null === $density_score ) {
self::add_check(
$checks,
'answer_density',
__( 'Answer density', 'aeo-god-mode' ),
'not_applicable',
__( 'No question-shaped headings; this score is not applicable.', 'aeo-god-mode' )
);
} else {
$unanswered = array_key_exists( 'unanswered_answers', $density )
? (int) $density['unanswered_answers']
: count( array_filter( (array) ( $density['issues'] ?? array() ), function ( $issue ) {
return 'no_direct_answer' === ( $issue['issue'] ?? '' );
} ) );
$density_ok = $density_score >= self::MIN_ANSWER_DENSITY && 0 === $unanswered;
self::add_check(
$checks,
'answer_density',
__( 'Answer density', 'aeo-god-mode' ),
$density_ok ? 'pass' : 'fail',
sprintf(
/* translators: 1: score, 2: target, 3: direct answers, 4: headings */
__( '%1$d/100 (target: %2$d+); %3$d of %4$d headings lead with a direct answer.', 'aeo-god-mode' ),
$density_score,
self::MIN_ANSWER_DENSITY,
(int) ( $density['direct_answers'] ?? 0 ),
$question_count
),
true
);
}
$length_target = 'long' === $expectations['length']
? array( 1900, 2400 )
: array( 1100, 1400 );
$length_limit = 'long' === $expectations['length']
? array( 1700, 2700 )
: array( 900, 1600 );
$length_ok = $word_count >= $length_target[0] && $word_count <= $length_target[1];
$length_in_tol = $word_count >= $length_limit[0] && $word_count <= $length_limit[1];
self::add_check(
$checks,
'word_count',
__( 'Planned depth', 'aeo-god-mode' ),
$length_ok ? 'pass' : ( $length_in_tol ? 'warn' : 'fail' ),
sprintf(
/* translators: 1: actual word count, 2: minimum, 3: maximum */
__( '%1$d words; expected %2$d–%3$d for this generation mode.', 'aeo-god-mode' ),
$word_count,
$length_target[0],
$length_target[1]
),
true
);
$body_nonempty = '' !== trim( $plain );
self::add_check(
$checks,
'body',
__( 'Article body', 'aeo-god-mode' ),
$body_nonempty ? 'pass' : 'fail',
$body_nonempty ? __( 'The draft has a non-empty article body.', 'aeo-god-mode' ) : __( 'The generated article body is empty.', 'aeo-god-mode' ),
true
);
preg_match_all( '/
]*>/i', (string) $post->post_content, $h2_matches );
$h2_count = count( $h2_matches[0] );
self::add_check(
$checks,
'h2_structure',
__( 'Section structure', 'aeo-god-mode' ),
$h2_count >= 3 && $h2_count <= 8 ? 'pass' : 'fail',
sprintf( __( '%d H2 sections (expected: 3–8).', 'aeo-god-mode' ), $h2_count ),
true
);
$opener_report = is_array( $rendered_analysis )
? (array) ( $rendered_analysis['metrics']['h2_openers'] ?? array( 'checked' => 0, 'issues' => array() ) )
: self::h2_opener_report( (string) $post->post_content );
self::add_check(
$checks,
'h2_openers',
__( 'Answer-first section openers', 'aeo-god-mode' ),
empty( $opener_report['issues'] ) ? 'pass' : 'fail',
empty( $opener_report['issues'] )
? sprintf( __( 'All %d substantive H2 sections open with a direct answer.', 'aeo-god-mode' ), $opener_report['checked'] )
: sprintf( __( '%d H2 section(s) do not open with a direct answer.', 'aeo-god-mode' ), count( $opener_report['issues'] ) ),
true,
array( 'section_issues' => $opener_report['issues'] )
);
$opening = is_array( $rendered_analysis )
? (array) ( $rendered_analysis['metrics']['opening_answer'] ?? array( 'direct' => false, 'first_sentence' => '' ) )
: self::opening_answer_report( (string) $post->post_content );
self::add_check(
$checks,
'opening_answer',
__( 'Main-query opening answer', 'aeo-god-mode' ),
$opening['direct'] ? 'pass' : 'fail',
$opening['direct']
? __( 'The article opens with a standalone direct answer.', 'aeo-god-mode' )
: __( 'Lead with a standalone answer before the first section heading.', 'aeo-god-mode' ),
true,
array( 'first_sentence' => $opening['first_sentence'] )
);
$unsafe_html = (bool) preg_match( '/<(?:script|style|iframe|form)\b/i', (string) $post->post_content );
self::add_check(
$checks,
'safe_html',
__( 'Safe article HTML', 'aeo-god-mode' ),
$unsafe_html ? 'fail' : 'pass',
$unsafe_html ? __( 'The body contains a disallowed script, style, iframe or form tag.', 'aeo-god-mode' ) : __( 'No disallowed executable or embedded HTML is present.', 'aeo-god-mode' ),
true
);
$has_placeholder = (bool) preg_match( '/\[(?:YOUR\s+DETAIL|ADD\s+(?:FACT|DETAIL)|INSERT[^\]]*)\]|\bTODO\b/i', (string) $post->post_content );
self::add_check(
$checks,
'no_placeholders',
__( 'No publishing placeholders', 'aeo-god-mode' ),
$has_placeholder ? 'fail' : 'pass',
$has_placeholder ? __( 'Remove unresolved placeholder text.', 'aeo-god-mode' ) : __( 'No unresolved publishing placeholders were found.', 'aeo-god-mode' ),
true
);
$has_h1 = (bool) preg_match( '/post_content );
self::add_check(
$checks,
'no_h1',
__( 'WordPress heading structure', 'aeo-god-mode' ),
$has_h1 ? 'fail' : 'pass',
$has_h1
? __( 'Remove the H1 from the body; WordPress renders the post title as H1.', 'aeo-god-mode' )
: __( 'No duplicate H1 in the article body.', 'aeo-god-mode' ),
true
);
$title_len = self::text_length( $meta_title );
self::add_check(
$checks,
'generated_meta_title',
__( 'Generated metadata title', 'aeo-god-mode' ),
$title_len > 0 && $title_len <= 60 ? 'pass' : 'fail',
$title_len > 0
? sprintf( __( '%d characters (generation contract maximum: 60).', 'aeo-god-mode' ), $title_len )
: __( 'No generated metadata title was saved.', 'aeo-god-mode' ),
true
);
$description_len = self::text_length( $meta_description );
$description_ok = $description_len >= 145 && $description_len <= 158;
$description_safe = $description_len >= 120 && $description_len <= 160;
self::add_check(
$checks,
'generated_meta_description',
__( 'Generated metadata description', 'aeo-god-mode' ),
$description_ok ? 'pass' : ( $description_safe ? 'warn' : 'fail' ),
$description_len > 0
? sprintf( __( '%d characters (generation target: 145–158; accepted: 120–160).', 'aeo-god-mode' ), $description_len )
: __( 'No generated metadata description was saved.', 'aeo-god-mode' ),
true
);
$faq_parse = class_exists( __NAMESPACE__ . '\\FaqParser' )
? FaqParser::parse_aeogm( (string) $post->post_content )
: array( 'pairs' => array(), 'wrapper_balanced' => false, 'item_open_count' => 0, 'item_close_count' => 0 );
$faq_count = count( (array) $faq_parse['pairs'] );
$faq_ok = $faq_count >= $expectations['faq_min']
&& $faq_count <= $expectations['faq_max']
&& ! empty( $faq_parse['wrapper_balanced'] )
&& $faq_count === (int) $faq_parse['item_open_count'];
self::add_check(
$checks,
'faq_contract',
__( 'FAQ contract', 'aeo-god-mode' ),
$faq_ok ? 'pass' : 'fail',
sprintf(
/* translators: 1: FAQ count, 2: minimum, 3: maximum */
__( '%1$d complete FAQ items; expected %2$d–%3$d inside one balanced wrapper.', 'aeo-god-mode' ),
$faq_count,
$expectations['faq_min'],
$expectations['faq_max']
),
true
);
$missing_links = array();
$duplicate_links = array();
foreach ( $expectations['internal_urls'] as $url ) {
$occurrences = self::url_occurrences( (string) $post->post_content, $url );
if ( 0 === $occurrences ) {
$missing_links[] = $url;
} elseif ( $occurrences > 1 ) {
$duplicate_links[] = $url;
}
}
$link_status = ! empty( $missing_links ) ? 'fail' : ( ! empty( $duplicate_links ) ? 'warn' : 'pass' );
self::add_check(
$checks,
'planned_links',
__( 'Planned internal links', 'aeo-god-mode' ),
$link_status,
empty( $missing_links )
? ( empty( $duplicate_links )
? sprintf( __( 'All %d planned links are present exactly once.', 'aeo-god-mode' ), count( $expectations['internal_urls'] ) )
: sprintf( __( '%d planned link(s) appear more than once.', 'aeo-god-mode' ), count( $duplicate_links ) ) )
: sprintf( __( '%d planned link(s) are missing.', 'aeo-god-mode' ), count( $missing_links ) ),
true,
array( 'missing_urls' => $missing_links, 'duplicate_urls' => $duplicate_links )
);
self::add_format_check( $checks, $post, $expectations['format'], $gaps, $faq_count );
$has_image = ! empty( $gaps['details']['has_featured'] ) || ! empty( $gaps['details']['image_count'] );
self::add_check(
$checks,
'editorial_image',
__( 'Editorial image', 'aeo-god-mode' ),
$has_image ? 'pass' : 'manual',
$has_image
? __( 'A featured or inline image is available to Article schema.', 'aeo-god-mode' )
: __( 'Choose a relevant featured image before publishing; this adds Google’s recommended Article image property.', 'aeo-god-mode' )
);
$author_name = get_the_author_meta( 'display_name', $post->post_author );
$author_bio = get_the_author_meta( 'description', $post->post_author );
$author_job = get_user_meta( $post->post_author, '_asgm_eeat_job_title', true );
$author_ok = self::text_length( $author_name ) > 2 && ( $author_bio || $author_job );
self::add_check(
$checks,
'author_review',
__( 'Author evidence', 'aeo-god-mode' ),
$author_ok ? 'pass' : 'manual',
$author_ok
? __( 'The assigned author has a usable name and profile evidence.', 'aeo-god-mode' )
: __( 'Assign an expert author and complete their bio or job title before publishing.', 'aeo-god-mode' )
);
self::add_check(
$checks,
'fact_review',
__( 'Facts and first-hand evidence', 'aeo-god-mode' ),
'manual',
__( 'Verify every claim and add first-hand examples, data or authoritative sources where the topic supports them.', 'aeo-god-mode' )
);
$citability = null;
if ( class_exists( __NAMESPACE__ . '\\CitabilityScore' ) ) {
$citability = ( new CitabilityScore() )->score_post( $post_id, true, false );
}
$citability_content = is_array( $citability )
? (int) ( $citability['dimensions']['content']['percent'] ?? 0 )
: null;
if ( null !== $citability_content ) {
self::add_check(
$checks,
'citability_content',
__( 'Citability content signals', 'aeo-god-mode' ),
$citability_content >= 80 ? 'pass' : 'fail',
sprintf( __( '%d%% of content-controlled citability points (target: 80%%+).', 'aeo-god-mode' ), $citability_content ),
true
);
}
$blocking = array_values( array_filter( $checks, function ( $check ) {
return 'fail' === $check['status'];
} ) );
$warnings = array_values( array_filter( $checks, function ( $check ) {
return 'warn' === $check['status'];
} ) );
$manual = array_values( array_filter( $checks, function ( $check ) {
return 'manual' === $check['status'];
} ) );
$machine_ready = empty( $blocking ) && $shared_ready;
$result = array(
'contract_version' => self::CONTRACT_VERSION,
'contract_scope' => 'full_generated_draft',
'post_id' => (int) $post_id,
'source' => $source,
'evaluated_at' => current_time( 'mysql' ),
'fingerprint_version' => self::FINGERPRINT_VERSION,
'content_hash' => self::current_content_hash( $post_id ),
'machine_ready' => $machine_ready,
'status' => $machine_ready ? ( empty( $manual ) ? 'ready' : 'human_review' ) : 'needs_work',
'scores' => array(
'aeo' => $aeo_score,
'answer_density' => $density_score,
'citability' => is_array( $citability ) ? (int) ( $citability['score'] ?? 0 ) : null,
'citability_content' => $citability_content,
),
'thresholds' => array(
'aeo' => self::MIN_AEO_SCORE,
'answer_density' => self::MIN_ANSWER_DENSITY,
),
'checks' => $checks,
'blocking_failures' => $blocking,
'warnings' => $warnings,
'manual_checks' => $manual,
'answer_density' => $density,
'rendered_analysis' => $rendered_analysis,
'citability' => $citability,
'sidebar_analysis' => $gaps,
'expectations' => $expectations,
'provenance' => $provenance,
'evaluator_versions'=> array(
'content_gaps' => ContentGaps::SCORE_VERSION,
'rendered_page' => RenderedPageEvaluator::SCORE_VERSION,
'answer_density' => Answer_Density::SCORE_VERSION,
'schema_quality' => Validator::SCORE_VERSION,
'citability' => defined( __NAMESPACE__ . '\CitabilityScore::SCORE_VERSION' ) ? CitabilityScore::SCORE_VERSION : null,
),
);
if ( $persist ) {
update_post_meta( $post_id, self::META_KEY, $result );
delete_post_meta( $post_id, '_asgm_editor_panel_gaps' );
}
return $result;
}
/**
* Whether a persisted draft report uses every current scorer contract.
*
* @param mixed $result Stored draft report.
* @return bool
*/
public static function is_current_result( $result ) {
if ( ! is_array( $result )
|| self::CONTRACT_VERSION !== (int) ( $result['contract_version'] ?? 0 )
|| self::FINGERPRINT_VERSION !== (int) ( $result['fingerprint_version'] ?? 0 )
|| ContentGaps::SCORE_VERSION !== (int) ( $result['sidebar_analysis']['score_version'] ?? 0 )
|| RenderedPageEvaluator::SCORE_VERSION !== (int) ( $result['rendered_analysis']['score_version'] ?? 0 )
|| Answer_Density::SCORE_VERSION !== (int) ( $result['answer_density']['score_version'] ?? 0 ) ) {
return false;
}
$schema_known = ! empty( $result['rendered_analysis']['schema']['known'] );
$schema_version = $result['rendered_analysis']['schema']['validation']['score_version'] ?? null;
if ( $schema_known && Validator::SCORE_VERSION !== (int) $schema_version ) {
return false;
}
if ( ! $schema_known && null !== $schema_version && Validator::SCORE_VERSION !== (int) $schema_version ) {
return false;
}
$current_citability = defined( __NAMESPACE__ . '\\CitabilityScore::SCORE_VERSION' )
? (int) CitabilityScore::SCORE_VERSION
: null;
$stored_citability = $result['evaluator_versions']['citability'] ?? null;
$stored_citability = null === $stored_citability ? null : (int) $stored_citability;
if ( $current_citability !== $stored_citability ) {
return false;
}
return true;
}
/**
* Return a current stored report, rebuilding stale scorer contracts.
*
* @param int $post_id Post ID.
* @param array|null $result Optional report already read by the caller.
* @param bool $persist Persist a rebuilt report.
* @return array|\WP_Error
*/
public static function get_current_or_refresh( $post_id, $result = null, $persist = true ) {
if ( null === $result ) {
$result = get_post_meta( $post_id, self::META_KEY, true );
}
$current_hash = self::current_content_hash( $post_id );
$stored_hash = is_array( $result ) ? (string) ( $result['content_hash'] ?? '' ) : '';
if ( self::is_current_result( $result )
&& '' !== $current_hash
&& '' !== $stored_hash
&& hash_equals( $stored_hash, $current_hash ) ) {
return $result;
}
$expectations = is_array( $result ) ? (array) ( $result['expectations'] ?? array() ) : array();
if ( is_array( $result ) ) {
$expectations['source'] = (string) ( $result['source'] ?? 'topical_map' );
$expectations['provenance'] = (array) ( $result['provenance'] ?? array() );
}
return self::evaluate( $post_id, $expectations, $persist );
}
/**
* Fingerprint every deterministic input that can change a draft verdict
* without firing save_post (metadata and schema fix actions included).
*
* @param int $post_id Post ID.
* @return string
*/
private static function current_content_hash( $post_id ) {
$post = get_post( $post_id );
if ( ! $post ) {
return '';
}
$metadata = class_exists( __NAMESPACE__ . '\MetadataWriter' )
? MetadataWriter::read( $post_id )
: array();
$schema = class_exists( __NAMESPACE__ . '\Schema' )
? ContentGaps::schema_post_snapshot( $post )
: array();
$settings = get_option( 'asgm_settings', array() );
$author_id = (int) $post->post_author;
return hash(
'sha256',
wp_json_encode(
array(
'content' => (string) $post->post_content,
'post_title' => (string) $post->post_title,
'post_name' => (string) $post->post_name,
'post_excerpt' => (string) $post->post_excerpt,
'meta_title' => (string) ( $metadata['meta_title'] ?? '' ),
'description' => (string) ( $metadata['meta_description'] ?? '' ),
'post_author' => $author_id,
'author_name' => (string) get_the_author_meta( 'display_name', $author_id ),
'author_bio' => (string) get_the_author_meta( 'description', $author_id ),
'author_job' => (string) get_user_meta( $author_id, '_asgm_eeat_job_title', true ),
'thumbnail_id' => (int) get_post_thumbnail_id( $post_id ),
'schema' => (array) ( $schema['schemas'] ?? array() ),
'safe_mode' => ! empty( $settings['safe_mode'] ),
'schema_module' => ! empty( $settings['modules']['schema'] ),
'schema_ownership' => (array) get_option( 'asgm_schema_resolutions', array() ),
'schema_disabled' => (bool) get_post_meta( $post_id, '_asgm_disable_schema', true ),
'faq_disabled' => (bool) get_post_meta( $post_id, '_asgm_disable_faq', true ),
'howto_disabled' => (bool) get_post_meta( $post_id, '_asgm_disable_howto', true ),
)
)
);
}
/** Normalize and cap caller-controlled expectations. */
private static function normalize_expectations( $expectations ) {
$urls = array();
foreach ( (array) ( $expectations['internal_urls'] ?? array() ) as $url ) {
$url = esc_url_raw( (string) $url );
if ( $url ) {
$urls[] = $url;
}
}
return array(
'length' => 'long' === ( $expectations['length'] ?? '' ) ? 'long' : 'standard',
'format' => sanitize_key( (string) ( $expectations['format'] ?? 'guide' ) ),
'faq_min' => max( 2, min( 8, (int) ( $expectations['faq_min'] ?? 4 ) ) ),
'faq_max' => max( 2, min( 8, (int) ( $expectations['faq_max'] ?? 6 ) ) ),
'internal_urls' => array_values( array_unique( $urls ) ),
);
}
/** Keep provenance useful for audits without accepting arbitrary data. */
private static function normalize_provenance( $provenance ) {
return array(
'source' => sanitize_key( (string) ( $provenance['source'] ?? '' ) ),
'map_item_id' => absint( $provenance['map_item_id'] ?? 0 ),
'model' => sanitize_text_field( (string) ( $provenance['model'] ?? '' ) ),
'prompt_version' => sanitize_text_field( (string) ( $provenance['prompt_version'] ?? '' ) ),
'request_id' => sanitize_text_field( (string) ( $provenance['request_id'] ?? '' ) ),
'generated_at' => sanitize_text_field( (string) ( $provenance['generated_at'] ?? '' ) ),
'scorers' => array(
'page_health' => ContentGaps::SCORE_VERSION,
'rendered_page' => defined( __NAMESPACE__ . '\\RenderedPageEvaluator::SCORE_VERSION' ) ? RenderedPageEvaluator::SCORE_VERSION : null,
'answer_density' => defined( __NAMESPACE__ . '\\Answer_Density::SCORE_VERSION' ) ? Answer_Density::SCORE_VERSION : null,
'citability' => defined( __NAMESPACE__ . '\\CitabilityScore::SCORE_VERSION' ) ? CitabilityScore::SCORE_VERSION : null,
),
);
}
/** Find one check in a shared rendered-page report. */
private static function shared_check( $analysis, $id ) {
foreach ( (array) ( $analysis['checks'] ?? array() ) as $check ) {
if ( $id === (string) ( $check['id'] ?? '' ) ) {
return $check;
}
}
return null;
}
/** Add a format-specific deterministic check. */
private static function add_format_check( &$checks, $post, $format, $gaps, $faq_count ) {
$content = (string) $post->post_content;
$status = 'pass';
$message = __( 'The generated structure matches the planned format.', 'aeo-god-mode' );
if ( 'howto_steps' === $format && empty( $gaps['details']['has_howto_schema'] ) ) {
$status = 'fail';
$message = __( 'A how-to draft needs at least five extractable steps and automatic HowTo schema.', 'aeo-god-mode' );
} elseif ( 'comparison_table' === $format ) {
preg_match_all( '/ $format )
);
}
/** Classify the opening sentence beneath every substantive H2. */
private static function h2_opener_report( $content ) {
$issues = array();
$checked = 0;
if ( ! class_exists( __NAMESPACE__ . '\\Answer_Density' ) ) {
return array( 'checked' => 0, 'issues' => array() );
}
preg_match_all( '/]*>(.*?)<\/h2>/si', $content, $headings, PREG_SET_ORDER | PREG_OFFSET_CAPTURE );
foreach ( $headings as $heading ) {
$label = trim( wp_strip_all_tags( $heading[1][0] ) );
if ( preg_match( '/^(?:faq|frequently asked questions|conclusion|summary|final thoughts|key takeaways|resources|references|sources|further reading|related(?: posts| articles| reading)?)\s*:?$/i', $label ) ) {
continue;
}
$checked++;
$end = (int) $heading[0][1] + strlen( $heading[0][0] );
$body = Answer_Density::extract_after_heading( $content, $end, 2 );
$test = Answer_Density::classify_answer( $body );
if ( 'direct' !== ( $test['classification'] ?? '' ) ) {
$issues[] = array(
'heading' => $label,
'classification' => (string) ( $test['classification'] ?? 'no_answer' ),
'first_sentence' => (string) ( $test['first_sentence'] ?? '' ),
);
}
}
return array( 'checked' => $checked, 'issues' => $issues );
}
/** Classify the prose before the first H2 as the main-query answer. */
private static function opening_answer_report( $content ) {
$intro = $content;
if ( preg_match( '/ 'direct' === ( $test['classification'] ?? '' ),
'first_sentence' => (string) ( $test['first_sentence'] ?? '' ),
);
}
return array( 'direct' => '' !== trim( wp_strip_all_tags( $intro ) ), 'first_sentence' => '' );
}
/** Append a stable check object. */
private static function add_check( &$checks, $id, $label, $status, $message, $machine_fixable = false, $extra = array() ) {
$checks[] = array_merge(
array(
'id' => $id,
'label' => $label,
'status' => $status,
'message' => $message,
'machine_fixable' => (bool) $machine_fixable,
),
$extra
);
}
/** Unicode-safe text length with a PHP fallback. */
private static function text_length( $text ) {
return function_exists( 'mb_strlen' ) ? mb_strlen( (string) $text ) : strlen( (string) $text );
}
/** Count a planned URL while tolerating a trailing slash difference. */
private static function url_occurrences( $content, $url ) {
$content = html_entity_decode( (string) $content, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
$needle = untrailingslashit( (string) $url );
if ( '' === $needle ) {
return 0;
}
$pattern = '/href\s*=\s*(["\'])' . preg_quote( $needle, '/' ) . '\/?(?:[#?][^"\']*)?\1/i';
return preg_match_all( $pattern, $content, $matches );
}
}
|