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:
69
.github/workflows/ci.yml
vendored
Executable file
69
.github/workflows/ci.yml
vendored
Executable 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
254
.github/workflows/deploy.yml
vendored
Executable 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
15
.gitignore
vendored
Executable 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
28
.phpcs.xml.dist
Executable 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
387
assets/css/peptide-search.css
Executable 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
214
assets/js/peptide-search.js
Executable 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);
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -416,18 +478,20 @@ PROMPT;
|
||||
* On third rate limit: waits 20s before final attempt.
|
||||
* Other errors are returned immediately without retry.
|
||||
*
|
||||
* @param string $api_key The OpenRouter API key.
|
||||
* @param string $model The model identifier.
|
||||
* @param string $prompt The prompt text.
|
||||
* @param int $max_tokens Maximum tokens to generate.
|
||||
* @param string $api_key The OpenRouter API key.
|
||||
* @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() ) {
|
||||
@@ -454,13 +518,15 @@ PROMPT;
|
||||
/**
|
||||
* Single OpenRouter API call (no retry).
|
||||
*
|
||||
* @param string $api_key The OpenRouter API key.
|
||||
* @param string $model The model identifier.
|
||||
* @param string $prompt The prompt text.
|
||||
* @param int $max_tokens Maximum tokens to generate.
|
||||
* @param string $api_key The OpenRouter API key.
|
||||
* @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
44
includes/class-psa-config.php
Executable 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
|
||||
}
|
||||
286
includes/class-psa-cost-tracker.php
Executable file
286
includes/class-psa-cost-tracker.php
Executable 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
39
includes/class-psa-error.php
Executable 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
162
includes/class-psa-post-type.php
Executable 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
152
includes/class-psa-pubchem.php
Executable 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
584
includes/class-psa-search.php
Executable 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">🔍</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">⏳</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
131
includes/class-psa-template.php
Executable 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 .= ' — This entry was automatically generated and may contain inaccuracies. Please verify critical information with primary sources.';
|
||||
}
|
||||
$html .= '</div>';
|
||||
}
|
||||
|
||||
$html .= '</div>';
|
||||
|
||||
return $content . $html;
|
||||
}
|
||||
}
|
||||
@@ -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
66
readme.txt
Executable 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
42
uninstall.php
Executable 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();
|
||||
Reference in New Issue
Block a user