Compare commits
3 Commits
main
...
claude/geo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f488f4a966 | ||
|
|
41f5969a71 | ||
|
|
ca1a41f6f9 |
78
.github/workflows/ci.yml
vendored
Normal file
78
.github/workflows/ci.yml
vendored
Normal file
@@ -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."
|
||||
188
ARCHITECTURE.md
Normal file
188
ARCHITECTURE.md
Normal file
@@ -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. |
|
||||
35
CHANGELOG.md
Normal file
35
CHANGELOG.md
Normal file
@@ -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.
|
||||
93
CONTEXT.md
Normal file
93
CONTEXT.md
Normal file
@@ -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()`.
|
||||
84
CONVENTIONS.md
Normal file
84
CONVENTIONS.md
Normal file
@@ -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:
|
||||
```
|
||||
/**
|
||||
* <One-sentence what>.
|
||||
*
|
||||
* Who triggers: <which class/hook calls this>.
|
||||
* Dependencies: <what this class needs to exist>.
|
||||
*
|
||||
* @see <cross-reference 1>
|
||||
* @see <cross-reference 2>
|
||||
* @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).
|
||||
77
README.md
77
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.
|
||||
|
||||
24
composer.json
Normal file
24
composer.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
180
includes/collector/class-pgm-ai-visibility-collector.php
Normal file
180
includes/collector/class-pgm-ai-visibility-collector.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* AI-visibility data collector — v1 implementation of PGM_Data_Collector.
|
||||
*
|
||||
* Reads the pgm_ai_visibility table and returns structured data for:
|
||||
* - Trendline: per-run visibility score (% cited, position-weighted).
|
||||
* - Standings: per-peptide latest cited status, our position, competing domains.
|
||||
* - Run metadata: last run time, MTD cost vs cap.
|
||||
*
|
||||
* Score formula (documented in CONTEXT.md):
|
||||
* base_score = cited_probes / total_probes (range 0–1)
|
||||
* position_bonus = Σ (1 / our_position) for cited probes / total_probes
|
||||
* visibility_score = round((base_score + position_bonus) / 2, 4)
|
||||
*
|
||||
* Who triggers: PGM_Admin_Page calls the registered collector via the registry.
|
||||
* Dependencies: $wpdb, PGM_Table_Manager, PGM_Cost_Ledger, PGM_Config.
|
||||
*
|
||||
* @see interface-pgm-data-collector.php — Interface this implements.
|
||||
* @see class-pgm-ai-visibility-panel.php — Renders the data returned here.
|
||||
* @see CONTEXT.md — Score formula + glossary.
|
||||
* @see ARCHITECTURE.md — §Storage, §Score.
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
class PGM_Ai_Visibility_Collector implements PGM_Data_Collector {
|
||||
|
||||
/**
|
||||
* @var PGM_Cost_Ledger
|
||||
*/
|
||||
private PGM_Cost_Ledger $ledger;
|
||||
|
||||
/**
|
||||
* @param PGM_Cost_Ledger|null $ledger Injected for testing.
|
||||
*/
|
||||
public function __construct( ?PGM_Cost_Ledger $ledger = null ) {
|
||||
$this->ledger = $ledger ?? new PGM_Cost_Ledger();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* @return array{
|
||||
* trendline: array<int, array{run_id: string, captured_at: string, score: float}>,
|
||||
* standings: array<string, array{label: string, cited: bool, our_position: int|null, top_domains: string[], model_count: int}>,
|
||||
* 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 ) );
|
||||
}
|
||||
}
|
||||
40
includes/collector/interface-pgm-data-collector.php
Normal file
40
includes/collector/interface-pgm-data-collector.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Seam: contract for all GEO-monitor data collectors.
|
||||
*
|
||||
* v1 implements only PGM_Ai_Visibility_Collector. Future collectors
|
||||
* (keyword rankings, schema coverage, technical-SEO) implement this
|
||||
* interface and register themselves with PGM_Collector_Registry without
|
||||
* changing the dashboard shell.
|
||||
*
|
||||
* Who triggers: PGM_Collector_Registry dispatches registered collectors.
|
||||
* Dependencies: None — pure interface.
|
||||
*
|
||||
* @see class-pgm-ai-visibility-collector.php — v1 implementation.
|
||||
* @see class-pgm-collector-registry.php — Registration hub.
|
||||
* @see ARCHITECTURE.md — §Collector/Panel seam.
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
interface PGM_Data_Collector {
|
||||
|
||||
/**
|
||||
* Collect and return the data payload for this category.
|
||||
*
|
||||
* The return shape is collector-specific; the dashboard panel for this
|
||||
* category receives the same array and is responsible for rendering it.
|
||||
*
|
||||
* @return array<string, mixed> 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;
|
||||
}
|
||||
32
includes/core/class-pgm-activator.php
Normal file
32
includes/core/class-pgm-activator.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Plugin activation handler.
|
||||
*
|
||||
* Creates the custom database table via dbDelta and registers the weekly
|
||||
* WP-Cron probe schedule. Safe to call on re-activation — dbDelta is
|
||||
* idempotent and the cron schedule is only added when absent.
|
||||
*
|
||||
* Who triggers: register_activation_hook() in peptide-geo-monitor.php.
|
||||
* Dependencies: PGM_Table_Manager, PGM_Cron, PGM_Config.
|
||||
*
|
||||
* @see class-pgm-table-manager.php — Database schema creation.
|
||||
* @see class-pgm-cron.php — Cron scheduling.
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
class PGM_Activator {
|
||||
|
||||
/**
|
||||
* Run all activation tasks.
|
||||
*
|
||||
* Side effects: Creates DB table, writes default options, schedules cron.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function activate(): void {
|
||||
PGM_Table_Manager::create_table();
|
||||
PGM_Config::seed_defaults();
|
||||
PGM_Cron::schedule_weekly();
|
||||
}
|
||||
}
|
||||
202
includes/core/class-pgm-admin-page.php
Normal file
202
includes/core/class-pgm-admin-page.php
Normal file
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* GEO Monitor top-level admin page.
|
||||
*
|
||||
* Registers the "GEO Monitor" menu item (manage_options), enqueues Chart.js,
|
||||
* handles the "Run now" POST action (nonce + capability), and delegates
|
||||
* rendering to the registered panels via PGM_Collector_Registry.
|
||||
*
|
||||
* Who triggers: PGM_Plugin::init() → is_admin() guard.
|
||||
* Dependencies: PGM_Collector_Registry, PGM_Probe_Runner.
|
||||
*
|
||||
* @see class-pgm-plugin.php — Calls register_hooks().
|
||||
* @see class-pgm-collector-registry.php — Provides panels.
|
||||
* @see class-pgm-probe-runner.php — Invoked by the Run now action.
|
||||
* @see ARCHITECTURE.md — §Admin dashboard.
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
class PGM_Admin_Page {
|
||||
|
||||
/**
|
||||
* Admin menu slug.
|
||||
*/
|
||||
const MENU_SLUG = 'pgm-geo-monitor';
|
||||
|
||||
/**
|
||||
* Nonce action for the Run now form.
|
||||
*/
|
||||
const NONCE_ACTION = 'pgm_run_now';
|
||||
|
||||
/**
|
||||
* Nonce field name.
|
||||
*/
|
||||
const NONCE_FIELD = 'pgm_nonce';
|
||||
|
||||
/**
|
||||
* Register WordPress admin hooks.
|
||||
*
|
||||
* Side effects: Adds admin_menu and admin_post actions.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register_hooks(): void {
|
||||
add_action( 'admin_menu', array( $this, 'add_menu_page' ) );
|
||||
add_action( 'admin_post_pgm_run_now', array( $this, 'handle_run_now' ) );
|
||||
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the "GEO Monitor" top-level menu page.
|
||||
*
|
||||
* Side effects: Adds a WP admin menu entry.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function add_menu_page(): void {
|
||||
add_menu_page(
|
||||
__( 'GEO Monitor', 'peptide-geo-monitor' ),
|
||||
__( 'GEO Monitor', 'peptide-geo-monitor' ),
|
||||
'manage_options',
|
||||
self::MENU_SLUG,
|
||||
array( $this, 'render_page' ),
|
||||
'dashicons-chart-line',
|
||||
58
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue Chart.js on the GEO Monitor page only.
|
||||
*
|
||||
* @param string $hook Current admin page hook.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function enqueue_assets( string $hook ): void {
|
||||
if ( false === strpos( $hook, self::MENU_SLUG ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
wp_enqueue_script(
|
||||
'pgm-chartjs',
|
||||
'https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js',
|
||||
array(),
|
||||
'4.4.3',
|
||||
true
|
||||
);
|
||||
|
||||
wp_add_inline_style(
|
||||
'wp-admin',
|
||||
'.pgm-meta-bar{display:flex;gap:20px;margin:10px 0 15px;flex-wrap:wrap;}
|
||||
.pgm-meta-item{background:#f6f7f7;padding:6px 12px;border-radius:4px;font-size:13px;}
|
||||
.pgm-card{background:#fff;border:1px solid #c3c4c7;padding:16px 20px;margin-bottom:20px;border-radius:4px;}
|
||||
.pgm-card h2{margin-top:0;}
|
||||
.pgm-proxy-note{margin:10px 0;}
|
||||
.pgm-standings th,.pgm-standings td{vertical-align:middle;}
|
||||
canvas#pgm-trendline-chart{max-height:300px;}'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the "Run now" POST action.
|
||||
*
|
||||
* Validates nonce and capability before dispatching to PGM_Probe_Runner.
|
||||
* Redirects back to the admin page with a status query arg.
|
||||
*
|
||||
* Side effects: Triggers a full probe run; redirects.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle_run_now(): void {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_die( esc_html__( 'Insufficient permissions.', 'peptide-geo-monitor' ), 403 );
|
||||
}
|
||||
|
||||
$nonce = isset( $_POST[ self::NONCE_FIELD ] ) ? sanitize_text_field( wp_unslash( $_POST[ self::NONCE_FIELD ] ) ) : '';
|
||||
if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) {
|
||||
wp_die( esc_html__( 'Security check failed.', 'peptide-geo-monitor' ), 403 );
|
||||
}
|
||||
|
||||
$runner = new PGM_Probe_Runner();
|
||||
$counts = $runner->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 '<div class="wrap">';
|
||||
echo '<h1>' . esc_html__( 'GEO Monitor — AI Visibility', 'peptide-geo-monitor' ) . '</h1>';
|
||||
|
||||
$this->render_run_done_notice();
|
||||
|
||||
// "Run now" button form.
|
||||
echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '">';
|
||||
echo '<input type="hidden" name="action" value="pgm_run_now">';
|
||||
wp_nonce_field( self::NONCE_ACTION, self::NONCE_FIELD );
|
||||
submit_button( __( 'Run now', 'peptide-geo-monitor' ), 'secondary', 'pgm_run_now_btn', false );
|
||||
echo '</form>';
|
||||
|
||||
$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 '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 '<div class="notice notice-success is-dismissible"><p>';
|
||||
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 '</p></div>';
|
||||
}
|
||||
}
|
||||
69
includes/core/class-pgm-autoloader.php
Normal file
69
includes/core/class-pgm-autoloader.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Class and interface autoloader for the Peptide GEO Monitor plugin.
|
||||
*
|
||||
* Converts PGM_* class/interface names to file paths under includes/.
|
||||
* Naming conventions:
|
||||
* PGM_Foo_Bar (class) → class-pgm-foo-bar.php
|
||||
* PGM_Foo_Bar (interface) → interface-pgm-foo-bar.php (tried second)
|
||||
*
|
||||
* Who triggers: Plugin boot (peptide-geo-monitor.php) — called once before
|
||||
* any PGM_* class or interface is instantiated.
|
||||
* Dependencies: None (pure PHP).
|
||||
*
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
class PGM_Autoloader {
|
||||
|
||||
/**
|
||||
* Register the autoloader with the SPL stack.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register(): void {
|
||||
spl_autoload_register( array( static::class, 'load' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to load a PGM_* class or interface file.
|
||||
*
|
||||
* Walks the known include subdirectories in order, trying the class-
|
||||
* filename first and the interface-filename second.
|
||||
*
|
||||
* Side effects: require_once on a matching file.
|
||||
*
|
||||
* @param string $class_name Fully-qualified class or interface name.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function load( string $class_name ): void {
|
||||
if ( 0 !== strpos( $class_name, 'PGM_' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$slug = strtolower( str_replace( '_', '-', $class_name ) );
|
||||
|
||||
$search_dirs = array(
|
||||
PGM_PLUGIN_DIR . 'includes/core/',
|
||||
PGM_PLUGIN_DIR . 'includes/providers/',
|
||||
PGM_PLUGIN_DIR . 'includes/collector/',
|
||||
PGM_PLUGIN_DIR . 'includes/panel/',
|
||||
PGM_PLUGIN_DIR . 'includes/',
|
||||
);
|
||||
|
||||
// Try class file first, then interface file.
|
||||
$candidates = array( 'class-' . $slug . '.php', 'interface-' . $slug . '.php' );
|
||||
|
||||
foreach ( $search_dirs as $dir ) {
|
||||
foreach ( $candidates as $file ) {
|
||||
$path = $dir . $file;
|
||||
if ( file_exists( $path ) ) {
|
||||
require_once $path;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
112
includes/core/class-pgm-collector-registry.php
Normal file
112
includes/core/class-pgm-collector-registry.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Registry: holds all registered collectors and panels.
|
||||
*
|
||||
* PGM_Plugin::init() registers the v1 AI-visibility collector + panel.
|
||||
* Future collectors/panels call register_collector()/register_panel()
|
||||
* without touching the dashboard shell.
|
||||
*
|
||||
* Who triggers: PGM_Plugin::init() on plugins_loaded.
|
||||
* Dependencies: PGM_Data_Collector, PGM_Dashboard_Panel interfaces.
|
||||
*
|
||||
* @see interface-pgm-data-collector.php — Collector contract.
|
||||
* @see interface-pgm-dashboard-panel.php — Panel contract.
|
||||
* @see class-pgm-plugin.php — Calls register_* on boot.
|
||||
* @see ARCHITECTURE.md — §Collector/Panel seam.
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
class PGM_Collector_Registry {
|
||||
|
||||
/**
|
||||
* Singleton instance.
|
||||
*
|
||||
* @var PGM_Collector_Registry|null
|
||||
*/
|
||||
private static ?PGM_Collector_Registry $instance = null;
|
||||
|
||||
/**
|
||||
* Registered collectors keyed by their collector key.
|
||||
*
|
||||
* @var array<string, PGM_Data_Collector>
|
||||
*/
|
||||
private array $collectors = array();
|
||||
|
||||
/**
|
||||
* Registered panels keyed by their panel key (matches collector key).
|
||||
*
|
||||
* @var array<string, PGM_Dashboard_Panel>
|
||||
*/
|
||||
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<string, PGM_Data_Collector>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
129
includes/core/class-pgm-config.php
Normal file
129
includes/core/class-pgm-config.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Configuration helper: seed defaults and retrieve plugin settings.
|
||||
*
|
||||
* All mutable settings live in wp_options under the pgm_ prefix. This class
|
||||
* provides typed getters (no magic values in call sites) and seeds the initial
|
||||
* defaults on first activation.
|
||||
*
|
||||
* Who triggers: PGM_Activator::activate() for seeding; every component that
|
||||
* needs a setting reads through here.
|
||||
* Dependencies: get_option(), update_option().
|
||||
*
|
||||
* @see CONTEXT.md — Domain glossary: peptides, prompt intents, models.
|
||||
* @see ARCHITECTURE.md — §Config (options + defaults).
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
class PGM_Config {
|
||||
|
||||
/**
|
||||
* Seed the default option values on first activation.
|
||||
*
|
||||
* Uses add_option() so existing values are never overwritten on re-activation.
|
||||
*
|
||||
* Side effects: Writes multiple wp_options rows on first run.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function seed_defaults(): void {
|
||||
add_option( 'pgm_monthly_budget_usd', PGM_DEFAULT_MONTHLY_BUDGET_USD );
|
||||
add_option( 'pgm_peptides', self::default_peptides() );
|
||||
add_option( 'pgm_prompt_intents', self::default_prompt_intents() );
|
||||
add_option( 'pgm_models', self::default_models() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the monthly budget cap in USD.
|
||||
*
|
||||
* @return float Positive float; falls back to the plugin default.
|
||||
*/
|
||||
public static function get_monthly_budget_usd(): float {
|
||||
$val = (float) get_option( 'pgm_monthly_budget_usd', PGM_DEFAULT_MONTHLY_BUDGET_USD );
|
||||
return $val > 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<int, array{slug: string, label: string}>
|
||||
*/
|
||||
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<int, string>
|
||||
*/
|
||||
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<int, string>
|
||||
*/
|
||||
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<int, array{slug: string, label: string}>
|
||||
*/
|
||||
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<int, string>
|
||||
*/
|
||||
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<int, string>
|
||||
*/
|
||||
private static function default_models(): array {
|
||||
return array(
|
||||
'perplexity/sonar',
|
||||
'openai/gpt-4o-search-preview',
|
||||
'google/gemini-2.0-flash-001',
|
||||
);
|
||||
}
|
||||
}
|
||||
109
includes/core/class-pgm-cost-ledger.php
Normal file
109
includes/core/class-pgm-cost-ledger.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Cost ledger: per-call logging + hard monthly budget enforcement.
|
||||
*
|
||||
* Every paid API call goes through record_call(). Before each call the
|
||||
* runner should check can_afford() to abort gracefully at the cap without
|
||||
* throwing — a partial run that respects the budget is acceptable; an
|
||||
* over-spend is not.
|
||||
*
|
||||
* Costs are stored in the pgm_ai_visibility table (cost_usd column per row)
|
||||
* and summarized by summing the current calendar month's rows. No separate
|
||||
* ledger table is needed; the visibility table IS the ledger.
|
||||
*
|
||||
* Who triggers: PGM_Probe_Runner before and after each API call.
|
||||
* Dependencies: $wpdb, PGM_Table_Manager.
|
||||
*
|
||||
* @see class-pgm-probe-runner.php — Calls can_afford() and record_call().
|
||||
* @see class-pgm-table-manager.php — Table where cost_usd is stored.
|
||||
* @see CONTEXT.md — "monthly cap" definition.
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
class PGM_Cost_Ledger {
|
||||
|
||||
/**
|
||||
* Retrieve the total probe cost incurred in the current calendar month (UTC).
|
||||
*
|
||||
* Side effects: Database read.
|
||||
*
|
||||
* @return float Sum of cost_usd for rows in the current month, in USD.
|
||||
*/
|
||||
public function get_month_to_date_usd(): float {
|
||||
global $wpdb;
|
||||
|
||||
$table = PGM_Table_Manager::get_table_name();
|
||||
$month = gmdate( 'Y-m' );
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$result = $wpdb->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;
|
||||
}
|
||||
}
|
||||
82
includes/core/class-pgm-cron.php
Normal file
82
includes/core/class-pgm-cron.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* WP-Cron management for the weekly AI-visibility probe.
|
||||
*
|
||||
* Registers the pgm_weekly_probe hook, schedules/clears the event, and
|
||||
* dispatches to PGM_Probe_Runner on each tick.
|
||||
*
|
||||
* Who triggers: PGM_Plugin::init() (hook registration), PGM_Activator
|
||||
* (schedule), PGM_Deactivator (clear).
|
||||
* Dependencies: PGM_Probe_Runner.
|
||||
*
|
||||
* @see class-pgm-probe-runner.php — Runner invoked on each cron tick.
|
||||
* @see class-pgm-activator.php — Calls schedule_weekly() on activation.
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
class PGM_Cron {
|
||||
|
||||
/**
|
||||
* Register WordPress action hooks.
|
||||
*
|
||||
* Side effects: Adds action for PGM_CRON_HOOK.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register_hooks(): void {
|
||||
add_action( PGM_CRON_HOOK, array( $this, 'handle_cron_tick' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback fired by WP-Cron on the weekly schedule.
|
||||
*
|
||||
* Side effects: Instantiates and runs PGM_Probe_Runner.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle_cron_tick(): void {
|
||||
$runner = new PGM_Probe_Runner();
|
||||
$runner->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 );
|
||||
}
|
||||
}
|
||||
29
includes/core/class-pgm-deactivator.php
Normal file
29
includes/core/class-pgm-deactivator.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Plugin deactivation handler.
|
||||
*
|
||||
* Clears the scheduled WP-Cron event so probes stop running when the plugin
|
||||
* is deactivated. Does NOT purge data — that is uninstall's job.
|
||||
*
|
||||
* Who triggers: register_deactivation_hook() in peptide-geo-monitor.php.
|
||||
* Dependencies: PGM_Cron.
|
||||
*
|
||||
* @see class-pgm-cron.php — Cron registration + clearing.
|
||||
* @see uninstall.php — Full data purge on uninstall.
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
class PGM_Deactivator {
|
||||
|
||||
/**
|
||||
* Clear the weekly cron schedule.
|
||||
*
|
||||
* Side effects: Removes the pgm_weekly_probe event from WP-Cron.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function deactivate(): void {
|
||||
PGM_Cron::clear_schedule();
|
||||
}
|
||||
}
|
||||
41
includes/core/class-pgm-plugin.php
Normal file
41
includes/core/class-pgm-plugin.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Main orchestrator for the Peptide GEO Monitor plugin.
|
||||
*
|
||||
* Boots sub-systems in the correct order, hooks into WP, and keeps
|
||||
* the top-level file under the 300-line limit by delegating all logic
|
||||
* to specialist classes.
|
||||
*
|
||||
* Who triggers: plugins_loaded action (peptide-geo-monitor.php).
|
||||
* Dependencies: PGM_Cron, PGM_Admin_Page, PGM_Collector_Registry.
|
||||
*
|
||||
* @see ARCHITECTURE.md — Boot sequence diagram.
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
class PGM_Plugin {
|
||||
|
||||
/**
|
||||
* Wire up all WordPress hooks.
|
||||
*
|
||||
* Side effects: Registers admin menus, enqueues assets, and registers the
|
||||
* collector/panel registry.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function init(): void {
|
||||
$cron = new PGM_Cron();
|
||||
$cron->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() );
|
||||
}
|
||||
}
|
||||
69
includes/core/class-pgm-probe-result.php
Normal file
69
includes/core/class-pgm-probe-result.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Value object: the result of a single LLM probe call.
|
||||
*
|
||||
* Returned by every PGM_Probe_Provider implementation. Immutable — all
|
||||
* fields are set in the constructor and read via typed getters.
|
||||
*
|
||||
* Who triggers: PGM_Perplexity_Provider and PGM_OpenRouter_Provider.
|
||||
* Dependencies: None.
|
||||
*
|
||||
* @see interface-pgm-probe-provider.php — Provider interface that returns this.
|
||||
* @see class-pgm-probe-runner.php — Consumes the result for storage.
|
||||
* @see CONTEXT.md — "probe result", "cited", "our_position".
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
class PGM_Probe_Result {
|
||||
|
||||
/**
|
||||
* @param string $raw_excerpt First ~500 chars of the LLM's response.
|
||||
* @param string[] $source_domains Domains extracted from the provider's citations.
|
||||
* @param bool $cited Whether peptiderepo.com appears in source_domains.
|
||||
* @param int|null $our_position 1-based position of peptiderepo.com in sources, or null.
|
||||
* @param float $cost_usd Estimated call cost in USD.
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $raw_excerpt,
|
||||
private readonly array $source_domains,
|
||||
private readonly bool $cited,
|
||||
private readonly ?int $our_position,
|
||||
private readonly float $cost_usd
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function get_raw_excerpt(): string {
|
||||
return $this->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;
|
||||
}
|
||||
}
|
||||
202
includes/core/class-pgm-probe-runner.php
Normal file
202
includes/core/class-pgm-probe-runner.php
Normal file
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Orchestrates a full probe run: peptides × intents × models.
|
||||
*
|
||||
* For each combination the runner:
|
||||
* 1. Checks the monthly budget cap (aborts gracefully when hit).
|
||||
* 2. Caches responses for identical (peptide, intent, model) within the run.
|
||||
* 3. Dispatches to the correct provider (resolved from PGM_Config::get_models()).
|
||||
* 4. Writes a row to the pgm_ai_visibility table.
|
||||
* 5. Settles the actual cost against the ledger row.
|
||||
*
|
||||
* Who triggers: PGM_Cron::handle_cron_tick() and PGM_Admin_Page (Run now action).
|
||||
* Dependencies: PGM_Config, PGM_Cost_Ledger, PGM_Table_Manager, all providers.
|
||||
*
|
||||
* @see class-pgm-cron.php — Calls run() on the weekly schedule.
|
||||
* @see class-pgm-cost-ledger.php — Budget cap enforcement.
|
||||
* @see class-pgm-perplexity-provider.php
|
||||
* @see class-pgm-openrouter-provider.php
|
||||
* @see ARCHITECTURE.md — §Probe run flow.
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
class PGM_Probe_Runner {
|
||||
|
||||
/**
|
||||
* @var PGM_Cost_Ledger
|
||||
*/
|
||||
private PGM_Cost_Ledger $ledger;
|
||||
|
||||
/**
|
||||
* In-run response cache: key = "{peptide_slug}|{intent}|{model}" → PGM_Probe_Result.
|
||||
*
|
||||
* @var array<string, PGM_Probe_Result>
|
||||
*/
|
||||
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 )
|
||||
);
|
||||
}
|
||||
}
|
||||
94
includes/core/class-pgm-table-manager.php
Normal file
94
includes/core/class-pgm-table-manager.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Database table creation and schema versioning for PGM.
|
||||
*
|
||||
* Creates {prefix}pgm_ai_visibility via dbDelta (idempotent). Tracks the
|
||||
* schema version in the pgm_schema_version option so future migrations can
|
||||
* detect when an upgrade is needed without destructive ALTER TABLE.
|
||||
*
|
||||
* Who triggers: PGM_Activator::activate() on plugin activation.
|
||||
* Dependencies: $wpdb global, dbDelta() (requires upgrade.php).
|
||||
*
|
||||
* @see class-pgm-activator.php — Calls create_table() on activation.
|
||||
* @see ARCHITECTURE.md — §Storage.
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
class PGM_Table_Manager {
|
||||
|
||||
/**
|
||||
* Name of the custom table (without $wpdb->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}" );
|
||||
}
|
||||
}
|
||||
152
includes/panel/class-pgm-ai-visibility-panel.php
Normal file
152
includes/panel/class-pgm-ai-visibility-panel.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* AI-visibility dashboard panel — renders the GEO Monitor admin page.
|
||||
*
|
||||
* Renders four sections:
|
||||
* 1. Proxy-metric disclaimer note.
|
||||
* 2. Chart.js trendline of visibility score across runs.
|
||||
* 3. Per-peptide standings table (cited, position, top competitor domains).
|
||||
* 4. MTD cost vs cap + last-run time.
|
||||
*
|
||||
* All output is escaped. Chart.js is enqueued via PGM_Admin_Page.
|
||||
*
|
||||
* Who triggers: PGM_Admin_Page::render_page() via PGM_Collector_Registry.
|
||||
* Dependencies: PGM_Ai_Visibility_Collector (data format contract).
|
||||
*
|
||||
* @see interface-pgm-dashboard-panel.php — Interface this implements.
|
||||
* @see class-pgm-ai-visibility-collector.php — Produces the $data array.
|
||||
* @see class-pgm-admin-page.php — Enqueues Chart.js; calls render().
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
class PGM_Ai_Visibility_Panel implements PGM_Dashboard_Panel {
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* @param array<string, mixed> $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 '<div class="notice notice-info pgm-proxy-note"><p>';
|
||||
echo '<strong>' . esc_html__( 'Note:', 'peptide-geo-monitor' ) . '</strong> ';
|
||||
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 '</p></div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 '<div class="pgm-meta-bar">';
|
||||
echo '<span class="pgm-meta-item"><strong>' . esc_html__( 'Last run:', 'peptide-geo-monitor' ) . '</strong> ' . esc_html( $last_run_at ) . '</span>';
|
||||
echo '<span class="pgm-meta-item"><strong>' . esc_html__( 'MTD cost:', 'peptide-geo-monitor' ) . '</strong> $' . esc_html( number_format( $mtd_cost, 4 ) ) . ' / $' . esc_html( number_format( $cap, 2 ) ) . ' (' . esc_html( $pct ) . '% of cap)</span>';
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Chart.js visibility score trendline.
|
||||
*
|
||||
* @param array<int, array{run_id: string, captured_at: string, score: float}> $trendline
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_trendline( array $trendline ): void {
|
||||
echo '<div class="pgm-card"><h2>' . esc_html__( 'Visibility Score — Trend', 'peptide-geo-monitor' ) . '</h2>';
|
||||
|
||||
if ( empty( $trendline ) ) {
|
||||
echo '<p>' . esc_html__( 'No probe runs recorded yet. Click "Run now" to collect the first data point.', 'peptide-geo-monitor' ) . '</p></div>';
|
||||
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 '<canvas id="pgm-trendline-chart" height="120"></canvas>';
|
||||
echo '<script>document.addEventListener("DOMContentLoaded",function(){';
|
||||
echo 'var ctx=document.getElementById("pgm-trendline-chart").getContext("2d");';
|
||||
echo 'new Chart(ctx,{type:"line",data:{labels:' . $labels_json . ',datasets:[{label:"Visibility Score",data:' . $scores_json . ',borderColor:"#2271b1",tension:0.3,fill:false}]},options:{scales:{y:{min:0,max:1,title:{display:true,text:"Score"}}},plugins:{legend:{display:false}}}});';
|
||||
echo '});</script>';
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the per-peptide current standings table.
|
||||
*
|
||||
* @param array<string, array{label: string, cited: bool, our_position: int|null, top_domains: string[], model_count: int}> $standings
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_standings( array $standings ): void {
|
||||
echo '<div class="pgm-card"><h2>' . esc_html__( 'Current Standings', 'peptide-geo-monitor' ) . '</h2>';
|
||||
|
||||
if ( empty( $standings ) ) {
|
||||
echo '<p>' . esc_html__( 'No standings data yet.', 'peptide-geo-monitor' ) . '</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
echo '<table class="wp-list-table widefat fixed striped pgm-standings">';
|
||||
echo '<thead><tr>';
|
||||
echo '<th>' . esc_html__( 'Peptide', 'peptide-geo-monitor' ) . '</th>';
|
||||
echo '<th>' . esc_html__( 'Cited?', 'peptide-geo-monitor' ) . '</th>';
|
||||
echo '<th>' . esc_html__( 'Our Position', 'peptide-geo-monitor' ) . '</th>';
|
||||
echo '<th>' . esc_html__( 'Top Competing Domains', 'peptide-geo-monitor' ) . '</th>';
|
||||
echo '</tr></thead><tbody>';
|
||||
|
||||
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 '<tr>';
|
||||
echo '<td>' . esc_html( (string) $row['label'] ) . '</td>';
|
||||
echo '<td>' . $cited_icon . '</td>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- only emoji
|
||||
echo '<td>' . esc_html( $position ) . '</td>';
|
||||
echo '<td>' . wp_kses_post( $domains ) . '</td>';
|
||||
echo '</tr>';
|
||||
}
|
||||
|
||||
echo '</tbody></table></div>';
|
||||
}
|
||||
}
|
||||
38
includes/panel/interface-pgm-dashboard-panel.php
Normal file
38
includes/panel/interface-pgm-dashboard-panel.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Seam: contract for all GEO-monitor dashboard panels.
|
||||
*
|
||||
* Each panel renders one data category within the GEO Monitor admin page.
|
||||
* v1 ships only PGM_Ai_Visibility_Panel. Future panels (keyword rankings,
|
||||
* schema coverage…) implement this and register with PGM_Collector_Registry.
|
||||
*
|
||||
* Who triggers: PGM_Admin_Page when building the GEO Monitor page.
|
||||
* Dependencies: None — pure interface.
|
||||
*
|
||||
* @see class-pgm-ai-visibility-panel.php — v1 implementation.
|
||||
* @see class-pgm-collector-registry.php — Registration hub.
|
||||
* @see ARCHITECTURE.md — §Collector/Panel seam.
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
interface PGM_Dashboard_Panel {
|
||||
|
||||
/**
|
||||
* Render the panel HTML into the current output buffer.
|
||||
*
|
||||
* Implementors MUST escape all output and MUST NOT echo raw DB values.
|
||||
*
|
||||
* @param array<string, mixed> $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;
|
||||
}
|
||||
80
includes/providers/class-pgm-citation-detector.php
Normal file
80
includes/providers/class-pgm-citation-detector.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Detects whether peptiderepo.com appears in a list of source domains.
|
||||
*
|
||||
* Centralises the citation-detection logic so both providers can share it
|
||||
* without duplication. Also parses raw citation arrays/URLs into a clean
|
||||
* domain list.
|
||||
*
|
||||
* Who triggers: PGM_Perplexity_Provider, PGM_OpenRouter_Provider.
|
||||
* Dependencies: None (pure PHP).
|
||||
*
|
||||
* @see CONTEXT.md — "cited", "our_position", "source_domains".
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
class PGM_Citation_Detector {
|
||||
|
||||
/**
|
||||
* Parse raw citation data from an API response into a clean domain list.
|
||||
*
|
||||
* Accepts either a flat array of URL strings or an array of objects with
|
||||
* a 'url' key (Perplexity style). Unknown shapes are skipped gracefully.
|
||||
*
|
||||
* @param array<mixed> $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.
|
||||
}
|
||||
}
|
||||
172
includes/providers/class-pgm-gateway-client.php
Normal file
172
includes/providers/class-pgm-gateway-client.php
Normal file
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Cloudflare AI Gateway HTTP client — shared by all providers.
|
||||
*
|
||||
* Builds the gateway URL, injects the Authorization header, applies
|
||||
* exponential-backoff retry, and delegates cURL auth injection (for
|
||||
* hosts that strip the Authorization header) — mirroring the approach
|
||||
* used in PRAutoBlogger's OpenRouter provider.
|
||||
*
|
||||
* Gateway URL shape:
|
||||
* https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}/openrouter
|
||||
*
|
||||
* Required WP-config constants (documented in README.md):
|
||||
* PGM_CF_ACCOUNT_ID — Cloudflare account ID
|
||||
* PGM_CF_GATEWAY_ID — AI Gateway name (e.g. "peptiderepo-prod")
|
||||
* PGM_OPENROUTER_API_KEY — OpenRouter API key (sk-or-…)
|
||||
*
|
||||
* Who triggers: PGM_Perplexity_Provider, PGM_OpenRouter_Provider.
|
||||
* Dependencies: wp_remote_post(), add_action(), curl_setopt().
|
||||
*
|
||||
* @see class-pgm-perplexity-provider.php — Uses post_to_gateway().
|
||||
* @see class-pgm-openrouter-provider.php — Uses post_to_gateway().
|
||||
* @see ARCHITECTURE.md — §External API integrations.
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
class PGM_Gateway_Client {
|
||||
|
||||
/**
|
||||
* Base URL for the Cloudflare AI Gateway (without trailing slash).
|
||||
*/
|
||||
private const GATEWAY_BASE = 'https://gateway.ai.cloudflare.com/v1';
|
||||
|
||||
/**
|
||||
* Fallback to direct OpenRouter when the gateway is not configured.
|
||||
*/
|
||||
private const OPENROUTER_BASE = 'https://openrouter.ai/api/v1';
|
||||
|
||||
/**
|
||||
* Send a chat-completion POST request through the gateway (or direct).
|
||||
*
|
||||
* Retries with exponential backoff on transient failures (5xx, timeout).
|
||||
* Throws on permanent failure after exhausting retries.
|
||||
*
|
||||
* Side effects: HTTP request; logs errors to PHP error log.
|
||||
*
|
||||
* @param string $provider_slug Gateway provider path segment (e.g. "openrouter", "perplexity").
|
||||
* @param array<string, mixed> $body JSON request body.
|
||||
* @param string $api_key Bearer token.
|
||||
* @param string $endpoint Path appended after the provider base (default "/chat/completions").
|
||||
*
|
||||
* @return array<string, mixed> 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<string, string> $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;
|
||||
}
|
||||
}
|
||||
160
includes/providers/class-pgm-openrouter-provider.php
Normal file
160
includes/providers/class-pgm-openrouter-provider.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Generic OpenRouter provider — parameterised for GPT-search and Gemini-search.
|
||||
*
|
||||
* One class handles any OpenRouter-routed model. The model identifier is set
|
||||
* at construction time; the runner instantiates one provider per model from
|
||||
* PGM_Config::get_models().
|
||||
*
|
||||
* GPT-search (openai/gpt-4o-search-preview) and Gemini-search
|
||||
* (google/gemini-2.0-flash-001) do not return a structured citations array
|
||||
* via OpenRouter — citations are parsed from URLs embedded in the response
|
||||
* text or from any 'annotations' key the model may include.
|
||||
*
|
||||
* Required WP-config constant:
|
||||
* PGM_OPENROUTER_API_KEY — OpenRouter key (sk-or-…)
|
||||
*
|
||||
* Who triggers: PGM_Probe_Runner via PGM_Probe_Provider interface.
|
||||
* Dependencies: PGM_Gateway_Client, PGM_Citation_Detector, PGM_Probe_Result.
|
||||
*
|
||||
* @see interface-pgm-probe-provider.php — Interface this implements.
|
||||
* @see class-pgm-gateway-client.php — Shared HTTP + retry logic.
|
||||
* @see class-pgm-citation-detector.php — Domain extraction + detection.
|
||||
* @see ARCHITECTURE.md — §Provider implementations.
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
class PGM_OpenRouter_Provider implements PGM_Probe_Provider {
|
||||
|
||||
/**
|
||||
* Estimated cost per probe call in USD (conservative cross-model default).
|
||||
*/
|
||||
const ESTIMATED_COST_PER_PROBE = 0.003;
|
||||
|
||||
/**
|
||||
* @var string OpenRouter model identifier (e.g. "openai/gpt-4o-search-preview").
|
||||
*/
|
||||
private string $model;
|
||||
|
||||
/**
|
||||
* @var PGM_Gateway_Client
|
||||
*/
|
||||
private PGM_Gateway_Client $gateway;
|
||||
|
||||
/**
|
||||
* @var PGM_Citation_Detector
|
||||
*/
|
||||
private PGM_Citation_Detector $detector;
|
||||
|
||||
/**
|
||||
* @param string $model OpenRouter model identifier.
|
||||
* @param PGM_Gateway_Client|null $gateway Injected for testing.
|
||||
* @param PGM_Citation_Detector|null $detector Injected for testing.
|
||||
*/
|
||||
public function __construct( string $model, ?PGM_Gateway_Client $gateway = null, ?PGM_Citation_Detector $detector = null ) {
|
||||
$this->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<string, mixed> $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;
|
||||
}
|
||||
}
|
||||
149
includes/providers/class-pgm-perplexity-provider.php
Normal file
149
includes/providers/class-pgm-perplexity-provider.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Perplexity sonar provider — primary citation signal.
|
||||
*
|
||||
* Routes through the Cloudflare AI Gateway (openrouter path) because
|
||||
* OpenRouter proxies Perplexity models. Perplexity's sonar returns
|
||||
* an explicit `citations` array — the most faithful citation signal
|
||||
* available (real web retrieval vs. training-time knowledge).
|
||||
*
|
||||
* Required WP-config constant:
|
||||
* PGM_OPENROUTER_API_KEY — OpenRouter key (sk-or-…)
|
||||
*
|
||||
* Who triggers: PGM_Probe_Runner via the PGM_Probe_Provider interface.
|
||||
* Dependencies: PGM_Gateway_Client, PGM_Citation_Detector, PGM_Probe_Result.
|
||||
*
|
||||
* @see interface-pgm-probe-provider.php — Interface this implements.
|
||||
* @see class-pgm-gateway-client.php — Shared HTTP + retry logic.
|
||||
* @see class-pgm-citation-detector.php — Domain extraction + detection.
|
||||
* @see ARCHITECTURE.md — §Provider implementations.
|
||||
* @see CONTEXT.md — "Perplexity sonar", "citations".
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
class PGM_Perplexity_Provider implements PGM_Probe_Provider {
|
||||
|
||||
/**
|
||||
* Model identifier passed to OpenRouter for Perplexity routing.
|
||||
*/
|
||||
const MODEL = 'perplexity/sonar';
|
||||
|
||||
/**
|
||||
* Estimated cost per probe call in USD (sonar is ~$0.005 / 1k tokens).
|
||||
* Used by the ledger's can_afford() pre-check. Actual cost settled post-call.
|
||||
*/
|
||||
const ESTIMATED_COST_PER_PROBE = 0.005;
|
||||
|
||||
/**
|
||||
* @var PGM_Gateway_Client
|
||||
*/
|
||||
private PGM_Gateway_Client $gateway;
|
||||
|
||||
/**
|
||||
* @var PGM_Citation_Detector
|
||||
*/
|
||||
private PGM_Citation_Detector $detector;
|
||||
|
||||
/**
|
||||
* @param PGM_Gateway_Client|null $gateway Injected for testing; auto-created otherwise.
|
||||
* @param PGM_Citation_Detector|null $detector Injected for testing; auto-created otherwise.
|
||||
*/
|
||||
public function __construct( ?PGM_Gateway_Client $gateway = null, ?PGM_Citation_Detector $detector = null ) {
|
||||
$this->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<string, mixed> $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;
|
||||
}
|
||||
}
|
||||
56
includes/providers/interface-pgm-probe-provider.php
Normal file
56
includes/providers/interface-pgm-probe-provider.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Contract for all LLM probe providers.
|
||||
*
|
||||
* Each provider implementation wraps one LLM backend (Perplexity sonar,
|
||||
* OpenRouter GPT-search, OpenRouter Gemini-search, …). Swapping providers
|
||||
* requires only a new class — the runner, ledger, and storage are agnostic.
|
||||
*
|
||||
* Who triggers: PGM_Probe_Runner for each (model, query) combination.
|
||||
* Dependencies: None — pure interface.
|
||||
*
|
||||
* @see class-pgm-perplexity-provider.php — Perplexity sonar implementation.
|
||||
* @see class-pgm-openrouter-provider.php — OpenRouter generic implementation.
|
||||
* @see class-pgm-probe-runner.php — Dispatches to providers via this interface.
|
||||
* @see CONTEXT.md — "provider", "probe".
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
interface PGM_Probe_Provider {
|
||||
|
||||
/**
|
||||
* Send a search/retrieval query and return a structured probe result.
|
||||
*
|
||||
* Implementations MUST:
|
||||
* - Extract cited source domains from the model's response.
|
||||
* - Detect whether PGM_TARGET_DOMAIN appears in those sources.
|
||||
* - Record our 1-based position (null when not cited).
|
||||
* - Return the call's estimated USD cost.
|
||||
* - Throw \RuntimeException on unrecoverable failure (caller logs + skips).
|
||||
*
|
||||
* @param string $query The natural-language query to send.
|
||||
*
|
||||
* @return PGM_Probe_Result
|
||||
*
|
||||
* @throws \RuntimeException When the API call fails after retries.
|
||||
*/
|
||||
public function probe( string $query ): PGM_Probe_Result;
|
||||
|
||||
/**
|
||||
* Get a short human-readable name for this provider (e.g. "Perplexity sonar").
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_name(): string;
|
||||
|
||||
/**
|
||||
* Check whether the required credentials/constants are configured.
|
||||
*
|
||||
* Returns false (never throws) so the runner can skip unconfigured
|
||||
* providers without aborting the whole run.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function is_configured(): bool;
|
||||
}
|
||||
68
peptide-geo-monitor.php
Normal file
68
peptide-geo-monitor.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
/**
|
||||
* Plugin Name: Peptide GEO Monitor
|
||||
* Plugin URI: https://peptiderepo.com
|
||||
* Description: Weekly server-side LLM probes for AI-visibility (GEO) tracking. Records whether peptiderepo.com is cited by LLMs across core peptides, stores time-series, and renders an admin trendline + standings table.
|
||||
* Version: 0.1.0
|
||||
* Author: peptiderepo
|
||||
* Author URI: https://peptiderepo.com
|
||||
* License: GPL-2.0-or-later
|
||||
* Text Domain: peptide-geo-monitor
|
||||
* Requires PHP: 8.1
|
||||
*
|
||||
* @see ARCHITECTURE.md — Full data flow and file tree.
|
||||
* @see CONVENTIONS.md — Naming patterns and extension guide.
|
||||
* @see CONTEXT.md — Domain glossary incl. the visibility score formula.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/* ── Constants ────────────────────────────────────────────────────────── */
|
||||
|
||||
define( 'PGM_VERSION', '0.1.0' );
|
||||
define( 'PGM_PLUGIN_FILE', __FILE__ );
|
||||
define( 'PGM_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
||||
define( 'PGM_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
|
||||
define( 'PGM_SCHEMA_VERSION', 1 );
|
||||
|
||||
/** @var int Maximum HTTP retries for LLM API calls. */
|
||||
define( 'PGM_MAX_RETRIES', 3 );
|
||||
|
||||
/** @var int API request timeout in seconds. */
|
||||
define( 'PGM_API_TIMEOUT_SECONDS', 60 );
|
||||
|
||||
/** @var int Base backoff delay (seconds) between retries. */
|
||||
define( 'PGM_RETRY_BASE_DELAY_SECONDS', 2 );
|
||||
|
||||
/** @var float Default monthly budget cap in USD. */
|
||||
define( 'PGM_DEFAULT_MONTHLY_BUDGET_USD', 5.0 );
|
||||
|
||||
/** @var string WP-Cron hook name for the weekly probe run. */
|
||||
define( 'PGM_CRON_HOOK', 'pgm_weekly_probe' );
|
||||
|
||||
/** @var string The site we are tracking citations for. */
|
||||
define( 'PGM_TARGET_DOMAIN', 'peptiderepo.com' );
|
||||
|
||||
/* ── Autoloader ───────────────────────────────────────────────────────── */
|
||||
|
||||
require_once PGM_PLUGIN_DIR . 'includes/core/class-pgm-autoloader.php';
|
||||
PGM_Autoloader::register();
|
||||
|
||||
/* ── Activation / Deactivation ────────────────────────────────────────── */
|
||||
|
||||
register_activation_hook( __FILE__, array( 'PGM_Activator', 'activate' ) );
|
||||
register_deactivation_hook( __FILE__, array( 'PGM_Deactivator', 'deactivate' ) );
|
||||
|
||||
/* ── Boot ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
add_action(
|
||||
'plugins_loaded',
|
||||
static function (): void {
|
||||
$plugin = new PGM_Plugin();
|
||||
$plugin->init();
|
||||
}
|
||||
);
|
||||
17
phpcs.xml.dist
Normal file
17
phpcs.xml.dist
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0"?>
|
||||
<ruleset name="Peptide GEO Monitor">
|
||||
<description>WordPress coding standards for Peptide GEO Monitor.</description>
|
||||
|
||||
<file>.</file>
|
||||
|
||||
<exclude-pattern>/vendor/*</exclude-pattern>
|
||||
<exclude-pattern>/node_modules/*</exclude-pattern>
|
||||
<exclude-pattern>/tests/*</exclude-pattern>
|
||||
|
||||
<arg name="extensions" value="php"/>
|
||||
<arg name="severity" value="1"/>
|
||||
<arg name="warning-severity" value="0"/>
|
||||
<arg value="sp"/>
|
||||
|
||||
<rule ref="WordPress"/>
|
||||
</ruleset>
|
||||
290
tests/bootstrap.php
Normal file
290
tests/bootstrap.php
Normal file
@@ -0,0 +1,290 @@
|
||||
<?php
|
||||
/**
|
||||
* Minimal test bootstrap — stubs all WordPress functions used by the plugin
|
||||
* so classes can be exercised in plain PHP without a WP install.
|
||||
*
|
||||
* Pattern mirrors peptide-repo-core's bootstrap (flat PHP, no PHPUnit).
|
||||
*
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/* ── Constants ────────────────────────────────────────────────────────── */
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
define( 'ABSPATH', __DIR__ . '/' );
|
||||
}
|
||||
|
||||
define( 'PGM_VERSION', '0.1.0' );
|
||||
define( 'PGM_PLUGIN_FILE', __DIR__ . '/../peptide-geo-monitor.php' );
|
||||
define( 'PGM_PLUGIN_DIR', realpath( __DIR__ . '/..' ) . '/' );
|
||||
define( 'PGM_PLUGIN_URL', 'http://example.test/wp-content/plugins/peptide-geo-monitor/' );
|
||||
define( 'PGM_SCHEMA_VERSION', 1 );
|
||||
define( 'PGM_MAX_RETRIES', 3 );
|
||||
define( 'PGM_API_TIMEOUT_SECONDS', 60 );
|
||||
define( 'PGM_RETRY_BASE_DELAY_SECONDS', 2 );
|
||||
define( 'PGM_DEFAULT_MONTHLY_BUDGET_USD', 5.0 );
|
||||
define( 'PGM_CRON_HOOK', 'pgm_weekly_probe' );
|
||||
define( 'PGM_TARGET_DOMAIN', 'peptiderepo.com' );
|
||||
|
||||
/* ── Global test state ─────────────────────────────────────────────────── */
|
||||
|
||||
$GLOBALS['pgm_test_state'] = [
|
||||
'options' => [],
|
||||
'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 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';
|
||||
81
tests/unit/test-citation-detector.php
Normal file
81
tests/unit/test-citation-detector.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PGM_Citation_Detector: domain extraction and cite detection.
|
||||
*
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../bootstrap.php';
|
||||
|
||||
echo "=== PGM Citation Detector Tests ===\n";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
$detector = new PGM_Citation_Detector();
|
||||
|
||||
// ── Test: parse plain URL strings ────────────────────────────────────────
|
||||
|
||||
pgm_test_reset();
|
||||
$domains = $detector->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( 'pubmed.ncbi.nlm.nih.gov', $domains, true ), 'parse_domains: full subdomain preserved when not www.' );
|
||||
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() );
|
||||
85
tests/unit/test-cost-ledger.php
Normal file
85
tests/unit/test-cost-ledger.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PGM_Cost_Ledger: MTD cost retrieval, budget pre-check, and
|
||||
* the budget-cap abort behaviour.
|
||||
*
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../bootstrap.php';
|
||||
|
||||
echo "=== PGM Cost Ledger Tests ===\n";
|
||||
|
||||
// ── Test: can_afford when below cap ─────────────────────────────────────
|
||||
|
||||
pgm_test_reset();
|
||||
$GLOBALS['pgm_test_state']['options']['pgm_monthly_budget_usd'] = 5.0;
|
||||
$GLOBALS['pgm_test_state']['wpdb_var'] = '2.50'; // MTD spent
|
||||
|
||||
$ledger = new PGM_Cost_Ledger();
|
||||
pgm_assert( $ledger->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() );
|
||||
64
tests/unit/test-cron.php
Normal file
64
tests/unit/test-cron.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PGM_Cron: scheduling, clearing, and registration.
|
||||
*
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../bootstrap.php';
|
||||
|
||||
echo "=== PGM Cron Tests ===\n";
|
||||
|
||||
// ── Test: schedule_weekly adds the event ────────────────────────────────
|
||||
|
||||
pgm_test_reset();
|
||||
pgm_assert( ! PGM_Cron::is_scheduled(), 'schedule_weekly: not scheduled before call' );
|
||||
|
||||
PGM_Cron::schedule_weekly();
|
||||
pgm_assert( PGM_Cron::is_scheduled(), 'schedule_weekly: event scheduled after call' );
|
||||
|
||||
// ── Test: schedule_weekly is idempotent ──────────────────────────────────
|
||||
|
||||
PGM_Cron::schedule_weekly(); // Second call should no-op.
|
||||
$events = $GLOBALS['pgm_test_state']['cron_events'];
|
||||
pgm_assert_equals( 1, count( $events ), 'schedule_weekly: idempotent — second call does not add duplicate' );
|
||||
|
||||
// ── Test: clear_schedule removes the event ──────────────────────────────
|
||||
|
||||
PGM_Cron::clear_schedule();
|
||||
pgm_assert( ! PGM_Cron::is_scheduled(), 'clear_schedule: event removed after clear' );
|
||||
|
||||
// ── Test: clear_schedule is safe when nothing scheduled ─────────────────
|
||||
|
||||
pgm_test_reset();
|
||||
PGM_Cron::clear_schedule(); // Should not throw.
|
||||
pgm_assert( ! PGM_Cron::is_scheduled(), 'clear_schedule: safe when nothing scheduled' );
|
||||
|
||||
// ── Test: register_hooks registers the cron hook action ─────────────────
|
||||
|
||||
pgm_test_reset();
|
||||
$cron = new PGM_Cron();
|
||||
$cron->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() );
|
||||
180
tests/unit/test-probe-runner.php
Normal file
180
tests/unit/test-probe-runner.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PGM_Probe_Runner: budget-cap abort, in-run caching, and run mechanics.
|
||||
*
|
||||
* Uses queued mock HTTP responses (via $GLOBALS['pgm_test_state']['remote_posts'])
|
||||
* and a mock ledger to verify runner behaviour without real API calls.
|
||||
*
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../bootstrap.php';
|
||||
|
||||
echo "=== PGM Probe Runner Tests ===\n";
|
||||
|
||||
// ── Define API key constants (required by providers) ──────────────────────
|
||||
|
||||
if ( ! defined( 'PGM_OPENROUTER_API_KEY' ) ) {
|
||||
define( 'PGM_OPENROUTER_API_KEY', 'sk-or-test-key' );
|
||||
}
|
||||
if ( ! defined( 'PGM_CF_ACCOUNT_ID' ) ) {
|
||||
define( 'PGM_CF_ACCOUNT_ID', '' );
|
||||
}
|
||||
if ( ! defined( 'PGM_CF_GATEWAY_ID' ) ) {
|
||||
define( 'PGM_CF_GATEWAY_ID', '' );
|
||||
}
|
||||
|
||||
// ── Mock ledger: tracks afford calls, simulates exhaustion ────────────────
|
||||
|
||||
class PGM_Mock_Ledger extends PGM_Cost_Ledger {
|
||||
public int $afford_count = 0;
|
||||
public int $no_afford_count = 0;
|
||||
public int $exhaust_after = PHP_INT_MAX;
|
||||
|
||||
public function can_afford( float $estimated ): bool {
|
||||
$total = $this->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() );
|
||||
135
tests/unit/test-provider-parsing.php
Normal file
135
tests/unit/test-provider-parsing.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for provider response parsing.
|
||||
*
|
||||
* Exercises PGM_Perplexity_Provider::parse_response() and
|
||||
* PGM_OpenRouter_Provider::parse_response() with mock HTTP responses,
|
||||
* verifying domain extraction, citation detection, and cost calculation.
|
||||
*
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../bootstrap.php';
|
||||
|
||||
echo "=== PGM Provider Response Parsing Tests ===\n";
|
||||
|
||||
// ── PGM_Perplexity_Provider parsing ─────────────────────────────────────
|
||||
|
||||
$provider = new PGM_Perplexity_Provider( null, new PGM_Citation_Detector() );
|
||||
|
||||
// Test: cited via citations array
|
||||
pgm_test_reset();
|
||||
$data = [
|
||||
'choices' => [[ '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() );
|
||||
80
tests/unit/test-score-calc.php
Normal file
80
tests/unit/test-score-calc.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for the visibility score formula in PGM_Ai_Visibility_Collector.
|
||||
*
|
||||
* Score formula (CONTEXT.md):
|
||||
* base_score = cited_probes / total_probes
|
||||
* position_bonus = Σ(1/our_position for cited) / total_probes
|
||||
* visibility_score = round((base_score + position_bonus) / 2, 4)
|
||||
*
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../bootstrap.php';
|
||||
|
||||
echo "=== PGM Score Calculation Tests ===\n";
|
||||
|
||||
// Instantiate with a null ledger (no DB calls in these tests).
|
||||
$collector = new PGM_Ai_Visibility_Collector( null );
|
||||
|
||||
// ── Test: zero total → score 0 ──────────────────────────────────────────
|
||||
|
||||
pgm_test_reset();
|
||||
pgm_assert_equals( 0.0, $collector->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() );
|
||||
101
tests/unit/test-uninstall.php
Normal file
101
tests/unit/test-uninstall.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for uninstall purge: table drop + options deletion.
|
||||
*
|
||||
* Exercises PGM_Table_Manager::drop_table() and verifies the uninstall
|
||||
* script logic (simulated because it requires WP_UNINSTALL_PLUGIN).
|
||||
*
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../bootstrap.php';
|
||||
|
||||
echo "=== PGM Uninstall Purge Tests ===\n";
|
||||
|
||||
// ── Mock $wpdb to track DROP calls ──────────────────────────────────────
|
||||
|
||||
$GLOBALS['pgm_test_state']['drop_calls'] = [];
|
||||
|
||||
// Extend the stub to record DROP statements.
|
||||
global $wpdb;
|
||||
$wpdb = new class extends stdClass_wpdb {
|
||||
public function query( string $sql ): bool {
|
||||
if ( str_contains( strtoupper( $sql ), 'DROP TABLE' ) ) {
|
||||
$GLOBALS['pgm_test_state']['drop_calls'][] = $sql;
|
||||
}
|
||||
if ( str_contains( strtoupper( $sql ), 'DELETE FROM' ) && str_contains( $sql, "pgm\\_" ) ) {
|
||||
$GLOBALS['pgm_test_state']['options_deleted'] = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// ── Test: drop_table issues DROP TABLE IF EXISTS ─────────────────────────
|
||||
|
||||
pgm_test_reset();
|
||||
$GLOBALS['pgm_test_state']['drop_calls'] = [];
|
||||
PGM_Table_Manager::drop_table();
|
||||
|
||||
pgm_assert( ! empty( $GLOBALS['pgm_test_state']['drop_calls'] ), 'drop_table: DROP TABLE query executed' );
|
||||
$drop_sql = $GLOBALS['pgm_test_state']['drop_calls'][0] ?? '';
|
||||
pgm_assert( str_contains( strtoupper( $drop_sql ), 'DROP TABLE IF EXISTS' ), 'drop_table: uses DROP TABLE IF EXISTS' );
|
||||
pgm_assert( str_contains( $drop_sql, 'pgm_ai_visibility' ), 'drop_table: targets pgm_ai_visibility table' );
|
||||
|
||||
// ── Test: create_table sets the schema_version option ────────────────────
|
||||
|
||||
pgm_test_reset();
|
||||
PGM_Table_Manager::create_table();
|
||||
$schema_ver = get_option( 'pgm_schema_version' );
|
||||
pgm_assert_equals( PGM_SCHEMA_VERSION, $schema_ver, 'create_table: pgm_schema_version option set after create' );
|
||||
|
||||
// ── Test: get_table_name returns prefixed name ───────────────────────────
|
||||
|
||||
pgm_test_reset();
|
||||
$table = PGM_Table_Manager::get_table_name();
|
||||
pgm_assert_equals( 'wp_pgm_ai_visibility', $table, 'get_table_name: returns wp_ prefixed table name' );
|
||||
|
||||
// ── Test: seed_defaults writes expected option keys ──────────────────────
|
||||
|
||||
pgm_test_reset();
|
||||
PGM_Config::seed_defaults();
|
||||
|
||||
pgm_assert( isset( $GLOBALS['pgm_test_state']['options']['pgm_monthly_budget_usd'] ), 'seed_defaults: pgm_monthly_budget_usd set' );
|
||||
pgm_assert( isset( $GLOBALS['pgm_test_state']['options']['pgm_peptides'] ), 'seed_defaults: pgm_peptides set' );
|
||||
pgm_assert( isset( $GLOBALS['pgm_test_state']['options']['pgm_prompt_intents'] ), 'seed_defaults: pgm_prompt_intents set' );
|
||||
pgm_assert( isset( $GLOBALS['pgm_test_state']['options']['pgm_models'] ), 'seed_defaults: pgm_models set' );
|
||||
|
||||
// ── Test: seed_defaults does not overwrite existing values ───────────────
|
||||
|
||||
pgm_test_reset();
|
||||
$GLOBALS['pgm_test_state']['options']['pgm_monthly_budget_usd'] = 99.0;
|
||||
PGM_Config::seed_defaults();
|
||||
pgm_assert_equals( 99.0, $GLOBALS['pgm_test_state']['options']['pgm_monthly_budget_usd'], 'seed_defaults: does not overwrite existing pgm_monthly_budget_usd' );
|
||||
|
||||
// ── Test: default budget is PGM_DEFAULT_MONTHLY_BUDGET_USD ──────────────
|
||||
|
||||
pgm_test_reset();
|
||||
PGM_Config::seed_defaults();
|
||||
pgm_assert_equals( PGM_DEFAULT_MONTHLY_BUDGET_USD, $GLOBALS['pgm_test_state']['options']['pgm_monthly_budget_usd'], 'seed_defaults: default budget = PGM_DEFAULT_MONTHLY_BUDGET_USD (5.0)' );
|
||||
|
||||
// ── Test: default peptide count is 12 ───────────────────────────────────
|
||||
|
||||
pgm_test_reset();
|
||||
$peptides = PGM_Config::get_peptides();
|
||||
pgm_assert_equals( 12, count( $peptides ), 'get_peptides: default seed has 12 peptides' );
|
||||
|
||||
// ── Test: default prompt intent count is 3 ──────────────────────────────
|
||||
|
||||
pgm_test_reset();
|
||||
$intents = PGM_Config::get_prompt_intents();
|
||||
pgm_assert_equals( 3, count( $intents ), 'get_prompt_intents: default seed has 3 intents' );
|
||||
|
||||
// ── Test: default model count is 3 ──────────────────────────────────────
|
||||
|
||||
pgm_test_reset();
|
||||
$models = PGM_Config::get_models();
|
||||
pgm_assert_equals( 3, count( $models ), 'get_models: default seed has 3 models' );
|
||||
pgm_assert( in_array( 'perplexity/sonar', $models, true ), 'get_models: perplexity/sonar in default models' );
|
||||
|
||||
exit( pgm_test_summary() );
|
||||
34
uninstall.php
Normal file
34
uninstall.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
/**
|
||||
* Peptide GEO Monitor — full data teardown on uninstall.
|
||||
*
|
||||
* Drops the custom pgm_ai_visibility table and deletes every wp_options
|
||||
* row whose name starts with "pgm_". WP-Cron events are already cleared
|
||||
* by PGM_Deactivator on deactivation; this runs after that.
|
||||
*
|
||||
* @see ARCHITECTURE.md — §Uninstall specification.
|
||||
* @see class-pgm-deactivator.php — Clears cron on deactivation.
|
||||
*/
|
||||
|
||||
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
|
||||
/* ── 1. Drop the custom table ─────────────────────────────────────── */
|
||||
|
||||
$table = $wpdb->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' );
|
||||
Reference in New Issue
Block a user