) and URLs quoted inside a sentence don't
// match (core doesn't embed those either), so prose is untouched.
$html = preg_replace( '#^\s*https?://[^\s<>"]+\s*$#im', ' ', $html );
$html = preg_replace( '#]*)?>\s*https?://[^\s<>"]+\s*
#i', ' ', $html );
// 1. Strip block comments (Gutenberg leaves them in post_content).
$html = preg_replace( '##s', '', $html );
// 2. Remove noisy elements wholesale. Filterable so themes/plugins
// can add custom ones (e.g. an "advertisement" shortcode wrapper).
$noisy = apply_filters(
'ayudawp_aiss_summary_noisy_tags',
array( 'table', 'pre', 'code', 'figcaption', 'figure', 'form', 'button', 'nav', 'script', 'style', 'iframe', 'svg', 'audio', 'video' )
);
if ( ! empty( $noisy ) ) {
$pattern = '#<(' . implode( '|', array_map( 'preg_quote', $noisy ) ) . ')\b[^>]*>.*?\1>#is';
$html = preg_replace( $pattern, ' ', $html );
}
// 3. Replace block-level tag boundaries (open AND close) with a
// sentence separator so prose from different blocks doesn't get
// concatenated by wp_strip_all_tags().
$boundaries = 'p|h[1-6]|li|div|td|th|tr|br|hr|blockquote|aside|section|article|header|footer|dd|dt';
$html = preg_replace( '#?(?:' . $boundaries . ')(?:\s[^>]*)?/?>#i', '. ', $html );
// 4. Strip the rest.
$text = wp_strip_all_tags( $html );
// 5. Whitespace normalization + collapse repeated punctuation runs.
$text = preg_replace( '/\s+/u', ' ', $text );
$text = preg_replace( '/(\s*\.\s*){2,}/', '. ', $text ); // ". . . " → ". "
$text = preg_replace( '/\s+\./', '.', $text ); // " ." → "."
$text = preg_replace( '/\.{2,}/', '.', $text ); // ".." → "."
return trim( $text );
}
/**
* Words of real text in a post, counted the way its summary would see them (v2.6.0)
*
* Counts on the text ayudawp_aiss_clean_html_for_summary() leaves, so images,
* embeds, tables and shortcodes do not count. Counts like core's
* wp_trim_words(): by spaces, or by characters in the locales core counts that
* way (Chinese, Japanese...), where a post would otherwise never reach the
* minimum. Tokens with no letter or digit (a stray dash, the full stops the
* cleaner inserts between blocks) are not words.
*
* @since 2.6.0
* @param string $content Raw post content.
* @return int Number of words (or characters, in character-count locales).
*/
function ayudawp_aiss_count_summary_words( $content ) {
$text = ayudawp_aiss_clean_html_for_summary( (string) $content );
if ( '' === $text ) {
return 0;
}
// wp_get_word_count_type() exists since WordPress 6.2; the plugin still
// runs on 6.1, where counting by spaces is what core did too.
$type = function_exists( 'wp_get_word_count_type' ) ? wp_get_word_count_type() : 'words';
if ( 0 === strpos( $type, 'characters' ) ) {
return (int) preg_match_all( '/[\p{L}\p{N}]/u', $text );
}
$tokens = preg_split( '/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY );
if ( ! is_array( $tokens ) ) {
return 0;
}
return count( preg_grep( '/[\p{L}\p{N}]/u', $tokens ) );
}
/**
* Minimum length of a post for it to get an automatic summary (v2.6.0)
*
* Below it the AI tends to answer that there is nothing to summarize, and
* that answer used to be stored and shown as the summary.
*
* @since 2.6.0
* @return int Minimum number of words, 0 for no minimum.
*/
function ayudawp_aiss_get_summary_min_words() {
/**
* Filter the minimum number of words a post needs for a summary.
*
* Counted on the text the summary is built from (no images, embeds,
* tables or shortcodes). Return 0 to summarize posts of any length.
*
* @since 2.6.0
* @param int $min_words Minimum number of words. Default 50.
*/
return max( 0, (int) apply_filters( 'ayudawp_aiss_summary_min_words', 50 ) );
}
/**
* Locale a post is written in, when a multilingual plugin knows it (v2.7.1)
*
* Polylang and WPML store the language of every post, so on a multilingual
* site the answer is exact. Without them there is no certain answer: plenty
* of sites write in a language other than their settings (many never change
* the default en_US), so the site locale is only a hint, given by
* ayudawp_aiss_summary_language_instruction().
*
* @since 2.7.1
* @param int $post_id Post ID.
* @return string Locale such as 'es_ES', or '' when it is not known for certain.
*/
function ayudawp_aiss_get_post_locale( $post_id ) {
$post_id = (int) $post_id;
$locale = '';
if ( $post_id > 0 ) {
try {
if ( function_exists( 'pll_get_post_language' ) ) {
// Polylang: the locale string, or false when the post has none.
$locale = pll_get_post_language( $post_id, 'locale' );
} else {
// WPML (registered in sitepress.class.php): an array with
// 'locale', or a WP_Error. Null when no plugin answers.
$details = apply_filters( 'wpml_post_language_details', null, $post_id ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Consuming WPML's own documented filter, not a hook defined by this plugin.
if ( is_array( $details ) && isset( $details['locale'] ) ) {
$locale = $details['locale'];
}
}
} catch ( \Throwable $e ) {
// A multilingual plugin half set up (no languages yet) must never
// stop the summary: it just goes without a certain language.
unset( $e );
$locale = '';
}
}
/**
* Filters the language the AI summary of a post is written in.
*
* Comes from Polylang or WPML when one of them is active, and is empty
* otherwise. Return a locale ('es_ES', 'ca', 'pt_BR') to fix the language
* of the summary, for example on a site whose settings say en_US but that
* writes in another language. Return '' to let the AI follow the language
* of the post.
*
* @since 2.7.1
* @param string $locale Locale of the post, or ''.
* @param int $post_id Post ID (0 when unknown).
*/
$locale = apply_filters( 'ayudawp_aiss_summary_locale', is_string( $locale ) ? $locale : '', $post_id );
// It goes into the instruction sent to the AI: only something shaped like a locale.
if ( ! is_string( $locale ) || ! preg_match( '/^[a-z]{2,3}(?:[_-][A-Za-z0-9]{2,8}){0,3}$/', $locale ) ) {
return '';
}
return $locale;
}
/**
* How to name a locale in the instruction sent to the AI (v2.7.1)
*
* "Spanish (Spain), locale es_ES" when PHP has the intl extension, which
* knows every language name in English; just "locale es_ES" otherwise, which
* the models understand too. WordPress variants (de_DE_formal, pt_PT_ao90)
* are named by their language and region.
*
* @since 2.7.1
* @param string $locale Locale.
* @return string Description of the locale, in English.
*/
function ayudawp_aiss_describe_locale( $locale ) {
$locale = (string) $locale;
$code = (string) preg_replace( '/^([a-z]{2,3}(?:_[A-Z]{2})?).*$/', '$1', $locale );
$name = class_exists( 'Locale' ) ? (string) Locale::getDisplayName( $code, 'en' ) : '';
if ( '' === $name || $name === $code ) {
return sprintf( 'locale %s', $locale );
}
return sprintf( '%1$s, locale %2$s', $name, $locale );
}
/**
* The sentence of the AI instruction that sets the language of the summary (v2.7.1)
*
* Until 2.7.0 the instruction only said "Match the language of the input",
* in English like the rest of it, and now and then a model answered in
* English for a post written in another language. Now:
*
* - When the language of the post is known (Polylang, WPML or the
* ayudawp_aiss_summary_locale filter), it is named and required.
* - Otherwise the AI follows the language of the post's own sentences, and a
* site language other than English is named as the usual answer. An
* English site language is not named: en_US is the default that many sites
* writing in another language never change, so naming it would push their
* summaries into English.
*
* Both say that quotes, names and technical terms in another language, and
* the language of the instruction itself, do not count.
*
* @since 2.7.1
* @param int $post_id Post ID, or 0 when unknown.
* @return string Instruction sentence, in English like the rest of the prompt.
*/
function ayudawp_aiss_summary_language_instruction( $post_id = 0 ) {
$rule = 'Quotes, names or technical terms in another language inside the post do not change that, and neither does the language of these instructions.';
$locale = ayudawp_aiss_get_post_locale( $post_id );
if ( '' !== $locale ) {
return sprintf( 'Write the summary in %1$s: it is the language this post is written in. %2$s', ayudawp_aiss_describe_locale( $locale ), $rule );
}
$base = 'Write the summary in the language the post itself is written in, judging by its own sentences. ' . $rule;
$site = (string) get_locale();
if ( '' === $site || preg_match( '/^en(?:_|$)/', $site ) ) {
return $base;
}
return sprintf( '%1$s This site publishes in %2$s, so it is almost always that one.', $base, ayudawp_aiss_describe_locale( $site ) );
}
/**
* Split a block of text into sentences (v2.1.0)
*
* Single source of truth for sentence segmentation, shared by the extractive
* summarizer and the frontend renderer (which prints one per sentence for
* readability). Splits on sentence-final punctuation followed by whitespace and
* an uppercase/opening character, so decimals and most abbreviations inside a
* sentence are not split.
*
* @since 2.1.0
* @param string $text Plain text (already stripped of HTML).
* @return array Trimmed, non-empty sentences in original order.
*/
function ayudawp_aiss_split_sentences( $text ) {
$text = trim( (string) $text );
if ( '' === $text ) {
return array();
}
$parts = preg_split( '/(?<=[.!?])\s+(?=[A-ZÁÉÍÓÚÑ¿¡"\'])/u', $text );
if ( ! is_array( $parts ) ) {
return array( $text );
}
$parts = array_map( 'trim', $parts );
return array_values( array_filter( $parts, 'strlen' ) );
}
/**
* Generate an extractive summary from post content (v2.0.0)
*
* Picks the top N sentences scored by token-frequency (excluding stopwords),
* with a bonus for title words and a lead-position bonus for the first two
* sentences. Returns sentences in their original order so the summary reads
* naturally.
*
* Used as a fallback when WP AI Client is not available, or when the call
* to wp_ai_client_prompt() fails.
*
* @since 2.0.0
* @param string $content Raw post content (HTML accepted, will be stripped).
* @param string $title Post title — its words get a 2x weight bonus.
* @param int $n Target sentence count (clamped to 1..5).
* @return string Summary text, or '' if the content is too short to summarize.
*/
function ayudawp_aiss_extractive_summary( $content, $title, $n = 3 ) {
$text = ayudawp_aiss_clean_html_for_summary( (string) $content );
if ( '' === $text ) {
return '';
}
$sentences = ayudawp_aiss_split_sentences( $text );
if ( count( $sentences ) < 3 ) {
return '';
}
$stopwords = apply_filters( 'ayudawp_aiss_extractive_stopwords', ayudawp_aiss_get_stopwords() );
/**
* Tunable weights. Filterable so themes/plugins can adjust them without
* forking the algorithm.
*
* - lead_bonus: multiplier applied to the first two sentences. Lowered
* from the previous 1.3 because it made the first sentence always
* "win", giving the impression the summarizer hadn't done anything.
* - title_weight: how much extra each title word contributes to its
* frequency score. Raised from 2x to 3x so title-relevant sentences
* stand out more reliably.
* - min_words: discard sentences shorter than this (likely headings
* leaked from the markup, navigation labels, etc).
* - similarity_threshold: Jaccard similarity above which a candidate
* sentence is considered redundant with one already selected.
*
* @since 2.0.0
*/
$weights = apply_filters( 'ayudawp_aiss_extractive_weights', array(
'lead_bonus' => 1.1,
'title_weight' => 3,
'min_words' => 5,
'similarity_threshold' => 0.5,
) );
// Tokenize once per sentence and reuse — Jaccard needs token sets too.
$sentence_tokens = array();
foreach ( $sentences as $i => $sentence ) {
$tokens = preg_split( '/\W+/u', mb_strtolower( $sentence, 'UTF-8' ) );
$tokens = is_array( $tokens ) ? array_values( array_filter( $tokens, 'strlen' ) ) : array();
$sentence_tokens[ $i ] = $tokens;
}
// Build word-frequency table over content-bearing tokens.
$freq = array();
foreach ( $sentence_tokens as $tokens ) {
foreach ( $tokens as $token ) {
if ( isset( $stopwords[ $token ] ) || mb_strlen( $token, 'UTF-8' ) < 3 ) {
continue;
}
$freq[ $token ] = isset( $freq[ $token ] ) ? $freq[ $token ] + 1 : 1;
}
}
// Title words boost their frequency weight.
$title_tokens = preg_split( '/\W+/u', mb_strtolower( (string) $title, 'UTF-8' ) );
if ( is_array( $title_tokens ) ) {
foreach ( $title_tokens as $token ) {
if ( isset( $freq[ $token ] ) ) {
$freq[ $token ] *= (float) $weights['title_weight'];
}
}
}
// Score sentences and remember which are eligible (have enough words).
$scored = array();
$eligible = array();
$jaccard_set = array(); // Per-sentence content-token set used for similarity comparison.
foreach ( $sentence_tokens as $i => $tokens ) {
if ( count( $tokens ) < (int) $weights['min_words'] ) {
continue;
}
$score = 0.0;
$set = array();
foreach ( $tokens as $token ) {
if ( isset( $stopwords[ $token ] ) || mb_strlen( $token, 'UTF-8' ) < 3 ) {
continue;
}
if ( isset( $freq[ $token ] ) ) {
$score += $freq[ $token ];
}
$set[ $token ] = 1;
}
if ( $i < 2 ) {
$score *= (float) $weights['lead_bonus'];
}
$scored[ $i ] = $score;
$eligible[ $i ] = true;
$jaccard_set[ $i ] = $set;
}
if ( empty( $scored ) ) {
return '';
}
arsort( $scored );
$n = max( 1, min( 5, (int) $n ) );
// Greedy MMR-style selection: walk candidates by score descending and
// skip any that are too similar (Jaccard >= threshold) to a sentence
// already picked. Falls back to plain top-N if Jaccard filtering would
// leave us with fewer than N selections.
$threshold = (float) $weights['similarity_threshold'];
$selected = array();
foreach ( array_keys( $scored ) as $idx ) {
if ( count( $selected ) >= $n ) {
break;
}
$is_redundant = false;
foreach ( $selected as $picked ) {
$intersection = count( array_intersect_key( $jaccard_set[ $idx ], $jaccard_set[ $picked ] ) );
$union = count( $jaccard_set[ $idx ] + $jaccard_set[ $picked ] );
$similarity = $union > 0 ? ( $intersection / $union ) : 0;
if ( $similarity >= $threshold ) {
$is_redundant = true;
break;
}
}
if ( ! $is_redundant ) {
$selected[] = $idx;
}
}
// Backfill if the similarity filter was too aggressive.
if ( count( $selected ) < $n ) {
foreach ( array_keys( $scored ) as $idx ) {
if ( count( $selected ) >= $n ) {
break;
}
if ( ! in_array( $idx, $selected, true ) ) {
$selected[] = $idx;
}
}
}
// Restore original sentence order in the output.
sort( $selected );
$pieces = array();
foreach ( $selected as $index ) {
if ( isset( $sentences[ $index ] ) ) {
$pieces[] = $sentences[ $index ];
}
}
return trim( implode( ' ', $pieces ) );
}
/**
* Get the stopwords lookup table for the extractive summarizer (v2.0.0)
*
* Returns an associative array (`'the' => 1`) so callers can use
* `isset( $stopwords[ $token ] )` for O(1) membership checks.
*
* Covers English and Spanish — extend via the `ayudawp_aiss_extractive_stopwords`
* filter to support additional languages.
*
* @since 2.0.0
* @return array Associative array of stopwords.
*/
function ayudawp_aiss_get_stopwords() {
$words = array(
// English.
'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can', 'her', 'was', 'one', 'our', 'out',
'day', 'get', 'has', 'him', 'his', 'how', 'man', 'new', 'now', 'old', 'see', 'two', 'way', 'who',
'boy', 'did', 'its', 'let', 'put', 'say', 'she', 'too', 'use', 'with', 'this', 'that', 'from',
'they', 'have', 'were', 'been', 'will', 'your', 'what', 'when', 'make', 'like', 'time', 'just',
'know', 'take', 'into', 'some', 'them', 'than', 'then', 'look', 'only', 'come', 'over', 'also',
'back', 'after', 'work', 'first', 'well', 'even', 'want', 'because', 'these', 'give', 'most',
'about', 'which', 'their', 'would', 'there', 'could', 'other', 'should', 'where', 'while',
'being', 'does', 'each', 'such', 'very', 'much', 'many', 'more', 'made', 'said', 'here',
// Spanish.
'los', 'las', 'una', 'uno', 'del', 'que', 'por', 'con', 'para', 'pero', 'sus', 'sin', 'son',
'mas', 'muy', 'ese', 'esa', 'eso', 'este', 'esta', 'esto', 'hay', 'fue', 'ser', 'han', 'has',
'como', 'cuando', 'donde', 'porque', 'sobre', 'entre', 'desde', 'hasta', 'también', 'tambien',
'cada', 'todo', 'todos', 'todas', 'algo', 'algun', 'alguna', 'mucho', 'mucha', 'muchos', 'muchas',
'otro', 'otra', 'otros', 'otras', 'mismo', 'misma', 'tanto', 'tanta', 'poco', 'poca', 'nada',
'ante', 'bajo', 'cabe', 'contra', 'segun', 'según', 'durante', 'mediante', 'hacia',
'aqui', 'aquí', 'alli', 'allí', 'ahi', 'ahí', 'asi', 'así', 'aun', 'aún', 'ya', 'si', 'sí',
'siempre', 'nunca', 'jamás', 'jamas', 'antes', 'despues', 'después', 'luego', 'entonces',
'estaba', 'estaban', 'estamos', 'estar', 'estado', 'estos', 'estas', 'eran', 'era', 'eres',
'haber', 'había', 'habia', 'habían', 'habian', 'hace', 'hacer', 'hecho', 'puede', 'pueden',
'porqué', 'porque', 'cual', 'cuál', 'quien', 'quién', 'cuanto', 'cuánto',
);
return array_fill_keys( $words, 1 );
}