feat: add cost/token tracking, monthly budget system, and dry-run mode

- Create PSA_Cost_Tracker class with database table for API call logging
- Log prompt/completion tokens and estimated cost for every OpenRouter API call
- Implement monthly budget system with hard stop (budget of 0 = unlimited)
- Add budget checks in validate_peptide_name() and generate_peptide_content()
- Add dry_run() method to estimate costs before making API calls
- Display monthly usage summary and recent API logs in admin settings
- Add monthly_budget setting to admin interface with number input field
- Update uninstall.php to drop psa_api_logs table on plugin removal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
peptiderepo
2026-04-11 18:59:19 +07:00
parent 4770f0a861
commit 4a5f2711a9
18 changed files with 2642 additions and 13 deletions

69
.github/workflows/ci.yml vendored Executable file
View File

@@ -0,0 +1,69 @@
name: CI — Lint & Test
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
workflow_call:
permissions:
contents: read
jobs:
# ── PHP Syntax Check ─────────────────────────────────────────────
lint-php:
name: PHP Lint (${{ matrix.php }})
runs-on: ubuntu-latest
strategy:
matrix:
php: ['7.4', '8.1', '8.3']
steps:
- uses: actions/checkout@v4
- name: Set up PHP ${{ matrix.php }}
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
- name: Syntax check all PHP files
run: find . -name '*.php' -not -path './vendor/*' -print0 | xargs -0 -n1 php -l
# ── WordPress Coding Standards (PHP CodeSniffer) ──────────────────
lint-phpcs:
name: WordPress Coding Standards
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.1'
- name: Install Composer dependencies
run: |
composer install --no-interaction --prefer-dist --no-progress
- name: Run PHPCS with WordPress-Core standard
run: |
composer run lint
# ── JavaScript Lint ──────────────────────────────────────────────
lint-js:
name: JavaScript Linting
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Check JavaScript syntax
run: |
for f in $(find assets/js -name '*.js' -type f); do
node --check "$f" || exit 1
done
echo "All JavaScript files passed syntax check"

254
.github/workflows/deploy.yml vendored Executable file
View File

@@ -0,0 +1,254 @@
name: Deploy to WordPress
on:
push:
branches: [ main ]
permissions:
contents: write
# Cancel previous deploys if a new push arrives while one is running.
concurrency:
group: deploy-production
cancel-in-progress: true
jobs:
# -- Run tests first --
test:
name: Pre-deploy tests
uses: ./.github/workflows/ci.yml
# -- Validate deployment readiness --
validate:
name: Validate deployment
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
with:
ref: main
- name: Verify required files exist
run: |
echo "Checking for required plugin files..."
required_files=(
"peptide-search-ai.php"
"includes/"
"assets/"
"readme.txt"
)
for file in "${required_files[@]}"; do
if [ ! -e "$file" ]; then
echo "❌ Missing required file: $file"
exit 1
fi
echo "✓ Found: $file"
done
- name: Verify PSA_VERSION constant
run: |
echo "Checking PSA_VERSION constant..."
if grep -q "define( 'PSA_VERSION'" peptide-search-ai.php; then
version=$(grep "define( 'PSA_VERSION'" peptide-search-ai.php | sed "s/.*'\([^']*\)'.*/\1/")
echo "✓ PSA_VERSION found: $version"
else
echo "❌ PSA_VERSION constant not found in peptide-search-ai.php"
exit 1
fi
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.1'
- name: Validate PHP syntax on all files
run: |
echo "Validating PHP syntax..."
find . -name '*.php' -not -path './vendor/*' -not -path './node_modules/*' -print0 | \
xargs -0 -n1 php -l > /dev/null && echo "✓ All PHP files are valid"
- name: Check for sensitive files
run: |
echo "Scanning for sensitive files..."
found_sensitive=0
sensitive_patterns=(
"\.env"
"\.env\."
"credentials"
"\.key$"
"\.pem$"
"private_key"
"\.sql$"
)
for pattern in "${sensitive_patterns[@]}"; do
if find . -name "$pattern" -not -path './vendor/*' -not -path './.git/*' 2>/dev/null | grep -q .; then
echo "❌ Found sensitive file matching pattern: $pattern"
find . -name "$pattern" -not -path './vendor/*' -not -path './.git/*' 2>/dev/null
found_sensitive=1
fi
done
if [ $found_sensitive -eq 1 ]; then
exit 1
fi
echo "✓ No sensitive files detected"
# -- Push clean plugin files to the deploy branch --
sync-deploy-branch:
name: Sync deploy branch
runs-on: ubuntu-latest
needs: [test, validate]
environment: production
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Sync deploy branch
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# Fetch the remote deploy branch (may not exist on first run).
git fetch origin deploy:deploy 2>/dev/null || true
# If deploy branch exists, check it out; otherwise create it.
if git rev-parse --verify deploy >/dev/null 2>&1; then
git checkout deploy
else
git checkout --orphan deploy
git rm -rf --cached . > /dev/null 2>&1 || true
fi
# Remove all tracked files so we start clean each time.
git rm -rf . > /dev/null 2>&1 || true
# Checkout only the plugin runtime files from main.
git checkout main -- \
assets/ \
includes/ \
peptide-search-ai.php \
readme.txt
# Commit only if there are actual changes.
git add -A
if git diff-index --quiet HEAD -- 2>/dev/null; then
echo "No changes to deploy."
else
git commit -m "Deploy ${GITHUB_SHA::7} from main"
fi
# Normal push (fast-forward).
git push origin deploy
# -- Deploy to Hostinger via SSH --
deploy:
name: Deploy to Hostinger
runs-on: ubuntu-latest
needs: sync-deploy-branch
environment: production
steps:
- name: Pull deploy branch on Hostinger
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USERNAME }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SSH_PORT }}
script_stop: true
envs: DEPLOY_KEY
script: |
# Ensure GitHub host key is trusted (one-time setup).
mkdir -p ~/.ssh
ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
# Install deploy key so Hostinger can pull from the private repo.
if [ ! -f ~/.ssh/deploy_key ]; then
echo "$DEPLOY_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
fi
# Configure SSH to use the deploy key for github.com.
if ! grep -q "github.com" ~/.ssh/config 2>/dev/null; then
cat >> ~/.ssh/config <<'SSHEOF'
Host github.com
IdentityFile ~/.ssh/deploy_key
StrictHostKeyChecking no
SSHEOF
chmod 600 ~/.ssh/config
fi
PLUGIN_DIR="$HOME/domains/peptiderepo.com/public_html/wp-content/plugins/peptide-search-ai"
REPO_URL="git@github.com:peptiderepo/peptide-search-ai.git"
if [ -d "$PLUGIN_DIR/.git" ]; then
# Existing clone -- just pull latest.
cd "$PLUGIN_DIR"
git fetch origin deploy
git reset --hard origin/deploy
else
# First deploy -- clone the deploy branch.
rm -rf "$PLUGIN_DIR"
git clone -b deploy "$REPO_URL" "$PLUGIN_DIR"
fi
echo "Deployed to $PLUGIN_DIR"
ls -la "$PLUGIN_DIR"
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
- name: Verify deployment on server
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USERNAME }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SSH_PORT }}
script_stop: true
script: |
PLUGIN_DIR="$HOME/domains/peptiderepo.com/public_html/wp-content/plugins/peptide-search-ai"
echo "Verifying deployment..."
if [ ! -d "$PLUGIN_DIR" ]; then
echo "❌ Plugin directory not found at $PLUGIN_DIR"
exit 1
fi
# Check for required files
required_files=(
"peptide-search-ai.php"
"includes"
"assets"
"readme.txt"
)
for file in "${required_files[@]}"; do
if [ ! -e "$PLUGIN_DIR/$file" ]; then
echo "❌ Missing file: $file"
exit 1
fi
echo "✓ Found: $file"
done
# Extract and log deployed version
if [ -f "$PLUGIN_DIR/peptide-search-ai.php" ]; then
version=$(grep "Version:" "$PLUGIN_DIR/peptide-search-ai.php" | head -1 | sed 's/.*Version: *//; s/ *.*//')
echo "✓ Deployed version: $version"
else
echo "❌ Plugin file not found"
exit 1
fi
echo "✓ Deployment verification successful"
- name: Deploy summary
run: |
echo "## Deployment Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- **Branch:** \`${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY
echo "- **Commit:** \`${GITHUB_SHA::7}\`" >> $GITHUB_STEP_SUMMARY
echo "- **Method:** SSH direct deploy (git pull on Hostinger)" >> $GITHUB_STEP_SUMMARY
echo "- **Deployed to:** peptiderepo.com" >> $GITHUB_STEP_SUMMARY

15
.gitignore vendored Executable file
View File

@@ -0,0 +1,15 @@
# Dependencies
vendor/
node_modules/
# IDE / OS
.idea/
.vscode/
*.swp
*.swo
.DS_Store
Thumbs.db
# Build artifacts
*.zip
*.tar.gz

28
.phpcs.xml.dist Executable file
View File

@@ -0,0 +1,28 @@
<?xml version="1.0"?>
<ruleset name="Peptide Search AI">
<description>PHP CodeSniffer configuration for Peptide Search AI WordPress plugin</description>
<!-- Set baseline standard to WordPress-Core -->
<rule ref="WordPress-Core">
<!-- Allow direct database queries with prepare() statements -->
<exclude name="WordPress.DB.DirectDatabaseQuery" />
</rule>
<!-- Files and directories to check -->
<file>peptide-search-ai.php</file>
<file>includes/</file>
<!-- Exclude vendor and node_modules -->
<exclude-pattern>/vendor/*</exclude-pattern>
<exclude-pattern>/node_modules/*</exclude-pattern>
<exclude-pattern>/.git/*</exclude-pattern>
<exclude-pattern>/.github/*</exclude-pattern>
<!-- Report settings -->
<arg name="colors" />
<arg value="sp" />
<arg name="report-width" value="120" />
<!-- Do not fail CI on warnings (only errors should block) -->
<config name="ignore_warnings_on_exit" value="1" />
</ruleset>

387
assets/css/peptide-search.css Executable file
View File

@@ -0,0 +1,387 @@
/* ==========================================================================
Peptide Search AI — Frontend Styles
========================================================================== */
/* --- Search Form --- */
.psa-search-wrap {
max-width: 720px;
margin: 2rem auto;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
}
.psa-search-form {
margin-bottom: 1.5rem;
}
.psa-search-input-wrap {
display: flex;
gap: 0;
border: 2px solid #2563eb;
border-radius: 8px;
overflow: hidden;
transition: border-color 0.2s;
}
.psa-search-input-wrap:focus-within {
border-color: #1d4ed8;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
}
.psa-search-input {
flex: 1;
padding: 14px 18px;
font-size: 16px;
border: none;
outline: none;
background: #fff;
color: #1e293b;
}
.psa-search-input::placeholder {
color: #94a3b8;
}
.psa-search-input:focus {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
.psa-search-btn {
padding: 14px 24px;
background: #2563eb;
color: #fff;
border: none;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
white-space: nowrap;
}
.psa-search-btn:hover {
background: #1d4ed8;
}
.psa-search-btn:focus-visible {
outline: 2px solid #fff;
outline-offset: -2px;
}
.psa-search-icon {
margin-right: 4px;
}
/* --- Search Results --- */
.psa-search-results {
margin-top: 1rem;
}
.psa-result-count {
font-size: 14px;
color: #64748b;
margin-bottom: 1rem;
}
.psa-result-item {
display: block;
padding: 16px 20px;
margin-bottom: 8px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
text-decoration: none;
color: inherit;
transition: border-color 0.2s, box-shadow 0.2s;
}
.psa-result-item:hover {
border-color: #2563eb;
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.1);
}
.psa-result-title {
font-size: 18px;
font-weight: 600;
color: #1e293b;
margin: 0 0 6px;
}
.psa-result-meta {
font-size: 13px;
color: #64748b;
margin-bottom: 6px;
}
.psa-result-meta span {
margin-right: 16px;
}
.psa-result-excerpt {
font-size: 14px;
color: #475569;
line-height: 1.5;
margin: 0;
}
.psa-result-badge {
display: inline-block;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 2px 8px;
border-radius: 4px;
margin-left: 8px;
vertical-align: middle;
}
.psa-badge-ai {
background: #fef3c7;
color: #92400e;
}
.psa-badge-verified {
background: #d1fae5;
color: #065f46;
}
.psa-badge-manual {
background: #dbeafe;
color: #1e40af;
}
/* --- Checking / Loading State --- */
.psa-checking {
margin-top: 1.5rem;
}
.psa-checking-inner {
text-align: center;
padding: 32px 24px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
}
.psa-checking-inner p {
margin: 12px 0 0;
font-size: 15px;
color: #64748b;
}
/* --- Spinner --- */
.psa-spinner {
display: inline-block;
width: 36px;
height: 36px;
border: 4px solid #bfdbfe;
border-top-color: #2563eb;
border-radius: 50%;
animation: psa-spin 0.8s linear infinite;
}
@keyframes psa-spin {
to { transform: rotate(360deg); }
}
/* --- Pending / Being Added Page --- */
.psa-pending {
margin-top: 1.5rem;
}
.psa-pending-inner {
text-align: center;
padding: 40px 32px;
background: linear-gradient(135deg, #eff6ff 0%, #f0f9ff 100%);
border: 1px solid #bfdbfe;
border-radius: 16px;
}
.psa-pending-icon {
font-size: 48px;
line-height: 1;
margin-bottom: 8px;
}
.psa-pending-inner h3 {
margin: 0 0 12px;
font-size: 21px;
color: #1e40af;
font-weight: 700;
}
.psa-pending-inner > p {
margin: 0 0 28px;
font-size: 15px;
color: #3b82f6;
max-width: 500px;
margin-left: auto;
margin-right: auto;
line-height: 1.6;
}
/* --- Invalid Peptide --- */
.psa-invalid {
margin-top: 1.5rem;
}
.psa-invalid-inner {
text-align: center;
padding: 32px 24px;
background: #fafafa;
border: 1px solid #e2e8f0;
border-radius: 12px;
}
.psa-invalid-inner h3 {
margin: 0 0 12px;
font-size: 20px;
color: #334155;
}
.psa-invalid-inner p {
margin: 0;
font-size: 15px;
color: #64748b;
max-width: 500px;
margin-left: auto;
margin-right: auto;
line-height: 1.6;
}
/* --- Error State --- */
.psa-error {
margin-top: 1.5rem;
padding: 16px 20px;
background: #fef2f2;
border: 1px solid #fca5a5;
border-radius: 8px;
color: #991b1b;
font-size: 14px;
}
/* --- Single Peptide Page Data --- */
.psa-peptide-data {
margin-top: 2rem;
}
.psa-quick-facts {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 20px 24px;
margin-bottom: 2rem;
}
.psa-quick-facts h3 {
margin: 0 0 12px;
font-size: 16px;
font-weight: 700;
color: #334155;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.psa-facts-table {
width: 100%;
border-collapse: collapse;
}
.psa-facts-table th {
text-align: left;
padding: 8px 16px 8px 0;
font-weight: 600;
color: #475569;
width: 180px;
vertical-align: top;
border-bottom: 1px solid #e2e8f0;
font-size: 14px;
}
.psa-facts-table td {
padding: 8px 0;
color: #1e293b;
border-bottom: 1px solid #e2e8f0;
font-size: 14px;
}
.psa-facts-table tr:last-child th,
.psa-facts-table tr:last-child td {
border-bottom: none;
}
.psa-sequence {
font-family: "SF Mono", "Fira Code", "Fira Mono", Menlo, Consolas, monospace;
font-size: 13px;
word-break: break-all;
background: #f1f5f9;
padding: 4px 8px;
border-radius: 4px;
}
.psa-section {
margin-bottom: 2rem;
}
.psa-section h3 {
font-size: 18px;
font-weight: 700;
color: #1e293b;
margin: 0 0 12px;
padding-bottom: 8px;
border-bottom: 2px solid #2563eb;
}
.psa-section-content {
font-size: 15px;
line-height: 1.77;
color: #334155;
}
.psa-source-badge {
margin-top: 2rem;
padding: 12px 16px;
border-radius: 8px;
font-size: 13px;
line-height: 1.5;
}
.psa-source-badge.psa-badge-ai {
background: #fffbeb;
border: 1px solid #fde68a;
color: #92400e;
}
.psa-source-badge.psa-badge-verified {
background: #ecfdf5;
border: 1px solid #6ee7b7;
color: #065f46;
}
.psa-source-badge.psa-badge-manual {
background: #eff6ff;
border: 1px solid #93c5fd;
color: #1e40af;
}
/* --- Responsive --- */
@media (max-width: 600px) {
.psa-search-input-wrap,
.psa-notify-input-wrap {
flex-direction: column;
}
.psa-search-btn,
.psa-notify-btn {
border-radius: 0;
}
.psa-facts-table th {
width: 120px;
}
.psa-pending-inner {
padding: 28px 20px;
}
}

214
assets/js/peptide-search.js Executable file
View File

@@ -0,0 +1,214 @@
/**
* Peptide Search AI — Frontend JavaScript
*
* Supports multiple shortcode instances on the same page by using
* class-based selectors scoped to each .psa-search-wrap container.
*
* Flow:
* 1. User searches -> AJAX call to backend
* 2. If found -> show results
* 3. If not found -> backend automatically validates + queues generation
* -> user sees "pending" page
* 4. If invalid peptide name -> show "not recognized" message
*/
(function ($) {
'use strict';
/**
* Initialize a single search instance scoped to its .psa-search-wrap container.
*/
function initSearchInstance($wrap) {
var $form = $wrap.find('.psa-search-form');
var $input = $wrap.find('.psa-search-input');
var $results = $wrap.find('.psa-search-results');
var $checking = $wrap.find('.psa-checking');
var $pending = $wrap.find('.psa-pending');
var $invalid = $wrap.find('.psa-invalid');
var $error = $wrap.find('.psa-error');
function debounce(fn, delay) {
var timer;
return function () {
var args = arguments, ctx = this;
clearTimeout(timer);
timer = setTimeout(function () { fn.apply(ctx, args); }, delay);
};
}
function hideAll() {
$results.hide();
$results.attr('aria-busy', 'false');
$checking.hide();
$pending.hide();
$invalid.hide();
$error.hide();
}
function showError(msg) {
hideAll();
$error.html('<strong>Error:</strong> ' + esc(msg)).show();
$error.attr('role', 'alert');
}
function esc(str) {
var d = document.createElement('div');
d.appendChild(document.createTextNode(str));
return d.innerHTML;
}
function sourceBadge(source) {
var map = {
'pubchem': { label: 'Verified', cls: 'psa-badge-verified' },
'manual': { label: 'Curated', cls: 'psa-badge-manual' }
};
var b = map[source];
if (!b) return '';
return '<span class="psa-result-badge ' + b.cls + '">' + b.label + '</span>';
}
/**
* Render search results.
*/
function renderResults(data) {
hideAll();
var html = '<p class="psa-result-count">' + data.total + ' peptide' +
(data.total !== 1 ? 's' : '') + ' found</p>';
data.results.forEach(function (item) {
html += '<a href="' + esc(item.url) + '" class="psa-result-item">';
html += '<h4 class="psa-result-title">' + esc(item.title);
if (item.source) html += sourceBadge(item.source);
html += '</h4>';
var meta = [];
if (item.molecular_weight) meta.push('<span>MW: ' + esc(item.molecular_weight) + '</span>');
if (item.molecular_formula) meta.push('<span>Formula: ' + esc(item.molecular_formula) + '</span>');
if (item.sequence && item.sequence.length <= 40) meta.push('<span>Seq: <code>' + esc(item.sequence) + '</code></span>');
if (meta.length) html += '<div class="psa-result-meta">' + meta.join('') + '</div>';
if (item.excerpt) html += '<p class="psa-result-excerpt">' + esc(item.excerpt) + '</p>';
html += '</a>';
});
$results.html(html).show();
}
/**
* Show the pending/in-progress message.
*/
function showPending(name) {
hideAll();
$pending.find('.psa-pending-inner p').html(
'<strong>' + esc(name) + '</strong> is currently being added to our database. Please check back again later.'
);
$pending.show();
}
/**
* Show the invalid peptide message.
*/
function showInvalid(name, reason) {
hideAll();
$invalid.find('.psa-invalid-text').html(
'<strong>' + esc(name) + '</strong> does not appear to be a recognized peptide. Please check the spelling or try a different search term.'
);
$invalid.show();
}
/**
* Perform the search. The backend handles everything:
* - If found: returns results
* - If not found + valid peptide: validates, creates placeholder, queues generation
* - If not found + invalid: returns invalid status
*/
function doSearch(query) {
if (query.length < 2) {
hideAll();
return;
}
hideAll();
$checking.show();
$results.attr('aria-busy', 'true');
$.ajax({
url: psaAjax.ajaxurl,
method: 'GET',
data: {
action: 'psa_search',
nonce: psaAjax.nonce,
q: query
},
timeout: 30000,
success: function (response) {
$results.attr('aria-busy', 'false');
if (!response.success) {
showError(response.data || 'Search failed.');
return;
}
var d = response.data;
switch (d.status) {
case 'found':
renderResults(d.data);
break;
case 'pending':
showPending(d.peptide_name);
break;
case 'invalid':
showInvalid(query, d.message);
break;
case 'rate_limited':
showError(d.message || 'Too many requests. Please try again later.');
break;
default:
showError('Unexpected response.');
}
},
error: function (jqXHR, textStatus, errorThrown) {
$results.attr('aria-busy', 'false');
var errorMsg = 'Could not connect to the server. Please try again.';
if (textStatus === 'timeout') {
errorMsg = 'Request timed out. The server took too long to respond. Please try again.';
} else if (textStatus === 'error') {
if (jqXHR.status === 0) {
errorMsg = 'Network error. Please check your connection and try again.';
} else if (jqXHR.status === 500) {
errorMsg = 'Server error. Please try again later.';
} else if (jqXHR.status === 503) {
errorMsg = 'Server is temporarily unavailable. Please try again later.';
}
}
showError(errorMsg);
}
});
}
// --- Event Handlers (scoped to this instance) ---
$form.on('submit', function (e) {
e.preventDefault();
doSearch($input.val().trim());
});
var debouncedSearch = debounce(function () {
var q = $input.val().trim();
if (q.length >= 2) {
doSearch(q);
} else {
hideAll();
}
}, 400);
$input.on('input', debouncedSearch);
}
// --- Initialize all search instances on page ---
$(document).ready(function () {
$('.psa-search-wrap').each(function () {
initSearchInstance($(this));
});
});
})(jQuery);

View File

@@ -92,6 +92,13 @@ class PSA_Admin {
'peptide-search-ai',
'psa_behavior_section'
);
add_settings_field(
'monthly_budget',
__( 'Monthly API Budget (USD)', 'peptide-search-ai' ),
array( __CLASS__, 'render_budget_field' ),
'peptide-search-ai',
'psa_behavior_section'
);
}
/**
@@ -123,6 +130,7 @@ class PSA_Admin {
'auto_publish' => sanitize_text_field( $input['auto_publish'] ?? 'draft' ),
// Checkbox: if key is absent in POST, it was unchecked → '0'.
'use_pubchem' => ! empty( $input['use_pubchem'] ) ? '1' : '0',
'monthly_budget' => max( 0, floatval( $input['monthly_budget'] ?? PSA_Config::DEFAULT_MONTHLY_BUDGET ) ),
);
}
@@ -210,6 +218,14 @@ class PSA_Admin {
<?php
}
public static function render_budget_field() {
$value = self::get_setting( 'monthly_budget', PSA_Config::DEFAULT_MONTHLY_BUDGET );
?>
<input type="number" name="psa_settings[monthly_budget]" value="<?php echo esc_attr( $value ); ?>" step="0.01" min="0" />
<p class="description"><?php esc_html_e( 'Set to 0 for unlimited. Generation will stop when this budget is reached.', 'peptide-search-ai' ); ?></p>
<?php
}
// -------------------------------------------------------------------------
// Settings page
// -------------------------------------------------------------------------
@@ -227,6 +243,10 @@ class PSA_Admin {
?>
</form>
<hr />
<h2><?php esc_html_e( 'API Usage & Costs', 'peptide-search-ai' ); ?></h2>
<?php self::render_usage_summary(); ?>
<hr />
<h2><?php esc_html_e( 'How It Works', 'peptide-search-ai' ); ?></h2>
<ol style="max-width:600px; line-height:1.8;">
@@ -250,6 +270,58 @@ class PSA_Admin {
<?php
}
/**
* Render the API usage summary section.
*/
private static function render_usage_summary() {
$current_spend = PSA_Cost_Tracker::get_monthly_spend();
$current_tokens = PSA_Cost_Tracker::get_monthly_tokens();
$budget = floatval( self::get_setting( 'monthly_budget', PSA_Config::DEFAULT_MONTHLY_BUDGET ) );
$budget_remaining = ( 0 === $budget ) ? __( 'Unlimited', 'peptide-search-ai' ) : '$' . number_format( max( 0, $budget - $current_spend ), 2 );
$recent_logs = PSA_Cost_Tracker::get_recent_logs( 20 );
?>
<div style="background: #f5f5f5; padding: 15px; border-radius: 4px; margin-bottom: 20px;">
<h3><?php esc_html_e( 'This Month', 'peptide-search-ai' ); ?></h3>
<p>
<strong><?php esc_html_e( 'Spend:', 'peptide-search-ai' ); ?></strong> $<?php echo esc_html( number_format( $current_spend, 2 ) ); ?><br />
<strong><?php esc_html_e( 'Tokens:', 'peptide-search-ai' ); ?></strong> <?php echo esc_html( number_format( $current_tokens ) ); ?><br />
<strong><?php esc_html_e( 'Budget Remaining:', 'peptide-search-ai' ); ?></strong> <?php echo esc_html( $budget_remaining ); ?>
</p>
</div>
<?php if ( ! empty( $recent_logs ) ) : ?>
<h3><?php esc_html_e( 'Recent API Calls (Last 20)', 'peptide-search-ai' ); ?></h3>
<table class="widefat striped" style="margin-bottom: 20px;">
<thead>
<tr>
<th><?php esc_html_e( 'Date', 'peptide-search-ai' ); ?></th>
<th><?php esc_html_e( 'Model', 'peptide-search-ai' ); ?></th>
<th><?php esc_html_e( 'Tokens', 'peptide-search-ai' ); ?></th>
<th><?php esc_html_e( 'Cost', 'peptide-search-ai' ); ?></th>
<th><?php esc_html_e( 'Type', 'peptide-search-ai' ); ?></th>
<th><?php esc_html_e( 'Peptide', 'peptide-search-ai' ); ?></th>
<th><?php esc_html_e( 'Status', 'peptide-search-ai' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $recent_logs as $log ) : ?>
<tr>
<td><?php echo esc_html( $log->created_at ); ?></td>
<td><code><?php echo esc_html( $log->model ); ?></code></td>
<td><?php echo esc_html( number_format( $log->total_tokens ) ); ?></td>
<td>$<?php echo esc_html( number_format( $log->estimated_cost_usd, 4 ) ); ?></td>
<td><?php echo esc_html( $log->request_type ); ?></td>
<td><?php echo esc_html( $log->peptide_name ); ?></td>
<td><?php echo ( 1 === (int) $log->success ) ? '✓' : '✗'; ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php
}
/**
* Helper to get a single setting with a default.
*

View File

@@ -47,6 +47,11 @@ class PSA_AI_Generator {
return new WP_Error( 'no_api_key', 'OpenRouter API key is not configured.' );
}
// Check monthly budget before validation.
if ( PSA_Cost_Tracker::is_budget_exceeded() ) {
return new WP_Error( 'budget_exceeded', 'Monthly API budget reached.' );
}
// Check transient cache first (avoid repeated API calls for same term).
$cache_key = 'psa_validate_' . md5( strtolower( trim( $name ) ) );
$cached = get_transient( $cache_key );
@@ -61,7 +66,9 @@ class PSA_AI_Generator {
$api_key,
$model ? $model : 'google/gemini-2.0-flash-001',
$prompt,
PSA_Config::VALIDATION_MAX_TOKENS
PSA_Config::VALIDATION_MAX_TOKENS,
'validation',
$name
);
if ( is_wp_error( $response ) ) {
@@ -202,6 +209,11 @@ class PSA_AI_Generator {
return new WP_Error( 'no_api_key', 'OpenRouter API key is not configured.' );
}
// Check monthly budget before generation.
if ( PSA_Cost_Tracker::is_budget_exceeded() ) {
return new WP_Error( 'budget_exceeded', 'Monthly API budget reached.' );
}
$prompt = self::build_generation_prompt( $peptide_name );
// Allow filtering of generation prompt before sending.
$prompt = apply_filters( 'psa_generation_prompt', $prompt, $peptide_name );
@@ -209,7 +221,9 @@ class PSA_AI_Generator {
$api_key,
$model ? $model : 'google/gemini-2.5-flash',
$prompt,
PSA_Config::GENERATION_MAX_TOKENS
PSA_Config::GENERATION_MAX_TOKENS,
'generation',
$peptide_name
);
if ( is_wp_error( $response ) ) {
@@ -219,6 +233,54 @@ class PSA_AI_Generator {
return self::parse_ai_response( $response );
}
/**
* Perform a dry-run to estimate costs WITHOUT making API calls.
*
* @param string $peptide_name The peptide name to estimate.
* @return array {
* 'validation' => { tokens: int, estimated_cost_usd: float },
* 'generation' => { tokens: int, estimated_cost_usd: float },
* 'total_estimated_cost_usd' => float,
* }
*/
public static function dry_run( $peptide_name ) {
$options = self::get_settings();
$validation_model = $options['validation_model'] ? $options['validation_model'] : 'google/gemini-2.0-flash-001';
$generation_model = $options['ai_model'] ? $options['ai_model'] : 'google/gemini-2.5-flash';
// Estimate validation: prompt tokens from validation prompt + completion tokens.
$validation_prompt = self::build_validation_prompt( $peptide_name );
$validation_prompt_tokens = (int) ceil( strlen( $validation_prompt ) / 4 ); // rough estimate: 1 token ≈ 4 chars.
$validation_completion_tokens = PSA_Config::VALIDATION_MAX_TOKENS;
$validation_cost = PSA_Cost_Tracker::estimate_cost(
$validation_model,
$validation_prompt_tokens,
$validation_completion_tokens
);
// Estimate generation: prompt tokens from generation prompt + completion tokens.
$generation_prompt = self::build_generation_prompt( $peptide_name );
$generation_prompt_tokens = (int) ceil( strlen( $generation_prompt ) / 4 );
$generation_completion_tokens = PSA_Config::GENERATION_MAX_TOKENS;
$generation_cost = PSA_Cost_Tracker::estimate_cost(
$generation_model,
$generation_prompt_tokens,
$generation_completion_tokens
);
return array(
'validation' => array(
'tokens' => $validation_prompt_tokens + $validation_completion_tokens,
'estimated_cost_usd' => $validation_cost,
),
'generation' => array(
'tokens' => $generation_prompt_tokens + $generation_completion_tokens,
'estimated_cost_usd' => $generation_cost,
),
'total_estimated_cost_usd' => $validation_cost + $generation_cost,
);
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
@@ -420,14 +482,16 @@ PROMPT;
* @param string $model The model identifier.
* @param string $prompt The prompt text.
* @param int $max_tokens Maximum tokens to generate.
* @param string $request_type 'validation' or 'generation' (default).
* @param string $peptide_name Peptide name for logging.
* @return string|WP_Error Response text or error.
*/
private static function call_openrouter( $api_key, $model, $prompt, $max_tokens = PSA_Config::GENERATION_MAX_TOKENS ) {
private static function call_openrouter( $api_key, $model, $prompt, $max_tokens = PSA_Config::GENERATION_MAX_TOKENS, $request_type = 'generation', $peptide_name = '' ) {
$max_retries = PSA_Config::API_RETRY_MAX;
$base_delay = PSA_Config::API_RETRY_BASE_DELAY; // seconds
for ( $attempt = 1; $attempt <= $max_retries; $attempt++ ) {
$result = self::call_openrouter_once( $api_key, $model, $prompt, $max_tokens );
$result = self::call_openrouter_once( $api_key, $model, $prompt, $max_tokens, $request_type, $peptide_name );
// If not a rate limit error, return immediately.
if ( ! is_wp_error( $result ) || 'rate_limited' !== $result->get_error_code() ) {
@@ -458,9 +522,11 @@ PROMPT;
* @param string $model The model identifier.
* @param string $prompt The prompt text.
* @param int $max_tokens Maximum tokens to generate.
* @param string $request_type 'validation' or 'generation' (default).
* @param string $peptide_name Peptide name for logging.
* @return string|WP_Error Response text or error.
*/
private static function call_openrouter_once( $api_key, $model, $prompt, $max_tokens = 4096 ) {
private static function call_openrouter_once( $api_key, $model, $prompt, $max_tokens = 4096, $request_type = 'generation', $peptide_name = '' ) {
$site_url = get_bloginfo( 'url' );
$site_name = get_bloginfo( 'name' );
@@ -525,6 +591,22 @@ PROMPT;
return new WP_Error( 'api_error', 'OpenRouter API returned an empty response.' );
}
// Log API call with token usage.
$prompt_tokens = (int) ( $body['usage']['prompt_tokens'] ?? 0 );
$completion_tokens = (int) ( $body['usage']['completion_tokens'] ?? 0 );
PSA_Cost_Tracker::log_api_call(
array(
'provider' => 'openrouter',
'model' => $model,
'prompt_tokens' => $prompt_tokens,
'completion_tokens' => $completion_tokens,
'request_type' => $request_type,
'peptide_name' => $peptide_name,
'success' => true,
)
);
return $body['choices'][0]['message']['content'];
}

44
includes/class-psa-config.php Executable file
View File

@@ -0,0 +1,44 @@
<?php
/**
* Configuration constants for Peptide Search AI.
* Centralizes all magic numbers and configuration values.
*
* @package PeptideSearchAI
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class PSA_Config {
// Search query validation
const MIN_QUERY_LENGTH = 2;
const MAX_QUERY_LENGTH = 100;
// Rate limiting (requests per hour)
const REST_RATE_LIMIT = 20;
const AJAX_RATE_LIMIT = 10;
// Daily generation cap
const DAILY_GENERATION_CAP = 50;
// Generation timing
const PENDING_TIMEOUT = 300; // seconds (5 minutes)
const MAX_GENERATION_RETRIES = 3;
// AI token limits
const VALIDATION_MAX_TOKENS = 300;
const GENERATION_MAX_TOKENS = 4096;
// Cache TTL
const VALIDATION_CACHE_TTL = null; // Set to DAY_IN_SECONDS at runtime
const PUBCHEM_CACHE_TTL = null; // Set to 7 * DAY_IN_SECONDS at runtime
// API retry settings
const API_RETRY_MAX = 3;
const API_RETRY_BASE_DELAY = 5; // seconds
// Cost tracking and budget
const DEFAULT_MONTHLY_BUDGET = 0; // 0 = unlimited
}

View File

@@ -0,0 +1,286 @@
<?php
/**
* Tracks API usage costs and enforces monthly budget limits.
*
* What: Logs every OpenRouter API call with token counts and estimated cost.
* Provides budget enforcement and usage summaries for the admin UI.
* Who calls it: PSA_AI_Generator (logs calls, checks budget), PSA_Admin (displays usage).
* Dependencies: WordPress $wpdb, PSA_Config.
*
* @package PeptideSearchAI
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class PSA_Cost_Tracker {
/**
* Create the API logs table if it doesn't exist.
* Uses dbDelta for safe schema updates.
*/
public static function create_table() {
global $wpdb;
$table_name = $wpdb->prefix . 'psa_api_logs';
$charset_collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE $table_name (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
provider VARCHAR(50) NOT NULL DEFAULT 'openrouter',
model VARCHAR(100) NOT NULL,
prompt_tokens INT NOT NULL DEFAULT 0,
completion_tokens INT NOT NULL DEFAULT 0,
total_tokens INT NOT NULL DEFAULT 0,
estimated_cost_usd DECIMAL(10,6) NOT NULL DEFAULT 0,
request_type VARCHAR(20) NOT NULL,
peptide_name VARCHAR(200) DEFAULT '',
success TINYINT(1) NOT NULL DEFAULT 1,
INDEX idx_created_at (created_at),
INDEX idx_request_type (request_type)
) $charset_collate;";
// phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
// Check for errors during table creation.
if ( ! empty( $wpdb->last_error ) ) {
error_log( 'PSA: Table creation error: ' . $wpdb->last_error );
}
}
/**
* Log an API call with token counts and cost.
*
* @param array $data {
* @type string $provider API provider (default: 'openrouter')
* @type string $model Model identifier
* @type int $prompt_tokens Tokens in prompt
* @type int $completion_tokens Tokens in completion
* @type int $total_tokens Total tokens (or 0 to auto-sum)
* @type string $request_type 'validation' or 'generation'
* @type string $peptide_name Name of peptide being processed
* @type bool $success Whether API call succeeded
* @type float $estimated_cost_usd Cost (or 0 to auto-calculate)
* }
* @return int|false Insert ID or false on error.
*/
public static function log_api_call( $data ) {
global $wpdb;
$table_name = $wpdb->prefix . 'psa_api_logs';
// Sanitize and prepare data.
$provider = $data['provider'] ?? 'openrouter';
$model = $data['model'] ?? '';
$prompt_tokens = (int) ( $data['prompt_tokens'] ?? 0 );
$completion_tokens = (int) ( $data['completion_tokens'] ?? 0 );
$total_tokens = (int) ( $data['total_tokens'] ?? 0 );
$request_type = $data['request_type'] ?? 'generation';
$peptide_name = $data['peptide_name'] ?? '';
$success = ! empty( $data['success'] ) ? 1 : 0;
$estimated_cost = floatval( $data['estimated_cost_usd'] ?? 0 );
// Auto-sum tokens if not provided.
if ( 0 === $total_tokens && ( $prompt_tokens > 0 || $completion_tokens > 0 ) ) {
$total_tokens = $prompt_tokens + $completion_tokens;
}
// Auto-calculate cost if not provided.
if ( 0 === $estimated_cost && $prompt_tokens > 0 ) {
$estimated_cost = self::estimate_cost( $model, $prompt_tokens, $completion_tokens );
}
$insert_data = array(
'provider' => sanitize_text_field( $provider ),
'model' => sanitize_text_field( $model ),
'prompt_tokens' => $prompt_tokens,
'completion_tokens' => $completion_tokens,
'total_tokens' => $total_tokens,
'estimated_cost_usd' => $estimated_cost,
'request_type' => sanitize_text_field( $request_type ),
'peptide_name' => sanitize_text_field( $peptide_name ),
'success' => $success,
);
$insert_format = array( '%s', '%s', '%d', '%d', '%d', '%f', '%s', '%s', '%d' );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$result = $wpdb->insert( $table_name, $insert_data, $insert_format );
if ( false === $result ) {
error_log( 'PSA: Failed to insert API log: ' . $wpdb->last_error );
return false;
}
return $wpdb->insert_id;
}
/**
* Get total spend for a given month.
*
* @param int|null $year Year (defaults to current year).
* @param int|null $month Month (defaults to current month).
* @return float Total USD spent in the month.
*/
public static function get_monthly_spend( $year = null, $month = null ) {
global $wpdb;
if ( null === $year ) {
$year = (int) gmdate( 'Y' );
}
if ( null === $month ) {
$month = (int) gmdate( 'm' );
}
$table_name = $wpdb->prefix . 'psa_api_logs';
// Ensure valid month.
$month = max( 1, min( 12, (int) $month ) );
// Build date range for the month.
$start_date = gmdate( 'Y-m-01', mktime( 0, 0, 0, $month, 1, $year ) );
$end_date = gmdate( 'Y-m-t', mktime( 0, 0, 0, $month, 1, $year ) );
$end_date = $end_date . ' 23:59:59';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$result = $wpdb->get_var(
$wpdb->prepare(
"SELECT COALESCE(SUM(estimated_cost_usd), 0) FROM $table_name WHERE created_at >= %s AND created_at <= %s",
$start_date,
$end_date
)
);
return floatval( $result );
}
/**
* Get total token usage for a given month.
*
* @param int|null $year Year (defaults to current year).
* @param int|null $month Month (defaults to current month).
* @return int Total tokens used in the month.
*/
public static function get_monthly_tokens( $year = null, $month = null ) {
global $wpdb;
if ( null === $year ) {
$year = (int) gmdate( 'Y' );
}
if ( null === $month ) {
$month = (int) gmdate( 'm' );
}
$table_name = $wpdb->prefix . 'psa_api_logs';
// Ensure valid month.
$month = max( 1, min( 12, (int) $month ) );
// Build date range for the month.
$start_date = gmdate( 'Y-m-01', mktime( 0, 0, 0, $month, 1, $year ) );
$end_date = gmdate( 'Y-m-t', mktime( 0, 0, 0, $month, 1, $year ) );
$end_date = $end_date . ' 23:59:59';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$result = $wpdb->get_var(
$wpdb->prepare(
"SELECT COALESCE(SUM(total_tokens), 0) FROM $table_name WHERE created_at >= %s AND created_at <= %s",
$start_date,
$end_date
)
);
return (int) $result;
}
/**
* Check if current month's budget has been exceeded.
* Budget of 0 means unlimited.
*
* @return bool True if budget exceeded, false otherwise.
*/
public static function is_budget_exceeded() {
$settings = get_option( 'psa_settings', array() );
$budget = floatval( $settings['monthly_budget'] ?? PSA_Config::DEFAULT_MONTHLY_BUDGET );
// Budget of 0 = unlimited.
if ( 0 === $budget ) {
return false;
}
$current_spend = self::get_monthly_spend();
return $current_spend >= $budget;
}
/**
* Get the most recent API call logs.
*
* @param int $limit Maximum number of logs to return.
* @return array Array of log objects.
*/
public static function get_recent_logs( $limit = 20 ) {
global $wpdb;
$table_name = $wpdb->prefix . 'psa_api_logs';
$limit = max( 1, (int) $limit );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM $table_name ORDER BY created_at DESC LIMIT %d",
$limit
)
);
return is_array( $results ) ? $results : array();
}
/**
* Estimate cost for an API call based on model and token counts.
* Uses known pricing, defaults to conservative estimate for unknown models.
*
* @param string $model Model identifier.
* @param int $prompt_tokens Tokens in prompt.
* @param int $completion_tokens Tokens in completion.
* @return float Estimated cost in USD.
*/
public static function estimate_cost( $model, $prompt_tokens, $completion_tokens ) {
// Known model pricing per 1 million tokens (input, output).
$pricing = array(
'google/gemini-2.5-flash' => array( 0.15, 0.60 ),
'google/gemini-2.0-flash-001' => array( 0.10, 0.40 ),
);
$model = sanitize_text_field( $model );
if ( isset( $pricing[ $model ] ) ) {
list( $input_price, $output_price ) = $pricing[ $model ];
} else {
// Conservative default for unknown models.
$input_price = 1.0;
$output_price = 2.0;
}
// Convert per 1M to per token.
$input_cost = ( $input_price / 1000000 ) * $prompt_tokens;
$output_cost = ( $output_price / 1000000 ) * $completion_tokens;
return (float) ( $input_cost + $output_cost );
}
/**
* Drop the API logs table during uninstall.
*/
public static function drop_table() {
global $wpdb;
$table_name = $wpdb->prefix . 'psa_api_logs';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$wpdb->query( "DROP TABLE IF EXISTS $table_name" );
}
}

39
includes/class-psa-error.php Executable file
View File

@@ -0,0 +1,39 @@
<?php
/**
* Standardized error handling for Peptide Search AI.
* Provides a unified interface for sending error responses with consistent structure.
*
* @package PeptideSearchAI
* @deprecated 4.1.0 Currently unused. Retained for potential future integration.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class PSA_Error {
// Error type constants
const TYPE_VALIDATION = 'validation_error';
const TYPE_RATE_LIMIT = 'rate_limit_error';
const TYPE_SYSTEM = 'system_error';
const TYPE_NOT_FOUND = 'not_found';
/**
* Send a standardized error response.
*
* @param string $type The error type (one of the TYPE_* constants).
* @param string $message User-friendly error message.
* @param int $http_code HTTP response code (default 400).
* @return void
*/
public static function send( $type, $message, $http_code = 400 ) {
wp_send_json_error(
array(
'error_type' => $type,
'message' => $message,
),
$http_code
);
}
}

162
includes/class-psa-post-type.php Executable file
View File

@@ -0,0 +1,162 @@
<?php
/**
* Registers the Peptide custom post type and its meta fields.
*
* @package PeptideSearchAI
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class PSA_Post_Type {
/** Meta field keys used throughout the plugin. */
const META_FIELDS = array(
'psa_sequence' => 'Amino Acid Sequence',
'psa_molecular_weight' => 'Molecular Weight',
'psa_molecular_formula' => 'Molecular Formula',
'psa_aliases' => 'Aliases / Synonyms',
'psa_mechanism' => 'Mechanism of Action',
'psa_research_apps' => 'Research Applications',
'psa_safety_profile' => 'Safety & Side Effects',
'psa_dosage_info' => 'Dosage Information',
'psa_references' => 'References & Citations',
'psa_source' => 'Data Source',
'psa_pubchem_cid' => 'PubChem CID',
);
/**
* Register admin-only hooks (meta boxes, save).
*/
public static function init_admin() {
add_action( 'add_meta_boxes', array( __CLASS__, 'add_meta_boxes' ) );
add_action( 'save_post_peptide', array( __CLASS__, 'save_meta' ) );
}
/**
* Register the peptide CPT.
*/
public static function register_peptide_post_type() {
if ( post_type_exists( 'peptide' ) ) {
return;
}
$labels = array(
'name' => 'Peptides',
'singular_name' => 'Peptide',
'menu_name' => 'Peptides',
'add_new' => 'Add Peptide',
'add_new_item' => 'Add New Peptide',
'edit_item' => 'Edit Peptide',
'new_item' => 'New Peptide',
'view_item' => 'View Peptide',
'search_items' => 'Search Peptides',
'not_found' => 'No peptides found',
'not_found_in_trash' => 'No peptides found in Trash',
);
register_post_type(
'peptide',
array(
'labels' => $labels,
'public' => true,
'has_archive' => true,
'rewrite' => array( 'slug' => 'peptides' ),
'supports' => array( 'title', 'editor', 'thumbnail', 'excerpt' ),
'menu_icon' => 'dashicons-database',
'show_in_rest' => true,
'capability_type' => 'post',
'hierarchical' => false,
)
);
}
/**
* Add meta boxes to the peptide editor.
*/
public static function add_meta_boxes() {
add_meta_box(
'psa_peptide_data',
'Peptide Scientific Data',
array( __CLASS__, 'render_meta_box' ),
'peptide',
'normal',
'high'
);
}
/**
* Render the meta box fields.
*
* @param WP_Post $post The current post object.
*/
public static function render_meta_box( $post ) {
wp_nonce_field( 'psa_save_meta', 'psa_meta_nonce' );
$textarea_fields = array(
'psa_sequence',
'psa_mechanism',
'psa_research_apps',
'psa_safety_profile',
'psa_dosage_info',
'psa_references',
);
echo '<table class="form-table">';
foreach ( self::META_FIELDS as $key => $label ) {
$value = get_post_meta( $post->ID, $key, true );
echo '<tr>';
echo '<th><label for="' . esc_attr( $key ) . '">' . esc_html( $label ) . '</label></th>';
echo '<td>';
if ( 'psa_source' === $key ) {
$options = array(
'' => '— Select —',
'ai-generated' => 'AI Generated',
'pubchem' => 'PubChem Verified',
'manual' => 'Manual Entry',
'pending' => 'Pending Generation',
);
echo '<select name="' . esc_attr( $key ) . '" id="' . esc_attr( $key ) . '">';
foreach ( $options as $opt_val => $opt_label ) {
echo '<option value="' . esc_attr( $opt_val ) . '"' . selected( $value, $opt_val, false ) . '>' . esc_html( $opt_label ) . '</option>';
}
echo '</select>';
} elseif ( in_array( $key, $textarea_fields, true ) ) {
echo '<textarea name="' . esc_attr( $key ) . '" id="' . esc_attr( $key ) . '" rows="4" class="large-text">' . esc_textarea( $value ) . '</textarea>';
} else {
echo '<input type="text" name="' . esc_attr( $key ) . '" id="' . esc_attr( $key ) . '" value="' . esc_attr( $value ) . '" class="regular-text" />';
}
echo '</td></tr>';
}
echo '</table>';
}
/**
* Save meta field values.
*
* @param int $post_id The post ID.
*/
public static function save_meta( $post_id ) {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput
if ( ! isset( $_POST['psa_meta_nonce'] ) ||
! wp_verify_nonce( wp_unslash( $_POST['psa_meta_nonce'] ), 'psa_save_meta' ) ) {
return;
}
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return;
}
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return;
}
foreach ( self::META_FIELDS as $key => $label ) {
if ( isset( $_POST[ $key ] ) ) {
$value = sanitize_textarea_field( wp_unslash( $_POST[ $key ] ) );
update_post_meta( $post_id, $key, $value );
}
}
}
}

152
includes/class-psa-pubchem.php Executable file
View File

@@ -0,0 +1,152 @@
<?php
/**
* PubChem PUG REST API integration for molecular data enrichment.
*
* @package PeptideSearchAI
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class PSA_PubChem {
const API_BASE = 'https://pubchem.ncbi.nlm.nih.gov/rest/pug';
/**
* Look up a peptide/compound by name and return molecular properties.
* Results are cached in transients to respect PubChem rate limits.
*
* @param string $name Peptide or compound name.
* @return array|WP_Error|null Array of properties, WP_Error on failure, or null if not found.
*/
public static function lookup( $name ) {
$options = get_option( 'psa_settings', array() );
$use_pubchem = $options['use_pubchem'] ?? '1';
if ( '1' !== $use_pubchem ) {
return null;
}
// Check cache first (respect PubChem's 5 req/sec limit).
$cache_key = 'psa_pubchem_' . md5( strtolower( trim( $name ) ) );
$cached = get_transient( $cache_key );
if ( false !== $cached ) {
// We store 'not_found' string to differentiate from cache miss.
if ( 'not_found' === $cached ) {
return null;
}
if ( is_wp_error( $cached ) ) {
return $cached;
}
return $cached;
}
// Step 1: Get the CID by compound name.
$cid = self::get_cid_by_name( $name );
if ( is_wp_error( $cid ) ) {
set_transient( $cache_key, $cid, 1 * HOUR_IN_SECONDS );
return $cid;
}
if ( ! $cid ) {
set_transient( $cache_key, 'not_found', 12 * HOUR_IN_SECONDS );
return null;
}
// Step 2: Get properties by CID.
$properties = self::get_properties( $cid );
if ( is_wp_error( $properties ) ) {
set_transient( $cache_key, $properties, 1 * HOUR_IN_SECONDS );
return $properties;
}
if ( ! $properties ) {
set_transient( $cache_key, 'not_found', 12 * HOUR_IN_SECONDS );
return null;
}
$properties['cid'] = $cid;
// Cache successful lookups for 7 days.
$ttl = PSA_Config::PUBCHEM_CACHE_TTL ? PSA_Config::PUBCHEM_CACHE_TTL : 7 * DAY_IN_SECONDS;
set_transient( $cache_key, $properties, $ttl );
return $properties;
}
/**
* Get PubChem Compound ID (CID) by name.
*
* @param string $name Compound name.
* @return int|WP_Error|null CID, WP_Error on API failure, or null if not found.
*/
private static function get_cid_by_name( $name ) {
$url = self::API_BASE . '/compound/name/' . rawurlencode( $name ) . '/cids/JSON';
$response = wp_remote_get(
$url,
array(
'timeout' => 15,
'headers' => array( 'Accept' => 'application/json' ),
)
);
if ( is_wp_error( $response ) ) {
return new WP_Error( 'pubchem_api_error', 'PubChem API request failed: ' . $response->get_error_message() );
}
$code = wp_remote_retrieve_response_code( $response );
if ( 200 !== $code ) {
return new WP_Error( 'pubchem_http_error', 'PubChem returned HTTP ' . $code );
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
return isset( $body['IdentifierList']['CID'][0] )
? (int) $body['IdentifierList']['CID'][0]
: null;
}
/**
* Get molecular properties for a given CID.
*
* @param int $cid PubChem Compound ID.
* @return array|WP_Error|null Properties array, WP_Error on failure, or null if not found.
*/
private static function get_properties( $cid ) {
$props = 'MolecularFormula,MolecularWeight,IUPACName,CanonicalSMILES,InChI';
$url = self::API_BASE . '/compound/cid/' . (int) $cid . '/property/' . $props . '/JSON';
$response = wp_remote_get(
$url,
array(
'timeout' => 15,
'headers' => array( 'Accept' => 'application/json' ),
)
);
if ( is_wp_error( $response ) ) {
return new WP_Error( 'pubchem_api_error', 'PubChem API request failed: ' . $response->get_error_message() );
}
$code = wp_remote_retrieve_response_code( $response );
if ( 200 !== $code ) {
return new WP_Error( 'pubchem_http_error', 'PubChem returned HTTP ' . $code );
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! isset( $body['PropertyTable']['Properties'][0] ) ) {
return null;
}
$p = $body['PropertyTable']['Properties'][0];
return array(
'molecular_formula' => $p['MolecularFormula'] ?? '',
'molecular_weight' => isset( $p['MolecularWeight'] ) ? $p['MolecularWeight'] . ' Da' : '',
'iupac_name' => $p['IUPACName'] ?? '',
'smiles' => $p['CanonicalSMILES'] ?? '',
'inchi' => $p['InChI'] ?? '',
);
}
}

584
includes/class-psa-search.php Executable file
View File

@@ -0,0 +1,584 @@
<?php
/**
* Handles AJAX search, the [peptide_search] shortcode, and the REST API endpoint.
* When a peptide is not found, it automatically validates and queues generation.
*
* @package PeptideSearchAI
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class PSA_Search {
/**
* Static counter for unique shortcode instance IDs.
*
* @var int
*/
private static $instance_count = 0;
public static function init() {
add_shortcode( 'peptide_search', array( __CLASS__, 'render_search_form' ) );
add_action( 'wp_ajax_psa_search', array( __CLASS__, 'ajax_search' ) );
add_action( 'wp_ajax_nopriv_psa_search', array( __CLASS__, 'ajax_search' ) );
add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) );
// Cache invalidation when a peptide is saved.
add_action( 'save_post_peptide', array( __CLASS__, 'invalidate_search_cache' ) );
}
/**
* Get the current cache generation counter.
*
* The generation counter is used to invalidate all psa_search_* caches
* without expensive DELETE queries. When a peptide is saved, we increment
* the generation, causing all searches to use a new cache key and thus
* bypass stale results. Old transients expire naturally via their 6hr TTL.
*
* @return int The current generation counter (default 0).
*/
private static function get_cache_generation() {
$gen = (int) get_option( 'psa_search_cache_gen', 0 );
return $gen;
}
/**
* Invalidate all search caches by incrementing the generation counter.
*
* Instead of a costly DELETE query that removes all psa_search_* transients,
* this method increments a generation counter. All future search_peptides()
* calls will use a new cache key (that includes the generation number),
* effectively invalidating all old caches. Old transients expire naturally
* via their 6hr TTL.
*
* Performance: O(1) option update vs. O(n) database DELETE query.
* Use flush_all_search_caches() only for manual cache clearing from admin.
*
* @param int $post_id The post ID being saved.
*/
public static function invalidate_search_cache( $post_id ) {
$current_gen = self::get_cache_generation();
update_option( 'psa_search_cache_gen', $current_gen + 1 );
}
/**
* Flush all search caches immediately via DELETE query.
*
* This is a manual cache-clearing method for admin use (e.g., from settings),
* not called on every peptide save. Use invalidate_search_cache() for that.
*
* WARNING: Expensive operation — performs a DELETE query on wp_options.
* Only use when absolutely necessary (e.g., maintenance, manual admin actions).
*
* @return void
*/
public static function flush_all_search_caches() {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$wpdb->query(
"DELETE FROM {$wpdb->options}
WHERE option_name LIKE '_transient_psa_search_%'
OR option_name LIKE '_transient_timeout_psa_search_%'"
);
}
/**
* Register REST API routes for external/headless access.
* Note: REST route only returns existing published results — it does NOT trigger generation.
*/
public static function register_rest_routes() {
register_rest_route(
'peptides/v1',
'/search',
array(
'methods' => 'GET',
'callback' => array( __CLASS__, 'rest_search' ),
'permission_callback' => '__return_true',
'args' => array(
'q' => array(
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => function ( $param ) {
return is_string( $param ) && strlen( $param ) >= PSA_Config::MIN_QUERY_LENGTH;
},
),
),
)
);
}
/**
* REST search callback — returns published peptides only (no generation trigger).
*
* Uses dual-layer rate limiting (transient + object cache) for resilience.
* If Redis/Memcached evicts the transient, we fall back to the object cache.
*
* Limitation: IP spoofing or cache server restart could bypass the limit.
* For high-traffic deployments, consider a custom DB table with persistent
* rate limit tracking (e.g., wp_psa_rate_limits with indexed IP + timestamp).
*
* @param WP_REST_Request $request The REST request.
* @return WP_REST_Response
*/
public static function rest_search( $request ) {
// Rate limiting: max REST_RATE_LIMIT requests per IP per hour.
// Uses dual-layer caching (transient + object cache) for resilience.
$ip = psa_get_client_ip();
$rate_key = 'psa_rest_rate_' . md5( $ip );
// Check transient first.
$count_transient = (int) get_transient( $rate_key );
// Check object cache as secondary source for resilience.
$count_cache = (int) wp_cache_get( $rate_key, 'psa_rate_limits' );
// Use the highest count from either source.
$count = max( $count_transient, $count_cache );
if ( $count >= PSA_Config::REST_RATE_LIMIT ) {
return new WP_REST_Response(
array( 'error' => 'Rate limit exceeded. Please try again later.' ),
429
);
}
// Update both transient and object cache for resilience.
set_transient( $rate_key, $count + 1, HOUR_IN_SECONDS );
wp_cache_set( $rate_key, $count + 1, 'psa_rate_limits', HOUR_IN_SECONDS );
$query = $request->get_param( 'q' );
// Max input length validation.
if ( mb_strlen( $query ) > PSA_Config::MAX_QUERY_LENGTH ) {
return new WP_REST_Response(
array( 'error' => 'Search query must not exceed ' . PSA_Config::MAX_QUERY_LENGTH . ' characters.' ),
400
);
}
$results = self::search_peptides( $query );
return new WP_REST_Response( $results, 200 );
}
/**
* Render the [peptide_search] shortcode.
*
* Uses class-based selectors (no IDs) to support multiple shortcode instances
* on the same page (e.g., hero section + search overlay). Each instance is
* independently functional via scoped JS initialization.
*
* @param array $atts Shortcode attributes.
* @return string HTML output.
*/
public static function render_search_form( $atts ) {
self::$instance_count++;
$instance = self::$instance_count;
$atts = shortcode_atts(
array(
'placeholder' => 'Search for a peptide (e.g., BPC-157, Thymosin Beta-4)...',
),
$atts,
'peptide_search'
);
ob_start();
?>
<div class="psa-search-wrap" data-psa-instance="<?php echo esc_attr( $instance ); ?>">
<form class="psa-search-form" role="search" autocomplete="off">
<div class="psa-search-input-wrap">
<input type="text" class="psa-search-input" placeholder="<?php echo esc_attr( $atts['placeholder'] ); ?>" aria-label="Search peptide database" minlength="2" required />
<button type="submit" class="psa-search-btn">
<span class="psa-search-icon">&#128269;</span> Search
</button>
</div>
</form>
<div class="psa-search-results" aria-live="polite" aria-busy="false" style="display:none;"></div>
<div class="psa-checking" role="status" style="display:none;">
<div class="psa-checking-inner">
<div class="psa-spinner"></div>
<p class="psa-checking-text">Searching our database...</p>
</div>
</div>
<div class="psa-pending" role="status" style="display:none;">
<div class="psa-pending-inner">
<div class="psa-pending-icon">&#9203;</div>
<p>This peptide is currently being added to our database. Please check back again later.</p>
</div>
</div>
<div class="psa-invalid" role="status" style="display:none;">
<div class="psa-invalid-inner">
<p class="psa-invalid-text">Peptide not found.</p>
</div>
</div>
<div class="psa-error" role="status" style="display:none;"></div>
</div>
<?php
return ob_get_clean();
}
/**
* AJAX handler: search for peptides.
* Orchestrates complete workflow: search, pending check, rate limiting, validation, generation.
*
* @return void Exits with wp_send_json_* responses.
*/
public static function ajax_search() {
check_ajax_referer( 'psa_search_nonce', 'nonce' );
$query = isset( $_GET['q'] ) ? sanitize_text_field( wp_unslash( $_GET['q'] ) ) : '';
// Validate query length using PSA_Config constants.
if ( mb_strlen( $query ) < PSA_Config::MIN_QUERY_LENGTH || mb_strlen( $query ) > PSA_Config::MAX_QUERY_LENGTH ) {
wp_send_json_error( 'Search query must be between ' . PSA_Config::MIN_QUERY_LENGTH . ' and ' . PSA_Config::MAX_QUERY_LENGTH . ' characters.' );
}
// 1. Search existing published peptides.
$results = self::search_peptides( $query );
if ( ! empty( $results['results'] ) ) {
wp_send_json_success(
array(
'status' => 'found',
'data' => $results,
)
);
return;
}
// 2. Check if already pending — handles retries if timed out.
$pending = self::find_pending_peptide( $query );
if ( $pending ) {
self::handle_pending_retry( $pending );
return; // handle_pending_retry() exits with wp_send_json_success()
}
// 3. Check rate limit before attempting new generation.
if ( ! self::check_rate_limit() ) {
wp_send_json_success(
array(
'status' => 'rate_limited',
'message' => 'Too many requests. Please try again later.',
)
);
return;
}
// 4. Validate and generate.
self::handle_new_generation( $query );
return; // handle_new_generation() exits with wp_send_json_*()
}
/**
* Handle a pending peptide — retry if timed out, else return pending status.
*
* @param WP_Post $pending The pending post.
* @return void Exits with wp_send_json_success().
*/
private static function handle_pending_retry( $pending ) {
$started = (int) get_post_meta( $pending->ID, 'psa_generation_started', true );
$retry_count = (int) get_post_meta( $pending->ID, 'psa_generation_attempts', true );
$timed_out = $started && ( time() - $started ) > PSA_Config::PENDING_TIMEOUT;
if ( $timed_out ) {
if ( $retry_count >= PSA_Config::MAX_GENERATION_RETRIES ) {
// Max retries exceeded — mark as failed.
error_log( 'PSA: Max retries (' . PSA_Config::MAX_GENERATION_RETRIES . ') exceeded for "' . $pending->post_title . '" (post ' . $pending->ID . '). Marking as failed.' );
update_post_meta( $pending->ID, 'psa_source', 'failed' );
update_post_meta( $pending->ID, 'psa_generation_error', 'Generation failed after ' . PSA_Config::MAX_GENERATION_RETRIES . ' attempts.' );
wp_send_json_success(
array(
'status' => 'invalid',
'message' => 'We were unable to generate content for this peptide. Please try again later.',
)
);
return;
}
// Retry generation.
error_log( 'PSA: Retrying stale pending peptide ' . $pending->post_title . ' (post ' . $pending->ID . ', attempt ' . ( $retry_count + 1 ) . '/' . PSA_Config::MAX_GENERATION_RETRIES . ')' );
update_post_meta( $pending->ID, 'psa_generation_started', time() );
update_post_meta( $pending->ID, 'psa_generation_attempts', $retry_count + 1 );
self::schedule_background_generation( $pending->ID, $pending->post_title );
}
wp_send_json_success(
array(
'status' => 'pending',
'peptide_name' => $pending->post_title,
)
);
}
/**
* Check rate limit for AJAX generation requests.
*
* Uses dual-layer caching (transient + object cache) for resilience.
* If Redis/Memcached evicts the transient, we fall back to the object cache.
* If either source reports the limit exceeded, the request is rate-limited.
*
* Limitation: IP spoofing or cache server restart could bypass the limit.
* For high-traffic deployments, consider a custom DB table with persistent
* rate limit tracking (e.g., wp_psa_rate_limits with indexed IP + timestamp).
*
* @return bool True if under limit, false if limit exceeded.
*/
private static function check_rate_limit() {
$ip = psa_get_client_ip();
$rate_key = 'psa_rate_' . md5( $ip );
// Check transient first.
$count_transient = (int) get_transient( $rate_key );
// Check object cache as secondary source for resilience.
$count_cache = (int) wp_cache_get( $rate_key, 'psa_rate_limits' );
// Use the highest count from either source.
$count = max( $count_transient, $count_cache );
return $count < PSA_Config::AJAX_RATE_LIMIT;
}
/**
* Handle a new generation request — validate, create placeholder, and schedule generation.
*
* @param string $query The search query (peptide name).
* @return void Exits with wp_send_json_* responses.
*/
private static function handle_new_generation( $query ) {
// Global daily generation cap to prevent cost overruns.
$daily_key = 'psa_daily_gen_' . gmdate( 'Y-m-d' );
$daily_count = (int) get_transient( $daily_key );
if ( $daily_count >= PSA_Config::DAILY_GENERATION_CAP ) {
wp_send_json_success(
array(
'status' => 'rate_limited',
'message' => 'Our system has reached its daily limit for adding new peptides. Please try again tomorrow.',
)
);
return;
}
// Validate peptide name via AI.
$validation = PSA_AI_Generator::validate_peptide_name( $query );
if ( is_wp_error( $validation ) ) {
error_log( 'PSA: Validation error for "' . $query . '": ' . $validation->get_error_message() );
wp_send_json_success(
array(
'status' => 'invalid',
'message' => 'Could not verify this peptide name.',
)
);
}
if ( empty( $validation['is_valid'] ) ) {
wp_send_json_success(
array(
'status' => 'invalid',
'message' => $validation['reason'] ?? 'This does not appear to be a recognized peptide.',
)
);
}
// It's a real peptide — increment rate limit and create placeholder.
// Update both transient and object cache for resilience.
$ip = psa_get_client_ip();
$rate_key = 'psa_rate_' . md5( $ip );
$count = (int) get_transient( $rate_key );
set_transient( $rate_key, $count + 1, HOUR_IN_SECONDS );
wp_cache_set( $rate_key, $count + 1, 'psa_rate_limits', HOUR_IN_SECONDS );
$canonical_name = sanitize_text_field( $validation['canonical_name'] ?? $query );
$placeholder_id = self::create_pending_placeholder( $canonical_name );
if ( is_wp_error( $placeholder_id ) ) {
wp_send_json_error( 'Failed to create peptide entry. Please try again.' );
}
// Increment global daily generation counter.
$daily_key = 'psa_daily_gen_' . gmdate( 'Y-m-d' );
$daily_count = (int) get_transient( $daily_key );
set_transient( $daily_key, $daily_count + 1, DAY_IN_SECONDS );
// Schedule async background generation.
error_log( 'PSA: Scheduling async generation for "' . $canonical_name . '" (post ' . $placeholder_id . ')' );
self::schedule_background_generation( $placeholder_id, $canonical_name );
wp_send_json_success(
array(
'status' => 'pending',
'peptide_name' => $canonical_name,
)
);
}
/**
* Find an existing pending peptide post by name.
*
* Performs a multi-table query to locate pending draft posts by title or alias.
* Queries the posts table joined with postmeta for 'psa_source' = 'pending' status,
* plus LEFT JOIN on postmeta for aliases. Returns the most recent match.
*
* @param string $query Search term (peptide name or alias).
* @return WP_Post|null Pending post object or null if not found.
*/
public static function find_pending_peptide( $query ) {
global $wpdb;
$like = '%' . $wpdb->esc_like( $query ) . '%';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$post_id = $wpdb->get_var(
$wpdb->prepare(
"SELECT p.ID
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} src ON p.ID = src.post_id
AND src.meta_key = 'psa_source' AND src.meta_value = 'pending'
LEFT JOIN {$wpdb->postmeta} alias ON p.ID = alias.post_id
AND alias.meta_key = 'psa_aliases'
WHERE p.post_type = 'peptide'
AND p.post_status = 'draft'
AND ( p.post_title LIKE %s OR alias.meta_value LIKE %s )
ORDER BY p.post_date DESC
LIMIT 1",
$like,
$like
)
);
return $post_id ? get_post( $post_id ) : null;
}
/**
* Create a placeholder draft post so we can track the pending state.
*
* @param string $peptide_name The canonical peptide name.
* @return int|WP_Error Post ID or error.
*/
public static function create_pending_placeholder( $peptide_name ) {
$post_id = wp_insert_post(
array(
'post_type' => 'peptide',
'post_title' => $peptide_name,
'post_content' => '',
'post_status' => 'draft',
'post_author' => psa_get_admin_user_id(),
),
true
);
if ( ! is_wp_error( $post_id ) ) {
update_post_meta( $post_id, 'psa_source', 'pending' );
update_post_meta( $post_id, 'psa_generation_started', time() );
// Allow developers to hook into placeholder creation.
do_action( 'psa_peptide_created', $post_id, $peptide_name );
}
return $post_id;
}
/**
* Schedule background generation using WP-Cron (non-blocking).
*
* @param int $post_id The placeholder post ID.
* @param string $peptide_name The peptide to research.
*/
public static function schedule_background_generation( $post_id, $peptide_name ) {
$args = array( $post_id, $peptide_name );
if ( ! wp_next_scheduled( 'psa_generate_peptide_background', $args ) ) {
wp_schedule_single_event( time(), 'psa_generate_peptide_background', $args );
}
spawn_cron();
}
/**
* Core search logic — queries CPT by title and aliases (published only).
* Includes transient caching and batch post/meta priming to avoid N+1 queries.
*
* Cache key includes the generation counter, so invalidate_search_cache()
* can purge all caches by incrementing the counter (O(1) vs. expensive DELETE).
*
* @param string $query Search term.
* @return array { results: array, total: int, query: string }
*/
public static function search_peptides( $query ) {
global $wpdb;
// Check transient cache first.
// Include generation counter in cache key for fast invalidation.
$cache_key = 'psa_search_' . self::get_cache_generation() . '_' . md5( sanitize_text_field( $query ) );
$cached = get_transient( $cache_key );
if ( false !== $cached ) {
return $cached;
}
$like = '%' . $wpdb->esc_like( $query ) . '%';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$post_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT DISTINCT p.ID
FROM {$wpdb->posts} p
LEFT JOIN {$wpdb->postmeta} m ON p.ID = m.post_id AND m.meta_key = 'psa_aliases'
WHERE p.post_type = 'peptide'
AND p.post_status = 'publish'
AND ( p.post_title LIKE %s OR m.meta_value LIKE %s )
ORDER BY p.post_title ASC
LIMIT 20",
$like,
$like
)
);
if ( ! empty( $post_ids ) ) {
// Prime the WordPress object cache for all posts and their meta in batch.
_prime_post_caches( $post_ids );
update_meta_cache( 'post', $post_ids );
}
$results = array();
foreach ( $post_ids as $id ) {
$results[] = self::format_peptide( (int) $id );
}
$response = array(
'results' => $results,
'total' => count( $results ),
'query' => $query,
);
// Cache results for 6 hours.
set_transient( $cache_key, $response, 6 * HOUR_IN_SECONDS );
// Allow filtering of search results before return.
$response = apply_filters( 'psa_search_results', $response, $query );
return $response;
}
/**
* Format a peptide post for JSON response.
*
* @param int $post_id The post ID.
* @return array
*/
public static function format_peptide( $post_id ) {
$post = get_post( $post_id );
if ( ! $post ) {
return array();
}
return array(
'id' => $post->ID,
'title' => $post->post_title,
'url' => get_permalink( $post->ID ),
'excerpt' => wp_trim_words( $post->post_content, 30 ),
'sequence' => get_post_meta( $post->ID, 'psa_sequence', true ),
'molecular_weight' => get_post_meta( $post->ID, 'psa_molecular_weight', true ),
'molecular_formula' => get_post_meta( $post->ID, 'psa_molecular_formula', true ),
'source' => get_post_meta( $post->ID, 'psa_source', true ),
);
}
}

131
includes/class-psa-template.php Executable file
View File

@@ -0,0 +1,131 @@
<?php
/**
* Custom template rendering for single peptide pages.
*
* @package PeptideSearchAI
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class PSA_Template {
public static function init() {
add_filter( 'the_content', array( __CLASS__, 'append_peptide_data' ) );
}
/**
* Append structured peptide data below the post content on single peptide pages.
*
* @param string $content The post content.
* @return string Modified content.
*/
public static function append_peptide_data( $content ) {
if ( ! is_singular( 'peptide' ) || ! in_the_loop() || ! is_main_query() ) {
return $content;
}
$post_id = get_the_ID();
$source = get_post_meta( $post_id, 'psa_source', true );
$pubchem_cid = get_post_meta( $post_id, 'psa_pubchem_cid', true );
// Build template data for filtering.
$data = array(
'post_id' => $post_id,
'source' => $source,
'pubchem_cid' => $pubchem_cid,
);
// Allow filtering of template data before rendering.
$data = apply_filters( 'psa_template_data', $data, $post_id );
// Restore values from possibly filtered data.
$post_id = $data['post_id'] ?? $post_id;
$source = $data['source'] ?? $source;
$pubchem_cid = $data['pubchem_cid'] ?? $pubchem_cid;
$html = '<div class="psa-peptide-data">';
// Quick facts box.
$weight = get_post_meta( $post_id, 'psa_molecular_weight', true );
$formula = get_post_meta( $post_id, 'psa_molecular_formula', true );
$seq = get_post_meta( $post_id, 'psa_sequence', true );
$aliases = get_post_meta( $post_id, 'psa_aliases', true );
if ( $weight || $formula || $seq ) {
$html .= '<div class="psa-quick-facts">';
$html .= '<h3>Quick Facts</h3>';
$html .= '<table class="psa-facts-table">';
if ( $aliases ) {
$html .= '<tr><th>Also Known As</th><td>' . esc_html( $aliases ) . '</td></tr>';
}
if ( $seq ) {
$html .= '<tr><th>Sequence</th><td><code class="psa-sequence">' . esc_html( $seq ) . '</code></td></tr>';
}
if ( $formula ) {
$html .= '<tr><th>Molecular Formula</th><td>' . esc_html( $formula ) . '</td></tr>';
}
if ( $weight ) {
$html .= '<tr><th>Molecular Weight</th><td>' . esc_html( $weight ) . '</td></tr>';
}
if ( $pubchem_cid ) {
$cid_int = intval( $pubchem_cid );
$html .= '<tr><th>PubChem CID</th><td><a href="https://pubchem.ncbi.nlm.nih.gov/compound/' . $cid_int . '" target="_blank" rel="noopener">' . $cid_int . '</a></td></tr>';
}
$html .= '</table>';
$html .= '</div>';
}
// Detailed sections.
$detail_fields = array(
'psa_mechanism' => 'Mechanism of Action',
'psa_research_apps' => 'Research Applications',
'psa_safety_profile' => 'Safety & Side Effects',
'psa_dosage_info' => 'Dosage Information',
'psa_references' => 'References',
);
foreach ( $detail_fields as $key => $label ) {
$value = get_post_meta( $post_id, $key, true );
if ( ! empty( $value ) ) {
$html .= '<div class="psa-section">';
$html .= '<h3>' . esc_html( $label ) . '</h3>';
$html .= '<div class="psa-section-content">' . wp_kses_post( wpautop( $value ) ) . '</div>';
$html .= '</div>';
}
}
// Source badge.
if ( $source && 'pending' !== $source ) {
$badges = array(
'ai-generated' => array(
'label' => 'AI-Generated Content',
'class' => 'psa-badge-ai',
),
'pubchem' => array(
'label' => 'PubChem Verified',
'class' => 'psa-badge-verified',
),
'manual' => array(
'label' => 'Manually Curated',
'class' => 'psa-badge-manual',
),
);
$badge = $badges[ $source ] ?? $badges['ai-generated'];
$html .= '<div class="psa-source-badge ' . esc_attr( $badge['class'] ) . '">';
$html .= esc_html( $badge['label'] );
if ( 'ai-generated' === $source ) {
$html .= ' &mdash; This entry was automatically generated and may contain inaccuracies. Please verify critical information with primary sources.';
}
$html .= '</div>';
}
$html .= '</div>';
return $content . $html;
}
}

View File

@@ -25,6 +25,7 @@ require_once PSA_PLUGIN_DIR . 'includes/class-psa-error.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-encryption.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-post-type.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-search.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-cost-tracker.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-ai-generator.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-pubchem.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-admin.php';
@@ -160,6 +161,7 @@ add_action( 'wp_footer', 'psa_replace_kb_search' );
function psa_activate() {
PSA_Post_Type::register_peptide_post_type();
PSA_Cost_Tracker::create_table();
flush_rewrite_rules();
}
register_activation_hook( __FILE__, 'psa_activate' );

66
readme.txt Executable file
View File

@@ -0,0 +1,66 @@
=== Peptide Search AI ===
Contributors: terence
Tags: peptide, database, search, AI, science, research, openrouter
Requires at least: 5.8
Tested up to: 6.7
Requires PHP: 7.4
Stable tag: 4.0.0
License: GPLv2 or later
Searchable peptide database with AI-powered auto-population. Visitors search for peptides — if one doesn't exist, it's automatically validated and created with comprehensive AI-generated scientific content in the background.
== Description ==
Peptide Search AI turns your WordPress site into an intelligent, self-growing peptide database.
**How it works:**
1. Visitors search for a peptide using the search form.
2. If found, results appear instantly with molecular data.
3. If not found, the plugin automatically validates the name using a lightweight AI call, creates a placeholder, and queues comprehensive content generation in the background via WP-Cron.
4. The visitor sees a "currently being added" message and can check back shortly.
5. Generated entries include: amino acid sequence, molecular weight/formula, mechanism of action, research applications, safety profile, dosage info, and references.
6. PubChem cross-referencing validates molecular data when available.
**Features:**
* AJAX search with instant results
* Automatic peptide name validation (lightweight AI check before full generation)
* Background content generation via WP-Cron (non-blocking)
* Custom Post Type with structured scientific fields
* AI content generation via OpenRouter — access hundreds of models (Gemini, Llama, DeepSeek, Mistral, Qwen, and more)
* Separate model configuration for validation (cheap/fast) and generation (full)
* PubChem PUG REST API integration for verified molecular data
* Rate limiting (10 requests/IP/hour) to prevent abuse
* Transient-based caching for validation and PubChem lookups
* Admin settings for API key, models, auto-publish behavior, PubChem toggle
* REST API endpoint for headless/external access (read-only)
* Conditional asset loading (only on pages with the shortcode or peptide singles)
* Source badges (AI-Generated / PubChem Verified / Manually Curated)
* Mobile-responsive design
== Installation ==
1. Upload the `peptide-search-ai` folder to `/wp-content/plugins/`
2. Activate the plugin through the Plugins menu
3. Go to Settings > Peptide Search AI
4. Enter your OpenRouter API key (get one at openrouter.ai/settings/keys)
5. Optionally set separate models for validation and generation
6. Configure auto-publish and PubChem settings
7. Add `[peptide_search]` to any page or post
== Shortcode ==
`[peptide_search]` — Displays the peptide search form.
Optional attributes:
* `placeholder` — Custom placeholder text for the search input.
Example: `[peptide_search placeholder="Search our peptide database..."]`
== REST API ==
Search endpoint: `GET /wp-json/peptides/v1/search?q=BPC-157`
Returns JSON with matching published peptides, their molecular data, and permalink URLs. This endpoint is read-only and does not trigger auto-generation.

42
uninstall.php Executable file
View File

@@ -0,0 +1,42 @@
<?php
/**
* Fired when the plugin is uninstalled.
*
* Cleans up all plugin data: peptide posts, post meta, options, transients, and rewrite rules.
*
* @package PeptideSearchAI
*/
// Abort if not called by WordPress.
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
exit;
}
global $wpdb;
// 1. Delete all peptide custom posts and their meta.
$peptide_ids = $wpdb->get_col(
"SELECT ID FROM {$wpdb->posts} WHERE post_type = 'peptide'"
);
if ( ! empty( $peptide_ids ) ) {
foreach ( $peptide_ids as $post_id ) {
wp_delete_post( (int) $post_id, true ); // Force delete, bypass trash.
}
}
// 2. Delete plugin options.
delete_option( 'psa_settings' );
// 3. Delete all plugin transients (validation cache, rate limits).
$wpdb->query(
"DELETE FROM {$wpdb->options}
WHERE option_name LIKE '_transient_psa_%'
OR option_name LIKE '_transient_timeout_psa_%'"
);
// 4. Drop API logs table.
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}psa_api_logs" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
// 5. Flush rewrite rules to clean up the peptide post type permalinks.
flush_rewrite_rules();