>}|\WP_Error */ public static function plan(int $postId) { $post = get_post($postId); if (!$post instanceof \WP_Post || $post->post_status !== 'publish') { return new \WP_Error('aipcf_links_post', __('Pick a published post.', 'aipcf-ai-provider-for-cloudflare')); } LinkIndex::install(); if (LinkIndex::termsOf($postId) === []) { LinkIndex::indexPost($postId); } $related = self::related($postId); if (is_wp_error($related)) { return $related; } $min = (float) Settings::get('related_min_score'); $source = self::text($post); $terms = LinkIndex::termsOf($postId); $total = max(1, LinkIndex::postCount()); $ceiling = max(3, (int) floor(self::TOO_COMMON * $total)); $distinct = max(3, (int) floor(self::DISTINCT * $total)); $usedSentences = []; $used = []; $out = []; $ownTitle = array_flip(LinkIndex::words(get_the_title($post))); foreach ($related as $id => $score) { $other = get_post((int) $id); // Never quote a post that is protected, unpublished or no longer selected, even if // an older related list still names it. if (!Related::showable($other) || $score < $min) { continue; } if (LinkIndex::termsOf((int) $id) === []) { LinkIndex::indexPost((int) $id); } $shared = array_values(array_intersect($terms, LinkIndex::termsOf((int) $id))); $df = LinkIndex::frequency($shared); $shared = array_values(array_filter($shared, static fn(string $w): bool => ($df[$w] ?? PHP_INT_MAX) <= $ceiling)); // Best anchors first: distinctive words from the linked post's title (they name what it // is about), then the rarest on the site, then the longest. A title word that many posts // use ("WordPress" on a WordPress blog) says nothing, so it only counts by its rarity. $titled = array_flip(LinkIndex::words(get_the_title($other))); $named = static fn(string $w): int => isset($titled[$w]) && ($df[$w] ?? PHP_INT_MAX) <= $distinct ? 0 : 1; usort($shared, static fn(string $a, string $b): int => [$named($a), $df[$a] ?? 0, -mb_strlen($a)] <=> [$named($b), $df[$b] ?? 0, -mb_strlen($b)]); $otherText = self::text($other); $outbound = null; foreach ($shared as $w) { if (isset($used[$w])) { continue; } // One link per sentence: the same sentence is not offered for two links. $sentence = self::sentenceWith($source, $w, $usedSentences); if ($sentence !== '') { $outbound = ['anchor' => self::asWritten($sentence, $w), 'sentence' => $sentence]; $used[$w] = true; $usedSentences[$sentence] = true; break; } } // A link back names this post: this post's title words first. $back = $shared; usort($back, static fn(string $a, string $b): int => [isset($ownTitle[$a]) ? 0 : 1, $df[$a] ?? 0, -mb_strlen($a)] <=> [isset($ownTitle[$b]) ? 0 : 1, $df[$b] ?? 0, -mb_strlen($b)]); $inbound = null; foreach ($back as $w) { $sentence = self::sentenceWith($otherText, $w); if ($sentence !== '') { $inbound = ['anchor' => self::asWritten($sentence, $w), 'sentence' => $sentence]; break; } } $out[] = [ 'id' => (int) $id, 'title' => html_entity_decode(get_the_title($other), ENT_QUOTES, 'UTF-8'), 'url' => (string) get_permalink($other), 'score' => round($score, 3), 'date' => get_the_date('Y-m-d', $other), 'shared' => array_slice($shared, 0, 5), 'linked' => LinkIndex::linksTo($postId, (int) $id), 'linked_back' => LinkIndex::linksTo((int) $id, $postId), 'outbound' => $outbound, 'inbound' => $inbound, ]; } return [ 'post' => ['id' => $postId, 'title' => html_entity_decode(get_the_title($post), ENT_QUOTES, 'UTF-8'), 'url' => (string) get_permalink($post)], 'candidates' => $out, ]; } /** * The post's closest posts: its related list when there is one, otherwise worked out now * from its stored vector (one store query, no neurons). * * @return array|\WP_Error */ private static function related(int $postId) { $stored = Related::stored($postId); if ($stored !== null && $stored !== []) { return array_slice($stored, 0, self::CANDIDATES, true); } $store = StoreFactory::reading(); // the old store during a move or rebuild if (is_wp_error($store)) { return $store; } $vid = Ingest::vectorId('post', $postId, 0); $vectors = $store->vectors([$vid]); if (is_wp_error($vectors)) { return $vectors; } if (!isset($vectors[$vid])) { return new \WP_Error('aipcf_links_post', __('This post is not in the vector store yet. Posts are embedded in the background, so try again in a few minutes.', 'aipcf-ai-provider-for-cloudflare')); } $found = Related::candidates($postId, $vectors[$vid], $store, 'links'); return is_wp_error($found) ? $found : array_slice($found, 0, self::CANDIDATES, true); } /** * The post's own text, the only place a link can go. Not the extracted document, which may * also hold custom fields and SEO text the site owner chose to embed. */ private static function text(\WP_Post $post): string { return Extractor::contentToText((string) $post->post_content); } /** * The first sentence that contains the word as a whole word, or ''. Lists (many commas) * and very long sentences are skipped: a link reads badly there. */ /** * @param array $skip Sentences already used. */ public static function sentenceWith(string $text, string $word, array $skip = []): string { $pattern = '/(? self::MAX_SENTENCE || substr_count($s, ',') > 6 || preg_match('/^(#|[-*•]|\d+[.)]\s)/u', $s) || count(preg_split('/\s+/u', $s) ?: []) < self::MIN_WORDS ) { continue; } if (preg_match($pattern, $s)) { return $s; } } return ''; } /** * The word as the sentence writes it ("WordCamp", not "wordcamp"). */ private static function asWritten(string $sentence, string $word): string { return preg_match('/(?