diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100755 index 0000000..c5edfc3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100755 index 0000000..848805d --- /dev/null +++ b/.github/workflows/deploy.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..6429e3d --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Dependencies +vendor/ +node_modules/ + +# IDE / OS +.idea/ +.vscode/ +*.swp +*.swo +.DS_Store +Thumbs.db + +# Build artifacts +*.zip +*.tar.gz diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist new file mode 100755 index 0000000..53b4be7 --- /dev/null +++ b/.phpcs.xml.dist @@ -0,0 +1,28 @@ + + + PHP CodeSniffer configuration for Peptide Search AI WordPress plugin + + + + + + + + + peptide-search-ai.php + includes/ + + + /vendor/* + /node_modules/* + /.git/* + /.github/* + + + + + + + + + diff --git a/assets/css/peptide-search.css b/assets/css/peptide-search.css new file mode 100755 index 0000000..e271197 --- /dev/null +++ b/assets/css/peptide-search.css @@ -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; + } +} diff --git a/assets/js/peptide-search.js b/assets/js/peptide-search.js new file mode 100755 index 0000000..b9c48a9 --- /dev/null +++ b/assets/js/peptide-search.js @@ -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('Error: ' + 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 '' + b.label + ''; + } + + /** + * Render search results. + */ + function renderResults(data) { + hideAll(); + + var html = '

' + data.total + ' peptide' + + (data.total !== 1 ? 's' : '') + ' found

'; + + data.results.forEach(function (item) { + html += ''; + html += '

' + esc(item.title); + if (item.source) html += sourceBadge(item.source); + html += '

'; + + var meta = []; + if (item.molecular_weight) meta.push('MW: ' + esc(item.molecular_weight) + ''); + if (item.molecular_formula) meta.push('Formula: ' + esc(item.molecular_formula) + ''); + if (item.sequence && item.sequence.length <= 40) meta.push('Seq: ' + esc(item.sequence) + ''); + if (meta.length) html += '
' + meta.join('') + '
'; + + if (item.excerpt) html += '

' + esc(item.excerpt) + '

'; + html += '
'; + }); + + $results.html(html).show(); + } + + /** + * Show the pending/in-progress message. + */ + function showPending(name) { + hideAll(); + $pending.find('.psa-pending-inner p').html( + '' + esc(name) + ' 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( + '' + esc(name) + ' 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); diff --git a/includes/class-psa-admin.php b/includes/class-psa-admin.php index bded3db..521f565 100755 --- a/includes/class-psa-admin.php +++ b/includes/class-psa-admin.php @@ -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 { + +

+ +
+

+ +

    @@ -250,6 +270,58 @@ class PSA_Admin { +
    +

    +

    + $
    +
    + +

    +
    + + +

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    created_at ); ?>model ); ?>total_tokens ) ); ?>$estimated_cost_usd, 4 ) ); ?>request_type ); ?>peptide_name ); ?>success ) ? '✓' : '✗'; ?>
    + + { 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']; } diff --git a/includes/class-psa-config.php b/includes/class-psa-config.php new file mode 100755 index 0000000..e5095e5 --- /dev/null +++ b/includes/class-psa-config.php @@ -0,0 +1,44 @@ +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" ); + } +} diff --git a/includes/class-psa-error.php b/includes/class-psa-error.php new file mode 100755 index 0000000..ea95600 --- /dev/null +++ b/includes/class-psa-error.php @@ -0,0 +1,39 @@ + $type, + 'message' => $message, + ), + $http_code + ); + } +} diff --git a/includes/class-psa-post-type.php b/includes/class-psa-post-type.php new file mode 100755 index 0000000..79043dd --- /dev/null +++ b/includes/class-psa-post-type.php @@ -0,0 +1,162 @@ + '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 ''; + foreach ( self::META_FIELDS as $key => $label ) { + $value = get_post_meta( $post->ID, $key, true ); + echo ''; + echo ''; + echo ''; + } + echo '
    '; + + if ( 'psa_source' === $key ) { + $options = array( + '' => '— Select —', + 'ai-generated' => 'AI Generated', + 'pubchem' => 'PubChem Verified', + 'manual' => 'Manual Entry', + 'pending' => 'Pending Generation', + ); + echo ''; + } elseif ( in_array( $key, $textarea_fields, true ) ) { + echo ''; + } else { + echo ''; + } + + echo '
    '; + } + + /** + * 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 ); + } + } + } +} diff --git a/includes/class-psa-pubchem.php b/includes/class-psa-pubchem.php new file mode 100755 index 0000000..cfe20da --- /dev/null +++ b/includes/class-psa-pubchem.php @@ -0,0 +1,152 @@ + 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'] ?? '', + ); + } +} diff --git a/includes/class-psa-search.php b/includes/class-psa-search.php new file mode 100755 index 0000000..61630fe --- /dev/null +++ b/includes/class-psa-search.php @@ -0,0 +1,584 @@ +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(); + ?> +
    + + + + + + +
    + 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 ), + ); + } +} diff --git a/includes/class-psa-template.php b/includes/class-psa-template.php new file mode 100755 index 0000000..5cc1506 --- /dev/null +++ b/includes/class-psa-template.php @@ -0,0 +1,131 @@ + $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 = '
    '; + + // 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 .= '
    '; + $html .= '

    Quick Facts

    '; + $html .= ''; + + if ( $aliases ) { + $html .= ''; + } + if ( $seq ) { + $html .= ''; + } + if ( $formula ) { + $html .= ''; + } + if ( $weight ) { + $html .= ''; + } + if ( $pubchem_cid ) { + $cid_int = intval( $pubchem_cid ); + $html .= ''; + } + + $html .= '
    Also Known As' . esc_html( $aliases ) . '
    Sequence' . esc_html( $seq ) . '
    Molecular Formula' . esc_html( $formula ) . '
    Molecular Weight' . esc_html( $weight ) . '
    PubChem CID' . $cid_int . '
    '; + $html .= '
    '; + } + + // 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 .= '
    '; + $html .= '

    ' . esc_html( $label ) . '

    '; + $html .= '
    ' . wp_kses_post( wpautop( $value ) ) . '
    '; + $html .= '
    '; + } + } + + // 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 .= '
    '; + $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 .= '
    '; + } + + $html .= '
    '; + + return $content . $html; + } +} diff --git a/peptide-search-ai.php b/peptide-search-ai.php index b049769..530d4f9 100755 --- a/peptide-search-ai.php +++ b/peptide-search-ai.php @@ -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' ); diff --git a/readme.txt b/readme.txt new file mode 100755 index 0000000..565847d --- /dev/null +++ b/readme.txt @@ -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. + diff --git a/uninstall.php b/uninstall.php new file mode 100755 index 0000000..877989b --- /dev/null +++ b/uninstall.php @@ -0,0 +1,42 @@ +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();