v0.6.2: deterministic peptide auto-linker

New Peptide_Linker class scans generated HTML and wraps every mention
of a known peptide in a hyperlink to its /peptides/ database page.
Runs as post-processing in Publisher — no LLM involvement, 100% reliable.
Handles case-insensitive matching, hyphen/space variants, skips existing
<a> tags. Gracefully no-ops if PR Core is not active.

Removed prompt-based peptide linking from Content_Prompts (unreliable).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
peptiderepo
2026-04-20 02:34:13 +07:00
parent 76235ef4d7
commit 4791e54821
6 changed files with 179 additions and 46 deletions

View File

@@ -134,6 +134,7 @@ prautoblogger/
│ │ ├── class-semantic-dedup.php # Embedding-based cosine similarity dedup with keyword fallback
│ │ ├── class-content-generator.php# Writer agent — manages the generation pipeline
│ │ ├── class-content-prompts.php # Builds all LLM prompts for content generation (system, stage, linking rules)
│ │ ├── class-peptide-linker.php # Deterministic post-processor: hyperlinks peptide mentions to /peptides/ pages
│ │ ├── class-chief-editor.php # Editor agent — LLM-powered editorial review
│ │ ├── class-publisher.php # Creates WordPress posts from approved content
│ │ ├── class-post-assembler.php # Post-creation helpers: taxonomy, log linking, images, sanitization

View File

@@ -5,6 +5,23 @@ All notable changes to PRAutoBlogger will be documented in this file.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project uses [Semantic Versioning](https://semver.org/).
## [0.6.2] — 2026-04-20
### Added
- **Deterministic peptide auto-linker.** New `Peptide_Linker` class scans
generated HTML and wraps every mention of a known peptide in a hyperlink
to its `/peptides/{slug}/` database page. Runs as a post-processing step
in Publisher before `wp_insert_post` — no LLM involvement, 100% reliable.
Handles case-insensitive matching, hyphen/space variants (e.g., "BPC-157"
and "BPC 157"), and skips text already inside `<a>` tags. Gracefully
no-ops if PR Core is not active.
### Changed
- **Removed prompt-based peptide linking.** The system prompt no longer asks
the LLM to link peptides (unreliable). Peptide links are now injected
deterministically by `Peptide_Linker` after content generation. The prompt
still provides article links and the "never fabricate URLs" rule.
## [0.6.1] — 2026-04-20
### Added

View File

@@ -167,13 +167,10 @@ class PRAutoBlogger_Content_Prompts {
$rules = "--- LINKING RULES ---\n";
$rules .= "NEVER fabricate or invent URLs. You do NOT have access to external sources.\n";
$rules .= "Only use the internal links listed below when linking within the article.\n";
$rules .= "If no listed page is relevant to a section, do not insert a link.\n";
$rules .= "IMPORTANT: Whenever ANY peptide name from the peptide database list below\n";
$rules .= "is mentioned in the article, ALWAYS link the FIRST mention to its database page.\n\n";
$rules .= "If no listed article is relevant to a section, do not insert a link.\n";
$rules .= "Do NOT add links for peptide names — those are injected automatically after generation.\n\n";
$rules .= self::build_article_links();
$rules .= "\n";
$rules .= self::build_peptide_links();
$rules .= "--- END LINKING RULES ---";
@@ -211,42 +208,4 @@ class PRAutoBlogger_Content_Prompts {
return implode( "\n", $lines ) . "\n";
}
/**
* Build a reference list of all published peptide database pages.
*
* Every peptide mentioned in an article must link to its database page
* on first mention. This gives the model the real URLs to use.
*
* @return string Formatted peptide link list, or empty if CPT unavailable.
*/
private static function build_peptide_links(): string {
// PR Core may not be active — guard against missing post type.
if ( ! post_type_exists( 'pr_peptide' ) ) {
return '';
}
$peptides = get_posts( [
'post_type' => 'pr_peptide',
'post_status' => 'publish',
'posts_per_page' => 200,
'orderby' => 'title',
'order' => 'ASC',
'fields' => 'ids',
] );
if ( empty( $peptides ) ) {
return '';
}
$lines = [ "Peptide database pages — ALWAYS link the first mention of each peptide:\n" ];
foreach ( $peptides as $peptide_id ) {
$title = get_the_title( $peptide_id );
$url = get_permalink( $peptide_id );
if ( $title && $url ) {
$lines[] = "- {$title}: {$url}";
}
}
return implode( "\n", $lines ) . "\n";
}
}

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
/**
* Deterministic post-processor that hyperlinks peptide mentions in HTML content.
*
* Scans generated article HTML for any peptide name that exists in the PR Core
* database and wraps every mention in an <a> tag pointing to its /peptides/ page.
* Skips text that is already inside an existing <a> tag to avoid nested links.
*
* Triggered by: PRAutoBlogger_Publisher::create_post() (before wp_insert_post).
* Dependencies: PR Core plugin (pr_peptide CPT). Gracefully no-ops if PR Core is inactive.
*
* @see core/class-publisher.php — Calls inject_links() on post_content.
* @see peptide-repo-core/includes/cpt/class-pr-core-peptide-cpt.php — Peptide CPT.
*/
class PRAutoBlogger_Peptide_Linker {
/**
* Inject peptide hyperlinks into HTML content.
*
* Every occurrence of a known peptide name is wrapped in a link to its
* database page, except when the text is already inside an <a> tag.
*
* @param string $html Article HTML content.
* @return string HTML with peptide names hyperlinked.
*/
public static function inject_links( string $html ): string {
if ( ! post_type_exists( 'pr_peptide' ) ) {
return $html;
}
$link_map = self::build_link_map();
if ( empty( $link_map ) ) {
return $html;
}
// Build a single regex alternation of all peptide names, longest first
// so "BPC-157" matches before a hypothetical "BPC" would.
$names = array_keys( $link_map );
usort( $names, static function ( string $a, string $b ): int {
return strlen( $b ) - strlen( $a );
} );
$escaped = array_map( static function ( string $name ): string {
return preg_quote( $name, '/' );
}, $names );
// Word-boundary match, case-insensitive.
$pattern = '/\b(' . implode( '|', $escaped ) . ')\b/iu';
// Split HTML into segments: inside <a>…</a> tags vs. outside.
// Only replace in segments that are NOT inside an anchor tag.
return self::replace_outside_links( $html, $pattern, $link_map );
}
/**
* Build a map of peptide display names → permalink URLs.
*
* Includes the canonical title and common aliases (e.g., "BPC-157" and
* "BPC 157") so both hyphenated and space-separated forms get linked.
*
* @return array<string, string> Name → URL pairs, case-preserved keys.
*/
private static function build_link_map(): array {
$peptides = get_posts( [
'post_type' => 'pr_peptide',
'post_status' => 'publish',
'posts_per_page' => 200,
'orderby' => 'title',
'order' => 'ASC',
'fields' => 'ids',
] );
$map = [];
foreach ( $peptides as $peptide_id ) {
$title = get_the_title( $peptide_id );
$url = get_permalink( $peptide_id );
if ( ! $title || ! $url ) {
continue;
}
// Canonical name (e.g., "BPC-157").
$map[ $title ] = $url;
// If the name contains hyphens, also match the space-separated
// variant (e.g., "BPC 157" → same URL as "BPC-157").
if ( strpos( $title, '-' ) !== false ) {
$space_variant = str_replace( '-', ' ', $title );
$map[ $space_variant ] = $url;
}
}
return $map;
}
/**
* Replace peptide names with links, but only outside existing <a> tags.
*
* Splits HTML into chunks by <a …>…</a> boundaries. Replacement only
* runs on chunks that are outside anchors, preventing nested <a> tags.
*
* @param string $html Full HTML content.
* @param string $pattern Regex pattern matching peptide names.
* @param array<string,string> $link_map Name → URL map (case-preserved keys).
* @return string HTML with replacements applied.
*/
private static function replace_outside_links( string $html, string $pattern, array $link_map ): string {
// Split on anchor tags, keeping the delimiters.
// This regex captures full <a …>…</a> blocks as array elements.
$parts = preg_split( '/(<a\s[^>]*>.*?<\/a>)/is', $html, -1, PREG_SPLIT_DELIM_CAPTURE );
if ( false === $parts ) {
return $html;
}
// Build a lowercase → URL lookup for case-insensitive matching.
$lower_map = [];
foreach ( $link_map as $name => $url ) {
$lower_map[ mb_strtolower( $name ) ] = $url;
}
$result = '';
foreach ( $parts as $part ) {
// If this chunk is an anchor tag, pass it through untouched.
if ( preg_match( '/^<a\s/i', $part ) ) {
$result .= $part;
continue;
}
// Replace peptide names with hyperlinks.
$result .= preg_replace_callback(
$pattern,
static function ( array $matches ) use ( $lower_map ): string {
$matched_text = $matches[0];
$key = mb_strtolower( $matched_text );
$url = $lower_map[ $key ] ?? '';
if ( '' === $url ) {
return $matched_text;
}
return '<a href="' . esc_url( $url ) . '">' . esc_html( $matched_text ) . '</a>';
},
$part
);
}
return $result;
}
}

View File

@@ -12,6 +12,7 @@ declare(strict_types=1);
* WordPress wp_insert_post(), post meta API.
*
* @see core/class-post-assembler.php — Post-creation helpers (taxonomy, images, logs).
* @see core/class-peptide-linker.php — Injects peptide database hyperlinks into content.
* @see core/class-chief-editor.php — Produces the editorial review we consume.
* @see core/class-content-generator.php — Produces the content we publish.
* @see ARCHITECTURE.md — Data flow step 6.
@@ -74,9 +75,15 @@ class PRAutoBlogger_Publisher {
string $post_status,
?string $run_id = null
): int {
// Clean LLM artifacts, then inject peptide hyperlinks deterministically
// before the content enters WordPress. Peptide linker no-ops gracefully
// if PR Core is not active.
$clean_content = PRAutoBlogger_Post_Assembler::sanitize_llm_content( $content );
$linked_content = PRAutoBlogger_Peptide_Linker::inject_links( $clean_content );
$post_data = [
'post_title' => sanitize_text_field( $idea->get_suggested_title() ),
'post_content' => wp_kses_post( PRAutoBlogger_Post_Assembler::sanitize_llm_content( $content ) ),
'post_content' => wp_kses_post( $linked_content ),
'post_status' => $post_status,
'post_type' => 'post',
'post_author' => PRAutoBlogger_Post_Assembler::get_default_author_id(),

View File

@@ -8,7 +8,7 @@ declare(strict_types=1);
* Plugin Name: PRAutoBlogger
* Plugin URI: https://peptiderepo.com/prautoblogger
* Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics.
* Version: 0.6.1
* Version: 0.6.2
* Requires at least: 6.0
* Requires PHP: 7.4
* Author: PeptideRepo
@@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) {
| and limits without magic strings.
*/
define( 'PRAUTOBLOGGER_VERSION', '0.6.1' );
define( 'PRAUTOBLOGGER_VERSION', '0.6.2' );
define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' );
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );