Compare commits

..

4 Commits

Author SHA1 Message Date
peptiderepo
6224b24cf8 ci: stop exposing CTO_ADMIN_TOKEN as env var (security cleanup)
Pass the PAT only via github-token param. continue-on-error on the
budget step so a missing/expired secret degrades gracefully instead
of failing the whole workflow.

Agent-Session: cowork-reviewer-ab-20260414
2026-04-14 17:34:59 +07:00
peptiderepo
32176faaf4 ci: address review feedback — per-model timeout + simpler cost math
- Per-model timeout of 60s so one hung model does not block siblings
- Simplify cost_cents math (cost_usd * 100 directly, no double-rounding)

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

Agent-Session: cowork-reviewer-ab-20260414
2026-04-14 17:31:56 +07:00
peptiderepo
2de786a144 ci: use CTO_ADMIN_TOKEN for repo variables + restore formal APPROVE/REQUEST_CHANGES
Default GITHUB_TOKEN cannot mutate Repository Variables (always 403).
Use a PAT-backed secret CTO_ADMIN_TOKEN for the budget counter steps.
Also restores formal APPROVE/REQUEST_CHANGES verdicts now that repo
setting can_approve_pull_request_reviews is enabled.

Agent-Session: cowork-reviewer-ab-20260414
2026-04-14 17:27:41 +07:00
peptiderepo
de76f26b1a ci: cost cap + 3-model A/B harness on CTO reviewer
Two enhancements to the PR reviewer:

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

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

Agent-Session: cowork-reviewer-ab-20260414
2026-04-14 17:22:20 +07:00
12 changed files with 1041 additions and 1570 deletions

View File

@@ -1,20 +1,15 @@
name: CTO Review
# Automated PR review powered by Claude + DeepSeek + Kimi (via OpenRouter)
# plus Neurometric PR-Summarizer (a Qwen3-4B task-specialist SLM).
# Automated PR review powered by Claude + DeepSeek + Kimi via OpenRouter.
#
# During the A/B window (until further notice), every PR gets reviewed by FOUR models
# During the A/B window (until further notice), every PR gets reviewed by THREE models
# in parallel. The PR comment shows each model's verdict side-by-side so we can compare
# quality/cost on real PRs before picking a permanent default.
#
# Neurometric is a "prose voice": it does not support OpenAI tool-calling, so its
# contribution is raw markdown + a heuristic verdict. It is never the primary voice.
#
# Does NOT merge. Does NOT post formal APPROVE/REQUEST_CHANGES reviews — always COMMENT
# (GITHUB_TOKEN can't formally approve PRs without repo-setting toggle).
#
# Requires repo secrets: OPENROUTER_API_KEY, NEUROMETRIC_API_KEY (optional; the
# Neurometric voice self-skips with an inline error note if the key is missing).
# Requires repo secret: OPENROUTER_API_KEY
#
# Cost cap: tracked in Repository Variables (CTO_REVIEW_SPEND_CENTS, CTO_REVIEW_CAP_CENTS).
# Default cap is 1000 cents ($10/month). Auto-resets on the 1st of each month.
@@ -150,12 +145,11 @@ jobs:
echo "EOF_CONV"
} >> "$GITHUB_OUTPUT"
- name: Call 4 models in parallel (OpenRouter + Neurometric)
- name: Call 3 models in parallel via OpenRouter
if: steps.budget.outputs.skip != 'true'
id: review
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
NEUROMETRIC_API_KEY: ${{ secrets.NEUROMETRIC_API_KEY }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
@@ -187,19 +181,14 @@ jobs:
api_key = os.environ["OPENROUTER_API_KEY"]
# A/B harness: four models. Each gets the same prompt.
# A/B harness: three models. Each gets the same prompt.
# Primary model's verdict drives the heading. Others are labeled.
# Edit this list to change the roster (or drop to 1 after A/B window closes).
#
# provider_type semantics:
# - "openrouter_tools": OpenAI tool-calling via OpenRouter; structured verdict.
# - "neurometric_prose": Neurometric marketplace SLM; prose output + heuristic verdict.
MODELS = [
# (slug, display name, input_per_M_USD, output_per_M_USD, is_primary, provider_type)
("anthropic/claude-haiku-4.5", "Claude Haiku 4.5", 1.00, 5.00, True, "openrouter_tools"),
("deepseek/deepseek-chat-v3.1", "DeepSeek V3.1", 0.15, 0.75, False, "openrouter_tools"),
("moonshotai/kimi-k2-thinking", "Kimi K2 Thinking", 0.60, 2.50, False, "openrouter_tools"),
("neurometric/pr-summarizer", "Neurometric PR-Summarizer (Qwen3-4B)", 0.00, 0.00, False, "neurometric_prose"),
# (openrouter slug, display name, input_per_M_USD, output_per_M_USD, is_primary)
("anthropic/claude-haiku-4.5", "Claude Haiku 4.5", 1.00, 5.00, True),
("deepseek/deepseek-chat-v3.1", "DeepSeek V3.1", 0.15, 0.75, False),
("moonshotai/kimi-k2-thinking", "Kimi K2 Thinking", 0.60, 2.50, False),
]
system = """You are the CTO and lead reviewer for the peptiderepo project. You are reviewing a pull request opened by an autonomous agent or human contributor.
@@ -262,28 +251,7 @@ jobs:
PER_MODEL_TIMEOUT = 60 # seconds; one hung model should not block siblings
# Heuristic verdict inference for prose-voice models that can't do tool-calling.
# Keyword order matters: block markers beat approve markers.
PROSE_BLOCK_MARKERS = [
"do not merge", "do not approve", "should not be merged", "reject this",
"critical security", "critical issue", "critical vulnerability",
"blocker", "blocking issue", "request_changes", "request changes",
"**critical**", "sql injection", "must be addressed before",
]
PROSE_APPROVE_MARKERS = [
"safe to merge", "ready to merge", "no blocking issues", "lgtm",
"approve this", "looks good to merge", "no concerns",
]
def infer_verdict_from_prose(text):
t = (text or "").lower()
if any(m in t for m in PROSE_BLOCK_MARKERS):
return "REQUEST_CHANGES"
if any(m in t for m in PROSE_APPROVE_MARKERS):
return "APPROVE"
return "COMMENT"
def call_openrouter_tools(slug, display, in_rate, out_rate, is_primary):
def call_model(slug, display, in_rate, out_rate, is_primary):
body = {
"model": slug,
"max_tokens": 2048,
@@ -311,7 +279,6 @@ jobs:
except Exception as e:
return {
"slug": slug, "display": display, "is_primary": is_primary,
"provider_type": "openrouter_tools",
"error": str(e)[:300],
"cost_cents": 0,
}
@@ -319,9 +286,9 @@ jobs:
choice = data.get("choices", [{}])[0].get("message", {})
tool_calls = choice.get("tool_calls", [])
if not tool_calls:
# Some non-Anthropic providers occasionally put the tool args in content.
return {
"slug": slug, "display": display, "is_primary": is_primary,
"provider_type": "openrouter_tools",
"error": f"No tool_call in response. Raw content: {str(choice)[:300]}",
"cost_cents": 0,
}
@@ -331,7 +298,6 @@ jobs:
except Exception as e:
return {
"slug": slug, "display": display, "is_primary": is_primary,
"provider_type": "openrouter_tools",
"error": f"Malformed tool args: {e}",
"cost_cents": 0,
}
@@ -343,62 +309,10 @@ jobs:
cost_cents = cost_usd * 100
return {
"slug": slug, "display": display, "is_primary": is_primary,
"provider_type": "openrouter_tools",
"review": review, "prompt_tokens": ptok, "completion_tokens": ctok,
"cost_cents": cost_cents,
}
def call_neurometric_prose(slug, display, is_primary):
nm_key = os.environ.get("NEUROMETRIC_API_KEY", "")
if not nm_key:
return {
"slug": slug, "display": display, "is_primary": is_primary,
"provider_type": "neurometric_prose",
"error": "NEUROMETRIC_API_KEY secret not set on this repo",
"cost_cents": 0,
}
body = {
"model": slug,
"max_tokens": 2048,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user_message},
],
}
req = urllib.request.Request(
"https://api.neurometric.ai/v1/chat/completions",
data=json.dumps(body).encode(),
headers={
"Authorization": f"Bearer {nm_key}",
"content-type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=PER_MODEL_TIMEOUT) as resp:
data = json.loads(resp.read())
except Exception as e:
return {
"slug": slug, "display": display, "is_primary": is_primary,
"provider_type": "neurometric_prose",
"error": str(e)[:300],
"cost_cents": 0,
}
prose = data.get("choices", [{}])[0].get("message", {}).get("content") or ""
verdict = infer_verdict_from_prose(prose)
return {
"slug": slug, "display": display, "is_primary": is_primary,
"provider_type": "neurometric_prose",
"review": {"verdict": verdict, "prose": prose},
"prompt_tokens": 0, "completion_tokens": 0,
"cost_cents": 0,
}
def call_model(slug, display, in_rate, out_rate, is_primary, provider_type="openrouter_tools"):
if provider_type == "neurometric_prose":
return call_neurometric_prose(slug, display, is_primary)
return call_openrouter_tools(slug, display, in_rate, out_rate, is_primary)
results = []
with concurrent.futures.ThreadPoolExecutor(max_workers=len(MODELS)) as pool:
futures = [pool.submit(call_model, *m) for m in MODELS]
@@ -428,21 +342,9 @@ jobs:
continue
rev = r["review"]
lines.append("")
if r.get("provider_type") == "neurometric_prose":
lines.append(f"**Verdict:** {rev['verdict']} _(heuristic, inferred from prose)_")
lines.append(f"**Verdict:** {rev['verdict']}")
lines.append("")
# Cap at 6KB to keep PR comments readable; 4B model output is rarely over this
prose = rev.get("prose", "")
if len(prose) > 6000:
prose = prose[:6000] + "\n\n_…output truncated for comment length…_"
lines.append(prose)
lines.append("")
lines.append("_free tier; per-call token usage not tracked_")
lines.append("")
continue
lines.append(f"**Verdict:** {rev.get('verdict', 'COMMENT')}")
lines.append("")
lines.append(rev.get("summary") or "_(model returned no summary)_")
lines.append(rev["summary"])
lines.append("")
if rev.get("concerns"):
lines.append("**Concerns:**")

View File

@@ -1,7 +1,5 @@
# Peptide News Aggregator - Architecture
> **Cross-app context:** decisions that affect multiple plugins (Cloudflare AI Gateway routing, OpenRouter account sharing, the interface pattern, image-generation stack, social distributor choice) are recorded in `Peptide Repo CTO/docs/engineering/decisions/`. The incident runbook for cross-app failure modes is at `Peptide Repo CTO/docs/engineering/INCIDENT-RUNBOOK.md`. Read both before making decisions that cross plugin boundaries.
**Version:** 2.4.0
**Last updated:** April 2026
@@ -43,12 +41,7 @@ peptide-news-plugin/
│ └── class-peptide-news-logger.php # Structured DB logging with auto-prune
│
├── admin/
│ ├── class-peptide-news-admin.php # Main orchestrator (delegator): preserves public interface
│ ├── class-pn-admin-assets.php # CSS/JS enqueuing for admin pages
│ ├── class-pn-admin-menu.php # Admin menu & submenu registration
│ ├── class-pn-admin-settings.php # Settings registration & field renderers (7 sections, 25+ fields)
│ ├── class-pn-admin-settings-page.php # Settings page render + plugin log viewer + AJAX handlers
│ ├── class-pn-admin-dashboard-pages.php # Dashboard, articles, cost dashboard pages
│ ├── 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/

File diff suppressed because it is too large Load Diff

View File

@@ -1,88 +0,0 @@
<?php
declare( strict_types=1 );
/**
* Admin asset enqueuing.
*
* Handles CSS and JavaScript enqueuing for admin pages,
* including Chart.js library for analytics and cost dashboards.
*
* @since 2.5.0
* @see Peptide_News_Admin — Main admin orchestrator
*/
class Peptide_News_Admin_Assets {
/** @var string Plugin name/slug */
private $plugin_name;
/** @var string Plugin version for cache busting */
private $version;
/**
* Constructor.
*
* @param string $plugin_name Plugin name/slug.
* @param string $version Plugin version.
*/
public function __construct( string $plugin_name, string $version ) {
$this->plugin_name = $plugin_name;
$this->version = $version;
}
/**
* Enqueue admin CSS.
*
* @param string $hook Current admin page hook.
* @return void
*/
public function enqueue_styles( string $hook ): void {
if ( strpos( $hook, 'peptide-news' ) === false ) {
return;
}
wp_enqueue_style(
$this->plugin_name . '-admin',
PEPTIDE_NEWS_PLUGIN_URL . 'admin/css/admin-style.css',
array(),
$this->version,
'all'
);
}
/**
* Enqueue admin JavaScript.
*
* Includes Chart.js for analytics and cost dashboards,
* plus the main admin script with AJAX handlers and localized data.
*
* @param string $hook Current admin page hook.
* @return void
*/
public function enqueue_scripts( string $hook ): void {
if ( strpos( $hook, 'peptide-news' ) === false ) {
return;
}
// Chart.js for analytics dashboard.
wp_enqueue_script(
'chartjs',
plugins_url( 'vendor/chartjs/chart.umd.min.js', __FILE__ ),
array(),
'4.4.0',
true
);
wp_enqueue_script(
$this->plugin_name . '-admin',
PEPTIDE_NEWS_PLUGIN_URL . 'admin/js/admin-script.js',
array( 'jquery', 'chartjs' ),
$this->version,
true
);
wp_localize_script( $this->plugin_name . '-admin', 'peptideNewsAdmin', array(
'ajax_url' => admin_url( 'admin-ajax.php' ),
'rest_url' => rest_url( 'peptide-news/v1/' ),
'nonce' => wp_create_nonce( 'wp_rest' ),
'admin_nonce' => wp_create_nonce( 'peptide_news_admin' ),
) );
}
}

View File

@@ -1,109 +0,0 @@
<?php
declare( strict_types=1 );
/**
* Dashboard, articles, and cost dashboard page rendering.
*
* Handles the main analytics dashboard, article list management,
* and LLM cost tracking dashboard. Includes CSV export for analytics.
*
* @since 2.5.0
* @see Peptide_News_Admin — Main admin orchestrator
* @see admin/partials/dashboard.php — Analytics dashboard partial
* @see admin/partials/articles-list.php — Article list partial
* @see admin/partials/cost-dashboard.php — Cost dashboard partial
*/
class Peptide_News_Admin_Dashboard_Pages {
/**
* Render the analytics dashboard page.
*
* Includes date filtering and calls the dashboard partial.
* Handles CSV export before rendering.
*
* @return void
*/
public function render_dashboard_page(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
// Handle CSV export before any HTML output.
if ( isset( $_GET['export'] ) && 'csv' === $_GET['export'] ) {
check_admin_referer( 'peptide_news_export_csv' );
$this->export_csv();
return;
}
include PEPTIDE_NEWS_PLUGIN_DIR . 'admin/partials/dashboard.php';
}
/**
* Stream a CSV export of click analytics data.
*
* Respects date range filters from GET parameters.
* Defaults to last 30 days if not specified.
*
* @return void
*/
private function export_csv(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Unauthorized.', 'peptide-news' ) );
}
$raw_start = isset( $_GET['start_date'] ) ? sanitize_text_field( wp_unslash( $_GET['start_date'] ) ) : '';
$raw_end = isset( $_GET['end_date'] ) ? sanitize_text_field( wp_unslash( $_GET['end_date'] ) ) : '';
$start_date = preg_match( '/^\d{4}-\d{2}-\d{2}$/', $raw_start ) ? $raw_start : gmdate( 'Y-m-d', strtotime( '-30 days' ) );
$end_date = preg_match( '/^\d{4}-\d{2}-\d{2}$/', $raw_end ) ? $raw_end : gmdate( 'Y-m-d' );
$data = Peptide_News_Analytics::export_clicks_csv( $start_date, $end_date );
$filename = 'peptide-news-clicks-' . $start_date . '-to-' . $end_date . '.csv';
header( 'Content-Type: text/csv; charset=utf-8' );
header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
header( 'Pragma: no-cache' );
header( 'Expires: 0' );
$output = fopen( 'php://output', 'w' );
if ( ! empty( $data ) ) {
fputcsv( $output, array_keys( $data[0] ) );
foreach ( $data as $row ) {
fputcsv( $output, $row );
}
} else {
fputcsv( $output, array( 'No data for selected period' ) );
}
fclose( $output );
exit;
}
/**
* Render the articles management page.
*
* @return void
*/
public function render_articles_page(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
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
* @return void
*/
public function render_cost_dashboard_page(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
include PEPTIDE_NEWS_PLUGIN_DIR . 'admin/partials/cost-dashboard.php';
}
}

View File

@@ -1,325 +0,0 @@
<?php
declare( strict_types=1 );
/**
* Settings field rendering.
*
* Provides render methods for all 25+ WordPress settings fields
* across 7 sections. Used by Peptide_News_Admin_Settings during
* add_settings_field() calls.
*
* @since 2.5.0
* @see Peptide_News_Admin_Settings — Calls these renderers
*/
class Peptide_News_Admin_Field_Renderers {
/**
* Get a masked version of an API key showing only the last 4 characters.
*
* 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).
*/
public static function mask_api_key( string $key ): string {
if ( empty( $key ) ) {
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 );
if ( $len <= 4 ) {
return str_repeat( '•', $len );
}
return str_repeat( '•', $len - 4 ) . substr( $key, -4 );
}
public static function render_fetch_interval_field(): void {
$value = get_option( 'peptide_news_fetch_interval', 'twicedaily' );
$options = array(
'every_fifteen_minutes' => __( 'Every 15 minutes', 'peptide-news' ),
'every_thirty_minutes' => __( 'Every 30 minutes', 'peptide-news' ),
'hourly' => __( 'Hourly', 'peptide-news' ),
'every_four_hours' => __( 'Every 4 hours', 'peptide-news' ),
'every_six_hours' => __( 'Every 6 hours', 'peptide-news' ),
'twicedaily' => __( 'Twice daily', 'peptide-news' ),
'daily' => __( 'Daily', 'peptide-news' ),
);
echo '<select name="peptide_news_fetch_interval" id="peptide_news_fetch_interval">';
foreach ( $options as $key => $label ) {
printf(
'<option value="%s"%s>%s</option>',
esc_attr( $key ),
selected( $value, $key, false ),
esc_html( $label )
);
}
echo '</select>';
echo '<p class="description">' . esc_html__( 'How often to check for new articles. After changing, deactivate and reactivate the plugin.', 'peptide-news' ) . '</p>';
}
public static function render_articles_count_field(): void {
$value = get_option( 'peptide_news_articles_count', 10 );
printf(
'<input type="number" name="peptide_news_articles_count" value="%d" min="1" max="100" class="small-text" />',
absint( $value )
);
echo '<p class="description">' . esc_html__( 'Number of articles to display in the shortcode/widget.', 'peptide-news' ) . '</p>';
}
public static function render_search_keywords_field(): void {
$value = get_option( 'peptide_news_search_keywords', '' );
printf(
'<input type="text" name="peptide_news_search_keywords" value="%s" class="regular-text" />',
esc_attr( $value )
);
echo '<p class="description">' . esc_html__( 'Comma-separated keywords for NewsAPI searches.', 'peptide-news' ) . '</p>';
}
public static function render_rss_enabled_field(): void {
$value = get_option( 'peptide_news_rss_enabled', 1 );
printf(
'<label><input type="checkbox" name="peptide_news_rss_enabled" value="1" %s /> %s</label>',
checked( $value, 1, false ),
esc_html__( 'Fetch articles from RSS feeds', 'peptide-news' )
);
}
public static function render_rss_feeds_field(): void {
$value = get_option( 'peptide_news_rss_feeds', '' );
printf(
'<textarea name="peptide_news_rss_feeds" rows="6" class="large-text code">%s</textarea>',
esc_textarea( $value )
);
}
public static function render_newsapi_enabled_field(): void {
$value = get_option( 'peptide_news_newsapi_enabled', 0 );
printf(
'<label><input type="checkbox" name="peptide_news_newsapi_enabled" value="1" %s /> %s</label>',
checked( $value, 1, false ),
esc_html__( 'Fetch articles from NewsAPI.org', 'peptide-news' )
);
}
public static function render_newsapi_key_field(): void {
$value = get_option( 'peptide_news_newsapi_key', '' );
$display_value = ! empty( $value ) ? self::mask_api_key( $value ) : '';
printf(
'<input type="password" name="peptide_news_newsapi_key" value="%s" class="regular-text" placeholder="%s" />',
esc_attr( $display_value ),
esc_attr__( 'Enter new API key to change', 'peptide-news' )
);
}
public static function render_article_retention_field(): void {
$value = get_option( 'peptide_news_article_retention', 90 );
printf(
'<input type="number" name="peptide_news_article_retention" value="%d" min="7" max="3650" class="small-text" />',
absint( $value )
);
echo '<p class="description">' . esc_html__( 'How long to keep fetched articles before deactivating them.', 'peptide-news' ) . '</p>';
}
public static function render_retention_field(): void {
$value = get_option( 'peptide_news_analytics_retention', 365 );
printf(
'<input type="number" name="peptide_news_analytics_retention" value="%d" min="30" max="3650" class="small-text" />',
absint( $value )
);
echo '<p class="description">' . esc_html__( 'How long to keep click analytics data.', 'peptide-news' ) . '</p>';
}
public static function render_anonymize_ip_field(): void {
$value = get_option( 'peptide_news_anonymize_ip', 1 );
printf(
'<label><input type="checkbox" name="peptide_news_anonymize_ip" value="1" %s /> %s</label>',
checked( $value, 1, false ),
esc_html__( 'Anonymize IP addresses for GDPR compliance', 'peptide-news' )
);
}
public static function render_filter_enabled_field(): void {
$value = get_option( 'peptide_news_filter_enabled', 1 );
printf(
'<label><input type="checkbox" name="peptide_news_filter_enabled" value="1" %s /> %s</label>',
checked( $value, 1, false ),
esc_html__( 'Filter out ads, press releases, and promotional content during fetch', 'peptide-news' )
);
}
public static function render_filter_sensitivity_field(): void {
$value = get_option( 'peptide_news_filter_sensitivity', 'moderate' );
$options = array(
'strict' => __( 'Strict — block aggressively (may catch some legitimate articles)', 'peptide-news' ),
'moderate' => __( 'Moderate — balanced filtering (recommended)', 'peptide-news' ),
'lenient' => __( 'Lenient — only block obvious promotional content', 'peptide-news' ),
);
echo '<select name="peptide_news_filter_sensitivity" id="peptide_news_filter_sensitivity">';
foreach ( $options as $key => $label ) {
printf(
'<option value="%s"%s>%s</option>',
esc_attr( $key ),
selected( $value, $key, false ),
esc_html( $label )
);
}
echo '</select>';
}
public static function render_filter_llm_enabled_field(): void {
$value = get_option( 'peptide_news_filter_llm_enabled', 0 );
printf(
'<label><input type="checkbox" name="peptide_news_filter_llm_enabled" value="1" %s /> %s</label>',
checked( $value, 1, false ),
esc_html__( 'Use LLM to classify borderline articles (requires AI Analysis to be enabled)', 'peptide-news' )
);
}
public static function render_filter_llm_model_field(): void {
$value = get_option( 'peptide_news_filter_llm_model', '' );
printf(
'<input type="text" name="peptide_news_filter_llm_model" value="%s" class="regular-text" />',
esc_attr( $value )
);
echo '<p class="description">' . esc_html__( 'OpenRouter model for content classification. Leave blank to use the Keywords Model above.', 'peptide-news' ) . '</p>';
}
public static function render_filter_title_keywords_field(): void {
$value = get_option( 'peptide_news_filter_title_keywords', '' );
$placeholder = class_exists( 'Peptide_News_Content_Filter' )
? Peptide_News_Content_Filter::get_default_title_keywords_text()
: '';
printf(
'<textarea name="peptide_news_filter_title_keywords" rows="8" class="large-text code" placeholder="%s">%s</textarea>',
esc_attr( $placeholder ),
esc_textarea( $value )
);
echo '<p class="description">' . esc_html__( 'One keyword/phrase per line. Articles with these in the title are blocked. Leave empty to use built-in defaults.', 'peptide-news' ) . '</p>';
}
public static function render_filter_body_keywords_field(): void {
$value = get_option( 'peptide_news_filter_body_keywords', '' );
$placeholder = class_exists( 'Peptide_News_Content_Filter' )
? Peptide_News_Content_Filter::get_default_body_keywords_text()
: '';
printf(
'<textarea name="peptide_news_filter_body_keywords" rows="8" class="large-text code" placeholder="%s">%s</textarea>',
esc_attr( $placeholder ),
esc_textarea( $value )
);
echo '<p class="description">' . esc_html__( 'One keyword/phrase per line. Multiple matches in article body trigger filtering (threshold depends on sensitivity). Leave empty to use built-in defaults.', 'peptide-news' ) . '</p>';
}
public static function render_filter_blocked_domains_field(): void {
$value = get_option( 'peptide_news_filter_blocked_domains', '' );
$placeholder = class_exists( 'Peptide_News_Content_Filter' )
? Peptide_News_Content_Filter::get_default_blocked_domains_text()
: '';
printf(
'<textarea name="peptide_news_filter_blocked_domains" rows="6" class="large-text code" placeholder="%s">%s</textarea>',
esc_attr( $placeholder ),
esc_textarea( $value )
);
echo '<p class="description">' . esc_html__( 'One domain per line. Articles from these sources are always blocked. Leave empty to use built-in defaults.', 'peptide-news' ) . '</p>';
}
public static function render_llm_enabled_field(): void {
$value = get_option( 'peptide_news_llm_enabled', 0 );
printf(
'<label><input type="checkbox" name="peptide_news_llm_enabled" value="1" %s /> %s</label>',
checked( $value, 1, false ),
esc_html__( 'Analyze articles with AI during each fetch cycle', 'peptide-news' )
);
}
public static function render_openrouter_key_field(): void {
$value = get_option( 'peptide_news_openrouter_api_key', '' );
$display_value = ! empty( $value ) ? self::mask_api_key( $value ) : '';
printf(
'<input type="password" name="peptide_news_openrouter_api_key" value="%s" class="regular-text" placeholder="%s" />',
esc_attr( $display_value ),
esc_attr__( 'Enter new API key to change', 'peptide-news' )
);
echo '<p class="description">' . wp_kses(
__( 'Get an API key at <a href="https://openrouter.ai/keys" target="_blank" rel="noopener">openrouter.ai/keys</a>.', 'peptide-news' ),
array(
'a' => array(
'href' => array(),
'target' => array(),
'rel' => array(),
),
)
) . '</p>';
}
public static function render_llm_keywords_model_field(): void {
$value = get_option( 'peptide_news_llm_keywords_model', 'google/gemini-2.0-flash-001' );
printf(
'<input type="text" name="peptide_news_llm_keywords_model" value="%s" class="regular-text" />',
esc_attr( $value )
);
echo '<p class="description">' . esc_html__( 'OpenRouter model ID for keyword extraction (e.g., google/gemini-2.0-flash-001, openai/gpt-4o-mini).', 'peptide-news' ) . '</p>';
}
public static function render_llm_summary_model_field(): void {
$value = get_option( 'peptide_news_llm_summary_model', 'google/gemini-2.0-flash-001' );
printf(
'<input type="text" name="peptide_news_llm_summary_model" value="%s" class="regular-text" />',
esc_attr( $value )
);
echo '<p class="description">' . esc_html__( 'OpenRouter model ID for article summarization (e.g., google/gemini-2.0-flash-001, anthropic/claude-3.5-sonnet).', 'peptide-news' ) . '</p>';
}
public static function render_llm_max_articles_field(): void {
$value = get_option( 'peptide_news_llm_max_articles', 10 );
printf(
'<input type="number" name="peptide_news_llm_max_articles" value="%d" min="1" max="50" class="small-text" />',
absint( $value )
);
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>';
}
public static 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>';
}
public static 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>';
}
public static 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>';
}
}

View File

@@ -1,155 +0,0 @@
<?php
declare( strict_types=1 );
/**
* Plugin log viewer component.
*
* Renders the real-time plugin log viewer with filtering, pagination,
* and AJAX handlers. Extracted from the main settings page for modularity.
*
* @since 2.5.0
* @see Peptide_News_Admin_Settings_Page — Uses this for log viewer rendering
*/
class Peptide_News_Admin_Log_Viewer {
/**
* Render the plugin log viewer UI with filters, table, and pagination.
*
* Includes inline JavaScript for AJAX log loading, filtering, and clearing.
*
* @return void
*/
public static function render_log_viewer(): void {
echo '<h2>' . esc_html__( 'Plugin Log', 'peptide-news' ) . '</h2>';
echo '<p class="description">' . esc_html__( 'Recent plugin activity — fetches, AI processing, errors, and admin actions.', 'peptide-news' ) . '</p>';
// Filters row.
echo '<div style="margin:10px 0;display:flex;gap:8px;align-items:center;">';
echo '<select id="peptide-log-level">';
echo '<option value="">' . esc_html__( 'All Levels', 'peptide-news' ) . '</option>';
echo '<option value="info">Info</option>';
echo '<option value="warning">Warning</option>';
echo '<option value="error">Error</option>';
echo '<option value="debug">Debug</option>';
echo '</select>';
echo '<select id="peptide-log-context">';
echo '<option value="">' . esc_html__( 'All Contexts', 'peptide-news' ) . '</option>';
echo '<option value="fetch">Fetch</option>';
echo '<option value="llm">LLM / AI</option>';
echo '<option value="cron">Cron</option>';
echo '<option value="admin">Admin</option>';
echo '<option value="general">General</option>';
echo '</select>';
echo '<button type="button" id="peptide-log-refresh" class="button">' . esc_html__( 'Refresh', 'peptide-news' ) . '</button>';
echo '<button type="button" id="peptide-log-clear" class="button" style="color:#a00;">' . esc_html__( 'Clear Log', 'peptide-news' ) . '</button>';
echo '<span id="peptide-log-status" style="margin-left:8px;color:#666;"></span>';
echo '</div>';
// Log table.
echo '<div id="peptide-log-container" style="max-height:400px;overflow-y:auto;border:1px solid #c3c4c7;background:#fff;">';
echo '<table class="widefat striped" style="margin:0;">';
echo '<thead><tr>';
echo '<th style="width:140px;">' . esc_html__( 'Time', 'peptide-news' ) . '</th>';
echo '<th style="width:70px;">' . esc_html__( 'Level', 'peptide-news' ) . '</th>';
echo '<th style="width:70px;">' . esc_html__( 'Context', 'peptide-news' ) . '</th>';
echo '<th>' . esc_html__( 'Message', 'peptide-news' ) . '</th>';
echo '</tr></thead>';
echo '<tbody id="peptide-log-body"><tr><td colspan="4" style="text-align:center;color:#999;">' . esc_html__( 'Loading...', 'peptide-news' ) . '</td></tr></tbody>';
echo '</table>';
echo '</div>';
// Pagination.
echo '<div id="peptide-log-pagination" style="margin:8px 0;display:flex;gap:8px;align-items:center;"></div>';
self::render_log_viewer_script();
}
/**
* Render the inline JavaScript for log viewer functionality.
*
* @return void
*/
private static function render_log_viewer_script(): void {
?>
<script>
jQuery(document).ready(function($) {
var currentPage = 1;
function levelBadge(level) {
var colors = { info: '#2271b1', warning: '#dba617', error: '#d63638', debug: '#8c8f94' };
var bg = { info: '#f0f6fc', warning: '#fcf9e8', error: '#fcf0f1', debug: '#f0f0f1' };
return '<span style="display:inline-block;padding:2px 8px;border-radius:3px;font-size:12px;font-weight:500;color:' +
(colors[level] || '#666') + ';background:' + (bg[level] || '#f0f0f1') + ';">' + level + '</span>';
}
function loadLogs(page) {
currentPage = page || 1;
var $body = $('#peptide-log-body');
$body.html('<tr><td colspan="4" style="text-align:center;color:#999;">Loading...</td></tr>');
$.post(peptideNewsAdmin.ajax_url, {
action: 'peptide_news_get_logs',
nonce: peptideNewsAdmin.admin_nonce,
page: currentPage,
level: $('#peptide-log-level').val(),
context: $('#peptide-log-context').val()
}, function(response) {
if (!response.success) {
$body.html('<tr><td colspan="4" style="color:#d63638;">Error loading logs.</td></tr>');
return;
}
var rows = response.data.rows;
if (rows.length === 0) {
$body.html('<tr><td colspan="4" style="text-align:center;color:#999;">No log entries.</td></tr>');
$('#peptide-log-pagination').empty();
$('#peptide-log-status').text('0 entries');
return;
}
var html = '';
$.each(rows, function(i, row) {
html += '<tr>';
html += '<td style="font-size:12px;white-space:nowrap;">' + $('<span>').text(row.created_at).html() + '</td>';
html += '<td>' + levelBadge(row.level) + '</td>';
html += '<td style="font-size:12px;">' + $('<span>').text(row.context).html() + '</td>';
html += '<td style="font-size:13px;word-break:break-word;">' + $('<span>').text(row.message).html() + '</td>';
html += '</tr>';
});
$body.html(html);
// Pagination.
var totalPages = response.data.total_pages;
var pagHtml = '<span style="color:#666;font-size:12px;">Page ' + currentPage + ' of ' + totalPages + ' (' + response.data.total + ' entries)</span>';
if (currentPage > 1) {
pagHtml += ' <button type="button" class="button button-small peptide-log-page" data-page="' + (currentPage - 1) + '">&laquo; Prev</button>';
}
if (currentPage < totalPages) {
pagHtml += ' <button type="button" class="button button-small peptide-log-page" data-page="' + (currentPage + 1) + '">Next &raquo;</button>';
}
$('#peptide-log-pagination').html(pagHtml);
$('#peptide-log-status').text(response.data.total + ' entries');
});
}
// Bindings.
$('#peptide-log-refresh').on('click', function() { loadLogs(1); });
$('#peptide-log-level, #peptide-log-context').on('change', function() { loadLogs(1); });
$(document).on('click', '.peptide-log-page', function() { loadLogs($(this).data('page')); });
$('#peptide-log-clear').on('click', function() {
if (!confirm('<?php echo esc_js( __( 'Clear all log entries?', 'peptide-news' ) ); ?>')) return;
$.post(peptideNewsAdmin.ajax_url, {
action: 'peptide_news_clear_logs',
nonce: peptideNewsAdmin.admin_nonce
}, function(response) {
if (response.success) loadLogs(1);
});
});
// Initial load.
loadLogs(1);
});
</script>
<?php
}
}

View File

@@ -1,98 +0,0 @@
<?php
declare( strict_types=1 );
/**
* Admin menu registration.
*
* Registers the main Peptide News admin menu and subpages
* (Dashboard, Settings, Articles, Cost Tracking).
*
* Callbacks are provided externally (via the main Admin class).
*
* @since 2.5.0
* @see Peptide_News_Admin — Main admin orchestrator
*/
class Peptide_News_Admin_Menu {
/** @var callable Dashboard page renderer */
private $dashboard_callback;
/** @var callable Settings page renderer */
private $settings_callback;
/** @var callable Articles page renderer */
private $articles_callback;
/** @var callable Cost dashboard page renderer */
private $cost_callback;
/**
* Constructor.
*
* @param callable $dashboard_callback Renderer for dashboard page.
* @param callable $settings_callback Renderer for settings page.
* @param callable $articles_callback Renderer for articles page.
* @param callable $cost_callback Renderer for cost dashboard page.
*/
public function __construct(
callable $dashboard_callback,
callable $settings_callback,
callable $articles_callback,
callable $cost_callback
) {
$this->dashboard_callback = $dashboard_callback;
$this->settings_callback = $settings_callback;
$this->articles_callback = $articles_callback;
$this->cost_callback = $cost_callback;
}
/**
* Register all admin menu pages.
*
* Creates the main menu (Analytics Dashboard) and three submenus
* (Settings, Articles, Cost Dashboard).
*
* @return void
*/
public function add_admin_menu(): void {
// Main menu — Analytics Dashboard.
add_menu_page(
__( 'Peptide News', 'peptide-news' ),
__( 'Peptide News', 'peptide-news' ),
'manage_options',
'peptide-news-dashboard',
$this->dashboard_callback,
'dashicons-rss',
30
);
// Sub-menu — Settings.
add_submenu_page(
'peptide-news-dashboard',
__( 'Settings', 'peptide-news' ),
__( 'Settings', 'peptide-news' ),
'manage_options',
'peptide-news-settings',
$this->settings_callback
);
// Sub-menu — Articles.
add_submenu_page(
'peptide-news-dashboard',
__( 'Articles', 'peptide-news' ),
__( 'Articles', 'peptide-news' ),
'manage_options',
'peptide-news-articles',
$this->articles_callback
);
// Sub-menu — Cost Dashboard.
add_submenu_page(
'peptide-news-dashboard',
__( 'LLM Costs', 'peptide-news' ),
__( 'LLM Costs', 'peptide-news' ),
'manage_options',
'peptide-news-costs',
$this->cost_callback
);
}
}

View File

@@ -1,214 +0,0 @@
<?php
declare( strict_types=1 );
/**
* Settings page rendering.
*
* Renders the main settings form with:
* - Manual fetch/summary generation buttons with AJAX handlers
* - Settings form (sections/fields via WordPress)
* - Plugin usage documentation
* - Real-time plugin log viewer with filtering and pagination
*
* @since 2.5.0
* @see Peptide_News_Admin — Main admin orchestrator
* @see Peptide_News_Admin_Settings — Field renderers
*/
class Peptide_News_Admin_Settings_Page {
/** @var Peptide_News_Admin_Settings Settings instance for field rendering */
private $settings;
/**
* Constructor.
*
* @param Peptide_News_Admin_Settings $settings Settings instance.
*/
public function __construct( Peptide_News_Admin_Settings $settings ) {
$this->settings = $settings;
}
/**
* Render the settings page with form, buttons, and log viewer.
*
* @return void
*/
public function render_settings_page(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
// Handle manual fetch trigger.
if ( isset( $_POST['peptide_news_fetch_now'] ) ) {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Unauthorized', 'peptide-news' ), 403 );
}
check_admin_referer( 'peptide_news_fetch_now_action' );
$fetcher = new Peptide_News_Fetcher();
$fetcher->fetch_all_sources();
echo '<div class="notice notice-success"><p>' . esc_html__( 'Fetch completed!', 'peptide-news' ) . '</p></div>';
}
echo '<div class="wrap">';
echo '<h1>' . esc_html( get_admin_page_title() ) . '</h1>';
// Fetch Now button.
echo '<form method="post" style="display:inline-block;margin-right:12px;">';
wp_nonce_field( 'peptide_news_fetch_now_action' );
submit_button( __( 'Fetch Articles Now', 'peptide-news' ), 'secondary', 'peptide_news_fetch_now', false );
echo '</form>';
// Backfill Sources button (fixes "news.google.com" source labels).
echo '<button type="button" id="peptide-backfill-sources" class="button button-secondary">';
echo esc_html__( 'Fix Article Sources', 'peptide-news' );
echo '</button>';
echo '<span id="peptide-backfill-result" style="margin-left:10px;"></span>';
// Generate AI Summaries button.
echo '<br style="margin-bottom:8px;">';
echo '<button type="button" id="peptide-generate-summaries" class="button button-primary" style="margin-top:8px;">';
echo esc_html__( 'Generate AI Summaries', 'peptide-news' );
echo '</button>';
echo '<span id="peptide-summary-result" style="margin-left:10px;"></span>';
?>
<script>
(function($) {
$('#peptide-backfill-sources').on('click', function() {
var $btn = $(this), $result = $('#peptide-backfill-result');
$btn.prop('disabled', true).text('<?php echo esc_js( __( 'Fixing...', 'peptide-news' ) ); ?>');
$result.text('');
$.post(peptideNewsAdmin.ajax_url, {
action: 'peptide_news_backfill_sources',
nonce: peptideNewsAdmin.admin_nonce
}, function(response) {
$btn.prop('disabled', false).text('<?php echo esc_js( __( 'Fix Article Sources', 'peptide-news' ) ); ?>');
if (response.success) {
$result.text(response.data.updated + ' article(s) updated.');
} else {
$result.text('Error: ' + (response.data || 'Unknown error'));
}
}).fail(function() {
$btn.prop('disabled', false).text('<?php echo esc_js( __( 'Fix Article Sources', 'peptide-news' ) ); ?>');
$result.text('Request failed.');
});
});
// Generate AI Summaries — loops in batches until all articles are done.
// Uses a 3-second delay between batches to respect free-tier rate limits,
// and retries up to 3 times when a batch fails before giving up.
$('#peptide-generate-summaries').on('click', function() {
var $btn = $(this), $result = $('#peptide-summary-result');
var totalProcessed = 0;
var retries = 0;
var maxRetries = 3;
$btn.prop('disabled', true);
$result.text('<?php echo esc_js( __( 'Starting...', 'peptide-news' ) ); ?>');
function runBatch() {
$btn.text('<?php echo esc_js( __( 'Generating...', 'peptide-news' ) ); ?>');
$.post(peptideNewsAdmin.ajax_url, {
action: 'peptide_news_generate_summaries',
nonce: peptideNewsAdmin.admin_nonce
}, function(response) {
if (response.success) {
totalProcessed += response.data.processed;
var remaining = response.data.remaining;
$result.text(totalProcessed + ' summarized, ' + remaining + ' remaining...');
if (remaining > 0 && response.data.processed > 0) {
// Success — reset retry counter and continue after a delay
// to respect the free-tier rate limit (20 req/min).
retries = 0;
setTimeout(runBatch, 3000);
} else if (remaining > 0 && retries < maxRetries) {
// No progress — likely rate limited. Wait longer and retry.
retries++;
$result.text(totalProcessed + ' summarized, ' + remaining + ' remaining... (rate limited, retrying in 10s, attempt ' + retries + '/' + maxRetries + ')');
setTimeout(runBatch, 10000);
} else {
// Done — either all processed or exhausted retries.
$btn.prop('disabled', false).text('<?php echo esc_js( __( 'Generate AI Summaries', 'peptide-news' ) ); ?>');
if (remaining === 0) {
$result.text('Done! ' + totalProcessed + ' article(s) summarized.');
} else {
var errMsg = totalProcessed + ' summarized. ' + remaining + ' could not be processed.';
if (response.data.errors && response.data.errors.length > 0) {
errMsg += ' Error: ' + response.data.errors[0];
}
$result.text(errMsg);
}
}
} else {
$btn.prop('disabled', false).text('<?php echo esc_js( __( 'Generate AI Summaries', 'peptide-news' ) ); ?>');
$result.text('Error: ' + (response.data || 'Unknown error'));
}
}).fail(function() {
if (retries < maxRetries) {
retries++;
$result.text(totalProcessed + ' completed. Request failed, retrying in 10s... (attempt ' + retries + '/' + maxRetries + ')');
setTimeout(runBatch, 10000);
} else {
$btn.prop('disabled', false).text('<?php echo esc_js( __( 'Generate AI Summaries', 'peptide-news' ) ); ?>');
$result.text('Request failed. ' + totalProcessed + ' completed before failure.');
}
});
}
runBatch();
});
})(jQuery);
</script>
<?php
// Last fetch info.
$last_fetch = get_option( 'peptide_news_last_fetch' );
if ( $last_fetch ) {
$extra_info = '';
if ( isset( $last_fetch['filtered_out'] ) && $last_fetch['filtered_out'] > 0 ) {
$extra_info .= sprintf(
' | %s: %d',
esc_html__( 'Filtered out', 'peptide-news' ),
$last_fetch['filtered_out']
);
}
if ( isset( $last_fetch['ai_processed'] ) ) {
$extra_info .= sprintf(
' | %s: %d',
esc_html__( 'AI analyzed', 'peptide-news' ),
$last_fetch['ai_processed']
);
}
printf(
'<p class="description">%s: %s | %s: %d | %s: %d%s</p>',
esc_html__( 'Last fetch', 'peptide-news' ),
esc_html( $last_fetch['time'] ),
esc_html__( 'Found', 'peptide-news' ),
intval( $last_fetch['found'] ),
esc_html__( 'New stored', 'peptide-news' ),
intval( $last_fetch['new_stored'] ),
esc_html( $extra_info )
);
}
// Settings form.
echo '<form method="post" action="options.php">';
settings_fields( 'peptide_news_settings_group' );
do_settings_sections( 'peptide-news-settings' );
submit_button();
echo '</form>';
// Shortcode usage info.
echo '<h2>' . esc_html__( 'Usage', 'peptide-news' ) . '</h2>';
echo '<p><strong>' . esc_html__( 'Shortcode:', 'peptide-news' ) . '</strong> <code>[peptide_news]</code> ' .
esc_html__( 'or', 'peptide-news' ) . ' <code>[peptide_news count="5"]</code></p>';
echo '<p><strong>' . esc_html__( 'Widget:', 'peptide-news' ) . '</strong> ' .
esc_html__( 'Add "Peptide News" from Appearance > Widgets.', 'peptide-news' ) . '</p>';
// ── Plugin Log Viewer ──────────────────────────────────────────
Peptide_News_Admin_Log_Viewer::render_log_viewer();
echo '</div>';
}
}

View File

@@ -1,289 +0,0 @@
<?php
declare( strict_types=1 );
/**
* Settings registration and sanitization.
*
* Registers all WordPress settings (7 sections, 25+ fields) and provides
* sanitization callbacks. Field rendering is delegated to
* Peptide_News_Admin_Field_Renderers for modularity.
*
* @since 2.5.0
* @see Peptide_News_Admin_Field_Renderers — For field rendering
* @see Peptide_News_Encryption — For API key encryption
* @see Peptide_News_Cost_Tracker — For budget status in section header
*/
class Peptide_News_Admin_Settings {
/**
* Register all WordPress settings and sections.
*
* @return void
*/
public function register_settings(): void {
// --- General section ---
add_settings_section(
'peptide_news_general',
__( 'General Settings', 'peptide-news' ),
function () {
echo '<p>' . esc_html__( 'Configure how and when peptide news articles are fetched.', 'peptide-news' ) . '</p>';
},
'peptide-news-settings'
);
$this->add_setting( 'fetch_interval', __( 'Fetch Interval', 'peptide-news' ), 'render_fetch_interval_field', 'peptide_news_general' );
$this->add_setting( 'articles_count', __( 'Articles to Display', 'peptide-news' ), 'render_articles_count_field', 'peptide_news_general' );
$this->add_setting( 'search_keywords', __( 'Search Keywords', 'peptide-news' ), 'render_search_keywords_field', 'peptide_news_general' );
// --- RSS section ---
add_settings_section(
'peptide_news_rss',
__( 'RSS Feed Sources', 'peptide-news' ),
function () {
echo '<p>' . esc_html__( 'Add RSS feed URLs, one per line.', 'peptide-news' ) . '</p>';
},
'peptide-news-settings'
);
$this->add_setting( 'rss_enabled', __( 'Enable RSS Feeds', 'peptide-news' ), 'render_rss_enabled_field', 'peptide_news_rss' );
$this->add_setting( 'rss_feeds', __( 'RSS Feed URLs', 'peptide-news' ), 'render_rss_feeds_field', 'peptide_news_rss' );
// --- NewsAPI section ---
add_settings_section(
'peptide_news_newsapi',
__( 'NewsAPI.org', 'peptide-news' ),
function () {
echo '<p>' . esc_html__( 'Optional. Get a free API key at newsapi.org.', 'peptide-news' ) . '</p>';
},
'peptide-news-settings'
);
$this->add_setting( 'newsapi_enabled', __( 'Enable NewsAPI', 'peptide-news' ), 'render_newsapi_enabled_field', 'peptide_news_newsapi' );
$this->add_setting( 'newsapi_key', __( 'API Key', 'peptide-news' ), 'render_newsapi_key_field', 'peptide_news_newsapi' );
// --- Analytics section ---
add_settings_section(
'peptide_news_analytics_section',
__( 'Analytics', 'peptide-news' ),
function () {
echo '<p>' . esc_html__( 'Configure click tracking and data retention.', 'peptide-news' ) . '</p>';
},
'peptide-news-settings'
);
$this->add_setting( 'article_retention', __( 'Article Retention (days)', 'peptide-news' ), 'render_article_retention_field', 'peptide_news_analytics_section' );
$this->add_setting( 'analytics_retention', __( 'Analytics Data Retention (days)', 'peptide-news' ), 'render_retention_field', 'peptide_news_analytics_section' );
$this->add_setting( 'anonymize_ip', __( 'Anonymize IPs', 'peptide-news' ), 'render_anonymize_ip_field', 'peptide_news_analytics_section' );
// --- Content Filter section ---
add_settings_section(
'peptide_news_filter_section',
__( 'Content Filter', 'peptide-news' ),
function () {
echo '<p>' . esc_html__( 'Filter out ads, press releases, and promotional content during fetch. Articles that match the rules are discarded before being saved.', 'peptide-news' ) . '</p>';
$last_run = get_option( 'peptide_news_filter_last_run' );
if ( $last_run && is_array( $last_run ) ) {
printf(
'<p class="description"><strong>%s:</strong> %s | %s: %d | %s: %d | %s: %d | %s: %d</p>',
esc_html__( 'Last filter run', 'peptide-news' ),
esc_html( $last_run['time'] ),
esc_html__( 'Evaluated', 'peptide-news' ),
absint( $last_run['total'] ),
esc_html__( 'Removed', 'peptide-news' ),
absint( $last_run['removed'] ),
esc_html__( 'LLM checked', 'peptide-news' ),
absint( $last_run['llm_checked'] ),
esc_html__( 'Passed', 'peptide-news' ),
absint( $last_run['passed'] )
);
}
},
'peptide-news-settings'
);
$this->add_setting( 'filter_enabled', __( 'Enable Content Filter', 'peptide-news' ), 'render_filter_enabled_field', 'peptide_news_filter_section' );
$this->add_setting( 'filter_sensitivity', __( 'Filter Sensitivity', 'peptide-news' ), 'render_filter_sensitivity_field', 'peptide_news_filter_section' );
$this->add_setting( 'filter_llm_enabled', __( 'LLM Classification', 'peptide-news' ), 'render_filter_llm_enabled_field', 'peptide_news_filter_section' );
$this->add_setting( 'filter_llm_model', __( 'Filter LLM Model', 'peptide-news' ), 'render_filter_llm_model_field', 'peptide_news_filter_section' );
$this->add_setting( 'filter_title_keywords', __( 'Title Keywords', 'peptide-news' ), 'render_filter_title_keywords_field', 'peptide_news_filter_section' );
$this->add_setting( 'filter_body_keywords', __( 'Body Keywords', 'peptide-news' ), 'render_filter_body_keywords_field', 'peptide_news_filter_section' );
$this->add_setting( 'filter_blocked_domains', __( 'Blocked Domains', 'peptide-news' ), 'render_filter_blocked_domains_field', 'peptide_news_filter_section' );
// --- AI / LLM section ---
add_settings_section(
'peptide_news_llm_section',
__( 'AI Analysis (OpenRouter)', 'peptide-news' ),
function () {
echo '<p>' . esc_html__( 'Configure LLM-powered keyword extraction and article summarization via OpenRouter.', 'peptide-news' ) . '</p>';
},
'peptide-news-settings'
);
$this->add_setting( 'llm_enabled', __( 'Enable AI Analysis', 'peptide-news' ), 'render_llm_enabled_field', 'peptide_news_llm_section' );
$this->add_setting( 'openrouter_api_key', __( 'OpenRouter API Key', 'peptide-news' ), 'render_openrouter_key_field', 'peptide_news_llm_section' );
$this->add_setting( 'llm_keywords_model', __( 'Keywords Model', 'peptide-news' ), 'render_llm_keywords_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' );
// --- 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.
*
* @param string $key Setting key (without prefix).
* @param string $label Field label.
* @param string $callback Render method name in Peptide_News_Admin_Field_Renderers.
* @param string $section Settings section ID.
* @return void
*/
private function add_setting( string $key, string $label, string $callback, string $section ): void {
$option_name = "peptide_news_{$key}";
$sanitize_cb = $this->get_sanitize_callback( $key );
register_setting( 'peptide_news_settings_group', $option_name, array(
'sanitize_callback' => $sanitize_cb,
) );
add_settings_field(
$option_name,
$label,
array( 'Peptide_News_Admin_Field_Renderers', $callback ),
'peptide-news-settings',
$section
);
}
/**
* Return the appropriate sanitize callback for a given setting key.
*
* @param string $key Setting key (without prefix).
* @return callable
*/
private function get_sanitize_callback( string $key ) {
$callbacks = array(
'fetch_interval' => 'sanitize_text_field',
'articles_count' => 'absint',
'search_keywords' => 'sanitize_text_field',
'rss_enabled' => 'absint',
'rss_feeds' => 'sanitize_textarea_field',
'newsapi_enabled' => 'absint',
'newsapi_key' => function ( $value ) {
return $this->sanitize_api_key( $value, 'peptide_news_newsapi_key' );
},
'article_retention' => 'absint',
'analytics_retention' => 'absint',
'anonymize_ip' => 'absint',
'filter_enabled' => 'absint',
'filter_sensitivity' => 'sanitize_text_field',
'filter_llm_enabled' => 'absint',
'filter_llm_model' => array( $this, 'sanitize_model_id' ),
'filter_title_keywords' => 'sanitize_textarea_field',
'filter_body_keywords' => 'sanitize_textarea_field',
'filter_blocked_domains' => 'sanitize_textarea_field',
'llm_enabled' => 'absint',
'openrouter_api_key' => function ( $value ) {
return $this->sanitize_api_key( $value, 'peptide_news_openrouter_api_key' );
},
'llm_keywords_model' => array( $this, 'sanitize_model_id' ),
'llm_summary_model' => array( $this, 'sanitize_model_id' ),
'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';
}
/**
* Sanitize and validate an OpenRouter model ID.
*
* @param string $value Raw input.
* @return string Sanitized model ID or default.
*/
public function sanitize_model_id( string $value ): string {
$value = sanitize_text_field( $value );
if ( ! empty( $value ) && class_exists( 'Peptide_News_LLM' ) && ! Peptide_News_LLM::is_valid_model( $value ) ) {
add_settings_error(
'peptide_news_settings_group',
'invalid_model',
sprintf(
/* translators: %s: model ID */
__( 'Invalid model ID format: %s. Expected format: provider/model-name', 'peptide-news' ),
esc_html( $value )
),
'error'
);
return 'google/gemini-2.0-flash-001';
}
return $value;
}
/**
* Sanitize and encrypt API keys before storage.
*
* If a masked key is submitted (••••••••XXXX format), the 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( string $value, string $option_name ): string {
$value = sanitize_text_field( $value );
if ( empty( $value ) ) {
return '';
}
// If masked format detected (starts with bullets), keep existing stored value.
if ( mb_strpos( $value, '••••••' ) === 0 ) {
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;
}
}

View File

@@ -44,13 +44,6 @@ class Peptide_News {
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-cost-tracker.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'admin/class-pn-admin-assets.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'admin/class-pn-admin-menu.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'admin/class-pn-admin-field-renderers.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'admin/class-pn-admin-settings.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'admin/class-pn-admin-log-viewer.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'admin/class-pn-admin-settings-page.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'admin/class-pn-admin-dashboard-pages.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';

View File

@@ -300,55 +300,6 @@ if ( ! function_exists( 'selected' ) ) {
return $result;
}
}
if ( ! function_exists( 'checked' ) ) {
function checked( $checked, $current = true, $echo = true ) {
$result = (string) $checked === (string) $current ? ' checked="checked"' : '';
if ( $echo ) {
echo $result;
}
return $result;
}
}
if ( ! function_exists( 'wp_enqueue_style' ) ) {
function wp_enqueue_style( $handle, $src = '', $deps = array(), $ver = false, $media = 'all' ) {
return true;
}
}
if ( ! function_exists( 'wp_enqueue_script' ) ) {
function wp_enqueue_script( $handle, $src = '', $deps = array(), $ver = false, $args = array() ) {
return true;
}
}
if ( ! function_exists( 'wp_localize_script' ) ) {
function wp_localize_script( $handle, $object_name, $l10n ) {
return true;
}
}
if ( ! function_exists( 'add_menu_page' ) ) {
function add_menu_page( $page_title, $menu_title, $capability, $menu_slug, $callback = '', $icon_url = '', $position = null ) {
return $menu_slug;
}
}
if ( ! function_exists( 'add_submenu_page' ) ) {
function add_submenu_page( $parent_slug, $page_title, $menu_title, $capability, $menu_slug, $callback = '', $position = null ) {
return $menu_slug;
}
}
if ( ! function_exists( 'register_setting' ) ) {
function register_setting( $option_group, $option_name, $args = array() ) {
return true;
}
}
if ( ! function_exists( 'add_settings_section' ) ) {
function add_settings_section( $id, $title, $callback, $page, $args = array() ) {
return true;
}
}
if ( ! function_exists( 'add_settings_field' ) ) {
function add_settings_field( $id, $title, $callback, $page, $section = 'default', $args = array() ) {
return true;
}
}
// Cost tracker provides calculate_cost(), budget checks used by tests.
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-cost-tracker.php';