37 Commits

Author SHA1 Message Date
peptiderepo-ops
c4cd64eb6b ci(gitea): deploy also on manual runs of main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AqpXh3hQMMaC74pTjFq4it
2026-10-24 09:57:51 +00:00
peptiderepo-ops
4b8b9a0fa8 ci(gitea): re-render pipeline (prebuilt PHP CI image)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AqpXh3hQMMaC74pTjFq4it
2026-10-24 09:49:27 +00:00
peptiderepo-ops
a886d27037 ci(gitea): add generated Gitea Actions pipeline
Rendered by peptide-e2e/tools/gitea-pipeline/render.py from repos.json; calls
the shared composite actions in peptide-e2e. Production deploy is gated by the
PROD_DEPLOY_ENABLED org variable (cutover switch). .github/workflows unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AqpXh3hQMMaC74pTjFq4it
2026-10-24 09:39:30 +00:00
peptiderepo
0eb0c68c40 ci: remove advisory CTO Review (Haiku) workflow (#36)
Advisory, non-gating PR reviewer superseded by the independent qa-review deploy gate. Removes redundant OpenRouter (Claude Haiku) spend. CEO directive 2026-06-23.

Agent-Session: remove-cto-review-20260623

Co-authored-by: peptiderepo <peptiderepo@users.noreply.github.com>
2026-06-23 18:19:02 +07:00
peptiderepo
003029af93 ci: fix peptide-e2e org ref + deploy.yml permissions (#35)
Fix stale peptiderepo/peptide-e2e refs and permissions mismatch in deploy.yml.

Agent-Session: fix-e2e-ref-20260617
2026-06-17 11:36:51 +07:00
peptiderepo
fefc8fdc13 ci: add workflow_call to ci.yml so deploy can gate on it (#34)
deploy.yml uses `uses: ./.github/workflows/ci.yml` as a pre-deploy gate.
Without workflow_call in the on: block, GitHub rejects the reference and
the auto-deploy errors on every push to main. No plugin code changed;
no version bump (CI-only change per CONVENTIONS).

Agent-Session: cowork-2026-06-17-ci-workflowcall

Co-authored-by: peptiderepo <bot@peptiderepo.com>
2026-06-17 01:37:14 +07:00
peptiderepo
c81700bd38 Adopt reusable CI standard + 300-line cleanup (#33)
* ci: adopt reusable CI + 300-line compliance for v4.7.0

- Replace per-repo CI jobs with thin reusable caller
  (peptide-e2e ci.yml@main; tests:stubs, has_js:true)
- Add permissions: contents: write (required for phpcbf auto-commit)
- deploy.yml: drop file-existence + PHP-syntax redundancy; keep PSA_VERSION check
- class-psa-admin.php: remove 37 dead backward-compat proxy lines (327→290)
- class-psa-ai-generator.php: remove 17 dead backward-compat proxy lines (309→292)
- class-psa-search.php: remove 13 dead backward-compat proxy lines (307→294)
  All three files now comply with the estate 300-line rule.
  Zero external callers of removed methods confirmed.
- package.json / package-lock.json: minimal npm manifest for lint-js npm ci step
- ARCHITECTURE.md: update ci.yml description
- PSA_VERSION: sync constant (was 4.5.0) with header (was 4.6.0) → both 4.7.0

Agent-Session: claude-sonnet-4-6-adopt-ci-20260616

* style: auto-fix PHPCS violations via phpcbf

* fix(tests): restore tested proxy methods in PSA_AI_Generator

AIGeneratorTest accesses build_validation_prompt() and
build_generation_prompt() via ReflectionClass - confirmed by running CI.
Restored both as compact single-docblock proxies to PSA_AI_Content.
Condensed section headers to keep file under 300 lines (now 299).

Agent-Session: claude-sonnet-4-6-adopt-ci-20260616

---------

Co-authored-by: peptiderepo <bot@peptiderepo.com>
Co-authored-by: peptiderepo <peptiderepo@users.noreply.github.com>
2026-06-16 20:38:06 +07:00
peptiderepo
d3bdfdd08d ci(review): drop multi-model panel to Haiku-only (#32)
QA A/B decision 2026-06-12 (CEO-ratified): Haiku-only. Evidence: convo/cto/threads/2026-06-qa-ab-review/. QA verdict per repo in qa-reviews/.

Agent-Session: cto-panel-merge-20260612
2026-06-12 21:53:38 +07:00
peptiderepo
603571c9a3 ci: adopt shared reusable deploy workflow (#31)
Replaces the per-repo staging/smoke/production jobs with one call to
peptiderepo/peptide-e2e/.github/workflows/deploy-app.yml (target_path=wp-content/plugins/peptide-search-ai).
Validated end-to-end on the calculator pilot.

Agent-Session: cto rollout-reusable-deploy
2026-06-09 19:15:51 +07:00
peptiderepo
4948f153b7 ci(audit): retry PR-association check (#30)
listPullRequestsAssociatedWithCommit is eventually-consistent after a squash
merge; the single immediate call intermittently flagged legit merges as
'direct push to main'. Now polls up to 5x/10s before opening an issue.

Agent-Session: cto audit-race-fix
2026-06-09 13:39:13 +07:00
peptiderepo
3b65c2c102 ci: restore deploy pipeline (#29)
Same 0-job-failure fix as prautoblogger #147 / prcore #14.

Agent-Session: cto restore-deploy-org-sweep
2026-06-09 13:27:02 +07:00
peptiderepo
0ea8642339 ci: make smoke gate informational (#28)
Smoke runs but does not block prod promotion until triage completes.

Agent-Session: cto retry-merge-smoke-informational
2026-05-14 21:34:11 +07:00
peptiderepo
ecf3b8c1ce ci: migrate to rsync deploy, fix concurrency group, standardize excludes 2026-04-26 11:55:48 +07:00
peptiderepo
cba62b0192 ci: standardize PHP matrix to 8.1/8.2/8.3, PHPCS continue-on-error 2026-04-26 11:55:46 +07:00
peptiderepo
9e20805034 ci: add Playwright smoke gate before prod deploy 2026-04-26 11:41:20 +07:00
peptiderepo
a894345652 v4.6.0 — Brand token adoption (teal/lime, Inter font)
Replace hardcoded #2563eb blue with brand token set.
All primary/accent/focus colors → teal scale.
Font → Inter / IBM Plex Mono.
2026-04-26 04:22:12 +00:00
peptiderepo
14a0f86810 fix(ci): treat phpcs exit 2 (warnings-only) as success
After phpcbf auto-fix in previous commit, remaining violations are
all warnings (exit code 2). Apply same pattern as peptide-news-plugin:
  composer run lint || test $? -eq 2
2026-04-24 06:34:33 +00:00
peptiderepo
3dda80b771 fix(ci): phpcbf auto-format + add missing bootstrap requires for tests
212 PHPCS formatting errors auto-fixed by phpcbf across 18 files.
All remaining issues are warnings (exit 2), not errors — CI treats
exit 2 as success via the same pattern as peptide-news-plugin.

Also adds class-psa-ai-content.php and class-psa-directory-api.php to
tests/bootstrap.php require list — both files exist in includes/ but
were missing from bootstrap, causing 5 PHPUnit "Class not found"
failures in AIGeneratorTest and DirectoryTest.
2026-04-24 06:33:41 +00:00
peptiderepo
4ac81f5d23 ci: add staging deploy step + production approval gate 2026-04-24 13:12:42 +07:00
peptiderepo
9daeac7fb7 chore: add v4.5.1 CHANGELOG entry 2026-04-24 00:51:23 +07:00
peptiderepo
00ff676e3b chore: update contributors to peptiderepo 2026-04-24 00:51:21 +07:00
peptiderepo
7961928898 chore: update author to peptiderepo, fix plugin URI, bump to v4.5.1 2026-04-24 00:51:20 +07:00
peptiderepo
e87e15326a ci(cto-review): add app-map freshness check to QA gate (#17)
When a PR modifies the admin settings surface, file/class structure, or data flow, the QA reviewer now flags the PR as a MAJOR concern unless the PR description references an accompanying update to the CTO app-map at `Peptide Repo CTO/app-maps/<repo-slug>.md`.

The CTO workspace is a separate repo, so CI cannot verify the doc update mechanically — the reviewer inspects the PR description for an explicit mention.

Why: the CTO-level app-maps are the single-pane-of-glass reference that lets the CTO answer questions like 'which settings does this plugin expose?' without grepping the repo each time. Without a merge-gate prompt, these maps drift out of sync with the code.

Co-authored-by: peptiderepo <peptiderepo@users.noreply.github.com>
2026-04-22 18:33:31 +07:00
peptiderepo
868d31cfb3 v4.5.0: drop peptide CPT registration; add PR Core dependency check (#15)
Drops PSA registration of peptide CPT + peptide_category taxonomy (PR Core >= 0.2.0 now owns them per 2026-04-22 consolidation). Adds hard dependency check on PR Core 0.2.0+ — PSA self-deactivates with an admin notice if PR Core is missing or below min version.

CI note: PHPUnit failures (PSA_AI_Content, PSA_Directory_API not found) and WPCS failure are pre-existing on main at HEAD 65f973f (verified: same 5 errors in run 24530495421). PHP Lint green on 7.4/8.1/8.3. No regressions introduced by this PR.

QA: qa-reviews/2026-04-22-2609c66.md PASS.
2026-04-22 17:12:34 +07:00
peptiderepo
65f973fea4 refactor: extract PSA_Directory_API from PSA_Directory (317→114+159)
REST endpoint registration, compounds query, and compound formatting
moved to PSA_Directory_API. PSA_Directory retains shortcode rendering,
constants, and hook registration. Backward-compatible proxies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 02:45:56 +07:00
peptiderepo
33a1133cf4 refactor: extract PSA_Cost_Reporter from PSA_Cost_Tracker (337→171+136)
Monthly spend/token/estimate-count queries and recent-logs retrieval
moved to PSA_Cost_Reporter. Tracker retains table management, logging,
budget enforcement, and cost estimation. Shared month_range helper
in reporter eliminates duplicated date-range logic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 02:44:33 +07:00
peptiderepo
e8adce77e9 refactor: extract PSA_Post_Type_Meta from PSA_Post_Type (343→193+140)
Meta box registration, rendering (core + extended), and save handler
moved to PSA_Post_Type_Meta. PSA_Post_Type retains CPT/taxonomy
registration, default categories, admin columns, and constants.
Backward-compatible proxy methods on PSA_Post_Type.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 02:43:13 +07:00
peptiderepo
671c0a207a refactor: extract PSA_AI_Content from PSA_AI_Generator (539→282+228)
Prompt templates (validation + generation), meta field saving,
category term assignment, and amino acid count derivation moved to
PSA_AI_Content. Generator retains orchestration: validate, generate,
background processing, dry-run, settings, input validation.
Backward-compatible proxy methods on PSA_AI_Generator.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 02:41:45 +07:00
peptiderepo
4a67958a68 refactor: extract PSA_Admin_Page from PSA_Admin (560→290+221)
Rendering methods (settings page, field renderers, usage summary,
batch script enqueue) moved to PSA_Admin_Page. PSA_Admin retains
registration, sanitization, migration, re-enrichment, and get_setting
(now public). Backward-compatible proxy methods on PSA_Admin.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 02:39:33 +07:00
peptiderepo
6480eecaa4 refactor(search): split 590-line search class into search + handler
Extract AJAX handler, generation workflow, pending retry logic, and
placeholder creation into PSA_Search_Handler (211 lines). Main class
retains search logic, caching, REST endpoint, and shortcode rendering
(300 lines). Backward-compatible proxies preserved.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 02:35:17 +07:00
peptiderepo
979a5af866 ci(deploy): tolerate 403 on post-deploy health check (#8)
The peptide-starter-theme v1.5.2 release added
peptide_starter_is_cloudflare_peer() — a CF-peer IP validator that
correctly rejects direct-to-origin requests as non-Cloudflare traffic
with HTTP 403. The deploy workflow's post-deploy health check makes
exactly such a request from a GHA runner IP, so it now reliably
receives 403 even on successful deploys.

Two deploys today (peptide-starter-theme v1.5.2, prautoblogger Commit 1a)
both succeeded but reported workflow failure due to this. The actual
deploys (rsync + cache purge) ran clean; site verified HTTP 200 from
real traffic both times.

Fix: accept either 200 (normal healthy response) or 403 (validator
actively rejecting non-CF origin — itself a positive liveness signal)
in the health check. Reject only on 5xx, 4xx-other, or connection
failure.

Reverting this fix is correct only if we either (a) bypass the
validator for our own health-check user-agent, or (b) route the
health check through Cloudflare. Both are larger changes for a
later release.

Agent-Session: cowork-deploy-healthcheck-fix-20260415

Co-authored-by: peptiderepo <peptiderepo@users.noreply.github.com>
2026-04-15 20:17:27 +07:00
peptiderepo
301dc13d0e feat(ci): add Neurometric PR-Summarizer as 4th QA voice (#6)
Neurometric markets a task-specialized PR-Summarizer SLM (Qwen3-4B +
specialist system prompt) on a free tier. Adding it as a 4th voice in
the existing A/B reviewer panel to see whether a specialized small
model catches things the 3 general-purpose voices miss.

The SLM does not honor OpenAI tool-calling, so it cannot return our
structured submit_review schema. Wired as a "prose voice":

- MODELS tuple gains a 6th provider_type field
  ("openrouter_tools" | "neurometric_prose")
- call_model() dispatches; new call_neurometric_prose() path hits
  api.neurometric.ai/v1 without tools, embeds raw markdown output,
  and infers verdict from keyword heuristic
- Render branch in PR comment marks the verdict as heuristic and
  notes tokens/cost are not tracked (free tier)
- Never the primary voice; heading still driven by Haiku

Also hardens the shared render path against models that omit the
optional 'summary' field (rev.get('summary') fallback).

Requires new repo secret NEUROMETRIC_API_KEY (already set on all four
peptiderepo repos). Voice self-skips with inline error if missing.

Agent-Session: cowork-neurometric-qa-voice-20260415

Co-authored-by: peptiderepo <peptiderepo@users.noreply.github.com>
2026-04-15 16:12:18 +07:00
peptiderepo
08c538988d docs(arch): link to cross-app engineering reference docs
Adds a pointer to the cross-app engineering docs in the CTO workspace so engineers scoped to this repo can find shared reference material (Cloudflare AI Gateway routing, OpenRouter key rotation, interface pattern, image-generation stack, social distributor choice) and the incident runbook.

No code changes. Pure docs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:59:17 +07:00
peptiderepo
7587a1cebe fix: close QA P1 findings on auto-schema-upgrade (#4)
Closes three P1 findings from the QA baseline review of a8a0ce2:
- uninstall.php deletes psa_db_version
- PSA_Upgrade class with unit tests (is_needed is pure, maybe_run is the driver)
- ARCHITECTURE.md documents the schema-versioning mechanism

Also extracts the logic into includes/class-psa-upgrade.php so it is cleanly testable without bootstrapping the whole plugin file.

Agent-Session: cowork-qa-p1-fixes-20260414
2026-04-14 20:47:30 +07:00
peptiderepo
3820bdfd28 ci: reviewer cost cap + 3-model A/B harness (#3)
* ci: cost cap + 3-model A/B harness on CTO reviewer

Two enhancements to the PR reviewer:

1. Monthly spend cap: tracked in Repo Variables (CTO_REVIEW_MONTH,
   CTO_REVIEW_SPEND_CENTS, CTO_REVIEW_CAP_CENTS). Default cap $10/month.
   Auto-resets on month rollover. Posts a skip-comment on PRs when cap hit.

2. A/B harness: reviews every PR with 3 models in parallel
   (Claude Haiku 4.5, DeepSeek V3.1, Kimi K2 Thinking). Comment shows
   all 3 verdicts side-by-side so we can compare quality/cost on real
   PRs before picking a permanent default. Drop to 1 model by editing
   the MODELS list after A/B window closes.

Agent-Session: cowork-reviewer-ab-20260414

* ci: use CTO_ADMIN_TOKEN for repo variables + restore formal APPROVE/REQUEST_CHANGES

Default GITHUB_TOKEN cannot mutate Repository Variables (always 403).
Use a PAT-backed secret CTO_ADMIN_TOKEN for the budget counter steps.
Also restores formal APPROVE/REQUEST_CHANGES verdicts now that repo
setting can_approve_pull_request_reviews is enabled.

Agent-Session: cowork-reviewer-ab-20260414

* ci: address review feedback — per-model timeout + simpler cost math

- Per-model timeout of 60s so one hung model does not block siblings
- Simplify cost_cents math (cost_usd * 100 directly, no double-rounding)

Defer: fine-grained PAT for CTO_ADMIN_TOKEN (requires manual OAuth device flow).

Agent-Session: cowork-reviewer-ab-20260414

* ci: stop exposing CTO_ADMIN_TOKEN as env var (security cleanup)

Pass the PAT only via github-token param. continue-on-error on the
budget step so a missing/expired secret degrades gracefully instead
of failing the whole workflow.

Agent-Session: cowork-reviewer-ab-20260414
2026-04-14 17:35:10 +07:00
peptiderepo
2fdcca124a ci: post CTO review as COMMENT instead of APPROVE (#2)
GITHUB_TOKEN cannot formally approve PRs without a repo setting toggle.
Verdict is still prominent in the first line of the review body, and
the human merges based on reading it — no behavior loss.

Agent-Session: cowork-reviewer-fix-20260414
2026-04-14 16:53:16 +07:00
peptiderepo
6ad9f49b86 ci: add review gate (tripwire audit + Claude auto-review via OpenRouter) (#1)
* ci: add main-push audit tripwire

Flags direct pushes to main that did not come from a merged PR.
Free-plan workaround since branch protection requires GitHub Pro.

* docs: add Git Workflow section to CONVENTIONS.md

Documents the PR-gated review process enforced at the agent layer.
Free-plan workaround since branch protection requires GitHub Pro.

* ci: add Claude-powered CTO PR review workflow

Auto-reviews every PR with Claude Haiku 4.5 and posts a structured
review (APPROVE / REQUEST_CHANGES / COMMENT). Does NOT auto-merge — a
human still clicks Merge. Requires ANTHROPIC_API_KEY secret on the repo.

* ci: switch CTO reviewer from Anthropic direct to OpenRouter

Reuses the OPENROUTER_API_KEY secret that already backs the other AI
plugins. Consolidates AI spend on one bill. Model: anthropic/claude-haiku-4.5.
2026-04-14 16:45:55 +07:00
38 changed files with 2546 additions and 2299 deletions

View File

@@ -0,0 +1,143 @@
# GENERATED by peptide-e2e/tools/gitea-pipeline/render.py from repos.json.
# Do not edit by hand: change repos.json or the template and re-render.
name: CI / Deploy
on:
push:
branches: [main]
pull_request: {}
workflow_dispatch: {}
concurrency:
group: pipeline-peptide-search-ai-${{ github.ref }}
cancel-in-progress: false
jobs:
lint-php:
name: PHP Lint (${{ matrix.php }})
runs-on: ubuntu-latest
container: git.protopeer.com/peptide-repo/ci-php:2026-10
strategy:
fail-fast: false
matrix:
php: ["8.1", "8.2", "8.3"]
steps:
- uses: actions/checkout@v4
- uses: https://git.protopeer.com/peptide-repo/peptide-e2e/.gitea/actions/ci-lint-php@main
with:
php: ${{ matrix.php }}
phpcs:
name: PHPCS (WordPress standards)
runs-on: ubuntu-latest
container: git.protopeer.com/peptide-repo/ci-php:2026-10
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref || github.ref_name }}
token: ${{ secrets.GITHUB_TOKEN }}
- uses: https://git.protopeer.com/peptide-repo/peptide-e2e/.gitea/actions/ci-phpcs@main
with:
autofix: 'true'
file-size:
name: 300-line rule (includes)
runs-on: ubuntu-latest
container: git.protopeer.com/peptide-repo/ci-php:2026-10
steps:
- uses: actions/checkout@v4
- uses: https://git.protopeer.com/peptide-repo/peptide-e2e/.gitea/actions/ci-file-size@main
with:
paths: includes
phpunit:
name: PHPUnit - stubs (PHP ${{ matrix.php }})
runs-on: ubuntu-latest
container: git.protopeer.com/peptide-repo/ci-php:2026-10
strategy:
fail-fast: false
matrix:
php: ["8.1", "8.2", "8.3"]
steps:
- uses: actions/checkout@v4
- uses: https://git.protopeer.com/peptide-repo/peptide-e2e/.gitea/actions/ci-phpunit@main
with:
php: ${{ matrix.php }}
tests: stubs
lint-js:
name: JS Lint
runs-on: ubuntu-latest
container: git.protopeer.com/peptide-repo/ci-php:2026-10
steps:
- uses: actions/checkout@v4
- uses: https://git.protopeer.com/peptide-repo/peptide-e2e/.gitea/actions/ci-lint-js@main
validate:
name: Validate PSA_VERSION constant
runs-on: ubuntu-latest
container: git.protopeer.com/peptide-repo/ci-php:2026-10
steps:
- uses: actions/checkout@v4
- run: |
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: $version"
else
echo "::error::PSA_VERSION not found"; exit 1
fi
deploy-staging:
name: Deploy to staging
needs: ["lint-php", "phpcs", "file-size", "phpunit", "lint-js", "validate"]
if: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' }}
runs-on: peptide-vps
steps:
- uses: actions/checkout@v4
- uses: https://git.protopeer.com/peptide-repo/peptide-e2e/.gitea/actions/deploy@main
with:
stage: staging
target_path: wp-content/plugins/peptide-search-ai
ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }}
ssh_host: ${{ secrets.SSH_HOST }}
ssh_port: ${{ secrets.SSH_PORT }}
ssh_username: ${{ secrets.SSH_USERNAME }}
smoke:
name: Smoke tests (staging)
needs: deploy-staging
runs-on: peptide-vps
timeout-minutes: 15
steps:
- uses: https://git.protopeer.com/peptide-repo/peptide-e2e/.gitea/actions/smoke@main
with:
wp_admin_user: ${{ secrets.STAGING_WP_ADMIN_USER }}
wp_admin_password: ${{ secrets.STAGING_WP_ADMIN_PASSWORD }}
deploy-production:
name: Deploy to production
# Smoke is a gate: production runs only if staging deploy AND smoke succeeded.
# PROD_DEPLOY_ENABLED (org variable) is the cutover switch / kill switch.
needs: [deploy-staging, smoke]
if: ${{ vars.PROD_DEPLOY_ENABLED == 'true' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' }}
runs-on: peptide-vps
steps:
- uses: actions/checkout@v4
- uses: https://git.protopeer.com/peptide-repo/peptide-e2e/.gitea/actions/deploy@main
with:
stage: production
target_path: wp-content/plugins/peptide-search-ai
ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }}
ssh_host: ${{ secrets.SSH_HOST }}
ssh_port: ${{ secrets.SSH_PORT }}
ssh_username: ${{ secrets.SSH_USERNAME }}
audit-direct-push:
name: Audit - commit came from a PR
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
runs-on: ubuntu-latest
container: git.protopeer.com/peptide-repo/ci-php:2026-10
steps:
- uses: https://git.protopeer.com/peptide-repo/peptide-e2e/.gitea/actions/audit-direct-push@main
with:
token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,91 +1,20 @@
name: CI — Lint & Test
name: CI
on:
pull_request: {}
push:
branches: [ main ]
pull_request:
branches: [ main ]
workflow_call:
branches: [main]
workflow_call: {}
# Required: reusable phpcs job declares contents: write for phpcbf auto-commit,
# but GitHub enforces that the caller must also declare it at workflow level.
permissions:
contents: read
contents: write
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
# ── PHPUnit Tests ────────────────────────────────────────────────
test-phpunit:
name: PHPUnit Tests (PHP ${{ 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 }}
tools: composer
- name: Install Composer dependencies
run: composer install --no-interaction --prefer-dist --no-progress
- name: Run PHPUnit tests
run: composer run test
# ── 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"
ci:
uses: peptide-repo/peptide-e2e/.github/workflows/ci.yml@main
with:
tests: stubs
has_js: true
secrets: inherit

View File

@@ -7,18 +7,16 @@ on:
permissions:
contents: write
# Cancel previous deploys if a new push arrives while one is running.
concurrency:
group: deploy-production
group: deploy-peptide-search-ai
cancel-in-progress: true
jobs:
# -- Run tests first --
test:
name: Pre-deploy tests
uses: ./.github/workflows/ci.yml
secrets: inherit
# -- Validate deployment readiness --
validate:
name: Validate deployment
runs-on: ubuntu-latest
@@ -27,248 +25,19 @@ jobs:
- 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"
echo "✓ PSA_VERSION: $version"
else
echo "❌ PSA_VERSION constant not found in peptide-search-ai.php"
exit 1
echo "❌ PSA_VERSION not found"; 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: HTTP health check
run: |
echo "Verifying site is accessible after deploy..."
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 30 "https://peptiderepo.com/")
if [ "$HTTP_STATUS" -eq 200 ]; then
echo "✓ Site returned HTTP $HTTP_STATUS"
else
echo "⚠ Site returned HTTP $HTTP_STATUS (expected 200)"
echo "The deploy completed but the site may need attention."
exit 1
fi
# Verify REST API endpoint is responding.
REST_STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 15 "https://peptiderepo.com/wp-json/peptides/v1/search?q=BPC-157")
if [ "$REST_STATUS" -eq 200 ]; then
echo "✓ REST API returned HTTP $REST_STATUS"
else
echo "⚠ REST API returned HTTP $REST_STATUS (expected 200)"
fi
- 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
name: Deploy (shared pipeline)
needs: validate
uses: peptide-repo/peptide-e2e/.github/workflows/deploy-app.yml@main
with:
target_path: wp-content/plugins/peptide-search-ai
secrets: inherit

72
.github/workflows/main-push-audit.yml vendored Normal file
View File

@@ -0,0 +1,72 @@
name: Audit — Direct push to main
# Tripwire: fires on every push to main and flags pushes that did not come
# from a merged PR. We run GitHub on the free plan, which means we can't use
# branch protection on private repos. This is the soft-enforcement layer.
#
# Behavior:
# - If the pushed commit is associated with a merged PR, exit quietly.
# - If not, open a GitHub issue tagging the pusher so it's visible.
#
# This never blocks the push. It's an audit, not a gate.
on:
push:
branches: [main]
permissions:
contents: read
issues: write
pull-requests: read
jobs:
audit:
name: Check commit came from PR
runs-on: ubuntu-latest
steps:
- name: Check for associated PR
uses: actions/github-script@v7
with:
script: |
const sha = context.sha;
// Squash-merge -> PR association is eventually-consistent; poll briefly
// before concluding this was a direct push (avoids false positives on merges).
async function findMergedPR() {
for (let attempt = 1; attempt <= 5; attempt++) {
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: sha,
});
const m = prs.find(p => p.merged_at);
if (m) return m;
if (attempt < 5) await new Promise(r => setTimeout(r, 10000));
}
return null;
}
const merged = await findMergedPR();
if (merged) {
core.info(`OK: commit ${sha.substring(0,7)} came from PR #${merged.number}`);
return;
}
const pusher = context.payload.pusher?.name || context.actor;
const msg = context.payload.head_commit?.message?.split('\n')[0] || '(no message)';
const body = [
`**Direct push to \`main\` detected** — commit was not associated with a merged PR.`,
``,
`- Commit: \`${sha.substring(0,7)}\` — ${msg}`,
`- Pusher: @${pusher}`,
`- Workflow run: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
``,
`The project policy is that all changes to \`main\` go through a pull request so they can be reviewed before deploying to production. If this push was intentional (hotfix, infra change) close this issue with a comment explaining why.`,
``,
`_This issue was opened automatically by \`.github/workflows/main-push-audit.yml\`._`,
].join('\n');
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `Direct push to main: ${sha.substring(0,7)}`,
body,
labels: ['policy-violation', 'audit'],
});
core.warning(`Opened audit issue for direct push ${sha.substring(0,7)}`);

View File

@@ -1,5 +1,7 @@
# Peptide Search AI — Architecture
> **Cross-app context:** decisions that affect multiple plugins (Cloudflare AI Gateway routing, OpenRouter account sharing, the interface pattern, image-generation stack, social distributor choice) are recorded in `Peptide Repo CTO/docs/engineering/decisions/`. The incident runbook for cross-app failure modes is at `Peptide Repo CTO/docs/engineering/INCIDENT-RUNBOOK.md`. Read both before making decisions that cross plugin boundaries.
## Overview
Peptide Search AI is a WordPress plugin that transforms sites into intelligent, self-growing peptide databases. Visitors search for peptides using an AJAX search form—if a result exists, it displays instantly; if not, the plugin automatically validates the peptide name via lightweight AI, creates a placeholder post, and queues comprehensive content generation in the background via WP-Cron. The generated entry includes molecular data, mechanism of action, research applications, safety profiles, and PubChem cross-reference enrichment. All functionality is non-blocking, respects rate limits, and supports multiple search instances on a single page.
@@ -8,8 +10,8 @@ Peptide Search AI is a WordPress plugin that transforms sites into intelligent,
```
peptide-search-ai/
├── peptide-search-ai.php Main plugin file; defines constants, hooks init/admin_init
├── uninstall.php Cleans up all peptide posts, meta, options, transients
├── peptide-search-ai.php Main plugin file; defines constants, hooks init/admin_init, gates CPT boot paths on PSA_Dependency_Check
├── uninstall.php Cleans up PSA-owned options, transients, and the `wp_psa_api_logs` table (v4.5.0+ does NOT touch peptide posts/terms — owned by PR Core)
├── readme.txt Plugin directory listing (WordPress.org metadata)
├── composer.json PHP dependencies; PSR-4 autoloader for includes/
├── .phpcs.xml.dist WordPress Coding Standards (WPCS) configuration
@@ -18,7 +20,8 @@ peptide-search-ai/
├── includes/
│ ├── class-psa-config.php Configuration constants (rate limits, timeouts, tokens, TTL)
│ ├── class-psa-encryption.php AES-256-CBC encryption for API keys at rest
│ ├── class-psa-post-type.php CPT registration, peptide_category taxonomy, meta field definitions
│ ├── class-psa-dependency-check.php PR Core >= 0.2.0 dependency gate + admin notice (v4.5.0)
│ ├── class-psa-post-type.php Admin columns + `psa_*` meta key definitions on the `peptide` CPT owned by PR Core
│ ├── class-psa-search.php AJAX search, REST API route, shortcode rendering, cache mgmt
│ ├── class-psa-directory.php Browsable directory: [peptide_directory] shortcode + /v1/compounds REST endpoint
│ ├── class-psa-ai-generator.php AI pipeline orchestrator (validation, generation, category assignment, meta save)
@@ -40,7 +43,7 @@ peptide-search-ai/
│ └── psa-directory.css Directory card grid, filter pills, modal styles, dark mode support
│
└── .github/workflows/
├── ci.yml PHP lint, PHPCS, JS syntax checks on PR/push
├── ci.yml Thin caller → peptide-e2e reusable CI (stubs, has_js:true); blocking PHPCS + 300-line rule
└── deploy.yml Pre-deploy validation, orphan deploy branch sync, SSH deploy
```
@@ -212,6 +215,9 @@ graph TD
### 1. Custom Post Type + Post Meta vs. Custom Tables
**Decision:** Use CPT (`post_type='peptide'`) with post meta fields (`psa_*`).
Registration moved from PSA to Peptide Repo Core (PR Core) in PSA v4.5.0 —
see the "Post types" section above. The CPT-vs-custom-tables choice itself
still holds; only ownership of the registration moved.
**Rationale:**
- **WordPress integration:** Leverage native post, user, revision, and permission systems.
@@ -272,6 +278,23 @@ graph TD
- PubChem cache: 7 days for hits, 12 hours for misses
- Rate limit counters: 1 hour per request window
## Post types
PSA no longer registers any post type. The `peptide` CPT and `peptide_category`
taxonomy are owned by Peptide Repo Core (PR Core). PSA depends on PR Core ≥ 0.2.0
for these to be available.
PSA still owns:
- Meta boxes attached to the `peptide` CPT (psa_peptide_data, psa_extended_data)
- The `psa_*` meta key namespace on peptide posts
- Search index, directory widget, KB renderer, search REST endpoints
Dependency is enforced at boot by `PSA_Dependency_Check::is_satisfied()` — when
PR Core < 0.2.0 or is absent, PSA renders an admin notice and all CPT-bound
surfaces (meta boxes, admin columns, directory, KB renderer, search, single-
peptide template) skip registration. Non-CPT surfaces (settings page, upgrade
driver) still boot so the admin can read the notice and configure the plugin.
## Data Storage
### Post Meta Fields (PSA_Post_Type::META_FIELDS)
@@ -305,13 +328,19 @@ All stored on peptide posts as post meta:
| `psa_cycle_parameters` | Cycle/protocol phase info | Text | AI (v4.3.0) |
| `psa_amino_acid_count` | Number of amino acids | Integer | Derived from sequence (v4.3.0) |
### Taxonomy: peptide_category (v4.3.0)
### Taxonomy: peptide_category
Hierarchical taxonomy registered on the `peptide` CPT. Pre-populated terms:
Tissue Repair, Lipid Metabolism, Aging Research, Dermatological, Metabolic, Growth Hormone, Immunology, Endocrine.
Hierarchical taxonomy on the `peptide` CPT. **Registered by PR Core** (>= 0.2.0)
as of PSA v4.5.0 — PSA no longer calls `register_taxonomy( 'peptide_category', … )`.
The 8 terms seeded by earlier PSA versions (Tissue Repair, Lipid Metabolism, Aging
Research, Dermatological, Metabolic, Growth Hormone, Immunology, Endocrine) stay
intact because `wp_term_taxonomy` is keyed on the taxonomy name, which is
unchanged.
Auto-assigned during AI generation via `PSA_AI_Generator::assign_category_term()`.
Migration for existing peptides via admin "Migrate Existing Peptides to Categories" button.
PSA continues to *read* and *write* term assignments on peptide posts — any
plugin can operate on a public taxonomy. `PSA_AI_Generator::assign_category_term()`
still auto-assigns during AI generation; the admin "Migrate Existing Peptides
to Categories" button still runs.
### Integration Hooks (v4.3.0)
@@ -356,6 +385,22 @@ $psa_settings = array(
);
```
### Schema versioning & automatic upgrades
The plugin stores the schema version it last migrated to in a separate option, `psa_db_version`. This is distinct from `PSA_VERSION` (the running code's semver), and lets the plugin detect whether the database needs to catch up after an in-place file update.
- **Option name:** `PSA_Upgrade::OPTION_NAME` (`psa_db_version`), string, semver.
- **Default:** `PSA_Upgrade::DEFAULT_STORED_VERSION` (`'0.0.0'`) when the option has never been written — strictly less than any released version so fresh installs always trigger the upgrade path.
- **Driver:** `PSA_Upgrade::maybe_run()` in `includes/class-psa-upgrade.php`, called on every `admin_init` from `psa_admin_init()`. The method:
1. Reads `psa_db_version`.
2. Calls the pure helper `PSA_Upgrade::is_needed( $stored, PSA_VERSION )`, a thin wrapper over `version_compare` (stored < current → upgrade).
3. If an upgrade is needed, re-runs `PSA_Cost_Tracker::create_table()` so `dbDelta` picks up any additive schema changes (new columns, new indexes).
4. Writes `update_option( 'psa_db_version', PSA_VERSION, false )` so the upgrade doesn't re-run on the next request.
- **Why on `admin_init`:** dbDelta is safe-to-repeat but not free. Running it once when an admin visits after an update — rather than on every frontend page load — keeps the cost near zero without requiring users to deactivate/reactivate the plugin.
- **Downgrade / rollback:** if the running code is older than `psa_db_version` (e.g., deploy rolled back), `is_needed()` returns false and the driver is a no-op. The newer schema is left intact; old code continues to work against it as long as changes are additive.
- **Cleanup:** `uninstall.php` deletes `psa_db_version` alongside the other plugin options.
- **Testability:** `PSA_Upgrade::is_needed()` is a pure static method (no WordPress API calls) and is covered by `tests/unit/UpgradeTest.php` across fresh-install, same-version, future-version, and older-version scenarios, plus constant-stability checks on `OPTION_NAME` and `DEFAULT_STORED_VERSION`.
## Frontend Architecture
### JavaScript (jQuery-based)
@@ -416,40 +461,4 @@ $psa_settings = array(
## Performance Optimizations
- **Post caching:** `_prime_post_caches()` and `update_meta_cache()` batch-load posts and meta (lines 458–459 in class-psa-search.php).
- **Transient caching:** Search results cached 6 hours; invalidated on post save.
- **Conditional asset loading:** CSS/JS only loaded on relevant pages.
- **Lazy PubChem:** Optional, can be disabled via setting.
- **Daily generation cap:** 50/day to limit API costs.
- **Separate validation model:** Fast/cheap model for upfront validation to reduce full-generation calls.
## Testing
**CI Pipeline:** `.github/workflows/ci.yml`
- PHPUnit tests across PHP 7.4, 8.1, 8.3
- PHP syntax checks across PHP 7.4, 8.1, 8.3
- WordPress Coding Standards (PHPCS) validation
- JavaScript syntax checks (Node.js)
**Post-Deploy:** `.github/workflows/deploy.yml`
- HTTP health check verifies site returns 200
- REST API smoke test verifies `/wp-json/peptides/v1/search?q=BPC-157` responds
**Manual Testing Checklist:**
- Search for existing peptide → results appear
- Search for new peptide (valid) → "pending" message → check back → content generated
- Search for invalid peptide → "not recognized" message
- Test rate limits (10/hour AJAX, 20/hour REST API)
- Verify PubChem data merges correctly
- Test auto-publish on/off
- Verify shortcode works multiple times on same page
- Test on mobile (600px breakpoint)
## Deployment
See `.github/workflows/deploy.yml`:
1. Push to main triggers CI tests.
2. If tests pass, validate deployment readiness (required files, no sensitive data).
3. Sync clean files to orphan `deploy` branch.
4. SSH to Hostinger, git pull `deploy` branch into plugin directory.
5. Verify deployment (check version, required files exist).
- **Post caching:** `_prime_post_caches()` and `update_meta_cache()` batch-load posts and meta (lines 458–4

View File

@@ -5,6 +5,63 @@ All notable changes to the Peptide Search AI plugin will be documented in this f
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Fixed
- `.github/workflows/ci.yml`: added `workflow_call: {}` trigger so `deploy.yml`'s `uses: ./.github/workflows/ci.yml` gate resolves correctly. No plugin code changed.
## [4.7.0] — 2026-06-16
### Changed
- `.github/workflows/ci.yml`: replaced per-repo CI jobs with thin caller for the reusable `peptiderepo/peptide-e2e/.github/workflows/ci.yml@main` (tests: stubs, has_js: true). Adds blocking PHPCS, 300-line file-size enforcement, and phpcbf auto-commit on PRs.
- `.github/workflows/deploy.yml`: dropped redundant file-existence and PHP-syntax checks now covered by the reusable CI; kept PSA_VERSION constant check.
- `includes/class-psa-admin.php`: removed dead backward-compat proxy methods (all delegated to `PSA_Admin_Page`; zero external callers). File reduced from 327 → 290 lines (estate 300-line rule compliance).
- `includes/class-psa-ai-generator.php`: removed untested backward-compat proxies (save_peptide_meta, assign_category_term); retained build_validation_prompt and build_generation_prompt as tested proxies (AIGeneratorTest uses reflection). Section headers condensed. File 309 → 299 lines.
- `includes/class-psa-search.php`: removed dead backward-compat proxy methods (all delegated to `PSA_Search_Handler`; zero external callers). File reduced from 307 → 294 lines.
- `peptide-search-ai.php`: synced `PSA_VERSION` constant (was stuck at 4.5.0) with plugin header (was 4.6.0); both now 4.7.0.
- `package.json` / `package-lock.json`: added minimal npm manifest (no dependencies) so the reusable `lint-js` job can run `npm ci` and fall back to `node --check` on the three JS asset files.
## [4.6.0] — 2026-04-26
### Changed
- `assets/css/peptide-search.css`: adopted brand tokens — replaced hardcoded `#2563eb` blue with `var(--teal-500)` family; font → Inter / IBM Plex Mono
- `assets/css/psa-directory.css`: same token adoption; active tabs/filters → teal; dark mode active states → teal
## [4.5.1] - 2026-04-23
### Changed
- Plugin header: author updated to `peptiderepo`, Plugin URI corrected to GitHub repo URL.
## [4.5.0] - 2026-04-22
### Changed (BREAKING)
- Dropped registration of the `peptide` custom post type and `peptide_category` taxonomy. These are now owned by Peptide Repo Core (PR Core) v0.2.0+, the canonical peptide data layer. Existing posts and term relationships are unaffected — they continue to work with PR Core's registration.
- PSA now has a hard dependency on PR Core ≥ 0.2.0. An admin notice appears if the dependency is not met; PSA features that operate on the `peptide` CPT (meta boxes, admin columns, directory widget, KB renderer, search widget, single-peptide template) degrade gracefully until PR Core is updated.
- `psa_activate()` no longer seeds default `peptide_category` terms — PR Core inherits the 8 existing terms via taxonomy-name keying in `wp_term_taxonomy`.
- `uninstall.php` no longer deletes `peptide` posts or `peptide_category` terms. Those entities are owned by PR Core as of this release; removing them on PSA uninstall would break cross-plugin contracts. PSA uninstall now only removes PSA's own options, transients, and the `wp_psa_api_logs` table.
### Added
- `PSA_Dependency_Check` class (`includes/class-psa-dependency-check.php`): verifies `PR_CORE_VERSION >= 0.2.0`, renders an admin notice when unmet, and is used as a boot-path gate by CPT-dependent PSA classes.
- PHPUnit coverage for `PSA_Dependency_Check::is_satisfied()` across undefined / 0.1.1 / 0.2.0 / 0.3.0 version scenarios plus a regression assertion that `PSA_Post_Type` no longer exposes `register_post_type` or `register_taxonomy` call paths.
### Removed
- `PSA_Post_Type::register_peptide_post_type()` method.
- `PSA_Post_Type::register_taxonomy()` method.
- `PSA_Post_Type::populate_default_categories()` method and its activation hook (seeding is a data-layer concern now owned by PR Core; the 8 existing terms remain in `wp_term_taxonomy`).
- Note: the `PSA_Post_Type::DEFAULT_CATEGORIES` constant is **retained** — PSA still consumes it as a slug→name reference list in `PSA_AI_Content::assign_category_term()` and the admin "Migrate Existing Peptides to Categories" button. Its role changed from "seed list" to "consumer lookup"; the value is unchanged.
### Unchanged
- Meta boxes (`psa_peptide_data`, `psa_extended_data`) and all `psa_*` meta keys on peptide posts.
- Directory shortcode `[peptide_directory]` and its `/wp-json/peptide-search-ai/v1/compounds` REST endpoint.
- Single-peptide template (`PSA_Template`) and KB article renderer (`PSA_KB_Builder`).
- Search widget, `/wp-json/peptides/v1/search` REST endpoint, and AJAX search handler.
- Cost tracker, API logs table schema, and the `psa_db_version` option.
### Migration notes
- No data migration required. The existing `peptide` posts and all `peptide_category` term assignments remain intact and functional — PR Core registers the same post-type name (`peptide`) and taxonomy name (`peptide_category`), so `wp_posts.post_type` and `wp_term_taxonomy.taxonomy` continue to resolve.
- After upgrading, ensure PR Core is at 0.2.0 or later — otherwise PSA surfaces an admin notice and degrades the peptide-dependent features until PR Core is updated.
- Deploy order (coordinated release): PR Core v0.2.0 ships first and restores the peptide detail pages on its own (PSA v4.4.3's existing `post_type_exists( 'peptide' )` guard no-ops its registration once PR Core claims the CPT). PSA v4.5.0 ships second as cleanup — no user-visible change at that point.
## [4.4.3] - 2026-04-14
### Fixed
@@ -63,77 +120,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- `PSA_AI_Generator` now delegates to `PSA_OpenRouter` and `PSA_KB_Builder` (reduced from 827 to ~470 lines).
- Updated test suite to call `PSA_OpenRouter::parse_response()` instead of removed private method.
- Updated `ARCHITECTURE.md` and `CONVENTIONS.md` to reflect new class structure.
- Bumped minimum PHP recommendation to 7.4+ with strict type enforcement.
### Removed
- Deprecated `PSA_Error` class and `class-psa-error.php` (unused, no callers).
## [4.1.0] - 2026-04-10
### Added
- AES-256-CBC encryption for API keys stored in the database (`PSA_Encryption` class).
- Cost/token tracking with `wp_psa_api_logs` custom table (`PSA_Cost_Tracker` class).
- Monthly budget system with hard-stop enforcement.
- Dry-run mode for cost estimation before API calls.
- Admin UI for API usage summary and recent call logs.
- Monthly budget configuration field in settings.
### Fixed
- PHPCS `PreparedSQL` errors in cost tracker queries.
## [4.0.0] - 2026-04-10
### Security
- **SEC-001 CRITICAL:** Removed spoofable `X-Forwarded-For` / `X-Real-IP` from IP detection; now trusts only `CF-Connecting-IP` + `REMOTE_ADDR`.
- **SEC-004 HIGH:** Added peptide name input validation with character whitelist and prompt injection blocking.
- **SEC-006 HIGH:** Added missing `return` after `wp_send_json_success` in pending retry max-retries block.
- **SEC-007 MEDIUM:** KB articles now respect the `auto_publish` setting instead of always publishing.
- **SEC-009 MEDIUM:** Replaced hardcoded page ID 73 with configurable `kb_page_id` setting.
- **SEC-010 MEDIUM:** Fixed cache invalidation transient name pattern (added underscore prefix).
- **SEC-011 MEDIUM:** Skipped `sleep()` during WP-Cron to avoid blocking PHP workers on rate limit retry.
- **SEC-012 MEDIUM:** Added global daily generation cap (50/day) to prevent cost overruns.
- **SEC-013 LOW:** Added `@deprecated` notice to unused `PSA_Error` class.
### Added
- PHPUnit test infrastructure with `bootstrap.php` and 20 unit tests across 2 test files.
- `ARCHITECTURE.md` and `CONVENTIONS.md` documentation.
### Fixed
- JavaScript typos breaking form submit and network error handling.
- CSS missing semicolons.
- PHPCS compliance: short ternaries, array formatting, Yoda conditions, tab indentation.
## [3.0.0] - 2026-04-09
### Added
- Dual-layer rate limiting (transient + object cache) for AJAX and REST endpoints.
- Cache generation counter for O(1) cache invalidation instead of expensive DELETE queries.
- PubChem data enrichment with molecular weight, formula, SMILES, and InChI.
- Background generation via WP-Cron (non-blocking).
- Retry logic with configurable max attempts for stale pending peptides.
- REST API endpoint (`/wp-json/peptides/v1/search`) for headless access.
- Multiple shortcode instances on the same page via `data-psa-instance` attribute.
### Changed
- Search results cached with generation counter for fast invalidation.
- Batch post/meta priming to avoid N+1 queries in search results.
## [2.0.0] - 2026-04-08
### Added
- AI-powered peptide name validation (lightweight model before full generation).
- OpenRouter API integration for multi-model support.
- Custom Post Type (`peptide`) with 15+ meta fields.
- Admin settings page for API key, model selection, and behavior configuration.
- Single peptide page template with quick-facts table and source badges.
- Echo Knowledge Base article auto-creation from generated content.
- CSS design system with `.psa-*` namespace and BEM-inspired structure.
## [1.0.0] - 2026-04-07
### Added
- Initial release.
- Basic peptide search shortcode.
- AJAX-powered search with debounced input.
- Responsive mobile layout (600px breakpoint).
- Updated

View File

@@ -705,3 +705,49 @@ wp_send_json_success(
| **Line Length** | <120 chars | Configure in `.phpcs.xml.dist` |
| **Escaping** | `esc_*()` always | `esc_html()`, `esc_attr()`, `wp_kses_post()` |
| **Sanitizing** | `sanitize_*()` always | `sanitize_text_field()`, `wp_unslash()` |
## Git Workflow — PR-Gated, Soft-Enforced
This repo is private on GitHub's free plan, which does not support branch protection or rulesets. The review gate is enforced at the agent layer, not server-side. Every agent and human contributor follows these rules; the `.github/workflows/main-push-audit.yml` tripwire opens an audit issue on any direct push to `main` that did not come from a merged PR.
### Rules
1. **Never push to `main` directly.** Every change lands via a pull request that the maintainer merges. No self-merging. No force-pushing to `main`.
2. **Branch naming**: `claude/<scope>-<YYYYMMDD>` for agent-authored work, `fix/<scope>` or `feat/<scope>` for human-authored. The scope is a 1–3 word kebab-case description.
3. **Commit trailer**: every commit authored by an agent must end with an `Agent-Session:` trailer so commits can be correlated back to the conversation that produced them, even though all commits share the `peptiderepo` bot identity.
```
feat: add per-request cost tracking
Agent-Session: cowork-2026-04-14-cost-audit
```
4. **PR description template**: every PR description covers
- **What changed** (one paragraph)
- **Why** (motivation or incident link)
- **Risk flags** (schema changes, API contract changes, cost impact, compatibility)
- **Test plan** (what was run locally, what to smoke-test after merge)
5. **Emergency push exception**: if a situation genuinely requires pushing to `main` without a PR (site down, CI broken, one-line hotfix), surface it in the chat before doing it. Every emergency push gets a follow-up PR that commits the same changes through the normal flow so git stays the source of truth. The tripwire will open an audit issue — close it with a comment explaining why.
### Opening a PR from an agent session
The `gh` CLI is not installed in the Cowork sandbox. Use `curl` with the PAT from the workspace `.env.credentials`:
```bash
GH_TOKEN=$(grep "^GITHUB_PAT=" "$WORKSPACE/.env.credentials" | cut -d= -f2)
git push -u origin HEAD
curl -s -X POST -H "Authorization: token $GH_TOKEN" \
-H "Content-Type: application/json" \
"https://api.github.com/repos/peptiderepo/<repo>/pulls" \
-d "$(jq -cn --arg title "<title>" --arg head "<branch>" --arg body "<body>" \
'{title:$title, head:$head, base:"main", body:$body}')"
```
### When this changes
If the peptiderepo GitHub account is ever upgraded to Pro (or the repo goes public), replace this soft-enforcement section with a note pointing to the real branch protection rules in repo settings.

View File

@@ -6,7 +6,7 @@
.psa-search-wrap {
max-width: 720px;
margin: 2rem auto;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.psa-search-form {
@@ -16,15 +16,15 @@
.psa-search-input-wrap {
display: flex;
gap: 0;
border: 2px solid #2563eb;
border: 2px solid var(--teal-500);
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);
border-color: var(--teal-600);
box-shadow: 0 0 0 3px rgba(27, 138, 146, 0.15);
}
.psa-search-input {
@@ -42,13 +42,13 @@
}
.psa-search-input:focus {
outline: 2px solid #2563eb;
outline: 2px solid var(--teal-500);
outline-offset: 2px;
}
.psa-search-btn {
padding: 14px 24px;
background: #2563eb;
background: var(--teal-500);
color: #fff;
border: none;
font-size: 15px;
@@ -59,7 +59,7 @@
}
.psa-search-btn:hover {
background: #1d4ed8;
background: var(--teal-600);
}
.psa-search-btn:focus-visible {
@@ -95,8 +95,8 @@
}
.psa-result-item:hover {
border-color: #2563eb;
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.1);
border-color: var(--teal-500);
box-shadow: 0 2px 8px rgba(27, 138, 146, 0.1);
}
.psa-result-title {
@@ -146,8 +146,8 @@
}
.psa-badge-manual {
background: #dbeafe;
color: #1e40af;
background: var(--teal-100);
color: var(--teal-700);
}
/* --- Checking / Loading State --- */
@@ -174,8 +174,8 @@
display: inline-block;
width: 36px;
height: 36px;
border: 4px solid #bfdbfe;
border-top-color: #2563eb;
border: 4px solid var(--teal-100);
border-top-color: var(--teal-500);
border-radius: 50%;
animation: psa-spin 0.8s linear infinite;
}
@@ -192,8 +192,8 @@
.psa-pending-inner {
text-align: center;
padding: 40px 32px;
background: linear-gradient(135deg, #eff6ff 0%, #f0f9ff 100%);
border: 1px solid #bfdbfe;
background: linear-gradient(135deg, var(--teal-50) 0%, var(--teal-50) 100%);
border: 1px solid var(--teal-100);
border-radius: 16px;
}
@@ -206,14 +206,14 @@
.psa-pending-inner h3 {
margin: 0 0 12px;
font-size: 21px;
color: #1e40af;
color: var(--teal-700);
font-weight: 700;
}
.psa-pending-inner > p {
margin: 0 0 28px;
font-size: 15px;
color: #3b82f6;
color: var(--teal-400);
max-width: 500px;
margin-left: auto;
margin-right: auto;
@@ -312,7 +312,7 @@
}
.psa-sequence {
font-family: "SF Mono", "Fira Code", "Fira Mono", Menlo, Consolas, monospace;
font-family: "IBM Plex Mono", "Fira Code", Menlo, Consolas, monospace;
font-size: 13px;
word-break: break-all;
background: #f1f5f9;
@@ -330,7 +330,7 @@
color: #1e293b;
margin: 0 0 12px;
padding-bottom: 8px;
border-bottom: 2px solid #2563eb;
border-bottom: 2px solid var(--teal-500);
}
.psa-section-content {
@@ -360,9 +360,9 @@
}
.psa-source-badge.psa-badge-manual {
background: #eff6ff;
background: var(--teal-50);
border: 1px solid #93c5fd;
color: #1e40af;
color: var(--teal-700);
}
/* --- Responsive --- */

View File

@@ -28,7 +28,7 @@
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1rem;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
/* --- Header --- */
@@ -68,8 +68,8 @@
}
.psa-dir-search:focus {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
border-color: var(--teal-500);
box-shadow: 0 0 0 3px rgba(27, 138, 146, 0.15);
}
/* --- Tabs --- */
@@ -97,9 +97,9 @@
}
.psa-dir-tab--active {
background: #2563eb;
background: var(--teal-500);
color: #fff;
border-color: #2563eb;
border-color: var(--teal-500);
}
/* --- Category Filters --- */
@@ -124,14 +124,14 @@
}
.psa-dir-filter:hover {
border-color: #2563eb;
color: #2563eb;
border-color: var(--teal-500);
color: var(--teal-500);
}
.psa-dir-filter--active {
background: #2563eb;
background: var(--teal-500);
color: #fff;
border-color: #2563eb;
border-color: var(--teal-500);
}
/* --- Grid --- */
@@ -153,8 +153,8 @@
}
.psa-dir-card:hover {
border-color: #2563eb;
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.08);
border-color: var(--teal-500);
box-shadow: 0 4px 12px rgba(27, 138, 146, 0.08);
}
.psa-dir-card__header {
@@ -207,7 +207,7 @@
padding: 10px;
font-size: 14px;
font-weight: 600;
background: #2563eb;
background: var(--teal-500);
color: #fff;
border: none;
border-radius: 8px;
@@ -217,7 +217,7 @@
}
.psa-dir-card__btn:hover {
background: #1d4ed8;
background: var(--teal-600);
}
/* --- Category Badge Colors --- */
@@ -261,15 +261,15 @@
font-size: 15px;
font-weight: 600;
background: #fff;
color: #2563eb;
border: 2px solid #2563eb;
color: var(--teal-500);
border: 2px solid var(--teal-500);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.psa-dir-load-more:hover {
background: #2563eb;
background: var(--teal-500);
color: #fff;
}
@@ -380,7 +380,7 @@
padding: 4px 12px;
font-size: 12px;
font-weight: 600;
background: #2563eb;
background: var(--teal-500);
color: #fff;
border: none;
border-radius: 4px;
@@ -389,7 +389,7 @@
}
.psa-dir-modal__copy:hover {
background: #1d4ed8;
background: var(--teal-600);
}
/* --- Data Grid --- */
@@ -435,7 +435,7 @@
color: #1e293b;
margin: 0 0 0.5rem;
padding-bottom: 6px;
border-bottom: 2px solid #2563eb;
border-bottom: 2px solid var(--teal-500);
}
.psa-dir-modal__section p {
@@ -449,7 +449,7 @@
.psa-dir-modal__pubchem {
display: inline-block;
font-size: 13px;
color: #2563eb;
color: var(--teal-500);
text-decoration: none;
margin-bottom: 1rem;
}
@@ -465,7 +465,7 @@
font-size: 14px;
font-weight: 600;
background: #f1f5f9;
color: #2563eb;
color: var(--teal-500);
text-decoration: none;
border-radius: 8px;
transition: background 0.2s;
@@ -546,7 +546,7 @@
}
[data-theme="dark"] .psa-dir-tab--active {
background: #2563eb;
background: var(--teal-500);
color: #fff;
}
@@ -557,7 +557,7 @@
}
[data-theme="dark"] .psa-dir-filter--active {
background: #2563eb;
background: var(--teal-500);
color: #fff;
}

View File

@@ -0,0 +1,230 @@
<?php
/**
* Admin page rendering for Peptide Search AI settings.
*
* What: Settings page HTML, field renderers, usage summary, batch enrich scripts.
* Who calls it: PSA_Admin::add_menu_pages() registers render_settings_page as page callback.
* Dependencies: PSA_Config, PSA_Cost_Tracker, PSA_Encryption.
*
* @package PeptideSearchAI
* @since 2.6.0
* @see includes/class-psa-admin.php — Registration, sanitization, migration tools.
*/
declare( strict_types=1 );
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class PSA_Admin_Page {
/**
* Enqueue batch enrichment script on the PSA settings page only.
*
* @param string $hook Admin page hook suffix.
*/
public static function enqueue_batch_scripts( string $hook ): void {
if ( 'settings_page_peptide-search-ai' !== $hook ) {
return;
}
wp_enqueue_script( 'psa-batch-enrich', PSA_PLUGIN_URL . 'assets/js/psa-batch-enrich.js', array( 'jquery' ), PSA_VERSION, true );
wp_localize_script(
'psa-batch-enrich',
'psaBatchEnrich',
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'psa_batch_enrich' ),
)
);
}
// ── Field renderers ─────────────────────────────────────────────────
public static function render_section_description(): void {
printf(
'<p>' . __( 'This plugin uses <a href="%s" target="_blank" rel="noopener">OpenRouter</a> to access AI models.', 'peptide-search-ai' ) . '</p>',
esc_url( 'https://openrouter.ai/' )
);
}
public static function render_api_key_field(): void {
if ( defined( 'PSA_OPENROUTER_KEY' ) && PSA_OPENROUTER_KEY ) {
echo '<span class="dashicons dashicons-yes-alt" style="color:#46b450;"></span> ';
echo '<strong>' . esc_html__( 'Configured via wp-config.php', 'peptide-search-ai' ) . '</strong>';
echo '<p class="description">' . esc_html__( 'The API key is securely set using the PSA_OPENROUTER_KEY constant.', 'peptide-search-ai' ) . '</p>';
} else {
$value = PSA_Admin::get_setting( 'api_key', '' );
$display_value = ! empty( $value ) ? '••••••••' : '';
printf(
'<input type="password" name="psa_settings[api_key]" value="%s" class="regular-text" autocomplete="off" />',
esc_attr( $display_value )
);
printf(
'<p class="description">%s <a href="https://openrouter.ai/settings/keys" target="_blank" rel="noopener">%s</a>.</p>',
esc_html__( 'Your OpenRouter API key.', 'peptide-search-ai' ),
esc_html__( 'Get one here', 'peptide-search-ai' )
);
}
}
public static function render_model_field(): void {
$value = PSA_Admin::get_setting( 'ai_model', '' );
printf(
'<input type="text" name="psa_settings[ai_model]" value="%s" class="regular-text" placeholder="%s" />',
esc_attr( $value ),
esc_attr__( 'Leave blank for default', 'peptide-search-ai' )
);
echo '<p class="description">' . esc_html__( 'Model for full content generation. Default: google/gemini-2.5-flash', 'peptide-search-ai' ) . '</p>';
}
public static function render_validation_model_field(): void {
$value = PSA_Admin::get_setting( 'validation_model', '' );
printf(
'<input type="text" name="psa_settings[validation_model]" value="%s" class="regular-text" placeholder="%s" />',
esc_attr( $value ),
esc_attr__( 'Leave blank for default', 'peptide-search-ai' )
);
echo '<p class="description">' . esc_html__( 'Lighter model for name validation. Default: google/gemini-2.0-flash-001', 'peptide-search-ai' ) . '</p>';
}
public static function render_publish_field(): void {
$value = PSA_Admin::get_setting( 'auto_publish', 'draft' );
echo '<select name="psa_settings[auto_publish]">';
printf( '<option value="draft" %s>%s</option>', selected( $value, 'draft', false ), esc_html__( 'Save as Draft', 'peptide-search-ai' ) );
printf( '<option value="publish" %s>%s</option>', selected( $value, 'publish', false ), esc_html__( 'Publish Immediately', 'peptide-search-ai' ) );
echo '</select>';
}
public static function render_pubchem_field(): void {
$value = PSA_Admin::get_setting( 'use_pubchem', '1' );
printf(
'<label><input type="checkbox" name="psa_settings[use_pubchem]" value="1" %s /> %s</label>',
checked( $value, '1', false ),
esc_html__( 'Cross-reference with PubChem for verified molecular data', 'peptide-search-ai' )
);
}
public static function render_budget_field(): void {
$value = PSA_Admin::get_setting( 'monthly_budget', PSA_Config::DEFAULT_MONTHLY_BUDGET );
printf(
'<input type="number" name="psa_settings[monthly_budget]" value="%s" step="0.01" min="0" />',
esc_attr( $value )
);
echo '<p class="description">' . esc_html__( 'Set to 0 for unlimited.', 'peptide-search-ai' ) . '</p>';
}
// ── Settings page ───────────────────────────────────────────────────
public static function render_settings_page(): void {
$migrate_url = wp_nonce_url( admin_url( 'options-general.php?page=peptide-search-ai&psa_action=migrate_categories' ), 'psa_migrate_categories' );
?>
<div class="wrap">
<h1><?php esc_html_e( 'Peptide Search AI Settings', 'peptide-search-ai' ); ?></h1>
<form method="post" action="options.php">
<?php
settings_fields( 'psa_settings_group' );
do_settings_sections( 'peptide-search-ai' );
submit_button();
?>
</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( 'Data Tools', 'peptide-search-ai' ); ?></h2>
<table class="form-table">
<tr>
<th><?php esc_html_e( 'Assign Categories', 'peptide-search-ai' ); ?></th>
<td>
<a href="<?php echo esc_url( $migrate_url ); ?>" class="button"><?php esc_html_e( 'Migrate Existing Peptides to Categories', 'peptide-search-ai' ); ?></a>
<p class="description"><?php esc_html_e( 'Reads AI-generated category data from existing peptides and assigns taxonomy terms. Safe to run multiple times.', 'peptide-search-ai' ); ?></p>
</td>
</tr>
<tr>
<th><?php esc_html_e( 'Re-enrich Data', 'peptide-search-ai' ); ?></th>
<td>
<button type="button" id="psa-batch-enrich-start" class="button button-primary"><?php esc_html_e( 'Re-enrich Peptides', 'peptide-search-ai' ); ?></button>
<span id="psa-batch-enrich-status" style="margin-left:10px;color:#666;"></span>
<p class="description"><?php esc_html_e( 'Processes one peptide at a time via AI — no WP-Cron dependency. Respects daily generation cap.', 'peptide-search-ai' ); ?></p>
<div id="psa-batch-enrich-progress" style="display:none;margin-top:10px;max-width:500px;">
<div style="background:#e0e0e0;border-radius:3px;height:20px;overflow:hidden;">
<div id="psa-batch-enrich-bar" style="background:#0073aa;height:100%;width:0%;transition:width 0.3s;"></div>
</div>
<span id="psa-batch-enrich-pct" style="font-size:12px;color:#666;"></span>
</div>
<div id="psa-batch-enrich-log" style="display:none;margin-top:10px;max-height:200px;overflow-y:auto;padding:8px;background:#f9f9f9;border:1px solid #ddd;border-radius:3px;max-width:500px;font-family:monospace;font-size:12px;"></div>
</td>
</tr>
</table>
<hr />
<h2><?php esc_html_e( 'Shortcodes', 'peptide-search-ai' ); ?></h2>
<p><code>[peptide_search]</code> — <?php esc_html_e( 'Search form with auto-generation', 'peptide-search-ai' ); ?></p>
<p><code>[peptide_directory]</code> — <?php esc_html_e( 'Browsable directory grid with filters and detail modal', 'peptide-search-ai' ); ?></p>
<hr />
<h2><?php esc_html_e( 'REST API', 'peptide-search-ai' ); ?></h2>
<p><code>GET /wp-json/peptides/v1/search?q=BPC-157</code> — <?php esc_html_e( 'Search (published only)', 'peptide-search-ai' ); ?></p>
<p><code>GET /wp-json/peptide-search-ai/v1/compounds?search=&amp;category=&amp;page=1</code> — <?php esc_html_e( 'Directory listing', 'peptide-search-ai' ); ?></p>
</div>
<?php
}
/** Render the API usage summary section with recent call log. */
private static function render_usage_summary(): void {
$current_spend = PSA_Cost_Tracker::get_monthly_spend();
$current_tokens = PSA_Cost_Tracker::get_monthly_tokens();
$estimated_count = PSA_Cost_Tracker::get_monthly_estimated_count();
$budget = floatval( PSA_Admin::get_setting( 'monthly_budget', PSA_Config::DEFAULT_MONTHLY_BUDGET ) );
$budget_remaining = ( 0.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 ) ); ?>
<?php if ( $estimated_count > 0 ) : ?>
<span style="color:#b45309;font-size:12px;" title="<?php echo esc_attr( $estimated_count ); ?> API calls used character-based token estimates">&#9888; includes estimates</span>
<?php endif; ?>
<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 ) : ?>
<?php $is_estimated = ( isset( $log->token_source ) && 'estimated' === $log->token_source ); ?>
<tr<?php echo $is_estimated ? ' style="color:#92400e;"' : ''; ?>>
<td><?php echo esc_html( $log->created_at ); ?></td>
<td><code><?php echo esc_html( $log->model ); ?></code></td>
<td><?php echo $is_estimated ? '<span title="Estimated">~' : ''; ?><?php echo esc_html( number_format( $log->total_tokens ) ); ?><?php echo $is_estimated ? '</span>' : ''; ?></td>
<td><?php echo $is_estimated ? '<span title="Estimated">~$' : '$'; ?><?php echo esc_html( number_format( $log->estimated_cost_usd, 4 ) ); ?><?php echo $is_estimated ? '</span>' : ''; ?></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 ) ? '&#10003;' : '&#10007;'; ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php
endif;
}
}

View File

@@ -1,15 +1,16 @@
<?php
/**
* Admin settings page for Peptide Search AI.
* Admin settings registration, sanitization, and data tools for Peptide Search AI.
*
* What: Registers settings page, fields, sanitization, usage dashboard, migration tools.
* What: Settings registration, sanitization, admin action handlers, migration, re-enrichment.
* Who calls it: WordPress admin_menu and admin_init hooks via PSA_Admin::init().
* Dependencies: PSA_Encryption (encrypt API keys), PSA_Cost_Tracker (usage display), PSA_Config, PSA_Post_Type.
* Dependencies: PSA_Encryption, PSA_Config, PSA_Post_Type, PSA_Admin_Page (rendering).
*
* @package PeptideSearchAI
* @since 2.6.0
* @see includes/class-psa-admin-page.php — Settings page rendering and field renderers.
* @see includes/class-psa-encryption.php
* @see includes/class-psa-cost-tracker.php
* @see includes/class-psa-post-type.php
*/
declare( strict_types=1 );
@@ -23,25 +24,21 @@ class PSA_Admin {
add_action( 'admin_menu', array( __CLASS__, 'add_menu_pages' ) );
add_action( 'admin_init', array( __CLASS__, 'register_settings' ) );
add_action( 'admin_init', array( __CLASS__, 'handle_admin_actions' ) );
add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_batch_scripts' ) );
add_action( 'admin_enqueue_scripts', array( 'PSA_Admin_Page', 'enqueue_batch_scripts' ) );
}
/**
* Add settings page under Settings menu.
*/
/** Add settings page under Settings menu. */
public static function add_menu_pages(): void {
add_options_page(
__( 'Peptide Search AI', 'peptide-search-ai' ),
__( 'Peptide Search AI', 'peptide-search-ai' ),
'manage_options',
'peptide-search-ai',
array( __CLASS__, 'render_settings_page' )
array( 'PSA_Admin_Page', 'render_settings_page' )
);
}
/**
* Register settings fields.
*/
/** Register settings fields — rendering delegated to PSA_Admin_Page. */
public static function register_settings(): void {
register_setting(
'psa_settings_group',
@@ -51,23 +48,21 @@ class PSA_Admin {
)
);
// OpenRouter Section.
add_settings_section( 'psa_ai_section', __( 'OpenRouter API Settings', 'peptide-search-ai' ), array( __CLASS__, 'render_section_description' ), 'peptide-search-ai' );
add_settings_field( 'api_key', __( 'API Key', 'peptide-search-ai' ), array( __CLASS__, 'render_api_key_field' ), 'peptide-search-ai', 'psa_ai_section' );
add_settings_field( 'ai_model', __( 'Generation Model', 'peptide-search-ai' ), array( __CLASS__, 'render_model_field' ), 'peptide-search-ai', 'psa_ai_section' );
add_settings_field( 'validation_model', __( 'Validation Model', 'peptide-search-ai' ), array( __CLASS__, 'render_validation_model_field' ), 'peptide-search-ai', 'psa_ai_section' );
add_settings_section( 'psa_ai_section', __( 'OpenRouter API Settings', 'peptide-search-ai' ), array( 'PSA_Admin_Page', 'render_section_description' ), 'peptide-search-ai' );
add_settings_field( 'api_key', __( 'API Key', 'peptide-search-ai' ), array( 'PSA_Admin_Page', 'render_api_key_field' ), 'peptide-search-ai', 'psa_ai_section' );
add_settings_field( 'ai_model', __( 'Generation Model', 'peptide-search-ai' ), array( 'PSA_Admin_Page', 'render_model_field' ), 'peptide-search-ai', 'psa_ai_section' );
add_settings_field( 'validation_model', __( 'Validation Model', 'peptide-search-ai' ), array( 'PSA_Admin_Page', 'render_validation_model_field' ), 'peptide-search-ai', 'psa_ai_section' );
// Behavior Section.
add_settings_section( 'psa_behavior_section', __( 'Behavior Settings', 'peptide-search-ai' ), null, 'peptide-search-ai' );
add_settings_field( 'auto_publish', __( 'Auto-Publish', 'peptide-search-ai' ), array( __CLASS__, 'render_publish_field' ), 'peptide-search-ai', 'psa_behavior_section' );
add_settings_field( 'use_pubchem', __( 'PubChem Enrichment', 'peptide-search-ai' ), array( __CLASS__, 'render_pubchem_field' ), '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' );
add_settings_field( 'auto_publish', __( 'Auto-Publish', 'peptide-search-ai' ), array( 'PSA_Admin_Page', 'render_publish_field' ), 'peptide-search-ai', 'psa_behavior_section' );
add_settings_field( 'use_pubchem', __( 'PubChem Enrichment', 'peptide-search-ai' ), array( 'PSA_Admin_Page', 'render_pubchem_field' ), 'peptide-search-ai', 'psa_behavior_section' );
add_settings_field( 'monthly_budget', __( 'Monthly API Budget (USD)', 'peptide-search-ai' ), array( 'PSA_Admin_Page', 'render_budget_field' ), 'peptide-search-ai', 'psa_behavior_section' );
}
/**
* Sanitize settings input.
*
* @param array $input Raw input from the form.
* @param array $input Raw form input.
* @return array Sanitized settings.
*/
public static function sanitize_settings( array $input ): array {
@@ -79,23 +74,20 @@ class PSA_Admin {
$encrypted = PSA_Encryption::encrypt( $sanitized );
$api_key_value = ( false !== $encrypted ) ? $encrypted : '';
} else {
$existing = self::get_setting( 'api_key', '' );
$api_key_value = $existing;
$api_key_value = self::get_setting( 'api_key', '' );
}
return array(
'api_key' => $api_key_value,
'ai_model' => sanitize_text_field( $input['ai_model'] ?? '' ),
'validation_model' => sanitize_text_field( $input['validation_model'] ?? '' ),
'auto_publish' => sanitize_text_field( $input['auto_publish'] ?? 'draft' ),
'use_pubchem' => ! empty( $input['use_pubchem'] ) ? '1' : '0',
'monthly_budget' => max( 0, floatval( $input['monthly_budget'] ?? PSA_Config::DEFAULT_MONTHLY_BUDGET ) ),
'api_key' => $api_key_value,
'ai_model' => sanitize_text_field( $input['ai_model'] ?? '' ),
'validation_model' => sanitize_text_field( $input['validation_model'] ?? '' ),
'auto_publish' => sanitize_text_field( $input['auto_publish'] ?? 'draft' ),
'use_pubchem' => ! empty( $input['use_pubchem'] ) ? '1' : '0',
'monthly_budget' => max( 0, floatval( $input['monthly_budget'] ?? PSA_Config::DEFAULT_MONTHLY_BUDGET ) ),
);
}
/**
* Handle admin actions (migration, re-enrich) via GET params with nonce verification.
*/
/** Handle admin actions (migration, re-enrich) via GET params with nonce verification. */
public static function handle_admin_actions(): void {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( ! isset( $_GET['psa_action'] ) || ! current_user_can( 'manage_options' ) ) {
@@ -122,7 +114,6 @@ class PSA_Admin {
function () use ( $queued ) {
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html(
sprintf(
/* translators: %d = number of peptides queued */
__( '%d peptides queued for re-enrichment.', 'peptide-search-ai' ),
$queued
)
@@ -132,15 +123,11 @@ class PSA_Admin {
}
}
// -------------------------------------------------------------------------
// Migration: assign categories to existing peptides
// -------------------------------------------------------------------------
// ── Category migration ──────────────────────────────────────────────
/**
* One-time migration: assign peptide_category terms to existing peptide posts.
*
* Reads the free-text psa_category meta from each published peptide and maps
* it to a taxonomy term. Safe to run multiple times (idempotent).
* Assign peptide_category terms to existing posts from free-text meta.
* Idempotent — safe to run multiple times.
*/
private static function run_category_migration(): void {
$posts = get_posts(
@@ -153,7 +140,6 @@ class PSA_Admin {
);
foreach ( $posts as $post_id ) {
// Skip posts that already have a category term assigned.
$terms = wp_get_object_terms( $post_id, 'peptide_category' );
if ( ! is_wp_error( $terms ) && ! empty( $terms ) ) {
continue;
@@ -167,7 +153,6 @@ class PSA_Admin {
continue;
}
// Use the same mapping logic as the AI generator.
$slug = self::map_category_to_slug( $text );
if ( ! empty( $slug ) ) {
wp_set_object_terms( $post_id, $slug, 'peptide_category' );
@@ -210,7 +195,7 @@ class PSA_Admin {
return $map[ $lower ];
}
// Fuzzy match.
// Fuzzy match against registered default categories.
foreach ( PSA_Post_Type::DEFAULT_CATEGORIES as $slug => $name ) {
if ( stripos( $text, $name ) !== false || stripos( $text, str_replace( '-', ' ', $slug ) ) !== false ) {
return $slug;
@@ -220,14 +205,10 @@ class PSA_Admin {
return '';
}
// -------------------------------------------------------------------------
// Re-enrichment: queue background regeneration for posts missing new fields
// -------------------------------------------------------------------------
// ── Re-enrichment ───────────────────────────────────────────────────
/**
* Queue re-enrichment for published peptides missing the new extended fields.
*
* Schedules WP-Cron events for each peptide that needs regeneration.
* Queue re-enrichment for published peptides missing extended fields.
* Respects the daily generation cap to avoid cost overruns.
*
* @return int Number of peptides queued.
@@ -239,7 +220,6 @@ class PSA_Admin {
'post_status' => 'publish',
'posts_per_page' => -1,
'fields' => 'ids',
// Only select posts missing the half_life field (proxy for "needs extended data").
'meta_query' => array(
'relation' => 'OR',
array(
@@ -259,7 +239,6 @@ class PSA_Admin {
$queued = 0;
foreach ( $posts as $post_id ) {
// Respect daily cap.
if ( ( $daily_count + $queued ) >= PSA_Config::DAILY_GENERATION_CAP ) {
break;
}
@@ -269,7 +248,6 @@ class PSA_Admin {
continue;
}
// Mark as pending so the generator picks it up, then schedule.
update_post_meta( $post_id, 'psa_source', 'pending' );
wp_update_post(
array(
@@ -281,17 +259,13 @@ class PSA_Admin {
$args = array( $post_id, $post->post_title );
if ( ! wp_next_scheduled( 'psa_generate_peptide_background', $args ) ) {
// Stagger jobs 30 seconds apart to avoid rate limit hammering.
// Stagger 30s apart to avoid rate-limit hammering.
wp_schedule_single_event( time() + ( $queued * 30 ), 'psa_generate_peptide_background', $args );
}
$queued++;
++$queued;
}
// Bump daily counter.
set_transient( $daily_key, $daily_count + $queued, DAY_IN_SECONDS );
// Kick off the cron runner.
if ( $queued > 0 ) {
spawn_cron();
}
@@ -299,261 +273,16 @@ class PSA_Admin {
return $queued;
}
/**
* Enqueue batch enrichment script on the PSA settings page only.
*
* @param string $hook The admin page hook suffix.
*/
public static function enqueue_batch_scripts( string $hook ): void {
if ( 'settings_page_peptide-search-ai' !== $hook ) {
return;
}
wp_enqueue_script(
'psa-batch-enrich',
PSA_PLUGIN_URL . 'assets/js/psa-batch-enrich.js',
array( 'jquery' ),
PSA_VERSION,
true
);
wp_localize_script(
'psa-batch-enrich',
'psaBatchEnrich',
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'psa_batch_enrich' ),
)
);
}
// -------------------------------------------------------------------------
// Field renderers
// -------------------------------------------------------------------------
public static function render_section_description(): void {
?>
<p>
<?php
printf(
/* translators: %s = OpenRouter URL */
__( 'This plugin uses <a href="%s" target="_blank" rel="noopener">OpenRouter</a> to access AI models.', 'peptide-search-ai' ),
esc_url( 'https://openrouter.ai/' )
);
?>
</p>
<?php
}
public static function render_api_key_field(): void {
if ( defined( 'PSA_OPENROUTER_KEY' ) && PSA_OPENROUTER_KEY ) {
?>
<span class="dashicons dashicons-yes-alt" style="color:#46b450;"></span>
<strong><?php esc_html_e( 'Configured via wp-config.php', 'peptide-search-ai' ); ?></strong>
<p class="description"><?php esc_html_e( 'The API key is securely set using the PSA_OPENROUTER_KEY constant.', 'peptide-search-ai' ); ?></p>
<?php
} else {
$value = self::get_setting( 'api_key', '' );
$display_value = ! empty( $value ) ? '••••••••' : '';
?>
<input type="password" name="psa_settings[api_key]" value="<?php echo esc_attr( $display_value ); ?>" class="regular-text" autocomplete="off" />
<p class="description">
<?php esc_html_e( 'Your OpenRouter API key.', 'peptide-search-ai' ); ?>
<a href="https://openrouter.ai/settings/keys" target="_blank" rel="noopener"><?php esc_html_e( 'Get one here', 'peptide-search-ai' ); ?></a>.
</p>
<?php
}
}
public static function render_model_field(): void {
$value = self::get_setting( 'ai_model', '' );
?>
<input type="text" name="psa_settings[ai_model]" value="<?php echo esc_attr( $value ); ?>" class="regular-text" placeholder="<?php esc_attr_e( 'Leave blank for default', 'peptide-search-ai' ); ?>" />
<p class="description"><?php esc_html_e( 'Model for full content generation. Default: google/gemini-2.5-flash', 'peptide-search-ai' ); ?></p>
<?php
}
public static function render_validation_model_field(): void {
$value = self::get_setting( 'validation_model', '' );
?>
<input type="text" name="psa_settings[validation_model]" value="<?php echo esc_attr( $value ); ?>" class="regular-text" placeholder="<?php esc_attr_e( 'Leave blank for default', 'peptide-search-ai' ); ?>" />
<p class="description"><?php esc_html_e( 'Lighter model for name validation. Default: google/gemini-2.0-flash-001', 'peptide-search-ai' ); ?></p>
<?php
}
public static function render_publish_field(): void {
$value = self::get_setting( 'auto_publish', 'draft' );
?>
<select name="psa_settings[auto_publish]">
<option value="draft" <?php selected( $value, 'draft' ); ?>><?php esc_html_e( 'Save as Draft', 'peptide-search-ai' ); ?></option>
<option value="publish" <?php selected( $value, 'publish' ); ?>><?php esc_html_e( 'Publish Immediately', 'peptide-search-ai' ); ?></option>
</select>
<?php
}
public static function render_pubchem_field(): void {
$value = self::get_setting( 'use_pubchem', '1' );
?>
<label>
<input type="checkbox" name="psa_settings[use_pubchem]" value="1" <?php checked( $value, '1' ); ?> />
<?php esc_html_e( 'Cross-reference with PubChem for verified molecular data', 'peptide-search-ai' ); ?>
</label>
<?php
}
public static function render_budget_field(): void {
$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.', 'peptide-search-ai' ); ?></p>
<?php
}
// -------------------------------------------------------------------------
// Settings page
// -------------------------------------------------------------------------
public static function render_settings_page(): void {
$migrate_url = wp_nonce_url( admin_url( 'options-general.php?page=peptide-search-ai&psa_action=migrate_categories' ), 'psa_migrate_categories' );
?>
<div class="wrap">
<h1><?php esc_html_e( 'Peptide Search AI Settings', 'peptide-search-ai' ); ?></h1>
<form method="post" action="options.php">
<?php
settings_fields( 'psa_settings_group' );
do_settings_sections( 'peptide-search-ai' );
submit_button();
?>
</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( 'Data Tools', 'peptide-search-ai' ); ?></h2>
<table class="form-table">
<tr>
<th><?php esc_html_e( 'Assign Categories', 'peptide-search-ai' ); ?></th>
<td>
<a href="<?php echo esc_url( $migrate_url ); ?>" class="button">
<?php esc_html_e( 'Migrate Existing Peptides to Categories', 'peptide-search-ai' ); ?>
</a>
<p class="description"><?php esc_html_e( 'Reads AI-generated category data from existing peptides and assigns taxonomy terms. Safe to run multiple times.', 'peptide-search-ai' ); ?></p>
</td>
</tr>
<tr>
<th><?php esc_html_e( 'Re-enrich Data', 'peptide-search-ai' ); ?></th>
<td>
<button type="button" id="psa-batch-enrich-start" class="button button-primary">
<?php esc_html_e( 'Re-enrich Peptides', 'peptide-search-ai' ); ?>
</button>
<span id="psa-batch-enrich-status" style="margin-left:10px;color:#666;"></span>
<p class="description"><?php esc_html_e( 'Processes one peptide at a time via AI — no WP-Cron dependency. Respects daily generation cap.', 'peptide-search-ai' ); ?></p>
<div id="psa-batch-enrich-progress" style="display:none;margin-top:10px;max-width:500px;">
<div style="background:#e0e0e0;border-radius:3px;height:20px;overflow:hidden;">
<div id="psa-batch-enrich-bar" style="background:#0073aa;height:100%;width:0%;transition:width 0.3s;"></div>
</div>
<span id="psa-batch-enrich-pct" style="font-size:12px;color:#666;"></span>
</div>
<div id="psa-batch-enrich-log" style="display:none;margin-top:10px;max-height:200px;overflow-y:auto;padding:8px;background:#f9f9f9;border:1px solid #ddd;border-radius:3px;max-width:500px;font-family:monospace;font-size:12px;"></div>
</td>
</tr>
</table>
<hr />
<h2><?php esc_html_e( 'Shortcodes', 'peptide-search-ai' ); ?></h2>
<p><code>[peptide_search]</code> — <?php esc_html_e( 'Search form with auto-generation', 'peptide-search-ai' ); ?></p>
<p><code>[peptide_directory]</code> — <?php esc_html_e( 'Browsable directory grid with filters and detail modal', 'peptide-search-ai' ); ?></p>
<hr />
<h2><?php esc_html_e( 'REST API', 'peptide-search-ai' ); ?></h2>
<p><code>GET /wp-json/peptides/v1/search?q=BPC-157</code> — <?php esc_html_e( 'Search (published only)', 'peptide-search-ai' ); ?></p>
<p><code>GET /wp-json/peptide-search-ai/v1/compounds?search=&amp;category=&amp;page=1</code> — <?php esc_html_e( 'Directory listing', 'peptide-search-ai' ); ?></p>
</div>
<?php
}
// ── Settings accessor ───────────────────────────────────────────────
/**
* Render the API usage summary section.
*/
private static function render_usage_summary(): void {
$current_spend = PSA_Cost_Tracker::get_monthly_spend();
$current_tokens = PSA_Cost_Tracker::get_monthly_tokens();
$estimated_count = PSA_Cost_Tracker::get_monthly_estimated_count();
$budget = floatval( self::get_setting( 'monthly_budget', PSA_Config::DEFAULT_MONTHLY_BUDGET ) );
$budget_remaining = ( 0.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 ) ); ?>
<?php if ( $estimated_count > 0 ) : ?>
<span style="color:#b45309;font-size:12px;" title="<?php echo esc_attr( $estimated_count ); ?> API calls used character-based token estimates because the model did not report usage data">&#9888; includes estimates</span>
<?php endif; ?>
<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 ) : ?>
<?php $is_estimated = ( isset( $log->token_source ) && 'estimated' === $log->token_source ); ?>
<tr<?php echo $is_estimated ? ' style="color:#92400e;"' : ''; ?>>
<td><?php echo esc_html( $log->created_at ); ?></td>
<td><code><?php echo esc_html( $log->model ); ?></code></td>
<td>
<?php if ( $is_estimated ) : ?>
<span title="Estimated from character length — model did not report token usage">~<?php echo esc_html( number_format( $log->total_tokens ) ); ?></span>
<?php else : ?>
<?php echo esc_html( number_format( $log->total_tokens ) ); ?>
<?php endif; ?>
</td>
<td>
<?php if ( $is_estimated ) : ?>
<span title="Cost estimated from approximate token count">~$<?php echo esc_html( number_format( $log->estimated_cost_usd, 4 ) ); ?></span>
<?php else : ?>
$<?php echo esc_html( number_format( $log->estimated_cost_usd, 4 ) ); ?>
<?php endif; ?>
</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 ) ? '&#10003;' : '&#10007;'; ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php
}
/**
* Helper to get a single setting with a default.
* Get a single setting with a default.
*
* @param string $key Setting key.
* @param mixed $default Default value.
* @return mixed
*/
private static function get_setting( string $key, $default = '' ) {
public static function get_setting( string $key, $default = '' ) {
$options = get_option( 'psa_settings', array() );
return $options[ $key ] ?? $default;
}

View File

@@ -0,0 +1,228 @@
<?php
/**
* AI prompt templates and post-generation data handlers for peptide entries.
*
* What: Prompt construction (validation + generation), meta field saving, category assignment.
* Who calls it: PSA_AI_Generator during validate/generate workflows.
* Dependencies: PSA_Config, PSA_Post_Type (for DEFAULT_CATEGORIES).
*
* @package PeptideSearchAI
* @since 4.5.0
* @see includes/class-psa-ai-generator.php — Orchestration that calls these methods.
* @see includes/class-psa-post-type.php — DEFAULT_CATEGORIES used in assign_category_term.
*/
declare( strict_types=1 );
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class PSA_AI_Content {
/**
* Build the lightweight validation prompt.
*
* @param string $name The peptide name to validate.
* @return string Prompt text for the OpenRouter API.
*/
public static function build_validation_prompt( string $name ): string {
$json_name = wp_json_encode( $name );
return <<<PROMPT
You are a peptide and biochemistry expert. Determine if the peptide name given in the following JSON-encoded string is a real, recognized peptide, protein fragment, or peptide-based compound.
Peptide name: {$json_name}
Return ONLY valid JSON (no markdown fences) with exactly these fields:
{
"is_valid": true or false,
"canonical_name": "The standard/official name for this peptide (correcting capitalization or common abbreviations)",
"reason": "Brief explanation of why this is or isn't a recognized peptide"
}
Rules:
- Return is_valid: true only for real peptides, peptide hormones, peptide-based drugs, neuropeptides, antimicrobial peptides, synthetic peptides used in research, or well-known protein fragments.
- Return is_valid: false for random words, made-up names, non-peptide drugs, non-peptide supplements, general proteins (unless commonly referred to as peptides), or anything that is clearly not a peptide.
- Be generous with research peptides and experimental compounds that are known in the scientific literature.
- Common examples that ARE valid: BPC-157, Thymosin Beta-4, TB-500, GHK-Cu, Melanotan II, Ipamorelin, CJC-1295, Semaglutide, Tirzepatide, AOD-9604, PT-141, DSIP, Epitalon, LL-37, Oxytocin, Vasopressin, Substance P, etc.
PROMPT;
}
/**
* Build the comprehensive generation prompt (v4.3.0: includes extended fields).
*
* @param string $peptide_name The peptide to research.
* @return string Prompt text for the OpenRouter API.
*/
public static function build_generation_prompt( string $peptide_name ): string {
$json_name = wp_json_encode( $peptide_name );
return <<<PROMPT
You are a scientific writer specializing in peptide research. Generate a comprehensive database entry for the peptide given in the following JSON-encoded string.
Peptide name: {$json_name}
Return ONLY valid JSON (no markdown fences, no commentary) with exactly these fields:
{
"name": "Official peptide name",
"aliases": "Comma-separated list of alternative names, abbreviations, or synonyms",
"category_label": "e.g. Tissue Healing / Cytoprotective, or GH Secretagogue, etc.",
"origin": "Origin description, e.g. Synthetic (derived from human gastric juice protein)",
"sequence": "Full amino acid sequence in three-letter code with dashes, e.g. Gly-Glu-Pro-Pro-Pro (N amino acids). Use single-letter if three-letter not standard.",
"molecular_weight": "Molecular weight in Daltons (e.g. 1419.53 Da)",
"molecular_formula": "Chemical formula (e.g. C62H98N16O22)",
"overview": "2-3 paragraphs: what this peptide is, its origin, discovery, and significance. Written for a scientifically literate audience.",
"mechanism": "Detailed mechanism of action. Start with an intro paragraph, then describe each pathway as a labeled item like: Pathway name: Description. Separate items with double newlines.",
"research_benefits": "Detailed research applications organized by therapeutic area. Use area names as subheadings followed by a paragraph.",
"administration_dosing": "Include a disclaimer that this is from research only. Then describe: typical research dose range, routes of administration, frequency, and duration from studies.",
"safety_side_effects": "Known safety profile from animal studies. Include any anecdotally reported side effects. Note theoretical concerns.",
"legal_regulatory": "Current regulatory and legal status. Mention FDA status, WADA status if applicable, classification as research chemical.",
"references": "5-8 real published scientific references with authors, title, journal, year. Use real studies only.",
"category": "Classify into exactly ONE of: Tissue Repair, Lipid Metabolism, Aging Research, Dermatological, Metabolic, Growth Hormone, Immunology, Endocrine",
"half_life": "Approximate half-life (e.g. '~4-6 hours', '15-20 minutes'). Write 'Unknown' if not established.",
"stability": "Stability information (e.g. '28 Days at 2-8°C after reconstitution'). Include lyophilized and reconstituted if available.",
"solubility": "Recommended reconstitution solvent (e.g. 'Bacteriostatic Water', 'Sterile Water')",
"vial_size_mg": "Most common research vial size in mg as a number (e.g. 5, 10, 2). Use 0 if not applicable.",
"storage_lyophilized": "Storage conditions for lyophilized/powder form (e.g. '-20°C, protect from light')",
"storage_reconstituted": "Storage conditions after reconstitution (e.g. '2-8°C, use within 28 days')",
"typical_dose_mcg": "Typical research dose range (e.g. '200-300 mcg', '100-200 mcg/kg'). Include units.",
"cycle_parameters": "Typical research cycle/protocol info (e.g. '4-6 weeks on, 2 weeks off', 'Daily subcutaneous injection')"
}
Important:
- Use factual, evidence-based information only.
- If information is uncertain or unknown, say so explicitly rather than fabricating data.
- All content is for research and educational purposes.
- Write in clean paragraphs. Do not use markdown formatting symbols like # or ** or *.
- For the "category" field, use EXACTLY one of the eight listed categories.
PROMPT;
}
/**
* Save all meta fields for a peptide post (core + extended).
* Prefers PubChem data for molecular properties when available.
*
* @param int $post_id The post ID.
* @param array $ai_data AI-generated data.
* @param array|null $pubchem_data PubChem data (or null).
*/
public static function save_peptide_meta( int $post_id, array $ai_data, ?array $pubchem_data ): void {
$amino_acid_count = self::derive_amino_acid_count( $ai_data );
$meta = array(
'psa_sequence' => $ai_data['sequence'] ?? '',
'psa_molecular_weight' => ! empty( $pubchem_data['molecular_weight'] ) ? $pubchem_data['molecular_weight'] : ( $ai_data['molecular_weight'] ?? '' ),
'psa_molecular_formula' => ! empty( $pubchem_data['molecular_formula'] ) ? $pubchem_data['molecular_formula'] : ( $ai_data['molecular_formula'] ?? '' ),
'psa_aliases' => $ai_data['aliases'] ?? '',
'psa_mechanism' => $ai_data['mechanism'] ?? '',
'psa_overview' => $ai_data['overview'] ?? $ai_data['description'] ?? '',
'psa_category_label' => $ai_data['category_label'] ?? '',
'psa_origin' => $ai_data['origin'] ?? '',
'psa_research_apps' => $ai_data['research_benefits'] ?? ( $ai_data['research_applications'] ?? '' ),
'psa_safety_profile' => $ai_data['safety_side_effects'] ?? ( $ai_data['safety_profile'] ?? '' ),
'psa_dosage_info' => $ai_data['administration_dosing'] ?? ( $ai_data['dosage_info'] ?? '' ),
'psa_legal_regulatory' => $ai_data['legal_regulatory'] ?? '',
'psa_references' => $ai_data['references'] ?? '',
'psa_category' => $ai_data['category'] ?? '',
'psa_source' => ! empty( $pubchem_data ) ? 'pubchem' : 'ai-generated',
'psa_pubchem_cid' => $pubchem_data['cid'] ?? '',
'psa_half_life' => $ai_data['half_life'] ?? '',
'psa_stability' => $ai_data['stability'] ?? '',
'psa_solubility' => $ai_data['solubility'] ?? '',
'psa_vial_size_mg' => $ai_data['vial_size_mg'] ?? '',
'psa_storage_lyophilized' => $ai_data['storage_lyophilized'] ?? '',
'psa_storage_reconstituted' => $ai_data['storage_reconstituted'] ?? '',
'psa_typical_dose_mcg' => $ai_data['typical_dose_mcg'] ?? '',
'psa_cycle_parameters' => $ai_data['cycle_parameters'] ?? '',
'psa_amino_acid_count' => $amino_acid_count,
);
$meta = apply_filters( 'psa_peptide_meta', $meta, $post_id, $ai_data, $pubchem_data );
foreach ( $meta as $key => $value ) {
update_post_meta( $post_id, $key, $value );
}
}
/**
* Auto-assign a peptide_category taxonomy term based on AI-returned category.
*
* @param int $post_id The peptide post ID.
* @param array $ai_data AI-generated data containing 'category' or 'category_label'.
*/
public static function assign_category_term( int $post_id, array $ai_data ): void {
$ai_category = $ai_data['category'] ?? $ai_data['category_label'] ?? '';
if ( empty( $ai_category ) ) {
return;
}
// Why: AI returns varied labels ("GH Secretagogues", "Healing & Repair") that need
// to map to our controlled vocabulary of taxonomy terms.
$category_map = array(
'gh secretagogues' => 'growth-hormone',
'growth hormone' => 'growth-hormone',
'healing & repair' => 'tissue-repair',
'healing and repair' => 'tissue-repair',
'tissue repair' => 'tissue-repair',
'tissue healing' => 'tissue-repair',
'cytoprotective' => 'tissue-repair',
'melanocortin peptides' => 'dermatological',
'melanocortin' => 'dermatological',
'dermatological' => 'dermatological',
'metabolic & anti-aging' => 'metabolic',
'metabolic and anti-aging' => 'metabolic',
'metabolic' => 'metabolic',
'anti-aging' => 'aging-research',
'aging research' => 'aging-research',
'nootropic & neuroprotective' => 'immunology',
'nootropic' => 'immunology',
'neuroprotective' => 'immunology',
'immunology' => 'immunology',
'immune' => 'immunology',
'lipid metabolism' => 'lipid-metabolism',
'endocrine' => 'endocrine',
);
$lower = strtolower( trim( $ai_category ) );
$slug = $category_map[ $lower ] ?? '';
// Fuzzy fallback: check if AI category contains any known slug keyword.
if ( empty( $slug ) ) {
foreach ( PSA_Post_Type::DEFAULT_CATEGORIES as $cat_slug => $cat_name ) {
if ( stripos( $ai_category, $cat_name ) !== false || stripos( $ai_category, str_replace( '-', ' ', $cat_slug ) ) !== false ) {
$slug = $cat_slug;
break;
}
}
}
if ( ! empty( $slug ) ) {
wp_set_object_terms( $post_id, $slug, 'peptide_category' );
}
}
/**
* Derive amino acid count from sequence data or explicit AI field.
*
* @param array $ai_data AI-generated data.
* @return string Amino acid count as string, or empty.
*/
private static function derive_amino_acid_count( array $ai_data ): string {
$sequence = $ai_data['sequence'] ?? '';
$count = '';
if ( ! empty( $sequence ) ) {
// Three-letter codes use dashes; single-letter codes are uppercase chars.
$count = ( strpos( $sequence, '-' ) !== false )
? (string) count( explode( '-', $sequence ) )
: (string) strlen( preg_replace( '/[^A-Z]/', '', $sequence ) );
}
// AI may return this directly — prefer explicit value.
if ( ! empty( $ai_data['amino_acid_count'] ) ) {
$count = (string) intval( $ai_data['amino_acid_count'] );
}
return $count;
}
}

View File

@@ -1,16 +1,18 @@
<?php
/**
* Handles AI content generation for new peptide entries.
* Orchestrates AI content generation for new peptide entries.
*
* What: Orchestrates the full AI pipeline — validation, generation, PubChem enrichment, KB article creation.
* Who calls it: PSA_Search (validation + generation scheduling), WP-Cron (background_generate).
* Dependencies: PSA_Config, PSA_Cost_Tracker, PSA_Encryption, PSA_PubChem, PSA_Post_Type, OpenRouter API.
* What: Validation, generation, PubChem enrichment, background processing, dry-run costing.
* Who calls it: PSA_Search_Handler (validation + generation scheduling), WP-Cron (background_generate).
* Dependencies: PSA_Config, PSA_Cost_Tracker, PSA_Encryption, PSA_OpenRouter, PSA_PubChem,
* PSA_KB_Builder, PSA_AI_Content (prompts + meta saving).
*
* @package PeptideSearchAI
* @see includes/class-psa-search.php
* @since 2.6.0
* @see includes/class-psa-ai-content.php — Prompt templates, meta saving, category assignment.
* @see includes/class-psa-search-handler.php
* @see includes/class-psa-cost-tracker.php
* @see includes/class-psa-pubchem.php
* @see includes/class-psa-post-type.php
*/
declare( strict_types=1 );
@@ -26,13 +28,12 @@ class PSA_AI_Generator {
/**
* Validate whether a search term is a legitimate peptide name.
* Uses a lightweight AI call to check before committing to full generation.
* Uses a lightweight AI call before committing to full generation.
*
* @param string $name The name to validate.
* @return array|WP_Error { is_valid: bool, canonical_name: string, reason: string }
*/
public static function validate_peptide_name( string $name ) {
// Input character validation — block injection attempts before API call.
if ( ! self::validate_peptide_input( $name ) ) {
return array(
'is_valid' => false,
@@ -42,31 +43,25 @@ class PSA_AI_Generator {
}
$options = self::get_settings();
$api_key = $options['api_key'];
$model = $options['validation_model'];
if ( empty( $api_key ) ) {
if ( empty( $options['api_key'] ) ) {
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).
// Transient cache to avoid repeated API calls for same term.
$cache_key = 'psa_validate_' . md5( strtolower( trim( $name ) ) );
$cached = get_transient( $cache_key );
if ( false !== $cached ) {
return $cached;
}
$prompt = self::build_validation_prompt( $name );
// Allow filtering of validation prompt before sending.
$prompt = apply_filters( 'psa_validation_prompt', $prompt, $name );
$prompt = PSA_AI_Content::build_validation_prompt( $name );
$prompt = apply_filters( 'psa_validation_prompt', $prompt, $name );
$response = PSA_OpenRouter::send_request(
$api_key,
$model ? $model : 'google/gemini-2.0-flash-001',
$options['api_key'],
$options['validation_model'] ? $options['validation_model'] : 'google/gemini-2.0-flash-001',
$prompt,
PSA_Config::VALIDATION_MAX_TOKENS,
'validation',
@@ -92,9 +87,7 @@ class PSA_AI_Generator {
'reason' => sanitize_text_field( $data['reason'] ?? '' ),
);
// Cache for 24 hours (using PSA_Config for consistency).
set_transient( $cache_key, $result, DAY_IN_SECONDS );
return $result;
}
@@ -111,43 +104,34 @@ class PSA_AI_Generator {
return;
}
// Only process posts that are still in 'pending' state.
$source = get_post_meta( $post_id, 'psa_source', true );
if ( 'pending' !== $source ) {
error_log( 'PSA: background_generate skipped — post ' . $post_id . ' source is "' . $source . '", not "pending".' );
return;
}
$options = self::get_settings();
$auto_publish = $options['auto_publish'];
$options = self::get_settings();
error_log( 'PSA: Generating content for "' . $peptide_name . '" (post ' . $post_id . ') using model: ' . ( $options['ai_model'] ? $options['ai_model'] : 'google/gemini-2.5-flash' ) );
// Allow developers to hook before generation starts.
do_action( 'psa_before_generation', $post_id, $peptide_name );
// Generate the full content.
$result = self::generate_peptide_content( $peptide_name );
if ( is_wp_error( $result ) ) {
$err_msg = $result->get_error_message();
error_log( 'PSA: Generation FAILED for "' . $peptide_name . '": ' . $err_msg );
update_post_meta( $post_id, 'psa_generation_error', $err_msg );
error_log( 'PSA: Generation FAILED for "' . $peptide_name . '": ' . $result->get_error_message() );
update_post_meta( $post_id, 'psa_generation_error', $result->get_error_message() );
return;
}
error_log( 'PSA: AI content generated successfully for "' . $peptide_name . '". Fields: ' . implode( ', ', array_keys( $result ) ) );
// FIX: Use 'overview' field (which the prompt returns) with fallback to 'description'.
$post_content = $result['overview'] ?? $result['description'] ?? '';
// Safeguard: if post_content is still empty after generation, log a warning.
if ( empty( trim( $post_content ) ) ) {
error_log( 'PSA: WARNING — AI returned valid JSON but post content (overview/description) is empty for "' . $peptide_name . '". Available keys: ' . implode( ', ', array_keys( $result ) ) );
update_post_meta( $post_id, 'psa_generation_error', 'AI returned empty overview/description content. Available fields: ' . implode( ', ', array_keys( $result ) ) );
error_log( 'PSA: WARNING — AI returned valid JSON but overview/description is empty for "' . $peptide_name . '".' );
update_post_meta( $post_id, 'psa_generation_error', 'AI returned empty overview/description. Fields: ' . implode( ', ', array_keys( $result ) ) );
return;
}
// Try PubChem enrichment.
// PubChem enrichment for molecular data.
$pubchem_data = null;
if ( ! empty( $options['use_pubchem'] ) ) {
$pubchem_result = PSA_PubChem::lookup( $peptide_name );
@@ -159,9 +143,7 @@ class PSA_AI_Generator {
}
}
// Update the placeholder with real content.
$post_status = ( 'publish' === $auto_publish ) ? 'publish' : 'draft';
$post_status = ( 'publish' === $options['auto_publish'] ) ? 'publish' : 'draft';
$update_result = wp_update_post(
array(
'ID' => $post_id,
@@ -178,23 +160,15 @@ class PSA_AI_Generator {
return;
}
// Save all meta fields — prefer PubChem data for molecular properties.
self::save_peptide_meta( $post_id, $result, $pubchem_data );
// Auto-assign peptide_category taxonomy term based on AI category.
self::assign_category_term( $post_id, $result );
// Create a matching Knowledge Base article.
PSA_AI_Content::save_peptide_meta( $post_id, $result, $pubchem_data );
PSA_AI_Content::assign_category_term( $post_id, $result );
PSA_KB_Builder::create_article( $post_id, $result, $peptide_name, $post_status );
// Clean up generation tracking.
delete_post_meta( $post_id, 'psa_generation_started' );
delete_post_meta( $post_id, 'psa_generation_error' );
update_post_meta( $post_id, 'psa_generation_completed', time() );
// Allow developers to hook after generation completes.
do_action( 'psa_after_generation', $post_id, $result, $peptide_name );
error_log( 'PSA: Successfully completed generation for "' . $peptide_name . '" (post ' . $post_id . ', status: ' . $post_status . ')' );
}
@@ -206,24 +180,18 @@ class PSA_AI_Generator {
*/
public static function generate_peptide_content( string $peptide_name ) {
$options = self::get_settings();
$api_key = $options['api_key'];
$model = $options['ai_model'];
if ( empty( $api_key ) ) {
if ( empty( $options['api_key'] ) ) {
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 );
$prompt = PSA_AI_Content::build_generation_prompt( $peptide_name );
$prompt = apply_filters( 'psa_generation_prompt', $prompt, $peptide_name );
$response = PSA_OpenRouter::send_request(
$api_key,
$model ? $model : 'google/gemini-2.5-flash',
$options['api_key'],
$options['ai_model'] ? $options['ai_model'] : 'google/gemini-2.5-flash',
$prompt,
PSA_Config::GENERATION_MAX_TOKENS,
'generation',
@@ -238,49 +206,39 @@ class PSA_AI_Generator {
}
/**
* Perform a dry-run to estimate costs WITHOUT making API calls.
* Dry-run: 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,
* }
* @return array { validation: {tokens, cost}, generation: {tokens, cost}, total }
*/
public static function dry_run( string $peptide_name ): array {
$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';
$validation_prompt = self::build_validation_prompt( $peptide_name );
$validation_prompt_tokens = (int) ceil( strlen( $validation_prompt ) / 4 );
$validation_completion = PSA_Config::VALIDATION_MAX_TOKENS;
$validation_cost = PSA_Cost_Tracker::estimate_cost( $validation_model, $validation_prompt_tokens, $validation_completion );
$val_prompt = PSA_AI_Content::build_validation_prompt( $peptide_name );
$val_tokens = (int) ceil( strlen( $val_prompt ) / 4 );
$val_cost = PSA_Cost_Tracker::estimate_cost( $validation_model, $val_tokens, PSA_Config::VALIDATION_MAX_TOKENS );
$generation_prompt = self::build_generation_prompt( $peptide_name );
$generation_prompt_tokens = (int) ceil( strlen( $generation_prompt ) / 4 );
$generation_completion = PSA_Config::GENERATION_MAX_TOKENS;
$generation_cost = PSA_Cost_Tracker::estimate_cost( $generation_model, $generation_prompt_tokens, $generation_completion );
$gen_prompt = PSA_AI_Content::build_generation_prompt( $peptide_name );
$gen_tokens = (int) ceil( strlen( $gen_prompt ) / 4 );
$gen_cost = PSA_Cost_Tracker::estimate_cost( $generation_model, $gen_tokens, PSA_Config::GENERATION_MAX_TOKENS );
return array(
'validation' => array(
'tokens' => $validation_prompt_tokens + $validation_completion,
'estimated_cost_usd' => $validation_cost,
'validation' => array(
'tokens' => $val_tokens + PSA_Config::VALIDATION_MAX_TOKENS,
'estimated_cost_usd' => $val_cost,
),
'generation' => array(
'tokens' => $generation_prompt_tokens + $generation_completion,
'estimated_cost_usd' => $generation_cost,
'generation' => array(
'tokens' => $gen_tokens + PSA_Config::GENERATION_MAX_TOKENS,
'estimated_cost_usd' => $gen_cost,
),
'total_estimated_cost_usd' => $validation_cost + $generation_cost,
'total_estimated_cost_usd' => $val_cost + $gen_cost,
);
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
/**
* Get plugin settings with defaults.
* Get plugin settings with defaults, decrypted API key, and constant override.
*
* @return array
*/
@@ -292,10 +250,8 @@ class PSA_AI_Generator {
'auto_publish' => 'draft',
'use_pubchem' => '1',
);
$settings = wp_parse_args( get_option( 'psa_settings', array() ), $defaults );
// Decrypt API key if it's encrypted and constant is not defined.
if ( ! empty( $settings['api_key'] ) && ! ( defined( 'PSA_OPENROUTER_KEY' ) && PSA_OPENROUTER_KEY ) ) {
$decrypted = PSA_Encryption::decrypt( $settings['api_key'] );
if ( false !== $decrypted ) {
@@ -303,139 +259,12 @@ class PSA_AI_Generator {
}
}
// Prefer wp-config.php constant over database value for security.
// wp-config.php constant takes precedence for security.
if ( defined( 'PSA_OPENROUTER_KEY' ) && PSA_OPENROUTER_KEY ) {
$settings['api_key'] = PSA_OPENROUTER_KEY;
}
$settings = apply_filters( 'psa_ai_settings', $settings );
return $settings;
}
/**
* Save meta fields for a peptide post (core + extended).
*
* @param int $post_id The post ID.
* @param array $ai_data AI-generated data.
* @param array|null $pubchem_data PubChem data (or null).
*/
private static function save_peptide_meta( int $post_id, array $ai_data, ?array $pubchem_data ): void {
// Derive amino acid count from sequence if available.
$amino_acid_count = '';
$sequence = $ai_data['sequence'] ?? '';
if ( ! empty( $sequence ) ) {
// Count residues: split on dashes for three-letter codes, or count chars for single-letter.
if ( strpos( $sequence, '-' ) !== false ) {
$amino_acid_count = (string) count( explode( '-', $sequence ) );
} else {
$amino_acid_count = (string) strlen( preg_replace( '/[^A-Z]/', '', $sequence ) );
}
}
// AI may also return this directly.
if ( ! empty( $ai_data['amino_acid_count'] ) ) {
$amino_acid_count = (string) intval( $ai_data['amino_acid_count'] );
}
$meta = array(
'psa_sequence' => $ai_data['sequence'] ?? '',
'psa_molecular_weight' => ! empty( $pubchem_data['molecular_weight'] )
? $pubchem_data['molecular_weight']
: ( $ai_data['molecular_weight'] ?? '' ),
'psa_molecular_formula' => ! empty( $pubchem_data['molecular_formula'] )
? $pubchem_data['molecular_formula']
: ( $ai_data['molecular_formula'] ?? '' ),
'psa_aliases' => $ai_data['aliases'] ?? '',
'psa_mechanism' => $ai_data['mechanism'] ?? '',
'psa_overview' => $ai_data['overview'] ?? $ai_data['description'] ?? '',
'psa_category_label' => $ai_data['category_label'] ?? '',
'psa_origin' => $ai_data['origin'] ?? '',
'psa_research_apps' => $ai_data['research_benefits'] ?? ( $ai_data['research_applications'] ?? '' ),
'psa_safety_profile' => $ai_data['safety_side_effects'] ?? ( $ai_data['safety_profile'] ?? '' ),
'psa_dosage_info' => $ai_data['administration_dosing'] ?? ( $ai_data['dosage_info'] ?? '' ),
'psa_legal_regulatory' => $ai_data['legal_regulatory'] ?? '',
'psa_references' => $ai_data['references'] ?? '',
'psa_category' => $ai_data['category'] ?? '',
'psa_source' => ! empty( $pubchem_data ) ? 'pubchem' : 'ai-generated',
'psa_pubchem_cid' => $pubchem_data['cid'] ?? '',
// Extended fields (v4.3.0).
'psa_half_life' => $ai_data['half_life'] ?? '',
'psa_stability' => $ai_data['stability'] ?? '',
'psa_solubility' => $ai_data['solubility'] ?? '',
'psa_vial_size_mg' => $ai_data['vial_size_mg'] ?? '',
'psa_storage_lyophilized' => $ai_data['storage_lyophilized'] ?? '',
'psa_storage_reconstituted' => $ai_data['storage_reconstituted'] ?? '',
'psa_typical_dose_mcg' => $ai_data['typical_dose_mcg'] ?? '',
'psa_cycle_parameters' => $ai_data['cycle_parameters'] ?? '',
'psa_amino_acid_count' => $amino_acid_count,
);
$meta = apply_filters( 'psa_peptide_meta', $meta, $post_id, $ai_data, $pubchem_data );
foreach ( $meta as $key => $value ) {
update_post_meta( $post_id, $key, $value );
}
}
/**
* Auto-assign a peptide_category taxonomy term based on AI-returned category.
*
* Maps the AI's free-text category to the registered taxonomy terms.
* Falls back to fuzzy matching if no exact match found.
*
* @param int $post_id The peptide post ID.
* @param array $ai_data AI-generated data containing 'category' or 'category_label'.
*/
private static function assign_category_term( int $post_id, array $ai_data ): void {
$ai_category = $ai_data['category'] ?? $ai_data['category_label'] ?? '';
if ( empty( $ai_category ) ) {
return;
}
// Mapping from AI free-text categories to taxonomy slugs.
// Why: AI returns varied labels ("GH Secretagogues", "Healing & Repair") that need
// to map to our controlled vocabulary of taxonomy terms.
$category_map = array(
'gh secretagogues' => 'growth-hormone',
'growth hormone' => 'growth-hormone',
'healing & repair' => 'tissue-repair',
'healing and repair' => 'tissue-repair',
'tissue repair' => 'tissue-repair',
'tissue healing' => 'tissue-repair',
'cytoprotective' => 'tissue-repair',
'melanocortin peptides' => 'dermatological',
'melanocortin' => 'dermatological',
'dermatological' => 'dermatological',
'metabolic & anti-aging' => 'metabolic',
'metabolic and anti-aging' => 'metabolic',
'metabolic' => 'metabolic',
'anti-aging' => 'aging-research',
'aging research' => 'aging-research',
'nootropic & neuroprotective' => 'immunology',
'nootropic' => 'immunology',
'neuroprotective' => 'immunology',
'immunology' => 'immunology',
'immune' => 'immunology',
'lipid metabolism' => 'lipid-metabolism',
'endocrine' => 'endocrine',
);
$lower = strtolower( trim( $ai_category ) );
$slug = $category_map[ $lower ] ?? '';
// Fuzzy fallback: check if AI category contains any known slug keyword.
if ( empty( $slug ) ) {
foreach ( PSA_Post_Type::DEFAULT_CATEGORIES as $cat_slug => $cat_name ) {
if ( stripos( $ai_category, $cat_name ) !== false || stripos( $ai_category, str_replace( '-', ' ', $cat_slug ) ) !== false ) {
$slug = $cat_slug;
break;
}
}
}
if ( ! empty( $slug ) ) {
wp_set_object_terms( $post_id, $slug, 'peptide_category' );
}
return apply_filters( 'psa_ai_settings', $settings );
}
/**
@@ -458,82 +287,13 @@ class PSA_AI_Generator {
return true;
}
/**
* Build the lightweight validation prompt.
*
* @param string $name The peptide name to validate.
* @return string Prompt text ready to send to OpenRouter API.
*/
private static function build_validation_prompt( string $name ): string {
$json_name = wp_json_encode( $name );
return <<<PROMPT
You are a peptide and biochemistry expert. Determine if the peptide name given in the following JSON-encoded string is a real, recognized peptide, protein fragment, or peptide-based compound.
Peptide name: {$json_name}
Return ONLY valid JSON (no markdown fences) with exactly these fields:
{
"is_valid": true or false,
"canonical_name": "The standard/official name for this peptide (correcting capitalization or common abbreviations)",
"reason": "Brief explanation of why this is or isn't a recognized peptide"
}
Rules:
- Return is_valid: true only for real peptides, peptide hormones, peptide-based drugs, neuropeptides, antimicrobial peptides, synthetic peptides used in research, or well-known protein fragments.
- Return is_valid: false for random words, made-up names, non-peptide drugs, non-peptide supplements, general proteins (unless commonly referred to as peptides), or anything that is clearly not a peptide.
- Be generous with research peptides and experimental compounds that are known in the scientific literature.
- Common examples that ARE valid: BPC-157, Thymosin Beta-4, TB-500, GHK-Cu, Melanotan II, Ipamorelin, CJC-1295, Semaglutide, Tirzepatide, AOD-9604, PT-141, DSIP, Epitalon, LL-37, Oxytocin, Vasopressin, Substance P, etc.
PROMPT;
/** @see PSA_AI_Content::build_validation_prompt() — kept for test reflection. */
public static function build_validation_prompt( string $name ): string {
return PSA_AI_Content::build_validation_prompt( $name );
}
/**
* Build the comprehensive generation prompt (v4.3.0: includes extended fields).
*
* @param string $peptide_name The peptide to research.
* @return string Prompt text ready to send to OpenRouter API.
*/
private static function build_generation_prompt( string $peptide_name ): string {
$json_name = wp_json_encode( $peptide_name );
return <<<PROMPT
You are a scientific writer specializing in peptide research. Generate a comprehensive database entry for the peptide given in the following JSON-encoded string.
Peptide name: {$json_name}
Return ONLY valid JSON (no markdown fences, no commentary) with exactly these fields:
{
"name": "Official peptide name",
"aliases": "Comma-separated list of alternative names, abbreviations, or synonyms",
"category_label": "e.g. Tissue Healing / Cytoprotective, or GH Secretagogue, etc.",
"origin": "Origin description, e.g. Synthetic (derived from human gastric juice protein)",
"sequence": "Full amino acid sequence in three-letter code with dashes, e.g. Gly-Glu-Pro-Pro-Pro (N amino acids). Use single-letter if three-letter not standard.",
"molecular_weight": "Molecular weight in Daltons (e.g. 1419.53 Da)",
"molecular_formula": "Chemical formula (e.g. C62H98N16O22)",
"overview": "2-3 paragraphs: what this peptide is, its origin, discovery, and significance. Written for a scientifically literate audience.",
"mechanism": "Detailed mechanism of action. Start with an intro paragraph, then describe each pathway as a labeled item like: Pathway name: Description. Separate items with double newlines.",
"research_benefits": "Detailed research applications organized by therapeutic area. Use area names as subheadings followed by a paragraph.",
"administration_dosing": "Include a disclaimer that this is from research only. Then describe: typical research dose range, routes of administration, frequency, and duration from studies.",
"safety_side_effects": "Known safety profile from animal studies. Include any anecdotally reported side effects. Note theoretical concerns.",
"legal_regulatory": "Current regulatory and legal status. Mention FDA status, WADA status if applicable, classification as research chemical.",
"references": "5-8 real published scientific references with authors, title, journal, year. Use real studies only.",
"category": "Classify into exactly ONE of: Tissue Repair, Lipid Metabolism, Aging Research, Dermatological, Metabolic, Growth Hormone, Immunology, Endocrine",
"half_life": "Approximate half-life (e.g. '~4-6 hours', '15-20 minutes'). Write 'Unknown' if not established.",
"stability": "Stability information (e.g. '28 Days at 2-8°C after reconstitution'). Include lyophilized and reconstituted if available.",
"solubility": "Recommended reconstitution solvent (e.g. 'Bacteriostatic Water', 'Sterile Water')",
"vial_size_mg": "Most common research vial size in mg as a number (e.g. 5, 10, 2). Use 0 if not applicable.",
"storage_lyophilized": "Storage conditions for lyophilized/powder form (e.g. '-20°C, protect from light')",
"storage_reconstituted": "Storage conditions after reconstitution (e.g. '2-8°C, use within 28 days')",
"typical_dose_mcg": "Typical research dose range (e.g. '200-300 mcg', '100-200 mcg/kg'). Include units.",
"cycle_parameters": "Typical research cycle/protocol info (e.g. '4-6 weeks on, 2 weeks off', 'Daily subcutaneous injection')"
}
Important:
- Use factual, evidence-based information only.
- If information is uncertain or unknown, say so explicitly rather than fabricating data.
- All content is for research and educational purposes.
- Write in clean paragraphs. Do not use markdown formatting symbols like # or ** or *.
- For the "category" field, use EXACTLY one of the eight listed categories.
PROMPT;
/** @see PSA_AI_Content::build_generation_prompt() — kept for test reflection. */
public static function build_generation_prompt( string $peptide_name ): string {
return PSA_AI_Content::build_generation_prompt( $peptide_name );
}
}

View File

@@ -106,7 +106,7 @@ class PSA_Batch_Enrichment {
PSA_AI_Generator::background_generate( $post_id, $peptide_name );
// Check outcome: did generation succeed?
$error = get_post_meta( $post_id, 'psa_generation_error', true );
$error = get_post_meta( $post_id, 'psa_generation_error', true );
$refreshed_post = get_post( $post_id );
// If still in draft (generation failed or auto_publish is 'draft'), force republish.

View File

@@ -25,23 +25,23 @@ class PSA_Config {
const AJAX_RATE_LIMIT = 10;
// Daily generation cap
const DAILY_GENERATION_CAP = 50;
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;
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
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
const API_RETRY_MAX = 3;
const API_RETRY_BASE_DELAY = 5; // seconds
// Cost tracking and budget
const DEFAULT_MONTHLY_BUDGET = 0; // 0 = unlimited

View File

@@ -0,0 +1,136 @@
<?php
/**
* Read-only reporting queries for API usage data.
*
* What: Monthly spend/token/estimate-count summaries and recent log retrieval.
* Who calls it: PSA_Admin_Page (usage summary), PSA_Cost_Tracker (budget check).
* Dependencies: WordPress $wpdb.
*
* @package PeptideSearchAI
* @since 4.5.0
* @see includes/class-psa-cost-tracker.php — Write-side: logging, cost estimation, table management.
* @see includes/class-psa-admin-page.php — Renders usage summary using these queries.
*/
declare( strict_types=1 );
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class PSA_Cost_Reporter {
/**
* Get total spend for a given month.
*
* @param int|null $year Year (defaults to current).
* @param int|null $month Month (defaults to current).
* @return float Total USD spent.
*/
public static function get_monthly_spend( ?int $year = null, ?int $month = null ): float {
global $wpdb;
list( $start, $end ) = self::month_range( $year, $month );
$table = $wpdb->prefix . 'psa_api_logs';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$result = $wpdb->get_var(
$wpdb->prepare(
'SELECT COALESCE(SUM(estimated_cost_usd), 0) FROM ' . $table . ' WHERE created_at >= %s AND created_at <= %s', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$start,
$end
)
);
return floatval( $result );
}
/**
* Get total token usage for a given month.
*
* @param int|null $year Year (defaults to current).
* @param int|null $month Month (defaults to current).
* @return int Total tokens.
*/
public static function get_monthly_tokens( ?int $year = null, ?int $month = null ): int {
global $wpdb;
list( $start, $end ) = self::month_range( $year, $month );
$table = $wpdb->prefix . 'psa_api_logs';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$result = $wpdb->get_var(
$wpdb->prepare(
'SELECT COALESCE(SUM(total_tokens), 0) FROM ' . $table . ' WHERE created_at >= %s AND created_at <= %s', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$start,
$end
)
);
return (int) $result;
}
/**
* Count API calls this month that used character-based token estimates.
*
* Why: Lets the admin UI flag when reported costs include approximations
* so operators can distinguish measured spend from estimated spend.
*
* @param int|null $year Year (defaults to current).
* @param int|null $month Month (defaults to current).
* @return int Number of estimated rows.
*/
public static function get_monthly_estimated_count( ?int $year = null, ?int $month = null ): int {
global $wpdb;
list( $start, $end ) = self::month_range( $year, $month );
$table = $wpdb->prefix . 'psa_api_logs';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$result = $wpdb->get_var(
$wpdb->prepare(
'SELECT COUNT(*) FROM ' . $table . ' WHERE created_at >= %s AND created_at <= %s AND token_source = %s', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$start,
$end,
'estimated'
)
);
return (int) $result;
}
/**
* Get the most recent API call logs.
*
* @param int $limit Maximum number of logs to return.
* @return array Array of log row objects.
*/
public static function get_recent_logs( int $limit = 20 ): array {
global $wpdb;
$table = $wpdb->prefix . 'psa_api_logs';
$limit = max( 1, (int) $limit );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$results = $wpdb->get_results(
$wpdb->prepare(
'SELECT * FROM ' . $table . ' ORDER BY created_at DESC LIMIT %d', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$limit
)
);
return is_array( $results ) ? $results : array();
}
/**
* Build start/end datetime strings for a given month.
*
* @param int|null $year Year (defaults to current).
* @param int|null $month Month (defaults to current).
* @return array{0: string, 1: string} [start_date, end_datetime]
*/
private static function month_range( ?int $year, ?int $month ): array {
$year = $year ?? (int) gmdate( 'Y' );
$month = max( 1, min( 12, $month ?? (int) gmdate( 'm' ) ) );
$start = gmdate( 'Y-m-01', mktime( 0, 0, 0, $month, 1, $year ) );
$end = gmdate( 'Y-m-t', mktime( 0, 0, 0, $month, 1, $year ) ) . ' 23:59:59';
return array( $start, $end );
}
}

View File

@@ -1,13 +1,14 @@
<?php
/**
* Tracks API usage costs and enforces monthly budget limits.
* Logs 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.
* What: Table management, API call logging, cost estimation, budget enforcement.
* Who calls it: PSA_OpenRouter (logs calls), PSA_AI_Generator (checks budget).
* Dependencies: WordPress $wpdb, PSA_Config, PSA_Cost_Reporter (for spend queries).
*
* @package PeptideSearchAI
* @since 1.0.0
* @see includes/class-psa-cost-reporter.php — Read-side: monthly summaries, recent logs.
*/
declare( strict_types=1 );
@@ -17,17 +18,14 @@ if ( ! defined( 'ABSPATH' ) ) {
class PSA_Cost_Tracker {
/**
* Create the API logs table if it doesn't exist.
* Uses dbDelta for safe schema updates.
*/
/** Create the API logs table if it doesn't exist. Uses dbDelta for safe schema updates. */
public static function create_table(): void {
global $wpdb;
$table_name = $wpdb->prefix . 'psa_api_logs';
$charset_collate = $wpdb->get_charset_collate();
$table = $wpdb->prefix . 'psa_api_logs';
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE $table_name (
$sql = "CREATE TABLE $table (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
provider VARCHAR(50) NOT NULL DEFAULT 'openrouter',
@@ -42,13 +40,12 @@ class PSA_Cost_Tracker {
token_source VARCHAR(20) NOT NULL DEFAULT 'api',
INDEX idx_created_at (created_at),
INDEX idx_request_type (request_type)
) $charset_collate;";
) $charset;";
// 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 );
}
@@ -58,63 +55,44 @@ class PSA_Cost_Tracker {
* 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)
* @type string $token_source 'api' (from usage response), 'estimated' (character-based), or 'none'
* @type string $provider, @type string $model, @type int $prompt_tokens,
* @type int $completion_tokens, @type int $total_tokens, @type string $request_type,
* @type string $peptide_name, @type bool $success, @type float $estimated_cost_usd,
* @type string $token_source 'api'|'estimated'|'none'
* }
* @return int|false Insert ID or false on error.
*/
public static function log_api_call( array $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 );
$table = $wpdb->prefix . 'psa_api_logs';
$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 );
$token_source = $data['token_source'] ?? 'api';
$total_tokens = (int) ( $data['total_tokens'] ?? 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 and we have token data.
if ( 0 === $estimated_cost && $prompt_tokens > 0 ) {
$estimated_cost = self::estimate_cost( $model, $prompt_tokens, $completion_tokens );
$estimated_cost = self::estimate_cost( $data['model'] ?? '', $prompt_tokens, $completion_tokens );
}
$insert_data = array(
'provider' => sanitize_text_field( $provider ),
'model' => sanitize_text_field( $model ),
'provider' => sanitize_text_field( $data['provider'] ?? 'openrouter' ),
'model' => sanitize_text_field( $data['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,
'token_source' => sanitize_text_field( $token_source ),
'request_type' => sanitize_text_field( $data['request_type'] ?? 'generation' ),
'peptide_name' => sanitize_text_field( $data['peptide_name'] ?? '' ),
'success' => ! empty( $data['success'] ) ? 1 : 0,
'token_source' => sanitize_text_field( $data['token_source'] ?? 'api' ),
);
$insert_format = array( '%s', '%s', '%d', '%d', '%d', '%f', '%s', '%s', '%d', '%s' );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$result = $wpdb->insert( $table_name, $insert_data, $insert_format );
$result = $wpdb->insert( $table, $insert_data, array( '%s', '%s', '%d', '%d', '%d', '%f', '%s', '%s', '%d', '%s' ) );
if ( false === $result ) {
error_log( 'PSA: Failed to insert API log: ' . $wpdb->last_error );
@@ -125,167 +103,23 @@ class PSA_Cost_Tracker {
}
/**
* Get total spend for a given month.
* Check if current month's budget has been exceeded. Budget of 0 = unlimited.
*
* @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( ?int $year = null, ?int $month = null ): float {
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
$result = $wpdb->get_var(
$wpdb->prepare(
'SELECT COALESCE(SUM(estimated_cost_usd), 0) FROM ' . $table_name . ' WHERE created_at >= %s AND created_at <= %s', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- table name is safe, built from $wpdb->prefix.
$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( ?int $year = null, ?int $month = null ): int {
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
$result = $wpdb->get_var(
$wpdb->prepare(
'SELECT COALESCE(SUM(total_tokens), 0) FROM ' . $table_name . ' WHERE created_at >= %s AND created_at <= %s', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- table name is safe, built from $wpdb->prefix.
$start_date,
$end_date
)
);
return (int) $result;
}
/**
* Count how many API calls this month used character-based token estimates.
*
* Why: Lets the admin UI flag when reported costs include approximations
* so operators can distinguish measured spend from estimated spend.
*
* @param int|null $year Year (defaults to current year).
* @param int|null $month Month (defaults to current month).
* @return int Number of estimated rows this month.
*/
public static function get_monthly_estimated_count( ?int $year = null, ?int $month = null ): int {
global $wpdb;
if ( null === $year ) {
$year = (int) gmdate( 'Y' );
}
if ( null === $month ) {
$month = (int) gmdate( 'm' );
}
$table_name = $wpdb->prefix . 'psa_api_logs';
$month = max( 1, min( 12, (int) $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 ) ) . ' 23:59:59';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$result = $wpdb->get_var(
$wpdb->prepare(
'SELECT COUNT(*) FROM ' . $table_name . ' WHERE created_at >= %s AND created_at <= %s AND token_source = %s', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- table name is safe, built from $wpdb->prefix.
$start_date,
$end_date,
'estimated'
)
);
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.
* @return bool True if budget exceeded.
*/
public static function is_budget_exceeded(): bool {
$settings = get_option( 'psa_settings', array() );
$budget = floatval( $settings['monthly_budget'] ?? PSA_Config::DEFAULT_MONTHLY_BUDGET );
// Budget of 0 = unlimited. Use loose comparison because floatval() returns float.
if ( 0.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( int $limit = 20 ): array {
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', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- table name is safe, built from $wpdb->prefix.
$limit
)
);
return is_array( $results ) ? $results : array();
return PSA_Cost_Reporter::get_monthly_spend() >= $budget;
}
/**
* 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.
@@ -293,45 +127,49 @@ class PSA_Cost_Tracker {
* @return float Estimated cost in USD.
*/
public static function estimate_cost( string $model, int $prompt_tokens, int $completion_tokens ): float {
// Known model pricing per 1 million tokens (input, output).
// Why: OpenRouter pricing varies by model; we maintain known rates here and
// fall back to a conservative default for unlisted models so cost tracking
// always produces a usable upper-bound estimate.
// Per 1M tokens (input, output). Conservative default for unknown models.
$pricing = array(
'google/gemini-2.5-flash' => array( 0.15, 0.60 ),
'google/gemini-2.0-flash-001' => array( 0.10, 0.40 ),
'deepseek/deepseek-v3.2' => array( 0.27, 1.10 ),
'deepseek/deepseek-chat' => array( 0.27, 1.10 ),
'deepseek/deepseek-r1' => array( 0.55, 2.19 ),
'qwen/qwen3.6-plus:free' => array( 0.00, 0.00 ),
'google/gemini-2.5-flash' => array( 0.15, 0.60 ),
'google/gemini-2.0-flash-001' => array( 0.10, 0.40 ),
'deepseek/deepseek-v3.2' => array( 0.27, 1.10 ),
'deepseek/deepseek-chat' => array( 0.27, 1.10 ),
'deepseek/deepseek-r1' => array( 0.55, 2.19 ),
'qwen/qwen3.6-plus:free' => array( 0.00, 0.00 ),
);
$model = sanitize_text_field( $model );
if ( isset( $pricing[ $model ] ) ) {
list( $input_price, $output_price ) = $pricing[ $model ];
list( $in, $out ) = $pricing[ $model ];
} else {
// Conservative default for unknown models.
$input_price = 1.0;
$output_price = 2.0;
$in = 1.0;
$out = 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 );
return (float) ( ( $in / 1000000 ) * $prompt_tokens + ( $out / 1000000 ) * $completion_tokens );
}
/**
* Drop the API logs table during uninstall.
*/
/** Drop the API logs table during uninstall. */
public static function drop_table(): void {
global $wpdb;
$table_name = $wpdb->prefix . 'psa_api_logs';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared -- table name is safe, built from $wpdb->prefix.
$wpdb->query( 'DROP TABLE IF EXISTS ' . $table_name );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query( 'DROP TABLE IF EXISTS ' . $wpdb->prefix . 'psa_api_logs' );
}
// ── Backward-compatible proxies (reporting moved to PSA_Cost_Reporter) ──
/** @see PSA_Cost_Reporter::get_monthly_spend() */
public static function get_monthly_spend( ?int $year = null, ?int $month = null ): float {
return PSA_Cost_Reporter::get_monthly_spend( $year, $month ); }
/** @see PSA_Cost_Reporter::get_monthly_tokens() */
public static function get_monthly_tokens( ?int $year = null, ?int $month = null ): int {
return PSA_Cost_Reporter::get_monthly_tokens( $year, $month ); }
/** @see PSA_Cost_Reporter::get_monthly_estimated_count() */
public static function get_monthly_estimated_count( ?int $year = null, ?int $month = null ): int {
return PSA_Cost_Reporter::get_monthly_estimated_count( $year, $month ); }
/** @see PSA_Cost_Reporter::get_recent_logs() */
public static function get_recent_logs( int $limit = 20 ): array {
return PSA_Cost_Reporter::get_recent_logs( $limit ); }
}

View File

@@ -0,0 +1,81 @@
<?php
/**
* Dependency check for Peptide Repo Core (PR Core).
*
* What: Verifies PR Core >= REQUIRED_PR_CORE_VERSION is active so PSA can rely
* on the `peptide` CPT + `peptide_category` taxonomy being registered. Renders
* an admin notice when the dependency is unmet. Serves as a boot-path gate:
* PSA surfaces that operate on the `peptide` CPT (meta boxes, admin columns,
* directory widget, KB renderer, search widget, template) call
* is_satisfied() and degrade gracefully — they do not fatal — when PR Core is
* missing or out of date.
*
* Who calls it: Main plugin bootstrap (`psa_init` + `psa_admin_init`) and each
* CPT-dependent class's `init()` method before hooking into WordPress.
* Dependencies: PR Core defines the PR_CORE_VERSION constant in its main
* plugin file; absence of the constant is treated as "not installed."
*
* @package PeptideSearchAI
* @since 4.5.0
* @see includes/class-psa-post-type.php — Gated via init_admin().
* @see includes/class-psa-directory.php — Gated via init().
* @see includes/class-psa-template.php — Gated via init().
* @see includes/class-psa-search.php — Gated via init().
*/
declare( strict_types=1 );
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class PSA_Dependency_Check {
/**
* Minimum PR Core version that registers the `peptide` CPT + `peptide_category`
* taxonomy in the shape PSA depends on. Coordinated release: see
* `convo/peptidesearch/threads/2026-04-peptide-cpt-consolidation/`.
*/
const REQUIRED_PR_CORE_VERSION = '0.2.0';
/** Register the admin notice hook. Safe to call multiple times. */
public static function init(): void {
add_action( 'admin_notices', array( __CLASS__, 'maybe_render_notice' ) );
}
/**
* Whether PR Core is installed, active, and at a version PSA accepts.
*
* Pure function — no side effects. Safe to call from any boot-path gate.
*
* @return bool True when PR_CORE_VERSION is defined and >= REQUIRED_PR_CORE_VERSION.
*/
public static function is_satisfied(): bool {
if ( ! defined( 'PR_CORE_VERSION' ) ) {
return false;
}
return version_compare( (string) constant( 'PR_CORE_VERSION' ), self::REQUIRED_PR_CORE_VERSION, '>=' );
}
/**
* Render the admin notice when the dependency is unmet.
*
* Hooked to `admin_notices`. Skips rendering when the dependency is
* satisfied and when the current user lacks the `activate_plugins`
* capability (the only role that can act on the notice).
*/
public static function maybe_render_notice(): void {
if ( self::is_satisfied() ) {
return;
}
if ( ! function_exists( 'current_user_can' ) || ! current_user_can( 'activate_plugins' ) ) {
return;
}
$msg = esc_html__(
'Peptide Search AI requires Peptide Repo Core 0.2.0 or newer for peptide data (custom post type and taxonomy). Please install or update Peptide Repo Core — until then, peptide meta boxes, the directory widget, and the search widget are disabled.',
'peptide-search-ai'
);
printf( '<div class="notice notice-error"><p>%s</p></div>', $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $msg already escaped via esc_html__.
}
}

View File

@@ -0,0 +1,197 @@
<?php
/**
* REST API endpoint for the browsable peptide directory.
*
* What: GET /v1/compounds — paginated, filterable compound listing with basic/full field sets.
* Who calls it: PSA_Directory::init() registers the REST route; frontend JS fetches data.
* Dependencies: PSA_Post_Type (meta fields, taxonomy), PSA_Directory (constants).
*
* @package PeptideSearchAI
* @since 4.5.0
* @see includes/class-psa-directory.php — Shortcode rendering, hook registration.
* @see assets/js/psa-directory.js — Frontend grid, filter, modal logic.
*/
declare( strict_types=1 );
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class PSA_Directory_API {
/**
* Register the /v1/compounds endpoint. Public (no auth) — rate limiting at server/CDN level.
*/
public static function register_rest_routes(): void {
register_rest_route(
'peptide-search-ai/v1',
'/compounds',
array(
'methods' => 'GET',
'callback' => array( __CLASS__, 'rest_compounds' ),
'permission_callback' => '__return_true',
'args' => array(
'search' => array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'default' => '',
),
'category' => array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'default' => '',
),
'fields' => array(
'type' => 'string',
'enum' => array( 'basic', 'full' ),
'default' => 'full',
),
'page' => array(
'type' => 'integer',
'default' => 1,
'minimum' => 1,
'sanitize_callback' => 'absint',
),
'per_page' => array(
'type' => 'integer',
'default' => PSA_Directory::PER_PAGE_DEFAULT,
'minimum' => 1,
'maximum' => PSA_Directory::PER_PAGE_MAX,
'sanitize_callback' => 'absint',
),
),
)
);
}
/**
* REST callback for GET /v1/compounds.
* Supports search, category filtering, pagination, and basic/full field sets.
*
* @param WP_REST_Request $request The REST request.
* @return WP_REST_Response
*/
public static function rest_compounds( $request ) {
$search = $request->get_param( 'search' );
$category = $request->get_param( 'category' );
$fields = $request->get_param( 'fields' );
$page = max( 1, (int) $request->get_param( 'page' ) );
$per_page = min( PSA_Directory::PER_PAGE_MAX, max( 1, (int) $request->get_param( 'per_page' ) ) );
$query_args = array(
'post_type' => 'peptide',
'post_status' => 'publish',
'posts_per_page' => $per_page,
'paged' => $page,
'orderby' => 'title',
'order' => 'ASC',
);
if ( ! empty( $search ) ) {
$query_args['s'] = $search;
}
if ( ! empty( $category ) ) {
$query_args['tax_query'] = array(
array(
'taxonomy' => 'peptide_category',
'field' => 'slug',
'terms' => $category,
),
);
}
$query = new WP_Query( $query_args );
$post_ids = wp_list_pluck( $query->posts, 'ID' );
if ( ! empty( $post_ids ) ) {
update_meta_cache( 'post', $post_ids );
}
$compounds = array();
foreach ( $query->posts as $post ) {
$compounds[] = self::format_compound( $post, $fields );
}
$response = new WP_REST_Response(
array(
'compounds' => $compounds,
'total' => (int) $query->found_posts,
'total_pages' => (int) $query->max_num_pages,
'page' => $page,
'per_page' => $per_page,
),
200
);
$response->header( 'X-WP-Total', (string) $query->found_posts );
$response->header( 'X-WP-TotalPages', (string) $query->max_num_pages );
return $response;
}
/**
* Format a single compound for REST response.
*
* @param WP_Post $post The peptide post.
* @param string $fields 'basic' for lightweight data, 'full' for everything.
* @return array Formatted compound data.
*/
public static function format_compound( $post, string $fields = 'full' ): array {
$id = $post->ID;
$terms = wp_get_object_terms( $id, 'peptide_category', array( 'fields' => 'all' ) );
$categories = array();
if ( ! is_wp_error( $terms ) ) {
foreach ( $terms as $term ) {
$categories[] = array(
'slug' => $term->slug,
'name' => $term->name,
);
}
}
$basic = array(
'id' => $id,
'name' => $post->post_title,
'slug' => $post->post_name,
'url' => get_permalink( $id ),
'categories' => $categories,
'half_life' => get_post_meta( $id, 'psa_half_life', true ),
'stability' => get_post_meta( $id, 'psa_stability', true ),
'source' => get_post_meta( $id, 'psa_source', true ),
'extras' => apply_filters( 'psa_directory_card_extras', '', $id ),
);
if ( 'basic' === $fields ) {
$basic['vial_size_mg'] = get_post_meta( $id, 'psa_vial_size_mg', true );
return $basic;
}
$full = array_merge(
$basic,
array(
'excerpt' => wp_trim_words( $post->post_content, 30 ),
'description' => wp_trim_words( $post->post_content, 80 ),
'sequence' => get_post_meta( $id, 'psa_sequence', true ),
'molecular_weight' => get_post_meta( $id, 'psa_molecular_weight', true ),
'molecular_formula' => get_post_meta( $id, 'psa_molecular_formula', true ),
'aliases' => get_post_meta( $id, 'psa_aliases', true ),
'mechanism' => get_post_meta( $id, 'psa_mechanism', true ),
'research_apps' => get_post_meta( $id, 'psa_research_apps', true ),
'safety_profile' => get_post_meta( $id, 'psa_safety_profile', true ),
'dosage_info' => get_post_meta( $id, 'psa_dosage_info', true ),
'references' => get_post_meta( $id, 'psa_references', true ),
'pubchem_cid' => get_post_meta( $id, 'psa_pubchem_cid', true ),
'solubility' => get_post_meta( $id, 'psa_solubility', true ),
'vial_size_mg' => get_post_meta( $id, 'psa_vial_size_mg', true ),
'storage_lyophilized' => get_post_meta( $id, 'psa_storage_lyophilized', true ),
'storage_reconstituted' => get_post_meta( $id, 'psa_storage_reconstituted', true ),
'typical_dose_mcg' => get_post_meta( $id, 'psa_typical_dose_mcg', true ),
'cycle_parameters' => get_post_meta( $id, 'psa_cycle_parameters', true ),
'amino_acid_count' => get_post_meta( $id, 'psa_amino_acid_count', true ),
)
);
return apply_filters( 'psa_rest_compound_data', $full, $id );
}
}

View File

@@ -1,15 +1,16 @@
<?php
/**
* Browsable peptide directory — REST endpoint, shortcode, and data formatting.
* Browsable peptide directory — shortcode rendering and hook registration.
*
* What: Provides the [peptide_directory] shortcode and GET /v1/compounds REST endpoint.
* Who calls it: WordPress shortcode parser, REST API router, main plugin file via PSA_Directory::init().
* Dependencies: PSA_Post_Type (meta fields, taxonomy), PSA_Config.
* What: Provides the [peptide_directory] shortcode and delegates REST to PSA_Directory_API.
* Who calls it: WordPress shortcode parser, main plugin file via PSA_Directory::init().
* Dependencies: PSA_Directory_API (REST endpoint), frontend JS/CSS.
*
* @package PeptideSearchAI
* @see includes/class-psa-post-type.php — CPT + taxonomy registration, meta keys
* @see includes/class-psa-search.php — existing search shortcode (separate from directory)
* @see assets/js/psa-directory.js — frontend grid, filter, modal logic
* @since 4.0.0
* @see includes/class-psa-directory-api.php — REST endpoint and compound formatting.
* @see assets/js/psa-directory.js — Frontend grid, filter, modal logic.
* @see assets/css/psa-directory.css — Directory-specific styles.
*/
declare( strict_types=1 );
@@ -23,225 +24,20 @@ class PSA_Directory {
const PER_PAGE_DEFAULT = 12;
const PER_PAGE_MAX = 100;
/**
* Register hooks.
*/
/** Register hooks. */
public static function init(): void {
add_shortcode( 'peptide_directory', array( __CLASS__, 'render_shortcode' ) );
add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) );
add_action( 'rest_api_init', array( 'PSA_Directory_API', 'register_rest_routes' ) );
}
// -------------------------------------------------------------------------
// REST API
// -------------------------------------------------------------------------
/**
* Register the /v1/compounds endpoint for directory listing and cross-plugin data access.
*
* Public endpoint (no auth required) — serves the browsable directory.
* Rate limiting should be handled at the server/CDN level.
*/
public static function register_rest_routes(): void {
register_rest_route(
'peptide-search-ai/v1',
'/compounds',
array(
'methods' => 'GET',
'callback' => array( __CLASS__, 'rest_compounds' ),
'permission_callback' => '__return_true',
'args' => array(
'search' => array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'default' => '',
),
'category' => array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'default' => '',
),
'fields' => array(
'type' => 'string',
'enum' => array( 'basic', 'full' ),
'default' => 'full',
),
'page' => array(
'type' => 'integer',
'default' => 1,
'minimum' => 1,
'sanitize_callback' => 'absint',
),
'per_page' => array(
'type' => 'integer',
'default' => self::PER_PAGE_DEFAULT,
'minimum' => 1,
'maximum' => self::PER_PAGE_MAX,
'sanitize_callback' => 'absint',
),
),
)
);
}
/**
* REST callback for GET /v1/compounds.
*
* Supports search, category filtering, pagination, and basic/full field sets.
*
* @param WP_REST_Request $request The REST request.
* @return WP_REST_Response
*/
public static function rest_compounds( $request ) {
$search = $request->get_param( 'search' );
$category = $request->get_param( 'category' );
$fields = $request->get_param( 'fields' );
$page = max( 1, (int) $request->get_param( 'page' ) );
$per_page = min( self::PER_PAGE_MAX, max( 1, (int) $request->get_param( 'per_page' ) ) );
$query_args = array(
'post_type' => 'peptide',
'post_status' => 'publish',
'posts_per_page' => $per_page,
'paged' => $page,
'orderby' => 'title',
'order' => 'ASC',
);
// Search filter: title and alias matching.
if ( ! empty( $search ) ) {
$query_args['s'] = $search;
}
// Category filter: taxonomy query.
if ( ! empty( $category ) ) {
$query_args['tax_query'] = array(
array(
'taxonomy' => 'peptide_category',
'field' => 'slug',
'terms' => $category,
),
);
}
$query = new WP_Query( $query_args );
// Prime caches for performance.
$post_ids = wp_list_pluck( $query->posts, 'ID' );
if ( ! empty( $post_ids ) ) {
update_meta_cache( 'post', $post_ids );
}
$compounds = array();
foreach ( $query->posts as $post ) {
$compounds[] = self::format_compound( $post, $fields );
}
$response_data = array(
'compounds' => $compounds,
'total' => (int) $query->found_posts,
'total_pages' => (int) $query->max_num_pages,
'page' => $page,
'per_page' => $per_page,
);
$response = new WP_REST_Response( $response_data, 200 );
// Pagination headers for client convenience.
$response->header( 'X-WP-Total', (string) $query->found_posts );
$response->header( 'X-WP-TotalPages', (string) $query->max_num_pages );
return $response;
}
/**
* Format a single compound for REST response.
*
* @param WP_Post $post The peptide post.
* @param string $fields 'basic' for lightweight data, 'full' for everything.
* @return array Formatted compound data.
*/
public static function format_compound( $post, string $fields = 'full' ): array {
$post_id = $post->ID;
// Get category terms.
$terms = wp_get_object_terms( $post_id, 'peptide_category', array( 'fields' => 'all' ) );
$categories = array();
if ( ! is_wp_error( $terms ) ) {
foreach ( $terms as $term ) {
$categories[] = array(
'slug' => $term->slug,
'name' => $term->name,
);
}
}
$basic = array(
'id' => $post_id,
'name' => $post->post_title,
'slug' => $post->post_name,
'url' => get_permalink( $post_id ),
'categories' => $categories,
'half_life' => get_post_meta( $post_id, 'psa_half_life', true ),
'stability' => get_post_meta( $post_id, 'psa_stability', true ),
'source' => get_post_meta( $post_id, 'psa_source', true ),
);
// Allow other plugins to add data to directory cards (e.g., observation count).
$basic['extras'] = apply_filters( 'psa_directory_card_extras', '', $post_id );
if ( 'basic' === $fields ) {
// Lightweight response for cross-plugin use.
$basic['vial_size_mg'] = get_post_meta( $post_id, 'psa_vial_size_mg', true );
return $basic;
}
// Full response: merge in all meta for detail modal.
$full = array_merge(
$basic,
array(
'excerpt' => wp_trim_words( $post->post_content, 30 ),
'description' => wp_trim_words( $post->post_content, 80 ),
'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 ),
'aliases' => get_post_meta( $post_id, 'psa_aliases', true ),
'mechanism' => get_post_meta( $post_id, 'psa_mechanism', true ),
'research_apps' => get_post_meta( $post_id, 'psa_research_apps', true ),
'safety_profile' => get_post_meta( $post_id, 'psa_safety_profile', true ),
'dosage_info' => get_post_meta( $post_id, 'psa_dosage_info', true ),
'references' => get_post_meta( $post_id, 'psa_references', true ),
'pubchem_cid' => get_post_meta( $post_id, 'psa_pubchem_cid', true ),
'solubility' => get_post_meta( $post_id, 'psa_solubility', true ),
'vial_size_mg' => get_post_meta( $post_id, 'psa_vial_size_mg', true ),
'storage_lyophilized' => get_post_meta( $post_id, 'psa_storage_lyophilized', true ),
'storage_reconstituted' => get_post_meta( $post_id, 'psa_storage_reconstituted', true ),
'typical_dose_mcg' => get_post_meta( $post_id, 'psa_typical_dose_mcg', true ),
'cycle_parameters' => get_post_meta( $post_id, 'psa_cycle_parameters', true ),
'amino_acid_count' => get_post_meta( $post_id, 'psa_amino_acid_count', true ),
)
);
// Allow other plugins (e.g., Peptide Community) to enrich the compound data.
$full = apply_filters( 'psa_rest_compound_data', $full, $post_id );
return $full;
}
// -------------------------------------------------------------------------
// Shortcode
// -------------------------------------------------------------------------
/**
* Render the [peptide_directory] shortcode.
* Outputs a container div populated by frontend JS via REST.
*
* Outputs a container div that the frontend JS populates with the card grid.
* Passes REST endpoint URL and category data via wp_localize_script.
*
* @param array $atts Shortcode attributes (unused for now, reserved for future).
* @param array $atts Shortcode attributes (reserved for future use).
* @return string HTML output.
*/
public static function render_shortcode( $atts ): string {
// Get categories for filter buttons.
$terms = get_terms(
array(
'taxonomy' => 'peptide_category',
@@ -261,7 +57,6 @@ class PSA_Directory {
}
}
// Localize script data for the directory JS.
wp_localize_script(
'psa-directory',
'psaDirectory',
@@ -271,35 +66,35 @@ class PSA_Directory {
'categories' => $categories,
'perPage' => self::PER_PAGE_DEFAULT,
'i18n' => array(
'title' => __( 'Peptide Directory', 'peptide-search-ai' ),
'subtitle' => __( 'Browse reagents with detailed research parameters.', 'peptide-search-ai' ),
'title' => __( 'Peptide Directory', 'peptide-search-ai' ),
'subtitle' => __( 'Browse reagents with detailed research parameters.', 'peptide-search-ai' ),
'searchPlaceholder' => __( 'Search peptides...', 'peptide-search-ai' ),
'showAll' => __( 'Show All', 'peptide-search-ai' ),
'loadMore' => __( 'Load More', 'peptide-search-ai' ),
'loading' => __( 'Loading...', 'peptide-search-ai' ),
'noResults' => __( 'No peptides found matching your criteria.', 'peptide-search-ai' ),
'viewDetails' => __( 'View Research Details', 'peptide-search-ai' ),
'viewFullPage' => __( 'View Full Page', 'peptide-search-ai' ),
'close' => __( 'Close', 'peptide-search-ai' ),
'halfLife' => __( 'Half-Life', 'peptide-search-ai' ),
'stability' => __( 'Stability', 'peptide-search-ai' ),
'solubility' => __( 'Solubility', 'peptide-search-ai' ),
'vialSize' => __( 'Vial Size', 'peptide-search-ai' ),
'storageLyo' => __( 'Storage (Lyophilized)', 'peptide-search-ai' ),
'storageRecon' => __( 'Storage (Reconstituted)', 'peptide-search-ai' ),
'molecularWeight' => __( 'Molecular Weight', 'peptide-search-ai' ),
'formula' => __( 'Formula', 'peptide-search-ai' ),
'typicalDose' => __( 'Typical Dose', 'peptide-search-ai' ),
'cycleParams' => __( 'Cycle Parameters', 'peptide-search-ai' ),
'sequence' => __( 'Sequence', 'peptide-search-ai' ),
'singleReagents' => __( 'Single Reagents', 'peptide-search-ai' ),
'protocolModels' => __( 'Protocol Models', 'peptide-search-ai' ),
'comingSoon' => __( 'Coming Soon', 'peptide-search-ai' ),
'researchParams' => __( 'Research Parameters', 'peptide-search-ai' ),
'communityObs' => __( 'Community Observations', 'peptide-search-ai' ),
'pubchemLink' => __( 'View on PubChem', 'peptide-search-ai' ),
'copySequence' => __( 'Copy', 'peptide-search-ai' ),
'copied' => __( 'Copied!', 'peptide-search-ai' ),
'showAll' => __( 'Show All', 'peptide-search-ai' ),
'loadMore' => __( 'Load More', 'peptide-search-ai' ),
'loading' => __( 'Loading...', 'peptide-search-ai' ),
'noResults' => __( 'No peptides found matching your criteria.', 'peptide-search-ai' ),
'viewDetails' => __( 'View Research Details', 'peptide-search-ai' ),
'viewFullPage' => __( 'View Full Page', 'peptide-search-ai' ),
'close' => __( 'Close', 'peptide-search-ai' ),
'halfLife' => __( 'Half-Life', 'peptide-search-ai' ),
'stability' => __( 'Stability', 'peptide-search-ai' ),
'solubility' => __( 'Solubility', 'peptide-search-ai' ),
'vialSize' => __( 'Vial Size', 'peptide-search-ai' ),
'storageLyo' => __( 'Storage (Lyophilized)', 'peptide-search-ai' ),
'storageRecon' => __( 'Storage (Reconstituted)', 'peptide-search-ai' ),
'molecularWeight' => __( 'Molecular Weight', 'peptide-search-ai' ),
'formula' => __( 'Formula', 'peptide-search-ai' ),
'typicalDose' => __( 'Typical Dose', 'peptide-search-ai' ),
'cycleParams' => __( 'Cycle Parameters', 'peptide-search-ai' ),
'sequence' => __( 'Sequence', 'peptide-search-ai' ),
'singleReagents' => __( 'Single Reagents', 'peptide-search-ai' ),
'protocolModels' => __( 'Protocol Models', 'peptide-search-ai' ),
'comingSoon' => __( 'Coming Soon', 'peptide-search-ai' ),
'researchParams' => __( 'Research Parameters', 'peptide-search-ai' ),
'communityObs' => __( 'Community Observations', 'peptide-search-ai' ),
'pubchemLink' => __( 'View on PubChem', 'peptide-search-ai' ),
'copySequence' => __( 'Copy', 'peptide-search-ai' ),
'copied' => __( 'Copied!', 'peptide-search-ai' ),
),
)
);
@@ -314,4 +109,18 @@ class PSA_Directory {
<?php
return ob_get_clean();
}
// ── Backward-compatible proxies (REST moved to PSA_Directory_API) ───
/** @see PSA_Directory_API::register_rest_routes() */
public static function register_rest_routes(): void {
PSA_Directory_API::register_rest_routes(); }
/** @see PSA_Directory_API::rest_compounds() */
public static function rest_compounds( $request ) {
return PSA_Directory_API::rest_compounds( $request ); }
/** @see PSA_Directory_API::format_compound() */
public static function format_compound( $post, string $fields = 'full' ): array {
return PSA_Directory_API::format_compound( $post, $fields ); }
}

View File

@@ -79,7 +79,7 @@ class PSA_Encryption {
}
// First 16 bytes are the IV.
$iv = substr( $data, 0, 16 );
$iv = substr( $data, 0, 16 );
$encrypted_data = substr( $data, 16 );
// Decrypt.

View File

@@ -47,7 +47,7 @@ class PSA_KB_Builder {
return;
}
$title = sanitize_text_field( $ai_data['name'] ?? $peptide_name );
$title = sanitize_text_field( $ai_data['name'] ?? $peptide_name );
$existing = get_posts(
array(
'post_type' => 'epkb_post_type_1',
@@ -124,10 +124,10 @@ class PSA_KB_Builder {
// Research & Potential Benefits (with h3 subheadings).
if ( ! empty( $ai_data['research_benefits'] ) || ! empty( $ai_data['research_applications'] ) ) {
$c .= '<h2>Research &amp; Potential Benefits</h2>' . "\n";
$c .= '<p><em>The following potential benefits are based on animal studies and preclinical research. Human clinical trial data is extremely limited.</em></p>' . "\n";
$c .= '<h2>Research &amp; Potential Benefits</h2>' . "\n";
$c .= '<p><em>The following potential benefits are based on animal studies and preclinical research. Human clinical trial data is extremely limited.</em></p>' . "\n";
$research = $ai_data['research_benefits'] ?? $ai_data['research_applications'] ?? '';
$c .= self::research_with_subheadings( $research );
$c .= self::research_with_subheadings( $research );
}
// Administration & Dosing.

View File

@@ -90,15 +90,15 @@ class PSA_OpenRouter {
array(
'timeout' => 120,
'headers' => array(
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $api_key,
'HTTP-Referer' => $site_url,
'X-Title' => $site_name,
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $api_key,
'HTTP-Referer' => $site_url,
'X-Title' => $site_name,
),
'body' => wp_json_encode(
'body' => wp_json_encode(
array(
'model' => $model,
'messages' => array(
'model' => $model,
'messages' => array(
array(
'role' => 'system',
'content' => 'You are a scientific database assistant. Return only valid JSON.',
@@ -108,8 +108,8 @@ class PSA_OpenRouter {
'content' => $prompt,
),
),
'max_tokens' => $max_tokens,
'temperature' => 0.3,
'max_tokens' => $max_tokens,
'temperature' => 0.3,
)
),
)
@@ -147,10 +147,10 @@ class PSA_OpenRouter {
}
// Log API call with token usage.
$response_text = $body['choices'][0]['message']['content'];
$prompt_tokens = (int) ( $body['usage']['prompt_tokens'] ?? 0 );
$response_text = $body['choices'][0]['message']['content'];
$prompt_tokens = (int) ( $body['usage']['prompt_tokens'] ?? 0 );
$completion_tokens = (int) ( $body['usage']['completion_tokens'] ?? 0 );
$token_source = 'api';
$token_source = 'api';
// Diagnostic: log the raw usage structure once per model so we can verify
// whether the provider returns token counts. Remove after confirming.
@@ -172,14 +172,14 @@ class PSA_OpenRouter {
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,
'token_source' => $token_source,
'provider' => 'openrouter',
'model' => $model,
'prompt_tokens' => $prompt_tokens,
'completion_tokens' => $completion_tokens,
'request_type' => $request_type,
'peptide_name' => $peptide_name,
'success' => true,
'token_source' => $token_source,
)
);

View File

@@ -0,0 +1,147 @@
<?php
/**
* Meta box rendering and save handlers for the Peptide CPT editor.
*
* What: Adds and renders meta boxes (core + extended fields), saves meta on post update.
* Who calls it: PSA_Post_Type::init_admin() registers hooks that point here.
* Dependencies: PSA_Post_Type (META_FIELDS, EXTENDED_META_FIELDS constants).
*
* @package PeptideSearchAI
* @since 4.5.0
* @see includes/class-psa-post-type.php — CPT registration, taxonomy, admin columns.
*/
declare( strict_types=1 );
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class PSA_Post_Type_Meta {
/** Register meta boxes on the peptide editor screen. */
public static function add_meta_boxes(): void {
add_meta_box(
'psa_peptide_data',
'Peptide Scientific Data',
array( __CLASS__, 'render_meta_box' ),
'peptide',
'normal',
'high'
);
add_meta_box(
'psa_extended_data',
'Extended Research Data',
array( __CLASS__, 'render_extended_meta_box' ),
'peptide',
'normal',
'default'
);
}
/**
* Render core meta box fields (sequence, molecular data, mechanism, etc.).
*
* @param WP_Post $post The current post object.
*/
public static function render_meta_box( $post ): void {
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 ( PSA_Post_Type::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>';
}
/**
* Render extended meta box fields (half-life, stability, storage, etc.).
*
* @param WP_Post $post The current post object.
*/
public static function render_extended_meta_box( $post ): void {
echo '<table class="form-table">';
foreach ( PSA_Post_Type::EXTENDED_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_vial_size_mg' === $key ) {
echo '<input type="number" step="0.01" name="' . esc_attr( $key ) . '" id="' . esc_attr( $key ) . '" value="' . esc_attr( $value ) . '" class="small-text" /> mg';
} elseif ( 'psa_amino_acid_count' === $key ) {
echo '<input type="number" step="1" name="' . esc_attr( $key ) . '" id="' . esc_attr( $key ) . '" value="' . esc_attr( $value ) . '" class="small-text" />';
} 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 (core + extended) on post save.
*
* @param int $post_id The post ID.
*/
public static function save_meta( int $post_id ): void {
// 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;
}
$numeric_fields = array( 'psa_vial_size_mg', 'psa_amino_acid_count' );
$all_fields = array_merge( PSA_Post_Type::META_FIELDS, PSA_Post_Type::EXTENDED_META_FIELDS );
foreach ( $all_fields as $key => $label ) {
if ( isset( $_POST[ $key ] ) ) {
$raw = wp_unslash( $_POST[ $key ] );
if ( in_array( $key, $numeric_fields, true ) ) {
$value = ( '' === trim( $raw ) ) ? '' : (string) max( 0, floatval( $raw ) );
} else {
$value = sanitize_textarea_field( $raw );
}
update_post_meta( $post_id, $key, $value );
}
}
}
}

View File

@@ -1,14 +1,26 @@
<?php
/**
* Registers the Peptide custom post type, taxonomy, and meta fields.
* PSA's consumer-side integration with the `peptide` custom post type.
*
* What: Registers the 'peptide' CPT, 'peptide_category' taxonomy, meta boxes, and save handlers.
* Who calls it: Main plugin file via psa_init() and psa_admin_init() hooks.
* Dependencies: WordPress CPT API, taxonomy API, meta box API.
* As of v4.5.0 the `peptide` CPT and `peptide_category` taxonomy are owned by
* Peptide Repo Core (PR Core) >= 0.2.0 — PSA no longer registers them. This
* class now wires PSA-owned admin surfaces (meta boxes, admin columns) onto
* the CPT that PR Core registers, and exposes the `psa_*` meta key namespace
* via constants for the rest of PSA to consume.
*
* What: Admin columns, meta key definitions, get_all_meta_keys() helper, and
* backward-compatible proxies to PSA_Post_Type_Meta.
* Who calls it: `psa_admin_init()` in the main plugin file, gated by
* PSA_Dependency_Check::is_satisfied() so admin surfaces don't bind against
* a missing CPT when PR Core is absent.
* Dependencies: PR Core >= 0.2.0 for the CPT itself (runtime); WordPress
* meta-box + admin-column APIs; PSA_Post_Type_Meta for meta box rendering.
*
* @package PeptideSearchAI
* @see includes/class-psa-ai-generator.php — auto-assigns category term after generation
* @see includes/class-psa-directory.php — reads taxonomy for directory filtering
* @since 1.0.0 (CPT registration removed in 4.5.0)
* @see includes/class-psa-post-type-meta.php — Meta box rendering and save handlers.
* @see includes/class-psa-dependency-check.php — PR Core gate used by init_admin().
* @see includes/class-psa-directory.php — Reads `peptide_category` terms for directory filtering.
*/
declare( strict_types=1 );
@@ -19,8 +31,14 @@ if ( ! defined( 'ABSPATH' ) ) {
class PSA_Post_Type {
/**
* Default peptide category terms pre-populated on activation.
* Key = slug, value = display name.
* Canonical slug → display-name map for the peptide categories PSA's
* AI and admin tools know how to assign terms to. This is a consumer
* reference list (used by PSA_AI_Content::assign_category_term() and
* the "Migrate Existing Peptides to Categories" admin button) — not a
* seed list. As of PSA v4.5.0 PR Core >= 0.2.0 owns the
* `peptide_category` taxonomy; the 8 terms below were seeded by PSA
* in pre-v4.5.0 releases and remain intact in `wp_term_taxonomy`. PSA
* no longer seeds them on activation.
*/
const DEFAULT_CATEGORIES = array(
'tissue-repair' => 'Tissue Repair',
@@ -48,249 +66,34 @@ class PSA_Post_Type {
'psa_pubchem_cid' => 'PubChem CID',
);
/**
* Extended meta fields added in v4.3.0 for the directory feature.
* Separated from META_FIELDS so existing code continues to work unchanged.
*/
/** Extended meta fields added in v4.3.0 for the directory feature. */
const EXTENDED_META_FIELDS = array(
'psa_half_life' => 'Half-Life',
'psa_stability' => 'Stability',
'psa_solubility' => 'Solubility / Reconstitution',
'psa_vial_size_mg' => 'Common Vial Size (mg)',
'psa_storage_lyophilized' => 'Storage (Lyophilized)',
'psa_storage_reconstituted' => 'Storage (Reconstituted)',
'psa_typical_dose_mcg' => 'Typical Research Dose',
'psa_cycle_parameters' => 'Cycle / Protocol Parameters',
'psa_amino_acid_count' => 'Amino Acid Count',
'psa_half_life' => 'Half-Life',
'psa_stability' => 'Stability',
'psa_solubility' => 'Solubility / Reconstitution',
'psa_vial_size_mg' => 'Common Vial Size (mg)',
'psa_storage_lyophilized' => 'Storage (Lyophilized)',
'psa_storage_reconstituted' => 'Storage (Reconstituted)',
'psa_typical_dose_mcg' => 'Typical Research Dose',
'psa_cycle_parameters' => 'Cycle / Protocol Parameters',
'psa_amino_acid_count' => 'Amino Acid Count',
);
/**
* Register admin-only hooks (meta boxes, save, admin columns).
*
* Caller (`psa_admin_init`) gates this behind
* PSA_Dependency_Check::is_satisfied() so meta boxes + admin columns
* don't attempt to bind against a `peptide` CPT that PR Core has not
* yet registered.
*/
public static function init_admin(): void {
add_action( 'add_meta_boxes', array( __CLASS__, 'add_meta_boxes' ) );
add_action( 'save_post_peptide', array( __CLASS__, 'save_meta' ) );
add_action( 'add_meta_boxes', array( 'PSA_Post_Type_Meta', 'add_meta_boxes' ) );
add_action( 'save_post_peptide', array( 'PSA_Post_Type_Meta', 'save_meta' ) );
add_filter( 'manage_peptide_posts_columns', array( __CLASS__, 'add_admin_columns' ) );
add_action( 'manage_peptide_posts_custom_column', array( __CLASS__, 'render_admin_column' ), 10, 2 );
}
/**
* Register the peptide CPT.
*/
public static function register_peptide_post_type(): void {
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,
)
);
}
/**
* Register the peptide_category taxonomy on the peptide CPT.
* Called from psa_init() after CPT registration.
*/
public static function register_taxonomy(): void {
if ( taxonomy_exists( 'peptide_category' ) ) {
return;
}
$labels = array(
'name' => __( 'Peptide Categories', 'peptide-search-ai' ),
'singular_name' => __( 'Peptide Category', 'peptide-search-ai' ),
'search_items' => __( 'Search Categories', 'peptide-search-ai' ),
'all_items' => __( 'All Categories', 'peptide-search-ai' ),
'edit_item' => __( 'Edit Category', 'peptide-search-ai' ),
'update_item' => __( 'Update Category', 'peptide-search-ai' ),
'add_new_item' => __( 'Add New Category', 'peptide-search-ai' ),
'new_item_name' => __( 'New Category Name', 'peptide-search-ai' ),
'menu_name' => __( 'Categories', 'peptide-search-ai' ),
);
register_taxonomy(
'peptide_category',
'peptide',
array(
'labels' => $labels,
'hierarchical' => true,
'public' => true,
'show_in_rest' => true,
'show_admin_column' => true,
'rewrite' => array( 'slug' => 'peptide-category' ),
)
);
}
/**
* Pre-populate default category terms on activation.
* Safe to call multiple times — skips existing terms.
*/
public static function populate_default_categories(): void {
foreach ( self::DEFAULT_CATEGORIES as $slug => $name ) {
if ( ! term_exists( $slug, 'peptide_category' ) ) {
wp_insert_term( $name, 'peptide_category', array( 'slug' => $slug ) );
}
}
}
/**
* Add meta boxes to the peptide editor.
*/
public static function add_meta_boxes(): void {
add_meta_box(
'psa_peptide_data',
'Peptide Scientific Data',
array( __CLASS__, 'render_meta_box' ),
'peptide',
'normal',
'high'
);
add_meta_box(
'psa_extended_data',
'Extended Research Data',
array( __CLASS__, 'render_extended_meta_box' ),
'peptide',
'normal',
'default'
);
}
/**
* Render the core meta box fields.
*
* @param WP_Post $post The current post object.
* @return void
*/
public static function render_meta_box( $post ): void {
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>';
}
/**
* Render the extended meta box fields (half-life, stability, storage, etc.).
*
* @param WP_Post $post The current post object.
* @return void
*/
public static function render_extended_meta_box( $post ): void {
echo '<table class="form-table">';
foreach ( self::EXTENDED_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_vial_size_mg' === $key ) {
echo '<input type="number" step="0.01" name="' . esc_attr( $key ) . '" id="' . esc_attr( $key ) . '" value="' . esc_attr( $value ) . '" class="small-text" /> mg';
} elseif ( 'psa_amino_acid_count' === $key ) {
echo '<input type="number" step="1" name="' . esc_attr( $key ) . '" id="' . esc_attr( $key ) . '" value="' . esc_attr( $value ) . '" class="small-text" />';
} 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 (core + extended).
*
* @param int $post_id The post ID.
*/
public static function save_meta( int $post_id ): void {
// 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;
}
$numeric_fields = array( 'psa_vial_size_mg', 'psa_amino_acid_count' );
$all_fields = array_merge( self::META_FIELDS, self::EXTENDED_META_FIELDS );
foreach ( $all_fields as $key => $label ) {
if ( isset( $_POST[ $key ] ) ) {
$raw = wp_unslash( $_POST[ $key ] );
if ( in_array( $key, $numeric_fields, true ) ) {
// Numeric fields: store as non-negative number or empty string.
$value = ( '' === trim( $raw ) ) ? '' : (string) max( 0, floatval( $raw ) );
} else {
$value = sanitize_textarea_field( $raw );
}
update_post_meta( $post_id, $key, $value );
}
}
}
/**
* Add custom columns to the peptide admin list.
*
@@ -301,7 +104,6 @@ class PSA_Post_Type {
$new = array();
foreach ( $columns as $key => $label ) {
$new[ $key ] = $label;
// Insert source column after title.
if ( 'title' === $key ) {
$new['psa_source'] = __( 'Source', 'peptide-search-ai' );
}
@@ -335,9 +137,24 @@ class PSA_Post_Type {
* @return array<string> Meta keys.
*/
public static function get_all_meta_keys(): array {
return array_merge(
array_keys( self::META_FIELDS ),
array_keys( self::EXTENDED_META_FIELDS )
);
return array_merge( array_keys( self::META_FIELDS ), array_keys( self::EXTENDED_META_FIELDS ) );
}
// ── Backward-compatible proxies (meta boxes moved to PSA_Post_Type_Meta) ──
/** @see PSA_Post_Type_Meta::add_meta_boxes() */
public static function add_meta_boxes(): void {
PSA_Post_Type_Meta::add_meta_boxes(); }
/** @see PSA_Post_Type_Meta::render_meta_box() */
public static function render_meta_box( $post ): void {
PSA_Post_Type_Meta::render_meta_box( $post ); }
/** @see PSA_Post_Type_Meta::render_extended_meta_box() */
public static function render_extended_meta_box( $post ): void {
PSA_Post_Type_Meta::render_extended_meta_box( $post ); }
/** @see PSA_Post_Type_Meta::save_meta() */
public static function save_meta( int $post_id ): void {
PSA_Post_Type_Meta::save_meta( $post_id ); }
}

View File

@@ -0,0 +1,242 @@
<?php
/**
* AJAX search handler and peptide generation workflow.
*
* What: Orchestrates the AJAX search flow — checks existing results, handles
* pending retries, validates new peptide names, creates placeholders,
* and schedules background generation.
* Who calls it: PSA_Search::init() registers the AJAX action that invokes ajax_search().
* Dependencies: PSA_Config, PSA_AI_Generator, PSA_Search (for search_peptides/find_pending).
*
* @package PeptideSearchAI
* @since 2.6.0
* @see includes/class-psa-search.php — Core search and cache logic.
* @see includes/class-psa-ai-generator.php — Peptide validation and content generation.
*/
declare( strict_types=1 );
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class PSA_Search_Handler {
/**
* AJAX handler: search for peptides.
*
* Orchestrates: search → pending check → rate limit → validate → generate.
* Side effects: nonce check, JSON response, may create posts and schedule cron.
*/
public static function ajax_search(): void {
check_ajax_referer( 'psa_search_nonce', 'nonce' );
$query = isset( $_GET['q'] ) ? sanitize_text_field( wp_unslash( $_GET['q'] ) ) : '';
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 = PSA_Search::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 = PSA_Search::find_pending_peptide( $query );
if ( $pending ) {
self::handle_pending_retry( $pending );
return;
}
// 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 );
}
/**
* Handle a pending peptide — retry if timed out, else return pending status.
*
* Side effects: may update post meta, schedule cron, JSON response.
*
* @param WP_Post $pending The pending post.
*/
private static function handle_pending_retry( $pending ): void {
$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 ) {
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;
}
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.
*
* @return bool True if under limit.
*/
private static function check_rate_limit(): bool {
$ip = psa_get_client_ip();
$rate_key = 'psa_rate_' . md5( $ip );
$count_transient = (int) get_transient( $rate_key );
$count_cache = (int) wp_cache_get( $rate_key, 'psa_rate_limits' );
$count = max( $count_transient, $count_cache );
return $count < PSA_Config::AJAX_RATE_LIMIT;
}
/**
* Handle a new generation request — validate, create placeholder, and schedule.
*
* Side effects: AI validation call, post creation, rate limit update, cron schedule, JSON response.
*
* @param string $query The search query (peptide name).
*/
private static function handle_new_generation( string $query ): void {
// 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;
}
$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.',
)
);
}
// Valid peptide — increment rate limit and create placeholder.
$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.
set_transient( $daily_key, $daily_count + 1, DAY_IN_SECONDS );
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,
)
);
}
/**
* Create a placeholder draft post for the pending peptide.
*
* @param string $peptide_name Canonical peptide name.
* @return int|WP_Error Post ID or error.
*/
public static function create_pending_placeholder( string $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() );
do_action( 'psa_peptide_created', $post_id, $peptide_name );
}
return $post_id;
}
/**
* Schedule background generation using WP-Cron (non-blocking).
*
* @param int $post_id Placeholder post ID.
* @param string $peptide_name Peptide to research.
*/
public static function schedule_background_generation( int $post_id, string $peptide_name ): void {
$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();
}
}

View File

@@ -1,13 +1,13 @@
<?php
/**
* Handles AJAX search, the [peptide_search] shortcode, and the REST API endpoint.
* Core search, caching, shortcode rendering, and REST endpoint for peptides.
*
* What: Core search logic — AJAX handler, REST endpoint, shortcode rendering, cache management.
* Who calls it: WordPress AJAX/REST hooks, shortcode parser, save_post_peptide action.
* Dependencies: PSA_Config, PSA_AI_Generator, WordPress AJAX/REST/shortcode APIs.
* What: Shortcode rendering, REST search, transient caching, result formatting.
* Who calls it: WordPress shortcode parser, REST API, PSA_Search_Handler (for search_peptides).
* Dependencies: PSA_Config, PSA_Search_Handler (for AJAX/generation workflow).
*
* @package PeptideSearchAI
* @see includes/class-psa-ai-generator.php
* @see includes/class-psa-search-handler.php — AJAX handler and generation workflow.
* @see includes/class-psa-config.php
*/
declare( strict_types=1 );
@@ -18,66 +18,45 @@ if ( ! defined( 'ABSPATH' ) ) {
class PSA_Search {
/**
* Static counter for unique shortcode instance IDs.
*
* @var int
*/
/** @var int Static counter for unique shortcode instance IDs. */
private static $instance_count = 0;
public static function init(): void {
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( 'wp_ajax_psa_search', array( 'PSA_Search_Handler', 'ajax_search' ) );
add_action( 'wp_ajax_nopriv_psa_search', array( 'PSA_Search_Handler', '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.
* 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 bypass stale results.
*
* @return int The current generation counter (default 0).
* @return int Current generation counter.
*/
private static function get_cache_generation(): int {
$gen = (int) get_option( 'psa_search_cache_gen', 0 );
return $gen;
return (int) get_option( 'psa_search_cache_gen', 0 );
}
/**
* 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.
* O(1) option update vs. O(n) DELETE query.
*
* @param int $post_id The post ID being saved.
*/
public static function invalidate_search_cache( int $post_id ): void {
$current_gen = self::get_cache_generation();
update_option( 'psa_search_cache_gen', $current_gen + 1 );
update_option( 'psa_search_cache_gen', self::get_cache_generation() + 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
* WARNING: Expensive — only use for manual admin actions, not on every save.
*/
public static function flush_all_search_caches(): void {
global $wpdb;
@@ -89,10 +68,7 @@ class PSA_Search {
);
}
/**
* Register REST API routes for external/headless access.
* Note: REST route only returns existing published results — it does NOT trigger generation.
*/
/** Register REST API route for external/headless access. */
public static function register_rest_routes(): void {
register_rest_route(
'peptides/v1',
@@ -118,44 +94,27 @@ class PSA_Search {
* 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 );
$count_cache = (int) wp_cache_get( $rate_key, 'psa_rate_limits' );
$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
);
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.' ),
@@ -163,28 +122,23 @@ class PSA_Search {
);
}
$results = self::search_peptides( $query );
return new WP_REST_Response( $results, 200 );
return new WP_REST_Response( self::search_peptides( $query ), 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.
* Uses class-based selectors (no IDs) to support multiple instances per page.
*
* @param array $atts Shortcode attributes.
* @return string HTML output.
*/
public static function render_search_form( $atts ): string {
self::$instance_count++;
++self::$instance_count;
$instance = self::$instance_count;
$atts = shortcode_atts(
array(
'placeholder' => 'Search for a peptide (e.g., BPC-157, Thymosin Beta-4)...',
),
array( 'placeholder' => 'Search for a peptide (e.g., BPC-157, Thymosin Beta-4)...' ),
$atts,
'peptide_search'
);
@@ -225,210 +179,10 @@ class PSA_Search {
}
/**
* 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(): void {
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 ): void {
$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(): bool {
$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( string $query ): void {
// 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.
* Find an existing pending peptide post by name or alias.
*
* @param string $query Search term (peptide name or alias).
* @return WP_Post|null Pending post object or null if not found.
* @return WP_Post|null Pending post or null.
*/
public static function find_pending_peptide( string $query ) {
global $wpdb;
@@ -458,62 +212,16 @@ class PSA_Search {
}
/**
* Create a placeholder draft post so we can track the pending state.
* Core search — queries CPT by title and aliases (published only).
*
* @param string $peptide_name The canonical peptide name.
* @return int|WP_Error Post ID or error.
*/
public static function create_pending_placeholder( string $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( int $post_id, string $peptide_name ): void {
$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).
* Includes transient caching with generation-counter invalidation.
*
* @param string $query Search term.
* @return array { results: array, total: int, query: string }
* @return array{results: array, total: int, query: string}
*/
public static function search_peptides( string $query ): array {
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 ) {
@@ -539,7 +247,6 @@ class PSA_Search {
);
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 );
}
@@ -555,13 +262,9 @@ class PSA_Search {
'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;
return apply_filters( 'psa_search_results', $response, $query );
}
/**

View File

@@ -210,7 +210,7 @@ class PSA_Template {
'class' => 'psa-badge-manual',
),
);
$badge = $badges[ $source ] ?? $badges['ai-generated'];
$badge = $badges[ $source ] ?? $badges['ai-generated'];
$html = '<div class="psa-source-badge ' . esc_attr( $badge['class'] ) . '">';
$html .= esc_html( $badge['label'] );

View File

@@ -0,0 +1,83 @@
<?php
/**
* Schema upgrade helpers for Peptide Search AI.
*
* What: Pure-function helpers that decide whether a database schema upgrade
* is required, plus the driver that runs the upgrade when the installed
* code version is newer than the stored psa_db_version option.
*
* Who triggers it: psa_admin_init() in peptide-search-ai.php calls
* PSA_Upgrade::maybe_run() on every WordPress admin_init hook.
*
* Dependencies: PSA_Cost_Tracker (for re-running dbDelta on the
* cost-tracker table), WordPress options API (get_option / update_option),
* and the PSA_VERSION constant defined in peptide-search-ai.php.
*
* @package PeptideSearchAI
*/
declare( strict_types=1 );
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* PSA_Upgrade — thin wrapper around version_compare + dbDelta.
*
* The class exists so the decision logic (is_needed) can be unit-tested as a
* pure function, while the impure driver (maybe_run) stays isolated.
*/
class PSA_Upgrade {
/**
* Option name used to persist the schema version last migrated to.
*
* @var string
*/
const OPTION_NAME = 'psa_db_version';
/**
* Default stored version for fresh installs. Strictly less than any
* real released version so version_compare treats it as "older".
*
* @var string
*/
const DEFAULT_STORED_VERSION = '0.0.0';
/**
* Decide whether a schema upgrade is needed given a stored version and
* the currently-installed code version. Pure function — no side effects,
* no WordPress API calls — so it can be exercised in isolation.
*
* @param string $stored_version Version string recorded in psa_db_version.
* @param string $current_version Version string of the running code.
* @return bool True if an upgrade must run; false otherwise.
*/
public static function is_needed( string $stored_version, string $current_version ): bool {
return version_compare( $stored_version, $current_version, '<' );
}
/**
* Run database upgrades when the installed code version is newer than
* the stored psa_db_version option. Keeps users from needing to
* deactivate/reactivate the plugin after a schema change.
*
* Side effects: reads psa_db_version option; on upgrade, calls
* PSA_Cost_Tracker::create_table() and writes psa_db_version.
*
* @return void
*/
public static function maybe_run(): void {
$stored = get_option( self::OPTION_NAME, self::DEFAULT_STORED_VERSION );
if ( ! self::is_needed( $stored, PSA_VERSION ) ) {
return;
}
// Re-run CREATE TABLE via dbDelta — additive schema changes (new
// columns, new indexes) are applied automatically.
PSA_Cost_Tracker::create_table();
update_option( self::OPTION_NAME, PSA_VERSION, false );
}
}

13
package-lock.json generated Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "peptide-search-ai",
"version": "4.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "peptide-search-ai",
"version": "4.7.0",
"devDependencies": {}
}
}
}

8
package.json Normal file
View File

@@ -0,0 +1,8 @@
{
"name": "peptide-search-ai",
"version": "4.7.0",
"description": "Peptide Search AI WordPress plugin — JS assets",
"private": true,
"scripts": {},
"devDependencies": {}
}

View File

@@ -1,10 +1,10 @@
<?php
/**
* Plugin Name: Peptide Search AI
* Plugin URI: https://example.com/peptide-search-ai
* Plugin URI: https://github.com/peptiderepo/peptide-search-ai
* Description: Searchable peptide database with AI-powered auto-population and browsable directory.
* Version: 4.4.3
* Author: Terence
* Version: 4.7.0
* Author: peptiderepo
* License: GPL v2 or later
* Text Domain: peptide-search-ai
* Requires at least: 5.8
@@ -15,31 +15,48 @@ if ( ! defined( 'ABSPATH' ) ) {
exit;
}
define( 'PSA_VERSION', '4.4.3' );
define( 'PSA_VERSION', '4.7.0' );
define( 'PSA_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'PSA_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
define( 'PSA_PLUGIN_FILE', __FILE__ );
require_once PSA_PLUGIN_DIR . 'includes/class-psa-config.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-encryption.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-dependency-check.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-post-type.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-post-type-meta.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-search-handler.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-search.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-cost-reporter.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-cost-tracker.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-openrouter.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-kb-builder.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-ai-content.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-page.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-admin.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-batch-enrichment.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-template.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-directory-api.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-directory.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-upgrade.php';
function psa_init() {
PSA_Post_Type::register_peptide_post_type();
PSA_Post_Type::register_taxonomy();
// Settings + dependency notice always boot so the admin can see plugin
// state and act on a missing PR Core dependency.
PSA_Admin::init();
PSA_Dependency_Check::init();
// PR Core >= 0.2.0 owns `peptide` CPT + `peptide_category` taxonomy as
// of v4.5.0. Gate CPT-dependent surfaces so PSA degrades gracefully
// instead of binding hooks against an unregistered post type.
if ( ! PSA_Dependency_Check::is_satisfied() ) {
return;
}
PSA_Search::init();
PSA_AI_Generator::init();
PSA_Admin::init();
PSA_Batch_Enrichment::init();
PSA_Template::init();
PSA_Directory::init();
@@ -47,33 +64,17 @@ function psa_init() {
add_action( 'init', 'psa_init' );
function psa_admin_init() {
PSA_Post_Type::init_admin();
psa_maybe_run_upgrades();
}
add_action( 'admin_init', 'psa_admin_init' );
PSA_Upgrade::maybe_run();
/**
* Run database upgrades when the installed code version is newer than the
* stored `psa_db_version` option. Keeps users from needing to
* deactivate/reactivate the plugin after a schema change.
*
* Triggered on admin_init so dbDelta runs once per upgrade, not on every
* frontend page load.
*
* @return void
*/
function psa_maybe_run_upgrades() {
$stored = get_option( 'psa_db_version', '0.0.0' );
if ( version_compare( $stored, PSA_VERSION, '>=' ) ) {
// Meta boxes + admin columns bind to the `peptide` CPT; only register
// when PR Core has provided it.
if ( ! PSA_Dependency_Check::is_satisfied() ) {
return;
}
// Re-run CREATE TABLE via dbDelta — additive schema changes (new columns)
// will be applied automatically.
PSA_Cost_Tracker::create_table();
update_option( 'psa_db_version', PSA_VERSION, false );
PSA_Post_Type::init_admin();
}
add_action( 'admin_init', 'psa_admin_init' );
/**
* Check if current page is the Echo KB main page.
@@ -155,20 +156,20 @@ function psa_enqueue_frontend_assets() {
'ajaxurl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'psa_search_nonce' ),
'i18n' => array(
'error' => __( 'Error:', 'peptide-search-ai' ),
'searchFailed' => __( 'Search failed.', 'peptide-search-ai' ),
'unexpected' => __( 'Unexpected response.', 'peptide-search-ai' ),
'rateLimited' => __( 'Too many requests. Please try again later.', 'peptide-search-ai' ),
'networkError' => __( 'Could not connect to the server. Please try again.', 'peptide-search-ai' ),
'timeout' => __( 'Request timed out. The server took too long to respond. Please try again.', 'peptide-search-ai' ),
'connectionError' => __( 'Network error. Please check your connection and try again.', 'peptide-search-ai' ),
'serverError' => __( 'Server error. Please try again later.', 'peptide-search-ai' ),
'unavailable' => __( 'Server is temporarily unavailable. Please try again later.', 'peptide-search-ai' ),
'peptidesFound' => __( 'peptide(s) found', 'peptide-search-ai' ),
'pendingMsg' => __( 'is currently being added to our database. Please check back again later.', 'peptide-search-ai' ),
'invalidMsg' => __( 'does not appear to be a recognized peptide. Please check the spelling or try a different search term.', 'peptide-search-ai' ),
'verified' => __( 'Verified', 'peptide-search-ai' ),
'curated' => __( 'Curated', 'peptide-search-ai' ),
'error' => __( 'Error:', 'peptide-search-ai' ),
'searchFailed' => __( 'Search failed.', 'peptide-search-ai' ),
'unexpected' => __( 'Unexpected response.', 'peptide-search-ai' ),
'rateLimited' => __( 'Too many requests. Please try again later.', 'peptide-search-ai' ),
'networkError' => __( 'Could not connect to the server. Please try again.', 'peptide-search-ai' ),
'timeout' => __( 'Request timed out. The server took too long to respond. Please try again.', 'peptide-search-ai' ),
'connectionError' => __( 'Network error. Please check your connection and try again.', 'peptide-search-ai' ),
'serverError' => __( 'Server error. Please try again later.', 'peptide-search-ai' ),
'unavailable' => __( 'Server is temporarily unavailable. Please try again later.', 'peptide-search-ai' ),
'peptidesFound' => __( 'peptide(s) found', 'peptide-search-ai' ),
'pendingMsg' => __( 'is currently being added to our database. Please check back again later.', 'peptide-search-ai' ),
'invalidMsg' => __( 'does not appear to be a recognized peptide. Please check the spelling or try a different search term.', 'peptide-search-ai' ),
'verified' => __( 'Verified', 'peptide-search-ai' ),
'curated' => __( 'Curated', 'peptide-search-ai' ),
),
)
);
@@ -249,9 +250,13 @@ function psa_replace_kb_search() {
add_action( 'wp_footer', 'psa_replace_kb_search' );
function psa_activate() {
PSA_Post_Type::register_peptide_post_type();
PSA_Post_Type::register_taxonomy();
PSA_Post_Type::populate_default_categories();
// As of PSA v4.5.0, PR Core (>= 0.2.0) owns the `peptide` CPT and
// `peptide_category` taxonomy — PSA no longer registers them here. The
// 8 default category terms were seeded on earlier PSA versions and
// remain in wp_term_taxonomy; PR Core inherits them by registering the
// same taxonomy name, so no re-seed is required. PSA only owns its
// own cost-tracker table and still needs to flush rewrite rules on
// activation so the `peptide` CPT permalinks resolve on this install.
PSA_Cost_Tracker::create_table();
flush_rewrite_rules();
}

View File

@@ -1,5 +1,5 @@
=== Peptide Search AI ===
Contributors: terence
Contributors: peptiderepo
Tags: peptide, database, search, AI, science, research, openrouter
Requires at least: 5.8
Tested up to: 6.7

View File

@@ -129,4 +129,7 @@ require_once ABSPATH . 'includes/class-psa-config.php';
require_once ABSPATH . 'includes/class-psa-cost-tracker.php';
require_once ABSPATH . 'includes/class-psa-openrouter.php';
require_once ABSPATH . 'includes/class-psa-kb-builder.php';
require_once ABSPATH . 'includes/class-psa-ai-content.php';
require_once ABSPATH . 'includes/class-psa-directory-api.php';
require_once ABSPATH . 'includes/class-psa-ai-generator.php';
require_once ABSPATH . 'includes/class-psa-upgrade.php';

View File

@@ -0,0 +1,200 @@
<?php
/**
* Tests for PSA_Dependency_Check.
*
* Covers:
* - is_satisfied() returns false when PR_CORE_VERSION is undefined.
* - is_satisfied() returns false when PR_CORE_VERSION < REQUIRED_PR_CORE_VERSION.
* - is_satisfied() returns true when PR_CORE_VERSION == REQUIRED_PR_CORE_VERSION.
* - is_satisfied() returns true when PR_CORE_VERSION > REQUIRED_PR_CORE_VERSION.
* - Regression guard: PSA_Post_Type no longer declares
* register_peptide_post_type / register_taxonomy / populate_default_categories.
*
* PHP constants are defined process-wide and cannot be redefined once set, so
* each version-value variant runs in its own separate process via
* @runInSeparateProcess + @preserveGlobalState disabled. That gives each case
* a clean state to define (or not define) PR_CORE_VERSION.
*
* @package PeptideSearchAI
*/
declare( strict_types=1 );
// Stub WordPress admin-notice helpers needed by maybe_render_notice() and the
// class autoloader. Pure stubs; no assertions run against them here — the
// maybe_render_notice() path is exercised only indirectly via is_satisfied().
if ( ! function_exists( 'add_action' ) ) {
function add_action() {}
}
if ( ! function_exists( 'current_user_can' ) ) {
function current_user_can( $cap ) { return true; }
}
if ( ! function_exists( 'esc_html__' ) ) {
function esc_html__( $text, $domain = 'default' ) {
return htmlspecialchars( $text, ENT_QUOTES, 'UTF-8' );
}
}
require_once dirname( __DIR__ ) . '/bootstrap.php';
require_once ABSPATH . 'includes/class-psa-dependency-check.php';
class DependencyCheckTest extends \PHPUnit\Framework\TestCase {
/**
* REQUIRED_PR_CORE_VERSION is the contract — changing it is a breaking
* coordination event with PR Core. Assert it here so an accidental bump
* trips CI.
*/
public function test_required_pr_core_version_constant(): void {
$this->assertSame(
'0.2.0',
PSA_Dependency_Check::REQUIRED_PR_CORE_VERSION,
'REQUIRED_PR_CORE_VERSION is a contract with PR Core; changing it must be a coordinated PR across both plugins.'
);
}
/**
* @runInSeparateProcess
* @preserveGlobalState disabled
*/
public function test_is_satisfied_false_when_pr_core_version_undefined(): void {
require_once dirname( __DIR__, 2 ) . '/includes/class-psa-dependency-check.php';
$this->assertFalse(
defined( 'PR_CORE_VERSION' ),
'Precondition: PR_CORE_VERSION must not be defined in this process.'
);
$this->assertFalse( PSA_Dependency_Check::is_satisfied() );
}
/**
* @runInSeparateProcess
* @preserveGlobalState disabled
*/
public function test_is_satisfied_false_when_pr_core_version_below_required(): void {
define( 'PR_CORE_VERSION', '0.1.1' );
require_once dirname( __DIR__, 2 ) . '/includes/class-psa-dependency-check.php';
$this->assertFalse( PSA_Dependency_Check::is_satisfied() );
}
/**
* @runInSeparateProcess
* @preserveGlobalState disabled
*/
public function test_is_satisfied_true_when_pr_core_version_exactly_required(): void {
define( 'PR_CORE_VERSION', '0.2.0' );
require_once dirname( __DIR__, 2 ) . '/includes/class-psa-dependency-check.php';
$this->assertTrue( PSA_Dependency_Check::is_satisfied() );
}
/**
* @runInSeparateProcess
* @preserveGlobalState disabled
*/
public function test_is_satisfied_true_when_pr_core_version_above_required(): void {
define( 'PR_CORE_VERSION', '0.3.0' );
require_once dirname( __DIR__, 2 ) . '/includes/class-psa-dependency-check.php';
$this->assertTrue( PSA_Dependency_Check::is_satisfied() );
}
/**
* @runInSeparateProcess
* @preserveGlobalState disabled
*/
public function test_is_satisfied_false_for_pre_release_below_required(): void {
// Defensive: PHP's version_compare treats "0.2.0-alpha" as < "0.2.0",
// which is the desired behaviour here. Regression guard against the
// constant silently accepting pre-release PR Core builds.
define( 'PR_CORE_VERSION', '0.2.0-alpha' );
require_once dirname( __DIR__, 2 ) . '/includes/class-psa-dependency-check.php';
$this->assertFalse( PSA_Dependency_Check::is_satisfied() );
}
/**
* Regression guard for the v4.5.0 cleanup: PSA_Post_Type must no longer
* expose the three removed registration methods. A future refactor that
* re-introduces any of them would re-create the CPT collision with PR
* Core and break the production site.
*/
public function test_psa_post_type_registration_methods_removed(): void {
// Stub the minimum WP surface PSA_Post_Type touches at class-load
// time so we can reflect on it here without pulling the full bootstrap.
if ( ! function_exists( 'post_type_exists' ) ) {
function post_type_exists( $post_type ) { return false; }
}
if ( ! function_exists( 'register_post_type' ) ) {
function register_post_type( $post_type, $args = array() ) { return null; }
}
if ( ! function_exists( 'taxonomy_exists' ) ) {
function taxonomy_exists( $taxonomy ) { return false; }
}
if ( ! function_exists( 'register_taxonomy' ) ) {
function register_taxonomy( $taxonomy, $object_type, $args = array() ) { return null; }
}
if ( ! function_exists( 'add_filter' ) ) {
function add_filter() {}
}
if ( ! function_exists( '__' ) ) {
function __( $text, $domain = 'default' ) { return $text; }
}
require_once ABSPATH . 'includes/class-psa-post-type.php';
$this->assertFalse(
method_exists( 'PSA_Post_Type', 'register_peptide_post_type' ),
'PSA_Post_Type::register_peptide_post_type() was removed in v4.5.0 — do not reintroduce it; PR Core owns the peptide CPT.'
);
$this->assertFalse(
method_exists( 'PSA_Post_Type', 'register_taxonomy' ),
'PSA_Post_Type::register_taxonomy() was removed in v4.5.0 — do not reintroduce it; PR Core owns the peptide_category taxonomy.'
);
$this->assertFalse(
method_exists( 'PSA_Post_Type', 'populate_default_categories' ),
'PSA_Post_Type::populate_default_categories() was removed in v4.5.0 — PR Core owns taxonomy term seeding.'
);
// DEFAULT_CATEGORIES constant is intentionally retained: PSA consumes
// it as a slug→name reference list for fuzzy-matching in
// PSA_AI_Content::assign_category_term() and the admin migration
// button. It is no longer a seed list. See CHANGELOG [4.5.0] and
// the class docblock.
$this->assertTrue(
defined( 'PSA_Post_Type::DEFAULT_CATEGORIES' ),
'PSA_Post_Type::DEFAULT_CATEGORIES is retained as a consumer lookup — PSA_AI_Content and the admin migration button read it.'
);
$this->assertCount(
8,
PSA_Post_Type::DEFAULT_CATEGORIES,
'DEFAULT_CATEGORIES should still contain exactly 8 entries matching the terms seeded on prod.'
);
}
/**
* Meta-box contract survives the CPT handoff — the `psa_*` meta key
* namespace on the `peptide` CPT is still owned by PSA, so the class
* constants that drive meta-box rendering must remain intact.
*/
public function test_psa_post_type_meta_keys_unchanged(): void {
$this->assertArrayHasKey( 'psa_sequence', PSA_Post_Type::META_FIELDS );
$this->assertArrayHasKey( 'psa_molecular_weight', PSA_Post_Type::META_FIELDS );
$this->assertArrayHasKey( 'psa_source', PSA_Post_Type::META_FIELDS );
$this->assertArrayHasKey( 'psa_pubchem_cid', PSA_Post_Type::META_FIELDS );
$this->assertArrayHasKey( 'psa_half_life', PSA_Post_Type::EXTENDED_META_FIELDS );
$this->assertArrayHasKey( 'psa_stability', PSA_Post_Type::EXTENDED_META_FIELDS );
$this->assertArrayHasKey( 'psa_solubility', PSA_Post_Type::EXTENDED_META_FIELDS );
}
/**
* PSA_Post_Type::init_admin() still hooks meta boxes + admin columns
* against the `peptide` CPT. The method has to survive the CPT-handoff
* refactor — it's the primary consumer-side attachment point.
*/
public function test_psa_post_type_init_admin_preserved(): void {
$this->assertTrue( method_exists( 'PSA_Post_Type', 'init_admin' ) );
}
}

View File

@@ -2,79 +2,39 @@
/**
* Tests for PSA_Post_Type class.
*
* Covers: taxonomy constants, default categories list, meta field constants,
* extended meta fields, and get_all_meta_keys() helper.
* As of v4.5.0 this class no longer registers the `peptide` CPT or
* `peptide_category` taxonomy — PR Core >= 0.2.0 owns those. PSA_Post_Type's
* remaining public surface is: META_FIELDS, EXTENDED_META_FIELDS,
* get_all_meta_keys(), init_admin() (meta boxes + admin columns), and the
* add_admin_columns / render_admin_column helpers.
*
* These tests cover the public meta-key contract and the admin-column
* helpers. Registration-path coverage lives in DependencyCheckTest (which
* also contains the regression guard against those methods coming back).
*
* @package PeptideSearchAI
*/
declare( strict_types=1 );
// Stub WordPress functions needed by PSA_Post_Type but not by the test bootstrap.
if ( ! function_exists( 'post_type_exists' ) ) {
function post_type_exists( $post_type ) {
return false;
}
}
if ( ! function_exists( 'register_post_type' ) ) {
function register_post_type( $post_type, $args = array() ) {
return null;
}
}
if ( ! function_exists( 'taxonomy_exists' ) ) {
function taxonomy_exists( $taxonomy ) {
return false;
}
}
if ( ! function_exists( 'register_taxonomy' ) ) {
function register_taxonomy( $taxonomy, $object_type, $args = array() ) {
return null;
}
}
if ( ! function_exists( '__' ) ) {
function __( $text, $domain = 'default' ) {
return $text;
}
}
// Stub WordPress functions needed by PSA_Post_Type at class-load time.
if ( ! function_exists( 'add_action' ) ) {
function add_action() {}
}
if ( ! function_exists( 'add_filter' ) ) {
function add_filter() {}
}
if ( ! function_exists( '__' ) ) {
function __( $text, $domain = 'default' ) {
return $text;
}
}
require_once dirname( __DIR__ ) . '/bootstrap.php';
require_once ABSPATH . 'includes/class-psa-post-type.php';
class PostTypeTest extends \PHPUnit\Framework\TestCase {
/**
* Verify default categories constant contains exactly 8 entries.
*/
public function test_default_categories_count(): void {
$this->assertCount( 8, PSA_Post_Type::DEFAULT_CATEGORIES );
}
/**
* Verify required category slugs exist in the constant.
*/
public function test_default_categories_slugs(): void {
$required = array(
'tissue-repair',
'lipid-metabolism',
'aging-research',
'dermatological',
'metabolic',
'growth-hormone',
'immunology',
'endocrine',
);
foreach ( $required as $slug ) {
$this->assertArrayHasKey( $slug, PSA_Post_Type::DEFAULT_CATEGORIES, "Missing category slug: {$slug}" );
}
}
/**
* Verify core META_FIELDS constant has expected keys.
*/
@@ -99,7 +59,7 @@ class PostTypeTest extends \PHPUnit\Framework\TestCase {
}
/**
* Verify extended meta fields contain the 9 new v4.3.0 fields.
* Verify extended meta fields contain the 9 v4.3.0 fields.
*/
public function test_extended_meta_fields_count(): void {
$this->assertCount( 9, PSA_Post_Type::EXTENDED_META_FIELDS );
@@ -141,4 +101,38 @@ class PostTypeTest extends \PHPUnit\Framework\TestCase {
$expected_count = count( PSA_Post_Type::META_FIELDS ) + count( PSA_Post_Type::EXTENDED_META_FIELDS );
$this->assertCount( $expected_count, $all_keys );
}
/**
* add_admin_columns() inserts the Source column right after Title.
* This is the CPT-independent part of the admin surface and runs even
* before PSA_Post_Type_Meta is loaded by PR Core's registration.
*/
public function test_add_admin_columns_inserts_source_after_title(): void {
$columns = array(
'cb' => '<input />',
'title' => 'Title',
'date' => 'Date',
);
$result = PSA_Post_Type::add_admin_columns( $columns );
$keys = array_keys( $result );
$this->assertSame( array( 'cb', 'title', 'psa_source', 'date' ), $keys );
$this->assertSame( 'Source', $result['psa_source'] );
}
/**
* add_admin_columns() is a no-op when 'title' is not in the columns
* array (defensive — WP themes/plugins can filter the columns array
* upstream of us, so we must not assume 'title' is present).
*/
public function test_add_admin_columns_without_title_is_noop(): void {
$columns = array(
'cb' => '<input />',
'date' => 'Date',
);
$result = PSA_Post_Type::add_admin_columns( $columns );
$this->assertSame( $columns, $result );
$this->assertArrayNotHasKey( 'psa_source', $result );
}
}

View File

@@ -0,0 +1,87 @@
<?php
/**
* Tests for the schema-upgrade decision logic in PSA_Upgrade.
*
* Covers PSA_Upgrade::is_needed() across the four version-comparison
* scenarios: fresh install, already-upgraded, future-version (downgrade),
* and older stored version.
*
* @package PeptideSearchAI
*/
declare( strict_types=1 );
use PHPUnit\Framework\TestCase;
// PSA_Upgrade is already loaded by tests/bootstrap.php via class-psa-upgrade.php.
// No plugin-file bootstrap needed — the decision helper is a pure function.
/**
* Test case for PSA_Upgrade::is_needed().
*
* The helper is a pure static method (no WordPress API calls, no side
* effects), so it can be exercised directly without a live option store.
*/
class UpgradeTest extends TestCase {
/**
* Fresh install: stored version defaults to '0.0.0', which is strictly
* less than any real released version, so an upgrade must run.
*/
public function test_fresh_install_triggers_upgrade(): void {
$this->assertTrue(
PSA_Upgrade::is_needed( '0.0.0', '4.4.3' ),
'Fresh install (stored = 0.0.0) must trigger an upgrade against any released version.'
);
}
/**
* Already-upgraded: stored version equals current. No upgrade needed.
*/
public function test_same_version_skips_upgrade(): void {
$this->assertFalse(
PSA_Upgrade::is_needed( '4.4.3', '4.4.3' ),
'Stored version equal to current version must not trigger an upgrade.'
);
}
/**
* Future-version: stored version is ahead of the running code (e.g., a
* downgrade, or a user running multiple sites out of sync). Do not run
* upgrade — it could apply an older schema over a newer one.
*/
public function test_future_stored_version_skips_upgrade(): void {
$this->assertFalse(
PSA_Upgrade::is_needed( '5.0.0', '4.4.3' ),
'Stored version greater than current version must not trigger an upgrade.'
);
}
/**
* Belt-and-braces: a strictly newer code version against an older
* stored version must trigger an upgrade. Guards against an accidental
* operator flip in version_compare() (e.g., '>=' vs '<').
*/
public function test_older_stored_version_triggers_upgrade(): void {
$this->assertTrue(
PSA_Upgrade::is_needed( '4.4.2', '4.4.3' ),
'Stored version older than current version must trigger an upgrade.'
);
}
/**
* Constants used by the driver should be stable across releases so
* existing installs keep pointing at the same option key.
*/
public function test_option_name_constant_is_stable(): void {
$this->assertSame( 'psa_db_version', PSA_Upgrade::OPTION_NAME );
}
/**
* Default stored version must be strictly less than any future release
* so fresh-install detection keeps working.
*/
public function test_default_stored_version_is_zero(): void {
$this->assertSame( '0.0.0', PSA_Upgrade::DEFAULT_STORED_VERSION );
}
}

View File

@@ -2,7 +2,17 @@
/**
* Fired when the plugin is uninstalled.
*
* Cleans up all plugin data: peptide posts, post meta, options, transients, and rewrite rules.
* As of PSA v4.5.0 the `peptide` custom post type and `peptide_category`
* taxonomy are owned by Peptide Repo Core (PR Core) — PSA is a consumer, not
* the owner. Uninstalling PSA must therefore NOT delete peptide posts,
* peptide_category terms, or any `psa_*` post meta: that data predates PR
* Core and is consumed by other plugins via PR Core's data layer + PSA's
* REST endpoints. Removing it on uninstall would be destructive across
* plugin boundaries.
*
* This uninstaller only removes PSA's own option rows, transients, and the
* `wp_psa_api_logs` table (PSA's internal cost-tracking table — no other
* plugin reads from it).
*
* @package PeptideSearchAI
*/
@@ -14,36 +24,12 @@ if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
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 peptide_category taxonomy terms and relationships.
$terms = get_terms(
array(
'taxonomy' => 'peptide_category',
'hide_empty' => false,
'fields' => 'ids',
)
);
if ( ! is_wp_error( $terms ) && ! empty( $terms ) ) {
foreach ( $terms as $term_id ) {
wp_delete_term( (int) $term_id, 'peptide_category' );
}
}
// 3. Delete plugin options.
// 1. Delete plugin options.
delete_option( 'psa_settings' );
delete_option( 'psa_search_cache_gen' );
delete_option( 'psa_db_version' );
// 4. Delete all plugin transients (validation cache, rate limits).
// 2. Delete all plugin transients (validation cache, rate limits, PubChem cache).
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
@@ -52,8 +38,7 @@ $wpdb->query(
)
);
// 5. Drop API logs table.
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}psa_api_logs" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
// 3. Drop PSA's API logs table. Owned entirely by PSA; no cross-plugin reads.
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}psa_api_logs" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// 6. Flush rewrite rules to clean up the peptide post type permalinks.
flush_rewrite_rules();
// 4. NOT deleted (owned by Peptide Repo Core as of PSA v4.5.0)