diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0c85f64 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,78 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + workflow_call: + +permissions: + contents: read + +jobs: + # ── PHP Syntax Check ───────────────────────────────────────────── + lint-php: + name: PHP Lint (${{ matrix.php }}) + runs-on: ubuntu-latest + strategy: + matrix: + php: [ '8.1', '8.2', '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/*" | xargs -n 1 php -l + + - name: Unit tests (lightweight, no PHPUnit) + run: | + for test in tests/unit/test-*.php; do + echo "=== Running $test ===" + php "$test" + done + + # ── 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 + run: composer run lint + continue-on-error: true + + # ── 300-line file check ────────────────────────────────────────── + file-size: + name: 300-line file check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check no PHP file exceeds 300 lines + run: | + violations=0 + while IFS= read -r file; do + lines=$(wc -l < "$file") + if [ "$lines" -gt 300 ]; then + echo "VIOLATION: $file ($lines lines)" + violations=$((violations + 1)) + fi + done < <(find . -name "*.php" -not -path "./vendor/*" -not -path "./tests/*") + if [ "$violations" -gt 0 ]; then + echo "$violations file(s) exceed 300 lines" + exit 1 + fi + echo "All PHP files are 300 lines or fewer." diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..b55b94b --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,188 @@ +# Architecture — Peptide GEO Monitor + +**Version:** 0.1.0 | **Last updated:** 2026-06-13 + +--- + +## 1. Overview + +`peptide-geo-monitor` is an internal WordPress plugin that runs weekly server-side LLM probes to measure whether peptiderepo.com appears in AI citations across core peptide queries. It stores a time-series in a custom table and renders an admin dashboard. + +**v1 scope:** AI-visibility only. The collector/panel seam is present so future data categories (keyword rankings, technical-SEO) can be added as new classes. + +--- + +## 2. File tree + +``` +peptide-geo-monitor/ +├── peptide-geo-monitor.php # Plugin boot: constants, autoloader, hooks +├── uninstall.php # Full data teardown: DROP TABLE + DELETE pgm_* options +├── composer.json # Dev deps (PHPCS only) +├── phpcs.xml.dist # PHPCS config (WordPress standard) +├── includes/ +│ ├── core/ +│ │ ├── class-pgm-autoloader.php # SPL autoloader: PGM_* → includes/** +│ │ ├── class-pgm-plugin.php # Orchestrator: boots sub-systems +│ │ ├── class-pgm-activator.php # Activation: table + defaults + cron +│ │ ├── class-pgm-deactivator.php # Deactivation: clear cron +│ │ ├── class-pgm-table-manager.php # dbDelta create/drop + get_table_name() +│ │ ├── class-pgm-config.php # Typed getters + seed_defaults() +│ │ ├── class-pgm-cron.php # Weekly WP-Cron schedule/clear +│ │ ├── class-pgm-cost-ledger.php # MTD cost + hard monthly cap enforcement +│ │ ├── class-pgm-probe-result.php # Immutable value object from a probe call +│ │ ├── class-pgm-probe-runner.php # Orchestrates peptide×intent×model run +│ │ ├── class-pgm-collector-registry.php # Singleton registry for collectors + panels +│ │ └── class-pgm-admin-page.php # Admin page: menu, Run now, rendering +│ ├── providers/ +│ │ ├── interface-pgm-probe-provider.php # probe(query): PGM_Probe_Result contract +│ │ ├── class-pgm-gateway-client.php # Cloudflare AI Gateway HTTP + retry +│ │ ├── class-pgm-citation-detector.php # Domain extraction + cite detection +│ │ ├── class-pgm-perplexity-provider.php # Perplexity sonar via OpenRouter/gateway +│ │ └── class-pgm-openrouter-provider.php # Generic OpenRouter (GPT-search, Gemini) +│ ├── collector/ +│ │ ├── interface-pgm-data-collector.php # collect(): array seam +│ │ └── class-pgm-ai-visibility-collector.php # AI-visibility data from DB +│ └── panel/ +│ ├── interface-pgm-dashboard-panel.php # render(data): void seam +│ └── class-pgm-ai-visibility-panel.php # Renders trendline + standings +├── tests/ +│ ├── bootstrap.php # WP-stub bootstrap (no PHPUnit) +│ └── unit/ +│ ├── test-citation-detector.php # Domain parsing + cite detection +│ ├── test-score-calc.php # Visibility score formula +│ ├── test-cost-ledger.php # MTD cost + budget cap +│ ├── test-provider-parsing.php # Mock-HTTP provider response parsing +│ ├── test-cron.php # Schedule/clear + activation/deactivation +│ ├── test-probe-runner.php # Run mechanics + budget abort +│ └── test-uninstall.php # Table drop + option purge +└── .github/workflows/ci.yml # PHP lint matrix + PHPCS + 300-line check +``` + +--- + +## 3. Data flow + +``` +WP-Cron (weekly) ──────────────────────────────────────┐ +Admin "Run now" (POST + nonce) ─────────────────────────┤ + ↓ + PGM_Probe_Runner::run() + │ + ┌───────────────────────────────────┤ + │ for each peptide × intent × model│ + │ │ + │ PGM_Cost_Ledger::can_afford() │ + │ └─ ABORT gracefully if at cap │ + │ │ + │ PGM_Probe_Provider::probe(query) │ + │ └─ PGM_Gateway_Client │ + │ └─ Cloudflare AI Gateway │ + │ └─ OpenRouter │ + │ └─ LLM │ + │ │ + │ PGM_Citation_Detector │ + │ └─ extract domains │ + │ └─ detect peptiderepo.com │ + │ │ + │ $wpdb→insert(pgm_ai_visibility) │ + │ PGM_Cost_Ledger::update_row_cost()│ + └───────────────────────────────────┘ + │ +Admin page load ────────────────────────────────────────┤ + ↓ + PGM_Admin_Page::render_page() + │ + PGM_Collector_Registry + │ + PGM_Ai_Visibility_Collector::collect() + │ (DB reads) + PGM_Ai_Visibility_Panel::render() + │ (HTML output) + Browser (Chart.js) +``` + +--- + +## 4. Database + +**Table:** `{prefix}pgm_ai_visibility` + +| Column | Type | Notes | +|--------|------|-------| +| id | BIGINT UNSIGNED PK | Auto-increment | +| run_id | VARCHAR(36) | UUID per run | +| captured_at | DATETIME | UTC | +| peptide_slug | VARCHAR(200) | e.g. "bpc-157" | +| peptide_label | VARCHAR(200) | e.g. "BPC-157" | +| model | VARCHAR(200) | e.g. "perplexity/sonar" | +| prompt_intent | VARCHAR(200) | Template string | +| cited | TINYINT(1) | 1 = peptiderepo.com in sources | +| our_position | INT NULL | 1-based position; NULL if not cited | +| source_domains | LONGTEXT | JSON array of domains | +| raw_excerpt | LONGTEXT | First 500 chars of LLM response | +| cost_usd | DECIMAL(12,8) | Actual call cost | + +Schema version tracked in `pgm_schema_version` option. + +--- + +## 5. External API integrations + +### Cloudflare AI Gateway + +All LLM calls route through: +``` +https://gateway.ai.cloudflare.com/v1/{PGM_CF_ACCOUNT_ID}/{PGM_CF_GATEWAY_ID}/openrouter +``` + +Falls back to direct OpenRouter (`https://openrouter.ai/api/v1`) when the constants are absent or empty. + +Pattern mirrors PRAutoBlogger's `class-open-router-config.php` and `class-open-router-request-builder.php` — same auth injection, same cURL belt-and-suspenders for Hostinger. + +### Providers (v1) + +| Provider | Model | Citation source | +|----------|-------|-----------------| +| PGM_Perplexity_Provider | `perplexity/sonar` | `citations[]` array (primary, real-web retrieval) | +| PGM_OpenRouter_Provider | `openai/gpt-4o-search-preview` | annotations or inline URL regex | +| PGM_OpenRouter_Provider | `google/gemini-2.0-flash-001` | annotations or inline URL regex | + +--- + +## 6. Collector / Panel seam + +``` +PGM_Data_Collector (interface) PGM_Dashboard_Panel (interface) + │ │ +PGM_Ai_Visibility_Collector PGM_Ai_Visibility_Panel + │ │ + └──────── PGM_Collector_Registry ──────────┘ + (key: "ai_visibility") +``` + +To add a future SEO collector (keyword rankings, schema coverage…): +1. Implement `PGM_Data_Collector` + `PGM_Dashboard_Panel`. +2. Register both in `PGM_Plugin::init()`. +No dashboard shell changes required. + +--- + +## 7. Security + +- Secrets read from `wp-config.php` constants at runtime — never hardcoded. +- All admin actions: `manage_options` capability check + nonce verification. +- All DB values escaped with `$wpdb->prepare()`. +- All HTML output escaped with `esc_html()`, `esc_url()`, `wp_json_encode()`. + +--- + +## 8. Key decisions + +| Decision | Rationale | +|----------|-----------| +| Use OpenRouter for all models | Single API key, single gateway URL, Perplexity sonar is accessible via OpenRouter's model routing. | +| Perplexity sonar as primary | Returns an explicit `citations[]` array — the most faithful citation signal available. | +| Cost ledger from visibility table | No separate ledger table needed; `SUM(cost_usd)` per calendar month is sufficient and keeps the schema minimal. | +| Chart.js via CDN | No build step, no bundler, admin-only load, pinned version. | +| Plain PHP tests (no PHPUnit) | Mirrors peptide-repo-core's pattern; CI runs in the lint-php job with no extra setup. | diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9bc7db6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,35 @@ +# Changelog — Peptide GEO Monitor + +All notable changes to this project will be documented in this file. + +Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) +Versioning: [Semantic Versioning](https://semver.org/) + +--- + +## [0.1.0] — 2026-06-13 + +**Initial release — AI-visibility dashboard.** + +### Added + +- `PGM_Probe_Provider` interface + `PGM_Probe_Result` value object. +- `PGM_Perplexity_Provider`: Perplexity `sonar` via OpenRouter/Cloudflare AI Gateway; parses `citations[]` array. +- `PGM_OpenRouter_Provider`: generic parameterised provider for GPT-search and Gemini-search models; parses annotations + inline URL regex fallback. +- `PGM_Gateway_Client`: shared CF AI Gateway HTTP client with exponential-backoff retry and cURL auth injection (mirrors PRAutoBlogger pattern). +- `PGM_Citation_Detector`: domain extraction from citation arrays/URLs, `is_cited()`, `get_our_position()`. +- `PGM_Cost_Ledger`: month-to-date spend from DB, `can_afford()` pre-check, `update_row_cost()` settlement. +- `PGM_Probe_Runner`: full peptides × intents × models run with budget cap abort, in-run cache, and partial-run persistence. +- `PGM_Table_Manager`: `{prefix}pgm_ai_visibility` via `dbDelta`, schema-version option, `drop_table()` for uninstall. +- `PGM_Config`: typed getters + `seed_defaults()` with ~12 peptides, 3 intents, 3 models. +- `PGM_Cron`: weekly `pgm_weekly_probe` WP-Cron; `schedule_weekly()` / `clear_schedule()`. +- `PGM_Data_Collector` + `PGM_Dashboard_Panel` interfaces (collector/panel seam). +- `PGM_Collector_Registry`: singleton registry for future extensibility. +- `PGM_Ai_Visibility_Collector`: trendline (per-run visibility score), standings (per-peptide latest), MTD cost. +- `PGM_Ai_Visibility_Panel`: proxy-metric note, Chart.js trendline, per-peptide standings table, MTD cost bar, "Run now" button. +- `PGM_Admin_Page`: "GEO Monitor" top-level menu, Chart.js enqueue, Run now POST handler (nonce + `manage_options`). +- `uninstall.php`: DROP TABLE + DELETE `pgm_*` options + `wp_clear_scheduled_hook`. +- Unit tests: citation detection, score formula, cost ledger + budget cap abort, provider response parsing (mock HTTP), cron registration, uninstall purge. +- `ARCHITECTURE.md`, `CONVENTIONS.md`, `CONTEXT.md` (incl. visibility score formula), `README.md`. +- CI: PHP lint (8.1/8.2/8.3) + PHPCS + 300-line check (mirrors `peptide-repo-core`). +- Seed config: 12 peptides, 3 prompt intents, Perplexity sonar + GPT-4o-search + Gemini-2.0-Flash. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..ec249bc --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,93 @@ +# Context — Domain Glossary (Peptide GEO Monitor) + +**Version:** 0.1.0 | **Last updated:** 2026-06-13 + +This file is the ubiquitous-language glossary for `peptide-geo-monitor`. Every term below has a precise meaning within this codebase. AI agents and human contributors should use this terminology consistently. + +--- + +## Core domain terms + +### AI Visibility +Whether and where peptiderepo.com appears in AI assistant responses to peptide-related queries. The core metric this plugin measures. + +**Code identifiers:** `PGM_Ai_Visibility_Collector`, `PGM_Ai_Visibility_Panel`, table column `cited`, option prefix `pgm_`. + +### Probe +A single server-side LLM API call with a specific `(peptide, intent, model)` combination. The result is a `PGM_Probe_Result` value object. + +**Code identifiers:** `PGM_Probe_Provider::probe()`, `PGM_Probe_Runner::run()`. + +### Probe Run +A batch execution of all configured `peptides × prompt_intents × models`. Identified by a `run_id` (UUID). One cron tick = one run; the "Run now" button also triggers a run. + +**Code identifiers:** `PGM_Probe_Runner::run()`, `run_id` column in `pgm_ai_visibility`. + +### Peptide +A compound being tracked (e.g. BPC-157, TB-500). Stored as a slug + label pair in the `pgm_peptides` option. + +**Code identifiers:** `pgm_peptides` option, `peptide_slug` + `peptide_label` columns. + +### Prompt Intent +A query template describing a searcher's intent (e.g. "what is {peptide}", "{peptide} reconstitution guide"). The `{peptide}` placeholder is replaced with the peptide's label at runtime. + +**Code identifiers:** `pgm_prompt_intents` option, `prompt_intent` column. + +### Citation / Cited +A probe is **cited** when `peptiderepo.com` appears in the LLM's source/citation list for that response. The `cited` column is `TINYINT(1)` (1 = cited, 0 = not cited). + +**Code identifiers:** `PGM_Citation_Detector::is_cited()`, `cited` column, `PGM_Probe_Result::is_cited()`. + +### Source Domains +The list of domains returned by the LLM as its sources for a given response (e.g. `["peptiderepo.com", "examine.com"]`). Stored as JSON in `source_domains`. + +**Code identifiers:** `PGM_Citation_Detector::parse_domains()`, `PGM_Probe_Result::get_source_domains()`, `source_domains` column. + +### Our Position +The 1-based rank of `peptiderepo.com` in the source domains list for a cited probe (`NULL` when not cited). Position 1 = we appear first. + +**Code identifiers:** `PGM_Citation_Detector::get_our_position()`, `our_position` column, `PGM_Probe_Result::get_our_position()`. + +### Visibility Score +A composite metric summarising peptiderepo.com's AI visibility for a run. + +**Formula:** +``` +base_score = cited_probes / total_probes +position_bonus = Σ(1 / our_position) for each cited probe / total_probes +visibility_score = round((base_score + position_bonus) / 2, 4) +``` + +- **Range:** [0.0, 1.0]. Score 1.0 requires 100% citation rate at position 1 for all probes. +- **Why position-weighted?** Being cited at position 1 is more valuable than position 10. The 1/position term gives a graduated bonus: position 1 = 1.0, position 2 = 0.5, position 5 = 0.2, etc. +- **Stored:** Computed from aggregate DB rows per run in `PGM_Ai_Visibility_Collector::compute_score()`, not persisted as a column (recomputed on page load). + +**Code identifiers:** `PGM_Ai_Visibility_Collector::compute_score()`, trendline data in `collect()`. + +### Monthly Budget Cap +The maximum USD spend on LLM API calls in a calendar month. Stored in `pgm_monthly_budget_usd` (default `$5.00`). The runner checks `PGM_Cost_Ledger::can_afford()` **before** each probe and stops gracefully when the cap is reached. + +**Code identifiers:** `PGM_Cost_Ledger`, `pgm_monthly_budget_usd` option, `PGM_DEFAULT_MONTHLY_BUDGET_USD` constant. + +### Provider +An implementation of `PGM_Probe_Provider` that wraps one LLM backend. v1 ships `PGM_Perplexity_Provider` (sonar) and `PGM_OpenRouter_Provider` (parameterised). + +**Code identifiers:** `interface-pgm-probe-provider.php`, `PGM_Perplexity_Provider`, `PGM_OpenRouter_Provider`. + +### Collector / Panel Seam +The extensibility mechanism: `PGM_Data_Collector` produces data; `PGM_Dashboard_Panel` renders it. Both are registered with `PGM_Collector_Registry`. v1 ships only the AI-visibility pair; future SEO categories add new pairs without touching the shell. + +**Code identifiers:** `interface-pgm-data-collector.php`, `interface-pgm-dashboard-panel.php`, `PGM_Collector_Registry`. + +### GEO +Generative Engine Optimization — the practice of improving a brand's visibility in AI-generated responses. This plugin is the measurement instrument for peptiderepo.com's GEO program. + +### Proxy Metric +The plugin's scores are API-probe measurements, not real-world consumer LLM usage. The admin page labels these explicitly as "a directional proxy, not the consumer ChatGPT/Gemini apps." + +**Code identifiers:** `PGM_Ai_Visibility_Panel::render_proxy_note()`. + +### MTD Cost +Month-to-date spend in USD, summed from the `cost_usd` column for rows in the current calendar month. + +**Code identifiers:** `PGM_Cost_Ledger::get_month_to_date_usd()`. diff --git a/CONVENTIONS.md b/CONVENTIONS.md new file mode 100644 index 0000000..c4d6667 --- /dev/null +++ b/CONVENTIONS.md @@ -0,0 +1,84 @@ +# Conventions — Peptide GEO Monitor + +**Version:** 0.1.0 | **Last updated:** 2026-06-13 + +Applies to all contributors and AI agents working in this repo. Where this document and `AGENT-OPERATING-STANDARD.md` conflict on process, the Standard wins; this document wins on code style. + +--- + +## 1. PHP style + +- **Standard:** WordPress Coding Standards (PHPCS `ruleset ref="WordPress"`). +- **Declare:** `declare(strict_types=1);` at the top of every PHP file. +- **Formatting:** Tabs for indentation (WP standard). + +## 2. Naming + +| Concept | Convention | Example | +|---------|-----------|---------| +| Class | `PGM_` prefix + PascalCase | `PGM_Cost_Ledger` | +| Interface | `PGM_` prefix + PascalCase | `PGM_Probe_Provider` | +| File | `class-pgm-*.php` or `interface-pgm-*.php` | `class-pgm-probe-runner.php` | +| Option | `pgm_` prefix | `pgm_monthly_budget_usd` | +| Hook / cron | `pgm_` prefix | `pgm_weekly_probe` | +| Constant | `PGM_` prefix | `PGM_TARGET_DOMAIN` | +| wp-config key | `PGM_` prefix | `PGM_OPENROUTER_API_KEY` | +| Test file | `test-*.php` | `test-citation-detector.php` | + +## 3. File structure + +- **One class per file.** Interfaces get their own file. +- **300-line limit** (enforced by CI). Split at the method boundary when approaching the limit. +- Autoloader resolves `PGM_Foo_Bar` → `class-pgm-foo-bar.php` via a search across `includes/` subdirectories. + +## 4. Docblocks + +Every class must have a preamble docblock: +``` +/** + * . + * + * Who triggers: . + * Dependencies: . + * + * @see + * @see + * @package PeptideGeoMonitor + */ +``` + +Every public method must have: +- `@param` with type + description. +- `@return` with type. +- A sentence listing side effects (DB writes, HTTP calls, option writes). + +## 5. Security rules + +- **Sanitize** all input at the boundary (`sanitize_text_field()`, `absint()`, `wp_unslash()`). +- **Escape** all output at render (`esc_html()`, `esc_url()`, `wp_json_encode()`, `wp_kses_post()`). +- **Nonce** every admin form action. +- **Capability** check (`manage_options`) every admin action handler. +- **No secrets in code** — read from wp-config constants at runtime. + +## 6. Tests + +- Tests live in `tests/unit/test-*.php` and are run by the CI `lint-php` job via `php $test`. +- Each test file `require_once`s `tests/bootstrap.php` and calls `exit( pgm_test_summary() )`. +- Use `pgm_assert()`, `pgm_assert_equals()`, `pgm_assert_throws()`. +- Mock HTTP via `$GLOBALS['pgm_test_state']['remote_posts']` (queued responses). +- No PHPUnit dependency. + +## 7. Adding a new provider + +1. Create `includes/providers/class-pgm-{name}-provider.php`. +2. Implement `PGM_Probe_Provider` (`probe()`, `get_name()`, `is_configured()`). +3. Add a branch in `PGM_Probe_Runner::resolve_provider()`. +4. Update `ARCHITECTURE.md` §5 (Providers table). +5. Add a parse test in `tests/unit/test-provider-parsing.php`. + +## 8. Adding a new collector/panel pair + +1. Implement `PGM_Data_Collector` in `includes/collector/`. +2. Implement `PGM_Dashboard_Panel` in `includes/panel/`. +3. Register both in `PGM_Plugin::init()` via `PGM_Collector_Registry::instance()`. +4. Update `ARCHITECTURE.md` §6 (Collector/Panel seam). diff --git a/README.md b/README.md index c3159b4..6405bb0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,75 @@ -# peptide-geo-monitor -AI-visibility (GEO) monitoring dashboard for peptiderepo.com — weekly LLM probes, cost ledger, trendline admin panel. +# Peptide GEO Monitor + +An internal WordPress plugin that runs weekly server-side LLM probes to measure whether peptiderepo.com is cited by AI assistants across core peptides. + +**v0.1.0 — AI-visibility dashboard only. Internal use; no public-facing output.** + +--- + +## Purpose + +GEO (Generative Engine Optimization) is peptiderepo.com's #1 strategic goal. This plugin provides the measurement instrument: weekly LLM probes → time-series storage → admin trendline + per-peptide standings. + +The displayed metric is a **directional proxy** — API probes do not replicate the consumer ChatGPT/Gemini experience (different retrieval, personalisation, and system prompts), but they provide a consistent, trackable signal. + +--- + +## Required wp-config.php constants + +```php +// OpenRouter API key (sk-or-…) — required for all providers. +define( 'PGM_OPENROUTER_API_KEY', 'sk-or-YOUR_KEY_HERE' ); + +// Cloudflare AI Gateway routing (optional — falls back to direct OpenRouter). +define( 'PGM_CF_ACCOUNT_ID', 'YOUR_CF_ACCOUNT_ID' ); +define( 'PGM_CF_GATEWAY_ID', 'YOUR_GATEWAY_NAME' ); +``` + +**Never commit these values. They are read at runtime from wp-config.php only.** + +--- + +## Architecture + +See `ARCHITECTURE.md` for the full file tree, data flow, and design decisions. + +--- + +## Admin page + +Navigate to **GEO Monitor** in the WordPress admin sidebar (`manage_options` required). + +The page shows: +- A proxy-metric disclaimer note. +- A Chart.js trendline of the visibility score across runs. +- A per-peptide standings table (cited?, our position, top competitor domains). +- Month-to-date cost vs. the monthly cap. +- A **"Run now"** button to trigger an on-demand probe run. + +--- + +## Scheduling + +A weekly WP-Cron event (`pgm_weekly_probe`) is registered on plugin activation and cleared on deactivation. Probe runs (cron or manual) respect the hard monthly budget cap. + +--- + +## Cost cap + +Default cap: **$5.00 USD/month** (configurable via the `pgm_monthly_budget_usd` option). The runner checks the month-to-date spend **before** each API call and stops gracefully when the cap is reached — partial runs are recorded, never over-spend. + +--- + +## Uninstall + +Uninstalling the plugin drops the `{prefix}pgm_ai_visibility` table and deletes all `pgm_*` options. Deactivation only clears the cron schedule. + +--- + +## Developer notes + +- PHP 8.1+ required. No runtime Composer dependencies. +- All classes prefixed `PGM_`. All options/hooks prefixed `pgm_`. +- Files are kept under 300 lines; one class per file. +- See `CONVENTIONS.md` for naming patterns and extension guides. +- See `CONTEXT.md` for the domain glossary including the visibility score formula. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..36f4752 --- /dev/null +++ b/composer.json @@ -0,0 +1,24 @@ +{ + "name": "peptiderepo/peptide-geo-monitor", + "description": "AI-visibility (GEO) monitoring dashboard for peptiderepo.com — weekly LLM probes, cost ledger, trendline admin panel.", + "type": "wordpress-plugin", + "license": "GPL-2.0-or-later", + "require": { + "php": ">=8.1" + }, + "require-dev": { + "squizlabs/php_codesniffer": "^3.13", + "wp-coding-standards/wpcs": "^3.0", + "phpcsstandards/phpcsutils": "^1.0", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0" + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } + }, + "scripts": { + "lint": "phpcs", + "lint:fix": "phpcbf" + } +} diff --git a/includes/collector/class-pgm-ai-visibility-collector.php b/includes/collector/class-pgm-ai-visibility-collector.php new file mode 100644 index 0000000..1b81d07 --- /dev/null +++ b/includes/collector/class-pgm-ai-visibility-collector.php @@ -0,0 +1,180 @@ +ledger = $ledger ?? new PGM_Cost_Ledger(); + } + + /** + * {@inheritDoc} + * + * @return array{ + * trendline: array, + * standings: array, + * last_run_at: string|null, + * mtd_cost_usd: float, + * monthly_cap_usd: float, + * } + */ + public function collect(): array { + global $wpdb; + + $table = PGM_Table_Manager::get_table_name(); + + // ── Trendline: one score per run_id ──────────────────────────── + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $run_rows = $wpdb->get_results( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "SELECT run_id, MIN(captured_at) AS captured_at, + SUM(cited) AS cited_count, + COUNT(*) AS total_count, + SUM(CASE WHEN our_position IS NOT NULL THEN 1.0/our_position ELSE 0 END) AS position_sum + FROM {$table} + GROUP BY run_id + ORDER BY MIN(captured_at) ASC + LIMIT 52", + ARRAY_A + ); + + $trendline = array(); + foreach ( (array) $run_rows as $row ) { + $trendline[] = array( + 'run_id' => (string) $row['run_id'], + 'captured_at' => (string) $row['captured_at'], + 'score' => $this->compute_score( + (int) $row['cited_count'], + (int) $row['total_count'], + (float) $row['position_sum'] + ), + ); + } + + // ── Standings: latest result per peptide ──────────────────────── + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $standing_rows = $wpdb->get_results( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "SELECT peptide_slug, peptide_label, + MAX(cited) AS cited, + MIN(CASE WHEN cited=1 THEN our_position ELSE NULL END) AS our_position, + GROUP_CONCAT(DISTINCT source_domains ORDER BY captured_at DESC SEPARATOR '|||') AS domains_json_list, + COUNT(DISTINCT model) AS model_count + FROM {$table} + WHERE run_id = (SELECT run_id FROM {$table} ORDER BY captured_at DESC LIMIT 1) + GROUP BY peptide_slug, peptide_label", + ARRAY_A + ); + + $standings = array(); + foreach ( (array) $standing_rows as $row ) { + $top_domains = $this->extract_top_domains( (string) ( $row['domains_json_list'] ?? '' ) ); + $standings[ (string) $row['peptide_slug'] ] = array( + 'label' => (string) $row['peptide_label'], + 'cited' => (bool) (int) $row['cited'], + 'our_position' => null !== $row['our_position'] ? (int) $row['our_position'] : null, + 'top_domains' => $top_domains, + 'model_count' => (int) $row['model_count'], + ); + } + + // ── Run metadata ──────────────────────────────────────────────── + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $last_run = $wpdb->get_var( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "SELECT MAX(captured_at) FROM {$table}" + ); + + return array( + 'trendline' => $trendline, + 'standings' => $standings, + 'last_run_at' => $last_run ? (string) $last_run : null, + 'mtd_cost_usd' => $this->ledger->get_month_to_date_usd(), + 'monthly_cap_usd' => PGM_Config::get_monthly_budget_usd(), + ); + } + + /** + * {@inheritDoc} + */ + public function get_key(): string { + return 'ai_visibility'; + } + + /** + * Compute the visibility score from a run's aggregate stats. + * + * See CONTEXT.md for the full formula explanation. + * + * @param int $cited_count Number of probes where we were cited. + * @param int $total_count Total probes in the run. + * @param float $position_sum Sum of (1/our_position) for cited probes. + * + * @return float Visibility score in range [0, 1]. + */ + public function compute_score( int $cited_count, int $total_count, float $position_sum ): float { + if ( 0 === $total_count ) { + return 0.0; + } + $base_score = $cited_count / $total_count; + $position_bonus = $position_sum / $total_count; + return round( ( $base_score + $position_bonus ) / 2.0, 4 ); + } + + /** + * Extract and deduplicate the top domain names from a GROUP_CONCAT result. + * + * @param string $domains_json_list Pipe-separated JSON arrays from GROUP_CONCAT. + * + * @return string[] Up to 5 top domains. + */ + private function extract_top_domains( string $domains_json_list ): array { + if ( '' === $domains_json_list ) { + return array(); + } + + $all = array(); + $chunks = explode( '|||', $domains_json_list ); + foreach ( $chunks as $chunk ) { + $decoded = json_decode( $chunk, true ); + if ( is_array( $decoded ) ) { + $all = array_merge( $all, $decoded ); + } + } + + $counts = array_count_values( $all ); + arsort( $counts ); + $top = array_slice( array_keys( $counts ), 0, 5 ); + return array_values( array_filter( $top, fn( $d ) => PGM_TARGET_DOMAIN !== $d ) ); + } +} diff --git a/includes/collector/interface-pgm-data-collector.php b/includes/collector/interface-pgm-data-collector.php new file mode 100644 index 0000000..53d15a0 --- /dev/null +++ b/includes/collector/interface-pgm-data-collector.php @@ -0,0 +1,40 @@ + Collected data. + */ + public function collect(): array; + + /** + * Short machine key identifying this collector (e.g. "ai_visibility"). + * + * Used as the registry key and as the panel lookup key. + * + * @return string + */ + public function get_key(): string; +} diff --git a/includes/core/class-pgm-activator.php b/includes/core/class-pgm-activator.php new file mode 100644 index 0000000..c9138ab --- /dev/null +++ b/includes/core/class-pgm-activator.php @@ -0,0 +1,32 @@ +run(); + + $redirect = add_query_arg( + array( + 'page' => self::MENU_SLUG, + 'pgm_run_done' => 1, + 'pgm_probed' => (int) $counts['probed'], + 'pgm_skipped' => (int) ( $counts['skipped_budget'] + $counts['skipped_error'] ), + ), + admin_url( 'admin.php' ) + ); + + wp_safe_redirect( $redirect ); + exit; + } + + /** + * Render the full GEO Monitor admin page. + * + * Side effects: Outputs HTML directly. + * + * @return void + */ + public function render_page(): void { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'Insufficient permissions.', 'peptide-geo-monitor' ) ); + } + + echo '
'; + echo '

' . esc_html__( 'GEO Monitor — AI Visibility', 'peptide-geo-monitor' ) . '

'; + + $this->render_run_done_notice(); + + // "Run now" button form. + echo '
'; + echo ''; + wp_nonce_field( self::NONCE_ACTION, self::NONCE_FIELD ); + submit_button( __( 'Run now', 'peptide-geo-monitor' ), 'secondary', 'pgm_run_now_btn', false ); + echo '
'; + + $registry = PGM_Collector_Registry::instance(); + + foreach ( $registry->get_collectors() as $key => $collector ) { + $panel = $registry->get_panel( $key ); + if ( null === $panel ) { + continue; + } + $data = $collector->collect(); + $panel->render( $data ); + } + + echo '
'; + } + + /** + * Render a success or partial notice after a Run now redirect. + * + * @return void + */ + private function render_run_done_notice(): void { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( empty( $_GET['pgm_run_done'] ) ) { + return; + } + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $probed = (int) ( $_GET['pgm_probed'] ?? 0 ); + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $skipped = (int) ( $_GET['pgm_skipped'] ?? 0 ); + + echo '

'; + echo esc_html( + sprintf( + /* translators: 1: probed count, 2: skipped count */ + __( 'Run complete: %1$d probes collected, %2$d skipped (budget or error).', 'peptide-geo-monitor' ), + $probed, + $skipped + ) + ); + echo '

'; + } +} diff --git a/includes/core/class-pgm-autoloader.php b/includes/core/class-pgm-autoloader.php new file mode 100644 index 0000000..f33991d --- /dev/null +++ b/includes/core/class-pgm-autoloader.php @@ -0,0 +1,69 @@ + + */ + private array $collectors = array(); + + /** + * Registered panels keyed by their panel key (matches collector key). + * + * @var array + */ + private array $panels = array(); + + /** + * Private constructor — use instance(). + */ + private function __construct() {} + + /** + * Get (or create) the singleton instance. + * + * @return static + */ + public static function instance(): static { + if ( null === static::$instance ) { + static::$instance = new static(); + } + return static::$instance; + } + + /** + * Register a data collector. + * + * @param PGM_Data_Collector $collector + * + * @return void + */ + public function register_collector( PGM_Data_Collector $collector ): void { + $this->collectors[ $collector->get_key() ] = $collector; + } + + /** + * Register a dashboard panel. + * + * @param string $key Panel key (must match a collector key). + * @param PGM_Dashboard_Panel $panel + * + * @return void + */ + public function register_panel( string $key, PGM_Dashboard_Panel $panel ): void { + $this->panels[ $key ] = $panel; + } + + /** + * Get all registered collectors. + * + * @return array + */ + public function get_collectors(): array { + return $this->collectors; + } + + /** + * Get the panel for a given key (null when not registered). + * + * @param string $key Panel key. + * + * @return PGM_Dashboard_Panel|null + */ + public function get_panel( string $key ): ?PGM_Dashboard_Panel { + return $this->panels[ $key ] ?? null; + } + + /** + * Reset the registry — test use only. + * + * @internal + * @return void + */ + public static function reset_for_testing(): void { + static::$instance = null; + } +} diff --git a/includes/core/class-pgm-config.php b/includes/core/class-pgm-config.php new file mode 100644 index 0000000..e1e5c43 --- /dev/null +++ b/includes/core/class-pgm-config.php @@ -0,0 +1,129 @@ + 0.0 ? $val : PGM_DEFAULT_MONTHLY_BUDGET_USD; + } + + /** + * Get the list of peptide slugs/labels to probe. + * + * Each element is an associative array with 'slug' and 'label' keys. + * + * @return array + */ + public static function get_peptides(): array { + $raw = get_option( 'pgm_peptides', self::default_peptides() ); + return is_array( $raw ) ? $raw : self::default_peptides(); + } + + /** + * Get the prompt intent templates. + * + * Each string may contain {peptide} as a placeholder. + * + * @return array + */ + public static function get_prompt_intents(): array { + $raw = get_option( 'pgm_prompt_intents', self::default_prompt_intents() ); + return is_array( $raw ) ? $raw : self::default_prompt_intents(); + } + + /** + * Get the model identifiers to probe with. + * + * @return array + */ + public static function get_models(): array { + $raw = get_option( 'pgm_models', self::default_models() ); + return is_array( $raw ) ? $raw : self::default_models(); + } + + /** + * Default set of ~12 high-interest peptides (slug + human-readable label). + * + * @return array + */ + private static function default_peptides(): array { + return array( + array( 'slug' => 'bpc-157', 'label' => 'BPC-157' ), + array( 'slug' => 'tb-500', 'label' => 'TB-500' ), + array( 'slug' => 'mk-677', 'label' => 'MK-677' ), + array( 'slug' => 'cjc-1295', 'label' => 'CJC-1295' ), + array( 'slug' => 'ghrp-6', 'label' => 'GHRP-6' ), + array( 'slug' => 'ipamorelin', 'label' => 'Ipamorelin' ), + array( 'slug' => 'semaglutide', 'label' => 'Semaglutide' ), + array( 'slug' => 'tirzepatide', 'label' => 'Tirzepatide' ), + array( 'slug' => 'selank', 'label' => 'Selank' ), + array( 'slug' => 'semax', 'label' => 'Semax' ), + array( 'slug' => 'nad-plus', 'label' => 'NAD+' ), + array( 'slug' => 'aod-9604', 'label' => 'AOD-9604' ), + ); + } + + /** + * Default prompt intent templates — three query styles per peptide. + * + * @return array + */ + private static function default_prompt_intents(): array { + return array( + 'what is {peptide}', + '{peptide} benefits and dosage', + '{peptide} reconstitution guide', + ); + } + + /** + * Default models: Perplexity sonar (primary citation signal) + two + * OpenRouter-routed search-capable models for breadth. + * + * @return array + */ + private static function default_models(): array { + return array( + 'perplexity/sonar', + 'openai/gpt-4o-search-preview', + 'google/gemini-2.0-flash-001', + ); + } +} diff --git a/includes/core/class-pgm-cost-ledger.php b/includes/core/class-pgm-cost-ledger.php new file mode 100644 index 0000000..7aef316 --- /dev/null +++ b/includes/core/class-pgm-cost-ledger.php @@ -0,0 +1,109 @@ +get_var( + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "SELECT COALESCE(SUM(cost_usd), 0) FROM {$table} WHERE DATE_FORMAT(captured_at, %s) = %s", + '%Y-%m', + $month + ) + ); + + return (float) $result; + } + + /** + * Check whether there is remaining budget for another API call. + * + * Callers should invoke this BEFORE dispatching a probe to avoid + * overspend. Returns false when MTD spend is at or above the cap. + * + * @param float $estimated_cost_usd Estimated cost of the upcoming call. + * + * @return bool True when the call can proceed within budget. + */ + public function can_afford( float $estimated_cost_usd ): bool { + $cap = PGM_Config::get_monthly_budget_usd(); + $spent = $this->get_month_to_date_usd(); + return ( $spent + $estimated_cost_usd ) <= $cap; + } + + /** + * Check remaining budget headroom in USD. + * + * @return float Remaining USD; 0.0 when the cap is already hit. + */ + public function get_remaining_budget_usd(): float { + $cap = PGM_Config::get_monthly_budget_usd(); + $spent = $this->get_month_to_date_usd(); + return max( 0.0, $cap - $spent ); + } + + /** + * Record a completed API call's cost against a specific row ID. + * + * Updates the cost_usd column on the row created by PGM_Probe_Runner. + * The run is already written to the table before the cost is settled so + * even partial runs (budget abort) have their completed rows costed. + * + * Side effects: Database write. + * + * @param int $row_id Primary key of the pgm_ai_visibility row. + * @param float $cost_usd Actual cost in USD. + * + * @return bool True on success. + */ + public function update_row_cost( int $row_id, float $cost_usd ): bool { + global $wpdb; + + $table = PGM_Table_Manager::get_table_name(); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $result = $wpdb->update( + $table, + array( 'cost_usd' => number_format( $cost_usd, 8, '.', '' ) ), + array( 'id' => $row_id ), + array( '%s' ), + array( '%d' ) + ); + + return false !== $result; + } +} diff --git a/includes/core/class-pgm-cron.php b/includes/core/class-pgm-cron.php new file mode 100644 index 0000000..4c33e98 --- /dev/null +++ b/includes/core/class-pgm-cron.php @@ -0,0 +1,82 @@ +run(); + } + + /** + * Schedule the weekly cron event if not already scheduled. + * + * Safe to call on re-activation — no-op when already scheduled. + * + * Side effects: Adds a WP-Cron event. + * + * @return void + */ + public static function schedule_weekly(): void { + if ( ! wp_next_scheduled( PGM_CRON_HOOK ) ) { + wp_schedule_event( time(), 'weekly', PGM_CRON_HOOK ); + } + } + + /** + * Remove the weekly cron schedule. + * + * Called on deactivation. Does not purge data. + * + * Side effects: Removes the WP-Cron event. + * + * @return void + */ + public static function clear_schedule(): void { + $timestamp = wp_next_scheduled( PGM_CRON_HOOK ); + if ( false !== $timestamp ) { + wp_unschedule_event( $timestamp, PGM_CRON_HOOK ); + } + } + + /** + * Check whether the weekly event is currently scheduled. + * + * @return bool + */ + public static function is_scheduled(): bool { + return false !== wp_next_scheduled( PGM_CRON_HOOK ); + } +} diff --git a/includes/core/class-pgm-deactivator.php b/includes/core/class-pgm-deactivator.php new file mode 100644 index 0000000..807d511 --- /dev/null +++ b/includes/core/class-pgm-deactivator.php @@ -0,0 +1,29 @@ +register_hooks(); + + if ( is_admin() ) { + $page = new PGM_Admin_Page(); + $page->register_hooks(); + } + + // Register the v1 AI-visibility collector + panel. + $registry = PGM_Collector_Registry::instance(); + $registry->register_collector( new PGM_Ai_Visibility_Collector() ); + $registry->register_panel( 'ai_visibility', new PGM_Ai_Visibility_Panel() ); + } +} diff --git a/includes/core/class-pgm-probe-result.php b/includes/core/class-pgm-probe-result.php new file mode 100644 index 0000000..f62e6e7 --- /dev/null +++ b/includes/core/class-pgm-probe-result.php @@ -0,0 +1,69 @@ +raw_excerpt; + } + + /** + * @return string[] + */ + public function get_source_domains(): array { + return $this->source_domains; + } + + /** + * @return bool + */ + public function is_cited(): bool { + return $this->cited; + } + + /** + * @return int|null 1-based position or null when not cited. + */ + public function get_our_position(): ?int { + return $this->our_position; + } + + /** + * @return float Estimated cost in USD. + */ + public function get_cost_usd(): float { + return $this->cost_usd; + } +} diff --git a/includes/core/class-pgm-probe-runner.php b/includes/core/class-pgm-probe-runner.php new file mode 100644 index 0000000..bb5958b --- /dev/null +++ b/includes/core/class-pgm-probe-runner.php @@ -0,0 +1,202 @@ + + */ + private array $cache = array(); + + /** + * @param PGM_Cost_Ledger|null $ledger Injected for testing; auto-created otherwise. + */ + public function __construct( ?PGM_Cost_Ledger $ledger = null ) { + $this->ledger = $ledger ?? new PGM_Cost_Ledger(); + } + + /** + * Execute the full probe run. + * + * Iterates peptides × prompt intents × models. Aborts at the budget cap + * without throwing — partial results are kept. Each successful probe is + * persisted immediately so a mid-run failure doesn't lose data. + * + * Side effects: HTTP calls to LLM APIs, database writes. + * + * @return array{probed: int, skipped_budget: int, skipped_error: int} + * Summary counts for the admin log. + */ + public function run(): array { + $run_id = $this->generate_run_id(); + $peptides = PGM_Config::get_peptides(); + $intents = PGM_Config::get_prompt_intents(); + $models = PGM_Config::get_models(); + + $counts = array( + 'probed' => 0, + 'skipped_budget' => 0, + 'skipped_error' => 0, + ); + + foreach ( $peptides as $peptide ) { + foreach ( $intents as $intent_tpl ) { + $query = str_replace( '{peptide}', $peptide['label'], $intent_tpl ); + + foreach ( $models as $model ) { + $provider = $this->resolve_provider( $model ); + + if ( null === $provider || ! $provider->is_configured() ) { + $counts['skipped_error']++; + continue; + } + + // Budget pre-check. + $estimated = $this->get_estimated_cost( $model ); + if ( ! $this->ledger->can_afford( $estimated ) ) { + $counts['skipped_budget']++; + continue; + } + + // In-run cache check. + $cache_key = $peptide['slug'] . '|' . $intent_tpl . '|' . $model; + if ( isset( $this->cache[ $cache_key ] ) ) { + $result = $this->cache[ $cache_key ]; + } else { + try { + $result = $provider->probe( $query ); + $this->cache[ $cache_key ] = $result; + } catch ( \Exception $e ) { + error_log( sprintf( 'PGM: probe failed [%s / %s]: %s', $model, $peptide['slug'], $e->getMessage() ) ); + $counts['skipped_error']++; + continue; + } + } + + $row_id = $this->persist_result( $run_id, $peptide, $model, $intent_tpl, $result ); + if ( $row_id > 0 ) { + $this->ledger->update_row_cost( $row_id, $result->get_cost_usd() ); + } + + $counts['probed']++; + } + } + } + + $this->cache = array(); // Reset cache after run. + return $counts; + } + + /** + * Persist a single probe result to the database. + * + * @param string $run_id UUID for this run. + * @param array{slug:string, label:string} $peptide Peptide config. + * @param string $model Model identifier. + * @param string $intent_tpl Raw intent template string. + * @param PGM_Probe_Result $result Probe result. + * + * @return int Inserted row ID, or 0 on failure. + */ + private function persist_result( string $run_id, array $peptide, string $model, string $intent_tpl, PGM_Probe_Result $result ): int { + global $wpdb; + + $table = PGM_Table_Manager::get_table_name(); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->insert( + $table, + array( + 'run_id' => $run_id, + 'captured_at' => current_time( 'mysql', true ), + 'peptide_slug' => $peptide['slug'], + 'peptide_label' => $peptide['label'], + 'model' => $model, + 'prompt_intent' => $intent_tpl, + 'cited' => $result->is_cited() ? 1 : 0, + 'our_position' => $result->get_our_position(), + 'source_domains' => wp_json_encode( $result->get_source_domains() ), + 'raw_excerpt' => $result->get_raw_excerpt(), + 'cost_usd' => number_format( $result->get_cost_usd(), 8, '.', '' ), + ), + array( '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s' ) + ); + + return (int) $wpdb->insert_id; + } + + /** + * Instantiate the correct provider class for a model string. + * + * @param string $model OpenRouter model identifier. + * + * @return PGM_Probe_Provider|null Null when the model is unrecognised. + */ + private function resolve_provider( string $model ): ?PGM_Probe_Provider { + if ( 'perplexity/sonar' === $model ) { + return new PGM_Perplexity_Provider(); + } + // All other models route through the generic OpenRouter provider. + return new PGM_OpenRouter_Provider( $model ); + } + + /** + * Conservative per-call cost estimate used for the budget pre-check. + * + * @param string $model Model identifier. + * + * @return float Estimated USD. + */ + private function get_estimated_cost( string $model ): float { + if ( 'perplexity/sonar' === $model ) { + return PGM_Perplexity_Provider::ESTIMATED_COST_PER_PROBE; + } + return PGM_OpenRouter_Provider::ESTIMATED_COST_PER_PROBE; + } + + /** + * Generate a UUID v4 run identifier. + * + * @return string + */ + private function generate_run_id(): string { + return sprintf( + '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', + wp_rand( 0, 0xffff ), + wp_rand( 0, 0xffff ), + wp_rand( 0, 0xffff ), + wp_rand( 0, 0x0fff ) | 0x4000, + wp_rand( 0, 0x3fff ) | 0x8000, + wp_rand( 0, 0xffff ), + wp_rand( 0, 0xffff ), + wp_rand( 0, 0xffff ) + ); + } +} diff --git a/includes/core/class-pgm-table-manager.php b/includes/core/class-pgm-table-manager.php new file mode 100644 index 0000000..c97b571 --- /dev/null +++ b/includes/core/class-pgm-table-manager.php @@ -0,0 +1,94 @@ +prefix). + */ + const TABLE_BASE = 'pgm_ai_visibility'; + + /** + * Create or upgrade the AI-visibility table. + * + * Uses dbDelta to create or non-destructively alter the table. + * Records the schema version in an option afterwards. + * + * Side effects: Writes to the database; sets pgm_schema_version option. + * + * @return void + */ + public static function create_table(): void { + global $wpdb; + + $table_name = $wpdb->prefix . self::TABLE_BASE; + $charset_collate = $wpdb->get_charset_collate(); + + $sql = "CREATE TABLE {$table_name} ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + run_id VARCHAR(36) NOT NULL, + captured_at DATETIME NOT NULL, + peptide_slug VARCHAR(200) NOT NULL, + peptide_label VARCHAR(200) NOT NULL, + model VARCHAR(200) NOT NULL, + prompt_intent VARCHAR(200) NOT NULL, + cited TINYINT(1) NOT NULL DEFAULT 0, + our_position INT(11) NULL DEFAULT NULL, + source_domains LONGTEXT NOT NULL DEFAULT '', + raw_excerpt LONGTEXT NOT NULL DEFAULT '', + cost_usd DECIMAL(12,8) NOT NULL DEFAULT 0.00000000, + PRIMARY KEY (id), + KEY run_id (run_id), + KEY peptide_slug (peptide_slug), + KEY captured_at (captured_at) + ) {$charset_collate};"; + + if ( ! function_exists( 'dbDelta' ) ) { + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + } + + dbDelta( $sql ); + + update_option( 'pgm_schema_version', PGM_SCHEMA_VERSION ); + } + + /** + * Get the full table name including the WP prefix. + * + * @return string + */ + public static function get_table_name(): string { + global $wpdb; + return $wpdb->prefix . self::TABLE_BASE; + } + + /** + * Drop the AI-visibility table entirely. + * + * Called only from uninstall.php — never during normal deactivation. + * + * Side effects: Permanently destroys all probe data. + * + * @return void + */ + public static function drop_table(): void { + global $wpdb; + $table = $wpdb->prefix . self::TABLE_BASE; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->query( "DROP TABLE IF EXISTS {$table}" ); + } +} diff --git a/includes/panel/class-pgm-ai-visibility-panel.php b/includes/panel/class-pgm-ai-visibility-panel.php new file mode 100644 index 0000000..75f53ca --- /dev/null +++ b/includes/panel/class-pgm-ai-visibility-panel.php @@ -0,0 +1,152 @@ + $data Payload from PGM_Ai_Visibility_Collector. + * + * @return void + */ + public function render( array $data ): void { + $trendline = is_array( $data['trendline'] ?? null ) ? $data['trendline'] : array(); + $standings = is_array( $data['standings'] ?? null ) ? $data['standings'] : array(); + $last_run_at = isset( $data['last_run_at'] ) && $data['last_run_at'] ? esc_html( (string) $data['last_run_at'] ) : esc_html__( 'Never', 'peptide-geo-monitor' ); + $mtd_cost = isset( $data['mtd_cost_usd'] ) ? (float) $data['mtd_cost_usd'] : 0.0; + $cap = isset( $data['monthly_cap_usd'] ) ? (float) $data['monthly_cap_usd'] : PGM_DEFAULT_MONTHLY_BUDGET_USD; + + $this->render_proxy_note(); + $this->render_meta_bar( $last_run_at, $mtd_cost, $cap ); + $this->render_trendline( $trendline ); + $this->render_standings( $standings ); + } + + /** + * {@inheritDoc} + */ + public function get_title(): string { + return __( 'AI Visibility', 'peptide-geo-monitor' ); + } + + /** + * Render the proxy-metric disclaimer note. + * + * @return void + */ + private function render_proxy_note(): void { + echo '

'; + echo '' . esc_html__( 'Note:', 'peptide-geo-monitor' ) . ' '; + echo esc_html__( 'Scores are measured via API probes — a directional proxy, not the consumer ChatGPT/Gemini apps (different retrieval, personalisation, and system prompts).', 'peptide-geo-monitor' ); + echo '

'; + } + + /** + * Render the run metadata bar (last run, cost, cap). + * + * @param string $last_run_at Formatted last-run timestamp or "Never". + * @param float $mtd_cost Month-to-date spend in USD. + * @param float $cap Monthly cap in USD. + * + * @return void + */ + private function render_meta_bar( string $last_run_at, float $mtd_cost, float $cap ): void { + $pct = $cap > 0 ? min( 100.0, round( $mtd_cost / $cap * 100, 1 ) ) : 0; + echo '
'; + echo '' . esc_html__( 'Last run:', 'peptide-geo-monitor' ) . ' ' . esc_html( $last_run_at ) . ''; + echo '' . esc_html__( 'MTD cost:', 'peptide-geo-monitor' ) . ' $' . esc_html( number_format( $mtd_cost, 4 ) ) . ' / $' . esc_html( number_format( $cap, 2 ) ) . ' (' . esc_html( $pct ) . '% of cap)'; + echo '
'; + } + + /** + * Render the Chart.js visibility score trendline. + * + * @param array $trendline + * + * @return void + */ + private function render_trendline( array $trendline ): void { + echo '

' . esc_html__( 'Visibility Score — Trend', 'peptide-geo-monitor' ) . '

'; + + if ( empty( $trendline ) ) { + echo '

' . esc_html__( 'No probe runs recorded yet. Click "Run now" to collect the first data point.', 'peptide-geo-monitor' ) . '

'; + return; + } + + $labels = array(); + $scores = array(); + foreach ( $trendline as $point ) { + $labels[] = esc_js( date( 'M j', strtotime( $point['captured_at'] ) ) ); + $scores[] = (float) $point['score']; + } + + $labels_json = wp_json_encode( $labels ); + $scores_json = wp_json_encode( $scores ); + + echo ''; + echo ''; + echo ''; + } + + /** + * Render the per-peptide current standings table. + * + * @param array $standings + * + * @return void + */ + private function render_standings( array $standings ): void { + echo '

' . esc_html__( 'Current Standings', 'peptide-geo-monitor' ) . '

'; + + if ( empty( $standings ) ) { + echo '

' . esc_html__( 'No standings data yet.', 'peptide-geo-monitor' ) . '

'; + return; + } + + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + + foreach ( $standings as $row ) { + $cited_icon = $row['cited'] ? '✅' : '❌'; + $position = null !== $row['our_position'] ? '#' . (int) $row['our_position'] : '—'; + $domains = implode( ', ', array_map( 'esc_html', (array) $row['top_domains'] ) ); + + echo ''; + echo ''; + echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- only emoji + echo ''; + echo ''; + echo ''; + } + + echo '
' . esc_html__( 'Peptide', 'peptide-geo-monitor' ) . '' . esc_html__( 'Cited?', 'peptide-geo-monitor' ) . '' . esc_html__( 'Our Position', 'peptide-geo-monitor' ) . '' . esc_html__( 'Top Competing Domains', 'peptide-geo-monitor' ) . '
' . esc_html( (string) $row['label'] ) . '' . $cited_icon . '' . esc_html( $position ) . '' . wp_kses_post( $domains ) . '
'; + } +} diff --git a/includes/panel/interface-pgm-dashboard-panel.php b/includes/panel/interface-pgm-dashboard-panel.php new file mode 100644 index 0000000..e7aedcc --- /dev/null +++ b/includes/panel/interface-pgm-dashboard-panel.php @@ -0,0 +1,38 @@ + $data Payload returned by the matching collector. + * + * @return void + */ + public function render( array $data ): void; + + /** + * Short human-readable title for the panel tab / section heading. + * + * @return string + */ + public function get_title(): string; +} diff --git a/includes/providers/class-pgm-citation-detector.php b/includes/providers/class-pgm-citation-detector.php new file mode 100644 index 0000000..126e6c0 --- /dev/null +++ b/includes/providers/class-pgm-citation-detector.php @@ -0,0 +1,80 @@ + $raw_citations Raw citations from the API response. + * + * @return string[] Lowercase domain names (e.g. ['peptiderepo.com', 'examine.com']). + */ + public function parse_domains( array $raw_citations ): array { + $domains = array(); + + foreach ( $raw_citations as $item ) { + $url = ''; + if ( is_string( $item ) ) { + $url = $item; + } elseif ( is_array( $item ) && isset( $item['url'] ) && is_string( $item['url'] ) ) { + $url = $item['url']; + } + + if ( '' === $url ) { + continue; + } + + $host = (string) wp_parse_url( $url, PHP_URL_HOST ); + if ( '' !== $host ) { + $domains[] = strtolower( ltrim( $host, 'www.' ) ); + } + } + + return array_values( array_unique( $domains ) ); + } + + /** + * Detect whether PGM_TARGET_DOMAIN appears in a domain list. + * + * @param string[] $domains Parsed domain list from parse_domains(). + * + * @return bool + */ + public function is_cited( array $domains ): bool { + return in_array( PGM_TARGET_DOMAIN, $domains, true ); + } + + /** + * Find the 1-based position of PGM_TARGET_DOMAIN in a domain list. + * + * Returns null when the target domain is not found. + * + * @param string[] $domains Parsed domain list. + * + * @return int|null + */ + public function get_our_position( array $domains ): ?int { + $pos = array_search( PGM_TARGET_DOMAIN, $domains, true ); + if ( false === $pos ) { + return null; + } + return (int) $pos + 1; // Convert 0-based index to 1-based position. + } +} diff --git a/includes/providers/class-pgm-gateway-client.php b/includes/providers/class-pgm-gateway-client.php new file mode 100644 index 0000000..dea8b19 --- /dev/null +++ b/includes/providers/class-pgm-gateway-client.php @@ -0,0 +1,172 @@ + $body JSON request body. + * @param string $api_key Bearer token. + * @param string $endpoint Path appended after the provider base (default "/chat/completions"). + * + * @return array Decoded JSON response body. + * + * @throws \RuntimeException On permanent HTTP failure or parse error. + */ + public function post_to_gateway( string $provider_slug, array $body, string $api_key, string $endpoint = '/chat/completions' ): array { + $base_url = $this->build_base_url( $provider_slug ); + $url = $base_url . $endpoint; + $base_host = (string) wp_parse_url( $base_url, PHP_URL_HOST ); + + $headers = array( + 'Authorization' => 'Bearer ' . $api_key, + 'Content-Type' => 'application/json', + 'HTTP-Referer' => home_url(), + 'X-Title' => 'Peptide GEO Monitor', + ); + + // Belt-and-suspenders cURL injection — some hosts strip Authorization. + $curl_filter = $this->register_curl_auth_filter( $headers, $base_host ); + + $last_error = ''; + + try { + for ( $attempt = 1; $attempt <= PGM_MAX_RETRIES; $attempt++ ) { + $response = wp_remote_post( + $url, + array( + 'timeout' => PGM_API_TIMEOUT_SECONDS, + 'headers' => $headers, + 'body' => wp_json_encode( $body ), + ) + ); + + if ( is_wp_error( $response ) ) { + $last_error = $response->get_error_message(); + if ( $attempt < PGM_MAX_RETRIES ) { + sleep( PGM_RETRY_BASE_DELAY_SECONDS * (int) pow( 2, $attempt - 1 ) ); + } + continue; + } + + $status = wp_remote_retrieve_response_code( $response ); + $raw_body = wp_remote_retrieve_body( $response ); + + if ( $status >= 500 || 429 === $status ) { + $last_error = sprintf( 'HTTP %d', $status ); + if ( $attempt < PGM_MAX_RETRIES ) { + $retry_after = wp_remote_retrieve_header( $response, 'retry-after' ); + $delay = $retry_after ? min( (int) $retry_after, 60 ) : PGM_RETRY_BASE_DELAY_SECONDS * (int) pow( 2, $attempt - 1 ); + sleep( $delay ); + } + continue; + } + + if ( $status >= 400 ) { + throw new \RuntimeException( + sprintf( 'GEO Monitor gateway HTTP %d: %s', $status, substr( $raw_body, 0, 300 ) ) + ); + } + + $data = json_decode( $raw_body, true ); + if ( ! is_array( $data ) ) { + throw new \RuntimeException( 'GEO Monitor: invalid JSON response from gateway.' ); + } + + return $data; + } + } finally { + remove_action( 'http_api_curl', $curl_filter, 99 ); + } + + throw new \RuntimeException( + sprintf( 'GEO Monitor: gateway failed after %d attempts. Last: %s', PGM_MAX_RETRIES, $last_error ) + ); + } + + /** + * Build the API base URL (gateway or direct fallback). + * + * @param string $provider_slug Path segment after gateway_id (e.g. "openrouter"). + * + * @return string Base URL without trailing slash. + */ + public function build_base_url( string $provider_slug ): string { + if ( defined( 'PGM_CF_ACCOUNT_ID' ) && defined( 'PGM_CF_GATEWAY_ID' ) + && '' !== PGM_CF_ACCOUNT_ID && '' !== PGM_CF_GATEWAY_ID ) { + return sprintf( + '%s/%s/%s/%s', + self::GATEWAY_BASE, + PGM_CF_ACCOUNT_ID, + PGM_CF_GATEWAY_ID, + $provider_slug + ); + } + return self::OPENROUTER_BASE; + } + + /** + * Register cURL auth injection filter (mirrors PRAutoBlogger pattern). + * + * @param array $headers Request headers including Authorization. + * @param string $base_host Upstream host — limits leakage scope. + * + * @return callable The registered filter (caller MUST remove_action after request). + */ + private function register_curl_auth_filter( array $headers, string $base_host ): callable { + $filter = function ( $handle, $parsed_args, $url ) use ( $headers, $base_host ): void { + if ( '' === $base_host || false === strpos( (string) $url, $base_host ) ) { + return; + } + $curl_headers = array(); + foreach ( $headers as $name => $value ) { + $curl_headers[] = $name . ': ' . $value; + } + // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt + curl_setopt( $handle, CURLOPT_HTTPHEADER, $curl_headers ); + }; + add_action( 'http_api_curl', $filter, 99, 3 ); + return $filter; + } +} diff --git a/includes/providers/class-pgm-openrouter-provider.php b/includes/providers/class-pgm-openrouter-provider.php new file mode 100644 index 0000000..9babbb4 --- /dev/null +++ b/includes/providers/class-pgm-openrouter-provider.php @@ -0,0 +1,160 @@ +model = $model; + $this->gateway = $gateway ?? new PGM_Gateway_Client(); + $this->detector = $detector ?? new PGM_Citation_Detector(); + } + + /** + * {@inheritDoc} + * + * @param string $query + * @return PGM_Probe_Result + * @throws \RuntimeException On permanent API failure. + */ + public function probe( string $query ): PGM_Probe_Result { + $api_key = $this->resolve_api_key(); + + $body = array( + 'model' => $this->model, + 'messages' => array( + array( 'role' => 'user', 'content' => $query ), + ), + 'max_tokens' => 512, + ); + + $data = $this->gateway->post_to_gateway( 'openrouter', $body, $api_key ); + + return $this->parse_response( $data ); + } + + /** + * Parse the OpenRouter response into a PGM_Probe_Result. + * + * Attempts to extract citations from: + * 1. `annotations` array (some models include structured source references). + * 2. URLs found inline in the response text (regex fallback). + * + * @param array $data Decoded API response. + * + * @return PGM_Probe_Result + */ + public function parse_response( array $data ): PGM_Probe_Result { + $content = ''; + if ( isset( $data['choices'][0]['message']['content'] ) ) { + $content = (string) $data['choices'][0]['message']['content']; + } + $raw_excerpt = mb_substr( $content, 0, 500 ); + + // 1. Prefer structured annotations when present. + $raw_citations = array(); + if ( isset( $data['choices'][0]['message']['annotations'] ) && is_array( $data['choices'][0]['message']['annotations'] ) ) { + foreach ( $data['choices'][0]['message']['annotations'] as $ann ) { + if ( isset( $ann['url_citation']['url'] ) ) { + $raw_citations[] = $ann['url_citation']['url']; + } + } + } + + // 2. Regex fallback: extract URLs from response text. + if ( empty( $raw_citations ) && '' !== $content ) { + preg_match_all( '#https?://[^\s\)\]\"\'<>]+#i', $content, $matches ); + $raw_citations = $matches[0] ?? array(); + } + + $domains = $this->detector->parse_domains( $raw_citations ); + $cited = $this->detector->is_cited( $domains ); + $our_position = $this->detector->get_our_position( $domains ); + + // Cost estimation from usage data. + $cost_usd = self::ESTIMATED_COST_PER_PROBE; + if ( isset( $data['usage']['total_tokens'] ) ) { + // Rough blended rate ~$2/1M tokens across GPT-4o and Gemini Flash. + $cost_usd = (float) $data['usage']['total_tokens'] * 0.000002; + } + + return new PGM_Probe_Result( $raw_excerpt, $domains, $cited, $our_position, $cost_usd ); + } + + /** + * {@inheritDoc} + */ + public function get_name(): string { + return 'OpenRouter/' . $this->model; + } + + /** + * {@inheritDoc} + */ + public function is_configured(): bool { + return defined( 'PGM_OPENROUTER_API_KEY' ) && '' !== PGM_OPENROUTER_API_KEY; + } + + /** + * Retrieve the OpenRouter API key from wp-config constants. + * + * @return string + * @throws \RuntimeException When the constant is not defined or empty. + */ + private function resolve_api_key(): string { + if ( ! defined( 'PGM_OPENROUTER_API_KEY' ) || '' === PGM_OPENROUTER_API_KEY ) { + throw new \RuntimeException( + __( 'PGM_OPENROUTER_API_KEY constant is not defined. Add it to wp-config.php.', 'peptide-geo-monitor' ) + ); + } + return PGM_OPENROUTER_API_KEY; + } +} diff --git a/includes/providers/class-pgm-perplexity-provider.php b/includes/providers/class-pgm-perplexity-provider.php new file mode 100644 index 0000000..90c6f97 --- /dev/null +++ b/includes/providers/class-pgm-perplexity-provider.php @@ -0,0 +1,149 @@ +gateway = $gateway ?? new PGM_Gateway_Client(); + $this->detector = $detector ?? new PGM_Citation_Detector(); + } + + /** + * {@inheritDoc} + * + * Sends the query as a user message to perplexity/sonar via OpenRouter. + * Parses the citations[] array from the OpenRouter response envelope. + * + * @param string $query + * + * @return PGM_Probe_Result + * @throws \RuntimeException On permanent API failure. + */ + public function probe( string $query ): PGM_Probe_Result { + $api_key = $this->resolve_api_key(); + + $body = array( + 'model' => self::MODEL, + 'messages' => array( + array( 'role' => 'user', 'content' => $query ), + ), + 'max_tokens' => 512, + ); + + $data = $this->gateway->post_to_gateway( 'openrouter', $body, $api_key ); + + return $this->parse_response( $data ); + } + + /** + * Parse the OpenRouter/Perplexity response into a PGM_Probe_Result. + * + * Side effects: None. + * + * @param array $data Decoded API response. + * + * @return PGM_Probe_Result + */ + public function parse_response( array $data ): PGM_Probe_Result { + // Extract text excerpt from the first choice. + $content = ''; + if ( isset( $data['choices'][0]['message']['content'] ) ) { + $content = (string) $data['choices'][0]['message']['content']; + } + $raw_excerpt = mb_substr( $content, 0, 500 ); + + // Perplexity returns citations at the top level of the response envelope. + $raw_citations = array(); + if ( isset( $data['citations'] ) && is_array( $data['citations'] ) ) { + $raw_citations = $data['citations']; + } + + $domains = $this->detector->parse_domains( $raw_citations ); + $cited = $this->detector->is_cited( $domains ); + $our_position = $this->detector->get_our_position( $domains ); + + // Cost from usage data when available; fall back to estimate. + $cost_usd = self::ESTIMATED_COST_PER_PROBE; + if ( isset( $data['usage']['total_tokens'] ) ) { + // Sonar: ~$1 per 1M input, $1 per 1M output tokens. Avg ~$0.001/1k. + $cost_usd = (float) $data['usage']['total_tokens'] * 0.000001; + } + + return new PGM_Probe_Result( $raw_excerpt, $domains, $cited, $our_position, $cost_usd ); + } + + /** + * {@inheritDoc} + */ + public function get_name(): string { + return 'Perplexity sonar'; + } + + /** + * {@inheritDoc} + */ + public function is_configured(): bool { + return defined( 'PGM_OPENROUTER_API_KEY' ) && '' !== PGM_OPENROUTER_API_KEY; + } + + /** + * Retrieve the OpenRouter API key from wp-config constants. + * + * @return string + * @throws \RuntimeException When the constant is not defined or empty. + */ + private function resolve_api_key(): string { + if ( ! defined( 'PGM_OPENROUTER_API_KEY' ) || '' === PGM_OPENROUTER_API_KEY ) { + throw new \RuntimeException( + __( 'PGM_OPENROUTER_API_KEY constant is not defined. Add it to wp-config.php.', 'peptide-geo-monitor' ) + ); + } + return PGM_OPENROUTER_API_KEY; + } +} diff --git a/includes/providers/interface-pgm-probe-provider.php b/includes/providers/interface-pgm-probe-provider.php new file mode 100644 index 0000000..cefb7db --- /dev/null +++ b/includes/providers/interface-pgm-probe-provider.php @@ -0,0 +1,56 @@ +init(); + } +); diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..0c7ebb5 --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,17 @@ + + + WordPress coding standards for Peptide GEO Monitor. + + . + + /vendor/* + /node_modules/* + /tests/* + + + + + + + + diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..0f40a1b --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,294 @@ + [], + 'actions' => [], + 'wpdb_insert_id' => 1, + 'wpdb_results' => [], + 'wpdb_var' => null, + 'cron_events' => [], + 'remote_posts' => [], +]; + +function pgm_test_reset(): void { + $GLOBALS['pgm_test_state'] = [ + 'options' => [], + 'actions' => [], + 'wpdb_insert_id' => 1, + 'wpdb_results' => [], + 'wpdb_var' => null, + 'cron_events' => [], + 'remote_posts' => [], + ]; + PGM_Collector_Registry::reset_for_testing(); +} + +/* ── WordPress function stubs ──────────────────────────────────────────── */ + +function get_option( string $name, $default = false ) { + return $GLOBALS['pgm_test_state']['options'][ $name ] ?? $default; +} + +function update_option( string $name, $value ): bool { + $GLOBALS['pgm_test_state']['options'][ $name ] = $value; + return true; +} + +function add_option( string $name, $value = '' ): bool { + if ( ! isset( $GLOBALS['pgm_test_state']['options'][ $name ] ) ) { + $GLOBALS['pgm_test_state']['options'][ $name ] = $value; + } + return true; +} + +function add_action( string $hook, $callback, int $priority = 10, int $accepted_args = 1 ): bool { + $GLOBALS['pgm_test_state']['actions'][] = compact( 'hook', 'callback', 'priority' ); + return true; +} + +function remove_action( string $hook, $callback, int $priority = 10 ): bool { + return true; +} + +function add_menu_page( ...$args ): void {} + +function wp_schedule_event( int $timestamp, string $recurrence, string $hook ): void { + $GLOBALS['pgm_test_state']['cron_events'][ $hook ] = $timestamp; +} + +function wp_next_scheduled( string $hook ) { + return $GLOBALS['pgm_test_state']['cron_events'][ $hook ] ?? false; +} + +function wp_unschedule_event( int $timestamp, string $hook ): void { + unset( $GLOBALS['pgm_test_state']['cron_events'][ $hook ] ); +} + +function wp_clear_scheduled_hook( string $hook ): void { + unset( $GLOBALS['pgm_test_state']['cron_events'][ $hook ] ); +} + +function wp_rand( int $min = 0, int $max = 0 ): int { + return random_int( $min ?: 0, $max ?: PHP_INT_MAX ); +} + +function current_time( string $type, bool $gmt = false ): string { + return gmdate( 'Y-m-d H:i:s' ); +} + +function home_url( string $path = '' ): string { + return 'http://example.test' . $path; +} + +function __( string $text, string $domain = 'default' ): string { + return $text; +} + +function esc_html( string $text ): string { + return htmlspecialchars( $text, ENT_QUOTES ); +} + +function esc_html__( string $text, string $domain = 'default' ): string { + return esc_html( $text ); +} + +function esc_js( string $text ): string { + return addslashes( $text ); +} + +function esc_url( string $url ): string { + return $url; +} + +function wp_json_encode( $data, int $flags = 0 ): string { + return json_encode( $data, $flags ) ?: 'null'; +} + +function wp_parse_url( string $url, int $component = -1 ) { + return parse_url( $url, $component ); +} + +function absint( $value ): int { + return abs( (int) $value ); +} + +function sanitize_text_field( string $str ): string { + return trim( $str ); +} + +function number_format( $num, $decimals = 0, string $decimal_point = '.', string $thousands_sep = ',' ): string { + return \number_format( $num, $decimals, $decimal_point, $thousands_sep ); +} + +function current_user_can( string $cap ): bool { + return true; +} + +function wp_die( $message = '', $code = 0 ): void { + throw new \RuntimeException( is_string( $message ) ? $message : 'wp_die' ); +} + +function wp_verify_nonce( string $nonce, string $action ): bool { + return true; +} + +function wp_nonce_field( string $action, string $name ): string { + return ''; +} + +function submit_button( string $text, string $type = 'primary', string $name = 'submit', bool $wrap = true ): void {} + +function add_query_arg( array $args, string $url ): string { + return $url . '?' . http_build_query( $args ); +} + +function admin_url( string $path ): string { + return 'http://example.test/wp-admin/' . ltrim( $path, '/' ); +} + +function wp_safe_redirect( string $url ): void {} + +function is_wp_error( $thing ): bool { + return $thing instanceof WP_Error; +} + +function wp_remote_post( string $url, array $args = array() ) { + // Return from test queue if set, else a generic success. + $queue = &$GLOBALS['pgm_test_state']['remote_posts']; + if ( ! empty( $queue ) ) { + return array_shift( $queue ); + } + return array( 'response' => array( 'code' => 200 ), 'body' => '{"choices":[{"message":{"content":"test"}}],"citations":[],"usage":{"total_tokens":100}}' ); +} + +function wp_remote_retrieve_response_code( $response ): int { + return (int) ( $response['response']['code'] ?? 200 ); +} + +function wp_remote_retrieve_body( $response ): string { + return (string) ( $response['body'] ?? '' ); +} + +function wp_remote_retrieve_header( $response, string $header ): string { + return (string) ( $response['headers'][ $header ] ?? '' ); +} + +class WP_Error { + public function __construct( private string $code = '', private string $message = '' ) {} + public function get_error_message(): string { return $this->message; } +} + +/* ── Minimal $wpdb stub ────────────────────────────────────────────────── */ + +class stdClass_wpdb { + public string $prefix = 'wp_'; + public int $insert_id = 0; + public array $_results = []; + public mixed $_var = null; + + public function get_charset_collate(): string { + return 'DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci'; + } + public function prepare( string $query, ...$args ): string { + return vsprintf( str_replace( '%s', "'%s'", $query ), array_map( 'addslashes', $args ) ); + } + public function query( string $sql ): bool { return true; } + public function insert( string $table, array $data, array $format = [] ): int { + $this->insert_id = $GLOBALS['pgm_test_state']['wpdb_insert_id']++; + return 1; + } + public function update( string $table, array $data, array $where, array $data_format = [], array $where_format = [] ): int { + return 1; + } + public function get_results( string $query, string $output = 'OBJECT' ): array { + return is_array( $GLOBALS['pgm_test_state']['wpdb_results'] ) ? $GLOBALS['pgm_test_state']['wpdb_results'] : []; + } + public function get_var( string $query ): mixed { + return $GLOBALS['pgm_test_state']['wpdb_var']; + } +} + +$wpdb = new stdClass_wpdb(); + +function dbDelta( string $sql ): array { return []; } + +/* ── Test assertion helpers ────────────────────────────────────────────── */ + +$GLOBALS['pgm_test_report'] = [ 'pass' => 0, 'fail' => 0, 'failures' => [] ]; + +function pgm_assert( bool $condition, string $label ): void { + if ( $condition ) { + $GLOBALS['pgm_test_report']['pass']++; + echo " PASS: {$label}\n"; + } else { + $GLOBALS['pgm_test_report']['fail']++; + $GLOBALS['pgm_test_report']['failures'][] = $label; + echo " FAIL: {$label}\n"; + } +} + +function pgm_assert_equals( $expected, $actual, string $label ): void { + $pass = ( $expected === $actual ); + pgm_assert( $pass, $label . ( $pass ? '' : ' — expected ' . var_export( $expected, true ) . ', got ' . var_export( $actual, true ) ) ); +} + +function pgm_assert_throws( callable $fn, string $exception_class, string $label ): void { + try { + $fn(); + pgm_assert( false, $label . ' — expected ' . $exception_class . ' but no exception thrown' ); + } catch ( \Throwable $e ) { + pgm_assert( $e instanceof $exception_class, $label . ' — got ' . get_class( $e ) ); + } +} + +function pgm_test_summary(): int { + $r = $GLOBALS['pgm_test_report']; + echo "\n---\n"; + echo "Totals: {$r['pass']} passed, {$r['fail']} failed\n"; + if ( $r['fail'] > 0 ) { + echo "Failures:\n"; + foreach ( $r['failures'] as $f ) { + echo " - {$f}\n"; + } + return 1; + } + return 0; +} + +/* ── Load autoloader + all plugin classes ──────────────────────────────── */ + +require_once __DIR__ . '/../includes/core/class-pgm-autoloader.php'; +PGM_Autoloader::register(); +require_once __DIR__ . '/../includes/providers/interface-pgm-probe-provider.php'; +require_once __DIR__ . '/../includes/collector/interface-pgm-data-collector.php'; +require_once __DIR__ . '/../includes/panel/interface-pgm-dashboard-panel.php'; diff --git a/tests/unit/test-citation-detector.php b/tests/unit/test-citation-detector.php new file mode 100644 index 0000000..cc46f6e --- /dev/null +++ b/tests/unit/test-citation-detector.php @@ -0,0 +1,81 @@ +parse_domains( [ + 'https://peptiderepo.com/bpc-157/', + 'https://www.examine.com/supplements/bpc-157/', + 'https://pubmed.ncbi.nlm.nih.gov/12345', +] ); + +pgm_assert( in_array( 'peptiderepo.com', $domains, true ), 'parse_domains: peptiderepo.com extracted from URL' ); +pgm_assert( in_array( 'examine.com', $domains, true ), 'parse_domains: www. stripped from examine.com' ); +pgm_assert( in_array( 'ncbi.nlm.nih.gov', $domains, true ), 'parse_domains: subdomain-only domain extracted' ); +pgm_assert_equals( 3, count( $domains ), 'parse_domains: exactly 3 unique domains' ); + +// ── Test: parse object-style citations (Perplexity format) ─────────────── + +pgm_test_reset(); +$domains = $detector->parse_domains( [ + [ 'url' => 'https://peptiderepo.com/tb-500/' ], + [ 'url' => 'https://examine.com/tb500' ], + [ 'url' => 'https://peptiderepo.com/bpc-157/' ], // duplicate domain +] ); + +pgm_assert_equals( 2, count( $domains ), 'parse_domains: object-style citations, duplicates removed' ); +pgm_assert( in_array( 'peptiderepo.com', $domains, true ), 'parse_domains: object-style, peptiderepo.com present' ); + +// ── Test: parse empty input ────────────────────────────────────────────── + +pgm_test_reset(); +$domains = $detector->parse_domains( [] ); +pgm_assert_equals( [], $domains, 'parse_domains: empty input returns empty array' ); + +// ── Test: is_cited detection ───────────────────────────────────────────── + +pgm_test_reset(); +pgm_assert( $detector->is_cited( ['peptiderepo.com', 'examine.com'] ), 'is_cited: returns true when target present' ); +pgm_assert( ! $detector->is_cited( ['examine.com', 'pubmed.ncbi.nlm.nih.gov'] ), 'is_cited: returns false when target absent' ); +pgm_assert( ! $detector->is_cited( [] ), 'is_cited: returns false for empty list' ); + +// ── Test: get_our_position ─────────────────────────────────────────────── + +pgm_test_reset(); +$domains = ['examine.com', 'peptiderepo.com', 'pubmed.ncbi.nlm.nih.gov']; +pgm_assert_equals( 2, $detector->get_our_position( $domains ), 'get_our_position: 1-based, position 2' ); + +$domains_first = ['peptiderepo.com', 'examine.com']; +pgm_assert_equals( 1, $detector->get_our_position( $domains_first ), 'get_our_position: position 1 when first' ); + +$domains_absent = ['examine.com', 'pubmed.ncbi.nlm.nih.gov']; +pgm_assert_equals( null, $detector->get_our_position( $domains_absent ), 'get_our_position: null when not present' ); + +// ── Test: malformed / unexpected input ─────────────────────────────────── + +pgm_test_reset(); +$domains = $detector->parse_domains( [ + 'not-a-url', + 42, + null, + ['no_url_key' => 'foo'], + ['url' => ''], +] ); +pgm_assert_equals( [], $domains, 'parse_domains: gracefully skips malformed items' ); + +exit( pgm_test_summary() ); diff --git a/tests/unit/test-cost-ledger.php b/tests/unit/test-cost-ledger.php new file mode 100644 index 0000000..6a9db4f --- /dev/null +++ b/tests/unit/test-cost-ledger.php @@ -0,0 +1,85 @@ +can_afford( 1.0 ), 'can_afford: 2.50 spent + 1.00 = 3.50 < 5.00 → true' ); + +// ── Test: can_afford when at cap ───────────────────────────────────────── + +pgm_test_reset(); +$GLOBALS['pgm_test_state']['options']['pgm_monthly_budget_usd'] = 5.0; +$GLOBALS['pgm_test_state']['wpdb_var'] = '5.00'; + +$ledger = new PGM_Cost_Ledger(); +pgm_assert( ! $ledger->can_afford( 0.01 ), 'can_afford: 5.00 spent + 0.01 > 5.00 → false' ); + +// ── Test: can_afford when exactly at cap ───────────────────────────────── + +pgm_test_reset(); +$GLOBALS['pgm_test_state']['options']['pgm_monthly_budget_usd'] = 5.0; +$GLOBALS['pgm_test_state']['wpdb_var'] = '4.99'; + +$ledger = new PGM_Cost_Ledger(); +pgm_assert( $ledger->can_afford( 0.01 ), 'can_afford: 4.99 + 0.01 = 5.00 exactly → true (at cap is allowed)' ); + +// ── Test: get_remaining_budget_usd ────────────────────────────────────── + +pgm_test_reset(); +$GLOBALS['pgm_test_state']['options']['pgm_monthly_budget_usd'] = 5.0; +$GLOBALS['pgm_test_state']['wpdb_var'] = '3.25'; + +$ledger = new PGM_Cost_Ledger(); +pgm_assert_equals( 1.75, round( $ledger->get_remaining_budget_usd(), 2 ), 'get_remaining_budget_usd: 5.00 - 3.25 = 1.75' ); + +// ── Test: get_remaining_budget_usd never negative ──────────────────────── + +pgm_test_reset(); +$GLOBALS['pgm_test_state']['options']['pgm_monthly_budget_usd'] = 5.0; +$GLOBALS['pgm_test_state']['wpdb_var'] = '10.00'; // over cap + +$ledger = new PGM_Cost_Ledger(); +pgm_assert_equals( 0.0, $ledger->get_remaining_budget_usd(), 'get_remaining_budget_usd: clamps to 0.0 when over cap' ); + +// ── Test: get_month_to_date_usd with null DB result (no rows yet) ──────── + +pgm_test_reset(); +$GLOBALS['pgm_test_state']['options']['pgm_monthly_budget_usd'] = 5.0; +$GLOBALS['pgm_test_state']['wpdb_var'] = null; + +$ledger = new PGM_Cost_Ledger(); +pgm_assert_equals( 0.0, $ledger->get_month_to_date_usd(), 'get_month_to_date_usd: null DB result → 0.0' ); + +// ── Test: update_row_cost returns true ────────────────────────────────── + +pgm_test_reset(); +$ledger = new PGM_Cost_Ledger(); +pgm_assert( $ledger->update_row_cost( 42, 0.00123456 ), 'update_row_cost: returns true on success' ); + +// ── Test: budget abort mid-run (runner stops when cap hit) ────────────── + +pgm_test_reset(); +// Simulate a nearly-exhausted budget. +$GLOBALS['pgm_test_state']['options']['pgm_monthly_budget_usd'] = 0.001; // $0.001 cap +$GLOBALS['pgm_test_state']['wpdb_var'] = '0.001'; // fully spent + +$ledger2 = new PGM_Cost_Ledger(); +pgm_assert( ! $ledger2->can_afford( 0.000001 ), 'budget abort: can_afford returns false at cap → runner would stop' ); + +exit( pgm_test_summary() ); diff --git a/tests/unit/test-cron.php b/tests/unit/test-cron.php new file mode 100644 index 0000000..37c2ccd --- /dev/null +++ b/tests/unit/test-cron.php @@ -0,0 +1,64 @@ +register_hooks(); + +$hooks = array_column( $GLOBALS['pgm_test_state']['actions'], 'hook' ); +pgm_assert( in_array( PGM_CRON_HOOK, $hooks, true ), 'register_hooks: pgm_weekly_probe action registered' ); + +// ── Test: hook constant is correct string ──────────────────────────────── + +pgm_assert_equals( 'pgm_weekly_probe', PGM_CRON_HOOK, 'PGM_CRON_HOOK: constant value correct' ); + +// ── Test: PGM_Activator calls schedule_weekly ──────────────────────────── + +pgm_test_reset(); +// Seed options so seed_defaults() doesn't fail. +PGM_Activator::activate(); +pgm_assert( PGM_Cron::is_scheduled(), 'PGM_Activator::activate: weekly cron scheduled after activation' ); + +// ── Test: PGM_Deactivator calls clear_schedule ─────────────────────────── + +PGM_Deactivator::deactivate(); +pgm_assert( ! PGM_Cron::is_scheduled(), 'PGM_Deactivator::deactivate: cron cleared after deactivation' ); + +exit( pgm_test_summary() ); diff --git a/tests/unit/test-probe-runner.php b/tests/unit/test-probe-runner.php new file mode 100644 index 0000000..791aeca --- /dev/null +++ b/tests/unit/test-probe-runner.php @@ -0,0 +1,180 @@ +afford_count + $this->no_afford_count; + if ( $total >= $this->exhaust_after ) { + $this->no_afford_count++; + return false; + } + $this->afford_count++; + return true; + } + + public function update_row_cost( int $row_id, float $cost ): bool { return true; } + public function get_month_to_date_usd(): float { return 0.0; } +} + +// ── Shared mock response body (Perplexity / cited) ──────────────────────── + +$cited_body = json_encode([ + 'choices' => [['message' => ['content' => 'BPC-157 info.']]], + 'citations' => ['https://peptiderepo.com/bpc-157/', 'https://examine.com'], + 'usage' => ['total_tokens' => 100], +]); + +// ── Test: basic run completes — 1×1×1 (peptide × intent × model) ───────── + +pgm_test_reset(); +$GLOBALS['pgm_test_state']['options']['pgm_peptides'] = [['slug'=>'bpc-157','label'=>'BPC-157']]; +$GLOBALS['pgm_test_state']['options']['pgm_prompt_intents'] = ['what is {peptide}']; +$GLOBALS['pgm_test_state']['options']['pgm_models'] = ['perplexity/sonar']; +$GLOBALS['pgm_test_state']['remote_posts'] = [ + ['response' => ['code' => 200], 'body' => $cited_body], +]; + +$ledger1 = new PGM_Mock_Ledger(); +$runner1 = new PGM_Probe_Runner( $ledger1 ); +$counts1 = $runner1->run(); + +pgm_assert_equals( 1, $counts1['probed'], 'basic run: 1 probed' ); +pgm_assert_equals( 0, $counts1['skipped_budget'], 'basic run: 0 budget skips' ); +pgm_assert_equals( 0, $counts1['skipped_error'], 'basic run: 0 error skips' ); +pgm_assert_equals( 1, $ledger1->afford_count, 'basic run: 1 can_afford check' ); + +// ── Test: 2×1×1 run completes all probes when budget sufficient ─────────── + +pgm_test_reset(); +$GLOBALS['pgm_test_state']['options']['pgm_peptides'] = [ + ['slug'=>'bpc-157','label'=>'BPC-157'], + ['slug'=>'tb-500','label'=>'TB-500'], +]; +$GLOBALS['pgm_test_state']['options']['pgm_prompt_intents'] = ['what is {peptide}']; +$GLOBALS['pgm_test_state']['options']['pgm_models'] = ['perplexity/sonar']; +$GLOBALS['pgm_test_state']['remote_posts'] = [ + ['response' => ['code' => 200], 'body' => $cited_body], + ['response' => ['code' => 200], 'body' => $cited_body], +]; + +$ledger2 = new PGM_Mock_Ledger(); +$runner2 = new PGM_Probe_Runner( $ledger2 ); +$counts2 = $runner2->run(); + +pgm_assert_equals( 2, $counts2['probed'], '2×1×1 run: 2 probes completed' ); +pgm_assert_equals( 2, $ledger2->afford_count, '2×1×1 run: 2 can_afford checks' ); + +// ── Test: budget cap abort after N probes ──────────────────────────────── + +pgm_test_reset(); +$GLOBALS['pgm_test_state']['options']['pgm_peptides'] = [ + ['slug'=>'bpc-157','label'=>'BPC-157'], + ['slug'=>'tb-500','label'=>'TB-500'], + ['slug'=>'mk-677','label'=>'MK-677'], +]; +$GLOBALS['pgm_test_state']['options']['pgm_prompt_intents'] = ['what is {peptide}']; +$GLOBALS['pgm_test_state']['options']['pgm_models'] = ['perplexity/sonar']; +// Queue 3 responses but cap budget at 1. +$GLOBALS['pgm_test_state']['remote_posts'] = [ + ['response' => ['code' => 200], 'body' => $cited_body], + ['response' => ['code' => 200], 'body' => $cited_body], + ['response' => ['code' => 200], 'body' => $cited_body], +]; + +$exhausting_ledger = new PGM_Mock_Ledger(); +$exhausting_ledger->exhaust_after = 1; // Budget exhausted after 1st can_afford check. + +$runner3 = new PGM_Probe_Runner( $exhausting_ledger ); +$counts3 = $runner3->run(); + +pgm_assert_equals( 1, $counts3['probed'], 'budget abort: 1 probe completed before cap' ); +pgm_assert( $counts3['skipped_budget'] >= 2, 'budget abort: ≥2 probes skipped (budget)' ); +pgm_assert_equals( 0, $counts3['skipped_error'], 'budget abort: 0 error skips' ); + +// ── Test: HTTP error counts as skipped_error ───────────────────────────── + +pgm_test_reset(); +$GLOBALS['pgm_test_state']['options']['pgm_peptides'] = [['slug'=>'bpc-157','label'=>'BPC-157']]; +$GLOBALS['pgm_test_state']['options']['pgm_prompt_intents'] = ['what is {peptide}']; +$GLOBALS['pgm_test_state']['options']['pgm_models'] = ['perplexity/sonar']; +// All retries fail with 500. +$GLOBALS['pgm_test_state']['remote_posts'] = [ + ['response' => ['code' => 500], 'body' => 'Error'], + ['response' => ['code' => 500], 'body' => 'Error'], + ['response' => ['code' => 500], 'body' => 'Error'], +]; + +$ledger4 = new PGM_Mock_Ledger(); +$runner4 = new PGM_Probe_Runner( $ledger4 ); +$counts4 = $runner4->run(); + +pgm_assert_equals( 0, $counts4['probed'], 'HTTP 500: 0 probes' ); +pgm_assert( $counts4['skipped_error'] >= 1, 'HTTP 500: ≥1 error skip recorded' ); + +// ── Test: WP_Error from wp_remote_post counts as skipped_error ────────── + +pgm_test_reset(); +$GLOBALS['pgm_test_state']['options']['pgm_peptides'] = [['slug'=>'bpc-157','label'=>'BPC-157']]; +$GLOBALS['pgm_test_state']['options']['pgm_prompt_intents'] = ['what is {peptide}']; +$GLOBALS['pgm_test_state']['options']['pgm_models'] = ['perplexity/sonar']; +// Return WP_Error for all retries. +$wp_error = new WP_Error( 'http_request_failed', 'cURL error 28: Operation timed out' ); +$GLOBALS['pgm_test_state']['remote_posts'] = [ $wp_error, $wp_error, $wp_error ]; + +$ledger5 = new PGM_Mock_Ledger(); +$runner5 = new PGM_Probe_Runner( $ledger5 ); +$counts5 = $runner5->run(); + +pgm_assert_equals( 0, $counts5['probed'], 'WP_Error: 0 probes completed' ); +pgm_assert( $counts5['skipped_error'] >= 1, 'WP_Error: ≥1 error skip recorded' ); + +// ── Test: run returns correct keys in counts array ─────────────────────── + +pgm_test_reset(); +$GLOBALS['pgm_test_state']['options']['pgm_peptides'] = [['slug'=>'bpc-157','label'=>'BPC-157']]; +$GLOBALS['pgm_test_state']['options']['pgm_prompt_intents'] = ['what is {peptide}']; +$GLOBALS['pgm_test_state']['options']['pgm_models'] = ['perplexity/sonar']; +$GLOBALS['pgm_test_state']['remote_posts'] = [ + ['response' => ['code' => 200], 'body' => $cited_body], +]; + +$ledger6 = new PGM_Mock_Ledger(); +$runner6 = new PGM_Probe_Runner( $ledger6 ); +$counts6 = $runner6->run(); + +pgm_assert( array_key_exists( 'probed', $counts6 ), 'run: returns probed key' ); +pgm_assert( array_key_exists( 'skipped_budget', $counts6 ), 'run: returns skipped_budget key' ); +pgm_assert( array_key_exists( 'skipped_error', $counts6 ), 'run: returns skipped_error key' ); + +exit( pgm_test_summary() ); diff --git a/tests/unit/test-provider-parsing.php b/tests/unit/test-provider-parsing.php new file mode 100644 index 0000000..2ca94ad --- /dev/null +++ b/tests/unit/test-provider-parsing.php @@ -0,0 +1,135 @@ + [[ 'message' => [ 'content' => 'BPC-157 is a peptide. See peptiderepo.com' ] ]], + 'citations' => [ + 'https://peptiderepo.com/bpc-157/', + 'https://examine.com/bpc-157', + ], + 'usage' => [ 'total_tokens' => 500 ], +]; +$result = $provider->parse_response( $data ); +pgm_assert( $result->is_cited(), 'Perplexity: cited=true when peptiderepo.com in citations' ); +pgm_assert_equals( 1, $result->get_our_position(), 'Perplexity: position=1 when first in citations' ); +pgm_assert( count( $result->get_source_domains() ) === 2, 'Perplexity: 2 source domains extracted' ); +pgm_assert_equals( 500 * 0.000001, $result->get_cost_usd(), 'Perplexity: cost from token usage' ); + +// Test: not cited, no citations +pgm_test_reset(); +$data_empty = [ + 'choices' => [[ 'message' => [ 'content' => 'BPC-157 info here.' ] ]], + 'citations' => [], + 'usage' => [ 'total_tokens' => 200 ], +]; +$result_empty = $provider->parse_response( $data_empty ); +pgm_assert( ! $result_empty->is_cited(), 'Perplexity: cited=false with empty citations' ); +pgm_assert_equals( null, $result_empty->get_our_position(), 'Perplexity: position=null when not cited' ); + +// Test: excerpt truncated to 500 chars +pgm_test_reset(); +$long_content = str_repeat( 'a', 600 ); +$data_long = [ + 'choices' => [[ 'message' => [ 'content' => $long_content ] ]], + 'citations' => [], +]; +$result_long = $provider->parse_response( $data_long ); +pgm_assert_equals( 500, mb_strlen( $result_long->get_raw_excerpt() ), 'Perplexity: excerpt truncated to 500 chars' ); + +// Test: object-style citations +pgm_test_reset(); +$data_obj = [ + 'choices' => [[ 'message' => [ 'content' => 'answer' ] ]], + 'citations' => [ + [ 'url' => 'https://peptiderepo.com/mk-677/' ], + [ 'url' => 'https://examine.com/mk677' ], + ], +]; +$result_obj = $provider->parse_response( $data_obj ); +pgm_assert( $result_obj->is_cited(), 'Perplexity: object-style citations — cited=true' ); +pgm_assert_equals( 1, $result_obj->get_our_position(), 'Perplexity: object-style — position=1' ); + +// Test: fallback cost when no usage data +pgm_test_reset(); +$data_no_usage = [ + 'choices' => [[ 'message' => [ 'content' => 'answer' ] ]], + 'citations' => [], +]; +$result_no_usage = $provider->parse_response( $data_no_usage ); +pgm_assert_equals( PGM_Perplexity_Provider::ESTIMATED_COST_PER_PROBE, $result_no_usage->get_cost_usd(), 'Perplexity: fallback to ESTIMATED_COST_PER_PROBE when no usage' ); + +// ── PGM_OpenRouter_Provider parsing ───────────────────────────────────── + +echo "\n--- OpenRouter provider parsing ---\n"; + +$or_provider = new PGM_OpenRouter_Provider( 'openai/gpt-4o-search-preview', null, new PGM_Citation_Detector() ); + +// Test: annotations path (structured citations) +pgm_test_reset(); +$data_ann = [ + 'choices' => [[ + 'message' => [ + 'content' => 'BPC-157 reconstitution steps.', + 'annotations' => [ + [ 'url_citation' => [ 'url' => 'https://peptiderepo.com/bpc-157-reconstitution/' ] ], + [ 'url_citation' => [ 'url' => 'https://examine.com/bpc-157' ] ], + ], + ], + ]], + 'usage' => [ 'total_tokens' => 300 ], +]; +$result_ann = $or_provider->parse_response( $data_ann ); +pgm_assert( $result_ann->is_cited(), 'OpenRouter: annotations path — cited=true' ); +pgm_assert_equals( 1, $result_ann->get_our_position(), 'OpenRouter: annotations path — position=1' ); + +// Test: regex URL extraction fallback +pgm_test_reset(); +$data_regex = [ + 'choices' => [[ + 'message' => [ + 'content' => 'See https://peptiderepo.com/tb-500/ and https://examine.com/tb-500 for details.', + ], + ]], + 'usage' => [ 'total_tokens' => 150 ], +]; +$result_regex = $or_provider->parse_response( $data_regex ); +pgm_assert( $result_regex->is_cited(), 'OpenRouter: regex fallback — cited=true from inline URL' ); + +// Test: not cited +pgm_test_reset(); +$data_no_cite = [ + 'choices' => [[ 'message' => [ 'content' => 'Some response without our domain.' ] ]], +]; +$result_no_cite = $or_provider->parse_response( $data_no_cite ); +pgm_assert( ! $result_no_cite->is_cited(), 'OpenRouter: not cited when peptiderepo.com absent from text' ); + +// Test: cost calculation from token usage +pgm_test_reset(); +$data_cost = [ + 'choices' => [[ 'message' => [ 'content' => 'test' ] ]], + 'usage' => [ 'total_tokens' => 1000 ], +]; +$result_cost = $or_provider->parse_response( $data_cost ); +pgm_assert_equals( 1000 * 0.000002, $result_cost->get_cost_usd(), 'OpenRouter: cost from token usage (2$/1M)' ); + +exit( pgm_test_summary() ); diff --git a/tests/unit/test-score-calc.php b/tests/unit/test-score-calc.php new file mode 100644 index 0000000..f71819a --- /dev/null +++ b/tests/unit/test-score-calc.php @@ -0,0 +1,80 @@ +compute_score( 0, 0, 0.0 ), 'score: zero total returns 0.0' ); + +// ── Test: all cited, position 1 ───────────────────────────────────────── + +// 10 probes, all cited at position 1 +// base_score = 10/10 = 1.0 +// position_bonus = (1+1+1+…)/10 = 10/10 = 1.0 +// score = (1.0 + 1.0) / 2 = 1.0 +pgm_test_reset(); +pgm_assert_equals( 1.0, $collector->compute_score( 10, 10, 10.0 ), 'score: all cited at position 1 → 1.0' ); + +// ── Test: none cited ──────────────────────────────────────────────────── + +// 10 probes, 0 cited, position_sum=0 +// base_score = 0, position_bonus = 0 → score = 0 +pgm_test_reset(); +pgm_assert_equals( 0.0, $collector->compute_score( 0, 10, 0.0 ), 'score: none cited → 0.0' ); + +// ── Test: half cited at position 2 ────────────────────────────────────── + +// 10 probes, 5 cited each at position 2 → position_sum = 5*(1/2) = 2.5 +// base_score = 5/10 = 0.5 +// position_bonus = 2.5/10 = 0.25 +// score = (0.5 + 0.25) / 2 = 0.375 +pgm_test_reset(); +$score = $collector->compute_score( 5, 10, 2.5 ); +pgm_assert_equals( 0.375, $score, 'score: half cited at position 2 → 0.375' ); + +// ── Test: single probe, cited at position 3 ───────────────────────────── + +// 1 probe, 1 cited, position_sum = 1/3 ≈ 0.3333 +// base_score = 1.0 +// position_bonus = 0.3333 +// score = (1.0 + 0.3333) / 2 ≈ 0.6667 (rounded to 4 decimals) +pgm_test_reset(); +$score = $collector->compute_score( 1, 1, 1.0 / 3.0 ); +pgm_assert_equals( 0.6667, $score, 'score: single probe cited at position 3 → 0.6667' ); + +// ── Test: score is bounded [0, 1] ─────────────────────────────────────── + +// Extreme: position_sum could theoretically exceed total but that's logically +// impossible with 1-based positions (position_sum ≤ cited_count ≤ total). +// Verify edge case: 1 probe, 1 cited, position 1 +pgm_test_reset(); +pgm_assert( $collector->compute_score( 1, 1, 1.0 ) <= 1.0, 'score: never exceeds 1.0' ); +pgm_assert( $collector->compute_score( 0, 10, 0.0 ) >= 0.0, 'score: never below 0.0' ); + +// ── Test: precision — 4 decimal places ────────────────────────────────── + +pgm_test_reset(); +$score = $collector->compute_score( 3, 7, 1.5 ); +// base = 3/7 ≈ 0.4286, bonus = 1.5/7 ≈ 0.2143, avg ≈ 0.3214 +$expected = round( ( 3.0 / 7.0 + 1.5 / 7.0 ) / 2.0, 4 ); +pgm_assert_equals( $expected, $score, 'score: 4-decimal precision maintained' ); + +exit( pgm_test_summary() ); diff --git a/tests/unit/test-uninstall.php b/tests/unit/test-uninstall.php new file mode 100644 index 0000000..8beb04c --- /dev/null +++ b/tests/unit/test-uninstall.php @@ -0,0 +1,101 @@ +prefix . 'pgm_ai_visibility'; +// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared +$wpdb->query( "DROP TABLE IF EXISTS {$table}" ); + +/* ── 2. Delete all pgm_ prefixed options ──────────────────────────── */ + +// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery +$wpdb->query( + "DELETE FROM {$wpdb->options} WHERE option_name LIKE 'pgm\_%'" +); + +/* ── 3. Clear any remaining scheduled cron events ─────────────────── */ + +wp_clear_scheduled_hook( 'pgm_weekly_probe' );