feat: add LLM cost tracking with budget enforcement and admin dashboard

- New Cost_Tracker class: logs every API call (model, tokens, cost), enforces
  monthly budgets with hard-stop mode, provides aggregated reporting
- New llm_costs database table with indexes on created_at, model, operation
- Modified LLM class: call_openrouter_with_usage() extracts token usage from
  OpenRouter responses; budget gate checks before each API call
- Admin settings: monthly budget (USD), enforcement mode (disabled/warn/hard-stop),
  cost log retention period
- Admin cost dashboard: budget progress bar, daily cost chart (Chart.js),
  per-model and per-operation breakdowns, recent API call log
- PHPUnit tests: 16 tests covering cost calculation, pricing lookups,
  budget enforcement logic, and model validation
- Updated ARCHITECTURE.md with new table schema, data flow, and key decisions
- Updated CONVENTIONS.md with "how to add cost tracking for a new operation"
- Version bump to 2.4.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Terence
2026-04-13 04:18:30 +07:00
parent 6796121003
commit b7da4f42b9
47 changed files with 2192 additions and 164 deletions

0
.github/workflows/ci.yml vendored Normal file → Executable file
View File

0
.github/workflows/deploy.yml vendored Normal file → Executable file
View File

0
.github/workflows/rollback.yml vendored Normal file → Executable file
View File

5
.gitignore vendored Normal file → Executable file
View File

@@ -16,6 +16,11 @@ wp-content/uploads/
node_modules/ node_modules/
vendor/ vendor/
# Environment & credentials
.env
.env.local
.env.credentials
# Logs # Logs
*.log *.log
debug.log debug.log

0
.phpcs.xml.dist Normal file → Executable file
View File

156
ARCHITECTURE.md Executable file
View File

@@ -0,0 +1,156 @@
# Peptide News Aggregator - Architecture
**Version:** 2.4.0
**Last updated:** April 2026
## What it does
Peptide News Aggregator is a WordPress plugin that fetches peptide research news from RSS feeds and NewsAPI, optionally enriches articles with LLM-generated keywords and summaries via OpenRouter, filters out promotional/press-release content, and presents the feed through a React-powered shortcode with click analytics and trend reporting.
## File tree
```
peptide-news-plugin/
├── peptide-news-plugin.php # Plugin bootstrap: constants, activation hooks, DB upgrade check
├── uninstall.php # Clean uninstall: drops all 4 custom tables, deletes all options
├── composer.json # Dev dependencies (PHPUnit, WP stubs)
├── phpunit.xml.dist # PHPUnit config with coverage for includes/, admin/, public/
├── package.json # JS lint config (ESLint)
├── .phpcs.xml.dist # PHPCS config (WordPress-Core ruleset)
├── ARCHITECTURE.md # This file
├── CONVENTIONS.md # Coding patterns and extension guides
├── README.md # Plugin overview and setup instructions
│
├── .github/workflows/
│ ├── ci.yml # CI: PHP lint x3, PHPCS, PHPUnit x3, security audit, JS lint
│ ├── deploy.yml # Deploy: gates on CI, rsync+SSH to Hostinger, cache purge
│ └── rollback.yml # Manual rollback workflow
│
├── includes/
│ ├── class-peptide-news.php # Main orchestrator: loads deps, registers all hooks
│ ├── class-peptide-news-loader.php # Hook registration utility (actions + filters)
│ ├── class-peptide-news-activator.php # Activation: creates tables, sets defaults, schedules cron
│ ├── class-peptide-news-deactivator.php # Deactivation: clears cron (preserves data)
│ ├── class-peptide-news-encryption.php # AES-256-CBC encryption for API keys at rest
│ ├── class-peptide-news-fetcher.php # RSS + NewsAPI fetching, dedup, storage, cron lock
│ ├── class-peptide-news-llm.php # OpenRouter integration (keywords + summaries)
│ ├── class-peptide-news-cost-tracker.php # LLM API cost tracking, budget enforcement, reporting
│ ├── class-peptide-news-content-filter.php # Two-tier ad/promo filter (rules + LLM)
│ ├── class-peptide-news-analytics.php # Click recording, daily aggregation, CSV export
│ ├── class-peptide-news-rest-api.php # REST API: /articles, /analytics/*, article deletion
│ └── class-peptide-news-logger.php # Structured DB logging with auto-prune
│
├── admin/
│ ├── class-peptide-news-admin.php # Settings page, analytics dashboard, admin assets
│ ├── css/admin-style.css # Admin dashboard styles
│ ├── js/admin-script.js # Analytics charts (Chart.js), AJAX handlers
│ ├── partials/
│ │ ├── dashboard.php # Analytics dashboard view
│ │ ├── articles-list.php # Articles management view
│ │ └── cost-dashboard.php # LLM cost tracking dashboard view
│ └── vendor/chartjs/ # Bundled Chart.js 4.4.0
│
├── public/
│ ├── class-peptide-news-public.php # Shortcode, widget, click tracking AJAX handler
│ ├── css/public-style.css # Frontend feed styles (.pn-* namespace)
│ ├── js/peptide-news-feed.js # React feed component
│ └── images/default-thumb.png # Fallback thumbnail
│
├── tests/
│ ├── bootstrap.php # Test bootstrap (WP function stubs)
│ └── unit/
│ ├── FetcherTest.php
│ ├── AdminTest.php
│ ├── RestApiTest.php
│ ├── ContentFilterTest.php
│ ├── CostTrackerTest.php
│ ├── LlmUsageTest.php
│ └── PluginBootstrapTest.php
│
└── languages/
└── peptide-news.pot # i18n template
```
## Data flow
```
User configures settings (admin/class-peptide-news-admin.php)
│
▼
WP-Cron fires `peptide_news_cron_fetch`
│
▼
Fetcher::fetch_all_sources() ─── acquires transient lock (5 min) ───┐
│ │
├── fetch_rss_feeds() → SimplePie via fetch_feed() │
├── fetch_newsapi() → NewsAPI REST (decrypted key) │
│ │
▼ │
Content_Filter::filter_articles() │
├── Rule-based: domain blocklist, title keywords, body keywords │
└── LLM tier: borderline articles → OpenRouter classification │
│ │
▼ │
Fetcher::store_article() → SHA-256 dedup → INSERT into articles table│
│ │
▼ │
LLM::process_unanalyzed() (if enabled) │
├── Budget check: Cost_Tracker::is_budget_exceeded() │
├── Keywords: OpenRouter (Gemini Flash default) │
├── Summary: OpenRouter (Gemini Flash default) │
└── Cost logging: Cost_Tracker::log_api_call() after each call │
│ │
▼ │
Results saved to articles table (tags, ai_summary columns) │
Cost data saved to llm_costs table (tokens, cost, model) │
│ lock released
▼
REST API serves /articles → React feed component renders on frontend
│
▼
User clicks article → AJAX click tracking → Analytics::record_click()
│
▼
Daily aggregation → Admin dashboard (Chart.js) + CSV export
```
## Custom database tables
| Table | Schema | Written by | Purpose |
|-------|--------|-----------|---------|
| `wp_peptide_news_articles` | id, source, source_url, title, excerpt, content, author, thumbnail_url, thumbnail_local, published_at, fetched_at, categories, tags, language, sentiment_score, ai_summary, hash (UNIQUE), is_active | Fetcher, LLM | Stores fetched articles with SHA-256 dedup hash |
| `wp_peptide_news_clicks` | id, article_id (FK), clicked_at, user_ip, user_agent, referrer_url, page_url, session_id, user_id, country, device_type | Analytics | Granular click events per article |
| `wp_peptide_news_daily_stats` | id, article_id (FK), stat_date, click_count, unique_visitors | Analytics | Aggregated daily metrics for fast trend queries |
| `wp_peptide_news_llm_costs` | id, request_id, model, provider, operation, prompt_tokens, completion_tokens, total_tokens, cost_usd, article_id, created_at | Cost_Tracker | Per-API-call cost log with token counts and USD cost |
| `wp_peptide_news_log` | id, level, context, message, created_at | Logger | Structured application log (auto-pruned at 2000 rows) |
Schema version tracked via `peptide_news_db_version` option. Migrations run via `dbDelta()` on activation and on `plugins_loaded` when version mismatch detected.
## External API integrations
| Service | Purpose | Integration code | Auth |
|---------|---------|-----------------|------|
| NewsAPI.org | Fetch news articles by keyword | `Fetcher::fetch_newsapi()` | API key (encrypted in wp_options) |
| OpenRouter | LLM inference (keywords, summaries, content classification) | `LLM::call_openrouter()` | API key (encrypted in wp_options) |
Both keys are encrypted at rest using AES-256-CBC via `Peptide_News_Encryption` (since v2.3.0). Legacy plaintext keys are decrypted transparently during the migration period.
## Key architectural decisions
**Transient-based fetch lock instead of database mutex.** The fetcher uses a 5-minute transient lock to prevent overlapping cron runs. This is simpler than a DB-level `INSERT IGNORE` mutex and sufficient for the plugin's write volume. The lock is released on completion or expires after 5 minutes as a safety net.
**Two-tier content filter.** Rule-based keyword/domain matching handles obvious spam cheaply. Only borderline cases (1 body keyword match, below threshold) are sent to the LLM for classification. This keeps API costs near zero for most filter operations.
**React frontend on WP-bundled React.** The feed component uses WordPress's bundled React to avoid shipping a duplicate. This ties us to WP's React version but avoids bloating the plugin.
**CSV export capped at 50,000 rows.** The `export_clicks_csv()` method applies a SQL LIMIT to prevent memory exhaustion on high-traffic sites. The cap is defined as a class constant (`CSV_EXPORT_MAX_ROWS`) for easy adjustment.
**API keys encrypted at rest.** All API keys stored in wp_options are encrypted using AES-256-CBC with `wp_salt('auth')` as key material. The encryption class handles legacy plaintext values transparently, so existing installations upgrade without manual re-entry.
**Cascading foreign keys.** The clicks and daily_stats tables have ON DELETE CASCADE foreign keys referencing the articles table, so deleting an article automatically cleans up its analytics data.
**LLM cost tracking in a dedicated table.** API call costs are logged to `wp_peptide_news_llm_costs` rather than post_meta because cost data is high-volume write data (2 rows per article processed) that needs date-range aggregation queries. The table is indexed on `created_at` and `model` for fast dashboard reporting.
**Budget enforcement with hard-stop mode.** The cost tracker checks `is_budget_exceeded()` before every LLM API call. In `hard_stop` mode, calls are blocked when monthly spend meets the configured limit — this prevents silent overspending. Budget alerts fire at 50%, 80%, and 100% thresholds (once per month per threshold). The monthly spend is cached in a 5-minute transient to avoid excessive DB queries during batch processing.
**OpenRouter usage data extraction.** Token counts and costs are extracted from the OpenRouter API response body (`data.usage` field) rather than response headers, because the response body is more reliable and includes model-specific token counts. If the API reports an explicit cost, that takes precedence over our calculated estimate.

143
CONVENTIONS.md Executable file
View File

@@ -0,0 +1,143 @@
# Peptide News Aggregator - Conventions
Project-specific patterns and step-by-step extension guides.
## Naming conventions
**Classes:** `Peptide_News_{Name}` (WordPress standard, one class per file).
**Files:** `class-peptide-news-{name}.php` in `includes/`, `admin/`, or `public/`.
**Options:** All prefixed `peptide_news_` (e.g., `peptide_news_fetch_interval`).
**Transients:** Prefixed `peptide_news_` (e.g., `peptide_news_fetch_lock`).
**Database tables:** `{$wpdb->prefix}peptide_news_{name}` (e.g., `wp_peptide_news_articles`).
**Hooks:** Custom actions/filters prefixed `peptide_news_` (e.g., `peptide_news_cron_fetch`).
**CSS classes:** `.pn-*` namespace to avoid collisions with theme and other plugins.
**Constants:** `PEPTIDE_NEWS_*` (e.g., `PEPTIDE_NEWS_VERSION`, `PEPTIDE_NEWS_PLUGIN_DIR`).
## Hook registration pattern
All hooks are registered centrally in `class-peptide-news.php` via the Loader class. Never call `add_action()` or `add_filter()` directly in a module class. Instead, register in the appropriate `define_*_hooks()` method:
```php
// In class-peptide-news.php
private function define_cron_hooks() {
$fetcher = new Peptide_News_Fetcher();
$this->loader->add_action( 'peptide_news_cron_fetch', $fetcher, 'fetch_all_sources' );
}
```
## Error handling patterns
**External API calls:** Always check `is_wp_error()` on `wp_remote_post()`/`wp_remote_get()` responses. Return `WP_Error` from methods that make API calls. Log errors via `Peptide_News_Logger`.
**AJAX handlers:** Always verify nonce with `check_ajax_referer( 'peptide_news_admin', 'nonce' )` and check `current_user_can( 'manage_options' )` before any action. Return via `wp_send_json_success()` / `wp_send_json_error()`.
**Fail-open for optional features:** If encryption is unavailable (no OpenSSL), store keys as plaintext. If LLM classification fails, let the article through (`'editorial'`). Never block core functionality due to optional feature failures.
**Logging:** Use `Peptide_News_Logger` with appropriate levels and context tags. Available contexts: `fetch`, `llm`, `admin`, `cron`, `general`. The logger auto-prunes at 2000 rows.
## How to add a new news source
1. Add a new private method to `class-peptide-news-fetcher.php`:
```php
private function fetch_new_source() {
// Return array of article arrays matching this structure:
// [ 'source' => 'SourceName', 'source_url' => '...', 'title' => '...',
// 'excerpt' => '...', 'content' => '...', 'author' => '...',
// 'thumbnail_url' => '', 'published_at' => 'Y-m-d H:i:s', 'categories' => '' ]
}
```
2. Call the method from `fetch_all_sources()`, gated by an option:
```php
if ( get_option( 'peptide_news_new_source_enabled', 0 ) ) {
$articles = array_merge( $articles, $this->fetch_new_source() );
}
```
3. Add the setting to `class-peptide-news-admin.php` in `get_sanitize_callback()`.
4. Add a field renderer method and register it in `register_settings()`.
5. Update `ARCHITECTURE.md` with the new source in the data flow diagram.
## How to add a new admin setting
1. In `class-peptide-news-admin.php`:
- Add the sanitize callback in `get_sanitize_callback()`.
- Add a `render_{setting_name}_field()` method.
- Register the field in `register_settings()` using `$this->register_field()`.
2. In `class-peptide-news-activator.php`:
- Add a default value in the `$defaults` array in `set_default_options()`.
3. The option is automatically registered with the Settings API and will appear in the admin UI.
## How to add a new LLM provider
The plugin currently talks to OpenRouter, which proxies to multiple LLM providers. To add a direct provider:
1. Create `includes/class-peptide-news-{provider}.php` implementing the same interface as `LLM::call_openrouter()`: takes `(string $api_key, string $model, string $prompt)` and returns `string|WP_Error`.
2. Add a provider selection setting to the admin page.
3. Update `LLM::process_article()` to dispatch to the selected provider.
4. Encrypt the new provider's API key using `Peptide_News_Encryption::encrypt()` on save.
## How to add a new REST API endpoint
1. In `class-peptide-news-rest-api.php`:
- Add the route in `register_routes()`.
- Add the callback method.
- Use `check_admin_permissions` for admin-only endpoints.
- Use `'permission_callback' => '__return_true'` for public endpoints.
2. For analytics endpoints, use the shared `get_date_range_args()` for consistent parameter handling.
## How to add cost tracking for a new LLM operation
1. In the method that calls the LLM API, use `call_openrouter_with_usage()` instead of `call_openrouter()`:
```php
$response = self::call_openrouter_with_usage( $api_key, $model, $prompt );
```
2. After the API call, log the cost via `Peptide_News_Cost_Tracker::log_api_call()`:
```php
if ( class_exists( 'Peptide_News_Cost_Tracker' ) && ! empty( $response['usage'] ) ) {
Peptide_News_Cost_Tracker::log_api_call(
$model,
'your_operation_name', // e.g., 'keywords', 'summary', 'filter'
$response['usage'],
$article_id,
$response['request_id'] ?? '',
$response['cost'] ?? 0.0
);
}
```
3. Before the API call, check the budget gate:
```php
if ( class_exists( 'Peptide_News_Cost_Tracker' ) && Peptide_News_Cost_Tracker::is_budget_exceeded() ) {
// Skip the call, log a warning, return early
}
```
4. If the model isn't in `DEFAULT_MODEL_PRICING`, add it to the constant array in `class-peptide-news-cost-tracker.php`.
## Security checklist for every change
- Sanitize all input: `sanitize_text_field()`, `absint()`, `esc_url_raw()`.
- Escape all output: `esc_html()`, `esc_attr()`, `esc_url()`, `wp_kses_post()`.
- Nonce on every form/AJAX: `wp_nonce_field()` / `check_ajax_referer()`.
- Capability check: `current_user_can( 'manage_options' )` on every admin action.
- API keys: always encrypt via `Peptide_News_Encryption::encrypt()`, decrypt via `::decrypt()`.
- Never expose API keys to the browser (all API calls are server-side only).
## Database migration pattern
- Schema changes go in `Peptide_News_Activator::create_tables()` using `dbDelta()`.
- The `plugins_loaded` hook in the bootstrap file checks `peptide_news_db_version` against `PEPTIDE_NEWS_VERSION` and re-runs activation if outdated.
- Always use `$wpdb->prepare()` for queries with dynamic values.
- Use `// phpcs:ignore` comments for unavoidable PHPCS warnings on `$wpdb->query()` calls.
## Type safety
All PHP files use `declare(strict_types=1)`. All public and private methods have typed parameters and return types. This catches type errors at call time rather than at runtime and gives AI agents structural information for reasoning about code.

0
README.md Normal file → Executable file
View File

171
admin/class-peptide-news-admin.php Normal file → Executable file
View File

@@ -1,4 +1,5 @@
<?php <?php
declare( strict_types=1 );
/** /**
* Admin-specific functionality. * Admin-specific functionality.
* *
@@ -14,7 +15,7 @@ class Peptide_News_Admin {
/** @var string */ /** @var string */
private $version; private $version;
public function __construct( $plugin_name, $version ) { public function __construct( string $plugin_name, string $version ) {
$this->plugin_name = $plugin_name; $this->plugin_name = $plugin_name;
$this->version = $version; $this->version = $version;
} }
@@ -22,7 +23,7 @@ class Peptide_News_Admin {
/** /**
* Enqueue admin CSS. * Enqueue admin CSS.
*/ */
public function enqueue_styles( $hook ) { public function enqueue_styles( string $hook ): void {
if ( strpos( $hook, 'peptide-news' ) === false ) { if ( strpos( $hook, 'peptide-news' ) === false ) {
return; return;
} }
@@ -38,7 +39,7 @@ class Peptide_News_Admin {
/** /**
* Enqueue admin JS. * Enqueue admin JS.
*/ */
public function enqueue_scripts( $hook ) { public function enqueue_scripts( string $hook ): void {
if ( strpos( $hook, 'peptide-news' ) === false ) { if ( strpos( $hook, 'peptide-news' ) === false ) {
return; return;
} }
@@ -71,7 +72,7 @@ class Peptide_News_Admin {
/** /**
* Add admin menu pages. * Add admin menu pages.
*/ */
public function add_admin_menu() { public function add_admin_menu(): void {
// Main menu — Analytics Dashboard. // Main menu — Analytics Dashboard.
add_menu_page( add_menu_page(
__( 'Peptide News', 'peptide-news' ), __( 'Peptide News', 'peptide-news' ),
@@ -102,12 +103,22 @@ class Peptide_News_Admin {
'peptide-news-articles', 'peptide-news-articles',
array( $this, 'render_articles_page' ) array( $this, 'render_articles_page' )
); );
// Sub-menu — Cost Dashboard.
add_submenu_page(
'peptide-news-dashboard',
__( 'LLM Costs', 'peptide-news' ),
__( 'LLM Costs', 'peptide-news' ),
'manage_options',
'peptide-news-costs',
array( $this, 'render_cost_dashboard_page' )
);
} }
/** /**
* Register all settings. * Register all settings.
*/ */
public function register_settings() { public function register_settings(): void {
// --- General section --- // --- General section ---
add_settings_section( add_settings_section(
@@ -213,12 +224,43 @@ class Peptide_News_Admin {
$this->add_setting( 'llm_summary_model', __( 'Summary Model', 'peptide-news' ), 'render_llm_summary_model_field', 'peptide_news_llm_section' ); $this->add_setting( 'llm_summary_model', __( 'Summary Model', 'peptide-news' ), 'render_llm_summary_model_field', 'peptide_news_llm_section' );
$this->add_setting( 'llm_max_articles', __( 'Max Articles per Cycle', 'peptide-news' ), 'render_llm_max_articles_field', 'peptide_news_llm_section' ); $this->add_setting( 'llm_max_articles', __( 'Max Articles per Cycle', 'peptide-news' ), 'render_llm_max_articles_field', 'peptide_news_llm_section' );
// --- Cost Tracking / Budget section ---
add_settings_section(
'peptide_news_cost_section',
__( 'Cost Tracking & Budget', 'peptide-news' ),
function () {
echo '<p>' . esc_html__( 'Monitor and control LLM API spending. Set a monthly budget to prevent unexpected charges.', 'peptide-news' ) . '</p>';
if ( class_exists( 'Peptide_News_Cost_Tracker' ) ) {
$month_spend = Peptide_News_Cost_Tracker::get_current_month_spend();
$budget = (float) get_option( 'peptide_news_monthly_budget', 0.0 );
printf(
'<p class="description"><strong>%s:</strong> $%.4f',
esc_html__( 'This month\'s spend', 'peptide-news' ),
$month_spend
);
if ( $budget > 0 ) {
printf(
' / $%.2f (%.1f%%)',
$budget,
( $month_spend / $budget ) * 100
);
}
echo '</p>';
}
},
'peptide-news-settings'
);
$this->add_setting( 'monthly_budget', __( 'Monthly Budget (USD)', 'peptide-news' ), 'render_monthly_budget_field', 'peptide_news_cost_section' );
$this->add_setting( 'budget_mode', __( 'Budget Enforcement', 'peptide-news' ), 'render_budget_mode_field', 'peptide_news_cost_section' );
$this->add_setting( 'cost_retention', __( 'Cost Log Retention (days)', 'peptide-news' ), 'render_cost_retention_field', 'peptide_news_cost_section' );
} }
/** /**
* Helper: register a single setting with a field callback. * Helper: register a single setting with a field callback.
*/ */
private function add_setting( $key, $label, $callback, $section ) { private function add_setting( string $key, string $label, string $callback, string $section ): void {
$option_name = "peptide_news_{$key}"; $option_name = "peptide_news_{$key}";
$sanitize_cb = $this->get_sanitize_callback( $key ); $sanitize_cb = $this->get_sanitize_callback( $key );
@@ -242,7 +284,7 @@ class Peptide_News_Admin {
* @param string $key Setting key (without prefix). * @param string $key Setting key (without prefix).
* @return callable * @return callable
*/ */
private function get_sanitize_callback( $key ) { private function get_sanitize_callback( string $key ) {
$callbacks = array( $callbacks = array(
'fetch_interval' => 'sanitize_text_field', 'fetch_interval' => 'sanitize_text_field',
'articles_count' => 'absint', 'articles_count' => 'absint',
@@ -270,6 +312,16 @@ class Peptide_News_Admin {
'llm_keywords_model' => array( $this, 'sanitize_model_id' ), 'llm_keywords_model' => array( $this, 'sanitize_model_id' ),
'llm_summary_model' => array( $this, 'sanitize_model_id' ), 'llm_summary_model' => array( $this, 'sanitize_model_id' ),
'llm_max_articles' => 'absint', 'llm_max_articles' => 'absint',
'monthly_budget' => function ( $value ) {
return max( 0.0, (float) $value );
},
'budget_mode' => function ( $value ) {
$valid = array( 'disabled', 'hard_stop', 'warn_only' );
return in_array( $value, $valid, true ) ? $value : 'disabled';
},
'cost_retention' => function ( $value ) {
return max( 30, absint( $value ) );
},
); );
return isset( $callbacks[ $key ] ) ? $callbacks[ $key ] : 'sanitize_text_field'; return isset( $callbacks[ $key ] ) ? $callbacks[ $key ] : 'sanitize_text_field';
@@ -281,7 +333,7 @@ class Peptide_News_Admin {
* @param string $value Raw input. * @param string $value Raw input.
* @return string Sanitized model ID or default. * @return string Sanitized model ID or default.
*/ */
public function sanitize_model_id( $value ) { public function sanitize_model_id( string $value ): string {
$value = sanitize_text_field( $value ); $value = sanitize_text_field( $value );
if ( ! empty( $value ) && class_exists( 'Peptide_News_LLM' ) && ! Peptide_News_LLM::is_valid_model( $value ) ) { if ( ! empty( $value ) && class_exists( 'Peptide_News_LLM' ) && ! Peptide_News_LLM::is_valid_model( $value ) ) {
add_settings_error( add_settings_error(
@@ -300,13 +352,17 @@ class Peptide_News_Admin {
} }
/** /**
* Sanitize API keys. If a masked key is submitted (••••••••XXXX format), * Sanitize and encrypt API keys before storage.
* keep the existing key. Otherwise, store the new full key.
* *
* @param string $value Raw input. * If a masked key is submitted (••••••••XXXX format), the existing
* @return string Stored API key (full value or existing). * encrypted value is preserved. New plaintext keys are encrypted
* via Peptide_News_Encryption before being written to wp_options.
*
* @param string $value Raw input from the settings form.
* @param string $option_name The wp_options key for this API key.
* @return string Encrypted API key or existing stored value.
*/ */
public function sanitize_api_key( $value, $option_name ) { public function sanitize_api_key( string $value, string $option_name ): string {
$value = sanitize_text_field( $value ); $value = sanitize_text_field( $value );
if ( empty( $value ) ) { if ( empty( $value ) ) {
return ''; return '';
@@ -317,19 +373,33 @@ class Peptide_News_Admin {
return get_option( $option_name, '' ); return get_option( $option_name, '' );
} }
// Encrypt the new plaintext key before storing.
if ( class_exists( 'Peptide_News_Encryption' ) ) {
return Peptide_News_Encryption::encrypt( $value );
}
return $value; return $value;
} }
/** /**
* Get a masked version of an API key showing only the last 4 characters. * Get a masked version of an API key showing only the last 4 characters.
* *
* @param string $key Full API key. * Decrypts the stored value first (handles both encrypted and legacy
* plaintext keys transparently).
*
* @param string $key Stored API key (encrypted or plaintext).
* @return string Masked key (e.g., ••••••••XXXX). * @return string Masked key (e.g., ••••••••XXXX).
*/ */
private function mask_api_key( $key ) { private function mask_api_key( string $key ): string {
if ( empty( $key ) ) { if ( empty( $key ) ) {
return ''; return '';
} }
// Decrypt before masking so we show the real key's last 4 chars.
if ( class_exists( 'Peptide_News_Encryption' ) ) {
$key = Peptide_News_Encryption::decrypt( $key );
}
$len = strlen( $key ); $len = strlen( $key );
if ( $len <= 4 ) { if ( $len <= 4 ) {
return str_repeat( '•', $len ); return str_repeat( '•', $len );
@@ -591,9 +661,57 @@ class Peptide_News_Admin {
echo '<p class="description">' . esc_html__( 'Maximum number of articles to analyze with AI per fetch cycle. Controls API costs and prevents cron timeouts.', 'peptide-news' ) . '</p>'; echo '<p class="description">' . esc_html__( 'Maximum number of articles to analyze with AI per fetch cycle. Controls API costs and prevents cron timeouts.', 'peptide-news' ) . '</p>';
} }
// --- Cost tracking field renderers ---
/**
* Render the monthly budget input field.
*/
public function render_monthly_budget_field(): void {
$value = (float) get_option( 'peptide_news_monthly_budget', 0.0 );
printf(
'<input type="number" name="peptide_news_monthly_budget" value="%.2f" min="0" step="0.01" class="small-text" style="width:100px;" />',
$value
);
echo '<p class="description">' . esc_html__( 'Set to 0 to disable budget limits. The plugin will stop making API calls when this amount is reached (if enforcement is enabled).', 'peptide-news' ) . '</p>';
}
/**
* Render the budget enforcement mode selector.
*/
public function render_budget_mode_field(): void {
$value = get_option( 'peptide_news_budget_mode', 'disabled' );
$modes = array(
'disabled' => __( 'Disabled — track costs only, no limits', 'peptide-news' ),
'warn_only' => __( 'Warn only — log alerts at 50%, 80%, 100%', 'peptide-news' ),
'hard_stop' => __( 'Hard stop — block API calls when budget is reached', 'peptide-news' ),
);
echo '<select name="peptide_news_budget_mode">';
foreach ( $modes as $mode => $label ) {
printf(
'<option value="%s" %s>%s</option>',
esc_attr( $mode ),
selected( $value, $mode, false ),
esc_html( $label )
);
}
echo '</select>';
}
/**
* Render the cost log retention field.
*/
public function render_cost_retention_field(): void {
$value = absint( get_option( 'peptide_news_cost_retention', 365 ) );
printf(
'<input type="number" name="peptide_news_cost_retention" value="%d" min="30" max="3650" class="small-text" />',
$value
);
echo '<p class="description">' . esc_html__( 'How long to keep detailed cost records. Minimum 30 days.', 'peptide-news' ) . '</p>';
}
// --- Page renderers --- // --- Page renderers ---
public function render_settings_page() { public function render_settings_page(): void {
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
return; return;
} }
@@ -895,7 +1013,7 @@ class Peptide_News_Admin {
echo '</div>'; echo '</div>';
} }
public function render_dashboard_page() { public function render_dashboard_page(): void {
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
return; return;
} }
@@ -913,7 +1031,7 @@ class Peptide_News_Admin {
/** /**
* Stream a CSV export of click analytics data. * Stream a CSV export of click analytics data.
*/ */
private function export_csv() { private function export_csv(): void {
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Unauthorized.', 'peptide-news' ) ); wp_die( esc_html__( 'Unauthorized.', 'peptide-news' ) );
} }
@@ -947,10 +1065,25 @@ class Peptide_News_Admin {
exit; exit;
} }
public function render_articles_page() { public function render_articles_page(): void {
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
return; return;
} }
include PEPTIDE_NEWS_PLUGIN_DIR . 'admin/partials/articles-list.php'; include PEPTIDE_NEWS_PLUGIN_DIR . 'admin/partials/articles-list.php';
} }
/**
* Render the LLM cost tracking dashboard page.
*
* Shows monthly budget status, daily cost chart, per-model breakdown,
* and recent API call log. Data is loaded via AJAX for responsiveness.
*
* @since 2.4.0
*/
public function render_cost_dashboard_page(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
include PEPTIDE_NEWS_PLUGIN_DIR . 'admin/partials/cost-dashboard.php';
}
} }

0
admin/css/admin-style.css Normal file → Executable file
View File

0
admin/js/admin-script.js Normal file → Executable file
View File

1
admin/partials/articles-list.php Normal file → Executable file
View File

@@ -1,4 +1,5 @@
<?php <?php
declare( strict_types=1 );
/** /**
* Articles management partial. * Articles management partial.
* *

325
admin/partials/cost-dashboard.php Executable file
View File

@@ -0,0 +1,325 @@
<?php
/**
* Cost Dashboard admin partial.
*
* Displays LLM API cost tracking data: budget status, daily cost chart,
* per-model and per-operation breakdowns, and a recent API call log.
*
* Data is loaded via AJAX (peptide_news_get_cost_data action) and rendered
* client-side using Chart.js for the daily cost trend chart.
*
* @since 2.4.0
* @see class-peptide-news-cost-tracker.php — Data source for all cost queries
* @see class-peptide-news-admin.php — Registers this page via add_submenu_page()
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
?>
<div class="wrap">
<h1><?php esc_html_e( 'LLM Cost Dashboard', 'peptide-news' ); ?></h1>
<!-- Period selector -->
<div style="margin: 15px 0;">
<label for="pn-cost-period"><strong><?php esc_html_e( 'Period:', 'peptide-news' ); ?></strong></label>
<select id="pn-cost-period" style="margin-left: 5px;">
<option value="day"><?php esc_html_e( 'Today', 'peptide-news' ); ?></option>
<option value="week"><?php esc_html_e( 'Last 7 Days', 'peptide-news' ); ?></option>
<option value="month" selected><?php esc_html_e( 'This Month', 'peptide-news' ); ?></option>
</select>
<button type="button" id="pn-cost-refresh" class="button button-secondary" style="margin-left: 8px;">
<?php esc_html_e( 'Refresh', 'peptide-news' ); ?>
</button>
</div>
<!-- Budget Status Card -->
<div id="pn-budget-status" class="card" style="max-width: 600px; padding: 15px; margin-bottom: 20px;">
<h2 style="margin-top: 0;"><?php esc_html_e( 'Monthly Budget', 'peptide-news' ); ?></h2>
<div id="pn-budget-content">
<p class="description"><?php esc_html_e( 'Loading...', 'peptide-news' ); ?></p>
</div>
</div>
<!-- Summary Cards Row -->
<div style="display: flex; gap: 15px; flex-wrap: wrap; margin-bottom: 20px;">
<div class="card" style="flex: 1; min-width: 150px; padding: 15px;">
<h3 style="margin: 0 0 5px;"><?php esc_html_e( 'Total Cost', 'peptide-news' ); ?></h3>
<p id="pn-cost-total" style="font-size: 24px; font-weight: bold; margin: 0;">$0.0000</p>
</div>
<div class="card" style="flex: 1; min-width: 150px; padding: 15px;">
<h3 style="margin: 0 0 5px;"><?php esc_html_e( 'API Requests', 'peptide-news' ); ?></h3>
<p id="pn-cost-requests" style="font-size: 24px; font-weight: bold; margin: 0;">0</p>
</div>
<div class="card" style="flex: 1; min-width: 150px; padding: 15px;">
<h3 style="margin: 0 0 5px;"><?php esc_html_e( 'Total Tokens', 'peptide-news' ); ?></h3>
<p id="pn-cost-tokens" style="font-size: 24px; font-weight: bold; margin: 0;">0</p>
</div>
</div>
<!-- Daily Cost Chart -->
<div class="card" style="max-width: 800px; padding: 15px; margin-bottom: 20px;">
<h2 style="margin-top: 0;"><?php esc_html_e( 'Daily Cost Trend', 'peptide-news' ); ?></h2>
<canvas id="pn-cost-chart" height="250"></canvas>
</div>
<!-- Breakdowns Row -->
<div style="display: flex; gap: 15px; flex-wrap: wrap; margin-bottom: 20px;">
<!-- Per-Model Breakdown -->
<div class="card" style="flex: 1; min-width: 350px; padding: 15px;">
<h2 style="margin-top: 0;"><?php esc_html_e( 'Cost by Model', 'peptide-news' ); ?></h2>
<table class="widefat striped" id="pn-cost-by-model">
<thead>
<tr>
<th><?php esc_html_e( 'Model', 'peptide-news' ); ?></th>
<th><?php esc_html_e( 'Requests', 'peptide-news' ); ?></th>
<th><?php esc_html_e( 'Tokens', 'peptide-news' ); ?></th>
<th><?php esc_html_e( 'Cost', 'peptide-news' ); ?></th>
</tr>
</thead>
<tbody>
<tr><td colspan="4"><?php esc_html_e( 'Loading...', 'peptide-news' ); ?></td></tr>
</tbody>
</table>
</div>
<!-- Per-Operation Breakdown -->
<div class="card" style="flex: 1; min-width: 300px; padding: 15px;">
<h2 style="margin-top: 0;"><?php esc_html_e( 'Cost by Operation', 'peptide-news' ); ?></h2>
<table class="widefat striped" id="pn-cost-by-operation">
<thead>
<tr>
<th><?php esc_html_e( 'Operation', 'peptide-news' ); ?></th>
<th><?php esc_html_e( 'Requests', 'peptide-news' ); ?></th>
<th><?php esc_html_e( 'Cost', 'peptide-news' ); ?></th>
</tr>
</thead>
<tbody>
<tr><td colspan="3"><?php esc_html_e( 'Loading...', 'peptide-news' ); ?></td></tr>
</tbody>
</table>
</div>
</div>
<!-- Recent API Calls Log -->
<div class="card" style="padding: 15px; margin-bottom: 20px;">
<h2 style="margin-top: 0;"><?php esc_html_e( 'Recent API Calls', 'peptide-news' ); ?></h2>
<table class="widefat striped" id="pn-cost-recent">
<thead>
<tr>
<th><?php esc_html_e( 'Time', 'peptide-news' ); ?></th>
<th><?php esc_html_e( 'Model', 'peptide-news' ); ?></th>
<th><?php esc_html_e( 'Operation', 'peptide-news' ); ?></th>
<th><?php esc_html_e( 'Prompt', 'peptide-news' ); ?></th>
<th><?php esc_html_e( 'Completion', 'peptide-news' ); ?></th>
<th><?php esc_html_e( 'Total', 'peptide-news' ); ?></th>
<th><?php esc_html_e( 'Cost', 'peptide-news' ); ?></th>
</tr>
</thead>
<tbody>
<tr><td colspan="7"><?php esc_html_e( 'Loading...', 'peptide-news' ); ?></td></tr>
</tbody>
</table>
</div>
</div>
<script>
(function($) {
'use strict';
var costChart = null;
function loadCostData() {
var period = $('#pn-cost-period').val();
$.ajax({
url: peptideNewsAdmin.ajax_url,
method: 'GET',
data: {
action: 'peptide_news_get_cost_data',
nonce: peptideNewsAdmin.admin_nonce,
period: period
},
success: function(response) {
if (response.success) {
renderCostData(response.data);
}
}
});
}
function renderCostData(data) {
// Summary cards.
$('#pn-cost-total').text('$' + parseFloat(data.summary.total_cost).toFixed(4));
$('#pn-cost-requests').text(data.summary.total_requests.toLocaleString());
$('#pn-cost-tokens').text(data.summary.total_tokens.toLocaleString());
// Budget status.
renderBudget(data.budget);
// Daily chart.
renderChart(data.daily_costs);
// Model breakdown table.
renderModelTable(data.summary.by_model);
// Operation breakdown table.
renderOperationTable(data.summary.by_operation);
// Recent calls log.
renderRecentCalls(data.recent_calls);
}
function renderBudget(budget) {
var html = '';
if (budget.limit <= 0 || budget.mode === 'disabled') {
html = '<p>' + '<?php echo esc_js( __( 'No budget set. Configure a monthly budget in Settings to track spending limits.', 'peptide-news' ) ); ?>' + '</p>';
} else {
var pct = budget.percent_used;
var barColor = pct >= 100 ? '#dc3232' : (pct >= 80 ? '#dba617' : '#46b450');
var modeLabel = budget.mode === 'hard_stop' ? '<?php echo esc_js( __( 'Hard Stop', 'peptide-news' ) ); ?>' : '<?php echo esc_js( __( 'Warn Only', 'peptide-news' ) ); ?>';
html += '<p><strong><?php echo esc_js( __( 'Spent:', 'peptide-news' ) ); ?></strong> $' + budget.month_spend.toFixed(4) + ' / $' + budget.limit.toFixed(2) + ' (' + pct.toFixed(1) + '%)</p>';
html += '<div style="background:#e0e0e0;border-radius:4px;height:20px;max-width:400px;margin:8px 0;">';
html += '<div style="background:' + barColor + ';height:100%;border-radius:4px;width:' + Math.min(pct, 100) + '%;transition:width 0.3s;"></div>';
html += '</div>';
html += '<p class="description"><strong><?php echo esc_js( __( 'Mode:', 'peptide-news' ) ); ?></strong> ' + modeLabel + '</p>';
if (pct >= 100 && budget.mode === 'hard_stop') {
html += '<div class="notice notice-error inline" style="margin:10px 0;"><p><?php echo esc_js( __( 'Budget exceeded! LLM API calls are blocked until next month or budget is increased.', 'peptide-news' ) ); ?></p></div>';
} else if (pct >= 80) {
html += '<div class="notice notice-warning inline" style="margin:10px 0;"><p><?php echo esc_js( __( 'Approaching budget limit.', 'peptide-news' ) ); ?></p></div>';
}
}
$('#pn-budget-content').html(html);
}
function renderChart(dailyCosts) {
var ctx = document.getElementById('pn-cost-chart');
if (!ctx) return;
var labels = dailyCosts.map(function(d) { return d.date; });
var costs = dailyCosts.map(function(d) { return parseFloat(d.cost); });
var tokens = dailyCosts.map(function(d) { return parseInt(d.tokens, 10); });
if (costChart) {
costChart.destroy();
}
costChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: '<?php echo esc_js( __( 'Cost (USD)', 'peptide-news' ) ); ?>',
data: costs,
backgroundColor: 'rgba(0, 115, 170, 0.7)',
borderColor: '#0073aa',
borderWidth: 1,
yAxisID: 'y'
}, {
label: '<?php echo esc_js( __( 'Tokens', 'peptide-news' ) ); ?>',
data: tokens,
type: 'line',
borderColor: '#46b450',
backgroundColor: 'rgba(70, 180, 80, 0.1)',
fill: true,
tension: 0.3,
yAxisID: 'y1'
}]
},
options: {
responsive: true,
interaction: { mode: 'index', intersect: false },
scales: {
y: {
type: 'linear',
position: 'left',
title: { display: true, text: '<?php echo esc_js( __( 'Cost (USD)', 'peptide-news' ) ); ?>' },
ticks: {
callback: function(v) { return '$' + v.toFixed(4); }
}
},
y1: {
type: 'linear',
position: 'right',
title: { display: true, text: '<?php echo esc_js( __( 'Tokens', 'peptide-news' ) ); ?>' },
grid: { drawOnChartArea: false }
}
}
}
});
}
function renderModelTable(models) {
var tbody = $('#pn-cost-by-model tbody');
if (!models || models.length === 0) {
tbody.html('<tr><td colspan="4"><?php echo esc_js( __( 'No data for this period.', 'peptide-news' ) ); ?></td></tr>');
return;
}
var html = '';
models.forEach(function(m) {
html += '<tr>';
html += '<td><code>' + escHtml(m.model) + '</code></td>';
html += '<td>' + parseInt(m.requests, 10).toLocaleString() + '</td>';
html += '<td>' + parseInt(m.total_tokens, 10).toLocaleString() + '</td>';
html += '<td>$' + parseFloat(m.total_cost).toFixed(4) + '</td>';
html += '</tr>';
});
tbody.html(html);
}
function renderOperationTable(ops) {
var tbody = $('#pn-cost-by-operation tbody');
if (!ops || ops.length === 0) {
tbody.html('<tr><td colspan="3"><?php echo esc_js( __( 'No data for this period.', 'peptide-news' ) ); ?></td></tr>');
return;
}
var html = '';
ops.forEach(function(o) {
html += '<tr>';
html += '<td>' + escHtml(o.operation) + '</td>';
html += '<td>' + parseInt(o.requests, 10).toLocaleString() + '</td>';
html += '<td>$' + parseFloat(o.total_cost).toFixed(4) + '</td>';
html += '</tr>';
});
tbody.html(html);
}
function renderRecentCalls(calls) {
var tbody = $('#pn-cost-recent tbody');
if (!calls || calls.length === 0) {
tbody.html('<tr><td colspan="7"><?php echo esc_js( __( 'No API calls recorded yet.', 'peptide-news' ) ); ?></td></tr>');
return;
}
var html = '';
calls.forEach(function(c) {
html += '<tr>';
html += '<td>' + escHtml(c.created_at) + '</td>';
html += '<td><code style="font-size:11px;">' + escHtml(c.model) + '</code></td>';
html += '<td>' + escHtml(c.operation) + '</td>';
html += '<td>' + parseInt(c.prompt_tokens, 10).toLocaleString() + '</td>';
html += '<td>' + parseInt(c.completion_tokens, 10).toLocaleString() + '</td>';
html += '<td>' + parseInt(c.total_tokens, 10).toLocaleString() + '</td>';
html += '<td>$' + parseFloat(c.cost_usd).toFixed(6) + '</td>';
html += '</tr>';
});
tbody.html(html);
}
function escHtml(str) {
if (!str) return '';
var div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
// Event handlers.
$('#pn-cost-period').on('change', loadCostData);
$('#pn-cost-refresh').on('click', loadCostData);
// Initial load.
$(document).ready(loadCostData);
})(jQuery);
</script>

1
admin/partials/dashboard.php Normal file → Executable file
View File

@@ -1,4 +1,5 @@
<?php <?php
declare( strict_types=1 );
/** /**
* Analytics dashboard partial. * Analytics dashboard partial.
* *

0
composer.json Normal file → Executable file
View File

19
includes/class-peptide-news-activator.php Normal file → Executable file
View File

@@ -1,4 +1,5 @@
<?php <?php
declare( strict_types=1 );
/** /**
* Fired during plugin activation. * Fired during plugin activation.
* *
@@ -11,7 +12,7 @@ class Peptide_News_Activator {
/** /**
* Create custom tables and schedule cron. * Create custom tables and schedule cron.
*/ */
public static function activate() { public static function activate(): void {
self::create_tables(); self::create_tables();
self::set_default_options(); self::set_default_options();
self::schedule_cron(); self::schedule_cron();
@@ -20,12 +21,17 @@ class Peptide_News_Activator {
if ( class_exists( 'Peptide_News_Logger' ) ) { if ( class_exists( 'Peptide_News_Logger' ) ) {
Peptide_News_Logger::create_table(); Peptide_News_Logger::create_table();
} }
// Create the LLM cost tracking table (idempotent via dbDelta).
if ( class_exists( 'Peptide_News_Cost_Tracker' ) ) {
Peptide_News_Cost_Tracker::create_table();
}
} }
/** /**
* Create the articles and analytics database tables. * Create the articles and analytics database tables.
*/ */
private static function create_tables() { private static function create_tables(): void {
global $wpdb; global $wpdb;
$charset_collate = $wpdb->get_charset_collate(); $charset_collate = $wpdb->get_charset_collate();
@@ -107,7 +113,7 @@ class Peptide_News_Activator {
/** /**
* Set sensible default option values. * Set sensible default option values.
*/ */
private static function set_default_options() { private static function set_default_options(): void {
$defaults = array( $defaults = array(
'fetch_interval' => 'twicedaily', 'fetch_interval' => 'twicedaily',
'articles_count' => 10, 'articles_count' => 10,
@@ -128,6 +134,9 @@ class Peptide_News_Activator {
'llm_keywords_model' => 'google/gemini-2.0-flash-001', 'llm_keywords_model' => 'google/gemini-2.0-flash-001',
'llm_summary_model' => 'google/gemini-2.0-flash-001', 'llm_summary_model' => 'google/gemini-2.0-flash-001',
'llm_max_articles' => 10, 'llm_max_articles' => 10,
'monthly_budget' => 0,
'budget_mode' => 'disabled',
'cost_retention' => 365,
); );
foreach ( $defaults as $key => $value ) { foreach ( $defaults as $key => $value ) {
@@ -140,7 +149,7 @@ class Peptide_News_Activator {
/** /**
* Schedule the cron event based on the configured interval. * Schedule the cron event based on the configured interval.
*/ */
private static function schedule_cron() { private static function schedule_cron(): void {
$interval = get_option( 'peptide_news_fetch_interval', 'twicedaily' ); $interval = get_option( 'peptide_news_fetch_interval', 'twicedaily' );
if ( ! wp_next_scheduled( 'peptide_news_cron_fetch' ) ) { if ( ! wp_next_scheduled( 'peptide_news_cron_fetch' ) ) {
@@ -151,7 +160,7 @@ class Peptide_News_Activator {
/** /**
* Add foreign key constraints to tables. * Add foreign key constraints to tables.
*/ */
private static function add_foreign_key_constraints( $articles_table, $clicks_table, $daily_table ) { private static function add_foreign_key_constraints( string $articles_table, string $clicks_table, string $daily_table ): void {
global $wpdb; global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

47
includes/class-peptide-news-analytics.php Normal file → Executable file
View File

@@ -1,4 +1,5 @@
<?php <?php
declare( strict_types=1 );
/** /**
* Click analytics tracking and reporting. * Click analytics tracking and reporting.
* *
@@ -16,7 +17,7 @@ class Peptide_News_Analytics {
* @param array $meta Additional context (IP, UA, referrer, etc.). * @param array $meta Additional context (IP, UA, referrer, etc.).
* @return bool * @return bool
*/ */
public static function record_click( $article_id, $meta = array() ) { public static function record_click( int $article_id, array $meta = array() ): bool {
global $wpdb; global $wpdb;
$table = $wpdb->prefix . 'peptide_news_clicks'; $table = $wpdb->prefix . 'peptide_news_clicks';
@@ -61,7 +62,7 @@ class Peptide_News_Analytics {
* @param int $article_id * @param int $article_id
* @param string $ip Anonymized IP for unique visitor counting. * @param string $ip Anonymized IP for unique visitor counting.
*/ */
private static function update_daily_stats( $article_id, $ip ) { private static function update_daily_stats( int $article_id, string $ip ): void {
global $wpdb; global $wpdb;
$table = $wpdb->prefix . 'peptide_news_daily_stats'; $table = $wpdb->prefix . 'peptide_news_daily_stats';
@@ -114,7 +115,7 @@ class Peptide_News_Analytics {
* @param int $limit * @param int $limit
* @return array * @return array
*/ */
public static function get_top_articles( $start_date, $end_date, $limit = 20 ) { public static function get_top_articles( string $start_date, string $end_date, int $limit = 20 ): array {
global $wpdb; global $wpdb;
$stats_table = $wpdb->prefix . 'peptide_news_daily_stats'; $stats_table = $wpdb->prefix . 'peptide_news_daily_stats';
@@ -143,7 +144,7 @@ class Peptide_News_Analytics {
* @param string $end_date * @param string $end_date
* @return array * @return array
*/ */
public static function get_click_trends( $start_date, $end_date ) { public static function get_click_trends( string $start_date, string $end_date ): array {
global $wpdb; global $wpdb;
$table = $wpdb->prefix . 'peptide_news_daily_stats'; $table = $wpdb->prefix . 'peptide_news_daily_stats';
@@ -167,7 +168,7 @@ class Peptide_News_Analytics {
* @param int $limit * @param int $limit
* @return array * @return array
*/ */
public static function get_popular_topics( $start_date, $end_date, $limit = 20 ) { public static function get_popular_topics( string $start_date, string $end_date, int $limit = 20 ): array {
global $wpdb; global $wpdb;
$stats_table = $wpdb->prefix . 'peptide_news_daily_stats'; $stats_table = $wpdb->prefix . 'peptide_news_daily_stats';
@@ -221,7 +222,7 @@ class Peptide_News_Analytics {
* @param string $end_date * @param string $end_date
* @return array * @return array
*/ */
public static function get_device_breakdown( $start_date, $end_date ) { public static function get_device_breakdown( string $start_date, string $end_date ): array {
global $wpdb; global $wpdb;
$table = $wpdb->prefix . 'peptide_news_clicks'; $table = $wpdb->prefix . 'peptide_news_clicks';
@@ -244,7 +245,7 @@ class Peptide_News_Analytics {
* @param string $end_date * @param string $end_date
* @return array * @return array
*/ */
public static function get_source_performance( $start_date, $end_date ) { public static function get_source_performance( string $start_date, string $end_date ): array {
global $wpdb; global $wpdb;
$stats_table = $wpdb->prefix . 'peptide_news_daily_stats'; $stats_table = $wpdb->prefix . 'peptide_news_daily_stats';
@@ -265,27 +266,41 @@ class Peptide_News_Analytics {
) ); ) );
} }
/**
* Hard cap on CSV export rows to prevent memory exhaustion on large datasets.
*
* @var int
*/
const CSV_EXPORT_MAX_ROWS = 50000;
/** /**
* Export click data as CSV-ready array. * Export click data as CSV-ready array.
* *
* @param string $start_date * Capped at CSV_EXPORT_MAX_ROWS to prevent memory exhaustion on sites
* @param string $end_date * with large click volumes. The cap is applied at the SQL level so
* @return array * only the limited result set is ever loaded into PHP memory.
*
* @param string $start_date Y-m-d format.
* @param string $end_date Y-m-d format.
* @return array Array of associative arrays (one per click row).
*/ */
public static function export_clicks_csv( $start_date, $end_date ) { public static function export_clicks_csv( string $start_date, string $end_date ): array {
global $wpdb; global $wpdb;
$clicks_table = $wpdb->prefix . 'peptide_news_clicks'; $clicks_table = $wpdb->prefix . 'peptide_news_clicks';
$articles_table = $wpdb->prefix . 'peptide_news_articles'; $articles_table = $wpdb->prefix . 'peptide_news_articles';
return $wpdb->get_results( $wpdb->prepare( return $wpdb->get_results( $wpdb->prepare(
"SELECT c.clicked_at, a.title, a.source, a.source_url, a.categories, "SELECT c.clicked_at, a.title, a.source, a.source_url, a.categories,
c.referrer_url, c.page_url, c.device_type, c.session_id c.referrer_url, c.page_url, c.device_type, c.session_id
FROM {$clicks_table} c FROM {$clicks_table} c
INNER JOIN {$articles_table} a ON a.id = c.article_id INNER JOIN {$articles_table} a ON a.id = c.article_id
WHERE DATE(c.clicked_at) BETWEEN %s AND %s WHERE DATE(c.clicked_at) BETWEEN %s AND %s
ORDER BY c.clicked_at DESC", ORDER BY c.clicked_at DESC
LIMIT %d",
$start_date, $start_date,
$end_date $end_date,
self::CSV_EXPORT_MAX_ROWS
), ARRAY_A ); ), ARRAY_A );
} }
@@ -294,7 +309,7 @@ class Peptide_News_Analytics {
* *
* @return string|null * @return string|null
*/ */
private static function get_client_ip() { private static function get_client_ip(): ?string {
$ip_keys = array( 'HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR' ); $ip_keys = array( 'HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR' );
foreach ( $ip_keys as $key ) { foreach ( $ip_keys as $key ) {
@@ -316,7 +331,7 @@ class Peptide_News_Analytics {
* @param string $ip * @param string $ip
* @return string * @return string
*/ */
private static function anonymize_ip( $ip ) { private static function anonymize_ip( string $ip ): string {
if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
return preg_replace( '/\.\d+$/', '.0', $ip ); return preg_replace( '/\.\d+$/', '.0', $ip );
} }
@@ -332,7 +347,7 @@ class Peptide_News_Analytics {
* @param string $ua * @param string $ua
* @return string * @return string
*/ */
private static function detect_device( $ua ) { private static function detect_device( string $ua ): string {
$ua = strtolower( $ua ); $ua = strtolower( $ua );
if ( preg_match( '/(tablet|ipad|playbook|silk)/', $ua ) ) { if ( preg_match( '/(tablet|ipad|playbook|silk)/', $ua ) ) {

38
includes/class-peptide-news-content-filter.php Normal file → Executable file
View File

@@ -1,4 +1,5 @@
<?php <?php
declare( strict_types=1 );
/** /**
* Content filter for detecting and removing ads, press releases, * Content filter for detecting and removing ads, press releases,
* and promotional content from fetched articles. * and promotional content from fetched articles.
@@ -122,7 +123,7 @@ class Peptide_News_Content_Filter {
* *
* @return bool * @return bool
*/ */
public static function is_enabled() { public static function is_enabled(): bool {
return (bool) get_option( 'peptide_news_filter_enabled', 1 ); return (bool) get_option( 'peptide_news_filter_enabled', 1 );
} }
@@ -134,7 +135,7 @@ class Peptide_News_Content_Filter {
* *
* @return bool * @return bool
*/ */
public static function is_llm_filter_enabled() { public static function is_llm_filter_enabled(): bool {
if ( ! (bool) get_option( 'peptide_news_filter_llm_enabled', 0 ) ) { if ( ! (bool) get_option( 'peptide_news_filter_llm_enabled', 0 ) ) {
return false; return false;
} }
@@ -151,7 +152,7 @@ class Peptide_News_Content_Filter {
* @param array $articles Array of article arrays from fetch_rss_feeds() / fetch_newsapi(). * @param array $articles Array of article arrays from fetch_rss_feeds() / fetch_newsapi().
* @return array Filtered articles with promotional content removed. * @return array Filtered articles with promotional content removed.
*/ */
public static function filter_articles( $articles ) { public static function filter_articles( array $articles ): array {
if ( ! self::is_enabled() || empty( $articles ) ) { if ( ! self::is_enabled() || empty( $articles ) ) {
return $articles; return $articles;
} }
@@ -227,7 +228,7 @@ class Peptide_News_Content_Filter {
* @param int $body_threshold Minimum body keyword matches to flag. * @param int $body_threshold Minimum body keyword matches to flag.
* @return array Array with 'verdict' (clean|borderline|promotional), 'rule', 'score'. * @return array Array with 'verdict' (clean|borderline|promotional), 'rule', 'score'.
*/ */
public static function evaluate_article( $article, $title_keywords, $body_keywords, $blocked_domains, $body_threshold ) { public static function evaluate_article( array $article, array $title_keywords, array $body_keywords, array $blocked_domains, int $body_threshold ): array {
$score = 0; $score = 0;
$rule = ''; $rule = '';
@@ -343,9 +344,12 @@ class Peptide_News_Content_Filter {
* @param array $article Article data array. * @param array $article Article data array.
* @return string 'editorial' or 'promotional'. * @return string 'editorial' or 'promotional'.
*/ */
private static function classify_with_llm( $article ) { private static function classify_with_llm( array $article ): string {
$api_key = get_option( 'peptide_news_openrouter_api_key', '' ); $api_key_raw = get_option( 'peptide_news_openrouter_api_key', '' );
$model = get_option( 'peptide_news_filter_llm_model', '' ); $api_key = class_exists( 'Peptide_News_Encryption' )
? Peptide_News_Encryption::decrypt( $api_key_raw )
: $api_key_raw;
$model = get_option( 'peptide_news_filter_llm_model', '' );
// Fall back to the keywords model if no dedicated filter model is set. // Fall back to the keywords model if no dedicated filter model is set.
if ( empty( $model ) ) { if ( empty( $model ) ) {
@@ -387,7 +391,7 @@ class Peptide_News_Content_Filter {
* @param array $article Article data. * @param array $article Article data.
* @return string Prompt text. * @return string Prompt text.
*/ */
private static function build_classification_prompt( $article ) { private static function build_classification_prompt( array $article ): string {
$title = $article['title'] ?? ''; $title = $article['title'] ?? '';
$excerpt = $article['excerpt'] ?? ''; $excerpt = $article['excerpt'] ?? '';
$content = $article['content'] ?? ''; $content = $article['content'] ?? '';
@@ -420,7 +424,7 @@ class Peptide_News_Content_Filter {
* @param string $sensitivity 'strict', 'moderate', or 'lenient'. * @param string $sensitivity 'strict', 'moderate', or 'lenient'.
* @return int Number of body keyword matches required. * @return int Number of body keyword matches required.
*/ */
private static function get_body_threshold( $sensitivity ) { private static function get_body_threshold( string $sensitivity ): int {
$thresholds = array( $thresholds = array(
'strict' => 1, 'strict' => 1,
'moderate' => 2, 'moderate' => 2,
@@ -435,7 +439,7 @@ class Peptide_News_Content_Filter {
* *
* @return array * @return array
*/ */
public static function get_title_keywords() { public static function get_title_keywords(): array {
$custom = get_option( 'peptide_news_filter_title_keywords', '' ); $custom = get_option( 'peptide_news_filter_title_keywords', '' );
if ( ! empty( $custom ) ) { if ( ! empty( $custom ) ) {
@@ -450,7 +454,7 @@ class Peptide_News_Content_Filter {
* *
* @return array * @return array
*/ */
public static function get_body_keywords() { public static function get_body_keywords(): array {
$custom = get_option( 'peptide_news_filter_body_keywords', '' ); $custom = get_option( 'peptide_news_filter_body_keywords', '' );
if ( ! empty( $custom ) ) { if ( ! empty( $custom ) ) {
@@ -465,7 +469,7 @@ class Peptide_News_Content_Filter {
* *
* @return array * @return array
*/ */
public static function get_blocked_domains() { public static function get_blocked_domains(): array {
$custom = get_option( 'peptide_news_filter_blocked_domains', '' ); $custom = get_option( 'peptide_news_filter_blocked_domains', '' );
if ( ! empty( $custom ) ) { if ( ! empty( $custom ) ) {
@@ -480,7 +484,7 @@ class Peptide_News_Content_Filter {
* *
* @return string * @return string
*/ */
public static function get_default_title_keywords_text() { public static function get_default_title_keywords_text(): string {
return implode( "\n", self::$default_title_keywords ); return implode( "\n", self::$default_title_keywords );
} }
@@ -489,7 +493,7 @@ class Peptide_News_Content_Filter {
* *
* @return string * @return string
*/ */
public static function get_default_body_keywords_text() { public static function get_default_body_keywords_text(): string {
return implode( "\n", self::$default_body_keywords ); return implode( "\n", self::$default_body_keywords );
} }
@@ -498,7 +502,7 @@ class Peptide_News_Content_Filter {
* *
* @return string * @return string
*/ */
public static function get_default_blocked_domains_text() { public static function get_default_blocked_domains_text(): string {
return implode( "\n", self::$default_blocked_domains ); return implode( "\n", self::$default_blocked_domains );
} }
@@ -509,7 +513,7 @@ class Peptide_News_Content_Filter {
* @param int $removed Articles removed by the filter. * @param int $removed Articles removed by the filter.
* @param int $llm_checked Articles sent to LLM for classification. * @param int $llm_checked Articles sent to LLM for classification.
*/ */
private static function update_filter_stats( $total, $removed, $llm_checked ) { private static function update_filter_stats( int $total, int $removed, int $llm_checked ): void {
update_option( 'peptide_news_filter_last_run', array( update_option( 'peptide_news_filter_last_run', array(
'time' => current_time( 'mysql' ), 'time' => current_time( 'mysql' ),
'total' => $total, 'total' => $total,
@@ -524,7 +528,7 @@ class Peptide_News_Content_Filter {
* *
* @param string $message Log message. * @param string $message Log message.
*/ */
private static function log( $message ) { private static function log( string $message ): void {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( self::LOG_PREFIX . ' ' . $message ); error_log( self::LOG_PREFIX . ' ' . $message );
} }

View File

@@ -0,0 +1,546 @@
<?php
declare( strict_types=1 );
/**
* Tracks LLM API costs: logs every API call, enforces monthly budgets, and
* provides aggregated reporting data for the admin cost dashboard.
*
* Triggered by: Peptide_News_LLM after each call_openrouter() invocation.
* Admin AJAX handlers for cost dashboard data.
*
* Dependencies: $wpdb (custom table), Peptide_News_Logger.
*
* Data flow: LLM::call_openrouter() returns usage data → Cost_Tracker::log_api_call()
* writes to wp_peptide_news_llm_costs table. Budget checks happen
* before each LLM call via Cost_Tracker::is_budget_exceeded().
* Admin dashboard queries aggregated data via get_cost_summary() and
* get_daily_costs().
*
* @see class-peptide-news-llm.php — Calls log_api_call() after every API request
* @see class-peptide-news-admin.php — Renders cost dashboard and budget settings
* @see class-peptide-news-activator.php — Creates the llm_costs table on activation
* @see ARCHITECTURE.md — Data flow diagram
*
* @since 2.4.0
*/
class Peptide_News_Cost_Tracker {
/**
* Default model pricing per 1M tokens (USD).
*
* These are fallback rates used when the API response doesn't include cost data.
* Pricing sourced from OpenRouter's published rates as of April 2026.
* Users can override per-model pricing via the admin settings.
*
* @var array<string, array{input: float, output: float}>
*/
const DEFAULT_MODEL_PRICING = array(
'google/gemini-2.0-flash-001' => array( 'input' => 0.10, 'output' => 0.40 ),
'google/gemma-3-27b-it:free' => array( 'input' => 0.00, 'output' => 0.00 ),
'anthropic/claude-3.5-sonnet' => array( 'input' => 3.00, 'output' => 15.00 ),
'anthropic/claude-3-haiku' => array( 'input' => 0.25, 'output' => 1.25 ),
'openai/gpt-4o-mini' => array( 'input' => 0.15, 'output' => 0.60 ),
'openai/gpt-4o' => array( 'input' => 2.50, 'output' => 10.00 ),
);
/** @var string Budget enforcement mode: 'hard_stop', 'warn_only', or 'disabled'. */
const BUDGET_MODE_HARD_STOP = 'hard_stop';
const BUDGET_MODE_WARN_ONLY = 'warn_only';
const BUDGET_MODE_DISABLED = 'disabled';
/**
* Create the llm_costs table. Called from Activator::activate().
*
* Uses dbDelta() for idempotent CREATE/ALTER so it's safe to call on every
* plugin upgrade without manual migration logic.
*/
public static function create_table(): void {
global $wpdb;
$table = $wpdb->prefix . 'peptide_news_llm_costs';
$charset_collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table} (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
request_id VARCHAR(64) NOT NULL DEFAULT '',
model VARCHAR(100) NOT NULL DEFAULT '',
provider VARCHAR(50) NOT NULL DEFAULT 'openrouter',
operation VARCHAR(50) NOT NULL DEFAULT '',
prompt_tokens INT UNSIGNED NOT NULL DEFAULT 0,
completion_tokens INT UNSIGNED NOT NULL DEFAULT 0,
total_tokens INT UNSIGNED NOT NULL DEFAULT 0,
cost_usd DECIMAL(10,6) NOT NULL DEFAULT 0.000000,
article_id BIGINT(20) UNSIGNED DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_created_at (created_at),
KEY idx_model (model),
KEY idx_operation (operation),
KEY idx_article (article_id)
) {$charset_collate};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
}
/**
* Log a single API call's token usage and cost.
*
* Called by LLM::call_openrouter() after a successful (or failed) API response.
* Extracts token counts from the OpenRouter response's `usage` field and
* calculates cost using model-specific pricing.
*
* @param string $model OpenRouter model ID (e.g., 'google/gemini-2.0-flash-001').
* @param string $operation What this call was for: 'keywords', 'summary', or 'filter'.
* @param array $usage Token usage from API response: {prompt_tokens, completion_tokens, total_tokens}.
* @param int|null $article_id Associated article ID, if applicable.
* @param string $request_id OpenRouter request ID from response headers (for debugging).
* @param float $explicit_cost Cost reported directly by the API (OpenRouter generation.total_cost), if available.
* @return bool True on success.
*/
public static function log_api_call(
string $model,
string $operation,
array $usage,
?int $article_id = null,
string $request_id = '',
float $explicit_cost = 0.0
): bool {
global $wpdb;
$prompt_tokens = absint( $usage['prompt_tokens'] ?? 0 );
$completion_tokens = absint( $usage['completion_tokens'] ?? 0 );
$total_tokens = absint( $usage['total_tokens'] ?? ( $prompt_tokens + $completion_tokens ) );
// Use explicit cost from API if available, otherwise calculate from pricing table.
$cost_usd = $explicit_cost > 0.0
? $explicit_cost
: self::calculate_cost( $model, $prompt_tokens, $completion_tokens );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$result = $wpdb->insert(
$wpdb->prefix . 'peptide_news_llm_costs',
array(
'request_id' => sanitize_text_field( $request_id ),
'model' => sanitize_text_field( $model ),
'provider' => 'openrouter',
'operation' => sanitize_text_field( $operation ),
'prompt_tokens' => $prompt_tokens,
'completion_tokens' => $completion_tokens,
'total_tokens' => $total_tokens,
'cost_usd' => $cost_usd,
'article_id' => $article_id,
'created_at' => current_time( 'mysql' ),
),
array( '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%f', '%d', '%s' )
);
if ( false === $result ) {
Peptide_News_Logger::error( 'Failed to log API cost for model ' . $model, 'cost' );
return false;
}
// Check budget thresholds and fire alerts if needed.
self::check_budget_alerts();
return true;
}
/**
* Calculate estimated cost from token counts using model pricing.
*
* Pricing is per 1M tokens. Checks user-configured overrides first,
* then falls back to DEFAULT_MODEL_PRICING, then to zero (free model assumed).
*
* @param string $model OpenRouter model ID.
* @param int $prompt_tokens Number of input tokens.
* @param int $completion_tokens Number of output tokens.
* @return float Estimated cost in USD.
*/
public static function calculate_cost( string $model, int $prompt_tokens, int $completion_tokens ): float {
$pricing = self::get_model_pricing( $model );
$input_cost = ( $prompt_tokens / 1000000.0 ) * $pricing['input'];
$output_cost = ( $completion_tokens / 1000000.0 ) * $pricing['output'];
return round( $input_cost + $output_cost, 6 );
}
/**
* Get pricing for a specific model (per 1M tokens).
*
* Checks user-configured custom pricing first, then falls back to defaults.
*
* @param string $model OpenRouter model ID.
* @return array{input: float, output: float}
*/
public static function get_model_pricing( string $model ): array {
// Check user-configured custom pricing overrides.
$custom_pricing = get_option( 'peptide_news_custom_model_pricing', array() );
if ( is_array( $custom_pricing ) && isset( $custom_pricing[ $model ] ) ) {
return array(
'input' => (float) ( $custom_pricing[ $model ]['input'] ?? 0.0 ),
'output' => (float) ( $custom_pricing[ $model ]['output'] ?? 0.0 ),
);
}
// Fall back to built-in defaults.
if ( isset( self::DEFAULT_MODEL_PRICING[ $model ] ) ) {
return self::DEFAULT_MODEL_PRICING[ $model ];
}
// Unknown model — assume free to avoid overcharging estimates.
return array( 'input' => 0.0, 'output' => 0.0 );
}
/**
* Check if the monthly budget has been exceeded.
*
* Should be called before every LLM API call to enforce the hard stop.
* Returns true if budget mode is 'hard_stop' and current month's spend
* meets or exceeds the configured limit.
*
* @return bool True if budget is exceeded and calls should be blocked.
*/
public static function is_budget_exceeded(): bool {
$mode = get_option( 'peptide_news_budget_mode', self::BUDGET_MODE_DISABLED );
$limit = (float) get_option( 'peptide_news_monthly_budget', 0.0 );
if ( self::BUDGET_MODE_DISABLED === $mode || $limit <= 0.0 ) {
return false;
}
if ( self::BUDGET_MODE_HARD_STOP !== $mode ) {
return false;
}
$current_spend = self::get_current_month_spend();
return $current_spend >= $limit;
}
/**
* Get total spend for the current calendar month.
*
* Uses a short-lived transient cache (5 min) to avoid hammering the DB
* on every budget check during batch processing.
*
* @return float Total USD spent this month.
*/
public static function get_current_month_spend(): float {
$cache_key = 'peptide_news_month_spend_' . gmdate( 'Y_m' );
$cached = get_transient( $cache_key );
if ( false !== $cached ) {
return (float) $cached;
}
global $wpdb;
$table = $wpdb->prefix . 'peptide_news_llm_costs';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$spend = (float) $wpdb->get_var(
$wpdb->prepare(
"SELECT COALESCE(SUM(cost_usd), 0) FROM {$table} WHERE created_at >= %s",
gmdate( 'Y-m-01 00:00:00' )
)
);
set_transient( $cache_key, $spend, 5 * MINUTE_IN_SECONDS );
return $spend;
}
/**
* Invalidate the monthly spend cache.
*
* Called after logging a new API call so the next budget check
* reflects the latest spend.
*/
public static function invalidate_spend_cache(): void {
delete_transient( 'peptide_news_month_spend_' . gmdate( 'Y_m' ) );
}
/**
* Check budget threshold alerts and log warnings.
*
* Fires at 50%, 80%, and 100% of the monthly budget. Each threshold
* is only logged once per month (tracked via a transient).
*/
private static function check_budget_alerts(): void {
$mode = get_option( 'peptide_news_budget_mode', self::BUDGET_MODE_DISABLED );
$limit = (float) get_option( 'peptide_news_monthly_budget', 0.0 );
if ( self::BUDGET_MODE_DISABLED === $mode || $limit <= 0.0 ) {
return;
}
// Invalidate cache so we get fresh numbers.
self::invalidate_spend_cache();
$spend = self::get_current_month_spend();
$percent = ( $spend / $limit ) * 100.0;
$alert_key = 'peptide_news_budget_alerts_' . gmdate( 'Y_m' );
$fired = get_transient( $alert_key );
if ( ! is_array( $fired ) ) {
$fired = array();
}
$thresholds = array( 50, 80, 100 );
foreach ( $thresholds as $threshold ) {
if ( $percent >= $threshold && ! in_array( $threshold, $fired, true ) ) {
$message = sprintf(
'LLM budget alert: %.0f%% of $%.2f monthly budget used ($%.4f spent).',
$percent,
$limit,
$spend
);
Peptide_News_Logger::warning( $message, 'cost' );
if ( 100 === $threshold && self::BUDGET_MODE_HARD_STOP === $mode ) {
Peptide_News_Logger::error( 'Monthly LLM budget exceeded — API calls will be blocked until next month.', 'cost' );
}
$fired[] = $threshold;
}
}
set_transient( $alert_key, $fired, 35 * DAY_IN_SECONDS );
}
/**
* Get an aggregated cost summary for a date range.
*
* Returns total spend, total tokens, request count, and per-model breakdown.
* Used by the admin cost dashboard.
*
* @param string $start_date Start date (Y-m-d format).
* @param string $end_date End date (Y-m-d format).
* @return array{total_cost: float, total_tokens: int, total_requests: int, by_model: array, by_operation: array}
*/
public static function get_cost_summary( string $start_date, string $end_date ): array {
global $wpdb;
$table = $wpdb->prefix . 'peptide_news_llm_costs';
// Overall totals.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$totals = $wpdb->get_row(
$wpdb->prepare(
"SELECT
COALESCE(SUM(cost_usd), 0) AS total_cost,
COALESCE(SUM(total_tokens), 0) AS total_tokens,
COUNT(*) AS total_requests
FROM {$table}
WHERE created_at >= %s AND created_at < %s",
$start_date . ' 00:00:00',
$end_date . ' 23:59:59'
)
);
// Per-model breakdown.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$by_model = $wpdb->get_results(
$wpdb->prepare(
"SELECT
model,
COUNT(*) AS requests,
COALESCE(SUM(prompt_tokens), 0) AS prompt_tokens,
COALESCE(SUM(completion_tokens), 0) AS completion_tokens,
COALESCE(SUM(total_tokens), 0) AS total_tokens,
COALESCE(SUM(cost_usd), 0) AS total_cost
FROM {$table}
WHERE created_at >= %s AND created_at < %s
GROUP BY model
ORDER BY total_cost DESC",
$start_date . ' 00:00:00',
$end_date . ' 23:59:59'
)
);
// Per-operation breakdown.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$by_operation = $wpdb->get_results(
$wpdb->prepare(
"SELECT
operation,
COUNT(*) AS requests,
COALESCE(SUM(total_tokens), 0) AS total_tokens,
COALESCE(SUM(cost_usd), 0) AS total_cost
FROM {$table}
WHERE created_at >= %s AND created_at < %s
GROUP BY operation
ORDER BY total_cost DESC",
$start_date . ' 00:00:00',
$end_date . ' 23:59:59'
)
);
return array(
'total_cost' => (float) ( $totals->total_cost ?? 0 ),
'total_tokens' => (int) ( $totals->total_tokens ?? 0 ),
'total_requests' => (int) ( $totals->total_requests ?? 0 ),
'by_model' => $by_model ?: array(),
'by_operation' => $by_operation ?: array(),
);
}
/**
* Get daily cost data for charting.
*
* Returns one row per day within the date range with aggregated cost and token counts.
*
* @param string $start_date Start date (Y-m-d).
* @param string $end_date End date (Y-m-d).
* @return array Array of objects with {date, cost, tokens, requests}.
*/
public static function get_daily_costs( string $start_date, string $end_date ): array {
global $wpdb;
$table = $wpdb->prefix . 'peptide_news_llm_costs';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
return $wpdb->get_results(
$wpdb->prepare(
"SELECT
DATE(created_at) AS date,
COALESCE(SUM(cost_usd), 0) AS cost,
COALESCE(SUM(total_tokens), 0) AS tokens,
COUNT(*) AS requests
FROM {$table}
WHERE created_at >= %s AND created_at < %s
GROUP BY DATE(created_at)
ORDER BY date ASC",
$start_date . ' 00:00:00',
$end_date . ' 23:59:59'
)
) ?: array();
}
/**
* Get the most recent API calls for the cost log table.
*
* @param int $limit Number of recent entries to return.
* @return array Array of cost log rows.
*/
public static function get_recent_calls( int $limit = 50 ): array {
global $wpdb;
$table = $wpdb->prefix . 'peptide_news_llm_costs';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
return $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$table} ORDER BY created_at DESC LIMIT %d",
$limit
)
) ?: array();
}
/**
* AJAX handler: return cost dashboard data.
*
* Accepts 'period' param: 'day', 'week', 'month', or 'custom' with start/end dates.
* Returns summary stats and daily chart data.
*
* @since 2.4.0
*/
public static function ajax_get_cost_data(): void {
check_ajax_referer( 'peptide_news_admin', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( 'Unauthorized', 403 );
}
$period = sanitize_text_field( wp_unslash( $_GET['period'] ?? 'month' ) );
switch ( $period ) {
case 'day':
$start_date = gmdate( 'Y-m-d' );
$end_date = gmdate( 'Y-m-d' );
break;
case 'week':
$start_date = gmdate( 'Y-m-d', strtotime( '-6 days' ) );
$end_date = gmdate( 'Y-m-d' );
break;
case 'custom':
$raw_start = sanitize_text_field( wp_unslash( $_GET['start_date'] ?? '' ) );
$raw_end = sanitize_text_field( wp_unslash( $_GET['end_date'] ?? '' ) );
$start_date = self::validate_date( $raw_start ) ? $raw_start : gmdate( 'Y-m-01' );
$end_date = self::validate_date( $raw_end ) ? $raw_end : gmdate( 'Y-m-d' );
break;
case 'month':
default:
$start_date = gmdate( 'Y-m-01' );
$end_date = gmdate( 'Y-m-d' );
break;
}
$summary = self::get_cost_summary( $start_date, $end_date );
$daily_costs = self::get_daily_costs( $start_date, $end_date );
$recent = self::get_recent_calls( 25 );
// Budget context.
$budget_limit = (float) get_option( 'peptide_news_monthly_budget', 0.0 );
$budget_mode = get_option( 'peptide_news_budget_mode', self::BUDGET_MODE_DISABLED );
$month_spend = self::get_current_month_spend();
wp_send_json_success( array(
'summary' => $summary,
'daily_costs' => $daily_costs,
'recent_calls' => $recent,
'budget' => array(
'limit' => $budget_limit,
'mode' => $budget_mode,
'month_spend' => $month_spend,
'percent_used' => $budget_limit > 0 ? round( ( $month_spend / $budget_limit ) * 100, 1 ) : 0,
),
'period' => array(
'start' => $start_date,
'end' => $end_date,
),
) );
}
/**
* Validate a date string is in Y-m-d format and represents a real date.
*
* @param string $date Date string to validate.
* @return bool True if valid Y-m-d date.
*/
private static function validate_date( string $date ): bool {
$d = \DateTime::createFromFormat( 'Y-m-d', $date );
return $d && $d->format( 'Y-m-d' ) === $date;
}
/**
* Prune old cost records beyond the configured retention period.
*
* Called on a monthly cron schedule. Default retention: 365 days.
*
* @return int Number of rows deleted.
*/
public static function prune_old_records(): int {
$retention_days = absint( get_option( 'peptide_news_cost_retention', 365 ) );
if ( $retention_days < 30 ) {
$retention_days = 30; // Minimum 30 days to prevent accidental data loss.
}
global $wpdb;
$table = $wpdb->prefix . 'peptide_news_llm_costs';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$deleted = $wpdb->query(
$wpdb->prepare(
"DELETE FROM {$table} WHERE created_at < %s",
gmdate( 'Y-m-d 00:00:00', strtotime( "-{$retention_days} days" ) )
)
);
if ( $deleted > 0 ) {
Peptide_News_Logger::info( sprintf( 'Pruned %d cost records older than %d days.', $deleted, $retention_days ), 'cost' );
}
return (int) $deleted;
}
}

3
includes/class-peptide-news-deactivator.php Normal file → Executable file
View File

@@ -1,4 +1,5 @@
<?php <?php
declare( strict_types=1 );
/** /**
* Fired during plugin deactivation. * Fired during plugin deactivation.
* *
@@ -8,7 +9,7 @@
*/ */
class Peptide_News_Deactivator { class Peptide_News_Deactivator {
public static function deactivate() { public static function deactivate(): void {
wp_clear_scheduled_hook( 'peptide_news_cron_fetch' ); wp_clear_scheduled_hook( 'peptide_news_cron_fetch' );
} }
} }

View File

@@ -0,0 +1,174 @@
<?php
declare( strict_types=1 );
/**
* AES-256-CBC encryption for sensitive option values (API keys).
*
* Encrypts data at rest in wp_options using AES-256-CBC with WordPress
* auth salt as the key material. Transparent encrypt/decrypt API so
* callers don't need to know the cipher details.
*
* Triggered by: Admin settings save (class-peptide-news-admin.php),
* and read by any class that retrieves API keys.
*
* Dependencies: OpenSSL PHP extension (standard on all modern hosts).
*
* @since 2.3.0
* @see class-peptide-news-admin.php — Encrypts keys on save via sanitize callback.
* @see class-peptide-news-llm.php — Decrypts OpenRouter key before API calls.
* @see class-peptide-news-fetcher.php — Decrypts NewsAPI key before API calls.
* @see ARCHITECTURE.md — Security section documents encryption strategy.
*/
class Peptide_News_Encryption {
/**
* Cipher algorithm used for encryption.
*
* @var string
*/
const CIPHER = 'aes-256-cbc';
/**
* Encrypt a plaintext string.
*
* Returns a base64-encoded string containing the IV prepended to the
* ciphertext. Returns the original value if OpenSSL is unavailable or
* encryption fails (fail-open so the plugin still works on hosts
* without OpenSSL, just without at-rest encryption).
*
* @param string $plaintext The value to encrypt.
* @return string Encrypted value (base64) or original plaintext on failure.
*/
public static function encrypt( string $plaintext ): string {
if ( empty( $plaintext ) ) {
return '';
}
if ( ! self::is_available() ) {
return $plaintext;
}
$key = self::get_key();
$iv_length = openssl_cipher_iv_length( self::CIPHER );
$iv = openssl_random_pseudo_bytes( $iv_length );
$ciphertext = openssl_encrypt( $plaintext, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv );
if ( false === $ciphertext ) {
return $plaintext;
}
// Prepend IV to ciphertext and base64-encode for safe storage.
return base64_encode( $iv . $ciphertext ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
}
/**
* Decrypt a previously encrypted string.
*
* Handles both encrypted (base64) and legacy plaintext values
* gracefully, so existing unencrypted keys continue to work
* during the migration period.
*
* @param string $encrypted The encrypted value (or legacy plaintext).
* @return string Decrypted plaintext.
*/
public static function decrypt( string $encrypted ): string {
if ( empty( $encrypted ) ) {
return '';
}
if ( ! self::is_available() ) {
return $encrypted;
}
// Detect legacy plaintext: if it doesn't look like base64, return as-is.
// Valid encrypted values are always base64 and at least 24 chars
// (16-byte IV + minimum 1-byte ciphertext, base64-encoded).
if ( ! self::looks_encrypted( $encrypted ) ) {
return $encrypted;
}
$raw = base64_decode( $encrypted, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
if ( false === $raw ) {
return $encrypted;
}
$key = self::get_key();
$iv_length = openssl_cipher_iv_length( self::CIPHER );
if ( strlen( $raw ) <= $iv_length ) {
// Too short to contain IV + ciphertext — treat as plaintext.
return $encrypted;
}
$iv = substr( $raw, 0, $iv_length );
$ciphertext = substr( $raw, $iv_length );
$plaintext = openssl_decrypt( $ciphertext, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv );
if ( false === $plaintext ) {
// Decryption failed — likely a legacy plaintext value that
// happened to look like base64. Return the original.
return $encrypted;
}
return $plaintext;
}
/**
* Check whether a stored value looks like it was encrypted by this class.
*
* Used to distinguish encrypted values from legacy plaintext API keys
* during the migration period. Encrypted values are base64-encoded
* and at least 24 characters long (16-byte IV + ciphertext).
*
* @param string $value The stored option value.
* @return bool True if the value appears to be encrypted.
*/
public static function looks_encrypted( string $value ): bool {
if ( strlen( $value ) < 24 ) {
return false;
}
// Must be valid base64.
if ( ! preg_match( '/^[A-Za-z0-9+\/]+=*$/', $value ) ) {
return false;
}
// API keys typically start with "sk-" or similar prefixes
// and contain characters that aren't valid base64.
// If it starts with a known API key prefix, it's plaintext.
$plaintext_prefixes = array( 'sk-', 'pk-', 'key-', 'Bearer ' );
foreach ( $plaintext_prefixes as $prefix ) {
if ( strpos( $value, $prefix ) === 0 ) {
return false;
}
}
return true;
}
/**
* Check whether the OpenSSL extension is available with the required cipher.
*
* @return bool
*/
public static function is_available(): bool {
return function_exists( 'openssl_encrypt' )
&& in_array( self::CIPHER, openssl_get_cipher_methods(), true );
}
/**
* Derive the encryption key from WordPress auth salt.
*
* Uses SHA-256 hash of the auth salt to ensure a consistent
* 32-byte key regardless of the salt's actual length.
*
* @return string 32-byte binary key.
*/
private static function get_key(): string {
// wp_salt('auth') returns the AUTH_KEY + AUTH_SALT concatenation.
// Hash it to get a consistent 32-byte key for AES-256.
return hash( 'sha256', wp_salt( 'auth' ), true );
}
}

38
includes/class-peptide-news-fetcher.php Normal file → Executable file
View File

@@ -1,4 +1,5 @@
<?php <?php
declare( strict_types=1 );
/** /**
* Fetches news from multiple sources and stores them in the database. * Fetches news from multiple sources and stores them in the database.
* *
@@ -16,7 +17,7 @@ class Peptide_News_Fetcher {
* @param array $schedules Existing schedules. * @param array $schedules Existing schedules.
* @return array * @return array
*/ */
public function add_custom_cron_schedules( $schedules ) { public function add_custom_cron_schedules( array $schedules ): array {
$schedules['every_fifteen_minutes'] = array( $schedules['every_fifteen_minutes'] = array(
'interval' => 900, 'interval' => 900,
'display' => __( 'Every 15 Minutes', 'peptide-news' ), 'display' => __( 'Every 15 Minutes', 'peptide-news' ),
@@ -40,7 +41,7 @@ class Peptide_News_Fetcher {
* Master fetch method — called by WP-Cron. * Master fetch method — called by WP-Cron.
* Uses a transient lock to prevent concurrent executions. * Uses a transient lock to prevent concurrent executions.
*/ */
public function fetch_all_sources() { public function fetch_all_sources(): void {
// Prevent overlapping fetch jobs. // Prevent overlapping fetch jobs.
$lock_key = 'peptide_news_fetch_lock'; $lock_key = 'peptide_news_fetch_lock';
if ( get_transient( $lock_key ) ) { if ( get_transient( $lock_key ) ) {
@@ -61,7 +62,10 @@ class Peptide_News_Fetcher {
// Fetch from NewsAPI. // Fetch from NewsAPI.
if ( get_option( 'peptide_news_newsapi_enabled', 0 ) ) { if ( get_option( 'peptide_news_newsapi_enabled', 0 ) ) {
$api_key = get_option( 'peptide_news_newsapi_key', '' ); $api_key_raw = get_option( 'peptide_news_newsapi_key', '' );
$api_key = class_exists( 'Peptide_News_Encryption' )
? Peptide_News_Encryption::decrypt( $api_key_raw )
: $api_key_raw;
if ( ! empty( $api_key ) ) { if ( ! empty( $api_key ) ) {
$newsapi_articles = $this->fetch_newsapi( $api_key ); $newsapi_articles = $this->fetch_newsapi( $api_key );
$articles = array_merge( $articles, $newsapi_articles ); $articles = array_merge( $articles, $newsapi_articles );
@@ -128,7 +132,7 @@ class Peptide_News_Fetcher {
* *
* @return array * @return array
*/ */
private function fetch_rss_feeds() { private function fetch_rss_feeds(): array {
$feeds_raw = get_option( 'peptide_news_rss_feeds', '' ); $feeds_raw = get_option( 'peptide_news_rss_feeds', '' );
$feeds = array_filter( array_map( 'trim', explode( "\n", $feeds_raw ) ) ); $feeds = array_filter( array_map( 'trim', explode( "\n", $feeds_raw ) ) );
$articles = array(); $articles = array();
@@ -175,7 +179,7 @@ class Peptide_News_Fetcher {
* @param string $feed_url * @param string $feed_url
* @return array Article data or empty array if processing failed. * @return array Article data or empty array if processing failed.
*/ */
private function process_rss_item( $item, $feed_url ) { private function process_rss_item( $item, string $feed_url ): array {
$pub_date = $item->get_date( 'Y-m-d H:i:s' ); $pub_date = $item->get_date( 'Y-m-d H:i:s' );
if ( empty( $pub_date ) ) { if ( empty( $pub_date ) ) {
$pub_date = current_time( 'mysql' ); $pub_date = current_time( 'mysql' );
@@ -209,7 +213,7 @@ class Peptide_News_Fetcher {
* @param string $api_key * @param string $api_key
* @return array * @return array
*/ */
private function fetch_newsapi( $api_key ) { private function fetch_newsapi( string $api_key ): array {
$keywords = get_option( 'peptide_news_search_keywords', 'peptide research' ); $keywords = get_option( 'peptide_news_search_keywords', 'peptide research' );
$articles = array(); $articles = array();
@@ -267,7 +271,7 @@ class Peptide_News_Fetcher {
* @param array $article * @param array $article
* @return bool True if new article was inserted. * @return bool True if new article was inserted.
*/ */
private function store_article( $article ) { private function store_article( array $article ): bool {
global $wpdb; global $wpdb;
$table = $wpdb->prefix . 'peptide_news_articles'; $table = $wpdb->prefix . 'peptide_news_articles';
@@ -310,7 +314,7 @@ class Peptide_News_Fetcher {
/** /**
* Remove articles older than the retention period. * Remove articles older than the retention period.
*/ */
private function prune_old_articles() { private function prune_old_articles(): void {
global $wpdb; global $wpdb;
$retention_days = (int) get_option( 'peptide_news_article_retention', 90 ); $retention_days = (int) get_option( 'peptide_news_article_retention', 90 );
@@ -326,7 +330,7 @@ class Peptide_News_Fetcher {
/** /**
* Clear all article transient caches. * Clear all article transient caches.
*/ */
private function clear_article_cache() { private function clear_article_cache(): void {
global $wpdb; global $wpdb;
$wpdb->query( $wpdb->query(
$wpdb->prepare( $wpdb->prepare(
@@ -342,7 +346,7 @@ class Peptide_News_Fetcher {
/** /**
* Invalidate REST API response caches matching the articles pattern. * Invalidate REST API response caches matching the articles pattern.
*/ */
private function invalidate_rest_api_cache() { private function invalidate_rest_api_cache(): void {
global $wpdb; global $wpdb;
$wpdb->query( $wpdb->query(
$wpdb->prepare( $wpdb->prepare(
@@ -361,7 +365,7 @@ class Peptide_News_Fetcher {
* @param SimplePie_Item $item * @param SimplePie_Item $item
* @return string Comma-separated categories. * @return string Comma-separated categories.
*/ */
private function extract_categories( $item ) { private function extract_categories( $item ): string {
$categories = $item->get_categories(); $categories = $item->get_categories();
if ( empty( $categories ) ) { if ( empty( $categories ) ) {
return ''; return '';
@@ -393,7 +397,7 @@ class Peptide_News_Fetcher {
* @param string $article_url The article permalink. * @param string $article_url The article permalink.
* @return string Source name or domain. * @return string Source name or domain.
*/ */
private function resolve_article_source( $item, $feed_url, $article_url ) { private function resolve_article_source( $item, string $feed_url, string $article_url ): string {
$feed_domain = $this->extract_domain( $feed_url ); $feed_domain = $this->extract_domain( $feed_url );
$is_aggregator = in_array( $is_aggregator = in_array(
$feed_domain, $feed_domain,
@@ -455,7 +459,7 @@ class Peptide_News_Fetcher {
* @param string $url The URL to resolve. * @param string $url The URL to resolve.
* @return string|false The final URL or false on failure. * @return string|false The final URL or false on failure.
*/ */
private function resolve_redirect_url( $url ) { private function resolve_redirect_url( string $url ) {
if ( empty( $url ) ) { if ( empty( $url ) ) {
return false; return false;
} }
@@ -505,7 +509,7 @@ class Peptide_News_Fetcher {
* *
* @return int Number of articles updated. * @return int Number of articles updated.
*/ */
public function backfill_article_sources() { public function backfill_article_sources(): int {
global $wpdb; global $wpdb;
$table = $wpdb->prefix . 'peptide_news_articles'; $table = $wpdb->prefix . 'peptide_news_articles';
@@ -572,7 +576,7 @@ class Peptide_News_Fetcher {
/** /**
* AJAX handler for the backfill action. * AJAX handler for the backfill action.
*/ */
public function ajax_backfill_sources() { public function ajax_backfill_sources(): void {
check_ajax_referer( 'peptide_news_admin', 'nonce' ); check_ajax_referer( 'peptide_news_admin', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
@@ -589,7 +593,7 @@ class Peptide_News_Fetcher {
* @param string $url * @param string $url
* @return string * @return string
*/ */
private function extract_domain( $url ) { private function extract_domain( string $url ): string {
$host = wp_parse_url( $url, PHP_URL_HOST ); $host = wp_parse_url( $url, PHP_URL_HOST );
return $host ? preg_replace( '/^www\./', '', $host ) : 'unknown'; return $host ? preg_replace( '/^www\./', '', $host ) : 'unknown';
} }
@@ -599,7 +603,7 @@ class Peptide_News_Fetcher {
* *
* @param string $message * @param string $message
*/ */
private function log_error( $message ) { private function log_error( string $message ): void {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( '[Peptide News] ' . $message ); error_log( '[Peptide News] ' . $message );
} }

201
includes/class-peptide-news-llm.php Normal file → Executable file
View File

@@ -1,4 +1,5 @@
<?php <?php
declare( strict_types=1 );
/** /**
* LLM integration via OpenRouter for article analysis. * LLM integration via OpenRouter for article analysis.
* *
@@ -20,7 +21,7 @@ class Peptide_News_LLM {
* *
* @return string * @return string
*/ */
private static function get_api_url() { private static function get_api_url(): string {
return get_option( 'peptide_news_llm_api_url', 'https://openrouter.ai/api/v1/chat/completions' ); return get_option( 'peptide_news_llm_api_url', 'https://openrouter.ai/api/v1/chat/completions' );
} }
@@ -29,11 +30,30 @@ class Peptide_News_LLM {
* *
* @return bool * @return bool
*/ */
public static function is_enabled() { public static function is_enabled(): bool {
$api_key = get_option( 'peptide_news_openrouter_api_key', '' ); $api_key = self::get_api_key();
return ! empty( $api_key ) && (bool) get_option( 'peptide_news_llm_enabled', 0 ); return ! empty( $api_key ) && (bool) get_option( 'peptide_news_llm_enabled', 0 );
} }
/**
* Retrieve and decrypt the OpenRouter API key.
*
* Handles both encrypted (AES-256-CBC) and legacy plaintext values
* transparently via Peptide_News_Encryption::decrypt().
*
* @return string Decrypted API key or empty string.
*/
private static function get_api_key(): string {
$raw = get_option( 'peptide_news_openrouter_api_key', '' );
if ( empty( $raw ) ) {
return '';
}
if ( class_exists( 'Peptide_News_Encryption' ) ) {
return Peptide_News_Encryption::decrypt( $raw );
}
return $raw;
}
/** /**
* Validate an OpenRouter model ID format. * Validate an OpenRouter model ID format.
* *
@@ -42,7 +62,7 @@ class Peptide_News_LLM {
* @param string $model * @param string $model
* @return bool * @return bool
*/ */
public static function is_valid_model( $model ) { public static function is_valid_model( string $model ): bool {
return (bool) preg_match( '/^[a-zA-Z0-9_-]+\/[a-zA-Z0-9._-]+(:[a-zA-Z0-9._-]+)?$/', $model ); return (bool) preg_match( '/^[a-zA-Z0-9_-]+\/[a-zA-Z0-9._-]+(:[a-zA-Z0-9._-]+)?$/', $model );
} }
@@ -50,12 +70,13 @@ class Peptide_News_LLM {
* Process a single article: extract keywords and generate a summary. * Process a single article: extract keywords and generate a summary.
* *
* Skips articles that already have both tags and ai_summary unless $force is true. * Skips articles that already have both tags and ai_summary unless $force is true.
* Checks budget before each API call and logs costs via Cost_Tracker.
* *
* @param object $article Database row with id, title, excerpt, content, categories. * @param object $article Database row with id, title, excerpt, content, categories.
* @param bool $force Re-process even if already analyzed. * @param bool $force Re-process even if already analyzed.
* @return array Results with 'keywords', 'summary', and 'success' keys. * @return array Results with 'keywords', 'summary', and 'success' keys.
*/ */
public static function process_article( $article, $force = false ) { public static function process_article( object $article, bool $force = false ): array {
if ( ! self::is_enabled() ) { if ( ! self::is_enabled() ) {
return array( 'keywords' => '', 'summary' => '', 'success' => false ); return array( 'keywords' => '', 'summary' => '', 'success' => false );
} }
@@ -67,25 +88,44 @@ class Peptide_News_LLM {
'errors' => array(), 'errors' => array(),
); );
$api_key = get_option( 'peptide_news_openrouter_api_key', '' ); $api_key = self::get_api_key();
$any_success = false; $any_success = false;
$article_id = (int) $article->id;
// --- Keyword extraction --- // --- Keyword extraction ---
$keywords_model = get_option( 'peptide_news_llm_keywords_model', 'google/gemini-2.0-flash-001' ); $keywords_model = get_option( 'peptide_news_llm_keywords_model', 'google/gemini-2.0-flash-001' );
$need_keywords = $force || empty( $article->tags ); $need_keywords = $force || empty( $article->tags );
if ( $need_keywords && self::is_valid_model( $keywords_model ) ) { if ( $need_keywords && self::is_valid_model( $keywords_model ) ) {
$keywords_prompt = self::build_keywords_prompt( $article ); // Budget gate: block if monthly limit reached.
$keywords_result = self::call_openrouter( $api_key, $keywords_model, $keywords_prompt ); if ( class_exists( 'Peptide_News_Cost_Tracker' ) && Peptide_News_Cost_Tracker::is_budget_exceeded() ) {
$results['errors'][] = 'Monthly LLM budget exceeded — keywords skipped.';
if ( ! is_wp_error( $keywords_result ) ) { Peptide_News_Logger::warning( 'Budget exceeded, skipping keywords for article #' . $article_id, 'cost' );
$results['keywords'] = self::sanitize_keywords( $keywords_result );
$any_success = true;
Peptide_News_Logger::debug( 'Keywords extracted for article #' . $article->id, 'llm' );
} else { } else {
$err_msg = 'Keywords (' . $keywords_model . '): ' . $keywords_result->get_error_message(); $keywords_prompt = self::build_keywords_prompt( $article );
$results['errors'][] = $err_msg; $keywords_response = self::call_openrouter_with_usage( $api_key, $keywords_model, $keywords_prompt );
Peptide_News_Logger::error( 'Keyword extraction failed for article #' . $article->id . ': ' . $keywords_result->get_error_message(), 'llm' );
if ( ! is_wp_error( $keywords_response['content'] ) ) {
$results['keywords'] = self::sanitize_keywords( $keywords_response['content'] );
$any_success = true;
Peptide_News_Logger::debug( 'Keywords extracted for article #' . $article_id, 'llm' );
} else {
$err_msg = 'Keywords (' . $keywords_model . '): ' . $keywords_response['content']->get_error_message();
$results['errors'][] = $err_msg;
Peptide_News_Logger::error( 'Keyword extraction failed for article #' . $article_id . ': ' . $keywords_response['content']->get_error_message(), 'llm' );
}
// Log cost regardless of success — failed calls still consume tokens.
if ( class_exists( 'Peptide_News_Cost_Tracker' ) && ! empty( $keywords_response['usage'] ) ) {
Peptide_News_Cost_Tracker::log_api_call(
$keywords_model,
'keywords',
$keywords_response['usage'],
$article_id,
$keywords_response['request_id'] ?? '',
$keywords_response['cost'] ?? 0.0
);
}
} }
} elseif ( $need_keywords ) { } elseif ( $need_keywords ) {
$results['errors'][] = 'Invalid keywords model ID: ' . $keywords_model; $results['errors'][] = 'Invalid keywords model ID: ' . $keywords_model;
@@ -97,18 +137,36 @@ class Peptide_News_LLM {
$need_summary = $force || empty( $article->ai_summary ); $need_summary = $force || empty( $article->ai_summary );
if ( $need_summary && self::is_valid_model( $summary_model ) ) { if ( $need_summary && self::is_valid_model( $summary_model ) ) {
$summary_prompt = self::build_summary_prompt( $article ); // Budget gate: re-check in case keyword extraction pushed us over.
$summary_result = self::call_openrouter( $api_key, $summary_model, $summary_prompt ); if ( class_exists( 'Peptide_News_Cost_Tracker' ) && Peptide_News_Cost_Tracker::is_budget_exceeded() ) {
$results['errors'][] = 'Monthly LLM budget exceeded — summary skipped.';
if ( ! is_wp_error( $summary_result ) ) { Peptide_News_Logger::warning( 'Budget exceeded, skipping summary for article #' . $article_id, 'cost' );
// Strip all HTML tags from LLM output to prevent XSS.
$results['summary'] = sanitize_textarea_field( wp_strip_all_tags( $summary_result ) );
$any_success = true;
Peptide_News_Logger::info( 'AI summary generated for article #' . $article->id . ': ' . mb_substr( $article->title, 0, 60 ), 'llm' );
} else { } else {
$err_msg = 'Summary (' . $summary_model . '): ' . $summary_result->get_error_message(); $summary_prompt = self::build_summary_prompt( $article );
$results['errors'][] = $err_msg; $summary_response = self::call_openrouter_with_usage( $api_key, $summary_model, $summary_prompt );
Peptide_News_Logger::error( 'Summarization failed for article #' . $article->id . ': ' . $summary_result->get_error_message(), 'llm' );
if ( ! is_wp_error( $summary_response['content'] ) ) {
// Strip all HTML tags from LLM output to prevent XSS.
$results['summary'] = sanitize_textarea_field( wp_strip_all_tags( $summary_response['content'] ) );
$any_success = true;
Peptide_News_Logger::info( 'AI summary generated for article #' . $article_id . ': ' . mb_substr( $article->title, 0, 60 ), 'llm' );
} else {
$err_msg = 'Summary (' . $summary_model . '): ' . $summary_response['content']->get_error_message();
$results['errors'][] = $err_msg;
Peptide_News_Logger::error( 'Summarization failed for article #' . $article_id . ': ' . $summary_response['content']->get_error_message(), 'llm' );
}
// Log cost regardless of success.
if ( class_exists( 'Peptide_News_Cost_Tracker' ) && ! empty( $summary_response['usage'] ) ) {
Peptide_News_Cost_Tracker::log_api_call(
$summary_model,
'summary',
$summary_response['usage'],
$article_id,
$summary_response['request_id'] ?? '',
$summary_response['cost'] ?? 0.0
);
}
} }
} elseif ( $need_summary ) { } elseif ( $need_summary ) {
$results['errors'][] = 'Invalid summary model ID: ' . $summary_model; $results['errors'][] = 'Invalid summary model ID: ' . $summary_model;
@@ -119,7 +177,7 @@ class Peptide_News_LLM {
// --- Persist results --- // --- Persist results ---
if ( ! empty( $results['keywords'] ) || ! empty( $results['summary'] ) ) { if ( ! empty( $results['keywords'] ) || ! empty( $results['summary'] ) ) {
self::save_results( $article->id, $results, $force ); self::save_results( $article_id, $results, $force );
} }
return $results; return $results;
@@ -136,7 +194,7 @@ class Peptide_News_LLM {
* @param bool $override_limit When true, ignore the admin "max per cycle" cap. * @param bool $override_limit When true, ignore the admin "max per cycle" cap.
* @return int Number of articles successfully processed. * @return int Number of articles successfully processed.
*/ */
public static function process_unanalyzed( $batch_size = 10, $override_limit = false ) { public static function process_unanalyzed( int $batch_size = 10, bool $override_limit = false ): int {
if ( ! self::is_enabled() ) { if ( ! self::is_enabled() ) {
return 0; return 0;
} }
@@ -221,7 +279,7 @@ class Peptide_News_LLM {
* @param object $article * @param object $article
* @return string * @return string
*/ */
private static function build_keywords_prompt( $article ) { private static function build_keywords_prompt( object $article ): string {
$text = $article->title; $text = $article->title;
if ( ! empty( $article->excerpt ) ) { if ( ! empty( $article->excerpt ) ) {
$text .= "\n\n" . $article->excerpt; $text .= "\n\n" . $article->excerpt;
@@ -245,7 +303,7 @@ class Peptide_News_LLM {
* @param object $article * @param object $article
* @return string * @return string
*/ */
private static function build_summary_prompt( $article ) { private static function build_summary_prompt( object $article ): string {
$title = trim( $article->title ?? '' ); $title = trim( $article->title ?? '' );
$excerpt = trim( $article->excerpt ?? '' ); $excerpt = trim( $article->excerpt ?? '' );
$content = trim( $article->content ?? '' ); $content = trim( $article->content ?? '' );
@@ -287,7 +345,7 @@ class Peptide_News_LLM {
* @param string $url The article URL. * @param string $url The article URL.
* @return string Cleaned plain text or empty string on failure. * @return string Cleaned plain text or empty string on failure.
*/ */
private static function fetch_article_text( $url ) { private static function fetch_article_text( string $url ): string {
$response = wp_remote_get( $url, array( $response = wp_remote_get( $url, array(
'timeout' => 10, 'timeout' => 10,
'user-agent' => 'Mozilla/5.0 (compatible; PeptideNewsBot/1.0; +https://peptiderepo.com)', 'user-agent' => 'Mozilla/5.0 (compatible; PeptideNewsBot/1.0; +https://peptiderepo.com)',
@@ -337,12 +395,38 @@ class Peptide_News_LLM {
/** /**
* Call the OpenRouter API with retry logic for 429 rate limits. * Call the OpenRouter API with retry logic for 429 rate limits.
* *
* Backward-compatible wrapper that returns only the content string.
* Internally delegates to call_openrouter_with_usage().
*
* @param string $api_key * @param string $api_key
* @param string $model * @param string $model
* @param string $prompt * @param string $prompt
* @return string|WP_Error The response text or an error. * @return string|WP_Error The response text or an error.
*/ */
public static function call_openrouter( $api_key, $model, $prompt ) { public static function call_openrouter( string $api_key, string $model, string $prompt ) {
$result = self::call_openrouter_with_usage( $api_key, $model, $prompt );
return $result['content'];
}
/**
* Call the OpenRouter API and return content + token usage data.
*
* Returns an array with 'content' (string|WP_Error), 'usage' (token counts),
* 'request_id' (for debugging), and 'cost' (if API reports it).
*
* @param string $api_key
* @param string $model
* @param string $prompt
* @return array{content: string|WP_Error, usage: array, request_id: string, cost: float}
*/
public static function call_openrouter_with_usage( string $api_key, string $model, string $prompt ): array {
$result = array(
'content' => '',
'usage' => array(),
'request_id' => '',
'cost' => 0.0,
);
$body = array( $body = array(
'model' => $model, 'model' => $model,
'messages' => array( 'messages' => array(
@@ -371,7 +455,8 @@ class Peptide_News_LLM {
) ); ) );
if ( is_wp_error( $response ) ) { if ( is_wp_error( $response ) ) {
return $response; $result['content'] = $response;
return $result;
} }
$status = wp_remote_retrieve_response_code( $response ); $status = wp_remote_retrieve_response_code( $response );
@@ -391,29 +476,55 @@ class Peptide_News_LLM {
$data = json_decode( wp_remote_retrieve_body( $response ), true ); $data = json_decode( wp_remote_retrieve_body( $response ), true );
// Extract usage data from response regardless of success/failure.
// OpenRouter includes usage in the response body: data.usage.{prompt_tokens, completion_tokens, total_tokens}
if ( is_array( $data ) && isset( $data['usage'] ) && is_array( $data['usage'] ) ) {
$result['usage'] = array(
'prompt_tokens' => (int) ( $data['usage']['prompt_tokens'] ?? 0 ),
'completion_tokens' => (int) ( $data['usage']['completion_tokens'] ?? 0 ),
'total_tokens' => (int) ( $data['usage']['total_tokens'] ?? 0 ),
);
}
// Extract the request ID from response for debugging/reconciliation.
if ( is_array( $data ) && isset( $data['id'] ) ) {
$result['request_id'] = (string) $data['id'];
}
// OpenRouter may include generation cost in data.usage.total_cost or similar.
if ( is_array( $data ) && isset( $data['usage']['total_cost'] ) ) {
$result['cost'] = (float) $data['usage']['total_cost'];
}
if ( $status < 200 || $status >= 300 ) { if ( $status < 200 || $status >= 300 ) {
$error_msg = 'HTTP ' . $status; $error_msg = 'HTTP ' . $status;
if ( is_array( $data ) && isset( $data['error'] ) && is_array( $data['error'] ) && isset( $data['error']['message'] ) ) { if ( is_array( $data ) && isset( $data['error'] ) && is_array( $data['error'] ) && isset( $data['error']['message'] ) ) {
$error_msg = $data['error']['message']; $error_msg = $data['error']['message'];
} }
return new WP_Error( 'openrouter_error', $error_msg ); $result['content'] = new WP_Error( 'openrouter_error', $error_msg );
return $result;
} }
// Validate the response structure thoroughly. // Validate the response structure thoroughly.
if ( ! is_array( $data ) ) { if ( ! is_array( $data ) ) {
return new WP_Error( 'openrouter_invalid', 'Invalid JSON response from OpenRouter' ); $result['content'] = new WP_Error( 'openrouter_invalid', 'Invalid JSON response from OpenRouter' );
return $result;
} }
if ( ! isset( $data['choices'] ) || ! is_array( $data['choices'] ) || empty( $data['choices'] ) ) { if ( ! isset( $data['choices'] ) || ! is_array( $data['choices'] ) || empty( $data['choices'] ) ) {
return new WP_Error( 'openrouter_empty', 'No choices in OpenRouter response' ); $result['content'] = new WP_Error( 'openrouter_empty', 'No choices in OpenRouter response' );
return $result;
} }
if ( ! isset( $data['choices'][0]['message'] ) || ! is_array( $data['choices'][0]['message'] ) ) { if ( ! isset( $data['choices'][0]['message'] ) || ! is_array( $data['choices'][0]['message'] ) ) {
return new WP_Error( 'openrouter_empty', 'Malformed choice in OpenRouter response' ); $result['content'] = new WP_Error( 'openrouter_empty', 'Malformed choice in OpenRouter response' );
return $result;
} }
if ( ! isset( $data['choices'][0]['message']['content'] ) || '' === trim( $data['choices'][0]['message']['content'] ) ) { if ( ! isset( $data['choices'][0]['message']['content'] ) || '' === trim( $data['choices'][0]['message']['content'] ) ) {
return new WP_Error( 'openrouter_empty', 'Empty content in OpenRouter response' ); $result['content'] = new WP_Error( 'openrouter_empty', 'Empty content in OpenRouter response' );
return $result;
} }
return trim( $data['choices'][0]['message']['content'] ); $result['content'] = trim( $data['choices'][0]['message']['content'] );
return $result;
} }
/** /**
@@ -422,7 +533,7 @@ class Peptide_News_LLM {
* @param string $raw Raw comma-separated keywords. * @param string $raw Raw comma-separated keywords.
* @return string Cleaned, deduplicated, comma-separated list. * @return string Cleaned, deduplicated, comma-separated list.
*/ */
private static function sanitize_keywords( $raw ) { private static function sanitize_keywords( string $raw ): string {
// Strip all HTML first to prevent XSS. // Strip all HTML first to prevent XSS.
$raw = wp_strip_all_tags( $raw ); $raw = wp_strip_all_tags( $raw );
@@ -451,7 +562,7 @@ class Peptide_News_LLM {
* @param array $results Array with 'keywords' and 'summary'. * @param array $results Array with 'keywords' and 'summary'.
* @param bool $force Overwrite existing values. * @param bool $force Overwrite existing values.
*/ */
private static function save_results( $article_id, $results, $force = false ) { private static function save_results( int $article_id, array $results, bool $force = false ): void {
global $wpdb; global $wpdb;
$table = $wpdb->prefix . 'peptide_news_articles'; $table = $wpdb->prefix . 'peptide_news_articles';
@@ -487,7 +598,7 @@ class Peptide_News_LLM {
* *
* @since 2.0.1 * @since 2.0.1
*/ */
public static function ajax_generate_summaries() { public static function ajax_generate_summaries(): void {
check_ajax_referer( 'peptide_news_admin', 'nonce' ); check_ajax_referer( 'peptide_news_admin', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Unauthorized', 'peptide-news' ), 403 ); wp_die( esc_html__( 'Unauthorized', 'peptide-news' ), 403 );
@@ -557,7 +668,7 @@ class Peptide_News_LLM {
/** /**
* Clear all article transient caches. * Clear all article transient caches.
*/ */
private static function clear_article_cache() { private static function clear_article_cache(): void {
global $wpdb; global $wpdb;
$wpdb->query( $wpdb->query(
$wpdb->prepare( $wpdb->prepare(
@@ -575,7 +686,7 @@ class Peptide_News_LLM {
* *
* @param string $message * @param string $message
*/ */
private static function log_error( $message ) { private static function log_error( string $message ): void {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( '[Peptide News LLM] ' . $message ); error_log( '[Peptide News LLM] ' . $message );
} }

9
includes/class-peptide-news-loader.php Normal file → Executable file
View File

@@ -1,4 +1,5 @@
<?php <?php
declare( strict_types=1 );
/** /**
* Register all actions and filters for the plugin. * Register all actions and filters for the plugin.
* *
@@ -50,7 +51,7 @@ class Peptide_News_Loader {
* @param int $priority Optional. The priority at which the function should be fired. Default is 10. * @param int $priority Optional. The priority at which the function should be fired. Default is 10.
* @param int $accepted_args Optional. The number of arguments that should be passed to the $callback. Default is 1. * @param int $accepted_args Optional. The number of arguments that should be passed to the $callback. Default is 1.
*/ */
public function add_action( $hook, $component, $callback, $priority = 10, $accepted_args = 1 ) { public function add_action( string $hook, object $component, string $callback, int $priority = 10, int $accepted_args = 1 ): void {
$this->actions = $this->add( $this->actions, $hook, $component, $callback, $priority, $accepted_args ); $this->actions = $this->add( $this->actions, $hook, $component, $callback, $priority, $accepted_args );
} }
@@ -64,7 +65,7 @@ class Peptide_News_Loader {
* @param int $priority Optional. The priority at which the function should be fired. Default is 10. * @param int $priority Optional. The priority at which the function should be fired. Default is 10.
* @param int $accepted_args Optional. The number of arguments that should be passed to the $callback. Default is 1. * @param int $accepted_args Optional. The number of arguments that should be passed to the $callback. Default is 1.
*/ */
public function add_filter( $hook, $component, $callback, $priority = 10, $accepted_args = 1 ) { public function add_filter( string $hook, object $component, string $callback, int $priority = 10, int $accepted_args = 1 ): void {
$this->filters = $this->add( $this->filters, $hook, $component, $callback, $priority, $accepted_args ); $this->filters = $this->add( $this->filters, $hook, $component, $callback, $priority, $accepted_args );
} }
@@ -81,7 +82,7 @@ class Peptide_News_Loader {
* @param int $accepted_args The number of arguments that should be passed to the $callback. * @param int $accepted_args The number of arguments that should be passed to the $callback.
* @return array The collection of actions and filters registered with WordPress. * @return array The collection of actions and filters registered with WordPress.
*/ */
private function add( $hooks, $hook, $component, $callback, $priority, $accepted_args ) { private function add( array $hooks, string $hook, object $component, string $callback, int $priority, int $accepted_args ): array {
$hooks[] = array( $hooks[] = array(
'hook' => $hook, 'hook' => $hook,
'component' => $component, 'component' => $component,
@@ -98,7 +99,7 @@ class Peptide_News_Loader {
* *
* @since 1.0.0 * @since 1.0.0
*/ */
public function run() { public function run(): void {
foreach ( $this->filters as $hook ) { foreach ( $this->filters as $hook ) {
add_filter( add_filter(
$hook['hook'], $hook['hook'],

25
includes/class-peptide-news-logger.php Normal file → Executable file
View File

@@ -1,4 +1,5 @@
<?php <?php
declare( strict_types=1 );
/** /**
* Plugin Logger. * Plugin Logger.
* *
@@ -21,7 +22,7 @@ class Peptide_News_Logger {
/** /**
* Return the log table name. * Return the log table name.
*/ */
public static function table() { public static function table(): string {
global $wpdb; global $wpdb;
return $wpdb->prefix . 'peptide_news_log'; return $wpdb->prefix . 'peptide_news_log';
} }
@@ -37,7 +38,7 @@ class Peptide_News_Logger {
* @param string $message Human-readable message. * @param string $message Human-readable message.
* @param string $context Category tag (fetch, llm, admin, cron, general). * @param string $context Category tag (fetch, llm, admin, cron, general).
*/ */
public static function log( $level, $message, $context = 'general' ) { public static function log( string $level, string $message, string $context = 'general' ): void {
global $wpdb; global $wpdb;
$wpdb->insert( $wpdb->insert(
@@ -56,22 +57,22 @@ class Peptide_News_Logger {
} }
/** Convenience: info level. */ /** Convenience: info level. */
public static function info( $message, $context = 'general' ) { public static function info( string $message, string $context = 'general' ): void {
self::log( self::LEVEL_INFO, $message, $context ); self::log( self::LEVEL_INFO, $message, $context );
} }
/** Convenience: warning level. */ /** Convenience: warning level. */
public static function warning( $message, $context = 'general' ) { public static function warning( string $message, string $context = 'general' ): void {
self::log( self::LEVEL_WARNING, $message, $context ); self::log( self::LEVEL_WARNING, $message, $context );
} }
/** Convenience: error level. */ /** Convenience: error level. */
public static function error( $message, $context = 'general' ) { public static function error( string $message, string $context = 'general' ): void {
self::log( self::LEVEL_ERROR, $message, $context ); self::log( self::LEVEL_ERROR, $message, $context );
} }
/** Convenience: debug level (only writes when WP_DEBUG is on). */ /** Convenience: debug level (only writes when WP_DEBUG is on). */
public static function debug( $message, $context = 'general' ) { public static function debug( string $message, string $context = 'general' ): void {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
self::log( self::LEVEL_DEBUG, $message, $context ); self::log( self::LEVEL_DEBUG, $message, $context );
} }
@@ -94,7 +95,7 @@ class Peptide_News_Logger {
* } * }
* @return array { 'rows' => array, 'total' => int } * @return array { 'rows' => array, 'total' => int }
*/ */
public static function get_logs( $args = array() ) { public static function get_logs( array $args = array() ): array {
global $wpdb; global $wpdb;
$defaults = array( $defaults = array(
@@ -145,7 +146,7 @@ class Peptide_News_Logger {
/** /**
* Remove rows beyond the MAX_ROWS cap. * Remove rows beyond the MAX_ROWS cap.
*/ */
private static function maybe_prune() { private static function maybe_prune(): void {
global $wpdb; global $wpdb;
$table = self::table(); $table = self::table();
@@ -168,7 +169,7 @@ class Peptide_News_Logger {
/** /**
* Clear all log entries. * Clear all log entries.
*/ */
public static function clear() { public static function clear(): void {
global $wpdb; global $wpdb;
$wpdb->query( "TRUNCATE TABLE " . self::table() ); $wpdb->query( "TRUNCATE TABLE " . self::table() );
} }
@@ -180,7 +181,7 @@ class Peptide_News_Logger {
/** /**
* AJAX: fetch paginated log entries. * AJAX: fetch paginated log entries.
*/ */
public static function ajax_get_logs() { public static function ajax_get_logs(): void {
check_ajax_referer( 'peptide_news_admin', 'nonce' ); check_ajax_referer( 'peptide_news_admin', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
@@ -210,7 +211,7 @@ class Peptide_News_Logger {
/** /**
* AJAX: clear all log entries. * AJAX: clear all log entries.
*/ */
public static function ajax_clear_logs() { public static function ajax_clear_logs(): void {
check_ajax_referer( 'peptide_news_admin', 'nonce' ); check_ajax_referer( 'peptide_news_admin', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
@@ -230,7 +231,7 @@ class Peptide_News_Logger {
/** /**
* Create the log table (called from activator / upgrade check). * Create the log table (called from activator / upgrade check).
*/ */
public static function create_table() { public static function create_table(): void {
global $wpdb; global $wpdb;
$table = self::table(); $table = self::table();

9
includes/class-peptide-news-rest-api.php Normal file → Executable file
View File

@@ -1,4 +1,5 @@
<?php <?php
declare( strict_types=1 );
/** /**
* REST API endpoints for external analytics access. * REST API endpoints for external analytics access.
* *
@@ -22,7 +23,7 @@ class Peptide_News_Rest_API {
/** /**
* Register REST routes. * Register REST routes.
*/ */
public function register_routes() { public function register_routes(): void {
// Public: list articles. // Public: list articles.
register_rest_route( self::API_NAMESPACE, '/articles', array( register_rest_route( self::API_NAMESPACE, '/articles', array(
@@ -83,7 +84,7 @@ class Peptide_News_Rest_API {
* *
* @return array * @return array
*/ */
private function get_date_range_args() { private function get_date_range_args(): array {
return array( return array(
'start_date' => array( 'start_date' => array(
'default' => gmdate( 'Y-m-d', strtotime( '-30 days' ) ), 'default' => gmdate( 'Y-m-d', strtotime( '-30 days' ) ),
@@ -216,7 +217,7 @@ class Peptide_News_Rest_API {
* @param string $source_url The source URL (used to extract domain-based publisher names). * @param string $source_url The source URL (used to extract domain-based publisher names).
* @return string Cleaned text. * @return string Cleaned text.
*/ */
private function strip_source_suffix( $text, $source, $source_url = '' ) { private function strip_source_suffix( string $text, string $source, string $source_url = '' ): string {
if ( empty( $text ) ) { if ( empty( $text ) ) {
return $text; return $text;
} }
@@ -387,7 +388,7 @@ class Peptide_News_Rest_API {
* *
* @since 2.1.0 * @since 2.1.0
*/ */
public static function ajax_delete_articles() { public static function ajax_delete_articles(): void {
check_ajax_referer( 'peptide_news_admin', 'nonce' ); check_ajax_referer( 'peptide_news_admin', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {

22
includes/class-peptide-news.php Normal file → Executable file
View File

@@ -1,4 +1,5 @@
<?php <?php
declare( strict_types=1 );
/** /**
* The core plugin class. * The core plugin class.
* *
@@ -31,16 +32,18 @@ class Peptide_News {
/** /**
* Load all required files. * Load all required files.
*/ */
private function load_dependencies() { private function load_dependencies(): void {
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-loader.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-loader.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-activator.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-activator.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-deactivator.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-deactivator.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-encryption.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-fetcher.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-fetcher.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-analytics.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-analytics.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-rest-api.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-rest-api.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-llm.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-llm.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-logger.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-logger.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-content-filter.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-content-filter.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-cost-tracker.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'admin/class-peptide-news-admin.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'admin/class-peptide-news-admin.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'public/class-peptide-news-public.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'public/class-peptide-news-public.php';
@@ -50,7 +53,7 @@ class Peptide_News {
/** /**
* Register admin-side hooks. * Register admin-side hooks.
*/ */
private function define_admin_hooks() { private function define_admin_hooks(): void {
$admin = new Peptide_News_Admin( $this->plugin_name, $this->version ); $admin = new Peptide_News_Admin( $this->plugin_name, $this->version );
$this->loader->add_action( 'admin_enqueue_scripts', $admin, 'enqueue_styles' ); $this->loader->add_action( 'admin_enqueue_scripts', $admin, 'enqueue_styles' );
@@ -62,7 +65,7 @@ class Peptide_News {
/** /**
* Register public-facing hooks. * Register public-facing hooks.
*/ */
private function define_public_hooks() { private function define_public_hooks(): void {
$public = new Peptide_News_Public( $this->plugin_name, $this->version ); $public = new Peptide_News_Public( $this->plugin_name, $this->version );
$this->loader->add_action( 'wp_enqueue_scripts', $public, 'enqueue_styles' ); $this->loader->add_action( 'wp_enqueue_scripts', $public, 'enqueue_styles' );
@@ -76,7 +79,7 @@ class Peptide_News {
/** /**
* Register WP-Cron hooks for scheduled fetching. * Register WP-Cron hooks for scheduled fetching.
*/ */
private function define_cron_hooks() { private function define_cron_hooks(): void {
$fetcher = new Peptide_News_Fetcher(); $fetcher = new Peptide_News_Fetcher();
$this->loader->add_action( 'peptide_news_cron_fetch', $fetcher, 'fetch_all_sources' ); $this->loader->add_action( 'peptide_news_cron_fetch', $fetcher, 'fetch_all_sources' );
@@ -86,12 +89,13 @@ class Peptide_News {
$this->loader->add_action( 'wp_ajax_peptide_news_get_logs', 'Peptide_News_Logger', 'ajax_get_logs' ); $this->loader->add_action( 'wp_ajax_peptide_news_get_logs', 'Peptide_News_Logger', 'ajax_get_logs' );
$this->loader->add_action( 'wp_ajax_peptide_news_clear_logs', 'Peptide_News_Logger', 'ajax_clear_logs' ); $this->loader->add_action( 'wp_ajax_peptide_news_clear_logs', 'Peptide_News_Logger', 'ajax_clear_logs' );
$this->loader->add_action( 'wp_ajax_peptide_news_delete_articles', 'Peptide_News_Rest_API', 'ajax_delete_articles' ); $this->loader->add_action( 'wp_ajax_peptide_news_delete_articles', 'Peptide_News_Rest_API', 'ajax_delete_articles' );
$this->loader->add_action( 'wp_ajax_peptide_news_get_cost_data', 'Peptide_News_Cost_Tracker', 'ajax_get_cost_data' );
} }
/** /**
* Register REST API hooks. * Register REST API hooks.
*/ */
private function define_rest_hooks() { private function define_rest_hooks(): void {
$rest = new Peptide_News_Rest_API(); $rest = new Peptide_News_Rest_API();
$this->loader->add_action( 'rest_api_init', $rest, 'register_routes' ); $this->loader->add_action( 'rest_api_init', $rest, 'register_routes' );
@@ -100,19 +104,19 @@ class Peptide_News {
/** /**
* Run the loader to execute all registered hooks. * Run the loader to execute all registered hooks.
*/ */
public function run() { public function run(): void {
$this->loader->run(); $this->loader->run();
} }
public function get_plugin_name() { public function get_plugin_name(): string {
return $this->plugin_name; return $this->plugin_name;
} }
public function get_version() { public function get_version(): string {
return $this->version; return $this->version;
} }
public function get_loader() { public function get_loader(): Peptide_News_Loader {
return $this->loader; return $this->loader;
} }
} }

0
languages/peptide-news.pot Normal file → Executable file
View File

0
package-lock.json generated Normal file → Executable file
View File

0
package.json Normal file → Executable file
View File

13
peptide-news-plugin.php Normal file → Executable file
View File

@@ -1,9 +1,10 @@
<?php <?php
declare( strict_types=1 );
/** /**
* Plugin Name: Peptide News Aggregator * Plugin Name: Peptide News Aggregator
* Plugin URI: https://github.com/peptiderepo/peptide-news-plugin * Plugin URI: https://github.com/peptiderepo/peptide-news-plugin
* Description: Aggregates and displays the latest peptide research news from multiple sources with click analytics and trend reporting. * Description: Aggregates and displays the latest peptide research news from multiple sources with click analytics and trend reporting.
* Version: 2.2.0 * Version: 2.4.0
* Author: Peptide News Team * Author: Peptide News Team
* Author URI: https://github.com/peptiderepo * Author URI: https://github.com/peptiderepo
* License: GPL-2.0+ * License: GPL-2.0+
@@ -22,7 +23,7 @@ if ( ! defined( 'WPINC' ) ) {
/** /**
* Current plugin version — follows SemVer. * Current plugin version — follows SemVer.
*/ */
define( 'PEPTIDE_NEWS_VERSION', '2.2.0' ); define( 'PEPTIDE_NEWS_VERSION', '2.4.0' );
define( 'PEPTIDE_NEWS_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'PEPTIDE_NEWS_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'PEPTIDE_NEWS_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); define( 'PEPTIDE_NEWS_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
define( 'PEPTIDE_NEWS_PLUGIN_BASENAME', plugin_basename( __FILE__ ) ); define( 'PEPTIDE_NEWS_PLUGIN_BASENAME', plugin_basename( __FILE__ ) );
@@ -30,7 +31,7 @@ define( 'PEPTIDE_NEWS_PLUGIN_BASENAME', plugin_basename( __FILE__ ) );
/** /**
* Runs during plugin activation. * Runs during plugin activation.
*/ */
function peptide_news_activate() { function peptide_news_activate(): void {
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-activator.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-activator.php';
Peptide_News_Activator::activate(); Peptide_News_Activator::activate();
} }
@@ -38,7 +39,7 @@ function peptide_news_activate() {
/** /**
* Runs during plugin deactivation. * Runs during plugin deactivation.
*/ */
function peptide_news_deactivate() { function peptide_news_deactivate(): void {
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-deactivator.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-deactivator.php';
Peptide_News_Deactivator::deactivate(); Peptide_News_Deactivator::deactivate();
} }
@@ -56,7 +57,7 @@ require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news.php';
* This handles existing installs that upgrade from 1.0.0 to 1.1.0 * This handles existing installs that upgrade from 1.0.0 to 1.1.0
* without re-activating the plugin. * without re-activating the plugin.
*/ */
function peptide_news_check_db_upgrade() { function peptide_news_check_db_upgrade(): void {
$installed_version = get_option( 'peptide_news_db_version', '1.0.0' ); $installed_version = get_option( 'peptide_news_db_version', '1.0.0' );
if ( version_compare( $installed_version, PEPTIDE_NEWS_VERSION, '<' ) ) { if ( version_compare( $installed_version, PEPTIDE_NEWS_VERSION, '<' ) ) {
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-activator.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-activator.php';
@@ -69,7 +70,7 @@ add_action( 'plugins_loaded', 'peptide_news_check_db_upgrade' );
/** /**
* Begins execution of the plugin. * Begins execution of the plugin.
*/ */
function peptide_news_run() { function peptide_news_run(): void {
$plugin = new Peptide_News(); $plugin = new Peptide_News();
$plugin->run(); $plugin->run();
} }

0
phpunit.xml.dist Normal file → Executable file
View File

17
public/class-peptide-news-public.php Normal file → Executable file
View File

@@ -1,4 +1,5 @@
<?php <?php
declare( strict_types=1 );
/** /**
* Public-facing functionality. * Public-facing functionality.
* *
@@ -16,7 +17,7 @@ class Peptide_News_Public {
/** @var string */ /** @var string */
private $version; private $version;
public function __construct( $plugin_name, $version ) { public function __construct( string $plugin_name, string $version ) {
$this->plugin_name = $plugin_name; $this->plugin_name = $plugin_name;
$this->version = $version; $this->version = $version;
} }
@@ -24,7 +25,7 @@ class Peptide_News_Public {
/** /**
* Register front-end CSS (enqueued on demand by the shortcode/widget). * Register front-end CSS (enqueued on demand by the shortcode/widget).
*/ */
public function enqueue_styles() { public function enqueue_styles(): void {
wp_register_style( wp_register_style(
$this->plugin_name, $this->plugin_name,
PEPTIDE_NEWS_PLUGIN_URL . 'public/css/public-style.css', PEPTIDE_NEWS_PLUGIN_URL . 'public/css/public-style.css',
@@ -40,7 +41,7 @@ class Peptide_News_Public {
* Loads React (from WordPress core), the feed component, and * Loads React (from WordPress core), the feed component, and
* passes configuration via wp_localize_script. * passes configuration via wp_localize_script.
*/ */
public function enqueue_scripts() { public function enqueue_scripts(): void {
// React feed component (depends on WP-bundled React). // React feed component (depends on WP-bundled React).
wp_register_script( wp_register_script(
$this->plugin_name . '-feed', $this->plugin_name . '-feed',
@@ -54,14 +55,14 @@ class Peptide_News_Public {
/** /**
* Register the shortcode. * Register the shortcode.
*/ */
public function register_shortcode() { public function register_shortcode(): void {
add_shortcode( 'peptide_news', array( $this, 'render_shortcode' ) ); add_shortcode( 'peptide_news', array( $this, 'render_shortcode' ) );
} }
/** /**
* Register the widget. * Register the widget.
*/ */
public function register_widget() { public function register_widget(): void {
register_widget( 'Peptide_News_Widget' ); register_widget( 'Peptide_News_Widget' );
} }
@@ -75,7 +76,7 @@ class Peptide_News_Public {
* @param array $atts Shortcode attributes. * @param array $atts Shortcode attributes.
* @return string HTML output. * @return string HTML output.
*/ */
public function render_shortcode( $atts ) { public function render_shortcode( $atts ): string {
$atts = shortcode_atts( array( $atts = shortcode_atts( array(
'count' => get_option( 'peptide_news_articles_count', 10 ), 'count' => get_option( 'peptide_news_articles_count', 10 ),
), $atts, 'peptide_news' ); ), $atts, 'peptide_news' );
@@ -102,7 +103,7 @@ class Peptide_News_Public {
/** /**
* Handle AJAX click tracking with basic rate limiting. * Handle AJAX click tracking with basic rate limiting.
*/ */
public function handle_click_tracking() { public function handle_click_tracking(): void {
check_ajax_referer( 'peptide_news_track', 'nonce' ); check_ajax_referer( 'peptide_news_track', 'nonce' );
$article_id = isset( $_POST['article_id'] ) ? absint( $_POST['article_id'] ) : 0; $article_id = isset( $_POST['article_id'] ) ? absint( $_POST['article_id'] ) : 0;
@@ -142,7 +143,7 @@ class Peptide_News_Public {
* *
* @return string * @return string
*/ */
private function get_or_create_session_id() { private function get_or_create_session_id(): string {
if ( isset( $_COOKIE['pn_session_id'] ) ) { if ( isset( $_COOKIE['pn_session_id'] ) ) {
return sanitize_text_field( wp_unslash( $_COOKIE['pn_session_id'] ) ); return sanitize_text_field( wp_unslash( $_COOKIE['pn_session_id'] ) );
} }

0
public/css/public-style.css Normal file → Executable file
View File

0
public/images/default-thumb.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

0
public/js/peptide-news-feed.js Normal file → Executable file
View File

0
public/js/public-script.js Normal file → Executable file
View File

0
public/react-app/PeptideNewsFeed.jsx Normal file → Executable file
View File

92
tests/bootstrap.php Normal file → Executable file
View File

@@ -118,6 +118,32 @@ if ( ! function_exists( 'add_filter' ) ) {
return true; return true;
} }
} }
if ( ! function_exists( 'get_transient' ) ) {
function get_transient( $transient ) {
global $_test_transients;
return isset( $_test_transients[ $transient ] ) ? $_test_transients[ $transient ] : false;
}
}
if ( ! function_exists( 'set_transient' ) ) {
function set_transient( $transient, $value, $expiration = 0 ) {
global $_test_transients;
$_test_transients[ $transient ] = $value;
return true;
}
}
if ( ! function_exists( 'delete_transient' ) ) {
function delete_transient( $transient ) {
global $_test_transients;
unset( $_test_transients[ $transient ] );
return true;
}
}
if ( ! defined( 'MINUTE_IN_SECONDS' ) ) {
define( 'MINUTE_IN_SECONDS', 60 );
}
if ( ! defined( 'DAY_IN_SECONDS' ) ) {
define( 'DAY_IN_SECONDS', 86400 );
}
if ( ! function_exists( 'add_action' ) ) { if ( ! function_exists( 'add_action' ) ) {
function add_action( $tag, $callback, $priority = 10, $accepted_args = 1 ) { function add_action( $tag, $callback, $priority = 10, $accepted_args = 1 ) {
return true; return true;
@@ -212,6 +238,72 @@ require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-content-filt
// LLM class provides is_valid_model() used by Admin sanitize callbacks. // LLM class provides is_valid_model() used by Admin sanitize callbacks.
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-llm.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-llm.php';
// Stub AJAX-related functions used by cost tracker and other modules.
if ( ! function_exists( 'wp_send_json_success' ) ) {
function wp_send_json_success( $data = null, $status_code = null ) {
global $_test_json_response;
$_test_json_response = array( 'success' => true, 'data' => $data );
}
}
if ( ! function_exists( 'wp_send_json_error' ) ) {
function wp_send_json_error( $data = null, $status_code = null ) {
global $_test_json_response;
$_test_json_response = array( 'success' => false, 'data' => $data );
}
}
if ( ! function_exists( 'check_ajax_referer' ) ) {
function check_ajax_referer( $action = -1, $query_arg = false, $die = true ) {
return true;
}
}
if ( ! function_exists( 'wp_unslash' ) ) {
function wp_unslash( $value ) {
return is_string( $value ) ? stripslashes( $value ) : $value;
}
}
if ( ! function_exists( 'wp_json_encode' ) ) {
function wp_json_encode( $data, $options = 0, $depth = 512 ) {
return json_encode( $data, $options, $depth );
}
}
if ( ! function_exists( 'home_url' ) ) {
function home_url( $path = '' ) {
return 'https://example.com' . $path;
}
}
if ( ! function_exists( 'wp_remote_post' ) ) {
function wp_remote_post( $url, $args = array() ) {
return new WP_Error( 'http_request_not_available', 'HTTP requests not available in tests' );
}
}
if ( ! function_exists( 'wp_remote_retrieve_response_code' ) ) {
function wp_remote_retrieve_response_code( $response ) {
return 200;
}
}
if ( ! function_exists( 'wp_remote_retrieve_body' ) ) {
function wp_remote_retrieve_body( $response ) {
return '';
}
}
if ( ! function_exists( 'wp_remote_retrieve_header' ) ) {
function wp_remote_retrieve_header( $response, $header ) {
return '';
}
}
if ( ! function_exists( 'selected' ) ) {
function selected( $selected, $current = true, $echo = true ) {
$result = (string) $selected === (string) $current ? ' selected="selected"' : '';
if ( $echo ) {
echo $result;
}
return $result;
}
}
// Cost tracker provides calculate_cost(), budget checks used by tests.
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-cost-tracker.php';
// Stub WP_REST_Request for REST API tests. // Stub WP_REST_Request for REST API tests.
if ( ! class_exists( 'WP_REST_Request' ) ) { if ( ! class_exists( 'WP_REST_Request' ) ) {
class WP_REST_Request { class WP_REST_Request {

0
tests/unit/AdminTest.php Normal file → Executable file
View File

0
tests/unit/ContentFilterTest.php Normal file → Executable file
View File

213
tests/unit/CostTrackerTest.php Executable file
View File

@@ -0,0 +1,213 @@
<?php
/**
* Unit tests for Peptide_News_Cost_Tracker.
*
* Tests cost calculation, budget enforcement logic, and model pricing lookups.
* Database-dependent methods (log_api_call, get_cost_summary, etc.) are covered
* by integration tests; these unit tests focus on pure logic.
*
* @since 2.4.0
*/
use PHPUnit\Framework\TestCase;
class CostTrackerTest extends TestCase {
/**
* Reset global test options before each test.
*/
protected function setUp(): void {
global $_test_options;
$_test_options = array();
}
// =============================================
// calculate_cost() — pricing calculation tests
// =============================================
public function test_calculate_cost_with_known_model(): void {
// google/gemini-2.0-flash-001: input $0.10/1M, output $0.40/1M
$cost = Peptide_News_Cost_Tracker::calculate_cost(
'google/gemini-2.0-flash-001',
1000, // 1K prompt tokens
500 // 500 completion tokens
);
// Expected: (1000/1M * 0.10) + (500/1M * 0.40) = 0.0001 + 0.0002 = 0.0003
$this->assertEqualsWithDelta( 0.0003, $cost, 0.000001 );
}
public function test_calculate_cost_with_free_model(): void {
$cost = Peptide_News_Cost_Tracker::calculate_cost(
'google/gemma-3-27b-it:free',
5000,
2000
);
$this->assertEqualsWithDelta( 0.0, $cost, 0.000001 );
}
public function test_calculate_cost_with_unknown_model_returns_zero(): void {
// Unknown models default to zero pricing to avoid overcharging estimates.
$cost = Peptide_News_Cost_Tracker::calculate_cost(
'unknown/model-xyz',
10000,
5000
);
$this->assertEqualsWithDelta( 0.0, $cost, 0.000001 );
}
public function test_calculate_cost_with_expensive_model(): void {
// anthropic/claude-3.5-sonnet: input $3.00/1M, output $15.00/1M
$cost = Peptide_News_Cost_Tracker::calculate_cost(
'anthropic/claude-3.5-sonnet',
2000, // 2K prompt tokens
1000 // 1K completion tokens
);
// Expected: (2000/1M * 3.00) + (1000/1M * 15.00) = 0.006 + 0.015 = 0.021
$this->assertEqualsWithDelta( 0.021, $cost, 0.000001 );
}
public function test_calculate_cost_with_zero_tokens(): void {
$cost = Peptide_News_Cost_Tracker::calculate_cost(
'anthropic/claude-3.5-sonnet',
0,
0
);
$this->assertEqualsWithDelta( 0.0, $cost, 0.000001 );
}
public function test_calculate_cost_precision_with_large_token_count(): void {
// Simulate a large batch: 1M prompt tokens with gpt-4o ($2.50/1M)
$cost = Peptide_News_Cost_Tracker::calculate_cost(
'openai/gpt-4o',
1000000, // 1M prompt tokens
500000 // 500K completion tokens
);
// Expected: (1M/1M * 2.50) + (500K/1M * 10.00) = 2.50 + 5.00 = 7.50
$this->assertEqualsWithDelta( 7.5, $cost, 0.000001 );
}
// =============================================
// get_model_pricing() — pricing lookup tests
// =============================================
public function test_get_model_pricing_returns_known_defaults(): void {
$pricing = Peptide_News_Cost_Tracker::get_model_pricing( 'openai/gpt-4o-mini' );
$this->assertArrayHasKey( 'input', $pricing );
$this->assertArrayHasKey( 'output', $pricing );
$this->assertEqualsWithDelta( 0.15, $pricing['input'], 0.001 );
$this->assertEqualsWithDelta( 0.60, $pricing['output'], 0.001 );
}
public function test_get_model_pricing_unknown_model_returns_zero(): void {
$pricing = Peptide_News_Cost_Tracker::get_model_pricing( 'totally/unknown-model' );
$this->assertEqualsWithDelta( 0.0, $pricing['input'], 0.001 );
$this->assertEqualsWithDelta( 0.0, $pricing['output'], 0.001 );
}
public function test_get_model_pricing_respects_custom_overrides(): void {
global $_test_options;
$_test_options['peptide_news_custom_model_pricing'] = array(
'custom/my-model' => array( 'input' => 1.50, 'output' => 5.00 ),
);
$pricing = Peptide_News_Cost_Tracker::get_model_pricing( 'custom/my-model' );
$this->assertEqualsWithDelta( 1.50, $pricing['input'], 0.001 );
$this->assertEqualsWithDelta( 5.00, $pricing['output'], 0.001 );
}
public function test_custom_pricing_overrides_defaults(): void {
global $_test_options;
// Override the default pricing for gemini flash.
$_test_options['peptide_news_custom_model_pricing'] = array(
'google/gemini-2.0-flash-001' => array( 'input' => 0.50, 'output' => 1.00 ),
);
$pricing = Peptide_News_Cost_Tracker::get_model_pricing( 'google/gemini-2.0-flash-001' );
$this->assertEqualsWithDelta( 0.50, $pricing['input'], 0.001 );
$this->assertEqualsWithDelta( 1.00, $pricing['output'], 0.001 );
}
// =============================================
// is_budget_exceeded() — budget enforcement tests
// =============================================
public function test_budget_disabled_never_exceeds(): void {
global $_test_options;
$_test_options['peptide_news_budget_mode'] = 'disabled';
$_test_options['peptide_news_monthly_budget'] = 10.0;
$this->assertFalse( Peptide_News_Cost_Tracker::is_budget_exceeded() );
}
public function test_budget_zero_limit_never_exceeds(): void {
global $_test_options;
$_test_options['peptide_news_budget_mode'] = 'hard_stop';
$_test_options['peptide_news_monthly_budget'] = 0.0;
$this->assertFalse( Peptide_News_Cost_Tracker::is_budget_exceeded() );
}
public function test_budget_warn_only_never_exceeds(): void {
global $_test_options;
$_test_options['peptide_news_budget_mode'] = 'warn_only';
$_test_options['peptide_news_monthly_budget'] = 1.0;
// warn_only should never return true (it only logs warnings).
$this->assertFalse( Peptide_News_Cost_Tracker::is_budget_exceeded() );
}
// =============================================
// DEFAULT_MODEL_PRICING — structure validation
// =============================================
public function test_all_default_pricing_has_required_keys(): void {
foreach ( Peptide_News_Cost_Tracker::DEFAULT_MODEL_PRICING as $model => $pricing ) {
$this->assertArrayHasKey( 'input', $pricing, "Model {$model} missing 'input' key" );
$this->assertArrayHasKey( 'output', $pricing, "Model {$model} missing 'output' key" );
$this->assertIsFloat( $pricing['input'], "Model {$model} 'input' should be float" );
$this->assertIsFloat( $pricing['output'], "Model {$model} 'output' should be float" );
$this->assertGreaterThanOrEqual( 0.0, $pricing['input'], "Model {$model} 'input' should be non-negative" );
$this->assertGreaterThanOrEqual( 0.0, $pricing['output'], "Model {$model} 'output' should be non-negative" );
}
}
public function test_default_pricing_includes_key_models(): void {
$required_models = array(
'google/gemini-2.0-flash-001',
'anthropic/claude-3.5-sonnet',
'openai/gpt-4o-mini',
);
foreach ( $required_models as $model ) {
$this->assertArrayHasKey(
$model,
Peptide_News_Cost_Tracker::DEFAULT_MODEL_PRICING,
"Default pricing should include {$model}"
);
}
}
// =============================================
// Budget mode constants — sanity checks
// =============================================
public function test_budget_mode_constants_are_distinct(): void {
$modes = array(
Peptide_News_Cost_Tracker::BUDGET_MODE_HARD_STOP,
Peptide_News_Cost_Tracker::BUDGET_MODE_WARN_ONLY,
Peptide_News_Cost_Tracker::BUDGET_MODE_DISABLED,
);
$this->assertCount( 3, array_unique( $modes ), 'Budget mode constants must be distinct' );
}
}

0
tests/unit/FetcherTest.php Normal file → Executable file
View File

82
tests/unit/LlmUsageTest.php Executable file
View File

@@ -0,0 +1,82 @@
<?php
/**
* Unit tests for LLM cost tracking integration.
*
* Tests that call_openrouter_with_usage() correctly structures its
* return array and that the backward-compatible call_openrouter()
* wrapper still works.
*
* @since 2.4.0
*/
use PHPUnit\Framework\TestCase;
class LlmUsageTest extends TestCase {
protected function setUp(): void {
global $_test_options;
$_test_options = array();
}
// =============================================
// is_valid_model() — unchanged, regression check
// =============================================
public function test_valid_model_format(): void {
$this->assertTrue( Peptide_News_LLM::is_valid_model( 'google/gemini-2.0-flash-001' ) );
$this->assertTrue( Peptide_News_LLM::is_valid_model( 'anthropic/claude-3.5-sonnet' ) );
$this->assertTrue( Peptide_News_LLM::is_valid_model( 'google/gemma-3-27b-it:free' ) );
}
public function test_invalid_model_format(): void {
$this->assertFalse( Peptide_News_LLM::is_valid_model( '' ) );
$this->assertFalse( Peptide_News_LLM::is_valid_model( 'no-slash-model' ) );
$this->assertFalse( Peptide_News_LLM::is_valid_model( 'has spaces/model' ) );
}
// =============================================
// is_enabled() — budget-aware checks
// =============================================
public function test_is_enabled_false_when_no_api_key(): void {
global $_test_options;
$_test_options['peptide_news_llm_enabled'] = 1;
$_test_options['peptide_news_openrouter_api_key'] = '';
$this->assertFalse( Peptide_News_LLM::is_enabled() );
}
public function test_is_enabled_false_when_disabled(): void {
global $_test_options;
$_test_options['peptide_news_llm_enabled'] = 0;
$_test_options['peptide_news_openrouter_api_key'] = 'sk-test-key';
$this->assertFalse( Peptide_News_LLM::is_enabled() );
}
// =============================================
// process_article() — budget gate behavior
// =============================================
public function test_process_article_returns_early_when_disabled(): void {
global $_test_options;
$_test_options['peptide_news_llm_enabled'] = 0;
$_test_options['peptide_news_openrouter_api_key'] = '';
$article = (object) array(
'id' => 1,
'title' => 'Test Article',
'excerpt' => 'Test excerpt.',
'content' => 'Test content.',
'categories' => 'test',
'tags' => '',
'ai_summary' => '',
);
$result = Peptide_News_LLM::process_article( $article );
$this->assertFalse( $result['success'] );
$this->assertEmpty( $result['keywords'] );
$this->assertEmpty( $result['summary'] );
}
}

0
tests/unit/PluginBootstrapTest.php Normal file → Executable file
View File

0
tests/unit/RestApiTest.php Normal file → Executable file
View File

6
uninstall.php Normal file → Executable file
View File

@@ -1,13 +1,17 @@
<?php <?php
declare( strict_types=1 );
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) { if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
exit; exit;
} }
global $wpdb; global $wpdb;
$prefix = $wpdb->prefix; $prefix = $wpdb->prefix;
// Drop custom tables. // Drop custom tables (order matters: child tables first due to FK constraints).
$wpdb->query( "DROP TABLE IF EXISTS {$prefix}peptide_news_clicks" ); $wpdb->query( "DROP TABLE IF EXISTS {$prefix}peptide_news_clicks" );
$wpdb->query( "DROP TABLE IF EXISTS {$prefix}peptide_news_daily_stats" ); $wpdb->query( "DROP TABLE IF EXISTS {$prefix}peptide_news_daily_stats" );
$wpdb->query( "DROP TABLE IF EXISTS {$prefix}peptide_news_log" );
$wpdb->query( "DROP TABLE IF EXISTS {$prefix}peptide_news_llm_costs" );
$wpdb->query( "DROP TABLE IF EXISTS {$prefix}peptide_news_articles" ); $wpdb->query( "DROP TABLE IF EXISTS {$prefix}peptide_news_articles" );
// Delete all plugin options. // Delete all plugin options.